Theatre.js是一款用可视化时间轴编辑网页动画的利器,动画设计师可以在浏览器里拖拽数值曲线,前端工程师再通过API读取这些数值驱动Three.js或DOM元素。不过在影视预演、动效交付审核等场景下,光有浏览器里的实时预览还不够,往往需要把动画的若干关键帧导出成静态图片,交给不熟悉开发工具的人查看。这件事在纯浏览器环境下做起来很别扭,而Node.js配合无头浏览器恰好能把它变成一条可自动化的流水线。本文就来拆解这套被称作TheatreJS2Image的实现思路。

一、整体架构:无头浏览器是核心
要在Node.js里导出Theatre.js动画的某一帧,最大的问题不是截图本身,而是Theatre.js的工程本质上运行在浏览器环境中,它依赖DOM、requestAnimationFrame等一系列浏览器能力。直接在Node.js里require一个Theatre.js的bundle是行不通的。所以整个方案的骨架是:Node.js作为控制端,通过自动化协议驱动一个无头浏览器,让浏览器加载你真正的动画页面,Node.js只负责发指令和收文件。
目前主流的选择有两个:Puppeteer和Playwright。两者都提供完整的无头Chrome控制能力,包括页面截图API。如果你的动画页面已经是一个可以在本地跑起来的静态工程(比如用Vite构建后输出的dist目录),Puppeteer就够了;如果需要跨浏览器验证渲染一致性,可以选Playwright。下面的示例以Puppeteer为准,因为它安装简单,API直觉性强。
流程上大致分四步:启动无头浏览器并打开动画页面;通过page.evaluate在页面上下文里访问Theatre.js导出的对象;调用sequence.position把时间轴设置到目标时间点;最后调用page.screenshot保存PNG。这个循环跑完所有需要的时间点,就得到了整组帧图片。
二、在页面侧暴露可控制的接口
Node.js侧没法凭空拿到Theatre.js的内部状态,你的动画页面需要主动暴露一个控制入口。常见做法是在初始化project和sequence之后,把关键对象挂到window上,同时确保页面在没有用户交互时也能渲染。下面是一个典型的页面侧代码骨架:
// 动画页面中的初始化代码
import { getProject, types } from '@theatre/core';
import state from './state.json'; // Theatre.js导出的工程状态
const project = getProject('My Project', { state });
const sheet = project.sheet('Scene');
const sequence = sheet.sequence;
// 暴露控制接口给外部(Node.js侧通过evaluate调用)
window.__theatreControl = {
// 跳转到指定时间点(单位:秒)
async seek(timeInSeconds) {
await sequence.play({ iterationCount: 0 });
sequence.position = timeInSeconds;
},
// 获取时间轴总长度
getDuration() {
return sequence.pointerToObject.length;
}
};
project.ready.then(() => {
document.body.dataset.ready = 'true'; // 告诉外部页面已就绪
});
几个细节值得注意。第一,sequence.position是可以直接赋值的,赋值后Theatre.js会立刻把该时间点的所有数值推送给订阅者,你的onValuesChange回调会被同步触发,DOM或Three.js场景随之更新。第二,如果动画里用了Three.js,别忘了在数值变化后手动调用一次renderer.render,因为脱离了rAF循环,渲染不会自动发生。第三,project.ready这个Promise必须等待,否则seek操作可能作用在尚未加载完成的工程上,导出的帧会是初始状态,这是新手最容易踩的坑。
三、Node.js侧的逐帧导出实现
页面侧准备好之后,Node.js侧的逻辑就相对直白了。核心是循环时间点,每跳一帧等一帧、截一张图。基础实现如下:
const puppeteer = require('puppeteer');
const path = require('path');
(async () => {
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
// 加载本地动画页面
await page.goto('http://127.0.0.1:5173/', { waitUntil: 'networkidle0' });
// 等待页面标记就绪
await page.waitForFunction(() => document.body.dataset.ready === 'true');
const duration = await page.evaluate(() => window.__theatreControl.getDuration());
const fps = 24;
const frameCount = Math.floor(duration * fps);
for (let i = 0; i < frameCount; i++) {
const t = i / fps;
await page.evaluate((time) => window.__theatreControl.seek(time), t);
// 给WebGL或异步更新留出一拍时间
await new Promise(r => setTimeout(r, 50));
await page.screenshot({
path: path.join(__dirname, 'frames', `frame_${String(i).padStart(4, '0')}.png`),
type: 'png'
});
console.log(`已导出第 ${i + 1}/${frameCount} 帧`);
}
await browser.close();
})();
这段代码里有三处需要根据实际情况调整。首先是waitUntil: 'networkidle0',如果页面里有长连接或轮询请求,可以换成domcontentloaded再配合waitForFunction,否则会一直卡在goto上。其次是那个50毫秒的等待,它不是必须的,但如果你的动画回调里有异步操作(比如等待纹理加载),少了这一拍截图就会拍到旧画面。最后是视口尺寸,导出图片的分辨率完全由setViewport决定,需要2倍图就把宽高各乘以2并配合deviceScaleFactor。
四、常见问题与优化手段
实际跑起来后,你大概率会遇到几类问题。第一类是截图全黑,这通常出现在WebGL场景中,原因是Puppeteer默认的无头模式下GPU加速可能不可用,解决办法是launch时加上args: ['--use-gl=angle', '--enable-unsafe-swiftshader'],或者直接加--disable-gpu配合软件渲染兜底。第二类是帧与帧之间内容跳变不一致,多半是seek后没有强制渲染,建议在__theatreControl.seek内部主动触发一次渲染再resolve,比在外面盲等更可靠。
性能方面,如果导出的帧数很多,逐张写PNG会很慢。一个明显提速的办法是并行化:Puppeteer支持同时开多个page加载同一个页面,各自负责不同的时间区间。由于每个page是独立实例,互不干扰,四路并行通常能获得接近线性的加速。另外可以把截图格式换成JPEG,体积能小一个数量级,如果只是给人预览而非后续合成,这个取舍很划算。
最后提醒一点状态管理:Theatre.js的工程状态是加载JSON导出文件,如果你在浏览器编辑器里改了动画,记得重新导出state.json并让Node.js流程使用最新版本,否则命令行导出的帧和编辑器里看到的会对不上。把这一步纳入构建脚本,比如在导出前先跑一次打包命令,整个流程才算真正自动化。这样一来,设计师改完动画,一条命令就能拿到全部帧图片,交付效率会提升不少。
Theatre.jsNode.js动画导出修改时间:2026-09-08 13:43:04