用代码生成宣传片(一):技术栈总览与 Remotion 画面生成

本文基于 MiBee NVR 开源项目 45 秒宣传片的实战经验整理。你将学到用 代码生成视频画面(Remotion)、用 AI 语音生成配音(edge-tts)、离线合成 BGM(numpy),以及用 ffmpeg 把三者合成成品。所有步骤均可直接上手。

这套技术能做什么

一个多语言宣传片项目包含三个独立的「生产环节」,最后由 ffmpeg 拼合成最终视频:

环节工具产物
画面Remotion(React 写视频)一段无声 MP4
配音edge-tts(微软免费 TTS)每段旁白的 MP3
配乐numpy 离线合成一段 BGM 的 WAV
合成ffmpeg filter_complex最终成片 MP4(画面+人声+BGM)

整体管线如下图所示:

mermaid
flowchart TD
    A[画面生成<br/>Remotion] --> D[ffmpeg 合成]
    B[配音生成<br/>edge-tts] --> D
    C[配乐生成<br/>numpy] --> D
    D --> E[成片 MP4]

    classDef blue fill:#2196F3,stroke:#1565C0,color:#fff;
    classDef green fill:#4CAF50,stroke:#2E7D32,color:#fff;
    classDef orange fill:#FF9800,stroke:#E65100,color:#fff;
    classDef purple fill:#9C27B0,stroke:#6A1B9A,color:#fff;

    class A blue;
    class B green;
    class C orange;
    class D purple;
    class E green;

流程说明:三个独立环节分别产出无声画面、配音 MP3 和配乐 WAV,最后通过 ffmpeg 合成输出最终 MP4 成片。每个环节可独立迭代修改,不影响其他环节。

为什么用代码而不是剪映/PR

传统视频剪辑软件(剪映、PR、DaVinci)制作单个视频效率很高,但需要批量产出时——比如将同一段宣传片翻译成 6 种语言、每种语言还要男声和女声两个版本——手动剪辑的工作量会线性增长。代码方案的核心优势:

  • 可版本化:文案是字符串,画面是 React 组件,配音是 voice ID,BGM 是合成参数,合成是 ffmpeg filter 图。全是文本,能进 Git、能 diff、能回滚。
  • 可复现:同一组参数永远得到同一结果。Remotion 的纯函数帧模型保证了画面输出的确定性。
  • 完全免费:edge-tts 使用微软公开接口,BGM 用 numpy 现场计算,不花一分钱。
  • 多语言零边际成本:旁白换一个 voice 参数就能出英文版/粤语版,画面完全复用——画面只渲染一次,6 种语言只是 6 种不同的音频叠加方案。
  • 可调试:每个环节独立(画面/配音/BGM/合成),出问题能分层定位。

技术栈与目录结构

推荐的项目目录结构如下(请将项目根目录设为 BASE,所有相对路径均基于此):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
项目根/
├── remotion-videos/                # Remotion 工程(出画面)
   ├── package.json                # 依赖:remotion ^4
   ├── remotion.config.ts          # 配置 Chrome 可执行文件路径
   ├── chrome-dl/                  # 手动下载的 chrome-headless-shell
      └── chrome-headless-shell-win64/chrome-headless-shell.exe
   └── src/
       ├── Root.tsx                # 注册 Composition
       └── compositions/promo/
           ├── VideoComposition.tsx # 用 Sequence 拼接场景
           ├── Scene1~Scene7*.tsx   # 每个场景一个组件
           ├── Icons.tsx            # 内联 SVG 图标
           ├── Backgrounds.tsx      # 背景组件
           └── constants.ts         # 配色、文案常量
├── voiceover/                      # 音频工程
   ├── gen_all_voices.py           # edge-tts 批量生成
   ├── gen_bgm.py                  # numpy 合成 BGM
   ├── mix_all.py                  # ffmpeg 合成
   ├── bgm.wav                     # 合成出的 BGM
   └── voice_zh_f/ zh_m/ en_f/ ... # 各版本旁白
