Claude Opus 5.5 做视频动手篇:搭一条 seek(t) 渲染管线,实测做出一条 16 秒的片子
本站在服务器上搭了一套代码做视频的渲染管线并跑通:只由时间决定的 seek(t)、逐帧取图、ffmpeg 合成、代码合成配乐、节拍分析。再让 Claude Code 用 Opus 5.5 在里面做了一条 16 秒的片子,三轮自查,约 10.73 美元。代码、实测数字和成片都在文中。
本站已实测,测试日期:2026-10-13。
本页目录(11 节)
这篇解决一个问题:想让 Claude 写代码做视频,手边要先有一套什么样的东西,它才能自己渲染、自己看、自己改?
原理和提示词怎么写,本站已经有一篇《用 Claude Opus 5.5 写代码做视频》。这一篇是动手篇:把渲染管线真的搭起来,每一步给出能运行的代码和实测的数字,最后让 Claude Code 在这套管线里做出一条片子。
方法来自 @0xMovez 的长文《How to build motion design studio with Opus 5.5》。文中的代码是本站按同样的思路自己写的一版,和原文不一样的地方会说明;五张英文示意图来自那篇长文。
这是成片,16 秒,有声音,画面和声音全部是代码算出来的:
| 实测环境 | |
|---|---|
| 日期 | 2026-10-13 |
| 机器 | Linux 服务器,Ubuntu 24.04,8 核 |
| 软件 | Node.js 22.23,ffmpeg 6.1.1,Playwright 1.64(Chromium 156),librosa 1.0,Claude Code 2.1.296 |
| 模型 | Claude Opus 5.5,effort 设为 xhigh,通过中转站调用 |
先说实测的结论:
- 管线本身不用模型就能验证。 同一条片子渲两遍,输出的文件逐字节相同。
- 取帧的办法差 6 倍。 用 Playwright 的元素截图每帧 265 毫秒,改成在页面里直接导出画布是 41 毫秒。
- 节拍分析默认会差半拍。 速度量得准,位置却落在反拍上,差了 236 毫秒;只看低频之后是 12 毫秒。
- Claude 做这条片子用了 82 轮,模型干活的时间合计约 47 分钟,Claude Code 估算的花费约 10.73 美元。它自查了三轮,看了自己渲出来的图 31 次。
- 草稿查不出运动模糊带来的问题。 前两轮自查用的是不带运动模糊的草稿,跳动的数字重影、波形被抹掉这两处,到成片才看出来。
- 中途被打断过一次。 跑到 37 分半时接口返回了额度相关的错误,过了约二十分钟才恢复。长时间的任务要给额度留余量。
一、整条管线

Opus 5.5 不能直接输出视频。它写的是一个程序,由别的东西把程序变成一帧帧画面。上图是四条路线,这一篇走的是 A:画面全部由代码画,不依赖任何框架。按 @__morse 的观察,这也是 Opus 5.5 在没有别的要求时自己会选的做法:一个 HTML 文件,一个 seek 函数,用 Playwright 逐帧取图,再用 ffmpeg 编码。

