用 Remotion + Edge TTS 搭一个中文配音视频生成器:从单次渲染到 episode 工作空间
把”做视频”从手动操作变成可编程、可留存、可回改的工程。 本文复盘一个真实项目:Remotion 写画面 + Edge TTS 生成中文配音 + ffmpeg 合并音频,最终输出抖音竖屏 MP4。
一、为什么做这个
场景很具体:做抖音/小红书的中文知识科普短视频——配音讲知识点,画面跟着配音切换分镜,底部字幕同步。
现成工具都有缺口:
- 剪映/PR:能做,但纯手动操作,改一句话要重新剪辑一遍,没法批量化。
- Remotion:用 React 写视频,可编程、可版本控制,但原生不带中文 TTS,配音要自己接。
- Edge TTS:微软在线中文语音,免费、无 API key、声音质量在线 TTS 里算上乘,但只出音频,不跟你画面同步。
所以方案就是把两者拼起来:Remotion 负画面和渲染管线,Edge TTS 负配音,中间用一份”字幕时间轴”把两者同步。一句话定位——一个”文案进去、MP4 出来”的可编程视频脚手架。
二、最小可用:第一版单次渲染流程
先不看工程化,把”文案 → MP4”这条最小管线跑通。
目录与依赖
bookmuse/
├── scripts/
│ ├── generate_voice.py ← Edge TTS 配音 + 时间轴
│ └/build-render.mjs ← 生成 render-data.ts
│ └/render.mjs ← 一键渲染
└── src/
├── Root.tsx ← Remotion Composition 入口
├── MyComp.tsx ← 主画面(分镜全在这里)
├── Subtitle.tsx ← 底部字幕
└/render-data.ts ← 自动生成,勿手改
依赖三类:Remotion(remotion、@remotion/cli、@remotion/renderer)、Edge TTS(Python 官方 edge-tts 包)、ffmpeg/ffprobe(音频合并 + 时长探测)。国内装 Node 依赖走 .npmrc 里配的 npmmirror 镜像,Pip 走清华源,避免卡住。
配音生成:逐句切 → 合成 → 测时长 → 合并
配音脚本的核心难题是画面要跟配音同步,但 Edge TTS 只给你一段 mp3,不告诉你第几个字对应第几秒。我没用逐字时间戳(Edge TTS 的高级接口要处理 WordBoundary WebSocket 事件,复杂),而是走”逐句”粒度:
# 1. 文案按中文标点切成句子
sentences = re.split(r"(?<=[。!?\n])", text)
# 2. 逐句合成,每句一个 mp3
for i, sentence in enumerate(sentences):
communicate = edge_tts.Communicate(sentence, voice,
rate="+0%", pitch="+0Hz", volume="+0%")
await communicate.save(seg_path)
# 3. ffprobe 读每句时长,累加得到起止时间
dur = probe_duration(seg_path)
segments.append({"text": sentence, "start": cursor, "end": cursor + dur})
cursor += dur
句粒度够用吗?对于知识科普短视频,字幕按句显示反而比逐字更易读;画面切换也按句走,节奏自然。代价是字幕不能做”逐字高亮”那种效果——但那是演讲类视频的需求,不是这里的目标。
最后用 ffmpeg 的 concat demuxer 把所有句子 mp3 合并成一段 voice.mp3,同时输出一份 subtitle.json:
{
"fps": 30,
"durationSec": 97.32,
"totalFrames": 2920,
"segments": [
{"index": 0, "text": "上班族的你...", "start": 0, "end": 4.2},
...
]
}
这份 JSON 是整个系统的”同步锚”——Remotion 组件读它来决定视频总长、当前该显示哪句字幕。
Remotion 组件:深色背景 + 底部字幕 + 配音混入
画面三层:深色背景 + radial-gradient 光晕(避免死板)、中间分镜画面、底部字幕卡片。配音用 <Audio> 混入:
import { AbsoluteFill, Audio, staticFile, useCurrentFrame } from 'remotion';
export const MyComp = ({ voicePath, segments, scenes }) => {
const frame = useCurrentFrame();
const currentSec = frame / 30;
// 找当前该显示的字幕
const activeSegment = segments.find(
(s) => currentSec >= s.start && currentSec < s.end
);
return (
<AbsoluteFill style={{ backgroundColor: '#0a0a0f' }}>
{/* 分镜画面 */}
<SceneRenderer sceneId={sceneId} scenes={scenes} />
{/* 配音 - staticFile 解析 public/ 下的文件 */}
<Audio src={staticFile(voicePath)} />
{/* 底部字幕 */}
<Subtitle text={activeSegment?.text ?? ''} />
</AbsoluteFill>
);
};
字幕组件是个半透明卡片,每句淡入 6 帧:
const fadeIn = interpolate(frame, [0, 6], [0, 1], {
extrapolateRight: 'clamp',
easing: Easing.out(Easing.cubic),
});
关键点:视频时长由配音驱动
Remotion 的 <Composition durationInFrames={N}> 要你给死一个帧数。但我们的视频长度取决于配音说了多久——文案改一个字,时长就变。
解法:不写死。用 build-render.mjs 读 subtitle.json 的 totalFrames,生成一份 src/render-data.ts:
export const RENDER_DATA = {
episodeId: "2026-07-22-time-management",
totalFrames: 2920, // ← 来自配音实际时长
durationSec: 97.32,
segments: [...],
scenes: [...],
} as const;
Root.tsx import 这份文件,把 totalFrames 喂给 <Composition>:
const DURATION = RENDER_DATA.totalFrames || 300;
<Composition
durationInFrames={DURATION} // ← 跟着配音走
fps={30}
width={1080}
height={1920}
...
/>
这样改文案 → 重跑配音 → totalFrames 自动变 → 视频长度自动跟着变,不用手动调。
第一版跑通的是横屏 1920×1080、22 秒、5 句话的最小示例。能出片,但有几个坑要先处理。
三、踩坑与修正
这节是项目里最值得留下的部分——官方文档不会告诉你这些。
坑1:npm 的 edge-tts 包入口是 .ts,Node 22 拒接
现象:import { ttsSave } from 'edge-tts' 直接抛 ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING。
根因:npm 上那个 edge-tts 包的 package.json 里 main 指向 index.ts(源码),不是构建产物。Node 22 的 ESM 类型剥离只支持你自己项目里的 .ts,不支持 node_modules 里的——它认为第三方包应该自己预编译好。
修法:包里其实有构建好的 out/index.js,直接指它:
// 不用包入口,指构建产物
import { ttsSave } from '../node_modules/edge-tts/out/index.js';
但更彻底的修法在坑2——这个包还有更严重的问题。
坑2:硬编码旧 token,微软已封(403)
现象:改完坑1能跑了,但 ttsSave() 抛 Unexpected server response: 403。
根因:看包源码,它硬编码了一个 2022 年的 TrustedClientToken 去调微软接口。微软后来加了更严的鉴权(要 Sec-MS-GEC 签名头,定期轮换),旧 token 直接被封。而这个 npm 包一年多没更新了。
修法:换语言。Python 官方维护的 edge-tts 持续更新鉴权逻辑,装 7.2.8 版本:
pip3 install edge-tts -i https://pypi.tuna.tsinghua.edu.cn/simple
于是配音脚本从 Node 改写成 Python,Node 只负责读时长和生成 render-data。语言混着用不优雅,但比等一个僵尸包更新靠谱。
坑3:staticFile 在 CLI 渲染时 404
现象:跑 npx remotion render 报 Error while downloading http://localhost:3000/audio/voice.mp3: 404。
根因:<Audio src={voicePath} /> 里我传的是相对路径 'audio/voice.mp3'。Remotion 的 webpack dev server 会把它当成 http://localhost:3000/audio/voice.mp3 去下载,但 CLI 渲染时这个静态文件服务的路径解析有问题,找不到。
修法:用 staticFile() 包一层。staticFile('audio/voice.mp3') 会正确解析到 public/audio/voice.mp3:
import { staticFile } from 'remotion';
// ❌ <Audio src={voicePath} />
// ✅
<Audio src={staticFile(voicePath)} />
而且文件必须放在 public/ 下——这是 Remotion 的硬约定,放项目任意位置它都不认。
坑4:tsc 报 readonly 数组赋值错误
现象:npx tsc --noEmit 报 TS2322: Type 'readonly [...]' is not assignable to type '...[]'。
根因:render-data.ts 里 RENDER_DATA 用了 as const,所以 segments 被推导成 readonly 字面量类型。而 <Composition defaultProps> 期望可变数组。
修法:展开运算符把 readonly 转成新的可变数组:
// ❌ segments: RENDER_DATA.segments,
// ✅
segments: [...RENDER_DATA.segments],
scenes: [...RENDER_DATA.scenes],
四个坑,每个都配一个”现象 → 根因 → 修法”的三段式。踩完坑第一版才算真能跑,接下来才谈得上工程化。
四、从单次到可复用:episode 工作空间模型(核心设计)
问题诊断:单次版会互相覆盖
第一版能出片,但每跑一次就覆盖一次:
content/script.txt ← 唯一文案入口,换主题就被覆盖
public/audio/voice.mp3 ← 音频被覆盖
public/audio/subtitle.json ← 时间轴被覆盖
src/render-data.ts ← 被下一个主题覆盖
out/video.mp4 ← 渲染产物被覆盖
做第二期”深度工作入门”时就会发现:第一期”时间管理”的文案、音频、渲染产物全没了。改 A 会影响 B,没法回改历史版本——这在”做完一期之后想回去调第 5 个分镜的画面”时特别痛。
设计目标:每期视频是一个独立、可留存、可回改的工作单元
核心概念是 episode:一期视频对应一个独立目录,文案、分镜、音频、字幕、渲染产物全在里面,互不覆盖。
bookmuse/
├── episodes/ ← 每期一个独立目录
│ ├── 2026-07-22-time-management/ ← 第一期
│ │ ├── script.txt ← 配音文案 ★ 最该留存的
│ │ ├── storyboard.md ← 分镜脚本(人读) ★ 最该留存的
│ │ ├── config.json ← 元数据:voice、尺寸、scenes 时间表
│ │ └── audio/ ← 配音音频 + 字幕时间轴
│ │ ├── voice.mp3
│ │ ├── subtitle.json
│ │ └── segments/
│ └── 2026-07-25-deep-work/ ← 第二期,互不干扰
│ └── ...
├── out/ ← 渲染产物也按 episode 隔离
│ ├── 2026-07-22-time-management.mp4
│ └── 2026-07-25-deep-work.mp4
└── .current-episode ← 当前 episode 指针
“最该留存的文件”排序
重构时我梳理了一遍哪些文件是创作核心、哪些是自动产物(删了能重建),排了个优先级:
storyboard.md— 分镜脚本,创作核心,绝对不能丢script.txt— 配音文案,改一句话就要重生成配音config.json— 元数据(声音、尺寸、分镜时间表),调节奏用MyComp.tsx里的 switch — 画面逻辑,改视觉用audio/subtitle.json— 时间轴,配音变才需要重生成render-data.ts— 自动产物,删了能重建
排这个表的实际用处:决定哪些文件该手改、哪些该脚本自动生成。1–4 是人写的,5–6 是 generate_voice.py / build-render.mjs 自动产物——自动产物一旦写了 .gitignore 规则就不用管版本控制,丢 了也能一行命令重建。
“当前 episode”指针:4 级解析优先级
但还有一个问题:命令怎么知道”现在在操作哪一期”?总不能每条命令都传一遍 --episode 2026-07-22-time-management。
解法是一个分层指针,所有脚本共享一份 _episode.mjs 解析逻辑:
// scripts/_episode.mjs
export async function resolveEpisode(argv = []) {
// 1. 命令行参数 --episode <id>(最高)
if (argv.includes('--episode')) return /* ... */;
// 2. 环境变量 EPISODE=<id>
if (process.env.EPISODE) return /* ... */;
// 3. 项目根 .current-episode 文件(默认)
if (existsSync('.current-episode')) return /* ... */;
// 4. episodes/ 下唯一目录(自动兜底)
const dirs = await readdir('episodes');
if (dirs.length === 1) return dirs[0];
throw new Error('存在多个 episode,请用 --episode 指定');
}
四级优先级从高到低:命令行 --episode > 环境变量 EPISODE > .current-episode 文件 > episodes/ 下唯一目录自动兜底。
兜底逻辑是给新用户准备的——刚 clone 项目只有一个示例 episode,所有命令不用任何参数就能默认操作它,降低上手门槛。等做多期了,build-render.mjs 会自动把当前 id 写进 .current-episode,后续命令默认读它。
staticFile 与 episode 隔离的协调:软链
这里有个 Remotion 的硬约束卡了一下:<Audio src={staticFile('audio/voice.mp3')}> 要求音频在 public/ 下,但 episode 模型把音频放在 episodes/<id>/audio/。总不能每期都把音频复制一份到 public/——那又回到覆盖老路了。
解法是软链。build-render.mjs 在构建渲染数据时顺便建一个 public/audio 软链,指向当前 episode 的 audio 目录:
// scripts/build-render.mjs
async function linkEpisodeAudio(epAudioDir) {
const linkPath = join(ROOT, 'public', 'audio');
if (existsSync(linkPath)) await rm(linkPath, { recursive: true, force: true });
await symlink(epAudioDir, linkPath, 'dir');
}
切 episode 时 build-render.mjs 重跑一次,软链自动重指,staticFile 就能读到新一期的音频。一份音频物理存储,通过软链让 Remotion 认得出来——隔离与兼容两头都顾上了。
五、分镜与画面:11 个分镜的组件化实现
时间表与画面的对应关系
分镜有两份描述,各司其职:
config.json的scenes数组 — 机器读,定义分镜时间表(每个分镜的id/name/start/end),决定”第几秒到第几秒画面是哪个 sceneId”storyboard.md— 人读,描述每个分镜的画面意图、配音文案、字幕,是创作规划
两者通过 sceneId 跟 MyComp.tsx 的 switch 对应——时间表说”现在该显示 sceneId=5”,组件就 case 5 渲染对应画面:
const SceneRenderer = ({ sceneId, scenes }) => {
const scene = scenes.find((s) => s.id === sceneId);
if (!scene) return null;
// 场景内相对帧(从场景开始算起)
const localFrame = useCurrentFrame() - Math.round(scene.start * 30);
const sceneTotalFrames = Math.round((scene.end - scene.start) * 30);
// 淡入淡出包装
const opacity =
fadeIn(localFrame, 6) * fadeOut(localFrame, sceneTotalFrames, 6);
switch (sceneId) {
case 1: return /* 开场标题 */;
case 2: return <TodoList />;
case 3: return <FourQuadrant fillMode="intro" />;
// ...
case 6: return <PomodoroClock />;
case 11: return /* 收尾"点赞收藏" */;
default: return null;
}
};
这里有个设计取舍:分镜画面是全塞一个 MyComp.tsx 文件(而不是每个分镜一个 Scene01.tsx)。代价是文件长(500+ 行),好处是改一个分镜不用在多个文件间跳、所有分镜共享同一组样式常量(FONT、ACCENT 色)和动画 helper(fadeIn/fadeOut)。对于这种”分镜共享视觉语言”的视频,紧凑度比物理拆分更重要。
几个有代表性的分镜实现
四象限法(CSS Grid 2×2)——分镜 3/4/5 复用同一组件,靠 fillMode prop 区分:引入时只显示标签、示例时填入任务、收尾时在右下角灰格打红 ✕。
const FourQuadrant = ({ fillMode = 'intro' }) => {
return (
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gridTemplateRows: '1fr 1fr',
gap: 12, width: 820, height: 820,
}}>
{QuadrantLabels.map((label, i) => {
const appear = interpolate(frame, [i * 10, i * 10 + 12], [0, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
});
return (
<div key={i} style={{ opacity: appear, /* ...格子样式 */ }}>
<div>{label}</div>
{/* fillMode='examples' 时显示示例任务 */}
{/* fillMode='cross' && i===3 时叠大红 ✕ */}
{showCross && <div style={{ fontSize: 120, color: '#ff6b6b' }}>✕</div>}
</div>
);
})}
</div>
);
};
四个格子配四色(红/蓝/橙/灰),对应”重要紧急/重要不紧急/紧急不重要/不紧急不重要”,颜色本身就是信息——观众一眼能看出优先级梯度。
番茄钟(SVG stroke-dashoffset 圆环倒计时)——一个从 25:00 倒数的圆环,用 SVG 的 strokeDasharray + strokeDashoffset 做进度动画:
const radius = 180;
const circumference = 2 * Math.PI * radius;
const progress = (frame % displayCycle) / displayCycle; // 0 → 1 循环
const dashOffset = circumference * progress;
<circle cx="210" cy="210" r={radius}
fill="none" stroke={ACCENT} strokeWidth="16"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
strokeLinecap="round" />
strokeDashoffset 从 circumference(满偏移=空圆)渐变到 0(无偏移=满圆),视觉上就是圆环被进度色”填满”。配合中间的数字倒计时,一个番茄钟就成型了。
进度条(interpolate 0→80% + 实时百分比)——分镜 8 的”完成 > 完美”,进度条用 interpolate 做缓动:
const progress = interpolate(frame, [5, 60], [0, 80], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
easing: Easing.out(Easing.cubic), // 先快后慢,像真实工作进度
});
<div style={{ width: `${progress}%`, /* 蓝色渐变填充 */ }} />
<div>{Math.round(progress)}%</div>
关键点:进度只到 80% 而不是 100%——这本身就是文案的视觉补充(“完成 > 完美”意味着不追求 100%,80% 就该收工)。
回顾三招(卡片依次缩放出现 + 边框光晕)——三张卡片用 scale + boxShadow 做依次出现:
{tips.map((tip, i) => {
const appear = interpolate(frame, [i * 20 + 15, i * 20 + 27], [0, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
});
const scale = interpolate(frame, [i * 20 + 15, i * 20 + 27], [0.9, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
});
return (
<div key={i} style={{
opacity: appear,
transform: `scale(${scale})`,
border: `2px solid ${tip.color}40`, // 半透明边框
boxShadow: `0 8px 32px ${tip.color}20`, // 色调光晕
}}>
{/* 三招内容 */}
</div>
);
})}
每张卡用对应招式的色调(四象限红/番茄钟蓝/晨间橙),边框和光晕都用 ${color}40/${color}20 的半透明叠法——既统一又区分。
竖屏适配
抖音是竖屏 1080×1920。从横屏改竖屏不只是改 <Composition width height>,所有字号、padding、组件宽度都要按比例放大——横屏 1920 宽时的 72px 标题,在竖屏 1080 宽时同视觉比例应该是 ~40px,但竖屏消费场景(手机竖看)观众离屏更近,反而要放大到 ~96px 才够醒目。
这是个”物理尺寸 vs 观看距离”的权衡,没有公式,只能渲染出来手机上看效果再调。实际做竖屏版时我整体把字号放大约 1.3 倍、padding 放大 1.5 倍,字幕卡片从底部 90px 抬到 140px(竖屏底部有手势区,字幕太靠下会被系统手势干扰)。
六、完整工作流
新建一期(端到端)
# 1. 创建 episode 脚手架(建目录 + 四个模板文件 + 设为当前)
node scripts/new-episode.mjs 2026-07-25-deep-work "深度工作入门"
# 2. 填四个文件:script.txt(文案) / storyboard.md(分镜)
# config.json(voice+尺寸+scenes 时间表) / MyComp.tsx 的 switch(画面)
# 3. 生成配音 → audio/voice.mp3 + subtitle.json
python3 scripts/generate_voice.py
# 4. 构建渲染数据 → src/render-data.ts + 建 public/audio 软链
node scripts/build-render.mjs
# 5. 渲染 → out/2026-07-25-deep-work.mp4
node scripts/render.mjs
改了什么就重跑什么
不是每次改动都要从头跑一遍——按”改了什么”决定重跑哪些命令,这是省时间的关窍:
| 想改什么 | 改哪个文件 | 重跑命令 |
|---|---|---|
| 配音文案 | episodes/<id>/script.txt | generate_voice.py → build-render.mjs → render.mjs |
| 换声音 | episodes/<id>/config.json 的 voice | generate_voice.py → build-render.mjs → render.mjs |
| 分镜节奏 | episodes/<id>/config.json 的 scenes 时间表 | build-render.mjs → render.mjs |
| 分镜画面 | src/MyComp.tsx 的 switch | 只跑 render.mjs |
| 字幕样式 | src/Subtitle.tsx | 只跑 render.mjs |
| 视频尺寸 | src/Root.tsx 的 width/height | 只跑 render.mjs |
最值钱的一行:改画面/字幕/尺寸 → 只跑 render.mjs,不用重新生成配音。配音生成要联网调 Edge TTS、要逐句合成、要测时长,是整个流程最慢的一步(20 句约 30 秒);而渲染本地多核并行,2920 帧也就 2–3 分钟。能不重跑配音就不重跑,迭代画面时每次只花 2 分钟出片。
切换 episode
“当前 episode”的 4 级解析优先级(从高到低):
# 1. 命令行参数(最高)
node scripts/render.mjs --episode 2026-07-22-time-management
# 2. 环境变量
EPISODE=2026-07-22-time-management node scripts/render.mjs
# 3. .current-episode 文件(默认,build-render.mjs 会自动维护它)
# 4. episodes/ 下唯一目录(自动兜底,给新用户降门槛)
切回某期只需重跑 build-render.mjs(重建 render-data + 重指软链),就能操作它了。每期的 script.txt、storyboard.md、config.json、audio/ 都独立存在,切换不丢任何数据——切换只是改”当前正在操作哪一期”。
两种使用方式
这个脚手架支持两种交互方式,覆盖不同使用者:
方式一:代码指令——开发者手动控制每一步,适合精细调试:
node scripts/new-episode.mjs 2026-07-25-deep-work "深度工作入门"
vim episodes/2026-07-25-deep-work/script.txt
python3 scripts/generate_voice.py
node scripts/build-render.mjs
node scripts/render.mjs
方式二:自然语言——直接对 AI 说你想做什么视频,AI 按脚手架约定帮你跑命令。说法模板:
用这个 Remotion 脚手架,帮我做一期关于「XXX」的中文配音视频,发抖音,约 90 秒,轻松实用风格,声音用 zh-CN-YunxiNeural。 请:① 先给我分镜脚本(storyboard.md)让我确认 → ② 确认后生成配音 → ③ 渲染 MP4。 创建新 episode 用:node scripts/new-episode.mjs <日期-slug> <标题>
AI 会按”创建脚手架 → 填四个文件 → 生成配音 → 构建 → 渲染”的流程执行,中间分镜脚本让你确认。切换 episode 也能用自然语言:
帮我切回「时间管理入门」那期,我想改第 5 个分镜的画面。先 node scripts/build-render.mjs —episode 2026-07-22-time-management 切回去。
两种方式不互斥——你可以在自然语言对话里让 AI 跑代码指令,也可以自己跑命令时让 AI 帮你写文案/分镜。
七、还能往哪走
脚手架是”最小够用”的,还有几个明显的扩展方向:
分镜画面模板化——开场标题、列表、进度条、行动召唤这些分镜在很多期里会复用。可以把它们抽成可复用组件库,新一期只组合不重写。new-episode.mjs 生成脚手架时甚至能按分镜模板自动填充 MyComp.tsx 的 switch 骨架。
引入图片/视频素材——Remotion 有 <Img>、<Video>、<OffthreadVideo> 组件。做读书笔记类视频时,可以插入书的封面、章节插图,配音讲知识点时画面跟着切图。episode 目录加个 assets/ 存素材,config.json 的 scenes 里加 imagePath 字段,组件里 <Img src={staticFile(scene.imagePath)} /> 即可。
逐字字幕——当前是句粒度字幕。Edge TTS 的高级接口有 WordBoundary WebSocket 事件,能拿到每个字的起止时间,做”逐字高亮”效果(像卡拉OK)。但合成逻辑要从”逐句一段 mp3”改成”一次整段合成 + 解析 boundary 事件”,改动较大,留作专项。
渲染上云——本地渲染吃 CPU 且串行。@remotion/lambda 能在 AWS Lambda 上并行渲染,批量出片时从”一期 2 分钟”压到”一期 20 秒 + 并发多期”。对做日更频道的场景,上云能把迭代周期压一半。
多语种——config.json 加 locale 字段,generate_voice.py 按 locale 选对应声音(英文用 en-US-AriaNeural、日文用 ja-JP-NanamiNeural),同一份文案做海外版。字幕组件按 locale 切字体(日文要切 Noto Sans JP)。
八、收尾
全项目结构速查
bookmuse/
├── episodes/ ← 每期一个独立目录
│ └── 2026-07-22-time-management/
│ ├── script.txt ← 配音文案 ★
│ ├── storyboard.md ← 分镜脚本(人读) ★
│ ├── config.json ← 元数据:voice、尺寸、scenes
│ └── audio/{voice.mp3, subtitle.json, segments/}
├── out/ ← 渲染产物,按 episode 隔离
│ └── 2026-07-22-time-management.mp4
├── public/audio -> episodes/<id>/audio ← 软链,build-render 自动维护
├── .current-episode ← 当前 episode 指针
├── scripts/
│ ├── _episode.mjs ← 共享:解析当前 episode(4 级优先级)
│ ├── generate_voice.py ← Edge TTS 配音 + 时间轴
│ ├── build-render.mjs ← 生成 render-data.ts + 建软链
│ ├── render.mjs ← 一键渲染
│ └/new-episode.mjs ← 创建新 episode 脚手架
└── src/
├── Root.tsx ← Remotion Composition 入口
├── MyComp.tsx ← 主画面(分镜全在这里)
├── Subtitle.tsx ← 底部字幕
└── render-data.ts ← 自动生成,勿手改
一句价值总结
做完这个项目最大的体会是:“做视频”不必是手动剪辑行为,它可以是一份可编程、可版本控制、可回改的工程。
文案是 script.txt 里的纯文本,分镜是 storyboard.md 里的表格,画面是 MyComp.tsx 里的 React 组件,配音是 Edge TTS 按文案生成的 mp3,时间轴是 ffprobe 测出来的 JSON——每一样都是开发者熟悉的形态,都能用 git 管理、用脚本自动化、用 AI 辅助修改。
episode 工作空间模型解决的是”做多期不互相覆盖、改历史版本不丢数据”这个工程化关窍。它不复杂——一个目录 + 一个指针 + 一份软链——但有了它,脚手架就从”能跑一次的 demo”变成了”能持续做内容的生产工具”。
附:完整演示
抖音/小红书上的中文知识科普短视频,也许可以用这套脚手架批量化生产了。文案进去,MP4 出来,改画面不用重新配音,切回老期不丢任何数据。剩下的就是想选题了。