---
name: mannequin
description: Make a narrated vertical reel (1080x1920, 30-100 s) in the "Mannequin" format - a Blender-style POV horror story told to "you" in a wet, hazy London-style subway at night, starring a glossy white artist's mannequin with a painted cartoon face (dot or ring eyes, thick brushy brows) and a black mannequin with two glowing white eyes that comes up behind him; white brick tiles with a teal band, navy station signs, red seats, fluorescent tube battens, a dot-matrix NEXT TRAIN board, a silent lit train, a taped night-shift note; hard cuts every ~2 s, each a slow push-in; white bold caption chunks of 1-3 words; the lights dying far to near; an ending where two choice pills (1 GET ON / 2 STAY) draw lines to what they mean. Rendered frame by frame in headless Chrome (three.js), narrated by ElevenLabs, a free neural voice or your own recording, mixed over a synthesized subway drone. Use it when someone asks for a mannequin reel, "mannequin.md", a choose-your-path horror story, a "what would you do?" POV reel, or wants to turn a creepy second-person script into this kind of video.
---

# Mannequin reel: how to make one

You will make a **vertical video (1080x1920, 30 fps, H.264 + AAC)** that looks like **a small Blender horror short**. Everything happens at **Ashford Road**, a deep London-style subway station at night: white brick tiles with a teal band at hip height, navy "Ashford Road" signs with a white inset border, red plastic bucket seats, posters, fluorescent tube battens on a near-black ceiling, a wet concrete floor full of tube reflections and puddles, milky haze down the platform, an amber dot-matrix **NEXT TRAIN 05:30** board hanging from the ceiling, a red dot-matrix wall clock, the exit stairs behind a folding gate, two tunnel mouths, and off to the side a white **driver's booth** with a blue fascia and a **night-shift note** taped inside its window ("NIGHT SHIFT INSTRUCTIONS / STAFF ONLY / RULE NO. 1: NEVER BOARD AN EMPTY TRAIN. / RULE NO. 2: IF YOU HEAR FOOTSTEPS BEHIND YOU, DO NOT…", the rest torn off). The hero is **"you": a glossy off-white wooden artist's mannequin** (a big smooth sphere head with a painted black face, a rounded chest, a waist ring, round hips, long slim limbs with ball joints, mitten hands). The threat is **a black mannequin with two glowing white eye dots**. A **silent train** with a lit white interior and blue benches. The story is told **to "you", in the present tense, slowly**: you miss the last train, you're alone, there's a rule, you shrug it off, the lights die, something comes, **you have two choices**. Desaturated neutral grey while the lights are on, a cold navy night after they die, warm sepia on paper. **Hard cuts only, a new shot every ~2 s**, almost every shot one slow push-in that starts and ends at rest. **Captions**: white bold caps (Inter Tight ExtraBold), 1-3 words, a soft drop shadow, blank in the pauses. The ending: **"1 GET ON" / "2 STAY" pills** whose lines draw to a dot on the open door, on the floor, on his palms, then park at the top while the camera dives into his eyes.

**`reference.jpg`** (next to this file, if you have it) is a contact sheet of 12 frames from a finished Mannequin reel: **this is what it should look like.** Left to right, top row first: (1) behind him walking down the hazy platform, the train on the left, "IMAGINE YOU"; (2) him facing the exit stairs, the black folding gate slammed shut, "THE GATES"; (3) the navy Ashford Road sign on the tiles; (4) the phone close-up in his mitten hand, 23:41, a red empty battery, 3%; (5) an extreme close-up of his face, angry V brows; (6) him slumped on the red seats, sad; (7) the night-shift note close up, a glossy streak across it; (8) the red dot-matrix clock reading 00:00; (9) him in the blue dark, ring eyes, hands up at his chest; (10) right behind him as the train doors open on a flood of white light; (11) him frontal with ring eyes and an "o" mouth, the black mannequin at his shoulder; (12) the high wide at the open door with the two choice pills and their lines. 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 starter video plus a 47-shot library (Appendix B). With the engine written, **a video is four small files**: `script.txt` (what the voice says), `video.json` (settings), `scene.js` (the director file: one `shot()` per camera set-up) and `cues.mjs` (sound effects).

Work in this order and don't skip ahead: **setup → script → fake timings → direct the shots → stills → voice → real timings → fix the shots → 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 an hour.
2. **Never print secrets.** The ElevenLabs 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` is a dry run unless you pass `--yes`: one take of the whole script (~1 credit per character). Never regenerate a take that exists. No key, or no wish to spend: `gen-vo-edge.mjs` (free) or the user's own recording.
4. **Stills before film.** Direct the whole scene on fake timings and check stills before you make the voice, and never start the full render until every shot's still is right.
5. **Background jobs die when your turn ends.** Render in foreground chunks (`render.mjs my-video full 0 600`, then `full 600 1200`, …), each call under your tool timeout. The render resumes where it stopped.
6. **No real people, no real brands, no gore.** The cast are invented mannequins; the station, signs and phone screens are drawn. Creepy, never graphic. 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`).
- **A narration**, one of: an **ElevenLabs API key** (best: a calm low storyteller, section 5); **edge-tts** (`pip install edge-tts`, free Microsoft neural voices: good for drafts; check the terms before publishing); or **your own recording** (a WAV).
- **Python + faster-whisper** (`pip install faster-whisper`): times the words of an edge-tts or recorded narration (ElevenLabs gives its own timings) and checks the final mix word by word.
- Free, automatic (`setup.mjs`): the caption font **Inter Tight ExtraBold** (SIL Open Font License) and the whole sound kit, synthesized with ffmpeg (no downloads, no credits).

## 2. Project layout and setup

```
mannequin-reel/                   one project folder, many videos
  .env                            ELEVENLABS_API_KEY=...   (optional; 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 kit.js      the page: font → <video>/film.json + <video>/scene.js → the kit (world, cast, helpers) → window.setT(t)
    mq.js station.js train.js tex.js wet.js sublook.js   the mannequins · the subway + booth · the train · textures · the wet floor · light
    overlay.js camera.js post.js fx.js lib.js            captions, pills, safe zone · shots → camera · post · flashes, whips · helpers
    common.mjs cdp.mjs setup.mjs make-sfx.mjs gen-vo.mjs gen-vo-edge.mjs words.mjs build.mjs align.mjs votight.mjs voedit.mjs whisper-words.py
    render.mjs sheet.mjs redo.mjs mix.mjs encode.mjs encode-report.mjs check.mjs
  assets/fonts/ assets/music/ assets/sfx/   downloaded or synthesized once
  shots/subway-example.js        the 47-shot library (Appendix B): copy shots from it, never load it
  my-video/                       one folder per video (Appendix B is a complete starter)
    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 mannequin-reel && cd mannequin-reel
echo '{ "type": "module", "private": true, "dependencies": { "three": "0.170.0" } }' > package.json && npm i
# write engine/* and shots/subway-example.js from the appendices, then:
node engine/setup.mjs            # checks Node/ffmpeg/three/Chrome/GPU and the voice options, downloads the font, makes the sound kit
```

A video:

```bash
mkdir my-video                   # write script.txt (section 4) + video.json + scene.js + cues.mjs (Appendix B is a working start)
node engine/build.mjs my-video --fake                         # guessed timings (~2.6 words a second): direct before you make a voice
node engine/render.mjs my-video stills shots --safe           # one still in the middle of every shot + a contact sheet (section 8)
# the narration, ONE of:
node engine/gen-vo.mjs my-video          # dry run (text + cost), then --yes: ElevenLabs, one take → my-video/vo/a.mp3 + its timings
node engine/gen-vo-edge.mjs my-video     # free: edge-tts → my-video/vo/a.mp3
#   or put your own recording in my-video/vo/a.wav
node engine/align.mjs my-video                                # → film.json (Whisper times edge-tts and recordings): prints every caption chunk
node engine/render.mjs my-video stills shots --safe           # every shot again on the real timings
node engine/render.mjs my-video full 0 600 && node engine/render.mjs my-video full 600 1200 && node engine/render.mjs my-video full 1200
node engine/redo.mjs my-video 13.5-15.1                       # 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 station, a new shot every ~2 s, each a slow push-in |
| Caption | centred on x 510, **y 1323** (high) or **y 1428** (low) | 1-3 words, Inter Tight 800, 84 px (shrinks to fit 800 px), white, a soft drop shadow, no outline |
| Choice pills | live: near their target; parked: y 288-369 (GET ON x 153-431, STAY x 676-897) | a dark pill with a white rim, a coloured number disc, white bold caps; a thin white leader line to a dot |
| Glossy streak | note close-ups, x 400-875, y ≈ 300-410 | a soft white bar (a tube reflected in the paper), fixed on screen while the paper drifts under it |

**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 original reel put its low caption line at y 1611, under the app's buttons; this format moves it up to 1428.) 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, boards, the note, the phone) isn't tracked: keep it big and central.

### 3.2 Colours

| Token | Hex | Used for |
|---|---|---|
| tiles | `#d6d4cc` (grout `#8d8b85`), the far wall `#cbc8be` | 0.2 × 0.075 m glossy bricks, a running bond |
| teal band | `#0f5e5c` | y 1.05-1.32 on every wall, a thin second line at y 2.95 |
| floor | concrete `#4e4e4c` with darker puddles | wet: puddles mirror, the rest a blurred sheen |
| ceiling | `#2a2a2a`, beams `#232323` | near black |
| signs | navy `#1b3a8c` with a white inset border, white "Ashford Road" | 2.0 × 0.5 m at y 2.12 |
| seats | red `#c23a22`, steel legs `#3c3c3c` | rows of 4 against the wall |
| boards | NEXT TRAIN amber dots (255,140,50) on `#1a120c`; the clock red dots (255,55,40) on `#160807` | 5x7 dot-matrix glyphs, unlit dots faintly visible |
| train | body `#c9ccd0` (metallic), stripe `#1f47a8`, pillars `#2a2d33`, inside white `#e8eaec`, benches `#2a5fd0`, header `#c9a24a` | silver-white cars |
| mannequins | white `#c4c4c0` (roughness 0.3, clearcoat 0.6); black `#08080a` (roughness 0.4, clearcoat 0.7); face ink `#0d0d0f` | |
| phone | screen `#0f1730` → `#0a0f22`, coral `#ff5a4a` | the battery / no-signal lock screens |
| note | paper `#efece4` → `#ddd8cc`, ink `#2a2826`, red `#9b2b25`, the red box `#c8302a` | |
| pills | fill `rgba(30,31,34,.9)`, rim white, discs `#2c7af3` (1) / `#e6453c` (2) | |

### 3.3 Fonts

**Inter Tight ExtraBold (800)** for the captions, the pills and every drawn screen, sign and note (SIL OFL; `setup.mjs` downloads it to `assets/fonts/InterTight-800.woff2`). The boards draw their own 5x7 dot glyphs.

### 3.4 The station (world units are metres; y is up)

| Thing | Where |
|---|---|
| platform | x −2.2 (edge, white coping + a yellow line at x −1.58) to 3.3 (the wall), z −46 (south) to 28 (north); floor y 0; ceiling 4.3 |
| track | the bed at y −1.15, x −6.4 to −2.2, centre x −3.9; rails ±0.72; the far wall at x −6.4 |
| tunnels | south: an arch over the track at z −46; north: an arch over the track at z 28. **Looking −z (south) the track is on your left; looking +z (north) it is on your right**: pick the end that gives the reference's screen direction |
| exit | north end wall, an opening x −0.1 to 2.5, stairs going up, a green EXIT sign, a folding gate (`st.gate(k)`, 0 open → 1 shut) |
| fixtures | ceiling tube battens every 4.5 m in two rows (x 0.55 and x −3.9), wall battens every 7 m at y 3.25 |
| signs, posters, seats | signs on the wall at z −24, −6, 12 and the far wall at −15, 3; posters (one "RIDE SAFE" in teal and purple at z −14); seat rows at z −6.2, 8.5, −19 (move them per shot: `st.seats[i].position.z`) |
| boards | NEXT TRAIN at (0.7, 3.15, −9) and a second at (0.7, 3.15, −30) (both double-sided); the wall clock at (3.24, 3.3, 18.6) |
| the booth | its own set at `ST.BOOTH` = (60, 0, 0): the kiosk centred at z −2, its window facing +z, the note at (60.45, 1.68, −1.13); a grey speckled room, its own tube |

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

`makeMannequin({ dark })`: ~1.8 m tall, the head a 0.14 m sphere (centre y ≈ 1.64). White ("you"): a painted face (a 768 × 640 canvas on the front of the head, redrawn only when the expression changes). Black ("it"): no face, two glowing white eye dots (`it.eyes`). `MQ.pose(P, s)`:

- `x y z yaw` (yaw 0 faces +z; −π/2 faces −x, toward the track; +π/2 faces +x, the wall; π faces −z), `lift` (pelvis up/down), `hipR spine chest neck` ([rx, ry, rz]; spine rx > 0 leans forward), `head: [pitch (+ looks down), yaw (+ turns to his left), roll]`.
- arms `armL / armR: { f (raise forward, rad), o (outward), tw (twist), e (elbow), wr: [rx, ry, rz] }`; legs `legL / legR: { f, o, k (knee), a (ankle) }`; `MQ.walk(phase, stride)` (merge it in), `MQ.SIT(seatTop)`, `MQ.merge(a, b)`.
- `face: { eyes: dot|small|wide|ring|ringS|squint|shut, brow: flat|raise|up|angry|sad|worried|skeptic|none, mouth: flat|smile|grin|smirk|frown|wave|o|O, mouthS (size), gaze: [x, y], blink }`.
- Poses that work: hands up at the chest `{ f: 0.35, o: 0.12, e: 2.1, tw: −1.2 }` (both arms); hugging himself `{ f: 0.45, o: 0.12, e: 2.0, tw: −1.35 }`; a shrug, palms up `{ f: 0.3, o: 0.5, e: 1.75, tw: −1.3 }`; offering a palm `{ f: 1.2, o: 0.1, e: 0.35, tw: −1.3 }`; reaching for the gate `armR: { f: 1.3, e: 0.3 }`; slumped on the bench `SIT(0.48)`, `spine: [0.32, 0, 0]`, `head: [0.12, 0, 0.14]`, arms `{ f: 0.55, e: 1.05 }`; standing `K.STAND`.
- Expressions the story uses: neutral (dot, flat, flat), annoyed (dot, angry), sad (small, sad, gaze down), noticing (dot, raise), smug (dot, flat, smirk), scared (ring, worried, wave), terror (ring, worried, O).

### 3.6 The kit (`kit.js`, `E.K` in scene.js)

`lightsOn(p, o)` (the station lit: area lights on the 8 ceiling tubes + 4 wall battens nearest p, plus `o.key` a shadow spot `{ p, t, i, a }`), `night(o)` (every tube dead, a faint cold-blue ambient, dark haze), `moon(p, target, i, angle)` (a cold key for night shots), `boothLights(o)`, `trainAt(noseZ, lights, doors)` (the train runs toward −z; `lights: { head, inside, tail, rHead, rTail, beam }`; `doors` 0..1), `carLights(noseZ, k)` (the lit cars at night), `stopFor(doorZ)` (the nose z that stops a door at doorZ), `walkPath(t, t0, t1, [x, z], [x, z], ease)` + `walkPose(w, stride, extra)`, `hand(P, 'R'|'L')` (a world point on the palm), `proj(point)` (screen px), `phonePose(up)` + `holdPhone(up)` (the phone from the hip to beside his head), the close-up hand + phone `K.CH` with `K.PS.draw('battery'|'signal', time)`, `st.clock.board.draw(['00:00'], { scale: 2 })`, `st.tubesOn(g => 0|1)` (per tube: the lights dying), `bo.note.tex.draw(k)` (the red box drawing round rule 1, 0 → 1), `K.pole` (a chrome pole for a near-lens whip), `K.floorPool` (a light pool on the floor), `K.reset()` (every frame, before the shot).

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

- **Captions** `O.captions(g, film, t, { lo: t0 => bool })`: the chunk on screen, 84 px white, a soft shadow; **a 3-frame pop** (frame 1 at 75 % opacity, frame 2 at 102 % scale, then 100 %); **no caption in the pauses** (a chunk leaves 0.15 s after its last word when the next one starts later); the low line while a sign, the phone, a board or the note fills the frame (`lo` decided when the chunk appears, so it keeps its line across a cut).
- **Choices** `O.choice(g, t, { n, label, w, x0, cy, t0, to, line0, line1 })`: the pill pops at t0 (45 % at ×0.82 → 90 % at ×1 → ×1.066 → settled by 0.2 s), the line draws from the pill's bottom-centre to `to` (screen px, e.g. `proj(hand(you, 'R'))`) between line0 and line1, the dot pops a frame later. `O.parked(g, t)` = both at the top. Widths `O.PILL.getOnW` 278, `stayW` 221.
- **Streak** `O.streak(g, y)`: the glossy tube reflection on the note (y as a fraction of the height, 0.15-0.22).

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

Rect-area lights on the fixtures near each shot, one shadow-casting key spot, a grey hemisphere (0.4), a studio environment for the gloss, exponential haze (`#76787a`, 0.026 on the platform). The wet floor: a hidden planar reflector rendered at half resolution, mixed in through the puddle mask with fresnel and a small ripple. Post: depth ambient occlusion, depth of field per shot (`ap`, `focus`, `blur`), bloom on bright things (tubes, eyes, headlights), ACES, then the grade per section: **lights on** exposure 0.56, saturation 0.62, warm 0.04, vignette 0.4; **night** exposure 0.95, saturation 0.6, warm −0.4 (blue), contrast 1.1, vignette 0.55; **paper** exposure 0.62, warm 0.4, vignette 0.85; grain 0.008-0.016. A white flash (6 frames: .55 .34 .26 .17 .09) on a slam; a horizontal whip blur to hide a cut behind the pole.

## 4. Writing for this format

- **Second person, present tense, slow.** "Imagine you…", "You look up at…", "But then…". About **2.6-2.8 spoken words a second with real pauses** (the pauses are the blank-caption beats and the cuts). A full story is 70-100 s (~250 words); a short one 30-60 s.
- **The shape:** **the setup** ("Imagine you miss the last train home, and the second it leaves, the gates slam shut behind you.") → **alone** (the place, a dead phone: "3% battery. And of course, no signal at all.") → **the wait** (the board: "The next train doesn't come until 5:30 a.m.") → **the rule** (a note: "Never board an empty train." The rest is torn off) → **the shrug** ("You smile and think it's probably just a joke.") → **the turn on a time** ("But then, exactly at midnight, every single light goes out.") → **it comes** (silent, slow; a sense: footsteps, breath on your neck) → **the choice** ("You have two choices. You get on the train, or you stay alone in the dark, with whatever is standing right behind you.") → **the ask** ("So, what would you do?", or a keyword: "Comment MANNEQUIN and I'll send it to you.").
- **Every sentence must be showable** as one or two shots in the station: a place (a sign), a thing (the phone, the board, the clock, the note), a feeling (a face close-up), an event on its word (a gate slams, the clock flips, the lights die, doors open), the threat (eyes in the dark, legs walking, a head at his shoulder).
- `script.txt` = one sentence per line, exactly what the voice reads; `video.json` `say` = how the voice says a shown token (`"5:30": "five thirty"`, `"3%": "three percent"`). Captions show the script as written (in caps).

## 5. The voice

- **ElevenLabs:** **Brian** (`nPczCjzI2devNBz1zQrb`) on **eleven_multilingual_v2**, stability 0.5, similarity 0.8, style 0.3, speaker boost, **speed 0.95**: a calm, low storyteller. One take of the whole script. Keep its pauses (no tightening: this format breathes). A word that comes out wrong: `voedit.mjs` splices a one-line re-take in.
- **Free:** `gen-vo-edge.mjs my-video [--voice en-GB-RyanNeural] [--rate -6%] [--pitch -4Hz]` (edge-tts). Good for drafts and tests; check the voice's terms before publishing.
- **Your own recording:** a WAV in `my-video/vo/a.wav` (any rate; mono or stereo). Read slowly, leave real pauses.
- With no ElevenLabs timings, `align.mjs` times the script's words **by Whisper alone** (it matches the script's words to Whisper's and spreads the unmatched ones between their neighbours) and prints how many matched.
- `video.json` `"audio": "as-is"` when your WAV already is the whole soundtrack (music and effects included): `mix.mjs` then only levels it.

## 6. Timings: film.json

`build.mjs` (run by `align.mjs`) writes the **caption words** (`film.words`: `cap`, `t0`, `t1`) and the **chunks** (1-3 words, broken after punctuation, balanced; each shows from its first word to 0.15 s after its last, or until the next starts). `END` = the last word + `tail` (1.2 s for the final stare). `--fake` writes guessed timings (no voice needed). **In scene.js, `T(word, n)` = when the n-th caption word `word` starts. It is case-insensitive and ignores punctuation: "the" matches "The", so count the occurrences.**

## 7. Directing (scene.js)

### 7.1 The shape of the file

`setup(E)` returns `{ SHOTS, world, overlay, effects }`. The world and the cast already exist (`E.K`); scene.js only adds **shots**:

```js
// shot(t0, t1, cam, run): cam = { P: [[t, [x,y,z], ease?], …] (camera), L: [[t, [x,y,z], ease?], …] (look-at, also the focus
// distance unless `focus:`), fov, ap (depth-of-field strength), focus?, blur? (max blur px), lo? (captions on the low line) }
// run(t) lights the shot and poses what it shows. Eases default to smoothstep: the reference's moves start and end at rest.
const tSlam = T('slam');
shot(T('the', 2) - 0.07, T('ashford') - 0.06, { P: [[0, [0.35, 1.2, 21.2]], [tSlam + 0.17, [0.35, 1.2, 21.2]], [T('ashford'), [0.45, 1.22, 21.95]]],
  L: [[0, [1.75, 1.3, 28]]], fov: 58, ap: 0.8 }, t => {
  lightsOn([1, 0, 24], { key: { p: [1.4, 3.9, 23.6], t: [1.7, 0, 25.4], i: 22 } });
  st.gate(ss(t, tSlam - 0.06, tSlam + 0.04));                                   // the gate shoots across in 3 frames, on "slam"
  MQ.pose(you, { x: 1.75, z: 25.3, yaw: -0.08, armL: { o: 0.04, e: 0.12 }, armR: { f: 1.3 * ss(t, tSlam, tSlam + 0.1), e: 0.3 } });
});
```

`world()` = `K.reset()` then the current shot's `run(t)`. `overlay()` draws the streak, the choices and the captions. `effects()` sets the grade per section and the flashes (Appendix B shows all three).

### 7.2 Framing a 9:16 frame

- At fov 30 the frame is only **0.30 × d wide**. A face close-up d ≈ 0.6-0.7 (focus on the face surface: `focus: d − 0.14`, `ap` 30-40, `blur` 40 so a poster behind turns to mush), head to knees d ≈ 1.9 at fov 38, a full figure ≈ 3.5, the note filling the width d ≈ 1.5 at fov 26, the clock d ≈ 3.6 at fov 50, a platform wide ≈ 8-15 m at fov 56-62.
- Wides use fov 56-62, close-ups 26-34. The subject sits a bit above the high caption line; a sign or the note fills the middle (captions low).

### 7.3 The edit follows the words

- **Hard cuts only**, a new shot every 1.5-3 s, most cuts 0.05-0.1 s before a sentence's first word. A cut can hide behind a near-lens whip (the pole sweeping past with the whip blur).
- **Almost every shot is one slow push-in** (×1.05-1.30) that starts and ends at rest; a few are locked off (the tunnel, the arriving train); the last one dives ×2.5 into his eyes.
- **On the word:** the gate slams + a white flash on "slam"; the phone screen ticks to 23:42 across a cut; his brows turn angry after the phone comes up; his head snaps toward what he notices; the clock flips to 00:00 on "midnight"; the lights die far → near over ~0.55 s before "out", then near-black with a faint blue; the headlights grow in the tunnel; the doors open on "open" and close on "start"; the choice pills pop on "you have" and "two".
- Captions: blank in every pause ≥ 0.2 s and around most cuts.

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

- Every frame is a pure function of `t`: no randomness, no state carried between frames. `K.reset()` puts everything back first; a shot only sets what it shows.
- Characters hold still (pose to pose); a head turn takes 0.3 s, an arm snaps in 0.1 s; walks use `walkPath` (the phase comes from the distance, so the feet never slide).
- The train moves at a steady speed and brakes over ~0.3 s with an ease-out; doors open over 0.5 s.
- Screen direction: check which end of the station the shot looks at (section 3.4) before placing the train.

### 7.5 The shot library

`shots/subway-example.js` (Appendix B) is a complete 98.6-second film in this format, 47 shots at fixed times: the walk away as the last train leaves, the tail into the tunnel, the gate slam + flash, the sign trucking left, the high wide, the face + phone up, the two phone inserts, the angry close-up, the board from behind and the dolly in, the walk to the seats and the pivot, the slump and the dolly back, the head turn, the booth dolly, four note close-ups with the streak, the smile, the smug walk, the clock flip, the lights dying, the frightened mid-shot, the tunnel mouth, the train out of the tunnel, the nose blowing past at floor level, the 3/4 head, the silent train braking, the doors opening on white light, the glide down the empty car + the pole whip, the boxed rule, the eyes far off in the dark, the legs walking at floor level, the eyes coming up behind him, the two-shot, the breath on his neck, the terror close-up, the doors closing from inside, the three choice shots, the black head, the final dive. Copy a shot, move its times onto your words (`T(...)`), keep its camera and lights.

## 8. Stills and the safe zone

`node engine/render.mjs my-video stills shots --safe` renders the middle of every shot and a contact sheet (`stills shots 10 20` = shots 10-19 only; `stills 12.3,40.1 --safe --no-sheet` for exact times). Check every shot: the subject whole and readable, the screen direction right, the caption not on the face or the note's key line, the pills 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.4 s a frame on an ordinary GPU: ~20 minutes for 99 s, ~4 minutes for 20 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. **Never edit scene.js or the engine while it renders** (the page reloads with the new code mid-film). After a fix, `redo.mjs my-video 13.5-15.1` deletes only those frames; `full` again renders just them.

## 10. Sound

The bed: `assets/music/subway-drone.wav` (a 60-second seamless loop: a dark room tone, a 50 Hz mains hum with a slow swell, a faint tube hiss), looped under the whole film and ducked by the voice, `music.db −8.5` ≈ 13 LU under it. `cues.mjs` puts the kit on the picture's events, `ev(name, t, dB, maxDuration?)`: **slam** (the gate), **step** (each footfall), **rumble** (a train in the tunnel, 4 s rising), **whoosh** (the nose passing), **clunk** (each light dying), **door** (sliding doors), **hit** (a low boom under a reveal), **tick** (each pill), **breath** (on his neck), **buzz** (a tube flickering). Keep loud hits off key words. `mix.mjs` lands the mix at −14 LUFS, true peak ≤ −1 dBTP; `STEM=vo|music|sfx node engine/mix.mjs my-video` measures one stem.

