作者注:从提示词、景深补偿公式到 Playwright+ffmpeg 逐帧导出,完整拆解如何用 Claude Code 搭建一个“导出不掉帧”的 2.5D 视差动画工作室。
很多人第一次用 AI 编程助手做动画,浏览器里预览得很顺,一导出视频就出现卡顿、跳帧和时长对不上。问题往往出在架构上:一开始没有把“导出”当成设计的一部分。本文围绕 纯函数 renderAt(t) + 逐帧 seek 截图 这条主线,讲清楚如何用 Claude Code 在一次会话里搭出 2.5D 视差动画工作室,并稳定导出 MP4。
这不是又一篇“让 AI 帮你写个动画”的演示。本文会拆开景深补偿公式的推导、一个很典型的偏移 bug,以及实时播放和逐帧导出的本质区别。
核心价值:读完本文,你会拿到一套可复用的提示词结构、一条可复现的导出管线,以及一份能迁移到其他程序化视频项目的验证清单。

Claude Code 2.5D 视差动画工作室核心要点
参考资料中的实践来自一篇 2026-11-09 发布的文章。作者用 Claude Code 搭配 Opus 5.5,只用一次会话就做出一个单文件 HTML 工作室,外加一个 25 行左右的 Playwright 导出脚本。在作者的笔记本上,6 秒 720p 的视频(180 帧)大约 20 秒导出完成。作者没有给出硬件配置,所以这个耗时只能作为量级参考。
| 要点 | 说明 | 价值 |
|---|---|---|
| 场景图(Scene Graph) | 每个图层有 z 深度、尺寸、位置和 HTML 内容 | 不用 3D 模型也能做出纵深感 |
| 时间轴(Timeline) | 相机与图层的关键帧,每段配一条缓动曲线 | 镜头运动可编辑、可复用 |
| 确定性渲染器 | renderAt(t) 对任意时刻 t 输出唯一画面 |
导出结果与机器快慢无关 |
| 景深补偿缩放 | 尺寸和偏移同时乘以 1 - z/perspective |
静止时所见即所写,运动时自带视差 |
| 逐帧导出管线 | Playwright 按 i/fps seek 截图,交给 ffmpeg 编码 |
每台机器输出一致,不掉帧 |
2.5D 视差动画的原理
2.5D 的做法是把扁平的 2D 素材放到 3D 空间的不同深度上,再让一台虚拟相机穿过它们。离相机近的图层移动快,远的移动慢,人眼会把这种速度差读成纵深,这就是视差(parallax)。整个过程没有建模、灯光,也没有 3D 引擎,浏览器的 CSS perspective 和 preserve-3d 就够用了。
“工作室”和“一次性动画”的区别在于三个部件是否齐全:场景图、时间轴和确定性渲染器。很多教程会跳过第三个,可它决定了导出的 MP4 会不会卡顿。所以在写第一行提示词时,就要把它定为硬性要求。

