比特之外

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;
};

这里的 startend 可以是帧号,也可以是毫秒。关键是要和音频内容对齐。

词级时间轴能做逐词高亮,句级时间轴适合普通字幕条。

下面这组短语时间和本文示例音频对应。时间单位是帧,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 数据验证模板:

  1. 脚本结构能驱动画面。
  2. 音频能正常播放。
  3. 字幕能按时间高亮。
  4. props 能改变标题和主题色。
  5. 单帧和短片段渲染都稳定。

模板稳定后,再把 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 和队列系统做批量出片。

本文标签