☰
rrweb事件流转MP4:转换链路、参数调优与踩坑实战
2026/10/6 5:06:11 网站建设 项目流程

简介:rrweb 录制生成的 JSON 原始数据,在回放时需依赖页面上的图片、CSS 等静态资源,随着项目持续迭代,这些资源的 hash 值往往已变更甚至被删除,导致回放画面异常。这份面向 JavaScript 前端的工具项目正是为此而生:将 rrweb 原始数据直接转换为视频,实现永久保存与离线查看,适合使用 rrweb 做用户行为录屏回放、需要长期留档的开发者。资源包共 11 个文件,以 6 个 JS 文件为主体,覆盖项目入口、服务端、页面脚本与打包配置等环节,另含 JSON 配置、HTML 回放页面、README 说明文档及 .gitignore 忽略规则,结构清晰明了。整个压缩包仅 47KB,轻量精简;只需安装并配置好 FFmpeg 环境,即可通过命令行将 JSON 转换生成 mp4 视频。目前已有 2574 人学习浏览,随包提供完整源码、构建配置与测试示例,可快速理解转换流程并接入自身项目,为回放数据永久留存提供可靠方案。

1. 从事件流到视频文件:为什么回放需要被固化成 mp4

做过用户行为回放的都知道,rrweb 把页面操作录成 JSON 事件流,体积比录屏小一个量级,分析时却要开着浏览器一段段回放,没法直接交付、没法归档到对象存储。rrweb-to-video 这类工具把 rrweb 原始数据按时间顺序渲染到 canvas,再用 MediaRecorder 录制成 mp4,等于把「需要环境的回放」固化成一个普通视频文件。它适合三类人:做客服质检要留证据的、做用户行为分析要批量产出的、以及需要把录制数据长期归档但不想依赖播放器的。核心一句话:录屏做的是屏幕,rrweb 做的是 DOM,rrweb-to-video 做的是把 DOM 恢复成画面再录下来,落地的关键是 JavaScript 侧对事件流时序和编码参数的控制。下面把链路拆开,参数和坑都按可复现来写。

2. rrweb 数据结构和转换链路:事件流是怎么一步步变成 canvas 的

对只拿 rrweb 录过数据、没仔细看过事件流的人来说,第一步是把数据结构吃透。rrweb 输出的是一个数组,每个元素是一个 event,event 里有 type、timestamp、data 三个固定字段,其余都是 data 下的子字段。type 为 0 的是 Meta 事件,记录当时页面的宽高和 href;type 为 2 是 FullSnapshot,存的是整个 DOM 的序列化快照;type 为 3 是 IncrementalSnapshot,负责后续所有增量变化,比如输入、鼠标移动、DOM 修改和样式变化。回放时就是先拿 Meta 和 FullSnapshot 建立初始页面,再拿着按时间戳排序的增量事件逐个应用,最后才能谈得上把画面输出成视频。

2.1 快照、增量与时间戳:rrweb events 的三段式结构

把一段 rrweb 数据打印出来看,常见结构是这样的:

[ { "type": 0, "timestamp": 1690000000000, "data": { "href": "https://example.com/checkout", "width": 1440, "height": 900 } }, { "type": 2, "timestamp": 1690000000100, "data": { "node": { "tagName": "html", "childNodes": [ { "tagName": "body", "childNodes": [] } ] } } }, { "type": 3, "timestamp": 1690000000200, "data": { "source": 2, "texts": [{ "id": 12, "value": "已选 2 件商品" }] } } ]

这段 JSON 说明三件事:第一个事件建立画布尺寸和页面地址,第二个事件把 DOM 全量画出来,第三个事件是增量,只改 id 为 12 的节点的文本。回放引擎拿到这段数据后,实际是在内存 DOM 里渲染,不是直接操作视频帧,所以效率高、体积小,但代价是回放必须依赖 JavaScript 运行环境。

参数说明要留意几点。type 0 的 data.width 和 data.height 决定了最终视频的宽高比例,转码时如果强制把输出尺寸设成和这里不一致,要么留边、要么拉伸。type 2 的 data.node 是序列化后的 DOM 树,自闭合节点、input 的 checked 状态、canvas 的快照内容都会以特殊字段保存。type 3 的 data.source 细分了增量类型,source 为 0 是鼠标交互,为 1 是滚动,为 2 是文本内容变化。每个事件的 timestamp 是绝对时间戳,回放进度算的是相邻事件的时间戳差值。

