---
name: paper
description: Make a narrated vertical reel (1080x1920, 25-40 s) in the "Paper" format - a papercraft 3D diorama shot like a little TV show - orange construction-paper block characters in leather armchairs, a studio whose grid windows light up, a giant paper shark, a corridor with a glowing doorway, flat paper dioramas (green felt with a conveyor, a brick wall with sticky notes, a grey office with a paper city), a torn paper banner taped across the top for every beat, orange torn-paper word tags as captions, a blue news chyron with a foamy water edge, round speech bubbles with drawn faces and a big COMMENT bubble at the end. One fast ElevenLabs voice and a music bed, rendered frame by frame in headless Chrome (three.js) and encoded with ffmpeg. Use it when someone asks for a paper reel, "paper.md", a papercraft or cardboard diorama reel, a Shark-Tank-style story reel, or wants to turn a script into this kind of video.
---

# Paper reel: how to make one

You will make a **25-40 second vertical video (1080x1920, 30 fps, H.264 + AAC, at most 60 s and 60 MB)** that looks like **a little TV show built out of paper**. Everything is cut from construction paper and cardboard: **orange block characters** with black square eyes (the narrator is a small Clawd-style critter in a blue cap; the others sit in leather armchairs wearing paper outfits: a hoodie, a pinstripe suit, pearls), a **navy studio whose grid windows go from night blue to glowing orange**, a painted rug, a **giant paper shark** with zigzag teeth, a corridor with a glowing doorway, and three **flat paper dioramas** shot straight on (green felt with a conveyor and a piggy bank, a brown wall with bricks and sticky notes, a grey office with a paper city). The camera never stops and **cuts to a new set or angle every ~1.8 s**, with depth of field, bloom, light chromatic edges, film grain and drifting dust. Over it sit four paper layers: a **torn paper banner taped across the top** that titles every beat, **orange torn-paper word tags** that appear word by word as the voice says them, a **blue news chyron with a foamy water edge** for the key fact, **round speech bubbles with drawn faces** over the characters, and on the call to action a **big white COMMENT bubble** with the keyword in red.

**`reference.jpg`** (next to this file, if you have it) is a contact sheet of 12 frames from a finished Paper reel: **this is what it should look like.** Left to right, top row first: (1) the hook: a giant orange paper shark chewing the right edge of a white "SKILL.md" card on an easel in a dark navy studio, blue grid windows behind, the banner "NEVER PAY FOR A SKILL AGAIN", the tag "realize"; (2) the reveal: the shark rips a red "$30" tag off the card, "FREE" in green underneath, the windows glowing orange; (3) a small orange critter in a blue cap carrying a white card toward a glowing doorway under a "MORPHY" sign, a blue chyron "CASPER: BUILT MORPHY, AN AGENTIC SUPER APP"; (4) the sharks'-eye view of the pitch: a card "FORMAT + SKILL.md $0" on an easel, a phone and a paper doc popping up behind it, the critter beside it; (5) a row of orange blocks in leather armchairs under glowing windows, round bubbles with drawn faces over them; (6) a shiny red gift box with a gold bow and a "PREMIUM" tag; (7) a teal cash register showing "$30.00", the chyron "HIS OFFER: MY FREE FILE FOR $30"; (8) a flat green felt diorama: cards on a conveyor stamped "$30", a piggy bank, a "$30 EACH" marquee; (9) the easel card flipping from a red "$30" to a green "$0"; (10) a bookshelf of .md books inside a paper app window titled "Morphy"; (11) a flat brick wall: sticky notes "NO PAYWALLS", "NO SUBSCRIPTIONS", "NO STRINGS" slapped on a white sign; (12) the CTA: three blocks in armchairs holding green "FREE" signs, bubbles over them, a big white bubble "COMMENT PAPER". If you only have this file, section 3 describes everything with numbers.

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

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

---

## 0. Rules of engagement (read first)

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

## 1. Requirements

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

## 2. Project layout and setup

```
paper-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 (fonts/fonts.json) -> film.json + scene.js -> window.setT(t)
    world.js papertex.js tex.js  every set, character and prop + their paper textures (WORLD_DEFAULTS = what they say)
    overlay.js                   banner, word tags, chyron, bubbles, COMMENT bubble, paper plane, safe zone
    look.js post.js camera.js fx.js lib.js     lights per set · post chain · SHOTS -> camera · whips/flash/punch · helpers
    common.mjs cdp.mjs setup.mjs render.mjs gen-vo.mjs voedit.mjs votight.mjs build.mjs gen-sfx.mjs mix.mjs encode.mjs check.mjs
  fonts/  music/  sfx/           downloaded / generated once
  my-video/                      one folder per video (Appendix B is a complete one)
    script.mjs  times.mjs        what the voice says (+ how to say symbols) · the cuts
    scene.js  cues.mjs           the director file · the sound effects
    video.json                   settings
    vo/ film.json stills/ frames/ mix.wav video.mp4 cover.jpg
```

```bash
mkdir paper-reel && cd paper-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, writes fonts/fonts.json
node engine/gen-sfx.mjs          # dry run: lists the 23 sounds; then --yes to make them once
```

A video:

```bash
mkdir my-video                    # write my-video/script.mjs (section 4); copy times.mjs, scene.js, cues.mjs from Appendix B and rewrite them
node engine/build.mjs my-video --fake                                 # guessed timings: direct before you pay for a voice
node engine/render.mjs my-video stills auto 10 --safe                 # look at my-video/stills/sheet-safe.jpg; fix; repeat
node engine/gen-vo.mjs my-video                                       # dry run: the text the voice will read + its cost
node engine/gen-vo.mjs my-video --yes                                 # ONE take -> my-video/vo/a.*
node engine/votight.mjs my-video --from a --out b --set               # the format's pace: pauses cut, x1.12 -> vo/b.*
node engine/build.mjs my-video                                        # real timings: prints every caption token with its time + the tag chunks
node engine/render.mjs my-video stills 0.6,3,7,10,14,18,22,26,29 --safe
node engine/render.mjs my-video full 0 500 && node engine/render.mjs my-video full 500      # ~0.16-0.5 s a frame on a mid GPU
node engine/mix.mjs my-video && node engine/encode.mjs my-video && node engine/check.mjs my-video
```

## 3. The look, pinned down

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

| Layer | Where | What |
|---|---|---|
| The set | everything behind | one paper set per shot (3.4), lit like a studio, depth of field on the subject |
| Subject | between the banner and the chyron: y ≈ 400-1080 | the character, card or prop the sentence is about; a close-up block fills ~70-100 % of the width |
| Banner | y 276-382, x 70-950 | a torn paper strip taped at both top corners, the beat's title in heavy caps |
| Bubbles | over a character's head (≥ ~200 px above it) | a white disc with a drawn face and a tail, popping on that character's word |
| Chyron | bottom edge y 1336, from the left edge, 560-900 px wide | the blue news panel with the key fact, 2-3 lines |
| Word tags | centred on x 510, block centre y 1400, bottom ≤ 1470 | orange paper tags, ≤ 3 words, word by word |
| COMMENT bubble | centred x 510, y ≈ 1000 (CTA only) | the keyword in red |

**Safe zone (every platform covers the edges):** all text inside **x 60-960, y 250-1480**; tags inside y 1150-1480, at most 820 wide, centred on x 510. Nothing important in the top 250 px, the bottom 440 px or the right 120 px. The overlay records every text box it draws; `render.mjs ... --safe` draws the zone and the boxes, every run prints anything outside it, and the full render logs every frame for `check.mjs`. In-world text (cards, signs, notes, the shelf) isn't tracked: keep it big and central.

### 3.2 Colours

| Token | Hex | Used for |
|---|---|---|
| character paper | `#e8784a` (blotches toward `#8f3c1f`), alt `#e9804f` | every orange block, the shark, the critter |
| eyes / teeth | `#141414` / `#fbf7ee` on mouth `#2a0d0a` | faces |
| outfits | hoodie `#23305a`, pinstripe `#2b2f3c` + white V + red `#c8322a`, red top `#d2322a` + pearls `#fbf7ee`, teal `#2f6e8f`, black cap `#1a1a1f`, the narrator's cap `#3b6fd8` | who is who |
| studio walls | `#222b58` paper (blotches `#121735`), pillars `#3a2620`, ceiling `#0c0f22` | the TV studio |
| windows | night panes `#3c64c8 → #1b2f6c`, lit `#ffd08a → #ff9d45 → #c4521f`, mullions `#3a1d0e` | the light-up moment |
| rug / floor | rug `#17286a` with gold `#f2c14e`, orange `#e98a3a`, red `#c4472a` swirls and a `#7d2a1a` border; floor dark planks | the stage |
| chairs | leather `#6e2d1d`, cushions lighter, brass studs `#d8b56a` | the sharks' seats |
| signs | red `#d93b2b`, green `#3a9d4f`, white ink | front / back of the sharks' signs |
| card | paper `#f7f4ee`, ink `#1d1d1f`, brand `#e8603c`, green price `#2f9e57`, red price `#d23a2a` | the easel card, wall sign, docs |
| flat sets | felt `#1f5a45`, brown wall `#4a2c1c`, bricks `#a8502f`, concrete `#8f9298`, sky `#9cc8f2`, paper city `#5b7ec4` | the three dioramas |
| sticky notes | `#f4d34a` `#f3a3c2` `#8ec5f0` `#8fdc9a` | notes |
| banner | paper `#f1ece2`, tape `#d6c69a`, ink `#161616` | the top strip |
| tags | paper `#cf6a43`, ink `#fdf3e4`, shadow `rgba(70,25,8,.35)` | captions |
| chyron | `#1f5cc0 → #17479f → #0e2f74`, foam `rgba(160,205,255,.9)` + white dots | the lower third |
| CTA word | `#ff6a5a → #d42f2f` | COMMENT bubble |

### 3.3 Fonts

**Inter Tight** pinned to 800 (the banner, cards, signs, notes), **Cooper Black** for the word tags if the user has it (it ships with Microsoft Office), otherwise **Fraunces** pinned to SOFT 100 / WONK 1 / opsz 144 / wght 900 (a soft serif drawn after the Cooper series: the same look), **Roboto Condensed** 700 (the chyron) and **JetBrains Mono** 800 for the register's LED (Consolas Bold if present). All free ones are SIL OFL from google/fonts; `setup.mjs` downloads them and writes `fonts/fonts.json`, the list the page loads from.

### 3.4 The sets (one visible at a time; world units, the floor is y 0)

- **Studio** (at the origin). Back wall at z −9 with **five grid windows** (x −7.6 … 7.6, centre y 3.8, 2.9 × 4.1) whose shader mixes a night-blue and a glowing-orange canvas per window (`R.winMats[i].uniforms.k`), pillars between them, a logo panel at y 7.4 (two metallic blue words). **Five armchairs on an arc** (x = (i − 2) × 3.1, z = −5.2 + |i − 2| × 0.55, turned toward the stage) with a seated block in each and a sign on a stick beside it; small side tables with cups. The stage: a painted rug (9 × 7) at z 0-4 with paper confetti on it. Side walls at x ±13, a front wall at z 8.5 with a **glowing entrance**. Lights: a warm key spot (with shadows), a blue hemisphere and rim, warm window lights when lit, a stage spot.
- **Corridor** (x −70): wood-panel walls at x ±3 with lamps, a glowing door frame at z −9.85 with two doors that swing open, a lit sign above it.
- **Felt** (x 70): a flat green felt backdrop; a conveyor at y −0.5 with rollers; posts with dials; a stamp press over x 0; cards that ride the belt; a pink paper piggy bank on the right; a marquee with bulbs; two small flat figures and a lever; banknotes pinned on the felt.
- **Brick** (x 120): a brown wall, a door with an EXIT sign on the left, a brick wall on the right, a white sign at (0.6, 2.0) that collects sticky notes, a lamp, a string hanging from the sign and a pair of scissors.
- **Office** (x 133): grey concrete, a big window on a blue paper city, a long table, two flat figures behind it, a paper doc that slides in and gets stamped, a green check card.
- The flat sets are planes facing +z, lit by one directional light that throws soft drop shadows, shot straight on from 13-16 units with a long lens (fov 34-36), almost no depth of field.

### 3.5 The characters (`world.js`)

- **A block** (`R.makeBlock`): a 1.3 × 1.05 × 0.95 box of mottled orange paper with rounded edges, two black eyes (0.13 × 0.2) at 62 % of its height, optional zigzag teeth, two arms (shoulder pivots) and four legs (bent forward when seated). Outfits wrap the lower 36-42 %: hoodie (navy + two white drawstrings), suit (pinstripe + white V + red pocket square + a gold button), pearls (red top + a curve of white beads), blonde (teal top), cap. Hair on top: messy brown lumps, long or bobbed blonde lumps with side pieces, bald with thin round glasses, a black cap with a brim.
- **The narrator** (`R.makeCritter`): a small 0.86 × 0.52 block on four thin legs with two diamond hands and a blue cap. It walks (legs swing), gestures (hands), hops (`hop`).
- **The giant shark** (`R.makeBigShark`): a 2.5 × 1.55 × 1.9 head over a dark mouth slot with an upper row of zigzag teeth and a hinged lower jaw (its own teeth), slanted angry eyes, a fin on top, a tail fin, four tall legs and a lollipop sign on its head. It chomps by rotating the jaw.
- **They never stand still:** blinks (eyes squash for 0.09 s every ~2.6 s), a lean when they "move in", a hop on a big word, arms that point or grab, chairs that slide.

### 3.6 Props (studio, hidden until a shot shows them)

The easel with a **card whose face is a canvas you redraw** (`P.cardCanvas`: brand row, title, price, a stamp, bite holes), a **phone** (`R.screenTex` draws its screen), a paper doc, a **gift box** (lid on a hinge, a gold bow that pops, a tag), a **cash register** (an LED that counts, a drawer that slides out), a **money stack** with a price tag that **catches fire** (flame cards + a flickering light), a **bookshelf of .md books** with a lit sign and a **paper app window** that frames it, a held doc, a small card, a paw in a suit sleeve, out-of-focus paws for a sharks'-eye shot, flying banknotes, paper scraps for bites, and **dust motes** that hang in front of the camera and blur into bokeh. Everything is drawn; nothing is a real file, logo or screenshot.

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

- **Banner:** torn strip 880 × 106 px from y 276, centred on x 510, a 0.6 % tilt that alternates per beat, paper fibres multiplied in, two tape strips 118 × 46 at the top corners (±0.78 rad); the title in Inter Tight 800 caps fitted to ≤ 770 px (34-54 px). **A new title on every cut**, with a 0.16 s 3.5 % pop.
- **Word tags:** each word its own torn orange tag (text width + 0.62 × size wide, 1.42 × size tall, ±3° tilt and ±6 px jitter from a hash of the word), 62 px; a word **pops in as it is said** (0.13 s, overshoot), the row re-centres as each new tag lands (0.12 s); a chunk holds ≤ 3 words and clears for the next.
- **Chyron:** slides in from the left in 0.2 s (out in 0.14 s), panel width = text + 150 (560-900), a wavy top edge with a light-blue line and white foam dots; Roboto Condensed 700 60 px from x 72, bottom edge y 1336. Its words appear only once the panel has landed (otherwise the last slide frames put them past the safe edge).
- **Bubble:** a white disc (radius 62-92 + a 9 px ring) with a drawn face clipped inside, a tail toward the speaker, a soft shadow; pops in 0.22 s with overshoot, bobs ±3 px. Hang it at the character's `top` + 0.35-0.42 units, projected every frame (`E.project`).
- **COMMENT bubble:** a 600 × 300 white ellipse with a tail at (510, ~1000): "COMMENT" small, grey and letter-spaced, the keyword up to 128 px in red; a burst ring with rays on the pop and stars twinkling around it. A **paper plane** flies off to the top right on "send".

### 3.8 The signature moments (keep all of them)

1. **The hook (0-3 s).** A giant shark gnaws the edge of a card on an easel in the dark blue studio (bites leave holes, paper scraps fly). The thing being sold arrives (money flies into its mouth, a red price tag slaps on) and on the reveal word **it rips the tag off**: the truth is printed underneath and **the windows light up orange one by one**, with a soft warm flash.
2. **The walk-in.** The narrator walks toward a glowing doorway under a lit sign (the doors swing open), seen from behind; a chyron says who it is.
3. **The sharks'-eye pitch.** From behind out-of-focus paws, the narrator presents a card on the easel; what it offers pops up behind the card (a phone, a doc); a chyron states the offer; a stamp lands on the last word.
4. **The row.** A pan across the armchairs under the glowing windows; bubbles pop over each character on its word; they lean in.
5. **Close-up + cut-away.** One character fills the frame with its bubble, then a whip to the object of its sentence (a box that closes with a bow, a register that rings up the price), a chyron on the "offer".
6. **The flat diorama.** A process shown straight on in paper: the conveyor stamping every card on the beat, a wall collecting sticky notes one per phrase (a string snipped on "attached"), an office stamping a doc.
7. **The turn and the payoff.** A price flips on its word; a shelf rises like a pop-up book and fills with books; money catches fire; the camera pulls back to frame it all.
8. **The CTA.** The wide row again: the signs flip to the good side on "free", bubbles over three characters, the COMMENT bubble on "comment", the plane on "send".

### 3.9 Post (`post.js`, `fx.js`, `look.js`)

ACES tone mapping, bloom 0.36-0.55 at threshold 0.9 (0.26 on flat sets), chromatic aberration 1.0 (edges only), vignette 0.36, **grain 0.028** (more blows up the file size), a warm grade when the studio is lit, depth of field per shot (aperture `ap` 6-13 in 3D, ~1.2 on flat sets), flashes capped at 0.24, horizontal whip blur ±0.09 s around cuts into cut-aways, a 4-5 % zoom punch into big reveals.

## 4. Writing for this format

- **The narrator speaks in the first person** (or describes "you"), fast and continuous: after `votight`, ~4.4 said words a second, so **110-135 words = 25-32 s**.
- **The shape** (the grammar the look was built for): **hook** "Most people don't realize …" → **setup** (who, what was done) → **"Then the … moved in."** (the characters arrive) → **"Like one who …, and another who …"** (two characters, two specific behaviours: each gets a close-up and a cut-away) → **the turn** ("So here's my response: …") → **the payoff** ("Because …" / "Which means …") → **the format or product line** → **CTA** "Good thing it's free: just comment KEYWORD and I'll send it to you."
- **Every sentence must be showable** as one set or prop: a price (a tag, a register, a flip), a giveaway (a card on the easel), a process (the conveyor), a list of three (sticky notes), a thing that ends (fire), a thing that's free (a stamp). One sentence = one or two shots.
- `script.mjs`: `LINES` = what the tags show, one sentence or clause per line; `SAID` = how the voice says a token with dots or symbols (`'SKILL.md': 'skill dot M D'`, `'$30': 'thirty dollars'`, `'.md': 'dot M D'`) and the keyword it must stress (`'PAPER': '"Paper"'`). A line after a full stop starts with a capital even when its first token is replaced.

## 5. The voice (ElevenLabs)

- **Liam** (`TX3LPaxmHKxFdv7VOQHJ`) on **eleven_multilingual_v2**, stability 0.4, similarity 0.8, style 0.5, speaker boost, **speed 1.2**: a young, energetic narrator (median pitch ~148 Hz). Another voice: put `voice` in `video.json`; try it with `--test 2 --take t1` first.
- **One take of the whole script**, then **`votight.mjs`**: the format talks at ~265 words a minute with no breathing room. It cuts every pause inside real silence (after `. ! ? :` it keeps 0.12 s, after `, ;` 0.06 s, else 0.04 s) and speeds the take up **×1.12** (1.04 of it by resampling, a touch brighter; the rest with the pitch kept). Don't go past ×1.15: fast words blur.
- **A word that comes out wrong** ("sharks showed up" heard as "shark showed up"): change the line if you must, make a one-line re-take (`gen-vo.mjs my-video --yes --only 3 --take s1`), list the words (`voedit.mjs my-video --list a`) and splice it in (`--out c --use a:0-39,s1:0-4,a:45-`), then tighten `c`.

## 6. Timings: film.json

`build.mjs` maps the shown tokens onto the said words (so "SKILL.md" spans "skill dot M D"), moves each word's start to where its sound starts, and writes `film.json`: `words` (each tag token with `t0`/`t1`, its line), `chunks` (the tag groups: the format's even chunks of ≤ 3 words per phrase, re-balanced only when a chunk would be wider than `chunkW`), `env` (loudness per frame) and `END` (last word + 1.3 s). It prints every token with its time, line by line, then the chunks: **you key everything to these.** `--fake` guesses timings at the tightened pace so you can direct and render stills before the voice exists; re-run it without `--fake` after the take and only nudge what moved.

## 7. Directing

### 7.1 `times.mjs`: the cuts

