R RenderComp
remotion playwright testing visual-regression ci

PlaywrightスクリーンショットでRemotionフレームの視覚的回帰を捕捉する

執筆: RenderComp チーム 編集方針

Remotionがビデオ制作においてプログラマブルな強みを発揮できる根本的な理由は、コンポジションのフレームが決定論的であることです。useCurrentFrame() が返す値はフレーム番号であり、時刻・乱数・非同期フェッチといった副作用は持ちません。同じコンポーネント・同じprops・同じフレーム番号を渡せば、何度実行しても常に同じピクセル出力が得られます。通常のReactコンポーネントにはこの保証がありませんが、Remotionのコンポジションにはあります。

この決定論的な性質により、ビジュアル回帰テストを実用レベルで信頼できる形に実現できます。フレーム番号を固定してレンダリングした出力をベースラインスナップショットと比較すれば、アニメーションパラメータの微妙なずれ、フォント変更、レイアウトの崩れを自動で検出できます。通常のWebアプリのビジュアルテストがタイミングの問題に悩まされるのと異なり、Remotionでは「いつ取るか」ではなく「何フレーム目を取るか」に集中できます。

本記事では、@remotion/rendererrenderFrames を使ってPNGを書き出すアプローチと、@remotion/player をPlaywrightで操作して特定フレームをスクリーンショットするアプローチの2つを実装します。どちらもCI環境で動作するコードを示します。


テスト対象フレームの選定戦略

90フレームのコンポジションであれば90枚のPNGが生成されますが、隣接するフレーム間の差異はほとんどなく、スナップショットの更新コストだけが増大します。意味のある節目フレームを選定するのが現実的です。

spring() の収束特性と節目フレーム

spring() はデフォルトパラメータ(stiffness: 100, damping: 10, mass: 1)で漸近的に収束します。30fpsで計算すると以下の特性があります。

import { spring, useCurrentFrame, useVideoConfig, AbsoluteFill } from 'remotion';

export function SlideIn() {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // デフォルトパラメータでの収束状況(30fps換算):
  // frame 10: 約55% 収束
  // frame 20: 約87% 収束
  // frame 30: 約97% 収束  ← タイミング変更の影響が最も可視化されやすい
  // frame 45: 約99.8% 収束 ← ほぼ終端状態。frame 46 とピクセルレベルで同一
  const progress = spring({
    frame,
    fps,
    config: {
      stiffness: 100,
      damping: 10,
      mass: 1,
    },
  });

  return (
    <AbsoluteFill
      style={{ transform: `translateX(${(1 - progress) * 120}px)` }}
    />
  );
}

フレーム45と46はピクセルレベルでほぼ同一です。フレーム30付近は変化量が最大になるため、stiffness をわずかに変更した際の影響を最も大きく拾えます。テスト対象フレームとして30を選ぶのはこのためです。

interpolate() の境界点

interpolate() を使う場合、入力範囲の境界フレームを必ず含めるのが原則です。

import { interpolate, useCurrentFrame, AbsoluteFill } from 'remotion';

export function FadeInOut() {
  const frame = useCurrentFrame();

  const opacity = interpolate(
    frame,
    [0, 20, 70, 90], // ← この4点が視覚的変化の境界になる
    [0, 1, 1, 0],
    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
  );

  return <AbsoluteFill style={{ opacity }} />;
}
// → テスト対象フレーム: 0, 20, 70, 89(最終フレーム)

フレーム19は opacity = 0.95、フレーム20は opacity = 1.0 です。この境界を跨いでいるかどうかが重要なので、20を外してはいけません。


アプローチ1: renderFrames で PNG を書き出す

@remotion/rendererrenderFrames を使えば、ブラウザを起動せずにNode.jsから直接フレームをPNGとして書き出せます。CI環境での再現性が高く、実行速度も速いのが特徴です。

// scripts/render-test-frames.ts
import { bundle } from '@remotion/bundler';
import { renderFrames, selectComposition } from '@remotion/renderer';
import path from 'node:path';
import fs from 'node:fs';

const ENTRY_POINT = path.resolve('./src/index.ts');
const SNAPSHOTS_DIR = path.resolve('./tests/snapshots');

// spring() の収束特性に基づいた節目フレーム(30fps・90フレームの場合)
const KEYFRAMES = [0, 20, 30, 89];

