Puppeteer 不是 Selenium 替代品,它是把浏览器变成可暂停的渲染器

0 阅读6分钟

第一次接触 Puppeteer 大概率是写爬虫或者跑 e2e 测试,所以我们脑海里它的形象是"无头 Chrome 自动化框架"——和 Selenium、Playwright 排在一个货架上。这层印象在做"HTML 转视频"的 agent 时会立刻露馅:很多人第一反应是开 setInterval(33),每 33 毫秒调一次 page.screenshot(),以为这就是"60fps 视频抓帧"。然后撞上一系列怪事——有的帧文字还没渲染、有的帧动画走到一半、整段视频抖得像老式 VHS。

问题不在我们写得不够认真,而在用错了 API 层级。Puppeteer 在视频赛道真正提供的能力,藏在底下那一层 CDP 里。

一个反直觉的类比:画家的手稿,不是录像机

把"用 Chrome 做视频"分两种心智模型。

录像机模型:浏览器在墙钟时间里自顾自播放动画,我们拿着相机在外面按快门。这是 page.screenshot() 的世界——抓到什么算什么,渲染稍慢一点就吃到一张半成品。

画家手稿模型:浏览器变成一支笔,我们说"画时间 t=0 那一帧",它画完递给我们;我们再说"画 t=1/60s 那一帧",它继续画。时间不前进,除非我们让它前进。这就是 CDP 的 HeadlessExperimental.beginFrame 给的能力——它把浏览器从"播放器"切换成"按需出帧的画板"。

image.png

如上图所示,上轨四张被截在 33 毫秒边界、但渲染需要 50–65 毫秒的帧,得到的是"还没画完"的版本——这就是为什么墙钟抓帧的视频在快速动画处会撕裂、在字体加载时会闪。下轨的 beginFrame 模式则反过来:虚拟时间是我们手里的旋钮,渲染要花 65 毫秒就让它花,渲染完才出帧、再推进。一段需要 60 秒墙钟时间的动画,可以离线渲 5 分钟、跑出每一帧都干净的 4K MP4;反之,即使墙钟只跑 30 秒,如果我们没冻结时间,出来的素材也是不可用的。

拆开看:三层 API,叠加起来才是"视频生成"

Puppeteer 的能力分三个层级,每一层解决不同的问题。

第一层:page.screenshot()(上层 API)。 调一次出一张 PNG。clip / fullPage / omitBackground 都有,给的是"截图"——拍墙钟此刻的页面状态。做静态海报、做 Open Graph 缩略图、做监控告警截图,这一层够用。做视频,这一层只是最末端的"保存按钮"。

第二层:page.target().createCDPSession() 拿到 CDP session。 这是绕过 Puppeteer 上层封装、直接和 Chromium 说话的口子。CDP 全称 Chrome DevTools Protocol,是 DevTools 与浏览器之间的通信协议;Puppeteer 自己就是它的官方 Node 客户端。视频赛道会反复用到的 CDP 命令有三条:

  • Animation.setPlaybackRate(0) —— 把所有 CSS / Web Animations API 动画的播放速率设成 0,等于全局暂停。配合 Animation.seekAnimations(animations, currentTime) 可以把动画"快进"到任意时间点。
  • Emulation.setVirtualTimePolicy({ policy: 'pause' }) —— 暂停虚拟时间,所有 setTimeout / requestAnimationFrame / Date.now() 都被冻结,直到我们用 Emulation.setVirtualTimePolicy({ policy: 'advance', budget: 16 }) 推进 16 毫秒。比 setPlaybackRate 更彻底,因为它连"基于墙钟的 JS 计时器"都管。
  • HeadlessExperimental.beginFrame({ frameTimeTicks, interval, screenshot }) —— 按需出帧。给定虚拟时间戳与帧间隔,让 Chromium 渲染这一帧,渲染完成后(可选)直接返回 base64 编码的截图。这一条是 timecut 与 puppeteer-capture 的核心,也是 hyperframes packages/engine 里 BeginFrame-based capture 走的路径

第三层:page.evaluate(() => { ... }) 注入控制脚本。 这一层让我们走进页面上下文,直接改 document.timeline.currentTime、调 GSAP timeline 的 seek()、强制 Lottie animation goToAndStop(t)。CDP 解决"浏览器层时间冻结",page.evaluate 解决"动画库层时间冻结"——两者必须搭配。GSAP / Anime.js / Three.js 这些库内部维护自己的时间状态,只冻结浏览器是不够的,我们还得显式调它的时间轴:"把内部时间拨到 t=2.5 秒,然后渲染一帧。"

