How to Update Remotion Frame Snapshots After a Visual Change
By RenderComp Team Editorial policy
Remotion’s core promise is that a composition is a pure function: given frame number n, the output pixels are fully determined by your component tree and the props you feed in. No network calls, no Date.now(), no Math.random(). Just a React tree evaluated at a specific moment in synthetic time. That determinism is what makes snapshot testing meaningful: render the same frame twice, and you should get bit-identical PNG files.
In practice, most teams discover snapshot tests the hard way. A designer adjusts a gradient, a developer tweaks letter-spacing, a refactor changes the default font stack, and the preview “looks fine” in the Studio. But twenty compositions share that CSS variable, and three of them now have subtly wrong contrast ratios in their title frames. A snapshot test would have caught all three in CI.
The tricky part isn’t writing the first snapshot. It’s knowing what to do when a snapshot fails after an intentional visual change: how to verify the diff is correct, update the baselines without accidentally blessing a regression, and keep the workflow fast enough that developers don’t start skipping it.
The Rendering Foundation: renderStill from @remotion/renderer
Snapshot tests for Remotion compositions center on renderStill, the same function the CLI calls under the hood when you run npx remotion still. It spins up a headless Chromium instance, seeks to the requested frame, and writes the result to a PNG, JPEG, or WEBP file.
Before calling renderStill, you need a serve URL, a local HTTP server Remotion uses to load your bundle. The bundle function produces that:
// test/helpers/bundle.ts
import path from 'path';
import { bundle } from '@remotion/renderer';
let cachedBundleUrl: string | null = null;
export async function getBundleUrl(): Promise<string> {
if (cachedBundleUrl) return cachedBundleUrl;
cachedBundleUrl = await bundle({
entryPoint: path.resolve('./src/index.ts'),
// Skip source maps in test bundles — they add ~40 % build time
// and stack traces from renderStill are rarely actionable inside tests.
webpackOverride: (config) => config,
});
return cachedBundleUrl;
}
Caching the URL matters: bundle compiles your entire project via webpack and takes 5 to 15 seconds on a typical Remotion project. Sharing a single bundle across all tests in a suite drops total test time from minutes to seconds.
Ensuring Chromium Is Available
renderStill uses the Chromium revision that Remotion downloads at install time. In CI, the postinstall script may not run if you’re restoring from a dependency cache. Call ensureBrowser() in your global test setup:
// jest.globalSetup.ts (or vitest globalSetup)
import { ensureBrowser } from '@remotion/renderer';
export default async function globalSetup() {
// Downloads Chromium only if the expected revision is missing.
// Idempotent — safe to call unconditionally on every CI run.
await ensureBrowser();
}
The swangle GL Backend: The Difference Between Flaky and Reliable
The single most important option in any cross-machine snapshot pipeline is gl: 'swangle':
import { renderStill, selectComposition } from '@remotion/renderer';
await renderStill({
composition,
serveUrl: bundleUrl,
output: outputPath,
frame: 30,
imageFormat: 'png',
chromiumOptions: {
// 'swangle' is Chromium's software WebGL renderer (ANGLE + SwiftShader).
// Hardware GPU outputs can differ by 1–3 pixel values across machines
// due to driver rounding differences, making pixel-exact comparison
// impossible in CI. swangle gives bit-identical output everywhere.
gl: 'swangle',
},
// Give Chromium enough time on slow CI runners — default is 30 000 ms.
timeoutInMilliseconds: 60_000,
});
Without gl: 'swangle', gradients and WebGL-based animations often produce 1 to 3 LSB differences between a Mac M-series laptop and an Ubuntu CI runner. That registers as hundreds of thousands of mismatched pixels in pixelmatch even when the visual result looks identical to the human eye. Software rendering eliminates the entire class of hardware-dependent flakiness.
A Minimal Snapshot Test
With the infrastructure in place, a snapshot test is under 60 lines of TypeScript:
// test/snapshots/IntroSlide.snapshot.test.ts
import fs from 'fs';
import path from 'path';
import { renderStill, selectComposition } from '@remotion/renderer';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';
import { getBundleUrl } from '../helpers/bundle';
const SNAPSHOT_DIR = path.resolve('./test/snapshots/__baselines__');
const DIFF_DIR = path.resolve('./test/snapshots/__diffs__');
async function renderFrame(
bundleUrl: string,
compositionId: string,
frame: number,
outputPath: string,
): Promise<void> {
const composition = await selectComposition({
serveUrl: bundleUrl,
id: compositionId,
// Pass the same props you'd pass in production.
// Snapshot tests with empty props test the default-prop state.
inputProps: {},
});
await renderStill({
composition,
serveUrl: bundleUrl,
output: outputPath,
frame,
imageFormat: 'png',
chromiumOptions: { gl: 'swangle' },
timeoutInMilliseconds: 60_000,
});
}
function compareWithBaseline(
baselinePath: string,
actualPath: string,
diffPath: string,
): number {
const baseline = PNG.sync.read(fs.readFileSync(baselinePath));
const actual = PNG.sync.read(fs.readFileSync(actualPath));
const { width, height } = baseline;
const diff = new PNG({ width, height });
const mismatchedPixels = pixelmatch(
baseline.data,
actual.data,
diff.data,
width,
height,
{
// 0.1 tolerates sub-pixel antialiasing differences without masking
// real color or layout shifts. For text-heavy compositions, 0.05
// is more appropriate — font rasterisation differences are rare
// when swangle is used consistently.
threshold: 0.1,
alpha: 0.3, // dim unchanged pixels so changed regions stand out
},
);
if (mismatchedPixels > 0) {
fs.mkdirSync(DIFF_DIR, { recursive: true });
fs.writeFileSync(diffPath, PNG.sync.write(diff));
}
return mismatchedPixels;
}
describe('IntroSlide snapshots', () => {
let bundleUrl: string;
beforeAll(async () => {
bundleUrl = await getBundleUrl();
fs.mkdirSync(SNAPSHOT_DIR, { recursive: true });
}, 30_000);
test.each([
{ frame: 0, label: 'initial' },
{ frame: 30, label: 'mid-entrance' },
{ frame: 89, label: 'hold' },
])('frame $frame ($label)', async ({ frame, label }) => {
const actualPath = path.join(DIFF_DIR, `IntroSlide-${label}-actual.png`);
const baselinePath = path.join(SNAPSHOT_DIR, `IntroSlide-${label}.png`);
const diffPath = path.join(DIFF_DIR, `IntroSlide-${label}-diff.png`);
await renderFrame(bundleUrl, 'IntroSlide', frame, actualPath);
// First run: no baseline yet — write it and pass unconditionally.
if (!fs.existsSync(baselinePath)) {
fs.copyFileSync(actualPath, baselinePath);
return;
}
const mismatched = compareWithBaseline(baselinePath, actualPath, diffPath);
// Zero tolerance with swangle — allow exactly 0 mismatched pixels.
expect(mismatched).toBe(0);
}, 90_000);
});
Which Frames to Snapshot
Choosing frames strategically gives more coverage per test-second. At 30 fps, a 3-second composition has 90 frames. These four keypoints cover nearly every distinct visual state:
- Frame 0 catches initialization: whether the background is the right color, whether default props are applied, and whether the composition starts at the correct position.
- The peak of the entrance animation is where
spring-driven motion tends to settle. For aspringwithdurationInFrames: 45andstiffness: 80, frame 30 sits near the asymptote where most visual content is visible; frame 15 captures it mid-flight if you want to verify timing. - The hold frame (the last frame of the entrance, before any exit begins) is what the majority of viewers see longest, making it the highest-ROI snapshot target.
- The last frame (e.g., frame 89 for a 90-frame clip) catches off-by-one errors in exit animations that leave elements partially faded or slightly mistranslated.
When a Snapshot Fails
Intentional Change
You updated IntroSlide to use a darker background: #0A0A0F instead of #111118. The test fails with 180,000 mismatched pixels across three frames. That’s expected: the background fills the entire 1920×1080 canvas, so nearly every pixel changes.
The update workflow:
# 1. Review the diff image — it lives in test/snapshots/__diffs__/
open test/snapshots/__diffs__/IntroSlide-hold-diff.png
# 2. If the diff matches your intent, copy the actual renders over the baselines.
cp test/snapshots/__diffs__/IntroSlide-hold-actual.png \
test/snapshots/__baselines__/IntroSlide-hold.png
cp test/snapshots/__diffs__/IntroSlide-mid-entrance-actual.png \
test/snapshots/__baselines__/IntroSlide-mid-entrance.png
cp test/snapshots/__diffs__/IntroSlide-initial-actual.png \
test/snapshots/__baselines__/IntroSlide-initial.png
# 3. Re-run to confirm all three pass against the new baselines.
npx vitest run test/snapshots/IntroSlide
A helper script speeds this up when multiple compositions are affected:
// scripts/update-snapshots.ts
import fs from 'fs';
import path from 'path';
import { glob } from 'glob';
// Replace baselines for any composition ID passed as a CLI argument.
// Usage: npx tsx scripts/update-snapshots.ts IntroSlide OutroSlide
const targetIds = process.argv.slice(2);
const diffs = await glob('test/snapshots/__diffs__/*-actual.png');
for (const actualPath of diffs) {
const filename = path.basename(actualPath);
// Filename format: {CompositionId}-{label}-actual.png
const compositionId = filename.split('-')[0];
if (targetIds.length && !targetIds.includes(compositionId)) continue;
const baselineFilename = filename.replace('-actual', '');
const baselinePath = path.join('test/snapshots/__baselines__', baselineFilename);
fs.copyFileSync(actualPath, baselinePath);
console.log(`Updated: ${baselineFilename}`);
}
Unintentional Change
You updated a shared Title component to fix a line-height calculation. The IntroSlide test fails with 3,200 mismatched pixels concentrated in the title text region. You didn’t intend to change the slide. Open the diff PNG: the text is shifted 2 px downward. That’s a regression, not a desired change. Fix the component rather than updating the snapshot.
The diff image is your primary diagnostic tool. Upload __diffs__/ as a CI artifact so it’s accessible without a local re-render:
# .github/workflows/test.yml (excerpt)
- name: Run snapshot tests
run: npx vitest run test/snapshots
- name: Upload diffs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: snapshot-diffs
path: test/snapshots/__diffs__/
retention-days: 7
Edge Cases That Break Determinism
Math.random() in Compositions
If a particle system or noise function calls Math.random() inside a component, every render will produce a different result and your snapshots will never stabilize. Replace with a frame-seeded PRNG:
import { useCurrentFrame } from 'remotion';
// A simple LCG seeded by frame number. Same frame → same sequence, always.
function seededRandom(seed: number): () => number {
let s = seed;
return () => {
s = (s * 1664525 + 1013904223) & 0xffffffff;
return (s >>> 0) / 0xffffffff;
};
}
export const ParticleBurst: React.FC = () => {
const frame = useCurrentFrame();
// Multiply by a prime so adjacent frames don't share initial state.
const rand = seededRandom(frame * 7919);
return (
<>
{Array.from({ length: 20 }, (_, i) => {
const x = rand() * 1920;
const y = rand() * 1080;
return <circle key={i} cx={x} cy={y} r={4} fill="white" />;
})}
</>
);
};
System Fonts Not Loading
renderStill with swangle still relies on the system font stack for text that falls back to system fonts. On a CI runner with minimal font packages, “Helvetica Neue” may not exist and text renders in a narrower or wider fallback, producing a line-break at a different point and cascading layout differences throughout the frame.
The safest approach is embedding fonts via @font-face pointing to a staticFile path. Remotion serves the public/ directory locally, so no external CDN is needed:
// src/fonts.ts — call once inside your Root component
import { continueRender, delayRender, staticFile } from 'remotion';
export const loadFonts = () => {
const handle = delayRender('Loading fonts');
const style = document.createElement('style');
style.textContent = `
@font-face {
font-family: 'Inter';
src: url('${staticFile('fonts/Inter-Regular.woff2')}') format('woff2');
font-weight: 400;
}
@font-face {
font-family: 'Inter';
src: url('${staticFile('fonts/Inter-SemiBold.woff2')}') format('woff2');
font-weight: 600;
}
`;
document.head.appendChild(style);
// delayRender holds frame capture until the font is applied.
// Without this, Remotion may snapshot before the WOFF2 is parsed,
// rendering text in the fallback stack.
document.fonts.ready.then(() => continueRender(handle));
};
Animations That Depend on Real Time
Date.now() or new Date() inside a component will always produce a different value between renders. If you need a “current date” stamp in your composition, pass it as an inputProp and freeze it in tests:
const composition = await selectComposition({
serveUrl: bundleUrl,
id: 'DateBadge',
inputProps: {
// Freeze to a known date so snapshots don't go stale over time.
date: '2026-01-15',
},
});
Wrapping Up
Frame snapshot tests for Remotion compositions pay back quickly once the pipeline is in place. The key decisions that determine whether tests stay reliable:
- Use
gl: 'swangle'without exception. This eliminates GPU-dependent pixel variance across machines and is the single change most likely to transform a flaky snapshot suite into a stable one. - Bundle once per test suite to cut run time from minutes to under a minute for 10 to 20 compositions.
- Choose frames by visual state rather than even intervals. Frame 0, entrance peak, hold, and last frame cover the meaningful states without redundant renders.
- Store PNG baselines in version control so diffs are visible in PR reviews, and the update history documents every intentional visual change with a commit.
- Seed all randomness by frame number. Any
Math.random()call inside a composition is a snapshot time bomb that will surface at the worst possible moment. - Upload diff images as CI artifacts. The diff PNG is how you distinguish an intentional change from a regression without performing a local re-render.
The update workflow itself should feel deliberate: render, review the diff, copy the actual if it’s correct, re-run to confirm. If you want to see how these design-system constraints (system-font fallbacks, no external assets, deterministic animations) apply at scale, templates in the RenderComp catalog follow the same rules, making them ready for snapshot testing without modification.
The goal is a reviewable record of every frame that shifted and why, a history that becomes useful the next time someone wonders “when did this composition start looking like this?”
Now available
Get 1,000+ Remotion Templates
Pay once — no subscription. Lifetime updates. TypeScript-first.
View pricing →