Remotion で始めるフレームレベル・ビジュアルリグレッションテスト
執筆: RenderComp チーム 編集方針
Remotion のアーキテクチャの核心には「動画=純粋関数」という設計思想があります。const frame = useCurrentFrame() が同じ値を返す限り、コンポジションは必ず同じピクセル列を描画します。この決定論的な性質は、従来の動画ワークフローでは得られなかったテスト可能性をもたらします。mp4 を比較するかわりに、フレーム番号を入力として受け取り、PNG を出力する関数を直接テストします。
ところが実際のプロジェクトでは、spring() の stiffness を微調整したり、interpolate() の入力範囲を 1 フレームずらしたりした際に、意図しない箇所のレイアウトが崩れることがあります。人の目によるレビューは、特に 30fps で数百フレームに及ぶコンポジションでは現実的ではありません。ここでピクセルレベルのビジュアルリグレッションテストが効いてきます。
このガイドでは、@remotion/renderer の renderStill API を軸に、ゴールデンフレームの生成、ピクセル差分検出、CI 統合という流れを、実際のコードで一貫して組み立てます。Playwright や外部スナップショットサービスは使いません。Remotion のバンドラーとレンダラーを直接叩くことで、フレーム単位の精度を保ちながら依存を最小限に抑えます。
テスト対象フレームの選定戦略
全フレームをテストするのは非現実的です。60 秒・30fps のコンポジションなら 1,800 フレームあり、1 フレームのレンダリングに平均 200ms かかるとすると、フルセットで 6 分かかります。代わりに、アニメーションの状態変化が起きるキーフレームを選ぶのが実践的です。
具体的には次の 3 種類のフレームを対象にします。
- フェーズ境界フレームは、
<Sequence from={N}>の開始直前(N-1)、開始(N)、終了(N+durationInFrames-1)の各フレームです。 - スプリング収束フレームは、
spring({ fps, frame, config })が 0.999 を超える最初のフレームです。 - 静止フレームは、アニメーションが収まった後の定常状態で、通常は末尾から 10 フレーム前後です。
以下のユーティリティで、コンポジションのメタデータからこれらのフレームを自動計算できます。
// test/utils/select-keyframes.ts
import { spring } from "remotion";
export function selectKeyframes(
durationInFrames: number,
fps: number,
sequences: Array<{ from: number; durationInFrames: number }>,
springConfig = { stiffness: 200, damping: 26, mass: 1 }
): number[] {
const frames = new Set<number>();
// フェーズ境界
for (const seq of sequences) {
frames.add(Math.max(0, seq.from - 1));
frames.add(seq.from);
frames.add(Math.min(durationInFrames - 1, seq.from + seq.durationInFrames - 1));
}
// スプリング収束フレームを探索
for (let f = 0; f < durationInFrames; f++) {
const value = spring({ fps, frame: f, config: springConfig });
if (value > 0.999) {
frames.add(f);
break; // 収束後は単調なので最初の 1 点だけ記録する
}
}
// 静止フレーム: 末尾から 10 フレーム前
frames.add(Math.max(0, durationInFrames - 10));
return [...frames].sort((a, b) => a - b);
}
環境セットアップ
必要なパッケージをインストールします。@remotion/bundler と @remotion/renderer はすでにプロジェクトに含まれているはずですが、ピクセル差分には pixelmatch と pngjs を追加します。
npm install --save-dev pixelmatch pngjs @types/pngjs vitest
テストランナーは Vitest を使います。Jest でも動きますが、Vitest の ESM ネイティブサポートにより Remotion のモジュールグラフとの相性が良く、タイムアウト設定も柔軟です。
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
// renderStill は Chromium を起動するため 1 テストあたり最低 30s 確保する
testTimeout: 60_000,
hookTimeout: 30_000,
// 並列レンダは後述の concurrency で制御するため、ここはシリアル実行
poolOptions: {
threads: { singleThread: true },
},
},
});
バンドルとコンポジション選択
renderStill に渡す前に、コンポジションを Webpack バンドルし、利用可能なコンポジション一覧から対象を選択する必要があります。この処理はテストスイート全体で一度だけ行い、beforeAll でキャッシュします。
// test/setup.ts
import { bundle } from "@remotion/bundler";
import { selectComposition } from "@remotion/renderer";
import path from "node:path";
let bundleLocation: string;
export async function getBundleLocation(): Promise<string> {
if (bundleLocation) return bundleLocation;
bundleLocation = await bundle({
entryPoint: path.join(process.cwd(), "src/index.ts"),
// 本番ビルドと同じ webpackOverride を渡すこと
// webpackOverride: (current) => customWebpack(current),
});
return bundleLocation;
}
export async function getComposition(
id: string,
inputProps: Record<string, unknown> = {}
) {
const location = await getBundleLocation();
return selectComposition({
serveUrl: location,
id,
inputProps,
});
}
bundle() は初回で数秒かかります。CI では Vitest の --project.cache=true や Jest の --cache を活用してバンドルキャッシュを永続化すると、2 回目以降の実行が高速になります。
ゴールデンフレームの生成
初回実行時に「正解画像」となるゴールデンファイルを生成します。renderStill はフレーム番号を受け取り、指定パスに PNG を書き出します。
// test/visual/generate-golden.ts
import { renderStill } from "@remotion/renderer";
import fs from "node:fs/promises";
import path from "node:path";
import { getBundleLocation, getComposition } from "../setup";
const GOLDEN_DIR = path.join(process.cwd(), "test/visual/__golden__");
export async function generateGolden(
compositionId: string,
frame: number,
inputProps: Record<string, unknown> = {}
): Promise<void> {
await fs.mkdir(GOLDEN_DIR, { recursive: true });
const composition = await getComposition(compositionId, inputProps);
const outputPath = path.join(
GOLDEN_DIR,
`${compositionId}-frame${frame}.png`
);
await renderStill({
composition,
serveUrl: await getBundleLocation(),
output: outputPath,
frame,
// imageFormat は "png" 固定。"jpeg" は非可逆圧縮でピクセル差分が安定しない
imageFormat: "png",
chromiumOptions: {
// ローカル macOS では "angle"、Docker/Linux CI では "swiftshader" が安定
gl: (process.env.REMOTION_GL as "angle" | "swiftshader") ?? "angle",
},
// フォントレンダリングを安定させるためタイムゾーンを固定する
envVariables: { TZ: "UTC" },
});
console.log(`Generated golden: ${outputPath}`);
}
gl: "angle" と gl: "swiftshader" の選択は環境依存です。ローカルと CI で gl オプションを一致させることが、差分ノイズを防ぐ最重要ポイントです。環境変数 REMOTION_GL で切り替えられるようにしておくと、CI の yaml 側だけ変更できて便利です。
ピクセル差分でリグレッションを検出
ゴールデンが生成されたら、テスト本体でリグレッションを検出します。pixelmatch は 2 枚の PNG のピクセルデータを比較し、差分ピクセル数を返します。
// test/visual/composition.test.ts
import { renderStill } from "@remotion/renderer";
import { PNG } from "pngjs";
import pixelmatch from "pixelmatch";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, it, expect, beforeAll } from "vitest";
import { getBundleLocation, getComposition } from "../setup";
import { selectKeyframes } from "../utils/select-keyframes";
const GOLDEN_DIR = path.join(process.cwd(), "test/visual/__golden__");
// ピクセル単位の色差許容量(0-1)。アンチエイリアシングの微妙な差を吸収する
const PIXELMATCH_THRESHOLD = 0.1;
// 全ピクセル中で差分ピクセルが占める割合の上限
const MAX_DIFF_RATIO = 0.001; // 0.1%
describe("HeroTitle コンポジション — ビジュアルリグレッション", () => {
const COMPOSITION_ID = "HeroTitle";
let bundleLocation: string;
beforeAll(async () => {
bundleLocation = await getBundleLocation();
});
const KEYFRAMES = selectKeyframes(
90, // durationInFrames
30, // fps
[
{ from: 0, durationInFrames: 30 }, // タイトルフェードイン
{ from: 30, durationInFrames: 30 }, // サブタイトル
{ from: 60, durationInFrames: 30 }, // CTA
]
);
it.each(KEYFRAMES)(
"frame %i がゴールデンと一致する",
async (frame) => {
const composition = await getComposition(COMPOSITION_ID);
const tmpPath = path.join(
os.tmpdir(),
`vrt-${COMPOSITION_ID}-${frame}.png`
);
await renderStill({
composition,
serveUrl: bundleLocation,
output: tmpPath,
frame,
imageFormat: "png",
chromiumOptions: {
gl: (process.env.REMOTION_GL as "angle" | "swiftshader") ?? "angle",
},
envVariables: { TZ: "UTC" },
});
const goldenPath = path.join(
GOLDEN_DIR,
`${COMPOSITION_ID}-frame${frame}.png`
);
const [goldenBuffer, actualBuffer] = await Promise.all([
fs.readFile(goldenPath),
fs.readFile(tmpPath),
]);
const golden = PNG.sync.read(goldenBuffer);
const actual = PNG.sync.read(actualBuffer);
expect(actual.width).toBe(golden.width);
expect(actual.height).toBe(golden.height);
const diff = new PNG({ width: golden.width, height: golden.height });
const diffPixels = pixelmatch(
golden.data,
actual.data,
diff.data,
golden.width,
golden.height,
{ threshold: PIXELMATCH_THRESHOLD }
);
const totalPixels = golden.width * golden.height;
const diffRatio = diffPixels / totalPixels;
if (diffRatio > MAX_DIFF_RATIO) {
// テスト失敗時に差分画像を保存してデバッグを容易にする
const diffPath = path.join(
os.tmpdir(),
`vrt-diff-${COMPOSITION_ID}-${frame}.png`
);
await fs.writeFile(diffPath, PNG.sync.write(diff));
console.error(`Diff image saved: ${diffPath}`);
}
expect(diffRatio).toBeLessThanOrEqual(MAX_DIFF_RATIO);
await fs.rm(tmpPath, { force: true });
}
);
});
閾値の設計思想
PIXELMATCH_THRESHOLD = 0.1 は 1 ピクセルあたりの色差の許容量です。アンチエイリアシングによる 1-2 ピクセルのぼかし差異はこれで吸収されます。MAX_DIFF_RATIO = 0.001 はグローバルな差分割合の上限で、1920×1080 だと約 2,000 ピクセル分の余裕があります。テキストの 1 文字が崩れれば数百ピクセル以上の差分になるため、この閾値で十分に検出できます。両方の閾値を上げすぎると実際のリグレッションを見逃しますが、下げすぎると環境差異によるフォールスポジティブが増えます。
動的な値を持つコンポジションへの対処
new Date() や Math.random() をコンポジション内で直接呼ぶと、フレームの決定論的性質が崩れます。テスト時に固定値を注入するには inputProps を使います。
// src/compositions/TimestampedTitle.tsx
import { useCurrentFrame, useVideoConfig, AbsoluteFill } from "remotion";
type Props = {
title: string;
// テスト時は固定値を渡す。本番では呼び出し元が Date.now() を渡す
timestamp: number;
};
export const TimestampedTitle: React.FC<Props> = ({ title, timestamp }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const date = new Date(timestamp);
const label = date.toLocaleDateString("ja-JP", {
year: "numeric",
month: "2-digit",
day: "2-digit",
timeZone: "Asia/Tokyo",
});
return (
<AbsoluteFill
style={{
background: "#0f0f0f",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<div style={{ color: "#fff", fontSize: 48, fontFamily: '"Hiragino Kaku Gothic ProN", sans-serif' }}>
{title}
</div>
<div style={{ color: "#888", fontSize: 24, marginTop: 16 }}>
{label}
</div>
</AbsoluteFill>
);
};
テスト側では inputProps に固定の timestamp を渡します。
const composition = await selectComposition({
serveUrl: bundleLocation,
id: "TimestampedTitle",
inputProps: {
title: "テスト用タイトル",
timestamp: 1_700_000_000_000, // 2023-11-14 固定エポック値
},
});
Math.random() に依存するエフェクトがある場合は、シードベースの擬似乱数ジェネレータを使い、シードを inputProps 経由で渡すパターンが定石です。コンポジション側でシードを受け取り、mulberry32 などの軽量な純粋関数で乱数列を生成すれば、フレームの決定論性を維持できます。
calculateMetadata との組み合わせ
calculateMetadata でコンポジションの durationInFrames を動的に決定している場合、selectComposition が解決した後の値を使って selectKeyframes を呼ぶ必要があります。
const composition = await getComposition("DynamicDuration", {
items: ["A", "B", "C"],
});
// calculateMetadata が inputProps を元に解決した実際の長さを使う
const frames = selectKeyframes(
composition.durationInFrames, // ← inputProps に基づいて計算済み
composition.fps,
[]
);
テスト用 inputProps は「最短の合理的なケース」に設定するのがおすすめです。items が空配列だと durationInFrames が 0 になってしまうような実装では、キーフレーム選定ロジックが壊れます。
CI での実行
GitHub Actions での設定例です。ubuntu-latest は GPU を持たないため、REMOTION_GL=swiftshader を指定します。
# .github/workflows/visual-regression.yml
name: Visual Regression
on:
pull_request:
paths:
- "src/**"
jobs:
vrt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- name: Run visual regression tests
run: npx vitest run test/visual
env:
# GPU なし環境では swiftshader が安定する
REMOTION_GL: swiftshader
- name: Upload diff artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: vrt-diffs
path: /tmp/vrt-diff-*.png
retention-days: 7
ゴールデンファイルはリポジトリにコミットします。差分が発生した PR では CI が失敗し、開発者は差分 PNG をアーティファクトとしてダウンロードして確認できます。意図的なデザイン変更の場合はゴールデンを再生成してコミットします。package.json の scripts に以下を登録しておくと便利です。
{
"scripts": {
"test:visual": "vitest run test/visual",
"test:visual:update": "npx ts-node test/visual/generate-golden.ts"
}
}
まとめ
Remotion の決定論的レンダリングは、ビジュアルリグレッションテストを現実的なコストで実現できる数少ない動画ワークフローの一つです。ポイントを整理します。
- テスト対象フレームは、フェーズ境界・スプリング収束・静止状態の 3 カテゴリに絞ります。
glオプション・タイムゾーン・inputPropsを固定し、ローカルと CI の描画を一致させます。- ピクセル単位の色差(
threshold)とグローバルな差分割合(MAX_DIFF_RATIO)は分けて 2 層で設定します。 - テスト失敗時は差分 PNG をアーティファクトとして保存し、人間がレビューできるようにします。
Date.now()やMath.random()はコンポジション内から取り除き、inputPropsで固定値を注入します。
この基盤があれば、RenderComp のカタログにあるような複雑なテンプレートを改修する際も、意図しないリグレッションを自信を持って検出できます。コンポジションが複雑になるほど、フレームレベルのテストが「設計の正確さ」を担保する最後の砦になります。