Proving Remotion Renders Are Deterministic Across Machines and CI
By RenderComp Team Editorial policy
Remotion’s core claim is that a video composition is a pure function: given frame n and fixed props, the output pixels are always identical. That framing is what makes video feel like code. You can version it, diff it, and reproduce it on any machine. But “pure function” is an aspiration, not a guarantee the runtime enforces. Your component tree runs inside a real browser context, and nothing stops you from calling Date.now() or pulling unversioned data from a network.
The mismatch matters in CI. A pipeline that renders on a Linux runner while your dev machine runs macOS produces visually identical output only if you actively eliminate non-determinism sources. If you don’t, frame-level pixel hashes diverge, making automated regression checks meaningless. You can’t diff a video you don’t trust is reproducible.
This article walks through exactly what “deterministic” means at the Remotion level, five specific ways production components break the contract, and a concrete verification harness you can drop into GitHub Actions today.
The Determinism Contract
Remotion renders each frame by mounting your component with useCurrentFrame() returning exactly n, then capturing a screenshot via headless Chromium. From the composition’s perspective the loop is synchronous: Remotion advances the frame counter, waits for all delayRender promises to resolve, screenshots, advances again.
The primitives in @remotion/core are themselves pure:
import { spring, interpolate, useCurrentFrame, useVideoConfig } from 'remotion';
function PureFade({ children }: { children: React.ReactNode }) {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// spring() is a pure function of frame and fps — no hidden clock
const opacity = spring({
frame,
fps,
config: { damping: 200, stiffness: 80, mass: 1 },
durationInFrames: 20,
});
return <div style={{ opacity }}>{children}</div>;
}
Call spring({ frame: 12, fps: 30, config: { damping: 200, stiffness: 80, mass: 1 }, durationInFrames: 20 }) on any machine running the same Remotion version and you get the same float back. interpolate() is equally deterministic:
// Returns 0.5 on every machine, every run
const x = interpolate(15, [0, 30], [0, 1]);
The contract holds as long as your component tree is equally pure. Here’s where it breaks.
The Five Failure Modes
1. Wall-Clock Time Leaks
The most common offender:
// ❌ Different every render
function BadTimestamp() {
const now = new Date().toISOString();
return <div>{now}</div>;
}
// ✅ Derive time from frame number instead
function GoodTimestamp() {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const elapsed = frame / fps; // seconds elapsed in the video
const mm = String(Math.floor(elapsed / 60)).padStart(2, '0');
const ss = String(Math.floor(elapsed % 60)).padStart(2, '0');
return <div>{mm}:{ss}</div>;
}
Date.now(), performance.now(), and new Date() without arguments all leak wall-clock time. Any timestamp that must reflect real-world time should be passed in via getInputProps() at render time.
2. Unseeded Random Numbers
// ❌ Each render generates different particle positions
const positions = Array.from({ length: 50 }, () => ({
x: Math.random() * 1920,
y: Math.random() * 1080,
}));
Remotion ships a seeded pseudo-random number generator in remotion:
import { random, AbsoluteFill, useCurrentFrame } from 'remotion';
function Particles() {
const frame = useCurrentFrame();
const particles = Array.from({ length: 50 }, (_, i) => ({
// String seed: same input → same output on every machine
x: random(`px-${i}`) * 1920,
// Include frame in the seed to animate position over time
y: random(`py-${i}-${frame}`) * 1080,
}));
return (
<AbsoluteFill>
{particles.map((p, i) => (
<div
key={i}
style={{
position: 'absolute',
left: p.x,
top: p.y,
width: 4,
height: 4,
borderRadius: '50%',
background: 'white',
}}
/>
))}
</AbsoluteFill>
);
}
random('same-seed') always returns the same float in [0, 1]. Math.random() never belongs in a Remotion component.
3. Async Data Not Pinned to Render Time
// ❌ Network response may vary between renders, or between machines
function LivePrice() {
const [price, setPrice] = React.useState<number | null>(null);
const handle = delayRender();
React.useEffect(() => {
fetch('https://api.example.com/price')
.then(r => r.json())
.then(d => { setPrice(d.price); continueRender(handle); });
}, []);
return price !== null ? <div>{price}</div> : null;
}
The fix is to push data into render time via inputProps, not fetch it at mount:
import { getInputProps } from 'remotion';
// Data is injected when the render process starts — same on every runner
const { price } = getInputProps<{ price: number }>();
function StablePrice() {
return <div>{price}</div>;
}
Render invocation:
npx remotion render MainComp out.mp4 --props='{"price":142.50}'
For CI, write props to a file in a preceding job step so both the render and any downstream verification use identical input:
npx remotion render MainComp out.mp4 --props=./render-props.json
4. Dynamic Imports with Side-Effectful Modules
Webpack’s code-splitting doesn’t guarantee evaluation order when React.lazy() boundaries share modules that mutate module-level state. In practice this is uncommon with Remotion’s default bundle config, but if you’ve added overrideWebpackConfig with custom chunk splitting, prefer eager imports at composition scope:
// ✅ Eager import — deterministic module initialization order
import { HeavyScene } from './HeavyScene';
// ❌ Lazy boundary introduces chunk ordering ambiguity
const HeavyScene = React.lazy(() => import('./HeavyScene'));
5. System Font Fallback
This is the subtlest failure mode and the most common source of cross-platform pixel drift. If your composition names a font installed on macOS but absent on the Linux CI runner, the browser falls back silently to a different system font. Text wrapping, glyph advances, and subpixel antialiasing all shift. Frame hashes diverge even though the animation logic is identical.
Bundling Fonts for Cross-Platform Consistency
The solution is to serve fonts from your bundle, not the host system. Place font files in your public/ directory:
public/
fonts/
inter-variable.woff2
Then declare the @font-face rule using staticFile(), which resolves correctly in both the Studio preview and headless renders:
import { AbsoluteFill, staticFile } from 'remotion';
const fontFace = `
@font-face {
font-family: 'Inter';
src: url('${staticFile('fonts/inter-variable.woff2')}') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: block;
}
`;
export function Root() {
return (
<>
<style>{fontFace}</style>
<AbsoluteFill style={{ fontFamily: 'Inter, system-ui, sans-serif' }}>
{/* scenes */}
</AbsoluteFill>
</>
);
}
font-display: block is deliberate: it keeps text invisible during the font fetch window rather than swapping in a fallback glyph. For Remotion’s frame-by-frame screenshot loop, an invisible glyph is safer than a mismatched one.
To ensure Remotion holds the screenshot until the font is actually loaded, use a delayRender gate at the root of your composition:
import React, { useEffect, useState } from 'react';
import { delayRender, continueRender, AbsoluteFill, staticFile } from 'remotion';
export function Root({ children }: { children: React.ReactNode }) {
const [handle] = useState(() => delayRender('Waiting for fonts'));
useEffect(() => {
document.fonts.ready.then(() => continueRender(handle));
}, [handle]);
return (
<>
<style>{fontFace}</style>
<AbsoluteFill>{children}</AbsoluteFill>
</>
);
}
Without this, Remotion may screenshot frame 0 before the woff2 file has been parsed, producing blank text on the first few frames.
CI Pipeline Setup
Remotion’s @remotion/renderer package downloads a pinned Chromium build on install. The npm lockfile is therefore load-bearing: it locks both your Remotion version and the exact Chromium binary that gets used. Use npm ci, not npm install:
# .github/workflows/render-check.yml
name: Render Determinism Check
on:
pull_request:
paths:
- 'src/**'
- 'public/**'
jobs:
render:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- run: npm ci # lockfile is law
- name: Write render props
run: echo '{"buildId":"${{ github.sha }}"}' > render-props.json
- name: Render (run 1)
run: |
npx remotion render MainComp out/run1.mp4 \
--concurrency=2 \
--props=render-props.json
- name: Render (run 2)
run: |
npx remotion render MainComp out/run2.mp4 \
--concurrency=2 \
--props=render-props.json
- name: Verify frame determinism
run: bash ./scripts/verify-determinism.sh out/run1.mp4 out/run2.mp4
- uses: actions/upload-artifact@v4
if: always()
with:
name: render-${{ github.sha }}
path: out/run1.mp4
--concurrency=2 matches the runner’s core count to avoid Chrome processes competing for memory. Rendering the same composition twice in the same job is intentional; separate jobs introduce environment state differences as an additional variable.
Frame-Hash Verification
Comparing raw MP4 files with sha256sum fails because the muxer embeds a creation timestamp in the container headers. The payload (the compressed pixel data) may be identical while the file hashes differ. Compare decoded frames instead:
#!/usr/bin/env bash
# scripts/verify-determinism.sh
# Usage: ./verify-determinism.sh run1.mp4 run2.mp4
set -euo pipefail
MP4_A="$1"
MP4_B="$2"
FRAMES_A=$(mktemp -d)
FRAMES_B=$(mktemp -d)
cleanup() { rm -rf "$FRAMES_A" "$FRAMES_B"; }
trap cleanup EXIT
echo "Extracting frames from $MP4_A..."
ffmpeg -i "$MP4_A" -vsync 0 -f image2 "$FRAMES_A/frame%06d.png" -loglevel warning
echo "Extracting frames from $MP4_B..."
ffmpeg -i "$MP4_B" -vsync 0 -f image2 "$FRAMES_B/frame%06d.png" -loglevel warning
COUNT_A=$(find "$FRAMES_A" -name '*.png' | wc -l | tr -d ' ')
COUNT_B=$(find "$FRAMES_B" -name '*.png' | wc -l | tr -d ' ')
if [ "$COUNT_A" != "$COUNT_B" ]; then
echo "FAIL: frame counts differ ($COUNT_A vs $COUNT_B)"
exit 1
fi
echo "Comparing $COUNT_A frames..."
MISMATCHES=0
for i in $(seq -w 1 "$COUNT_A"); do
HASH_A=$(sha256sum "$FRAMES_A/frame${i}.png" | cut -c1-64)
HASH_B=$(sha256sum "$FRAMES_B/frame${i}.png" | cut -c1-64)
if [ "$HASH_A" != "$HASH_B" ]; then
echo " MISMATCH at frame $i"
MISMATCHES=$((MISMATCHES + 1))
fi
done
if [ "$MISMATCHES" -eq 0 ]; then
echo "PASS: all $COUNT_A frames match"
else
echo "FAIL: $MISMATCHES / $COUNT_A frames differ"
exit 1
fi
-vsync 0 tells ffmpeg not to duplicate or drop frames to match a target frame rate, extracting exactly one PNG per encoded frame and preserving the 1:1 correspondence between useCurrentFrame() values and output images.
Golden Frame Comparison for Cross-Platform Drift
To catch macOS-vs-Linux divergence, commit a golden render produced in CI (never locally) and compare against it on every PR:
- name: Download golden artifact
uses: actions/download-artifact@v4
with:
name: golden-render
path: golden/
- name: Compare against golden
run: bash ./scripts/verify-determinism.sh golden/render.mp4 out/run1.mp4
If text frames diverge by ±1 pixel values, a sign that subpixel font hinting differs between Chromium builds, fix the root cause (a bundled font not being loaded before the first screenshot) rather than switching to a perceptual hash threshold. Exact pixel match is achievable once the delayRender font gate is in place; accepting near-match as the standard masks regressions.
Wrapping Up
Five rules that together make a Remotion composition reproducible anywhere:
- Keep
Date.now(),new Date(), andperformance.now()out of your components entirely. Pass real-world timestamps in viagetInputProps()instead. - Use
random()fromremotionwherever the code needs a float. The string seed is your contract with reproducibility;Math.random()provides none. - Dynamic data belongs in
--props, not inuseEffectfetches. If you needcalculateMetadatato derive duration from data, the data source must itself be versioned or passed in. - Serve woff2 via
staticFile(), declarefont-display: block, and hold the first screenshot with adelayRenderthat waits ondocument.fonts.ready. - Verify with frame hashes extracted by
ffmpeg -vsync 0, not with file hashes. Hash each PNG and compare the lists.
A composition that passes this check is one you can cache aggressively, run on any runner, and rerender a year from now with confidence. Parameterized templates—like those in the RenderComp catalog—depend on this property: a template you can’t reproduce reliably can’t safely accept client data at scale.
Now available
Get 1,000+ Remotion Templates
Pay once — no subscription. Lifetime updates. TypeScript-first.
View pricing →