One function shared by the picture and the sound: `times(film)` returns `T('word', n)` (the n-th occurrence's start), `tok(line, k)`, `lineT(line)` and **`C`, one cut per beat, just before its sentence** (`lineT(3) - 0.09`), plus cuts inside long sentences on a key word (`T('repackaged') - 0.05`). The scene builds its shots between consecutive cuts; the cues put a whoosh on most of them.

### 7.2 The scene file

```js
import { times } from './times.mjs';
export const WORLD = { logo: ['SKILL', 'SHARKS'], door: 'MORPHY', signs: ['$30', 'FREE'], price: '$30', … };   // what the props say
export default function setup(E) {
  const { R, O, P, L, FX, X, film } = E, { T, C, lineT } = times(film);
  const SHOTS = [ shot('hook', P_keys, L_keys, { fov, ap, fy }), … ];     // one per cut: camera keys from the cut to the next
  function world(t, si) { defaults(); WORLDS[SHOTS[si].name](t); dust(t); }   // reset, then the current shot shows its set
  const WORLDS = { hook(t) { R.show('studio'); E.look(t, { set: 'studio', lit }); … }, … };
  function overlay(g, t) { bubbles…; O.banner(…); chyrons…; O.captions(g, film, t); }
  function effects(t) { FX.apply(E, t, { whip, flash, zoom }); }
  return { SHOTS, IMPULSE, world, overlay, effects };
}
```

Copy the helpers from Appendix B as they are (`shot`, `defaults`, `blink`, `walk`, `card`, `scraps`, `dust`, `stepAt`, `bub`, `sparkle`): a new video rewrites `WORLD`, the shot list, the per-shot functions, `BANNERS`, `CHYRONS` and the cues.

### 7.3 What the engine gives you (`E`)

| Call | What it does |
|---|---|
| `R.show(name)` · `E.look(t, { set, lit, spot, key, keyPos, fire, warm, bloom, exposure, winGain })` | one set visible · its lights and grade (`lit` 0 night → 1 glowing; `R.winMats[i].uniforms.k` lights one window) |
| `R.sharks[i]` · `R.chairs[i]` · `R.signs[i]` | the seated blocks (`pivot`, `arms`, `eyes`, `top`) · their chairs (move them) · signs (`g` raise, `card.rotation.y = π` flips) |
| `R.critter` · `R.critter2` (+ `R.carry`) · `R.big` | the narrator in the studio · walking in the corridor · the giant shark (`jaw`, `head`, `sign`) |
| `R.easel` + `R.card` + `R.cardTex.set(key, draw)` | the easel card: redraw only when `key` changes (`P.cardCanvas({...})`) |
| `R.phone` (`R.screenTex`), `R.skillDoc`, `R.gift` (`R.giftLid`, `R.bow`, `R.giftTag`), `R.register` (`R.ledTex`, `R.drawer`), `R.money` (`R.moneyTag`) + `R.fire` (`R.flames`), `R.shelf` (`R.books`) + `R.appFrame`, `R.paperDoc`, `R.miniCard`, `R.suitPaw`, `R.fgPaws`, `R.flyBills`, `R.scraps`, `R.dust` | studio props |
| `R.doors`, `R.doorSignM` · `R.feltCards`, `R.press`/`R.pressHead`, `R.piggy`, `R.marqueeTex`, `R.lever`, `R.flatSeller`, `R.flatMe` · `R.wallSignTex`, `R.notes`, `R.flatMe2`, `R.string`/`R.stringLow`, `R.scissors` · `R.officeDoc`/`R.docTex`, `R.people`, `R.check` | the corridor · felt · brick · office |
| `E.project(objOrPoint, [dx, dy, dz])` | a 3D point → overlay pixels `{ x, y, behind }` (bubbles, sparkles) |
| `O.banner` `O.captions` `O.chyron` `O.bubble` `O.commentBubble` `O.plane` · `P.avatarCanvas(kind)` `P.starCanvas()` | the 2D layer · drawn faces (hoodie, suit, pearls, blonde, cap, me) and a star |
| `FX.apply` `FX.whips` `FX.flash` `FX.punchIn` `FX.punchOut` · `IMPULSE: [[t, amount]]` | post effects · a camera shake |
| `R.makeBlock(o)` `R.makeCritter()` `R.makeChair()` `R.box(w, h, d, R.mat(c))` `R.plane` `R.dynTex(w, h)` | more characters and props (add them to `R.sets.<name>` in `setup`) |

### 7.4 Framing a 9:16 frame

- At fov 40 the frame is only **~23° wide**: a 1.3-wide block fills the width at ~3.2 units. Close-ups **4-5 units**, a block with its chair **6-7**, a card with the narrator **~6.8**, the shelf **~10**; three sharks at fov 52 need **~12** (the CTA moves the three middle chairs 2.45 apart).
- Flat sets: straight on from 13-16 units at fov 34-36 (~5-6 units across).
- Keep the subject between the banner (y 276-382) and the chyron (from y ~1130): `fy` (0.46-0.56) is where the look-at lands on screen. A bubble needs ~200 px above a head.

### 7.5 The edit follows the words

- **On the word, not near it.** A bubble pops on its character's word; a bite, stamp, slap or flip lands on the stressed word (+0-0.1 s) with its sound; a chyron slides in on the fact and stays through the beat; the reveal (the lights) lands on the reveal word.
- **A new shot every 1-2.5 s** (17 in the 30 s example), a new banner title on each cut, whips into cut-aways and flat sets, a zoom punch into the big reveals.
- **Nothing sits still for more than ~0.3 s:** dust, push-ins, blinks, bobbing bubbles, the conveyor, the flames.
- **Frame 0 is the cover-worthy hook:** the set, the shark at the card, the banner (no pop on the first title), the first tag.

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

1. **Reset every frame:** `world(t)` hides every prop and puts every moved part back (`defaults()`), then the current shot sets what it needs. Every frame renders alone, in any order: **no `Math.random`, no state between frames** (use `L.hash`, seeds).
2. **A plane on a box face at the same depth z-fights** (a sign or doc goes blank): keep text planes ≥ 0.01 in front of their board.
3. **Billboards** (flames) turn toward the camera inside `world(t)`; the camera is already placed when `world` runs.
4. **Dynamic textures** redraw only when their key changes (`R.cardTex.set('flipB', …)`): never redraw every frame.
5. **Keep effects off key words** in the mix (section 10).

## 8. Stills and the safe zone

```bash
node engine/render.mjs my-video stills 0.6,3,7,10,14,18,22,26,29 --safe
```
Look at `stills/sheet-safe.jpg` (red = keep text out, cyan = the tag band, every text box green or red). The run prints `OUTSIDE SAFE ZONE` lines and page errors: both must be empty. **The first stills of a new shot are almost always too tight** (portrait width): pull the camera back. Check the hook (shark at the card, readable), a close-up (bubble below the banner), a chyron beat (the card's key line above the chyron), a flat set (centred, nothing important under the banner), the CTA (three characters, the COMMENT bubble). Then stills without `--safe` for colour.

## 9. The full render

`node engine/render.mjs my-video full [from] [to]` writes `frames/f0000.jpg…` (about 0.15-0.5 s a frame on a mid-range GPU: ~3 min for 30 s). It **resumes** (existing frames are skipped): after any change to `scene.js` or `times.mjs`, delete `frames/` (or just the frames you changed: `f%04d.jpg` = t × 30). Run it in chunks under your tool timeout.

## 10. Sound

- **The bed:** Mixkit "Coffee or Tea" from 8 s, `db −10`: about 13 LU under the voice after the voice ducks it. `STEM=vo` / `STEM=music node engine/mix.mjs my-video` measure them; effects sit ~7 LU under the voice.
- **The kit** (`sfx/<name>.wav`, made once by `gen-sfx.mjs`): `chomp` (bites), `rip` (a tag torn off), `kaching` (a register), `stamp`, `slap` (a tag or note lands), `whoosh` / `swish` / `swoosh` (cuts, things flying), `pop` (bubbles, pop-ups), `thud` (a lid), `boing` (a hop), `flick` (signs flip, a card turns), `clunk` (the lights switch on), `ding` / `shimmer` / `chime` (good things, the name), `click` (footsteps, books), `clack` (the conveyor), `burst` / `burn` / `fuse` (fire), `impact` (low, under the CTA), `plane` (the DM flies off).
- **Levels and timing:** most cues −9 to −20 dB; `ev(name, t, dB)` starts the file at `t`, so subtract each sound's onset (the example's `ON` table) to land the hit on the picture.
- **Keep effects off key words:** a chomp on a name or a footstep on a fast word can make it unintelligible (a speech recogniser heard "Claude" as "quad" and "agentic" as "agendix"). Move the cue, or add a `muffle` window (`video.json` `music.muffle: [[t0, t1]]` low-passes the bed there).

## 11. Encode, check, deliver

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

## 12. Checklist

- [ ] 110-135 words in the format's grammar; every sentence showable; the CTA keyword one easy word.
- [ ] The voice tightened (`votight`, ~265 wpm) and every word clear.
- [ ] Frame 0 = the hook; the reveal lights the windows; a new banner title on every cut.
- [ ] A new shot every 1-2.5 s; bubbles on their characters' words; chyrons on the facts; stamps and slaps on the stressed words; nothing still for more than ~0.3 s.
- [ ] Drawn faces only; no real show, logo, screenshot or handle.
- [ ] `--safe` stills clean; `check.mjs` all ✓; watched once with sound.

---

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

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

### `engine/film.html`

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

### `engine/main.js`

```js
// Engine boot: fonts → the video's film.json + scene.js → world, post, overlay → window.setT(t). Engine file.
// The video folder comes from film.html?video=<folder> (relative to the kit root; '.' = the root).
// Every frame is a pure function of film time t, so any frame can be rendered alone, in any order.
import * as THREE from 'three';
import { build, CORRIDOR_X, FELT_X, BRICK_X, OFFICE_X } from './world.js';
import { makePost } from './post.js';
import * as O from './overlay.js';
import * as P from './papertex.js';
import * as L from './lib.js';
import * as FX from './fx.js';
import { placeCamera, FIT, minDistance } from './camera.js';
import { applyLook } from './look.js';

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

// Fonts: fonts/fonts.json lists the files this kit has. The licensed originals win when they are there (Cooper Black ships
// with Microsoft Office, Consolas with Windows); otherwise the free look-alikes (SIL OFL, google/fonts) with their axes pinned:
// Fraunces at SOFT 100 / WONK 1 / opsz 144 / wght 900 is drawn after the Cooper series, so the word tags keep their look.
const FACES = {
  'Inter Tight 800': [['InterTight-800.ttf'], ['InterTight.ttf', { variationSettings: '"wght" 800' }]],
  'Cooper Black': [['COOPBL.TTF'], ['Fraunces.ttf', { variationSettings: '"SOFT" 100, "WONK" 1, "opsz" 144, "wght" 900' }]],
  'Consolas Bold': [['consolab.ttf'], ['JetBrainsMono.ttf', { variationSettings: '"wght" 800' }]],
  'Roboto Condensed': [['RobotoCondensed.ttf', { weight: '100 900' }]],
};
const fontUrl = f => new URL(`../fonts/${f}`, import.meta.url);
let HAVE = null; try { HAVE = new Set(await (await fetch(fontUrl('fonts.json'), { cache: 'no-store' })).json()); } catch { }
for (const [family, list] of Object.entries(FACES)) {
  let ok = false;
  for (const [file, desc = {}] of list) {
    if (HAVE && !HAVE.has(file)) continue;
    try { const ff = new FontFace(family, `url(${fontUrl(file)})`, desc); await ff.load(); document.fonts.add(ff); ok = true; break; } catch { }
  }
  if (!ok) console.error(`font "${family}" missing: put one of ${list.map(x => x[0]).join(' / ')} in fonts/ (and list it in fonts.json)`);
}
const film = await (await fetch(new URL('film.json', base), { cache: 'no-store' })).json();
const mod = await import(new URL('scene.js', base).href);
const END = film.END;
window.TOTAL = END; window.film = film;

const renderer = new THREE.WebGLRenderer({ antialias: false, preserveDrawingBuffer: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(1); renderer.setSize(W, H); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.NoToneMapping; renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);
const camera = new THREE.PerspectiveCamera(40, W / H, 0.1, 200);
const { scene, R } = build(mod.WORLD || {});
scene.background = new THREE.Color(0x05060c);
const post = makePost(renderer, W, H);

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

const _v = new THREE.Vector3();
const E = {
  THREE, R, scene, post, camera, renderer, film, END, W, H, O, P, L, FX, FIT, minDistance, X: { CORRIDOR_X, FELT_X, BRICK_X, OFFICE_X },
  look: (t, o) => applyLook(E, t, o), V: (x, y, z) => new THREE.Vector3(x, y, z),
  // a world point (or an object's world position + offset) → overlay pixels { x, y, behind }
  project(p, off = [0, 0, 0]) { if (p.isObject3D) { p.getWorldPosition(_v); } else _v.set(p[0], p[1], p[2]); _v.x += off[0]; _v.y += off[1]; _v.z += off[2];
    _v.project(camera); return { x: (_v.x + 1) / 2 * W, y: (1 - _v.y) / 2 * H, behind: _v.z > 1 }; },
};
const V = mod.default(E);
for (const k of ['SHOTS', 'overlay', 'world']) if (!V[k]) throw new Error(`scene.js setup() must return ${k}`);

window.framing = {};
function setT(t) {
  t = L.clamp(t, 0, 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, cam.shot);
  scene.updateMatrixWorld(true); camera.updateMatrixWorld();
  V.effects ? V.effects(t) : FX.apply(E, t, {});
  og.clearRect(0, 0, W, H); O.beginFrame(); og.save(); V.overlay(og, t); og.restore(); oTex.needsUpdate = true;
  post.render(scene, camera, null);
  if (safeOn) O.drawSafe(scv.getContext('2d'));
  return true;
}
window.setT = setT;
window.safeReport = () => O.violations();
window.textBoxes = () => O.boxes().map(b => ({ ...b, x0: Math.round(b.x0), x1: Math.round(b.x1), y0: Math.round(b.y0), y1: Math.round(b.y1) }));
setT(+(q.get('t') || 0));
window.__ready = true;
```

### `engine/lib.js`

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

### `engine/tex.js`

```js
// Shared canvas helpers (drawn once, deterministic): the font names, a canvas factory and the paper-fibre texture
// the 2D layer multiplies over its torn paper. Engine file. Never put real files, handles, reels or view counts on a texture.
import { rng } from './lib.js';
export const FONT = { sans: '"Inter Tight 800"', coop: '"Cooper Black"', mono: '"Consolas Bold"', cond: '"Roboto Condensed"' };
export function cv(w, h) { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; }

// cardboard / paper fibres used by the 2D overlay (multiplied over 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] = v; 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; const 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;
}
```

### `engine/papertex.js`

```js
// Canvas textures of the papercraft world (drawn once at load, deterministic). Everything looks cut from paper:
// mottled construction paper, felt, cardboard, brick, a painted rug, paper signs. Never real files, handles or faces.
import { rng } from './lib.js';
import { cv, FONT } from './tex.js';

// value-noise field 0..1 (tileable), octaves of a random grid
function field(W, H, seed, scales, weights) {
  const R = rng(seed), out = new Float32Array(W * H);
  for (let o = 0; o < scales.length; o++) {
    const n = scales[o], g = []; for (let i = 0; i < (n + 1) * (n + 1); i++) g.push(R());
    const at = (i, j) => g[(j % n) * (n + 1) + (i % n)];
    for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
      const fx = x / W * n, fy = y / H * 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);
      out[y * W + x] += weights[o] * ((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);
    }
  }
  return out;
}
const hex = c => [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)];

// construction paper: a base colour with darker blotches, lighter fibres and a fine grain (the reference's mottled orange)
export function paperColor(base = '#e8784a', { seed = 1, size = 512, blot = 0.22, grain = 0.06, dark = null } = {}) {
  const c = cv(size, size), g = c.getContext('2d'), img = g.createImageData(size, size), [r, gg, b] = hex(base);
  const [dr, dg, db] = dark ? hex(dark) : [r * 0.62, gg * 0.5, b * 0.42];
  const F = field(size, size, seed, [3, 7, 16], [0.55, 0.3, 0.15]), R = rng(seed + 9);
  for (let i = 0; i < size * size; i++) {
    let k = Math.min(1, Math.max(0, (F[i] - 0.42) * 2.4)) * blot; // blotches
    const n = (R() - 0.5) * grain;
    img.data[i * 4] = Math.max(0, Math.min(255, r + (dr - r) * k + n * 255));
    img.data[i * 4 + 1] = Math.max(0, Math.min(255, gg + (dg - gg) * k + n * 255));
    img.data[i * 4 + 2] = Math.max(0, Math.min(255, b + (db - b) * k + n * 255));
    img.data[i * 4 + 3] = 255;
  }
  g.putImageData(img, 0, 0);
  for (let k = 0; k < 140; k++) { // fibres
    g.strokeStyle = `rgba(255,240,220,${0.03 + R() * 0.05})`; g.lineWidth = 0.6 + R() * 1.2;
    g.beginPath(); let x = R() * size, y = R() * size; g.moveTo(x, y); for (let s = 0; s < 4; s++) { x += (R() - 0.5) * 60; y += (R() - 0.5) * 60; g.lineTo(x, y); } g.stroke();
  }
  return c;
}

// grey fibre multiply map (for materials that keep their own colour)
export function fibreMap(seed = 3, size = 512, lo = 0.8) {
  const c = cv(size, size), g = c.getContext('2d'), img = g.createImageData(size, size);
  const F = field(size, size, seed, [4, 11, 30], [0.5, 0.3, 0.2]), R = rng(seed + 1);
  for (let i = 0; i < size * size; i++) { const v = 255 * (lo + (1 - lo) * Math.min(1, F[i] * 1.2) - R() * 0.04); img.data[i * 4] = img.data[i * 4 + 1] = img.data[i * 4 + 2] = v; img.data[i * 4 + 3] = 255; }
  g.putImageData(img, 0, 0); return c;
}

// the studio's grid window: panes + mullions. lit = false (cool night blue) | true (warm, glowing centre)
export function windowCanvas(lit) {
  const W = 512, H = 720, c = cv(W, H), g = c.getContext('2d'), R = rng(lit ? 21 : 22);
  g.fillStyle = lit ? '#3a1d0e' : '#0d1530'; g.fillRect(0, 0, W, H);
  const cols = 4, rows = 6, m = 24, pw = (W - m * (cols + 1)) / cols, ph = (H - m * (rows + 1)) / rows;
  for (let j = 0; j < rows; j++) for (let i = 0; i < cols; i++) {
    const x = m + i * (pw + m), y = m + j * (ph + m);
    const gr = g.createRadialGradient(W / 2, H * 0.45, 20, W / 2, H * 0.45, W * 0.9);
    if (lit) { gr.addColorStop(0, '#ffd08a'); gr.addColorStop(0.4, '#ff9d45'); gr.addColorStop(1, '#c4521f'); }
    else { gr.addColorStop(0, '#3c64c8'); gr.addColorStop(0.6, '#2a4797'); gr.addColorStop(1, '#1b2f6c'); }
    g.fillStyle = gr; g.fillRect(x, y, pw, ph);
    g.fillStyle = `rgba(255,255,255,${0.04 + R() * 0.06})`; g.fillRect(x + 4, y + 4, pw * 0.35, ph - 8); // sheen
  }
  return c;
}

// the show's logo panel: chunky metallic two-line wordmark on navy
export function logoCanvas(l1 = 'SKILL', l2 = 'SHARKS') {
  const W = 640, H = 420, c = cv(W, H), g = c.getContext('2d');
  g.fillStyle = '#121a3f'; g.fillRect(0, 0, W, H);
  g.strokeStyle = '#6f9ee8'; g.lineWidth = 10; g.strokeRect(14, 14, W - 28, H - 28);
  const word = (t, y, size) => {
    g.font = `${size}px ${FONT.sans}`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.letterSpacing = '-2px';
    g.save(); g.translate(W / 2, y); g.transform(1, 0, -0.12, 1, 0, 0);
    g.fillStyle = '#0a1028'; g.fillText(t, 6, 8);
    const gr = g.createLinearGradient(0, -size / 2, 0, size / 2); gr.addColorStop(0, '#f2f8ff'); gr.addColorStop(0.45, '#a9c9f2'); gr.addColorStop(0.55, '#6f93cf'); gr.addColorStop(1, '#c9defa');
    g.fillStyle = gr; g.fillText(t, 0, 0); g.lineWidth = 3; g.strokeStyle = '#2b4a8f'; g.strokeText(t, 0, 0); g.restore();
  };
  word(l1, 140, 150); word(l2, 290, 150);
  return c;
}

// painted rug: deep blue, a gold/orange swirl medallion, red-gold border
export function rugCanvas() {
  const W = 1024, H = 1024, c = cv(W, H), g = c.getContext('2d'), R = rng(31);
  g.fillStyle = '#17286a'; g.fillRect(0, 0, W, H);
  for (let k = 0; k < 3000; k++) { g.fillStyle = `rgba(${R() < 0.5 ? '8,14,50' : '50,80,170'},${R() * 0.22})`; g.fillRect(R() * W, R() * H, 2 + R() * 4, 2 + R() * 4); }
  g.lineWidth = 26; g.strokeStyle = '#7d2a1a'; g.strokeRect(34, 34, W - 68, H - 68);
  g.lineWidth = 6; g.strokeStyle = '#d9993a'; g.strokeRect(62, 62, W - 124, H - 124);
  const cx = W / 2, cy = H / 2;
  // thin ornamental swirls (gold, orange, a little red), low contrast like a printed rug
  for (let k = 0; k < 16; k++) {
    const a0 = k / 16 * Math.PI * 2, col = ['rgba(232,138,58,0.85)', 'rgba(242,193,78,0.8)', 'rgba(196,71,42,0.75)'][k % 3];
    g.strokeStyle = col; g.lineWidth = 9 - (k % 3) * 2; g.lineCap = 'round'; g.beginPath();
    for (let s2 = 0; s2 <= 34; s2++) { const a = a0 + s2 * 0.045, r = 120 + s2 * 9 + Math.sin(s2 * 0.6 + k) * 14; const x = cx + Math.cos(a) * r, y = cy + Math.sin(a) * r; s2 ? g.lineTo(x, y) : g.moveTo(x, y); }
    g.stroke();
    const a = a0 + 1.6, r = 300 + (k % 2) * 60; g.beginPath(); g.arc(cx + Math.cos(a) * r, cy + Math.sin(a) * r, 22, 0, Math.PI * 1.5); g.stroke();
  }
  for (let k = 0; k < 40; k++) { const a = R() * 7, r = 150 + R() * 300; g.fillStyle = ['#e98a3a', '#f2c14e', '#c4472a'][k % 3]; g.beginPath(); g.ellipse(cx + Math.cos(a) * r, cy + Math.sin(a) * r, 10, 5, a, 0, 7); g.fill(); }
  const md = g.createRadialGradient(cx, cy, 6, cx, cy, 96); md.addColorStop(0, '#ffe08a'); md.addColorStop(0.7, '#e9a83a'); md.addColorStop(1, '#b86a1e');
  g.fillStyle = md; g.beginPath(); for (let k = 0; k <= 24; k++) { const a = k / 24 * Math.PI * 2, r = k % 2 ? 62 : 94; g.lineTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r * 0.8); } g.closePath(); g.fill();
  return c;
}

export function floorCanvas() {
  const W = 1024, H = 1024, c = cv(W, H), g = c.getContext('2d'), R = rng(41), n = 8, pw = W / n;
  for (let i = 0; i < n; i++) {
    let y = -R() * 400;
    while (y < H) { const len = 300 + R() * 500, v = 58 + R() * 18; g.fillStyle = `rgb(${v + 20},${v - 8},${v - 30})`; g.fillRect(i * pw, y, pw, len);
      for (let k = 0; k < 10; k++) { g.strokeStyle = `rgba(30,12,4,${0.1 + R() * 0.15})`; g.lineWidth = 1 + R() * 2; const x = i * pw + R() * pw; g.beginPath(); g.moveTo(x, y); g.lineTo(x + (R() - 0.5) * 8, y + len); g.stroke(); }
      g.fillStyle = 'rgba(20,8,2,0.7)'; g.fillRect(i * pw, y, pw, 4); y += len; }
    g.fillStyle = 'rgba(15,6,2,0.85)'; g.fillRect(i * pw, 0, 5, H);
  }
  return c;
}

// tufted leather for the armchairs
export function leatherCanvas() {
  const W = 512, H = 512, c = cv(W, H), g = c.getContext('2d'), R = rng(51);
  g.fillStyle = '#6e2d1d'; g.fillRect(0, 0, W, H);
  for (let k = 0; k < 4000; k++) { g.fillStyle = `rgba(${R() < 0.5 ? '40,10,4' : '150,70,45'},${R() * 0.18})`; g.fillRect(R() * W, R() * H, 2 + R() * 6, 2 + R() * 6); }
  for (let y = 40; y < H; y += 85) for (let x = (y / 85 % 2) * 42 + 20; x < W; x += 85) {
    const gr = g.createRadialGradient(x, y, 2, x, y, 30); gr.addColorStop(0, 'rgba(25,6,2,0.65)'); gr.addColorStop(1, 'rgba(25,6,2,0)'); g.fillStyle = gr; g.fillRect(x - 30, y - 30, 60, 60);
  }
  return c;
}

// a paper sign card (the easel board, the wall sign): white paper with a small brand row and one or two lines
// o = { brand, title, price, priceCol, sub, stamp, stampCol, bite: [[x, y, r], …] (cut-outs, 0..1 coords) }
export function cardCanvas(o = {}) {
  const W = 1024, H = 640, c = cv(W, H), g = c.getContext('2d'), R = rng(61);
  g.fillStyle = '#f7f4ee'; g.fillRect(0, 0, W, H);
  for (let k = 0; k < 1500; k++) { g.fillStyle = `rgba(120,110,95,${R() * 0.06})`; g.fillRect(R() * W, R() * H, 2 + R() * 3, 2 + R() * 3); }
  if (o.brand) {
    g.fillStyle = o.brandCol || '#e8603c'; g.beginPath(); g.roundRect(60, 54, 64, 64, 14); g.fill();
    g.fillStyle = '#fff'; g.font = `40px ${FONT.sans}`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(o.brandIcon || '✳', 92, 88);
    g.fillStyle = o.brandCol || '#e8603c'; g.font = `40px ${FONT.sans}`; g.textAlign = 'left'; g.fillText(o.brand, 144, 90);
  }
  if (o.title) { g.fillStyle = '#1d1d1f'; g.textAlign = 'center'; g.textBaseline = 'middle'; let s = o.titleSize || 86; g.font = `${s}px ${FONT.sans}`; while (g.measureText(o.title).width > W - 120 && s > 40) { s -= 4; g.font = `${s}px ${FONT.sans}`; } g.fillText(o.title, W / 2, o.price ? 255 : 330); }
  if (o.price) {
    let s = o.priceSize || 210; g.font = `${s}px ${FONT.sans}`; g.textAlign = 'center'; g.textBaseline = 'middle';
    g.fillStyle = 'rgba(0,0,0,0.12)'; g.fillText(o.price, W / 2 + 6, 445 + 8);
    g.fillStyle = o.priceCol || '#2f9e57'; g.fillText(o.price, W / 2, 445);
  }
  if (o.sub) { g.fillStyle = '#6b6b6b'; g.font = `34px ${FONT.sans}`; g.textAlign = 'center'; g.fillText(o.sub, W / 2, 590); }
  if (o.stamp) {
    g.save(); g.translate(W * 0.7, H * 0.62); g.rotate(-0.16); g.globalAlpha = 0.92;
    g.strokeStyle = o.stampCol || '#d23a2a'; g.fillStyle = o.stampCol || '#d23a2a'; g.lineWidth = 14;
    g.font = `120px ${FONT.sans}`; g.textAlign = 'center'; g.textBaseline = 'middle'; const tw = g.measureText(o.stamp).width;
    g.strokeRect(-tw / 2 - 30, -78, tw + 60, 156); g.fillText(o.stamp, 0, 6); g.restore();
  }
  if (o.bite) { g.globalCompositeOperation = 'destination-out'; for (const [x, y, r] of o.bite) { g.beginPath(); for (let k = 0; k <= 18; k++) { const a = k / 18 * Math.PI * 2, rr = r * W * (k % 2 ? 0.8 : 1); g.lineTo(x * W + Math.cos(a) * rr, y * H + Math.sin(a) * rr); } g.fill(); } g.globalCompositeOperation = 'source-over'; }
  return c;
}

// a red / green sign on a stick: "I'M OUT" · "$30" · "FREE"
export function signCanvas(text, bg = '#d93b2b', ink = '#fff4ec') {
  const W = 512, H = 300, c = cv(W, H), g = c.getContext('2d'), R = rng(text.length * 7 + 3);
  g.fillStyle = bg; g.fillRect(0, 0, W, H);
  for (let k = 0; k < 900; k++) { g.fillStyle = `rgba(0,0,0,${R() * 0.08})`; g.fillRect(R() * W, R() * H, 2 + R() * 4, 2 + R() * 4); }
  g.fillStyle = ink; let s = 150; g.font = `${s}px ${FONT.sans}`; while (g.measureText(text).width > W - 70) { s -= 6; g.font = `${s}px ${FONT.sans}`; }
  g.textAlign = 'center'; g.textBaseline = 'middle'; g.save(); g.translate(W / 2, H / 2 + 6); g.rotate(-0.04); g.fillText(text, 0, 0); g.restore();
  return c;
}

// LED display of the cash register
export function ledCanvas(text) {
  const W = 512, H = 160, c = cv(W, H), g = c.getContext('2d');
  g.fillStyle = '#04140a'; g.fillRect(0, 0, W, H);
  g.font = `112px "Consolas Bold"`; g.textAlign = 'center'; g.textBaseline = 'middle';
  g.shadowColor = '#3dff8a'; g.shadowBlur = 24; g.fillStyle = '#8dffb6'; g.fillText(text, W / 2, H / 2 + 6);
  return c;
}

// banknote (paper money, our own design: no real currency art)
export function billCanvas(seed = 1) {
  const W = 512, H = 230, c = cv(W, H), g = c.getContext('2d'), R = rng(70 + seed);
  g.fillStyle = '#9fd28b'; g.fillRect(0, 0, W, H);
  g.strokeStyle = '#3e7a3b'; g.lineWidth = 10; g.strokeRect(14, 14, W - 28, H - 28);
  g.fillStyle = '#5d9a52'; g.beginPath(); g.ellipse(W / 2, H / 2, 70, 70, 0, 0, 7); g.fill();
  g.fillStyle = '#e9f6df'; g.font = `110px ${FONT.sans}`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText('$', W / 2, H / 2 + 6);
  g.fillStyle = '#3e7a3b'; g.font = `40px ${FONT.sans}`; g.fillText('100', 70, 52); g.fillText('100', W - 70, H - 46);
  for (let k = 0; k < 600; k++) { g.fillStyle = `rgba(30,70,30,${R() * 0.12})`; g.fillRect(R() * W, R() * H, 2, 2); }
  return c;
}

// green felt, brown cardboard wall, grey concrete: big flat backdrops for the 2D diorama scenes
export function feltCanvas(base = '#1f5a45', seed = 81) { return paperColor(base, { seed, size: 1024, blot: 0.32, grain: 0.07, dark: '#123a2c' }); }
export function brickCanvas() {
  const W = 1024, H = 512, c = cv(W, H), g = c.getContext('2d'), R = rng(91);
  g.fillStyle = '#5e2c19'; g.fillRect(0, 0, W, H);
  const bw = 128, bh = 56, m = 8;
  for (let j = 0; j * (bh + m) < H; j++) for (let i = -1; i * (bw + m) < W; i++) {
    const x = i * (bw + m) + (j % 2) * (bw / 2), y = j * (bh + m) + m / 2, v = R();
    g.fillStyle = `rgb(${168 + v * 30},${78 + v * 18},${46 + v * 12})`; g.fillRect(x, y, bw, bh);
    g.fillStyle = 'rgba(255,220,180,0.1)'; g.fillRect(x, y, bw, 6); g.fillStyle = 'rgba(40,10,4,0.18)'; g.fillRect(x, y + bh - 6, bw, 6);
  }
  return c;
}

// sticky note with a word (marker-ish heavy sans)
export function noteCanvas(text, bg) {
  const W = 400, H = 360, c = cv(W, H), g = c.getContext('2d'), R = rng(text.length * 13 + 5);
  g.fillStyle = bg; g.fillRect(0, 0, W, H);
  g.fillStyle = 'rgba(0,0,0,0.07)'; g.fillRect(0, 0, W, 44);
  for (let k = 0; k < 500; k++) { g.fillStyle = `rgba(0,0,0,${R() * 0.05})`; g.fillRect(R() * W, R() * H, 2 + R() * 3, 2 + R() * 3); }
  g.fillStyle = '#1b1b1d'; g.textAlign = 'center'; g.textBaseline = 'middle';
  const lines = text.split('\n'); let s = 76; g.font = `${s}px ${FONT.sans}`;
  while (Math.max(...lines.map(l => g.measureText(l).width)) > W - 50 && s > 30) { s -= 3; g.font = `${s}px ${FONT.sans}`; }
  lines.forEach((l, i) => g.fillText(l, W / 2, H / 2 + 14 + (i - (lines.length - 1) / 2) * s * 1.08));
  return c;
}

// a book spine of the library (paper-wrapped): the format's name + a .md tag
export function spineCanvas(name, bg, ink = '#fff8ee', isNew = false) {
  const W = 160, H = 640, c = cv(W, H), g = c.getContext('2d'), R = rng(name.length * 17 + 1);
  g.fillStyle = bg; g.fillRect(0, 0, W, H);
  for (let k = 0; k < 500; k++) { g.fillStyle = `rgba(0,0,0,${R() * 0.07})`; g.fillRect(R() * W, R() * H, 2 + R() * 4, 2 + R() * 4); }
  g.fillStyle = 'rgba(255,255,255,0.18)'; g.fillRect(0, 40, W, 12); g.fillRect(0, H - 52, W, 12);
  g.save(); g.translate(W / 2 + 10, H / 2); g.rotate(-Math.PI / 2); g.fillStyle = ink; g.textAlign = 'center'; g.textBaseline = 'middle';
  let s = 70; g.font = `${s}px ${FONT.sans}`; while (g.measureText(name).width > H - 150 && s > 30) { s -= 3; g.font = `${s}px ${FONT.sans}`; }
  g.fillText(name, 0, 0); g.restore();
  if (isNew) { g.fillStyle = '#ffd54a'; g.beginPath(); g.arc(W / 2, 92, 46, 0, 7); g.fill(); g.fillStyle = '#1b1b1d'; g.font = `30px ${FONT.sans}`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText('NEW', W / 2, 94); }
  return c;
}

// a text file on paper: title row + lines of "text" (paper.md, SKILL.md)
export function docCanvas(title, { stamp = null, stampCol = '#2f9e57', lines = 9 } = {}) {
  const W = 512, H = 680, c = cv(W, H), g = c.getContext('2d'), R = rng(title.length * 11 + 7);
  g.fillStyle = '#fbf8f2'; g.fillRect(0, 0, W, H);
  for (let k = 0; k < 900; k++) { g.fillStyle = `rgba(120,110,95,${R() * 0.07})`; g.fillRect(R() * W, R() * H, 2 + R() * 3, 2 + R() * 3); }
  g.fillStyle = '#1d1d1f'; let s = 72; g.font = `${s}px ${FONT.sans}`; while (g.measureText(title).width > W - 80) { s -= 3; g.font = `${s}px ${FONT.sans}`; }
  g.textAlign = 'left'; g.textBaseline = 'middle'; g.fillText(title, 40, 90);
  g.fillStyle = '#e8603c'; g.fillRect(40, 140, 120, 10);
  for (let k = 0; k < lines; k++) { const w = 200 + R() * 220; g.fillStyle = k % 4 === 0 ? '#3b3b40' : '#b9b4ab'; g.fillRect(40, 190 + k * 48, k % 4 === 0 ? w * 0.6 : w, k % 4 === 0 ? 20 : 14); }
  if (stamp) {
    g.save(); g.translate(W * 0.6, H * 0.66); g.rotate(-0.2); g.strokeStyle = stampCol; g.fillStyle = stampCol; g.lineWidth = 12; g.globalAlpha = 0.95;
    g.font = `112px ${FONT.sans}`; g.textAlign = 'center'; g.textBaseline = 'middle'; const tw = g.measureText(stamp).width; g.strokeRect(-tw / 2 - 26, -70, tw + 52, 140); g.fillText(stamp, 0, 6); g.restore();
  }
  return c;
}

// the format on the phone: a tiny papercraft reel (banner, orange critter, word tags) — drawn, not a real reel
export function formatScreen() {
  const W = 360, H = 640, c = cv(W, H), g = c.getContext('2d');
  const sky = g.createLinearGradient(0, 0, 0, H); sky.addColorStop(0, '#1b2350'); sky.addColorStop(1, '#3a1f17'); g.fillStyle = sky; g.fillRect(0, 0, W, H);
  for (let i = 0; i < 3; i++) { const gr = g.createLinearGradient(0, 160, 0, 380); gr.addColorStop(0, '#ffcf7a'); gr.addColorStop(1, '#e0702f'); g.fillStyle = gr; g.fillRect(30 + i * 108, 150, 84, 220); g.fillStyle = '#3a1d0e'; for (let k = 1; k < 4; k++) g.fillRect(30 + i * 108, 150 + k * 55, 84, 6); g.fillRect(30 + i * 108 + 40, 150, 6, 220); }
  g.fillStyle = '#f3eee4'; g.fillRect(28, 50, W - 56, 52); g.fillStyle = '#d9c99c'; g.fillRect(22, 44, 40, 18); g.fillRect(W - 62, 44, 40, 18);
  g.fillStyle = '#141414'; g.font = `24px ${FONT.sans}`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText('A PAPER STORY', W / 2, 78);
  g.fillStyle = '#e8784a'; g.fillRect(120, 380, 120, 96); g.fillStyle = '#141414'; g.fillRect(146, 408, 16, 24); g.fillRect(198, 408, 16, 24);
  g.fillStyle = '#e8784a'; for (let k = 0; k < 4; k++) g.fillRect(128 + k * 28, 476, 14, 36);
  const tag = (t, x) => { g.font = `30px ${FONT.coop}`; const w = g.measureText(t).width + 22; g.fillStyle = '#cf6a43'; g.fillRect(x - w / 2, 548, w, 44); g.fillStyle = '#fdf3e4'; g.fillText(t, x, 571); };
  tag('your', 120); tag('story', 236);
  return c;
}

// flame tongue (additive sprite): white-yellow core, orange edge
export function flameCanvas() {
  const W = 256, H = 512, c = cv(W, H), g = c.getContext('2d');
  const shape = (sc, col) => { g.beginPath(); g.moveTo(W / 2, H * (1 - 0.95 * sc)); g.bezierCurveTo(W * (0.5 + 0.42 * sc), H * 0.45, W * (0.5 + 0.38 * sc), H * 0.95, W / 2, H * 0.97); g.bezierCurveTo(W * (0.5 - 0.38 * sc), H * 0.95, W * (0.5 - 0.42 * sc), H * 0.45, W / 2, H * (1 - 0.95 * sc)); g.fillStyle = col; g.fill(); };
  shape(1, 'rgba(255,140,40,0.9)'); shape(0.72, 'rgba(255,200,90,0.95)'); shape(0.45, 'rgba(255,245,200,1)');
  return c;
}

// soft round dot (dust bokeh, sparkles) and a 4-point star
export function dotCanvas() { const c = cv(128, 128), g = c.getContext('2d'), gr = g.createRadialGradient(64, 64, 0, 64, 64, 64); gr.addColorStop(0, 'rgba(255,255,255,1)'); gr.addColorStop(0.35, 'rgba(255,255,255,0.7)'); gr.addColorStop(1, 'rgba(255,255,255,0)'); g.fillStyle = gr; g.fillRect(0, 0, 128, 128); return c; }
export function starCanvas() { const c = cv(128, 128), g = c.getContext('2d'); g.fillStyle = '#fff'; g.beginPath(); for (let k = 0; k < 8; k++) { const a = k / 8 * Math.PI * 2 - Math.PI / 2, r = k % 2 ? 12 : 62; g.lineTo(64 + Math.cos(a) * r, 64 + Math.sin(a) * r); } g.closePath(); g.fill(); return c; }

// the city through the office window (flat paper cut-out buildings)
export function skylineCanvas() {
  const W = 1024, H = 640, c = cv(W, H), g = c.getContext('2d'), R = rng(101);
  const sky = g.createLinearGradient(0, 0, 0, H); sky.addColorStop(0, '#9cc8f2'); sky.addColorStop(1, '#cfe4f7'); g.fillStyle = sky; g.fillRect(0, 0, W, H);
  for (let k = 0; k < 900; k++) { g.fillStyle = `rgba(255,255,255,${R() * 0.12})`; g.fillRect(R() * W, R() * H, 3, 3); }
  for (const layer of [0, 1]) { let x = -10; while (x < W) { const bw = 50 + R() * 70, bh = (layer ? 240 : 300) + R() * (layer ? 160 : 260); g.fillStyle = layer ? '#7d9fd6' : '#5b7ec4'; g.fillRect(x, H - bh, bw, bh);
    g.fillStyle = layer ? '#a9c3ea' : '#d7e6fa'; for (let yy = H - bh + 14; yy < H - 10; yy += 26) for (let xx = x + 8; xx < x + bw - 12; xx += 18) g.fillRect(xx, yy, 9, 14);
    x += bw + 8 + R() * 20; } }
  return c;
}

// a drawn face for the speech bubbles (our own archetypes, paper-cut style; never a real person)
// kind: 'hoodie' (messy brown hair, sunglasses on head) · 'suit' (bald, round glasses) · 'pearls' (blonde bob) ·
//       'blonde' (long hair) · 'cap' (black cap, beard) · 'me' (the narrator critter with the blue cap)
export function avatarCanvas(kind) {
  const S = 256, c = cv(S, S), g = c.getContext('2d'), R = rng(kind.length * 31 + 2);
  const bgc = { hoodie: '#f0d9b5', suit: '#cfe0f2', pearls: '#f6d2d8', blonde: '#e3d9f5', cap: '#d6eed5', me: '#ffe1c2' }[kind] || '#eee';
  g.fillStyle = bgc; g.fillRect(0, 0, S, S);
  if (kind === 'me') { // the orange critter with the blue cap
    g.fillStyle = '#e8784a'; g.fillRect(48, 92, 160, 110); g.fillStyle = '#141414'; g.fillRect(88, 122, 20, 32); g.fillRect(148, 122, 20, 32);
    g.fillStyle = '#3b6fd8'; g.fillRect(52, 64, 152, 34); g.fillRect(150, 88, 74, 12); for (let k = 0; k < 4; k++) { g.fillStyle = '#e8784a'; g.fillRect(64 + k * 36, 202, 18, 40); }
    return c;
  }
  const skin = { hoodie: '#e9b48c', suit: '#efc39f', pearls: '#f1c6a2', blonde: '#f0c19c', cap: '#a8714c' }[kind];
  // shoulders / clothes
  const cloth = { hoodie: '#1f2b4f', suit: '#2a2f3d', pearls: '#d23a2a', blonde: '#2f6e8f', cap: '#1d1d1f' }[kind];
  g.fillStyle = cloth; g.beginPath(); g.ellipse(S / 2, S + 30, 120, 110, 0, 0, 7); g.fill();
  if (kind === 'suit') { g.fillStyle = '#f4f1ea'; g.beginPath(); g.moveTo(S / 2 - 34, S - 92); g.lineTo(S / 2, S - 20); g.lineTo(S / 2 + 34, S - 92); g.fill(); g.fillStyle = '#c0392b'; g.fillRect(S / 2 - 6, S - 80, 12, 50); }
  if (kind === 'hoodie') { g.strokeStyle = '#f4f1ea'; g.lineWidth = 6; g.beginPath(); g.moveTo(S / 2 - 22, S - 84); g.lineTo(S / 2 - 26, S - 30); g.moveTo(S / 2 + 22, S - 84); g.lineTo(S / 2 + 26, S - 30); g.stroke(); }
  // neck + head
  g.fillStyle = skin; g.fillRect(S / 2 - 26, 150, 52, 50);
  g.beginPath(); g.ellipse(S / 2, 118, 62, 74, 0, 0, 7); g.fill();
  if (kind === 'pearls') { g.fillStyle = '#fbf7ee'; for (let k = 0; k < 11; k++) { const a = Math.PI * (0.15 + k * 0.07); g.beginPath(); g.arc(S / 2 + Math.cos(a) * 60, 176 + Math.sin(a) * 34, 8, 0, 7); g.fill(); } }
  // hair
  if (kind === 'hoodie') { g.fillStyle = '#5a3a22'; g.beginPath(); g.ellipse(S / 2, 70, 70, 40, 0, Math.PI, 0); g.fill(); for (let k = 0; k < 7; k++) { g.beginPath(); g.ellipse(S / 2 - 54 + k * 18, 64 + R() * 8, 16, 22, R() - 0.5, 0, 7); g.fill(); }
    g.fillStyle = '#141414'; g.fillRect(S / 2 - 54, 50, 46, 20); g.fillRect(S / 2 + 8, 50, 46, 20); g.fillRect(S / 2 - 8, 56, 16, 6); }
  if (kind === 'pearls') { g.fillStyle = '#f2d27a'; g.beginPath(); g.ellipse(S / 2, 84, 80, 62, 0, Math.PI * 0.95, Math.PI * 2.05); g.fill(); g.fillRect(S / 2 - 80, 80, 26, 80); g.fillRect(S / 2 + 54, 80, 26, 80); for (let k = 0; k < 6; k++) { g.beginPath(); g.arc(S / 2 - 50 + k * 20, 66, 18, 0, 7); g.fill(); } }
  if (kind === 'blonde') { g.fillStyle = '#efd38a'; g.beginPath(); g.ellipse(S / 2, 90, 82, 70, 0, Math.PI, 0); g.fill(); g.fillRect(S / 2 - 82, 88, 34, 130); g.fillRect(S / 2 + 48, 88, 34, 130); }
  if (kind === 'cap') { g.fillStyle = '#1d1d1f'; g.beginPath(); g.ellipse(S / 2, 70, 68, 40, 0, Math.PI, 0); g.fill(); g.fillRect(S / 2 - 70, 64, 140, 16); g.fillRect(S / 2 - 6, 66, 110, 12);
    g.fillStyle = '#2a1a10'; g.beginPath(); g.ellipse(S / 2, 166, 46, 26, 0, 0, Math.PI); g.fill(); }
  if (kind === 'suit') { g.fillStyle = '#d9a982'; g.beginPath(); g.ellipse(S / 2, 70, 56, 24, 0, Math.PI, 0); g.fill(); }
  // face: eyes, brows, mouth (smug)
  g.fillStyle = '#1b1b1b';
  const ey = kind === 'hoodie' ? 116 : 112;
  g.beginPath(); g.ellipse(S / 2 - 22, ey, 6, 8, 0, 0, 7); g.fill(); g.beginPath(); g.ellipse(S / 2 + 22, ey, 6, 8, 0, 0, 7); g.fill();
  g.lineWidth = 5; g.strokeStyle = '#3a2416'; g.beginPath(); g.moveTo(S / 2 - 34, ey - 18); g.lineTo(S / 2 - 12, ey - 14); g.moveTo(S / 2 + 12, ey - 14); g.lineTo(S / 2 + 34, ey - 20); g.stroke();
  if (kind === 'suit') { g.lineWidth = 4; g.strokeStyle = '#1b1b1b'; g.beginPath(); g.arc(S / 2 - 22, ey, 17, 0, 7); g.moveTo(S / 2 + 39, ey); g.arc(S / 2 + 22, ey, 17, 0, 7); g.moveTo(S / 2 - 5, ey); g.lineTo(S / 2 + 5, ey); g.stroke(); }
  g.strokeStyle = '#7a2e22'; g.lineWidth = 5; g.beginPath(); g.moveTo(S / 2 - 20, 150); g.quadraticCurveTo(S / 2 + 4, 162, S / 2 + 24, 144); g.stroke();
  g.fillStyle = 'rgba(220,90,70,0.25)'; g.beginPath(); g.arc(S / 2 - 38, 140, 12, 0, 7); g.arc(S / 2 + 38, 140, 12, 0, 7); g.fill();
  return c;
}
```

### `engine/world.js`

```js
// The papercraft world: every set, character and prop of the "Paper" format, built once. Engine file.
// build(cfg) -> { scene, R }. A video's world(t) shows one set at a time (R.show(name)), poses the characters and moves props.
// Sets (far apart, one visible at a time): studio (0,0,0) · corridor (CORRIDOR_X) · felt (FELT_X) · brick (BRICK_X) · office (OFFICE_X).
// cfg (a video's WORLD export) sets what things SAY: show logo, card faces, sign words, sticky notes, books, docs.
import * as THREE from 'three';
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';
import * as P from './papertex.js';
import { cv, FONT } from './tex.js';
import { rng } from './lib.js';

export const CORRIDOR_X = -70, FELT_X = 70, BRICK_X = 120, OFFICE_X = 133;

export const WORLD_DEFAULTS = {
  logo: ['PAPER', 'TANK'],                  // the show's logo panel (two words)
  door: 'STUDIO',                            // the lit sign over the corridor's doorway
  books: [['hooks.md', '#2b2b30'], ['scripts.md', '#3b6fd8'], ['voices.md', '#c4683f'], ['edits.md', '#d9b25f'], ['captions.md', '#5a3d8f'], ['paper.md', '#e8784a', true]],   // [spine, colour, new?]
  notes: [['NO\nPAYWALLS', '#f4d34a'], ['NO\nSUBSCRIPTIONS', '#f3a3c2'], ['NO\nSTRINGS', '#8ec5f0']],
  signs: ['$30', 'FREE'],                    // the sharks' signs: front (red) and back (green); flip with card.rotation.y = π
  price: '$30',                              // the stamp press label, the money's tag, the stamp on the conveyor cards
  card: { title: 'SKILL.md', word: 'FREE' }, // the small card (grabbed, dropped in the box) and the conveyor cards
  giftTag: 'PREMIUM', shelfSign: 'LIBRARY', appBar: ['My App', 'Library'], doc: 'SKILL.md', held: 'paper.md', exit: 'EXIT',
};

export function build(cfg = {}) {
  cfg = { ...WORLD_DEFAULTS, ...cfg };
  const scene = new THREE.Scene();
  const R = { sets: {}, dyn: [] };
  const TX = new Map();
  const tex = (key, make, { srgb = true, rep = null, aniso = 8 } = {}) => {
    if (TX.has(key)) return TX.get(key);
    const x = new THREE.CanvasTexture(make()); if (srgb) x.colorSpace = THREE.SRGBColorSpace; x.anisotropy = aniso;
    if (rep) { x.wrapS = x.wrapT = THREE.RepeatWrapping; x.repeat.set(...rep); }
    TX.set(key, x); return x;
  };
  const FIB = tex('fib', () => P.fibreMap(3, 512, 0.78), { rep: [1, 1] }), FIB2 = tex('fib2', () => P.fibreMap(7, 512, 0.88));
  const mat = (color, o = {}) => new THREE.MeshStandardMaterial({ color, roughness: o.rough ?? 0.9, metalness: o.metal ?? 0, map: o.map === null ? null : (o.map || FIB), emissive: o.emissive ?? 0x000000, emissiveIntensity: o.ei ?? 1, emissiveMap: o.emap || null, transparent: !!o.transparent, opacity: o.opacity ?? 1, side: o.side || THREE.FrontSide, alphaTest: o.alphaTest || 0, depthWrite: o.depthWrite ?? true });
  const box = (w, h, d, m, r = 0) => { const g = r ? new RoundedBoxGeometry(w, h, d, 2, r) : new THREE.BoxGeometry(w, h, d); const x = new THREE.Mesh(g, m); x.castShadow = true; x.receiveShadow = true; return x; };
  const plane = (w, h, m) => { const x = new THREE.Mesh(new THREE.PlaneGeometry(w, h), m); x.castShadow = true; x.receiveShadow = true; return x; };
  const ORANGE = tex('orange', () => P.paperColor('#e8784a', { seed: 1 })), ORANGE2 = tex('orange2', () => P.paperColor('#e9804f', { seed: 4, blot: 0.3 }));
  const orangeM = mat(0xffffff, { map: ORANGE }), orangeM2 = mat(0xffffff, { map: ORANGE2 }), blackM = mat(0x141414, { map: null, rough: 0.6 }), whiteM = mat(0xf6f2ea, { map: FIB2 });
  R.mat = mat; R.box = box; R.plane = plane; R.tex = tex; R.orangeM = orangeM;
  // a canvas-backed texture that a video redraws when its content changes (cards that flip, the register display, …)
  R.dynTex = (w, h, draw0) => { const c = cv(w, h), t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 8; let key = null;
    const o = { c, t, set(k, draw) { if (k === key) return; key = k; const g = c.getContext('2d'); g.clearRect(0, 0, w, h); draw(g, w, h); t.needsUpdate = true; } };
    if (draw0) o.set('init', draw0); return o; };
  const fromCanvas = (key, canvas) => { const x = new THREE.CanvasTexture(canvas); x.colorSpace = THREE.SRGBColorSpace; x.anisotropy = 8; return x; };
  R.fromCanvas = fromCanvas;
  const set = name => { const g = new THREE.Group(); g.name = name; scene.add(g); R.sets[name] = g; return g; };
  R.show = (...names) => { for (const [n, g] of Object.entries(R.sets)) g.visible = names.includes(n); };

  // ======================= characters =======================
  // a seated shark / any orange block character. opts: { outfit: 'hoodie'|'suit'|'pearls'|'blonde'|'cap'|null, teeth, w, h, d }
  R.makeBlock = (opts = {}) => {
    const w = opts.w ?? 1.3, h = opts.h ?? 1.05, d = opts.d ?? 0.95, root = new THREE.Group(), pivot = new THREE.Group(); root.add(pivot);
    const body = box(w, h, d, opts.alt ? orangeM2 : orangeM, 0.03); body.position.y = h / 2; pivot.add(body);
    const face = new THREE.Group(); face.position.set(0, h * 0.62, d / 2 + 0.006); pivot.add(face);
    const eyes = [-1, 1].map(s => { const e = box(0.13, 0.2, 0.02, blackM); e.position.set(s * w * 0.2, 0, 0); face.add(e); return e; });
    let mouth = null;
    if (opts.teeth) { mouth = plane(w * 0.55, h * 0.16, mat(0xffffff, { map: tex('teeth', teethCanvas), transparent: true, alphaTest: 0.3 })); mouth.position.set(0, -h * 0.3, 0.004); face.add(mouth); }
    const outfit = new THREE.Group(); pivot.add(outfit);
    const o = opts.outfit;
    if (o === 'hoodie' || o === 'cap') {
      const sh = box(w + 0.03, h * 0.4, d + 0.03, mat(o === 'cap' ? 0x1d1d22 : 0x23305a)); sh.position.y = h * 0.2; outfit.add(sh);
      for (const s of [-1, 1]) { const c = box(0.035, 0.22, 0.02, whiteM); c.position.set(s * 0.12, h * 0.26, d / 2 + 0.03); c.rotation.z = s * 0.08; outfit.add(c); }
    }
    if (o === 'suit') {
      const sh = box(w + 0.03, h * 0.42, d + 0.03, mat(0xffffff, { map: tex('pinstripe', pinstripeCanvas) })); sh.position.y = h * 0.21; outfit.add(sh);
      const v = new THREE.Mesh(new THREE.ShapeGeometry(tri(0.2, 0.3)), whiteM); v.position.set(0, h * 0.42, d / 2 + 0.017); v.rotation.z = Math.PI; outfit.add(v);
      const sq = box(0.1, 0.06, 0.02, mat(0xc8322a)); sq.position.set(-w * 0.3, h * 0.3, d / 2 + 0.02); outfit.add(sq);
      const btn = box(0.04, 0.04, 0.02, mat(0xd8b14a)); btn.position.set(0, h * 0.16, d / 2 + 0.02); outfit.add(btn);
    }
    if (o === 'pearls') {
      const sh = box(w + 0.03, h * 0.36, d + 0.03, mat(0xd2322a)); sh.position.y = h * 0.18; outfit.add(sh);
      for (let k = 0; k < 11; k++) { const a = Math.PI * (0.08 + k * 0.084); const p = new THREE.Mesh(new THREE.SphereGeometry(0.035, 10, 8), mat(0xfbf7ee, { map: null, rough: 0.3 })); p.position.set(Math.cos(a) * w * 0.3, h * 0.42 - Math.sin(a) * 0.12, d / 2 + 0.03); outfit.add(p); }
    }
    if (o === 'blonde') { const sh = box(w + 0.03, h * 0.36, d + 0.03, mat(0x2f6e8f)); sh.position.y = h * 0.18; outfit.add(sh); }
    // hair / hats on top
    const top = new THREE.Group(); top.position.y = h; pivot.add(top);
    const hair = opts.hair;
    if (hair === 'messy') { const hm = mat(0x5a3a22, { map: tex('brownHair', () => P.paperColor('#6a4528', { seed: 12, blot: 0.4 })) }); const R2 = rng(5);
      for (let k = 0; k < 9; k++) { const s = new THREE.Mesh(new THREE.SphereGeometry(0.22 + R2() * 0.08, 8, 6), hm); s.scale.y = 0.45; s.position.set(-w * 0.38 + (k % 5) * w * 0.19 + (R2() - 0.5) * 0.05, 0.02 + R2() * 0.04, -d * 0.25 + Math.floor(k / 5) * d * 0.45); s.castShadow = true; top.add(s); } }
    if (hair === 'bob' || hair === 'long') { const hm = mat(0xf0cc6a, { map: tex('blondeHair', () => P.paperColor('#efcf74', { seed: 13, blot: 0.35, dark: '#c99a3a' })) }); const R2 = rng(9);
      for (let k = 0; k < 12; k++) { const s = new THREE.Mesh(new THREE.SphereGeometry(0.2 + R2() * 0.06, 8, 6), hm); s.scale.y = 0.6; s.position.set(-w * 0.45 + (k % 6) * w * 0.18, 0.04, -d * 0.3 + Math.floor(k / 6) * d * 0.5); s.castShadow = true; top.add(s); }
      for (const sx of [-1, 1]) { const side = box(0.16, hair === 'long' ? h * 0.85 : h * 0.45, d * 0.9, hm); side.position.set(sx * (w / 2 + 0.06), hair === 'long' ? -h * 0.42 : -h * 0.2, -0.02); top.add(side); } }
    if (hair === 'bald') { const sk = box(w * 0.7, 0.04, d * 0.6, mat(0xd99a72)); sk.position.y = 0.02; top.add(sk);
      const gm = blackM; for (const s of [-1, 1]) { const fr = new THREE.Mesh(new THREE.TorusGeometry(0.1, 0.009, 6, 20), gm); fr.position.set(s * w * 0.2, h * 0.62 - h, d / 2 + 0.04); top.add(fr); } const br = box(0.06, 0.012, 0.012, gm); br.position.set(0, h * 0.62 - h, d / 2 + 0.04); top.add(br); }
    if (hair === 'cap') { const cm = mat(0x1a1a1f); const cap = box(w * 0.92, 0.2, d * 0.9, cm); cap.position.y = 0.1; top.add(cap); const brim = box(w * 0.6, 0.04, 0.4, cm); brim.position.set(0, 0.03, d / 2 + 0.15); top.add(brim); }
    if (hair === 'bluecap') { const cm = mat(0x3b6fd8); const cap = box(w * 0.95, 0.16, d * 0.92, cm); cap.position.y = 0.08; top.add(cap); const brim = box(w * 0.55, 0.035, 0.3, cm); brim.position.set(w * 0.12, 0.02, d / 2 + 0.12); top.add(brim); }
    // arms: pivots at the shoulders (rotation.z swings them out, rotation.x forward)
    const arms = [-1, 1].map(s => { const p = new THREE.Group(); p.position.set(s * (w / 2 + 0.07), h * 0.55, 0); const a = box(0.16, 0.42, 0.16, opts.alt ? orangeM2 : orangeM); a.position.y = -0.2; p.add(a); pivot.add(p); return p; });
    const legs = [-1.5, -0.5, 0.5, 1.5].map(i => { const p = new THREE.Group(); p.position.set(i * w * 0.2, 0.02, 0.05); const l = box(0.16, 0.45, 0.16, opts.alt ? orangeM2 : orangeM); l.position.y = -0.22; p.add(l); pivot.add(p); return p; });
    return { root, pivot, body, face, eyes, mouth, arms, legs, top, outfit, w, h, d };
  };
  // the narrator: the little Clawd-style critter with the blue cap
  R.makeCritter = () => {
    const root = new THREE.Group(), pivot = new THREE.Group(); root.add(pivot);
    const body = box(0.86, 0.52, 0.46, orangeM, 0.02); body.position.y = 0.62; pivot.add(body);
    const face = new THREE.Group(); face.position.set(0, 0.68, 0.236); pivot.add(face);
    const eyes = [-1, 1].map(s => { const e = box(0.075, 0.14, 0.02, blackM); e.position.set(s * 0.17, 0, 0); face.add(e); return e; });
    const hands = [-1, 1].map(s => { const p = new THREE.Group(); p.position.set(s * 0.5, 0.62, 0); const hnd = box(0.16, 0.16, 0.16, orangeM); hnd.rotation.z = Math.PI / 4; p.add(hnd); pivot.add(p); return p; });
    const legs = [-0.3, -0.1, 0.1, 0.3].map(x => { const p = new THREE.Group(); p.position.set(x, 0.37, 0); const l = box(0.1, 0.37, 0.12, orangeM); l.position.y = -0.18; p.add(l); pivot.add(p); return p; });
    const cap = new THREE.Group(); cap.position.y = 0.88; pivot.add(cap);
    const cm = mat(0x3b6fd8); const c1 = box(0.8, 0.12, 0.44, cm); c1.position.y = 0.06; cap.add(c1); const c2 = box(0.42, 0.03, 0.22, cm); c2.position.set(0.12, 0.015, 0.3); cap.add(c2);
    return { root, pivot, body, face, eyes, hands, legs, cap };
  };
  // the giant shark of the hook: big orange block, dark mouth with zigzag teeth, a hinged jaw, fin, tail, legs, a "$" lollipop sign
  R.makeBigShark = () => {
    const root = new THREE.Group(), pivot = new THREE.Group(); root.add(pivot);
    const W = 2.5, H = 1.55, D = 1.9;
    // head = the upper block (y 0..H); under it a dark mouth slot (y −0.55..0) with the upper teeth; the hinged jaw below
    const head = new THREE.Group(); head.position.y = 2.3; pivot.add(head);
    const upper = box(W, H, D, orangeM, 0.04); upper.position.y = H / 2; head.add(upper);
    const mouthIn = box(W * 0.92, 0.56, D * 0.88, mat(0x2a0d0a, { map: null })); mouthIn.position.y = -0.27; head.add(mouthIn);
    const teethU = plane(W * 0.86, 0.32, mat(0xffffff, { map: tex('teethU', () => zigzagCanvas(false)), transparent: true, alphaTest: 0.3, side: THREE.DoubleSide })); teethU.position.set(0, -0.16, D / 2 - 0.04); head.add(teethU);
    const jaw = new THREE.Group(); jaw.position.set(0, -0.55, -D / 2 + 0.1); head.add(jaw);
    const lower = box(W, 0.5, D, orangeM, 0.04); lower.position.set(0, -0.25, D / 2 - 0.1); jaw.add(lower);
    const teethL = plane(W * 0.86, 0.3, mat(0xffffff, { map: tex('teethL', () => zigzagCanvas(true)), transparent: true, alphaTest: 0.3, side: THREE.DoubleSide })); teethL.position.set(0, 0.15, D - 0.14); jaw.add(teethL);
    const eyes = [-1, 1].map(s => { const e = box(0.32, 0.42, 0.04, blackM); e.position.set(s * 0.55, H * 0.62, D / 2 + 0.01); e.rotation.z = s * -0.18; head.add(e); return e; });
    const finShape = new THREE.Shape(); finShape.moveTo(-0.5, 0); finShape.lineTo(0.55, 0); finShape.lineTo(-0.35, 1.0); finShape.closePath();
    const finM = mat(0xc9603a, { map: ORANGE }); const fin = new THREE.Mesh(new THREE.ExtrudeGeometry(finShape, { depth: 0.12, bevelEnabled: false }), finM); fin.position.set(-0.1, H - 0.02, -0.06); fin.castShadow = true; head.add(fin);
    const tail = new THREE.Mesh(new THREE.ExtrudeGeometry(finShape, { depth: 0.12, bevelEnabled: false }), finM); tail.position.set(W / 2 + 0.2, 0.3, -0.06); tail.rotation.z = -Math.PI / 2 - 0.3; tail.castShadow = true; head.add(tail);
    const legs = [-0.9, -0.3, 0.3, 0.9].map(x => { const p = new THREE.Group(); p.position.set(x, 1.25, 0.2); const l = box(0.3, 1.28, 0.3, orangeM); l.position.y = -0.62; p.add(l); pivot.add(p); return p; });
    const sign = new THREE.Group(); sign.position.set(0.5, H + 0.2, 0.3); head.add(sign);
    const stick = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, 0.7, 8), mat(0x9b9b9b)); stick.position.y = 0.35; sign.add(stick);
    const discM = mat(0xb8b1a6, { map: tex('dollarDisc', () => discCanvas('$')) }); const disc = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.4, 0.05, 32), [mat(0xb8b1a6), discM, discM]);
    disc.rotation.x = Math.PI / 2; disc.position.y = 0.95; disc.castShadow = true; sign.add(disc);
    return { root, pivot, head, jaw, eyes, legs, sign, fin, tail };
  };

  // ======================= STUDIO =======================
  const S = set('studio');
  { // floor, rug, walls, windows, pillars, logo, ceiling
    const floor = plane(60, 40, mat(0x8a6a52, { map: tex('floor', P.floorCanvas, { rep: [6, 4] }) })); floor.rotation.x = -Math.PI / 2; floor.position.z = -2; S.add(floor);
    const rug = plane(9, 7, mat(0xffffff, { map: tex('rug', P.rugCanvas) })); rug.rotation.x = -Math.PI / 2; rug.position.set(0, 0.006, 1.6); S.add(rug); R.rug = rug;
    const wallM = mat(0x1d2550, { map: tex('navyPaper', () => P.paperColor('#222b58', { seed: 21, blot: 0.35, dark: '#121735' })) });
    const back = plane(40, 12, wallM); back.position.set(0, 6, -9); S.add(back);
    for (const sx of [-1, 1]) { const side = plane(30, 12, wallM); side.position.set(sx * 13, 6, 3); side.rotation.y = -sx * Math.PI / 2; S.add(side); }
    const ceil = plane(40, 30, mat(0x0c0f22, { map: null })); ceil.rotation.x = Math.PI / 2; ceil.position.set(0, 9.5, 2); S.add(ceil);
    R.winMats = [];
    const winNight = tex('winNight', () => P.windowCanvas(false)), winLit = tex('winLit', () => P.windowCanvas(true));
    R.windows = [-7.6, -3.8, 0, 3.8, 7.6].map((x, i) => {
      const g = new THREE.Group(); g.position.set(x, 3.8, -8.85); S.add(g);
      const wm = new THREE.ShaderMaterial({ uniforms: { a: { value: winNight }, b: { value: winLit }, k: { value: 0 }, gain: { value: 1 } },
        vertexShader: 'varying vec2 vUv; void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.); }',
        fragmentShader: `varying vec2 vUv; uniform sampler2D a, b; uniform float k, gain;
          vec3 lin(vec3 c){ return pow(c, vec3(2.2)); }
          void main(){ vec3 c = mix(lin(texture2D(a, vUv).rgb) * 0.85, lin(texture2D(b, vUv).rgb) * 1.35, k); gl_FragColor = vec4(c * gain, 1.); }` });
      R.winMats.push(wm);
      const p = new THREE.Mesh(new THREE.PlaneGeometry(2.9, 4.1), wm); g.add(p);
      const fr = mat(0x2a1c18, { map: FIB }); for (const [w, h, px, py] of [[3.15, 0.14, 0, 2.1], [3.15, 0.14, 0, -2.1], [0.14, 4.3, -1.52, 0], [0.14, 4.3, 1.52, 0]]) { const b = box(w, h, 0.2, fr); b.position.set(px, py, 0.05); g.add(b); }
      return g;
    });
    const pil = mat(0x2a1d1a, { map: tex('pillar', () => P.paperColor('#3a2620', { seed: 23, blot: 0.4 })) });
    for (const x of [-9.6, -5.7, -1.9, 1.9, 5.7, 9.6]) { const b = box(0.9, 9, 0.7, pil); b.position.set(x, 4.5, -8.6); S.add(b); }
    const beam = box(24, 0.9, 0.8, pil); beam.position.set(0, 6.3, -8.5); S.add(beam);
    const logo = box(3.0, 2.0, 0.2, [pil, pil, pil, pil, mat(0xffffff, { map: tex('logo', () => P.logoCanvas(...cfg.logo)), emissive: 0x6f9ee8, ei: 0.25, emap: tex('logo', () => P.logoCanvas(...cfg.logo)) }), pil]);
    logo.position.set(0, 7.4, -8.3); S.add(logo); R.logo = logo;
    const glow = plane(3.4, 2.4, new THREE.MeshBasicMaterial({ color: 0x4f7fe0, transparent: true, opacity: 0.25, depthWrite: false })); glow.position.set(0, 7.4, -8.42); S.add(glow);
  }
  // armchairs (tufted leather, brass studs) with side tables and cups; seated sharks
  const leather = tex('leather', P.leatherCanvas);
  const chairM = mat(0xffffff, { map: leather }), chairM2 = mat(0xc9b0a0, { map: leather }), studM = mat(0xd8b56a, { map: null, metal: 0.6, rough: 0.35 });
  R.makeChair = () => {
    const g = new THREE.Group();
    const seat = box(1.9, 0.5, 1.4, chairM, 0.06); seat.position.set(0, 0.45, 0.05); g.add(seat);
    const cush = box(1.5, 0.16, 1.15, chairM2, 0.06); cush.position.set(0, 0.76, 0.12); g.add(cush);
    const back = box(1.9, 1.5, 0.36, chairM, 0.08); back.position.set(0, 1.15, -0.6); g.add(back);
    for (const s of [-1, 1]) { const arm = box(0.3, 0.75, 1.5, chairM, 0.08); arm.position.set(s * 0.85, 0.85, 0.02); g.add(arm);
      for (let k = 0; k < 6; k++) { const st = new THREE.Mesh(new THREE.SphereGeometry(0.035, 8, 6), studM); st.position.set(s * 0.85, 0.5 + k * 0.11, 0.78); g.add(st); } }
    return g;
  };
  const tableM = mat(0x2a2a30, { metal: 0.3, rough: 0.5 }), cupM = mat(0xeef3f7, { map: null, rough: 0.3 });
  R.sharks = []; R.chairs = []; R.signs = [];
  const SHARKS = [{ outfit: 'cap', hair: 'cap' }, { outfit: 'hoodie', hair: 'messy' }, { outfit: 'suit', hair: 'bald' }, { outfit: 'pearls', hair: 'bob', teeth: true }, { outfit: 'blonde', hair: 'long' }];
  const signFlip = tex('signOut', () => P.signCanvas(cfg.signs[0], '#d93b2b'));
  SHARKS.forEach((o, i) => {
    const x = (i - 2) * 3.1, z = -5.2 + Math.abs(i - 2) * 0.55, ry = -(i - 2) * 0.16;
    const g = new THREE.Group(); g.position.set(x, 0, z); g.rotation.y = ry; S.add(g);
    const ch = R.makeChair(); g.add(ch); R.chairs.push(g);
    const sh = R.makeBlock({ ...o, alt: i % 2 === 1 }); sh.root.position.set(0, 0.84, 0.05); g.add(sh.root);
    for (const l of sh.legs) l.rotation.x = -Math.PI / 2 + 0.15; // seated: legs on the cushion
    for (const a of sh.arms) a.rotation.z = 0; R.sharks.push(sh);
    // the sign on a stick each shark can raise: front = "$30" (red), back = "FREE" (green); flip = rotation.y π
    const sg = new THREE.Group(); sg.position.set(0.95, 1.2, 0.25); g.add(sg);
    const stick = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, 1.2, 8), mat(0x8a7a6a)); stick.position.y = 0.6; sg.add(stick);
    const card = new THREE.Group(); card.position.y = 1.35; sg.add(card);
    const f = plane(1.1, 0.64, mat(0xffffff, { map: signFlip })); f.position.z = 0.012; card.add(f);
    const b = plane(1.1, 0.64, mat(0xffffff, { map: tex('signFree', () => P.signCanvas(cfg.signs[1], '#3a9d4f')) })); b.rotation.y = Math.PI; b.position.z = -0.012; card.add(b);
    const edge = box(1.12, 0.66, 0.02, mat(0xe9e2d6)); card.add(edge);
    sg.visible = false; R.signs.push({ g: sg, card, front: f });
    const tb = new THREE.Group(); tb.position.set(1.35, 0, 0.5); g.add(tb);
    const top = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.3, 0.05, 20), tableM); top.position.y = 0.9; top.castShadow = true; tb.add(top);
    const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.9, 8), tableM); leg.position.y = 0.45; tb.add(leg);
    const cup = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.065, 0.2, 14), cupM); cup.position.set(0.05, 1.02, 0); cup.castShadow = true; tb.add(cup);
  });
  // the easel with its card (a dynamic texture the video redraws: price flips, bites, stamps)
  R.easel = new THREE.Group(); S.add(R.easel);
  { const em = mat(0x2b2b2b, { map: null, rough: 0.5, metal: 0.4 });
    for (const [x, rz, rx] of [[-0.55, 0.12, 0.05], [0.55, -0.12, 0.05], [0, 0, -0.32]]) { const l = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 2.4, 8), em); l.position.set(x * 0.9, 1.15, rx < 0 ? -0.35 : 0.05); l.rotation.set(rx, 0, rz); l.castShadow = true; R.easel.add(l); }
    const ledge = box(1.6, 0.06, 0.18, em); ledge.position.set(0, 0.95, 0.12); R.easel.add(ledge);
    R.cardTex = R.dynTex(1024, 640);
    const cardM = mat(0xffffff, { map: R.cardTex.t, transparent: true, alphaTest: 0.5, emissive: 0xffffff, ei: 0.14, emap: R.cardTex.t });
    R.card = new THREE.Group(); R.card.position.set(0, 1.62, 0.16); R.card.rotation.x = -0.08; R.easel.add(R.card);
    const face = plane(2.0, 1.25, cardM); face.position.z = 0.012; R.card.add(face); R.cardFace = face;
    const backing = box(2.0, 1.25, 0.02, mat(0xe7e2d8)); backing.material = mat(0xe7e2d8); R.card.add(backing); R.cardBacking = backing;
  }
  // the narrator critter + the giant shark (positioned per shot)
  R.critter = R.makeCritter(); S.add(R.critter.root);
  R.big = R.makeBigShark(); S.add(R.big.root);
  // stage lights (visible cones are faked with soft additive planes)
  R.studioLights = new THREE.Group(); S.add(R.studioLights);
  R.hemi = new THREE.HemisphereLight(0x7a8ad8, 0x2a160e, 0.55); R.studioLights.add(R.hemi);
  R.key = new THREE.SpotLight(0xffd9b0, 260, 40, 0.55, 0.6, 1.4); R.key.position.set(4, 11, 9); R.key.target.position.set(0, 0.8, -1);
  R.key.castShadow = true; R.key.shadow.mapSize.set(2048, 2048); R.key.shadow.bias = -0.0004; R.key.shadow.normalBias = 0.03; R.key.shadow.radius = 5;
  R.studioLights.add(R.key, R.key.target);
  R.rim = new THREE.DirectionalLight(0x6f8fff, 1.2); R.rim.position.set(-3, 6, -10); R.studioLights.add(R.rim);
  R.winLights = [-6, 0, 6].map(x => { const l = new THREE.PointLight(0xff9a4a, 0, 14, 1.6); l.position.set(x, 4, -6.8); R.studioLights.add(l); return l; });
  R.spot = new THREE.SpotLight(0xfff0d8, 0, 16, 0.32, 0.5, 1.2); R.spot.position.set(0, 9, 3); R.spot.target.position.set(0, 0, 1.5); R.studioLights.add(R.spot, R.spot.target);
  R.fill = new THREE.DirectionalLight(0xffc9a0, 0.35); R.fill.position.set(-5, 4, 8); R.studioLights.add(R.fill);

  // ======================= CORRIDOR (the way in: doors, a lit sign above them) =======================
  const C = set('corridor'); C.position.x = CORRIDOR_X;
  { const wood = mat(0xffffff, { map: tex('woodPanel', () => P.paperColor('#6b3a22', { seed: 31, blot: 0.45, dark: '#3a1d0e' })) });
    const fl = plane(8, 24, mat(0x6a4a36, { map: tex('floor', P.floorCanvas, { rep: [6, 4] }) })); fl.rotation.x = -Math.PI / 2; fl.position.set(0, 0, -2); C.add(fl);
    for (const sx of [-1, 1]) { const w = plane(24, 7, wood); w.position.set(sx * 3, 3.5, -2); w.rotation.y = -sx * Math.PI / 2; C.add(w);
      for (const z of [-6, -1, 4]) { const lamp = new THREE.Mesh(new THREE.SphereGeometry(0.18, 12, 10), new THREE.MeshBasicMaterial({ color: 0xffd08a })); lamp.position.set(sx * 2.85, 3.4, z); C.add(lamp); } }
    const ceil = plane(8, 24, mat(0x140b06, { map: null })); ceil.rotation.x = Math.PI / 2; ceil.position.set(0, 7, -2); C.add(ceil);
    const endW = plane(8, 7, wood); endW.position.set(0, 3.5, -10); C.add(endW);
    // doorway: glowing frame, two doors swung open, light behind
    const glowM = mat(0xffa040, { map: null, emissive: 0xffa040, ei: 2.2 });
    for (const [w, h, x, y] of [[2.9, 0.16, 0, 3.6], [0.16, 3.6, -1.42, 1.8], [0.16, 3.6, 1.42, 1.8]]) { const b = box(w, h, 0.18, glowM); b.position.set(x, y, -9.85); C.add(b); }
    const light = plane(2.7, 3.5, new THREE.MeshBasicMaterial({ color: 0xffe7c0 })); light.position.set(0, 1.78, -9.95); C.add(light);
    R.doors = [-1, 1].map(s => { const p = new THREE.Group(); p.position.set(s * 1.32, 0, -9.82); C.add(p); const d = box(1.3, 3.4, 0.08, mat(0x5a2f18, { map: tex('woodPanel', () => null) })); d.material = wood; d.position.set(-s * 0.65, 1.7, 0); p.add(d); return p; });
    const signM = mat(0xffffff, { map: tex('doorLogo', () => P.logoCanvas(cfg.door, '')), emissive: 0x9cc3f0, ei: 0.4, emap: tex('doorLogo', () => P.logoCanvas(cfg.door, '')) });
    const sgn = box(2.2, 1.4, 0.15, [wood, wood, wood, wood, signM, wood]); sgn.position.set(0, 4.75, -9.85); C.add(sgn); R.doorSignM = signM;
    R.corLights = new THREE.Group(); C.add(R.corLights);
    const h = new THREE.HemisphereLight(0xffc89a, 0x2a160e, 0.5); R.corLights.add(h);
    const pl = new THREE.PointLight(0xffb066, 30, 18, 1.5); pl.position.set(0, 2.5, -8.5); R.corLights.add(pl);
    for (const z of [-6, -1, 4]) { const l = new THREE.PointLight(0xffc27a, 4, 6, 1.8); l.position.set(0, 3.3, z); R.corLights.add(l); }
    const rear = new THREE.PointLight(0xffd2a0, 9, 14, 1.4); rear.position.set(0, 2.6, 7.5); R.corLights.add(rear);
  }
  R.critter2 = R.makeCritter(); C.add(R.critter2.root); // the narrator walking in (carrying a card)
  R.carry = plane(1.2, 0.8, whiteM); R.critter2.pivot.add(R.carry); R.carry.position.set(0, 0.95, 0.3);

  // ======================= the 2D diorama sets: flat paper layers facing +z =======================
  const layer = (grp, w, h, map, x, y, z, o = {}) => { const m = mat(0xffffff, { map, transparent: !!o.alpha, alphaTest: o.alpha ? 0.4 : 0, ...o.m }); const p = plane(w, h, m); p.position.set(x, y, z); grp.add(p); return p; };
  const flatLight = grp => { const g = new THREE.Group(); grp.add(g); const h = new THREE.HemisphereLight(0xfff1de, 0x3a2a20, 0.9); g.add(h);
    const d = new THREE.DirectionalLight(0xfff0dc, 2.2); d.position.set(-3, 5, 9); d.target.position.set(0, 0, 0); d.castShadow = true; d.shadow.mapSize.set(2048, 2048); d.shadow.camera.left = -9; d.shadow.camera.right = 9; d.shadow.camera.top = 9; d.shadow.camera.bottom = -9; d.shadow.radius = 6; d.shadow.bias = -0.0005; g.add(d, d.target); return g; };

  // FELT: the conveyor that stamps $30 on free cards and drops them in the piggy bank
  const F = set('felt'); F.position.x = FELT_X; R.feltLights = flatLight(F);
  { layer(F, 22, 26, tex('felt', () => P.feltCanvas()), 0, 2, -0.5).receiveShadow = true;
    const ground = layer(F, 22, 4, tex('feltDark', () => P.feltCanvas('#1c3f33', 82)), 0, -3.4, -0.3);
    const belt = box(14, 0.36, 0.3, mat(0x2b2b2e, { map: tex('belt', beltCanvas) })); belt.position.set(0, -0.5, 0); F.add(belt);
    for (let k = 0; k < 14; k++) { const r = new THREE.Mesh(new THREE.CylinderGeometry(0.11, 0.11, 0.32, 12), mat(0x9a9aa0, { metal: 0.5, rough: 0.4, map: null })); r.rotation.x = Math.PI / 2; r.position.set(-6.5 + k, -0.5, 0.05); F.add(r); }
    for (const x of [-2.75, -0.95, 0.95, 2.75]) { const post = box(0.14, 2.6, 0.1, mat(0x8d9096)); post.position.set(x, 0.8, -0.3); F.add(post);
      const dial = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, 0.05, 20), [mat(0x777), mat(0xffffff, { map: tex('dial', dialCanvas) }), mat(0x777)]); dial.rotation.x = Math.PI / 2; dial.position.set(x, 1.5, -0.22); F.add(dial); }
    R.bills = [[-2.0, 3.25, 0.12], [2.05, 3.4, -0.1], [0.1, 4.6, 0.05]].map(([x, y, r], i) => { const b = layer(F, 1.9, 0.86, tex('bill' + i, () => P.billCanvas(i)), x, y, -0.4); b.rotation.z = r; return b; });
    // the stamp press over the belt
    R.press = new THREE.Group(); R.press.position.set(0, 2.45, 0.05); F.add(R.press);
    const arm = box(0.3, 2.4, 0.25, mat(0x9a9aa0, { metal: 0.4, rough: 0.5 })); arm.position.y = 0.6; R.press.add(arm);
    R.pressHead = new THREE.Group(); R.press.add(R.pressHead);
    const head = box(1.1, 0.5, 0.5, mat(0xd23a2a)); R.pressHead.add(head);
    const pad = box(1.0, 0.12, 0.45, mat(0x3a1010)); pad.position.y = -0.3; R.pressHead.add(pad);
    const lbl = layer(R.pressHead, 0.9, 0.36, tex('pressLbl', () => P.signCanvas(cfg.price, '#d23a2a')), 0, 0, 0.26);
    // the cards riding the belt: front shows FREE; once stamped, the $30 face
    R.feltCards = Array.from({ length: 7 }, (_, i) => { const g = new THREE.Group(); F.add(g);
      const a = layer(g, 1.5, 1.0, tex('cardFree', () => P.cardCanvas({ title: cfg.card.title, price: cfg.card.word, priceCol: '#2f9e57', priceSize: 150, titleSize: 96 })), 0, 0, 0);
      const b = layer(g, 1.5, 1.0, tex('cardPaid', () => P.cardCanvas({ title: cfg.card.title, price: cfg.card.word, priceCol: '#2f9e57', priceSize: 150, titleSize: 96, stamp: cfg.price })), 0, 0, 0.002); b.visible = false;
      return { g, free: a, paid: b }; });
    // the piggy bank (paper cut-out)
    R.piggy = layer(F, 3.0, 2.34, tex('piggy', piggyCanvas), 2.55, -0.3, 0.25, { alpha: true });
    // the marquee sign with bulbs
    R.marqueeTex = R.dynTex(768, 300);
    R.marquee = layer(F, 1.8, 0.7, R.marqueeTex.t, 1.55, 3.05, 0.1, { m: { emissive: 0xffffff, ei: 0.35, emap: R.marqueeTex.t } });
    // the narrator and the seller as flat cut-outs
    R.flatMe = layer(F, 1.05, 0.91, tex('flatMe', () => flatCritterCanvas(true)), -2.75, -1.35, 0.4, { alpha: true });
    R.flatSeller = layer(F, 1.3, 1.53, tex('flatSeller', flatSellerCanvas), -1.75, -1.3, 0.4, { alpha: true });
    R.lever = new THREE.Group(); R.lever.position.set(-0.75, -1.9, 0.35); F.add(R.lever);
    const lv = box(0.1, 1.2, 0.08, mat(0x8d9096)); lv.position.y = 0.6; R.lever.add(lv); const knob = new THREE.Mesh(new THREE.SphereGeometry(0.22, 16, 12), mat(0xd23a2a, { map: null, rough: 0.4 })); knob.position.y = 1.25; R.lever.add(knob);
  }

  // BRICK: brown wall, EXIT door, a brick wall, the sign that collects sticky notes, the narrator below
  const B = set('brick'); B.position.x = BRICK_X; R.brickLights = flatLight(B);
  { layer(B, 26, 24, tex('brownWall', () => P.paperColor('#4a2c1c', { seed: 71, blot: 0.3, size: 1024, dark: '#2e190e' })), 0, 2, -0.6);
    layer(B, 26, 6, tex('brownFloor', () => P.paperColor('#38200f', { seed: 72, blot: 0.3, dark: '#211208' })), 0, -4.8, -0.4);
    const brick = layer(B, 7, 3.4, tex('brick', P.brickCanvas), 2.6, -1.0, -0.2);
    const doorM = mat(0xffffff, { map: tex('doorFlat', doorCanvas) }); const door = plane(4.2, 4.6, doorM); door.position.set(-3.0, -0.4, -0.25); B.add(door);
    const exit = box(1.9, 0.62, 0.12, [blackM, blackM, blackM, blackM, mat(0xffffff, { map: tex('exit', () => exitCanvas(cfg.exit)), emissive: 0xffffff, ei: 0.5, emap: tex('exit', () => null) }), blackM]); exit.position.set(-1.95, 2.45, -0.15); B.add(exit);
    const lamp = new THREE.Mesh(new THREE.CylinderGeometry(0.34, 0.34, 0.06, 24), mat(0xfff1c0, { map: null, emissive: 0xffe08a, ei: 1.4 })); lamp.rotation.x = Math.PI / 2; lamp.position.set(3.05, 2.3, -0.2); B.add(lamp);
    const lstem = box(0.08, 0.6, 0.06, mat(0xb08a4a)); lstem.position.set(3.05, 1.85, -0.2); B.add(lstem);
    R.wallSignTex = R.dynTex(1024, 640);
    R.wallSign = new THREE.Group(); R.wallSign.position.set(0.6, 2.0, 0); B.add(R.wallSign);
    const ws = plane(4.2, 2.62, mat(0xffffff, { map: R.wallSignTex.t })); ws.position.z = 0.035; R.wallSign.add(ws); const wsb = box(4.25, 2.67, 0.04, mat(0xe7e2d8)); R.wallSign.add(wsb);
    R.notes = cfg.notes.map(([t, col], i) => { const g = new THREE.Group(); B.add(g); const n = plane(1.6, 1.44, mat(0xffffff, { map: tex('note' + i, () => P.noteCanvas(t, col)) })); g.add(n); g.visible = false; return g; });
    R.flatMe2 = layer(B, 1.7, 1.47, tex('flatMe', () => flatCritterCanvas(true)), 0.55, -1.25, 0.25, { alpha: true });
    // the string tied to the sign + the scissors that snip it
    R.string = new THREE.Group(); R.string.position.set(2.25, 0.72, 0.06); B.add(R.string);
    const st = new THREE.Mesh(new THREE.CylinderGeometry(0.015, 0.015, 2.6, 6), mat(0xf1e6c8, { map: null })); st.position.y = -1.3; st.castShadow = true; R.string.add(st);
    R.stringLow = new THREE.Group(); R.stringLow.position.y = -1.3; R.string.add(R.stringLow);
    const st2 = new THREE.Mesh(new THREE.CylinderGeometry(0.015, 0.015, 1.3, 6), mat(0xf1e6c8, { map: null })); st2.position.y = -0.65; R.stringLow.add(st2);
    R.scissors = layer(B, 1.1, 1.1, tex('scissors', scissorsCanvas), 2.7, -0.5, 0.4, { alpha: true });
  }

  // OFFICE: grey concrete, a big window on a paper city, a long table; the free text file gets its stamp
  const O = set('office'); O.position.x = OFFICE_X; R.officeLights = flatLight(O);
  { layer(O, 26, 24, tex('concrete', () => P.paperColor('#8f9298', { seed: 61, blot: 0.35, size: 1024, dark: '#6c6f75' })), 0, 2, -0.6);
    layer(O, 8.6, 4.8, tex('skyline', P.skylineCanvas), 0, 2.5, -0.5);
    const fr = mat(0xeef1f4); for (const [w, h, x, y] of [[8.9, 0.22, 0, 4.9], [8.9, 0.22, 0, 0.1], [0.22, 5, -4.4, 2.5], [0.22, 5, 4.4, 2.5], [0.14, 5, -1.45, 2.5], [0.14, 5, 1.45, 2.5]]) { const b = box(w, h, 0.15, fr); b.position.set(x, y, -0.4); O.add(b); }
    const table = box(10, 0.3, 0.6, mat(0xdfe3e8)); table.position.set(0, -0.85, 0.1); O.add(table);
    const front = box(10, 1.6, 0.1, mat(0xc5cad1)); front.position.set(0, -1.75, 0.38); O.add(front);
    R.people = [-1.55, 1.55].map((x, i) => layer(O, 1.45, 1.55, tex('person' + i, () => flatPersonCanvas(i)), x, -0.1, -0.1, { alpha: true }));
    R.docTex = R.dynTex(512, 680);
    R.officeDoc = new THREE.Group(); O.add(R.officeDoc);
    const dp = plane(1.5, 2.0, mat(0xffffff, { map: R.docTex.t })); dp.position.z = 0.022; R.officeDoc.add(dp); const db = box(1.52, 2.02, 0.02, mat(0xe9e4da)); R.officeDoc.add(db);
    R.check = layer(O, 1.0, 1.0, tex('check', checkCanvas), 3.8, 3.3, 0.3, { alpha: true }); R.check.visible = false;
    R.flatMe3 = layer(O, 1.5, 1.3, tex('flatMe', () => flatCritterCanvas(true)), -4.6, -0.2, 0.4, { alpha: true });
  }

  // ======================= props (positioned per shot, in the studio) =======================
  R.phone = new THREE.Group(); S.add(R.phone);
  { const body = box(0.78, 1.5, 0.08, mat(0x1d1e24, { map: null, rough: 0.3, metal: 0.3 }), 0.05); R.phone.add(body);
    R.screenTex = R.dynTex(360, 640, g => g.drawImage(P.formatScreen(), 0, 0));   // a video may redraw it: R.screenTex.set(key, (g, w, h) => …)
    const scr = plane(0.68, 1.36, mat(0xffffff, { map: R.screenTex.t, emissive: 0xffffff, ei: 0.12, emap: R.screenTex.t })); scr.position.z = 0.045; R.phone.add(scr); }
  R.skillDoc = new THREE.Group(); S.add(R.skillDoc);
  { const p = plane(1.0, 1.33, mat(0xffffff, { map: tex('skillDoc', () => P.docCanvas(cfg.doc)) })); p.position.z = 0.012; R.skillDoc.add(p); const b = box(1.02, 1.35, 0.02, mat(0xe9e4da)); R.skillDoc.add(b); }
  // the shiny box the card gets repackaged in
  R.gift = new THREE.Group(); S.add(R.gift);
  { const gm = mat(0xffffff, { map: tex('giftPaper', giftCanvas), rough: 0.35, metal: 0.25 });
    const base = box(1.6, 0.9, 1.1, gm); base.position.y = 0.45; R.gift.add(base);
    R.giftLid = new THREE.Group(); R.giftLid.position.set(0, 0.9, -0.55); R.gift.add(R.giftLid);
    const lid = box(1.7, 0.22, 1.2, gm); lid.position.set(0, 0.11, 0.55); R.giftLid.add(lid);
    const rib = mat(0xf2c94c, { map: null, rough: 0.3, metal: 0.4 });
    for (const [w, d, x, z] of [[0.18, 1.22, 0, 0.55], [1.72, 0.18, 0, 0.55]]) { const r = box(w, 0.24, d, rib); r.position.set(x, 0.12, z); R.giftLid.add(r); }
    R.bow = new THREE.Group(); R.bow.position.set(0, 0.3, 0.55); R.giftLid.add(R.bow);
    for (const s of [-1, 1]) { const l = new THREE.Mesh(new THREE.TorusGeometry(0.2, 0.06, 8, 16), rib); l.position.set(s * 0.2, 0.08, 0); l.rotation.set(0, Math.PI / 2, s * 0.5); l.castShadow = true; R.bow.add(l); }
    const tg = plane(0.7, 0.36, mat(0xffffff, { map: tex('premium', () => P.signCanvas(cfg.giftTag, '#1d1d1f', '#f2c94c')) })); tg.position.set(0.55, 0.5, 0.56); tg.rotation.z = -0.25; R.gift.add(tg); R.giftTag = tg;
    for (const [w, d] of [[0.18, 1.12], [1.62, 0.18]]) { const r = box(w, 0.9, d, rib); r.position.y = 0.45; R.gift.add(r); }
  }
  // the cash register with its LED display
  R.register = new THREE.Group(); S.add(R.register);
  { const tm = mat(0x2f8f86, { rough: 0.5 });
    const body = box(1.8, 0.8, 1.3, tm, 0.06); body.position.y = 0.4; R.register.add(body);
    const top = box(1.5, 0.5, 0.7, tm, 0.06); top.position.set(0, 1.0, -0.2); top.rotation.x = -0.35; R.register.add(top);
    for (let j = 0; j < 3; j++) for (let i = 0; i < 4; i++) { const k = box(0.22, 0.08, 0.16, mat(0xe9eef0, { map: null })); k.position.set(-0.45 + i * 0.3, 1.18 - j * 0.07, -0.05 + j * 0.13); k.rotation.x = -0.35; R.register.add(k); }
    R.ledTex = R.dynTex(512, 160);
    const scrM = new THREE.MeshBasicMaterial({ map: R.ledTex.t }); const dsp = box(0.95, 0.32, 0.2, [tm, tm, tm, tm, scrM, tm]); dsp.position.set(0, 1.62, -0.4); R.register.add(dsp);
    const stand = box(0.12, 0.3, 0.12, tm); stand.position.set(0, 1.4, -0.4); R.register.add(stand);
    R.drawer = box(1.6, 0.32, 1.2, mat(0x24706a)); R.drawer.position.set(0, 0.2, 0.06); R.register.add(R.drawer);
  }
  // money in a paw that catches fire
  R.money = new THREE.Group(); S.add(R.money);
  { const bm = mat(0xffffff, { map: tex('billTop', () => P.billCanvas(5)) }), side = mat(0x7fbf6a);
    for (let k = 0; k < 6; k++) { const b = box(1.1, 0.07, 0.5, [side, side, bm, side, side, side]); b.position.set((k % 2) * 0.06, 0.04 + k * 0.075, (k % 3) * 0.03); b.rotation.y = (k % 3 - 1) * 0.08; R.money.add(b); }
    const tag = plane(0.6, 0.34, mat(0xffffff, { map: tex('price30', () => P.signCanvas(cfg.price, '#d93b2b')) })); tag.position.set(0.25, 0.5, 0.26); tag.rotation.set(-0.3, 0, 0.2); R.money.add(tag); R.moneyTag = tag; }
  R.fire = new THREE.Group(); S.add(R.fire);
  { const fm = new THREE.MeshBasicMaterial({ map: tex('flame', P.flameCanvas), transparent: true, opacity: 0.8, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide });
    R.flames = [[-0.22, 0.8, 0.1], [0.08, 1.15, 0], [0.32, 0.75, -0.1], [-0.02, 0.9, 0.15]].map(([x, s, z]) => { const g = new THREE.Group(); g.position.set(x, 0, z); const p = new THREE.Mesh(new THREE.PlaneGeometry(0.42 * s, 0.85 * s), fm); p.position.y = 0.42 * s; g.add(p); R.fire.add(g); return g; });
    R.fireLight = new THREE.PointLight(0xffa040, 0, 6, 1.6); R.fireLight.position.y = 0.8; R.fire.add(R.fireLight); }
  // the library shelf with the format books
  R.shelf = new THREE.Group(); S.add(R.shelf); R.books = [];
  { const wm = mat(0xffffff, { map: tex('shelfWood', () => P.paperColor('#7a4a2a', { seed: 91, blot: 0.4, dark: '#4a2a14' })) });
    for (const [w, h, d, x, y, z] of [[4.4, 0.16, 0.9, 0, 0.08, 0], [4.4, 0.12, 0.9, 0, 1.4, 0], [4.4, 0.12, 0.9, 0, 2.7, 0], [4.4, 0.16, 0.9, 0, 4.0, 0], [0.16, 4.0, 0.9, -2.2, 2.0, 0], [0.16, 4.0, 0.9, 2.2, 2.0, 0], [4.4, 4.0, 0.06, 0, 2.0, -0.44]]) { const b = box(w, h, d, wm); b.position.set(x, y, z); R.shelf.add(b); }
    const sgM = mat(0xffffff, { map: tex('libSign', () => P.signCanvas(cfg.shelfSign, '#1d2550', '#ffe7b0')), emissive: 0xffd08a, ei: 0.25, emap: tex('libSign', () => null) });
    R.shelfSign = box(2.6, 0.8, 0.1, [wm, wm, wm, wm, sgM, wm]); R.shelfSign.position.set(0, 4.55, 0.1); R.shelf.add(R.shelfSign);
    const all = [...cfg.books, ...cfg.books.slice(0, 5), ...cfg.books.slice(1, 6)];
    all.forEach(([name, col, isNew], k) => { const row = Math.floor(k / 6), i = k % 6; const g = new THREE.Group(); g.position.set(-1.75 + i * 0.7, 0.16 + row * 1.3, 0.05); R.shelf.add(g);
      const sp = mat(0xffffff, { map: tex('spine' + name + (isNew ? 'n' : ''), () => P.spineCanvas(name, col, '#fff8ee', !!isNew)) }), cm = mat(col);
      const b = box(0.36, 1.08, 0.7, [cm, cm, cm, cm, sp, cm]); b.position.y = 0.54; g.add(b); R.books.push({ g, b, name, isNew: !!isNew }); });
  }
  // paper.md, the card the narrator holds up
  R.paperDoc = new THREE.Group(); S.add(R.paperDoc);
  { const p = plane(1.3, 1.73, mat(0xffffff, { map: tex('paperDoc', () => P.docCanvas(cfg.held, { lines: 8 })) })); p.position.z = 0.012; R.paperDoc.add(p); const b = box(1.32, 1.75, 0.02, mat(0xe9e4da)); R.paperDoc.add(b); }
  // the studio's front wall: the entrance (a glowing doorway) seen from the sharks' side
  { const wallM = mat(0x1d2550, { map: tex('navyPaper', () => null) }); const fw = new THREE.Group(); fw.position.z = 8.5; fw.rotation.y = Math.PI; S.add(fw);
    for (const [w, x] of [[10.5, -6.75], [10.5, 6.75]]) { const p = plane(w, 12, wallM); p.position.set(x, 6, 0); fw.add(p); }
    const top = plane(3, 7.6, wallM); top.position.set(0, 8.2, 0); fw.add(top);
    const glowM = mat(0xffa040, { map: null, emissive: 0xffa040, ei: 2.0 });
    for (const [w, h, x, y] of [[3.0, 0.16, 0, 4.4], [0.16, 4.4, -1.46, 2.2], [0.16, 4.4, 1.46, 2.2]]) { const b = box(w, h, 0.2, glowM); b.position.set(x, y, 0.05); fw.add(b); }
    const lt = plane(2.8, 4.3, new THREE.MeshBasicMaterial({ color: 0xffe2b8 })); lt.position.set(0, 2.15, -0.1); fw.add(lt);
    R.entranceLight = new THREE.PointLight(0xffb070, 0, 10, 1.6); R.entranceLight.position.set(0, 2.2, 7.6); S.add(R.entranceLight); }
  // paper money flying into the shark's mouth, paper scraps from its bites, confetti on the rug
  R.flyBills = Array.from({ length: 5 }, (_, i) => { const p = plane(0.62, 0.28, mat(0xffffff, { map: tex('bill' + (i % 3), () => P.billCanvas(i % 3)), side: THREE.DoubleSide })); S.add(p); return p; });
  R.scraps = Array.from({ length: 18 }, (_, i) => { const p = plane(0.09 + (i % 3) * 0.03, 0.07 + (i % 2) * 0.03, mat(i % 4 ? 0xf6f2ea : 0xe8603c, { side: THREE.DoubleSide })); S.add(p); return p; });
  { const Rr = rng(5); for (let k = 0; k < 60; k++) { const sh = new THREE.Shape(); sh.moveTo(0, 0); sh.lineTo(0.12, 0); sh.lineTo(0.05, 0.1); sh.closePath();
      const c = new THREE.Mesh(new THREE.ShapeGeometry(sh), mat([0xf6f2ea, 0xd93b2b, 0xf2c14e][k % 3], { side: THREE.DoubleSide })); c.rotation.set(-Math.PI / 2, 0, Rr() * 6); c.position.set((Rr() - 0.5) * 7, 0.012, -1 + Rr() * 5.5); S.add(c); } }
  // out-of-focus shark paws for the sharks'-eye view (navy cuffs)
  R.fgPaws = new THREE.Group(); S.add(R.fgPaws);
  { const cuff = mat(0x23305a); for (const s of [-1, 1]) { const g = new THREE.Group(); g.position.x = s * 0.55; R.fgPaws.add(g); const a = box(0.32, 0.32, 0.9, orangeM); a.position.z = -0.1; g.add(a); const c = box(0.36, 0.36, 0.4, cuff); c.position.z = 0.45; g.add(c); } }
  // a small copy of the free card (a shark grabs it, it drops into the box)
  R.miniCard = new THREE.Group(); S.add(R.miniCard);
  { const p = plane(0.9, 0.56, mat(0xffffff, { map: tex('miniCard', () => P.cardCanvas({ title: cfg.card.title, price: cfg.card.word, priceCol: '#2f9e57', priceSize: 160, titleSize: 100 })), side: THREE.DoubleSide })); p.position.z = 0.008; R.miniCard.add(p); const b = box(0.9, 0.56, 0.012, mat(0xe7e2d8)); R.miniCard.add(b); }
  // a paw in a suit sleeve (holds the money that burns)
  R.suitPaw = new THREE.Group(); S.add(R.suitPaw);
  { const sl = box(0.5, 0.5, 1.6, mat(0xffffff, { map: tex('pinstripe', pinstripeCanvas) })); sl.position.z = -0.9; R.suitPaw.add(sl); const pw = box(0.62, 0.3, 0.7, orangeM); pw.position.set(0, -0.05, 0.2); R.suitPaw.add(pw); }
  // an app window the library lives in (a paper frame with a title bar)
  R.appFrame = new THREE.Group(); S.add(R.appFrame);
  { const fm = mat(0xffffff, { map: tex('appBar', () => appBarCanvas(...cfg.appBar)) }), side = mat(0x1b2040, { map: tex('navyPaper', () => null) });
    const bar = box(5.2, 0.62, 0.12, [side, side, side, side, fm, side]); bar.position.set(0, 5.3, 0); R.appFrame.add(bar);
    for (const [w, h, x, y] of [[0.2, 5.4, -2.5, 2.6], [0.2, 5.4, 2.5, 2.6], [5.2, 0.2, 0, -0.06]]) { const b = box(w, h, 0.12, side); b.position.set(x, y, 0); R.appFrame.add(b); } }
  // dust motes (tiny glowing specks that the depth of field turns into bokeh)
  R.dust = new THREE.Group(); scene.add(R.dust);
  { const dm = new THREE.MeshBasicMaterial({ color: 0xc8a878 }); const Rr = rng(77);
    for (let k = 0; k < 34; k++) { const s = new THREE.Mesh(new THREE.SphereGeometry(0.006 + Rr() * 0.01, 6, 4), dm); s.userData = { x: Rr(), y: Rr(), z: Rr(), p: Rr() * 6 }; R.dust.add(s); } }
  // sparkle stars (CTA, stamps): sprites the video scales/positions
  R.sparkTex = tex('star', P.starCanvas);
  return { scene, R };
}

// ---------- small canvas helpers for world-only textures ----------
function tri(w, h) { const s = new THREE.Shape(); s.moveTo(-w / 2, 0); s.lineTo(w / 2, 0); s.lineTo(0, h); s.closePath(); return s; }
function teethCanvas() { const c = cv(256, 64), g = c.getContext('2d'); g.fillStyle = '#2a0d0a'; g.fillRect(0, 0, 256, 64); g.fillStyle = '#fbf7ee'; for (let k = 0; k < 8; k++) { g.beginPath(); g.moveTo(k * 32, 0); g.lineTo(k * 32 + 32, 0); g.lineTo(k * 32 + 16, 40); g.fill(); } return c; }
function zigzagCanvas(up) { const c = cv(512, 96), g = c.getContext('2d'); g.fillStyle = '#fbf7ee'; for (let k = 0; k < 12; k++) { g.beginPath(); if (up) { g.moveTo(k * 42.6, 96); g.lineTo(k * 42.6 + 42.6, 96); g.lineTo(k * 42.6 + 21.3, 8); } else { g.moveTo(k * 42.6, 0); g.lineTo(k * 42.6 + 42.6, 0); g.lineTo(k * 42.6 + 21.3, 88); } g.fill(); } return c; }
function discCanvas(ch) { const c = cv(256, 256), g = c.getContext('2d'); g.fillStyle = '#f6f1e8'; g.fillRect(0, 0, 256, 256); g.fillStyle = '#e8603c'; g.font = `180px ${FONT.sans}`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(ch, 128, 140); return c; }
function pinstripeCanvas() { const c = cv(256, 256), g = c.getContext('2d'); g.fillStyle = '#2b2f3c'; g.fillRect(0, 0, 256, 256); g.strokeStyle = 'rgba(220,225,235,0.35)'; g.lineWidth = 2; for (let x = 8; x < 256; x += 22) { g.beginPath(); g.moveTo(x, 0); g.lineTo(x, 256); g.stroke(); } return c; }
function beltCanvas() { const c = cv(1024, 64), g = c.getContext('2d'); g.fillStyle = '#2b2b2e'; g.fillRect(0, 0, 1024, 64); g.fillStyle = '#3a3a3e'; for (let x = 0; x < 1024; x += 48) g.fillRect(x, 0, 20, 64); return c; }
function dialCanvas() { const c = cv(128, 128), g = c.getContext('2d'); g.fillStyle = '#f4f1ea'; g.beginPath(); g.arc(64, 64, 62, 0, 7); g.fill(); g.strokeStyle = '#d23a2a'; g.lineWidth = 8; g.beginPath(); g.moveTo(64, 64); g.lineTo(100, 34); g.stroke(); return c; }
function piggyCanvas() { const W = 512, H = 400, c = cv(W, H), g = c.getContext('2d');
  g.fillStyle = '#f2a8b8'; g.beginPath(); g.ellipse(250, 220, 200, 140, 0, 0, 7); g.fill();
  g.beginPath(); g.ellipse(440, 210, 56, 50, 0, 0, 7); g.fill(); g.fillStyle = '#e48aa0'; g.beginPath(); g.ellipse(450, 210, 30, 26, 0, 0, 7); g.fill();
  g.fillStyle = '#7a2e44'; g.beginPath(); g.arc(442, 204, 6, 0, 7); g.arc(460, 204, 6, 0, 7); g.fill(); g.beginPath(); g.arc(360, 160, 10, 0, 7); g.fill();
  g.fillStyle = '#f2a8b8'; g.beginPath(); g.moveTo(300, 100); g.lineTo(340, 40); g.lineTo(360, 110); g.fill();
  for (const x of [130, 200, 300, 370]) { g.fillRect(x, 320, 44, 70); }
  g.fillStyle = '#2b2b30'; g.fillRect(200, 92, 120, 16); // coin slot
  g.fillStyle = '#3a3a40'; g.beginPath(); g.roundRect(130, 190, 200, 110, 20); g.fill(); g.strokeStyle = '#c9c9cf'; g.lineWidth = 6; for (let x = 150; x < 320; x += 22) { g.beginPath(); g.moveTo(x, 200); g.lineTo(x, 290); g.stroke(); }
  return c; }
function flatCritterCanvas(cap) { const W = 300, H = 260, c = cv(W, H), g = c.getContext('2d');
  g.fillStyle = '#e8784a'; g.fillRect(60, 70, 180, 110); g.fillStyle = '#141414'; g.fillRect(105, 100, 22, 34); g.fillRect(173, 100, 22, 34);
  g.fillStyle = '#e8784a'; for (const x of [70, 115, 160, 205]) g.fillRect(x, 180, 24, 60);
  for (const s of [-1, 1]) { g.save(); g.translate(150 + s * 112, 120); g.rotate(Math.PI / 4); g.fillRect(-17, -17, 34, 34); g.restore(); }
  if (cap) { g.fillStyle = '#3b6fd8'; g.fillRect(62, 46, 176, 28); g.fillRect(170, 66, 80, 10); }
  return c; }
function flatSellerCanvas() { const W = 340, H = 400, c = cv(W, H), g = c.getContext('2d');
  g.fillStyle = '#e9804f'; g.fillRect(60, 60, 220, 200); g.fillStyle = '#141414'; g.fillRect(118, 100, 22, 36); g.fillRect(200, 100, 22, 36);
  g.strokeStyle = '#141414'; g.lineWidth = 6; g.beginPath(); g.arc(129, 118, 26, 0, 7); g.moveTo(237, 118); g.arc(211, 118, 26, 0, 7); g.stroke();
  g.fillStyle = '#2b2f3c'; g.fillRect(60, 170, 220, 90); g.fillStyle = '#f4f1ea'; g.beginPath(); g.moveTo(140, 170); g.lineTo(170, 230); g.lineTo(200, 170); g.fill(); g.fillStyle = '#c8322a'; g.fillRect(80, 186, 26, 14);
  g.strokeStyle = 'rgba(220,225,235,0.35)'; g.lineWidth = 2; for (let x = 66; x < 280; x += 16) { if (x > 136 && x < 204) continue; g.beginPath(); g.moveTo(x, 172); g.lineTo(x, 260); g.stroke(); }
  g.fillStyle = '#e9804f'; for (const x of [80, 135, 190, 245]) g.fillRect(x, 260, 26, 80);
  g.fillStyle = '#2b2f3c'; for (const x of [80, 135, 190, 245]) g.fillRect(x, 300, 26, 40);
  return c; }
function flatPersonCanvas(i) { const W = 300, H = 320, c = cv(W, H), g = c.getContext('2d'), suit = ['#1f2b4f', '#2f6e4f', '#1d1d1f'][i];
  g.fillStyle = '#e8784a'; g.fillRect(50, 50, 200, 150); g.fillStyle = '#141414'; g.fillRect(100, 90, 20, 32); g.fillRect(180, 90, 20, 32);
  g.fillStyle = suit; g.fillRect(50, 160, 200, 100); g.fillStyle = '#f4f1ea'; g.beginPath(); g.moveTo(125, 160); g.lineTo(150, 210); g.lineTo(175, 160); g.fill();
  return c; }
function doorCanvas() { const W = 512, H = 560, c = cv(W, H), g = c.getContext('2d'); g.fillStyle = '#5a341e'; g.fillRect(0, 0, W, H);
  g.strokeStyle = '#c9a24a'; g.lineWidth = 10; g.strokeRect(20, 20, W / 2 - 30, H - 30); g.strokeRect(W / 2 + 10, 20, W / 2 - 30, H - 30);
  g.strokeStyle = 'rgba(30,12,4,0.5)'; g.lineWidth = 6; for (const x of [50, W / 2 + 40]) { g.strokeRect(x, 60, W / 2 - 90, 180); g.strokeRect(x, 290, W / 2 - 90, 220); }
  g.fillStyle = '#c9a24a'; g.fillRect(W / 2 - 30, 250, 12, 90); g.fillRect(W / 2 + 18, 250, 12, 90); return c; }
function exitCanvas(word = 'EXIT') { const c = cv(380, 124), g = c.getContext('2d'); g.fillStyle = '#161616'; g.fillRect(0, 0, 380, 124); g.fillStyle = '#ff4a36'; g.font = `92px ${FONT.sans}`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(word, 190, 68, 340); return c; }
function scissorsCanvas() { const W = 256, H = 256, c = cv(W, H), g = c.getContext('2d'); g.save(); g.translate(128, 128); g.rotate(-0.6);
  g.fillStyle = '#d7dce2'; g.beginPath(); g.moveTo(-10, -8); g.lineTo(110, -18); g.lineTo(-10, 6); g.fill(); g.beginPath(); g.moveTo(-10, 8); g.lineTo(110, 18); g.lineTo(-10, -6); g.fill();
  g.lineWidth = 16; g.strokeStyle = '#e8603c'; g.beginPath(); g.arc(-50, -26, 26, 0, 7); g.stroke(); g.beginPath(); g.arc(-50, 26, 26, 0, 7); g.stroke(); g.restore(); return c; }
function appBarCanvas(title = 'My App', right = 'Library') { const W = 1240, H = 140, c = cv(W, H), g = c.getContext('2d'); g.fillStyle = '#1b2040'; g.fillRect(0, 0, W, H);
  ['#ff5f57', '#febc2e', '#28c840'].forEach((col, i) => { g.fillStyle = col; g.beginPath(); g.arc(60 + i * 52, H / 2, 17, 0, 7); g.fill(); });
  g.fillStyle = '#f3f3f1'; g.font = `64px ${FONT.sans}`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(title, W / 2, H / 2 + 4);
  g.fillStyle = 'rgba(195,202,255,0.9)'; g.font = `34px ${FONT.sans}`; g.textAlign = 'right'; g.fillText(right, W - 50, H / 2 + 2); return c; }
function checkCanvas() { const c = cv(256, 256), g = c.getContext('2d'); g.fillStyle = '#2f9e57'; g.beginPath(); g.roundRect(8, 8, 240, 240, 26); g.fill(); g.strokeStyle = '#fff'; g.lineWidth = 30; g.lineCap = 'round'; g.lineJoin = 'round'; g.beginPath(); g.moveTo(62, 132); g.lineTo(108, 182); g.lineTo(196, 80); g.stroke(); return c; }
function giftCanvas() { const c = cv(512, 512), g = c.getContext('2d'); const gr = g.createLinearGradient(0, 0, 512, 512); gr.addColorStop(0, '#c0392b'); gr.addColorStop(0.5, '#e74c3c'); gr.addColorStop(1, '#a93226'); g.fillStyle = gr; g.fillRect(0, 0, 512, 512);
  g.fillStyle = 'rgba(255,215,120,0.45)'; for (let y = 20; y < 512; y += 64) for (let x = (y / 64 % 2) * 32 + 16; x < 512; x += 64) { g.beginPath(); for (let k = 0; k < 10; k++) { const a = k / 10 * Math.PI * 2 - Math.PI / 2, r = k % 2 ? 6 : 14; g.lineTo(x + Math.cos(a) * r, y + Math.sin(a) * r); } g.fill(); } return c; }
```

### `engine/overlay.js`

```js
// The 2D layer over the 3D (1080x1920 canvas), in the reference's papercraft look: the torn paper banner on two strips of
// tape (one per beat), orange torn-paper word tags (the captions), the blue torn-water news chyron, round speech bubbles
// with drawn avatars, and the big COMMENT bubble of the CTA. Engine file; a video calls these from its overlay(g, t).
// Every text element is placed inside the safe zone (SAFE below: the platforms cover the edges) and records its box, so the stills renderer
// can draw the zone (--safe) and report anything outside it.
import { FONT, paperCanvas } from './tex.js';
import { rng, seg, clamp, easeOut, easeOutBack, hash, lerp } from './lib.js';

export const OW = 1080, OH = 1920;
// The safe zone: all text x 60–960, y 250–1480; captions centred on x 510, ≤ 820 wide, block within 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: 276, bannerW: 880,          // paper banner: top edge and its (constant) width, like the reference's strip
  capY: 1400, capBottom: 1470,           // caption block centre; its bottom never goes below capBottom
  chyY: 1336,                            // chyron: bottom edge (it sits right above the caption tags)
};
const PAPER = paperCanvas(5, 242, 30), PAPER2 = paperCanvas(9, 225, 50);
const TAG = '#cf6a43', TAG_TXT = '#fdf3e4', INK = '#161616';

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

// ---------- paper helpers ----------
function torn(g, x, y, w, h, seed, jt = 2.2, js = 6, step = 12) {
  const R = rng(seed + 1); g.beginPath();
  const top = [], right = [], bot = [], left = [];
  for (let s = 0; s <= w; s += step) top.push([x + s, y + (R() - 0.5) * jt * 2]);
  for (let s = 0; s <= h; s += 8) right.push([x + w + (R() - 0.5) * js * 2, y + s]);
  for (let s = w; s >= 0; s -= step) bot.push([x + s, y + h + (R() - 0.5) * jt * 2]);
  for (let s = h; s >= 0; s -= 8) left.push([x + (R() - 0.5) * js * 2, y + s]);
  [...top, ...right, ...bot, ...left].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, step } = {}) {
  g.save(); torn(g, x, y, w, h, seed, jt, js, step);
  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, step); g.clip(); g.globalCompositeOperation = 'multiply'; g.globalAlpha = ta;
  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.88;
  g.shadowColor = 'rgba(0,0,0,0.25)'; g.shadowBlur = 6; g.shadowOffsetY = 2;
  torn(g, -w / 2, -h / 2, w, h, seed, 1.2, 5, 8); g.fillStyle = '#d6c69a'; g.fill(); g.shadowColor = 'transparent';
  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 fit = (g, text, font, max, size, min = 30) => { for (; size > min; size -= 1) { g.font = `${size}px ${font}`; if (g.measureText(text).width <= max) break; } return size; };

// ---------- banner: one torn strip across the top, taped at both corners; the beat's title in heavy caps ----------
// st: { text, at (start time), n (index → tilt/tape seed) }
export function banner(g, st, t) {
  if (!st || !st.text) return;
  const k = t - st.at, first = st.at <= 0.001, pop = first ? 0 : 1 - easeOut(seg(k, 0, 0.16));
  const w = LAYOUT.bannerW, h = 106, cy = LAYOUT.bannerTop + h / 2, rot = ((st.n % 2) ? 1 : -1) * 0.006;
  g.save(); g.translate(SAFE.cx, cy); g.rotate(rot); g.scale(1 + 0.035 * pop, 1 + 0.035 * pop);
  paper(g, -w / 2, -h / 2, w, h, 3 + st.n * 7, '#f1ece2', { jt: 3.2, js: 3, step: 9, ta: 0.6 });
  tape(g, -w / 2 + 22, -h / 2 + 4, 118, 46, -0.78, 7 + st.n); tape(g, w / 2 - 22, -h / 2 + 4, 118, 46, 0.78, 13 + st.n);
  g.letterSpacing = '-0.5px'; const size = fit(g, st.text, FONT.sans, w - 110, 54, 34);
  g.font = `${size}px ${FONT.sans}`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillStyle = INK;
  const tw = g.measureText(st.text).width; mark(g, 'banner', st.text, -tw / 2, -size * 0.55, tw, size * 1.1);
  g.globalAlpha = first ? 1 : clamp(k / 0.05); g.fillText(st.text, 0, size * 0.05);
  g.restore();
}

// ---------- captions (paper word tags) ----------
function tag(g, text, cx, cy, size, seed, sc, alpha, { fill = TAG, ink = TAG_TXT, font = FONT.coop, kind = 'caption' } = {}) {
  g.save(); g.font = `${size}px ${font}`; const tw = g.measureText(text).width, w = tw + size * 0.62, h = size * 1.42;
  g.translate(cx, cy); g.rotate((hash(seed) - 0.5) * 0.1); 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.fillStyle = 'rgba(70,25,8,0.35)'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(text, 1.5, size * 0.06 + 3);
  g.fillStyle = ink; g.fillText(text, 0, size * 0.06);
  g.restore(); return w;
}
function layout(g, words, size, font, gap, maxW, pad) {
  g.font = `${size}px ${font}`; const rows = [[]]; let rw = 0;
  for (const w of words) { const ww = g.measureText(w.cap).width + size * pad; if (rows.at(-1).length && rw + gap + ww > maxW) { rows.push([]); rw = 0; } rows.at(-1).push({ w, ww }); rw += (rw ? gap : 0) + ww; }
  const out = []; const lh = size * 1.55;
  rows.forEach((r, ri) => { const tot = r.reduce((a, x) => a + x.ww, 0) + gap * (r.length - 1); let x = SAFE.cx - tot / 2; for (const it of r) { out.push({ ...it, cx: x + it.ww / 2, row: ri, rows: rows.length, lh }); x += it.ww + gap; } });
  return out;
}
function blockY(rows, lh, h) { const half = ((rows - 1) * lh + h) / 2; return Math.max(SAFE.cap0 + half, Math.min(LAYOUT.capY, LAYOUT.capBottom - half)); }
// words appear one by one as they are said (≤ 3 a chunk); the row re-centres as each new tag lands
export function captions(g, film, t, st = {}) {
  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 = 62 * (st.scale || 1); g.font = `${size}px ${FONT.coop}`;
  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 = 10 * size / 64;
  const L = layout(g, words, size, FONT.coop, gap, maxW, 0.62), Lp = words.length > 1 ? layout(g, words.slice(0, -1), size, FONT.coop, gap, maxW, 0.62) : null;
  const Y = blockY(L[0].rows, L[0].lh, size * 1.42);
  const slide = easeOut(seg(t, words.at(-1).t0, words.at(-1).t0 + 0.12));
  L.forEach((it, j) => {
    const p = Lp && Lp[j] ? Lp[j] : null;
    const cx = p ? lerp(p.cx, it.cx, slide) : it.cx, row = p ? lerp(p.row - (p.rows - 1) / 2, it.row - (it.rows - 1) / 2, slide) : it.row - (it.rows - 1) / 2;
    const k = seg(t, it.w.t0 - 0.02, it.w.t0 + 0.13);
    tag(g, it.w.cap, cx, Y + row * it.lh + (hash(it.w.i + 9) - 0.5) * 12 * size / 64, size, it.w.i + 1, easeOutBack(k, 2.2) * 0.4 + 0.6 * clamp(k * 1.5), clamp(k * 4), st.fill ? { fill: st.fill } : {});
  });
}

// ---------- chyron: the blue lower third with a torn, foamy water edge (the reference's "SEEKING $1M" panel) ----------
// st: { lines: ['MY OFFER:', 'FORMAT + SKILL.md FOR $0'], at, out }
export function chyron(g, st, t) {
  if (!st || t < st.at) return;
  const inK = easeOut(seg(t, st.at, st.at + 0.2)), outK = st.out != null ? seg(t, st.out, st.out + 0.14) : 0;
  if (outK >= 1) return;
  const size = st.size || 60, lh = size * 1.02, n = st.lines.length, padT = 40, padB = 24;
  g.save(); g.font = `700 ${size}px ${FONT.cond}`; g.letterSpacing = '0.5px';
  const tw = Math.max(...st.lines.map(l => g.measureText(l).width)), W = Math.min(900, Math.max(560, tw + 150)), H = padT + n * lh + padB;
  const y1 = LAYOUT.chyY, y0 = y1 - H, slide = (1 - inK) * -(W + 40) + outK * -(W + 40);
  g.translate(slide, 0);
  // panel with a wavy top edge
  const R = rng(st.seed || 3), wave = x => Math.sin(x * 0.045 + 1.3) * 6 + Math.sin(x * 0.13) * 3 + (R() - 0.5) * 3;
  g.beginPath(); g.moveTo(-10, y1); g.lineTo(-10, y0 + wave(0)); for (let x = 0; x <= W; x += 12) g.lineTo(x, y0 + wave(x)); g.lineTo(W + 6, y0 + 10); g.lineTo(W - 6, y1); g.closePath();
  const gr = g.createLinearGradient(0, y0, 0, y1); gr.addColorStop(0, '#1f5cc0'); gr.addColorStop(0.35, '#17479f'); gr.addColorStop(1, '#0e2f74');
  g.shadowColor = 'rgba(0,0,0,0.45)'; g.shadowBlur = 20; g.shadowOffsetY = 6; g.fillStyle = gr; g.fill(); g.shadowColor = 'transparent';
  // foam along the edge: a light band + white dots and a few sparkles
  const R2 = rng((st.seed || 3) + 7);
  g.strokeStyle = 'rgba(160,205,255,0.9)'; g.lineWidth = 5; g.beginPath(); for (let x = 0; x <= W; x += 12) { const y = y0 + wave(x) + 2; x ? g.lineTo(x, y) : g.moveTo(x, y); } g.stroke();
  for (let k = 0; k < 70; k++) { const x = R2() * W, y = y0 + 6 + R2() * 18 - 4; g.fillStyle = `rgba(255,255,255,${0.35 + R2() * 0.6})`; g.beginPath(); g.arc(x, y + Math.sin(x * 0.045 + 1.3) * 6, 1.5 + R2() * 3, 0, 7); g.fill(); }
  for (let k = 0; k < 10; k++) { const x = R2() * W, y = y0 + 30 + R2() * (H - 50); g.fillStyle = `rgba(190,220,255,${0.25 + R2() * 0.3})`; g.beginPath(); g.arc(x, y, 1.5 + R2() * 2.5, 0, 7); g.fill(); }
  // text
  g.fillStyle = '#ffffff'; g.textAlign = 'left'; g.textBaseline = 'middle'; g.shadowColor = 'rgba(0,10,40,0.5)'; g.shadowBlur = 6; g.shadowOffsetY = 2;
  // the words only show once the panel has landed (and leave before it slides out), so no text crosses the safe edge
  const ta = clamp((inK - 0.986) / 0.014) * (outK > 0 ? 0 : 1);
  if (ta > 0) { g.globalAlpha = ta; st.lines.forEach((l, i) => { const y = y0 + padT + (i + 0.5) * lh; g.fillText(l, 72, y); mark(g, 'chyron', l, 72, y - size * 0.5, g.measureText(l).width, size); }); }
  g.restore();
}

// ---------- speech bubble: a white ring with a drawn avatar and a little tail (x, y = the point it hangs over) ----------
// st: { at, out, r (radius, px), img (canvas), tail: 'down' | 'downL' | 'downR' }
export function bubble(g, x, y, st, t) {
  if (!st || t < st.at || !st.img) return;
  const k = seg(t, st.at, st.at + 0.22), outK = st.out != null ? seg(t, st.out, st.out + 0.12) : 0; if (outK >= 1) return;
  const s = easeOutBack(k, 2.4) * (1 - outK), r = (st.r || 82) * s; if (r < 1) return;
  const bob = Math.sin((t - st.at) * 3.1 + (st.seed || 0)) * 3, cy = y - r - 28 + bob;
  g.save(); g.shadowColor = 'rgba(0,0,0,0.42)'; g.shadowBlur = 18; g.shadowOffsetY = 6; g.fillStyle = '#ffffff';
  const tx = st.tail === 'downR' ? 0.45 : st.tail === 'downL' ? -0.45 : 0;
  g.beginPath(); g.arc(x, cy, r + 9 * s, 0, Math.PI * 2); g.fill();
  g.beginPath(); g.moveTo(x + (tx * r) - 16 * s, cy + r * 0.8); g.lineTo(x + tx * r * 1.4, cy + r + 30 * s); g.lineTo(x + (tx * r) + 18 * s, cy + r * 0.78); g.closePath(); g.fill();
  g.shadowColor = 'transparent';
  g.beginPath(); g.arc(x, cy, r, 0, Math.PI * 2); g.clip(); g.drawImage(st.img, x - r, cy - r, r * 2, r * 2);
  g.restore();
  g.save(); g.lineWidth = 3 * s; g.strokeStyle = 'rgba(40,30,25,0.35)'; g.beginPath(); g.arc(x, cy, r, 0, Math.PI * 2); g.stroke(); g.restore();
}

// ---------- the COMMENT bubble (CTA): a big puffy white bubble, "COMMENT" + the keyword in red, a burst and sparkles ----------
// C: { at, word, x, y }
export function commentBubble(g, t, C, starImg) {
  if (!C || t < C.at) return;
  const k = seg(t, C.at, C.at + 0.28), s = easeOutBack(k, 2.0), x = C.x ?? SAFE.cx, y = C.y ?? 1010, w = 600, h = 300;
  g.save(); g.translate(x, y); g.scale(s, s);
  // burst ring
  const bk = seg(t, C.at, C.at + 0.5); if (bk < 1) { g.save(); g.globalAlpha = 1 - bk; g.strokeStyle = '#ffffff'; g.lineWidth = 8 * (1 - bk) + 2; g.beginPath();
    for (let a = 0; a <= 40; a++) { const ang = a / 40 * Math.PI * 2, rr = (1 + bk * 0.5) * (w / 2) * (a % 2 ? 0.92 : 1.05); g.lineTo(Math.cos(ang) * rr, Math.sin(ang) * rr * 0.62); } g.closePath(); g.stroke();
    for (let a = 0; a < 12; a++) { const ang = a / 12 * Math.PI * 2, r0 = w * 0.34 * (1 + bk), r1 = r0 + 60; g.beginPath(); g.moveTo(Math.cos(ang) * r0, Math.sin(ang) * r0 * 0.62); g.lineTo(Math.cos(ang) * r1, Math.sin(ang) * r1 * 0.62); g.stroke(); } g.restore(); }
  g.shadowColor = 'rgba(0,0,0,0.35)'; g.shadowBlur = 30; g.shadowOffsetY = 10;
  const gr = g.createLinearGradient(0, -h / 2, 0, h / 2); gr.addColorStop(0, 'rgba(255,255,255,0.97)'); gr.addColorStop(1, 'rgba(232,230,240,0.95)');
  g.fillStyle = gr; g.beginPath(); g.ellipse(0, 0, w / 2, h / 2, 0, 0, Math.PI * 2); g.fill();
  g.beginPath(); g.moveTo(-w * 0.26, h * 0.32); g.lineTo(-w * 0.34, h * 0.68); g.lineTo(-w * 0.08, h * 0.44); g.closePath(); g.fill();
  g.shadowColor = 'transparent';
  g.fillStyle = '#7d7a86'; g.font = `34px ${FONT.sans}`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.letterSpacing = '3px';
  g.fillText('COMMENT', 0, -64); mark(g, 'comment', 'COMMENT', -100, -84, 200, 40);
  g.letterSpacing = '-1px'; const size = fit(g, C.word, FONT.sans, w - 120, 128, 60); g.font = `${size}px ${FONT.sans}`;
  const wg = g.createLinearGradient(0, -size / 2, 0, size / 2); wg.addColorStop(0, '#ff6a5a'); wg.addColorStop(1, '#d42f2f');
  g.fillStyle = 'rgba(120,20,20,0.25)'; g.fillText(C.word, 4, 34 + 5); g.fillStyle = wg; g.fillText(C.word, 0, 34);
  const ww = g.measureText(C.word).width; mark(g, 'comment', C.word, -ww / 2, 34 - size * 0.5, ww, size);
  g.restore();
  // sparkles twinkling around it
  if (starImg) for (let i = 0; i < 6; i++) { const ph = (t - C.at) * 4 + i * 1.7, a = Math.max(0, Math.sin(ph)); if (a <= 0 || k < 0.6) continue;
    const ang = i / 6 * Math.PI * 2 + 0.4, px = x + Math.cos(ang) * (w * 0.55), py = y + Math.sin(ang) * (h * 0.62), ss = 26 + 26 * a;
    g.save(); g.globalAlpha = a; g.translate(px, py); g.rotate(ph * 0.3); g.drawImage(starImg, -ss / 2, -ss / 2, ss, ss); g.restore(); }
}

// ---------- paper plane (the DM) flying off to the top right — decoration, not text ----------
export function plane(g, t, t0, x0, y0) {
  if (t < t0 || t > t0 + 0.95) return;
  const k = seg(t, t0, t0 + 0.9), e = easeOut(k);
  const x = lerp(x0, 1180, e) - Math.sin(k * Math.PI) * 80, yy = lerp(y0, 380, 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.82, 1);
  g.shadowColor = 'rgba(0,0,0,0.4)'; g.shadowBlur = 14; g.fillStyle = '#ffffff';
  g.beginPath(); g.moveTo(-60, 0); g.lineTo(66, -36); g.lineTo(-12, 41); g.lineTo(-17, 12); g.closePath(); g.fill();
  g.fillStyle = '#d7dbe3'; g.beginPath(); g.moveTo(-17, 12); g.lineTo(66, -36); g.lineTo(-12, 41); g.closePath(); g.fill(); g.restore();
}
```

### `engine/look.js`

```js
// Lights + grade per set, as dials a video turns over time. Engine file.
//   set   'studio' | 'corridor' | 'felt' | 'brick' | 'office'   (which set's lights are on; world(t) shows that set)
//   lit   0..1  studio windows from cool night blue to warm glowing orange (+ warm window lights, warm grade)
//   spot  0..1  the stage spotlight; key 0..2 multiplies the key light; fire 0..1 the fire light
//   warm  0..1  extra warm grade; exposure multiplies
import { lerp } from './lib.js';
export function applyLook(E, t, o = {}) {
  const { R, post } = E, set = o.set || 'studio', lit = o.lit ?? 0;
  for (const [n, g] of Object.entries({ studio: R.studioLights, corridor: R.corLights, felt: R.feltLights, brick: R.brickLights, office: R.officeLights })) if (g) g.visible = n === set;
  const wA = Math.min(1, (o.warm ?? 0) + (set === 'studio' ? lit * 0.3 : set === 'corridor' ? 0.35 : 0.1));
  post.U.wA.value = wA; post.F.wA.value = wA; post.U.wM.value = 0; post.F.wM.value = 0;
  post.U.bloom.value = o.bloom ?? (set === 'studio' ? lerp(0.4, 0.36, lit) : set === 'corridor' ? 0.5 : 0.26);
  post.U.exposure.value = (o.exposure ?? 1) * (set === 'studio' ? lerp(0.95, 1.05, lit) : set === 'corridor' ? 1.0 : 1.08);
  if (set === 'studio') {
    for (const m of R.winMats) { m.uniforms.k.value = lit; m.uniforms.gain.value = o.winGain ?? 0.85; }
    R.key.position.set(...(o.keyPos || [4, 11, 9]));
    R.key.intensity = 300 * (o.key ?? 1) * lerp(0.85, 1, lit);
    R.key.color.setRGB(1, lerp(0.82, 0.78, lit), lerp(0.7, 0.58, lit));
    R.hemi.color.setRGB(lerp(0.42, 0.62, lit), lerp(0.5, 0.52, lit), lerp(0.9, 0.62, lit)); R.hemi.groundColor.setRGB(0.06, 0.05, 0.08); R.hemi.intensity = lerp(0.5, 0.42, lit);
    R.rim.intensity = lerp(1.4, 0.9, lit);
    for (const l of R.winLights) l.intensity = 16 * lit;
    R.spot.intensity = 110 * (o.spot ?? 0);
    R.fill.intensity = lerp(0.55, 0.7, lit);
  }
  R.fireLight.intensity = 14 * (o.fire ?? 0);
}
```

### `engine/post.js`

```js
// Post pipeline: scene (MSAA + depth) -> bloom chain -> comp (DOF, bloom, tone map, grade) -> final (whip/zoom blur, CA, leak, flash, vignette, overlay, grain).
// Engine file: a video never edits this. Grain is kept light (0.028) because it decides the file size; flash is capped by flashMax.
import { FLASH_MAX, GRAIN } from './fx.js';
import * as THREE from 'three';

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

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

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

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

  const U = {
    tScene: { value: rtScene.texture }, tDepth: { value: depthTex }, b1: { value: levels[0][0].texture }, b2: { value: levels[1][0].texture }, b3: { value: levels[2][0].texture },
    res: { value: new THREE.Vector2(W, H) }, near: { value: 0.1 }, far: { value: 200 }, focus: { value: 5 }, aperture: { value: 10 }, maxBlur: { value: 16 },
    bloom: { value: 0.55 }, 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++) {
          float r = sqrt(float(i) / 40.); float a = float(i) * 2.39996;
          vec2 o = vec2(cos(a), sin(a)) * r * c0 / res;
          float sz = linZ(texture2D(tDepth, vUv + o).x); float sc = coc(sz);
          float w = sz < z ? smoothstep(r * c0 - 1.5, r * c0, sc + 1.) : 1.;
          w = max(w, 0.15);
          acc += texture2D(tScene, vUv + o).rgb * w; ws += w;
        }
      }
      vec3 col = acc / ws;
      col += (texture2D(b1, vUv).rgb * 0.5 + texture2D(b2, vUv).rgb * 0.8 + texture2D(b3, vUv).rgb * 1.1) * bloom;
      col *= exposure;
      col *= mix(vec3(1.), vec3(1.16, 1.0, 0.8), wA);
      col *= mix(vec3(1.), vec3(0.78, 0.9, 1.12), wM);
      col = aces(col);
      col = pow(col, vec3(1. / 2.2));
      float l = dot(col, vec3(0.2126, 0.7152, 0.0722));
      float sat = 1.1 - 0.05 * wA - 0.5 * wM;
      col = mix(vec3(l), col, sat);
      float con = 1.04 - 0.08 * wA + 0.22 * wM;
      col = (col - 0.5) * con + 0.5;
      col += vec3(0.04, 0.022, 0.01) * wA + vec3(0.0, 0.01, 0.028) * wM;
      gl_FragColor = vec4(clamp(col, 0., 1.), 1.);
    }`, U);

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

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