Claude Code 2.5D 工作室的环境与模型配置
参考资料推荐用 Opus 5.5 完成首次构建。Anthropic 官方的模型概览把它定位为“长时间运行的智能体编程与知识工作”模型,1M token 上下文,最大输出 128K。官方定价为每百万输入 / 输出 token 4 / 20 美元,实际价格请以官方页面为准。
建议在启动时锁定具体模型 ID,避免项目中途别名切换导致行为变化:
claude --model claude-opus-5-5 --effort high
Opus 5.5 默认使用 medium effort。首次构建时,agent 要一次写对投影数学、关键帧引擎和导出管线,所以建议调到 high。之后只是改关键帧数值或颜色这类小修改,可以回到 medium,节省 token。
| 阶段 | 推荐 effort | 典型任务 | 理由 |
|---|---|---|---|
| 首次构建 | high | 投影数学、关键帧引擎、导出脚本 | 多个子系统要一次对齐 |
| 迭代微调 | medium | 调关键帧、改配色、加图层 | 改动范围小,逻辑已稳定 |
| “描述镜头”接口 | low(起步) | 返回固定 schema 的 JSON 关键帧 | 任务短、输出结构固定 |
用其他模型做同等构建效果如何、成本差多少,参考资料没有给出对比数据。如果要换模型,建议用同一份提示词各跑一遍,再用后文的静帧验证法逐项对比。
用 Claude Code 构建 2.5D 工作室的提示词结构
一个写得具体的提示词,效果好过五个含糊的提示词。参考资料里的构建规格是一整段提示词,下面是保留原意的版本:
Build a 2.5D motion studio as one file, studio.html, with no dependencies.
- Scene: 6 layers (sky, moon, hills, city skyline, a title card "Ship it.",
foreground foliage), each at its own z depth, using CSS perspective and preserve-3d.
- Camera: keyframes for x, y, z (dolly) and yaw (orbit around the card).
- Per-layer tracks: opacity, scale, y. Easings: linear, easeInOutCubic,
easeInOutSine, easeOutBack.
- A timeline bar with keyframe markers, a playhead, click to scrub, space to play or pause.
- renderAt(t) must be pure in t. Expose window.__seek(t) that resolves after
the frame paints, and a ?capture mode that hides the UI.
Then write export.mjs: Playwright opens ?capture, seeks every frame at 30 fps,
screenshots it and pipes PNGs to ffmpeg as H.264.
这段提示词可以拆成四层:场景、相机、轨道与缓动、交互 UI,最后是导出契约。真正起决定作用的是最后两行。它们要求 renderAt(t) 必须是关于 t 的纯函数,要求暴露一个“画完才返回”的 __seek,还要求提供隐藏 UI 的 ?capture 模式。这样导出能力就成了架构约束,agent 写代码时会照着它来设计。
如果你要迁移到自己的项目,可以把这份结构当模板:前面写清“画什么”,最后用两三行写死“怎么被确定性地取帧”。
Claude Code 生成的 2.5D 核心代码解析
整个工作室大约 150 行,关键是下面三段。
关键帧采样与缓动
每条轨道都是一个按时间排序的关键帧数组。sample() 先找到 t 前后的两个关键帧,用前一个关键帧的缓动函数算出进度,再对所有数值字段做插值:
function sample(keys, t) {
if (t <= keys[0].t) return keys[0];
for (let i = 0; i < keys.length - 1; i++) {
const a = keys[i], b = keys[i + 1];
if (t <= b.t) {
const p = (easings[a.ease] || easings.linear)((t - a.t) / (b.t - a.t));
const out = {};
for (const k in b)
if (typeof b[k] === 'number' && k !== 't') out[k] = a[k] + (b[k] - a[k]) * p;
return out;
}
}
return keys[keys.length - 1];
}
这个函数不读当前时间、不依赖上一帧状态,也不用随机数。同一个 t 永远得到同一组数值,renderAt(t) 的确定性就是从这里来的。
景深补偿:2.5D 视差的核心公式
这里的思路是相机不动,让整个世界反向运动。每个图层除了在 z 轴上平移,还要乘一个补偿缩放:
const PERSPECTIVE = 1200; // 必须与 #viewport 的 perspective 一致
world.style.transform =
`translate3d(${-cam.x}px, ${-cam.y}px, ${cam.z}px) rotateY(${-cam.yaw}deg)`;
const comp = 1 - L.z / PERSPECTIVE;
el.style.transform = `translate(-50%,-50%) ` +
`translate3d(${x * comp}px, ${y * comp}px, ${L.z}px) scale(${comp * s})`;
推导过程如下。CSS 透视距离为 p 时,深度为 z 的点(z 为负表示远离观察者)投影到屏幕上,会被缩放 p / (p - z) 倍。取 p = 1200、z = -900,得到 1200 / 2100 ≈ 0.571,也就是图层只剩原始尺寸的约 57%。
要抵消这个缩小,就乘以它的倒数 (p - z) / p = 1 - z/p = 1 + 900/1200 = 1.75。代码里的 comp 就是这个值。由于 0.571 × 1.75 = 1,相机静止时,每个图层都会以你设定的尺寸和位置出现。
视差是怎么出现的?相机移动时,整个 world 平移了同样的 dx,但每个图层在屏幕上的位移仍然要乘以各自的 p/(p - z)。远处图层的系数小,所以移得少;近处图层系数大,所以移得多。补偿只修正了静止状态的构图,不影响运动时的速度差,2.5D 的效果靠的就是这一点。
| 图层 z | 投影缩放 p/(p−z) | 补偿系数 comp | 相机平移时相对速度 |
|---|---|---|---|
| 0(标题卡平面) | 1.000 | 1.000 | 基准 |
| -300 | 0.800 | 1.250 | 较慢 |
| -900 | 0.571 | 1.750 | 明显更慢 |
| -1800 | 0.400 | 2.500 | 接近静止背景 |
(表中数值按 p = 1200 计算,-300 与 -1800 两行为示例推导。)
一个值得记住的 bug:只补偿尺寸,没补偿偏移
第一版代码只给图层乘了 scale(comp),x/y 偏移没有乘 comp。结果远处的山丘偏移量被透视压缩了,构图整体错位,山丘一路升高,盖住了大约三分之二的天空。修复方法很简单:x 和 y 也乘以 comp,因为透视对位置和尺寸的缩放是同一个系数。
更值得注意的是这个 bug 是怎么发现的。作者看了一帧渲染结果就发现了问题,读代码时并没有看出来。所以在信任任何镜头运动之前,先让 agent 渲染起点、中点、终点三张静帧。

