Remotion フレームスナップショットをビジュアル変更後に正しく更新する
執筆: RenderComp チーム 編集方針
Remotion の根本的な設計思想は「ビデオは関数である」というものです。コンポジションの任意のフレーム f を入力すると、同一の React ツリーが同一のピクセル出力を生成します。副作用もランダム性も時刻依存の処理も持ち込めないため、決定論的な性質を保ちます。この性質により、ユニットテストが自然に成立します。renderStill で特定フレームの PNG を取得してベースラインと比較すれば、「このコンポジションは昨日と同じ見た目か」を機械的に検証できます。
ただし現実には、ビジュアルの変更は頻繁に起きます。フォントサイズを 1px 調整したとき、カラーパレットを更新したとき、アニメーションの easing を変えたとき、意図した変更なのに CI が赤くなります。そのとき必要なのは「どのコンポジションのどのフレームが変わったか」を素早く把握し、変更を確認したうえでベースラインを更新するフローです。
本記事では @remotion/renderer の renderStill を軸に、ピクセル比較ライブラリ pixelmatch と組み合わせたスナップショットテストの実装、そして意図的なビジュアル変更後にベースラインを安全に更新する具体的な手順を解説します。
なぜ Jest/Vitest の toMatchSnapshot ではなく PNG 比較か
React コンポーネントのスナップショットテストでは通常 toMatchSnapshot() を使ってシリアライズされた JSX ツリーを比較します。しかし Remotion コンポジションでこれをやると、interpolate の戻り値や spring の計算結果がフレーム数に依存する数値として現れ、スナップショットは実質的に数値の羅列になります。「テキストが左に 3px ずれた」という変化を人間が読み取ることは不可能です。
PNG 比較はこの問題を解決します。diff 画像を目視すれば変化箇所が一目でわかり、pixelmatch が返す不一致ピクセル数によって「誤差範囲内か」を定量的に判断できます。Remotion の決定論的レンダリングがあるからこそ、PNG 比較が信頼できるテスト手法として成立します。
セットアップ
必要なパッケージをインストールします。@remotion/bundler はプロジェクトを Webpack でバンドルし、@remotion/renderer が実際のフレームを Chromium でレンダリングします。
npm install --save-dev @remotion/bundler @remotion/renderer pixelmatch pngjs
npm install --save-dev @types/pixelmatch @types/pngjs vitest
テストファイルは src/__tests__/snapshots/ 以下に置き、ベースライン PNG は src/__tests__/snapshots/__baseline__/ に Git 管理します。
src/
__tests__/
snapshots/
__baseline__/
MyComp-frame0.png
MyComp-frame30.png
snapshot.test.ts
renderStill でフレームをキャプチャする
renderStill を呼ぶ前に、必ず bundle でプロジェクトをバンドルする必要があります。この手順が抜けがちですが、renderStill は Webpack バンドル済みの serveUrl(ローカルサーバー URL またはディレクトリパス)を受け取る設計になっています。
// src/__tests__/snapshots/helpers/bundle.ts
import { bundle } from "@remotion/bundler";
import path from "path";
let cachedBundleUrl: string | null = null;
// バンドルはテストスイート全体で一度だけ実行する
export async function getBundleUrl(): Promise<string> {
if (cachedBundleUrl) return cachedBundleUrl;
cachedBundleUrl = await bundle({
entryPoint: path.resolve("./src/index.ts"),
// webpackOverride が必要な場合はここに渡す
webpackOverride: (config) => config,
});
return cachedBundleUrl;
}
次に、フレームをキャプチャするヘルパーを実装します。
// src/__tests__/snapshots/helpers/capture.ts
import { getCompositions, renderStill, openBrowser } from "@remotion/renderer";
import type { Browser } from "@remotion/renderer";
import fs from "fs";
import path from "path";
const BASELINE_DIR = path.resolve("src/__tests__/snapshots/__baseline__");
const TEMP_DIR = path.resolve("src/__tests__/snapshots/__temp__");
// ブラウザインスタンスを再利用してオーバーヘッドを削減する
let browserInstance: Browser | null = null;
export async function getBrowser(): Promise<Browser> {
if (!browserInstance) {
browserInstance = await openBrowser("chrome", {
// ヘッドレス環境では --no-sandbox が必要になることがある
chromiumOptions: { disableWebSecurity: false },
});
}
return browserInstance;
}
export async function closeBrowser(): Promise<void> {
if (browserInstance) {
await browserInstance.close();
browserInstance = null;
}
}
export async function captureFrame(
serveUrl: string,
compositionId: string,
frame: number,
outputPath: string,
): Promise<void> {
const compositions = await getCompositions(serveUrl);
const composition = compositions.find((c) => c.id === compositionId);
if (!composition) {
throw new Error(`Composition "${compositionId}" not found`);
}
// frame は 0-indexed で [0, durationInFrames - 1] の範囲内である必要がある
if (frame < 0 || frame >= composition.durationInFrames) {
throw new Error(
`Frame ${frame} is out of range for "${compositionId}" ` +
`(durationInFrames: ${composition.durationInFrames})`,
);
}
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
await renderStill({
composition,
serveUrl,
output: outputPath,
frame,
// PNG は可逆圧縮なので差分比較に向いている
imageFormat: "png",
// scale: 1 が基本。2 にすると Retina 解像度になるが比較コストも上がる
scale: 1,
puppeteerInstance: await getBrowser(),
});
}
キャプチャするフレームの選び方
スナップショットのフレームは「状態が安定しているフレーム」を選ぶことが重要です。一般的な方針を示します。
| 目的 | 推奨フレーム |
|---|---|
| 初期状態の検証 | frame: 0 |
| アニメーション完了後の検証 | durationInFrames - 1 |
| 途中状態の検証 | アニメーションが収束する直前のフレーム |
ただし、spring を使ったアニメーションには注意が必要です(後述)。
ピクセル比較の実装
pixelmatch は 2 枚の PNG のピクセルを比較し、不一致ピクセル数を返します。threshold パラメータが重要で、0.0 は完全一致、1.0 は全ピクセル許容します。アンチエイリアシングによる 1-2px の揺れを許容しつつ意図しない変化を検知するには、0.1 が実用的な出発点です。
// src/__tests__/snapshots/helpers/compare.ts
import pixelmatch from "pixelmatch";
import { PNG } from "pngjs";
import fs from "fs";
import path from "path";
const DIFF_DIR = path.resolve("src/__tests__/snapshots/__diff__");
export interface CompareResult {
mismatchedPixels: number;
totalPixels: number;
mismatchRatio: number;
diffPath: string;
}
export function comparePngs(
baselinePath: string,
currentPath: string,
label: string,
): CompareResult {
const baseline = PNG.sync.read(fs.readFileSync(baselinePath));
const current = PNG.sync.read(fs.readFileSync(currentPath));
if (baseline.width !== current.width || baseline.height !== current.height) {
throw new Error(
`Size mismatch for "${label}": ` +
`baseline ${baseline.width}x${baseline.height} vs ` +
`current ${current.width}x${current.height}. ` +
`コンポジションの width/height/scale が変更された可能性があります。`,
);
}
const { width, height } = baseline;
const diff = new PNG({ width, height });
const mismatchedPixels = pixelmatch(
baseline.data,
current.data,
diff.data,
width,
height,
{
threshold: 0.1,
// アンチエイリアシングを考慮した比較
includeAA: false,
},
);
fs.mkdirSync(DIFF_DIR, { recursive: true });
const diffPath = path.join(DIFF_DIR, `${label}.diff.png`);
fs.writeFileSync(diffPath, PNG.sync.write(diff));
return {
mismatchedPixels,
totalPixels: width * height,
mismatchRatio: mismatchedPixels / (width * height),
diffPath,
};
}
スナップショットテストの本体
UPDATE_SNAPSHOTS 環境変数が true のとき、比較をスキップしてベースラインを上書きします。このパターンにより、テストコードを変えずにモードを切り替えられます。
// src/__tests__/snapshots/snapshot.test.ts
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getBundleUrl } from "./helpers/bundle";
import { captureFrame, closeBrowser } from "./helpers/capture";
import { comparePngs } from "./helpers/compare";
import fs from "fs";
import path from "path";
const BASELINE_DIR = path.resolve("src/__tests__/snapshots/__baseline__");
const TEMP_DIR = path.resolve("src/__tests__/snapshots/__temp__");
const UPDATE = process.env.UPDATE_SNAPSHOTS === "true";
// 検証するコンポジションとフレームの一覧
const SNAPSHOT_TARGETS = [
{ compositionId: "Intro", frames: [0, 30, 59] },
{ compositionId: "OutroCard", frames: [0, 15] },
] as const;
// 許容する不一致ピクセルの上限(全ピクセルの 0.05%)
const MISMATCH_THRESHOLD_RATIO = 0.0005;
let serveUrl: string;
beforeAll(async () => {
serveUrl = await getBundleUrl();
fs.mkdirSync(BASELINE_DIR, { recursive: true });
fs.mkdirSync(TEMP_DIR, { recursive: true });
}, 60_000); // バンドルに時間がかかるのでタイムアウトを延ばす
afterAll(async () => {
await closeBrowser();
});
for (const { compositionId, frames } of SNAPSHOT_TARGETS) {
describe(compositionId, () => {
for (const frame of frames) {
const label = `${compositionId}-frame${frame}`;
it(label, async () => {
const baselinePath = path.join(BASELINE_DIR, `${label}.png`);
const currentPath = path.join(TEMP_DIR, `${label}.png`);
// 現在のフレームをキャプチャ
await captureFrame(serveUrl, compositionId, frame, currentPath);
if (UPDATE || !fs.existsSync(baselinePath)) {
// ベースラインが存在しない場合、または更新モードの場合は上書き
fs.copyFileSync(currentPath, baselinePath);
console.log(`✓ Baseline updated: ${label}`);
return;
}
// 比較
const result = comparePngs(baselinePath, currentPath, label);
expect(
result.mismatchRatio,
`"${label}" に予期しない差分があります。\n` +
`不一致ピクセル数: ${result.mismatchedPixels} / ${result.totalPixels} ` +
`(${(result.mismatchRatio * 100).toFixed(3)}%)\n` +
`diff 画像: ${result.diffPath}\n` +
`意図した変更の場合は UPDATE_SNAPSHOTS=true npx vitest で更新してください。`,
).toBeLessThanOrEqual(MISMATCH_THRESHOLD_RATIO);
}, 30_000);
}
});
}
Spring アニメーションと「安定フレーム」の落とし穴
spring 関数はバネ物理演算に基づいており、理論上は終端値(通常 1)に漸近しますが、有限のフレーム数では収束しきらないケースがあります。たとえば以下のパラメータでは、フレームによって微妙に異なる値が返されます。
// MyComp.tsx
import { useCurrentFrame, spring, interpolate, AbsoluteFill } from "remotion";
export const MyComp: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// damping: 12, stiffness: 100, mass: 1 の場合
// frame=20 で約 0.9991、frame=30 で約 0.9999 程度になる
const progress = spring({
frame,
fps,
config: { damping: 12, stiffness: 100, mass: 1 },
});
const x = interpolate(progress, [0, 1], [0, 200]);
return (
<AbsoluteFill>
<div style={{ transform: `translateX(${x}px)` }}>Hello</div>
</AbsoluteFill>
);
};
frame=29 と frame=30 では x の値が 199.97px と 199.99px のように異なる可能性があります。この差は 1px 未満ですが、サブピクセルレンダリングの関係でピクセル比較に影響します。
安定フレームを特定する方法
スナップショット用フレームを選ぶ前に、spring の値が実用上収束するフレームを確認しましょう。
// scripts/check-spring.ts — Remotion プロジェクト外で実行する純粋な計算スクリプト
import { spring } from "remotion";
const fps = 30;
const config = { damping: 12, stiffness: 100, mass: 1 };
for (let frame = 0; frame <= 60; frame++) {
const value = spring({ frame, fps, config });
if (Math.abs(value - 1) < 0.001) {
console.log(`Frame ${frame}: ${value.toFixed(6)} ← ここから先は安定`);
break;
}
console.log(`Frame ${frame}: ${value.toFixed(6)}`);
}
このスクリプトを実行して「0.001 未満の誤差になるフレーム」を特定し、それをスナップショット対象フレームに指定するのが確実です。一般に damping: 12, stiffness: 100 の組み合わせでは 30fps で frame 20 前後が安全な閾値です。
ビジュアル変更後のスナップショット更新フロー
意図したデザイン変更(ブランドカラーの更新など)を行った後の作業手順です。
1. 変更を実装してテストを実行する
# 通常テストで差分を確認する
npx vitest run --reporter=verbose
CI が赤くなったら src/__tests__/snapshots/__diff__/ 以下の diff 画像を確認します。
2. 変更が意図通りか確認してからベースラインを更新する
# 全コンポジションのベースラインを一括更新
UPDATE_SNAPSHOTS=true npx vitest run
特定のコンポジションだけ更新したい場合は Vitest のフィルタを組み合わせます。
# "Intro" コンポジションのスナップショットのみ更新
UPDATE_SNAPSHOTS=true npx vitest run --reporter=verbose -t "Intro"
3. diff を Git でレビューする
# ベースライン PNG の差分を確認する
git diff --stat src/__tests__/snapshots/__baseline__/
# 変更された PNG を視覚的にプレビューするには
# git diff HEAD -- src/__tests__/snapshots/__baseline__/Intro-frame0.png
# を VSCode の Git extension で開くと Before/After が見やすい
更新されたベースライン PNG は変更内容とともに同一コミットに含めます。「デザイン変更」と「スナップショット更新」を別コミットに分けると、後からの調査が難しくなります。
CI/CD での運用
GitHub Actions での設定例です。__baseline__ ディレクトリは Git 管理します。__temp__ と __diff__ は .gitignore に追加します。
# .github/workflows/snapshot.yml
name: Snapshot Tests
on:
pull_request:
push:
branches: [main]
jobs:
snapshot:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
# Chromium の依存ライブラリをインストール
- run: npx playwright install-deps chromium
- name: Run snapshot tests
run: npx vitest run --reporter=verbose
timeout-minutes: 10
# 差分画像を Artifact として保存(失敗時の調査用)
- name: Upload diff artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: snapshot-diffs
path: src/__tests__/snapshots/__diff__/
retention-days: 14
差分が発生して CI が落ちたとき、Artifact から diff 画像をダウンロードして「これは意図した変更か」を確認し、意図通りであればローカルで UPDATE_SNAPSHOTS=true を実行してコミットします。
まとめ
Remotion の決定論的レンダリングは、ピクセルレベルのスナップショットテストを他のフロントエンドツールより信頼性高く実現できます。本記事でカバーした要点を整理します。
bundle→getCompositions→renderStillの 3 ステップがキャプチャの基本フローです。バンドルはテストスイートで 1 回だけ実行してキャッシュします。openBrowserの結果を使い回してブラウザインスタンスを再利用すると、テスト全体の実行時間を短縮できます。springアニメーションは有限フレームで収束しきらないため、スナップショット対象フレームは収束後の安定フレームを選びます。UPDATE_SNAPSHOTS=trueの環境変数パターンを使うと、テストコードを変えずにベースライン更新モードと検証モードを切り替えられます。- ベースライン PNG は Git にコミットし、デザイン変更と同一コミットに含めると変更履歴を追跡しやすくなります。
RenderComp のカタログのような本番グレードのテンプレートでは、このスナップショットテストを @remotion/renderer の renderMedia による統合テストと組み合わせることで、フレーム単位の回帰と実際の動画出力の両方を継続的に保護しています。ピクセル比較のセットアップに数時間かけることで、リグレッションを早期に検知し、「動画が壊れたままデプロイされる」リスクを排除できます。