### `engine/camera.js`

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

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

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

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

### `engine/fx.js`

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

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

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

### `engine/common.mjs`

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

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

### `engine/setup.mjs`

```js
// Requirements check + the free assets. Run once per project:  node engine/setup.mjs
// Node 22+, ffmpeg/ffprobe, three.js, Chrome/Edge with WebGL on the GPU, the ElevenLabs key (never printed); downloads the fonts + music
// if missing and writes fonts/fonts.json (the files the page may load: licensed originals you copy in win over the free look-alikes).
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { ROOT } from './common.mjs';
import { CHROME, launch } from './cdp.mjs';
import { KIT } from './gen-sfx.mjs';
const ok = (good, msg) => console.log(good ? '✓' : '✗', msg);
ok(+process.versions.node.split('.')[0] >= 22, 'Node ' + process.versions.node + ' (22+ needed: global fetch and WebSocket)');
for (const c of ['ffmpeg', 'ffprobe']) ok(spawnSync(c, ['-version']).status === 0, c + ' on PATH');
ok(fs.existsSync(path.join(ROOT, 'node_modules/three/build/three.module.js')), 'three.js (npm i three@0.170.0)');
ok(!!CHROME, 'Chrome or Edge: ' + (CHROME || 'not found, set CHROME=<path to the browser>'));
ok(!!(process.env.ELEVENLABS_API_KEY || (fs.existsSync(path.join(ROOT, '.env')) && /^ELEVENLABS_API_KEY=./m.test(fs.readFileSync(path.join(ROOT, '.env'), 'utf8')))), 'ELEVENLABS_API_KEY (environment or .env)');
const GH = 'https://raw.githubusercontent.com/google/fonts/main/ofl/';
const GET = [   // all free: the fonts are SIL Open Font License (google/fonts), the music is Mixkit's free licence
  ['fonts/InterTight.ttf', GH + 'intertight/InterTight%5Bwght%5D.ttf'],                       // the banner (pinned to 800)
  ['fonts/Fraunces.ttf', GH + 'fraunces/Fraunces%5BSOFT,WONK,opsz,wght%5D.ttf'],               // the word tags (soft, Cooper-like, 900)
  ['fonts/RobotoCondensed.ttf', GH + 'robotocondensed/RobotoCondensed%5Bwght%5D.ttf'],         // the chyron (700)
  ['fonts/JetBrainsMono.ttf', GH + 'jetbrainsmono/JetBrainsMono%5Bwght%5D.ttf'],               // the register's LED (800)
  ['music/coffee-or-tea.mp3', 'https://assets.mixkit.co/music/1168/1168.mp3'],                 // Mixkit #1168 "Coffee or Tea": level from 8 s on
];
for (const [rel, url] of GET) {
  const f = path.join(ROOT, rel); if (fs.existsSync(f)) { ok(true, rel); continue; }
  fs.mkdirSync(path.dirname(f), { recursive: true });
  try { const r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0' } }); if (!r.ok) throw new Error(r.status); fs.writeFileSync(f, Buffer.from(await r.arrayBuffer())); ok(true, rel + ' downloaded'); }
  catch (e) { ok(false, `${rel}: download failed (${e.message}) from ${url}`); }
}
// the page loads only the fonts listed here (no 404s); COOPBL.TTF (Microsoft Office) or consolab.ttf (Windows), if you copy them in, win
const fonts = fs.readdirSync(path.join(ROOT, 'fonts')).filter(f => /\.(ttf|otf)$/i.test(f));
fs.writeFileSync(path.join(ROOT, 'fonts', 'fonts.json'), JSON.stringify(fonts, null, 1)); ok(fonts.length >= 4, 'fonts/fonts.json: ' + fonts.join(', '));
const have = Object.keys(KIT).filter(n => fs.existsSync(path.join(ROOT, 'sfx', n + '.wav')));
ok(have.length === Object.keys(KIT).length, `${have.length} of ${Object.keys(KIT).length} sound effects in sfx/ (make the kit once: node engine/gen-sfx.mjs --yes)`);
if (CHROME) try { const api = await launch({ port: 9699 }); ok(true, 'WebGL on the GPU: ' + api.gpu); await api.close(); } catch (e) { ok(false, e.message); }
setTimeout(() => process.exit(0), 1200);
```