2.5D 视差动画逐帧确定性导出 MP4
实时播放与逐帧导出的本质差异
实时预览用 requestAnimationFrame,机器一忙就会丢帧,这在预览阶段可以接受。导出不能丢帧。如果用录屏,录到的是“真实时间里屏幕刚好显示的东西”,慢一帧就会变成重复帧或缺帧。
| 维度 | 实时播放(rAF / 录屏) | 逐帧 seek 导出 |
|---|---|---|
| 时间来源 | 系统时钟 | 帧序号 i / fps |
| 机器繁忙时 | 掉帧、重复帧 | 只是变慢,画面不变 |
| 跨机器一致性 | 不一致 | 每台机器输出相同 |
| 帧数 | 不可控 | 严格等于 duration × fps |
| 适用场景 | 编辑预览、拖动时间轴 | 最终成片、CI 自动出片 |
极简导出脚本
导出器的时间由帧序号驱动。每帧先 seek,等画面绘制完成再截图,然后把 PNG 写进 ffmpeg 的标准输入:
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import { pathToFileURL } from 'node:url';
const out = process.argv[2] || 'out.mp4';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
await page.goto(pathToFileURL('studio.html').href + '?capture');
const { duration, fps } = await page.evaluate(() => window.__scene);
const ffmpeg = spawn('ffmpeg', ['-y', '-f', 'image2pipe', '-framerate', String(fps),
'-i', '-', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '18', out],
{ stdio: ['pipe', 'ignore', 'inherit'] });
const frames = Math.round(duration * fps);
for (let i = 0; i < frames; i++) {
await page.evaluate(t => window.__seek(t), i / fps);
ffmpeg.stdin.write(await page.screenshot({ type: 'png' }));
}
ffmpeg.stdin.end();
await new Promise(r => ffmpeg.on('close', r));
await browser.close();
有几个细节需要注意。window.__seek(t) 要等两个动画帧之后才 resolve,这样截图拿到的一定是新的 transform。image2pipe 让 ffmpeg 从 stdin 读取 PNG 序列。yuv420p 保证生成的文件能在浏览器中正常播放。如果 Playwright 还没装浏览器,先运行一次 npx playwright install chromium。
查看 __seek 与静帧验证的参考实现(示意代码)
// studio.html 内:确定性 seek,画完两帧后再返回
window.__seek = (t) => new Promise(resolve => {
renderAt(t); // 纯函数:只依赖 t
requestAnimationFrame(() =>
requestAnimationFrame(resolve)); // 确保样式已提交并绘制
});
// stills.mjs:渲染起点 / 中点 / 终点三张静帧,先看图再信任镜头
import { chromium } from 'playwright';
import { pathToFileURL } from 'node:url';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
await page.goto(pathToFileURL('studio.html').href + '?capture');
const { duration } = await page.evaluate(() => window.__scene);
for (const [name, t] of [['start', 0], ['mid', duration / 2], ['end', duration - 1e-3]]) {
await page.evaluate(t => window.__seek(t), t);
await page.screenshot({ path: `still-${name}.png` });
}
await browser.close();
以上两段是按参考资料思路写的示意实现,具体以 agent 在你项目里生成的代码为准。
作者的实测结果:180 帧、1280×720、30 fps、时长 6.0 秒,输出 MP4 约 420 KB,总耗时约 20 秒。画面中城市塔楼的位移小于深色前景山丘,前景山丘最终完全移出画面,这正是视差效果。

