R RenderComp
remotion ci testing visual-regression ssim

Remotion CIのビジュアルリグレッション:SSIMとピクセル差分

執筆: RenderComp チーム 編集方針

Remotionの設計思想の核心は「ビデオをReactコンポーネントとして決定論的に計算する」という点にあります。同じコードベース、同じフレーム番号、同じuseVideoConfig()の値からは、常に同一のピクセル列が生成されます。これはJestのスナップショットテストと同じ保証をビデオレンダリングに持ち込める、という意味です。

しかし実際のプロジェクトでは、spring()stiffnessを1ポイント調整したり、interpolate()extrapolateRightclampからextendに変えたりした際に、意図しないビジュアルリグレッションが静かに忍び込みます。型チェックもユニットテストもこれを検出できません。問題に気づくのは本番レンダリングが走った後、あるいはクライアントが動画を確認した後です。

解決策は、Remotionの決定論的な性質を利用してフレームを画像として取り出し、コード変更の前後で比較することです。比較手法として、性質の異なる2つのアプローチを扱います。ピクセル単位の完全一致を検査するpixelmatchと、人間の視覚特性に近いスコアを返す**SSIM(Structural Similarity Index)**です。この記事では両者の実装と閾値設計のトレードオフ、GitHub ActionsへのCI統合を解説します。


どのフレームをテスト対象にするか

最初に決めるべきことは「何フレーム目をゴールデン画像にするか」です。フレーム0はほとんどの場合、まだアニメーションが始まっておらずコンテンツが空白に近い状態で、テストとしての価値は低いです。

テストすべきフレームは3種類あります。

アニメーション収束点は、spring()アニメーションが目標値に到達した直後のフレームです。Remotionのspringはdamping: 10, stiffness: 100, mass: 1を基準に、概ね30〜45フレーム(1秒〜1.5秒 at 30fps)で安定します。spring({ frame, fps, config: { stiffness: 100, damping: 10 } })の出力が0.995を超えるフレームを事前に計算してピン留めします。

テキスト・グラフィックの安定表示区間は、フェードインや移動アニメーションが終わり、コンテンツが静止している中間フレームです。人間が実際に見る区間であり、ビジュアルリグレッションの影響が最もわかりやすく出ます。

トランジション境界は、<Sequence from={60}>のような切り替え直後のフレームです。合成のオーバーラップやz-indexの重なりに関するバグが出やすい場所です。

フレーム選択は手動で行うのではなく、コンポジションのメタデータとして管理します。

// src/compositions/ProductShowcase/index.ts

export const testFrames = {
  // spring が stiffness:120/damping:14 で収束する計算上のフレーム
  titleSettled: 38,
  // メインビジュアルの静止区間
  mainStill: 75,
  // アウトロートランジションの開始
  outroEntry: 120,
} as const;

export const composition = {
  id: 'ProductShowcase',
  width: 1920,
  height: 1080,
  fps: 30,
  durationInFrames: 180,
} as const;

renderStill()でゴールデンフレームを生成する

@remotion/rendererrenderStill()はサーバーサイドで単一フレームをPNGとして出力するAPIです。renderMedia()の全フレームレンダリングとは異なり、1フレームだけを指定して高速に取り出せます。

まずバンドルとゴールデン画像の生成スクリプトを作ります。

// scripts/update-goldens.ts
import { bundle } from '@remotion/bundler';
import { getCompositions, renderStill } from '@remotion/renderer';
import path from 'path';
import fs from 'fs';

const GOLDEN_DIR = path.resolve(__dirname, '../__goldens__');

async function updateGoldens() {
  // webpackでバンドルしてローカルサーバーURLを取得
  const bundleLocation = await bundle({
    entryPoint: path.resolve(__dirname, '../src/index.ts'),
    // Remotionが内部で使うwebpackOverrideをそのまま通す
    onProgress: (progress) => {
      process.stdout.write(`\rBundling: ${progress}%`);
    },
  });
  console.log('\nBundle complete:', bundleLocation);

  const compositions = await getCompositions(bundleLocation);

  for (const comp of compositions) {
    const testFramesModule = await import(
      `../src/compositions/${comp.id}/index`
    ).catch(() => null);

    if (!testFramesModule?.testFrames) continue;

    const outputDir = path.join(GOLDEN_DIR, comp.id);
    fs.mkdirSync(outputDir, { recursive: true });

    for (const [label, frame] of Object.entries(testFramesModule.testFrames)) {
      const outputPath = path.join(outputDir, `${label}.png`);

      await renderStill({
        composition: comp,
        serveUrl: bundleLocation,
        output: outputPath,
        frame: frame as number,
        // CIと同じ解像度で取り出す。縮小して比較すると差分が潰れる
        scale: 1,
        // PNG品質は最大(可逆圧縮)
        imageFormat: 'png',
      });

      console.log(`  Golden: ${comp.id}/${label} (frame ${frame}) → ${outputPath}`);
    }
  }
}

