资讯动态

rrweb原始数据转视频:Puppeteer截帧与ffmpeg合成实践

发布时间:2026/10/7 5:06:53 来源:尧图企业网站定制
简介这是一份面向JavaScript开发者的实用工具包用于将 rrweb 录制的 JSON 回放数据转换为视频文件由于回放时需加载原始页面中的图片、样式等静态资源而项目迭代会造成资源哈希变更或失效直接转码为视频即可实现永久保存与离线查看。压缩包共包含 11 个文件其中 6 个 JavaScript 文件负责核心逻辑、配置与示例入口另有 JSON 文件承载依赖清单HTML 文件提供回放演示页面Markdown 文档介绍使用方法与贡献流程。整个资源包整体仅 47KB结构紧凑目前已有两千五百七十四人学习或下载适合需要长期存档网页操作录屏或进行自动化回放验证的开发者。开发者通过项目内测试脚本即可快速体验转换流程也欢迎以 Issue 或合并请求方式参与完善与后续维护。1. 为什么非要把 rrweb 原始数据转成视频把 rrweb 录下来的原始数据原样扔给同事对方通常一脸懵一堆 JSON 记录的是 DOM 变更、鼠标坐标和 input 事件不是现成视频。而审计、客服、产品评审要的是能直接双击打开、能转发、能进工单附件的 MP4。rrweb-to-video 正是干这个的把 rrweb 原始数据里的事件流按时间轴重新「演」出来逐帧截屏再合成视频文件。适合的场景包括长会话归档、跨端分发、合规留痕以及一切无法依赖在线回放播放器的场合。下面按我落地的思路讲清楚渲染选型、最小实现、四个必调参数和五个会翻车的点。2. 从原始数据到视频帧回放引擎与渲染管线的四个选型2.1 rrweb 的原始数据不是视频是三条事件流rrweb 之所以比录屏轻量是因为它不记录像素而是记录浏览器内部的变化。每次用户输入、滚动、鼠标移动、DOM 节点增删都会产生一条 JSON 事件。一个半小时的会话原始数据可能只有几十 MB但换成同长度的录屏视频早就是 GB 级了。这些原始数据按 type 字段区分0 是 DOMContentLoaded1 是 FullSnapshot整页快照2 是 IncrementalSnapshot增量更新3 是 Meta视口宽高、用户代理等元信息4 是自定义事件5 是插件事件。整个会话在时间轴上的重建逻辑其实就建立在 1 和 2 的交替上1 负责把页面还原到初始状态2 负责把后续改动一点点叠加回去。理解这层结构对转视频很重要。第一FullSnapshot 不是视频帧它只是一份序列化后的 DOM 树必须由浏览器解析执行后才会有实际画面第二事件时间戳并不均匀用户可能三分钟不动也可能一秒内产生上百条鼠标事件。直接按 JSON 行数去匹配帧出来的视频节奏会完全失真。2.2 为什么选无头浏览器截帧对比三种实现路线把原始数据转成视频有三条常见路线。第一条是用 Canvas 自己画进度条、鼠标轨迹和页面缩略图这条路最省资源但信息丢失严重你只能画出一个「示意图」没法还原 input 框的真实值、下拉菜单的展开状态、弹窗的层级关系。第二条是用 Playwright 或 Puppeteer 打开一个真实的回放页面播放 rrweb 事件流同时用浏览器自带的录屏接口录制 WebM。这条路线能拿到完整画面但录制期间 CPU 占用极高且视频编码参数被浏览器锁死输出格式不好控制。我一般选第三条无头浏览器加载 rrweb-player把事件流「演」出来再用page.screenshot()按固定间隔截 PNG 帧最后交给 ffmpeg 合成 MP4。这样做的核心原因有两个。一是 rrweb-player 本身已经把事件回放、时间轴、样式还原做完了我不用自己实现任何 DOM diff 逻辑二是截图阶段和编码阶段解耦帧率、分辨率、码率全部由我控制遇到哪一帧渲染异常还能单独重截相当于给整个流程留了后悔药。2.3 渲染管线四段式解析、注入、截帧、合成我落地 rrweb-to-video 时把流程拆成四个独立阶段每段都可以单独跑、单独验证。阶段输入输出常见坑解析原始数据session.json清洗后的 events 数组空事件、时间戳倒挂、缺失 FullSnapshot注入并初始化events、视口尺寸就绪的回放播放器事件量大时注入超时、播放器未就绪截帧回放画面PNG 序列截图耗时超过帧间隔导致丢帧合成PNG 序列MP4 文件编码参数不对导致播放器不兼容解析阶段要做三件事把 JSON 文件读进来、过滤 type 为 null 的脏数据、确认第一个 FullSnapshot 存在。注入阶段要注意 meta 事件里的 width 和 height这决定了无头浏览器的视口应该设多大。截帧阶段是整个流程的瓶颈后面会重点讲参数。合成阶段反而最简单ffmpeg 一条命令就能完成但参数选错会出现花屏或无法播放我把它放到第四章。3. 用 Puppeteer 把 rrweb 原始数据跑成视频最小可复制骨架3.1 准备回放页面加载 rrweb-player 并暴露控制句柄先把 rrweb-player 的 JS 和 CSS 构建产物放到本地目录不要走 CDN因为内网环境和离线环境经常拿不到外部资源。回放页面只做一件事监听一个全局注入函数在 puppeteer 把 events 传进来之后再初始化播放器。!doctype html html head meta charsetutf-8 link relstylesheet href./rrweb-player.css style html, body { margin: 0; background: #000; overflow: hidden; } /style /head body div idplayer/div script src./rrweb-player.js/script script // 用全局变量承载事件数据和播放器实例方便 puppeteer 从外部注入 window.__RRWEB_EVENTS__ []; window.__PLAYER_READY__ false; window.__PLAYER__ null; window.__initReplayer__ function (speed) { const target document.getElementById(player); window.__PLAYER__ new rrwebPlayer({ target, props: { events: window.__RRWEB_EVENTS__, speed: speed || 1, autoPlay: false, showController: false } }); window.__PLAYER_READY__ true; }; /script /body /html这里把showController设为 false 是必须的否则视频右下角会多出一个播放控制条后期去不掉。autoPlay设成 false是为了让 puppeteer 完全掌控开始时机避免页面还没准备好时播放器已经开始消耗事件流。把window.__initReplayer__挂到全局是为了在注入完 events 之后再创建播放器而不是在页面加载时就拿空数组初始化。3.2 注入原始数据并启动播放Node.js 端的核心逻辑是读取 JSON、解析 events、设置视口、注入数据、初始化播放器。这里有一个关键点events 数组可能很大几万条事件序列化后也许有几十 MB不要试图塞进 URL query 参数要用page.evaluate传参。// render.js —— 运行前先安装 puppeteer const fs require(fs); const path require(path); const puppeteer require(puppeteer); const SESSION_FILE process.argv[2] || session.json; const OUT_MP4 process.argv[3] || output.mp4; const FPS Number(process.env.FPS || 5); const SPEED Number(process.env.SPEED || 2); async function main() { const events JSON.parse(fs.readFileSync(SESSION_FILE, utf8)); // 取当前会话的时间跨度单位毫秒 const totalMs events[events.length - 1].timestamp - events[0].timestamp; // 从 meta 事件里取原始视口尺寸避免画面被截断 const meta events.find(e e.type 3); const viewport meta ? { width: meta.data.width, height: meta.data.height } : { width: 1280, height: 720 }; const browser await puppeteer.launch({ headless: true, args: [--no-sandbox, --disable-dev-shm-usage] }); const page await browser.newPage(); await page.setViewport({ ...viewport, deviceScaleFactor: 1 }); await page.goto(file:// path.resolve(__dirname, replayer.html)); // 第一步把原始数据注入页面全局变量 await page.evaluate((evts) { window.__RRWEB_EVENTS__ evts; }, events); // 第二步初始化播放器传入倍速 await page.evaluate((speed) { window.__initReplayer__(speed); }, SPEED); // 第三步等待播放器真正就绪 await page.waitForFunction(window.__PLAYER_READY__ true); // 开始播放 await page.evaluate(() { window.__PLAYER__.play(); }); // ...截帧逻辑在 3.3 节 } main().catch(err { console.error(err); process.exit(1); });--no-sandbox在容器环境里常要加上否则 Chromium 启动时会因为权限问题直接退出。--disable-dev-shm-usage是防止 /dev/shm 太小导致页面崩溃尤其是并发渲染时。deviceScaleFactor我固定成 1后面第四章会讲为什么不要随意调 2。等待播放器就绪这一步不能省__PLAYER_READY__是页面里自己置位的比固定 sleep 几秒要可靠得多。3.3 按时间轴截帧5fps 和固定间隔截帧是 rrweb-to-video 里最容易出问题的一步。表面上看是循环截图实际上要解决一个矛盾截图本身耗时而播放器一直在走时间轴。如果截图耗时超过帧间隔视频就会比预期短或者后半段全是重复帧。我的经验是先算总帧数再按固定间隔截图。// 接 3.2 节 const framesDir path.resolve(__dirname, frames); if (fs.existsSync(framesDir)) fs.rmSync(framesDir, { recursive: true }); fs.mkdirSync(framesDir); // 输出时长 事件跨度 / 倍速再乘帧率得到总帧数 const renderSeconds totalMs / 1000 / SPEED; const frameCount Math.round(renderSeconds * FPS); const intervalMs 1000 / FPS; // 5fps 时是 200ms for (let i 0; i frameCount; i) { const shotStart Date.now(); const seq String(i).padStart(6, 0); await page.screenshot({ path: ${framesDir}/frame-${seq}.png }); const cost Date.now() - shotStart; // 只有截图耗时小于间隔时才补等待保证每一帧之间的实际间隔均匀 if (cost intervalMs) { await new Promise(r setTimeout(r, intervalMs - cost)); } } await browser.close(); console.log(done: ${frameCount} frames);这段逻辑为什么不用播放器状态作为结束条件因为播放器在截图慢的时候已经播完了再等它的结束信号会导致后面全是重复帧。用预先算好的frameCount做循环上限总帧数是确定的视频时长也就确定。intervalMs是帧间隔截图耗时如果长期大于这个值就要降帧率或者换机器这个坑在第五章会专门展开。3.4 用 ffmpeg 把 PNG 序列合成 MP4PNG 序列准备好之后合成是标准操作。我这里把编码参数固定成一组兼容性最好的值方便后续在微信、浏览器、监控平台里直接播放。ffmpeg -y \ -framerate 5 \ -start_number 0 \ -i frames/frame-%06d.png \ -c:v libx264 \ -preset slow \ -crf 20 \ -pix_fmt yuv420p \ -profile:v main \ -movflags faststart \ output.mp4-framerate 5必须和截帧时的 FPS 一致否则视频时长会错。-pix_fmt yuv420p是兼容性关键浏览器打包的 PNG 是 RGB 像素格式不转成 yuv420p 的话很多播放器会显示黑屏或绿屏。-movflags faststart把元数据挪到文件头部网页里播放时进度条能更快拖动。-preset slow压得慢但画质好如果机器不行改成medium也可以不要用ultrafast压缩痕迹会非常明显。4. rrweb-to-video 的必调参数帧率、视口、speed 与编码三件套4.1 帧率5fps 够用15fps 反而翻车很多第一次做 rrweb 转视频的人第一反应是 30fps觉得这样才流畅。实际上 rrweb 的原始数据里鼠标轨迹是最频繁的视觉变化而大部分 DOM 操作在两次 mutation 之间是静止的。30fps 意味着每帧之间只有约 33ms 的间隔高帧率不仅没有新增信息反而让截图压力成倍上升。我实践中测过一组数据在一台 2 核 4G 的容器里5fps 截图平均耗时约 120ms10fps 约 220ms15fps 会到 350ms 以上。截图耗时一旦超过帧间隔后面的帧全会错位视频看起来就是一顿一顿的这就是典型的翻车现场。帧率帧间隔截图耗时参考适用场景3fps333ms约 100ms归档留痕只看页面状态变化5fps200ms约 120ms默认选择鼠标轨迹可接受10fps100ms约 220ms需要较平滑鼠标轨迹时15fps66ms约 350ms不推荐容易翻车4.2 视口与 deviceScaleFactor清晰度、截断和体积怎么平衡视口尺寸应该直接从 events 里的 meta 事件读取而不是自己拍脑袋定 1280x720。因为 rrweb 录制时页面的实际宽高是多少回放时就必须是多少否则右侧内容会被截掉或者元素位置整体偏移。meta 里的 width 和 height 是录制时刻的视口大小照搬即可。deviceScaleFactor是一个容易让人纠结的参数。调成 2 确实能拿到更清晰的截图但代价是截图数据量变成原来的 4 倍截帧耗时也可能翻倍。对大多数客服质检、审计留痕场景来说1 倍足够因为视频本身是给人看的操作过程不是像素级还原。只有当你后续要做 OCR 识别视频里的界面文字时才值得用deviceScaleFactor: 2。4.3 speed 时间缩放事件稀疏的会话怎么正确换算时长speed 参数控制播放节奏它的本质是让回放器的虚拟时间走得更快。用户可能在一个输入框里停顿两分钟speed1 时这段停顿也会原样录进视频bulky 且无聊。把 speed 调到 2 或 4输出时长直接按比例缩短。换算公式很简单输出时长 最后一个事件时间戳 - 第一个事件时间戳/ 1000 / speed。帧数 输出时长 × FPS。这个公式必须刻在脑子里因为很多人在截帧循环里只算了 FPS忘了除以 speed结果视频时长变成预期的两倍。speed30 分钟会话的输出时长适用场景130 分钟培训录屏、需保留真实停顿节奏215 分钟客服质检默认47.5 分钟长会话快速复盘83.75 分钟不推荐交互太快看不清4.4 编码参数CRF、preset、pix_fmt 三件套编码参数最容易被看成玄学其实真正要关心的只有三个变量。-crf控制画质数值越小画质越高18 接近无损23 是默认平衡值28 文件小但会有可见压缩痕迹。-preset控制压缩速度和体积的权衡slow比fast压出更小的文件但耗时更长。-pix_fmt yuv420p则直接影响兼容性不带它很多 Windows 播放器、网页播放器直接黑屏。参数推荐值效果什么时候改crf20画质和体积平衡文件太大时改 23要求高时改 18presetslow画质好耗时略长机器慢时改 mediumpix_fmtyuv420p兼容全部播放器不要改profilemain兼容性足够特殊情况才改 high5. 转换翻车现场rrweb-to-video 五个高频坑与排查路径5.1 开头黑屏画面突然跳到中间现象输出的视频前两三秒是全黑然后画面突然跳到会话中段。原因播放器初始化需要时间FullSnapshot 事件还没被解析成 DOM 树截图就已经开始了。这个阶段截出来的帧就是空白页。跳到会话中段是因为黑屏阶段占掉的帧数被后续内容补上时间轴错位。解决在注入 events 之后、调用 play() 之前增加一个等待逻辑例如在页面里轮询document.querySelector(#player svg)确认回放容器里已经渲染出内容再开始播放。实践里还可以在初始化后固定等 500ms并且把开头几帧丢弃不用。5.2 canvas 和 iframe 区域拍出来是空白现象页面主体内容正常但凡是 canvas 绘图的区域视频里全是空白iframe 嵌入的第三方页面也是灰块。原因rrweb 录制时canvas 的内容默认不会被序列化除非录制端显式开启 canvas 录制。iframe 则受跨域限制同源 iframe 能被录制跨域 iframe 只能拿到一个占位块。转视频端是无能为力的因为原始数据里根本没有这部分内容。解决先回到录制端把 canvas 录制开启并接受文件体积变大。转视频端能做的只是把 canvas 区域和 iframe 区域的事件替换成一张带文字的背景图告诉看视频的人这里有一块动态内容没有完整还原避免误判为 bug。5.3 长会话内存暴涨截图越来越慢现象一个小时的会话截到第 20 分钟时单帧截图耗时从 120ms 涨到 800ms视频后半段全是重复帧。原因播放器持续回放导致 DOM 节点累积页面内存占用越来越高同时 PNG 文件直接写磁盘但 puppeteer 每次截图调用本身有序列化开销页面越复杂开销越大。解决最有效的办法是分片把一小时的事件流拆成 10 分钟的多个片段每个片段独立渲染成小 MP4最后用 ffmpeg concat 合并。分片时要注意每个片段开头都保留一个 FullSnapshot 事件否则切片无法独立重建页面。5.4 输出时长比预期短尾部卡住现象设定输出 30 分钟实际视频只有 22 分钟最后几秒的画面一直重复。原因截帧时机器性能跟不上截图实际耗时约 170ms而 10fps 的帧间隔是 100ms。每帧都超出间隔约 70ms导致最终帧数不足后面播放器已播完只能重复最后一帧。解决先在目标机器上做一次截图热测连续截 10 张取平均耗时。如果平均耗时接近或超过想设的帧间隔就把 FPS 降到 5或把 speed 调到 4让播放器走得更快。这是一个需要先测再定参数的场景不要拍脑袋。5.5 MP4 在部分播放器里打不开或显示绿屏现象ffmpeg 输出后在本地播放器正常但用网页播放器、微信内打开是黑屏或者画面偏色。原因PNG 序列是 RGB 色彩空间ffmpeg 合成时如果没指定-pix_fmt yuv420p输出文件的色彩采样是 yuv444 或 yuv422很多低配播放器不支持。另一个常见原因是没加-profile:v mainh264 的 high profile 在老设备上无法硬解。解决检查 ffprobe 输出里的pix_fmt是否为yuv420p编码 profile 是否为 Main。不一致就用第四章那条 ffmpeg 命令重新合成参数别精简。6. 进阶分片渲染长会话再用 ffprobe 验收输出6.1 按时间窗口切 events并行渲染后 concat长会话分片是唯一能稳定控制内存的方案。按 10 分钟一个窗口切分原始数据每个切片保留当前窗口开头最近的一个 FullSnapshot保证回放器能独立重建页面。function splitEventsByTime(events, windowMs 10 * 60 * 1000) { const start events[0].timestamp; const buckets []; for (const ev of events) { const idx Math.floor((ev.timestamp - start) / windowMs); (buckets[idx] buckets[idx] || []).push(ev); } return buckets.map(list list.filter(Boolean)); }每个切片输出一个临时 MP4编码参数保持完全一致最后用 concat 协议合并。for f in segments/seg-*.mp4; do echo file $f list.txt; done ffmpeg -y -f concat -safe 0 -i list.txt -c copy merged.mp4-c copy不会重新编码所以要求所有切片的分辨率、帧率、编码方式完全一致。如果某个切片因为截图超时导致分辨率变化合并时会直接报错这时需要回到该切片重新渲染。6.2 用 ffprobe 一条命令验收输出转完视频后不要直接看时长就完事。我习惯用 ffprobe 核对三个字段分辨率、帧率、时长外加确认 pix_fmt 是 yuv420p。ffprobe -v error \ -show_entries formatduration,size \ -show_entries streamcodec_name,width,height,r_frame_rate,pix_fmt \ -of defaultnoprint_wrappers1 output.mp4输出里如果r_frame_rate是5/1说明帧率设置成功如果出现25/1说明 ffmpeg 用了默认帧率源文件命名和-framerate参数一定有一个地方没对上。duration则要和第三章算出的renderSeconds对得上误差超过 5% 就要回查截帧循环。我最早搭这个转换流程时卡在帧率和截图耗时上折腾了三天最后发现先把本机截图耗时测一遍再定帧率比调一堆编码玄学都管用。这个习惯我一直留着任何新环境跑 rrweb-to-video先截 10 帧热一下再决定参数能少踩一半的坑。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