需要特别留意的:rrweb 的快照不是简单 innerHTML。它会给每个节点分配 id,在增量事件里用 id 引用节点,跨事件序列化是稳定的。转视频时如果发现某个输入框内容没变化,先检查增量事件里 texts 字段的 id 在 FullSnapshot 里是否存在。id 对不上说明录制端和回放端的 rrweb 版本不一致,这是最容易被忽略的黑匣子。

2.2 转换链路里最关键的一跳:canvas 流与 MediaRecorder

rrweb 自己只做录制和回放,不做视频输出。rrweb-to-video 的切入点在回放的渲染层:回放时不是直接在页面 DOM 里铺开,而是把回放窗口渲染到一个 canvas 上,再用 canvas.captureStream(fps) 拿到实时视频流,最后交给 MediaRecorder 编码。这一步的代码形态就是标准事件流:

const canvas = document.getElementById('replay'); const context = canvas.getContext('2d'); const stream = canvas.captureStream(30); const recorder = new MediaRecorder(stream, { mimeType: 'video/webm;codecs=vp9', videoBitsPerSecond: 3_000_000, }); recorder.ondataavailable = (e) => chunks.push(e.data); recorder.start(); // 这里由回放引擎持续驱动 canvas 重绘 recorder.stop();

代码逻辑:captureStream 接受一个帧率参数,告诉浏览器从 canvas 里取画面的频率;MediaRecorder 接到这个 MediaStream 后开始编码。回放引擎每处理一个新事件,都重新绘制 canvas,而不是逐帧用 requestAnimationFrame 硬刷,这样能省掉大量无意义的重复绘制。

常见的错误理解是把 MediaRecorder 当成瞬时录屏器。实际上 captureStream 的帧率上限由 canvas 的重绘频率决定,如果事件流里两秒没有变化,canvas 没有新帧产出,某些浏览器会把这 2 秒录成黑帧或者直接跳过,这会造成后面黑屏问题。处理方式是给 canvas 绘制加一个固定频率的循环,在无事件时也重绘当前状态,保证视频流不断帧。

为什么不直接用浏览器原生录屏或现成的屏幕录制库?两个原因。一是 rrweb 事件的回放发生在内存 DOM 里,不产生真实屏幕画面,录屏工具只能录到页面 URL 和 loading 状态。二是在无头浏览器里,Chrome 的 tab 捕获经常拿不到 canvas 的加速合成层,转出来白屏概率极高。canvas.captureStream 从 JavaScript 层面直接拿流,绕开了系统录屏权限,也绕开了 GPU 合成层的坑,这是 rrweb-to-video 最值得学习的一条选型思路。

另一个和 headless 环境强相关的点是时间驱动。rrweb 回放有一个基准时间,所有增量事件都相对基准推进。普通回放里,页面用 requestAnimationFrame 驱动,每帧推进一段时间;转到无头浏览器后,rAF 仍然存在,但页面不可见时浏览器会把帧率降到很低,导致录制出的视频时间被拉长。常见做法是在注入脚本里强制用一个固定帧率的绘制时钟替代 rAF,并且把页面置于可见状态。这些细节在 rrweb 的回放 demo 里表现不出来,只有跑到转换视频时才会暴露。

3. 从 npm 包到 mp4:rrweb-to-video 完整复现一次转换

要在自己环境里把 rrweb 原始数据转成视频,直接把整个仓库 clone 下来当黑匣子跑是最容易翻车的路径。我更建议按它的核心链路自己搭一次:Node 脚本拉起一个无头浏览器,注入回放渲染逻辑,等事件回放完,停止录制,输出文件。这样每一步都有日志可看,出问题能定位到具体环节,而不是看着一个 node 进程报一个含糊的退出码。

3.1 环境准备:Node、Puppeteer 与浏览器内核版本搭配

依赖先装三个:puppeteer-core、rrweb 的回放包、以及转码封装包。安装命令如下:

mkdir rrweb-to-video-demo && cd rrweb-to-video-demo npm init -y npm i puppeteer-core rrweb rrweb-to-video