## 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 that asks the question ("1 or 2? Tell me in the comments").

## 12. Checklist

- [ ] The script follows section 4's shape, second person, slow, every sentence showable; `say` covers every symbol.
- [ ] Fake timings → every shot's still checked before the voice.
- [ ] One narration (ElevenLabs, edge-tts or a recording) → `align.mjs` (Whisper matched every word, or nearly).
- [ ] Every shot's still checked again on the real timings; screen directions right; no `OUTSIDE SAFE ZONE`, no page errors.
- [ ] Full render: ✓ text inside the safe zone on every frame.
- [ ] Mix −14 LUFS, TP ≤ −1, the bed ~13 LU under the voice; encode ≤ 60 MB; `check.mjs` all ✓.

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

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

### `engine/film.html`

```html
<!doctype html>
<html><head><meta charset="utf-8"><title>mannequin</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: the caption font (Inter Tight ExtraBold) → the video's film.json + scene.js → the kit (station, cast, train),
// lights, post, overlay → window.setT(t). Engine file. The video folder comes from film.html?video=<folder> ('.' = this folder).
// Every frame is a pure function of film time t (30 fps), so any frame can be rendered alone, in any order.
import * as THREE from 'three';
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 MQ from './mq.js';
import * as ST from './station.js';
import * as TR from './train.js';
import * as WET from './wet.js';
import * as TX from './tex.js';
import { makeKit } from './kit.js';
import { placeCamera, minDistance } from './camera.js';
import { makeLights } from './sublook.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);

// Inter Tight ExtraBold is required (SIL OFL, google/fonts); Consolas Bold (Windows) only labels the --safe overlay
const FONTS = [['Inter Tight', ['InterTight-800.ttf', 'InterTight-800.woff2'], '800', true], ['Consolas Bold', ['consolab.ttf'], '400', false]];
for (const [fam, files, weight, need] of FONTS) {
  let ok = false;
  for (const file of files) { try { const f = new FontFace(fam, `url(${new URL('../assets/fonts/' + file, import.meta.url)})`, { weight }); await f.load(); document.fonts.add(f); ok = true; break; } catch { } }
  if (!ok && need) throw new Error(`font ${files.join(' or ')} missing in assets/fonts (node engine/setup.mjs)`);
}
await document.fonts.load('800 30px "Inter Tight"');
const film = await (await fetch(new URL('film.json', base), { cache: 'no-store' })).json();
const mod = await import(new URL('scene.js', base).href);
const END = film.END;
window.TOTAL = END; window.film = film;

const renderer = new THREE.WebGLRenderer({ antialias: false, preserveDrawingBuffer: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(1); renderer.setSize(W, H); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.NoToneMapping; renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);
const camera = new THREE.PerspectiveCamera(35, W / H, 0.03, 300);
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, MQ, ST, TR, WET, TX, LT, STEP, minDistance, V: (x, y, z) => new THREE.Vector3(x, y, z) };
E.K = makeKit(E);
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.SHOTLIST = V.SHOTS.map(s => s.t);   // render.mjs stills shots: one still in the middle of every shot

window.framing = {};
function setT(t) {
  t = L.clamp(t, 0, END);
  const tq = Math.floor(t * STEP + 1e-4) / STEP;
  const cam = placeCamera(E, V.SHOTS, V.IMPULSE || [], t);
  if (cam.clamped) window.framing[cam.shot] = Math.max(window.framing[cam.shot] || 0, +cam.clamped.toFixed(3));
  V.world?.(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/kit.js`

```js
// The Mannequin kit: everything a video shares, built once per page. Engine file — a video never edits it.
//   const K = makeKit(E)   → the world (station, booth, train), the cast (you = white, it = black), props, light and motion helpers.
// The station (station.js): a platform along z (tunnel south at z −46, exit stairs + a north tunnel at z 28), the track at x < −2.2,
// the platform wall at x 3.3. The booth is its own little set at ST.BOOTH (x 60). The train runs toward −z (noseAt(z)).
// Every frame: K.reset() (in scene.js world()) puts everything back, then the current shot's run(t) poses what it needs.
import * as THREE from 'three';
import * as MQ from './mq.js';
import * as ST from './station.js';
import * as TR from './train.js';
import * as WET from './wet.js';
import * as TX from './tex.js';
import { seg, lerp, smooth } from './lib.js';

export function makeKit(E) {
  const { scene, camera, LT } = E;
  const V3 = (x, y, z) => new THREE.Vector3(x, y, z);
  const S = ST.S, B = ST.BOOTH;
  const std = o => new THREE.MeshStandardMaterial(o);
  const box = (w, h, d, m) => new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
  // ---- the world ----
  const WF = WET.makeWet(E, { y: 0, size: [200, 200], center: [20, 0] });
  const wet = { mat: o => WET.wetMat(o, WF), hide: WF.hide };
  const st = ST.makeStation(E, { wet });
  const bo = ST.makeBooth(E, { wet });
  const tr = TR.makeTrain(E, { cars: 3 });
  const you = MQ.makeMannequin(); scene.add(you.root);
  const it = MQ.makeMannequin({ dark: true }); scene.add(it.root);
  // the phone (in his right hand) and its lock screen ('battery' | 'signal', a time)
  const PS = TX.phoneScreen(); PS.draw('battery', '23:41');
  const mkPhone = () => {
    const g = new THREE.Group();
    g.add(new THREE.Mesh(new THREE.BoxGeometry(0.074, 0.156, 0.009), std({ color: 0x0d0e11, roughness: 0.25, metalness: 0.5 })));
    const scr = new THREE.Mesh(new THREE.PlaneGeometry(0.068, 0.146), new THREE.MeshBasicMaterial({ map: PS.tex, toneMapped: false })); scr.position.z = 0.0047; g.add(scr);
    g.traverse(m => { if (m.isMesh) m.castShadow = true; });
    return g;
  };
  const phone = mkPhone(); scene.add(phone);
  // the close-up hand for phone inserts: palm behind, three fingertips round the left edge, a thumb on the screen
  const CH = new THREE.Group(); scene.add(CH); CH.add(mkPhone());
  { const M = you.mat; const ball = (sx, sy, sz, x, y, z, rz = 0) => { const m = new THREE.Mesh(new THREE.SphereGeometry(1, 40, 30), M); m.scale.set(sx, sy, sz); m.position.set(x, y, z); m.rotation.z = rz; m.castShadow = true; CH.add(m); return m; };
    ball(0.05, 0.085, 0.03, -0.005, -0.05, -0.03);
    for (let i = 0; i < 3; i++) ball(0.014, 0.019, 0.015, -0.04, 0.02 - i * 0.03, 0.0, 0.15);
    ball(0.014, 0.03, 0.014, 0.022, -0.056, 0.011, -0.75);
    ball(0.045, 0.06, 0.04, 0.0, -0.13, -0.03);
  }
  // props a shot may switch on: a tube batten on the north end wall, a chrome pole (for a pole wipe), a floor-pool spot,
  // a poster + teal band inside the booth (seen past the note's torn edge), a red exit light
  const endBatten = new THREE.Group(); { endBatten.add(box(1.3, 0.08, 0.12, std({ color: 0x999999 }))); const tm = std({ color: 0, emissive: 0xf2f6ff, emissiveIntensity: 3.2 }); const tb = box(1.22, 0.05, 0.04, tm); tb.position.set(0, -0.03, -0.05); endBatten.add(tb); endBatten.userData.tm = tm; }
  endBatten.position.set(2.9, 2.95, S.Z1 - 0.08); scene.add(endBatten);
  const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 3, 16), std({ color: 0x9aa0a8, roughness: 0.2, metalness: 0.9 })); scene.add(pole);
  const floorPool = new THREE.SpotLight(0xdfe8ff, 0, 8, 0.55, 0.7, 1.2); floorPool.castShadow = true; floorPool.shadow.mapSize.set(1024, 1024); scene.add(floorPool, floorPool.target);
  { const p = new THREE.Mesh(new THREE.PlaneGeometry(0.62, 0.3), std({ map: TX.poster(1, 'CITY LIGHTS'), roughness: 0.5 })); p.position.set(0.5, 1.08, -0.82); bo.kiosk.add(p); }
  const exitDot = new THREE.Mesh(new THREE.PlaneGeometry(0.28, 0.12), new THREE.MeshBasicMaterial({ color: 0xff2a1a })); exitDot.position.set(1.8, 2.9, 22); exitDot.rotation.y = Math.PI; scene.add(exitDot);

  // ---- light ----
  const FIX = st.fixtures.map(f => ({ g: f, p: [f.position.x, f.position.y, f.position.z] }));
  const BAT = st.battens.map(f => ({ g: f, p: [f.position.x + f.userData.dir * 0.05, f.position.y, f.position.z, 0, 0.05, 1.22, 14] }));
  const near = (list, p, n, ok = () => true) => list.filter(e => ok(e.g)).sort((a, b) => Math.hypot(a.p[0] - p[0], a.p[2] - p[2]) - Math.hypot(b.p[0] - p[0], b.p[2] - p[2])).slice(0, n).map(e => e.p);
  const ON = { hemi: 0.4, fog: 0.026, fogC: 0x76787a, env: 0.3 };
  // the station lit: area lights on the 8 ceiling tubes + 4 wall battens nearest p = [x, y, z]; o = LT.set options + ok(fixture) + k
  function lightsOn(p, o = {}) { const ok = o.ok || (() => true); LT.tubes([...near(FIX, p, 8, ok), ...near(BAT, p, 4, ok)], o.k ?? 1); LT.set({ ...ON, ...o }); }
  // after midnight: every tube dead, a faint cold-blue ambient, dark haze; the NEXT TRAIN board dimmed
  function night(o = {}) {
    st.tubesOn(() => 0); endBatten.userData.tm.emissiveIntensity = 0;
    LT.tubes([]); LT.set({ hemi: 0.1, hemiC: 0x6f86b4, hemiG: 0x05070c, fog: 0.04, fogC: 0x030508, env: 0.05, ...o });
    st.stairLight.intensity = 0; bo.inLight.intensity = 0; bo.tube.children[1].material.emissiveIntensity = 0;
    st.nextBoard.face.material.color.setScalar(0.6);
  }
  // a cold "moonlight" key spot for night shots: from p at target tg
  const moon = (p, tg, i = 3.5, a = 0.55) => ({ p, t: tg, i, c: 0xa8bce6, a, pen: 0.9 });
  // the booth set lit by its own tube
  function boothLights(o = {}) { LT.tubes([[B.x + 0.8, 3.6, B.z + 0.6, 0.6, 0.14, 1.5, 22]], 1); LT.set({ hemi: 0.28, fog: 0.022, fogC: 0x47484a, env: 0.22, key: { p: [B.x + 0.6, 3.6, B.z + 1.4], t: [B.x, 0.5, B.z - 0.8], i: 16 }, ...o }); }
  // ---- the train ----
  const CARLEN = tr.cars * TR.CAR.L + (tr.cars - 1) * TR.CAR.GAP + 0.4;   // nose to tail
  function trainAt(nose, lights = { inside: 1, rTail: 1 }, doors = 0) { tr.G.visible = true; tr.G.position.x = S.XT; tr.noseAt(nose); tr.lights(lights); tr.doors(doors); }
  // area lights along the cars' ceilings (they also spill out of the doors and windows): the lit train at night
  const carLights = (nose, k = 1) => LT.tubes([0, 1, 2].flatMap(c => [-4, 4].flatMap(dz => [-0.55, 0.55].map(dx => [S.XT + dx, 2.62, nose + 8.2 + c * 16.6 + dz, 0, 0.16, 3.2, 34 * k]))), 1);
  // the nose position that stops car 2's front door at world z (the doors of car c: nose + 8.2 + c·16.6 ± 4.6)
  const stopFor = doorZ => doorZ - 20.2;
  // ---- motion ----
  // walking from A [x, z] to B between t0 and t1: position, facing and a walk phase from the distance covered
  function walkPath(t, t0, t1, A, Bp, ease = smooth) {
    const k = ease(seg(t, t0, t1)), x = lerp(A[0], Bp[0], k), z = lerp(A[1], Bp[1], k);
    const d = Math.hypot(Bp[0] - A[0], Bp[1] - A[1]) * k, yaw = Math.atan2(Bp[0] - A[0], Bp[1] - A[1]);
    return { x, z, yaw, ph: d / 1.3 * Math.PI * 2 };
  }
  const walkPose = (w, k = 1, extra = {}) => MQ.merge(MQ.merge({ x: w.x, z: w.z, yaw: w.yaw }, MQ.walk(w.ph, k)), extra);
  const STAND = { armL: { o: 0.04, e: 0.12 }, armR: { o: 0.04, e: 0.12 } };
  // world point of a hand (for props and the choice dots); screen pixels of a world point
  const hand = (P, side, off = [0, -0.075, 0.03]) => { P.root.updateMatrixWorld(true); return P.arms[side].hand.localToWorld(V3(...off)); };
  const proj = p => { camera.updateMatrixWorld(); const v = (p.isVector3 ? p : V3(...p)).clone().project(camera); return [(v.x + 1) / 2 * 1080, (1 - v.y) / 2 * 1920]; };
  // the phone in his right hand: up = 0 flat at the hip, 1 beside his head
  const phonePose = up => ({ armL: { o: 0.04, e: 0.12 }, armR: { f: lerp(0.25, 0.5, up), o: lerp(0.06, 0.3, up), e: lerp(0.55, 2.35, up), tw: lerp(0, 0.6, up), wr: [lerp(-1.3, -0.2, up), 0, lerp(0, 0.3, up)] } });
  function holdPhone(up) { phone.visible = true; const h = you.arms.R.hand; you.root.updateMatrixWorld(true); phone.position.copy(h.localToWorld(V3(0, -0.075, 0.035))); phone.quaternion.copy(h.getWorldQuaternion(new THREE.Quaternion())); phone.rotateX(-Math.PI / 2 * (1 - up)); phone.rotateY(up * 0.4); }

  // ---- every frame, before the shot ----
  function reset() {
    you.root.visible = false; it.root.visible = false; phone.visible = false; CH.visible = false; tr.G.visible = false; pole.visible = false; floorPool.intensity = 0;
    it.eyes.forEach(e => { e.scale.set(1, 1.2, 0.45); e.visible = true; }); it.mat.envMapIntensity = 0.8;
    st.gate(0); st.tubesOn(() => 1); endBatten.userData.tm.emissiveIntensity = 3.2; st.stairLight.intensity = 6; bo.inLight.intensity = 2.2; bo.tube.children[1].material.emissiveIntensity = 3.2;
    st.nextBoard.face.material.color.setScalar(1); st.nextBoard.G.visible = true; st.nextBoard2.position.set(0.7, 3.15, -30);
    st.clock.board.draw(['23:59'], { scale: 2 }); bo.note.tex.draw(0);
    st.seats.forEach((g, i) => { g.visible = true; g.position.z = [-6.2, 8.5, -19][i]; }); st.posters[4].position.z = 14;
    PS.draw('battery', '23:41');
  }
  return { S, B, st, bo, tr, you, it, phone, PS, CH, endBatten, pole, floorPool, exitDot, wet, FIX, BAT, near, ON, lightsOn, night, moon, boothLights,
    CARLEN, trainAt, carLights, stopFor, walkPath, walkPose, STAND, hand, proj, phonePose, holdPhone, reset, V3 };
}
```

### `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/mq.js`

```js
// The mannequin: a glossy wooden artist's mannequin (ball joints, tapered limbs, mitten hands, a big smooth sphere head)
// with a painted cartoon face (black oval or ring eyes, thick brushy brows, a line mouth). A white one ("you") and a black
// one (no face, two glowing white eye dots). Engine file.
//   const P = makeMannequin({ dark: false })   scene.add(P.root)
//   pose(P, { x, y, z, yaw, lift, spine:[rx,ry,rz], chest:[..], head:[rx,ry,rz], armL:{ f, o, tw, e, wr:[..], hand }, armR, legL:{ f, o, k, a }, legR, face:{…} })
//   walk(t, phase, speed) → the pose bits of a walk cycle (merge into a pose)
// The figure faces +z, its left side is +x, feet at the root's origin. Height ≈ 1.78.
import * as THREE from 'three';

const V3 = (x, y, z) => new THREE.Vector3(x, y, z);
export const MQ = { headR: 0.14 };

// ---------------- materials ----------------
export function mqMat(dark) {
  return dark
    ? new THREE.MeshPhysicalMaterial({ color: 0x08080a, roughness: 0.4, metalness: 0, clearcoat: 0.7, clearcoatRoughness: 0.15, envMapIntensity: 0.8 })
    : new THREE.MeshPhysicalMaterial({ color: 0xc4c4c0, roughness: 0.3, metalness: 0, clearcoat: 0.6, clearcoatRoughness: 0.18 });
}

// a lathe solid from a profile [[r, y], ...] (y up), smooth
function lathe(pts, seg = 40) { const g = new THREE.LatheGeometry(pts.map(([r, y]) => new THREE.Vector2(Math.max(r, 0.0001), y)), seg); g.computeVertexNormals(); return g; }
// a tapered rounded limb from y=0 down to y=-len, radius r0 at the top, r1 at the bottom
function limb(len, r0, r1) {
  const pts = [];
  for (let i = 0; i <= 10; i++) { const a = i / 10 * Math.PI / 2; pts.push([r0 * Math.sin(a), -r0 * 0.35 * Math.cos(a)]); }
  const N = 14; for (let i = 1; i < N; i++) { const k = i / N; const bulge = Math.sin(k * Math.PI) * 0.12; pts.push([(r0 + (r1 - r0) * k) * (1 + bulge), -len * k]); }
  for (let i = 10; i >= 0; i--) { const a = i / 10 * Math.PI / 2; pts.push([r1 * Math.sin(a), -len - r1 * 0.35 * Math.cos(a)]); }
  return lathe(pts.reverse(), 32);
}
const ball = (r, m) => new THREE.Mesh(new THREE.SphereGeometry(r, 32, 24), m);

// ---------------- the painted face ----------------
// A patch of the head sphere: PHI rad around the front, THETA rad from above the brows to the chin. 384 px per radian.
const FW = 768, FH = 640, PHI = 2.0, THETA = 1.66, TH0 = Math.PI / 2 - 0.92, PX = FW / PHI;
function facePatch(r) { const g = new THREE.SphereGeometry(r * 1.004, 72, 60, Math.PI / 2 - PHI / 2, PHI, TH0, THETA); return g; }
// brushy thick stroke along a quadratic curve
function brush(g, x0, y0, cx, cy, x1, y1, w, ink, seed = 1) {
  g.save(); g.lineCap = 'round'; g.strokeStyle = ink; g.lineWidth = w;
  g.beginPath(); g.moveTo(x0, y0); g.quadraticCurveTo(cx, cy, x1, y1); g.stroke();
  // bristle streaks lighter on the top edge (the reference's dry-brush brows)
  g.globalCompositeOperation = 'destination-out'; g.lineWidth = 1.6;
  let s = seed * 9301;
  for (let i = 0; i < 9; i++) {
    s = (s * 9301 + 49297) % 233280; const u = s / 233280; const off = (u - 0.5) * w * 0.9; const a0 = 0.05 + ((s >> 3) % 7) / 40, a1 = 0.95 - ((s >> 5) % 7) / 40;
    g.globalAlpha = 0.25 + u * 0.3; g.beginPath();
    for (let k = 0; k <= 12; k++) { const q = a0 + (a1 - a0) * k / 12; const x = (1 - q) * (1 - q) * x0 + 2 * (1 - q) * q * cx + q * q * x1, y = (1 - q) * (1 - q) * y0 + 2 * (1 - q) * q * cy + q * q * y1; k ? g.lineTo(x, y + off) : g.moveTo(x, y + off); }
    g.stroke();
  }
  g.restore();
}
// st: { eyes: 'dot'|'ring'|'small'|'wide'|'shut'|'squint', gaze:[x,y], brow:'flat'|'raise'|'angry'|'sad'|'worried'|'skeptic'|'up', mouth:'flat'|'smile'|'o'|'O'|'wave'|'frown'|'none'|'grin', mouthS (scale), blink 0..1 }
function drawFace(g, st) {
  g.clearRect(0, 0, FW, FH);
  const ink = '#0d0d0f', cx = FW / 2, EY = (0.92 - 0.03) * PX, MY = (0.92 + 0.42) * PX;
  const ex = 0.46 * PX, gz = st.gaze || [0, 0], eyes = st.eyes || 'dot', blink = st.blink || 0;
  const gx = gz[0] * 0.07 * PX, gy = -gz[1] * 0.06 * PX;
  for (const s of [-1, 1]) {
    const x = cx + s * ex + gx, y = EY + gy;
    if (eyes === 'shut' || blink > 0.7) { g.strokeStyle = ink; g.lineWidth = 9; g.lineCap = 'round'; g.beginPath(); g.moveTo(x - 26, y + 4); g.quadraticCurveTo(x, y + 14, x + 26, y + 4); g.stroke(); continue; }
    if (eyes === 'ring' || eyes === 'ringS') {   // scared: an outlined circle, a small pupil dot
      const R = (eyes === 'ringS' ? 0.12 : 0.15) * PX * (st.eyeK || 1);
      g.strokeStyle = ink; g.lineWidth = R * 0.2; g.beginPath(); g.ellipse(cx + s * ex, EY, R, R * (1 - blink), 0, 0, 7); g.stroke();
      g.fillStyle = ink; g.beginPath(); g.arc(cx + s * ex + gx * 0.8, EY + gy * 0.8, R * 0.24, 0, 7); g.fill();
      continue;
    }
    const k = eyes === 'small' ? 0.62 : eyes === 'wide' ? 1.15 : 1;
    const rx = 0.082 * PX * k, ry = 0.11 * PX * k * (1 - blink) * (eyes === 'squint' ? 0.55 : 1);
    g.fillStyle = ink; g.beginPath(); g.ellipse(x, y, rx, Math.max(ry, 2), 0, 0, 7); g.fill();
    if (ry > rx * 0.5) { g.fillStyle = 'rgba(255,255,255,0.96)'; g.beginPath(); g.ellipse(x + rx * 0.32, y - ry * 0.38, rx * 0.3, rx * 0.3, 0, 0, 7); g.fill(); }
  }
  // brows: [inner y offset, outer y offset] per kind, in px (negative = up)
  const B = { flat: [0, 0], raise: [-26, -26], up: [-36, -30], angry: [22, -18], sad: [-22, 12], worried: [-30, 6], skeptic: null, none: null }[st.brow || 'flat'];
  const by = EY - 0.25 * PX + (st.browY || 0), bw = 0.34 * PX;
  if (st.brow === 'skeptic') {   // one raised arched brow, the other low and flat
    brush(g, cx - ex - bw * 0.5, by + 10, cx - ex, by + 2, cx - ex + bw * 0.5, by + 10, 30, ink, 2);
    brush(g, cx + ex - bw * 0.5, by - 14, cx + ex, by - 46, cx + ex + bw * 0.5, by - 18, 30, ink, 3);
  } else if (B) {
    for (const s of [-1, 1]) {
      const xi = cx + s * (ex - bw * 0.5), xo = cx + s * (ex + bw * 0.5);
      const yi = by + B[0], yo = by + B[1];
      brush(g, xi, yi, (xi + xo) / 2, (yi + yo) / 2 - 10, xo, yo, 30, ink, s + 3);
    }
  }
  // mouth
  const m = st.mouth || 'flat', ms = st.mouthS || 1, mw = 0.16 * PX * ms;
  g.strokeStyle = ink; g.fillStyle = ink; g.lineCap = 'round'; g.lineJoin = 'round';
  if (m === 'flat') { g.lineWidth = 14; g.beginPath(); g.moveTo(cx - mw * 0.55, MY); g.lineTo(cx + mw * 0.55, MY); g.stroke(); }
  else if (m === 'smile' || m === 'grin') { g.lineWidth = 11; g.beginPath(); g.moveTo(cx - mw, MY - 10); g.quadraticCurveTo(cx, MY + (m === 'grin' ? 46 : 32), cx + mw, MY - 10); g.stroke(); }
  else if (m === 'smirk') { g.lineWidth = 11; g.beginPath(); g.moveTo(cx - mw * 0.6, MY + 4); g.quadraticCurveTo(cx + mw * 0.2, MY + 16, cx + mw * 0.8, MY - 10); g.stroke(); }
  else if (m === 'frown') { g.lineWidth = 11; g.beginPath(); g.moveTo(cx - mw * 0.7, MY + 10); g.quadraticCurveTo(cx, MY - 16, cx + mw * 0.7, MY + 10); g.stroke(); }
  else if (m === 'wave') { g.lineWidth = 8; g.beginPath(); const w = mw * 0.75; for (let i = 0; i <= 24; i++) { const u = i / 24; const x = cx - w + 2 * w * u, y = MY + Math.sin(u * Math.PI * 3) * 7; i ? g.lineTo(x, y) : g.moveTo(x, y); } g.stroke(); }
  else if (m === 'o') { g.beginPath(); g.ellipse(cx, MY, 0.045 * PX * ms, 0.058 * PX * ms, 0, 0, 7); g.fill(); }
  else if (m === 'O') { g.beginPath(); g.ellipse(cx, MY + 6, 0.085 * PX * ms, 0.105 * PX * ms, 0, 0, 7); g.fill(); }
}
function makeFace(head, r) {
  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.MeshPhysicalMaterial({ map: tex, transparent: true, roughness: 0.3, clearcoat: 0.6, clearcoatRoughness: 0.18, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -4 });
  const patch = new THREE.Mesh(facePatch(r), 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, st); tex.needsUpdate = true; } };
}

// ---------------- the body ----------------
export function makeMannequin(o = {}) {
  const dark = !!o.dark, M = mqMat(dark), J = dark ? mqMat(true) : M;
  const root = new THREE.Group(), body = new THREE.Group(); root.add(body);
  const add = (p, m, x = 0, y = 0, z = 0) => { m.position.set(x, y, z); p.add(m); return m; };
  // pelvis: wide round hips that taper to the crotch (an upside-down bulb), centre y 0.94
  const hips = new THREE.Group(); hips.position.set(0, 0.94, 0); body.add(hips);
  const pel = add(hips, new THREE.Mesh(lathe([[0, -0.115], [0.05, -0.112], [0.1, -0.09], [0.14, -0.045], [0.158, 0.0], [0.152, 0.04], [0.13, 0.075], [0.105, 0.098], [0.06, 0.108], [0, 0.11]]), M));
  pel.scale.set(1, 1, 0.86);
  // waist: a short rounded ring segment
  const spine = new THREE.Group(); spine.position.set(0, 0.105, 0); hips.add(spine);
  const wst = add(spine, new THREE.Mesh(lathe([[0, -0.03], [0.07, -0.03], [0.094, -0.02], [0.1, 0.0], [0.094, 0.025], [0.07, 0.036], [0, 0.036]]), J), 0, 0.02, 0); wst.scale.set(1, 1, 0.78);
  const chest = new THREE.Group(); chest.position.set(0, 0.06, 0); spine.add(chest);
  // chest: a big rounded shield, wide at the shoulders, narrow at the waist
  const tor = add(chest, new THREE.Mesh(lathe([[0, 0], [0.06, 0.004], [0.095, 0.025], [0.122, 0.07], [0.15, 0.14], [0.175, 0.215], [0.188, 0.27], [0.18, 0.31], [0.15, 0.338], [0.09, 0.352], [0, 0.356]]), M));
  tor.scale.set(1, 1, 0.6);
  // neck + head
  const neck = new THREE.Group(); neck.position.set(0, 0.345, 0); chest.add(neck);
  add(neck, new THREE.Mesh(new THREE.CylinderGeometry(0.033, 0.036, 0.13, 24), J), 0, 0.055, 0);
  const head = new THREE.Group(); head.position.set(0, 0.105, 0); neck.add(head);
  const R = MQ.headR;
  const skull = add(head, ball(R, M), 0, R * 0.9, 0); skull.geometry = new THREE.SphereGeometry(R, 64, 48);
  const headC = new THREE.Group(); headC.position.set(0, R * 0.9, 0); head.add(headC);
  let face = null, eyes = null;
  if (!dark) face = makeFace(headC, R);
  else {   // two glowing dots
    const em = new THREE.MeshBasicMaterial({ color: 0xffffff }); eyes = [];
    for (const s of [-1, 1]) { const e = new THREE.Mesh(new THREE.SphereGeometry(0.012, 16, 12), em); const a = s * 0.3, b = 0.06; e.position.set(Math.sin(a) * Math.cos(b) * R * 0.995, Math.sin(b) * R * 0.995, Math.cos(a) * Math.cos(b) * R * 0.995); e.scale.set(1, 1.2, 0.45); e.lookAt(e.position.clone().multiplyScalar(2)); headC.add(e); eyes.push(e); }
  }
  // arms: long and slim, a ring at the elbow, big mitten hands
  const arm = s => {
    const sh = new THREE.Group(); sh.position.set(s * 0.188, 0.288, 0); chest.add(sh);
    add(sh, ball(0.056, J));
    const up = new THREE.Group(); sh.add(up); add(up, new THREE.Mesh(limb(0.27, 0.043, 0.034), M), 0, -0.035, 0);
    const el = new THREE.Group(); el.position.set(0, -0.33, 0); up.add(el); add(el, ball(0.036, J));
    add(el, new THREE.Mesh(limb(0.235, 0.035, 0.027), M), 0, -0.028, 0);
    const wr = new THREE.Group(); wr.position.set(0, -0.285, 0); el.add(wr); add(wr, ball(0.025, J));
    const hand = new THREE.Group(); hand.position.set(0, -0.02, 0); wr.add(hand);
    const palm = add(hand, ball(1, M), 0, -0.068, 0); palm.scale.set(0.026, 0.074, 0.046);
    const th = add(hand, ball(1, M), 0, -0.05, 0.04); th.scale.set(0.018, 0.042, 0.018); th.rotation.x = -0.5;
    return { sh, up, el, wr, hand, th };
  };
  const leg = s => {
    const hp = new THREE.Group(); hp.position.set(s * 0.082, -0.05, 0); hips.add(hp);
    add(hp, new THREE.Mesh(limb(0.42, 0.066, 0.047), M), 0, 0.02, 0);
    const kn = new THREE.Group(); kn.position.set(0, -0.44, 0); hp.add(kn); add(kn, ball(0.047, J));
    add(kn, new THREE.Mesh(limb(0.36, 0.047, 0.032), M), 0, -0.035, 0);
    const an = new THREE.Group(); an.position.set(0, -0.41, 0); kn.add(an); add(an, ball(0.03, J));
    const foot = add(an, ball(1, M), 0, -0.012, 0.035); foot.scale.set(0.042, 0.03, 0.09);
    return { hp, kn, an };
  };
  const A = { L: arm(1), R: arm(-1) }, G = { L: leg(1), R: leg(-1) };
  root.traverse(m => { if (m.isMesh) { m.castShadow = true; m.receiveShadow = true; } });
  if (face) { face.patch.castShadow = false; }
  if (eyes) eyes.forEach(e => { e.castShadow = false; });
  const P = { root, body, hips, spine, chest, neck, head, headC, arms: A, legs: G, face, eyes, dark, mat: M };
  return P;
}

// ---------------- posing ----------------
const E = (o, v) => { if (v) o.rotation.set(v[0] || 0, v[1] || 0, v[2] || 0); else o.rotation.set(0, 0, 0); };
// arm: f = raise forward (rad), o = outward, tw = twist about the arm, e = elbow bend (hand forward/up), wr = wrist [rx,ry,rz]
function poseArm(a, s, p = {}) {
  a.sh.rotation.order = 'ZXY';
  a.sh.rotation.set(-(p.f || 0), s * (p.tw || 0), s * (0.06 + (p.o || 0)));
  E(a.up, null); a.up.rotation.y = s * (p.up || 0);
  a.el.rotation.set(-(p.e ?? 0.12), s * (p.etw || 0), 0);
  E(a.wr, p.wr); a.hand.rotation.set(0, 0, 0);
  a.th.rotation.x = -0.5 - (p.grip || 0) * 0.6;
}
function poseLeg(l, s, p = {}) {
  l.hp.rotation.order = 'ZXY';
  l.hp.rotation.set(-(p.f || 0), s * (p.tw || 0), s * (p.o || 0));
  l.kn.rotation.set(p.k || 0, 0, 0);
  l.an.rotation.set(p.a || 0, 0, 0);
}
export function pose(P, s = {}) {
  P.root.position.set(s.x || 0, s.y || 0, s.z || 0); P.root.rotation.set(0, s.yaw || 0, 0);
  P.root.visible = s.visible !== false;
  P.hips.position.set(s.hx || 0, 0.94 + (s.lift || 0), s.hz || 0);
  P.hips.rotation.set(s.hipR?.[0] || 0, s.hipR?.[1] || 0, s.hipR?.[2] || 0);
  E(P.spine, s.spine); E(P.chest, s.chest); E(P.neck, s.neck); E(P.head, s.head);
  poseArm(P.arms.L, 1, s.armL); poseArm(P.arms.R, -1, s.armR);
  poseLeg(P.legs.L, 1, s.legL); poseLeg(P.legs.R, -1, s.legR);
  if (P.face) P.face.set({ eyes: 'dot', brow: 'flat', mouth: 'flat', gaze: [0, 0], ...(s.face || {}) });
  if (P.eyes) P.eyes.forEach(e => { e.visible = s.eyesOn !== false; });
}
// a walk cycle at phase ph (radians); k = stride 0..1. Returns pose parts to merge.
export function walk(ph, k = 1) {
  const sL = Math.sin(ph), sR = Math.sin(ph + Math.PI);
  const kneeL = Math.max(0, Math.sin(ph - 1.2)) * 0.95 + 0.06, kneeR = Math.max(0, Math.sin(ph + Math.PI - 1.2)) * 0.95 + 0.06;
  return {
    lift: (-0.018 + Math.abs(Math.cos(ph)) * 0.022) * k,
    hipR: [0, sL * 0.07 * k, 0],
    chest: [0.03, -sL * 0.08 * k, 0],
    legL: { f: sL * 0.42 * k, k: kneeL * k, a: -Math.min(0, sL) * 0.3 * k },
    legR: { f: sR * 0.42 * k, k: kneeR * k, a: -Math.min(0, sR) * 0.3 * k },
    armL: { f: sR * 0.32 * k, o: 0.05, e: 0.2 + Math.max(0, sR) * 0.25 * k },
    armR: { f: sL * 0.32 * k, o: 0.05, e: 0.2 + Math.max(0, sL) * 0.25 * k },
  };
}
// sitting on a seat at height h (seat top) — pelvis drops so the thighs rest on it
export const SIT = (h = 0.45) => ({ lift: h - 0.94 + 0.11, legL: { f: 1.5, k: 1.45, a: 0.05, o: 0.06 }, legR: { f: 1.5, k: 1.45, a: 0.05, o: 0.06 } });
export function merge(a, b) { const o = { ...a }; for (const k in b) o[k] = (b[k] && typeof b[k] === 'object' && !Array.isArray(b[k]) && a[k] && typeof a[k] === 'object' && !Array.isArray(a[k])) ? { ...a[k], ...b[k] } : b[k]; return o; }
// world position of a hand (for props held in it)
export function handWorld(P, side = 'R', off = [0, -0.07, 0.03]) { P.root.updateMatrixWorld(true); return P.arms[side].hand.localToWorld(V3(...off)); }
```