### `engine/render.mjs`

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

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

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

### `engine/voedit.mjs`

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

### `engine/votight.mjs`

```js
// The reference's rapid-fire delivery without paying for a new take: shorten every pause (cut only inside real silence), then speed
// the take up, and remap its word timings.
//   node engine/votight.mjs <video> --from a --out b [--stop 0.12] [--comma 0.06] [--word 0.04] [--tempo 1.12] [--pitch 1.04] [--set]
// --stop: the pause kept after . ! ? :   --comma: after , ;   --word: between other words (only longer silences are cut)
// --tempo: the total speed-up; --pitch: the part done by resampling (1.04 ≈ +0.7 semitone, a touch brighter), the rest by atempo
// (pitch kept). ×1.12 keeps every word clear; ×1.18 starts to blur fast words. Writes vo/<out>.wav + .json (like gen-vo).
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { args, videoDir, saveVideo, alignWords, run, die, frozen } from './common.mjs';
const { pos, opt } = args(), { dir } = videoDir(pos[0]), vo = path.join(dir, 'vo'), SR = 48000;
if (frozen(dir)) die('this voice is frozen (vo/FROZEN): tighten takes in your own video folder');
const from = opt.from || 'a', out = opt.out || 'b'; if (out === from) die('--out must differ from --from');
const STOP = +(opt.stop ?? 0.12), COMMA = +(opt.comma ?? 0.06), WORD = +(opt.word ?? 0.04), TEMPO = +(opt.tempo ?? 1.12), PITCH = +(opt.pitch ?? 1.04);
const r = spawnSync('ffmpeg', ['-v', 'error', '-i', path.join(vo, from + '.wav'), '-ac', '1', '-ar', String(SR), '-f', 's16le', '-'], { maxBuffer: 1 << 30 });
if (r.status !== 0) die(`vo/${from}.wav missing`);
const P = new Int16Array(r.stdout.buffer, r.stdout.byteOffset, r.stdout.length / 2);
const J = JSON.parse(fs.readFileSync(path.join(vo, from + '.json'), 'utf8')), A = J.alignment, W = alignWords(A);
// 5 ms loudness frames; silence = 40 dB under the take's loud frames
const nF = Math.floor(P.length / 240), dB = new Float32Array(nF);
for (let f = 0; f < nF; f++) { let s = 0; for (let i = f * 240; i < (f + 1) * 240; i++) s += (P[i] / 32768) ** 2; dB[f] = 10 * Math.log10(s / 240 + 1e-12); }
const THR = [...dB].sort((a, b) => a - b)[Math.floor(nF * 0.9)] - 40;
const cuts = [];
for (let k = 0; k + 1 < W.length; k++) {
  // the silence after word k (ElevenLabs often hands it to the next word, so look into that word's span too)
  let a = Math.ceil(W[k].e * 200); const lim = Math.floor(Math.min(W[k + 1].e - 0.05, W[k + 1].s + 0.8) * 200);
  while (a < lim && dB[a] > THR) a++;
  let b = a; while (b < lim && dB[b] <= THR) b++;
  const s0 = a / 200 + 0.015, s1 = b / 200 - 0.015, sil = s1 - s0; if (sil <= 0) continue;
  const keep = /[.!?:]["”]?$/.test(W[k].w) ? STOP : /[,;]["”]?$/.test(W[k].w) ? COMMA : WORD, drop = sil - keep;
  if (drop > 0.01) { const mid = (s0 + s1) / 2; cuts.push([mid - drop / 2, mid + drop / 2]); }
}
// splice with 4 ms fades on both sides of each cut, keeping a map from old to new time
const outPCM = [], fade = Math.round(0.004 * SR), map = []; let pos0 = 0, dst = 0;
for (const [a, b] of [...cuts, [P.length / SR, P.length / SR]]) {
  const i0 = Math.round(pos0 * SR), i1 = Math.round(a * SR), seg = Array.from(P.subarray(i0, i1)), last = a >= P.length / SR;
  for (let i = 0; i < Math.min(fade, seg.length); i++) { if (pos0 > 0) seg[i] *= i / fade; if (!last) seg[seg.length - 1 - i] *= i / fade; }
  map.push([pos0, a, dst]); dst += (i1 - i0) / SR; for (const v of seg) outPCM.push(Math.round(v)); pos0 = b;
}
const t2 = t => { for (const [s0, s1, d0] of map) if (t <= s1) return d0 + Math.max(0, t - s0); const [s0, , d0] = map.at(-1); return d0 + (t - s0); };
const raw = path.join(vo, out + '.raw'); fs.writeFileSync(raw, Buffer.from(new Int16Array(outPCM).buffer));
run('ffmpeg', ['-v', 'error', '-y', '-f', 's16le', '-ar', String(SR), '-ac', '1', '-i', raw, '-af', `asetrate=${SR * PITCH},aresample=${SR},atempo=${(TEMPO / PITCH).toFixed(5)}`, '-ar', String(SR), path.join(vo, out + '.wav')]);
fs.rmSync(raw);
const T = t => +(t2(t) / TEMPO).toFixed(3);
fs.writeFileSync(path.join(vo, out + '.json'), JSON.stringify({ ...J, edit: { from, stop: STOP, comma: COMMA, word: WORD, tempo: TEMPO, pitch: PITCH, cuts: cuts.length },
  alignment: { characters: A.characters, character_start_times_seconds: A.character_start_times_seconds.map(T), character_end_times_seconds: A.character_end_times_seconds.map(T) } }));
if (opt.set) saveVideo(dir, { take: out });
const len = dst / TEMPO, removed = cuts.reduce((s, [a, b]) => s + b - a, 0);
console.log(`vo/${out}.wav · ${cuts.length} pauses shortened (−${removed.toFixed(2)} s) · ×${TEMPO} · ${len.toFixed(2)} s · ${(W.length / len * 60).toFixed(0)} words/min (the reference: 287)${opt.set ? ' · video.json take = ' + out : ''}`);
```