如果你在 CI 或服务器上跑,建议直接用 puppeteer 而不是 puppeteer-core。puppeteer 会自动下载 Chromium,体积大约 150 MB,省去手动装 Chrome 的麻烦;puppeteer-core 默认不下载浏览器,适合本机已有 Chrome 的环境。注意 Firefox 的 MediaRecorder 对 canvas.captureStream 支持不完整,导出环节请固定在 Chromium 系浏览器。

这里有一个必须提前处理的点:无头浏览器的自动播放策略。MediaRecorder 的启动在部分 Chromium 版本里会被当成需要用户手势的媒体操作,导致 recorder.start() 不执行。常见做法是在启动浏览器时加两个参数:--autoplay-policy=no-user-gesture-required 和 --use-fake-ui-for-media-stream。前者放行自动播放,后者跳过麦克风和摄像头授权弹窗。如果你在本地跑没问题、上服务器就空白,优先检查这两个参数。

生产环境还需要确认 Chromium 的 GPU 加速是否开启。默认 new headless 模式下,canvas 的 2D 上下文可能走 SwiftShader 软件渲染,转出来的视频会明显掉帧。这时候关掉 headless 或者显式传 --use-gl=swiftshader,等视频转完再切回来。软件渲染不影响视频内容正确性,只影响性能和帧率稳定性,批量转换时可以接受。

3.2 最小化转码脚本:把一份 events 换成 mp4

写一个 Node 脚本 convert.js,接收输入文件路径和输出路径两个参数:

const fs = require('fs'); const { toVideo } = require('rrweb-to-video'); const [, , inputPath, outputPath] = process.argv; (async () => { const events = JSON.parse(fs.readFileSync(inputPath || './events.json', 'utf8')); await toVideo({ events, output: outputPath || './output/result.mp4', width: 1440, height: 900, fps: 30, mimeType: 'video/webm;codecs=h264', inlineAssets: true, compressIdle: { threshold: 5000, ratio: 20 }, }); console.log('converted:', outputPath || './output/result.mp4'); })();

逻辑说明:toVideo 是这类仓库封装的转换入口,核心行为是按 events 重建页面、设置画布尺寸、渲染回放、启动录制。events 直接传原始 JSON 数组;output 是产物路径;width 和 height 强制覆盖画布尺寸;fps 决定 captureStream 取帧频率;mimeType 优先请求 h264 编码;inlineAssets 把 CSS、图片内联成 data URL,减少网络加载导致的样式闪烁。

参数说明有几个坑点。width 和 height 如果和事件里的 Meta 不一致,产物会被拉伸,所以建议从 events 的第一个 type 为 0 的事件里读取,而不是硬编码。mimeType 请求 h264 后,浏览器可能不支持而静默回退到 vp9,不报错,但文件后缀是 mp4、实际编码是 vp9,交付前必须用 ffprobe 验证。compressIdle 是空闲压缩配置,threshold 单位是毫秒,ratio 表示把超过 threshold 的空闲段压缩为原来的 1/20,这个参数对长会话录制尤其重要。

跑一下看看效果:

node convert.js ./events.json ./output/result.mp4

跑完用 ffprobe 验证实际编码:

ffprobe -v error -show_entries stream=codec_name,width,height,r_frame_rate -of json ./output/result.mp4

这条命令能看到三个关键信息:codec_name 是实际视频编码,width 和 height 是实际分辨率,r_frame_rate 是实际帧率。如果 codec_name 不是 h264 而是 vp9,说明 mimeType 回退了,后续要决定是接受 webm 产物还是用 ffmpeg 再转一次。r_frame_rate 如果显示 30/1 而实际播放卡顿,再查 canvas 的重绘日志。

3.3 批量转换与产物校验:从 demo 走向生产

真实场景是几十甚至几百个 JSON 文件要一起转。写一个 bash 循环:

for f in data/events_*.json; do name=$(basename "$f" .json) node convert.js "$f" "output/${name}.mp4" || echo "FAIL ${name}" >> batch.log ffprobe -v error -select_streams v:0 -show_entries stream=codec_name -of csv=p=0 "output/${name}.mp4" done

