不找配乐也能出片:TTS 驱动的手绘动画短片手把手教程
照着 PDoomVideo 做 MV,最费事的不是画面,而是音乐:得先有一首歌,再测 BPM、找第一拍、抄歌词时间轴,然后才能让画面卡点(见 上一篇拆解)。想「今天有个点子,今天就出片」,这一步就是拦路虎。
这篇换一个思路:不去对齐已有的音乐,而是让声音从脚本里生成出来。旁白用 TTS 合成,配乐用代码现场合成,时间轴是生成过程的副产品,画面直接读时间轴。整条链路没有任何需要人工测量、对齐的环节。
画面部分沿用 ClaudeAnimationBase1(p5.brush 水彩 + 墨线,和 PDoomVideo2 同一套引擎),声音部分复用 claude-cartoon 项目里已经跑通的豆包 TTS3 和程序化 BGM。
一、先选声音方案
| 方案 | 声音来源 | 准备工作 | 适合 | 状态 |
|---|---|---|---|---|
| A. 纯画面 + BGM | 代码合成的尤克里里 BGM | 零 | 无对白的小短片(像 Base 模板的 demo) | 本文实测 ✅ |
| B. 旁白绘本(推荐) | TTS 旁白 + 代码 BGM + 代码音效 | 写几句台词 | 讲故事、科普、角色独白 | 本文实测 ✅ |
| C. 歌曲 MV | AI 生成的歌曲 | 要测 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 会自动查找 |
| ffmpeg | ffmpeg -version | render.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 表示从镜头开头算 | 无 |
审的时候重点看三件事:
- 每个镜头都有事件:分镜里写的不能只是「Clawd 很开心」,得是「Clawd 扶车 → 车倒了 → 它愣住」。
tail够不够:台词说完之后的反应、转场都在tail里发生。有笑点或反转的镜头,tail给到 1~1.5 s。- 无台词镜头别省:开场、动作高潮、结尾余韵用
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:没有台词的纯画面短片
不想写台词,只要一支配乐小短片:
- 提需求时说明「无旁白,时长 15 秒」。
- Claude 按 Base 指南正常写分镜和场景,不需要
story.mjs,镜头时间自己定。 - 生成同样时长的 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 defined | voice.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」也能变成和本文一样的一条命令流程。
参考资料
- JohnHeibel — ClaudeAnimationBase · GitHub · 2026↩
- JohnHeibel — PDoomVideo · GitHub · 2026↩
- 火山引擎 — 豆包语音合成 HTTP 接口 · 官方文档↩