Remotion 教程 12:AI 口播短视频工作流
AI 口播短视频不是一个单点功能,而是一条工作流。你需要把脚本、分镜、音频、字幕、素材和视觉模板连接起来。
Remotion 位于后半段,负责把结构化数据变成确定的视频画面。
主题 -> 脚本 -> 分镜 -> 音频 -> 字幕 -> 素材 -> Remotion 模板 -> 视频文件不要把脚本当成一段纯文本
很多人会先生成一整段口播文案,然后直接丢进模板。这样很难控制画面节奏。
更好的结构是按场景拆开:
type ScriptScene = {
id: string;
title: string;
narration: string;
accent: string;
startFrame: number;
endFrame: number;
};每段口播都能对应:
- 画面标题。
- 主题色。
- 素材或图表。
- 字幕时间段。
- 出现时机。
一个最小输入数据
const scenes: ScriptScene[] = [
{
id: "hook",
title: "视频动画要可确定",
narration: "Remotion 的核心不是浏览器动画,而是逐帧生成可复现的视频画面。",
accent: "#2563eb",
startFrame: 0,
endFrame: 188,
},
{
id: "workflow",
title: "脚本、字幕、素材一起编排",
narration: "把脚本、口播音频、字幕时间和视觉模板放进同一条时间轴。",
accent: "#16a34a",
startFrame: 188,
endFrame: 363,
},
{
id: "render",
title: "模板可以批量出片",
narration: "输入数据稳定后,同一个模板就能批量生成不同主题的视频。",
accent: "#7c3aed",
startFrame: 363,
endFrame: 540,
},
];这比纯文本更适合视频生成,因为模板可以根据结构化字段决定画面。
字幕和音频要分开看
音频提供声音,字幕提供可视化节奏。两者不是自动绑定的。
你需要一条字幕时间轴:
type CaptionWord = {
text: string;
start: number;
end: number;
};这里的 start 和 end 可以是帧号,也可以是毫秒。关键是要和音频内容对齐。
词级时间轴能做逐词高亮,句级时间轴适合普通字幕条。
下面这组短语时间和本文示例音频对应。时间单位是帧,30fps 下第 188 帧约等于 6.27 秒:
const captionWords = [
{ text: "Remotion 的核心", start: 3, end: 45 },
{ text: "不是浏览器动画", start: 45, end: 92 },
{ text: "而是逐帧生成", start: 92, end: 138 },
{ text: "可复现的视频画面", start: 138, end: 188 },
{ text: "把脚本", start: 188, end: 220 },
{ text: "口播音频", start: 220, end: 252 },
{ text: "字幕时间", start: 252, end: 284 },
{ text: "和视觉模板", start: 284, end: 322 },
{ text: "放进同一条时间轴", start: 322, end: 363 },
{ text: "输入数据稳定后", start: 363, end: 410 },
{ text: "同一个模板", start: 410, end: 450 },
{ text: "就能批量生成", start: 450, end: 490 },
{ text: "不同主题的视频", start: 490, end: 526 },
] as const;模板参数要受控
AI 视频模板通常需要接收外部输入。不要让任意结构直接进入模板,建议用 zod 做校验:
import { z } from "zod";
export const talkingVideoSchema = z.object({
title: z.string(),
themeColor: z.string(),
});这样模板至少能保证关键字段存在,批量生成时也更容易定位错误。
完整最小示例
下面是一个简化版 AI 口播模板:标题来自 props,场景来自结构化数据,字幕放在底部。
import React from "react";
import { z } from "zod";
import {
AbsoluteFill,
Audio,
Sequence,
interpolate,
staticFile,
useCurrentFrame,
} from "remotion";
export const talkingVideoSchema = z.object({
title: z.string(),
themeColor: z.string(),
});
const scenes = [
{
id: "hook",
title: "视频动画要可确定",
narration: "Remotion 的核心不是浏览器动画,而是逐帧生成可复现的视频画面。",
accent: "#2563eb",
startFrame: 0,
endFrame: 188,
},
{
id: "workflow",
title: "脚本、字幕、素材一起编排",
narration: "把脚本、口播音频、字幕时间和视觉模板放进同一条时间轴。",
accent: "#16a34a",
startFrame: 188,
endFrame: 363,
},
{
id: "render",
title: "模板可以批量出片",
narration: "输入数据稳定后,同一个模板就能批量生成不同主题的视频。",
accent: "#7c3aed",
startFrame: 363,
endFrame: 540,
},
] as const;
const captionWords = [
{ text: "Remotion 的核心", start: 3, end: 45 },
{ text: "不是浏览器动画", start: 45, end: 92 },
{ text: "而是逐帧生成", start: 92, end: 138 },
{ text: "可复现的视频画面", start: 138, end: 188 },
{ text: "把脚本", start: 188, end: 220 },
{ text: "口播音频", start: 220, end: 252 },
{ text: "字幕时间", start: 252, end: 284 },
{ text: "和视觉模板", start: 284, end: 322 },
{ text: "放进同一条时间轴", start: 322, end: 363 },
{ text: "输入数据稳定后", start: 363, end: 410 },
{ text: "同一个模板", start: 410, end: 450 },
{ text: "就能批量生成", start: 450, end: 490 },
{ text: "不同主题的视频", start: 490, end: 526 },
] as const;
const CaptionLine: React.FC = () => {
const frame = useCurrentFrame();
const visibleWords = captionWords.filter(
(word) => frame >= word.start - 12 && frame < word.end + 12,
);
const displayWords = visibleWords.length > 0 ? visibleWords : [captionWords[0]];
return (
<div style={{ display: "flex", justifyContent: "center", flexWrap: "wrap", gap: 12 }}>
{displayWords.map((word) => {
const active = frame >= word.start && frame < word.end;
return (
<span
key={`${word.text}-${word.start}`}
style={{
color: active ? "#f59e0b" : "#111827",
opacity: frame >= word.start ? 1 : 0.34,
fontSize: 44,
fontWeight: 900,
}}
>
{word.text}
</span>
);
})}
</div>
);
};
export const TalkingVideo: React.FC<z.infer<typeof talkingVideoSchema>> = ({
title,
themeColor,
}) => {
const frame = useCurrentFrame();
const activeScene =
scenes.find((scene) => frame >= scene.startFrame && frame < scene.endFrame) ??
scenes[scenes.length - 1];
const sceneFrame = frame - activeScene.startFrame;
const sceneDuration = activeScene.endFrame - activeScene.startFrame;
const titleOpacity = interpolate(frame, [0, 24], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const sceneProgress = interpolate(
sceneFrame,
[0, 18, sceneDuration - 24, sceneDuration],
[0, 1, 1, 0.92],
{
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
},
);
return (
<AbsoluteFill
style={{
backgroundColor: "#0f172a",
color: "white",
padding: 96,
fontFamily: "Inter, sans-serif",
}}
>
<Audio src={staticFile("audio/talking-demo.wav")} />
<div style={{ fontSize: 30, color: themeColor, fontWeight: 800 }}>
AI 口播短视频模板
</div>
<h1 style={{ margin: "22px 0 0", fontSize: 82, opacity: titleOpacity }}>
{title}
</h1>
<Sequence from={34}>
<div
style={{
marginTop: 86,
padding: 42,
borderRadius: 8,
backgroundColor: activeScene.accent,
opacity: sceneProgress,
transform: `translateY(${(1 - sceneProgress) * 24}px)`,
}}
>
<h2 style={{ margin: 0, fontSize: 54 }}>{activeScene.title}</h2>
<p style={{ margin: "24px 0 0", fontSize: 34, lineHeight: 1.35 }}>
{activeScene.narration}
</p>
</div>
</Sequence>
<div
style={{
position: "absolute",
left: 96,
right: 96,
bottom: 120,
padding: "30px 34px",
borderRadius: 8,
backgroundColor: "rgba(248, 250, 252, 0.94)",
}}
>
<CaptionLine />
</div>
</AbsoluteFill>
);
};为什么先用 mock
很多人一上来就想接 LLM、TTS、Whisper 和素材搜索。更稳的做法是先用 mock 数据验证模板:
- 脚本结构能驱动画面。
- 音频能正常播放。
- 字幕能按时间高亮。
- props 能改变标题和主题色。
- 单帧和短片段渲染都稳定。
模板稳定后,再把 mock 替换成真实 API 输出。
真实工作流怎么接
一个实际 AI 口播视频工作流通常是:
用户输入主题
-> LLM 生成脚本和分镜
-> TTS 生成音频
-> Whisper 或 TTS 返回字幕时间轴
-> 素材服务返回图片/视频/图表数据
-> Remotion 模板预览
-> Renderer 出片每一步的输出都应该变成明确数据结构,而不是临时拼字符串。
示例项目位置
这篇文章对应示例项目里的:
Composition: FinalTalkingVideo
文件: src/final-project/TalkingVideo.tsx
数据: src/course/mockData.ts
音频: public/audio/talking-demo.wav
字幕: public/audio/talking-demo.srt运行示例项目后,可以修改 FinalTalkingVideo 的默认标题、主题色和三段口播脚本,观察模板如何自动更新画面。
下一篇看模板稳定后,如何接入 Player、Renderer 和队列系统做批量出片。