循环逻辑很直白:遍历 data 目录下所有 events 开头的 JSON 文件,逐个转换,转换失败记入 batch.log,成功则用 ffprobe 打印视频编码。批量处理时要把日志落盘,不要只打到终端,几百个文件跑下来终端缓冲会被撑爆,而且失败记录容易丢。

这里容易踩一个性能坑:循环里每次执行 node convert.js 都会重新拉起一个浏览器实例,对几百个文件来说时间会翻很多倍。建议在 Node 脚本里循环处理同一个浏览器实例,只在 pages 间切换,或者用并发队列控制同时最多跑 4 个转换任务。无头浏览器的内存占用通常在 300 MB 左右,并发开多了会直接 OOM,不是 CPU 不够的问题。

另一个坑是失败重试。rrweb 数据偶发有损坏的 JSON,JSON.parse 直接抛异常,脚本退出。批量转换前先做一次格式校验,常见的做法是跑一个 node -e 脚本把所有文件先 JSON.parse 一遍,过滤出失败名单,再进入正式转换流程,避免转了一半才发现第 37 个文件是坏的。这一步五分钟能省下后面半小时的返工。

4. 录制参数边界:帧率、编码和时长对齐该设多少

转换产物的质量不是越高越好,而是要和事件频率、存储成本、后续处理折中。视频参数设得不对,最常见的结果是文件巨大但画面内容稀疏,或者文件很小但关键操作糊成一片。参数选型要围绕「场景是客服质检还是用户行为分析」来定,而不是照搬默认值。

4.1 影响产物质量的关键参数速查

参数推荐值边界与影响
width / height与 Meta 事件一致不一致会拉伸或留边,语音回放场景建议锁 16:9
fps30低于 15 动画卡顿,高于 60 对 canvas 重绘压力大
videoBitsPerSecond3-6 Mbps纯文本界面 2M 够,动态图表需要 6M+
mimeTypevideo/webm;codecs=h264 优先Chromium 对 h264 支持不稳定,需要 ffprobe 回验
compressIdle.threshold3000-5000ms低于 2s 会误压缩打字停顿,高于 10s 压缩效果差
waitUntilnetworkidle0网络慢时 DOM 未就绪,快照会变形

参数说明:width 和 height 不建议按播放平台尺寸硬设,而应该读取 events 第一个 meta 事件的 data.width 和 data.height。fps 设 30 是大多数视频平台的基线,客服场景不需要 60fps,因为鼠标轨迹和界面变化在 30fps 下已经完整;数据可视化动画多的场景再上 60。videoBitsPerSecond 是码率上限,不是目标码率,纯文本界面设 2Mbps 足够,页面里如果有地图或实时图表,至少 6Mbps,否则拖动时会出现马赛克。

mimeType 值得单独说:Chromium 系浏览器对 video/webm;codecs=h264 的支持不稳定,很多版本会直接回退到 vp9,不报错、不警告。如果你交付对象要求 mp4,脚本里要做两层准备,第一层请求 h264,第二层用 ffprobe 检测实际编码,不对就自动走 ffmpeg 转换。不要在代码里写死 mp4 就以为产物是 h264,这事我翻过车。

4.2 时长对齐与空闲压缩策略

时长对齐是 rrweb-to-video 里最影响交付质量的指标。rrweb 的 events 里时间戳是绝对毫秒值,但有些录制端会把一段空闲期的最后一条事件时间戳一直往后推,造成事件流时间轴和实际操作时间严重偏离。转成视频前必须重新计算相邻事件差值,而不是直接拿最后一条事件的时间戳当总时长。

let lastActive = events[0].timestamp; const compressed = []; for (const ev of events) { const gap = ev.timestamp - lastActive; if (gap > config.threshold) { const fakeGap = Math.max(50, Math.round(gap / config.ratio)); const prev = compressed[compressed.length - 1]; compressed.push({ ...prev, timestamp: prev.timestamp + fakeGap }); } compressed.push(ev); lastActive = ev.timestamp; }

代码逻辑:遍历所有事件,遇到超过 threshold 的空闲段,就插入一条虚拟事件,把时间轴推进 compressIdle.ratio 分之一。这样用户离开 10 分钟,视频里只剩 30 秒,但画面停留状态还在。fakeGap 最小值设为 50ms,是为了避免压缩后时间倒序或相邻事件时间戳完全相等,这两个问题都会让回放引擎报错。

