---
name: cutscene
description: Make a narrated LANDSCAPE 16:9 video (1920x1080, 30-45 s) in the "Cutscene" format - a director's cut in which every beat of the script gets its own little three.js world (a laptop on grass, flat orange and red rooms, red subtitles on black, a theatre stage, a gold price you fly through, a voxel desk, a cloud tunnel, a tron-grid library, a black-and-white dot cosmos, the film playing inside its own monitor), two pixel-art characters (a detective-builder in a flat cap and long coat, and an orange block "Claude" in a fedora), every key word slammed in as extruded 3D type, hard cuts and pixel-block wipes, flashes, zoom punches, RGB split and pixelate dissolves, and a comment panel at the end. One ElevenLabs voice, tightened for free, a music bed and a synthesized sound kit, rendered frame by frame in headless Chrome and encoded with ffmpeg. Use it when someone asks for a cutscene video, "cutscene.md", a director-cut / motion-graphics explainer in landscape, a 16:9 YouTube-style version of a reel script, or wants to turn a script into this kind of video.
---

# Cutscene video: how to make one

You will make a **30-45 second LANDSCAPE video (1920x1080, 16:9, 30 fps, H.264 + AAC, at most 60 s and 60 MB)** that feels like a game's cutscene cut by a hyperactive director. **Every beat of the script is a new world**: a tiny three.js set that exists for 1.5-4 seconds, cut hard (or through a wipe of coloured pixel blocks) into the next. Two **pixel-art characters** act in those worlds: a **detective-builder** (flat cap, long brown coat, black hair) and **Claude as an orange block in a grey fedora**. **Every word that matters is 3D type**: thick extruded letters (a face plus 10-24 darker layers behind it) that fly in on the word with an overshoot, rain down letter by letter, or dive through the camera. On every slam there is a hit: overlay shake, an RGB split, a flash, a zoom punch, a sound. The voice is quick and dry; the music is a beat under it.

**`reference.jpg`** (next to this file, if you have it) is a contact sheet of 12 frames from a finished Cutscene video, the one in Appendix B: **this is what it should look like.** Left to right, top row first: (1) a laptop on grass under a blue sky, its screen a feed of tiles, a red "▲ 8.2M views" chip, "BLOWING" in rainbow 3D letters and a red-and-yellow "UP!" crashing into the grass; (2) a flat orange room, the detective on the left, a stack of white 3D words "everyone's / asking / the SAME / thing." turned a little in space, gold embers; (3) a red room, cream Anton "WRONG / QUESTION." with dark red sides, the startled detective with a red "!" over his cap, "prompt?" struck through, a ring of red pixels; (4) black with a red glow: subtitles "For / free." with red pixel corner marks and red pixels rising; (5) the theatre stage: wood wall, red pixel floor, the detective, Claude in his fedora, a dark "COURSE $$$" box on a pedestal, a yellow "$30" tag swinging, red Anton "SOLD" and gold "for $30."; (6) a huge gold extruded "$30" in a dark red void with embers (the camera is about to fly through the zero); (7) the voxel desk from above, a monitor with an app's cards popping in, white Syne "Morphy," across the top, salmon "agentic" and white "super app."; (8) inside a cloud tunnel rushing into a white sun, "here's my" and gold "RESPONSE." whose letters fly in from deep space; (9) a tron grid in teal, glowing file props with labels hanging in the air, Claude walking, "An entire / LIBRARY / content-format" in white, teal and mono; (10) black and white: a sphere of dots in space and a typed line "No subscriptions|"; (11) the tron grid with four pixel-noise panels (red, white, teal, gold), confetti, the detective, "It's called / cutscene.md" falling in letter by letter; (12) the stage again, the detective patting Claude on the left, and on the right a dark panel: a teal mono chip, "Comment", a glowing teal "CUTSCENE", "I'll send you the .md file", a comment box typing the keyword and a tilted red FREE stamp. 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)`: every frame is a pure function of the time), then muxed with a mix made in ffmpeg. This file gives you the look (colours, fonts, sizes, positions), the grammar of the edit, the rules that keep it from looking buggy, the complete tested engine (Appendix A: write it once) and a complete example video (Appendix B). With the engine written, **a video is three files**: `script.mjs` (what is said), `scene.js` (the director file: the sets, the cut list, the 2D layer and the effects) and `cues.mjs` (sound effects on the words).

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

---

## 0. Rules of engagement (read first)

1. **Be gentle with the user's computer.** One headless browser, on the GPU, at the lowest priority, pinned to one CPU thread, never parallel workers. If WebGL is on the CPU (SwiftShader, llvmpipe, "software"), `cdp.mjs` refuses to render: tell the user to fix the graphics driver rather than forcing it.
2. **Never print secrets.** The only key is `ELEVENLABS_API_KEY` (environment, or `.env` in the project folder). Scripts read it; you never echo it, log it or put it in a file you show.
3. **Spend credits deliberately.** `gen-vo.mjs` (and the optional `gen-sfx.mjs`) are dry runs unless you pass `--yes`. One voice take of the whole script (~1 credit per character, ~550-700 for a video). Never pay for a take that exists: tighten it with `votight.mjs`, fix a word with `voedit.mjs`. The sound kit is synthesized for free (`synth-sfx.mjs`).
4. **Stills before film.** Build every set on fake timings and look at stills before you pay for the voice; never start the full render until the stills contact sheet is right.
5. **Background jobs die when your turn ends.** Render in foreground chunks (`render.mjs my-video full 0 600`, then `full 600`), each call under your tool timeout. The render resumes where it stopped.
6. **No real people's content.** No real screenshots, logos, app icons, handles, view counts or other creators' frames. A feed, an app screen, a store page are drawn on a canvas in the set. Numbers on screen are only the numbers the voice says (a view counter may climb as decoration: invent it, never copy a real account's).
7. **Don't publish anything.** You deliver files. Posting is the user's call.
8. If the user's instructions conflict with this file, the user wins. If something is missing (no key, no GPU, no ffmpeg), say exactly what and offer the closest thing (everything up to the stills works without a key). Never fake success.

## 1. Requirements

- **Node 22+** (global `fetch` and `WebSocket`), **ffmpeg + ffprobe** on PATH, **Chrome or Edge** (or set `CHROME=<path>`), a GPU. Chrome is driven through ANGLE: d3d11 on Windows, metal on macOS, gl on Linux (if the GPU probe fails on Linux, try `CUTSCENE_ANGLE=vulkan`). Debug ports 9650-9699 on 127.0.0.1.
- **three.js 0.170.0** from npm (pin it; the import map points into `node_modules/three`).
- **An ElevenLabs API key** for the voice. The free plan (about 10,000 credits a month) is enough to try the format but has no commercial licence; videos that promote anything need a paid plan. No key yet: do everything up to the stills with `build.mjs --fake` and ask for the key.
- **Free assets** (`setup.mjs` fetches or makes them): 7 fonts from google/fonts (SIL Open Font License), the music bed **"Hip Hop 02" by Lily J, Mixkit #738** (Mixkit's free licence: free for commercial and personal videos, no attribution required, don't resell or redistribute the track on its own; https://mixkit.co/license/#musicFree), and **27 sound effects synthesized by `synth-sfx.mjs`** (code, no licence questions). Optionally `gen-sfx.mjs --yes` remakes the same 27 names with ElevenLabs sound generation, or drop your own `.wav` files in `sfx/` under those names.

## 2. Project layout and setup

```
cutscene-video/                  one project folder, many videos
  package.json                   { "type": "module", "dependencies": { "three": "0.170.0" } }
  .env                           ELEVENLABS_API_KEY=...   (never print it)
  engine/                        Appendix A: write these files once, never edit them per video
    film.html main.js            the page: fonts -> film.json + scene.js -> sets -> window.setT(t); E.snapshot / E.screenMat
    sets.js                      set building blocks (backdrops, stage, desk, keyboard, monitor, file props, tron grid, clouds, stars, dots)
    sprites.js                   the pixel characters (procedural, no image files) + glyphs ('?', '!', cursor, hand, spark, heart)
    type3d.js                    extruded 3D type from stacked canvas planes + its screen box
    overlay.js                   frame size + safe zone, the text-box register, MAP (design space -> frame)
    post.js fx.js lib.js         post chain (DOF, bloom, hud layer, pixelate, RGB split, invert, flash, zoom, whip, shake) · caps · helpers
    common.mjs cdp.mjs setup.mjs synth-sfx.mjs gen-sfx.mjs render.mjs gen-vo.mjs votight.mjs voedit.mjs build.mjs mix.mjs encode.mjs check.mjs
  fonts/  music/  sfx/           downloaded / synthesized once
  my-video/                      one folder per video (Appendix B is a complete one)
    script.mjs  video.json       what is said (+ how to say symbols); settings
    scene.js  cues.mjs           the director file; the sound effects
    vo/ film.json stills/ frames/ mix.wav video.mp4 cover.jpg
```

```bash
mkdir cutscene-video && cd cutscene-video
echo '{ "type": "module", "private": true, "dependencies": { "three": "0.170.0" } }' > package.json
npm install
# write engine/* from Appendix A, then:
node engine/setup.mjs            # checks Node/ffmpeg/three/Chrome/GPU/key, downloads fonts + music, synthesizes the 27 sounds
```

A video:

```bash
mkdir my-video                    # write my-video/script.mjs (section 4) + video.json; copy scene.js + cues.mjs from Appendix B and rewrite them
node engine/build.mjs my-video --fake                                  # guessed timings: build the sets before you pay for a voice
node engine/render.mjs my-video stills auto 8 --safe                   # look at my-video/stills/sheet-safe.jpg; fix; repeat
node engine/gen-vo.mjs my-video                                        # dry run: the text the voice will read + its cost
node engine/gen-vo.mjs my-video --yes                                  # ONE take -> my-video/vo/a.*
node engine/votight.mjs my-video --from a --out b --stop 0.34 --comma 0.14 --word 0.05 --tempo 1.04 --set   # the rapid-fire cut, free
node engine/build.mjs my-video                                         # real timings: prints every token with its time
node engine/render.mjs my-video stills 1.6,3,5.5,9,12.5,17,20,22.5,27,32,37 --safe
node engine/render.mjs my-video full 0 600 && node engine/render.mjs my-video full 600       # ~0.2 s a frame on a mid GPU
node engine/mix.mjs my-video && node engine/encode.mjs my-video && node engine/check.mjs my-video
```

## 3. The look, pinned down

### 3.1 The frame (1920x1080 px)

| Layer | Where | What |
|---|---|---|
| The set | the whole frame | one three.js scene + its camera, its own background (a flat colour with a radial glow, a sky, a void), its own light and post settings |
| World type | inside the set | 3D words standing in the world (they get depth of field and move with the set): the big reveal words ("BLOWING", "UP!", "$30") |
| Hud type | in front of the set | 3D words on a camera-attached layer, 8 units in front of the lens, **composited after the depth of field so they are always sharp**: most key words |
| Characters | in the set, usually the left third | pixel sprites standing on the floor of the set; in landscape they stand left and the type sits centre-right, like the reference |
| 2D overlay | on top | subtitles, typed lines, chips, the player title and progress bar, the comment panel (right half), the pixel-block wipes |

**Safe zone (landscape title-safe):** all text inside **x 96-1824, y 54-1026** (5 % in from every edge). The overlay records every 2D text box (`E.O.markBox`) and the projected front face of every 3D word in `set.words`; `render.mjs ... --safe` draws the zone and the boxes (green inside, red outside), every run prints anything outside, the full render logs every frame into `frames/safe.json` and `check.mjs` fails if any frame has text outside. A word that passes through the camera on purpose is marked `userData.noMark = true` while it does.

**The design space.** `scene.js` places type in a **1080x1920 design space** (the coordinates the reference's vertical edit used) and maps it into the landscape frame: 3D words `MX(x) = 960 + (x - 510) x 0.9`, `MY(y) = 540 + (y - 870) x 0.77`; the 2D overlay is drawn with `setTransform(0.9, 0, 0, 0.9, 960 - 510 x 0.9, 540 - 880 x 0.9)` so design (510, 880) is the frame centre. Keep type in design x 150-900 and y 380-1400 and it lands well inside the safe zone. The comment panel uses the same transform centred on x 1390 (the right half).

### 3.2 Colours

| Token | Hex | Used for |
|---|---|---|
| red palette | `#e01f26 #ff9b73 #f6efe4 #0b0909 #7b0b0f #ff4436` | the red wipes, the subtitle highlight block (`#e01f26`), red pixels |
| red room | bg `#b3121b`, radial `#ee3a35 -> #8a0a0f` | "Wrong question." |
| dark room | bg `#060304`, radial `#3a0507 -> #040203`, red point light `#ff2020` | subtitles |
| orange room | bg `#d9580f`, radial `#ff9a3c -> #b8400a`, embers `#ffd23d` | the hook stack |
| stage | wood wall, red pixel floor, signs `#ff2a2a #2ae0d0 #ff9b73` | the story beats and the CTA |
| gold | `#ffc63a` front, sides `#d9480f` / `#e0600f`, back `#3a0a02` / `#5a1a02` | money, the reveal word |
| teal | grid `#1fd6c6` on `#02060a`; type `#4ff0e0`, sides `#0b6a62`, back `#02201e`; wipe `#1fbfb0 #4ff0e0 #f6efe4 #06201e #ffc63a #0b0909` | the library, the name, the CTA |
| cream type | `#f7e8d4` front, `#7a0a0e` sides, `#2a0204` back | Anton words on red |
| white type | `#ffffff` front; sides tinted from the set (`#c2410c` on orange, `#7a0a0e` on red, `#0e5a55` on teal, `#4a5a7a` in the clouds) | everything else |
| detective | cap `#7b6656`, hair `#1d1a21`, skin `#f7d4b1`, coat `#4d3e35`, shirt `#211f25`, pants `#3b3b4a`, shoes `#181818` | the builder |
| Claude | body `#e8743f` / light `#f18a55` / dark `#c95c2c`, hat `#3c3b40`, band `#e9dcc3` | the fedora Claude |
| CTA panel | scrim `rgba(8,4,4,0.55)`, chip `#4ff0e0`, keyword `#4ff0e0` with a 40 px glow, box `rgba(20,30,30,0.9)` with a teal border, FREE stamp `#e01f26` | the end |

### 3.3 Fonts

**Fredoka** 600/700 for most 3D type (round, friendly, thick); **Anton** 400 for verdicts (WRONG, QUESTION., SOLD); **Syne** 800 for names ("Morphy,", "this video", a person's name); **JetBrains Mono** 700 for file names and the CTA chip; **Silkscreen** for pixel HUD text ("QUESTION: INVALID"); **Inter** 500-900 for subtitles, typed lines and the comment panel. All SIL OFL from google/fonts.

### 3.4 The characters (`sprites.js`)

- Drawn on a tiny grid with code (no image files), a 1 px dark outline added automatically, shown as a plane with nearest-neighbour filtering so the pixels stay crisp at any size. `S.character(E, 'det', { h })` or `'claude'`, height in world units.
- **The detective-builder** (48 x 66 art px): `pose({ armL, armR, legs, eyes: 'open'|'closed'|'happy'|'wide', mouth: 'smile'|'open'|'flat'|'o'|'grin', item: 'magnifier', look })`. Arms are angles (0 = hanging, + = raised), `legs` spreads them (a jump: `legs: 3`), `look` shifts the eyes -1..1.
- **The fedora Claude** (40 x 40): `pose({ eyes: 'open'|'happy'|'sad'|'wide'|'closed', mouth, arms: -1..1 (a wave), step: 0|1 })`.
- **They are never still**: lip-sync from the voice's loudness (`mouth: E.film.env[Math.floor(t * 30)] > 0.3 ? 'open' : 'smile'`), walks alternate `legs` / `step` ten times a second, they jump in with a parabola, a "!" or "?" glyph pops over them (`S.drawGlyph('!')` as a sprite).

### 3.5 The 3D type (`type3d.js`)

- `T3.word(E, 'SAME', { font: '700 {px}px Fredoka', px, color, side, back, depth, layers, letters, colors, sides })`: the text is drawn on a canvas once, then stacked as a front plane + `layers` planes behind it over `depth` world units, the side colour fading to `back`. Cheap, sharp, any font. `letters: true` makes every letter its own group (per-letter flights); `colors` / `sides` cycle per letter (the rainbow "BLOWING").
- **Sizes**: a hud word is sized by its on-screen height in design px (`hudWord(set, 'WRONG', 540, 600, 250, …)`): 60-90 px for small connecting words, 100-130 for normal words, 150-250 for the slam. Depth 0.12 for small words, 0.25 for normal, 0.45-0.9 with 16-24 layers for the big ones.
- **Moves** (`kin`): fly in from an offset `p`, a rotation `r` and a scale `s` in 0.2-0.3 s with an overshoot (`easeOutBack`), hold, fly out together 0.1 s before the next beat. **Letter rain** (`letters`): letters drop 1.6-3.5 units with a 0.03-0.06 s stagger, a spin and a squash on landing. **Through the lens**: a world word you fly at and through (the gold "$30"). A word can flash its face colour for 0.12 s on the stress (`userData.tint`).

### 3.6 The sets (`sets.js`)

Each set is `K.newSet(E, { fov, bg, fog })`, filled with a few blocks: `backdrop` (a texture glued to the camera: `radial(inner, outer)` glows, a painted sky), `embers` (floating squares), `stage` (wood wall + red pixel floor) + `glowSign`, `desk` + `keyboard` + `monitor` + `voxGuy` (a voxel person typing, seen from behind), `fileProp` (a paper file with a coloured band and a label), `courseBox`, `priceTag`, `gridFloor` (tron), `dotTrail`, `cloudTex` (billboards), `stars`, `dotSphere`, `dotPlane`, `scatter` (dots blowing away), `sunTex`, `pixTex` (pixel-noise panels), `canvasTex(w, h, draw)` (anything drawn: a feed, an app screen; redraw it every frame with `tex.userData.redraw`). `K.cam(set, position, lookAt, roll)` every frame. Post per set: `{ focus, aperture, bloom, thr, vig, grain, exposure }` (defaults focus 5, aperture 0 = no DOF, bloom 0.35, threshold 0.8, vignette 0.18, grain 0.02; the cosmos uses grain 0.05, vignette 0.3).

**Screens inside screens**: `E.snapshot('orange', tSet, E.rt('mon'))` renders another set at another time into a texture and `E.screenMat(tex)` shows it on a plane: the desk monitor plays the film's own hook on "this video you're watching right now?", the player flicks through seven sets at 0.16 s each.

### 3.7 The 2D layer

- **Subtitles** (on black): Inter 600 96 px, two lines centred, each word blurs in (12 px -> 0 over 0.16 s) and slides 40 px; the word being said sits on a **red block** `#e01f26`; pixel icons pop beside them (a heart, a star). A one-word punchline ("free.") alone: Inter 800 170 px with red pixel corner marks and red pixels rising.
- **Typed lines** (cosmos): Inter 400 76 px white, typed over the word's length with a blinking caret, a 5-copy horizontal smear for 0.1 s when a beat starts.
- **Chips**: a red pill "▲ 8.2M views" climbing, Silkscreen "QUESTION: INVALID" typing in.
- **The player**: the film inside a framed card that swings into place, a Syne 800 72 px title word by word over it, a red progress bar at the bottom.
- **The comment panel** (right half): scrim, teal mono chip "FREE CONTENT FORMAT · NAME.MD", "Comment" Inter 800 120 px, the keyword Inter 900 136 px teal with a glow, "I'll send you the .md file", a comment box typing the keyword with a caret, "name.md = free" in mono, and a tilted red **FREE** stamp popping on "Completely free.".
- **Pixel-block wipes**: a grid of 120 px cells; each cell lights up in a random colour of the palette (red or teal) for a random window inside 0.34 s centred 0.16 s before the cut, so the frame breaks into coloured blocks and rebuilds as the next set.

### 3.8 Effects (`effects(t, name)`, every frame)

`inv` + `sat` (frame 0 is a negative; colour snaps in at 0.3 s with a 0.6 flash), `flash` 0.35-0.8 for 0.12-0.2 s with `flashCol` (a warm `[1, 0.75, 0.7]` hit on "WRONG"), `zoom` (a radial zoom blur punch: 0.06-0.12 on slams, 0.25-0.35 into a dissolve), `pix` (pixelate: up to 1 + 50 px blocks, a dissolve between sets), `rgb` (a horizontal colour split of 8-18 px that decays in 0.2-0.3 s), `whip` (motion blur on a whip pan), `scan` (scanlines in the glitch), `sat: -1` (the black-and-white cosmos), `shake` (the overlay jitters up to 14 px on slams), `fade` (the last 0.35 s darkens to 15 %). The engine composites the hud type **before** these, so a slam shakes the type with the world.

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

1. **The negative first frame**: frame 0 is the hook set inverted and desaturated; at 0.3 s the colour snaps in with a flash. The hook's key words are already falling in.
2. **One world per beat, cut on the word**: 12-16 sets in 40 s.
3. **The stack**: a sentence as a poster of 3D words in different sizes, turned a little in space, the stressed word huge and darker-sided, all flying out together.
4. **The verdict slam**: a word in Anton fills the frame from behind the camera with a flash, an RGB split and a shake; the character reacts ("!" over the head).
5. **Subtitles on black** for the line that needs to be read: word by word on a red block.
6. **Fly-through**: the camera rushes at a giant word and through it into the next idea.
7. **The film in its own screen** on the line that says "this video": the hook plays on a monitor in a set, then the player flicks through the sets.
8. **The name falls in letter by letter** over pixel panels with confetti; the CTA panel types the keyword.

## 4. Writing for this format

- **First person, fast, dry**, short sentences and fragments. The reference's narrator talks to the viewer about the video itself. Brian tightened runs ~2.7 words a second, so **85-120 words = 32-45 s**.
- **Shape:** a hook that names a common question ("This style is blowing up, and everyone's asking the same thing. What's the prompt?") -> the turn ("Wrong question.") -> the story in 4-7 short beats, each with something showable (a number, a price, a file, a place, a person) -> the reveal about the video itself ("And this video you're watching right now? It's one of them.") -> the name -> the CTA: "Comment KEYWORD, and I'll send it to you." (+ a two-word kicker: "Completely free."). The keyword is one word, ≤ 10 letters (it is typed into a comment box).
- **Every beat starts on a strong word** (the cut lands on it) and each line has one word worth slamming.
- `script.mjs`: `LINES` = the script, one sentence (or two short ones) per line; `SAID` = how the voice says a token with dots or symbols (`'$30': 'thirty dollars'`, `'cutscene.md': 'cutscene dot M D'`, `'content-format': 'content format'`) and a keyword it must stress (`'CUTSCENE': '"Cutscene"'`). Write repeated words knowing you will key beats to them by occurrence (`T('No', 2)`).

## 5. The voice (ElevenLabs)

- **Brian** (`nPczCjzI2devNBz1zQrb`) on **eleven_multilingual_v2**, stability 0.4, similarity 0.8, style 0.45, speaker boost, **speed 1.05**: a dry, deep, quick narrator (the reference sits at a median pitch of ~121 Hz). Another voice: put `voice` in `video.json`; try it first with `--test 3 --take t1` (the first three lines, a couple of hundred credits).
- **One take of the whole script, then tighten it for free:** `votight.mjs --from a --out b --stop 0.34 --comma 0.14 --word 0.05 --tempo 1.04 --set` shortens every pause inside real silence (0.34 s kept after . ! ?, 0.14 s after a comma, 0.05 s between words) and speeds the take up 4 % with the pitch kept: 41.7 s -> 37.3 s in the example, the reference's rapid-fire rhythm.
- A wrong word: don't pay for a new full take. Make a short `--test` take that says it right and splice (`voedit.mjs my-video --list a`, then `--out c --use t1:0-26,a:27-`), then tighten that. `--pause 7:0.3` adds room after word 7 for a visual beat.
- `video.json` `voOff` (0.3 s) delays the voice so frame 0 is the hook without a word on it.

## 6. Timings: film.json

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

## 7. Directing: scene.js

### 7.1 The shape of the file

```js
import * as THREE from 'three';
import { clamp, lerp, seg, easeOut, easeIn, easeInOut, easeOutBack, hash, keys } from '/engine/lib.js';   // absolute: the server's root is the project folder

export default function setup(E) {
  const { T, TE, S, T3, K, END } = E;          // word times · sprites · 3D type · sets.js
  const sets = {};
  // the helpers from Appendix B, copied as they are: LAND/KS/MX/MY/MAP2 (design space), W3, at, pxPerUnit, hudWord, kin, letters, shakeOf
  {
    const s = sets.red = K.newSet(E, { fov: 40, bg: 0xb3121b });
    K.backdrop(s, K.radial('#ee3a35', '#8a0a0f', { cy: 0.4, r: 0.85 }), 120);
    const det = S.character(E, 'det', { h: 3.0 }); s.scene.add(det);
    const wrong = hudWord(s, 'WRONG', 540, 600, 250, { font: '400 {px}px Anton', color: '#f7e8d4', side: '#7a0a0e', back: '#2a0204', depth: 0.6, layers: 18 });
    s.update = t => {
      kin(wrong, t, { at: T('Wrong') - 0.06, dur: 0.22, from: { p: [0, 0, 6], s: 3.2, r: [0, 0, -0.2] }, ease: easeOut });
      det.position.set(lerp(-9, -2.9, easeOutBack(seg(t, T('Wrong') + 0.15, T('Wrong') + 0.45))), -1.4, 0.5);
      det.userData.pose({ eyes: 'wide', mouth: 'o', armR: 0.4, look: 1 });
      K.cam(s, [0, 0, lerp(9, 8.2, seg(t, T('Wrong'), T('Wrong') + 1.2))], [0, 0, 0], -0.05);
    };
  }
  // … one block per set …
  const cuts = [[0, 'feed'], [T('and') - 0.02, 'orange'], [T('Wrong') - 0.05, 'red'] /* … */];
  const WIPES = [[T('Because') - 0.15, 'red'] /* … */];
  function overlay(g, t, name) { /* 2D: subtitles, chips, the comment panel, then the wipes */ }
  function effects(t, name) { return { /* flash, zoom, pix, rgb, inv, sat, scan, whip, shake, fade, post */ }; }
  return { cuts, sets, overlay, effects };
}
```

### 7.2 What the engine gives a scene

| Name | What it is |
|---|---|
| `E.T(word, n)` · `E.TE(word, n)` | the token's start / end time (throws if it doesn't exist: a typo fails loudly) |
| `E.K.newSet(E, { fov, bg, fog })` | `{ scene, camera, hud, hudCam, words: [], post: {}, update }`; give it `update(t)` |
| `E.K.cam(set, p, l, roll)` | place the set's camera (every frame) |
| `E.S.character(E, kind, { h })` · `E.S.sprite(E, canvas, { h, anchor })` · `E.S.drawGlyph(kind, colour)` · `E.S.blit(g, canvas, x, y, scale)` | characters · any pixel canvas as a plane · glyphs · a pixel canvas in 2D |
| `E.T3.word(E, str, o)` | 3D type (section 3.5); push it into `set.words` so its box is checked |
| `E.snapshot(set, t, E.rt(key))` · `E.screenMat(tex)` | another set rendered at time t into a texture · a plane material that shows it |
| `E.O.markBox(kind, text, x0, y0, x1, y1)` · `E.O.setMap(fn)` · `E.O.rr(g, x, y, w, h, r)` | record a 2D text box (design space) · the design -> frame map · a rounded rect |
| `E.film` (`words`, `env`, `END`) · `E.W`, `E.H` | the timings · the frame size |
| return `{ cuts, sets, overlay(g, t, setName), effects(t, setName) }` | `cuts` = `[[t0, setName], …]`, the set shown from t0 on |

### 7.3 The helpers (in Appendix B's scene.js; copy them)

- `hudWord(set, str, sx, sy, heightPx, o)`: a 3D word on the hud layer at design (sx, sy), `heightPx` tall in design px. `at(set, obj, sx, sy, d)`: put any object on the hud layer.
- `kin(obj, t, { at, dur, from: { p, r, s }, out, outDur, to, ease, float })`: the fly-in / fly-out from the rest pose; returns 0..1.
- `letters(word, t, at, { stagger, dur, drop, spin, out })`: letter rain (the word needs `letters: true`).
- `shakeOf(t, [t0, …], amp)`: a decaying camera shake from each slam.
- `LAND`, `KS` (0.9), `LX` (1.8: multiply a character's x offsets in landscape), `MX`, `MY`, `MAP2`.

### 7.4 The edit follows the words

- **Cut on the word**: the cut 0.02-0.15 s before the beat's first word (0.25-0.4 s when a wipe, a whip or a dissolve covers it). A new set every 1.5-4 s; a set can return later (the desk comes back for "this video").
- **Type on the word**: start the fly-in 0.04-0.06 s before the word it shows, 0.2-0.3 s long. A slam (the verdict, the price, the name) gets a shake, a flash or an RGB split and a hit sound on the same frame.
- **Every claim is a literal picture**: "blowing up" -> a feed whose counters climb; "people took it" -> hands and cursors grab a file and yank it off-screen; "repackaged" -> it lands as a COURSE box on a pedestal; "sold it for $30" -> a swinging price tag; "an entire library" -> nine files flying into one window; "no paywalls / subscriptions / strings" -> a dot wall, a dot moon, dot strings blowing away; "this video" -> the film on its own monitor.
- **Something hits every ~0.3 s.** Calm holds ≤ 0.6 s. Cameras always move a little (a slow push, an orbit, a roll of 0.02-0.05).

### 7.5 Pacing (the example)

39.2 s, 15 cuts, 6 wipes: the hook 1.8 s (feed) + 3.1 s (orange stack), the turn 1.1 s, subtitles 3.2 s, the stage 4.0 s, the fly-through 2.4 s, the face 0.8 s, the desk 2.7 s, clouds 1.5 s, the library 4.4 s, the cosmos 3.4 s, the monitor 1.8 s, the player 1.2 s, the name 2.1 s, the CTA 5.8 s.

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

1. **Every frame renders alone, in any order**: everything is a function of `t`. No `Math.random` (use `hash(i)`), no state carried between frames. A set's `update(t)` must set every property it ever changes.
2. **Type that must read goes on the hud layer** (`hudWord` / `at`): world type gets depth of field and blurs with the set.
3. **Keep text in the safe zone**: design x 150-900, y 380-1400. Words that leave the frame on purpose (a fly-out, a dive through the lens) are `noMark` while they do.
4. **The safe box of a 3D word is its front face only**: the side layers project wider; `screenBox` already uses the visible front meshes. Don't add other meshes with `renderOrder` 2.
5. **Flashes**: a full white frame compresses to a tiny JPEG that looks like a lost WebGL frame. Keep a full-frame flash ≤ 0.6 on a light set, or make sure the frame keeps detail; `render.mjs` retries small frames and keeps them on the 3rd try with a warning, and `encode.mjs` refuses to encode with any frame missing (ffmpeg would stop at the gap and the video would end short).
6. **`T('word', n)` is an occurrence of the exact token**: after every rebuild, check the beats keyed to repeated words ("the", "of", "It's", "No").
7. **Snapshots** run the other set's `update(t)` at the time you pass and nest at most two deep.

## 8. Stills and the safe zone

```bash
node engine/render.mjs my-video stills 0.15,1.6,3,5.5,8.9,12.5,13.8,17,20.3,22.5,26.6,30,32.8,37 --safe
```
Look at `stills/sheet-safe.jpg` (red edges = keep text out, every text box green or red). The run prints `OUTSIDE SAFE ZONE` lines and page errors: both must be empty. Check: frame 0 (a negative with type falling in), each set's first readable frame (the type complete, the character placed, nothing cut by the frame edge), a slam (the shake and the split visible), the wipes (blocks covering the frame), the CTA panel (everything readable on the right, the characters on the left). Then a few stills without `--safe` for colour.

## 9. The full render

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

## 10. Sound

- **The bed:** Mixkit "Hip Hop 02" (#738) from 0 s at `db -16`: about 13 LU under the voice after the voice ducks it. `muffle: [[t0, t1]]` low-passes the bed in a window (the example muffles it from "And this video…" to the name, so the reveal feels inside the film). `STEM=vo` / `STEM=music node engine/mix.mjs my-video` measure them.
- **The kit** (`sfx/<name>.wav`, from `synth-sfx.mjs`): `whoosh` (a cut, a fly-in), `swoosh` / `swish` (a word slides, a flick), `impact` (a slam, a big landing), `thud` (a heavy landing), `slap` (a word slams flat), `stamp` (a verdict, a FREE stamp), `pop` / `pop_hi` / `pop_hi2` (small words and glyphs pop, alternate pitches), `boing` (a jump), `click` (subtitle words, the player flicking), `clack` (files landing), `type` (a typed line), `typing` (a keyboard), `blocks` (a pixel wipe, letters raining), `burst` (dots blowing away, confetti), `beep` (a pixel HUD), `ding` (a value lands), `chime` (a reveal, the name, the CTA end), `shimmer` (something turns good), `clang` (a box hits a pedestal), `kaching` (a price), `flick` (a quick cut to a close-up), `riser` (into a big moment), `plane` (the cloud rush), `glitch` (a glitch cut, frame 0).
- **Levels:** most cues -15 to -22 dB; slams -10 to -14; ticks and clicks -21 to -26; a wipe's blocks -18.
- **One sound on every cut and every slam** (the reference has a sound on almost every motion). `cues.mjs` gets `ev(name, t, dB, maxDur?)` and `film` (Appendix B).

## 11. Mix, encode, check, deliver

- `node engine/mix.mjs my-video` -> `mix.wav`: the voice compressed and brightened on top, the bed ducked under it, the cues; the whole thing brought to **-14 LUFS integrated, true peak ≤ -1 dBTP**.
- `node engine/encode.mjs my-video` -> `video.mp4` (**1920x1080**, H.264 High, yuv420p TV range, 30 fps, crf 20, maxrate ≤ 10 Mb/s, AAC 192k, faststart, ≤ 60 MB) + `cover.jpg` (the frame at `cover`, 1.6 s: the hook with its type).
- `node engine/check.mjs my-video` checks format, length (**a file shorter than the film is reported as a test clip: a full encode must equal `END`**), size, loudness, frame 0, white-outs (a frame over 235 on a 0-255 grey scale, or 3 in a row over 212; single flash frames are part of the look), stretches over 0.6 s where nothing moves, the cut rhythm and the safe zone on every frame.
- **Watch it once with sound**, then deliver `video.mp4`, `cover.jpg` and the script. Delete `frames/` (~370 MB).

## 12. Checklist

- [ ] 85-120 words, first person; a hook question, a turn, 4-7 beats with something showable, the "this video" reveal, the name, the CTA keyword ≤ 10 letters.
- [ ] Frame 0 = the hook set as a negative with type falling in; colour snaps in at 0.3 s.
- [ ] A new set on every beat, cut on its word; 12-16 sets; wipes on the big turns.
- [ ] Every key word is 3D type, on the word; the slams shake, flash or split; something hits every ~0.3 s.
- [ ] The characters act (jump in, react, walk, talk); no real screenshots, logos or handles.
- [ ] The CTA: the panel on the right, the keyword typed, the FREE stamp; the characters on the left.
- [ ] `--safe` stills clean; `check.mjs` all ✓ with the full length; watched once with sound.

---

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

The browser part (`film.html`, `main.js` and the `.js` modules) and the Node scripts (`.mjs`). Write every file once; a video never edits them. `overlay.js` also carries spare 2D helpers (pills, a caption box, titles) that the example doesn't use.

### `engine/film.html`

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

### `engine/main.js`

```js
// Cutscene engine boot: fonts → film.json + scene.js → sets (one three.js scene + camera each) → window.setT(t).
// A video is a cut list over sets: [[t0, 'set'], …]; each set places its camera and animates itself as a pure function of t.
// Any frame renders alone. A set can show another set live on a screen (E.snapshot → a render target).
import * as THREE from 'three';
import { makePost } from './post.js';
import * as O from './overlay.js';
import * as L from './lib.js';
import * as S from './sprites.js';
import * as T3 from './type3d.js';
import * as K from './sets.js';

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

const FONTS = [['Inter', 'Inter.ttf', '100 900'], ['JetBrains Mono', 'JetBrainsMono.ttf', '100 800'], ['Fredoka', 'Fredoka.ttf', '300 700'], ['Anton', 'Anton.ttf', '400'], ['Syne', 'Syne.ttf', '400 800'], ['Silkscreen', 'Silkscreen.ttf', '400'], ['Silkscreen', 'Silkscreen-Bold.ttf', '700']];
for (const [n, f, wt] of FONTS) { const ff = new FontFace(n, `url(${new URL(`../fonts/${f}`, import.meta.url)})`, { weight: wt }); await ff.load(); document.fonts.add(ff); }
for (const s of ['400 40px Inter', '600 40px Inter', '700 40px Inter', '800 40px Inter', '900 40px Inter', '700 40px "JetBrains Mono"', '600 40px Fredoka', '700 40px Fredoka', '400 40px Anton', '700 40px Syne', '800 40px Syne', '400 40px Silkscreen', '700 40px Silkscreen']) await document.fonts.load(s);
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 post = makePost(renderer, W, H);

const ocv = document.createElement('canvas'); ocv.width = W; ocv.height = H; const og = ocv.getContext('2d');
const oTex = new THREE.CanvasTexture(ocv); oTex.generateMipmaps = false; oTex.minFilter = THREE.LinearFilter; post.F.tO.value = oTex;
const blank = new THREE.CanvasTexture(document.createElement('canvas'));
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 T = (cap, n = 1) => { let k = 0; for (const w of film.words) if (w.cap === cap && ++k === n) return w.t0; throw new Error(`no word "${cap}" #${n}`); };
const TE = (cap, n = 1) => { let k = 0; for (const w of film.words) if (w.cap === cap && ++k === n) return w.t1; throw new Error(`no word "${cap}" #${n}`); };
O.setFrame(W, H);
const E = { THREE, renderer, post, film, END, W, H, O, L, S, T3, K, T, TE };

// post settings a set may carry: { focus, aperture, bloom, thr, vig, grain, exposure }
const DEF = { focus: 5, aperture: 0, bloom: 0.35, thr: 0.8, vig: 0.18, grain: 0.02, exposure: 1 };
function applyPost(p = {}, fx = {}) {
  const U = post.U, F = post.F, P = { ...DEF, ...p, ...(fx.post || {}) };
  U.focus.value = P.focus; U.aperture.value = P.aperture; U.bloom.value = P.bloom; U.exposure.value = P.exposure; post.brightM.uniforms.thr.value = P.thr;
  F.vig.value = P.vig; F.grain.value = P.grain;
  F.zoom.value = fx.zoom || 0; F.zoomC.value.set(...(fx.zoomC || [0.5, 0.5])); F.mbDir.value.set(...(fx.whip || [0, 0]));
  F.pix.value = fx.pix || 0; F.rgb.value = fx.rgb || 0; F.inv.value = fx.inv || 0; F.satB.value = fx.sat || 0; F.scan.value = fx.scan || 0;
  F.leak.value = fx.leak || 0; F.leakX.value = fx.leakX || 0;
  F.flash.value = fx.flash || 0; F.flashMax.value = 0.9; F.flashCol.value.setRGB(...(fx.flashCol || [1, 1, 1]));
  F.oShift.value.set(...(fx.shake || [0, 0])); F.fade.value = fx.fade ?? 1; F.ca.value = fx.ca || 0;
}
// render set `name` at time t into a render target (for screens inside other sets)
const RTS = {};
E.rt = (key, w = W / 2, h = H / 2) => (RTS[key] ||= new THREE.WebGLRenderTarget(w, h, { type: THREE.UnsignedByteType, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, generateMipmaps: false }));
let depth = 0;
E.snapshot = (name, t, rt, fx = {}) => {
  if (depth > 1) return rt.texture; depth++;
  const set = V.sets[name]; set.update(t); set.camera.updateMatrixWorld(true);
  applyPost(set.post, fx); post.F.tO.value = blank; post.F.oOn.value = 0;
  post.render(set.scene, set.camera, rt, set.hud, set.hudCam);
  post.F.tO.value = oTex; post.F.oOn.value = 1; depth--;
  return rt.texture;
};
// a material that shows a display-space texture (a snapshot) inside a lit-free set
E.screenMat = (tex, o = {}) => new THREE.ShaderMaterial({
  uniforms: { t: { value: tex }, b: { value: o.b ?? 1 }, a: { value: 1 } }, transparent: !!o.transparent, side: THREE.DoubleSide,
  vertexShader: 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.); }',
  fragmentShader: 'varying vec2 vUv; uniform sampler2D t; uniform float b, a; void main(){ vec3 c = texture2D(t, vUv).rgb; gl_FragColor = vec4(pow(c, vec3(2.2)) * b, a); }',
});

const V = mod.default(E);
if (!V.cuts || !V.sets) throw new Error('scene.js must return { cuts, sets, overlay, effects }');
const visible = o => { for (let p = o; p; p = p.parent) if (!p.visible) return false; return true; };
function markWords(set) {
  if (!set.words) return;
  for (const w of set.words) {
    if (!w || !visible(w) || (w.userData.alpha ?? 1) < 0.5 || w.userData.noMark) continue;
    const b = T3.screenBox(w, w.userData.hud ? set.hudCam : set.camera, W, H); if (!b) continue;
    if (b.x1 < 0 || b.x0 > W || b.y1 < 0 || b.y0 > H) continue;
    const s = w.userData.markShrink ?? 0.16, dx = (b.x1 - b.x0) * s / 2, dy = (b.y1 - b.y0) * s / 2;   // the canvas padding around the glyphs
    O.markRaw('3d', w.userData.text, b.x0 + dx, b.y0 + dy, b.x1 - dx, b.y1 - dy);
  }
}
const setAt = t => { let n = V.cuts[0][1]; for (const [t0, s] of V.cuts) if (t >= t0) n = s; return n; };
function setT(t) {
  t = L.clamp(t, 0, END);
  const name = setAt(t), set = V.sets[name];
  window.__set = name;
  set.update(t); set.camera.updateMatrixWorld(true);
  const fx = V.effects ? V.effects(t, name) : {};
  applyPost(set.post, fx); post.F.time.value = t;
  og.clearRect(0, 0, W, H); O.beginFrame(); markWords(set); og.save(); V.overlay?.(og, t, name); og.restore(); oTex.needsUpdate = true;
  post.render(set.scene, set.camera, null, set.hud, set.hudCam);
  if (safeOn) O.drawSafe(scv.getContext('2d'));
  return true;
}
window.setT = setT;
window.safeReport = () => O.violations();
window.textBoxes = () => O.boxes().map(b => ({ ...b, x0: Math.round(b.x0), x1: Math.round(b.x1), y0: Math.round(b.y0), y1: Math.round(b.y1) }));
window.framing = {};
setT(+(q.get('t') || 0));
window.__ready = true;
```

### `engine/lib.js`

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

### `engine/sets.js`

```js
// Building blocks for the sets: backgrounds, pixel textures, voxel props (desk, keyboard, monitor, file, course box),
// the theatre stage (wood wall + red pixel floor + glowing signs), the tron grid, clouds, stars, dot spheres. Engine file.
import * as THREE from 'three';
import { hash, rng } from './lib.js';

export function newSet(E, { fov = 40, bg = 0x000000, fog = null, near = 0.05, far = 400 } = {}) {
  const scene = new THREE.Scene(); scene.background = new THREE.Color(bg);
  if (fog) scene.fog = new THREE.Fog(fog[0], fog[1], fog[2]);
  if (E.W > E.H) fov *= 0.8;   // landscape: a tighter lens so subjects fill the wide frame
  const camera = new THREE.PerspectiveCamera(fov, E.W / E.H, near, far); scene.add(camera);
  // the type layer: its own scene + a fixed camera at the origin, composited after depth of field (so type is never blurred or hidden)
  const hud = new THREE.Scene(), hudCam = new THREE.PerspectiveCamera(40, E.W / E.H, 0.05, 100);
  return { scene, camera, hud, hudCam, words: [], post: {}, update() { } };
}
// place the camera: p = position, l = look-at, roll radians, fov
export function cam(set, p, l, roll = 0, fov) {
  const c = set.camera; c.position.set(...p); c.up.set(0, 1, 0); c.lookAt(...l); if (roll) c.rotateZ(roll);
  if (fov && c.fov !== fov) { c.fov = fov; c.updateProjectionMatrix(); }
}
export function canvasTex(w, h, draw, { nearest = false } = {}) {
  const c = document.createElement('canvas'); c.width = w; c.height = h; const g = c.getContext('2d'); draw(g, w, h);
  const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 8;
  if (nearest) { t.magFilter = THREE.NearestFilter; t.minFilter = THREE.NearestFilter; t.generateMipmaps = false; }
  t.userData = { c, g, redraw: (fn) => { fn(g, w, h); t.needsUpdate = true; } };
  return t;
}
// a backdrop glued to the camera, filling the frame at distance d
export function backdrop(set, tex, d = 150, o = {}) {
  const c = set.camera, hh = 2 * d * Math.tan(c.fov * Math.PI / 360) * (o.over ?? 1.25), ww = hh * c.aspect;
  const m = new THREE.Mesh(new THREE.PlaneGeometry(ww, hh), new THREE.MeshBasicMaterial({ map: tex, depthWrite: false, fog: false, color: o.color ?? 0xffffff }));
  m.position.z = -d; m.renderOrder = -10; c.add(m); return m;
}
export const radial = (inner, outer, { cx = 0.5, cy = 0.45, r = 0.75 } = {}) => canvasTex(512, 910, (g, w, h) => {
  const gr = g.createRadialGradient(w * cx, h * cy, 0, w * cx, h * cy, h * r); gr.addColorStop(0, inner); gr.addColorStop(1, outer); g.fillStyle = gr; g.fillRect(0, 0, w, h);
  // soft blotches for depth
  for (let i = 0; i < 40; i++) { const x = hash(i * 3.1) * w, y = hash(i * 7.7) * h, rr = 40 + hash(i * 1.9) * 140; const b = g.createRadialGradient(x, y, 0, x, y, rr); b.addColorStop(0, `rgba(255,255,255,${0.025 + hash(i) * 0.03})`); b.addColorStop(1, 'rgba(255,255,255,0)'); g.fillStyle = b; g.fillRect(x - rr, y - rr, rr * 2, rr * 2); }
});
// pixel noise texture (n×m cells, palette)
export function pixTex(n, m, pal, seed = 1, extra) {
  return canvasTex(n, m, (g) => { const R = rng(seed); for (let y = 0; y < m; y++) for (let x = 0; x < n; x++) { g.fillStyle = pal[Math.floor(R() * pal.length)]; g.fillRect(x, y, 1, 1); } extra?.(g, n, m); }, { nearest: true });
}
const std = (o) => new THREE.MeshStandardMaterial({ roughness: 0.85, metalness: 0, ...o });

// ---------- floating squares (embers, dust) ----------
export function embers(set, { n = 60, box = [8, 12, 6], center = [0, 0, -2], color = 0xffd23d, size = 0.06, speed = 0.25, seed = 3 } = {}) {
  const geo = new THREE.PlaneGeometry(size, size), mat = new THREE.MeshBasicMaterial({ color, transparent: true, depthWrite: false, fog: false });
  const im = new THREE.InstancedMesh(geo, mat, n); const R = rng(seed), P = Array.from({ length: n }, () => [R(), R(), R(), R()]);
  const M = new THREE.Matrix4(), Q = new THREE.Quaternion(), Sc = new THREE.Vector3(), V = new THREE.Vector3();
  im.userData.update = (t, cam) => {
    if (cam) Q.copy(cam.quaternion);
    P.forEach((p, i) => { const y = ((p[1] + t * speed * (0.5 + p[3])) % 1) - 0.5; V.set(center[0] + (p[0] - 0.5) * box[0] + Math.sin(t * 0.7 + i) * 0.15, center[1] + y * box[1], center[2] + (p[2] - 0.5) * box[2]); const s = 0.6 + p[3] * 0.9 * (0.6 + 0.4 * Math.sin(t * 3 + i)); Sc.set(s, s, s); M.compose(V, Q, Sc); im.setMatrixAt(i, M); });
    im.instanceMatrix.needsUpdate = true;
  };
  set.scene.add(im); return im;
}

// ---------- the theatre stage: wood-panel wall, red pixel floor, glowing signs ----------
export function stage(set, { floorW = 16, floorD = 12, seed = 5, spot = [0, 0, 0] } = {}) {
  const S = set.scene, g = new THREE.Group(); S.add(g);
  const wall = canvasTex(512, 256, (c, w, h) => {
    c.fillStyle = '#2a120c'; c.fillRect(0, 0, w, h);
    for (let x = 0; x < w; x += 16) { const k = hash(x * 0.37); c.fillStyle = `rgb(${70 + k * 30},${30 + k * 14},${20 + k * 10})`; c.fillRect(x, 0, 14, h); c.fillStyle = 'rgba(0,0,0,0.55)'; c.fillRect(x + 14, 0, 2, h); for (let y = 0; y < h; y += 4) { c.fillStyle = `rgba(255,190,140,${hash(x + y * 3.1) * 0.06})`; c.fillRect(x + 2, y, 10, 2); } }
  });
  wall.wrapS = THREE.RepeatWrapping; wall.repeat.set(2, 1);
  const wm = new THREE.Mesh(new THREE.PlaneGeometry(40, 14), std({ map: wall, roughness: 0.7 })); wm.position.set(0, 5, -6); g.add(wm);
  // the red pixel floor: instanced voxel tiles
  const T = 0.25, nx = Math.round(floorW / T), nz = Math.round(floorD / T), N = nx * nz, R = rng(seed);
  const im = new THREE.InstancedMesh(new THREE.BoxGeometry(T * 0.98, 0.1, T * 0.98), std({ roughness: 0.6 }), N);
  const M = new THREE.Matrix4(), C = new THREE.Color(); let i = 0;
  for (let z = 0; z < nz; z++) for (let x = 0; x < nx; x++) {
    const px = (x - nx / 2) * T, pz = -6 + z * T, d = Math.hypot(px - spot[0], (pz - spot[2]) * 1.4);
    M.makeTranslation(px, -0.05 + R() * 0.03, pz); im.setMatrixAt(i, M);
    const v = R(), ring = Math.abs(d - 3.2) < 0.13 ? 0.35 : 0;
    C.setRGB(0.55 + v * 0.35 + ring, 0.02 + v * 0.05, 0.03 + v * 0.04).multiplyScalar(d < 3.2 ? 1.0 : 0.62); im.setColorAt(i++, C);
  }
  g.add(im);
  // lights
  S.add(new THREE.HemisphereLight(0xffd0b0, 0x300806, 0.9));
  const sp = new THREE.SpotLight(0xffe6d0, 60, 30, 0.55, 0.6, 1.4); sp.position.set(0, 9, 4); sp.target.position.set(0, 0, -1); S.add(sp, sp.target);
  const rim = new THREE.PointLight(0xff3020, 25, 14, 1.5); rim.position.set(-3, 3, -4); S.add(rim);
  return { group: g, wall: wm, floor: im };
}
export function glowSign(color = 0xff2a2a, w = 1.6, h = 0.9) {
  const g = new THREE.Group();
  const frame = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.1), new THREE.MeshBasicMaterial({ color: new THREE.Color(color).multiplyScalar(1.6) }));
  const inner = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.78, h * 0.62), new THREE.MeshBasicMaterial({ color: new THREE.Color(color).multiplyScalar(0.35) })); inner.position.z = 0.051;
  g.add(frame, inner); return g;
}

