剪辑软件擅长的是"做一条片子"。当需求变成"用同一套版式出 500 条,每条只换标题、数据、封面",手工剪辑的成本会随条数线性上涨,而代码生成的成本几乎是常数。Remotion 的思路很直接:把视频的每一帧描述成一个 React 组件,帧号就是输入,画面就是输出,出片变成一次构建。
它解决的不是替代剪辑,而是把重复劳动的版式变成可复用的函数。
1. 用代码做视频解决什么
| 需求形态 | 手工剪辑的做法 | 代码生成的做法 |
|---|---|---|
| 批量出片 | 复制工程、逐个改文案 | 改数据文件,重跑渲染命令 |
| 数据驱动 | 人肉抄表格进画面 | 组件直接读 JSON / 接口返回值 |
| 版本控制 | 二进制工程文件无法 diff | 源码进 Git,改动可评审可回滚 |
| 复用组件 | 复制粘贴时间轴片段 | 抽成组件,改一处全部生效 |
| 参数化变体 | 手动对齐时间点 | props 驱动,同一份代码出多语言/多尺寸 |
| 确定性重出 | 依赖人记得所有细节 | 同一份输入永远得到同一份输出 |
判断标准很简单:如果同一条片子你做第二遍,且第二遍和第一遍只有数据不同,就该用代码生成。
2. 与剪辑软件的适用边界
| 场景 | 更适合 | 原因 |
|---|---|---|
| 一次性的宣传片、需要大量调色与运镜 | 剪辑软件 | 创意探索靠手感,不靠参数 |
| 每周几十条同版式短视频 | 代码生成 | 版式固定,只有数据在变 |
| 素材本身要精剪(多机位、访谈) | 剪辑软件 | 波形对齐、节拍剪辑是编辑器强项 |
| 画面由数据决定(图表、榜单、排行榜) | 代码生成 | 数据变化直接反映到画面 |
| 复杂特效、粒子、3D 合成 | 剪辑/合成软件,或代码生成 + 预渲染素材 | 两者可组合,不要二选一 |
实践中更常见的是混合:片头片尾与动态数据画面用代码生成,中间的精剪片段由剪辑软件产出,再用代码拼起来。
3. 核心模型:帧、插值与 Composition
三个概念撑起全部:useCurrentFrame() 给出当前帧号;interpolate() 把帧号映射成任意属性;Composition 定义这条片子的时长、帧率、分辨率。
// src/Root.tsx
import { Composition } from "remotion";
import { Episode } from "./Episode";
export const RemotionRoot: React.FC = () => (
<Composition
id="Episode"
component={Episode}
durationInFrames={30 * 12} // 12 秒 × 30fps,注意这里是帧不是秒
fps={30}
width={1920}
height={1080}
defaultProps={{ title: "占位标题", items: [] as string[] }}
/>
);
// src/Episode.tsx
import { useCurrentFrame, useVideoConfig, interpolate, AbsoluteFill } from "remotion";
export const Episode: React.FC<{ title: string; items: string[] }> = ({ title, items }) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
// 0~20 帧内从透明到不透明,之后保持
const opacity = interpolate(frame, [0, 20], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// 整片进度,用来做进度条
const progress = interpolate(frame, [0, durationInFrames], [0, 1]);
return (
<AbsoluteFill style={{ backgroundColor: "#0f172a", padding: 80 }}>
<h1 style={{ color: "#fff", opacity }}>{title}</h1>
<div style={{ color: "#38bdf8", width: `${progress * 100}%`, height: 8 }} />
<ul style={{ color: "#e2e8f0" }}>
{items.map((it) => <li key={it}>{it}</li>)}
</ul>
</AbsoluteFill>
);
};
几个必须记牢的点:
| API | 单位 | 常见误用 |
|---|---|---|
durationInFrames |
帧 | 直接填"秒数",结果片子短了 30 倍 |
fps |
帧/秒 | 与素材帧率不一致,音画不同步 |
interpolate 的 extrapolate* |
clamp / extend / identity | 不设 clamp,动画结束后数值继续外推 |
useVideoConfig() |
当前 Composition 配置 | 在组件里硬编码 1920×1080,换尺寸就错位 |
Sequence 的 from / durationInFrames |
帧 | 用毫秒算,时间轴整体错位 |
4. 项目结构、预览与出片
# 预览:本地打开 Studio,改代码即时热更新
npx remotion studio
# 出片:渲染某个 Composition
npx remotion render Episode out/episode-001.mp4
# 指定并发与质量
npx remotion render Episode out/episode-001.mp4 --concurrency=4
# 用外部数据覆盖 defaultProps
npx remotion render Episode out/001.mp4 --props=./data/001.json
典型目录形态:
| 路径 | 放什么 |
|---|---|
src/Root.tsx |
Composition 注册表,所有片子的入口 |
src/<Name>.tsx |
每条片子的组件 |
src/components/ |
可复用片段(片头、字幕条、数据卡) |
public/ |
图片、音频、字体等静态素材 |
data/ |
每条片子一份 JSON 数据 |
--props 读的是 JSON 文件,也可以用 --props='{"title":"..."}' 直接内联。命令行 props 会覆盖 defaultProps,但不会自动帮你补齐缺失字段——少传的字段要么在组件里给默认值,要么在渲染前校验,否则画面里会出现 undefined。
5. 数据驱动与参数化
组件入口处做一次 props 收敛(title 兜底 "未命名"、items 限长 slice(0, 5)),是批量场景里最便宜的一道保护。
批量渲染最省事的写法是让每条片子一个 JSON,然后用一个循环驱动:
#!/usr/bin/env bash
set -euo pipefail
mkdir -p out
for f in data/*.json; do
name="$(basename "$f" .json)"
[[ -f "out/$name.mp4" ]] && continue # 断点续跑:已有产物就跳过
echo "== render $name"
npx remotion render Episode "out/$name.mp4" --props="$f" --concurrency=4
done
set -euo pipefail 与"已有产物跳过"这两句在批量场景里很关键:500 条里挂第 300 条时,重跑不会把前 299 条重做一遍。
6. 音频与字幕轨道
音频不是"背景",它和时间轴一样是帧级的:
import { Audio, Sequence, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
// 背景音乐:整片铺底,带淡入
<Audio src={staticFile("bgm.mp3")} volume={(f) =>
interpolate(f, [0, 15], [0, 0.6], { extrapolateRight: "clamp" })
} />
// 第 3 秒起插入一段 2 秒的旁白
<Sequence from={3 * 30} durationInFrames={2 * 30}>
<Audio src={staticFile("vo/001.mp3")} />
</Sequence>
| 轨道 | 做法 | 坑 |
|---|---|---|
| 背景音乐 | <Audio> 铺满整片 + volume 回调 |
音频比视频长/短,结尾被硬切 |
| 旁白 | Sequence 定位起点 |
from 用秒算,整体偏移 |
| 字幕 | 把 SRT 解析成 [{startMs, endMs, text}],再按帧区间渲染 |
直接渲染整段文本,时间轴对不上 |
字幕的稳定做法是先解析成结构化数组,渲染时用当前帧反查命中项,而不是在页面里做 DOM 动画。
7. 渲染性能与并发
渲染是把每一帧交给浏览器引擎画一遍,再编码成视频,所以瓶颈通常有三处:帧数 × 复杂度、编码器、内存。
| 旋钮 | 作用 | 调整思路 |
|---|---|---|
--concurrency |
同时渲染的帧数(按浏览器标签/线程切分) | 从机器核数起步,观察内存再往上加 |
| 分辨率与码率 | 单帧像素量与编码压力 | 需要 4K 就 4K,不需要别默认拉满 |
| 帧率 | 帧总数 | 30fps 足够就不上 60fps,帧数直接翻倍 |
长视频的时间成本可以用一个粗算公式自查:总帧数 ÷ 并发数 × 单帧平均渲染耗时。12 秒 30fps 是 360 帧,看起来很小;把片子拉到 20 分钟,就是 36000 帧,同样的单帧耗时会变成近百倍的时长。所以工程上更推荐分片渲染再拼接,而不是一次性渲染一条长片。
8. 放进 CI 批量出片
# .github/workflows/render.yml(片段)
jobs:
render:
runs-on: ubuntu-latest
strategy:
matrix:
data: [001, 002, 003] # 需要多少条就在这里展开
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- name: Render
run: npx remotion render Episode "out/${{ matrix.data }}.mp4" \
--props="data/${{ matrix.data }}.json" --concurrency=2
- uses: actions/upload-artifact@v4
with:
name: video-${{ matrix.data }}
path: out/*.mp4
CI 里要额外留意三件事:渲染需要的浏览器运行时必须在 CI 环境里存在(安装依赖时按当前版本核对是否需要显式安装);字体要么打进仓库、要么用字体包显式加载,不能依赖构建机的系统字体;产物走 artifact 上传,不要塞进 Git 历史。
9. 排错:七种"看着不对"的情况
| 现象 | 原因 | 处理 |
|---|---|---|
| 出来的片子比预期短/长几倍 | 把秒数填进了 durationInFrames,或把 30fps 当 25fps 算 |
统一用"秒 × fps"推导帧数,全项目只维护 fps 一处 |
| 中文全是方框或豆腐块 | 渲染环境没有该字体 | public/ 放字体文件并显式加载,CI 与本地字体一致 |
| 本地能渲染、CI 报浏览器相关错误 | 缺少渲染所需的浏览器运行时 | 在 CI 步骤里按当前版本核对补齐依赖与系统库 |
| 渲染中途进程被杀 | 并发太高导致内存爆 | 降 --concurrency,或拆成分片渲染 |
| 音画不同步 | 音频素材采样率/偏移与帧率换算不一致 | 以帧为单位描述音频起点,长片分段核对 |
画面里出现 undefined |
--props 缺字段且组件没有兜底 |
渲染前做 props 校验,给默认值 |
10. 边界:这些情况别硬上
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 只出一次、以后不再改 | 剪辑软件 | 写组件的前期投入收不回来 |
| 大量运镜、调色、手工特效 | 剪辑/合成软件 | 创意型操作不适合参数化 |
| 极长视频(几十分钟以上) | 分片渲染 + 拼接,或交给专业转码链路 | 单进程渲染的内存与时间成本不可控 |
一句话定位:Remotion 把"版式"变成可版本控制、可参数化、可批量执行的代码,但它不替代剪辑,它替代的是"重复剪同一套版式"。
11. 自测
- 什么时候值得把视频从剪辑软件搬到代码生成?
durationInFrames填12而fps是 30,实际会出多长的片子?interpolate不写extrapolateRight: "clamp"会出现什么现象?- 批量渲染 500 条时,为什么要先判断产物是否已存在?
- 本地渲染正常、CI 渲染报浏览器错误,先查哪一类依赖?
- 一段 20 分钟、30fps 的片子大概是多少帧,为什么建议分片渲染?