返回 Skill 列表
extension
分类: 内容与媒体无需 API Key

讲解视频生成,教学视频自动生成

remotion-tts-video-pipeline

person作者: yanggg1133hubModelScope

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>

问题

  1. 场景切换时音频被截断(Remotion 的 Audio 组件在 Sequence 边界处会切断)
  2. 每个场景需要额外缓冲帧,总时长难以精确控制
  3. 场景间可能有静音间隙或重叠

✅ 正确做法:顶层单轨音频

// 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-ttszh-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 注意事项ffprobeffmpeg 通常不在系统 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 秒。

原因排查清单

  1. [ ] 是否用了旧的 Composition ID?(检查 renderMediacompositionId 参数)
  2. [ ] 渲染是否被超时中断?(前台模式默认 10 分钟限制)
  3. [ ] durations.ts 是否与当前音频文件同步?
  4. [ ] 是否有残留的旧视频文件覆盖了新输出?

解决方法

  • 清理旧 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:渲染进度卡住

症状:进度停在某个百分比不动,但进程还在。

排查步骤

  1. 检查 Chrome 进程数:tasklist | grep chrome.exe | wc -l
  2. 检查临时目录大小:ls -lh /tmp/react-motion-render*/pre-encode.mp4
  3. 如果 Chrome 进程归零但 Node 还在 → 可能是合成阶段(正常,耐心等待)
  4. 如果所有进程都停了但文件没更新 → 查看日志是否有报错

陷阱 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 软渲染。

解决方法

  1. 必须在 chromiumOptions.args 中加入 --ignore-gpu-blocklist--enable-gpu-rasterization
  2. 针对 AMD 老卡,必须加入 --enable-webgl--disable-software-rasterizer(逼迫 Chrome 用 GPU 而非回退 CPU)。
  3. 注意:老卡(如 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