比特之外

Remotion 教程 05:从 SRT 到逐词高亮的口播字幕视频

上一章把音频、转场和运动尾迹放进了时间轴。这一篇继续处理口播视频里最常见的问题:声音有了,字幕怎么和声音对齐?

字幕不是简单地把一段文字放在底部。真正影响观感的是三件事:

  • 当前说到哪一句。
  • 当前高亮哪个词。
  • 字幕节奏是否和音频一致。

如果字幕没有时间信息,你只能整段显示文本。想做逐词高亮,就必须知道每个词的开始和结束时间。

字幕数据从哪里来

真实项目里,字幕时间轴可能来自:

  • Whisper 语音识别。
  • TTS 服务返回的 word timing。
  • 剪辑软件导出的 SRT。
  • 人工校对后的字幕文件。

为了先讲清楚链路,下面用一段本地 SRT 字符串做示例:

const captionSrt = `1
00:00:00,100 --> 00:00:06,276
Remotion 的核心不是浏览器动画,而是逐帧生成可复现的视频画面。
 
2
00:00:06,226 --> 00:00:12,095
把脚本、口播音频、字幕时间和视觉模板放进同一条时间轴。
 
3
00:00:12,095 --> 00:00:17,511
输入数据稳定后,同一个模板就能批量生成不同主题的视频。
`;

SRT 里的时间是毫秒级时间,不是 Remotion 的帧号。后面需要把当前帧换算成当前毫秒。

第一步:解析 SRT

@remotion/captions 提供了 parseSrt()

const { captions } = parseSrt({ input: captionSrt });

解析后,每条字幕大致会变成:

{
  text: string;
  startMs: number;
  endMs: number;
}

注意这里是毫秒,不是帧。

第二步:整理成短视频字幕片段

短视频字幕通常不是一整段全显示,而是分成一屏一屏。可以用 createTikTokStyleCaptions()

const { pages } = createTikTokStyleCaptions({
  captions,
  combineTokensWithinMilliseconds: 900,
});

你可以把 page 理解为“一屏字幕”,token 理解为“这一屏里的一个词”。

每个 token 有自己的时间范围:

{
  text: string;
  fromMs: number;
  toMs: number;
}

combineTokensWithinMilliseconds: 900 表示:时间距离比较近的词可以合并到同一屏字幕里。

第三步:把 frame 换算成毫秒

Remotion 组件里拿到的是 frame:

const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const currentMs = (frame / fps) * 1000;

如果是 30fps:

frame = 30  -> currentMs = 1000
frame = 60  -> currentMs = 2000

这样就能用当前视频时间去匹配字幕时间。

第四步:找到当前字幕页

const activePage =
  pages.find(
    (page) =>
      currentMs >= page.startMs && currentMs < page.startMs + page.durationMs,
  ) ?? pages[0];

这段代码的意思是:找到当前时间应该显示哪一屏字幕。如果没有找到,就默认显示第一屏,避免画面空掉。

第五步:逐词高亮

每个 token 都有开始和结束时间:

const active = currentMs >= token.fromMs && currentMs < token.toMs;

如果当前时间落在这个 token 的时间范围内,就把它变红、放大:

style={{
  color: active ? "#dc2626" : "#111827",
  opacity: currentMs >= token.fromMs ? 1 : 0.34,
  transform: active ? "scale(1.08)" : "scale(1)",
}}

这就是逐词高亮的核心逻辑。它不依赖浏览器动画,也不依赖播放器状态,只依赖当前 frame 换算出来的时间。

完整最小示例

下面是一个可独立理解的口播字幕组件。它会播放音频,并根据 SRT 时间做逐词高亮。

import React from "react";
import {
  createTikTokStyleCaptions,
  parseSrt,
} from "@remotion/captions";
import {
  AbsoluteFill,
  Audio,
  staticFile,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
 
const captionSrt = `1
00:00:00,100 --> 00:00:06,276
Remotion 的核心不是浏览器动画,而是逐帧生成可复现的视频画面。
 
2
00:00:06,226 --> 00:00:12,095
把脚本、口播音频、字幕时间和视觉模板放进同一条时间轴。
 
3
00:00:12,095 --> 00:00:17,511
输入数据稳定后,同一个模板就能批量生成不同主题的视频。
`;
 
const { captions } = parseSrt({ input: captionSrt });
const { pages } = createTikTokStyleCaptions({
  captions,
  combineTokensWithinMilliseconds: 900,
});
 
export const CaptionDemo: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const currentMs = (frame / fps) * 1000;
 
  const activePage =
    pages.find(
      (page) =>
        currentMs >= page.startMs &&
        currentMs < page.startMs + page.durationMs,
    ) ?? pages[0];
 
  return (
    <AbsoluteFill
      style={{
        backgroundColor: "#f8fafc",
        padding: 96,
        fontFamily: "Inter, sans-serif",
      }}
    >
      <Audio src={staticFile("audio/talking-demo.wav")} />
 
      <h1 style={{ margin: 0, fontSize: 72 }}>逐词高亮字幕</h1>
 
      <div
        style={{
          marginTop: 150,
          padding: "42px 46px",
          borderRadius: 8,
          backgroundColor: "white",
          boxShadow: "0 24px 80px rgba(15, 23, 42, 0.12)",
        }}
      >
        <div
          style={{
            display: "flex",
            justifyContent: "center",
            flexWrap: "wrap",
            gap: 16,
            fontSize: 58,
            fontWeight: 900,
          }}
        >
          {activePage.tokens.map((token) => {
            const active =
              currentMs >= token.fromMs && currentMs < token.toMs;
 
            return (
              <span
                key={`${token.text}-${token.fromMs}`}
                style={{
                  color: active ? "#dc2626" : "#111827",
                  opacity: currentMs >= token.fromMs ? 1 : 0.34,
                  transform: active ? "scale(1.08)" : "scale(1)",
                }}
              >
                {token.text}
              </span>
            );
          })}
        </div>
      </div>
    </AbsoluteFill>
  );
};

为什么先用本地 mock

很多人一上来就想接 Whisper、TTS、LLM。更稳的做法是先用本地 mock 数据跑通模板:

  1. 本地音频能播放。
  2. 本地 SRT 能解析。
  3. 当前 frame 能换算成毫秒。
  4. 当前 token 能正确高亮。

这些都稳定之后,再替换真实 API 输出。

示例项目位置

这篇文章对应示例项目里的:

Composition: LessonCaptionsTalking
文件: src/lessons/CaptionsTalking.tsx
数据: src/course/mockData.ts
音频: public/audio/talking-demo.wav
字幕: public/audio/talking-demo.srt

运行示例项目后,可以试三个改动:

  1. 修改 captionSrt 里的某个短语。
  2. 修改某个词的时间范围,观察高亮时机变化。
  3. combineTokensWithinMilliseconds 从 900 改成 300,观察每屏字幕如何变化。

下一篇单独讨论 Motion 的边界:哪些 Motion 能力适合视频,哪些不适合。

本文标签