rrweb录屏数据转MP4:事件流回放与无头浏览器录制实践 简介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最大的一个事件类型里面又细分 mutationDOM 增删改、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是 FullSnapshotnode是被序列化的 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 数组然后在浏览器层通过 CDPChrome 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-video3.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-hintingmedium, --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备注客服工单归档1280x7201528体积优先够看清操作步骤就行测试环境回放比照1920x10803023画质优先方便定位前端问题法务取证存证1440x900 时间戳3018必须保留完整操作过程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 里的内容录不进去白了一整块一些老业务系统会在主页面里嵌 iframerrweb 默认不采集 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 formatduration -of defaultnoprint_wrappers1 output.mp4从那以后我每次交付 rrweb 转换结果之前都会强制走一遍「抽帧 像素比对 时长校验」这三个流程没有通过就回炉重录不再把「能出片」当成「能上线」。这么下来至少能筛掉九成以上的问题也希望这套流程能帮你少踩几个坑。希望帮到你。本文还有配套的精品资源点击获取