Remotion LabRemotion Lab
動畫庫整合Rive State Machine:用 Remotion frame 驅動互動動畫
rivestate-machineintegrationadvanced

Rive State Machine:用 Remotion frame 驅動互動動畫

Rive 的殺手鐧是 state machine:設計師定義狀態轉換,你用 useCurrentFrame 算出數值丟進 input,畫面就會即時反應。本篇直接用 @rive-app/canvas-advanced 串到 Remotion 時間軸。

成品預覽

6 秒、單一場景的 Rive 示範。畫面中央是一張粉紅色的評分卡,上面有 5 顆星,隨著影片進度 rating 從 0 一路漲到 5 再退回 2——每次數字變動的瞬間,整組星星會用設計師在 Rive editor 裡調好的 transition 動畫切換(縮放、顏色漸變)。底部的小膠囊即時顯示當前 rating = N 的數值,確認這個值確實是從 Remotion frame 推出來的。

這就是 Rive 跟 Lottie 最大的差別:Lottie 是「按下播放就讓它跑」,Rive 是「你每幀都可以告訴它當下的狀態」。前者是錄影帶,後者是遊戲角色。


為什麼 @remotion/rive 不夠用?

Remotion 官方有一個 @remotion/rive 套件,用起來很清爽:給它一個 src 檔案路徑就能渲染。但它只支援「線性動畫模式」——也就是把 .riv 檔當成「一段從頭播到尾的動畫」處理。

它沒有暴露 state machine input 的 API。這意味著如果你想要用 Remotion frame 去驅動 Rive 的狀態切換,@remotion/rive 幫不了你——你會發現它把你的 state machine 當成普通動畫播,rating 永遠停在預設值。

解法是繞過 @remotion/rive,直接用 Rive 官方的低階套件 @rive-app/canvas-advanced。這個套件暴露了完整的 state machine API:你可以找到某個 input、設值、然後手動 advanceAndApply(dt) 推進時間。整個流程跟 Rive runtime 在網頁上跑的方式一樣,只是把「真實時間」換成 Remotion 的 frame / fps


這篇會用到

  • @rive-app/canvas-advanced — Rive 官方低階 runtime,WASM-based
  • delayRender / continueRender — 載入 WASM + .riv 檔都是非同步的,要告訴 Remotion 等一下
  • useEffect + useRef — 一個用來初始化 runtime(只跑一次),一個用來每幀推進
  • canvas — Rive 直接在 HTML canvas 上繪圖,不是 SVG / React 元件

核心心法一句話:Rive runtime 在瀏覽器是「用 requestAnimationFrame 推進時間」的,你要把它改成「用 Remotion frame 推進時間」。整個技術難點就這一句。


前置知識

  • Lottie 整合:如果 delayRender / continueRender 的觀念還不熟,先看那一篇。本篇會同時使用兩個 handle(一個等 WASM、一個等 .riv 檔),機制一模一樣。
  • 對 canvas API 有基本認識即可——你不需要會手刻 2D 繪圖,Rive 都幫你畫好。

Step 1:安裝套件

npm install @rive-app/canvas-advanced

不裝 @remotion/rive——那個是走線性動畫路線的。我們要用的是 Rive 官方的低階 runtime。


Step 2:準備 Rive 檔案

Rive 的素材取得方式:

  1. rive.app 的 Community 區有大量 CC0 授權的 .riv 檔可以下載
  2. 設計師用 Rive editor 做完之後直接匯出 .riv

重點:下載的時候要確認這個 .riv 檔有 state machine,而且知道 state machine 的名字、input 的名字、input 的型別(number 或 boolean)。這些資訊在 Rive editor 裡都看得到——如果你是自己做的就直接知道;如果是下載別人的作品,打開 Rive editor 看一下 State Machine 面板。

把檔案放到 public/ 資料夾:

public/
└── animations/
    └── rating.riv

Step 3:最小可行的 Rive 整合

