Chapter 12

Programmatic Video (Capstone)

Animation as code, rendered to frames.

Motion CanvasRemotionWebCodecs
Compositions, sequences, interpolation; Player vs renderer; frame-accurate encoding. Motion Canvas uses generators; Remotion renders React to video.

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.

Programmatic video studio — player · props · renderer live demo
frame 0 / 179 time 0.00s / 6.0s fps 30 res 960×540
Input props (live re-render)
Renderer
WebCodecs ✕ MediaRecorder ✕

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.

WebCodecs (VideoEncoder / VideoDecoder) newly available 2024
Chrome/Edge
Firefox
Safari

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's spring().
  • 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 clamped interpolate.
  • 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.