python 程序目录
注意检查python的安装目录 如果有 C:\Python\Python312
就用这个python版本
项目用 pnpm install,一次下载永久复用
所以将来你手动创建项目时,记得用 pnpm 代替 npm 就行:
pnpm init # 代替 npm init
pnpm add react # 代替 npm install react
pnpm install # 代替 npm install
Remotion + TTS 教学视频生成流水线
概述
本技能封装了从文案到最终 MP4 视频的完整生产流水线。核心理念:音频优先,实测驱动,单轨合并,并行渲染。
适用场景
- 教程视频(如 HPC 入门、编程教学)
- 科普/解说类视频(PPT 内容转视频)
- 带旁白的产品演示视频
- 自动化批量生成个性化视频
不适用场景
- 纯音乐/无旁白的视频(不需要 TTS 流水线)
- 需要真人配音的项目(应使用专业录音 + 手动对轨)
- 实时直播或交互式视频
核心架构:单轨音频模式 ⭐(最重要)
❌ 错误做法:逐场景音频
// 每个 Series.Sequence 内放独立 Audio 组件
<Series.Sequence durationInFrames={200}>
<SceneWithAudio>
<Audio src={staticFile("audio/ch1.mp3")} /> {/* 危险! */}
<Ch1Visual />
</SceneWithAudio>
</Series.Sequence>
问题:
- 场景切换时音频被截断(Remotion 的 Audio 组件在 Sequence 边界处会切断)
- 每个场景需要额外缓冲帧,总时长难以精确控制
- 场景间可能有静音间隙或重叠
✅ 正确做法:顶层单轨音频
// HpcTutorial.tsx — 顶层结构
<AbsoluteFill style={{ backgroundColor: "#1a1a2e" }}>
{/* 单条音轨贯穿全程 */}
<Audio src={staticFile("audio/combined.mp3")} volume={0.85} />
<Series>
<Series.Sequence durationInFrames={SCENE_DURATIONS.title}>
<TitleVisual /> {/* 纯视觉,不含 Audio */}
</Series.Sequence>
<Series.Sequence durationInFrames={SCENE_DURATIONS.ch1_what}>
<Ch1WhatVisual />
</Series.Sequence>
{/* ... 更多场景 ... */}
</Series>
</AbsoluteFill>
优势:
- 音频连续播放,不会被场景切换截断
- 场景时长 = 音频实际秒数 × FPS(精确匹配)
- 无需缓冲帧估算
完整工作流(6 步)
Step 1:准备文案
按章节拆分文案,每个章节对应一个场景。
NARRATIONS = {
"00_title": ("欢迎来到XXX教程!今天我们将..."),
"01_ch1_intro": ("第一章:什么是..."),
"01_ch1_content": ("详细内容..."),
# ... 更多章节
}
原则:
- 每段控制在 5~30 秒(太长拆分,太短合并)
- 中文推荐语速约 180~220 字/分钟
- 用
edge-tts的zh-CN-XiaoxiaoNeural音色效果最好
Step 2:生成 TTS 音频
python 程序目录
注意检查python的安装目录 如果有 C:\Python\Python312
就用这个python版本
使用 scripts/generate-tts-audio.py 或参考以下核心逻辑:
import asyncio
from edge_tts import Communicate
VOICE = "zh-CN-XiaoxiaoNeural" # 温暖专业女声
OUTPUT_DIR = "public/audio"
async def generate(name, text):
output_path = f"{OUTPUT_DIR}/{name}.mp3"
communicate = Communicate(text, VOICE)
await communicate.save(output_path)
return get_actual_duration(output_path) # 见 Step 3
async def main():
results = {}
for name, text in NARRATIONS.items():
path, dur = await generate(name, text)
results[name] = {"path": path, "duration": dur}
print(f" {name}: {dur:.1f}s")
# 保存结果供下一步使用
依赖安装:pip install edge-tts tinytag imageio_ffmpeg
Step 3:测量实际音频时长(关键!)
❌ 绝对禁止的手动计算
# 错误!不要这样做!
duration = len(audio_data) / sample_rate # 只适用于 PCM 原始数据
duration = len(chunk["data"]) / 24000 # 对 MP3 完全错误
duration = file_size / 2000 # 粗略且不准
✅ 正确方式:用专业库读取元数据
方案 A:tinytag(推荐,纯 Python)
import tinytag
def get_duration(filepath):
tag = tinytag.TinyTag.get(filepath)
return tag.duration # 返回秒数(float)
方案 B:ffprobe(最精确)
import subprocess
from imageio_ffmpeg import get_ffmpeg_exe
FFMPEG = get_ffmpeg_exe() # Windows 下获取 ffmpeg 路径的关键!
FFPROBE = FFMPEG.replace('ffmpeg', 'ffprobe')
def get_duration_ffprobe(filepath):
result = subprocess.run(
[FFPROBE, "-v", "quiet", "-show_entries", "format=duration",
"-of", "csv=p=0", filepath],
capture_output=True, text=True
)
return float(result.stdout.strip())
⚠️ Windows 注意事项:
ffprobe和ffmpeg通常不在系统 PATH 中。 必须使用imageio_ffmpeg.get_ffmpeg_exe()获取路径, 然后.replace('ffmpeg', 'ffprobe')得到 ffprobe 路径。
Step 4:合并音频为单轨 + 生成时间线
使用 scripts/merge-audio.py:
import json, os, subprocess
from imageio_ffmpeg import get_ffmpeg_exe
import tinytag
AUDIO_DIR = "public/audio"
OUTPUT_COMBINED = os.path.join(AUDIO_DIR, "combined.mp3")
OUTPUT_TIMELINE = os.path.join(AUDIO_DIR, "timeline.json")
FPS = 30
FFMPEG = get_ffmpeg_exe()
# 定义场景顺序(必须与 Remotion Series.Sequence 顺序一致!)
SCENE_ORDER = [
("title", "00_title"),
("ch1_intro", "01_ch1_intro"),
("ch1_what", "01_ch1_what"),
# ... 完整列表
("ending", "99_ending"),
]
def main():
segments = []
current_start = 0.0
concat_list_path = os.path.join(AUDIO_DIR, "concat_list.txt")
with open(concat_list_path, "w") as f:
for scene_name, audio_file in SCENE_ORDER:
audio_path = os.path.join(AUDIO_DIR, f"{audio_file}.mp3")
# 测量实际时长
duration = tinytag.TinyTag.get(audio_path).duration
duration_in_frames = round(duration * FPS)
segments.append({
"sceneName": scene_name,
"audioFile": audio_file,
"startSeconds": round(current_start, 3),
"endSeconds": round(current_start + duration, 3),
"durationInSeconds": round(duration, 3),
"durationInFrames": duration_in_frames,
})
f.write(f"file '{os.path.abspath(audio_path)}'\n")
current_start += duration
total_seconds = current_start
total_frames = round(total_seconds * FPS)
# ffmpeg concat 合并
subprocess.run([
FFMPEG, "-y", "-f", "concat", "-safe", "0",
"-i", concat_list_path,
"-c", "copy", OUTPUT_COMBINED
], check=True)
timeline = {
"fps": FPS,
"totalFrames": total_frames,
"totalDurationSeconds": round(total_seconds, 3),
"segments": segments,
}
with open(OUTPUT_TIMELINE, "w") as f:
json.dump(timeline, f, ensure_ascii=False, indent=2)
print(f"✅ combined.mp3: {total_seconds:.1f}s")
print(f"✅ timeline.json: {total_frames} frames @ {FPS}fps")
Step 5:从时间线生成 durations.ts
// generate-durations-from-timeline.cjs
const fs = require("fs");
const path = require("path");
const timeline = JSON.parse(
fs.readFileSync(path.join(__dirname, "public/audio/timeline.json"), "utf-8")
);
const lines = timeline.segments.map(seg =>
` ${seg.sceneName}: ${seg.durationInFrames}, // "${seg.audioFile}" ${seg.durationInSeconds}s`
);
lines.unshift("export const SCENE_DURATIONS = {");
lines.push("} as const;");
lines.push("");
lines.push(`export const TOTAL_FRAMES = ${timeline.totalFrames};`);
lines.push("");
lines.push(`export const SCENE_ORDER = [${timeline.segments.map(s => `"${s.sceneName}"`).join(", ")}] as const;`);
fs.writeFileSync(path.join(__dirname, "src/durations.ts"), lines.join("\n"));
console.log(`✅ durations.ts: ${timeline.totalFrames} frames`);
运行:node generate-durations-from-timeline.cjs
Step 6:高性能渲染
先生成字幕
然后渲染
使用 scripts/render-fast.cjs 或参考以下配置:
const { bundle } = require("@remotion/bundler");
const { renderMedia, selectComposition } = require("@remotion/renderer");
const path = require("path");
const fs = require("fs");
const os = require("os");
const { execSync } = require("child_process");
const CHROME_PATH = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
const COMPOSITION_ID = "YourCompositionId"; // 修改为你的 ID
const ENTRY_POINT = path.resolve(__dirname, "src/index.ts");
const OUTPUT_PATH = path.resolve(__dirname, "output/video.mp4");
// 6核CPU建议并发3~4,过高会抢内存导致崩溃
const CONCURRENCY = process.env.REMOTION_CONCURRENCY
? parseInt(process.env.REMOTION_CONCURRENCY) : 3;
// 自动检测 GPU(支持 AMD/NVIDIA/Intel)
function checkGPU() {
try {
if (os.platform() !== "win32") return { hasGPU: false, isAMD: false };
const info = execSync("wmic path win32_videocontroller get name /value", { encoding: "utf8" });
const hasGPU = /(AMD|Radeon|NVIDIA|Intel)/i.test(info);
const isAMD = /(R7 200|AMD|Radeon)/i.test(info);
return { hasGPU, isAMD };
} catch (e) {
return { hasGPU: false, isAMD: false };
}
}
async function main() {
const gpuInfo = checkGPU();
// 1. Bundle
const bundled = await bundle({
entryPoint: ENTRY_POINT,
});
// 2. 选择 Composition
const composition = await selectComposition({
serveUrl: bundled,
id: COMPOSITION_ID,
browserExecutable: CHROME_PATH,
// 修复:必须使用 chromiumOptions
chromiumOptions: {
args: gpuInfo.hasGPU ? ["--no-sandbox", "--disable-dev-shm-usage"] : ["--no-sandbox", "--disable-gpu"],
}
});
// 3. 渲染
let lastPercent = -1;
await renderMedia({
composition,
serveUrl: bundled,
codec: "h264",
outputLocation: OUTPUT_PATH,
concurrency: CONCURRENCY,
browserExecutable: CHROME_PATH,
// 修复:必须使用 chromiumOptions,并针对 AMD 老卡强制开启 WebGL
chromiumOptions: {
args: gpuInfo.isAMD
? ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-gpu-blocklist", "--enable-gpu-rasterization", "--enable-webgl", "--disable-software-rasterizer"]
: gpuInfo.hasGPU
? ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-gpu-blocklist", "--enable-gpu-rasterization"]
: ["--no-sandbox", "--disable-gpu"],
},
onProgress: ({ progress }) => {
// 修复:整数百分比过滤,防止日志刷屏
const currentPercent = Math.floor(progress * 100);
if (currentPercent > lastPercent && currentPercent % 5 === 0) {
console.log(` Render: ${currentPercent}%`);
lastPercent = currentPercent;
}
},
});
console.log(`✅ 渲染完成:${OUTPUT_PATH}`);
}
main().catch(err => console.error("❌ 渲染失败:", err)); // 打印完整堆栈
渲染性能参数选择指南
| 硬件环境 | concurrency | Node 内存 | Chrome 模式 | GPU 模式 | 预期速度 | |----------|-------------|-----------|------------|---------|---------| | 4 核 / 8GB RAM | 2 | 2GB | multi-process | 自动检测 | ~15 min / 4min视频 | | 6 核 / 16GB RAM | 3~4 | 2GB | multi-process | 自动检测(AMD需强制开) | ~10~15 min | | 8+ 核 / 32GB RAM | 5~8 | 2GB | multi-process | 自动检测 | ~8~12 min | | Docker / 低内存 | 1~2 | 1GB | single-process | 禁用(防崩溃) | ~25~40 min |
启动命令示例:
# 默认配置
node --max-old-space-size=2048 render-fast.cjs
# 自定义并发数
REMOTION_CONCURRENCY=6 node --max-old-space-size=2048 render-fast.cjs
⚠️ Node.js 内存不要设太大(不要超过 4GB)。Node 本身是调度器, 真正吃内存的是 Chrome 并行实例。把更多内存留给 Chrome 才能提高渲染速度。
常见陷阱与解决方案(血泪经验汇总)
陷阱 1:视频时长远短于预期
症状:代码设定 260 秒,实际视频只有 60~120 秒。
原因排查清单:
- [ ] 是否用了旧的 Composition ID?(检查
renderMedia的compositionId参数) - [ ] 渲染是否被超时中断?(前台模式默认 10 分钟限制)
- [ ]
durations.ts是否与当前音频文件同步? - [ ] 是否有残留的旧视频文件覆盖了新输出?
解决方法:
- 清理旧 build 缓存:
rm -rf build .remotion - 后台运行渲染并写日志到文件:
node script > log.txt & - 渲染完成后立即验证输出文件的修改时间和时长
陷阱 2:音频在场景切换处被截断
症状:每段话说到一半就被切掉。
根因:使用了逐场景 <Audio> 组件。
解决方法:改用单轨音频模式(见上方核心架构)。
陷阱 3:ffmpeg 找不到(Windows 特有)
症状:FileNotFoundError: [WinError 2] 或 ffprobe not found。
原因:Windows 上 ffmpeg 通常不在 PATH 中。
解决方法:
from imageio_ffmpeg import get_ffmpeg_exe
FFMPEG = get_ffmpeg_exe()
# 返回类似:C:\Python\Python312\Lib\site-packages\imageio_ffmpeg\binaries\ffmpeg-win-x86_64-v7.1.exe
注意:imageio_ffmpeg 自带的只有 ffmpeg 没有 ffprobe。如需 ffprobe,可以用 ffmpeg 替代获取时长:
import re
result = subprocess.run([FFMPEG, "-i", filepath], capture_output=True, text=True)
match = re.search(r'Duration: (\d+):(\d+):(\d+\.\d+)', result.stderr)
h, m, s = int(match.group(1)), int(match.group(2)), float(match.group(3))
duration = h * 3600 + m * 60 + s
陷阱 4:Chrome Headless Shell 下载失败(国内网络)
症状:Remotion 报错无法下载 Chromium。
解决方法:指定本地已安装的 Chrome:
const CHROME_PATH = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
// 在 bundle() 和 selectComposition/renderMedia 中都加:
{ browserExecutable: CHROME_PATH }
陷阱 5:文件锁导致编辑失败
症状:EBUSY: resource busy or locked 无法编辑文件。
原因:渲染过程中 Node 进程持有文件句柄。
解决方法:先停止渲染进程再编辑,或者写入新文件而非修改正在使用的文件。
陷阱 6:渲染进度卡住
症状:进度停在某个百分比不动,但进程还在。
排查步骤:
- 检查 Chrome 进程数:
tasklist | grep chrome.exe | wc -l - 检查临时目录大小:
ls -lh /tmp/react-motion-render*/pre-encode.mp4 - 如果 Chrome 进程归零但 Node 还在 → 可能是合成阶段(正常,耐心等待)
- 如果所有进程都停了但文件没更新 → 查看日志是否有报错
陷阱 7:Python 3.14 + numpy DLL 兼容性
症状:ImportError: DLL load failed 使用 moviepy/numpy 时。
影响范围:Python 3.14.4 + numpy 2.4.4 组合有已知问题。
解决方法:本项目不依赖 moviepy(用 ffmpeg 替代),所以不受此问题影响。如需 Python 视频处理,建议降级到 Python 3.11~3.12。
陷阱 8:老显卡(如 AMD R7 200)CPU 满载而 GPU 闲置
症状:任务管理器显示 CPU 100%,但 GPU 占用几乎为 0,渲染极慢。
根因:Chrome 维护了一份 GPU 黑名单,老旧显卡(特别是 AMD R7/R9 200/300 系列)默认被拉黑,强制使用 CPU 软渲染。
解决方法:
- 必须在
chromiumOptions.args中加入--ignore-gpu-blocklist和--enable-gpu-rasterization。 - 针对 AMD 老卡,必须加入
--enable-webgl和--disable-software-rasterizer(逼迫 Chrome 用 GPU 而非回退 CPU)。 - 注意:老卡(如 R7 200)官方已停止主流驱动支持,升级最新驱动对 Remotion 渲染意义不大,核心在于绕过 Chrome 的黑名单。
陷阱 9:Remotion API 使用错误导致 Chrome 参数未生效
症状:明明在渲染脚本里写了 GPU 参数,但渲染速度毫无提升。
根因:使用了 chromeLaunchOptions(Puppeteer 的写法),Remotion 4.x 不认此字段,正确字段为 chromiumOptions。
解决方法: 将渲染脚本中的:
陷阱 9:Remotion API 使用错误导致 Chrome 参数未生效 症状:明明在渲染脚本里写了 GPU 参数,但渲染速度毫无提升。
根因:使用了 chromeLaunchOptions(Puppeteer 的写法),Remotion 4.x 不认此字段,正确字段为 chromiumOptions。
解决方法: 将渲染脚本中的:
将渲染脚本中的:
// ❌ 错误写法(Remotion 4.x 不识别,参数会完全不生效)
chromeLaunchOptions: { args: [...] }
替换为:
// ✅ 正确写法(Remotion 4.x 专属字段)
chromiumOptions: { args: [...] }
---
## 文件组织规范
project-root/ ├── public/ │ └── audio/ │ ├── 00_title.mp3 # 各场景音频 │ ├── 01_ch1_intro.mp3 │ ├── ... │ ├── combined.mp3 # 合并后的单轨音频(Step 4 生成) │ └── timeline.json # 时间线数据(Step 4 生成) ├── src/ │ ├── Root.tsx # Composition 注册(引用 TOTAL_FRAMES) │ ├── durations.ts # 场景时长常量(自动生成,勿手动编辑) │ ├── HpcTutorial.tsx # 主组件(单轨 Audio + Series 编排) │ └── scenes/ │ ├── TitleScene.tsx # 各场景视觉组件 │ ├── Ch1IntroScene.tsx │ └── ... ├── generate-audio.py # Step 2: TTS 音频生成 ├── merge-audio.py # Step 4: 音频合并 + 时间线生成 ├── generate-durations-from-timeline.cjs # Step 5: 生成 durations.ts └── render-fast.cjs # Step 6: 高性能渲染脚本
---
## 一键重新渲染流程
当修改了视觉组件或文案后:
```bash
# 1. 如文案变了:重新生成音频
python generate-audio.py
# 2. 更新时间线和 durations
python merge-audio.py
node generate-durations-from-timeline.cjs
# 3. 清理缓存
rm -rf build .remotion
# 4. 渲染
node --max-old-space-size=2048 render-fast.cjs > /tmp/render-log.txt 2>&1 &
# 5. 监控进度
tail -f /tmp/render-log.txt
# 6. 验证结果
ffmpeg -i output/video.mp4 2>&1 | grep Duration
微信扫一扫