第一次接触 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 给的能力——它把浏览器从"播放器"切换成"按需出帧的画板"。
如上图所示,上轨四张被截在 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、自动渲成视频"的工具,可以照下面这条路验证可行性:
- 先用 Puppeteer +
page.screenshot()跑通"开浏览器 → 加载本地 HTML → 截一张图"——这是最便宜的 hello world,不要跳过。 - 在 HTML 里加一段 GSAP 或 CSS keyframes 动画,验证"墙钟抓帧"会撕裂——亲手撞一次这个坑,后面才理解为什么要切到 CDP。
- 切换到上面的三层叠加伪代码,跑出一段 60 帧的 PNG 序列;用
ffmpeg -framerate 60 -i frame-%d.png out.mp4拼成视频,确认每一帧都干净。 - 把控制台让给 agent:让 Claude / Cursor 来写 GSAP timeline、写 CSS 动画;agent 输出 HTML,渲染管线只负责"按时间出帧"。
- 走到这一步再回头看 Remotion 与 hyperframes,会发现它们做的额外的事——浏览器进程池、热重载、组件化、并发渲染——都是工程化优化,核心循环没变。
也就是说,理解 HeadlessExperimental.beginFrame 这一个 CDP 调用,就能从"会用 Puppeteer 截图"升级到"能写视频生成 agent"。这个跃迁的难度比想象中低,但门槛不在文档里——它在我们对 Puppeteer 的分类标签上。把它从"自动化测试工具"那一格挪到"可暂停的渲染器"那一格,后面的事情会顺很多。