Remotion LabRemotion Lab
動畫庫整合GSAP × Remotion:用 Seek Bridge 模式把 GSAP Timeline 搬進影片
gsapintegrationtimelineadvanced

GSAP × Remotion:用 Seek Bridge 模式把 GSAP Timeline 搬進影片

設計師常用的 GSAP 動畫函式庫 + Remotion = 強大的組合。用 tl.pause() 建立一個 paused timeline,在 useCurrentFrame 驅動下 tl.seek(frame/fps),GSAP 的所有 ease、stagger、timeline 語法都能直接用在影片裡。

成品預覽

15 秒展示 GSAP seek bridge:5 個方塊經歷 5 個 phase(stagger entry → sine wave → scatter & reform → pulse → converge to「GSAP」)。底部 progress bar 即時顯示 tl.progress(),左上角 timecode 顯示 frame / fps,方便驗證 GSAP timeline 與 Remotion frame clock 是否完美同步。

整段影片裡 沒有任何一個 interpolate() 呼叫——所有的位移、ease、stagger、顏色循環,全部都是 GSAP timeline 在做事;Remotion 只負責提供 frame 數字。

為什麼要 GSAP × Remotion?

Remotion 自己的 interpolate() 已經很強,但它的設計哲學是「一段動畫 = 一段程式碼」。當你想做一個 5 秒、5 個 phase、20 個物件交錯出場的 choreography,會發現:

  • 每個物件的進場時間要自己手算 frame
  • stagger 要自己寫 for loop + delay
  • elastic / back / bounce 這類 ease 要自己定義
  • 「在某段動畫之後 0.5 秒接下一段」要自己維護 offset 變數

而 GSAP 的 timeline 就是為了解決這些問題而生的:

  • stagger: 0.1 一行搞定多物件延遲
  • 30+ 內建 ease,字串就能呼叫
  • 絕對/相對時間定位("+=0.5""<"、label)
  • nested timeline,可以把一段動畫包成函式重複用

更重要的是:設計師早就會用 GSAP 了。如果你的團隊有平面/網頁動效設計師,他們不需要重學 Remotion 的 API,只要把熟悉的 GSAP 程式碼丟進 Remotion 的 component 就能渲染成影片。

唯一要解決的問題就是:GSAP 預設用 wall-clock 跑動畫,而 Remotion 是 deterministic renderer——同一個 frame 不管渲染幾次都必須產生同一張畫面。這兩件事看似衝突,但其實 GSAP 早就提供了解法,也就是這篇要講的 seek bridge 模式

核心原理:Seek Bridge 模式

關鍵洞察:GSAP 的 timeline 有兩種「推進時間」的方式:

  1. autoplay:自己跑 requestAnimationFrame,時間來源是瀏覽器的 wall clock
  2. seek(time):外部告訴它「現在時間是 X 秒」,它立刻跳到那一幀的狀態

Remotion 是 deterministic renderer,同一個 frame 必須永遠產生同一張畫面。autoplay 模式完全不行——render 時 wall clock 不會跟 frame 對齊,且 Remotion 是並行渲染多個 frame,每個 frame 都會掛載一份 component,autoplay 完全沒機會跑起來。

seek(time) 完美契合:

tl.seek(frame / fps, false);
//      ^^^^^^^^^^^  把 Remotion 的幀數換算成秒,餵給 GSAP
//                   ^^^^^ muteCallbacks

第二個參數 falsemuteCallbacks——意思是「不要 mute callback」。在 render 時其實建議傳 true,因為同一個 frame 可能會被反覆 seek 多次(preview、render、re-render),onUpdate / onComplete 跑 N 次會讓人困惑。但本範例沒用到 callback,傳什麼都一樣。

這個模式之所以叫 bridge,是因為 GSAP 的時間軸與 Remotion 的 frame clock 原本是兩個獨立系統,而 frame / fps 這條算式就是橋——一邊輸入 frame,另一邊輸出 GSAP 的 internal time。

這篇會用到

  • gsap v3.14.2(或任何 GSAP 3 版本)
  • Remotion 的 useCurrentFrame + useVideoConfig
  • React useMemo 確保 timeline 只建一次

不需要 GSAP 的任何 plugin(ScrollTrigger / Draggable / MotionPath 等等都不能用,後面會解釋)。