// ---------- voxel props ----------
export function fileProp({ w = 1.0, h = 1.3, label = '', band = '#c0141a' } = {}) {
  const front = canvasTex(40, 52, (g, W, H) => {
    const R = rng(9); for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { const v = R(); g.fillStyle = v < 0.33 ? '#e7cfa6' : v < 0.66 ? '#dcc095' : '#efdcb8'; g.fillRect(x, y, 1, 1); }
    // dog-ear
    for (let y = 0; y < 9; y++) for (let x = W - 9 + y; x < W; x++) g.clearRect(x, y, 1, 1);
    for (let y = 0; y < 9; y++) for (let x = W - 9; x < W - 9 + y; x++) { g.fillStyle = y === x - (W - 9) + 1 ? '#8a6e4a' : '#c9ad84'; g.fillRect(x, y, 1, 1); }
    g.fillStyle = band; g.fillRect(3, 13, W - 6, 7); g.fillStyle = '#e8343f'; g.fillRect(3, 13, W - 6, 1);
    g.fillStyle = '#2b2522'; for (let k = 0; k < 4; k++) g.fillRect(7, 25 + k * 5, k === 3 ? 14 : W - 14, 2);
  }, { nearest: true });
  const side = new THREE.MeshStandardMaterial({ color: 0xe9b97a, roughness: 0.9 }), fm = new THREE.MeshStandardMaterial({ map: front, transparent: true, alphaTest: 0.5, roughness: 0.9, emissive: 0x3a2a10, emissiveIntensity: 0.6 });
  const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.14), [side, side, side, side, fm, fm]);
  const g = new THREE.Group(); g.add(m);
  if (label) { const lt = canvasTex(512, 96, (c, W, H) => { c.font = '700 54px "JetBrains Mono"'; c.fillStyle = '#fff'; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(label, W / 2, H / 2); }); const lm = new THREE.Mesh(new THREE.PlaneGeometry(1.6, 0.3), new THREE.MeshBasicMaterial({ map: lt, transparent: true })); lm.position.set(0, -h / 2 - 0.28, 0); g.add(lm); g.userData.label = lm; }
  return g;
}
export function courseBox() {
  const tex = canvasTex(256, 300, (g, W, H) => {
    const R = rng(4); for (let y = 0; y < H; y += 4) for (let x = 0; x < W; x += 4) { g.fillStyle = R() < 0.5 ? '#a81419' : '#b8191f'; g.fillRect(x, y, 4, 4); }
    g.font = '900 50px Fredoka'; g.fillStyle = '#fff'; g.textAlign = 'center'; g.fillText('COURSE', W / 2, 120); g.font = '400 44px Silkscreen'; g.fillStyle = '#ffc63a'; g.fillText('$$$', W / 2, 190);
  });
  const red = new THREE.MeshStandardMaterial({ color: 0x8e1015, roughness: 0.8 }), fm = new THREE.MeshStandardMaterial({ map: tex, roughness: 0.8 });
  const box = new THREE.Mesh(new THREE.BoxGeometry(1.4, 1.65, 0.6), [red, red, red, red, fm, red]);
  const ped = new THREE.Mesh(new THREE.BoxGeometry(1.7, 1.0, 1.0), new THREE.MeshStandardMaterial({ color: 0x141416, roughness: 0.5 })); ped.position.y = -1.33;
  const top = new THREE.Mesh(new THREE.BoxGeometry(1.75, 0.04, 1.05), new THREE.MeshStandardMaterial({ color: 0x5f7f86, roughness: 0.4 })); top.position.y = -0.83;
  const g = new THREE.Group(); g.add(box, ped, top); g.userData.box = box; return g;
}
export function priceTag(text = '$30') {
  const tex = canvasTex(360, 200, (g, W, H) => {
    g.fillStyle = '#ffc63a'; g.beginPath(); g.moveTo(60, 10); g.lineTo(W - 10, 10); g.lineTo(W - 10, H - 10); g.lineTo(60, H - 10); g.lineTo(8, H / 2); g.closePath(); g.fill();
    g.lineWidth = 8; g.strokeStyle = '#2a1608'; g.stroke(); g.fillStyle = '#2a1608'; g.beginPath(); g.arc(52, H / 2, 12, 0, 7); g.fill();
    g.font = '900 120px Fredoka'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillStyle = '#2a1608'; g.fillText(text, W / 2 + 30, H / 2 + 6);
  });
  return new THREE.Mesh(new THREE.PlaneGeometry(1.8, 1.0), new THREE.MeshBasicMaterial({ map: tex, transparent: true, side: THREE.DoubleSide }));
}
export function keyboard({ cols = 15, rows = 5, key = 0.16, accent = [3, 8, 11, 19, 27, 40, 52, 60] } = {}) {
  const g = new THREE.Group();
  const base = new THREE.Mesh(new THREE.BoxGeometry(cols * key + 0.2, 0.1, rows * key + 0.2), std({ color: 0xece6dc })); g.add(base);
  const im = new THREE.InstancedMesh(new THREE.BoxGeometry(key * 0.78, 0.07, key * 0.78), std({ roughness: 0.5 }), cols * rows), M = new THREE.Matrix4(), C = new THREE.Color();
  let i = 0; for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) { M.makeTranslation((c - cols / 2 + 0.5) * key, 0.08, (r - rows / 2 + 0.5) * key); im.setMatrixAt(i, M); im.setColorAt(i, C.set(accent.includes(i) ? 0xf07a3a : 0x1d1b1e)); i++; }
  g.add(im); g.userData.keys = im; g.userData.press = (k, d) => { im.getMatrixAt(k, M); const p = new THREE.Vector3().setFromMatrixPosition(M); p.y = 0.08 - d * 0.04; M.makeTranslation(p.x, p.y, p.z); im.setMatrixAt(k, M); im.instanceMatrix.needsUpdate = true; };
  return g;
}
// a monitor: bezel + screen plane (material given or a canvas texture) + stand
export function monitor({ w = 3.2, h = 1.9, mat } = {}) {
  const g = new THREE.Group();
  const bez = new THREE.Mesh(new THREE.BoxGeometry(w + 0.16, h + 0.16, 0.12), std({ color: 0x1b1c20, roughness: 0.4 })); g.add(bez);
  const scr = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat || new THREE.MeshBasicMaterial({ color: 0x111111 })); scr.position.z = 0.065; g.add(scr);
  const st = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.9, 0.12), std({ color: 0x2a2b30 })); st.position.set(0, -h / 2 - 0.4, -0.12); g.add(st);
  const ft = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.06, 0.6), std({ color: 0x2a2b30 })); ft.position.set(0, -h / 2 - 0.86, -0.05); g.add(ft);
  g.userData.screen = scr; return g;
}
// the voxel desk: orange pixel top, lamp, books, plant, mug
export function desk(set, { w = 7, d = 3.2 } = {}) {
  const g = new THREE.Group(), top = pixTex(56, 26, ['#c45f22', '#d16a2a', '#b85520', '#cf6d2f', '#a94c1c']);
  const t = new THREE.Mesh(new THREE.BoxGeometry(w, 0.22, d), std({ map: top })); t.position.y = -0.11; g.add(t);
  const lamp = new THREE.Group(); const lb = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.08, 0.5), std({ color: 0x3a2a1a })); const la = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.9, 0.08), std({ color: 0x3a2a1a })); la.position.y = 0.45;
  const lh = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.32, 0.5), new THREE.MeshBasicMaterial({ color: new THREE.Color(0xffc24a).multiplyScalar(1.5) })); lh.position.set(0.15, 0.95, 0); lamp.add(lb, la, lh); lamp.position.set(-w / 2 + 0.7, 0, -d / 2 + 0.6); g.add(lamp);
  const pl = new THREE.PointLight(0xffb060, 18, 9, 1.6); pl.position.set(-w / 2 + 0.9, 1.2, -d / 2 + 0.9); g.add(pl);
  const bc = [0x8e1b1b, 0x2d6b4f, 0xc79a2a, 0x3a3f7a];
  bc.forEach((c, i) => { const b = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.75 + (i % 2) * 0.12, 0.6), std({ color: c })); b.position.set(-w / 2 + 0.25 + i * 0.2, 0.4, -d / 2 + 0.35); g.add(b); });
  const mug = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.36, 0.3), std({ color: 0xd8342c })); mug.position.set(w / 2 - 0.8, 0.18, 0.3); g.add(mug);
  const pot = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.45, 0.5), std({ color: 0xa8552c })); pot.position.set(w / 2 - 0.5, 0.22, -d / 2 + 0.5); g.add(pot);
  const R = rng(12); for (let i = 0; i < 26; i++) { const lf = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.16, 0.16), std({ color: R() < 0.5 ? 0x2f8f3a : 0x46b04c })); lf.position.set(w / 2 - 0.5 + (R() - 0.5) * 0.6, 0.55 + R() * 0.9, -d / 2 + 0.5 + (R() - 0.5) * 0.6); g.add(lf); }
  set.scene.add(g); return g;
}
// the blocky builder seen from behind (flat cap, brown coat) with arms reaching to the keyboard
export function voxGuy() {
  const g = new THREE.Group(), coat = std({ color: 0x6b4a36 }), coatD = std({ color: 0x4f3626 }), hair = std({ color: 0x1d1a20 }), cap = std({ color: 0x7b6656 }), skin = std({ color: 0xf2c9a3 });
  const torso = new THREE.Mesh(new THREE.BoxGeometry(1.5, 1.6, 0.9), coat); torso.position.y = 0.8; g.add(torso);
  const head = new THREE.Mesh(new THREE.BoxGeometry(1.0, 1.0, 1.0), hair); head.position.y = 2.1; g.add(head);
  const capC = new THREE.Mesh(new THREE.BoxGeometry(1.16, 0.32, 1.16), cap); capC.position.y = 2.65; g.add(capC);
  const brim = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.1, 0.4), cap); brim.position.set(0, 2.5, -0.7); g.add(brim);
  const btn = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.1, 0.16), coatD); btn.position.y = 2.86; g.add(btn);
  const arms = [-1, 1].map(s => { const a = new THREE.Group(); const up = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.42, 1.25), coatD); up.position.z = -0.55; const hd = new THREE.Mesh(new THREE.BoxGeometry(0.36, 0.3, 0.3), skin); hd.position.z = -1.25; a.add(up, hd); a.position.set(s * 0.82, 1.35, -0.1); g.add(a); return a; });
  g.userData.arms = arms; return g;
}