updateGoldens().catch(console.error);

--update-goldensフラグを渡した場合のみこのスクリプトを実行するパターンが実運用では便利です。ゴールデン画像はGitにコミットしておきます。PNGは可逆圧縮なので、1920×1080の1フレームでも概ね150〜300KBに収まります。


pixelmatchによるピクセル差分テスト

pixelmatchはMapbox製のピクセル比較ライブラリです。2枚のUint8Array(RGBA)を受け取り、差分ピクセル数を返します。速度が速く、差分箇所を可視化したPNGも生成できます。

npm install --save-dev pixelmatch pngjs
// src/__tests__/visual-regression.test.ts
import { bundle } from '@remotion/bundler';
import { renderStill } from '@remotion/renderer';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';
import fs from 'fs';
import path from 'path';

// テスト前に一度だけバンドルする(JestのglobalSetupで行うと効率的)
let bundleUrl: string;

beforeAll(async () => {
  bundleUrl = await bundle({
    entryPoint: path.resolve(__dirname, '../src/index.ts'),
  });
}, 60_000);

describe('ProductShowcase visual regression', () => {
  const COMP_ID = 'ProductShowcase';
  const GOLDEN_DIR = path.resolve(__dirname, '../__goldens__', COMP_ID);
  const DIFF_DIR = path.resolve(__dirname, '../__diffs__', COMP_ID);
  
  // 差分画像の出力先を確保
  beforeAll(() => {
    fs.mkdirSync(DIFF_DIR, { recursive: true });
  });

  // testFramesをそのままループ
  const cases = [
    { label: 'titleSettled', frame: 38 },
    { label: 'mainStill', frame: 75 },
    { label: 'outroEntry', frame: 120 },
  ] as const;

  for (const { label, frame } of cases) {
    test(`frame ${frame} (${label}) matches golden`, async () => {
      const currentPath = path.join(DIFF_DIR, `${label}-current.png`);
      const diffPath = path.join(DIFF_DIR, `${label}-diff.png`);

      // 現在のコードで同じフレームを再レンダリング
      await renderStill({
        composition: {
          id: COMP_ID,
          width: 1920,
          height: 1080,
          fps: 30,
          durationInFrames: 180,
          defaultProps: {},
          props: {},
          defaultCodec: null,
        },
        serveUrl: bundleUrl,
        output: currentPath,
        frame,
        imageFormat: 'png',
      });

      const goldenBuffer = fs.readFileSync(path.join(GOLDEN_DIR, `${label}.png`));
      const currentBuffer = fs.readFileSync(currentPath);

      const golden = PNG.sync.read(goldenBuffer);
      const current = PNG.sync.read(currentBuffer);

      expect(current.width).toBe(golden.width);
      expect(current.height).toBe(golden.height);

      const { width, height } = golden;
      const diff = new PNG({ width, height });

      const numDiffPixels = pixelmatch(
        golden.data,
        current.data,
        diff.data,
        width,
        height,
        {
          // per-pixel のRGB差分の許容度(0〜1)
          // 0.1はサブピクセルレベルのアンチエイリアス差異を吸収する実用値
          threshold: 0.1,
          // アルファ値を比較に含める
          includeAA: false,
          // 差分ピクセルを赤でハイライト(デバッグ用)
          diffColor: [255, 0, 0],
        }
      );

      // 差分画像を常に保存(CIのアーティファクトとして収集する)
      fs.writeFileSync(diffPath, PNG.sync.write(diff));

      const totalPixels = width * height;
      const diffRatio = numDiffPixels / totalPixels;

      // 1920×1080で0.01% = 2073ピクセル
      // フォントヒンティングの差を吸収しつつ明確な変化を検出する実用閾値
      expect(diffRatio).toBeLessThan(0.0001);
    }, 30_000);
  }
});

threshold: 0.1の意味は「隣接するピクセルとのRGB差分がこの値(0〜1スケール)以下なら同一とみなす」です。0に近いほど厳密です。Remotionの静止コンポジションは0.05で十分ですが、CIランナーとローカルでフォントレンダリングエンジンが異なる場合は0.1〜0.15に緩める必要があります。