└── MiBeeNVR_Promo_*.mp4            # 成品输出

运行环境只需要 Node.js(推荐 18+)和 Python 3.8+。本系列所有代码均以此目录结构为基准。

Remotion 画面生成

安装与 Chrome 配置

bash
1
2
cd remotion-videos
npm install

Remotion 渲染必须要有浏览器内核支持。在沙箱或离线环境中,Remotion 默认下载 Chromium 常常失败。最稳妥的方案是手动下载 chrome-headless-shell 并显式指定路径。

Chrome for Testing 下载对应操作系统的最新版 chrome-headless-shell,解压到 remotion-videos/chrome-dl/ 目录。然后在 remotion.config.ts 中配置:

ts
1
2
3
4
5
6
7
8
import { Config } from "@remotion/cli/config";

Config.setVideoImageFormat("jpeg");
Config.setOverwriteOutput(true);
Config.setConcurrency(4);
Config.setBrowserExecutable(
  "./chrome-dl/chrome-headless-shell-win64/chrome-headless-shell.exe"
);

chrome-headless-shell 是去掉了 UI、扩展、打印等组件,只保留渲染引擎的精简版 Chrome,体积更小、更适合服务端和沙箱环境。

注册 Composition

一个 Composition 就是一段完整的视频。在 src/Root.tsx 中注册:

tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import React from "react";
import { Composition } from "remotion";
import { PromoComposition } from "./compositions/promo/VideoComposition";

export const RemotionRoot: React.FC = () => (
  <Composition
    id="promo"             // 渲染时用的 ID
    component={PromoComposition}
    durationInFrames={1350} // 45s × 30fps
    fps={30}
    width={1920}
    height={1080}
    defaultProps={{}}
  />
);

关键参数:

  • durationInFrames:总帧数 = 时长(秒) × fps
  • fps:帧率,通常 30
  • width / height:输出分辨率,1920×1080 是标配

用 Sequence 拼接多个场景

每个场景是一个独立组件,通过 Remotion 的 <Sequence> 组件拼接。Sequence 接受 from(起始帧)和 durationInFrames(持续帧数)两个参数:

tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { AbsoluteFill, Sequence, useCurrentFrame, interpolate } from "remotion";

// 场景间交叉淡化遮罩
const Overlay = ({ duration }: { duration: number }) => {
  const frame = useCurrentFrame();      // 局部帧,从 0 开始
  let opacity = 0;
  if (frame < 15)
    opacity = interpolate(frame, [0, 15], [1, 0], { extrapolateRight: "clamp" });
  else if (frame > duration - 15)
    opacity = interpolate(frame, [duration - 15, duration], [0, 1], { extrapolateLeft: "clamp" });
  return <AbsoluteFill style={{ backgroundColor: "#0a0e1a", opacity, pointerEvents: "none", zIndex: 100 }} />;
};

export const PromoComposition = () => (
  <AbsoluteFill style={{ backgroundColor: "#0a0e1a" }}>
    <Sequence from={0}    durationInFrames={150}><Scene1 /><Overlay duration={150} /></Sequence>
    <Sequence from={150}  durationInFrames={180}><Scene2 /><Overlay duration={180} /></Sequence>
    <Sequence from={330}  durationInFrames={240}><Scene3 /><Overlay duration={240} /></Sequence>
    <Sequence from={570}  durationInFrames={210}><Scene4 /><Overlay duration={210} /></Sequence>
    <Sequence from={780}  durationInFrames={210}><Scene5 /><Overlay duration={210} /></Sequence>
    <Sequence from={990}  durationInFrames={210}><Scene6 /><Overlay duration={210} /></Sequence>
    <Sequence from={1200} durationInFrames={150}><Scene7 /><Overlay duration={150} /></Sequence>
  </AbsoluteFill>
);

