ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

rrweb会话回放转MP4:无头浏览器逐帧渲染与ffmpeg编码实战

rrweb会话回放转MP4:无头浏览器逐帧渲染与ffmpeg编码实战 简介rrweb-to-video 是一套面向前端开发者的工具项目用于将 rrweb 录制的原始 JSON 数据转换为视频文件解决页面回放时静态资源 hash 变更或文件被删除导致回放失效的问题适合需要长期归档用户行为录制的团队与个人。资源包共 11 个文件以 6 个 JavaScript 源码文件为核心配合 2 个 JSON 配置、1 个 HTML 回放页面、1 个 Markdown 说明文档及 gitignore 等辅助文件压缩包约 47KB结构轻量、便于快速阅读与二次开发。项目依赖 FFmpeg 生成视频需先安装并配置环境变量随后可通过命令行运行测试脚本验证转换流程。目前已有 2573 人学习下载。读者可从中获取完整的转换实现思路、回放页面与打包配置参考以及可复用的脚本代码帮助把易失效的 rrweb 录制数据固化为可永久保存的视频降低因项目迭代造成的回放风险。1. rrweb-to-video把会话回放从 JSON 变成 MP4 的那条链路你可能遇到过这种场景线上用户反馈了一个 bug客服把 rrweb 录制的 session 数据导出来给你打开一看是几万行 JSONtype、timestamp、data嵌套得让人头皮发麻。你想把它转成一段视频发给产品经理看或者存档做用户行为分析结果发现 rrweb 官方只提供了rrweb-player在浏览器里回放没有一个现成的命令行工具能直接吐出 MP4。rrweb-to-video 要解决的就是这个问题把 rrweb 的原始事件流经过「重建 DOM 快照 → 逐帧渲染 → 编码封装」三步变成一段可以脱离浏览器播放的视频文件。它适合三类人做前端监控需要把异常会话存档的工程师、做用户行为分析需要批量处理 session 的数据团队、以及做自动化测试想把回放结果纳入 CI 产物的开发者。核心难点不在「录」而在「怎么把增量事件在无头环境里按时间轴还原成连续画面」。2. 拆解 rrweb 事件流全量快照与增量变更怎么还原成画面2.1 rrweb 的三种事件类型与时间轴模型rrweb 录制出来的数据本质上是一个按timestamp排序的事件数组每个事件有一个type字段。最常见的三种是type4的 Meta 事件记录视口宽高、href 等、type2的 FullSnapshot 事件整个 DOM 的序列化快照、type3的 IncrementalSnapshot 事件后续的 DOM 变更、鼠标移动、滚动、输入等。理解这一点是转换的前提FullSnapshot 只在录制开始时出现一次之后所有画面变化都靠 IncrementalSnapshot 里的mutation、mousemove、scroll、input等子类型叠加出来。时间轴模型是这样的每个事件都带timestamp毫秒转换器需要维护一个「虚拟时钟」从第一个事件的 timestamp 开始按真实时间间隔推进。比如两个事件相差 33ms就对应约 30fps 下的一帧。这里有个容易翻车的地方rrweb 的事件时间戳是录制端的本地时间如果录制跨了很长时间比如用户挂机两小时中间会有大段空白直接按时间戳渲染会生成大量重复帧需要在转换前做「空闲压缩」。// 读取 rrweb 事件流并按时间戳排序做基础校验 const fs require(fs); function loadEvents(path) { const raw JSON.parse(fs.readFileSync(path, utf-8)); // rrweb 导出格式可能是数组也可能包在 { events: [] } 里 const events Array.isArray(raw) ? raw : raw.events; if (!events || !events.length) throw new Error(事件流为空); // 按 timestamp 升序防止导出时顺序错乱 events.sort((a, b) a.timestamp - b.timestamp); const first events[0]; const last events[events.length - 1]; console.log(事件数: ${events.length}, 时长: ${(last.timestamp - first.timestamp) / 1000}s); return events; }上面这段代码做了三件事兼容两种导出格式、按时间戳排序、打印总时长用于预估视频长度。参数上要注意timestamp单位是毫秒如果你拿到的数据里是秒级需要先乘 1000否则渲染出来会快得看不清。常见做法是先用这个函数跑一遍确认事件数和时长符合预期再进入渲染环节。2.2 用 rrweb 的 Replayer 在无头浏览器里重建 DOM直接把 JSON 转成画面是不现实的必须借助 rrweb 自己的回放引擎。rrweb包导出的Replayer类可以在一个容器里按时间轴重放事件它内部处理了 DOM 重建、样式注入、鼠标轨迹绘制等细节。转换视频的思路是在 Puppeteer 或 Playwright 打开的无头页面里引入 rrweb创建 Replayer然后手动控制播放进度每推进一帧就截图。// 在无头页面中初始化 Replayer 并暴露逐帧控制接口 const puppeteer require(puppeteer); async function setupReplayer(events, width, height) { const browser await puppeteer.launch({ headless: new, args: [--window-size${width},${height}] }); const page await browser.newPage(); await page.setViewport({ width, height }); // 注入 rrweb 的 UMD 包实际路径以你安装的版本为准 await page.addScriptTag({ path: require.resolve(rrweb/dist/rrweb.umd.cjs) }); await page.evaluate((evts) { const { Replayer } window.rrweb; const replayer new Replayer(evts, { root: document.getElementById(stage), speed: 1, mouseTail: false, // 关掉鼠标拖尾减少每帧差异 skipInactive: false }); window.__replayer replayer; }, events); return { browser, page }; }这段代码的关键参数是mouseTail和skipInactive。mouseTail关掉后画面更干净截图差异小编码后体积也小skipInactive如果设为 truerrweb 会自动跳过无操作的时间段但这会让视频时间轴和真实时间轴不一致做行为分析时不要开。root指向一个固定尺寸的容器宽高要和录制时的视口一致否则 DOM 布局会错位。我一般会从 Meta 事件里读出原始宽高动态设置 viewport而不是写死 1920x1080。2.3 逐帧推进与截图时间精度和帧率怎么定Replayer 提供了play和pause但要做视频需要更细的控制。rrweb 的 Replayer 内部有一个getMetaData和playTo之类的接口不同版本 API 有差异稳妥的做法是利用requestAnimationFrame配合replayer.pause(timestamp)来定位到指定时刻。具体来说你按目标帧率算出每一帧对应的虚拟时间戳调用pause让 Replayer 把 DOM 状态更新到那个时刻然后截图。// 按目标帧率逐帧截图输出 PNG 序列 async function captureFrames(page, events, fps, outDir) { const start events[0].timestamp; const end events[events.length - 1].timestamp; const frameInterval 1000 / fps; let frameIndex 0; for (let t start; t end; t frameInterval) { await page.evaluate((ts) { // pause 到指定时间戳Replayer 会同步 DOM 到该时刻 window.__replayer.pause(ts); }, t); // 等待一帧渲染完成避免截到旧画面 await page.evaluate(() new Promise(r requestAnimationFrame(r))); await page.screenshot({ path: ${outDir}/frame_${String(frameIndex).padStart(6, 0)}.png }); frameIndex; } console.log(共生成 ${frameIndex} 帧); }帧率的选择是个权衡30fps 对大多数操作回放够用60fps 更流畅但帧数翻倍、编码时间也翻倍。如果原始事件里鼠标移动采样率很低比如 100ms 一个点强行 60fps 只会生成大量重复帧没有意义。我的经验是先用 25fps 跑一遍看效果如果鼠标轨迹明显卡顿再提到 30fps。截图格式用 PNG 保证无损后续交给 ffmpeg 编码不要直接截 JPEG否则文字边缘会有压缩伪影回放里的代码和表单内容会糊。3. 从 PNG 序列到 MP4ffmpeg 编码参数与音视频同步3.1 ffmpeg 命令行编码把帧序列压成 H.264拿到 PNG 序列后编码这一步反而是最成熟的。ffmpeg 支持直接读取按序号命名的图片序列用-framerate指定输入帧率输出 H.264 的 MP4。命令本身不复杂但参数选错会导致视频在某些播放器里打不开或者体积大得离谱。# 把 PNG 序列编码为 H.264 MP4yuv420p 保证兼容性 ffmpeg -y \ -framerate 25 \ -i frames/frame_%06d.png \ -c:v libx264 \ -preset medium \ -crf 23 \ -pix_fmt yuv420p \ -movflags faststart \ output.mp4逐参数说明-framerate 25必须和截图时的 fps 一致否则视频速度会不对-crf 23是质量档位数值越小越清晰、体积越大18 到 28 之间是常用范围回放类内容 23 足够-pix_fmt yuv420p是血泪经验不加这个参数很多播放器和浏览器解不出来因为默认可能是 yuv444p-movflags faststart把索引移到文件头方便边下边播。-preset medium是编码速度和压缩率的平衡追求速度可以改veryfast追求体积可以改slow。3.2 帧率、分辨率与体积的三角关系很多人第一次转出来发现视频几百 MB其实问题往往出在分辨率没对齐。如果录制视口是 1440x900截图就是 1440x900编码时保持原样即可不要盲目放大到 1080p那只会增加无意义的像素。体积的粗略估算公式是体积 ≈ 码率 × 时长而码率又受分辨率、帧率、内容复杂度影响。回放内容里大量静态画面H.264 的帧间压缩能压得很狠所以实际体积通常比按码率估算的小。参数常用值影响帧率25 / 30越高越流畅帧数和编码时间线性增长CRF23越小越清晰18 以下体积增长明显分辨率与录制视口一致放大不增加信息只增加体积presetmedium越慢压缩率越高veryfast 适合调试如果目标平台对体积有硬限制可以先用 CRF 28 跑一版看是否可接受再逐步降到 23。不要一上来就用 CRF 18那通常是给后期剪辑留余量的直接分发没必要。3.3 处理时间轴空洞与倍速播放需求rrweb 录制里经常有大段用户无操作的时间比如用户打开页面后去接了个电话回来继续操作。这段空白如果原样保留视频里就是几十秒静止画面。处理方式有两种一是在截图阶段用skipInactive让 rrweb 跳过但这样时间轴会压缩二是在编码阶段用 ffmpeg 的setpts滤镜做变速。前者适合只关心操作过程的场景后者适合需要保留真实时间比例但想加快播放的场景。# 对已有 MP4 做 2 倍速处理同时保持音视频同步如有音频 ffmpeg -y -i output.mp4 \ -filter:v setpts0.5*PTS \ -an \ output_2x.mp4setpts0.5*PTS表示时间戳乘以 0.5播放速度变为 2 倍。-an表示去掉音频轨因为纯回放视频通常没有音频如果原视频有音频需要同时用atempo2.0处理音频否则会不同步。这里有个坑如果先做倍速再编码和先编码再倍速画质损失是不一样的建议在 PNG 阶段就确定好最终帧率避免二次编码。4. 批量转换与工程化把脚本跑成可复用的流水线4.1 目录约定与任务队列设计单个 session 转换跑通后真正的需求往往是批量处理。我一般会约定一个目录结构input/放原始 JSONframes/放中间帧output/放成品 MP4每个 session 用它的 ID 建子目录。批量脚本读取input/下所有 JSON逐个走「加载 → 渲染 → 截图 → 编码 → 清理帧」的流程。清理帧这一步很重要否则磁盘很快被 PNG 撑满一个 30 秒的视频按 25fps 就是 750 张图批量跑几百个 session 就是几十万张。// 批量转换主流程串行执行避免无头浏览器资源竞争 const fs require(fs); const path require(path); async function batchConvert(inputDir, outputDir, fps) { const files fs.readdirSync(inputDir).filter(f f.endsWith(.json)); for (const file of files) { const id path.basename(file, .json); const frameDir path.join(outputDir, id, frames); fs.mkdirSync(frameDir, { recursive: true }); try { const events loadEvents(path.join(inputDir, file)); const { browser, page } await setupReplayer(events, 1440, 900); await captureFrames(page, events, fps, frameDir); await browser.close(); // 编码后删除帧目录释放磁盘 await encodeToMp4(frameDir, path.join(outputDir, ${id}.mp4), fps); fs.rmSync(frameDir, { recursive: true, force: true }); console.log(完成: ${id}); } catch (err) { console.error(失败: ${id}, err.message); } } }串行执行是有意为之无头浏览器很吃内存并行跑几个就容易 OOM而且截图是 IO 密集型操作并行反而互相拖慢。如果 session 数量很大可以用p-limit控制并发数为 2 到 3但不要超过 CPU 核心数。每个 session 用 try/catch 包住单个失败不影响整体失败记录单独写日志方便重跑。4.2 失败重试与中间产物管理批量跑的时候最常见的失败是某个 session 的 JSON 结构异常比如缺少 FullSnapshot导致 Replayer 初始化后画面空白。这种情况截图出来全是白屏编码后得到一个白视频。检测方法是在截图前先判断第一帧是否非空或者检查事件里是否存在type2的事件。如果没有 FullSnapshot直接标记为不可转换跳过。另一个坑是内存泄漏。Puppeteer 的 page 如果反复创建不关闭内存会持续上涨。我的做法是每个 session 处理完显式browser.close()并且在整个批量任务结束后检查是否有残留进程。中间产物除了帧目录还可以保留一份meta.json记录事件数、时长、帧数、编码参数方便后续排查「为什么这个视频特别大」之类的问题。4.3 用 Docker 固定运行环境rrweb 和 Puppeteer 的版本兼容性是个玄学问题不同版本之间 Replayer 的 API 可能不一样ffmpeg 的编码器支持也因系统而异。把整个流水线塞进 Docker 镜像能省掉大量「在我机器上能跑」的扯皮。基础镜像选带 Chromium 的 Node 镜像再装 ffmpeg把脚本和依赖一起打进去。FROM node:18-slim RUN apt-get update apt-get install -y ffmpeg chromium \ rm -rf /var/lib/apt/lists/* ENV PUPPETEER_EXECUTABLE_PATH/usr/bin/chromium WORKDIR /app COPY package*.json ./ RUN npm ci --omitdev COPY . . CMD [node, batch.js]关键是PUPPETEER_EXECUTABLE_PATH指向系统 Chromium避免 Puppeteer 自己去下载一个几百 MB 的浏览器。node:18-slim体积小但要注意它默认没有中文字体如果回放页面里有中文截图会显示成方块需要额外装fonts-noto-cjk。这个坑我踩过排查了半天才发现是字体缺失。5. 避坑与排查转换失败时先看这五个地方5.1 画面全白或只有背景色现象截图出来第一帧就是空白后续帧也没有内容。原因通常是事件流里缺少 FullSnapshottype2或者 FullSnapshot 的data.node结构被截断。解决在加载事件后先检查是否存在type2的事件没有就直接报错跳过如果有但画面仍空白检查 Replayer 的root容器尺寸是否为 0CSS 里给容器设固定宽高。5.2 视频速度明显偏快或偏慢现象生成的视频比实际录制时长短很多或长很多。原因一般是截图帧率和编码帧率不一致比如截图用了 25fpsffmpeg 命令里写了 30。解决把 fps 作为单一变量在脚本里传递截图和编码共用同一个值不要在两处分别写死。另外检查事件 timestamp 单位秒和毫秒混用会导致时间轴整体缩放。5.3 鼠标轨迹错位或点击位置偏移现象视频里鼠标移动的位置和实际点击的元素对不上。原因通常是录制时的视口尺寸和转换时的 viewport 不一致导致 DOM 布局变化元素位置偏移。解决从 Meta 事件里读取原始width和height用这个值设置 Puppeteer 的 viewport不要用默认值。如果原始页面有响应式布局还要确保转换时的设备像素比和录制时一致。5.4 编码后视频在部分播放器打不开现象本地用 VLC 能播发到网页或某些播放器里黑屏。原因基本是像素格式不是yuv420p或者没有加-movflags faststart。解决编码命令里固定加上-pix_fmt yuv420p这是兼容性最好的格式。如果还有问题检查 H.264 profile用-profile:v baseline或main能覆盖更多老设备。5.5 批量跑一半进程卡死现象处理到某个 session 时脚本不动了CPU 占用很低。原因可能是某个事件触发了 Replayer 的异常状态或者页面里有死循环的定时器。解决给每个 session 的处理加超时用Promise.race包一层超过 60 秒就强制关闭 browser 并标记失败。同时检查事件流里是否有异常大的 mutation 事件超大 DOM 变更会让 Replayer 渲染极慢。6. 进阶用 canvas 直出替代截图把转换速度提上来截图方案稳定但慢瓶颈在 Puppeteer 的page.screenshot每次都要走一遍渲染管线再编码 PNG。如果 session 量大可以换成 canvas 直出在页面里把 Replayer 的 DOM 渲染到一个离屏 canvas 上用html2canvas或者直接操作 canvas 的drawImage然后通过canvas.toDataURL或者page.evaluate拿到像素数据直接喂给 ffmpeg 的 stdin省掉 PNG 落盘和再读取的开销。// 通过 CDP 截取页面并直接推流给 ffmpeg减少磁盘 IO const { spawn } require(child_process); function createFfmpegPipe(outputPath, fps) { const ffmpeg spawn(ffmpeg, [ -y, -f, image2pipe, -framerate, String(fps), -i, -, -c:v, libx264, -preset, veryfast, -crf, 23, -pix_fmt, yuv420p, outputPath ]); return ffmpeg.stdin; } // 截图后直接写入 ffmpeg stdin不落盘 async function captureToPipe(page, events, fps, outputPath) { const stdin createFfmpegPipe(outputPath, fps); const start events[0].timestamp; const end events[events.length - 1].timestamp; const interval 1000 / fps; for (let t start; t end; t interval) { await page.evaluate(ts window.__replayer.pause(ts), t); await page.evaluate(() new Promise(r requestAnimationFrame(r))); const buf await page.screenshot({ type: png }); stdin.write(buf); } stdin.end(); }这个方案的关键是-f image2pipe和-i -让 ffmpeg 从标准输入读图片流。page.screenshot返回 Buffer直接 write 进去省掉了文件系统往返。实测在同样硬件上直出方案比落盘再编码快 30% 到 50%session 越多差距越明显。注意veryfastpreset 在这里更合适因为管道模式下 CPU 同时在做截图和编码用medium会让截图等待编码反而拖慢整体。验证转换质量有个简单办法抽三帧对比第一帧、中间帧、最后一帧分别和 rrweb-player 在浏览器里手动拖到对应时间点的截图做像素级对比差异应该在字体渲染的抗锯齿范围内。如果中间帧偏差大说明时间轴对齐有问题回去检查pause的时间戳计算。我自己的习惯是任何批量转换任务先拿 3 个 session 跑通全流程确认帧率、体积、画面对齐都没问题再放开跑全量。这个「先小后大」的习惯帮我省过很多次重跑几百个 session 的时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表