### `engine/station.js`

```js
// Ashford Road: a London-style deep-level platform. White brick tiles with a teal band, blue station signs, posters,
// red bucket seats, a dark ceiling with fluorescent fixtures, a dot-matrix NEXT TRAIN board, a wet platform with a yellow
// line, the track bed with rails, a tunnel mouth at the south end, the exit stairs with a folding gate at the north end,
// a dot-matrix wall clock, and (off to the side, its own little set) the driver's booth with the taped note. Engine file.
// Axes: the platform runs along z (tunnel at −z, exit at +z); x < EDGE is the track; the platform wall is at x = WALL.
import * as THREE from 'three';
import * as T from './tex.js';

export const S = { EDGE: -2.2, WALL: 3.3, Z0: -46, Z1: 28, CEIL: 4.3, BED: -1.15, XT: -3.9, FAR: -6.4, RAIL: -0.98 };
export const BOOTH = new THREE.Vector3(60, 0, 0);   // the booth set's origin

function uvScale(g, su, sv) { const uv = g.attributes.uv; for (let i = 0; i < uv.count; i++) uv.setXY(i, uv.getX(i) * su, uv.getY(i) * sv); uv.needsUpdate = true; return g; }
// a wall plane w × h, facing +z in its own frame, tiled with a texture that covers tw × th metres
function wall(w, h, mat, tw = 2, th = 1) { return new THREE.Mesh(uvScale(new THREE.PlaneGeometry(w, h), w / tw, h / th), mat); }
const box = (w, h, d, m) => new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
const std = (o) => new THREE.MeshStandardMaterial(o);

export function makeStation(E, { wet } = {}) {
  const G = new THREE.Group(); E.scene.add(G);
  const TL = T.tiles({ seed: 3, base: '#d6d4cc' }), TL2 = T.tiles({ seed: 8, dirty: 0.9, base: '#cbc8be' });
  const tileM = std({ map: TL.map, bumpMap: TL.bump, bumpScale: 1.4, roughnessMap: TL.rough, roughness: 0.55, envMapIntensity: 0.5 });
  const tileM2 = std({ map: TL2.map, bumpMap: TL2.bump, bumpScale: 1.4, roughnessMap: TL2.rough, roughness: 0.6, envMapIntensity: 0.4 });
  const teal = std({ color: 0x0f5e5c, roughness: 0.35, envMapIntensity: 0.6 });
  const dark = std({ color: 0x1b1b1c, roughness: 0.9 });
  const ceilT = T.rough('#2a2a2a', 9); ceilT.repeat.set(8, 30); ceilT.wrapS = ceilT.wrapT = THREE.RepeatWrapping;
  const ceilM = std({ map: ceilT, roughness: 0.95 });
  const L = S.Z1 - S.Z0, ZC = (S.Z0 + S.Z1) / 2;
  const out = { G, mats: { tileM, tileM2, teal, dark, ceilM }, emiss: [], fixtures: [], wetFloors: [] };

  // ---- platform floor (wet) ----
  const CON = T.concrete({ seed: 5, base: '#4e4e4c' });
  const floorM = wet.mat({ map: CON.map, wet: CON.wet, roughness: 0.6, env: 0.06, strength: 0.55, damp: 0.1 });
  const pw = S.WALL - S.EDGE;
  const floor = new THREE.Mesh(uvScale(new THREE.PlaneGeometry(pw, L), pw / CON.S, L / CON.S), floorM);
  floor.rotation.x = -Math.PI / 2; floor.position.set((S.EDGE + S.WALL) / 2, 0, ZC); floor.receiveShadow = true; G.add(floor); wet.hide.push(floor); out.floor = floor;
  // edge: a white strip, then the yellow line
  const edgeM = std({ color: 0xcfcfc8, roughness: 0.5 }), yel = std({ color: 0xd7a31c, roughness: 0.45, emissive: 0x000000 });
  const strip = box(0.32, 0.05, L, edgeM); strip.position.set(S.EDGE + 0.16, -0.02, ZC); strip.receiveShadow = true; G.add(strip);
  const yl = new THREE.Mesh(new THREE.PlaneGeometry(0.11, L), yel); yl.rotation.x = -Math.PI / 2; yl.position.set(S.EDGE + 0.62, 0.003, ZC); yl.receiveShadow = true; G.add(yl);
  // platform face down to the bed
  const face = wall(L, -S.BED, tileM2, 2, 1); face.rotation.y = -Math.PI / 2; face.position.set(S.EDGE, S.BED / 2, ZC); G.add(face);
  // ---- track bed, sleepers, rails ----
  const bedT = T.rough('#262523', 21); bedT.repeat.set(3, 50); bedT.wrapS = bedT.wrapT = THREE.RepeatWrapping;
  const bed = new THREE.Mesh(new THREE.PlaneGeometry(S.EDGE - S.FAR, L + 200), std({ map: bedT, roughness: 0.95 })); bed.rotation.x = -Math.PI / 2; bed.position.set((S.EDGE + S.FAR) / 2, S.BED, ZC - 100); bed.receiveShadow = true; G.add(bed);
  const slM = std({ color: 0x3a3632, roughness: 0.85 });
  const NS = Math.floor((L + 200) / 0.65), sl = new THREE.InstancedMesh(new THREE.BoxGeometry(2.6, 0.14, 0.24), slM, NS); const m4 = new THREE.Matrix4();
  for (let i = 0; i < NS; i++) { m4.makeTranslation(S.XT, S.BED + 0.07, S.Z1 - i * 0.65); sl.setMatrixAt(i, m4); } sl.receiveShadow = true; G.add(sl);
  const railM = std({ color: 0x8a8a88, roughness: 0.25, metalness: 0.9 });
  for (const s of [-1, 1]) { const r = box(0.08, 0.16, L + 200, railM); r.position.set(S.XT + s * 0.72, S.BED + 0.22, ZC - 100); G.add(r); }
  // ---- platform wall (x = WALL, facing −x) ----
  const H = S.CEIL;
  const w1 = wall(L, H, tileM, 2, 1); w1.rotation.y = -Math.PI / 2; w1.position.set(S.WALL, H / 2, ZC); w1.receiveShadow = true; G.add(w1);
  const band = box(0.03, 0.27, L, teal); band.position.set(S.WALL - 0.015, 1.18, ZC); G.add(band);
  const band2 = box(0.03, 0.05, L, teal); band2.position.set(S.WALL - 0.015, 2.95, ZC); G.add(band2);
  const skirt = box(0.04, 0.12, L, dark); skirt.position.set(S.WALL - 0.02, 0.06, ZC); G.add(skirt);
  // ---- far wall behind the track ----
  const w2 = wall(L + 4, H - S.BED, tileM2, 2, 1); w2.rotation.y = Math.PI / 2; w2.position.set(S.FAR, (H + S.BED) / 2, ZC); G.add(w2);
  const band3 = box(0.03, 0.27, L, teal); band3.position.set(S.FAR + 0.015, 1.18, ZC); G.add(band3);
  // ---- ceiling + beams ----
  const ceil = new THREE.Mesh(new THREE.PlaneGeometry(S.WALL - S.FAR, L + 4), ceilM); ceil.rotation.x = Math.PI / 2; ceil.position.set((S.WALL + S.FAR) / 2, H, ZC); G.add(ceil);
  const beamM = std({ color: 0x232323, roughness: 0.9 });
  for (let z = S.Z0; z <= S.Z1; z += 9) { const b = box(S.WALL - S.FAR, 0.35, 0.4, beamM); b.position.set((S.WALL + S.FAR) / 2, H - 0.17, z); G.add(b); }
  const lip = box(0.5, 0.5, L, beamM); lip.position.set(S.EDGE - 0.2, H - 0.25, ZC); G.add(lip);
  // ---- fluorescent fixtures (two rows) ----
  const housing = std({ color: 0x9a9a98, roughness: 0.5, metalness: 0.3 });
  const tubeM = new THREE.MeshStandardMaterial({ color: 0x000000, emissive: 0xf2f6ff, emissiveIntensity: 3.2 });
  out.tubeM = tubeM;
  for (const x of [0.55, S.XT]) for (let z = S.Z0 + 2; z <= S.Z1 - 1; z += 4.5) {
    const g = new THREE.Group(); g.position.set(x, H - 0.42, z);
    const hb = box(0.2, 0.07, 1.55, housing); g.add(hb);
    const tm = tubeM.clone(); const tb = box(0.12, 0.03, 1.45, tm); tb.position.y = -0.045; g.add(tb); g.userData.tm = tm;
    for (const s of [-1, 1]) { const rod = box(0.01, 0.38, 0.01, housing); rod.position.set(0, 0.22, s * 0.6); g.add(rod); }
    G.add(g); out.fixtures.push(g);
  }
  // wall battens high on the platform wall (and the far wall), along z
  out.battens = [];
  for (const [x, dir] of [[S.WALL - 0.09, -1], [S.FAR + 0.09, 1]]) for (let z = S.Z0 + 4; z <= S.Z1 - 2; z += 7) {
    const g = new THREE.Group(); g.position.set(x, 3.25, z);
    g.add(box(0.12, 0.08, 1.3, housing)); const tm = tubeM.clone(); const tb = box(0.04, 0.05, 1.22, tm); tb.position.set(dir * 0.05, -0.03, 0); g.add(tb); g.userData.tm = tm; g.userData.dir = dir;
    G.add(g); out.battens.push(g);
  }
  out.allTubes = () => [...out.fixtures, ...out.battens];
  out.tubesOn = (f = () => 1) => { for (const g of out.allTubes()) g.userData.tm.emissiveIntensity = 3.2 * f(g); };
  // ---- signs, posters, seats on the platform wall ----
  const signTex = T.stationSign('Ashford Road');
  const signM = std({ map: signTex, roughness: 0.35, emissive: 0xffffff, emissiveMap: signTex, emissiveIntensity: 0.06 });
  const sign = (x, z, ry, s = 1) => { const m = new THREE.Mesh(new THREE.PlaneGeometry(2.0 * s, 0.5 * s), signM); m.position.set(x, 2.12, z); m.rotation.y = ry; G.add(m); return m; };
  out.signs = [sign(S.WALL - 0.02, -24, -Math.PI / 2), sign(S.WALL - 0.02, -6, -Math.PI / 2), sign(S.WALL - 0.02, 12, -Math.PI / 2), sign(S.FAR + 0.02, -15, Math.PI / 2), sign(S.FAR + 0.02, 3, Math.PI / 2)];
  const frameM = std({ color: 0x1a1a1a, roughness: 0.5 });
  const poster = (x, z, ry, seed, label, pi) => { const p = new THREE.Group(); const f = box(0.86, 1.22, 0.03, frameM); p.add(f); const m = new THREE.Mesh(new THREE.PlaneGeometry(0.78, 1.14), std({ map: T.poster(seed, label, pi), roughness: 0.4 })); m.position.z = 0.02; p.add(m); p.position.set(x, 1.95, z); p.rotation.y = ry; G.add(p); return p; };
  out.posters = [poster(S.WALL - 0.03, -14, -Math.PI / 2, 0, 'RIDE SAFE', 4), poster(S.WALL - 0.03, -10.5, -Math.PI / 2, 1), poster(S.WALL - 0.03, 1.5, -Math.PI / 2, 2), poster(S.WALL - 0.03, 5, -Math.PI / 2, 3),
    poster(S.WALL - 0.03, 18, -Math.PI / 2, 0), poster(S.FAR + 0.03, -8, Math.PI / 2, 1), poster(S.FAR + 0.03, 9, Math.PI / 2, 2), poster(S.FAR + 0.03, -22, Math.PI / 2, 3)];
  out.seatsAt = [];
  const seatM = std({ color: 0xc23a22, roughness: 0.4 }), legM = std({ color: 0x3c3c3c, roughness: 0.4, metalness: 0.6 });
  const seats = (z0, n = 4) => {
    const g = new THREE.Group(); g.position.set(S.WALL - 0.42, 0, z0); g.rotation.y = -Math.PI / 2;
    const beam = box(n * 0.56 + 0.2, 0.06, 0.08, legM); beam.position.set(0, 0.38, 0.05); g.add(beam);
    for (const s of [-1, 1]) { const l = box(0.06, 0.38, 0.4, legM); l.position.set(s * (n * 0.28 + 0.05), 0.19, 0.02); g.add(l); }
    for (let i = 0; i < n; i++) {
      const x = (i - (n - 1) / 2) * 0.56;
      const seat = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.06, 0.44, 1, 1, 1), seatM); seat.position.set(x, 0.45, 0.06); g.add(seat);
      const back = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.5, 0.05), seatM); back.position.set(x, 0.74, -0.15); back.rotation.x = -0.1; g.add(back);
    }
    g.traverse(m => { if (m.isMesh) { m.castShadow = true; m.receiveShadow = true; } });
    G.add(g); out.seatsAt.push(z0); return g;
  };
  out.seats = [seats(-6.2), seats(8.5), seats(-19)];
  // ---- NEXT TRAIN board (hanging, facing +z) ----
  const nb = T.dotBoard({ cols: 64, rows: 20, px: 16, color: [255, 140, 50] }); nb.draw(['NEXT TRAIN', '05:30']);
  const nbG = new THREE.Group(); const nbHouse = box(1.5, 0.5, 0.12, std({ color: 0x2c2c2c, roughness: 0.4, metalness: 0.4 })); nbG.add(nbHouse);
  const nbFace = new THREE.Mesh(new THREE.PlaneGeometry(1.38, 0.43), new THREE.MeshBasicMaterial({ map: nb.tex, toneMapped: false })); nbFace.position.z = 0.062; nbG.add(nbFace);
  const nbBack = nbFace.clone(); nbBack.rotation.y = Math.PI; nbBack.position.z = -0.062; nbG.add(nbBack);
  for (const s of [-1, 1]) { const rod = box(0.02, 0.9, 0.02, housing); rod.position.set(s * 0.5, 0.7, 0); nbG.add(rod); }
  nbG.position.set(0.7, 3.15, -9); G.add(nbG); out.nextBoard = { G: nbG, board: nb, face: nbFace };
  const nb2 = nbG.clone(); nb2.position.set(0.7, 3.15, -30); G.add(nb2); out.nextBoard2 = nb2;
  // ---- the wall clock (red dot matrix) on the platform wall near the exit ----
  const ck = T.dotBoard({ cols: 74, rows: 26, px: 20, color: [255, 55, 40], bg: '#160807' }); ck.draw(['23:59'], { scale: 2 });
  const ckG = new THREE.Group(); ckG.add(box(1.5, 0.6, 0.1, std({ color: 0x1f1f1f, roughness: 0.4, metalness: 0.5 })));
  const ckFace = new THREE.Mesh(new THREE.PlaneGeometry(1.42, 0.5), new THREE.MeshBasicMaterial({ map: ck.tex, toneMapped: false })); ckFace.position.z = 0.052; ckG.add(ckFace);
  ckG.position.set(S.WALL - 0.06, 3.3, 18.6); ckG.rotation.y = -Math.PI / 2; G.add(ckG); out.clock = { G: ckG, board: ck };
  // ---- exit at the north end: end wall with an opening, stairs up, EXIT sign, folding gate ----
  const ex0 = -0.1, ex1 = 2.5, exH = 2.55, zE = S.Z1;
  const endL = wall(ex0 - S.EDGE, H, tileM, 2, 1); endL.rotation.y = Math.PI; endL.position.set((S.EDGE + ex0) / 2, H / 2, zE); G.add(endL);
  const endR = wall(S.WALL - ex1, H, tileM, 2, 1); endR.rotation.y = Math.PI; endR.position.set((ex1 + S.WALL) / 2, H / 2, zE); G.add(endR);
  const endT = wall(ex1 - ex0, H - exH, tileM, 2, 1); endT.rotation.y = Math.PI; endT.position.set((ex0 + ex1) / 2, (H + exH) / 2, zE); G.add(endT);
  for (const [x0, x1] of [[S.EDGE, ex0], [ex1, S.WALL]]) { const b = box(x1 - x0, 0.27, 0.03, teal); b.position.set((x0 + x1) / 2, 1.18, zE - 0.015); G.add(b); }

  // stairwell: side walls, ceiling, steps going up toward +z
  const stairM = std({ color: 0x5d5d5b, roughness: 0.8 }), noseM = std({ color: 0x2a2a2a, roughness: 0.6 });
  for (let i = 0; i < 18; i++) { const st = box(ex1 - ex0, 0.17, 0.3, stairM); st.position.set((ex0 + ex1) / 2, 0.085 + i * 0.17 + 0.0, zE + 1.0 + i * 0.3); st.scale.y = 1; st.position.y = (i + 0.5) * 0.17; const h = (i + 1) * 0.17; st.geometry = new THREE.BoxGeometry(ex1 - ex0, h, 0.3); st.position.y = h / 2; st.receiveShadow = true; st.castShadow = true; G.add(st); const n = box(ex1 - ex0, 0.025, 0.03, noseM); n.position.set((ex0 + ex1) / 2, h + 0.012, zE + 0.86 + i * 0.3); G.add(n); }
  const landing = box(ex1 - ex0, 0.1, 1.0, stairM); landing.position.set((ex0 + ex1) / 2, -0.05, zE + 0.5); G.add(landing);
  for (const x of [ex0, ex1]) { const sw = wall(8, 7, tileM, 2, 1); sw.rotation.y = x === ex0 ? Math.PI / 2 : -Math.PI / 2; sw.position.set(x, 3.0, zE + 4); G.add(sw); }
  const sc = new THREE.Mesh(new THREE.PlaneGeometry(ex1 - ex0, 9), ceilM); sc.rotation.x = Math.PI / 2 + 0.5; sc.position.set((ex0 + ex1) / 2, 4.3, zE + 4); G.add(sc);
  const stairLight = new THREE.PointLight(0xdfe6ff, 6, 9, 1.6); stairLight.position.set((ex0 + ex1) / 2, 3.4, zE + 4.2); G.add(stairLight); out.stairLight = stairLight;
  const exT = T.exitSign(); const exS = new THREE.Mesh(new THREE.PlaneGeometry(0.9, 0.28), new THREE.MeshBasicMaterial({ map: exT, toneMapped: false })); exS.position.set((ex0 + ex1) / 2 - 0.4, exH + 0.45, zE - 0.03); exS.rotation.y = Math.PI; G.add(exS); out.exitSign = exS;
  // folding gate: vertical bars + diagonal braces, slides from x1 toward x0 (open k = 0 .. 1 shut)
  const gateG = new THREE.Group(); const barM = std({ color: 0x151515, roughness: 0.35, metalness: 0.7 });
  const NB = 12, gw = ex1 - ex0; const bars = [];
  for (let i = 0; i < NB; i++) { const b = box(0.035, exH - 0.05, 0.035, barM); b.position.y = exH / 2; gateG.add(b); bars.push(b); }
  const diags = []; for (let i = 0; i < NB - 1; i++) for (const s of [-1, 1]) { const d = box(0.018, 0.9, 0.018, barM); gateG.add(d); diags.push([d, i, s]); }
  const rail = box(gw, 0.05, 0.06, barM); rail.position.set((ex0 + ex1) / 2 - 0, exH - 0.03, 0); gateG.add(rail);
  gateG.position.set(0, 0, zE - 0.06); G.add(gateG);
  gateG.traverse(m => { if (m.isMesh) m.castShadow = true; });
  out.gate = (k) => {   // k: 0 open (folded at the right side), 1 shut
    const span = 0.25 + (gw - 0.25) * k, x1 = ex1 - 0.03, step = span / (NB - 1);
    bars.forEach((b, i) => { b.position.x = x1 - i * step; });
    for (const [d, i, s] of diags) { const xa = x1 - i * step, xb = x1 - (i + 1) * step; const ang = Math.atan2(step, 0.9); d.position.set((xa + xb) / 2, exH / 2 + s * 0.55, 0); d.rotation.z = s * ang; d.scale.y = Math.hypot(step, 0.9) / 0.9; }
  };
  out.gate(0);
  // ---- tunnel at the south end (over the track) ----
  const tunM = std({ map: TL2.map, bumpMap: TL2.bump, bumpScale: 1.2, color: 0x6a6660, roughness: 0.8 });
  const endS = wall(S.WALL - S.EDGE, H, tileM, 2, 1); endS.position.set((S.EDGE + S.WALL) / 2, H / 2, S.Z0); G.add(endS);
  for (const [x0, x1] of [[S.EDGE, S.WALL]]) { const b = box(x1 - x0, 0.27, 0.03, teal); b.position.set((x0 + x1) / 2, 1.18, S.Z0 + 0.015); G.add(b); }
  const R = (S.EDGE - S.FAR) / 2, tcx = (S.EDGE + S.FAR) / 2;
  const tun = new THREE.Mesh(uvScale(new THREE.CylinderGeometry(R, R, 140, 32, 1, true, -Math.PI / 2, Math.PI), 6, 70), new THREE.MeshStandardMaterial({ map: TL2.map, color: 0x5e5a55, roughness: 0.85, side: THREE.BackSide }));
  tun.rotation.x = -Math.PI / 2; tun.position.set(tcx, S.BED + 1.6, S.Z0 - 70); G.add(tun); out.tunnel = tun;
  const tunWalls = []; for (const s of [-1, 1]) { const w = new THREE.Mesh(new THREE.PlaneGeometry(140, 1.6), tunM); w.rotation.y = s * Math.PI / 2; w.position.set(tcx - s * R, S.BED + 0.8, S.Z0 - 70); G.add(w); tunWalls.push(w); }
  // the portal face above the arch
  const portal = new THREE.Shape(); portal.moveTo(S.FAR, S.BED); portal.lineTo(S.EDGE, S.BED); portal.lineTo(S.EDGE, H); portal.lineTo(S.FAR, H); portal.lineTo(S.FAR, S.BED);
  const hole = new THREE.Path(); hole.moveTo(S.FAR + 0.0, S.BED); hole.lineTo(S.FAR, S.BED + 1.6); hole.absarc(tcx, S.BED + 1.6, R, Math.PI, 0, true); hole.lineTo(S.EDGE, S.BED); portal.holes.push(hole);
  const pf = new THREE.Mesh(uvScale(new THREE.ShapeGeometry(portal), 0.5, 1), tileM2); pf.position.z = S.Z0; G.add(pf);
  // the north tunnel (the ghost train comes out of it)
  const tunN = tun.clone(); tunN.position.set(tcx, S.BED + 1.6, S.Z1 + 70); G.add(tunN); out.tunnelN = tunN;
  for (const w of tunWalls) { const w2 = w.clone(); w2.position.z = S.Z1 + 70; G.add(w2); }
  const pfN = pf.clone(); pfN.position.z = S.Z1; pfN.rotation.y = Math.PI; pfN.position.x = 0; pfN.scale.x = 1; G.add(pfN);
  pfN.geometry = pf.geometry; pfN.rotation.y = 0; pfN.position.z = S.Z1 + 0.01; pfN.material = tileM2.clone(); pfN.material.side = THREE.DoubleSide;
  // ---- materials receive shadows ----
  G.traverse(m => { if (m.isMesh && m.receiveShadow !== false) m.receiveShadow = true; });
  return out;
}

// ---- the driver's booth: its own little set at BOOTH (a white kiosk with a blue fascia, a window, the note taped inside) ----
export function makeBooth(E, { wet } = {}) {
  const G = new THREE.Group(); G.position.copy(BOOTH); E.scene.add(G);
  const out = { G };
  const greyT = T.rough('#4a4a48', 31, 512); greyT.wrapS = greyT.wrapT = THREE.RepeatWrapping; greyT.repeat.set(6, 3);
  const greyM = std({ map: greyT, roughness: 0.9, bumpMap: greyT, bumpScale: 2 });
  // room: floor (wet-ish), back + side walls, ceiling
  const CON = T.concrete({ seed: 15, base: '#5a5a58', puddles: 4 });
  const floorM = wet.mat({ map: CON.map, wet: CON.wet, roughness: 0.6, env: 0.12, strength: 0.7, damp: 0.15 });
  const fl = new THREE.Mesh(uvScale(new THREE.PlaneGeometry(16, 16), 4, 4), floorM); fl.rotation.x = -Math.PI / 2; fl.position.set(0, 0, 0); fl.receiveShadow = true; G.add(fl); wet.hide.push(fl);
  const back = new THREE.Mesh(new THREE.PlaneGeometry(16, 5), greyM); back.position.set(0, 2.5, -4); back.receiveShadow = true; G.add(back);
  const side = new THREE.Mesh(new THREE.PlaneGeometry(16, 5), greyM); side.rotation.y = -Math.PI / 2; side.position.set(5, 2.5, 0); side.receiveShadow = true; G.add(side);
  const side2 = side.clone(); side2.rotation.y = Math.PI / 2; side2.position.set(-6, 2.5, 0); G.add(side2);
  const ceilT = T.rough('#222222', 33); const ceil = new THREE.Mesh(new THREE.PlaneGeometry(16, 16), std({ map: ceilT, roughness: 0.95 })); ceil.rotation.x = Math.PI / 2; ceil.position.set(0, 4.0, 0); G.add(ceil);
  const yel = std({ color: 0xc89a1c, roughness: 0.5 }); const yl = new THREE.Mesh(new THREE.PlaneGeometry(0.12, 12), yel); yl.rotation.x = -Math.PI / 2; yl.position.set(-4.2, 0.003, 0); G.add(yl);
  // kiosk: centred at (0, 0, -1.6), 2.4 wide (x), 1.7 deep, 2.6 tall; window on its +z face from y 1.05 to 2.15
  const K = new THREE.Group(); K.position.set(0, 0, -2.0); G.add(K);
  const white = std({ color: 0xaeaca5, roughness: 0.55 }), cream = std({ color: 0x9a927f, roughness: 0.7 });
  const bw = 2.6, bd = 1.8, bh = 2.7, wy0 = 1.05, wy1 = 2.2;
  const kb = (w, h, d, x, y, z, m = white) => { const b = box(w, h, d, m); b.position.set(x, y, z); b.castShadow = true; b.receiveShadow = true; K.add(b); return b; };
  kb(bw, wy0, 0.1, 0, wy0 / 2, bd / 2);                         // front lower panel
  kb(bw + 0.1, 0.06, 0.32, 0, wy0 + 0.03, bd / 2 + 0.08);        // counter ledge
  kb(bw, bh - wy1, 0.1, 0, (bh + wy1) / 2, bd / 2);              // front top
  kb(0.12, bh, bd, -bw / 2, bh / 2, 0); kb(0.12, bh, bd, bw / 2, bh / 2, 0);   // sides
  kb(bw, bh, 0.1, 0, bh / 2, -bd / 2, cream);                    // back wall (inside)
  kb(bw + 0.25, 0.14, bd + 0.25, 0, bh + 0.07, 0);               // roof slab
  const fascia = kb(bw * 0.55, 0.32, 0.04, 0, bh - 0.22, bd / 2 + 0.07, std({ color: 0x3e6fc8, roughness: 0.4 }));
  kb(bw - 0.2, 0.05, bd - 0.2, 0, 0.9, 0, cream);                // inside desk
  // the inside: cream tiles + a teal band + a green round sign, lit by a little light inside
  const TL = T.tiles({ seed: 41, base: '#a99f88' });
  const inT = new THREE.Mesh(uvScale(new THREE.PlaneGeometry(bw - 0.24, bh - 0.1), (bw - 0.24) / 2, (bh - 0.1) / 1), std({ map: TL.map, bumpMap: TL.bump, bumpScale: 1, roughness: 0.6 }));
  inT.position.set(0, bh / 2, -bd / 2 + 0.06); K.add(inT);
  const tb = box(bw - 0.24, 0.2, 0.02, std({ color: 0x1d5c58, roughness: 0.4 })); tb.position.set(0, 1.25, -bd / 2 + 0.08); K.add(tb);
  const gs = new THREE.Mesh(new THREE.CircleGeometry(0.32, 48), std({ color: 0x1f6b47, roughness: 0.4 })); gs.position.set(0.15, 1.9, -bd / 2 + 0.08); K.add(gs);
  const inL = new THREE.PointLight(0xfff0d8, 2.2, 4, 1.5); inL.position.set(0, 2.35, 0.1); K.add(inL); out.inLight = inL;
  // glass in the window + the note taped to its inside
  const glass = new THREE.Mesh(new THREE.PlaneGeometry(bw - 0.24, wy1 - wy0), new THREE.MeshPhysicalMaterial({ color: 0xffffff, roughness: 0.05, transmission: 0, transparent: true, opacity: 0.08, envMapIntensity: 1.5 }));
  glass.position.set(0, (wy0 + wy1) / 2, bd / 2 - 0.02); K.add(glass);
  const note = T.noteTex(); note.draw(0);
  const noteM = new THREE.Mesh(new THREE.PlaneGeometry(0.36, 0.36 / note.aspect), std({ map: note.tex, transparent: true, alphaTest: 0.02, roughness: 0.8, side: THREE.DoubleSide }));
  noteM.position.set(0.45, 1.68, bd / 2 - 0.03); noteM.rotation.z = 0.035; K.add(noteM);
  out.note = { tex: note, mesh: noteM };
  // a ceiling tube above the kiosk
  const tubeM = new THREE.MeshStandardMaterial({ color: 0, emissive: 0xf4f7ff, emissiveIntensity: 3.2 });
  const tub = new THREE.Group(); tub.add(box(0.2, 0.07, 1.6, std({ color: 0x999999, roughness: 0.5 }))); const tt = box(0.12, 0.03, 1.5, tubeM); tt.position.y = -0.045; tub.add(tt);
  tub.position.set(0.8, 3.6, 0.6); tub.rotation.y = 0.6; G.add(tub);
  out.tube = tub; out.kiosk = K;
  return out;
}
```

