Visual Regression Testing for Remotion with BackstopJS
By RenderComp Team Editorial policy
Remotion’s core promise is that video is a pure function of time: given the same frame number and the same props, the renderer will always produce the same pixels. That determinism is exactly what makes visual regression testing not just possible, but unusually reliable compared to testing a conventional web UI. A traditional web page renders differently across font hinting backends, OS versions, and GPU drivers. A Remotion composition, running through the same headless Chromium at the same frame, resolves to a stable pixel grid you can diff with confidence.
The gap in most Remotion workflows is that this determinism goes untested. A designer tweaks an interpolate ease curve, a merge conflict corrupts a spring stiffness value, or a dependency upgrade shifts sub-pixel antialiasing, and the breakage only surfaces in a rendered video no one watches frame-by-frame. Unit tests with Jest can catch logic errors; TypeScript catches type errors. Neither catches the frame at t=0.5 of your spring animation painting two pixels too high.
BackstopJS solves the diffing half of this problem, but it was designed around navigating URLs and screenshotting live pages. The trick is to keep BackstopJS’s comparison engine and reporting infrastructure while replacing the screenshot-of-a-URL step with renderStill(), Remotion’s programmatic single-frame renderer. The result is a suite where each “screenshot” is a lossless PNG rendered by Remotion itself, and BackstopJS handles the pixel diffing, threshold management, and HTML diff reports.
How the Pipeline Fits Together
The setup has two phases, each using a different tool:
renderStill()from@remotion/rendererrenders specific key frames to PNG files on disk. Run it once to establish a reference baseline, then again after changes to produce the test frames.- BackstopJS handles frame comparison, configured with a per-scenario
referenceUrlandurlpointing at a thin local file server. Both endpoints serve pre-rendered PNGs wrapped in a minimal HTML shell. BackstopJS’s Puppeteer engine captures those images at exact viewport dimensions and diffs them.
The critical design choice is to never let BackstopJS drive the Remotion preview server live. The Remotion Studio at localhost:3000 introduces race conditions (fonts load asynchronously, <Video> poster frames vary, and React hydration timing is not guaranteed frame-accurate). Pre-rendering with renderStill() removes all of that.
Bootstrapping the Bundle
Before renderStill() can render anything, Remotion needs a Webpack bundle. Use bundle() from @remotion/bundler, then getCompositions() to discover composition metadata (duration, fps, width, height) without hard-coding it.
// scripts/build-bundle.ts
import { bundle } from '@remotion/bundler';
import { getCompositions } from '@remotion/renderer';
import path from 'path';
export async function buildBundle() {
const serveUrl = await bundle({
entryPoint: path.resolve('./src/index.ts'),
// If you have a custom webpack config, pass webpackOverride here.
// Avoid publicPath overrides — they break asset resolution in headless Chrome.
});
const compositions = await getCompositions(serveUrl, {
inputProps: {},
// Pass your default props if compositions require them at discovery time.
});
return { serveUrl, compositions };
}
bundle() returns a serveUrl. In local mode this is a file:// path pointing at the built output folder. Pass it directly to renderStill() and getCompositions(). In CI, where you may want reproducible byte-identical bundles, set the publicPath to an empty string and cache the .remotion/ cache directory between runs.
Choosing Frames Worth Testing
The naive approach (testing frame 0 and frame durationInFrames - 1) misses the most interesting visual states. Animations are usually in motion between those poles, and that motion is where regressions hide.
A practical frame selection heuristic:
- Frame 0: The initial composition state before any animation fires.
- The spring settle frame is where a spring with default parameters (
damping: 10, stiffness: 100, mass: 1) reaches>0.999of its target value. At 30 fps this is approximately frame 28; scale it to your composition’s fps withMath.round((28 / 30) * fps). - Sequence boundary frames catch off-by-one timing errors: one frame before a
<Sequence from={N}>starts and the first frame of the Sequence. Errors betweenfromandfrom - 1are invisible except at exactly these frames. Math.floor(durationInFrames / 2)as a midpoint catches easing direction inversions.- Final frame:
durationInFrames - 1, the resting state.
// scripts/frame-scenarios.ts
export interface FrameScenario {
compositionId: string;
frame: number;
label: string;
inputProps?: Record<string, unknown>;
}
interface CompositionMeta {
id: string;
durationInFrames: number;
fps: number;
}
export function scenariosFor(
comp: CompositionMeta,
sequenceBoundaries: number[] = [], // pass explicit Sequence `from` values here
inputProps: Record<string, unknown> = {}
): FrameScenario[] {
const { id, durationInFrames, fps } = comp;
// Spring settle at default params, scaled to fps
const springSettle = Math.min(Math.round((28 / 30) * fps), durationInFrames - 1);
const candidates = [
0,
springSettle,
Math.floor(durationInFrames * 0.25),
Math.floor(durationInFrames * 0.5),
durationInFrames - 1,
// Include frame-before and frame-of each Sequence boundary
...sequenceBoundaries.flatMap(f => [Math.max(0, f - 1), f]),
];
// Deduplicate and sort
const frames = [...new Set(candidates)]
.filter(f => f >= 0 && f < durationInFrames)
.sort((a, b) => a - b);
return frames.map(frame => ({
compositionId: id,
frame,
// Zero-pad to 4 digits so filenames sort lexicographically
label: `${id}--f${String(frame).padStart(4, '0')}`,
inputProps,
}));
}
Rendering Frames with renderStill
renderStill() accepts a browserInstance that can be shared across renders, which cuts the total runtime by eliminating repeated Chrome launches. Open it once with openBrowser() and pass it through.
For CI, gl: 'swiftshader' is the most portable GPU backend. It runs in sandboxed environments like GitHub Actions runners that have no real GPU. Locally, angle gives slightly sharper text rendering. Keep these in sync between local and CI runs, or you will get false positives from font hinting differences.
// scripts/render-frames.ts
import { openBrowser, renderStill, selectComposition } from '@remotion/renderer';
import fs from 'fs';
import path from 'path';
import type { FrameScenario } from './frame-scenarios';
export async function renderFrames(
scenarios: FrameScenario[],
serveUrl: string,
outputDir: string,
glBackend: 'swiftshader' | 'angle' = 'swiftshader'
): Promise<void> {
await fs.promises.mkdir(outputDir, { recursive: true });
// One browser instance for all renders — saves ~400ms per frame
const browserInstance = await openBrowser('chrome', {
chromiumOptions: {
gl: glBackend,
// Do not enable disableWebSecurity unless your assets require cross-origin.
},
});
try {
for (const scenario of scenarios) {
const composition = await selectComposition({
serveUrl,
id: scenario.compositionId,
inputProps: scenario.inputProps ?? {},
});
const outputPath = path.join(outputDir, `${scenario.label}.png`);
await renderStill({
composition,
serveUrl,
output: outputPath,
frame: scenario.frame,
inputProps: scenario.inputProps ?? {},
imageFormat: 'png', // PNG only — JPEG compression introduces lossy artifacts
// that cause false positives on every diff run
browserInstance,
onBrowserLog: () => {}, // suppress console.log noise from the composition
});
console.log(` ✓ ${scenario.label} → ${path.relative(process.cwd(), outputPath)}`);
}
} finally {
await browserInstance.close(false);
}
}
Run this twice: once before your change to populate frames/reference/, once after to populate frames/test/.
The Frame Server
BackstopJS expects to navigate to a URL and screenshot a page. We serve each PNG as the only content in a zero-margin HTML document. When Puppeteer sets its viewport to exactly the composition’s width × height, the screenshot is a pixel-identical copy of the PNG, with no scaling, no border, and no scrollbar.
// scripts/frame-server.ts
import http from 'http';
import fs from 'fs';
import path from 'path';
export function startFrameServer(framesDir: string, port: number): http.Server {
const server = http.createServer((req, res) => {
// URL format: /<label>.png — strip leading slash
const filename = decodeURIComponent(req.url ?? '/').replace(/^\//, '');
const filePath = path.join(framesDir, filename.endsWith('.png') ? filename : `${filename}.png`);
if (!fs.existsSync(filePath)) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end(`Not found: ${filename}`);
return;
}
const png = fs.readFileSync(filePath);
const base64 = png.toString('base64');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
/* Zero margin — the PNG must fill the viewport exactly */
* { margin: 0; padding: 0; overflow: hidden; }
body { background: #000; }
img { display: block; }
</style>
</head>
<body>
<img
id="frame"
src="data:image/png;base64,${base64}"
width="${getWidth(filePath)}"
height="${getHeight(filePath)}"
>
</body>
</html>`);
});
server.listen(port);
return server;
}
// Read PNG dimensions from the IHDR chunk (bytes 16–24) without a library
function getWidth(filePath: string): number {
const buf = fs.readFileSync(filePath);
return buf.readUInt32BE(16);
}
function getHeight(filePath: string): number {
const buf = fs.readFileSync(filePath);
return buf.readUInt32BE(20);
}
BackstopJS Configuration
BackstopJS receives a scenario per frame. The referenceUrl points at the reference server; url points at the test server. The viewport must match your composition dimensions exactly. Mismatched viewports cause Puppeteer to add scrollbars, which break the diff.
{
"id": "remotion-regression",
"viewports": [
{ "label": "comp-1920x1080", "width": 1920, "height": 1080 }
],
"onReadyScript": "backstop_engine_scripts/onReady.js",
"scenarios": [
{
"label": "TitleCard--f0000",
"url": "http://localhost:4456/TitleCard--f0000",
"referenceUrl": "http://localhost:4455/TitleCard--f0000",
"misMatchThreshold": 0.15,
"requireSameDimensions": true,
"delay": 0
},
{
"label": "TitleCard--f0028",
"url": "http://localhost:4456/TitleCard--f0028",
"referenceUrl": "http://localhost:4455/TitleCard--f0028",
"misMatchThreshold": 0.15,
"requireSameDimensions": true,
"delay": 0
}
],
"paths": {
"bitmaps_reference": "backstop_data/bitmaps_reference",
"bitmaps_test": "backstop_data/bitmaps_test",
"html_report": "backstop_data/html_report",
"ci_report": "backstop_data/ci_report"
},
"engine": "puppeteer",
"report": ["CI"],
"engineOptions": {
"args": ["--no-sandbox", "--disable-setuid-sandbox"]
},
"asyncCaptureLimit": 1,
"asyncCompareLimit": 10
}
The onReadyScript waits for the image to fully decode before Puppeteer fires the screenshot. Without this, you occasionally catch the blank frame before the base64 image renders:
// backstop_engine_scripts/onReady.js
module.exports = async (page) => {
await page.waitForSelector('img#frame');
await page.evaluate(() =>
new Promise((resolve, reject) => {
const img = document.getElementById('frame');
if (img.complete && img.naturalWidth > 0) return resolve();
img.addEventListener('load', resolve);
img.addEventListener('error', reject);
setTimeout(reject, 8000);
})
);
};
Set misMatchThreshold per scenario, not globally. Text-heavy frames with sharp edges tolerate 0.05–0.15. Frames heavy with spring-driven opacity blends can go up to 0.5 without masking real regressions, because antialiasing variation between Chrome versions can shift a few percent of semi-transparent pixels.
Wiring It All Together in CI
A minimal GitHub Actions job that runs the full suite:
# .github/workflows/visual-regression.yml
name: Remotion Visual Regression
on: [pull_request]
jobs:
visual-regression:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
# Render reference frames from main branch
- name: Render reference frames
run: |
git stash
npx ts-node scripts/render-frames.ts --phase reference --gl swiftshader
git stash pop
# Render test frames from the PR branch
- name: Render test frames
run: |
npx ts-node scripts/render-frames.ts --phase test --gl swiftshader
# Start both servers and run BackstopJS
- name: Run BackstopJS diff
run: |
npx ts-node scripts/start-servers.ts &
sleep 2
npx backstop test --config backstop.json
env:
REFERENCE_PORT: 4455
TEST_PORT: 4456
- name: Upload diff report
if: failure()
uses: actions/upload-artifact@v4
with:
name: backstop-diff-report
path: backstop_data/html_report/
The --gl swiftshader flag is non-negotiable for GitHub Actions. GitHub’s Ubuntu runners have no hardware GPU, and Chrome will fall back to software rendering regardless, but specifying swiftshader explicitly gives you deterministic rasterization rather than whatever fallback Chrome picks. Leaving it unspecified produces occasional one-pixel-off diffs for text with subpixel hints.
Cache the frames/reference/ directory between runs using a cache key derived from your main branch SHA. This eliminates the re-render of references on every PR and cuts CI time roughly in half for large template suites.
Wrapping Up
The payoff of this setup is that Remotion’s determinism becomes a testing guarantee. Once your reference frames are committed, any code change that shifts pixels (a tweaked spring config, a wrong interpolate output range, a changed default prop) will fail the CI job with a visual diff report showing exactly which frame broke and by how many pixels.
A few practical decisions to make upfront:
- Start with your most frequently shipped templates. Templates like those in the RenderComp catalog with data-driven text layouts benefit most, because a stray number format change in
inputPropscan reflow an entire title card. - For threshold calibration, run a baseline pass with
misMatchThreshold: 0and observe natural drift between two identical renders. Anything above zero in that baseline is noise to absorb in your threshold, not ignore. - Five to eight frames per composition is usually enough. More frames increase CI time without proportionally increasing defect detection; the failing frame is almost always at the spring settle point or a Sequence boundary, not at the midpoint.
- Re-run
--phase referencedeliberately after intentional visual changes, never automatically. Automating reference updates removes the human approval step that makes visual regression testing meaningful.
The grunt work is in the initial frame selection and threshold calibration. Once those are dialed in, the suite runs unattended and surfaces the class of bugs that are otherwise invisible until a client spots them in a delivered file.
Now available
Get 1,000+ Remotion Templates
Pay once — no subscription. Lifetime updates. TypeScript-first.
View pricing →