useCurrentFrame() 帧模型<Sequence from={150}> 会给子组件施加一个帧偏移。渲染全局第 150 帧时,子组件中 useCurrentFrame() 返回的是 0(局部帧),而非全局的 150。因此每个 Scene 内的 interpolate() 参数都基于该 Scene 的局部帧区间编写。以 Overlay 为例:frame < 15 判断的是「场景的前 15 帧」做淡入——这正是局部帧偏移带来的便利。

场景间的交叉淡入淡出通过覆盖层组件实现:场景开头从背景色变透明(淡入),结尾从透明变背景色(淡出)。

核心动画 API

Remotion 提供了几个动画函数,掌握它们就能实现绝大部分动效:

tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate, spring } from "remotion";

export const Scene1 = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // 1) interpolate:把帧号映射成任意属性值
  const opacity = interpolate(frame, [40, 70], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  // 2) spring:物理弹性入场
  const scale = spring({
    frame: frame - 40,   // 第 40 帧开始弹
    fps,
    config: { damping: 12, stiffness: 100, mass: 0.8 },
  });

  // 3) 三角函数做呼吸浮动
  const float = Math.sin(frame * 0.08) * 8;

  return (
    <AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
      <div style={{ transform: `scale(${scale}) translateY(${float}px)`, opacity }}>
        {/* 画面内容 */}
      </div>
    </AbsoluteFill>
  );
};

三个核心工具:

  • interpolate(input, [inMin,inMax], [outMin,outMax], options):把帧号线性映射到任意数值范围的万能函数。extrapolateLeft / extrapolateRight 控制超出区间时的行为(clamp 夹住、extend 继续延伸)。
  • spring({frame, fps, config}):基于物理模拟的弹性动画,比线性过渡更生动。damping(阻尼)、stiffness(刚度)、mass(质量)三个参数控制弹性表现。
  • 三角函数Math.sin(frame * frequency) * amplitude 可做出浮动、呼吸、脉冲等周期性效果,轻量且无需额外依赖。

背景与 SVG 图标

⚠️ headless Chrome 没有彩色 emoji 字体:代码中出现的 🐝🫐📷⚡ 等 emoji 会渲染成空白方框——看起来就像「图标不完整」。所有图标必须用内联 SVG 绘制,不能用 emoji。

每个图标封装为一个返回 <svg> 的 React 组件:

tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React from "react";

type IconProps = { size?: number; color?: string; glowColor?: string; glow?: number };

// 辉光:两层 drop-shadow 叠加
const withGlow = (glowColor: string, g: number) =>
  g > 0
    ? `drop-shadow(0 0 ${6 * g}px ${glowColor}) drop-shadow(0 0 ${14 * g}px ${glowColor}aa)`
    : "none";

// 摄像头图标(子弹型监控)
export const CameraIcon: React.FC<IconProps> = ({
  size = 64, color = "#4CAF50", glowColor = "#4CAF50", glow = 0.6,
}) => (
  <svg width={size} height={size} viewBox="0 0 100 100"
    style={{ filter: withGlow(glowColor, glow), overflow: "visible" }}>
    <rect x="22" y="34" width="46" height="30" rx="8" fill="#0d1526" stroke={color} strokeWidth="2.5" />
    <circle cx="68" cy="49" r="15" fill="#0d1526" stroke={color} strokeWidth="2.5" />
    <circle cx="68" cy="49" r="8" fill={color} opacity="0.35" />
    <circle cx="68" cy="49" r="3.5" fill={color} />
  </svg>
);

建议也将背景封装为可复用组件。常见类型:

  • 渐变底色:消灭画面「悬浮在黑色空洞中」的感觉。
  • 浮动六边形 / 粒子流:增加科技感与空间层次。
  • 辉光效果:利用 CSS drop-shadow 多层叠加制造发光感。

