00三十秒结论
如果你的问题是「这两个该怎么选」,答案往往被包装成性能对比。但读完 Playwright 的实现会发现:
两者调用的是同一个 CDP 命令,连 quality 默认值都一样(90)。
真正的差别只有一个 —— 帧拿到手之后,谁来把它变成文件。
| 判断 | 依据 | 证据 |
|---|---|---|
| 采集端同源 | recordVideo 内部就是 ScreencastClient 的一个客户端,调用 Page.startScreencast;format: "jpeg"、quality ?? 90。 |
源码 |
| fps 只是配额 | 传 fps 只改两处:maxFramesInFlight(5 或 10)和 ffmpeg 的 -r。声明 90 时,90 帧里有 31 帧是 ffmpeg 复制出来的。 |
源码 实测 |
| 不抢主线程 | 注入 rAF 探针测真实渲染帧率:不录制 116.85 / 录 60fps 116.45 / 录 90fps 116.67 / CDP 116.80,差异 < 0.5%。 | 实测 |
| 按镜头分工 | 1× 常规镜头用 fps:60(唯一 47.8,降到 30fps 交付够用);2× 以上慢放用 CDP(慢放后 27.8 vs 25.0)。 |
实测 |
因为选型的成本差在「工程」而不在「性能」:recordVideo 是 20 行配置换一个 webm,CDP 是 20 行协议换 900 张 JPEG + 一份 meta.json。 性能上 CDP 全面占优,但它要求你自己管帧号、时间戳和后续编码 —— 这部分的代价,只有在你真的需要慢放或逐帧对齐时才值得付。
01同源,在第四步分叉
把两条链路画在一起,交叉点非常清楚:上半和下半在 Chromium 出帧 之前完全重叠。 这个结构决定了后面所有结论 —— 既然是同一个水源,那产能上限就由水源决定,下游只能决定「怎么装瓶」。
这个分叉在源码里只有一个入口可以确认 —— ScreencastClient.addClient()。
Playwright 的 VideoRecorder 把 onFrame 回调注册进去,从此每一帧都被同步转交给 ffmpeg 的 stdin。
也就是说,recordVideo 并不是「另一个采集实现」,它是 CDP 采集的一个消费者。
// playwright-core/src/server/videoRecorder.ts(1.64.1,压缩后变量名已还原)
const ffmpegPath = registry.findExecutable("ffmpeg").executablePathOrDie();
this._client = {
onFrame: (frame) => this._videoRecorder.writeFrame(frame.buffer, frame.frameSwapWallTime),
size: options.size,
fps: options.fps // ← 只被用于 maxFramesInFlight 与 ffmpeg 的 -r
};
const { size } = this._screencast.addClient(this._client);
this._videoRecorder = new FfmpegVideoRecorder(ffmpegPath, videoSize, options.fps ?? kDefaultFps, outputFile);
// kDefaultFps = 25 —— 这就是那个「不报错、只是默默给你 25fps」的默认值
02安装:三个二进制,一个被钉死的版本
这一步最容易出问题的原因,是它不由你决定。你在 package.json 里写的是
playwright@1.64.1,但这个版本要配哪个 Chromium、哪个 ffmpeg,是
playwright-core/browsers.json 里钉死的 revision。升级 Playwright 而没重跑安装,
就会出现「代码是新的、二进制是旧的」—— 报错信息还只说文件不存在,不说版本不匹配。
browsers.json 决定;三个二进制各自独立下载、独立打完成标记。
注意 ffmpeg 只有 2.9 MB —— 它不是完整版 ffmpeg,只带 image2pipe → VP9 这条最窄的能力。
·三条命令,差别在「装了哪些」
| 命令 | 实际下载 | 够不够录 |
|---|---|---|
npm i -D playwright@1.64.1 |
只有 JS 包。不下载任何浏览器(1.64.1 的 package.json 已无 install 脚本)。 | 不能 |
npx playwright install chromium |
Chromium 1248 + headless shell + ffmpeg 1013(实测 dry-run:这个组合里 ffmpeg 是连带项)。 | 能 |
npx playwright install |
5 项全装:chromium、shell、firefox、webkit、ffmpeg。约 1.2 GB。 | 能 |
npx playwright install ffmpeg |
只补 ffmpeg(2.9 MB)。已有 Chromium 时最省的一刀。 | 仅补齐 |
我一度以为原因是「install chromium 不含 ffmpeg」。实测 --dry-run 后否掉了这个假设:
1.64.1 里 ffmpeg 是 chromium 安装组的连带项,会一起下。
真正的失败原因是:升级 Playwright 时只跑完了 Chromium 那一段,ffmpeg 的下载被中断,
目录存在但没有 INSTALLATION_COMPLETE,于是 recordVideo 在启动 ffmpeg 时才炸。
教训不是「记得装 ffmpeg」,而是「装完必须对账」。
·装完的对账三件事
对账# 1. 这个版本到底要哪些二进制、装在哪
npx playwright install --dry-run
# 2. 每个目录是否真的装完了(缺这个文件 = 半成品)
ls ~/Library/Caches/ms-playwright/*/INSTALLATION_COMPLETE
# 3. ffmpeg 能否执行(它是 recordVideo 的硬依赖)
~/Library/Caches/ms-playwright/ffmpeg-1013/ffmpeg-mac -version
# → ffmpeg version n7.0.1-playwright-build-1013
第 3 步有个容易忽略的细节:这个 ffmpeg 是 Playwright 自己编译的裁剪版,
只保留 VP9 编码这一条链路,所以其他机器上 which ffmpeg 找到的系统版不能替代它。
反过来说,走 CDP 路线时采集端完全不需要它 —— 这是两个方案在部署上的实质差异。
这次检查发现缓存里同时躺着 7 代 Chromium(1055 到 1248,单代 330–366 MB),
而当前版本只需要 1248 —— 升级过程并没有顺手清掉旧代际。
只跑 install chromium 时,其他代际也不在它的处理范围里,于是越攒越多。
回收空间:npx playwright uninstall(清掉本安装用到的)或加 --all(清所有版本),
也可以手工删掉带旧 revision 的目录。
03recordVideo 的工作原理
它做的事可以一句话说完:把一个内置的 screencast 客户端接上,把每帧 JPEG 连同自己写的时间戳封进 Matroska 管道,喂给一个常驻的 ffmpeg 进程实时压成 VP9。 下面按时序拆开,编号与图 3 对应。
-
建 context 就开始录
newContext({ recordVideo })一返回,VideoRecorder.start()就已经addClient并startScreencast了。所以录制起点早于goto, 片头一定带着加载画面 —— 实测两次相同操作,成片时长分别是 1.68s 和 1.96s,差的就是这段。 -
拉起一个常驻 ffmpeg
输入是
pipe:0(stdin),容器声明为matroska,输出libvpx-vp9。 它不是「录完再压」,而是页面动一下它就收到一帧。 -
收帧:base64 解码后的原始 JPEG
CDP 事件里的
data是 base64,Playwright 解成 Buffer 后不做任何转码, 原样往下传。所以 quality 90 的 JPEG 压缩痕迹会一路留到 VP9 里。 -
自己封 Matroska:先写集群头,再写 JPEG
这一步很反直觉 —— ffmpeg 的输入不是裸 JPEG 流,而是 Playwright 手工拼的 EBML 结构。
writeClusterHeader(timestamp, length)写入时间戳和长度,紧跟原始 JPEG 字节。 用绝对时间戳(不是等间隔)意味着容器里保留了真实的采集节奏,后面才有「重采样」的空间。 -
实时 VP9 编码,参数全为「快」让路
-deadline realtime、-cpu-used 8、-lag-in-frames 0: 不做前瞻缓存、用最快的预设。代价是压缩效率,收益是不堵管道。 -
回执,释放一个在途名额
Chromium 只有在收到
screencastFrameAck之后才会继续推后续帧, 在途上限是maxFramesInFlight。这是整条链路真正的限速器(见 §5)。 -
ctx.close()才收尾关闭 context 会触发
stdin.end(),ffmpeg 才会 flush 出完整的 WebM 索引(Cues)。 先saveAs再关 context,会拿到半截文件或报Target closed。 -
交付一个 webm
VP9 + WebM,没有音轨(参数里写死了
-an)。要 mp4 得自己转封装。
·那串 ffmpeg 参数,逐行看
1.64.1 实际下发的参数-loglevel error
-f matroska # 输入容器:Playwright 手工拼的 EBML
-fpsprobesize 0 -probesize 32 -analyzeduration 0
# 放弃探测:这三行是「别浪费时间分析流」
-i pipe:0 -y -an
-r {fps} # 输出时间基 —— 关键:不足就复制帧补齐
-c:v libvpx-vp9 -qmin 0 -qmax 50 -crf 8
-deadline realtime -cpu-used 8 -lag-in-frames 0
# 实时优先:无前瞻、最快预设
-b:v {bitrate}k -threads {threads} -tile-columns {tile}
-row-mt 1
-vf pad={w}:{h}:0:0:gray,crop={w}:{h}:0:0
# 补到偶数尺寸(yuv420 要求)
-metadata creation_time=...
{output}.webm
其中 -r 是理解后面一切的钥匙:它声明「输出必须是这个帧率」。
当实际进来的帧少于这个数,ffmpeg 的默认行为是重复上一帧,而不是丢时间。
所以文件看起来是 90fps,画面信息量却只有 59fps —— 这就是 §5 要展开的核心。
·位率、线程、切片:三个自动算出来的数
你没法直接传码率,只能传 fps 和 size,剩下三个值由公式推导
(w·h·fps / (800·450·25) 得到一个「像素率倍数」):
| 1920×1080 | 像素率倍数 | 码率 | threads | tile-columns |
|---|---|---|---|---|
| fps 25 | 5.760 | 5.76 Mbps | 2 | 1 |
| fps 60 | 13.824 | 13.82 Mbps | 4 | 2 |
| fps 90 | 20.736 | 20.74 Mbps | 6 | 3 |
这个设计很聪明也很坑:码率随 fps 同比放大,每帧分到的比特数恒定。 所以提高 fps 不会让画面更清楚,只会让文件更大、编码更吃力,而你多拿到的那些帧里有一半是重复的。 实测体积也吻合:同样 22 秒,25fps 10 MB、60fps 23 MB、90fps 34 MB。
① 不传 size → 录出来是 800×450。
源码里 screencast 尺寸在缺省时按「最长边不超过 800」等比缩小(1920×1080 → 800×450)。
实测对照:不传 size 得 800×450 / 20 KB,传 size:{1920,1080} 得 1920×1080 / 85 KB。
② 不传 fps → 恒定 25fps。
两次实测都稳定输出 r_frame_rate=25/1,不报错、不警告。
可用的最小配置const browser = await chromium.launch();
const ctx = await browser.newContext({
viewport: { width: 1920, height: 1080 },
deviceScaleFactor: 1,
recordVideo: {
dir: "raw",
size: { width: 1920, height: 1080 }, // 不传 = 800×450
fps: 60 // 不传 = 25
},
});
const page = await ctx.newPage();
// ... 操作 ...
const video = page.video();
await ctx.close(); // 必须先关,录制才收尾
await video.saveAs("raw/shot-b.webm");
await browser.close();
04CDP 的工作原理
把 Playwright 那层客户端拆掉,直接对着协议说话,就得到 CDP 路线: 发一条命令开始,每来一帧存一张 JPEG,存完回执,循环到你说停。 没有任何编码,没有任何容器,因此也没有任何隐藏的帧率换算。
最小可用采集器const cdp = await ctx.newCDPSession(page);
let n = 0;
cdp.on("Page.screencastFrame", async ({ data, sessionId, timestamp }) => {
writeFileSync(`${dir}/${String(n++).padStart(6, "0")}.jpg`, Buffer.from(data, "base64"));
await cdp.send("Page.screencastFrameAck", { sessionId }); // 不回执 = 停在 maxFramesInFlight
});
await cdp.send("Page.startScreencast", {
format: "jpeg", quality: 92,
maxWidth: 1920, maxHeight: 1080,
everyNthFrame: 1,
});
// ... 操作 ...
await cdp.send("Page.stopScreencast");
// 帧数 / 耗时 → 写进 meta.json,后面裁剪靠它换算
·三个必须自己兜住的细节
一、base64 有 33% 的体积税。 协议传的是 base64 字符串,解回来才是 JPEG。1080p / quality 92 下,903 帧实测占了 165 MB (平均 183 KB/帧),而同样 9 秒的 recordVideo 成片是 23 MB —— 约 7 倍的磁盘差。 长时间录制要盯着磁盘,或者边录边转。
二、timestamp 是唯一可信的时间基准。
帧不是等间隔到达的(合成器忙就慢),所以「第 N 帧 = 第 N/fps 秒」这个假设在 CDP 路线上完全不成立。
每场采完必须存一份 meta.json,记录总帧数、总耗时、实测 fps —— 本次各场 fps 在 60–98 之间波动,
没有这份元数据就没法把帧号映射回时间。
三、没有 ffmpeg 也能采。 CDP 采集端不依赖任何二进制,JPEG 落盘就结束了。编码是之后的事, 所以内网、CI、装不上 Playwright ffmpeg 的环境里,这条路线仍然可用。
05fps 是配额,不是产能
这是整份文档最值得记住的一节。把 fps 当成「我要每秒 90 张不同画面」是错的 ——
它实际的含义是「我要求输出流每秒有 90 个时间槽」。槽位空着怎么办?ffmpeg 复制上一帧填进去。
而真正决定槽位能不能填满的,是两处限制:一处写在源码里,一处来自 Chromium 本身。
maxFramesInFlight:fps > 30 ? 10 : 5。在途帧满了,Chromium 就暂停推。- Chromium 自己的 JPEG 编码:1080p 一帧要几毫秒,它跟合成器抢同一个进程的时间片。
注意 fps 参数在这两处只影响第一处,而且只是把窗口从 5 放到 10。
它没有任何办法让 Chromium 更快地产生画面 —— 于是第二个限制成了真正的天花板。
fps 从 30 提到 90,只是把窗口从 5 放到 10 而已。
-r 90 输出的流。
橙色那 31 格不是「补间动画」,而是把第 59 帧原样重复了 31 次 —— 播放器会如实显示,观感就是卡顿。
·三档实测:提高标称只增加重复
| 方案 | 标称 | 9s 输出帧 | 唯一帧 | 唯一帧率 | 重复率 |
|---|---|---|---|---|---|
| recordVideo | 25 | 225 | 199 | 22.1 | 11.6% |
| recordVideo | 60 | 540 | 430 | 47.8 | 20.4% |
| recordVideo | 90 | 810 | 529 | 58.8 | 34.7% |
| CDP 直采 | — | 903 | 719 | 77.9 | 20.4% |
最后一行还有一个值得注意的细节:CDP 的采集侧是 97.85 帧/秒,去重后 77.9。 这条链路根本没有编码环节,所以差的这 20 帧不可能是「编码丢帧」,而是场景里变化较慢的部分被去重算法判成了重复。 不同场景的数字不能直接横比,同一台机器上的相对关系才有意义。
recordVideo 这一侧的趋势更明确:标称值从 60 提到 90,重复率从 20% 涨到 35%。 多出来的帧全是填充物 —— 文件更大、编码更慢、画面并不会更顺。
·慢放把这个问题放大
慢放的本质是「用更少的时间采样换更长的时间展示」。2 倍慢放意味着每个源帧要撑 2 个输出帧, 所以源唯一帧率至少要翻倍。目标 30fps 交付时,2× 慢放需要 ≥ 60 唯一帧/秒的源:
要判断「这个方案够不够」,别看标称 fps,算这个:源唯一帧率 ÷ 慢放倍率 ≥ 输出帧率。 本次实测:recordVideo@60 是 47.8 ÷ 2 = 23.9,不够 30;CDP 是 77.9 ÷ 2 = 39,够。
06选型决策
前面的数据可以压缩成一棵三岔树。注意分支依据不是「哪个更快」,而是这批镜头要怎么用 —— 慢放和逐帧对齐是 CDP 唯一不可替代的两个场景。
·成本对照:性能之外的那一半
| 维度 | recordVideo | CDP 直采 |
|---|---|---|
| 产物 | 1 个 webm(VP9,无音轨) | N 张 JPEG + meta.json,编码另行 |
| 磁盘(9 秒 1080p) | 23 MB(fps:60) | 165 MB(903 张,均 183 KB) |
| 时间码 | 直接可用:-ss 12.4 就够 |
需 帧号 ÷ 实测fps;各场 fps 在 60–98 漂移 |
| 分段录制 | 每个 page 一个文件,同一页不能中途分段 | 天然按场次起停,帧号从 0 开始 |
| 依赖 | 需要 Playwright 专用 ffmpeg 二进制 | 采集端零依赖(writeFileSync 而已) |
| 最大唯一帧率 | 实测 58.8 | 实测 77.9(采集 97.85) |
本次成片 22 个镜头里,常规动作走 recordVideo,三处 2× 慢放走 CDP —— 两条链路同时存在不冲突, 因为它们只是同一个 page 上的两个消费者。真正的成本是「维护两套采集脚本」,代价约 40 行。
07工程约束:五条会咬人的规则
这些都不是「最佳实践」,而是踩过之后总结的硬约束 —— 违反任何一条,产物就是废的。
·一、关闭顺序决定文件是否完整
ctx.close() → video.saveAs() → browser.close()。
顺序颠倒会报 Target closed,或者拿到一个没有索引的 webm(能播,但时长显示为 0)。
·二、一个 page 只出一个文件
recordVideo 配在 context 上,但产物是每个 page 一个 webm(文件名取 page 的 guid)。
公开 API 没有分段录制;内部虽有重启录制的私有方法,但不要依赖。
正确做法是一次录一整场,后期按 marks 裁切:脚本记录每段动作的起止时刻,剪辑时按时间码切。
这样反而比中途起停更稳,因为不用担心文件碎片。
·三、尺寸会被强制取偶
源码里 width & ~1、height & ~1 —— yuv420 要求偶数边长。
传 1919 会静默变成 1918。不要用奇数分辨率设计分镜。
·四、录制从 context 创建开始,不是从 goto 结束开始
所以每段成片开头都带着页面加载期(本次约 7–9 秒,因为要等 10 MB 的 GLB 模型)。 两个处理方式:剪辑时按 marks 裁掉;或者干脆录前先静置到画面稳定,把「加载期」压到最短。
·五、headless 完全可以录
不需要虚拟显示器、不需要 headed 模式。CDP screencast 走的是合成器输出,与窗口是否可见无关。
这也是它在 CI 里能用的原因 —— 顺带一提,--mute-audio 要自己加,音频不会进 webm(-an)。
08故障对照表
按「症状 → 先查什么 → 根因 → 修法」排列。这些症状的共同点是:都不报错,或者报错信息指向错误的方向。
| 症状 | 先查什么 | 根因与修法 |
|---|---|---|
Executable doesn't exist at …/ffmpeg-1013/ffmpeg-mac |
ls ~/Library/Caches/ms-playwright/ffmpeg-1013/ 有没有 INSTALLATION_COMPLETE |
组件下载中断。跑 npx playwright install ffmpeg(2.9 MB,最快)。 |
Executable doesn't exist at …/chromium-1248/… |
npx playwright --version 与 install --dry-run 对比 revision |
升级了 JS 包但没重装浏览器(缓存里还是旧 revision)。npx playwright install chromium。 |
| 成片糊、只有 800×450 | ffprobe -show_entries stream=width,height |
没传 recordVideo.size,被按 800 上限缩放。显式传 1920×1080。 |
| 帧率恒定 25,怎么调都不变 | ffprobe -show_entries stream=r_frame_rate |
没传 fps,落到 kDefaultFps=25。传 fps: 60。 |
| 设了 90fps 还是很卡 | 去重:mpdecimate 后数唯一帧 |
标称是配额不是产能。要么接受 60,要么换 CDP。 |
Target closed |
三个关闭调用的顺序 | 先 ctx.close(),再 saveAs,最后 browser.close()。 |
| 录到一半画面冻结 | 采集回调里是否 await 了耗时操作 |
没回执 = 在途窗口塞满 = Chromium 停止推帧。回执要早于重活,或把重活挪到队列。 |
| CDP 采集成片时间对不上 | 有没有存 meta.json |
帧间隔不均匀,不能用固定 fps 换算。每场记录总帧数/总耗时。 |
·验证「帧率是否真实」的标准动作
两个数都要看:容器声明的帧率(r_frame_rate)和去重后的唯一帧数。
只看前者会被骗,只看后者不知道时间轴对不对。
去重检测# 声明帧率 + 总帧数
ffprobe -v error -count_frames -select_streams v:0 \
-show_entries stream=r_frame_rate,nb_read_frames -of csv=p=0 app.webm
# 唯一帧:mpdecimate 丢掉近似重复帧,剩下的才是真信息量
ffmpeg -i app.webm -vf "mpdecimate=hi=768:lo=320:frac=0.33" \
-vsync 0 -f null - 2>&1 | tail -1
# frame= 529 ← 与声明的 810 相差 281,就是重复帧
09速查与复跑
本节是可以直接抄走的部分。所有数字都来自 promo/recordvideo-benchmark.json,
换了机器想重新对比,改路径重跑即可。
·两套采集器的最小骨架
A · recordVideoconst ctx = await browser.newContext({
viewport: { width: 1920, height: 1080 },
recordVideo: { dir: "raw", size: { width: 1920, height: 1080 }, fps: 60 },
});
// 一场一录;动作起止用 marks 记录,剪辑按 marks 裁
const v = page.video();
await ctx.close();
await v.saveAs("raw/scene-b.webm");
B · CDP 直采const cdp = await ctx.newCDPSession(page);
let n = 0, t0 = Date.now();
cdp.on("Page.screencastFrame", async ({ data, sessionId }) => {
writeFileSync(`${dir}/${String(n++).padStart(6,"0")}.jpg`, Buffer.from(data, "base64"));
await cdp.send("Page.screencastFrameAck", { sessionId });
});
await cdp.send("Page.startScreencast", {
format: "jpeg", quality: 92, maxWidth: 1920, maxHeight: 1080,
});
// ... 动作 ...
await cdp.send("Page.stopScreencast");
writeFileSync(`${dir}/meta.json`, JSON.stringify({ frames: n, ms: Date.now() - t0 }));
·参数怎么定:从交付目标倒推
- 先定输出帧率
平台通常 30fps 足够。定 30,别再往上加。
- 再定慢放倍率
有 2× 慢放就要源 ≥ 60 唯一帧/秒;没有就只需 ≥ 30。
- 反查方案产能
recordVideo@60 是 47.8,@90 是 58.8;CDP 是 77.9。都不够就降倍率,不要提 fps。
- 最后才选方案
1× 且 ≥30 唯一帧 → recordVideo;需要 ≥60 → CDP。
- 录完必须验证
mpdecimate去重数唯一帧,与预期对账。
「参数能不能调」和「调了有没有用」是两件事。1.64.1 确实支持 recordVideo.fps,
但它的作用被两处硬限制夹住了 —— 只查文档会以为 90 就是 90,只有去数唯一帧才知道不是。
以后遇到任何「帧率/采样率」类参数,默认它描述的是配额,然后用去重验证产能。