Capture Pipeline · 技术选型手册

recordVideo 与 CDP
两条画面采集链路的工作原理

它们不是两套技术,而是同一个 Chromium 供帧管道的两个下游出口。这份文档把安装链路、源码机制、 实测帧率和选型判断拆成可核对的步骤 —— 每一步都标注了证据来源:是实测出来的、读源码读出来的,还是推断。

Playwright 1.64.1 Chromium 1248 · CFT 156.0.8078.4 ffmpeg 1013 · n7.0.1 采集 1920×1080 9 张 手绘 SVG

00三十秒结论

如果你的问题是「这两个该怎么选」,答案往往被包装成性能对比。但读完 Playwright 的实现会发现: 两者调用的是同一个 CDP 命令,连 quality 默认值都一样(90)。 真正的差别只有一个 —— 帧拿到手之后,谁来把它变成文件。

CDP 直采
97.85 帧/秒
recordVideo @90
58.8 唯一帧/秒
默认录制尺寸
800×450
默认帧率
25 fps
判断依据证据
采集端同源 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 出帧 之前完全重叠。 这个结构决定了后面所有结论 —— 既然是同一个水源,那产能上限就由水源决定,下游只能决定「怎么装瓶」。

① 采集端 · 两条链路共用 ② 下游 · 在这里分叉 Chromium 合成器 每合成一帧就推一次 Page.startScreencast jpeg · quality 90 maxFramesInFlight 5 / 10 recordVideo 内置客户端 Playwright 自己写盘 + 编码 Matroska 封装 EBML 头 + JPEG 帧 ffmpeg VP9 实时编码 app.webm(VP9) 自建 CDP 客户端 你写 onFrame + ack JPEG 序列 000123.jpg ffmpeg 离线编码 成片 mp4(H.264) 边录边压 · 实时 先落盘 · 录完再压 上游代码完全相同,差别只在第 4 步之后 —— 谁负责把帧变成文件。
图 1 · 链路全景。上半(橙)是 recordVideo:帧一进 Node 就被封进 Matroska 管道喂给 ffmpeg,边录边编码。 下半(绿)是 CDP 直采:Node 只负责把 JPEG 写进磁盘,编码推迟到录制结束后。同一台机器、同一场景,上半实测 58.8 唯一帧/秒,下半 77.9。

这个分叉在源码里只有一个入口可以确认 —— 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 而没重跑安装, 就会出现「代码是新的、二进制是旧的」—— 报错信息还只说文件不存在,不说版本不匹配。

package.json playwright@1.64.1 playwright-core/browsers.json 钉死每个二进制的 revision 不由你决定 ↑ chromium-1248 headed 采集用它 366 MB chromium_headless_shell-1248 headless 默认用它 203 MB ffmpeg-1013 recordVideo 的编码器 2.9 MB 缓存根目录(macOS): ~/Library/Caches/ms-playwright PLAYWRIGHT_BROWSERS_PATH=0 → 改装到 node_modules/.local-browsers 每个目录里的 INSTALLATION_COMPLETE 是「真的装完」的唯一凭据 默认清单 = 5 项 chromium + shell + firefox + webkit + ffmpeg (dry-run 可原样打印这份清单)
图 2 · 安装链路。revision 由 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 对应。

