---
name: sandbox
description: Make a narrated vertical reel (1080x1920, 35-55 s) in the "Sandbox" format - Claude itself narrates as a blocky orange voxel mascot that hops along a path of round sand discs under a cream sky, one step of the story per disc, while white UI cards build themselves out of flying cubes above it and voxel props burst apart; dark "CLAUDE · MADE THIS" pills, a step chip with dots, word-by-word captions with the newest word in chartreuse, a title card a firecracker sets on fire, a crane over the whole path as it lights up chartreuse, and COMMENT + a keyword built of voxels. One ElevenLabs voice and a music bed, rendered frame by frame in headless Chrome (three.js) and encoded with ffmpeg. Use it when someone asks for a sandbox reel, "sandbox.md", a voxel Claude explainer, a "Claude made this" step-by-step reel, or wants to turn a script into this kind of video.
---

# Sandbox reel: how to make one

You will make a **35-55 second vertical video (1080x1920, 30 fps, H.264 + AAC, at most 60 s and 60 MB)** in which **Claude tells the story itself**. A blocky orange voxel Claude stands on a **sand disc** (a round patch of square beige tiles) in a flat cream world. The story is a **path of discs, one step per disc**: the mascot hops from disc to disc while the camera whips after it. Above it, white **UI cards build themselves out of flying white cubes** and blow back into cubes when they leave, and **voxel props** (a bookshelf, a stone wall, a keyboard, a bin) assemble and burst the same way. Two dark mono pills sit at the top, a **step chip** counts the discs, and the **captions show the last four words of the clause with the newest word in chartreuse**. It opens on a **title card that a firecracker sets on fire**, and it ends with the camera **craning over the whole path while it lights up chartreuse**, diving into the last disc for **COMMENT + the keyword built of voxels**.

**`reference.jpg`** (next to this file, if you have it) is a contact sheet of 12 frames from a finished Sandbox reel: **this is what it should look like.** Left to right, top row first: (1) the hook: a cream title card "Never pay for a / Claude skill again ✳" with a red "$30" sticker over the mascot holding a lit firecracker; (2) the card burning from the right in pixel fire, embers flying up-right; (3) "IT'S ME. / CLAUDE." top-left with a dark chip under it; (4) a black stepped doorway behind the mascot with a dark app screen inside and two chips; (5) a white card with two file rows, "$0", a chartreuse FREE chip and a "Shared ✓" button; (6) a dark glossy "ULTIMATE" product card; (7) a card with two rows, red "✗ $30" and green "✓ FREE"; (8) the mascot standing on a voxel keyboard under a typing card; (9) a checklist card with green bars and a voxel bookshelf; (10) a grey stone wall with a gold padlock bursting into cubes; (11) the recap: the whole path from above, every disc chartreuse with a label pill; (12) the CTA: green tiles, "COMMENT", "SANDBOX" in terracotta voxels, a chip, the mascot holding a terracotta "THE FILE" slab. If you only have this file, section 3 describes everything with numbers.

Everything is rendered **one frame at a time** from a three.js page in headless Chrome (`window.setT(t)`), then muxed with a mix made in ffmpeg. This file gives you the look (exact colours, fonts, sizes, positions), the grammar of the edit, the rules that keep it from looking buggy, the complete tested engine (Appendix A: write it once) and a complete example video (Appendix B). With the engine written, **a video is three small files**: `script.mjs` (what Claude says), `scene.js` (the director file: what appears when, declared with the kit) and `cues.mjs` (sound effects).

Work in this order and don't skip ahead: **setup -> script -> fake timings -> direct the scene -> stills -> voice -> real timings -> fix the beats -> stills -> full render -> sound + mix -> encode -> check -> deliver.**

---

## 0. Rules of engagement (read first)

1. **Be gentle with the user's computer.** One headless browser, on the GPU, at the lowest priority, pinned to one CPU thread, never parallel workers. If WebGL is on the CPU (SwiftShader, llvmpipe, "software"), `cdp.mjs` refuses to render: tell the user to fix the graphics driver.
2. **Never print secrets.** The key is `ELEVENLABS_API_KEY` (environment, or `.env` in the project folder). Scripts read it; you never echo it, log it or put it in a file you show.
3. **Spend credits deliberately.** `gen-vo.mjs` and `gen-sfx.mjs` are dry runs unless you pass `--yes`. One voice take of the whole script (~1 credit per character, ~700-950 for a reel). Never regenerate a take that exists: fix a wrong word with `voedit.mjs` (it splices words from another take).
4. **Stills before film.** Direct the whole scene on fake timings and check stills before you pay for the voice, and never start the full render until the stills contact sheet is right.
5. **Background jobs die when your turn ends.** Render in foreground chunks (`render.mjs my-video full 0 700`, then `full 700`), each call under your tool timeout. The render resumes where it stopped.
6. **No real people's content.** No real screenshots, logos, app icons, handles, view counts or other creators' frames. Every card is drawn; an app's screen is a stylised drawing. Counters count only what is said.
7. **Don't publish anything.** You deliver files. Posting is the user's call.
8. If the user's instructions conflict with this file, the user wins. If something is missing (no key, no GPU, no ffmpeg), say exactly what and offer the closest thing. Never fake success.

## 1. Requirements

- **Node 22+** (global `fetch` and `WebSocket`), **ffmpeg + ffprobe** on PATH, **Chrome or Edge** (or set `CHROME=<path>`), a GPU. Chrome is driven through ANGLE: d3d11 on Windows, metal on macOS, gl on Linux (if the GPU probe fails on Linux, try `SANDBOX_ANGLE=vulkan`). Debug ports 9650-9699 on 127.0.0.1.
- **three.js 0.170.0** from npm (pin it; the import map points into `node_modules/three`).
- **An ElevenLabs API key.** The free plan (about 10,000 credits a month) is enough to try the format but has no commercial licence; videos that promote anything need a paid plan. No key yet: do everything up to the stills with `build.mjs --fake` and ask for the key.
- Fonts and music are free downloads (`setup.mjs` fetches them). The 25 sound effects are made once with ElevenLabs sound generation (`gen-sfx.mjs`, ~30 s of sound), or downloaded free (Mixkit) and saved under the names in section 10.

## 2. Project layout and setup

```
sandbox-reel/                    one project folder, many videos
  package.json                   { "type": "module", "dependencies": { "three": "0.170.0" } }
  .env                           ELEVENLABS_API_KEY=...   (never print it)
  engine/                        Appendix A: write these files once, never edit them per video
    film.html main.js            the page: fonts -> film.json + scene.js -> window.setT(t)
    kit.js                       THE TOOLKIT scene.js uses: path + hops, mascot beats, hook, props, cards, camera, recap, CTA, HUD
    layouts.js cards.js          ready card layouts · the card system + drawing helpers
    vox.js props.js mascot.js    voxel models that build and burst · prop builders, particles · the voxel Claude
    world.js overlay.js          sky, floor, discs, labels · HUD pills, step chip, captions, titles, safe zone
    post.js camera.js fx.js lib.js     post chain · SHOTS -> camera · whips/flash/punch · helpers
    common.mjs cdp.mjs setup.mjs render.mjs gen-vo.mjs voedit.mjs build.mjs gen-sfx.mjs mix.mjs encode.mjs check.mjs
  fonts/  music/  sfx/           downloaded / generated once
  my-video/                      one folder per video (Appendix B is a complete one)
    script.mjs  video.json       what Claude says (+ how to say symbols); settings
    scene.js  cues.mjs           the director file; the sound effects
    vo/ film.json stills/ frames/ mix.wav video.mp4 cover.jpg
```

```bash
mkdir sandbox-reel && cd sandbox-reel
echo '{ "type": "module", "private": true, "dependencies": { "three": "0.170.0" } }' > package.json
npm install
# write engine/* from Appendix A, then:
node engine/setup.mjs            # checks Node/ffmpeg/three/Chrome/GPU/key, downloads fonts + music
node engine/gen-sfx.mjs          # dry run: lists the 25 sounds; then --yes to make them once
```

A video:

```bash
mkdir my-video                    # write my-video/script.mjs (section 4), copy scene.js + cues.mjs from Appendix B and rewrite them
node engine/build.mjs my-video --fake                                 # guessed timings: direct before you pay for a voice
node engine/render.mjs my-video stills auto 10 --safe                 # look at my-video/stills/sheet-safe.jpg; fix; repeat
node engine/gen-vo.mjs my-video                                       # dry run: the text the voice will read + its cost
node engine/gen-vo.mjs my-video --yes                                 # ONE take -> my-video/vo/a.*
node engine/build.mjs my-video                                        # real timings: prints every caption word with its time
node engine/render.mjs my-video stills 1.3,4,8,12,18,24,30,36,40 --safe
node engine/render.mjs my-video full 0 700 && node engine/render.mjs my-video full 700      # ~0.17 s a frame on a mid GPU
node engine/mix.mjs my-video && node engine/encode.mjs my-video && node engine/check.mjs my-video
```

## 3. The look, pinned down

### 3.1 The frame, layer by layer (1080x1920 px)

| Layer | Where | What |
|---|---|---|
| Sky | everything behind | flat cream `#f1e2cc`; fog in the same colour swallows the floor from ~11 to ~34 units, so there is no horizon line, just haze |
| Floor | lower ~45 % | bare sand `#e3d3b7` with **discs**: round patches of square tiles (0.305 units on a 0.34 grid, so thin sand-coloured gaps), each 0.035-0.08 high in 7 beiges, ragged edges; narrow tile trails join the discs |
| Mascot | centre, feet at y ≈ 1460, body ≈ x 330-750, y 980-1450 at a station | the voxel Claude, about 40 % of the frame width |
| Card | the standard spot: centred, y ≈ 400-890, ≈ 745 px wide | white UI card with a soft white glow, floating above the mascot (in 3D, facing the camera) |
| HUD | y 262-312 | left: dark pill "✳ CLAUDE · MADE THIS" from x 60; right: dark pill with the topic (e.g. "FREE LIBRARY", "BEHIND THE REEL") ending at x 954 |
| Step chip | y 324-360, x 60 | dots + "03 / LABEL", from the first step to the CTA |
| Captions | centred on x 510, y 1376-1444 | a dark box with the last 4 words, the newest in chartreuse |
| Titles | top-left from y ≈ 360, or centred at y 470 | "IT'S ME. / CLAUDE." (beat 2) and "COMMENT" (the CTA) |

**Safe zone (every platform covers the edges):** all text, including in-world card text and labels, inside **x 60-960, y 250-1480**; captions inside y 1150-1480, at most 820 wide, centred on x 510. Nothing important in the top 250 px, the bottom 440 px or the right 120 px. The overlay records every text box it draws; `render.mjs ... --safe` draws the zone and the boxes, every run prints anything outside it, and the full render logs every frame for `check.mjs`.

### 3.2 Colours

| Token | Hex | Used for |
|---|---|---|
| sky / fog | `#f1e2cc` | the background, the haze |
| sand | `#e3d3b7` | bare floor between discs |
| tiles | `#efe3cf #e9dbc3 #e3d4b9 #ebdcc0 #f3e9d7 #decdb0 #e6d9c6` | disc tiles (random per tile) |
| lit tiles | `#dff45c #d6ef55 #e5f86d #cfe84e #daf262` | the recap sweep and the CTA floor |
| mascot | `#cd6f51` ±5 % lightness per voxel; face `#232222` | the voxel Claude |
| pill | `#2e2c28` (text white, ✳ `#e2795c`) | HUD pills, chips |
| step chip | `#45413c`, dots done `#e6fd5e`, to do `#6a655d` | the step chip |
| caption box | `rgba(43,38,32,0.96)`, text `#ffffff`, newest word `#e6fd5e` | captions |
| card | body `#fdfcfa`, field `#f5f1e9`, ink `#1d1b19`, muted `#8f877c`, header `#a39b90` | UI cards |
| accents | terracotta `#d9775a`, green `#2f9e5b`, red `#d4483b`, chartreuse `#e6fd5e` | dots, ✓, ✗, FREE chips |
| CTA | voxel word `#cb6a4c`, slab `#d9694d` / sides `#c95f45` | the end card |

### 3.3 Fonts

**Inter** (variable, 400-900) for captions (700, 46 px), titles (600/500), card text; **JetBrains Mono** (variable, 600-800) for pills, chips, card headers and labels (caps, letter-spaced); **Inter Tight** 900 for the slab's big words. All SIL OFL from google/fonts; `setup.mjs` downloads them into `fonts/`.

### 3.4 The world

