---
name: voxel
description: Make a narrated vertical reel (1080x1920, 40-55 s) in the "Voxel 3D" format - a blocky three.js night desk by a big city window, an orange Claude block and a blue-cap creator acting out every sentence, a torn paper banner on tape, a counter badge, orange torn-paper word captions and a terminal bar, one ElevenLabs voice, a music bed, rendered frame by frame in headless Chrome and encoded with ffmpeg. Use it when someone asks for a voxel reel, "voxel.md", a 3D explainer reel or a Short in this format, or wants to turn a script into this kind of video.
---

# Voxel 3D reel: how to make one

You will make a **40-55 second vertical video (1080x1920, 30 fps, H.264 + AAC, at most 60 s and 60 MB)**: one narrator explains something while a voxel toy world acts out every sentence. A blocky orange Claude and a creator in a cream hoodie and blue cap stand on a wooden desk at night, a phone lies on a navy mat in front of them, a huge window full of tiny lit city windows fills the background, everything is a little soft (depth of field), glowing (bloom) and grainy. Over the 3D sit four paper-craft layers: a **torn cream paper banner on two strips of tape** (the beat's chapter label), a **white counter badge**, **orange torn-paper word tags** (the captions, word by word) and a **dark terminal bar** that types the prompt. The camera never stops moving, there is one shot per beat, and when the voice says something changes, the video changes.

**`reference.jpg`** (next to this file, if you have it) is a contact sheet of 12 frames from a finished Voxel reel: **this is what it should look like.** If you only have this file, section 3 describes everything in that picture, 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 rules that keep it from looking buggy, and the complete, tested source of the engine (Appendix A) plus one example video (Appendix B). Write the engine once, then each video is two files: `scene.js` (the director file) and `cues.mjs` (sound effects).

Work in this order and don't skip ahead: **setup -> script -> blocking with guessed timings -> voice -> timings -> 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. A CPU render of this film pegs every core for half an hour.
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 (about 1 credit per character, ~700-950 for a reel). Never regenerate a take that exists; listen first, regenerate only what is wrong.
4. **Stills before film.** Never start the full render until the stills contact sheet is right. Re-rendering 1,600 frames for a one-shot fix is the most expensive mistake here.
5. **Background jobs die when your turn ends.** Render in foreground chunks (`render.mjs ... full 0 900`, then `full 900`), 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, file names, view counts or other creators' frames. Everything on screen is drawn: the creator, the phone's screen, the props. Counters count only what is said or what is on screen.
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 `VOXEL_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, API access, premade voices) is enough for a few reels but has no commercial licence; monetised videos need a paid plan. No key yet: do everything with `build.mjs --fake` (guessed timings) up to the stills, and ask for the key.
- Fonts and music are free downloads (`setup.mjs` fetches them). Sound effects are made once with ElevenLabs sound generation (`gen-sfx.mjs`), or downloaded free (Mixkit's free sound effects) and saved under the names in section 10.

## 2. Project layout and setup

```
voxel-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)
    lib.js tex.js world.js       helpers, canvas textures, the set + characters + prop helpers
    post.js camera.js look.js    post chain, camera from SHOTS, grade/lights dials + effects
    overlay.js screen.js         the 2D paper layer, the phone's screen
    common.mjs cdp.mjs setup.mjs render.mjs gen-vo.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.txt  video.json       the voice text; settings
    scene.js  cues.mjs           the director file; the sound effects
    vo/ film.json stills/ frames/ mix.wav video.mp4 cover.jpg caption.md
```

```bash
mkdir voxel-reel && cd voxel-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 20 sounds; add --yes to make them (once, for every future video)
```

**A new video** = a new folder with `script.txt` (the voice text; `#` lines are ignored), `video.json`, `scene.js` and `cues.mjs` (start from Appendix B). `video.json` fields, all optional: `take` (set by gen-vo), `tail` 1.7 (seconds after the last word), `cover` 0.5 (the cover frame time), `capFix` ({ "voxel": "VOXEL" }), `chunkW` 760, `music` ({ file, from: 8, db: -11.5, muffle: [] }), `voice` ({ id, model_id, voice_settings }).

Commands for one video (from the project folder; `my-video` is the folder name):

```bash
node engine/build.mjs my-video --fake                    # guessed timings from script.txt: block out shots without a voice
node engine/gen-vo.mjs my-video                          # dry run: characters, words, seconds
node engine/gen-vo.mjs my-video --yes                    # ONE take -> vo/a.mp3 .wav .json ; listen to it
node engine/build.mjs my-video                           # real timings -> film.json, prints every caption chunk with its time
node engine/render.mjs my-video stills 0.5,3,7,11,15 --safe   # stills + sheet-safe.jpg with the safe zone; fix; repeat
node engine/render.mjs my-video full 0 900               # full render in chunks (resumable)
node engine/render.mjs my-video full 900
node engine/mix.mjs my-video                             # mix.wav at -14 LUFS
node engine/encode.mjs my-video                          # video.mp4 + cover.jpg
node engine/check.mjs my-video                           # the final checks
```

---

## 3. The look, pinned down

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

| Layer | Where | What |
|---|---|---|
| Platform UI (keep clear) | top 250 px, bottom 440 px, right 120 px | Instagram/TikTok/YouTube cover these. Nothing important there. |
| **Banner** | top edge y 268, centred on x 510, paper 560-880 px wide | Torn cream paper, two tape strips on the top corners, black caps. Changes on the first word of every beat, with a slap. |
| **Counter badge** | right edge x 950, centre y 470 (22 px below a 2-line banner) | White card, label + big coloured number. Stays left of x 960, out of the right 120 px. |
| **Subject** | the look-at point lands at y ~845 (44 % of the height) | A lens shift (`frameY` 0.44) keeps characters above the terminal and captions. |
| **Terminal bar** | centre y 1092, x 66-954, 76 px tall | Dark bar, `>` prompt, types while the voice says the prompt. Only when a prompt is said. |
| **Comment box** (CTA) | centre y 1062, the posted comment 150 px above | Instagram-like pill; types the keyword, posts, heart, a paper plane flies off. |
| **Captions** | block centred at y 1385 (72 % of the height), always inside y 1150-1480, centred on x 510, at most 820 px wide | Orange torn-paper tags, Cooper Black, 1-3 words at a time, word by word. |
| 3D world | everything | DOF, bloom, chromatic edges, vignette, grain. |

**Safe zone (checked automatically):** all text in x 60-960, y 250-1480; captions centred on x 510, at most 820 px wide, within y 1150-1480. The engine records every text box each frame; `--safe` stills draw the zone and print anything outside it; the full render logs violations to `frames/safe.json`; `check.mjs` fails on any.

**Frame 0 is the cover**: it already holds the subject, the hook banner, the counter and the first caption word. No black, no fade-in. The end is a hard cut 1.7 s after the last word.

### 3.2 Colours

| Element | Hex |
|---|---|
| Banner paper / tape / ink | `#f3eee4` (+ fibre texture multiplied at 55 %) / `#d9c99c` at 86 % / `#141414` |
| Banner, dark ("tape") mode for a moody beat | `#17171a`, text `#f2f2f2`, no tape strips |
| Badge card / outline 4 px + label ink | `#f8f3e9` / `#2b2420` |
| Badge numbers: good / neutral / bad | `#2fae5a` / `#e0702f` / `#d6452b` |
| Caption tag / text / text shadow | `#cf6a43` (moody beat `#a9512f`) / `#fdf3e4` / `rgba(70,25,8,.35)` offset 1.5, 3 |
| Huge headline tags | `#b8532f` |
| Terminal bar / text / `>` + send flash + "↵ sent" | `rgba(16,16,19,.94)` / `#ecebe8` / `#e8784a` |
| Comment UI | white, text `#111`, grey `#8e8e8e`, Post `#0095f6` (empty `#b2dffc`, pressed `#00376b`), heart `#ff3040`, avatar disc `#d97757` |
| Desk wood / desk front / mat (+ border) | `#a77a62` over warm plank texture / `#4a2716` / `#1c2045` (+ `#e8873f`) |
| Window frames / sill / fog | `#161a33` / `#232845` / `#0e1230` from 30 to 70 units |
| Night sky / buildings / lit windows | `#141a3e` -> `#27306a` / `#1a2152`, far `#11173a` / `#ffd27a #ffb05a #ffe7a8 #8fb4ff #ff8f5a`, 42 % of 5x8 px windows lit |
| Claude / eyes | `#e8845c` / `#111014` |
| Creator: hoodie, pocket, skin, cap, jeans | `#f3ecdc`, `#d9ceb8`, `#ffd9b5`, `#3b6fd8`, `#2a3350` |
| Accents: lamp, mug, clock | lamp `#2a2f55` + shade `#e8873f` + bulb `#fff1d0`; mug `#f3efe6` + band `#e8873f`; clock face `#f6f1e6`, rim `#e8873f`, minute hand `#d9482b`; sticky notes `#f7d548`, `#f5a3c7` |

### 3.3 Fonts

| Use | Original (if installed) | Free stand-in (downloaded by setup.mjs) | Size / weight |
|---|---|---|---|
| Banner, badge label, in-world signs | Inter Tight ExtraBold | the same (Google Fonts, OFL) | banner 68 px caps, letter-spacing -1 px, shrinks to 50 px, then 2 balanced lines (64-36 px) over 760 px; badge label 22 px, spacing 1 px |
| Captions, badge numbers, price tags | **Cooper Black** (ships with MS Office) | **Fraunces 72pt SuperSoft Black** (OFL; inspired by the Cooper series) | captions 64 px (1.42x for "bigger"), huge slams up to 190 px; badge number 58 px |
| Terminal, file names | Consolas Bold | JetBrains Mono Bold (OFL) | 36 px, shrinks to fit (min 24) |
| Magazine masthead (optional mode) | Bodoni MT Black (+ Italic) | Bodoni Moda Black (OFL) | up to 230 px, letters drop in |
| Comment box | Segoe UI | Inter (OFL) | 30-38 px, 400/600/700 |

`main.js` registers each family with `local("<original>")` first and the free file second, so the original is used where it exists and the free font everywhere else. Never bundle or redistribute the licensed originals. The free set looks almost identical (tested).

### 3.4 The world

All units are world units; the desk top is y = 0 and +z points at the camera.

- **Desk** 10 x 6.2 (x -5..5, z -3.2..3.0), warm planks; a dark desk front edge. **Mat** 2.5 x 3.65 under the **phone**, which lies flat centred at (0, 0.012, 0.15), 1.55 x 2.76, 0.13 thick, top surface at **y 0.142**; its screen is a 540x960 canvas the video draws every frame ("the video" with the drawn creator talking, or a card). `R.phonePivot.rotation.x = 1.3` stands it up facing the camera (the CTA).
- **Window**: dark navy mullions at x +-1.75 and +-4.8, transoms at y 0.15, 5.2, 10.4, all at z -3.7; a sill. The **city** is a 90 x 45 canvas plane at z -32 (night; sunset and moody versions fade over it), plus two side planes so wide shots never see an edge. Rain streaks on the glass in the moody look.
- **Props at rest**: desk lamp back left (-3.3, -1.7) with an orange cone shade and a glowing bulb, mug right (3.0, -0.2), a wall clock hanging in front of the right window pane (3.15, 3.0, -3.5) whose hands the video turns, three sticky notes.
- **Lights**: hemisphere sky `#7f8fe0` / ground `#2a1a24` 0.9; **key** spot `#ffd8b0` intensity 60 (distance 30, angle 0.62, penumbra 0.55, decay 1.6) from (4.2, 8.5, 6.5) with soft 2048 px shadows; **rim** directional `#7f9cff` 1.6 from behind (-2, 5, -9), the window's cool light on every edge; **lamp** point `#ffb36b` 7 (distance 9, decay 1.6) at (-2.4, 1.9, -1.2); fill directional `#ffc9a0` 0.5 from (-4, 3, 6).
- **Materials**: every surface is a matte `MeshStandardMaterial` (roughness ~0.82) whose colour is multiplied by a blotchy grey value-noise map, so flat colours look hand-painted voxels. Boxes have small rounded edges (RoundedBoxGeometry). Detail comes from light, DOF and bloom, never from texture detail.

### 3.5 The characters

