ClaudeClaude Code

Claude Opus 5.5 做视频动手篇:搭一条 seek(t) 渲染管线,实测做出一条 16 秒的片子

本站在服务器上搭了一套代码做视频的渲染管线并跑通:只由时间决定的 seek(t)、逐帧取图、ffmpeg 合成、代码合成配乐、节拍分析。再让 Claude Code 用 Opus 5.5 在里面做了一条 16 秒的片子,三轮自查,约 10.73 美元。代码、实测数字和成片都在文中。

发布 · 约 32 分钟读完 · 经人工审核()· 本站的内容怎么来的

本站已实测,测试日期:2026-10-13。

本页目录(11 节)
  1. 一、整条管线
  2. 二、装什么
  3. 三、核心约定:seek(t)
  4. 四、动效函数:弹簧也只由时间决定
  5. 五、渲染:render.mjs
  6. 六、声音
  7. 七、自查用的图:qa.sh
  8. 八、让 Claude 在里面做一条片子
  9. 九、花了多少
  10. 十、通过中转站用,有什么不一样
  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 是纯代码画面加无界面浏览器,B 是 Remotion 或 HyperFrames 这类框架,C 是混合图像和视频模型,D 是剪辑已有的素材

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

管线的示意图:index.html 提供 window.seek(t),render.mjs 按 t = i ÷ (帧率 × 子帧数) 逐帧调用,ffmpeg 用 tmix 混成 60 帧,得到没有声音的视频,再和配乐合成最终的文件

本站这一版的文件:

文件 做什么
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 是最容易被忽略的一个。一个值先后有几个目标时,不要每次重新起一根弹簧,那样会在切换的瞬间跳一下。做法是每次变化各加一根弹簧、各从自己的时间开始,叠起来的运动是连续的,并且仍然只由时间决定。

四组弹簧参数的响应曲线:Snappy 很快到位,Default 平稳到位,Heavy 慢慢到位,Playful 冲过 1 再回来

原文给了四组参数。本站用上面的函数把它们算了一遍:

名字 刚度 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}'

自查循环的示意图:每一拍渲一张静帧,拼成抽帧图,按七个方面打 1 到 10 分,把最严重的三个问题改掉,重复到每一项都在 8 分以上再交付

三张图各看一样东西:抽帧总览看节奏和重复;连续帧看快动作里有没有跳变;手机尺寸看字缩小之后还认不认得出。

八、让 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:给这次运行设一个花费上限。

它做了什么

全程没有人插手,下面是它自己的做法,按先后:

  1. 先把项目读了一遍,然后扩展了配乐脚本:加了三种音色,让和弦可以按小节指定、各个声部可以按时间段开关。规矩里没有禁止它改这个文件。
  2. 写分镜表,70 行。每个动作写明落在第几秒、第几拍,屏幕上是什么字,配什么音效。
  3. 一个镜头一个镜头写 film.js,写完一个就导出几张静帧,拼成一张图打开看。它为此自己写了两个小脚本:一个把静帧拼成总览,一个在转场前后抽连续帧。
  4. 渲草稿,做第 1 轮自查。 六项打分在 6 到 8 分之间,最严重的三个问题记进了 docs/review_log.md。
  5. 改完再渲草稿,做第 2 轮自查。 它还用 beats.py 量了自己合成的配乐,确认拍点对得上;又核对了混音前后声音有没有被错开。

前两轮它找到的问题和改法:

轮次 最严重的问题 怎么改的
1 960 格阵列是一堵灰墙,数帧的时候看不出扫到了哪 扫过的格子提亮,没扫过的压暗,扫描头拖一条强调色的尾巴
1 格子飞进画框时堆成一个台阶形,看不出是“汇到一起” 离画框近的先飞,从四周往里收
1 结尾构图散,右半边空着 结尾改成和开场成对:先出一行代码,再删掉代码的外壳,只剩站名
2 声音镜头 4 秒一个样 后半段把波形放大,叠一条按公式算出来的底鼓波形线
2 第 0 帧只有一个字,当封面太空 第一行字的起始时间往前错开
2 数到 960 的那一下没有力 数满时整个阵列闪一下强调色

然后被打断了。 跑到 37 分半、第 2 轮的修改刚做完,接口返回了一个额度相关的错误,会话结束。马上用 claude --resume 接着做,连续两次被同样的错误拒绝;同一时间发一个很短的请求却是正常的。过了约二十分钟再试,恢复了。

等的这段时间里,本站手动跑了剩下的固定步骤:整片渲染和混音,得到第一版成片。这几步不需要模型。

恢复之后,先让它看第一版成片。它的判断是:第 2 轮的三处修改两处生效,声音镜头那一处只生效了一半。它还发现了两个草稿里没有的问题:

同一时刻的两帧:上面是第一版成片,计数器中间一位叠着好几个数字的重影;下面是修改之后,是干净的 350

  • 跳动的数字叠成了重影。 成片的每一帧由 4 个子帧混成,计数器在子帧之间变了数,几个数字就叠在同一帧里。上图的上半部分就是。
  • 底鼓的波形线被抹成了一团灰。 波形在滚动,运动模糊的长度和振荡的周期差不多长,线就没了。

原因是前两轮自查看的都是草稿,草稿不带运动模糊。 这是本站给它的命令里的一个坑:为了快,草稿用的是 --sub 1。

第 3 轮它只改这两处,办法是同一个:加一个函数把时间取整到所在的那一帧,数字和波形按帧取值,同一帧的 4 个子帧画出同样的内容;游标、镜头这些真正在动的东西照旧按子帧画,保留运动模糊。改完先用带运动模糊的局部渲染(--from 7 --to 8.5)验证,再重新出整片。

教训是:最后一轮自查要看带运动模糊的版本,哪怕只渲出问题的那几秒。

成片

成片的抽帧图,每秒两格:开头两行大字,退后露出它们是代码里的字符串;带时间轴的预览窗口,窗口里又套着这条片子自己;从 1 数到 960 的帧阵列;带波形和拍点的声音镜头;结尾的站名和网址

它想到的核心手法值得说一下,这是简报里没有的:因为 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,没有和别的档位比。

来源

通过 API 中转站使用这些模型?到 Veridrop 免费检测中转站给你的是不是它声称的模型,或者看看中转站排行榜前 100 名。

相关文章