R RenderComp
remotion testing visual-regression typescript jest

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:

Framespring() value
00.000
50.521
100.876
150.964
200.989
250.997
300.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 →