坑点在于空闲压缩不是对每条事件做等比例缩放,而是只压缩「没有事件发生的区间」。用户打字时每击键之间可能只有 300ms,这个 gap 小于 threshold,不会被压缩;用户离开去开会 5 分钟,这个 gap 才会被处理。所以 threshold 设置非常关键,低于 2000ms 会把正常思考停顿也压掉,回放看起来像快进;高于 10000ms 则压缩效果微弱。

另一个隐蔽问题:鼠标移动事件在 rrweb 里是高频的,每几十毫秒一条。如果你简单压缩大数据段,这段内的鼠标轨迹也会被压缩甚至丢失。常见做法是把空闲段的压缩只应用在最后一个鼠标移动事件之后,或者干脆把整个会话的鼠标轨迹做降采样,只保留关键转折点。对客服质检来说,鼠标轨迹丢失不可接受,建议 threshold 保持到 3000ms 以上,让轨迹尽量完整。

4.3 音画同步与移动端兼容性的取舍

rrweb-to-video 多数场景是无声的,但如果录制端同时采集了麦克风或 tab 音频,事件流会包含音频数据。音画同步依赖的不是 MediaRecorder 自动处理,而是录音时间基和回放时间基一致。麦克风采集用的是 AudioContext.currentTime,回放框架用的是事件时间戳,两者各自漂移,录制超过 5 分钟后误差会到几百毫秒。

常见做法是录完后用 ffmpeg 对齐音轨:

ffmpeg -i source.webm -i audio.wav \ -filter_complex "[0:v]setpts=PTS-STARTPTS[v];[1:a]adelay=800|800[a]" \ -map "[v]" -map "[a]" -c:v libx264 -pix_fmt yuv420p output.mp4

这里不太建议把音频延迟写死。正确顺序是先用 ffprobe 看两个文件各自的起始时间,再计算偏移;我一般用 ffmpeg 的 -fflags +genpts 重新生成时间戳,再用同步音轨的方式保证对齐。如果你对音画同步要求不高,比如只做质检回放,音轨偏差在 1 秒内可接受,直接去掉音频更省事。

移动端是另一层问题。Safari 的 MediaRecorder 对 mp4 封装支持很弱,h264 输出基本不可用,产物大概率是 webm 或直接报 NotSupportedError。如果你的转换服务跑在服务端,移动端兼容性不影响;但如果用户直接在自己手机上跑转换脚本,建议尽早提示 iOS 走服务端转换链路。这个限制不是参数能绕过去的,是底层编码器的差异。

5. 转换避坑:rrweb-to-video 常见的四个真实踩坑

这部分每一段都是我在实际转换 rrweb 数据时踩过并确认原因的问题。现象、原因、解决三步写清楚,你可以对照自己的日志逐条排查。

5.1 导出的视频有声音没画面

现象:视频文件时长正常,有音轨,但画面全程黑屏或只有第一帧画面。

原因:canvas 没有持续重绘,captureStream 拿不到新帧,MediaRecorder 录成了黑帧。这在「用户停留在页面两分钟没操作」的长尾会话里最常见,因为事件流里没有新事件触发 canvas 绘制,浏览器就不产生新帧。

解决:在回放循环里加一个固定间隔的绘制函数,不管有没有新事件,都重绘当前帧:

setInterval(() => { renderCurrentFrame(); }, 1000 / fps);

加上后验证方式很简单:把视频某一帧导出为图片,如果画面内容和最后停留状态一致,说明修复生效。这个坑是 z-index 的布局变化导致的,canvas 被覆盖后 captureStream 依然输出图层内容,所以也可能是画布本身绘制异常,用 setInterval 强制重绘能同时排查两种情况。

5.2 视频时长和实际操作时间差出一大截

现象:用户实际操作 5 分钟,转出来视频 2 小时;或者反过来,用户操作 2 小时,转出来只有 3 分钟。

原因:直接把事件流里的绝对时间戳当视频时间轴用。rrweb 里增量事件的 timeOffset 是相对字段,某些封装版本里还混用了 Unix 毫秒时间戳和相对时间,两者一起算会把时间轴拉成一个随机数。

