资讯动态

rrweb 会话回放转 MP4:无头浏览器重放与 ffmpeg 编码实践

发布时间:2026/10/9 15:30:28 来源:尧图企业网站定制
简介rrweb-to-video 是一个面向前端开发者的 JavaScript 工具可将 rrweb 录制的 JSON 原始数据转换为视频文件。其核心场景是解决回放时静态资源 hash 变化或已被删除导致的加载失败问题通过转码实现页面操作的永久保存。压缩包共 11 个文件含 6 个 JS 源码/构建脚本、2 个 JSON 配置、1 个 HTML 演示页、1 份 Markdown 文档体积仅 47KB结构精简。由于生成视频依赖 FFmpeg包内 README 明确了安装与环境变量配置方法便于快速上手。目前已有 2574 人学习适合需要长期存档网页录屏的测试、运维或前端工程师。资源内提供 rollup 打包配置、server 服务、bundle 产物及 test 示例运行 node test/index.js 即可体验完整转换流程还可参考 bundle.js 理解打包结果整体是一个可直接复用的轻量工具包。对于研究前端录制回放、数据持久化方案的开发者这份资源能提供现成实现与思路参考。1. rrweb-to-video 在解决什么把 JSON 事件流变成谁都能播的 MP4rrweb-to-video 这个方向解决的痛点是rrweb 的「回放」本质上不是视频而是一串 JSON 事件它记录的是 DOM 怎么增删改、鼠标怎么动、输入框敲了什么不是像素怎么变化。想把这个回放发给产品、测试或外部客户对方得先装回放环境、导入事件数据、再手动播放门槛高得离谱。把 rrweb 原始数据转换成视频就是用无头浏览器按时间轴重放这些事件、逐帧采图最后用 ffmpeg 合成出标准 MP4让任何播放器都能打开。这篇文章写给两类人一是已经接了 rrweb 采集、现在想把会话录屏导出成视频的开发者二是刚看到这个标题、想评估「这东西能不能用、投入多少能跑通」的选型者。内容从事件结构讲起给可直接复现的转换管道再到参数调优和一组真实的翻车现场。按文中的顺序走你能在本机跑出一条完整的转换链路。2. 理解 rrweb 原始数据事件流结构、时间轴与回放机制转换之前得先搞清楚手上这份 JSON 到底是什么。很多人第一次打开 rrweb 导出的文件看到一堆 type 数字和嵌套 data 就发懵直接拿 JSON.stringify 去拼页面结果转出来的视频要么白屏要么错位。这一章把数据模型讲透顺便给你一个转换前必跑的分析脚本。2.1 rrweb 到底录了什么快照、增量与元数据的 JSON 结构rrweb 的原始数据是一个事件数组每个事件都带有 type 和 timestamp 两个基本字段。type 是数字枚举常见的有这么几类元数据事件记录页面 URL、视口宽高、完整快照事件整个 DOM 的序列化树、增量快照事件后续所有的 DOM 增删改、鼠标移动、滚动、输入、视口尺寸变化以及自定义事件和插件事件。下面这条是典型的元数据事件回放器靠它确定画布尺寸和页面地址{ type: 4, data: { href: https://example.com/checkout/page, width: 1280, height: 720 }, timestamp: 1718000000000 }type 为 4 表示 Meta 事件data 里的 width/height 决定回放容器的大小这直接关系到你后面视频的分辨率。完整快照事件则是一个序列化后的 DOM 树每个节点都有稳定 id后续所有增量事件都引用这些 id。增量快照是整个数据里最复杂的一类它内部还有 source 字段区分类型包括 Mutation、MouseMove、MouseInteraction、Scroll、ViewportResize、Input、TouchMove 等每种携带的数据结构都不一样。把这几种事件的职责理成一张表转换时心里就有数了事件类别作用转换时是否关键Meta页面地址、视口宽高关键决定视频画布FullSnapshot完整 DOM 序列化树关键决定首帧画面IncrementalSnapshot后续全部交互与 DOM 变化关键决定中间过程Custom业务自定义事件可选Plugin扩展插件事件音频、canvas 等视插件而定这里有个容易误判的点增量事件不是「可直接执行的 DOM 操作」它引用的是 rrweb 序列化后的节点 id 和属性描述必须由回放器先重建快照树再按 id 应用增量。所以转换时不要试图自己写解析器去 apply 这些事件那是把回放器重写一遍的工程量。后面所有方案都建立在「官方回放器在真实浏览器里跑」这个前提下。2.2 回放时间轴怎么算timestamp、delay 与虚拟时钟rrweb 每个事件的 timestamp 是毫秒级整数来源是采集端页面的 Date.now()。回放器内部维护一条虚拟时间轴它以第一个事件的 timestamp 为基准事件与事件之间的延迟等于两个时间戳之差除以播放速度。换句话说speed2 时原本 1000ms 的间隔被压缩成 500ms视频时长也相应减半。转换脚本里最常用的两个公式是// 视频总时长毫秒speed 为回放速度倍数 const videoDurationMs (lastTs - firstTs) / speed; // 第 i 帧对应的视频时刻毫秒 const frameTimeMs i * 1000 / fps;第一个公式用来估算回放需要跑多久、什么时候该结束录屏第二个公式在抽帧校验时会用到。注意 firstTs 是事件流里第一个事件的 timestamp不一定是 0这点直接导致很多人转出视频开头一大段空白第五章会展开讲。动手转换前我强烈建议先跑一遍这个分析脚本确认数据没有硬伤再进管道// analyze-events.js —— 转换前先看清你的数据 const fs require(fs); const events JSON.parse(fs.readFileSync(rrweb-events.json, utf8)); const firstTs events[0].timestamp; const lastTs events[events.length - 1].timestamp; const typeNames { 0: DomContentLoaded, 1: Load, 2: FullSnapshot, 3: IncrementalSnapshot, 4: Meta, 5: Custom, 6: Plugin }; const byType {}; for (const e of events) { const name typeNames[e.type] || (Unknown_ e.type); byType[name] (byType[name] || 0) 1; } console.log(事件总数:, events.length); console.log(总时长(秒):, ((lastTs - firstTs) / 1000).toFixed(2)); console.log(类型分布:, JSON.stringify(byType, null, 2));这段脚本干三件事统计事件总量、算原始会话时长、按类型分布校验。如果结果显示 FullSnapshot 数量为 0说明数据是从会话中途开始录的或者埋点逻辑有 bug直接转视频必然白屏如果总时长是个位数秒说明时间戳有问题先回采集端排查。这个脚本不依赖任何第三方库存成 .js 用 Node 跑就行。2.3 为什么要在浏览器里重放而不是直接写解析器很多第一次做转换的人会问能不能不启浏览器自己遍历事件、用 jsdom 应用修改然后截图答案是别折腾。rrweb 的增量事件依赖节点 id 映射、序列化样式和滚动/输入状态jsdom 不支持真实排版和字体渲染截出来的图和你肉眼看到的页面完全是两回事。转换成视频的可靠路径是让官方回放器跑在一个真实 Chromium 实例里再想办法把画面抓出来。目前主流的实现路线可以列成一张对比表方案实现成本还原度适用场景自己写解析器 截图高低布局和字体差异大不推荐无头浏览器 官方回放器 录屏低高首帧到交互动画基本一致推荐绝大多数项目选这条用户端实时录屏采集时就录中取决于录制环境适合采集阶段就规划好视频导出的场景第三类方案其实是另一个产品方向在用户浏览器里用 getDisplayMedia 或 MediaRecorder 直接录。它的问题是成本高、耗流量、且必须在采集端提前介入对已经沉淀了大量 rrweb 历史数据的团队不现实。所以 rrweb-to-video 的通用做法就是让官方回放器在无头浏览器里跑然后抓帧编码这也是接下来两章的核心内容。3. 搭建本地转换流程无头浏览器重放、逐帧采集与编码这一章给两条可落地的管道。第一条依赖 Chrome 自带的 headless 截屏命令最少、最快跑通第二条用 Puppeteer 的 CDP 接口抓帧可控性更好适合要做成服务的情况。两条管道最终都汇到 ffmpeg 编码。3.1 最小可复现管道自包含页面 Chrome headless --screencast先说最省事的办法。Chrome 的 headless 模式自带 --screencast 参数会在回放网页时自动把页面画布按固定帧率输出为一组编号 PNG。前提是你要先准备一个自包含的回放页面把 rrweb-player 的脚本、样式和事件数据都打进去。!-- player.html事件数据由构建脚本注入避免手动粘贴大 JSON -- !DOCTYPE html html head meta charsetutf-8 link relstylesheet hrefrrweb-player.min.css /head body div idplayer/div script srcrrweb-player.min.js/script script // 由构建脚本把事件数组注入到 window.__INJECTED_EVENTS window.__EVENTS window.__INJECTED_EVENTS || []; new rrwebPlayer({ target: document.getElementById(player), props: { events: window.__EVENTS, width: 1280, height: 720, speed: 2, showControls: false, autoPlay: true } }); /script /body /html这里几个 props 都是回放器直接支持的width/height 决定画布和视频分辨率speed2 表示事件间隔减半视频时长是原始会话的一半showControls 关掉播放器 UI否则截图会带上进度条autoPlay 让页面加载后立即重放。注入事件时有个坑如果直接用字符串拼接事件里一旦包含/script就会把页面切坏标准做法是先把 JSON 里的转义成\u003c或者干脆用下一章的 Puppeteer evaluate 方式传对象。准备好页面后用 Chrome headless 拉起来# 以你的 Chrome/Chromium 实际二进制路径为准 chrome --headless --disable-gpu \ --window-size1280,720 --force-device-scale-factor1 \ --screencast --screencast-fps30 \ file:///绝对路径/player.html--window-size 要和回放画布一致--force-device-scale-factor1 保证 CSS 像素和视频像素一一对应否则高分屏下截图可能是 2 倍尺寸。Chrome 会在当前目录输出一组编号 PNG命名和帧数上限跟具体版本有关转码前先 ls 看一眼实际文件名再写通配符。这组 PNG 就是视频的帧序列。# 把 PNG 序列编码成 H.264 MP4 ffmpeg -y -framerate 30 -i screencast-%d.png \ -c:v libx264 -crf 23 -preset veryfast \ -pix_fmt yuv420p session.mp4-framerate 30 告诉 ffmpeg 输入帧率它同时决定输出时间基-crf 23 是 H.264 在 web 分发里比较平衡的默认值数字越小画质越高文件越大-preset veryfast 牺牲一点压缩率换编码速度本地调试够用-pix_fmt yuv420p 是兼容性关键不加的话有些播放器会花屏。如果你对文件命名和起始序号有疑问ffmpeg 的 -start_number 参数可以对齐起点。3.2 用 Puppeteer CDP 采帧可控性更强的替代路线--screencast 的问题是黑匣子无法在字体加载完、首帧渲染稳定之后再开录也无法控制单帧画质。换成 Puppeteer 的 CDP 接口就能精确掌握开始时机还能把帧直接以 JPEG 管道喂给 ffmpeg不落盘、不占磁盘。const puppeteer require(puppeteer); const { spawn } require(child_process); (async function convert() { const browser await puppeteer.launch({ headless: new, args: [--autoplay-policyno-user-gesture-required] }); const page await browser.newPage(); await page.setViewport({ width: 1280, height: 720 }); const events require(./rrweb-events.json); await page.setContent(buildPlayerHtml()); // 用 evaluate 传事件对象避免 HTML 转义问题 await page.evaluate((ev) { window.__replayer new window.rrwebPlayer({ target: document.getElementById(player), props: { events: ev, speed: 2, showControls: false, autoPlay: false } }); }, events); // 等字体和首帧就绪避免开头白屏 await page.evaluate(async () { await document.fonts.ready; await new Promise(r setTimeout(r, 500)); }); const client await page.createCDPSession(); await client.send(Page.enable); await client.send(Page.startScreencast, { format: jpeg, quality: 80, maxWidth: 1280, maxHeight: 720, everyNthFrame: 1 }); const ffmpeg spawn(ffmpeg, [ -y, -f, image2pipe, -vcodec, mjpeg, -framerate, 30, -i, pipe:0, -c:v, libx264, -crf, 23, -preset, veryfast, -pix_fmt, yuv420p, session.mp4 ]); client.on(Page.screencastFrame, async ({ data, sessionId }) { // data 是 base64 编码的 JPEG 帧直接写进 ffmpeg 标准输入 ffmpeg.stdin.write(Buffer.from(data, base64)); // 必须 ack否则 Chromium 会停止继续发帧 await client.send(Page.screencastFrameAck, { sessionId }); }); await page.evaluate(() window.__replayer.play()); // 根据总时长估算回放结束时间留 3 秒余量 const durationMs (events[events.length - 1].timestamp - events[0].timestamp) / 2; await new Promise(r setTimeout(r, durationMs 3000)); await client.send(Page.stopScreencast); ffmpeg.stdin.end(); await browser.close(); })();这段代码比上一章的方案多了几个关键动作Page.startScreencast 里的 format/quality 控制单帧编码格式和质量maxWidth/maxHeight 可以在服务端先缩一档减少网络传输和 ffmpeg 压力screencastFrame 事件回调里拿到的是 base64 JPEG转成 Buffer 直接写管道全程不产生中间文件screencastFrameAck 必须每次回调都回这是背压机制漏了 Chromium 会停发后续帧你会看到输出视频只播几秒就断了。回放结束时间用总时长公式估算这里除以 2 是因为 speed2。更稳的做法是轮询回放器实例暴露的当前时间但不同版本 API 名不一样所以我习惯用估算加余量最后再用 ffmpeg -t 截掉多余部分省心。3.3 ffmpeg 编码帧序列与管道两种输入的区别ffmpeg 的输入方式决定了你的管道是简单还是绕。第 3.1 节用的是图片序列输入也就是 -i screencast-%d.png这种模式适合调试因为每一帧都是落盘的 PNG肉眼能直接检查缺点是磁盘占用大、IO 密集。第 3.2 节用的是 image2pipe 从标准输入读 JPEG适合生产因为帧不落盘、编码连续性好缺点是排查起来看不到中间产物。两种模式对应的关键参数差异只有输入描述部分图片序列用-framerate 30 -i 序列通配符管道用-f image2pipe -vcodec mjpeg -framerate 30 -i pipe:0。注意 -framerate 必须放在 -i 前面它声明的是输入帧率如果放到输出侧含义就变成输出帧率处理可变帧率的 screencast 时会出现时间轴忽快忽慢的问题。输出参数里-crf 是单次编码的画质基准23 适合快速分发-preset 影响编码速度和压缩率的平衡生产环境可以开 -preset medium 拿更好的体积时间紧用 veryfast。-movflags faststart 会把 moov 元数据挪到文件头方便网页端边下边播如果你要把视频丢到对象存储或 CDN 上这参数值得加。音频轨道默认没有rrweb 本身不录音这一层在第五章单独讲。4. 参数调优与产物质量帧率、分辨率、码率与平滑度的取舍管道通了之后下一个问题就是出片质量。帧率、回放速度、分辨率和编码参数是互相牵制的单独调某一个往往顾此失彼。这一章给出我常用的参数区间和联动逻辑并解释一个最常见的卡顿误区。4.1 关键参数表fps、speed、scale、crf 的推荐区间先给一张直接可以抄的参数表。它按使用场景分了三档每档都考虑到了会话时长和分发渠道场景帧率分辨率crf回放速度备注内部快速预览10-15960×54026-284-8文件小看个流程够用产品演示 / 交付25-301280×72023-241-2点击和输入过程要顺滑归档留存301920×108018-201画质优先文件大可以接受这里最容易踩的联动关系是 speed 和 fps。speed4 以上时事件间隔被压缩到原来的四分之一单位时间内画面变化更密集如果你的 fps 只有 15鼠标轨迹和动画会出现明显的跳变看起来像掉帧。反过来speed1 的 30 分钟会话用 30fps 转会得到 54000 帧文件大且编码慢但画质并没有比 15fps 好太多因为录屏场景里大部分时间页面是静止的。分辨率方面我一般直接取回放画布的实际尺寸。rrweb 的 Meta 事件里有 width/height如果采集端页面是 1280 宽硬拉到 1920 不会有任何细节增益只是让编码更慢。想要小分辨率时用 ffmpeg 的 -vf scale960:-2 在编码阶段统一缩放不要在采帧时改回放画布尺寸否则布局会重排和原始会话不一致。4.2 帧捕获节奏为什么 setInterval 截图会让视频卡成 PPT很多第一版转换脚本长这样setInterval 每 33ms 调一次 page.screenshot。这在本地 Mac 上跑可能还行换到低配机器或 Docker 容器里就会发现输出视频卡成幻灯片。原因不是回放慢而是 page.screenshot 是同步的合成-编码-压缩过程单帧耗时可能波动到 100ms 以上setInterval 不会等上一次任务完成任务排队实际帧率直接掉到个位数而回放器还在真实时间推进帧与帧之间就丢了大量过程画面。正确的做法有两种。第一种是第 3.2 节的 CDP screencast它由 Chromium 合成器直接产出 JPEG帧率稳定得多。第二种是保留 screenshot 但改成 deadline 驱动也就是每帧之间用「目标时间」而不是「固定间隔」来睡// epoch-based 截图循环能吸收单帧耗时波动 let next Date.now(); const intervalMs 1000 / fps; while (frameIndex totalFrames) { await page.screenshot({ path: frames/${String(frameIndex).padStart(5, 0)}.jpg, type: jpeg, quality: 80 }); frameIndex; next intervalMs; const wait next - Date.now(); if (wait 0) { await new Promise(r setTimeout(r, wait)); } }这段代码的逻辑是每次都先截图再计算这次截图花了多久用剩余时间补觉。如果某帧慢了 60ms下一帧就少睡 60ms整体平均帧率依然能贴近目标不会像 setInterval 那样无限积压。jpeg quality 80 是为了减少单帧体积和编码耗时如果你必须出 PNG至少把 size 压到和画布一致别留 device scale factor 的放大余量。4.3 长会话与超大 JSON 的处理内存与分段策略事件 JSON 超过 50MB 或会话超过 10 分钟时转换管道会进入另一种状态不一定是跑不动而是慢得让人怀疑人生。先说数据注入几十 MB 的 JSON 不要用 setContent 拼 HTML字符串拼接和转义都会有肉眼可见的开销用第 3.2 节的 page.evaluate 传对象走 CDP 协议直接序列化快得多。帧的存储是第二个瓶颈。30fps 的 10 分钟会话是 18000 帧JPEG 80% 质量单帧约 60-150KB总量在 1.5-2.5GB如果存 PNG 会到 4GB 以上。所以生产管道我不用落盘方案直接管道喂 ffmpeg。如果必须落盘用 ramdisk 或 tmpfs 能省掉不少 IO 等待。第三个问题是分段。超过 15 分钟的会话我一般按事件时间切成 3-5 分钟的段每段独立转换再拼接# 每段独立编码参数保持一致 ffmpeg -y -framerate 30 -i seg1/%d.jpg -c:v libx264 -crf 23 -preset veryfast seg1.mp4 # 用 concat 拼接注意分段时参数必须完全相同 printf file seg1.mp4\nfile seg2.mp4\nfile seg3.mp4\n list.txt ffmpeg -f concat -safe 0 -i list.txt -c copy merged.mp4分段的好处是单段失败可以重试不会让 40 分钟的编码白跑坏处是段与段之间可能有一两帧的衔接跳动拼接点选在页面静止时段比如连续几秒没有鼠标移动和 DOM 变化的区间可以忽略这个瑕疵。段与段的分割点按时间戳二分查找事件索引就行写一个函数把 targetTime 映射到最近事件下标逻辑很简单但分段方案对内存和重试的收益是实打实的。5. 避坑清单rrweb 转视频最常翻车的 5 个现场转换管道跑通只是开始真正耗时间的是处理各种边角数据。这一章记录了几条高频踩坑都是「现象 → 原因 → 解决」的顺序能帮你少走弯路。5.1 黑帧与白屏回放器还没就绪就开始录现象转出来的视频前几秒是全黑或纯白然后画面突然跳到会话中段像是把关键动作剪掉了。原因autoPlay 和 screencast 同时启动比赛谁能先跑。回放器要完成完整快照的 DOM 重建、样式计算和字体加载大页面可能需要几百毫秒到一秒这段时间里录到的就是空白画布。解决不要依赖 autoPlay把回放器设成 autoPlay: false先等 document.fonts.ready 和一段固定延迟再手动调 play()。第 3.2 节代码里就是这么处理的。如果首屏仍然白检查事件流里是否存在类型为 2 的 FullSnapshot 事件没有就不是时序问题而是数据本身缺快照。5.2 时间轴错位开头多出几十秒空白或视频比会话短现象视频头 20 秒是空白的浏览器窗口或者采集端明明录了 10 分钟视频文件只有 8 分钟。原因第一个事件的 timestamp 不是会话开始时刻常见于采集脚本在页面加载早期就初始化、用户在浏览器后台停留了很久才操作另一个原因是事件流里存在时间倒挂或完全相同的时间戳导致回放器延迟计算出现负值。解决转换前做时间归一化把所有事件的时间戳整体左移// 归一化时间戳把第一个事件对齐到业务时间起点 const firstTs events[0].timestamp; const offsetMs 1000; // 给开头留 1 秒缓冲 const normalized events.map(e ({ ...e, timestamp: e.timestamp - firstTs offsetMs }));做完归一化再看一遍总时长如果归一化后 duration 和采集端预期的会话时长差很多说明事件流里有异常时间戳。可以在分析脚本里加一个检查遍历 events找出 ts[i] ts[i - 1] 的事件下标这类事件要么丢弃要么修正不要让回放器自己处理它的处理方式往往是直接归零延迟表现在视频里就是画面跳变。5.3 字体闪烁与布局抖动headless 里没有目标页面同款字体现象视频刚开始文字用的是 fallback 字体播到中段突然切换成正常字体整页产生一次重排肉眼看起来像画面抖了一下。原因rrweb 记录的是 DOM 变化不负责字体。无头 Chromium 环境里没有业务页面的字体文件初次渲染走系统后备字体页面里 font-face 加载完成后才换回来。解决headless 启动前先等字体就绪回放器初始化前执行一段await document.fonts.ready。如果业务页面用的是 webfont且字体文件加载很慢可以在回放页面里预先把字体包打进本地用

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

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

免费获取报价 →
↑