diffRatioの閾値は解像度に依存します。1920×1080の全ピクセル数は約207万です。0.01%は約207ピクセルに相当し、テキスト1〜2文字分のアンチエイリアス差異を吸収しつつ、ブロック単位の変化(新しいUIエレメントの追加、色の変更)は確実に検出します。


SSIMによる知覚的品質評価

ピクセル単位の完全一致に近い比較を行うpixelmatchは、「少しだけ全体的に明るくなった」「コントラストが微妙に変わった」といった知覚的な変化の検出には不向きです。SSIMはこのギャップを埋めます。

輝度(luminance)、コントラスト(contrast)、構造(structure)の3成分に分解してスコア化します。同一画像のスコアは1.0で、大きく異なる場合は0に近づきます。0.99以上が「実質的に同一」、0.95未満は明確な視覚差異が存在します。

ssim-jsライブラリを使うと、Nodeから直接計算できます。

npm install --save-dev ssim-js
// src/__tests__/ssim-utils.ts
import { ssim } from 'ssim-js';
import { PNG } from 'pngjs';

export interface SSIMResult {
  mssim: number;      // Mean SSIM(0〜1)
  performance: number; // 計算時間(ms)
}

/**
 * 2枚のPNGバッファのSSIMスコアを計算する。
 *
 * ssim-jsはRGBAのImageData形式を期待するため、
 * pngjsのデータをそのまま渡せる。
 * チャンネル数は4(RGBA)固定。
 */
export function computeSSIM(goldenBuf: Buffer, currentBuf: Buffer): SSIMResult {
  const golden = PNG.sync.read(goldenBuf);
  const current = PNG.sync.read(currentBuf);

  if (golden.width !== current.width || golden.height !== current.height) {
    throw new Error(
      `Size mismatch: golden=${golden.width}x${golden.height}, current=${current.width}x${current.height}`
    );
  }

  const width = golden.width;
  const height = golden.height;

  const start = Date.now();

  // ssim-jsはImageData互換のオブジェクトを受け取る
  const result = ssim(
    { data: golden.data, width, height },
    { data: current.data, width, height },
    {
      // ウィンドウサイズ(デフォルト11。大きくすると広域変化に敏感になる)
      windowSize: 11,
      // K1/K2は画像の輝度範囲に対する安定化定数(論文準拠のデフォルト)
      k1: 0.01,
      k2: 0.03,
      // 輝度ビット深度。PNG 8bitなら255
      bitDepth: 8,
      // RGBAの4チャンネルをそれぞれ計算してMeanをとる
      ssim: 'bezkrovny',
    }
  );

  return {
    mssim: result.mssim,
    performance: Date.now() - start,
  };
}

テストへの組み込みは以下のようになります。

// SSIMテストをpixelmatchと並行して実行
test(`frame ${frame} (${label}) SSIM score meets threshold`, async () => {
  const goldenBuffer = fs.readFileSync(path.join(GOLDEN_DIR, `${label}.png`));
  const currentBuffer = fs.readFileSync(path.join(DIFF_DIR, `${label}-current.png`));

  const { mssim, performance } = computeSSIM(goldenBuffer, currentBuffer);

  console.log(`  SSIM[${label}] = ${mssim.toFixed(6)} (${performance}ms)`);

  // 完全静止フレームは0.999以上を要求
  // アニメーション中間フレームは0.995まで緩める
  const threshold = label === 'mainStill' ? 0.999 : 0.995;

  expect(mssim).toBeGreaterThanOrEqual(threshold);
});

SSIMは1920×1080のPNG1枚に対してNode.jsシングルスレッドで50〜150msかかります(CPUに依存)。テストスイート全体を高速に保つには、renderStill()の並列実行とSSIM計算の後回しが効果的です。


閾値設計と2つの手法のトレードオフ

実運用での使い分けは明確です。

検出したい変化pixelmatchSSIM
UIエレメントの追加・削除◎ 確実△ スコアが下がるが場所は不明
テキスト内容の変更◎ 差分画像で一目瞭然△ 構造変化として検出
全体的な明るさ・色調の変化△ 差分ピクセル数が多くなるが場所が散漫◎ 輝度成分として検出
サブピクセルのアンチエイリアス差△ 誤検知しやすい◎ ウィンドウ平均で吸収
圧縮アーティファクト△ 過剰検出◎ 知覚的に許容範囲内であれば通過