### `engine/build.mjs`

```js
// Voice take -> <video>/film.json: the shown caption tokens (script.mjs LINES) timed on the said words, cut into the word-tag chunks
// (≤ 3 tags, a break after punctuation), and a 30 fps loudness envelope. Prints every token with its time, then the chunks.
//   node engine/build.mjs <video>           from vo/<take>.json (ElevenLabs alignment, tightened by votight) + vo/<take>.wav
//   node engine/build.mjs <video> --fake    timings guessed from script.mjs at the tightened pace (~4.4 words/s): direct and render
//                                           stills before paying for a voice
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { FPS, args, videoDir, loadVideo, loadScript, tokens, alignWords, die } from './common.mjs';
const { pos, opt } = args(), { dir } = videoDir(pos[0]), cfg = loadVideo(dir), S = await loadScript(dir), TOK = tokens(S.LINES, S.SAID);
const take = opt.fake ? 'fake' : cfg.take, said = TOK.flatMap(t => t.said.split(/\s+/));
let W = [], env = [];
if (opt.fake) {   // ≈ Liam at speed 1.2 after votight (pauses 0.12 / 0.06 s, ×1.12)
  let t = 0.05; for (const w of said) { const d = (0.065 + 0.042 * w.replace(/[^A-Za-z0-9]/g, '').length) / 1.12; W.push({ w, s: t, e: t + d }); t += d + (/[.!?]["”]?$/.test(w) ? 0.12 : /[,:;]["”]?$/.test(w) ? 0.06 : 0.01); }
  for (let f = 0; f < Math.ceil((t + 2) * FPS); f++) { const s = f / FPS, on = W.some(w => s >= w.s && s < w.e); env.push(on ? +(0.45 + 0.4 * Math.abs(Math.sin(f * 1.7))).toFixed(3) : 0); }
} else {
  const al = path.join(dir, 'vo', take + '.json'); if (!fs.existsSync(al)) die(`vo/${take}.json missing: node engine/gen-vo.mjs ${pos[0]} --yes (or build --fake)`);
  W = alignWords(JSON.parse(fs.readFileSync(al, 'utf8')).alignment);
  if (W.length !== said.length) die(`the take has ${W.length} words, script.mjs says ${said.length}: was the take made from this script?`);
  // ElevenLabs hands the silence before a word to that word: move each start to where its sound begins (10 ms RMS > 5 % of the loud level)
  const pcm = spawnSync('ffmpeg', ['-v', 'error', '-i', path.join(dir, 'vo', take + '.wav'), '-f', 'f32le', '-ac', '1', '-ar', '16000', '-'], { maxBuffer: 1 << 28 }).stdout;
  const X = new Float32Array(pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + (pcm.length & ~3))), rms = [];
  for (let i = 0; i + 160 <= X.length; i += 160) { let a = 0; for (let j = i; j < i + 160; j++) a += X[j] * X[j]; rms.push(Math.sqrt(a / 160)); }
  const thr = Math.max(0.004, 0.05 * [...rms].sort((a, b) => a - b)[Math.floor(rms.length * 0.95)]);
  for (const w of W) { let f = Math.round(w.s * 100); const lim = Math.round(Math.min(w.e - 0.05, w.s + 0.8) * 100); while (f < lim && (rms[f] ?? 1) < thr) f++; w.s = Math.max(w.s, f / 100 - 0.02); }
  const hop = 16000 / FPS; for (let f = 0; f * hop < X.length; f++) { let a = 0; const s0 = Math.floor(f * hop), s1 = Math.min(X.length, Math.floor((f + 1) * hop)); for (let i = s0; i < s1; i++) a += X[i] * X[i]; env.push(Math.sqrt(a / Math.max(1, s1 - s0))); }
  const pk = [...env].sort((a, b) => a - b)[Math.floor(env.length * 0.97)] || 1; env = env.map(v => +Math.min(1, v / pk).toFixed(3));
}
// what a tag shows: the token without trailing punctuation or quotes ("SKILL.md" and ".md" keep their dots); capFix fixes spellings
const CAP = Object.fromEntries(Object.entries(cfg.capFix || {}).map(([k, v]) => [k.toLowerCase(), v]));
const capOf = tok => tok.replace(/^["“'‘(]+/, '').replace(/["”’)]+$/, '').replace(/[.,!?;:]+$/, '');
const OFF = cfg.voOff || 0; let k = 0;
const words = TOK.map((t, i) => {
  const a = W[k], b = W[k + t.n - 1]; k += t.n; const bare = capOf(t.cap);
  return { i, w: t.cap, cap: CAP[bare.toLowerCase()] || bare, line: t.line, t0: +(a.s + OFF).toFixed(3), t1: +(b.e + OFF).toFixed(3), stop: /[.,!?;:]["”]?$/.test(t.cap) };
});
// chunks: the reference's even chunks of ≤ 3 words per phrase; a phrase whose chunk would be too wide gets the fewest chunks that fit,
// the most even widths among them
const width = c => c.reduce((a, w) => a + w.cap.length * 34 + 70, 0), phrases = [], chunks = []; let cur = [];
words.forEach(w => { cur.push(w); if (w.stop) { phrases.push(cur); cur = []; } }); if (cur.length) phrases.push(cur);
for (const p of phrases) {
  const n0 = Math.ceil(p.length / 3), even = []; for (let c = 0, i = 0; c < n0; c++) { const m = Math.floor(p.length / n0) + (c < p.length % n0 ? 1 : 0); even.push(p.slice(i, i + m)); i += m; }
  if (even.every(c => width(c) <= cfg.chunkW)) { chunks.push(...even); continue; }
  const L = p.length, best = Array.from({ length: L + 1 }, () => new Map()); best[0].set(0, { max: 0, sq: 0, cut: -1 });
  for (let i = 1; i <= L; i++) for (let m = 1; m <= Math.min(3, i); m++) {
    const wd = width(p.slice(i - m, i)); if (wd > cfg.chunkW && m > 1) continue;
    for (const [n, v] of best[i - m]) { const mx = Math.max(v.max, wd), sq = v.sq + wd * wd, c = best[i].get(n + 1); if (!c || mx < c.max || (mx === c.max && sq < c.sq)) best[i].set(n + 1, { max: mx, sq, cut: i - m }); }
  }
  const parts = []; let i = L, n = Math.min(...best[L].keys()); while (i > 0) { const { cut } = best[i].get(n); parts.unshift(p.slice(cut, i)); i = cut; n--; }
  chunks.push(...parts);
}
const CH = chunks.map((c, j) => ({ k: j, t0: c[0].t0, end: c.at(-1).t1, words: c.map(w => w.i) }));
CH.forEach((c, j) => { const next = CH[j + 1]; c.t1 = +Math.min(next ? next.t0 : c.end + 1.2, c.end + 0.7).toFixed(3); });
env = [...Array(Math.round(OFF * FPS)).fill(0), ...env];
const END = +(words.at(-1).t1 + cfg.tail).toFixed(2);
fs.writeFileSync(path.join(dir, 'film.json'), JSON.stringify({ take, voOff: OFF, END, words, chunks: CH, env }));
console.log(`${words.length} caption tokens · ${CH.length} tag chunks · END ${END} s${END > 60 ? '  ✗ over 60 s: cut the script' : ''}${opt.fake ? ' · FAKE timings (no voice yet)' : ''}`);
let line = -1, s = ''; for (const w of words) { if (w.line !== line) { if (s) console.log(s); line = w.line; s = `L${line} `; } s += `${w.cap}@${w.t0.toFixed(2)} `; } console.log(s);
console.log(CH.map(c => c.words.map(i => words[i].cap).join(' ') + ' @' + c.t0.toFixed(2)).join(' | '));
```