- **Claude** (the "Clawd" mascot style of Claude Code, built from boxes, no logo asset): an orange block 1.12 wide x 0.72 tall x 0.78 deep with 0.05 rounded edges, on **four stubby legs** (0.22 tall), **two tall black rectangular eyes** (0.1 x 0.19), **two stub arms** at the sides, **no mouth**. About 0.92 tall, 1.5 wide with arms. Stands at (0, 0, -1.75) facing the camera.
- **The creator** ("me", the narrator's avatar): blocky, about 1.8 tall, cream hoodie with a chest pocket, skin-coloured head with two small black eyes, **blue cap with a brim**, navy jeans. Stands at (1.15, 0, -1.25), turned -0.38 rad towards Claude. Extra characters (a rival, a customer) are the same build in other colours (`makeCreator(mat, box, 1, { hood: 0x1b1b20, cap: 0xd6452b })`), mini Claudes are `makeClaude(..., 0.5)`. Accessories (a hat, glasses, gloves) are boxes parented to `R.claude.pivot` or an arm.
- **Acting**: Claude has no mouth, so life comes from breathing (squash +-2.5 % at 0.38 Hz), blinks every 2.6-4.2 s, eye darts toward what matters, eyes widening (scale 1.25-1.35), arm raises, and **hops on named words** (a crouch, a parabola, a landing squash). The creator nods, tilts, points, waves. One motion per character at a time.

### 3.6 Props: every claim gets a literal object

Props are boxes and thin cards with a canvas texture on the front face, built in `setup()` and moved in `world(t)`:
`R.tile({ label, bg: [c0, c1], icon })` an app tile (rounded block) · `R.card(canvas, w, h)` paper, documents, signs · `R.decal(canvas, w, h)` stamps, X marks, check marks, price tags · `R.box(w, h, d, R.mat(color), round)` anything else, then `E.scene.add(mesh)`. Canvas recipes in `tex.js`: `tileCanvas`, `docCanvas` (coloured header, title, mono subtitle, rows), `stampCanvas('FREE', '#2fae5a')` (double border, distressed ink), `tagPriceCanvas('$30')`, `labelCanvas`.

**The phone screen** (`phone(g, t)` draws a 540x960 canvas every frame): `S.edited` ("the video": night-to-orange gradient, the drawn creator talking with a rim light, a progress bar, its own small captions), `S.raw` (grey shaky camera footage, REC, timecode), `S.card` (the deliverable: orange pill, 2-3 big lines, a document or list icon, a glow), `S.wipe` / `S.crossfade` between modes, or draw anything else yourself (an app UI, a list, a chart) in the same palette. Never a real screenshot.

Rest heights: desk y 0, mat 0.012, on the phone 0.142; a box of height h at scale s rests at surface + h*s/2. Long or wide props go **in depth** (see 7.3). Props never just appear: they drop with a bounce, pop with an overshoot, fly in on an arc, flip in, or slam (stamps scale 2.4x -> 1x in 0.12 s with a camera kick).

Proven props: app tiles that fall in and get red-X-stamped, then swept off the desk · document cards (a SKILL.md, a resume, a plan) with a green stamp · a price tag swinging on a box · a cork board with flip-in panels · a cardboard box that bursts · a wall of file cards · a timeline strip with clips snapping in · polaroids floating up · question marks over the creator's head · a trash bin · a crowd of 49 identical mini desks far away ("lots of people do this") · the wall clock spinning ("a few minutes later") · the phone standing up with the deliverable on its screen.

### 3.7 Post (the numbers)

| Stage | Setting |
|---|---|
| Scene | linear HDR half-float target, 4x MSAA, depth texture; no tone mapping in three.js; camera fov 42 default, near 0.1, far 200 |
| DOF | 40-tap golden-angle gather; blur px = `ap * abs(1 - focus/z)`, max 16 px; focus = distance to the shot's look-at point. `ap` per shot: wide 4-7, medium 8-10, close-up 11-12, top-down insert 3-4 |
| Bloom | bright pass `smoothstep(0.9, 1.5, max(r,g,b))` at 1/2 res; 3 blurred levels at 1/4, 1/8, 1/16, added x0.5, x0.8, x1.1, times strength 0.5 |
| Grade | exposure 1.0, ACES (Narkowicz), gamma 2.2, saturation 1.1, contrast 1.04 |
| Final | chromatic edges 1.7 (radial, zero in the centre), vignette 0.32, **grain 0.028**, flash capped at **0.24**, the 2D overlay composited last (straight alpha) |
| Dials | `warm` 0..1 (sunset city, warmer key, bloom 0.85, halation, warm tint) and `moody` 0..1 (dark blue, rain, desaturated, lamp off); 3D lightning capped at 2.2 |

**The caps exist because of real failures:** a 0.55 flash plus a strong lightning light washed three frames white (flash <= 0.24); grain 0.045 doubled the file to 113 MB for 55 s (grain 0.028 keeps it near 50 MB: don't raise it); a 1.2-unit close-up cut Claude off at the sides (minimum framing distance, `fit` 1.8, see 7.3).

### 3.8 The overlay elements, behaviour

- **Banner**: one per beat, changes on the beat's first word with a slap sound: pops 1.09x -> 1x in 0.24 s with a small wobble, text fades in over 0.06 s, alternating tilt +-0.018 rad. 2-6 words, caps, at most ~28 characters (longer wraps to 2 balanced lines and pushes the badge down). Modes: `paper` (default), `tape` (black strip, for the dark beat), `mag` (Bodoni masthead, for a "magazine/aesthetic" beat).
- **Badge**: `{ label, value, col }`, label in caps (2-3 words), value a number or a short word (`$0`, `7`, `3/5`, `SENT ✓`). Pops in with an overshoot (0.22 s), out in 0.15 s; when the value changes it pops 1.22x. A number on screen in every beat, and it changes inside the beat: counting up on spoken numbers, flipping red -> green on the turn.
- **Captions**: chunks of at most 3 words (a break after punctuation, balanced), each word appears at its spoken start (pop + slight tilt), earlier words slide over to make room; the chunk stays up to 0.7 s after its last word. `scale` grows them on "bigger captions"; `huge` slams 1-4 big words in rows on a headline moment (with a thud, a camera kick and an overlay shake).
- **Terminal**: slides up 26 px, types the prompt from the first to the last spoken word of the prompt, block cursor, sends 0.1 s after: the outline flashes orange, "↵ sent" fades, Claude hops.
- **Comment box (CTA)**: appears on "Comment", types the keyword as it is spoken, posts, the posted comment slides in above with a heart that fills red, a paper plane (the DM) flies to the top right, then the badge says `DM` `SENT ✓`.

---

## 4. Writing the script for this format

The user may hand you a script; if not, write one. It is a **narrated explainer**: one voice, first person ("I built", "I stopped"), talking to "you".

- **Length**: 40-55 s. Liam speaks about 3 words a second: **120-160 words** (~700-950 characters). The dry run prints the estimate. Over 55 s, cut a beat; never speed the voice up.
- **The hook is the first banner and the first line**, complete within 3 s and readable at frame 0 with the sound off: a claim with a number or a stake, under ~12 words. Shapes that work: "N <things> that <unexpected result>." / "I stopped <doing X>. Claude does it now." / "Never <pay for / do> X again."
- **Beats**: every sentence (or two short ones) is a beat of 2-4 s, and every beat gets a **banner** (2-6 words, the beat's job, not a transcript), **one shot**, and **one literal visual**. If you can't name the prop or action that shows a sentence on a desk, rewrite the sentence.
- **A counter that counts something real** in every beat: something the voice says ("3 apps" -> 3), or something on screen (the 12 file cards -> 12). Never invent statistics.
- **A turn at about 60 %** ("But here's the part...", "And it doesn't stop there") opens a second act where attention dips.
- **The CTA is the last line**: "Comment KEYWORD and I'll send you <the exact thing>." One word, in caps, that appears nowhere else in the script (the caption fix uppercases every occurrence), ideally the topic's own noun. Promise only something that exists and that will really be sent (the prompt, the file, the list). The keyword appears four times: spoken, in the last banner, typed in the comment box, and twice in the post caption.
- **Style**: short sentences, one idea each, concrete nouns, no hype words ("insane", "game-changer", "10x"), no emojis in the voice. Numbers: v3 reads "$30", "7" and "50%" correctly; spell out anything ambiguous (versions, years, ranges, symbols) and use `capFix` in `video.json` to show the written form. Don't say URLs ("the link").
- **A shape that works** (fill it with the user's topic):

| Time | Beat | Line (shape) | Banner | Counter |
|---|---|---|---|---|
| 0-3 s | Hook | "I stopped paying for 3 apps." | 3 APPS I STOPPED PAYING FOR | APPS I PAY FOR 0 -> 3 as they land |
| 3-7 s | The claim | "Claude does all three now." | CLAUDE DOES ALL THREE | 3 (red) -> 0 (green) |
| 7-30 s | 3-5 points | one concrete claim each, each with its prop | the point in 2-5 words | counts that point's thing |
| ~60 % | The turn | "But here's the part nobody shows." | THE PART NOBODY SHOWS | a new counter |
| to -6 s | Payoff | what the viewer gets, and that it's free | ... | ... |
| last 4-6 s | CTA | "Comment KEYWORD and I'll send you the prompt." | COMMENT KEYWORD FOR THE PROMPT | DM SENT ✓ |

- **Write `caption.md`** next to the video: the final script, the post caption (the hook line, 2-4 lines of what it does, "Comment KEYWORD ...", 3-5 hashtags), and a `DM:` line saying exactly what gets sent.

---

## 5. The voice (ElevenLabs)

- **Voice: Liam**, premade, `TX3LPaxmHKxFdv7VOQHJ` (labelled "social media creator"), model **`eleven_v3`**, `stability 0.5, similarity_boost 0.8, style 0.6, use_speaker_boost true`, endpoint `/v1/text-to-speech/{id}/with-timestamps` (mp3_44100_128). These are the defaults in `common.mjs`; `video.json` -> `voice` overrides them. Any other premade voice works (e.g. Brian `nPczCjzI2devNBz1zQrb`, Will `bIHbv24MWmeRgasZH58o`; list yours with `GET /v1/voices`).
- **One take of the whole script.** v3's character alignment is accurate to about 0.1 s, so one take gives better rhythm than stitched lines and costs one character per character. `gen-vo.mjs` writes `vo/a.mp3`, `vo/a.wav` (48 kHz mono) and `vo/a.json` (text + alignment) and sets `video.json` `take`. Listen to it. Only if it is wrong (a mispronounced name, a flat hook): `--yes --take b`, and keep the old one.
- If the API refuses `eleven_v3` for the account, set `"voice": { "model_id": "eleven_multilingual_v2" }` and remove any `[audio tags]`.
- `build.mjs` turns the alignment into words: ElevenLabs gives the silence before a word to that word, so each word's start is moved to where its sound really begins (an RMS onset in the wav). Captions then pop exactly on the syllable.

## 6. Timings: film.json

`node engine/build.mjs my-video` writes `film.json`: `words[]` (`{ i, w, cap, t0, t1 }`), `chunks[]` (`{ t0, t1, words }`) and `END` (last word + `tail` 1.7 s). It prints each caption chunk with its start time. Use `--fake` before you have a voice: same format, guessed timings, so the whole scene can be blocked out and still-checked first.

**Key every beat to words, not to numbers.** In `scene.js`, `at('calendar')` is the start of the first "calendar", `at('my', 2)` the second "my", `end('plan', 2)` the end of the second "plan" (Appendix B). Then a new take re-times the whole video by itself. Re-run `build.mjs` after changing the take, `capFix`, `chunkW` or `tail`, and re-run `mix.mjs` after any change to film.json.

---

## 7. Directing: beats, shots, the edit

### 7.1 How a beat is written

`scene.js` exports `setup(E)`, called once; it returns `{ SHOTS, IMPULSE, world(t), effects(t), phone(g, t), overlay(g, t) }`. `E` holds: `THREE`, `R` (the set: `R.claude`, `R.creator`, `R.phonePivot`, `R.minHand`/`R.hourHand`, `R.lamp`, `R.mug`, prop helpers `R.tile/card/decal/box/mat/tex`), `scene`, `camera`, `film`, `END`, `O` (overlay: `banner`, `badge`, `captions`, `terminal`, `comment`, `COL`), `S` (phone screens), `T` (canvas textures, `cv`), `L` (easing, `hop`, `hash`, `keys`), `FX` (`punchIn/punchOut`, `whips`, `flash`, `pulse`, `shake`, `applyFx`), `look(t, { warm, moody, spin, flicker, lightning })` (call it every frame), `rig` (`poseClaude`, `poseCreator`, `blinkAt`, `makeClaude`, `makeCreator`), `V(x, y, z)`. Writing `scene.js` is the long step (an hour or two for a 50 s reel); everything else is minutes.

For each sentence, in `scene.js`:
1. **Its start**: `B[k] = at('<first word>')`. The banner changes there (`BANNERS`), the shot cuts there (`SHOTS`), a `slap` sound plays there (`cues.mjs`).
2. **One shot** from `B[k]` to `B[k+1]`: camera keys `P` and look-at keys `L` (on the subject: it is also the focus distance), a slow push-in or drift of 4-12 %.
3. **A badge** whose value changes on a word of the beat.
4. **The action on the stressed word**: a prop drops/pops/flies at `at('<word>')`, a stamp slams, Claude hops; add an `IMPULSE` (camera kick, 0.02-0.06) for impacts.
5. **A cut accent** within +-0.1-0.3 s of the boundary: a whip (`FX.whips`, 0.04-0.1) or a zoom punch into the cut (`FX.punchIn/punchOut`, 0.05-0.12).
6. **Its sounds** in `cues.mjs`, on the same word times.

```js
// a beat: "It reads my calendar, my notes and my tasks" — a side shot, a doc flies from the phone into Claude on each noun, a counter 1-2-3
{ t: [B[3], B[4]], P: [[B[3], [3.9, 1.5, 3.0]], [B[4], [3.5, 1.4, 2.7]]], L: [[B[3], [0, 0.75, -0.7]], [B[4], [0, 0.78, -0.75]]], fov: 40, ap: 9, sh: 0.012, fit: 2.0 },
[B[3], 'CALENDAR, NOTES, TASKS'],                                                         // BANNERS
{ at: B[3] + 0.1, out: B[4] - 0.12, label: 'SOURCES READ', value: t => String(read(t)), col: COL.neutral, minW: 40 },   // BADGES
const READ = [at('calendar'), at('notes'), at('tasks')];                                 // world(t) flies doc i from READ[i]
for (const w of ['calendar', 'notes', 'tasks']) ev('swoosh', at(w), -12);                // cues.mjs
```

### 7.2 Shot library (tested positions; Claude at z -1.75, the phone at z 0.15)

| Shot | Use | Camera P (start -> end) | Look-at L | fov | ap | fit |
|---|---|---|---|---|---|---|
| Medium, props in front of Claude | hook, a list of things | (-0.35, 1.75, 4.6) -> (0.3, 1.75, 4.45) | (0, 0.55, 0.2) | 40 | 10 | 1.8 |
| High three-quarter | a change of angle on the same set | (1.3, 2.5, 4.4) -> (0.9, 2.35, 4.0) | (0, 0.6, -0.3) | 44 | 9 | 1.8 |
| Claude close-up | a key line, a prompt being typed | (0, 1.66, 3.34) -> (0, 1.48, 2.77) | (0, 0.62, -1.75) | 40 | 11 | 1.8 |
| Wide, both characters | a reaction, a summary | (-0.6, 2.0, 5.2) -> (0, 1.9, 4.6) | (0.4, 0.8, -1.3) | 46 | 7 | 1.8 |
| Establishing wide | once, early | (-1.5, 1.9, 4.0) -> (1.4, 1.95, 3.7) | (0, 0.75, -0.3) | 46 | 8 | 1.8 |
| Low along a long prop | a strip, a row of cards | (-3.75, 1.2, 2.95) -> (-3.3, 1.05, 2.85) | (0.1, 0.05, 2.15) | 38 | 11 | 1.3 |
| Side: phone -> Claude | something travels to Claude | (3.9, 1.5, 3.0) -> (3.5, 1.4, 2.7) | (0, 0.75, -0.7) | 40 | 9 | 2.0 |
| Top-down phone insert | what lands on / plays on the phone | (0.12, 3.75, 2.85) -> (-0.05, 3.55, 2.6) | (0, 0.14, 0.15) | 40 | 3-4 | 0, frameY 0.47 |
| A board in front of the window, behind Claude | a cork board, a wall of cards | (0, 2.05, 5.2) -> (0, 2.0, 4.35) | (0, 1.85, -2.9) | 50 | 6 | 1.8 |
| The city | "the internet", "everyone" | (0.75, 1.35, 1.0) -> (0.35, 1.5, 0.25) | (0, 2.4, -12) | 46 | 3 | 1.8 |
| Wall clock | time passing | (2.35, 2.75, -1.1) -> (2.65, 2.9, -1.75) | (3.15, 3.0, -3.5) | 40 | 7 | 1.0 |
| CTA, phone standing | the deliverable + comment box | (0, 1.9, 7.4) -> (0, 1.75, 6.6) | (0, 1.25, 0.9) | 44 | 6 | 1.8 |
| End pull-back | the last 2-3 s | (0, 2.5, 8.9) -> (0, 2.35, 8.2) | (0, 1.2, 0.9) | 50 | 4 | 1.8 |

Handheld `sh` 0.012 (0.006 for inserts, up to 0.03 for chaos). Shots are `{ t: [start, end], P: [[t, [x,y,z], ease?], ...], L: [...], fov, fov1?, ap, sh, fit?, frameY?, up? }`; keys ease in-out by default. The last shot runs to `END + 1`.

### 7.3 Portrait framing (the lesson that cost a re-render)

- At fov 42 a 9:16 frame is only **about 24 degrees wide**. Put props **in depth, not side by side**. A long prop gets a low camera looking **along** it.
- **Minimum framing distance:** the camera is pushed back along its line until `fit` world units fit across the frame at the look-at point. Default 1.8, so Claude (1.5 wide with arms) never fills more than ~80 % of the width. Smaller `fit` (1.0-1.4) for small props, `fit: 0` only for screen inserts. A pushed-back shot loses its push-in: when the stills print `framing: shots pushed back`, move that shot's P keys back yourself.
- **Lens shift, not tilt**: `frameY` 0.44 puts the look-at point at 44 % of the height, above the terminal and captions; 0.5 for centred inserts. Put `L` **on** the subject: it is the focus.
- Don't cut to the same framing twice: change distance by ~30 % or the angle by ~30 degrees. Keep geography: Claude centre, creator to its right, unless a cut moves them.

### 7.4 The edit follows the words

Every spoken claim gets its literal visual **at that moment**, and when the voice says something changes, the video itself does it:

| The voice says | The video does |
|---|---|
| a number | the counter ticks to it on that word |
| a thing ("my calendar") | that prop appears on that word |
| no / never / without / stopped | a red X slams onto it (stamp sound + camera kick) |
| free / done / approved / fixed | a green stamp slams; the badge turns green |
| a price | a price tag swings in; the badge shows it in red |
| "I type / I tell Claude / I say:" | the terminal types the prompt in sync and sends with an orange flash; Claude hops on send |
| warmer / aesthetic / golden | `E.look(t, { warm })` 0 -> 1 over ~1 s, a light-leak sweep, shimmer; optional `mag` masthead banner |
| darker / moody / serious | `moody` 0 -> 1, black `tape` banner, a lightning pulse, rain, darker caption tags, the music muffled (`muffle`) |
| bigger / huge / louder | captions `scale: 1.42`, or a `huge` slam |
| later / minutes / hours | the key light swings (`spin`), the clock hands spin, Claude zips around |
| lots of people / everyone | a far-away crowd of identical mini desks (a 7x7 grid of small desks with `makeClaude(..., 0.5)` at x = 80, its own lights, its own shot), then one lights up |
| comment KEYWORD | the comment box types it, posts, heart, paper plane; badge `DM SENT ✓` |

### 7.5 Pacing (measured on finished reels)

- **About 3.7 hard cuts per 10 s** (one per beat, 2-4 s shots, the longest ~5 s). `check.mjs` reports it.
- **Something changes every ~0.3 s**: a caption word, a prop, the counter, a stamp, the camera. Nothing on screen holds still for more than 0.5 s (`check.mjs` flags it).
- The camera always moves: a 4-12 % push or drift per shot plus handheld. No locked-off frames, no fast whips except as cut accents.
- No pause over 0.4 s in the voice except one deliberate beat.

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

1. **Every frame is a pure function of `t`.** No clocks, no `requestAnimationFrame`, no accumulated state, no `Math.random` (use `L.hash(i)` / `L.rng(seed)`). Any frame must render alone, in any order: the renderer reopens Chrome every 150 frames.
2. **Ease everything** (`easeOut`, `easeOutBack` for pops, `easeOutBounce` for drops, `keys()` in-out). Key times strictly increasing.
3. **Ground everything.** Claude's legs on the desk except during a hop; props rest exactly at their surface height (3.6). A prop floating a few hundredths above the desk is the most visible bug.
4. **Characters move only on a cut, or along an eased path** (a hop-walk). No teleport inside a shot.
5. **Set every prop's `visible`, position, rotation and scale in `world(t)` for every t**, not only inside its time window, or it stays where the last rendered frame left it (frames render in any order).
6. **No jitter**: no motion faster than ~2 Hz except a deliberate wave or shake; no raw audio levels driving positions.
7. **No coplanar faces**: offset stacked cards and decals by >= 0.01-0.03 units.
8. **Flashes stay under the caps**; the phone screen and the lamp are the brightest things, never a face.
9. **The camera never enters a prop** and never sees past the side city planes or under the desk; check the first and last frame of every shot.

---

## 8. Stills and the safe zone

```bash
node engine/render.mjs my-video stills 0.3,2.9,5.6,8.9,12.2,14.5,16.5 --safe
```
Always include frame 0.3 (the cover), the first and last second of every shot, every stamp/slam, and the CTA. Look at `stills/sheet-safe.jpg` (and the single stills at full size for details) and check:

- The output says `✓ all text inside the safe zone`, and no `framing: shots pushed back` line (move those P keys back).
- The hook frame reads with the sound off: subject, banner, counter, first word.
- No prop cut off at the frame edge in a way that looks accidental; no prop intersecting Claude, the creator or the phone; nothing floating.
- Faces (Claude's eyes, the creator's face) are never under the banner, badge, terminal or captions.
- Then render the same times without `--safe` and compare with `reference.jpg`: same warmth, same softness, same paper.

Expect a few rounds. One round takes about 15 s.

## 9. The full render

```bash
node engine/render.mjs my-video full 0 900       # ~0.16 s a frame on a mid-range GPU: 900 frames ~ 2.5 min
node engine/render.mjs my-video full 900         # the rest (a 55 s reel is ~1,650 frames)
```
Resumable: it skips frames that exist (> 20 KB). **After any change to scene.js or the engine, delete `frames/` (or the changed range) first.** Blank frames (a lost WebGL context) are retried in a fresh browser. Each frame's text boxes are checked against the safe zone into `frames/safe.json`.

## 10. Sound

**The kit** (`sfx/<name>.wav`, made once by `gen-sfx.mjs --yes`; the prompts are in that file; names must stay plain letters) and the levels that work, in `cues.mjs`:

| Sound | When | dB |
|---|---|---|
| `slap` | every banner change (not at 0) | -17 |
| `type` (with a max duration) / `enter` | the terminal typing / sending | -14 / -8 |
| `boing` | Claude pops up / hops | -8 / -15 |
| `clunk` | a prop lands | -6 / -7 |
| `stamp` | an X or a stamp | -6 (a big one -4) |
| `whoosh` / `swoosh` | a cut with a whip, something sweeps / flies | -9 to -11 / -12 |
| `thud` | a huge caption slam | -4 to -8 |
| `shimmer` | it turns warm / good | -7 |
| `ding` / `plane` | the comment posts / the DM flies | -10 / -10 |
| `clack`, `flick` | things snapping into a row / cards flipping | -15 / -17 |
| `clang`, `beep`, `spot`, `ff` | a bin, a UI beep, a spotlight, fast-forward | -6, -12, -6, -9 |
| `rain`, `thunder` | the moody beat | -11, -4 |

**Music**: Mixkit **"Coffee or Tea"** by Michael Ramir C. (Mixkit #1168, `https://assets.mixkit.co/music/1168/1168.mp3`). It is free under the Mixkit Stock Music Free License: personal and commercial projects including YouTube and social media, no attribution required; not for CDs/DVDs, games or TV/radio broadcast, and you may not redistribute the file or register/claim it as your own (check mixkit.co/license before monetising). Start it **8 s into the track** (`music.from`), at **-11.5 dB** (`music.db`), **ducked by the voice** (sidechain), fading out over the last 1.4 s. That puts the bed about **13 LU under the voice**; measure it (`STEM=vo` / `STEM=music node engine/mix.mjs my-video`) if you use another track. A dark beat can low-pass the bed (`music.muffle: [[t0, t1]]`).

**The mix**: voice high-pass 70 Hz, compressor (-20 dB, 2.5:1, 8/120 ms, +2 dB), +2 dB presence at 3.5 kHz; music + sound effects; normalised to **-14 LUFS integrated, true peak <= -1 dBTP** (limiter at 0.79). Re-run the mix whenever film.json or cues.mjs change: a stale effect is heard as a stray click.

## 11. Encode, check, deliver

- `encode.mjs`: H.264 High, yuv420p **TV range** (JPEG frames are full range: without the conversion players get `yuvj420p`), 30 fps, `-crf 20`, `-maxrate 10M` (lowered automatically so the file stays **<= 60 MB**), AAC 192k 48 kHz, `+faststart`; `cover.jpg` = the frame at `video.json` `cover` (the hook).
- `check.mjs` must pass: 1080x1920, 30 fps, yuv420p, High, AAC, <= 60 s, <= 60 MB, -14 +-1 LUFS, true peak <= -1; frame 0 not black or flat; no washed-out (flash) frames; no frozen stretch over 0.5 s; text inside the safe zone on every frame; it prints the cut rhythm (aim 2.5-4 per 10 s).
- Then pull ~12 frames from the final mp4 into a sheet (`ffmpeg -ss <t> -i video.mp4 -frames:v 1 ...`) and look once more, and listen to the whole mix once if you can (or transcribe it with Whisper and compare with the script).
- **Deliver** `video.mp4`, `cover.jpg`, `caption.md` to a normal folder (for example `Videos/Voxel reels/<title>/`), never overwrite an earlier version, then delete `frames/` (~100 MB). Report in a few lines: title, length, shots, the keyword and what the DM must send, any take alternates, and honestly anything you'd still fix. Don't post.

## 12. Checklist

- [ ] `setup.mjs` all green (GPU WebGL, fonts, music, sfx kit); the key present, never printed.
- [ ] Script: 120-160 words, hook in 3 s, one filmable claim per sentence, a real number per beat, a turn at ~60 %, CTA "Comment KEYWORD and I'll send you ..." for something that exists.
- [ ] `build.mjs --fake` -> scene blocked out -> stills look right.
- [ ] One voice take, listened to; `build.mjs`; every beat keyed to words (`at()`), banners on first words, badges changing inside beats.
- [ ] Stills: safe zone ✓, no pushed-back shots, nothing floating or cut off, frame 0 is a finished cover.
- [ ] Full render in foreground chunks; mix at -14 LUFS; encode <= 60 MB; `check.mjs` all ✓.
- [ ] `caption.md` (script, caption with the keyword twice, `DM:` line); delivered; frames deleted; short honest report; nothing posted.

---

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

Tested together (extracted from this file into an empty folder, `npm install`, `setup.mjs`, stills, a mix, an encode and `check.mjs`) on Windows 10 with Chrome, three.js 0.170.0, Node 22 and ffmpeg 7. Paths are relative to the project folder; the render server serves that folder as `/`.

### `engine/film.html`

```html
<!doctype html>
<html><head><meta charset="utf-8"><title>voxel</title>
<style>html,body{margin:0;background:#000;overflow:hidden}canvas{display:block}</style>
<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?.stack || e.reason)));
import('/engine/main.js');
</script>
</body></html>
```

### `engine/main.js`

```js
// Boot: fonts -> <video>/film.json + <video>/scene.js -> world, post, overlay, phone -> window.setT(t).
// film.html?v=<video folder>&t=<seconds>&safe=1
import * as THREE from 'three';
import { build, SCREEN, PHONE, poseClaude, poseCreator, blinkAt, makeClaude, makeCreator } from './world.js';
import { makePost } from './post.js';
import * as O from './overlay.js';
import * as S from './screen.js';
import * as L from './lib.js';
import * as T from './tex.js';
import * as FX from './look.js';
import { placeCamera, minDistance } from './camera.js';

const W = 1080, H = 1920, q = new URLSearchParams(location.search), video = q.get('v') || 'video';
// the installed original font if this machine has it, else the free file in /fonts (never bundle the licensed ones)
const FONTS = [['VX Sans', 'url(/fonts/InterTight.ttf)', '100 900'], ['VX Coop', 'local("Cooper Black"), local("CooperBlack"), url(/fonts/Fraunces-SuperSoft-Black.ttf)', '400'],
  ['VX Mono', 'local("Consolas Bold"), local("Consolas-Bold"), url(/fonts/JetBrainsMono.ttf)', '700'], ['VX Serif', 'local("Bodoni MT Black"), url(/fonts/BodoniModa.ttf)', '900'],
  ['VX SerifI', 'local("Bodoni MT Black Italic"), url(/fonts/BodoniModa-Italic.ttf)', '900'], ['VX UI', 'url(/fonts/Inter.ttf)', '100 900']];
for (const [name, src, weight] of FONTS) document.fonts.add(await new FontFace(name, src, { weight }).load());
const film = await (await fetch(`/${video}/film.json`, { cache: 'no-store' })).json();
const mod = await import(`/${video}/scene.js`);
window.TOTAL = film.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; document.body.appendChild(renderer.domElement);
const camera = new THREE.PerspectiveCamera(42, W / H, 0.1, 200);
const { scene, R } = build(), post = makePost(renderer, W, H);
const canvasTex = c => { const x = new THREE.CanvasTexture(c); x.generateMipmaps = false; x.minFilter = THREE.LinearFilter; return x; };
const ocv = T.cv(W, H), og = ocv.getContext('2d'), oTex = canvasTex(ocv); post.F.tO.value = oTex;                  // overlay (display space)
const pcv = T.cv(S.SW, S.SH), pg = pcv.getContext('2d'), pTex = canvasTex(pcv); R.screenU.tCanvas.value = pTex;     // phone screen
const scv = T.cv(W, H); scv.style.cssText = 'position:absolute;left:0;top:0;pointer-events:none;display:none'; document.body.appendChild(scv);
window.setSafe = on => { scv.style.display = on ? 'block' : 'none'; window.safeOn = !!on; };

const E = { THREE, R, scene, post, camera, renderer, film, END: film.END, W, H, O, S, L, T, FX, SCREEN, PHONE, minDistance,
  rig: { poseClaude, poseCreator, blinkAt, makeClaude, makeCreator }, look: (t, o) => FX.applyLook(E, t, o), V: (x, y, z) => new THREE.Vector3(x, y, z) };
const V = mod.default(E);
for (const k of ['SHOTS', 'world', 'phone', 'overlay']) if (!V[k]) throw new Error(`scene.js setup() must return ${k}`);
window.framing = {};   // shots the minimum framing distance pushed back

window.setT = t => {   // a pure function of t: any frame, any order
  t = L.clamp(t, 0, film.END);
  const cam = placeCamera(E, V.SHOTS, V.IMPULSE || [], t); if (cam.clamped) window.framing[cam.shot] = Math.max(window.framing[cam.shot] || 0, +cam.clamped.toFixed(3));
  V.world(t); V.effects ? V.effects(t) : FX.applyFx(E, t, {});
  pg.save(); V.phone(pg, t); pg.restore(); pTex.needsUpdate = true;
  og.clearRect(0, 0, W, H); O.beginFrame(); og.save(); V.overlay(og, t); og.restore(); oTex.needsUpdate = true;
  post.render(scene, camera);
  if (window.safeOn) O.drawSafe(scv.getContext('2d'));
  return true;
};
window.safeReport = () => O.violations();
window.textBoxes = () => O.boxes();
window.setSafe(q.get('safe') === '1');
window.setT(+(q.get('t') || 0));
window.__ready = true;
```

### `engine/lib.js`

```js
// Deterministic helpers: every frame 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));               // 0..1 progress of t through [a, b]
export const ss = (t, a, b) => { const k = seg(t, a, b); return k * k * (3 - 2 * k); };
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) => 1 + (s + 1) * 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; };
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); };
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;
// keyframes [[t, value, ease?], ...] (value = number or array), eased between keys; times strictly increasing
export function keys(t, K) {
  if (t <= K[0][0]) return K[0][1];
  for (let i = 1; i < K.length; i++) if (t < K[i][0]) { const k = (K[i][2] || easeInOut)(seg(t, K[i - 1][0], K[i][0])), 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];
}
// a hop from t0 lasting d, height h, with crouch and landing squash → { y, sq }
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) };
  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) };
}
```

### `engine/tex.js`

```js
// Fonts + procedural canvas textures (drawn once, deterministic). Never put real files, handles, logos or view counts on them.
import { rng } from './lib.js';
// Families are registered in main.js (local font first if installed, else the free file in fonts/).
export const F = {
  sans: s => `800 ${s}px "VX Sans"`,            // Inter Tight ExtraBold: banners, labels, in-world signs
  coop: s => `400 ${s}px "VX Coop"`,            // Cooper Black (free stand-in: Fraunces 72pt SuperSoft Black): captions, numbers
  mono: s => `700 ${s}px "VX Mono"`,            // Consolas Bold (free: JetBrains Mono Bold): terminal, file names
  serif: s => `900 ${s}px "VX Serif"`,          // Bodoni MT Black (free: Bodoni Moda Black): magazine masthead
  serifI: s => `900 ${s}px "VX SerifI"`,        // Bodoni MT Black Italic (free: Bodoni Moda Black Italic)
  ui: (s, w = 400) => `${w} ${s}px "Segoe UI", "VX UI", system-ui, sans-serif`,   // the comment box (Instagram-like UI)
};
export function cv(w, h) { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; }

// blotchy grey value noise lo..hi; used as the `map` of every material, so flat colours look hand-painted
export function noiseCanvas(size = 256, seed = 1, lo = 0.78, hi = 1.0, scale = 6) {
  const c = cv(size, size), g = c.getContext('2d'), img = g.createImageData(size, size), R = rng(seed);
  const grid = n => Array.from({ length: (n + 1) * (n + 1) }, () => R());
  const oct = [[scale, grid(scale), 0.55], [scale * 2, grid(scale * 2), 0.27], [scale * 5, grid(scale * 5), 0.12], [size / 2, grid(size / 2), 0.06]];
  for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
    let v = 0;
    for (const [n, G, w] of oct) {
      const fx = x / size * n, fy = y / size * n, ix = Math.floor(fx), iy = Math.floor(fy), ux = fx - ix, uy = fy - iy;
      const sx = ux * ux * (3 - 2 * ux), sy = uy * uy * (3 - 2 * uy), at = (i, j) => G[(j % n) * (n + 1) + (i % n)];
      v += w * ((at(ix, iy) * (1 - sx) + at(ix + 1, iy) * sx) * (1 - sy) + (at(ix, iy + 1) * (1 - sx) + at(ix + 1, iy + 1) * sx) * sy);
    }
    const k = Math.round(255 * (lo + (hi - lo) * Math.min(1, Math.max(0, (v - 0.25) * 1.6)))), p = (y * size + x) * 4;
    img.data[p] = img.data[p + 1] = img.data[p + 2] = k; img.data[p + 3] = 255;
  }
  g.putImageData(img, 0, 0); return c;
}
// warm wooden planks (desk top)
export function woodCanvas() {
  const W = 2048, H = 1024, c = cv(W, H), g = c.getContext('2d'), R = rng(11), ph = H / 9;
  for (let i = 0; i < 9; i++) {
    for (let x = -R() * 600; x < W;) {
      const len = 500 + R() * 700; g.fillStyle = `rgb(${Math.round(138 + R() * 26)},${Math.round(78 + R() * 16)},${Math.round(44 + R() * 12)})`; g.fillRect(x, i * ph, len, ph);
      for (let k = 0; k < 26; k++) { const y = i * ph + R() * ph, a = 0.05 + R() * 0.12; g.strokeStyle = R() < 0.5 ? `rgba(70,35,18,${a})` : `rgba(200,140,90,${a * 0.7})`; g.lineWidth = 1 + R() * 2.5;
        g.beginPath(); g.moveTo(x, y); for (let s = 0; s <= len; s += 60) g.lineTo(x + s, y + Math.sin(s * 0.01 + k) * 3 * R()); g.stroke(); }
      g.fillStyle = 'rgba(40,18,8,0.55)'; g.fillRect(x, i * ph, 4, ph); x += len;
    }
    g.fillStyle = 'rgba(35,15,6,0.7)'; g.fillRect(0, i * ph, W, 5); g.fillStyle = 'rgba(255,200,150,0.08)'; g.fillRect(0, i * ph + 5, W, 3);
  }
  return c;
}
// the skyline behind the window: 'night' (default), 'sunset' (warm), 'moody' (dark blue)
export function cityCanvas(mode) {
  const W = 2048, H = 1024, c = cv(W, H), g = c.getContext('2d'), R = rng({ night: 3, sunset: 4, moody: 5 }[mode]);
  const sky = g.createLinearGradient(0, 0, 0, H);
  ({ night: [[0, '#141a3e'], [1, '#27306a']], sunset: [[0, '#6d4d8f'], [0.45, '#e9857a'], [0.8, '#ffc38a'], [1, '#ffe2a8']], moody: [[0, '#0b1222'], [1, '#22304c']] })[mode].forEach(([o, col]) => sky.addColorStop(o, col));
  g.fillStyle = sky; g.fillRect(0, 0, W, H);
  if (mode === 'sunset') { const s = g.createRadialGradient(W * 0.62, H * 0.78, 10, W * 0.62, H * 0.78, 420); s.addColorStop(0, 'rgba(255,240,200,0.95)'); s.addColorStop(0.2, 'rgba(255,200,140,0.6)'); s.addColorStop(1, 'rgba(255,170,120,0)'); g.fillStyle = s; g.fillRect(0, 0, W, H); }
  for (const layer of [0, 1]) for (let x = -20; x < W;) {
    const bw = 46 + R() * 110, bh = (layer ? 380 : 520) + R() * (layer ? 380 : 440), top = H - bh;
    g.fillStyle = { night: layer ? '#11173a' : '#1a2152', sunset: layer ? '#5b3149' : '#8a4a5e', moody: layer ? '#0a0f1c' : '#121a2c' }[mode]; g.fillRect(x, top, bw, bh);
    for (let yy = top + 10; yy < H - 6; yy += 15) for (let xx = x + 6; xx < x + bw - 7; xx += 10) {      // 5x8 px windows
      const r = R(); if (!(mode === 'night' ? r < 0.42 : mode === 'sunset' ? r < 0.12 : r < 0.16)) continue;
      const pal = mode === 'moody' ? ['#5d7aa8', '#3e5680'] : mode === 'sunset' ? ['#ffd9a0', '#ffb978'] : ['#ffd27a', '#ffb05a', '#ffe7a8', '#8fb4ff', '#ff8f5a'];
      g.fillStyle = pal[Math.floor(R() * pal.length)]; g.globalAlpha = (layer ? 0.55 : 0.95) * (0.6 + R() * 0.4); g.fillRect(xx, yy, 5, 8); g.globalAlpha = 1;
    }
    x += bw + 3 + R() * 14;
  }
  return c;
}
// the navy desk mat under the phone, orange border, faint grid
export function matCanvas() {
  const c = cv(512, 740), g = c.getContext('2d'); g.fillStyle = '#1c2045'; g.fillRect(0, 0, 512, 740);
  g.strokeStyle = '#e8873f'; g.lineWidth = 10; g.strokeRect(14, 14, 484, 712); g.strokeStyle = 'rgba(255,255,255,0.05)'; g.lineWidth = 1;
  for (let x = 40; x < 512; x += 40) { g.beginPath(); g.moveTo(x, 20); g.lineTo(x, 720); g.stroke(); }
  for (let y = 40; y < 740; y += 40) { g.beginPath(); g.moveTo(20, y); g.lineTo(492, y); g.stroke(); }
  return c;
}
// paper fibres, multiplied over the 2D paper shapes
export function paperCanvas(seed = 5, base = 236, spread = 26) {
  const W = 1024, H = 256, c = cv(W, H), g = c.getContext('2d'), img = g.createImageData(W, H), R = rng(seed);
  for (let i = 0; i < W * H; i++) { const v = base - R() * spread * 0.5; img.data[i * 4] = img.data[i * 4 + 1] = v; img.data[i * 4 + 2] = v - 3; img.data[i * 4 + 3] = 255; }
  g.putImageData(img, 0, 0);
  for (let k = 0; k < 90; k++) { g.strokeStyle = `rgba(120,110,95,${0.05 + R() * 0.08})`; g.lineWidth = 1 + R() * 2; g.beginPath(); let x = R() * W, y = R() * H; g.moveTo(x, y); for (let s = 0; s < 6; s++) { x += (R() - 0.5) * 160; y += (R() - 0.5) * 40; g.lineTo(x, y); } g.stroke(); }
  for (let k = 0; k < 40; k++) { const x = R() * W, y = R() * H, r = 20 + R() * 90, gr = g.createRadialGradient(x, y, 0, x, y, r); gr.addColorStop(0, `rgba(255,255,255,${R() * 0.18})`); gr.addColorStop(1, 'rgba(255,255,255,0)'); g.fillStyle = gr; g.fillRect(x - r, y - r, r * 2, r * 2); }
  return c;
}

// ---------- prop faces (a video's props are boxes with one of these on the front) ----------
const rr = (g, x, y, w, h, q) => { g.beginPath(); g.roundRect(x, y, w, h, q); };
// app tile: { label, bg: [c0, c1], icon: 'play' | 'x' | any short text like '$' or '?', ink, size }
export function tileCanvas(o = {}) {
  const c = cv(512, 512), g = c.getContext('2d'), [c0, c1] = o.bg || ['#e0702f', '#b8451c'], ink = o.ink || '#ffffff';
  const gr = g.createLinearGradient(0, 0, 512, 512); gr.addColorStop(0, c0); gr.addColorStop(1, c1); g.fillStyle = gr; rr(g, 0, 0, 512, 512, 90); g.fill();
  g.fillStyle = ink; g.strokeStyle = ink; g.lineCap = 'round'; g.lineJoin = 'round'; g.lineWidth = 30; g.textAlign = 'center';
  if (o.icon === 'play') { g.beginPath(); g.moveTo(205, 140); g.lineTo(335, 220); g.lineTo(205, 300); g.closePath(); g.fill(); }
  else if (o.icon === 'x') { g.beginPath(); g.moveTo(170, 140); g.lineTo(342, 300); g.moveTo(342, 140); g.lineTo(170, 300); g.stroke(); }
  else { g.font = F.sans(170); g.fillText(String(o.icon ?? '?'), 256, 300); }
  g.font = F.sans(o.size || 44); g.fillText(o.label || '', 256, 440, 470);
  return c;
}
// a paper document card: coloured header with a title, a mono subtitle, then grey text lines or labelled rows. { title, sub, head, lines, rows }
export function docCanvas(o = {}) {
  const c = cv(600, 780), g = c.getContext('2d'), R = rng(o.seed || 7);
  g.fillStyle = '#f7f3ea'; g.fillRect(0, 0, 600, 780); g.fillStyle = o.head || '#e0702f'; g.fillRect(0, 0, 600, 150);
  g.fillStyle = '#fff'; g.font = F.sans(84); g.textAlign = 'left'; g.textBaseline = 'alphabetic'; g.fillText(o.title || 'SKILL.md', 40, 110, 520);
  g.fillStyle = '#2b2420'; g.font = F.mono(40); g.fillText(o.sub || '# format', 40, 230, 520);
  const rows = o.rows || Array(o.lines || 7).fill(''), step = Math.min(62, 470 / rows.length);
  rows.forEach((label, i) => { const x = label ? 150 : 40; if (label) { g.fillStyle = '#2b2420'; g.font = F.mono(30); g.fillText(label, 40, 300 + i * step + 18); }
    g.fillStyle = i % 3 === 0 ? '#c9c2b4' : '#ddd7cb'; g.fillRect(x, 290 + i * step, (560 - x) * (0.55 + R() * 0.45), 22); });
  return c;
}
// a rubber stamp (double border, distressed ink): stamp('FREE', '#2fae5a')
export function stampCanvas(txt, col = '#2fae5a') {
  const c = cv(640, 220), g = c.getContext('2d'); g.strokeStyle = col; g.lineWidth = 16; g.strokeRect(14, 14, 612, 192); g.lineWidth = 5; g.strokeRect(34, 34, 572, 152);
  g.fillStyle = col; g.font = F.sans(120); g.textAlign = 'center'; g.fillText(txt, 320, 156, 540);
  const d = g.getImageData(0, 0, 640, 220), R = rng(77); for (let k = 0; k < d.data.length; k += 4) if (R() < 0.18) d.data[k + 3] *= 0.4; g.putImageData(d, 0, 0);
  return c;
}
// a price tag with a hole and a string knot: tagPriceCanvas('$30')
export function tagPriceCanvas(txt, ink = '#d6452b') {
  const c = cv(512, 256), g = c.getContext('2d');
  g.fillStyle = '#fbf7ef'; g.beginPath(); g.moveTo(90, 20); g.lineTo(492, 20); g.lineTo(492, 236); g.lineTo(90, 236); g.lineTo(14, 128); g.closePath(); g.fill();
  g.fillStyle = '#2b2420'; g.beginPath(); g.arc(70, 128, 16, 0, 7); g.fill();
  g.fillStyle = ink; g.font = F.coop(150); g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(txt, 300, 136, 360); return c;
}
// a label/sign: labelCanvas('HIRING MANAGER', { bg: '#141414', fg: '#f3eee4' })
export function labelCanvas(text, { w = 512, h = 128, bg = '#f6efe2', fg = '#1a1a1a', font = F.sans, size = 64 } = {}) {
  const c = cv(w, h), g = c.getContext('2d'); if (bg) { g.fillStyle = bg; g.fillRect(0, 0, w, h); }
  g.fillStyle = fg; g.font = font(size); g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(text, w / 2, h / 2 + 2, w - 24); return c;
}
```

### `engine/world.js`

```js
// The set: a voxel desk at night by a big window over a lit city, the phone, Claude, the creator, lights.
// Units: the desk top is y = 0, +z points at the camera, Claude stands at z = -1.75. build() -> { scene, R }.
import * as THREE from 'three';
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';
import * as T from './tex.js';
import { hash } from './lib.js';

export const SCREEN = { w: 1.43, h: 2.54 };                      // phone screen (9:16), canvas 540x960
export const PHONE = { w: 1.55, d: 2.76, th: 0.13, z: 0.15 };     // phone lying on the desk, centred at z 0.15

export function build() {
  const scene = new THREE.Scene(), R = {};
  const tex = (c, { srgb = true, rep = null } = {}) => { const x = new THREE.CanvasTexture(c); if (srgb) x.colorSpace = THREE.SRGBColorSpace; x.anisotropy = 8; if (rep) { x.wrapS = x.wrapT = THREE.RepeatWrapping; x.repeat.set(...rep); } return x; };
  const N1 = tex(T.noiseCanvas(256, 1, 0.74, 1.0, 5)), N2 = tex(T.noiseCanvas(256, 2, 0.82, 1.0, 9)), N3 = tex(T.noiseCanvas(256, 3, 0.66, 1.0, 3));
  // every material: matte, the colour multiplied by a blotchy noise map (the painted-voxel look)
  const mat = (color, o = {}) => new THREE.MeshStandardMaterial({ color, roughness: o.rough ?? 0.82, metalness: o.metal ?? 0, map: o.map === null ? null : (o.map || N1),
    emissive: o.emissive || 0x000000, emissiveIntensity: o.ei ?? 1, transparent: !!o.transparent, opacity: o.opacity ?? 1, side: o.side || THREE.FrontSide });
  const box = (w, h, d, m, r = 0) => { const x = new THREE.Mesh(r ? new RoundedBoxGeometry(w, h, d, 3, r) : new THREE.BoxGeometry(w, h, d), m); x.castShadow = x.receiveShadow = true; return x; };
  const add = (o, x, y, z) => { o.position.set(x, y, z); scene.add(o); return o; };
  Object.assign(R, { mat, box, tex, N1, N2, N3 });

  // ---------- desk ----------
  add(box(10, 0.3, 6.2, mat(0xa77a62, { map: tex(T.woodCanvas(), { rep: [1.4, 1.2] }), rough: 0.7 })), 0, -0.15, -0.1);
  add(box(10, 1.6, 0.3, mat(0x4a2716, { map: N3 })), 0, -1.1, 3.0);
  add(box(2.5, 0.012, 3.65, mat(0xffffff, { map: tex(T.matCanvas()), rough: 0.9 })), 0, 0.006, PHONE.z);
  for (const [x, z, ry, col] of [[-2.35, 1.05, 0.25, 0xf7d548], [2.55, 1.55, -0.4, 0xf7d548], [-2.75, -0.4, -0.15, 0xf5a3c7]]) { const s = add(box(0.5, 0.01, 0.5, mat(col, { map: N2 })), x, 0.006, z); s.rotation.y = ry; }
  R.mug = new THREE.Group();
  { const m = new THREE.Mesh(new THREE.CylinderGeometry(0.26, 0.24, 0.55, 20), mat(0xf3efe6)); m.position.y = 0.275; m.castShadow = true;
    const band = new THREE.Mesh(new THREE.CylinderGeometry(0.262, 0.262, 0.12, 20), mat(0xe8873f)); band.position.y = 0.3;
    const hdl = new THREE.Mesh(new THREE.TorusGeometry(0.13, 0.04, 8, 16), mat(0xf3efe6)); hdl.position.set(0.3, 0.3, 0); R.mug.add(m, band, hdl); }
  add(R.mug, 3.0, 0, -0.2);

  // ---------- window, city, rain, wall clock ----------
  const cityM = ['night', 'sunset', 'moody'].map((m, i) => new THREE.MeshBasicMaterial({ map: tex(T.cityCanvas(m)), transparent: i > 0, opacity: i ? 0 : 1, depthWrite: i === 0, fog: false }));
  R.city = cityM.map((m, i) => add(new THREE.Mesh(new THREE.PlaneGeometry(90, 45), m), 0, 9, -32 + i * 0.05));
  for (const sx of [-1, 1]) { const p = add(new THREE.Mesh(new THREE.PlaneGeometry(70, 40), new THREE.MeshBasicMaterial({ map: cityM[0].map, fog: false })), sx * 22, 7, -4); p.rotation.y = -sx * Math.PI / 2; }
  const frameM = mat(0x161a33, { map: N3, rough: 0.6 });
  for (const x of [-4.8, -1.75, 1.75, 4.8]) add(box(0.24, 12, 0.3, frameM), x, 5.5, -3.7);       // mullions
  for (const y of [0.15, 5.2, 10.4]) add(box(14, 0.22, 0.3, frameM), 0, y, -3.7);                 // transoms
  add(box(14, 0.3, 0.9, mat(0x232845, { map: N3 })), 0, -0.1, -3.5);                             // sill
  R.rainU = { t: { value: 0 }, a: { value: 0 } };
  add(new THREE.Mesh(new THREE.PlaneGeometry(14, 12), new THREE.ShaderMaterial({ uniforms: R.rainU, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
    vertexShader: 'varying vec2 vUv; void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.); }',
    fragmentShader: `varying vec2 vUv; uniform float t, a; float h(float n){ return fract(sin(n*127.1)*43758.5453); }
      void main(){ vec2 uv=vUv*vec2(36.,1.); float col=floor(uv.x); float y=fract(vUv.y*1.3+t*(1.6+h(col)*1.4)+h(col+3.)*7.);
        float streak=smoothstep(0.,0.02,y)*smoothstep(0.16,0.,y)*smoothstep(0.5,0.3,abs(fract(uv.x)-0.5));
        vec2 g=fract(vUv*vec2(38.,24.))-0.5; vec2 id=floor(vUv*vec2(38.,24.)); float drop=h(id.x*7.+id.y*13.)>0.86 ? smoothstep(0.12,0.04,length(g*vec2(1.,1.4))) : 0.;
        gl_FragColor=vec4(vec3(0.55,0.68,0.95)*(streak*1.3+drop*0.8)*a,1.); }` })), 0, 5.2, -3.45);
  R.clock = new THREE.Group();
  { const face = new THREE.Mesh(new THREE.CylinderGeometry(0.62, 0.62, 0.12, 40), mat(0xf6f1e6, { map: N2 })); face.rotation.x = Math.PI / 2;
    R.clock.add(face, new THREE.Mesh(new THREE.TorusGeometry(0.62, 0.07, 10, 40), mat(0xe8873f)));
    for (let k = 0; k < 12; k++) { const tk = box(0.04, 0.12, 0.02, mat(0x222222, { map: null })), a = k / 12 * Math.PI * 2; tk.position.set(Math.sin(a) * 0.5, Math.cos(a) * 0.5, 0.07); tk.rotation.z = -a; R.clock.add(tk); }
    const hand = (w, l, col, z) => { const g = new THREE.Group(), h = box(w, l, 0.02, mat(col, { map: null })); h.position.y = l * 0.44; g.add(h); g.position.z = z; R.clock.add(g); return g; };
    R.hourHand = hand(0.06, 0.32, 0x1a1a1a, 0.085); R.minHand = hand(0.04, 0.46, 0xd9482b, 0.1); }
  add(R.clock, 3.15, 3.0, -3.5);

  // ---------- desk lamp ----------
  R.lamp = new THREE.Group();
  { const lm = mat(0x2a2f55, { map: N3, rough: 0.5 }), part = (o, x, y, rz) => { o.position.set(x, y, 0); o.rotation.z = rz; o.castShadow = true; R.lamp.add(o); };
    part(new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.48, 0.12, 24), lm), 0, 0.06, 0); part(box(0.1, 1.5, 0.1, lm), 0, 0.8, 0.25); part(box(0.1, 1.2, 0.1, lm), 0.32, 1.8, -0.9);
    part(new THREE.Mesh(new THREE.ConeGeometry(0.42, 0.55, 24, 1, true), mat(0xe8873f, { side: THREE.DoubleSide })), 0.9, 2.05, -0.55);
    R.bulbM = new THREE.MeshBasicMaterial({ color: 0xfff1d0 }); const bulb = new THREE.Mesh(new THREE.SphereGeometry(0.16, 16, 12), R.bulbM); bulb.position.set(1.02, 1.86, 0); R.lamp.add(bulb); }
  add(R.lamp, -3.3, 0, -1.7).rotation.y = 0.5;

  // ---------- lights (look.js re-colours them every frame) ----------
  R.hemi = new THREE.HemisphereLight(0x7f8fe0, 0x2a1a24, 0.9); scene.add(R.hemi);
  R.key = new THREE.SpotLight(0xffd8b0, 60, 30, 0.62, 0.55, 1.6); R.key.position.set(4.2, 8.5, 6.5); R.key.castShadow = true;
  Object.assign(R.key.shadow, { bias: -0.0004, normalBias: 0.02, radius: 4 }); R.key.shadow.mapSize.set(2048, 2048); R.key.shadow.camera.near = 3; R.key.shadow.camera.far = 25;
  scene.add(R.key, R.key.target);
  R.rim = new THREE.DirectionalLight(0x7f9cff, 1.6); R.rim.position.set(-2, 5, -9); scene.add(R.rim);           // cool back light from the window
  R.lampL = new THREE.PointLight(0xffb36b, 7, 9, 1.6); R.lampL.position.set(-2.4, 1.9, -1.2); scene.add(R.lampL);
  R.fill = new THREE.DirectionalLight(0xffc9a0, 0.5); R.fill.position.set(-4, 3, 6); scene.add(R.fill);
  R.flash = new THREE.DirectionalLight(0xdfe8ff, 0); R.flash.position.set(0, 9, -6); scene.add(R.flash);        // lightning
  scene.fog = new THREE.Fog(0x0e1230, 30, 70);

  // ---------- the phone (its screen is a 540x960 canvas drawn every frame by the video's phone(g, t)) ----------
  R.phonePivot = add(new THREE.Group(), 0, 0.012, PHONE.z + PHONE.d / 2);                          // rotate .x to stand it up
  R.phone = new THREE.Group(); R.phone.position.set(0, 0, -PHONE.d / 2); R.phonePivot.add(R.phone);
  { const body = box(PHONE.w, PHONE.th, PHONE.d, mat(0x1d1e24, { rough: 0.35, metal: 0.3, map: N2 }), 0.06); body.position.y = PHONE.th / 2; R.phone.add(body);
    const cam = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.01, 16), new THREE.MeshBasicMaterial({ color: 0x050505 })); cam.position.set(0, PHONE.th + 0.003, -PHONE.d / 2 + 0.07); R.phone.add(cam); }
  R.screenU = { tCanvas: { value: null }, bright: { value: 1 } };
  R.screen = new THREE.Mesh(new THREE.PlaneGeometry(SCREEN.w, SCREEN.h), new THREE.ShaderMaterial({ uniforms: R.screenU,
    vertexShader: 'varying vec2 vUv; void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.); }',
    fragmentShader: 'varying vec2 vUv; uniform sampler2D tCanvas; uniform float bright; void main(){ gl_FragColor=vec4(pow(texture2D(tCanvas,vUv).rgb, vec3(2.2))*bright,1.); }' }));
  R.screen.rotation.x = -Math.PI / 2; R.screen.position.y = PHONE.th + 0.002; R.phone.add(R.screen);

  // ---------- characters ----------
  R.claude = makeClaude(mat, box, 0xe8845c); scene.add(R.claude.root);
  R.creator = makeCreator(mat, box); scene.add(R.creator.root);

  // ---------- prop helpers for a video's setup() ----------
  // a flat card (paper, sign, document) with a canvas on its front face; w x h world units, d thick
  R.card = (canvas, w, h, d = 0.02, side = 0xf3eee4) => { const s = mat(side, { map: N2 }); const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), [s, s, s, s, new THREE.MeshStandardMaterial({ map: tex(canvas), roughness: 0.8 }), s]); m.castShadow = m.receiveShadow = true; scene.add(m); return m; };
  // an app tile: rounded block, canvas face (T.tileCanvas)
  R.tile = o => { const s = mat(0x2a2a30, { rough: 0.5 }); const m = new THREE.Mesh(new RoundedBoxGeometry(0.95, 0.95, 0.16, 3, 0.08), [s, s, s, s, new THREE.MeshStandardMaterial({ map: tex(T.tileCanvas(o)), roughness: 0.45 }), s]); m.castShadow = true; scene.add(m); return m; };
  // a transparent decal (stamp, X, check mark, price tag) that always faces where you point it
  R.decal = (canvas, w, h) => { const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ map: tex(canvas), transparent: true, depthWrite: false })); scene.add(m); return m; };
  return { scene, R };
}

// Claude: an orange block (1.12 x 0.72 x 0.78, rounded 0.05) on four stubby legs, two tall black eyes, two stub arms. No mouth.
export function makeClaude(mat, box, color = 0xe8845c, s = 1) {
  const root = new THREE.Group(), pivot = new THREE.Group(), face = new THREE.Group(); root.add(pivot); root.scale.setScalar(s);
  const NC = new THREE.CanvasTexture(T.noiseCanvas(256, 4, 0.86, 1.0, 4)); NC.colorSpace = THREE.SRGBColorSpace;
  const body = box(1.12, 0.72, 0.78, mat(color, { rough: 0.85, map: NC }), 0.05); body.position.y = 0.56; pivot.add(body);
  pivot.add(face); face.position.set(0, 0.62, 0.395);
  const em = new THREE.MeshStandardMaterial({ color: 0x111014, roughness: 0.4 });
  const eyes = [-0.2, 0.2].map(x => { const e = box(0.1, 0.19, 0.03, em); e.position.set(x, 0, 0.01); face.add(e); return e; });
  const arms = [-1, 1].map(sx => { const a = new THREE.Group(); a.position.set(sx * 0.56, 0.5, 0); const m = box(0.2, 0.16, 0.2, mat(color, { map: NC }), 0.03); m.position.x = sx * 0.09; a.add(m); pivot.add(a); return a; });
  const legs = [-0.39, -0.13, 0.13, 0.39].map(x => { const l = box(0.12, 0.22, 0.14, mat(color, { map: null }), 0.02); l.position.set(x, 0.11, 0.02); root.add(l); return l; });
  return { root, pivot, face, eyes, arms, legs, body };
}
// The creator ("me"): blocky, cream hoodie with a pocket, skin head, blue cap with a brim, navy jeans. 1.66 tall.
export function makeCreator(mat, box, s = 1, c = {}) {   // c: { hood, cap, jeans, skin } for extra characters (a rival in a red cap...)
  const root = new THREE.Group(); root.scale.setScalar(s);
  const NC = new THREE.CanvasTexture(T.noiseCanvas(256, 6, 0.9, 1.0, 4)); NC.colorSpace = THREE.SRGBColorSpace;
  const m = x => mat(x, { map: NC }), jeans = m(c.jeans ?? 0x2a3350), hood = m(c.hood ?? 0xf3ecdc), skin = m(c.skin ?? 0xffd9b5), cap = m(c.cap ?? 0x3b6fd8);
  const legs = [-0.15, 0.15].map(x => { const l = box(0.22, 0.42, 0.24, jeans, 0.03); l.position.set(x, 0.21, 0); root.add(l); return l; });
  const torso = new THREE.Group(); torso.position.y = 0.42; root.add(torso);
  const chest = box(0.78, 0.66, 0.48, hood, 0.05); chest.position.y = 0.33; torso.add(chest);
  const pocket = box(0.42, 0.16, 0.02, mat(0xd9ceb8)); pocket.position.set(0, 0.16, 0.25); torso.add(pocket);
  const head = new THREE.Group(); head.position.y = 0.66; torso.add(head);
  const h = box(0.62, 0.58, 0.56, skin, 0.05); h.position.y = 0.3; head.add(h);
  const c1 = box(0.66, 0.18, 0.6, cap, 0.04); c1.position.y = 0.62; head.add(c1);
  const brim = box(0.5, 0.05, 0.3, cap, 0.02); brim.position.set(0, 0.55, 0.38); head.add(brim);
  const em = new THREE.MeshStandardMaterial({ color: 0x111014, roughness: 0.4 });
  const eyes = [-0.14, 0.14].map(x => { const e = box(0.08, 0.13, 0.03, em); e.position.set(x, 0.33, 0.285); head.add(e); return e; });
  const arms = [-1, 1].map(sx => { const a = new THREE.Group(); a.position.set(sx * 0.48, 0.6, 0); const sl = box(0.18, 0.56, 0.2, hood, 0.04); sl.position.y = -0.26; a.add(sl);
    const hand = box(0.16, 0.14, 0.18, skin, 0.03); hand.position.y = -0.58; a.add(hand); torso.add(a); return a; });
  return { root, torso, head, eyes, arms, legs };
}
// pose Claude from a plain object (every field optional):
// { x, y, z, yaw, roll, sq (squash/stretch), ex, ey (eye offset -1..1), es (eye scale), blink, aL, aR (arm raise, rad), walk (phase) }
export function poseClaude(c, o) {
  c.root.position.set(o.x || 0, o.y || 0, o.z ?? -1.75); c.root.rotation.set(0, o.yaw || 0, o.roll || 0);
  const sq = o.sq || 0; c.pivot.scale.set(1 - sq * 0.55, 1 + sq, 1 - sq * 0.55);
  c.face.position.set((o.ex || 0) * 0.07, 0.62 + (o.ey || 0) * 0.05, 0.395);
  c.eyes.forEach(e => { e.scale.y = o.blink ? 0.12 : (o.es || 1); });
  c.arms[0].rotation.z = -(o.aL || 0); c.arms[1].rotation.z = o.aR || 0;
  c.legs.forEach((l, i) => { l.position.y = 0.11 + (o.walk != null ? Math.max(0, Math.sin(o.walk + i * 1.7)) * 0.07 : 0); });
}
// pose the creator: { x, y, z, yaw, nod, hy (head yaw), tilt, fL, fR (arm forward), sL, sR (arm out to the side), blink, bob }
export function poseCreator(cr, o) {
  cr.root.position.set(o.x ?? 1.15, o.y || 0, o.z ?? -1.25); cr.root.rotation.y = o.yaw ?? -0.38;
  cr.head.rotation.set(o.nod || 0, o.hy || 0, o.tilt || 0);
  cr.arms[0].rotation.set(-(o.fL || 0), 0, -(o.sL || 0)); cr.arms[1].rotation.set(-(o.fR || 0), 0, o.sR || 0);
  cr.eyes.forEach(e => { e.scale.y = o.blink ? 0.12 : 1; }); cr.torso.position.y = 0.42 + (o.bob || 0);
}
export const blinkAt = (t, seed) => (t + hash(seed + 1) * 3) % (2.6 + hash(seed) * 1.6) < 0.11;   // a blink every 2.6-4.2 s
```

### `engine/post.js`

```js
// Post: scene (linear HDR, 4x MSAA, depth) -> bloom (bright pass + 3 blur levels) -> comp (DOF, bloom, warm/moody grade, ACES, gamma)
// -> final (whip/zoom blur, chromatic edges, light leak, vignette, capped flash, the 2D overlay, grain).
import * as THREE from 'three';
export const FLASH_MAX = 0.24;   // strongest full-frame flash. 0.55 washed whole frames white.
export const GRAIN = 0.028;      // film grain. 0.045 doubled the file size (113 MB for 55 s): don't raise it.
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), quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2)), fs = new THREE.Scene(); fs.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(fs, 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 }), rtBright = rt(W / 2, H / 2), rtC = rt(W, H);
  const levels = [4, 8, 16].map(d => [rt(W / d, H / d), rt(W / d, H / d)]);

  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.9 } });
  const downM = pass(`varying vec2 vUv; uniform sampler2D t; uniform vec2 px;
    void main(){ gl_FragColor = vec4((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) * .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() } });

  // U.focus = distance camera -> look-at point; U.aperture = the shot's `ap` (blur px per unit of |1 - focus/z|), capped at maxBlur px
  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: 9 }, maxBlur: { value: 16 },
    bloom: { value: 0.5 }, 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 aces(vec3 x){ return clamp((x*(2.51*x+0.03))/(x*(2.43*x+0.59)+0.14), 0., 1.); }
    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++) {                       // 40-tap golden-angle disc
        float r = sqrt(float(i) / 40.), a = float(i) * 2.39996; vec2 o = vec2(cos(a), sin(a)) * r * c0 / res;
        float sz = linZ(texture2D(tDepth, vUv + o).x), sc = coc(sz);
        float w = sz < z ? smoothstep(r * c0 - 1.5, r * c0, sc + 1.) : 1.;   // sharp foreground doesn't smear over the background
        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 = pow(aces(col), vec3(1. / 2.2));
      float l = dot(col, vec3(0.2126, 0.7152, 0.0722)); col = mix(vec3(l), col, 1.1 - 0.05 * wA - 0.5 * wM);   // saturation 1.1
      col = (col - 0.5) * (1.04 - 0.08 * wA + 0.22 * wM) + 0.5;                                                 // contrast 1.04
      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 }, oShift: { value: new THREE.Vector2() }, res: { value: new THREE.Vector2(W, H) },
    ca: { value: 1.7 }, vig: { value: 0.32 }, 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 }, 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 ca, vig, grain, time, zoom, leak, leakX, flash, 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; vec2 off = d * dot(d, d) * ca * 24.0 / res.x;                 // chromatic edges, 0 in the centre
      return vec3(texture2D(tC, uv + off).r, texture2D(tC, uv).g, texture2D(tC, uv - off).b); }
    void main(){
      vec2 uv = vUv; vec3 col;
      if (length(mbDir) + zoom > 0.0005) { col = vec3(0.); for (int k = 0; k < 16; k++) { float f = float(k) / 15. - 0.5; col += caS(uv + mbDir * f + (uv - zoomC) * zoom * f); } col /= 16.; }
      else col = caS(uv);
      if (leak > 0.) { float x = uv.x * 0.8 + uv.y * 0.35 - leakX; col += mix(vec3(1.0, 0.55, 0.25), vec3(1.0, 0.85, 0.55), uv.y) * exp(-x * x * 9.) * leak * 0.9; col = mix(col, col * vec3(1.06, 1.0, 0.92), leak * 0.4); }
      vec2 d = uv - 0.5; d.x *= res.x / res.y; col *= mix(1. - vig - 0.25 * wM, 1., smoothstep(0.95, 0.25, length(d) * (1.0 + 0.35 * wM)));
      col = mix(col, 1. - (1. - col) * (1. - col * vec3(0.18, 0.08, 0.02)), wA * 0.6);                   // warm halation
      col += flashCol * min(flash, ${FLASH_MAX.toFixed(2)}) * (1.0 - 0.55 * col);
      vec4 o = texture2D(tO, uv - oShift / res); col = mix(col, o.rgb, o.a);                              // the 2D overlay (straight alpha)
      col += (h(uv * res + fract(time * 7.13) * 91.7) - 0.5) * grain;
      gl_FragColor = vec4(clamp(col * fade, 0., 1.), 1.); }`, F);

  function render(scene, camera) {
    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) {
      downM.uniforms.t.value = src.texture; downM.uniforms.px.value.set(1 / src.width, 1 / src.height); run(downM, 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, null);
  }
  return { U, F, render };
}
```