// ---------- tron grid floor ----------
export function gridFloor(set, { color = 0x22e0d0, size = 80, cell = 1, y = 0, fade = 40 } = {}) {
  const m = new THREE.ShaderMaterial({
    transparent: true, depthWrite: false, uniforms: { col: { value: new THREE.Color(color) }, cell: { value: cell }, fade: { value: fade }, glow: { value: 1 } },
    vertexShader: 'varying vec3 wp; void main(){ vec4 w = modelMatrix * vec4(position,1.); wp = w.xyz; gl_Position = projectionMatrix * viewMatrix * w; }',
    fragmentShader: `varying vec3 wp; uniform vec3 col; uniform float cell, fade, glow;
      void main(){ vec2 g = abs(fract(wp.xz / cell - 0.5) - 0.5) / fwidth(wp.xz / cell); float l = 1. - min(min(g.x, g.y), 1.);
        float d = length(wp.xz - cameraPosition.xz); float f = exp(-d / fade);
        gl_FragColor = vec4(col * (0.35 + 2.2 * l) * glow, (0.12 + 0.88 * l) * f); }`,
  });
  const p = new THREE.Mesh(new THREE.PlaneGeometry(size, size), m); p.rotation.x = -Math.PI / 2; p.position.y = y; set.scene.add(p); return p;
}
// ---------- dotted trails (points along a curve, travelling) ----------
export function dotTrail(set, pts, { n = 40, colors = [0xff4fa0, 0x4fe0d0], size = 0.06 } = {}) {
  const curve = new THREE.CatmullRomCurve3(pts.map(p => new THREE.Vector3(...p))), geo = new THREE.BufferGeometry(), pos = new Float32Array(n * 3), col = new Float32Array(n * 3);
  geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
  const P = new THREE.Points(geo, new THREE.PointsMaterial({ size, vertexColors: true, transparent: true, depthWrite: false, sizeAttenuation: true }));
  const C = colors.map(c => new THREE.Color(c));
  P.userData.update = (t, k0 = 0, k1 = 1) => { for (let i = 0; i < n; i++) { const u = k0 + (k1 - k0) * ((i / n + t * 0.4) % 1); const v = curve.getPointAt(Math.min(1, Math.max(0, u))); pos.set([v.x, v.y, v.z], i * 3); const c = C[i % C.length]; col.set([c.r * 2, c.g * 2, c.b * 2], i * 3); } geo.attributes.position.needsUpdate = true; geo.attributes.color.needsUpdate = true; };
  set.scene.add(P); return P;
}

// ---------- clouds ----------
export function cloudTex(seed = 1, { lit = '#ffffff', mid = '#9fb4cc', dark = '#2b3b55' } = {}) {
  return canvasTex(256, 256, (g, w, h) => {
    const R = rng(seed);
    for (let i = 0; i < 60; i++) {
      const a = R() * Math.PI * 2, r = Math.sqrt(R()) * 80, x = w / 2 + Math.cos(a) * r, y = h / 2 + Math.sin(a) * r * 0.75, rr = 26 + R() * 34;
      const gr = g.createRadialGradient(x - rr * 0.3, y - rr * 0.35, 0, x, y, rr);
      gr.addColorStop(0, lit); gr.addColorStop(0.45, mid); gr.addColorStop(1, 'rgba(43,59,85,0)');
      g.globalAlpha = 0.35 + R() * 0.4; g.fillStyle = gr; g.beginPath(); g.arc(x, y, rr, 0, 7); g.fill();
    }
    g.globalAlpha = 1;
  });
}
// ---------- stars ----------
export function stars(set, { n = 1500, r = 60, size = 0.12, seed = 2 } = {}) {
  const R = rng(seed), pos = new Float32Array(n * 3);
  for (let i = 0; i < n; i++) { const u = R() * 2 - 1, a = R() * Math.PI * 2, rr = r * (0.6 + R() * 0.4); pos.set([Math.sqrt(1 - u * u) * Math.cos(a) * rr, u * rr, Math.sqrt(1 - u * u) * Math.sin(a) * rr], i * 3); }
  const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  const P = new THREE.Points(g, new THREE.PointsMaterial({ size, color: 0xffffff, sizeAttenuation: true, transparent: true, opacity: 0.85, depthWrite: false, fog: false })); set.scene.add(P); return P;
}
// a sphere of dots (dot-matrix moon); brightness from value noise
export function dotSphere(set, { r = 3, n = 9000, size = 0.05, seed = 4 } = {}) {
  const R = rng(seed), pos = new Float32Array(n * 3), col = new Float32Array(n * 3);
  for (let i = 0; i < n; i++) {
    const y = 1 - 2 * (i + 0.5) / n, rad = Math.sqrt(1 - y * y), a = i * 2.39996;
    const x = Math.cos(a) * rad, z = Math.sin(a) * rad; pos.set([x * r, y * r, z * r], i * 3);
    const lit = Math.max(0.06, (-x * 0.55 + y * 0.45 + z * 0.7)); const v = (0.45 + 0.35 * Math.sin(x * 5.1 + y * 3.3) * Math.cos(z * 4.2 - y * 2.1) + 0.2 * R()) * Math.min(1, lit * 1.4);
    col.set([v, v, v], i * 3);
  }
  const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(pos, 3)); g.setAttribute('color', new THREE.BufferAttribute(col, 3));
  const P = new THREE.Points(g, new THREE.PointsMaterial({ size, vertexColors: true, sizeAttenuation: true, transparent: true, depthWrite: false }));
  P.userData.base = pos.slice(); set.scene.add(P); return P;
}
// scatter a points cloud outward (dust): k 0..1
export function scatter(P, k, amt = 4, seed = 7) {
  const pos = P.geometry.attributes.position, b = P.userData.base;
  for (let i = 0; i < pos.count; i++) { const h1 = hash(i * 1.3 + seed), h2 = hash(i * 2.7 + seed), h3 = hash(i * 0.7 + seed); const e = k * k * amt * (0.4 + h1); pos.setXYZ(i, b[i * 3] * (1 + e * 0.3) + (h2 - 0.5) * e, b[i * 3 + 1] + (h3 - 0.3) * e, b[i * 3 + 2] * (1 + e * 0.3) + (h1 - 0.5) * e); }
  pos.needsUpdate = true; P.material.opacity = 1 - k * k;
}
// a plane of dots (a wall, a wave)
export function dotPlane(set, { nx = 60, ny = 90, w = 6, h = 9, size = 0.05, color = 0xffffff } = {}) {
  const n = nx * ny, pos = new Float32Array(n * 3); let i = 0;
  for (let y = 0; y < ny; y++) for (let x = 0; x < nx; x++) pos.set([(nx > 1 ? x / (nx - 1) - 0.5 : 0) * w, (y / (ny - 1) - 0.5) * h, 0], (i++) * 3);
  const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  const P = new THREE.Points(g, new THREE.PointsMaterial({ size, color, sizeAttenuation: true, transparent: true, depthWrite: false })); P.userData.base = pos.slice(); set.scene.add(P); return P;
}
// a B&W sun-surface texture (granules + loops)
export function sunTex(seed = 3) {
  return canvasTex(1024, 1024, (g, w, h) => {
    g.fillStyle = '#121012'; g.fillRect(0, 0, w, h); const R = rng(seed);
    for (let i = 0; i < 2600; i++) { const x = R() * w, y = R() * h, r = 4 + R() * 26, v = Math.floor(40 + R() * 150); g.fillStyle = `rgba(${v},${v - 6},${v},${0.18 + R() * 0.3})`; g.beginPath(); g.arc(x, y, r, 0, 7); g.fill(); }
    g.lineCap = 'round';
    for (let i = 0; i < 260; i++) { const x = R() * w, y = R() * h, r = 10 + R() * 60, a = R() * 6; g.strokeStyle = `rgba(255,235,250,${0.1 + R() * 0.45})`; g.lineWidth = 1 + R() * 3; g.beginPath(); g.arc(x, y, r, a, a + 1.2 + R() * 1.6); g.stroke(); }
  });
}
```

### `engine/sprites.js`

```js
// Pixel-art characters, drawn procedurally on a tiny grid (no image files), with an automatic dark outline.
// The detective-builder (flat cap, long coat) and the fedora Claude (an orange block with a hat), in poses.
// draw*(o) → a small canvas (1 px = 1 art pixel). sprite(E, drawFn) → a THREE plane with NearestFilter that can swap poses.
import * as THREE from 'three';

const OUT = '#16110f';
// raster: W×H grid of colour strings (null = empty)
function grid(W, H) { return { W, H, px: Array.from({ length: H }, () => Array(W).fill(null)) }; }
function put(G, x, y, c) { x = Math.round(x); y = Math.round(y); if (x >= 0 && y >= 0 && x < G.W && y < G.H && c) G.px[y][x] = c; }
function rect(G, x, y, w, h, c) { for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) put(G, x + i, y + j, typeof c === 'function' ? c(i, j) : c); }
function ell(G, cx, cy, rx, ry, c) { for (let y = Math.floor(cy - ry); y <= cy + ry; y++) for (let x = Math.floor(cx - rx); x <= cx + rx; x++) { const dx = (x + 0.5 - cx) / rx, dy = (y + 0.5 - cy) / ry; if (dx * dx + dy * dy <= 1) put(G, x, y, typeof c === 'function' ? c(x, y, dx, dy) : c); } }
// a thick limb from (x0,y0) along angle a (radians, 0 = down, + = towards +x) with length L and width w
function limb(G, x0, y0, a, L, w, c, end) {
  const dx = Math.sin(a), dy = Math.cos(a);
  for (let s = 0; s <= L; s += 0.35) for (let k = -w / 2; k <= w / 2; k += 0.35) put(G, x0 + dx * s - dy * k, y0 + dy * s + dx * k, typeof c === 'function' ? c(s / L, k) : c);
  if (end) { const ex = x0 + dx * (L + 1), ey = y0 + dy * (L + 1); end(ex, ey, a); }
  return [x0 + dx * L, y0 + dy * L];
}
function outline(G, col = OUT) {
  const add = [];
  for (let y = 0; y < G.H; y++) for (let x = 0; x < G.W; x++) if (!G.px[y][x]) {
    for (const [i, j] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const yy = y + j, xx = x + i; if (yy >= 0 && yy < G.H && xx >= 0 && xx < G.W && G.px[yy][xx] && G.px[yy][xx] !== col) { add.push([x, y]); break; } }
  }
  for (const [x, y] of add) G.px[y][x] = col;
}
function toCanvas(G, cv) {
  cv = cv || document.createElement('canvas'); cv.width = G.W; cv.height = G.H; const g = cv.getContext('2d'); g.clearRect(0, 0, G.W, G.H);
  for (let y = 0; y < G.H; y++) for (let x = 0; x < G.W; x++) if (G.px[y][x]) { g.fillStyle = G.px[y][x]; g.fillRect(x, y, 1, 1); }
  return cv;
}
const hsh = (x, y) => { const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453; return s - Math.floor(s); };

// ---------------- the detective-builder: 48 × 66 ----------------
// o: { armL, armR (radians: 0 = hanging, + = raised forward/out), legs (spread px), eyes: 'open'|'closed'|'happy'|'wide', mouth: 'smile'|'open'|'flat'|'o'|'grin',
//      item: null|'magnifier', look: -1..1 (eyes shift), squash }
export const DET = { cap: '#7b6656', capL: '#9b8574', capD: '#584739', hair: '#1d1a21', hairL: '#3b3542', skin: '#f7d4b1', skinD: '#e6ae8b', blush: '#f19b8a', coat: '#4d3e35', coatL: '#6a594d', coatD: '#352a22', shirt: '#211f25', pants: '#3b3b4a', pantsD: '#2a2a37', shoe: '#181818', eye: '#16110f', mouth: '#7e2f25' };
export function drawDetective(o = {}) {
  const C = DET, G = grid(48, 68);
  const armL = o.armL ?? 0.18, armR = o.armR ?? -0.18, legs = o.legs ?? 0, look = o.look ?? 0;
  // legs
  rect(G, 18 - legs, 52, 5, 11, (i, j) => j > 8 ? C.shoe : (i === 0 ? C.pantsD : C.pants));
  rect(G, 25 + legs, 52, 5, 11, (i, j) => j > 8 ? C.shoe : (i === 4 ? C.pantsD : C.pants));
  rect(G, 16 - legs, 61, 7, 3, C.shoe); rect(G, 25 + legs, 61, 7, 3, C.shoe);
  // back arm (left side of the picture = character's right)
  const handL = (x, y) => { ell(G, x, y, 2.2, 2.2, C.skin); };
  limb(G, 14, 33, -armL, 14, 5, (s, k) => k < -1.2 ? C.coatD : s > 0.82 ? C.coatL : C.coat, handL);
  // coat body: a long coat widening to the hem
  for (let y = 30; y < 56; y++) { const k = (y - 30) / 26, w = 18 + k * 6, x0 = 24 - w / 2; for (let x = Math.floor(x0); x < x0 + w; x++) { const edge = x < x0 + 1.5 ? C.coatD : x > x0 + w - 2 ? C.coatD : C.coat; put(G, x, y, edge); } }
  rect(G, 21, 30, 6, 18, (i, j) => (i === 0 || i === 5) ? C.coatL : C.shirt);   // lapels + shirt
  for (let y = 48; y < 56; y++) put(G, 24, y, C.coatD);                           // coat split
  rect(G, 15, 44, 18, 2, (i) => i % 6 === 0 ? C.coatD : C.coatL);                // belt line
  // front arm (character's left)
  let item = null;
  const handR = (x, y) => { ell(G, x, y, 2.2, 2.2, C.skin); item = [x, y]; };
  limb(G, 34, 33, armR, 14, 5, (s, k) => k > 1.2 ? C.coatD : s > 0.82 ? C.coatL : C.coat, handR);
  if (o.item === 'magnifier' && item) { const [x, y] = item; limb(G, x, y, Math.PI * 0.85 + armR, 4, 1.6, '#6b4a2b'); const cx = x + Math.sin(Math.PI * 0.85 + armR) * 8, cy = y + Math.cos(Math.PI * 0.85 + armR) * 8; ell(G, cx, cy, 4.6, 4.6, '#c8c8c8'); ell(G, cx, cy, 3.4, 3.4, (x2, y2, dx, dy) => (dx + dy < -0.4 ? '#ffffff' : '#bfe3ef')); }
  // head
  ell(G, 24, 20, 12.5, 11.5, (x, y, dx, dy) => dy > 0.55 && Math.abs(dx) > 0.5 ? C.skinD : C.skin);
  // hair: sides + fringe
  ell(G, 24, 15, 13.5, 8.5, C.hair);
  for (let x = 11; x < 38; x++) { const fr = 18 + Math.round(2.5 * Math.abs(Math.sin(x * 0.9))) + (x < 16 || x > 32 ? 5 : 0); for (let y = 12; y < fr; y++) put(G, x, y, hsh(x, y) > 0.85 ? C.hairL : C.hair); }
  rect(G, 11, 18, 3, 9, C.hair); rect(G, 35, 18, 3, 9, C.hair);
  // flat cap (newsboy): crown + brim + button
  ell(G, 24, 9, 15, 6.5, (x, y, dx, dy) => dy < -0.3 ? C.capL : (dx > 0.55 ? C.capD : C.cap));
  for (let x = 10; x < 39; x++) { put(G, x, 13, C.capD); put(G, x, 14, x > 18 && x < 33 ? C.capD : null); }
  for (let i = 0; i < 4; i++) put(G, 13 + i * 6, 8 + (i % 2), C.capD);
  rect(G, 23, 2, 2, 2, C.capD);
  // face
  const ex = Math.round(look * 1.5);
  const eye = (x) => {
    if (o.eyes === 'closed') rect(G, x - 1 + ex, 23, 3, 1, C.eye);
    else if (o.eyes === 'happy') { put(G, x - 1 + ex, 23, C.eye); put(G, x + ex, 22, C.eye); put(G, x + 1 + ex, 23, C.eye); }
    else if (o.eyes === 'wide') { rect(G, x - 1 + ex, 20, 3, 4, C.eye); put(G, x + ex, 20, '#ffffff'); }
    else { rect(G, x + ex, 21, 2, 3, C.eye); put(G, x + ex, 21, '#ffffff'); }
  };
  eye(19); eye(28);
  put(G, 17, 25, C.blush); put(G, 18, 25, C.blush); put(G, 30, 25, C.blush); put(G, 31, 25, C.blush);
  const m = o.mouth ?? 'smile';
  if (m === 'smile') { put(G, 23, 27, C.mouth); put(G, 24, 28, C.mouth); put(G, 25, 27, C.mouth); }
  else if (m === 'grin') { rect(G, 22, 27, 5, 1, C.mouth); rect(G, 23, 28, 3, 1, C.mouth); }
  else if (m === 'open') { rect(G, 23, 27, 3, 2, C.mouth); put(G, 24, 29, C.mouth); }
  else if (m === 'o') { rect(G, 23, 27, 2, 2, C.mouth); }
  else rect(G, 23, 28, 3, 1, C.mouth);
  outline(G);
  return toCanvas(G, o.canvas);
}

// ---------------- the fedora Claude: 40 × 40 ----------------
// o: { eyes: 'open'|'happy'|'sad'|'wide'|'closed', mouth: null|'o'|'smile', arms: -1..1 (wave), hat: true, step: 0|1 (legs) }
export const CL = { body: '#e8743f', bodyL: '#f18a55', bodyD: '#c95c2c', eye: '#16110f', hat: '#3c3b40', hatL: '#55545a', band: '#e9dcc3' };
export function drawClaude(o = {}) {
  const C = CL, G = grid(44, 42);
  const st = o.step ?? 0;
  // legs
  for (const [x, up] of [[11, st], [17, 0], [24, 0], [30, st ? 0 : 1]]) rect(G, x, 35 - up, 3, 5, C.bodyD);
  // body block
  rect(G, 8, 16, 28, 20, (i, j) => j === 0 ? C.bodyL : i > 25 ? C.bodyD : (hsh(i, j) > 0.93 ? C.bodyL : C.body));
  // side nubs (arms)
  const a = o.arms ?? 0;
  rect(G, 5, 23 - Math.round(a * 4), 3, 3, C.bodyD); rect(G, 36, 23 + Math.round(Math.min(0, a) * 4) - Math.round(Math.max(0, a) * 6), 3, 3, C.bodyD);
  // eyes
  const e = o.eyes ?? 'open';
  for (const x of [15, 26]) {
    if (e === 'happy') { put(G, x - 1, 24, C.eye); put(G, x, 23, C.eye); put(G, x + 1, 23, C.eye); put(G, x + 2, 24, C.eye); }
    else if (e === 'sad') { rect(G, x - 1, 24, 4, 1, C.eye); put(G, x + (x < 20 ? 2 : -1), 23, C.eye); }
    else if (e === 'closed') rect(G, x - 1, 24, 4, 1, C.eye);
    else if (e === 'wide') rect(G, x, 21, 3, 5, C.eye);
    else rect(G, x, 22, 2, 4, C.eye);
  }
  if (o.mouth === 'o') rect(G, 20, 28, 3, 2, C.eye);
  if (o.mouth === 'smile') { put(G, 19, 28, C.eye); rect(G, 20, 29, 3, 1, C.eye); put(G, 23, 28, C.eye); }
  // fedora
  if (o.hat !== false) {
    rect(G, 4, 14, 36, 2, C.hat); rect(G, 6, 13, 32, 1, C.hatL);
    rect(G, 11, 5, 22, 8, (i, j) => j < 2 ? C.hatL : C.hat); rect(G, 14, 4, 16, 1, C.hat); put(G, 22, 5, '#2a292e'); put(G, 21, 5, '#2a292e');
    rect(G, 11, 11, 22, 2, C.band);
  }
  outline(G);
  return toCanvas(G, o.canvas);
}

// a pixel "?" or "!" or arrow cursor, hand cursor
export function drawGlyph(kind, col = '#ffd23d') {
  const maps = {
    '?': ['.####.', '##..##', '....##', '...##.', '..##..', '..##..', '......', '..##..'],
    '!': ['##', '##', '##', '##', '##', '..', '##'],
    cursor: ['#.........', '##........', '#w#.......', '#ww#......', '#www#.....', '#wwww#....', '#wwwww#...', '#wwwwww#..', '#wwwwwww#.', '#wwww#####', '#ww#w#....', '#w#.#w#...', '##..#w#...', '#....#w#..', '.....###..'],
    hand: ['....##......', '...#ww#.....', '...#ww#.....', '...#ww###...', '...#ww#ww##.', '.###ww#ww#w#', '#ww#wwwwww#w#', '#wwwwwwwwwww#', '.#wwwwwwwwww#', '..#wwwwwwww#.', '...#wwwwwww#.', '....#######..'],
    spark: ['..#..', '..#..', '##w##', '..#..', '..#..'],
    heart: ['.##.##.', '#######', '#######', '.#####.', '..###..', '...#...'],
  };
  const m = maps[kind], H = m.length, W = Math.max(...m.map(r => r.length)), G = grid(W + 2, H + 2);
  m.forEach((r, y) => [...r].forEach((ch, x) => { if (ch === '#') put(G, x + 1, y + 1, kind === 'cursor' || kind === 'hand' ? '#111' : col); else if (ch === 'w') put(G, x + 1, y + 1, '#ffffff'); }));
  if (kind !== 'cursor' && kind !== 'hand') outline(G, '#2a1608');
  return toCanvas(G);
}