- **Discs** sit along a path that goes away from the first camera (`z` decreasing about 4.7 units per disc) and zig-zags left and right (`x` between -3 and +3), so from above it reads as a winding path. Radius 2.2-2.6. Nine discs for a 43 s reel; the first is the hook's, the last the CTA's.
- **Light:** a warm hemisphere (sky `#fff7ec`, ground `#d9c7a8`) plus a warm key from the right and above (`#fff1df`, 2.1) with soft shadows (the mascot's shadow falls to its left), and a soft fill from the left. The mascot's front face reads `#b56348`-`#c36f57`, its lit side `#d3775b`.
- **Depth of field** at every station (aperture 6.5): the mascot and the card are sharp, the far discs soft. **No grain to speak of, no chromatic aberration, a light bloom**, and no ACES: the cream must stay cream.

### 3.5 The mascot (`mascot.js`)

- **Body** 13 x 10 x 8 voxels of 0.1 units (1.3 x 1.0 x 0.8), on **four legs** 2 x 4 x 2 voxels in a row (1 voxel gaps), with a **paw** on each side (a stubby 2-wide, 3-tall block with a thumb, at mid-height) that rotates at the shoulder. Total height 1.4.
- **Face** on the front: two black eyes, 1 x 2 voxels, at a quarter and three quarters of the width, three voxels below the top. **The mouth exists only while it talks**: 1-6 black voxels below the eyes in five shapes, picked from the voice's loudness every frame (`film.json` `env`). Silent = no mouth, like the reference.
- **It never stands still:** it breathes, the paws bob with the voice, it blinks rarely. Between discs it **hops twice**, turning sideways toward where it goes and back to the camera on landing. On stressed words it does a **small hop** (`K.mascot({ hops })`), on "It's me" it **waves** (`wave`), it can **hold** something on its right paw (`hold`) and **stand on** a prop (`K.keyboard` does that).
- **It builds itself:** in the first second its voxels fall from above and land in place (frame 0 = a cloud of falling cubes over the title card).

### 3.6 Cards (`cards.js`, `layouts.js`)

- **The look:** white `#fdfcfa`, corner radius 34 px (canvas), a 60 px soft **white glow** around it and a faint warm drop shadow. Inside, a **header** in small grey mono caps with a terracotta dot ("● CHECKOUT · CONTENT SKILL"), often a light **field** `#f5f1e9` holding the content, body text in Inter 56-64 px (canvas; ×0.72 on screen), big numbers 104-124 px, chips as rounded pills.
- **They build themselves:** a grid of white cubes (18 across) flies in from a cloud around the card's spot in 0.35-0.55 s, each cube landing on its own delay; as the last ones land, the cubes shrink away and the card fades in through them. **They leave by bursting** into the same cubes, usually as the mascot takes off for the next disc. Within one disc a card can burst and the next build in the same cloud (a card "morph").
- **The standard spot:** 2.0 x 1.25 units at height 2.2, z -0.25 over the disc, centred above the mascot (≈ 745 x 465 px on screen). Smaller cards: 1.45-1.75 wide. Bubbles: one line, pill-shaped, 0.34 high, scattered around the mascot at y 1.9-2.8.
- **Layouts** (`layouts.js`), each one drawn every frame so it can animate: `typing` (a message typing with a caret), `counter` (a number that counts up to what the voice says, with a button the cursor clicks), `checklist` (rows that fill one by one and tick), `compare` (rows that appear at their own times, ✓ green or ✗ red), `file` (a plain-text file in mono with a stamp that slams on), `price` (an amount, a period and a red strike-through), `product` (a dark glossy card for anything over-packaged), `pill` (one line). Anything else: draw it with the helpers in `cards.js` (`header`, `field`, `fileRow`, `txt`, `chipBox`, `bar`, `cursor`, `typed`, `caret`, `rr`) the way Appendix B draws its giveaway card.

### 3.7 Props (`props.js`, `vox.js`)

- Every object is **voxels** (instanced cubes with a faint seam on each face and ±3.5 % colour jitter). Builders: `bookshelf`, `doorway` (black stepped frame), `keyboard`, `paywall` (grey stone wall + gold padlock), `puppetBar`, `page`, `bin`, `firecracker`; your own with `vox.box(x, y, z, w, h, d, colour, list)` and `vox.text('WORD')`.
- **They build and burst like the cards**: in `swarm` (from a cloud), `fall` (from above), `rise` (from below) or `pop`; out `burst` (outward, then shrink), `crumble` (falls in place) or `swarm` to a point. A prop can swarm in from where the last one burst (give `in` a world point) so one object seems to turn into the next.
- **Placement:** behind the mascot at z -1.0 to -1.4 and x ±0.8-1.3 (it stays readable over its shoulder); in front at z +0.8 when the voice says it gets destroyed (a wall that blocks the mascot, then bursts on the word). Voxel size 0.06-0.1; keep a prop under ~2 units wide.
- **Colours must read against the mascot:** grey, black, white, cream or multicolour. A terracotta prop vanishes behind the terracotta mascot.

### 3.8 The overlay (`overlay.js`)

- **HUD pills** (every frame): JetBrains Mono 700 21 px, letter-spacing 0.14 em, white on `#2e2c28`, fully rounded. Left "✳ CLAUDE · MADE THIS" (the ✳ terracotta). Right: the topic in one or two words.
- **Step chip** (from the first step to the CTA): one dot per disc after the first (done = chartreuse, the current one slightly bigger for 0.35 s), then "03 / LABEL" in mono 18 px. It changes on the step's first word, the same moment the mascot lands.
- **Captions:** the words of the current clause appear as they are said; the box shows **the last 4** and **the newest is chartreuse**. A comma or full stop starts a new clause (the box resets). The last box stays up through pauses. Inter 700 46 px, box `rgba(43,38,32,.96)`, radius 13, centred on x 510 at y 1410.
- **Titles:** "IT'S ME." (Inter 600 128 px, `#1d1b19`) and "CLAUDE." (terracotta) under it, blurring in on their words, top-left from x 66, with a small chip "SYNTHETIC VOICE · MADE BY CLAUDE" under them. "COMMENT" (Inter 500 104 px) centred at y 470 on the CTA.
- **Small chips** (mono, dark or terracotta pill) pop in with a little overshoot to label a beat ("MORPHY · AGENTIC SUPER APP", "BUILT BY CASPER").

### 3.9 The signature moments (keep all six)

1. **The hook (0-3 s).** Frame 0: the cream title card (`#f7f3e9`, Inter 800 two lines, a terracotta ✳ after the second line, a red sticker on its corner with the number or price) and a cloud of terracotta cubes falling into the mascot. By ~0.7 s the mascot stands there holding a **red firecracker** on its right paw, fuse fizzing (tiny yellow-orange flame cubes, white smoke cubes). On the hook's last word a **trail of sparks** runs up to the card's right edge and the card **burns right to left in pixel fire** (an 18 px block grid: yellow at the front, orange, then a black char line), embers drifting up-right, gone ~1 s later.
2. **"It's me, Claude."** The mascot faces the camera and waves; "IT'S ME." appears, "CLAUDE." blurs in on its word, the chip under them; a little hop on "Claude.".
3. **The whip-hop.** Leaving a disc: the card bursts, the mascot hops twice sideways to the next disc, the camera follows with horizontal motion blur; on landing the next card is already swarming in and the step chip ticks.
4. **Every claim is an object.** A price -> a counter that counts to it; "took it" -> bubbles; "no paywalls" -> a stone wall that bursts on the word; "no strings attached" -> strings that snap; "free" -> a FREE stamp; a message -> a card typing it while the mascot types on a voxel keyboard.
5. **The recap.** On the line that reveals the point ("And this exact format?"): the camera cranes straight up until the whole path is in view (labels on every disc, dark mono pills), then the discs **light up chartreuse one after another from the first** (0.11 s each, each from its centre out) with a shimmer; the last disc's label grows on its name; the camera **dives** into the last disc.
6. **The CTA.** The last disc is chartreuse; "COMMENT" appears, **the keyword builds itself out of terracotta voxels** (11 voxels tall, ~2 units wide) with a cube-rain sound and a small flash, a chip promises what they get, and the mascot holds a **terracotta slab** with "THE / FILE" (or GUIDE, SETUP…) in huge white Inter Tight and the file name under it. The word floats, the camera creeps in.

### 3.10 Post (the numbers, in `post.js` / `fx.js`)

A soft shoulder instead of ACES above 0.82 (`1 - 0.18·e^(-(x-0.82)/0.18)`), bloom 0.3 at threshold 0.93, depth of field per shot (aperture 6.5 at stations, 3 on moves, ~0.5 from above), vignette 0.1, grain 0.012, no chromatic aberration. Whip blur on hops 0.04-0.05 (horizontal), a vertical blur on the dive, flashes capped at 0.24 (the example uses 0.10-0.12), a 5 % zoom punch out of the CTA cut.

## 4. Writing for this format

The script matters less than the pictures, but the pictures need a script with this shape:

- **Claude narrates in the first person** ("I", "me"; "we" with the user). Its voice is fast and bright: ~3.6-3.9 words a second, so **110-160 words = 35-50 s**.
- **Shape:** the hook line or two (the claim; it goes on the title card, shortened) -> "It's me, Claude." and one line of who or where -> **4-8 steps, one per disc, a sentence or two each** -> one reveal line for the recap ("And this exact format? It's one of them.") -> the CTA: "Comment KEYWORD, and I'll send it to you." The keyword is one word (it is built in voxels: ≤ 8 letters reads best).
- **Every step starts on a clear word** (it changes the chip and lands the hop) and says something **showable**: a number, a price, a file, a message, a list, a before/after. Name each step in one or two words for the chip ("GIVEN AWAY", "$30", "LIBRARY").
- `script.mjs`: `LINES` = what the captions show, one sentence (or two short ones) per line; `SAID` = how the voice says a token with dots or symbols (`'SKILL.md': 'skill dot M D'`, `'$30': 'thirty dollars'`, `'.md': 'dot M D'`) and a keyword it must stress (`'SANDBOX': '"Sandbox"'`).

## 5. The voice (ElevenLabs)

- **Liam** (`TX3LPaxmHKxFdv7VOQHJ`) on **eleven_multilingual_v2**, stability 0.45, similarity 0.8, style 0.45, speaker boost, **speed 1.2**. That matches the reference's young, bright, quick narrator (median pitch ~132 Hz, 3.8 words/s). eleven_v3 has no speed setting and reads this too slowly. Another voice: put `voice` in `video.json`; try it first with `--test 3 --take t1` (the first three lines, a few hundred credits).
- **One take of the whole script.** Listen. If one word is wrong, don't pay for a full new take: make a short `--test` take that says it right and splice (`voedit.mjs --list a`, then `--out b --use t1:0-26,a:27-`). `--pause 7:0.3` adds room for a visual beat after word 7 (the burn, the title, a big burst, the crane).
- `video.json` `voOff` (0.25 s) delays the voice so frame 0 is the hook without a word on it.

## 6. Timings: film.json

`build.mjs` maps the shown tokens onto the said words (so "SKILL.md" spans "skill dot M D"), moves each word's start to where its sound starts, and writes `film.json`: `words` (each caption token with `t0`/`t1`, its line and clause), `env` (loudness per frame for the mouth) and `END` (last word + 1.7 s tail). It prints every token with its time, line by line: **you key everything to these**. In `scene.js`, `K.T('word')` is when that token starts (`K.T('free.', 2)` = its 2nd occurrence; punctuation is part of the token) and `K.TE('word')` when it ends. `--fake` guesses timings from the text (~3.8 words/s) so you can direct and render stills before the voice exists; re-run it without `--fake` after the take and only nudge what moved.

## 7. Directing with the kit

### 7.1 The scene file

```js
export const WORLD = { discs: [ { x: 0, z: 0, r: 2.6, label: 'CLAUDE' }, { x: -2.9, z: -4.8, r: 2.3, label: 'STEP ONE' }, … ] };
export const STEPS = [['STEP ONE', 'First,'], ['STEP TWO', 'Then'], …];     // chip label + the word the step starts on (+ occurrence, hop shift)
export function stays(film) { … }                                           // [arrive, leave] per disc from those words (copy it from Appendix B)
export default function setup(E) {
  const STAY = stays(E.film), K = E.kit({ discs: WORLD.discs, stay: STAY }), { T, TE, props: P, layouts: LAY, cards: C, O } = K;
  K.hud({ left: 'CLAUDE · MADE THIS', right: 'YOUR TOPIC' });
  K.steps(STEPS.map(([label, word, n = 1]) => [label, T(word, n)]));
  K.hook({ lines: ['First line of', 'the hook'], sticker: '$30', burn: T('lastword.'), until: T('Next') });
  // … one or two cards / props per disc, the mascot's beats …
  K.path({ from: 1, to: WORLD.discs.length - 2 });
  K.recap({ up: T('reveal?') + 0.25, sweep: T('word'), name: T('name.'), cta: T('Comment') });
  K.cta({ at: T('Comment'), word: 'KEYWORD', wordAt: T('KEYWORD,'), chip: "I'LL SEND YOU THE …", chipAt: T('and', 3), slab: ['THE', 'FILE', 'name.md', 'free'] });
  return K.done();
}
```

### 7.2 The kit (everything a scene declares; times are seconds)

| Call | What it does |
|---|---|
| `K.T(word, n)` · `K.TE(word, n)` · `K.at(disc, dx, y, dz)` | word start/end times · a world point relative to a disc (y absolute) |
| `K.hud({ left, right })` · `K.steps([[label, t], …])` | the HUD pills · the step chip (one entry per disc after the first) |
| `K.hook({ lines: [a, b], sticker, burn, until, disc?, x?, y?, w?, h? })` | the title card that burns at `burn` + the firecracker on the right paw until `until`; `K.hookTitle.pos` is where the card was (a prop can swarm in from it) |
| `K.mascot({ hops: [[t, dur, height]], wave: [[t0, t1]], hold: [[t0, t1, angle]], stand: { disc: y } })` | the mascot's beats (it talks, idles, falls in at the start and hops between discs by itself) |
| `K.card(draw, { disc, x, y, z, w, h, in: [t0, dur], out: [t1, dur?, 'burst'\|'swarm'], dark, bg })` | a UI card at a disc (default the standard spot); `draw(g, W, H, t)` from `layouts.js` or your own |
| `K.bubble(text, o)` · `K.pill(text, o)` | a one-line speech bubble · a chartreuse pill ("FREE ✓") |
| `K.prop(voxels, { disc, x, y, z, rotY, size, in: [t0, dur, type, fromWorld?], out: [t1, dur, type, toWorld?], spread, follow })` | a voxel prop; `y` is height above the floor; `follow(m, t)` moves it every frame |
| `K.keyboard({ disc, in, out, typing: [t0, t1] })` | a voxel keyboard the mascot stands on; keys flash terracotta while it types |
| `K.portal({ disc, z, in, out, screen, screenAt })` | the black stepped doorway with a glowing screen (`screen(g, W, H, t)` draws it) |
| `K.toss({ disc, from: [dx, y, dz], bin, at, show: [t0], draw })` | a small tag that hangs, then flies into the `bin` prop at `at` |
| `K.shot(t0, t1, { p0, p1, l0, l1, ease, ap })` | any camera shot: P = position keys, L = look-at keys (= the focus) |
| `K.path({ from, to, cam: { disc: { dx, lx, ly, d, y, push } } })` | a station shot per disc from..to + a whip move from the disc before; `cam[from-1]` is where the first move starts |
| `K.recap({ up, sweep, name, cta, leave?, dive?, ctaCam? })` | crane up at `leave` (default: when the mascot leaves the second-last disc) → over the path by `up` → sweep from `sweep` → the last label grows at `name` → dive at `name + 0.35` → the CTA framing at `cta` |
| `K.cta({ at, word, wordAt, chip, chipAt, slab: [big1, big2, small1, small2], comment? })` | COMMENT + the voxel keyword + chip + the slab on the mascot's right paw |
| `K.flash(t, amount)` · `K.world(fn)` · `K.after(fn)` · `K.overlay(fn)` | a flash · anything else per frame (3D) · things that face the camera · extra 2D (titles, chips) |
| `return K.done()` | hands the engine `SHOTS`, `world`, `after`, `effects`, `overlay` |

### 7.3 Where things go (world units; the floor is y 0, a station camera looks along -z)

- **A station:** the mascot at the disc centre; the camera 7.1 back, 2.15 up, looking at y 1.45 (fov 40), pushing in 0.55 over the stay with a ±0.18 orbit (alternating sides). With that framing: a card at the standard spot fills y ≈ 400-890 px; a prop at z -1.3, x 1.3 shows over the mascot's shoulder; anything at y > 3 is near the HUD.
- **The opening disc** gets its own shots with `K.shot`: the hook close-up (camera `[0, 2.0, 6.5] → [0, 1.95, 6.0]`, look at `[0, 1.75, 0]`), a pull-back for whatever builds behind it (`[0.45, 2.15, 7.9]`, look `[0.45, 1.25, -0.8]`), the "It's me" shot (`[0, 1.75, 6.4]`, look `[0, 1.25, 0]`), and a wider one for a doorway or a big prop (`[0, 2.05, 7.6]`, look `[0, 1.6, -0.6]`). Each new shot eases in from the last one.
- **The recap** computes its own height so every disc label sits inside y 250-1480; it needs ~4 s between the mascot leaving the second-last disc and the CTA.

### 7.4 The edit follows the words

- **On the word, not near it.** A card builds so it is complete as its first important word is said (start it 0.1-0.5 s before). A counter counts during the number. A stamp, a burst, a strike or a hop lands **on** the stressed word (+0-0.3 s), with its sound.
- **One disc = one step = 2-6 s,** one or two cards on it. A card change inside a disc is a burst + build in the same spot.
- **Nothing sits still for more than ~0.3 s:** the mascot breathes and talks, the camera pushes and orbits, cards type, count or fill, cubes fly.
- **Frame 0 is the cover:** the title card, the falling cubes and the HUD, no caption yet.

### 7.5 Pacing (the example)

43 s: the hook and "It's me" on disc 0 (8.7 s, four shots), 7 steps of 1.2-6 s, the recap 4 s, the CTA 4.7 s. A hop takes 0.28 s (0.15 before the step word, 0.13 after). Cards build in 0.35-0.55 s and burst in 0.3-0.4 s.

### 7.6 Motion rules (break these and it looks buggy)

1. **Billboards face the camera after it moves**: anything that faces the camera is oriented in `K.after`, never in `K.world` (the camera is placed between the two).
2. **Cards write depth** (the engine does it) — a plane that doesn't gets blurred by depth of field as if it were the sky.
3. **No `Math.random`.** Every frame renders alone, in any order: use `hash(i)` and seeds.
4. **Same colour = invisible:** nothing terracotta near the mascot except the CTA slab.
5. **Looking straight down needs another up vector:** the recap blends the camera's up from +y to -z and back; copy that if you add an aerial shot, and lift the fog (the kit does it for the recap).
6. Keep in-world text inside the safe zone: cards ≤ 2.0 wide at the standard spot, bubbles inside x ±1.2, the keyword ≤ ~2 units (the kit sizes it).

## 8. Stills and the safe zone

```bash
node engine/render.mjs my-video stills 1.3,2.2,4,5.4,8,12,16,20,24,28,32,36,38,40 --safe
```
Look at `stills/sheet-safe.jpg` (red = keep text out, cyan = the caption band, every text box green or red). The run prints `OUTSIDE SAFE ZONE` lines and page errors: both must be empty. Check: the hook frame (card readable, mascot built, firecracker on the paw), a station (card centred above the mascot, the prop over its shoulder, nothing on the face but the caption), a hop (blur, the next card arriving), the recap (all labels inside, discs lit), the CTA (word inside, slab readable). Then stills without `--safe` for colour.

## 9. The full render

`node engine/render.mjs my-video full [from] [to]` writes `frames/f0000.jpg…` (about 0.15-0.25 s a frame on a mid-range GPU: ~4-6 min for 43 s). It **resumes** (existing frames are skipped): after any change to `scene.js`, delete `frames/` (or the frames you changed). Run it in chunks under your tool timeout.

## 10. Sound

- **The bed:** Mixkit "Coffee or Tea" from 9 s (it is level from there), `db -11.5`: about 13 LU under the voice after the voice ducks it. `STEM=vo` / `STEM=music node engine/mix.mjs my-video` measure them.
- **The kit** (`sfx/<name>.wav`, made once by `gen-sfx.mjs`): `blocks` (cubes rain: the mascot at 0 s, the CTA word), `burst` (a card or prop blows apart; every hop take-off), `implode` (a prop or card builds), `fuse` + `burn` (the hook), `whoosh` (hop take-off), `thud` (hop landing, -24 dB), `swish` / `swoosh` (spark run, flicks, strikes), `pop` (bubbles, pills), `boing` (a little hop), `click` (a cursor click, chips), `ding` (a value lands, a tick), `clack` (checklist rows), `shimmer` (the sweep, something turns good), `chime` (the name, the CTA word), `riser` (the crane), `impact` (a wall bursts), `ff` (a counter running), `slap` (a red ✗ row), `typing` + `enter` (a message), `flick` (strings snap), `clunk` (into the bin), `stamp` (FREE stamp).
- **Levels:** most cues -15 to -22 dB; whooshes -17; landings -24; the hook's blocks and burn -13; the CTA's blocks -14.
- `cues.mjs` gets `film` and can use the scene's `stays(film)` to put a whoosh on every take-off (Appendix B).

## 11. Encode, check, deliver

`node engine/encode.mjs my-video` -> `video.mp4` (H.264 High, yuv420p TV range, crf 20, maxrate ≤ 10 Mb/s, AAC 192k, faststart, ≤ 60 MB) + `cover.jpg` (the frame at `cover`, 1.3 s: the hook). `node engine/check.mjs my-video` checks format, length, size, loudness (-14 LUFS, true peak ≤ -1), frame 0, washed-out frames, stretches where nothing moves and the safe zone on every frame. Then **watch it once with sound** and deliver `video.mp4`, `cover.jpg` and the script. Delete `frames/` (~300 MB).

## 12. Checklist

- [ ] Claude narrates; 110-160 words; one step per disc, each starting on a clear word; the CTA keyword ≤ 8 letters.
- [ ] Frame 0 = title card + falling cubes + HUD. The card burns on the hook's last word.
- [ ] Every step: the chip ticks and the mascot lands on its first word; a card or prop shows the literal claim; something moves every ~0.3 s.
- [ ] Cards at the standard spot, readable (56-64 px body text), building from cubes and bursting on the hop.
- [ ] No terracotta props near the mascot; no real screenshots, logos or handles.
- [ ] The recap: every label inside the safe zone, the sweep from the first disc to the last, the dive.
- [ ] The CTA: COMMENT, the voxel keyword inside x 60-960, the chip, the slab.
- [ ] `--safe` stills clean; `check.mjs` all ✓; watched once with sound.

---

## Appendix A: the engine (write these files exactly)

The browser part (`film.html`, `main.js` and the `.js` modules) and the Node scripts (`.mjs`). Write every file once; a video never edits them.

### `engine/film.html`

```html
<!doctype html>
<html><head><meta charset="utf-8"><title>sandbox</title>
<style>html,body{margin:0;background:#000;overflow:hidden}canvas{display:block}</style>
<!-- /node_modules/ is served from ..\..\node_modules (reel-formats\node_modules) by engine\render.mjs -->
<script type="importmap">{"imports":{"three":"/node_modules/three/build/three.module.js","three/addons/":"/node_modules/three/examples/jsm/"}}</script>
</head><body>
<script type="module">
window.addEventListener('error', e => console.error('ERR', e.message, e.filename, e.lineno));
window.addEventListener('unhandledrejection', e => console.error('REJ', String(e.reason && e.reason.stack || e.reason)));
import('./main.js');
</script>
</body></html>
```

### `engine/main.js`

```js
// Engine boot: fonts → the video's film.json + scene.js → world, mascot, post, overlay → window.setT(t).
// Engine file. The video folder comes from film.html?video=<folder> (relative to the kit root; '.' = the root).
// Every frame is a pure function of film time t, so any frame can be rendered alone, in any order.
import * as THREE from 'three';
import { buildWorld } from './world.js';
import { makeMascot, talkAt } from './mascot.js';
import { makePost } from './post.js';
import * as O from './overlay.js';
import * as L from './lib.js';
import * as FX from './fx.js';
import * as vox from './vox.js';
import * as cards from './cards.js';
import * as props from './props.js';
import { placeCamera } from './camera.js';
import { makeKit } from './kit.js';
import * as layouts from './layouts.js';

const W = 1080, H = 1920;
const q = new URLSearchParams(location.search);
const video = (q.get('video') || '.').replace(/\\/g, '/').replace(/\/+$/, '');
if (/(^|\/)\.\.(\/|$)/.test(video) || /^[a-z]:|^\//i.test(video)) throw new Error('video must be a folder inside the kit: ' + video);
const base = new URL(`../${video}/`, import.meta.url);

// the three free fonts (SIL OFL) in ../fonts: Inter (captions, cards, titles), JetBrains Mono (pills, chips, headers), Inter Tight (the slab)
const FONTS = [['Inter', 'Inter.ttf', '100 900'], ['JetBrains Mono', 'JetBrainsMono.ttf', '100 800'], ['Inter Tight', 'InterTight.ttf', '100 900']];
for (const [n, f, wt] of FONTS) { const ff = new FontFace(n, `url(${new URL(`../fonts/${f}`, import.meta.url)})`, { weight: wt }); await ff.load(); document.fonts.add(ff); }
for (const w of [400, 500, 600, 700, 800, 900]) await document.fonts.load(`${w} 40px Inter`);
for (const w of [600, 700, 800]) await document.fonts.load(`${w} 40px "JetBrains Mono"`);
const film = await (await fetch(new URL('film.json', base), { cache: 'no-store' })).json();
const mod = await import(new URL('scene.js', base).href);
const END = film.END;
window.TOTAL = END; window.film = film;

const renderer = new THREE.WebGLRenderer({ antialias: false, preserveDrawingBuffer: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(1); renderer.setSize(W, H); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.NoToneMapping; renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);
const camera = new THREE.PerspectiveCamera(42, W / H, 0.1, 400);
const scene = new THREE.Scene(); scene.add(camera);
const post = makePost(renderer, W, H);

// 2D overlay layer (composited in the final post pass)
const ocv = document.createElement('canvas'); ocv.width = W; ocv.height = H; const og = ocv.getContext('2d');
const oTex = new THREE.CanvasTexture(ocv); oTex.generateMipmaps = false; oTex.minFilter = THREE.LinearFilter; post.F.tO.value = oTex;
const scv = document.createElement('canvas'); scv.width = W; scv.height = H; scv.style.cssText = 'position:absolute;left:0;top:0;pointer-events:none;display:none'; document.body.appendChild(scv);
let safeOn = false;
window.setSafe = on => { safeOn = !!on; scv.style.display = safeOn ? 'block' : 'none'; };

const E = { THREE, scene, camera, renderer, post, film, END, W, H, O, L, FX, vox, cards, props, talkAt: t => talkAt(film, t), V3: (x, y, z) => new THREE.Vector3(x, y, z) };
E.R = buildWorld(E, mod.WORLD || {});
E.mascot = makeMascot(E);
E.layouts = layouts;
E.kit = cfg => makeKit(E, cfg);
// screen position (px) of a world point with the current camera
E.project = (p) => { const v = new THREE.Vector3(...p).project(camera); return [(v.x + 1) / 2 * W, (1 - v.y) / 2 * H]; };
const V = mod.default(E);
for (const k of ['SHOTS', 'overlay']) if (!V[k]) throw new Error(`scene.js setup() must return ${k}`);

function setT(t) {
  t = L.clamp(t, 0, END);
  V.world?.(t);                                   // the world first (it may move what the camera follows)
  placeCamera(E, V.SHOTS, V.IMPULSE || [], t);
  camera.updateMatrixWorld(true);
  V.after?.(t);                                   // things that face the camera (cards, labels)
  V.effects ? V.effects(t) : FX.apply(E, t, {});
  og.clearRect(0, 0, W, H); O.beginFrame(); og.save(); V.overlay(og, t); og.restore(); oTex.needsUpdate = true;
  post.render(scene, camera, null);
  if (safeOn) O.drawSafe(scv.getContext('2d'));
  return true;
}
window.setT = setT;
window.safeReport = () => O.violations();
window.textBoxes = () => O.boxes().map(b => ({ ...b, x0: Math.round(b.x0), x1: Math.round(b.x1), y0: Math.round(b.y0), y1: Math.round(b.y1) }));
window.framing = {};
setT(+(q.get('t') || 0));
window.__ready = true;
```

### `engine/lib.js`

```js
// Small deterministic helpers: everything in the film is a pure function of film time t.
export const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
export const lerp = (a, b, k) => a + (b - a) * k;
export const seg = (t, a, b) => clamp((t - a) / (b - a));
export const smooth = k => k * k * (3 - 2 * k);
export const ss = (t, a, b) => smooth(seg(t, a, b));
export const easeOut = k => 1 - Math.pow(1 - k, 3);
export const easeIn = k => k * k * k;
export const easeInOut = k => k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2;
export const easeOutBack = (k, s = 1.70158) => { const c3 = s + 1; return 1 + c3 * Math.pow(k - 1, 3) + s * Math.pow(k - 1, 2); };
export const easeOutBounce = k => {
  const n = 7.5625, d = 2.75;
  if (k < 1 / d) return n * k * k;
  if (k < 2 / d) return n * (k -= 1.5 / d) * k + 0.75;
  if (k < 2.5 / d) return n * (k -= 2.25 / d) * k + 0.9375;
  return n * (k -= 2.625 / d) * k + 0.984375;
};
// damped spring settle: 0 -> 1 with overshoot wobble
export const springy = (k, freq = 3.2, damp = 5.5) => k <= 0 ? 0 : 1 - Math.exp(-damp * k) * Math.cos(freq * Math.PI * k);
export function rng(seed) { let s = (seed * 2654435761) >>> 0 || 1; return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; }; }
export const hash = (x) => { const s = Math.sin(x * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); };
// 1D value noise in [-1, 1]
export function noise1(x, seed = 0) {
  const i = Math.floor(x), f = x - i, u = f * f * (3 - 2 * f);
  return lerp(hash(i + seed * 57.3), hash(i + 1 + seed * 57.3), u) * 2 - 1;
}
export const fbm1 = (x, seed = 0) => noise1(x, seed) * 0.6 + noise1(x * 2.1, seed + 3) * 0.3 + noise1(x * 4.3, seed + 7) * 0.1;
// piecewise: [[t, v], ...] with easing between keys
export function keys(t, K, ease = easeInOut) {
  if (t <= K[0][0]) return K[0][1];
  for (let i = 1; i < K.length; i++) if (t < K[i][0]) { const k = ease(seg(t, K[i - 1][0], K[i][0])); const a = K[i - 1][1], b = K[i][1]; return Array.isArray(a) ? a.map((v, j) => lerp(v, b[j], k)) : lerp(a, b, k); }
  return K.at(-1)[1];
}
// active entry of a step list [[t, value], ...]
export function step(t, L) { let v = null, at = -1; for (const [t0, x] of L) if (t >= t0) { v = x; at = t0; } return { v, at }; }
// hop: parabolic jump from t0 lasting d, height h; returns {y, squash}
export function hop(t, t0, d, h) {
  const k = (t - t0) / d;
  if (k < -0.15 || k > 1.35) return { y: 0, sq: 0 };
  if (k < 0) return { y: 0, sq: -0.18 * Math.sin(seg(k, -0.15, 0) * Math.PI) }; // crouch
  if (k <= 1) return { y: 4 * h * k * (1 - k), sq: 0.12 * Math.sin(k * Math.PI) };
  return { y: 0, sq: -0.16 * Math.sin(seg(k, 1, 1.35) * Math.PI) }; // land squash
}
```

### `engine/vox.js`

```js
// Voxel models that build themselves out of flying cubes and blow apart again (the reference's main transition).
// Engine file. A model is one InstancedMesh of unit cubes under a THREE.Group (place/rotate/scale the group).
//   const m = vox.model(E, voxels, { size: 0.1, center: true })     voxels: [{ x, y, z, c }] in voxel units, c = 0xRRGGBB
//   m.anim(t, { in: [t0, dur, 'swarm'|'fall'|'rise'|'pop'|'none', from?], out: [t1, dur, 'burst'|'crumble'|'swarm'|'none', to?], spread })
// Every voxel's path is a pure function of t and its index (no state), so any frame renders alone.
import * as THREE from 'three';
import { clamp, seg, easeOut, easeIn, easeInOut, easeOutBack, hash, lerp } from './lib.js';

let FACE = null; // a subtle seam on every cube face (the reference's voxel grid lines)
function faceTex() {
  if (FACE) return FACE;
  const c = document.createElement('canvas'); c.width = c.height = 32; const g = c.getContext('2d');
  g.fillStyle = '#ffffff'; g.fillRect(0, 0, 32, 32);
  g.fillStyle = 'rgba(0,0,0,0.10)'; g.fillRect(0, 0, 32, 2); g.fillRect(0, 30, 32, 2); g.fillRect(0, 0, 2, 32); g.fillRect(30, 0, 2, 32);
  g.fillStyle = 'rgba(255,255,255,0.10)'; g.fillRect(2, 2, 28, 2); g.fillRect(2, 2, 2, 28);
  FACE = new THREE.CanvasTexture(c); FACE.colorSpace = THREE.SRGBColorSpace; FACE.magFilter = THREE.LinearFilter; FACE.anisotropy = 4;
  return FACE;
}
const GEO = new THREE.BoxGeometry(1, 1, 1);
export const material = (o = {}) => new THREE.MeshStandardMaterial({ map: o.seams === false ? null : faceTex(), roughness: o.rough ?? 0.92, metalness: 0, emissive: o.emissive ?? 0x000000, emissiveIntensity: o.ei ?? 1, transparent: !!o.transparent, opacity: o.opacity ?? 1, depthWrite: o.depthWrite ?? true, fog: o.fog ?? true });

const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _e = new THREE.Euler(), _p = new THREE.Vector3(), _s = new THREE.Vector3(), _c = new THREE.Color();

// voxels → InstancedMesh under a group. center: put the model's footprint centre at x = z = 0 and its bottom at y = 0.
export function model(E, voxels, o = {}) {
  const size = o.size ?? 0.1, n = voxels.length;
  const mesh = new THREE.InstancedMesh(GEO, o.material || material(o), Math.max(1, n));
  mesh.castShadow = o.shadow ?? true; mesh.receiveShadow = o.receive ?? true; mesh.frustumCulled = false;
  const g = new THREE.Group(); g.add(mesh); (o.parent || E.scene).add(g);
  let cx = 0, cz = 0, y0 = 0;
  if (o.center !== false && n) {
    const xs = voxels.map(v => v.x), zs = voxels.map(v => v.z), ys = voxels.map(v => v.y);
    cx = (Math.min(...xs) + Math.max(...xs)) / 2; cz = (Math.min(...zs) + Math.max(...zs)) / 2; y0 = Math.min(...ys);
  }
  const home = voxels.map(v => new THREE.Vector3((v.x - cx) * size, (v.y - y0 + 0.5) * size, (v.z - cz) * size));
  voxels.forEach((v, i) => { _c.set(v.c); if (o.jitter !== 0) _c.offsetHSL(0, 0, (hash(i * 7.31 + (o.seed || 0)) - 0.5) * (o.jitter ?? 0.035)); mesh.setColorAt(i, _c); });
  if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
  const box = { w: 0, h: 0, d: 0 };
  if (n) { const X = home.map(p => p.x), Y = home.map(p => p.y), Z = home.map(p => p.z); box.w = Math.max(...X) - Math.min(...X) + size; box.h = Math.max(...Y) - Math.min(...Y) + size; box.d = Math.max(...Z) - Math.min(...Z) + size; }
  const M = { group: g, mesh, home, size, n, voxels, box, seed: o.seed || 0, visible: true };
  // recolour voxel i (e.g. the green sweep): call M.flushColors() after
  M.color = (i, hex) => { _c.set(hex); mesh.setColorAt(i, _c); };
  M.flushColors = () => { if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; };
  M.anim = (t, a = {}) => animate(M, t, a);
  M.anim(0, {});
  return M;
}

// a world point → this model's local space (for 'from'/'to' targets given in world units)
function toLocal(M, w) { if (!w) return null; const v = new THREE.Vector3(...w); M.group.updateMatrixWorld(true); return M.group.worldToLocal(v); }

export function animate(M, t, a = {}) {
  const { mesh, home, size, n, seed } = M;
  const [i0, iD, iT = 'swarm', iFrom] = a.in || [-1, 0, 'none'];
  const [o0, oD, oT = 'burst', oTo] = a.out || [1e9, 0, 'none'];
  const spread = a.spread ?? 1, stagger = a.stagger ?? 0.45;
  const from = iFrom ? toLocal(M, iFrom) : null, to = oTo ? toLocal(M, oTo) : null;
  const bw = Math.max(M.box.w, M.box.h, 0.3);
  let any = false;
  for (let i = 0; i < n; i++) {
    const h = home[i], r1 = hash(i * 1.37 + seed), r2 = hash(i * 2.11 + seed + 3), r3 = hash(i * 3.97 + seed + 7), r4 = hash(i * 5.3 + seed + 11);
    let x = h.x, y = h.y, z = h.z, s = 1, rx = 0, ry = 0, rz = 0;
    // ---- in ----
    if (t < i0) s = 0;
    else if (iD > 0 && t < i0 + iD) {
      const d = (a.order ? a.order(i, h) : r1) * stagger, k = clamp((t - i0) / iD - d) / (1 - d);
      if (k <= 0) s = 0;
      else if (iT === 'fall') { // straight down from above, ease in, small squash on landing
        const hgt = (1.6 + r2 * 1.8) * bw * spread, e = easeIn(k);
        y = h.y + hgt * (1 - e); x = h.x + (r3 - 0.5) * 0.15 * (1 - e); z = h.z + (r4 - 0.5) * 0.15 * (1 - e); rx = (1 - e) * r2 * 3; rz = (1 - e) * r3 * 3;
      } else if (iT === 'pop') { const e = easeOutBack(k, 2.2); s = Math.max(0, e); }
      else { // swarm / rise: from a cloud (or from a point) curving in
        const th = r1 * 6.283, ph = Math.acos(2 * r2 - 1), rad = (0.9 + r3 * 1.6) * bw * 0.75 * spread;
        let sx = h.x + Math.sin(ph) * Math.cos(th) * rad, sy = h.y + Math.cos(ph) * rad * 0.8 + (iT === 'rise' ? -bw * 1.2 : bw * 0.35), sz = h.z + Math.sin(ph) * Math.sin(th) * rad;
        if (from) { sx = from.x + (r1 - 0.5) * 0.6 * spread; sy = from.y + (r2 - 0.5) * 0.6 * spread; sz = from.z + (r3 - 0.5) * 0.6 * spread; }
        const e = easeOut(k), arc = Math.sin(k * Math.PI) * 0.25 * bw;
        x = lerp(sx, h.x, e); y = lerp(sy, h.y, e) + arc; z = lerp(sz, h.z, e);
        rx = (1 - e) * (r4 - 0.5) * 8; ry = (1 - e) * (r1 - 0.5) * 8; s = 0.35 + 0.65 * easeOut(Math.min(1, k * 1.6));
      }
    }
    // ---- out ----
    if (t >= o0 && s > 0) {
      const d = (a.outOrder ? a.outOrder(i, h) : r2) * (a.outStagger ?? 0.3), k = oD > 0 ? clamp((t - o0) / oD - d) / (1 - d) : 1;
      if (oT === 'none') s = 0;
      else if (k > 0) {
        if (oT === 'crumble') { // falls down in place, tumbling
          const fall = easeIn(k) * (h.y + 0.6), sp = k * 6;
          y = h.y - fall; x = h.x + (r3 - 0.5) * k * 0.6; z = h.z + (r4 - 0.5) * k * 0.6; rx = sp * (r1 - 0.5); rz = sp * (r2 - 0.5); s = k > 0.85 ? (1 - k) / 0.15 : 1;
        } else if (oT === 'swarm' && to) { // fly to a point and vanish there
          const e = easeIn(k); x = lerp(h.x, to.x + (r1 - 0.5) * 0.5, e); y = lerp(h.y, to.y + (r2 - 0.5) * 0.5, e) + Math.sin(k * Math.PI) * 0.3; z = lerp(h.z, to.z + (r3 - 0.5) * 0.5, e); s = 1 - e * 0.8; rx = e * 4 * (r4 - 0.5);
        } else { // burst: outward from the model's centre, then slows and shrinks (a cloud the next model can swarm out of)
          const cx = 0, cy = M.box.h * 0.5, dx = h.x - cx + (r1 - 0.5) * 0.4, dy = h.y - cy + (r2 - 0.5) * 0.4 + 0.15, dz = h.z + (r3 - 0.5) * 0.8;
          const L = Math.hypot(dx, dy, dz) || 1, push = (0.7 + r4 * 1.3) * bw * 0.9 * spread, e = easeOut(k);
          x = h.x + dx / L * push * e; y = h.y + dy / L * push * e - 0.25 * k * k; z = h.z + dz / L * push * e;
          rx = k * 7 * (r3 - 0.5); ry = k * 7 * (r4 - 0.5); s = k < 0.55 ? 1 : Math.max(0, 1 - (k - 0.55) / 0.45);
        }
      }
    }
    if (a.wobble) { const w = a.wobble(i, h, t); x += w[0]; y += w[1]; z += w[2]; }
    if (s > 0.001) any = true;
    _e.set(rx, ry, rz); _q.setFromEuler(_e); _p.set(x, y, z); const sc = s * size * (a.scale ? a.scale(i, h, t) : 1); _s.set(sc, sc, sc);
    _m.compose(_p, _q, _s); mesh.setMatrixAt(i, _m);
  }
  mesh.instanceMatrix.needsUpdate = true;
  M.group.visible = any;
  return any;
}

// ---------- voxel builders ----------
export const box = (x0, y0, z0, w, h, d, c, out = [], hollow = false) => {
  for (let x = x0; x < x0 + w; x++) for (let y = y0; y < y0 + h; y++) for (let z = z0; z < z0 + d; z++) {
    if (hollow && x > x0 && x < x0 + w - 1 && y > y0 && y < y0 + h - 1 && z > z0 && z < z0 + d - 1) continue;
    out.push({ x, y, z, c: typeof c === 'function' ? c(x, y, z) : c });
  }
  return out;
};
// text → voxels (a canvas raster: one voxel per filled pixel). rows = letter height in voxels.
export function text(str, { rows = 13, font = '900 100px Inter', depth = 2, c = 0xc96a4f, thresh = 0.5, track = 0 } = {}) {
  const cv = document.createElement('canvas'), g = cv.getContext('2d');
  g.font = font; const m = g.measureText(str); const H = 120, W = Math.ceil(m.width + track * str.length + 20);
  cv.width = W; cv.height = H; g.font = font; g.textBaseline = 'alphabetic'; g.fillStyle = '#000';
  if (track) { let x = 10; for (const ch of str) { g.fillText(ch, x, 100); x += g.measureText(ch).width + track; } } else g.fillText(str, 10, 100);
  // the ink box
  const D = g.getImageData(0, 0, W, H).data; let x0 = W, x1 = 0, y0 = H, y1 = 0;
  for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) if (D[(y * W + x) * 4 + 3] > 20) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }
  const cell = (y1 - y0 + 1) / rows, cols = Math.round((x1 - x0 + 1) / cell), out = [];
  for (let gy = 0; gy < rows; gy++) for (let gx = 0; gx < cols; gx++) {
    let on = 0, tot = 0;
    for (let sy = 0; sy < 4; sy++) for (let sx = 0; sx < 4; sx++) { const px = Math.floor(x0 + (gx + (sx + 0.5) / 4) * cell), py = Math.floor(y0 + (gy + (sy + 0.5) / 4) * cell); tot++; if (D[(py * W + px) * 4 + 3] > 120) on++; }
    if (on / tot >= thresh) for (let z = 0; z < depth; z++) out.push({ x: gx, y: rows - 1 - gy, z, c });
  }
  return out;
}
```

### `engine/mascot.js`

```js
// The voxel Claude mascot (our own build of the blocky orange critter): a 13×10×8 body, four legs, two paws, black eyes,
// a mouth that only shows while it talks (lip-sync from film.json's loudness envelope). Engine file.
//   const C = makeMascot(E)        C.pose(t, { x, z, yaw, hop, sq, armL, armR, walk, lean, talk, look, scale, anim })
// x/z = feet on the ground; yaw 0 = facing +z (the camera); hop = height of the feet; sq = squash (-0.2..0.2);
// armL/armR = paw lift (radians, + = up); walk = leg phase (radians, advance it while moving); talk = 0..1 mouth amount.
import * as THREE from 'three';
import * as vox from './vox.js';
import { hash, clamp } from './lib.js';

export const BODY = 0xcd6f51, EYE = 0x232222;
const W = 13, H = 10, D = 8, LEG = 4;       // body size and leg height (voxels)
export const SIZE = 0.1;                     // one voxel = 0.1 world units → body 1.3 × 1.0 × 0.8, total height 1.4

export function makeMascot(E, o = {}) {
  const V = [], part = [];
  const add = (x, y, z, p, c = BODY) => { V.push({ x, y, z, c }); part.push(p); };
  // body shell (x 0..12, y LEG..LEG+9, z 0..7), front face = z 7
  for (let x = 0; x < W; x++) for (let y = LEG; y < LEG + H; y++) for (let z = 0; z < D; z++) {
    if (x > 0 && x < W - 1 && y > LEG && y < LEG + H - 1 && z > 0 && z < D - 1) continue;
    add(x, y, z, 'body');
  }
  // legs: 2×4×2 at x 1-2, 4-5, 7-8, 10-11, z 3-4
  [1, 4, 7, 10].forEach((lx, li) => { for (let x = lx; x < lx + 2; x++) for (let y = 0; y < LEG; y++) for (let z = 3; z < 5; z++) add(x, y, z, 'leg' + li); });
  // paws: a stubby 2-wide, 3-tall block with a thumb, on each side (z 3-4), shoulder at y LEG+5
  const paw = [[-1, 0], [-2, 0], [-1, 1], [-2, 1], [-1, 2], [-2, 2], [-3, 2], [-3, 1]];
  for (const [dx, dy] of paw) for (let z = 3; z < 5; z++) { add(dx, LEG + 4 + dy, z, 'armL'); add(W - 1 - dx, LEG + 4 + dy, z, 'armR'); }
  const M = vox.model(E, V, { size: SIZE, center: false, jitter: 0.05, seed: 3 });
  // indexes of the front-face voxels by (x, y) for eyes + mouth
  const front = new Map(); V.forEach((v, i) => { if (v.z === D - 1 && part[i] === 'body') front.set(v.x + ',' + v.y, i); });
  const EYES = [[3, LEG + 6], [3, LEG + 5], [9, LEG + 6], [9, LEG + 5]];
  const MOUTH_AREA = []; for (let x = 4; x <= 8; x++) for (let y = LEG + 2; y <= LEG + 4; y++) MOUTH_AREA.push([x, y]);
  // mouth shapes on the front face (x, y): small line, open, wide, teeth
  const SH = [
    [],
    [[6, LEG + 3]],
    [[5, LEG + 3], [6, LEG + 3], [7, LEG + 3]],
    [[5, LEG + 3], [6, LEG + 3], [7, LEG + 3], [6, LEG + 2]],
    [[5, LEG + 3], [6, LEG + 3], [7, LEG + 3], [5, LEG + 2], [6, LEG + 2], [7, LEG + 2]],
    [[4, LEG + 3], [5, LEG + 3], [6, LEG + 3], [7, LEG + 3], [8, LEG + 3], [5, LEG + 2], [7, LEG + 2]],
  ];
  const base = V.map(v => new THREE.Vector3(v.x, v.y, v.z));
  const cx = (W - 1) / 2, cz = (D - 1) / 2;
  const colorOf = i => { const c = new THREE.Color(V[i].c); c.offsetHSL(0, 0, (hash(i * 7.31 + 3) - 0.5) * 0.05); return c; };
  const bodyCol = new Map(); for (const [k, i] of front) bodyCol.set(i, colorOf(i));
  let lastMouth = '';
  const C = { M, group: M.group, part, V };
  C.pose = (t, p = {}) => {
    const g = M.group, s = p.scale ?? 1;
    g.position.set(p.x || 0, (p.hop || 0) + (p.y || 0), p.z || 0); g.rotation.set(p.lean || 0, p.yaw || 0, p.roll || 0); g.scale.setScalar(s);
    const sq = p.sq || 0, sy = 1 - sq, sxz = 1 + sq * 0.6;
    const aL = p.armL ?? 0.15, aR = p.armR ?? 0.15, walk = p.walk ?? 0, breathe = Math.sin(t * 2.4 + (p.seed || 0)) * 0.012;
    for (let i = 0; i < base.length; i++) {
      const b = base[i], pt = part[i]; let x = b.x - cx, y = b.y, z = b.z - cz;
      if (pt === 'armL' || pt === 'armR') { // rotate around the shoulder (body side, y LEG+4.5)
        const sx = pt === 'armL' ? -0.5 - cx : W - 0.5 - cx, sy0 = LEG + 4.5, a = pt === 'armL' ? -aL : aR;
        const dx = x - sx, dy = y - sy0; x = sx + dx * Math.cos(a) - dy * Math.sin(a); y = sy0 + dx * Math.sin(a) + dy * Math.cos(a);
      }
      if (pt.startsWith('leg')) { const li = +pt[3], ph = walk + (li % 2) * Math.PI; if (walk) y += Math.max(0, Math.sin(ph)) * 1.2; }
      else { y = LEG + (y - LEG) * (sy + breathe); x *= sxz; z *= sxz; }
      M.home[i].set(x * SIZE, (y + 0.5) * SIZE, z * SIZE);
    }
    // face: eyes always, mouth by the talk amount (no mouth when silent, like the reference)
    const lvl = p.talk || 0, mi = lvl < 0.08 ? 0 : Math.min(SH.length - 1, 1 + Math.floor((lvl - 0.08) / 0.92 * (SH.length - 1) + hash(Math.floor(t * 12)) * 0.9));
    const key = mi + (p.blink ? 'b' : '') + (p.eyes || '');
    if (key !== lastMouth) {
      for (const [x, y] of MOUTH_AREA) { const i = front.get(x + ',' + y); if (i != null) M.mesh.setColorAt(i, bodyCol.get(i)); }
      for (const [x, y] of SH[mi]) { const i = front.get(x + ',' + y); if (i != null) M.color(i, EYE); }
      EYES.forEach(([x, y], k) => { const i = front.get(x + ',' + y); if (i == null) return; const top = k % 2 === 0; if (p.blink && top) M.mesh.setColorAt(i, bodyCol.get(i)); else M.color(i, EYE); });
      M.flushColors(); lastMouth = key;
    }
    return M.anim(t, p.anim || {});
  };
  return C;
}
// mouth amount at time t from film.json's per-frame envelope (smoothed a little, opens slightly ahead of the sound)
export function talkAt(film, t, fps = 30) {
  const e = film.env || [], f = Math.round((t + 0.03) * fps);
  const v = ((e[f - 1] || 0) + 2 * (e[f] || 0) + (e[f + 1] || 0)) / 4;
  return clamp((v - 0.1) / 0.75);
}
```

### `engine/world.js`

```js
// The sandbox: a cream sky, a soft sand floor, and sand DISCS (round patches of square tiles with ragged edges) joined by
// tile trails — one disc per station. Warm key light from the right, soft shadows, fog into the sky. Engine file.
//   const R = buildWorld(E, { discs: [{ x, z, r, label }], seed })
//   R.follow(x, z)                 the key light's shadow box follows the action
//   R.paintTiles(fn)               fn(tile) → 0xRRGGBB or null (its own colour), e.g. the green sweep
//   R.labels[i].show(alpha, scale) the dark label pill over disc i (recap)
import * as THREE from 'three';
import { hash, clamp } from './lib.js';

export const SKY = 0xf1e2cc, SAND = 0xe3d3b7;
export const TILE_COLS = [0xefe3cf, 0xe9dbc3, 0xe3d4b9, 0xebdcc0, 0xf3e9d7, 0xdecdb0, 0xe6d9c6];
export const LIME_COLS = [0xdff45c, 0xd6ef55, 0xe5f86d, 0xcfe84e, 0xdaf262];
const PITCH = 0.34, TS = 0.305;

export function buildWorld(E, cfg = {}) {
  const { scene } = E, R = {};
  scene.background = new THREE.Color(SKY);
  scene.fog = new THREE.Fog(SKY, cfg.fogNear ?? 11, cfg.fogFar ?? 34);
  // floor
  const ground = new THREE.Mesh(new THREE.PlaneGeometry(600, 600), new THREE.MeshStandardMaterial({ color: SAND, roughness: 1 }));
  ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground); R.ground = ground;
  // lights: sky/ground hemisphere + a warm key from the right with soft shadows
  R.hemi = new THREE.HemisphereLight(0xfff7ec, 0xd9c7a8, 1.55); scene.add(R.hemi);
  R.key = new THREE.DirectionalLight(0xfff1df, 2.1); R.key.castShadow = true;
  R.key.shadow.mapSize.set(2048, 2048); R.key.shadow.bias = -0.0004; R.key.shadow.normalBias = 0.025; R.key.shadow.radius = 6;
  const sc = R.key.shadow.camera; sc.left = -9; sc.right = 9; sc.top = 9; sc.bottom = -9; sc.near = 0.5; sc.far = 40;
  scene.add(R.key, R.key.target);
  R.fill = new THREE.DirectionalLight(0xffe6cf, 0.45); R.fill.position.set(-6, 4, 8); scene.add(R.fill);
  R.follow = (x, z) => { R.key.position.set(x + 6.5, 10, z + 4.5); R.key.target.position.set(x, 0, z); R.key.target.updateMatrixWorld(); R.fill.position.set(x - 6, 4, z + 8); };
  R.follow(0, 0);

  // ---------- tiles: discs + trails on one grid ----------
  const discs = cfg.discs || [], seen = new Map(), tiles = [];
  const put = (gx, gz, disc, edge) => { const k = gx + ',' + gz; if (seen.has(k)) return; seen.set(k, 1); tiles.push({ gx, gz, x: gx * PITCH, z: gz * PITCH, disc, edge }); };
  discs.forEach((d, di) => {
    const r = d.r ?? 2.3, n = Math.ceil((r + 0.6) / PITCH);
    const gx0 = Math.round(d.x / PITCH), gz0 = Math.round(d.z / PITCH);
    for (let i = -n; i <= n; i++) for (let j = -n; j <= n; j++) {
      const x = (gx0 + i) * PITCH, z = (gz0 + j) * PITCH, dist = Math.hypot(x - d.x, z - d.z);
      const edge = r - 0.25 + hash((gx0 + i) * 13.1 + (gz0 + j) * 7.7 + di) * 0.55;
      if (dist < edge) put(gx0 + i, gz0 + j, di, dist / r);
    }
  });
  for (let di = 0; di + 1 < discs.length; di++) { // trails between neighbours
    const a = discs[di], b = discs[di + 1], L = Math.hypot(b.x - a.x, b.z - a.z), steps = Math.ceil(L / (PITCH * 0.5));
    for (let s = 0; s <= steps; s++) {
      const k = s / steps, x = a.x + (b.x - a.x) * k, z = a.z + (b.z - a.z) * k, wob = Math.sin(k * 6.283 + di) * 0.25;
      const nx = -(b.z - a.z) / L, nz = (b.x - a.x) / L;
      for (let o = -2; o <= 2; o++) {
        const ox = x + nx * (o * PITCH * 0.55 + wob), oz = z + nz * (o * PITCH * 0.55 + wob);
        const gx = Math.round(ox / PITCH), gz = Math.round(oz / PITCH);
        if (hash(gx * 3.3 + gz * 5.9 + 0.7) < (Math.abs(o) <= 1 ? 0.85 : 0.35)) put(gx, gz, -1 - di, 1);
      }
    }
  }
  const tmat = new THREE.MeshStandardMaterial({ roughness: 0.95, metalness: 0 });
  const tmesh = new THREE.InstancedMesh(new THREE.BoxGeometry(1, 1, 1), tmat, tiles.length);
  tmesh.receiveShadow = true; tmesh.castShadow = false; tmesh.frustumCulled = false;
  const m4 = new THREE.Matrix4(), q = new THREE.Quaternion(), e = new THREE.Euler(), col = new THREE.Color();
  tiles.forEach((tl, i) => {
    const h1 = hash(tl.gx * 1.7 + tl.gz * 3.1), h2 = hash(tl.gx * 4.3 - tl.gz * 1.9 + 5), h3 = hash(tl.gx * 9.1 + tl.gz * 0.7 + 2);
    tl.h = 0.035 + h1 * 0.045; tl.col = TILE_COLS[Math.floor(h2 * TILE_COLS.length)]; tl.lime = LIME_COLS[Math.floor(h3 * LIME_COLS.length)];
    tl.top = tl.h;
    e.set(0, (h3 - 0.5) * 0.06, 0); q.setFromEuler(e);
    m4.compose(new THREE.Vector3(tl.x + (h1 - 0.5) * 0.03, tl.h / 2, tl.z + (h2 - 0.5) * 0.03), q, new THREE.Vector3(TS, tl.h, TS));
    tmesh.setMatrixAt(i, m4); col.set(tl.col); tmesh.setColorAt(i, col);
  });
  tmesh.instanceMatrix.needsUpdate = true; tmesh.instanceColor.needsUpdate = true; scene.add(tmesh);
  R.tiles = tiles; R.tmesh = tmesh;
  let lastSig = '';
  R.paintTiles = (fn, sig) => { // sig: skip the upload when nothing changed
    if (sig != null && sig === lastSig) return; lastSig = sig ?? '';
    tiles.forEach((tl, i) => { const c = fn ? fn(tl, i) : null; col.set(c ?? tl.col); tmesh.setColorAt(i, col); });
    tmesh.instanceColor.needsUpdate = true;
  };
  R.floorY = 0.06; // where feet stand on a disc

  // ---------- labels (dark mono pills over each disc, for the recap) ----------
  R.labels = discs.map((d, i) => {
    const text = d.label || ''; const cv = document.createElement('canvas'), g = cv.getContext('2d');
    g.font = '800 64px "JetBrains Mono", Consolas, monospace'; if ('letterSpacing' in g) g.letterSpacing = '6px';
    const tw = g.measureText(text).width; cv.width = Math.ceil(tw + 96); cv.height = 116;
    g.font = '800 64px "JetBrains Mono", Consolas, monospace'; if ('letterSpacing' in g) g.letterSpacing = '6px';
    g.fillStyle = '#2b2925'; const h = 116, w = cv.width; g.beginPath(); g.roundRect ? g.roundRect(0, 0, w, h, h / 2) : g.rect(0, 0, w, h); g.fill();
    g.fillStyle = '#ffffff'; g.textBaseline = 'middle'; g.fillText(text, 48, h / 2 + 3);
    const tex = new THREE.CanvasTexture(cv); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 8;
    const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false, depthTest: false, fog: false, toneMapped: false }));
    sp.renderOrder = 20; const aspect = cv.width / cv.height; sp.userData = { aspect, cv, g, text };
    sp.position.set(d.x + (d.lx ?? 0), d.ly ?? 1.0, d.z + (d.lz ?? -(d.r ?? 2.3) * 0.72)); sp.visible = false; scene.add(sp);
    return { sprite: sp, show(alpha, size = 0.9, lime = false) {
      sp.visible = alpha > 0.01; sp.material.opacity = alpha; sp.scale.set(size * aspect, size, 1);
      if (sp.userData.lime !== lime) { // repaint chartreuse when its disc lights up
        sp.userData.lime = lime; const G = sp.userData.g; G.clearRect(0, 0, cv.width, cv.height);
        G.font = '800 64px "JetBrains Mono", Consolas, monospace'; if ('letterSpacing' in G) G.letterSpacing = '6px';
        G.fillStyle = lime ? '#e6fd5e' : '#2b2925'; G.beginPath(); G.roundRect ? G.roundRect(0, 0, cv.width, 116, 58) : G.rect(0, 0, cv.width, 116); G.fill();
        G.fillStyle = lime ? '#1d1b19' : '#ffffff'; G.textBaseline = 'middle'; G.fillText(text, 48, 61); tex.needsUpdate = true;
      }
    } };
  });
  return R;
}
// the tile under (x, z): its top height (for feet), or 0 on bare sand
export function groundAt(R, x, z) { return R.floorY; }
```

### `engine/cards.js`

```js
// Floating UI cards: a canvas drawn every frame on a plane that faces the camera, with a soft white glow, built out of
// white cubes that fly in (and blow back into cubes on the way out) — the reference's cards. Engine file.
//   const c = card(E, { w: 2.2, h: 1.25, draw: (g, W, H, t) => {...}, dark: false })
//   c.at(x, y, z)      c.update(t, { in: [t0, dur], out: [t1, dur, 'burst'|'swarm', toWorld?], tilt })
// draw() paints the card body only (0..W × 0..H px, already clipped to the rounded card); the glow is the engine's.
import * as THREE from 'three';
import * as vox from './vox.js';
import { clamp, seg, easeOut, easeIn, lerp, hash } from './lib.js';

export const PX = 520;          // canvas pixels per world unit (a 2.2-unit card = 1144 px wide)
const MARGIN = 70;              // glow margin around the card body, px

export function card(E, o) {
  const w = o.w ?? 2.2, h = o.h ?? 1.25, W = Math.round(w * PX), H = Math.round(h * PX);
  const cv = document.createElement('canvas'); cv.width = W + MARGIN * 2; cv.height = H + MARGIN * 2; const g = cv.getContext('2d');
  const tex = new THREE.CanvasTexture(cv); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 8; tex.minFilter = THREE.LinearMipmapLinearFilter;
  const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: true, alphaTest: 0.04, fog: false, toneMapped: false });
  const mesh = new THREE.Mesh(new THREE.PlaneGeometry(cv.width / PX, cv.height / PX), mat);
  mesh.renderOrder = o.order ?? 5;
  const group = new THREE.Group(); group.add(mesh); E.scene.add(group);
  // the cubes it builds from: a grid over the card body, in the card's plane
  const cols = o.cols ?? 18, rows = Math.max(3, Math.round(cols * h / w)), cs = w / cols;
  const V = []; for (let x = 0; x < cols; x++) for (let y = 0; y < rows; y++) V.push({ x, y, z: 0, c: o.cube ?? (o.dark ? 0x26231f : 0xf7f5f0) });
  const cubes = vox.model(E, V, { size: cs, center: false, parent: group, shadow: false, jitter: 0.04, seed: (o.seed || 1) * 13 });
  cubes.group.position.set(-w / 2 + cs / 2, -h / 2, 0);
  const R = o.radius ?? 34;
  const C = { group, mesh, g, W, H, w, h, cubes, opacity: 1 };
  C.at = (x, y, z) => { group.position.set(x, y, z); return C; };
  C.update = (t, a = {}) => {
    const cam = E.camera; group.quaternion.copy(cam.quaternion);
    if (a.tilt) group.rotateY(a.tilt);
    const [i0, iD] = a.in || [-1, 0], [o0, oD, oT = 'burst', oTo] = a.out || [1e9, 0];
    // the card itself shows once its cubes have landed, and leaves the moment the cubes take off
    const kIn = iD > 0 ? clamp((t - i0) / iD) : (t >= i0 ? 1 : 0);
    const show = t < i0 ? 0 : t >= o0 ? 0 : clamp((kIn - 0.72) / 0.28);
    const cubesOn = (t >= i0 && kIn < 1) || (t >= o0 && t < o0 + oD + 0.05);
    // while the card fades in, its cubes shrink away (so the card shows through instead of popping in at the end)
    if (cubesOn) cubes.anim(t, { in: [i0, iD * 0.9, 'swarm'], out: t >= o0 ? [o0, oD, oT, oTo] : [1e9, 0], spread: a.spread ?? 0.8, stagger: 0.5, scale: t < o0 ? () => Math.max(0, 1 - show * 1.15) : null });
    else { cubes.group.visible = false; }
    if (show <= 0.001 && !cubesOn) { mesh.visible = false; return false; }
    mesh.visible = show > 0.001; mat.opacity = show * (a.alpha ?? 1);
    if (mesh.visible) paint(t, a);
    return true;
  };
  function paint(t, a) {
    g.clearRect(0, 0, cv.width, cv.height);
    g.save(); g.translate(MARGIN, MARGIN);
    // glow + body
    g.save(); g.shadowColor = o.dark ? 'rgba(20,16,12,0.35)' : 'rgba(255,255,255,0.95)'; g.shadowBlur = o.glow ?? 60;
    g.fillStyle = o.bg ?? (o.dark ? '#1c1a17' : '#fdfcfa'); rr(g, 0, 0, W, H, R); g.fill(); g.restore();
    if (!o.dark) { g.save(); g.shadowColor = 'rgba(120,90,50,0.10)'; g.shadowBlur = 18; g.shadowOffsetY = 6; g.fillStyle = o.bg ?? '#fdfcfa'; rr(g, 0, 0, W, H, R); g.fill(); g.restore(); }
    g.save(); rr(g, 0, 0, W, H, R); g.clip();
    o.draw && o.draw(g, W, H, t, a);
    g.restore(); g.restore();
    tex.needsUpdate = true;
  }
  return C;
}

// ---------- drawing helpers for card contents ----------
export function rr(g, x, y, w, h, r) { r = Math.min(r, h / 2, w / 2); g.beginPath(); g.moveTo(x + r, y); g.arcTo(x + w, y, x + w, y + h, r); g.arcTo(x + w, y + h, x, y + h, r); g.arcTo(x, y + h, x, y, r); g.arcTo(x, y, x + w, y, r); g.closePath(); }
export const SANS = 'Inter, "Inter Tight", sans-serif', MONO = '"JetBrains Mono", Consolas, monospace';
export const INK = '#1d1b19', MUTED = '#8f877c', LINE = '#ece6dc', FIELD = '#f5f1e9', TERRA = '#d9775a', GREEN = '#2f9e5b', RED = '#d4483b', LIME = '#e6fd5e';
export function txt(g, s, x, y, { size = 40, weight = 500, color = INK, font = SANS, align = 'left', base = 'alphabetic', sp = 0, maxW } = {}) {
  g.font = `${weight} ${size}px ${font}`; g.fillStyle = color; g.textAlign = align; g.textBaseline = base;
  if ('letterSpacing' in g) g.letterSpacing = sp + 'px';
  if (maxW) { const w = g.measureText(s).width; if (w > maxW) { g.save(); g.translate(x, y); g.scale(maxW / w, 1); g.fillText(s, 0, 0); g.restore(); } else g.fillText(s, x, y); }
  else g.fillText(s, x, y);
  const w = g.measureText(s).width; if ('letterSpacing' in g) g.letterSpacing = '0px'; g.textAlign = 'left'; return w;
}
// "● LABEL · SUB" header in small grey mono caps
export function header(g, s, x, y, { dot = TERRA, size = 26, color = '#a39b90' } = {}) {
  if (dot) { g.fillStyle = dot; g.beginPath(); g.arc(x + 10, y - 9, 10, 0, 7); g.fill(); x += 34; }
  return txt(g, s, x, y, { size, weight: 600, font: MONO, color, sp: 2 });
}
export function chipBox(g, s, x, y, { bg = '#e9f7ee', color = GREEN, size = 30, weight = 700, padX = 22, h = 54, font = SANS, align = 'left' } = {}) {
  g.font = `${weight} ${size}px ${font}`; const w = g.measureText(s).width + padX * 2, x0 = align === 'right' ? x - w : align === 'center' ? x - w / 2 : x;
  g.fillStyle = bg; rr(g, x0, y, w, h, h / 2); g.fill(); g.fillStyle = color; g.textBaseline = 'middle'; g.fillText(s, x0 + padX, y + h / 2 + 1); g.textBaseline = 'alphabetic';
  return w;
}
// a progress bar (0..1) in a track
export function bar(g, x, y, w, k, { h = 12, color = GREEN, track = '#eee8de' } = {}) {
  g.fillStyle = track; rr(g, x, y, w, h, h / 2); g.fill(); if (k > 0) { g.fillStyle = color; rr(g, x, y, Math.max(h, w * clamp(k)), h, h / 2); g.fill(); }
}
// a mouse cursor (arrow) at x, y; press 0..1 shrinks it a little + a ring
export function cursor(g, x, y, press = 0) {
  g.save(); g.translate(x, y); const s = 1.5 * (1 - 0.12 * press); g.scale(s, s);
  if (press > 0) { g.strokeStyle = `rgba(217,119,90,${0.6 * press})`; g.lineWidth = 3; g.beginPath(); g.arc(0, 0, 18 + 10 * (1 - press), 0, 7); g.stroke(); }
  g.beginPath(); g.moveTo(0, 0); g.lineTo(0, 30); g.lineTo(8, 23); g.lineTo(13, 34); g.lineTo(18, 32); g.lineTo(13, 21); g.lineTo(23, 21); g.closePath();
  g.fillStyle = '#111'; g.fill(); g.strokeStyle = '#fff'; g.lineWidth = 2.4; g.stroke(); g.restore();
}
// typewriter: the first n characters of s (n from a time window), with a blinking caret
export function typed(s, t, t0, t1) { const k = clamp((t - t0) / Math.max(0.01, t1 - t0)); return s.slice(0, Math.round(k * s.length)); }
export function caret(g, x, y, h, t) { if (Math.floor(t * 2.2) % 2 === 0) { g.fillStyle = INK; g.fillRect(x + 3, y - h * 0.8, 3, h); } }
// wrap s into lines of ≤ maxW px with the current font
export function wrap(g, s, maxW) { const out = []; let line = ''; for (const w of s.split(' ')) { const tt = line ? line + ' ' + w : w; if (g.measureText(tt).width > maxW && line) { out.push(line); line = w; } else line = tt; } if (line) out.push(line); return out; }
// a light inner field (the request/checkout cards) and a file row (icon + name + grey sub)
export function field(g, W, H, y0 = 96) { g.fillStyle = FIELD; rr(g, 28, y0, W - 56, H - y0 - 28, 26); g.fill(); }
export function fileRow(g, x, y, w, name, sub, o = {}) {
  g.fillStyle = o.hi ? 'rgba(217,119,90,0.16)' : '#ffffff'; rr(g, x, y, w, 112, 20); g.fill(); g.strokeStyle = o.hi ? 'rgba(217,119,90,0.6)' : '#ebe5da'; g.lineWidth = 3; g.stroke();
  g.fillStyle = o.folder ? '#f2d9a6' : '#f0ebe2'; rr(g, x + 24, y + 22, 54, 68, 9); g.fill(); if (!o.folder) { g.fillStyle = '#c9c0b2'; for (let r = 0; r < 3; r++) g.fillRect(x + 35, y + 42 + r * 13, 32, 5); }
  const nw = txt(g, name, x + 104, y + 76, { size: 56, weight: 700 }); if (sub) txt(g, sub, x + 104 + nw + 16, y + 76, { size: 38, weight: 500, color: MUTED });
}
```

### `engine/layouts.js`

```js
// Ready card layouts. Each returns draw(g, W, H, t) for a card (kit.card / cards.card). W ≈ 520 px per world unit,
// so a 2-unit card is ~1040 px wide and shows at ~0.72× on screen: body text 56–64 px, headers 28 px mono caps.
// Engine file. Times are film seconds (from K.T('word')).
import * as C from './cards.js';
import { clamp, ss, lerp, easeOut, easeOutBack, hash } from './lib.js';
const { txt, header, field, fileRow, chipBox, bar, cursor, typed, caret, wrap, rr, INK, MUTED, GREEN, RED, TERRA, LIME, MONO } = C;

// "● HEADER" + a field where `text` types between t0 and t1, with a blinking caret
export const typing = ({ header: h, text, t0, t1, size = 64 }) => (g, W, H, t) => {
  header(g, h, 42, 64, { size: 28 }); field(g, W, H);
  const s = typed(text, t, t0, t1); g.font = `500 ${size}px Inter`; const lines = wrap(g, s, W - 150);
  lines.forEach((l, i) => txt(g, l, 68, 190 + i * size * 1.25, { size, weight: 500 }));
  const last = lines.at(-1) || ''; g.font = `500 ${size}px Inter`; caret(g, 68 + g.measureText(last).width, 190 + Math.max(0, lines.length - 1) * size * 1.25, size, t);
};
// a big number counting from → to between t0 and t1 (views, a price), with a title block and an optional button the cursor clicks at `click`
export const counter = ({ header: h, title = [], sub = '', from = 0, to, t0, t1, prefix = '', suffix = '', decimals = 0, button, click, thumb }) => (g, W, H, t) => {
  header(g, h, 42, 64, { size: 28 }); field(g, W, H);
  let x = 56;
  if (thumb) { // a small dark product tile with two words
    const gr = g.createLinearGradient(56, 120, 236, 330); gr.addColorStop(0, '#3a1d22'); gr.addColorStop(1, '#14121a'); g.fillStyle = gr; rr(g, 56, 122, 180, 212, 20); g.fill();
    txt(g, thumb[0], 146, 216, { size: 32, weight: 900, color: '#fff', align: 'center' }); txt(g, thumb[1] || '', 146, 258, { size: 24, weight: 800, color: '#f5c451', align: 'center' }); x = 268;
  }
  title.forEach((l, i) => txt(g, l, x, 186 + i * 64, { size: 56, weight: 700, maxW: W - x - 52 }));
  if (sub) txt(g, sub, x, 186 + title.length * 64 - 4, { size: 38, weight: 500, color: MUTED });
  const v = lerp(from, to, easeOut(ss(t, t0, t1)));
  txt(g, prefix + v.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) + suffix, 56, H - 52, { size: 118, weight: 800, sp: -3 });
  if (button) {
    const bx = W - 344, by = H - 146, press = click != null ? ss(t, click - 0.1, click + 0.05) * (1 - ss(t, click + 0.15, click + 0.3)) : 0;
    g.fillStyle = TERRA; rr(g, bx, by, 296, 92, 46); g.fill(); txt(g, button, bx + 148, by + 62, { size: 42, weight: 700, color: '#fff', align: 'center' });
    if (click != null) { const kc = ss(t, click - 1.2, click - 0.15); cursor(g, lerp(W - 70, bx + 210, kc), lerp(H - 10, by + 60, kc), press); }
  }
};
// rows that fill one after another between t0 and t1 (✓ once full); `newIndex` gets a NEW chip; `chip` sits top-right
export const checklist = ({ title, sub = '', items, t0, t1, newIndex = -1, chip }) => (g, W, H, t) => {
  const tw = txt(g, title, 44, 86, { size: 62, weight: 800, sp: -1 }); if (sub) txt(g, sub, 44 + tw + 20, 86, { size: 40, weight: 500, color: MUTED });
  if (chip) chipBox(g, chip, W - 46, 42, { size: 28, h: 54, bg: LIME, color: INK, weight: 800, align: 'right' });
  const n = items.length, rowH = Math.min(104, (H - 140) / n);
  items.forEach((f, i) => {
    const y = 122 + i * rowH, a = t0 + (t1 - t0) * i / n, k = ss(t, a, a + (t1 - t0) / n * 0.9), done = k >= 1;
    g.fillStyle = done ? '#ffffff' : '#f7f4ee'; rr(g, 30, y, W - 60, rowH - 14, 18); g.fill(); if (done) { g.strokeStyle = '#ebe5da'; g.lineWidth = 3; g.stroke(); }
    const fw = txt(g, (done ? '✓ ' : '') + f, 58, y + rowH * 0.6, { size: 46, weight: done ? 700 : 500, color: done ? INK : '#b8afa2' });
    if (i === newIndex) chipBox(g, 'NEW', 58 + fw + 16, y + rowH * 0.6 - 42, { size: 26, h: 50, bg: '#ffe9e2', color: TERRA, weight: 800, padX: 16 });
    bar(g, W - 290, y + rowH * 0.38, 230, k, { h: 14, color: GREEN });
  });
};
// rows that appear at their own times: name + who on the left, a little waveform of the content, ✓ value (green) or ✗ value (red)
export const compare = ({ header: h, rows }) => (g, W, H, t) => {
  header(g, h, 42, 64, { size: 28 });
  const rowH = Math.min(196, (H - 110) / rows.length);
  rows.forEach((r, i) => {
    const a = ss(t, r.at - 0.05, r.at + 0.2); if (a <= 0) return; const y = 104 + i * rowH;
    g.save(); g.globalAlpha = a; g.translate(0, (1 - a) * 18);
    g.fillStyle = r.ok ? 'rgba(47,158,91,0.08)' : 'rgba(212,72,59,0.07)'; rr(g, 30, y, W - 60, rowH - 20, 24); g.fill();
    g.strokeStyle = r.ok ? 'rgba(47,158,91,0.5)' : 'rgba(212,72,59,0.45)'; g.lineWidth = 3; g.stroke();
    txt(g, r.name, 64, y + 76, { size: 56, weight: 700 }); if (r.who) txt(g, r.who, 64, y + 136, { size: 40, weight: 500, color: MUTED });
    for (let b = 0; b < 16; b++) { const hh = 16 + hash(b * 3.7) * 56; g.fillStyle = r.ok ? '#5fb97f' : '#e07a6e'; g.fillRect(372 + b * 19, y + 88 - hh / 2, 10, hh); }
    txt(g, (r.ok ? '✓ ' : '✗ ') + r.value, W - 64, y + 108, { size: 72, weight: 800, color: r.ok ? GREEN : RED, align: 'right' });
    g.restore();
  });
};
// a plain-text file: mono name, a rule, mono lines, and a stamp chip that slams on at `stampAt`
export const file = ({ name, lines, stamp = 'FREE ✓', stampAt }) => (g, W, H, t) => {
  txt(g, name, 40, 72, { size: 42, weight: 700, font: MONO, maxW: W - 80 });
  g.fillStyle = '#ece6dc'; g.fillRect(40, 100, W - 80, 4);
  lines.forEach((l, i) => txt(g, l, 40, 168 + i * 64, { size: 40, weight: 500, font: MONO, color: i === 0 ? INK : '#6d665c', maxW: W - 80 }));
  const k = stampAt != null ? ss(t, stampAt - 0.05, stampAt + 0.2) : 0;
  if (k > 0) { g.save(); g.translate(W - 190, H - 110); g.rotate(-0.12); const s = lerp(1.6, 1, easeOut(k)); g.scale(s, s); g.globalAlpha = k; chipBox(g, stamp, 0, 0, { size: 52, h: 92, bg: LIME, color: INK, weight: 900, align: 'center', padX: 30 }); g.restore(); }
};
// a price: header, big amount + period, a small line, a button, and a red strike-through at `strikeAt`
export const price = ({ header: h, amount, period = '', note = '', button = '', strikeAt }) => (g, W, H, t) => {
  header(g, h, 40, 62, { size: 28 });
  const pw = txt(g, amount, 40, 200, { size: 124, weight: 800, sp: -3 }); if (period) txt(g, period, 40 + pw + 16, 200, { size: 50, weight: 600, color: MUTED });
  if (note) txt(g, note, 40, 268, { size: 38, weight: 500, color: MUTED });
  if (button) { g.fillStyle = INK; rr(g, W - 312, H - 120, 272, 82, 41); g.fill(); txt(g, button, W - 176, H - 66, { size: 38, weight: 700, color: '#fff', align: 'center' }); }
  const k = strikeAt != null ? ss(t, strikeAt, strikeAt + 0.25) : 0;
  if (k > 0) { g.strokeStyle = RED; g.lineWidth = 16; g.lineCap = 'round'; g.beginPath(); g.moveTo(30, H * 0.58); g.lineTo(30 + (W - 60) * k, H * 0.58 - 46 * k); g.stroke(); }
};
// a dark, glossy product card (the "repackaged" look): badge, big title, subtitle, rating and a footnote. Use with { dark: true }
export const product = ({ badge = '✦ PRO PACK', title, subtitle = '', rating = '', foot = '' }) => (g, W, H) => {
  const gr = g.createLinearGradient(0, 0, W, H); gr.addColorStop(0, '#2a1e33'); gr.addColorStop(0.5, '#3a1d22'); gr.addColorStop(1, '#14121a'); g.fillStyle = gr; g.fillRect(0, 0, W, H);
  g.fillStyle = 'rgba(255,190,90,0.18)'; g.beginPath(); g.arc(W * 0.82, H * 0.2, 260, 0, 7); g.fill();
  if (badge) chipBox(g, badge, 46, 44, { size: 32, h: 60, bg: '#f5c451', color: '#2a1e10', weight: 800 });
  txt(g, title, 46, 218, { size: 108, weight: 900, color: '#ffffff', sp: -3, maxW: W - 92 });
  if (subtitle) txt(g, subtitle, 46, 312, { size: 70, weight: 800, color: '#ffd9a8', sp: -1, maxW: W - 92 });
  if (rating) txt(g, rating, 46, 400, { size: 40, weight: 600, color: 'rgba(255,255,255,0.72)' });
  if (foot) txt(g, foot, 46, H - 50, { size: 34, weight: 600, font: MONO, color: 'rgba(255,255,255,0.5)' });
};
// one centred line (speech bubbles, the FREE ✓ pill)
export const pill = ({ text, size = 92, weight = 700, color = INK, sp = 0 }) => (g, W, H) => { txt(g, text, W / 2, H * 0.66 + (size - 92) * 0.12, { size, weight, color, align: 'center', sp }); };
```

### `engine/props.js`

```js
// Props for the sandbox, as voxel lists (vox.model builds them) plus a few special objects. Engine file.
// Voxel units: 1 = the model's `size` (0.1 world units unless a prop says otherwise).
import * as THREE from 'three';
import * as vox from './vox.js';
import { hash, clamp, lerp, easeOut, easeIn } from './lib.js';

export const PAL = { terra: 0xd9694d, brick: 0xbf5b40, brick2: 0xa94f38, teal: 0x2f9aa8, mustard: 0xe3b23c, cream: 0xefe6d2, lime: 0xd8f05a, red: 0xc94b3f, black: 0x1f1e1c, dark: 0x2b2926, grey: 0x8a8782, white: 0xf4f1ea, wood: 0x9a6b45, gold: 0xe2b23a };

// the firecracker the mascot holds in the hook: red stick, grey cap, a fuse (flame + smoke are particles)
export function firecracker() {
  const V = [];
  vox.box(0, 0, 0, 2, 5, 2, PAL.red, V);
  vox.box(0, 5, 0, 2, 1, 2, 0x86847f, V);
  V.push({ x: 0, y: 6, z: 0, c: 0x3a3330 });
  return V;
}
// a bookshelf full of .md "books" (the library)
export function bookshelf(seed = 1) {
  const V = [], Wd = 22, Ht = 17, Dp = 4, F = PAL.black;
  vox.box(0, 0, 0, 1, Ht, Dp, F, V); vox.box(Wd - 1, 0, 0, 1, Ht, Dp, F, V); vox.box(1, Ht - 1, 0, Wd - 2, 1, Dp, F, V);
  vox.box(1, 0, 0, Wd - 2, 1, Dp, F, V); vox.box(1, 0, 0, Wd - 2, Ht - 1, 1, 0x2a2724, V); // back + bottom
  const cols = [PAL.terra, PAL.teal, PAL.mustard, PAL.cream, PAL.lime, PAL.red, 0x6f8fd8, PAL.terra, PAL.teal];
  for (const sy of [1, 6, 11]) {
    if (sy > 1) vox.box(1, sy - 1, 1, Wd - 2, 1, Dp - 1, F, V);
    let x = 1, k = 0;
    while (x < Wd - 2) {
      const bw = hash(x * 3.1 + sy * 7 + seed) < 0.7 ? 1 : 2, bh = 3 + Math.floor(hash(x * 5.7 + sy + seed) * 2), c = cols[Math.floor(hash(x * 1.3 + sy * 11 + seed) * cols.length)];
      if (x + bw > Wd - 1) break;
      if (hash(x * 9.1 + sy * 3 + seed) < 0.9) vox.box(x, sy, 1, bw, bh, Dp - 2, c, V);
      x += bw; k++;
    }
  }
  return V;
}
// a black doorway with stepped inner frames (the reference's "this entire reel" portal); the screen is a separate plane
export function doorway(Wd = 16, Ht = 26) {
  const V = [], greys = [0x1e1d1b, 0x3a3836, 0x5f5c58, 0x8d8a85];
  greys.forEach((c, k) => {
    const x0 = k, y0 = k, w = Wd - 2 * k, h = Ht - 2 * k, z = -k * 2;
    for (let x = x0; x < x0 + w; x++) for (let y = y0; y < y0 + h; y++) {
      const edge = x === x0 || x === x0 + w - 1 || y === y0 || y === y0 + h - 1;
      if (edge) { V.push({ x, y, z, c }); V.push({ x, y, z: z - 1, c }); }
    }
  });
  return V;
}
// a keyboard: black base + 6 rows of white keys (key i = the voxel list index range keys[i])
export function keyboard() {
  const V = [], keys = [];
  vox.box(0, 0, 0, 24, 1, 9, PAL.black, V);
  for (let r = 0; r < 6; r++) for (let c = 0; c < 21; c++) {
    if (r === 5 && (c < 6 || c > 14) && c % 2) continue;
    const i = V.length; V.push({ x: 1 + c + (r % 2 ? 0 : 0), y: 1, z: 1 + r + (r > 0 ? 0 : 0), c: 0xf2efe8 }); keys.push(i);
  }
  return { V, keys };
}
// a stone paywall (grey bricks in running bond, light mortar) with a big gold padlock in front
export function paywall() {
  const V = [], Wd = 20, Ht = 14, STONE = [0x8b857c, 0x7d776f, 0x948e85, 0x736d66];
  for (let y = 0; y < Ht; y++) for (let x = 0; x < Wd; x++) {
    const off = (y % 2) * 2, mortar = (x + off) % 4 === 3 || y % 3 === 2;
    const id = Math.floor((x + off) / 4) * 31 + Math.floor(y / 3) * 7, c = STONE[Math.floor(hash(id * 1.37) * STONE.length)];
    V.push({ x, y, z: 0, c: mortar ? 0xd8cfc0 : c }); if (!mortar) V.push({ x, y, z: 1, c });
  }
  // padlock: gold body + grey shackle + black keyhole, proud of the wall
  vox.box(6, 2, 2, 8, 6, 2, PAL.gold, V);
  for (let x = 7; x < 13; x++) for (let y = 8; y < 12; y++) if (x === 7 || x === 8 || x === 11 || x === 12 || y >= 10) { if (!(y === 11 && (x === 7 || x === 12))) { V.push({ x, y, z: 2, c: 0xa6a39d }); V.push({ x, y, z: 3, c: 0xa6a39d }); } }
  for (const [x, y] of [[9, 5], [10, 5], [9, 4], [10, 4], [9, 3], [10, 3]]) V.push({ x, y, z: 4, c: x === 10 && y === 3 ? PAL.gold : PAL.black });
  return V;
}
// a puppet control (wooden cross) — strings are separate lines
export function puppetBar() {
  const V = [];
  vox.box(0, 0, 3, 16, 1, 1, PAL.wood, V); vox.box(7, 0, 0, 2, 1, 8, PAL.wood, V); vox.box(7, 1, 3, 2, 3, 2, 0x87593a, V);
  return V;
}
// a page (the .md file) — white sheet with a folded corner and grey "text" lines
export function page(w = 10, h = 13) {
  const V = [];
  for (let x = 0; x < w; x++) for (let y = 0; y < h; y++) {
    if (x >= w - 3 && y >= h - 3 && (x - (w - 3)) + (y - (h - 3)) > 2) continue; // folded corner
    const line = y > 1 && y < h - 3 && y % 2 === 0 && x > 0 && x < w - 1 - (y % 4 === 0 ? 2 : 0);
    V.push({ x, y, z: 0, c: line ? 0xb9b2a6 : 0xfbf9f4 });
  }
  for (let k = 0; k < 3; k++) V.push({ x: w - 3 + k, y: h - 1 - k, z: 1, c: 0xd9d3c7 });
  return V;
}
// a grey bucket (trash bin) with staves
export function bin() {
  const V = [], R0 = 4.6;
  for (let y = 0; y < 9; y++) for (let x = -5; x <= 5; x++) for (let z = -5; z <= 5; z++) {
    const d = Math.hypot(x, z), r = R0 + y * 0.06;
    if (d <= r && d > r - 1.25) { const ang = Math.atan2(z, x), stave = Math.floor((ang + Math.PI) / (Math.PI * 2) * 14) % 2; V.push({ x, y, z, c: y === 8 ? 0x6e6b67 : stave ? 0x8f8c87 : 0x7c7975 }); }
  }
  for (let x = -4; x <= 4; x++) for (let z = -4; z <= 4; z++) if (Math.hypot(x, z) < 4) V.push({ x, y: 0, z, c: 0x5e5b57 });
  return V;
}

// ---------- particles: embers, sparks, smoke (pure functions of t) ----------
// P = particles(E, n, { size }) ; P.update(t, (i, r) => ({ p:[x,y,z], s, c }) | null)
export function particles(E, n, o = {}) {
  const mat = vox.material({ seams: false, emissive: o.glow ? 0xffffff : 0x000000, ei: 0 });
  const mesh = new THREE.InstancedMesh(new THREE.BoxGeometry(1, 1, 1), mat, n); mesh.frustumCulled = false; mesh.castShadow = !!o.shadow;
  E.scene.add(mesh); const m4 = new THREE.Matrix4(), q = new THREE.Quaternion(), e = new THREE.Euler(), c = new THREE.Color(), pv = new THREE.Vector3(), sv = new THREE.Vector3();
  const size = o.size ?? 0.06;
  return { mesh, update(t, fn) {
    let any = false;
    for (let i = 0; i < n; i++) {
      const r = [hash(i * 1.1 + 3), hash(i * 2.3 + 5), hash(i * 3.7 + 9), hash(i * 5.1 + 1)], st = fn(i, r);
      if (!st || st.s <= 0.001) { m4.makeScale(0, 0, 0); mesh.setMatrixAt(i, m4); continue; }
      any = true; e.set(st.rx || 0, st.ry || 0, st.rz || 0); q.setFromEuler(e); pv.set(...st.p); sv.setScalar(st.s * size);
      m4.compose(pv, q, sv); mesh.setMatrixAt(i, m4); c.set(st.c); mesh.setColorAt(i, c);
    }
    mesh.instanceMatrix.needsUpdate = true; if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; mesh.visible = any;
  } };
}

// a thin line (string) from a to b
export function line(E, color = 0x2b2926, r = 0.012) {
  const m = new THREE.Mesh(new THREE.CylinderGeometry(r, r, 1, 6), new THREE.MeshStandardMaterial({ color, roughness: 0.9 })); m.castShadow = true; E.scene.add(m);
  const up = new THREE.Vector3(0, 1, 0), d = new THREE.Vector3(), A = new THREE.Vector3(), B = new THREE.Vector3();
  return { mesh: m, set(a, b, on = true) { m.visible = on; if (!on) return; A.set(...a); B.set(...b); d.subVectors(B, A); const L = d.length(); m.position.copy(A).addScaledVector(d, 0.5); m.scale.set(1, Math.max(0.001, L), 1); m.quaternion.setFromUnitVectors(up, d.normalize()); } };
}

// a flat canvas plane in the world (price tag, screens). draw(g, W, H, t)
export function plane(E, w, h, draw, o = {}) {
  const px = o.px ?? 420, cv = document.createElement('canvas'); cv.width = Math.round(w * px); cv.height = Math.round(h * px); const g = cv.getContext('2d');
  const tex = new THREE.CanvasTexture(cv); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 8;
  const mat = o.lit ? new THREE.MeshStandardMaterial({ map: tex, transparent: !!o.transparent, roughness: 0.9, side: THREE.DoubleSide }) : new THREE.MeshBasicMaterial({ map: tex, transparent: true, fog: false, toneMapped: false, side: THREE.DoubleSide, depthWrite: o.depthWrite ?? true });
  const mesh = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat); mesh.castShadow = !!o.shadow; (o.parent || E.scene).add(mesh);
  return { mesh, g, cv, tex, mat, paint(t, ...a) { g.clearRect(0, 0, cv.width, cv.height); draw(g, cv.width, cv.height, t, ...a); tex.needsUpdate = true; } };
}

// the CTA's held card: a thick terracotta slab with white type on the front ("THE FILE" / "sandbox.md")
export function slab(E, w, h, d, lines) {
  const cv = document.createElement('canvas'); cv.width = Math.round(w * 600); cv.height = Math.round(h * 600); const g = cv.getContext('2d');
  g.fillStyle = '#d9694d'; g.fillRect(0, 0, cv.width, cv.height);
  g.fillStyle = 'rgba(0,0,0,0.06)'; g.fillRect(0, cv.height - 16, cv.width, 16); g.fillRect(cv.width - 14, 0, 14, cv.height);
  for (const L of lines) { g.font = L.font; g.fillStyle = L.color || '#ffffff'; if ('letterSpacing' in g) g.letterSpacing = (L.sp ?? 0) + 'px'; g.fillText(L.text, L.x * cv.width, L.y * cv.height); }
  const tex = new THREE.CanvasTexture(cv); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 8;
  const side = new THREE.MeshStandardMaterial({ color: 0xc95f45, roughness: 0.85 }), front = new THREE.MeshStandardMaterial({ map: tex, roughness: 0.85, emissive: 0xffffff, emissiveMap: tex, emissiveIntensity: 0.42 });
  const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), [side, side, side, side, front, side]); mesh.castShadow = true; E.scene.add(mesh);
  return mesh;
}
```

### `engine/overlay.js`

```js
// The 2D layer over the 3D (1080x1920): the two HUD pills, the step chip with progress dots, the word-by-word captions
// (last 4 words of the clause, the newest one chartreuse), big titles, mono chips. Engine file.
// Every element is placed inside the RULES.md safe zone and records its box (stills draw them with --safe).
import { clamp, seg, easeOut, easeOutBack, lerp } from './lib.js';

export const OW = 1080, OH = 1920;
export const SAFE = { x0: 60, x1: 960, y0: 250, y1: 1480, cap0: 1150, cap1: 1480, cx: 510, capW: 820 };
export const COL = { ink: '#1d1b19', pill: '#2e2c28', chip: '#45413c', lime: '#e6fd5e', dot: '#6a655d', terra: '#d9775a', star: '#e2795c', white: '#ffffff', cap: 'rgba(43,38,32,0.96)' };
export const LAYOUT = { hudY: 262, hudH: 50, chipY: 324, chipH: 36, capY: 1410 };
const MONO = '"JetBrains Mono", Consolas, monospace', SANS = 'Inter, "Inter Tight", sans-serif';

let BOXES = [];
export function beginFrame() { BOXES = []; }
export const boxes = () => BOXES;
function mark(g, kind, text, x, y, w, h) {
  const m = g.getTransform(), P = [[x, y], [x + w, y], [x, y + h], [x + w, y + h]].map(([px, py]) => [m.a * px + m.c * py + m.e, m.b * px + m.d * py + m.f]);
  BOXES.push({ kind, text: String(text).slice(0, 40), x0: Math.min(...P.map(p => p[0])), x1: Math.max(...P.map(p => p[0])), y0: Math.min(...P.map(p => p[1])), y1: Math.max(...P.map(p => p[1])) });
}
export function violations(list = BOXES) {
  const tol = 3, out = [];
  for (const b of list) {
    const cap = b.kind === 'caption';
    const X0 = cap ? SAFE.cx - SAFE.capW / 2 : SAFE.x0, X1 = cap ? SAFE.cx + SAFE.capW / 2 : SAFE.x1, Y0 = cap ? SAFE.cap0 : SAFE.y0, Y1 = cap ? SAFE.cap1 : SAFE.y1;
    if (b.x0 < X0 - tol || b.x1 > X1 + tol || b.y0 < Y0 - tol || b.y1 > Y1 + tol) out.push({ ...b, x0: Math.round(b.x0), x1: Math.round(b.x1), y0: Math.round(b.y0), y1: Math.round(b.y1) });
  }
  return out;
}
export function drawSafe(g, list = BOXES) {
  g.save(); g.clearRect(0, 0, OW, OH);
  g.fillStyle = 'rgba(255,0,60,0.26)';
  g.fillRect(0, 0, OW, SAFE.y0); g.fillRect(0, SAFE.y1, OW, OH - SAFE.y1); g.fillRect(0, SAFE.y0, SAFE.x0, SAFE.y1 - SAFE.y0); g.fillRect(SAFE.x1, SAFE.y0, OW - SAFE.x1, SAFE.y1 - SAFE.y0);
  g.setLineDash([14, 10]); g.lineWidth = 3; g.strokeStyle = 'rgba(0,220,255,0.95)';
  g.strokeRect(SAFE.cx - SAFE.capW / 2, SAFE.cap0, SAFE.capW, SAFE.cap1 - SAFE.cap0); g.setLineDash([]);
  const bad = new Set(violations(list).map(b => b.kind + b.text + Math.round(b.x0) + Math.round(b.y0)));
  for (const b of list) { g.strokeStyle = bad.has(b.kind + b.text + Math.round(b.x0) + Math.round(b.y0)) ? '#ff2040' : '#30ff70'; g.lineWidth = 3; g.strokeRect(b.x0, b.y0, b.x1 - b.x0, b.y1 - b.y0); }
  g.font = '26px Consolas, monospace'; g.fillStyle = '#fff'; g.textBaseline = 'top';
  g.fillText('SAFE  text x 60–960 · y 250–1480   captions (cyan) y 1150–1480, ≤ 820 wide, centre x 510', 20, 20);
  g.restore();
}

export function rr(g, x, y, w, h, r) { r = Math.min(r, h / 2, w / 2); g.beginPath(); g.moveTo(x + r, y); g.arcTo(x + w, y, x + w, y + h, r); g.arcTo(x + w, y + h, x, y + h, r); g.arcTo(x, y + h, x, y, r); g.arcTo(x, y, x + w, y, r); g.closePath(); }
// letter-spaced text (canvas letterSpacing where available)
function spaced(g, s, x, y, sp) { if ('letterSpacing' in g) { g.letterSpacing = sp + 'px'; g.fillText(s, x, y); g.letterSpacing = '0px'; } else g.fillText(s, x, y); }
function spacedW(g, s, sp) { if ('letterSpacing' in g) { g.letterSpacing = sp + 'px'; const w = g.measureText(s).width; g.letterSpacing = '0px'; return w - sp; } return g.measureText(s).width; }

// a dark mono pill. align: 'left' (x = left edge) | 'right' (x = right edge) | 'center'. Returns its box.
export function pill(g, text, x, y, o = {}) {
  const fs = o.size ?? 21, h = o.h ?? Math.round(fs * 2.35), padX = o.padX ?? Math.round(fs * 0.95), sp = o.sp ?? fs * 0.14;
  g.save(); g.font = `${o.weight ?? 700} ${fs}px ${MONO}`;
  const star = o.star ? fs * 1.05 : 0, tw = spacedW(g, text, sp) + star, w = tw + padX * 2;
  const x0 = o.align === 'right' ? x - w : o.align === 'center' ? x - w / 2 : x;
  g.globalAlpha = o.alpha ?? 1;
  const sc = o.scale ?? 1; if (sc !== 1) { g.translate(x0 + w / 2, y + h / 2); g.scale(sc, sc); g.translate(-(x0 + w / 2), -(y + h / 2)); }
  g.fillStyle = o.bg ?? COL.pill; rr(g, x0, y, w, h, h / 2); g.fill();
  g.textBaseline = 'middle'; g.fillStyle = o.color ?? COL.white;
  if (o.star) { g.fillStyle = COL.star; g.font = `700 ${fs * 1.05}px ${SANS}`; g.fillText('✳', x0 + padX - 2, y + h / 2 + 1); g.font = `${o.weight ?? 700} ${fs}px ${MONO}`; g.fillStyle = o.color ?? COL.white; }
  spaced(g, text, x0 + padX + star, y + h / 2 + 1, sp);
  mark(g, o.kind || 'pill', text, x0, y, w, h);
  g.restore();
  return { x0, y, w, h };
}
// the two HUD pills (every frame)
export function hud(g, t, o = {}) {
  pill(g, o.left ?? 'CLAUDE · MADE THIS', SAFE.x0, LAYOUT.hudY, { star: true, kind: 'hud' });
  pill(g, o.right ?? 'BEHIND THE REEL', SAFE.x1 - 6, LAYOUT.hudY + 2, { align: 'right', size: 20, h: 46, kind: 'hud' });
}
// the step chip under the left pill: n dots (done = chartreuse) + "03 / LABEL". pop = 0..1 appear amount
export function stepChip(g, t, { step, of = 8, label, at = 0, pulse = 0 } = {}) {
  if (t < at) return;
  const k = clamp((t - at) / 0.22), fs = 18, h = LAYOUT.chipH, y = LAYOUT.chipY, x = SAFE.x0;
  g.save(); g.globalAlpha = easeOut(k);
  g.font = `700 ${fs}px ${MONO}`;
  const txt = `${String(step).padStart(2, '0')} / ${label}`, dotsW = of * 13, tw = spacedW(g, txt, 2), w = 18 + dotsW + 10 + tw + 18;
  g.translate(x, y + h / 2); g.scale(lerp(0.85, 1, easeOutBack(k)), lerp(0.85, 1, easeOutBack(k))); g.translate(-x, -(y + h / 2));
  g.fillStyle = COL.chip; rr(g, x, y, w, h, h / 2); g.fill();
  for (let i = 0; i < of; i++) {
    const on = i < step, cur = i === step - 1, r = cur ? 5 + 1.6 * pulse : 4.2;
    g.fillStyle = on ? COL.lime : COL.dot; g.beginPath(); g.arc(x + 18 + i * 13 + 4, y + h / 2, r, 0, 7); g.fill();
  }
  g.fillStyle = COL.white; g.textBaseline = 'middle'; spaced(g, txt, x + 18 + dotsW + 10, y + h / 2 + 1, 2);
  mark(g, 'chip', txt, x, y, w, h);
  g.restore();
}

// ---------- captions: last 4 words of the current clause, the newest word chartreuse ----------
export function caption(g, t, film, o = {}) {
  const W = film.words; let c = -1;
  for (let i = 0; i < W.length; i++) if (t >= W[i].t0 - 0.02) c = i; else break;
  if (c < 0 || (o.hide && o.hide(t))) return;
  const cl = W[c].clause; let a = c; while (a > 0 && W[a - 1].clause === cl && c - a < (o.max ?? 4) - 1) a--;
  const ws = W.slice(a, c + 1).map(w => w.cap);
  const fs = o.size ?? 46, padX = 20, h = Math.round(fs * 1.5), gap = fs * 0.27;
  g.save(); g.font = `700 ${fs}px ${SANS}`;
  const widths = ws.map(w => g.measureText(w).width), tot = widths.reduce((s, x) => s + x, 0) + gap * (ws.length - 1);
  let scale = Math.min(1, (SAFE.capW - 2 * padX) / tot);
  const bw = tot * scale + padX * 2, cy = o.y ?? LAYOUT.capY, x0 = SAFE.cx - bw / 2, y0 = cy - h / 2;
  g.fillStyle = COL.cap; rr(g, x0, y0, bw, h, 13); g.fill();
  g.textBaseline = 'middle'; let x = x0 + padX;
  g.translate(0, 0);
  ws.forEach((w, i) => {
    g.fillStyle = i === ws.length - 1 ? COL.lime : COL.white;
    g.save(); g.translate(x, cy + 2); g.scale(scale, scale); g.fillText(w, 0, 0); g.restore();
    x += (widths[i] + gap) * scale;
  });
  mark(g, 'caption', ws.join(' '), x0, y0, bw, h);
  g.restore();
}

// ---------- big titles ----------
// "IT'S ME." / "CLAUDE." stacked, left-aligned; the second line blurs in (k2 0..1)
export function title(g, t, lines, { x = SAFE.x0 + 6, y = 360, size = 124, at = [], blurIn = true, out = 1e9 } = {}) {
  g.save(); g.textBaseline = 'alphabetic';
  lines.forEach((L, i) => {
    const t0 = at[i] ?? 0; if (t < t0) return;
    const k = clamp((t - t0) / 0.28), ko = clamp((t - out) / 0.2); if (ko >= 1) return;
    g.font = `${L.weight ?? 600} ${L.size ?? size}px ${SANS}`; g.fillStyle = L.color ?? COL.ink;
    g.globalAlpha = (blurIn ? easeOut(k) : 1) * (1 - ko);
    if (blurIn && k < 1 && 'filter' in g) g.filter = `blur(${((1 - k) * 10).toFixed(1)}px)`;
    const yy = y + i * (L.lh ?? size * 1.0);
    if ('letterSpacing' in g) g.letterSpacing = (L.sp ?? -2) + 'px';
    g.fillText(L.text, x, yy); const w = g.measureText(L.text).width;
    if ('letterSpacing' in g) g.letterSpacing = '0px'; g.filter = 'none';
    mark(g, 'title', L.text, x, yy - (L.size ?? size) * 0.74, w, (L.size ?? size) * 0.76);
  });
  g.restore();
}
// one centred line of big text (the CTA's "COMMENT")
export function centered(g, text, y, { size = 100, weight = 500, color = COL.ink, alpha = 1, scale = 1, sp = -1 } = {}) {
  g.save(); g.globalAlpha = alpha; g.font = `${weight} ${size}px ${SANS}`; g.fillStyle = color; g.textBaseline = 'alphabetic';
  if ('letterSpacing' in g) g.letterSpacing = sp + 'px';
  const w = g.measureText(text).width; g.translate(SAFE.cx, y); g.scale(scale, scale); g.fillText(text, -w / 2, 0);
  if ('letterSpacing' in g) g.letterSpacing = '0px';
  mark(g, 'title', text, -w / 2, -size * 0.74, w, size * 0.76);
  g.restore();
}
```

### `engine/post.js`

```js
// Post pipeline: scene (MSAA + depth) -> bloom chain -> comp (DOF, bloom, tone map, grade) -> final (whip/zoom blur, CA, leak, flash, vignette, overlay, grain).
// Engine file (from the Voxel kit). Sandbox look: no ACES (a soft shoulder keeps the cream palette exact), no CA, a little grain.
import { FLASH_MAX, GRAIN } from './fx.js';
import * as THREE from 'three';

const VS = 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0., 1.); }';

export function makePost(renderer, W, H) {
  const cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
  const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2));
  const fsScene = new THREE.Scene(); fsScene.add(quad);
  const pass = (frag, uniforms) => new THREE.ShaderMaterial({ vertexShader: VS, fragmentShader: frag, uniforms, depthTest: false, depthWrite: false });
  const run = (m, target) => { quad.material = m; renderer.setRenderTarget(target); renderer.render(fsScene, cam); };
  const rt = (w, h, o = {}) => new THREE.WebGLRenderTarget(w, h, { type: THREE.HalfFloatType, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, ...o });

  const depthTex = new THREE.DepthTexture(W, H); depthTex.type = THREE.FloatType;
  const rtScene = rt(W, H, { samples: 4, depthTexture: depthTex });
  const rtBright = rt(W / 2, H / 2);
  const levels = [4, 8, 16].map(d => [rt(W / d, H / d), rt(W / d, H / d)]);
  const rtC = rt(W, H);
  const rtF = [0, 1].map(() => new THREE.WebGLRenderTarget(W, H, { type: THREE.UnsignedByteType, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter }));

  const brightM = pass(`varying vec2 vUv; uniform sampler2D t; uniform float thr;
    void main(){ vec3 c = texture2D(t, vUv).rgb; float l = max(c.r, max(c.g, c.b)); gl_FragColor = vec4(c * smoothstep(thr, thr + 0.6, l), 1.); }`, { t: { value: null }, thr: { value: 0.93 } });
  const copyM = pass(`varying vec2 vUv; uniform sampler2D t; uniform vec2 px;
    void main(){ vec3 c = texture2D(t, vUv + px*vec2(-.5,-.5)).rgb + texture2D(t, vUv + px*vec2(.5,-.5)).rgb + texture2D(t, vUv + px*vec2(-.5,.5)).rgb + texture2D(t, vUv + px*vec2(.5,.5)).rgb; gl_FragColor = vec4(c*.25, 1.); }`, { t: { value: null }, px: { value: new THREE.Vector2() } });
  const blurM = pass(`varying vec2 vUv; uniform sampler2D t; uniform vec2 dir;
    void main(){ vec3 c = texture2D(t, vUv).rgb * 0.227027;
      c += (texture2D(t, vUv + dir*1.3846).rgb + texture2D(t, vUv - dir*1.3846).rgb) * 0.3162162;
      c += (texture2D(t, vUv + dir*3.2308).rgb + texture2D(t, vUv - dir*3.2308).rgb) * 0.0702703;
      gl_FragColor = vec4(c, 1.); }`, { t: { value: null }, dir: { value: new THREE.Vector2() } });

  const U = {
    tScene: { value: rtScene.texture }, tDepth: { value: depthTex }, b1: { value: levels[0][0].texture }, b2: { value: levels[1][0].texture }, b3: { value: levels[2][0].texture },
    res: { value: new THREE.Vector2(W, H) }, near: { value: 0.1 }, far: { value: 200 }, focus: { value: 5 }, aperture: { value: 10 }, maxBlur: { value: 16 },
    bloom: { value: 0.3 }, exposure: { value: 1 }, wA: { value: 0 }, wM: { value: 0 },
  };
  const compM = pass(`varying vec2 vUv; uniform sampler2D tScene, tDepth, b1, b2, b3; uniform vec2 res; uniform float near, far, focus, aperture, maxBlur, bloom, exposure, wA, wM;
    float linZ(float d){ float z = d * 2. - 1.; return 2. * near * far / (far + near - z * (far - near)); }
    float coc(float z){ return clamp(aperture * abs(1. - focus / z), 0., maxBlur); }
    vec3 shoulder(vec3 x){ vec3 k = 1. - 0.18 * exp(-(x - 0.82) / 0.18); return mix(x, k, step(0.82, x)); }
    void main(){
      float z = linZ(texture2D(tDepth, vUv).x); float c0 = coc(z);
      vec3 acc = texture2D(tScene, vUv).rgb; float ws = 1.;
      if (c0 > 0.6) {
        for (int i = 1; i < 40; i++) {
          float r = sqrt(float(i) / 40.); float a = float(i) * 2.39996;
          vec2 o = vec2(cos(a), sin(a)) * r * c0 / res;
          float sz = linZ(texture2D(tDepth, vUv + o).x); float sc = coc(sz);
          float w = sz < z ? smoothstep(r * c0 - 1.5, r * c0, sc + 1.) : 1.;
          w = max(w, 0.15);
          acc += texture2D(tScene, vUv + o).rgb * w; ws += w;
        }
      }
      vec3 col = acc / ws;
      col += (texture2D(b1, vUv).rgb * 0.5 + texture2D(b2, vUv).rgb * 0.8 + texture2D(b3, vUv).rgb * 1.1) * bloom;
      col *= exposure;
      col *= mix(vec3(1.), vec3(1.16, 1.0, 0.8), wA);
      col *= mix(vec3(1.), vec3(0.78, 0.9, 1.12), wM);
      col = shoulder(col);
      col = pow(col, vec3(1. / 2.2));
      float l = dot(col, vec3(0.2126, 0.7152, 0.0722));
      float sat = 1.0 - 0.05 * wA - 0.5 * wM;
      col = mix(vec3(l), col, sat);
      float con = 1.0 - 0.08 * wA + 0.22 * wM;
      col = (col - 0.5) * con + 0.5;
      col += vec3(0.04, 0.022, 0.01) * wA + vec3(0.0, 0.01, 0.028) * wM;
      gl_FragColor = vec4(clamp(col, 0., 1.), 1.);
    }`, U);

  const F = {
    tC: { value: rtC.texture }, tO: { value: null }, oOn: { value: 1 }, oShift: { value: new THREE.Vector2() }, res: { value: new THREE.Vector2(W, H) },
    ca: { value: 0.0 }, vig: { value: 0.1 }, grain: { value: GRAIN }, time: { value: 0 },
    mbDir: { value: new THREE.Vector2() }, zoom: { value: 0 }, zoomC: { value: new THREE.Vector2(0.5, 0.5) },
    leak: { value: 0 }, leakX: { value: 0 }, flash: { value: 0 }, flashMax: { value: FLASH_MAX }, flashCol: { value: new THREE.Color(1, 1, 1) }, wM: { value: 0 }, wA: { value: 0 }, fade: { value: 1 },
  };
  const finalM = pass(`varying vec2 vUv; uniform sampler2D tC, tO; uniform float oOn, ca, vig, grain, time, zoom, leak, leakX, flash, flashMax, wM, wA, fade; uniform vec2 oShift, res, mbDir, zoomC; uniform vec3 flashCol;
    float h(vec2 p){ return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
    vec3 caS(vec2 uv){ vec2 d = uv - 0.5; float r2 = dot(d, d); vec2 off = d * r2 * ca * 4.0 / res.x * 6.0;
      return vec3(texture2D(tC, uv + off).r, texture2D(tC, uv).g, texture2D(tC, uv - off).b); }
    void main(){
      vec2 uv = vUv; vec3 col;
      float mb = length(mbDir) + zoom;
      if (mb > 0.0005) { col = vec3(0.); for (int k = 0; k < 16; k++) { float f = float(k) / 15. - 0.5; vec2 o = mbDir * f + (uv - zoomC) * zoom * f; col += caS(uv + o); } col /= 16.; }
      else col = caS(uv);
      // warm light leak sweeping across (aesthetic warm-up)
      if (leak > 0.) { float x = uv.x * 0.8 + uv.y * 0.35 - leakX; float g = exp(-x * x * 9.) ; vec3 lc = mix(vec3(1.0, 0.55, 0.25), vec3(1.0, 0.85, 0.55), uv.y); col += lc * g * leak * 0.9; col = mix(col, col * vec3(1.06, 1.0, 0.92), leak * 0.4); }
      // vignette
      vec2 d = uv - 0.5; d.x *= res.x / res.y; float v = smoothstep(0.95, 0.25, length(d) * (1.0 + 0.35 * wM));
      col *= mix(1. - vig - 0.25 * wM, 1., v);
      // halation glow in aesthetic
      col = mix(col, 1. - (1. - col) * (1. - col * vec3(0.18, 0.08, 0.02)), wA * 0.6);
      // flash: capped and eased toward white so a lightning hit never washes the frame out
      float fl = min(flash, flashMax); col += flashCol * fl * (1.0 - 0.55 * col);
      // overlay (straight alpha, display space)
      if (oOn > 0.5) { vec4 o = texture2D(tO, uv - oShift / res); col = mix(col, o.rgb, o.a); }
      col += (h(uv * res + fract(time * 7.13) * 91.7) - 0.5) * grain;
      col *= fade;
      gl_FragColor = vec4(clamp(col, 0., 1.), 1.);
    }`, F);

  function render(scene, camera, target, o = {}) {
    U.near.value = camera.near; U.far.value = camera.far;
    renderer.setRenderTarget(rtScene); renderer.render(scene, camera);
    brightM.uniforms.t.value = rtScene.texture; run(brightM, rtBright);
    let src = rtBright;
    for (const [a, b] of levels) {
      copyM.uniforms.t.value = src.texture; copyM.uniforms.px.value.set(1 / src.width, 1 / src.height); run(copyM, a);
      blurM.uniforms.t.value = a.texture; blurM.uniforms.dir.value.set(1 / a.width, 0); run(blurM, b);
      blurM.uniforms.t.value = b.texture; blurM.uniforms.dir.value.set(0, 1 / a.height); run(blurM, a);
      src = a;
    }
    run(compM, rtC);
    run(finalM, target);
  }
  return { U, F, render, rtF, brightM };
}
```

### `engine/camera.js`

```js
// Camera from a video's SHOTS list. Engine file.
// A shot: { t: [start, end], P: [[t, [x,y,z], ease?], ...], L: [[t, [x,y,z], ease?], ...], fov, fov1?, ap, sh, up?, focus?, fit?, frameY? }
//   P = camera position keys, L = look-at keys (put L ON the subject: it is also the focus distance).
//   fit = how many world units the frame must hold across at L (default FIT). The camera is pushed back
//   along its own line until that fits, so a close-up can never get tighter than that. fit: 0 = off (screen inserts).
//   frameY = where L lands on screen, as a fraction of the height (default FRAME_Y, a lens shift: the subject sits above
//   the terminal bar and the caption band instead of behind them). 0.5 = centre.
import { lerp, seg, easeInOut, fbm1 } from './lib.js';

export const FRAME_Y = 0.5; // subject at y ≈ 845 of 1920; terminal ≈ 1092, captions 1150–1480
export const FIT = 0; // a Claude with arms is ~1.5 wide → it fills ≤ ~80 % of the width (the source's 1.2-unit close-up cut it off)

export function keysE(t, K) { // [[t, value, ease?], ...]
  if (t <= K[0][0]) return K[0][1];
  for (let i = 1; i < K.length; i++) if (t < K[i][0]) { const e = K[i][2] || easeInOut; const k = e(seg(t, K[i - 1][0], K[i][0])); const a = K[i - 1][1], b = K[i][1]; return Array.isArray(a) ? a.map((v, j) => lerp(v, b[j], k)) : lerp(a, b, k); }
  return K.at(-1)[1];
}
export const shotIndex = (SHOTS, t) => { const i = SHOTS.findIndex(s => t >= s.t[0] && t < s.t[1]); return i < 0 ? SHOTS.length - 1 : i; };
// closest camera-to-target distance at which `fit` world units fill the frame's width (portrait: the width is the tight side)
export function minDistance(fovDeg, aspect, fit = FIT) { return fit / (2 * Math.tan(fovDeg * Math.PI / 360) * aspect); }

export function placeCamera(E, SHOTS, IMPULSE, t) {
  const { camera, post } = E;
  const i = shotIndex(SHOTS, t), s = SHOTS[i];
  let p = keysE(t, s.P); const l = keysE(t, s.L);
  const fov = s.fov1 != null ? lerp(s.fov, s.fov1, easeInOut(seg(t, s.t[0], s.t[1]))) : s.fov;
  let d = Math.hypot(p[0] - l[0], p[1] - l[1], p[2] - l[2]), clamped = 0;
  const fit = s.fit ?? FIT;
  if (fit > 0) { const md = minDistance(fov, camera.aspect, fit); if (d < md - 1e-4) { const k = md / d; p = p.map((v, j) => l[j] + (v - l[j]) * k); clamped = md - d; d = md; } }
  const sh = s.sh || 0;
  const hp = [fbm1(t * 0.55, 1) * sh * 6, fbm1(t * 0.55, 2) * sh * 4, fbm1(t * 0.5, 4) * sh * 3], hl = [fbm1(t * 0.5, 3) * sh * 3, fbm1(t * 0.5, 5) * sh * 3, 0];
  let im = 0; for (const [t0, a] of IMPULSE) if (t >= t0 && t < t0 + 0.5) im += a * Math.exp(-(t - t0) * 12) * Math.sin((t - t0) * 70);
  camera.position.set(p[0] + hp[0], p[1] + hp[1] + im, p[2] + hp[2]);
  camera.up.set(...(typeof s.up === 'function' ? s.up(t) : s.up || [0, 1, 0]));
  camera.lookAt(l[0] + hl[0], l[1] + hl[1] + im * 0.6, l[2]);
  const dy = Math.round((0.5 - (s.frameY ?? FRAME_Y)) * E.H);
  if (dy) camera.setViewOffset(E.W, E.H, 0, dy, E.W, E.H); else camera.clearViewOffset();
  camera.fov = fov; camera.updateProjectionMatrix();
  post.U.focus.value = s.focus || d;
  post.U.aperture.value = s.ap;
  return { shot: i, clamped };
}
```

### `engine/fx.js`

```js
// Post-effect helpers for a video's effects(t): zoom punches, whip blur, flashes, overlay shake.
// Engine file. The caps below are the engine's defaults: a video can't wash the frame out by accident.
import { seg, easeIn } from './lib.js';

export const FLASH_MAX = 0.24;      // strongest full-frame flash (additive, display space). 0.55 washed the frame white for 3 frames.
export const LIGHTNING_MAX = 2.2;   // strongest 3D lightning light (the source used 6 → white-out with bloom)
export const GRAIN = 0.012;         // film grain amount. 0.045 doubled the file size (113 MB for 55 s).

// [[t0, amount, [r,g,b]?], ...] → { v, col }: the strongest flash at t, decaying fast, capped at FLASH_MAX
export function flash(t, list, { dur = 0.35, decay = 14 } = {}) {
  let v = 0, col = [1, 1, 1];
  for (const [f0, a, c] of list) if (t >= f0 && t < f0 + dur) { const x = a * Math.exp(-(t - f0) * decay); if (x > v) { v = x; col = c || [1, 1, 1]; } }
  return { v: Math.min(v, FLASH_MAX), col };
}
// [[t0, amount], ...] → 0..1 decaying pulse (lightning light, sparks)
export function pulse(t, list, decay = 16, dur = 0.3) {
  let v = 0; for (const [f0, a] of list) if (t >= f0 && t < f0 + dur) v = Math.max(v, a * Math.exp(-(t - f0) * decay));
  return v;
}
// zoom punch into a cut: punchIn(t, cutT, lead, amount) ramps up before the cut, punchOut settles after it
export const punchIn = (t, cut, lead, amt) => t >= cut - lead && t < cut ? amt * easeIn(seg(t, cut - lead, cut)) : 0;
export const punchOut = (t, cut, dur, amt) => t >= cut && t < cut + dur ? amt * (1 - seg(t, cut, cut + dur)) : 0;
// [[t0, dx, dy], ...] → motion-blur direction around whip cuts (±0.1 s)
export function whips(t, list, half = 0.1) {
  for (const [t0, dx, dy] of list) { const k = Math.abs(t - t0); if (k < half) { const a = 1 - k / half; return [dx * a, dy * a]; } }
  return [0, 0];
}
// overlay shake on slams: [t0, ...] → [sx, sy] in px
export function shake(t, list, amp = 16) {
  let sx = 0, sy = 0;
  for (const s0 of list) if (t >= s0 && t < s0 + 0.3) { const a = amp * Math.exp(-(t - s0) * 14); sx += Math.sin((t - s0) * 90) * a; sy += Math.cos((t - s0) * 70) * a; }
  return [sx, sy];
}
// write everything to the post pass
export function apply(E, t, o = {}) {
  const F = E.post.F;
  F.zoom.value = o.zoom || 0; F.zoomC.value.set(...(o.zoomC || [0.5, 0.5])); F.mbDir.value.set(...(o.whip || [0, 0]));
  F.leak.value = o.leak || 0; F.leakX.value = o.leakX || 0;
  const fl = o.flash || { v: 0, col: [1, 1, 1] }; F.flash.value = Math.min(fl.v, FLASH_MAX); F.flashCol.value.setRGB(...fl.col);
  F.oShift.value.set(...(o.shake || [0, 0]));
  F.time.value = t; F.fade.value = o.fade ?? 1;
}
```

### `engine/kit.js`

```js
// The Sandbox toolkit: a video's scene.js declares WHAT happens WHEN, the kit does the rest every frame.
// Engine file.   const K = E.kit({ discs, stay })   ...declare...   return K.done({ world, after, overlay })
//
//   discs  [{ x, z, r, label }]   the sand discs, in path order (the mascot hops 0 → 1 → 2 …; the recap labels them)
//   stay   [[arrive, leave], …]   seconds the mascot stands on each disc; it hops over in the gaps (keep 0.25–0.5 s)
//
// What a scene can declare (every time is film seconds, usually K.T('word') = when that caption word starts):
//   K.mascot({ hops, wave, hold, stand })           beats for the voxel Claude (it talks, idles and hops between discs by itself)
//   K.hook({ lines, sticker, burn, until })         the title card that burns + the firecracker in the right paw (the reference's hook)
//   K.prop(voxels, { disc, x, y, z, rotY, size, in, out })   a voxel prop that builds itself and blows apart
//   K.card(draw, { disc, x, y, z, w, h, in, out, dark, bg })  a UI card (engine/layouts.js) that builds out of white cubes
//   K.bubble(text, o) · K.pill(text, o)             small one-line cards
//   K.keyboard({ disc, in, out, typing })           a voxel keyboard the mascot stands on and types with its feet
//   K.portal({ disc, in, out, screen })             a black stepped doorway with a glowing screen inside
//   K.toss({ disc, draw, from, bin, at, show })     a small tag thrown into a bin prop
//   K.path({ from, to, cam })                       station camera + whip-hop moves for discs from..to
//   K.shot(t0, t1, { p0, p1, l0, l1, fov, ap, ease }) any extra camera shot (P = position, L = look-at = the focus)
//   K.recap({ leave, up, sweep, name, dive, cta })  crane over the whole path, light it up chartreuse disc by disc, dive into the last
//   K.cta({ at, word, wordAt, chip, chipAt, slab }) COMMENT + the keyword in voxels + a chip + the slab the mascot holds
//   K.hud({ left, right }) · K.steps([[label, t], …], { until })   the HUD pills and the step chip
//   K.flash(t, amount) · K.world(fn) · K.after(fn) · K.overlay(fn)   extras
import * as THREE from 'three';
import * as vox from './vox.js';
import * as cards from './cards.js';
import * as LAY from './layouts.js';
import * as P from './props.js';
import * as O from './overlay.js';
import * as FX from './fx.js';
import { clamp, seg, ss, lerp, easeOut, easeIn, easeInOut, easeOutBack, hop, hash } from './lib.js';

export function makeKit(E, cfg) {
  const { film, R } = E, D = cfg.discs, STAY = cfg.stay, C = E.mascot, FLOOR = R.floorY;
  const K = { E, D, STAY, FLOOR, film, SHOTS: [], IMPULSE: [], layouts: LAY, cards, props: P, vox, O };
  const worlds = [], afters = [], overlays = [], flashes = [], whips = [];
  // ---------------- words ----------------
  const find = (cap, n, key) => { let k = 0; for (const w of film.words) if (w.cap === cap && ++k === n) return w[key]; throw new Error(`no caption word "${cap}" #${n} (film.json words are the shown tokens, punctuation included)`); };
  K.T = (cap, n = 1) => find(cap, n, 't0');   // when the n-th caption token `cap` starts
  K.TE = (cap, n = 1) => find(cap, n, 't1');  // … and ends
  K.at = (i, dx = 0, y = 0, dz = 0) => [D[i].x + dx, y, D[i].z + dz];
  // ---------------- the path: where the mascot is ----------------
  const wrap = a => { while (a > Math.PI) a -= 2 * Math.PI; while (a < -Math.PI) a += 2 * Math.PI; return a; };
  K.walker = t => { // { x, z, hop, yaw, sq, moving, i }: two hops from disc i-1 to i in each gap, turning toward where it goes
    for (let i = 0; i < STAY.length; i++) {
      const [a, b] = STAY[i];
      if (t < a && i > 0) {
        const b0 = STAY[i - 1][1], k = clamp((t - b0) / (a - b0)), A = D[i - 1], B = D[i];
        const kk = (k % 0.5) * 2, pk = (k < 0.5 ? 0 : 0.5) + 0.5 * easeInOut(kk), dir = Math.atan2(B.x - A.x, B.z - A.z);
        const yaw = k < 0.15 ? lerp(0, dir, k / 0.15) : k > 0.85 ? lerp(dir, 0, (k - 0.85) / 0.15) : dir;
        return { x: lerp(A.x, B.x, pk), z: lerp(A.z, B.z, pk), hop: Math.sin(kk * Math.PI) * 0.55, yaw: wrap(yaw), sq: -0.12 * Math.sin(kk * Math.PI), moving: true, k, i };
      }
      if (t <= b || i === STAY.length - 1) return { x: D[i].x, z: D[i].z, hop: 0, yaw: 0, sq: 0, moving: false, i };
    }
  };
  K.discAt = t => { let i = 0; for (let k = 0; k < STAY.length; k++) if (t >= STAY[k][0] - 0.15) i = k; return i; };

  // ---------------- the mascot ----------------
  const M = { hops: [], wave: [], hold: [], stand: {}, fallIn: true };
  K.mascot = (o = {}) => { Object.assign(M, o); return K; };
  K.pose = { y: 0, hop: 0, armR: null, x: 0, z: 0 };   // the mascot's pose this frame (props that follow it read this)
  function poseMascot(t) {
    const w = K.walker(t), talk = E.talkAt(t), di = K.discAt(t);
    let y = FLOOR, hp = w.hop, sq = w.sq, armL = 0.18 + Math.sin(t * 5.1) * 0.05 + talk * 0.25, armR = 0.18 + Math.sin(t * 4.7 + 1) * 0.05 + talk * 0.22;
    if (!w.moving && M.stand[di] != null) y += typeof M.stand[di] === 'function' ? M.stand[di](t) : M.stand[di];
    for (const [t0, t1] of M.wave) if (t >= t0 && t < t1) armR = 0.9 + Math.sin((t - t0) * 16) * 0.45 * (1 - ss(t, t1 - 0.4, t1));
    for (const [t0, t1, a] of M.hold) if (t >= t0 && t < t1) armR = a ?? 0.6;
    for (const [h0, d, hh] of M.hops) { const h = hop(t, h0, d ?? 0.3, hh ?? 0.18); hp += h.y; sq += h.sq; }
    const anim = M.fallIn && t < 1.2 ? { in: [-0.3, 1.0, 'fall'], spread: 0.42, stagger: 0.5 } : {};
    // it never stands still: a slow sway and lean while it stands on a disc (more when it is silent)
    const idle = w.moving ? 0 : 1, quiet = 1 - Math.min(1, talk * 3);
    const yaw = w.yaw + idle * (Math.sin(t * 0.9) * 0.06 + quiet * Math.sin(t * 1.9 + 0.7) * 0.07), lean = idle * Math.sin(t * 1.3 + 0.4) * 0.025;
    C.pose(t, { x: w.x, y, z: w.z, hop: hp, yaw, lean, sq, armL, armR, talk: w.moving ? 0 : talk, anim, blink: Math.floor(t * 30) % 97 === 0 });
    Object.assign(K.pose, { x: w.x, z: w.z, y, hop: hp, armR, moving: w.moving, disc: di });
    R.follow(w.x, w.z);
  }

  // ---------------- props + cards (registered, then animated every frame) ----------------
  let seed = 1;
  K.prop = (voxels, o = {}) => {
    const m = vox.model(E, voxels, { size: o.size ?? 0.1, seed: o.seed ?? (seed += 7), jitter: o.jitter });
    const base = o.disc != null ? K.at(o.disc, o.x ?? 0, FLOOR + (o.y ?? 0), o.z ?? 0) : [o.x ?? 0, o.y ?? 0, o.z ?? 0];
    m.group.position.set(...base); m.group.rotation.y = o.rotY ?? 0;
    const a = { in: o.in, out: o.out, spread: o.spread ?? 1, stagger: o.stagger };
    m.anims = a; worlds.push(t => { if (o.follow) o.follow(m, t); m.anim(t, { in: a.in || [-1, 0, 'none'], out: a.out || [1e9, 0, 'none'], spread: a.spread, stagger: a.stagger }); });
    return m;
  };
  K.card = (draw, o = {}) => {
    const c = cards.card(E, { w: o.w ?? 2.0, h: o.h ?? 1.25, draw, dark: o.dark, bg: o.bg, cube: o.cube, cols: o.cols, radius: o.radius, glow: o.glow, seed: o.seed ?? (seed += 5) });
    c.at(...(o.disc != null ? K.at(o.disc, o.x ?? 0, o.y ?? 2.2, o.z ?? -0.25) : [o.x ?? 0, o.y ?? 2.2, o.z ?? 0]));
    afters.push(t => c.update(t, { in: o.in, out: o.out ? [o.out[0], o.out[1] ?? 0.4, o.out[2] ?? 'burst'] : undefined, spread: o.spread }));
    return c;
  };
  K.bubble = (text, o = {}) => K.card(LAY.pill({ text }), { w: 0.98 + text.length * 0.02, h: 0.34, cols: 10, radius: 70, glow: 30, z: -0.05, ...o });
  K.pill = (text, o = {}) => K.card(LAY.pill({ text, size: 104, weight: 900, sp: -2 }), { w: 0.82, h: 0.34, cols: 8, radius: 80, glow: 26, bg: '#e6fd5e', cube: 0xe6fd5e, ...o });

  // a voxel keyboard on a disc: the mascot stands on it; keys light up terracotta while `typing` = [t0, t1]
  K.keyboard = (o) => {
    const kb = P.keyboard(), m = K.prop(kb.V, { disc: o.disc, x: o.x ?? 0, z: o.z ?? 0.05, size: 0.085, in: o.in, out: o.out, spread: 1.2 });
    M.stand[o.disc] = t => 0.17 + Math.abs(Math.sin(t * 22)) * 0.025 * ss(t, o.typing[0], o.typing[0] + 0.15) * (1 - ss(t, o.typing[1], o.typing[1] + 0.1));
    worlds.push(t => { if (t < (o.in?.[0] ?? 0) - 0.5 || t > (o.out?.[0] ?? 1e9) + 0.5) return; const on = t > o.typing[0] && t < o.typing[1], sig = on ? Math.floor(t * 16) : -1;
      kb.keys.forEach((vi, k) => m.color(vi, on && hash(k * 3.3 + sig * 1.7) < 0.12 ? 0xd9694d : 0xf2efe8)); m.flushColors(); });
    return m;
  };
  // the black stepped doorway with a screen inside (screen = draw(g, W, H, t); it fades in at screenAt)
  K.portal = (o) => {
    const door = K.prop(P.doorway(17, 27), { disc: o.disc, x: o.x ?? 0, y: -0.02, z: o.z ?? -0.95, size: 0.1, in: o.in, out: o.out, spread: 1.1 });
    const scr = P.plane(E, 0.92, 1.72, o.screen, { px: 520 }); scr.mesh.position.set(...K.at(o.disc, o.x ?? 0, FLOOR + 1.35, (o.z ?? -0.95) - 0.77)); scr.mesh.renderOrder = 1;
    worlds.push(t => { const k = ss(t, o.screenAt ?? o.in[0] + 0.5, (o.screenAt ?? o.in[0] + 0.5) + 0.3) * (1 - ss(t, o.out[0] - 0.08, o.out[0] + 0.02)); scr.mesh.visible = k > 0.01; if (scr.mesh.visible) { scr.mat.opacity = k; scr.paint(t); } });
    return { door, screen: scr };
  };
  // a small tag (draw(g, W, H, k)) hanging at `from` (offsets from the disc), thrown into the bin prop at `at`
  K.toss = (o) => {
    const tag = P.plane(E, o.w ?? 0.5, o.h ?? 0.3, (g, W, H, t, k) => o.draw(g, W, H, k), { px: 520 });
    worlds.push(t => {
      const tagK = ss(t, o.at, o.at + 0.55), s0 = K.at(o.disc, ...o.from), bp = o.bin.group.position, b = [bp.x, FLOOR + 0.62, bp.z];
      tag.mesh.visible = t > o.show[0] && t < o.at + 0.7; if (!tag.mesh.visible) return;
      tag.mesh.position.set(lerp(s0[0], b[0], easeInOut(tagK)), lerp(s0[1], b[1], easeIn(tagK)) + Math.sin(tagK * Math.PI) * 0.25, lerp(s0[2], b[2], tagK));
      tag.mesh.scale.setScalar(1 - 0.4 * ss(t, o.at + 0.45, o.at + 0.7)); tag.paint(t, tagK);
    });
    afters.push(t => { if (!tag.mesh.visible) return; const tagK = ss(t, o.at, o.at + 0.55); tag.mesh.quaternion.copy(E.camera.quaternion); tag.mesh.rotateZ(Math.sin(t * 4) * 0.12 * (1 - tagK) + tagK * 2.4); });
    return tag;
  };

  // ---------------- the hook: a title card that burns + a firecracker in the right paw ----------------
  K.hook = (o) => {
    const disc = o.disc ?? 0, TW = o.w ?? 1.72, TH = o.h ?? 0.92, tp = K.at(disc, o.x ?? -0.06, o.y ?? 2.42, o.z ?? -0.35), HK = o.burn, UNTIL = o.until;
    const burnK = t => ss(t, HK + 0.02, HK + 0.95);
    const title = P.plane(E, TW, TH, (g, W, H, t) => drawTitle(g, W, H, t), { px: 600 }); title.mesh.position.set(...tp); title.mesh.renderOrder = 6;
    function drawTitle(g, W, H, t) {
      const k = burnK(t), R0 = 44, [l1, l2] = o.lines;
      g.save(); cards.rr(g, 8, 8, W - 16, H - 16, R0); g.fillStyle = '#f7f3e9'; g.fill(); g.restore();
      g.save(); cards.rr(g, 8, 8, W - 16, H - 16, R0); g.clip();
      cards.txt(g, l1, W / 2, H * 0.43, { size: 96, weight: 800, align: 'center', sp: -2, maxW: W - 120 });
      g.font = '800 96px Inter'; const w2 = Math.min(W - 160, g.measureText(l2).width);
      cards.txt(g, l2, W / 2 - 34, H * 0.8, { size: 96, weight: 800, align: 'center', sp: -2, maxW: W - 160 });
      cards.txt(g, '✳', W / 2 + w2 / 2 + 8, H * 0.8, { size: 84, weight: 700, color: '#d9775a', align: 'center' });
      if (o.sticker) { g.save(); g.translate(W - 118, 90); g.rotate(0.22); g.fillStyle = '#d4483b'; cards.rr(g, -88, -46, 176, 92, 22); g.fill(); cards.txt(g, o.sticker, 0, 22, { size: 62, weight: 800, color: '#fff', align: 'center', maxW: 160 }); g.restore(); }
      g.restore();
      if (k <= 0) return;
      // the burn: a ragged front sweeping right → left in 18 px blocks; past it the card is gone, at it flame + char
      const B = 18, front = y => W * (1.06 - k * 1.2) + Math.sin(y * 0.021 + t * 9) * 26 + Math.sin(y * 0.063 - t * 13) * 14;
      for (let y = 0; y < H; y += B) {
        const f = front(y + B / 2); g.clearRect(Math.max(0, Math.floor(f / B) * B), y, W, B);
        for (let x = Math.floor((f - 70) / B) * B; x < f; x += B) {
          const d = f - x, fl = hash(Math.floor(x / B) * 7.1 + y * 3.3 + Math.floor(t * 20) * 1.7);
          g.fillStyle = d < 22 ? (fl < 0.5 ? '#ffd24a' : '#ffb13b') : d < 44 ? (fl < 0.5 ? '#f08a2c' : '#e2622a') : d < 60 ? '#2a1b12' : 'rgba(60,40,25,0.45)';
          if (x + B > 0) g.fillRect(x, y, B, B);
        }
      }
    }
    const cracker = vox.model(E, P.firecracker(), { size: 0.058, seed: 21 }), fx = P.particles(E, 260, { size: 0.05 });
    M.hold.push([0, UNTIL, 0.55]);
    worlds.push(t => {
      const crX = K.pose.x + 0.84, crY = K.pose.y + K.pose.hop + 0.8, z = K.pose.z;
      cracker.group.position.set(crX, crY, z + 0.08); cracker.group.rotation.z = -0.12;
      cracker.anim(t, { in: [-0.1, 0.8, 'fall'], out: [UNTIL + 0.05, 0.45, 'crumble'], spread: 0.6 });
      const fuse = [crX - 0.02, crY + 0.42, z + 0.1];
      fx.update(t, (i, r) => {
        if (i < 70) { // fuse flame + smoke, until the cracker leaves
          if (t > UNTIL + 0.1 || t < 0.6) return null;
          const life = 0.55, ph = ((t + r[0] * life) % life) / life, smoke = i >= 45;
          return { p: [fuse[0] + (r[1] - 0.5) * 0.08 + (smoke ? ph * 0.05 : 0), fuse[1] + ph * (smoke ? 0.34 : 0.14), fuse[2] + (r[2] - 0.5) * 0.05], s: smoke ? (0.9 + r[3]) * (1 - ph) * 1.1 : (1.1 - ph) * (0.7 + r[3] * 0.6), c: smoke ? (r[2] < 0.5 ? 0xf1ece4 : 0xd9d3c8) : ph < 0.35 ? 0xffe066 : ph < 0.7 ? 0xf5a03a : 0xe0632e };
        }
        if (i < 110) { // the spark run up to the card's right edge
          const t0 = HK - 0.45 + (i - 70) * 0.008, k = (t - t0) / 0.42; if (k < 0 || k > 1) return null;
          return { p: [lerp(fuse[0], tp[0] + TW / 2 - 0.05, k) + (r[0] - 0.5) * 0.06, lerp(fuse[1], tp[1] - 0.1, k) + Math.sin(k * Math.PI) * 0.35 + (r[1] - 0.5) * 0.06, tp[2] + 0.05], s: 1.1 - k * 0.4, c: r[2] < 0.5 ? 0xffd34d : 0xf08a2c };
        }
        // embers off the burning edge, drifting up-right
        const t0 = HK + (i - 110) / 150 * 0.95, age = t - t0; if (age < 0 || age > 1.1) return null;
        const fy = tp[1] + (r[0] - 0.5) * TH, fxp = tp[0] + TW / 2 - burnK(t0) * TW + 0.05;
        return { p: [fxp + age * (0.5 + r[1] * 0.9), fy + age * (0.5 + r[2] * 0.7) + age * age * 0.3, tp[2] + 0.05 + r[3] * 0.1], s: (1 - age / 1.1) * (0.6 + r[3] * 0.8), c: r[1] < 0.35 ? 0xffd04a : r[1] < 0.7 ? 0xef7d2b : 0x3a2418, rx: age * 5, ry: age * 3 };
      });
      title.mesh.visible = t < HK + 1.0; if (title.mesh.visible) title.paint(t);
    });
    afters.push(() => title.mesh.quaternion.copy(E.camera.quaternion));
    K.hookTitle = { pos: tp, w: TW, h: TH };
    return K;
  };

  // ---------------- camera ----------------
  // a standard station framing: 7.1 back, 2.15 up, looking at 1.45 over the disc, a slow push-in with a little orbit (alternating sides)
  K.cam = { d: 7.1, y: 2.15, ly: 1.45, push: 0.55, orbit: 0.36, fov: 40, ap: 6.5 };
  const camP = (i, k = 0, o = {}) => [D[i].x + (o.dx ?? 0) + ((o.push ?? K.cam.push) === 0 ? 0 : (k - 0.5) * K.cam.orbit * (i % 2 ? 1 : -1)), o.y ?? K.cam.y, D[i].z + (o.d ?? K.cam.d) - k * (o.push ?? K.cam.push)];
  const camL = (i, o = {}) => [D[i].x + (o.lx ?? 0), o.ly ?? K.cam.ly, D[i].z + (o.lz ?? 0)];
  K.camP = camP; K.camL = camL;
  K.shot = (t0, t1, s) => { K.SHOTS.push({ t: [t0, t1], P: [[t0, s.p0], [t1, s.p1 ?? s.p0, s.ease]], L: [[t0, s.l0], [t1, s.l1 ?? s.l0, s.ease]], fov: s.fov ?? K.cam.fov, ap: s.ap ?? 6, up: s.up }); return K; };
  K.station = (i, t0, t1, o = {}) => { K.SHOTS.push({ t: [t0, t1], P: [[t0, camP(i, 0, o)], [t1, camP(i, 1, o)]], L: [[t0, camL(i, o)], [t1, camL(i, o)]], fov: o.fov ?? K.cam.fov, ap: o.ap ?? K.cam.ap }); return K; };
  K.move = (i, j, t0, t1, oi = {}, oj = {}) => { K.SHOTS.push({ t: [t0, t1], P: [[t0, camP(i, 1, oi)], [t1, camP(j, 0, oj)]], L: [[t0, camL(i, oi)], [t1, camL(j, oj)]], fov: K.cam.fov, ap: 3 }); return K; };
  // station shots for discs from..to, each with a whip-hop move from the disc before (cam[i] = framing options for disc i; cam[from-1] = where the move starts)
  K.path = ({ from = 1, to = D.length - 2, cam = {} } = {}) => {
    for (let i = from; i <= to; i++) { K.move(i - 1, i, STAY[i - 1][1], STAY[i][0], cam[i - 1] || {}, cam[i] || {}); K.station(i, STAY[i][0], STAY[i][1], cam[i] || {}); }
    K.pathCam = cam; return K;
  };
  // whips on every hop (blur in the hop's screen direction), auto
  for (let i = 1; i < STAY.length; i++) { const a = STAY[i - 1][1], b = STAY[i][0], dx = Math.sign(D[i].x - D[i - 1].x) || 1; whips.push([a + (b - a) * 0.3, dx * 0.05, 0], [a + (b - a) * 0.75, dx * 0.04, 0]); }

  // ---------------- the recap: crane up over the path, chartreuse sweep, dive into the last disc ----------------
  K.recap = (o) => {
    const last = D.length - 1, prev = last - 1, LEAVE = o.leave ?? STAY[prev][1], UP = o.up, SW0 = o.sweep, NAME = o.name, DIVE0 = o.dive ?? NAME + 0.28, CTA = o.cta;
    const cp = K.pathCam?.[prev] || {}, cc = { dx: 0.28, y: 1.72, d: 6.2, ly: 1.55, ...(o.ctaCam || {}) };
    // top-down: every label (at each disc's far edge) inside the safe band y 250–1480, centred on y ≈ 850
    const lz0 = D[0].z - D[0].r * 0.72, lz1 = D[last].z - D[last].r * 0.72, fh = (lz0 - lz1) * 1920 / 900, hgt = fh / (2 * Math.tan(20 * Math.PI / 180));
    const camZ = (lz0 + lz1) / 2 + (110 / 1920) * fh, cx = D.reduce((a, d) => a + d.x, 0) / D.length;
    const top = [cx, hgt, camZ], topL = [cx, 0, camZ - 0.1], top2 = [cx, hgt - 2.5, camZ - 0.15];
    const ctaP = [D[last].x + cc.dx, cc.y, D[last].z + cc.d], ctaL = [D[last].x + cc.dx, cc.ly, D[last].z];
    K.ctaCam = { p: ctaP, l: ctaL };
    // the crane turns the camera's up vector from +y to -z (screen-up = further along the path), the top view turns slowly (0.07 rad/s), the dive turns it back
    const roll = t => 0.07 * clamp(t - UP, 0, DIVE0 - UP), turn = t => [Math.sin(roll(t)), 0, -Math.cos(roll(t))];
    const upAt = t => { if (t < UP) { const k = ss(t, LEAVE + 0.2, UP - 0.1); return [0, 1 - k, -k]; } if (t < DIVE0) return turn(t); const k = ss(t, DIVE0 + 0.3, CTA - 0.1), u = turn(DIVE0); return [u[0] * (1 - k), k, u[2] * (1 - k)]; };
    K.SHOTS.push({ t: [LEAVE, UP], P: [[LEAVE, camP(prev, 1, cp)], [LEAVE + 0.35, [D[last].x, 3.4, D[last].z + 7.5]], [UP, top, easeInOut]], L: [[LEAVE, camL(prev, cp)], [LEAVE + 0.35, [D[last].x, 1.2, D[last].z]], [UP, topL, easeInOut]], fov: 40, ap: 0.6, up: upAt });
    K.SHOTS.push({ t: [UP, DIVE0], P: [[UP, top], [DIVE0, top2]], L: [[UP, topL], [DIVE0, topL]], fov: 40, ap: 0.4, up: upAt });
    K.SHOTS.push({ t: [DIVE0, CTA - 0.05], P: [[DIVE0, top2], [CTA - 0.05, ctaP, easeInOut]], L: [[DIVE0, topL], [CTA - 0.05, ctaL, easeInOut]], fov: 40, ap: 2, up: upAt });
    K.SHOTS.push({ t: [CTA - 0.05, 999], P: [[CTA - 0.05, ctaP], [film.END, [ctaP[0], ctaP[1] - 0.03, ctaP[2] - 0.5]]], L: [[CTA - 0.05, ctaL], [film.END, ctaL]], fov: 40, ap: 6 });
    worlds.push(t => {
      const air = ss(t, LEAVE + 0.05, LEAVE + 0.45) * (1 - ss(t, DIVE0 + 0.45, CTA - 0.25));   // fog lifts while the camera is up
      E.scene.fog.near = 11 + air * 260; E.scene.fog.far = 34 + air * 420;
      const labA = ss(t, UP - 0.55, UP - 0.1) * (1 - ss(t, DIVE0 + 0.2, DIVE0 + 0.6));
      R.labels.forEach((lb, i) => lb.show(labA, 2.15 + (i === last ? 0.45 * ss(t, NAME, NAME + 0.25) : 0), t > SW0 + i * 0.11));
      if (t > SW0 - 0.1) R.paintTiles(tl => { const di = tl.disc >= 0 ? tl.disc : -tl.disc - 1, t0 = SW0 + di * 0.11 + (tl.disc >= 0 ? (tl.edge || 0) * 0.08 : 0.1); return t > t0 ? tl.lime : null; }, String(Math.floor(t * 60)));
      else R.paintTiles(null, 'base');
    });
    whips.push(['dive', DIVE0, CTA]);
    K.recapT = { LEAVE, UP, SW0, NAME, DIVE0, CTA };
    return K;
  };

  // ---------------- the CTA: COMMENT + the keyword in voxels + a chip + the slab the mascot holds ----------------
  K.cta = (o) => {
    const last = D.length - 1, AT = o.at, cam = K.ctaCam || { p: [D[last].x + 0.28, 1.72, D[last].z + 6.2], l: [D[last].x + 0.28, 1.55, D[last].z] };
    const word = vox.model(E, vox.text(o.word, { rows: 11, font: '900 100px Inter', depth: 2, c: 0xcb6a4c, track: 6 }), { size: o.size ?? Math.min(0.0245, 1.85 / Math.max(1, vox.text(o.word, { rows: 11, font: '900 100px Inter', depth: 1 }).reduce((m, v) => Math.max(m, v.x), 0))), seed: 81 });
    const slab = o.slab ? P.slab(E, 0.74, 0.94, 0.09, [
      { text: o.slab[0], font: '900 150px "Inter Tight", Inter', x: 0.09, y: 0.3, sp: -6 },
      { text: o.slab[1], font: '900 150px "Inter Tight", Inter', x: 0.07, y: 0.55, sp: -6 },
      { text: o.slab[2] || '', font: '700 58px Inter', x: 0.1, y: 0.76, color: 'rgba(255,255,255,0.95)' },
      { text: o.slab[3] || '', font: '600 50px Inter', x: 0.1, y: 0.9, color: 'rgba(255,255,255,0.8)' },
    ]) : null;
    M.hold.push([AT - 0.4, 999, 0.7]); M.hops.push([o.wordAt, 0.32, 0.24]); flashes.push([o.wordAt + 0.4, 0.1, [1, 1, 0.9]]); K.IMPULSE.push([o.wordAt + 0.4, 0.05]);
    worlds.push(t => {
      const bob = Math.sin((t - o.wordAt) * 2.3) * 0.04 * ss(t, o.wordAt + 0.8, o.wordAt + 1.2);   // the word floats a little once built
      word.group.position.set(cam.l[0] - 0.08, 2.2 + bob, D[last].z - 0.35); word.group.lookAt(cam.p[0] - 0.06, 2.2 + bob, cam.p[2]);
      word.anim(t, { in: [o.wordAt - 0.05, 0.75, 'swarm'], spread: 2.4, stagger: 0.6 });
      if (!slab) return; slab.visible = t > AT - 0.3;
      if (slab.visible) { const k = easeOutBack(ss(t, AT - 0.3, AT + 0.15)); slab.position.set(K.pose.x + 0.74, K.pose.y + K.pose.hop + 0.6 + (1 - k) * -0.4, K.pose.z + 0.52); slab.rotation.set(0, -0.22 + Math.sin(t * 1.7) * 0.04, -0.08 + Math.sin(t * 2.3) * 0.025); slab.scale.setScalar(Math.max(0.01, k)); }
    });
    overlays.push((g, t) => {
      if (t < AT) return;
      O.centered(g, o.comment ?? 'COMMENT', 470, { size: 104, weight: 500, alpha: ss(t, AT, AT + 0.15), scale: lerp(0.92, 1, easeOut(ss(t, AT, AT + 0.3))) });
      if (o.chip && t >= o.chipAt) O.pill(g, o.chip, 510, 812, { align: 'center', size: 19, h: 46, alpha: ss(t, o.chipAt, o.chipAt + 0.2), scale: lerp(0.85, 1, easeOutBack(ss(t, o.chipAt, o.chipAt + 0.3))), kind: 'chip' });
    });
    whips.push(['punch', AT]);
    K.ctaT = AT;
    return K;
  };

  // ---------------- the 2D layer ----------------
  let HUD = { left: 'CLAUDE · MADE THIS', right: 'BEHIND THE REEL' }, STEPS = null, STEPS_UNTIL = 1e9;
  K.hud = (o) => { HUD = { ...HUD, ...o }; return K; };
  K.steps = (list, o = {}) => { STEPS = list; STEPS_UNTIL = o.until ?? 1e9; return K; };
  K.flash = (t, a = 0.12, col = [1, 0.95, 0.85]) => { flashes.push([t, a, col]); return K; };
  K.world = fn => { worlds.push(fn); return K; };
  K.after = fn => { afters.push(fn); return K; };
  K.overlay = fn => { overlays.push(fn); return K; };

  // ---------------- assemble ----------------
  K.done = (extra = {}) => {
    const until = () => Math.min(STEPS_UNTIL, (K.ctaT ?? 1e9) - 0.05);
    return {
      SHOTS: K.SHOTS, IMPULSE: K.IMPULSE,
      world(t) { poseMascot(t); for (const f of worlds) f(t); extra.world?.(t); },
      after(t) { for (const f of afters) f(t); extra.after?.(t); },
      effects(t) {
        let wv = FX.whips(t, whips.filter(w => typeof w[0] === 'number'), 0.12), zoom = 0;
        for (const w of whips) if (w[0] === 'dive' && t > w[1] && t < w[2]) wv = [0, Math.sin(seg(t, w[1], w[2]) * Math.PI) * 0.03]; else if (w[0] === 'punch') zoom += FX.punchOut(t, w[1], 0.35, 0.05);
        FX.apply(E, t, { whip: wv, flash: FX.flash(t, flashes), zoom });
      },
      overlay(g, t) {
        O.hud(g, t, HUD);
        if (STEPS && t < until()) {
          let si = -1; STEPS.forEach((s, i) => { if (t >= s[1] - 0.02) si = i; });
          if (si >= 0) O.stepChip(g, t, { step: si + 1, of: STEPS.length, label: STEPS[si][0], at: STEPS[0][1], pulse: 1 - ss(t, STEPS[si][1], STEPS[si][1] + 0.35) });
        }
        for (const f of overlays) f(g, t);
        extra.overlay?.(g, t);
        O.caption(g, t, film);
      },
    };
  };
  return K;
}
```

### `engine/common.mjs`

```js
// Shared bits for the Node scripts. Usage everywhere: node engine/<script>.mjs <video folder> [...]
import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { spawnSync } from 'node:child_process'; import { pathToFileURL } from 'node:url';
export const ROOT = path.resolve(import.meta.dirname, '..'), FPS = 30;
export function low() { try { os.setPriority(os.constants.priority.PRIORITY_LOW); } catch { } }   // children inherit it
export function args() {
  const a = process.argv.slice(2), pos = [], opt = {};
  for (let i = 0; i < a.length; i++) if (a[i].startsWith('--')) { const k = a[i].slice(2); opt[k] = a[i + 1] != null && !a[i + 1].startsWith('--') ? a[++i] : true; } else pos.push(a[i]);
  return { pos, opt };
}
export function videoDir(name) { if (!name) die('first argument: the video folder (e.g. my-video)'); const dir = path.resolve(ROOT, name); if (!fs.existsSync(dir)) die('no folder ' + dir); return { dir, rel: path.relative(ROOT, dir).replace(/\\/g, '/') || '.' }; }
export const DEFAULTS = {
  take: 'a', voOff: 0.25, tail: 1.7, cover: 1.3,
  music: { file: 'coffee-or-tea.mp3', from: 9, db: -11.5, muffle: [] },   // the bed ~13 LU under the voice; muffle: [[t0, t1]] low-pass windows
  voice: { id: 'TX3LPaxmHKxFdv7VOQHJ', name: 'Liam', model_id: 'eleven_multilingual_v2', voice_settings: { stability: 0.45, similarity_boost: 0.8, style: 0.45, use_speaker_boost: true, speed: 1.2 } },
};
export function loadVideo(dir) {
  const f = path.join(dir, 'video.json'), v = fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : {};
  return { ...DEFAULTS, ...v, music: { ...DEFAULTS.music, ...v.music }, voice: { ...DEFAULTS.voice, ...v.voice, voice_settings: { ...DEFAULTS.voice.voice_settings, ...v.voice?.voice_settings } } };
}
export function saveVideo(dir, patch) { const f = path.join(dir, 'video.json'), v = fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : {}; fs.writeFileSync(f, JSON.stringify({ ...v, ...patch }, null, 2) + '\n'); }
// script.mjs: LINES (what the captions show) + SAID (how the voice says a shown token)
export async function loadScript(dir) { const f = path.join(dir, 'script.mjs'); if (!fs.existsSync(f)) die('script.mjs missing in ' + dir); const m = await import(pathToFileURL(f).href + '?t=' + Date.now()); return { LINES: m.LINES || [], SAID: m.SAID || {} }; }
const split = tok => /^(.*?)([.,!?;:]*)$/.exec(tok);
export function tokens(lines, said) { const out = []; lines.forEach((line, li) => line.split(/\s+/).filter(Boolean).forEach(tok => { const m = split(tok); const s = said[m[1]] != null ? said[m[1]] + m[2] : tok; out.push({ cap: tok, line: li, said: s, n: s.split(/\s+/).length }); })); return out; }
export const saidText = (lines, said) => tokens(lines, said).map(t => t.said).join(' ');
// words of an ElevenLabs alignment: [{ w, s, e, i }] (i = the word's first character index)
export function alignWords(A) { const out = []; let cur = null; A.characters.forEach((ch, k) => { if (/\s/.test(ch)) { if (cur) out.push(cur); cur = null; return; } if (!cur) cur = { w: '', s: A.character_start_times_seconds[k], e: 0, i: k }; cur.w += ch; cur.e = A.character_end_times_seconds[k]; }); if (cur) out.push(cur); return out; }
export function apiKey() {   // from the environment or ROOT/.env. Never print it.
  if (process.env.ELEVENLABS_API_KEY) return process.env.ELEVENLABS_API_KEY.trim();
  const f = path.join(ROOT, '.env'), line = fs.existsSync(f) && fs.readFileSync(f, 'utf8').split(/\r?\n/).find(l => l.startsWith('ELEVENLABS_API_KEY='));
  if (!line) die('ELEVENLABS_API_KEY is not set (environment or .env in the project folder)'); return line.slice(19).trim().replace(/^["']|["']$/g, '');
}
export function loud(file) {   // integrated loudness, LRA, true peak
  const e = spawnSync('ffmpeg', ['-hide_banner', '-nostats', '-i', file, '-af', 'ebur128=peak=true', '-f', 'null', '-'], { encoding: 'utf8' }).stderr.split('Summary:')[1] || '';
  const n = re => +(re.exec(e)?.[1] ?? NaN); return { I: n(/I:\s+(-?[\d.]+) LUFS/), LRA: n(/LRA:\s+(-?[\d.]+) LU/), TP: n(/Peak:\s+(-?[\d.inf]+) dBFS/) };
}
// a sound or the music: the video's own folder first, then the project's
export const findAsset = (dir, kind, file) => [path.join(dir, kind, file), path.join(ROOT, kind, file)].find(p => fs.existsSync(p));
export function run(cmd, a) { const r = spawnSync(cmd, a, { stdio: 'inherit' }); if (r.status !== 0) die(`${cmd} failed`); }
export function die(msg) { console.error('✗ ' + msg); process.exit(1); }
```

### `engine/cdp.mjs`

```js
// Headless Chrome/Edge over the DevTools protocol, no dependencies. ONE browser, on the GPU, at the lowest priority.
// Refuses software WebGL (SwiftShader): a CPU render of this film pegs every core for half an hour.
import { spawn, execFile } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
export const CHROME = [process.env.CHROME, 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
  'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', 'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
  '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
  '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/microsoft-edge'].find(p => p && fs.existsSync(p));
const ANGLE = process.env.SANDBOX_ANGLE || (process.platform === 'win32' ? 'd3d11' : process.platform === 'darwin' ? 'metal' : 'gl');   // Linux: try SANDBOX_ANGLE=vulkan if the GPU probe fails
const sleep = ms => new Promise(r => setTimeout(r, ms));

export async function launch({ width = 1080, height = 1920, port = 9650 } = {}) {
  if (!CHROME) throw new Error('Chrome or Edge not found (set CHROME=<path>)');
  try { os.setPriority(os.constants.priority.PRIORITY_LOW); } catch { }
  const udd = fs.mkdtempSync(path.join(os.tmpdir(), 'sandbox-chrome-'));
  const proc = spawn(CHROME, [`--remote-debugging-port=${port}`, `--user-data-dir=${udd}`, '--headless=new', '--no-first-run', '--no-default-browser-check', '--disable-extensions',
    '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--mute-audio', `--window-size=${width},${height}`,
    '--use-gl=angle', `--use-angle=${ANGLE}`, '--ignore-gpu-blocklist', '--enable-gpu', 'about:blank'], { stdio: 'ignore' });
  let list = null; for (let i = 0; i < 100 && !list?.length; i++) { try { list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); } catch { await sleep(150); } }
  if (!list?.length) { proc.kill(); throw new Error('Chrome devtools not reachable on port ' + port); }
  // polite: lower the browser and every child process (Chrome raises its GPU process by itself), now and every 5 s; on Windows pin them to one logical CPU
  const pinned = new Set();
  const polite = async () => { try {
    const v = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json(), bws = new WebSocket(v.webSocketDebuggerUrl);
    await new Promise((res, rej) => { bws.onopen = res; bws.onerror = rej; });
    const info = await new Promise(res => { bws.onmessage = ev => { const m = JSON.parse(ev.data); if (m.id === 1) res(m.result); }; bws.send(JSON.stringify({ id: 1, method: 'SystemInfo.getProcessInfo' })); setTimeout(() => res(null), 10000); });
    bws.close(); const pids = [proc.pid, ...(info?.processInfo || []).map(p => p.id)];
    for (const id of pids) { try { os.setPriority(id, os.constants.priority.PRIORITY_LOW); } catch { } }
    const fresh = pids.filter(id => !pinned.has(id)); fresh.forEach(id => pinned.add(id));
    if (process.platform === 'win32' && fresh.length) execFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', `$ErrorActionPreference='SilentlyContinue'; foreach ($id in @(${fresh.join(',')})) { (Get-Process -Id $id).ProcessorAffinity = [IntPtr]${2 ** (Math.min(os.cpus().length, 52) - 1)} }`], { windowsHide: true }, () => { });
  } catch { } };
  await polite(); setTimeout(polite, 1200).unref(); const timer = setInterval(polite, 5000); timer.unref();
  const ws = new WebSocket(list.find(t => t.type === 'page').webSocketDebuggerUrl); await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
  let id = 0; const pending = new Map(), logs = [];
  ws.onmessage = ev => { const m = JSON.parse(ev.data);
    if (m.id && pending.has(m.id)) { const { res, rej } = pending.get(m.id); pending.delete(m.id); m.error ? rej(new Error(m.error.message)) : res(m.result); }
    else if (m.method === 'Runtime.exceptionThrown') logs.push({ type: 'exception', text: m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text });
    else if (m.method === 'Runtime.consoleAPICalled') logs.push({ type: m.params.type, text: m.params.args.map(a => a.value ?? a.description).join(' ') }); };
  const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
  await send('Runtime.enable'); await send('Page.enable');
  await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
  const api = { send, logs,
    async eval(expr) { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error('eval: ' + (r.exceptionDetails.exception?.description || r.exceptionDetails.text)); return r.result.value; },
    async waitFor(expr, ms = 10000) { const t0 = Date.now(); while (Date.now() - t0 < ms) { try { if (await api.eval(expr)) return; } catch { } await sleep(100); } throw new Error('timeout: ' + expr); },
    async goto(url) { await send('Page.navigate', { url }); await sleep(300); await api.waitFor('document.readyState === "complete"', 15000); },
    async close() { clearInterval(timer); try { ws.close(); } catch { } try { proc.kill(); } catch { } setTimeout(() => fs.rmSync(udd, { recursive: true, force: true }), 800); } };
  // GPU probe
  const gl = await api.eval(`(() => { const c = document.createElement('canvas'), g = c.getContext('webgl2') || c.getContext('webgl'); if (!g) return ''; const e = g.getExtension('WEBGL_debug_renderer_info'); return String(e ? g.getParameter(e.UNMASKED_RENDERER_WEBGL) : g.getParameter(g.RENDERER)); })()`);
  if (!gl || /swiftshader|software|basic render|llvmpipe/i.test(gl)) { await api.close(); throw new Error(`WebGL is not on the GPU (${gl || 'none'}). Not rendering on the CPU: check the graphics driver.`); }
  api.gpu = gl; return api;
}
```

### `engine/setup.mjs`

```js
// Requirements check + the free assets. Run once per project:  node engine/setup.mjs
// Node 22+, ffmpeg/ffprobe, three.js, Chrome/Edge with WebGL on the GPU, the ElevenLabs key (never printed); downloads the fonts + music if missing.
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { ROOT } from './common.mjs';
import { CHROME, launch } from './cdp.mjs';
import { KIT } from './gen-sfx.mjs';
const ok = (good, msg) => console.log(good ? '✓' : '✗', msg);
ok(+process.versions.node.split('.')[0] >= 22, 'Node ' + process.versions.node + ' (22+ needed: global fetch and WebSocket)');
for (const c of ['ffmpeg', 'ffprobe']) ok(spawnSync(c, ['-version']).status === 0, c + ' on PATH');
ok(fs.existsSync(path.join(ROOT, 'node_modules/three/build/three.module.js')), 'three.js (npm i three@0.170.0)');
ok(!!CHROME, 'Chrome or Edge: ' + (CHROME || 'not found, set CHROME=<path to the browser>'));
ok(!!(process.env.ELEVENLABS_API_KEY || (fs.existsSync(path.join(ROOT, '.env')) && /^ELEVENLABS_API_KEY=./m.test(fs.readFileSync(path.join(ROOT, '.env'), 'utf8')))), 'ELEVENLABS_API_KEY (environment or .env)');
const GET = [   // all free: the fonts are SIL Open Font License (google/fonts), the music is Mixkit's free licence
  ['fonts/Inter.ttf', 'https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz,wght%5D.ttf'],
  ['fonts/JetBrainsMono.ttf', 'https://raw.githubusercontent.com/google/fonts/main/ofl/jetbrainsmono/JetBrainsMono%5Bwght%5D.ttf'],
  ['fonts/InterTight.ttf', 'https://raw.githubusercontent.com/google/fonts/main/ofl/intertight/InterTight%5Bwght%5D.ttf'],
  ['music/coffee-or-tea.mp3', 'https://assets.mixkit.co/music/1168/1168.mp3'],   // Mixkit #1168 "Coffee or Tea": a calm lo-fi bed, level from 9 s on
];
for (const [rel, url] of GET) {
  const f = path.join(ROOT, rel); if (fs.existsSync(f)) { ok(true, rel); continue; }
  fs.mkdirSync(path.dirname(f), { recursive: true });
  try { const r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0' } }); if (!r.ok) throw new Error(r.status); fs.writeFileSync(f, Buffer.from(await r.arrayBuffer())); ok(true, rel + ' downloaded'); }
  catch (e) { ok(false, `${rel}: download failed (${e.message}) from ${url}`); }
}
const have = Object.keys(KIT).filter(n => fs.existsSync(path.join(ROOT, 'sfx', n + '.wav')));
ok(have.length === Object.keys(KIT).length, `${have.length} of ${Object.keys(KIT).length} sound effects in sfx/ (make the kit once: node engine/gen-sfx.mjs --yes)`);
if (CHROME) try { const api = await launch({ port: 9699 }); ok(true, 'WebGL on the GPU: ' + api.gpu); await api.close(); } catch (e) { ok(false, e.message); }
setTimeout(() => process.exit(0), 1200);
```

### `engine/render.mjs`

```js
// node engine/render.mjs <video> stills 1.3,4,9.2 [--safe]   -> <video>/stills/t*.jpg + sheet.jpg (or: stills auto 10)
// node engine/render.mjs <video> full [fromFrame] [toFrame]    -> <video>/frames/f0000.jpg ... (resumable: existing frames are skipped)
// ONE browser at a time, on the GPU, at the lowest priority (cdp.mjs). The page is served from the project folder.
import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import net from 'node:net'; import { spawnSync } from 'node:child_process';
import { ROOT, FPS, args, videoDir, low, die } from './common.mjs';
import { launch } from './cdp.mjs';
low();
const { pos, opt } = args(), { dir, rel } = videoDir(pos[0]), mode = pos[1] || 'stills';
if (!fs.existsSync(path.join(dir, 'film.json'))) die('film.json missing: run engine/build.mjs first (--fake works before you have a voice)');
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.ttf': 'font/ttf', '.woff2': 'font/woff2', '.jpg': 'image/jpeg', '.png': 'image/png' };
const srv = http.createServer((req, res) => {
  const p = path.resolve(ROOT, '.' + decodeURIComponent(req.url.split('?')[0]));
  if (!p.startsWith(ROOT) || !fs.existsSync(p) || !fs.statSync(p).isFile()) { res.writeHead(404); return res.end(); }
  res.writeHead(200, { 'Content-Type': MIME[path.extname(p)] || 'application/octet-stream', 'Cache-Control': 'no-store' }); fs.createReadStream(p).pipe(res);
});
await new Promise(r => srv.listen(0, '127.0.0.1', r));
const url = `http://127.0.0.1:${srv.address().port}/engine/film.html?video=${encodeURIComponent(rel)}`;
const free = p => new Promise(res => { const s = net.createServer().once('error', () => res(false)).once('listening', () => s.close(() => res(true))).listen(p, '127.0.0.1'); });
async function open() {
  let port = 9650; while (!(await free(port))) if (++port > 9699) die('no free debug port in 9650-9699');
  const api = await launch({ port }); await api.goto(url);
  try { await api.waitFor('window.__ready === true', 120000); } catch (e) { console.log(api.logs.slice(0, 20)); await api.close(); throw e; }
  return api;
}
const shot = async (api, q) => Buffer.from((await api.send('Page.captureScreenshot', { format: 'jpeg', quality: q })).data, 'base64');
const fmt = v => v.map(b => `${b.kind} "${b.text}" x ${b.x0}-${b.x1} y ${b.y0}-${b.y1}`).join('; ');
try {
  if (mode === 'stills') {
    const api = await open(), END = await api.eval('window.TOTAL'), out = path.join(dir, 'stills'); fs.mkdirSync(out, { recursive: true });
    const times = pos[2] === 'auto' ? Array.from({ length: +(pos[3] || 10) }, (_, i) => +((i + 0.5) / +(pos[3] || 10) * END).toFixed(2)) : (pos[2] || '0').split(',').map(Number);
    if (opt.safe) await api.eval('window.setSafe(true)');
    const files = []; let bad = 0;
    for (const t of times) {
      await api.eval(`window.setT(${t})`); const f = path.join(out, `t${t.toFixed(2)}${opt.safe ? '-safe' : ''}.jpg`); fs.writeFileSync(f, await shot(api, 90)); files.push(f);
      const v = await api.eval('window.safeReport()'); bad += v.length; console.log('still', t, v.length ? 'OUTSIDE SAFE ZONE: ' + fmt(v) : '');
    }
    const errs = api.logs.filter(l => /error|exception/.test(l.type)); console.log('page errors:', errs.length, errs.slice(0, 5)); await api.close();
    const cols = Math.min(files.length, 6), lay = files.map((_, i) => `${(i % cols) * 360}_${Math.floor(i / cols) * 640}`).join('|');   // contact sheet
    spawnSync('ffmpeg', ['-v', 'error', '-y', ...files.flatMap(f => ['-i', f]), '-filter_complex', files.map((_, i) => `[${i}:v]scale=360:640[v${i}]`).join(';') + ';' + (files.length > 1 ? files.map((_, i) => `[v${i}]`).join('') + `xstack=inputs=${files.length}:layout=${lay}:fill=black[o]` : '[v0]null[o]'), '-map', '[o]', '-q:v', '3', path.join(out, opt.safe ? 'sheet-safe.jpg' : 'sheet.jpg')], { stdio: 'inherit' });
    console.log(bad ? `✗ ${bad} text boxes outside the safe zone` : '✓ all text inside the safe zone', '· sheet in', path.join(out));
  } else if (mode === 'full') {
    let api = await open(); const N = Math.ceil((await api.eval('window.TOTAL')) * FPS), from = +(pos[2] || 0), to = Math.min(N, +(pos[3] || N)), out = path.join(dir, 'frames'); fs.mkdirSync(out, { recursive: true });
    const fname = f => path.join(out, `f${String(f).padStart(4, '0')}.jpg`), sf = path.join(out, 'safe.json');
    const SAFE = fs.existsSync(sf) ? JSON.parse(fs.readFileSync(sf, 'utf8')) : { bad: {}, captions: {} };
    const todo = []; for (let f = from; f < to; f++) if (!(fs.existsSync(fname(f)) && fs.statSync(fname(f)).size > 20000)) todo.push(f);
    console.log(`frames to render ${todo.length} of ${to - from} (film ${N} frames)`); const t0 = Date.now(); let n = 0, since = 0;
    for (const f of todo) for (let attempt = 0; attempt < 3; attempt++) {
      try {
        if (!api || since >= 150) { await api?.close(); api = await open(); since = 0; }   // a fresh browser every 150 frames
        await api.eval(`window.setT(${(f / FPS).toFixed(4)})`); const b = await shot(api, 93);
        if (b.length < 20000) throw new Error('blank frame (WebGL context lost?)');
        fs.writeFileSync(fname(f), b); since++;
        const v = await api.eval('window.safeReport()'); if (v.length) SAFE.bad[f] = v; else delete SAFE.bad[f];
        const caps = (await api.eval('window.textBoxes()')).filter(b => b.kind === 'caption'); if (caps.length) SAFE.captions[f] = [Math.min(...caps.map(b => b.x0)), Math.max(...caps.map(b => b.x1)), Math.min(...caps.map(b => b.y0)), Math.max(...caps.map(b => b.y1))].map(Math.round);
        if (++n % 60 === 0) { console.log('done', n, '/', todo.length, ((Date.now() - t0) / 1000).toFixed(0) + 's'); fs.writeFileSync(sf, JSON.stringify(SAFE)); }
        break;
      } catch (e) { console.log('retry', f, String(e).slice(0, 120)); try { await api?.close(); } catch { } api = null; }
    }
    await api?.close(); fs.writeFileSync(sf, JSON.stringify(SAFE));
    const nb = Object.keys(SAFE.bad).length; console.log(`rendered ${n} in ${((Date.now() - t0) / 1000).toFixed(0)} s`, nb ? `· ✗ ${nb} frames with text outside the safe zone` : '· ✓ safe zone ok');
  } else die('mode: stills | full');
} finally { srv.close(); }
```

### `engine/gen-vo.mjs`

```js
// ONE ElevenLabs take of script.mjs (LINES with the SAID spellings), with character timestamps. Dry run unless --yes (~1 credit a character).
//   node engine/gen-vo.mjs <video> [--yes] [--take b]                  -> <video>/vo/<take>.mp3 .wav .json, video.json "take"
//   node engine/gen-vo.mjs <video> --yes --test 3 --take t1            just the first 3 lines (to try a voice; video.json is left alone)
import fs from 'node:fs'; import path from 'node:path';
import { args, videoDir, loadVideo, saveVideo, loadScript, saidText, apiKey, run, die } from './common.mjs';
const { pos, opt } = args(), { dir } = videoDir(pos[0]), cfg = loadVideo(dir), vo = path.join(dir, 'vo'); fs.mkdirSync(vo, { recursive: true });
const S = await loadScript(dir), lines = opt.test ? S.LINES.slice(0, +opt.test) : S.LINES;
if (!lines.length) die('script.mjs has no LINES');
let take = opt.take || 'a'; if (!opt.take) while (fs.existsSync(path.join(vo, take + '.json'))) take = String.fromCharCode(take.charCodeAt(0) + 1);
if (fs.existsSync(path.join(vo, take + '.json'))) die(`vo/${take}.json exists: never pay for the same take twice`);
const text = saidText(lines, S.SAID), words = text.split(/\s+/).length;
console.log(`${cfg.voice.name} · ${cfg.voice.model_id} · take ${take} · ${text.length} characters · ${words} words ≈ ${(words / 3.6).toFixed(0)} s\n${text}`);
if (!opt.yes) { console.log('dry run: nothing sent. Add --yes to generate (≈ ' + text.length + ' credits).'); process.exit(0); }
const r = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${cfg.voice.id}/with-timestamps?output_format=mp3_44100_128`, { method: 'POST',
  headers: { 'xi-api-key': apiKey(), 'content-type': 'application/json' }, body: JSON.stringify({ text, model_id: cfg.voice.model_id, voice_settings: cfg.voice.voice_settings }) });
if (!r.ok) die(`ElevenLabs ${r.status}: ${(await r.text()).slice(0, 300)}`);
const j = await r.json();
fs.writeFileSync(path.join(vo, take + '.mp3'), Buffer.from(j.audio_base64, 'base64'));
fs.writeFileSync(path.join(vo, take + '.json'), JSON.stringify({ text, lines, voice: { id: cfg.voice.id, name: cfg.voice.name }, cfg: { model_id: cfg.voice.model_id, voice_settings: cfg.voice.voice_settings }, alignment: j.alignment }));
run('ffmpeg', ['-v', 'error', '-y', '-i', path.join(vo, take + '.mp3'), '-ar', '48000', '-ac', '1', path.join(vo, take + '.wav')]);
if (!opt.test) saveVideo(dir, { take });
const end = j.alignment.character_end_times_seconds.at(-1);
console.log(`ok · ${end.toFixed(2)} s · ${(words / end).toFixed(2)} words/s (aim 3.4–3.9)${opt.test ? '' : ` · next: node engine/build.mjs ${pos[0]}`}`);
```

### `engine/voedit.mjs`

```js
// Edit voice takes without paying again: splice word runs from several takes and add pauses, cutting in the silences.
//   node engine/voedit.mjs <video> --list a                          the take's words with their indexes and the gaps after them
//   node engine/voedit.mjs <video> --out c --use t1:0-26,a:27- [--pause 9:0.5,30:0.25]
// --use: runs "take:first-last" (word indexes of that take; "27-" = to the end), joined in order. A word one take says wrong
//        comes from another take (a --test take of the first lines is often enough).
// --pause: "k:sec" adds sec of silence after OUTPUT word k (room for a visual beat). Writes vo/<out>.wav + .json and sets video.json take.
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { args, videoDir, saveVideo, alignWords, run, die } from './common.mjs';
const { pos, opt } = args(), { dir } = videoDir(pos[0]), vo = path.join(dir, 'vo'), SR = 48000;
const load = take => JSON.parse(fs.readFileSync(path.join(vo, take + '.json'), 'utf8'));
const pcm = take => { const r = spawnSync('ffmpeg', ['-v', 'error', '-i', path.join(vo, take + '.wav'), '-ac', '1', '-ar', String(SR), '-f', 's16le', '-'], { maxBuffer: 1 << 30 }); return new Int16Array(r.stdout.buffer, r.stdout.byteOffset, r.stdout.length / 2); };
if (opt.list) { const W = alignWords(load(opt.list).alignment); console.log(W.map((w, k) => `${k}:${w.w}${k + 1 < W.length ? ` (${(W[k + 1].s - w.e).toFixed(2)})` : ''}`).join('  ')); process.exit(0); }
if (!opt.out || !opt.use) die('--out <take> --use take:a-b,… required');
const runs = String(opt.use).split(',').map(s => { const m = /^([^:]+):(\d+)-(\d*)$/.exec(s.trim()); if (!m) die('bad run ' + s); return { take: m[1], a: +m[2], b: m[3] === '' ? Infinity : +m[3] }; });
const pauses = Object.fromEntries(String(opt.pause || '').split(',').filter(Boolean).map(s => s.split(':').map(Number)));
const out = [], chars = [], st = [], en = [], texts = []; let kOut = 0;
const tOut = () => out.length / SR, push = a => { for (const v of a) out.push(v); };
for (const [ri, r] of runs.entries()) {
  const J = load(r.take), A = J.alignment, W = alignWords(A), P = pcm(r.take), b = Math.min(r.b, W.length - 1), dur = P.length / SR;
  for (let k = r.a; k <= b; k++) {
    const w = W[k], c0 = k === 0 ? 0 : (W[k - 1].e + w.s) / 2, c1 = k + 1 < W.length ? (w.e + W[k + 1].s) / 2 : dur, off = tOut() - c0;   // cut in the middle of the gaps
    for (let i = w.i; i < w.i + w.w.length; i++) { chars.push(A.characters[i]); st.push(+(A.character_start_times_seconds[i] + off).toFixed(3)); en.push(+(A.character_end_times_seconds[i] + off).toFixed(3)); }
    texts.push(w.w); push(P.subarray(Math.round(c0 * SR), Math.round(c1 * SR)));
    if (pauses[kOut] > 0) push(new Int16Array(Math.round(pauses[kOut] * SR)));
    if (!(ri === runs.length - 1 && k === b)) { chars.push(' '); st.push(+tOut().toFixed(3)); en.push(+tOut().toFixed(3)); }
    kOut++;
  }
}
const raw = path.join(vo, opt.out + '.raw'); fs.writeFileSync(raw, Buffer.from(new Int16Array(out).buffer));
run('ffmpeg', ['-v', 'error', '-y', '-f', 's16le', '-ar', String(SR), '-ac', '1', '-i', raw, path.join(vo, opt.out + '.wav')]); fs.rmSync(raw);
const first = load(runs[0].take);
fs.writeFileSync(path.join(vo, opt.out + '.json'), JSON.stringify({ text: texts.join(' '), voice: first.voice, cfg: first.cfg, edit: { use: opt.use, pause: opt.pause || '' }, alignment: { characters: chars, character_start_times_seconds: st, character_end_times_seconds: en } }));
saveVideo(dir, { take: opt.out });
console.log(`vo/${opt.out}.wav · ${kOut} words · ${(out.length / SR).toFixed(2)} s · video.json take = ${opt.out} · next: node engine/build.mjs ${pos[0]}`);
```

### `engine/build.mjs`

```js
// Voice take -> <video>/film.json: the shown caption tokens (script.mjs LINES) timed on the said words, their clauses (captions show
// the last 4 tokens of a clause, the newest in chartreuse) and a 30 fps loudness envelope (the mouth). Prints every token with its time.
//   node engine/build.mjs <video>           from vo/<take>.json (ElevenLabs alignment) + vo/<take>.wav
//   node engine/build.mjs <video> --fake    timings guessed from script.mjs (~3.6 words/s): direct and render stills before paying for a voice
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { FPS, args, videoDir, loadVideo, loadScript, tokens, alignWords, die } from './common.mjs';
const { pos, opt } = args(), { dir } = videoDir(pos[0]), cfg = loadVideo(dir), S = await loadScript(dir), TOK = tokens(S.LINES, S.SAID);
const take = opt.fake ? 'fake' : cfg.take, said = TOK.flatMap(t => t.said.split(/\s+/));
let W = [], env = [];
if (opt.fake) {
  let t = 0.05; for (const w of said) { const d = 0.065 + 0.042 * w.replace(/[^A-Za-z0-9]/g, '').length; W.push({ w, s: t, e: t + d }); t += d + (/[.!?]["”]?$/.test(w) ? 0.34 : /[,:;]["”]?$/.test(w) ? 0.15 : 0.02); }   // ≈ Liam at speed 1.2
  for (let f = 0; f < Math.ceil((t + 2) * FPS); f++) { const s = f / FPS, on = W.some(w => s >= w.s && s < w.e); env.push(on ? +(0.45 + 0.4 * Math.abs(Math.sin(f * 1.7))).toFixed(3) : 0); }
} else {
  const al = path.join(dir, 'vo', take + '.json'); if (!fs.existsSync(al)) die(`vo/${take}.json missing: node engine/gen-vo.mjs ${pos[0]} --yes (or build --fake)`);
  W = alignWords(JSON.parse(fs.readFileSync(al, 'utf8')).alignment);
  if (W.length !== said.length) die(`the take has ${W.length} words, script.mjs says ${said.length}: was the take made from this script?`);
  // ElevenLabs hands the silence before a word to that word: move each start to where its sound begins (10 ms RMS > 5 % of the loud level)
  const pcm = spawnSync('ffmpeg', ['-v', 'error', '-i', path.join(dir, 'vo', take + '.wav'), '-f', 'f32le', '-ac', '1', '-ar', '16000', '-'], { maxBuffer: 1 << 28 }).stdout;
  const X = new Float32Array(pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + (pcm.length & ~3))), rms = [];
  for (let i = 0; i + 160 <= X.length; i += 160) { let a = 0; for (let j = i; j < i + 160; j++) a += X[j] * X[j]; rms.push(Math.sqrt(a / 160)); }
  const thr = Math.max(0.004, 0.05 * [...rms].sort((a, b) => a - b)[Math.floor(rms.length * 0.95)]);
  for (const w of W) { let f = Math.round(w.s * 100); const lim = Math.round(Math.min(w.e - 0.05, w.s + 0.8) * 100); while (f < lim && (rms[f] ?? 1) < thr) f++; w.s = Math.max(w.s, f / 100 - 0.02); }
  // the mouth: loudness per frame, normalised to the 97th percentile
  const hop = 16000 / FPS; for (let f = 0; f * hop < X.length; f++) { let a = 0; const s0 = Math.floor(f * hop), s1 = Math.min(X.length, Math.floor((f + 1) * hop)); for (let i = s0; i < s1; i++) a += X[i] * X[i]; env.push(Math.sqrt(a / Math.max(1, s1 - s0))); }
  const pk = [...env].sort((a, b) => a - b)[Math.floor(env.length * 0.97)] || 1; env = env.map(v => +Math.min(1, v / pk).toFixed(3));
}
const OFF = cfg.voOff || 0; let k = 0, clause = 0;
const words = TOK.map((t, i) => {
  const a = W[k], b = W[k + t.n - 1]; k += t.n;
  const o = { i, w: t.cap, cap: t.cap, line: t.line, clause, t0: +(a.s + OFF).toFixed(3), t1: +(b.e + OFF).toFixed(3), stop: /[.,!?;:]$/.test(t.cap), end: /[.!?]$/.test(t.cap) };
  if (o.stop) clause++; return o;
});
env = [...Array(Math.round(OFF * FPS)).fill(0), ...env];
const END = +(words.at(-1).t1 + cfg.tail).toFixed(2);
fs.writeFileSync(path.join(dir, 'film.json'), JSON.stringify({ take, voOff: OFF, END, words, env }));
console.log(`${words.length} caption tokens · END ${END} s${END > 60 ? '  ✗ over 60 s: cut the script' : ''}${opt.fake ? ' · FAKE timings (no voice yet)' : ''}`);
let line = -1, s = ''; for (const w of words) { if (w.line !== line) { if (s) console.log(s); line = w.line; s = `L${line} `; } s += `${w.cap}@${w.t0.toFixed(2)} `; } console.log(s);
```

### `engine/gen-sfx.mjs`

```js
// The sound kit, made once with ElevenLabs sound generation and reused by every video -> sfx/<name>.wav. Dry run unless --yes.
//   node engine/gen-sfx.mjs [--yes] [name ...]      (existing files are never regenerated; ~30 s of sound in all)
import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process';
import { ROOT, args, apiKey } from './common.mjs';
export const KIT = {   // name: [prompt, seconds]   — what each is for in the cues
  blocks: ['many small hard plastic toy blocks raining down and clicking together into place, quick cascade, dry, no music', 1.2],   // cubes build the mascot / the CTA word
  burst: ['a pile of small plastic toy blocks bursting apart and scattering, light and bright, no explosion boom', 0.8],             // a card or prop blows apart
  implode: ['small plastic pieces swirling together and snapping into one shape, quick reverse whoosh into a soft click', 1.0],      // a prop or card builds itself
  fuse: ['short firecracker fuse sizzling and fizzing, small sparks crackle, close mic, no explosion', 1.8],                         // the hook's fuse
  burn: ['paper card catching fire, quick whoosh then crackling flames burning across, no explosion', 1.3],                          // the title card burns
  whoosh: ['a quick airy whoosh transition', 0.8], swoosh: ['a soft short swoosh of paper flying past', 0.7], swish: ['a fast thin swish, like a card flicked through the air', 0.6],
  thud: ['a soft low thud of a small toy landing on sand', 0.8],                                                                        // a hop lands
  pop: ['a soft cartoon pop, bubbly and light', 0.5], boing: ['a short cartoon spring boing, playful bounce', 0.5],
  click: ['a single clean mouse click', 0.5], ding: ['a bright clean notification ding', 0.6], clack: ['a plastic tile clacking into place', 0.5],
  shimmer: ['a warm magical shimmer, rising sparkly chimes', 2.2], chime: ['a gentle sparkling chime arpeggio, bright and short, rising', 2.0],
  riser: ['a short soft synth riser, building up, airy', 2.0], impact: ['a deep soft cinematic impact with a short rumble tail', 1.5],
  ff: ['cartoon fast-forward tape chirp, speeding up', 1.4], slap: ['a single sheet of thick paper slapped flat onto a table, short dry slap', 0.5],
  typing: ['fast clicky mechanical keyboard typing, a short burst of keystrokes', 2.0], enter: ['one satisfying mechanical keyboard enter key press', 0.5],
  flick: ['a taut string snapping, short twang', 0.5], clunk: ['a small object dropping into an empty metal bin, short clunk', 0.5], stamp: ['a rubber stamp pressed hard onto paper on a desk, single thump', 0.6],
};
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
  const { pos, opt } = args(), out = path.join(ROOT, 'sfx'); fs.mkdirSync(out, { recursive: true });
  const todo = (pos.length ? pos : Object.keys(KIT)).filter(n => KIT[n] && !fs.existsSync(path.join(out, n + '.wav')));
  console.log(`${todo.length} sounds to make: ${todo.join(' ') || '(none)'}`); if (!opt.yes) { console.log('dry run: add --yes to generate them (sound generation costs credits).'); process.exit(0); }
  for (const n of todo) {
    const [text, sec] = KIT[n], r = await fetch('https://api.elevenlabs.io/v1/sound-generation', { method: 'POST', headers: { 'xi-api-key': apiKey(), 'content-type': 'application/json' }, body: JSON.stringify({ text, duration_seconds: sec, prompt_influence: 0.6 }) });
    if (!r.ok) { console.log(n, r.status, (await r.text()).slice(0, 200)); continue; }
    const mp3 = path.join(out, n + '.mp3'); fs.writeFileSync(mp3, Buffer.from(await r.arrayBuffer()));
    execFileSync('ffmpeg', ['-v', 'error', '-y', '-i', mp3, '-ar', '48000', '-ac', '2', path.join(out, n + '.wav')]); fs.rmSync(mp3); console.log(n, 'ok');
  }
}
```

### `engine/mix.mjs`

```js
// Voice on top, the music bed ~13 LU under it (ducked by the voice), sound effects from <video>/cues.mjs -> mix.wav at -14 LUFS, true peak <= -1 dBTP.
//   node engine/mix.mjs <video> [--to 6]          (--to: first N seconds only -> mix-6s.wav)
//   STEM=vo|music|sfx node engine/mix.mjs <video>  (measure one stem: music should sit ~13 LU under vo)
// cues.mjs: export default function (ev, film) { ev('pop', 2.15, -17); ev('typing', 21.3, -17, 1.2); }   ev(name, time s, gain dB, max duration s?)
import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { pathToFileURL } from 'node:url';
import { args, videoDir, loadVideo, findAsset, loud, low, die } from './common.mjs';
low();
const { pos, opt } = args(), { dir } = videoDir(pos[0]), cfg = loadVideo(dir), film = JSON.parse(fs.readFileSync(path.join(dir, 'film.json'), 'utf8'));
const LEN = opt.to ? Math.min(+opt.to, film.END) : film.END, OUT = opt.to ? `mix-${+opt.to}s.wav` : 'mix.wav', STEM = process.env.STEM || '';
const E = [], ev = (name, t, db = 0, dur = null) => { if (t < LEN) E.push({ name, t, db, dur }); };
(await import(pathToFileURL(path.join(dir, 'cues.mjs')).href)).default(ev, film);
const vo = path.join(dir, 'vo', cfg.take + '.wav'); if (!fs.existsSync(vo)) die(`vo/${cfg.take}.wav missing (a --fake film has no voice: make the take first)`);
const music = findAsset(dir, 'music', cfg.music.file) || die('music not found: ' + cfg.music.file), names = [...new Set(E.map(e => e.name))];
const a = ['-v', 'error', '-y', '-i', vo, '-ss', String(cfg.music.from), '-t', String(LEN + 0.5), '-i', music, ...names.flatMap(n => ['-i', findAsset(dir, 'sfx', n + '.wav') || die(`sfx/${n}.wav missing: node engine/gen-sfx.mjs --yes ${n}`)])];
const f = [], fade = opt.to ? 0.3 : 1.4, mz = cfg.music.muffle.map(([m0, m1]) => `between(t,${m0},${m1})*min(1,(t-${m0})/0.35)`).join('+') || '0';
f.push(`[0:a]aresample=48000,aformat=channel_layouts=stereo,highpass=f=70,acompressor=threshold=-20dB:ratio=2.5:attack=8:release=120:makeup=2,equalizer=f=3500:t=q:w=1.2:g=2${(cfg.voOff || 0) > 0 ? `,adelay=${Math.round(cfg.voOff * 1000)}|${Math.round(cfg.voOff * 1000)}` : ''},apad=whole_dur=${LEN.toFixed(3)},asplit=2[vo][sc]`);
f.push(`[1:a]aresample=48000,aformat=channel_layouts=stereo,apad=whole_dur=${(LEN + 0.2).toFixed(3)},volume=${cfg.music.db}dB,asplit=2[mA][mB]`);
f.push(`[mA]volume='1-(${mz})':eval=frame[mA2]`, `[mB]lowpass=f=420,lowpass=f=420,volume='1.6*(${mz})':eval=frame[mB2]`);   // muffled windows
f.push(`[mA2][mB2]amix=inputs=2:normalize=0,afade=t=in:d=0.05,afade=t=out:st=${(LEN - fade).toFixed(2)}:d=${fade}[mus]`);
f.push(`[mus][sc]sidechaincompress=threshold=0.04:ratio=5:attack=15:release=350:makeup=1[duck]`);   // the voice ducks the bed
const outs = !STEM ? ['[vo]', '[duck]'] : STEM === 'vo' ? ['[vo]'] : STEM === 'music' ? ['[duck]'] : [];
if (STEM && STEM !== 'vo') f.push('[vo]anullsink'); if (STEM && STEM !== 'music') f.push('[duck]anullsink');
names.forEach((n, k) => { const L = E.filter(e => e.name === n); f.push(`[${k + 2}:a]aresample=48000,aformat=channel_layouts=stereo,asplit=${L.length}${L.map((_, j) => `[${n}${j}]`).join('')}`);
  L.forEach((e, j) => { const ms = Math.round(e.t * 1000), p = e.dur ? [`atrim=0:${e.dur.toFixed(3)}`, `afade=t=out:st=${Math.max(0, e.dur - 0.08).toFixed(3)}:d=0.08`] : [];
    f.push(`[${n}${j}]${[...p, `volume=${e.db}dB`, `adelay=${ms}|${ms}`].join(',')}[e${n}${j}]`); if (!STEM || STEM === 'sfx') outs.push(`[e${n}${j}]`); else f.push(`[e${n}${j}]anullsink`); }); });
f.push(`${outs.join('')}amix=inputs=${outs.length}:normalize=0:duration=longest,atrim=0:${LEN.toFixed(3)}[pre]`);
const script = path.join(dir, 'mix.filter'), pre = path.join(dir, STEM ? `stem_${STEM}.wav` : 'mix_pre.wav'); fs.writeFileSync(script, f.join(';\n'));
execFileSync('ffmpeg', [...a, '-filter_complex_script', script, '-map', '[pre]', '-ar', '48000', pre], { stdio: 'inherit' });
if (STEM) { const x = loud(pre); console.log(STEM, `I ${x.I} LUFS · TP ${x.TP} dBTP`); process.exit(0); }
let gain = -14 - loud(pre).I, fin;
for (let k = 0; k < 3; k++) {   // gain + limiter, re-measured until within 0.3 LU of -14
  execFileSync('ffmpeg', ['-v', 'error', '-y', '-i', pre, '-af', `volume=${gain.toFixed(2)}dB,alimiter=limit=0.79:level=false:attack=2:release=60`, '-ar', '48000', path.join(dir, OUT)]);
  fin = loud(path.join(dir, OUT)); if (Math.abs(fin.I + 14) <= 0.3) break; gain += -14 - fin.I;
}
fs.rmSync(pre, { force: true });
console.log(`${OUT}: ${fin.I} LUFS · true peak ${fin.TP} dBTP · ${E.length} sound events`, Math.abs(fin.I + 14) <= 1 && fin.TP <= -1 ? '✓' : '✗');
```

### `engine/encode.mjs`

```js
// frames + mix -> <video>/video.mp4 (H.264 High, yuv420p TV range, 30 fps, crf 20, maxrate <= 10M, AAC 192k, faststart, <= 60 MB) + cover.jpg
//   node engine/encode.mjs <video> [--to 6 --out test.mp4]
import fs from 'node:fs'; import path from 'node:path';
import { FPS, args, videoDir, loadVideo, low, run, die } from './common.mjs';
low();
const { pos, opt } = args(), { dir } = videoDir(pos[0]), cfg = loadVideo(dir), film = JSON.parse(fs.readFileSync(path.join(dir, 'film.json'), 'utf8'));
const DUR = opt.to ? Math.min(+opt.to, film.END) : film.END, NF = Math.round(DUR * FPS), audio = path.join(dir, opt.to ? `mix-${+opt.to}s.wav` : 'mix.wav');
const frame = f => path.join(dir, 'frames', `f${String(f).padStart(4, '0')}.jpg`);
if (!fs.existsSync(audio)) die(path.basename(audio) + ' missing: run engine/mix.mjs'); for (const f of [0, NF - 1]) if (!fs.existsSync(frame(f))) die(`frame ${f} missing: render 0-${NF} first`);
const out = path.join(dir, opt.out || 'video.mp4');
let maxK = Math.min(10000, Math.floor((58e6 * 8 / DUR - 256e3) / 1000));   // grain is expensive: lower the cap when the length needs it
for (let pass = 0; pass < 4; pass++) {
  run('ffmpeg', ['-v', 'error', '-y', '-framerate', String(FPS), '-i', path.join(dir, 'frames', 'f%04d.jpg'), '-i', audio, '-map', '0:v', '-map', '1:a', '-frames:v', String(NF),
    '-vf', 'scale=in_range=pc:out_range=tv,format=yuv420p', '-color_range', 'tv',   // JPEG frames are full range; without this players get yuvj420p
    '-c:v', 'libx264', '-preset', 'slow', '-profile:v', 'high', '-pix_fmt', 'yuv420p', '-r', String(FPS), '-crf', '20', '-maxrate', `${maxK}k`, '-bufsize', `${2 * maxK}k`, '-threads', '4',
    '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-t', DUR.toFixed(3), '-movflags', '+faststart', out]);
  const mb = fs.statSync(out).size / 1e6; console.log(`${path.basename(out)} · ${DUR.toFixed(2)} s · ${mb.toFixed(1)} MB · maxrate ${maxK}k`); if (mb <= 60) break; maxK = Math.floor(maxK * 0.85);
}
if (!opt.to) { run('ffmpeg', ['-v', 'error', '-y', '-i', frame(Math.min(NF - 1, Math.round(cfg.cover * FPS))), '-q:v', '2', path.join(dir, 'cover.jpg')]); console.log('cover.jpg = the frame at', cfg.cover, 's'); }
```

### `engine/check.mjs`

```js
// The finished-video checks:  node engine/check.mjs <video> [--file video.mp4]
// format + size + loudness · frame 0 not black · text inside the safe zone on every frame · flash white-outs · frozen stretches · cut rhythm
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { args, videoDir, loud, low, die } from './common.mjs';
low();
const { pos, opt } = args(), { dir } = videoDir(pos[0]), file = path.resolve(dir, opt.file || 'video.mp4'); if (!fs.existsSync(file)) die('no ' + file);
let ok = true; const say = (good, msg) => { ok &&= good; console.log(good ? '✓' : '✗', msg); };
const p = JSON.parse(spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration,size:stream=codec_type,codec_name,profile,width,height,r_frame_rate,pix_fmt', '-of', 'json', file], { encoding: 'utf8' }).stdout);
const v = p.streams.find(s => s.codec_type === 'video'), au = p.streams.find(s => s.codec_type === 'audio'), dur = +p.format.duration, mb = p.format.size / 1e6, L = loud(file);
say(v.width === 1080 && v.height === 1920 && v.r_frame_rate === '30/1' && v.pix_fmt === 'yuv420p' && v.profile === 'High', `video ${v.codec_name} ${v.profile} ${v.pix_fmt} ${v.width}x${v.height} ${v.r_frame_rate}`);
say(au?.codec_name === 'aac', `audio ${au?.codec_name}`); say(dur <= 60.05, `${dur.toFixed(2)} s`); say(mb <= 60, `${mb.toFixed(1)} MB`);
say(Math.abs(L.I + 14) <= 1 && L.TP <= -1, `loudness ${L.I} LUFS · true peak ${L.TP} dBTP`);
// every frame at 54x96 grey: mean brightness and change from the previous frame
const raw = spawnSync('ffmpeg', ['-v', 'error', '-threads', '2', '-i', file, '-vf', 'scale=54:96,format=gray', '-f', 'rawvideo', '-'], { maxBuffer: 1 << 28 }).stdout, N = 54 * 96;
const mean = [], diff = []; for (let k = 0; (k + 1) * N <= raw.length; k++) { let m = 0, d = 0; for (let i = 0; i < N; i++) { m += raw[k * N + i]; if (k) d += Math.abs(raw[k * N + i] - raw[(k - 1) * N + i]); } mean.push(m / N); diff.push(d / N); }
const f0 = [...raw.subarray(0, N)], sd = Math.sqrt(f0.reduce((a, x) => a + (x - mean[0]) ** 2, 0) / N);
say(mean[0] > 25 && sd > 12, `frame 0: mean ${mean[0].toFixed(0)}, contrast ${sd.toFixed(0)} (it is the hook: the title card, the falling cubes, the HUD)`);
// the cream frames sit around 190–206 on this scale: a flash that washes the frame out goes over 218
const white = mean.map((m, k) => [m, k]).filter(([m]) => m > 218); say(!white.length, `washed-out frames (flash too strong): ${white.length}${white.length ? ' e.g. ' + (white[0][1] / 30).toFixed(2) + ' s' : ''}`);
// a frozen render: over 1 s with almost no change (this format holds calm beats of ~0.5 s on purpose, like its reference)
let run0 = 0, frozen = []; diff.forEach((d, k) => { if (d < 0.15) { if (++run0 === 30) frozen.push(((k - 29) / 30).toFixed(2)); } else run0 = 0; });
say(!frozen.length, `stretches over 1 s where nothing moves: ${frozen.length ? 'from ' + frozen.join(', ') + ' s' : 'none'}`);
const cuts = diff.filter((d, k) => { const nb = [diff[k - 2], diff[k - 1], diff[k + 1], diff[k + 2]].filter(x => x != null).sort((a, b) => a - b); return d > 25 && d > 3 * nb[1]; }).length; console.log(`· ${cuts} hard cuts ≈ ${(cuts / dur * 10).toFixed(1)} per 10 s (the hops: a disc every 3–6 s)`);
const sf = path.join(dir, 'frames', 'safe.json');
if (fs.existsSync(sf)) { const S = JSON.parse(fs.readFileSync(sf, 'utf8')), bad = Object.keys(S.bad); say(!bad.length, `text outside the safe zone on ${bad.length} frames${bad.length ? ', e.g. f' + bad[0] + ' ' + JSON.stringify(S.bad[bad[0]][0]) : ''}`); }
console.log(ok ? '\n✓ all checks passed. Now watch it once with sound.' : '\n✗ fix the ✗ lines');
```


## Appendix B: an example video (complete)

"Never pay for a Claude content skill again" (43 s, the reel in `reference.jpg`). It uses every part of the kit plus three custom pieces (the giveaway card, the app screen in the doorway, the puppet strings), so it shows how to go past the ready layouts. The names in it are its creator's own: use yours. Copy the folder, write your own `script.mjs`, then rewrite `WORLD`, `STEPS` and the declarations in `scene.js` and the cues for your words.

`example/video.json`: `{ "take": "a" }` (after `gen-vo.mjs`; everything else comes from the defaults in `common.mjs`).

### `example/script.mjs`

```js
// Sandbox · "Never pay for a Claude content skill again", reworked into @jayantcreates.ai's voxel-Claude grammar:
// Claude itself narrates ("It's me, Claude"), walks a path of sand-disc stations with a step chip, and ends on COMMENT + a voxel word.
// Casper's message and order are kept: the hook, the library promise, Morphy (Casper built it), the free format + SKILL.md,
// the $30 resellers, the response (a free library of .md files in Morphy), no paywalls, the text-file line, this format's name, the CTA.
//
// LINES: what is shown in the captions. SAID: how the voice must say a shown word (the TTS text uses it).
export const LINES = [
  'Never pay for a Claude content skill again.',
  "We're making the entire library free.",
  "It's me, Claude.",
  'I live inside Morphy, the agentic super app Casper built.',
  'Recently, Casper gave away his own content format, and its SKILL.md. Completely free.',
  'Then people took it, repackaged it, and started selling it. For $30.',
  '$30, for something he gave away for free.',
  "So here's our response.",
  "We're building an entire library of ready-to-use content-format .md files, right inside Morphy.",
  'No paywalls. No subscriptions. No strings attached.',
  "Because you shouldn't pay for a text file that was free to begin with.",
  "And this exact format? It's one of them. It's called sandbox.md.",
  "Comment SANDBOX, and I'll send it to you. Completely free.",
];
// shown → said (whole tokens, punctuation kept on the shown side)
export const SAID = {
  'SKILL.md': 'skill dot M D',
  '$30': 'thirty dollars',
  'content-format': 'content format',
  '.md': 'dot M D',
  'sandbox.md': 'sandbox dot M D',
  'SANDBOX': '"Sandbox"',
};
```

### `example/scene.js`

```js
// THE PER-VIDEO DIRECTOR FILE — the example: "Never pay for a Claude content skill again" (43 s).
// Claude (the voxel mascot) hops along a path of sand discs, one per step of the story; cards build out of white cubes above
// it; props assemble and blow apart; the path lights up chartreuse at "this exact format"; COMMENT SANDBOX ends it.
// Everything is keyed to the voice: K.T('word') = when that caption word starts (K.T('free.', 2) = its 2nd occurrence).

// the sand discs, in path order (z goes away from the first camera; x zig-zags ±2–3 so the recap reads as a path)
export const WORLD = { discs: [
  { x: 0.0, z: 0.0, r: 2.6, label: 'CLAUDE' },
  { x: -2.9, z: -4.8, r: 2.3, label: 'GIVEN AWAY' },
  { x: 2.3, z: -9.5, r: 2.3, label: 'COPIED' },
  { x: -1.8, z: -14.2, r: 2.3, label: '$30' },
  { x: 2.8, z: -18.9, r: 2.2, label: 'RESPONSE' },
  { x: -2.4, z: -23.7, r: 2.4, label: 'LIBRARY' },
  { x: 2.1, z: -28.4, r: 2.3, label: 'NO PAYWALL' },
  { x: -1.9, z: -33.1, r: 2.3, label: 'FREE' },
  { x: 0.9, z: -38.0, r: 2.6, label: 'SANDBOX.MD' },
] };
// one step per disc after the first: [chip label, the caption word the step starts on, its occurrence, hop shift in s].
// The mascot hops from disc i-1 to disc i around step i's word (0.15 s before → 0.13 s after) and the step chip changes on the word.
export const STEPS = [
  ['GIVEN AWAY', 'Recently,'], ['COPIED', 'Then'], ['$30', 'selling', 1, 0.2], ['RESPONSE', 'So'], ['LIBRARY', "We're", 2],
  ['NO PAYWALL', 'No'], ['FREE', 'Because'], ['SANDBOX.MD', 'And', 1, -0.35],
];
// [arrive, leave] per disc from the voice's word times (cues.mjs uses it too)
export function stays(film) {
  const T = (cap, n = 1) => { let k = 0; for (const w of film.words) if (w.cap === cap && ++k === n) return w.t0; throw new Error(`no caption word "${cap}" #${n}`); };
  const S = [[0, 0]];
  STEPS.forEach(([, word, n = 1, shift = 0], i) => { const t = T(word, n) + shift; S[i][1] = +(t - 0.15).toFixed(2); S.push([+(t + 0.13).toFixed(2), 0]); });
  S.at(-1)[1] = 999; return S;
}

export default function setup(E) {
  const STAY = stays(E.film);
  const K = E.kit({ discs: WORLD.discs, stay: STAY }), { T, TE, props: P, layouts: LAY, cards: C, O } = K;
  const { ss, lerp, easeIn, easeOutBack } = E.L;

  // ---------------- the HUD and the step chip ----------------
  K.hud({ left: 'CLAUDE · MADE THIS', right: 'FREE LIBRARY' });
  K.steps(STEPS.map(([label, word, n = 1]) => [label, T(word, n)]));

  // ---------------- disc 0: the hook, the library teaser, "It's me, Claude", the doorway into Morphy ----------------
  const LIB = T("We're"), ME = T("It's"), LIVE = T('I');
  K.hook({ lines: ['Never pay for a', 'Claude skill again'], sticker: '$30', burn: T('again.'), until: LIB });
  K.prop(P.bookshelf(2), { disc: 0, x: 0.78, z: -1.35, rotY: -0.12, size: 0.085, in: [LIB + 0.02, 0.75, 'swarm', [K.hookTitle.pos[0] + 0.6, K.hookTitle.pos[1], K.hookTitle.pos[2]]], out: [ME - 0.02, 0.55, 'burst'] });
  K.pill('FREE ✓', { disc: 0, x: 1.05, y: 2.05, z: -1.1, in: [T('free.') - 0.12, 0.3], out: [ME - 0.02, 0.4] });
  K.portal({ disc: 0, z: -0.95, in: [T('inside') - 0.25, 0.6, 'swarm'], out: [STAY[0][1] - 0.05, 0.4, 'burst'], screenAt: T('inside') + 0.25, screen: drawMorphy });

  // ---------------- discs 1–7: one step each ----------------
  K.card(drawGive, { disc: 1, h: 1.3, in: [STAY[1][0] - 0.05, 0.55], out: [STAY[1][1] - 0.02] });
  [['copied ✓', -1.05, 1.95, 0], ['saving this 👀', 0.95, 2.25, 0.12], ['mine now', -0.8, 2.75, 0.22]].forEach(([text, x, y, d]) =>
    K.bubble(text, { disc: 2, x, y, in: [T('took') - 0.12 + d * 0.7, 0.36], out: [T('repackaged') - 0.08 + d * 0.2, 0.3] }));
  K.card(LAY.product({ title: 'ULTIMATE', subtitle: 'Claude Content Skill', rating: '★★★★★  "game changer"', foot: 'inside: the same SKILL.md' }), { disc: 2, y: 2.25, dark: true, in: [T('repackaged') - 0.06, 0.4], out: [STAY[2][1] - 0.02] });
  K.card(LAY.counter({ header: 'CHECKOUT · CONTENT SKILL', thumb: ['ULTIMATE', 'PRO PACK'], title: ['ULTIMATE Claude', 'Content Skill'], sub: 'one SKILL.md file', to: 30, t0: T('For') - 0.05, t1: TE('$30.') - 0.05, prefix: '$', decimals: 2, button: 'Buy now', click: TE('$30.') }),
    { disc: 3, in: [STAY[3][0] - 0.05, 0.5], out: [T('$30,') - 0.12, 0.35] });
  K.card(LAY.compare({ header: 'SAME FILE · TWO PRICES', rows: [{ name: 'SKILL.md', who: 'repackaged', value: '$30', ok: false, at: T('$30,') + 0.1 }, { name: 'SKILL.md', who: 'from Casper', value: 'FREE', ok: true, at: T('gave', 2) }] }),
    { disc: 3, h: 1.2, in: [T('$30,') + 0.05, 0.45], out: [STAY[3][1] - 0.02] });
  K.keyboard({ disc: 4, in: [STAY[4][0] - 0.3, 0.4, 'swarm'], out: [STAY[4][1] - 0.05, 0.4, 'burst'], typing: [STAY[4][0] + 0.12, T('response.') + 0.45] });
  K.card(LAY.typing({ header: 'OUR RESPONSE · REAL MESSAGE', text: 'build the whole library. make it free.', t0: T('So') - 0.05, t1: TE('response.') + 0.25 }), { disc: 4, h: 0.95, y: 2.3, in: [STAY[4][0] - 0.1, 0.4], out: [STAY[4][1] - 0.02, 0.35] });
  K.card(LAY.checklist({ title: 'Library.md', sub: 'inside Morphy', items: ['dev-claude-reel.md', 'ink.md', 'voxel.md', 'clay.md', 'sandbox.md'], newIndex: 4, t0: T('ready-to-use') - 0.1, t1: TE('files,') + 0.1 }),
    { disc: 5, h: 1.27, y: 2.26, in: [STAY[5][0] - 0.05, 0.5], out: [STAY[5][1] - 0.02] });
  K.prop(P.bookshelf(7), { disc: 5, x: 1.3, z: -1.35, rotY: -0.28, size: 0.075, in: [T('library', 2) - 0.1, 0.65, 'swarm'], out: [STAY[5][1] - 0.05, 0.4, 'burst'], spread: 0.65 });
  K.prop(P.paywall(), { disc: 6, z: 0.78, size: 0.1, in: [STAY[6][0] - 0.3, 0.45, 'swarm'], out: [T('paywalls.') + 0.28, 0.6, 'burst'], spread: 1.1 });
  K.flash(T('paywalls.') + 0.28, 0.12);
  K.card(LAY.price({ header: 'SUBSCRIPTION', amount: '$29', period: '/ month', note: 'auto-renews · forever', button: 'Subscribe', strikeAt: T('subscriptions.') + 0.02 }),
    { disc: 6, w: 1.75, h: 0.92, y: 2.15, in: [T('paywalls.') + 0.36, 0.36], out: [T('subscriptions.') + 0.62] });
  noStrings(6, T('No', 3), T('attached.') + 0.12);
  const bin = K.prop(P.bin(), { disc: 7, x: 1.0, z: 0.35, size: 0.06, in: [STAY[7][0] - 0.2, 0.45, 'swarm'], out: [STAY[7][1] + 0.05, 0.4, 'burst'], spread: 0.9 });
  K.card(LAY.file({ name: 'content-format.md', lines: ['# my format', '1. a hook', '2. one idea a line', '3. one ask', 'just plain text.'], stampAt: T('free') }),
    { disc: 7, x: -0.1, w: 1.45, h: 1.15, y: 2.12, in: [STAY[7][0] - 0.05, 0.5], out: [STAY[7][1] - 0.02] });
  K.toss({ disc: 7, from: [0.62, 2.28, -0.1], bin, at: T('pay', 2), show: [STAY[7][0] + 0.35], draw: (g, W, H) => {
    g.fillStyle = '#d4483b'; C.rr(g, 6, 6, W - 12, H - 12, 22); g.fill(); g.fillStyle = '#f7f3e9'; g.beginPath(); g.arc(34, H / 2, 10, 0, 7); g.fill();
    C.txt(g, '$30', W / 2 + 14, H * 0.7, { size: 84, weight: 800, color: '#fff', align: 'center' }); } });

  // ---------------- the mascot's beats ----------------
  K.mascot({
    wave: [[ME, LIVE]],
    hops: [[T('Claude.'), 0.32, 0.2], [T('took'), 0.26, 0.16], [T('repackaged'), 0.26, 0.14], [T('$30.'), 0.3, 0.2], [T('free.', 3), 0.3, 0.18], [T('library', 2), 0.3, 0.16], [T('paywalls.') + 0.3, 0.28, 0.2], [T('attached.') + 0.15, 0.3, 0.18], [T('free'), 0.3, 0.18]],
  });

  // ---------------- camera: the hook shots on disc 0, then the path, the recap and the CTA ----------------
  K.shot(0, LIB - 0.1, { p0: [0, 2.0, 6.5], p1: [0, 1.95, 6.0], l0: [0, 1.75, 0], l1: [0, 1.72, 0] });                               // hook close-up
  K.shot(LIB - 0.1, ME - 0.05, { p0: [0.45, 2.15, 7.9], p1: [0.45, 2.1, 7.5], l0: [0.45, 1.25, -0.8] });                             // pull back for the shelf
  K.shot(ME - 0.05, LIVE - 0.1, { p0: [0, 1.75, 6.4], p1: [0, 1.72, 6.0], l0: [0, 1.25, 0] });                                       // "It's me, Claude."
  K.shot(LIVE - 0.1, STAY[0][1], { p0: [0, 2.05, 7.6], p1: [0, 2.0, 7.0], l0: [0, 1.6, -0.6] });                                     // the doorway
  K.path({ from: 1, to: 7, cam: { 0: { dx: 0, y: 2.0, d: 7.0, push: 0, ly: 1.6, lz: -0.6 }, 4: { ly: 1.5 }, 5: { lx: 0.12, dx: 0.12 }, 7: { lx: 0.18, dx: 0.14 } } });
  K.recap({ up: T('format?') + 0.25, sweep: T("It's", 2), name: T('sandbox.md.'), cta: T('Comment') });
  K.cta({ at: T('Comment'), word: 'SANDBOX', wordAt: T('SANDBOX,'), chip: "I'LL SEND YOU THE .MD FILE · FREE", chipAt: T('and', 3), slab: ['THE', 'FILE', 'sandbox.md', 'free'] });

  // ---------------- extra overlay: "IT'S ME. / CLAUDE." and the Morphy chips ----------------
  K.overlay((g, t) => {
    if (t >= ME && t < LIVE - 0.02) {
      O.title(g, t, [{ text: "IT'S ME." }, { text: 'CLAUDE.', color: O.COL.terra }], { y: 452, size: 128, at: [ME, T('Claude.')], out: LIVE - 0.2 });
      if (t > T('Claude.') + 0.15) O.pill(g, 'SYNTHETIC VOICE · MADE BY CLAUDE', 66, 612, { size: 17, h: 40, alpha: ss(t, T('Claude.') + 0.15, T('Claude.') + 0.4), kind: 'chip' });
    }
    if (t >= T('Morphy,') && t < STAY[0][1] - 0.05) {
      const pop = (t0) => ({ alpha: ss(t, t0, t0 + 0.2), scale: lerp(0.8, 1, easeOutBack(ss(t, t0, t0 + 0.25))) });
      O.pill(g, 'MORPHY · AGENTIC SUPER APP', 510, 370, { align: 'center', size: 19, h: 44, ...pop(T('Morphy,')), kind: 'chip' });
      if (t >= T('Casper')) O.pill(g, 'BUILT BY CASPER', 510, 426, { align: 'center', size: 19, h: 44, bg: '#d9775a', ...pop(T('Casper')), kind: 'chip' });
    }
  });
  return K.done();

  // ---------------- custom pieces of this video ----------------
  // the giveaway card: two file rows, $0 that turns green with a FREE chip, a "Give away" button the cursor clicks
  function drawGive(g, W, H, t) {
    C.header(g, 'THE GIVEAWAY · BY CASPER', 42, 64, { size: 28 }); C.field(g, W, H);
    C.fileRow(g, 52, 120, W - 104, 'my content format', '', { folder: true });
    C.fileRow(g, 52, 248, W - 104, 'SKILL.md', '', { hi: t > T('SKILL.md.') - 0.05 });
    const free = ss(t, T('Completely') - 0.05, T('Completely') + 0.2);
    C.txt(g, '$0', 58, H - 58, { size: 104, weight: 800, color: free > 0.5 ? C.GREEN : C.INK, sp: -2 });
    if (free > 0) { g.save(); g.globalAlpha = free; g.translate(212, H - 140); const s = lerp(0.6, 1, easeOutBack(free)); g.scale(s, s); C.chipBox(g, 'FREE ✓', 0, 0, { size: 40, h: 78, bg: C.LIME, color: C.INK, weight: 800, padX: 26 }); g.restore(); }
    const press = ss(t, T('gave') + 0.05, T('gave') + 0.2) * (1 - ss(t, T('away') + 0.1, T('away') + 0.3)), done = t > T('away') + 0.1;
    const bx = W - 352, by = H - 140; g.fillStyle = done ? '#e9f7ee' : C.INK; C.rr(g, bx, by, 300, 84, 42); g.fill();
    C.txt(g, done ? 'Shared ✓' : 'Give away', bx + 150, by + 57, { size: 40, weight: 700, color: done ? C.GREEN : '#fff', align: 'center' });
    const kc = ss(t, STAY[1][0] + 0.3, T('gave'));
    if (t < T('format,')) C.cursor(g, lerp(W - 90, bx + 200, kc), lerp(H - 10, by + 56, kc), press);
  }
  // a stylised Morphy canvas on the doorway's screen (dark UI with cards), never a screenshot
  function drawMorphy(g, W, H, t) {
    const k = ss(t, T('inside') + 0.25, T('Morphy,') + 0.2);
    g.fillStyle = '#ffffff'; g.fillRect(0, 0, W, H);
    g.globalAlpha = k; g.fillStyle = '#1c1d20'; g.fillRect(0, 0, W, H);
    g.fillStyle = '#26282c'; g.fillRect(0, 0, W, 64); C.txt(g, 'Morphy', 26, 44, { size: 30, weight: 700, color: '#f3f3f1' });
    [[28, 96, 210, 150], [256, 96, 186, 230], [28, 266, 210, 210], [256, 346, 186, 130], [28, 496, 414, 140], [28, 656, 196, 160], [244, 656, 198, 220]].forEach(([x, y, w, h], i) => {
      const a = ss(t, T('Morphy,') - 0.1 + i * 0.07, T('Morphy,') + 0.15 + i * 0.07), dy = (1 - a) * 20;
      g.globalAlpha = k * a; g.fillStyle = 'rgba(243,243,241,0.06)'; C.rr(g, x, y + dy, w, h, 16); g.fill(); g.strokeStyle = 'rgba(243,243,241,0.12)'; g.lineWidth = 2; g.stroke();
      g.fillStyle = i === 1 ? '#3446f0' : 'rgba(243,243,241,0.35)'; C.rr(g, x + 16, y + 18 + dy, w * 0.55, 12, 6); g.fill();
      g.fillStyle = 'rgba(243,243,241,0.16)'; for (let r = 0; r < 3; r++) { C.rr(g, x + 16, y + 44 + r * 22 + dy, (w - 32) * (0.9 - r * 0.2), 9, 4); g.fill(); }
    });
    g.globalAlpha = 1;
  }
  // "No strings attached.": a page on four strings from a wooden puppet bar; on the snap the bar flies off and the strings drop
  function noStrings(disc, NS, SNAP) {
    const pg = K.at(disc, 0, 0, -0.3), END = STAY[disc][1];
    const sheet = K.prop(P.page(10, 13), { size: 0.07, in: [NS - 0.15, 0.45, 'swarm'], out: [END - 0.05, 0.4, 'burst'], spread: 0.9,
      follow: (m, t) => { const drop = ss(t, SNAP + 0.05, SNAP + 0.3); m.group.position.set(pg[0] + 0.3, K.FLOOR + 1.3 + Math.sin(t * 3) * 0.02 * (1 - drop) - drop * 0.06, pg[2]); m.group.rotation.set(0, 0, 0.04 - drop * 0.06); } });
    const bar = K.prop(P.puppetBar(), { size: 0.08, in: [NS - 0.1, 0.45, 'swarm'], out: [SNAP + 0.45, 0.35, 'burst'], spread: 0.8,
      follow: (m, t) => { m.group.position.set(pg[0] + 0.32, 2.86 + easeIn(ss(t, SNAP, SNAP + 0.5)) * 1.6, pg[2] - 0.32); m.group.rotation.z = Math.sin(t * 2.2) * 0.06 + ss(t, SNAP, SNAP + 0.6) * 0.5; } });
    const strings = [0, 1, 2, 3].map(() => [P.line(E, 0x2b2622, 0.016), P.line(E, 0x2b2622, 0.016)]);
    K.world(t => strings.forEach(([up, lo], k) => {
      const on = t > NS + 0.25 && t < END && sheet.group.visible && bar.group.visible, b = bar.group.position, s = sheet.group.position;
      const top = [b.x + (k - 1.5) * 0.36, b.y + 0.02, b.z + 0.32], bot = [s.x + (k - 1.5) * 0.17, s.y + 0.9, s.z];
      const mid = [lerp(top[0], bot[0], 0.5), lerp(2.88, bot[1], 0.5), lerp(top[2], bot[2], 0.5)];
      if (!on) { up.set(top, bot, false); lo.set(top, bot, false); return; }
      if (t < SNAP) { up.set(top, mid); lo.set(mid, bot); return; }
      const k2 = ss(t, SNAP, SNAP + 0.35);
      up.set(top, [mid[0], mid[1] + (top[1] - 2.88), mid[2]]);
      lo.set([lerp(mid[0], bot[0], k2 * 0.6) + Math.sin(t * 20 + k) * 0.03 * (1 - k2), lerp(mid[1], bot[1] + 0.02, k2), mid[2]], bot, k2 < 0.98);
    }));
  }
}
```

### `example/cues.mjs`

```js
// Sound effects of this video, on its words and beats: ev(name, t, dB, maxDur?). Sounds: sfx/ (made for this video:
// blocks, fuse, burn, burst), then assets/sfx (the kit's), then ../../shared/sfx.
import { stays } from './scene.js';
export default function (ev, film) {
  const STAY = stays(film);
  const T = (cap, n = 1) => { let k = 0; for (const w of film.words) if (w.cap === cap && ++k === n) return w.t0; throw new Error(`no word "${cap}" #${n}`); };
  const TE = (cap, n = 1) => { let k = 0; for (const w of film.words) if (w.cap === cap && ++k === n) return w.t1; throw new Error(`no word "${cap}" #${n}`); };
  const HK = T('again.'), LIB = T("We're"), ME = T("It's"), CTA = T('Comment');
  // ---- hook: cubes rain into the mascot, the fuse fizzes, a spark runs up, the card burns ----
  ev('blocks', 0.0, -13);
  ev('thud', 0.62, -20);
  ev('fuse', 0.55, -24, 1.7);
  ev('swish', HK - 0.45, -21);
  ev('burn', HK - 0.02, -13);
  // ---- library teaser + FREE ----
  ev('implode', LIB - 0.05, -19);
  ev('pop', T('free.') - 0.1, -15);
  // ---- it's me, Claude ----
  ev('burst', ME - 0.04, -15);
  ev('swoosh', ME, -22);
  ev('boing', T('Claude.') - 0.02, -27);
  // ---- doorway into Morphy ----
  ev('implode', T('inside') - 0.3, -19);
  ev('click', T('Morphy,'), -20);
  ev('click', T('Casper'), -18);
  // ---- hops between discs: a whoosh on take-off, a soft landing ----
  for (let i = 1; i < STAY.length; i++) {
    const a = STAY[i - 1][1], b = STAY[i][0];
    if (i < STAY.length - 1) { ev('burst', a - 0.03, -20); ev('whoosh', a + 0.02, -17); }
    ev('thud', b - 0.02, -24);
  }
  // ---- 01 given away ----
  ev('implode', STAY[1][0] - 0.1, -21);
  ev('click', T('gave') + 0.12, -15);
  ev('ding', T('SKILL.md.'), -22);
  ev('ding', T('Completely'), -16);
  // ---- 02 copied: bubbles pop in, the product card swarms in ----
  [0, 0.12, 0.22].forEach(d => ev('pop', T('took') - 0.1 + d * 0.7, -18));
  ev('burst', T('repackaged') - 0.05, -19);
  ev('shimmer', T('repackaged') + 0.05, -21, 1.0);
  // ---- 03 $30: the price counts up, the buy click; the two prices ----
  ev('implode', STAY[3][0] - 0.1, -21);
  ev('ff', T('For') - 0.05, -24, 0.55);
  ev('ding', TE('$30.') - 0.05, -15);
  ev('click', TE('$30.') - 0.02, -17);
  ev('burst', T('$30,') - 0.12, -19);
  ev('slap', T('$30,') + 0.1, -19);
  ev('ding', T('gave', 2), -17);
  // ---- 04 response: typing on the keyboard ----
  ev('implode', STAY[4][0] - 0.3, -21);
  ev('typing', STAY[4][0] + 0.1, -17, 1.25);
  ev('enter', TE('response.') + 0.25, -19);
  // ---- 05 library: the shelf, the files ticking in ----
  ev('implode', T('library', 2) - 0.1, -19);
  for (let i = 0; i < 5; i++) { const a = T('ready-to-use') - 0.1, b = TE('files,') + 0.1; ev('clack', a + (b - a) * (i + 0.9) / 5, -22); }
  ev('ding', TE('files,'), -20);
  // ---- 06 no paywall / no subscriptions / no strings ----
  ev('implode', STAY[6][0] - 0.3, -20);
  ev('impact', T('paywalls.') + 0.26, -15, 1.2);
  ev('burst', T('paywalls.') + 0.27, -12);
  ev('swish', T('subscriptions.') + 0.02, -17);
  ev('burst', T('subscriptions.') + 0.62, -17);
  ev('flick', T('attached.') + 0.12, -13);
  ev('flick', T('attached.') + 0.17, -16);
  ev('swoosh', T('attached.') + 0.2, -20);
  // ---- 07 free: the tag falls in the bin, the FREE stamp ----
  ev('swish', T('pay', 2) + 0.02, -19);
  ev('clunk', T('pay', 2) + 0.52, -14);
  ev('stamp', T('free') - 0.02, -14);
  // ---- 08 the recap: crane up, the green sweep, the name, the dive ----
  ev('riser', STAY[7][1] + 0.1, -18, 1.5);
  ev('shimmer', T("It's", 2) - 0.05, -14);
  ev('chime', T('sandbox.md.'), -15, 1.6);
  ev('whoosh', T('sandbox.md.') + 0.55, -12);
  // ---- CTA ----
  ev('swoosh', CTA - 0.05, -18);
  ev('blocks', T('SANDBOX,') - 0.05, -14);
  ev('chime', T('SANDBOX,') + 0.45, -17, 1.8);
  ev('click', T('and', 3), -20);
}
```