Step 1:安裝套件

npm install gsap

GSAP 3 是 MIT License 免費的,商用也沒問題。Plugin(ScrollTrigger 例外,那也是 free)才需要 Club GreenSock。

Step 2:建立 paused timeline

import {gsap} from 'gsap';
import {useMemo} from 'react';
 
const tl = useMemo(() => {
  const t = gsap.timeline({
    paused: true,
    defaults: {ease: 'power2.out'},
  });
  // 後面 step 會逐步加動畫
  return t;
}, []);

關鍵字:paused: true。沒加這個,GSAP 會立刻用 autoplay 跑,Remotion 渲染時就會出現不同步。

defaults 是 GSAP 的小技巧——你在這裡定義的屬性會自動套用到所有後續加進來的 tween,除非那段 tween 自己覆寫。一次設定 ease,整條 timeline 都吃。

useMemo 的 dependency 是空陣列 [],意味著「只在掛載時建立一次」。為什麼這樣可以、為什麼必須這樣,最後一節會詳細解釋。

Step 3:動畫「純物件」而不是 DOM

GSAP 的動畫目標可以是任何有數字屬性的 JavaScript 物件——不限 DOM element。這點對 Remotion 來說是天大的好消息,因為 Remotion 是 React-first 的,我們不會去碰 document.querySelector

所以策略是:把所有要動畫的數值放在一個 plain object 裡,讓 GSAP mutate 它,然後 React 在 JSX 裡讀這個物件畫出來。

type BoxState = {
  x: number;
  y: number;
  scale: number;
  rotation: number;
  opacity: number;
  hue: number;
  saturation: number;
  lightness: number;
  borderRadius: number;
};
 
type AnimState = {
  boxes: BoxState[];
  title: {y: number; opacity: number; letterSpacing: number};
  subtitle: {y: number; opacity: number};
  finalLabel: {opacity: number; scale: number};
  bgGlow: {opacity: number; hue: number};
  phaseLabel: {opacity: number; y: number; text: string};
};
 
const state = useMemo<AnimState>(
  () => ({
    boxes: Array.from({length: 5}, (_, i) => ({
      x: initialBoxX(i),
      y: 1280, // 從畫面底下開始
      scale: 1,
      rotation: 0,
      opacity: 0,
      hue: 200 + i * 30,
      saturation: 75,
      lightness: 58,
      borderRadius: 22,
    })),
    title: {y: -80, opacity: 0, letterSpacing: 0},
    subtitle: {y: 30, opacity: 0},
    finalLabel: {opacity: 0, scale: 0.7},
    bgGlow: {opacity: 0, hue: 210},
    phaseLabel: {opacity: 0, y: 16, text: 'Phase 1 / Stagger Entry'},
  }),
  [],
);

注意 boxes 的初始 y: 1280——這就是動畫的起點,待會 GSAP 會把它從畫面下方拉到中間。opacity: 0 也是同樣道理。GSAP 的 tl.to(target, {y: 500}) 是「從目前狀態 tween 到 500」,所以這個 plain object 的初始值就決定了動畫的起始畫面。

Step 4:加動畫到 timeline

現在開始把 timeline 的內容填進去。我們把 5 個 phase 全部串起來,每個 phase 約 3 秒:

const buildTimeline = (state: AnimState) => {
  const tl = gsap.timeline({
    paused: true,
    defaults: {ease: 'power2.out'},
  });
 
  // ===== Phase 1 (0 - 3s): Stagger entry =====
  tl.to(state.title, {y: 0, opacity: 1, letterSpacing: 6, duration: 0.7}, 0);
  tl.to(state.subtitle, {y: 0, opacity: 1, duration: 0.6}, 0.35);
  tl.to(state.bgGlow, {opacity: 0.35, duration: 1.2}, 0);
  tl.to(
    state.boxes,
    {
      opacity: 1,
      y: midY - BOX_SIZE / 2,
      duration: 0.65,
      stagger: 0.1,
      ease: 'back.out(1.6)',
    },
    0.5,
  );

重點解讀:

  • 第三個參數00.350.5)是「絕對時間位置」,單位是秒。GSAP 預設行為是「接在上一段後面」,但我們想要多段並行 + 精確控制時,傳絕對位置最直觀
  • stagger: 0.1state.boxes 這個陣列當成 5 個獨立 target,每個延遲 0.1 秒啟動。如果用 Remotion 的 interpolate() 寫,至少要一個 for loop + 5 個 interpolate 呼叫
  • ease: 'back.out(1.6)' 字串 ease 是 GSAP 的招牌語法。back 表示衝過頭再回彈,1.6 是回彈強度