// a THREE plane showing a pixel canvas; height in world units; anchor at the feet (bottom centre)
export function sprite(E, canvas, { h = 1, anchor = 'bottom', fog = false } = {}) {
  const tex = new THREE.CanvasTexture(canvas); tex.magFilter = THREE.NearestFilter; tex.minFilter = THREE.NearestFilter; tex.generateMipmaps = false; tex.colorSpace = THREE.SRGBColorSpace;
  const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, alphaTest: 0.5, side: THREE.DoubleSide, fog });
  const w = h * canvas.width / canvas.height;
  const geo = new THREE.PlaneGeometry(w, h); if (anchor === 'bottom') geo.translate(0, h / 2, 0);
  const mesh = new THREE.Mesh(geo, mat);
  mesh.userData.set = (cv) => { if (cv !== canvas) { const g = canvas.getContext('2d'); canvas.width = cv.width; canvas.height = cv.height; g.clearRect(0, 0, cv.width, cv.height); g.drawImage(cv, 0, 0); } tex.needsUpdate = true; };
  return mesh;
}
// a posed character: pose(o) redraws when the pose key changes
export function character(E, kind, { h = 1.6 } = {}) {
  const draw = kind === 'claude' ? drawClaude : drawDetective;
  const cv = draw({}); const m = sprite(E, cv, { h });
  let key = '';
  m.userData.pose = (o = {}) => { const k = JSON.stringify(o); if (k === key) return; key = k; m.userData.set(draw(o)); };
  return m;
}
// draw a pixel canvas into a 2D context at a pixel scale (overlay use)
export function blit(g, cv, x, y, scale, o = {}) { g.save(); g.imageSmoothingEnabled = false; if (o.alpha != null) g.globalAlpha = o.alpha; g.translate(x, y); if (o.rot) g.rotate(o.rot); g.drawImage(cv, -cv.width * scale * (o.ax ?? 0.5), -cv.height * scale * (o.ay ?? 0.5), cv.width * scale, cv.height * scale); g.restore(); }
```

### `engine/type3d.js`

```js
// Extruded 3D type made of stacked canvas planes (a front face + N side layers in a darker colour): cheap, sharp, any font.
// word(E, 'SAME', { font, px, color, side, depth, layers, letters }) → { group, w, h, letters[], marks() }
// 1 world unit = `unit` canvas px (default 100). With letters: true each letter is its own sub-group (for per-letter flights).
import * as THREE from 'three';

const cache = new Map();
function textCanvas(str, font, px, sp = 0, stroke = 0) {
  const key = [str, font, px, sp, stroke].join('|'); if (cache.has(key)) return cache.get(key);
  const c = document.createElement('canvas'), g = c.getContext('2d');
  g.font = `${font.replace('{px}', px)}`; if ('letterSpacing' in g) g.letterSpacing = sp + 'px';
  const m = g.measureText(str), pad = Math.ceil(px * 0.25 + stroke);
  const asc = Math.ceil(m.actualBoundingBoxAscent || px * 0.8), desc = Math.ceil(m.actualBoundingBoxDescent || px * 0.2);
  c.width = Math.ceil(m.width + pad * 2); c.height = asc + desc + pad * 2;
  g.font = `${font.replace('{px}', px)}`; if ('letterSpacing' in g) g.letterSpacing = sp + 'px';
  g.textBaseline = 'alphabetic'; g.fillStyle = '#fff';
  if (stroke) { g.lineJoin = 'round'; g.lineWidth = stroke * 2; g.strokeStyle = '#fff'; g.strokeText(str, pad, pad + asc); }
  g.fillText(str, pad, pad + asc);
  const out = { c, w: c.width, h: c.height, pad, asc, desc, adv: m.width };
  cache.set(key, out); return out;
}
function texOf(tc) { const t = new THREE.CanvasTexture(tc.c); t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 8; return t; }

// one extruded slab of a text canvas
function slab(tc, o) {
  const unit = o.unit ?? 100, w = tc.w / unit, h = tc.h / unit, tex = texOf(tc), g = new THREE.Group();
  const geo = new THREE.PlaneGeometry(w, h);
  const N = o.layers ?? 10, D = o.depth ?? 0.12;
  const front = new THREE.MeshBasicMaterial({ map: tex, color: new THREE.Color(o.color ?? '#ffffff'), transparent: true, alphaTest: 0.35, alphaToCoverage: true, side: THREE.DoubleSide, fog: !!o.fog });
  const fm = new THREE.Mesh(geo, front); fm.renderOrder = 2; g.add(fm);
  const sideC = new THREE.Color(o.side ?? '#888888'), backC = new THREE.Color(o.back ?? o.side ?? '#444444').multiplyScalar(o.back ? 1 : 0.55);
  const sides = [];
  for (let i = 1; i <= N; i++) {
    const k = i / N, col = sideC.clone().lerp(backC, k);
    const m = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ map: tex, color: col, transparent: true, alphaTest: 0.35, alphaToCoverage: true, side: THREE.DoubleSide, fog: !!o.fog }));
    m.position.z = -D * k; m.renderOrder = 1; g.add(m); sides.push(m);
  }
  if (o.shadow) { const sm = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ map: tex, color: 0x000000, transparent: true, opacity: o.shadow, depthWrite: false })); sm.position.set(0.04, -0.06, -D - 0.02); g.add(sm); }
  g.userData = { w, h, front, sides, tc };
  return g;
}

export function word(E, str, o = {}) {
  const font = o.font ?? '900 {px}px Fredoka', px = o.px ?? 160, unit = o.unit ?? 100, root = new THREE.Group();
  const letters = [];
  if (o.letters) {
    // lay letters out by the measured advance of the prefix (kerning-ish)
    const full = textCanvas(str, font, px, o.sp ?? 0, o.stroke ?? 0);
    const mc = document.createElement('canvas').getContext('2d'); mc.font = font.replace('{px}', px);
    let x0 = -full.adv / 2;
    [...str].forEach((ch, i) => {
      const pre = mc.measureText(str.slice(0, i)).width + (o.sp ?? 0) * i, aw = mc.measureText(ch).width;
      if (ch === ' ') return;
      const tc = textCanvas(ch, font, px, 0, o.stroke ?? 0), s = slab(tc, { ...o, color: o.colors ? o.colors[letters.length % o.colors.length] : o.color, side: o.sides ? o.sides[letters.length % o.sides.length] : o.side });
      const cx = (x0 + pre + aw / 2) / unit, inner = new THREE.Group(); inner.add(s);
      // centre the letter's canvas on its advance box, baseline at y = 0
      s.position.set(0, (tc.h / 2 - tc.pad - tc.asc) / unit, 0);
      inner.position.set(cx, 0, 0); inner.userData.home = cx; root.add(inner); letters.push(inner);
    });
    root.userData = { w: full.adv / unit, h: (full.asc + full.desc) / unit };
  } else {
    const tc = textCanvas(str, font, px, o.sp ?? 0, o.stroke ?? 0), s = slab(tc, o);
    s.position.set(0, (tc.h / 2 - tc.pad - tc.asc) / unit + (tc.asc - tc.desc) / unit / 2, 0);   // centred on the cap box
    root.add(s); root.userData = { w: tc.adv / unit, h: (tc.asc + tc.desc) / unit, slab: s };
  }
  root.userData.letters = letters; root.userData.text = str;
  root.userData.opacity = (a) => root.traverse(m => { if (m.material) { m.material.opacity = a; m.material.transparent = true; m.visible = a > 0.01; } });
  root.userData.tint = (c) => root.traverse(m => { if (m.material && m.renderOrder === 2) m.material.color.set(c); });
  return root;
}

// screen box of a word (for the safe-zone report): project its bounding box with the set's camera
const _b = new THREE.Box3(), _v = new THREE.Vector3();
export function screenBox(obj, camera, W = 1080, H = 1920) {
  obj.updateMatrixWorld(true); _b.makeEmpty(); obj.traverseVisible(m => { if (m.isMesh && m.renderOrder === 2) _b.expandByObject(m); }); if (_b.isEmpty()) return null;
  let x0 = 1e9, x1 = -1e9, y0 = 1e9, y1 = -1e9;
  for (let i = 0; i < 8; i++) { _v.set(i & 1 ? _b.max.x : _b.min.x, i & 2 ? _b.max.y : _b.min.y, i & 4 ? _b.max.z : _b.min.z).project(camera); if (_v.z > 1) return null; const x = (_v.x + 1) / 2 * W, y = (1 - _v.y) / 2 * H; x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }
  return { x0, x1, y0, y1 };
}
```

### `engine/overlay.js`

```js
// The 2D layer's bookkeeping: the frame size + safe zone (landscape 1920×1080 = 5 % title-safe on every edge), the text-box
// register (every 2D text and every 3D word records its box; --safe stills draw them, violations() lists those outside),
// MAP (a scene's 1080×1920 design space → the frame), rr(). The pill / caption / title helpers below are spare parts. Engine file.
import { clamp, seg, easeOut, easeOutBack, lerp } from './lib.js';

export let OW = 1080, OH = 1920;
export const SAFE = { x0: 60, x1: 960, y0: 250, y1: 1480, cap0: 1150, cap1: 1480, cx: 510, capW: 820 };
// landscape (YouTube): title-safe 5 % in from every edge
export function setFrame(w, h) { OW = w; OH = h; if (w > h) Object.assign(SAFE, { x0: Math.round(w * 0.05), x1: Math.round(w * 0.95), y0: Math.round(h * 0.05), y1: Math.round(h * 0.95), cap0: Math.round(h * 0.7), cap1: Math.round(h * 0.95), cx: w / 2, capW: w * 0.8 }); }
// boxes given in a video's design space are mapped to the frame by MAP (scene.js sets it)
export let MAP = (x, y) => [x, y]; export function setMap(f) { MAP = f; }
export const COL = { ink: '#1d1b19', pill: '#2e2c28', chip: '#45413c', lime: '#e6fd5e', dot: '#6a655d', terra: '#d9775a', star: '#e2795c', white: '#ffffff', cap: 'rgba(43,38,32,0.96)' };
export const LAYOUT = { hudY: 262, hudH: 50, chipY: 324, chipH: 36, capY: 1410 };
const MONO = '"JetBrains Mono", Consolas, monospace', SANS = 'Inter, "Inter Tight", sans-serif';

let BOXES = [];
export function beginFrame() { BOXES = []; }
export const boxes = () => BOXES;
export function markRaw(kind, text, x0, y0, x1, y1) { BOXES.push({ kind, text: String(text).slice(0, 40), x0, x1, y0, y1 }); }
export function markBox(kind, text, x0, y0, x1, y1) { const [a, b] = MAP(x0, y0), [c, d] = MAP(x1, y1); markRaw(kind, text, a, b, c, d); }
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.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 ${SAFE.x0}–${SAFE.x1} · y ${SAFE.y0}–${SAFE.y1}   captions (cyan) y ${SAFE.cap0}–${SAFE.cap1}, ≤ ${Math.round(SAFE.capW)} wide`, 20, 20);
  g.restore();
}

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

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

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

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

### `engine/post.js`

```js
// Post pipeline: scene (MSAA + depth) -> bloom chain -> comp (DOF, bloom, tone map, grade) -> final (whip/zoom blur, CA, leak, flash, vignette, overlay, grain).
// Engine file (from the Voxel kit). No ACES (a soft shoulder keeps saturated set colours exact). The hud layer (3D type attached
// to the camera) is composited AFTER the depth of field, so type is always sharp; fx: pix (pixelate), rgb (split), inv, sat, scan, flash, zoom, whip.
import { FLASH_MAX, GRAIN } from './fx.js';
import * as THREE from 'three';

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

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

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

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

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

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

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

### `engine/fx.js`

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

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

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

### `engine/common.mjs`

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

### `engine/cdp.mjs`

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

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

### `engine/setup.mjs`

```js
// Requirements check + the free assets. Run once per project:  node engine/setup.mjs
// Node 22+, ffmpeg/ffprobe, three.js, Chrome/Edge with WebGL on the GPU, the ElevenLabs key (never printed);
// downloads the fonts + the music bed if missing, and synthesizes the sound kit (no credits).
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { ROOT } from './common.mjs';
import { CHROME, launch } from './cdp.mjs';
import { KIT } from './synth-sfx.mjs';
const ok = (good, msg) => console.log(good ? '✓' : '✗', msg);
ok(+process.versions.node.split('.')[0] >= 22, 'Node ' + process.versions.node + ' (22+ needed: global fetch and WebSocket)');
for (const c of ['ffmpeg', 'ffprobe']) ok(spawnSync(c, ['-version']).status === 0, c + ' on PATH');
ok(fs.existsSync(path.join(ROOT, 'node_modules/three/build/three.module.js')), 'three.js (npm i three@0.170.0)');
ok(!!CHROME, 'Chrome or Edge: ' + (CHROME || 'not found, set CHROME=<path to the browser>'));
ok(!!(process.env.ELEVENLABS_API_KEY || (fs.existsSync(path.join(ROOT, '.env')) && /^ELEVENLABS_API_KEY=./m.test(fs.readFileSync(path.join(ROOT, '.env'), 'utf8')))), 'ELEVENLABS_API_KEY (environment or .env; only needed for the voice)');
const F = 'https://raw.githubusercontent.com/google/fonts/main/ofl/';
const GET = [   // all free: the fonts are SIL Open Font License (google/fonts); the music is Mixkit's free licence (mixkit.co/license)
  ['fonts/Inter.ttf', F + 'inter/Inter%5Bopsz,wght%5D.ttf'],
  ['fonts/JetBrainsMono.ttf', F + 'jetbrainsmono/JetBrainsMono%5Bwght%5D.ttf'],
  ['fonts/Fredoka.ttf', F + 'fredoka/Fredoka%5Bwdth,wght%5D.ttf'],
  ['fonts/Anton.ttf', F + 'anton/Anton-Regular.ttf'],
  ['fonts/Syne.ttf', F + 'syne/Syne%5Bwght%5D.ttf'],
  ['fonts/Silkscreen.ttf', F + 'silkscreen/Silkscreen-Regular.ttf'],
  ['fonts/Silkscreen-Bold.ttf', F + 'silkscreen/Silkscreen-Bold.ttf'],
  ['music/hip-hop-02.mp3', 'https://assets.mixkit.co/music/738/738.mp3'],   // Mixkit #738 "Hip Hop 02" (Lily J): a bright beat from the first bar, 1:55
];
for (const [rel, url] of GET) {
  const f = path.join(ROOT, rel); if (fs.existsSync(f)) { ok(true, rel); continue; }
  fs.mkdirSync(path.dirname(f), { recursive: true });
  try { const r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0' } }); if (!r.ok) throw new Error(r.status); fs.writeFileSync(f, Buffer.from(await r.arrayBuffer())); ok(true, rel + ' downloaded'); }
  catch (e) { ok(false, `${rel}: download failed (${e.message}) from ${url}`); }
}
const missing = () => Object.keys(KIT).filter(n => !fs.existsSync(path.join(ROOT, 'sfx', n + '.wav')));
if (missing().length) spawnSync(process.execPath, [path.join(ROOT, 'engine', 'synth-sfx.mjs')], { stdio: 'inherit' });
ok(!missing().length, `${Object.keys(KIT).length - missing().length} of ${Object.keys(KIT).length} sound effects in sfx/ (synthesized: node engine/synth-sfx.mjs)`);
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/synth-sfx.mjs`

```js
// The sound kit, synthesized: no downloads, no credits. Writes sfx/<name>.wav (48 kHz, 16-bit mono) for every sound the cues use.
//   node engine/synth-sfx.mjs            (existing files are kept; --force rewrites them; name … = only those)
// Every sound is a pure function of a seed, so the kit is the same on every machine. Want richer sounds? gen-sfx.mjs makes the
// same names with ElevenLabs sound generation (costs credits), or drop your own .wav files in sfx/ under these names.
import fs from 'node:fs'; import path from 'node:path';
import { ROOT, args } from './common.mjs';
const SR = 48000, TAU = Math.PI * 2;
const buf = s => new Float32Array(Math.round(s * SR));
function rng(seed) { let s = seed >>> 0 || 1; return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 4294967296 * 2 - 1; }; }
// a biquad band-pass / low-pass / high-pass whose centre can move every sample: f(t) in Hz
function filt(x, type, f, q = 0.8) {
  const y = new Float32Array(x.length); let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
  for (let i = 0; i < x.length; i++) {
    const fc = Math.min(SR * 0.45, typeof f === 'function' ? f(i / SR) : f), w = TAU * fc / SR, al = Math.sin(w) / (2 * q), c = Math.cos(w);
    let b0, b1, b2; const a0 = 1 + al, a1 = -2 * c, a2 = 1 - al;
    if (type === 'bp') { b0 = al; b1 = 0; b2 = -al; } else if (type === 'lp') { b0 = (1 - c) / 2; b1 = 1 - c; b2 = (1 - c) / 2; } else { b0 = (1 + c) / 2; b1 = -(1 + c); b2 = (1 + c) / 2; }
    const v = (b0 * x[i] + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2) / a0; x2 = x1; x1 = x[i]; y2 = y1; y1 = v; y[i] = v;
  }
  return y;
}
const noise = (s, seed = 1) => { const r = rng(seed), x = buf(s); for (let i = 0; i < x.length; i++) x[i] = r(); return x; };
// a tone: f(t) Hz (number or function), amplitude envelope env(t), wave 'sin' | 'sq' | 'tri'
function tone(s, f, env, wave = 'sin') {
  const x = buf(s); let ph = 0;
  for (let i = 0; i < x.length; i++) { const t = i / SR; ph += (typeof f === 'function' ? f(t) : f) / SR; ph -= Math.floor(ph);
    const v = wave === 'sq' ? (ph < 0.5 ? 1 : -1) * 0.6 : wave === 'tri' ? 4 * Math.abs(ph - 0.5) - 1 : Math.sin(TAU * ph); x[i] = v * env(t); }
  return x;
}
const env = (x, e) => { for (let i = 0; i < x.length; i++) x[i] *= e(i / SR); return x; };
const dec = (k, a = 0.002) => t => Math.min(1, t / a) * Math.exp(-t * k);               // fast attack, exponential decay
const mix = (len, ...parts) => { const out = buf(len); for (const [x, g = 1, at = 0] of parts) { const o = Math.round(at * SR); for (let i = 0; i < x.length && i + o < out.length; i++) out[i + o] += x[i] * g; } return out; };
// a short plastic click (blocks, clack, keys): a resonant noise tick + a tiny tone
const tick = (f, seed, k = 90) => mix(0.08, [env(filt(noise(0.08, seed), 'bp', f, 6), dec(k)), 3], [tone(0.08, f * 0.5, dec(k * 1.2)), 0.25]);
// a bell / metal: inharmonic partials with their own decays
const bell = (s, parts) => mix(s, ...parts.map(([f, g, k]) => [tone(s, f, dec(k)), g]));
function scatter(len, n, spread, make, seed, density = t => 1) { const r = rng(seed), parts = []; for (let i = 0; i < n; i++) { const u = (r() + 1) / 2, at = Math.pow(u, density(u)) * spread; parts.push([make(i, r), 0.5 + 0.5 * Math.abs(r()), at]); } return mix(len, ...parts); }
const sweep = (s, f0, f1, curve = 1) => t => f0 * Math.pow(f1 / f0, Math.pow(Math.min(1, t / s), curve));
const hump = (s, a, b) => t => Math.min(1, t / (s * a)) * Math.min(1, Math.max(0, (s - t) / (s * b)));

export const KIT = {
  whoosh: () => env(filt(noise(0.8, 3), 'bp', t => 300 + 2600 * Math.sin(Math.PI * Math.min(1, t / 0.8)), 1.2), hump(0.8, 0.45, 0.55)),                 // a cut, a fly-in
  swoosh: () => env(filt(noise(0.6, 4), 'bp', sweep(0.6, 700, 3800), 1.4), hump(0.6, 0.3, 0.7)),                                                       // softer, brighter
  swish: () => env(filt(noise(0.4, 5), 'bp', sweep(0.4, 2000, 7000), 2), hump(0.4, 0.15, 0.85)),                                                         // a quick flick
  impact: () => mix(1.3, [tone(1.3, t => 40 + 70 * Math.exp(-t * 18), dec(3.2)), 1.1], [env(filt(noise(1.3, 6), 'lp', t => 200 + 1400 * Math.exp(-t * 10)), dec(5)), 0.9]),   // a slam
  thud: () => mix(0.5, [tone(0.5, t => 45 + 45 * Math.exp(-t * 30), dec(11)), 1], [env(filt(noise(0.5, 7), 'lp', 900), dec(40)), 0.5]),
  slap: () => mix(0.3, [env(filt(noise(0.3, 8), 'bp', 1500, 0.9), dec(38)), 1.4], [tone(0.3, 210, dec(30)), 0.3]),                                            // a word slams in
  stamp: () => mix(0.5, [tone(0.5, t => 60 + 60 * Math.exp(-t * 40), dec(14)), 1], [env(filt(noise(0.5, 9), 'lp', 2200), dec(26)), 0.9]),
  pop: () => tone(0.16, sweep(0.07, 900, 280), dec(28)),
  pop_hi: () => tone(0.12, sweep(0.06, 1320, 420), dec(32)),
  pop_hi2: () => tone(0.11, sweep(0.05, 1610, 500), dec(36)),
  boing: () => tone(0.55, t => 170 * (1 + 0.35 * Math.sin(TAU * 13 * t) * Math.exp(-t * 4)) * (1 + 0.6 * Math.min(1, t / 0.4)), dec(5.5), 'tri'),
  click: () => mix(0.05, [env(filt(noise(0.05, 10), 'hp', 3000), dec(260)), 1], [tone(0.05, 2100, dec(200)), 0.4]),
  clack: () => mix(0.14, [tick(1800, 11, 70), 1], [tick(2700, 12, 80), 0.6, 0.012]),
  type: () => scatter(0.8, 7, 0.65, (i, r) => tick(2000 + 900 * r(), 20 + i, 110), 13),                                                                  // a few keys
  typing: () => scatter(2.0, 22, 1.85, (i, r) => tick(1900 + 1000 * r(), 40 + i, 110), 14),                                                             // a burst of typing
  blocks: () => scatter(1.1, 34, 0.95, (i, r) => tick(1500 + 2500 * Math.abs(r()), 80 + i, 70), 15, () => 1.6),                                         // plastic blocks rain in
  burst: () => mix(0.7, [scatter(0.7, 26, 0.28, (i, r) => tick(1700 + 2600 * Math.abs(r()), 130 + i, 60), 16), 1], [env(filt(noise(0.7, 17), 'bp', sweep(0.5, 3000, 600), 0.9), dec(7)), 0.5]),
  beep: () => tone(0.22, 1000, t => (t < 0.18 ? 1 : Math.max(0, 1 - (t - 0.18) / 0.04)) * Math.min(1, t / 0.005) * 0.5, 'sq'),                          // a pixel HUD beep
  ding: () => bell(0.9, [[1320, 0.6, 5], [2640, 0.25, 7], [3960, 0.12, 9], [5280, 0.06, 12]]),
  chime: () => mix(1.7, ...[1047, 1319, 1568, 2093].map((f, i) => [bell(1.2, [[f, 0.5, 3.5], [f * 2.01, 0.15, 6], [f * 3.02, 0.06, 9]]), 0.8, i * 0.085])),   // a rising arpeggio
  shimmer: () => scatter(1.6, 40, 1.0, (i, r) => bell(0.6, [[2200 + 3800 * Math.abs(r()), 0.3, 6]]), 18),                                               // sparkles
  clang: () => mix(1.0, [bell(1.0, [[220, 0.5, 4], [563, 0.4, 5], [912, 0.3, 6], [1340, 0.25, 7], [2110, 0.15, 9]]), 1], [env(filt(noise(1, 19), 'bp', 3000, 1), dec(40)), 0.5]),
  kaching: () => mix(1.1, [tick(2600, 21, 50), 1.2], [tick(3400, 22, 50), 1, 0.05], [bell(1.0, [[2093, 0.5, 4], [3136, 0.35, 5], [4186, 0.25, 6], [5274, 0.12, 8]]), 0.9, 0.12]),   // a cash register
  flick: () => tone(0.35, t => 330 * (1 + 0.05 * Math.exp(-t * 30)), t => dec(14)(t) * (1 + 0.5 * Math.sin(TAU * 990 * t)), 'tri'),                       // a string snaps
  riser: () => { const D = 1.0, x = filt(noise(D, 23), 'bp', sweep(D, 400, 12000), 1.2), y = tone(D, sweep(D, 340, 1400, 1.3), () => 0.35); return env(mix(D, [x, 1.6], [y, 1]), t => Math.min(1, (t / D) ** 2 * 1.15) * (t > D - 0.01 ? (D - t) / 0.01 : 1) * 0.7); },
  plane: () => mix(1.7, [env(filt(noise(1.7, 24), 'lp', t => 500 + 1300 * Math.sin(Math.PI * t / 1.7)), hump(1.7, 0.5, 0.5)), 1.4], [tone(1.7, t => 190 - 60 * t / 1.7, hump(1.7, 0.5, 0.5), 'tri'), 0.15]),   // a rush past
  glitch: () => { const D = 0.36, r = rng(25), x = buf(D); let f = 400, ph = 0, on = 1; for (let i = 0; i < x.length; i++) { if (i % 960 === 0) { f = 120 + 1800 * Math.abs(r()); on = r() > -0.4 ? 1 : 0; } ph += f / SR; ph -= Math.floor(ph); x[i] = on * ((ph < 0.5 ? 0.5 : -0.5) + 0.35 * r()) * Math.round(Math.abs(Math.sin(i / 37)) * 4) / 4; } return env(x, hump(D, 0.02, 0.15)); },   // digital stutter
};

// peak level per sound in dB under −1 dBFS, so the cue levels in scene cues (−12 … −26 dB) sit right: dense or harsh sounds lower
export const LEVEL = { beep: -19, glitch: -16, pop: -16, pop_hi: -16, pop_hi2: -16, click: -16, clack: -12, kaching: -11, blocks: -7, burst: -6, typing: -12, type: -12, slap: -4, flick: -6 };
function writeWav(file, x, db = 0) {
  let pk = 0; for (const v of x) pk = Math.max(pk, Math.abs(v)); const g = pk > 0 ? 0.89 / pk * 10 ** (db / 20) : 1;   // normalised to −1 dBFS + LEVEL
  const b = Buffer.alloc(44 + x.length * 2); b.write('RIFF', 0); b.writeUInt32LE(36 + x.length * 2, 4); b.write('WAVEfmt ', 8); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
  b.writeUInt32LE(SR, 24); b.writeUInt32LE(SR * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(x.length * 2, 40);
  for (let i = 0; i < x.length; i++) b.writeInt16LE(Math.max(-32768, Math.min(32767, Math.round(x[i] * g * 32767))), 44 + i * 2);
  fs.writeFileSync(file, b);
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
  const { pos, opt } = args(), out = path.join(ROOT, 'sfx'); fs.mkdirSync(out, { recursive: true });
  let n = 0; for (const name of pos.length ? pos : Object.keys(KIT)) {
    if (!KIT[name]) { console.log('✗ no recipe for', name); continue; }
    const f = path.join(out, name + '.wav'); if (fs.existsSync(f) && !opt.force) continue;
    writeWav(f, KIT[name](), LEVEL[name] || 0); n++;
  }
  console.log(`sfx/: ${n} sounds written, ${Object.keys(KIT).filter(k => fs.existsSync(path.join(out, k + '.wav'))).length} of ${Object.keys(KIT).length} in the kit`);
}
```

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

