R RenderComp
remotion ci-cd testing typescript video-generation

Remotionの決定論的レンダリングをCIでフレームハッシュ検証する

執筆: RenderComp チーム 編集方針

Remotion の核心にある設計思想は、「動画フレームは純粋関数である」という考え方です。フレーム番号 f を入力として受け取り、そのフレームのピクセルデータを出力する ── この関係が副作用なしに成立するとき、レンダリングは自然に決定論的になります。同じ f を与えれば、ローカルの MacBook でも、GitHub Actions の Ubuntu ランナーでも、ピクセル単位で同一の出力が得られるはずです。

しかし、この保証は「React コンポーネントが本当に純粋関数として書かれているか」にかかっています。Math.random() を呼べばフレームごとに異なる値が返り、Date.now() を呼べばレンダリングマシンのシステム時刻に依存した値が混入します。開発環境では問題なく動いているように見えて、CI で突然レンダリング結果が変わる ── その根本原因のほぼすべては、コンポーネント内の非決定論的な式です。

このガイドでは、Remotion がフレーム番号をコンポーネントに注入するメカニズムから始め、決定論を破壊する典型的な落とし穴と組み込みの random() 関数による修正を説明します。さらに renderFrames() と SHA-256 ハッシュを使って CI パイプラインで同一性を定量的に証明する方法まで、実装ベースで解説します。


フレームが純粋関数になる仕組み

Remotion のレンダラーは、Puppeteer(Headless Chrome)を使って各フレームをスクリーンショットします。動画を「再生」するのではなく、レンダラーはブラウザに対して「フレーム番号 n の状態に移動せよ」という指示を出します。具体的には、window.remotion_framewindow.remotion_fpswindow.remotion_durationInFrames などのグローバル変数をレンダラーがブラウザにセットし、React のレンダーを走らせた後にスクリーンショットを取ります。

useCurrentFrame() は、このグローバル変数を読み取るだけのシンプルなフックです。

// Remotion の内部動作(概念的な疑似コード)
export function useCurrentFrame(): number {
  return window.remotion_frame; // レンダラーが書き込んだ整数値をそのまま返す
}

そして spring()interpolate() は、フレーム番号を受け取る純粋な数学関数です。

import {
  AbsoluteFill,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";

const SlideIn: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // spring() はフレーム番号と fps と config だけで一意に決まる純粋関数
  const progress = spring({
    frame,
    fps,
    config: {
      stiffness: 120, // 高いほどバネが硬く、素早く目標値に収束する
      damping: 14,    // 減衰係数。14 前後でオーバーシュートをほぼ消せる
      mass: 1,        // 慣性。大きくすると動き始めが遅くなる
    },
  });

  // progress: 0 → 1 の範囲をピクセル変位にマッピング
  const translateX = interpolate(progress, [0, 1], [-200, 0]);

  return (
    <AbsoluteFill style={{ transform: `translateX(${translateX}px)` }}>
      <h1 style={{ fontSize: 80, color: "#fff" }}>Hello, Remotion</h1>
    </AbsoluteFill>
  );
};

spring() の戻り値は framefpsconfig の3つで一意に決定されます。同じ入力 → 同じ出力という純粋関数の性質が、決定論的レンダリングの土台になっています。


決定論を壊す3つの罠

罠①:Math.random()

Math.random() はコンポーネントが再評価されるたびに異なる値を返します。開発時のホットリロードはもちろん、CI での2回目のレンダリングでも1回目と異なるフレームが生成されます。

// NG:フレームをレンダリングするたびに位置が変わる
const x = Math.random() * 1920;

// OK:Remotion 組み込みの決定論的乱数(次のセクションで詳述)
import { random } from "remotion";
const x = random("particle-x") * 1920; // 常に同じ値

罠②:Date.now() / new Date()

タイムスタンプはレンダリングマシンのシステム時刻に依存します。ローカルと CI では当然値が異なり、同じマシンでも実行タイミングが変われば異なる値になります。

// NG:実行時刻に依存。CI と開発環境では必ず異なる
const phase = (Date.now() % 1000) / 1000;