### `engine/camera.js`

```js
// The camera from a video's SHOTS. A shot:
// { t: [start, end], P: [[t, [x,y,z], ease?], ...] camera keys, L: [[t, [x,y,z]], ...] look-at keys ON the subject (= focus distance),
//   fov (vertical deg), fov1? (fov at the end), ap (DOF aperture 3-12), sh (handheld 0.006-0.03), up?, fit?, frameY? }
// fit: world units that must fit across the frame at L (default 1.8). If P is closer, the camera is pushed back along its line.
// frameY: where L lands on screen as a fraction of the height (default 0.44: above the terminal and the caption band). 0.5 = centre.
import { lerp, seg, easeInOut, fbm1, keys } from './lib.js';
export const FRAME_Y = 0.44, FIT = 1.8;
export const minDistance = (fov, aspect, fit = FIT) => fit / (2 * Math.tan(fov * Math.PI / 360) * aspect);   // portrait: width is the tight side
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; };

export function placeCamera(E, SHOTS, IMPULSE, t) {
  const { camera, post } = E, i = shotIndex(SHOTS, t), s = SHOTS[i];
  let p = keys(t, s.P); const l = keys(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;                                                     // handheld: slow fbm drift on position and aim
  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];
  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);   // a kick on impacts
  camera.position.set(p[0] + hp[0], p[1] + hp[1] + im, p[2] + hp[2]); camera.up.set(...(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);                // lens shift, not a tilt
  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/look.js`

