影片章節導航:用 Sequence 做進度條與章節字卡
做出 YouTube/Spotify 等長影片的殺手級功能:底部章節進度條 + 切換時跳出的全螢幕標題卡。任何長影片(教學課程、Podcast、Vlog、會議錄影、研討會)都能套用——核心是用 Remotion 的 Sequence 把時間軸切成多段、各自排程獨立的視覺。
成品預覽
12 秒的章節導航 demo:左上角是課程系列名稱、中央是「目前播放的章節」資訊(任何視覺內容都可以放這裡)、底部是按比例分配寬度的章節進度條、每次切換到新章節時畫面會跳出全螢幕字卡淡入淡出 2 秒。所有時間排程靠的是一個 Remotion 元件——<Sequence>。
這篇教什麼
長影片有一個共同的問題:觀眾不知道現在在講什麼、什麼時候會講到他想聽的。YouTube 的解法是 video chapters、Spotify 是 Podcast chapters、Coursera 是 lesson navigation——本質上都是同一件事:
- 底部進度條:按時長比例分段,當前章節用顯眼顏色填充
- 章節字卡:切換時跳出全螢幕標題提醒觀眾「新段落開始」
- 章節列表:(本篇不做)側邊或目錄頁
這套機制適用於任何分段內容:
- 教學課程影片(章節 = 單元)
- Podcast 錄音影片(章節 = 主題段落)
- 旅遊 vlog(章節 = 不同景點)
- 會議錄影(章節 = 議程項目)
- 研討會 talk(章節 = slides 大段)
- 健身影片(章節 = 不同訓練動作)
只要你的影片可以「分段」,這篇教的東西就能直接套上去。核心 API 是 Remotion 的 <Sequence>——這是 Remotion 整個時間軸系統的基石,學會它就學會了 Remotion 80% 的時間排程能力。
前置知識
- T3:YouTube 片頭動畫 — 熟悉
<Sequence>與interpolate的基本概念 - T8:Podcast 音波視覺化 —(選讀)如果你的場景是 Podcast,可以先做這篇做出音波視覺,再用本篇加上章節導航
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
章節資料怎麼來? 三種方式:
- 手寫——你自己錄的影片,剪輯時就標好
- 從平台匯出——YouTube Studio、Apple Podcasts Connect、Spotify for Podcasters 都支援章節 marker
- 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:套到任何影片上
到這裡為止你已經有了三個元件:useCurrentChapter、ChapterTimeline、ChapterChangeOverlay + <Sequence> 排程邏輯。把它們套到任何影片上只要三步:
- 準備章節資料(Step 1)——可以從 props 傳入,或用
calculateMetadata在 render 前 fetch 進來 - 在 Composition 裡放主要內容——
<Video>、<Img>、自己畫的視覺,什麼都可以 - 疊上
<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 可以同時設定 durationInFrames 跟 props,章節就會以 props 形式傳給 component。詳細看 /docs/dynamic-metadata。
Q:章節太多塞不下底部進度條怎麼辦?
兩種解法:
- 用兩排——
flexWrap: "wrap",超過 8 個自動換到第二行 - 隱藏遠期章節——只顯示「過去 2 個 + 當前 + 未來 3 個」共 6 個 segment,到了新章節時把視窗滑動
對於 20 分鐘以內、章節數 10 個以下的影片,一排放得下。超過這個尺度通常表示「該重新設計章節分段」——觀眾看到 30 個 segment 也只會眼花。
本篇涵蓋的官方文件
- /docs/sequence —
<Sequence>元件與時間排程(核心) - /docs/animation-math —
interpolate與 spring - /docs/dynamic-metadata —
calculateMetadata動態傳入章節資料 - /docs/player — 嵌在網頁上做點擊跳轉
下一步
T12:短影音自動上字幕 — 章節導航是「長影片」的解藥,下一篇進到「短影片」的世界:怎麼把長影片切片、自動轉錄、套上 TikTok 風格逐字字幕。
或者直接跳到 T13:影片剪輯——裁切、拼接、配樂 學完整的影片剪輯工作流。