本站这一版的文件:
| 文件 | 做什么 |
|---|---|
index.html |
页面。提供 window.seek(t):给一个时间,画出那一刻的画面 |
film.js |
片子本身。让 Claude 写的就是这个文件 |
lib/motion.js |
弹簧、轨迹、带种子的随机数这些动效函数 |
render.mjs |
开一个无界面的浏览器逐帧取图,接给 ffmpeg 编码 |
audio/score.mjs |
用代码合成配乐和音效,写成 WAV |
audio/beats.py |
量一段现成音乐的节拍 |
mix.sh、qa.sh |
混音;给成片出几张自查用的图 |
CLAUDE.md |
项目的规矩,Claude Code 每次启动都会读 |
二、装什么
# 1. ffmpeg
sudo apt install ffmpeg
# 2. 项目目录,Playwright 和它的浏览器
mkdir studio && cd studio && npm init -y
npm i -D playwright
npx playwright install chromium
# 3. 分析音乐用的 Python 库,装在项目自己的虚拟环境里
python3 -m venv .venv
.venv/bin/pip install numpy librosa soundfile
实测时的三处情况:
- 浏览器缺系统库。
npx playwright install-deps --dry-run chromium在这台机器上列出 32 个缺少的包,其中不少是带界面运行才用得到的。本站只装了无界面运行需要的 13 个(几个libatk、libcups、libxcomposite、libxdamage这类库,加三种字体),浏览器就能启动了。嫌麻烦可以直接sudo npx playwright install-deps chromium全装。 - 占的空间不小。 ffmpeg 连依赖 381 MB,Playwright 的浏览器 681 MB,Python 的虚拟环境 500 MB。
- macOS 上没有测。 ffmpeg 和 Node.js 用 Homebrew 装;Python 的库同样建议装在虚拟环境里。
三、核心约定:seek(t)
整条管线只立一条规矩:画面只由时间决定。 页面提供一个 seek(t),传进去第几秒,它就画出那一刻的画面;同一个 t 永远画出同一帧。
有了这一条,渲染就是把 t 从 0 走到结尾,每一步取一张图。不依赖计时器,所以每次渲染结果相同;想改一处,改完重渲就行;想只看第 812 帧,也不用先算前面的 811 帧。
index.html:
<!doctype html>
<meta charset="utf-8">
<title>film</title>
<style>
/* 字体放在项目里,用 @font-face 加载。别指望渲染的机器上装了哪种字体。 */
@font-face { font-family: "Studio"; src: url("assets/fonts/NotoSansSC.ttf"); font-weight: 100 900; }
html, body { margin: 0; background: #111; }
canvas { display: block; }
</style>
<canvas id="c"></canvas>
<script type="module">
import { film } from './film.js';
// 画面的逻辑尺寸取自 film.js;scale 只改变输出的像素数,画的内容不变。
const q = new URLSearchParams(location.search);
const W = Number(q.get('w')) || film.width;
const H = Number(q.get('h')) || film.height;
const scale = Number(q.get('scale')) || 1;
const canvas = document.getElementById('c');
canvas.width = Math.round(W * scale);
canvas.height = Math.round(H * scale);
const g = canvas.getContext('2d');
// 整条片子只有这一个入口:给一个时间,画出那一刻的画面。
window.seek = (t) => {
g.setTransform(scale, 0, 0, scale, 0, 0);
g.clearRect(0, 0, W, H);
film.draw(g, t, { W, H });
};
window.FILM = { duration: film.duration, width: canvas.width, height: canvas.height };
// 画布上的字要等字体真的加载完才能画。
await document.fonts.load('700 100px "Studio"', '字');
window.READY = true;
// 直接用浏览器打开时循环预览;渲染时由 render.mjs 一帧一帧地调 seek。
if (!q.has('render')) {
const t0 = performance.now();
(function loop() {
window.seek(((performance.now() - t0) / 1000) % film.duration);
requestAnimationFrame(loop);
})();
}
</script>
两处和原文不同,都是实测时定下来的:
- 字体放进项目,等它加载完再画。 画布上的字不会等字体,字体没到就用默认字体画了,而且不报错。本站把思源黑体(Noto Sans SC,一个文件包含全部字重,17.8 MB)放在
assets/fonts/里。 - 片子单独一个文件。
film.js用 ES 模块引进来,Claude 只改它,页面和渲染脚本不动。代价是不能再用file://直接打开,见第五节。
film.js 要导出的东西只有四样。下面是用来自检管线的最小片子,4 秒,两个镜头:
import { spring, track, span, easeOut, rng } from './lib/motion.js';
const INK = '#141413', PAPER = '#F0EEE6', CLAY = '#D97757';
const scenes = [
{ from: 0, to: 2, draw(g, t, { W, H }) {
const s = spring(t - 0.1, 220, 22);
g.fillStyle = PAPER; g.textAlign = 'center'; g.textBaseline = 'middle';
g.font = `900 ${Math.round(220 * (0.7 + 0.3 * s))}px "Studio"`;
g.globalAlpha = span(t, 0.05, 0.25);
g.fillText('渲染自检', W / 2, H / 2 - 40);
g.globalAlpha = 1;
g.fillStyle = CLAY;
const w = 760 * spring(t - 0.35, 170, 26);
g.fillRect(W / 2 - w / 2, H / 2 + 110, w, 16);
} },
{ from: 2, to: 4, draw(g, t, { W, H }) {
// 一个方块依次到三个位置:每次变化各加一根弹簧。
const x = track(t, [[0, 360], [0.3, W / 2], [1.0, W - 360]], 200, 18);
const r = rng(7);
for (let i = 0; i < 40; i++) {
const px = r() * W, py = r() * H, p = easeOut(span(t, i * 0.02, i * 0.02 + 0.5));
g.fillStyle = i % 5 ? 'rgba(240,238,230,0.35)' : CLAY;
g.fillRect(px - 6 * p, py - 6 * p, 12 * p, 12 * p);
}
g.fillStyle = PAPER;
g.fillRect(x - 120, H / 2 - 120, 240, 240);
} },
];
export const film = {
width: 1920, height: 1080, duration: 4,
draw(g, t, L) {
g.fillStyle = INK; g.fillRect(0, 0, L.W, L.H);
for (const s of scenes) if (t >= s.from && t < s.to) { g.save(); s.draw(g, t - s.from, L); g.restore(); }
},
};
四、动效函数:弹簧也只由时间决定
匀速或者固定曲线的运动看起来廉价。有质量感的运动会加速、稍微冲过头、再停稳,这就是弹簧。
通常弹簧是逐帧模拟的:这一帧的位置取决于上一帧。那就破坏了“只由时间决定”。办法是直接用公式算:弹簧从 0 弹到 1 的过程有解析解,传进去时间就得到位置。
lib/motion.js:
// 动效用的几个函数。全部只由传进来的时间决定,不记任何状态。
export const clamp = (x, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, x));
export const lerp = (a, b, p) => a + (b - a) * p;
// 弹簧的阶跃响应:t 秒时从 0 走到 1 走了多少。k 是刚度,d 是阻尼。
// 欠阻尼(会回弹)、临界阻尼、过阻尼三种情况都直接用公式算,不做逐帧模拟。
export function spring(t, k = 170, d = 26) {
if (t <= 0) return 0;
const w0 = Math.sqrt(k);
const z = d / (2 * w0);
if (z < 1) {
const wd = w0 * Math.sqrt(1 - z * z);
return 1 - Math.exp(-z * w0 * t) * (Math.cos(wd * t) + ((z * w0) / wd) * Math.sin(wd * t));
}
if (z === 1) return 1 - Math.exp(-w0 * t) * (1 + w0 * t);
const s = Math.sqrt(z * z - 1);
const r1 = -w0 * (z - s);
const r2 = -w0 * (z + s);
return 1 - (r2 * Math.exp(r1 * t) - r1 * Math.exp(r2 * t)) / (r2 - r1);
}
// 一个值先后有好几个目标(光标的位置、容器的宽度):每次变化各加一根弹簧。
// keys 是 [[时间, 值], ...],按时间排好。
export function track(t, keys, k = 170, d = 26) {
let v = keys[0][1];
for (let i = 1; i < keys.length; i++) v += (keys[i][1] - keys[i - 1][1]) * spring(t - keys[i][0], k, d);
return v;
}
// 在 [t0, t1] 之间从 0 到 1 的进度,两头截断。
export const span = (t, t0, t1) => clamp((t - t0) / (t1 - t0));
export const easeOut = (p) => 1 - (1 - p) ** 3;
export const easeInOut = (p) => (p < 0.5 ? 4 * p ** 3 : 1 - (-2 * p + 2) ** 3 / 2);
// 带种子的随机数。同一个种子每次给出同一串数,渲染才能重复。
export function rng(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let x = Math.imul(a ^ (a >>> 15), 1 | a);
x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
};
}
// 循环播放时把任意时间折回 [0, dur)。
export const loopT = (t, dur) => ((t % dur) + dur) % dur;
track 是最容易被忽略的一个。一个值先后有几个目标时,不要每次重新起一根弹簧,那样会在切换的瞬间跳一下。做法是每次变化各加一根弹簧、各从自己的时间开始,叠起来的运动是连续的,并且仍然只由时间决定。

