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 数据跑通模板:
- 本地音频能播放。
- 本地 SRT 能解析。
- 当前 frame 能换算成毫秒。
- 当前 token 能正确高亮。
这些都稳定之后,再替换真实 API 输出。
示例项目位置
这篇文章对应示例项目里的:
Composition: LessonCaptionsTalking
文件: src/lessons/CaptionsTalking.tsx
数据: src/course/mockData.ts
音频: public/audio/talking-demo.wav
字幕: public/audio/talking-demo.srt运行示例项目后,可以试三个改动:
- 修改
captionSrt里的某个短语。 - 修改某个词的时间范围,观察高亮时机变化。
- 把
combineTokensWithinMilliseconds从 900 改成 300,观察每屏字幕如何变化。
下一篇单独讨论 Motion 的边界:哪些 Motion 能力适合视频,哪些不适合。