---
name: toybox
description: Make a narrated vertical reel (1080x1920, 40-60 s) in the "Toybox" format - a Blender-style toy cartoon in one sunny suburb (a mowed lawn with wide stripes, white houses with orange-red roofs and blue shutters, white picket fences, a big lumpy tree, a patio with a kettle grill, puffy clouds) with vinyl-toy characters that have big heads and painted faces (a kid in an orange hoodie and a cream beanie, a dad in a teal polo and a red cap, a grandma, a raccoon in a tiny business suit, a tin robot), telling an escalation story in the second person ("If you ... congratulations! You've built ... So, you ...") where every fix grows the build on screen, cut every ~1.2 s, one caption word at a time (Poppins Bold, white, key words yellow) and big sticker words (Archivo Black, yellow or red, thick black outline) slammed next to the thing they name. 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 toybox reel, "toybox.md", a toy or Blender-style cartoon explainer, a "how X becomes Y" escalation reel, or wants to turn a script into this kind of video.
---

# Toybox reel: how to make one

You will make a **40-60 second vertical video (1080x1920, 30 fps, H.264 + AAC, at most 60 s and 60 MB)** that looks like **a little Blender cartoon made of toys**. Everything happens in **one sunny backyard**: a mowed lawn with wide light and dark green stripes, a white two-storey house with an orange-red gable roof and blue shutters, a terracotta patio with a red kettle grill, a big lumpy tree, white picket fences, rows of neighbours' houses fading into blue haze, puffy white clouds. The characters are **smooth vinyl toys with big round heads and painted faces** (white eyes with a dark outline and a big pupil, pink cheeks, simple mouths): **Claude**, a kid in an orange hoodie, blue jeans, white sneakers and a cream beanie; **you**, a dad in a teal polo, khaki trousers and a red cap; **Grandma** in a green cardigan, a pink skirt, red round glasses and a white bun; **a raccoon in a tiny navy business suit** with a red tie and a briefcase; and **a little tin robot** with a screen face. The story is an **escalation explainer told to "you"**: a tiny first attempt ("If you write three lines in a text file and tell Claude to make a reel, congratulations! You've built a content skill."), a gag, then "So, you …" fixes that **grow the build on screen** (a paper nailed to the tree → a platform on four posts → a wobbly tower of wooden cabins → one clean monument), a twist (someone knocks: a raccoon in a suit takes it), and the turn. **A new shot every ~1.2 s**, every shot a slow push or orbit. **One caption word at a time** in the middle of the lower half (Poppins Bold, white, key words yellow, a dark outline). **Sticker words** slam in on their word next to the thing they name (Archivo Black caps, yellow or red, a thick black outline and a hard shadow, tilted a few degrees) and vanish on the cut. Poofs of white smoke when things land, confetti on "congratulations", and at the end a white **COMMENT card** with the keyword.

**`reference.jpg`** (next to this file, if you have it) is a contact sheet of 12 frames from a finished Toybox reel: **this is what it should look like.** Left to right, top row first: (1) the hook: the big tree with a sheet of paper nailed to it, the dad in the red cap writing on it, Claude in the orange hoodie looking up at it, the white houses behind, the caption "write"; (2) the paper close up ("reel-skill.txt": "1. make a reel / 2. make it good / 3. thx") with the sticker CONTENT SKILL; (3) Claude holding up a phone whose screen shows "3 views", the red sticker 3 VIEWS; (4) Grandma on the patio holding up two phones with hearts, hearts floating, "Grandma."; (5) a wooden platform on four posts with a board "HOOK" nailed on, a poof, the sticker HOOK; (6) a tower of ten wooden cabins with coloured shutters, far away, "10 FORMATS"; (7) Claude on the tower's roof, arms up, screaming, a sweat drop; (8) the monument: a giant white document "SKILL.md" on a pedestal, sparkles, the sticker SKILL.md; (9) the raccoon in the suit, close up and smug; (10) a red "$30" price tag punched in, the sticker "$30!"; (11) a glass skyscraper with red diagonal bracing rising behind the houses, LIBRARY; (12) everyone waving in front of the tree, the comment card "COMMENT TOYBOX", a paper plane. 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 of the edit, the rules that keep it from looking buggy, the complete tested engine (Appendix A: write it once) and a complete example video (Appendix B). With the engine written, **a video is four small files**: `script.txt` (what the voice says), `video.json` (settings and pronunciations), `scene.js` (the director file: the yard, the cast, and one beat per shot) and `cues.mjs` (sound effects).

Work in this order and don't skip ahead: **setup → script → fake timings → direct the beats → stills → voice → tighten → real timings → fix the beats → stills → full render → 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 (`cdp.mjs` lowers every Chrome process and, on Windows, pins them to one CPU), never parallel workers. The film needs WebGL on the GPU: `cdp.mjs` refuses software WebGL (SwiftShader), because a CPU render pegs every core for half an hour.
2. **Never print secrets.** The key is `ELEVENLABS_API_KEY` (environment, or `.env` in the project folder). Scripts read it; you never echo it, log it or put it in a file you show.
3. **Spend credits deliberately.** `gen-vo.mjs` and `gen-sfx.mjs` are dry runs unless you pass `--yes`. One voice take of the whole script (~1 credit per character, ~1,300 for a 55-second reel). Never regenerate a take that exists: fix a wrong word with a one-line re-take 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 every beat's still is right.
5. **Background jobs die when your turn ends.** Render in foreground chunks (`render.mjs my-video full 0 500`, then `full 500 1000`, …), each call under your tool timeout. The render resumes where it stopped.
6. **No real people, no real brands.** The characters are invented toys (the kid called Claude is a generic boy in an orange hoodie, not anyone's mascot). Phones and laptops show drawn screens. No real logos, handles or view counts; a number on screen must be one the script says.
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 ffmpeg, no GPU), 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>`) with **WebGL on a GPU**. Debug ports 9640-9719 on 127.0.0.1.
- **three.js 0.170.0** from npm (pin it; the page's import map points into `node_modules/three`).
- **An ElevenLabs API key** for the voice and, once, 23 sound effects (~21 s of sound generation). The free plan 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.
- Free downloads (`setup.mjs` fetches them): the fonts **Poppins Bold** and **Archivo Black** (SIL Open Font License, google/fonts) and the music bed **"Coffee or Tea"** (Mixkit #1168, Mixkit free licence). Six sounds are synthesized with ffmpeg (`make-toy-sfx.mjs`).
- Optional: **Python + faster-whisper** (`pip install faster-whisper`): better word starts after pauses (`align.mjs`) and a word-by-word check of the final mix (`check.mjs`). Without it both say so and carry on.

## 2. Project layout and setup

```
toybox-reel/                      one project folder, many videos
  .env                            ELEVENLABS_API_KEY=...   (never print it)
  package.json                    { "type": "module", "private": true, "dependencies": { "three": "0.170.0" } }
  node_modules/three/             npm i
  engine/                         Appendix A: write these files once, never edit them per video
    film.html main.js             the page: fonts → <video>/film.json + <video>/scene.js → window.setT(t)
    toy.js yard.js props.js clay.js   the cast · the suburb and its props · story props · the toy material + shape helpers
    overlay.js camera.js look.js post.js fx.js lib.js   captions, stickers, safe zone · camera · light · post · flashes · helpers
    common.mjs cdp.mjs setup.mjs gen-vo.mjs align.mjs build.mjs words.mjs votight.mjs voedit.mjs whisper-words.py
    render.mjs sheet.mjs beats.mjs redo.mjs gen-sfx.mjs make-toy-sfx.mjs mix.mjs encode.mjs encode-report.mjs check.mjs
  assets/fonts/ assets/music/ assets/sfx/   downloaded, synthesized or generated once
  my-video/                       one folder per video (Appendix B is a complete one)
    script.txt video.json scene.js cues.mjs   what the voice says · settings · the director file · the sound effects
    vo/ film.json stills/ frames/ mix.wav video.mp4 cover.jpg
```

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

A video:

```bash
mkdir my-video                   # write script.txt (section 4) + video.json; copy scene.js and cues.mjs from Appendix B and rewrite the beats
node engine/build.mjs my-video --fake                     # guessed timings: direct before you pay for a voice
node engine/beats.mjs my-video                            # every beat's cut and mid time (✗ if a cut list is not increasing)
node engine/render.mjs my-video stills <the mids> --safe --no-sheet   # look at every beat; make sheets of 12 (section 8)
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/align.mjs my-video                            # film.json on take a (Whisper, if installed, gives votight better word starts)
node engine/votight.mjs my-video --from a --out b --stop 0.16 --comma 0.07 --word 0.04 --tempo 1.1 --pitch 1.03 --set
node engine/align.mjs my-video                            # film.json on take b: prints every caption word with its time
node engine/beats.mjs my-video && node engine/render.mjs my-video stills <the mids> --safe --no-sheet
node engine/render.mjs my-video full 0 500 && node engine/render.mjs my-video full 500 1000 && node engine/render.mjs my-video full 1000
node engine/redo.mjs my-video 8.6-9.7                     # after a fix: delete only those frames, then `full` again
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 film | everywhere | the 3D yard, a new shot every ~1.2 s |
| Sticker | usually y 420-640, centred on x 510 or near its thing | Archivo Black, 100-260 px, ≤ 760 px wide at rest (the pop-in overshoots to ~1.12× and still fits x 60-960), rotated ±0.04-0.09 rad |
| Caption | centred on x 510, y 1196 | ONE word, Poppins 700 68 px (shrinks to fit 800 px), white or yellow, a dark outline |
| Comment card | x 130-890, y 450-750 (the end only) | white rounded card, black border 8 px, an orange avatar, "COMMENT" grey, the keyword yellow with a black outline, a red heart |

**Safe zone (every platform covers the edges):** all text inside **x 60-960, y 250-1480**; captions inside the band **y 1150-1480**, ≤ 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 and prints anything outside it, the full render logs every frame to `frames/safe.json`, and `check.mjs` reads it. In-world text (signs, the paper, phone screens) isn't tracked: keep it big and central.

### 3.2 Colours

| Token | Hex | Used for |
|---|---|---|
| sky | `#3f86d6` top → `#7db5e8` → `#d4e7f4` horizon; haze (fog) `#cfe2f1` from 45 to 190 m | the dome, the far houses melting into it |
| clouds | white, emissive `#dfe9f5` 0.38 | puffy heaps of 11 blobs |
| lawn | stripes `#5aac3c` / `#7ccd55`, 2.4 m wide | the mowed lawn |
| patio | tiles `#c98a6a` | under the grill |
| houses | walls `#f3f1ec`, roofs `#d9603f` / `#c9533a`, shutters `#3a6fc4` (some `#2f9e8f`), frames white, glass `#2c4f86`, door `#3a6fc4` | every house |
| tree | bark `#7a5236` (streaked texture), leaves `#4c9a35 #5fae3e #3f8a2e` | the big tree, small trees, bushes |
| wood | `#c6935c` / dark `#9c6c40`, cabin planks `#c99a62` | the platform, cabins, benches, signs |
| cast | skin `#f2c4a0` (kid) `#e9b58f` (dad) `#f2c8a8` (grandma); hoodie `#e8763a`, jeans `#3d66b8`, beanie `#f3ead8`; polo `#2f9e8f`, trousers `#c4a46a`, cap `#d64535`; cardigan `#7fae6a`, skirt `#e98fae`, glasses `#c0392b`; raccoon fur `#8d8d97`, mask `#3b3b44`, suit `#2b3b6b`, tie `#d9473a` | the characters |
| text | white `#ffffff`, yellow `#ffd52e`, red `#f0282b`, outline `#121212` / `#141414` | captions and stickers |

### 3.3 Fonts

**Poppins Bold** for the captions (and every drawn screen), **Archivo Black** for the stickers, signs and every 3D letter (the monument's "SKILL.md", the skyscraper's "MORPHY"). Both SIL OFL from google/fonts; `setup.mjs` downloads them to `assets/fonts/`.

### 3.4 The yard (world units are metres; y is up, the camera looks mostly toward −z)

| Thing | Where |
|---|---|
| lawn | a 420 × 420 plane, stripes running along z |
| the house (door side +z) | centre (−6.5, 0, −8), 7.5 × 6 × 4.6, gable roof, the back door at x −4.7 on a hinge (`house.userData.door`), a step in front |
| patio + grill | patio centre (−6.5, 0, −3.6), 7.6 × 2.8; kettle grill at (−4.1, 0.06, −3.4); a white side table at (−8, 0.06, −3.3) with the microwave and the robot |
| the big tree | trunk at (2.2, 0, −4.5), 4.2 m to the crown, canopy heaps up to ~8 m; the paper nailed at (2.2, 1.62, −3.95) |
| fences | z −13 from x −16 to 16, x ±14-16 down the sides; neighbours' houses beyond (z −21, −36), 80 far houses scattered to z −170 |
| the build site | (7.2, 0, −3.2): the platform (four posts, a 2.5 × 2.5 deck at 1.55 m), cabins 1.38 m tall stacked on it, later the monument |
| other places | lawn chair (−1, 0, −0.9), garden table + laptop (−1.8, 0, 0.65), bench in front of the tree, the raccoon's stall (9.8, 0, 1.2), the toy chest (4, 0, 1.4), the library skyscraper (3, 0, −54) behind the houses |

### 3.5 The cast (`toy.js`)

`makeToon({ kind, … })`: `kind` **kid** (~1.45 m, the face centre at y ≈ 1.08), **adult** (`h: 1.12`, the face at ≈ 1.26), **granny** (`h: 0.98`), **raccoon** (~1.0 m, a snout, cone ears, a striped tail); `top` hoodie / polo / cardigan (+ `skirt`) / suit (+ `acc` for the tie); `hat` beanie (+ `pom`) / cap; `hairStyle` tufts / short / bun; `glasses` (a colour). Big round heads, capsule limbs, mitten hands with a thumb, round shoes with soles.

**The faces are painted:** a 640 × 512 canvas mapped onto the front of the head (1.8 rad wide, 1.44 rad tall, so a circle stays round) and redrawn only when the expression changes. Eyes: **open half happy shut wide angry sad dizzy**, a gaze `[x, y]`, brows `[raise, tilt]`; mouths: **smile grin o open scream flat frown smirk teeth a e**; pink blush; a sweat drop. They blink every 2.4-4 s (seeded per character), talk with `TOY.talk(film, t)` (open/close shapes on the voice's words) and breathe.

### 3.6 Props

- `yard.js`: `sky cloud lawn patio house farHouse fence bigTree smallTree bush grill table bench lawnChair ladder phone laptop microwave robot paper toyChest puff confetti board`, and `screen(w, h)`, a canvas texture you redraw with a key (`.draw(key, fn)` only redraws when the key changes): phone screens (POST button, a view counter, hearts), the laptop (a drawn canvas app), the paper being written, a sign's text.
- `props.js`: the **.md file** (a cream sheet with a folded corner, text lines and a 3D label; `glow(v)`), the **gift box** (an open box + `lidG`), the **price tag**, the **counter**, the striped **awning**, a **sign**, a **shelf**, **bricks**, **scissors** (`open(k)`), **coins**.
- **Poofs** (`YD.puff`, `set(k)`: white blobs that burst out and fade in ~0.45 s) on every landing and slam; **confetti** (`set(seconds)`: 80 coloured bits launched up, falling).

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

- **Captions:** the chunk on screen (one word), 68 px Poppins 700 centred on (510, 1196), white, or `#ffd52e` when the word is a key word (`KEY` in scene.js), outline `#121212` 13 % of the size on each side, a soft shadow; a quick scale-in from 86 % over 0.08 s. A trailing comma is dropped.
- **Stickers** (`O.sticker(g, t, { text | lines, at, out, x, y, size, col: yellow|red|white, rot, skew, maxW, breathe })`): pop in from 35 % with an overshoot over 0.2 s, **vanish at `out` (the next cut) with no exit animation**, fit themselves inside the safe zone at their peak size.
- **Sparkles** (4-point stars), **hearts** (drawn in scene.js) floating up, the **comment card** (`O.commentCard(g, t, { at, y, word })`: pops in, the keyword letters pop in one by one, the heart pulses) and a **paper plane** (`O.plane`).

### 3.8 Light and post (`look.js`, `post.js`, `fx.js`)

A warm sun (`#fff2de`, 2.9) from (−0.45, 0.82, 0.55) with soft PCF shadows whose box follows the shot (`shadow` = its half-size), a sky/lawn hemisphere light (1.05), a front fill (0.45), a soft studio environment for the vinyl sheen, blue haze. Post: depth ambient occlusion (contact shadows), depth of field per shot (`ap` 0-6), bloom on bright things only, ACES, warm 0.12, saturation 1.14, chromatic edges 0.5, vignette 0.2, grain 0.022. Flashes are capped at 0.24; zoom punches and overlay shake on slams.

## 4. Writing for this format

- **The narrator talks to "you", fast and continuous**: after `votight`, ~4.4 spoken words a second, so **210-240 words = 50-57 s**. Funny, deadpan, a roast of the first attempt.
- **The shape** (the grammar the look was built for): **hook** "If you [do one small thing] and tell [character] to [do X], congratulations! You've built [the thing]." → **the gag** (what's wrong with it: "A content skill with the creative range of a microwave manual.") → **the consequence + a triple** ("Two of them are Grandma. The third is a bot.") → **"So, you [fix]."** (the build grows) → **"Now [it works], but [he] wants [more]."** → **"So, you [add one], then [two], then [three]."** (numbers) → **the next failure** ("By the tenth format, the whole thing starts leaning like it's had seventeen coffees.") → **"So, you [the clean fix]."** → **"Congratulations! You've built [the real thing]."** → **"Everything is perfect, until someone knocks on your door."** → **the twist** (a small animal in a business suit, the absurd claim, a callback price) → **the payoff or turn** → **the format line** → **CTA** "So comment KEYWORD and I'll send it to you. Completely free."
- **Every sentence must be showable** as one or two shots in the yard: a thing written (the paper), a number (a phone counter, a sticker), a list of three (boards slammed on one by one), growth (cabins landing), failure (leaning, collapsing into a poof), a price (a tag swinging in, flipping), an arrival (a knock, a door).
- `script.txt` = one sentence per line, exactly what the voice reads; `video.json` `say` = how the voice says a shown token (`"SKILL.md": "skill dot M D"`, `"$30": "thirty dollars"`, `".md": "dot M D"`) and the keyword it must stress (`"TOYBOX": "\"Toybox\""`). Captions show the script as written.

## 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 bright, fast young narrator (median pitch ~150-160 Hz). Another voice: put `voice` in `video.json`.
- **One take of the whole script**, then **`votight.mjs --stop 0.16 --comma 0.07 --word 0.04 --tempo 1.1 --pitch 1.03 --set`**: it cuts every pause inside real silence (keeping 0.16 s after `. ! ? :`, 0.07 s after `, ;`, 0.04 s between words) and speeds the take up ×1.1 (1.03 of it by resampling, a touch brighter; the rest with the pitch kept). The example went from 217 to 262 words a minute. Don't go past ×1.15: fast words blur.
- **A word that comes out wrong**: list the words (`voedit.mjs my-video --list a`), make a one-line re-take into another take letter, splice it in (`voedit.mjs my-video --out c --use a:0-39,s1:0-4,a:45-`), then tighten `c`.

## 6. Timings: film.json

`build.mjs` (run by `align.mjs`) turns the take's character timings into **caption words** (`film.words`: `cap`, `t0`, `t1`), undoing `say` ("skill dot M D" becomes the one word "SKILL.md"), and **chunks** of one word (`chunkMax: 1`). `END` = the last word + `tail` (1.6 s). `--fake` writes guessed timings (no voice needed) so you can direct and check stills first. **In scene.js, `T(word, n)` = when the n-th caption token `word` starts. It is case-insensitive and ignores punctuation: "A" also matches "a", "The" matches "the", so count the occurrences.** `beats.mjs` prints every cut and flags a cut list that is not increasing (the sign of a wrong occurrence).

## 7. Directing (scene.js)

### 7.1 The shape of the file

`setup(E)` builds **the world** (sky, clouds, lawn, the house, the patio, the tree, fences, neighbours, the build site, every prop), **the cast**, then **the beats**, and returns `{ SHOTS, IMPULSE, world, overlay, effects, smoothCam: true }`. Keep the world and the cast from Appendix B; rewrite the beats, the persistent-state functions, `STK` (stickers), `KEY` (yellow words) and `effects`.

```js
// a beat = [cut time, camera, run(t)]. The camera orbits the look-at L (also the focus distance):
// yaw/pitch in degrees (yaw 0 = camera on the +z side of L, pitch > 0 = above), distance d → d1, look-at L → L1 over the beat
beat(T('Two') - 0.06, { L: [-3.3, 1.0, -2.75], yaw: 10, yaw1: 6, pitch: 4, d: 3.7, d1: 3.3, fov: 30, ap: 3.5, shadow: 3 }, t => {
  put(gran, { x: -3.3, z: -2.75, y: 0.06, yaw: 0.3, armL: { f: 1.9, o: 0.25, e: 0.5, hand: 'hold' }, armR: { f: 1.9, o: 0.25, e: 0.5, hand: 'hold' },
              face: { eyes: 'happy', mouth: 'grin' } }, t);
  granPh.forEach((p, i) => { drawPhone(p, 'heart'); hold(p, gran, i, [0, -0.07, 0.06], [0, 0.3, 0]); });
});
```

- `world(tq, t, shot)` runs every frame: **it first resets everything a beat may move** (hides the cast and the one-beat props, puffs, confetti), **then sets the things that persist as pure functions of `t`** (the tower: posts pop on "give", boards slam on their words, cabins land, lean, collapse; the monument; the stall; the skyscraper), **then runs the current beat**. A beat that shows something without a reset in `world()` leaks into every later frame.
- `put(P, pose, t)` = `poseToon` + breathing + blinks. A pose: `x y z yaw` (`faceTo(P, x, z, tx, tz)` gives the yaw toward a point), `lean tilt bob sq` (squash), `walk` (a phase), `bend twist`, `head: [pitch, yaw, roll]`, arms `{ f (raise forward, rad), o (outward), tw (twist), e (elbow), hand: open|fist|point|hold }` (2.6-2.75 = arm straight up, 1.25 with o −0.25 = holding something at the chest), `legL legR` (with `SIT` = sitting; set `y` so the hips rest on the seat), `face: { eyes, gaze, brow, mouth, blush, sweat }`, `noBlink`. Lip sync: `mouth: TOY.talk(film, t, 'smile')`. Hold a prop: `hold(obj, P, handIndex, offset, rotation)`, or place it at a fixed spot and pose the hands toward it (steadier). Poofs: `pp(i, x, y, z, k)`. Hops: `L.hop(t, t0, duration, height)` → `{ y, sq }`.
- A character can ride a moving thing for a beat (`cabin.add(claude.root)`); `world()` puts every root back in the scene first.

### 7.2 Framing a 9:16 frame

- At fov 30 the frame is only **0.30 × d wide** (fov 36: 0.37 × d). A kid's face needs d ≈ 2, a kid full body ≈ 3.4, a phone at the chest ≈ 1.5, two people side by side ≈ 4.6, a group of five ≈ 8, a 10-cabin tower from afar ≈ 22, a skyscraper's roof ≈ 55. **The first stills of every new beat are almost always too tight: widen first.**
- `frameY` (default 0.45) is where the look-at lands on screen: 0.37 lifts a subject above the caption, 0.6 puts a group under the comment card.
- Keep the camera off occluders: the tree canopy, the monument, a counter in front of a short character (the raccoon stands on a crate behind his stall: `y: 0.45`), a gift box or a price tag in front of a face. Move the look-at sideways rather than only the yaw.
- The hook (frame 0) shows the yard, the characters and the first caption word; no sticker on the first beat.

### 7.3 The edit follows the words

- **A cut just before each sentence** (0.06 s before its first word) and inside long sentences on the key word: ~1.2-1.6 s a shot (the example: 46 beats in 57 s).
- **On the word:** a sticker pops on its word; a board slams on "hook"/"style"/"voice"; cabins land on "another"/"third"/"fourth"; the price tag swings in on "sells"; the door shakes on each knock; confetti on "congratulations"; the toy chest bursts open on "called".
- **Every shot moves:** an orbit of 4-10° and a push of 8-15 % over the beat (`yaw → yaw1`, `d → d1`); something else moves every ~0.3 s (blinks, breathing, hearts, sparkles, a phone counter, a wobble).
- **Stickers vanish on the cut** (`out` = the next beat's cut).

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

- Every frame is a pure function of `t`: no randomness (seeded `rnd`), no state carried between frames.
- Persistent things (a tower that grew, a sign planted, a door that opened) are computed from `t` every frame, visibility included.
- Things land with a quick fall (ease-in) and a poof; things appear with an overshoot (`easeOutBack`); nothing pops into a frame without a cause.
- Poofs must not cover the word being said: put them behind or under the thing, r ≤ 0.3, ≤ 0.45 s.

## 8. Stills and the safe zone

`node engine/beats.mjs my-video` prints the mid time of every beat; render them all: `node engine/render.mjs my-video stills <mids> --safe --no-sheet`, then make contact sheets of 12 (`node -e` with `makeSheet` from `engine/sheet.mjs`, or open the stills). Check every beat: the subject whole and readable, nobody hidden behind a prop, the caption not on top of an important number, stickers inside the zone. The run prints `OUTSIDE SAFE ZONE` lines and page errors: both must be empty.

## 9. The full render

`render.mjs my-video full [from] [to]` writes `frames/f0000.jpg …` (~0.25 s a frame on an ordinary GPU → ~8 minutes for 57 s), reopens the browser every 150 frames, retries a blank frame, logs every frame's text boxes to `frames/safe.json` and resumes where it stopped. After a fix, `redo.mjs my-video 8.6-9.7 26.6-27.5` deletes only the frames of those seconds; `full` again renders just them.

## 10. Sound

`cues.mjs` puts the kit on the picture's events, `e(name, t, dB, maxDuration?)` with a master trim `G`: **poof** (landings, slams), **thud / clunk** (hammering, landings), **whoosh** (things falling in), **creak** (leaning), **clack** (rattling cups, scissors), **blocks + impact** (a collapse, a wall bursting), **knock** ×3, **flick** (a door, a tag flipping), **swish** (grabbing; −26 dB on some cuts), **tear** (wrapping), **slap** (signs, a clap), **kaching / coins** (prices), **boing** (a flop), **beep** (the robot, the microwave), **ding / shimmer / chime** (reveals, the format name), **pop / pop_hi** (hearts, counters, the comment card's letters), **plane**, **riser + thunder** (the skyscraper rising), **scribble** (the pencil), **enter** (the laptop). Effects sit ~8 LU under the voice. **Keep loud effects off key words** (a hit on a word makes it unclear). `mix.mjs` ducks the music under the voice (the bed ~13 LU under it), adds the effects and lands the mix at −14 LUFS, true peak ≤ −1 dBTP.

## 11. Encode, check, deliver

`encode.mjs` → `video.mp4` (H.264 High, yuv420p, 1080x1920, 30 fps, CRF 20 with a maxrate that keeps it ≤ 60 MB, AAC 192k, faststart) and `cover.jpg` (the frame at `cover`). `check.mjs`: format and size, loudness, frame 0 not black or flat, captions inside their band, text inside the safe zone on every frame, and (with Whisper) the mix compared word by word with `script.txt`. Deliver `video.mp4`, `cover.jpg` and a caption.

## 12. Checklist

- [ ] The script follows section 4's shape, ~230 spoken words, every sentence showable; `say` covers every symbol and the keyword.
- [ ] Fake timings → every beat's still checked before the voice.
- [ ] One take, tightened to ~260 words a minute; `beats.mjs` cut list increasing.
- [ ] Every beat's still checked again on the real timings; no `OUTSIDE SAFE ZONE`, no page errors.
- [ ] Full render: ✓ text inside the safe zone on every frame.
- [ ] Mix −14 LUFS, TP ≤ −1; encode ≤ 60 MB, ≤ 60 s; `check.mjs` all ✓.

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

The browser page and its modules, then the Node scripts. `whisper-words.py` is optional (Python + faster-whisper).

### `engine/film.html`

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

### `engine/main.js`

```js
// Engine boot: fonts (Poppins Bold captions, Archivo Black stickers + 3D letters) → the video's film.json + scene.js → lights,
// post, overlay → window.setT(t). Engine file. The video folder comes from film.html?video=<folder> ('.' = the work folder itself).
// Smooth animation: the world moves on every 30-fps frame (STEP 30; a scene may still ask for held poses with E.q).
// 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 { TTFLoader } from 'three/addons/loaders/TTFLoader.js';
import { Font } from 'three/addons/loaders/FontLoader.js';
import { makePost } from './post.js';
import * as O from './overlay.js';
import * as L from './lib.js';
import * as FX from './fx.js';
import * as C from './clay.js';
import * as PR from './props.js';
import * as TOY from './toy.js';
import * as YD from './yard.js';
import { placeCamera, minDistance } from './camera.js';
import { makeLights } from './look.js';

const W = 1080, H = 1920, STEP = 30;
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 template: ' + video);
const base = new URL(`../${video}/`, import.meta.url);

// Poppins + Archivo Black are required (SIL OFL, google/fonts); Consolas Bold (Windows) only labels the --safe overlay and the microwave display
const FONTS = [['Poppins', 'Poppins-Bold.ttf', '700', true], ['Archivo Black', 'ArchivoBlack-Regular.ttf', '400', true], ['Consolas Bold', 'consolab.ttf', '400', false]];
for (const [fam, file, weight, need] of FONTS) {
  try { const f = new FontFace(fam, `url(${new URL('../assets/fonts/' + file, import.meta.url)})`, { weight }); await f.load(); document.fonts.add(f); }
  catch (e) { if (need) throw new Error(`font ${file} missing in assets/fonts`); }
}
await document.fonts.load('700 30px "Poppins"'); await document.fonts.load('30px "Archivo Black"');
C.CLAY.font = new Font(new TTFLoader().parse(await (await fetch(new URL('../assets/fonts/ArchivoBlack-Regular.ttf', import.meta.url))).arrayBuffer()));
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.PCFShadowMap;   // PCF with a wide radius = the soft sun shadows of a toy set
renderer.toneMapping = THREE.NoToneMapping; renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);
const camera = new THREE.PerspectiveCamera(35, W / H, 0.05, 400);
const scene = new THREE.Scene();
const post = makePost(renderer, W, H);
const LT = makeLights({ scene, renderer });

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

const E = {
  THREE, scene, post, camera, renderer, film, END, W, H, O, L, FX, C, PR, TOY, YD, LT, STEP, minDistance,
  V: (x, y, z) => new THREE.Vector3(x, y, z), q: t => Math.floor(t * STEP + 1e-4) / STEP,
};
const V = await mod.default(E);
for (const k of ['SHOTS', 'overlay']) if (!V[k]) throw new Error(`scene.js setup() must return ${k}`);

window.framing = {};
function setT(t) {
  t = L.clamp(t, 0, END);
  const step = Math.floor(t * STEP + 1e-4), tq = step / STEP;   // STEP 30: every frame moves
  C.CLAY.step.value = step;
  const si = V.SHOTS.findIndex(s => t >= s.t[0] && t < s.t[1]), smooth = V.smoothCam || (si >= 0 && V.SHOTS[si].smooth);
  const cam = placeCamera(E, V.SHOTS, V.IMPULSE || [], smooth ? t : tq);   // a shot may glide (smooth: true), the rest moves on twos
  if (cam.clamped) window.framing[cam.shot] = Math.max(window.framing[cam.shot] || 0, +cam.clamped.toFixed(3));
  LT.aim(cam.s.sun || cam.L, cam.s.shadow ?? 4);
  V.world?.(tq, t, cam.shot);
  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/clay.js`

```js
// The material + the hand-made geometry helpers every prop and character is built from. Engine file.
// Toybox runs it in toy mode (CLAY.toy = true): smooth vinyl plastic with a soft sheen, no fingerprints, smears or pits, and
// every hand-made bump scaled by CLAY.lumpK (0.2), so shapes read as moulded toys. (With CLAY.toy = false it is plasticine.)
//   clayMat(color, { rough, bump, print, smear, tone, emissive, ei, scale, env, transparent, opacity })
//   blob(rx, ry, rz, mat, { lump, freq, seg })   rbox(w, h, d, r, mat, { lump })   roll(points, radius, mat)   capsule(r, len, mat)
//   lathe(profile [[r, y], …], mat)   text3d(str, size, depth, mat)   labelTex(w, h, draw)   glow(color, size)
import * as THREE from 'three';
import { mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js';
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';
import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';

export const CLAY = { step: { value: 0 }, font: null, toy: true, lumpK: 0.2 };   // toy: smooth vinyl (no prints, smears or pits), lumpK scales every hand-made bump

const GLSL_NOISE = /* glsl */`
uniform float uClayStep;
float cH31(vec3 p){ p = fract(p * vec3(.1031, .11369, .13787)); p += dot(p, p.yxz + 19.19); return fract((p.x + p.y) * p.z); }
float cVN(vec3 p){ vec3 i = floor(p), f = fract(p); f = f * f * (3. - 2. * f);
  return mix(mix(mix(cH31(i), cH31(i + vec3(1,0,0)), f.x), mix(cH31(i + vec3(0,1,0)), cH31(i + vec3(1,1,0)), f.x), f.y),
             mix(mix(cH31(i + vec3(0,0,1)), cH31(i + vec3(1,0,1)), f.x), mix(cH31(i + vec3(0,1,1)), cH31(i + vec3(1,1,1)), f.x), f.y), f.z); }
// a thumbprint: concentric ridges around a random centre in some of the cells of a grid (k cells per unit)
float cPrint(vec3 p, float k, float seed, float fw){
  vec3 c = floor(p * k); if (cH31(c + seed) < 0.62) return 0.;
  vec3 ctr = (c + 0.3 + 0.4 * vec3(cH31(c + seed + 1.7), cH31(c + seed + 2.3), cH31(c + seed + 3.1))) / k;
  vec3 d = (p - ctr) * k;
  float a = cH31(c + seed + 4.2) * 6.283; float ca = cos(a), sa = sin(a);
  d.xy = mat2(ca, -sa, sa, ca) * d.xy;
  float dist = length(d * vec3(1., 1.5, 1.2)) + 0.22 * (cVN(p * k * 3.) - .5);
  float m = smoothstep(0.38, 0.12, dist) * smoothstep(0.25, 0.6, cVN(p * k * 2.2 + seed));   // a partial, smudged print
  float period = 6.283 / 60. / k;
  float aa = smoothstep(period * 0.9, period * 0.3, fw);
  return sin(dist * 60.) * m * aa;
}
float clayH(vec3 p, float pr, float sm, float fw){
  float h = (cVN(p * 1.7) - .5) * 0.75 + (cVN(p * 4.6 + 3.1) - .5) * 0.32;   // kneading lumps
  h += pr * (cPrint(p, 3.0, 0., fw) * 0.12 + cPrint(p + 11.3, 4.2, 7., fw) * 0.09);  // thumbprints
  // tool and thumb smears: streaks whose direction wanders over the surface
  float a = cVN(p * 0.9 + 5.) * 6.283; float ca = cos(a), sa = sin(a);
  vec3 q = vec3(ca * p.x - sa * p.z, p.y, sa * p.x + ca * p.z); float b = cVN(p * 1.1 + 2.) * 3.14; q = vec3(q.x, cos(b) * q.y - sin(b) * q.z, sin(b) * q.y + cos(b) * q.z);
  h += sm * (cVN(q * vec3(3.5, 26., 3.5)) - .5) * 0.2 * smoothstep(0.03, 0.01, fw);
  float pit = cVN(p * 55.); h -= smoothstep(0.82, 0.96, pit) * 0.3 * smoothstep(0.02, 0.006, fw);
  return h;
}
`;

// matte plasticine. bump = relief depth in world units (0.004 ≈ a fingerprint on a 25-cm puppet), print/smear 0..1
export function clayMat(color, o = {}) {
  if (CLAY.toy) o = { ...o, print: 0, smear: 0, bump: Math.min(o.bump ?? 0.0045, 0.0045) * 0.12, tone: Math.min(o.tone ?? 0.09, 0.09) * 0.35, rough: o.rough ?? 0.55, boil: false };
  const m = new THREE.MeshStandardMaterial({
    color, roughness: o.rough ?? 0.58, metalness: 0, envMapIntensity: o.env ?? 0.55,
    emissive: o.emissive ?? 0x000000, emissiveIntensity: o.ei ?? 1, transparent: !!o.transparent, opacity: o.opacity ?? 1, side: o.side ?? THREE.FrontSide,
    map: o.map || null,
  });
  const U = { uBump: { value: o.bump ?? 0.0045 }, uPrint: { value: o.print ?? 1 }, uSmear: { value: o.smear ?? 1 }, uTone: { value: o.tone ?? 0.09 }, uScale: { value: o.scale ?? 1 }, uSeed: { value: o.seed ?? (hashStr(String(color)) % 97) }, uBoil: { value: o.boil ? 1 : 0 }, uClayStep: CLAY.step };
  m.userData.U = U;
  m.onBeforeCompile = sh => {
    Object.assign(sh.uniforms, U);
    sh.vertexShader = sh.vertexShader.replace('#include <common>', '#include <common>\nvarying vec3 vClayP;')
      .replace('#include <begin_vertex>', `#include <begin_vertex>
        vClayP = transformed;
        #ifdef USE_INSTANCING
          vClayP += instanceMatrix[3].xyz * 1.37;
        #endif`);
    sh.fragmentShader = sh.fragmentShader.replace('#include <common>', '#include <common>\nvarying vec3 vClayP;\nuniform float uBump, uPrint, uSmear, uTone, uScale, uSeed, uBoil;\n' + GLSL_NOISE)
      .replace('#include <color_fragment>', `#include <color_fragment>
        vec3 cP = vClayP * uScale + uSeed * 1.913 + uBoil * vec3(cH31(vec3(uClayStep, uSeed, 1.)), cH31(vec3(uSeed, uClayStep, 2.)), 0.) * 0.012;
        vec3 cDx = dFdx(cP), cDy = dFdy(cP); float cFw = max(length(cDx), length(cDy));
        float cH0 = clayH(cP, uPrint, uSmear, cFw);
        float cHx = clayH(cP + cDx, uPrint, uSmear, cFw), cHy = clayH(cP + cDy, uPrint, uSmear, cFw);
        diffuseColor.rgb *= 1. + (cVN(cP * 1.3 + 7.) - .5) * uTone * 2.;
        diffuseColor.rgb *= 1. - clamp(-cH0, 0., 1.) * 0.18;              // a touch darker in the dents`)
      .replace('#include <normal_fragment_maps>', `#include <normal_fragment_maps>
        {
          vec3 sx = dFdx(-vViewPosition), sy = dFdy(-vViewPosition);
          vec3 R1 = cross(sy, normal), R2 = cross(normal, sx);
          float det = dot(sx, R1) * faceDirection;
          float dBx = (cHx - cH0) * uBump / max(uScale, 0.001), dBy = (cHy - cH0) * uBump / max(uScale, 0.001);
          vec3 grad = sign(det) * (dBx * R1 + dBy * R2);
          normal = normalize(abs(det) * normal - grad);
        }`)
      .replace('#include <roughnessmap_fragment>', `#include <roughnessmap_fragment>
        roughnessFactor = clamp(roughnessFactor + (cVN(cP * 5.) - .5) * 0.16, 0.25, 1.);`);
  };
  m.customProgramCacheKey = () => 'clay1';
  return m;
}
function hashStr(s) { let h = 2166136261; for (const c of s) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619) >>> 0; } return h; }

// ---- deterministic JS noise for the hand-made shapes ----
const fr = x => x - Math.floor(x);
const h3 = (x, y, z) => fr(Math.sin(x * 127.1 + y * 311.7 + z * 74.7) * 43758.5453);
export function vnoise3(x, y, z) {
  const ix = Math.floor(x), iy = Math.floor(y), iz = Math.floor(z), fx = x - ix, fy = y - iy, fz = z - iz;
  const u = fx * fx * (3 - 2 * fx), v = fy * fy * (3 - 2 * fy), w = fz * fz * (3 - 2 * fz), L = (a, b, k) => a + (b - a) * k;
  return L(L(L(h3(ix, iy, iz), h3(ix + 1, iy, iz), u), L(h3(ix, iy + 1, iz), h3(ix + 1, iy + 1, iz), u), v),
    L(L(h3(ix, iy, iz + 1), h3(ix + 1, iy, iz + 1), u), L(h3(ix, iy + 1, iz + 1), h3(ix + 1, iy + 1, iz + 1), u), v), w) * 2 - 1;
}
// push every vertex along its normal by a little noise: no clay shape is a perfect primitive
export function lumpy(geo, amp = 0.012, freq = 3, seed = 0) {
  amp *= CLAY.lumpK;
  if (!amp) return geo;
  const keepUV = !!geo.attributes.uv;
  let g = geo;
  if (!keepUV) { g.deleteAttribute('normal'); g = mergeVertices(g, 1e-5); g.computeVertexNormals(); }
  else if (!g.attributes.normal) g.computeVertexNormals();
  const p = g.attributes.position, n = g.attributes.normal;
  for (let i = 0; i < p.count; i++) {
    const x = p.getX(i), y = p.getY(i), z = p.getZ(i);
    const d = (vnoise3(x * freq + seed, y * freq, z * freq) * 0.75 + vnoise3(x * freq * 2.7, y * freq * 2.7 + seed, z * freq * 2.7) * 0.25) * amp;
    p.setXYZ(i, x + n.getX(i) * d, y + n.getY(i) * d, z + n.getZ(i) * d);
  }
  g.computeVertexNormals();
  return g;
}
export const mesh = (geo, mat, { cast = true, recv = true } = {}) => { const m = new THREE.Mesh(geo, mat); m.castShadow = cast; m.receiveShadow = recv; return m; };
export function blob(rx, ry, rz, mat, o = {}) {
  const s = o.seg ?? 40, g = new THREE.SphereGeometry(1, s, Math.round(s * 0.75));
  g.scale(rx, ry, rz);
  return mesh(lumpy(g, o.lump ?? Math.min(rx, ry, rz) * 0.05, o.freq ?? 2.2 / Math.max(rx, ry, rz), o.seed ?? 0), mat, o);
}
export function rbox(w, h, d, r, mat, o = {}) {
  const g = new RoundedBoxGeometry(w, h, d, o.seg ?? 4, Math.min(r, w / 2 - 1e-3, h / 2 - 1e-3, d / 2 - 1e-3));
  if (o.lump !== 0) { const sx = Math.max(w, h, d); subdivideBox(g); lumpy(g, o.lump ?? Math.min(w, h, d) * 0.035, o.freq ?? 2.5 / sx, o.seed ?? 0); }
  return mesh(g, mat, o);
}
function subdivideBox() { } // RoundedBoxGeometry is already dense enough at the corners; flat faces stay flat-ish (fine for clay slabs)
// a rolled clay snake through points (rounded ends)
export function roll(points, radius, mat, o = {}) {
  const curve = new THREE.CatmullRomCurve3(points.map(p => p.isVector3 ? p : new THREE.Vector3(...p)), !!o.closed, 'centripetal');
  const g = new THREE.TubeGeometry(curve, o.seg ?? Math.max(8, points.length * 10), radius, o.radial ?? 12, !!o.closed);
  const grp = new THREE.Group();
  const tube = mesh(o.lump === 0 ? g : lumpy(g, o.lump ?? radius * 0.12, o.freq ?? 1.5 / radius * 0.2, o.seed ?? 0), mat, o); grp.add(tube);
  if (!o.closed && o.caps !== false) for (const t of [0, 1]) { const c = mesh(new THREE.SphereGeometry(radius, 12, 8), mat, o); c.position.copy(curve.getPoint(t)); grp.add(c); }
  grp.userData.curve = curve;
  return grp;
}
export function capsule(r, len, mat, o = {}) {
  const g = new THREE.CapsuleGeometry(r, len, 6, o.radial ?? 16);
  return mesh(o.lump === 0 ? g : lumpy(g, o.lump ?? r * 0.06, o.freq ?? 1.2 / r * 0.25, o.seed ?? 0), mat, o);
}
export function lathe(profile, mat, o = {}) {
  const g = new THREE.LatheGeometry(profile.map(([r, y]) => new THREE.Vector2(r, y)), o.seg ?? 40);
  return mesh(o.lump === 0 ? g : lumpy(g, o.lump ?? 0.008, o.freq ?? 3, o.seed ?? 0), mat, o);
}
// puffy clay letters in Cooper Black (CLAY.font, loaded by main.js)
export function text3d(str, size, depth, mat, o = {}) {
  const g = new TextGeometry(str, { font: CLAY.font, size, depth, curveSegments: o.curve ?? 8, bevelEnabled: true, bevelThickness: o.bevelT ?? size * 0.12, bevelSize: o.bevel ?? size * 0.07, bevelSegments: o.bevelSeg ?? 4 });
  g.computeBoundingBox(); const b = g.boundingBox;
  const ax = o.align === 'left' ? -b.min.x : -(b.min.x + b.max.x) / 2;
  g.translate(ax, -(b.min.y + b.max.y) / 2 * (o.vcenter === false ? 0 : 1), -(b.min.z + b.max.z) / 2);
  const m = mesh(o.lump === 0 ? g : lumpy(g, o.lump ?? size * 0.012, o.freq ?? 2 / size, o.seed ?? 0), mat, o);
  m.userData.width = b.max.x - b.min.x; m.userData.height = b.max.y - b.min.y;
  return m;
}
// a small painted label (printed paper look) for things too small for clay letters
export function labelTex(w, h, draw) {
  const c = document.createElement('canvas'); c.width = w; c.height = h; const g = c.getContext('2d'); draw(g, w, h);
  const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 8; return t;
}
// soft additive glow card (the light around a glowing file)
let GLOW_TEX = null;
export function glow(color = 0xffe7a0, size = 1, opacity = 0.8) {
  if (!GLOW_TEX) GLOW_TEX = labelTex(128, 128, (g, w) => { const r = g.createRadialGradient(w / 2, w / 2, 0, w / 2, w / 2, w / 2); r.addColorStop(0, 'rgba(255,255,255,1)'); r.addColorStop(0.35, 'rgba(255,255,255,0.45)'); r.addColorStop(1, 'rgba(255,255,255,0)'); g.fillStyle = r; g.fillRect(0, 0, w, w); });
  const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: GLOW_TEX, color, transparent: true, opacity, blending: THREE.AdditiveBlending, depthWrite: false }));
  s.scale.set(size, size, 1); s.renderOrder = 5; return s;
}

// ---- boil: held poses breathe a little every 12-fps step (the animator's hands) ----
const BOIL = [];
export function boil(obj, amp = 0.003, rot = 0.006) { BOIL.push({ obj, amp, rot, seed: BOIL.length * 7.31 + 1 }); return obj; }
// applyBoil before the render, clearBoil right after it, so poses set elsewhere never drift
export function applyBoil(step) {
  for (const b of BOIL) {
    const r = k => (h3(step, b.seed, k) - 0.5) * 2;
    b.off = [r(1) * b.amp, r(2) * b.amp * 0.5, r(3) * b.amp, r(4) * b.rot, r(5) * b.rot, r(6) * b.rot];
    const [a, c, d, e, f, g] = b.off;
    b.obj.position.x += a; b.obj.position.y += c; b.obj.position.z += d; b.obj.rotation.x += e; b.obj.rotation.y += f; b.obj.rotation.z += g;
  }
}
export function clearBoil() {
  for (const b of BOIL) {
    if (!b.off) continue; const [a, c, d, e, f, g] = b.off;
    b.obj.position.x -= a; b.obj.position.y -= c; b.obj.position.z -= d; b.obj.rotation.x -= e; b.obj.rotation.y -= f; b.obj.rotation.z -= g; b.off = null;
  }
}
```

### `engine/toy.js`

```js
// Toy characters: smooth vinyl-toy people with big heads and PAINTED faces (a canvas decal on the head's front, redrawn
// only when the expression changes), stubby limbs, mitten hands. Engine file.
//   const P = makeToon({ kind: 'kid'|'adult'|'granny'|'raccoon', skin, top: 'hoodie'|'polo'|'cardigan'|'suit', topC, pants, shoes,
//                        hat: 'beanie'|'cap'|null, hatC, hair, hairStyle: 'tufts'|'short'|'bun'|null, glasses, h })
//   poseToon(P, { x, y, z, yaw, lean, tilt, bob, sq, walk, bend, twist, head: [pitch, yaw, roll],
//                 armL: { f, o, tw, e, hand: 'open'|'fist'|'point'|'hold' }, armR, legL, legR,
//                 face: { eyes: 'open'|'half'|'happy'|'shut'|'wide'|'angry'|'sad'|'dizzy', gaze: [x, y], brow: [raise, tilt],
//                         mouth: 'smile'|'grin'|'o'|'open'|'scream'|'flat'|'frown'|'smirk'|'teeth'|'a'|'e', blush, sweat } })
//   talk(film, t, rest) → a talking mouth for whatever the voice says at t   ·   blink(t, seed) → true on blink frames
//   handPos(P, i) → world position of hand i (0 = left, 1 = right)
// The character faces +z, its left hand is at +x, its feet at the root's origin. Heights (h = 1): ~1.45 for 'kid'.
import * as THREE from 'three';
import { clayMat, blob, capsule, lathe, roll, rbox, mesh } from './clay.js';

const V = (x, y, z) => new THREE.Vector3(x, y, z);
export const toyMat = (c, o = {}) => clayMat(c, { rough: 0.58, ...o });
const shade = (hex, k) => { const c = new THREE.Color(hex); c.multiplyScalar(k); return '#' + c.getHexString(); };

// ---------------- the painted face ----------------
// a patch of the head ellipsoid: phi 1.8 rad around the front, theta 1.44 rad from just above the brows to the chin.
// canvas 640 × 512 → ~355 px per radian both ways, so a circle drawn on it stays round on the head.
const FW = 640, FH = 512, PHI = 1.8, THETA = 1.44, TH0 = Math.PI / 2 - 0.78;
const PX = FW / PHI;   // px per radian
function facePatch(rx, ry, rz) {
  const g = new THREE.SphereGeometry(1, 64, 48, Math.PI / 2 - PHI / 2, PHI, TH0, THETA);
  g.scale(rx * 1.012, ry * 1.012, rz * 1.012);
  return g;
}
function drawFace(g, F, st) {
  g.clearRect(0, 0, FW, FH);
  const cx = FW / 2;   // eye line eyeUp rad above the head's equator, mouth mouthDown rad below it
  const EY = (0.78 - (F.eyeUp ?? 0.1)) * PX, MY = (0.78 + (F.mouthDown ?? 0.36)) * PX;
  const ex = (F.eyeGap ?? 0.27) * PX, erx = (F.eyeR ?? 0.13) * PX, ery = erx * (F.eyeTall ?? 1.18);
  const ink = F.ink || '#2b1a12', skin = F.skin;
  const eyes = st.eyes || 'open', gz = st.gaze || [0, 0], br = st.brow || [0, 0];
  // raccoon mask / extras under the eyes
  if (F.mask) { g.fillStyle = F.mask; g.beginPath(); g.ellipse(cx, EY + ery * 0.15, ex + erx * 2.0, ery * 1.45, 0, 0, Math.PI * 2); g.fill(); }
  // blush
  const bl = st.blush ?? F.blush ?? 0.55;
  if (bl > 0) for (const s of [-1, 1]) { const gr = g.createRadialGradient(cx + s * (ex + erx * 0.35), EY + ery * 1.25, 2, cx + s * (ex + erx * 0.35), EY + ery * 1.25, erx * 1.0); gr.addColorStop(0, `rgba(240,110,120,${0.55 * bl})`); gr.addColorStop(1, 'rgba(240,110,120,0)'); g.fillStyle = gr; g.fillRect(0, 0, FW, FH); }
  if (F.freckles) { g.fillStyle = 'rgba(150,80,50,0.55)'; for (const s of [-1, 1]) for (let k = 0; k < 4; k++) { g.beginPath(); g.arc(cx + s * (ex + erx * (0.0 + (k % 2) * 0.45)), EY + ery * (1.1 + (k > 1 ? 0.25 : 0)), 3.2, 0, 7); g.fill(); } }
  g.lineCap = 'round'; g.lineJoin = 'round';
  for (const s of [-1, 1]) {
    const x = cx + s * ex, y = EY;
    if (eyes === 'happy') { g.strokeStyle = ink; g.lineWidth = erx * 0.24; g.beginPath(); g.arc(x, y + ery * 0.35, erx * 0.75, Math.PI * 1.12, Math.PI * 1.88); g.stroke(); continue; }
    if (eyes === 'shut') { g.strokeStyle = ink; g.lineWidth = erx * 0.2; g.beginPath(); g.arc(x, y - ery * 0.35, erx * 0.75, Math.PI * 0.15, Math.PI * 0.85); g.stroke(); continue; }
    if (eyes === 'dizzy') { g.strokeStyle = ink; g.lineWidth = erx * 0.14; g.beginPath(); for (let a = 0; a < 14; a += 0.2) { const r = a * erx * 0.06; g.lineTo(x + Math.cos(a * s) * r, y + Math.sin(a * s) * r); } g.stroke(); continue; }
    const wide = eyes === 'wide' ? 1.18 : 1;
    // white
    g.fillStyle = '#fbfbf7'; g.beginPath(); g.ellipse(x, y, erx * wide, ery * wide, 0, 0, Math.PI * 2); g.fill();
    // pupil (follows the gaze)
    const pr = erx * (eyes === 'wide' ? 0.42 : 0.58), px = x + gz[0] * erx * 0.38, py = y - gz[1] * ery * 0.3 + ery * 0.08;
    g.save(); g.beginPath(); g.ellipse(x, y, erx * wide, ery * wide, 0, 0, Math.PI * 2); g.clip();
    g.fillStyle = F.iris || '#1c130e'; g.beginPath(); g.ellipse(px, py, pr, pr * 1.12, 0, 0, Math.PI * 2); g.fill();
    g.fillStyle = 'rgba(255,255,255,0.95)'; g.beginPath(); g.arc(px + pr * 0.35, py - pr * 0.42, pr * 0.3, 0, 7); g.fill();
    g.beginPath(); g.arc(px - pr * 0.3, py + pr * 0.35, pr * 0.12, 0, 7); g.fill();
    // lids: half / angry / sad cover the top of the eye in skin
    let lid = eyes === 'half' ? 0.48 : eyes === 'angry' || eyes === 'sad' ? 0.36 : st.lid || 0;
    if (lid > 0) {
      const slant = eyes === 'angry' ? -0.45 * s : eyes === 'sad' ? 0.45 * s : 0;
      g.fillStyle = skin; g.beginPath(); const ly = y - ery * wide + lid * 2 * ery * wide;
      g.moveTo(x - erx * 1.6, y - ery * 2); g.lineTo(x + erx * 1.6, y - ery * 2); g.lineTo(x + erx * 1.6, ly - slant * erx * 1.0); g.lineTo(x - erx * 1.6, ly + slant * erx * 1.0); g.closePath(); g.fill();
      g.strokeStyle = ink; g.lineWidth = erx * 0.13; g.beginPath(); g.moveTo(x - erx * 1.02, ly + slant * erx * 0.62); g.lineTo(x + erx * 1.02, ly - slant * erx * 0.62); g.stroke();
    }
    g.restore();
    g.strokeStyle = ink; g.lineWidth = erx * 0.1; g.beginPath(); g.ellipse(x, y, erx * wide, ery * wide, 0, 0, Math.PI * 2); g.stroke();
    if (F.lashes) { g.lineWidth = erx * 0.09; for (const k of [0, 1]) { const ax = x + s * erx * (0.82 - k * 0.22), ay = y - ery * (0.6 + k * 0.25); g.beginPath(); g.moveTo(ax, ay); g.lineTo(ax + s * erx * 0.3, ay - erx * 0.2); g.stroke(); } }
  }
  // brows
  if (F.brows !== false) {
    g.strokeStyle = F.browC || ink; g.lineWidth = erx * (F.browW ?? 0.22);
    for (const s of [-1, 1]) {
      const x = cx + s * ex, y = EY - ery * (eyes === 'wide' ? 1.55 : 1.38) - br[0] * ery * 0.45;
      const tl = (eyes === 'angry' ? 0.5 : eyes === 'sad' ? -0.45 : 0) + br[1];
      const xi = x - s * erx * 0.8, xo = x + s * erx * 0.8;   // inner end (near the nose), outer end
      g.beginPath(); g.moveTo(xi, y + tl * erx * 0.45); g.quadraticCurveTo(x, y - erx * 0.22, xo, y - tl * erx * 0.45); g.stroke();
    }
  }
  // mouth
  const m = st.mouth || 'smile', mw = (F.mouthW ?? 0.26) * PX, mx = cx + (st.mouthX || 0) * PX;
  g.strokeStyle = F.lip || '#5a2618'; g.fillStyle = '#5a1c1c'; g.lineWidth = erx * 0.16;
  const tongue = '#e26d6a', teeth = '#fbfaf5';
  const openShape = (w, h, top, flatTop) => {
    g.beginPath();
    if (flatTop) { g.moveTo(mx - w, top); g.quadraticCurveTo(mx, top + h * 0.12, mx + w, top); g.bezierCurveTo(mx + w * 0.9, top + h * 1.25, mx - w * 0.9, top + h * 1.25, mx - w, top); }
    else g.ellipse(mx, top + h / 2, w, h / 2, 0, 0, Math.PI * 2);
    g.closePath(); g.fillStyle = '#5a1c1c'; g.fill();
    g.save(); g.clip();
    g.fillStyle = tongue; g.beginPath(); g.ellipse(mx, top + h * 1.0, w * 0.7, h * 0.42, 0, 0, Math.PI * 2); g.fill();
    if (flatTop || h > 40) { g.fillStyle = teeth; g.fillRect(mx - w, top - 4, w * 2, h * 0.2 + 4); }
    g.restore();
    g.strokeStyle = F.lip || '#5a2618'; g.lineWidth = erx * 0.1; g.stroke();
  };
  if (m === 'smile') { g.beginPath(); g.arc(mx, MY - mw * 0.75, mw * 1.05, Math.PI * 0.28, Math.PI * 0.72); g.stroke(); }
  else if (m === 'smirk') { g.beginPath(); g.moveTo(mx - mw * 0.55, MY + 4); g.quadraticCurveTo(mx + mw * 0.15, MY + 10, mx + mw * 0.7, MY - 10); g.stroke(); }
  else if (m === 'flat') { g.beginPath(); g.moveTo(mx - mw * 0.45, MY + 2); g.lineTo(mx + mw * 0.45, MY + 2); g.stroke(); }
  else if (m === 'frown') { g.beginPath(); g.arc(mx, MY + mw * 0.95, mw * 0.9, Math.PI * 1.3, Math.PI * 1.7); g.stroke(); }
  else if (m === 'grin') openShape(mw * 0.9, mw * 0.8, MY - mw * 0.18, true);
  else if (m === 'teeth') { g.fillStyle = teeth; g.beginPath(); g.roundRect(mx - mw * 0.7, MY - mw * 0.18, mw * 1.4, mw * 0.42, 8); g.fill(); g.lineWidth = erx * 0.09; g.stroke(); g.beginPath(); g.moveTo(mx - mw * 0.7, MY + 0.03 * mw); g.lineTo(mx + mw * 0.7, MY + 0.03 * mw); for (let k = -2; k <= 2; k++) { g.moveTo(mx + k * mw * 0.24, MY - mw * 0.18); g.lineTo(mx + k * mw * 0.24, MY + mw * 0.24); } g.stroke(); }
  else if (m === 'o') openShape(mw * 0.26, mw * 0.36, MY - mw * 0.12, false);
  else if (m === 'a') openShape(mw * 0.42, mw * 0.5, MY - mw * 0.16, true);
  else if (m === 'e') openShape(mw * 0.5, mw * 0.28, MY - mw * 0.1, true);
  else if (m === 'open') openShape(mw * 0.55, mw * 0.72, MY - mw * 0.2, false);
  else if (m === 'scream') openShape(mw * 0.62, mw * 1.15, MY - mw * 0.3, false);
  // sweat drop
  if (st.sweat) { const x = cx + ex + erx * 1.7, y = EY - ery * 0.6 + (st.sweat % 1) * 20; g.fillStyle = '#9fd8ff'; g.strokeStyle = '#2c6fa8'; g.lineWidth = 4; g.beginPath(); g.moveTo(x, y - 26); g.quadraticCurveTo(x + 16, y, x, y + 12); g.quadraticCurveTo(x - 16, y, x, y - 26); g.fill(); g.stroke(); }
}
function makeFace(head, rx, ry, rz, F) {
  const cv = document.createElement('canvas'); cv.width = FW; cv.height = FH; const g = cv.getContext('2d');
  const tex = new THREE.CanvasTexture(cv); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 8;
  const m = new THREE.MeshStandardMaterial({ map: tex, transparent: true, roughness: 0.5, metalness: 0, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -4 });
  const patch = new THREE.Mesh(facePatch(rx, ry, rz), m); patch.renderOrder = 2; head.add(patch);
  let key = '';
  return {
    patch, set(st) { const k = JSON.stringify(st); if (k === key) return; key = k; drawFace(g, F, st); tex.needsUpdate = true; },
  };
}

// ---------------- the body ----------------
export function makeToon(o = {}) {
  const kind = o.kind || 'kid', H = o.h ?? 1;
  const skin = o.skin || '#f0c09a';
  const M = {
    skin: toyMat(skin, { rough: 0.5 }), top: toyMat(o.topC || '#e8763a', { rough: 0.7 }), top2: toyMat(o.topC2 || shade(o.topC || '#e8763a', 0.85), { rough: 0.7 }),
    pants: toyMat(o.pants || '#3d66b8', { rough: 0.72 }), shoe: toyMat(o.shoes || '#f6f4ef', { rough: 0.45 }), sole: toyMat(o.sole || '#d9d4cc', { rough: 0.6 }),
    hair: toyMat(o.hair || '#6a3f24', { rough: 0.55 }), hat: toyMat(o.hatC || '#f2e9d8', { rough: 0.75 }), hat2: toyMat(o.hatC2 || shade(o.hatC || '#f2e9d8', 0.9), { rough: 0.75 }),
    white: toyMat('#f7f5f0', { rough: 0.5 }), acc: toyMat(o.acc || '#d9473a', { rough: 0.5 }),
  };
  const P = { o, M, H, kind };
  const root = new THREE.Group(); P.root = root;
  const body = new THREE.Group(); root.add(body); P.body = body; body.scale.setScalar(H);
  const raccoon = kind === 'raccoon';
  const legLen = raccoon ? 0.22 : kind === 'granny' ? 0.3 : 0.36, hipY = legLen + 0.06;
  // legs + shoes
  P.legs = [1, -1].map(s => {
    const piv = new THREE.Group(); piv.position.set(s * 0.09, hipY, 0); body.add(piv);
    const leg = capsule(raccoon ? 0.06 : 0.075, legLen - 0.06, raccoon ? M.top : M.pants, { lump: 0 }); leg.position.y = -legLen / 2; piv.add(leg);
    const shoe = blob(0.088, 0.062, 0.13, M.shoe, { lump: 0, seg: 28 }); shoe.position.set(0, -hipY + 0.06, 0.035); piv.add(shoe);
    const sole = blob(0.09, 0.022, 0.132, M.sole, { lump: 0, seg: 20 }); sole.position.set(0, -hipY + 0.022, 0.035); piv.add(sole);
    if (!raccoon && kind !== 'granny') { const cuff = capsule(0.081, 0.02, M.pants, { lump: 0 }); cuff.rotation.z = Math.PI / 2; cuff.scale.set(1, 1, 1); cuff.position.y = -legLen + 0.07; }
    return piv;
  });
  // torso
  const torso = new THREE.Group(); torso.position.y = hipY - 0.02; body.add(torso); P.torso = torso;
  const tw = raccoon ? 0.17 : kind === 'adult' ? 0.215 : 0.2, tH = raccoon ? 0.36 : kind === 'adult' ? 0.5 : 0.44; P.tH = tH;
  const prof = [[0, -0.03], [tw * 0.86, -0.025], [tw * 1.0, 0.04], [tw * 1.05, tH * 0.35], [tw * 1.02, tH * 0.6], [tw * 0.9, tH * 0.85], [tw * 0.6, tH * 0.99], [0, tH * 1.02]];
  const chest = lathe(prof, M.top, { lump: 0, seg: 40 }); chest.scale.z = 0.86; torso.add(chest);
  const top = o.top || 'hoodie';
  if (top === 'hoodie') {
    const hood = roll([V(-tw * 0.62, tH * 0.92, -0.02), V(-tw * 0.45, tH * 1.02, -0.12), V(0, tH * 1.06, -0.16), V(tw * 0.45, tH * 1.02, -0.12), V(tw * 0.62, tH * 0.92, -0.02)], 0.065, M.top2, { lump: 0 }); torso.add(hood);
    const pocket = blob(tw * 0.66, tH * 0.15, 0.03, M.top2, { lump: 0 }); pocket.position.set(0, tH * 0.27, tw * 0.86); pocket.rotation.x = -0.1; torso.add(pocket);
    for (const s of [1, -1]) { const st = roll([V(s * 0.045, tH * 0.92, tw * 0.62), V(s * 0.05, tH * 0.78, tw * 0.82), V(s * 0.046, tH * 0.68, tw * 0.86)], 0.011, M.white, { lump: 0 }); torso.add(st); }
    const hem = roll([...Array(25)].map((_, i) => { const a = i / 24 * Math.PI * 2; return V(Math.sin(a) * tw * 1.0, 0.02, Math.cos(a) * tw * 0.86); }), 0.03, M.top2, { closed: true, lump: 0 }); torso.add(hem);
  } else if (top === 'polo') {
    for (const s of [1, -1]) { const c = blob(0.075, 0.03, 0.06, M.top2, { lump: 0 }); c.position.set(s * 0.06, tH * 0.97, tw * 0.5); c.rotation.set(0.5, 0, s * 0.5); torso.add(c); }
    for (let k = 0; k < 2; k++) { const b = blob(0.012, 0.012, 0.008, M.white, { lump: 0 }); b.position.set(0, tH * (0.86 - k * 0.09), tw * 0.83); torso.add(b); }
    const belt = roll([...Array(25)].map((_, i) => { const a = i / 24 * Math.PI * 2; return V(Math.sin(a) * tw * 1.02, 0.04, Math.cos(a) * tw * 0.88); }), 0.024, toyMat('#5a3a24'), { closed: true, lump: 0 }); torso.add(belt);
    const buckle = rbox(0.06, 0.045, 0.02, 0.01, toyMat('#d9b64a', { rough: 0.3 })); buckle.position.set(0, 0.04, tw * 0.9); torso.add(buckle);
  } else if (top === 'cardigan') {
    const skirt = lathe([[0, -0.02], [tw * 1.5, -0.2], [tw * 1.42, -0.16], [tw * 1.1, tH * 0.2], [0, tH * 0.2]], toyMat(o.skirt || '#e98fae', { rough: 0.7 }), { lump: 0 }); skirt.scale.z = 0.88; torso.add(skirt);
    for (let k = 0; k < 3; k++) { const b = blob(0.017, 0.017, 0.01, M.white, { lump: 0 }); b.position.set(0, tH * (0.82 - k * 0.15), tw * 0.86 - k * 0.005); torso.add(b); }
    const col = roll([V(-tw * 0.55, tH * 0.96, tw * 0.25), V(0, tH * 0.86, tw * 0.62), V(tw * 0.55, tH * 0.96, tw * 0.25)], 0.022, M.white, { lump: 0 }); torso.add(col);
    if (o.apron !== false) { const ap = blob(tw * 0.7, tH * 0.34, 0.02, toyMat('#fbf6ee'), { lump: 0 }); ap.position.set(0, tH * 0.18, tw * 0.95); torso.add(ap); }
  } else if (top === 'suit') {
    const shirt = blob(tw * 0.42, tH * 0.32, 0.02, M.white, { lump: 0 }); shirt.position.set(0, tH * 0.72, tw * 0.84); torso.add(shirt);
    const tie = rbox(0.045, tH * 0.42, 0.016, 0.01, M.acc); tie.position.set(0, tH * 0.62, tw * 0.9); tie.rotation.x = -0.12; torso.add(tie);
    const knot = blob(0.03, 0.026, 0.02, M.acc, { lump: 0 }); knot.position.set(0, tH * 0.88, tw * 0.82); torso.add(knot);
    for (const s of [1, -1]) { const lp = rbox(0.05, tH * 0.42, 0.02, 0.01, M.top2); lp.position.set(s * 0.06, tH * 0.7, tw * 0.86); lp.rotation.set(-0.1, 0, s * 0.35); torso.add(lp); }
    for (let k = 0; k < 2; k++) { const b = blob(0.014, 0.014, 0.008, toyMat('#d9b64a', { rough: 0.3 }), { lump: 0 }); b.position.set(0.03, tH * (0.38 - k * 0.12), tw * 0.95); torso.add(b); }
  }
  // arms
  const shY = tH * 0.86, armR = raccoon ? 0.045 : 0.058, up = raccoon ? 0.09 : 0.13, fo = raccoon ? 0.085 : 0.12;
  P.arms = [1, -1].map(s => {
    const sh = new THREE.Group(); sh.position.set(s * tw * 0.98, shY, 0); torso.add(sh);
    const cap = blob(armR * 1.3, armR * 1.25, armR * 1.25, M.top, { lump: 0 }); sh.add(cap);
    const ua = capsule(armR, up, M.top, { lump: 0 }); ua.position.y = -up / 2 - 0.02; sh.add(ua);
    const el = new THREE.Group(); el.position.y = -up - 0.04; sh.add(el);
    const sleeve = o.sleeves === 'short' ? M.skin : M.top;
    const fa = capsule(armR * 0.94, fo, sleeve, { lump: 0 }); fa.position.y = -fo / 2 - 0.01; el.add(fa);
    if (top === 'hoodie' || top === 'cardigan') { const cuff = capsule(armR * 1.08, 0.025, M.top2, { lump: 0 }); cuff.position.y = -fo - 0.01; el.add(cuff); }
    const hand = new THREE.Group(); hand.position.y = -fo - 0.06; el.add(hand);
    const palm = blob(armR * 1.18, armR * 1.25, armR * 1.0, raccoon ? toyMat('#3a3a42') : M.skin, { lump: 0, seg: 24 }); hand.add(palm);
    const thumb = capsule(armR * 0.42, armR * 0.6, raccoon ? toyMat('#3a3a42') : M.skin, { lump: 0 }); thumb.position.set(-s * armR * 0.0 + 0, 0.0, armR * 0.85); thumb.rotation.x = 0.9; hand.add(thumb);
    const finger = capsule(armR * 0.48, armR * 0.9, raccoon ? toyMat('#3a3a42') : M.skin, { lump: 0 }); finger.position.set(0, -armR * 1.1, armR * 0.2); finger.visible = false; hand.add(finger);
    return { sh, el, hand, palm, thumb, finger, s };
  });
  // head
  const headP = new THREE.Group(); headP.position.y = tH * 0.98; torso.add(headP); P.headP = headP;
  const neck = capsule(0.06, 0.04, M.skin, { lump: 0 }); neck.position.y = 0.02; headP.add(neck);
  const hr = (o.head ?? 1) * (raccoon ? 0.21 : kind === 'adult' ? 0.255 : kind === 'granny' ? 0.25 : 0.27);
  const rx = hr * (o.headW ?? 1.0), ry = hr * (o.headH ?? 1.02), rz = hr * 0.96; P.hr = hr;
  const head = new THREE.Group(); head.position.y = ry * 0.92; headP.add(head); P.head = head;
  const skull = blob(rx, ry, rz, raccoon ? toyMat(o.fur || '#8d8d97', { rough: 0.7 }) : M.skin, { lump: 0, seg: 64 }); head.add(skull); P.skull = skull;
  const F = { skin: raccoon ? (o.fur || '#8d8d97') : skin, ink: o.ink, iris: o.iris, eyeR: o.eyeR, eyeGap: o.eyeGap, eyeUp: o.eyeUp, mouthDown: o.mouthDown, mouthW: o.mouthW, blush: o.blush, mask: o.mask, freckles: o.freckles, lashes: o.lashes, browC: o.browC || (o.hair ? shade(o.hair, 0.8) : undefined), browW: o.browW, brows: o.brows, lip: o.lip };
  P.face = makeFace(head, rx, ry, rz, F);
  // nose + ears
  if (raccoon) {
    const snout = blob(hr * 0.42, hr * 0.32, hr * 0.42, toyMat(o.muzzle || '#e6e1da', { rough: 0.6 }), { lump: 0 }); snout.position.set(0, -hr * 0.3, rz * 0.78); head.add(snout);
    const nose = blob(hr * 0.13, hr * 0.1, hr * 0.1, toyMat('#1f1c22', { rough: 0.3 }), { lump: 0 }); nose.position.set(0, -hr * 0.18, rz * 1.18); head.add(nose); P.nose = nose;
    for (const s of [1, -1]) { const ear = mesh(new THREE.ConeGeometry(hr * 0.3, hr * 0.5, 20), toyMat(o.fur || '#8d8d97', { rough: 0.7 })); ear.position.set(s * rx * 0.62, ry * 0.86, -0.02); ear.rotation.z = -s * 0.35; head.add(ear); const tip = mesh(new THREE.ConeGeometry(hr * 0.16, hr * 0.22, 16), toyMat('#3a3a42')); tip.position.set(s * rx * 0.72, ry * 1.06, -0.02); tip.rotation.z = -s * 0.35; head.add(tip); }
    // striped tail
    const tail = new THREE.Group(); tail.position.set(0, 0.06, -tw * 0.85); torso.add(tail); P.tail = tail;
    for (let k = 0; k < 6; k++) { const b = blob(0.07 - k * 0.004, 0.07 - k * 0.004, 0.06, toyMat(k % 2 ? '#3a3a42' : (o.fur || '#8d8d97'), { rough: 0.75 }), { lump: 0 }); b.position.set(0, k * 0.035, -k * 0.065); tail.add(b); }
  } else {
    const nose = blob(hr * (o.noseR ?? 0.15), hr * (o.noseR ?? 0.15) * 0.92, hr * (o.noseR ?? 0.15) * 0.9, toyMat(shade(skin, 0.94), { rough: 0.45 }), { lump: 0 }); nose.position.set(0, -hr * 0.1, rz * 0.98); head.add(nose); P.nose = nose;
    for (const s of [1, -1]) { const ear = blob(hr * 0.17, hr * 0.24, hr * 0.12, M.skin, { lump: 0 }); ear.position.set(s * rx * 0.97, -hr * 0.05, -0.02); ear.rotation.y = s * 0.3; head.add(ear); }
  }
  // hair
  const hs = o.hairStyle;
  if (hs === 'tufts' || hs === 'short') {
    const capG = new THREE.SphereGeometry(1, 48, 24, 0, Math.PI * 2, 0, Math.PI * (hs === 'short' ? 0.42 : 0.45)); capG.scale(rx * 1.04, ry * 1.04, rz * 1.06);
    const cap = mesh(capG, M.hair); cap.rotation.x = -0.32; cap.position.z = -0.01; head.add(cap);
    if (hs === 'tufts') for (const [th, ph, l] of [[0.55, 0.3, 1], [0.5, -0.35, 0.9], [0.35, 0.0, 1.1], [0.62, 0.9, 0.8], [0.62, -0.95, 0.8]]) {
      const n = V(Math.sin(th) * Math.sin(ph), Math.cos(th), Math.sin(th) * Math.cos(ph));
      const t = blob(0.06, 0.04, 0.09 * l, M.hair, { lump: 0 }); t.position.set(n.x * rx * 1.02, n.y * ry * 1.02, n.z * rz * 1.04); t.lookAt(t.position.clone().multiplyScalar(2).add(V(0, 0.2, 0.25))); head.add(t);
    }
  }
  if (hs === 'bun') {
    const capG = new THREE.SphereGeometry(1, 48, 24, 0, Math.PI * 2, 0, Math.PI * 0.5); capG.scale(rx * 1.05, ry * 1.05, rz * 1.07);
    const cap = mesh(capG, M.hair); cap.rotation.x = -0.4; head.add(cap);
    const bun = blob(hr * 0.42, hr * 0.38, hr * 0.42, M.hair, { lump: 0 }); bun.position.set(0, ry * 1.0, -rz * 0.25); head.add(bun);
    for (const s of [1, -1]) { const side = blob(hr * 0.22, hr * 0.32, hr * 0.28, M.hair, { lump: 0 }); side.position.set(s * rx * 0.9, ry * 0.12, -rz * 0.1); head.add(side); }
  }
  if (o.hat === 'beanie') {
    const bc = new THREE.SphereGeometry(1, 48, 24, 0, Math.PI * 2, 0, Math.PI * 0.5); bc.scale(rx * 1.05, ry * 1.02, rz * 1.06);
    const b = mesh(bc, M.hat); b.rotation.x = -0.3; b.position.set(0, ry * 0.3, -0.03); head.add(b);
    const rib = roll([...Array(33)].map((_, i) => { const a = i / 32 * Math.PI * 2; return V(Math.cos(a) * rx * 1.06, 0, Math.sin(a) * rz * 1.08); }), hr * 0.1, M.hat2, { closed: true, lump: 0 }); rib.position.set(0, ry * 0.36, -0.03); rib.rotation.x = -0.3; head.add(rib);
    const pom = blob(hr * 0.26, hr * 0.24, hr * 0.26, toyMat(o.pom || '#e8763a', { rough: 0.85 }), { lump: 0 }); pom.position.set(0, ry * 1.32, -rz * 0.3); head.add(pom);
    // hair sticking out under it
    for (const s of [1, -1]) { const t = blob(hr * 0.16, hr * 0.12, hr * 0.2, M.hair, { lump: 0 }); t.position.set(s * rx * 0.9, ry * 0.3, -rz * 0.12); t.rotation.z = s * 0.5; head.add(t); }
  }
  if (o.hat === 'cap') {
    const cc = new THREE.SphereGeometry(1, 48, 24, 0, Math.PI * 2, 0, Math.PI * 0.5); cc.scale(rx * 1.06, ry * 0.86, rz * 1.07);
    const c = mesh(cc, M.hat); c.position.y = ry * 0.34; c.rotation.x = -0.12; head.add(c);
    const brim = blob(rx * 0.8, 0.02, rz * 0.55, M.hat2, { lump: 0 }); brim.position.set(0, ry * 0.42, rz * 0.98); brim.rotation.x = -0.1; head.add(brim);
    const btn = blob(0.03, 0.02, 0.03, M.hat2, { lump: 0 }); btn.position.set(0, ry * 1.2, -0.02); head.add(btn);
  }
  if (o.glasses) {
    const gm = toyMat(o.glasses, { rough: 0.35 });
    for (const s of [1, -1]) { const r = mesh(new THREE.TorusGeometry(hr * 0.27, hr * 0.035, 10, 32), gm); r.position.set(s * hr * 0.29, hr * 0.1, rz * 1.02); r.rotation.y = s * 0.28; head.add(r); }
    const br = roll([V(-hr * 0.04, hr * 0.13, rz * 1.12), V(hr * 0.04, hr * 0.13, rz * 1.12)], hr * 0.03, gm, { lump: 0 }); head.add(br);
  }
  root.traverse(m => { if (m.isMesh && m !== P.face.patch) { m.castShadow = true; m.receiveShadow = true; } });
  P.face.patch.castShadow = false; P.face.patch.receiveShadow = true;
  poseToon(P, {});
  return P;
}

function poseHand(A, kind) {
  A.finger.visible = kind === 'point';
  A.palm.scale.set(1, kind === 'fist' || kind === 'point' || kind === 'hold' ? 0.9 : 1.05, kind === 'open' ? 0.75 : 1);
  A.thumb.rotation.x = kind === 'open' ? 0.4 : 0.9;
}
export function poseToon(P, s) {
  const r = P.root;
  r.position.set(s.x ?? 0, s.y ?? 0, s.z ?? 0); r.rotation.set(0, s.yaw ?? 0, 0);
  const sq = s.sq ?? 0; P.body.scale.set(P.H * (1 + sq * 0.5), P.H * (1 - sq), P.H * (1 + sq * 0.5));
  P.body.rotation.set(s.lean ?? 0, 0, s.tilt ?? 0);
  const ph = s.walk, walking = ph != null, sw = walking ? Math.sin(ph) : 0;
  P.body.position.y = (walking ? Math.abs(Math.cos(ph)) * 0.035 : 0) + (s.bob ?? 0);
  P.legs[0].rotation.set((s.legL ?? 0) + sw * 0.55, 0, s.legLo ?? 0); P.legs[1].rotation.set((s.legR ?? 0) - sw * 0.55, 0, s.legRo ?? 0);
  P.torso.rotation.set(s.bend ?? 0, s.twist ?? 0, s.torsoTilt ?? 0);
  P.arms.forEach((a, i) => {
    const A = (i === 0 ? s.armL : s.armR) || {}, sgn = a.s, swing = walking ? (i === 0 ? -sw : sw) * 0.45 : 0;
    a.sh.rotation.set(-(A.f ?? 0) - swing, (A.tw ?? 0) * sgn, sgn * (0.14 + (A.o ?? 0)), 'XZY');
    a.el.rotation.set(-(A.e ?? 0.2), 0, 0);
    a.hand.rotation.set(...(A.hr || [0, 0, 0]));
    poseHand(a, A.hand || 'relax');
  });
  const hd = s.head || [0, 0, 0]; P.headP.rotation.set(hd[0], hd[1], hd[2] ?? 0);
  if (P.tail) P.tail.rotation.set(0.5 + (s.tail ?? 0), Math.sin((s.t ?? 0) * 3) * 0.2, 0);
  P.face.set({ eyes: 'open', gaze: [0, 0], brow: [0, 0], mouth: 'smile', ...(s.face || {}) });
}
// lip sync: open/close on the voice's words (changes every 1/12 s so it reads as talking, not jitter)
export function talk(film, t, rest = 'smile') {
  const w = film.words.find(w => t >= w.t0 - 0.02 && t < w.t1 - 0.03); if (!w) return rest;
  const k = (Math.floor(t * 12) * 7 + w.i * 3) % 5; return ['a', 'e', 'o', 'a', 'flat'][k];
}
export const blink = (t, seed = 0) => { const per = 2.4 + (seed % 3) * 0.8, ph = ((t + seed * 1.37) % per + per) % per; return ph < 0.1; };
export function handPos(P, i, off = [0, -0.05, 0.02]) { P.root.updateMatrixWorld(true); return P.arms[i].hand.localToWorld(new THREE.Vector3(...off)); }
```

### `engine/yard.js`

```js
// The toy suburb: a sunny sky with puffy clouds, a mowed lawn with wide stripes, white houses with orange-red roofs and
// blue shutters, white picket fences, a big lumpy tree, a patio with a kettle grill, and the little props the stories
// need (ladder, bench, garden table, phone, laptop, microwave, robot, toy chest, signs, puff clouds, confetti). Engine file.
// Every factory returns a THREE.Group at the origin (y = 0 on the ground). Units ≈ metres; a kid toy is ~1.45 tall.
import * as THREE from 'three';
import { clayMat, blob, rbox, roll, capsule, lathe, mesh, text3d, labelTex, glow, lumpy } from './clay.js';
import { toyMat } from './toy.js';

const V = (x, y, z) => new THREE.Vector3(x, y, z);
export const rnd = 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 YC = {
  lawn: '#5aac3c', lawn2: '#7ccd55', wall: '#f3f1ec', wall2: '#e9e6df', roof: '#d9603f', roof2: '#c9533a', shutter: '#3a6fc4', glass: '#2c4f86',
  fence: '#fbfaf6', trunk: '#7a5236', leaf: '#4c9a35', leaf2: '#5fae3e', leaf3: '#3f8a2e', tile: '#c98a6a', wood: '#c6935c', woodD: '#9c6c40',
  yellow: '#ffd23a', red: '#e5322d', ink: '#22202a', cream: '#f7f0dd', sky: '#4f95dc',
};
const MC = {}; const M = (k, c, o) => MC[k] || (MC[k] = toyMat(c, o));

// ---------------- sky ----------------
export function sky({ r = 160, top = '#3f86d6', mid = '#7db5e8', low = '#d4e7f4' } = {}) {
  const m = new THREE.ShaderMaterial({ side: THREE.BackSide, depthWrite: false, fog: false, uniforms: { a: { value: new THREE.Color(top) }, b: { value: new THREE.Color(mid) }, c: { value: new THREE.Color(low) } },
    vertexShader: 'varying vec3 vP; void main(){ vP = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.); }',
    fragmentShader: 'uniform vec3 a, b, c; varying vec3 vP; void main(){ float y = vP.y; vec3 col = y > 0.1 ? mix(b, a, smoothstep(0.1, 0.65, y)) : mix(c, b, smoothstep(-0.02, 0.1, y)); gl_FragColor = vec4(col, 1.); }' });
  const s = new THREE.Mesh(new THREE.SphereGeometry(r, 32, 16), m); s.renderOrder = -10; s.frustumCulled = false; return s;
}
// a puffy cumulus: flat-ish bottom, round heaps on top. Lit by the sun but never casting shadows on the town.
export function cloud({ s = 1, seed = 9, puffs = 11 } = {}) {
  const g = new THREE.Group(), R = rnd(seed), m = M('cloud', '#ffffff', { rough: 1, emissive: 0xdfe9f5, ei: 0.38 });
  for (let i = 0; i < puffs; i++) {
    const x = (R() - 0.5) * 3.2 * s, r = (0.55 + R() * 0.6) * s * (1 - Math.abs(x) / (2.6 * s) * 0.55);
    const b = blob(r * 1.15, r, r * 0.9, m, { seed: seed + i, lump: 0, cast: false, recv: false, seg: 20 }); b.position.set(x, r * 0.45 + R() * 0.3 * s, (R() - 0.5) * 0.9 * s); b.castShadow = false; b.receiveShadow = false; g.add(b);
  }
  return g;
}

// ---------------- the lawn ----------------
export function lawn({ size = 420, stripe = 2.2, dir = 0 } = {}) {
  const c = document.createElement('canvas'); c.width = 512; c.height = 512; const g = c.getContext('2d');
  for (let i = 0; i < 2; i++) { g.fillStyle = i ? YC.lawn2 : YC.lawn; g.fillRect(i * 256, 0, 256, 512); }
  const R = rnd(4); for (let k = 0; k < 9000; k++) { g.fillStyle = `rgba(${R() < 0.5 ? '30,80,20' : '220,255,170'},${0.03 + R() * 0.05})`; g.fillRect(R() * 512, R() * 512, 2, 2 + R() * 5); }
  const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; t.wrapS = t.wrapT = THREE.RepeatWrapping; t.anisotropy = 16;
  t.repeat.set(size / (stripe * 2), size / (stripe * 2)); t.rotation = dir; t.center.set(0.5, 0.5);
  const m = new THREE.MeshStandardMaterial({ map: t, roughness: 0.95, metalness: 0 });
  const p = new THREE.Mesh(new THREE.PlaneGeometry(size, size), m); p.rotation.x = -Math.PI / 2; p.receiveShadow = true;
  return p;
}
export function patio({ w = 5, d = 4, tile = 0.5, color = YC.tile } = {}) {
  const c = document.createElement('canvas'); c.width = 256; c.height = 256; const g = c.getContext('2d');
  g.fillStyle = '#b37a5c'; g.fillRect(0, 0, 256, 256); const R = rnd(7);
  for (let i = 0; i < 2; i++) for (let j = 0; j < 2; j++) { const col = new THREE.Color(color).multiplyScalar(0.92 + R() * 0.14); g.fillStyle = '#' + col.getHexString(); g.fillRect(i * 128 + 4, j * 128 + 4, 120, 120); }
  const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(w / tile / 2, d / tile / 2); t.anisotropy = 8;
  const p = rbox(w, 0.06, d, 0.02, new THREE.MeshStandardMaterial({ map: t, roughness: 0.8 }), { lump: 0 }); p.position.y = 0.03; p.receiveShadow = true;
  const gg = new THREE.Group(); gg.add(p); return gg;
}

// ---------------- houses ----------------
function gable(w, d, h, mat, over = 0.35) {   // a roof prism along x: ridge at height h, eaves over the walls
  const sh = new THREE.Shape(); sh.moveTo(-d / 2 - over, 0); sh.lineTo(0, h); sh.lineTo(d / 2 + over, 0); sh.lineTo(d / 2 + over, -0.12); sh.lineTo(0, h - 0.16); sh.lineTo(-d / 2 - over, -0.12); sh.closePath();
  const geo = new THREE.ExtrudeGeometry(sh, { depth: w + over * 2, bevelEnabled: true, bevelThickness: 0.04, bevelSize: 0.04, bevelSegments: 2 });
  geo.translate(0, 0, -(w + over * 2) / 2); geo.rotateY(Math.PI / 2);
  return mesh(geo, mat);
}
function windowUnit(w = 0.9, h = 1.15, shutter = YC.shutter) {
  const g = new THREE.Group();
  const frame = rbox(w + 0.12, h + 0.12, 0.08, 0.03, M('wframe', '#ffffff', { rough: 0.5 }), { lump: 0 }); g.add(frame);
  const glass = rbox(w - 0.04, h - 0.04, 0.04, 0.01, M('glass', YC.glass, { rough: 0.15, env: 1.4 }), { lump: 0 }); glass.position.z = 0.03; g.add(glass);
  const bar = rbox(0.05, h - 0.04, 0.05, 0.01, M('wframe', '#ffffff'), { lump: 0 }); bar.position.z = 0.05; g.add(bar);
  const bar2 = rbox(w - 0.04, 0.05, 0.05, 0.01, M('wframe', '#ffffff'), { lump: 0 }); bar2.position.set(0, h * 0.1, 0.05); g.add(bar2);
  if (shutter) for (const s of [-1, 1]) { const sh = rbox(w * 0.42, h + 0.1, 0.05, 0.02, M('shut' + shutter, shutter, { rough: 0.5 }), { lump: 0 }); sh.position.set(s * (w / 2 + w * 0.28), 0, 0.02); g.add(sh); }
  const sill = rbox(w + 0.24, 0.06, 0.16, 0.02, M('wframe', '#ffffff'), { lump: 0 }); sill.position.set(0, -h / 2 - 0.08, 0.06); g.add(sill);
  return g;
}
// a two-storey suburban house, front (door side) facing +z. door: x offset or null
export function house({ w = 7, d = 6, h = 4.6, wall = YC.wall, roof = YC.roof, shutter = YC.shutter, door = 0, doorC = '#3a6fc4', chimney = true, seed = 1, windows } = {}) {
  const g = new THREE.Group(), R = rnd(seed);
  const body = rbox(w, h, d, 0.06, M('wall' + wall, wall, { rough: 0.8 }), { lump: 0 }); body.position.y = h / 2; g.add(body);
  const base = rbox(w + 0.08, 0.3, d + 0.08, 0.03, M('base', '#d9d4c8'), { lump: 0 }); base.position.y = 0.15; g.add(base);
  const rf = gable(w, d, d * 0.42, M('roof' + roof, roof, { rough: 0.6 })); rf.position.y = h; g.add(rf);
  if (chimney) { const ch = rbox(0.6, 1.6, 0.6, 0.03, M('wall' + wall, wall), { lump: 0 }); ch.position.set(w * 0.28, h + d * 0.3, -d * 0.12); g.add(ch); const cc = rbox(0.72, 0.14, 0.72, 0.03, M('roof' + roof, roof), { lump: 0 }); cc.position.set(w * 0.28, h + d * 0.3 + 0.82, -d * 0.12); g.add(cc); }
  const wins = windows || [[-w * 0.3, h * 0.72], [0, h * 0.72], [w * 0.3, h * 0.72], [-w * 0.3, h * 0.27], [w * 0.3, h * 0.27]];
  const doorObj = { g: null };
  for (const [x, y] of wins) { if (door != null && Math.abs(x - door) < 0.8 && y < h * 0.5) continue; const wu = windowUnit(0.85, 1.05, shutter); wu.position.set(x, y, d / 2 + 0.02); g.add(wu); }
  for (const [x, y] of wins.filter(([, y]) => y > h * 0.5)) { const wu = windowUnit(0.85, 1.05, shutter); wu.position.set(x, y, -d / 2 - 0.02); wu.rotation.y = Math.PI; g.add(wu); }
  for (const s of [-1, 1]) for (const z of [-d * 0.22, d * 0.22]) { const wu = windowUnit(0.8, 1.0, shutter); wu.position.set(s * (w / 2 + 0.02), h * 0.72, z); wu.rotation.y = s * Math.PI / 2; g.add(wu); }
  if (door != null) {
    const fr = rbox(1.26, 2.36, 0.12, 0.03, M('wframe', '#ffffff'), { lump: 0 }); fr.position.set(door, 1.18 + 0.28, d / 2 + 0.02); g.add(fr);
    const hinge = new THREE.Group(); hinge.position.set(door - 0.53, 0.32, d / 2 + 0.07); g.add(hinge);
    const dr = rbox(1.06, 2.16, 0.07, 0.03, M('door' + doorC, doorC, { rough: 0.45 }), { lump: 0 }); dr.position.set(0.53, 1.08, 0); hinge.add(dr);
    for (const [py, ph] of [[1.55, 0.7], [0.62, 0.8]]) { const pn = rbox(0.78, ph, 0.03, 0.02, M('door2' + doorC, new THREE.Color(doorC).multiplyScalar(0.85).getStyle()), { lump: 0 }); pn.position.set(0.53, py, 0.04); hinge.add(pn); }
    const knob = blob(0.045, 0.045, 0.04, M('gold', '#e2b84a', { rough: 0.25 }), { lump: 0 }); knob.position.set(0.92, 1.05, 0.07); hinge.add(knob);
    const step = rbox(1.7, 0.28, 0.8, 0.03, M('base', '#d9d4c8'), { lump: 0 }); step.position.set(door, 0.14, d / 2 + 0.4); g.add(step);
    const lamp = blob(0.09, 0.13, 0.09, M('lamp', '#fff1c4', { emissive: 0xfff1c4, ei: 0.3 }), { lump: 0 }); lamp.position.set(door + 0.85, 2.15, d / 2 + 0.12); g.add(lamp);
    doorObj.g = hinge;
  }
  g.userData = { door: doorObj.g, w, d, h };
  g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
  return g;
}
// a cheap far house: one box with painted windows + a roof prism (for the rows behind the fences and the horizon)
let FAR = null;
function farMats() {
  if (FAR) return FAR;
  const c = document.createElement('canvas'); c.width = 256; c.height = 160; const g = c.getContext('2d');
  g.fillStyle = YC.wall; g.fillRect(0, 0, 256, 160);
  for (const [x, y] of [[40, 30], [110, 30], [180, 30], [40, 98], [180, 98]]) { g.fillStyle = YC.shutter; g.fillRect(x - 9, y - 3, 54, 42); g.fillStyle = '#ffffff'; g.fillRect(x - 2, y - 2, 40, 40); g.fillStyle = YC.glass; g.fillRect(x + 2, y + 2, 32, 32); }
  g.fillStyle = '#3a6fc4'; g.fillRect(112, 88, 30, 72);
  const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 8;
  FAR = { wall: new THREE.MeshStandardMaterial({ map: t, roughness: 0.85 }), side: M('wall' + YC.wall, YC.wall), roofs: [YC.roof, YC.roof2, '#e0704a', '#b9573f'].map(c => M('roof' + c, c, { rough: 0.6 })) };
  return FAR;
}
export function farHouse({ w = 6, d = 5, h = 4, seed = 1 } = {}) {
  const F = farMats(), g = new THREE.Group(), R = rnd(seed);
  const mats = [F.side, F.side, F.side, F.side, F.wall, F.wall];
  const b = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mats); b.position.y = h / 2; b.castShadow = true; b.receiveShadow = true; g.add(b);
  const rf = gable(w, d, d * 0.42, F.roofs[Math.floor(R() * F.roofs.length)], 0.3); rf.position.y = h; rf.castShadow = true; g.add(rf);
  if (R() < 0.6) { const ch = new THREE.Mesh(new THREE.BoxGeometry(0.5, 1.4, 0.5), F.side); ch.position.set(w * (R() - 0.5) * 0.5, h + d * 0.3, -d * 0.12); g.add(ch); }
  return g;
}

// ---------------- fences, trees, plants ----------------
// a white picket fence from a to b ([x, z]); returns a group. gate: skip pickets in [t0, t1] (0..1 along it)
export function fence(a, b, { h = 1.05, gap = 0.2, gate } = {}) {
  const g = new THREE.Group(), dx = b[0] - a[0], dz = b[1] - a[1], L = Math.hypot(dx, dz), n = Math.floor(L / gap);
  const picket = new THREE.BoxGeometry(0.1, h, 0.04); const tipG = new THREE.ConeGeometry(0.075, 0.12, 4); tipG.rotateY(Math.PI / 4);
  const m = M('fence', YC.fence, { rough: 0.55 });
  const im = new THREE.InstancedMesh(picket, m, n + 1), it = new THREE.InstancedMesh(tipG, m, n + 1), m4 = new THREE.Matrix4(), q = new THREE.Quaternion().setFromAxisAngle(V(0, 1, 0), -Math.atan2(dz, dx));
  let k = 0;
  for (let i = 0; i <= n; i++) { const u = i / n; if (gate && u > gate[0] && u < gate[1]) continue; const x = a[0] + dx * u, z = a[1] + dz * u; m4.compose(V(x, h / 2, z), q, V(1, 1, 1)); im.setMatrixAt(k, m4); m4.compose(V(x, h + 0.05, z), q, V(1, 1, 1)); it.setMatrixAt(k, m4); k++; }
  im.count = k; it.count = k; im.castShadow = it.castShadow = true; im.receiveShadow = true; g.add(im, it);
  for (const y of [h * 0.3, h * 0.75]) { const rail = new THREE.Mesh(new THREE.BoxGeometry(L, 0.07, 0.04), m); rail.position.set(a[0] + dx / 2, y, a[1] + dz / 2); rail.quaternion.copy(q); rail.translateZ(-0.04); rail.castShadow = true; rail.receiveShadow = true; g.add(rail); }
  return g;
}
// the big lumpy tree: flared trunk with bark streaks, a few thick branches, a canopy of round heaps
let BARK = null;
function barkTex() {
  if (BARK) return BARK;
  const c = document.createElement('canvas'); c.width = 256; c.height = 512; const g = c.getContext('2d'); const R = rnd(11);
  g.fillStyle = YC.trunk; g.fillRect(0, 0, 256, 512);
  for (let i = 0; i < 260; i++) { const x = R() * 256, w = 2 + R() * 6; g.fillStyle = `rgba(${R() < 0.5 ? '60,38,22' : '150,110,78'},${0.25 + R() * 0.3})`; g.beginPath(); g.moveTo(x, 0); for (let y = 0; y <= 512; y += 32) g.lineTo(x + Math.sin(y * 0.02 + i) * 6, y); g.lineTo(x + w, 512); g.lineTo(x + w, 0); g.fill(); }
  BARK = new THREE.CanvasTexture(c); BARK.colorSpace = THREE.SRGBColorSpace; BARK.wrapS = BARK.wrapT = THREE.RepeatWrapping; BARK.anisotropy = 8;
  return BARK;
}
export function bigTree({ h = 4.2, r = 0.55, crown = 3.6, seed = 21, leaves = [YC.leaf, YC.leaf2, YC.leaf3] } = {}) {
  const g = new THREE.Group(), R = rnd(seed);
  const bm = new THREE.MeshStandardMaterial({ map: barkTex(), roughness: 0.9 });
  const trunk = mesh(new THREE.LatheGeometry([[r * 1.6, 0], [r * 1.15, 0.25], [r, 0.7], [r * 0.92, h * 0.6], [r * 0.82, h], [r * 0.6, h + 0.6], [0.01, h + 0.9]].map(([a, b]) => new THREE.Vector2(a, b)), 28), bm); g.add(trunk);
  const branches = [];
  for (const [a, up, len] of [[0.4, 0.75, 2.6], [2.5, 0.6, 2.4], [4.3, 0.7, 2.2], [1.4, 0.95, 1.8], [5.5, 0.5, 2.0]]) {
    const p0 = V(0, h * (0.75 + R() * 0.15), 0), dir = V(Math.cos(a), up, Math.sin(a)).normalize();
    const p1 = p0.clone().addScaledVector(dir, len * 0.55).add(V(0, 0.15, 0)), p2 = p0.clone().addScaledVector(dir, len);
    const br = roll([p0, p1, p2], r * 0.3, bm, { lump: 0, radial: 10 }); g.add(br); branches.push(p2);
  }
  g.userData.branches = branches;
  const lm = leaves.map((c, i) => M('leaf' + c, c, { rough: 0.85 }));
  const heaps = [];
  for (let i = 0; i < 16; i++) {
    const a = R() * Math.PI * 2, d = Math.sqrt(R()) * crown * 0.75, y = h + 1.1 + R() * crown * 0.7 - (d / crown) * 0.8;
    const s = crown * (0.42 + R() * 0.22);
    const b = blob(s, s * 0.82, s, lm[i % 3], { seed: seed + i, lump: s * 0.6, freq: 1.6 / s, seg: 36 }); b.position.set(Math.cos(a) * d, y, Math.sin(a) * d * 0.85); g.add(b); heaps.push(b);
  }
  g.userData.heaps = heaps;
  g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
  return g;
}
export function smallTree({ h = 2.2, r = 1.1, seed = 31 } = {}) {
  const g = new THREE.Group(), R = rnd(seed);
  const tr = mesh(new THREE.CylinderGeometry(0.1, 0.15, h, 10), new THREE.MeshStandardMaterial({ map: barkTex(), roughness: 0.9 })); tr.position.y = h / 2; g.add(tr);
  for (let i = 0; i < 5; i++) { const s = r * (0.55 + R() * 0.3); const b = blob(s, s * 0.85, s, M('leaf' + YC.leaf, [YC.leaf, YC.leaf2, YC.leaf3][i % 3]), { seed: seed + i, lump: s * 0.5, freq: 1.6 / s, seg: 24 }); b.position.set((R() - 0.5) * r, h + (R() - 0.2) * r * 0.6, (R() - 0.5) * r); g.add(b); }
  g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
  return g;
}
export function bush({ r = 0.6, seed = 41 } = {}) {
  const g = new THREE.Group(), R = rnd(seed);
  for (let i = 0; i < 4; i++) { const s = r * (0.6 + R() * 0.35); const b = blob(s, s * 0.8, s, M('leaf' + YC.leaf2, [YC.leaf2, YC.leaf, YC.leaf3][i % 3]), { seed: seed + i, lump: s * 0.5, seg: 22 }); b.position.set((R() - 0.5) * r * 1.2, s * 0.7, (R() - 0.5) * r * 0.8); g.add(b); }
  g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
  return g;
}

// ---------------- yard furniture ----------------
export function grill() {   // a red kettle grill with a lid on a hinge (lid.rotation.x opens it) and three legs
  const g = new THREE.Group(), red = M('grill', '#d8352b', { rough: 0.3, env: 1.2 }), blk = M('grillB', '#2a2a30', { rough: 0.4 });
  const bowl = mesh(new THREE.SphereGeometry(0.36, 32, 16, 0, Math.PI * 2, Math.PI / 2, Math.PI / 2), red); bowl.position.y = 0.82; g.add(bowl);
  const lidP = new THREE.Group(); lidP.position.set(0, 0.82, -0.36); g.add(lidP);
  const lid = mesh(new THREE.SphereGeometry(0.37, 32, 16, 0, Math.PI * 2, 0, Math.PI / 2), red); lid.position.z = 0.36; lidP.add(lid);
  const hd = rbox(0.18, 0.04, 0.05, 0.02, blk, { lump: 0 }); hd.position.set(0, 0.4, 0.36); lidP.add(hd);
  const rack = mesh(new THREE.CylinderGeometry(0.34, 0.34, 0.02, 24), blk); rack.position.y = 0.8; g.add(rack);
  for (let i = 0; i < 3; i++) { const a = i / 3 * Math.PI * 2 + 0.3, l = mesh(new THREE.CylinderGeometry(0.02, 0.02, 0.85, 8), blk); l.position.set(Math.cos(a) * 0.2, 0.42, Math.sin(a) * 0.2); l.rotation.set(Math.sin(a) * 0.25, 0, -Math.cos(a) * 0.25); g.add(l); }
  const patties = []; for (let i = 0; i < 4; i++) { const p = blob(0.08, 0.025, 0.08, M('patty', '#6b3a22', { rough: 0.7 }), { lump: 0 }); p.position.set(-0.13 + (i % 2) * 0.26, 0.83, -0.1 + Math.floor(i / 2) * 0.2); g.add(p); patties.push(p); }
  g.userData = { lid: lidP, patties };
  g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
  return g;
}
export function table({ w = 1.4, d = 0.8, h = 0.78, color = '#ffffff', seed = 1 } = {}) {
  const g = new THREE.Group(), m = M('table' + color, color, { rough: 0.5 });
  const top = rbox(w, 0.06, d, 0.03, m, { lump: 0 }); top.position.y = h - 0.03; g.add(top);
  for (const [sx, sz] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) { const l = rbox(0.06, h - 0.06, 0.06, 0.02, m, { lump: 0 }); l.position.set(sx * (w / 2 - 0.08), (h - 0.06) / 2, sz * (d / 2 - 0.08)); g.add(l); }
  g.userData = { h };
  g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
  return g;
}
export function bench({ w = 1.7, color = YC.wood } = {}) {
  const g = new THREE.Group(), m = M('wood' + color, color, { rough: 0.6 }), mf = M('metal', '#3a3d46', { rough: 0.4 });
  for (let i = 0; i < 3; i++) { const s = rbox(w, 0.05, 0.13, 0.02, m, { lump: 0 }); s.position.set(0, 0.46, -0.16 + i * 0.15); g.add(s); }
  for (let i = 0; i < 2; i++) { const s = rbox(w, 0.13, 0.04, 0.02, m, { lump: 0 }); s.position.set(0, 0.68 + i * 0.17, -0.27); s.rotation.x = -0.12; g.add(s); }
  for (const sx of [-1, 1]) { const l = rbox(0.06, 0.46, 0.4, 0.02, mf, { lump: 0 }); l.position.set(sx * (w / 2 - 0.12), 0.23, -0.04); g.add(l); }
  g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
  return g;
}
export function lawnChair({ color = '#f2c94c' } = {}) {
  const g = new THREE.Group(), m = M('chair' + color, color, { rough: 0.5 }), w = M('wframe', '#ffffff');
  const seat = rbox(0.6, 0.06, 0.6, 0.03, m, { lump: 0 }); seat.position.y = 0.36; g.add(seat);
  const back = rbox(0.6, 0.7, 0.06, 0.03, m, { lump: 0 }); back.position.set(0, 0.68, -0.32); back.rotation.x = -0.35; g.add(back);
  for (const [sx, sz] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) { const l = rbox(0.04, 0.36, 0.04, 0.01, w, { lump: 0 }); l.position.set(sx * 0.27, 0.18, sz * 0.27); g.add(l); }
  g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
  return g;
}
export function ladder({ h = 2.6, w = 0.5 } = {}) {   // an aluminium step ladder leaning on +z side
  const g = new THREE.Group(), m = M('alu', '#c9ced8', { rough: 0.3, env: 1.3 });
  for (const s of [-1, 1]) { const r = rbox(0.05, h, 0.04, 0.01, m, { lump: 0 }); r.position.set(s * w / 2, h / 2, 0); g.add(r); }
  for (let y = 0.3; y < h - 0.1; y += 0.32) { const st = rbox(w, 0.035, 0.09, 0.01, m, { lump: 0 }); st.position.set(0, y, 0.02); g.add(st); }
  g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
  return g;
}

// ---------------- screens + story props ----------------
// a canvas you can redraw (phone screens, laptop screens, signs); returns { tex, g, cv, draw(fn) } with a key cache
export function screen(w = 512, h = 512) {
  const cv = document.createElement('canvas'); cv.width = w; cv.height = h; const g = cv.getContext('2d');
  const tex = new THREE.CanvasTexture(cv); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 8; let key = null;
  return { tex, g, cv, draw(k, fn) { if (k === key) return; key = k; g.clearRect(0, 0, w, h); fn(g, w, h); tex.needsUpdate = true; } };
}
export function phone({ w = 0.34, h = 0.62, color = '#25242c' } = {}) {
  const g = new THREE.Group();
  const body = rbox(w, h, 0.035, 0.05, M('phone' + color, color, { rough: 0.35 }), { lump: 0 }); g.add(body);
  const scr = screen(360, 660);
  const face = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.9, h * 0.92), new THREE.MeshBasicMaterial({ map: scr.tex, toneMapped: false }));
  face.position.z = 0.019; g.add(face);
  g.userData = { scr, face }; g.traverse(o => { if (o.isMesh) o.castShadow = true; });
  return g;
}
export function laptop({ w = 1.1, d = 0.75 } = {}) {
  const g = new THREE.Group(), m = M('lap', '#c7cbd3', { rough: 0.3, env: 1.2 });
  const base = rbox(w, 0.04, d, 0.03, m, { lump: 0 }); base.position.y = 0.02; g.add(base);
  const kb = rbox(w * 0.86, 0.006, d * 0.5, 0.004, M('lapk', '#2c2d34'), { lump: 0 }); kb.position.set(0, 0.043, 0.04); g.add(kb);
  const lidP = new THREE.Group(); lidP.position.set(0, 0.04, -d / 2 + 0.02); lidP.rotation.x = -0.28; g.add(lidP);
  const lid = rbox(w, d * 0.92, 0.035, 0.03, m, { lump: 0 }); lid.position.y = d * 0.46; lidP.add(lid);
  const scr = screen(1024, 680);
  const face = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.93, d * 0.84), new THREE.MeshBasicMaterial({ map: scr.tex, toneMapped: false }));
  face.position.set(0, d * 0.46, 0.019); lidP.add(face);
  g.userData = { scr, lid: lidP, face };
  g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
  return g;
}
export function microwave() {
  const g = new THREE.Group();
  const body = rbox(0.62, 0.38, 0.42, 0.04, M('mw', '#eceae6', { rough: 0.4 }), { lump: 0 }); body.position.y = 0.19; g.add(body);
  const door = rbox(0.42, 0.3, 0.02, 0.02, M('mwg', '#2b2f3a', { rough: 0.15, env: 1.4 }), { lump: 0 }); door.position.set(-0.08, 0.19, 0.215); g.add(door);
  const pad = rbox(0.12, 0.3, 0.02, 0.02, M('mwp', '#c9c6c0'), { lump: 0 }); pad.position.set(0.22, 0.19, 0.215); g.add(pad);
  const scr = screen(128, 48); scr.draw('0', (c) => { c.fillStyle = '#123'; c.fillRect(0, 0, 128, 48); c.fillStyle = '#7dff9a'; c.font = 'bold 34px Consolas'; c.textAlign = 'center'; c.fillText('0:00', 64, 36); });
  const d = new THREE.Mesh(new THREE.PlaneGeometry(0.1, 0.04), new THREE.MeshBasicMaterial({ map: scr.tex, toneMapped: false })); d.position.set(0.22, 0.29, 0.227); g.add(d);
  const manual = rbox(0.3, 0.025, 0.38, 0.01, M('manual', '#f4f1e6', { rough: 0.7 }), { lump: 0 }); manual.position.set(-0.05, 0.395, 0.0); manual.rotation.y = 0.15; g.add(manual);
  const lab = text3d('MANUAL', 0.045, 0.006, M('manualInk', '#2b5aa8'), { lump: 0 }); lab.rotation.x = -Math.PI / 2; lab.rotation.z = -0.15; lab.position.set(-0.05, 0.41, -0.06); g.add(lab);
  g.userData = { scr };
  g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
  return g;
}
export function robot() {   // a little tin robot with a screen face (scr) and an antenna
  const g = new THREE.Group(), m = M('robot', '#9fb2c6', { rough: 0.25, env: 1.4 }), d = M('robotD', '#56657a', { rough: 0.3 });
  const body = rbox(0.34, 0.3, 0.26, 0.06, m, { lump: 0 }); body.position.y = 0.32; g.add(body);
  const head = new THREE.Group(); head.position.y = 0.58; g.add(head);
  const hb = rbox(0.32, 0.24, 0.26, 0.06, m, { lump: 0 }); head.add(hb);
  const scr = screen(160, 110); const face = new THREE.Mesh(new THREE.PlaneGeometry(0.24, 0.165), new THREE.MeshBasicMaterial({ map: scr.tex, toneMapped: false })); face.position.z = 0.132; head.add(face);
  const ant = capsule(0.012, 0.12, d, { lump: 0 }); ant.position.y = 0.19; head.add(ant);
  const bulb = blob(0.035, 0.035, 0.035, M('bulb', '#ff4a4a', { emissive: 0xff2a2a, ei: 0.6 }), { lump: 0 }); bulb.position.y = 0.28; head.add(bulb);
  for (const s of [-1, 1]) { const a = capsule(0.035, 0.14, d, { lump: 0 }); a.position.set(s * 0.21, 0.32, 0); a.rotation.z = s * 0.25; g.add(a); const l = capsule(0.045, 0.1, d, { lump: 0 }); l.position.set(s * 0.08, 0.1, 0); g.add(l); }
  g.userData = { scr, head, bulb };
  g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
  return g;
}
// a paper sheet you can write on (scr redraws it), on a thin board
export function paper({ w = 0.7, h = 0.95 } = {}) {
  const g = new THREE.Group();
  const scr = screen(420, 570);
  const p = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshStandardMaterial({ map: scr.tex, roughness: 0.8 })); p.castShadow = true; p.receiveShadow = true; g.add(p);
  const back = rbox(w, h, 0.012, 0.004, M('paperB', '#f1ece0'), { lump: 0 }); back.position.z = -0.008; g.add(back);
  g.userData = { scr, sheet: p }; return g;
}
// a toy chest: wooden box with a hinged lid (lid.rotation.x < 0 opens it) and brass corners
export function toyChest({ w = 1.1, d = 0.65, h = 0.6, color = '#d0763c' } = {}) {
  const g = new THREE.Group(), m = M('chest' + color, color, { rough: 0.5 }), b = M('brass', '#e2b84a', { rough: 0.25, env: 1.3 });
  const t = 0.05;
  const box = new THREE.Group(); g.add(box);
  const bt = rbox(w, t, d, 0.02, m, { lump: 0 }); bt.position.y = t / 2; box.add(bt);
  for (const [x, z, ww, dd] of [[0, d / 2 - t / 2, w, t], [0, -d / 2 + t / 2, w, t], [w / 2 - t / 2, 0, t, d], [-w / 2 + t / 2, 0, t, d]]) { const wl = rbox(ww, h, dd, 0.02, m, { lump: 0 }); wl.position.set(x, h / 2, z); box.add(wl); }
  for (const s of [-1, 1]) { const band = rbox(0.08, h + 0.02, d + 0.03, 0.02, b, { lump: 0 }); band.position.set(s * w * 0.32, h / 2, 0); box.add(band); }
  const inside = rbox(w - 2 * t, 0.02, d - 2 * t, 0.01, M('chestIn', '#5a2e14'), { lump: 0 }); inside.position.y = t + 0.01; box.add(inside);
  const lidP = new THREE.Group(); lidP.position.set(0, h, -d / 2); g.add(lidP);
  const lid = rbox(w + 0.04, 0.1, d + 0.04, 0.04, m, { lump: 0 }); lid.position.set(0, 0.05, d / 2); lidP.add(lid);
  const lock = rbox(0.12, 0.14, 0.04, 0.02, b, { lump: 0 }); lock.position.set(0, -0.02, d + 0.03); lidP.add(lock);
  g.userData = { lid: lidP, h };
  g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
  return g;
}
// a white smoke puff burst (the reference's poofs): k 0..1 grows and fades it
export function puff({ n = 9, r = 0.5, seed = 51 } = {}) {
  const g = new THREE.Group(), R = rnd(seed), parts = [];
  for (let i = 0; i < n; i++) {
    const m = toyMat('#ffffff', { rough: 1, emissive: 0xe8edf3, ei: 0.35, transparent: true });
    const b = blob(1, 0.9, 1, m, { seed: seed + i, lump: 0, seg: 18, cast: false }); b.castShadow = false;
    const a = R() * Math.PI * 2, el = (R() - 0.3) * 1.2; const dir = V(Math.cos(a) * Math.cos(el), Math.sin(el) * 0.6 + 0.2, Math.sin(a) * Math.cos(el));
    g.add(b); parts.push({ b, dir, s: r * (0.5 + R() * 0.5), sp: 0.6 + R() * 0.6 });
  }
  g.set = k => {
    g.visible = k > 0 && k < 1;
    for (const p of parts) { const e = 1 - Math.pow(1 - k, 2.2); p.b.position.copy(p.dir).multiplyScalar(e * p.sp * r * 2.2); const sc = p.s * (0.35 + e * 0.9) * (1 - Math.pow(k, 3) * 0.6); p.b.scale.setScalar(Math.max(0.001, sc)); p.b.material.opacity = 1 - Math.pow(k, 2.5); }
  };
  g.set(0); return g;
}
// confetti: n little paper bits; set(k) launches them from the origin and lets them fall
export function confetti({ n = 70, seed = 61, colors = ['#ffd23a', '#e5322d', '#3a6fc4', '#57b947', '#ffffff', '#f28ab0'] } = {}) {
  const g = new THREE.Group(), R = rnd(seed);
  const geo = new THREE.PlaneGeometry(0.06, 0.1);
  const mats = colors.map(c => new THREE.MeshStandardMaterial({ color: c, side: THREE.DoubleSide, roughness: 0.6 }));
  const bits = [];
  for (let i = 0; i < n; i++) { const m = new THREE.Mesh(geo, mats[i % mats.length]); g.add(m); bits.push({ m, v: V((R() - 0.5) * 3.2, 3 + R() * 3, (R() - 0.5) * 3.2), w: V(R() * 9, R() * 9, R() * 9) }); }
  g.set = tt => { g.visible = tt > 0 && tt < 3.5; for (const b of bits) { b.m.position.set(b.v.x * tt, b.v.y * tt - 4.9 * tt * tt * 0.55, b.v.z * tt); if (b.m.position.y < -3) b.m.position.y = -3; b.m.rotation.set(b.w.x * tt, b.w.y * tt, b.w.z * tt); } };
  g.set(0); return g;
}
// a flat sign board with painted text (for in-world labels too small for 3D letters)
export function board({ w = 1.2, h = 0.5, text = 'SIGN', bg = YC.cream, ink = YC.red, font = '"Archivo Black"', post = 0, size = 0.62 } = {}) {
  const g = new THREE.Group();
  const scr = screen(Math.round(512 * w / h * 0.5) * 2, 512);
  const draw = (t) => scr.draw(t + bg + ink, (c, W, H) => { c.fillStyle = bg; c.fillRect(0, 0, W, H); c.fillStyle = ink; c.textAlign = 'center'; c.textBaseline = 'middle'; let fs = H * size; c.font = `${fs}px ${font}`; const tw = c.measureText(t).width; if (tw > W * 0.88) { fs *= W * 0.88 / tw; c.font = `${fs}px ${font}`; } c.fillText(t, W / 2, H * 0.53); });
  draw(text);
  const b = rbox(w + 0.08, h + 0.08, 0.06, 0.03, M('boardE', '#b98a58'), { lump: 0 }); g.add(b);
  const f = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshStandardMaterial({ map: scr.tex, roughness: 0.7 })); f.position.z = 0.032; g.add(f);
  if (post) { const p = rbox(0.08, post, 0.08, 0.02, M('wood' + YC.wood, YC.wood), { lump: 0 }); p.position.set(0, -post / 2 - h / 2, -0.06); g.add(p); }
  g.userData = { scr, draw, face: f };
  g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
  return g;
}
```

### `engine/props.js`

```js
// Story props made of smooth toy plastic (clay.js with CLAY.toy): the .md file (glows), coins, a gift box with a lid,
// a price tag, a market counter with a striped awning, a hand-made sign, a shelf, bricks, scissors. Engine file: a video's
// scene.js places them. Every factory returns a THREE.Group (or a mesh) at the origin; sizes in metres (a kid toy ≈ 1.45 tall).
import * as THREE from 'three';
import { clayMat, blob, rbox, roll, capsule, lathe, mesh, text3d, labelTex, glow, lumpy, vnoise3 } from './clay.js';

const V = (x, y, z) => new THREE.Vector3(x, y, z);
const rnd = 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 COL = {
  plaster: '#d6bf9a', plaster2: '#c9b08a', stone: '#bfae96', grout: '#c4ad8a', teal: '#1f9c9b', orange: '#f2792b', terracotta: '#d9643a',
  wood: '#9a6a45', woodD: '#7a5034', shutter: '#2f62b8', leaf: '#4f8a3a', leaf2: '#6aa548', gold: '#e6b43c', cream: '#f5eedc', ink: '#2d2a3a',
  morphy: '#6b7cf5', red: '#d8452c', pink: '#ef8fae', white: '#f7f4ee',
};
const MAT = {};
const mat = (k, c, o) => MAT[k] || (MAT[k] = clayMat(c, o));

export function counter({ w = 2.2, d = 0.7, h = 1.0, color = COL.wood, front = '#b07a4f', seed = 15 } = {}) {
  const g = new THREE.Group();
  const top = rbox(w + 0.12, 0.09, d + 0.1, 0.035, mat('wood' + color, color, { bump: 0.006, smear: 2.2 }), { seed }); top.position.y = h - 0.045; g.add(top);
  const n = Math.round(w / 0.24);
  for (let i = 0; i < n; i++) { const p = rbox(w / n - 0.02, h - 0.09, 0.07, 0.025, mat('plank' + front, front, { bump: 0.006, smear: 2.4 }), { seed: seed + i }); p.position.set(-w / 2 + (i + 0.5) * w / n, (h - 0.09) / 2, d / 2 - 0.035); g.add(p); }
  const back = rbox(w, h - 0.09, d - 0.07, 0.02, mat('wood' + COL.woodD, COL.woodD)); back.position.set(0, (h - 0.09) / 2, -0.035); g.add(back);
  return g;
}
// striped awning sloping toward +z, with a scalloped edge
export function awning({ w = 2.6, d = 1.0, slope = 0.35, colors = [COL.red, COL.cream], seed = 16 } = {}) {
  const g = new THREE.Group(), n = Math.round(w / 0.26);
  for (let i = 0; i < n; i++) {
    const m = mat('awn' + colors[i % 2], colors[i % 2], { bump: 0.005 });
    const s = rbox(w / n + 0.005, 0.05, d, 0.02, m, { seed: seed + i }); s.rotation.x = Math.atan(slope); s.position.set(-w / 2 + (i + 0.5) * w / n, -d * slope / 2, d / 2); g.add(s);
    const sc = blob(w / n * 0.5, 0.09, 0.03, m, { seed: seed + 50 + i }); sc.position.set(-w / 2 + (i + 0.5) * w / n, -d * slope - 0.06, d); g.add(sc);
  }
  return g;
}
export function shelf({ w = 2.4, h = 2.6, d = 0.45, rows = 4, color = COL.wood, seed = 17 } = {}) {
  const g = new THREE.Group(), m = mat('wood' + color, color, { bump: 0.006, smear: 2.2 });
  for (const s of [-1, 1]) { const p = rbox(0.1, h, d, 0.03, m, { seed: seed + s }); p.position.set(s * (w / 2 - 0.05), h / 2, 0); g.add(p); }
  const backP = rbox(w, h, 0.05, 0.02, mat('wood' + COL.woodD, COL.woodD), { seed }); backP.position.set(0, h / 2, -d / 2 + 0.025); g.add(backP);
  g.userData.levels = [];
  for (let i = 0; i <= rows; i++) { const y = 0.06 + i * (h - 0.12) / rows; const p = rbox(w, 0.07, d, 0.025, m, { seed: seed + 10 + i }); p.position.set(0, y, 0); g.add(p); g.userData.levels.push(y + 0.035); }
  return g;
}

export function mdFile({ label = '.md', w = 0.34, h = 0.44, size, color = COL.cream, ink = COL.morphy, lines = 4, seed = 18, glowColor = 0xffe3a0 } = {}) {
  const g = new THREE.Group(), body = new THREE.Group(); g.add(body);
  const sheetM = clayMat(color, { bump: 0.003, print: 0.7, emissive: 0xffd890, ei: 0 });
  const sh = new THREE.Shape(); const c = w * 0.22, r = 0.02;
  sh.moveTo(-w / 2 + r, -h / 2); sh.lineTo(w / 2 - r, -h / 2); sh.quadraticCurveTo(w / 2, -h / 2, w / 2, -h / 2 + r); sh.lineTo(w / 2, h / 2 - c); sh.lineTo(w / 2 - c, h / 2); sh.lineTo(-w / 2 + r, h / 2); sh.quadraticCurveTo(-w / 2, h / 2, -w / 2, h / 2 - r); sh.lineTo(-w / 2, -h / 2 + r); sh.quadraticCurveTo(-w / 2, -h / 2, -w / 2 + r, -h / 2);
  const sg = new THREE.ExtrudeGeometry(sh, { depth: 0.02, bevelEnabled: true, bevelThickness: 0.008, bevelSize: 0.008, bevelSegments: 3, curveSegments: 6 }); sg.translate(0, 0, -0.01);
  const sheet = mesh(lumpy(sg, 0.0025, 9, seed), sheetM); body.add(sheet);
  const fold = new THREE.Shape(); fold.moveTo(w / 2 - c, h / 2); fold.lineTo(w / 2 - c, h / 2 - c); fold.lineTo(w / 2, h / 2 - c);
  const fg = new THREE.ExtrudeGeometry(fold, { depth: 0.012, bevelEnabled: true, bevelThickness: 0.004, bevelSize: 0.004, bevelSegments: 2 });
  const fm = mesh(fg, clayMat('#e2d6b8', { bump: 0.002 })); fm.position.z = 0.016; body.add(fm);
  const lineM = clayMat('#9a9bb0', { bump: 0.001, print: 0.2 });
  for (let i = 0; i < lines; i++) { const L = w * (0.62 - (i % 3) * 0.12); const ln = roll([V(-w * 0.36, 0, 0), V(-w * 0.36 + L, 0, 0)], 0.0075, lineM, { seed: seed + i, lump: 0, seg: 6, radial: 8 }); ln.position.set(0, -h * 0.08 - i * h * 0.1, 0.02); body.add(ln); }
  let lab = null;
  if (label) {
    const ts = size ?? Math.min(w * 0.26, (w * 0.8) / Math.max(2.2, label.length * 0.55));
    lab = text3d(label, ts, ts * 0.25, clayMat(ink, { bump: 0.002, rough: 0.45, emissive: ink, ei: 0 }), { seed, lump: ts * 0.01 });
    lab.position.set(0, h * 0.22, 0.03); body.add(lab);
  }
  const gl = glow(glowColor, Math.max(w, h) * 2.0, 0); gl.position.z = -0.05; g.add(gl);
  g.userData = { sheetM, lab, gl, body, w, h };
  g.glow = v => { sheetM.emissiveIntensity = 0.22 * v; gl.material.opacity = 0.42 * v; if (lab) lab.material.emissiveIntensity = 0.0; };
  g.traverse(m => { if (m.isMesh) { m.castShadow = true; m.receiveShadow = true; } });
  return g;
}
export function coin({ r = 0.055, seed = 19 } = {}) {
  const m = MAT.coin || (MAT.coin = (() => { const x = clayMat(COL.gold, { rough: 0.3, bump: 0.002, print: 0.4, env: 1.4 }); x.metalness = 0.55; return x; })());
  const g = lumpy(new THREE.CylinderGeometry(r, r, r * 0.26, 24), r * 0.03, 10, seed);
  const c = mesh(g, m); const rim = roll([...Array(17)].map((_, i) => { const a = i / 16 * Math.PI * 2; return V(Math.cos(a) * r * 0.78, r * 0.14, Math.sin(a) * r * 0.78); }), r * 0.07, m, { closed: true, lump: 0 }); c.add(rim); return c;
}
export function coinPile({ n = 40, r = 0.4, seed = 20 } = {}) {
  const g = new THREE.Group(), R = rnd(seed);
  for (let i = 0; i < n; i++) { const c = coin({ seed: seed + i }); const a = R() * 6.28, d = Math.sqrt(R()) * r; c.position.set(Math.cos(a) * d, (1 - d / r) * r * 0.5 * R() + 0.01, Math.sin(a) * d); c.rotation.set((R() - 0.5) * 0.8, R() * 6, (R() - 0.5) * 0.8); g.add(c); }
  return g;
}
export function giftBox({ s = 0.42, color = '#c8407a', ribbon = COL.gold, seed = 21 } = {}) {
  const g = new THREE.Group(), m = clayMat(color, { bump: 0.004 }), rm = clayMat(ribbon, { rough: 0.35, bump: 0.002 });
  // an open-topped box (bottom + four walls), so whatever is inside shows when the lid comes off
  const box = new THREE.Group(); g.add(box); const th = 0.035;
  const bt = rbox(s, th, s, 0.015, m, { seed }); bt.position.y = th / 2; box.add(bt);
  for (const [x, z, w, d] of [[0, s / 2 - th / 2, s, th], [0, -s / 2 + th / 2, s, th], [s / 2 - th / 2, 0, th, s], [-s / 2 + th / 2, 0, th, s]]) { const wl = rbox(w, s * 0.8, d, 0.015, m, { seed: seed + x * 7 + z * 3 }); wl.position.set(x, s * 0.4, z); box.add(wl); }
  const inside = rbox(s - th * 2, 0.01, s - th * 2, 0.004, clayMat('#7a2a4a')); inside.position.y = th + 0.006; box.add(inside);
  const lidG = new THREE.Group(); lidG.position.y = s * 0.8; g.add(lidG);
  const lid = rbox(s * 1.08, s * 0.16, s * 1.08, 0.04, m, { seed: seed + 1 }); lid.position.y = s * 0.06; lidG.add(lid);
  for (const rot of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) { const st = new THREE.Group(); st.rotation.y = rot; g.add(st); const b = rbox(s * 0.16, s * 0.8, 0.014, 0.006, rm); b.position.set(0, s * 0.4, s / 2 + 0.006); st.add(b); }
  for (const rot of [0, Math.PI / 2]) { const b2 = rbox(s * 0.17, s * 0.18, s * 1.1, 0.02, rm); b2.position.y = s * 0.06; b2.rotation.y = rot; lidG.add(b2); }
  const bow = new THREE.Group(); bow.position.y = s * 0.16; lidG.add(bow);
  for (const sx of [-1, 1]) { const l = mesh(lumpy(new THREE.TorusGeometry(s * 0.14, s * 0.045, 10, 20), 0.003, 20, seed + sx), rm); l.position.set(sx * s * 0.14, s * 0.08, 0); l.rotation.set(0, 0, sx * 0.5); l.scale.set(1, 0.8, 0.6); bow.add(l); }
  const knot = blob(s * 0.07, s * 0.06, s * 0.07, rm); knot.position.y = s * 0.04; bow.add(knot);
  g.userData = { lidG, bow, box }; g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
  return g;
}
export function priceTag({ text = '$30', w = 0.46, h = 0.27, color = COL.cream, ink = COL.red, seed = 22 } = {}) {
  const g = new THREE.Group();
  const sh = new THREE.Shape(); sh.moveTo(-w / 2 + 0.09, h / 2); sh.lineTo(w / 2 - 0.02, h / 2); sh.lineTo(w / 2, h / 2 - 0.02); sh.lineTo(w / 2, -h / 2 + 0.02); sh.lineTo(w / 2 - 0.02, -h / 2); sh.lineTo(-w / 2 + 0.09, -h / 2); sh.lineTo(-w / 2, 0); sh.closePath();
  const hole = new THREE.Path(); hole.absarc(-w / 2 + 0.07, 0, 0.022, 0, Math.PI * 2, true); sh.holes.push(hole);
  const tg = new THREE.ExtrudeGeometry(sh, { depth: 0.025, bevelEnabled: true, bevelThickness: 0.01, bevelSize: 0.01, bevelSegments: 3 }); tg.translate(0, 0, -0.012);
  const tag = mesh(lumpy(tg, 0.002, 8, seed), clayMat(color, { bump: 0.003 })); g.add(tag);
  const t = text3d(text, h * 0.56, 0.03, clayMat(ink, { bump: 0.002, rough: 0.45 }), { seed }); t.position.set(0.04, 0, 0.025); g.add(t);
  if (t.userData.width > w * 0.78) t.scale.setScalar(w * 0.78 / t.userData.width);
  g.userData = { tag, text: t };
  return g;
}
export function sign({ text = 'FREE', w, h = 0.36, size, board = '#f4e7c8', ink = COL.red, post = 0, seed = 24 } = {}) {
  const g = new THREE.Group();
  const ts = size ?? h * 0.56;
  const t = text3d(text, ts, ts * 0.22, clayMat(ink, { bump: 0.002, rough: 0.45 }), { seed });
  const bw = w ?? t.userData.width + h * 0.6;
  const b = rbox(bw, h, 0.06, 0.03, clayMat(board, { bump: 0.005, smear: 1.6 }), { seed }); g.add(b);
  t.position.z = 0.04; g.add(t);
  if (post) { const p = rbox(0.07, post, 0.06, 0.02, mat('wood' + COL.wood, COL.wood)); p.position.set(0, -post / 2 - h / 2 + 0.02, -0.06); g.add(p); }
  g.userData = { text: t, board: b, w: bw };
  return g;
}
// a big rounded app window (title bar, three dots, a title in clay letters) — its opening is w × h, centred at the origin
export function scissors({ s = 1, seed = 27 } = {}) {
  const g = new THREE.Group(), blade = clayMat('#c3c7cf', { rough: 0.3, bump: 0.002 }); blade.metalness = 0.4;
  const hm = clayMat(COL.red, { bump: 0.003 });
  const mk = (sgn) => {
    const p = new THREE.Group(); g.add(p);
    const sh = new THREE.Shape(); sh.moveTo(0, 0); sh.quadraticCurveTo(0.03 * sgn, 0.12, 0.004 * sgn, 0.32); sh.lineTo(-0.012 * sgn, 0.3); sh.quadraticCurveTo(-0.01 * sgn, 0.12, -0.02 * sgn, 0); sh.closePath();
    const b = mesh(new THREE.ExtrudeGeometry(sh, { depth: 0.012, bevelEnabled: true, bevelThickness: 0.004, bevelSize: 0.004, bevelSegments: 2 }), blade); b.position.z = sgn * 0.008; p.add(b);
    const ring = mesh(lumpy(new THREE.TorusGeometry(0.055, 0.018, 10, 20), 0.002, 30, seed + sgn), hm); ring.position.set(-0.03 * sgn, -0.08, 0); p.add(ring);
    const arm = capsule(0.016, 0.04, hm); arm.position.set(-0.012 * sgn, -0.03, 0); arm.rotation.z = 0.35 * sgn; p.add(arm);
    return p;
  };
  const a = mk(1), b = mk(-1); const sc = blob(0.016, 0.016, 0.03, blade); g.add(sc);
  g.scale.setScalar(s); g.userData = { a, b };
  g.open = k => { a.rotation.z = -0.35 * k; b.rotation.z = 0.35 * k; };
  return g;
}
// a clay picture frame around an image (texture), with an optional view-count pill under it
export function brick({ w = 0.36, h = 0.18, d = 0.22, color = '#b5553a', seed = 31 } = {}) { return rbox(w, h, d, 0.035, clayMat(color, { bump: 0.006 }), { seed, lump: 0.01 }); }
```

### `engine/overlay.js`

```js
// The 2D layer over the toy film (1080x1920 canvas): the reference's captions (ONE word at a time, Poppins Bold, white with
// the key words yellow, a dark outline and a soft shadow), its STICKER words (Archivo Black caps, yellow / red / white,
// a thick dark outline and a hard drop shadow, slightly tilted, popping in next to the thing they name), the CTA comment
// card, 2D sparkles, and the RULES.md safe zone + its check. Engine file.
// Everything that draws text records its box, so stills (--safe) can draw the zone and every render reports text outside it.
import { seg, clamp, easeOut, easeOutBack, lerp } from './lib.js';

export const OW = 1080, OH = 1920;
// RULES.md 7: 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 = { capY: 1196, capSize: 68, capMaxW: 800 };
export const FONT = { cap: '"Poppins", "Segoe UI", sans-serif', stick: '"Archivo Black", "Arial Black", sans-serif' };
export const CAPSTYLE = { fill: '#ffffff', key: '#ffd52e', edge: '#121212', edgeW: 0.13, shadow: 'rgba(0,0,0,0.55)' };
export const STICK = { yellow: '#ffd52e', red: '#f0282b', white: '#ffffff', edge: '#141414' };

let BOXES = [];
export function beginFrame() { BOXES = []; }
export const boxes = () => BOXES;
export 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();
}

// ---------------- captions: one word at a time ----------------
// film.chunks hold one word each (video.json chunkMax 1). st: { y, scale, hide, key: Set|fn of yellow words, style }
export function captions(g, film, t, st = {}) {
  if (st.hide) return;
  const c = film.chunks.find(c => t >= (c.k === 0 ? 0 : c.t0 - 0.03) && t < c.t1); if (!c) return;
  const ws = c.words.map(i => film.words[i]); const text = ws.map(w => w.cap).join(' ');
  const style = { ...CAPSTYLE, ...(st.style || {}) };
  const isKey = typeof st.key === 'function' ? st.key(ws[0], text) : st.key ? st.key.has(ws[0].i) : false;
  let size = LAYOUT.capSize * (st.scale || 1); g.font = `700 ${size}px ${FONT.cap}`;
  const shown = text.replace(/[,;:]$/, '');
  let tw = g.measureText(shown).width; if (tw > LAYOUT.capMaxW) { size *= LAYOUT.capMaxW / tw; g.font = `700 ${size}px ${FONT.cap}`; tw = LAYOUT.capMaxW; }
  const k = c.k === 0 ? 1 : seg(t, c.t0 - 0.03, c.t0 + 0.05), s = 0.86 + 0.14 * easeOut(k);
  const y = st.y ?? LAYOUT.capY, x = SAFE.cx;
  g.save(); g.translate(x, y); g.scale(s, s);
  g.textAlign = 'center'; g.textBaseline = 'middle'; g.lineJoin = 'round'; g.miterLimit = 2;
  g.shadowColor = style.shadow; g.shadowBlur = size * 0.22; g.shadowOffsetY = size * 0.05;
  g.lineWidth = size * style.edgeW * 2; g.strokeStyle = style.edge; g.strokeText(shown, 0, size * 0.04);
  g.shadowColor = 'transparent'; g.fillStyle = isKey ? style.key : style.fill; g.fillText(shown, 0, size * 0.04);
  g.restore();
  mark(g, 'caption', shown, x - tw / 2 * s - size * 0.1, y - size * 0.62, tw * s + size * 0.2, size * 1.2);
}

// ---------------- stickers ----------------
// { text, at, out, x, y, size, col: 'yellow'|'red'|'white', rot (rad), skew, pop (0.18), lines } — x, y = centre in px.
// Fits itself inside x 60–960 (shrinks, then nudges), so the safe report stays clean.
export function sticker(g, t, s) {
  if (t < s.at || (s.out != null && t >= s.out)) return;   // gone on the cut, like the reference
  const kin = seg(t, s.at, s.at + (s.pop ?? 0.2)), kout = 0;
  const sc = (kin < 1 ? lerp(0.35, 1, easeOutBack(kin, 2.6)) : 1) * (1 - kout * 0.7) * (s.breathe ? 1 + Math.sin((t - s.at) * 5) * 0.015 : 1);
  const alpha = clamp(kin * 4) * (1 - kout);
  const lines = s.lines || [s.text];
  let size = s.size || 92; g.font = `${size}px ${FONT.stick}`;
  const wMax = Math.max(...lines.map(l => g.measureText(l).width));
  const PEAK = 1.13, maxW = 760 * (s.maxW ?? 1);   // the pop-in overshoots to ~1.12×: fit the peak, not the rest size
  if (wMax > maxW) { size *= maxW / wMax; g.font = `${size}px ${FONT.stick}`; }
  const W = Math.max(...lines.map(l => g.measureText(l).width)), lh = size * 1.02, Ht = lh * lines.length;
  let x = s.x ?? SAFE.cx, y = s.y ?? 520;
  const rot = s.rot ?? 0, halfW = PEAK * ((W / 2) * Math.abs(Math.cos(rot)) + (Ht / 2) * Math.abs(Math.sin(rot)) + size * 0.12), halfH = PEAK * ((Ht / 2) * Math.abs(Math.cos(rot)) + (W / 2) * Math.abs(Math.sin(rot)) + size * 0.12);
  x = clamp(x, SAFE.x0 + halfW + 4, SAFE.x1 - halfW - 4); y = clamp(y, SAFE.y0 + halfH + 4, SAFE.y1 - halfH - 4);
  const fill = STICK[s.col || 'yellow'] || s.col;
  g.save(); g.globalAlpha = alpha; g.translate(x, y); g.rotate(rot); if (s.skew) g.transform(1, s.skew, 0, 1, 0, 0); g.scale(sc, sc);
  g.textAlign = 'center'; g.textBaseline = 'middle'; g.lineJoin = 'round'; g.miterLimit = 2;
  lines.forEach((ln, i) => {
    const ly = (i - (lines.length - 1) / 2) * lh + size * 0.05;
    g.fillStyle = 'rgba(10,10,10,0.9)'; g.lineWidth = size * 0.22; g.strokeStyle = STICK.edge;
    g.save(); g.translate(size * 0.035, size * 0.075); g.strokeText(ln, 0, ly); g.fillText(ln, 0, ly); g.restore();   // hard drop shadow
    g.strokeText(ln, 0, ly);
    g.fillStyle = fill; g.fillText(ln, 0, ly);
  });
  g.restore();
  // record the box of the settled sticker (rotation included) once it is readable
  if (alpha > 0.3) { g.save(); g.translate(x, y); g.rotate(rot); if (s.skew) g.transform(1, s.skew, 0, 1, 0, 0); g.scale(sc, sc); mark(g, 'sticker', lines.join(' '), -W / 2 - size * 0.1, -Ht / 2 - size * 0.08, W + size * 0.2, Ht + size * 0.2); g.restore(); }
}

// ---------------- 2D sparkles (4-point stars) ----------------
export function sparkle(g, x, y, r, k, col = '#ffffff') {
  if (k <= 0 || k >= 1) return;
  const s = Math.sin(k * Math.PI) * r;
  g.save(); g.translate(x, y); g.rotate(k * 0.8); g.fillStyle = col; g.shadowColor = 'rgba(255,240,180,0.9)'; g.shadowBlur = r * 0.6;
  g.beginPath(); for (let i = 0; i < 8; i++) { const a = i / 8 * Math.PI * 2, rr = i % 2 ? s * 0.22 : s; g.lineTo(Math.cos(a) * rr, Math.sin(a) * rr); } g.closePath(); g.fill(); g.restore();
}

// ---------------- the CTA comment card ----------------
// a white rounded comment card: avatar, "COMMENT" small, the keyword big (yellow, outlined), a heart. k = 0..1 pop.
export function commentCard(g, t, { at, x = 510, y = 640, word = 'TOYBOX', w = 760, h = 300 } = {}) {
  if (t < at) return;
  const k = seg(t, at, at + 0.28), sc = lerp(0.3, 1, easeOutBack(k, 2.2)), bob = Math.sin((t - at) * 3.2) * 6;
  g.save(); g.translate(x, y + bob); g.scale(sc, sc); g.globalAlpha = clamp(k * 3);
  g.shadowColor = 'rgba(0,0,0,0.35)'; g.shadowBlur = 30; g.shadowOffsetY = 12;
  g.fillStyle = '#ffffff'; g.beginPath(); g.roundRect(-w / 2, -h / 2, w, h, 48); g.fill();
  g.shadowColor = 'transparent';
  g.beginPath(); g.moveTo(-w * 0.28, h / 2 - 2); g.lineTo(-w * 0.36, h / 2 + 64); g.lineTo(-w * 0.14, h / 2 - 2); g.closePath(); g.fill();
  g.lineWidth = 8; g.strokeStyle = '#141414'; g.beginPath(); g.roundRect(-w / 2, -h / 2, w, h, 48); g.stroke();
  g.fillStyle = '#e8763a'; g.beginPath(); g.arc(-w / 2 + 78, -h / 2 + 76, 36, 0, 7); g.fill(); g.lineWidth = 5; g.stroke();
  g.fillStyle = '#fbf4e8'; g.beginPath(); g.arc(-w / 2 + 78, -h / 2 + 68, 13, 0, 7); g.fill(); g.beginPath(); g.ellipse(-w / 2 + 78, -h / 2 + 98, 22, 14, 0, Math.PI, 0); g.fill();
  g.fillStyle = '#6b6b75'; g.font = `44px ${FONT.stick}`; g.textAlign = 'left'; g.textBaseline = 'middle'; g.fillText('COMMENT', -w / 2 + 136, -h / 2 + 78);
  // the keyword, letter by letter
  const L = [...word]; g.font = `150px ${FONT.stick}`; const tw = g.measureText(word).width, fs = Math.min(150, 150 * (w - 90) / tw); g.font = `${fs}px ${FONT.stick}`;
  let xx = -g.measureText(word).width / 2; g.textAlign = 'left'; g.lineJoin = 'round';
  L.forEach((ch, i) => {
    const kk = seg(t, at + 0.12 + i * 0.05, at + 0.3 + i * 0.05); const cw = g.measureText(ch).width;
    if (kk > 0) { g.save(); g.translate(xx + cw / 2, h * 0.14); g.scale(lerp(0.2, 1, easeOutBack(kk, 3)), lerp(0.2, 1, easeOutBack(kk, 3))); g.lineWidth = fs * 0.2; g.strokeStyle = '#141414'; g.textAlign = 'center'; g.strokeText(ch, 0, 0); g.fillStyle = STICK.yellow; g.fillText(ch, 0, 0); g.restore(); }
    xx += cw;
  });
  g.fillStyle = '#f0282b'; const hx = w / 2 - 70, hy = -h / 2 + 76, hs = 26 * (1 + 0.15 * Math.max(0, Math.sin((t - at) * 6)));
  g.beginPath(); g.moveTo(hx, hy + hs * 0.9); g.bezierCurveTo(hx - hs * 1.6, hy - hs * 0.1, hx - hs * 0.7, hy - hs * 1.3, hx, hy - hs * 0.45); g.bezierCurveTo(hx + hs * 0.7, hy - hs * 1.3, hx + hs * 1.6, hy - hs * 0.1, hx, hy + hs * 0.9); g.fill();
  g.restore();
  if (k > 0.3) { g.save(); g.translate(x, y + bob); g.scale(sc, sc); mark(g, 'cta', 'COMMENT ' + word, -w / 2, -h / 2, w, h); g.restore(); }
}
// a paper plane flying along a path (p 0..1) from (x0,y0) to (x1,y1) with a little arc
export function plane(g, t, t0, dur, x0, y0, x1, y1) {
  const k = seg(t, t0, t0 + dur); if (k <= 0 || k >= 1) return;
  const e = easeOut(k), x = lerp(x0, x1, e), y = lerp(y0, y1, e) - Math.sin(k * Math.PI) * 120, a = Math.atan2(y1 - y0 - Math.cos(k * Math.PI) * 120 * Math.PI, x1 - x0);
  g.save(); g.translate(x, y); g.rotate(a); const s = 1.2 - k * 0.4; g.scale(s, s);
  g.fillStyle = '#ffffff'; g.strokeStyle = '#141414'; g.lineWidth = 5; g.lineJoin = 'round';
  g.beginPath(); g.moveTo(70, 0); g.lineTo(-50, -40); g.lineTo(-30, 0); g.lineTo(-50, 40); g.closePath(); g.fill(); g.stroke();
  g.beginPath(); g.moveTo(70, 0); g.lineTo(-30, 0); g.stroke(); g.restore();
}
```

### `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 = 0; // off by default in clay: face close-ups are the look. A shot may set fit (world units across) to keep a prop whole

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, L: l, s };
}
```

### `engine/look.js`

```js
// Sunny suburban daylight: a warm sun with soft PCF shadows (the shadow box follows each shot's subject), a sky/lawn
// hemisphere, a soft front fill, a studio environment for the vinyl sheen, and blue aerial haze (fog) that melts the far
// houses into the sky like the reference. Engine file.
//   const LT = makeLights(E)     LT.aim(target [x,y,z], size)     LT.set({ sun, hemi, fill, dir, fog: [near, far] })
import * as THREE from 'three';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';

export const HAZE = '#cfe2f1';
export function makeLights({ scene, renderer }) {
  const hemi = new THREE.HemisphereLight(0xd6e8ff, 0x7fae5a, 1.05); scene.add(hemi);
  const sun = new THREE.DirectionalLight(0xfff2de, 2.9);
  sun.castShadow = true; sun.shadow.mapSize.set(2048, 2048); sun.shadow.radius = 4; sun.shadow.bias = -0.0005; sun.shadow.normalBias = 0.03;
  const sc = sun.shadow.camera; sc.near = 0.5; sc.far = 120;
  scene.add(sun, sun.target);
  const fill = new THREE.DirectionalLight(0xfff0e0, 0.45); scene.add(fill, fill.target);
  const pm = new THREE.PMREMGenerator(renderer); scene.environment = pm.fromScene(new RoomEnvironment(), 0.04).texture; scene.environmentIntensity = 0.38;
  scene.fog = new THREE.Fog(HAZE, 45, 190);
  const DIR = new THREE.Vector3(-0.45, 0.82, 0.55).normalize(), FILLD = new THREE.Vector3(0.6, 0.3, 0.9).normalize();
  const LT = {
    hemi, sun, fill, dir: DIR,
    aim(L, size = 6) {
      const t = new THREE.Vector3(...L);
      sun.target.position.copy(t); sun.position.copy(t).addScaledVector(LT.dir, 50);
      sc.left = -size; sc.right = size; sc.top = size; sc.bottom = -size; sc.updateProjectionMatrix();
      fill.target.position.copy(t); fill.position.copy(t).addScaledVector(FILLD, 10);
    },
    set(o = {}) {
      sun.intensity = o.sun ?? 2.9; hemi.intensity = o.hemi ?? 1.05; fill.intensity = o.fill ?? 0.45;
      LT.dir = o.dir ? new THREE.Vector3(...o.dir).normalize() : DIR;
      scene.fog.near = o.fog?.[0] ?? 45; scene.fog.far = o.fog?.[1] ?? 190;
    },
  };
  return LT;
}
```

### `engine/post.js`

```js
// Post pipeline: scene (MSAA + depth) → ambient occlusion (depth-based, the contact shadows of a miniature set) → AO applied
// → bloom chain (only glowing props pass the threshold) → comp (DOF, bloom, ACES, warm daylight grade) → final (CA, flash, vignette,
// overlay, grain). Engine file. Grain stays light (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 rtAO = rt(W / 2, H / 2), rtA = rt(W, H);
  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 }));

  // ---- ambient occlusion (Alchemy-style, view-space positions from depth) ----
  const AOU = { tDepth: { value: depthTex }, projInv: { value: new THREE.Matrix4() }, pxPerUnit: { value: 1000 }, res: { value: new THREE.Vector2(W / 2, H / 2) }, radius: { value: 0.22 }, intensity: { value: 1.25 }, on: { value: 1 } };
  const aoM = pass(`varying vec2 vUv; uniform sampler2D tDepth; uniform mat4 projInv; uniform float pxPerUnit, radius, intensity, on; uniform vec2 res;
    vec3 vpos(vec2 uv){ float d = texture2D(tDepth, uv).x; vec4 p = projInv * vec4(uv * 2. - 1., d * 2. - 1., 1.); return p.xyz / p.w; }
    float h(vec2 p){ return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
    void main(){
      if (on < 0.5) { gl_FragColor = vec4(1.); return; }
      float d0 = texture2D(tDepth, vUv).x; if (d0 >= 0.9999) { gl_FragColor = vec4(1.); return; }
      vec3 P = vpos(vUv);
      vec2 px = 1. / res;
      vec3 Px = vpos(vUv + vec2(px.x, 0.)), Px2 = vpos(vUv - vec2(px.x, 0.)), Py = vpos(vUv + vec2(0., px.y)), Py2 = vpos(vUv - vec2(0., px.y));
      vec3 dx = abs(Px.z - P.z) < abs(Px2.z - P.z) ? Px - P : P - Px2; vec3 dy = abs(Py.z - P.z) < abs(Py2.z - P.z) ? Py - P : P - Py2;
      vec3 N = normalize(cross(dx, dy));
      float rPx = radius * pxPerUnit / max(0.05, -P.z);
      rPx = clamp(rPx, 2., 90.);
      float occ = 0., rot = h(gl_FragCoord.xy) * 6.283;
      for (int i = 0; i < 14; i++) {
        float fi = float(i) + 0.5; float a = fi * 2.39996 + rot; float r = sqrt(fi / 14.);
        vec2 o = vec2(cos(a), sin(a)) * r * rPx * px * vec2(1., 1.);
        vec3 S = vpos(vUv + o); vec3 v = S - P; float vv = dot(v, v);
        occ += max(0., dot(v, N) + P.z * 0.002) / (vv + 0.0004) * smoothstep(radius * radius * 4., radius * radius * 0.5, vv);
      }
      float ao = clamp(1. - intensity * 2. * radius * 0.25 * occ / 14., 0., 1.);
      gl_FragColor = vec4(vec3(ao), 1.);
    }`, AOU);
  const applyM = pass(`varying vec2 vUv; uniform sampler2D tScene, tAO; uniform vec2 res;
    void main(){ vec2 p = 1. / res; float ao = 0.;
      for (int x = -1; x <= 1; x++) for (int y = -1; y <= 1; y++) ao += texture2D(tAO, vUv + vec2(float(x), float(y)) * p * 1.5).r;
      ao /= 9.; vec3 c = texture2D(tScene, vUv).rgb; gl_FragColor = vec4(c * mix(0.35, 1., ao), 1.); }`, { tScene: { value: rtScene.texture }, tAO: { value: rtAO.texture }, res: { value: new THREE.Vector2(W / 2, H / 2) } });

  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: 1.25 } });
  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: rtA.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: 18 },
    bloom: { value: 0.35 }, exposure: { value: 1 }, warm: { value: 0.12 }, sat: { value: 1.14 }, con: { value: 1.0 }, lift: { value: 0.012 },
  };
  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, warm, sat, con, lift;
    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 < 44; i++) {
          float r = sqrt(float(i) / 44.); 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.07, 1.0, 0.9), warm);
      col = aces(col);
      col = pow(col, vec3(1. / 2.2));
      float l = dot(col, vec3(0.2126, 0.7152, 0.0722));
      col = mix(vec3(l), col, sat);
      col = (col - 0.5) * con + 0.5;
      col = col + lift * (1. - col);
      gl_FragColor = vec4(clamp(col, 0., 1.), 1.);
    }`, U);

  const F = {
    tC: { value: rtC.texture }, tO: { value: null }, oOn: { value: 1 }, oShift: { value: new THREE.Vector2() }, res: { value: new THREE.Vector2(W, H) },
    ca: { value: 0.5 }, vig: { value: 0.2 }, 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) }, 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, 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);
      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.75, 0.4), vec3(1.0, 0.9, 0.65), uv.y); col += lc * g * leak * 0.7; }
      vec2 d = uv - 0.5; d.x *= res.x / res.y; float v = smoothstep(0.95, 0.3, length(d));
      col *= mix(1. - vig, 1., v);
      float fl = min(flash, flashMax); col += flashCol * fl * (1.0 - 0.55 * col);
      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) {
    U.near.value = camera.near; U.far.value = camera.far;
    renderer.setRenderTarget(rtScene); renderer.render(scene, camera);
    AOU.projInv.value.copy(camera.projectionMatrixInverse); AOU.pxPerUnit.value = (H / 2) * camera.projectionMatrix.elements[5] / 2;
    run(aoM, rtAO); run(applyM, rtA);
    brightM.uniforms.t.value = rtA.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, AOU, render, rtF, brightM };
}
```

### `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.022;         // film grain amount (light: it decides the file size; 0.045 made a 55 s reel 113 MB)

// [[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: paths, the video folder, video.json, the ElevenLabs key (never printed), loudness.
// The project folder holds engine/, assets/ (fonts, music, sfx), node_modules/three and one folder per video.
import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { spawnSync } from 'node:child_process';

export const ENGINE = import.meta.dirname;
export const ROOT = path.resolve(ENGINE, '..');                  // the project folder
export const ASSETS = path.join(ROOT, 'assets');
export const SHARED = path.join(ROOT, 'shared');                  // (optional) extra sounds or music you want to share between projects
export const NODE_MODULES = path.join(ROOT, 'node_modules');
export const CDP = path.join(ENGINE, 'cdp.mjs');
export const ENV_FILE = path.join(ROOT, '.env');
export const FPS = 30;

// Whisper is optional (Python + faster-whisper): align.mjs and check.mjs use it when it's there and say so when it isn't
let HAS_WHISPER = null;
export function hasWhisper() { if (HAS_WHISPER == null) HAS_WHISPER = spawnSync('python', ['-c', 'import faster_whisper'], { stdio: 'ignore' }).status === 0; return HAS_WHISPER; }

export function low() { try { os.setPriority(os.constants.priority.PRIORITY_LOW); } catch { } }

// argv: node engine/<cmd>.mjs <video> [positional…] [--flag value|--flag]
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); if (a[i + 1] != null && !a[i + 1].startsWith('--')) opt[k] = a[++i]; else opt[k] = true; }
    else pos.push(a[i]);
  }
  return { pos, opt };
}
// the video folder, relative to the project (e.g. my-video); it must hold scene.js
export function videoDir(name, { need = 'scene.js' } = {}) {
  if (!name) die(`usage: node engine/${path.basename(process.argv[1])} <video folder> …   (e.g. my-video)`);
  const dir = path.resolve(ROOT, name), rel = path.relative(ROOT, dir).replace(/\\/g, '/') || '.';
  if (rel.startsWith('..')) die('the video folder must be inside ' + ROOT);
  if (need && !fs.existsSync(path.join(dir, need))) die(`${need} not found in ${dir} (a video folder holds script.txt, video.json, scene.js, cues.mjs: see SKILL.md)`);
  return { dir, rel };
}
export const DEFAULTS = {
  take: 'a',                     // vo/<take>.mp3|.wav|.json — the one voice take this video uses
  voOff: 0.1,                    // seconds the voice starts after frame 0
  tail: 1.6,                     // seconds after the last word (end card)
  capFix: {},                    // caption spelling fixes: { "colors": "colours", "morphy": "MORPHY" }
  chunkMax: 1,                   // caption chunk: ONE word at a time (the reference's captions)
  chunkW: 760,                   // and at most this wide
  say: {},                       // pronunciation: { "SKILL.md": "skill dot M D" } — the voice says the right side, captions show the left
  music: { file: 'coffee-or-tea.mp3', from: 8, db: -10.5, muffle: [] }, // db: bed ≈ 13 LU under the voice after ducking; muffle: [[t0, t1], …] low-pass windows
  cover: 0.4,                    // cover.jpg = the frame at this time (the hook frame)
  // the reference's fast, bright narrator (≈160 Hz): Liam at speed 1.2, then votight.mjs to ~260 words a minute
  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');
  const 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 readScript(dir) {
  const f = path.join(dir, 'script.txt'); if (!fs.existsSync(f)) die('script.txt not found in ' + dir);
  return fs.readFileSync(f, 'utf8').split(/\r?\n/).filter(l => !l.trim().startsWith('#')).join(' ').replace(/\s+/g, ' ').trim();
}
// the text the voice reads: script.txt with video.json "say" applied (whole words, trailing punctuation kept)
const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export function spokenText(dir, cfg = loadVideo(dir)) {
  let s = readScript(dir);
  for (const [shown, said] of Object.entries(cfg.say || {})) s = s.replace(new RegExp(`(^|\\s)${esc(shown)}(?=[\\s.,:;?!]|$)`, 'g'), `$1${said}`);
  return s.replace(/(^|[.!?]\s+)([a-z])/g, (m, a, b) => a + b.toUpperCase()); // "Thirty dollars" opening a sentence
}
export function envKey(name) {   // from the environment or the project's .env. Never print it.
  if (process.env[name]) return process.env[name].trim();
  const line = fs.existsSync(ENV_FILE) && fs.readFileSync(ENV_FILE, 'utf8').split(/\r?\n/).find(l => l.startsWith(name + '='));
  if (!line) die(`${name} is not set (environment, or .env in the project folder)`);
  return line.slice(name.length + 1).trim().replace(/^["']|["']$/g, '');
}
// sfx/music lookup: the video's own folder first, then assets/, then shared/
export function findAsset(dir, kind, file) {
  for (const p of [path.join(dir, kind, file), path.join(ASSETS, kind, file), path.join(SHARED, kind, file)]) if (fs.existsSync(p)) return p;
  return null;
}
export function loud(file) {
  const e = spawnSync('ffmpeg', ['-hide_banner', '-nostats', '-i', file, '-af', 'ebur128=peak=true', '-f', 'null', '-'], { encoding: 'utf8' }).stderr.split('Summary:')[1] || '';
  const num = re => { const m = re.exec(e); return m ? +m[1] : NaN; };
  return { I: num(/I:\s+(-?[\d.]+) LUFS/), LRA: num(/LRA:\s+(-?[\d.]+) LU/), TP: num(/Peak:\s+(-?[\d.inf]+) dBFS/) };
}
export function probe(file) {
  const r = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration,size,bit_rate:stream=codec_type,codec_name,profile,width,height,r_frame_rate,pix_fmt,bit_rate,sample_rate,channels', '-of', 'json', file], { encoding: 'utf8' });
  return JSON.parse(r.stdout || '{}');
}
export function run(cmd, a, o = {}) { const r = spawnSync(cmd, a, { stdio: 'inherit', ...o }); if (r.status !== 0) die(`${cmd} failed (${r.status})`); return r; }
export function die(msg) { console.error('✗ ' + msg); process.exit(1); }
```

### `engine/cdp.mjs`

```js
// Headless Chrome/Edge over the DevTools protocol, no dependencies. ONE browser, on the GPU, at the lowest priority.
// Refuses software WebGL (SwiftShader): a CPU render of this film pegs every core for half an hour.
import { spawn, execFile } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
export const CHROME = [process.env.CHROME, 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
  'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', 'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
  '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
  '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/microsoft-edge'].find(p => p && fs.existsSync(p));
const ANGLE = process.env.TOYBOX_ANGLE || (process.platform === 'win32' ? 'd3d11' : process.platform === 'darwin' ? 'metal' : 'gl');   // Linux: try TOYBOX_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(), 'toybox-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; api.gl = 'gpu'; api.console = logs; return api;   // render.mjs reads api.gl and api.console
}
```

### `engine/setup.mjs`

```js
// Requirements check + the free assets. Run once per project:  node engine/setup.mjs
// Node 22+, ffmpeg/ffprobe, three.js 0.170, Chrome/Edge with WebGL on the GPU, the ElevenLabs key (never printed); downloads the
// two fonts and the music bed if missing, synthesizes the six free sounds, and lists the kit sounds still to make (gen-sfx.mjs).
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { ROOT, ASSETS, ENV_FILE, hasWhisper } 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 in the project folder)');
ok(!!CHROME, 'Chrome or Edge: ' + (CHROME || 'not found, set CHROME=<path to the browser>'));
ok(!!(process.env.ELEVENLABS_API_KEY || (fs.existsSync(ENV_FILE) && /^ELEVENLABS_API_KEY=./m.test(fs.readFileSync(ENV_FILE, 'utf8')))), 'ELEVENLABS_API_KEY (environment or .env)');
console.log(hasWhisper() ? '✓ Whisper (faster-whisper): align.mjs and check.mjs will use it' : '· Whisper not installed (optional: pip install faster-whisper — better word starts and a word check of the final mix)');
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/Poppins-Bold.ttf', GH + 'poppins/Poppins-Bold.ttf'],                     // the captions (700)
  ['fonts/ArchivoBlack-Regular.ttf', GH + 'archivoblack/ArchivoBlack-Regular.ttf'], // the stickers + every 3D letter
  ['music/coffee-or-tea.mp3', 'https://assets.mixkit.co/music/1168/1168.mp3'],       // Mixkit #1168 "Coffee or Tea": the bed from 8 s on
];
for (const [rel, url] of GET) {
  const f = path.join(ASSETS, rel); if (fs.existsSync(f)) { ok(true, 'assets/' + 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, 'assets/' + rel + ' downloaded'); }
  catch (e) { ok(false, `assets/${rel}: download failed (${e.message}) from ${url}`); }
}
// the six free sounds (ffmpeg synthesis)
spawnSync(process.execPath, [path.join(import.meta.dirname, 'make-toy-sfx.mjs')], { stdio: 'inherit' });
const missing = Object.keys(KIT).filter(n => !fs.existsSync(path.join(ASSETS, 'sfx', n + '.wav')));
ok(!missing.length, `${Object.keys(KIT).length - missing.length} of ${Object.keys(KIT).length} kit sounds in assets/sfx${missing.length ? ' (make them once: node engine/gen-sfx.mjs --yes, ~21 s of sound generation)' : ''}`);
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/gen-vo.mjs`

```js
// The voice: ONE ElevenLabs take of the whole script.txt, with character timestamps.  Costs credits — run once.
//   node engine/gen-vo.mjs <video>               dry run: shows the take letter and the character count, spends nothing
//   node engine/gen-vo.mjs <video> --yes         generates it (one take). [--take b] picks the letter; [--force] overwrites one
// Liam (TX3LPaxmHKxFdv7VOQHJ), eleven_multilingual_v2, stability 0.4 · similarity 0.8 · style 0.5 · speaker boost · speed 1.2
// (video.json "voice" overrides). Writes vo/<take>.mp3, vo/<take>.wav, vo/<take>.json { text, cfg, alignment, normalized }
// and sets video.json "take". A take that exists is never overwritten without --force. The key is read, never printed.
import fs from 'node:fs'; import path from 'node:path';
import { args, videoDir, loadVideo, spokenText, envKey, run, die } from './common.mjs';
const { pos, opt } = args(); const { dir, rel } = videoDir(pos[0], { need: 'script.txt' });
if (rel === 'example' || fs.existsSync(path.join(dir, 'vo', 'FROZEN'))) die('the example keeps its voice (vo/FROZEN): make takes in your own video folder');
const cfg = loadVideo(dir), vo = path.join(dir, 'vo'); fs.mkdirSync(vo, { recursive: true });
let take = opt.take; if (!take) { take = 'a'; while (fs.existsSync(path.join(vo, take + '.json'))) take = String.fromCharCode(take.charCodeAt(0) + 1); }
const js = path.join(vo, `${take}.json`);
if (fs.existsSync(js) && !opt.force) die(`vo/${take}.json exists. Use another --take, or --force to pay for it again.`);
const text = spokenText(dir, cfg);
const body = { text, model_id: cfg.voice.model_id, voice_settings: cfg.voice.voice_settings };
console.log(`voice ${cfg.voice.name} (${cfg.voice.model_id}) · take ${take} · ${text.length} characters`);
if (!opt.yes) { console.log('dry run: nothing sent. Add --yes to generate this take (costs about ' + text.length + ' ElevenLabs characters).'); 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': envKey('ELEVENLABS_API_KEY'), 'content-type': 'application/json' }, body: JSON.stringify(body) });
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(js, JSON.stringify({ text, cfg: { model_id: body.model_id, voice_settings: body.voice_settings }, alignment: j.alignment, normalized: j.normalized_alignment }));
run('ffmpeg', ['-v', 'error', '-y', '-i', path.join(vo, `${take}.mp3`), '-ar', '48000', '-ac', '1', path.join(vo, `${take}.wav`)]);
const vj = path.join(dir, 'video.json'), v = fs.existsSync(vj) ? JSON.parse(fs.readFileSync(vj, 'utf8')) : {}; v.take = take; fs.writeFileSync(vj, JSON.stringify(v, null, 2) + '\n');
const end = j.alignment.character_end_times_seconds.at(-1);
console.log(`ok · ${end.toFixed(2)} s · ${(text.split(/\s+/).length / end * 60).toFixed(0)} words/min · video.json take = "${take}"`);
console.log(`next: listen to vo/${take}.mp3, then node engine/align.mjs ${pos[0]}`);
```

### `engine/words.mjs`

```js
// Words from an ElevenLabs alignment: [{w, s, e, i}] (i = char index of the word in the text).
//   node engine/words.mjs <video> [take…]   prints word count, length, words/s and pauses > 0.25 s per take
import fs from 'node:fs'; import path from 'node:path';
export function words(file) {
  const j = JSON.parse(fs.readFileSync(file, 'utf8')); const a = j.alignment, out = []; let cur = null;
  for (let k = 0; k < a.characters.length; k++) {
    const ch = a.characters[k];
    if (/\s/.test(ch)) { if (cur) out.push(cur); cur = null; continue; }
    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;
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
  const { args, videoDir, loadVideo } = await import('./common.mjs');
  const { pos } = args(); const { dir } = videoDir(pos[0], { need: null });
  for (const id of pos.length > 1 ? pos.slice(1) : [loadVideo(dir).take]) {
    const W = words(path.join(dir, 'vo', `${id}.json`)); const gaps = [];
    for (let k = 1; k < W.length; k++) { const g = W[k].s - W[k - 1].e; if (g > 0.25) gaps.push(`${W[k - 1].w}|${g.toFixed(2)}@${W[k].s.toFixed(1)}`); }
    console.log(id, 'words', W.length, 'end', W.at(-1).e.toFixed(2), 'speech wps', (W.length / W.at(-1).e).toFixed(2));
    console.log('  gaps>0.25:', gaps.join('  '));
  }
}
```

### `engine/build.mjs`

```js
// film.json = caption words + chunks from the voice take's ElevenLabs alignment (vo/<take>.json).
// Sentence-start words take whisper's start (vo/<take>-whisper.json), because ElevenLabs hands the silence before a word to that word.
//   node engine/build.mjs <video>        (engine/align.mjs runs whisper first, then this)
//   node engine/build.mjs <video> --fake  guessed timings from script.txt (~260 words a minute, the tightened Liam): direct
//                                          the scene and render stills BEFORE paying for the voice (writes vo/fake.json, film.json)
// video.json "say" is undone here: the spoken words "skill dot M D" become the one caption word "SKILL.md" (first start, last end).
// Captions keep the script's punctuation (the reference shows "He agrees, but gives").
// Chunks: ≤ chunkMax words (3), a break after punctuation, balanced (no lone last word when it can be avoided).
import fs from 'node:fs'; import path from 'node:path';
import { words } from './words.mjs';
import { args, videoDir, loadVideo, spokenText, die } from './common.mjs';

const norm = s => s.toLowerCase().replace(/[^a-z0-9']/g, '');

// a stand-in alignment in ElevenLabs' shape: every character gets a time, words last 0.064 + 0.0295 s per letter,
// a sentence end adds 0.15 s, a comma 0.065 s (≈ the pace votight.mjs gives the real take: the example 55 s either way)
export function fakeAlignment(text) {
  const characters = [], s0 = [], s1 = []; let t = 0.05;
  for (const tok of text.split(/(\s+)/)) {
    if (!tok) continue;
    if (/^\s+$/.test(tok)) { for (const ch of tok) { characters.push(ch); s0.push(t); s1.push(t); } continue; }
    const d = 0.064 + 0.0295 * tok.replace(/[^A-Za-z0-9$]/g, '').length;
    [...tok].forEach((ch, k, a) => { characters.push(ch); s0.push(+(t + d * k / a.length).toFixed(3)); s1.push(+(t + d * (k + 1) / a.length).toFixed(3)); });
    t += d + (/[.!?]["”]?$/.test(tok) ? 0.15 : /[,;:]$/.test(tok) ? 0.065 : 0.012);
  }
  return { characters, character_start_times_seconds: s0, character_end_times_seconds: s1 };
}

export function buildFilm(dir, { fake = false } = {}) {
  const cfg = loadVideo(dir), VO_OFF = cfg.voOff;
  if (fake) { fs.mkdirSync(path.join(dir, 'vo'), { recursive: true }); fs.writeFileSync(path.join(dir, 'vo', 'fake.json'), JSON.stringify({ text: spokenText(dir, cfg), alignment: fakeAlignment(spokenText(dir, cfg)) })); }
  const TAKE = fake ? 'fake' : cfg.take;
  const al = path.join(dir, 'vo', `${TAKE}.json`), wh = path.join(dir, 'vo', `${TAKE}-whisper.json`);
  if (!fs.existsSync(al)) die(`vo/${TAKE}.json missing (video.json take = "${TAKE}")`);
  const W = words(al);
  if (fs.existsSync(wh)) {
    const X = JSON.parse(fs.readFileSync(wh, 'utf8')).segments.flatMap(s => s.words);
    let j = 0;
    for (const w of W) {
      const n = norm(w.w); let k = j;
      while (k < X.length && k < j + 4 && norm(X[k][2]) !== n) k++;
      if (k < X.length && norm(X[k][2]) === n) { if (X[k][0] > w.s && X[k][0] < w.e) w.s = X[k][0]; j = k + 1; }
    }
  } else console.log('(no whisper file: word starts are ElevenLabs only)');
  // undo "say": merge the spoken words back into the written one
  for (const [shown, said] of Object.entries(cfg.say || {}).sort((a, b) => b[1].split(/\s+/).length - a[1].split(/\s+/).length)) { // longest first: "toybox dot M D" before "dot M D"
    const S = said.split(/\s+/).map(norm);
    for (let i = 0; i + S.length <= W.length; i++) {
      if (!S.every((s, k) => norm(W[i + k].w) === s)) continue;
      const last = W[i + S.length - 1], punct = (last.w.match(/[.,:;?!]+$/) || [''])[0];
      W.splice(i, S.length, { w: shown + punct, s: W[i].s, e: last.e, i: W[i].i });
    }
  }
  const CAP = Object.fromEntries(Object.entries(cfg.capFix || {}).map(([k, v]) => [k.toLowerCase(), v]));
  const out = W.filter(w => /[A-Za-z0-9]/.test(w.w.replace(/\[[^\]]*\]/g, ''))).map((w, i) => {
    const m = w.w.match(/^(.*?)([.,:;?!]*)$/), core = m[1], punct = m[2];
    const cap = (CAP[core.toLowerCase()] || core) + punct;
    return { i, w: w.w, cap, t0: +(w.s + VO_OFF).toFixed(3), t1: +(w.e + VO_OFF).toFixed(3), stop: /[.,:;?!]$/.test(w.w) };
  });
  const chunks = [], phrases = []; let cur = [];
  const width = c => c.reduce((a, w) => a + w.cap.length * 44 + 30, 0);
  out.forEach(w => { cur.push(w); if (w.stop) { phrases.push(cur); cur = []; } });
  if (cur.length) phrases.push(cur);
  const MAX = cfg.chunkMax || 3;
  for (const p of phrases) {
    let k = Math.ceil(p.length / MAX);
    for (; ; k++) {
      const base = Math.floor(p.length / k), extra = p.length % k, parts = []; let i = 0;
      for (let c = 0; c < k; c++) { const n = base + (c < extra ? 1 : 0); parts.push(p.slice(i, i + n)); i += n; }
      if (parts.every(c => width(c) <= cfg.chunkW * 2) || k >= p.length) { chunks.push(...parts); break; }
    }
  }
  const CH = chunks.map((c, k) => ({ k, t0: c[0].t0, end: c.at(-1).t1, words: c.map(w => w.i) }));
  CH.forEach((c, k) => { const next = CH[k + 1]; c.t1 = +Math.min(next ? next.t0 : c.end + 1.2, c.end + 0.7).toFixed(3); });
  const END = +(out.at(-1).t1 + cfg.tail).toFixed(2);
  fs.writeFileSync(path.join(dir, 'film.json'), JSON.stringify({ take: TAKE, voOff: VO_OFF, END, words: out, chunks: CH, ...(fake ? { fake: true } : {}) }));
  if (fake) console.log('FAKE timings (no voice yet): stills only. Run gen-vo, votight, align before the full render.');
  console.log('words', out.length, 'chunks', CH.length, 'END', END);
  console.log(CH.map(c => c.words.map(i => out[i].cap).join(' ') + ' @' + c.t0.toFixed(2)).join(' | '));
  return { END, words: out, chunks: CH };
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
  const { pos, opt } = args(); buildFilm(videoDir(pos[0], { need: null }).dir, { fake: !!opt.fake });
}
```

### `engine/align.mjs`

```js
// Voice take → word timings → film.json.   node engine/align.mjs <video> [--redo]
// 1. vo/<take>.wav (48 kHz mono) from the mp3, 2. whisper (faster-whisper on the GPU, idle priority) → vo/<take>-whisper.json,
// 3. build.mjs → film.json. Whisper is skipped when its file exists (--redo forces it).
import fs from 'node:fs'; import path from 'node:path';
import { ENGINE, args, videoDir, loadVideo, low, run, die, hasWhisper } from './common.mjs';
import { buildFilm } from './build.mjs';
low();
const { pos, opt } = args(); const { dir } = videoDir(pos[0], { need: null });
const take = loadVideo(dir).take, vo = path.join(dir, 'vo');
const mp3 = path.join(vo, `${take}.mp3`), wav = path.join(vo, `${take}.wav`), w16 = path.join(vo, `${take}-16.wav`), wj = path.join(vo, `${take}-whisper.json`);
if (!fs.existsSync(wav)) { if (!fs.existsSync(mp3)) die(`vo/${take}.mp3 missing: node engine/gen-vo.mjs ${pos[0]}`); run('ffmpeg', ['-v', 'error', '-y', '-i', mp3, '-ar', '48000', '-ac', '1', wav]); }
if (!hasWhisper()) console.log('(Whisper not installed: sentence starts come from ElevenLabs alone, a little early after pauses — fine)');
else if (!fs.existsSync(wj) || opt.redo) {
  run('ffmpeg', ['-v', 'error', '-y', '-i', wav, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', w16]);
  console.log('whisper…'); run('python', [path.join(ENGINE, 'whisper-words.py'), w16, wj]);
  fs.rmSync(w16, { force: true });
}
buildFilm(dir);
```

### `engine/votight.mjs`

```js
// Tighten a voice take to the reference's rapid-fire delivery without paying for a new take:
// shorten every pause (cut only inside real silence), then optionally speed it up, and remap the 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: pause kept after . ! ? :   --comma: after , ;   --word: between other words (only longer silences are cut)
// --tempo: total speed factor; --pitch: part of it done by resampling (raises the pitch a little: 1.04 ≈ +0.7 semitone),
// the rest by atempo (pitch kept). Writes vo/<out>.wav (48 kHz mono) + vo/<out>.json (alignment remapped, like gen-vo).
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { args, videoDir, run, die } from './common.mjs';
import { words } from './words.mjs';
const { pos, opt } = args(); const { dir } = videoDir(pos[0], { need: null });
if (fs.existsSync(path.join(dir, 'vo', 'FROZEN'))) die('this voice is frozen (vo/FROZEN): tighten takes in your own video folder');
const vo = path.join(dir, 'vo'), SR = 48000, 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), PITCH = +(opt.pitch ?? 1);
const r = spawnSync('ffmpeg', ['-v', 'error', '-i', path.join(vo, from + '.wav'), '-ac', '1', '-ar', String(SR), '-f', 's16le', '-'], { maxBuffer: 1 << 30 });
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 = words(path.join(vo, from + '.json'));
// word starts after a pause: Whisper's (ElevenLabs gives the silence before a word to that word)
const wh = path.join(vo, from + '-whisper.json');
if (fs.existsSync(wh)) {
  const X = JSON.parse(fs.readFileSync(wh, 'utf8')).segments.flatMap(s => s.words), norm = s => s.toLowerCase().replace(/[^a-z0-9']/g, '');
  let j = 0; for (const w of W) { const n = norm(w.w); let k = j; while (k < X.length && k < j + 4 && norm(X[k][2]) !== n) k++; if (k < X.length && norm(X[k][2]) === n) { if (X[k][0] > w.s && X[k][0] < w.e) w.s = X[k][0]; j = k + 1; } }
}
// 5 ms energy frames; silence = 40 dB under the take's loud frames
const FR = SR / 200, nF = Math.floor(P.length / FR), dB = new Float32Array(nF);
for (let f = 0; f < nF; f++) { let s = 0; for (let i = f * FR; i < (f + 1) * FR; i++) s += (P[i] / 32768) ** 2; dB[f] = 10 * Math.log10(s / FR + 1e-12); }
const loudRef = [...dB].sort((a, b) => a - b)[Math.floor(nF * 0.9)], THR = loudRef - 40;
const cuts = []; // [a, b) seconds removed
for (let k = 0; k + 1 < W.length; k++) {
  const g0 = W[k].e, g1 = W[k + 1].s; if (g1 - g0 <= 0.02) continue;
  // the silent run inside the gap (frames below THR), padded 15 ms on both sides so tails and onsets stay whole
  let a = Math.ceil(g0 * 200), b = Math.floor(g1 * 200);
  while (a < b && dB[a] > THR) a++; while (b > a && dB[b - 1] > 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;
  const total = (g1 - g0), drop = Math.min(sil, total - keep); if (drop <= 0.01) continue;
  const mid = (s0 + s1) / 2; cuts.push([mid - drop / 2, mid + drop / 2]);
}
// splice with 4 ms fades on both sides of each cut
const outPCM = [], fade = Math.round(0.004 * SR); let pos0 = 0;
const map = []; // [srcStart, srcEnd, dstStart]
let 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);
  const seg = Array.from(P.subarray(i0, i1));
  for (let i = 0; i < Math.min(fade, seg.length); i++) { if (pos0 > 0) seg[i] *= i / fade; seg[seg.length - 1 - i] *= a < P.length / SR ? i / fade : 1; }
  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));
const af = TEMPO !== 1 ? ['-af', `asetrate=${SR * PITCH},aresample=${SR},atempo=${(TEMPO / PITCH).toFixed(5)}`] : [];
run('ffmpeg', ['-v', 'error', '-y', '-f', 's16le', '-ar', String(SR), '-ac', '1', '-i', raw, ...af, '-ar', String(SR), path.join(vo, out + '.wav')]); fs.rmSync(raw);
const T = t => +(t2(t) / TEMPO).toFixed(3);
const al = { 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) };
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: al, normalized: undefined }));
if (opt.set) { const vj = path.join(dir, 'video.json'), v = JSON.parse(fs.readFileSync(vj, 'utf8')); v.take = out; fs.writeFileSync(vj, JSON.stringify(v, null, 2) + '\n'); }
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 ${TEMPO} · ${len.toFixed(2)} s · ${(W.length / len * 60).toFixed(0)} words/min`);
```

### `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 gaps
//   node engine/voedit.mjs <video> --out c --use tX:0-26,a:27- [--pause 9:0.5,30:0.25] [--set]
// --use: runs "take:first-last" (word indexes of that take; "27-" = to the end), joined in order.
// --pause: "k:sec" adds sec of silence after OUTPUT word k. Cuts are made in the middle of the gap between two words.
// Writes vo/<out>.wav (48 kHz mono) + vo/<out>.json (text + shifted alignment, like gen-vo). --set: video.json take = out.
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { args, videoDir, run, die } from './common.mjs';
import { words } from './words.mjs';
const { pos, opt } = args(); const { dir, rel } = videoDir(pos[0], { need: null });
if (opt.out && (rel === 'example' || fs.existsSync(path.join(dir, 'vo', 'FROZEN')))) die('the example keeps its voice (vo/FROZEN): edit takes in your own video folder');
const vo = path.join(dir, 'vo'), SR = 48000;
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 = words(path.join(vo, opt.list + '.json'));
  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 tOut = 0, kOut = 0;
const push = (a) => { for (const v of a) out.push(v); tOut = out.length / SR; };
for (const [ri, r] of runs.entries()) {
  const J = JSON.parse(fs.readFileSync(path.join(vo, r.take + '.json'), 'utf8')), A = J.alignment, W = words(path.join(vo, r.take + '.json')), P = pcm(r.take);
  const b = Math.min(r.b, W.length - 1), dur = P.length / SR;
  for (let k = r.a; k <= b; k++) {
    const w = W[k];
    const c0 = k === r.a ? (k === 0 ? 0 : (W[k - 1].e + w.s) / 2) : (W[k - 1].e + w.s) / 2;      // cut in the gap before
    const c1 = k === b ? (k + 1 < W.length ? (w.e + W[k + 1].s) / 2 : dur) : (w.e + W[k + 1].s) / 2; // and in the gap after
    const off = tOut - c0;
    // this word's characters (and the space after it, if any) with shifted times
    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)));
    const extra = pauses[kOut] || 0; if (extra > 0) push(new Int16Array(Math.round(extra * 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 = JSON.parse(fs.readFileSync(path.join(vo, runs[0].take + '.json'), 'utf8'));
const last = JSON.parse(fs.readFileSync(path.join(vo, runs.at(-1).take + '.json'), 'utf8'));
fs.writeFileSync(path.join(vo, opt.out + '.json'), JSON.stringify({ text: texts.join(' '), lines: last.lines, 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 } }));
if (opt.set) { const vj = path.join(dir, 'video.json'), v = JSON.parse(fs.readFileSync(vj, 'utf8')); v.take = opt.out; fs.writeFileSync(vj, JSON.stringify(v, null, 2) + '\n'); }
console.log(`vo/${opt.out}.wav · ${kOut} words · ${(out.length / SR).toFixed(2)} s${opt.set ? ' · video.json take = ' + opt.out : ''}`);
```

### `engine/whisper-words.py`

```python
# Transcribe one audio file with faster-whisper on the GPU (CPU if that fails).
#   python whisper.py <audio> <out.json>
# WHISPER_MODEL picks the model (default: large-v3-turbo, already int8 — 818 MB on disk instead of 1.6 GB).
import ctypes, json, os, site, sys

# Idle priority, like every heavy job on this PC.
try:
    ctypes.windll.kernel32.SetPriorityClass(ctypes.windll.kernel32.GetCurrentProcess(), 0x40)
except Exception:
    pass

# cuDNN/cuBLAS from the nvidia-* pip wheels.
for base in site.getsitepackages() + [site.getusersitepackages()]:
    for sub in ("nvidia/cudnn/bin", "nvidia/cublas/bin"):
        p = os.path.join(base, sub)
        if os.path.isdir(p):
            os.add_dll_directory(p)
            os.environ["PATH"] = p + os.pathsep + os.environ["PATH"]

import wave

import numpy as np
from faster_whisper import WhisperModel

audio, out = sys.argv[1], sys.argv[2]

# The 16 kHz mono WAV from ffmpeg, as samples: faster-whisper's own decoder (PyAV)
# breaks on av 19 (open() lost `metadata_errors`).
with wave.open(audio, "rb") as w:
    pcm = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(np.float32) / 32768.0
name = os.environ.get("WHISPER_MODEL", "Zoont/faster-whisper-large-v3-turbo-int8-ct2")


def run(device, compute):
    model = WhisperModel(name, device=device, compute_type=compute, cpu_threads=2)
    # Not conditioning on earlier text stops loops like "Go! Go! Go!" over music.
    segments, info = model.transcribe(pcm, beam_size=5, vad_filter=True, condition_on_previous_text=False, word_timestamps=True)
    end = len(pcm) / 16000
    segs = [{"start": round(s.start, 2), "end": round(min(s.end, end), 2), "text": s.text, "words": [[round(w.start,2), round(w.end,2), w.word] for w in (s.words or [])]} for s in segments if s.start < end - 0.2]
    return segs, info


try:
    # int8 weights, float32 maths: GTX 16-series cards are unreliable in float16.
    segs, info = run("cuda", "int8_float32")
    device = "GPU"
except Exception as e:
    print(f"GPU failed ({e}); using the CPU", file=sys.stderr)
    segs, info = run("cpu", "int8")
    device = "CPU"

with open(out, "w", encoding="utf-8") as f:
    json.dump({"model": name, "device": device, "language": info.language, "probability": info.language_probability, "segments": segs}, f, ensure_ascii=False, indent=1)
```

### `engine/render.mjs`

```js
// Renders the film in Chrome — only through cdp.mjs, ONE browser, on the graphics card, at low priority.
// Always run it under the reel-formats render lock, from the template/work folder:
//   node ../../shared/with-lock.mjs node engine/render.mjs <video> stills 0.5,3.2,11.8 --safe     → <video>/stills/t*.jpg + sheet.jpg
//   node ../../shared/with-lock.mjs node engine/render.mjs <video> stills auto 8 --safe          (8 times spread over the film)
//   node ../../shared/with-lock.mjs node engine/render.mjs <video> full [fromFrame] [toFrame]     → <video>/frames/f0000.jpg …
// --safe draws the RULES safe zone + every text box (green ok / red outside) on the stills; every run prints text outside the zone.
// Ports: --port N or TOYBOX_PORT, else one derived from this folder's path in 9640–9719 (a copy in work\<row> gets its own),
// and a port something is already listening on is skipped. The page is served from this folder; /node_modules and /shared
// come from ../../node_modules and ../../shared, so it works the same in templates\toybox and in work\<row>.
import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import net from 'node:net'; import { pathToFileURL } from 'node:url';
import { ROOT, NODE_MODULES, SHARED, CDP, FPS, args, videoDir, low, die } from './common.mjs';
import { makeSheet } from './sheet.mjs';
low();
const { pos, opt } = args(); const { dir, rel } = videoDir(pos[0]); const mode = pos[1] || 'stills';
if (!fs.existsSync(path.join(dir, 'film.json'))) die(`${rel}/film.json missing: run node engine/align.mjs ${rel}`);
const { launch } = await import(pathToFileURL(CDP).href);

// ---- static server: / = this folder, /node_modules = ../../node_modules, /shared = ../../shared ----
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.jpg': 'image/jpeg', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' };
const MOUNTS = [['/node_modules/', NODE_MODULES], ['/shared/', SHARED], ['/', ROOT]];
function resolveFile(url) {
  for (const [pre, base] of MOUNTS) if (url.startsWith(pre)) {
    const p = path.resolve(base, '.' + url.slice(pre.length - 1));
    if (p.startsWith(base) && fs.existsSync(p) && fs.statSync(p).isFile()) return p;
    return null;
  }
  return null;
}
const srv = http.createServer((req, res) => { if (req.url === '/favicon.ico') { res.writeHead(204); return res.end(); } const p = resolveFile(decodeURIComponent(req.url.split('?')[0])); if (!p) { res.writeHead(404); return res.end(); } res.writeHead(200, { 'Content-Type': MIME[path.extname(p).toLowerCase()] || '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 pageUrl = `http://127.0.0.1:${srv.address().port}/engine/film.html?video=${encodeURIComponent(rel)}`;

// ---- ports ----
const hashPort = s => { let h = 2166136261; for (const c of s.toLowerCase()) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619) >>> 0; } return 9640 + (h % 40) * 2; };
const busy = port => new Promise(res => { const s = net.createServer().once('error', () => res(true)).once('listening', () => s.close(() => res(false))).listen(port, '127.0.0.1'); });
async function freePort(start) { let p = start; for (let i = 0; i < 80; i++, p = 9640 + ((p - 9640 + 1) % 80)) if (!(await busy(p))) return p; die('no free Chrome debug port in 9640–9719'); }
const PORT0 = +(opt.port || process.env.TOYBOX_PORT || hashPort(ROOT));
let flip = 0;

const save = (file, b64) => fs.writeFileSync(file, Buffer.from(b64, 'base64'));
const errs = api => api.console.filter(c => c.type === 'exception' || c.type === 'error' || c.type === 'log:error' || /ERR|REJ/.test(JSON.stringify(c)));
async function open() {
  const port = await freePort(PORT0 + (flip++ % 2));
  const api = await launch({ width: 1080, height: 1920, port, offline: false });
  if (api.gl !== 'gpu') { await api.close(); die('Chrome fell back to software WebGL (SwiftShader). Not rendering on the CPU — check the graphics driver.'); }
  await api.goto(pageUrl);
  try { await api.waitFor('window.__ready === true || !!window.__err', 120000); if (await api.eval('window.__err || null')) throw new Error('page error: ' + await api.eval('window.__err')); } catch (e) { for (const x of api.console.slice(0, 20)) console.log(JSON.stringify(x).slice(0, 500)); await api.close(); throw e; }
  return api;
}
const fmtBad = 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(), TOTAL = await api.eval('window.TOTAL');
    const times = pos[2] === 'auto' ? Array.from({ length: +(pos[3] || 8) }, (_, i) => +((i + 0.5) / +(pos[3] || 8) * TOTAL).toFixed(2)) : (pos[2] || '1').split(',').map(Number);
    const out = path.join(dir, 'stills'); fs.mkdirSync(out, { recursive: true });
    if (opt.safe) await api.eval('window.setSafe(true)');
    const files = []; let nBad = 0;
    for (const t of times) {
      const t0 = Date.now(); await api.eval(`window.setT(${t})`);
      const f = path.join(out, `t${(+t).toFixed(2)}${opt.safe ? '-safe' : ''}.jpg`);
      save(f, (await api.send('Page.captureScreenshot', { format: 'jpeg', quality: 90 })).data); files.push(f);
      const bad = await api.eval('window.safeReport()'); nBad += bad.length;
      console.log('still', t, ((Date.now() - t0) / 1000).toFixed(2) + 's', bad.length ? '  OUTSIDE SAFE ZONE: ' + fmtBad(bad) : '');
    }
    const fr = await api.eval('window.framing');
    if (Object.keys(fr).length) console.log('framing: these shots were pushed back to the minimum distance (move their P keys back):', JSON.stringify(fr));
    const e = errs(api); console.log('errors:', e.length); for (const x of e.slice(0, 10)) console.log(JSON.stringify(x).slice(0, 400));
    await api.close();
    if (!opt['no-sheet']) makeSheet(files, path.join(out, opt.safe ? 'sheet-safe.jpg' : 'sheet.jpg'));
    console.log(nBad ? `✗ ${nBad} text box(es) outside the safe zone` : '✓ all text inside the safe zone');
  } else if (mode === 'full') {
    let api = await open(); const TOTAL = await api.eval('window.TOTAL'), N = Math.ceil(TOTAL * FPS);
    const from = +(pos[2] || 0), to = Math.min(N, +(pos[3] || N)), out = path.join(dir, 'frames'); fs.mkdirSync(out, { recursive: true });
    const safeFile = path.join(out, 'safe.json'), SAFE = fs.existsSync(safeFile) ? JSON.parse(fs.readFileSync(safeFile, 'utf8')) : { total: N, bad: {}, captions: {} };
    SAFE.total = N;
    const ok = f => { try { return fs.statSync(path.join(out, `f${String(f).padStart(4, '0')}.jpg`)).size > 20000; } catch { return false; } };
    const todo = []; for (let f = from; f < to; f++) if (!ok(f)) todo.push(f);
    console.log('frames to render', todo.length, 'of', to - from, '(film', N, 'frames)');
    const t0 = Date.now(); let done = 0, sinceOpen = 0;
    while (todo.length) {
      const f = todo.shift();
      for (let attempt = 0; attempt < 3; attempt++) {
        try {
          if (!api || sinceOpen >= 150) { try { await api?.close(); } catch { } api = await open(); sinceOpen = 0; }
          await Promise.race([api.eval(`window.setT(${(f / FPS).toFixed(4)})`), new Promise((_, rej) => setTimeout(() => rej(new Error('setT timeout')), 60000))]);
          const b64 = (await api.send('Page.captureScreenshot', { format: 'jpeg', quality: 93 })).data;
          if (b64.length * 0.75 < 20000) throw new Error('blank frame');
          save(path.join(out, `f${String(f).padStart(4, '0')}.jpg`), b64); sinceOpen++;
          const bad = await api.eval('window.safeReport()'); if (bad.length) SAFE.bad[f] = bad; 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))];
          if (++done % 30 === 0) { console.log('done', done, '/', done + todo.length, ((Date.now() - t0) / 1000).toFixed(0) + 's', 'f' + f); fs.writeFileSync(safeFile, JSON.stringify(SAFE)); }
          break;
        } catch (e) { console.log('retry', f, String(e).slice(0, 120)); try { await api?.close(); } catch { } api = null; }
      }
    }
    try { await api?.close(); } catch { }
    fs.writeFileSync(safeFile, JSON.stringify(SAFE));
    const nb = Object.keys(SAFE.bad).length;
    console.log('all done', ((Date.now() - t0) / 1000).toFixed(0) + 's', nb ? `· ✗ ${nb} frames with text outside the safe zone (frames/safe.json)` : '· ✓ text inside the safe zone on every frame');
  } else die('mode: stills | full');
} finally { srv.close(); }
```

### `engine/sheet.mjs`

```js
// Contact sheet of stills (360x640 each, time labels).  node engine/sheet.mjs <video> [out.jpg]  → <video>/stills/sheet.jpg
// render.mjs stills calls makeSheet() itself after every stills run.
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { ROOT, args, videoDir } from './common.mjs';

export function makeSheet(files, out) {
  const n = files.length; if (!n) return null;
  const cols = n <= 4 ? n : n <= 8 ? 4 : 6, rows = Math.ceil(n / cols);
  const inputs = files.flatMap(f => ['-i', f]);
  const lab = f => { const b = path.basename(f, '.jpg').replace(/^t/, ''); return b.replace('-safe', '') + 's' + (b.endsWith('-safe') ? ' safe' : ''); };
  const font = ['assets/fonts/consolab.ttf', 'assets/fonts/Poppins-Bold.ttf'].find(f => fs.existsSync(path.join(ROOT, f)));
  const filt = files.map((f, i) => `[${i}:v]scale=360:640,drawtext=fontfile=${font}:text='${lab(f)}':x=10:y=10:fontsize=28:fontcolor=yellow:box=1:boxcolor=black@0.6[v${i}]`).join(';');
  const lay = files.map((_, i) => `${(i % cols) * 360}_${Math.floor(i / cols) * 640}`).join('|');
  const tail = n === 1 ? '[v0]null[o]' : `${files.map((_, i) => `[v${i}]`).join('')}xstack=inputs=${n}:layout=${lay}:fill=black[o]`;
  const r = spawnSync('ffmpeg', ['-v', 'error', '-y', ...inputs, '-filter_complex', `${filt};${tail}`, '-map', '[o]', '-q:v', '3', out], { cwd: ROOT, stdio: 'inherit' });
  if (r.status !== 0) throw new Error('ffmpeg sheet failed');
  console.log('sheet', out, `(${cols}x${rows})`);
  return out;
}

if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
  const { pos } = args(); const { dir } = videoDir(pos[0], { need: null });
  const st = path.join(dir, 'stills');
  const files = fs.readdirSync(st).filter(f => /^t[\d.]+(-safe)?\.jpg$/.test(f)).sort((a, b) => parseFloat(a.slice(1)) - parseFloat(b.slice(1))).map(f => path.join(st, f));
  makeSheet(files, pos[1] ? path.resolve(pos[1]) : path.join(st, 'sheet.jpg'));
}
```

### `engine/beats.mjs`

```js
// The beats of a video's scene.js: every cut time and the middle of every beat (for stills), from film.json.
//   node engine/beats.mjs <video>        prints "cuts …" and "mids …" (pass the mids to render.mjs stills)
// It reads the `beat(<time>, { L: …` calls of scene.js and evaluates <time> with the same T(word, n) the scene uses,
// so a T() that picked the wrong occurrence shows up here as a cut list that is not increasing.
import fs from 'node:fs'; import path from 'node:path';
import { args, videoDir } from './common.mjs';
const { pos } = args(); const { dir } = videoDir(pos[0]);
const film = JSON.parse(fs.readFileSync(path.join(dir, 'film.json'), 'utf8'));
const norm = s => s.toLowerCase().replace(/[^a-z0-9$.']/g, '').replace(/\.$/, '');
const T = (w, n = 1) => { const k = norm(w); const h = film.words.filter(v => norm(v.cap) === k); if (!h[n - 1]) throw new Error(`word not found: ${w} #${n}`); return h[n - 1].t0; };
const src = fs.readFileSync(path.join(dir, 'scene.js'), 'utf8');
// constants the cut expressions may use (declared in scene.js as `const tX = T(...)`)
const consts = {};
for (const m of src.matchAll(/^\s*const (t[A-Za-z0-9]+) = (T\([^;]*?\)(?: [-+] [\d.]+)?);/gm)) { try { consts[m[1]] = eval(m[2]); } catch { } }
const cuts = [...src.matchAll(/^\s*beat\((.*?), \{ L:/gm)].map(m => { const ex = m[1].replace(/\bt[A-Z][A-Za-z0-9]*\b/g, n => consts[n] ?? n); return eval(ex); });
const ends = cuts.slice(1).concat(film.END), mids = cuts.map((t, i) => +((t + ends[i]) / 2).toFixed(2));
const bad = cuts.map((t, i) => i && t <= cuts[i - 1] ? i + 1 : 0).filter(Boolean);
console.log(cuts.length + ' beats' + (bad.length ? `  ✗ not increasing at beat ${bad.join(', ')} (a T() picked the wrong occurrence?)` : ''));
console.log('cuts ' + cuts.map(t => t.toFixed(2)).join(','));
console.log('mids ' + mids.join(','));
```

### `engine/redo.mjs`

```js
// Deletes the rendered frames of the given time ranges, so `render.mjs <video> full` (which resumes) renders only those again.
//   node engine/redo.mjs <video> 8.6-9.7 26.6-27.5 [--dry]
// Use it after a fix that touches a few beats or stickers: re-rendering 100 frames takes ~30 s, the whole film ~8 min.
import fs from 'node:fs'; import path from 'node:path';
import { FPS, args, videoDir, die } from './common.mjs';
const { pos, opt } = args(); const { dir } = videoDir(pos[0], { need: null });
if (pos.length < 2) die('usage: node engine/redo.mjs <video> <t0-t1> [<t0-t1> …] [--dry]');
let n = 0;
for (const r of pos.slice(1)) {
  const [a, b] = r.split('-').map(Number); if (!(b >= a)) die('a range is t0-t1 in seconds: ' + r);
  for (let f = Math.floor(a * FPS); f <= Math.ceil(b * FPS); f++) { const p = path.join(dir, 'frames', `f${String(f).padStart(4, '0')}.jpg`); if (fs.existsSync(p)) { if (!opt.dry) fs.rmSync(p); n++; } }
}
console.log((opt.dry ? 'would delete ' : 'deleted ') + n + ' frames; now: node ../../shared/with-lock.mjs node engine/render.mjs ' + pos[0] + ' full');
```

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

```js
// The sound kit, made once with ElevenLabs sound generation and reused by every video → assets/sfx/<name>.wav. Dry run unless --yes.
//   node engine/gen-sfx.mjs [--yes] [name …]      (existing files are never regenerated; ~21 s of sound in all)
// Six more are synthesized for free by make-toy-sfx.mjs: knock scribble creak poof coins tear.
// A one-off sound for one video:  node engine/gen-sfx.mjs --yes --video my-video --name honk --prompt "a toy car horn honk" --sec 0.6
import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process';
import { ASSETS, ROOT, args, envKey, die } from './common.mjs';
export const KIT = {   // name: [prompt, seconds] — what each is for in the cues
  pop: ['a soft cartoon pop, bubbly and light, dry, no music', 0.5], pop_hi: ['a tiny high cartoon pop, like a bubble, very short', 0.35],   // hearts, counters, letters
  boing: ['a short cartoon spring boing, playful bounce, slightly sad', 0.5],                                                    // a sad beat, a flop
  thud: ['a wooden box landing on wooden planks, one soft low thud', 0.8], clunk: ['a hammer hitting a wooden board once, short wooden clunk', 0.5],
  whoosh: ['a quick airy whoosh, something big falling past', 0.8], swoosh: ['a soft short swoosh of paper sheets flying past', 0.7],
  swish: ['a fast thin swish, like a hand snatching a paper', 0.6], flick: ['a stiff cardboard tag flipped over quickly, short flick', 0.5],
  slap: ['a cardboard sign slapped flat onto a wooden counter, short dry slap', 0.5], click: ['a single small phone screen tap, dry click', 0.5],
  clack: ['ceramic coffee cups rattling briefly on a windowsill', 0.5], blocks: ['a stack of wooden toy blocks collapsing and tumbling onto grass', 1.2],
  impact: ['a deep soft cinematic impact with a short rumble tail', 1.5], riser: ['a short rising whoosh build-up, airy, no music', 2.0],
  thunder: ['a low distant rumble of a big building rising from the ground, no thunder crack', 1.5],
  ding: ['a bright clean microwave ding', 0.6], beep: ['a cute little robot beep, two quick tones', 0.5], enter: ['a laptop key press, short soft click', 0.5],
  kaching: ['vintage cash register ka-ching bell ring with the cash drawer sliding open, short, dry, no music', 1.3],
  shimmer: ['a warm magical shimmer, rising sparkly chimes', 2.2], chime: ['a gentle sparkling chime arpeggio, bright and short, rising', 2.0],
  plane: ['a paper airplane whooshing away quickly', 0.8],
};
async function make(file, text, sec) {
  const r = await fetch('https://api.elevenlabs.io/v1/sound-generation', { method: 'POST', headers: { 'xi-api-key': envKey('ELEVENLABS_API_KEY'), 'content-type': 'application/json' }, body: JSON.stringify({ text, duration_seconds: sec, prompt_influence: 0.6 }) });
  if (!r.ok) { console.log(path.basename(file), r.status, (await r.text()).slice(0, 200)); return; }
  const mp3 = file.replace(/\.wav$/, '.mp3'); fs.writeFileSync(mp3, Buffer.from(await r.arrayBuffer()));
  execFileSync('ffmpeg', ['-v', 'error', '-y', '-i', mp3, '-ar', '48000', '-ac', '2', file]); fs.rmSync(mp3); console.log(path.basename(file), 'ok');
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
  const { pos, opt } = args();
  if (opt.name) {   // a one-off sound in a video folder
    if (!opt.video || !opt.prompt) die('a one-off sound needs --video <folder> --name <name> --prompt "…" [--sec 1]');
    const out = path.join(ROOT, opt.video, 'sfx'); fs.mkdirSync(out, { recursive: true }); const f = path.join(out, opt.name + '.wav');
    if (fs.existsSync(f)) die(f + ' exists'); if (!opt.yes) { console.log('dry run: ' + opt.name + ' = "' + opt.prompt + '". Add --yes (costs credits).'); process.exit(0); }
    await make(f, opt.prompt, +(opt.sec || 1)); process.exit(0);
  }
  const out = path.join(ASSETS, '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 (!todo.length) process.exit(0);
  if (!opt.yes) { console.log('dry run: add --yes to generate them (sound generation costs credits).'); process.exit(0); }
  for (const n of todo) await make(path.join(out, n + '.wav'), ...KIT[n]);
}
```

### `engine/make-toy-sfx.mjs`

```js
// The kit's own synthesized sounds (ffmpeg, no ElevenLabs credits): knock (a knuckle on a wooden door), scribble (a pencil
// on paper), creak (a wooden tower leaning), poof (a soft smoke puff), coins (three clinks), tear (a card ripping).
// Writes assets/sfx/<name>.wav; an existing file is kept.
//   node engine/make-toy-sfx.mjs [--force]
import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os';
import { ASSETS, args, run, low } from './common.mjs';
low();
const { opt } = args(), out = path.join(ASSETS, 'sfx');
const make = (name, a) => { const f = path.join(out, name + '.wav'); if (fs.existsSync(f) && !opt.force) return console.log('kept', name); run('ffmpeg', ['-v', 'error', '-y', ...a(f)]); console.log('wrote', name); };
fs.mkdirSync(out, { recursive: true });
make('knock', f => ['-f', 'lavfi', '-i', "aevalsrc='(0.9*sin(2*PI*190*t)*exp(-t*55)+0.5*sin(2*PI*420*t)*exp(-t*80)+0.3*(random(0)-0.5)*exp(-t*120))*lt(t,0.18)':s=48000:d=0.2", '-af', 'lowpass=f=2400,volume=1.6,aformat=channel_layouts=stereo', '-ar', '48000', f]);
make('scribble', f => ['-f', 'lavfi', '-i', 'anoisesrc=d=1.0:c=pink:a=0.6:seed=11', '-af', 'highpass=f=1500,lowpass=f=7000,tremolo=f=13:d=0.85,tremolo=f=5:d=0.5,afade=t=in:d=0.04,afade=t=out:st=0.85:d=0.15,volume=1.3,aformat=channel_layouts=stereo', '-ar', '48000', f]);
make('creak', f => ['-f', 'lavfi', '-i', "aevalsrc='0.6*sin(2*PI*(95+40*sin(2*PI*1.3*t))*t*(1+0.02*sin(2*PI*31*t)))*(0.6+0.4*sin(2*PI*17*t))*exp(-t*1.2)':s=48000:d=1.2", '-af', 'bandpass=f=700:w=900,volume=2.2,afade=t=in:d=0.08,afade=t=out:st=0.9:d=0.3,aformat=channel_layouts=stereo', '-ar', '48000', f]);
make('poof', f => ['-f', 'lavfi', '-i', 'anoisesrc=d=0.5:c=brown:a=0.9:seed=5', '-af', 'lowpass=f=1100,highpass=f=120,afade=t=in:d=0.01,afade=t=out:st=0.05:d=0.42,volume=2.4,aformat=channel_layouts=stereo', '-ar', '48000', f]);
const tmp = path.join(os.tmpdir(), 'toy-coin.wav');
run('ffmpeg', ['-v', 'error', '-y', '-f', 'lavfi', '-i', "aevalsrc='(0.5*sin(2*PI*2350*t)*exp(-t*22)+0.35*sin(2*PI*3610*t)*exp(-t*28)+0.25*sin(2*PI*5480*t)*exp(-t*36)+0.15*sin(2*PI*7900*t)*exp(-t*50))*(lt(t,0.4))':s=48000:d=0.45", '-af', 'volume=0.9', tmp]);
make('coins', f => ['-i', tmp, '-i', tmp, '-i', tmp, '-filter_complex', '[1]adelay=75|75,asetrate=48000*1.12,aresample=48000[b];[2]adelay=160|160,asetrate=48000*0.93,aresample=48000[c];[0][b][c]amix=inputs=3:normalize=0,aformat=channel_layouts=stereo', '-ar', '48000', f]);
make('tear', f => ['-f', 'lavfi', '-i', 'anoisesrc=d=0.42:c=white:a=0.7:seed=3', '-af', 'highpass=f=1800,lowpass=f=9000,tremolo=f=38:d=0.7,afade=t=in:d=0.02,afade=t=out:st=0.2:d=0.22,volume=1.4,aformat=channel_layouts=stereo', '-ar', '48000', f]);
fs.rmSync(tmp, { force: true });
```

### `engine/mix.mjs`

```js
// The mix: voice on top, the music bed ~13 LU under it (ducked by the voice), timed sound effects → mix.wav at −14 LUFS,
// true peak ≤ −1 dBTP (RULES.md 9).
//   node engine/mix.mjs <video> [--to 6] [--music-db -17]      (--to N: only the first N s → mix-Ns.wav, for a test clip)
//   STEM=vo|music|sfx node engine/mix.mjs <video>               measure one stem (the bed should sit ~13 LU under the voice)
// Cues come from <video>/cues.mjs: export default function (ev, film) { ev('slap', 2.15, -17); … } — ev(name, t, dB, dur?)
// Sounds: <video>/sfx/<name>.wav, else assets/sfx, else ../../shared/sfx (.wav). Music: video.json music.file, same lookup.
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(); const { dir } = videoDir(pos[0], { need: 'cues.mjs' });
const 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';
const MUSIC_DB = +(opt['music-db'] ?? cfg.music.db), MUSIC_FROM = cfg.music.from;
const E = []; const ev = (name, t, db = 0, dur = null) => { if (t < LEN) E.push({ name, t, db, dur }); };
const cues = await import(pathToFileURL(path.join(dir, 'cues.mjs')).href);
(cues.default || cues.cues)(ev, film);

const voWav = path.join(dir, 'vo', `${cfg.take}.wav`); if (!fs.existsSync(voWav)) die(`vo/${cfg.take}.wav missing: node engine/align.mjs ${pos[0]}`);
const music = findAsset(dir, 'music', cfg.music.file); if (!music) die('music not found: ' + cfg.music.file);
const names = [...new Set(E.map(e => e.name))];
const files = names.map(n => findAsset(dir, 'sfx', n + '.wav') || die(`sound "${n}" not found (sfx/${n}.wav in the video, assets or ../../shared)`));
const a = ['-v', 'error', '-y', '-i', voWav, '-ss', String(MUSIC_FROM), '-t', String(LEN + 0.5), '-i', music];
files.forEach(f => a.push('-i', f));
const f = [], fadeD = opt.to ? 0.3 : 1.4, voD = Math.round(cfg.voOff * 1000);
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${voD ? `,adelay=${voD}|${voD}` : ''},apad=whole_dur=${LEN.toFixed(3)},asplit=2[vo][sc]`);
const mz = (cfg.music.muffle || []).map(([m0, m1]) => `between(t,${m0},${m1})*min(1,(t-${m0})/0.35)`).join('+') || '0';
f.push(`[1:a]aresample=48000,aformat=channel_layouts=stereo,apad=whole_dur=${(LEN + 0.2).toFixed(3)},volume=${MUSIC_DB}dB,asplit=2[mA][mB]`);
f.push(`[mA]volume='1-(${mz})':eval=frame[mA2]`);
f.push(`[mB]lowpass=f=420,lowpass=f=420,volume='1.6*(${mz})':eval=frame[mB2]`);
f.push(`[mA2][mB2]amix=inputs=2:normalize=0,afade=t=in:d=0.05,afade=t=out:st=${(LEN - fadeD).toFixed(2)}:d=${fadeD}[mus]`);
f.push(`[mus][sc]sidechaincompress=threshold=0.04:ratio=5:attack=15:release=350:makeup=1[duck]`);
const STEM = process.env.STEM || '';
const outs = STEM === 'music' ? ['[duck]'] : STEM === 'vo' ? ['[vo]'] : STEM === 'sfx' ? [] : ['[vo]', '[duck]'];
if (STEM && STEM !== 'vo') f.push('[vo]anullsink'); if (STEM && STEM !== 'music') f.push('[duck]anullsink');
names.forEach((n, k) => {
  const list = E.filter(e => e.name === n);
  f.push(`[${k + 2}:a]aresample=48000,aformat=channel_layouts=stereo,asplit=${list.length}${list.map((_, j) => `[${n}${j}]`).join('')}`);
  list.forEach((e, j) => {
    const parts = [];
    if (e.dur) parts.push(`atrim=0:${e.dur.toFixed(3)}`, `afade=t=out:st=${Math.max(0, e.dur - 0.08).toFixed(3)}:d=0.08`);
    parts.push(`volume=${e.db}dB`, `adelay=${Math.round(e.t * 1000)}|${Math.round(e.t * 1000)}`);
    f.push(`[${n}${j}]${parts.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 filt = path.join(dir, 'mix.filter'); fs.writeFileSync(filt, f.join(';\n'));
const pre = path.join(dir, STEM ? `stem_${STEM}.wav` : 'mix_pre.wav');
execFileSync('ffmpeg', [...a, '-filter_complex_script', filt, '-map', '[pre]', '-ar', '48000', pre], { stdio: 'inherit' });
if (STEM) { const x = loud(pre); console.log(STEM, `I ${x.I} LUFS · TP ${x.TP} dBFS · LRA ${x.LRA}`); process.exit(0); }
const I = loud(pre).I; console.log('premix', I, 'LUFS ·', E.length, 'sound events');
let gain = -14 - I, fin;
for (let k = 0; k < 3; k++) {
  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}: I ${fin.I} LUFS · true peak ${fin.TP} dBTP · LRA ${fin.LRA} LU`, Math.abs(fin.I + 14) <= 1 && fin.TP <= -1 ? '✓' : '✗ check');
```

### `engine/encode.mjs`

```js
// frames + mix → video.mp4 with the RULES.md 10 settings, plus cover.jpg, then a report (ffprobe + loudness + limits).
//   node engine/encode.mjs <video>                            → <video>/video.mp4 + <video>/cover.jpg
//   node engine/encode.mjs <video> --to 6 --out test-6s.mp4   (first 6 s, audio from mix-6s.wav)
// H.264 High, yuv420p, 1080x1920, 30 fps, -crf 20 -maxrate 10M -bufsize 20M, AAC 192k, +faststart.
// Size cap ≤ 60 MB: when duration × (maxrate + audio) could pass 58 MB, maxrate is lowered to fit (and again if the file is still too big).
import fs from 'node:fs'; import path from 'node:path';
import { FPS, args, videoDir, loadVideo, low, run, die } from './common.mjs';
import { report } from './encode-report.mjs';
low();
const { pos, opt } = args(); const { dir } = videoDir(pos[0], { need: 'film.json' });
const 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);
const audio = path.join(dir, opt.to ? `mix-${+opt.to}s.wav` : 'mix.wav');
if (!fs.existsSync(audio)) die(`${path.basename(audio)} missing: node engine/mix.mjs ${pos[0]}${opt.to ? ' --to ' + opt.to : ''}`);
const frame = f => path.join(dir, 'frames', `f${String(f).padStart(4, '0')}.jpg`);
for (const f of [0, NF - 1]) if (!fs.existsSync(frame(f))) die(`frame ${f} missing: render frames 0–${NF} first`);
const out = path.join(dir, opt.out || 'video.mp4'), CAP = 60e6, AIM = 58e6;
let maxK = Math.min(10000, Math.floor((AIM * 8 / DUR - 192e3 - 64e3) / 1000));
for (let pass = 0; pass < 4; pass++) {
  console.log(`encode ${DUR.toFixed(2)} s · crf 20 · maxrate ${maxK}k · bufsize ${2 * maxK}k`);
  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: convert, or 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]);
  if (fs.statSync(out).size <= CAP) break;
  maxK = Math.floor(maxK * 0.85); console.log('over 60 MB, again with a lower maxrate');
}
if (!opt.to) { // cover = the hook frame
  const cf = Math.min(NF - 1, Math.round((cfg.cover || 0) * FPS));
  run('ffmpeg', ['-v', 'error', '-y', '-i', frame(cf), '-vf', 'scale=1080:1920', '-q:v', '2', path.join(dir, 'cover.jpg')]);
  console.log('cover.jpg = frame', cf);
}
report(out);
```

### `engine/encode-report.mjs`

```js
// ffprobe + loudness report against the RULES.md 9/10 limits (used by encode.mjs and check.mjs).
import path from 'node:path';
import { probe, loud } from './common.mjs';

export function report(file) {
  const p = probe(file), v = p.streams.find(s => s.codec_type === 'video'), a = p.streams.find(s => s.codec_type === 'audio');
  const size = +p.format.size, dur = +p.format.duration, L = loud(file);
  const rows = [
    ['file', path.basename(file)], ['duration', dur.toFixed(2) + ' s'], ['size', (size / 1e6).toFixed(2) + ' MB'], ['bitrate', (p.format.bit_rate / 1e6).toFixed(2) + ' Mb/s total'],
    ['video', `${v.codec_name} ${v.profile} ${v.pix_fmt} ${v.width}x${v.height} ${v.r_frame_rate} fps · ${(v.bit_rate / 1e6).toFixed(2)} Mb/s`],
    ['audio', a ? `${a.codec_name} ${a.sample_rate} Hz ${a.channels} ch · ${Math.round(a.bit_rate / 1e3)} kb/s` : 'none'],
    ['loudness', `${L.I} LUFS integrated · true peak ${L.TP} dBTP · LRA ${L.LRA} LU`],
  ];
  for (const [k, x] of rows) console.log(k.padEnd(9), x);
  const ok = [size <= 60e6, dur <= 60.05, v.profile === 'High' && v.pix_fmt === 'yuv420p' && v.width === 1080 && v.height === 1920 && v.r_frame_rate === '30/1', a && a.codec_name === 'aac', Math.abs(L.I + 14) <= 1, L.TP <= -1];
  console.log(ok.every(Boolean) ? '✓ RULES encode limits met' : '✗ outside the RULES limits: ' + ['size', 'duration', 'video format', 'audio', 'loudness', 'true peak'].filter((_, i) => !ok[i]).join(', '));
  return ok.every(Boolean);
}
```

### `engine/check.mjs`

```js
// The RULES.md 12 checks on the finished video.   node engine/check.mjs <video> [--file video.mp4] [--no-whisper]
// 1. format / size / loudness  2. frame 0 isn't black or empty  3. captions inside the safe zone on 5 random frames, and
// every frame's text (frames/safe.json, written by the full render)  4. whisper the final mix, compare word by word with script.txt.
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { ENGINE, args, videoDir, readScript, low, run, die, hasWhisper } from './common.mjs';
import { report } from './encode-report.mjs';
low();
const { pos, opt } = args(); const { dir } = videoDir(pos[0], { need: 'film.json' });
const file = path.join(dir, opt.file || 'video.mp4'); if (!fs.existsSync(file)) die(path.basename(file) + ' missing');
let ok = report(file);

// 2. frame 0
const r = spawnSync('ffmpeg', ['-v', 'error', '-i', file, '-frames:v', '1', '-vf', 'scale=54:96,format=gray', '-f', 'rawvideo', '-'], { maxBuffer: 1e7 });
const px = [...r.stdout], mean = px.reduce((a, b) => a + b, 0) / px.length, sd = Math.sqrt(px.reduce((a, b) => a + (b - mean) ** 2, 0) / px.length);
const f0 = mean > 25 && sd > 12; ok &&= f0;
console.log(`frame 0   mean ${mean.toFixed(0)} · contrast ${sd.toFixed(0)}`, f0 ? '✓ not black or empty' : '✗ looks black or flat');

// 3. safe zone
const sf = path.join(dir, 'frames', 'safe.json');
if (fs.existsSync(sf)) {
  const S = JSON.parse(fs.readFileSync(sf, 'utf8')), cf = Object.keys(S.captions), bad = Object.keys(S.bad);
  const pick = [...new Set(Array.from({ length: Math.min(5, cf.length) }, () => cf[Math.floor(Math.random() * cf.length)]))];
  for (const f of pick) { const [x0, x1, y0, y1] = S.captions[f]; const good = x0 >= 97 && x1 <= 923 && y0 >= 1147 && y1 <= 1483; ok &&= good; console.log(`captions  f${f} x ${x0}–${x1} y ${y0}–${y1}`, good ? '✓' : '✗'); }
  console.log(bad.length ? `✗ ${bad.length} frames have text outside the safe zone, e.g. f${bad[0]}: ${JSON.stringify(S.bad[bad[0]][0])}` : `✓ text inside the safe zone on every rendered frame`);
  ok &&= !bad.length;
} else console.log('(no frames/safe.json: render with engine/render.mjs full to get the per-frame safe-zone log)');

// 4. whisper vs script
if (!opt['no-whisper'] && !hasWhisper()) console.log('(Whisper not installed: listen to the mix yourself and compare it with script.txt)');
else if (!opt['no-whisper']) {
  const w16 = path.join(dir, 'check-16.wav'), wj = path.join(dir, 'check-whisper.json');
  run('ffmpeg', ['-v', 'error', '-y', '-i', file, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', w16]);
  console.log('whisper…'); run('python', [path.join(ENGINE, 'whisper-words.py'), w16, wj]); fs.rmSync(w16, { force: true });
  const norm = s => s.toLowerCase().replace(/\[[^\]]*\]/g, ' ').replace(/[’]/g, "'").replace(/[^a-z0-9' ]+/g, ' ').split(/\s+/).filter(Boolean);
  const film = JSON.parse(fs.readFileSync(path.join(dir, 'film.json'), 'utf8')), dur = +JSON.parse(spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'json', file], { encoding: 'utf8' }).stdout).format.duration;
  // a short test clip only says the words that end inside it
  let A = dur < film.END - 0.5 ? norm(film.words.filter(w => w.t1 <= dur + 0.05).map(w => w.w).join(' ')) : norm(readScript(dir));
  let B = norm(JSON.parse(fs.readFileSync(wj, 'utf8')).segments.map(s => s.text).join(' '));
  const setA = new Set(A); for (let j = 0; j < B.length - 1; j++) if (!setA.has(B[j]) && setA.has(B[j] + B[j + 1])) B.splice(j, 2, B[j] + B[j + 1]); // "cap cut" → "capcut"
  const D = Array.from({ length: A.length + 1 }, () => new Int32Array(B.length + 1));
  for (let i = A.length - 1; i >= 0; i--) for (let j = B.length - 1; j >= 0; j--) D[i][j] = A[i] === B[j] ? D[i + 1][j + 1] + 1 : Math.max(D[i + 1][j], D[i][j + 1]);
  const miss = [], extra = []; let i = 0, j = 0;
  while (i < A.length && j < B.length) { if (A[i] === B[j]) { i++; j++; } else if (D[i + 1][j] >= D[i][j + 1]) miss.push(A[i++]); else extra.push(B[j++]); }
  miss.push(...A.slice(i)); extra.push(...B.slice(j));
  const acc = D[0][0] / A.length; ok &&= acc >= 0.95;
  console.log(`whisper   ${D[0][0]}/${A.length} script words heard (${(acc * 100).toFixed(1)} %)`);
  if (miss.length) console.log('  not heard:', miss.join(' '));
  if (extra.length) console.log('  heard, not in the script:', extra.join(' '));
  console.log(acc >= 0.95 ? '✓ the mix says the script (names and numbers are often spelled differently: read the lists)' : '✗ listen to the take: words are missing');
}
console.log(ok ? '\n✓ all checks passed' : '\n✗ some checks failed');
```


## Appendix B: an example video (complete)

"Never pay for a content skill" (57.2 s): a text file nailed to the tree becomes a content skill, a platform, a tower of formats, one clean SKILL.md; a raccoon in a suit resells it; then the library. Copy these into `my-video/`, rewrite `script.txt` for your story and the beats for your script.

### `my-video/script.txt`

```text
# Toybox · "Never pay for a content skill", reworked into the reference's grammar (@wemakebuild "How a treehouse becomes a skyscraper"):
# "If you … and tell <character> to …, congratulations! You've built …" → a comic failure → "So, you …" fixes, each one growing the build →
# "Now …, but he wants more" → numbers → "Congratulations! You've built …" → someone knocks → a little animal in a business suit takes it → the turn.
# The voice reads it with video.json "say" applied (SKILL.md, toybox.md, .md, $30, $5, TOYBOX); captions show it as written.
If you write three lines in a text file and tell Claude to make a reel, congratulations!
You've built a content skill.
A content skill with the creative range of a microwave manual.
Claude posts one video, and it gets three views.
Two of them are Grandma. The third is a bot.
So, you give it a hook, a style, and a voice.
Now Claude makes reels people actually finish, but he wants a second format.
So, you write another format, then a third, then a fourth.
By the tenth format, the whole thing starts leaning like it's had seventeen coffees.
So, you pack it all into one clean SKILL.md.
Congratulations! You've built a content format.
And you give it away, completely free.
Everything is perfect, until someone knocks on your door.
It's a raccoon wearing a tiny business suit.
He took your free file, repackaged it, and now sells it for $30.
$30! For something you gave away for free.
He even charges Claude $5 to read it.
That actually happened to me.
I'm Casper, and I built Morphy, an agentic super app.
So here's my response.
I'm building an entire library of ready-to-use content-format .md files inside Morphy.
No paywalls. No subscriptions. No strings attached.
Never pay for a Claude content skill again.
This format is called toybox.md.
So comment TOYBOX and I'll send it to you. Completely free.
```

### `my-video/video.json`

```json
{
  "take": "b",
  "voOff": 0.1,
  "tail": 1.6,
  "say": {
    "SKILL.md": "skill dot M D",
    "toybox.md": "toybox dot M D",
    ".md": "dot M D",
    "$30": "thirty dollars",
    "$5": "five dollars",
    "TOYBOX": "\"Toybox\""
  },
  "capFix": {},
  "chunkMax": 1,
  "chunkW": 760,
  "music": {
    "file": "coffee-or-tea.mp3",
    "from": 8,
    "db": -10.5,
    "muffle": []
  },
  "cover": 0.4,
  "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
    }
  }
}
```

### `my-video/scene.js`

```js
// THE PER-VIDEO DIRECTOR FILE — "Never pay for a content skill" (Toybox, 57.2 s), after @wemakebuild's "How a treehouse
// becomes a skyscraper": a toy suburb, one backyard. A text file nailed to the big tree grows into a platform, a wobbly
// tower of formats, one clean SKILL.md; a raccoon in a tiny business suit resells it; then the library skyscraper.
// setup(E) builds the world + the cast and returns SHOTS (one per beat), world(tq, t, shot) and overlay(g, t).
// Every beat = [cut time, camera, run(t)]: the cut sits just before its word (T('word', n) = when token n starts).
export default function setup(E) {
  const { THREE, scene, camera, YD, TOY, PR, C, L, O, FX, film, LT } = E;
  const { ss, seg, lerp, clamp, easeOut, easeIn, easeInOut, easeOutBack, springy, hop } = L;
  const V3 = (x, y, z) => new THREE.Vector3(x, y, z);
  const add = (o, x = 0, y = 0, z = 0, ry = 0, parent = scene) => { o.position.set(x, y, z); o.rotation.y = ry; parent.add(o); return o; };
  const norm = s => s.toLowerCase().replace(/[^a-z0-9$.']/g, '').replace(/\.$/, '');
  const T = (w, n = 1) => { const k = norm(w); const hits = film.words.filter(v => norm(v.cap) === k); if (!hits[n - 1]) throw new Error(`word not found: ${w} #${n}`); return hits[n - 1].t0; };
  const END = film.END;
  const YC = YD.YC;

  // ===================== THE WORLD =====================
  scene.add(YD.sky());
  [[-34, 24, -95, 5.5], [8, 30, -120, 7], [44, 22, -100, 5], [-70, 20, -85, 6], [80, 27, -110, 6], [-16, 34, -140, 8], [26, 18, -70, 3.5]].forEach(([x, y, z, s], i) => add(YD.cloud({ s, seed: 90 + i }), x, y, z));
  scene.add(YD.lawn({ stripe: 2.4 }));
  const HOUSE = add(YD.house({ w: 7.5, d: 6, h: 4.6, door: 1.8, seed: 3 }), -6.5, 0, -8);
  const DOOR = HOUSE.userData.door;
  add(YD.patio({ w: 7.6, d: 2.8 }), -6.5, 0, -3.6);
  const grill = add(YD.grill(), -4.1, 0.06, -3.4, 0.3);
  const TREE = [2.2, 0, -4.5];
  const tree = add(YD.bigTree({ seed: 21 }), ...TREE);
  add(YD.fence([-16, -13], [16, -13]), 0, 0, 0); add(YD.fence([14, -13], [14, 12]), 0, 0, 0); add(YD.fence([-16, -13], [-16, 12]), 0, 0, 0);
  [[-14, -21, 0], [-3, -22, 1], [8, -21, 2], [19, -22, 3], [-21, -37, 4], [-9, -38, 5], [16, -37, 7], [28, -36, 8], [24, -6, 9], [25, 7, 10]].forEach(([x, z, s]) => add(YD.house({ w: 7, d: 6, h: 4.4, door: 0, seed: 10 + s, roof: s % 2 ? YC.roof : YC.roof2, shutter: ['#3a6fc4', '#2f9e8f', '#3a6fc4', '#c9533a'][s % 4] }), x, 0, z, x > 20 ? -Math.PI / 2 : 0));
  const R = YD.rnd(77);
  for (let i = 0; i < 80; i++) { let x = (R() - 0.5) * 240, z = -52 - R() * 120; if (Math.abs(x - 3) < 12 && z > -66) x += 26; add(YD.farHouse({ seed: i, w: 5 + R() * 2, d: 4.5 + R() * 1.5, h: 3.6 + R() }), x, 0, z, (R() - 0.5) * 0.3); }
  for (let i = 0; i < 34; i++) { let x = (R() - 0.5) * 220, z = -44 - R() * 110; if (Math.abs(x - 3) < 10 && z > -62) x -= 22; add(YD.smallTree({ seed: 100 + i, h: 2.2 + R() * 1.4, r: 1.1 + R() * 0.6 }), x, 0, z); }
  [[-12, -11], [10.5, -11.5], [12.4, -6], [-14, 4]].forEach(([x, z], i) => add(YD.smallTree({ seed: 200 + i, h: 2.4, r: 1.3 }), x, 0, z));
  [[-10.6, -4.9], [-2.4, -12.2], [6.5, -12.2], [12.6, 2], [-13.8, -2]].forEach(([x, z], i) => add(YD.bush({ seed: 300 + i, r: 0.7 }), x, 0, z));

  // the text file nailed to the trunk (its canvas is redrawn as the lines get written)
  const SHEET = [2.2, 1.62, -3.95];
  const sheet = add(YD.paper({ w: 0.62, h: 0.82 }), ...SHEET); sheet.rotation.x = -0.05;
  for (const sx of [-1, 1]) { const n = C.blob(0.022, 0.022, 0.02, TOY.toyMat('#8a8f99', { rough: 0.3 }), { lump: 0 }); n.position.set(sx * 0.24, 0.35, 0.01); sheet.add(n); }
  const LINES = ['1. make a reel', '2. make it good', '3. thx'];
  function drawSheet(p) {
    sheet.userData.scr.draw('s' + Math.round(p * 40), (g, w, h) => {
      g.fillStyle = '#fbf8ef'; g.fillRect(0, 0, w, h);
      g.strokeStyle = 'rgba(70,120,200,0.35)'; g.lineWidth = 2; for (let y = 150; y < h; y += 62) { g.beginPath(); g.moveTo(0, y); g.lineTo(w, y); g.stroke(); }
      g.strokeStyle = 'rgba(220,70,70,0.5)'; g.beginPath(); g.moveTo(52, 0); g.lineTo(52, h); g.stroke();
      g.fillStyle = '#2a2a33'; g.font = '700 34px Poppins'; g.textAlign = 'left'; g.fillText('reel-skill.txt', 66, 92);
      g.fillStyle = '#2449a8'; g.font = '700 40px Poppins';
      LINES.forEach((ln, i) => { const k = clamp(p - i); if (k <= 0) return; g.fillText(ln.slice(0, Math.ceil(ln.length * k)), 66, 138 + (i + 1) * 62); });
    });
  }
  drawSheet(0);
  const pencil = new THREE.Group(); { const y = TOY.toyMat('#f2c230', { rough: 0.45 }); const b = C.mesh(new THREE.CylinderGeometry(0.032, 0.032, 0.42, 6), y); pencil.add(b); const tip = C.mesh(new THREE.ConeGeometry(0.032, 0.08, 6), TOY.toyMat('#f0d2a8')); tip.position.y = -0.25; tip.rotation.x = Math.PI; pencil.add(tip); const lead = C.mesh(new THREE.ConeGeometry(0.011, 0.028, 6), TOY.toyMat('#2a2a30')); lead.position.y = -0.28; lead.rotation.x = Math.PI; pencil.add(lead); const er = C.capsule(0.033, 0.04, TOY.toyMat('#f08aa0'), { lump: 0 }); er.position.y = 0.23; pencil.add(er); scene.add(pencil); }

  // patio: a side table with the microwave and the robot
  const sideT = add(YD.table({ w: 1.5, d: 0.75, h: 0.78, color: '#ffffff' }), -8.0, 0.06, -3.3);
  const micro = add(YD.microwave(), -8.35, 0.84, -3.3, 0.12);
  const bot = add(YD.robot(), -7.5, 0.84, -3.25, -0.2);
  const botPhone = add(YD.phone({ w: 0.12, h: 0.22 }), 0, 0, 0); botPhone.visible = false;
  // phones
  const phoneC = add(YD.phone({ w: 0.3, h: 0.54 }), 0, 0, 0); phoneC.visible = false;
  const granPh = [0, 1].map(() => { const p = add(YD.phone({ w: 0.2, h: 0.36, color: ['#e86a8a', '#6a8ae8'][0] }), 0, 0, 0); p.visible = false; return p; });
  function drawPhone(ph, mode, n = 0) {
    ph.userData.scr.draw(mode + n, (g, w, h) => {
      g.fillStyle = '#16151c'; g.fillRect(0, 0, w, h);
      if (mode === 'post' || mode === 'posted') {
        const gr = g.createLinearGradient(0, 0, 0, h * 0.62); gr.addColorStop(0, '#ffb37a'); gr.addColorStop(1, '#e8763a'); g.fillStyle = gr; g.beginPath(); g.roundRect(24, 40, w - 48, h * 0.56, 22); g.fill();
        g.fillStyle = '#fff'; g.beginPath(); g.moveTo(w / 2 - 34, h * 0.25); g.lineTo(w / 2 + 44, h * 0.32); g.lineTo(w / 2 - 34, h * 0.39); g.fill();
        g.fillStyle = mode === 'posted' ? '#3ccf6a' : '#3a7cf0'; g.beginPath(); g.roundRect(40, h * 0.72, w - 80, 110, 55); g.fill();
        g.fillStyle = '#fff'; g.font = '800 64px Poppins'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(mode === 'posted' ? 'POSTED' : 'POST', w / 2, h * 0.72 + 58);
      } else if (mode === 'views' || mode === 'viral') {
        const gr = g.createLinearGradient(0, 0, 0, h); gr.addColorStop(0, '#ffb37a'); gr.addColorStop(1, '#e8763a'); g.fillStyle = gr; g.beginPath(); g.roundRect(24, 40, w - 48, h * 0.5, 22); g.fill();
        g.fillStyle = '#fff'; g.beginPath(); g.moveTo(w / 2 - 30, h * 0.22); g.lineTo(w / 2 + 40, h * 0.28); g.lineTo(w / 2 - 30, h * 0.34); g.fill();
        g.textAlign = 'center'; g.textBaseline = 'middle';
        g.fillStyle = '#9a98a8'; g.font = '700 46px Poppins'; g.fillText('views', w / 2, h * 0.84);
        g.fillStyle = mode === 'viral' ? '#3ccf6a' : '#ffffff'; g.font = `800 ${mode === 'viral' ? 120 : 190}px Poppins`;
        g.fillText(mode === 'viral' ? (n >= 1000 ? (n / 1000).toFixed(1) + 'K' : String(n)) : String(n), w / 2, h * 0.7);
      } else if (mode === 'heart') {
        g.fillStyle = '#ffe3ea'; g.fillRect(0, 0, w, h); g.fillStyle = '#f0284f'; const x = w / 2, y = h * 0.42, s = w * 0.3;
        g.beginPath(); g.moveTo(x, y + s * 0.9); g.bezierCurveTo(x - s * 1.6, y - s * 0.1, x - s * 0.7, y - s * 1.3, x, y - s * 0.45); g.bezierCurveTo(x + s * 0.7, y - s * 1.3, x + s * 1.6, y - s * 0.1, x, y + s * 0.9); g.fill();
        g.fillStyle = '#16151c'; g.font = '800 70px Poppins'; g.textAlign = 'center'; g.fillText('1 view', w / 2, h * 0.82);
      } else if (mode === 'bot') { g.fillStyle = '#d7e3ff'; g.fillRect(0, 0, w, h); g.fillStyle = '#16151c'; g.font = '800 90px Poppins'; g.textAlign = 'center'; g.fillText('👁', w / 2, h * 0.5); }
    });
  }
  drawPhone(botPhone, 'bot');
  function drawBotFace(mode) { bot.userData.scr.draw(mode, (g, w, h) => { g.fillStyle = '#10202c'; g.fillRect(0, 0, w, h); g.fillStyle = '#7dfcff'; g.shadowColor = '#7dfcff'; g.shadowBlur = 12; if (mode === 'blink') { g.fillRect(36, 52, 30, 8); g.fillRect(94, 52, 30, 8); } else if (mode === 'happy') { g.lineWidth = 9; g.strokeStyle = '#7dfcff'; for (const x of [51, 109]) { g.beginPath(); g.arc(x, 62, 15, Math.PI * 1.1, Math.PI * 1.9); g.stroke(); } } else { g.beginPath(); g.arc(51, 52, 14, 0, 7); g.arc(109, 52, 14, 0, 7); g.fill(); } g.fillRect(58, 84, 44, 6); }); }
  drawBotFace('open');

  // the platform + the tower of formats (pivot at the platform's base for the lean)
  const TW = [7.2, 0, -3.2], DH = 1.55;
  const towerP = add(new THREE.Group(), TW[0], 0, TW[2]);
  const wood = TOY.toyMat(YC.wood, { rough: 0.6 }), woodD = TOY.toyMat(YC.woodD, { rough: 0.6 });
  const posts = [[-1, -1], [1, -1], [-1, 1], [1, 1]].map(([sx, sz]) => { const p = C.rbox(0.16, DH, 0.16, 0.03, woodD, { lump: 0 }); p.geometry.translate(0, DH / 2, 0); p.position.set(sx * 1.05, 0, sz * 1.05); towerP.add(p); return p; });
  const deck = C.rbox(2.5, 0.14, 2.5, 0.04, wood, { lump: 0 }); deck.position.y = DH; towerP.add(deck);
  const deckLadder = YD.ladder({ h: 1.75, w: 0.42 }); deckLadder.position.set(0.55, 0, 1.45); deckLadder.rotation.x = -0.28; towerP.add(deckLadder);
  const BOARDS = ['HOOK', 'STYLE', 'VOICE'].map((w, i) => { const b = YD.board({ w: 0.74, h: 0.3, text: w, bg: '#e9c48a', ink: '#3a2414', size: 0.66 }); b.position.set(-0.82 + i * 0.82, DH - 0.02, 1.3); towerP.add(b); return b; });
  // cabins: format 2..10, wooden siding with coloured shutters (the reference's tower), coffee cups on the sills
  const plankTex = (() => { const s = YD.screen(256, 256); s.draw('p', (g, w, h) => { g.fillStyle = '#c99a62'; g.fillRect(0, 0, w, h); for (let y = 0; y < h; y += 32) { g.fillStyle = 'rgba(90,55,25,0.55)'; g.fillRect(0, y, w, 3); g.fillStyle = 'rgba(255,230,190,0.18)'; g.fillRect(0, y + 3, w, 3); } for (let k = 0; k < 40; k++) { g.fillStyle = 'rgba(120,80,40,0.12)'; g.fillRect((k * 53) % w, ((k * 97) % h), 40 + (k % 5) * 12, 2); } }); s.tex.wrapS = s.tex.wrapT = THREE.RepeatWrapping; s.tex.repeat.set(2, 1.2); return s.tex; })();
  const plankM = new THREE.MeshStandardMaterial({ map: plankTex, roughness: 0.75 });
  const SH = ['#5fb6c9', '#a77fd6', '#e8c14a', '#5f8fe0', '#e07a5f', '#68c08a'];
  const CH = 1.38;
  const cups = [];
  const cabins = [];
  for (let i = 0; i < 9; i++) {
    const g = new THREE.Group(); const R2 = YD.rnd(500 + i);
    const box = new THREE.Mesh(new THREE.BoxGeometry(2.3, CH - 0.04, 2.3), plankM); box.position.y = CH / 2; box.castShadow = box.receiveShadow = true; g.add(box);
    const trim = C.rbox(2.38, 0.08, 2.38, 0.03, woodD, { lump: 0 }); trim.position.y = 0.04; g.add(trim);
    const sc = SH[i % SH.length];
    for (const [x, z, ry] of [[-0.55, 1.16, 0], [0.55, 1.16, 0], [1.16, 0, Math.PI / 2], [-1.16, 0, -Math.PI / 2]]) {
      const wn = new THREE.Group(); wn.position.set(x, CH * 0.55, z); wn.rotation.y = ry; g.add(wn);
      const fr = C.rbox(0.5, 0.46, 0.06, 0.02, TOY.toyMat('#ffffff'), { lump: 0 }); wn.add(fr);
      const gl = C.rbox(0.4, 0.36, 0.04, 0.01, TOY.toyMat('#1d2a3a', { rough: 0.3 }), { lump: 0 }); gl.position.z = 0.02; wn.add(gl);
      for (const s of [-1, 1]) { const st = C.rbox(0.18, 0.48, 0.04, 0.01, TOY.toyMat(sc, { rough: 0.5 }), { lump: 0 }); st.position.set(s * 0.36, 0, 0.02); wn.add(st); }
      const sill = C.rbox(0.56, 0.05, 0.14, 0.01, TOY.toyMat('#ffffff'), { lump: 0 }); sill.position.set(0, -0.26, 0.06); wn.add(sill);
      if (z > 0 && i >= 1 && cups.length < 17) { const cup = new THREE.Group(); const c1 = C.mesh(new THREE.CylinderGeometry(0.06, 0.05, 0.12, 16), TOY.toyMat('#ffffff', { rough: 0.4 })); c1.position.y = 0.06; cup.add(c1); const cf = C.mesh(new THREE.CylinderGeometry(0.056, 0.056, 0.01, 16), TOY.toyMat('#5a3418')); cf.position.y = 0.115; cup.add(cf); const lid = C.mesh(new THREE.TorusGeometry(0.03, 0.01, 6, 12, Math.PI), TOY.toyMat('#ffffff')); lid.position.set(0.065, 0.07, 0); lid.rotation.z = -Math.PI / 2; cup.add(lid); cup.scale.setScalar(2.0); cup.position.set((cups.length % 2 ? 0.14 : -0.14), -0.23, 0.1); wn.add(cup); cups.push(cup); }
    }
    if (i === 8) { const roofG = new THREE.ConeGeometry(1.85, 0.9, 4); roofG.rotateY(Math.PI / 4); const rf = C.mesh(roofG, TOY.toyMat(YC.roof, { rough: 0.6 })); rf.position.y = CH + 0.45; g.add(rf); }
    const tag = YD.board({ w: 0.62, h: 0.26, text: 'FORMAT ' + (i + 2), bg: '#fff6dc', ink: '#2a2a33', size: 0.6 }); tag.position.set(0, CH * 0.12 + 0.1, 1.2); tag.scale.setScalar(0.9); g.add(tag);
    g.userData = { jx: (R2() - 0.5) * 0.22, jz: (R2() - 0.5) * 0.18, jr: (R2() - 0.5) * 0.14 };
    g.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
    towerP.add(g); cabins.push(g);
  }
  // the SKILL.md monument (after the collapse) + a stack of free copies on its pedestal
  const mono = add(new THREE.Group(), TW[0], 0, TW[2] + 0.2);
  const ped = C.rbox(2.1, 0.55, 1.0, 0.08, TOY.toyMat('#f4f1ea', { rough: 0.4 }), { lump: 0 }); ped.position.y = 0.275; mono.add(ped);
  const pedTrim = C.rbox(2.18, 0.08, 1.08, 0.03, TOY.toyMat('#e2b84a', { rough: 0.25, env: 1.3 }), { lump: 0 }); pedTrim.position.y = 0.55; mono.add(pedTrim);
  const bigDoc = PR.mdFile({ label: 'SKILL.md', w: 1.5, h: 1.95, size: 0.27, ink: '#2b4fa8', lines: 6, seed: 401 }); bigDoc.position.set(0, 0.6 + 0.975, 0); mono.add(bigDoc);
  const stack = []; for (let i = 0; i < 5; i++) { const f = PR.mdFile({ label: 'SKILL.md', w: 0.34, h: 0.44, size: 0.055, ink: '#2b4fa8', lines: 3, seed: 410 + i }); f.rotation.x = -Math.PI / 2; f.position.set(0.72, 0.6 + i * 0.035, 0.18); mono.add(f); stack.push(f); }
  const freeSign = add(YD.board({ w: 1.0, h: 0.46, text: 'FREE', bg: YC.yellow, ink: YC.red, post: 1.0, size: 0.72 }), TW[0] + 1.75, 1.23, TW[2] + 0.9, -0.25);
  const copies = [0, 1, 2, 3, 4, 5].map(i => add(PR.mdFile({ label: 'SKILL.md', w: 0.34, h: 0.44, size: 0.055, ink: '#2b4fa8', lines: 3, seed: 420 + i }), 0, 0, 0));
  const COPY_TO = [[3.5, 3.4, -22], [8, 3, -21], [11, 3.2, -21.5], [6, 3, -21], [13, 3, -21], [9, 3.6, -37]];
  // the lawn chair + lemonade, the bench + its reading fee
  const chair = add(YD.lawnChair({ color: '#f2c94c' }), -1.0, 0, -0.9, 0.2);
  const lemon = add(new THREE.Group(), 0, 0, 0); { const gl = C.mesh(new THREE.CylinderGeometry(0.05, 0.045, 0.14, 16), TOY.toyMat('#ffffff', { transparent: true, opacity: 0.55, rough: 0.1 })); gl.position.y = 0.07; lemon.add(gl); const lq = C.mesh(new THREE.CylinderGeometry(0.044, 0.04, 0.1, 16), TOY.toyMat('#ffe36a', { rough: 0.2 })); lq.position.y = 0.055; lemon.add(lq); const straw = C.mesh(new THREE.CylinderGeometry(0.006, 0.006, 0.2, 6), TOY.toyMat('#e5322d')); straw.position.set(0.015, 0.14, 0); straw.rotation.z = 0.2; lemon.add(straw); }
  const bench = add(YD.bench({ w: 1.7 }), TREE[0], 0, TREE[2] + 1.35);
  const feeSign = add(YD.board({ w: 0.9, h: 0.42, text: 'READING $5', bg: '#fff6dc', ink: YC.red, post: 0.9, size: 0.5 }), TREE[0] - 1.45, 1.12, TREE[2] + 1.6, 0.25);
  // the raccoon's stall
  const ST = [9.8, 0, 1.2];
  const stall = add(new THREE.Group(), ...ST);
  stall.add(PR.counter({ w: 2.0, d: 0.7, h: 1.0 }));
  const aw = PR.awning({ w: 2.5, d: 1.0, slope: 0.32, colors: [YC.red, '#ffffff'] }); aw.position.set(0, 2.35, -0.55); stall.add(aw);
  for (const sx of [-1, 1]) { const p = C.rbox(0.08, 2.4, 0.08, 0.02, TOY.toyMat(YC.wood), { lump: 0 }); p.position.set(sx * 1.1, 1.2, -0.42); stall.add(p); }
  const stallSign = YD.board({ w: 1.6, h: 0.42, text: 'SKILLS', bg: '#22305e', ink: YC.yellow, size: 0.7 }); stallSign.position.set(0, 2.62, -0.5); stall.add(stallSign);
  const closed = YD.board({ w: 1.0, h: 0.36, text: 'CLOSED', bg: '#ffffff', ink: YC.red, size: 0.62 }); stall.add(closed);
  const gift = add(PR.giftBox({ s: 0.42, color: '#d84a8a', ribbon: '#ffd23a' }), 0, 0, 0);
  const tagG = add(new THREE.Group(), 0, 0, 0);
  const tag30 = PR.priceTag({ text: '$30', w: 0.5, h: 0.3, ink: YC.red }); tagG.add(tag30);
  const tag0 = PR.priceTag({ text: '$0', w: 0.5, h: 0.3, ink: '#2f9e57' }); tag0.rotation.y = Math.PI; tag0.position.z = -0.03; tagG.add(tag0);
  const tagString = C.roll([V3(-0.2, 0, 0), V3(-0.3, 0.12, 0), V3(-0.33, 0.28, 0)], 0.006, TOY.toyMat('#e8dcc0'), { lump: 0 }); tagG.add(tagString);
  const stolen = add(PR.mdFile({ label: 'SKILL.md', w: 0.3, h: 0.39, size: 0.05, ink: '#2b4fa8', lines: 3, seed: 430 }), 0, 0, 0);
  const brief = add(new THREE.Group(), 0, 0, 0); { const b = C.rbox(0.3, 0.22, 0.08, 0.03, TOY.toyMat('#7a4a2a', { rough: 0.4 }), { lump: 0 }); b.position.y = -0.13; brief.add(b); const h = C.roll([V3(-0.06, 0, 0), V3(0, 0.04, 0), V3(0.06, 0, 0)], 0.012, TOY.toyMat('#3a2414'), { lump: 0 }); brief.add(h); const cl = C.rbox(0.05, 0.03, 0.09, 0.01, TOY.toyMat('#e2b84a', { rough: 0.25 }), { lump: 0 }); cl.position.y = -0.04; brief.add(cl); }
  // the garden table + laptop (Morphy, drawn)
  const gTable = add(YD.table({ w: 1.3, d: 0.8, h: 0.78, color: '#ffffff' }), -1.8, 0, 0.65);
  const lap = add(YD.laptop({ w: 0.95, d: 0.66 }), -1.8, 0.78, 0.6, 0);
  function drawLaptop(k) {
    lap.userData.scr.draw('l' + Math.round(k * 20), (g, w, h) => {
      g.fillStyle = '#f5f5fb'; g.fillRect(0, 0, w, h);
      g.fillStyle = 'rgba(84,88,255,0.12)'; for (let x = 20; x < w; x += 34) for (let y = 70; y < h; y += 34) g.fillRect(x, y, 3, 3);
      g.fillStyle = '#ffffff'; g.fillRect(0, 0, w, 54); g.fillStyle = '#e4e4f0'; g.fillRect(0, 54, w, 2);
      ['#ef6a5b', '#f3c14b', '#5fc06a'].forEach((c, i) => { g.fillStyle = c; g.beginPath(); g.arc(28 + i * 26, 27, 9, 0, 7); g.fill(); });
      g.fillStyle = '#5458ff'; g.font = '800 30px Poppins'; g.textAlign = 'center'; g.fillText('Morphy', w / 2, 38);
      const cards = [[40, 90, 300, 520, '#5458ff', 'Claude'], [370, 90, 290, 250, '#e8763a', 'Codex'], [370, 360, 290, 250, '#2f9e8f', 'notes.md'], [690, 90, 300, 330, '#d84a8a', 'Reels'], [690, 440, 300, 170, '#e8c14a', 'Tasks']];
      cards.forEach(([x, y, cw, ch, c, t], i) => {
        const a = clamp(k * 6 - i * 0.6); if (a <= 0) return; g.globalAlpha = a;
        g.fillStyle = 'rgba(30,30,60,0.12)'; g.beginPath(); g.roundRect(x + 4, y + 8, cw, ch, 18); g.fill();
        g.fillStyle = '#ffffff'; g.beginPath(); g.roundRect(x, y, cw, ch, 18); g.fill(); g.strokeStyle = '#dcdcea'; g.lineWidth = 2; g.stroke();
        g.fillStyle = c; g.beginPath(); g.roundRect(x, y, cw, 46, [18, 18, 0, 0]); g.fill();
        g.fillStyle = '#fff'; g.font = '700 24px Poppins'; g.textAlign = 'left'; g.fillText(t, x + 18, y + 31);
        g.fillStyle = '#d9dbe8'; for (let r = 0; r < Math.floor((ch - 70) / 40); r++) { g.beginPath(); g.roundRect(x + 18 + (r % 2) * 40, y + 66 + r * 40, cw - 76, 24, 12); g.fill(); }
        g.globalAlpha = 1;
      });
    });
  }
  drawLaptop(1);
  // the toy chest + toybox.md
  const CHEST = [4.0, 0, 1.4];
  const chest = add(YD.toyChest({ w: 1.1, d: 0.65, h: 0.6, color: '#d0763c' }), ...CHEST, -0.15);
  const toyFile = add(PR.mdFile({ label: 'toybox.md', w: 0.5, h: 0.64, size: 0.075, ink: '#d0563c', lines: 4, seed: 440 }), 0, 0, 0);
  // paywall, subscription sign, marionette strings (only in their beats)
  const PW = [-1.5, 0, 3.6];
  const pwall = add(new THREE.Group(), ...PW); const BR = [];
  for (let r = 0; r < 9; r++) for (let c = -4; c <= 4; c++) { const off = r % 2 ? 0.18 : 0, x = c * 0.36 + off; if (Math.abs(x) > 1.5) continue; const b = PR.brick({ w: 0.345, h: 0.17, color: ['#c0583c', '#b04e35', '#cc6646'][(r * 3 + c + 12) % 3] }); b.position.set(x, 0.09 + r * 0.18, 0); pwall.add(b); const R3 = YD.rnd(900 + r * 11 + c); BR.push({ b, x, y: 0.09 + r * 0.18, v: V3((R3() - 0.5) * 5 + x * 1.6, 2 + R3() * 3.5, 3.5 + R3() * 4), w: V3(R3() * 9, R3() * 9, R3() * 9) }); }
  const pwSign = YD.board({ w: 1.5, h: 0.38, text: 'PAYWALL', bg: '#22202a', ink: YC.yellow, size: 0.68 }); pwSign.position.set(0, 1.0, 0.16); pwall.add(pwSign);
  const subS = add(YD.board({ w: 1.1, h: 0.52, text: '$9/MO', bg: '#ffffff', ink: '#7a52c7', post: 1.0, size: 0.7 }), 1.6, 1.26, 3.8);
  const subPivot = add(new THREE.Group(), 1.6, 0, 3.74); subPivot.add(subS); subS.position.set(0, 1.26, 0.06);
  const ST2 = [4.6, 0, 3.6];
  const mar = add(new THREE.Group(), ...ST2);
  const bar1 = C.rbox(0.9, 0.08, 0.08, 0.02, wood, { lump: 0 }); bar1.position.y = 3.0; mar.add(bar1);
  const bar2 = C.rbox(0.08, 0.08, 0.7, 0.02, wood, { lump: 0 }); bar2.position.y = 3.0; mar.add(bar2);
  const strFile = add(PR.mdFile({ label: 'toybox.md', w: 0.44, h: 0.56, size: 0.065, ink: '#d0563c', lines: 4, seed: 450 }), 0, 0, 0);
  const STR = [[-0.4, 0], [0.4, 0], [0, 0.3]].map(([dx, dz]) => { const s = C.mesh(new THREE.CylinderGeometry(0.006, 0.006, 1, 6), TOY.toyMat('#f2ecdf')); scene.add(s); return { s, dx, dz }; });
  const scis = add(PR.scissors({ s: 1.6 }), 0, 0, 0);
  // the library skyscraper (glass, red diagonal bracing, floors of shelves full of .md files, MORPHY on the roof)
  const LIB = [3, 0, -54], LH = 60, LW = 10;
  const lib = add(new THREE.Group(), ...LIB);
  { const slabM = TOY.toyMat('#f2f2f0', { rough: 0.5 }); const colM = TOY.toyMat('#dfe3ea', { rough: 0.4 });
    for (let y = 0; y <= LH; y += 3) { const s = new THREE.Mesh(new THREE.BoxGeometry(LW, 0.35, LW), slabM); s.position.y = y; s.castShadow = true; s.receiveShadow = true; lib.add(s); }
    for (const [x, z] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) { const c = new THREE.Mesh(new THREE.BoxGeometry(0.5, LH, 0.5), colM); c.position.set(x * (LW / 2 - 0.25), LH / 2, z * (LW / 2 - 0.25)); lib.add(c); }
    const core = new THREE.Mesh(new THREE.BoxGeometry(3, LH, 3), TOY.toyMat('#d9dde5')); core.position.y = LH / 2; lib.add(core);
    // shelves: instanced files in bright colours along the four inner faces of every floor
    const FC = ['#5458ff', '#e8763a', '#2f9e8f', '#d84a8a', '#e8c14a', '#3a6fc4', '#68c08a', '#c9533a'];
    const fileG = new THREE.BoxGeometry(0.46, 0.62, 0.05); const n = Math.floor(LH / 3) * 4 * 2 * 11;
    const im = new THREE.InstancedMesh(fileG, new THREE.MeshStandardMaterial({ roughness: 0.5, emissive: 0xffffff, emissiveIntensity: 0.1 }), n); let k = 0; const m4 = new THREE.Matrix4(), q = new THREE.Quaternion(), col = new THREE.Color();
    const shelfM = TOY.toyMat('#c6935c');
    for (let y = 0; y + 3 <= LH; y += 3) for (let side = 0; side < 4; side++) {
      const a = side * Math.PI / 2; q.setFromAxisAngle(V3(0, 1, 0), a);
      for (let row = 0; row < 2; row++) {
        const sh = new THREE.Mesh(new THREE.BoxGeometry(8.4, 0.08, 0.5), shelfM); const pp = V3(0, y + 0.55 + row * 1.0, LW / 2 - 1.0).applyAxisAngle(V3(0, 1, 0), a); sh.position.copy(pp); sh.quaternion.copy(q); lib.add(sh);
        for (let c = 0; c < 11; c++) { const p = V3(-3.8 + c * 0.76, y + 0.84 + row * 1.0, LW / 2 - 1.0).applyAxisAngle(V3(0, 1, 0), a); m4.compose(p.add(V3(0, 0.06, 0)), q, V3(1, 1, 1)); im.setMatrixAt(k, m4); im.setColorAt(k, col.set(FC[(k * 7 + c) % FC.length])); k++; }
      }
    }
    im.count = k; lib.add(im);
    const glass = new THREE.MeshStandardMaterial({ color: '#8fc3f0', transparent: true, opacity: 0.14, roughness: 0.05, metalness: 0.1, envMapIntensity: 1.6, depthWrite: false });
    for (let side = 0; side < 4; side++) { const a = side * Math.PI / 2; const pl = new THREE.Mesh(new THREE.PlaneGeometry(LW, LH), glass); pl.position.copy(V3(0, LH / 2, LW / 2 + 0.05).applyAxisAngle(V3(0, 1, 0), a)); pl.rotation.y = a; pl.renderOrder = 3; lib.add(pl);
      const red = TOY.toyMat('#d8452c', { rough: 0.4 });
      for (let y = 0; y < LH; y += 10) for (const sgn of [-1, 1]) { const len = Math.hypot(LW, 10); const b = new THREE.Mesh(new THREE.BoxGeometry(0.45, len, 0.35), red); const p = V3(0, y + 5, LW / 2 + 0.3).applyAxisAngle(V3(0, 1, 0), a); b.position.copy(p); b.rotation.set(0, a, sgn * Math.atan2(LW, 10)); b.castShadow = true; lib.add(b); }
      const mull = TOY.toyMat('#3b4150', { rough: 0.3 });
      for (let x = -LW / 2; x <= LW / 2; x += 1.25) { const m = new THREE.Mesh(new THREE.BoxGeometry(0.06, LH, 0.06), mull); m.position.copy(V3(x, LH / 2, LW / 2 + 0.08).applyAxisAngle(V3(0, 1, 0), a)); lib.add(m); }
    }
    const roof = new THREE.Mesh(new THREE.BoxGeometry(LW + 0.4, 0.6, LW + 0.4), TOY.toyMat('#f2f2f0')); roof.position.y = LH + 0.3; lib.add(roof);
    const signTxt = C.text3d('MORPHY', 2.1, 0.5, TOY.toyMat('#ffffff', { rough: 0.35, emissive: 0xffffff, ei: 0.25 }), { lump: 0 }); signTxt.position.set(0, LH + 2.6, 0.6); lib.add(signTxt);
    const signBack = C.rbox(signTxt.userData.width + 1.2, 3.0, 0.4, 0.2, TOY.toyMat('#d8452c', { rough: 0.4 }), { lump: 0 }); signBack.position.set(0, LH + 2.6, 0.1); lib.add(signBack);
    for (const sx of [-1, 1]) { const leg = new THREE.Mesh(new THREE.BoxGeometry(0.3, 1.4, 0.3), TOY.toyMat('#3b4150')); leg.position.set(sx * 3.5, LH + 0.8, 0.1); lib.add(leg); }
  }
  // poofs and confetti
  const puffs = [0, 1, 2, 3, 4, 5].map(i => add(YD.puff({ n: 10, r: 0.3, seed: 51 + i * 13 }), 0, -50, 0));
  const bigPuff = add(YD.puff({ n: 16, r: 2.4, seed: 777 }), TW[0], 1.5, TW[2]);
  const conf = [0, 1, 2].map(i => add(YD.confetti({ n: 80, seed: 61 + i }), 0, -50, 0));
  const hammer = add(new THREE.Group(), 0, 0, 0); { const hd = C.rbox(0.07, 0.07, 0.2, 0.02, TOY.toyMat('#8a8f99', { rough: 0.3, env: 1.3 }), { lump: 0 }); hd.position.y = 0.18; hammer.add(hd); const hn = C.capsule(0.022, 0.28, TOY.toyMat('#c6935c'), { lump: 0 }); hn.position.y = 0.04; hammer.add(hn); }

  // ===================== THE CAST =====================
  const claude = TOY.makeToon({ kind: 'kid', skin: '#f2c4a0', top: 'hoodie', topC: '#e8763a', pants: '#3d66b8', hat: 'beanie', hatC: '#f3ead8', pom: '#e8763a', hair: '#7a4a2a', freckles: true, eyeR: 0.145 });
  const you = TOY.makeToon({ kind: 'adult', h: 1.12, skin: '#e9b58f', top: 'polo', topC: '#2f9e8f', pants: '#c4a46a', shoes: '#7a4a2c', sole: '#4a3020', hat: 'cap', hatC: '#d64535', hair: '#5a3a24', hairStyle: 'short', eyeR: 0.135 });
  const gran = TOY.makeToon({ kind: 'granny', h: 0.98, skin: '#f2c8a8', top: 'cardigan', topC: '#7fae6a', skirt: '#e98fae', shoes: '#8a5aa8', hair: '#efefef', hairStyle: 'bun', glasses: '#c0392b', lashes: true, browC: '#c9c9c9', eyeR: 0.14 });
  const coon = TOY.makeToon({ kind: 'raccoon', h: 1.0, fur: '#8d8d97', top: 'suit', topC: '#2b3b6b', acc: '#d9473a', shoes: '#1f1f25', sole: '#111111', mask: '#3b3b44', blush: 0, eyeR: 0.15, eyeGap: 0.28, mouthDown: 0.52, browC: '#2a2a30' });
  const CAST = [claude, you, gran, coon]; CAST.forEach(p => scene.add(p.root));
  // helpers
  const faceTo = (P, x, z, tx, tz) => Math.atan2(tx - x, tz - z);
  function put(P, s, t) {   // pose + idle life: breathing, blinks
    P.root.visible = true;
    const f = { ...(s.face || {}) };
    if ((!f.eyes || f.eyes === 'open' || f.eyes === 'wide' || f.eyes === 'half') && TOY.blink(t, CAST.indexOf(P) * 3 + 1) && !s.noBlink) f.eyes = 'shut';
    TOY.poseToon(P, { bob: Math.sin(t * 2.4 + CAST.indexOf(P)) * 0.006, t, ...s, face: f });
  }
  const hold = (obj, P, i, off = [0, -0.06, 0.05], rot) => { obj.visible = true; obj.position.copy(TOY.handPos(P, i, off)); if (rot) obj.rotation.set(...rot); else obj.rotation.set(0, P.root.rotation.y, 0); };
  const proj = p => { const v = (p.isVector3 ? p.clone() : V3(...p)).project(camera); return { x: (v.x + 1) / 2 * 1080, y: (1 - v.y) / 2 * 1920, behind: v.z > 1 }; };
  const sitY = P => 0.46 + 0.07 - (P.kind === 'adult' ? 0.42 : 0.42) * P.H;   // root height when sitting on a 0.46 seat
  const SIT = { legL: -1.45, legR: -1.45 };
  const pp = (i, x, y, z, k) => { const p = puffs[i]; p.position.set(x, y, z); p.set(k); };

  // ===================== BEATS =====================
  // cam: { L (look-at, also the focus), L1, yaw, yaw1, pitch, pitch1, d, d1, fov, ap, frameY, shadow }
  const BEATS = [];
  const beat = (t0, cam, run) => BEATS.push({ t0, cam, run });
  const ARMUP = (k = 1) => ({ f: 2.9 * k, o: 0.25, e: 0.2, hand: 'open' });
  const WAVE = t => ({ f: 0.3, o: 2.3, tw: 1.4, e: 1.0 + Math.sin(t * 14) * 0.45, hand: 'open' });
  const cheer = (t, s = 0) => ({ armL: { f: 2.6 + Math.sin(t * 10 + s) * 0.25, o: 0.5, e: 0.3, hand: 'fist' }, armR: { f: 2.6 + Math.sin(t * 10 + s + 1) * 0.25, o: 0.5, e: 0.3, hand: 'fist' } });

  // B1 — If you write three lines in a text file
  beat(0, { L: [2.1, 1.3, -3.7], yaw: 14, yaw1: 11, pitch: 6, d: 9.6, d1: 9.0, fov: 34, ap: 1.5, shadow: 7 }, t => {
    const p = seg(t, 0.36, 1.5) * 3; drawSheet(p);
    put(you, { x: 3.0, z: -3.45, yaw: faceTo(you, 3.0, -3.45, 2.3, -3.95), armR: { f: 1.75, o: 0.15, e: 0.55 + Math.sin(t * 22) * 0.12, hand: 'hold' }, armL: { f: 0.1, e: 0.3 }, head: [-0.1, 0, 0], face: { mouth: 'smirk', gaze: [-0.3, 0.2] } }, t);
    hold(pencil, you, 1, [0, -0.04, 0.06], [0.4, you.root.rotation.y, 0.6]);
    put(claude, { x: 1.1, z: -2.85, yaw: 0.45, head: [-0.32, 0, 0], face: { eyes: 'wide', mouth: 'o', gaze: [0.3, 0.6] } }, t);
    put(gran, { x: -3.4, z: -2.8, y: 0.06, yaw: -0.8, armR: { f: 1.0, e: 0.8, hand: 'hold' }, face: { mouth: 'smile', gaze: [-0.5, -0.3] } }, t);
  });
  // B2 — close on the sheet: the three lines get written
  beat(T('lines') - 0.12, { L: [2.2, 1.62, -3.95], yaw: 6, yaw1: 3, pitch: 2, d: 2.3, d1: 2.05, fov: 32, ap: 4, frameY: 0.45, shadow: 2 }, t => {
    const p = seg(t, 0.36, 1.5) * 3; drawSheet(p);
    const li = Math.min(2, Math.floor(p)), fr = p - li;
    pencil.visible = true; pencil.position.set(2.2 - 0.17 + fr * 0.36 * (LINES[li].length / 15), 1.62 + 0.41 - (138 + (li + 1) * 62) / 570 * 0.82 + 0.06 + Math.sin(t * 40) * 0.006, -3.86); pencil.rotation.set(0.35, 0.3, 0.55);
    put(you, { x: 3.0, z: -3.45, yaw: faceTo(you, 3.0, -3.45, 2.3, -3.95), armR: { f: 1.75, o: 0.15, e: 0.6, hand: 'hold' } }, t);
  });
  // B3 — and tell Claude to make a reel: Claude salutes
  beat(T('and') - 0.06, { L: [1.15, 1.08, -2.75], yaw: 18, yaw1: 12, pitch: 4, d: 2.9, d1: 2.6, fov: 30, ap: 4, shadow: 3 }, t => {
    const k = ss(t, T('Claude') - 0.05, T('Claude') + 0.18);
    put(claude, { x: 1.15, z: -2.75, yaw: 0.25, armR: { f: lerp(0.1, 2.6, k), o: lerp(0, 0.35, k), e: lerp(0.2, 0.35, k), hand: 'open' }, lean: -0.05 * k, head: [-0.05, 0, 0], face: { eyes: t > T('reel') ? 'happy' : 'open', mouth: t > T('make') ? 'grin' : 'smile', gaze: [0, 0] } }, t);
    put(you, { x: -0.2, z: -2.2, yaw: 1.2, armL: { f: 1.5, e: 0.1, hand: 'point' } }, t);
    put(gran, { x: -3.4, z: -2.8, y: 0.06, yaw: -0.8 }, t);
  });
  // B4 — congratulations! confetti from the sheet
  beat(T('congratulations') - 0.06, { L: [2.0, 1.55, -3.9], yaw: -14, yaw1: -10, pitch: 9, d: 8.6, d1: 7.8, fov: 36, ap: 1.2, shadow: 7 }, t => {
    drawSheet(3); const t0 = T('congratulations');
    conf[0].position.set(2.2, 1.9, -3.8); conf[0].set(Math.max(0, t - t0));
    const h1 = hop(t, t0 + 0.05, 0.45, 0.35);
    put(claude, { x: 1.15, z: -2.75, y: h1.y, sq: h1.sq, yaw: 0.2, ...cheer(t), face: { eyes: 'happy', mouth: 'grin' } }, t);
    put(you, { x: 3.0, z: -3.2, yaw: -0.3, ...cheer(t, 2), face: { eyes: 'happy', mouth: 'grin' } }, t);
    put(gran, { x: -3.4, z: -2.8, y: 0.06, yaw: 0.6, armL: { f: 1.2, o: 0.3, e: 1.2 + Math.sin(t * 18) * 0.3 }, armR: { f: 1.2, o: 0.3, e: 1.2 - Math.sin(t * 18) * 0.3 }, face: { eyes: 'happy', mouth: 'grin' } }, t);
  });
  // B5 — You've built a content skill (low hero angle on the sheet)
  beat(T("You've") - 0.06, { L: [2.2, 1.78, -3.95], yaw: -22, yaw1: -16, pitch: -14, pitch1: -12, d: 2.7, d1: 2.35, fov: 34, ap: 3, shadow: 3 }, t => { drawSheet(3); });
  // B6 — A content skill with the creative range of… (Claude, deadpan, reading it)
  beat(T('a', 4) - 0.06, { L: [2.75, 1.18, -3.45], yaw: -8, yaw1: -4, pitch: 3, d: 2.9, d1: 2.6, fov: 30, ap: 4, shadow: 3 }, t => {
    drawSheet(3);
    const look = t > T('creative') - 0.1;
    put(claude, { x: 2.95, z: -3.45, yaw: -0.1, head: [look ? 0 : -0.12, look ? 0 : -0.55, 0], armL: { f: 0.2, o: -0.1, e: 1.6, hand: 'fist' }, armR: { f: 0.2, o: -0.1, e: 1.6, hand: 'fist' }, face: { eyes: 'half', mouth: 'flat', gaze: look ? [0, 0] : [-0.7, 0.4], brow: [-0.2, 0] } }, t);
  });
  // B7 — microwave manual
  beat(T('microwave') - 0.07, { L: [-8.3, 1.0, -3.3], yaw: 16, yaw1: 10, pitch: 10, d: 2.7, d1: 2.35, fov: 30, ap: 4, frameY: 0.5, shadow: 2 }, t => {
    put(gran, { x: -6.6, z: -2.6, y: 0.06, yaw: -1.6 }, t);
  });
  // B8 — Claude posts one video
  beat(T('Claude', 2) - 0.06, { L: [0.6, 0.98, -0.8], yaw: -6, yaw1: -2, pitch: 2, d: 3.4, d1: 3.05, fov: 30, ap: 3.5, shadow: 3 }, t => {
    const tap = T('posts');
    drawPhone(phoneC, t > tap + 0.05 ? 'posted' : 'post');
    put(claude, { x: 0.6, z: -0.85, yaw: 0, armR: { f: 1.25, o: -0.25, e: 0.9, hand: 'hold' }, armL: { f: 1.25 + (Math.abs(t - tap) < 0.12 ? 0.2 : 0), o: -0.3, e: 1.1, hand: 'point' }, head: [0.15, 0, 0], face: { eyes: 'wide', mouth: 'grin', gaze: [0, -0.4] } }, t);
    phoneC.visible = true; phoneC.position.set(0.6, 0.74, -0.47); phoneC.rotation.set(-0.12, 0, 0);
  });
  // B9 — and it gets three views (the screen)
  beat(T('and', 2) - 0.06, { L: [0.6, 0.8, -0.47], yaw: -3, yaw1: 0, pitch: 4, d: 1.55, d1: 1.32, fov: 30, ap: 5, frameY: 0.37, shadow: 2 }, t => {
    const n = t < 8.9 ? 0 : t < 9.02 ? 1 : t < 9.16 ? 2 : 3; drawPhone(phoneC, 'views', n);
    put(claude, { x: 0.6, z: -0.85, yaw: 0, armR: { f: 1.25, o: -0.25, e: 0.9, hand: 'hold' }, armL: { f: 1.25, o: -0.3, e: 0.9, hand: 'hold' }, head: [0.15, 0, 0], face: { eyes: t > 9.3 ? 'sad' : 'wide', mouth: t > 9.3 ? 'frown' : 'o', gaze: [0, -0.4] } }, t);
    phoneC.visible = true; phoneC.position.set(0.6, 0.74, -0.47); phoneC.rotation.set(-0.12, 0, 0);
  });
  // B10 — Two of them are Grandma
  beat(T('Two') - 0.06, { L: [-3.3, 1.0, -2.75], yaw: 10, yaw1: 6, pitch: 4, d: 3.7, d1: 3.3, fov: 30, ap: 3.5, shadow: 3 }, t => {
    put(gran, { x: -3.3, z: -2.75, y: 0.06, yaw: 0.3, armL: { f: 1.9, o: 0.25, e: 0.5 + Math.sin(t * 9) * 0.12, hand: 'hold' }, armR: { f: 1.9, o: 0.25, e: 0.5 - Math.sin(t * 9) * 0.12, hand: 'hold' }, face: { eyes: 'happy', mouth: 'grin' } }, t);
    granPh.forEach((p, i) => { drawPhone(p, 'heart'); hold(p, gran, i, [0, -0.07, 0.06], [0, 0.3, 0]); });
  });
  // B11 — The third is a bot
  beat(T('the', 2) - 0.06, { L: [-7.5, 1.22, -3.25], yaw: -12, yaw1: -6, pitch: 6, d: 2.7, d1: 2.35, fov: 30, ap: 4, shadow: 2 }, t => {
    const ph = Math.floor((t - T('the', 2)) * 4) % 3; drawBotFace(ph === 1 ? 'blink' : t > T('bot') + 0.1 ? 'happy' : 'open');
    bot.userData.bulb.material.emissiveIntensity = (Math.floor(t * 6) % 2) ? 1.2 : 0.2;
    botPhone.visible = true; botPhone.position.set(-7.5 + 0.24, 0.84 + 0.36, -3.25 + 0.12); botPhone.rotation.set(-0.2, -0.4, 0.15);
    bot.userData.head.rotation.z = Math.sin(t * 7) * 0.12;
  });
  // B12 — So, you give it a hook, a style, and a voice (the platform)
  beat(T('So') - 0.06, { L: [7.0, 1.3, -3.0], yaw: -20, yaw1: -10, pitch: 6, d: 7.2, d1: 6.3, fov: 36, ap: 1.5, shadow: 5 }, t => {
    for (const [i, w] of [[0, 'hook'], [1, 'style'], [2, 'voice']]) { const tw = T(w) - 0.04; if (t > tw) pp(i, TW[0] - 0.82 + i * 0.82, DH + 0.12, TW[2] + 0.95, seg(t, tw, tw + 0.4)); }
    const hk = Math.min(...['hook', 'style', 'voice'].map(w => Math.abs(t - T(w)))), swing = hk < 0.1 ? 1 - hk / 0.1 : 0;
    put(you, { x: 8.75, z: -1.55, yaw: -0.9, armR: { f: 1.4 + swing * 1.0, o: 0.2, e: 0.5, hand: 'fist' }, face: { eyes: 'angry', mouth: 'teeth' } }, t);
    hold(hammer, you, 1, [0, -0.05, 0.03], [1.2 + swing * 0.8, -0.9, 0]);
    put(claude, { x: 8.9, z: -1.4, yaw: -0.6, face: { eyes: 'wide', mouth: 'o', gaze: [-0.6, 0.3] } }, t);
  });
  // B13 — Now Claude makes reels people actually finish (sitting on the deck)
  beat(T('Now') - 0.06, { L: [7.2, 2.0, -2.1], yaw: 22, yaw1: 14, pitch: 7, d: 3.9, d1: 3.4, fov: 30, ap: 3, shadow: 3 }, t => {
    const n = Math.round(1200 * Math.pow(40, seg(t, T('Now'), T('but') - 0.1))); drawPhone(phoneC, 'viral', n);
    put(claude, { x: TW[0], y: DH + 0.07 - 0.42, z: TW[2] + 1.05, yaw: 0.35, ...SIT, legL: -1.25 + Math.sin(t * 5) * 0.15, legR: -1.25 - Math.sin(t * 5) * 0.15, armR: { f: 1.0, o: 0.1, e: 1.0, hand: 'hold' }, armL: { f: 0.9, o: -0.1, e: 1.1, hand: 'hold' }, head: [0.2, 0, 0], face: { eyes: 'happy', mouth: 'grin' } }, t);
    hold(phoneC, claude, 1, [0.06, -0.06, 0.08], [-0.5, 0.0, 0]);
  });
  // B14 — but he wants a second format (stands, points up)
  beat(T('but') - 0.06, { L: [7.2, 2.45, -2.5], yaw: 10, yaw1: 6, pitch: -8, pitch1: -10, d: 3.5, d1: 3.1, fov: 32, ap: 3, shadow: 3 }, t => {
    const k = ss(t, T('wants') - 0.1, T('wants') + 0.15);
    put(claude, { x: TW[0], y: DH + 0.07, z: TW[2] + 0.75, yaw: 0, armR: { f: lerp(0.2, 2.7, k), o: 0.15, e: lerp(0.3, 0.05, k), hand: 'point' }, armL: { f: 0.4, o: 0.3, e: 0.6 }, head: [-0.12 * k, 0, 0.1 * k], face: { eyes: 'wide', mouth: t > T('second') ? 'grin' : 'o', gaze: [0.2, 0.6 * k] } }, t);
  });
  // tower state (a function of time; the cabins land on their words, then lean, wobble and collapse)
  const LAND = [T('another'), T('third', 2), T('fourth'), T('tenth') - 0.9, T('tenth') - 0.85, T('tenth') - 0.8, T('tenth') - 0.75, T('tenth') - 0.7, T('tenth') - 0.65];
  const tCol = T('So', 3) + 0.05, tMono = T('one', 2) + 0.05;
  function towerState(t) {
    const show = t > T('give') - 0.1 && t < tCol + 0.8;
    towerP.visible = show; if (!show) return;
    const pk = ss(t, T('give') - 0.08, T('give') + 0.06);
    posts.forEach(p => p.scale.set(1, Math.max(0.01, pk), 1)); deck.visible = pk > 0.5; deckLadder.visible = pk > 0.5;
    ['hook', 'style', 'voice'].forEach((w, i) => { const tw = T(w) - 0.04, k = seg(t, tw - 0.14, tw); BOARDS[i].visible = t > tw - 0.14; BOARDS[i].position.y = DH - 0.02 + (1 - easeIn(k)) * 1.6; });
    const lean = (ss(t, T('the', 4) - 0.2, T('coffees') + 0.3) * 0.1 + (t > T('the', 4) ? Math.sin(t * 9) * 0.012 : 0)) * (t < tCol ? 1 : 1);
    const shake = t > T('seventeen') - 0.1 && t < tCol ? Math.sin(t * 61) * 0.02 : 0;
    towerP.rotation.z = -lean - shake; towerP.rotation.x = lean * 0.25;
    const col = seg(t, tCol, tCol + 0.6);
    cabins.forEach((c, i) => {
      const tl = LAND[i], k = seg(t, tl - 0.22, tl);
      c.visible = t > tl - 0.22 && col < 1;
      const y0 = DH + 0.07 + i * CH;
      c.position.set(c.userData.jx * (i > 2 ? 1 : 0.3), y0 + (1 - easeIn(k)) * 5 + (t > tl ? Math.max(0, Math.sin((t - tl) * 30) * 0.06 * Math.exp(-(t - tl) * 9)) : 0) - col * (y0 + 1), c.userData.jz * (i > 2 ? 1 : 0.3));
      c.rotation.y = c.userData.jr * (i > 2 ? 1 : 0.4); const s = 1 - col * 0.9; c.scale.setScalar(Math.max(0.01, s));
    });
    posts.forEach(p => p.visible = col < 0.6); deck.visible = deck.visible && col < 0.6; BOARDS.forEach(b => { if (col > 0.6) b.visible = false; }); deckLadder.visible = deckLadder.visible && col < 0.6;
    cups.forEach((c, i) => { c.position.y = -0.23 + (t > T('seventeen') - 0.1 ? Math.abs(Math.sin(t * 40 + i)) * 0.03 : 0); c.rotation.z = t > T('seventeen') - 0.1 ? Math.sin(t * 33 + i) * 0.2 : 0; });
  }
  // B15 — So, you write another format
  beat(T('So', 2) - 0.06, { L: [7.2, 2.6, -3.2], yaw: -14, yaw1: -10, pitch: 3, d: 9.2, d1: 8.5, fov: 36, ap: 1.2, shadow: 6 }, t => {
    const tl = LAND[0]; if (t > tl) pp(0, TW[0], DH + 0.1, TW[2] + 1.0, seg(t, tl, tl + 0.6));
    put(you, { x: 5.2, z: -0.9, yaw: 0.7, armL: { f: 2.4, o: 0.4, e: 0.4, hand: 'open' }, face: { eyes: 'wide', mouth: 'grin', gaze: [0.3, 0.6] } }, t);
  });
  // B16 — then a third, then a fourth (crane up)
  beat(T('then') - 0.06, { L: [7.2, 3.8, -3.2], L1: [7.2, 5.8, -3.2], yaw: -6, yaw1: -2, pitch: 2, pitch1: 7, d: 8.2, d1: 8.8, fov: 36, ap: 1.2, shadow: 8 }, t => {
    for (const j of [1, 2]) { const tl = LAND[j]; if (t > tl) pp(j, TW[0], DH + 0.1 + j * CH, TW[2] + 1.0, seg(t, tl, tl + 0.6)); }
  });
  // B17 — By the tenth format (far)
  beat(T('By') - 0.06, { L: [7.2, 7.6, -3.2], yaw: 22, yaw1: 18, pitch: 3, d: 23, d1: 21, fov: 34, ap: 0.6, shadow: 14 }, t => {
    put(you, { x: 5.0, z: -0.6, yaw: 0.6, head: [-0.4, 0, 0], face: { eyes: 'wide', mouth: 'o' } }, t);
  });
  // B18 — the whole thing starts leaning (Claude screams from the top window)
  beat(T('the', 4) - 0.06, { L: [7.7, 14.7, -2.1], yaw: 14, yaw1: 10, pitch: 2, d: 4.8, d1: 4.2, fov: 32, ap: 2, shadow: 6 }, t => {
    cabins[8].add(claude.root);
    put(claude, { x: 0.0, y: CH - 0.1, z: 1.05, yaw: 0, ...SIT, legL: -1.3 + Math.sin(t * 17) * 0.3, legR: -1.3 - Math.sin(t * 17) * 0.3, armL: { f: 2.4, o: 0.6, e: 0.3 + Math.sin(t * 20) * 0.3, hand: 'open' }, armR: { f: 2.4, o: 0.6, e: 0.3 - Math.sin(t * 20) * 0.3, hand: 'open' }, head: [-0.1, 0, Math.sin(t * 13) * 0.1], face: { eyes: 'wide', mouth: 'scream', sweat: t * 2 }, noBlink: true }, t);
  });
  // B19 — like it's had seventeen coffees
  beat(T('seventeen') - 0.06, { L: [7.65, 6.75, -2.0], yaw: 28, yaw1: 24, pitch: 0, d: 4.2, d1: 3.8, fov: 32, ap: 2.5, shadow: 6 }, t => {});
  // B20 — So, you pack it all into… (collapse into a big poof)
  beat(T('So', 3) - 0.06, { L: [7.2, 4.0, -3.2], yaw: -8, yaw1: -6, pitch: 7, d: 15, d1: 13.5, fov: 36, ap: 0.8, shadow: 12 }, t => {
    bigPuff.set(seg(t, tCol + 0.05, tCol + 1.7));
    put(you, { x: 4.4, z: 0.2, yaw: 0.7, armL: { f: 1.4, o: 0.3, e: 0.2, hand: 'open' }, armR: { f: 1.4, o: 0.3, e: 0.2, hand: 'open' }, face: { eyes: 'wide', mouth: 'scream' } }, t);
  });
  // monument state
  function monoState(t) {
    mono.visible = t > tMono; const k = seg(t, tMono, tMono + 0.35); mono.scale.setScalar(Math.max(0.01, easeOutBack(k, 2)));
    bigDoc.glow(t > tMono ? 0.55 + 0.35 * Math.sin(t * 4) * 0.5 : 0);
    freeSign.visible = t > T('give', 2) - 0.12; freeSign.position.y = 1.23 + (1 - easeIn(seg(t, T('give', 2) - 0.12, T('give', 2)))) * 1.4;
    stack.forEach((f, i) => f.visible = !(i === 4 && t > T('took') + 0.05));
  }
  // B21 — one clean SKILL.md (the cloud clears)
  beat(T('one', 2) - 0.06, { L: [7.2, 1.75, -3.0], yaw: 8, yaw1: 4, pitch: -3, d: 6.8, d1: 5.9, fov: 34, ap: 1.5, shadow: 4 }, t => {
    bigPuff.set(seg(t, tCol + 0.05, tCol + 1.7));
  });
  // B22 — Congratulations! You've built a content format
  beat(T('congratulations', 2) - 0.06, { L: [6.9, 1.3, -2.2], yaw: -14, yaw1: -9, pitch: 8, d: 11, d1: 10, fov: 36, ap: 0.8, shadow: 7 }, t => {
    const t0 = T('congratulations', 2); conf[1].position.set(7.2, 2.6, -3.0); conf[1].set(Math.max(0, t - t0));
    const h1 = hop(t, t0 + 0.1, 0.42, 0.32), h2 = hop(t, t0 + 0.55, 0.42, 0.32);
    put(claude, { x: 8.4, z: -1.3, y: h1.y + h2.y, sq: h1.sq + h2.sq, yaw: -0.4, ...cheer(t), face: { eyes: 'happy', mouth: 'grin' } }, t);
    put(you, { x: 6.1, z: -1.4, yaw: 0.4, ...cheer(t, 1.5), face: { eyes: 'happy', mouth: 'grin' } }, t);
    put(gran, { x: 5.3, z: -1.9, yaw: 0.6, armL: { f: 1.3, o: -0.2, e: 1.4 + Math.sin(t * 16) * 0.25, hand: 'open' }, armR: { f: 1.3, o: -0.2, e: 1.4 - Math.sin(t * 16) * 0.25, hand: 'open' }, face: { eyes: 'happy', mouth: 'grin' } }, t);
  });
  // B23 — And you give it away (the FREE sign)
  beat(T('and', 4) - 0.06, { L: [8.95, 1.1, -2.0], yaw: 2, yaw1: 6, pitch: 4, d: 5.4, d1: 4.8, fov: 32, ap: 2, shadow: 4 }, t => {
    const tg = T('give', 2); if (t > tg) pp(3, TW[0] + 1.75, 0.1, TW[2] + 0.9, seg(t, tg, tg + 0.6));
    const sw = Math.abs(t - tg) < 0.12 ? 1 - Math.abs(t - tg) / 0.12 : 0;
    put(you, { x: 9.65, z: -1.75, yaw: -1.2, armR: { f: 1.9 + sw * 0.9, o: 0.1, e: 0.5, hand: 'fist' }, face: { eyes: 'happy', mouth: 'grin' } }, t);
    hold(hammer, you, 1, [0, -0.05, 0.03], [1.2 + sw, -1.0, 0]);
  });
  // B24 — completely free: copies fly over the fence to the neighbours
  beat(T('completely') - 0.06, { L: [8, 4.5, -14], yaw: 6, yaw1: 4, pitch: 6, d: 17, d1: 15.5, fov: 36, ap: 0.4, shadow: 14 }, t => {
    copies.forEach((c, i) => { const t0 = T('completely') + i * 0.11, k = seg(t, t0, t0 + 1.0); c.visible = k > 0 && k < 1; const [x, y, z] = COPY_TO[i]; c.position.set(lerp(TW[0], x, k), lerp(2.0, y, k) + Math.sin(k * Math.PI) * 4.5, lerp(TW[2], z, k)); c.rotation.set(k * 6 + i, k * 4, k * 3); c.scale.setScalar(2.6); });
    put(you, { x: 8.2, z: -1.0, yaw: Math.PI, armL: WAVE(t), face: { eyes: 'happy', mouth: 'grin' } }, t);
  });
  // B25 — Everything is perfect (lawn chair, lemonade)
  beat(T('Everything') - 0.06, { L: [-1.0, 0.85, -0.9], yaw: 22, yaw1: 18, pitch: 6, d: 3.9, d1: 3.5, fov: 30, ap: 3, shadow: 3 }, t => {
    put(you, { x: -1.0, y: 0.36 + 0.06 - 0.42 * 1.12, z: -0.95, yaw: 0.2, ...SIT, lean: -0.25, armR: { f: 1.1, o: 0.2, e: 1.3, hand: 'hold' }, armL: { f: 0.2, o: 0.5, e: 0.4 }, face: { eyes: 'happy', mouth: 'smile' } }, t);
    hold(lemon, you, 1, [0, -0.02, 0.06]);
    put(gran, { x: -3.6, z: -2.9, y: 0.06, yaw: -0.9, armR: { f: 1.0, e: 0.8 + Math.sin(t * 6) * 0.3, hand: 'hold' } }, t);
  });
  // B26 — until someone knocks on your door
  const KN = [T('knocks'), T('knocks') + 0.16, T('knocks') + 0.32];
  beat(T('until') - 0.06, { L: [-4.7, 1.15, -4.55], yaw: 62, yaw1: 56, pitch: 3, d: 3.4, d1: 3.05, fov: 32, ap: 2.5, shadow: 3 }, t => {
    const kn = Math.max(...KN.map(k => t > k && t < k + 0.12 ? 1 - (t - k) / 0.12 : 0));
    DOOR.rotation.y = kn * 0.025;
    put(coon, { x: -4.7, y: 0.28, z: -4.6, yaw: Math.PI, armR: { f: 1.6 + kn * 0.35, o: 0.1, e: 1.2 - kn * 0.4, hand: 'fist' }, armL: { f: 0.3, e: 0.2 }, face: { eyes: 'half', mouth: 'smirk' } }, t);
    hold(brief, coon, 0, [0, -0.04, 0]);
  });
  // B27 — It's a raccoon wearing a tiny business suit (your POV from the door)
  beat(T("it's", 2) - 0.06, { L: [-4.7, 0.78, -3.15], yaw: 180, yaw1: 180, pitch: 14, pitch1: 10, d: 1.85, d1: 1.6, fov: 30, ap: 5, shadow: 3 }, t => {
    DOOR.rotation.y = -1.2;
    const adj = ss(t, T('tiny') - 0.05, T('tiny') + 0.2) - ss(t, T('suit') + 0.1, T('suit') + 0.3);
    put(coon, { x: -4.7, y: 0.06, z: -3.15, yaw: Math.PI, armR: { f: lerp(0.3, 1.2, adj), o: lerp(0.1, -0.35, adj), e: lerp(0.3, 1.9, adj), hand: 'fist' }, armL: { f: 0.25, e: 0.2 }, head: [0, 0, 0.08 * adj], face: { eyes: 'half', mouth: 'smirk', brow: [0.3 * adj, 0.1] } }, t);
    hold(brief, coon, 0, [0, -0.04, 0]);
  });
  // B28 — He took your free file
  beat(T('He', 2) - 0.06, { L: [8.0, 0.85, -2.4], yaw: 30, yaw1: 24, pitch: 6, d: 2.9, d1: 2.6, fov: 30, ap: 3, shadow: 3 }, t => {
    const k = ss(t, T('took') - 0.05, T('took') + 0.15);
    put(coon, { x: 8.15, z: -2.3, yaw: -1.6 + k * 1.2, armR: { f: lerp(1.3, 0.2, k), o: lerp(0.2, 0.0, k), e: lerp(0.3, 0.9, k), hand: 'hold' }, armL: { f: 0.2, e: 0.3 }, face: { eyes: 'half', mouth: 'smirk', gaze: [0.8, 0] } }, t);
    if (t > T('took')) hold(stolen, coon, 1, [0, -0.08, 0.05], [0.2, coon.root.rotation.y, 0.3]);
  });
  // B29 — repackaged it (top-down on the stall counter)
  const tWrap = T('repackaged') + 0.38;
  beat(T('repackaged') - 0.06, { L: [9.4, 1.12, 1.25], yaw: 0, yaw1: 4, pitch: 32, pitch1: 36, d: 1.75, d1: 1.55, fov: 34, ap: 3, shadow: 2 }, t => {
    gift.visible = true; gift.position.set(9.3, 1.0, 1.25); gift.rotation.set(0, 0.2, 0);
    const lk = seg(t, tWrap - 0.12, tWrap); gift.userData.lidG.position.y = 0.42 * 0.8 + (1 - easeIn(lk)) * 0.5; gift.userData.lidG.visible = t > tWrap - 0.12;
    stolen.visible = t < tWrap - 0.02; stolen.position.set(9.4, 1.25 - seg(t, T('repackaged'), tWrap - 0.05) * 0.2, 1.2); stolen.rotation.set(-Math.PI / 2 + 0.3, 0, 0.4);
    if (t > tWrap) pp(4, 9.4, 1.25, 1.35, seg(t, tWrap, tWrap + 0.45));
    put(coon, { x: 9.85, y: 0.45, z: 0.55, yaw: 0, armL: { f: 1.2, o: -0.2, e: 0.8, hand: 'open' }, armR: { f: 1.2, o: -0.2, e: 0.8, hand: 'open' }, face: { eyes: 'half', mouth: 'grin' } }, t);
  });
  // stall state from here on
  function stallState(t) {
    stall.visible = t > T('repackaged') - 0.1;
    closed.visible = t > T('again') - 0.15; closed.position.set(0, 1.62 + (1 - easeOutBack(seg(t, T('again') - 0.15, T('again') + 0.1), 2)) * 0.6, 0.38); closed.rotation.z = Math.sin(t * 6) * 0.04;
    if (t > tWrap - 0.12) { gift.visible = true; gift.position.set(9.3, 1.0, 1.25); gift.rotation.set(0, 0.2, 0); gift.userData.lidG.visible = true; gift.userData.lidG.position.y = 0.336; }
    const tagIn = T('sells') - 0.05; tagG.visible = t > tagIn;
    const sw = springy(seg(t, tagIn, tagIn + 0.7), 3, 4);
    const flip = ss(t, T('Never') + 0.02, T('Never') + 0.3);
    tagG.position.set(10.5, 1.3, 1.46); tagG.rotation.set(0, flip * Math.PI, (1 - sw) * 0.9 + Math.sin(t * 3) * 0.04);
  }
  // B30 — and now sells it for $30
  beat(T('and', 5) - 0.06, { L: [10.0, 1.55, 1.2], yaw: -6, yaw1: -3, pitch: 4, d: 5.2, d1: 4.6, fov: 32, ap: 2, shadow: 3 }, t => {
    put(coon, { x: 9.85, y: 0.45, z: 0.55, yaw: 0, armL: { f: 1.3, o: 0.6, e: 0.4, hand: 'open' }, armR: { f: 1.3, o: 0.6, e: 0.4, hand: 'open' }, face: { eyes: 'half', mouth: 'grin', brow: [0.4, 0] } }, t);
  });
  // B31 — $30! (the tag, punched in)
  beat(T('$30', 2) - 0.06, { L: [10.5, 1.28, 1.46], yaw: 0, yaw1: 0, pitch: 0, d: 1.05, d1: 0.82, fov: 30, ap: 5, frameY: 0.5, shadow: 2 }, t => {
    put(coon, { x: 9.85, y: 0.45, z: 0.55, yaw: 0, armL: { f: 1.3, o: 0.6, e: 0.4, hand: 'open' }, armR: { f: 1.3, o: 0.6, e: 0.4, hand: 'open' }, face: { eyes: 'half', mouth: 'grin' } }, t);
  });
  // B32 — For something you gave away for free (shock)
  beat(T('for', 2) - 0.06, { L: [6.85, 1.15, -1.2], yaw: 2, yaw1: 0, pitch: 3, d: 5.2, d1: 4.7, fov: 30, ap: 2.5, shadow: 3 }, t => {
    const k = ss(t, T('free', 3) - 0.1, T('free', 3) + 0.1);
    put(you, { x: 6.35, z: -1.3, yaw: 0.15, armL: { f: 0.8 + k, o: 0.6, e: 1.6, hand: 'open' }, armR: { f: 0.8 + k, o: 0.6, e: 1.6, hand: 'open' }, face: { eyes: 'wide', mouth: k > 0.5 ? 'scream' : 'open' }, noBlink: true }, t);
    put(claude, { x: 7.35, z: -1.05, yaw: -0.15, armL: { f: 1.6, o: 0.2, e: 1.9, hand: 'open' }, armR: { f: 1.6, o: 0.2, e: 1.9, hand: 'open' }, face: { eyes: 'wide', mouth: 'scream' }, noBlink: true }, t);
  });
  // B33 — He even charges Claude $5 to read it (the bench)
  beat(T('He', 3) - 0.06, { L: [2.35, 0.8, -3.15], yaw: 8, yaw1: 4, pitch: 5, d: 4.6, d1: 4.2, fov: 30, ap: 2.5, shadow: 3 }, t => {
    put(claude, { x: 1.9, y: 0.46 + 0.05 - 0.42, z: TREE[2] + 1.25, yaw: 0, ...SIT, armL: { f: 1.1, o: -0.1, e: 0.9, hand: 'hold' }, armR: { f: 1.1, o: -0.1, e: 0.9, hand: 'hold' }, head: [0.25, 0, 0], face: { eyes: t > T('$5') ? 'sad' : 'half', mouth: 'frown', gaze: [0, -0.4] } }, t);
    stolen.visible = true; stolen.position.copy(TOY.handPos(claude, 0, [-0.12, -0.1, 0.1])); stolen.rotation.set(-1.0, 0, 0);
    put(coon, { x: 2.8, z: TREE[2] + 1.85, yaw: -0.6, armR: { f: 1.3, o: -0.1, e: 0.5, hand: 'open', hr: [0, 0, 0] }, face: { eyes: 'half', mouth: 'smirk', brow: [0.3, 0] } }, t);
    hold(brief, coon, 0, [0, -0.04, 0]);
  });
  // B34 — That actually happened to me (you, deadpan, to camera)
  beat(T('That') - 0.06, { L: [-2.7, 1.28, 0.45], yaw: 0, yaw1: 2, pitch: 1, d: 2.05, d1: 1.88, fov: 30, ap: 4, shadow: 2 }, t => {
    put(you, { x: -2.7, z: 0.45, yaw: 0.05, armL: { f: 0.1 }, face: { eyes: 'half', mouth: TOY.talk(film, t, 'flat'), brow: [-0.1, 0] } }, t);
  });
  // B35 — I'm Casper (waves, talking)
  beat(T("I'm") - 0.06, { L: [-2.7, 1.1, 0.45], yaw: -8, yaw1: -5, pitch: 3, d: 3.2, d1: 2.9, fov: 30, ap: 3, shadow: 3 }, t => {
    put(you, { x: -2.7, z: 0.45, yaw: 0.1, armL: WAVE(t), face: { eyes: 'open', mouth: TOY.talk(film, t, 'smile') } }, t);
  });
  // B36 — and I built Morphy, an agentic super app (the laptop)
  beat(T('and', 6) - 0.06, { L: [-2.25, 1.0, 0.7], yaw: 12, yaw1: 8, pitch: 8, d: 4.4, d1: 3.9, fov: 30, ap: 2, shadow: 3 }, t => {
    drawLaptop(seg(t, T('and', 6), T('agentic')));
    put(you, { x: -2.75, z: 0.8, yaw: 0.9, armL: { f: 1.1, o: -0.4, e: 0.4, hand: 'open' }, face: { eyes: 'happy', mouth: TOY.talk(film, t, 'grin') } }, t);
  });
  // B37 — So here's my response (determined, push in)
  beat(T('So', 4) - 0.06, { L: [-2.7, 1.28, 0.45], yaw: 10, yaw1: 4, pitch: 0, d: 2.3, d1: 1.65, fov: 30, ap: 4, shadow: 2 }, t => {
    put(you, { x: -2.7, z: 0.45, yaw: 0.0, armL: { f: 1.3, o: 0.1, e: 2.0, hand: 'fist' }, armR: { f: 1.3, o: 0.1, e: 2.0, hand: 'fist' }, face: { eyes: 'angry', mouth: TOY.talk(film, t, 'teeth') } }, t);
  });
  // library state: it rises behind the houses
  const tLib = T("I'm", 2);
  function libState(t) { lib.visible = t > tLib - 0.05; const k = seg(t, tLib, tLib + 1.35); lib.position.y = -LH - 4 + (LH + 4) * easeOut(k); }
  // B38 — I'm building an entire library (it rises)
  beat(tLib - 0.06, { L: [3, 12, -54], L1: [3, 28, -54], yaw: 6, yaw1: 6, pitch: -8, pitch1: -24, d: 60, d1: 57, fov: 40, ap: 0, shadow: 30 }, t => {
    put(claude, { x: 4.2, z: -6.6, yaw: Math.PI, head: [-0.35, 0, 0], face: { eyes: 'wide', mouth: 'o' } }, t);
    put(you, { x: 5.4, z: -6.3, yaw: Math.PI, head: [-0.35, 0, 0] }, t);
  });
  // B39 — of ready-to-use content-format .md files (rising along the glass)
  beat(T('ready-to-use') - 0.06, { L: [3, 14, -49], L1: [3, 32, -49], yaw: 12, yaw1: 8, pitch: -4, pitch1: -2, d: 10, d1: 9.2, fov: 36, ap: 0.6, shadow: 12 }, t => {});
  // B40 — inside Morphy (the roof sign)
  beat(T('inside') - 0.06, { L: [3, LH + 2.0, -53.4], yaw: 12, yaw1: 6, pitch: 12, pitch1: 9, d: 58, d1: 50, fov: 36, ap: 0, shadow: 18 }, t => {});
  // B41 — No paywalls (the wall blows apart)
  const tPW = T('paywalls') + 0.05;
  beat(T('No') - 0.06, { L: [-1.5, 1.0, 3.6], yaw: 8, yaw1: 4, pitch: 4, d: 4.2, d1: 3.7, fov: 32, ap: 2.5, shadow: 4 }, t => {
    pwall.visible = true; const dt = Math.max(0, t - tPW);
    BR.forEach(({ b, x, y, v, w }) => { b.position.set(x + v.x * dt * 0.6, y + v.y * dt - 4.9 * dt * dt, v.z * dt * 0.6); b.rotation.set(w.x * dt, w.y * dt, w.z * dt); });
    pwSign.position.set(0, 1.0 + dt * 2.5 - 4.9 * dt * dt, 0.16 + dt * 3); pwSign.rotation.set(-dt * 6, dt * 2, dt * 3);
    if (t > tPW) pp(5, PW[0], 0.8, PW[2] + 0.2, seg(t, tPW, tPW + 0.6));
    const h1 = hop(t, tPW - 0.25, 0.4, 0.4);
    put(claude, { x: PW[0] + 0.1, z: PW[2] + 1.0 + Math.max(0, (tPW - t)) * 0.0, y: h1.y, yaw: Math.PI, armL: { f: 2.2, o: 0.3, e: 0.2, hand: 'fist' }, armR: { f: 0.4, e: 0.6, hand: 'fist' }, face: { eyes: 'angry', mouth: 'teeth' } }, t);
  });
  // B42 — No subscriptions (the $9/MO sign tips over)
  const tSub = T('subscriptions') + 0.05;
  beat(T('No', 2) - 0.06, { L: [1.85, 1.1, 3.9], yaw: -8, yaw1: -4, pitch: 4, d: 5.2, d1: 4.7, fov: 32, ap: 2, shadow: 3 }, t => {
    subPivot.visible = true; subPivot.rotation.x = -easeIn(seg(t, tSub, tSub + 0.35)) * 1.45;
    if (t > tSub + 0.33) pp(0, 1.6, 0.2, 2.6, seg(t, tSub + 0.33, tSub + 0.9));
    const kick = ss(t, tSub - 0.15, tSub);
    put(you, { x: 2.35, z: 4.35, yaw: -1.3, legL: -kick * 1.1, armL: { f: 0.5, o: 0.6 }, armR: { f: 0.4, o: 0.6 }, face: { eyes: 'angry', mouth: 'teeth' } }, t);
  });
  // B43 — No strings attached (snip; the file drops into Claude's hands)
  const tSnip = T('attached') + 0.02;
  beat(T('No', 3) - 0.06, { L: [4.6, 1.85, 3.6], yaw: 6, yaw1: 3, pitch: 2, d: 5.4, d1: 4.9, fov: 32, ap: 1.5, shadow: 3 }, t => {
    mar.visible = true; const fall = Math.max(0, t - tSnip), fy = Math.max(1.0, 1.75 - 4.9 * fall * fall * 1.6);
    strFile.visible = true; strFile.position.set(ST2[0], fy + Math.sin(t * 3) * (t < tSnip ? 0.03 : 0), ST2[2]); strFile.rotation.set(0, Math.sin(t * 2) * 0.15, 0);
    STR.forEach(({ s, dx, dz }) => { const top = V3(ST2[0] + dx, 3.0, ST2[2] + dz), bot = V3(ST2[0] + dx * 0.3, 1.75 + 0.27, ST2[2] + dz * 0.2); const len = t < tSnip ? top.distanceTo(bot) : Math.max(0.01, top.distanceTo(bot) * (1 - seg(t, tSnip, tSnip + 0.2))); s.visible = true; s.position.copy(top).lerp(bot, len / top.distanceTo(bot) / 2); s.scale.set(1, len, 1); s.quaternion.setFromUnitVectors(V3(0, 1, 0), bot.clone().sub(top).normalize()); });
    scis.visible = t > T('strings') - 0.2; scis.position.set(ST2[0] + 0.55, 2.45, ST2[2] + 0.25); scis.rotation.set(0, -0.4, 1.7); scis.open(t < tSnip ? 0.5 + Math.sin(t * 20) * 0.3 : 0);
    put(claude, { x: ST2[0], z: ST2[2] + 0.42, yaw: 0, armL: { f: 1.6, o: -0.15, e: 0.5, hand: 'open' }, armR: { f: 1.6, o: -0.15, e: 0.5, hand: 'open' }, head: [-0.2, 0, 0], face: { eyes: t > tSnip + 0.25 ? 'happy' : 'wide', mouth: 'grin', gaze: [0, 0.5] } }, t);
  });
  // B44 — Never pay for a Claude content skill again (the tag flips to $0; CLOSED)
  beat(T('Never') - 0.06, { L: [9.9, 1.4, 1.2], yaw: 8, yaw1: 4, pitch: 3, d: 5.0, d1: 4.5, fov: 32, ap: 2, shadow: 3 }, t => {
    const sad = t > T('pay') + 0.05;
    put(coon, { x: 9.85, y: 0.45, z: 0.55, yaw: 0, lean: sad ? 0.12 : 0, armL: { f: sad ? 0.1 : 1.3, o: 0.2, e: 0.3 }, armR: { f: sad ? 0.1 : 1.3, o: 0.2, e: 0.3 }, head: [sad ? 0.3 : 0, 0, 0], face: { eyes: t > T('again') ? 'dizzy' : sad ? 'sad' : 'wide', mouth: sad ? 'frown' : 'o' }, noBlink: true }, t);
  });
  // B45 — This format is called toybox.md (the toy chest pops open)
  const tOpen = T('called') + 0.05;
  beat(T('This') - 0.06, { L: [4.1, 0.95, 1.4], yaw: -12, yaw1: -6, pitch: 10, d: 6.2, d1: 5.4, fov: 30, ap: 2, shadow: 3 }, t => {
    const lk = springy(seg(t, tOpen, tOpen + 0.7), 2.5, 5); chest.userData.lid.rotation.x = -1.9 * lk;
    toyFile.visible = t > tOpen; const rk = easeOutBack(seg(t, tOpen + 0.05, tOpen + 0.7), 1.5); toyFile.position.set(4.0, 0.35 + rk * 1.05 + Math.sin(t * 3) * 0.03, 1.45); toyFile.rotation.set(0, Math.sin(t * 1.6) * 0.35, 0); toyFile.glow(t > tOpen ? 1 : 0);
    put(claude, { x: 4.75, z: 1.95, yaw: -1.0, face: { eyes: t > tOpen ? 'wide' : 'open', mouth: t > tOpen ? 'grin' : 'smile', gaze: [-0.4, 0.3] } }, t);
  });
  // B46 — So comment TOYBOX and I'll send it to you. Completely free. (everyone waves)
  beat(T('So', 5) - 0.06, { L: [3.1, 0.8, -0.35], yaw: -3, yaw1: 2, pitch: 6, d: 7.9, d1: 7.3, fov: 36, ap: 1.2, frameY: 0.6, shadow: 5 }, t => {
    const ph = n => ({ ...WAVE(t + n), f: 0.3 });
    put(gran, { x: 2.1, z: -0.6, yaw: 0.25, armR: WAVE(t + 1), face: { eyes: 'happy', mouth: 'grin' } }, t);
    put(you, { x: 2.62, z: -0.4, yaw: 0.1, armL: WAVE(t), face: { eyes: 'happy', mouth: 'grin' } }, t);
    put(claude, { x: 3.15, z: -0.2, yaw: 0, armL: { f: 1.1, o: -0.2, e: 0.7, hand: 'hold' }, armR: WAVE(t + 2), face: { eyes: 'happy', mouth: 'grin' } }, t);
    hold(toyFile, claude, 0, [0, 0.05, 0.12], [0, 0, 0]); toyFile.glow(1);
    put(coon, { x: 3.68, z: -0.35, yaw: -0.2, armR: WAVE(t + 3), face: { eyes: 'happy', mouth: 'smile' } }, t);
    bot.position.set(4.12, 0, -0.5); bot.rotation.y = -0.3; drawBotFace('happy');
    const tc = T('completely', 2); conf[2].position.set(3.0, 3.2, -0.5); conf[2].set(Math.max(0, t - tc));
  });

  // ===================== SHOTS from the beats =====================
  BEATS.forEach((b, i) => { b.t1 = i + 1 < BEATS.length ? BEATS[i + 1].t0 : END + 0.01; });
  const lin = k => k;
  const SHOTS = BEATS.map(b => {
    const c = b.cam, N = 8, P = [], Lk = [];
    for (let j = 0; j <= N; j++) {
      const k = j / N, u = k * 0.75 + easeInOut(k) * 0.25, tt = lerp(b.t0, b.t1, k);
      const Lp = (c.L1 ? c.L.map((v, q) => lerp(v, c.L1[q], u)) : c.L);
      const yaw = lerp(c.yaw, c.yaw1 ?? c.yaw, u) * Math.PI / 180, pitch = lerp(c.pitch, c.pitch1 ?? c.pitch, u) * Math.PI / 180, d = lerp(c.d, c.d1 ?? c.d, u);
      const v = [Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch), Math.cos(yaw) * Math.cos(pitch)];
      P.push([tt, Lp.map((x, q) => x + v[q] * d), lin]); Lk.push([tt, Lp, lin]);
    }
    return { t: [b.t0, b.t1], P, L: Lk, fov: c.fov ?? 32, ap: c.ap ?? 4, frameY: c.frameY ?? 0.45, shadow: c.shadow ?? 5, sun: c.sun };
  });
  const IMPULSE = [[T('another'), 0.03], [T('third', 2), 0.03], [T('fourth'), 0.03], [tCol + 0.1, 0.05], [T('$30', 2), 0.02], [tPW, 0.04], [tLib + 0.2, 0.02], [tOpen, 0.02]];

  // ===================== WORLD (every frame) =====================
  function world(tq, t, si) {
    // reset what beats move
    CAST.forEach(P => { P.root.visible = false; if (P.root.parent !== scene) scene.add(P.root); });
    [pencil, phoneC, botPhone, hammer, lemon, brief, stolen, copies, granPh, toyFile, strFile, scis].flat().forEach(o => o.visible = false);
    STR.forEach(s => s.s.visible = false); pwall.visible = false; subPivot.visible = false; mar.visible = false; gift.visible = false;
    puffs.forEach(p => p.set(0)); conf.forEach(c => c.set(0)); bigPuff.set(0);
    DOOR.rotation.y = 0; bot.position.set(-7.5, 0.84, -3.25); bot.rotation.y = -0.2; bot.userData.head.rotation.z = 0;
    if (t > 1.6) drawSheet(3);
    bench.visible = feeSign.visible = t > T('He', 3) - 0.1;
    chest.visible = t > T('This') - 0.1 && t < T('So', 5) - 0.06;
    chest.userData.lid.rotation.x = t > tOpen ? -1.9 : 0;
    // the world's own story state
    towerState(t); monoState(t); stallState(t); libState(t);
    const b = BEATS[si] || BEATS[0];
    b.run(t, b);
  }

  // ===================== OVERLAY =====================
  const KEY = new Set(['three', 'lines', 'text', 'file', 'claude', 'reel', 'congratulations', 'content', 'skill', 'microwave', 'manual', 'views', 'grandma', 'bot', 'hook', 'style', 'voice', 'reels', 'finish', 'second', 'format', 'third', 'fourth', 'tenth', 'leaning', 'seventeen', 'coffees', 'skill.md', 'free', 'raccoon', 'suit', 'repackaged', '$30', '$5', 'casper', 'morphy', 'library', '.md', 'paywalls', 'subscriptions', 'strings', 'toybox.md', 'toybox', 'never', 'again', 'door', 'knocks', 'perfect', 'bot', 'agentic']);
  const STK = [
    { text: 'CONTENT SKILL', at: T('content'), out: T('a', 4) - 0.06, y: 470, size: 104, rot: -0.05 },
    { lines: ['MICROWAVE', 'MANUAL'], at: T('microwave'), out: T('Claude', 2) - 0.06, y: 500, size: 120, col: 'red', rot: 0.06 },
    { text: '3 VIEWS', at: T('three', 2), out: T('Two') - 0.06, y: 470, size: 150, col: 'red', rot: -0.06 },
    { text: 'HOOK', at: T('hook'), out: T('style') - 0.02, y: 470, size: 170, rot: -0.05 },
    { text: 'STYLE', at: T('style'), out: T('voice') - 0.02, y: 470, size: 170, rot: 0.04 },
    { text: 'VOICE', at: T('voice'), out: T('Now') - 0.06, y: 470, size: 170, rot: -0.04 },
    { text: 'FORMAT 2', at: T('another'), out: T('then') - 0.06, y: 430, size: 120, rot: -0.04 },
    { text: 'FORMAT 3', at: T('third', 2), out: T('fourth') - 0.02, y: 430, size: 120, rot: 0.04 },
    { text: 'FORMAT 4', at: T('fourth'), out: T('By') - 0.06, y: 430, size: 120, rot: -0.03 },
    { text: '10 FORMATS', at: T('tenth'), out: T('the', 4) - 0.06, y: 420, size: 124, rot: -0.04 },
    { text: '17 COFFEES', at: T('seventeen'), out: T('So', 3) - 0.06, x: 470, y: 640, size: 120, col: 'red', rot: -0.09, skew: -0.04 },
    { text: 'SKILL.md', at: T('SKILL.md'), out: T('congratulations', 2) - 0.06, y: 430, size: 140, rot: -0.04 },
    { text: 'CONTENT FORMAT', at: T('content', 3), out: T('and', 4) - 0.06, y: 450, size: 100, rot: 0.04 },
    { text: 'FREE', at: T('free'), out: T('Everything') - 0.06, y: 520, size: 230, rot: -0.06, breathe: true },
    { text: 'KNOCK KNOCK', at: T('knocks'), out: T("it's", 2) - 0.06, x: 560, y: 560, size: 104, col: 'white', rot: 0.08 },
    { text: '$30', at: T('$30'), out: T('$30', 2) - 0.06, y: 470, size: 190, col: 'red', rot: -0.06 },
    { text: '$30!', at: T('$30', 2), out: T('for', 2) - 0.06, y: 520, size: 260, col: 'red', rot: 0.05 },
    { text: '$5', at: T('$5'), out: T('That') - 0.06, x: 640, y: 520, size: 190, rot: -0.05 },
    { text: 'MORPHY', at: T('Morphy'), out: T('So', 4) - 0.06, x: 640, y: 420, size: 140, rot: -0.04 },
    { text: 'LIBRARY', at: T('library'), out: T('ready-to-use') - 0.06, y: 470, size: 160, rot: -0.04, maxW: 0.93 },
    { text: 'READY-TO-USE', at: T('ready-to-use'), out: T('.md') - 0.02, y: 470, size: 110, rot: 0.04 },
    { text: '.md FILES', at: T('.md'), out: T('inside') - 0.06, y: 470, size: 150, rot: -0.04 },
    { text: 'NO PAYWALLS', at: T('paywalls'), out: T('No', 2) - 0.06, y: 470, size: 124, col: 'red', rot: -0.05 },
    { text: 'NO SUBSCRIPTIONS', at: T('subscriptions'), out: T('No', 3) - 0.06, y: 470, size: 100, col: 'red', rot: 0.04 },
    { text: 'NO STRINGS', at: T('strings'), out: T('Never') - 0.06, y: 470, size: 130, col: 'red', rot: -0.05 },
    { text: '$0', at: T('pay'), out: T('This') - 0.06, x: 560, y: 500, size: 240, rot: -0.06, breathe: true },
    { text: 'toybox.md', at: T('toybox.md'), out: T('So', 5) - 0.06, y: 460, size: 140, rot: -0.04, breathe: true, maxW: 0.93 },
  ];
  const heart = (g, x, y, s, a) => { g.save(); g.globalAlpha = a; g.fillStyle = '#ff3b5c'; g.strokeStyle = '#141414'; g.lineWidth = s * 0.18; g.beginPath(); g.moveTo(x, y + s * 0.9); g.bezierCurveTo(x - s * 1.6, y - s * 0.1, x - s * 0.7, y - s * 1.3, x, y - s * 0.45); g.bezierCurveTo(x + s * 0.7, y - s * 1.3, x + s * 1.6, y - s * 0.1, x, y + s * 0.9); g.closePath(); g.stroke(); g.fill(); g.restore(); };
  function overlay(g, t) {
    // 2D life on top of the 3D: sparkles around the sheet and the monument, hearts over Grandma and Claude's phone
    if (t > T("You've") - 0.06 && t < T('a', 4) - 0.06) { const p = proj(SHEET); [[-170, -220, 0], [180, -150, 0.3], [-140, 160, 0.55], [200, 210, 0.15], [20, -300, 0.7]].forEach(([dx, dy, o], i) => O.sparkle(g, p.x + dx, p.y + dy, 46, ((t * 1.2 + o) % 1))); }
    if (t > tMono && t < T('congratulations', 2) + 1.0) { const p = proj([TW[0], 1.6, TW[2]]); [[-260, -300, 0], [240, -220, 0.35], [-220, 120, 0.6], [260, 140, 0.2]].forEach(([dx, dy, o]) => O.sparkle(g, p.x + dx, p.y + dy, 54, ((t * 1.1 + o) % 1))); }
    if (t > T('Two') - 0.06 && t < T('the', 2) - 0.06) for (let i = 0; i < 6; i++) { const t0 = T('Two') + i * 0.16, k = seg(t, t0, t0 + 0.9); if (k > 0 && k < 1) heart(g, 330 + ((i * 137) % 420), 900 - k * 520, 34 + (i % 3) * 8, 1 - k * k); }
    if (t > T('Now') - 0.06 && t < T('but') - 0.06) for (let i = 0; i < 7; i++) { const t0 = T('Now') + i * 0.17, k = seg(t, t0, t0 + 0.9); if (k > 0 && k < 1) heart(g, 380 + ((i * 151) % 360), 980 - k * 560, 30 + (i % 3) * 8, 1 - k * k); }
    if (t > tOpen && t < T('So', 5) - 0.06) { const p = proj(toyFile.position); [[-160, -150, 0], [170, -110, 0.4], [-120, 140, 0.7], [150, 170, 0.2]].forEach(([dx, dy, o]) => O.sparkle(g, p.x + dx, p.y + dy, 50, ((t * 1.3 + o) % 1))); }
    for (const s of STK) O.sticker(g, t, s);
    O.commentCard(g, t, { at: T('comment') - 0.04, y: 600, word: 'TOYBOX' });
    O.plane(g, t, T('send') - 0.05, 0.9, 260, 760, 900, 380);
    O.captions(g, film, t, { key: w => KEY.has(norm(w.cap)) });
  }
  function effects(t) {
    const fl = FX.flash(t, [[T('congratulations'), 0.12], [tMono, 0.18], [T('$30', 2), 0.1], [tOpen, 0.14], [tLib + 0.1, 0.06]]);
    const zoom = FX.punchOut(t, T('$30', 2) - 0.06, 0.18, 0.05) + FX.punchOut(t, T('So', 4) - 0.06, 0.18, 0.03) ;
    const sh = FX.shake(t, [T('hook'), T('style'), T('voice'), T('$30', 2), tPW], 7);
    FX.apply(E, t, { flash: fl, zoom, shake: sh, fade: 1 - seg(t, END - 0.35, END) * 0.0 });
  }
  return { SHOTS, IMPULSE, world, overlay, effects, smoothCam: true };
}
```

### `my-video/cues.mjs`

```js
// Sound effects of this video, on the picture's events in scene.js. ev(name, time s, gain dB, max duration s?)
// Kit sounds (assets/sfx): knock scribble creak poof (engine/make-toy-sfx.mjs) + the Voxel/Paper/Cutscene kit
// (thud impact blocks boing kaching coins pop pop_hi shimmer chime ding swish swoosh whoosh clunk slap beep riser tear clack plane).
// Effects sit ~8 LU under the voice; the loud ones land between words, never on a key word.
export default function cues(ev, film) {
  const norm = s => s.toLowerCase().replace(/[^a-z0-9$.']/g, '').replace(/\.$/, '');
  const T = (w, n = 1) => { const k = norm(w); const h = film.words.filter(v => norm(v.cap) === k); if (!h[n - 1]) throw new Error(`word not found: ${w} #${n}`); return h[n - 1].t0; };
  const G = -2;   // master trim for every effect
  const e = (n, t, db, d) => ev(n, t, db + G, d);

  // the text file: the pencil writes, Claude raises his hand, confetti
  e('scribble', T('three') - 0.1, -17, 1.0); e('scribble', T('text') + 0.05, -18, 0.6);
  e('pop', T('Claude') + 0.05, -16);
  e('pop', T('congratulations') + 0.02, -12); e('shimmer', T('congratulations') + 0.05, -18, 1.2);
  for (let i = 0; i < 4; i++) e('pop_hi', T('congratulations') + 0.1 + i * 0.09, -22);
  e('shimmer', T("You've") + 0.1, -20, 1.0); e('ding', T('skill') + 0.02, -18);
  // deadpan, the microwave
  e('swish', T('a', 4) - 0.08, -24);
  e('ding', T('manual') + 0.04, -11); e('beep', T('microwave') + 0.4, -22);
  // posts one video → three views
  e('click', T('posts') + 0.03, -12); e('pop', T('posts') + 0.12, -18);
  e('pop_hi', 8.9, -16); e('pop_hi', 9.03, -16); e('pop_hi', 9.17, -16);
  e('boing', T('views') + 0.35, -20);
  // Grandma's hearts, the bot
  for (let i = 0; i < 6; i++) e('pop', T('Two') + i * 0.16, -21);
  e('beep', T('bot') - 0.02, -12); e('beep', T('bot') + 0.18, -15);
  // the platform: posts, then HOOK / STYLE / VOICE hammered on
  e('poof', T('give') - 0.04, -12); e('clunk', T('give') + 0.02, -14);
  for (const w of ['hook', 'style', 'voice']) { e('thud', T(w) - 0.04, -12); e('clunk', T(w) - 0.03, -15); e('poof', T(w) - 0.02, -18); }
  // views climbing, hearts
  for (let i = 0; i < 7; i++) e('pop_hi', T('Now') + 0.1 + i * 0.17, -22);
  e('pop', T('wants') + 0.02, -18);
  // the cabins land
  for (const t of [T('another'), T('third', 2), T('fourth')]) { e('whoosh', t - 0.3, -20); e('thud', t - 0.01, -9); e('poof', t, -14); }
  e('riser', T('By') - 0.05, -22, 0.8);
  // it leans, the coffees rattle, it collapses
  e('creak', T('the', 4) + 0.1, -12); e('creak', T('leaning') + 0.2, -14);
  for (let i = 0; i < 8; i++) e('clack', T('seventeen') + 0.05 + i * 0.11, -21 - (i % 3) * 2);
  e('blocks', T('So', 3) + 0.08, -10); e('impact', T('So', 3) + 0.12, -14); e('poof', T('So', 3) + 0.15, -10);
  // one clean SKILL.md, congratulations
  e('shimmer', T('one', 2) + 0.05, -15, 1.6); e('chime', T('SKILL.md') + 0.15, -18, 1.4);
  e('pop', T('congratulations', 2) + 0.02, -11);
  for (let i = 0; i < 4; i++) e('pop_hi', T('congratulations', 2) + 0.1 + i * 0.09, -21);
  // the FREE sign, copies fly over the fence
  e('thud', T('give', 2) - 0.02, -11); e('poof', T('give', 2), -16);
  for (let i = 0; i < 6; i++) e('swoosh', T('completely') + i * 0.11, -20 - (i % 2) * 2);
  // calm, then knock knock knock
  e('pop', T('perfect') + 0.12, -24);
  for (const k of [0, 0.16, 0.32]) e('knock', T('knocks') + k, -6);
  e('flick', T("it's", 2) - 0.12, -16);   // the door opens
  e('swish', T('tiny') + 0.05, -20);
  // took, repackaged, sold
  e('swish', T('took') - 0.02, -13);
  e('tear', T('repackaged') + 0.1, -15); e('clunk', T('repackaged') + 0.38, -12); e('poof', T('repackaged') + 0.4, -17);
  e('slap', T('sells') - 0.02, -14); e('kaching', T('$30') + 0.42, -14, 0.9);
  e('impact', T('$30', 2) - 0.03, -15, 1.2); e('boing', T('free', 3) + 0.25, -14);
  e('coins', T('$5') + 0.42, -14);
  // you, Morphy, the response
  e('pop', T('Morphy') + 0.32, -18); e('enter', T('agentic') - 0.05, -20); e('pop_hi', T('super') + 0.05, -22);
  e('slap', T('response') + 0.1, -12);
  // the library rises
  e('riser', T("I'm", 2) - 0.05, -16, 1.6); e('thunder', T("I'm", 2) + 0.05, -22, 1.4);
  e('whoosh', T('ready-to-use') - 0.08, -18); e('shimmer', T('.md') + 0.05, -20, 1.2);
  e('chime', T('inside') + 0.05, -18, 1.0);
  // no paywalls / no subscriptions / no strings
  e('impact', T('paywalls') + 0.1, -12, 1.0); e('blocks', T('paywalls') + 0.12, -12);
  e('swish', T('subscriptions') + 0.02, -18); e('thud', T('subscriptions') + 0.42, -12); e('poof', T('subscriptions') + 0.44, -16);
  e('clack', T('attached') - 0.02, -10); e('pop', T('attached') + 0.4, -14);
  // the tag flips to $0, CLOSED
  e('flick', T('Never') + 0.06, -14); e('boing', T('pay') + 0.25, -16); e('slap', T('again') - 0.1, -16);
  // the toy chest
  e('clunk', T('called') + 0.06, -11); e('shimmer', T('called') + 0.12, -16, 1.5); e('ding', T('toybox.md') + 0.05, -18);
  // the comment card, the plane, confetti
  e('pop', T('comment') - 0.03, -10);
  for (let i = 0; i < 6; i++) e('pop_hi', T('comment') + 0.09 + i * 0.05, -18);
  e('plane', T('send') - 0.05, -14); e('whoosh', T('send') + 0.05, -20);
  e('pop', T('completely', 2) + 0.02, -14); e('chime', T('free', 4) + 0.05, -16, 1.6);
  // soft swishes on some cuts (the reference's edit has an air whoosh on many cuts)
  for (const t of [T('lines') - 0.12, T('and') - 0.06, T('Claude', 2) - 0.06, T('Two') - 0.06, T('So') - 0.06, T('but') - 0.06, T('He', 2) - 0.06, T('That') - 0.06, T('This') - 0.06]) e('swish', t - 0.05, -26);
}
```

