Remotion LabRemotion Lab
媒體整合影片章節導航:用 Sequence 做進度條與章節字卡
sequencechapterstimelinenavigationintermediate

影片章節導航:用 Sequence 做進度條與章節字卡

做出 YouTube/Spotify 等長影片的殺手級功能:底部章節進度條 + 切換時跳出的全螢幕標題卡。任何長影片(教學課程、Podcast、Vlog、會議錄影、研討會)都能套用——核心是用 Remotion 的 Sequence 把時間軸切成多段、各自排程獨立的視覺。

成品預覽

12 秒的章節導航 demo:左上角是課程系列名稱、中央是「目前播放的章節」資訊(任何視覺內容都可以放這裡)、底部是按比例分配寬度的章節進度條、每次切換到新章節時畫面會跳出全螢幕字卡淡入淡出 2 秒。所有時間排程靠的是一個 Remotion 元件——<Sequence>


這篇教什麼

長影片有一個共同的問題:觀眾不知道現在在講什麼、什麼時候會講到他想聽的。YouTube 的解法是 video chapters、Spotify 是 Podcast chapters、Coursera 是 lesson navigation——本質上都是同一件事:

  1. 底部進度條:按時長比例分段,當前章節用顯眼顏色填充
  2. 章節字卡:切換時跳出全螢幕標題提醒觀眾「新段落開始」
  3. 章節列表:(本篇不做)側邊或目錄頁

這套機制適用於任何分段內容

  • 教學課程影片(章節 = 單元)
  • Podcast 錄音影片(章節 = 主題段落)
  • 旅遊 vlog(章節 = 不同景點)
  • 會議錄影(章節 = 議程項目)
  • 研討會 talk(章節 = slides 大段)
  • 健身影片(章節 = 不同訓練動作)

只要你的影片可以「分段」,這篇教的東西就能直接套上去。核心 API 是 Remotion 的 <Sequence>——這是 Remotion 整個時間軸系統的基石,學會它就學會了 Remotion 80% 的時間排程能力。


前置知識


Step 1:章節資料結構

先定義章節列表。單位用秒,不要直接寫 frame——秒是人類看得懂的單位、是你跟剪輯師/客戶溝通用的單位,frame 是給機器算的。

// src/data/chapters.ts
export type Chapter = {
  title: string;
  start: number;  // 開始秒數
  end: number;    // 結束秒數
};
 
// 範例:一支 12 秒的 React 課程介紹影片
export const CHAPTERS: Chapter[] = [
  {title: "課程介紹與目標",  start: 0,    end: 2.4},
  {title: "Hooks 基礎複習",  start: 2.4,  end: 5.4},
  {title: "進階 useReducer", start: 5.4,  end: 8.4},
  {title: "Custom Hooks 實戰", start: 8.4, end: 12},
];

幾個原則:

  • end 是「下一章節的 start」——不要留間隔,否則 findIndex 會偶爾「找不到當前章節」
  • 章節數通常 4~10 個——太少沒意義、太多時間軸會擠成一團
  • 第一個章節從 0 開始——不要從 30 秒開始,否則前 30 秒會「沒有當前章節」要 fallback

章節資料怎麼來? 三種方式:

  1. 手寫——你自己錄的影片,剪輯時就標好
  2. 從平台匯出——YouTube Studio、Apple Podcasts Connect、Spotify for Podcasters 都支援章節 marker
  3. AI 生成——把逐字稿丟給 GPT 叫它分段,效果出乎意料地好

Step 2:找出當前章節(秒 → index)

寫一個 hook 把當前 frame 轉成章節 index。這是後面所有元件共用的核心:

import {useCurrentFrame, useVideoConfig} from 'remotion';
 
export const useCurrentChapter = (chapters: Chapter[]) => {
  const frame = useCurrentFrame();
  const {fps} = useVideoConfig();
  const currentSec = frame / fps;
 
  const currentIdx = chapters.findIndex(
    (c) => currentSec >= c.start && currentSec < c.end,
  );
  // 影片結尾後 findIndex 會回傳 -1,fallback 到最後一章
  const safeIdx = currentIdx === -1 ? chapters.length - 1 : currentIdx;
 
  return {
    chapter: chapters[safeIdx],
    index: safeIdx,
    currentSec,
  };
};

兩個關鍵點:

  • frame / fps 把 frame 轉成秒——這是 Remotion 跟「人類時間」的橋樑,記住這個換算
  • findIndex 可能回傳 -1——這是 JavaScript 的歷史包袱(找不到時不是 undefined 而是 -1),永遠要 guard 一下,否則 chapters[-1] 會 crash