原文给了四组参数。本站用上面的函数把它们算了一遍:
| 名字 | 刚度 k | 阻尼 d | 最大值(1 是目标) | 0.2 秒时走了多少 | 适合 |
|---|---|---|---|---|---|
| Snappy | 320 | 30 | 1.008 | 95% | 按钮、开关 |
| Default | 170 | 26 | 1.000 | 74% | 卡片、容器、镜头 |
| Heavy | 90 | 20 | 1.000 | 55% | 大字、标志 |
| Playful | 220 | 14 | 1.186 | 冲到 115% | 吉祥物、贴纸 |
算出来的数字和图上的曲线对得上。Heavy 这一组的阻尼比大于 1,属于过阻尼;原文的代码把这种情况当成临界阻尼算,本站这一版用了过阻尼的公式。
原文里弹簧那段代码有一处照抄会报错:文中说函数放在 lib/motion.js,但那段代码单独存成文件后,track 找不到 spring,另一个函数找不到 clamp,它们定义在别的代码块里。上面这一版是放在同一个文件里的。
五、渲染:render.mjs
// 把 index.html 渲染成视频,或者只导出几张静帧。
// node render.mjs 整片,60 帧,每帧 4 个子帧混出运动模糊
// node render.mjs --sub 1 --scale 0.5 半尺寸、不要运动模糊的草稿,快得多
// node render.mjs --from 4 --to 6 只渲第 4 到第 6 秒
// node render.mjs --stills 0.5,1,1.5 只导出这几个时刻的画面到 out/stills/
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync, existsSync, statSync } from 'node:fs';
import { extname, join, normalize, resolve } from 'node:path';
const argv = process.argv.slice(2);
const opt = (name, fallback) => { const i = argv.indexOf('--' + name); return i >= 0 ? argv[i + 1] : fallback; };
const FPS = Number(opt('fps', 60));
const SUB = Number(opt('sub', 4));
const SCALE = Number(opt('scale', 1));
const OUT = opt('out', 'out/silent.mp4');
const size = [opt('w'), opt('h')].every(Boolean) ? `&w=${opt('w')}&h=${opt('h')}` : '';
// 用一个只听本机的小服务器提供项目文件:ES 模块和字体从 file:// 打开时加载不了。
const ROOT = resolve('.');
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.ttf': 'font/ttf', '.otf': 'font/otf', '.woff2': 'font/woff2', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml' };
const server = createServer((req, res) => {
const file = join(ROOT, normalize(decodeURIComponent(new URL(req.url, 'http://x').pathname)));
if (!file.startsWith(ROOT) || !existsSync(file) || !statSync(file).isFile()) { res.writeHead(404).end(); return; }
res.writeHead(200, { 'Content-Type': TYPES[extname(file)] ?? 'application/octet-stream' }).end(readFileSync(file));
});
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
const browser = await chromium.launch();
const page = await browser.newPage({ deviceScaleFactor: 1 });
page.on('pageerror', (err) => { console.error('页面报错:' + err.message); process.exitCode = 1; });
await page.goto(`http://127.0.0.1:${server.address().port}/index.html?render=1&scale=${SCALE}${size}`);
await page.waitForFunction(() => window.READY === true, null, { timeout: 30_000 });
const film = await page.evaluate(() => window.FILM);
await page.setViewportSize({ width: film.width, height: film.height });
// 取一帧:在页面里画完,直接把画布导出成 PNG 传回来。
const shot = async (t) => {
const url = await page.evaluate((x) => { window.seek(x); return document.getElementById('c').toDataURL('image/png'); }, t);
return Buffer.from(url.slice(url.indexOf(',') + 1), 'base64');
};
mkdirSync('out/stills', { recursive: true });
if (opt('stills')) {
// 静帧:文件名带时间,同时打印每张图的哈希,方便比较两次渲染是不是一样。
for (const t of opt('stills').split(',').map(Number)) {
const png = await shot(t);
const name = `out/stills/t${t.toFixed(3).padStart(7, '0')}.png`;
writeFileSync(name, png);
console.log(name, createHash('sha1').update(png).digest('hex').slice(0, 12));
}
} else {
const from = Number(opt('from', 0));
const to = Number(opt('to', film.duration));
const total = Math.round((to - from) * FPS * SUB);
// 子帧按 FPS×SUB 的帧率送进去;tmix 把相邻 SUB 帧取平均,select 每 SUB 帧留一帧。
const blur = SUB > 1 ? ['-vf', `tmix=frames=${SUB},select=eq(mod(n\\,${SUB})\\,${SUB - 1}),setpts=N/${FPS}/TB`] : [];
const ffmpeg = spawn('ffmpeg', ['-y', '-loglevel', 'error', '-f', 'image2pipe', '-framerate', String(FPS * SUB), '-i', '-',
...blur, '-r', String(FPS), '-c:v', 'libx264', '-preset', 'medium', '-crf', '16', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', OUT],
{ stdio: ['pipe', 'inherit', 'inherit'] });
const started = Date.now();
for (let i = 0; i < total; i++) {
const png = await shot(from + i / (FPS * SUB));
if (!ffmpeg.stdin.write(png)) await new Promise((ok) => ffmpeg.stdin.once('drain', ok));
if (i % (FPS * SUB) === 0) console.log(`第 ${(from + i / (FPS * SUB)).toFixed(0)} 秒 / ${to} 秒`);
}
ffmpeg.stdin.end();
const code = await new Promise((ok) => ffmpeg.on('close', ok));
console.log(`${OUT}:${film.width}×${film.height},${FPS} 帧,${total} 张子帧,用时 ${((Date.now() - started) / 1000).toFixed(0)} 秒,ffmpeg 退出码 ${code}`);
if (code !== 0) process.exitCode = 1;
}
await browser.close();
server.close();
这个脚本里有四个决定,各有实测的依据。
取帧:别用元素截图
最直接的写法是每帧调一次 Playwright 的元素截图。本站第一版就是这样,4 秒的自检片子渲了很久。在 1920×1080 下各取 40 帧比了一下:
| 取帧的办法 | 每帧用时 |
|---|---|
| Playwright 的元素截图(PNG) | 265 毫秒 |
| 在页面里把画布导出成 PNG,传回来 | 41 毫秒 |
| 在页面里把画布导出成 JPEG(质量 0.95) | 55 毫秒 |
第二种快 6 倍,而且是无损的,所以用它。一条 16 秒的片子要取 3,840 张子帧,这个差别是十几分钟。
运动模糊:每帧多取几张再取平均
每一帧只取一张,快速移动的东西会一格一格地跳。办法是把时间切得更细:60 帧的片子,每帧取 4 个子帧,交给 ffmpeg 的 tmix 滤镜把相邻 4 张取平均,再每 4 张留 1 张。移动的东西就有了拖影。
代价是取帧的数量乘以 4。所以平时用 --sub 1 --scale 0.5 出草稿,定稿才用默认的参数。
本机的小服务器
film.js 是用 ES 模块引进来的,字体是用 @font-face 加载的。这两样从 file:// 打开的页面里都加载不了。所以渲染脚本先起一个只监听 127.0.0.1 的小服务器,端口由系统分配,渲完就关。
页面报错要让渲染失败
page.on('pageerror', …) 那一行不能省。片子的代码出了错,画布是空白的,不加这一行渲染照样“成功”,得到一条黑的视频。
自检的结果
用第三节那条 4 秒的片子:
| 检查 | 结果 |
|---|---|
| 取 4 张静帧,再倒着取一遍,比哈希 | 完全相同 |
| 半尺寸草稿(960×540,240 帧) | 9 秒 |
| 整片(1920×1080,960 张子帧) | 55 秒 |
| 整片再渲一遍,比两个文件 | 逐字节相同 |

“每次渲染都一样”是可以检查的,不只是一个说法。以后改了片子的代码,同样的检查能告诉你有没有不小心引进不确定的东西,比如忘了给随机数种子。
六、声音
没有声音的动效像半成品。声音有两条路:自己合成,或者用一段现成的音乐。

自己合成:audio/score.mjs
声音也可以只由时间决定。下面这个脚本合成一段 120 BPM 的配乐:每拍一个底鼓,反拍一个镲,低音走根音,上面一层分解和弦;再按 cues.json 里写的时间叠音效。
// 用代码合成配乐和音效,写成 WAV。没有随机成分,同样的输入每次得到同一个文件。
// node audio/score.mjs --bpm 120 --bars 8 --cues audio/cues.json --out out/score.wav
// cues.json: [{ "t": 2.0, "type": "thump" }, ...] type: click | pop | thump | whoosh
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
const argv = process.argv.slice(2);
const opt = (name, fallback) => { const i = argv.indexOf('--' + name); return i >= 0 ? argv[i + 1] : fallback; };
const SR = 48000;
const BPM = Number(opt('bpm', 120));
const BARS = Number(opt('bars', 8));
const OUT = opt('out', 'out/score.wav');
const cuesFile = opt('cues', 'audio/cues.json');
const cues = existsSync(cuesFile) ? JSON.parse(readFileSync(cuesFile, 'utf8')) : [];
const beat = 60 / BPM;
const length = Math.ceil((BARS * 4 * beat + 0.5) * SR);
const buf = new Float32Array(length);
const TAU = 2 * Math.PI;
const hz = (midi) => 440 * 2 ** ((midi - 69) / 12);
// 不用 Math.random:一个固定种子的线性同余数列当噪声。
let seed = 20261013;
const noise = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 2147483648) - 1;
// 把一个“从起音算起 t 秒时的振幅”函数叠到 start 秒处。
function add(start, dur, fn) {
const i0 = Math.floor(start * SR);
for (let i = 0; i < dur * SR && i0 + i < length; i++) if (i0 + i >= 0) buf[i0 + i] += fn(i / SR);
}
const voices = {
kick: (t) => Math.sin(TAU * (45 * t + (120 - 45) * (1 - Math.exp(-t * 30)) / 30)) * Math.exp(-t * 9) * 0.9,
hat: (t) => noise() * Math.exp(-t * 70) * 0.18,
click: (t) => Math.sin(TAU * 1800 * t) * Math.exp(-t * 90) * 0.45,
pop: (t) => Math.sin(TAU * (500 * t + 450 * t * t)) * Math.exp(-t * 28) * 0.4,
thump: (t) => Math.sin(TAU * (90 * t - 30 * t * t)) * Math.exp(-t * 8) * 0.8,
whoosh: (t) => noise() * Math.sin(Math.PI * Math.min(1, t / 0.4)) ** 2 * 0.22,
};
const note = (midi, amp) => (t) => {
const f = hz(midi);
// 基音加一点二次谐波;包络是很短的起音和指数衰减。
return (Math.sin(TAU * f * t) + 0.3 * Math.sin(TAU * 2 * f * t)) * Math.min(1, t / 0.01) * Math.exp(-t * 5) * amp;
};
// 四个和弦一轮:Am F C G。每小节一个和弦。
const chords = [[57, 60, 64], [53, 57, 60], [48, 52, 55], [55, 59, 62]];
for (let bar = 0; bar < BARS; bar++) {
const chord = chords[bar % chords.length];
for (let b = 0; b < 4; b++) {
const t = (bar * 4 + b) * beat;
add(t, 0.5, voices.kick); // 每一拍一个底鼓
add(t + beat / 2, 0.08, voices.hat); // 反拍的镲
add(t, beat * 0.9, note(chord[0] - 24, 0.32)); // 低音走根音
for (let s = 0; s < 4; s++) add(t + (s * beat) / 4, 0.35, note(chord[(b + s) % 3] + 12, 0.11)); // 十六分音符的分解和弦
}
}
for (const c of cues) {
if (!voices[c.type]) throw new Error(`不认识的音效类型:${c.type}`);
add(c.t, c.type === 'whoosh' ? 0.4 : 0.5, voices[c.type]);
}
// 尾巴淡出,整体用 tanh 压一下,免得叠起来的地方削波。
const fade = 0.4 * SR;
for (let i = 0; i < length; i++) buf[i] = Math.tanh(buf[i] * 1.1) * Math.min(1, (length - i) / fade);
// 16 位单声道 WAV。
const data = Buffer.alloc(44 + length * 2);
data.write('RIFF', 0); data.writeUInt32LE(36 + length * 2, 4); data.write('WAVEfmt ', 8);
data.writeUInt32LE(16, 16); data.writeUInt16LE(1, 20); data.writeUInt16LE(1, 22);
data.writeUInt32LE(SR, 24); data.writeUInt32LE(SR * 2, 28); data.writeUInt16LE(2, 32); data.writeUInt16LE(16, 34);
data.write('data', 36); data.writeUInt32LE(length * 2, 40);
for (let i = 0; i < length; i++) data.writeInt16LE(Math.round(Math.max(-1, Math.min(1, buf[i])) * 32767), 44 + i * 2);
mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, data);
console.log(`${OUT}:${(length / SR).toFixed(2)} 秒,${BPM} BPM,${BARS} 小节,${cues.length} 个音效`);
实测合成两次,两个文件相同。
自己合成有一个直接的好处:拍点不用量。 120 BPM 就是一拍 0.5 秒,第 n 拍在 n × 0.5 秒,画面直接照这个写。
用现成的音乐:audio/beats.py
用别人的音乐,就得先量出拍点在哪。
"""量出一段音乐的节拍,写成 JSON,画面按它来卡点。
用法:.venv/bin/python audio/beats.py 音乐.wav > audio/beats.json
加 --full-band 改成看全频段(默认只看 250 Hz 以下)
"""
import json
import sys
import librosa
import numpy as np
y, sr = librosa.load(sys.argv[1], sr=None, mono=True)
# 默认只看 250 Hz 以下,让节拍跟着底鼓和低音走。
# 看全频段时,节拍容易跟到反拍的镲上:速度是对的,位置整体差半拍。
band = {} if "--full-band" in sys.argv else {"fmax": 250, "n_mels": 24}
envelope = librosa.onset.onset_strength(y=y, sr=sr, **band)
tempo, frames = librosa.beat.beat_track(onset_envelope=envelope, sr=sr, units="frames")
beats = librosa.frames_to_time(frames, sr=sr)
# 把量到的拍点拟合成一条等间隔的网格(间隔和起点)。
# 画面按网格卡点,比逐拍用量到的值稳;开头量不到的那几拍也能往回推出来。
index = np.arange(len(beats))
period, first = np.polyfit(index, beats, 1)
residual = np.abs(beats - (first + index * period))
# 起音强度的峰值:鼓点和重音落在哪,音效和硬切可以对着它放。
strength = librosa.onset.onset_strength(y=y, sr=sr)
peaks = librosa.util.peak_pick(strength, pre_max=3, post_max=3, pre_avg=3, post_avg=5, delta=0.5, wait=10)
json.dump(
{
"duration": round(len(y) / sr, 3),
"bpm": round(float(np.atleast_1d(tempo)[0]), 2),
"beats": [round(float(t), 3) for t in beats],
"grid": {
"bpm": round(60 / float(period), 2),
"offset": round(float(first % period), 3),
"max_residual_ms": round(float(residual.max()) * 1000, 1),
},
"hits": [round(float(t), 3) for t in librosa.frames_to_time(peaks, sr=sr)],
},
sys.stdout,
indent=1,
)
这个脚本的默认做法是测出来的。 本站拿上面合成的配乐当考题,因为它的拍点是已知的。先用最常见的写法,也就是原文的写法,直接对整段音乐做节拍跟踪:
| 配乐 | 做法 | 量到的速度 | 拍点偏离真实位置 |
|---|---|---|---|
| 120 BPM | 看全频段 | 120.0 | 236 毫秒 |
| 120 BPM | 只看 250 Hz 以下 | 120.0 | 12 毫秒 |
| 96 BPM | 看全频段 | 96.0 | 299 毫秒 |
| 96 BPM | 只看 250 Hz 以下 | 96.0 | 10 毫秒 |
速度都量对了,位置却差了将近半拍:节拍跟到了反拍的镲上。按这个结果去卡点,每一次切换都会落在两拍中间。只看低频之后,节拍跟着底鼓走,偏差在一帧(16.7 毫秒)以内。
这是在本站合成的配乐上测的。换一段别的音乐,结果可能不同。所以量完之后要验一下:把 grid 里的起点和速度拿去,在拍点上放几个音效,混进去听一遍。
脚本输出里的 grid 是把逐拍量到的位置拟合成一条等间隔的网格。实测逐拍的值偏离网格最多 5 毫秒,开头的第一拍量不到,用网格可以往回推出来。
混音:mix.sh
#!/bin/bash
# 把画面和声音合成最终的文件,响度统一到 -14 LUFS。
set -euo pipefail
video=${1:-out/silent.mp4}; audio=${2:-out/score.wav}; out=${3:-out/final.mp4}
ffmpeg -y -loglevel error -i "$video" -i "$audio" -c:v copy \
-af "loudnorm=I=-14:TP=-1.5:LRA=11" -ar 48000 -c:a aac -b:a 192k -shortest -movflags +faststart "$out"
# 量一下成品的响度,看是不是真的到了目标值。
ffmpeg -hide_banner -nostats -i "$out" -af ebur128 -f null - 2>&1 | grep -E "^\s+I:" | tail -1
ffprobe -v error -show_entries format=duration,size -of default=noprint_wrappers=1 "$out"
目标是 -14 LUFS。实测量出来是 -14.8 到 -14.9:loudnorm 只过一遍时是边听边调的,到不了精确的目标值。差不到 1 个单位,本站没有再处理;要精确,得用它的两遍模式。
七、自查用的图:qa.sh
模型看不了视频,但能看图。把成片变成几张图,它就能检查自己的作品。
#!/bin/bash
# 给成片出几张检查用的图。用法:./qa.sh [视频] [要细看的时刻,秒]
set -euo pipefail
video=${1:-out/final.mp4}; at=${2:-1.0}
dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$video")
rows=$(python3 -c "import math,sys; print(max(1, math.ceil(float(sys.argv[1]) * 2 / 6)))" "$dur")
# 抽帧总览:每秒 2 帧,一行 6 张。
ffmpeg -y -loglevel error -i "$video" -vf "fps=2,scale=320:-1,tile=6x${rows}" -frames:v 1 out/contact.png
# 连续帧:某个快动作前后的 12 帧,看有没有跳变和重叠。
ffmpeg -y -loglevel error -ss "$at" -i "$video" -vf "scale=320:-1,tile=12x1" -frames:v 1 out/strip.png
# 手机尺寸:缩到 360 像素宽,看字还认不认得出。
ffmpeg -y -loglevel error -i "$video" -vf "fps=1,scale=360:-1,tile=5x$(( (${dur%.*} + 4) / 5 ))" -frames:v 1 out/phone.png
# 循环接缝:连着放两遍。
ffmpeg -y -loglevel error -stream_loop 1 -i "$video" -c copy out/loop_check.mp4
ls -la out/contact.png out/strip.png out/phone.png out/loop_check.mp4 | awk '{print $5, $9}'