```js
// The grade + lights as dials a video turns over time, and the post effects (punches, whips, flashes, leak, shake).
import { lerp, seg, easeIn } from './lib.js';
import { FLASH_MAX } from './post.js';
export const LIGHTNING_MAX = 2.2;

// call once per frame: warm 0..1 (sunset city, warm key, halation) · moody 0..1 (dark blue, rain on the glass, desaturated)
// spin = radians the key light has swung (time-lapse) · flicker multiplies the desk lamp · lightning 0..1 (3D flash from the window)
export function applyLook(E, t, o = {}) {
  const { R, post } = E, wA = o.warm || 0, wM = o.moody || 0, lt = o.spin || 0, flick = o.flicker ?? 1;
  post.U.wA.value = post.F.wA.value = wA; post.U.wM.value = post.F.wM.value = wM;
  post.U.bloom.value = 0.5 + 0.35 * wA - 0.1 * wM; post.U.exposure.value = 1.0 + 0.1 * wA + 0.02 * wM;
  R.key.position.set(4.2 * Math.cos(lt) + 6.5 * Math.sin(lt) * 0.3, 8.5, 6.5 * Math.cos(lt * 0.7));
  R.key.color.setRGB(lerp(1, 0.55, wM), lerp(lerp(0.85, 0.72, wA), 0.65, wM), lerp(lerp(0.69, 0.48, wA), 1, wM));
  R.key.intensity = lerp(lerp(60, 80, wA), 16, wM);
  R.hemi.color.setRGB(lerp(lerp(0.5, 1.0, wA), 0.2, wM), lerp(lerp(0.56, 0.78, wA), 0.27, wM), lerp(lerp(0.88, 0.62, wA), 0.5, wM)); R.hemi.intensity = lerp(lerp(0.9, 1.0, wA), 0.6, wM);
  R.rim.color.setRGB(lerp(lerp(0.5, 1, wA), 0.42, wM), lerp(lerp(0.61, 0.76, wA), 0.55, wM), lerp(1, lerp(0.48, 1, wM), wA)); R.rim.intensity = lerp(lerp(1.6, 2.0, wA), 3.4, wM);
  R.lampL.intensity = lerp(lerp(7, 11, wA), 0.3, wM) * flick; R.bulbM.color.setScalar(lerp(1, 0.18, wM) * flick);
  R.fill.color.setRGB(lerp(1, 0.45, wM), lerp(0.79, 0.6, wM), lerp(0.63, 1, wM)); R.fill.intensity = lerp(0.5, 1.6, wM);
  R.flash.intensity = Math.min(LIGHTNING_MAX, (o.lightning || 0) * 6);
  R.city[1].material.opacity = wA; R.city[2].material.opacity = wM; R.rainU.a.value = wM; R.rainU.t.value = t;
}
// ---- post effects for effects(t) ----
export const punchIn = (t, cut, lead, amt) => t >= cut - lead && t < cut ? amt * easeIn(seg(t, cut - lead, cut)) : 0;   // zoom blur into a cut
export const punchOut = (t, cut, dur, amt) => t >= cut && t < cut + dur ? amt * (1 - seg(t, cut, cut + dur)) : 0;     // ...settling after it
export function whips(t, list, half = 0.1) { for (const [t0, dx, dy] of list) { const k = Math.abs(t - t0); if (k < half) return [dx * (1 - k / half), dy * (1 - k / half)]; } return [0, 0]; }
export function flash(t, list, { dur = 0.35, decay = 14 } = {}) {   // [[t0, amount, [r,g,b]?], ...]
  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 };
}
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; }
export function shake(t, list, amp = 16) {   // overlay shake (px) on slams
  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];
}
export function applyFx(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/overlay.js`