接著 Phase 2 用正弦波讓方塊上下擺動:

  // ===== Phase 2 (3 - 6s): Sine wave =====
  tl.to(
    state.boxes,
    {
      y: (i: number) => midY - BOX_SIZE / 2 + Math.sin(i * 0.9) * 110,
      x: (i: number) => initialBoxX(i),
      hue: (i: number) => 180 + i * 25,
      saturation: 80,
      rotation: (i: number) => (i % 2 === 0 ? 8 : -8),
      duration: 1.2,
      ease: 'sine.inOut',
      stagger: 0.05,
    },
    3.0,
  );

注意 y: (i) => ... 這個寫法——GSAP 的 function-based values,當 target 是陣列時,每個元素會收到自己的 index,函式回傳的值就是該元素的目標。可以根據 index 算出不一樣的位置、顏色、旋轉,超實用。

Phase 3、4、5 長相類似,篇幅關係只列出最具代表性的 Phase 5(收斂成「GSAP」字樣):

  // ===== Phase 5 (12 - 15s): Convergence =====
  tl.to(
    state.boxes,
    {
      x: (i: number) => finalLetterX(i),
      y: (i: number) => finalLetterY(i),
      scale: (i: number) => (i === 4 ? 0.4 : 1.05),
      rotation: 0,
      borderRadius: (i: number) => (i === 4 ? 60 : 20),
      duration: 1.2,
      ease: 'power3.inOut',
      stagger: {each: 0.06, from: 'start'},
    },
    12.0,
  );
 
  // Hard pin the timeline length to exactly 15s.
  tl.set({}, {}, 15);
 
  return tl;
};

最後一行 tl.set({}, {}, 15) 是個小技巧:往空物件設空屬性,但時間位置是 15 秒。用途是「強制把 timeline 的總長度釘在 15 秒」,避免最後一段動畫提早結束導致 progress 永遠到不了 1。

Step 5:Seek Bridge — 最關鍵的一行

現在 timeline 建好了,但它還是 paused 的。要讓它跟著 Remotion 的 frame 走,只需要兩行:

const frame = useCurrentFrame();
const {fps} = useVideoConfig();
 
tl.pause();              // 保險再 pause 一次
tl.seek(frame / fps, false);
 
// 現在 state.boxes[0].x、state.title.opacity 等都是這一幀的值

為什麼這樣能行?拆解一下執行順序:

  1. Remotion 每一幀都會重新呼叫整個 component function(這是 React 的標準行為)
  2. useMemo 保證 statetl 只建立一次(dependency 是 []),不會被重建
  3. tl.seek(t)同步呼叫——它會立刻 mutate state 的所有屬性到 t 秒對應的狀態
  4. 緊接著 JSX 開始 render,讀 state.boxes[0].x 拿到的就是正確的這一幀數值
  5. 同一個 frame 永遠產生同一結果(因為 frame / fps 是純函式輸入)→ Remotion 可以並行渲染、可以隨意跳轉預覽

整個 bridge 的精妙在於:GSAP 完全不知道自己在 Remotion 裡跑。它以為自己只是被一段陌生程式碼呼叫了 seek(),按照 API 定義回傳結果——而我們利用這個「seek 是同步、結果可預測」的特性,把它變成 Remotion 的計算引擎。

Step 6:在 JSX 裡讀 state 畫出來

剩下的就是普通的 React 渲染。所有的數字都從剛才被 GSAP mutate 過的 state 物件讀出來:

return (
  <AbsoluteFill style={{backgroundColor: '#0a0f1c'}}>
    {/* Title */}
    <div
      style={{
        position: 'absolute',
        top: 110,
        left: 0,
        right: 0,
        textAlign: 'center',
        fontSize: 110,
        fontWeight: 900,
        letterSpacing: state.title.letterSpacing,
        transform: `translateY(${state.title.y}px)`,
        opacity: state.title.opacity,
      }}
    >
      GSAP Timeline
    </div>
 
    {/* Animated boxes */}
    {state.boxes.map((box, i) => (
      <div
        key={i}
        style={{
          position: 'absolute',
          left: box.x,
          top: box.y,
          width: 140,
          height: 140,
          borderRadius: box.borderRadius,
          background: `hsl(${box.hue}, ${box.saturation}%, ${box.lightness}%)`,
          transform: `scale(${box.scale}) rotate(${box.rotation}deg)`,
          opacity: box.opacity,
        }}
      />
    ))}
  </AbsoluteFill>
);

整段沒有任何 interpolate()、沒有任何 useEffect、沒有任何 ref。每一幀的視覺都是「state 被 seek mutate → JSX 讀 state」這個單向流程的結果。

Step 7:用 tl.progress() 做 progress bar

GSAP 的 tl.progress() 回傳當下的進度(0 到 1),這是免費送的視覺驗證工具:

const progress = tl.progress();   // 0 ~ 1
const timecode = (frame / fps).toFixed(2);
 
<div
  style={{
    position: 'absolute',
    bottom: 90,
    left: 140,
    right: 140,
    height: 8,
    backgroundColor: 'rgba(148,163,184,0.2)',
    borderRadius: 4,
    overflow: 'hidden',
  }}
>
  <div
    style={{
      width: `${progress * 100}%`,
      height: '100%',
      background: 'linear-gradient(90deg, #60a5fa 0%, #a855f7 50%, #ec4899 100%)',
    }}
  />
</div>
 
<div style={{position: 'absolute', bottom: 110, right: 140, fontSize: 20}}>
  tl.progress() = {progress.toFixed(3)} · t = {timecode}s
</div>

這個 bar 不只是給觀眾看的——它同時是 debug 工具。如果 seek 沒生效(譬如忘了 paused: true、或 useMemo deps 寫錯),progress 會永遠停在 0 或亂跳。一眼就能看出來。

Step 8:渲染

跟一般 Remotion composition 一樣:

npx remotion render TutorialGsapDemo out/t19.mp4 --codec=h264

GSAP 在 Node.js 環境(Remotion render worker)也能跑,因為它本來就支援動畫純物件,不依賴 DOM。

原理解析:為什麼 useMemo 的 dependency 是空陣列?

這是整個模式裡最容易踩坑的地方。三種寫法的差異:

// (A) 錯:每幀都重建
const tl = useMemo(() => buildTimeline(state), [frame]);
 
// (B) 錯:每次 state 變就重建(state 其實也應該是 useMemo([]))
const tl = useMemo(() => buildTimeline(state), [state]);
 
// (C) 對:只建立一次
const tl = useMemo(() => buildTimeline(state), []);

寫法 (A) 每幀都重建 timeline,會發生三件慘事:

  1. 超慢:建立 timeline 是 O(動畫段數),每幀都跑一次直接拖垮 render
  2. state 被重設:因為 state 也會跟著重建,你會看到 box 永遠在初始位置
  3. seek 沒意義:剛建好的 timeline seek 到 X 秒,下一幀又是全新的 timeline 再 seek 到 Y 秒,沒有任何「保留狀態」的概念

寫法 (C) 才對:state 和 tl 都只建立一次。關鍵洞察:state 物件本身永遠是同一個 reference,GSAP 是在 mutate 它的屬性而不是重新指派——React 完全不需要重建任何東西。

實際上 (B) 在這個範例也能跑(因為 state 用 useMemo([]) 也是只建一次),但保險起見直接寫 []

Remotion 的 Composition mount 之後不會 unmount(除非你切到別的 composition),所以 useMemo 的快取是穩定的,可以放心用。

原理解析:muteCallbacks = true vs false

tl.seek(time, muteCallbacks) 第二個參數是 boolean,控制 seek 過程中是否觸發 callback:

  • true(mute):不觸發 onUpdate / onComplete / onStart 等任何 callback
  • false(不 mute):正常觸發

Remotion 的 render 行為意味著「同一個 frame 可能被 seek 很多次」(preview 拖動、render 並行、re-render hot reload),如果你的 timeline 有 callback:

tl.to(state.box, {
  x: 500,
  onComplete: () => console.log('done!'),  // 會被呼叫 N 次
});

那 console 會被刷爆,更慘的是如果 callback 有 side effect(呼叫 API、改 React state),會出現難以追蹤的 bug。

