ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

rrweb回放数据转MP4:从事件流到视频的工程实践指南

rrweb回放数据转MP4:从事件流到视频的工程实践指南 简介rrweb-to-video 是一份面向前端开发者的实用工具资源核心目标是将 rrweb 录制得到的 JSON 数据转换为视频文件。由于回放过程依赖图片、样式表等静态资源而项目持续迭代会使资源哈希变化甚至被删除录屏就会失真或无法加载因此转换成视频可长期存档与查看。压缩包共 11 个文件其中 6 个脚本分别处理转换入口、本地服务与打包构建2 个配置文件保存依赖与参数另有 1 个页面用于在线回放、1 份说明文档及忽略文件整体仅 47KB结构简洁清晰。目前已有 2574 人浏览学习。借助示例脚本与完整源码学习者可以复现数据解析、静态资源整合、FFmpeg 调用等关键流程理解如何把浏览器操作录制结果固化为视频同时也能基于此扩展批量转换、定时归档等自动化能力。1. rrweb-to-video 到底是干什么的回放数据不是视频少走一步就卡在交付上做用户行为回放、自动化测试留证、异常会话审计手头最常碰到的就是一堆 rrweb 原始数据——一串带时间戳的 JSON 事件流能在网页里精准还原点击、滚动、输入和页面变化。但当你需要把它交给产品、运营或者法务时问题立刻出现对方要的是一个 mp4而不是一个需要打开播放器 SDK 才能看的回放脚本。rrweb-to-video 承担的就是这个转换环节把 rrweb 原始数据重新播放一遍、逐帧截图、再编码成真正可交付的视频文件。这条链路看似只是“把回放录下来”实际涉及事件时序、无头浏览器渲染、帧率控制和编码兼容性适合被“数据有了、视频拿不出来”这类需求卡住的工程师。不需要让非技术同事装任何插件也不需要手工开录屏软件在旁边守着。2. rrweb 原始数据为什么不能当视频用从事件流结构到转换原理先说结论rrweb 原始数据本质上是一份“行为描述”它需要解释器在浏览器环境里重新执行才能变成你眼睛看到的画面。直接把 JSON 扔给视频播放器播放器根本不知道“用户在这个页面上点过什么”意味着什么画面。下面把数据结构和转换逻辑拆开看。2.1 事件流的三层信息快照、增量、交互一份典型的 rrweb 原始数据是一个按时间排列的事件数组。我一般会先把它解析成一个 JavaScript 对象数组每一条都包含timestamp、type和data三段基础信息。直观长这样[ { type: 0, timestamp: 1621234567890, data: { node: { tagName: html } } }, { type: 1, timestamp: 1621234568000, delay: 110, data: { adds: [], removes: [], texts: [] } } ]说明第一条type为 0 表示完整快照记录的是页面初始 DOM 树回放器靠它渲染出第一帧第二条type为 1 表示增量快照记录的是后续 DOM 的增删改delay字段告诉回放器“这条事件距离前一条过去了多少毫秒”。真实项目里type枚举可能因 rrweb 版本略有差异判断类型别硬编码数字优先用rrweb/types导出的枚举或者直接看data里的字段形态。除了快照和增量事件流里还混着鼠标移动、滚动、输入、视口尺寸变化等交互记录。这些交互事件没有独立的画面内容却决定了播放时鼠标箭头的位置、滚动条状态和输入框内容。正是这些信息让一段数据在回放时“像真的有人在操作”。2.2 转换本质行为描述变成图像流再变成视频流视频的本质是连续的帧图像。要把 rrweb 原始数据变成视频中间必须架一座桥先把行为描述还原成网页画面再把画面按固定时间间隔截下来最后把帧序列交给编码器。我习惯把这条链路分成三层看待。层级职责常见承载数据解析层读取事件流、推进时间轴、触发渲染rrweb-player / 自研回放控制器渲染层把 DOM 变化画成真实页面Chromium 无头浏览器、iframe编码层把帧序列压缩成 mp4ffmpeg / x264选型逻辑很直接渲染层一定得是真实浏览器。因为 rrweb 的增量快照记录的是 DOM 变化页面的样式、布局、CSS 动画、Canvas 内容、Web Font 字体全部依赖浏览器引擎去解析和绘制。自己在 Node 里拿 jsdom 或者纯 Canvas 模拟遇到复杂页面时还原度极低属于典型的“看着能做、做起来就翻车”的方向不推荐碰。2.3 为什么不用纯 Canvas 或服务端渲染硬画有人会问既然要截图能不能在服务端直接调用某个渲染函数把事件流画到 Canvas 上然后读像素试过你就知道这条路的坑集中在两处。第一rrweb 记录的是 DOM 变化不是绘制指令任何脱离浏览器的渲染方案都得自己实现一套 CSS 布局引擎、盒模型、事件计算工程量约等于重写一个浏览器。第二Canvas 截图拿到的只是位图页面里的富文本、复杂 SVG、视频标签、WebGL 内容普通 canvas 2D 接口根本画不出来。常见做法是直接起一个无头 Chromium加载 rrweb-player把数据喂进去让它自己控制播放。所以你在各种 rrweb-to-video 方案里看到的流程基本都是同一个骨架浏览器打开回放页 → 按时间轴推进 → 定时截图 → ffmpeg 合成。理解了这一点后面调参数、排问题才有方向。3. 用 rrweb-to-video 跑通最小转换流程环境准备与两条实现路线当你手头有了一份 rrweb 原始数据最先要做的不是写转换脚本而是把环境备齐。下面按我实际操作的顺序讲。3.1 环境准备Node、无头浏览器、ffmpeg 三件套转换管线依赖三个组件Node.js 跑封装脚本、无头 Chromium 渲染页面、ffmpeg 做编码合成。缺一个流程就会断在某一环。先检查系统里有没有装node -v ffmpeg -version npx playwright --version说明node -v确认运行时版本一般 Node 16 以上就够用ffmpeg -version确认编码器存在npx playwright --version会顺带触发 Playwright 的 CLI 安装。如果 ffmpeg 不在macOS 上可以用brew install ffmpegUbuntu/Debian 上用sudo apt install ffmpeg。Playwright 的浏览器内核需要单独下载执行npx playwright install chromium这一步会把约 150MB 的 Chromium 拉进缓存目录后续无头渲染全部依赖它。提示如果公司内网有代理限制playwright install下载失败可以设置PLAYWRIGHT_DOWNLOAD_HOST指向内网镜像这个环境变量在官方文档里有说明。3.2 路线 A用 Playwright 加载 rrweb-player 逐帧截图这是最直接的实现方式。思路是先把 rrweb 原始数据注入一个内嵌回放器的页面然后按固定帧率循环推进时间轴、逐帧截图。下面是一个最小可跑的 Node 脚本骨架const { chromium } require(playwright); const fs require(fs); (async () { const events JSON.parse(fs.readFileSync(session.json, utf8)); const fps 10; // 输出视频帧率10fps 足够看交互过程 const totalMs events[events.length - 1].timestamp - events[0].timestamp; const browser await chromium.launch({ args: [--no-sandbox, --disable-gpu] // 服务器环境常需要这两个参数 }); const page await browser.newPage({ viewport: { width: 1280, height: 800 } // 视口必须与录制时一致否则坐标会漂移 }); // 打开一个本地回放页面页面里嵌入 rrweb-player 的 bundle await page.goto(file:///path/to/player.html); await page.evaluate((evts) { window.__RRWEB_EVENTS__ evts; // 把原始数据交给页面全局变量 }, events); await page.waitForFunction(() window.__REPLAY_READY__ true, null, { timeout: 15000 }); const frameCount Math.ceil((totalMs / 1000) * fps); const stepMs 1000 / fps; fs.mkdirSync(frames, { recursive: true }); for (let i 0; i frameCount; i) { const currentTime i * stepMs; await page.evaluate((ms) window.__REPLAY_SEEK__(ms), currentTime); await page.waitForTimeout(50); // 给浏览器一点渲染缓冲 await page.screenshot({ path: frames/frame_${String(i).padStart(5, 0)}.png }); } await browser.close(); })();逻辑说明脚本先读取原始数据计算出整段会话的时间跨度然后按1000 / fps毫秒的步长推进回放每推进一步就截一张 PNG。__REPLAY_READY__和__REPLAY_SEEK__是你在自研播放器壳子里约定的两个全局接口前者表示首帧已渲染完成后者接收毫秒时间戳并定位到对应事件位置。参数说明里最值得留意的是viewport。rrweb 的鼠标坐标和滚动位置都以录制时视口为基准如果无头浏览器视口和录制环境不一致转换出来的视频里鼠标轨迹会明显偏移这个后面避坑章节会细讲。fps我通常设为 10交互类数据不需要 30 帧10 帧足够看清点击和滚动同时能明显缩短截图耗时和产物体积。--disable-gpu是因为服务器大多没有 GPU开硬件加速反而容易黑屏。3.3 路线 B封装成一条命令直接产出 mp4逐帧截图只是半成品离“把原始数据转成视频”还差一步编码。我不建议每次手动敲两遍命令常见做法是把截图和编码封装进一个 Node 脚本对外只暴露入口函数。下面是我常用的封装思路#!/usr/bin/env node const { chromium } require(playwright); const { execSync } require(child_process); const fs require(fs); async function rrwebToVideo(inputPath, outputPath, opts {}) { const fps opts.fps || 10; const crf opts.crf || 20; const events JSON.parse(fs.readFileSync(inputPath, utf8)); if (!Array.isArray(events) || events.length 0) { throw new Error(不是合法的 rrweb 原始数据: ${inputPath}); } // 中间省略复用 3.2 的截图循环把帧输出到 frames/ 目录 // await buildFrames(events, opts); execSync( ffmpeg -y -framerate ${fps} -i frames/frame_%05d.png -c:v libx264 -crf ${crf} -pix_fmt yuv420p ${outputPath}, { stdio: inherit } ); console.log(转换完成: ${outputPath}); } rrwebToVideo(session.json, output.mp4, { fps: 10, crf: 20 });逻辑说明这个封装把“读数据、截帧、编码”三步收敛成一个函数调用方只需要关心输入 JSON、输出 MP4 和两个核心参数。截图循环没有重复贴一遍实际项目里应该把它单独抽成buildFrames(events, opts)便于测试和复用。参数说明里crf是 x264 的质量因子越小画质越高、体积越大18 到 23 是日常比较舒服的区间。fps这里的数值必须和截图循环里的fps保持一致否则生成出来的视频时长会被 ffmpeg 按帧率重新解释动作会变快或变慢两处参数不一致是我见过的高频失误。3.4 原始数据的校验与时间跨度预估实际拿到手的 rrweb 数据不一定干净尤其是从埋点平台导出的会话可能存在事件缺失或首尾时间戳异常。转换前最好先跑一个快速校验避免等截图截到一半才发现数据有问题。我一般用三段命令做基础体检node -e const erequire(./session.json); console.log(事件数:, e.length); console.log(开始:, new Date(e[0].timestamp).toISOString()); console.log(结束:, new Date(e[e.length-1].timestamp).toISOString());node -e直接执行内联脚本输出事件总数和首尾时间可以快速判断数据是否完整。如果结束时间早于开始时间或者事件数只有个位数那基本是采集端没存全别急着转视频先回去查埋点链路。按合理倍速回放这段数据的视频时长大约是(结束时间 - 开始时间) / 1000秒乘以帧率就是需要截的帧数提前算好可以避免半路磁盘写满。4. 从帧序列到视频ffmpeg 合成参数与画质控制截图序列一旦生成转换的重点就从“怎么还原”变成“怎么编码”。这一章说清楚 ffmpeg 合成的几个关键参数以及什么场景该用什么取值。4.1 合成命令帧率、分辨率、编码器的取舍ffmpeg 合成帧序列是最成熟的做法命令本身不复杂复杂的是参数选择。ffmpeg -y \ -framerate 10 \ -i frames/frame_%05d.png \ -vf scale1280:800,fps10 \ -c:v libx264 \ -preset medium \ -crf 20 \ -pix_fmt yuv420p \ -movflags faststart \ output.mp4逻辑说明-framerate 10告诉 ffmpeg 输入帧序列的播放速度-i frames/frame_%05d.png匹配 5 位零填充的帧文件名-vf里scale把画面统一到 1280x800fps保证输出帧率稳定-c:v libx264选 H.264 编码器兼容性最好-pix_fmt yuv420p是播放器兼容的关键不设置的话可能得到 4:4:4 色彩采样在部分播放器里无法解码。参数说明里需要关注的是-crf和-preset的配合。CRF 是质量因子同样条件下 18 比 28 质量高preset 决定编码速度和体积的平衡medium是默认值veryfast适合大批量处理、画质略降slow画质好但耗时可能翻倍。生产环境我是这样选的场景帧率CRF预设说明内部回归留证1020medium体积和画质都适中对外交付演示15-3018slow画质优先帧率更流畅审计归档1023veryfast长会话压缩快体积可控快速排查528ultrafast只看大致操作过程这套取值不是绝对的但方向是对的面向人的视频压得更精细面向机器/存储的压得更经济。4.2 音轨问题rrweb 数据里没有声音很多人第一次跑通转换拿到一段“无声电影”才意识到rrweb 原始数据只记录 DOM 和交互不录麦克风、不录系统声音。如果交付物需要含音轨比如客服对话回放需要单独用 WebRTC 或系统录音采集音轨存成 wav 或 m4a再用 ffmpeg 混流ffmpeg -y \ -i video.mp4 \ -i audio.m4a \ -c:v copy \ -c:a aac \ -shortest \ video_with_audio.mp4逻辑说明-c:v copy直接复制视频流不重新编码速度快-c:a aac把音频转成 mp4 容器兼容的 AAC-shortest让输出在视频或音频较短的那条结束时切断防止音画时间不一致。混流之前务必确认音轨时长和视频时长差多少差距太大就说明你录屏时的会话时长和导出数据的跨度对不上这种情况下不是音轨问题而是回放速度设置错了。注意如果只转不出声才是正常现象不要怀疑编码器坏了。需要声音时音频必须单独采集。4.3 容器兼容性为什么总要加faststart和yuv420p输出 mp4 之前有两个参数我几乎每次都会带上。-movflags faststart会把 moov 元数据块从文件尾部移到头部浏览器和手机播放器可以直接开始拖进度条不用等整个文件下载完。-pix_fmt yuv420p则是为了保证兼容性很多播放器不支持 yuv444 或 rgb 像素格式转成 yuv420p 后基本在所有平台都能流畅播放。这两步不加你交付出去的 mp4 在自己电脑上能播发到微信或上传网盘后对方手机播放器可能直接报无法解码。5. rrweb-to-video 避坑指南时序错乱、样式丢失和黑屏的高发问题转换流程跑通之后接下来就是和各种偶发问题缠斗。这几条都是实操里反复踩过的坑按“现象 → 原因 → 解决”的顺序列出来。5.1 视频开头几秒黑屏后面画面才出来现象转换出来的 mp4 前 1 到 2 秒是纯黑从回放中途才开始显示内容。 原因截图循环启动太快rrweb-player 还没完成首帧渲染就被screenshot截走了。完整快照包含整棵 DOM 树在低配服务器上解析可能要几百毫秒脚本里虽然调用了waitForFunction但那个只等到了 JS 变量就绪不等渲染完成。 解决在__REPLAY_READY__触发后额外加一个固定延时或者轮询检查某个关键 DOM 节点是否已渲染。我习惯做法是等待page.waitForTimeout(500)再开始第一帧截图另外手动把回放进度推进到 0 并强制触发一次 reflow首帧就不会再黑。5.2 视频中途出现画面停顿或掉帧现象看整体时长和事件时间戳对得上但某几秒画面像卡住一样鼠标箭头动但页面没变化。 原因无头浏览器的渲染线程被截图操作阻塞。page.screenshot是同步截取整个视口截图瞬间如果页面有持续动画或滚动事件帧内容可能被丢弃而waitForTimeout(50)又不保证渲染线程完成一次合成。 解决不要用定时器死等改用requestAnimationFrame或者等page.waitForLoadState(networkidle)这类事件驱动机制。截图间隔推荐不小于 100ms即 fps 最多 10如果必须更高帧率考虑用 Playwright 的录屏 API 替代逐帧截图它是浏览器底层录制不会丢帧。5.3 页面字体变成方块或空白区域现象视频里文字区域出现豆腐块或者背景有段落但文字渲染不出来。 原因无头浏览器默认不加载网页里的 Web Font。rrweb 数据里只记录文字内容不记录字体文件二进制。录制页面如果用的是 Google Fonts 或公司自建 CDN 字体无头浏览器在断网或环境变量未配置时直接跳过字体加载最终渲染成 fallback 字体甚至占位符。 解决截图前先page.goto到原站点让字体缓存加载一遍再打开回放页。或者更简单粗暴在回放开始前把系统字体安装齐全并把waitUntil: networkidle传给goto确保字体请求全部结束再开始截图。5.4 视频播放时长和会话实际时长对不上现象一次 5 分钟的会话转出来视频只有 1 分钟或者拉长到 20 分钟。 原因忽略了对delay字段的处理。部分 rrweb 增量事件自带延迟时间回放器以此控制事件推进节奏而你用事件时间戳首尾相减作为总时长中间如果存在跨标签页的长时间无操作按时间戳算和按事件 delay 累加算的结果会差很多。 解决以事件数组里的delay累加值作为总时长而不是直接用最后一个事件的timestamp。如果你沿用 3.2 脚本里的时间戳差值碰到用户切后台的会话时视频时长会被无操作时间“灌水”大量静帧内容白白浪费存储。5.5 鼠标轨迹和滚动条位置整体偏移现象鼠标箭头悬空点击位置落在按钮上方几像素处或者滚轮没生效。 原因录制页面和无头浏览器的 viewport 尺寸不一致。rrweb 记录的鼠标坐标是相对视口左上角的像素位置如果录制时屏幕是 1440x900转换时无头浏览器窗口是 1280x800两个坐标系叠加后所有指针事件全部平移看起来就像鼠标在“自己飘”。 解决转换前先确认录制端的window.innerWidth和innerHeight渲染视口必须一比一还原。无头浏览器里browser.newPage({ viewport })的宽度高度要和录制环境完全一致一个像素都不能差。6. 验证与进阶把转出来的视频当自动化测试资产用视频转出来不等于工作收尾我还会做一次验证这个习惯帮我挡掉过多次交付后被打回的局面。最简单的验证手段是检查时长和关键帧ffprobe -v error -show_entries formatduration -of csvp0 output.mp4把 ffprobe 输出的时长和原始数据的时间跨度放在一起比如果差超过 5%说明前面第 5.4 条的坑踩到了需要检查事件流里的 delay 是否完整。更进一步我会用 ffmpeg 抽第 50、100、150 帧图像人工扫一眼页面状态是否符合预期这比完整看一遍视频快得多。进阶用法里我比较推荐的是“只截关键帧”不用每帧都编码。对审计存档或测试留证来说用户点击、输入、滚动这三个动作节点值得保留其余静止画面可以丢弃。做法是监听 rrweb 的交互事件把每个交互时间点前后各 1 秒的帧保留最终合成一个 5 到 10 倍速的“关键动作集”视频体积能从上百 MB 压到几 MB。这个方向特别适合数据量大、但实际需要人工看的内容没多少的场景。最后一件事是我自己的血泪经验转换脚本一定要加日志和退出码。无论你是调execSync还是自己写截图模块ffmpeg 返回非零退出码时脚本必须抛异常并保留现场帧目录否则 CI 里转视频失败会静默通过下游拿到的是上一个成功历史产物最后追查起来非常痛苦。把日志打到标准输出把产物按日期归档再在 CI 里对“转换失败即构建失败”做硬校验这工具才算真正能交给团队用。希望帮到你。本文还有配套的精品资源点击获取
返回列表