```js
// The 2D paper-craft layer over the 3D (a 1080x1920 canvas composited in the final post pass):
// torn paper banner on two tape strips, counter badge, orange torn-paper word tags (captions), terminal bar, comment box (CTA).
// Every element records its box, so stills can draw the safe zone and every frame can be checked against it.
import { F, paperCanvas } from './tex.js';
import { rng, seg, clamp, easeOut, easeOutBack, hash, lerp } from './lib.js';

export const OW = 1080, OH = 1920;
// platform buttons cover the edges: text only in x 60-960, y 250-1480; captions centred on x 510, <= 820 wide, block in y 1150-1480
export const SAFE = { x0: 60, x1: 960, y0: 250, y1: 1480, cap0: 1150, cap1: 1480, cx: 510, capW: 820 };
export const LAYOUT = { bannerTop: 268, bannerMaxW: 880, badgeRight: 950, badgeY: 470, capY: 1385, capBottom: 1468, termY: 1092, commentY: 1062 };
const PAPER = paperCanvas(5, 242, 30), PAPER2 = paperCanvas(9, 225, 50);
export const COL = { paper: '#f3eee4', tape: '#d9c99c', ink: '#141414', tapeBanner: '#17171a', badge: '#f8f3e9', badgeInk: '#2b2420',
  good: '#2fae5a', neutral: '#e0702f', bad: '#d6452b', tag: '#cf6a43', tagMoody: '#a9512f', tagInk: '#fdf3e4', huge: '#b8532f', term: 'rgba(16,16,19,0.94)', accent: '#e8784a' };

let BOXES = [], bannerBottom = 0;
export function beginFrame() { BOXES = []; bannerBottom = 0; }
export const boxes = () => BOXES;
function mark(g, kind, text, x, y, w, h) {   // record a local rect under the current transform as a screen box
  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) {   // 3 px tolerance for torn edges
  return list.filter(b => { const c = b.kind === 'caption';
    const X0 = c ? SAFE.cx - SAFE.capW / 2 : SAFE.x0, X1 = c ? SAFE.cx + SAFE.capW / 2 : SAFE.x1, Y0 = c ? SAFE.cap0 : SAFE.y0, Y1 = c ? SAFE.cap1 : SAFE.y1;
    return b.x0 < X0 - 3 || b.x1 > X1 + 3 || b.y0 < Y0 - 3 || b.y1 > Y1 + 3; }).map(b => ({ ...b, x0: Math.round(b.x0), x1: Math.round(b.x1), y0: Math.round(b.y0), y1: Math.round(b.y1) }));
}
export function drawSafe(g) {   // stills only (?safe): red = keep text out, cyan = caption band, boxes green ok / red outside
  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().map(b => b.kind + b.text));
  for (const b of BOXES) { g.strokeStyle = bad.has(b.kind + b.text) ? '#ff2040' : '#30ff70'; g.strokeRect(b.x0, b.y0, b.x1 - b.x0, b.y1 - b.y0); }
  g.restore();
}

// ---------- paper ----------
function torn(g, x, y, w, h, seed, jt = 2.2, js = 6) {   // a rectangle with torn edges: top/bottom jitter jt every 12 px, sides js every 8 px
  const R = rng(seed + 1), P = [];
  for (let s = 0; s <= w; s += 12) P.push([x + s, y + (R() - 0.5) * jt * 2]);
  for (let s = 0; s <= h; s += 8) P.push([x + w + (R() - 0.5) * js * 2, y + s]);
  for (let s = w; s >= 0; s -= 12) P.push([x + s, y + h + (R() - 0.5) * jt * 2]);
  for (let s = h; s >= 0; s -= 8) P.push([x + (R() - 0.5) * js * 2, y + s]);
  g.beginPath(); P.forEach(([px, py], k) => k ? g.lineTo(px, py) : g.moveTo(px, py)); g.closePath();
}
function paper(g, x, y, w, h, seed, fill, { tex = PAPER, ta = 0.55, shadow = 1, jt, js } = {}) {
  g.save(); torn(g, x, y, w, h, seed, jt, js);
  if (shadow) { g.shadowColor = `rgba(0,0,0,${0.45 * shadow})`; g.shadowBlur = 22; g.shadowOffsetY = 8; }
  g.fillStyle = fill; g.fill(); g.restore();
  g.save(); torn(g, x, y, w, h, seed, jt, js); g.clip(); g.globalCompositeOperation = 'multiply'; g.globalAlpha = ta;   // fibres
  const sx = (seed * 97) % 600; g.drawImage(tex, sx, 0, Math.min(1024 - sx, 400), 256, x, y, w, h); g.restore();
}
function tape(g, cx, cy, w, h, rot, seed) {
  g.save(); g.translate(cx, cy); g.rotate(rot); g.globalAlpha = 0.86; torn(g, -w / 2, -h / 2, w, h, seed, 1.2, 5); g.fillStyle = COL.tape; g.fill();
  g.clip(); g.globalCompositeOperation = 'multiply'; g.globalAlpha = 0.5; g.drawImage(PAPER2, 0, 0, 300, 120, -w / 2, -h / 2, w, h); g.restore();
}
const fitSize = (g, text, font, max, size, min = 30) => { for (; size > min; size -= 2) { g.font = font(size); if (g.measureText(text).width <= max) break; } return size; };
function twoLines(g, text) {   // split into two lines of similar width
  const w = text.split(' '); let best = [text, ''], bd = Infinity;
  for (let i = 1; i < w.length; i++) { const a = w.slice(0, i).join(' '), b = w.slice(i).join(' '), d = Math.abs(g.measureText(a).width - g.measureText(b).width); if (d < bd) { bd = d; best = [a, b]; } }
  return best;
}

// ---------- banner: the beat's chapter label ----------
// st: { text (CAPS, 2-6 words), mode: 'paper' | 'tape' (black strip, for a dark/moody beat) | 'mag' (masthead), at (start time), n (banner index) }
export function banner(g, st, t) {
  if (!st || !st.text) return; if (st.mode === 'mag') return masthead(g, st, t);
  const k = t - st.at, first = st.at <= 0.001, pop = first ? 0 : 1 - easeOut(seg(k, 0, 0.24));
  const s = 1 + 0.09 * pop, rot = (st.n % 2 ? 1 : -1) * 0.018 + (first ? 0 : Math.sin(k * 34) * 0.02 * pop), tapeMode = st.mode === 'tape', maxT = LAYOUT.bannerMaxW - 120;
  g.save(); g.letterSpacing = '-1px';
  let lines = [st.text], size = fitSize(g, st.text, F.sans, maxT, 68, 50); g.font = F.sans(size);
  if (g.measureText(st.text).width > maxT) { g.font = F.sans(68); lines = twoLines(g, st.text); size = fitSize(g, lines.reduce((a, b) => g.measureText(a).width > g.measureText(b).width ? a : b), F.sans, maxT, 64, 36); g.font = F.sans(size); }
  const tw = Math.max(...lines.map(l => g.measureText(l).width)), w = Math.max(560, Math.min(LAYOUT.bannerMaxW, tw + 120)), lh = size * 1.08, h = size * 1.78 + lh * (lines.length - 1);
  const cy = LAYOUT.bannerTop + h / 2; bannerBottom = LAYOUT.bannerTop + h;
  g.translate(SAFE.cx, cy); g.rotate(tapeMode ? -0.03 : rot); mark(g, 'banner', st.text, -w / 2, -h / 2, w, h); g.scale(s, s);
  if (tapeMode) {
    paper(g, -w / 2, -h / 2, w, h, 31, COL.tapeBanner, { tex: PAPER2, ta: 0.25, js: 10 });
    const sh = g.createLinearGradient(0, -h / 2, 0, h / 2); sh.addColorStop(0, 'rgba(255,255,255,0.08)'); sh.addColorStop(0.45, 'rgba(255,255,255,0)'); sh.addColorStop(1, 'rgba(255,255,255,0.05)');
    g.fillStyle = sh; g.fillRect(-w / 2, -h / 2, w, h); g.fillStyle = '#f2f2f2';
  } else {
    paper(g, -w / 2, -h / 2, w, h, 3 + st.n * 7, COL.paper);
    tape(g, -w / 2 + 28, -h / 2 + 8, 120, 42, -0.72, 7 + st.n); tape(g, w / 2 - 28, -h / 2 + 8, 120, 42, 0.72, 13 + st.n); g.fillStyle = COL.ink;
  }
  g.globalAlpha = first ? 1 : clamp(k / 0.06); g.font = F.sans(size); g.textAlign = 'center'; g.textBaseline = 'middle';
  lines.forEach((l, i) => g.fillText(l, 0, size * 0.06 + (i - (lines.length - 1) / 2) * lh));
  g.restore();
}
// masthead (banner mode 'mag'): one huge Bodoni word dropping in letter by letter, kicker above, rule + two small lines below
export function masthead(g, st, t) {
  const k = t - st.at, word = st.text; g.save(); g.letterSpacing = '-2px';
  const size = fitSize(g, word, F.serif, LAYOUT.bannerMaxW, 230, 110), tw = (g.font = F.serif(size), g.measureText(word).width);
  const kickY = LAYOUT.bannerTop + 30, base = kickY + 40 + size * 0.72; let x = SAFE.cx - tw / 2;
  mark(g, 'masthead', word, x, base - size * 0.72, tw, size * 0.74); g.shadowColor = 'rgba(80,30,10,0.45)'; g.shadowBlur = 26; g.shadowOffsetY = 6; g.fillStyle = '#fff8ec';
  for (let j = 0; j < word.length; j++) { const kk = seg(k, j * 0.03, j * 0.03 + 0.3); g.globalAlpha = clamp(kk * 3); g.fillText(word[j], x, base - (1 - easeOutBack(kk, 1.3)) * 90); x += g.measureText(word.slice(0, j + 1)).width - g.measureText(word.slice(0, j)).width; }
  g.shadowBlur = 10; g.letterSpacing = '7px'; g.font = F.sans(26); g.textAlign = 'center'; g.globalAlpha = seg(k, 0.25, 0.5);
  g.fillText(st.kicker || 'THE ONE-SENTENCE ISSUE', SAFE.cx, kickY); g.fillRect(SAFE.cx - tw / 2, base + 32, tw, 3);
  g.letterSpacing = '4px'; g.font = F.sans(24); g.textAlign = 'left'; g.fillText(st.left || '', SAFE.cx - tw / 2, base + 76); g.textAlign = 'right'; g.fillText(st.right || '', SAFE.cx + tw / 2, base + 76);
  bannerBottom = base + 90; g.restore();
}

// ---------- counter badge (top right, under the banner) ----------
// st: { at, out (null = stays), label (CAPS), value (string), col (COL.good | neutral | bad), minW (stable width while counting), chAt (pop when it changes) }
export function badge(g, st, t) {
  if (!st) return; const inK = seg(t, st.at, st.at + 0.22), outK = st.out != null ? seg(t, st.out, st.out + 0.15) : 0; if (inK <= 0 || outK >= 1) return;
  const s = easeOutBack(inK, 2) * (1 - outK), pop = st.chAt != null && t >= st.chAt ? 1 + 0.22 * (1 - easeOut(seg(t, st.chAt, st.chAt + 0.22))) : 1;
  g.save(); g.letterSpacing = '1px'; g.font = F.sans(22); const lw = g.measureText(st.label).width;
  g.letterSpacing = '0px'; g.font = F.coop(58); const vw = Math.max(g.measureText(st.value).width, st.minW || 0);
  const w = 30 + lw + 20 + vw + 28, h = 98, cy = Math.max(LAYOUT.badgeY, bannerBottom + 22 + h / 2), x = LAYOUT.badgeRight - w, y = cy - h / 2;
  mark(g, 'badge', st.label + ' ' + st.value, x, y, w, h);
  g.translate(x + w, cy); g.scale(s, s); g.rotate(0.012); g.translate(-(x + w), -cy);
  g.shadowColor = 'rgba(0,0,0,0.4)'; g.shadowBlur = 20; g.shadowOffsetY = 7; g.beginPath(); g.roundRect(x, y, w, h, 18); g.fillStyle = COL.badge; g.fill(); g.shadowColor = 'transparent';
  g.lineWidth = 4; g.strokeStyle = COL.badgeInk; g.stroke();
  g.fillStyle = COL.badgeInk; g.letterSpacing = '1px'; g.font = F.sans(22); g.textBaseline = 'middle'; g.fillText(st.label, x + 28, cy + 2);
  g.letterSpacing = '0px'; g.font = F.coop(58); g.fillStyle = st.col || COL.neutral; g.textAlign = 'right';
  g.translate(x + w - 26, cy + 4); g.scale(pop, pop); g.fillText(st.value, 0, 0); g.restore();
}

// ---------- captions: orange torn-paper word tags, word by word ----------
function tag(g, text, cx, cy, size, seed, sc, alpha, { fill = COL.tag, ink = COL.tagInk, kind = 'caption' } = {}) {
  g.save(); g.font = F.coop(size); const w = g.measureText(text).width + size * 0.62, h = size * 1.42;
  g.translate(cx, cy); g.rotate((hash(seed) - 0.5) * 0.12); mark(g, kind, text, -w / 2, -h / 2, w, h); g.globalAlpha = alpha; g.scale(sc, sc);
  paper(g, -w / 2, -h / 2, w, h, seed * 3 + 1, fill, { ta: 0.42, jt: 2.2 * size / 64, js: 4 * size / 64 });
  g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillStyle = 'rgba(70,25,8,0.35)'; g.fillText(text, 1.5, size * 0.06 + 3); g.fillStyle = ink; g.fillText(text, 0, size * 0.06);
  g.restore();
}
function rows(g, words, size, gap, maxW) {   // words into centred rows <= maxW
  g.font = F.coop(size); const R = [[]]; let rw = 0;
  for (const w of words) { const ww = g.measureText(w.cap).width + size * 0.62; if (R.at(-1).length && rw + gap + ww > maxW) { R.push([]); rw = 0; } R.at(-1).push({ w, ww }); rw += (rw ? gap : 0) + ww; }
  const out = []; R.forEach((r, ri) => { let x = SAFE.cx - (r.reduce((a, it) => a + it.ww, 0) + gap * (r.length - 1)) / 2; for (const it of r) { out.push({ ...it, cx: x + it.ww / 2, row: ri - (R.length - 1) / 2, n: R.length }); x += it.ww + gap; } });
  return out;
}
// st: { scale (1 = 64 px; "bigger captions" = 1.42), moody (darker tags), mode: 'tag' | 'huge' (headline slam: st.huge = [[WORD, t0, row], ...]) }
export function captions(g, film, t, st = {}) {
  if (st.mode === 'huge') return huge(g, t, st.huge || []);
  const c = film.chunks.find(c => t >= c.t0 && t < c.t1); if (!c) return;
  const words = c.words.map(i => film.words[i]).filter(w => t >= w.t0 - 0.02); if (!words.length) return;
  const maxW = SAFE.capW - 24; let size = 64 * (st.scale || 1); g.font = F.coop(size);
  const widest = Math.max(...c.words.map(i => g.measureText(film.words[i].cap).width + size * 0.62)); if (widest > maxW) size *= maxW / widest;
  const gap = 12 * size / 64, lh = size * 1.55, L = rows(g, words, size, gap, maxW), Lp = words.length > 1 ? rows(g, words.slice(0, -1), size, gap, maxW) : null;
  const half = ((L[0].n - 1) * lh + size * 1.42) / 2, Y = Math.max(SAFE.cap0 + half, Math.min(LAYOUT.capY, LAYOUT.capBottom - half));
  const slide = easeOut(seg(t, words.at(-1).t0, words.at(-1).t0 + 0.13));   // earlier words slide to make room for the new one
  L.forEach((it, j) => {
    const p = Lp?.[j], cx = p ? lerp(p.cx, it.cx, slide) : it.cx, row = p ? lerp(p.row, it.row, slide) : it.row, k = seg(t, it.w.t0 - 0.02, it.w.t0 + 0.15);
    tag(g, it.w.cap, cx, Y + row * lh + (hash(it.w.i + 9) - 0.5) * 10 * size / 64, size, it.w.i + 1, easeOutBack(k, 2.2) * 0.4 + 0.6 * clamp(k * 1.5), clamp(k * 4), st.moody ? { fill: COL.tagMoody } : {});
  });
}
function huge(g, t, list, rowsY = [700, 935, 1170]) {   // big tags slammed one by one (pair each with a thud + IMPULSE + shake)
  for (const [w, t0, row] of list) {
    if (t < t0 - 0.01) continue; const k = seg(t, t0 - 0.01, t0 + 0.12), all = list.filter(h => h[2] === row), vis = all.filter(h => t >= h[1] - 0.01);
    const widthAt = s => { g.font = F.coop(s); return all.reduce((a, h) => a + g.measureText(h[0]).width + s * 0.62, 0) + 26 * (all.length - 1); };
    let size = 190; while (size > 90 && widthAt(size) > SAFE.capW) size -= 6; g.font = F.coop(size);
    const ws = vis.map(h => g.measureText(h[0]).width + size * 0.62); let x = SAFE.cx - (ws.reduce((a, b) => a + b, 0) + 26 * (ws.length - 1)) / 2;
    for (let j = 0; j < vis.length && vis[j][0] !== w; j++) x += ws[j] + 26;
    const ww = g.measureText(w).width + size * 0.62; tag(g, w, x + ww / 2, rowsY[row], size, 50 + list.findIndex(h => h[0] === w), 1 + 0.7 * (1 - easeOut(k)), clamp(k * 5), { fill: COL.huge, kind: 'headline' });
  }
}

// ---------- terminal bar: types the prompt while the voice says it ----------
// st: { text, t0, t1 (typing), enter (sent), vis0, vis1 (on screen) }
export function terminal(g, st, t) {
  if (!st) return; const a = seg(t, st.vis0, st.vis0 + 0.14) * (1 - seg(t, st.vis1 - 0.14, st.vis1)); if (a <= 0) return;
  const y = LAYOUT.termY + (1 - easeOut(seg(t, st.vis0, st.vis0 + 0.2))) * 26, x0 = SAFE.x0 + 6, x1 = SAFE.x1 - 6, n = Math.floor(st.text.length * seg(t, st.t0, st.t1)), typing = t >= st.t0 && t < st.t1 + 0.05;
  g.save(); g.globalAlpha = a; g.shadowColor = 'rgba(0,0,0,0.5)'; g.shadowBlur = 18; g.shadowOffsetY = 6;
  g.beginPath(); g.roundRect(x0, y - 38, x1 - x0, 76, 14); g.fillStyle = COL.term; g.fill(); g.shadowColor = 'transparent'; mark(g, 'terminal', st.text, x0, y - 38, x1 - x0, 76);
  const fl = t >= st.enter ? 1 - seg(t, st.enter, st.enter + 0.4) : 0;   // the outline flashes orange on send
  g.lineWidth = 2.5; g.strokeStyle = `rgba(${Math.round(lerp(255, 232, fl))},${Math.round(lerp(255, 120, fl))},${Math.round(lerp(255, 74, fl))},${0.16 + fl * 0.8})`; g.stroke();
  const size = fitSize(g, st.text, F.mono, x1 - x0 - 210, 36, 24); g.font = F.mono(size); g.textBaseline = 'middle';
  g.fillStyle = COL.accent; g.fillText('>', x0 + 26, y + 2); g.fillStyle = '#ecebe8'; const txt = st.text.slice(0, n); g.fillText(txt, x0 + 62, y + 2);
  if (t < st.enter && (typing || Math.floor(t * 2.4) % 2 === 0)) g.fillRect(x0 + 66 + g.measureText(txt).width, y - 18, size * 0.53, 38);   // block cursor
  if (t >= st.enter) { g.fillStyle = `rgba(232,120,74,${0.9 * (1 - seg(t, st.enter + 0.3, st.enter + 0.6))})`; g.font = F.mono(28); g.textAlign = 'right'; g.fillText('↵ sent', x1 - 24, y + 2); }
  g.restore();
}

// ---------- comment box: the CTA (keyword typed, posted, liked, DM paper plane) ----------
// C: { at, type0, type1, post, plane, keyword, user }
export function comment(g, t, C) {
  if (!C || t < C.at) return; const kw = C.keyword || 'KEYWORD', user = C.user || 'you';
  const a = seg(t, C.at, C.at + 0.2), s = easeOutBack(a, 1.6), y = LAYOUT.commentY, X0 = SAFE.x0 + 20, X1 = SAFE.x1 - 20;
  g.save(); g.translate(SAFE.cx, y); g.scale(s, s); g.translate(-SAFE.cx, -y); g.globalAlpha = clamp(a * 2); g.textBaseline = 'middle';
  if (t >= C.post) {   // the posted comment, 150 px above the box
    const k = seg(t, C.post, C.post + 0.25), ry = y - 150 + (1 - easeOut(k)) * 40;
    g.save(); g.globalAlpha = k; g.shadowColor = 'rgba(0,0,0,0.35)'; g.shadowBlur = 18; g.beginPath(); g.roundRect(X0, ry - 56, X1 - X0, 112, 28); g.fillStyle = 'rgba(250,250,250,0.97)'; g.fill(); g.shadowColor = 'transparent';
    if (k >= 1) mark(g, 'comment', kw, X0, ry - 56, X1 - X0, 112);
    avatar(g, X0 + 62, ry); g.fillStyle = '#111'; g.font = F.ui(30, 600); g.fillText(user, X0 + 114, ry - 16); const uw = g.measureText(user).width;
    g.font = F.ui(26); g.fillStyle = '#8e8e8e'; g.fillText('now', X0 + 128 + uw, ry - 16); g.fillStyle = '#111'; g.font = F.ui(36, 700); g.fillText(kw, X0 + 114, ry + 22);
    const hk = seg(t, C.post + 0.35, C.post + 0.55); heart(g, X1 - 50, ry, 1 + 0.4 * Math.sin(hk * Math.PI), hk > 0 ? '#ff3040' : null); g.restore();
  }
  g.shadowColor = 'rgba(0,0,0,0.45)'; g.shadowBlur = 24; g.shadowOffsetY = 8; g.beginPath(); g.roundRect(X0, y - 54, X1 - X0, 108, 54); g.fillStyle = '#ffffff'; g.fill(); g.shadowColor = 'transparent';
  mark(g, 'comment', 'box', X0, y - 54, X1 - X0, 108); avatar(g, X0 + 60, y);
  const typed = t >= C.post ? '' : kw.slice(0, Math.floor(kw.length * seg(t, C.type0, C.type1)));
  if (typed) { g.fillStyle = '#111'; g.font = F.ui(38, 700); g.fillText(typed, X0 + 110, y + 2); if (Math.floor(t * 2.4) % 2 === 0) g.fillRect(X0 + 114 + g.measureText(typed).width, y - 20, 3, 40); }
  else { g.fillStyle = '#8e8e8e'; g.font = F.ui(34); g.fillText('Add a comment…', X0 + 110, y + 2); }
  g.fillStyle = typed ? (t >= C.post - 0.12 ? '#00376b' : '#0095f6') : '#b2dffc'; g.font = F.ui(34, 700); g.textAlign = 'right'; g.fillText('Post', X1 - 34, y + 2);
  g.restore();
  if (C.plane != null && t >= C.plane && t < C.plane + 0.9) {   // the DM flies off to the top right (decoration, not text)
    const k = seg(t, C.plane, C.plane + 0.85), e = easeOut(k), x = lerp(820, 1180, e) - Math.sin(k * Math.PI) * 80, yy = lerp(y - 40, 420, e) - Math.sin(k * Math.PI) * 160;
    g.save(); g.translate(x, yy); g.rotate(-0.6 + k * 0.4); g.globalAlpha = 1 - seg(k, 0.8, 1); g.shadowColor = 'rgba(0,0,0,0.4)'; g.shadowBlur = 14;
    g.fillStyle = '#ffffff'; g.beginPath(); g.moveTo(-50, 0); g.lineTo(55, -30); g.lineTo(-10, 34); g.lineTo(-14, 10); g.closePath(); g.fill();
    g.fillStyle = '#d7dbe3'; g.beginPath(); g.moveTo(-14, 10); g.lineTo(55, -30); g.lineTo(-10, 34); g.closePath(); g.fill(); g.restore();
  }
}
function avatar(g, x, y) {   // the drawn creator, never a real profile picture
  g.save(); g.beginPath(); g.arc(x, y, 34, 0, 7); g.fillStyle = '#d97757'; g.fill(); g.clip();
  g.fillStyle = '#f0c39b'; g.fillRect(x - 20, y - 12, 40, 36); g.fillStyle = '#3b6fd8'; g.fillRect(x - 24, y - 26, 48, 16); g.fillRect(x - 6, y - 14, 30, 6);
  g.fillStyle = '#111'; g.fillRect(x - 10, y, 6, 10); g.fillRect(x + 6, y, 6, 10); g.restore();
}
function heart(g, x, y, s, fill) {
  g.save(); g.translate(x, y); g.scale(s, s); g.beginPath(); g.moveTo(0, 14); g.bezierCurveTo(-30, -6, -16, -30, 0, -14); g.bezierCurveTo(16, -30, 30, -6, 0, 14); g.closePath();
  if (fill) { g.fillStyle = fill; g.fill(); } else { g.lineWidth = 3; g.strokeStyle = '#8e8e8e'; g.stroke(); } g.restore();
}
```