pixelmatchのthresholdは、CI環境のフォントレンダリングが決定論的であることを前提に0.05から始め、誤検知が続く場合に0.1〜0.15へ上げます。diffRatioの上限は最初は0.0005(0.05%)程度から様子を見て、プロジェクトのノイズベースラインが確認できてから0.0001まで絞ります。

SSIMのthresholdは、完全静止フレームでは0.999、トランジション境界フレームでは0.995を目安にします。0.99を下回ったら人間の目にも見える差異があると判断して問題ありません。逆に0.9999以上を要求すると、CIランナーのlibjpegバージョン差異などで頻繁にフォールスポジティブが発生します。

両手法を組み合わせる場合、pixelmatchを一次フィルター(高速・差分画像生成)として使い、SSIM失敗時の詳細診断に使うのが効率的です。


差分画像をCI アーティファクトとして収集する

テストが失敗した際に差分画像を参照できない状態は、デバッグを著しく困難にします。GitHub Actionsでのアーティファクト収集を含む設定例です。

# .github/workflows/visual-regression.yml
name: Visual Regression Tests

on:
  pull_request:
    branches: [main]

jobs:
  visual-test:
    runs-on: ubuntu-latest
    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: Install Chromium dependencies
        run: |
          npx remotion install chrome-headless-shell

      - name: Run visual regression tests
        run: npx jest --testPathPattern=visual-regression --runInBand
        env:
          # Chromiumのサンドボックスを無効化(CI環境では必須)
          REMOTION_CHROME_FLAGS: "--no-sandbox --disable-setuid-sandbox"

      # テスト失敗時のみ差分画像をアーティファクトとして保存
      - name: Upload diff artifacts
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-regression-diffs
          path: __diffs__/
          # 差分画像は7日間保持(長期保存は不要)
          retention-days: 7

--runInBandはJestのテストを直列実行するフラグです。renderStill()はChromiumプロセスを起動するため、並列実行するとCI環境でリソース枯渇が起きやすいです。コンポジション数が多い場合はjest --maxWorkers=2で2並列に抑えます。

npx remotion install chrome-headless-shell@remotion/renderer 4.0.200以降で使えるコマンドで、puppeteerchromiumダウンロードの代替として推奨されています。CIのキャッシュに~/.cache/ms-playwrightまたは~/.cache/puppeteerを追加すると2回目以降のダウンロードを省略できます。


ゴールデン画像の更新フロー

ゴールデン画像の更新は意図的な操作として管理します。package.jsonにスクリプトを追加します。

{
  "scripts": {
    "test:visual": "jest --testPathPattern=visual-regression",
    "goldens:update": "ts-node scripts/update-goldens.ts",
    "goldens:diff": "jest --testPathPattern=visual-regression --verbose"
  }
}

PRレビューフローとしては、①機能変更 → ②npm run goldens:updateでゴールデン更新 → ③差分をコミットに含める → ④レビュアーがPNGの差分を視覚確認して承認、という流れが機能します。GitHubはPRのファイル差分でPNGをビジュアル比較できます(「Rich diff」表示)。テキストベースのコードレビューとビジュアルレビューが同一のPRワークフローで完結します。


まとめ

Remotionのレンダリングが決定論的である以上、ビジュアルリグレッションテストは実装コストに見合う確実な投資です。

実践的なポイントをまとめます。

  • テストフレームはアニメーション収束点・静止区間・トランジション境界の3種を選ぶ。フレーム0のような空白フレームはノイズにしかならない。
  • pixelmatchは差分の場所を特定するのに強く、差分画像でのデバッグが速い。threshold: 0.1, diffRatio < 0.0001が実用的な出発点。
  • SSIMは知覚的品質の変化(明るさ・コントラスト・全体的な色調)を捉えるのに向いている。静止フレームで0.999、トランジション付近で0.995を目安に設定する。
  • 差分画像は常にCI アーティファクトとして保存し、失敗時の原因調査コストを下げる。
  • ゴールデン画像の更新はpackage.jsonスクリプトとして明示的に操作し、意図しない更新を防ぐ。

RenderComp カタログのようなプロダクショングレードのテンプレートでは、こうした品質ゲートがコンポジション間のビジュアル一貫性を長期的に保つ基盤になります。テンプレートが増えるほどレグレッションリスクは増大しますが、上記のパターンはその増加に線形にスケールします。

販売中

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

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

料金プランを見る →