Node / Playwright VideoRecorder Chromium 合成器 + JPEG 编码 ffmpeg 子进程 常驻,等 stdin 磁盘 循环 · 每帧执行一次 1 startScreencast {quality:90, in-flight:10} 2 spawn ffmpeg -f matroska -i pipe:0 3 screencastFrame(JPEG base64) 4 EBML 集群头 + JPEG 字节 → stdin 5 VP9 → app.webm 6 screencastFrameAck(回执) 7 ctx.close() → stdin.end() → 编码收尾 8 video.saveAs(path) 录制起点 = context 创建时刻,早于 goto:成片开头必然含加载期,必须裁掉。
图 3 · recordVideo 时序。① 启动 screencast;② 拉起常驻 ffmpeg;③–⑥ 每帧走一遍 「收帧 → 封 Matroska → 回执」;⑦ 关闭时才结束管道;⑧ 才把临时文件交给你。 注意 编码是串在录制过程中的,这是它和 CDP 的根本区别。
  1. 建 context 就开始录

    newContext({ recordVideo }) 一返回,VideoRecorder.start() 就已经 addClient 并 startScreencast 了。所以录制起点早于 goto, 片头一定带着加载画面 —— 实测两次相同操作,成片时长分别是 1.68s 和 1.96s,差的就是这段。

  2. 拉起一个常驻 ffmpeg

    输入是 pipe:0(stdin),容器声明为 matroska,输出 libvpx-vp9。 它不是「录完再压」,而是页面动一下它就收到一帧。

  3. 收帧:base64 解码后的原始 JPEG

    CDP 事件里的 data 是 base64,Playwright 解成 Buffer 后不做任何转码, 原样往下传。所以 quality 90 的 JPEG 压缩痕迹会一路留到 VP9 里。

  4. 自己封 Matroska:先写集群头,再写 JPEG

    这一步很反直觉 —— ffmpeg 的输入不是裸 JPEG 流,而是 Playwright 手工拼的 EBML 结构。 writeClusterHeader(timestamp, length) 写入时间戳和长度,紧跟原始 JPEG 字节。 用绝对时间戳(不是等间隔)意味着容器里保留了真实的采集节奏,后面才有「重采样」的空间。

  5. 实时 VP9 编码,参数全为「快」让路

    -deadline realtime、-cpu-used 8、-lag-in-frames 0: 不做前瞻缓存、用最快的预设。代价是压缩效率,收益是不堵管道。

  6. 回执,释放一个在途名额

    Chromium 只有在收到 screencastFrameAck 之后才会继续推后续帧, 在途上限是 maxFramesInFlight。这是整条链路真正的限速器(见 §5)。

  7. ctx.close() 才收尾

    关闭 context 会触发 stdin.end(),ffmpeg 才会 flush 出完整的 WebM 索引(Cues)。 先 saveAs 再关 context,会拿到半截文件或报 Target closed。

  8. 交付一个 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像素率倍数码率threadstile-columns
fps 255.7605.76 Mbps21
fps 6013.82413.82 Mbps42
fps 9020.73620.74 Mbps63

这个设计很聪明也很坑:码率随 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,存完回执,循环到你说停。 没有任何编码,没有任何容器,因此也没有任何隐藏的帧率换算。

你的采集脚本 onFrame + 回执 Chromium 合成器 + JPEG 编码 磁盘 闭环 · 每帧打一个来回 1 startScreencast(jpeg / q92 / 1:1) 2 screencastFrame {data, sessionId, timestamp} 3 writeFileSync(000123.jpg) 4 screencastFrameAck {sessionId} 5 录制结束 → ffmpeg 离线编码(可选) 你控制三个旋钮:quality(画质/带宽)、maxWidth·maxHeight(尺寸)、回执节奏(供帧速度)。
图 4 · CDP 时序。① 一条命令启动;② 帧事件带着 base64 JPEG 抵达;③ 你决定怎么落盘; ④ 回执才允许下一帧;⑤ 录制结束后另行编码。采集与编码彻底解耦,这是它能跑满 97.85 帧/秒的原因。
最小可用采集器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 更快地产生画面 —— 于是第二个限制成了真正的天花板。

maxFramesInFlight:同时在途的帧数上限 图示:10 个槽位里,3 个已发出、正等回执 在途 在途 在途 空闲 fps ≤ 30 → 只有 5 个槽 fps > 30 → 放宽到 10 个槽 帧到 → 占一个槽 回执 → 释放一个槽 槽满 → 停止推帧 这是唯一的限速阀, 也是 fps 参数唯一碰到的地方。 放宽窗口只能减少「等回执」的空转,不能让 Chromium 更快合成 —— 产能上限在编码侧,不在槽位数。
图 5 · 在途窗口。槽位是「已发出但未确认」的帧。窗口越大,流水线越不容易空转; 但它不能突破上游产帧速度,所以把 fps 从 30 提到 90,只是把窗口从 5 放到 10 而已。
声明 fps:90 → 一秒有 90 个时间槽 Chromium 实际交付 59 帧 (每个蓝格 = 一个不同的画面) ffmpeg 输出 90 帧 (-r 90 把空槽填满:31 帧是复制品) 唯一画面 复制帧(信息量为零) 未交付的槽位 同一秒,同一场景:声明 90,得到 59 种画面,文件里写着 90fps。
图 6 · 一秒的解剖。上排是 Chromium 真实交付的帧,下排是 ffmpeg 按 -r 90 输出的流。 橙色那 31 格不是「补间动画」,而是把第 59 帧原样重复了 31 次 —— 播放器会如实显示,观感就是卡顿。

·三档实测:提高标称只增加重复