// OK:フレーム番号から経過時間を厳密に計算する
const { fps } = useVideoConfig();
const frame = useCurrentFrame();
// fps=30 なら frame 15 は常に 500ms 経過を意味する
const elapsedMs = (frame / fps) * 1000;
const phase = (elapsedMs % 1000) / 1000;

罠③:外部ネットワーク呼び出し

fetch() で外部 API を呼ぶと、レスポンス内容・レイテンシ・ネットワーク状態に依存した結果になります。API の内容が変わればフレームも変わります。

// NG:外部データが変われば別のフレームが生成される
fetch("https://api.example.com/today-stats").then(...);

// OK:staticFile() でバンドル内の静的ファイルを参照する
import { staticFile } from "remotion";
fetch(staticFile("data.json")).then(...);

staticFile() はバンドルに含まれたファイルへのパスを返すため、どの環境でも同じバイト列が読まれることが保証されます。


random() ── 決定論的な「乱数」

Remotion には random(seed: string | number): number 関数が用意されています。戻り値は 0 以上 1 未満の浮動小数点数ですが、同じ seed を渡せば 常に同じ値が返ります。内部ではシード付き擬似乱数生成器(PRNG)が使われており、Math.random() のような非決定論的挙動は一切ありません。

import { AbsoluteFill, random, useCurrentFrame } from "remotion";

const ParticleField: React.FC<{ count: number }> = ({ count }) => {
  const frame = useCurrentFrame();

  return (
    <AbsoluteFill>
      {Array.from({ length: count }, (_, id) => {
        // seed に id だけを含めると、全フレームで同じ基準位置になる
        const baseX = random(`p-${id}-x`) * 1920;
        const baseY = random(`p-${id}-y`) * 1080;

        // seed に frame を含めると、フレームごとに異なるが再現可能な動きになる
        // Math.sin() は純粋関数なので決定論を壊さない
        const oscillate = Math.sin(
          (frame / 30) * Math.PI * 2 + random(`p-${id}-phase`) * Math.PI * 2
        );
        const y = baseY + oscillate * 30;

        return (
          <div
            key={id}
            style={{
              position: "absolute",
              left: baseX,
              top: y,
              width: 8,
              height: 8,
              borderRadius: "50%",
              // 色相も seed ベースで固定。フレームによって変わらない
              backgroundColor: `hsl(${random(`p-${id}-hue`) * 360}, 80%, 60%)`,
            }}
          />
        );
      })}
    </AbsoluteFill>
  );
};

Math.sin()Math.cos() は入力が同じなら常に同じ値を返す純粋関数です。frame を引数に渡す限り決定論は壊れません。Math.random() の非決定性が問題であり、三角関数や指数関数は安全に使えます。


フレームハッシュで同一性を定量的に証明する

「決定論的である」という主張を証明するには、同一コンポジションを2回レンダリングして各フレームの SHA-256 ハッシュを比較するのが最も確実な方法です。

@remotion/rendererrenderFrames() は完成動画ではなく PNG/JPEG フレームを出力します。これを2回実行してハッシュを突合します。

