简介:rrweb-to-video 面向需要长期归档网页录制内容的前端开发者,解决 rrweb 录制 JSON 数据因静态资源哈希变更或删除而无法完整回放的问题。这份 JavaScript 工具源码可将 rrweb 原始数据直接转换为视频,便于永久保存与后续查阅,避免重新访问原站点时资源失效带来的困扰。压缩包共 11 个文件,以 JS 源码为核心,涵盖转换逻辑、本地服务与构建打包脚本,另有 JSON 配置、HTML 回放示例页面和 Markdown 说明文档,整体仅 47KB,结构精简易读。项目转换依赖 FFmpeg,附带的测试示例开箱即用,可快速验证转换流程。已有 2574 人学习这份资源,参考其实现在业务中集成视频归档流程,或基于源码改造适配自身录制场景,都能明显降低排查回放失效问题的时间成本。
1. 为什么要把 rrweb 录屏数据转成视频
rrweb-to-video 这个工具链解决的是一个非常具体的业务矛盾:rrweb 采集了大量用户行为事件流,但消费方要的是视频文件,不是回放播放器。rrweb 原生输出是一串按时间排列的 JSON 事件,必须依赖回放器在浏览器里重建界面之后才能看,而运营、客服、法务这些角色没有耐心也没有环境去搞一个回放器——他们要的是能直接双击播放的 MP4。这个项目就是把你手上的 rrweb 原始数据渲染出来、录制成标准视频,适合做用户行为采集的前端开发者、需要归档会话回放的质量保障工程师,以及任何想把录屏嵌入工单或证据系统的后端同学。
2. rrweb 事件流与会话重建:先搞清楚要转的是什么
2.1 rrweb 事件流里的核心结构
转视频之前,先得明白手里的东西是什么。rrweb 录屏不是录画面,而是记录了「从某个时间点开始,页面上每一个元素怎么变化」的事件流。一份典型的 rrweb 数据是一组事件对象,每个对象里有一个type字段标识事件类型,常见的类型有下面几类:
- Meta:记录事件流的版本信息、时间偏移和页面尺寸,是所有事件序列的第一条
- FullSnapshot:对当前 DOM 做一次完整序列化,相当于给页面拍了一张「结构照片」,回放器拿到它就能渲染出初始界面
- IncrementalSnapshot:最大的一个事件类型,里面又细分 mutation(DOM 增删改)、mouseInteraction(鼠标位置可视化)、input 输入、scroll 滚动、viewport 尺寸变化,这些增量事件让页面继续活动起来
- Custom:业务自定义事件,比如你在采集端额外塞的用户 ID 标识或者自定义埋点数据
一个完整的事件对象大概长这样:
{ "type": 2, "data": { "node": { "id": 17, "tagName": "button", "attributes": { "class": "submit-btn", "style": "background-color: #4a90d9" }, "childNodes": [] }, "initialOffset": 0 }, "timestamp": 1690000000000 }这里type: 2是 FullSnapshot,node是被序列化的 DOM 树结构,timestamp是事件发生的毫秒时间戳。解析事件流的时候,必须按timestamp升序处理,不能只依赖数组顺序,否则回放会乱。
所以 rrweb-to-video 的本质是:用一套回放引擎把事件流重建成实际 DOM,再把重建出来的页面一帧帧截图或录制,最后用编码器压成视频文件。重建的正确性直接决定了输出视频的画面质量。我一般会在代码里加一个断言:如果事件数组里连 FullSnapshot 都没有,就直接抛错误,不要硬录,因为这种情况下录出来只能是一片空白。
2.2 为什么回放器不能直接替代视频
很多团队会用 rrweb-player 直接嵌一个回放页面给业务看,但真到落地的时候你会发现三个绕不开的问题。
第一,回放依赖于 JavaScript 运行时。任何播放器界面都需要在浏览器里加载 rrweb-player 相关的脚本,这套东西在钉钉、企微的内置浏览器以及部分移动端 WebView 里兼容性并不稳定。视频是通用格式,任何平台都能播,不需要额外装依赖。
第二,回放是无状态的。我们要让客服去归档会话,或者让法务留作存证,一段可以随意暂停、倍速的回放并不具备「文件」属性,别人拿到 URL 之外的内容无能为力。变成 MP4 之后就是一个可分发、可加密、可裁剪的标准文件,能纳入工单系统、能上传证据链,语义完全不一样。
第三,性能问题。一个 40 分钟的长会话,回放器在低端设备上滚动都会掉帧,而视频是预渲染结果,播放器播视频吃的是解码器,不依赖业务 DOM。从工程角度讲,我把「转视频」看成一次离线预计算:把最高成本的渲染过程放到服务端,消费端只拿结果,这是典型的批处理思路。
当然也有一类场景不适合转视频:如果业务需要实时交互式查看用户轨迹,比如运营要边看边跳转到指定操作步骤,那回放器才是对的,视频没法做到按事件节点任意跳。这个边界要在项目启动前和业务方讲清楚,否则你辛辛苦苦做完转换服务,人家发现不能拖动到某个操作点,会反过来质疑你为什么不做回放。
2.3 三条技术路线的选型逻辑
把 rrweb 事件转成视频,目前业界常见做法有三条,按实现难度从低到高排:
| 路线 | 实现方式 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| A | 无头浏览器回放 + CDP 录屏 | 画面绝对真实、实现简单 | 占用内存较大、帧率受渲染性能影响 | 标准 Web 页面会话 |
| B | 真浏览器 + 屏幕采集 | 画面真实、能录到系统弹窗 | 依赖桌面环境、难自动化 | 本地调试、窗口级验证 |
| C | 离屏渲染 + 逐帧截图 + ffmpeg 合成 | 帧率稳定、可控性强 | 需要自己处理 canvas 渲染细节 | 需要精确帧级别的场景 |
我自己的项目里基本不用 B,因为要跑自动化,不可能每个转换任务都开一台带显示器的机器。C 的帧率稳定,但实现成本高,需要自己封装 DOM 重建、处理字体绘制,适合想重造轮子的团队。A 是 rrweb-to-video 最顺的路线:用 Puppeteer 拉起无头 Chrome,打开一个内置回放器的页面,注入 events 数组,然后在浏览器层通过 CDP(Chrome DevTools Protocol)录制屏幕帧,最后把帧流交给 ffmpeg 编码。
选 A 还有一个潜在优势:CDP 录屏期间可以拿到鼠标位置数据,直接在帧上绘制光标,这正好弥补了 rrweb 事件流里鼠标轨迹只有坐标没有样式的细节。你可以在浏览器的回放控制层把真实坐标转成可见的鼠标图形,这样视频里能丝滑地看到操作路径。
2.4 事件规模与转换成本的预估
做容量规划之前先要对工作量有个数。我拿真实会话数据做过一个粗略实测:一条 10 分钟的完整会话大约产生 8 万到 12 万个事件,JSON 体积在 4MB 到 8MB 之间,转换耗时约 2 到 4 分钟(取决于机器 CPU 和是否开 GPU 合成)。
规模评估可以按这个经验公式粗算:
- 页面静态表单型业务:事件量偏少,转换耗时接近视频时长的一半
- 重交互型业务(频繁输入、大量 DOM 变更):事件量是前者 2 到 3 倍,转换耗时会接近甚至超过视频时长
- 含 canvas / 复杂动画的页面:CDP 录屏帧率会掉,需要降低录制帧率来保证稳定,转换时间反而变长
这个预估直接影响架构选型:如果你的业务每天有上万条会话要转,服务端必须做成分布式任务队列,单机并发做不过来。量小的话,一个 Node 进程串行跑就够了,不用上来就搞 Kafka 那套。
3. 环境搭建与首次出片:用 Puppeteer 跑通 rrweb-to-video
3.1 前置依赖:Node.js 环境与浏览器内核
要跑通转换,环境准备就三件事:装 Node.js、装 Puppeteer、准备一个包含 rrweb 事件流的 JSON 文件。
Node.js 建议用当前 LTS 版本,Puppeteer 装最新稳定版即可。安装 Puppeteer 时它会自动下载匹配的 Chromium,这里有个容易忽视的点:如果内网环境下载 Chromium 失败,需要单独处理浏览器二进制文件,后面避坑部分我会给两个可行方案。
事件数据文件可以是你采集端上报的原始 events JSON,也可以是模拟出来的小样本。推荐的做法是先用真实采集数据跑一次完整流程,确认事件流本身没有脏数据,再开始折腾脚本。我一般会在项目里放一个 fixture 文件,里面是一条 30 秒的会话事件流,供开发调试用。
提示:puppeteer 与本地 Chrome 的版本如果差太多,CDP 协议的一些新字段会失效,特别是
Page.startScreencast这种依赖较深的接口。遇到接口报错,先确认浏览器内核版本,不要凭感觉升级依赖。
3.2 最小复现脚本:加载回放页并按帧录制
下面贴一段实际在用的最小转换脚本骨架,可以直接保存成render.js跑:
const puppeteer = require('puppeteer'); const fs = require('fs'); const { spawn } = require('child_process'); async function convert(eventsFile, outputFile) { const events = JSON.parse(fs.readFileSync(eventsFile, 'utf8')); // 启动无头浏览器 const browser = await puppeteer.launch({ headless: 'new', args: [ '--no-sandbox', '--font-render-hinting=medium', '--enable-font-antialiasing' ] }); const page = await browser.newPage(); await page.setViewport({ width: 1440, height: 900 }); // 建立 CDP 会话,接管屏幕录制 const client = await page.target().createCDPSession(); await client.send('Page.enable'); const frameBuffers = []; client.on('Page.screencastFrame', async ({ data, sessionId }) => { frameBuffers.push(Buffer.from(data, 'base64')); await client.send('Page.screencastFrameAck', { sessionId }); }); // 打开内置回放器的本地页面 await page.goto('file:///path/to/player.html'); await page.evaluate((eventData) => { // 调用回放器的初始化方法,把事件流喂进去 window.__REPLAY__.start(eventData); }, events); // 开始录屏 await client.send('Page.startScreencast', { format: 'jpeg', quality: 85, maxWidth: 1440, maxHeight: 900, everyNthFrame: 1 }); // 等待回放结束:监听自定义事件或轮询 window 标志位 await page.waitForFunction('window.__REPLAY__.finished === true', { timeout: 120000, polling: 500 }); await client.send('Page.stopScreencast'); // 帧流交给 ffmpeg 合成视频 const ffmpeg = spawn('ffmpeg', [ '-f', 'image2pipe', '-framerate', '30', '-i', 'pipe:0', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-pix_fmt', 'yuv420p', outputFile ]); for (const frame of frameBuffers) { ffmpeg.stdin.write(frame); } ffmpeg.stdin.end(); await new Promise((resolve) => ffmpeg.on('close', resolve)); await browser.close(); } convert('events.json', 'output.mp4').catch((err) => { console.error('转换失败:', err); process.exit(1); });这段代码的逻辑拆开看:
- 先启动无头浏览器并设置视口 1440x900,这个尺寸决定了最终视频分辨率,必须和采集端的页面视口对齐,否则画面比例会变形。
- 通过
createCDPSession建立会话,用Page.startScreencast开启屏幕帧流。CDP 会不断回调Page.screencastFrame事件,回调里得到的是 base64 编码的 JPEG 帧,所以要转成 Buffer 存起来。 - 回放器初始化完事件流后,浏览器页面会自己按时间线执行事件,页面就在「动」。等到
window.__REPLAY__.finished变成 true,说明回放结束,调用Page.stopScreencast停止录像。 - 把收集到的所有帧通过管道喂给 ffmpeg,用 libx264 编码成 H.264 的 MP4。
这里有几个参数值得单独说明。everyNthFrame: 1表示每一帧都采集,数值调大会跳帧,视频会卡顿但性能压力小。quality: 85是 JPEG 帧的压缩质量,值越高画面越接近原始渲染,但帧体积会变大。preset: veryfast是 ffmpeg 的编码预设,编码速度快、体积略大,适合批量转换;追求小体积可以换medium,代价是编码耗时变长。crf值 23 是画质和体积的平衡点,一般 18 到 28 之间调,数字越小画质越好。
3.3 回放器与事件流的匹配
这个脚本能跑通的关键前提:player.html里的回放器版本必须与采集端所用的 rrweb 版本对齐。rrweb 事件格式在不同 minor 版本之间可能有增量变化,如果 player 版本太旧会忽略某些新事件类型,录出来的视频会缺一部分页面变化。
我一般会在player.html里留一个版本检查接口,加载完事件流之后先在控制台打印事件协议版本,和采集端 SDK 的版本比对,不一致就直接抛错,不要闷头录。经验是rrweb采集端和rrweb-player回放端尽量用同一套版本,跨大版本兼容性风险最高。
3.4 前端帧率损失的补偿策略
无头浏览器录制的时候,如果页面渲染任务过重,CDP 的 screencast 帧率会自动掉,这和真实播放器的体验无关,纯粹是渲染线程抢不过。
两个补偿手段。第一,把视图尺寸缩小录制,例如按 0.75 倍缩放到 1080p,再用 ffmpeg 在编码时放大回目标分辨率,画面细节会有一定损失但流畅度提升明显。第二,调整 Chromium 的--disable-gpu和--disable-software-rasterizer参数组合,低配机器上软件光栅化反而比 GPU 合成更稳定,具体得实测。
这里的核心思路是:先保证每秒 30 帧的录制成功率,再谈画质,顺序不能反。帧率和分辨率不可兼得时,优先保住帧率。
4. 进阶实战:把 rrweb-to-video 接到业务链路里
4.1 从单脚本到 HTTP 转换服务
本地脚本只能自己调试,生产环境里一般要把转换能力封装成服务。常见做法是搭一个异步任务队列:客户端上传 JSON 事件文件,服务端立刻返回任务 ID,转换在后台执行,完成后把视频文件上传到对象存储并回调通知业务方。
核心接口可以简化成三件事:
- 上传接口:接收事件 JSON,写入临时目录,同时把任务状态标记为 pending
- 任务执行器:从队列里取出 pending 任务,逐条调用第三章的
convert函数 - 回调接口:转换完成后把视频 URL 和时长写进任务记录
用 Express 写一个精简版大概长这样:
const express = require('express'); const multer = require('multer'); const { convert } = require('./render'); const app = express(); const upload = multer({ dest: '/tmp/rrweb-uploads/' }); const taskMap = new Map(); app.post('/api/convert', upload.single('events'), async (req, res) => { const taskId = Date.now() + Math.random().toString(16).slice(2); taskMap.set(taskId, { status: 'pending', file: req.file }); // 异步执行,不阻塞请求 setImmediate(async () => { try { const output = `/tmp/rrweb-videos/${taskId}.mp4`; await convert(req.file.path, output); taskMap.set(taskId, { status: 'done', output }); } catch (err) { taskMap.set(taskId, { status: 'failed', error: err.message }); } }); res.json({ taskId, message: '转换任务已入队' }); }); app.get('/api/task/:id', (req, res) => { const task = taskMap.get(req.params.id); if (!task) return res.status(404).json({ message: '任务不存在' }); if (task.status === 'done') { res.json({ status: 'done', videoUrl: task.output }); } else { res.json({ status: task.status, error: task.error || null }); } }); app.listen(3000, () => { console.log('转换服务已启动'); });逻辑上这个服务做了两件事:一是把耗时的转换从请求线程隔离开,用setImmediate异步执行,避免前端长时间等待;二是提供查询接口,让业务方轮询任务进度。生产环境里我建议把taskMap换成 Redis 或者数据库,进程重启不会丢任务状态。
multipart 上传时字段注意和采集端约定一致,这里用的是events,如果采集端字段名不同,multer的single()参数要同步修改。
4.2 与采集端的对接细节
如果你是自己做的采集,只要在编码端把事件流 JSON 上报到转换服务即可。要注意的是,rrweb 的 events 数组里通常包含较大的 FullSnapshot 基础数据,实测一个 10 分钟的会话 JSON 可能到 5MB 以上。上传时建议开 gzip 压缩,能省掉很多带宽。
还有一种常见场景:事件数据不是完整回放,而是从数据库里按时间片段捞出来的。这种情况必须把片段开始时刻之前最近一条 FullSnapshot 也一起带上,否则回放页面从半中间开始无从渲染,输出视频开头就一片空白。很多第一次做分片查询的同学漏掉这一步,排查老半天结果原因特别简单。
4.3 不同业务形态的参数建议
转换服务的输出不是一套参数走天下的,按落地场景给三组推荐配置:
| 场景 | 分辨率 | 帧率 | crf | 备注 |
|---|---|---|---|---|
| 客服工单归档 | 1280x720 | 15 | 28 | 体积优先,够看清操作步骤就行 |
| 测试环境回放比照 | 1920x1080 | 30 | 23 | 画质优先,方便定位前端问题 |
| 法务取证存证 | 1440x900 + 时间戳 | 30 | 18 | 必须保留完整操作过程 |
4.4 长会话自动分段录制
超过 20 分钟的会话,一次性录完容易导致内存持续上涨。更稳的做法是分段录制:把事件流按时间切成长度约 5 分钟的片段,每一段单独起一个浏览器进程录制,最后用 ffmpeg concat 协议拼接。
# 先准备一个片段列表文件 list.txt # 格式:每行一个 file 'xxx.mp4' file 'segment_0.mp4' file 'segment_1.mp4' # 用 concat 协议无损拼接 ffmpeg -f concat -safe 0 -i list.txt -c copy merged.mp4分段的关键点是每一段都要在开头重新播放一遍上一个分段的最后 500 毫秒,避免拼接处操作内容缺失,这叫「交叠分段」。我一般会在切割事件流时保留前后各一小段重复区域,等拼接完成后再用-ss从成片头部剪掉重复部分。
注意:concat 协议要求所有分段编码参数完全一致,否则拼接时会出现花屏或画面跳变。因为录的是页面不是麦克风,一般不存在音轨问题,但编码参数不一致确实会很玄学地导致播放器不兼容。
5. 避坑指南:rrweb 转视频最容易翻车的五个问题
5.1 现象一:输出视频全程黑屏,只有光标在动
转换脚本跑完了,ffmpeg 也没报错,但视频打开就是黑的,偶尔能看到鼠标指针在画面上移动。
原因:回放器虽然加载了事件流,但实际内容渲染到一个自定义的 shadow DOM 容器或者被 CSS 隐藏的根节点。CDP 录制的是整个视口的画面,实际原因往往是回放实例没有挂载到可见 DOM,或者挂载容器高度为 0。
解决:在开始录制前,先用一段 evaluate 脚本检查当前页面里回放容器是否满足宽度和高度条件,不满足就延时重试,超过 10 秒直接报错退出。另外确认回放器初始化时传入的root参数与 HTML 里的节点 id 一致。
// 录制前检查回放容器是否可见 const visible = await page.evaluate(() => { const el = document.querySelector('#replay-root'); if (!el) return false; const rect = el.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }); if (!visible) { throw new Error('回放容器不可见,请检查 root 参数'); }5.2 现象二:字体跟采集时长得不一样,排版整体错位
录出来的视频文字字体和原页面不一致,按钮错位几像素,在中文页面尤其明显。
原因:回放页面加载事件流需要时间,而页面上的 web font 在这个窗口期还没下载好。rrweb 只记录 DOM 结构和样式,字体文件本身不会被序列化进去,回放时必须重新从 CDN 加载。CDN 慢或者网络抖一下,字体没到就开始录帧了。
解决:在触发回放前显式等待字体加载完成:
// 等待页面所有字体加载完成再启动回放 await page.evaluate(async () => { await document.fonts.ready; }); // 生产环境更可靠的做法:等待 CSS 加载完后加 500ms 缓冲 await new Promise((resolve) => setTimeout(resolve, 500));如果是服务端渲染、没有外部字体的场景可以忽略这条,但项目里有 iconfont、webfont 的一定要处理,否则视频里的界面会跟实际生产环境差一大截。
5.3 现象三:iframe 里的内容录不进去,白了一整块
一些老业务系统会在主页面里嵌 iframe,rrweb 默认不采集 iframe 内部,需要额外 plugin 处理。回放时 iframe 区域就是空白的。
原因:rrweb 对 iframe 的支持依赖跨源 iframe 镜像机制,而且 iframe 内容要单独回放,转换脚本里只初始化了主面板的回放实例。
解决:录制前主动检测页面里有没有 iframe 节点,有的话建议换成同源代理方式采集,把 iframe 内容拉到主文档里再处理,或者用浏览器原生--disable-web-security参数绕过跨域限制。这个参数只用于开发调试,生产环境不要开。
排查时可以在回放页面的 console 里执行document.querySelectorAll('iframe'),看返回的数量和内容,快速确认是不是 iframe 导致的白块。
5.4 现象四:视频时长和真实操作时长差一截
业务反馈:视频播完了,但用户实际操作明明是 8 分钟,视频只有 6 分半。
原因:录制阶段的帧率低于回放速度。如果 rrweb 回放器的speed参数设置成 1.5 倍速,回放会加速推进,但是 CDP 录制的帧率并没有跟着提高,导致事件的时间轴被压缩了。
解决:转换前把回放速度强制设为 1.0,同时用 rrweb-player 的timeOffset校准起点。更稳妥的方式是在事件流里找最后一个事件的timestamp和第一个事件的timestamp,算出真实时长,然后反过来检查视频播放时长,偏差超过 8% 就报警。
const duration = await page.evaluate(() => { return document.querySelector('#replay-root') .__player?.getTimeOffset?.() || 0; });5.5 现象五:转换到一半浏览器进程崩溃,任务卡死
一个 30 分钟的会话,转换到 10 分钟时 Puppeteer 进程消失,任务队列里一直 pending。
原因:长会话累积的帧数据量太大,内存里frameBuffers数组越存越多导致 OOM。这是最容易忽视的资源问题,因为短会话测试时根本不会触发。
解决:帧数据不要全部收集在内存里,边录边往磁盘写,或者用流式管道直接喂给编码器。我推荐一个折中做法:每攒够 300 帧就写一批到临时文件,最后用 ffmpeg concat 合并。如果实在不能落盘,就起分段录制,多个短任务比一个长任务稳定太多。
5.6 附带提醒:内网部署时浏览器二进制的坑
服务器在内网环境会面临 Chromium 下载失败。解决方案有两个:一是把 Puppeteer 缓存目录整个拷贝到内网机器上,设置PUPPETEER_CACHE_DIR环境变量指向它;二是用系统自带 Chrome,安装puppeteer-core并显式指定executablePath。前者依赖 Chromium 版本与 puppeteer 的包版本匹配,后者需要注意 Chrome 更新导致的协议变化。
这个属于环境问题,但排查优先级其实应该排在最前面。很多团队在开发机器上跑得好好的,一上服务器就各种 CDP 接口报错,八成是浏览器二进制没对版。
6. 验证视频质量:从「出片」到「能上线」
6.1 五步人工抽检清单
拿到转换输出的 MP4 后,我习惯先花两分钟过一遍基础项:
- 用 ffprobe 看视频编码、分辨率、时长、帧率是否符合预期
- 跳到中间位置,确认不是只有首屏画面
- 检查滚动页面时有没有明显的跳帧感
- 确认鼠标轨迹出现且位置大致吻合操作路径
- 如果有业务输入场景,核对输入框的文字内容是否正确
6.2 自动化校验:抽帧与像素差异比对
更可靠的验证方式是抽帧对比。做法是:用 ffmpeg 从输出视频里抽若干帧,和回放器在同时间点渲染出的页面截图做像素比较,差异过大的帧数超过阈值就判定为转换异常。
# 从视频第 5 秒抽一帧 ffmpeg -ss 5 -i output.mp4 -frames:v 1 frame_5s.png # 用 pixelmatch 比较两张截图 (node) npx pixelmatch frame_5s.png reference_5s.png diff.png --threshold 0.1这个方法能快速发现时间轴漂移和渲染中断的问题。像素差异阈值建议调在 0.1 到 0.2 之间,太小会把字体抗锯齿差异误判成严重缺陷,太大就失去意义。
我在交付前还会强制跑一遍 ffprobe 的时长校验:
# 检查视频时长,和事件流时间戳跨度比对 ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 output.mp4从那以后,我每次交付 rrweb 转换结果之前都会强制走一遍「抽帧 + 像素比对 + 时长校验」这三个流程,没有通过就回炉重录,不再把「能出片」当成「能上线」。这么下来至少能筛掉九成以上的问题,也希望这套流程能帮你少踩几个坑。希望帮到你。
本文还有配套的精品资源,点击获取