async function renderTestFrames() {
  // bundle() の結果はローカルサーバーのURLを返す。
  // 同じ entryPoint なら Webpack キャッシュが効くため2回目以降は高速
  const bundleLocation = await bundle({
    entryPoint: ENTRY_POINT,
  });

  const composition = await selectComposition({
    serveUrl: bundleLocation,
    id: 'MyComposition', // root.tsx で登録した compositionId
    inputProps: {},
  });

  for (const frame of KEYFRAMES) {
    const outputDir = path.join(SNAPSHOTS_DIR, 'actual', `frame-${frame}`);
    fs.mkdirSync(outputDir, { recursive: true });

    await renderFrames({
      composition,
      serveUrl: bundleLocation,
      outputDir,
      inputProps: {},
      // タプル形式で [start, end] を指定。同じ値を渡すと1フレームだけレンダリング
      frameRange: [frame, frame],
      imageFormat: 'png',
      // テスト用はスケールダウンで速度を上げる。1280x720 → 640x360
      scale: 0.5,
      concurrency: 1,
    });

    console.log(`Rendered frame ${frame}`);
  }
}

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

frameRange はタプル [start, end] 形式で連続範囲を指定します。特定の1フレームだけレンダリングしたい場合は [frame, frame] のように同じ値を渡します。出力ファイル名は実際のフレーム番号に基づいた frame-NNNNN.png(5桁ゼロ埋め)形式になります。

Playwright のスナップショット比較と組み合わせる

書き出したPNGをPlaywrightの expect(buffer).toMatchSnapshot() で比較すると、ベースライン管理をPlaywrightのスナップショット機構に統一できます。

// tests/frame-regression.spec.ts
import { test, expect } from '@playwright/test';
import path from 'node:path';
import fs from 'node:fs';

const KEYFRAMES = [0, 20, 30, 89];

test.describe('MyComposition frame regression', () => {
  for (const frame of KEYFRAMES) {
    test(`frame ${frame}`, async () => {
      // render-test-frames.ts で書き出したPNGを読み込む
      // Remotion は実フレーム番号をファイル名に使う(frame-00030.png など)
      const framePadded = String(frame).padStart(5, '0');
      const actualPath = path.resolve(
        `tests/snapshots/actual/frame-${frame}/frame-${framePadded}.png`
      );
      const actualBuffer = fs.readFileSync(actualPath);

      // 初回実行: tests/snapshots/ にベースラインを自動生成
      // 2回目以降: ピクセル差分を計算し threshold を超えると失敗
      expect(actualBuffer).toMatchSnapshot(`my-composition-frame-${frame}.png`, {
        threshold: 0.02,     // 各ピクセルの色差許容値(0〜1)
        maxDiffPixels: 100,  // 許容する差分ピクセル数の絶対上限
      });
    });
  }
});

アプローチ2: Playwright で Player を操作する

@remotion/player を使ったブラウザ上のテストは、フォントレンダリング・CSS・PlayerコンポーネントのReactレイヤーも含めて検証できます。アプローチ1より網羅性が高い反面、ブラウザ起動コストとフレームレンダリングの完了待ちに工夫が必要です。

テストハーネスページ

クエリパラメータ ?frame=N でPlayerを特定フレームに固定するReactページを用意します。

// src/TestHarness.tsx
import { Player, PlayerRef } from '@remotion/player';
import { useEffect, useRef } from 'react';
import { MyComposition } from './MyComposition';

export function TestHarness() {
  const playerRef = useRef<PlayerRef>(null);

  useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    const frame = parseInt(params.get('frame') ?? '0', 10);

    // seekTo() でフレームを指定し、pause() で停止させる
    playerRef.current?.seekTo(frame);
    playerRef.current?.pause();
  }, []);

  return (
    <div data-testid="remotion-player-wrapper">
      <Player
        ref={playerRef}
        component={MyComposition}
        durationInFrames={90}
        compositionWidth={1280}
        compositionHeight={720}
        fps={30}
        style={{ width: 640, height: 360 }}
        loop={false}
      />
    </div>
  );
}

フレームレンダリングの完了を確実に検知する

seekTo() 呼び出し後、Playerの内部ではReactの再レンダリングサイクルが走ります。waitForTimeout(500) のような固定時間待ちはCI環境のCPU負荷によって不安定になります。コンポジション側が現在フレームをDOM属性として公開する方法を使います。

// src/MyComposition.tsx
import { useCurrentFrame, AbsoluteFill } from 'remotion';

export function MyComposition() {
  const frame = useCurrentFrame();

  return (
    // data-current-frame 属性を Playwright の待機条件として使う。
    // Remotion の再レンダリングサイクルが完了したタイミングで属性値が変わる
    <AbsoluteFill
      data-current-frame={frame}
      style={{ background: '#0f0f1a', width: '100%', height: '100%' }}
    >
      {/* コンポジションの内容 */}
    </AbsoluteFill>
  );
}