为 2.5D 工作室加一个“描述镜头”功能
接下来可以让编剧输入一句镜头描述,由模型返回关键帧 JSON。参考资料中的做法是直接调用 Anthropic Messages API:
curl https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2026-10-21" \
-H "content-type: application/json" \
-d '{
"model": "claude-opus-5-5",
"max_tokens": 4096,
"output_config": { "effort": "low" },
"system": "Return only JSON: {\"camera\": [{\"t\",\"x\",\"y\",\"z\",\"yaw\",\"ease\"}], \"tracks\": {}}. Times in seconds, 0 to 6.",
"messages": [{ "role": "user", "content": "Slow push-in, slight orbit right, title fades up in the first second." }]
}'
这里有两个容易踩的坑。第一,effort 要写在 output_config.effort 里。第二,Opus 5.5 始终使用自适应思考,传 thinking: {"type": "disabled"} 会返回 400,而且思考 token 也计入 max_tokens。max_tokens 设得太低,JSON 会在数组中途被截断,stop_reason 为 "max_tokens"。
截断的或夹杂多余文字的回复,会在渲染时让 sample() 出错,所以这个调用最好配一个契约测试。参考资料用 Apidog CLI 让同一个 Claude Code 会话创建并运行测试,断言状态码 200、stop_reason 不是 "max_tokens"、返回文本能解析为 JSON,且 camera 数组的 t 从 0 递增到 6。用其他 API 测试工具或自己写的断言脚本也能达到同样目的。修改 system prompt 或 effort 后要重跑一遍,因为两者都会改变返回内容。
常见问题 FAQ
Q1:2.5D 视差动画和真正的 3D 动画有什么区别?
2.5D 把扁平的 2D 素材放在不同深度上,再用移动的相机拍摄,靠速度差产生纵深感。它不需要 3D 模型、灯光和 3D 引擎,用浏览器 CSS 就能实现,代价是相机不能大角度绕到图层背后。
Q2:为什么不直接录屏,而要逐帧 seek 截图?
录屏跟随真实时间,任何一帧慢了都会变成丢帧或重复帧。按 `i / fps` seek 后截图,每一帧的内容只由帧序号决定,所以在任何机器上的输出都一样。
Q3:补偿系数 1.75 是怎么算出来的?
透视距离 1200、深度 -900 时,投影缩放为 1200/2100 ≈ 0.571。补偿取倒数 `1 – z/p = 1 + 900/1200 = 1.75`,两者相乘等于 1,静止时图层就回到设计尺寸和位置。
Q4:首次构建为什么建议用 Opus 5.5 和 high effort?
首次构建要在一次会话里同时写好投影数学、关键帧引擎和导出管线。参考资料中,一次提示词加一轮修复就得到了可用的工作室。后续的小修改用 medium 或更低就够了。换成其他模型的效果目前没有公开对比数据,需要自己测试。
Q5:没有 API Key 能用这个工作室吗?
可以。工作室本身和 MP4 导出都在本地运行,不需要 Key。只有“描述镜头”功能要调用 Messages API,需要你自己的 Anthropic API Key。用 Claude Code 构建时消耗的 token,则按你所用账号的计费方式结算。
Q6:这套方法有哪些已知限制?
逐帧截图 PNG 的速度受分辨率和页面复杂度影响,参考资料里 20 秒的耗时只是单台笔记本的单次结果。CSS 3D 对大角度 yaw 和半透明叠层的渲染可能因浏览器版本而有差异。模型 ID、API 版本头和 effort 参数都有时效性,使用前请核对官方文档。
Q7:如何把“确定性渲染 + 静帧验证”迁移到其他程序化视频项目?
可以按下面的清单逐项检查:
– 渲染函数只接收 t,不读系统时钟、不依赖上一帧状态、不用未固定种子的随机数
– 提供一个“绘制完成才返回”的 seek 接口
– 提供隐藏 UI 的 capture 模式和固定视口尺寸
– 导出帧数严格等于 `round(duration × fps)`
– 改动镜头后先渲染起点、中点、终点三张静帧,确认无误再导出全片
– 对模型生成的 JSON 关键帧加契约测试:可解析、时间单调递增、未截断
Claude Code 2.5D 视差动画核心要点 Key Takeaways
- 导出是设计的一部分:在提示词最后几行写死纯函数 renderAt(t) 和 seek 契约
- 景深补偿公式:comp = 1 − z/perspective,尺寸和 x/y 偏移都要乘
- 视差从哪里来:补偿只修正静止构图,运动时各层仍按深度呈现不同速度
- 先看静帧再信镜头:起点、中点、终点三张图能发现读代码看不出的 bug
- 按帧序号驱动时间:Playwright seek 加截图,ffmpeg image2pipe 编码成 H.264
- effort 分阶段调整:首建用 high,微调用 medium,结构化 JSON 接口从 low 起步
- 模型输出要加契约测试:防止 max_tokens 截断导致关键帧解析失败
总结
用 Claude Code 搭建 2.5D 视差动画工作室,核心有三点:
- 架构:场景图、时间轴、确定性渲染器三件套,第三件决定成片质量
- 数学:透视补偿系数
1 - z/p同时作用于尺寸和偏移,静止时构图准确,运动时自带视差 - 管线:按
i/fps逐帧 seek、截图后交给 ffmpeg 编码,输出不受机器性能影响
建议先照着本文的提示词结构跑通一次 6 秒样片,养成“改镜头先看三张静帧”的习惯。之后再加图层、接入“描述镜头”接口,或者把同一套确定性思路迁移到 Remotion 等其他程序化视频方案。
📚 参考资料
- How to Build a 2.5D Motion Studio With Opus 5.5:本文实践来源
- 链接:
apidog.com/blog/build-2-5d-motion-studio-opus-5-5 -
说明: 包含完整提示词、工作室代码、导出脚本与“描述镜头”接口示例
-
Anthropic 模型与 effort 文档:模型定位、上下文、定价与 effort 参数说明
- 链接:
docs.anthropic.com -
说明: 核对 claude-opus-5-5 的模型 ID、API 版本头和 output_config 用法
-
ffmpeg 格式文档:image2pipe 等输入格式说明
- 链接:
ffmpeg.org/ffmpeg-formats.html -
说明: 理解 stdin 读取 PNG 序列与 H.264 编码参数
-
Playwright 文档:浏览器自动化与截图 API
- 链接:
playwright.dev - 说明: page.evaluate、page.screenshot 与浏览器安装命令