### `engine/train.js`

```js
// The train: silver-white cars with a blue stripe, a window band with dark pillars, sliding double doors, a lit interior
// (white walls, blue longitudinal benches, steel poles, two light strips, an ochre band), a cab front with two round
// headlights, a destination display and red tail lights. Engine file.
//   const TR = makeTrain(E, { cars: 3 })   TR.G.position.z = head z   TR.doors(k)   TR.lights({ head, inside })
// Local frame: cars run along z, the train's nose is at +z (it arrives from the tunnel at −z); x = 0 is the track centre,
// +x is the platform side. y = 0 is the car floor (= the platform's height).
import * as THREE from 'three';

export const CAR = { L: 16, W: 2.8, GAP: 0.6, FLOOR: 0.0, WIN0: 0.95, WIN1: 2.05, TOP: 2.62, ROOF: 3.0, BOT: -0.95, DOORS: [-4.6, 4.6], DW: 1.3 };
const box = (w, h, d, m) => new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);
const std = o => new THREE.MeshStandardMaterial(o);

export function makeTrain(E, { cars = 3 } = {}) {
  const G = new THREE.Group(); E.scene.add(G);
  const body = std({ color: 0xc9ccd0, roughness: 0.32, metalness: 0.55, envMapIntensity: 0.9 });
  const pillar = std({ color: 0x2a2d33, roughness: 0.35, metalness: 0.5 });
  const blue = std({ color: 0x1f47a8, roughness: 0.35, metalness: 0.2 });
  const glassM = new THREE.MeshPhysicalMaterial({ color: 0xbfd4e6, roughness: 0.05, metalness: 0, transparent: true, opacity: 0.16, envMapIntensity: 1.6, depthWrite: false });
  const inWall = std({ color: 0xe8eaec, roughness: 0.5 }), inFloor = std({ color: 0x8c8f93, roughness: 0.7 }), seatM = std({ color: 0x2a5fd0, roughness: 0.55 });
  const poleM = std({ color: 0xd8dade, roughness: 0.18, metalness: 0.9 }), ochre = std({ color: 0xc9a24a, roughness: 0.5 });
  const lightM = new THREE.MeshStandardMaterial({ color: 0, emissive: 0xf4f8ff, emissiveIntensity: 2.6 });
  const headM = new THREE.MeshStandardMaterial({ color: 0, emissive: 0xffffff, emissiveIntensity: 6 });
  const tailM = new THREE.MeshStandardMaterial({ color: 0, emissive: 0xff2a1a, emissiveIntensity: 3 });
  const destM = new THREE.MeshStandardMaterial({ color: 0, emissive: 0xffa040, emissiveIntensity: 1.6 });
  const { L, W } = CAR, hw = W / 2;
  const out = { G, carsG: [], doorLeaves: [], inLights: [], lightM, headM, tailM, destM, mats: { body, glassM } };
  const add = (p, m, x, y, z) => { m.position.set(x, y, z); p.add(m); m.castShadow = true; m.receiveShadow = true; return m; };
  for (let c = 0; c < cars; c++) {
    const C = new THREE.Group(); C.position.z = -c * (L + CAR.GAP); G.add(C); out.carsG.push(C);
    // underframe + floor + roof
    add(C, box(W - 0.2, 0.7, L - 0.6, std({ color: 0x1c1c1e, roughness: 0.8 })), 0, CAR.BOT + 0.2, 0);
    add(C, box(W - 0.1, 0.06, L, inFloor), 0, CAR.FLOOR - 0.03, 0);
    const roof = new THREE.Mesh(new THREE.CylinderGeometry(1, 1, L, 32, 1, false, -Math.PI / 2, Math.PI), body); roof.rotation.set(-Math.PI / 2, 0, 0); roof.scale.set(hw, 1, 0.38); roof.position.set(0, CAR.TOP, 0); C.add(roof); roof.castShadow = true;
    const ceil = add(C, box(W - 0.14, 0.04, L - 0.1, inWall), 0, CAR.TOP - 0.06, 0);
    // two ceiling light strips + the ochre band at the window tops
    for (const s of [-1, 1]) { add(C, box(0.16, 0.03, L - 1.2, lightM), s * 0.55, CAR.TOP - 0.1, 0).castShadow = false; add(C, box(0.04, 0.18, L - 0.2, ochre), s * (hw - 0.08), CAR.WIN1 + 0.12, 0); }
    // sides: lower panel, stripe, pillars + windows, doors, upper panel
    for (const s of [-1, 1]) {
      const x = s * (hw - 0.03);
      add(C, box(0.06, CAR.WIN0 - CAR.BOT, L, body), x, (CAR.WIN0 + CAR.BOT) / 2, 0);
      add(C, box(0.07, 0.22, L, blue), x + s * 0.005, 0.5, 0);
      add(C, box(0.06, CAR.TOP - CAR.WIN1, L, body), x, (CAR.TOP + CAR.WIN1) / 2, 0);
      // inner wall faces
      add(C, box(0.02, CAR.WIN0, L - 0.2, inWall), x - s * 0.045, CAR.WIN0 / 2, 0);
      // window band: pillars between windows, door frames
      const spans = []; let z = -L / 2;
      const marks = [...CAR.DOORS.map(d => [d - CAR.DW / 2, d + CAR.DW / 2])];
      for (const [a, b] of marks) { spans.push([z, a]); z = b; } spans.push([z, L / 2]);
      for (const [a, b] of spans) {
        const n = Math.max(1, Math.round((b - a) / 1.6)); const w = (b - a) / n;
        for (let i = 0; i <= n; i++) add(C, box(0.07, CAR.WIN1 - CAR.WIN0, 0.14, pillar), x, (CAR.WIN0 + CAR.WIN1) / 2, a + i * w);
        const gl = new THREE.Mesh(new THREE.PlaneGeometry(b - a, CAR.WIN1 - CAR.WIN0), glassM); gl.rotation.y = s * Math.PI / 2; gl.position.set(x + s * 0.02, (CAR.WIN0 + CAR.WIN1) / 2, (a + b) / 2); C.add(gl);
      }
      // doors: two leaves each (frame + glass), they slide along z
      for (const d of CAR.DOORS) {
        for (const lr of [-1, 1]) {
          const leaf = new THREE.Group();
          const fr = std({ color: 0xbfc3c8, roughness: 0.3, metalness: 0.6 });
          add(leaf, box(0.05, CAR.WIN0 + 0.0, CAR.DW / 2 - 0.02, fr), 0, CAR.WIN0 / 2, 0);
          add(leaf, box(0.05, CAR.TOP - 0.2 - CAR.WIN1, CAR.DW / 2 - 0.02, fr), 0, (CAR.TOP - 0.2 + CAR.WIN1) / 2, 0);
          add(leaf, box(0.05, CAR.WIN1 - CAR.WIN0, 0.07, fr), 0, (CAR.WIN0 + CAR.WIN1) / 2, lr * (CAR.DW / 4 - 0.045));
          add(leaf, box(0.05, CAR.WIN1 - CAR.WIN0, 0.05, pillar), 0, (CAR.WIN0 + CAR.WIN1) / 2, -lr * (CAR.DW / 4 - 0.03));
          const g2 = new THREE.Mesh(new THREE.PlaneGeometry(CAR.DW / 2 - 0.12, CAR.WIN1 - CAR.WIN0), glassM); g2.rotation.y = s * Math.PI / 2; g2.position.set(s * 0.03, (CAR.WIN0 + CAR.WIN1) / 2, 0); leaf.add(g2);
          leaf.position.set(x + s * 0.02, 0, d + lr * CAR.DW / 4); C.add(leaf);
          out.doorLeaves.push({ leaf, z0: d + lr * CAR.DW / 4, dir: lr, side: s });
        }
        add(C, box(0.08, 0.12, CAR.DW + 0.1, pillar), x, CAR.TOP - 0.16, d);
      }
      // longitudinal benches between the doors + the window bays
      for (const [a, b] of [[-L / 2 + 0.5, CAR.DOORS[0] - CAR.DW / 2 - 0.15], [CAR.DOORS[0] + CAR.DW / 2 + 0.15, CAR.DOORS[1] - CAR.DW / 2 - 0.15], [CAR.DOORS[1] + CAR.DW / 2 + 0.15, L / 2 - 0.5]]) {
        const zc = (a + b) / 2, len = b - a, xs = s * (hw - 0.33);
        add(C, box(0.5, 0.1, len, seatM), xs, 0.46, zc);
        const bk = add(C, box(0.1, 0.5, len, seatM), s * (hw - 0.12), 0.78, zc); bk.rotation.z = -s * 0.12;
        add(C, box(0.46, 0.4, len - 0.1, std({ color: 0x9aa0a6, roughness: 0.4, metalness: 0.4 })), xs, 0.2, zc);
      }
    }
    // poles at the doors + a grab rail
    for (const d of CAR.DOORS) for (const s of [-1, 1]) add(C, new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.02, CAR.TOP - 0.1, 12), poleM), 0, (CAR.TOP - 0.1) / 2, d + s * 1.1);
    for (const s of [-1, 1]) add(C, new THREE.Mesh(new THREE.CylinderGeometry(0.016, 0.016, L - 1, 10).rotateX(Math.PI / 2), poleM), s * 0.75, 1.95, 0);
    // end walls (inside, with a door window), and the car ends outside
    for (const e of [-1, 1]) {
      add(C, box(W - 0.1, CAR.TOP - CAR.BOT, 0.06, body), 0, (CAR.TOP + CAR.BOT) / 2, e * (L / 2 - 0.03)).visible = true;
      add(C, box(W - 0.3, CAR.TOP, 0.02, inWall), 0, CAR.TOP / 2, e * (L / 2 - 0.08));
      add(C, box(0.7, 1.1, 0.03, pillar), 0, 1.5, e * (L / 2 - 0.1));
    }
    // inside light (one area-ish point light per car, low)
    const pl = new THREE.PointLight(0xeef4ff, 0, 9, 1.4); pl.position.set(0, 2.2, 0); C.add(pl); out.inLights.push(pl);
  }
  // a cab at both ends: nose plate, a dark window, a destination strip, two round lamps and two red tail lights.
  // The nose is car 0's +z end; the rear is the last car's −z end. Each end has its own lamp materials.
  const cabWin = std({ color: 0x0c0e12, roughness: 0.08, metalness: 0.2, envMapIntensity: 1.8 });
  out.ends = {};
  for (const [name, C, zs] of [['front', out.carsG[0], 1], ['rear', out.carsG.at(-1), -1]]) {
    const z0 = zs * L / 2;
    const head = headM.clone(), tail = tailM.clone(), dest = destM.clone();
    const g = new THREE.Group(); g.position.z = z0; if (zs < 0) g.rotation.y = Math.PI; C.add(g);
    add(g, box(W - 0.04, CAR.ROOF - CAR.BOT - 0.15, 0.22, body), 0, (CAR.ROOF + CAR.BOT) / 2 - 0.08, 0.09);
    add(g, box(W - 0.6, 0.9, 0.04, cabWin), 0, 1.55, 0.21);
    add(g, box(0.9, 0.16, 0.03, dest), 0, 2.4, 0.215);
    const lamps = [];
    for (const s of [-1, 1]) {
      const hl = new THREE.Mesh(new THREE.CircleGeometry(0.13, 32), head); hl.position.set(s * 0.82, 0.22, 0.205); g.add(hl); lamps.push(hl);
      const ring = new THREE.Mesh(new THREE.RingGeometry(0.13, 0.17, 32), pillar); ring.position.set(s * 0.82, 0.22, 0.204); g.add(ring);
      const tl = new THREE.Mesh(new THREE.CircleGeometry(0.07, 20), tail); tl.position.set(s * 1.1, 0.22, 0.206); g.add(tl);
    }
    const spots = [];
    for (const s of [-1, 1]) { const sp = new THREE.SpotLight(0xf6f8ff, 0, 70, 0.42, 0.6, 1.1); sp.position.set(s * 0.82, 0.3, 0.3); sp.target.position.set(s * 0.6, -0.4, 20); g.add(sp, sp.target); spots.push(sp); }
    out.ends[name] = { g, head, tail, dest, spots, lamps };
  }
  out.heads = out.ends.front.lamps;
  out.doors = k => { for (const d of out.doorLeaves) d.leaf.position.z = d.z0 + d.dir * (CAR.DW / 2 - 0.08) * k * (d.side > 0 ? 1 : 0); };
  // lights({ head (front lamps + beams), inside, tail (front red), rHead (rear white lamps), rTail (rear red), beam (front spot k) })
  out.lights = ({ head = 0, inside = 0, tail = 0, rHead = 0, rTail = 1, beam = null } = {}) => {
    const F = out.ends.front, R = out.ends.rear;
    F.head.emissiveIntensity = 6 * head; F.spots.forEach(s => { s.intensity = 80 * (beam ?? head); });
    F.tail.emissiveIntensity = 3 * tail; F.dest.emissiveIntensity = 1.6 * Math.max(head, inside);
    R.head.emissiveIntensity = 4 * rHead; R.spots.forEach(s => { s.intensity = 0; });
    R.tail.emissiveIntensity = 3.5 * rTail; R.dest.emissiveIntensity = 1.6 * Math.max(rTail, inside);
    lightM.emissiveIntensity = 2.6 * inside; out.inLights.forEach(p => { p.intensity = 5 * inside; });
  };
  // nose at world z (the train runs toward −z: G is mirrored in z)
  out.cars = cars;
  out.noseAt = z => { G.scale.z = -1; G.position.z = z + L / 2 + 0.2; };
  out.rearZ = () => G.position.z + (cars - 1) * (L + CAR.GAP) + L / 2 + 0.2;
  out.doors(0); out.lights({ head: 1, inside: 1 });
  return out;
}
```

### `engine/tex.js`