Playwright テストの実装

// tests/player-regression.spec.ts
import { test, expect } from '@playwright/test';

const BASE_URL = 'http://localhost:5173'; // Vite 開発サーバー
const KEYFRAMES = [0, 20, 30, 89];

for (const frame of KEYFRAMES) {
  test(`MyComposition player - frame ${frame}`, async ({ page }) => {
    await page.goto(`${BASE_URL}/test-harness?frame=${frame}`);

    // data-current-frame が URL の frame 値と一致するまでポーリング。
    // polling: 100 は 100ms 間隔で DOM 属性を確認する
    await page.waitForFunction(
      (expectedFrame: number) => {
        const el = document.querySelector('[data-current-frame]');
        return (
          el !== null &&
          el.getAttribute('data-current-frame') === String(expectedFrame)
        );
      },
      frame,
      { timeout: 10_000, polling: 100 }
    );

    // Player ラッパーだけをスクリーンショット(ページ全体ではない)
    const wrapper = page.locator('[data-testid="remotion-player-wrapper"]');
    await expect(wrapper).toHaveScreenshot(
      `my-composition-player-frame-${frame}.png`,
      {
        threshold: 0.02,
        maxDiffPixels: 100,
      }
    );
  });
}

polling: 100 を指定すると100ms間隔でDOM属性を確認します。フレームレンダリングが完了していない状態でスクリーンショットを取るリスクをなくせます。


CI 環境での設定

フォントレンダリングの差異への対処

macOSのローカル環境とGitHub ActionsのUbuntu環境では、Chromiumのサブピクセルアンチエイリアスが異なります。日本語フォントを使っている場合、この差異は特に顕著になります。ベースラインスナップショットはCI環境で生成することを強く推奨します。

# .github/workflows/visual-regression.yml
name: Visual Regression Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npx playwright install --with-deps chromium

      # アプローチ1: renderFrames による PNG 生成
      - run: npx tsx scripts/render-test-frames.ts

      # アプローチ2: Playwright ブラウザテスト用の開発サーバー起動
      - run: npm run dev &
      - run: npx wait-on http://localhost:5173 --timeout 30000

      - run: npx playwright test

      # 差分が出た場合にアーティファクトとして保存
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

スナップショットの更新

テンプレートを意図的に変更した場合は --update-snapshots でベースラインを再生成します。このコマンドはCI環境で実行してコミットするのが安全です。

# ローカルで更新(macOS と CI 環境では結果が変わる可能性があることに注意)
npx playwright test --update-snapshots

# 特定ファイルだけ更新
npx playwright test --update-snapshots tests/frame-regression.spec.ts

2つのアプローチの使い分け

観点renderFramesPlaywright + Player
再現性高(Chromiumバージョン非依存)中(Chromiumバージョンで変動あり)
テスト対象Remotionレンダリング出力のみCSS・フォント・Playerレイヤーも含む
実行速度速い(ヘッドレスJSで直接レンダリング)遅い(ブラウザ起動 + seek + 安定待ちが必要)
セットアップNode.jsのみ開発サーバー + Playwright環境が必要
適したケースアニメーションロジックの回帰Playerの統合・UIスタイルの回帰

実プロジェクトでは両方を組み合わせるのが実用的です。アニメーションロジックの確認には renderFrames、最終的なPlayerとしての見え方の確認にはPlaywrightと使い分けると、テストの信頼性とメンテナンス性のバランスが取れます。


まとめ

Remotionのフレームが決定論的であるという性質は、視覚的回帰テストをWebアプリケーションより構造的に実装できることを意味します。実践で押さえるべき要点は3つです。

  1. 節目フレームの選定では、spring() の収束境界(30fps換算でフレーム25〜35付近)や interpolate() の入力範囲境界など視覚的変化の大きい点を選ぶ。全フレームのテストは過剰になる。
  2. フレームレンダリングの完了検知には waitForTimeout でなく data-current-frame 属性 + waitForFunction によるDOM経由のポーリングを使う。
  3. ベースラインスナップショットはCI環境で生成する。フォントアンチエイリアスの差異があるため、ローカルmacOSのスナップショットをそのままCI基準にしない。

RenderCompのカタログに収録されているようなパラメータ駆動のテンプレートでは、propsの組み合わせが多岐にわたります。このフレーム回帰テストを組み込むことで、テンプレートの更新がどのコンポジションに影響するかをPRのレビューサイクルで自動的に追跡できます。

販売中

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

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

料金プランを見る →