简介:rrweb-to-video 是一个基于 JavaScript 的实用工具项目,面向需要把 rrweb 录制的页面操作 JSON 数据转成视频以做长期保存或后续回放的开发者。它解决了 JSON 回放时页面图片、CSS 等静态资源因 hash 更新或删除而无法还原的问题,通过 FFmpeg 将录制数据直接渲染为视频文件,适用于用户行为录制归档、产品演示、评审留档等场景。资源压缩包共 11 个文件,以 JS 源码为主,另含 JSON 配置、HTML 回放页、Markdown 说明文档,整体大小仅 47KB,结构轻量,便于阅读和二次扩展。项目自带 rollup 构建配置、server/page 运行脚本、lib 处理模块和基础测试示例,README 也对 rrweb 数据流转与转换思路作了说明。读者可直接运行示例验证流程,也可在此基础上定制批量转码工具,将录制的网页操作变成可随时播放的视频资产。目前已有 2572 人学习/下载,适合对 Web 录制与视频转换感兴趣的前端开发者尝试。 你有没有遇到过这样的场景:产品经理丢过来一个 rrweb 回放链接,你打开网页确实能看用户操作,可临到要把这段操作发给客服、审计或者剪进培训材料,发现手上只有一堆 JSON 数据,连个像样的视频文件都拿不出来。我做 rrweb-to-video 这个项目,最开始就是想解决这个尴尬——把 rrweb 采集到的原始事件数据,老老实实转成能直接播放的 mp4 视频。
这个项目听起来像是“浏览器录屏”,但真正动手之后你会发现它完全不是一个录屏问题,而是“如何让浏览器自己把数据重新演一遍,再把演出的过程录下来”。这篇文章我尽量把完整的思路、方案选型、代码链路和踩坑记录都写清楚,适合正在被 rrweb 长回放、数据归档、操作留证折磨的人参考。
1. 先搞清楚 rrweb 的事件流到底是什么
1.1 rrweb 记录的不是视频帧
rrweb 和常规录屏软件最大的区别在于,它不录制像素帧,而是通过 MutationObserver 监听 DOM 的一切变化,把节点的新增、删除、属性变更、鼠标移动、滚动行为、视口变化、输入事件等统统记录成结构化的数据。回放的时候,rrweb 再把这些事件按时间顺序重放到一个真实浏览器环境中,于是页面就像“重演”一样动了起来。
这种设计带来的直接好处是数据量极小。一段 10 分钟的用户操作,录成视频可能上百 MB,但 rrweb 事件数据可能只有几 MB,而且天然可搜索、可分析。代价就是它只能在安装了 rrweb 运行时的浏览器环境里回放,不能直接拖进播放器里看。
所以 rrweb-to-video 的定位非常清晰:它做的事情就是“翻译一层格式”。把 rrweb 的事件流从浏览器内部的回放协议,转成通用视频编码。这层转换不是简单的转封装,而是需要把 DOM 渲染过程完整重演一遍,再重新“录“下来。
1.2 是哪些场景逼着我们必须转视频
有人会问:既然 rrweb 回放体验这么好,为什么还得转视频?我自己在项目里陆续收到过这三类需求,基本就是这个工具存在的全部理由:
第一类是分享分发。运营、客服、外部合作方不一定愿意装一个浏览器插件或者打开一个需要加载大量 JS 的回放页面,他们最希望收到的是一个微信里就能直接打开的 mp4。第二类是取证留底。用户操作投诉、异常行为分析这类场景,审计要求的是不可篡改、格式标准的视频记录,而不是一份可以被解释为“伪造”的 JSON 数据。第三类是二次加工。视频可以进剪辑软件做标注、裁剪、抽帧,也可以直接上传到培训平台、工单系统,这是网页回放做不到的。
这些需求叠加在一起,让“rrweb 原始数据转换成视频”变成了一个刚需,而不是前端工程师的自嗨玩具。
2. 方案选型:为什么绕不开“回放+录屏”
2.1 为什么不直接去解析 JSON 生成视频
初看这个问题,很容易想偏,觉得 rrweb 数据里已经有 DOM 结构和事件了,能不能解析一下、按时间轴拼出一个视频?这个思路理论上可行,但实操上几乎不可能做好。
原因在于,rrweb 的事件不是逐帧记录,浏览器里的动画、第三方脚本带来的渲染副作用、CSS 的级联计算、字体和图片的异步加载,都会影响最终的视觉结果。如果要脱离浏览器去“模拟渲染”,等于要自己实现一个迷你浏览器,成本高到离谱。所以最可靠、最贴近真实用户视角的方案,一定是在真实浏览器环境中按时间轴重放事件,同时把渲染结果记录下来。
2.2 录屏的三种路径对比
定下“回放+录屏”的大方向之后,录屏怎么录也有讲究。我在早期试过三条路线,各有取舍:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| CDP 逐帧截屏 | 通过 DevTools Protocol 的 Page.startScreencast 或连续截图拿帧,再在 Node 端合成视频 | 不侵入页面 DOM,通用性最强 | 每帧 JPEG 编码开销大,CPU 占用高,长视频容易卡顿 |
| 页面内 canvas 流录制 | 页面内用 html2canvas 等方式渲染回放区域到 canvas,通过 canvas.captureStream + MediaRecorder 输出 WebM | 视频帧率稳定,体积可控 | html2canvas 对复杂样式支持有限,需要额外维护渲染队列 |
| 外部录屏工具 | Xvfb 虚拟屏幕 + ffmpeg x11grab 录整个屏幕 | 能录制任意程序,不挑页面 | 环境配置重,不适合封装成轻量服务 |
最后我采用的是第一种路径:无头浏览器 + 事件注入 + 录制回放过程。原因很实际——rrweb 的回放基于 DOM 渲染,我们既不应该把回放 DOM 强行搬到 canvas 上牺牲兼容性,也不想引入 Xvfb 之类的外部依赖,CDP 截屏虽然 CPU 开销高,但对绝大多数场景已经够用。如果想进一步优化性能,可以针对简单页面切换到 canvas 流录制。
2.3 关键参数要提前算清楚
在动手写代码前,有几个参数直接决定视频质量和转换成本,建议先理清楚:
- 帧率:rrweb 是事件驱动,不是连续动画,15 fps 就能看,20~25 fps 基本上视觉流畅。不要盲目上 30 fps,视频体积和转码时间会成倍增加。
- 分辨率:和回放页面里容器的尺寸保持一致。常用 1280 x 720,viewport 和容器都按这个尺寸设置,避免回放区域出现滚动条。
- 倍速:短操作 1 倍速就好。长回放建议 2x~4x,节省录制时间,输出视频时长 = 原始事件时长 / 倍速。
- 尾帧缓冲:最后一条事件执行完之后,页面可能还有动画或状态刷新,至少要留 0.5~1 秒缓冲再停止录制,不然视频尾巴会被硬生生切掉。
- 编码:浏览器录制端一般出来的是 WebM/VP9 或者 WebM/VP8,要获得更通用的兼容性,最终得转成 H.264 的 MP4。
3. 完整链路实现:从 JSON 到 MP4
3.1 搭一个干净的回放页面
录视频的回放页面和平时给用户看的回放页面不一样,不需要控制条、不需要弹窗提示、更不需要重放菜单,画面越干净越好。我的做法是单独准备一个 player.html,只保留回放容器和必要的初始化逻辑。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <style> html, body { margin: 0; padding: 0; overflow: hidden; background: #fff; } #stage { width: 1280px; height: 720px; } </style> </head> <body> <div id="stage"></div> <script src="https://cdn.jsdelivr.net/npm/rrweb/dist/rrweb.min.js"></script> <script> function startReplay() { const events = window.__RRWEB_EVENTS__; const replayer = new rrweb.Replayer(events, { root: document.getElementById('stage'), speed: window.__RRWEB_SPEED__ || 1, showControls: false, showWarning: false, }); replayer.play(); replayer.on('finish', function () { window.__REPLAY_FINISHED__ = true; }); } if (window.__RRWEB_EVENTS__) { startReplay(); } else { window.__START__ = startReplay; } </script> </body> </html>这里有一个特别容易踩的坑:事件注入的时序。如果你先让页面加载完成,Replayer 已经初始化,再通过 page.evaluate 去设置 window 上的事件数据,它根本不会重新读取。所以我的方案是给页面留一个__START__全局函数,Puppeteer 先注入数据,再手动触发初始化。
3.2 Puppeteer 端驱动录制
接下来就是用 Puppeteer 加载这个页面、注入事件、等待回放结束。核心代码长这样:
const puppeteer = require('puppeteer'); async function convert(events, options = {}) { const browser = await puppeteer.launch({ headless: 'new', args: [ '--autoplay-policy=no-user-gesture-required', '--no-sandbox', '--use-gl=swiftshader', ], }); const page = await browser.newPage(); const width = options.width || 1280; const height = options.height || 720; await page.setViewport({ width, height }); await page.goto('file:///path/to/player.html'); // 注入事件数据,触发回放 await page.evaluate((ev, speed) => { window.__RRWEB_EVENTS__ = ev; window.__RRWEB_SPEED__ = speed || 1; if (window.__START__) window.__START__(); }, events, options.speed); // 轮询等待回放完成 await page.evaluate(() => { return new Promise((resolve) => { const timer = setInterval(() => { if (window.__REPLAY_FINISHED__) { clearInterval(timer); resolve(); } }, 200); }); }); // 留出尾帧缓冲时间 await new Promise((r) => setTimeout(r, 800)); // 在这个窗口期内,由录制模块输出视频流 await browser.close(); }录制模块本身可以独立出来:如果是 CDP 截帧方案,就在 Puppeteer 启动后开启 Page.startScreencast,把返回的 JPEG 帧一条条推到 ffmpeg 的 stdin 里;如果是页面内 canvas 录制方案,就需要在页面里额外注入 MediaRecorder 逻辑,最终把 WebM 二进制传回 Node 端。
实际使用中,我建议把“回放驱动”和“视频编码”拆成两个模块,方便单独测试。因为这类工具出问题,大部分时候是回放没触发、截图时机不对,而不是编码环节出错。
3.3 ffmpeg 后处理与体积控制
浏览器端录出来的文件通常要么是 WebM,要么是一堆 JPEG 帧,这两种格式都不适合直接交付。后处理统一交给 ffmpeg,最常用的命令是这样:
ffmpeg -i input.webm -c:v libx264 -crf 20 -preset medium -pix_fmt yuv420p output.mp4几个参数说明一下。-c:v libx264选的是 H.264 编码器,兼容性最好;-pix_fmt yuv420p一定要加,否则部分播放器会因为色度采样格式不支持而绿屏;-crf 20是质量参数,数字越小质量越好文件越大,日常归档用 20~23 之间比较合适。
如果原始录制方式是 JPEG 帧序列,那么命令改成:
ffmpeg -framerate 20 -i frame-%06d.jpg -c:v libx264 -pix_fmt yuv420p output.mp4注意-framerate必须和录制时设定的帧率保持一致,否则视频播放速度会快慢不均。
4. 录制排坑记录:黑屏、体积、时间轴
4.1 录出来的视频是黑屏或绿屏
这个问题的出现频率最高,基本可以占到踩坑数的一半。我遇到过的原因主要有三种:
第一是自动播放策略拦截。无头浏览器默认可能不允许自动播放视频或者回放,播放器执行 play() 后没有任何反应,最终整个录制过程录到的都是静止空白页。解决办法是在 Puppeteer 启动参数里加--autoplay-policy=no-user-gesture-required。
第二是事件注入时序不对,Replayer 初始化时拿到了空的 events 数组。这类问题往往不是你代码没写对,而是浏览器加载脚本的时间和 page.evaluate 执行时间存在竞态。所以页面里要有类似__START__这样的触发函数,不要依赖初始化代码“一定能在数据注入后执行”。
第三是 GPU 渲染导致黑帧。无头浏览器里有些机器 GPU 支持不完整,打开页面后 canvas 或 WebGL 层出现异常。稳妥的办法是加上--use-gl=swiftshader强制软件渲染,虽然性能稍低,但稳定性好很多。
4.2 视频体积过大、转换耗时太长
录一个 10 分钟回放,产出的 MP4 可能高达 500MB,这通常是帧率太高且 CRF 设得太低导致的。解决思路是分级处理:内部预览用 CRF 23、20fps,正式归档用 CRF 18~20、25fps。如果你的业务场景允许,还可以直接限制视频分辨率,比如 1280x720 的源视频统一缩到 960x540,肉眼几乎看不出差别,体积却能小一半。
长回放的卡顿问题则是另一回事。如果一个回放事件跨了 30 分钟甚至更久,浏览器内事件积压严重,回放速度会肉眼可见地拖慢。最简单的规避方式就是提高倍速,在可接受的前提下用 2x 或 4x 回放,录出来的视频总时长缩短,最终体积也会同步下降。我实际测下来,一段 30 分钟事件流切成 4 倍速回放,7.5 分钟视频基本能覆盖所有操作语义,对审计留证来说完全足够。
4.3 时间轴漂移和尾帧丢失
时间轴漂移通常发生在页面主线程被大量事件计算阻塞时,录制端的帧率没问题,但真实渲染的节奏已经跟不上事件时间戳,导致录出来的视频越到后面越“对不上号”。排查时不要急着调帧率,先看回放页面的 CPU 占用和事件处理时间。
尾帧丢失则是结束时机选错了。如果按固定时长去停止录制,最后几个关键操作很可能还在渲染队列里没出来。我的做法是监听 Replayer 的 finish 事件作为结束信号,之后再补一个 800ms 到 1s 的缓冲,原因是 finish 触发以后还有一小段浏览器级的重绘窗口,把这段也录进去,整个视频才完整。
这一点在我已经跑了上百次转换服务后仍然稳定,所以如果你打算做类似工具,也建议把“等待真实结束信号 + 尾帧缓冲”作为标准流程写进工具里。
另外还有一个小细节值得提醒:rrweb 回放默认不包含音频。如果业务上需要给视频配讲解、背景音,可以在录制结束后的 ffmpeg 环节再合成音轨,比在浏览器里捕获音频要简单可靠得多。
我个人在实际操作中最大的体会是:rrweb 转视频这个需求,难点从来不是“怎么编码”,而是“怎么让无头浏览器把回放过程稳定跑完”。事件注入时序、自动播放策略、结束信号、尾帧缓冲,每一步都决定了最终视频能不能看。把这些基础链路调稳定之后,再考虑性能优化和体积控制就水到渠成了。
本文还有配套的精品资源,点击获取