正式專案建議傳 true。本教學範例沒有用任何 callback,所以傳什麼都一樣,但養成習慣比較安全。

原理解析:GSAP vs 純 Remotion interpolate

什麼時候該用 GSAP,什麼時候直接用 Remotion 內建?

功能Remotion interpolateGSAP Timeline
單段簡單動畫更簡潔殺雞用牛刀
Stagger(多物件延遲)要手寫 loop + delaystagger: 0.1 一行
複雜 ease (elastic / back)要自己寫或用 @remotion/spring內建 30+ ease
相對時間定位 ("+=0.5")要自己算 frame字串語法
Nested timelines沒有原生概念支援
Function-based values沒有支援
檔案大小0(內建)+70KB
TypeScript 型別完整不錯但有些 ease 字串沒檢查

建議

  • 場景簡單、動畫單一 → 用 interpolate(),不要為了 70KB 引入 GSAP
  • 多 phase 編排、需要 stagger / 複雜 ease → 用 GSAP timeline
  • 已經有設計師寫好的 GSAP 程式碼 → 直接搬,省下重寫時間

兩者也可以混用——一個 component 裡面,背景的簡單淡入用 interpolate(),前景的複雜 choreography 用 GSAP,完全沒問題。

常見問題

Q:autoplay: falsepaused: true 有差嗎?

有差,但建議兩個都加。autoplay: false 是 GSAP 3 才加的選項,paused: true 是更老更通用的寫法。組合起來最保險:

gsap.timeline({paused: true, autoplay: false});

Q:seek 後 opacity 沒生效?

通常是把整個 state 物件傳給 tl.to 而不是子物件。GSAP 會試圖去動畫 state.boxesstate.title 這些 key(值是物件,不是數字),結果什麼都沒發生。正確寫法是分開傳:

// 錯
tl.to(state, {opacity: 1});
 
// 對
tl.to(state.title, {opacity: 1});
tl.to(state.boxes, {opacity: 1, stagger: 0.1});  // 陣列當 targets

Q:畫面跳來跳去?

檢查 timeline 的總長度是否超過 composition 的 durationInFrames / fps。超過會導致最後幾秒 progress > 1,GSAP 會把 state seek 到一個未定義的狀態(通常是最後一幀的延伸)。用 tl.set({}, {}, durationSeconds) 強制釘住長度。

Q:可以用 GSAP 的 ScrollTrigger / Draggable / MotionPath plugin 嗎?

ScrollTrigger 不行——它依賴 wall clock 和 DOM scroll event,Remotion render 環境兩個都沒有。Draggable 也不行(沒有 mouse event)。要在 Remotion 做 scroll-like 效果,直接用 frame 算 translateY 即可,反而更精確。

MotionPath plugin 理論上可以(它只是把 SVG path 轉成數值),但需要 DOM SVG element 解析路徑,在 server-side render 會出問題。安全做法是改用 gsap.utils.toArray + 純物件做路徑插值。

Q:GSAP timeline 太長想分割成多段?

可以用 gsap.timeline() 巢狀:

const phase1 = gsap.timeline();
phase1.to(state.title, {opacity: 1});
phase1.to(state.boxes, {y: 500, stagger: 0.1});
 
const main = gsap.timeline({paused: true});
main.add(phase1, 0);
main.add(phase2, 3);

子 timeline 不需要 paused: true,只要 main 是 paused 就會凍住所有 child。

本篇涵蓋的觀念

  • GSAP timeline 的 paused: true 模式
  • tl.seek(time, muteCallbacks) API 的語意
  • tl.progress() 讀當下進度,順便 debug
  • 用 plain JavaScript object 當 animation target
  • useMemo([]) 確保 timeline 與 state 只建立一次
  • Remotion useCurrentFrame × GSAP seek 的 bridge 模式
  • function-based values + stagger 做多物件 choreography
  • 為什麼 GSAP plugin(ScrollTrigger / Draggable)不能用

下一步

  • T20:Anime.js v4 × Remotion — 另一個輕量級動畫庫,看看 anime.js v4 全新的 timeline API 如何接上 Remotion
  • T21:Three.js + React Three Fiber 3D 場景 — 把整個 3D scene graph 也接上 frame clock,渲染出真正的 3D 影片