Programmatic Video (Capstone)
Animation as code, rendered to frames.
Every chapter so far made pixels move live, at whatever frame rate the device could manage. The capstone flips the relationship: we stop asking “what does this look like right now?” and start asking “what does frame number N look like?” Answer that as a pure function and you can render to an MP4 — deterministically, offline, in the cloud, in parallel.
Animation as code, rendered to frames
A programmatic video is a composition: a known width, height, frame rate and
duration-in-frames. Inside it, sequences time-shift child layers, and every
animated value is produced by interpolation from the current frame. The whole
thing is a function render(frame) → image. Crucially there is no wall clock — frame
137 always paints identically, which is exactly what makes the output frame-accurate.
Two real engines, two mental models
Motion Canvas (by aarthificial, TypeScript) is purpose-built for explainer
animation. A scene is a generator function; you yield* tweens
measured in seconds and the timeline advances. State lives in createSignal /
computed signals (the same reactive idea as Svelte's runes), Vite gives a real-time preview, and
there's a timeline editor plus waitUntil/useDuration for syncing to
audio. It reads like a script.
// Motion Canvas — a scene is a GENERATOR. You describe FLOW imperatively;
// each yield* awaits a tween (measured in seconds) while the timeline advances.
import { makeScene2D, Circle, Txt } from '@motion-canvas/2d';
import { createSignal, all, waitFor, createRef } from '@motion-canvas/core';
export default makeScene2D(function* (view) {
const radius = createSignal(40); // a reactive signal…
const area = createSignal(() => Math.PI * radius() ** 2); // …and a computed one
const label = createRef<Txt>();
view.add(<Circle width={() => radius() * 2} fill="#7c9cff" />);
view.add(<Txt ref={label} y={120} text={() => area().toFixed(0) + ' px²'} />);
yield* radius(120, 1.2); // animate radius → 120 over 1.2s
yield* all( // run tweens in parallel
label().scale(1.4, 0.6),
radius(40, 0.8),
);
yield* waitFor(0.5); // hold half a second
}); Remotion renders React components to video. A <Composition> declares dimensions / fps / duration; <Sequence> time-shifts its children; and inside a component useCurrentFrame() + interpolate() turn the frame number into concrete
values. It's declarative where Motion Canvas is imperative — but both are still just frame → pixels.
// Remotion — a composition is a REACT component sampled at the current frame.
import { useCurrentFrame, useVideoConfig, interpolate, spring, Sequence, AbsoluteFill } from 'remotion';
export const Title: React.FC<{ text: string }> = ({ text }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const enter = spring({ frame, fps, config: { damping: 14 } }); // 0→1, analytic
const opacity = interpolate(frame, [0, 12], [0, 1], { extrapolateRight: 'clamp' });
return (
<AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center' }}>
<h1 style={{ opacity, transform: `translateY(${(1 - enter) * 40}px)` }}>{text}</h1>
</AbsoluteFill>
);
};
export const Scene = () => (
<>
<Sequence durationInFrames={45}><Background /></Sequence>
<Sequence from={6}><Title text="Frames are a function" /></Sequence> {/* time-shift */}
</>
);
// register it (dimensions / fps / duration live here, like our <canvas> + props)
// <Composition id="Scene" component={Scene} width={1920} height={1080} fps={30} durationInFrames={180} /> // Player vs renderer. The browser <Player> previews; the renderer is HEADLESS.
import { renderMedia, selectComposition } from '@remotion/renderer';
const comp = await selectComposition({ serveUrl, id: 'Scene', inputProps });
await renderMedia({ composition: comp, serveUrl, codec: 'h264',
outputLocation: 'out/video.mp4', inputProps }); // frame-accurate, offline
// On Remotion Lambda a "main" fn resolves props + metadata, then FANS OUT many
// "renderer" fns that each encode a chunk of frames and stream it back to be
// stitched — distributed rendering, billed in Cloud Rendering Units. Parametrization: input props
Because a composition is a function, you can feed it input props — title text,
colours, durations, a data array — and render thousands of personalized variants from one
template. That's how Remotion inputProps and Revideo's parametrization power
“render 10,000 unique videos” pipelines. In the studio below the props are wired to live
controls so you can feel the composition re-evaluate.
The studio: a frame-based mini-engine
Everything below runs on renderFrame(ctx, frame, props) — one pure function in videoEngine.ts, built from a Remotion-style interpolate(frame, [inFrames], [outValues]) (clamp + easing), a sequence() time-shift, and an analytic spring() (Ch.4, in closed form
so any frame samples correctly in isolation). Scrub it, edit the props, then render it to a
downloadable video. The scrubber proves the point: position is a deterministic function of the
frame number, nothing else.
Export steps every frame deterministically and records via MediaRecorder (WebM). WebCodecs is detected and badged but in-browser MP4 muxing needs an extra muxer lib, so we use the dependency-free path.
Browser-native encoding: WebCodecs vs MediaRecorder
How do frames become a file in the browser? Two layers exist. WebCodecs is the low-level, hardware-accelerated path: VideoEncoder / VideoDecoder give you per-frame control with
exact timestamps — its whole advantage is frame-accuracy. The catch is that WebCodecs only
encodes; muxing those chunks into an .mp4 container needs an extra muxer library. MediaRecorder is the high-level path: point it at a captureStream() and it hands you a WebM — trivially easy, no extra deps, but it
offers no frame-rate guarantee. So the studio detects and badges WebCodecs, yet exports
via MediaRecorder for dependency-free reliability — the pragmatic fallback.
Broadly supported in Chromium and Safari; Firefox shipped VideoEncoder/VideoDecoder more recently (≈v130). MediaRecorder + canvas.captureStream() is the universal fallback used for export here.
Capstone: tying the course together
The pieces you've built all snap into this frame paradigm. Imagine one final clip:
- Ch.4 — Spring physics: a UI card springs in. In a renderer the integrator can't be
stateful per-frame; you sample the analytic spring at the current frame, exactly like
our
spring(frame, fps)and Remotion'sspring(). - Ch.11 — Shaders & the GPU: a fragment-shader background, with the frame number fed
in as the
uniform float u_time. Same shader you wrote live — now it's a pure function of frame, so it renders identically offline. - Ch.0 & Ch.10 — Pipeline & canvas: a data-driven
<Sequence>animates a bar chart from a props array, each bar a clampedinterpolate. - This chapter — output: compose the three as time-shifted sequences, parametrize via input props, and hand the whole thing to a renderer for a frame-accurate MP4.
The studio above is a deliberately small version of that combo: a spring-driven title, a tweened
shader-ish background, a moving accent puck, and a data bar — all one pure renderFrame, parametrized, scrubbable, and exportable. Swap canvas for WebGL, the
analytic spring for your Ch.4 numbers, and the linear background for your Ch.11 shader, and you
have the capstone. Animation as code, rendered to frames.