字号经验:1920×1080 在手机上缩放至约 0.21 倍,16–28px 的字会缩成 3–6px 糊成一团。如果希望视频在手机上也能看清,正文字号建议 ≥ 40px,或设计时做全局字号映射。

渲染命令

画面就绪后,渲染输出无声 MP4:

bash
1
2
cd remotion-videos
npx remotion render promo "../MiBeeNVR_Promo_Full.mp4" --log=error
  • promoRoot.tsx 中注册的 id
  • 输出是无声 MP4(音频由后续 ffmpeg 合成阶段处理)
  • --log=error 只显示错误信息,减少干扰
  • 渲染速度参考:45 秒视频约 2 分钟(取决于 CPU/内存和并发数)
  • Config.setConcurrency(4) 控制同时进行的截图进程数

Remotion 原理:React 如何变成视频

理解原理,才能改得动、调得对、出问题知道往哪查。

帧模型:视频 = 纯函数

视频的本质是一连串「按帧确定」的画面。 普通网页是交互驱动的——用户不操作就不变。视频不是,第 100 帧永远应该长那个样子。Remotion 把「第 N 帧长什么样」建模成一个纯函数:输入帧号 frame,输出确定的 DOM / SVG。

useCurrentFrame() 的值从哪来? 它虽然是 React Hook,但值不由用户交互决定——渲染器在生成第 N 帧时把 frame=N 注入进去。同理 useVideoConfig() 注入 fpswidthheightdurationInFrames。所以 interpolate(frame, [0,30], [0,1]) 在第 15 帧时必然得到 0.5——确定性、可复现、可测试。

逐帧截图管道

mermaid
flowchart TD
    A[React 组件] --> B[headless Chrome<br/>渲染每帧 DOM]
    B --> C[逐帧截图<br/>JPEG/PNG]
    C --> D[ffmpeg 编码]
    D --> E[无声 MP4]

    classDef blue fill:#2196F3,stroke:#1565C0,color:#fff;
    classDef green fill:#4CAF50,stroke:#2E7D32,color:#fff;
    classDef orange fill:#FF9800,stroke:#E65100,color:#fff;
    classDef purple fill:#9C27B0,stroke:#6A1B9A,color:#fff;

    class A orange;
    class B blue;
    class C blue;
    class D purple;
    class E green;

渲染流程:React 组件在 headless Chrome 中逐帧渲染为真实 DOM,截图引擎导出每帧为 JPEG/PNG 图片,最后 ffmpeg 编码为 H.264 视频流。

具体步骤如下:

  1. Remotion 启动 headless 浏览器(Chromium 或 chrome-headless-shell)。
  2. 浏览器中 React 渲染每一帧为真实 DOM——完整支持 CSS 渐变、Web 字体、SVG 滤镜、drop-shadow 等现代 Web 特性。
  3. 调用浏览器的截图能力,将该帧导出为 JPEG / PNG 图片。
  4. 重复 1350 次(45s × 30fps)→ 得到 1350 张图片。
  5. 内部 ffmpeg 将图片序列编码为 H.264 视频流,输出无声 MP4。

为什么需要 Chrome

现代 GPU 加速渲染器才能正确处理 SVG 滤镜、CSS drop-shadowmix-blend-mode、Web 字体等特性。Chrome 的 chrome-headless-shell 是砍掉界面(UI、扩展、打印等)只保留渲染引擎的精简版本——体积更小、资源消耗更低,适合服务端和沙箱环境。

为什么可以分布式 / Serverless

「第 N 帧」的渲染不依赖其他任何帧。 这是纯函数模型带来的关键特性——1350 帧可以任意切分,交给多台机器并行截图,最后再拼合成完整视频。Remotion Lambda(官方 Serverless 方案)正是基于此原理:并发数取决于预算,而非单机性能。本地 Config.setConcurrency(4) 做的就是同一台机器上 4 个截图进程并行。


本系列:① 总览与 Remotionedge-tts 配音numpy BGMffmpeg 合成