// scripts/check-determinism.ts
import { bundle } from "@remotion/bundler";
import { renderFrames, selectComposition } from "@remotion/renderer";
import { createHash } from "crypto";
import { mkdirSync, readdirSync, readFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";

async function renderAndHash(
  serveUrl: string,
  compositionId: string,
  outDir: string,
  inputProps: Record<string, unknown>
): Promise<string[]> {
  mkdirSync(outDir, { recursive: true });

  const composition = await selectComposition({
    serveUrl,
    id: compositionId,
    inputProps,
  });

  await renderFrames({
    composition,
    serveUrl,
    outputDir: outDir,
    inputProps,
    imageFormat: "png", // PNG は可逆圧縮。JPEG はエンコーダ差でハッシュが変わりうる
    concurrency: 1,     // 並列度1でスレッド間の実行順の影響を排除する
    onStart: ({ frameCount }) => {
      console.log(`  ${frameCount} フレームをレンダリング中...`);
    },
    onFrameUpdate: (rendered) => {
      process.stdout.write(`\r  ${rendered} フレーム完了`);
    },
  });

  // 出力ファイルを昇順ソートし、各 PNG の SHA-256 を計算する
  const files = readdirSync(outDir)
    .filter((f) => f.endsWith(".png"))
    .sort(); // ファイル名は frame-000000.png 形式のため辞書順 = フレーム順

  return files.map((f) => {
    const buf = readFileSync(join(outDir, f));
    return createHash("sha256").update(buf).digest("hex");
  });
}

async function main() {
  console.log("バンドルを作成中...");
  const serveUrl = await bundle({
    entryPoint: "./src/index.ts",
  });

  const COMPOSITION_ID = "MyComposition";
  const INPUT_PROPS = { title: "Determinism Test" };
  const dir1 = join(tmpdir(), "remotion-det-run1");
  const dir2 = join(tmpdir(), "remotion-det-run2");

  console.log("\n=== Run 1 ===");
  const hashes1 = await renderAndHash(serveUrl, COMPOSITION_ID, dir1, INPUT_PROPS);

  console.log("\n\n=== Run 2 ===");
  const hashes2 = await renderAndHash(serveUrl, COMPOSITION_ID, dir2, INPUT_PROPS);

  // フレームごとにハッシュを比較する
  const mismatches: number[] = [];
  for (let i = 0; i < hashes1.length; i++) {
    if (hashes1[i] !== hashes2[i]) {
      mismatches.push(i);
      console.error(`\nMISMATCH frame ${i}:`);
      console.error(`  Run1: ${hashes1[i]}`);
      console.error(`  Run2: ${hashes2[i]}`);
    }
  }

  if (mismatches.length === 0) {
    console.log(`\n✓ 全 ${hashes1.length} フレームが一致。決定論的レンダリングを確認しました。`);
    process.exit(0);
  } else {
    console.error(`\n✗ ${mismatches.length} フレームで不一致。決定論チェック失敗。`);
    process.exit(1);
  }
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});

concurrency: 1 について補足します。 デフォルトでは複数スレッドが並行してフレームをレンダリングしますが、スレッド間の実行順はランによって変わりえます。決定論テストでは concurrency: 1 を指定して1フレームずつ処理することで、並列度に起因する不確定性を排除します。本番レンダリングでは並列度を上げて速度を優先してください。

PNG を選ぶ理由は可逆性にあります。 JPEG はエンコーダの実装バージョン・量子化テーブルによって同一入力でもバイト列が変わることがあります。SHA-256 の比較が目的であれば imageFormat: "png" が必須です。


CI パイプラインへの統合(GitHub Actions)

このスクリプトをプルリクエストで自動実行することで、コード変更が決定論を壊していないかを継続的に検証できます。

# .github/workflows/determinism-check.yml
name: Remotion Determinism Check

on:
  push:
    branches: [main]
  pull_request:
    paths:
      - "src/**"
      - "package.json"
      - "package-lock.json"

jobs:
  check:
    runs-on: ubuntu-latest
    timeout-minutes: 20

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      # Remotion が使う Chromium をキャッシュして毎回のダウンロードを回避する
      - name: Cache Remotion browser
        uses: actions/cache@v4
        with:
          path: ~/.cache/puppeteer
          key: puppeteer-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

      - name: Remotion determinism check
        run: npx ts-node --esm scripts/check-determinism.ts
        env:
          # root ユーザーで動作する Docker/CI 環境では --no-sandbox が必要
          REMOTION_CHROMIUM_FLAGS: "--no-sandbox"

--no-sandbox フラグは、root ユーザーとして動作する多くの CI コンテナで Chrome を起動するために必要です。CI の隔離されたサンドボックス環境がセキュリティを保証するため、この用途では許容されます。

