在影片中顯示字幕
學習如何在 Remotion 影片中顯示字幕,包含逐字高亮、TikTok 風格分頁字幕與完整範例。
前提條件
本指南說明如何在 Remotion 中顯示字幕,假設你已有 Caption 格式的字幕資料。如需了解如何產生字幕,請參閱音訊轉錄為字幕。
載入字幕
首先,載入你的字幕 JSON 檔案。使用 useDelayRender() 暫停渲染,直到字幕載入完成:
import { useState, useEffect, useCallback } from 'react';
import { AbsoluteFill, staticFile, useDelayRender } from 'remotion';
import type { Caption } from '@remotion/captions';
export const MyComponent: React.FC = () => {
const [captions, setCaptions] = useState<Caption[] | null>(null);
const { delayRender, continueRender, cancelRender } = useDelayRender();
const [handle] = useState(() => delayRender());
const fetchCaptions = useCallback(async () => {
try {
const response = await fetch(staticFile('captions.json'));
const data = await response.json();
setCaptions(data);
continueRender(handle);
} catch (e) {
cancelRender(e);
}
}, [continueRender, cancelRender, handle]);
useEffect(() => {
fetchCaptions();
}, [fetchCaptions]);
if (!captions) {
return null;
}
return <AbsoluteFill>{/* 在此渲染字幕 */}</AbsoluteFill>;
};為什麼要用 useDelayRender()?
在 Remotion 的渲染流程中,每一幀都是獨立渲染的。如果不使用 useDelayRender(),Remotion 可能在字幕資料還未載入完成前就開始渲染,導致字幕無法顯示。useDelayRender() 會暫停該幀的渲染,直到你呼叫 continueRender() 為止。
建立字幕分頁
使用 createTikTokStyleCaptions() 將字幕分組成頁面。combineTokensWithinMilliseconds 參數控制每頁同時顯示的文字時長:
import { useMemo } from 'react';
import { createTikTokStyleCaptions } from '@remotion/captions';
const SWITCH_CAPTIONS_EVERY_MS = 1200;
const { pages } = useMemo(() => {
return createTikTokStyleCaptions({
captions,
combineTokensWithinMilliseconds: SWITCH_CAPTIONS_EVERY_MS,
});
}, [captions]);createTikTokStyleCaptions() 會將字幕 token 依時間窗口分組,產生類似 TikTok 影片中逐段切換的字幕效果。
使用 Sequence 渲染字幕
對每個分頁使用 <Sequence> 渲染,根據頁面時間計算起始幀與持續幀數:
import { AbsoluteFill, Sequence, useVideoConfig } from 'remotion';
const CaptionedContent: React.FC = () => {
const { fps } = useVideoConfig();
return (
<AbsoluteFill>
{pages.map((page, index) => {
const nextPage = pages[index + 1] ?? null;
const startFrame = (page.startMs / 1000) * fps;
const endFrame = Math.min(
nextPage ? (nextPage.startMs / 1000) * fps : Infinity,
startFrame + (SWITCH_CAPTIONS_EVERY_MS / 1000) * fps
);
const durationInFrames = endFrame - startFrame;
if (durationInFrames <= 0) {
return null;
}
return (
<Sequence
key={index}
from={startFrame}
durationInFrames={durationInFrames}
>
<CaptionPage page={page} />
</Sequence>
);
})}
</AbsoluteFill>
);
};<Sequence> 的作用
<Sequence> 元件讓你可以將元件限制在特定的時間範圍內顯示。from 屬性指定起始幀,durationInFrames 指定持續幀數。在 <Sequence> 內部,useCurrentFrame() 會從 0 開始計算,讓每個字幕頁面可以獨立計算自己的時間。
渲染單一字幕頁面
字幕頁面包含 tokens,你可以用來高亮目前正在說的字詞。以下範例示範如何在說話時高亮對應的字詞:
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from 'remotion';
import type { TikTokPage } from '@remotion/captions';
const HIGHLIGHT_COLOR = '#39E508';
const CaptionPage: React.FC<{ page: TikTokPage }> = ({ page }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// 相對於 Sequence 起始的目前時間
const currentTimeMs = (frame / fps) * 1000;
// 轉換為絕對時間,加上頁面起始時間
const absoluteTimeMs = page.startMs + currentTimeMs;
return (
<AbsoluteFill
style={{
justifyContent: 'center',
alignItems: 'center',
}}
>
<div
style={{
fontSize: 80,
fontWeight: 'bold',
textAlign: 'center',
// 保留字幕中的空白字元
whiteSpace: 'pre',
}}
>
{page.tokens.map((token) => {
const isActive =
token.fromMs <= absoluteTimeMs && token.toMs > absoluteTimeMs;
return (
<span
key={token.fromMs}
style={{
color: isActive ? HIGHLIGHT_COLOR : 'white',
}}
>
{token.text}
</span>
);
})}
</div>
</AbsoluteFill>
);
};時間計算說明
由於 <Sequence> 會讓 useCurrentFrame() 從 0 重新計算,我們需要將相對時間加上頁面的絕對起始時間 page.startMs,才能正確對應每個 token 的時間戳記。
完整範例
以下是一個完整的字幕影片範例,整合了上述所有步驟:
import {
useState,
useEffect,
useCallback,
useMemo,
} from 'react';
import {
AbsoluteFill,
Sequence,
staticFile,
useCurrentFrame,
useDelayRender,
useVideoConfig,
} from 'remotion';
import { createTikTokStyleCaptions } from '@remotion/captions';
import type { Caption, TikTokPage } from '@remotion/captions';
const SWITCH_CAPTIONS_EVERY_MS = 1200;
const HIGHLIGHT_COLOR = '#39E508';
const CaptionPage: React.FC<{ page: TikTokPage }> = ({ page }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const currentTimeMs = (frame / fps) * 1000;
const absoluteTimeMs = page.startMs + currentTimeMs;
return (
<AbsoluteFill
style={{
justifyContent: 'center',
alignItems: 'center',
}}
>
<div
style={{
fontSize: 80,
fontWeight: 'bold',
textAlign: 'center',
whiteSpace: 'pre',
}}
>
{page.tokens.map((token) => {
const isActive =
token.fromMs <= absoluteTimeMs && token.toMs > absoluteTimeMs;
return (
<span
key={token.fromMs}
style={{
color: isActive ? HIGHLIGHT_COLOR : 'white',
}}
>
{token.text}
</span>
);
})}
</div>
</AbsoluteFill>
);
};
export const CaptionedVideo: React.FC = () => {
const [captions, setCaptions] = useState<Caption[] | null>(null);
const { delayRender, continueRender, cancelRender } = useDelayRender();
const [handle] = useState(() => delayRender());
const { fps } = useVideoConfig();
const fetchCaptions = useCallback(async () => {
try {
const response = await fetch(staticFile('captions.json'));
const data = await response.json();
setCaptions(data);
continueRender(handle);
} catch (e) {
cancelRender(e);
}
}, [continueRender, cancelRender, handle]);
useEffect(() => {
fetchCaptions();
}, [fetchCaptions]);
const { pages } = useMemo(() => {
return createTikTokStyleCaptions({
captions: captions ?? [],
combineTokensWithinMilliseconds: SWITCH_CAPTIONS_EVERY_MS,
});
}, [captions]);
return (
<AbsoluteFill style={{ backgroundColor: 'black' }}>
{pages.map((page, index) => {
const nextPage = pages[index + 1] ?? null;
const startFrame = (page.startMs / 1000) * fps;
const endFrame = Math.min(
nextPage ? (nextPage.startMs / 1000) * fps : Infinity,
startFrame + (SWITCH_CAPTIONS_EVERY_MS / 1000) * fps
);
const durationInFrames = endFrame - startFrame;
if (durationInFrames <= 0) {
return null;
}
return (
<Sequence
key={index}
from={startFrame}
durationInFrames={durationInFrames}
>
<CaptionPage page={page} />
</Sequence>
);
})}
</AbsoluteFill>
);
};進階自訂
自動縮放文字
使用 @remotion/layout-utils 的 fitText() 自動將文字縮放至符合影片寬度:
import { fitText } from '@remotion/layout-utils';
const { fontSize } = fitText({
text: page.tokens.map((t) => t.text).join(''),
withinWidth: 1080 * 0.8, // 影片寬度的 80%
fontFamily: 'Arial',
fontWeight: 'bold',
});加入進入/退出動畫
搭配 Remotion 的動畫工具為字幕加入過場效果:
import { spring, useCurrentFrame, useVideoConfig } from 'remotion';
const CaptionPageWithAnimation: React.FC<{ page: TikTokPage }> = ({ page }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// 進入動畫:從下方滑入
const slideUp = spring({
frame,
fps,
config: {
damping: 200,
},
from: 50,
to: 0,
});
const opacity = spring({
frame,
fps,
config: { damping: 200 },
from: 0,
to: 1,
});
return (
<AbsoluteFill
style={{
justifyContent: 'center',
alignItems: 'center',
transform: `translateY(${slideUp}px)`,
opacity,
}}
>
{/* 字幕內容 */}
</AbsoluteFill>
);
};CSS 文字描邊提升可讀性
在淺色或複雜背景上,加入文字描邊可大幅提升字幕可讀性:
<div
style={{
WebkitTextStroke: '4px black',
paintOrder: 'stroke',
color: 'white',
fontSize: 80,
fontWeight: 'bold',
}}
>
{text}
</div>參考資料
- 音訊轉錄為字幕 — 從音訊產生字幕
- 匯出字幕 — 將字幕匯出為 SRT/VTT 格式
- Caption 型別 — 字幕資料結構
createTikTokStyleCaptions()API 參考<Sequence>元件參考