```js
// Procedural canvas textures for the subway: brick tiles, wet concrete with puddles, dark ceiling, signs, posters,
// dot-matrix boards, the phone screens and the night-shift note. Deterministic (seeded). Engine file.
import * as THREE from 'three';
import { rng } from './lib.js';

export const FONT_SIGN = '"Inter Tight", "Segoe UI", Arial, sans-serif';
export function canvas(w, h) { const c = document.createElement('canvas'); c.width = w; c.height = h; return [c, c.getContext('2d')]; }
export function tex(c, { srgb = true, rep = null, aniso = 8 } = {}) {
  const t = new THREE.CanvasTexture(c); if (srgb) t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = aniso;
  if (rep) { t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(rep[0], rep[1]); }
  return t;
}
// fine value noise into a canvas (grey levels around base ± amp)
function speckle(g, w, h, n, amp, seed, size = [1, 3]) {
  const R = rng(seed);
  for (let i = 0; i < n; i++) { const v = (R() - 0.5) * amp; g.fillStyle = v > 0 ? `rgba(255,255,255,${v})` : `rgba(0,0,0,${-v})`; const s = size[0] + R() * (size[1] - size[0]); g.fillRect(R() * w, R() * h, s, s); }
}
function blotches(g, w, h, n, col, rMin, rMax, seed, alpha = 0.08) {
  const R = rng(seed);
  for (let i = 0; i < n; i++) { const x = R() * w, y = R() * h, r = rMin + R() * (rMax - rMin); const gr = g.createRadialGradient(x, y, 0, x, y, r); gr.addColorStop(0, col.replace('A', alpha * (0.4 + R() * 0.6))); gr.addColorStop(1, col.replace('A', 0)); g.fillStyle = gr; g.fillRect(x - r, y - r, 2 * r, 2 * r); }
}

// ---- white brick tiles: 2 m × 1 m per texture (tiles 0.2 × 0.075 m), colour + bump + roughness ----
export function tiles({ base = '#e4e2dc', grout = '#8d8b85', seed = 3, dirty = 0.5 } = {}) {
  const W = 1024, H = 512, tw = W / 10, th = H / 13.333;
  const [c, g] = canvas(W, H), [b, gb] = canvas(W, H), [r, gr] = canvas(W, H);
  g.fillStyle = grout; g.fillRect(0, 0, W, H); gb.fillStyle = '#000'; gb.fillRect(0, 0, W, H); gr.fillStyle = '#c8c8c8'; gr.fillRect(0, 0, W, H);
  const R = rng(seed);
  for (let row = 0; row < 14; row++) for (let col = -1; col < 11; col++) {
    const x = col * tw + (row % 2 ? tw / 2 : 0), y = row * th, gap = 3;
    const v = (R() - 0.5) * 14; const cc = new THREE.Color(base); cc.offsetHSL(0, 0, v / 255);
    g.fillStyle = '#' + cc.getHexString(); g.fillRect(x + gap, y + gap, tw - 2 * gap, th - 2 * gap);
    // a soft bevel: lighter top-left, darker bottom
    const gg = g.createLinearGradient(0, y, 0, y + th); gg.addColorStop(0, 'rgba(255,255,255,0.10)'); gg.addColorStop(0.5, 'rgba(255,255,255,0)'); gg.addColorStop(1, 'rgba(0,0,0,0.10)'); g.fillStyle = gg; g.fillRect(x + gap, y + gap, tw - 2 * gap, th - 2 * gap);
    gb.fillStyle = '#fff'; gb.fillRect(x + gap + 2, y + gap + 2, tw - 2 * gap - 4, th - 2 * gap - 4);
    gb.fillStyle = '#bbb'; gb.fillRect(x + gap, y + gap, tw - 2 * gap, 2); gb.fillRect(x + gap, y + th - gap - 2, tw - 2 * gap, 2);
    gr.fillStyle = `rgb(${60 + R() * 40 | 0},${60 + R() * 40 | 0},${60 + R() * 40 | 0})`; gr.fillRect(x + gap, y + gap, tw - 2 * gap, th - 2 * gap);
  }
  if (dirty) { blotches(g, W, H, 40, 'rgba(70,64,52,A)', 20, 120, seed + 1, 0.12 * dirty); speckle(g, W, H, 9000, 0.12 * dirty, seed + 2); }
  return { map: tex(c, { rep: [1, 1] }), bump: tex(b, { srgb: false, rep: [1, 1] }), rough: tex(r, { srgb: false, rep: [1, 1] }), W: 2, H: 1 };
}

// ---- concrete floor 4 × 4 m with puddles: colour, roughness (puddles glossy), wet mask (alpha in red) ----
export function concrete({ base = '#6c6c6a', seed = 5, puddles = 7, wet = 1 } = {}) {
  const W = 1024, H = 1024;
  const [c, g] = canvas(W, H), [r, gr] = canvas(W, H), [m, gm] = canvas(W, H);
  g.fillStyle = base; g.fillRect(0, 0, W, H);
  blotches(g, W, H, 70, 'rgba(30,30,28,A)', 30, 180, seed, 0.2); blotches(g, W, H, 50, 'rgba(160,160,155,A)', 30, 160, seed + 1, 0.12);
  speckle(g, W, H, 60000, 0.16, seed + 2, [1, 2.5]);
  gr.fillStyle = '#d0d0d0'; gr.fillRect(0, 0, W, H); speckle(gr, W, H, 30000, 0.3, seed + 3, [1, 3]);
  gm.fillStyle = '#000'; gm.fillRect(0, 0, W, H);
  const R = rng(seed + 4);
  // puddles = blobby unions of soft ellipses; in them the floor is darker and glossy
  for (let p = 0; p < puddles * wet; p++) {
    const cx = R() * W, cy = R() * H, n = 7 + (R() * 7 | 0), s = 140 + R() * 220;
    for (let i = 0; i < n; i++) {
      const x = cx + (R() - 0.5) * s * 1.6, y = cy + (R() - 0.5) * s * 1.0, rx = s * (0.18 + R() * 0.32), ry = rx * (0.45 + R() * 0.5);
      for (const [gg, col] of [[gm, '255,255,255'], [gr, '20,20,20'], [g, '22,22,22']]) {
        for (const ox of [-W, 0, W]) for (const oy of [-H, 0, H]) {
          const G = gg.createRadialGradient(x + ox, y + oy, 0, x + ox, y + oy, rx); const a = gg === g ? 0.12 : 1;
          G.addColorStop(0, `rgba(${col},${a})`); G.addColorStop(0.55, `rgba(${col},${a * 0.9})`); G.addColorStop(1, `rgba(${col},0)`);
          gg.save(); gg.translate(x + ox, y + oy); gg.scale(1, ry / rx); gg.translate(-(x + ox), -(y + oy)); gg.fillStyle = G; gg.fillRect(x + ox - rx, y + oy - rx, rx * 2, rx * 2); gg.restore();
        }
      }
    }
  }
  return { map: tex(c, { rep: [1, 1] }), rough: tex(r, { srgb: false, rep: [1, 1] }), wet: tex(m, { srgb: false, rep: [1, 1] }), S: 4 };
}
export function rough(base = '#2a2a2a', seed = 9, W = 512) {
  const [c, g] = canvas(W, W); g.fillStyle = base; g.fillRect(0, 0, W, W);
  blotches(g, W, W, 40, 'rgba(0,0,0,A)', 20, 120, seed, 0.3); blotches(g, W, W, 30, 'rgba(255,255,255,A)', 20, 100, seed + 1, 0.05); speckle(g, W, W, 30000, 0.2, seed + 2);
  return tex(c, { rep: [1, 1] });
}

// ---- signs ----
export function stationSign(text = 'Ashford Road') {
  const [c, g] = canvas(1024, 256);
  g.fillStyle = '#f2f2f2'; g.fillRect(0, 0, 1024, 256);
  g.fillStyle = '#1b3a8c'; g.fillRect(14, 14, 996, 228);
  g.strokeStyle = '#f2f2f2'; g.lineWidth = 6; g.strokeRect(30, 30, 964, 196);
  g.fillStyle = '#f4f4f4'; g.font = `800 132px ${FONT_SIGN}`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(text, 512, 134);
  return tex(c);
}
export function exitSign() {
  const [c, g] = canvas(512, 160);
  g.fillStyle = '#3fbf74'; g.fillRect(0, 0, 512, 160);
  g.fillStyle = '#eafff2'; g.beginPath(); g.moveTo(60, 80); g.lineTo(110, 40); g.lineTo(110, 64); g.lineTo(160, 64); g.lineTo(160, 96); g.lineTo(110, 96); g.lineTo(110, 120); g.closePath(); g.fill();
  g.font = `800 88px ${FONT_SIGN}`; g.textBaseline = 'middle'; g.fillText('EXIT', 190, 84);
  return tex(c);
}
export function poster(seed = 1, label = '', pi = null) {
  const [c, g] = canvas(256, 384); const R = rng(seed);
  const pal = [['#e8762c', '#2a59c9', '#f2c84b'], ['#3d6fe0', '#e34b3c', '#1f2a44'], ['#2fb39a', '#7a3fd0', '#ffcf3a'], ['#d84a7a', '#264a9e', '#5ec7e0'], ['#6a3fb0', '#1f8f7a', '#14403a']][pi ?? seed % 4];
  g.fillStyle = pal[2]; g.fillRect(0, 0, 256, 384);
  for (let i = 0; i < 4; i++) { g.fillStyle = pal[i % 2]; g.beginPath(); const x = R() * 256, y = R() * 300, r = 60 + R() * 120; if (R() > 0.5) g.arc(x, y, r, 0, 7); else { g.moveTo(x, y); g.lineTo(x + r, y + r * 1.2); g.lineTo(x - r * 0.8, y + r); } g.fill(); }
  g.fillStyle = '#111'; g.fillRect(0, 316, 256, 68); g.fillStyle = '#eee'; g.font = `800 26px ${FONT_SIGN}`; g.fillText(label || ['RIDE SAFE', 'CITY LIGHTS', 'MIND THE GAP', 'NIGHT LINE'][seed % 4], 16, 352);
  g.font = `500 12px ${FONT_SIGN}`; g.fillStyle = '#aaa'; g.fillText('Keep the platform clear', 16, 372);
  g.strokeStyle = '#222'; g.lineWidth = 10; g.strokeRect(0, 0, 256, 384);
  return tex(c);
}

// ---- dot-matrix board: lines of text drawn as glowing dots (amber or red). Returns { tex, draw(lines) } ----
const GLYPH = {};
function glyphGrid(ch, h = 7) {   // rasterise a character to a 5×7 grid with the canvas (bold mono)
  const k = ch + h; if (GLYPH[k]) return GLYPH[k];
  const [c, g] = canvas(10, 14); g.fillStyle = '#000'; g.fillRect(0, 0, 10, 14); g.fillStyle = '#fff'; g.font = '700 13px Consolas, monospace'; g.textBaseline = 'top'; g.fillText(ch, 0, 0);
  const d = g.getImageData(0, 0, 10, 14).data, G = [];
  for (let y = 0; y < h; y++) { const row = []; for (let x = 0; x < 5; x++) { let s = 0; for (let yy = 0; yy < 2; yy++) for (let xx = 0; xx < 2; xx++) s += d[((y * 2 + yy) * 10 + (x * 2 + xx)) * 4]; row.push(s > 300); } G.push(row); }
  return (GLYPH[k] = G);
}
// a 5x7 font for digits/letters the board needs (crisper than the rasterised one)
const F57 = {
  '0': ['01110', '10001', '10011', '10101', '11001', '10001', '01110'], '1': ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
  '2': ['01110', '10001', '00001', '00010', '00100', '01000', '11111'], '3': ['11111', '00010', '00100', '00010', '00001', '10001', '01110'],
  '4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'], '5': ['11111', '10000', '11110', '00001', '00001', '10001', '01110'],
  '6': ['00110', '01000', '10000', '11110', '10001', '10001', '01110'], '7': ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
  '8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'], '9': ['01110', '10001', '10001', '01111', '00001', '00010', '01100'],
  ':': ['00000', '01100', '01100', '00000', '01100', '01100', '00000'], ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
  'N': ['10001', '11001', '10101', '10011', '10001', '10001', '10001'], 'E': ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
  'X': ['10001', '10001', '01010', '00100', '01010', '10001', '10001'], 'T': ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
  'R': ['11110', '10001', '10001', '11110', '10100', '10010', '10001'], 'A': ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
  'I': ['01110', '00100', '00100', '00100', '00100', '00100', '01110'],
};
export function dotBoard({ cols = 64, rows = 18, color = [255, 150, 60], px = 16, bg = '#1a120c' } = {}) {
  const W = cols * px, H = rows * px; const [c, g] = canvas(W, H); const t = tex(c);
  let key = '';
  const draw = (lines, opts = {}) => {
    const k = JSON.stringify([lines, opts]); if (k === key) return; key = k;
    g.fillStyle = bg; g.fillRect(0, 0, W, H);
    const on = Array.from({ length: rows }, () => new Array(cols).fill(false));
    const big = opts.scale || 1;   // each font pixel = big × big dots
    const lineH = 7 * big, gap = opts.gap ?? Math.max(1, big);
    const totalH = lines.length * lineH + (lines.length - 1) * gap; let y0 = Math.round((rows - totalH) / 2);
    for (const ln of lines) {
      const chars = [...ln]; const cw = 5 * big + big; const w = chars.length * cw - big; let x0 = Math.round((cols - w) / 2);
      for (const ch of chars) { const G = F57[ch] ? F57[ch].map(r => [...r].map(v => v === '1')) : glyphGrid(ch); for (let y = 0; y < 7; y++) for (let x = 0; x < 5; x++) if (G[y][x]) for (let a = 0; a < big; a++) for (let b = 0; b < big; b++) { const yy = y0 + y * big + a, xx = x0 + x * big + b; if (yy >= 0 && yy < rows && xx >= 0 && xx < cols) on[yy][xx] = true; } x0 += cw; }
      y0 += lineH + gap;
    }
    const [r, gg, b] = color, dim = opts.dim ?? 1;
    for (let y = 0; y < rows; y++) for (let x = 0; x < cols; x++) {
      const cx = x * px + px / 2, cy = y * px + px / 2;
      if (on[y][x]) { const G = g.createRadialGradient(cx, cy, 0, cx, cy, px * 0.62); G.addColorStop(0, `rgba(${Math.min(255, r + 60)},${Math.min(255, gg + 60)},${Math.min(255, b + 50)},${dim})`); G.addColorStop(0.45, `rgba(${r},${gg},${b},${0.95 * dim})`); G.addColorStop(1, `rgba(${r},${gg},${b},0)`); g.fillStyle = G; g.fillRect(cx - px, cy - px, px * 2, px * 2); }
      else { g.fillStyle = `rgba(${r * 0.3 | 0},${gg * 0.22 | 0},${b * 0.2 | 0},0.55)`; g.beginPath(); g.arc(cx, cy, px * 0.3, 0, 7); g.fill(); }
    }
    t.needsUpdate = true;
  };
  return { tex: t, draw, W, H };
}

// ---- the phone's lock screen: 'battery' | 'signal' with a time ----
export function phoneScreen() {
  const W = 540, H = 1140; const [c, g] = canvas(W, H); const t = tex(c); let key = '';
  const draw = (mode, time = '23:41', k = 1) => {
    const kk = mode + time + k.toFixed(2); if (kk === key) return; key = kk;
    const gr = g.createLinearGradient(0, 0, 0, H); gr.addColorStop(0, '#0f1730'); gr.addColorStop(1, '#0a0f22'); g.fillStyle = gr; g.fillRect(0, 0, W, H);
    g.fillStyle = '#e8ecf5'; g.font = `600 30px ${FONT_SIGN}`; g.textBaseline = 'middle'; g.fillText(time, 46, 60);
    const red = '#ff5a4a';
    // status icons top right
    g.strokeStyle = red; g.lineWidth = 4; g.strokeRect(W - 96, 46, 50, 26); g.fillStyle = red; g.fillRect(W - 44, 53, 6, 12); g.fillRect(W - 92, 50, 5, 18);
    if (mode === 'signal') { for (let i = 0; i < 4; i++) { g.fillStyle = red; g.fillRect(W - 170 + i * 14, 72 - 7 - i * 6, 9, 7 + i * 6); } g.strokeStyle = red; g.lineWidth = 4; g.beginPath(); g.moveTo(W - 176, 74); g.lineTo(W - 112, 40); g.stroke(); }
    g.textAlign = 'center'; g.fillStyle = '#f3f5fb'; g.font = `700 150px ${FONT_SIGN}`; g.fillText(time, W / 2, 250);
    g.font = `500 34px ${FONT_SIGN}`; g.fillStyle = '#c9cfdd'; g.fillText('Friday, 14 November', W / 2, 350);
    if (mode === 'battery') {
      g.strokeStyle = red; g.lineWidth = 10; const bx = W / 2 - 100, by = 560; g.beginPath(); g.roundRect(bx, by, 180, 96, 14); g.stroke(); g.fillStyle = red; g.fillRect(bx + 186, by + 30, 14, 36); g.fillRect(bx + 14, by + 14, 14, 68);
      g.font = `800 64px ${FONT_SIGN}`; g.fillStyle = red; g.fillText('3%', W / 2, 740); g.font = `600 34px ${FONT_SIGN}`; g.fillStyle = '#e8ecf5'; g.fillText('Battery low', W / 2, 805);
    } else {
      const bx = W / 2 - 85, by = 650; g.fillStyle = red; for (let i = 0; i < 5; i++) g.fillRect(bx + i * 34, by - 22 - i * 22, 22, 22 + i * 22);
      g.strokeStyle = red; g.lineWidth = 10; g.lineCap = 'round'; g.beginPath(); g.moveTo(bx - 20, by + 2); g.lineTo(bx + 175, by - 140); g.stroke();
      g.font = `800 58px ${FONT_SIGN}`; g.fillStyle = red; g.fillText('No Service', W / 2, 745); g.font = `600 32px ${FONT_SIGN}`; g.fillStyle = '#e8ecf5'; g.fillText('Emergency calls only', W / 2, 805);
    }
    g.textAlign = 'left'; t.needsUpdate = true;
  };
  return { tex: t, draw };
}

// ---- the night-shift note: paper with a torn bottom edge (alpha), tape strips, optional red box on rule 1 ----
export function noteTex() {
  const W = 800, H = 1000; const [c, g] = canvas(W, H); const t = tex(c); let key = '';
  const R0 = rng(77); const tear = Array.from({ length: 41 }, (_, i) => [i / 40 * W, H - 70 + (R0() - 0.5) * 70]);
  const draw = (box = 0) => {
    const k = box.toFixed(3); if (k === key) return; key = k;
    g.clearRect(0, 0, W, H);
    g.save(); g.beginPath(); g.moveTo(0, 0); g.lineTo(W, 0); for (let i = tear.length - 1; i >= 0; i--) g.lineTo(tear[i][0], tear[i][1]); g.closePath(); g.clip();
    const pg = g.createLinearGradient(0, 0, W, H); pg.addColorStop(0, '#efece4'); pg.addColorStop(1, '#ddd8cc'); g.fillStyle = pg; g.fillRect(0, 0, W, H);
    speckle(g, W, H, 6000, 0.07, 12);
    // creases
    g.strokeStyle = 'rgba(0,0,0,0.05)'; g.lineWidth = 3; g.beginPath(); g.moveTo(0, H * 0.42); g.lineTo(W, H * 0.4); g.stroke();
    const ink = '#2a2826'; g.fillStyle = ink; g.textAlign = 'center'; g.textBaseline = 'alphabetic';
    g.font = `800 70px ${FONT_SIGN}`; g.fillText('NIGHT SHIFT', W / 2, 140); g.fillText('INSTRUCTIONS', W / 2, 218);
    g.fillRect(40, 250, W - 80, 4);
    g.fillStyle = '#9b2b25'; g.font = `700 36px ${FONT_SIGN}`; g.fillText('STAFF ONLY', W / 2, 300);
    g.fillStyle = ink; g.fillRect(40, 326, W - 80, 4);
    g.textAlign = 'left'; g.font = `800 50px ${FONT_SIGN}`; g.fillText('RULE NO. 1:', 50, 410);
    g.font = `800 50px ${FONT_SIGN}`; g.fillText('NEVER BOARD', 50, 482); g.fillText('AN EMPTY', 50, 540); g.fillText('TRAIN.', 50, 598);
    g.fillStyle = '#9b2b25'; g.fillRect(40, 640, W - 80, 3);
    g.fillStyle = ink; g.font = `800 50px ${FONT_SIGN}`; g.fillText('RULE NO. 2:', 50, 722);
    g.font = `700 38px ${FONT_SIGN}`; g.fillText('IF YOU HEAR FOOTSTEPS', 50, 786); g.fillText('BEHIND YOU, DO NOT', 50, 840);
    if (box > 0) {   // the red box draws itself around rule 1's text
      g.strokeStyle = '#c8302a'; g.lineWidth = 9; g.lineJoin = 'round'; const x0 = 34, y0 = 436, w = W - 72, h = 188, per = 2 * (w + h), L = per * Math.min(1, box);
      const pts = [[x0, y0], [x0 + w, y0], [x0 + w, y0 + h], [x0, y0 + h], [x0, y0]]; let left = L;
      g.beginPath(); g.moveTo(x0, y0);
      for (let i = 1; i < pts.length && left > 0; i++) { const [ax, ay] = pts[i - 1], [bx, by] = pts[i]; const len = Math.hypot(bx - ax, by - ay), s = Math.min(1, left / len); g.lineTo(ax + (bx - ax) * s, ay + (by - ay) * s); left -= len; }
      g.stroke();
    }
    g.restore();
    // tape
    for (const [x, y, a] of [[90, 18, -0.08], [W - 90, 14, 0.06]]) { g.save(); g.translate(x, y); g.rotate(a); g.fillStyle = 'rgba(225,222,205,0.75)'; g.fillRect(-80, -26, 160, 52); g.strokeStyle = 'rgba(0,0,0,0.06)'; g.strokeRect(-80, -26, 160, 52); g.restore(); }
    t.needsUpdate = true;
  };
  return { tex: t, draw, aspect: W / H };
}
```

### `engine/wet.js`

```js
// Wet floors: a hidden planar Reflector renders the mirrored scene, and a MeshStandardMaterial floor samples it through
// its wet mask (puddles = mirror, damp concrete = a faint blurred sheen), with fresnel and a little ripple. Engine file.
//   const W = makeWet(E, { y: 0, size: [w, d], center: [x, z] })   floorMat = wetMat({ map, rough, wet, ...}, W)
import * as THREE from 'three';
import { Reflector } from 'three/addons/objects/Reflector.js';

export function makeWet(E, { y = 0, size = [40, 80], center = [0, 0], res = [540, 960] } = {}) {
  const geo = new THREE.PlaneGeometry(size[0], size[1]);
  const refl = new Reflector(geo, { textureWidth: res[0], textureHeight: res[1], clipBias: 0.003, color: 0xffffff, multisample: 0 });
  refl.rotation.x = -Math.PI / 2; refl.position.set(center[0], y - 0.0005, center[1]);
  refl.material.colorWrite = false; refl.material.depthWrite = false; refl.renderOrder = -1000;
  refl.frustumCulled = false;
  E.scene.add(refl); refl.updateMatrixWorld(true);
  const inv = new THREE.Matrix4().copy(refl.matrixWorld).invert();
  const hide = [];   // meshes to hide while the reflection renders (the floors themselves)
  const ob = refl.onBeforeRender.bind(refl);
  const U = { on: { value: 1 } };
  refl.onBeforeRender = (...a) => { if (U.on.value < 0.5) return; for (const m of hide) m.visible = false; ob(...a); for (const m of hide) m.visible = true; };
  return { refl, inv, hide, U, tex: refl.getRenderTarget().texture, tm: refl.material.uniforms.textureMatrix.value };
}

// a floor material that adds the reflection. o: { map, rough, wet (mask), repeat, color, strength, damp, roughness }
export function wetMat(o, W) {
  const m = new THREE.MeshStandardMaterial({ color: o.color ?? 0xffffff, map: o.map, roughnessMap: o.rough, roughness: o.roughness ?? 0.85, metalness: 0, envMapIntensity: o.env ?? 0.35 });
  const U = {
    tRefl: { value: W.tex }, reflTM: { value: W.tm }, reflInv: { value: W.inv }, tWet: { value: o.wet }, strength: { value: o.strength ?? 0.95 },
    damp: { value: o.damp ?? 0.16 }, blurR: { value: o.blur ?? 0.006 }, on: W.U.on,
  };
  m.userData.U = U;
  m.onBeforeCompile = sh => {
    Object.assign(sh.uniforms, U);
    sh.vertexShader = sh.vertexShader.replace('#include <common>', '#include <common>\nuniform mat4 reflTM; uniform mat4 reflInv; varying vec4 vRefl; varying vec3 vWP;')
      .replace('#include <project_vertex>', '#include <project_vertex>\nvec4 wp4 = modelMatrix * vec4(transformed, 1.0); vWP = wp4.xyz; vRefl = reflTM * reflInv * wp4;');
    sh.fragmentShader = sh.fragmentShader.replace('#include <common>', '#include <common>\nuniform sampler2D tRefl, tWet; uniform float strength, damp, blurR, on; varying vec4 vRefl; varying vec3 vWP;\nfloat hh(vec2 p){ return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }')
      .replace('#include <opaque_fragment>', `
        {
          float wet = texture2D(tWet, vMapUv).r;
          vec3 Vd = normalize(cameraPosition - vWP);
          float fr = 0.06 + 0.94 * pow(1.0 - clamp(Vd.y, 0.0, 1.0), 4.0);
          vec2 uv = vRefl.xy / vRefl.w;
          float rip = (hh(floor(vWP.xz * 40.0)) - 0.5) * 0.0015;
          uv += vec2(rip, rip * 0.5);
          float r = mix(blurR * 2.6, blurR * 0.25, wet);
          vec3 rc = vec3(0.0);
          for (int i = 0; i < 12; i++) { float a = float(i) * 2.39996; float q = sqrt((float(i) + 0.5) / 12.0); rc += texture2D(tRefl, uv + vec2(cos(a), sin(a) * 0.55) * q * r).rgb; }
          rc /= 12.0;
          float k = mix(damp, strength, wet) * mix(0.35, 1.0, fr) * on;
          outgoingLight = outgoingLight * (1.0 - 0.55 * k) + rc * k;
        }
        #include <opaque_fragment>`);
  };
  m.customProgramCacheKey = () => 'wetfloor';
  return m;
}
```

### `engine/sublook.js`

```js
// Subway light: fluorescent tubes as rect area lights (a pool of them, placed per shot on the fixtures near the camera),
// one shadow-casting key spot that grounds the figures, a grey hemisphere, a studio environment for the gloss, and
// exponential haze that turns the far platform into milky grey. After midnight: a cold blue night. Engine file.
//   const LT = makeLights(E)   LT.aim(L, size)   LT.set({ hemi, hemiC, fog, fogC, env, key, tubes, tubeK })
import * as THREE from 'three';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { RectAreaLightUniformsLib } from 'three/addons/lights/RectAreaLightUniformsLib.js';