paths トリガーを src/** に絞ることで、Markdown や設定ファイルの変更では不要なレンダリングジョブが走らないようにしています。コンポジション数が多い場合は、変更があったコンポジションだけを対象にするようスクリプトを拡張するとよいでしょう。


delayRender() と非同期アセットの決定論

delayRender() を使った非同期データ取得は、データソースが決定論的であれば問題ありません。

import { continueRender, delayRender, staticFile } from "remotion";
import { useEffect, useRef, useState } from "react";

type ChartData = { label: string; value: number }[];

const ChartComposition: React.FC<{ dataFile: string }> = ({ dataFile }) => {
  const [data, setData] = useState<ChartData | null>(null);
  // delayRender() はレンダラーに「このフレームはまだスクリーンショットしないで」と伝える
  const handle = useRef(delayRender(`Loading ${dataFile}`));

  useEffect(() => {
    fetch(staticFile(dataFile)) // バンドル内ファイル → 常に同一内容
      .then((res) => res.json() as Promise<ChartData>)
      .then((json) => {
        setData(json);
        continueRender(handle.current); // ここでレンダラーに描画再開を通知
      })
      .catch((err) => {
        console.error(err);
        continueRender(handle.current); // エラーでもブロックを解除しないとタイムアウトする
      });
  }, [dataFile]);

  if (!data) return null;

  return (
    <AbsoluteFill>
      {data.map(({ label, value }) => (
        <div key={label} style={{ display: "flex", gap: 16 }}>
          <span>{label}</span>
          <div style={{ width: value * 3, height: 24, background: "#4f8ef7" }} />
        </div>
      ))}
    </AbsoluteFill>
  );
};

staticFile() でバンドル内のファイルを参照する限り、どの環境でも同一のバイト列が読まれます。問題が起きるのは fetch("https://api.example.com/") のような外部エンドポイントを使う場合です。外部データが必要な場合は、ビルド時にデータを取得して静的 JSON としてバンドルに含める「データベイク」パターンを採用してください。


ESLint で非決定論的コードを静的検知する

ランタイム検証の前段として、@remotion/eslint-plugin を使った静的解析を加えるとさらに安全になります。このプラグインには Math.random()Date オブジェクトの使用を禁止するルールが含まれています。

// .eslintrc.json(抜粋)
{
  "plugins": ["@remotion"],
  "extends": ["plugin:@remotion/recommended"],
  "rules": {
    // 不確定な時刻依存コードをエラーとして検出
    "@remotion/no-date-object": "error",
    // Math.random() の使用を検出(random() への置き換えを促す)
    "@remotion/no-random": "error"
  }
}
# プルリクエストの CI ステップに追加する
npx eslint "src/**/*.{ts,tsx}"

ESLint でコミット段階に弾き、フレームハッシュ比較で結合テストとして検証する。この2層構造が最も費用対効果の高い構成です。


まとめ

Remotion の決定論的レンダリングは、フレーム番号をコンポーネントに注入するアーキテクチャによって構造的に実現されています。しかしこの保証は、コンポーネントが副作用のない純粋関数として書かれている場合に限り成立します。

実践で押さえるべきポイントは次の5点です。

  • Math.random()random(seed) に置き換える。同じ seed を渡すと常に同じ浮動小数点数が返る
  • Date.now() はフレーム演算に差し替え、(frame / fps) * 1000 で経過ミリ秒を算出する
  • 外部 API の呼び出しは staticFile() に替える。データはビルド時にバンドルへ含める
  • renderFrames() と SHA-256 を組み合わせて同一性を定量検証する。concurrency: 1 と PNG 形式が前提となる
  • @remotion/eslint-plugin による静的検知を加え、コミット段階で非決定論的コードを防ぐ

RenderComp のカタログに収録されているテンプレートはすべてこのアーキテクチャに準拠しており、inputProps が同一であれば任意の環境でピクセル一致のフレームを生成します。フレームハッシュ検証スクリプトを CI に組み込み、テンプレートのカスタマイズ中に決定論が維持されていることを継続的に確認してください。

販売中

1,000以上のRemotionテンプレートを一括入手

買い切り(一括払い)・サブスクなし・生涯アップデート無料。TypeScript製。

料金プランを見る →