A video is a function of the frame number
With Remotion, you write a video in React. Once you understand it is rendered frame by frame, in parallel, by workers that never talk to each other, a lot of bugs explain themselves. With a render simulation.
The training studio I built produces edited videos, with voice and music, from a few answers. Under the hood, each video is a React component rendered by Remotion.
Writing a video in React is a bit of a rush. Components, props, hot reload, Git for history, and tests. But the mental model is not a web page’s, and most bugs come from that gap.
One frame, one call
A web page lives in time: state, effects, animations that move on their own. A Remotion video has no time of its own. To produce frame 42, Remotion shows the component with useCurrentFrame() returning 42, waits for everything to load, takes a capture, then moves on to another frame.
So an animation is not something that plays out. It is a value computed from the frame number: interpolate() to go from one value to another between two frames, spring() for physical motion with a bounce. Same frame in, same pixel out.
import { interpolate, spring, useCurrentFrame, useVideoConfig } from 'remotion'
export function Title({ text }: { text: string }) {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
// everything on screen is derived from the frame number
const opacity = interpolate(frame, [0, 20], [0, 1], {
extrapolateRight: 'clamp'
})
const scale = spring({ frame, fps, config: { damping: 12 } })
return <h1 style={{ opacity, transform: `scale(${scale})` }}>{text}</h1>
}Rendering in parallel
To go fast, Remotion does not render frames in order on one machine. Locally, several browser tabs share the work. On Lambda, the video is cut into chunks of consecutive frames, and each chunk goes to a different function. These workers share no memory, no state and no clock. Each one shows the component on its own, for the frames it was given.
Anything that is not a pure function of the frame number then becomes a bug, often one you cannot see in the preview. The simulation renders 90 frames with four workers, one per colour. The highlighted dot leaves a dotted trail: its path across the whole video.
Three ways to draw a random number
Math.random() called during render returns a different number on every frame: elements jump all the time. That one shows up immediately, and Remotion’s ESLint plugin flags it before you even start a render.
The trap is the second version. You store the draw in a useState(() => Math.random()) or a useMemo, and the preview becomes perfect: in the browser a single component stays on screen for the whole playback, so the draw never changes. At render time each worker mounts its own component and draws its own numbers. The video jumps at every chunk boundary, and nothing in the preview could have warned you.
The right version uses random(), provided by Remotion: a pseudo-random number that depends only on the seed you give it. Same seed, same number, on any machine. For fifty particles, each one gets its own seed and keeps its place from frame to frame.
import { random, useCurrentFrame } from 'remotion'
export function Particles({ count }: { count: number }) {
const frame = useCurrentFrame()
return Array.from({ length: count }, (_, i) => {
// same seed, same number, on any worker
const x = random(`x-${i}`) * 1920
const y = random(`y-${i}`) * 1080 - frame * (1 + random(`v-${i}`))
return <Dot key={i} x={x} y={y} />
})
}Other sources of impurity
Randomness is the most visible case, not the only one. The same reasoning applies to anything that depends on the moment or the machine rather than the frame number.
Date.now()in a component: compute the date once, when the render starts, and pass it as a prop.- Data loaded with
fetch:delayRender()tells Remotion to wait for the response before capturing, otherwise some frames go out without it. - A CSS animation or transition: it runs on the browser’s clock, not the video’s. Remotion cannot capture it reliably.
- A font or an image that has not loaded yet: same cure, wait explicitly before capturing.
For the course platform’s presentation video, the soundtrack is generated procedurally, and the same rule applies to sound: computed audio must depend on time and fixed seeds, never on the moment the render runs.
What the constraint gives back
This discipline has an upside. A deterministic video can be tested: render frame 120 to a PNG with renderStill(), compare it to a reference image, and a layout regression breaks a test instead of shipping. It renders in parallel without surprises, and two renders of the same code give the same image.
It is also what opens the video to an agent: it can edit a component, render a few key frames and compare them to know whether it broke something. A hand-made edit allows none of that.
The rules
- Everything on screen is computed from
useCurrentFrame(). - Randomness goes through
random(seed), neverMath.random(), not even stored in state. - No
Date.now(), no CSS animation, no data loaded withoutdelayRender(). - Check a real parallel render, not just the preview.