解决:统一用相邻事件的时间戳差值计算视频总时长,不要信任任何单条事件的绝对时间戳。脚本里先把所有 events 按 timestamp 排序,清除掉所有 timestamp 倒序的事件,再去掉 idle 段。排序这一步必须在转码前做,因为录制端有时会因为网络重连导致事件乱序,不排序会让时间轴来回跳动。

5.3 iframe 和跨域资源在产物里凭空消失

现象:在浏览器里回放 rrweb 数据完全正常,但转成视频后 iframe 区域白屏,远程字体图标缺失,个别图片显示为裂图。

原因:rrweb 录制时默认不录制跨域 iframe 的内容,要显式配置 recordCrossOriginIframe 才行。转换环境里,原 iframe 指向的第三方服务可能已经反爬、改版或挂了,回放时拿不到内容,转出来自然是空白的。

解决:两个层面处理。录制端开启 recordCrossOriginIframe,转换端设 inlineAssets: true,把 CSS、字体、图片都内联成 data URL。如果原数据里本来就没有 iframe 内容,转换端补不出来,不要浪费时间,直接退回到录屏兜底方案。这里诚实说,rrweb 对跨域 iframe 的录制支持一直是弱项,依赖它做核心证据链有一定风险。

5.4 转 mp4 后时长对不上或颜色偏绿

现象:webm 产物播放正常,ffmpeg 转 mp4 后时长少了最后几秒,画面颜色发绿或偏紫。

原因:webm 是 vp9 编码,ffmpeg 直接转 h264 时,没有指定像素格式,默认产出 yuv444p,而很多播放器只支持 yuv420p,颜色就不对。时长丢失是因为源文件最后一段没有关键帧,转码器丢弃了不可解码的部分。

解决:转码时显式指定像素格式和参数:

ffmpeg -fflags +genpts -i source.webm \ -c:v libx264 -pix_fmt yuv420p -movflags +faststart \ output.mp4

这组参数里,-fflags +genpts 强制重新生成时间戳,-pix_fmt yuv420p 解决颜色问题,-movflags +faststart 让 mp4 可以在线播放,不加载完就能拖动进度条。转完再用 ffprobe 验证 codec_name 是 h264,时长误差在 500ms 内,这两项都过了再进交付流程。

6. 进阶:把转码封装成支持空闲压缩的 HTTP 小服务

在生产环境里,events 数据通常存在对象存储或数据库里,转换是异步任务,直接把 node 脚本发给同事跑,总有人改参数、改输出路径,最后产物对不上。我的做法是把它包成一个 Express 服务,输入为 events 与配置,输出为任务 ID,后台转换完成后回调。这样参数校验、日志收集、失败重试都收在一个地方。

const express = require('express'); const { toVideo } = require('rrweb-to-video'); const app = express(); app.use(express.json({ limit: '100mb' })); app.post('/convert', async (req, res) => { const { events, output = 'out.mp4', fps = 30, idleThreshold = 5000 } = req.body; const jobId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); setTimeout(() => convertJob(jobId, { events, output, fps, idleThreshold }), 0); res.json({ jobId }); }); async function convertJob(jobId, cfg) { await toVideo({ events: cfg.events, output: `jobs/${cfg.output}`, fps: cfg.fps, compressIdle: { threshold: cfg.idleThreshold, ratio: 20 }, }); } app.listen(3000);

逻辑说明:接口接收 events 数组和三个配置项,用时间戳加随机串生成 jobId,setTimeout 0 让接口立即返回任务 ID,实际转换在后台执行。这样前端只需要轮询一个查询接口,就能拿到转换状态,不会因为长时间转换把 HTTP 连接挂死。

参数校验建议加在接口层:检查 events 是数组、第一个事件 type 为 0、所有 timestamp 都是数字。这三项过了基本能排除大多数脏数据。额外建议把 mimeType 固定成 h264,并在 convertJob 里加一次 ffprobe 验证,失败就自动重试一次;重试仍失败就把 jobId 和错误信息写入失败日志,人肉排查时不用去翻 node 进程日志。

从那以后,我每次批量导视频都强制走一遍:先校验 events 格式,再检验产物编码是不是真的 h264,最后在样本上做一次空闲压缩验证。别怕多花这几分钟,比起几百个文件交付后被打回,这几分钟是我买过最值的后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询