跳到主要内容
AIDEV CATALOGNOTE

不找配乐也能出片:TTS 驱动的手绘动画短片手把手教程

照着 PDoomVideo 做 MV,最费事的不是画面,而是音乐:得先有一首歌,再测 BPM、找第一拍、抄歌词时间轴,然后才能让画面卡点(见 上一篇拆解)。想「今天有个点子,今天就出片」,这一步就是拦路虎。

这篇换一个思路:不去对齐已有的音乐,而是让声音从脚本里生成出来。旁白用 TTS 合成,配乐用代码现场合成,时间轴是生成过程的副产品,画面直接读时间轴。整条链路没有任何需要人工测量、对齐的环节。

画面部分沿用 ClaudeAnimationBase1(p5.brush 水彩 + 墨线,和 PDoomVideo2 同一套引擎),声音部分复用 claude-cartoon 项目里已经跑通的豆包 TTS3 和程序化 BGM。


一、先选声音方案​

方案声音来源准备工作适合状态
A. 纯画面 + BGM代码合成的尤克里里 BGM零无对白的小短片(像 Base 模板的 demo)本文实测 ✅
B. 旁白绘本(推荐)TTS 旁白 + 代码 BGM + 代码音效写几句台词讲故事、科普、角色独白本文实测 ✅
C. 歌曲 MVAI 生成的歌曲要测 BPM、对歌词时间真正的 MV思路,未实测

方案 B 是这篇的主线:它不需要任何素材,Clawd 的动作还能自动跟着台词走。方案 A 是 B 的子集(没有台词),方案 C 放到第七章当后续方向。

核心设计:时间轴从「测量」变成「生成」​

PDoom 的做法(先有歌):
歌曲 mp3 ──人工测量──► BPM / offset / 歌词时间轴 ──► 画面对齐

本文的做法(先有脚本):
story.mjs(台词)
│
▼
tools/soundtrack.mjs
├─► 逐句 TTS ──► 读出每句真实时长 ──► 算出时间轴 ──► src/voice.gen.js(画面读)
├─► 人声 + BGM(104 BPM) + 音效 ──► 混音 ──► assets/audio.wav
└─► out/video.srt(外挂字幕)
│
▼
render.mjs:画面按 VO 时间轴画,音轨直接混进 mp4

这样有三个直接好处:

  • 改台词不用改动画:台词变长变短,镜头时长和动作时间点自动跟着变。
  • BPM 是已知量:BGM 是按 104 BPM 合成的,把 PROJECT.bpm 设成 104,Clawd 所有律动天然踩在音乐上,不用测。
  • TTS 有缓存:按「音色 | 语速 | 文本」哈希存文件,只改一句就只重新合成一句,不重复计费。

二、一次性准备(约 10 分钟)​

前置条件​

依赖检查命令说明
Node.js 20.6+node -v需要 --env-file 和内置 fetch
Google Chrome装在 /Applications 下即可Base 的 render.mjs 会自动查找
ffmpegffmpeg -versionrender.mjs 编码 mp4 用系统 ffmpeg
豆包 TTS 凭证claude-cartoon/.env 里的 JIMENG_APPID / JIMENG_ACCESS_TOKEN只做方案 A 可以不要

步骤 1:从模板复制出一个工作项目​

模板保持干净,另复制一份当工作项目:

cd ~/bugcave/ai-toys
cp -R ClaudeAnimationBase clawd-studio
cd clawd-studio
rm -rf .git out && git init
npm install
npm i -D ffmpeg-static # 音频工具解码 TTS 用

步骤 2:搬入音频工具和凭证​

tts.mjs(豆包 TTS + 缓存 + 关水印)和 audio.mjs(BGM、13 种音效、人声避让混音)直接从 claude-cartoon 复制,不用改:

mkdir -p tools assets
cp ../claude-cartoon/engine/src/tts.mjs ../claude-cartoon/engine/src/audio.mjs tools/
cp ../claude-cartoon/.env .env
echo ".env" >> .gitignore

.env 里是 TTS 凭证,务必进 .gitignore。

步骤 3:新建胶水脚本 tools/soundtrack.mjs​

这是本方案唯一需要新写的代码,把「台词脚本」变成「时间轴 + 音轨 + 字幕」:

// soundtrack.mjs: story.mjs → TTS(带缓存)→ 时间轴 → src/voice.gen.js + assets/audio.wav + out/video.srt
// node --env-file=.env tools/soundtrack.mjs 有台词:配音 + BGM + 音效
// node tools/soundtrack.mjs --bgm=15 无台词:只生成 15 秒 BGM
import { mkdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { synthesize } from './tts.mjs';
import { SR, decode, mixdown } from './audio.mjs';

const LINE_GAP = 0.45; // 同一镜头内两句台词之间的停顿(秒)
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, v] = a.replace(/^--/, '').split('='); return [k, v ?? true]; }));
mkdirSync('assets', { recursive: true }); mkdirSync('out', { recursive: true });

if (args.bgm) {
mixdown({ duration: +args.bgm, voices: [], sfx: [], out: 'assets/audio.wav' });
writeFileSync('src/voice.gen.js', `// 由 tools/soundtrack.mjs 生成,不要手改\nconst VO = ${JSON.stringify({ duration: +args.bgm, shots: {} })};\n`);
console.log(`BGM ${args.bgm}s → assets/audio.wav`);
process.exit(0);
}

const { VOICE, SHOTS } = await import(pathToFileURL(resolve('story.mjs')).href);

// 1. 逐句 TTS:按「音色|语速|文本」缓存在 assets/tts/,改一句只重合成一句
const lines = SHOTS.flatMap(s => s.lines || []);
for (const [i, l] of lines.entries()) {
l.file = await synthesize(l.text, { ...VOICE, ...(l.voice && { voice: l.voice }) }, 'assets/tts');
l.dur = decode(l.file).length / SR;
console.log(`TTS ${i + 1}/${lines.length} ${l.dur.toFixed(2)}s ${l.text}`);
}

// 2. 时间轴:镜头时长由台词真实时长决定;无台词镜头用 hold 秒
let cursor = 0;
const VO = { duration: 0, shots: {} }, voices = [], sfx = [], srt = [];
for (const s of SHOTS) {
const lead = s.lead ?? .6, tail = s.tail ?? .8, shot = { start: cursor, lines: [] };
let local = lead;
for (const l of s.lines || []) {
shot.lines.push({ start: +local.toFixed(3), end: +(local + l.dur).toFixed(3), text: l.text });
voices.push({ at: cursor + local, file: l.file });
srt.push([cursor + local, cursor + local + l.dur, l.text]);
local += l.dur + LINE_GAP;
}
shot.dur = +((s.lines?.length ? shot.lines.at(-1).end + tail : s.hold ?? 2)).toFixed(3);
for (const [li, off, name] of s.sfx || []) sfx.push({ at: cursor + (li < 0 ? 0 : shot.lines[li].start) + off, name });
VO.shots[s.id] = shot;
cursor += shot.dur;
}
VO.duration = +cursor.toFixed(3);

// 3. 产物:页面读的时间轴、混好的音轨、外挂字幕
writeFileSync('src/voice.gen.js', `// 由 tools/soundtrack.mjs 生成,不要手改\nconst VO = ${JSON.stringify(VO, null, 1)};\n`);
mixdown({ duration: VO.duration, voices, sfx, out: 'assets/audio.wav' });
const ts = x => new Date(x * 1000).toISOString().slice(11, 23).replace('.', ',');
writeFileSync('out/video.srt', srt.map(([a, b, t], i) => `${i + 1}\n${ts(a)} --> ${ts(b)}\n${t}\n`).join('\n'));
console.log(`时长 ${VO.duration}s,${SHOTS.length} 个镜头 → src/voice.gen.js, assets/audio.wav, out/video.srt`);

为什么输出的是 src/voice.gen.js 而不是 JSON:Base 的页面用普通 <script> 加载(不是 ES module),也可能以 file:// 打开,直接生成一个定义全局 VO 的 JS 文件最省事,不用 fetch。

步骤 4:让画面读取时间轴​

改 src/config.js,时长取自 VO,BPM 固定为 BGM 的 104:

// config.js: project settings. 时长来自 tools/soundtrack.mjs 生成的 VO;bpm 与 BGM 固定的 104 一致,Clawd 的律动才会踩在音乐上
const PROJECT = { duration: VO.duration, bpm: 104, offset: 0, audio: 'assets/audio.wav' };

改 studio.html,在 config.js 之前加载 voice.gen.js:

<!-- engine -->
<script src="src/voice.gen.js"></script>
<script src="src/config.js"></script>
<script src="src/core.js"></script>

步骤 5:写项目级 CLAUDE.md​

把固定约定写进项目的 CLAUDE.md,以后每次的提示词只需要写「这一支讲什么」:

# clawd-studio

手绘水彩动画短片:p5.brush 画面(ClaudeAnimationBase)+ 豆包 TTS 旁白 + 程序化 BGM。

## 流程(每支视频都按这个顺序)

1. 读 ANIMATION_GUIDE.md(画面规则全部遵守,除了下面「和指南的差异」)。
2. 写 STORYBOARD.md(按指南格式)和 story.mjs(台词脚本,格式见 tools/soundtrack.mjs 与现有 story.mjs)。
3. 运行 `node --env-file=.env tools/soundtrack.mjs`,确认每句时长和总时长合理。
4. 在 src/scenes/ 写场景,studio.html 只引用这一支的场景文件。镜头边界用 VO.shots.<id>.start,
镜头内动作对齐台词用 VO.shots.<id>.lines[i].start / end(镜头内时间,和 lt 同一坐标)。
5. 按指南的 review loop 用 --sheet / --strip 看图自查,全部镜头过关再出片:
`node render.mjs --clip --out=out/video.mp4`。
6. 验收:ffprobe 确认 1920×1080、有音频流、时长等于 VO.duration;抽 3~4 帧复查。
声音 Agent 听不到,交付时提醒人工试听 assets/audio.wav。

## 和指南的差异

- 有旁白:画面「演」台词的意思,不把台词写在画面上;字幕只用外挂的 out/video.srt。
- PROJECT.bpm 固定 104(BGM 的速度),不要改,除非同时改 tools/audio.mjs 里的 BGM 速度。
- 每句台词约 35 个汉字以内;每个镜头 1~3 句。
- 声音事件(出现、落地、变身)在 story.mjs 的 sfx 里配音效,可用:pop tick ding boing clunk click flip whoosh fizz scribble buzz sparkle magic。

## TTS

- 只用 *_bigtts 大模型音色,音色表见 ../claude-cartoon/docs/api-tutorial.md。
- tools/tts.mjs 里 `aigc_watermark: false` 是关水印参数,保持原样。
- 凭证只通过 `node --env-file=.env` 注入,代码和日志里不写凭证值。

步骤 6:冒烟测试​

先用方案 A 的模式生成一段 11 秒 BGM(Base 自带 demo 就是 11 秒),直接渲染 demo,确认整条链路通:

node tools/soundtrack.mjs --bgm=11
node render.mjs --clip --out=out/video.mp4

打开 out/video.mp4,能看到 Clawd 追星星、听到尤克里里 BGM,准备工作就完成了。


三、每次出一支视频:五步​

第 1 步:一句话提需求​

在 clawd-studio 里打开 Claude Code(建议 Opus 5.5,推理强度 xhigh),发一句话:

做一支约 40 秒的短片:Clawd 第一次学骑自行车。旁白用 zh_male_wennuanahu_moon_bigtts,温暖、有点幽默。
先写 STORYBOARD.md 和 story.mjs,跑完 soundtrack 后停下来给我看时长,确认后再画。

需要控制更多细节时按需追加:

- 主角:{{默认 Clawd;也可以让它设计新角色}}
- 结构:{{开头 5 秒抓人的反差 / 中间三次失败 / 结尾反转}}
- 色彩弧线:{{如:清晨冷蓝 → 午后暖黄}}
- 结尾和开头押韵:{{如:开头摔倒的坡道,结尾轻松骑过}}

第 2 步:审分镜和台词​

Claude 会产出两个文件。story.mjs 的样子:

// 旁白脚本:每个镜头的台词、留白和音效;镜头时长由 TTS 实际时长自动算出
export const VOICE = { voice: 'zh_male_wennuanahu_moon_bigtts', speed: 1.05 };
export const SHOTS = [
{ id: 'open', hold: 2.5, sfx: [[-1, 1.2, 'sparkle']] }, // 无台词镜头:固定 2.5 秒
{ id: 'bike', lead: .6, tail: .8, sfx: [[1, .3, 'clunk']], lines: [
{ text: '这是 Clawd,今天它要挑战一件大事。' },
{ text: '学骑自行车。' },
] },
{ id: 'fall', lead: .4, tail: 1.2, lines: [
{ text: '第一次,它连车都没扶稳。', voice: 'zh_female_kailangjiejie_moon_bigtts' }, // 单句可换音色
] },
];
字段含义默认
id镜头名,场景代码里用 VO.shots.<id> 取时间必填
lines[].text这句旁白—
lines[].voice单句换音色(对话、角色独白)用 VOICE.voice
lead第一句之前的留白0.6 s
tail最后一句之后的留白,给反应和转场0.8 s
hold无台词镜头的时长2 s
sfx[台词序号, 偏移秒, 音效名],序号 -1 表示从镜头开头算无