三张图各看一样东西:抽帧总览看节奏和重复;连续帧看快动作里有没有跳变;手机尺寸看字缩小之后还认不认得出。
八、让 Claude 在里面做一条片子
到这里管线是通的,但 film.js 里还只是一条自检片子。接下来把它交给 Claude Code。
项目的规矩:CLAUDE.md
# 动效工作室的规矩
这个项目用代码做视频。你只在本目录里工作,不安装任何软件,不改 `render.mjs` 和 `index.html`。
## 渲染约定
- 片子写在 `film.js` 里,导出 `film = { width, height, duration, draw(g, t, { W, H }) }`。
- `draw` 只由 `t` 决定:同一个 `t` 永远画出同一帧。不用 `Math.random`、`Date`、计时器,帧和帧之间不保存任何状态。要随机数用 `lib/motion.js` 的 `rng(seed)`。
- 运动用 `lib/motion.js` 里的 `spring`、`track`、`span`、`easeOut`。一个值先后有几个目标时用 `track`,不要重新起一根弹簧。可以往这个文件里加函数。
- 坐标按 1920×1080 写。字体只用 `"Studio"`(思源黑体,字重 100 到 900),中文和英文都用它。
## 命令
node render.mjs --stills 0.5,1.0,1.5 # 导出这几个时刻的画面到 out/stills/
node render.mjs --sub 1 --scale 0.5 --out out/draft.mp4 # 半尺寸草稿,快
node render.mjs # 整片,1920×1080,60 帧,带运动模糊,输出 out/silent.mp4
node audio/score.mjs --bpm 120 --bars 8 --cues audio/cues.json --out out/score.wav
./mix.sh # out/silent.mp4 + out/score.wav → out/final.mp4
./qa.sh out/draft.mp4 3.0 # 出 out/contact.png、out/strip.png、out/phone.png
## 画面
- 不要这几样:渐变背景上居中的标题、所有东西都是淡入、角落里的小标签和边框、界面元素上的发光、通用的粒子爆炸。
- 一种强调色。每 2 到 4 秒画面上要有新的东西。
- 字要大:缩到 360 像素宽还得认得出。
## 声音
- 配乐和音效用 `audio/score.mjs` 合成,120 BPM,一拍 0.5 秒。
- 镜头切换和关键动作落在拍点上;音效写进 `audio/cues.json`,时间和画面上的动作对齐。
## 给我看之前先自己查
1. 写完一个镜头就导出它的几张静帧,**打开图片看**,不要只看代码。
2. 全部镜头写完后渲一条草稿,跑 `./qa.sh`,打开 `out/contact.png` 和 `out/phone.png` 看。
3. 按 1 到 10 打分:开头 2 秒抓不抓人、手机尺寸下看不看得清、运动质量、变化够不够、构图、声音对不对得上。
4. 把最严重的 3 个问题和时间点记到 `docs/review_log.md`,改掉,再查一轮。至少查两轮。
5. 最后才做整片渲染和混音。
简报
提示词写成一份简报,存在 brief.md 里。要点是这几块:
- 一句话:这条片子讲它自己是怎么来的,一段提示词变成一个函数,函数画出 960 帧,帧接成视频。看完的人要记住:这条视频是代码写出来的。
- 内容:16 秒,5 个镜头,各自的起止时间和要讲的事(开头的大字、
seek(t)、960 帧、声音、结尾的站名)。具体怎么画由它定。 - 外观:深色底、纸白、一种强调色,三个色值写明。
- 步骤:先写分镜表,再一个镜头一个镜头写,写完就导出静帧看;合成配乐;至少查两轮;最后整片渲染和混音。
- 交付:成片、抽帧图、分镜表、自查记录,以及它自己觉得哪里还不够好。
启动
claude -p "$(cat brief.md)" --effort xhigh --dangerously-skip-permissions --max-budget-usd 25
三个参数:
--effort xhigh:Opus 5.5 默认是 medium。原文说广为流传的那些一句话出片,用的都是 xhigh 或 max。--dangerously-skip-permissions:它要反复运行渲染、ffmpeg 和 Python,逐个批准不现实。这个参数只应该在隔离的环境里用。 本站是在服务器上一个没有管理员权限的专用用户下、只放着这个项目的目录里跑的。隔离的办法见《安全机制一览》。--max-budget-usd 25:给这次运行设一个花费上限。
它做了什么
全程没有人插手,下面是它自己的做法,按先后:
- 先把项目读了一遍,然后扩展了配乐脚本:加了三种音色,让和弦可以按小节指定、各个声部可以按时间段开关。规矩里没有禁止它改这个文件。
- 写分镜表,70 行。每个动作写明落在第几秒、第几拍,屏幕上是什么字,配什么音效。
- 一个镜头一个镜头写
film.js,写完一个就导出几张静帧,拼成一张图打开看。它为此自己写了两个小脚本:一个把静帧拼成总览,一个在转场前后抽连续帧。 - 渲草稿,做第 1 轮自查。 六项打分在 6 到 8 分之间,最严重的三个问题记进了
docs/review_log.md。 - 改完再渲草稿,做第 2 轮自查。 它还用
beats.py量了自己合成的配乐,确认拍点对得上;又核对了混音前后声音有没有被错开。
前两轮它找到的问题和改法:
| 轮次 | 最严重的问题 | 怎么改的 |
|---|---|---|
| 1 | 960 格阵列是一堵灰墙,数帧的时候看不出扫到了哪 | 扫过的格子提亮,没扫过的压暗,扫描头拖一条强调色的尾巴 |
| 1 | 格子飞进画框时堆成一个台阶形,看不出是“汇到一起” | 离画框近的先飞,从四周往里收 |
| 1 | 结尾构图散,右半边空着 | 结尾改成和开场成对:先出一行代码,再删掉代码的外壳,只剩站名 |
| 2 | 声音镜头 4 秒一个样 | 后半段把波形放大,叠一条按公式算出来的底鼓波形线 |
| 2 | 第 0 帧只有一个字,当封面太空 | 第一行字的起始时间往前错开 |
| 2 | 数到 960 的那一下没有力 | 数满时整个阵列闪一下强调色 |
然后被打断了。 跑到 37 分半、第 2 轮的修改刚做完,接口返回了一个额度相关的错误,会话结束。马上用 claude --resume 接着做,连续两次被同样的错误拒绝;同一时间发一个很短的请求却是正常的。过了约二十分钟再试,恢复了。
等的这段时间里,本站手动跑了剩下的固定步骤:整片渲染和混音,得到第一版成片。这几步不需要模型。
恢复之后,先让它看第一版成片。它的判断是:第 2 轮的三处修改两处生效,声音镜头那一处只生效了一半。它还发现了两个草稿里没有的问题:

- 跳动的数字叠成了重影。 成片的每一帧由 4 个子帧混成,计数器在子帧之间变了数,几个数字就叠在同一帧里。上图的上半部分就是。
- 底鼓的波形线被抹成了一团灰。 波形在滚动,运动模糊的长度和振荡的周期差不多长,线就没了。
原因是前两轮自查看的都是草稿,草稿不带运动模糊。 这是本站给它的命令里的一个坑:为了快,草稿用的是 --sub 1。
第 3 轮它只改这两处,办法是同一个:加一个函数把时间取整到所在的那一帧,数字和波形按帧取值,同一帧的 4 个子帧画出同样的内容;游标、镜头这些真正在动的东西照旧按子帧画,保留运动模糊。改完先用带运动模糊的局部渲染(--from 7 --to 8.5)验证,再重新出整片。
教训是:最后一轮自查要看带运动模糊的版本,哪怕只渲出问题的那几秒。
成片

它想到的核心手法值得说一下,这是简报里没有的:因为 draw(t) 只由时间决定,片子可以在画面里画出它自己的任意一帧。 第二个镜头的预览窗口、第三个镜头的 960 格阵列,里面画的都是这条片子真实的帧,不是示意图。预览窗口里甚至套着它自己,一层套一层,它给嵌套设了三层的上限。
还不够好的地方。前三条是它自己列的,后两条是本站看到的:
- 手机尺寸下有两处只剩纹理:第二个镜头底下的时间轴缩略图,和 960 格的全景。
- 第 9 秒画框里的快进太快,看得出在放片子,看不清在放什么。
- 原始文件 317 MB。 胶片颗粒每一帧都在变,加上渲染脚本用的是接近无损的质量。上面嵌的视频是另外压过的,1280×720,3.8 MB。
- 代码那几行字的字距不匀,比如结尾的
chunduai.com,i和.两边有空隙。项目里只放了一种字体,它把这种不等宽的字体按等宽排了。再放一个等宽字体进去能解决,这次没有再改。 - 配乐只有一个固定的套路,四个和弦加鼓。能卡上拍点,谈不上好听。
九、花了多少
模型这一头,按 Claude Code 自己的估算:
| 阶段 | 轮数 | 模型用时 | 累计花费 |
|---|---|---|---|
| 第一次运行:分镜、写片子、两轮自查 | 57 | 37.5 分钟 | 6.87 美元 |
| 恢复后看第一版成片、写总结 | 4 | 2.6 分钟 | 8.28 美元 |
| 第 3 轮:改两处、验证、重新出片、复查 | 21 | 7.2 分钟 | 10.73 美元 |
| 合计 | 82 | 约 47 分钟 | 10.73 美元 |
三段一共调用工具 75 次:运行命令 40 次,读文件 32 次(其中 31 次是看图),写文件 3 次。第一次运行输出了 14.3 万 token,读缓存 715 万 token,写缓存 51 万 token。花费的大头是每一轮都要重新读一遍越来越长的上下文。
机器这一头:
| 步骤 | 用时 |
|---|---|
| 半尺寸草稿(960 帧,不带运动模糊) | 105 秒 |
| 整片(1920×1080,3,840 张子帧) | 15 到 16 分钟 |
这条片子渲得比自检片子慢得多,每张子帧约 234 毫秒,因为它在画面里反复画自己。
从启动到拿到最终的成片,前后约一个半小时,其中二十分钟在等额度恢复。
作为对照,原文引用的一个案例是 @mablesjoseph 的 45 秒手绘短片:163 次模型调用,按 API 的标价约 34 美元,前后将近 7 小时,其中人动手的时间约一个半小时。作者自己说,那不是“一句话出片”。
十、通过中转站用,有什么不一样
- 看图是能用的。 自查这一步要把渲出来的图交给模型看,这次通过中转站一共看了 31 次,都正常。
- 长任务要给额度留余量。 这次用的 key 设了额度上限。跑到第 37 分半,接口返回了一个额度相关的错误,会话中断;小的请求仍然能过,带着整段会话上下文的请求被拒绝,过了约二十分钟才恢复。会话可以用
--resume接着做,没有丢东西,但人得在场。 - 花费以中转站的账单为准。 文中的金额是 Claude Code 按官方价格估算的。
这次实测没有覆盖到的
- 只做了一条片子。 换一个题材、换一份简报,结果和花费都可能差很多。
- 只在 Linux 上测过。 macOS 和 Windows 上的安装和字体没有测。
- 只走了纯代码这一条路线。 Remotion、HyperFrames 这类框架,以及混合视频模型的做法没有测,见《用 Claude Opus 5.5 写代码做视频》。
- 节拍分析只在本站合成的配乐上测过,没有用真实的歌。
- 没有做多种画幅。 页面支持传宽高,但这条片子是按 1920×1080 写的,没有试竖版。
- 打分是模型给自己打的。 它说 8 分,不等于你会满意。
- effort 只用了 xhigh,没有和别的档位比。