Motion · Films

Rapido, in motion.

A 30-second showreel, plus a 30-second film for each part of Rapido. All five are drawn live in your browser: every frame is computed from a single number, the time. Watch them below, download the MP4s, then see how they were built.

Live · rendered in your browser

Watch it live

00.00s / 30.00s
01 Ignition
02 Headline
03 Orbit
04 Portal
05 PO2Order
06 MCP
07 Proof
08 Type
09 Lockup
How it was built

One function of time.

There is no timeline editor and no keyframe file. The whole reel is one JavaScript function, render(t), that draws a complete 1920 × 1080 frame for any moment t between 0 and 30 seconds. Nothing carries over from the previous frame.

That one constraint does most of the work. Scrubbing, looping, slow motion and the offline MP4 export all fall out of it for free: to get frame 437, call render(437 / 60). Randomness is seeded by index, so a particle's path is the same in every render.

Films
5 · 2:30
Frames
9,000 @ 60 fps
Canvas
1920 × 1080
Sub-frames
8–32 per frame
Shared engine
motion.js
Dependencies
0
Storyboards

Every scene, every exit.

Each scene owns one idea and one way out. Pick a film, then click a scene to jump the player to it.

Motion

Springs, not keyframes.

Anything that lands, like letters, cards, chips or the mark itself, lands on a damped spring. It overshoots, settles, and feels physical. Anything that travels, like camera moves and wipes, uses exponential easing, so it leaves fast and arrives soft. Staggers of 20 to 90ms turn one motion into a wave.

Seven impacts drive a decaying camera shake. A flash frame and a shockwave that brightens the dot grid as it passes sell each hit.

// Damped spring, 0 → 1. dt = seconds since release.
const spring = (dt, freq = 2, damping = 6.5) =>
  dt <= 0 ? 0 : 1 - Math.exp(-damping * dt) * Math.cos(2 * Math.PI * freq * dt);

// "Accredo," — each letter released 45ms after the last.
glyphs.forEach((g, i) => {
  const s = spring(t - 2.16 - i * 0.045, 1.5, 6.5);
  ctx.translate(g.x + g.w / 2, baseline + (1 - s) * 300);
  ctx.rotate((1 - s) * 0.5);        // pivots from the baseline
  ctx.fillText(g.ch, -g.w / 2, 0);
});
Pacing

Holds without dead frames.

The first cut ran 15 seconds and never stopped to breathe. The 30-second cut keeps every transition at exactly the same speed and spends the extra time on holds, so the message lands and the cuts still hit hard.

Each scene is authored on its own clock, and a piecewise-linear map decides how output time flows through it: full speed through a transition, slowed through a scan, nearly frozen on a finished order. Ambient motion (orbits, data packets, pulses, grain) runs on real time, so a hold never looks like a paused video.

// Output time → the scene's authored clock.
// Slope 1 = as authored, < 1 = slowed, ~0 = a hold.
const mapB = pw([
  [12.30, 6.90],   // PO2Order enters
  [13.00, 7.60],   // scan starts
  [15.36, 8.90],   // scan at 0.55× so every line reads
  [15.71, 9.25],   // stamp, full speed
  [16.75, 9.45],   // hold on the finished order
  [17.20, 9.90],   // slice transition, full speed
  ...
]);

S4(ctx, mapB(t));          // choreography: remapped
dotGrid(ctx, RT);          // ambient motion: real time
The edit

Transitions are brand shapes.

No crossfades. Every cut is made from something already on screen, and the mark gets the first and last word.

01

Zoom-through

The mark becomes the door into the headline.

02

Match cut

A full stop becomes a circle becomes a background.

03

Card expand

A UI node grows into the scene it names.

04

Button press

A click on Reorder grows into the order it creates.

05

Slice

Nine bands, alternating, staggered 25ms apart.

06

Whip pan

inOutExpo over 0.32s. Motion blur does the rest.

07

Bar wipe

An underline stretches into the next colour.

08

Triangle iris

The mark closes the loop. Brand shapes are the edit.

Rendering

Motion blur by supersampling time.

Canvas has no motion blur, but a pure function of time doesn't need it. For each output frame the exporter draws eight sub-frames spread across half a frame (a 180° shutter, like a film camera) and averages them. Fast moves smear, slow ones stay sharp. During the ten transitions, where the camera can cross a full frame-width in a few frames, that goes up to 32 sub-frames so the blur reads as a smear, not a stack of copies.

Grain, the key light and the vignette are drawn once, after the average, so they stay crisp. The showreel HUD (timecode and scene counter) is only on the live player; the MP4 is clean so it can go straight into a deck. Turn on Motion blur above to see a lighter version live.

// Motion blur = averaging sub-frames across a 180° shutter.
for (let s = 0; s < samples; s++) {
  const ts = t + ((s + 0.5) / samples - 0.5) * (0.5 / fps);
  drawScene(layer, ts);
  ctx.globalAlpha = 1 / (s + 1);    // running average
  ctx.drawImage(layer.canvas, 0, 0);
}
Pipeline

From canvas to MP4.

  1. Build the site. Astro self-hosts Inter and JetBrains Mono, so the export uses exactly the fonts this page uses.
  2. Open the page headless. Playwright drives Chromium to this URL and waits for the fonts.
  3. Render every frame. A 1920 × 1080 canvas renders each frame from 8 sub-frames (32 in transitions) and returns it as a lossless PNG.
  4. Encode. The PNGs stream straight into ffmpeg's stdin: libx264, CRF 20, +faststart so it plays before it finishes downloading.
npm run build
node scripts/render-showreel.mjs --reel portal
# → public/animation/rapido-portal.mp4 (H.264, CRF 20, faststart)
# → public/animation/rapido-portal-poster.jpg

# Review stills without re-rendering (reels load live from public/):
node scripts/render-showreel.mjs --reel portal --stills 3 4.25 12
Design system

Only brand tokens.

No colour or typeface appears in the reel that isn't already on this site: six tokens from global.css, Inter for display, JetBrains Mono for the HUD and data.

Parchment #F5F0E8
Deep ink #0F1419
Signal coral #E76F51
Warm ochre #E9C46A
Olive #5C8458
Steel slate #465362
Display · Inter 600 Accredo, enhanced.
HUD · JetBrains Mono 500 TC 00:00:07:12