Rive runtime 需要兩樣東西才能跑:

  1. WASM 檔——Rive 的繪圖核心是用 C++ 寫的,編譯成 WASM 在瀏覽器裡跑
  2. .riv——你的動畫資料

兩個都是非同步載入。先看整個流程的骨架:

import RiveCanvas, {
  type Artboard,
  type CanvasRenderer,
  type RiveCanvas as RiveCanvasInstance,
  type StateMachineInstance,
  type SMIInput,
} from '@rive-app/canvas-advanced';
import {
  delayRender,
  continueRender,
  staticFile,
  useCurrentFrame,
  useVideoConfig,
} from 'remotion';
import {useEffect, useRef, useState} from 'react';
 
// Rive WASM 從 unpkg 拿(也可以自己 host)
const RIVE_WASM_URL =
  'https://unpkg.com/@rive-app/canvas-advanced@2.31.5/rive.wasm';
 
type RiveStateBundle = {
  rive: RiveCanvasInstance;
  artboard: Artboard;
  smi: StateMachineInstance;
  input: SMIInput;
  renderer: CanvasRenderer;
};

這些 import 看起來有點嚇人,但它們只是描述 Rive 底層物件的型別。你要做的是:初始化一次、拿到這些 handle、存進 ref,然後每幀重複使用


Step 4:初始化 Rive runtime

這一段是重頭戲。在 useEffect 裡做所有的非同步載入,全部成功之後 continueRender 放行:

const RiveStateMachineCanvas: React.FC<{
  src: string;
  stateMachine: string;
  inputName: string;
  inputValueAtFrame: (frame: number) => number;
  width: number;
  height: number;
}> = ({src, stateMachine, inputName, inputValueAtFrame, width, height}) => {
  const frame = useCurrentFrame();
  const {fps} = useVideoConfig();
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const bundleRef = useRef<RiveStateBundle | null>(null);
  const lastFrameRef = useRef(0);
  const [handle] = useState(() => delayRender('Loading Rive state machine'));
  const [ready, setReady] = useState(false);
 
  useEffect(() => {
    let cancelled = false;
    (async () => {
      // 1. 載入 Rive WASM runtime
      const rive = await RiveCanvas({locateFile: () => RIVE_WASM_URL});
 
      // 2. 載入 .riv 檔並解析
      const buf = await fetch(src).then((r) => r.arrayBuffer());
      const file = await rive.load(new Uint8Array(buf));
 
      // 3. 拿到預設 artboard
      const artboard = file.defaultArtboard();
 
      // 4. 拿到指定的 state machine
      const sm = artboard.stateMachineByName(stateMachine);
      const smi = new rive.StateMachineInstance(sm, artboard);
 
      // 5. 在 state machine 的 inputs 列表裡找到我們要的那一個
      let input: SMIInput | null = null;
      for (let i = 0; i < smi.inputCount(); i++) {
        const inp = smi.input(i);
        if (inp.name === inputName) {
          input = inp.asNumber();
          break;
        }
      }
      if (!input) {
        throw new Error(`Rive input "${inputName}" not found`);
      }
      if (cancelled || !canvasRef.current) return;
 
      // 6. 建立 renderer 綁到 canvas
      const renderer = rive.makeRenderer(canvasRef.current);
 
      // 7. 全部存進 ref,設 ready,放行 render
      bundleRef.current = {rive, artboard, smi, input, renderer};
      setReady(true);
      continueRender(handle);
    })();
    return () => {
      cancelled = true;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
 
  // ... 下一步:每幀推進
};

7 個步驟看起來很多,但每一步都很直接:載 WASM → 載 .riv → 找 artboard → 找 state machine → 找 input → 建 renderer → 存起來。只會跑一次,跑完之後 bundleRef.current 就裝滿了後續要用的 handle。

cancelled 的用途:如果元件在載入途中就被 unmount(例如 Studio 熱重載),useEffect 的 cleanup 會把 cancelled 設成 true,避免對已經不存在的 canvas 呼叫 makeRenderer。這是一個好習慣——Rive runtime 沒有 cancel token 的概念,只能靠我們自己攔。

為什麼 input.asNumber()?state machine input 可以是 number、boolean 或 trigger,Rive API 要你明確「轉型」到其中一種。這邊的 rating 是 number,所以用 asNumber()


Step 5:每幀推進 state machine

初始化完成後,用第二個 useEffect 在 frame 改變時重繪:

useEffect(() => {
  const bundle = bundleRef.current;
  if (!ready || !bundle || !canvasRef.current) return;
 
  const {rive, artboard, smi, input, renderer} = bundle;
 
  rive.requestAnimationFrame(() => {
    // 算上一幀到這一幀的時間差(秒)
    const diff = Math.max(0, frame - lastFrameRef.current);
    lastFrameRef.current = frame;
    const dt = diff / fps;
 
    // 更新 state machine input —— 這是整套東西的關鍵
    input.value = inputValueAtFrame(frame);
 
    // 推進 state machine 跟 artboard
    smi.advanceAndApply(dt);
    artboard.advance(dt);
 
    // 重繪
    renderer.clear();
    renderer.save();
    renderer.align(
      rive.Fit.contain,
      rive.Alignment.center,
      {minX: 0, minY: 0, maxX: width, maxY: height},
      artboard.bounds,
    );
    artboard.draw(renderer);
    renderer.restore();
  });
}, [frame, fps, ready, inputValueAtFrame, width, height]);
 
return (
  <canvas
    ref={canvasRef}
    width={width}
    height={height}
    style={{width, height, display: 'block'}}
  />
);

這段的關鍵是 dt = diff / fps——把「frame 差」換算成「秒差」餵給 Rive。如果 Remotion 現在是第 60 幀、上一次是第 59 幀、fps 是 30,dt = 1/30 = 0.033 秒。Rive 的 state machine 會根據這個 dt 跑一點點 transition 動畫(例如從 rating=2 轉到 rating=3 的過程)。

為什麼要自己維護 lastFrameRef 因為 Remotion 的 frame 不一定是連續的(在 render 階段是,但在 Studio preview 可能會跳)。每次都算「跟上一幀的差」才對得起來。

為什麼要用 rive.requestAnimationFrame Rive runtime 內部有個 micro-scheduler,畫面的最終繪製要透過它排程,才能保證 draw call 順序正確。用瀏覽器原生的 requestAnimationFrame 會跟 Rive 的內部狀態不同步。


Step 6:驅動 input 的值

上一步的 inputValueAtFrame 是一個純函式:給它當前 frame,它回傳 rating 應該是多少。範例影片用的是「離散階梯」:

const ratingAtFrame = React.useCallback((f: number) => {
  if (f < 20) return 0;
  if (f < 45) return 1;
  if (f < 70) return 2;
  if (f < 95) return 3;
  if (f < 120) return 4;
  if (f < 150) return 5;
  if (f < 160) return 4;
  if (f < 170) return 3;
  return 2;
}, []);

為什麼是階梯而不是 interpolate(frame, [0, 150], [0, 5]) 線性插值?因為 Rive 的 state machine 有離散狀態——rating 必須是 0、1、2、3、4、5 其中一個整數,中間值(例如 3.7)會讓 state machine 不知道該停在哪。階梯讓每個整數值停留 25 幀,剛好夠 state machine 的 transition 動畫跑完。

如果你的 state machine 是連續 input(像是 Speed: 0~100,那就可以用 interpolatespring,直接算出一個連續數值丟進去,Rive 會自動做內插。

拿這個函式去呼叫 <RiveStateMachineCanvas>

<RiveStateMachineCanvas
  src={staticFile('animations/rating.riv')}
  stateMachine="State Machine 1"
  inputName="rating"
  inputValueAtFrame={ratingAtFrame}
  width={520}
  height={520}
/>

Step 7:渲染

npx remotion render TutorialRiveDemo out/rive.mp4 --codec=h264

第一次 render 時會發現它需要下載 Rive WASM(幾百 KB),在 log 會看到「Loading Rive state machine」停留一下。下載完就正常了。如果你在斷網或 CI 環境,建議把 WASM 檔自己 host 到 public/ 下並修改 RIVE_WASM_URL


原理解析:Lottie vs Rive state machine

兩者都是「設計師做、工程師嵌入」,但互動能力差很多:

面向LottieRive state machine
來源工具After Effects + BodymovinRive editor 原生
檔案格式JSON(純文字).riv(二進位,更小)
時間軸模型線性:從 t=0 播到 t=end狀態機:根據 input 切換狀態
是否可外部驅動否,按下播放就完事是,每幀都能 set input
Remotion 寫法fetch JSON + <Lottie>低階 runtime + 手動 advance
適合做什麼loading、裝飾、純展示動畫評分、角色表情、進度綁動畫、互動式 UI

選擇建議

  • 純展示動畫(loading、icon、插畫)→ Lottie
  • 需要根據影片進度動態反應(評分從 0 漲到 5、角色根據速度變表情)→ Rive
  • 兩個都用也沒問題——它們可以共存在同一支影片裡

原理解析:為什麼要直接用 advance 而不是 requestAnimationFrame

一般在瀏覽器跑 Rive,runtime 內部會自己呼叫 requestAnimationFrame——瀏覽器每次 repaint 前就推進一點。這在「真實時間」的環境下完美運作。

但 Remotion 不是真實時間。它是「我給你 frame 號碼,你告訴我這一幀畫面長什麼樣」。每一幀都是獨立計算的,沒有「上一幀是什麼時候畫的」這個概念。所以你不能讓 Rive 自己排 requestAnimationFrame——它會按照真實時間跑,跟 Remotion 的 frame 完全對不起來。

解法就是 把 Rive 的時間完全接管:每次 Remotion 呼叫我們的元件(render 第 N 幀),我們就自己算 dt = 1/fps,手動呼叫 smi.advanceAndApply(dt)。這樣不管 Remotion render 速度是多快還是多慢、不管是在 Studio 即時預覽還是 Node.js 環境下 render,Rive 的時間永遠跟 Remotion 的 frame 完美同步。


常見問題

Q: input 找不到,throw Rive input "xxx" not found

打開 Rive editor 確認 state machine 的 input 名字。大小寫敏感、前後空白敏感。最常見的錯誤是「在 editor 裡叫 Rating、你在程式碼裡打 rating」。

Q: rating 變動時動畫沒有 transition,直接瞬間切換?

兩個可能:

  1. Rive editor 裡沒有為 state 之間設定 transition duration(預設是 0 秒)——回 editor 調
  2. dt 太大導致 transition 被一次跑完——檢查 lastFrameRef 邏輯有沒有正確維護

Q: 可以同時驅動多個 input 嗎?

可以。把 step 4 裡的 input 改成 inputs: Record<string, SMIInput>,for loop 每找到一個就存進 map。然後 step 5 的每幀更新裡同時 set 多個值。

Q: Rive 在 Studio 正常但 render 時空白?

通常是 delayRender 的 timeout 在 WASM 下載階段就觸發了。把 CompositiontimeoutInMilliseconds 調高(例如 60000),或者把 WASM 檔 host 到 public/ 讓它走本地。

Q: WASM 載入了、.riv 也載入了,但畫面還是空的?

檢查 canvasRef.currentuseEffect 執行的當下是不是 null。React 的 ref 在 mount 之前會是 null,而如果你用 conditional render(ready ? <canvas> : null),canvas 在初次 render 根本不存在。解法是永遠 render canvas(ready 只控制 draw call),或者把 useState 改成 mount-check。


本篇涵蓋的官方文件


下一步

T19:GSAP 動畫整合 — 連續兩篇都在講「設計師做、工程師嵌入」的動畫格式。下一篇要換到「工程師做、給工程師用」的老牌動畫函式庫 GSAP——怎麼把它的 timeline 概念接到 Remotion 的 frame 系統上,讓你可以把網頁上跑過的 GSAP 動畫直接搬進影片產線。