审的时候重点看三件事:

  1. 每个镜头都有事件:分镜里写的不能只是「Clawd 很开心」,得是「Clawd 扶车 → 车倒了 → 它愣住」。
  2. tail 够不够:台词说完之后的反应、转场都在 tail 里发生。有笑点或反转的镜头,tail 给到 1~1.5 s。
  3. 无台词镜头别省:开场、动作高潮、结尾余韵用 hold 镜头,让画面自己说话,节奏才不会像幻灯片。

第 3 步:生成音轨,看时长​

node --env-file=.env tools/soundtrack.mjs

输出类似:

TTS 1/3  4.51s  抬头看看,今天的天空蓝得像一大块果冻。
TTS 2/3 5.57s 可是,太阳光明明是白色的,太空更是一片漆黑。
TTS 3/3 3.62s 那天空的蓝色,到底是从哪儿来的?
时长 19.654s,3 个镜头 → src/voice.gen.js, assets/audio.wav, out/video.srt

这时自己听一遍 assets/audio.wav:音色合不合适、语速快不快、BGM 会不会盖住人声,Agent 都听不到。要调就改 story.mjs 的 voice / speed / 文本,重新跑这一步,只有改动的句子会重新调接口。

第 4 步:让 Claude 画​

确认后说「继续」。Claude 写场景时,镜头读时间轴的写法是:

(() => {
const S = VO.shots, L = id => S[id].lines.map(l => l.start); // 每句台词在镜头内的开始时间

function sky(t, lt, dur) {
const l = L('sky');
camBegin(960, 540 - 60 * ease(seg(lt, 0, 2)), 1); // 镜头缓慢上摇
ground(t);
clawd(960, 860, 28, emotions(lt, [[0, 'happy', { lookY: -1 }], [l[1], 'confused']])); // 第 2 句一开口就变困惑
camEnd();
if (lt < .3) brushWipe(.5 + lt / .6); // 接上一个镜头的笔刷擦除
if (lt > dur - .3) brushWipe((lt - (dur - .3)) / .6); // 擦向下一个镜头
}

shots([[S.open.start, open], [S.sky.start, sky], [S.why.start, why]]);
})();

三条对齐规则:

要对齐的东西写法
镜头切换shots([[S.<id>.start, fn], ...])
表情/动作跟某句台词S.<id>.lines[i].start(开口时)或 .end(说完时),和 lt 比较
律动、小跳、闪烁pulse(t)、move(style, t),自动踩 104 BPM

画的过程中 Claude 会按指南渲染联系表自查。你也可以随时用 Chrome 打开 studio.html 拖时间轴看。

第 5 步:出片与验收​

node render.mjs --clip --out=out/video.mp4       # 一条命令:逐帧画 + 混入 assets/audio.wav

长片(1 分钟以上)用并行可续跑的方式:

node render.mjs --frames --workers=4
node render.mjs --encode --audio=assets/audio.wav --out=out/video.mp4 # --encode 不读 PROJECT.audio,必须显式传

验收:

ffprobe -v error -show_entries format=duration:stream=codec_type,width,height -of compact out/video.mp4
ffmpeg -hide_banner -i out/video.mp4 -af ebur128 -f null - 2>&1 | grep -A1 "Integrated loudness"

需要字幕就把 out/video.srt 和视频一起上传,B 站、YouTube 都支持外挂字幕。

实测数据​

用 3 句旁白、3 个镜头的样片在本机(Apple Silicon + Chrome)跑通全流程:

项结果
成片19.65 s,1920×1080,24 fps,H.264 + AAC
渲染耗时472 帧,约 86 ms/帧,总计 44 s
响度-15.0 LUFS(混音自带 tanh 限幅,没有另做 loudnorm)
画面对齐第 2 句开口时 Clawd 切到 confused,第 3 句说完切到 idea,和时间轴一致

