BackstopJS × Remotion: フレーム単位のビジュアルリグレッションテスト
執筆: RenderComp チーム 編集方針
Remotion のコアコンセプトは「ビデオは決定論的な関数 f(frame) → pixels である」という一点に尽きます。同じフレーム番号を渡せば、いつ・どこで実行しても同一のピクセル列が返ってくる。この性質が成立しているからこそ、Next.js のスナップショットテストと同じロジックでビジュアルリグレッションテストを組むことができます。再生中の動画をブラウザでキャプチャする方法では、レンダリングタイミングやフレームレートのずれで偽陽性が発生しますが、renderStill() を使えば「フレーム 30 を PNG で出力」という操作は冪等になります。
問題は「その PNG をどう比較するか」です。OpenCV を自前で組み込むのは重く、Jest + jest-image-snapshot は CI でのレポーティングが貧弱です。BackstopJS は pixelmatch ベースのビットマップ差分エンジン・HTML レポート・backstop reference / backstop test / backstop compare のワークフロー CLI を持つ成熟したツールです。ただし本来は「URL をブラウザで開いてスクリーンショット」する設計なので、Remotion と組み合わせるには一工夫が必要です。
本記事では、@remotion/renderer の renderStill() でフレームを書き出し、backstop compare だけを使ってブラウザ起動なしでビットマップ比較を回す構成を解説します。ファイル命名規則の罠、gl オプションの決定論への影響、フレーム選定の考え方、GitHub Actions での並列実行まで実装を追います。
アーキテクチャの選択
BackstopJS のテストフローは大きく「キャプチャ → 比較」に分かれます。backstop test は両方を内部でやりますが、backstop compare は既存のビットマップを比較するだけです。Remotion では「キャプチャ」は renderStill() が担えるので、BackstopJS には「比較」だけやってもらいます。
renderStill() × N フレーム
↓ PNG ファイル群(命名規則を BackstopJS に合わせる)
backstop compare
↓ pixelmatch で diff 計算
↓ HTML レポート生成 + exit code
この分離により、Chromium を二重起動する無駄がなく、Remotion のレンダラーが唯一の「カメラ」になるため偽陽性が減ります。
依存パッケージとディレクトリ構成
npm install --save-dev backstopjs @remotion/renderer @remotion/bundler tsx
tsx は TypeScript スクリプトをトランスパイルなしで実行するために使います。
my-remotion-project/
├── src/
│ └── index.ts # Remotion エントリーポイント
├── public/
│ └── fonts/ # 同梱 woff2 (詳細は後述)
├── backstop_data/
│ ├── bitmaps_reference/ # git 管理 — リファレンスフレーム
│ ├── bitmaps_test/ # .gitignore — テスト実行ごとに再生成
│ │ └── current/
│ └── html_report/ # .gitignore
├── scripts/
│ ├── frame-specs.ts # どのフレームをテストするか定義
│ └── render-frames.ts # renderStill() ラッパー
├── backstop.json
└── remotion.config.ts
bitmaps_reference/ は初回に render-frames.ts で生成してそのまま git に追加します。以降、コンポジションを意図的に変更した場合のみ npm run backstop:approve でリファレンスを更新します。
フレーム仕様の定義
どのフレームをテストするかは frame-specs.ts で一元管理します。
// scripts/frame-specs.ts
export interface FrameSpec {
compositionId: string;
/** 0-indexed フレーム番号 */
frame: number;
/**
* backstop.json の scenario label と 1:1 対応させる。
* BackstopJS がビットマップ名として使う文字列になる。
*/
label: string;
/** renderStill に渡す inputProps (省略時は {}) */
inputProps?: Record<string, unknown>;
}
export const FRAME_SPECS: FrameSpec[] = [
// --- MyVideo (60fps / 5s = 300 frames) ---
{
compositionId: 'MyVideo',
frame: 0,
label: 'MyVideo_frame-000_first',
},
{
compositionId: 'MyVideo',
// spring({ fps: 60, frame: 30, config: { stiffness: 100, damping: 10, mass: 1 } })
// はこのフレームで定常値の 99.9% に到達する
frame: 30,
label: 'MyVideo_frame-030_spring-settled',
},
{
compositionId: 'MyVideo',
frame: 150,
label: 'MyVideo_frame-150_midpoint',
},
{
compositionId: 'MyVideo',
// durationInFrames - 1 = 299
frame: 299,
label: 'MyVideo_frame-299_last',
},
// --- データバリアント: 長文で折り返しレイアウトを検証 ---
{
compositionId: 'MyVideo',
frame: 30,
label: 'MyVideo_frame-030_long-text',
inputProps: {
title: 'あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめも',
},
},
];
spring() のデフォルト設定(stiffness: 100, damping: 10, mass: 1)では 60fps で概ね 30 フレーム目に定常値の 99.9% に到達します。このフレームを選定しておくと、アニメーションカーブを誤って変更したときに差分が出るので有効なリグレッションポイントになります。
renderStill() でフレームを書き出す
ファイル命名規則には重要な注意点があります。BackstopJS は bitmaps_reference/ と bitmaps_test/ 内のファイルを {label}_{viewport-label}.png というパターンで照合します。ビューポートの label を含めないと backstop compare がペアを見つけられず、テストが全件スキップされます。
// scripts/render-frames.ts
import path from 'node:path';
import fs from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { bundle } from '@remotion/bundler';
import { getCompositions, renderStill } from '@remotion/renderer';
import { FRAME_SPECS } from './frame-specs.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, '..');
const ENTRY_POINT = path.join(PROJECT_ROOT, 'src', 'index.ts');
// backstop.json の viewports[0].label と一致させること
const VIEWPORT_LABEL = 'video';
async function main() {
const mode = process.argv[2];
if (mode !== 'reference' && mode !== 'test') {
throw new Error('Usage: tsx render-frames.ts <reference|test>');
}
const outDir =
mode === 'reference'
? path.join(PROJECT_ROOT, 'backstop_data', 'bitmaps_reference')
: path.join(PROJECT_ROOT, 'backstop_data', 'bitmaps_test', 'current');
await fs.mkdir(outDir, { recursive: true });
console.log('Bundling Remotion project...');
const serveUrl = await bundle({
entryPoint: ENTRY_POINT,
// remotion.config.ts で webpackOverride を定義している場合は同じ関数を渡す
});
const compositions = await getCompositions(serveUrl, { inputProps: {} });
for (const spec of FRAME_SPECS) {
const comp = compositions.find((c) => c.id === spec.compositionId);
if (!comp) {
throw new Error(
`Composition "${spec.compositionId}" not found. ` +
`Available: ${compositions.map((c) => c.id).join(', ')}`
);
}
if (spec.frame >= comp.durationInFrames) {
throw new Error(
`Frame ${spec.frame} is out of range for "${spec.compositionId}" ` +
`(durationInFrames=${comp.durationInFrames})`
);
}
// BackstopJS の照合パターン: {label}_{viewport-label}.png
const filename = `${spec.label}_${VIEWPORT_LABEL}.png`;
const output = path.join(outDir, filename);
await renderStill({
composition: comp,
serveUrl,
output,
frame: spec.frame,
imageFormat: 'png',
inputProps: spec.inputProps ?? {},
chromiumOptions: {
// GPU のない CI 環境(Docker / GitHub Actions ubuntu-latest)では
// swiftshader を使わないと WebGL 描画が空になる。
// reference と test で同じ値にすることがピクセル一致の前提条件。
gl: 'swiftshader',
disableWebSecurity: false,
},
// 複雑なコンポジションでタイムアウトする場合は延ばす(デフォルト 30000ms)
timeoutInMilliseconds: 60_000,
});
console.log(` ✓ ${filename} (frame ${spec.frame})`);
}
console.log(`Done. ${FRAME_SPECS.length} frames → ${outDir}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
gl: 'swiftshader' はソフトウェアラスタライザーを強制します。hardware や angle を使うとマシン間でアンチエイリアシングのサブピクセル計算が変わり、0.01% の差分が恒常的に出続けます。reference と test で同じ gl 値を使うことが決定論の担保になります。
backstop.json の設定
{
"id": "remotion-regression",
"viewports": [
{
"label": "video",
"width": 1920,
"height": 1080
}
],
"scenarios": [
{ "label": "MyVideo_frame-000_first", "url": "about:blank" },
{ "label": "MyVideo_frame-030_spring-settled", "url": "about:blank" },
{ "label": "MyVideo_frame-150_midpoint", "url": "about:blank" },
{ "label": "MyVideo_frame-299_last", "url": "about:blank" },
{ "label": "MyVideo_frame-030_long-text", "url": "about:blank" }
],
"paths": {
"bitmaps_reference": "backstop_data/bitmaps_reference",
"bitmaps_test": "backstop_data/bitmaps_test/current",
"html_report": "backstop_data/html_report",
"ci_report": "backstop_data/ci_report"
},
"report": ["browser", "CI"],
"engine": "playwright",
"engineOptions": { "browser": "chromium" },
"asyncCompareLimit": 10,
"misMatchThreshold": 0.1
}
url: "about:blank" はプレースホルダーです。backstop compare はブラウザを起動しないため、このフィールドは実際には使われません。
misMatchThreshold: 0.1 は「0.1%を超えるピクセル差分でテスト失敗」を意味します。Remotion + 同一 gl 設定の組み合わせではブレは事実上ゼロなので、この値で十分に厳格です。アルファブレンドを多用する複雑なコンポジションでは 0.5 程度に緩めることもあります。
paths.bitmaps_test を固定パスにすることで、BackstopJS が通常生成するタイムスタンプ付きサブディレクトリを回避しています。これにより render-frames.ts の書き出し先と backstop compare の読み込み先が一致します。
npm scripts の統合
{
"scripts": {
"backstop:reference": "tsx scripts/render-frames.ts reference",
"backstop:test": "tsx scripts/render-frames.ts test && backstop compare",
"backstop:approve": "tsx scripts/render-frames.ts reference"
}
}
backstop:approve はコンポジションを意図的に変更した後にリファレンスを更新するコマンドです。実行後に bitmaps_reference/ の変更をコミットします。この更新コミットが PR に含まれていない限り、意図しない見た目の変化はマージをブロックします。
フレーム選定の 3 軸戦略
フレームを増やすほどテストは確実になりますが、renderStill() の実行時間も線形に増えます。実用的な選定基準は以下の 3 軸です。
1. 境界フレーム(frame 0 と durationInFrames − 1)
コンポジションの先頭と末尾は必ずカバーします。interpolate の extrapolateLeft: 'clamp' が正しく設定されているかの確認にもなります。
// clamp がない場合、frame -1 相当の値でクランプされず予期しない数値になることがある
const opacity = interpolate(frame, [0, 30], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
frame: 0 のテストで opacity が 0 になっていることを確認しておくと、後から inputRange を誤って変更したときのセーフティネットになります。
2. アニメーション変曲点
spring() が定常値に達するフレームや、interpolate のキーフレーム境界を指定します。変曲点の前後 ±1 フレームも追加すると、タイミングのずれを検出できます。
// spring のパラメーターを変えると定常到達フレームがずれる
// stiffness: 200 なら 60fps で frame ~18 が変曲点になる
const scale = spring({
fps,
frame,
config: { stiffness: 100, damping: 10, mass: 1 },
});
3. データドリブンのバリアント
inputProps で最短・最長のテキスト、境界値の数値を渡したフレームを用意します。テキスト折り返しやレイアウト崩れが最も発見しやすい方法です。
全フレームをカバーしない理由は単純です。durationInFrames: 300 のコンポジションを全フレームテストすると renderStill() が 300 回走り、CI では数十分かかります。変化量の大きいフレームを 5〜10 点選ぶのが現実的なバランスです。
GitHub Actions での CI 構成
# .github/workflows/visual-regression.yml
name: Visual Regression
on:
pull_request:
paths:
- 'src/**'
- 'scripts/frame-specs.ts'
jobs:
regression:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
# BackstopJS が内部で使う Playwright の Chromium を別途インストール
# (Remotion は自前の Chromium を npm install 時にダウンロード済み)
- name: Install Playwright Chromium
run: npx playwright install chromium --with-deps
- name: Run visual regression
run: npm run backstop:test
- name: Upload HTML report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: backstop-report-${{ github.run_number }}
path: backstop_data/html_report/
失敗時に HTML レポートをアーティファクトとしてアップロードすることで、PR レビュー画面から差分画像を直接確認できます。
フォントと決定論の落とし穴
最もはまりやすい問題はフォントのグリフが環境によって変わることです。system-ui や sans-serif を使っているコンポジションは、macOS と Linux(GitHub Actions ubuntu-latest)で描画が変わります。ローカルで取った bitmaps_reference/ が CI で一致しない原因のほぼ全件がこれです。
対策はフォントファイルを public/ に同梱し、staticFile() で参照することです。
// src/compositions/MyVideo.tsx
import { AbsoluteFill, staticFile, useCurrentFrame, spring, useVideoConfig } from 'remotion';
// コンポーネント外でスタイルを構築
const fontFaceCSS = `
@font-face {
font-family: 'MyFont';
src: url('${staticFile('fonts/MyFont-Regular.woff2')}') format('woff2');
font-weight: 400;
font-display: block;
}
`;
export const MyVideo: React.FC<{ title: string }> = ({ title }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const opacity = spring({ fps, frame, config: { stiffness: 100 } });
return (
<AbsoluteFill style={{ background: '#0f172a' }}>
<style>{fontFaceCSS}</style>
<h1
style={{
fontFamily: "'MyFont', 'Yu Gothic', 'Hiragino Kaku Gothic ProN', sans-serif",
color: '#f8fafc',
opacity,
}}
>
{title}
</h1>
</AbsoluteFill>
);
};
font-display: block を指定することで、フォントがロードされる前にテキストが描画されるのを防ぎます。renderStill() の内部では Chromium がフォントをロードするまで待機しますが、block を設定しておくと描画タイミングの非決定論的なブレがなくなります。
Wrapping Up
renderStill() は Remotion の「f(frame) = pixels」モデルを Node.js スクリプトから直接利用できる入口です。この冪等性を前提にすれば、BackstopJS の比較エンジンはブラウザ起動から切り離し、純粋なビットマップ差分ツールとして再利用できます。
実装のポイントをまとめます。
- ファイル命名は
{label}_{viewport-label}.pngにすること。backstop compareはこのパターンでペアを照合する。 gl: 'swiftshader'を reference と test の両方で揃える。マシン間 GPU 差を排除することがピクセル一致の前提条件。- フレーム選定は 3 軸で 5〜10 点。境界フレーム + アニメーション変曲点 + データバリアント。
- フォントは
staticFile()で同梱。システムフォントはクロスプラットフォームの決定論を壊す。 backstop compareだけ使う。Remotion のレンダラーが唯一のカメラになり、ブラウザキャプチャの非決定論的な要素が排除される。
RenderComp のテンプレートカタログのようにコンポジションが複数あるプロジェクトでは、FRAME_SPECS に各コンポジションの代表フレームを追加していくだけでカバレッジが広がります。ビジュアルリグレッションをパイプラインに組み込んでおけば、リファクタリング後の「なんかズレてる」を PR の段階で検出できます。