export function makeLights({ scene, renderer }) {
  RectAreaLightUniformsLib.init();
  const hemi = new THREE.HemisphereLight(0xd9dee4, 0x4a4844, 0.55); scene.add(hemi);
  const key = new THREE.SpotLight(0xf3f6ff, 0, 30, 0.7, 0.8, 1.4);
  key.castShadow = true; key.shadow.mapSize.set(2048, 2048); key.shadow.bias = -0.0004; key.shadow.normalBias = 0.02; key.shadow.radius = 6; key.shadow.camera.near = 0.3; key.shadow.camera.far = 30;
  scene.add(key, key.target);
  const fill = new THREE.DirectionalLight(0xffffff, 0); scene.add(fill, fill.target);
  const N = 12, rects = [];
  for (let i = 0; i < N; i++) { const r = new THREE.RectAreaLight(0xf2f6ff, 0, 0.14, 1.45); r.rotation.x = -Math.PI / 2; scene.add(r); rects.push(r); }
  const pm = new THREE.PMREMGenerator(renderer); scene.environment = pm.fromScene(new RoomEnvironment(), 0.04).texture; scene.environmentIntensity = 0.35;
  scene.fog = new THREE.FogExp2(0x8e9091, 0.028);
  const LT = {
    hemi, key, fill, rects,
    aim() { },
    // place the tube lights on fixture positions [[x,y,z], ...] (pointing down), intensity k
    tubes(list, k = 1, col = 0xf2f6ff) {
      rects.forEach((r, i) => { const p = list[i]; if (!p) { r.intensity = 0; return; } r.position.set(p[0], p[1] - 0.05, p[2]); r.rotation.set(-Math.PI / 2, 0, p[3] || 0); r.width = p[4] || 0.14; r.height = p[5] || 1.45; r.intensity = (p[6] ?? 26) * k; r.color.set(col); });
    },
    set(o = {}) {
      hemi.intensity = o.hemi ?? 0.55; hemi.color.set(o.hemiC ?? 0xd9dee4); hemi.groundColor.set(o.hemiG ?? 0x4a4844);
      scene.fog.color.set(o.fogC ?? 0x8e9091); scene.fog.density = o.fog ?? 0.028;
      scene.environmentIntensity = o.env ?? 0.35;
      const k = o.key;
      if (k) { key.intensity = k.i ?? 30; key.color.set(k.c ?? 0xf3f6ff); key.position.set(...k.p); key.target.position.set(...k.t); key.angle = k.a ?? 0.7; key.penumbra = k.pen ?? 0.8; key.distance = k.d ?? 30; key.castShadow = k.shadow !== false; }
      else key.intensity = 0;
      const f = o.fill;
      if (f) { fill.intensity = f.i; fill.color.set(f.c ?? 0xffffff); fill.position.set(...f.p); fill.target.position.set(...(f.t || [0, 0, 0])); } else fill.intensity = 0;
    },
  };
  return LT;
}
```

### `engine/overlay.js`

```js
// The 2D layer over the film (1080x1920 canvas): the reference's captions (white Inter Tight ExtraBold caps, 1-3 words, a soft
// drop shadow, a 3-frame pop, a high line and a low line), the choice pills ("1 GET ON" / "2 STAY": a dark pill with a white rim,
// a coloured number disc, a leader line that draws to a dot on the thing it means), the glossy streak on paper close-ups,
// and the RULES.md safe zone + its check. Engine file. Everything that draws text records its box (--safe stills, render scan).
import { seg, clamp, 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 };

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();
}

export const FONT = { cap: '"Inter Tight", "Segoe UI", Arial, sans-serif' };
// caption lines: hi = 0.689 H (the reference's default); lo = the bottom of the RULES band (the reference's own 0.839 H line sits
// under the app buttons), used while a sign, phone, board or note fills the middle of the frame
export const CAP = { size: 84, hi: 1323, lo: 1428, maxW: 800 };

// ---------------- captions ----------------
// film.chunks: { t0, t1, words: [i…], text?, y? }. A chunk shows from t0 to t1; the gaps between chunks are the reference's
// empty pauses. st.lo(t0) → true puts a chunk on the low line (decided when it appears, so it keeps its line across a cut).
export function captions(g, film, t, st = {}) {
  const c = film.chunks.find(c => t >= c.t0 && t < c.t1); if (!c) return;
  const text = c.text ?? c.words.map(i => film.words[i].cap).join(' ').toUpperCase();
  if (!text || text === '--') return;
  const f = Math.floor((t - c.t0) * 30 + 1e-4), op = f === 0 ? 0.75 : 1, sc = f === 1 ? 1.02 : 1;   // the pop: 75 % → 102 % → 100 %
  const lo = c.y ? c.y === 'lo' : !!st.lo?.(c.t0), y = lo ? CAP.lo : CAP.hi, size = CAP.size;
  g.save(); g.globalAlpha = op; g.font = `800 ${size}px ${FONT.cap}`; g.textAlign = 'center'; g.textBaseline = 'middle';
  const tw = g.measureText(text).width, k = Math.min(1, CAP.maxW / tw) * sc;
  g.translate(SAFE.cx, y); g.scale(k, k);
  g.shadowColor = 'rgba(0,0,0,0.6)'; g.shadowBlur = 14; g.shadowOffsetY = 4; g.fillStyle = '#ffffff'; g.fillText(text, 0, 0);
  g.shadowColor = 'rgba(0,0,0,0.35)'; g.shadowBlur = 4; g.shadowOffsetY = 2; g.fillText(text, 0, 0);
  g.restore();
  mark(g, 'caption', text, SAFE.cx - tw * k / 2, y - size * 0.55, tw * k, size * 1.1);
}

// ---------------- the choice pills ----------------
export const PILL = { h: 0.042 * OH, r: 0.3 * 0.042 * OH, disc: 0.047 * OW, font: 0.017 * OH / 0.72, getOnW: 0.257 * OW, stayW: 0.205 * OW, parkY: 0.171 * OH, parkL: 0.142 * OW, parkR: 0.626 * OW };
export const PILLCOL = { 1: '#2c7af3', 2: '#e6453c' };
// one pill: left edge x0, centre line cy, width w; a = opacity, sc = scale (about its centre)
export function pill(g, n, label, x0, cy, w, a = 1, sc = 1, col = PILLCOL[n] || '#2c7af3') {
  const h = PILL.h; g.save(); g.globalAlpha = a; g.translate(x0 + w / 2, cy); g.scale(sc, sc); g.translate(-w / 2, 0);
  g.beginPath(); g.roundRect(0, -h / 2, w, h, PILL.r); g.fillStyle = 'rgba(30,31,34,0.9)'; g.fill(); g.lineWidth = 3.5; g.strokeStyle = '#fff'; g.stroke();
  const dx = 0.038 * OW; g.beginPath(); g.arc(dx, 0, PILL.disc / 2, 0, 7); g.fillStyle = col; g.fill();
  g.fillStyle = '#fff'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.font = `800 ${PILL.disc * 0.62}px ${FONT.cap}`; g.fillText(String(n), dx, 1);
  g.textAlign = 'left'; g.font = `800 ${PILL.font}px ${FONT.cap}`; g.fillText(label, dx + PILL.disc / 2 + 0.018 * OW, 2);
  g.restore(); mark(g, 'pill', label, x0, cy - h / 2, w, h);
}
// the reference's pop: 45 % at 0.82 → 90 % at 1.0 → overshoot 1.066 → settled by 0.2 s
const popK = dt => dt < 0 ? null : dt < 1 / 30 ? [0.45, 0.82] : dt < 2 / 30 ? [0.9, 1.0] : dt < 4 / 30 ? [1, 1.066] : dt < 0.2 ? [1, lerp(1.03, 1.0, seg(dt, 4 / 30, 0.2))] : [1, 1];
// a choice: the pill pops at t0; with `to` (screen px) its line draws from the pill's bottom-centre between line0 and line1 and
// the dot pops a frame later. Without `to`: a parked pill. Leave t0/line0/line1 out for "already there" (a shot that starts with it).
export function choice(g, t, { n, label, w, x0, cy, t0 = -9, to = null, line0 = -9, line1 = -8, col }) {
  const p = popK(t - t0); if (!p) return;
  if (to && t >= line0) {
    const k = clamp((t - line0) / Math.max(1e-3, line1 - line0)), ax = x0 + w / 2, ay = cy + PILL.h / 2, [bx, by] = to;
    g.save(); g.strokeStyle = '#fff'; g.lineWidth = 2.6; g.beginPath(); g.moveTo(ax, ay); g.lineTo(lerp(ax, bx, k), lerp(ay, by, k)); g.stroke();
    if (t >= line1 + 1 / 30) { g.beginPath(); g.arc(bx, by, 0.011 * OW + 3, 0, 7); g.fillStyle = col || PILLCOL[n]; g.fill(); g.beginPath(); g.arc(bx, by, 0.011 * OW, 0, 7); g.fillStyle = '#fff'; g.fill(); }
    g.restore();
  }
  pill(g, n, label, x0, cy, w, p[0], p[1], col);
}
// both pills parked at the top (the reference's last shots)
export function parked(g, t, a = ['GET ON', 'STAY']) {
  choice(g, t, { n: 1, label: a[0], w: PILL.getOnW, x0: PILL.parkL, cy: PILL.parkY });
  choice(g, t, { n: 2, label: a[1], w: PILL.stayW, x0: PILL.parkR, cy: PILL.parkY });
}

// ---------------- the glossy streak ----------------
// a ceiling tube reflected in glossy paper: a soft white bar fixed on screen (y = a fraction of the height) in note close-ups
export function streak(g, y, x0 = 0.37, x1 = 0.81) {
  const Y = y * OH, X0 = x0 * OW, X1 = x1 * OW;
  g.save(); g.globalCompositeOperation = 'lighter'; g.filter = 'blur(3px)'; g.fillStyle = 'rgba(255,253,245,0.75)';
  g.beginPath(); g.moveTo(X0, Y - 7); g.lineTo(X1, Y - 11); g.lineTo(X1, Y + 5); g.lineTo(X0, Y + 9); g.fill(); 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: 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/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 ROOT ('.' = ROOT, 'example' = the example), must hold scene.js
export function videoDir(name, { need = 'scene.js' } = {}) {
  if (!name) die(`usage: node engine/${path.basename(process.argv[1])} <video folder> …   ('.' = this folder, 'example' = the example)`);
  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 new video: mkdir <name> and write its files (SKILL.md section 2)`);
  return { dir, rel };
}
export const DEFAULTS = {
  take: 'a',                     // vo/<take>.mp3|.wav|.json — the one voice take this video uses
  voOff: 0.3,                    // seconds the voice starts after frame 0
  tail: 1.2,                     // seconds after the last word (the final stare)
  capFix: {},                    // caption spelling fixes: { "5:30am": "5:30 A.M." }
  chunkMax: 3,                   // caption chunk: 1–3 words (the reference's chunks), broken after punctuation
  chunkW: 760,                   // and at most this wide
  capGap: 0.15,                  // a chunk stays this long after its last word when the next one starts later (pauses go blank)
  say: {},                       // pronunciation: { "5:30": "five thirty" } — the voice says the right side, captions show the left
  audio: 'mix',                  // 'mix' = voice + the drone bed + cues.mjs; 'as-is' = vo/<take>.wav is already the whole soundtrack
  music: { file: 'subway-drone.wav', from: 0, db: -8.5, muffle: [] }, // the synthesized subway drone (make-sfx.mjs), ~13 LU under the voice
  cover: 1.0,                    // cover.jpg = the frame at this time (the hook frame)
  // a calm, low storyteller (the reference's narrator): Brian on multilingual v2, a touch slower than normal
  voice: { id: 'nPczCjzI2devNBz1zQrb', name: 'Brian', model_id: 'eleven_multilingual_v2', voice_settings: { stability: 0.5, similarity_boost: 0.8, style: 0.3, use_speaker_boost: true, speed: 0.95 } },
};
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) {
  const line = fs.readFileSync(ENV_FILE, 'utf8').split(/\r?\n/).find(l => l.startsWith(name + '='));
  if (!line) die(`${name} is missing from the .env file`);
  return line.slice(name.length + 1).trim().replace(/^["']|["']$/g, '');
}
// sfx/music lookup: the video's own folder first, then this template's 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(), 'mannequin-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 voice options (an ElevenLabs key, edge-tts,
// or your own narration); downloads the caption font if missing and synthesizes the subway drone + the 10 sound effects.
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';
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>'));
const key = !!(process.env.ELEVENLABS_API_KEY || (fs.existsSync(ENV_FILE) && /^ELEVENLABS_API_KEY=./m.test(fs.readFileSync(ENV_FILE, 'utf8'))));
const edge = spawnSync('python', ['-c', 'import edge_tts'], { stdio: 'ignore' }).status === 0;
console.log(key ? '✓ ELEVENLABS_API_KEY: gen-vo.mjs can make the narration' : '· no ELEVENLABS_API_KEY (environment or .env): use edge-tts or your own recording instead');
console.log(edge ? '✓ edge-tts: gen-vo-edge.mjs can make a free narration' : '· edge-tts not installed (optional: pip install edge-tts, a free narration for drafts)');
console.log(hasWhisper() ? '✓ Whisper (faster-whisper): align.mjs and check.mjs will use it' : '✗ Whisper not installed (pip install faster-whisper): needed to time edge-tts or your own recording, and to check the final mix');
// the caption font: Inter Tight ExtraBold (SIL Open Font License), a static 800 from Fontsource's mirror of Google Fonts
const GET = [['fonts/InterTight-800.woff2', 'https://cdn.jsdelivr.net/fontsource/fonts/inter-tight@latest/latin-800-normal.woff2']];
for (const [rel, url] of GET) {
  const f = path.join(ASSETS, rel); if (fs.existsSync(f) || fs.existsSync(f.replace('.woff2', '.ttf'))) { 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 subway drone + the sound effects (ffmpeg synthesis, no downloads)
spawnSync(process.execPath, [path.join(import.meta.dirname, 'make-sfx.mjs')], { stdio: 'inherit' });
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/make-sfx.mjs`

```js
// The Mannequin sound kit, synthesized with ffmpeg (no credits, no downloads): the looping subway drone bed and 10 effects.
//   node engine/make-sfx.mjs [--force]      → assets/music/subway-drone.wav + assets/sfx/<name>.wav (an existing file is kept)
// drone   60 s seamless loop: a dark room tone (brown noise under 200 Hz), a 50 Hz mains hum with a slow swell, a faint tube hiss
// slam    a metal gate slamming shut (inharmonic clang + a noise crack)      step    one heavy footfall on concrete
// rumble  a train coming out of a tunnel (4 s, low, rising)                  whoosh  a train nose passing close (1.6 s)
// clunk   a relay: a light going out                                         door    sliding doors (0.6 s hiss + a soft thump)
// hit     a low boom under a reveal                                          tick    a soft UI tick (the choice pills)
// breath  a slow breath on the back of the neck (2 s)                        buzz    a fluorescent tube's flicker (0.4 s)
import fs from 'node:fs'; import path from 'node:path';
import { ASSETS, args, run, low } from './common.mjs';
low();
const { opt } = args();
const make = (dir, name, a) => { const f = path.join(ASSETS, dir, name + '.wav'); fs.mkdirSync(path.dirname(f), { recursive: true }); if (fs.existsSync(f) && !opt.force) return console.log('kept', name); run('ffmpeg', ['-v', 'error', '-y', ...a(f)]); console.log('wrote', name); };
const ST = ['-ar', '48000', '-ac', '2'];
make('music', 'subway-drone', f => ['-f', 'lavfi', '-i', 'anoisesrc=d=60:c=brown:a=0.8:seed=21', '-f', 'lavfi', '-i', "aevalsrc='(0.28*sin(2*PI*50*t)+0.1*sin(2*PI*100*t)+0.05*sin(2*PI*150*t))*(0.75+0.25*sin(2*PI*t/15))':s=48000:d=60", '-f', 'lavfi', '-i', 'anoisesrc=d=60:c=pink:a=0.05:seed=4',
  '-filter_complex', '[0]lowpass=f=200,lowpass=f=200,volume=1.6[a];[2]highpass=f=5000,lowpass=f=9000,volume=0.6[c];[a][1][c]amix=inputs=3:normalize=0,volume=0.9', ...ST, f]);
make('sfx', 'slam', f => ['-f', 'lavfi', '-i', "aevalsrc='(0.6*sin(2*PI*183*t)+0.45*sin(2*PI*437*t)+0.35*sin(2*PI*811*t)+0.25*sin(2*PI*1290*t))*exp(-t*6)+0.8*(random(0)-0.5)*exp(-t*40)':s=48000:d=1.6", '-af', 'lowpass=f=5000,volume=1.1,afade=t=out:st=1.2:d=0.4', ...ST, f]);
make('sfx', 'step', f => ['-f', 'lavfi', '-i', "aevalsrc='(0.9*sin(2*PI*70*t)*exp(-t*30)+0.6*(random(0)-0.5)*exp(-t*55))':s=48000:d=0.35", '-af', 'lowpass=f=1800,highpass=f=40,volume=1.6', ...ST, f]);
make('sfx', 'rumble', f => ['-f', 'lavfi', '-i', 'anoisesrc=d=4:c=brown:a=1:seed=8', '-af', "lowpass=f=300,lowpass=f=300,volume='0.15+1.6*pow(t/4,2)':eval=frame,afade=t=out:st=3.7:d=0.3", ...ST, f]);
make('sfx', 'whoosh', f => ['-f', 'lavfi', '-i', 'anoisesrc=d=1.6:c=pink:a=1:seed=13', '-af', "lowpass=f=2500,volume='1.8*exp(-pow((t-0.55)/0.28,2))+0.25':eval=frame,afade=t=in:d=0.05,afade=t=out:st=1.2:d=0.4", ...ST, f]);
make('sfx', 'clunk', f => ['-f', 'lavfi', '-i', "aevalsrc='(0.8*sin(2*PI*95*t)*exp(-t*35)+0.5*sin(2*PI*1900*t)*exp(-t*90)+0.4*(random(0)-0.5)*exp(-t*70))':s=48000:d=0.3", '-af', 'lowpass=f=4000,volume=1.4', ...ST, f]);
make('sfx', 'door', f => ['-f', 'lavfi', '-i', 'anoisesrc=d=0.65:c=pink:a=0.8:seed=17', '-f', 'lavfi', '-i', "aevalsrc='0.7*sin(2*PI*80*t)*exp(-t*25)':s=48000:d=0.65",
  '-filter_complex', "[0]highpass=f=900,lowpass=f=6000,volume='sin(PI*t/0.55)*1.2':eval=frame,afade=t=out:st=0.45:d=0.2[a];[1]adelay=520|520[b];[a][b]amix=inputs=2:normalize=0", ...ST, f]);
make('sfx', 'hit', f => ['-f', 'lavfi', '-i', "aevalsrc='(1.0*sin(2*PI*48*t)+0.4*sin(2*PI*96*t))*exp(-t*3.5)+0.3*(random(0)-0.5)*exp(-t*25)':s=48000:d=1.8", '-af', 'lowpass=f=600,volume=1.3,afade=t=out:st=1.4:d=0.4', ...ST, f]);
make('sfx', 'tick', f => ['-f', 'lavfi', '-i', "aevalsrc='0.6*sin(2*PI*1750*t)*exp(-t*70)+0.3*sin(2*PI*3300*t)*exp(-t*110)':s=48000:d=0.12", '-af', 'volume=0.9', ...ST, f]);
make('sfx', 'breath', f => ['-f', 'lavfi', '-i', 'anoisesrc=d=2:c=pink:a=0.7:seed=29', '-af', "highpass=f=300,lowpass=f=2600,volume='pow(sin(PI*t/2),2)*1.1':eval=frame", ...ST, f]);
make('sfx', 'buzz', f => ['-f', 'lavfi', '-i', "aevalsrc='(0.4*sin(2*PI*100*t)+0.25*sin(2*PI*200*t)+0.15*sin(2*PI*300*t))*gt(sin(2*PI*13*t),-0.2)':s=48000:d=0.4", '-af', 'highpass=f=80,volume=0.8,afade=t=out:st=0.3:d=0.1', ...ST, f]);
```

### `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/gen-vo-edge.mjs`

```js
// A free narration with Microsoft's neural voices (the edge-tts Python package): drafts, tests, or when there's no ElevenLabs.
//   node engine/gen-vo-edge.mjs <video> [--voice en-GB-RyanNeural] [--rate -6%] [--pitch -4Hz]   → <video>/vo/<take>.mp3
// Then `node engine/align.mjs <video>`: with no ElevenLabs alignment it times the script words by Whisper alone.
// Check the voice's terms before you publish a video made with it. Your own recording works the same: put it in vo/<take>.wav.
import fs from 'node:fs'; import path from 'node:path';
import { args, videoDir, loadVideo, spokenText, run, low } from './common.mjs';
low();
const { pos, opt } = args(); const { dir } = videoDir(pos[0], { need: 'script.txt' });
const cfg = loadVideo(dir), vo = path.join(dir, 'vo'), out = path.join(vo, `${cfg.take}.mp3`);
fs.mkdirSync(vo, { recursive: true });
if (fs.existsSync(out) && !opt.force) { console.log(`vo/${cfg.take}.mp3 exists (--force to remake it)`); process.exit(0); }
const text = spokenText(dir, cfg), tf = path.join(vo, `${cfg.take}.txt`); fs.writeFileSync(tf, text);
run('python', ['-m', 'edge_tts', '--voice', opt.voice || 'en-GB-RyanNeural', `--rate=${opt.rate || '-6%'}`, `--pitch=${opt.pitch || '-4Hz'}`, '-f', tf, '--write-media', out]);
for (const f of [path.join(vo, `${cfg.take}.wav`), path.join(vo, `${cfg.take}-whisper.json`)]) fs.rmSync(f, { force: true });   // align.mjs remakes them
console.log(`wrote vo/${cfg.take}.mp3 (${text.split(/\s+/).length} words). Next: 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), or — for your own recorded
// narration — from Whisper alone (vo/<take>-whisper.json, made by align.mjs).
// 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 };
}

// script words timed by Whisper alone: an LCS match of the script's tokens to Whisper's words; unmatched tokens share the time
// between their matched neighbours. Returns [{ w, s, e, i }] like words().
export function fromWhisper(text, file) {
  const X = JSON.parse(fs.readFileSync(file, 'utf8')).segments.flatMap(s => s.words).map(([s, e, w]) => ({ s, e, n: norm(w) })).filter(x => x.n);
  const T = text.split(/\s+/).filter(Boolean).map((w, i) => ({ w, n: norm(w), i }));
  const D = Array.from({ length: T.length + 1 }, () => new Int32Array(X.length + 1));
  for (let i = T.length - 1; i >= 0; i--) for (let j = X.length - 1; j >= 0; j--) D[i][j] = T[i].n && T[i].n === X[j].n ? D[i + 1][j + 1] + 1 : Math.max(D[i + 1][j], D[i][j + 1]);
  for (let i = 0, j = 0; i < T.length && j < X.length;) { if (T[i].n && T[i].n === X[j].n) { T[i].s = X[j].s; T[i].e = X[j].e; i++; j++; } else if (D[i + 1][j] >= D[i][j + 1]) i++; else j++; }
  for (let i = 0; i < T.length; i++) if (T[i].s == null) {
    let a = i - 1; while (a >= 0 && T[a].s == null) a--; let b = i + 1; while (b < T.length && T[b].s == null) b++;
    const t0 = a >= 0 ? T[a].e : (X[0]?.s ?? 0), t1 = b < T.length ? T[b].s : t0 + 0.4 * (b - a - 1), n = b - a - 1, k = i - a - 1;
    T[i].s = +(t0 + (t1 - t0) * k / n).toFixed(3); T[i].e = +(t0 + (t1 - t0) * (k + 1) / n).toFixed(3);
  }
  const m = T.filter(x => x.n).length, hit = D[0][0]; console.log(`whisper alignment: ${hit}/${m} script words matched`);
  return T.map(x => ({ w: x.w, s: x.s, e: x.e, i: x.i }));
}

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`);
  let W;
  if (fs.existsSync(al)) W = words(al);
  else if (fs.existsSync(wh)) W = fromWhisper(spokenText(dir, cfg), wh);   // your own narration: vo/<take>.wav + Whisper, no ElevenLabs
  else die(`vo/${TAKE}.json (ElevenLabs) or vo/${TAKE}-whisper.json (run align.mjs on your own vo/${TAKE}.wav) missing`);
  if (fs.existsSync(al) && 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 if (fs.existsSync(al)) 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: "five thirty a m" 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) }));
  const GAP = cfg.capGap ?? 0.15;   // the reference: a chunk leaves soon after its last word, so pauses have no caption
  CH.forEach((c, k) => { const next = CH[k + 1]; c.t1 = +Math.min(next ? next.t0 : c.end + 1.2, c.end + GAP).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]} (or put your own narration in vo/${take}.wav)`); 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, which is 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> stills shots [from] [to] --safe  (the middle of every shot, or shots from..to-1)