样片场景很简单(天空 + 草地 + 一个 Clawd)。场景复杂、水彩 fill 多时每帧会到几百毫秒,PDoom 成片在本机是 170~400 ms/帧。


四、方案 A:没有台词的纯画面短片​

不想写台词,只要一支配乐小短片:

  1. 提需求时说明「无旁白,时长 15 秒」。
  2. Claude 按 Base 指南正常写分镜和场景,不需要 story.mjs,镜头时间自己定。
  3. 生成同样时长的 BGM:
node tools/soundtrack.mjs --bgm=15
node render.mjs --clip --out=out/video.mp4

--bgm 模式会把 VO.duration 写成 15,config.js 不用改。要加音效,给 story.mjs 写只有 hold 和 sfx 的镜头,走方案 B 的命令即可(没有台词时不会调用 TTS)。


五、常见问题​

现象原因处理
页面报 VO is not definedvoice.gen.js 没生成,或在 config.js 之后加载先跑 soundtrack;检查 studio.html 脚本顺序
--encode 出来的视频没声音Base 的 --encode 只认 --audio= 参数加 --audio=assets/audio.wav;--clip 会自动读 PROJECT.audio
Clawd 律动和 BGM 对不上PROJECT.bpm 被改了保持 104;要换速度得同时改 tools/audio.mjs 里 music() 的 60 / 104
TTS 报 403 requested resource not granted音色不在已开通列表从音色表里选 *_bigtts
改了一句台词,后面动作全错位场景里写死了绝对秒数所有时间都从 VO.shots 取,不写死数字
音频末尾多一段节奏音TTS 水印没关检查 tools/tts.mjs 里的 aigc_watermark: false
画面里出现了台词文字模型习惯把旁白写成标题或气泡CLAUDE.md 已禁止;审分镜时再看一遍

六、这套方案的边界​

  • BGM 只有一首:audio.mjs 的 BGM 固定为 C–Am–F–G 尤克里里,轻快风格。悲伤、紧张的片子会不搭,需要改和弦、速度和音色(让 Claude 改 music() 就行,但要同步 PROJECT.bpm)。
  • 旁白 ≠ 音乐:画面踩的是 BGM 的拍子,台词不在拍子上。要「歌词卡拍」的 MV 效果,得走第七章的思路。
  • 声音只能人听:音色、语速、BGM 音量这些,Agent 无法验证,每次都要自己试听。
  • 成本:TTS 按字计费,有缓存所以只付一次;画面的主要成本是 Claude 的 token,方案 B 一支 40 秒短片预计在几十分钟量级(按 PDoom 第二轮约 45 分钟推断,未实测)。

七、以后想做「真 MV」:几个声音思路​

按实现难度从低到高:

思路做法难点状态
1. 参数化 BGM把 music() 的 BPM、和弦、音色做成参数,让 Claude 按每支片子的情绪选纯代码,改动小未实现
2. TTS 念白卡拍(说唱)每句台词放在小节起点,用 ffmpeg atempo 把时长伸缩到整数小节;画面按小节切镜头atempo 对人声有失真;需要把台词写得有韵律未验证
3. AI 生成歌曲用 AI 作曲工具按歌词生成完整歌曲,然后走 PDoom 的流程要测 BPM / 第一拍、要拿到歌词时间轴未验证
4. 让 Claude 写旋律在 audio.mjs 基础上让 Claude 按歌词写旋律音符,用合成器唱「啦啦啦」或纯器乐旋律质量取决于模型乐理未验证

思路 3 需要补上的两个自动化环节:

  • 节拍检测:用 librosa、aubio 等音频分析库检测 BPM 和第一拍位置,写进 PROJECT.bpm / PROJECT.offset。
  • 歌词对齐:用带词级时间戳的语音识别(如 Whisper 系)对歌曲人声做转写,得到每句的起止时间,生成 PDoom 格式的 lyrics.js。

这两步做成脚本后,「AI 歌曲 → MV」也能变成和本文一样的一条命令流程。


参考资料​


  1. JohnHeibel — ClaudeAnimationBase · GitHub · 2026↩
  2. JohnHeibel — PDoomVideo · GitHub · 2026↩
  3. 火山引擎 — 豆包语音合成 HTTP 接口 · 官方文档↩