R RenderComp
remotion regression-testing ai-agent video-pipeline typescript

AI生成Remotionコンポジションをリグレッション検証する

執筆: RenderComp チーム 編集方針

Remotionの核心は「ビデオはフレーム番号の純粋関数である」という設計思想です。同じpropsを渡せば同じフレームが得られます。この決定論的な性質こそが、Remotionを他のビデオ制作ツールと一線を画す特徴であり、リグレッションテストを意味あるものにする根拠でもあります。フレーム番号が変わらない限り出力が変わらないことを保証できるのであれば、過去の正解フレームとの差分比較によって品質を機械的に担保できます。

AIエージェントがRemotionコンポジションのコードやpropsを生成するようになると、この決定論性はダブルエッジになります。エージェントが正しいコードを生成すれば再現性は完璧ですが、わずかなミスが「レンダリングは成功するが動きが壊れている」という静かな障害を生みます。useCurrentFrame() の代わりにハードコードされた 0 が渡される、spring() パラメータが変わって演出のタイミングがずれる、composition の fps が意図せず変更される。こうした問題は、フレームを肉眼で確認するまで誰も気づきません。

この記事では、AIエージェントが生成したRemotionコンポジションを品質ゲートに通すためのリグレッションパイプラインを構築します。核心となるのは「フレームをどこで計測するか」という戦略です。静止したフレームを数点サンプリングするだけでは動きの凍結を検出できず、イベント位相に基づくフレーム選択と隣接フレーム比較が必要です。


AI生成コンポジションが壊れる4つのパターン

検証パイプラインを設計する前に、AIエージェントが引き起こす障害モードを整理しておきます。

パターン1: アニメーションの凍結

最も発見しにくい障害です。useCurrentFrame() の代わりに定数が埋め込まれると、コンポジションはレンダリングに成功しますが全フレームが同一になります。

import { useCurrentFrame, interpolate } from "remotion";

export const MyScene: React.FC = () => {
  const frame = useCurrentFrame();

  // 正常: frame が変化するためアニメーションする
  const opacity = interpolate(frame, [0, 30], [0, 1], {
    extrapolateRight: "clamp",
  });

  // AIエージェントが生成した破損版: 0 が固定されているため
  // 全フレームで opacity = 0 のまま変化しない
  // const opacity = interpolate(0, [0, 30], [0, 1], { extrapolateRight: "clamp" });

  return (
    <AbsoluteFill style={{ opacity, backgroundColor: "#1a1a2e" }}>
      <h1>Title</h1>
    </AbsoluteFill>
  );
};

フレーム0とフレーム60を点検してもこの問題は見えません。フレーム0は正常版でも opacity = 0 であり、凍結版と区別できないからです。

パターン2: 寸法・FPSのドリフト

AIエージェントがcomposition設定を書き換えると、width / height / fps / durationInFrames が意図せず変化することがあります。30fps想定のコンポジションが24fpsになると、すべての spring() 演算と interpolate() のフレームレンジが狂います。

パターン3: propsのサイレントフォールバック

Remotionはpropsが渡されなかった場合、defaultProps に静かにフォールバックします。エージェントがprops注入のコードを生成し損ねると、「完成済み」のコンポジションが実はデモ用デフォルト値で動いているという状況が生まれます。

パターン4: springパラメータのずれ

spring({ stiffness: 100, damping: 10 })spring({ stiffness: 200, damping: 20 }) は同じ最終値に収束しますが、到達タイミングは大きく異なります。静止フレームの比較では同一に見えても、演出の「気持ちよさ」は失われています。


検証基盤のセットアップ

@remotion/bundler@remotion/renderer をNode.jsスクリプトから直接呼び出してフレームを取得します。CIジョブとしてもスタンドアロンのNPMスクリプトとしても動かせる構成を前提にします。

npm install --save-dev @remotion/bundler @remotion/renderer pngjs pixelmatch

バンドルは一度だけ生成し、その serveUrl を全テストで使い回します。バンドルはディスクに書き出されるため、キャッシュすれば2回目以降は高速です。

// scripts/regression-harness.ts
import { bundle } from "@remotion/bundler";
import {
  getCompositions,
  selectComposition,
  renderStill,
} from "@remotion/renderer";
import {
  readFileSync,
  writeFileSync,
  existsSync,
  mkdirSync,
} from "fs";
import { join } from "path";
import { tmpdir } from "os";
import pixelmatch from "pixelmatch";
import { PNG } from "pngjs";

const ENTRY_POINT = join(__dirname, "../src/index.ts");
const GOLDEN_DIR = join(__dirname, "../test/golden");

async function buildBundle(): Promise<string> {
  console.log("Bundling...");
  return bundle({ entryPoint: ENTRY_POINT });
}