//   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 MANNEQUIN_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\mannequin 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.MANNEQUIN_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 shotMids = pos[2] === 'shots' ? (await api.eval('window.SHOTLIST')).map(([a, b]) => +((a + Math.min(b, TOTAL)) / 2).toFixed(2)) : null;
    const times = shotMids ? shotMids.slice(+(pos[3] || 0), pos[4] ? +pos[4] : undefined) : 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/InterTight-800.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/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/mix.mjs`

```js
// The mix: voice on top, the looping drone 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: 'film.json' });
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 }); };
if (fs.existsSync(path.join(dir, 'cues.mjs'))) { 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]}`);
if (cfg.audio === 'as-is') {   // the take already is the whole soundtrack (the example: the reference's own audio): only level it
  const pre0 = path.join(dir, 'mix_pre.wav');
  execFileSync('ffmpeg', ['-v', 'error', '-y', '-i', voWav, '-af', `aresample=48000,aformat=channel_layouts=stereo,apad=whole_dur=${LEN.toFixed(3)},atrim=0:${LEN.toFixed(3)}`, pre0]);
  let gain = -14 - loud(pre0).I, fin;
  for (let k = 0; k < 3; k++) { execFileSync('ffmpeg', ['-v', 'error', '-y', '-i', pre0, '-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(pre0, { force: true });
  console.log(`${OUT} (as-is): I ${fin.I} LUFS · true peak ${fin.TP} dBTP · LRA ${fin.LRA} LU`, Math.abs(fin.I + 14) <= 1 && fin.TP <= -1 ? '✓' : '✗ check');
  process.exit(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, '-stream_loop', '-1', '-ss', String(MUSIC_FROM), '-t', String(LEN + 0.5), '-i', music];   // the bed loops
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: a starter video and the shot library

**`my-video/`**: a complete, working 4-shot video (≈ 20 s): behind him walking down the lit platform, his worried face, the note in the booth with the red box drawing round rule 1, then the dark with the black one at his shoulder and both choices parked while the camera pushes in. Its cuts fall at the quarters of the words, so it works with any script; replace the shots with your own, on `T('word')` cuts. Make it with `gen-vo-edge.mjs` (free) or `gen-vo.mjs --yes`, then `align.mjs`.

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

```text
# What the narrator reads, one sentence per line. Lines starting with # are ignored.
Imagine you're the last one on the platform at Ashford Road.
Your phone is dead, and the next train doesn't come until 5:30.
In the driver's booth, there's a note. Never board an empty train.
Then every light goes out, and something is standing right behind you.
You have two choices. So, what would you do?
```

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

```json
{
  "take": "a",
  "say": { "5:30": "five thirty" },
  "cover": 1.0
}
```

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

```js
// THE DIRECTOR FILE of a new Mannequin video (written by new-video.mjs). A working starter: replace the shots with your own,
// one shot per caption phrase or two (the reference cuts every ~2 s, on sentence starts), each a slow smoothstep push-in.
// scene.example.js next to this file is the full 47-shot example: copy shots from it (the platform, the booth, the note, the
// clock, the lights dying, the tunnel, the train, the choice pills) and move them onto your own times.
// T('word', n) = when the n-th caption word `word` starts (case-insensitive). The world, the cast and the helpers come from E.K.
export default function setup(E) {
  const { THREE, L, O, FX, film, LT, MQ, K } = E;
  const { ss, seg, lerp, clamp, easeOut, easeIn } = L;
  const { S, B, st, bo, tr, you, it, lightsOn, night, moon, boothLights, trainAt, carLights, stopFor, walkPath, walkPose, STAND, hand, proj, V3 } = K;
  const sm = L.smooth, ssm = (t, a, b) => sm(seg(t, a, b));
  const norm = s => s.toLowerCase().replace(/[^a-z0-9:'.]/g, '').replace(/\.$/, '');
  const T = (w, n = 1) => { const k = norm(w), 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 END = film.END, W = film.words;
  // cut points: the start of the 2nd, 3rd, 4th quarter of the words (replace with T('word') cuts on your own script)
  const q = k => W[Math.min(W.length - 1, Math.floor(W.length * k))].t0 - 0.05;
  const c1 = q(0.25), c2 = q(0.5), c3 = q(0.75);

  const SH = [];
  // shot(t0, t1, cam, run): cam = { P: [[t, [x,y,z]], …], L: [[t, [x,y,z]], …], fov, ap (blur), focus?, blur? (max px) }; run(t) poses the world
  const shot = (t0, t1, cam, run) => SH.push({ t: [t0, t1], P: cam.P.map(k => [k[0], k[1], k[2] || sm]), L: cam.L.map(k => [k[0], k[1], k[2] || sm]), fov: cam.fov ?? 40, ap: cam.ap ?? 1, focus: cam.focus, frameY: 0.5, blur: cam.blur, run, lo: cam.lo });
  const move = (t0, t1, p0, p1, l0, l1 = l0) => ({ P: [[t0, p0], [t1, p1]], L: [[t0, l0], [t1, l1]] });

  // 1. behind him walking down the lit platform toward the tunnel, a slow dolly
  shot(0, c1, { ...move(0, c1, [-0.55, 1.5, 4.3], [-0.75, 1.5, 3.3], [-0.35, 1.1, -40]), fov: 62, ap: 0.5 }, t => {
    lightsOn([0, 0, -3], { key: { p: [-0.2, 3.9, 0.5], t: [-0.4, 0, -3], i: 26, a: 0.9 } });
    MQ.pose(you, walkPose(walkPath(t, -0.05, c1, [-0.42, 0.2], [-0.5, -1.4 * c1], k => k)));
  });
  // 2. frontal close-up of his face against the tiles, worried; a slow push
  shot(c1, c2, { ...move(c1, c2, [1.75, 1.6, -14], [1.83, 1.61, -14], [2.55, 1.68, -14]), fov: 30, ap: 30, focus: 0.66, blur: 40 }, t => {
    lightsOn([2.55, 0, -14], { key: { p: [0.6, 3.8, -14.6], t: [2.55, 1.4, -14], i: 18 } });
    MQ.pose(you, { x: 2.55, z: -14, yaw: -Math.PI / 2, ...STAND, face: { eyes: 'dot', brow: t > (c1 + c2) / 2 ? 'worried' : 'flat', mouth: 'flat' } });
  });
  // 3. the note in the booth (the low caption line while the paper fills the frame)
  const NOTE = V3(B.x + 0.45, 1.68, B.z - 1.13);
  shot(c2, c3, { P: [[c2, [NOTE.x, 1.82, NOTE.z + 1.55]], [c3, [NOTE.x, 1.79, NOTE.z + 1.4]]], L: [[c2, [NOTE.x, 1.835, NOTE.z]], [c3, [NOTE.x, 1.8, NOTE.z]]], fov: 26, ap: 16, focus: 1.48, blur: 30, lo: true }, t => {
    boothLights({ key: null, hemi: 0.22 }); bo.inLight.intensity = 0.45; bo.note.tex.draw(ssm(t, c2 + 0.3, c2 + 1.0));
  });
  // 4. the dark: him frightened in front of the black one, both choices parked; the long push into his eyes
  shot(c3, END + 1, { P: [[c3, [-0.05, 1.6, -12.12]], [END, [1.12, 1.67, -12.02], easeIn]], L: [[c3, [1.5, 1.6, -12.08]], [END, [1.5, 1.69, -12.02]]], fov: 34, ap: 3 }, t => {
    night({ hemi: 0.05, key: moon([-1.1, 2.6, -12.8], [1.5, 1.5, -12], 3.4, 0.35) });
    MQ.pose(you, { x: 1.5, z: -12, yaw: -Math.PI / 2, armL: { f: 0.3, o: 0.5, e: 1.75, tw: -1.3 }, armR: { f: 0.3, o: 0.5, e: 1.75, tw: -1.3 }, face: { eyes: 'ring', brow: 'worried', mouth: 'o' } });
    MQ.pose(it, { x: 2.1, z: -11.5, yaw: -Math.PI / 2 - 0.2, ...STAND });
  });
  const SHOTS = SH;

  function world(tq, t, si) { K.reset(); const s = SH[si]; if (s?.run) s.run(t); E.post.U.maxBlur.value = s?.blur ?? 18; }
  const shotAt = t => SH.find(s => t >= s.t[0] && t < s.t[1]);
  function overlay(g, t) {
    if (t >= c3) O.parked(g, t);                                  // the choices, parked at the top (the CTA can be a pill too)
    O.captions(g, film, t, { lo: t0 => !!shotAt(t0)?.lo });      // a chunk sits low while its shot says lo
  }
  // the grade: lights-on grey, cold navy at night, warm on paper
  function effects(t) {
    const P = E.post, s = shotAt(t), nightK = t >= c3, noteK = !!s?.lo;
    P.U.warm.value = nightK ? -0.4 : noteK ? 0.4 : 0.04; P.U.sat.value = nightK ? 0.6 : noteK ? 0.55 : 0.62;
    P.U.bloom.value = nightK ? 0.7 : 0.45; P.U.exposure.value = nightK ? 0.95 : noteK ? 0.62 : 0.56; P.U.con.value = nightK ? 1.1 : 1.04; P.U.lift.value = nightK ? 0 : 0.01;
    P.F.vig.value = noteK ? 0.85 : nightK ? 0.55 : 0.4; P.F.grain.value = nightK ? 0.016 : 0.008; P.F.ca.value = 0.25;
    FX.apply(E, t, {});
  }
  return { SHOTS, world, overlay, effects };
}
```

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

```js
// Sound effects on the picture's events: ev(name, time, dB, maxDur?). The kit: slam step rumble whoosh clunk door hit tick breath buzz.
export default function (ev, film) {
  const T = (w, n = 1) => { const k = w.toLowerCase(); const h = film.words.filter(v => v.cap.toLowerCase().replace(/[^a-z0-9:']/g, '') === k); return h[n - 1]?.t0 ?? -1; };
  ev('buzz', T('booth') - 0.3, -18);                           // a tube flickers over the note
  ev('clunk', T('out') - 0.35, -10); ev('clunk', T('out') - 0.1, -8);   // the lights die
  ev('hit', T('standing') - 0.1, -6); ev('breath', T('behind'), -14);   // it is there
  ev('tick', T('two') + 0.03, -16);                             // the choices
}
```


**`shots/subway-example.js`**: the 47-shot library (section 7.5). It was directed on a 98.6-second narration of the full subway story, so its times are absolute seconds: copy shots out of it into your scene.js and move their times onto your words. Don't load it as a scene.

### `shots/subway-example.js`

```js
// THE SHOT LIBRARY — a complete 98.6-second Mannequin film in 47 shots ("Imagine you miss the last train home"), directed on a
// narration of the full subway story: every cut, move, expression and graphic at its own time. A white artist's mannequin misses
// the last train at Ashford Road; a black one comes up behind him; 1 GET ON / 2 STAY. Copy shots out of it and move their times
// onto your own words. setup(E) returns SHOTS, world(tq, t, shot), overlay(g, t), effects(t); the world and helpers come from E.K.
export default function setup(E) {
  const { THREE, camera, L, O, FX, film, LT, MQ, ST, TR, TX, K } = E;
  const { ss, seg, lerp, clamp, easeOut, easeIn, easeInOut } = L;
  const { S, B, st, bo, tr, you, it, phone, PS, CH, endBatten, pole, floorPool, FIX, BAT, near, ON, lightsOn, night, moon,
    CARLEN, trainAt, walkPath, walkPose, STAND, hand, proj, phonePose, holdPhone, V3 } = K;
  const sm = L.smooth;   // smoothstep: the reference's move curve (starts and ends at rest)
  const ssm = (t, a, b) => sm(seg(t, a, b));
  const bootLights = K.boothLights;
  // the ghost train stops with car 2's front door at DZ (the sign and the seats behind the doorway)
  const DZ = -7.4, NOSE_STOP = K.stopFor(DZ);
  const carLights = (k = 1, nose = NOSE_STOP) => K.carLights(nose, k);

  // ===================== SHOTS =====================
  const SH = [];
  const shot = (t0, t1, cam, run) => SH.push({ t: [t0, t1], P: cam.P.map(k => [k[0], k[1], k[2] || sm]), L: cam.L.map(k => [k[0], k[1], k[2] || sm]), fov: cam.fov ?? 40, ap: cam.ap ?? 1, focus: cam.focus, frameY: cam.frameY ?? 0.5, blur: cam.blur, run, cam });
  const still = (t0, p, l) => ({ P: [[t0, p]], L: [[t0, l]] });
  const move = (t0, t1, p0, p1, l0, l1 = l0) => ({ P: [[t0, p0], [t1, p1]], L: [[t0, l0], [t1, l1]] });

  // S01 0–2.467: behind the walking mannequin, the last train pulls away ahead of him
  shot(0, 2.467, { ...move(0, 2.35, [-0.55, 1.5, 4.3], [-0.78, 1.5, 3.1], [-0.35, 1.1, -40], [-0.45, 1.1, -40]), fov: 62, ap: 0.5 }, t => {
    lightsOn([0, 0, -3], { key: { p: [-0.2, 3.9, 0.5], t: [-0.4, 0, -3], i: 26, a: 0.9 } });
    const w = walkPath(t, -0.05, 2.15, [-0.42, 0.2], [-0.5, -8.2], k => k);
    MQ.pose(you, walkPose(w, 1 - seg(t, 1.85, 2.2) * 0.9));
    const tt = t - 1.0, rear = tt < 0 ? 5.6 - 4.4 * t : 1.2 - 6 * tt - 13 * tt * tt;
    trainAt(rear - CARLEN, { inside: 1, rTail: 1, rHead: 0.5 });
  });
  // S02 2.467–3.900: locked off at the south end; the tail slides into the tunnel past him
  shot(2.467, 3.9, { ...still(2.467, [1.35, 1.3, -34.2], [-2.3, 1.05, -46]), fov: 52, ap: 0.6 }, t => {
    lightsOn([0, 0, -40], { key: { p: [0.4, 3.9, -38.5], t: [-1.2, 0, -40.5], i: 26 } });
    MQ.pose(you, { x: -1.15, z: -40.6, yaw: Math.PI + 0.15, ...STAND });
    const tt = t - 2.467, rear = -29.5 - 14 * tt - 3.5 * tt * tt;
    trainAt(rear - CARLEN, { inside: 1, rTail: 1, rHead: 0.6 });
  });
  // S03 3.900–6.067: the exit; the gate slams on "slam" (4.20–4.30) + a white flash, his arm snaps up
  shot(3.9, 6.067, { P: [[3.9, [0.35, 1.2, 21.2]], [4.467, [0.35, 1.2, 21.2]], [6.03, [0.45, 1.22, 21.95]]], L: [[3.9, [1.75, 1.3, 28]], [4.467, [1.75, 1.3, 28]], [6.03, [1.78, 1.32, 28]]], fov: 58, ap: 0.8 }, t => {
    lightsOn([1, 0, 24], { key: { p: [1.4, 3.9, 23.6], t: [1.7, 0, 25.4], i: 22 } });
    st.gate(ss(t, 4.2, 4.3));
    const up = ss(t, 4.3, 4.4);
    MQ.pose(you, { x: 1.75, z: 25.3, yaw: -0.08, armL: { o: 0.04, e: 0.12 }, armR: { f: lerp(0, 1.3, up), o: lerp(0.04, 0.12, up), e: lerp(0.12, 0.3, up) } });
  });
  // S04 6.067–7.800: the station sign from low, trucking left
  shot(6.067, 7.8, { ...move(6.067, 7.75, [0.25, 0.95, -5.85], [0.25, 0.95, -6.05], [3.3, 2.3, -5.85], [3.3, 2.3, -6.05]), fov: 62, ap: 0.4 }, t => {
    lightsOn([2, 0, -6], { key: { p: [1.0, 4.0, -4.5], t: [3.3, 2, -6], i: 12 } });
    st.seats[0].visible = false;
  });
  // S05 7.800–9.567: high wide, tiny figure on the platform edge in profile; very slow push
  shot(7.8, 9.567, { ...move(7.8, 9.567, [2.9, 4.05, -2.5], [2.78, 3.98, -3.05], [-3.6, 0.7, -16.8]), fov: 58, ap: 0.3 }, t => {
    lightsOn([0, 0, -12], { key: { p: [-0.5, 4.0, -12.5], t: [-1.7, 0, -14], i: 26 } });
    MQ.pose(you, { x: -1.72, z: -14.2, yaw: 0, ...STAND });
  });
  // S06 9.567–11.000: frontal medium, the face; the phone comes up beside his head (9.90–10.17), brows to angry (10.23–10.43)
  const ME6 = [2.55, 0, -14.0];
  shot(9.567, 11.0, { P: [[9.567, [ME6[0] - 1.95, 1.45, ME6[2] - 0.1]], [11.0, [ME6[0] - 1.8, 1.47, ME6[2] + 0.16]]], L: [[9.567, [ME6[0], 1.5, ME6[2]]]], fov: 38, ap: 1.8 }, t => {
    lightsOn([ME6[0], 0, ME6[2]], { key: { p: [0.6, 3.8, ME6[2] - 0.6], t: [ME6[0], 1, ME6[2]], i: 20 } });
    const up = ss(t, 9.9, 10.17), ang = ss(t, 10.23, 10.43);
    MQ.pose(you, { x: ME6[0], z: ME6[2], yaw: -Math.PI / 2, ...phonePose(up), face: { eyes: 'dot', brow: ang > 0.5 ? 'angry' : 'flat', mouth: 'flat' } });
    holdPhone(up);
  });
  // S07 11.000–12.600 + S08 12.600–13.533: the phone inserts, locked off; the hand lifts the phone toward the lens
  const PH = [2.0, 0.8, 10.0];
  const phoneShot = (t0, t1, mode, time) => shot(t0, t1, { ...still(t0, [PH[0] - 0.44, PH[1] + 0.05, PH[2]], [PH[0], PH[1] + 0.03, PH[2]]), fov: 34, ap: 60, focus: 0.43, blur: 46 }, t => {
    lightsOn([PH[0], 0, PH[2]], { key: { p: [PH[0] - 1.2, 2.8, PH[2] - 0.8], t: [PH[0], 0.9, PH[2]], i: 10, shadow: false } });
    PS.draw(mode, time);
    const k = ssm(t, t0 + 0.03, t1);
    CH.visible = true; CH.position.set(PH[0] - k * 0.055, PH[1] + k * 0.014, PH[2]); CH.rotation.set(0, -Math.PI / 2, 0); CH.rotateX(-0.08 + k * 0.03);
  });
  phoneShot(11.0, 12.6, 'battery', '23:41');
  phoneShot(12.6, 13.533, 'signal', '23:42');
  // S09 13.533–15.033: extreme close-up of the face, angry, slow push
  shot(13.533, 15.033, { ...move(13.533, 15.0, [ME6[0] - 0.64, 1.6, ME6[2]], [ME6[0] - 0.6, 1.62, ME6[2]], [ME6[0], 1.68, ME6[2]]), fov: 30, ap: 40, focus: 0.49, blur: 40 }, t => {
    lightsOn([ME6[0], 0, ME6[2]], { key: { p: [0.6, 3.8, ME6[2] - 0.6], t: [ME6[0], 1.4, ME6[2]], i: 18 } });
    MQ.pose(you, { x: ME6[0], z: ME6[2], yaw: -Math.PI / 2, ...phonePose(1), face: { eyes: 'dot', brow: 'angry', mouth: 'flat' } });
    holdPhone(1);
  });
  // S10 15.033–16.867: low from behind, the board just above his head, locked off
  shot(15.033, 16.867, { ...still(15.033, [0.62, 0.92, -1.75], [0.7, 2.55, -9]), fov: 52, ap: 0.8 }, t => {
    lightsOn([0.5, 0, -5], { key: { p: [0.5, 4.0, -2.5], t: [0.65, 0, -3.8], i: 20 } });
    MQ.pose(you, { x: 0.68, z: -3.6, yaw: Math.PI, head: [-0.05, 0, 0], ...STAND });
  });
  // S11 16.867–19.867: the departure board, dolly in (eases in, nearly still until 17.4)
  shot(16.867, 19.867, { P: [[16.867, [0.7, 1.55, -4.05]], [17.4, [0.7, 1.56, -4.12]], [19.8, [0.7, 1.95, -5.3]]], L: [[16.867, [0.7, 3.05, -9]]], fov: 40, ap: 0.5 }, t => {
    lightsOn([0.5, 0, -9], { key: { p: [0.5, 4.0, -7], t: [0.7, 0, -10], i: 14 } });
  });
  // S12 19.867–21.367: along the wall; he walks to the seats, pivots, backs up to the bench
  const SEAT = [2.48, -6.35];
  shot(19.867, 21.367, { ...move(19.867, 21.3, [-0.3, 1.4, -1.9], [-0.15, 1.4, -2.3], [3.0, 0.9, -5.4], [3.0, 0.9, -6.6]), fov: 52, ap: 0.6 }, t => {
    lightsOn([2, 0, -6], { key: { p: [1.2, 4.0, -5.0], t: [2.3, 0, -6.2], i: 22 } });
    let p;
    if (t < 20.4) { const w = walkPath(t, 19.2, 20.4, [0.75, -2.9], [1.95, -6.0], k => k); p = walkPose(w, 1); }
    else if (t < 20.67) { const k = ssm(t, 20.4, 20.67); p = { x: 1.95, z: -6.0, yaw: lerp(Math.atan2(1.2, -3.1), Math.atan2(1.2, -3.1) - Math.PI, k), ...STAND }; }
    else { const w = walkPath(t, 20.67, 21.33, [1.95, -6.0], [SEAT[0] - 0.1, SEAT[1]], k => k); p = MQ.merge({ x: w.x, z: w.z, yaw: Math.atan2(1.2, -3.1) - Math.PI }, MQ.walk(w.ph * 1.6, 0.4)); }
    MQ.pose(you, { ...p, face: { eyes: 'dot', brow: 'sad', mouth: 'flat' } });
  });
  // S13 21.367–24.000: frontal on the bench, slumped and sad; dolly back
  const slump = { x: SEAT[0], z: SEAT[1], yaw: -Math.PI / 2, ...MQ.SIT(0.48), spine: [0.32, 0, 0], chest: [0.12, 0, 0], head: [0.12, 0, 0.14],
    armL: { f: 0.55, o: 0.06, e: 1.05 }, armR: { f: 0.55, o: 0.06, e: 1.05 }, face: { eyes: 'small', brow: 'sad', mouth: 'flat', gaze: [0, -1] } };
  shot(21.367, 24.0, { ...move(21.367, 24.0, [-0.9, 1.15, SEAT[1]], [-2.0, 1.22, SEAT[1]], [SEAT[0] + 0.3, 0.8, SEAT[1]]), fov: 46, ap: 0.8 }, t => {
    lightsOn([2, 0, -6.4], { key: { p: [1.4, 4.0, -5.8], t: [2.4, 0, -6.4], i: 22 } });
    MQ.pose(you, slump);
  });
  // S14 24.000–26.333: close on his head from the right-front; he turns ~70° to his left on "notice" (24.70–25.00)
  shot(24.0, 26.333, { P: [[24.0, [SEAT[0] - 1.18, 1.22, SEAT[1] - 0.45]], [24.6, [SEAT[0] - 1.18, 1.22, SEAT[1] - 0.45]], [26.3, [SEAT[0] - 1.1, 1.23, SEAT[1] - 0.42]]], L: [[24.0, [SEAT[0] - 0.05, 1.05, SEAT[1] + 0.12]]], fov: 34, ap: 6 }, t => {
    lightsOn([2, 0, -6.4], { key: { p: [1.4, 4.0, -5.8], t: [2.4, 0.8, -6.4], i: 20 } });
    const k = ssm(t, 24.7, 25.0);
    MQ.pose(you, { ...slump, head: [lerp(0.12, -0.14, k), lerp(0, 0.78, k), lerp(0.14, 0, k)], face: { eyes: 'dot', brow: k > 0.5 ? 'raise' : 'sad', mouth: 'flat', gaze: k > 0.5 ? [0.4, 0.1] : [-0.5, -0.8] } });
  });
  // S15 26.333–29.033: the driver's booth from front-left, dolly in at the note
  const NOTE = V3(B.x + 0.45, 1.68, B.z - 2.0 + 0.87);
  shot(26.333, 29.033, { ...move(26.333, 29.0, [B.x - 2.7, 1.45, B.z + 2.5], [B.x - 1.0, 1.55, B.z + 1.05], [NOTE.x - 0.25, 1.75, NOTE.z], [NOTE.x, 1.66, NOTE.z]), fov: 56, ap: 0.5 }, t => { bootLights(); });
  // S16 29.033–33.167 / S17 33.167–35.033 / S18 35.033–38.000 / S19 38.000–40.033: the note, jump cuts in and down
  const noteCam = (t0, t1, y0, y1, d0, d1) => ({ P: [[t0, [NOTE.x - 0.004, y0 - 0.015, NOTE.z + d0]], [t1, [NOTE.x - 0.004, y1 - 0.015, NOTE.z + d1]]], L: [[t0, [NOTE.x, y0, NOTE.z]], [t1, [NOTE.x, y1, NOTE.z]]], fov: 26, ap: 16, focus: (d0 + d1) / 2, blur: 30 });
  const noteRun = box1 => t => { bootLights({ key: null, hemi: 0.22 }); bo.inLight.intensity = 0.45; bo.note.tex.draw(box1); };
  shot(29.033, 33.167, noteCam(29.033, 33.1, 1.835, 1.8, 1.55, 1.44), noteRun(0));
  shot(33.167, 35.033, noteCam(33.167, 35.0, 1.745, 1.73, 1.45, 1.36), noteRun(0));
  shot(35.033, 38.0, noteCam(35.033, 37.95, 1.696, 1.673, 1.17, 1.035), noteRun(0));
  shot(38.0, 40.033, noteCam(38.0, 40.0, 1.574, 1.55, 1.17, 1.03), noteRun(0));
  // S20 40.033–42.067: extreme close-up, the smile; a raised hand blurred lower-left
  const ME20 = [B.x - 1.4, 0, B.z + 1.0];
  shot(40.033, 42.067, { ...move(40.033, 42.0, [ME20[0] - 0.02, 1.62, ME20[2] + 0.72], [ME20[0] + 0.02, 1.64, ME20[2] + 0.56], [ME20[0], 1.68, ME20[2]]), fov: 30, ap: 6 }, t => {
    bootLights();
    MQ.pose(you, { x: ME20[0], z: ME20[2], yaw: 0, armL: { o: 0.04, e: 0.12 }, armR: { f: 1.05, o: 0.15, e: 1.75 }, face: { eyes: 'dot', brow: 'flat', mouth: 'smile', gaze: [0, 0] } });
  });
  // S21 42.067–44.300: the booth wide; he walks toward the camera, smug
  shot(42.067, 44.3, { P: [[42.067, [B.x + 1.6, 1.1, B.z + 6.6]], [43.6, [B.x + 1.45, 1.1, B.z + 5.2]], [44.3, [B.x + 1.42, 1.1, B.z + 5.1]]], L: [[42.067, [B.x + 0.6, 1.25, B.z - 3]], [43.6, [B.x + 0.7, 1.25, B.z - 3]], [44.3, [B.x + 1.1, 1.24, B.z - 3]]], fov: 62, ap: 0.4 }, t => {
    bootLights();
    const w = walkPath(t, 41.6, 44.6, [B.x + 1.05, B.z - 0.6], [B.x + 1.3, B.z + 3.5], k => k);
    MQ.pose(you, walkPose(w, 0.8, { face: { eyes: 'dot', brow: 'flat', mouth: 'smirk', gaze: [-0.4, 0] } }));
  });
  // S22 44.300–46.733: the wall clock, 23:59 → 00:00 at 45.933
  const CK = st.clock.G.position;
  shot(44.3, 46.733, { ...move(44.3, 46.7, [CK.x - 3.65, 3.12, CK.z], [CK.x - 2.8, 3.15, CK.z], [CK.x, 3.25, CK.z]), fov: 50, ap: 0.4 }, t => {
    lightsOn([2.5, 0, CK.z], { k: t > 44.77 ? 0.93 : 1, ok: g => !(g.position.x < 2 && Math.abs(g.position.z - CK.z) < 4), key: { p: [1.5, 3.2, CK.z - 2.0], t: [CK.x, 2.4, CK.z], i: 10 } });
    st.clock.board.draw([t >= 45.933 ? '00:00' : '23:59'], { scale: 2 });
  });
  // S23 46.733–48.667: the platform wide; every light goes out, far to near (47.40–47.967), then black with faint blue
  const C23 = [-0.6, 2.25, 14.5];
  shot(46.733, 48.667, { P: [[46.733, C23], [47.4, [C23[0], C23[1], C23[2] - 0.25]]], L: [[46.733, [-0.25, 1.1, -30]]], fov: 62, ap: 0.3 }, t => {
    const tOut = z => lerp(47.4, 47.83, clamp((-z + 30) / 44));   // far (−z) first
    const lit = g => { const z = g.position.z; if (z > 9.5) return t < 47.967 ? 1 : 0; if (z > 3) return t < 47.9 ? 1 : 0; return t < tOut(z) ? 1 : 0; };
    if (t < 47.967) {
      const dk = ss(t, 47.4, 47.95);
      lightsOn([0, 0, 4], { ok: g => lit(g) > 0, hemi: lerp(0.4, 0.06, dk), fog: lerp(0.022, 0.035, dk), fogC: new THREE.Color(0x76787a).lerp(new THREE.Color(0x141516), dk).getHex(), key: { p: [0.3, 4.0, -3.0], t: [0, 0, -4.2], i: 22 * (t < tOut(-4) ? 1 : 0) } });
      st.tubesOn(lit);
    } else night({ hemi: t < 48.1 ? 0.0 : 0.07 });
    MQ.pose(you, { x: 0.0, z: -4.2, yaw: 0, ...STAND });
  });
  // S24 48.667–51.000: frightened, facing us in the blue dark; head snaps to front on "darkness" (49.93–50.23)
  const ME24 = [2.6, 0, -13.0];
  shot(48.667, 51.0, { P: [[48.667, [ME24[0] - 2.75, 1.28, ME24[2]]], [51.0, [ME24[0] - 2.1, 1.3, ME24[2]], easeIn]], L: [[48.667, [ME24[0], 1.25, ME24[2]]]], fov: 38, ap: 1.2 }, t => {
    night({ hemi: 0.05, key: moon([ME24[0] - 3, 3.0, ME24[2] + 0.8], [ME24[0], 1.2, ME24[2]], 2.6) });
    const k = ssm(t, 49.93, 50.23);
    MQ.pose(you, { x: ME24[0], z: ME24[2], yaw: -Math.PI / 2, head: [0, lerp(-0.44, 0, k), 0],
      armL: { f: 0.75, o: 0.35, e: 1.45, tw: -0.9 }, armR: { f: 0.85, o: 0.25, e: 1.9, tw: -0.5 },
      face: { eyes: 'ring', brow: 'worried', mouth: k > 0.5 ? 'frown' : 'wave', mouthS: 0.8 } });
    st.seats[0].position.z = ME24[2] + 2.6;
  });
  // S25 51.000–52.700: the north tunnel mouth from low over the track, almost black; headlights deep inside
  const noseT = (t, a, b, za, zb, e = k => k) => lerp(za, zb, e(seg(t, a, b)));
  shot(51.0, 52.7, { ...still(51.0, [-3.3, 0.35, 12.5], [-4.6, 0.25, 40]), fov: 44, ap: 0.3 }, t => {
    night({ hemi: 0.02, fog: 0.007 });
    trainAt(noseT(t, 51.0, 52.7, 160, 132), { head: 1, inside: 0.5, beam: 0.5 });
  });
  // S26 52.700–55.567: further back, same axis; the train comes out of the tunnel and lights the station
  shot(52.7, 55.567, { ...still(52.7, [-2.45, 1.25, -6], [-3.3, 0.85, 28]), fov: 50, ap: 0.3 }, t => {
    night({ hemi: 0.02, fog: lerp(0.007, 0.02, seg(t, 52.7, 55.5)) });
    trainAt(noseT(t, 52.7, 55.567, 128, 26, k => k * (0.55 + 0.45 * k)), { head: 1, inside: 0.5, beam: 0.5 });
  });
  // S27 55.567–58.000: ground level by the yellow line; the train runs in and its nose blows past (57.30–57.40)
  const C27 = [-1.45, 0.16, -18];
  shot(55.567, 58.0, { ...still(55.567, C27, [-2.2, 0.55, 30]), fov: 62, ap: 0.4 }, t => {
    night({ hemi: 0.04, fog: 0.015 });
    const a = t - 55.6, z = t < 57.4 ? C27[2] + 40.5 - 19.5 * a : C27[2] + 40.5 - 19.5 * 1.8 - (19.5 * (t - 57.4) - 4.5 * (t - 57.4) ** 2);
    trainAt(z, { head: 1, inside: 1, beam: 0.5 });
  });
  // S28 58.000–59.567: close on his head, 3/4 toward screen-right; ring eye, zigzag mouth; dark navy, red seats behind
  const ME28 = [-0.6, 0, DZ + 2.0];
  shot(58.0, 59.567, { ...move(58.0, 59.55, [ME28[0] - 0.66, 1.66, ME28[2] - 0.12], [ME28[0] - 0.54, 1.66, ME28[2] - 0.1], [ME28[0], 1.64, ME28[2]]), fov: 32, ap: 7 }, t => {
    night({ key: moon([ME28[0] - 2.5, 2.8, ME28[2] - 1.0], [ME28[0], 1.5, ME28[2]], 4) });
    MQ.pose(you, { x: ME28[0], z: ME28[2], yaw: -Math.PI / 2 + 0.72 + ssm(t, 58, 59.5) * 0.08, ...STAND, face: { eyes: 'ring', brow: 'worried', mouth: 'wave', gaze: [0.3, 0] } });
    st.seats[0].position.z = ME28[2] + 0.4;
  });
  // S29 59.567–63.367: high rear 3/4; the silent train glides left→right, brakes 61.60–61.93 with the doors in front of him
  const ME29 = [-1.15, 0, DZ];
  shot(59.567, 63.367, { ...move(59.567, 63.3, [1.75, 2.15, DZ + 2.9], [1.55, 2.08, DZ + 2.55], [-2.6, 1.0, DZ - 0.6], [-2.6, 1.0, DZ - 0.95]), fov: 54, ap: 0.4 }, t => {
    night({ key: moon([2.5, 3.5, DZ + 3.5], [ME29[0], 1, DZ], 2) });
    const z = t < 61.6 ? NOSE_STOP + 1.1 + 6.7 * (61.6 - t) : NOSE_STOP + 1.1 * (1 - easeOut(seg(t, 61.6, 61.93)));
    trainAt(z, { inside: 1, head: 0.5 });
    carLights(1, z);
    MQ.pose(you, { x: ME29[0], z: ME29[2], yaw: -Math.PI / 2, armL: { o: 0.04, e: 0.12 }, armR: { f: 0.9, o: 0.12, e: 0.5 } });
  });
  // S30 63.367–65.067: right behind him; the doors slide open (63.97–64.47) and white light floods out
  shot(63.367, 65.067, { ...move(63.367, 65.0, [ME29[0] + 2.25, 1.62, DZ + 0.05], [ME29[0] + 2.08, 1.62, DZ + 0.05], [-3.0, 1.4, DZ + 0.02]), fov: 50, ap: 0.6 }, t => {
    night({ key: { p: [-2.7, 1.5, DZ], t: [ME29[0] + 0.5, 1.3, DZ], i: 40 * ss(t, 63.97, 64.6), c: 0xeef4ff, a: 0.8, shadow: false } });
    trainAt(NOSE_STOP, { inside: 1, head: 0.5 }, ss(t, 63.97, 64.47)); carLights(1.4);
    MQ.pose(you, { x: ME29[0], z: ME29[2], yaw: -Math.PI / 2, armL: { f: 0.55, o: 0.35, e: 1.6, tw: -0.6 }, armR: { f: 0.55, o: 0.35, e: 1.6, tw: -0.6 } });
  });
  // S31 65.067–67.467: inside the empty car, gliding down the aisle and yawing left; a pole whips past the lens (67.27–67.43)
  const CARZ = DZ + 4.6;   // car 2's centre (its doors at DZ and DZ + 9.2)
  shot(65.067, 67.467, { P: [[65.067, [S.XT + 0.2, 1.3, DZ + 0.6]], [65.9, [S.XT + 0.2, 1.3, DZ + 0.9], easeIn], [67.467, [S.XT + 0.15, 1.3, DZ + 2.6], k => k]], L: [[65.067, [S.XT - 1.1, 1.05, DZ + 9]], [67.467, [S.XT + 0.2, 1.05, DZ + 10], k => k * k]], fov: 60, ap: 0.4 }, t => {
    night(); trainAt(NOSE_STOP, { inside: 1, head: 0.5 }, 1); carLights(0.6);
    pole.visible = true; pole.position.set(S.XT + 0.58 - ss(t, 66.6, 67.42) * 0.55, 1.4, DZ + 1.3 + seg(t, 67.0, 67.43) * 0.5);
  });
  // S32 67.467–68.500: further along, centred down the aisle, a pole mid-frame; easing to rest on "passenger"
  shot(67.467, 68.5, { P: [[67.467, [S.XT, 1.3, DZ + 2.9]], [68.4, [S.XT, 1.3, DZ + 3.3], easeOut]], L: [[67.467, [S.XT - 0.02, 1.05, DZ + 12]], [68.4, [S.XT + 0.05, 1.05, DZ + 12], easeOut]], fov: 60, ap: 0.3 }, t => {
    night(); trainAt(NOSE_STOP, { inside: 1, head: 0.5 }, 1); carLights(0.6);
  });
  // S33 68.500–69.900: the note again, rule 1 boxed in red
  shot(68.5, 69.9, noteCam(68.5, 69.9, 1.687, 1.672, 1.69, 1.5), t => { bootLights({ key: null, hemi: 0.22 }); bo.inLight.intensity = 0.45; bo.note.tex.draw(1); });
  // S34 69.900–73.600: high wide down the dark platform; the train on the right, him tiny at a door; two eyes far off in the dark; his head snaps away on "footsteps"
  const ME34 = [-1.25, 0, DZ];
  shot(69.9, 73.6, { P: [[69.9, [-0.62, 2.5, DZ - 8.6]], [70.5, [-0.62, 2.5, DZ - 8.6]], [73.55, [-0.66, 2.42, DZ - 7.3]]], L: [[69.9, [-0.95, 0.85, DZ + 14]], [73.55, [-0.98, 0.85, DZ + 14]]], fov: 54, ap: 0.3 }, t => {
    night({ key: moon([1, 3.5, DZ - 2], [ME34[0], 1, DZ], 1.2) });
    trainAt(NOSE_STOP, { inside: 1, head: 0.5 }, 1); carLights(1);
    MQ.pose(you, { x: ME34[0], z: ME34[2], yaw: -Math.PI / 2, head: [0, lerp(0, -1.0, ss(t, 72.033, 72.1)), 0], ...STAND });
    MQ.pose(it, { x: 1.9, z: DZ + 16, yaw: Math.PI, ...STAND }); it.eyes.forEach(e => e.scale.set(3.4, 3.8, 1.2));
    st.nextBoard2.position.set(0.4, 3.45, DZ - 3.6); st.nextBoard.G.visible = false;
  });
  // S35 73.600–74.933: ground level, a pool of light; the black legs walk toward the lens (steps 73.8, 74.25, 74.7)
  const P35 = [0.6, 0, -16];
  shot(73.6, 74.933, { ...still(73.6, [P35[0], 0.15, P35[2] + 1.9], [P35[0], 0.55, P35[2] - 4]), fov: 50, ap: 3, focus: 1.6 }, t => {
    night({ hemi: 0.04 });
    floorPool.intensity = 26; floorPool.position.set(P35[0], 3.2, P35[2] + 0.6); floorPool.target.position.set(P35[0], 0, P35[2] + 0.9);
    const k = seg(t, 73.6, 74.95), z = lerp(P35[2] - 0.2, P35[2] + 1.15, k);
    const ph = (t - 73.35) / 0.9 * Math.PI * 2;
    MQ.pose(it, MQ.merge({ x: P35[0] + 0.05, z, yaw: 0 }, MQ.walk(ph, 0.55)));
    LT.key.intensity = 6; LT.key.position.set(P35[0] - 1.5, 1.2, P35[2] - 1.5); LT.key.target.position.set(P35[0], 0.4, z); LT.key.color.set(0xb8c8ff);
  });
  // S36 74.933–78.067: side view of him frozen, hands up; two eyes come up behind his back in the dark
  const ME36 = [-0.75, 0, -20];
  const anx = { armL: { f: 0.35, o: 0.12, e: 2.1, tw: -1.2, wr: [0.5, 0, 0] }, armR: { f: 0.35, o: 0.12, e: 2.1, tw: -1.2, wr: [0.5, 0, 0] } };
  shot(74.933, 78.067, { ...move(74.933, 78.0, [ME36[0] + 0.15, 1.3, ME36[2] - 3.3], [ME36[0] + 0.12, 1.38, ME36[2] - 2.65], [ME36[0] - 0.1, 1.15, ME36[2]], [ME36[0] - 0.1, 1.25, ME36[2]]), fov: 42, ap: 1 }, t => {
    night({ hemi: 0.03, key: { ...moon([ME36[0] - 1.6, 3.0, ME36[2] - 2.5], [ME36[0], 1.1, ME36[2]], 7.5, 0.24), pen: 0.5 } });
    MQ.pose(you, { x: ME36[0], z: ME36[2], yaw: -Math.PI / 2, ...anx, face: { eyes: 'ring', brow: 'worried', mouth: 'wave', gaze: [0.4, 0] } });
    const k = seg(t, 75.5, 78.067);
    MQ.pose(it, { x: lerp(ME36[0] + 0.12, ME36[0] + 0.1, k), z: lerp(ME36[2] + 3.6, ME36[2] + 0.8, k), yaw: Math.PI, ...STAND }); it.mat.envMapIntensity = 0.1;
    it.eyes.forEach(e => e.scale.set(1.3, 1.5, 0.6)); it.eyes.forEach(e => e.visible = t > 75.6);
  });
  // S37 78.067–80.800: frontal two-shot; the black one just behind his left shoulder; his "o" mouth widens
  const ME37 = [1.5, 0, -12.0];
  shot(78.067, 80.8, { ...move(78.6, 80.75, [ME37[0] - 2.7, 1.5, ME37[2] + 0.27], [ME37[0] - 2.48, 1.51, ME37[2] + 0.27], [ME37[0], 1.5, ME37[2] + 0.27]), fov: 34, ap: 1.4 }, t => {
    night({ hemi: 0.05, key: moon([ME37[0] - 3, 3.0, ME37[2] - 0.5], [ME37[0], 1.4, ME37[2]], 3.2, 0.4) });
    MQ.pose(you, { x: ME37[0], z: ME37[2], yaw: -Math.PI / 2, armR: { f: 0.25, o: 0.55, e: 1.0, tw: -1.2, wr: [0.4, 0, 0] }, armL: { f: 0.35, o: -0.1, e: 1.7, tw: 0.4 },
      face: { eyes: 'ring', brow: 'worried', mouth: 'o', mouthS: lerp(0.9, 1.25, ssm(t, 78.1, 80.0)) } });
    MQ.pose(it, { x: ME37[0] + 0.4, z: ME37[2] + 0.55, yaw: -Math.PI / 2 - 0.35, ...STAND });
    st.seats[0].position.z = ME37[2] + 2.2;
  });
  // S38 80.800–83.200: tight side view at head height: its head at frame left, the back of his head at frame right
  shot(80.8, 83.2, { P: [[80.8, [ME37[0] + 0.3, 1.64, ME37[2] - 1.05]], [81.7, [ME37[0] + 0.3, 1.64, ME37[2] - 1.05]], [83.2, [ME37[0] + 0.28, 1.64, ME37[2] - 0.84], easeIn]], L: [[80.8, [ME37[0] + 0.2, 1.64, ME37[2]]]], fov: 32, ap: 5 }, t => {
    night({ hemi: 0.04, key: moon([ME37[0] + 0.5, 2.8, ME37[2] - 2.2], [ME37[0], 1.6, ME37[2]], 3.2, 0.35) });
    MQ.pose(you, { x: ME37[0], z: ME37[2], yaw: -Math.PI / 2, ...STAND });
    MQ.pose(it, { x: ME37[0] + 0.38, z: ME37[2] + 0.02, yaw: -Math.PI / 2, ...STAND });
  });
  // S39 83.200–86.100: extreme close-up of his face, ring eye and a big black open mouth
  shot(83.2, 86.1, { P: [[83.2, [ME37[0] - 0.68, 1.62, ME37[2] + 0.03]], [83.3, [ME37[0] - 0.68, 1.62, ME37[2] + 0.03]], [85.9, [ME37[0] - 0.5, 1.63, ME37[2] + 0.02]]], L: [[83.2, [ME37[0], 1.65, ME37[2] + 0.03]], [85.9, [ME37[0], 1.64, ME37[2] + 0.03]]], fov: 30, ap: 7 }, t => {
    night({ hemi: 0.05, key: moon([ME37[0] - 2.5, 2.4, ME37[2] - 0.6], [ME37[0], 1.6, ME37[2]], 3.4, 0.35) });
    MQ.pose(you, { x: ME37[0], z: ME37[2], yaw: -Math.PI / 2 - 0.12, head: [0, 0, 0.12], ...STAND, face: { eyes: 'ring', brow: 'worried', mouth: 'O', mouthS: 1.1 } });
  });
  // S40 86.100–88.333: from inside the car through the open door; the doors start to close on "start" (87.13)
  const ME40 = [-1.15, 0, DZ];
  shot(86.1, 88.333, { ...move(86.1, 88.333, [S.XT - 0.6, 1.4, DZ], [S.XT - 0.35, 1.4, DZ], [ME40[0], 1.2, DZ], [ME40[0], 1.2, DZ]), fov: 54, ap: 0.6 }, t => {
    night({ key: { p: [S.XT + 0.6, 2.3, DZ], t: [ME40[0], 1.0, DZ], i: 9, c: 0xeef4ff, a: 0.6, shadow: false } });
    trainAt(NOSE_STOP, { inside: 1, head: 0.5 }, 1 - 0.42 * clamp((t - 87.13) / 1.2)); carLights(0.7);
    MQ.pose(you, { x: ME40[0], z: ME40[2], yaw: -Math.PI / 2, armL: { f: 0.42, o: 0.12, e: 2.0, tw: -1.35 }, armR: { f: 0.48, o: 0.12, e: 1.95, tw: -1.35 }, face: { eyes: 'ring', brow: 'worried', mouth: 'wave' } });
    MQ.pose(it, { x: ME40[0] + 0.42, z: DZ + 0.36, yaw: -Math.PI / 2, ...STAND });
    st.seats[0].position.z = DZ + 1.6;
  });
  // S41 88.333–89.800: high wide 3/4; him at the open door, hands raised to it, it right behind him. The choices pop.
  shot(88.333, 89.8, { ...still(88.333, [2.4, 3.05, DZ + 5.6], [-1.9, 0.75, DZ - 1.2]), fov: 50, ap: 0.3 }, t => {
    night({ key: moon([2, 3.5, DZ + 3], [-1, 1, DZ], 2.5) });
    trainAt(NOSE_STOP, { inside: 1, head: 0.5 }, 1); carLights(1);
    MQ.pose(you, { x: -1.3, z: DZ, yaw: -Math.PI / 2, armL: { f: 1.1, o: 0.15, e: 0.6, tw: -1.0 }, armR: { f: 1.1, o: 0.15, e: 0.6, tw: -1.0 } });
    MQ.pose(it, { x: -0.6, z: DZ + 0.35, yaw: -Math.PI / 2, ...STAND });
  });
  // S42 89.800–91.333: behind-right of the pair, looking at the lit door; his right palm offers the train (GET ON live)
  shot(89.8, 91.333, { ...move(89.8, 91.3, [2.2, 1.35, DZ + 3.6], [2.2, 1.35, DZ + 3.15], [-2.5, 1.15, DZ + 0.15], [-2.5, 1.15, DZ - 0.05]), fov: 48, ap: 0.6 }, t => {
    night({ key: moon([0.5, 3.0, DZ - 2.5], [-1, 1, DZ], 2.5) });
    trainAt(NOSE_STOP, { inside: 1, head: 0.5 }, 1); carLights(1);
    MQ.pose(you, { x: -0.95, z: DZ + 0.15, yaw: -Math.PI / 2 + 0.25, armR: { f: 1.2, o: 0.1, e: 0.35, tw: -1.3 }, armL: { o: 0.04, e: 0.12 }, face: { eyes: 'ring', brow: 'worried', mouth: 'wave' } });
    MQ.pose(it, { x: 0.15, z: DZ + 0.75, yaw: -Math.PI / 2 + 0.3, ...STAND });
  });
  // S43 91.333–93.100: low 3/4 front; his left palm offers the dark (STAY live); his head turns ~20° toward us (91.9–92.6)
  const ME43 = [-0.4, 0, -16];
  shot(91.333, 93.1, { ...move(91.333, 93.05, [ME43[0] + 0.95, 1.05, ME43[2] - 1.5], [ME43[0] + 1.12, 1.06, ME43[2] - 1.36], [ME43[0] - 0.2, 1.38, ME43[2] - 0.25]), fov: 46, ap: 1 }, t => {
    night({ key: moon([ME43[0] + 2, 3.0, ME43[2] - 2.5], [ME43[0], 1.2, ME43[2]], 3.5) });
    MQ.pose(you, { x: ME43[0], z: ME43[2], yaw: Math.PI, head: [0, lerp(0, 0.35, ss(t, 91.9, 92.6)), 0], armL: { f: 1.3, o: 0.12, e: 0.35, tw: -1.3 }, armR: { o: 0.04, e: 0.12 }, face: { eyes: 'ring', brow: 'skeptic', mouth: 'wave', gaze: [0.6, 0] } });
  });
  // S44 93.100–95.833: extreme close-up of the black head, its glowing eyes; pure push-in
  const ME44 = [2.3, 0, -16.6];
  shot(93.1, 95.833, { P: [[93.1, [ME44[0] - 0.92, 1.66, ME44[2] - 0.2]], [93.3, [ME44[0] - 0.92, 1.66, ME44[2] - 0.2]], [95.65, [ME44[0] - 0.68, 1.66, ME44[2] - 0.15]]], L: [[93.1, [ME44[0], 1.66, ME44[2]]]], fov: 30, ap: 5 }, t => {
    night({ key: { p: [ME44[0] - 1.2, 2.6, ME44[2] - 1.6], t: [ME44[0], 1.65, ME44[2]], i: 8, c: 0xdde6ff, a: 0.35, pen: 0.4 } });
    MQ.pose(it, { x: ME44[0], z: ME44[2], yaw: -Math.PI / 2 - 0.5, ...STAND });
    it.eyes.forEach(e => e.scale.set(1.3, 1.45, 0.6));
  });
  // S45 95.833–98.600: him, frontal, shrugging; the long push into his eyes (2.5×)
  const ME45 = [1.5, 0, -12.0];
  const D45 = [[95.833, 1.0], [96.2, 1.03], [96.6, 1.1], [97.0, 1.3], [97.4, 1.5], [97.8, 1.9], [98.2, 2.3], [98.6, 2.5]];
  const lin = k => k;
  shot(95.833, 98.6, { P: D45.map(([tt, sc], i) => [tt, [ME45[0] - 1.55 / sc, lerp(1.6, 1.69, (sc - 1) / 1.5), ME45[2] - lerp(0.12, 0.02, (sc - 1) / 1.5)], i ? (i === 1 || i === D45.length - 1 ? sm : lin) : sm]), L: [[95.833, [ME45[0], 1.6, ME45[2] - 0.08]], [98.6, [ME45[0], 1.69, ME45[2] - 0.02], easeIn]], fov: 34, ap: 3 }, t => {
    night({ hemi: 0.05, key: moon([ME45[0] - 2.6, 2.6, ME45[2] - 0.8], [ME45[0], 1.5, ME45[2]], 3.4, 0.35) });
    MQ.pose(you, { x: ME45[0], z: ME45[2], yaw: -Math.PI / 2, armL: { f: 0.3, o: 0.5, e: 1.75, tw: -1.3, wr: [0.4, 0, 0] }, armR: { f: 0.3, o: 0.5, e: 1.75, tw: -1.3, wr: [0.4, 0, 0] }, face: { eyes: 'ring', brow: 'worried', mouth: 'o' } });
    MQ.pose(it, { x: ME45[0] + 0.6, z: ME45[2] + 0.5, yaw: -Math.PI / 2 - 0.2, ...STAND });
  });
  const SHOTS = SH;

  // ===================== EVERY FRAME =====================
  function world(tq, t, si) {
    K.reset();
    const s = SH[si]; if (s?.run) s.run(t);
    E.post.U.maxBlur.value = s?.blur ?? 18;
  }
  // the overlay: the glossy streak on the note, the choices, the captions (film.json carries the reference's own chunks and lines)
  const GW = O.PILL.getOnW, SW = O.PILL.stayW;
  function overlay(g, t) {
    const ns = [[29.033, 33.167, 0.215], [33.167, 35.033, 0.2], [35.033, 38.0, 0.165], [38.0, 40.033, 0.155], [68.5, 69.9, 0.19]].find(([a, b]) => t >= a && t < b);
    if (ns) O.streak(g, ns[2]);
    if (t >= 88.333 && t < 89.8) {          // S41: both pop, their lines draw to the open door and to the floor by his feet
      const gx = 0.333 * 1080, gy = 0.306 * 1920, sx = 0.54 * 1080, sy = 0.433 * 1920;
      O.choice(g, t, { n: 1, label: 'GET ON', w: GW, x0: gx, cy: gy, t0: 88.367, to: proj(V3(S.XT + 1.45, 1.25, DZ - 0.62)), line0: 88.467, line1: 88.7 });
      O.choice(g, t, { n: 2, label: 'STAY', w: SW, x0: sx, cy: sy, t0: 88.667, to: proj(V3(-1.25, 0.01, DZ + 0.55)), line0: 88.767, line1: 89.0 });
    } else if (t >= 89.8 && t < 91.333) {   // S42: GET ON rides his right palm, STAY parked
      const palm = proj(hand(you, 'R', [0, -0.07, 0.04])), gx = clamp(palm[0] - GW / 2 + 0.02 * 1080, 60, 960 - GW);
      O.choice(g, t, { n: 1, label: 'GET ON', w: GW, x0: gx, cy: palm[1] - 0.19 * 1920, to: palm });
      O.choice(g, t, { n: 2, label: 'STAY', w: SW, x0: O.PILL.parkR, cy: O.PILL.parkY });
    } else if (t >= 91.333 && t < 93.1) {   // S43: STAY rides his left palm, GET ON parked
      const palm = proj(hand(you, 'L', [0, -0.07, 0.04])), sx = clamp(palm[0] - SW / 2 + 0.06 * 1080, 60, 960 - SW);
      O.choice(g, t, { n: 2, label: 'STAY', w: SW, x0: sx, cy: palm[1] - 0.17 * 1920, to: palm });
      O.choice(g, t, { n: 1, label: 'GET ON', w: GW, x0: O.PILL.parkL, cy: O.PILL.parkY });
    } else if (t >= 93.1) O.parked(g, t);
    O.captions(g, film, t);
  }
  // the grade: desaturated neutral grey before midnight, cold navy after, warm sepia on the note; the white flash on "slam"
  const FLASH = [0.55, 0.34, 0.26, 0.17, 0.09];

  function effects(t) {
    const P = E.post, nightK = t >= 47.967 && !(t >= 68.5 && t < 69.9), noteK = (t >= 29.033 && t < 40.033) || (t >= 68.5 && t < 69.9), car = t >= 65.067 && t < 68.5;
    P.U.warm.value = car ? -0.15 : nightK ? -0.4 : noteK ? 0.4 : 0.04; P.U.sat.value = car ? 0.9 : nightK ? 0.6 : noteK ? 0.55 : 0.62;
    P.U.bloom.value = nightK ? 0.7 : 0.45; P.U.exposure.value = car ? 0.72 : nightK ? 0.95 : noteK ? 0.62 : 0.56; P.U.con.value = nightK ? 1.1 : 1.04; P.U.lift.value = nightK ? 0.0 : 0.01;
    P.F.vig.value = noteK ? 0.85 : nightK ? 0.55 : 0.4; P.F.grain.value = nightK ? 0.016 : 0.008; P.F.ca.value = 0.25;
    P.F.flashMax.value = 0.6;
    const fi = Math.floor((t - 4.3) * 30 + 1e-4);
    // the pole whip (67.33–67.43): a hard horizontal motion blur hides the cut at 67.467
    const wk = t >= 67.3 && t < 67.5 ? Math.sin(seg(t, 67.3, 67.5) * Math.PI) : 0;
    FX.apply(E, t, { whip: [wk * 0.16, 0] });
    P.F.flash.value = (fi >= 0 && fi < FLASH.length ? FLASH[fi] : 0) + wk * 0.25;
  }
  return { SHOTS, world, overlay, effects, smoothCam: true };
}
```

