简介: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 事件流变成谁都能播的 MP4
rrweb-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 为基准,事件与事件之间的延迟等于两个时间戳之差除以播放速度。换句话说,speed=2 时,原本 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 charset="utf-8"> <link rel="stylesheet" href="rrweb-player.min.css"> </head> <body> <div id="player"></div> <script src="rrweb-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 决定画布和视频分辨率;speed=2 表示事件间隔减半,视频时长是原始会话的一半;showControls 关掉播放器 UI,否则截图会带上进度条;autoPlay 让页面加载后立即重放。注入事件时有个坑:如果直接用字符串拼接,事件里一旦包含</script>就会把页面切坏,标准做法是先把 JSON 里的<转义成\u003c,或者干脆用下一章的 Puppeteer evaluate 方式传对象。
准备好页面后,用 Chrome headless 拉起来:
# 以你的 Chrome/Chromium 实际二进制路径为准 chrome --headless --disable-gpu \ --window-size=1280,720 --force-device-scale-factor=1 \ --screencast --screencast-fps=30 \ file:///绝对路径/player.html--window-size 要和回放画布一致,--force-device-scale-factor=1 保证 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-policy=no-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 是因为 speed=2。更稳的做法是轮询回放器实例暴露的当前时间,但不同版本 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-15 | 960×540 | 26-28 | 4-8 | 文件小,看个流程够用 |
| 产品演示 / 交付 | 25-30 | 1280×720 | 23-24 | 1-2 | 点击和输入过程要顺滑 |
| 归档留存 | 30 | 1920×1080 | 18-20 | 1 | 画质优先,文件大可以接受 |
这里最容易踩的联动关系是 speed 和 fps。speed=4 以上时,事件间隔被压缩到原来的四分之一,单位时间内画面变化更密集,如果你的 fps 只有 15,鼠标轨迹和动画会出现明显的跳变,看起来像掉帧。反过来,speed=1 的 30 分钟会话用 30fps 转,会得到 54000 帧,文件大且编码慢,但画质并没有比 15fps 好太多,因为录屏场景里大部分时间页面是静止的。
分辨率方面,我一般直接取回放画布的实际尺寸。rrweb 的 Meta 事件里有 width/height,如果采集端页面是 1280 宽,硬拉到 1920 不会有任何细节增益,只是让编码更慢。想要小分辨率时用 ffmpeg 的 -vf scale=960:-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,且字体文件加载很慢,可以在回放页面里预先把字体包打进本地,用