次に、compositionのメタデータを検証する関数を作ります。AIエージェントが設定を変えていないかを getCompositions() の返り値と期待値を突き合わせて確認します。

interface CompositionSpec {
  id: string;
  width: number;
  height: number;
  fps: number;
  durationInFrames: number;
}

async function verifyCompositionMeta(
  serveUrl: string,
  spec: CompositionSpec
): Promise<void> {
  const compositions = await getCompositions(serveUrl);
  const found = compositions.find((c) => c.id === spec.id);

  if (!found) {
    throw new Error(`Composition "${spec.id}" not found in bundle`);
  }

  const mismatches: string[] = [];
  if (found.width !== spec.width)
    mismatches.push(`width: expected ${spec.width}, got ${found.width}`);
  if (found.height !== spec.height)
    mismatches.push(`height: expected ${spec.height}, got ${found.height}`);
  if (found.fps !== spec.fps)
    mismatches.push(`fps: expected ${spec.fps}, got ${found.fps}`);
  if (found.durationInFrames !== spec.durationInFrames)
    mismatches.push(
      `durationInFrames: expected ${spec.durationInFrames}, got ${found.durationInFrames}`
    );

  if (mismatches.length > 0) {
    throw new Error(`Composition meta mismatch:\n${mismatches.join("\n")}`);
  }
}

フレームをキャプチャするユーティリティです。renderStill はPNGをディスクに書き出すので、その後 pngjs でデコードしてピクセルバッファを得ます。

async function captureFrame(
  serveUrl: string,
  compositionId: string,
  frame: number,
  width: number,
  height: number,
  inputProps: Record<string, unknown> = {}
): Promise<{ data: Buffer; width: number; height: number }> {
  const composition = await selectComposition({
    serveUrl,
    id: compositionId,
    inputProps,
  });

  const outputPath = join(
    tmpdir(),
    `rc-regression-${compositionId}-f${frame}.png`
  );

  await renderStill({
    composition,
    serveUrl,
    output: outputPath,
    frame,
    inputProps,
    timeoutInMilliseconds: 30_000,
  });

  const raw = readFileSync(outputPath);
  const png = PNG.sync.read(raw);
  return { data: png.data, width: png.width, height: png.height };
}

イベント位相フレーム選択:静止点だけでは動きを保証できない

リグレッションで最もやりがちなミスは、「フレーム0・フレーム15・フレーム30・フレーム60」のように等間隔の静止点だけを検査することです。このアプローチが見落とすのがアニメーションの凍結です。

凍結しているコンポジションでは、フレーム0とフレーム60が意図通りの値を示すことがあります。問題はその途中、フレーム1からフレーム29の範囲で何も変化していないことです。「動いているはず」の区間で隣接フレームを比べなければ、凍結は永遠に見えません。

アニメーションが正常に動いているなら、連続する2フレームは必ず異なるはずです。この事実を利用して凍結を検出します。

// アニメーション区間で隣接フレームを比較し、凍結を検出する
async function assertMotionExists(
  serveUrl: string,
  compositionId: string,
  width: number,
  height: number,
  animationRange: { start: number; end: number },
  inputProps: Record<string, unknown> = {}
): Promise<void> {
  // 区間の中央付近で隣接フレームペアを取得する
  const midFrame = Math.floor((animationRange.start + animationRange.end) / 2);
  const nextFrame = midFrame + 1;

  const [frameA, frameB] = await Promise.all([
    captureFrame(serveUrl, compositionId, midFrame, width, height, inputProps),
    captureFrame(serveUrl, compositionId, nextFrame, width, height, inputProps),
  ]);

  // threshold: 0.01 は事実上「ほぼ同一」。差分0は完全凍結を意味する
  const diffCount = pixelmatch(
    frameA.data,
    frameB.data,
    null, // diff画像は不要
    width,
    height,
    { threshold: 0.01 }
  );

  if (diffCount === 0) {
    throw new Error(
      `Animation frozen: frames ${midFrame} and ${nextFrame} are identical ` +
        `in "${compositionId}" (range ${animationRange.start}-${animationRange.end})`
    );
  }
}

次に、フレーム選択を体系化します。compositionが複数の Sequence を持つ場合、各Sequenceの入口フレーム出口直前のフレームを検査対象に加えます。Sequenceの境界は状態遷移が起きるポイントであり、AIエージェントがタイミングを誤設定しても見逃しにくくなります。

// Sequenceの境界とアニメーション中間点を合わせた検査フレームリストを生成する
function buildVerificationFrames(config: {
  durationInFrames: number;
  sequences: Array<{ from: number; durationInFrames: number }>;
  animationRanges: Array<{ start: number; end: number }>;
}): number[] {
  const frames = new Set<number>();

  // 先頭・末尾は必須
  frames.add(0);
  frames.add(config.durationInFrames - 1);

  // 各 Sequence の入口と出口直前
  for (const seq of config.sequences) {
    frames.add(seq.from);
    frames.add(seq.from + seq.durationInFrames - 1);
  }

  // アニメーション区間の中間点と、その隣接フレーム(動き確認の起点)
  for (const range of config.animationRanges) {
    const mid = Math.floor((range.start + range.end) / 2);
    frames.add(mid);
    frames.add(mid + 1);
  }

  return Array.from(frames).sort((a, b) => a - b);
}