### `engine/screen.js`

```js
// The phone's screen: a 540x960 canvas the video's phone(g, t) redraws every frame. A few modes; draw your own for anything else.
// The person on it is always the drawn creator (cream hoodie, blue cap), never a real clip, file or account.
import { F } from './tex.js';
import { seg, clamp, easeOutBack, hash, noise1 } from './lib.js';
export const SW = 540, SH = 960;

export const talking = (film, t) => { const w = film.words.find(w => t >= w.t0 && t <= w.t1 + 0.04); return w ? 0.35 + 0.65 * Math.abs(Math.sin((t - w.t0) * 17)) : 0.08; };
export function person(g, cx, cy, s, { mouth = 0.1, hood = '#eee4d0', rim = null } = {}) {   // the creator, drawn flat
  g.save(); g.fillStyle = hood; g.beginPath(); g.roundRect(cx - 190 * s, cy + 125 * s, 380 * s, 360 * s, 50 * s); g.fill();
  g.fillStyle = '#f0c39b'; g.fillRect(cx - 112 * s, cy - 105 * s, 224 * s, 215 * s);
  g.fillStyle = '#3b6fd8'; g.fillRect(cx - 122 * s, cy - 150 * s, 244 * s, 72 * s); g.fillRect(cx - 70 * s, cy - 92 * s, 190 * s, 22 * s);
  g.fillStyle = '#141414'; g.fillRect(cx - 58 * s, cy - 32 * s, 24 * s, 44 * s); g.fillRect(cx + 34 * s, cy - 32 * s, 24 * s, 44 * s);
  const mh = (6 + 30 * mouth) * s; g.fillStyle = '#5a2418'; g.fillRect(cx - 30 * s, cy + 48 * s - mh / 2, 60 * s, mh);
  if (rim) { g.strokeStyle = rim; g.lineWidth = 7 * s; g.globalAlpha = 0.85; g.beginPath(); g.moveTo(cx + 116 * s, cy - 150 * s); g.lineTo(cx + 116 * s, cy + 110 * s); g.moveTo(cx + 194 * s, cy + 160 * s); g.lineTo(cx + 194 * s, cy + 480 * s); g.stroke(); }
  g.restore();
}
export function tags(g, film, t, size, y) {   // the phone video's own little captions (same words, smaller)
  const c = film.chunks.find(c => t >= c.t0 && t < c.t1); if (!c) return;
  const ws = c.words.map(i => film.words[i]).filter(w => t >= w.t0); g.save(); g.font = F.coop(size); g.textBaseline = 'middle'; g.textAlign = 'center';
  const wid = ws.map(w => g.measureText(w.cap).width + size * 0.6), gap = size * 0.2, tot = wid.reduce((a, b) => a + b, 0) + gap * (ws.length - 1), sc = Math.min(1, (SW - 40) / tot); let x = SW / 2 - tot * sc / 2;
  ws.forEach((w, j) => { const k = seg(t, w.t0, w.t0 + 0.12), z = sc * (0.7 + 0.3 * easeOutBack(k, 2));
    g.save(); g.translate(x + wid[j] * sc / 2, y); g.rotate((hash(w.i + 1) - 0.5) * 0.12); g.scale(z, z); g.globalAlpha = clamp(k * 4);
    g.fillStyle = '#cf6a43'; g.fillRect(-wid[j] / 2, -size * 0.7, wid[j], size * 1.4); g.fillStyle = '#fdf3e4'; g.fillText(w.cap, 0, size * 0.05); g.restore(); x += (wid[j] + gap) * sc; });
  g.restore();
}
// "the video": night-to-orange gradient, bokeh, the creator talking with a rim light, a progress bar, its own captions
export function edited(g, t, film) {
  const gr = g.createLinearGradient(0, 0, 0, SH); gr.addColorStop(0, '#26306a'); gr.addColorStop(0.6, '#3d3f86'); gr.addColorStop(1, '#e0805a'); g.fillStyle = gr; g.fillRect(0, 0, SW, SH);
  g.fillStyle = 'rgba(255,210,150,0.18)'; for (let k = 0; k < 14; k++) { g.beginPath(); g.arc((hash(k) * SW + t * 6 * (k % 3 + 1)) % SW, hash(k + 5) * SH * 0.6, 4 + hash(k + 9) * 10, 0, 7); g.fill(); }
  const z = 1.12 + 0.03 * Math.sin(t * 0.7); g.save(); g.translate(SW / 2, SH * 0.52); g.scale(z, z); g.translate(-SW / 2, -SH * 0.52); person(g, SW / 2, 470, 1, { mouth: talking(film, t), rim: '#ffb36b' }); g.restore();
  g.fillStyle = 'rgba(255,255,255,0.3)'; g.fillRect(24, 22, SW - 48, 5); g.fillStyle = '#fff'; g.fillRect(24, 22, (SW - 48) * ((t % 9) / 9), 5);
  tags(g, film, t, 30, 790);
}
// raw footage: grey, shaky, REC dot, timecode (no file names)
export function raw(g, t, film) {
  g.save(); g.fillStyle = '#7c817a'; g.fillRect(0, 0, SW, SH); g.translate(SW / 2 + noise1(t * 3, 1) * 8, SH / 2 + noise1(t * 3, 2) * 8); g.rotate(noise1(t * 2, 3) * 0.02); g.translate(-SW / 2, -SH / 2);
  g.fillStyle = '#8b8f87'; g.fillRect(-40, -40, SW + 80, 470); g.fillStyle = '#6d726b'; g.fillRect(-40, 430, SW + 80, 600); person(g, SW / 2 - 20, 470, 1, { mouth: talking(film, t) }); g.restore();
  g.save(); g.globalCompositeOperation = 'saturation'; g.fillStyle = 'rgba(128,128,128,0.55)'; g.fillRect(0, 0, SW, SH); g.restore();
  g.font = F.mono(26); g.textBaseline = 'middle'; if (Math.floor(t * 2) % 2 === 0) { g.fillStyle = '#ff3b30'; g.beginPath(); g.arc(44, 52, 11, 0, 7); g.fill(); }
  g.fillStyle = '#fff'; g.fillText('REC', 64, 53); const f = Math.floor(t * 30); g.fillText(`00:00:${String(10 + Math.floor(f / 30) % 50).padStart(2, '0')}:${String(f % 30).padStart(2, '0')}`, 300, SH - 46);
}
// the CTA's deliverable as a card: orange pill, up to 3 big lines, a doc / list icon, a glow. { kicker, lines, icon: 'doc' | 'list' }
export function card(g, t, o = {}) {
  const gr = g.createLinearGradient(0, 0, 0, SH); gr.addColorStop(0, '#15172b'); gr.addColorStop(1, '#2a1c2a'); g.fillStyle = gr; g.fillRect(0, 0, SW, SH);
  const gl = g.createRadialGradient(SW / 2, 470, 20, SW / 2, 470, 360); gl.addColorStop(0, 'rgba(232,120,74,0.55)'); gl.addColorStop(1, 'rgba(232,120,74,0)'); g.fillStyle = gl; g.fillRect(0, 0, SW, SH);
  g.textAlign = 'center'; g.font = F.sans(24); g.letterSpacing = '3px'; const kick = o.kicker || 'FREE', kw = Math.max(200, g.measureText(kick).width + 60);
  g.beginPath(); g.roundRect(SW / 2 - kw / 2, 90, kw, 54, 27); g.fillStyle = '#e8784a'; g.fill(); g.fillStyle = '#fff'; g.fillText(kick, SW / 2, 126);
  g.letterSpacing = '-1px'; g.fillStyle = '#fdf3e4'; g.font = F.sans(56); (o.lines || ['THE FILE']).slice(0, 3).forEach((l, i) => g.fillText(l, SW / 2, 240 + i * 62, SW - 50)); g.letterSpacing = '0px';
  const p = 1 + 0.06 * Math.sin(t * 5); g.save(); g.translate(SW / 2, 600); g.scale(p, p); g.beginPath(); g.roundRect(-78, -100, 156, 200, 16); g.fillStyle = '#fff'; g.fill(); g.fillStyle = '#e8784a';
  for (let k = 0; k < 5; k++) { if (o.icon === 'list') { g.beginPath(); g.arc(-48, -62 + k * 32, 7, 0, 7); g.fill(); g.fillRect(-30, -66 + k * 32, 80 - (k % 2) * 22, 9); } else g.fillRect(-52, -66 + k * 32, 104 - (k % 3) * 24, 10); }
  g.restore();
}
export function wipe(g, k, from, to) { to(); g.save(); g.beginPath(); g.rect(0, SH * k, SW, SH); g.clip(); from(); g.restore(); g.fillStyle = '#e8784a'; g.fillRect(0, SH * k - 6, SW, 12); }
export function crossfade(g, k, from, to) { from(); g.save(); g.globalAlpha = clamp(k); to(); g.restore(); }
```

