Everything below lives in src/scene/motion.ts unless stated otherwise. The
web runtime and the video compositions both read these tables, which is what
keeps a rendered frame and a live frame identical.
| Quantity | Value |
|---|---|
| Logical scene | 1240 × 1240 units |
| Card size | 704 × 352 units |
| Front card centre | (620.5, 620) |
| Web projection | Container-query units against the scene box |
| Video projection | Absolute pixels, 2100 × 1050 at a 3000 × 3000 scene |
The web stylesheet expresses the card in cqw, so the deck scales with its
container and stays sharp at any size. src/remotion/CardFace.tsx
resolves the same layout to pixels through CARD_UNIT = 2100 / 704.
The choreography has exactly four motion slots. A deck of four or more cards maps onto them: slot = card index modulo 4. Each slot owns a distinct arc, so a seven-card loop never repeats the same movement twice in a row.
| Slot | Exits | Peak rotation | Transition | Z-swap at |
|---|---|---|---|---|
| 0 | right | 8.20° | 22 frames | 45.5% |
| 1 | left | −11.00° | 22 frames | 45.5% |
| 2 | right | 19.50° | 22 frames | 50.0% |
| 3 | left | −11.24° | 23 frames | 59.0% |
Timing is authored in whole frames at 59.94 fps (FRAME_MS). Autoplay waits 8
frames, then advances every 52 / 50 / 52 / 51 frames by slot.
- Outward — the front card travels off its exit side, shrinking to ~48% and rotating toward its peak angle. Sampled from per-slot tables, not from a generic easing curve.
- Z-swap — at the slot's swap point the outgoing card drops behind the stack. The incoming card is already most of the way to the front.
- Return — the outgoing card curves back into the fan and settles into its resting pose.
- Promotion — the incoming card lingers in its fanned pose, then catches up
quickly as the outgoing card crosses behind it (
INCOMING_WEIGHT_SAMPLES).
EFFECTIVE_LOCAL_FRAMES remaps raw progress so the deliberate whole-scene
holds land on the same frames every time. The map stays piecewise linear
between frames, so live playback holds and catches up without quantising all
motion to frame boundaries.
The glass is animated, not static. materialProgress drives a five-stop
gradient from the back-card alphas [0.18, 0.27, 0.31, 0.23, 0.19] to the
front-card alphas [0.44, 0.43, 0.40, 0.28, 0.22], together with the backdrop
blur (1.45 → 3.39 cqw) and saturation (0.84 → 0.78).
Two rules hold everywhere:
- Only the surface layer is blurred. The content layer — the prompt copy — is never filtered, so text stays native and sharp through the motion.
- Blur is a depth and velocity accent confined to the stack crossover. A card is still crisp during its first outward frames.
The web runtime accepts query parameters and exposes a debug handle:
/?card=3 render a stable card
/?card=3&progress=0.45 freeze a transition mid-flight
/?autoplay=1 run the loop unattended
window.__cardStack.advance()
window.__cardStack.renderCard(2)
window.__cardStack.renderProgress(0.5)
window.__cardStack.topCard()
window.__cardStack.readydata-ready, data-animating and data-top-card on the stack element make
the same state visible to a test runner without reading internals.
src/remotion/timeline.ts holds the cut: 780 frames at 60 fps (13.00 s) with
six swipes.
| Swipe | Starts at frame | Duration | Slot |
|---|---|---|---|
| 1 | 93 | 22 | 0 |
| 2 | 208 | 22 | 1 |
| 3 | 323 | 22 | 2 |
| 4 | 437 | 23 | 3 |
| 5 | 552 | 22 | 0 |
| 6 | 666 | 22 | 1 |
Each swipe carries the matching stereo cue: the right-exit cue for slots 0 and 2, the left-exit cue for slots 1 and 3.
src/scene/nativeAudio.ts synthesizes the cue at runtime from filtered noise
and two oscillators — no audio file is loaded in the browser.
scripts/generate-sfx.mjs is a sample-accurate offline port of that same
graph, so the WAV files the video renders with are the cue you hear on the
page.