這個 hook 之後 ChapterTimeline 跟「目前播放章節」顯示都會用。把它做成接 chapters 參數的版本,就可以多個影片重用同一個 hook。


Step 3:底部章節進度條

const ChapterTimeline: React.FC<{chapters: Chapter[]}> = ({chapters}) => {
  const {currentSec, index} = useCurrentChapter(chapters);
  const totalSec = chapters[chapters.length - 1].end;
 
  return (
    <div style={{position: "absolute", bottom: 80, left: 80, right: 80}}>
      {/* 標題顯示在時間軸上方 */}
      <div style={{
        fontSize: 38,
        fontWeight: 700,
        color: "#fff",
        marginBottom: 16,
        fontFamily: "'Noto Sans TC', sans-serif",
      }}>
        Chapter {index + 1}: {chapters[index].title}
      </div>
 
      {/* 時間軸本體:N 個 segment */}
      <div style={{display: "flex", gap: 4, height: 14, borderRadius: 8, overflow: "hidden"}}>
        {chapters.map((chapter, i) => {
          const widthPct = ((chapter.end - chapter.start) / totalSec) * 100;
          const isActive = i === index;
          const isPast = i < index;
          let fillPct = 0;
          if (isActive) {
            fillPct = ((currentSec - chapter.start) / (chapter.end - chapter.start)) * 100;
          } else if (isPast) {
            fillPct = 100;
          }
          return (
            <div key={i} style={{
              width: `${widthPct}%`,
              backgroundColor: "rgba(255,255,255,0.2)",
              position: "relative",
              borderRadius: 4,
              overflow: "hidden",
            }}>
              <div style={{
                position: "absolute",
                inset: 0,
                width: `${fillPct}%`,
                backgroundColor: isActive ? "#facc15" : "#60a5fa",
              }} />
            </div>
          );
        })}
      </div>
    </div>
  );
};

幾個值得記下來的細節:

widthPct 用章節時長分配寬度——不是「每個 segment 等寬」。3 分鐘的章節就應該占時間軸的 3/35(如果總長 35 分鐘),而不是 1/N。等寬設計會讓觀眾誤判章節長度——一個 30 秒的章節跟一個 10 分鐘的章節在進度條上應該明顯不同寬度。

三種狀態三種顏色

  • 過去的章節isPast):藍色 100% 填滿——告訴觀眾「這段你已經聽過了」
  • 當前的章節isActive):黃色填到 currentPct——當前播放位置的視覺焦點
  • 未來的章節:背景色,沒填——「還沒到」的暗示

fillPct(currentSec - chapter.start) / (chapter.end - chapter.start)——當前秒數在這個章節區間的比例。例如章節 [180, 540]、現在 360 秒:(360-180)/(540-180) = 0.5 = 50%。

<div> 巢狀做填色——外層 div 是「軌道」(背景色),內層 div 是「填色」用 position: absolute + inset: 0 + width% 控制。比 SVG <rect> 簡單、也比 linear-gradient 好控制。


Step 4:章節切換的全螢幕字卡

每次切到新章節時,畫面中央彈出 60 frame(2 秒)的字卡:

import {AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig} from 'remotion';
 
const ChapterChangeOverlay: React.FC<{
  chapterIdx: number;
  title: string;
}> = ({chapterIdx, title}) => {
  const frame = useCurrentFrame();  // 注意:這是相對於 Sequence 的 local frame
  const {fps} = useVideoConfig();
 
  // 進場 → 停留 → 退場
  const opacity = interpolate(frame, [0, 10, 45, 60], [0, 1, 1, 0], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });
 
  // 進場時 scale 0.8 → 1,spring 彈跳
  const enterSpring = spring({
    frame,
    fps,
    config: {damping: 14, stiffness: 110},
    durationInFrames: 20,
  });
  const scale = interpolate(enterSpring, [0, 1], [0.8, 1]);
 
  return (
    <AbsoluteFill style={{
      backgroundColor: `rgba(0,0,0,${opacity * 0.4})`,
      justifyContent: "center",
      alignItems: "center",
      pointerEvents: "none",
    }}>
      <div style={{textAlign: "center", opacity, transform: `scale(${scale})`}}>
        <div style={{
          fontSize: 40,
          color: "#facc15",
          fontFamily: "Inter, sans-serif",
          letterSpacing: "0.15em",
          marginBottom: 16,
        }}>
          CHAPTER {chapterIdx + 1}
        </div>
        <div style={{
          fontSize: 120,
          fontWeight: 900,
          color: "#fff",
          letterSpacing: "-0.02em",
          textShadow: "0 8px 40px rgba(0,0,0,0.8)",
        }}>
          {title}
        </div>
      </div>
    </AbsoluteFill>
  );
};