### `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';
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'); 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', tail: 1.7, cover: 0.5, capFix: {}, chunkW: 760,
  music: { file: 'coffee-or-tea.mp3', from: 8, db: -11.5, muffle: [] },   // muffle: [[t0, t1]] low-pass windows (a dark beat)
  voice: { id: 'TX3LPaxmHKxFdv7VOQHJ', name: 'Liam', model_id: 'eleven_v3', voice_settings: { stability: 0.5, similarity_boost: 0.8, style: 0.6, use_speaker_boost: true } },
};
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'); }
export function readScript(dir) { return fs.readFileSync(path.join(dir, 'script.txt'), 'utf8').split(/\r?\n/).filter(l => !l.trim().startsWith('#')).join(' ').replace(/\s+/g, ' ').trim(); }
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/) };
}
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.VOXEL_ANGLE || (process.platform === 'win32' ? 'd3d11' : process.platform === 'darwin' ? 'metal' : 'gl');   // Linux: try VOXEL_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(), 'voxel-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 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';
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, the music is Mixkit's free licence
  ['fonts/InterTight.ttf', 'https://raw.githubusercontent.com/google/fonts/main/ofl/intertight/InterTight%5Bwght%5D.ttf'],
  ['fonts/Fraunces-SuperSoft-Black.ttf', 'https://raw.githubusercontent.com/undercasetype/Fraunces/master/fonts/ttf/Fraunces72ptSuperSoft-Black.ttf'],
  ['fonts/JetBrainsMono.ttf', 'https://raw.githubusercontent.com/google/fonts/main/ofl/jetbrainsmono/JetBrainsMono%5Bwght%5D.ttf'],
  ['fonts/BodoniModa.ttf', 'https://raw.githubusercontent.com/google/fonts/main/ofl/bodonimoda/BodoniModa%5Bopsz,wght%5D.ttf'],
  ['fonts/BodoniModa-Italic.ttf', 'https://raw.githubusercontent.com/google/fonts/main/ofl/bodonimoda/BodoniModa-Italic%5Bopsz,wght%5D.ttf'],
  ['fonts/Inter.ttf', 'https://raw.githubusercontent.com/google/fonts/main/ofl/inter/Inter%5Bopsz,wght%5D.ttf'],
  ['music/coffee-or-tea.mp3', 'https://assets.mixkit.co/music/1168/1168.mp3'],   // Mixkit #1168 "Coffee or Tea"
];
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 sfx = fs.existsSync(path.join(ROOT, 'sfx')) ? fs.readdirSync(path.join(ROOT, 'sfx')).filter(f => f.endsWith('.wav')).length : 0;
ok(sfx >= 20, `${sfx} 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 0.5,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)
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');
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?v=${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 fr = await api.eval('window.framing'); if (Object.keys(fr).length) console.log('framing: shots pushed back to the minimum distance (move their P keys back):', JSON.stringify(fr));
    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 the whole script, with character timestamps. Dry run unless --yes (it costs ~1 credit per character).
//   node engine/gen-vo.mjs <video> [--yes] [--take b]   -> <video>/vo/<take>.mp3 .wav .json, video.json "take"
import fs from 'node:fs'; import path from 'node:path';
import { args, videoDir, loadVideo, saveVideo, readScript, 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 });
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 = readScript(dir), words = text.split(/\s+/).length;
console.log(`${cfg.voice.name} · ${cfg.voice.model_id} · take ${take} · ${text.length} characters · ${words} words ≈ ${(words / 3).toFixed(0)} s`);
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, voice: cfg.voice, alignment: j.alignment }));
run('ffmpeg', ['-v', 'error', '-y', '-i', path.join(vo, take + '.mp3'), '-ar', '48000', '-ac', '1', path.join(vo, take + '.wav')]);
saveVideo(dir, { take });
const end = j.alignment.character_end_times_seconds.at(-1); console.log(`ok · ${end.toFixed(2)} s · ${(words / end * 60).toFixed(0)} words/min · next: node engine/build.mjs ${pos[0]}`);
```

### `engine/build.mjs`

```js
// Voice take -> word timings -> caption chunks -> <video>/film.json. Prints every chunk with its start time (key the beats to these).
//   node engine/build.mjs <video>           from vo/<take>.json (ElevenLabs alignment) + vo/<take>.wav
//   node engine/build.mjs <video> --fake    timings guessed from script.txt (~3 words/s): block out shots and stills before paying for a voice
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { args, videoDir, loadVideo, readScript, die } from './common.mjs';
const { pos, opt } = args(), { dir } = videoDir(pos[0]), cfg = loadVideo(dir), take = opt.fake ? 'fake' : cfg.take, W = [];
if (opt.fake) {
  let t = 0.05; for (const w of readScript(dir).split(' ')) { const d = 0.1 + 0.055 * w.length; W.push({ w, s: t, e: t + d }); t += d + (/[.!?]$/.test(w) ? 0.4 : /[,:;]$/.test(w) ? 0.2 : 0.03); }
} 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 --fake)`);
  const A = JSON.parse(fs.readFileSync(al, 'utf8')).alignment; let cur = null;
  A.characters.forEach((ch, k) => { if (/\s/.test(ch)) { if (cur) W.push(cur); cur = null; return; } if (!cur) cur = { w: '', s: A.character_start_times_seconds[k] }; cur.w += ch; cur.e = A.character_end_times_seconds[k]; });
  if (cur) W.push(cur);
  // 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 S = new Float32Array(pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + (pcm.length & ~3))), rms = [];
  for (let i = 0; i + 160 <= S.length; i += 160) { let a = 0; for (let j = i; j < i + 160; j++) a += S[j] * S[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); }
}
// caption words: [audio tags] are not words; capFix fixes spellings ({ "voxel": "VOXEL" }) — it applies to every occurrence of that word
const CAP = Object.fromEntries(Object.entries(cfg.capFix).map(([k, v]) => [k.toLowerCase(), v]));
const words = W.filter(w => /[A-Za-z0-9]/.test(w.w.replace(/\[[^\]]*\]/g, ''))).map((w, i) => {
  const bare = w.w.replace(/\[[^\]]*\]/g, '').replace(/[^A-Za-z0-9'’%$€.#@+\-]/g, '').replace(/[.\-]+$/, '');
  return { i, w: w.w, cap: CAP[bare.toLowerCase()] || bare, t0: +w.s.toFixed(3), t1: +w.e.toFixed(3), stop: /[.,:;?!]["”’)]?$/.test(w.w) };
});
// chunks: <= 3 words, a break after punctuation, balanced (no lone last word), each <= chunkW px wide at 64 px
const phrases = []; let p = []; for (const w of words) { p.push(w); if (w.stop) { phrases.push(p); p = []; } } if (p.length) phrases.push(p);
const width = c => c.reduce((a, w) => a + w.cap.length * 34 + 70, 0), chunks = [];
for (const ph of phrases) for (let k = Math.ceil(ph.length / 3); ; k++) {
  const base = Math.floor(ph.length / k), extra = ph.length % k, parts = []; let i = 0;
  for (let c = 0; c < k; c++) { const n = base + (c < extra ? 1 : 0); parts.push(ph.slice(i, i + n)); i += n; }
  if (parts.every(c => width(c) <= cfg.chunkW) || k >= ph.length) { chunks.push(...parts); break; }
}
const CH = chunks.map((c, k) => ({ k, t0: c[0].t0, end: c.at(-1).t1, words: c.map(w => w.i) }));
CH.forEach((c, k) => { c.t1 = +Math.min(CH[k + 1]?.t0 ?? Infinity, c.end + 0.7).toFixed(3); });   // up to 0.7 s after its last word
const END = +(words.at(-1).t1 + cfg.tail).toFixed(2);
fs.writeFileSync(path.join(dir, 'film.json'), JSON.stringify({ take, END, words, chunks: CH }));
console.log(`${words.length} words · ${CH.length} chunks · END ${END} s${END > 60 ? '  ✗ over 60 s: cut the script' : ''}`);
console.log(CH.map(c => `${c.t0.toFixed(2)}  ${c.words.map(i => words[i].cap).join(' ')}`).join('\n'));
```

### `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)
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 = {
  slap: ['a single sheet of thick paper slapped flat onto a wooden table, short dry paper slap', 0.5],   // every banner change
  type: ['fast clicky mechanical keyboard typing, a short burst of keystrokes', 2.2],                 // the terminal typing
  enter: ['one satisfying mechanical keyboard enter key press', 0.5],                                 // the prompt is sent
  boing: ['a short cartoon spring boing, playful bounce', 0.5],                                        // Claude hops / pops up
  clunk: ['a small wooden toy block dropping onto a wooden desk, short clunk', 0.5],                   // a prop lands
  stamp: ['a rubber stamp pressed hard onto paper on a desk, single thump', 0.6],                      // stamps, X marks
  whoosh: ['a quick airy whoosh transition', 0.8], swoosh: ['a soft short swoosh of paper flying past', 0.7],
  thud: ['a deep soft cinematic thud impact', 0.8],                                                    // big caption slams
  shimmer: ['a warm magical shimmer, rising sparkly chimes', 2.2],                                     // something turns good / warm
  ding: ['a bright clean notification ding', 0.6], beep: ['a short soft interface beep', 0.5],
  clack: ['a plastic tile clacking into place', 0.5], flick: ['a single paper card flicked onto a pile', 0.5],
  clang: ['a small metal bin lid clang', 0.8], plane: ['a paper airplane whooshing away fast', 0.8],
  spot: ['a big theatre spotlight switching on, heavy clunk and hum', 0.8], ff: ['cartoon fast-forward tape chirp, speeding up', 1.4],
  rain: ['steady rain on a window, heard from inside', 4], thunder: ['a close thunder crack and rumble', 3],
};
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('slap', 2.15, -17); ev('type', 0.15, -14, 1.6); }   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 { ROOT, args, videoDir, loadVideo, 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 find = (kind, f) => [path.join(dir, kind, f), path.join(ROOT, kind, f)].find(p => fs.existsSync(p));
const vo = path.join(dir, 'vo', cfg.take + '.wav'), music = find('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', find('sfx', n + '.wav') || die(`sfx/${n}.wav missing`)])];
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,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 cover: subject, banner, badge, first word)`);
const white = mean.map((m, k) => [m, k]).filter(([m]) => m > 205); say(!white.length, `washed-out frames (flash too strong): ${white.length}${white.length ? ' e.g. ' + (white[0][1] / 30).toFixed(2) + ' s' : ''}`);
let run0 = 0, frozen = []; diff.forEach((d, k) => { if (d < 0.35) { if (++run0 === 16) frozen.push(((k - 15) / 30).toFixed(2)); } else run0 = 0; });
say(!frozen.length, `stretches over 0.5 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 (aim: one per beat, 2.5-4 per 10 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)