```js
// OPTIONAL: the same sound kit made with ElevenLabs sound generation (richer than synth-sfx.mjs; costs credits, ~30 s of sound).
//   node engine/gen-sfx.mjs [--yes] [--force] [name ...]     dry run unless --yes; existing files are kept unless --force
// The free kit is node engine/synth-sfx.mjs (no key, no credits). Names and uses are the same, so cues.mjs works with either.
import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process';
import { ROOT, args, apiKey } from './common.mjs';
export const PROMPTS = {   // name: [prompt, seconds]
  whoosh: ['a quick airy whoosh transition', 0.8], swoosh: ['a soft short swoosh of air', 0.7], swish: ['a fast thin swish, like a card flicked through the air', 0.5],
  impact: ['a deep cinematic impact hit with a short rumble tail', 1.3], thud: ['a soft low thud', 0.6], slap: ['a single sheet of thick paper slapped flat onto a table, short dry slap', 0.5],
  stamp: ['a rubber stamp pressed hard onto paper on a desk, single thump', 0.6], pop: ['a soft cartoon pop, bubbly and light', 0.5], pop_hi: ['a small high-pitched cartoon pop', 0.5], pop_hi2: ['a tiny very high cartoon pop', 0.5], boing: ['a short cartoon spring boing', 0.6],
  click: ['a single clean mouse click', 0.5], clack: ['a plastic tile clacking into place', 0.5], type: ['a few mechanical keyboard keystrokes', 0.8],
  typing: ['fast clicky mechanical keyboard typing, a short burst of keystrokes', 2.0], blocks: ['many small hard plastic toy blocks raining down and clicking together, quick cascade, dry', 1.2],
  burst: ['a pile of small plastic toy blocks bursting apart and scattering, light, no explosion boom', 0.8], beep: ['a single retro 8-bit computer beep', 0.5],
  ding: ['a bright clean notification ding', 0.8], chime: ['a gentle sparkling chime arpeggio, bright and short, rising', 1.8], shimmer: ['a warm magical shimmer, sparkly chimes', 1.6],
  clang: ['a metal pedestal hit, short metallic clang', 1.0], kaching: ['an old cash register ka-ching', 1.1], flick: ['a taut string snapping, short twang', 0.5],
  riser: ['a short synth riser building up into a hit, airy', 1.0], plane: ['a fast rush of wind past the camera, like flying through clouds', 1.7], glitch: ['a short digital glitch stutter, data corruption', 0.5],
};
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
  const { pos, opt } = args(), out = path.join(ROOT, 'sfx'); fs.mkdirSync(out, { recursive: true });
  const todo = (pos.length ? pos : Object.keys(PROMPTS)).filter(n => PROMPTS[n] && (opt.force || !fs.existsSync(path.join(out, n + '.wav'))));
  console.log(`${todo.length} sounds to make: ${todo.join(' ') || '(none: the synth kit is already there; --force replaces it)'}`);
  if (!opt.yes) { console.log('dry run: add --yes to generate them (sound generation costs credits).'); process.exit(0); }
  for (const n of todo) {
    const [text, sec] = PROMPTS[n], r = await fetch('https://api.elevenlabs.io/v1/sound-generation', { method: 'POST', headers: { 'xi-api-key': apiKey(), 'content-type': 'application/json' }, body: JSON.stringify({ text, duration_seconds: Math.max(0.5, sec), prompt_influence: 0.6 }) });
    if (!r.ok) { console.log(n, r.status, (await r.text()).slice(0, 200)); continue; }
    const mp3 = path.join(out, n + '.mp3'); fs.writeFileSync(mp3, Buffer.from(await r.arrayBuffer()));
    execFileSync('ffmpeg', ['-v', 'error', '-y', '-i', mp3, '-ar', '48000', '-ac', '2', path.join(out, n + '.wav')]); fs.rmSync(mp3); console.log(n, 'ok');
  }
}
```

### `engine/render.mjs`

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

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

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

### `engine/votight.mjs`

```js
// Tighten a voice take to the format's rapid-fire delivery without paying for a new take: shorten every pause (cut only inside
// real silence), then speed it up a little with the pitch kept, and remap the word timings.
//   node engine/votight.mjs <video> --from a --out b --stop 0.34 --comma 0.14 --word 0.05 --tempo 1.04 --set
// --stop: pause kept after . ! ? :   --comma: after , ;   --word: between other words (only longer silences are cut)
// --tempo: total speed factor (atempo, pitch kept); --pitch: part of it done by resampling instead (raises the pitch: 1.04 ≈ +0.7 semitone).
// Writes vo/<out>.wav (48 kHz mono) + vo/<out>.json (the alignment remapped, like gen-vo); --set makes it video.json's take.
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { args, videoDir, saveVideo, alignWords, run, die } from './common.mjs';
const { pos, opt } = args(), { dir } = videoDir(pos[0]);
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.34), COMMA = +(opt.comma ?? 0.14), WORD = +(opt.word ?? 0.05), TEMPO = +(opt.tempo ?? 1.04), 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 = alignWords(A);
// 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;
// ElevenLabs hands the silence before a word to that word: move each start to where its sound begins
for (const w of W) { let f = Math.ceil(w.s * 200); const lim = Math.floor(Math.min(w.e - 0.05, w.s + 0.8) * 200); while (f < lim && dB[f] < THR + 12) f++; w.s = Math.max(w.s, f / 200 - 0.02); }
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, 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 drop = Math.min(sil, (g1 - g0) - 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), map = []; let pos0 = 0, dst = 0;   // map: [srcStart, srcEnd, dstStart]
for (const [a, b] of [...cuts, [P.length / SR, P.length / SR]]) {
  const i0 = Math.round(pos0 * SR), i1 = Math.round(a * SR), seg = Array.from(P.subarray(i0, i1));
  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 }));
if (opt.set) saveVideo(dir, { take: out });
const len = dst / TEMPO, removed = cuts.reduce((s, [a, b]) => s + b - a, 0);
console.log(`vo/${out}.wav · ${cuts.length} pauses shortened (−${removed.toFixed(2)} s) · tempo ${TEMPO} · ${len.toFixed(2)} s · ${(W.length / len).toFixed(2)} words/s${opt.set ? ` · video.json take = ${out} · next: node engine/build.mjs ${pos[0]}` : ''}`);
```

### `engine/voedit.mjs`

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

### `engine/build.mjs`

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

### `engine/mix.mjs`

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

### `engine/encode.mjs`

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

### `engine/check.mjs`

```js
// The finished-video checks:  node engine/check.mjs <video> [--file video.mp4]
// format (1920x1080) + size + loudness · the length (a short file = a test clip) · frame 0 not black · flash white-outs · frozen stretches
// · cut rhythm · text inside the safe zone on every frame
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { args, videoDir, loud, low, die } from './common.mjs';
import { readFileSync } from 'node:fs';
low();
const { pos, opt } = args(), { dir } = videoDir(pos[0]), file = path.resolve(dir, opt.file || 'video.mp4'); if (!fs.existsSync(file)) die('no ' + file);
let ok = true; const say = (good, msg) => { ok &&= good; console.log(good ? '✓' : '✗', msg); };
const p = JSON.parse(spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration,size:stream=codec_type,codec_name,profile,width,height,r_frame_rate,pix_fmt', '-of', 'json', file], { encoding: 'utf8' }).stdout);
const v = p.streams.find(s => s.codec_type === 'video'), au = p.streams.find(s => s.codec_type === 'audio'), dur = +p.format.duration, mb = p.format.size / 1e6, L = loud(file);
say(v.width === 1920 && v.height === 1080 && v.r_frame_rate === '30/1' && v.pix_fmt === 'yuv420p' && v.profile === 'High', `video ${v.codec_name} ${v.profile} ${v.pix_fmt} ${v.width}x${v.height} ${v.r_frame_rate}`);
const END = JSON.parse(readFileSync(path.join(dir, 'film.json'), 'utf8')).END;
say(au?.codec_name === 'aac', `audio ${au?.codec_name}`); say(dur <= 60.05, `${dur.toFixed(2)} s`);
console.log(Math.abs(dur - END) < 0.1 ? `· the whole film (END ${END} s)` : `· a test clip: ${dur.toFixed(2)} of ${END} s (a full encode must be the whole film: if this is video.mp4, frames are missing)`); say(mb <= 60, `${mb.toFixed(1)} MB`);
say(Math.abs(L.I + 14) <= 1 && L.TP <= -1, `loudness ${L.I} LUFS · true peak ${L.TP} dBTP`);
// every frame at 54x96 grey: mean brightness and change from the previous frame
const raw = spawnSync('ffmpeg', ['-v', 'error', '-threads', '2', '-i', file, '-vf', 'scale=96:54,format=gray', '-f', 'rawvideo', '-'], { maxBuffer: 1 << 28 }).stdout, N = 96 * 54;
const mean = [], diff = []; for (let k = 0; (k + 1) * N <= raw.length; k++) { let m = 0, d = 0; for (let i = 0; i < N; i++) { m += raw[k * N + i]; if (k) d += Math.abs(raw[k * N + i] - raw[(k - 1) * N + i]); } mean.push(m / N); diff.push(d / N); }
const f0 = [...raw.subarray(0, N)], sd = Math.sqrt(f0.reduce((a, x) => a + (x - mean[0]) ** 2, 0) / N);
say(mean[0] > 25 && sd > 12, `frame 0: mean ${mean[0].toFixed(0)}, contrast ${sd.toFixed(0)} (the hook: frame 0 is a negative of the first set)`);
// a single bright flash frame is part of the look (the example peaks at 224 on this scale); white-outs = a frame over 235, or 3 in a row over 212
const white = mean.map((m, k) => [m, k]).filter(([m, k]) => m > 235 || (m > 212 && mean[k - 1] > 212 && mean[k - 2] > 212)); say(!white.length, `washed-out frames (flash too strong or too long): ${white.length}${white.length ? ' e.g. ' + (white[0][1] / 30).toFixed(2) + ' s' : ''}`);
// a frozen render: over 0.6 s with almost no change (something hits every ~0.3 s in this format; the example's longest still run is 0.3 s)
let run0 = 0, frozen = []; diff.forEach((d, k) => { if (d < 0.15) { if (++run0 === 18) frozen.push(((k - 17) / 30).toFixed(2)); } else run0 = 0; });
say(!frozen.length, `stretches over 0.6 s where nothing moves: ${frozen.length ? 'from ' + frozen.join(', ') + ' s' : 'none'}`);
const cuts = diff.filter((d, k) => { const nb = [diff[k - 2], diff[k - 1], diff[k + 1], diff[k + 2]].filter(x => x != null).sort((a, b) => a - b); return d > 25 && d > 3 * nb[1]; }).length; console.log(`· ${cuts} hard cuts ≈ ${(cuts / dur * 10).toFixed(1)} per 10 s (the example: ~7 per 10 s: set cuts, wipes, slams)`);
const sf = path.join(dir, 'frames', 'safe.json');
if (fs.existsSync(sf)) { const S = JSON.parse(fs.readFileSync(sf, 'utf8')), bad = Object.keys(S.bad); say(!bad.length, `text outside the safe zone on ${bad.length} frames${bad.length ? ', e.g. f' + bad[0] + ' ' + JSON.stringify(S.bad[bad[0]][0]) : ''}`); }
console.log(ok ? '\n✓ all checks passed. Now watch it once with sound.' : '\n✗ fix the ✗ lines');
```


## Appendix B: an example video (complete)

"Never pay for a Claude content skill again · Cutscene" (39.2 s, the video in `reference.jpg`). It uses 14 sets, 15 cuts, 6 wipes and every effect. The names in it are its creator's own (his name, his app, his library): use yours. Make a folder, write your own `script.mjs`, copy `scene.js` and `cues.mjs`, keep the helpers at the top of `scene.js`, then rewrite the sets, `cuts`, `WIPES`, the overlay functions and `effects` for your words, and move every cue.

`example/video.json`:

```json
{ "take": "b", "voOff": 0.3, "tail": 1.6, "size": [1920, 1080], "cover": 1.6,
  "music": { "file": "hip-hop-02.mp3", "from": 0, "db": -16, "muffle": [[24.95, 28.3]] } }
```

(`take` b = take a after `votight.mjs`; `muffle` is keyed to this voice: move it to your own "this video" line.)

### `example/script.mjs`

```js
// Cutscene · "Never pay for a Claude content skill again", reworked into @miftahul_islam_efaz's director-cut grammar
// (the hook "everyone's asking the same thing → what's the prompt? → wrong question", the turn, "this video you're watching right now").
// Casper's message is kept: the free skill, the $30 resellers, Casper + Morphy, the free library, no paywalls, this format's name, the CTA.
export const LINES = [
  "This style is blowing up, and everyone's asking the same thing.",
  "What's the prompt?",
  'Wrong question.',
  'Because I gave the whole skill away. For free.',
  'And people took it, repackaged it, and sold it for $30.',
  '$30. For a text file.',
  "I'm Casper, and I built Morphy, an agentic super app.",
  "So here's my response.",
  'An entire library of content-format skill files, inside Morphy.',
  'No paywalls. No subscriptions. No strings attached.',
  "And this video you're watching right now? It's one of them.",
  "It's called cutscene.md.",
  "Comment CUTSCENE, and I'll send it to you. Completely free.",
];
export const SAID = {
  '$30': 'thirty dollars',
  'content-format': 'content format',
  'cutscene.md': 'cutscene dot M D',
  'CUTSCENE': '"Cutscene"',
};
```

### `example/scene.js`