關鍵概念:useCurrentFrame()<Sequence> 內部會回傳 local frame(相對於 Sequence 的開始)而不是全域 frame。所以這個 component 不需要知道「自己是第幾章節、什麼時候開始」——它只知道「我現在從 0 跑到 60」,opacity / scale 都用這個 local frame 算。

interpolate(frame, [0, 10, 45, 60], [0, 1, 1, 0]) 是經典的「淡入 → 停留 → 淡出」四段:

  • frame 0~10:opacity 0→1(淡入)
  • frame 10~45:opacity = 1(停留)
  • frame 45~60:opacity 1→0(淡出)

比寫三個 if 簡單一百倍。這個 4-keypoint pattern 在 Remotion 裡會用到無數次,記住它。


Step 5:用 Sequence 排程所有字卡

最巧妙的一步——用 Remotion 的 <Sequence> 把每個 overlay「掛在」對應的 frame 區段

const FPS = 30;
 
export const VideoWithChapters: React.FC = () => {
  return (
    <AbsoluteFill style={{backgroundColor: "#020617"}}>
      {/* 你的主要影片內容(在這裡放任何東西:<Video>、<Img>、自己畫的畫面…) */}
      <YourMainContent />
 
      {/* 持久顯示的章節進度條 */}
      <ChapterTimeline chapters={CHAPTERS} />
 
      {/* 每個章節(除了第一個)都加一張開場字卡 */}
      {CHAPTERS.map((chapter, i) => {
        if (i === 0) return null;  // 第一章不需要切換動畫
        const startFrame = Math.round(chapter.start * FPS) - 20;  // 提前 20 frame 進場
        return (
          <Sequence key={i} from={startFrame} durationInFrames={60}>
            <ChapterChangeOverlay chapterIdx={i} title={chapter.title} />
          </Sequence>
        );
      })}
    </AbsoluteFill>
  );
};

幾個值得記下來的細節:

<Sequence from={startFrame} durationInFrames={60}> — 這個元件把它的 children 只在 frame [startFrame, startFrame+60) 之間 mount。其他時間 children 完全不存在,所以不會佔效能、不會吃 memory。

Math.round(chapter.start * FPS) - 20 — 為什麼提前 20 frame?因為「章節切換」的視覺反饋應該比實際切換早一點——觀眾看到字卡淡入的瞬間就應該心理準備「啊新章節要開始了」,而不是字卡跳出來時內容已經切過去 0.7 秒了。提前 20 frame ≈ 0.67 秒是經驗值。

if (i === 0) return null — 第一章節是「影片開頭」,本來就會看到所有 UI 進場,不需要再有切換動畫。多這張卡反而冗。

為什麼不用一個大的 component 內部判斷而是用 Sequence? 因為 <Sequence> 是 Remotion 的時間排程機制——它告訴 render engine「這段內容只在這個區間需要計算」。如果你寫成大的 <ChapterOverlay> 內部判斷 frame 然後 return null,render 還是會走進 component、跑 hook、計算 props 然後丟掉。<Sequence> 是真正的「跳過」,效能差非常多——尤其是當你的章節 overlay 內部還跑 useAudioData 或重計算的時候。


Step 6:套到任何影片上

到這裡為止你已經有了三個元件:useCurrentChapterChapterTimelineChapterChangeOverlay + <Sequence> 排程邏輯。把它們套到任何影片上只要三步:

  1. 準備章節資料(Step 1)——可以從 props 傳入,或用 calculateMetadata 在 render 前 fetch 進來
  2. 在 Composition 裡放主要內容——<Video><Img>、自己畫的視覺,什麼都可以
  3. 疊上 <ChapterTimeline> + 用 <Sequence> 排程 <ChapterChangeOverlay>

範例:套在一支 Podcast 影片上(搭配上一篇 T8 做出來的音波):

export const PodcastWithChapters: React.FC = () => (
  <AbsoluteFill>
    <BlurredCover />
    <Waveform />  {/* 上一篇的元件 */}
    <EpisodeInfo />
    <Audio src={staticFile("podcast/ep12.mp3")} />
 
    <ChapterTimeline chapters={EP12_CHAPTERS} />
    {EP12_CHAPTERS.map((c, i) => i > 0 && (
      <Sequence key={i} from={Math.round(c.start * 30) - 20} durationInFrames={60}>
        <ChapterChangeOverlay chapterIdx={i} title={c.title} />
      </Sequence>
    ))}
  </AbsoluteFill>
);

範例:套在一支教學影片上(用 <OffthreadVideo> 載入螢幕錄影):