30fps・90フレーム(3秒)で Sequence を2つ持つ典型的なコンポジションの使用例です。

const verifyFrames = buildVerificationFrames({
  durationInFrames: 90,
  sequences: [
    { from: 0, durationInFrames: 45 },  // Scene A: フレーム0-44
    { from: 45, durationInFrames: 45 }, // Scene B: フレーム45-89
  ],
  animationRanges: [
    { start: 0, end: 20 },   // フェードイン区間
    { start: 45, end: 65 },  // Scene Bのスライドイン区間
  ],
});
// → [0, 1, 10, 11, 44, 45, 57, 58, 89]
// 9フレームで3秒の動画の主要な状態変化をすべてカバーする

ゴールデンレコードとピクセル差分比較

フレーム選択戦略が決まったら、ゴールデンレコード(基準画像)との比較を実装します。初回実行時はゴールデンレコードを生成し、以降の実行では差分ピクセル数が閾値以内に収まるかを確認します。

async function compareWithGolden(
  serveUrl: string,
  compositionId: string,
  frame: number,
  width: number,
  height: number,
  inputProps: Record<string, unknown> = {},
  // 許容差分ピクセル数(アンチエイリアスによる微小変動を許す)
  maxDiffPixels = 50
): Promise<void> {
  const goldenPath = join(GOLDEN_DIR, compositionId, `frame-${frame}.png`);
  const captured = await captureFrame(
    serveUrl, compositionId, frame, width, height, inputProps
  );

  if (!existsSync(goldenPath)) {
    mkdirSync(join(GOLDEN_DIR, compositionId), { recursive: true });
    const png = new PNG({ width, height });
    png.data = captured.data;
    writeFileSync(goldenPath, PNG.sync.write(png));
    console.log(`[GOLDEN] Created: ${goldenPath}`);
    return;
  }

  const goldenData = PNG.sync.read(readFileSync(goldenPath)).data;

  // diff 画像を保存しておくと失敗時のデバッグが楽になる
  const diffPng = new PNG({ width, height });
  const diffCount = pixelmatch(
    goldenData,
    captured.data,
    diffPng.data,
    width,
    height,
    {
      threshold: 0.1,    // 0.0(完全一致)〜1.0(差分を無視)
      includeAA: false,  // アンチエイリアスピクセルを差分としてカウントしない
    }
  );

  if (diffCount > maxDiffPixels) {
    const diffPath = join(GOLDEN_DIR, compositionId, `diff-frame-${frame}.png`);
    writeFileSync(diffPath, PNG.sync.write(diffPng));
    throw new Error(
      `Frame ${frame} diff: ${diffCount} pixels exceed threshold ${maxDiffPixels}. ` +
        `Diff saved to ${diffPath}`
    );
  }
}

threshold: 0.1 はピクセル単位の色差判定閾値で、0.0 は完全一致、1.0 は色差を事実上無視します。フォントレンダリングのサブピクセル差異を許容しつつ実際のアニメーション変化を検出するためには 0.1 前後が実用的な値です。maxDiffPixels = 50 は1920×1080の解像度では全ピクセルの0.003%未満であり、色収差やアンチエイリアスの誤差を吸収しながらも明確な変化を確実に捕捉します。


propsのラウンドトリップ検証

AIエージェントがpropsを正しく注入できているかを、レンダリング結果から確認します。テキストのOCRは実装コストが高いため、「渡した背景色が実際のフレームに描画されているか」を平均色で照合するアプローチが軽量で有効です。

function extractAverageColor(
  pixelData: Buffer,
  imageWidth: number,
  x: number,
  y: number,
  sampleW: number,
  sampleH: number
): { r: number; g: number; b: number } {
  let r = 0, g = 0, b = 0, count = 0;

  for (let row = y; row < y + sampleH; row++) {
    for (let col = x; col < x + sampleW; col++) {
      const idx = (row * imageWidth + col) * 4; // RGBA
      r += pixelData[idx];
      g += pixelData[idx + 1];
      b += pixelData[idx + 2];
      count++;
    }
  }

  return {
    r: Math.round(r / count),
    g: Math.round(g / count),
    b: Math.round(b / count),
  };
}