```js
// THE PER-VIDEO DIRECTOR FILE — "Never pay for a Claude content skill again · Cutscene" (39 s).
// After @miftahul_islam_efaz's director-cut reel: a pixel detective-builder + a fedora Claude, a new world on every beat
// (grass feed, orange, red WRONG, dark subtitles, theatre stage, $30 fly-through, pixel face, voxel desk, cloud tunnel,
// tron library, B&W cosmos, the film inside its own monitor, a player, the name, the CTA), 3D extruded type, pixel-block wipes.
import * as THREE from 'three';
import { clamp, lerp, seg, ss, easeOut, easeIn, easeInOut, easeOutBack, hash, keys, springy, noise1 } from '/engine/lib.js';   // served from the kit root: works from example and from a work row

const RED = ['#e01f26', '#ff9b73', '#f6efe4', '#0b0909', '#7b0b0f', '#ff4436'];
const YEL = '#ffd23d';

export default function setup(E) {
  const { T, TE, S, T3, K, END } = E;
  const sets = {};
  // ---------- helpers ----------
  // the type is laid out in a 1080×1920 design space; landscape maps it to the middle of the 1920×1080 frame at 3/4 size
  const LAND = E.W > E.H, KS = LAND ? 0.9 : 1, LX = LAND ? 1.8 : 1;
  const MX = x => LAND ? 960 + (x - 510) * KS : x, MY = y => LAND ? 540 + (y - 870) * 0.77 : y;
  const MAP2 = (x, y) => [MX(x), LAND ? 540 + (y - 880) * KS : y]; E.O.setMap(MAP2);
  const W3 = (str, o = {}) => T3.word(E, str, o);
  // place an object in camera space at screen px (sx, sy) at distance d
  function at(set, obj, sx, sy, d = 8) {
    const c = set.hudCam, hh = d * Math.tan(c.fov * Math.PI / 360), hw = hh * c.aspect;
    obj.position.set((MX(sx) / E.W * 2 - 1) * hw, (1 - MY(sy) / E.H * 2) * hh, -d); set.hud.add(obj); obj.userData.hud = true; return obj;
  }
  const pxPerUnit = (set, d) => E.H / (2 * d * Math.tan(set.hudCam.fov * Math.PI / 360));
  // a word sized to a target pixel height, camera-attached
  function hudWord(set, str, sx, sy, hPx, o = {}, d = 8) {
    const w = W3(str, o); const k = hPx * KS / (w.userData.h * pxPerUnit(set, d)); w.scale.setScalar(k);
    w.userData.rest = { p: null, r: new THREE.Euler(...(o.rot || [0, 0, 0])), s: k }; w.rotation.set(...(o.rot || [0, 0, 0]));
    at(set, w, sx, sy, d); w.userData.rest.p = w.position.clone(); set.words.push(w); return w;
  }
  // entry / exit animation from the rest pose
  function kin(o, t, { at: a, dur = 0.26, from = {}, out = 1e9, outDur = 0.18, to = {}, ease = easeOutBack, float = 0 }) {
    const R = o.userData.rest ||= { p: o.position.clone(), r: o.rotation.clone(), s: o.scale.x };
    if (!R.p) R.p = o.position.clone();
    if (t < a || t >= out + outDur) { o.visible = false; return 0; }
    o.visible = true;
    const k = clamp((t - a) / dur), e = ease(k), ko = clamp((t - out) / outDur), eo = easeIn(ko);
    const fp = from.p || [0, 0, 0], fr = from.r || [0, 0, 0], tp = to.p || [0, 0, 0], tr = to.r || [0, 0, 0];
    const fl = float ? Math.sin((t - a) * 2.2 + R.p.x) * float : 0;
    o.position.set(R.p.x + fp[0] * (1 - e) + tp[0] * eo, R.p.y + fp[1] * (1 - e) + tp[1] * eo + fl, R.p.z + fp[2] * (1 - e) + tp[2] * eo);
    o.rotation.set(R.r.x + fr[0] * (1 - e) + tr[0] * eo, R.r.y + fr[1] * (1 - e) + tr[1] * eo, R.r.z + fr[2] * (1 - e) + tr[2] * eo);
    const s = lerp(from.s ?? 0, 1, e) * lerp(1, to.s ?? 0, eo) * R.s; o.scale.setScalar(Math.max(1e-4, s));
    o.userData.alpha = k > 0.85 && ko < 0.15 ? 1 : 0;
    return e;
  }
  // per-letter drop-in (letters fall from above with a stagger, land with a squash)
  function letters(w, t, a, { stagger = 0.035, dur = 0.3, drop = 2.5, spin = 0.8, out = 1e9 } = {}) {
    w.visible = t >= a && t < out;
    w.userData.letters.forEach((L, i) => {
      const t0 = a + i * stagger, k = clamp((t - t0) / dur);
      L.visible = k > 0.3 && t < out; const e = easeOutBack(k, 2.2);
      L.position.y = (1 - easeOut(k)) * drop; L.rotation.z = (1 - e) * spin * (i % 2 ? 1 : -1); L.rotation.x = (1 - e) * 0.8;
      const sq = k >= 1 ? 1 : 1 + Math.sin(k * Math.PI) * 0.15; L.scale.set(1 / Math.sqrt(sq), sq, 1);
    });
  }
  const shakeOf = (t, list, amp = 0.05) => { let x = 0, y = 0; for (const s0 of list) if (t >= s0 && t < s0 + 0.35) { const a = amp * Math.exp(-(t - s0) * 12); x += Math.sin((t - s0) * 80) * a; y += Math.cos((t - s0) * 63) * a; } return [x, y]; };
  const poseAt = (t, list) => { let p = list[0][1]; for (const [t0, v] of list) if (t >= t0) p = v; return p; };

  // ===================================================================== 1. FEED: a laptop on grass, the feed blowing up
  {
    const s = sets.feed = K.newSet(E, { fov: 38, bg: 0x9cc8f0, fog: [0xcfe3f2, 14, 60] });
    const sky = K.canvasTex(512, 910, (g, w, h) => {
      const gr = g.createLinearGradient(0, 0, 0, h); gr.addColorStop(0, '#2f6fd0'); gr.addColorStop(0.55, '#8cc0ef'); gr.addColorStop(1, '#e4f2fb'); g.fillStyle = gr; g.fillRect(0, 0, w, h);
      for (let i = 0; i < 70; i++) { const x = 60 + hash(i * 1.7) * 420, y = 120 + hash(i * 3.1) * 260, r = 30 + hash(i * 5.3) * 70; const b = g.createRadialGradient(x - r * 0.3, y - r * 0.4, 0, x, y, r); b.addColorStop(0, 'rgba(255,255,255,0.95)'); b.addColorStop(0.6, 'rgba(225,235,248,0.75)'); b.addColorStop(1, 'rgba(160,190,225,0)'); g.fillStyle = b; g.beginPath(); g.arc(x, y, r, 0, 7); g.fill(); }
    });
    K.backdrop(s, sky, 150);
    const grass = K.canvasTex(512, 512, (g, w, h) => { g.fillStyle = '#2f6b1f'; g.fillRect(0, 0, w, h); for (let i = 0; i < 9000; i++) { const x = hash(i * 1.1) * w, y = hash(i * 2.3) * h, l = 6 + hash(i * 3.7) * 16, v = hash(i * 4.1); g.strokeStyle = `rgb(${50 + v * 80},${110 + v * 90},${30 + v * 30})`; g.lineWidth = 1.5; g.beginPath(); g.moveTo(x, y); g.lineTo(x + (hash(i) - 0.5) * 4, y - l); g.stroke(); } });
    grass.wrapS = grass.wrapT = THREE.RepeatWrapping; grass.repeat.set(14, 14);
    const gp = new THREE.Mesh(new THREE.PlaneGeometry(120, 120), new THREE.MeshStandardMaterial({ map: grass, roughness: 1 })); gp.rotation.x = -Math.PI / 2; s.scene.add(gp);
    // grass blades near the laptop (depth)
    const N = 1600, bl = new THREE.InstancedMesh(new THREE.PlaneGeometry(0.035, 0.32).translate(0, 0.16, 0), new THREE.MeshStandardMaterial({ color: 0xffffff, side: THREE.DoubleSide, roughness: 1 }), N), M = new THREE.Matrix4(), C = new THREE.Color();
    for (let i = 0; i < N; i++) { let x = (hash(i * 1.3) - 0.5) * 9; const z = -2.5 + hash(i * 2.9) * 3.0, sc = 0.6 + hash(i * 7.1) * 0.9; if (Math.abs(x) < 1.45 && z > -1.0) x = Math.sign(x || 1) * (1.45 + hash(i * 4.4) * 3); M.compose(new THREE.Vector3(x, 0, z), new THREE.Quaternion().setFromEuler(new THREE.Euler(0, hash(i) * 3, (hash(i * 9.1) - 0.5) * 0.5)), new THREE.Vector3(1, sc, 1)); bl.setMatrixAt(i, M); const v = hash(i * 5.5); bl.setColorAt(i, C.setRGB(0.18 + v * 0.3, 0.45 + v * 0.35, 0.1 + v * 0.1)); }
    s.scene.add(bl);
    s.scene.add(new THREE.HemisphereLight(0xdfefff, 0x3a5a20, 1.6)); const sun = new THREE.DirectionalLight(0xfff2dc, 2.4); sun.position.set(4, 8, 6); sun.castShadow = true; s.scene.add(sun);
    // the laptop
    const lap = new THREE.Group(); s.scene.add(lap);
    const alu = new THREE.MeshStandardMaterial({ color: 0xd4d6da, roughness: 0.35, metalness: 0.3 });
    const base = new THREE.Mesh(new THREE.BoxGeometry(2.6, 0.08, 1.7), alu); base.position.y = 0.04; lap.add(base);
    const kb = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.01, 0.8), new THREE.MeshStandardMaterial({ color: 0x2a2b2f })); kb.position.set(0, 0.085, -0.2); lap.add(kb);
    const lid = new THREE.Group(); lid.position.set(0, 0.08, -0.85); lap.add(lid);
    const lidM = new THREE.Mesh(new THREE.BoxGeometry(2.6, 1.7, 0.05), alu); lidM.position.y = 0.85; lid.add(lidM); lid.rotation.x = -0.08;
    const feed = K.canvasTex(1040, 680, () => { });
    const scr = new THREE.Mesh(new THREE.PlaneGeometry(2.46, 1.58), new THREE.MeshBasicMaterial({ map: feed })); scr.position.set(0, 0.85, 0.03); lid.add(scr);
    const det = S.drawDetective({ eyes: 'happy', mouth: 'grin', armR: 2.4 }), cl = S.drawClaude({ eyes: 'happy' });
    const TILES = [['#ff7a1f', 'SAME'], ['#d81f26', 'WRONG'], ['#140c0a', 'SKILL'], ['#101014', ''], ['#2a5fa8', 'COOK'], ['#0d1f22', '.md']];
    function drawFeed(t) {
      feed.userData.redraw((g, w, h) => {
        g.fillStyle = '#0c0c0f'; g.fillRect(0, 0, w, h);
        g.fillStyle = '#1b1b21'; g.fillRect(0, 0, w, 64); g.font = '700 30px Inter'; g.fillStyle = '#fff'; g.fillText('For you', 34, 43); g.fillStyle = '#8b8e94'; g.fillText('Following', 190, 43);
        g.fillStyle = '#e8343f'; g.beginPath(); g.arc(w - 50, 32, 16, 0, 7); g.fill();
        TILES.forEach(([c, word], i) => {
          const cx = 24 + (i % 3) * 338, cy = 84 + Math.floor(i / 3) * 296, tw = 322, th = 280;
          g.fillStyle = c; g.fillRect(cx, cy, tw, th);
          g.save(); g.beginPath(); g.rect(cx, cy, tw, th); g.clip();
          if (i === 0) { g.imageSmoothingEnabled = false; g.drawImage(det, cx + 18, cy + 60, 120, 170); }
          if (i === 3) { for (let k = 0; k < 500; k++) { const a = k * 2.4, r = Math.sqrt(k / 500) * 100; const v = 120 + 120 * hash(k); g.fillStyle = `rgb(${v},${v},${v})`; g.fillRect(cx + tw / 2 + Math.cos(a) * r, cy + th / 2 + Math.sin(a) * r, 3, 3); } }
          if (i === 2) { g.fillStyle = '#c45f22'; g.fillRect(cx, cy + 190, tw, 90); g.fillStyle = '#ffc24a'; g.fillRect(cx + 30, cy + 120, 40, 30); }
          if (i === 4) { g.imageSmoothingEnabled = false; g.drawImage(cl, cx + 200, cy + 150, 100, 96); }
          g.font = i === 1 ? '400 92px Anton' : '700 70px Fredoka'; g.fillStyle = i === 1 ? '#f6e7d2' : '#fff'; g.textAlign = 'center'; g.fillText(word, cx + tw / 2 + (i === 0 ? 50 : 0), cy + 130); g.textAlign = 'left';
          g.restore();
          // the view counter climbing
          const v = (0.4 + i * 0.37 + Math.pow(Math.max(0, t), 2.2) * (0.9 + i * 0.4)) ;
          g.fillStyle = 'rgba(0,0,0,0.55)'; g.fillRect(cx + 10, cy + th - 50, 190, 40); g.font = '700 28px Inter'; g.fillStyle = '#fff'; g.fillText('▶ ' + v.toFixed(1) + 'M', cx + 22, cy + th - 20);
        });
      });
    }
    // words
    const thisStyle = W3('THIS STYLE', { font: '700 {px}px Fredoka', px: 150, color: '#ffffff', side: '#6f86a6', depth: 0.16, layers: 12, letters: true }); { const d = 8, k = 112 * KS / (thisStyle.userData.h * pxPerUnit(s, d)); thisStyle.scale.setScalar(k); at(s, thisStyle, 510, 560, d); s.words.push(thisStyle); }
    const COLS = ['#e8343f', '#4f6bed', '#ff6fae', '#39c46a', '#ffc93c', '#ff7a1f', '#9b5cff'];
    const blowing = W3('BLOWING', { font: '700 {px}px Fredoka', px: 170, colors: COLS, sides: ['#8a1018', '#24358f', '#a8336a', '#1d7a3e', '#a87810', '#a8440c', '#5a2aa8'], depth: 0.3, layers: 14, letters: true });
    blowing.position.set(0, 2.4, -0.9); blowing.scale.setScalar(0.27); s.scene.add(blowing); s.words.push(blowing);
    const up = W3('UP!', { font: '700 {px}px Fredoka', px: 200, colors: ['#ff3b3b', '#ffc93c', '#ffffff'], sides: ['#7a0d0d', '#a87810', '#9aa7b8'], depth: 0.4, layers: 16, letters: true });
    up.position.set(0, 0.95, 1.35); up.scale.setScalar(0.32); s.scene.add(up); s.words.push(up);
    s.post = { focus: 6, aperture: 3, bloom: 0.25, thr: 0.9 };
    s.update = (t) => {
      drawFeed(t);
      const A = T('blowing'), U = T('up,');
      thisStyle.userData.noMark = t > A - 0.1; up.userData.noMark = blowing.userData.noMark = t > 1.62;
      thisStyle.visible = t < A + 0.1; letters(thisStyle, t, 0.28, { stagger: 0.04, drop: 1.6, spin: 0.3 }); thisStyle.position.y = thisStyle.userData.y0 ??= thisStyle.position.y; if (t > A - 0.08) thisStyle.position.y = thisStyle.userData.y0 + easeIn(seg(t, A - 0.08, A + 0.1)) * 4;
      letters(blowing, t, A - 0.06, { stagger: 0.03, drop: 3.5, spin: 0.35 });
      letters(up, t, U - 0.05, { stagger: 0.06, drop: 2.5, spin: 0.4 }); up.rotation.y = Math.sin(t * 3) * 0.08;
      // camera: screen close-up → pull back over the grass → push → rush into the first tile
      const P = keys(t, [[0, [0, 0.95, 2.5]], [0.3, [0, 0.95, 2.3]], [1.0, [0.35, 1.5, 7.8]], [1.6, [0.1, 1.4, 7.0]], [1.82, [-0.7, 1.15, 0.6]]], easeInOut);
      const Lk = keys(t, [[0, [0, 0.92, -0.8]], [0.3, [0, 0.92, -0.8]], [1.0, [0, 1.15, 0]], [1.6, [0, 1.15, 0]], [1.82, [-0.75, 1.25, -0.8]]], easeInOut);
      const [sx, sy] = shakeOf(t, [A + 0.1, U + 0.12], 0.08);
      K.cam(s, [P[0] + sx, P[1] + sy, P[2]], Lk, Math.sin(t * 1.3) * 0.02 + sx * 0.3);
      s.post.focus = s.camera.position.distanceTo(new THREE.Vector3(0, 0.8, -0.3));
    };
  }

  // ===================================================================== 2. ORANGE: "everyone's asking the same thing" + "What's the prompt?"
  {
    const s = sets.orange = K.newSet(E, { fov: 40, bg: 0xd9580f });
    K.backdrop(s, K.radial('#ff9a3c', '#b8400a', { cy: 0.42, r: 0.8 }), 120);
    const em = K.embers(s, { n: 80, box: [14, 16, 10], center: [0, 0, -4], color: 0xffd23d, size: 0.07 });
    const det = S.character(E, 'det', { h: 2.6 }); s.scene.add(det);
    const q = [0, 1, 2].map(() => { const m = S.sprite(E, S.drawGlyph('?'), { h: 0.6, anchor: 'center' }); s.scene.add(m); return m; });
    // the stack (camera-attached so it stays framed while the camera orbits the detective)
    const F = (w) => `${w} {px}px Fredoka`;
    const ev = hudWord(s, "everyone's", 640, 560, 62, { font: F(600), color: '#fff6ec', side: '#b5541a', depth: 0.12, rot: [0, -0.35, 0.04] });
    const ask = hudWord(s, 'asking', 640, 680, 120, { font: F(700), color: '#ffffff', side: '#c2410c', depth: 0.25, rot: [0, -0.35, 0.03] });
    const the = hudWord(s, 'the', 470, 790, 70, { font: F(600), color: '#ffffff', side: '#c2410c', depth: 0.12, rot: [0, -0.3, 0] });
    const same = hudWord(s, 'SAME', 620, 905, 168, { font: F(700), color: '#ffffff', side: '#b3121b', back: '#5a0408', depth: 0.45, layers: 16, rot: [0, -0.32, -0.03] });
    const thing = hudWord(s, 'thing.', 650, 1060, 120, { font: F(700), color: '#ffffff', side: '#c2410c', depth: 0.25, rot: [0, -0.3, 0.02] });
    const whats = hudWord(s, "What's", 560, 420, 110, { font: F(700), color: '#ffffff', side: '#c2410c', depth: 0.25, rot: [0.1, 0.25, -0.06] });
    const the2 = hudWord(s, 'the', 820, 640, 70, { font: F(600), color: '#ffffff', side: '#c2410c', depth: 0.12, rot: [0, -0.4, 0.05] });
    const prompt = hudWord(s, 'prompt?', 600, 1340, 165, { font: F(700), color: '#ffffff', side: '#b3121b', back: '#5a0408', depth: 0.45, layers: 16, rot: [-0.25, 0.2, 0.05] });
    s.update = (t) => {
      em.userData.update(t, s.camera);
      const A0 = T('and'), Q = T("What's"), P = T('prompt?'), Wr = T('Wrong');
      // detective: jumps in from the left, lands, then the magnifier
      const j = seg(t, A0 - 0.05, A0 + 0.45), jy = Math.sin(j * Math.PI) * 1.6 * (1 - j * 0.3);
      if (t < Q - 0.1) { det.position.set(lerp(-5.5 * LX, -1.2 * LX, easeOut(j)), -2.2 + jy, 0); det.userData.pose({ armL: j < 1 ? 2.3 : 0.6 + Math.sin(t * 5) * 0.15, armR: j < 1 ? -2.3 : -0.5, eyes: j < 1 ? 'happy' : 'open', mouth: j < 1 ? 'open' : (E.film.env[Math.floor(t * 30)] > 0.3 ? 'open' : 'smile'), legs: j < 1 ? 3 : 0 }); }
      else { const k = easeOutBack(seg(t, Q - 0.1, Q + 0.25)); det.position.set(lerp(-1.2 * LX, -0.9 * LX, k), -2.6 + Math.sin(t * 4) * 0.05, 0); det.userData.pose({ armR: 2.1 + Math.sin(t * 6) * 0.08, armL: 0.25, item: 'magnifier', eyes: 'wide', look: -0.6, mouth: 'o' }); }
      det.scale.setScalar(t < Q - 0.1 ? 1 : 0.95);
      // the hook stack
      const X = Q - 0.12;
      kin(ev, t, { at: T("everyone's") - 0.06, from: { p: [1.5, 0, 0], r: [0, 1.2, 0] }, out: X, to: { p: [-4, 2, 0] } });
      kin(ask, t, { at: T('asking') - 0.06, from: { p: [0, -1, 1], r: [1, 0, 0] }, out: X + 0.02, to: { p: [-4, 1, 0] } });
      kin(the, t, { at: T('the') - 0.05, from: { s: 3 }, out: X + 0.04, to: { p: [-4, 0, 0] } });
      const sk = kin(same, t, { at: T('same') - 0.05, dur: 0.3, from: { p: [0, 0, 4], s: 2.6, r: [0, 0, 0.4] }, out: X + 0.05, to: { p: [-4, -1, 0] } });
      if (same.visible) same.userData.tint(t - T('same') < 0.12 ? '#ff8fc8' : '#ffffff');
      kin(thing, t, { at: T('thing.') - 0.05, from: { p: [0, -2, 0], r: [-1.2, 0, 0] }, out: X + 0.06, to: { p: [-4, -2, 0] } });
      kin(whats, t, { at: Q - 0.04, from: { p: [0, 2, 0], r: [0, 0, 0.8] }, out: Wr - 0.05, to: { s: 2 } });
      kin(the2, t, { at: T('the', 2) - 0.04, from: { s: 0 }, out: Wr - 0.05, to: { s: 2 } });
      kin(prompt, t, { at: P - 0.05, dur: 0.32, from: { p: [0, -3, 2], r: [-1.4, 0.3, 0] }, out: Wr - 0.05, to: { s: 2 } });
      // question marks pop around the detective
      q.forEach((m, i) => { const t0 = Q + 0.12 + i * 0.22; const k = easeOutBack(seg(t, t0, t0 + 0.22), 3); m.visible = t > t0 && t < Wr; m.position.set(-1.3 * LX + i * 0.65, 0.5 + i * 0.4 + Math.sin(t * 6 + i) * 0.08, 0.2); m.scale.setScalar(Math.max(0.001, k * (1 + i * 0.25))); m.rotation.z = Math.sin(t * 5 + i) * 0.25; });
      // camera: orbit + dolly; a whip to the question on "What's"
      const sh = shakeOf(t, [T('same') + 0.05, P + 0.05], 0.12);
      let yaw = lerp(0.42, -0.12, easeInOut(seg(t, A0, X))), dist = lerp(9.5, 7.6, easeInOut(seg(t, A0, X))), py = 0.2;
      if (t >= X) { yaw = lerp(-0.5, 0.25, easeOut(seg(t, X, Wr))); dist = lerp(7.0, 6.2, seg(t, X, Wr)); py = lerp(-1.4, 0.4, easeOut(seg(t, X, P + 0.3))); }
      K.cam(s, [Math.sin(yaw) * dist + sh[0], py + sh[1], Math.cos(yaw) * dist], [0, -0.3, 0], Math.sin(t * 1.7) * 0.03 + (t >= X ? lerp(0.12, 0, easeOut(seg(t, X, X + 0.4))) : 0));
    };
  }

  // ===================================================================== 3. RED: "Wrong question."
  {
    const s = sets.red = K.newSet(E, { fov: 40, bg: 0xb3121b });
    K.backdrop(s, K.radial('#ee3a35', '#8a0a0f', { cy: 0.4, r: 0.85 }), 120);
    const det = S.character(E, 'det', { h: 3.0 }); s.scene.add(det);
    const bang = S.sprite(E, S.drawGlyph('!', '#ff3030'), { h: 0.9, anchor: 'center' }); s.scene.add(bang);
    const wrong = hudWord(s, 'WRONG', 540, 600, 250, { font: '400 {px}px Anton', color: '#f7e8d4', side: '#7a0a0e', back: '#2a0204', depth: 0.6, layers: 18 });
    const qn = hudWord(s, 'QUESTION.', 600, 830, 130, { font: '400 {px}px Anton', color: '#f7e8d4', side: '#7a0a0e', back: '#2a0204', depth: 0.3, layers: 12 });
    const pr = hudWord(s, 'prompt?', 330, 1120, 120, { font: '700 {px}px Fredoka', color: '#ffffff', side: '#7a0a0e', depth: 0.2, rot: [0, 0.3, -0.12] });
    const strike = new THREE.Mesh(new THREE.BoxGeometry(1, 0.08, 0.05), new THREE.MeshBasicMaterial({ color: new THREE.Color(0xff2020).multiplyScalar(2.2) })); pr.add(strike); strike.position.set(0, 0.0, 0.08);
    // a glowing ring of red pixels
    const ring = new THREE.Group(); for (let i = 0; i < 26; i++) { const a = -0.4 + i * 0.12; const b = new THREE.Mesh(new THREE.PlaneGeometry(0.11, 0.11), new THREE.MeshBasicMaterial({ color: new THREE.Color(0xff2a2a).multiplyScalar(2) })); b.position.set(Math.cos(a) * 1.4, Math.sin(a) * 1.4, 0); ring.add(b); }
    at(s, ring, 380, 1180, 8);
    s.update = (t) => {
      const Wr = T('Wrong'), Qn = T('question.');
      kin(wrong, t, { at: Wr - 0.06, dur: 0.22, from: { p: [0, 0, 6], s: 3.2, r: [0, 0, -0.2] }, ease: easeOut });
      kin(qn, t, { at: Qn - 0.04, dur: 0.24, from: { p: [3, 0, 0], r: [0, -1.3, 0] } });
      const kp = kin(pr, t, { at: Wr + 0.1, dur: 0.3, from: { p: [-2, -1, 0], r: [0, 0, 1] } });
      strike.scale.x = Math.max(0.001, easeOut(seg(t, Qn + 0.05, Qn + 0.25)) * pr.userData.w * 1.05);
      ring.children.forEach((b, i) => { b.visible = t > Qn + 0.05 + i * 0.012; b.scale.setScalar(1 + 0.3 * Math.sin(t * 9 + i)); }); ring.rotation.z = t * 0.6;
      det.position.set(lerp(-5 * LX, -2.15 * (LAND ? 1.35 : 1), easeOutBack(seg(t, Wr + 0.15, Wr + 0.45))), -1.4, 0.5); det.rotation.z = -0.12;
      det.userData.pose({ eyes: 'wide', mouth: 'o', armR: 0.4, armL: 0.3, look: 1 });
      const kb = easeOutBack(seg(t, Wr + 0.35, Wr + 0.55), 3); bang.visible = t > Wr + 0.35; bang.position.set(-1.7 * (LAND ? 1.38 : 1), 1.15 + Math.sin(t * 12) * 0.04, 0.6); bang.scale.setScalar(Math.max(0.001, kb));
      const sh = shakeOf(t, [Wr + 0.12, Qn + 0.05], 0.25);
      K.cam(s, [sh[0], sh[1], lerp(9, 8.2, seg(t, Wr, Wr + 1.2))], [0, 0, 0], -0.05 + sh[0] * 0.2);
    };
  }

  // ===================================================================== 4. DARK: subtitles "Because I gave the whole skill away. For free."
  {
    const s = sets.dark = K.newSet(E, { fov: 40, bg: 0x060304 });
    K.backdrop(s, K.radial('#3a0507', '#040203', { cy: 0.85, r: 0.7 }), 120);
    const kb = K.keyboard({ cols: 18, rows: 6, key: 0.3 }); kb.position.set(0, -3.2, -2); kb.rotation.x = 0.5; s.scene.add(kb);
    s.scene.add(new THREE.HemisphereLight(0x402020, 0x000000, 0.6)); const pl = new THREE.PointLight(0xff2020, 40, 12, 1.4); pl.position.set(0, -1, 0); s.scene.add(pl);
    const ring = new THREE.Mesh(new THREE.TorusGeometry(4.2, 0.012, 6, 160), new THREE.MeshBasicMaterial({ color: 0x5a0d12 })); ring.position.set(0, 0.6, -3); s.scene.add(ring);
    s.post = { focus: 3, aperture: 9, bloom: 0.4 };
    s.update = (t) => {
      const B = T('Because'), F0 = T('For');
      const z = lerp(6.5, 5.2, seg(t, B - 0.2, F0)) - (t > F0 ? easeOut(seg(t, F0, F0 + 0.9)) * 0.8 : 0);
      K.cam(s, [Math.sin(t * 0.8) * 0.2, 0.2, z], [0, -0.3, -2], Math.sin(t * 0.6) * 0.03);
      pl.intensity = 40 + (t > F0 ? 60 * Math.exp(-(t - F0) * 3) : 0);
      for (let i = 0; i < 6; i++) { const k = Math.floor(hash(Math.floor(t * 9) + i) * 108); kb.userData.press(k, 1); }
    };
  }

  // ===================================================================== 5. STAGE: took it, repackaged it, sold it for $30
  {
    const s = sets.stage = K.newSet(E, { fov: 40, bg: 0x1a0806 });
    K.stage(s, { spot: [0, 0, 0] });
    const signs = [[0xff2a2a, -2.6, 4.2], [0x2ae0d0, 0.2, 3.8], [0xff9b73, 2.8, 4.4], [0xff2a2a, 6.6, 4.0], [0x2ae0d0, 9.4, 4.3]].map(([c, x, y]) => { const g = K.glowSign(c, 1.5, 0.9); g.position.set(x, y, -5.9); s.scene.add(g); return g; });
    const det = S.character(E, 'det', { h: 2.2 }); s.scene.add(det);
    const cl = S.character(E, 'claude', { h: 1.15 }); s.scene.add(cl);
    const file = K.fileProp({ label: 'SKILL.md' }); s.scene.add(file);
    const course = K.courseBox(); course.position.set(6.6, 1.8, -1.5); s.scene.add(course);
    const tag = K.priceTag('$30'); s.scene.add(tag);
    const hands = [0, 1, 2].map(i => { const m = S.sprite(E, S.drawGlyph(i ? 'cursor' : 'hand'), { h: i ? 0.55 : 0.9, anchor: 'center' }); s.scene.add(m); return m; });
    const dust = new THREE.Group(); for (let i = 0; i < 40; i++) { const b = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.08, 0.08), new THREE.MeshBasicMaterial({ color: [0xffffff, 0xffc63a, 0xff6040][i % 3] })); dust.add(b); } s.scene.add(dust);
    // words at the top
    const took = hudWord(s, 'took it,', 510, 1320, 110, { font: '700 {px}px Fredoka', color: '#ffffff', side: '#7a0a0e', depth: 0.2 });
    const rep = hudWord(s, 'repackaged', 510, 1320, 120, { font: '700 {px}px Fredoka', color: '#ffffff', side: '#7a0a0e', depth: 0.25 });
    const sold = hudWord(s, 'SOLD', 510, 1170, 140, { font: '400 {px}px Anton', color: '#ff3b3b', side: '#4a0306', depth: 0.3 });
    const for30 = hudWord(s, 'for $30.', 510, 1350, 120, { font: '700 {px}px Fredoka', color: '#ffc63a', side: '#8a3a05', depth: 0.3 });
    s.post = { focus: 7, aperture: 4, bloom: 0.45, thr: 0.85 };
    s.update = (t) => {
      const A = T('And'), P = T('people'), Tk = T('took'), Rp = T('repackaged'), Sd = T('sold'), D30 = T('$30.');
      // characters
      det.position.set(lerp(-2.4, Tk > t ? -2.4 : lerp(-2.4, 4.6, easeInOut(seg(t, Tk + 0.3, Rp + 0.2))), 1), 0, 0.2);
      const walking = t > Tk + 0.3 && t < Rp + 0.2;
      det.userData.pose({ eyes: t > Tk ? 'wide' : 'open', mouth: t > Tk ? 'o' : 'smile', armR: t < Tk ? 0.2 : (t < Sd ? 1.6 : 2.4), armL: walking ? Math.sin(t * 18) * 0.5 : 0.2, legs: walking ? Math.round(Math.sin(t * 18) * 2) : 0 });
      cl.position.set(t < Tk ? 1.8 : lerp(1.8, 5.6, easeInOut(seg(t, Tk + 0.4, Rp + 0.3))), 0, 0.6);
      cl.userData.pose({ eyes: t < Tk ? 'happy' : t < Sd ? 'wide' : 'sad', step: t > Tk + 0.4 && t < Rp + 0.3 ? Math.floor(t * 10) % 2 : 0 });
      // the file: floats, gets grabbed and yanked off to the right, becomes a COURSE
      const fy = 1.7 + Math.sin(t * 2.4) * 0.08;
      if (t < Tk) file.position.set(0, fy, -0.6), file.rotation.set(0, Math.sin(t * 1.5) * 0.4, 0), file.scale.setScalar(1);
      else { const k = easeIn(seg(t, Tk, Tk + 0.35)); file.position.set(lerp(0, 7.2, k), fy + k * 0.6, -0.6 - k * 0.8); file.rotation.set(0, k * 6, k * -0.6); file.scale.setScalar(Math.max(0.001, 1 - seg(t, Rp - 0.05, Rp + 0.05))); }
      file.visible = t < Rp + 0.05;
      // the hand + cursors: drift in on "people", the hand grabs on "took"
      hands.forEach((h, i) => {
        const t0 = P + i * 0.08, k = easeOut(seg(t, t0, t0 + 0.35));
        if (i === 0) { const kk = easeIn(seg(t, Tk, Tk + 0.35)); h.position.set(lerp(4.5, 0.55, k) + kk * 7.2, fy + 0.1 + kk * 0.6, -0.4 - kk * 0.8); h.rotation.z = 0.3; }
        else { h.position.set(lerp(i === 1 ? -5 : 5, i === 1 ? -0.8 : 0.9, k), fy + (i === 1 ? 0.7 : -0.6), -0.3); h.rotation.z = i === 1 ? -0.4 : 0.6; }
        h.visible = t > t0 && (i === 0 ? t < Rp : t < Tk + 0.25);
      });
      // the COURSE drops on its pedestal
      const kd = seg(t, Rp - 0.05, Rp + 0.25), bounce = kd < 1 ? (1 - easeIn(kd)) * 5 : 0;
      course.userData.box.position.y = bounce; course.userData.box.scale.set(1 + (kd >= 1 ? 0.12 * Math.exp(-(t - Rp - 0.25) * 9) : 0), 1 - (kd >= 1 ? 0.15 * Math.exp(-(t - Rp - 0.25) * 9) : 0), 1);
      course.userData.box.visible = t > Rp - 0.05;
      dust.children.forEach((b, i) => { const t0 = Rp + 0.25, k = seg(t, t0, t0 + 0.7); b.visible = k > 0 && k < 1; const a = hash(i * 3.3) * 6.28, r = easeOut(k) * (0.8 + hash(i) * 1.6); b.position.set(7.0 + Math.cos(a) * r, 1.0 + Math.sin(k * 3) * hash(i * 2) * 1.2 - k * k * 0.8, -1.5 + Math.sin(a) * r * 0.5); b.scale.setScalar(1 - k); });
      // the price tag swings in, slams on "$30."
      const kt = easeOutBack(seg(t, Sd, Sd + 0.3), 2.5); tag.visible = t > Sd; tag.position.set(7.35, 3.0 + (1 - kt) * 3, -0.9); tag.rotation.z = Math.sin((t - Sd) * 7) * 0.25 * Math.exp(-(t - Sd) * 2.5) - 0.15; tag.scale.setScalar(Math.max(0.001, kt * 0.75 * (1 + (t > D30 ? 0.35 * easeOutBack(seg(t, D30, D30 + 0.2), 3) : 0))));
      signs.forEach((g, i) => g.children[0].material.color.setScalar(1).multiply(new THREE.Color([0xff2a2a, 0x2ae0d0, 0xff9b73, 0xff2a2a, 0x2ae0d0][i])).multiplyScalar(1.4 + 0.5 * Math.sin(t * 7 + i * 2)));
      // words
      kin(took, t, { at: Tk - 0.04, from: { p: [0, 1, 0], s: 1.8 }, out: Rp - 0.08 });
      kin(rep, t, { at: Rp - 0.05, from: { p: [-3, 0, 0], r: [0, 1, 0] }, out: Sd - 0.1, to: { p: [3, 0, 0] } });
      kin(sold, t, { at: Sd - 0.05, from: { s: 3, r: [0, 0, 0.5] } });
      kin(for30, t, { at: D30 - 0.08, from: { p: [0, -2, 2], s: 2 } });
      // camera: the stage → whip right after the file → the COURSE, push in on $30
      const camX = t < Tk + 0.12 ? lerp(0, 0.4, seg(t, A, Tk)) : lerp(0.4, 6.6, easeInOut(seg(t, Tk + 0.12, Tk + 0.5)));
      const push = easeOut(seg(t, D30, D30 + 0.5)) * 1.6 + easeInOut(seg(t, Sd, D30)) * 0.8;
      const sh = shakeOf(t, [Rp + 0.24, D30 + 0.05], 0.12);
      K.cam(s, [camX + sh[0], 2.1 + sh[1], 8.4 - push], [camX + (t > Tk ? 0.6 : 0), 1.45, -1], sh[0] * 0.2);
      s.post.focus = 8.4 - push + 0.6;
    };
  }

  // ===================================================================== 6. DOLLAR: "$30. For a text file."
  {
    const s = sets.dollar = K.newSet(E, { fov: 42, bg: 0x120202 });
    K.backdrop(s, K.radial('#4a0808', '#060101', { cy: 0.5, r: 0.7 }), 150);
    const em = K.embers(s, { n: 90, box: [16, 20, 30], center: [0, 0, -8], color: 0xffc63a, size: 0.09, speed: 0.15 });
    const d30 = W3('$30', { font: '700 {px}px Fredoka', px: 300, color: '#ffc63a', side: '#d9480f', back: '#3a0a02', depth: 0.9, layers: 24 }); d30.scale.setScalar(LAND ? 0.74 : 1.1); s.scene.add(d30); s.words.push(d30);
    const file = K.fileProp({}); file.position.set(0, 0.1, -9); file.scale.setScalar(2.2); s.scene.add(file);
    const glow = new THREE.PointLight(0xffd08a, 50, 14, 1.5); glow.position.set(0, 1, -6); s.scene.add(glow); s.scene.add(new THREE.HemisphereLight(0xffe0b0, 0x200000, 0.8));
    const tf = hudWord(s, 'text file.', 520, 1240, 130, { font: '700 {px}px Fredoka', color: '#ffffff', side: '#7a0a0e', depth: 0.25 });
    const fa = hudWord(s, 'For a', 520, 1090, 70, { font: '600 {px}px Fredoka', color: '#ffd9c0', side: '#7a0a0e', depth: 0.12 });
    s.update = (t) => {
      em.userData.update(t, s.camera);
      const D = T('$30.', 2), F0 = T('For', 2), Tx = T('text');
      d30.rotation.y = Math.sin(t * 1.4) * 0.35; d30.rotation.x = Math.sin(t * 0.9) * 0.1;
      // fly in hard on "$30.", then dive through the zero to the file
      let z = keys(t, [[D - 0.15, 20], [D + 0.12, 7.5], [F0 - 0.15, 6.2], [F0 + 0.25, -2.0], [F0 + 1.2, -4.0]], easeInOut);
      const x = keys(t, [[D, 0], [F0 - 0.15, 0], [F0 + 0.1, 0.95], [F0 + 0.3, 0.2]], easeInOut);
      const sh = shakeOf(t, [D + 0.1], 0.2);
      K.cam(s, [x + sh[0], 0.15 + sh[1], z], [x * 0.5, 0.1, z - 10], sh[0] * 0.3 + Math.sin(t * 2) * 0.02);
      file.rotation.y = t * 1.2; file.position.y = 0.1 + Math.sin(t * 2) * 0.12;
      fa.visible = tf.visible = false;
      kin(fa, t, { at: F0 + 0.05, from: { s: 0 } }); kin(tf, t, { at: Tx - 0.04, from: { p: [0, -1.5, 0], r: [-1.2, 0, 0] } });
      d30.visible = z > -0.5; d30.userData.noMark = z < 5.5;   // the dive through the zero: the word passes the camera on purpose
    };
  }

  // ===================================================================== 7. FACE: "I'm CASPER,"
  {
    const s = sets.face = K.newSet(E, { fov: 38, bg: 0x0b0605 });
    const shelf = new THREE.Group(); s.scene.add(shelf);
    for (let i = 0; i < 40; i++) { const b = new THREE.Mesh(new THREE.BoxGeometry(0.35, 0.9 + hash(i) * 0.6, 0.3), new THREE.MeshBasicMaterial({ color: new THREE.Color().setHSL(0.02 + hash(i * 2) * 0.1, 0.6, 0.12 + hash(i * 3) * 0.1) })); b.position.set(-6 + (i % 20) * 0.62, i < 20 ? 1.2 : -1.2, -12); shelf.add(b); }
    for (let i = 0; i < 6; i++) { const l = new THREE.Mesh(new THREE.PlaneGeometry(0.35, 0.35), new THREE.MeshBasicMaterial({ color: new THREE.Color(0xffd890).multiplyScalar(2.5) })); l.position.set(-4 + i * 1.7, 3.2 - (i % 2) * 2.8, -11.8); shelf.add(l); }
    const det = S.character(E, 'det', { h: 9 }); det.position.set(-2.3, -5.6, 0); s.scene.add(det);
    const im = hudWord(s, "I'm", 760, 520, 90, { font: '700 {px}px Syne', color: '#ffffff', side: '#555555', depth: 0.1 });
    const cas = hudWord(s, 'CASPER,', 515, 760, 100, { font: '800 {px}px Syne', color: '#ff3b30', side: '#6a0805', depth: 0.35, layers: 14 });
    s.post = { focus: 7, aperture: 14, bloom: 0.5 };
    s.update = (t) => {
      const I = T("I'm"), C = T('Casper,');
      det.userData.pose({ eyes: t > C + 0.2 && t < C + 0.32 ? 'closed' : 'open', mouth: E.film.env[Math.floor(t * 30)] > 0.35 ? 'open' : 'smile', look: 0.6 });
      kin(im, t, { at: I - 0.05, from: { p: [1, 0, 0] } });
      kin(cas, t, { at: C - 0.05, dur: 0.2, from: { p: [0, 0, 5], s: 2.5 }, ease: easeOut });
      const sh = shakeOf(t, [C + 0.1], 0.2);
      K.cam(s, [lerp(0.4, 0.0, seg(t, I - 0.2, C + 0.6)) + sh[0], 1.9 + sh[1], lerp(8.2, 7.2, seg(t, I - 0.2, C + 0.6))], [0, 1.9, 0], 0.04 + sh[0] * 0.2);
    };
  }

  // ===================================================================== 8. DESK: "and I built Morphy, an agentic super app." (+ the film inside its own monitor later)
  {
    const s = sets.desk = K.newSet(E, { fov: 40, bg: 0x140a06, fog: [0x140a06, 10, 30] });
    const back = new THREE.Mesh(new THREE.PlaneGeometry(30, 14), new THREE.MeshStandardMaterial({ color: 0x2a140c, roughness: 1 })); back.position.set(0, 4, -4); s.scene.add(back);
    const shelfC = [0x8e1b1b, 0x2d6b4f, 0xc79a2a, 0x3a3f7a, 0x7a3a8a];
    for (let i = 0; i < 9; i++) { const b = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.8, 0.5), new THREE.MeshStandardMaterial({ color: shelfC[i % 5] })); b.position.set(-4.2 + i * 0.32, 3.9, -3.6); s.scene.add(b); }
    K.desk(s, { w: 8, d: 3.6 });
    const kb = K.keyboard({ cols: 16, rows: 5, key: 0.17 }); kb.position.set(0, 0.05, 0.75); s.scene.add(kb);
    const ui = K.canvasTex(960, 560, () => { });
    const snapMat = E.screenMat(null);
    const mon = K.monitor({ w: 3.9, h: 2.25, mat: new THREE.MeshBasicMaterial({ map: ui }) }); mon.position.set(0, 2.25, -1.1); s.scene.add(mon);
    const guy = K.voxGuy(); guy.position.set(0, -0.2, 2.3); guy.scale.setScalar(0.85); s.scene.add(guy);
    s.scene.add(new THREE.HemisphereLight(0xffd8b0, 0x200800, 1.0)); const key = new THREE.DirectionalLight(0xffe2c0, 1.6); key.position.set(3, 7, 4); s.scene.add(key);
    const scrLight = new THREE.PointLight(0x9fb8ff, 10, 6, 1.5); scrLight.position.set(0, 2.2, 0.2); s.scene.add(scrLight);
    const mor = hudWord(s, 'Morphy,', 510, 400, 130, { font: '800 {px}px Syne', color: '#ffffff', side: '#3a3a3a', depth: 0.25 });
    const ag = hudWord(s, 'agentic', 360, 1190, 105, { font: '700 {px}px Fredoka', color: '#ff9b73', side: '#7a2a0e', depth: 0.2, rot: [0, 0.25, -0.05] });
    const sup = hudWord(s, 'super app.', 580, 1370, 110, { font: '700 {px}px Fredoka', color: '#ffffff', side: '#7a2a0e', depth: 0.22, rot: [0, -0.2, 0.04] });
    const now = hudWord(s, 'now?', 510, 860, 255, { font: '700 {px}px Fredoka', color: '#ffffff', side: '#9aa7b8', back: '#2a2a33', depth: 0.6, layers: 18 });
    const vid = hudWord(s, 'this video', 510, 420, 84, { font: '800 {px}px Syne', color: '#ffffff', side: '#444444', depth: 0.15 });
    // the Morphy screen: a canvas with cards popping in
    const CARDS = [[60, 90, 300, 190, 'Chat'], [400, 70, 250, 150, 'Notes'], [690, 110, 220, 230, 'Tasks'], [80, 320, 260, 180, 'Roadmap'], [380, 260, 280, 240, 'Claude'], [690, 370, 220, 140, 'Stats']];
    function drawUI(t) {
      ui.userData.redraw((g, w, h) => {
        g.fillStyle = '#1c1d20'; g.fillRect(0, 0, w, h);
        g.fillStyle = 'rgba(243,243,241,0.07)'; for (let y = 20; y < h; y += 28) for (let x = 20; x < w; x += 28) g.fillRect(x, y, 2, 2);
        g.fillStyle = '#161719'; g.fillRect(0, 0, w, 44); g.font = '700 22px Inter'; g.fillStyle = '#f3f3f1'; g.fillText('Morphy', 20, 30); g.fillStyle = '#a4a6aa'; g.font = '500 18px Inter'; g.fillText('Home · Canvas', 120, 30);
        const n = t < T('agentic') ? 2 : t < T('super') ? 4 : 6;
        CARDS.forEach(([x, y, cw, ch, name], i) => {
          if (i >= n) return; const t0 = i < 2 ? T('Morphy,') : i < 4 ? T('agentic') + (i - 2) * 0.12 : T('super') + (i - 4) * 0.12, k = easeOutBack(seg(t, t0 - 0.05, t0 + 0.2));
          if (k <= 0) return; g.save(); g.translate(x + cw / 2, y + ch / 2); g.scale(k, k); g.translate(-cw / 2, -ch / 2);
          g.fillStyle = 'rgba(243,243,241,0.07)'; g.strokeStyle = 'rgba(243,243,241,0.16)'; g.lineWidth = 2; g.beginPath(); g.roundRect(0, 0, cw, ch, 16); g.fill(); g.stroke();
          g.fillStyle = i === 4 ? '#e8743f' : '#3446f0'; g.beginPath(); g.arc(22, 24, 7, 0, 7); g.fill();
          g.fillStyle = '#f3f3f1'; g.font = '600 20px Inter'; g.fillText(name, 38, 31);
          g.fillStyle = 'rgba(243,243,241,0.22)'; for (let l = 0; l < Math.floor((ch - 60) / 24); l++) g.fillRect(18, 52 + l * 24, (cw - 36) * (0.5 + 0.5 * hash(i * 9 + l)), 10);
          g.restore();
        });
      });
    }
    s.post = { focus: 4, aperture: 3, bloom: 0.4 };
    s.update = (t) => {
      const recur = t > 25;
      if (!recur) { mon.userData.screen.material.map === ui || (mon.userData.screen.material = new THREE.MeshBasicMaterial({ map: ui })); drawUI(t); }
      else { const tex = E.snapshot('orange', 1.95 + (t - T('And', 2)) * 1.0, E.rt('mon')); snapMat.uniforms.t.value = tex; mon.userData.screen.material = snapMat; }
      // typing
      guy.userData.arms.forEach((a, i) => { a.position.y = 1.35 + Math.abs(Math.sin(t * 16 + i * 1.7)) * 0.08; });
      for (let i = 0; i < 4; i++) kb.userData.press(Math.floor(hash(Math.floor(t * 12) + i * 7) * 80), 1);
      if (!recur) {
        const B = T('built'), M = T('Morphy,'), A = T('agentic');
        const P = keys(t, [[B - 0.3, [0.3, 7.6, 3.4]], [M - 0.05, [0.6, 6.6, 3.9]], [M + 0.35, [2.2, 3.2, 6.2]], [A, [1.6, 3.0, 5.6]], [A + 1.2, [0.6, 2.7, 4.2]]], easeInOut);
        const Lk = keys(t, [[B - 0.3, [0, 0, 0.7]], [M - 0.05, [0, 0.4, 0.4]], [M + 0.35, [0, 1.8, -1]], [A + 1.2, [0, 2.1, -1.1]]], easeInOut);
        const sh = shakeOf(t, [M + 0.05, A + 0.05, T('super') + 0.05], 0.06);
        K.cam(s, [P[0] + sh[0], P[1] + sh[1], P[2]], Lk, sh[0] * 0.3 + (t < M + 0.35 ? 0.25 * (1 - seg(t, M, M + 0.35)) : 0));
        s.post.focus = s.camera.position.distanceTo(new THREE.Vector3(0, 1.4, 0));
        kin(mor, t, { at: M - 0.06, from: { p: [0, 0, 4], s: 2.4 }, ease: easeOut, dur: 0.22 });
        kin(ag, t, { at: A - 0.05, from: { p: [-3, 0, 0], r: [0, 1.4, 0] } });
        kin(sup, t, { at: T('super') - 0.05, from: { p: [3, -1, 0], r: [0, -1.4, 0] } });
        now.visible = vid.visible = false;
      } else {
        const A2 = T('And', 2), N = T('now?');
        mor.visible = ag.visible = sup.visible = false;
        const P = keys(t, [[A2 - 0.1, [-1.2, 3.6, 7.8]], [N - 0.1, [0.0, 2.6, 3.6]], [N + 0.35, [0.0, 2.3, 1.6]]], easeInOut);
        K.cam(s, P, [0, 2.2, -1.1], Math.sin(t) * 0.02);
        s.post.focus = s.camera.position.distanceTo(new THREE.Vector3(0, 2.2, -1.1));
        kin(vid, t, { at: T('this', 1) - 0.05, from: { p: [0, 2, 0] }, out: N - 0.1 });
        kin(now, t, { at: N - 0.06, dur: 0.24, from: { p: [0, 0, 6], s: 3, r: [0, 0, 0.3] }, ease: easeOut });
      }
    };
  }

  // ===================================================================== 9. CLOUDS: "So here's my RESPONSE."
  {
    const s = sets.clouds = K.newSet(E, { fov: 48, bg: 0x10203a, fog: [0x9fb8d8, 30, 95] });
    K.backdrop(s, K.radial('#ffffff', '#081428', { cy: 0.45, r: 0.45 }), 180);
    const ct = [1, 2, 3, 4].map(k => K.cloudTex(k, { lit: '#ffffff', mid: '#4a6488', dark: '#0d1830' }));
    const cl = new THREE.Group(); s.scene.add(cl);
    for (let i = 0; i < 160; i++) {
      const z = -i * 0.45, a = hash(i * 1.7) * Math.PI * 2, r = 4.6 + hash(i * 2.3) * 4.2;
      const m = new THREE.Mesh(new THREE.PlaneGeometry(5, 5), new THREE.MeshBasicMaterial({ map: ct[i % 4], transparent: true, depthWrite: false, opacity: 0.75, fog: true }));
      m.position.set(Math.cos(a) * r, Math.sin(a) * r * 1.4, z); m.rotation.z = hash(i) * 6; m.scale.setScalar(1 + hash(i * 5) * 1.6); cl.add(m);
    }
    const sunD = new THREE.Mesh(new THREE.CircleGeometry(6, 48), new THREE.MeshBasicMaterial({ color: new THREE.Color(0xffffff).multiplyScalar(3), fog: false })); sunD.position.set(0, 0, -95); s.scene.add(sunD);
    const rays = new THREE.Group(); s.scene.add(rays);
    const rayT = K.canvasTex(64, 512, (g, w, h) => { const gr = g.createLinearGradient(0, 0, 0, h); gr.addColorStop(0, 'rgba(255,255,255,0)'); gr.addColorStop(0.5, 'rgba(255,255,255,0.9)'); gr.addColorStop(1, 'rgba(255,255,255,0)'); g.fillStyle = gr; g.fillRect(w * 0.3, 0, w * 0.4, h); });
    for (let i = 0; i < 9; i++) { const r = new THREE.Mesh(new THREE.PlaneGeometry(1.2 + hash(i) * 2, 60), new THREE.MeshBasicMaterial({ map: rayT, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, opacity: 0.25, fog: false })); r.rotation.z = i * 0.7; r.position.z = -80; rays.add(r); }
    const so = hudWord(s, 'So,', 260, 760, 90, { font: '700 {px}px Fredoka', color: '#ffffff', side: '#4a5a7a', depth: 0.12 });
    const hm = hudWord(s, "here's my", 520, 880, 100, { font: '700 {px}px Fredoka', color: '#ffffff', side: '#4a5a7a', depth: 0.15 });
    const resp = W3('RESPONSE.', { font: '700 {px}px Fredoka', px: 200, color: '#ffc63a', side: '#e0600f', back: '#5a1a02', depth: 0.5, layers: 18, letters: true });
    { const d = 8, k = 118 * KS / (resp.userData.h * pxPerUnit(s, d)); resp.scale.setScalar(k); at(s, resp, 510, 1060, d); s.words.push(resp); }
    const streak = new THREE.Mesh(new THREE.PlaneGeometry(9, 0.03), new THREE.MeshBasicMaterial({ color: new THREE.Color(0xffd27a).multiplyScalar(3), transparent: true })); at(s, streak, 520, 1100, 7.9); streak.rotation.z = 0.25;
    s.post = { bloom: 0.6, thr: 0.75 };
    s.update = (t) => {
      const S0 = T('So'), Rs = T('response.');
      const z = lerp(8, -18, easeIn(seg(t, S0 - 0.15, T('An') + 0.1)) * 0.6 + seg(t, S0 - 0.15, T('An') + 0.1) * 0.4);
      K.cam(s, [Math.sin(t * 1.3) * 0.3, Math.cos(t * 1.1) * 0.2, z], [0, 0, z - 20], t * 0.25);
      cl.children.forEach((m, i) => { m.lookAt(s.camera.position); m.rotation.z = hash(i) * 6 + t * 0.1; });
      rays.rotation.z = t * 0.15;
      kin(so, t, { at: S0 - 0.04, from: { p: [-2, 0, 0] }, out: Rs - 0.05, to: { p: [-3, 0, 0] } });
      kin(hm, t, { at: T("here's") - 0.04, from: { p: [0, -1, 0], r: [1, 0, 0] } });
      resp.visible = t > Rs - 0.12;
      resp.userData.letters.forEach((L, i) => { const t0 = Rs - 0.1 + i * 0.03, k = seg(t, t0, t0 + 0.3); L.position.z = (1 - easeOut(k)) * -25; L.position.y = Math.sin(t * 4 + i) * 0.03; L.scale.setScalar(Math.max(0.001, easeOutBack(k))); L.rotation.y = (1 - easeOut(k)) * 2; });
      streak.visible = t > Rs; streak.position.x = lerp(-9, 9, seg(t, Rs + 0.1, Rs + 0.5)); streak.material.opacity = 1 - seg(t, Rs + 0.4, Rs + 0.6);
    };
  }

  // ===================================================================== 10. LIBRARY: "An entire library of content-format skill files, inside Morphy."
  {
    const s = sets.library = K.newSet(E, { fov: 42, bg: 0x02060a, fog: [0x02060a, 8, 34] });
    K.gridFloor(s, { color: 0x1fd6c6, cell: 1, fade: 18 });
    const NAMES = ['kinetic.md', 'motion.md', 'voxel.md', 'light.md', 'terracotta.md', 'sandbox.md', 'paper.md', 'pixel.md', 'glow.md'];
    const BANDS = ['#c0141a', '#2a6fd6', '#1fae7a', '#d6a22a', '#c2552a', '#e8743f', '#7a4ad6', '#e0336f', '#2ab3e0'];
    const files = NAMES.map((n, i) => { const f = K.fileProp({ label: n, band: BANDS[i] }); f.scale.setScalar(0.85); s.scene.add(f); f.userData.home = new THREE.Vector3(-8 + i * 2.0, 1.5 + Math.sin(i * 1.3) * 0.35, -2.5 - Math.cos(i * 0.9) * 0.8); return f; });
    s.scene.add(new THREE.HemisphereLight(0xbff8ff, 0x001010, 1.2)); const pl = new THREE.PointLight(0x2ae0d0, 30, 10, 1.5); s.scene.add(pl);
    const trails = [0, 1, 2, 3].map(i => K.dotTrail(s, [[-9, 3.5 - i * 0.3, -3], [-4, 4.6, -2.6 + i * 0.2], [1, 3.4 + i * 0.4, -2.2], [6, 4.2, -2.8], [11, 3, -3]], { n: 46, size: 0.07 }));
    const cl = S.character(E, 'claude', { h: 0.95 }); s.scene.add(cl);
    // the Morphy window the files fly into
    const win = K.canvasTex(1000, 760, (g, w, h) => {
      g.fillStyle = '#1c1d20'; g.beginPath(); g.roundRect(0, 0, w, h, 44); g.fill(); g.strokeStyle = 'rgba(243,243,241,0.22)'; g.lineWidth = 4; g.stroke();
      g.fillStyle = '#161719'; g.beginPath(); g.roundRect(0, 0, w, 96, [44, 44, 0, 0]); g.fill();
      g.fillStyle = '#f3f3f1'; g.font = '700 44px Inter'; g.fillText('Morphy', 50, 64); g.fillStyle = '#a4a6aa'; g.font = '500 34px Inter'; g.fillText('Content formats · free', 250, 64);
      for (let i = 0; i < 9; i++) { const x = 60 + (i % 3) * 300, y = 140 + Math.floor(i / 3) * 200; g.fillStyle = 'rgba(243,243,241,0.06)'; g.beginPath(); g.roundRect(x, y, 270, 170, 22); g.fill(); g.strokeStyle = 'rgba(195,202,255,0.35)'; g.lineWidth = 3; g.setLineDash([12, 10]); g.stroke(); g.setLineDash([]); }
    });
    const winM = new THREE.Mesh(new THREE.PlaneGeometry(5.2, 3.95), new THREE.MeshBasicMaterial({ map: win, transparent: true })); s.scene.add(winM);
    const an = hudWord(s, 'An entire', 520, 380, 90, { font: '700 {px}px Fredoka', color: '#ffffff', side: '#0e5a55', depth: 0.12 });
    const lib = hudWord(s, 'LIBRARY', 510, 540, 160, { font: '700 {px}px Fredoka', color: '#4ff0e0', side: '#0b6a62', back: '#02201e', depth: 0.45, layers: 16 });
    const cf = hudWord(s, 'content-format', 380, 700, 64, { font: '700 {px}px "JetBrains Mono"', color: '#ffffff', side: '#0e5a55', depth: 0.08 });
    const sf = hudWord(s, 'skill files,', 640, 790, 96, { font: '700 {px}px Fredoka', color: '#ffc63a', side: '#8a4a05', depth: 0.18 });
    const ins = hudWord(s, 'inside Morphy.', 510, 1340, 78, { font: '800 {px}px Syne', color: '#ffffff', side: '#3a3a55', depth: 0.18 });
    s.post = { bloom: 0.55, thr: 0.7, focus: 7, aperture: 2 };
    s.update = (t) => {
      const A = T('An'), Lb = T('library'), In = T('inside'), Mo = T('Morphy.');
      const fly = easeInOut(seg(t, In - 0.1, Mo + 0.25));
      const camX = lerp(-7, 5.5, easeInOut(seg(t, A - 0.1, In))) * (1 - fly);
      files.forEach((f, i) => {
        const h = f.userData.home, t0 = A - 0.1 + i * 0.16, k = easeOutBack(seg(t, t0, t0 + 0.35));
        const slot = new THREE.Vector3(-1.64 + (i % 3) * 1.56, 2.95 - Math.floor(i / 3) * 1.04, -1.95);
        const ki = easeInOut(seg(t, In - 0.1 + i * 0.03, Mo + 0.2 + i * 0.03));
        f.position.lerpVectors(new THREE.Vector3(h.x, h.y + Math.sin(t * 2 + i) * 0.1, h.z), slot, ki);
        f.scale.setScalar(Math.max(0.001, k * lerp(0.85, 0.42, ki))); f.rotation.y = lerp(Math.sin(t * 1.5 + i) * 0.5 + t * 0.6, 0, ki); f.rotation.x = 0;
        f.userData.label.visible = ki < 0.5;
      });
      winM.position.set(0, 2.1, -2.0); winM.visible = t > In - 0.15; winM.scale.setScalar(Math.max(0.001, easeOutBack(seg(t, In - 0.15, In + 0.2))));
      trails.forEach((p, i) => { p.userData.update(t + i * 0.3); p.visible = fly < 0.5; });
      cl.position.set(camX + 0.9, 0, 0.6); cl.userData.pose({ step: Math.floor(t * 9) % 2, eyes: 'happy' }); cl.visible = fly < 0.6;
      pl.position.set(camX, 2.5, 0);
      const P = [camX, lerp(2.0, 2.1, fly), lerp(6.6, 9.6, fly)], L = [camX * 0.9 + 0.6 * (1 - fly), lerp(1.7, 2.05, fly), -2.5];
      K.cam(s, P, L, (1 - fly) * Math.sin(t * 1.2) * 0.04);
      kin(an, t, { at: A - 0.04, from: { p: [0, 1, 0] }, out: In - 0.12 });
      kin(lib, t, { at: Lb - 0.05, from: { p: [0, 0, 4], s: 2.2 }, ease: easeOut, out: In - 0.1 });
      kin(cf, t, { at: T('content-format') - 0.04, from: { p: [-2, 0, 0] }, out: In - 0.08 });
      kin(sf, t, { at: T('skill', 2) - 0.04, from: { p: [2, 0, 0], r: [0, -1, 0] }, out: In - 0.06 });
      kin(ins, t, { at: In - 0.04, from: { p: [0, -1, 0], s: 0.4 } });
    };
  }

  // ===================================================================== 11. COSMOS: "No paywalls. No subscriptions. No strings attached." (B&W)
  {
    const s = sets.cosmos = K.newSet(E, { fov: 40, bg: 0x000000 });
    K.stars(s, { n: 2500, r: 80, size: 0.14 });
    const sunT = K.sunTex(5); sunT.wrapS = sunT.wrapT = THREE.RepeatWrapping; sunT.repeat.set(8, 4); const sun = new THREE.Mesh(new THREE.SphereGeometry(30, 96, 64), new THREE.MeshBasicMaterial({ map: sunT, color: 0xd8c8d8 })); sun.position.set(0, -31.2, -12); s.scene.add(sun);
    const wall = K.dotPlane(s, { nx: 56, ny: 40, w: 3.4, h: 2.4, size: 0.04 }); wall.position.set(0, 0.2, -5);
    const moon = K.dotSphere(s, { r: 2.6, n: 12000, size: 0.045 }); moon.position.set(0, 0.2, -30);
    const wave = K.dotPlane(s, { nx: 90, ny: 40, w: 9, h: 6, size: 0.05 }); wave.position.set(0, -1.4, -60); wave.rotation.x = -1.2;
    const strings = [0, 1, 2, 3, 4].map(i => { const p = K.dotPlane(s, { nx: 1, ny: 40, w: 0, h: 5, size: 0.05 }); p.position.set(-2 + i, 2.2, -58); return p; });
    s.update = (t) => {
      const N1 = T('No'), N2 = T('No', 2), N3 = T('No', 3), Pw = T('paywalls.'), Sb = T('subscriptions.'), At = T('attached.');
      sun.rotation.y = t * 0.02;
      K.scatter(wall, seg(t, Pw + 0.25, Pw + 0.75), 6); wall.visible = t < N2;
      moon.rotation.y = t * 0.3; K.scatter(moon, seg(t, Sb + 0.45, Sb + 1.0), 7, 3); moon.visible = t >= N2 - 0.1 && t < N3;
      { const pos = wave.geometry.attributes.position, b = wave.userData.base; for (let i = 0; i < pos.count; i++) pos.setZ(i, Math.sin(b[i * 3] * 1.2 + t * 3) * 0.35 + Math.cos(b[i * 3 + 1] * 1.6 + t * 2) * 0.25); pos.needsUpdate = true; }
      strings.forEach((p, i) => { K.scatter(p, seg(t, At + i * 0.05, At + 0.4 + i * 0.05), 2, i); });
      if (t < N2 - 0.08) K.cam(s, [lerp(-0.4, 0.4, seg(t, N1 - 0.2, N2)), lerp(-0.4, -0.2, seg(t, N1, N2)), lerp(5.8, 4.8, seg(t, N1 - 0.2, N2))], [0, 0.0, -6], -0.12);
      else if (t < N3 - 0.08) K.cam(s, [0, 0.2, lerp(-17, -19.5, seg(t, N2, N3))], [0, 0.2, -30], 0);
      else K.cam(s, [0, 1.0 + Math.sin(t) * 0.1, lerp(-50, -52, seg(t, N3, N3 + 1.3))], [0, 0.4, -60], 0.04);
    };
  }

  // ===================================================================== 12. PLAYER: "It's one of them." — the film inside a player, scenes flick past
  {
    const s = sets.player = K.newSet(E, { fov: 36, bg: 0x050505 });
    const mat = E.screenMat(null);
    const card = new THREE.Mesh(new THREE.PlaneGeometry(LAND ? 7.4 : 4.5, LAND ? 4.16 : 8), mat); s.scene.add(card);
    const frame = new THREE.Mesh(new THREE.PlaneGeometry(LAND ? 7.52 : 4.62, LAND ? 4.28 : 8.12), new THREE.MeshBasicMaterial({ color: 0x2a2a2a })); frame.position.z = -0.01; card.add(frame);
    s.update = (t) => {
      const I = T("It's"), CYC = [['red', 5.4], ['stage', 12.5], ['clouds', 20.2], ['library', 22.6], ['cosmos', 26.3], ['face', 16.2], ['dollar', 13.6]];
      const i = Math.min(CYC.length - 1, Math.floor(Math.max(0, t - (I - 0.25)) / 0.16));
      mat.uniforms.t.value = E.snapshot(CYC[i][0], CYC[i][1] + (t - I) * 0.5, E.rt('play'));
      card.rotation.set(lerp(0.25, -0.05, easeOut(seg(t, I - 0.4, T('them.')))), lerp(-0.45, 0.12, easeOut(seg(t, I - 0.4, T('them.') + 0.3))), lerp(0.06, 0, seg(t, I - 0.4, I)));
      K.cam(s, [0, LAND ? -0.3 : 0, lerp(LAND ? 12.5 : 17, LAND ? 10.8 : 14.5, easeOut(seg(t, I - 0.4, T('them.') + 0.4)))], [0, LAND ? -0.3 : 0.2, 0], 0);
    };
  }

  // ===================================================================== 13. LOGO: "It's called cutscene.md."
  {
    const s = sets.logo = K.newSet(E, { fov: 42, bg: 0x02060a, fog: [0x02060a, 10, 40] });
    K.gridFloor(s, { color: 0x1fd6c6, cell: 1, fade: 16 });
    const PAN = [['#d81f26', '#b8191f', '#e8343f'], ['#f2efe8', '#dcd8cf', '#ffffff'], ['#1fbfb0', '#17a497', '#2ad6c6'], ['#ffc63a', '#e8ad22', '#ffd65a']];
    const panels = PAN.map((pal, i) => { const m = new THREE.Mesh(new THREE.BoxGeometry(2.2, 5, 0.3), new THREE.MeshBasicMaterial({ map: K.pixTex(22, 50, pal, i + 3), color: 0x8a8a8a })); m.position.set(-3.6 + i * 2.4, 2.5, -6); s.scene.add(m); return m; });
    const det = S.character(E, 'det', { h: 2.3 }); det.position.set(2.6, 0, 0.8); s.scene.add(det);
    const its = hudWord(s, "It's called", 510, 640, 90, { font: '700 {px}px Fredoka', color: '#ffffff', side: '#0e5a55', depth: 0.12 });
    const name = W3('cutscene.md', { font: '700 {px}px Fredoka', px: 200, color: '#ffffff', side: '#1fbfb0', back: '#032a26', depth: 0.45, layers: 16, letters: true });
    { const d = 8, k = 112 * KS / (name.userData.h * pxPerUnit(s, d)); name.scale.setScalar(k); at(s, name, 510, 820, d); s.words.push(name); }
    const conf = new THREE.Group(); for (let i = 0; i < 60; i++) { const b = new THREE.Mesh(new THREE.PlaneGeometry(0.12, 0.12), new THREE.MeshBasicMaterial({ color: ['#e8343f', '#ffc63a', '#4ff0e0', '#ffffff'][i % 4], side: THREE.DoubleSide })); conf.add(b); } s.scene.add(conf);
    s.post = { bloom: 0.45, thr: 0.8 };
    s.update = (t) => {
      const I = T("It's", 2), Cl = T('called'), Nm = T('cutscene.md.');
      panels.forEach((p, i) => { const t0 = I - 0.1 + i * 0.1, k = easeOutBack(seg(t, t0, t0 + 0.35)); p.position.y = lerp(-3, 2.5, k); p.rotation.y = Math.sin(t * 0.8 + i) * 0.1; });
      kin(its, t, { at: I - 0.04, from: { p: [0, 1, 0] } });
      letters(name, t, Nm - 0.06, { stagger: 0.035, drop: 2.2, spin: 1.0 });
      name.rotation.y = Math.sin(t * 1.5) * 0.08;
      det.userData.pose({ armR: t > Nm ? 2.3 : 0.4, armL: 0.2, eyes: t > Nm ? 'happy' : 'open', mouth: 'grin' }); det.position.x = lerp(5, 2.6, easeOut(seg(t, I - 0.2, I + 0.3)));
      conf.children.forEach((b, i) => { const k = seg(t, Nm + 0.15, Nm + 1.8); b.visible = k > 0 && k < 1; const a = hash(i * 1.9) * 6.28; b.position.set(Math.cos(a) * (0.5 + 4 * easeOut(k)) * hash(i * 3), 3.2 + Math.sin(a) * 2.5 * easeOut(k) - k * k * 4, -1 + hash(i) * 2); b.rotation.set(t * 5 + i, t * 3 + i, 0); });
      const sh = shakeOf(t, [Nm + 0.2], 0.1);
      K.cam(s, [Math.sin(t * 0.6) * 0.8 + sh[0], 1.3 + sh[1], lerp(9, 7.6, seg(t, I - 0.2, Nm + 1.2))], [0, 2.0, -1], -0.03);
    };
  }

  // ===================================================================== 14. CTA: the stage, the detective pats Claude; the comment panel (overlay)
  {
    const s = sets.cta = K.newSet(E, { fov: 40, bg: 0x1a0806 });
    K.stage(s, { spot: [0, 0, 0], seed: 9 });
    [[0xff2a2a, -2.6, 4.2], [0x2ae0d0, 0.6, 3.9], [0xff9b73, 3.2, 4.4]].forEach(([c, x, y]) => { const g = K.glowSign(c, 1.5, 0.9); g.position.set(x, y, -5.9); s.scene.add(g); });
    const det = S.character(E, 'det', { h: 2.5 }); det.position.set(-0.75, 0, 0.6); s.scene.add(det);
    const cl = S.character(E, 'claude', { h: 1.25 }); cl.position.set(0.75, 0, 0.9); s.scene.add(cl);
    s.post = { focus: 6.5, aperture: 5, bloom: 0.45 };
    s.update = (t) => {
      const C = T('Comment'), F = T('free.', 2);
      const pat = Math.sin(t * 7) > 0;
      det.userData.pose({ armR: 1.25 + (pat ? 0.15 : 0), armL: t > F ? 2.6 : 0.2, eyes: 'happy', mouth: E.film.env[Math.floor(t * 30)] > 0.3 ? 'open' : 'grin' });
      cl.userData.pose({ eyes: 'happy', arms: t > F ? Math.sin(t * 10) : 0 }); cl.scale.y = pat ? 0.95 : 1;
      const ox = LAND ? 1.5 : 0; K.cam(s, [lerp(0.4, 0.1, seg(t, C - 0.2, END)) + ox, 1.2, lerp(6.4, 5.4, easeOut(seg(t, C - 0.2, END))) + (LAND ? 1.2 : 0)], [ox, 0.95, 0], 0);
    };
  }

  // ===================================================================== cuts
  const cuts = [
    [0, 'feed'], [T('and') - 0.02, 'orange'], [T('Wrong') - 0.05, 'red'], [T('Because') - 0.15, 'dark'], [T('And') - 0.1, 'stage'],
    [T('$30.', 2) - 0.12, 'dollar'], [T("I'm") - 0.12, 'face'], [T('built') - 0.3, 'desk'], [T('So') - 0.12, 'clouds'], [T('An') - 0.1, 'library'],
    [T('No') - 0.08, 'cosmos'], [T('And', 2) - 0.1, 'desk'], [T("It's") - 0.4, 'player'], [T("It's", 2) - 0.25, 'logo'], [T('Comment') - 0.12, 'cta'],
  ];

  // ===================================================================== the 2D layer
  const icons = { clap: S.drawGlyph('spark', '#ffffff'), star: S.drawGlyph('spark', YEL), heart: S.drawGlyph('heart', '#ff4436') };
  const WIPES = [[T('Because') - 0.15, 'red'], [T('And') - 0.1, 'red'], [T('built') - 0.3, 'red'], [T('And', 2) - 0.1, 'red'], [T("It's", 2) - 0.25, 'teal'], [T('Comment') - 0.12, 'teal']];
  function wipe(g, t) {
    for (const [c, pal] of WIPES) {
      const k = (t - (c - 0.16)) / 0.34; if (k < 0 || k > 1) continue;
      const P = pal === 'teal' ? ['#1fbfb0', '#4ff0e0', '#f6efe4', '#06201e', '#ffc63a', '#0b0909'] : RED, cs = 120;
      for (let y = 0; y < E.H; y += cs) for (let x = 0; x < E.W; x += cs) {
        const h = hash(x * 0.37 + y * 1.13 + c), a = h * 0.4, b = 0.55 + h * 0.4; if (k < a || k > b) continue;
        g.fillStyle = P[Math.floor(hash(x + y * 3 + c * 7) * P.length)]; g.fillRect(x, y, cs, cs);
      }
    }
  }
  function subtitles(g, t) {
    const B = T('Because'), F0 = T('For');
    if (t < B - 0.15 || t > T('And') - 0.1) return;
    const SANS = 'Inter, sans-serif';
    if (t < F0 - 0.06) {
      const L = [['Because', 'I', 'gave'], ['the', 'whole', 'skill', 'away.']], size = 96;
      g.font = `600 ${size}px ${SANS}`; g.textBaseline = 'middle';
      let n = 0; L.forEach((line, li) => {
        const ws = line.map(w => g.measureText(w).width), gap = 26, tot = ws.reduce((a, b) => a + b, 0) + gap * (ws.length - 1); let x = 510 - tot / 2; const y = 820 + li * 130;
        line.forEach((w, wi) => {
          const idx = li === 0 ? (wi === 0 ? 1 : wi === 1 ? 2 : 3) : (wi === 0 ? 4 : wi === 1 ? 5 : wi === 2 ? 6 : 7); n++;
          const tw = w === 'Because' ? T('Because') : w === 'I' ? T('I') : w === 'gave' ? T('gave') : w === 'the' ? T('the', 3) : w === 'whole' ? T('whole') : w === 'skill' ? T('skill') : T('away.');
          const k = clamp((t - tw + 0.05) / 0.16), cur = t >= tw - 0.05 && (idx === 7 || t < [0, T('I'), T('gave'), T('the', 3), T('whole'), T('skill'), T('away.'), 99][idx] - 0.05);
          if (k > 0) {
            g.save(); g.globalAlpha = k; if (k < 1) g.filter = `blur(${(1 - k) * 12}px)`;
            if (cur) { g.fillStyle = '#e01f26'; g.fillRect(x - 12, y - size * 0.62, ws[wi] + 24, size * 1.2); }
            g.fillStyle = cur ? '#ffffff' : '#f3ece6'; g.fillText(w, x + (1 - k) * 40, y); g.restore();
            E.O.markBox('sub', w, x, y - size * 0.62, x + ws[wi], y + size * 0.6);
          }
          x += ws[wi] + gap;
        });
      });
      // pixel icons
      const ic = [[T('gave'), icons.heart, 860, 640], [T('skill'), icons.star, 160, 1060], [T('away.'), icons.clap, 880, 1080]];
      for (const [t0, cv, x, y] of ic) { const k = easeOutBack(clamp((t - t0) / 0.2), 3); if (k > 0) S.blit(g, cv, x, y + Math.sin(t * 6 + x) * 6, 9 * k); }
    } else {
      const k1 = easeOut(clamp((t - F0 + 0.06) / 0.18)), k2 = easeOutBack(clamp((t - T('free.') + 0.04) / 0.22), 2.4);
      g.textBaseline = 'middle'; g.textAlign = 'center';
      g.font = '500 96px Inter, sans-serif'; g.fillStyle = `rgba(200,195,192,${k1})`; g.fillText('For', 510, 820 - (1 - k1) * 40);
      E.O.markBox('sub', 'For', 430, 770, 590, 870);
      if (k2 > 0) {
        g.save(); g.translate(510, 960); g.scale(k2, k2); g.font = '800 170px Inter, sans-serif'; g.fillStyle = '#ffffff'; g.fillText('free.', 0, 0);
        const w = g.measureText('free.').width; g.fillStyle = '#ff2a2a';
        for (const sx of [-1, 1]) for (const sy of [-1, 1]) { const x0 = sx * (w / 2 + 40), y0 = sy * 95; g.fillRect(x0 - 7, y0 - 7, 14, 14); g.fillRect(x0 - 7 - sx * 0, y0 - sy * 28 - 7, 14, 14); g.fillRect(x0 - sx * 28 - 7, y0 - 7, 14, 14); }
        g.restore(); E.O.markBox('sub', 'free.', 510 - 240, 960 - 110, 510 + 240, 960 + 110);
      }
      // red pixels rising
      for (let i = 0; i < 60; i++) { const k = ((t - F0) * (0.5 + hash(i) * 0.8) + hash(i * 3)) % 1; if (t < F0) break; g.fillStyle = `rgba(255,${40 + hash(i * 5) * 60},40,${1 - k})`; const sz = 8 + hash(i * 7) * 10; g.fillRect(100 + hash(i * 2.2) * 880, 1880 - k * 900, sz, sz); }
      g.textAlign = 'left';
    }
  }
  function cosmosText(g, t) {
    const N1 = T('No'), N2 = T('No', 2), N3 = T('No', 3), END3 = T('And', 2) - 0.1;
    if (t < N1 - 0.08 || t > END3) return;
    const beat = t < N2 - 0.08 ? ['No paywalls.', N1, T('paywalls.') + 0.5, 600] : t < N3 - 0.08 ? ['No subscriptions.', N2, T('subscriptions.') + 0.7, 930] : ['No strings attached.', N3, T('attached.') + 0.4, 820];
    const [txt, t0, t1, y] = beat, n = Math.round(clamp((t - t0 + 0.05) / (t1 - t0)) * txt.length), str = txt.slice(0, n);
    g.font = '400 76px Inter, sans-serif'; g.textBaseline = 'middle'; g.fillStyle = '#ffffff';
    const arrow = txt.startsWith('No strings');
    const w = g.measureText(txt).width, x0 = 510 - w / 2 + (arrow ? 60 : 0);
    if (arrow) { g.strokeStyle = '#fff'; g.lineWidth = 9; g.lineCap = 'round'; const ax = x0 - 110; g.beginPath(); g.moveTo(ax, y); g.lineTo(ax + 80, y); g.moveTo(ax + 50, y - 30); g.lineTo(ax + 82, y); g.lineTo(ax + 50, y + 30); g.stroke(); E.O.markBox('sub', '→', ax - 5, y - 32, ax + 85, y + 32); }
    // glitch smear right after a beat starts
    const gl = t - t0 < 0.1;
    if (gl) { g.save(); g.globalAlpha = 0.5; for (let k = 1; k < 6; k++) g.fillText(str, x0 + k * 14, y); g.restore(); }
    g.fillText(str, x0, y); const sw = g.measureText(str).width;
    if (Math.floor(t * 4) % 2 === 0 || n < txt.length) g.fillRect(x0 + sw + 6, y - 40, 4, 80);
    if (str) E.O.markBox('sub', txt, x0, y - 44, x0 + w, y + 44);
  }
  function player(g, t) {
    const I = T("It's"), Th = T('them.');
    if (t < I - 0.4 || t > T("It's", 2) - 0.25) return;
    g.font = '800 72px Syne, sans-serif'; g.fillStyle = '#ffffff'; g.textBaseline = 'alphabetic'; g.textAlign = 'center';
    const ws = ["It's", 'one', 'of', 'them.'], tt = [I, T('one'), T('of', 2), Th];
    const shown = ws.filter((w, i) => t >= tt[i] - 0.05).join(' ');
    const py = LAND ? 440 : 360; g.fillText(shown, 510, py); if (shown) { const w = g.measureText(shown).width; E.O.markBox('sub', shown, 510 - w / 2, py - 70, 510 + w / 2, py + 20); }
    // progress bar
    const k = seg(t, I - 0.4, T("It's", 2)); g.fillStyle = 'rgba(255,255,255,0.25)'; g.fillRect(140, 1440, 760, 8); g.fillStyle = '#e01f26'; g.fillRect(140, 1440, 760 * (0.2 + 0.8 * k), 8); g.fillStyle = '#fff'; g.beginPath(); g.arc(140 + 760 * (0.2 + 0.8 * k), 1444, 14, 0, 7); g.fill();
    g.textAlign = 'left';
  }
  function feedHUD(g, t) {
    if (t > T('and') - 0.02) return;
    // a big "view count" chip climbing at the top
    const k = easeOutBack(clamp((t - 0.35) / 0.25)); if (k <= 0) return;
    const v = (1.2 + Math.pow(t, 2.4) * 2.4).toFixed(1) + 'M views';
    g.save(); g.translate(510, 380); g.scale(k, k); g.font = '700 46px Inter, sans-serif'; const w = g.measureText('▲ ' + v).width + 56;
    g.fillStyle = '#e8343f'; E.O.rr(g, -w / 2, -38, w, 76, 38); g.fill(); g.fillStyle = '#fff'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText('▲ ' + v, 0, 2); g.restore();
    E.O.markBox('chip', v, 510 - w / 2 * k, 380 - 38 * k, 510 + w / 2 * k, 380 + 38 * k);
  }
  function redHUD(g, t) {
    const Q = T('question.'); if (t < Q + 0.1 || t > T('Because') - 0.15) return;
    const txt = 'QUESTION: INVALID', n = Math.round(clamp((t - Q - 0.1) / 0.4) * txt.length);
    g.font = '400 34px Silkscreen'; g.fillStyle = '#ffd0c8'; g.textBaseline = 'middle'; g.fillText(txt.slice(0, n), 300, 960); E.O.markBox('chip', txt, 300, 940, 300 + g.measureText(txt).width, 980);
  }
  function cta(g, t) {
    const C = T('Comment'); if (t < C - 0.12) return;
    const CU = T('CUTSCENE,'), A = T('and', 4), F = T('Completely');
    const pop = (t0, d = 0.25) => easeOutBack(clamp((t - t0) / d));
    // dark scrim behind the panel
    g.fillStyle = 'rgba(8,4,4,0.55)'; E.O.rr(g, 70, 300, 880, 760, 40); g.fill();
    g.textBaseline = 'alphabetic'; g.textAlign = 'center';
    let k = pop(C - 0.1); g.font = '700 28px "JetBrains Mono"'; g.fillStyle = '#4ff0e0'; g.globalAlpha = clamp(k); g.fillText('FREE CONTENT FORMAT · CUTSCENE.MD', 510, 380); E.O.markBox('cta', 'FREE', 190, 352, 830, 390); g.globalAlpha = 1;
    k = pop(C - 0.05); if (k > 0) { g.save(); g.translate(510, 500); g.scale(k, k); g.font = '800 120px Inter, sans-serif'; g.fillStyle = '#ffffff'; g.fillText('Comment', 0, 0); g.restore(); E.O.markBox('cta', 'Comment', 270, 410, 750, 510); }
    k = pop(CU - 0.06, 0.3); if (k > 0) { g.save(); g.translate(510, 670); g.scale(k, k); g.font = '900 136px Inter, sans-serif'; g.fillStyle = '#4ff0e0'; g.shadowColor = 'rgba(79,240,224,0.6)'; g.shadowBlur = 40; g.fillText('CUTSCENE', 0, 0); g.restore(); const w = 760; E.O.markBox('cta', 'CUTSCENE', 510 - w / 2, 570, 510 + w / 2, 680); }
    k = pop(A - 0.05); if (k > 0) {
      g.globalAlpha = clamp(k); g.font = '500 44px Inter, sans-serif'; g.fillStyle = '#e8e3dd'; g.fillText("I'll send you the .md file", 510, 760); E.O.markBox('cta', 'send', 250, 725, 770, 770);
      g.fillStyle = 'rgba(20,30,30,0.9)'; g.strokeStyle = '#4ff0e0'; g.lineWidth = 3; E.O.rr(g, 180, 805, 660, 100, 22); g.fill(); g.stroke();
      const typed = 'CUTSCENE'.slice(0, Math.round(clamp((t - A) / 0.6) * 8)); g.textAlign = 'left'; g.font = '700 54px Inter, sans-serif'; g.fillStyle = '#fff'; g.fillText(typed, 220, 875); const tw = g.measureText(typed).width; if (Math.floor(t * 3) % 2 === 0) g.fillRect(226 + tw, 830, 4, 56);
      g.font = '600 26px "JetBrains Mono"'; g.fillStyle = '#4ff0e0'; g.fillText('cutscene.md = free', 186, 950); E.O.markBox('cta', 'box', 180, 805, 840, 960); g.globalAlpha = 1; g.textAlign = 'center';
    }
    k = pop(F - 0.05, 0.3); if (k > 0) { g.save(); g.translate(760, 1010); g.rotate(-0.12); g.scale(k, k); g.fillStyle = '#e01f26'; E.O.rr(g, -150, -50, 300, 100, 18); g.fill(); g.lineWidth = 6; g.strokeStyle = '#fff'; g.stroke(); g.font = '900 64px Inter, sans-serif'; g.fillStyle = '#fff'; g.textBaseline = 'middle'; g.fillText('FREE', 0, 4); g.restore(); E.O.markBox('cta', 'FREE', 600, 950, 920, 1070); }
    g.textAlign = 'left';
  }
  function overlay(g, t, name) {
    const tf = (cx) => g.setTransform(KS, 0, 0, KS, cx - 510 * KS, 540 - 880 * KS);
    if (LAND) tf(960);
    feedHUD(g, t); redHUD(g, t); subtitles(g, t); cosmosText(g, t); player(g, t);
    // landscape: the comment panel sits on the right, the characters on the left (like the reference)
    if (LAND) { tf(1390); E.O.setMap((x, y) => [1390 + (x - 510) * KS, 540 + (y - 880) * KS]); }
    cta(g, t);
    E.O.setMap(MAP2); g.setTransform(1, 0, 0, 1, 0, 0);
    wipe(g, t);
  }

  // ===================================================================== effects (flashes, pixel dissolves, zooms, shakes, glitches)
  function effects(t, name) {
    const fx = {};
    const A0 = T('and');
    // frame 0: a negative, then colour snaps in
    if (t < 0.3) { fx.inv = 1; fx.sat = 0.4; } else if (t < 0.42) fx.flash = 0.6 * (1 - seg(t, 0.3, 0.42));
    // into the tile → pixel dissolve into orange
    if (t > A0 - 0.22 && t < A0) { fx.zoom = 0.25 * seg(t, A0 - 0.22, A0); fx.pix = 1 + 50 * easeIn(seg(t, A0 - 0.12, A0)); }
    if (t >= A0 && t < A0 + 0.16) fx.pix = 1 + 50 * (1 - easeOut(seg(t, A0, A0 + 0.16)));
    // WRONG: a red-white hit, RGB split, shake
    const Wr = T('Wrong'); if (t >= Wr - 0.05 && t < Wr + 0.3) { fx.flash = 0.7 * Math.exp(-(t - Wr + 0.05) * 14); fx.flashCol = [1, 0.75, 0.7]; fx.rgb = 14 * Math.exp(-(t - Wr) * 10); }
    const Q = T('question.'); if (t >= Q && t < Q + 0.2) fx.rgb = 10 * (1 - seg(t, Q, Q + 0.2));
    // stage → dollar: whip
    const D = T('$30.', 2) - 0.12; if (Math.abs(t - D) < 0.08) { fx.whip = [0.06 * (1 - Math.abs(t - D) / 0.08), 0]; fx.flash = 0.35; }
    if (t > T('took') + 0.1 && t < T('took') + 0.5) fx.whip = [0.05 * Math.sin(seg(t, T('took') + 0.1, T('took') + 0.5) * Math.PI), 0];
    // dollar fly-through
    const F2 = T('For', 2); if (t > F2 - 0.1 && t < F2 + 0.3) fx.zoom = 0.18 * Math.sin(seg(t, F2 - 0.1, F2 + 0.3) * Math.PI);
    // face: white flash in
    const I = T("I'm") - 0.12; if (t >= I && t < I + 0.15) fx.flash = 0.8 * (1 - seg(t, I, I + 0.15));
    // desk → clouds: a white frame and a zoom
    const So = T('So') - 0.12; if (t >= So - 0.06 && t < So + 0.2) { fx.flash = 0.6 * (1 - Math.abs(t - So) / 0.2); fx.zoom = 0.2 * (1 - seg(t, So, So + 0.2)); }
    // clouds → library: zoom blur into the light
    const An = T('An') - 0.1; if (t > An - 0.25 && t < An) { fx.zoom = 0.35 * seg(t, An - 0.25, An); fx.flash = 0.7 * seg(t, An - 0.15, An); }
    if (t >= An && t < An + 0.15) fx.flash = 0.6 * (1 - seg(t, An, An + 0.15));
    // library: "LIBRARY" punch
    const Lb = T('library'); if (t >= Lb && t < Lb + 0.2) fx.zoom = 0.08 * (1 - seg(t, Lb, Lb + 0.2));
    // cosmos: B&W, glitch at each beat
    if (name === 'cosmos') { fx.sat = -1; fx.post = { bloom: 0.2, grain: 0.05, vig: 0.3 }; for (const n of [T('No'), T('No', 2), T('No', 3)]) if (t >= n - 0.1 && t < n + 0.12) { fx.rgb = 18 * (1 - seg(t, n - 0.1, n + 0.12)); fx.inv = t < n - 0.03 ? 1 : 0; fx.scan = 0.6; } }
    // now?: slam
    const N = T('now?'); if (t >= N - 0.03 && t < N + 0.25) { fx.zoom = 0.12 * (1 - seg(t, N, N + 0.25)); fx.rgb = 8 * (1 - seg(t, N, N + 0.25)); }
    // player → logo: pixel dissolve (on top of the teal wipe)
    // logo: flash on the name
    const Nm = T('cutscene.md.'); if (t >= Nm + 0.15 && t < Nm + 0.35) fx.flash = 0.35 * (1 - seg(t, Nm + 0.15, Nm + 0.35));
    // CTA: a punch on the keyword
    const CU = T('CUTSCENE,'); if (t >= CU && t < CU + 0.2) fx.zoom = 0.06 * (1 - seg(t, CU, CU + 0.2));
    // overlay shake on slams
    let sx = 0, sy = 0; for (const s0 of [T('same'), Wr, Q, T('Casper,'), N, Nm]) if (t >= s0 && t < s0 + 0.3) { const a = 14 * Math.exp(-(t - s0) * 14); sx += Math.sin((t - s0) * 90) * a; sy += Math.cos((t - s0) * 70) * a; }
    fx.shake = [sx, sy];
    // fade out the very end
    fx.fade = 1 - seg(t, END - 0.35, END) * 0.85;
    return fx;
  }

  return { cuts, sets, overlay, effects };
}
```

