Remotion LabRemotion Lab
動畫庫整合Anime.js v4 × Remotion:createTimeline + Seek Bridge
animejsintegrationtimelineadvanced

Anime.js v4 × Remotion:createTimeline + Seek Bridge

Anime.js v4 是 2025 年底的大改版,用 createTimeline 取代舊版 anime.timeline。整合 Remotion 的關鍵:autoplay: false + tl.seek(ms),記得 Anime.js 用毫秒不是秒。

如果你看過上一篇 T28:GSAP × Remotion,這篇基本上是同一個 seek bridge 模式換另一個動畫引擎。但 Anime.js v4 有兩個會讓人踩雷的細節:毫秒 vs 秒、createTimeline vs anime.timeline。先把這兩件事釘在腦袋裡,後面就很順。

成品預覽

12 秒 4 階段:

  1. 0–3s:標題滑入 + 6 個圓 stagger 放大(outBack
  2. 3–6s:圓變方 + snap 進 3×2 grid + 旋轉 360°(outElastic
  3. 6–9s:離開 grid,沿正弦波形飄移橫越畫面(curve path)
  4. 9–12s:spiral 匯聚成中央脈衝光球 + "Anime.js × Remotion" 字幕

整支影片底下有 progress bar,數值直接讀 tl.currentTime / tl.duration。沒有任何 interpolate() 在做動畫運算,全部交給 Anime.js。

Anime.js v4 的重大改變

v3 → v4 是破壞性改版。如果你 google 到 2024 以前的 Anime.js 教學,那些 API 八成都不能用了:

v3v4
anime.timeline(...)createTimeline(...)
timeline.add({targets, ...})tl.add(targets, {...}, position)
easing: 'easeOutElastic'ease: 'outElastic(1, .6)'
callback-basedPromise + callback
default export (import anime from)named exports (import { ... } from)

這篇全部用 v4 API,跟舊版完全不相容,請注意你 package.json 裡的版本。

這篇會用到

  • animejs (v4.3.6 或以上)
  • import { createTimeline } from 'animejs'
  • Remotion useCurrentFrame + useVideoConfig
  • React useMemo(讓 timeline 只 build 一次)

前置知識

Step 1:安裝套件

npm install animejs@^4
npm install --save-dev @types/animejs  # 如果有 TS type 問題

要特別指定 ^4,因為 npm 上 animejs 預設可能還會抓到 v3 的某些版本範圍。確認 node_modules/animejs/package.json 的版本是 4.x.x 才安全。

Step 2:建立 paused timeline

import { createTimeline } from 'animejs';
 
const tl = useMemo(() => {
  const t = createTimeline({
    autoplay: false,  // ← 關鍵:不自動播
    defaults: { duration: 800, ease: 'outExpo' },
  });
  return t;
}, []);

差異比較:

  • GSAPgsap.timeline({ paused: true })
  • Anime.js v4createTimeline({ autoplay: false })

兩個結果一樣:timeline 被建立後不會自己跑,必須手動 seek() 才會有動作。這正是我們要的——讓 Remotion 的 frame 來掌控時間軸。

defaults 裡放共用的 duration / ease,這樣每次 tl.add() 都不用重複寫。

Step 3:定義狀態物件

跟 GSAP 範例一樣,用 plain object 當 animation targets。Anime.js v4 會直接 mutate 這些物件的 properties:

type ShapeState = {
  x: number;
  y: number;
  r: number;      // 半徑(圓階段)
  size: number;   // 邊長(方形階段)
  rotate: number;
  opacity: number;
  morph: number;  // 0 = 圓,1 = 方(控制 borderRadius)
  glow: number;   // box-shadow 強度
  trail: number;  // 拖尾透明度
};
 
const PALETTE = [
  '#FF4B4B', '#FFAD5A', '#FFEE7F',
  '#67EEAC', '#6A9CFF', '#B185F5',
] as const;
 
const state = useMemo(() => ({
  shapes: PALETTE.map((_, i) => ({
    x: 360 + i * 240,
    y: 600,
    r: 0,
    size: 140,
    rotate: 0,
    opacity: 0,
    morph: 0,
    glow: 0,
    trail: 0,
  })),
  title: { y: -120, opacity: 0, blur: 16 },
  centerPulse: { scale: 0, opacity: 0, ringScale: 0, ringOpacity: 0 },
  background: { hue: 0, vignette: 0.3 },
}), []);

重點:state 是「資料」不是 React state。Anime.js 會在 seek() 內部直接改它的 numeric properties。我們的 JSX 只是在每個 frame 把這些數字讀出來畫成 div。

Step 4:用 tl.add() 加動畫

v4 的 add() 簽名:

tl.add(targets, params, position)

實際用起來:

// 標題滑入
tl.add(
  state.title,
  {
    y: 0,
    opacity: 1,
    blur: 0,
    duration: 700,
    ease: 'outExpo',
  },
  0,  // ← position 是毫秒
);
 
// 6 個 shape 逐一放大(stagger by hand)
state.shapes.forEach((shape, i) => {
  tl.add(
    shape,
    {
      r: 70,
      opacity: 1,
      glow: 1,
      duration: 700,
      ease: 'outBack',
    },
    700 + i * 110,  // 從 700ms 開始,每個間隔 110ms
  );
});

注意: position 參數是毫秒。GSAP 是秒,Anime.js 是毫秒。踩過這個雷的人舉手 🙋

第二階段也很單純,6 個 shape 各 snap 到 3×2 grid 上:

const GRID_ORIGIN_X = 660;
const GRID_ORIGIN_Y = 420;
const GRID_GAP = 220;
 
state.shapes.forEach((shape, i) => {
  const col = i % 3;
  const row = Math.floor(i / 3);
  tl.add(
    shape,
    {
      x: GRID_ORIGIN_X + col * GRID_GAP,
      y: GRID_ORIGIN_Y + row * GRID_GAP,
      size: 160,
      morph: 1,        // 0→1 觸發圓變方
      rotate: 360,
      duration: 1300,
      ease: 'outElastic(1, .6)',
    },
    3000 + i * 60,
  );
});

注意 morph 從 0 跑到 1——這個欄位等下會被 JSX 拿來算 borderRadius。動畫值是真的在 0~1 之間慢慢補間,視覺上就會看到圓角逐漸縮小變方。

Step 5:Seek Bridge — 毫秒版本

整個整合的核心其實只有兩行:

const frame = useCurrentFrame();
const { fps } = useVideoConfig();
 
// ⚠️ 關鍵:frame / fps 是「秒」,要 × 1000 變毫秒
const timeMs = (frame / fps) * 1000;
tl.seek(timeMs, true);  // 第二個 true = muteCallbacks,避免 onBegin 重複觸發

跟 GSAP 比較:

// GSAP(用秒)
tl.seek(frame / fps);
 
// Anime.js v4(用毫秒)
tl.seek((frame / fps) * 1000);

第二個參數 truemuteCallbacks——因為我們是「掃過時間軸」而不是「正常播放」,每個 frame 都會 seek 一次。如果不 mute,onBegin / onComplete 之類的 callback 會在你前後拖動時瘋狂亂噴。

Step 6:讀 tl.currentTime 和 tl.duration

v4 的 Timer class(Timeline 的父類)暴露兩個 runtime 屬性:

const currentMs = tl.currentTime;  // 當下時間(ms)
const totalMs = tl.duration;        // 總長度(ms)
const progress = currentMs / totalMs;  // 0 ~ 1

包一層 helper 防止除零:

const readProgress = (tl: ReturnType<typeof createTimeline>): number => {
  const total = tl.duration || 1;
  const current = tl.currentTime || 0;
  const ratio = current / total;
  if (!Number.isFinite(ratio)) return 0;
  return Math.max(0, Math.min(1, ratio));
};

然後就可以直接畫 progress bar:

<div
  style={{
    width: `${progress * 100}%`,
    height: 8,
    background:
      'linear-gradient(90deg, #FF4B4B, #FFAD5A, #FFEE7F, #67EEAC, #6A9CFF, #B185F5)',
  }}
/>

連帶可以在畫面上印出原始毫秒數,方便 debug:

tl.currentTime: {Math.round(tl.currentTime || 0)}ms · tl.duration:{' '}
{Math.round(tl.duration || 0)}ms

Step 7:在 JSX 裡渲染 shapes

{state.shapes.map((s, i) => {
  // morph 0 → 用半徑算尺寸(圓)
  // morph 1 → 用 size 邊長(方)
  const liveSize = s.morph < 0.5 ? Math.max(s.r * 2, 1) : s.size;
  const borderRadius = interpolate(s.morph, [0, 1], [999, 18], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
 
  return (
    <div
      key={`shape-${i}`}
      style={{
        position: 'absolute',
        left: s.x - liveSize / 2,
        top: s.y - liveSize / 2,
        width: liveSize,
        height: liveSize,
        backgroundColor: PALETTE[i],
        borderRadius,
        transform: `rotate(${s.rotate}deg)`,
        opacity: s.opacity,
        boxShadow: `0 ${20 * s.glow}px ${60 * s.glow}px ${PALETTE[i]}66`,
      }}
    />
  );
})}

這裡混用 Remotion 的 interpolate() 不是在做動畫——只是把 morph (0~1) 映射到 borderRadius 的視覺空間(999px 圓 → 18px 方角)。動畫本身還是 Anime.js 在驅動。

trails 同理,只是用 s.trail 做透明度淡入淡出,畫在 shape 後面:

{state.shapes.map((s, i) => {
  if (s.trail <= 0.001) return null;
  const trailSize = (s.morph < 0.5 ? s.r * 2 : s.size) * 1.4;
  return (
    <div
      key={`trail-${i}`}
      style={{
        position: 'absolute',
        left: s.x - trailSize / 2,
        top: s.y - trailSize / 2,
        width: trailSize,
        height: trailSize,
        borderRadius: '50%',
        background: `radial-gradient(circle, ${PALETTE[i]}55, ${PALETTE[i]}00 70%)`,
        opacity: s.trail * 0.85,
        filter: 'blur(14px)',
      }}
    />
  );
})}

Step 8:渲染

npx remotion render TutorialAnimeJsDemo out/t20.mp4 --codec=h264

Composition 設定:1920×1080、30fps、360 frames(12 秒)。記得在 Root.tsxTUTORIAL_ANIMEJS_DEMO_DURATION_FRAMES exported constant 拿來用,避免時長對不上 timeline 內部毫秒。

原理解析:為什麼 Anime.js 用毫秒?

Web Animations API(WAAPI)、CSS animation、setTimeout 通通用毫秒。Anime.js v4 為了跟 WAAPI 對齊(v4 內部會嘗試 sync 到 WAAPI 實例做硬體加速),整個 API 統一改成毫秒。GSAP 走自己的路,堅持用秒。

換算公式:

frame → seconds = frame / fps
seconds → milliseconds = seconds * 1000

∴ frame → ms = (frame / fps) * 1000

30fps 下:

  • frame 30 = 1 秒 = 1000ms
  • frame 90 = 3 秒 = 3000ms
  • frame 360 = 12 秒 = 12000ms

如果你忘了 × 1000,會發生什麼?tl.seek(12) 對一個 12000ms 的 timeline,等於 0.1% 進度,你會看到的就是「畫面完全沒動」。這是 anime.js 整合最常見的 bug,而且超難 debug 因為沒有任何 error。

原理解析:v4 的 add() 第三參數 position

tl.add(targets, params, position) 的 position 支援多種寫法:

  • 數字:絕對毫秒(從 timeline 0 開始)。例如 3000 = 第 3 秒
  • '+=500':接在「上一段結束」之後 + 500ms
  • '-=200':與上一段重疊 200ms(提前 200ms 開始)
  • label:先 tl.label('foo', 3000)tl.add(..., 'foo')

本教學全部用絕對數字,因為 Remotion 要求「精確時間軸」,相對偏移在後期維護時很容易腦袋打結。

原理解析:v4 的 ease 字串語法

v4 的 ease 是 function-style 字串,比 v3 的 enum 更靈活:

  • 'outExpo' — 等同 v3 的 easeOutExpo
  • 'outBack' — 帶反彈
  • 'outElastic(1, .6)' — 彈性,帶參數(amplitude, period)
  • 'spring(1, 80, 10, 0)' — spring 物理模擬(mass, stiffness, damping, velocity)
  • 'cubicBezier(0.25, 0.1, 0.25, 1)' — 自訂貝茲曲線
  • 'inOutQuad' / 'inOutSine' / 'inOutCubic' — 標準雙向 ease

如果你忘了改 easingease(注意拼字!),v4 不會報錯,會直接用預設的 outExpo。又是一個超難察覺的雷。

GSAP vs Anime.js v4 選哪個?

GSAP 3Anime.js v4
時間單位毫秒
暫停方式paused: trueautoplay: false
Bundle size~70KB~30KB(更小)
插件生態巨大(ScrollTrigger / MotionPath / SplitText / DrawSVG…)較少但在長
收費免費(商業曾需 Club 授權,現在全免費了)MIT 一直免費
TypeScript官方 types 完整v4 新增 types,但較粗糙
學習曲線文件豐富、社群大v4 文件還在補齊中
API 風格流暢鏈式v4 更接近 WAAPI

實務建議:

  • 已經熟 GSAP → 繼續用,沒必要為了 30KB 整套重學
  • 新專案、只想做 Remotion 影片動畫 → 兩個都行,挑你看得順眼的
  • 擔心 bundle size → Anime.js
  • 要 ScrollTrigger 等進階插件 → GSAP(雖然 Remotion 渲染裡用不到 ScrollTrigger,但其他插件像 MotionPath 還是有用)
  • 要長期維護、團隊新人多 → GSAP,文件和 stackoverflow 答案數量輾壓

常見問題

Q:v4 沒有 default export 了?

對。從 import anime from 'animejs' 變成 import { createTimeline, animate, stagger } from 'animejs'。v3 的程式碼整個要改 import 寫法,這也是為什麼很多舊範例直接搬過來會壞。

Q:v3 的 anime.timeline({ easing: 'linear' }) 在 v4 怎麼寫?

createTimeline({ defaults: { ease: 'linear' } });

兩個變動:(1) 參數名從 easing 改成 ease,(2) 共用設定要包在 defaults 裡面。

Q:為什麼我 seek 後看不到動畫?

99% 是 frame / fps 沒 × 1000。你的 timeline 有 12000ms,但你 seek 到 12(也就是 0.1%),畫面當然沒動。用毫秒!毫秒!毫秒!(踩雷 3 次後就會記住)

Q:TypeScript 報 Timeline type 找不到?

v4 的 type export 命名跟 v3 不一樣。最穩的寫法是用 ReturnType

const tl: ReturnType<typeof createTimeline> = useMemo(() => { ... }, []);

或如果你的 v4 版本有 export,可以 import type { Timeline } from 'animejs'。視套件版本而定。

Q:tl.seek() 第二個參數是什麼?

muteCallbacks。傳 true 表示這次 seek 不要觸發 onBegin / onComplete / onUpdate 等 callback。Remotion 渲染時會反覆 seek 到不同 frame,如果不 mute,callback 會被瘋狂亂呼叫。

Q:可以在同一個 Composition 裡混用 GSAP 跟 Anime.js 嗎?

技術上可以,兩個 timeline 各自 build、各自 seek,不會打架。但實務上建議擇一,混用容易讓未來的自己看不懂哪個動畫是誰在驅動。

下一步

  • T21:Three.js + React Three Fiber — 把 3D 場景放進 Remotion 影片,包含 camera 動畫、PBR 材質、後製合成