async function assertPropsReachCanvas(
  serveUrl: string,
  compositionId: string,
  width: number,
  height: number,
  inputProps: { backgroundColor: string } & Record<string, unknown>
): Promise<void> {
  const frame = await captureFrame(
    serveUrl, compositionId, 0, width, height, inputProps
  );

  const hex = inputProps.backgroundColor.replace("#", "");
  const expectedR = parseInt(hex.slice(0, 2), 16);
  const expectedG = parseInt(hex.slice(2, 4), 16);
  const expectedB = parseInt(hex.slice(4, 6), 16);

  // 中央付近の200×200ピクセル領域でサンプリング
  const actual = extractAverageColor(
    frame.data, width,
    Math.floor(width / 2) - 100, Math.floor(height / 2) - 100,
    200, 200
  );

  // tolerance: 15 はガンマ補正・アルファ合成による誤差を許容する
  const tolerance = 15;
  const ok =
    Math.abs(actual.r - expectedR) <= tolerance &&
    Math.abs(actual.g - expectedG) <= tolerance &&
    Math.abs(actual.b - expectedB) <= tolerance;

  if (!ok) {
    throw new Error(
      `Props not reaching canvas. Expected rgb(${expectedR},${expectedG},${expectedB}), ` +
        `got rgb(${actual.r},${actual.g},${actual.b}). ` +
        `Check that defaultProps is not overriding inputProps.`
    );
  }
}

エラーメッセージに defaultProps への言及を入れているのは意図的です。この検査が失敗するほとんどのケースが「props注入コードの欠落 → defaultPropsへのフォールバック」であるため、開発者がすぐに調査箇所を絞り込めます。


パイプラインの統合とCI組み込み

上記の関数を1つのエントリポイントにまとめます。

async function runRegressionSuite(
  spec: CompositionSpec,
  animationRanges: Array<{ start: number; end: number }>,
  sequences: Array<{ from: number; durationInFrames: number }>,
  testProps: { backgroundColor: string } & Record<string, unknown>
): Promise<void> {
  const serveUrl = await buildBundle();
  const { id, width, height, durationInFrames } = spec;

  console.log(`\n▸ ${id}: メタデータ検証`);
  await verifyCompositionMeta(serveUrl, spec);

  console.log(`▸ ${id}: アニメーション凍結チェック`);
  for (const range of animationRanges) {
    await assertMotionExists(serveUrl, id, width, height, range, testProps);
  }

  console.log(`▸ ${id}: propsラウンドトリップ検証`);
  await assertPropsReachCanvas(serveUrl, id, width, height, testProps);

  const frames = buildVerificationFrames({
    durationInFrames,
    sequences,
    animationRanges,
  });

  console.log(`▸ ${id}: ゴールデン比較 (${frames.length}フレーム)`);
  for (const frame of frames) {
    await compareWithGolden(serveUrl, id, frame, width, height, testProps);
  }

  console.log(`✓ ${id}: 全チェック通過\n`);
}

runRegressionSuite(
  { id: "TitleCard", width: 1920, height: 1080, fps: 30, durationInFrames: 90 },
  [{ start: 0, end: 20 }, { start: 45, end: 65 }],
  [{ from: 0, durationInFrames: 45 }, { from: 45, durationInFrames: 45 }],
  { backgroundColor: "#1a1a2e", title: "Test Title" }
).catch((err) => {
  console.error(err.message);
  process.exit(1);
});

GitHub ActionsなどのCI環境では、Chromiumが事前に必要です。npx remotion install chromium をステップに追加するか、chromiumExecutablePath オプションでシステムのChrome実行ファイルパスを明示してください。ゴールデンレコード(test/golden/)はリポジトリにコミットしておき、AIエージェントがコンポジションを更新した際は意図的な変更のみ --update-golden フラグで更新する運用が堅牢です。


まとめ

AIエージェントが生成したRemotionコンポジションを品質ゲートに通すには、3つの検証層が必要です。

  1. メタデータ層では、getCompositions() でwidth・height・fps・durationInFramesが仕様と一致しているかを確認します。FPSのずれはすべてのタイミング計算に波及します。
  2. モーション層では、隣接フレームの差分が0でないことを確認し、アニメーション凍結を検出します。静止点のサンプリングだけでは動きを保証できません。
  3. ゴールデン層では、pixelmatchで実際のフレームをゴールデンレコードと比較し、視覚的なリグレッションを検出します。

フレーム選択の核心は「Sequenceの境界とアニメーション区間の中間点を組み合わせるイベント位相アプローチ」です。等間隔の静止点ではなく、状態遷移が発生するフレームを狙い撃ちすることで、9フレーム程度の少ないキャプチャ数でも3秒の動画の主要な破綻を検出できます。

RenderCompカタログのような制作グレードのコンポジションは、このパイプラインを前提に設計されています。propsの型が厳密に定義され、アニメーション区間がコンポジションの仕様書として記述されていれば、AIエージェントとの反復サイクルの速度が格段に上がります。

販売中

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

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

料金プランを見る →