公司动态

rrweb录制回放转视频实操:Puppeteer逐帧截图与ffmpeg合成

📅 2026/9/2 2:32:53
rrweb录制回放转视频实操:Puppeteer逐帧截图与ffmpeg合成
简介rrweb-to-video 是一个将 rrweb 原始录制数据JSON转换为视频的开源 JavaScript 工具面向使用 rrweb 做用户行为采集与回放的前端开发者解决因静态资源 hash 变化或删除导致回放失效的问题让录屏内容可永久保存。整个压缩包共 11 个文件包含 6 个 JavaScript 源码/脚本、2 个 JSON 配置、1 个 HTML 页面示例和 1 份 README 说明文档包体仅 47KB结构轻量而完整。通过阅读源码可理解 rrweb 数据解析、视频帧生成与 FFmpeg 调用的完整流程自带示例页面与服务端脚本可直接跑通“JSON 转视频”的本地测试README 中也有 FFmpeg 安装与环境变量配置指引适合直接上手改造或集成进现有录制归档流程。即使不熟悉 rrweb 内部实现也能按文档快速运行示例并验证效果。目前已有 2572 人浏览学习适合需要长期保存用户录屏或排查 rrweb 回放异常的前端开发者。1. 为什么非要把rrweb数据“拍”成视频1.1 回放器能看为什么还需要视频文件接触过rrweb的朋友应该都有类似的体验第一次跑通录制回放的时候确实挺兴奋的——页面上用户的每一次点击、输入、滚动甚至DOM的每一次变化都能原样复现。但这份兴奋往往维持不了多久因为当你试图把这份录制数据分享给别人的时候问题就来了。对方的电脑上没有装回放依赖也不想装一个Node服务去跑回放器客户那边的业务人员根本不知道rrweb是什么他们只想要一个能直接双击打开的东西或者你干脆就是在做个数据可视化平台需要在页面里嵌入一段“当时用户到底干了什么”的证据而不是再加载一个回放器实例。这种时候你就会意识到——rrweb产出的原始数据本质上是一串带时间戳的结构化事件流它不是多媒体文件。它需要加载回放器、需要浏览器环境、需要处理资源路径和跨域问题才能呈现。而视频文件mp4也好webm也罢是任何设备、任何播放器都能直接消费的产物。把rrweb录制数据转成视频本质上是在做一次“从事件日志到媒体文件”的格式转换这个转换的价值在于降低了内容的分发门槛。我最早做这个工具就是接到一个客诉工单客户反馈某个表单在提交时验证提示一闪而过怀疑是bug。开发想复现但复现不出来用户也不会配合装什么环境。后来让前段在客户侧页面嵌了rrweb录制拿到录制数据之后发现回放确实能看到问题但要把这段数据作为证据链提交给产品侧和测试侧总不能让别人也去搭一套回放环境吧。于是就有了把rrweb数据变成视频的念头。1.2 转视频的真实使用场景在继续聊技术细节之前我觉得有必要先盘一下“转视频”这件事到底会在哪些场景下被用到因为不同的场景对转换方案的复杂度要求完全不一样。一是客诉复现与Bug回溯。这是最常见的需求。客服收到反馈之后拿到录制数据转成视频直接贴到工单系统或者群里人人都能看。不需要对方理解rrweb是什么不需要安装任何依赖打开视频就知道当时发生了什么。二是用户行为分析报告。做产品分析的时候把某个用户的关键操作路径录制成视频嵌入到分析文档或者演示PPT里比贴一堆点击热力图和漏斗数据直观得多。这类场景通常不需要全量录屏而是截取关键时间片段。三是自动化测试的产物存档。E2E测试跑完以后把录制数据转成视频作为每次测试执行的可视化证据。这样测试报告里能直接链接到视频回溯问题的时候不需要重新跑一遍用例。四是恶意行为取证。比如风控场景下做欺诈审核时把用户的可疑操作过程转成视频存档。这类场景对视频的完整性和真实性要求很高不允许出现丢帧、画面错位等问题。场景不同对转换工具的精度、性能和资源消耗要求也不一样。但总体上把rrweb数据转成视频的技术路径是相同的用浏览器执行回放按时间截帧再把帧序列合成视频。接下来我要讲的就是这条路径上我自己踩过的所有坑和最终沉淀下来的完整实操方案。2. 技术选型无头浏览器逐帧截图再用ffmpeg合成2.1 为什么不是服务端直绘或Canvas直接渲染最开始我设想过一条看起来很优美的路线直接在服务端解析rrweb的事件流操作一个虚拟DOM然后用canvas逐帧绘制画面最后把canvas导出成图片序列再合成视频。这样做的好处是服务端一条龙搞定不用起浏览器没有窗口系统的依赖。但实际操作之后发现这条路走不通原因有三个。第一rrweb的事件流不是纯粹的DOM操作指令。比如Input事件、MediaInteraction事件、CanvasMutation事件、Drag事件其中有一部分只能通过浏览器引擎才能正确还原。服务端没有完整的布局引擎对于CSSOM的解析、绝对定位元素的层叠关系、动画的中间帧这些你没法用一个简易的DOM解析器去模拟。第二样式加载和计算是个大麻烦。网页渲染的最终效果取决于CSS的层叠规则、媒体查询、自定义字体、图像加载时机。这些计算在服务端做一遍跟在浏览器里做一遍结果只能说是“大致相似”不可能做到像素级一致。如果某个CSS hack或者兼容性写法恰好落在你模拟不到的地方转换出来的视频就是错的。第三性能上也不划算。自己写一套DOM解析和样式计算逻辑的成本太高不如直接用好现成的浏览器内核。Chromium本身就是一个高性能的DOM渲染引擎为什么还要重复造轮子。所以最终技术选型非常明确用Puppeteer或者Playwright驱动一个无头Chromium加载rrweb的replayer按时间戳逐步推进播放每一帧截一张图最后把图片序列交给ffmpeg合成视频。2.2 核心工具链与项目结构我的工具链组成是这样的rrweb / rrweb-player负责解析事件流并渲染页面画面。注意这里未必用rrweb-player的完整UI壳子更常用的做法是直接用rrweb提供的replayer核心API自己控制播放时间轴。Puppeteer无头浏览器控制。Puppeteer负责打开一个空白页注入回放脚本手动控制replayer.play()、replayer.pause()并在指定时间点通过page.screenshot()截图。ffmpeg负责把截图序列合成为视频并处理音频轨如果有以及压缩编码。任务编排脚本我用Node.js写的负责读取rrweb的录制文件解析时间戳计算截图时间点表调度Puppeteer和ffmpeg。整个项目结构大致是这样的rrweb-to-video/ ├── src/ │ ├── index.js # 入口任务编排 │ ├── parser.js # 解析录制文件生成时间轴 │ ├── recorder.js # Puppeteer无头浏览器截图逻辑 │ ├── composer.js # 调用ffmpeg合成视频 │ └── template/ │ └── player.html # 回放页面模板 ├── output/ │ ├── frames/ # 中间产物截图序列 │ └── videos/ # 最终产物mp4文件 └── package.json这个结构不复杂但每个模块之间都有需要仔细处理的细节。下面我把核心流程拆开讲。3. 转换流程拆解从事件流到mp43.1 元数据分析与回放窗口计算拿到一个rrweb录制文件之后第一步不是急着开浏览器而是先解析元数据、算清楚回放窗口到底有多长。rrweb的录制数据主体是一个事件数组每个事件对象都有一个timestamp字段单位是毫秒。这些事件按时间顺序排列从最早的事件时间戳到最晚的事件时间戳就是整个录制的总时长区间。但这里有一件事必须注意rrweb事件流里第一个事件往往是Meta事件它包含了起始时间、页面尺寸等信息而真正的内容是从FullSnapshot全量快照开始的。我解析的时候大致是这样的逻辑function parseRecordings(rawData) { const events Array.isArray(rawData) ? rawData : rawData.events; if (!events || events.length 0) { throw new Error(录制数据为空); } const metaEvent events.find(e e.type 4); // EventType.Meta const fullSnapshotEvent events.find(e e.type 2); // EventType.FullSnapshot let startTime fullSnapshotEvent ? fullSnapshotEvent.timestamp : metaEvent.timestamp; let endTime events[events.length - 1].timestamp; // 有的录制在结束时还有一个Meta事件记录关闭时间如果有的话以它为准 const lastMeta events[events.length - 1].type 4 ? events[events.length - 1] : null; if (lastMeta lastMeta.timestamp endTime) { endTime lastMeta.timestamp; } return { events, startTime, endTime, duration: endTime - startTime, width: metaEvent.data.width, height: metaEvent.data.height }; }这一步算出来的duration决定了后面要截多少帧。比如一个60秒的录制如果按每200ms截一帧那就是300帧左右合成出来的视频就是60秒按5fps算。这里有个关键取舍视频的帧率不等于截图的频率。这句话我一会儿在踩坑部分详细解释。元数据里的页面尺寸也很重要因为截图的分辨率要以它为准。如果录制时的视口是1920x1080那么截图也应该用这个尺寸否则画面会被拉伸变形。3.2 逐帧截图的时间轴策略时间轴策略是整个转换流程的核心。你的目标是在正确的时刻截取正确的画面但“正确”这个词没那么简单。最朴素的做法是设定一个固定的截图间隔比如每100ms截一张从startTime到endTime循环截过去。但这样做有两个问题。一是截图本身需要时间哪怕你在无头浏览器里执行一个screenshot也要几十毫秒如果录制时间很长截图上千张等待的时间会非常可观。二是如果两个事件之间的间隔很长比如用户在页面上看了10秒没动中间没有任何rrweb事件那么截出来的每一帧都一样既浪费存储又浪费时间。所以我的策略是动态时间轴先扫描事件流找到所有发生“视觉变化”的时间点在这些时间点前后各留一个很小的余量每个时间段里至少截一张图。然后在两个变化点之间的空闲区间按一个较大的步长比如每1秒截一张做稀疏采样保证视频看起来是连续播放的不会出现跳变。伪代码是这样的function buildFrameTimeline(events, duration) { const frameTimes []; const visualEventTypes [2, 3, 6, 7, 8, 9, 12]; // FullSnapshot, IncrementalSnapshot, Focus, Scroll, ViewportResize, Input, MediaInteraction // 第一步把有视觉变化的时间点加入候选集 for (const event of events) { if (visualEventTypes.includes(event.type)) { const t event.timestamp - startTime; // 在事件前60ms、事件发生时刻、事件后120ms各取一帧 frameTimes.push(Math.max(0, t - 60), t, t 120); } } // 第二步补充稀疏采样点确保视频整体连续 for (let t 0; t duration; t 1000) { frameTimes.push(t); } // 去重、排序 return [...new Set(frameTimes)].sort((a, b) a - b); }这个采样策略的好处在于事件密集的时间段截图密度高视频细节完整空闲时间段帧数少不浪费资源。实测下来一个30分钟的回放按这个策略通常只需要800到1500帧左右而固定100ms间隔截帧则需要18000张差距不是一个数量级。3.3 ffmpeg合成视频的细节参数截图序列生成完毕之后交给ffmpeg合成视频。这一步的参数选择直接影响成品的大小和播放兼容性。我常用的合成命令是这样ffmpeg -framerate 10 -i output/frames/frame_%05d.png \ -c:v libx264 -pix_fmt yuv420p \ -crf 23 -preset medium \ -movflags faststart \ output/videos/output.mp4解释一下几个关键参数-framerate 10告诉ffmpeg按每秒10帧的速度来播放图片序列。这里的10fps是根据我前面截图策略的平均密度定的。如果你的截图间隔是200ms一张那么帧率就是5fps。帧率低会造成画面一顿一顿的帧率太高又会把图片序列的播放速度加快导致整个视频时长远小于实际录制时长。这里必须保证帧率 截图总数 / 录制总时长秒。-pix_fmt yuv420p这个必须加。默认的像素格式可能是yuv444或者rgb很多播放器不支持加上这个参数能保证视频在所有现代播放器里正常播放。-crf 23CRF是质量参数数值越小画质越高文件也越大。一般录屏类内容用23就够了如果画面里有大量文字细节建议调到18到20。-movflags faststart把moov atom挪到文件头部这样视频在网页端可以边下边播不需要等整个文件下载完。如果视频是要在浏览器里嵌入播放的强烈建议加这个参数。如果录制过程中有音频事件rrweb的数据里是可以携带音频的比如AudioCapture事件那就需要把音频也导出来然后合并。一般做法是先把音频解码为wav或者直接用ffmpeg从事件数据中生成音频流然后合成时加上-i audio.wav -c:a aac -b:a 128k参数。音频这块我做得不算深但至少能保证音画同步。4. 实际踩坑记录时间轴错位、资源跨域与异常事件4.1 截图时机滞后导致的首帧黑屏第一个踩出来的坑是这个方案里最基础也最容易忽略的Puppeteer的screenshot调用不是瞬间完成的而浏览器渲染页面也不是同步的。我最早写截图逻辑的时候代码大概是这样的await page.evaluate((time) { replayer.pause(time); }, currentTime); await page.screenshot({ path: frame_${index}.png });看起来没问题先让回放器跳到某个时间点再截图。但实际跑出来的结果视频前面几帧经常是黑的或者画面卡在上一帧。问题出在replayer.pause(time)这个调用是异步改变内部状态的它本质上是让rrweb的replayer把当前时间轴更新到指定位置然后触发一次重新渲染。这个渲染过程需要走一遍样式计算、布局、绘制尤其是第一次播放FullSnapshot的时候整个页面是从零开始重建的耗时可能超过几百毫秒。如果你马上截图浏览器还没完成渲染拿到自然就是白屏。解决方案是在截图之前等一个固定的延迟。我自己调试下来首次加载FullSnapshot之后至少要等800ms后续增量快照的等待可以缩短到100ms左右。代码改成这样async function captureFrame(page, replayer, time, filePath, isFirstFrame false) { await page.evaluate((t) { replayer.pause(t); }, time); const waitTime isFirstFrame ? 800 : 100; await new Promise(resolve setTimeout(resolve, waitTime)); await page.screenshot({ path: filePath }); }这个方法简单粗暴但有效。很多做这类工具的人第一版都会踩到这里我见过不少开源项目里截图出来前几帧是黑屏的issue基本都是这个原因。4.2 外部资源加载失败导致画面空白第二个坑是关于页面里的外部资源。rrweb录制的时候它记录的是DOM状态和用户操作它不会把CSS、字体、图片内容全部内嵌进录制文件里。回放的时候replayer会尝试按照原始的URL去加载这些资源。如果录制环境的网络和回放环境的网络不一致比如录制时客户在内网回放时我在本地开发机上那这些资源就会加载失败页面显示就会和录制时不一致。更麻烦的是rrweb对于某些资源加载失败是有降级处理的——它会尝试用录制的DOM快照里的信息去恢复但对于CSS里引用的background-image这类内容降级处理的效果就很有限。截图出来之后图片位置就是一块空白或者一个破图标。我的处理方案分两步走第一步在回放页面里注入一段拦截逻辑把资源请求强制指向本地缓存。具体做法是在录制时就把页面里引用的静态资源JS、CSS、图片、字体下载到一个本地目录并在回放时通过自定义协议或者service worker做映射。这个方案改动量大需要维护资源映射表。第二步如果资源已经无法获取比如客户的内网资源已经删了那就做兜底渲染——在回放页面里对加载失败的元素加一个底色或占位符保证画面整体不是白的。从产品角度看占位符比空白好得多因为观看者至少能知道“这个位置原本是有内容的”。实际操作中我通常建议优先用第一步因为整体效果更好。但也要评估工作量如果资源数量不大最笨的办法也很有效直接在回放页面里用正则替换资源URL替换成本地文件地址然后通过page.setRequestInterception在Puppeteer层拦截请求。await page.setRequestInterception(true); page.on(request, (request) { const url request.url(); if (url.startsWith(https://original-cdn.example.com/)) { const localPath url.replace(https://original-cdn.example.com/, http://localhost:8080/assets/); request.continue({ url: localPath }); } else { request.continue(); } });4.3 事件序列不完整时的兜底处理第三个坑是录制数据本身不完整时怎么办。很多人忽略一个现实问题rrweb录制过程不是永远可靠的。页面崩溃、用户突然关闭浏览器、网络闪断都可能导致录制文件里缺少最后的收尾事件。最典型的两种情况一种情况是事件流里只有FullSnapshot没有任何IncrementalSnapshot。这说明录制刚启动就直接中断了。这种情况下回放器拿到一个快照页面停在那里视频转换出来就是一帧静态画面——这倒还好至少不是错误。另一种情况是事件流中间有缺口。比如用户的某个操作触发了页面跳转跳转期间录制还在继续但新页面的FullSnapshot没来得及记录下来。这种情况下replayer会挂在一个旧页面上后续的事件没有对应的DOM可执行可能导致报错。我处理这类情况的方式是在转换流程里加一个事件流校验器在进入截图阶段之前先把事件流过一遍看看结构是否完整。如果发现缺少关键的FullSnapshot或者事件类型和数量明显不成比例就直接报错提示需要重新录制。如果校验通过但仍有小缺口比如某个增量快照的节点缺失则打开回放器的skipInactive配置避免异常事件把整个流程中断。function validateEvents(events) { const hasFullSnapshot events.some(e e.type 2); if (!hasFullSnapshot) { throw new Error(事件流缺少FullSnapshot录制数据不完整); } // 检查时间戳是否严格递增 let prevTs 0; for (const e of events) { if (e.timestamp prevTs) { console.warn(发现时间戳倒置的事件可能录制异常); } prevTs e.timestamp; } return true; }4.4 性能与体量的平衡问题最后一个值得一提的坑是长录制视频的转换耗时和中间产物体积。我转过最长的录制是一个在线教学系统的用户操作回放时长约45分钟。按我的动态采样策略大约生成了1200帧每帧截图是1920x1080的PNG单张体积在500KB到2MB之间。所有截图加起来大概1.5GB左右。虽然能接受但硬盘空间小的人就要注意了。优化方向有两个。第一截图格式从PNG改成JPEG单张体积能缩小到200KB以内但画面里如果有文字JPEG压缩会导致边缘出现马赛克对可读性影响不小。所以我对文字密集型录屏用PNG对以操作轨迹为主的视频用JPEG。第二对空闲时间段的帧做智能去重。如果某一帧和前一帧的像素差异小于一个阈值比如95%以上像素点没变化直接丢弃这一帧在ffmpeg合成时通过修改帧率让它仍然能正常播放。这个优化能让总帧数再降一半左右。帧率和视频时长的关系也值得注意。同一个录制数据5fps和15fps的成片看起来体感差别很大——不是帧率越高越流畅这么简单因为rrweb的录制本身是“离散事件”驱动的它不代表真实的连续视频流所以帧率再高画面之间的变化也只是“跳变”的多少问题。我试下来的体感是8fps到10fps是性价比最好的区间看视频的人基本不会觉得卡顿文件大小也可控。5. 成品效果与后续优化空间5.1 转出来的视频实际长什么样最终转出来的视频和你在浏览器里用rrweb-player直接回放的效果在视觉上基本一致鼠标移动轨迹、表单输入、点击高亮、页面滚动都表现正常。因为本质上它就是把回放器的画面一帧帧录下来了所以回放器里看到的问题视频里也会有回放器里看不到的比如视频里鼠标指针的样式则取决于你的截图配置。有一点需要提醒录制的页面如果用了canvas、WebGL这类实时渲染的内容rrweb的录制数据里记录的可能只是操作指令回放时需要重新执行指令渲染画面。这种情况下转换出来的视频可能会出现渲染结果延迟、部分帧空白等问题。我在处理一个在线白板工具的录制时遇到过canvas上画的内容在回放时加载慢导致视频前几秒白板是空的后几秒突然全部出现。这块的解决方案需要针对具体场景做特殊优化不是通用方案能覆盖的。5.2 可以继续做的改进这个工具做到现在我认为还有几个方向值得继续深挖。一是做一个转换任务的Web服务化封装。目前我的实现是命令行工具一次处理一个文件。如果做成本地服务或者嵌入到现有的管理后台里让非技术人员也能上传rrweb录制文件、选择分辨率、一键转出视频分发门槛还能再降一个台阶。二是支持自定义输出视频的窗口大小和码率。录制文件里的Meta记录了原始视口尺寸但导出时可能需要适配不同的播放平台。比如嵌入到CRM系统里的视频可能只需要720p而作为证据链存档的可能需要原画。加一个缩放和码率控制选项能让工具的适用范围更广。三是做一个简单的Web端预览功能。转视频之前能先在网页上预览一下回放效果确认录制数据没问题了再提交转换任务省去“转完才发现数据损坏”这种白忙活的情况。说实话rrweb-to-video这种工具它不是一个业务功能更像是“基础设施里的螺丝刀”。平时用不上但真到需要的时候能省下大量沟通和协作成本。我做完这个工具之后的体会是它没有什么高深的技术核心就是把几个成熟组件rrweb、Puppeteer、ffmpeg组合起来并在组合的缝隙里把那些“没人跟你讲你绝对想不到”的细节填好。希望这篇文章里记录的这些坑和方案能让你少走点弯路。本文还有配套的精品资源点击获取