export const TutorialWithChapters: React.FC = () => (
  <AbsoluteFill>
    <OffthreadVideo src={staticFile("recordings/react-advanced.mp4")} />
 
    <ChapterTimeline chapters={LESSON_CHAPTERS} />
    {LESSON_CHAPTERS.map((c, i) => i > 0 && (
      <Sequence key={i} from={Math.round(c.start * 30) - 20} durationInFrames={60}>
        <ChapterChangeOverlay chapterIdx={i} title={c.title} />
      </Sequence>
    ))}
  </AbsoluteFill>
);

完全一樣的邏輯,只是內容元件不同。這就是「通用元件」的威力——chapter navigation 的程式碼不該知道內容是 Podcast、是課程、還是 vlog


常見問題

Q:字卡的 opacity 對了,但 Sequence 之外為什麼還會看到字卡?

不會——<Sequence> 控制 mount/unmount,frame 不在區間內 children 根本不存在。如果你看到「殘影」是 video player 的快取在騙你(拖動 timeline 時 frame 跳但畫面還沒更新)。重新 render 確認。

Q:章節切換字卡太多時,效能會不會崩?

不會。<Sequence> 是宣告式的——你寫 100 個 Sequence 但同一時間只有 1~2 個會 mount,render engine 只計算「當前 frame 需要的 children」。Remotion 內部用一棵 timeline tree 做 visibility check,很快。

Q:可以讓字卡有不同的設計(比如奇數章節用紅色、偶數用藍色)?

當然可以。在 ChapterChangeOverlay 加 props:

<ChapterChangeOverlay
  chapterIdx={i}
  title={chapter.title}
  color={i % 2 === 0 ? "#facc15" : "#f87171"}
/>

或者每個章節資料自己帶顏色:

{title: "...", start: 180, end: 540, color: "#facc15"}

把樣式跟資料綁在一起,視覺風格的責任就在資料層、component 變成純展示。

Q:時間軸可以做點擊跳轉嗎?

在渲染出來的 MP4 不行(MP4 沒有互動)。但如果你用 Remotion Player 嵌在網頁上可以——Player 有 seekTo() API,配合自訂 controls 就能做點擊跳章節。看 /docs/player 文件。

Q:章節時間軸的 segment 之間有縫隙看起來不連續?

縫隙是 gap: 4 造成的。把 gap 改成 0、segment 之間用 borderLeft 區隔反而更乾淨:

<div key={i} style={{
  width: `${widthPct}%`,
  borderLeft: i > 0 ? "2px solid rgba(255,255,255,0.3)" : "none",
  // ...
}}>

Q:可以讓「目前章節」的標題在切換時做漂亮的轉場(而不是直接換字)?

可以。把 <ChapterTimeline> 裡的標題改成讀「前一個 frame 的章節 index」做插值——但這很複雜,因為 useCurrentChapter() 是 stateless 的。簡單做法:用兩個 <div> 疊在一起,一個 fade out 舊標題、一個 fade in 新標題,opacity 用 Math.abs(currentSec - chapter.start) 算。但這已經是另一篇教學的尺度了。

Q:如果章節資料來自外部 API(YouTube chapters / Spotify markers),怎麼整合?

calculateMetadata 在 render 前 fetch 進來:

<Composition
  id="VideoWithChapters"
  component={VideoWithChapters}
  fps={30}
  width={1920}
  height={1080}
  durationInFrames={1}
  calculateMetadata={async () => {
    const res = await fetch("https://api.example.com/chapters/ep12");
    const chapters = await res.json();
    const totalDuration = chapters[chapters.length - 1].end;
    return {
      durationInFrames: Math.floor(totalDuration * 30),
      props: {chapters},
    };
  }}
/>

calculateMetadata 可以同時設定 durationInFramesprops,章節就會以 props 形式傳給 component。詳細看 /docs/dynamic-metadata

Q:章節太多塞不下底部進度條怎麼辦?

兩種解法:

  1. 用兩排——flexWrap: "wrap",超過 8 個自動換到第二行
  2. 隱藏遠期章節——只顯示「過去 2 個 + 當前 + 未來 3 個」共 6 個 segment,到了新章節時把視窗滑動

對於 20 分鐘以內、章節數 10 個以下的影片,一排放得下。超過這個尺度通常表示「該重新設計章節分段」——觀眾看到 30 個 segment 也只會眼花。


本篇涵蓋的官方文件


下一步

T12:短影音自動上字幕 — 章節導航是「長影片」的解藥,下一篇進到「短影片」的世界:怎麼把長影片切片、自動轉錄、套上 TikTok 風格逐字字幕。

或者直接跳到 T13:影片剪輯——裁切、拼接、配樂 學完整的影片剪輯工作流。