Remotion LabRemotion Lab
字幕在影片中顯示字幕

在影片中顯示字幕

學習如何在 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-utilsfitText() 自動將文字縮放至符合影片寬度:

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>

參考資料