Remotion CI でフレーム差分テストを使ってビジュアルリグレッションを防ぐ
執筆: RenderComp チーム 編集方針
Remotion の核心的な設計思想は「動画をフレームインデックスの純粋関数として扱う」ことです。useCurrentFrame() が返す値が同じであれば、同じコンポーネントは常に同じピクセルを描画する——この保証があるからこそ、Remotion の出力はユニットテスト可能な成果物になります。
しかしコードベースが成長するにつれ、この保証は思わぬ形で崩れます。CSS の z-index を一行変えた、spring() の stiffness をチューニングした、フォントのフォールバック順序が変わった——どれも「コンパイルは通るが映像が変わった」典型例です。コンポーネントのユニットテストでは検出できず、完全なレンダリングを目視確認して初めて気づく種類の変化です。
フレーム差分テスト(frame-diff testing)は、この問題を CI レイヤーで解決します。特定のフレームを PNG として書き出し、承認済みのベースライン画像とピクセル単位で比較する。これだけのことですが、実装には Remotion 固有のいくつかの落とし穴があります。以下では実際に動作する実装を段階的に構築しながら、各決断の根拠を説明します。
テストするフレームの選び方
最初の問いは「どのフレームを比較対象にするか」です。全フレームをテストするのは CI コストとして現実的ではなく、かつほとんどの変化はほんの数フレームに凝縮して表れます。
フレーム 0 と durationInFrames - 1(最終フレーム) は必須です。初期状態と終端状態を押さえることで、コンポーネントのマウント・アンマウント周辺のバグをキャッチできます。
各 <Sequence> の開始フレーム も有効です。from プロパティが定義するオフセットはレイアウトが切り替わるタイミングと一致することが多く、Sequence 境界での差分検出は高い情報密度を持ちます。
spring() アニメーションのピークフレーム は特に重要です。spring() 関数はバネ物理シミュレーションに基づくため、ピーク値(多くの場合オーバーシュートを含む)は stiffness と damping の組み合わせによって決まります。デフォルト値(stiffness: 100, damping: 10)では、ピークはおよそフレーム 8〜12 の範囲に現れます。
ピークフレームをハードコードすると、stiffness を変更した PR でテストが意図しない形でパスし続けます。動的に計算するユーティリティを次のように用意します。
import { spring, SpringConfig } from "remotion";
function findSpringPeakFrame(
config: Partial<SpringConfig>,
fps: number,
searchFrames = 60
): number {
let peakFrame = 0;
let peakValue = -Infinity;
for (let frame = 0; frame < searchFrames; frame++) {
const value = spring({ frame, fps, config });
if (value > peakValue) {
peakValue = value;
peakFrame = frame;
}
}
return peakFrame;
}
// fps=30、stiffness=100、damping=10 の場合は通常フレーム 9 か 10
const peak = findSpringPeakFrame({ stiffness: 100, damping: 10 }, 30);
このユーティリティをテストスクリプト内で呼び出せば、stiffness を変更した PR でピークフレームが自動的にシフトし、差分として確実に検出されます。
renderFrames で PNG を書き出す
特定フレームの画像を得るには renderMedia ではなく @remotion/renderer の renderFrames を使います。renderMedia は完成した動画ファイルを生成しますが、フレーム差分テストでは個別の PNG だけが必要です。renderFrames は中間フレームをディレクトリに直接書き出す低レイヤー API です。
import { renderFrames, selectComposition } from "@remotion/renderer";
import fs from "fs";
import path from "path";
const COMPOSITION_ID = "MyVideo";
const FPS = 30;
const DURATION_IN_FRAMES = 90;
// テスト対象フレームを動的に定義
const SNAPSHOT_FRAMES = [
0,
findSpringPeakFrame({ stiffness: 100, damping: 10 }, FPS),
29, // 最初の Sequence 終端(例: from=0, durationInFrames=30)
DURATION_IN_FRAMES - 1,
];
async function captureSnapshots(outputDir: string) {
const bundleLocation = process.env.BUNDLE_PATH!;
fs.mkdirSync(outputDir, { recursive: true });
const composition = await selectComposition({
serveUrl: bundleLocation,
id: COMPOSITION_ID,
inputProps: {},
});
// 非連続フレームは [frame, frame] の範囲指定で個別に書き出す
for (const frame of SNAPSHOT_FRAMES) {
await renderFrames({
composition,
serveUrl: bundleLocation,
outputDir,
frameRange: [frame, frame], // 単一フレームを [start, end] 形式で指定
imageFormat: "png", // ロッシー圧縮を避けるため必須
concurrency: 1, // CI でのメモリ競合を防ぐ
});
console.log(`Captured frame ${frame}`);
}
}
imageFormat: "png" の指定は差分精度のために必須です。"jpeg" を選ぶと、同一フレームを 2 回レンダリングしても JPEG のロッシー圧縮によって微妙なピクセル差が生じ得ます。差分テストには可逆形式の PNG のみを使ってください。
concurrency: 1 は CI 環境での安定性を高めます。Chromium の並列インスタンスが複数走るとメモリ競合が発生し得るため、スナップショット数が少ない用途では直列実行が確実です。スナップショットの取得は動画のフルレンダリングよりずっと速いため、直列実行でも問題になりません。
pixelmatch で差分を検出する
PNG の比較には pixelmatch と pngjs を組み合わせます。pixelmatch はピクセル単位の差異をカウントし、差分をビジュアライズした画像を生成できるシンプルなライブラリです。
import { PNG } from "pngjs";
import pixelmatch from "pixelmatch";
interface DiffResult {
frame: number;
diffPixels: number;
totalPixels: number;
diffRatio: number;
}
async function compareSnapshots(
baselineDir: string,
currentDir: string,
diffDir: string,
threshold = 0.1
): Promise<DiffResult[]> {
fs.mkdirSync(diffDir, { recursive: true });
const results: DiffResult[] = [];
for (const frame of SNAPSHOT_FRAMES) {
// renderFrames が書き出すファイル名は 8 桁ゼロ埋め
const filename = `element-${String(frame).padStart(8, "0")}.png`;
const baselinePath = path.join(baselineDir, filename);
const currentPath = path.join(currentDir, filename);
if (!fs.existsSync(baselinePath)) {
// 初回実行: 現在の出力をベースラインとして確定する
fs.copyFileSync(currentPath, baselinePath);
console.log(`Baseline created for frame ${frame}`);
continue;
}
const baseline = PNG.sync.read(fs.readFileSync(baselinePath));
const current = PNG.sync.read(fs.readFileSync(currentPath));
const { width, height } = baseline;
const diff = new PNG({ width, height });
const diffPixels = pixelmatch(
baseline.data,
current.data,
diff.data,
width,
height,
{
threshold, // 色差の許容範囲(0〜1)
includeAA: false, // アンチエイリアスのみの差分は無視
alpha: 0.1,
diffColor: [255, 0, 0],
}
);
if (diffPixels > 0) {
fs.writeFileSync(path.join(diffDir, filename), PNG.sync.write(diff));
}
results.push({
frame,
diffPixels,
totalPixels: width * height,
diffRatio: diffPixels / (width * height),
});
}
return results;
}
threshold: 0.1 はサブピクセルレンダリングやフォントのヒンティングによる微小差分を吸収するための値です。0 に設定するとアンチエイリアス差分まで検出してしまい、OS が変わるたびに誤検知が発生します。逆に 0.3 を超えると実質的な変化を見逃します。プロジェクトの性質に応じて 0.05〜0.15 の範囲で調整してください。
差分画像を diffDir に書き出す処理は「差分がある場合のみ」としています。全フレームに diff PNG を生成すると CI アーティファクトが肥大化するため、変化があったフレームだけを可視化するのが実用的です。
spring アニメーション特有の注意点
spring() は durationInFrames に依存しないよう設計されていますが、コンポジションの総フレーム数を変更すると間接的な影響が出ることがあります。より重要なのは from・to の値を変えたときの挙動です。
import { useCurrentFrame, useVideoConfig, spring, AbsoluteFill } from "remotion";
const MyCard: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// stiffness=150 → 200 に変更するとピークフレームと
// オーバーシュート量が両方変わる
const scale = spring({
frame,
fps,
config: { stiffness: 150, damping: 12 },
from: 0.8,
to: 1.0,
});
return (
<AbsoluteFill
style={{
backgroundColor: "#0f172a",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
transform: `scale(${scale})`,
width: 320,
height: 180,
backgroundColor: "#6366f1",
borderRadius: 16,
}}
/>
</AbsoluteFill>
);
};
from: 0.8, to: 1.0 の設定では、バネのオーバーシュートにより scale が一時的に 1.0 を超えます。stiffness を変えるとそのオーバーシュート量が変化するため、固定フレーム番号でのスナップショットでは変化を見落とす可能性があります。findSpringPeakFrame でピークを動的に求めることで、stiffness 変更の影響を確実にキャッチできます。
CI 環境でのフォント問題
CI ランナー(Ubuntu など)ではシステムフォントの構成がローカル環境と異なるため、テキストを含むコンポジションでフォントレンダリングが変わり得ます。外部 CDN からフォントを読み込む実装は CI でネットワークエラーになる可能性があり、そもそも本番でも障害点です。
フォントは必ず @font-face でローカルの woff2 ファイルを参照してください。staticFile() を使うと Remotion のバンドラーがファイルをコピーするパスを返すため、ローカルでも CI でも同じパスが解決されます。実装は次のとおりです。
import { staticFile, AbsoluteFill } from "remotion";
// public/fonts/ 以下に配置した woff2 を参照する
const fontStyle = `
@font-face {
font-family: "MyFont";
src: url(${staticFile("fonts/MyFont-Regular.woff2")}) format("woff2");
font-display: block;
}
`;
const TitleCard: React.FC<{ text: string }> = ({ text }) => (
<AbsoluteFill
style={{
backgroundColor: "#1e293b",
justifyContent: "center",
alignItems: "center",
}}
>
<style>{fontStyle}</style>
<p
style={{
fontFamily: '"MyFont", "Yu Gothic", "Hiragino Kaku Gothic ProN", sans-serif',
color: "#f8fafc",
fontSize: 64,
margin: 0,
}}
>
{text}
</p>
</AbsoluteFill>
);
font-display: block を指定することで、フォントがロードされるまで描画をブロックし、フォールバックフォントで一瞬レンダリングされた状態がスナップショットに混入するのを防ぎます。
GitHub Actions への組み込み
# .github/workflows/frame-diff.yml
name: Frame Diff Test
on:
pull_request:
branches: [main]
jobs:
frame-diff:
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
- name: Bundle Remotion composition
run: npx remotion bundle src/index.ts --output dist/bundle
- name: Capture snapshots
run: npx tsx scripts/capture-snapshots.ts
env:
BUNDLE_PATH: dist/bundle
- name: Compare against baselines
run: npx tsx scripts/compare-snapshots.ts
- name: Upload diff images on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: frame-diff-results
path: test-output/diff/
retention-days: 14
compare-snapshots.ts の終端では差分率に基づいて終了コードを制御します。diffRatio > 0.001(0.1% 以上の差分)を失敗閾値とするのが実用的な出発点で、次のように実装します。
async function main() {
const results = await compareSnapshots(
"test-output/baseline",
"test-output/current",
"test-output/diff"
);
const failures = results.filter((r) => r.diffRatio > 0.001);
if (failures.length > 0) {
console.error("\n--- Frame diff failures ---");
for (const f of failures) {
console.error(
`Frame ${f.frame}: ${f.diffPixels} px differ` +
` (${(f.diffRatio * 100).toFixed(2)}%)`
);
}
process.exit(1);
}
console.log("All snapshots match.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
アーティファクトに差分画像がアップロードされるため、PR レビュー時に「何が変わったか」を赤いピクセルのビジュアルで確認できます。
ベースラインの更新フロー
意図したビジュアル変更を加えた PR では、ベースラインを更新してコミットする必要があります。npm スクリプトとして用意しておくと手順が明確になります。
{
"scripts": {
"snapshot:update": "BUNDLE_PATH=dist/bundle tsx scripts/capture-snapshots.ts --update-baseline",
"snapshot:test": "BUNDLE_PATH=dist/bundle tsx scripts/compare-snapshots.ts"
}
}
CI でベースラインを自動更新してコミットする構成も技術的には可能ですが、推奨しません。ベースラインは「承認された映像の定義」です。自動更新を許すと、意図しない変化がレビューなしに正規化される恐れがあります。snapshot:update はローカルで手動実行し、生成された PNG をコミットに含めて PR に含める運用を基本としてください。
まとめ
Remotion の決定論的なフレーム計算という特性は、フレーム差分テストと非常に相性が良いです。実装上の要点を以下にまとめます。
フレーム選定は 0・各 <Sequence> 開始・spring() のピーク・最終フレームを基本とします。spring ピークは findSpringPeakFrame で動的に計算し、stiffness 変更を確実に検出します。
renderFrames は imageFormat: "png" と concurrency: 1 の組み合わせで呼び出します。JPEG のロッシー圧縮は誤検知の原因になるため使いません。
pixelmatch の threshold は 0.05〜0.15 の範囲で調整し、includeAA: false でアンチエイリアス差分を除外します。
フォントは外部 CDN を使わず staticFile() でローカルの woff2 を参照し、font-display: block でフォールバック混入を防ぎます。
ベースラインの更新は手動承認フローを維持し、意図しない変化の自動正規化を防ぎます。
RenderComp のテンプレートカタログのようなプロダクション品質の映像コンポーネントでは、このテストスタックを導入することで「リファクタリング後に映像が変わっていた」問題を PR マージ前に確実にキャッチできます。小さな設定変更が映像全体に与える影響を可視化する仕組みは、長期的なコードベースの品質維持に欠かせない投資です。