Frame-Level Visual Regression Testing for Remotion Compositions
By RenderComp Team Editorial policy
Remotion’s core premise is that video is a pure function of a frame index, and that is exactly what makes visual regression testing tractable. Unlike a browser UI that depends on timers, network, or system clock, a Remotion composition evaluated at frame 42 returns the same pixels every time, on any machine, given identical input props and composition config. That determinism is free; you should be capturing it.
The naive approach is to render the full video and compare MP4 files. That breaks immediately: video encoders are not deterministic across hardware, codec versions, or OS. Two semantically identical renders produce different files. The fix is to test at the level Remotion actually computes: individual PNG frames rendered with renderStill. Frame images are pixel-exact. If your animation changed, the diff is unambiguous, with no encoder noise and no bitrate artifacts.
This article walks through a complete setup: a Jest global bundle phase, a renderStill-based frame helper, a pixelmatch comparison harness, and a frame-selection strategy that survives spring() convergence behavior. All code is runnable against a real project.
Install Dependencies
You need @remotion/bundler and @remotion/renderer from the same version as your project, plus pixelmatch and pngjs for PNG comparison.
npm install --save-dev @remotion/bundler @remotion/renderer pixelmatch pngjs @types/pixelmatch @types/pngjs
The @remotion/bundler package is separate from the CLI. It exposes bundle(), which produces a Webpack artifact the renderer reads. This is the same process the Remotion Studio runs internally; you’re just invoking it directly in your test harness.
Bundle Once with Jest Global Setup
Bundling a Remotion project takes 10 to 40 seconds depending on composition count and asset size. Running it inside each test file would make your suite unusable. Instead, build the bundle once in Jest’s globalSetup hook and pass the output path to test workers via a temp file.
// test-utils/global-setup.ts
import { bundle } from '@remotion/bundler';
import fs from 'fs';
import path from 'path';
const BUNDLE_PATH_FILE = path.resolve('.remotion-test-bundle');
export default async function globalSetup(): Promise<void> {
const location = await bundle({
entryPoint: path.resolve('./src/index.ts'),
// Forward your existing webpack customisations unchanged
webpackOverride: (config) => config,
});
// Workers run in separate processes; a file is simpler than IPC
fs.writeFileSync(BUNDLE_PATH_FILE, location, 'utf-8');
}
// test-utils/global-teardown.ts
import fs from 'fs';
import path from 'path';
const BUNDLE_PATH_FILE = path.resolve('.remotion-test-bundle');
export default async function globalTeardown(): Promise<void> {
if (fs.existsSync(BUNDLE_PATH_FILE)) {
fs.unlinkSync(BUNDLE_PATH_FILE);
}
}
Wire these into jest.config.ts:
// jest.config.ts
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
globalSetup: './test-utils/global-setup.ts',
globalTeardown: './test-utils/global-teardown.ts',
testTimeout: 120_000, // renderStill is not instant
};
export default config;
The renderFrame Helper
renderStill requires the full composition metadata object. Passing just an ID string is not enough. selectComposition fetches that metadata from your bundle, including the resolved durationInFrames, fps, width, and height.
// test-utils/renderFrame.ts
import { renderStill, selectComposition } from '@remotion/renderer';
import fs from 'fs';
import path from 'path';
const BUNDLE_PATH_FILE = path.resolve('.remotion-test-bundle');
function getServeUrl(): string {
return fs.readFileSync(BUNDLE_PATH_FILE, 'utf-8').trim();
}
export async function renderFrame(
compositionId: string,
frame: number,
outputPath: string,
inputProps: Record<string, unknown> = {},
): Promise<void> {
const serveUrl = getServeUrl();
// selectComposition also validates that `frame` is within bounds
const composition = await selectComposition({
serveUrl,
id: compositionId,
inputProps,
});
await renderStill({
composition,
serveUrl,
output: outputPath,
frame,
inputProps,
// Increase for compositions that load heavy assets on mount
timeoutInMilliseconds: 30_000,
// Suppress Chromium GPU process logs in test output
chromiumOptions: { disableWebSecurity: false },
});
}
The Comparison Harness
pixelmatch compares two Uint8ClampedArray RGBA buffers and returns the count of differing pixels. The threshold option (between 0 and 1) is a per-channel tolerance: 0.1 means a per-channel difference of more than ~25/255 counts as a changed pixel. For animation regression, 0.1 is reasonable. It absorbs sub-pixel antialiasing differences while catching layout shifts and color changes.
// test-utils/compareFrames.ts
import pixelmatch from 'pixelmatch';
import { PNG } from 'pngjs';
import fs from 'fs';
export interface DiffResult {
numDiffPixels: number;
totalPixels: number;
/** Fraction 0–1 of pixels that differ */
ratio: number;
}
export function compareFrames(
baselinePath: string,
currentPath: string,
diffOutputPath: string,
channelThreshold = 0.1,
): DiffResult {
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(
`Dimension mismatch: baseline ${baseline.width}×${baseline.height} vs current ${current.width}×${current.height}`,
);
}
const { width, height } = baseline;
const diff = new PNG({ width, height });
const numDiffPixels = pixelmatch(
baseline.data,
current.data,
diff.data,
width,
height,
{ threshold: channelThreshold },
);
// Write the diff image so CI artifacts reveal what changed
fs.mkdirSync(path.dirname(diffOutputPath), { recursive: true });
fs.writeFileSync(diffOutputPath, PNG.sync.write(diff));
return { numDiffPixels, totalPixels: width * height, ratio: numDiffPixels / (width * height) };
}
import path from 'path';
Choosing Which Frames to Test
This is where most visual regression setups underperform. Testing only frame 0 and the last frame misses the majority of motion. Testing every frame is overkill and slow. The right checkpoints depend on your animation primitives.
For spring()-driven animations
The spring() function in Remotion converges asymptotically. With the default config (stiffness: 100, damping: 10, mass: 1), the value at 30 fps reaches approximately:
| Frame | spring() value |
|---|---|
| 0 | 0.000 |
| 5 | 0.521 |
| 10 | 0.876 |
| 15 | 0.964 |
| 20 | 0.989 |
| 25 | 0.997 |
| 30 | 0.999 |
The steepest change per frame falls in the 0 to 10 range. Testing frame 8 (mid-ramp) alongside frame 0 and a settled frame (e.g., frame 50 in a 60-frame composition) gives you meaningful coverage. If you only test start and end, a bug that flips an element at frame 5 and recovers by frame 59 passes silently.
// Compute a spring-hot frame for a given config
import { spring } from 'remotion';
function findSpringMidpoint(fps: number, config: SpringConfig): number {
for (let f = 1; f < 120; f++) {
const val = spring({ frame: f, fps, config });
if (val > 0.5) return f; // first frame past 50% — steepest zone
}
return 10; // fallback
}
For interpolate()-driven animations
Test the exact frame at each inputRange boundary. interpolate(frame, [0, 20, 30], [0, 1, 0]) changes character at frames 20 and 30, so those are your checkpoints, plus one frame inside each segment.
The Test Suite
With the helpers in place, a test suite for a TitleCard composition looks like this:
// __tests__/TitleCard.visual.test.ts
import path from 'path';
import fs from 'fs';
import { renderFrame } from '../test-utils/renderFrame';
import { compareFrames } from '../test-utils/compareFrames';
const SNAPSHOTS_DIR = path.resolve('__snapshots__/frames');
const DIFFS_DIR = path.resolve('__snapshots__/diffs');
const UPDATE = process.env.UPDATE_SNAPSHOTS === '1';
// For a 60-frame composition at 30fps:
// 0 = static initial state
// 8 = spring at ~70% (hottest change rate with stiffness:100, damping:10)
// 59 = fully settled final frame
const CHECKPOINTS = [0, 8, 59];
// Lock input props as test fixtures — never use dynamic values
const INPUT_PROPS = {
title: 'Visual Regression Fixture',
subtitle: 'Do not change this string',
accentColor: '#0066ff',
};
// Fail if more than 0.3% of pixels differ
const MAX_DIFF_RATIO = 0.003;
describe('TitleCard visual regression', () => {
beforeAll(() => {
fs.mkdirSync(SNAPSHOTS_DIR, { recursive: true });
fs.mkdirSync(DIFFS_DIR, { recursive: true });
});
test.each(CHECKPOINTS)(
'frame %i matches baseline',
async (frame) => {
const name = `TitleCard-f${String(frame).padStart(3, '0')}.png`;
const baselinePath = path.join(SNAPSHOTS_DIR, name);
const currentPath = path.join(DIFFS_DIR, `current-${name}`);
const diffPath = path.join(DIFFS_DIR, `diff-${name}`);
await renderFrame('TitleCard', frame, currentPath, INPUT_PROPS);
if (UPDATE || !fs.existsSync(baselinePath)) {
fs.copyFileSync(currentPath, baselinePath);
console.log(`Wrote baseline: ${name}`);
return;
}
const { ratio, numDiffPixels, totalPixels } = compareFrames(
baselinePath,
currentPath,
diffPath,
);
expect(ratio).toBeLessThan(MAX_DIFF_RATIO);
if (ratio > 0) {
console.log(`Frame ${frame}: ${numDiffPixels}/${totalPixels} px differ (${(ratio * 100).toFixed(3)}%)`);
}
},
60_000,
);
});
Run the full suite with npx jest --testPathPattern=visual. Update baselines after an intentional design change:
UPDATE_SNAPSHOTS=1 npx jest --testPathPattern=visual
Handling Environment Drift
The most common source of spurious CI failures is font rendering. macOS renders text with subpixel antialiasing by default; Linux (your CI runner) does not. If your compositions use system fonts, a glyph that renders slightly differently between environments will fail the pixel comparison every time.
Two mitigations:
1. Disable subpixel antialiasing in Chromium by passing a flag:
await renderStill({
// ...
chromiumOptions: {
gl: 'swiftshader', // software renderer, consistent across platforms
},
});
2. Relax the threshold in CI only:
const MAX_DIFF_RATIO = process.env.CI ? 0.008 : 0.003;
A ratio ceiling of 0.8% in CI still catches layout shifts, opacity regressions, and wrong colors while absorbing font hinting differences. Tighten it locally once you have a stable baseline set on your primary development machine.
One more footgun: if your composition loads assets asynchronously inside a useEffect or a lazyComponent, renderStill may capture the loading state. Remotion’s delayRender / continueRender pattern prevents this, and you should use it anywhere you fetch or decode assets before painting. If you’re seeing blank frames in test output, a missing continueRender call is the first thing to check.
Discovering All Compositions Automatically
For a project with many compositions, enumerate them at test time rather than listing IDs by hand. getCompositions returns metadata for every composition registered in your bundle:
import { getCompositions } from '@remotion/renderer';
const all = await getCompositions(serveUrl, { inputProps: {} });
// all: Array<{ id, width, height, fps, durationInFrames, defaultProps }>
You can drive test.each from this list, automatically covering every new composition added to the project without touching the test file. The cost is slightly longer test runs when you add compositions; the benefit is that deleted or renamed compositions surface as missing baseline warnings rather than silent gaps.
Wrapping Up
The key moves:
- Bundle once in
globalSetup, sharing the path via file. The 20-second bundle cost amortizes across every test in the suite. - Test the spring-hot zone, not just frame 0 and the last frame. The mid-ramp frames in a spring curve carry the most information about timing bugs.
- Lock input props as typed fixtures. Any dynamic value (including
Date.now()or randomized data) destroys the determinism Remotion’s architecture provides for free. - Write diff images unconditionally. When a test fails in CI, the diff PNG in your artifact storage tells you exactly which pixels changed and where.
- Relax the threshold in CI only. Font hinting variance is real and narrow; treat it as an environment constant rather than trying to eliminate it.
This setup scales naturally to large template libraries. The RenderComp catalog runs dozens of composition variants in parallel; the frame snapshot approach is the only testing method that catches visual regressions before a render ever touches a video encoder.
Now available
Get 1,000+ Remotion Templates
Pay once — no subscription. Lifetime updates. TypeScript-first.
View pricing →