### `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; ~20 s of sound in all)
import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process';
import { ROOT, args, apiKey } from './common.mjs';
export const KIT = {   // name: [prompt, seconds]   — what each is for in the cues
  chomp: ['cartoon monster chomp, one quick hard bite with a crunchy paper crunch, punchy, dry, no music, no voice', 0.8],   // a bite
  rip: ['a paper price sticker ripped off a card quickly, short sharp paper tear, dry, no music', 0.7],                      // a tag torn off
  kaching: ['vintage cash register ka-ching bell ring with the cash drawer sliding open, short, dry, no music', 1.3],        // a sale
  burn: ['a stack of paper money catching fire, quick whoosh then crackling flames, no explosion', 1.3],                     // fire
  burst: ['a quick bright burst of sparks, small and crackly, no explosion boom', 0.8], fuse: ['short fizzing sizzle of sparks, close mic', 1.8],
  whoosh: ['a quick airy whoosh transition', 0.8], swoosh: ['a soft short swoosh of paper flying past', 0.7], swish: ['a fast thin swish, like a card flicked through the air', 0.6],
  pop: ['a soft cartoon pop, bubbly and light', 0.5], boing: ['a short cartoon spring boing, playful bounce', 0.5],
  thud: ['a cardboard box lid shutting with a soft low thud', 0.8], flick: ['a stiff paper sign flipped over quickly, short papery flick', 0.5],
  clunk: ['a big studio light switch clunking on', 0.5], ding: ['a bright clean notification ding', 0.6],
  shimmer: ['a warm magical shimmer, rising sparkly chimes', 2.2], chime: ['a gentle sparkling chime arpeggio, bright and short, rising', 2.0],
  click: ['a single small dry click, like a tiny footstep on a wooden stage', 0.5], clack: ['a conveyor belt clacking forward one notch, short mechanical clack', 0.5],
  slap: ['a sticky note slapped flat onto a paper sign, short dry slap', 0.5], stamp: ['a rubber stamp pressed hard onto paper on a desk, single thump', 0.6],
  impact: ['a deep soft cinematic impact with a short rumble tail', 1.5], plane: ['a paper airplane whooshing away quickly', 0.8],
};
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
  const { pos, opt } = args(), out = path.join(ROOT, 'sfx'); fs.mkdirSync(out, { recursive: true });
  const todo = (pos.length ? pos : Object.keys(KIT)).filter(n => KIT[n] && !fs.existsSync(path.join(out, n + '.wav')));
  console.log(`${todo.length} sounds to make: ${todo.join(' ') || '(none)'}`); if (!opt.yes) { console.log('dry run: add --yes to generate them (sound generation costs credits).'); process.exit(0); }
  for (const n of todo) {
    const [text, sec] = KIT[n], r = await fetch('https://api.elevenlabs.io/v1/sound-generation', { method: 'POST', headers: { 'xi-api-key': apiKey(), 'content-type': 'application/json' }, body: JSON.stringify({ text, duration_seconds: sec, prompt_influence: 0.6 }) });
    if (!r.ok) { console.log(n, r.status, (await r.text()).slice(0, 200)); continue; }
    const mp3 = path.join(out, n + '.mp3'); fs.writeFileSync(mp3, Buffer.from(await r.arrayBuffer()));
    execFileSync('ffmpeg', ['-v', 'error', '-y', '-i', mp3, '-ar', '48000', '-ac', '2', path.join(out, n + '.wav')]); fs.rmSync(mp3); console.log(n, 'ok');
  }
}
```

### `engine/mix.mjs`

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

### `engine/encode.mjs`

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

### `engine/check.mjs`

```js
// The finished-video checks:  node engine/check.mjs <video> [--file video.mp4]
// format + size + loudness · frame 0 not black · text inside the safe zone on every frame · flash white-outs · frozen stretches · cut rhythm
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { args, videoDir, loud, low, die } from './common.mjs';
low();
const { pos, opt } = args(), { dir } = videoDir(pos[0]), file = path.resolve(dir, opt.file || 'video.mp4'); if (!fs.existsSync(file)) die('no ' + file);
let ok = true; const say = (good, msg) => { ok &&= good; console.log(good ? '✓' : '✗', msg); };
const p = JSON.parse(spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration,size:stream=codec_type,codec_name,profile,width,height,r_frame_rate,pix_fmt', '-of', 'json', file], { encoding: 'utf8' }).stdout);
const v = p.streams.find(s => s.codec_type === 'video'), au = p.streams.find(s => s.codec_type === 'audio'), dur = +p.format.duration, mb = p.format.size / 1e6, L = loud(file);
say(v.width === 1080 && v.height === 1920 && v.r_frame_rate === '30/1' && v.pix_fmt === 'yuv420p' && v.profile === 'High', `video ${v.codec_name} ${v.profile} ${v.pix_fmt} ${v.width}x${v.height} ${v.r_frame_rate}`);
say(au?.codec_name === 'aac', `audio ${au?.codec_name}`); say(dur <= 60.05, `${dur.toFixed(2)} s`); say(mb <= 60, `${mb.toFixed(1)} MB`);
say(Math.abs(L.I + 14) <= 1 && L.TP <= -1, `loudness ${L.I} LUFS · true peak ${L.TP} dBTP`);
// every frame at 54x96 grey: mean brightness and change from the previous frame
const raw = spawnSync('ffmpeg', ['-v', 'error', '-threads', '2', '-i', file, '-vf', 'scale=54:96,format=gray', '-f', 'rawvideo', '-'], { maxBuffer: 1 << 28 }).stdout, N = 54 * 96;
const mean = [], diff = []; for (let k = 0; (k + 1) * N <= raw.length; k++) { let m = 0, d = 0; for (let i = 0; i < N; i++) { m += raw[k * N + i]; if (k) d += Math.abs(raw[k * N + i] - raw[(k - 1) * N + i]); } mean.push(m / N); diff.push(d / N); }
const f0 = [...raw.subarray(0, N)], sd = Math.sqrt(f0.reduce((a, x) => a + (x - mean[0]) ** 2, 0) / N);
say(mean[0] > 25 && sd > 12, `frame 0: mean ${mean[0].toFixed(0)}, contrast ${sd.toFixed(0)} (it is the hook: the set, the characters, the banner, the first tag)`);
// the studio frames sit around 40–120 on this scale, the paper office ~170: a flash that washes the frame out goes over 218
const white = mean.map((m, k) => [m, k]).filter(([m]) => m > 218); say(!white.length, `washed-out frames (flash too strong): ${white.length}${white.length ? ' e.g. ' + (white[0][1] / 30).toFixed(2) + ' s' : ''}`);
// a frozen render: over 1 s with almost no change (this format never holds still: dust, push-ins, blinks)
let run0 = 0, frozen = []; diff.forEach((d, k) => { if (d < 0.15) { if (++run0 === 30) frozen.push(((k - 29) / 30).toFixed(2)); } else run0 = 0; });
say(!frozen.length, `stretches over 1 s where nothing moves: ${frozen.length ? 'from ' + frozen.join(', ') + ' s' : 'none'}`);
const cuts = diff.filter((d, k) => { const nb = [diff[k - 2], diff[k - 1], diff[k + 1], diff[k + 2]].filter(x => x != null).sort((a, b) => a - b); return d > 25 && d > 3 * nb[1]; }).length; console.log(`· ${cuts} hard cuts ≈ ${(cuts / dur * 10).toFixed(1)} per 10 s (this format: a new shot every ~1.8 s, ≈ 5 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)

"Never pay for a Claude content skill again" (30.5 s, the reel in `reference.jpg`). It uses every set, most of the props and every overlay piece. The names in it (the narrator, his app, the show, the format list) are its creator's own: use yours. Copy the folder, write your own `script.mjs`, then rewrite `times.mjs`, `WORLD`, the shots and per-shot functions, `BANNERS`, `CHYRONS` and the cues for your words.

`example/video.json`: `{ "take": "d", "music": { "muffle": [[4.7, 5.6]] }, "cover": 3.12 }` (take `a` = the full take, `s1` = a one-line re-take spliced in as `c`, `d` = `c` tightened by `votight`; the muffle clears "agentic super app"; everything else comes from the defaults in `common.mjs`).

### `example/script.mjs`

```js
// Paper · "Never pay for a Claude content skill again", reworked into the grammar of the "Shark Tank in Claude" reference
// (a papercraft diorama story: "Most people don't realize…" → the setup → "Like one… and another…" → the twist → the payoff
// → "Good thing it's free: just comment …"). Casper's message and order are kept: the free format + SKILL.md, Morphy that
// he built, the $30 resellers ("the sharks"), $30 for something free, his response (a free library of .md files inside
// Morphy), no paywalls / subscriptions / strings, the text-file line, this format's name, the CTA.
//
// LINES: what the captions show. SAID: how the voice must say a shown token (the TTS text uses it).
export const LINES = [
  "Most people don't realize the Claude content skill they paid $30 for was free.",
  "I'm Casper, I built Morphy, an agentic super app,",
  'and I gave my own content format and its SKILL.md away, completely free.',
  'Then the sharks moved in.',
  'Like one who took it and repackaged it,',
  'and another who started selling it for $30.',
  '$30, for something I gave away for free.',
  "So here's my response:",
  'an entire library of ready-to-use content-format .md files, inside Morphy.',
  'No paywalls, no subscriptions, no strings attached.',
  "Because you shouldn't pay for a text file that was free to begin with.",
  'This format is called paper.md.',
  "Good thing it's free: just comment PAPER and I'll send it to you.",
];
// shown → said (whole tokens, punctuation kept on the shown side)
export const SAID = {
  'SKILL.md': 'skill dot M D',
  '$30': 'thirty dollars',
  'content-format': 'content format',
  '.md': 'dot M D',
  'paper.md': 'paper dot M D',
  'PAPER': '"Paper"',
};
```

### `example/times.mjs`

```js
// The film's clock, shared by scene.js (picture) and cues.mjs (sound): word times from film.json and the cuts.
// A cut sits just before each sentence (like the reference), so it follows the voice if the take changes.
export function times(film) {
  const LINES = [];
  for (const w of film.words) (LINES[w.line] ||= []).push(w);
  const tok = (li, k = 0) => LINES[li][k];
  const lineT = li => LINES[li][0].t0;
  const say = (cap, n = 0) => { let k = 0; for (const w of film.words) if (w.cap.toLowerCase() === cap.toLowerCase() && k++ === n) return w; throw new Error(`no word "${cap}" #${n}`); };
  const T = (cap, n = 0) => say(cap, n).t0;
  const C = {
    hook: 0, cor: lineT(1) - 0.12, pov: lineT(2) - 0.09, row: lineT(3) - 0.09, s1: lineT(4) - 0.1, gift: T('repackaged') - 0.05,
    s2: lineT(5) - 0.09, reg: T('selling') - 0.06, felt: lineT(6) - 0.11, flip: lineT(7) - 0.09, lib: lineT(8) - 0.1,
    books: T('content-format') - 0.08, brick: lineT(9) - 0.09, fire: lineT(10) - 0.09, office: tok(10, 6).t0 - 0.06,
    paper: lineT(11) - 0.08, cta: lineT(12) - 0.11, end: film.END,
  };
  for (const k in C) C[k] = +C[k].toFixed(3);
  const NAMES = ['hook', 'cor', 'pov', 'row', 's1', 'gift', 's2', 'reg', 'felt', 'flip', 'lib', 'books', 'brick', 'fire', 'office', 'paper', 'cta'];
  return { LINES, tok, lineT, say, T, C, NAMES };
}
```

### `example/scene.js`

```js
// THE PER-VIDEO DIRECTOR FILE — "Never pay for a Claude content skill again" · Paper (papercraft diorama format).
// The reference's grammar, shot for shot: a giant shark chomps the card (hook, lights come on at the reveal) → the walk in →
// the sharks'-eye view of the pitch with a chyron and pop-ups → the row of sharks with bubbles → close-up on one shark and a
// cut-away (the box / the register) → a flat felt diorama (the conveyor) → the card's price flips → the library rises →
// a flat brick-wall diorama with sticky notes → money catches fire → a flat office with the stamp → the format card →
// the wide CTA (signs flip to FREE, the COMMENT bubble). Every time below comes from the voice's word timings.
import { times } from './times.mjs';
// What the world says (engine/world.js WORLD_DEFAULTS has every field): the show's logo, the sign over the entrance, the
// library's books, the sticky notes, the sharks' sign (front / back), the price, the small card, tags and labels.
export const WORLD = {
  logo: ['SKILL', 'SHARKS'], door: 'MORPHY',
  books: [['ink.md', '#2b2b30'], ['voxel.md', '#3b6fd8'], ['clay.md', '#c4683f'], ['sandbox.md', '#d9b25f'], ['dev-claude.md', '#5a3d8f'], ['paper.md', '#e8784a', true]],
  notes: [['NO\nPAYWALLS', '#f4d34a'], ['NO\nSUBSCRIPTIONS', '#f3a3c2'], ['NO\nSTRINGS', '#8ec5f0']],
  signs: ['$30', 'FREE'], price: '$30', card: { title: 'SKILL.md', word: 'FREE' },
  giftTag: 'PREMIUM', shelfSign: 'LIBRARY', appBar: ['Morphy', 'Library'], doc: 'SKILL.md', held: 'paper.md', exit: 'EXIT',
};

export default function setup(E) {
  const { THREE, R, O, P, L, FX, X, film } = E;
  const { clamp, lerp, seg, ss, easeOut, easeIn, easeInOut, easeOutBack, hash, hop, springy } = L;

  // ---------------- word times and cuts (times.mjs: shared with cues.mjs) ----------------
  const { tok, lineT, T, C, NAMES } = times(film);
  const next = n => C[NAMES[NAMES.indexOf(n) + 1]] ?? C.end;

  // ---------------- camera: one shot per beat (P = camera keys, L = look-at keys, which is also the focus) ----------------
  const shot = (n, P, Lk, o = {}) => ({ name: n, t: [C[n], next(n)], P: P.map(([dt, v, e]) => [C[n] + (dt === 'end' ? next(n) - C[n] : dt), v, e]), L: Lk.map(([dt, v, e]) => [C[n] + (dt === 'end' ? next(n) - C[n] : dt), v, e]), fov: o.fov ?? 40, fov1: o.fov1, ap: o.ap ?? 9, sh: o.sh ?? 0.006, fit: 0, frameY: o.fy ?? 0.46, focus: o.focus });
  const CX = X.CORRIDOR_X, FX0 = X.FELT_X, BX = X.BRICK_X, OX = X.OFFICE_X;
  const SHOTS = [
    shot('hook', [[0, [-1.1, 1.55, 8.7]], ['end', [-0.8, 1.7, 7.7]]], [[0, [-0.2, 2.05, 1.2]], ['end', [-0.12, 2.1, 1.2]]], { fov: 50, fov1: 48, ap: 7, fy: 0.5 }),
    shot('cor', [[0, [CX + 0.2, 1.45, 8.7]], ['end', [CX + 0.05, 1.55, 3.7]]], [[0, [CX, 1.0, 3.2]], ['end', [CX, 1.05, -1.8]]], { fov: 46, ap: 6, focus: 5.5, fy: 0.5 }),
    shot('pov', [[0, [-0.25, 1.95, -3.6]], ['end', [-0.05, 1.85, -2.45]]], [[0, [0.2, 1.62, 2.4]], ['end', [0.2, 1.7, 2.4]]], { fov: 48, ap: 13 }),
    shot('row', [[0, [-4.5, 2.55, 4.6]], ['end', [-1.5, 2.45, 4.0]]], [[0, [-3.4, 1.55, -5.0]], ['end', [0.1, 1.55, -5.0]]], { fov: 44, ap: 8, fy: 0.55 }),
    shot('s1', [[0, [-2.6, 1.78, 0.25]], ['end', [-2.7, 1.72, -0.3]]], [[0, [-3.05, 1.45, -4.5]], ['end', [-3.08, 1.47, -4.5]]], { fov: 40, ap: 11, fy: 0.56 }),
    shot('gift', [[0, [3.3, 3.2, 8.1]], ['end', [3.0, 2.95, 7.3]]], [[0, [2.0, 1.2, 3.0]], ['end', [2.0, 1.15, 3.0]]], { fov: 40, ap: 10 }),
    shot('s2', [[0, [1.0, 1.85, 1.9]], ['end', [0.7, 1.72, 1.15]]], [[0, [0.05, 1.48, -4.95]], ['end', [0.03, 1.5, -4.95]]], { fov: 42, ap: 10, fy: 0.53 }),
    shot('reg', [[0, [0.6, 2.75, 8.4]], ['end', [0.3, 2.6, 7.7]]], [[0, [-1.35, 1.55, 3.3]], ['end', [-1.35, 1.6, 3.3]]], { fov: 40, ap: 10, fy: 0.55 }),
    shot('felt', [[0, [FX0 + 0.2, 1.05, 13.4]], ['end', [FX0 - 0.15, 1.05, 12.4]]], [[0, [FX0 + 0.1, 1.05, 0]], ['end', [FX0 - 0.1, 1.05, 0]]], { fov: 36, ap: 1.2, fy: 0.5, sh: 0.002 }),
    shot('flip', [[0, [0.25, 1.4, 8.4]], ['end', [0.35, 1.55, 7.6]]], [[0, [0.45, 1.5, 1.5]], ['end', [0.45, 1.55, 1.5]]], { fov: 44, ap: 8 }),
    shot('lib', [[0, [0.75, 1.4, 7.6]], ['end', [0.5, 1.75, 6.1]]], [[0, [0, 2.3, -3.0]], ['end', [0, 2.35, -3.0]]], { fov: 46, ap: 6 }),
    shot('books', [[0, [-1.5, 2.0, 1.7]], [T('inside') - C.books - 0.12, [1.2, 2.0, 1.5]], ['end', [0.3, 3.0, 6.6], easeOut]], [[0, [-1.3, 2.0, -3.0]], [T('inside') - C.books - 0.12, [1.15, 2.0, -3.0]], ['end', [0, 3.1, -3.0], easeOut]], { fov: 40, fov1: 56, ap: 9 }),
    shot('brick', [[0, [BX + 0.62, 0.8, 15.8]], ['end', [BX + 0.58, 0.8, 14.4]]], [[0, [BX + 0.62, 0.8, 0]], ['end', [BX + 0.58, 0.8, 0]]], { fov: 34, ap: 1.2, fy: 0.5, sh: 0.002 }),
    shot('fire', [[0, [2.2, 1.85, 5.6]], ['end', [1.9, 1.75, 5.1]]], [[0, [0.4, 1.75, 2.1]], ['end', [0.4, 1.8, 2.1]]], { fov: 38, ap: 13, fy: 0.5 }),
    shot('office', [[0, [OX + 0.2, 0.2, 14.6]], ['end', [OX, 0.2, 13.2]]], [[0, [OX + 0.2, 0.2, 0]], ['end', [OX, 0.2, 0]]], { fov: 34, ap: 1.2, fy: 0.5, sh: 0.002 }),
    shot('paper', [[0, [0.15, 1.6, 8.2]], ['end', [0.0, 1.85, 6.9]]], [[0, [0, 1.85, 1.6]], ['end', [0, 1.95, 1.6]]], { fov: 44, ap: 9, fy: 0.5 }),
    shot('cta', [[0, [0, 2.75, 7.4]], ['end', [0, 2.55, 6.0]]], [[0, [0, 1.85, -5.0]], ['end', [0, 1.85, -5.0]]], { fov: 52, ap: 5, fy: 0.5 }),
  ];
  const IMPULSE = [[T('free') + 0.05, 0.035], [T('paper.md') + 0.02, 0.02]];

  // ---------------- banners, chyrons, bubbles ----------------
  const BANNERS = [[C.hook, 'NEVER PAY FOR A SKILL AGAIN'], [C.cor, "I'M CASPER. I BUILT MORPHY"], [C.pov, 'I GAVE IT AWAY FOR FREE'], [C.row, 'THEN THE SHARKS MOVED IN'],
    [C.s1, 'ONE REPACKAGED IT'], [C.s2, 'ANOTHER SELLS IT FOR $30'], [C.felt, '$30 FOR SOMETHING FREE'], [C.flip, "SO HERE'S MY RESPONSE"],
    [C.lib, 'A FREE LIBRARY, INSIDE MORPHY'], [C.brick, 'NO STRINGS ATTACHED'], [C.fire, 'IT WAS FREE TO BEGIN WITH'], [C.paper, 'THIS FORMAT IS PAPER.MD'], [C.cta, "IT'S FREE: COMMENT PAPER"]];
  const CHYRONS = [
    { at: T('built') - 0.04, out: C.pov - 0.1, lines: ['CASPER:', 'BUILT MORPHY, AN', 'AGENTIC SUPER APP'], seed: 3 },
    { at: T('away') - 0.04, out: C.row - 0.1, lines: ['MY OFFER:', 'FORMAT + SKILL.md FOR $0'], seed: 5 },
    { at: T('started') - 0.04, out: C.felt - 0.1, lines: ['HIS OFFER:', 'MY FREE FILE FOR $30'], seed: 7 },
    { at: T('ready-to-use') - 0.04, out: C.brick - 0.1, lines: ['THE LIBRARY:', 'EVERY FORMAT FOR $0'], seed: 9 },
    { at: tok(10, 8).t0 - 0.04, out: C.paper - 0.1, lines: ['THE REAL PRICE:', '$0 FOR A TEXT FILE'], seed: 11 },
  ];
  const AV = Object.fromEntries(['hoodie', 'suit', 'pearls', 'blonde', 'cap', 'me'].map(k => [k, P.avatarCanvas(k)]));
  const SH_AV = ['cap', 'hoodie', 'suit', 'pearls', 'blonde'];
  const STAR = P.starCanvas();

  // ---------------- props made here ----------------
  const studio = R.sets.studio;
  const tag30 = new THREE.Mesh(new THREE.PlaneGeometry(0.95, 0.56), R.mat(0xffffff, { map: R.fromCanvas('t30', P.signCanvas('$30', '#d93b2b')), side: THREE.DoubleSide }));
  tag30.castShadow = true; studio.add(tag30);
  const regTable = R.box(2.4, 1.0, 1.7, R.mat(0x3a2a24)); studio.add(regTable);
  const giftTable = R.box(2.2, 0.5, 1.8, R.mat(0x3a2a24)); studio.add(giftTable);
  const v3 = new THREE.Vector3();
  const chairHome = R.chairs.map(c => c.position.clone());
  const defaults = () => {
    for (const p of [R.big.root, R.easel, R.critter.root, R.phone, R.skillDoc, R.gift, R.register, R.money, R.fire, R.shelf, R.appFrame, R.paperDoc, R.fgPaws, R.miniCard, R.suitPaw, tag30, regTable, giftTable, ...R.flyBills, ...R.scraps]) p.visible = false;
    R.signs.forEach(s => { s.g.visible = false; s.card.rotation.y = 0; s.g.position.y = 1.2; });
    R.chairs.forEach((c, i) => c.position.copy(chairHome[i]));
    R.sharks.forEach(s => { s.pivot.rotation.set(0, 0, 0); s.pivot.position.set(0, 0, 0); s.arms.forEach(a => a.rotation.set(0, 0, 0)); s.eyes.forEach(e => e.scale.y = 1); });
    R.critter.root.scale.setScalar(1); R.critter.pivot.position.set(0, 0, 0); R.critter.pivot.rotation.set(0, 0, 0); R.critter.hands.forEach(h => h.rotation.set(0, 0, 0)); R.critter.legs.forEach(l => l.rotation.set(0, 0, 0));
    R.card.rotation.set(-0.08, 0, 0); R.card.scale.set(1, 1, 1); R.entranceLight.intensity = 0; R.easel.rotation.set(0, 0, 0);
  };
  // blinks: deterministic, ~every 2.5 s
  const blink = (t, seed) => { const ph = (t + hash(seed) * 3) % 2.6; return ph < 0.09 ? 0.15 : 1; };
  const walk = (c, t, speed = 9) => { c.legs.forEach((l, i) => l.rotation.x = Math.sin(t * speed + (i % 2) * Math.PI) * 0.55); c.pivot.position.y = Math.abs(Math.sin(t * speed)) * 0.04; };
  const card = (key, o) => R.cardTex.set(key, g => g.drawImage(P.cardCanvas(o), 0, 0));
  // scraps burst from a point at time tb (paper bits flying out and falling)
  const scraps = (t, tb, at, k0 = 0, n = 6) => { for (let i = 0; i < n; i++) { const s = R.scraps[(k0 + i) % R.scraps.length], u = t - tb; if (u < 0 || u > 1.2) continue;
    const a = hash(i + k0 * 7) * Math.PI * 2, sp = 1.4 + hash(i * 3 + k0) * 1.6; s.visible = true;
    s.position.set(at[0] + Math.cos(a) * sp * u * 0.7, at[1] + (1.6 + hash(i + 11) * 1.4) * u - 4.9 * u * u, at[2] + Math.sin(a) * sp * u * 0.5 + 0.25);
    s.rotation.set(u * (6 + i), u * (4 + i * 2), u * 3); } };
  // keep a light flicker on the dust and a slow drift
  const camDir = new THREE.Vector3(), camRight = new THREE.Vector3(), camUp = new THREE.Vector3();
  function dust(t, on = true) {
    R.dust.visible = on; if (!on) return; const cam = E.camera; cam.updateMatrixWorld();
    camRight.setFromMatrixColumn(cam.matrixWorld, 0); camUp.setFromMatrixColumn(cam.matrixWorld, 1); camDir.setFromMatrixColumn(cam.matrixWorld, 2).negate();
    const th = Math.tan(cam.fov * Math.PI / 360);
    R.dust.children.forEach(d => { const u = d.userData, dist = 0.9 + u.z * 5.5, half = dist * th;
      const x = (u.x - 0.5) * 2 * half * 0.56 + Math.sin(t * 0.35 + u.p) * 0.06, y = (u.y - 0.5) * 2 * half + Math.sin(t * 0.23 + u.p * 2) * 0.06 + (((t * 0.04 + u.p) % 1) - 0.5) * 0.15;
      d.position.copy(cam.position).addScaledVector(camDir, dist).addScaledVector(camRight, x).addScaledVector(camUp, y); });
  }

  // ---------------- the world, shot by shot ----------------
  let cur = 'hook';
  function world(t, si) {
    const name = SHOTS[si]?.name || 'hook'; cur = name; defaults();
    const S = WORLDS[name]; S(t); dust(t, !['felt', 'brick', 'office'].includes(name));
  }
  const tf = T('free');                     // "…was free." (the reveal)
  const WORLDS = {
    hook(t) {
      R.show('studio');
      // lights come on window by window at the reveal
      const lit = ss(t, tf - 0.02, tf + 0.3);
      E.look(t, { set: 'studio', lit, key: 1.15, keyPos: [-5, 10, 9] });
      R.winMats.forEach((m, i) => m.uniforms.k.value = ss(t, tf - 0.05 + i * 0.045, tf + 0.1 + i * 0.045));
      R.easel.visible = true; R.easel.position.set(-1.0, 0, 1.3); R.easel.rotation.y = 0.25;
      const bites = [[0.18, [0.985, 0.3, 0.085]], [0.545, [0.99, 0.56, 0.075]], [0.909, [0.975, 0.8, 0.08]]].filter(b => t >= b[0]);
      const freeOn = t >= tf + 0.07;
      card('hook' + bites.length + (freeOn ? 'F' : ''), { brand: 'CLAUDE CONTENT SKILL', brandIcon: '✳', title: 'SKILL.md', titleSize: 120, price: freeOn ? 'FREE' : ' ', priceCol: '#2f9e57', bite: bites.map(b => b[1]) });
      // the giant shark gnaws the card's edge, eats the money, then rips the $30 tag off
      const B = R.big; B.root.visible = true; B.root.position.set(0.82, 0, 0.85); B.root.rotation.y = -0.95; B.root.scale.setScalar(0.82);
      const chomp = (a, b, f = 5.5) => t >= a && t < b ? Math.max(0, Math.sin((t - a) * f * Math.PI * 2)) : 0;
      const jaw = 0.05 + 0.42 * Math.max(chomp(0, 0.95), chomp(T('paid') - 0.02, T('paid') + 0.55, 6)) + 0.5 * (ss(t, tf - 0.12, tf - 0.02) - ss(t, tf + 0.02, tf + 0.08));
      B.jaw.rotation.x = jaw; B.head.rotation.x = -0.05 * jaw; B.head.position.z = 0.06 * Math.sin(t * 11) * (t < 0.95 ? 1 : 0);
      B.pivot.position.y = Math.abs(Math.sin(t * 5)) * 0.03; B.sign.rotation.z = Math.sin(t * 3.1) * 0.12;
      B.eyes.forEach(e => e.scale.y = blink(t, 3));
      [0.18, 0.545, 0.909].forEach((tb, i) => scraps(t, tb, [0.05, 1.6 + i * 0.25, 1.35], i * 4, 4));
      // the money flies into its mouth on "paid $30"
      const mouth = [0.18, 1.68, 1.3];
      R.flyBills.forEach((b, i) => { const a = T('paid') - 0.08 + i * 0.075, u = seg(t, a, a + 0.42); if (u <= 0 || u >= 1) return; b.visible = true; const e = easeIn(u);
        b.position.set(lerp(-2.6 + i * 0.25, mouth[0], e), lerp(0.4 + i * 0.12, mouth[1], e) + Math.sin(u * Math.PI) * 0.6, lerp(4.4, mouth[2], e)); b.rotation.set(u * 6 + i, u * 4, u * 9 + i); b.scale.setScalar(1 - 0.4 * u); });
      // the red $30 tag: slaps on with "$30", ripped away in the jaws at "free"
      const tOn = T('$30'), up = seg(t, tf + 0.0, tf + 0.3);
      if (t >= tOn - 0.03 && up < 1) { tag30.visible = true; R.card.updateWorldMatrix(true, false); v3.set(0.38, -0.28, 0.03); R.card.localToWorld(v3);
        const k = seg(t, tOn - 0.03, tOn + 0.1), sc = 1 + 0.5 * (1 - easeOut(k));
        tag30.position.set(lerp(v3.x, mouth[0] + 0.1, easeIn(up)), lerp(v3.y, mouth[1] + 0.5, easeIn(up)) + up * 0.8, lerp(v3.z, mouth[2] + 0.2, up));
        tag30.rotation.set(-0.08 + up * 1.5, 0.25 + up * 0.8, -0.12 + up * 2.5); tag30.scale.setScalar(sc * (1 - 0.3 * up)); }
      scraps(t, tf + 0.02, [-0.4, 1.4, 1.5], 12, 6);
    },
    cor(t) {
      R.show('corridor'); E.look(t, { set: 'corridor' });
      const c = R.critter2, u = seg(t, C.cor, C.pov);
      c.root.position.set(0, 0, lerp(3.2, -1.8, u)); c.root.rotation.y = Math.PI; c.root.scale.setScalar(1.4); walk(c, t, 10);
      c.hands.forEach((h, i) => h.rotation.x = -1.2); R.carry.visible = true;
      const o = easeOut(seg(t, C.cor + 0.05, C.cor + 0.6)); R.doors[0].rotation.y = -1.45 * o; R.doors[1].rotation.y = 1.45 * o;
      R.doorSignM.emissiveIntensity = 0.12 + 0.9 * ss(t, T('Morphy') - 0.05, T('Morphy') + 0.15) * (0.92 + 0.08 * Math.sin(t * 40));
    },
    pov(t) {
      R.show('studio'); E.look(t, { set: 'studio', lit: 0.15, spot: 0.85, key: 0.75 });
      R.spot.position.set(0.4, 9, 0.5); R.spot.target.position.set(0.6, 0, 2.6); R.entranceLight.intensity = 26;
      R.easel.visible = true; R.easel.position.set(0.3, 0, 2.55); R.easel.rotation.y = Math.PI;
      const st = t >= T('free', 1) ? 'S' : '';
      card('pov' + st, { brand: 'MY FORMAT', brandIcon: '✳', title: 'FORMAT + SKILL.md', titleSize: 92, price: '$0', priceCol: '#2f9e57', stamp: st ? 'FREE' : null, stampCol: '#2f9e57' });
      if (st) { const k = seg(t, T('free', 1), T('free', 1) + 0.12); R.card.scale.setScalar(1 + 0.06 * (1 - easeOut(k))); }
      // the narrator beside the easel
      const c = R.critter; c.root.visible = true; c.root.position.set(-1.05, 0, 2.05); c.root.rotation.y = Math.PI - 0.35; c.root.scale.setScalar(1.3);
      const g1 = seg(t, T('gave') - 0.05, T('gave') + 0.25); c.hands.forEach((h, i) => h.rotation.z = (i ? -1 : 1) * 0.9 * Math.sin(g1 * Math.PI));
      const h1 = hop(t, T('free', 1) - 0.05, 0.3, 0.25); c.pivot.position.y = h1.y; c.eyes.forEach(e => e.scale.y = blink(t, 5));
      // the format (phone) and its SKILL.md pop up behind the card
      const pk = easeOutBack(seg(t, T('content', 1) - 0.05, T('content', 1) + 0.28), 1.8), dk = easeOutBack(seg(t, T('SKILL.md') - 0.05, T('SKILL.md') + 0.28), 1.8);
      if (pk > 0) { R.phone.visible = true; R.phone.position.set(-0.15, lerp(1.4, 3.15, pk), 2.68); R.phone.rotation.set(0, Math.PI + 0.12, -0.06); }
      if (dk > 0) { R.skillDoc.visible = true; R.skillDoc.position.set(0.95, lerp(1.4, 3.05, dk), 2.7); R.skillDoc.rotation.set(0, Math.PI - 0.1, 0.1); }
      // out-of-focus shark paws in the foreground (we sit with the sharks)
      R.fgPaws.visible = true; R.fgPaws.position.set(-0.05, 1.1, -2.15 + (t - C.pov) * 0.3); R.fgPaws.rotation.set(0.15, 0, 0);
    },
    row(t) {
      R.show('studio'); E.look(t, { set: 'studio', lit: 1 });
      const mv = T('moved');
      R.chairs.forEach((c, i) => { const k = easeOutBack(seg(t, mv - 0.05 + i * 0.04, mv + 0.3 + i * 0.04), 1.6); c.position.z = chairHome[i].z + 0.55 * k; });
      R.sharks.forEach((s, i) => { s.pivot.rotation.x = 0.18 * ss(t, mv - 0.05 + i * 0.04, mv + 0.25 + i * 0.04); s.eyes.forEach(e => e.scale.y = blink(t, i + 1)); });
    },
    s1(t) {
      R.show('studio'); E.look(t, { set: 'studio', lit: 1 });
      const s = R.sharks[1]; s.eyes.forEach(e => e.scale.y = blink(t, 2)); s.pivot.rotation.x = 0.06;
      // "took it": its arm shoots up holding the free card
      const tk = T('took'), a = easeOutBack(seg(t, tk - 0.06, tk + 0.18), 1.6);
      s.arms[1].rotation.x = -1.9 * a; s.arms[1].rotation.z = 0.25 * a;
      if (t >= tk - 0.1) { R.miniCard.visible = true; s.arms[1].updateWorldMatrix(true, false); v3.set(0, -0.5, 0.12); s.arms[1].localToWorld(v3);
        const fly = 1 - easeOut(seg(t, tk - 0.1, tk + 0.12)); R.miniCard.position.set(v3.x + fly * 0.6, v3.y + 0.18 + fly * 0.2, v3.z + 0.12 + fly * 1.8); R.miniCard.rotation.set(-0.2, 0.16 + fly, 0.12 - fly * 0.6); }
    },
    gift(t) {
      R.show('studio'); E.look(t, { set: 'studio', lit: 1, key: 1.1 });
      giftTable.visible = true; giftTable.position.set(2.0, 0.25, 3.0);
      R.gift.visible = true; R.gift.position.set(2.0, 0.5, 3.0); R.gift.rotation.y = -0.35;
      const rp = T('repackaged'), drop = seg(t, rp, rp + 0.2), close = seg(t, rp + 0.2, rp + 0.36), bow = seg(t, rp + 0.38, rp + 0.6);
      R.giftLid.rotation.x = -1.9 * (1 - easeIn(close)) + (close >= 1 ? 0.05 * Math.sin(seg(t, rp + 0.36, rp + 0.6) * Math.PI * 3) * (1 - seg(t, rp + 0.36, rp + 0.6)) : 0);
      R.bow.scale.setScalar(Math.max(0.001, easeOutBack(bow, 2.6))); R.giftTag.visible = t >= rp + 0.5; R.giftTag.rotation.z = -0.25 + 0.3 * Math.sin(seg(t, rp + 0.5, rp + 1.2) * 9) * (1 - seg(t, rp + 0.5, rp + 1.2));
      if (drop < 1) { R.miniCard.visible = true; R.miniCard.position.set(2.0, lerp(2.7, 1.0, easeIn(drop)), 3.0); R.miniCard.rotation.set(-1.2 + drop * 0.4, -0.35, 0.3 - drop * 0.3); }
    },
    s2(t) {
      R.show('studio'); E.look(t, { set: 'studio', lit: 1 });
      const s = R.sharks[2]; s.eyes.forEach(e => e.scale.y = blink(t, 7)); s.pivot.rotation.x = 0.05 + 0.05 * ss(t, T('started') - 0.1, T('started') + 0.2);
      const a = easeOutBack(seg(t, T('another') - 0.05, T('another') + 0.25), 1.5); s.arms[0].rotation.z = -0.9 * a; s.arms[0].rotation.x = -0.6 * a;
    },
    reg(t) {
      R.show('studio'); E.look(t, { set: 'studio', lit: 1, key: 1.1 });
      regTable.visible = true; regTable.position.set(-1.35, 0.5, 3.25);
      R.register.visible = true; R.register.position.set(-1.35, 1.0, 3.25); R.register.rotation.y = 0.32;
      const t30 = T('$30', 1), n = t < t30 ? 0 : Math.min(3, 1 + Math.floor((t - t30) / 0.05));
      R.ledTex.set('led' + n, g => g.drawImage(P.ledCanvas(`$${n * 10}.00`), 0, 0));
      R.drawer.position.z = 0.06 + 0.55 * easeOutBack(seg(t, t30 + 0.12, t30 + 0.3), 2);
      R.gift.visible = true; R.gift.position.set(0.35, 0, 3.6); R.gift.rotation.y = -0.6; R.giftLid.rotation.x = 0; R.bow.scale.setScalar(1); R.giftTag.visible = true;
    },
    felt(t) {
      R.show('felt'); E.look(t, { set: 'felt' });
      // cards advance one slot per stamp, the press hits on the beat, stamped cards drop into the piggy bank
      const hit0 = T('$30', 2), per = 0.5;
      const cyc = (t - hit0 + 0.25) / per, k = Math.floor(cyc), f = cyc - k;
      const step = k + easeInOut(clamp(f / 0.45));
      R.feltCards.forEach((c, i) => { const slot = i - 2 + step; const x = -3.8 + slot * 1.9; c.g.visible = x > -4.8 && x < 2.9;
        const stamped = x > -0.2 + 0.001 || (Math.abs(x) < 0.05 && f > 0.6);
        c.paid.visible = stamped; c.free.visible = !stamped;
        const dropK = seg(x, 1.9, 2.8); c.g.position.set(x, 0.18 - dropK * 1.0, 0.25 + dropK * 0.02); c.g.rotation.z = -dropK * 0.5; c.g.scale.setScalar(1 - dropK * 0.6); });
      const press = f > 0.45 ? Math.sin(clamp((f - 0.45) / 0.45) * Math.PI) : 0;
      R.pressHead.position.y = -1.55 * Math.min(1, press * 1.4);
      R.lever.rotation.z = -0.6 * press;
      R.marqueeTex.set('mq' + (Math.floor(t * 6) % 2), g => { g.fillStyle = '#141414'; g.fillRect(0, 0, 768, 300); g.strokeStyle = '#c9a24a'; g.lineWidth = 10; g.strokeRect(10, 10, 748, 280);
        for (let b = 0; b < 22; b++) { const on = (b + Math.floor(t * 6)) % 2; const x = 30 + (b % 11) * 70.8, y = b < 11 ? 32 : 268; g.fillStyle = on ? '#ffe9a0' : '#7a5a20'; g.beginPath(); g.arc(x, y, 11, 0, 7); g.fill(); }
        g.fillStyle = '#ffd34d'; g.font = '128px "Inter Tight 800"'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText('$30 EACH', 384, 156); });
      R.flatSeller.position.set(-1.4, -1.3 + 0.06 * Math.abs(Math.sin(t * 6)), 0.4);
      R.flatMe.position.set(-2.2, -1.35 + hop(t, T('free', 2) - 0.05, 0.3, 0.25).y, 0.42);
    },
    flip(t) {
      R.show('studio'); E.look(t, { set: 'studio', lit: 0.2, spot: 1, key: 0.7 });
      R.spot.position.set(0.2, 9, 3.2); R.spot.target.position.set(0.4, 0, 1.6);
      R.easel.visible = true; R.easel.position.set(0, 0, 1.5);
      const tr = T('response'), k = seg(t, tr - 0.02, tr + 0.18), flipped = k >= 0.5;
      card(flipped ? 'flipB' : 'flipA', { brand: 'MY FORMAT', brandIcon: '✳', title: 'SKILL.md', titleSize: 110, price: flipped ? '$0' : '$30', priceCol: flipped ? '#2f9e57' : '#d23a2a' });
      R.card.scale.y = Math.max(0.02, Math.abs(Math.cos(k * Math.PI))); R.card.rotation.x = -0.08;
      const c = R.critter; c.root.visible = true; c.root.position.set(1.45, 0, 1.75); c.root.rotation.y = -0.4; c.root.scale.setScalar(1.3);
      c.pivot.position.y = hop(t, tr + 0.05, 0.32, 0.3).y; c.eyes.forEach(e => e.scale.y = blink(t, 9));
      c.hands.forEach((h, i) => h.rotation.z = (i ? -1 : 1) * 0.8 * ss(t, tr, tr + 0.2));
    },
    lib(t) {
      R.show('studio'); E.look(t, { set: 'studio', lit: 0.65, spot: 0.5 });
      R.spot.position.set(0, 9, 2.5); R.spot.target.position.set(0, 0, 0);
      R.shelf.visible = true; R.shelf.position.set(0, 0, -3.0);
      const up = easeOutBack(seg(t, T('entire') - 0.05, T('entire') + 0.32), 1.7); R.shelf.scale.set(1, Math.max(0.02, up), 1);
      const t0 = T('library'), t1 = T('content-format') - 0.1;
      R.books.forEach((b, i) => { const k = easeOutBack(seg(t, t0 + (t1 - t0) * i / R.books.length, t0 + (t1 - t0) * i / R.books.length + 0.2), 2); b.g.visible = k > 0.01; b.g.scale.setScalar(Math.max(0.01, k)); b.g.position.z = 0.05 + (1 - k) * 0.6; });
      const c = R.critter; c.root.visible = true; c.root.position.set(1.3, 0, -0.6); c.root.rotation.y = 2.6; c.root.scale.setScalar(1.25); c.pivot.rotation.x = -0.15; c.eyes.forEach(e => e.scale.y = blink(t, 11));
    },
    books(t) {
      R.show('studio'); E.look(t, { set: 'studio', lit: 0.65, spot: 0.5 });
      R.spot.position.set(0, 9, 2.5); R.spot.target.position.set(0, 0, 0);
      R.shelf.visible = true; R.shelf.position.set(0, 0, -3.0); R.shelf.scale.set(1, 1, 1);
      R.books.forEach(b => { b.g.visible = true; b.g.scale.setScalar(1); b.g.position.z = 0.05; });
      const nb = R.books.find(b => b.isNew); nb.g.position.z = 0.05 + 0.45 * easeOutBack(seg(t, T('files') - 0.05, T('files') + 0.25), 2);
      const ins = T('inside'); R.appFrame.visible = t >= ins - 0.05; const fk = easeOutBack(seg(t, ins - 0.05, ins + 0.25), 1.6); R.appFrame.position.set(0, 0, -2.85); R.appFrame.scale.setScalar(0.85 + 0.15 * fk);
      const c = R.critter; c.root.visible = true; c.root.position.set(1.3, 0, -0.6); c.root.rotation.y = 2.6; c.root.scale.setScalar(1.25); c.eyes.forEach(e => e.scale.y = blink(t, 11));
      c.pivot.position.y = hop(t, T('Morphy', 1) - 0.05, 0.3, 0.25).y;
    },
    brick(t) {
      R.show('brick'); E.look(t, { set: 'brick' });
      R.wallSignTex.set('ws', g => g.drawImage(P.cardCanvas({ brand: 'MORPHY', brandIcon: '✳', title: 'LIBRARY.md', titleSize: 110, price: '$0', priceCol: '#2f9e57' }), 0, 0));
      const hits = [T('paywalls'), T('subscriptions'), T('strings')], spots = [[-0.95, 2.55, 0.06, -0.1], [1.05, 2.6, 0.07, 0.12], [0.05, 1.3, 0.08, -0.05]];
      R.notes.forEach((n, i) => { const k = seg(t, hits[i] - 0.1, hits[i] + 0.02); n.visible = k > 0; if (!n.visible) return; const [x, y, z, r] = spots[i];
        const land = easeIn(k), sq = seg(t, hits[i] + 0.02, hits[i] + 0.16);
        n.position.set(x + (1 - land) * (i - 1) * 1.2, y + (1 - land) * 1.5, z + (1 - land) * 3); n.rotation.z = r + (1 - land) * 0.6;
        const s = k < 1 ? 1.15 : 1 + 0.12 * Math.sin(sq * Math.PI) * (1 - sq); n.scale.set(s, k < 1 ? s : 2 - s, 1); });
      R.flatMe2.position.y = -1.25 + Math.max(...hits.map(h => hop(t, h + 0.02, 0.26, 0.18).y));
      const sn = T('attached'), sk = seg(t, sn - 0.3, sn - 0.05);
      R.scissors.position.set(lerp(3.6, 2.55, easeOut(sk)), -0.35, 0.4); R.scissors.rotation.z = 0.2 * Math.sin(seg(t, sn - 0.05, sn + 0.15) * Math.PI * 2);
      const fall = Math.max(0, t - sn - 0.02); R.stringLow.position.y = -1.3 - 4.9 * fall * fall; R.stringLow.rotation.z = fall * 1.2;
    },
    fire(t) {
      const ig = T("shouldn't") + 0.05; // it catches fire on "shouldn't", full blaze on "pay"
      R.show('studio'); E.look(t, { set: 'studio', lit: 0.35, key: 0.55, fire: 0.6 * ss(t, ig - 0.05, T('pay') + 0.1) * (0.85 + 0.15 * Math.sin(t * 31)) });
      R.suitPaw.visible = true; R.suitPaw.position.set(0.55, 1.15, 2.0); R.suitPaw.rotation.set(0.1, -0.5, 0.05);
      R.money.visible = true; R.money.position.set(0.42, 1.27, 2.1); R.money.rotation.set(0.05, -0.5, 0.05);
      const fk = easeOutBack(seg(t, ig - 0.05, T('pay') + 0.1), 1.8);
      R.fire.visible = fk > 0.01; R.fire.position.set(0.42, 1.38, 2.1);
      R.flames.forEach((f, i) => { f.lookAt(E.camera.position); f.scale.set(fk * (0.9 + 0.12 * Math.sin(t * 17 + i * 2)), fk * (1 + 0.2 * Math.sin(t * 23 + i * 3)), 1); });
      R.moneyTag.visible = t < ig + 0.25;
    },
    office(t) {
      R.show('office'); E.look(t, { set: 'office' });
      const st = t >= T('free', 3) + 0.02;
      R.docTex.set(st ? 'docS' : 'doc', g => g.drawImage(P.docCanvas('SKILL.md', { stamp: st ? 'FREE' : null }), 0, 0));
      const tin = easeOutBack(seg(t, T('text') - 0.12, T('text') + 0.2), 1.4), sk = seg(t, T('free', 3) + 0.02, T('free', 3) + 0.14);
      R.officeDoc.position.set(lerp(-4.5, 0, tin), -0.05, 0.45); R.officeDoc.rotation.z = (1 - tin) * 0.5 - 0.04; R.officeDoc.scale.setScalar(1 + 0.08 * Math.sin(sk * Math.PI));
      R.check.position.set(1.3, 1.95, 0.3); R.check.visible = t >= T('free', 3) + 0.08; R.check.scale.setScalar(Math.max(0.01, easeOutBack(seg(t, T('free', 3) + 0.08, T('free', 3) + 0.3), 2.2)));
      R.people.forEach((p, i) => { p.position.y = -0.1 + hop(t, T('free', 3) + 0.05 + i * 0.07, 0.3, 0.22).y; });
      R.flatMe3.visible = false;
    },
    paper(t) {
      R.show('studio'); E.look(t, { set: 'studio', lit: 0.55, spot: 1, key: 0.8 });
      R.spot.position.set(0, 9, 3.4); R.spot.target.position.set(0, 0, 1.6);
      const c = R.critter; c.root.visible = true; c.root.position.set(0, 0, 1.6); c.root.rotation.y = 0.15; c.root.scale.setScalar(1.55); c.eyes.forEach(e => e.scale.y = blink(t, 13));
      const lift = easeOutBack(seg(t, T('called') - 0.1, T('paper.md') + 0.05), 1.6);
      c.hands.forEach((h, i) => { h.rotation.z = (i ? -1 : 1) * 2.4 * lift; });
      c.pivot.position.y = hop(t, T('paper.md') - 0.02, 0.3, 0.2).y;
      R.paperDoc.visible = lift > 0.02; R.paperDoc.position.set(0, lerp(1.2, 2.22, lift) + c.pivot.position.y * 1.5, 1.95); R.paperDoc.rotation.set(0, 0.1, Math.sin(t * 2.2) * 0.04); R.paperDoc.scale.setScalar(Math.max(0.05, lift) * 0.85);
    },
    cta(t) {
      R.show('studio'); E.look(t, { set: 'studio', lit: 1 });
      // the three middle sharks sit closer together for the wide shot (the outer two leave the frame)
      [1, 2, 3].forEach(i => R.chairs[i].position.set((i - 2) * 2.45, 0, -5.0 + Math.abs(i - 2) * 0.3));
      const fr = T('free', 4);
      R.signs.forEach((s, i) => { s.g.visible = true; s.g.position.y = 1.2 + 0.25 * easeOutBack(seg(t, C.cta + i * 0.05, C.cta + 0.3 + i * 0.05), 2);
        const k = seg(t, fr - 0.04 + i * 0.05, fr + 0.18 + i * 0.05); s.card.rotation.y = Math.PI * easeInOut(k); s.card.position.y = 1.35 + 0.15 * Math.sin(k * Math.PI); });
      R.sharks.forEach((s, i) => { s.eyes.forEach(e => e.scale.y = blink(t, 20 + i)); s.pivot.position.y = hop(t, fr + 0.06 + i * 0.05, 0.3, 0.12).y; });
    },
  };

  // ---------------- 2D layer ----------------
  const stepAt = (list, t) => { let v = null, n = -1; list.forEach((x, i) => { if (t >= x[0]) { v = x; n = i; } }); return v ? { text: v[1], at: v[0], n } : null; };
  function overlay(g, t) {
    // bubbles over characters (drawn avatars, never real faces)
    const bub = (obj, off, st) => { const p = E.project(obj, off); if (!p.behind) O.bubble(g, p.x, p.y, st, t); };
    if (cur === 'cor') bub(R.critter2.cap, [0, 0.35, 0], { at: T('Casper') - 0.05, img: AV.me, r: 78, seed: 1 });
    if (cur === 'row') R.sharks.forEach((s, i) => bub(s.top, [0, 0.4, 0], { at: T('sharks') - 0.06 + i * 0.07, img: AV[SH_AV[i]], r: 74, seed: i, tail: i < 2 ? 'downR' : 'downL' }));
    if (cur === 's1') bub(R.sharks[1].top, [0.1, 0.42, 0], { at: T('one') - 0.06, img: AV.hoodie, r: 92, seed: 2 });
    if (cur === 's2') bub(R.sharks[2].top, [0.1, 0.4, 0], { at: T('another') - 0.06, img: AV.suit, r: 92, seed: 3 });
    if (cur === 'felt') bub(R.flatSeller, [0.2, 0.8, 0], { at: C.felt + 0.2, img: AV.suit, r: 74, seed: 4, tail: 'downL' });
    if (cur === 'cta') [1, 2, 3].forEach((i, j) => bub(R.sharks[i].top, [0, 0.42, 0], { at: C.cta + 0.15 + j * 0.08, img: AV[SH_AV[i]], r: 62, seed: i }));
    // sparkles (2D stars) on stamps and pops
    const sparkle = (obj, off, t0, n = 5, rad = 90) => { if (t < t0 || t > t0 + 0.6) return; const p = E.project(obj, off), k = seg(t, t0, t0 + 0.6);
      for (let i = 0; i < n; i++) { const a = i / n * Math.PI * 2 + 0.3, r = rad * easeOut(k), s = 46 * Math.sin(k * Math.PI); g.save(); g.globalAlpha = 1 - k * 0.6; g.translate(p.x + Math.cos(a) * r, p.y + Math.sin(a) * r); g.rotate(k * 2); g.drawImage(STAR, -s / 2, -s / 2, s, s); g.restore(); } };
    if (cur === 'reg') sparkle(R.register, [0, 1.75, -0.3], T('$30', 1) + 0.1, 5, 110);
    if (cur === 'fire') sparkle(R.money, [0, 0.4, 0], T('you') - 0.02, 7, 150);
    if (cur === 'paper') sparkle(R.paperDoc, [0, 0, 0], T('paper.md'), 6, 170);
    if (cur === 'office') sparkle(R.officeDoc, [0, 0.4, 0], T('free', 3) + 0.03, 6, 140);
    // banner, chyron, captions, CTA
    O.banner(g, stepAt(BANNERS, t), t);
    for (const ch of CHYRONS) O.chyron(g, ch, t);
    if (cur === 'cta') { O.commentBubble(g, t, { at: T('comment') - 0.04, word: 'PAPER', y: 1000 }, STAR); O.plane(g, t, T('send') - 0.04, 640, 960); }
    O.captions(g, film, t);
  }

  // ---------------- post: whips into the cut-aways, a warm flash at the reveal ----------------
  const WHIPS = [[C.gift, 0.028, 0], [C.reg, -0.026, 0], [C.felt, 0, 0.022], [C.office, 0.034, 0], [C.brick, 0.02, 0]];
  function effects(t) {
    const fl = FX.flash(t, [[tf + 0.03, 0.14, [1, 0.8, 0.55]], [T('pay') + 0.02, 0.08, [1, 0.7, 0.4]]]);
    const zoom = FX.punchIn(t, C.lib, 0.12, 0.05) + FX.punchOut(t, C.lib, 0.18, 0.05) + FX.punchIn(t, C.cta, 0.12, 0.04) + FX.punchOut(t, C.cta, 0.18, 0.04);
    FX.apply(E, t, { whip: FX.whips(t, WHIPS, 0.09), flash: fl, zoom });
  }
  window.CUTS = C;
  return { SHOTS, IMPULSE, world, overlay, effects };
}
```

### `example/cues.mjs`

```js
// Sound effects of "Never pay for a Claude content skill again · Paper", on the same clock as the picture (times.mjs).
// ev(name, t, dB, maxDur?) starts the sound file at t; each sound's hit sits a little into its file (its "onset"),
// so a cue starts at (picture event − onset). Sounds: the kit in sfx/ (made once by gen-sfx.mjs; a video's own sfx/ wins).
import { times } from './times.mjs';
const ON = { chomp: 0.07, rip: 0.01, slap: 0.12, stamp: 0.03, whoosh: 0.1, swish: 0.11, swoosh: 0.14, thud: 0.07, flick: 0.08, burn: 0.3, click: 0.13 };
export default function cues(ev0, film) {
  const { T, C, tok } = times(film);
  const G = -4; // every effect sits a little under the voice (measured: STEM=sfx ≈ 7 LU under STEM=vo)
  const ev = (n, t, db, dur) => ev0(n, Math.max(0, +(t - (ON[n] || 0)).toFixed(3)), db + G, dur);
  const tf = T('free');
  // ---- cuts: a whoosh into most shots (whips into the cut-aways are louder) ----
  const soft = ['cor', 'pov', 'row', 's1', 's2', 'flip', 'books', 'fire', 'paper'], hard = ['gift', 'reg', 'felt', 'brick', 'office'], punch = ['lib', 'cta'];
  soft.forEach((n, i) => ev(i % 2 ? 'swish' : 'whoosh', C[n] - 0.02, -15));
  hard.forEach(n => ev('whoosh', C[n] - 0.03, -9));
  punch.forEach(n => ev('swoosh', C[n] - 0.04, -15));
  // ---- hook: the shark gnaws the card, eats the money, rips the $30 tag off; the lights come on ----
  // bites on the jaw closings, kept clear of "the Claude" (≈0.9–1.25 s) so the name stays clear
  [[0.18, -10], [0.545, -11], [0.909, -21]].forEach(([t, db]) => ev('chomp', t, db, 0.4));
  ev('chomp', 0.363, -18, 0.35);
  const paid = T('paid'); [paid + 0.147, paid + 0.313, paid + 0.48].forEach((t, i) => ev('chomp', t, -12 - i * 2, 0.4));
  ev('swish', paid - 0.02, -13); ev('swish', paid + 0.12, -16);
  ev('slap', T('$30') + 0.02, -4);
  ev('chomp', tf - 0.0, -6, 0.5); ev('rip', tf + 0.02, -6);
  ev('clunk', tf + 0.05, 8); ev('shimmer', tf + 0.08, -12);
  // ---- corridor ----
  ev('swoosh', C.cor + 0.06, -18);                                   // doors swing open
  for (let k = 0; k < 8; k++) { const t = C.cor + 0.2 + k * 0.314; if (t < T('agentic') - 0.45 || t > T('app') + 0.2) ev('click', t, -6); } // little footsteps, not over "agentic super app"
  ev('pop', T('Casper') - 0.05, 4);
  ev('swish', T('built') - 0.04, -14);                               // chyron
  ev('clunk', T('Morphy') - 0.05, 9);                                // the MORPHY sign lights up
  // ---- the pitch, from the sharks' side ----
  ev('pop', T('content', 1) - 0.03, 4); ev('swoosh', T('content', 1) - 0.08, -18);
  ev('pop', T('SKILL.md') - 0.03, 3);
  ev('swish', T('away') - 0.04, -14);
  ev('stamp', T('free', 1) + 0.0, -2); ev('boing', T('free', 1) + 0.02, -16);
  // ---- the sharks move in ----
  for (let i = 0; i < 5; i++) ev('pop', T('sharks') - 0.06 + i * 0.07, 3 - i);
  ev('swoosh', T('moved') - 0.05, -16);
  // ---- one repackaged it ----
  ev('pop', T('one') - 0.06, 4);
  ev('swish', T('took'), -11);
  const rp = T('repackaged');
  ev('swish', rp + 0.02, -15); ev('thud', rp + 0.36, -9); ev('boing', rp + 0.4, -12); ev('flick', rp + 0.5, -2);
  // ---- another sells it for $30 ----
  ev('pop', T('another') - 0.06, 4); ev('swish', T('started') - 0.04, -14);
  ev('kaching', T('$30', 1) - 0.02, -2); ev('clunk', T('$30', 1) + 0.14, 8);
  // ---- the felt conveyor: a stamp on the beat, cards slide, coins drop ----
  const hit0 = T('$30', 2);
  for (let k = 0; k < 4; k++) { ev('stamp', hit0 + 0.03 + k * 0.5, -3); ev('clack', hit0 - 0.25 + k * 0.5, -10); if (k) ev('ding', hit0 - 0.05 + k * 0.5, 4); }
  ev('pop', C.felt + 0.2, 2); ev('boing', T('free', 2) - 0.05, -16);
  // ---- so here's my response: the price flips to $0 ----
  const tr = T('response'); ev('flick', tr, 0); ev('ding', tr + 0.1, 8); ev('shimmer', tr + 0.1, -14); ev('boing', tr + 0.07, -16);
  // ---- the library rises, the books pop in, it lives inside Morphy ----
  ev('swoosh', T('entire') - 0.06, -14); ev('thud', T('entire') + 0.25, -12);
  const b0 = T('library'), b1 = T('content-format') - 0.1; for (let k = 0; k < 9; k++) ev('click', b0 + (b1 - b0) * k / 9, 0);
  ev('swish', T('ready-to-use') - 0.04, -14);
  ev('swish', T('files') - 0.05, -13);
  ev('pop', T('inside') - 0.05, 5); ev('whoosh', T('inside') - 0.1, -15);
  // ---- no paywalls, no subscriptions, no strings attached ----
  [T('paywalls'), T('subscriptions'), T('strings')].forEach(h => ev('slap', h + 0.02, -3));
  ev('click', T('attached'), 6); ev('swish', T('attached') + 0.05, -17);
  // ---- the money burns ----
  const ig = T("shouldn't") + 0.05; ev('shimmer', T('you') - 0.02, -10); ev('burst', ig, -3); ev('burn', ig + 0.02, -1); ev('fuse', T('pay'), -8, 0.6);
  // ---- the office: the text file gets its FREE stamp ----
  ev('swish', T('text') - 0.12, -12); ev('swish', tok(10, 8).t0 - 0.04, -16);
  ev('stamp', T('free', 3) + 0.02, -1); ev('ding', T('free', 3) + 0.1, 8); ev('boing', T('free', 3) + 0.07, -17);
  // ---- this format is paper.md ----
  ev('swoosh', T('called') - 0.1, -15); ev('chime', T('paper.md') - 0.02, -9);
  // ---- CTA: signs flip to FREE, the COMMENT bubble, the DM flies off ----
  ev('swish', C.cta + 0.05, -16);
  [0, 1, 2].forEach(j => ev('pop', C.cta + 0.15 + j * 0.08, 3));
  const fr = T('free', 4); [1, 2, 3].forEach(i => ev('flick', fr - 0.04 + i * 0.05 + 0.08, -2));
  ev('pop', T('comment') - 0.04, 8); ev('impact', T('comment') - 0.04, -18); ev('chime', T('comment') + 0.02, -12);
  ev('plane', T('send') - 0.04, 2);
}
```