### `example/cues.mjs`

```js
// Sound effects of this video: a hit, whoosh or glitch on every cut, slam and pop (the reference has one on every motion).
export default function (ev, film) {
  const T = (cap, n = 1) => { let k = 0; for (const w of film.words) if (w.cap === cap && ++k === n) return w.t0; throw new Error(`no word "${cap}" #${n}`); };
  // ---- hook: the screen snaps to colour, the counter, the letters crash into the grass ----
  ev('glitch', 0.28, -18);
  ev('riser', 0.3, -24, 0.8);
  ev('blocks', T('blowing') - 0.04, -16);
  ev('impact', T('up,') + 0.05, -15, 0.9);
  ev('whoosh', T('and') - 0.2, -16);
  ev('glitch', T('and') - 0.02, -20);
  // ---- orange: the jump, the stack, SAME slams ----
  ev('boing', T('and') + 0.05, -24);
  ev('pop', T("everyone's") - 0.04, -20);
  ev('swish', T('asking') - 0.05, -20);
  ev('pop_hi', T('the') - 0.03, -22);
  ev('slap', T('same') - 0.02, -14);
  ev('swoosh', T('thing.') - 0.05, -21);
  ev('whoosh', T("What's") - 0.15, -18);
  [0.12, 0.34, 0.56].forEach(d => ev('pop_hi2', T("What's") + d, -20));
  ev('impact', T('prompt?') - 0.02, -19, 0.6);
  // ---- WRONG ----
  ev('thud', T('Wrong') - 0.05, -10);
  ev('impact', T('Wrong') - 0.04, -12, 1.2);
  ev('stamp', T('question.') - 0.02, -14);
  ev('glitch', T('question.') + 0.1, -22);
  ev('beep', T('question.') + 0.15, -26);
  // ---- dark subtitles: the block wipe, a tick per word ----
  ev('blocks', T('Because') - 0.3, -18);
  for (const [w, n] of [['Because', 1], ['I', 1], ['gave', 1], ['the', 3], ['whole', 1], ['skill', 1], ['away.', 1]]) ev('click', T(w, n) - 0.03, -24);
  ev('ding', T('skill') + 0.05, -24);
  ev('impact', T('free.') - 0.02, -16, 1.0);
  ev('shimmer', T('free.') + 0.05, -22, 0.8);
  // ---- stage: hands, the grab, the drop, ka-ching ----
  ev('blocks', T('And') - 0.25, -18);
  ev('pop', T('people') + 0.05, -22); ev('pop_hi', T('people') + 0.15, -22);
  ev('whoosh', T('took'), -14);
  ev('clang', T('repackaged') + 0.18, -16);
  ev('thud', T('repackaged') + 0.22, -14);
  ev('swish', T('sold') - 0.05, -18);
  ev('kaching', T('$30.') - 0.02, -14);
  // ---- $30 fly-through ----
  ev('whoosh', T('$30.', 2) - 0.2, -13);
  ev('impact', T('$30.', 2) + 0.08, -13, 1.0);
  ev('riser', T('$30.', 2) + 0.3, -22, 0.8);
  ev('swoosh', T('For', 2) - 0.1, -17);
  ev('shimmer', T('text') - 0.05, -20, 1.0);
  // ---- face + desk ----
  ev('flick', T("I'm") - 0.12, -14);
  ev('slap', T('Casper,') - 0.02, -14);
  ev('blocks', T('built') - 0.45, -18);
  ev('typing', T('built') - 0.2, -22, 2.4);
  ev('impact', T('Morphy,') - 0.03, -17, 0.6);
  [0, 0.12].forEach(d => ev('pop', T('agentic') + d, -20));
  [0, 0.12].forEach(d => ev('pop_hi', T('super') + d, -20));
  // ---- clouds: the flash, the rush, the gold word ----
  ev('flick', T('So') - 0.14, -13);
  ev('plane', T('So') - 0.1, -20, 1.6);
  ev('impact', T('response.') - 0.03, -14, 1.2);
  ev('chime', T('response.') + 0.1, -18, 1.2);
  ev('riser', T('An') - 0.6, -18, 0.6);
  // ---- library: files pop in, they fly home ----
  ev('flick', T('An') - 0.1, -15);
  for (let i = 0; i < 9; i++) ev(i % 2 ? 'pop_hi' : 'pop', T('An') - 0.08 + i * 0.16, -23);
  ev('slap', T('library') - 0.02, -17);
  ev('whoosh', T('inside') - 0.1, -16);
  for (let i = 0; i < 9; i++) ev('clack', T('inside') + 0.25 + i * 0.05, -22);
  ev('ding', T('Morphy.') + 0.2, -19);
  // ---- cosmos: glitches, typing, dust ----
  for (const n of [1, 2, 3]) { ev('glitch', T('No', n) - 0.1, -15); ev('type', T('No', n), -22, 0.8); }
  ev('burst', T('paywalls.') + 0.25, -17);
  ev('burst', T('subscriptions.') + 0.45, -17);
  ev('flick', T('attached.') + 0.05, -18);
  // ---- the film in its own monitor, the player ----
  ev('blocks', T('And', 2) - 0.25, -18);
  ev('whoosh', T('watching'), -18);
  ev('impact', T('now?') - 0.03, -12, 1.0);
  for (let i = 0; i < 7; i++) ev('click', T("It's") - 0.25 + i * 0.16, -21);
  // ---- the name ----
  ev('blocks', T("It's", 2) - 0.3, -18);
  [0, 0.1, 0.2, 0.3].forEach(d => ev('thud', T("It's", 2) - 0.1 + d, -22));
  ev('slap', T('cutscene.md.') - 0.04, -15);
  ev('burst', T('cutscene.md.') + 0.15, -17);
  ev('chime', T('cutscene.md.') + 0.2, -17, 1.4);
  // ---- CTA ----
  ev('swoosh', T('Comment') - 0.1, -18);
  ev('impact', T('CUTSCENE,') - 0.04, -15, 0.8);
  ev('typing', T('and', 4), -22, 0.6);
  ev('stamp', T('Completely') - 0.03, -14);
  ev('chime', T('free.', 2), -18, 1.6);
}
```

