用 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.mjssubtitle.jsontotalFrames,生成一份 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.jsonmain 指向 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 renderError 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 --noEmitTS2322: Type 'readonly [...]' is not assignable to type '...[]'

根因:render-data.tsRENDER_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 指针

“最该留存的文件”排序

重构时我梳理了一遍哪些文件是创作核心、哪些是自动产物(删了能重建),排了个优先级:

  1. storyboard.md — 分镜脚本,创作核心,绝对不能丢
  2. script.txt — 配音文案,改一句话就要重生成配音
  3. config.json — 元数据(声音、尺寸、分镜时间表),调节奏用
  4. MyComp.tsx 里的 switch — 画面逻辑,改视觉用
  5. audio/subtitle.json — 时间轴,配音变才需要重生成
  6. 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.jsonscenes 数组 — 机器读,定义分镜时间表(每个分镜的 id / name / start / end),决定”第几秒到第几秒画面是哪个 sceneId”
  • storyboard.md — 人读,描述每个分镜的画面意图、配音文案、字幕,是创作规划

两者通过 sceneIdMyComp.tsxswitch 对应——时间表说”现在该显示 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+ 行),好处是改一个分镜不用在多个文件间跳、所有分镜共享同一组样式常量(FONTACCENT 色)和动画 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" />

strokeDashoffsetcircumference(满偏移=空圆)渐变到 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.txtgenerate_voice.pybuild-render.mjsrender.mjs
换声音episodes/<id>/config.jsonvoicegenerate_voice.pybuild-render.mjsrender.mjs
分镜节奏episodes/<id>/config.jsonscenes 时间表build-render.mjsrender.mjs
分镜画面src/MyComp.tsxswitch只跑 render.mjs
字幕样式src/Subtitle.tsx只跑 render.mjs
视频尺寸src/Root.tsxwidth/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.txtstoryboard.mdconfig.jsonaudio/ 都独立存在,切换不丢任何数据——切换只是改”当前正在操作哪一期”。

两种使用方式

这个脚手架支持两种交互方式,覆盖不同使用者:

方式一:代码指令——开发者手动控制每一步,适合精细调试:

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 出来,改画面不用重新配音,切回老期不丢任何数据。剩下的就是想选题了。