同一 9 秒动作段 · 1920×1080 · mpdecimate 去重 标称 fps(配额) 唯一帧率(产能) 30 交付线 60 慢放门槛 recordVideo 25 22.1 recordVideo 60 47.8 recordVideo 90 58.8 CDP 直采 77.9 0 20 40 60 80 fps 虚线是标称值、实条是真实产能:两者的落差就是「被复制出来的帧」。
图 7 · 配额 vs 产能。虚线是声明的标称帧率,蓝条是实际不同的画面数。 25 → 60 有明显改善(22.1 → 47.8),但 60 → 90 只从 47.8 涨到 58.8 —— 说明天花板已经不在参数上了。
方案标称9s 输出帧唯一帧唯一帧率重复率
recordVideo2522519922.111.6%
recordVideo6054043047.820.4%
recordVideo9081052958.834.7%
CDP 直采—90371977.920.4%

最后一行还有一个值得注意的细节:CDP 的采集侧是 97.85 帧/秒,去重后 77.9。 这条链路根本没有编码环节,所以差的这 20 帧不可能是「编码丢帧」,而是场景里变化较慢的部分被去重算法判成了重复。 不同场景的数字不能直接横比,同一台机器上的相对关系才有意义。

recordVideo 这一侧的趋势更明确:标称值从 60 提到 90,重复率从 20% 涨到 35%。 多出来的帧全是填充物 —— 文件更大、编码更慢、画面并不会更顺。

·慢放把这个问题放大

慢放的本质是「用更少的时间采样换更长的时间展示」。2 倍慢放意味着每个源帧要撑 2 个输出帧, 所以源唯一帧率至少要翻倍。目标 30fps 交付时,2× 慢放需要 ≥ 60 唯一帧/秒的源:

2× 慢放 → 输出 30fps 后的有效帧率 30 recordVideo 60 25.0 recordVideo 90 22.0 CDP 直采 27.8 0 10 20 30 fps 判据:源唯一帧率 ≥ 慢放倍率 × 30。2× 需要 ≥ 60 —— 只有 CDP(77.9)满足,fps:90 反而比 fps:60 更差。
图 8 · 慢放的预算。为什么 fps:90 反而比 60 差?因为它多出的帧本来就是重复的, 慢放后重复帧被拉长成两帧完全一样的画面,去重统计自然更低。这条反直觉的结论,是选型时最容易踩的坑。
一句话判据

要判断「这个方案够不够」,别看标称 fps,算这个:源唯一帧率 ÷ 慢放倍率 ≥ 输出帧率。 本次实测:recordVideo@60 是 47.8 ÷ 2 = 23.9,不够 30;CDP 是 77.9 ÷ 2 = 39,够。

06选型决策

前面的数据可以压缩成一棵三岔树。注意分支依据不是「哪个更快」,而是这批镜头要怎么用 —— 慢放和逐帧对齐是 CDP 唯一不可替代的两个场景。

这批镜头怎么用? 先问用途,再问参数 常规 1× 播放 动作不需要放慢细看 2× 及以上慢放 要看清每一帧的姿态 逐帧对齐 / 超长时段 时间码要精确到帧 recordVideo fps:60 唯一 47.8 → 交付 30fps 够用 时间码直接用 -ss 裁 CDP 直采 唯一 77.9,慢放后 27.8 fps:90 也救不了 → 见 §5 CDP 直采 JPEG 序列 + 每帧时间戳 帧号可精确映射回时刻 两条链路的上游完全相同,切换成本 = 换一个采集函数,不用改页面驱动逻辑。 但下游成本不同:CDP 要自己管 meta.json、磁盘和批量编码,这是它的「税」。
图 9 · 决策树。三岔树里只有两个叶子指向 CDP,判断依据都是「对单帧信息量的需求」, 与录多久、分辨率多高无关。

·成本对照:性能之外的那一半

维度recordVideoCDP 直采
产物 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 }));

·参数怎么定:从交付目标倒推

  1. 先定输出帧率

    平台通常 30fps 足够。定 30,别再往上加。

  2. 再定慢放倍率

    有 2× 慢放就要源 ≥ 60 唯一帧/秒;没有就只需 ≥ 30。

  3. 反查方案产能

    recordVideo@60 是 47.8,@90 是 58.8;CDP 是 77.9。都不够就降倍率,不要提 fps。

  4. 最后才选方案

    1× 且 ≥30 唯一帧 → recordVideo;需要 ≥60 → CDP。

  5. 录完必须验证

    mpdecimate 去重数唯一帧,与预期对账。

这次真正学到的

「参数能不能调」和「调了有没有用」是两件事。1.64.1 确实支持 recordVideo.fps, 但它的作用被两处硬限制夹住了 —— 只查文档会以为 90 就是 90,只有去数唯一帧才知道不是。 以后遇到任何「帧率/采样率」类参数,默认它描述的是配额,然后用去重验证产能。