A 20-second demo that uses every device once: tiles falling in and X-stamped, the terminal, docs flying into Claude with a counter, a document landing with a stamp, a wide shot, and the CTA with the phone standing up. Make the folder `demo/` with these four files, then `node engine/build.mjs demo --fake` and `node engine/render.mjs demo stills auto 10 --safe` work without any voice. Your own videos copy this structure: rewrite the words in `at()`, the props, `SHOTS`, `BANNERS`, `BADGES`, `TERMS`, `COMMENT` and the cues for the new script.

### `demo/script.txt`

```text
# Lines starting with # are ignored. The voice reads the rest as one take.
Three apps I stopped paying for.
Claude does all three now.
I type one sentence: plan my week.
It reads my calendar, my notes and my tasks, and builds the whole plan.
No app opened.
Comment WEEKLY and I'll send you the prompt.
```

### `demo/video.json`

```json
{ "capFix": { "weekly": "WEEKLY" }, "cover": 0.5 }
```

### `demo/scene.js`

```js
// THE DIRECTOR FILE of one video. setup(E) runs once; then for every frame time t the engine places the camera from SHOTS and calls
// world(t), effects(t), phone(g, t) and overlay(g, t). Everything is a pure function of t: no clocks, no state, no Math.random.
export default function setup(E) {
  const { R, film, END, O, S, T, FX, L } = E, { COL } = O;
  const { clamp, lerp, seg, ss, easeOut, easeIn, easeInOut, easeOutBack, easeOutBounce, hop } = L, { poseClaude, poseCreator, blinkAt } = E.rig;

  // ---------- word times (from film.json, so a new voice take re-times everything) ----------
  const norm = s => s.toLowerCase().replace(/[^a-z0-9']/g, '');
  const word = (w, n = 1) => film.words.filter(x => norm(x.cap) === w)[n - 1] || (() => { throw new Error(`word "${w}" #${n} not in film.json`); })();
  const at = (w, n) => word(w, n).t0, end = (w, n) => word(w, n).t1;
  // beats = the first word of each sentence. One beat = one banner = one shot.
  const B = [0, at('claude'), at('i', 2), at('it'), at('and', 2), at('no'), at('comment')];
  const sent = end('week') + 0.12, post = end('weekly') + 0.35;            // terminal sends after "week"; the comment posts after "WEEKLY"

  // ---------- props (built once; world(t) shows and moves them) ----------
  const APPS = [{ label: 'CALENDAR', bg: ['#3d72e8', '#1b2f7a'], icon: '31' }, { label: 'NOTES', bg: ['#f2c14e', '#b8860b'], icon: '✎' }, { label: 'TASKS', bg: ['#2fae5a', '#1d6b35'], icon: '✓' }];
  const tiles = APPS.map(o => R.tile({ ...o, size: 50 }));
  const xMark = (() => { const c = T.cv(256, 256), g = c.getContext('2d'); g.strokeStyle = '#e0362c'; g.lineCap = 'round'; g.lineWidth = 40; g.beginPath(); g.moveTo(40, 40); g.lineTo(216, 216); g.moveTo(216, 40); g.lineTo(40, 216); g.stroke(); return c; })();
  const xs = APPS.map(() => R.decal(xMark, 0.9, 0.9));
  const docs = APPS.map((o, i) => R.card(T.docCanvas({ title: o.label, head: o.bg[0], sub: '# this week', seed: i + 3 }), 0.6, 0.78));
  const plan = R.card(T.docCanvas({ title: 'WEEK PLAN', sub: '# 7 days', rows: ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'] }), 1.1, 1.43);
  const done = R.decal(T.stampCanvas('DONE', COL.good), 1.0, 0.34);
  const TILE_POS = [[-0.38, 0.95], [0.38, 0.95], [0, 0.1]], LAND = [at('three') + 0.15, at('apps') + 0.1, at('i') + 0.1], STAMP = [at('claude') + 0.2, at('all'), at('three', 2)];
  const READ = [at('calendar'), at('notes'), at('tasks')];

  // ---------- shots: P = camera keys, L = look-at keys ON the subject (also the focus), fov, ap = DOF, sh = handheld ----------
  const SHOTS = [
    { t: [B[0], B[1]], P: [[0, [-0.35, 1.75, 4.6]], [B[1], [0.3, 1.75, 4.45]]], L: [[0, [0, 0.55, 0.2]], [B[1], [0, 0.58, 0.15]]], fov: 40, ap: 10, sh: 0.015 },   // hook: Claude behind the tiles, in depth
    { t: [B[1], B[2]], P: [[B[1], [1.3, 2.5, 4.4]], [B[2], [0.9, 2.35, 4.0]]], L: [[B[1], [0, 0.6, -0.3]], [B[2], [0, 0.62, -0.4]]], fov: 44, ap: 9, sh: 0.015 },   // higher 3/4: a clear change of angle
    { t: [B[2], B[3]], P: [[B[2], [0, 1.66, 3.34]], [B[3], [0, 1.48, 2.77]]], L: [[B[2], [0, 0.62, -1.75]], [B[3], [0, 0.64, -1.75]]], fov: 40, ap: 11, sh: 0.012 },  // Claude close-up
    { t: [B[3], B[4]], P: [[B[3], [3.9, 1.5, 3.0]], [B[4], [3.5, 1.4, 2.7]]], L: [[B[3], [0, 0.75, -0.7]], [B[4], [0, 0.78, -0.75]]], fov: 40, ap: 9, sh: 0.012, fit: 2.0 },  // side: docs fly phone -> Claude
    { t: [B[4], B[5]], P: [[B[4], [0.12, 3.75, 2.85]], [B[5], [-0.05, 3.55, 2.6]]], L: [[B[4], [0, 0.14, 0.15]], [B[5], [0, 0.14, 0.12]]], fov: 40, ap: 4, sh: 0.006, fit: 0, frameY: 0.47 },  // top-down insert
    { t: [B[5], B[6]], P: [[B[5], [-0.6, 2.0, 5.2]], [B[6], [0, 1.9, 4.6]]], L: [[B[5], [0.4, 0.8, -1.3]], [B[6], [0.4, 0.82, -1.3]]], fov: 46, ap: 7, sh: 0.012 },        // wide: both of them
    { t: [B[6], END + 1], P: [[B[6], [0, 1.9, 7.4]], [END, [0, 1.75, 6.6]]], L: [[B[6], [0, 1.25, 0.9]], [END, [0, 1.3, 0.9]]], fov: 44, ap: 6, sh: 0.012 },          // CTA: the phone stands up
  ];
  const IMPULSE = [...STAMP.map(s => [s, 0.03]), [sent, 0.03], [end('plan', 2), 0.05]];   // camera kicks on impacts

  // ---------- overlay timeline ----------
  const BANNERS = [[B[0], '3 APPS I STOPPED PAYING FOR'], [B[1], 'CLAUDE DOES ALL THREE'], [B[2], 'ONE SENTENCE'], [B[3], 'CALENDAR, NOTES, TASKS'],
    [B[4], 'THE WHOLE WEEK, PLANNED'], [B[5], 'ZERO APPS OPENED'], [B[6], 'COMMENT WEEKLY FOR THE PROMPT']];
  const landed = t => LAND.filter(x => t >= x).length, read = t => READ.filter(x => t >= x + 0.6).length;
  const BADGES = [   // a number on screen in every beat, changing inside it; count only what is said or shown
    { at: -1, out: B[1] - 0.12, label: 'APPS I PAY FOR', value: t => String(landed(t)), col: COL.bad, minW: 40 },
    { at: B[1] + 0.05, out: B[2] - 0.12, label: 'APPS I PAY FOR', value: t => t < at('now') ? '3' : '0', col: t => t < at('now') ? COL.bad : COL.good, chAt: at('now') },
    { at: B[2] + 0.1, out: B[3] - 0.12, label: 'SENTENCES', value: () => '1', col: COL.neutral },
    { at: B[3] + 0.1, out: B[4] - 0.12, label: 'SOURCES READ', value: t => String(read(t)), col: COL.neutral, minW: 40 },
    { at: B[4] + 0.1, out: B[5] - 0.12, label: 'DAYS PLANNED', value: t => String(Math.round(7 * ss(t, at('whole'), end('plan', 2)))), col: COL.good, minW: 40 },
    { at: B[5] + 0.05, out: B[6] - 0.12, label: 'APPS OPENED', value: () => '0', col: COL.good },
    { at: post + 0.85, out: null, label: 'DM', value: () => 'SENT ✓', col: COL.good },
  ];
  const TERMS = [{ text: 'plan my week', t0: at('plan'), t1: end('week'), enter: sent, vis0: B[2] - 0.1, vis1: B[3] + 0.35 }];
  const COMMENT = { at: B[6] + 0.05, type0: at('weekly'), type1: end('weekly'), post, plane: post + 0.25, keyword: 'WEEKLY' };

  function overlay(g, t) {
    let bi = 0; BANNERS.forEach((b, i) => { if (t >= b[0]) bi = i; });
    O.banner(g, { text: BANNERS[bi][1], mode: BANNERS[bi][2] || 'paper', at: BANNERS[bi][0], n: bi }, t);
    const bd = BADGES.find(x => t >= x.at && (x.out == null || t < x.out + 0.15));
    if (bd) O.badge(g, { ...bd, value: bd.value(t), col: typeof bd.col === 'function' ? bd.col(t) : bd.col }, t);
    const tm = TERMS.find(x => t >= x.vis0 && t < x.vis1); if (tm) O.terminal(g, tm, t);
    O.captions(g, film, t, {});
    O.comment(g, t, COMMENT);
  }
  function phone(g, t) {   // the phone shows "the video" (the drawn creator), then the deliverable
    if (t < B[6] + 0.2) S.edited(g, t, film);
    else S.card(g, t, { kicker: 'FREE', lines: ['THE WEEKLY', 'PLAN PROMPT'], icon: 'doc' });
  }

  function world(t) {
    E.look(t, {});                                                          // the night look (warm / moody dials stay 0 here)
    R.minHand.rotation.z = -t * 0.08; R.hourHand.rotation.z = -t * 0.08 / 12 - 1.2;
    // ----- Claude -----
    const C = { x: 0, z: -1.75, y: 0, sq: Math.sin(t * 2.4) * 0.025, blink: blinkAt(t, 1), aL: 0.15, aR: 0.15 };   // breathing
    for (const [h0, d, h] of [[at('claude'), 0.3, 0.22], [sent, 0.34, 0.3], [at('no'), 0.3, 0.18]]) { const k = hop(t, h0, d, h); C.y += k.y; C.sq += k.sq; }
    if (t >= STAMP[0] - 0.2 && t < B[2]) { C.ex = t < STAMP[1] - 0.1 ? -0.6 : t < STAMP[2] - 0.1 ? 0.6 : 0; C.ey = -0.5; }          // looks at each tile it stamps
    if (t >= B[2] - 0.55 && t < B[2]) { const k = Math.sin(seg(t, B[2] - 0.55, B[2] - 0.05) * Math.PI); C.aL = C.aR = 0.15 + 1.6 * k; }   // sweeps them away
    for (const r of READ) if (t >= r + 0.55 && t < r + 0.85) C.es = 1.3;   // eyes widen as each doc goes in
    if (t >= B[5] && t < B[6]) { C.yaw = 0.35; C.ex = 0.6; }               // turns to the creator
    if (t >= B[6]) { const k = seg(t, B[6] + 0.1, B[6] + 0.57), h = hop(t, B[6] + 0.1, 0.47, 0.75); C.x = lerp(0, 1.36, easeInOut(k)); C.z = lerp(-1.75, 1.15, easeInOut(k)); C.y += h.y; C.sq += h.sq; C.yaw = k > 0 && k < 1 ? 0.6 : -0.25;
      if (t > post) C.aR = 0.2 + 1.5 * (0.5 + 0.5 * Math.sin((t - post) * 14)); }   // waves when the comment lands
    poseClaude(R.claude, C);
    // ----- the creator (moves only on a cut) -----
    const K = { blink: blinkAt(t, 7), bob: Math.sin(t * 2.1) * 0.01, fL: 0.05, fR: 0.05 };
    if (t >= B[5] && t < B[6]) { const k = ss(t, at('opened') - 0.1, at('opened') + 0.15) * (1 - ss(t, B[6] - 0.4, B[6] - 0.1)); K.nod = 0.25 * k; K.fR = 1.2 * k; }
    if (t >= B[6]) { Object.assign(K, { x: -1.2, z: 1.1, yaw: 0.38, hy: -0.4 }); if (t > post) K.sL = 2.5 + 0.35 * Math.sin((t - post) * 13); }
    poseCreator(R.creator, K);
    // ----- three app tiles fall in, get X-stamped, get swept away -----
    tiles.forEach((m, i) => {
      const [x, z] = TILE_POS[i], t0 = LAND[i], onPhone = Math.abs(x) < 0.775 && z > -1.23 && z < 1.53, base = (onPhone ? 0.142 : 0) + 0.475 * 0.58;
      let p = [x, base + (1 - easeIn(seg(t, t0 - 0.25, t0))) * 3.2, z], rz = t >= t0 && t < t0 + 0.25 ? Math.sin(seg(t, t0, t0 + 0.25) * Math.PI * 2) * 0.06 : 0, rx = 0;
      const ft = B[2] - 0.45 + i * 0.07; if (t >= ft) { const k = seg(t, ft, ft + 0.55), dir = [-1, 1, 0.3][i]; p = [x + dir * 3.2 * k, p[1] + 2.4 * k - 2.6 * k * k + (i === 2 ? 2.2 * k : 0), z]; rz = -dir * 6 * k; rx = -3 * k; }
      m.visible = t >= t0 - 0.25 && t < B[2] + 0.2; m.position.set(...p); m.rotation.set(rx, 0, rz); m.scale.setScalar(0.58);
      const xk = easeOut(seg(t, STAMP[i], STAMP[i] + 0.12)); xs[i].visible = m.visible && t >= STAMP[i];
      xs[i].position.set(p[0], p[1], p[2] + 0.07); xs[i].rotation.set(rx, 0, rz + 0.12); xs[i].scale.setScalar(0.58 * (1 + 1.4 * (1 - xk)));   // slams in from 2.4x
    });
    // ----- docs rise out of the phone and fly into Claude, one per word -----
    docs.forEach((m, i) => {
      const t0 = READ[i], k = seg(t, t0 - 0.1, t0 + 0.6); m.visible = t >= t0 - 0.1 && t < t0 + 0.6;
      const up = [lerp(0, -0.5 + 0.5 * i, easeOut(seg(k, 0, 0.45))), 0.15 + 1.25 * easeOut(seg(k, 0, 0.45)), 0.15], into = [0, 0.62, -1.4], e = easeIn(seg(k, 0.45, 1));
      m.position.set(lerp(up[0], into[0], e), lerp(up[1], into[1], e), lerp(up[2], into[2], e)); m.rotation.set(0, (1 - k) * 0.6 * (i - 1), (i - 1) * 0.1); m.scale.setScalar(1 - 0.85 * e);
    });
    // ----- the plan lands on the phone, then the stamp -----
    { const t0 = at('builds'), k = seg(t, t0, t0 + 0.5); plan.visible = t >= t0 && t < B[5];
      plan.position.set(0, 0.155 + (1 - easeOutBounce(k)) * 3, 0.15); plan.rotation.set(-Math.PI / 2, 0, 0.04);
      const s0 = end('plan', 2), sk = easeOut(seg(t, s0, s0 + 0.12)); done.visible = plan.visible && t >= s0;
      done.position.set(0.05, 0.18, 0.35); done.rotation.set(-Math.PI / 2, 0, 0.15); done.scale.setScalar(1 + 1.4 * (1 - sk)); }
    // ----- CTA: the phone stands up (it shows the deliverable) -----
    R.phonePivot.rotation.x = 1.3 * easeOutBack(seg(t, B[6] + 0.03, B[6] + 0.6), 1.4);
  }

  function effects(t) {
    const zoom = FX.punchIn(t, B[2], 0.3, 0.07) + FX.punchOut(t, B[2], 0.13, 0.05) + FX.punchIn(t, B[4], 0.22, 0.1) + FX.punchOut(t, B[4], 0.13, 0.05);
    const whip = FX.whips(t, [[B[1], 0.06, 0], [B[3], -0.08, 0], [B[5], 0, 0.06], [B[6], 0, -0.08]]);
    const flash = FX.flash(t, [[sent, 0.1, [1, 0.8, 0.6]], [end('plan', 2), 0.14, [0.8, 1, 0.85]]]);
    FX.applyFx(E, t, { zoom, whip, flash });
  }
  return { SHOTS, IMPULSE, world, effects, phone, overlay };
}
```

### `demo/cues.mjs`

```js
// Sound effects on the words: ev(name, time s, gain dB, max duration s?). Re-run mix.mjs whenever film.json changes.
export default function cues(ev, film) {
  const norm = s => s.toLowerCase().replace(/[^a-z0-9']/g, ''), at = (w, n = 1) => film.words.filter(x => norm(x.cap) === w)[n - 1].t0, end = (w, n = 1) => film.words.filter(x => norm(x.cap) === w)[n - 1].t1;
  const B = [0, at('claude'), at('i', 2), at('it'), at('and', 2), at('no'), at('comment')], sent = end('week') + 0.12, post = end('weekly') + 0.35;
  for (const b of B.slice(1)) ev('slap', b, -17);                                          // every banner change
  for (const t of [at('three') + 0.15, at('apps') + 0.1, at('i') + 0.1]) ev('clunk', t - 0.02, -7);   // tiles land
  for (const t of [at('claude') + 0.2, at('all'), at('three', 2)]) ev('stamp', t - 0.01, -6);          // X stamps
  ev('whoosh', B[2] - 0.45, -10);                                                          // tiles swept away
  ev('type', at('plan'), -14, end('week') - at('plan')); ev('enter', sent, -8);             // the terminal
  for (const t of [at('claude'), sent, at('no')]) ev('boing', t - 0.05, -15);              // hops
  for (const w of ['calendar', 'notes', 'tasks']) ev('swoosh', at(w), -12);                 // docs fly in
  ev('clunk', at('builds') + 0.3, -6); ev('stamp', end('plan', 2), -4);                     // the plan lands, DONE
  ev('whoosh', B[6], -11);
  for (let i = 0; i < 6; i++) ev('type', at('weekly') + i * 0.08, -15, 0.09);              // the keyword typed
  ev('ding', post, -10); ev('plane', post + 0.25, -10); ev('ding', post + 0.85, -9);       // posted, DM, sent
}
```