把三层叠起来的伪代码长这样:

const client = await page.target().createCDPSession();
await client.send('Animation.setPlaybackRate', { playbackRate: 0 });
await client.send('Emulation.setVirtualTimePolicy', { policy: 'pause' });

const FPS = 60, DURATION = 5;
for (let i = 0; i < FPS * DURATION; i++) {
  const t = i / FPS;                       // virtual time, in seconds
  await page.evaluate((t) => {
    window.gsapTimeline.seek(t);           // sync framework-layer time
    document.timeline.currentTime = t * 1000;
  }, t);
  const { data } = await client.send('HeadlessExperimental.beginFrame', {
    frameTimeTicks: t * 1000,
    interval: 1000 / FPS,
    screenshot: { format: 'png' },
  });
  await fs.writeFile(`frame-${i}.png`, Buffer.from(data, 'base64'));
}
// → ffmpeg -framerate 60 -i frame-%d.png out.mp4

整个 hyperframes / Remotion 这条赛道,本质都是这段循环加上工程化(浏览器进程池、字体就绪检测、distributed 渲染)。

它不解决什么

这套办法很强,但有几条边界值得提前知道。

音频对齐不在 Puppeteer 这边。CDP 没有"按需出音频帧"这种 API——音频本质上是连续信号,不是离散帧。视频流水线里音频通常单独走:让 <audio> / TTS 引擎先输出 WAV/MP3,在 FFmpeg 拼帧那一步用 -i audio.mp3 -map 0:v -map 1:a 后期合成。hyperframes 的 /hyperframes-media skill 调 Kokoro TTS 走的就是这个路。

性能上限在单浏览器实例的渲染速度。1080p 复杂场景,单个 Chromium 大概每秒能出 10–25 帧,做长视频必须并发——Remotion Lambda 走的是 AWS 函数级并发,hyperframes 走的是本地进程池。要做 4K HDR 或者 SVG 滤镜爆炸的视觉,单机 Puppeteer 会很慢,这时候应该考虑 Skia / WebGPU 离线渲染或者 Remotion 那种把帧切片到云函数的架构。

真人形象、扩散模型生成画面、3D 物理仿真,Puppeteer 都不做。它的本职是"把网页当画布",所以擅长的是基于 DOM/CSS/Canvas/WebGL 的程序化动画——数据图表、产品演示、文档配图视频、技术解说类。需要"提示词进、电影画面出"那种,本质上是扩散模型的活,和 Puppeteer 不在同一个抽象层。

给打算做视频生成 agent 的我们一份清单

如果接下来要自己写一个"agent 编 HTML、自动渲成视频"的工具,可以照下面这条路验证可行性:

  1. 先用 Puppeteer + page.screenshot() 跑通"开浏览器 → 加载本地 HTML → 截一张图"——这是最便宜的 hello world,不要跳过。
  2. 在 HTML 里加一段 GSAP 或 CSS keyframes 动画,验证"墙钟抓帧"会撕裂——亲手撞一次这个坑,后面才理解为什么要切到 CDP。
  3. 切换到上面的三层叠加伪代码,跑出一段 60 帧的 PNG 序列;用 ffmpeg -framerate 60 -i frame-%d.png out.mp4 拼成视频,确认每一帧都干净。
  4. 把控制台让给 agent:让 Claude / Cursor 来写 GSAP timeline、写 CSS 动画;agent 输出 HTML,渲染管线只负责"按时间出帧"。
  5. 走到这一步再回头看 Remotion 与 hyperframes,会发现它们做的额外的事——浏览器进程池、热重载、组件化、并发渲染——都是工程化优化,核心循环没变。

也就是说,理解 HeadlessExperimental.beginFrame 这一个 CDP 调用,就能从"会用 Puppeteer 截图"升级到"能写视频生成 agent"。这个跃迁的难度比想象中低,但门槛不在文档里——它在我们对 Puppeteer 的分类标签上。把它从"自动化测试工具"那一格挪到"可暂停的渲染器"那一格,后面的事情会顺很多。