---
name: clay
description: 'Make a 35-55 s vertical (1080x1920, 30 fps) faceless voiceover explainer reel in the Terracotta UI look - warm paper with a 60 px grid and drifting colour fields, terracotta accent, serif-italic key words, word-by-word captions with a sliding box, black slash-command chips, a dark starfield act and a terracotta flood. Covers the whole pipeline (script, ElevenLabs voice with word timings, an HTML motion-graphics page rendered frame by frame in headless Chrome, sound effects and music, a -14 LUFS mix, mp4) plus the checks that keep it inside the Instagram/TikTok/YouTube safe zone. Use it when someone asks for a clay or clay.md reel, a Terracotta UI video, or a short motion-graphics explainer in this style.'
---

# clay.md: Terracotta UI explainer reels

You will make a **35-55 second vertical video (1080x1920, 30 fps, H.264 + AAC)**: one narrator (an ElevenLabs voice), no face, no footage. Everything you see is 2D HTML/CSS/SVG motion graphics: headlines that slot in word by word with one terracotta serif-italic key word, white UI panels, a white prompt bar that types, black slash-command chips, rubber stamps, a terracotta flood with a giant numeral, a dome that rises into a dark starfield act, and captions at the bottom where a terracotta box slides from word to word. **The edit follows the words**: every visual change lands on its spoken word, and when the voice says something changes, the video does it.

The page is rendered **one frame at a time** in headless Chrome from a time-pure `setT(t)` function, then muxed with a mix at -14 LUFS.

**This is what it should look like:** `reference.jpg` (next to this file, if you have it) is a contact sheet of 12 frames from a finished reel in this format. Open it first. If you only have this file, the look is fully specified in section 3. In words, the reference shows, left to right: (1) the hook: "Never *pay* again." with a serif-italic terracotta "pay", a white store card and a red NEVER AGAIN stamp at an angle, the caption "again." in a terracotta box; (2) the terracotta flood: a white grid on `#cf6a52` with a grid of white file cards; (3) a dark dome rising from the bottom over the paper; (4-6) the dark act: near-black with purple and rust glows, a faint white grid, warm twinkling stars, glass cards and red price stickers; (7) a black `/response` chip, a big two-line headline and the white prompt bar typing; (8) the warm (peach) act with a library of file cards; (9) the flood with struck-out rows; (10) a plain-text file card with a handwritten note and a price tag; (11) a card with a giant serif key word under it; (12) the CTA: a black COMMENT sticker, a terracotta KEYWORD sticker, the prompt bar typing the keyword and a "New message" toast. (The names and logo in it are its creator's own: use yours.)

Work in this order: **brief -> script -> estimated timings (free) -> scenes -> stills + safe-zone scan -> voice (credits) -> timeline -> stills + scan again -> full render -> sound -> mix -> encode -> verify -> deliver.** You lay the whole film out against estimated word times first, for free, and only spend voice credits once the stills look right. Scenes are written against the words (`T('s4', 'style guide')`), so they follow the real timings by themselves.

---

## 0. Rules of engagement (read first)

1. **Be gentle with the computer.** One headless browser, at idle (lowest) priority, no parallel workers (`cdp.mjs` lowers Chrome and all its child processes every 5 s and, on Windows, pins it to one logical processor). This format is 2D: a GPU makes it faster, software rendering still works (2-4x slower). Never "speed up" with more browsers.
2. **Never print secrets.** The key is `ELEVENLABS_API_KEY` (environment, or a `.env` file in the video folder). Scripts read it; you never echo, log or show it.
3. **Stills before film.** Never start a full render until the stills sheet looks right and the scan is clean. A full re-render for a one-frame fix is the expensive mistake.
4. **Background jobs die when your turn ends.** Render in foreground chunks (`--to 20`, then `--from 20`), each call well under your tool timeout. Rendering is resumable.
5. **One video per folder.** The format files (`engine.js`, `edit.css`, the scripts) are the look: don't restyle them per video. A video writes its own `script.mjs` and `scenes.js` (and any images it uses). If you find an engine bug, fix it once in the engine for all videos.
6. **Don't publish anything.** You deliver an mp4, a cover and a caption. Posting is the user's call.
7. If this file conflicts with what the user asks, the user wins. If something is missing (no key, no ffmpeg, no Chrome), say exactly what and offer the closest alternative. Never fake a result: if you could not voice it, render it or check it, say so.
8. **Show the script first.** Before spending credits, show the user the script and its estimated length (from `--estimate` + `build-timeline.mjs`), unless they told you to just go.
9. **Credits:** `eleven_multilingual_v2` costs about 1 credit per character. A 150-word script is about 850 characters; with a few retakes, budget ~1,500 per video. `gen-vo.mjs` prints the characters before it sends anything and never re-sends an unchanged take.

## 1. Requirements check (do this in the first minute)

- **Node 22+** (`node -v`): global `fetch` and `WebSocket` are used. No npm packages at all.
- **Chrome or Edge** (or Chromium). `cdp.mjs` finds the usual install paths; otherwise set `CHROME=<path to the executable>`.
- **ffmpeg + ffprobe** on PATH (`ffmpeg -version`), any recent build with libx264, libmp3lame and the native aac encoder. Or set `FFMPEG` / `FFPROBE` to their paths.
- **An ElevenLabs API key.** The free plan works for testing (about 10,000 credits a month, premade voices, API access); posting monetised videos needs a paid plan's commercial licence. If there is no key yet: build and check the whole film with `gen-vo.mjs --estimate` (silent, estimated timings) and ask for the key before the voice step.
- **Internet** once, for the fonts (`get-fonts.mjs`) and the voice. Rendering is offline.

## 2. Project layout

One folder per video, plain files, no build step, somewhere in the user's own space (for example `Documents/clay-reels/<slug>/`), never in a system or temp folder. Every command runs **from the video folder**.

```
reels/<slug>/
  .env                    ELEVENLABS_API_KEY=...   (or set it in the environment)
  -- the format (write once from this file; copy the whole set into each new video folder) --
  get-fonts.mjs           -> fonts/ (free fonts, downloaded once)
  lib.mjs  markup.mjs     shared helpers · the script markup
  gen-vo.mjs              script -> vo/<id>.mp3 + word timings (ElevenLabs, or --estimate)
  build-timeline.mjs      takes -> timeline.js (every word on the film clock) + vo.wav
  edit.html edit.css engine.js   the page: stage, components, captions, setT(t)
  cdp.mjs  render.mjs     headless Chrome: stills, safe-zone scan, frames
  make-sfx.mjs            -> sfx/*.wav (+ --bed: music/bed.wav), synthesised, free
  mix.mjs  encode.mjs     -> mix.wav (-14 LUFS) -> video.mp4 + cover.jpg + checks
  -- per video --
  script.mjs              the words: TAKES + GAP
  scenes.js               everything the film shows, on the voice's clock
  -- built --
  vo/ vo.wav timeline.js timeline.json stills/ frames/ cues.json mix.wav video.mp4 cover.jpg
```

| # | Step | Command | Time |
|---|---|---|---|
| 1 | Fonts, once per folder | `node get-fonts.mjs` | 10 s |
| 2 | Sound effects (+ a fallback bed) | `node make-sfx.mjs` (`--bed`) | 5 s |
| 3 | Script | write `script.mjs` (section 4) | - |
| 4 | Estimated timings, free | `node gen-vo.mjs --estimate` then `node build-timeline.mjs` | 2 s |
| 5 | Scenes | write `scenes.js` (section 8) | - |
| 6 | Stills | `node render.mjs --stills --safe` (`--at 0,3.2,8` for exact times) -> `stills/sheet-safe.jpg`; then without `--safe` -> `stills/sheet.jpg`. **Look at both.** | 10 s |
| 7 | Scan | `node render.mjs --scan`: every frame; must say `all text inside` and `EMPTY: none` | 1 min |
| 8 | Voice (credits) | `node gen-vo.mjs` then `node build-timeline.mjs` (prints the length and wpm) | 1 min |
| 9 | Stills + scan again | steps 6-7 on the real timings | 1 min |
| 10 | Full render | `node render.mjs --full --to 20`, then `--from 20` until it says `ALL FRAMES` | 0.1-0.3 s/frame |
| 11 | Sound | music bed in `music/` (section 10), then `node mix.mjs` | 10 s |
| 12 | Encode + checks | `node encode.mjs` -> must say `ENCODE OK` | 30 s |

**What to rerun.** Script changed: `gen-vo` (only the changed takes are re-sent), `build-timeline`, stills, scan, **delete `frames/`**, full render, mix, encode. Visuals changed: stills, scan, **delete `frames/`** (resume skips frames that exist, so stale frames survive otherwise), full render, encode. Only sound changed: `render.mjs --cues`, mix, encode (the frames stay).

Quick end-to-end test once the voice exists (the mix refuses an estimated, silent voice): `render.mjs --full --to 3`, `mix.mjs`, `encode.mjs --to 3 --out test.mp4`, then delete `frames/`.

### Setup files

`get-fonts.mjs` downloads the free fonts (Inter Tight, Instrument Serif, Bodoni Moda, Inconsolata, Kalam) as woff2 into `fonts/` and writes `fonts/fonts.css`, including **Clay Serif**: Instrument Serif italic scaled to 84 %, which matches the width of the original key-word face (Bodoni MT Condensed italic, a Windows/Office font; when it is installed it is used first).

```js
// get-fonts.mjs: download the free fonts once into ./fonts, so rendering never needs the network.
//   node get-fonts.mjs  ->  fonts/*.woff2 + fonts/fonts.css
import fs from 'node:fs';
const FAMILIES = ['Inter+Tight:ital,wght@0,400..900;1,400..900', 'Instrument+Serif:ital@0;1', 'Bodoni+Moda:ital,opsz,wght@0,6..96,400..900;1,6..96,400..900', 'Inconsolata:wght@400..900', 'Kalam:wght@400;700'];
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';   // makes Google serve woff2
const css = await (await fetch('https://fonts.googleapis.com/css2?' + FAMILIES.map(f => 'family=' + f).join('&') + '&display=block', { headers: { 'user-agent': UA } })).text();
fs.mkdirSync('fonts', { recursive: true });
const keep = []; let serif = null;
for (const block of css.split(/(?=\/\* )/)) {              // one @font-face per subset, each after a /* subset */ comment
  const sub = (block.match(/^\/\* ([\w-]+) \*\//) || [])[1];
  if (sub !== 'latin' && sub !== 'latin-ext') continue;
  const fam = block.match(/font-family: '([^']+)'/)[1], style = block.match(/font-style: (\w+)/)[1], src = block.match(/url\((https:[^)]+)\)/)[1];
  const file = `${fam.replace(/\s/g, '')}-${style}-${sub}-${keep.length}.woff2`;
  fs.writeFileSync('fonts/' + file, Buffer.from(await (await fetch(src)).arrayBuffer()));
  keep.push(block.replace(src, file));
  if (fam === 'Instrument Serif' && style === 'italic' && sub === 'latin') serif = file;
}
// 'Clay Serif': Instrument Serif italic scaled to the width of Bodoni MT Condensed italic (the original key-word face, Windows/Office only)
keep.push(`@font-face { font-family: 'Clay Serif'; font-style: italic; font-weight: 400; size-adjust: 84%; src: url(${serif}) format('woff2'); }\n`);
fs.writeFileSync('fonts/fonts.css', keep.join(''));
console.log('fonts:', keep.length, 'faces -> fonts/fonts.css');
```

`lib.mjs`:

```js
// lib.mjs: paths and helpers every step shares. Run every step from the video folder.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';

export const DIR = process.cwd();
export const V = (...p) => path.join(DIR, ...p);
export const FPS = 30, W = 1080, H = 1920;
export const FF = process.env.FFMPEG || 'ffmpeg', FP = process.env.FFPROBE || 'ffprobe';
export const arg = (k, d) => { const i = process.argv.indexOf(k); return i > 0 && i + 1 < process.argv.length ? process.argv[i + 1] : d; };
export const has = k => process.argv.includes(k);
export const low = () => { try { os.setPriority(os.constants.priority.PRIORITY_LOW); } catch { } };
export const need = (f, hint) => { if (!fs.existsSync(f)) { console.error(`missing ${f}${hint ? ' (' + hint + ')' : ''}`); process.exit(1); } };
export const loadScript = () => import(pathToFileURL(V('script.mjs')).href + '?t=' + Date.now());
export function apiKey() { // from the environment or ./.env; never print it
  if (process.env.ELEVENLABS_API_KEY) return process.env.ELEVENLABS_API_KEY.trim();
  const f = V('.env'); if (!fs.existsSync(f)) return null;
  const m = fs.readFileSync(f, 'utf8').match(/^\s*ELEVENLABS_API_KEY\s*=\s*["']?([^"'\r\n]+)/m); return m ? m[1].trim() : null;
}
export const ff = (args, opt = {}) => {
  const r = spawnSync(FF, ['-hide_banner', '-nostdin', '-y', '-v', 'error', '-threads', '2', ...args], { encoding: 'utf8', maxBuffer: 1 << 26, ...opt });
  if (r.status !== 0) { console.error('ffmpeg failed:', (r.stderr || r.error?.message || '').slice(-1500)); process.exit(1); } return r;
};
export function loudness(file) { // EBU R128 integrated loudness + true peak
  const r = spawnSync(FF, ['-hide_banner', '-nostats', '-i', file, '-af', 'ebur128=peak=true', '-f', 'null', '-'], { encoding: 'utf8', maxBuffer: 1 << 26 });
  const s = r.stderr.slice(r.stderr.lastIndexOf('Summary:'));
  return { I: +(s.match(/I:\s+(-?[\d.]+|-inf) LUFS/) || [])[1], TP: +(s.match(/Peak:\s+(-?[\d.]+|-inf) dBFS/) || [])[1] };
}
export function peak(file) { // sample peak in dBFS
  const r = spawnSync(FF, ['-hide_banner', '-nostats', '-i', file, '-af', 'volumedetect', '-f', 'null', '-'], { encoding: 'utf8' });
  return +(r.stderr.match(/max_volume: (-?[\d.]+) dB/) || [, 0])[1];
}
export const probe = file => JSON.parse(spawnSync(FP, ['-v', 'error', '-show_entries', 'format=duration,size:stream=codec_type,codec_name,profile,width,height,pix_fmt,r_frame_rate,sample_rate', '-of', 'json', file], { encoding: 'utf8' }).stdout);
```

---

## 3. The look (the exact spec)

### 3.1 Canvas, pacing
- **1080 x 1920, 30 fps**, 35-55 s, H.264 High yuv420p, AAC 48 kHz. The film is laid out in CSS pixels on that canvas.
- **~185 words per minute** (the voice in section 5). A scene change (new panel, cut, punch, act or dome) every **2-3.5 s**; something moves in every half second; a new headline every sentence or two.
- **Frame 0 is a finished picture** (it is the cover candidate and the first thing a scroller sees): the first panel is already in (entrance from t = -0.34) and the first headline word is up.
- The film **ends 1.25 s after the last word**, on the CTA. No outro, no fade to black.

### 3.2 Colour tokens

| Token | Hex | Use |
|---|---|---|
| Paper | `#f4f2ee` | the stage |
| Ink | `#141211` | headlines, caption past words, body text |
| **Accent (terracotta)** | **`#d4715a`** | key words, caption box, send button, numbered dots, segment bars, wipes, KEYWORD sticker |
| Deep accent | `#b9553e` | caption box in the warm act, small mono labels |
| Peach | `#f6c6b1` | chip text, tags and notes on dark |
| Chip black | `#1b1715` | slash-command chips, COMMENT sticker, caption box on the flood, dark pills |
| AI orange | `#d97757` | the AI's avatar and name (headline kind `'c'`) |
| Lilac | `#b9bff0` | the cool colour field, waveforms, accents |
| Green | `#22a559` | checks, "Ready", "+" rows |
| Red | `#e5484d` | stamps, strikes, price tags, "x" |
| Blue | `#1662d6` | rare second colour only |
| Terra | `#c0592f` | key words in the warm act |
| Muted | `#8f8a85` / `#9b948d` | mono labels, meta text |
| Night | `#141011` | the dark act |
| Flood | `#cf6a52` | the terracotta stage |
| Warm | `#f5e4cf` | the warm (amber) act |
| Field grey | `#f3f1ee` | prompt field; `#efebe6` placeholder bars; `#e2ddd8` empty segments |
| Caption future words | `#aba6a1` | not yet spoken |
| Dark-act key word | `#e48a6f` | the accent on night |

### 3.3 The stage (layers, bottom to top)
1. **Paper** `#f4f2ee`.
2. **Grid under**: 2 px lines every **60 px**, offset (-2, 18), `rgba(70,52,44,0.07)`.
3. **Four drifting colour fields**: radial gradients (`col a 0% -> col a/2 36% -> transparent 70%`), radius 560 / 500 / 600 / 440 px, centred near (230,560) / (840,930) / (360,1520) / (920,250), drifting on slow sines (amplitude 110-170 px, 0.16-0.23 rad/s). Colours: terracotta `rgb(232,140,112)` a .46, lilac `rgb(178,186,244)` a .42, peach `rgb(240,168,136)` a .36, terracotta `rgb(230,150,122)` a .32.
4. **Grid over**: the same grid in **white** `rgba(255,255,255,0.6)`, so it shows through the colour fields as white lines.
5. **Floating shapes**: 30 small outlined squares (30 %), circles (25 %), x's (20 %) and dots (25 %), 13-26 px, 2.6 px lines, `rgb(168,140,128)` at 15-34 % opacity, rising 6-20 px/s, rotating slowly, twinkling.
6. The scenes (panels), then a vignette + film grain (only in the dark and warm acts), then chips and headlines.
7. Above the zoomable world: the **dome** canvas, the **captions**, the **pixel-wipe** canvas.

### 3.4 Acts (the stage recolours with the story)

| Act | Stage | Fields | Headline / key word | Captions (past / future / box) | Use it for |
|---|---|---|---|---|---|
| base | paper `#f4f2ee`, both grids | terracotta, lilac, peach | ink / accent | ink / `#aba6a1` / accent | most of the film |
| `flood` | `#cf6a52`, white grids (.10 / .12), white shapes | peach + rust | white / white | white / white 60 % / `#1b1715` | the promise ("three steps"), a list of no's: hard cut in, giant numeral on rays |
| `moody` (the dark act) | `#141011`, grid white .05 / .04, **46 warm stars** (core `rgb(255,214,196)`, 3.2x halo `rgb(255,170,140)` at 18 %), drifting up 3-11 px/s, twinkling; vignette .75, grain .14 | rust `rgb(176,78,56)`, purple `rgb(104,44,128)` and `rgb(88,36,110)`, rust `rgb(150,64,50)` | white / `#e48a6f` | white / white 42 % / accent | the turn at ~55-65 % of the film: the problem, the twist, "but here's the part nobody does"; panels become `.dcard` glass, tags peach |
| `warm` | `#f5e4cf`, warm glow, grain .08 | amber and orange | `#2a190e` / `#c0592f` | `#2d1b10` / `#b49a85` / `#b9553e` | "it warms up", a building/progress beat |
| `cover` | fields and grid off | - | your own magazine layout | cream / terra box | a magazine-cover moment over a full-bleed picture |

Enter the dark act with a **dark dome**, leave it with a **light dome**; enter and leave the flood with **hard cuts + a punch**. Change acts about every 2-3 sections, never inside a sentence.

### 3.5 Type (with free fallbacks)

| Role | Original | Free fallback (in `fonts/`) | Spec |
|---|---|---|---|
| Headlines | Inter Tight | same (free) | weight 760, letter-spacing -0.025em, line-height 1.06, 92 px (84-116), centred in x 60-960, max 2 lines; auto-shrinks to fit |
| **Key word** | Bodoni MT Condensed *italic* (Windows/Office) | **Clay Serif** = Instrument Serif italic at 84 % | 1.2em, weight 400, accent colour, line-height 0.9; one per headline |
| Captions | Inter Tight | same | weight 800, 62 px, letter-spacing -0.012em, line-height 1.3 |
| Chips / tags | Consolas bold | Inconsolata 700 | 31 px, letter-spacing 0.01em, peach on `#1b1715` |
| Mono labels | Consolas bold caps | Inconsolata 700 | 17-22 px, uppercase, letter-spacing 0.14em, muted |
| Handwritten notes | Segoe Print | Kalam | 22-26 px, `#9b6a4a` on paper, peach on dark, rotated 3-5° |
| Magazine | Bodoni MT Black | Bodoni Moda 900 | only for a magazine-cover beat |
| Numbers on panels | Inter Tight 850, tabular-nums | same | counters, prices, timers |

Avoid emoji inside the film (headless Chrome may draw them as boxes); they are fine in the post caption.

### 3.6 Components

- **Card** `.card`: white, 30 px radius, shadow `0 24px 54px rgba(90,56,36,.12), 0 3px 8px rgba(90,56,36,.06)`, 1 px border `rgba(60,40,30,.06)`. Every UI mock sits on one. **Dark card** `.dcard`: `rgba(30,25,24,.92)`, 26 px radius, 2 px border `rgba(255,255,255,.07)`, for the dark act.
- **Slash-command chip** `stepChip('/paste', n, t0, t1)`: a black pill (`#1b1715`, peach mono 31 px, padding 12/30 px) centred at **y 262**; with `n > 0`, a 3-segment bar under it (50 x 7 px, 8 px gaps, `#e2ddd8`, filled accent up to n). Name each step as a command (`/paste`, `/ask`, `/save`, `/edit`, `/revise`). While a chip shows, headlines drop from y 282 to **y 378**.
- **Prompt bar** `prompt(root, y)`: a white pill 840 x 118 at x 90, radius 59, soft warm shadow; an 80 px avatar circle (AI: orange `#d97757` with a white 12-ray spark; user: a peach-to-terracotta gradient); a grey field `#f3f1ee` (radius 40, 38 px bold text, a 4 x 40 px caret blinking at 2.4 Hz); an 80 px terracotta send button with a white arrow that dips 18 % when "pressed". Text types in on the words it says. The field holds ~28 characters.
- **Stamp** `stamp(root, y, 'GENERIC', { t0 })`: 9 px border, 24 px radius, 900 weight 100 px caps, rotated -8°, red `#e5484d` (or accent), on an **opaque** paper background `#fffdfb`, slams in from 1.12 in 0.16 s with a 12 px blur. Lands the punch of a sentence.
- **Stickers** (CTA): `COMMENT` on `#1b1715` (100 px, 900, with a speech-bubble icon, rotated -4°) and the **KEYWORD** on accent (128-140 px, 900, rotated +2°), both slamming on their words.
- **Toast**: a white card 860 x 150 with a black 94 px rounded icon tile, "New message" (24 px muted) and one bold line: the promise arriving.
- **Doc tile**: an accent rounded square (radius 25 %) with a white document icon and an inner bottom shadow, a bold file name under it.
- **Numbered rows**: a card row with a 56 px accent circle holding a white number, a 36-38 px bold label and a muted sub-line or a green check.
- **Strike**: a red bar 12-20 px thick that grows across an item (`scaleX`, 0.16-0.22 s, rotated -8 to -38°) while the item turns grey.
- **Giant numeral**: 560 px, weight 850, white, on 28 rays (alternating 17 px / 9 px, white 22 % / 13 %) that turn at 9°/s; slams from 1.6. The flood's centrepiece.
- **Pills**: `.pill` (white label, 26 px 650) and `.st` (state pill that flips "Manual" -> "Done ✓" by squashing through zero height in 0.09 s and back-easing open; recipe in section 8).

### 3.7 Motion
- `show(el, t, t0, t1, { kin, kout })` is the one entrance/exit call. Entrances: **pop** (scale 0.6 -> 1 with a back-ease overshoot, 0.34 s, blur 10 -> 0), **rise** (from 50-90 px below, ease-out, 0.3-0.42 s), **drop** (from above, back-ease), **slam** (scale 1.12 -> 1, 0.16-0.22 s, blur), **fade**. Exits 0.24 s: up / down / shrink, blurring out. Stagger lists by 0.07-0.17 s.
- **Headline slot-in**: each word enters on its spoken time from 0.42em below, stretched 2.15x tall from its **top edge**, blurred 8 px and skewed up to 7°, settling in 0.26 s. Words not yet spoken keep their space, so a half-spoken headline sits off-centre: that is the look. Exit: the words stretch down and blur out in 0.18 s, 25 ms apart.
- Easing: ease-out cubic for most things, back (c = 1.7) for pops, ease-in-out cubic for camera moves and counters. **Nothing linear, nothing from a clock, nothing random**: every value is a pure function of t.

### 3.8 Transitions and camera
- **Hard cut + punch**: cut on a word, and push the whole world in 2.5-3.5 % over 50 ms, settling back over 0.37 s (`punch(t, 0.03)`).
- **Jump cut** `cam(t0, t1, 1.04)`: hold a 4 % closer framing for a beat ("tighter", "after a month"). Max 1.06. Zooms are anchored at x 510, y 300, so the top chip and headline never leave the zone.
- **Dome** `dome(tc, 'dark' | 'light' | 'flood')`: a circle rising from (540, 2160), radius 120 -> 2260 px over 0.46-0.5 s (ease-in-out) with a 5 px terracotta rim; the dark dome already carries the purple glow and the grid. It covers the frame at `tc`, where you swap the act. It is drawn **under the captions**.
- **Pixel wipe** `wipe(tc)`: 60 px terracotta squares (every third lighter `#e08a70`) grow diagonally, the frame is fully covered at `tc`, then they shrink away (0.27 s in, 0.32 s out). Drawn over everything. Optional; domes and hard cuts are the main ones.

### 3.9 Captions
- Chunks of **1-4 words** (from the `/` marks in the script), one chunk at a time, **centred on x 510, bottom at y 1466**, at most 820 px wide (it shrinks to fit).
- Inter Tight 800, 62 px. Words already spoken are ink; words to come are `#aba6a1`.
- **The box**: an accent rectangle (radius 10) slides to the word being spoken: its right edge leads, its left edge follows (0.12 s). **It is a mask**: under it lies a second copy of the line in white, clipped (`clip-path: inset(...)`) to the box. That way the active word is always white on accent, even halfway through a slide. A box simply placed behind the words makes the active word vanish mid-slide, which looks broken.
- A chunk enters in 0.14 s with a 6 px blur. Colours follow the act (3.4).
- `capHuge(t0, t1, 's6', /nobody/)`: in the dark act or the flood, one huge white word at a time (168 px, the matching word 250 px), popping from 1.35x, still inside the caption band. `capGrow(t, 1.3)`: the captions grow for the rest of the film (when the voice says "bigger captions").

### 3.10 The safe zone (Instagram, TikTok, YouTube Shorts)
The apps cover the top (status, tabs), the bottom (caption, handle, music) and the right (like, comment, share buttons).
- **All text** (headlines, chips, labels, captions, stamps): **x 60-960, y 250-1480.** Nothing important in the top 250 px, the bottom 440 px or the right 120 px.
- Captions: centred on **x 510** (not 540: the button column is on the right), block between y 1150 and 1480.
- Panels: inside x 60-960, ending by **y ~1330**, above the captions.
- The engine's defaults keep it there: headlines at y 282 (378 under a chip), chips at y 262, captions' bottom at 1466, slams from 1.12 and **capped** so their text stays inside the zone while they land, `cam`/`punch` anchored at y 300 (`cam` warns above 1.08), words stretching from their top edge, the huge caption's pop capped to the column.
- Check: `render.mjs --stills --safe` draws the zone (red = no text, green = text zone, blue = caption band) and boxes any text outside it in red; `render.mjs --scan` checks every frame.

---

## 4. Writing the script for this format

A voiceover explainer that teaches or reveals one useful thing. You (or the user) write it fresh; never copy someone else's script.

1. **35-55 s = 110-165 words** at ~185 wpm. Over ~170 words risks 60 s: cut words, never speed the voice up.
2. **A hook headline in the first second.** The first line is the headline: a claim, a contradiction or a promise, in 4-10 words ("Your AI sounds like everyone else."). It is on screen, complete, by ~1.5 s, with a panel that shows it and usually a stamp that lands it.
3. **A promise with a count**: "in three steps", "five tools", "one file". The count becomes the flood's giant numeral and the chips' segment bar, so viewers know how much is left.
4. **Numbered beats, one visual each.** Each step: "One." / "Two." / "Three." + a verb you can show (paste, ask, save, cut, send). Every sentence must be showable: if you can't name its visual, rewrite it. One idea per sentence, 4-12 words.
5. **A turn on the dark act at ~55-65 %**: "But here's the part nobody does." / "Here's the catch." / a question the viewer really has. A short line after a 0.4 s pause, then the insight that makes the video worth saving.
6. **A resolution in threes** back on paper: short, punchy, one punch per phrase ("One file. Your voice. Every post.").
7. **A comment-keyword CTA last**: "Comment *KEYWORD* and I'll send you the exact prompt." One word, in caps on screen, that names the reward (VOICE, PROMPT, LIST, CLAY). Promise **something real that exists and that you will actually send** (the prompt, the file, the checklist, the template), never something vague. Then the film ends.
8. Be concrete and honest: real numbers, real steps, nothing you can't back up. No "like and follow", no hype words.
9. Write for the ear: spell numbers and symbols as said, avoid phrases a TTS slurs (some voices read "Want a change?" as "Want to change?": write "Need a change?").

### Markup (`script.mjs`)

`TAKES` = one ElevenLabs take per section, in film order; **the last one is `cta`**. `GAP` = the silence before a section (default 0.3 s; 0.15-0.3 inside a beat, **0.4 before the turn and before the CTA**).

| Mark | Meaning |
|---|---|
| `/` | a new caption chunk starts here (keep chunks to 1-4 words) |
| `*word*` | a key word (for your headlines and emphasis) |
| `*two_words*` | one token over two spoken words, so `T('s4', 'style guide')` finds it and the caption box covers both |
| `{shown\|said}` | show one spelling, say another: `*{8x\|eight times}*`, `*{GPT-5\|GPT five}*` |

The example used throughout this file (107 words, ~36 s):

```js
// script.mjs: the words. One ElevenLabs take per section, in film order; the last one is `cta`.
export const TAKES = {
  s0: 'Your AI / sounds like / *everyone_else.*',
  s1: "Here's how to make it / sound like *you,* / in *three* steps.",
  s2: '*One.* / Paste in five / of your *best* posts. / Not your newest. / Your *best.*',
  s3: 'Two. / Ask it: / what makes / my writing *mine?*',
  s4: 'It writes you / a *style_guide.* / Your hooks, / your *rhythm,* / the words / you *never* use.',
  s5: 'Three. / Save it / as a *file,* / and load it / *every* time.',
  s6: "But here's the part / *nobody* does.",
  s7: 'Every time / you fix a draft, / tell it *why,* / and add that rule / to the *file.* / After a month, / it *knows* you.',
  s8: 'One file. / Your *voice.* / Every post.',
  cta: "Comment *VOICE* / and I'll send you / the exact *prompt.*",
};
export const GAP = { s1: 0.16, s2: 0.3, s3: 0.28, s4: 0.2, s5: 0.3, s6: 0.4, s7: 0.25, s8: 0.3, cta: 0.4 };   // silence before a section (default 0.3)
```

`markup.mjs`:

```js
// markup.mjs: script.mjs TAKES markup -> caption tokens.
//   /            a new caption chunk starts here (keep chunks to 1-4 words)
//   *word*       key word (headline accent / caption emphasis)
//   *two_words*  one token over two spoken words, so T('s4', 'style guide') finds it
//   {shown|said} show one spelling, say another: *{GPT-5|GPT five}*
export function parse(src) {
  const out = []; let br = true;
  for (const raw of src.match(/[^\s{]*\{[^}]*\}\S*|\S+/g)) {
    if (raw === '/') { br = true; continue; }
    let core = raw, tail = '', key = false;
    if (raw.startsWith('*')) { const j = raw.lastIndexOf('*'); key = true; core = raw.slice(1, j); tail = raw.slice(j + 1); }
    let d = core, said = core; const m = core.match(/^(.*)\{([^|]*)\|([^}]*)\}(.*)$/);
    if (m) { d = m[1] + m[2] + m[4]; said = m[1] + m[3] + m[4]; }
    out.push({ d: d.replace(/_/g, ' ') + tail, said: said.replace(/_/g, ' ') + tail, key, br }); br = false;
  }
  return out;
}
export const spoken = src => parse(src).map(t => t.said).join(' ');
```

---

## 5. The voice (ElevenLabs)

- **Voice: "Liam"**, a premade voice every account has: `TX3LPaxmHKxFdv7VOQHJ`, model **`eleven_multilingual_v2`**, **speed 1.1, stability 0.45, similarity_boost 0.8, style 0.45, use_speaker_boost true**. A young, energetic creator delivery at about **185 wpm**. Another premade voice works too (list `GET /v1/voices`, pick a young conversational narrator); keep the settings and adjust `speed` (0.7-1.2) until `build-timeline.mjs` reports 180-190 wpm.
- `POST /v1/text-to-speech/{voice_id}/with-timestamps` returns the audio plus a character alignment; `gen-vo.mjs` turns it into a start and end time for every word, which is what every caption, headline word and cut hangs on.
- **One take per section** (a sentence or two). Takes that didn't change are never re-sent; `--only s3,s7 --fresh` redoes just those. If a take sounds wrong in the alignment (a word missing, `WARNING: script has N words`), reword it rather than regenerating it blindly. Punctuation shapes the delivery: a full stop gives a beat, a comma a breath.
- `--estimate` makes silent takes with estimated times (about 0.05 s per letter plus punctuation pauses, close to Liam at 1.1) so you can build and check the whole film before spending credits. `mix.mjs` refuses to mix a silent voice.

```js
// gen-vo.mjs: one ElevenLabs take per section (with-timestamps), so every caption word has its time.
//   node gen-vo.mjs                    voice every new or changed take (prints the characters first)
//   node gen-vo.mjs --only s3,s7 --fresh   redo just those takes
//   node gen-vo.mjs --estimate         no API call: silent takes with estimated word times (layout tests, stills)
// -> vo/<id>.mp3 + vo/<id>.json { id, text, dur, est, tokens: [{ d, said, key, br, t0, t1 }] }
import fs from 'node:fs';
import { V, arg, has, ff, apiKey, loadScript } from './lib.mjs';
import { parse } from './markup.mjs';
const VOICE = { id: 'TX3LPaxmHKxFdv7VOQHJ', model: 'eleven_multilingual_v2',   // Liam (premade): ~185 wpm at speed 1.1
  settings: { stability: 0.45, similarity_boost: 0.8, style: 0.45, use_speaker_boost: true, speed: 1.1 } };

const { TAKES } = await loadScript(), ids = Object.keys(TAKES);
if (ids.at(-1) !== 'cta') { console.error('script.mjs: the last take must be `cta`'); process.exit(1); }
if (has('--fresh') && !arg('--only')) { console.error('--fresh needs --only <ids>'); process.exit(1); }
const only = arg('--only') ? arg('--only').split(',') : ids, est = has('--estimate');
const said = id => parse(TAKES[id]).map(t => t.said).join(' ');
const old = id => { try { return JSON.parse(fs.readFileSync(V('vo', id + '.json'), 'utf8')); } catch { return null; } };
const todo = only.filter(id => has('--fresh') || !old(id) || old(id).text !== said(id) || (old(id).est && !est));
if (!todo.length) { console.log('all takes cached, 0 characters sent'); process.exit(0); }
fs.mkdirSync(V('vo'), { recursive: true });
const key = est ? null : apiKey();
if (!est && !key) { console.error('no ELEVENLABS_API_KEY in the environment or .env (use --estimate for a silent layout test)'); process.exit(1); }
if (!est) console.log('sending', todo.reduce((n, id) => n + said(id).length, 0), 'characters for', todo.join(' '));
for (const id of todo) {
  const toks = parse(TAKES[id]), text = toks.map(t => t.said).join(' ');
  let words = [], dur;
  if (est) { // ~0.05 s per letter + pauses after punctuation: close to Liam at 1.1
    let t = 0.05;
    for (const w of text.split(/\s+/)) { const d = 0.03 + 0.05 * w.length; words.push({ t0: t, t1: t + d }); t += d + (/[.?!]$/.test(w) ? 0.3 : /[,:;]$/.test(w) ? 0.17 : 0.03); }
    dur = words.at(-1).t1 + 0.15;
    ff(['-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono', '-t', dur.toFixed(3), '-c:a', 'libmp3lame', '-b:a', '64k', V('vo', id + '.mp3')]);
  } else {
    const r = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${VOICE.id}/with-timestamps?output_format=mp3_44100_128`, {
      method: 'POST', headers: { 'xi-api-key': key, 'content-type': 'application/json' },
      body: JSON.stringify({ text, model_id: VOICE.model, voice_settings: VOICE.settings }) });
    if (!r.ok) { console.error(id, r.status, (await r.text()).slice(0, 300)); process.exit(1); }
    const j = await r.json(), A = j.alignment;
    fs.writeFileSync(V('vo', id + '.mp3'), Buffer.from(j.audio_base64, 'base64'));
    let cur = null;   // characters -> words: a word = first char start .. last char end
    A.characters.forEach((c, i) => {
      if (/\s/.test(c)) { if (cur) words.push(cur); cur = null; return; }
      if (!cur) cur = { t0: A.character_start_times_seconds[i] }; cur.t1 = A.character_end_times_seconds[i];
    });
    if (cur) words.push(cur);
    dur = A.character_end_times_seconds.at(-1);
  }
  let wi = 0;   // tokens may span several spoken words (*style_guide*)
  for (const t of toks) { const n = t.said.split(' ').length, span = words.slice(wi, wi + n); wi += n; t.t0 = +span[0].t0.toFixed(3); t.t1 = +span.at(-1).t1.toFixed(3); }
  if (wi !== words.length) console.log(id, 'WARNING: script has', wi, 'words, the alignment', words.length, '(a dash or symbol spoken as a word? reword it)');
  fs.writeFileSync(V('vo', id + '.json'), JSON.stringify({ id, text, dur, est, tokens: toks }, null, 1));
  console.log(id, est ? 'estimated' : 'voiced', dur.toFixed(2) + ' s');
}
```

## 6. The timeline

`build-timeline.mjs` lays the takes end to end: the film starts 0.1 s before the first word (`LEAD`), each take is trimmed 0.12 s after its last word, `GAP` sets the silence before each section, and the film holds 1.25 s after the last word (`HOLD`). It writes `timeline.js` (`window.TL = { total, sections: [{ id, start, end, tokens: [{ d, said, key, br, t0, t1 }] }] }`), the single source of truth for every timing, and `vo.wav`. It prints the length and the wpm.

```js
// build-timeline.mjs: lay the takes end to end (script order + GAP) on the film clock.
//   -> timeline.json + timeline.js (window.TL = { total, sections: [{ id, start, end, tokens }] }) and vo.wav (the voice track)
import fs from 'node:fs';
import { V, ff, need, loadScript } from './lib.mjs';
const { TAKES, GAP = {}, LEAD = 0.1, HOLD = 1.25 } = await loadScript(), ids = Object.keys(TAKES);
let t = LEAD; const sections = [], inputs = [], fc = [];
ids.forEach((id, i) => {
  need(V('vo', id + '.json'), 'run gen-vo.mjs first');
  const j = JSON.parse(fs.readFileSync(V('vo', id + '.json'), 'utf8'));
  if (i) t += GAP[id] ?? 0.3;
  const start = +t.toFixed(3), speechEnd = j.tokens.at(-1).t1;   // the take is trimmed after its last word
  sections.push({ id, start, end: +(start + speechEnd).toFixed(3), tokens: j.tokens.map(k => ({ ...k, t0: +(start + k.t0).toFixed(3), t1: +(start + k.t1).toFixed(3) })) });
  inputs.push('-i', V('vo', id + '.mp3'));
  const ms = Math.round(start * 1000);
  fc.push(`[${i}:a]atrim=0:${(speechEnd + 0.12).toFixed(3)},afade=t=out:st=${(speechEnd + 0.02).toFixed(3)}:d=0.1,adelay=${ms}|${ms}[a${i}]`);
  t = start + speechEnd;
});
const total = +(t + HOLD).toFixed(3);
fs.writeFileSync(V('timeline.json'), JSON.stringify({ total, sections }, null, 1));
fs.writeFileSync(V('timeline.js'), `window.TL = ${JSON.stringify({ total, sections })};`);
fc.push(`${ids.map((_, i) => `[a${i}]`).join('')}amix=inputs=${ids.length}:normalize=0,apad=whole_dur=${total},atrim=0:${total}[vo]`);
ff([...inputs, '-filter_complex', fc.join(';'), '-map', '[vo]', '-ar', '48000', '-ac', '1', V('vo.wav')]);
const words = sections.reduce((n, s) => n + s.tokens.reduce((m, k) => m + k.said.split(' ').length, 0), 0);
console.log(`total ${total} s · ${words} words · ${(words / (t - LEAD) * 60).toFixed(0)} wpm · ` + sections.map(s => `${s.id}@${s.start}`).join(' '));
if (total > 58) console.log('WARNING: longer than ~55 s; cut words, not speed');
```

---

## 7. The page and the engine

**The one rule: `window.setT(t)` is a pure function of time.** Given t (seconds) it sets every element's opacity, transform and text for that moment and returns. No clocks (`performance.now`, `Date`, `requestAnimationFrame`), no CSS transitions or animations, no `Math.random()` (use the seeded `rng(seed)`), no state carried from the previous frame. Then any frame renders in any order, renders resume, and a still at any time is cheap. The engine also sets `window.ready` (a promise: fonts and images loaded, headlines fitted), `window.META` (total, cover time, sound cues, music plan, warnings), `window.safeReport()` and `window.content()`.

`edit.html`:

```html
<!doctype html>
<html><head><meta charset="utf-8"><title>Clay reel</title>
<link rel="stylesheet" href="fonts/fonts.css"><link rel="stylesheet" href="edit.css"></head>
<body><div id="film"></div>
<script>
  // loads the timeline, the engine and this video's scenes in order, then builds the film. ?safe=1 draws the safe zone.
  const files = ['timeline.js', 'engine.js', 'scenes.js'], bust = '?v=' + Date.now(); let left = files.length;
  for (const f of files) { const s = document.createElement('script'); s.src = f + bust; s.async = false;
    s.onload = () => { if (--left === 0) try { CLAY.boot(); } catch (e) { console.error(e.stack); window.ready = Promise.reject(e); } };
    s.onerror = () => { console.error('missing ' + f); window.ready = Promise.reject(new Error('missing ' + f)); }; document.head.appendChild(s); }
</script></body></html>
```

`edit.css`:

```css
/* Clay (Terracotta UI) style. Fonts: fonts/fonts.css (get-fonts.mjs). Local Windows/Office fonts win when present. */
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 1080px; height: 1920px; overflow: hidden; background: #000; }
#film { position: absolute; left: 0; top: 0; width: 1080px; height: 1920px; overflow: hidden; font-family: 'Inter Tight', system-ui, sans-serif; color: #141211; -webkit-font-smoothing: antialiased; }
.L, .sc { position: absolute; left: 0; top: 0; width: 1080px; height: 1920px; }
.sc { display: none; }
.A { position: absolute; }
/* white panel every UI mock sits on; dark glass panel for the dark act */
.card { background: #fff; border-radius: 30px; box-shadow: 0 24px 54px rgba(90, 56, 36, 0.12), 0 3px 8px rgba(90, 56, 36, 0.06); border: 1px solid rgba(60, 40, 30, 0.06); }
.dcard { background: rgba(30, 25, 24, 0.92); border-radius: 26px; border: 2px solid rgba(255, 255, 255, 0.07); box-shadow: 0 24px 54px rgba(0, 0, 0, 0.35); color: #fff; }
/* type */
.serif { font-family: 'Bodoni MT Condensed', 'Clay Serif', 'Instrument Serif', Georgia, serif; font-style: italic; font-weight: 400; letter-spacing: 0; }
.mag { font-family: 'Bodoni MT Black', 'Bodoni Moda', serif; font-weight: 900; letter-spacing: -0.01em; }
.mono { font-family: Consolas, Inconsolata, 'Cascadia Mono', monospace; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
.hand { font-family: 'Segoe Print', Kalam, cursive; }
/* black slash-command chip ("/edit"): peach mono on #1b1715 */
.chip { display: inline-flex; align-items: center; gap: 12px; background: #1b1715; color: #f6c6b1; border-radius: 999px; padding: 12px 30px; font-size: 24px; white-space: nowrap; }
.tag { font-family: Consolas, Inconsolata, 'Cascadia Mono', monospace; font-weight: 700; font-size: 31px; letter-spacing: 0.01em; }
/* white floating label; small state pill (Manual -> Done ✓) */
.pill { display: inline-flex; align-items: center; gap: 12px; background: #fff; border-radius: 999px; padding: 14px 24px; font-size: 26px; font-weight: 650; white-space: nowrap; box-shadow: 0 10px 26px rgba(90, 56, 36, 0.12), 0 2px 5px rgba(90, 56, 36, 0.06); }
.st { display: inline-block; border-radius: 999px; font-size: 26px; font-weight: 750; padding: 9px 0; width: 196px; text-align: center; white-space: nowrap; }
/* headline words stretch from their TOP edge (the slot-in must never cross y 250) */
#hl .hw { display: inline-block; white-space: pre; transform-origin: 50% 0%; will-change: transform, opacity, filter; }
#hl .ln { display: block; white-space: nowrap; }
/* captions */
#cap > .A { line-height: 0; font-size: 0; }
#cap .cw { display: inline-block; position: relative; z-index: 2; white-space: pre; padding: 0 9px; }
#cap .box { position: absolute; z-index: 1; border-radius: 10px; }
```

`engine.js` (the stage, acts, components, headlines, captions, domes, wipes, camera, checks):

```js
// engine.js: the Clay (Terracotta UI) engine. Write it once, never edit it per video; a video only writes scenes.js.
// edit.html loads timeline.js, engine.js, scenes.js, then CLAY.boot(). window.setT(t) draws film time t, purely.
(() => {
  'use strict';
  const W = 1080, H = 1920, PI = Math.PI;
  // safe zone: text in x 60-960 × y 250-1480; captions centred on x 510, <= 820 wide, bottom at y 1466
  const SAFE = { x0: 60, x1: 960, y0: 250, y1: 1480, cx: 510, capW: 820, capBottom: 1466, headY: 282, headYChip: 378, chipY: 262 };
  const COL = { PAPER: '#f4f2ee', INK: '#141211', ACC: '#d4715a', ACC2: '#b9553e', PEACH: '#f6c6b1', CL: '#d97757', LILAC: '#b9bff0', GREEN: '#22a559',
    RED: '#e5484d', BLUE: '#1662d6', TERRA: '#c0592f', MUTED: '#8f8a85', NIGHT: '#141011', FLOOD: '#cf6a52', WARM: '#f5e4cf', CHIP: '#1b1715' };
  const { INK, ACC, CL } = COL;

  // ---------- maths + DOM ----------
  const clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
  const lerp = (a, b, k) => a + (b - a) * k;
  const ease = x => { x = clamp(x); return x * x * (3 - 2 * x); };
  const eo = x => 1 - Math.pow(1 - clamp(x), 3);                                   // ease-out cubic: most entrances
  const eio = x => { x = clamp(x); return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; };
  const back = (x, c = 1.7) => { x = clamp(x); return 1 + (c + 1) * Math.pow(x - 1, 3) + c * Math.pow(x - 1, 2); };   // overshoot: pops
  const rng = seed => { let s = seed >>> 0; return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; };      // never Math.random()
  const px = v => v + 'px';
  const hex = c => [1, 3, 5].map(i => parseInt(c.slice(i, i + 2), 16));
  const mixA = (a, b, k) => a.map((v, i) => lerp(v, b[i], clamp(k)));
  const rgb = (a, al = 1) => `rgba(${a.map(v => Math.round(v)).join(',')},${al})`;
  const h = (tag, cls, style, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (style) Object.assign(e.style, style); if (html != null) e.innerHTML = html; return e; };
  const A = (p, x, y, w, hh, cls = '', html, style = {}) => { const e = h('div', 'A ' + cls, { left: px(x), top: px(y), ...(w != null ? { width: px(w) } : {}), ...(hh != null ? { height: px(hh) } : {}), ...style }, html); p.appendChild(e); return e; };
  const st = (el, op, tf = '', blur = 0) => { const o = clamp(op); el.style.visibility = o <= 0.002 ? 'hidden' : 'visible'; el.style.opacity = o.toFixed(3); el.style.transform = tf; el.style.filter = blur > 0.3 ? `blur(${blur.toFixed(1)}px)` : 'none'; };

  // ---------- icons ----------
  const SPARK = (s, col = '#fff', sw = 9) => { let d = ''; const r = rng(11); for (let i = 0; i < 12; i++) { const a = i / 12 * PI * 2 + (r() - 0.5) * 0.2 - PI / 2, L = i % 2 ? 36 + r() * 4 : 45 + r() * 3; d += `M${(50 + Math.cos(a) * 8).toFixed(1)} ${(50 + Math.sin(a) * 8).toFixed(1)}L${(50 + Math.cos(a) * L).toFixed(1)} ${(50 + Math.sin(a) * L).toFixed(1)}`; } return `<svg width="${s}" height="${s}" viewBox="0 0 100 100"><path d="${d}" stroke="${col}" stroke-width="${sw}" stroke-linecap="round"/></svg>`; };
  const SEND = s => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="#fff"><path d="M3.5 19.5l17-7.5-17-7.5 2.2 7.5z"/></svg>`;
  const DOC = (s, col = '#fff') => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="${col}" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4M9 12h6M9 16h6"/></svg>`;
  const CHECK = (s, col = COL.GREEN, sw = 3.2) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="${col}" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>`;
  const BUBBLE = s => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="#fff"><path d="M3 5.5A2.5 2.5 0 0 1 5.5 3h13A2.5 2.5 0 0 1 21 5.5v9a2.5 2.5 0 0 1-2.5 2.5H10l-5 4v-4H5.5A2.5 2.5 0 0 1 3 14.5z"/></svg>`;
  const MAIL = s => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"><rect x="3" y="5.5" width="18" height="13" rx="2.5"/><path d="M3.5 7l8.5 6.5L20.5 7"/></svg>`;

  // ---------- entrance / exit, one call per frame ----------
  // show(el, t, t0, t1, o): in at t0, out at t1 (null = stays). kin: pop · rise · drop · slam · fade   kout: up · down · shrink
  // A slam scales down from `from` (default 1.12). A bigger `from` is capped so the element's TEXT never leaves the safe zone.
  const slamCap = (el, base, from) => {
    if (el._cap != null) return el._cap;
    if (!el.textContent.trim()) return (el._cap = from);
    const keep = el.style.transform, vis = el.style.visibility; el.style.transform = base || 'none'; el.style.visibility = 'visible';
    const o = el.getBoundingClientRect(), ox = (o.left + o.right) / 2, oy = (o.top + o.bottom) / 2; let cap = from, seen = false;
    const tw = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
    for (let n; (n = tw.nextNode());) {
      if (!n.textContent.trim()) continue; const rg = document.createRange(); rg.selectNodeContents(n);
      for (const b of rg.getClientRects()) {
        if (b.width < 2 || b.height < 2) continue; seen = true;
        if (b.left < ox - 1) cap = Math.min(cap, (ox - SAFE.x0) / (ox - b.left)); if (b.right > ox + 1) cap = Math.min(cap, (SAFE.x1 - ox) / (b.right - ox));
        if (b.top < oy - 1) cap = Math.min(cap, (oy - SAFE.y0) / (oy - b.top)); if (b.bottom > oy + 1) cap = Math.min(cap, (SAFE.y1 - oy) / (b.bottom - oy));
      }
    }
    el.style.transform = keep; el.style.visibility = vis;
    if (!seen || o.width < 1) return from;
    return (el._cap = Math.max(1, Math.min(from, cap * 0.995)));
  };
  const show = (el, t, t0, t1, o = {}) => {
    const { kin = 'pop', kout = 'up', din = 0.34, dout = 0.24, base = '', dy = 60, dx = 0, blur = 10 } = o;
    const from = o.from ?? (kin === 'slam' ? 1.12 : 0.6);
    if (t < t0 || (t1 != null && t >= t1 + dout)) { st(el, 0); return 0; }
    let op = 1, tf = base, bl = 0; const ki = (t - t0) / din;
    if (kin === 'pop') { const k = back(ki); tf += ` scale(${(from + (1 - from) * k).toFixed(4)})`; op = clamp(ki * 3.5); bl = (1 - eo(ki)) * blur; }
    else if (kin === 'rise') { const k = eo(ki); tf += ` translate(${((1 - k) * dx).toFixed(1)}px, ${((1 - k) * dy).toFixed(1)}px)`; op = clamp(ki * 2.2); bl = (1 - k) * blur; }
    else if (kin === 'drop') { const k = back(ki, 1.25); tf += ` translateY(${((1 - k) * -dy).toFixed(1)}px)`; op = clamp(ki * 4); }
    else if (kin === 'slam') { const f = from > 1 && o.cap !== false && ki < 1 ? slamCap(el, base, from) : from, k = eo(ki); tf += ` scale(${lerp(f, 1, k).toFixed(4)})`; op = clamp(ki * 5); bl = (1 - k) * blur; }
    else if (kin === 'fade') op = ease(ki);
    if (t1 != null && t >= t1) {
      const ko = eo((t - t1) / dout);
      if (kout === 'up') tf += ` translateY(${(-ko * 46).toFixed(1)}px)`; else if (kout === 'down') tf += ` translateY(${(ko * 46).toFixed(1)}px)`; else if (kout === 'shrink') tf += ` scale(${(1 - 0.14 * ko).toFixed(4)})`;
      op *= 1 - ko; bl += ko * 9;
    }
    st(el, op, tf, bl); return op;
  };
  const typed = (el, text, t, t0, t1) => { const n = Math.round(clamp((t - t0) / Math.max(0.05, t1 - t0)) * [...text].length); el.textContent = [...text].slice(0, n).join(''); return n; };
  const caretOn = t => Math.floor(t * 2.4) % 2 === 0;

  function boot() {
    const TL = window.TL; if (!TL) throw new Error('no timeline.js: run build-timeline.mjs');
    if (typeof window.VIDEO !== 'function') throw new Error('scenes.js must set window.VIDEO = E => { … }');
    const film = document.getElementById('film');

    // ---------- the voice's clock ----------
    const S = Object.fromEntries(TL.sections.map(s => [s.id, s]));
    const norm = s => s.toLowerCase().replace(/[^a-z0-9 ]/g, '');
    const K = (id, word, n = 0) => { if (!S[id]) throw new Error(`no section "${id}"`); let c = 0; const q = norm(word); for (const k of S[id].tokens) { const d = norm(k.d); if (d === q || d.startsWith(q + ' ')) { if (c === n) return k; c++; } } throw new Error(`no word "${word}" (#${n}) in ${id}`); };
    const T = (id, word, n) => K(id, word, n).t0, TE = (id, word, n) => K(id, word, n).t1;   // when the n-th match starts / ends
    const mid = (a, b) => +((S[a].end + S[b].start) / 2).toFixed(3);                           // the gap between two sections
    const WARN = [], warn = m => { if (!WARN.includes(m)) { WARN.push(m); console.warn('WARN ' + m); } };
    const CUES = [], cue = (name, t, db = 0, len = null, off = 0) => CUES.push([name, +Math.max(0, t).toFixed(3), db, len, off]);  // sfx/<name>.wav, db vs its default level

    // ---------- layers (bottom to top) ----------
    const world = h('div', 'L'); film.appendChild(world);                 // cam/punch zoom this; captions sit outside it
    const L = () => { const e = h('div', 'L'); world.appendChild(e); return e; };
    const bg = L(), gridD = L(), blobL = L(), gridW = L(), glowW = L();
    for (const g of [gridD, gridW]) { g.style.backgroundSize = '60px 60px'; g.style.backgroundPosition = '-2px 18px'; }
    const BLOBS = [{ r: 560, x: 230, y: 560, ax: 130, ay: 170, sp: 0.23, ph: 0 }, { r: 500, x: 840, y: 930, ax: 150, ay: 130, sp: 0.18, ph: 2.1 },
      { r: 600, x: 360, y: 1520, ax: 170, ay: 120, sp: 0.16, ph: 4.2 }, { r: 440, x: 920, y: 250, ax: 110, ay: 150, sp: 0.2, ph: 1.3 }]
      .map(b => ({ ...b, e: A(blobL, 0, 0, b.r * 2, b.r * 2, '', '', { borderRadius: '50%' }) }));
    const BLOBC = {   // [rgb, alpha] per blob, per act: terracotta · lilac · peach · terracotta
      base: [[[232, 140, 112], 0.46], [[178, 186, 244], 0.42], [[240, 168, 136], 0.36], [[230, 150, 122], 0.32]],
      warm: [[[245, 160, 100], 0.55], [[250, 190, 130], 0.45], [[236, 140, 96], 0.42], [[255, 200, 150], 0.4]],
      moody: [[[176, 78, 56], 0.5], [[104, 44, 128], 0.62], [[88, 36, 110], 0.5], [[150, 64, 50], 0.4]],
      flood: [[[255, 196, 170], 0.42], [[176, 72, 52], 0.34], [[255, 214, 190], 0.32], [[190, 84, 60], 0.3]] };
    glowW.style.background = 'radial-gradient(820px 900px at 50% 42%, rgba(255,196,140,0.55), transparent 70%), radial-gradient(700px 600px at 92% 8%, rgba(255,170,150,0.35), transparent 70%)';
    const ambEl = h('canvas', 'L'); ambEl.width = W; ambEl.height = H; world.appendChild(ambEl); const amb = ambEl.getContext('2d');
    const sceneL = L(), fxL = L();
    const vign = A(fxL, 0, 0, W, H, '', '', { background: 'radial-gradient(ellipse 80% 70% at 50% 48%, transparent 45%, rgba(0,0,0,0.62) 100%)' });
    const grainEl = h('canvas', 'L', { width: px(W), height: px(H), mixBlendMode: 'overlay' }); grainEl.width = 270; grainEl.height = 480; fxL.appendChild(grainEl); const grain = grainEl.getContext('2d');
    const chipL = L(), hlL = L(); hlL.id = 'hl';
    const domeEl = h('canvas', 'L'); domeEl.width = W; domeEl.height = H; film.appendChild(domeEl); const domeCtx = domeEl.getContext('2d');   // under the captions
    const capL = h('div', 'L'); capL.id = 'cap'; film.appendChild(capL);
    const wipeEl = h('canvas', 'L'); wipeEl.width = W; wipeEl.height = H; film.appendChild(wipeEl); const wipeCtx = wipeEl.getContext('2d'); // over everything
    const AMB = []; { const r = rng(7); for (let i = 0; i < 30; i++) { const q = r(); AMB.push({ x: r() * W, y: r() * H, s: 13 + r() * 13, v: 6 + r() * 14, kind: q < 0.3 ? 'sq' : q < 0.55 ? 'ci' : q < 0.75 ? 'x' : 'dot', a: 0.35 + r() * 0.45, ph: r() * 6.3, rot: r() * 6.3 }); } }
    const STARS = []; { const r = rng(17); for (let i = 0; i < 46; i++) STARS.push({ x: r() * W, y: r() * H, s: 2 + r() * 4.5, v: 3 + r() * 8, a: 0.35 + r() * 0.65, ph: r() * 6.3, tw: 1 + r() * 2.5 }); }

    // ---------- registries a video fills ----------
    const SCENES = [], HL = [], CHIPS = [], ACTS = [], WIPES = [], DOMES = [], CAMS = [], PUNCH = [], CAP = { grow: null, huge: [] };
    let MUSIC = null;
    const scene = (t0, t1, build) => { const root = h('div', 'sc'); sceneL.appendChild(root); SCENES.push({ t0, t1, root, upd: build(root) }); };
    const act = (kind, t0, t1, fade = 0) => { if (!['warm', 'cover', 'moody', 'flood'].includes(kind)) throw new Error('act kind ' + kind); ACTS.push({ kind, t0, t1, fade }); };
    const theme = t => { const o = { warm: 0, cover: 0, moody: 0, flood: 0 }; for (const a of ACTS) if (t >= a.t0 && t < a.t1) o[a.kind] = Math.max(o[a.kind], a.fade ? ease((t - a.t0) / a.fade) : 1); return o; };
    const center = (p, y, html, cls = '', style = {}) => { const row = A(p, SAFE.x0, y, SAFE.x1 - SAFE.x0, null, '', '', { display: 'flex', justifyContent: 'center' }); const e = h('div', cls, style, html); row.appendChild(e); return { row, e }; };
    const wipe = (tc, dir = 1) => { WIPES.push([tc, dir]); cue('whoosh', tc - 0.27, -2, 0.6); cue('glitch', tc - 0.05); };
    const dome = (tc, kind = 'dark', dur = 0.46) => { DOMES.push({ tc, kind, dur }); cue('whoosh', tc - dur, 0, 0.8); };   // swap the act exactly at tc
    const cam = (t0, t1, s, cy = 300) => { if (s > 1.08) warn(`cam(${t0.toFixed(2)}) scale ${s} > 1.08 pushes side text out of the safe zone`); CAMS.push({ t0, t1, s, cy }); };
    const punch = (t0, amp = 0.03, dur = 0.42) => PUNCH.push({ t0, amp, dur });
    // the white prompt bar: avatar · grey field with a caret · terracotta send. Type into .tx with typed().
    const prompt = (p, y, o = {}) => {
      const { x = 90, w: bw = 840, av = 'ai' } = o;
      const row = A(p, x, y, bw, 118, '', '', { background: '#fff', borderRadius: '59px', boxShadow: '0 18px 40px rgba(80,50,30,0.13), 0 2px 6px rgba(80,50,30,0.06)', border: '1px solid rgba(60,40,30,0.06)' });
      const avEl = A(row, 19, 19, 80, 80, '', av === 'ai' ? SPARK(48, '#fff', 10) : '', { borderRadius: '50%', background: av === 'ai' ? CL : 'linear-gradient(140deg,#f4b49b,#d4715a)', display: 'flex', alignItems: 'center', justifyContent: 'center' });
      const field = A(row, 116, 19, bw - 232, 80, '', '', { background: '#f3f1ee', borderRadius: '40px', padding: '0 28px', fontSize: '38px', fontWeight: 700, lineHeight: '80px', whiteSpace: 'nowrap', overflow: 'hidden', color: INK });
      const tx = h('span'), car = h('span', '', { display: 'inline-block', width: '4px', height: '40px', background: INK, marginLeft: '4px', verticalAlign: '-7px', borderRadius: '2px' }); field.append(tx, car);
      const send = A(row, bw - 99, 19, 80, 80, '', SEND(36), { borderRadius: '50%', background: ACC, display: 'flex', alignItems: 'center', justifyContent: 'center' });
      return { row, tx, car, send, field, av: avEl };
    };
    // a rotated rubber stamp on OPAQUE paper (rows behind never ghost through); slams in at t0. update(t) in the scene.
    const stamp = (p, y, html, o = {}) => {
      const { t0, t1 = null, color = COL.RED, bg = '#fffdfb', rot = -8, size = 100, from = 1.12 } = o;
      if (/rgba|transparent|hsla/i.test(bg)) warn('stamp() background must be opaque: ' + bg);
      const s = center(p, y, html, '', { border: `9px solid ${color}`, color, borderRadius: '24px', fontSize: px(size), fontWeight: 900, letterSpacing: '0.01em', padding: '4px 36px', background: bg, boxShadow: `0 22px 44px ${color}33`, whiteSpace: 'nowrap', ...(o.style || {}) });
      return { ...s, update: t => show(s.e, t, t0, t1, { kin: 'slam', from, din: 0.16, base: `rotate(${rot}deg)`, blur: 12, kout: 'shrink' }) };
    };
    // the black slash-command tag ("/edit") at y 262; n > 0 adds an n-of-`of` segment bar; headlines drop to y 378 while it shows
    const stepChip = (label, n, t0, t1, o = {}) => {
      const of = o.of ?? 3, row = A(chipL, SAFE.x0, SAFE.chipY, SAFE.x1 - SAFE.x0, null, '', '', { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' });
      row.appendChild(h('div', 'chip tag', o.acc ? { background: ACC, color: '#fff' } : {}, label));
      const segs = []; if (n) { const bar = h('div', '', { display: 'flex', gap: '8px' }); for (let i = 0; i < of; i++) { const s = h('i', '', { display: 'block', width: '50px', height: '7px', borderRadius: '4px', background: '#e2ddd8' }); bar.appendChild(s); segs.push(s); } row.appendChild(bar); }
      CHIPS.push({ row, segs, n, t0, t1 });
    };
    const chipActive = t => CHIPS.some(c => t >= c.t0 && t < c.t1 + 0.2);

    // ---------- headlines: words slot in on their spoken time ----------
    // head(lines, t1, o): lines = [[word, …], …]; a word = w(id, 'word', n, kind) on its spoken time, or wt(text, kind, t).
    // kind: '' sans · 'i' accent serif-italic key word · 'c' Claude orange · 'm' magazine Bodoni ('mi' italic). o: { y, size, centerY, out: 'anim'|'cut' }
    const head = (lines, t1, o = {}) => {
      const { y = null, size = 92, centerY = false, out = 'anim' } = o;
      const el = A(hlL, SAFE.x0, y ?? SAFE.headY, SAFE.x1 - SAFE.x0, null, '', '', { textAlign: 'center', fontSize: px(size), fontWeight: 760, letterSpacing: '-0.025em', lineHeight: '1.06' });
      const words = [], lns = [];
      lines.forEach(ln => { const l = h('div', 'ln'); ln.forEach(([txt, kind = '', tIn], j) => { if (j) l.appendChild(document.createTextNode(' ')); const s = h('span', 'hw' + (kind.includes('i') && !kind.includes('m') ? ' serif' : '') + (kind.includes('m') ? ' mag' : ''), {}, txt); if (kind.includes('i') && !kind.includes('m')) { s.style.fontSize = '1.2em'; s.style.lineHeight = '0.9'; } if (kind === 'mi') s.style.fontStyle = 'italic'; l.appendChild(s); words.push({ s, kind, tIn }); }); el.appendChild(l); lns.push(l); });
      HL.push({ el, lns, words, size, centerY, y: y ?? SAFE.headY, fixedY: y != null, t0: Math.min(...words.map(x => x.tIn)), t1, out });
    };
    const w = (id, word, n, kind = '', dt = 0) => [word, kind, T(id, word.replace(/[^\w' ]/g, '').split(' ')[0], n) + dt];
    const wt = (word, kind, t) => [word, kind, t];
    // slot-in: from 0.42em below, stretched 2.15× tall and blurred, settled in 0.26 s; the stretch hangs from the word's top
    const wordAnim = (s, t, tIn, tOut, i) => {
      if (t < tIn) { s.style.opacity = 0; return; }
      const k = clamp((t - tIn) / 0.26), e = eo(k);
      let ty = (1 - e) * 0.42, sy = 1 + (1 - e) * 1.15, op = clamp(k * 4.5), bl = (1 - e) * 8; const sk = k < 0.3 ? Math.sin(tIn * 997 + i) * 7 * (1 - k / 0.3) : 0;
      if (tOut != null && t >= tOut + i * 0.025) { const ko = eo((t - tOut - i * 0.025) / 0.18); sy += ko * 0.8; op *= 1 - ko; bl += ko * 9; }
      s.style.transform = `translateY(${ty.toFixed(3)}em) scaleY(${sy.toFixed(3)}) skewX(${sk.toFixed(2)}deg)`; s.style.opacity = op.toFixed(3); s.style.filter = bl > 0.3 ? `blur(${bl.toFixed(1)}px)` : 'none';
    };

    const E = { W, H, PI, SAFE, COL, ...COL, TL, S, K, T, TE, mid, clamp, lerp, ease, eo, eio, back, rng, px, hex, mixA, rgb, h, A, st, show, typed, caretOn, cue, warn,
      SPARK, SEND, DOC, CHECK, BUBBLE, MAIL, scene, act, center, wipe, dome, cam, punch, prompt, stamp, stepChip, head, w, wt,
      capGrow: (t, scale = 1.3) => { CAP.grow = { t, scale }; }, capHuge: (t0, t1, sec, big = null) => CAP.huge.push({ t0, t1, sec, big }),
      music: plan => { MUSIC = plan; }, title: document.title, coverAt: null };
    window.VIDEO(E);

    // ---------- captions: chunk by chunk, past words ink, future grey, a box slides to the spoken word ----------
    // The box is a MASK: a second copy of the line in white, clipped to the box, lies on top, so the active word never vanishes mid-slide.
    const CH = [];
    TL.sections.forEach(s => { let cur = null; s.tokens.forEach(k => { if (k.br || !cur) { cur = { toks: [] }; CH.push(cur); } cur.toks.push(k); }); });
    CH.forEach(c => { c.t0 = c.toks[0].t0 - 0.03; }); CH.forEach((c, i) => { c.t1 = i + 1 < CH.length ? CH[i + 1].t0 : TL.total + 1; });
    const capRow = A(capL, SAFE.x0, 0, SAFE.x1 - SAFE.x0, null, '', '', { textAlign: 'center' });
    const capLine = h('div', '', { position: 'relative', display: 'inline-block', fontSize: '62px', fontWeight: 800, letterSpacing: '-0.012em', lineHeight: '1.3', whiteSpace: 'nowrap', transformOrigin: '50% 100%' }); capRow.appendChild(capLine);
    const capBox = h('div', 'box'); capLine.appendChild(capBox);
    const capOn = h('div', '', { position: 'absolute', left: 0, top: 0, whiteSpace: 'nowrap', zIndex: 3 }); capLine.appendChild(capOn);
    const capHugeR = A(capL, SAFE.x0, 0, SAFE.x1 - SAFE.x0, null, '', '', { textAlign: 'center', top: 'auto', bottom: px(H - SAFE.capBottom + 6) });
    const capHugeS = h('span', '', { display: 'inline-block', fontWeight: 900, letterSpacing: '-0.03em', color: '#fff', lineHeight: '1.15', textShadow: '0 12px 50px rgba(0,0,0,0.55)', transformOrigin: '50% 100%', whiteSpace: 'nowrap' }); capHugeR.appendChild(capHugeS);
    let capIdx = -1, capWords = [], capOnW = [], capNatW = 0;
    const capTheme = th => {
      if (th.flood) return { past: '#fff', fut: 'rgba(255,255,255,0.6)', box: COL.CHIP, on: '#fff', sh: 'none' };
      if (th.cover) return { past: '#fff4e6', fut: 'rgba(255,244,230,0.62)', box: COL.TERRA, on: '#fff', sh: '0 3px 18px rgba(40,15,5,0.65)' };
      if (th.moody > 0.5) return { past: '#fff', fut: 'rgba(255,255,255,0.42)', box: ACC, on: '#fff', sh: '0 3px 16px rgba(0,0,0,0.5)' };
      if (th.warm > 0.5) return { past: '#2d1b10', fut: '#b49a85', box: COL.ACC2, on: '#fff', sh: 'none' };
      return { past: INK, fut: '#aba6a1', box: ACC, on: '#fff', sh: 'none' };
    };
    const drawCaptions = (t, th) => {
      const hz = CAP.huge.find(z => t >= z.t0 && t < z.t1);
      if (hz) {   // one huge white word at a time, still inside the caption band (dark act / flood only)
        capRow.style.display = 'none'; capHugeR.style.display = 'block';
        let k = null; for (const x of S[hz.sec].tokens) if (x.t0 >= hz.t0 - 0.01 && t >= x.t0 - 0.02) k = x;
        if (!k) { capHugeR.style.display = 'none'; return; }
        const big = hz.big && hz.big.test(k.d); capHugeS.textContent = k.d.replace(/[.,]/g, big ? '.' : ''); capHugeS.style.fontSize = px(big ? 250 : 168);
        const ow = Math.max(1, capHugeS.offsetWidth), fit = Math.min(1, SAFE.capW / ow), u = (t - k.t0 + 0.02) / 0.16, e = eo(u);
        capHugeS.style.transform = `scale(${Math.min(lerp(1.35, 1, e) * fit, (SAFE.x1 - SAFE.x0) / ow).toFixed(3)})`;   // the entry overshoot stays inside x 60-960 capHugeS.style.opacity = clamp(u * 3).toFixed(2); capHugeS.style.filter = e < 0.97 ? `blur(${((1 - e) * 10).toFixed(1)}px)` : 'none';
        return;
      }
      capHugeR.style.display = 'none';
      let ci = -1; for (let i = 0; i < CH.length; i++) if (t >= CH[i].t0 && t < CH[i].t1) { ci = i; break; }
      if (ci < 0) { capRow.style.display = 'none'; return; }
      capRow.style.display = 'block'; const c = CH[ci];
      if (ci !== capIdx) { capIdx = ci; capWords.forEach(e => e.remove()); capOnW.forEach(e => e.remove()); capWords = c.toks.map(k => { const e = h('span', 'cw', {}, k.d); capLine.insertBefore(e, capOn); return e; }); capOnW = c.toks.map(k => { const e = h('span', 'cw', {}, k.d); capOn.appendChild(e); return e; }); capNatW = capLine.offsetWidth; }
      const col = capTheme(th), grow = CAP.grow && t >= CAP.grow.t ? lerp(1, CAP.grow.scale, back((t - CAP.grow.t) / 0.35, 2)) : 1;
      capRow.style.top = px(SAFE.capBottom - capLine.offsetHeight); capLine.style.transform = `scale(${Math.min(grow, SAFE.capW / Math.max(1, capNatW)).toFixed(3)})`;
      let a = -1; c.toks.forEach((k, i) => { if (t >= k.t0 - 0.02) a = i; });
      capWords.forEach((e, i) => { e.style.color = i <= a ? col.past : col.fut; e.style.textShadow = col.sh; }); capOn.style.color = col.on; capOn.style.background = col.box;
      const ent = eo((t - c.t0) / 0.14); capLine.style.opacity = ent.toFixed(3); capLine.style.filter = ent < 0.95 ? `blur(${((1 - ent) * 6).toFixed(1)}px)` : 'none';
      if (a < 0) { capBox.style.opacity = 0; capOn.style.display = 'none'; return; }
      capOn.style.display = 'block';
      const r = i => ({ l: capWords[i].offsetLeft, w: capWords[i].offsetWidth }), cur = r(a), prev = a > 0 ? r(a - 1) : cur, u = clamp((t - c.toks[a].t0 + 0.02) / 0.12);
      const L1 = lerp(prev.l, cur.l, eo(u * 0.85)), R1 = lerp(prev.l + prev.w, cur.l + cur.w, eo(u * 1.4));   // right edge leads, left edge follows
      Object.assign(capBox.style, { opacity: 1, background: col.box, left: px(L1), width: px(Math.max(10, R1 - L1)), top: '6px', height: 'calc(100% - 10px)' });
      capOn.style.clipPath = `inset(6px ${(capLine.offsetWidth - Math.max(L1 + 10, R1)).toFixed(1)}px 4px ${L1.toFixed(1)}px round 10px)`; capOn.style.height = px(capLine.offsetHeight);
    };

    // ---------- pixel wipe (fully terracotta at tc) and dome (a circle rising from below, under the captions) ----------
    const CELL = 60, DIN = 0.27, DOUT = 0.32;
    const drawWipe = t => {
      wipeCtx.clearRect(0, 0, W, H); const wp = WIPES.find(([tc]) => t >= tc - DIN && t < tc + DOUT); if (!wp) return;
      const [tc, dir] = wp, cover = t < tc, p = cover ? (t - (tc - DIN)) / DIN : (t - tc) / DOUT, r = rng(Math.round(tc * 100));
      for (let j = 0; j < H / CELL; j++) for (let i = 0; i < W / CELL; i++) {
        let d = (i * CELL / W + j * CELL / H) / 2; if (dir < 0) d = 1 - d; d = clamp(d + (r() - 0.5) * 0.08);
        let k = clamp((p - d * 0.55) / 0.45); if (!cover) k = 1 - k; if (k <= 0.01) continue;
        const s = k >= 0.98 ? CELL + 1 : k * (CELL - 8), x = i * CELL + (CELL - s) / 2, y = j * CELL + (CELL - s) / 2;
        wipeCtx.fillStyle = k < 0.98 && (i + j) % 3 === 0 ? '#e08a70' : ACC; wipeCtx.fillRect(x, y, s, s);
        if (k < 0.98 && k > 0.2) { wipeCtx.strokeStyle = 'rgba(120,40,20,0.45)'; wipeCtx.lineWidth = 2; wipeCtx.strokeRect(x + 1, y + 1, s - 2, s - 2); }
      }
    };
    const DOMEC = { dark: COL.NIGHT, light: COL.PAPER, flood: COL.FLOOD };
    const drawDomes = t => {
      domeCtx.clearRect(0, 0, W, H); const d = DOMES.find(x => t >= x.tc - x.dur && t < x.tc + 0.14); if (!d) return;
      const p = eio((t - (d.tc - d.dur)) / d.dur), fade = t > d.tc ? 1 - (t - d.tc) / 0.14 : 1, cy = H + 240, R = lerp(120, 2260, p);
      domeCtx.save(); domeCtx.globalAlpha = clamp(fade); domeCtx.beginPath(); domeCtx.arc(540, cy, R, 0, PI * 2); domeCtx.fillStyle = DOMEC[d.kind]; domeCtx.fill();
      if (d.kind === 'dark') {   // the dark act's own glow + grid, already inside the rising dome
        domeCtx.save(); domeCtx.clip();
        const gr = domeCtx.createRadialGradient(700, 1300, 0, 700, 1300, 900); gr.addColorStop(0, 'rgba(104,44,128,0.55)'); gr.addColorStop(1, 'rgba(104,44,128,0)'); domeCtx.fillStyle = gr; domeCtx.fillRect(0, 0, W, H);
        domeCtx.strokeStyle = 'rgba(255,255,255,0.05)'; domeCtx.lineWidth = 2; domeCtx.beginPath();
        for (let x = -2; x < W; x += 60) { domeCtx.moveTo(x, 0); domeCtx.lineTo(x, H); } for (let y = 18; y < H; y += 60) { domeCtx.moveTo(0, y); domeCtx.lineTo(W, y); } domeCtx.stroke(); domeCtx.restore();
      }
      domeCtx.beginPath(); domeCtx.arc(540, cy, R, 0, PI * 2); domeCtx.lineWidth = 5; domeCtx.strokeStyle = d.kind === 'dark' ? 'rgba(228,138,111,0.75)' : 'rgba(212,113,90,0.6)'; domeCtx.stroke(); domeCtx.restore();
    };

    // ---------- one frame ----------
    const C = { base: hex(COL.PAPER), warm: hex(COL.WARM), moody: hex(COL.NIGHT), flood: hex(COL.FLOOD) };
    const gridCss = (el, col) => { if (el._c === col) return; el._c = col; el.style.backgroundImage = `linear-gradient(${col} 2px, transparent 2px), linear-gradient(90deg, ${col} 2px, transparent 2px)`; };
    const setT = t => {
      const th = theme(t), dk = th.moody > 0.5;
      let c = mixA(C.base, C.warm, th.warm); c = mixA(c, C.moody, th.moody); if (th.flood) c = C.flood; bg.style.background = rgb(c);
      BLOBS.forEach((b, i) => {
        let [col, al] = BLOBC.base[i];
        for (const [k, set] of [[th.warm, BLOBC.warm], [th.moody, BLOBC.moody]]) if (k > 0) { col = mixA(col, set[i][0], k); al = lerp(al, set[i][1], k); }
        if (th.flood) [col, al] = BLOBC.flood[i];
        const x = b.x + Math.sin(t * b.sp + b.ph) * b.ax - b.r, y = b.y + Math.cos(t * b.sp * 0.8 + b.ph) * b.ay - b.r;
        b.e.style.background = `radial-gradient(circle, ${rgb(col, al)} 0%, ${rgb(col, al * 0.5)} 36%, ${rgb(col, 0)} 70%)`; b.e.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px)`;
      });
      blobL.style.opacity = (1 - th.cover).toFixed(3); glowW.style.opacity = (th.warm * (1 - th.cover) * 0.55).toFixed(3);
      gridCss(gridD, th.flood ? 'rgba(255,255,255,0.10)' : dk ? 'rgba(255,255,255,0.05)' : th.warm > 0.5 ? 'rgba(120,70,30,0.075)' : 'rgba(70,52,44,0.07)');
      gridCss(gridW, th.flood ? 'rgba(255,255,255,0.12)' : dk ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.6)');
      gridD.style.opacity = gridW.style.opacity = (1 - th.cover).toFixed(3);
      amb.clearRect(0, 0, W, H);
      if (!th.cover && dk) for (const a of STARS) {   // the dark act: slow warm twinkling stars
        const y = ((a.y - t * a.v) % (H + 40) + H + 40) % (H + 40) - 20, al = a.a * (0.55 + 0.45 * Math.sin(t * a.tw + a.ph)) * clamp((th.moody - 0.5) * 2); if (al < 0.03) continue;
        amb.fillStyle = `rgba(255,170,140,${(al * 0.18).toFixed(3)})`; amb.beginPath(); amb.arc(a.x, y, a.s * 3.2, 0, PI * 2); amb.fill();
        amb.fillStyle = `rgba(255,214,196,${al.toFixed(3)})`; amb.beginPath(); amb.arc(a.x, y, a.s, 0, PI * 2); amb.fill();
      } else if (!th.cover) {   // paper acts: outlined squares, circles, x's and dots drifting up
        const ac = th.flood ? [255, 255, 255] : mixA([168, 140, 128], [176, 112, 70], th.warm), am = th.flood ? 0.45 : 0.42; amb.lineWidth = 2.6; amb.lineCap = 'round';
        for (const a of AMB) {
          const y = ((a.y - t * a.v) % (H + 60) + H + 60) % (H + 60) - 30, r_ = a.s / 2; amb.strokeStyle = amb.fillStyle = rgb(ac, a.a * am * (0.6 + 0.4 * Math.sin(t * 1.4 + a.ph)));
          amb.save(); amb.translate(a.x, y); amb.rotate(a.rot + t * 0.2); amb.beginPath();
          if (a.kind === 'sq') { amb.roundRect(-r_, -r_, a.s, a.s, 5); amb.stroke(); } else if (a.kind === 'ci') { amb.arc(0, 0, r_, 0, PI * 2); amb.stroke(); }
          else if (a.kind === 'x') { amb.moveTo(-r_ * 0.7, -r_ * 0.7); amb.lineTo(r_ * 0.7, r_ * 0.7); amb.moveTo(r_ * 0.7, -r_ * 0.7); amb.lineTo(-r_ * 0.7, r_ * 0.7); amb.stroke(); } else { amb.arc(0, 0, 4.5, 0, PI * 2); amb.fill(); }
          amb.restore();
        }
      }
      for (const s of SCENES) { const on = t >= s.t0 && t < s.t1; s.root.style.display = on ? 'block' : 'none'; if (on && s.upd) s.upd(t); }
      const g = th.moody * 0.14 + th.cover * 0.12 + th.warm * 0.08; grainEl.style.opacity = g.toFixed(3); vign.style.opacity = Math.max(th.moody * 0.75, th.cover * 0.55).toFixed(3);
      if (g > 0.01) { const r = rng(Math.floor(t * 30) + 1), img = grain.createImageData(270, 480), d = img.data; for (let i = 0; i < d.length; i += 4) { d[i] = d[i + 1] = d[i + 2] = r() * 255; d[i + 3] = 255; } grain.putImageData(img, 0, 0); }
      const ink = th.flood || dk ? '#fff' : th.warm > 0.5 ? '#2a190e' : INK, acc = th.flood ? '#fff' : dk ? '#e48a6f' : th.warm > 0.5 ? COL.TERRA : ACC, chip = chipActive(t);
      for (const hd of HL) {
        const vis = t >= hd.t0 - 0.01 && t < Math.min(hd.t1 + (hd.out === 'cut' ? 0 : 0.35), hd.cutAt ?? Infinity); hd.el.style.display = vis ? 'block' : 'none'; if (!vis) continue;
        hd.el.style.color = ink; if (!hd.fixedY) hd.el.style.top = px(chip ? SAFE.headYChip : SAFE.headY);
        hd.words.forEach((x, i) => { x.s.style.color = x.kind.includes('c') ? CL : x.kind.includes('i') ? acc : ink; wordAnim(x.s, t, x.tIn, hd.out === 'cut' ? null : hd.t1, i); });
      }
      for (const c_ of CHIPS) { show(c_.row, t, c_.t0, c_.t1, { from: 0.6, dout: 0.2, kout: 'shrink' }); c_.segs.forEach((s, i) => { s.style.background = i < c_.n - 1 || (i === c_.n - 1 && t > c_.t0 + 0.3) ? ACC : '#e2ddd8'; }); }
      drawCaptions(t, th); drawWipe(t); drawDomes(t);
      let sc = 1, cy = 300;   // cam (held jump cut) × punch (push-in that settles), around x 510 · y 300
      for (const c_ of CAMS) if (t >= c_.t0 && t < c_.t1) { sc *= c_.s; cy = c_.cy; }
      for (const p_ of PUNCH) { const u = t - p_.t0; if (u >= 0 && u < p_.dur) sc *= 1 + p_.amp * (u < 0.05 ? u / 0.05 : 1 - eo((u - 0.05) / (p_.dur - 0.05))); }
      world.style.transformOrigin = `${SAFE.cx}px ${cy}px`; world.style.transform = Math.abs(sc - 1) > 0.0005 ? `scale(${sc.toFixed(4)})` : 'none';
      if (safeL) safeMark();
    };

    // ---------- fit headlines into the column, centre the big ones, and never let two overprint ----------
    const fitHeadlines = () => {
      for (const hd of HL) {
        hd.el.style.display = 'block';
        const wmax = Math.max(...hd.lns.map(l => l.scrollWidth)), room = SAFE.x1 - SAFE.x0 - 40;   // 20 px each side: the slot-in skew needs it
        if (wmax > room) hd.el.style.fontSize = px(Math.floor(hd.size * room / wmax));
        if (hd.centerY) { hd.el.style.top = px(hd.y - hd.el.offsetHeight / 2); hd.fixedY = true; }
        const top = hd.fixedY ? parseFloat(hd.el.style.top) : SAFE.headY; hd.yr = [top, (hd.fixedY ? top : SAFE.headYChip) + hd.el.offsetHeight];
        hd.el.style.display = 'none';
      }
      const byT = [...HL].sort((a, b) => a.t0 - b.t0);
      byT.forEach((a, i) => { for (const b of byT.slice(i + 1)) { if (a.yr[1] <= b.yr[0] || b.yr[1] <= a.yr[0]) continue;
        if (a.t1 + (a.out === 'cut' ? 0 : 0.35) > b.t0) { a.cutAt = Math.min(a.cutAt ?? Infinity, b.t0 - 0.01); if (a.t1 > b.t0 + 0.02) warn(`headline "${a.el.textContent.trim().slice(0, 30)}" holds to ${a.t1.toFixed(2)} s, past the next one at ${b.t0.toFixed(2)} s: cut there`); }
        break; } });
    };

    // ---------- safe zone: overlay (?safe=1) + a report of every visible text run outside it ----------
    let safeL = null, safeBad = null;
    if (/[?&]safe=1/.test(location.search)) {
      safeL = h('div', 'L', { pointerEvents: 'none', zIndex: 50 }); film.appendChild(safeL);
      const band = (x, y, w_, hh, c_) => A(safeL, x, y, w_, hh, '', '', { background: c_ });
      band(0, 0, W, SAFE.y0, 'rgba(229,72,77,0.28)'); band(0, SAFE.y1, W, H - SAFE.y1, 'rgba(229,72,77,0.28)'); band(SAFE.x1, SAFE.y0, W - SAFE.x1, SAFE.y1 - SAFE.y0, 'rgba(229,72,77,0.28)'); band(0, SAFE.y0, SAFE.x0, SAFE.y1 - SAFE.y0, 'rgba(229,72,77,0.12)');
      A(safeL, SAFE.x0, SAFE.y0, SAFE.x1 - SAFE.x0, SAFE.y1 - SAFE.y0, '', '', { outline: '3px dashed rgba(31,163,91,0.95)' });
      A(safeL, SAFE.cx - SAFE.capW / 2, 1150, SAFE.capW, SAFE.y1 - 1150, '', '', { outline: '3px dashed rgba(22,98,214,0.95)' });
      safeBad = A(safeL, 0, 0, W, H);
    }
    const safeReport = () => {
      const bad = [], tw = document.createTreeWalker(film, NodeFilter.SHOW_TEXT);
      const visible = el => { let op = 1; for (let e = el; e && e !== film; e = e.parentElement) { if (e === safeL || e.dataset?.free) return false; const cs = getComputedStyle(e); if (cs.display === 'none' || cs.visibility === 'hidden') return false; op *= +cs.opacity; } return op > 0.15; };
      for (let n; (n = tw.nextNode());) {
        if (!n.textContent.trim() || !visible(n.parentElement)) continue; const rg = document.createRange(); rg.selectNodeContents(n);
        for (const b of rg.getClientRects()) if (b.width >= 2 && b.height >= 2 && (b.left < SAFE.x0 - 2 || b.right > SAFE.x1 + 2 || b.top < SAFE.y0 - 2 || b.bottom > SAFE.y1 + 2))
          bad.push({ text: n.textContent.trim().slice(0, 40), x0: Math.round(b.left), y0: Math.round(b.top), x1: Math.round(b.right), y1: Math.round(b.bottom) });
      }
      return bad;
    };
    // content check: how many things a viewer sees besides the stage and the caption (0 = an empty frame)
    const content = () => { let n = 0;   // elements that paint something (fill, border, shadow, text, svg/img/canvas), visible and >= 6000 px²
      const paints = (e, cs) => cs.backgroundColor !== 'rgba(0, 0, 0, 0)' || cs.backgroundImage !== 'none' || parseFloat(cs.borderTopWidth) > 0 || cs.boxShadow !== 'none' || /^(svg|img|canvas)$/i.test(e.tagName) || [...e.childNodes].some(c => c.nodeType === 3 && c.textContent.trim());
      for (const sc of SCENES) { if (sc.root.style.display !== 'block') continue; for (const e of sc.root.querySelectorAll('*')) {
        const r = e.getBoundingClientRect(); if (r.width * r.height < 6000 || r.right < 0 || r.left > W || r.bottom < 0 || r.top > H) continue;
        const cs = getComputedStyle(e); if (!paints(e, cs)) continue;
        let op = 1; for (let x = e; x && x !== film && op > 0.12; x = x.parentElement) { const c = x === e ? cs : getComputedStyle(x); op *= c.display === 'none' || c.visibility === 'hidden' ? 0 : +c.opacity; } if (op > 0.12) n++; } }
      for (const hd of HL) if (hd.el.style.display === 'block') for (const x of hd.words) if (+x.s.style.opacity > 0.12) n++; return n; };
    const safeMark = () => { safeBad.innerHTML = ''; for (const b of safeReport()) A(safeBad, b.x0, b.y0, b.x1 - b.x0, b.y1 - b.y0, '', '', { outline: '4px solid #ff0040', background: 'rgba(255,0,64,0.15)' }); };

    window.setT = setT; window.safeReport = safeReport; window.content = content; window.TOTAL = TL.total;
    window.META = { title: E.title, total: TL.total, coverAt: E.coverAt ?? Math.min(2.5, TL.total / 2), music: MUSIC, sfx: CUES, warn: WARN,
      busy: [...WIPES.map(x => x[0]), ...DOMES.map(d => d.tc - d.dur / 2)], sections: TL.sections.map(s => [s.id, s.start, s.end]) };
    const fonts = ['800 62px "Inter Tight"', '760 92px "Inter Tight"', 'italic 100px "Bodoni MT Condensed"', 'italic 100px "Clay Serif"', '900 100px "Bodoni Moda"', '700 31px Inconsolata', '24px Kalam'];
    const imgs = [...film.querySelectorAll('img')].map(i => i.decode().catch(() => console.error('missing image ' + i.src)));   // scenes' <img> must be loaded before frame 0
    window.ready = Promise.all([...fonts.map(f => document.fonts.load(f)), ...imgs]).then(() => document.fonts.ready).then(() => { fitHeadlines(); setT(0); return true; });
  }
  window.CLAY = { boot };
})();
```

### What `scenes.js` gets (`window.VIDEO = E => { ... }`, called once)

| | |
|---|---|
| **Clock** | `S.s4.start / .end` (a section's bounds) · `T(id, word, n)` when the n-th match of a word starts, `TE(...)` when it ends (case and punctuation are ignored; `'style guide'` finds a `*style_guide*` token; **pass n for repeated words**, the first match is n = 0) · `mid('s5', 's6')` the middle of the gap between two sections (cut acts there) · `TL.total` |
| **Scenes** | `scene(t0, t1, root => { build DOM; return t => { update } })`: the root is shown only inside [t0, t1); the update runs every frame |
| **Layout** | `A(parent, x, y, w, h, cls, html, style)` an absolutely placed box · `center(parent, y, html, cls, style)` a row centred on x 510 (returns `{ row, e }`) · `h(tag, cls, style, html)` · classes `card dcard chip tag pill st mono serif hand mag` |
| **Motion** | `show(el, t, t0, t1, { kin, kout, din, dout, from, dy, dx, base, blur, cap })` returns the opacity (kin: pop / rise / drop / slam / fade; kout: up / down / shrink) · `st(el, opacity, transform, blur)` · `typed(el, text, t, t0, t1)` returns the characters shown · `caretOn(t)` · easings `ease eo eio back` · `clamp lerp rng hex mixA rgb` |
| **Headlines** | `head(lines, t1, { y, size, centerY, out })` (out: `'anim'` or `'cut'`); a word is `w(id, word, n, kind, dt)` (on its spoken time, shifted by dt) or `wt(text, kind, t)`; kinds `''` sans, `'i'` serif-italic key word, `'c'` AI orange, `'m'`/`'mi'` magazine. Big centred lines: `{ y: 860, size: 110-120, centerY: true }` |
| **Components** | `stepChip(label, n, t0, t1, { of: 3, acc })` · `prompt(root, y, { x, w, av })` (av: `'ai'` or `'user'`) -> `{ row, tx, car, send, field, av }` · `stamp(root, y, html, { t0, t1, color, bg, rot, size, from })` -> `{ row, e, update(t) }` · icons `SPARK SEND DOC CHECK BUBBLE MAIL` (functions of size) |
| **Acts + transitions** | `act(kind, t0, t1, fade)` (flood / moody / warm / cover) · `dome(tc, kind, dur)` (dark / light / flood) · `wipe(tc, ±1)` · `cam(t0, t1, scale)` · `punch(t, amp, dur)` |
| **Captions** | automatic from the script's chunks · `capHuge(t0, t1, sectionId, /bigWord/)` · `capGrow(t, 1.3)` |
| **Sound + meta** | `cue(name, t, db, len, offset)` a sound effect from `sfx/<name>.wav` at film time t, `db` relative to that sound's default level · `E.music({ file, under, fadeOut, beds: [{ at, from, until, fadeIn, fadeOut }], muffle: [[a, b]] })` · `E.title` · `E.coverAt` (the frame that becomes cover.jpg) · `E.warn(msg)` · colours `ACC INK PAPER RED GREEN PEACH CHIP CL LILAC NIGHT FLOOD ...` |

---

## 8. Writing `scenes.js`

**The edit follows the words.** Read the script sentence by sentence and give each one its literal visual, entering on its word:

| The voice says | The video does |
|---|---|
| a claim ("sounds like everyone else") | panels that show it + a stamp that slams on the key word ("GENERIC") |
| a count ("in three steps") | hard cut to the flood, the giant numeral slams on "three", the steps pop in as chips |
| "One. Paste ..." | a chip `/paste` with 1/3 filled, the things being pasted rise in on "five" |
| "Ask it: what makes my writing mine?" | the prompt bar types exactly that, on those words; send presses as the sentence ends |
| "It writes you a style guide" | a card titled `style-guide.md` rises on "style guide", its rows appear on "hooks", "rhythm", "never" |
| "But here's the part nobody does." | a dark dome rises, the act turns dark, the headline sits big in the middle, the captions go huge |
| "After a month" | a jump cut 4 % closer, a counter runs up |
| "Comment VOICE" | COMMENT and VOICE stickers slam, the bar types VOICE, a toast brings the reward |

Rules that keep it from looking buggy:
1. **Every time comes from the words** (`T`, `TE`, `S`, `mid`), usually 0.03-0.15 s before the word. Never hard-code seconds: the real voice moves everything.
2. **Never an empty frame.** An outgoing panel leaves **when the next one arrives** (its exit `t1` = the next element's `t0`), not before. The scan reports frames that show only background + caption.
3. **Headlines never overprint.** A headline ends where the next one at the same height starts (`t1` = the next one's first word); use `out: 'cut'` before cuts, domes and act changes. The engine cuts any overlap and prints `ENGINE WARNING` (usually a repeated word looked up with the wrong n).
4. **Frame 0 is finished**: the first panel enters at t0 = -0.34 and the first headline word gets `dt` = -0.4. Set `E.coverAt` to the moment the hook is complete (headline + stamp in).
5. **Layout grid**: chip y 262; headline y 282 (378 under a chip), 1-2 lines; panels between ~y 520 and ~1330 inside x 90-950 (cards 780-860 wide); captions below. Big centred dark-act lines at y 860.
6. **One idea on screen at a time.** Exit what the voice is done with. Max ~3 elements moving at once.
7. Text-free shapes may overshoot during a slam; text never leaves the zone (`--scan`).
8. **Sound on the film clock**: `pop` for chips and cards, `click` for rows, ticks and send, `typing` under typed text (with its length), `swish`/`whoosh` for movement and domes (the engine adds one per dome and wipe), `impact` for stamps, numerals and act cuts, `riser` into a reveal, `chime` for a reward or a check, `glitch` for wipes, `implode` when the music sucks out.

The worked example (the script from section 4). It uses every main component; copy its patterns, not its content:

```js
// scenes.js: everything this film shows, on the voice's clock. The engine calls window.VIDEO(E) once.
// Layout: text inside x 60-960 × y 250-1480 (centre x 510); panels end by y ~1330, captions sit at ~1360-1466.
window.VIDEO = E => {
  const { S, T, TE, mid, clamp, lerp, eo, eio, back, A, h, st, show, typed, caretOn, cue, scene, act, center, dome, cam, punch,
    prompt, stamp, stepChip, head, w, wt, ACC, INK, RED, GREEN, PEACH, CHIP, DOC, CHECK, BUBBLE, MAIL, PI } = E;
  E.title = 'Make your AI sound like you';

  // ---------- small parts ----------
  const tile = (s, inner, bg = ACC) => `<div style="width:${s}px;height:${s}px;border-radius:${Math.round(s * 0.25)}px;background:${bg};display:flex;align-items:center;justify-content:center;box-shadow:0 18px 40px rgba(150,70,40,0.28), inset 0 -6px 0 rgba(0,0,0,0.08)">${inner}</div>`;
  const bars = ws => ws.map(x => `<i style="display:block;height:14px;width:${x}px;border-radius:7px;background:#efebe6;margin-top:12px"></i>`).join('');
  const post = (p, x, y, wd, text, meta) => A(p, x, y, wd, null, 'card', `<div style="padding:24px 26px"><div style="display:flex;align-items:center;gap:14px"><i style="width:44px;height:44px;border-radius:50%;background:linear-gradient(140deg,#f4b49b,${ACC})"></i><b style="font-size:26px">you</b><span class="mono" style="margin-left:auto;font-size:17px;color:#9b948d">${meta}</span></div><div style="font-size:30px;font-weight:650;margin-top:16px;line-height:1.2">${text}</div>${bars([wd - 120, wd - 200])}</div>`);

  // ---------- skeleton: acts, transitions, key times (all from the words) ----------
  const tFlood = T('s1', 'three') - 0.03;            // "in THREE steps": hard cut into the terracotta flood
  const tOne = T('s2', 'One') - 0.04;                // "One.": hard cut back to paper, on the word
  const tDome = S.s6.start + 0.02;                   // "But here's the part nobody does": the dark dome closes over
  const tLight = S.s8.start - 0.02;                  // "One file.": a paper dome brings the light back
  const tThree = T('s5', 'Three') - 0.04;            // "Three.": the next step takes over on its word
  const tCta = T('cta', 'Comment') - 0.04;           // the CTA takes over on its first word
  act('flood', tFlood, tOne);
  dome(tDome, 'dark', 0.5); act('moody', tDome, tLight);
  dome(tLight, 'light', 0.42);
  cam(T('s7', 'After'), tLight - 0.45, 1.04);         // "After a month": jump cut 4 % closer
  [[tFlood, 0.035], [tOne, 0.025], [T('s8', 'file'), 0.03], [T('s8', 'voice'), 0.03], [T('s8', 'post'), 0.03], [T('cta', 'Comment'), 0.03]].forEach(([t, a]) => punch(t, a));
  E.capHuge(T('s6', 'part'), S.s7.start - 0.08, 's6', /nobody/);   // one huge white word at a time, "nobody" biggest
  E.coverAt = TE('s0', 'everyone else') + 0.35;                   // cover.jpg = the finished hook

  // ===== 1 · HOOK "Your AI sounds like everyone else." + "Here's how to make it sound like you" =====
  scene(-1, tFlood, root => {
    const SAME = 'In today’s fast-paced world, consistency is key…';
    const cards = [[110, 560, -4], [150, 700, 3], [120, 840, -2]].map(([x, y, r], i) => ({ e: post(root, x, y, 780, SAME, '2 likes'), r, t0: -0.34 + i * 0.1 }));
    const stmp = stamp(root, 760, 'GENERIC', { t0: T('s0', 'everyone else') - 0.02 });
    const you = A(root, 150, 1120, 780, 120, 'card', `<div style="display:flex;align-items:center;gap:18px;padding:0 30px;height:120px;font-size:36px;font-weight:750">${tile(64, DOC(36))}<span>Sounds like <span class="serif" style="color:${ACC};font-size:1.25em">you</span></span><span style="margin-left:auto">${CHECK(48)}</span></div>`);
    cue('pop', 0.02, 0); cue('swish', T('s0', 'sounds') - 0.05); cue('impact', T('s0', 'everyone else') - 0.02); cue('pop', T('s1', 'you') - 0.05);
    return t => {
      cards.forEach((c, i) => show(c.e, t, c.t0, T('s1', 'you') - 0.06 + i * 0.04, { kin: 'rise', dy: 90, din: 0.4, base: `rotate(${c.r}deg)`, kout: 'up' }));   // out as the next panel comes in: never an empty frame
      stmp.update(t);
      const tOut = T('s1', 'you') - 0.06; if (t > tOut) st(stmp.row, 1 - eo((t - tOut) / 0.2));     // the stamp leaves with the cards
      show(you, t, T('s1', 'you') - 0.06, null, { kin: 'pop', from: 0.5 });
    };
  });

  // ===== 2 · FLOOD "…in three steps." a giant numeral on rays =====
  scene(tFlood, tOne, root => {
    const R0 = 760, rays = A(root, 540 - R0, 790 - R0, R0 * 2, R0 * 2, '', `<svg width="${R0 * 2}" height="${R0 * 2}" viewBox="${-R0} ${-R0} ${R0 * 2} ${R0 * 2}">${Array.from({ length: 28 }, (_, i) => { const a = i / 28 * PI * 2; return `<line x1="${(Math.cos(a) * 250).toFixed(1)}" y1="${(Math.sin(a) * 250).toFixed(1)}" x2="${(Math.cos(a) * 740).toFixed(1)}" y2="${(Math.sin(a) * 740).toFixed(1)}" stroke="rgba(255,255,255,${i % 2 ? 0.13 : 0.22})" stroke-width="${i % 2 ? 9 : 17}" stroke-linecap="round"/>`; }).join('')}</svg>`);
    const num = A(root, 60, 500, 900, 560, '', '3', { textAlign: 'center', fontSize: '560px', fontWeight: 850, color: '#fff', lineHeight: '560px', letterSpacing: '-0.05em', textShadow: '0 30px 80px rgba(120,30,10,0.3)' });
    const steps = ['/paste', '/ask', '/save'].map((s, i) => A(root, 120 + i * 270, 1110, 240, 76, 'chip tag', s, { justifyContent: 'center' }));
    cue('impact', tFlood); steps.forEach((_, i) => cue('pop', T('s1', 'steps') + i * 0.1));
    return t => {
      show(rays, t, tFlood, null, { kin: 'fade', din: 0.3, base: `rotate(${(t * 9).toFixed(2)}deg)` });
      show(num, t, tFlood - 0.02, null, { kin: 'slam', from: 1.6, din: 0.22, blur: 16 });
      steps.forEach((e, i) => show(e, t, T('s1', 'steps') + i * 0.1, null, { from: 0.5 }));
    };
  });

  // ===== 3 · STEP 1 "Paste in five of your best posts. Not your newest. Your best." =====
  scene(tOne, S.s3.start - 0.05, root => {
    const P = [['The 3 rules I wish I knew at 20', '9.4K'], ['I quit meetings for a month', '7.1K'], ['Nobody reads your bio', '5.8K'], ['Write like you talk', '4.9K'], ['One habit, two years', '4.2K'], ['Morning thoughts', '12']];
    const cards = P.map(([txt, likes], i) => ({ e: post(root, i % 2 ? 515 : 105, 610 + Math.floor(i / 2) * 225, 400, txt, '♥ ' + likes), t0: T('s2', 'five') - 0.05 + i * 0.07, i }));
    const strike = A(cards[5].e, -10, 96, 420, 12, '', '', { background: RED, borderRadius: '6px', transformOrigin: '0 50%' });
    const newest = A(root, 680, 1030, 230, 56, '', '✕ newest', { background: RED, color: '#fff', borderRadius: '999px', fontSize: '28px', fontWeight: 800, textAlign: 'center', lineHeight: '56px' });
    const tNew = T('s2', 'newest'), tBest = T('s2', 'best', 1);
    cards.forEach(c => cue('pop', c.t0, -2)); cue('swish', tNew); cue('chime', tBest);
    return t => {
      cards.forEach(c => {
        show(c.e, t, c.t0, null, { kin: 'rise', dy: 70, din: 0.32 });
        if (c.i < 5 && t >= tBest) c.e.style.boxShadow = `0 0 0 5px ${ACC}, 0 24px 54px rgba(90,56,36,0.16)`; else c.e.style.boxShadow = '';
      });
      const ks = eo((t - tNew) / 0.2); strike.style.transform = `rotate(-8deg) scaleX(${ks.toFixed(3)})`; strike.style.opacity = ks > 0 ? 1 : 0;
      cards[5].e.style.filter = `grayscale(${ks.toFixed(2)})`; if (t > tNew) cards[5].e.style.opacity = (1 - 0.45 * ks).toFixed(2);
      show(newest, t, tNew + 0.1, null, { from: 0.4 });
    };
  });

  // ===== 4 · STEP 2 "Ask it: what makes my writing mine?" + "It writes you a style guide…" =====
  scene(S.s3.start - 0.05, tThree, root => {
    const pr = prompt(root, 600, { av: 'user' });
    const doc = A(root, 90, 770, 860, 520, 'card', `<div class="mono" style="padding:24px 30px 0;font-size:19px;color:#9b948d">style-guide.md</div>`);
    const ROWS = [['Hooks', 'a number, then a promise', 'your'], ['Rhythm', 'short. short. one long line.', 'rhythm'], ['Never', '“delve”, “game-changer”, emojis', 'never']];
    const rows = ROWS.map(([k, v, word], i) => ({ e: A(doc, 30, 92 + i * 136, 800, 116, '', `<div style="display:flex;align-items:center;gap:22px;height:116px;border-top:2px solid #f1ede9"><span style="width:56px;height:56px;border-radius:50%;background:${ACC};color:#fff;font-size:28px;font-weight:800;display:flex;align-items:center;justify-content:center">${i + 1}</span><div><div style="font-size:36px;font-weight:800">${k}</div><div style="font-size:28px;color:#6f6863">${v}</div></div></div>`), t0: T('s4', word) - 0.05 }));
    const TXT = 'what makes my writing mine?', tA = T('s3', 'what'), tB = TE('s3', 'mine') - 0.1, tDoc = T('s4', 'style guide') - 0.1;
    cue('pop', S.s3.start); cue('typing', tA, 0, tB - tA); cue('click', tB + 0.02); cue('whoosh', tDoc - 0.1, -3); rows.forEach(r => cue('click', r.t0));
    return t => {
      show(pr.row, t, S.s3.start - 0.05, null, { kin: 'rise', dy: 50, din: 0.3 });
      const n = typed(pr.tx, TXT, t, tA, tB); pr.car.style.opacity = n < TXT.length || caretOn(t) ? 1 : 0;
      pr.send.style.transform = `scale(${(1 - 0.18 * Math.sin(clamp((t - tB - 0.02) / 0.18) * PI)).toFixed(3)})`;
      show(doc, t, tDoc, null, { kin: 'rise', dy: 90, din: 0.4 });
      rows.forEach(r => show(r.e, t, r.t0, null, { kin: 'rise', dy: 30, din: 0.26 }));
    };
  });

  // ===== 5 · STEP 3 "Save it as a file, and load it every time." =====
  scene(tThree, tDome + 0.02, root => {
    const file = center(root, 560, `${tile(200, DOC(110))}<div style="font-size:44px;font-weight:800;margin-top:22px">voice.md</div>`, '', { display: 'flex', flexDirection: 'column', alignItems: 'center' });
    const DAYS = [['Mon', 'load'], ['Tue', 'every'], ['Wed', 'time']].map(([d, word], i) => ({ e: A(root, 160, 900 + i * 118, 760, 98, 'card', `<div style="display:flex;align-items:center;height:98px;padding:0 30px;font-size:34px;font-weight:750"><span class="mono" style="font-size:20px;color:#9b948d;width:110px">${d}</span>New post<span style="margin-left:auto;display:flex;align-items:center;gap:10px;color:${GREEN};font-size:28px">voice.md loaded ${CHECK(36)}</span></div>`), t0: T('s5', word) - 0.05 }));
    cue('impact', T('s5', 'file') - 0.03, -4); DAYS.forEach(d => cue('pop', d.t0));
    return t => {
      show(file.e, t, T('s5', 'file') - 0.05, null, { kin: 'drop', dy: 160, din: 0.36 });
      DAYS.forEach(d => show(d.e, t, d.t0, null, { kin: 'rise', dy: 40, din: 0.28 }));
    };
  });

  // ===== 6 · DARK ACT "But here's the part nobody does." + "Every time you fix a draft, tell it why…" =====
  scene(tDome - 0.02, tLight + 0.02, root => {
    const card = A(root, 90, 500, 860, 660, 'dcard');
    A(card, 36, 30, 400, null, 'tag', '/voice.md', { color: PEACH });
    const count = A(card, 560, 26, 264, null, '', '', { textAlign: 'right', fontSize: '40px', fontWeight: 850, fontVariantNumeric: 'tabular-nums' });
    const draft = A(card, 36, 110, 788, 120, '', `<div style="font-size:34px;color:rgba(255,255,255,0.55);position:relative;display:inline-block">In today’s fast-paced world…<i class="x" style="position:absolute;left:-6px;right:-6px;top:22px;height:6px;background:${RED};border-radius:3px;transform-origin:0 50%"></i></div><div style="font-size:38px;font-weight:800;margin-top:12px">Three rules I learned the hard way.</div>`);
    const xLine = draft.querySelector('.x');
    const why = A(card, 470, 250, 360, null, 'hand', 'why: too generic, open with a number', { color: PEACH, fontSize: '26px', lineHeight: '1.25', transform: 'rotate(-3deg)' });
    const RULES = ['Open with a number', 'No “in today’s world”', 'End on one short line'].map((r, i) => A(card, 36, 340 + i * 98, 788, 82, '', `<div style="display:flex;align-items:center;gap:18px;height:82px;border-radius:18px;background:rgba(255,255,255,0.06);padding:0 22px;font-size:32px;font-weight:700"><b style="color:${GREEN};font-size:40px">+</b>${r}</div>`));
    const tFix = T('s7', 'fix'), tWhy = T('s7', 'why'), tRule = T('s7', 'rule'), tMonth = T('s7', 'month'), tKnows = T('s7', 'knows');
    const stmp = stamp(root, 1185, 'IT KNOWS YOU', { t0: tKnows - 0.02, size: 84, color: ACC });
    cue('impact', tDome, -2); cue('swish', tFix); cue('pop', tWhy); cue('click', tRule); cue('riser', tMonth - 0.6, 0, 0.7); RULES.forEach((_, i) => i && cue('click', tMonth + 0.2 + (i - 1) * 0.3)); cue('impact', tKnows - 0.02, -3);
    return t => {
      show(card, t, S.s7.start - 0.05, null, { kin: 'rise', dy: 80, din: 0.4 });
      show(draft, t, S.s7.start, null, { kin: 'fade', din: 0.2 });
      xLine.style.transform = `scaleX(${eo((t - tFix) / 0.22).toFixed(3)})`;
      show(why, t, tWhy - 0.05, null, { kin: 'pop', from: 0.8, base: 'rotate(-3deg)' });
      RULES.forEach((r, i) => show(r, t, i ? tMonth + 0.2 + (i - 1) * 0.3 : tRule - 0.05, null, { kin: 'rise', dy: 40, din: 0.26 }));
      const n = Math.round(lerp(1, 27, eio((t - tMonth) / 0.8))); count.innerHTML = `<span class="mono" style="font-size:18px;color:rgba(255,255,255,0.5);margin-right:10px">rules</span>${t < tRule ? 0 : n}`;
      count.style.color = t >= tMonth + 0.8 ? '#e48a6f' : '#fff';
      stmp.update(t);
    };
  });

  // ===== 7 · "One file. Your voice. Every post." back on paper, a punch on every phrase =====
  scene(tLight - 0.02, tCta, root => {
    const file = center(root, 600, `${tile(240, DOC(130))}<div style="font-size:48px;font-weight:800;margin-top:24px">voice.md</div>`, '', { display: 'flex', flexDirection: 'column', alignItems: 'center' });
    const fan = [-14, -5, 5, 14].map((r, i) => A(root, 300 + i * 70, 1010, 220, 260, 'card', `<div style="padding:22px">${bars([150, 170, 110, 160])}<div style="margin-top:22px">${CHECK(40)}</div></div>`, { transformOrigin: '50% 100%' }));
    cue('pop', T('s8', 'file')); cue('pop', T('s8', 'voice')); fan.forEach((_, i) => cue('click', T('s8', 'post') + i * 0.06));
    return t => {
      show(file.e, t, tLight - 0.02, null, { kin: 'pop', from: 0.6 });
      fan.forEach((e, i) => show(e, t, T('s8', 'Every') + i * 0.06, null, { kin: 'rise', dy: 120, din: 0.3, base: `rotate(${[-14, -5, 5, 14][i]}deg)` }));
    };
  });

  // ===== 8 · CTA "Comment VOICE and I'll send you the exact prompt." =====
  scene(tCta, E.TL.total + 1, root => {
    const com = center(root, 430, `${BUBBLE(80)}<span>COMMENT</span>`, '', { display: 'flex', alignItems: 'center', gap: '22px', background: CHIP, color: '#fff', borderRadius: '22px', padding: '14px 46px 14px 36px', fontSize: '100px', fontWeight: 900, boxShadow: '0 20px 40px rgba(0,0,0,0.25)' });
    const kw = center(root, 590, 'VOICE', '', { background: ACC, color: '#fff', borderRadius: '26px', padding: '6px 56px', fontSize: '140px', fontWeight: 900, boxShadow: '0 24px 50px rgba(212,113,90,0.42)' });
    const bar = prompt(root, 860, { av: 'user' });
    const toast = A(root, 90, 1020, 860, 150, 'card', `<div style="position:absolute;left:24px;top:28px;width:94px;height:94px;border-radius:24px;background:${CHIP};display:flex;align-items:center;justify-content:center">${MAIL(52)}</div><div style="position:absolute;left:144px;top:30px;font-size:24px;color:#9b948d;font-weight:600">New message</div><div style="position:absolute;left:144px;top:66px;font-size:42px;font-weight:800;white-space:nowrap">Here’s the exact prompt</div>`);
    const tCom = T('cta', 'Comment'), tKw = T('cta', 'VOICE'), tTy0 = tKw + 0.05, tTy1 = tKw + 0.4, tSend = T('cta', 'send');
    cue('impact', tCom, -2); cue('impact', tKw); cue('typing', tTy0, 0, tTy1 - tTy0); cue('click', tTy1 + 0.08); cue('chime', tSend + 0.05);
    return t => {
      show(com.e, t, tCom - 0.02, null, { kin: 'slam', from: 1.25, din: 0.2, base: 'rotate(-4deg)' });
      show(kw.e, t, tKw - 0.02, null, { kin: 'slam', from: 1.15, din: 0.2, base: 'rotate(2deg)' });
      show(bar.row, t, tCom + 0.12, null, { kin: 'rise', dy: 60, din: 0.3 });
      const n = typed(bar.tx, 'VOICE', t, tTy0, tTy1); bar.car.style.opacity = n < 5 || caretOn(t) ? 1 : 0;
      bar.send.style.transform = `scale(${(1 - 0.18 * Math.sin(clamp((t - tTy1 - 0.08) / 0.18) * PI)).toFixed(3)})`;
      show(toast, t, tSend - 0.08, null, { kin: 'rise', dy: 70, din: 0.36 });
    };
  });

  // ---------- headlines: word by word on the voice; 'i' = terracotta serif-italic key word ----------
  head([[w('s0', 'Your', 0, '', -0.4), w('s0', 'AI')], [w('s0', 'sounds'), w('s0', 'like'), w('s0', 'everyone else.', 0, 'i')]], T('s1', 'make'));
  head([[w('s1', 'Make'), w('s1', 'it'), w('s1', 'sound'), w('s1', 'like'), w('s1', 'you.', 0, 'i')]], tFlood, { out: 'cut' });
  head([[w('s1', 'Three', 0, 'i'), w('s1', 'steps.')]], tOne, { out: 'cut' });
  head([[w('s2', 'One.', 0, 'i', -0.04)]], T('s2', 'Paste'));
  head([[w('s2', 'Paste'), w('s2', 'in'), w('s2', 'five')], [w('s2', 'of'), w('s2', 'your'), w('s2', 'best', 0, 'i'), w('s2', 'posts.')]], S.s3.start - 0.05, { out: 'cut' });
  head([[w('s3', 'What'), w('s3', 'makes'), w('s3', 'my'), w('s3', 'writing'), w('s3', 'mine?', 0, 'i')]], T('s4', 'a'));
  head([[w('s4', 'A'), wt('style guide.', 'i', T('s4', 'style guide'))]], tThree, { out: 'cut' });
  head([[w('s5', 'Three.', 0, 'i', -0.04)]], T('s5', 'Save'));
  head([[w('s5', 'Save'), w('s5', 'it'), w('s5', 'as'), w('s5', 'a'), w('s5', 'file.', 0, 'i')]], T('s5', 'and'));
  head([[w('s5', 'Load'), w('s5', 'it', 1), w('s5', 'every', 0, 'i'), w('s5', 'time.')]], tDome - 0.25, { out: 'cut' });
  head([[w('s6', 'But'), w('s6', "here's"), w('s6', 'the'), w('s6', 'part')], [w('s6', 'nobody', 0, 'i'), w('s6', 'does.')]], S.s7.start - 0.05, { y: 860, size: 116, centerY: true, out: 'cut' });
  head([[w('s7', 'Tell'), w('s7', 'it'), w('s7', 'why.', 0, 'i')]], T('s7', 'After'));
  head([[w('s7', 'It', 1), w('s7', 'knows', 0, 'i'), w('s7', 'you.', 1)]], tLight - 0.05, { out: 'cut' });
  head([[w('s8', 'One'), w('s8', 'file.', 0, 'i')]], T('s8', 'Your'), { out: 'cut' });
  head([[w('s8', 'Your'), w('s8', 'voice.', 0, 'i')]], T('s8', 'Every'), { out: 'cut' });
  head([[w('s8', 'Every'), w('s8', 'post.', 0, 'i')]], tCta, { out: 'cut' });

  // ---------- slash-command tags with a 1-2-3 segment bar ----------
  stepChip('/paste', 1, tOne, S.s3.start - 0.1);
  stepChip('/ask', 2, S.s3.start - 0.05, tThree - 0.05);
  stepChip('/save', 3, tThree, tDome - 0.25);

  // ---------- music: one bed, low-passed while the act is dark ----------
  E.music({ file: 'music/bed.mp3', under: 13, fadeOut: 1.6, beds: [{ at: 0, from: 0 }], muffle: [[tDome - 0.15, tLight]] });
};
```

Two more recipes the format uses (a state pill that flips on its word, a hand-drawn ring or underline):

```js
// inside window.VIDEO = E => { … }, with A, eio, back, ACC, RED from E
// a state pill (.st) that flips Manual -> Done ✓ on its word: squashes through zero height, then back-eases open
const flip = (pill, t, tf, on = 'Done ✓', off = 'Manual') => {
  const isOn = t >= tf; pill.textContent = isOn ? on : off;
  pill.style.background = isOn ? ACC : '#efebe6'; pill.style.color = isOn ? '#fff' : '#9b948d';
  const sy = t < tf - 0.09 ? 1 : t < tf ? (tf - t) / 0.09 : back((t - tf) / 0.18, 2);
  pill.style.transform = `scaleY(${Math.max(0.04, sy).toFixed(3)})`;
};
// a hand-drawn stroke (an SVG path: a circle around something, an underline, a strike) that draws itself from t0
const scribble = (p, x, y, w, h, d, col = RED, sw = 9) => {
  const e = A(p, x, y, w, h, '', `<svg width="${w}" height="${h}" style="overflow:visible"><path d="${d}" fill="none" stroke="${col}" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round"/></svg>`);
  const path = e.querySelector('path'); let L = 0;
  return (t, t0, dur = 0.35) => { if (!L) L = path.getTotalLength() || 1; path.style.strokeDasharray = L; path.style.strokeDashoffset = (L * (1 - eio((t - t0) / dur))).toFixed(1); e.style.opacity = t < t0 ? 0 : 1; };
};
// usage in a scene: const pill = A(card, 620, 40, null, null, 'st', 'Manual');   …in the update: flip(pill, t, T('s1', 'captions'));
//                   const ring = scribble(root, 70, 520, 900, 380, 'M 470 10 C 780 4, 890 80, 870 190 C 850 330, 560 372, 380 366 C 150 358, 18 300, 24 180 C 30 70, 220 14, 480 12');
//                   …in the update: ring(t, T('s9', 'different'), 0.4);
```

---

## 9. Rendering (headless Chrome)

`cdp.mjs` is a dependency-free Chrome DevTools Protocol driver: `--headless=new`, GPU via ANGLE (`d3d11` on Windows, `metal` on macOS, `gl` on Linux), viewport 1080 x 1920 at device scale 1, idle priority for the browser and all its children, re-applied every 5 s, and on Windows a one-thread affinity pin.

```js
// cdp.mjs: a dependency-free headless Chrome/Edge driver (Chrome DevTools Protocol). One browser, idle priority.
import { spawn, execFile } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const CHROME = [process.env.CHROME, 'C:/Program Files/Google/Chrome/Application/chrome.exe', process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, '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.platform === 'win32' ? 'd3d11' : process.platform === 'darwin' ? 'metal' : 'gl';

export async function launch({ width = 1080, height = 1920, port = 9450 } = {}) {
  if (!CHROME) throw new Error('no Chrome or Edge found: install one or set CHROME=<path>');
  try { os.setPriority(os.constants.priority.PRIORITY_LOW); } catch { }
  const udd = fs.mkdtempSync(path.join(os.tmpdir(), 'clay-'));
  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' });
  // keep the browser and ALL its children at idle priority (Chrome raises its GPU process by itself); on Windows pin it to one thread
  const polite = async () => {
    try {
      const v = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json(), b = new WebSocket(v.webSocketDebuggerUrl);
      await new Promise((res, rej) => { b.onopen = res; b.onerror = rej; });
      const info = await new Promise(res => { b.onmessage = e => { const m = JSON.parse(e.data); if (m.id === 1) res(m.result); }; b.send(JSON.stringify({ id: 1, method: 'SystemInfo.getProcessInfo' })); setTimeout(() => res(null), 10000); });
      b.close();
      const pids = [proc.pid, ...((info && info.processInfo) || []).map(p => p.id)];
      for (const id of pids) { try { os.setPriority(id, os.constants.priority.PRIORITY_LOW); } catch { } }
      if (process.platform === 'win32') execFile('powershell', ['-NoProfile', '-NonInteractive', '-Command',
        `$ErrorActionPreference='SilentlyContinue'; $m=[int64](Get-Process -Id ${proc.pid}).ProcessorAffinity; $k=0L; for($i=62;$i -ge 0;$i--){ if($m -band (1L -shl $i)){ $k=1L -shl $i; break } }; foreach($id in @(${pids.join(',')})){ (Get-Process -Id $id).ProcessorAffinity=[IntPtr]$k }`], { windowsHide: true }, () => { });
    } catch { }
  };
  let list; 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 new Promise(r => setTimeout(r, 150)); } }
  if (!list?.length) throw new Error('Chrome DevTools not reachable on port ' + port);
  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, proc,
    async eval(expr) { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error('eval failed: ' + (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 true; } catch { } await new Promise(r => setTimeout(r, 100)); } throw new Error('timeout: ' + expr); },
    async goto(url) { await send('Page.navigate', { url }); await new Promise(r => setTimeout(r, 300)); await api.waitFor('document.readyState === "complete"', 15000); },
    async shot(file, quality = 92) { const r = await send('Page.captureScreenshot', { format: 'jpeg', quality }); fs.writeFileSync(file, Buffer.from(r.data, 'base64')); },
    async gpu() { return api.eval(`(() => { const g = document.createElement('canvas').getContext('webgl'); const e = g && g.getExtension('WEBGL_debug_renderer_info'); return g ? String(g.getParameter(e ? e.UNMASKED_RENDERER_WEBGL : g.RENDERER)) : ''; })()`); },
    async close() {   // wait for Chrome to exit, then delete its temp profile (scripts exit right after this)
      clearInterval(timer); try { ws.close(); } catch { } try { proc.kill(); } catch { }
      await new Promise(r => { if (proc.exitCode !== null) return r(); proc.once('exit', r); setTimeout(r, 4000); });
      for (let i = 0; i < 10; i++) { try { fs.rmSync(udd, { recursive: true, force: true }); break; } catch { await new Promise(r => setTimeout(r, 300)); } } } };
  return api;
}
```

`render.mjs` serves the folder on 127.0.0.1 (no cache), opens `edit.html` in **one** browser, waits for `window.ready`, prints page errors and `ENGINE WARNING`s, then:
- `--stills [--at auto|t1,t2] [--n 8] [--safe]`: stills + a contact sheet built in the same Chrome. Always include frame 0, each act and transition, the turn and the CTA. **Open the sheet and look at every frame**: is the key word a condensed serif italic (not Times)? Are panels clear of the captions? Anything cut off, overlapping, ghosting through a stamp, or empty?
- `--scan [--step 0.0333]`: `safeReport()` and `content()` at every frame: text outside the zone, and frames with nothing but background + caption. Both must be clean.
- `--full [--from s] [--to s]`: `frames/f00000.jpg ...`, JPEG q92; skips frames that exist (resumable). Expect 0.1-0.3 s per frame (a 45 s film = 1,350 frames, ~2.5-7 minutes). Render in chunks that finish well inside your tool timeout.
- `--cues`: only `cues.json` (what `mix.mjs` and `encode.mjs` read; `--full` writes it too).

```js
// render.mjs: the page in ONE headless Chrome, at idle priority.
//   node render.mjs --stills [--at auto|t1,t2,…] [--n 8] [--safe]   -> stills/<t>.jpg + stills/sheet.jpg (contact sheet)
//   node render.mjs --scan [--step 0.0333]                           -> every visible text run outside the safe zone
//   node render.mjs --full [--from 0] [--to 20]                      -> frames/f00000.jpg … (resumable chunks) + cues.json
//   node render.mjs --cues                                           -> cues.json only (what mix.mjs and encode.mjs read)
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { V, DIR, FPS, arg, has, low, need } from './lib.mjs';
import { launch } from './cdp.mjs';
low(); need(V('timeline.js'), 'run build-timeline.mjs'); need(V('scenes.js'), 'write scenes.js');
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.woff2': 'font/woff2', '.jpg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.svg': 'image/svg+xml' };
const server = http.createServer((req, res) => {   // serve the video folder on 127.0.0.1 only, no cache
  const f = path.join(DIR, decodeURIComponent(req.url.split('?')[0]));
  if (!f.startsWith(DIR) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { res.writeHead(404); return res.end(); }
  res.writeHead(200, { 'content-type': TYPES[path.extname(f)] || 'application/octet-stream', 'cache-control': 'no-store' }); fs.createReadStream(f).pipe(res);
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}/`;
const page = await launch({ port: 9450 + Math.floor(Math.random() * 40) });
try {
  console.log('renderer:', await page.gpu() || 'unknown', '(software is fine for this 2D format, just slower)');
  await page.goto(base + 'edit.html' + (has('--safe') ? '?safe=1' : ''));
  try { await page.eval('window.ready'); } catch (e) { console.error('page failed:', e.message, page.logs.slice(-6)); process.exit(1); }
  const errs = page.logs.filter(l => /error|exception/.test(l.type)); if (errs.length) console.log('PAGE ERRORS:', errs.slice(-5));
  const M = await page.eval('window.META');
  for (const m of M.warn) console.log('ENGINE WARNING:', m);
  if (has('--cues') || has('--full')) fs.writeFileSync(V('cues.json'), JSON.stringify(M, null, 1));
  if (has('--stills')) {
    const at = arg('--at', 'auto'), n = +arg('--n', 8), busy = t => M.busy.some(b => Math.abs(t - b) < 0.35);
    const ats = at === 'auto' ? Array.from({ length: n }, (_, i) => { let t = M.total * (i + 0.5) / n; for (let k = 0; busy(t) && k < 20; k++) t += 0.15; return +Math.min(M.total - 0.05, t).toFixed(2); }) : at.split(',').map(Number);
    fs.mkdirSync(V('stills'), { recursive: true }); let bad = 0;
    for (const t of ats) {
      await page.eval(`setT(${t})`); await page.shot(V('stills', `${t}.jpg`), 90);
      if (has('--safe')) { const b = await page.eval('safeReport()'); bad += b.length; if (b.length) console.log(`  ${t}s outside:`, b.slice(0, 3).map(x => `"${x.text}" [${x.x0},${x.y0}-${x.x1},${x.y1}]`).join(' · ')); }
    }
    // contact sheet, built in the same Chrome (no ffmpeg font setup needed)
    const cols = Math.min(4, ats.length), rows = Math.ceil(ats.length / cols), cw = 360, ch = 640;
    fs.writeFileSync(V('stills', '_sheet.html'), `<body style="margin:0;background:#111;display:grid;grid-template-columns:repeat(${cols},${cw}px);gap:6px;padding:6px">` + ats.map(t => `<div style="position:relative"><img src="${t}.jpg" style="width:${cw}px;height:${ch}px;display:block"><b style="position:absolute;left:6px;top:6px;background:#ff0;font:700 20px monospace;padding:2px 8px">${t}s</b></div>`).join('') + '</body>');
    await page.send('Emulation.setDeviceMetricsOverride', { width: cols * (cw + 6) + 6, height: rows * (ch + 6) + 6, deviceScaleFactor: 1, mobile: false });
    await page.goto(base + 'stills/_sheet.html'); await page.waitFor('[...document.images].every(i => i.complete)');
    await page.shot(V('stills', has('--safe') ? 'sheet-safe.jpg' : 'sheet.jpg'), 86); fs.rmSync(V('stills', '_sheet.html'));
    console.log('stills', ats.join(' '), '-> stills/' + (has('--safe') ? 'sheet-safe.jpg' : 'sheet.jpg'), has('--safe') ? (bad ? `SAFE ZONE: ${bad} problem(s)` : 'SAFE ZONE: all text inside') : '');
  }
  if (has('--scan')) {
    const step = +arg('--step', 1 / FPS), hits = [], empty = [];
    for (let t = 0; t < M.total; t += step) { const [b, c] = await page.eval(`(setT(${t.toFixed(4)}), [safeReport(), content()])`); if (b.length) hits.push([t.toFixed(2), b[0]]); if (!c && t > 0.05 && t < M.total - 0.1) empty.push(t.toFixed(2)); }
    hits.slice(0, 12).forEach(([t, b]) => console.log(`  ${t}s "${b.text}" [${b.x0},${b.y0}-${b.x1},${b.y1}]`));
    console.log(hits.length ? `SCAN: text outside the safe zone at ${hits.length} times` : `SCAN: all text inside at all ${Math.ceil(M.total / step)} times`);
    const gaps = []; for (const t of empty.map(Number)) { const g = gaps.at(-1); if (g && t - g[1] < step * 1.5) g[1] = t; else gaps.push([t, t]); }   // a lone blank frame at a hard cut is invisible: report gaps >= 0.1 s
    const long = gaps.filter(([a, b]) => b - a >= 0.1 - 1e-6);
    console.log(long.length ? `EMPTY: ${long.length} gap(s) with only background + caption: ${long.map(([a, b]) => a.toFixed(2) + '-' + b.toFixed(2) + ' s').join(', ')}` : 'EMPTY: none, every frame has a panel or a headline');
  }
  if (has('--full')) {
    const n = Math.round(M.total * FPS), f0 = Math.round(+arg('--from', 0) * FPS), f1 = Math.min(n, Math.round(+arg('--to', M.total) * FPS));
    fs.mkdirSync(V('frames'), { recursive: true }); const t0 = Date.now(); let done = 0;
    for (let i = f0; i < f1; i++) {
      const f = V('frames', `f${String(i).padStart(5, '0')}.jpg`);
      if (fs.existsSync(f) && fs.statSync(f).size > 8000) continue;   // resumable
      await page.eval(`setT(${(i / FPS).toFixed(5)})`); await page.shot(f, 92); done++;
      if (done % 150 === 0) console.log(`${i + 1}/${n} frames · ${((Date.now() - t0) / done).toFixed(0)} ms/frame`);
    }
    const have = fs.readdirSync(V('frames')).filter(x => x.endsWith('.jpg')).length;
    console.log(`frames ${f0}-${f1 - 1} done (${done} new, ${((Date.now() - t0) / 1000).toFixed(0)} s) · ${have}/${n} on disk` + (have < n ? ' · run again with --from ' + (f1 / FPS).toFixed(2) : ' · ALL FRAMES'));
  }
} finally { await page.close(); server.close(); }
process.exit(0);
```

---

## 10. Sound: effects, music, mix

The voice carries the video; the bed gives it pace; small effects make the UI feel physical.

### Sound effects
`make-sfx.mjs` synthesises the ten effects the scenes cue (pop, click, swish, whoosh, impact, riser, typing, chime, glitch, implode) with ffmpeg: free, no API, deterministic. Better-sounding alternatives, same file names in `sfx/` (`.wav` or `.mp3`):
- **ElevenLabs sound effects** (costs credits; generate once, keep the files): `POST https://api.elevenlabs.io/v1/sound-generation` with `{ text, duration_seconds (0.5-30), prompt_influence: 0.5 }` and `xi-api-key`, returns audio bytes. Prompts: pop "soft UI pop, short bubbly click, clean, dry" 0.5 s · click "single crisp mouse click, dry" 0.5 s · swish "fast airy swish transition" 0.6 s · whoosh "smooth cinematic whoosh transition" 1.2 s · impact "deep punchy hit with a short boom tail" 1.5 s · riser "short tension riser, noise and synth building up" 2 s · typing "fast typing on a laptop keyboard, close" 2 s · chime "bright two-note notification chime" 1.5 s · glitch "short digital glitch stutter" 0.5 s · implode "reverse suck-in whoosh that stops abruptly" 1 s.
- A free library (Pixabay sound effects, for example): check each licence.

```js
// make-sfx.mjs: the ten sound effects (and a fallback music bed) synthesised with ffmpeg. Free, no API, deterministic.
//   node make-sfx.mjs          -> sfx/{pop,click,swish,whoosh,impact,riser,typing,chime,glitch,implode}.wav
//   node make-sfx.mjs --bed    -> also music/bed.wav: a soft 112 BPM pad + kick + hats bed (only if you have no real track)
import fs from 'node:fs';
import { V, ff, has } from './lib.mjs';
const SR = 48000, ev = (expr, d) => ['-f', 'lavfi', '-i', `aevalsrc='${expr}':s=${SR}:d=${d}`];
const SFX = {
  pop: [ev('sin(2*PI*(820*t-2600*t*t))*exp(-34*t)', 0.16), 'highpass=f=180'],                                  // soft bloop, pitch falling
  click: [ev('(random(0)*2-1)*exp(-260*t)+0.6*sin(2*PI*2400*t)*exp(-180*t)', 0.06), 'highpass=f=1200'],          // UI tick
  swish: [ev('(random(0)*2-1)*pow(sin(PI*t/0.4),2)', 0.4), 'bandpass=f=3200:t=h:w=2600,highpass=f=900'],             // quick air
  whoosh: [ev('(random(0)*2-1)*pow(sin(PI*t/0.9),3)', 0.9), 'lowpass=f=2200,highpass=f=120,aecho=0.6:0.4:40:0.25'],  // a slower, fuller pass
  impact: [ev('0.9*sin(2*PI*(95*t-38*t*t))*exp(-4.5*t)+0.5*(random(0)*2-1)*exp(-45*t)', 1.4), 'lowpass=f=5000'],   // boom + transient
  riser: [ev('(0.5*sin(2*PI*(180*t+260*t*t))+0.5*(random(0)*2-1))*pow(t/2,2.2)', 2.0), 'highpass=f=250,lowpass=f=7000'],
  typing: [ev('(random(0)*2-1)*exp(-320*mod(t,0.083))*(0.55+0.45*sin(37*t))', 2.0), 'bandpass=f=2600:t=h:w=3000'],   // keys at ~12/s
  chime: [ev('0.55*sin(2*PI*1318.5*t)*exp(-3.6*t)+0.4*sin(2*PI*1975.5*t)*exp(-4.8*t)+0.2*sin(2*PI*2637*t)*exp(-7*t)', 1.6), 'aecho=0.7:0.5:60:0.2'],
  glitch: [ev('sgn(sin(2*PI*160*t))*gt(random(0),0.45)*exp(-9*t)', 0.32), 'acrusher=bits=6:mode=log:aa=1,highpass=f=300'],
  implode: [ev('(random(0)*2-1)*pow(t/0.7,3)', 0.7), 'lowpass=f=1600,highpass=f=100'],                         // a suck-in that stops dead
};
fs.mkdirSync(V('sfx'), { recursive: true });
for (const [name, [src, fx]] of Object.entries(SFX)) ff([...src, '-af', `${fx},alimiter=limit=0.89:level=false,aformat=channel_layouts=mono`, '-ar', String(SR), V('sfx', name + '.wav')]);
console.log('sfx:', Object.keys(SFX).join(' '), '-> sfx/');
if (has('--bed')) {   // 64 s: A minor-ish pad (detuned sines, slow swell) + a soft kick on every beat + offbeat hats, 112 BPM
  const b = 60 / 112, pad = [220, 261.63, 329.63, 392].map((f, i) => `0.12*sin(2*PI*${f}*t+${i})*(0.75+0.25*sin(2*PI*t/${(8 * b).toFixed(4)}+${i}))+0.06*sin(2*PI*${(f * 1.003).toFixed(3)}*t)`).join('+');
  const m = `mod(t,${b.toFixed(5)})`, kick = `0.9*sin(2*PI*(50*${m}+8*(1-exp(-25*${m}))))*exp(-8*${m})`;   // 250 -> 50 Hz thump
  const hat = `0.12*(random(0)*2-1)*exp(-60*mod(t+${(b / 2).toFixed(5)},${b.toFixed(5)}))`;
  fs.mkdirSync(V('music'), { recursive: true });
  ff([...ev(`${pad}+${kick}+${hat}`, 64), '-af', 'lowpass=f=9000,highpass=f=35,alimiter=limit=0.89:level=false', '-ac', '2', '-ar', String(SR), V('music', 'bed.wav')]);
  console.log('bed -> music/bed.wav (set E.music({ file: "music/bed.wav", … }) in scenes.js)');
}
```

### Music
The bed is an **upbeat modern instrumental** (light electronic / pop, clean punchy kick, ~110-120 BPM, bright and confident, no vocals), sitting **13 LU under the voice** and ducked under speech. Ways to get one (no copyrighted tracks):
1. **ElevenLabs music** (if your plan includes it): `POST https://api.elevenlabs.io/v1/music` with `{ prompt, music_length_ms: 60000, force_instrumental: true }`, returns audio. Prompt: "Upbeat modern instrumental bed for a short tech explainer: warm plucky synths, light pop drums with a clean punchy kick, about 112 BPM, bright and confident, steady energy, no vocals, no big drops." Save as `music/bed.mp3`.
2. A royalty-free track you are allowed to use (YouTube Audio Library, Pixabay Music, ...), saved as `music/bed.mp3`.
3. The fallback: `node make-sfx.mjs --bed` writes `music/bed.wav` (a soft pad + kick + hats at 112 BPM). Plain, but it carries the pace. (`mix.mjs` takes `music/bed.mp3` or `music/bed.wav`, whichever exists.)
4. No bed at all: leave `E.music` out; the mix is voice + effects. Some creators add a trending sound in the app instead.

**Cut the bed to the story** with `E.music`: `beds` place pieces of the track on the film clock (`at` = film time, `from` = time in the track; start on a strong bar), `until` stops a piece dead (drop the music on a punchline like "Watch.", bring it back on a kick with `fadeIn: 0.015`), `muffle` low-passes it while the act is dark, `fadeOut` 1.6 s at the end.

### Levels (`mix.mjs`)
- Voice normalised to -24 LUFS, then the **bed at voice - 13 LU**, sidechain-ducked under the voice (threshold 0.05, ratio 3, attack 20 ms, release 320 ms).
- Effects by peak level, with the voice at -24 LUFS: impact -22 dBFS, whoosh -26, swish -27, chime -27, riser -28, typing -32, click -34, implode -34, pop -37, glitch -39. A cue's `db` shifts one hit (e.g. `cue('pop', t, -2)`). Effects are felt, never louder than a word.
- Two-pass `loudnorm` to **-14 LUFS integrated, true peak -1.5**, then a make-up gain into a limiter if the true-peak ceiling held it short. Target: **-14 LUFS (±0.5), true peak ≤ -1 dBTP.**

```js
// mix.mjs: voice + music bed (cut to the story) + sound effects -> mix.wav at -14 LUFS, true peak <= -1 dBTP.
//   needs vo.wav (build-timeline.mjs) and cues.json (render.mjs --cues or --full)
import fs from 'node:fs';
import { spawnSync } from 'node:child_process';
import { V, FF, ff, loudness, peak, need, low } from './lib.mjs';
low(); need(V('vo.wav'), 'run build-timeline.mjs'); need(V('cues.json'), 'run render.mjs --cues');
const C = JSON.parse(fs.readFileSync(V('cues.json'), 'utf8')), DUR = C.total;
const P = { under: 13, fadeOut: 1.6, beds: [{ at: 0, from: 0 }], muffle: [], ...(C.music || {}) };
// sfx peak level (dBFS) with the voice at -24 LUFS; a cue's db shifts its sound from here
const LEVEL = { impact: -22, whoosh: -26, swish: -27, chime: -27, riser: -28, typing: -32, click: -34, implode: -34, pop: -37, glitch: -39 };
const sfxFile = n => ['wav', 'mp3'].map(x => V('sfx', `${n}.${x}`)).find(f => fs.existsSync(f));
const VOICE_I = -24, vo = loudness(V('vo.wav')).I, gv = VOICE_I - vo;
if (!isFinite(vo) || vo < -60) { console.error('vo.wav is silent (estimated takes?): voice the script before mixing'); process.exit(1); }

// 1 · the bed: pieces of the track placed on the film clock, low-passed in the `muffle` windows
let gb = 0; P.file = [P.file, P.file?.replace(/\.\w+$/, '.mp3'), P.file?.replace(/\.\w+$/, '.wav')].find(f => f && fs.existsSync(V(f)));   // .mp3 or .wav
if (P.file) {
  const args = [], fc = [];
  P.beds.forEach((b, i) => {
    const until = b.until ?? DUR, len = Math.max(0.05, until - b.at), fo = b.until == null ? P.fadeOut : (b.fadeOut ?? 0.05);
    args.push('-i', V(P.file));
    fc.push(`[${i}:a]aformat=channel_layouts=stereo,aresample=48000,atrim=start=${b.from}:end=${(b.from + len).toFixed(3)},asetpts=PTS-STARTPTS,afade=t=in:d=${b.fadeIn ?? 0.02},afade=t=out:st=${Math.max(0, len - fo).toFixed(3)}:d=${fo},adelay=delays=${Math.round(b.at * 1000)}:all=1,apad=whole_dur=${DUR}[b${i}]`);
  });
  fc.push(`${P.beds.map((_, i) => `[b${i}]`).join('')}amix=inputs=${P.beds.length}:normalize=0,atrim=0:${DUR}[bed0]`);
  if (P.muffle.length) {
    const M = P.muffle.map(([a, b]) => `clip((t-${a.toFixed(3)})/0.3,0,1)*clip((${b.toFixed(3)}-t)/0.05,0,1)`).join('+');
    fc.push('[bed0]asplit=2[dry][wet]', `[wet]lowpass=f=420,lowpass=f=420,volume=1.9,volume='${M}':eval=frame[w2]`, `[dry]volume='1-(${M})':eval=frame[d2]`, '[d2][w2]amix=inputs=2:normalize=0[bed]');
  } else fc.push('[bed0]anull[bed]');
  ff([...args, '-filter_complex', fc.join(';'), '-map', '[bed]', '-ar', '48000', '-ac', '2', V('bed.wav')]);
  gb = (VOICE_I - P.under) - loudness(V('bed.wav')).I;   // the bed sits `under` LU below the voice, then ducks under speech
} else console.log('no music bed (E.music({ file }) missing or not found): voice + sfx only');

// 2 · voice + bed + sfx
const hasBed = fs.existsSync(V('bed.wav')) && P.file, sfx = (C.sfx || []).filter(([n]) => sfxFile(n) || console.log('no sfx file for', n));
const args = ['-i', V('vo.wav'), ...(hasBed ? ['-i', V('bed.wav')] : [])], o = hasBed ? 2 : 1;
const fc = [`[0:a]volume=${gv.toFixed(2)}dB,aformat=channel_layouts=stereo,aresample=48000,asplit=2[vo][sc]`];
if (hasBed) fc.push(`[1:a]volume=${gb.toFixed(2)}dB[m0]`, '[m0][sc]sidechaincompress=threshold=0.05:ratio=3:attack=20:release=320[m]'); else fc.push('[sc]anullsink');
const pk = {}; sfx.forEach(([n, t, db = 0, len, off = 0], i) => {
  const f = sfxFile(n); pk[n] ??= peak(f); args.push('-i', f);
  const g = (LEVEL[n] ?? -30) + db - pk[n], trim = len ? `atrim=start=${off}:end=${(off + len).toFixed(3)},asetpts=PTS-STARTPTS,afade=t=out:st=${Math.max(0, len - 0.12).toFixed(3)}:d=0.12,` : '';
  fc.push(`[${i + o}:a]aformat=channel_layouts=stereo,aresample=48000,${trim}volume=${g.toFixed(2)}dB,adelay=delays=${Math.round(t * 1000)}:all=1,apad=whole_dur=${DUR}[s${i}]`);
});
fc.push(`[vo]${hasBed ? '[m]' : ''}${sfx.map((_, i) => `[s${i}]`).join('')}amix=inputs=${sfx.length + o}:normalize=0:duration=first,atrim=0:${DUR}[pre]`);
ff([...args, '-filter_complex', fc.join(';'), '-map', '[pre]', '-ar', '48000', '-ac', '2', V('pre.wav')]);

// 3 · two-pass loudnorm to -14 LUFS / -1.5 dBTP, then make up what the true-peak ceiling held back, into a limiter
const p1 = spawnSync(FF, ['-hide_banner', '-i', V('pre.wav'), '-af', 'loudnorm=I=-14:TP=-1.5:LRA=11:print_format=json', '-f', 'null', '-'], { encoding: 'utf8' });
const j = JSON.parse(p1.stderr.slice(p1.stderr.lastIndexOf('{'), p1.stderr.lastIndexOf('}') + 1));
ff(['-i', V('pre.wav'), '-af', `loudnorm=I=-14:TP=-1.5:LRA=11:measured_I=${j.input_i}:measured_TP=${j.input_tp}:measured_LRA=${j.input_lra}:measured_thresh=${j.input_thresh}:offset=${j.target_offset}:linear=true,aresample=48000`, '-ar', '48000', V('norm.wav')]);
let L = loudness(V('norm.wav'));
if (Math.abs(L.I + 14) <= 0.1 && L.TP <= -1) fs.copyFileSync(V('norm.wav'), V('mix.wav'));
else { let gain = -14 - L.I, lim = 0.83;
  for (let k = 0; k < 6; k++) {
    ff(['-i', V('norm.wav'), '-af', `volume=${gain.toFixed(2)}dB,alimiter=limit=${lim.toFixed(3)}:attack=1.5:release=60:level=false:latency=true`, '-ar', '48000', V('mix.wav')]);
    L = loudness(V('mix.wav')); if (L.TP > -1.05) { lim *= 0.95; continue; } if (Math.abs(L.I + 14) <= 0.1) break; gain += -14 - L.I;
  } }
L = loudness(V('mix.wav'));
for (const f of ['bed.wav', 'pre.wav', 'norm.wav']) fs.rmSync(V(f), { force: true });
console.log(`mix.wav · ${L.I} LUFS · true peak ${L.TP} dBTP · ${sfx.length} sfx · bed ${hasBed ? P.under + ' LU under the voice' : 'none'}`);
console.log(Math.abs(L.I + 14) <= 0.5 && L.TP <= -1 ? 'LOUDNESS OK' : 'LOUDNESS OFF TARGET: check the stems');
```

---

## 11. Encode, verify, deliver

`encode.mjs`: **H.264 High, yuv420p, 1080x1920, 30 fps, `-crf 20 -maxrate 10M -bufsize 20M -preset slow`, AAC 192k 48 kHz, `+faststart`**, full-range screenshots converted to TV range. A full encode also writes `cover.jpg` (the `E.coverAt` frame). Then it checks the stream, the length, the loudness, the size (≤ 60 MB; grain and stars blow it up without the maxrate cap) and that frame 0 is not black.

```js
// encode.mjs: frames + mix -> video.mp4 (H.264 High, yuv420p, 1080x1920, 30 fps, AAC 192k, +faststart) + cover.jpg, then the checks.
//   node encode.mjs [--to 6 --out test.mp4]   (a partial encode for a quick end-to-end test)
import fs from 'node:fs';
import { spawnSync } from 'node:child_process';
import { V, FF, FPS, arg, ff, loudness, probe, need, low } from './lib.mjs';
low(); need(V('mix.wav'), 'run mix.mjs'); need(V('cues.json'), 'run render.mjs --cues');
const C = JSON.parse(fs.readFileSync(V('cues.json'), 'utf8')), part = arg('--to') != null;
const dur = part ? Math.min(+arg('--to'), C.total) : C.total, n = Math.round(dur * FPS), out = V(arg('--out', 'video.mp4'));
const frame = i => V('frames', `f${String(i).padStart(5, '0')}.jpg`);
const missing = []; for (let i = 0; i < n; i++) if (!fs.existsSync(frame(i)) || fs.statSync(frame(i)).size < 8000) missing.push(i);
if (missing.length) { console.error(`${missing.length} frame(s) missing or blank (first: ${missing[0]}): run render.mjs --full --from ${(missing[0] / FPS).toFixed(2)}`); process.exit(1); }
ff(['-framerate', String(FPS), '-i', V('frames', 'f%05d.jpg'), '-i', V('mix.wav'), '-t', dur.toFixed(3),
  '-vf', 'scale=1080:1920:flags=lanczos:in_range=pc:out_range=tv,format=yuv420p', '-color_range', 'tv',   // screenshots are full-range JPEGs
  '-c:v', 'libx264', '-preset', 'slow', '-profile:v', 'high', '-pix_fmt', 'yuv420p', '-r', String(FPS), '-crf', '20', '-maxrate', '10M', '-bufsize', '20M',
  '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-movflags', '+faststart', out]);
if (!part) { const ci = Math.min(n - 1, Math.round((C.coverAt ?? 0) * FPS)); fs.copyFileSync(frame(ci), V('cover.jpg')); console.log('cover.jpg = frame', ci); }
const p = probe(out), v = p.streams.find(s => s.codec_type === 'video'), a = p.streams.find(s => s.codec_type === 'audio'), mb = +p.format.size / 1048576, secs = +p.format.duration, L = loudness(out);
const y0 = +(spawnSync(FF, ['-hide_banner', '-i', frame(0), '-vf', 'signalstats,metadata=print', '-f', 'null', '-'], { encoding: 'utf8' }).stderr.match(/YAVG=([\d.]+)/) || [, 0])[1];
console.log(`${out} · ${secs.toFixed(2)} s · ${mb.toFixed(1)} MB · ${v.codec_name} ${v.profile} ${v.width}x${v.height} ${v.pix_fmt} ${v.r_frame_rate} · ${a?.codec_name} · ${L.I} LUFS · TP ${L.TP} dBTP · frame 0 brightness ${y0.toFixed(0)}/255`);
const bad = [];
if (v.width !== 1080 || v.height !== 1920 || v.pix_fmt !== 'yuv420p' || !/High/.test(v.profile) || v.r_frame_rate !== '30/1') bad.push('video stream is not H.264 High yuv420p 1080x1920 30 fps');
if (!a) bad.push('no audio'); if (Math.abs(secs - dur) > 0.1) bad.push(`length ${secs.toFixed(2)} s vs ${dur.toFixed(2)} s`);
if (!part && (Math.abs(L.I + 14) > 0.7 || L.TP > -1)) bad.push('loudness off -14 LUFS / -1 dBTP'); if (y0 < 20) bad.push('frame 0 is (nearly) black');
if (mb > 60) bad.push('bigger than 60 MB'); if (secs > 60.5) bad.push('longer than 60 s');
console.log(bad.length ? 'PROBLEMS: ' + bad.join(' · ') : 'ENCODE OK');
```

Before you say it is done:
- `encode.mjs` says **ENCODE OK**; `render.mjs --scan` said all text inside and **EMPTY: none**.
- Pull ~12 frames from the **final mp4** (`ffmpeg -ss <t> -i video.mp4 -frames:v 1 f<t>.jpg`, or a sheet with `-vf fps=1/3,scale=270:-1,tile=6x2`) and look at them once more: compression, captions, the key-word font, the dark act's stars.
- Frame 0 is a finished picture; `cover.jpg` is the hook, complete.
- The voice says the script: if a local speech-to-text (whisper) is available, transcribe `mix.wav` and diff it against the script; otherwise rely on the alignment word counts from `gen-vo.mjs` and say you could not listen.
- **Deliver** to a normal user folder (`Videos/<project>/<Title>.mp4` + `<Title> - cover.jpg` + a caption text: the hook line, 3-5 short lines that mirror the steps, "Comment KEYWORD and I'll send you ...", a few hashtags). Never overwrite an earlier version (v2, v3). Open it for the user and report: title, length, wpm, what you chose (voice, acts, music), anything you would push further, and honestly any flaw you saw. Don't post it.

---

## 12. Gotchas (the things that look buggy)

1. **Repeated words**: `T('s7', 'you')` is the first "you" in s7. A headline word looked up with the wrong n slots in seconds early; the overlap guard then warns. Pass n (`w('s7', 'you.', 1)`).
2. **Headline overlap**: two headlines at the same height overprint unless the first ends at the next one's first word. Read every `ENGINE WARNING`.
3. **Ghosting**: stamp and sticker backgrounds must be opaque, or the rows behind show through (worst on the cover).
4. **Slams**: from > 1.12 a wide element leaves the zone while it lands. The engine caps text, but not borders and boxes: keep those inside yourself.
5. **Zooms**: `cam` ≤ 1.06, punches 0.025-0.035. A zoom anchored low pushes the top chip above y 250 (the engine anchors at y 300).
6. **Caption box**: it must be the mask (3.9). Hide parts with `display`, not `visibility` (a child with `visibility: visible` leaks through a hidden parent).
7. **Domes** are drawn under the captions, wipes over everything. Swap the act exactly at the dome's `tc`. During a dark dome the caption is still dark-on-dark for half a second: that is the look.
8. **Stale frames**: rendering resumes over existing frames. After any visual change, delete `frames/` before the full render.
9. **Fonts**: the engine waits for the fonts; if `fonts/` is missing, Chrome silently falls back to Times/Arial. Check the key word in the stills.
10. **Images** you add go in the video folder and into the DOM during build (as `<img>`): the engine waits for them before frame 0. Draw neutral illustrations yourself (cards, icons, abstract UI); don't put other people's videos, faces or brands on screen without rights.
11. **Purity**: CSS transitions/animations, `Date`, `Math.random()` or state kept between frames make frames depend on render order and speed: the stills and the film disagree. Everything from t.
12. **Long chunks** shrink the caption: keep 1-4 words between `/` marks.
13. **TTS slips**: a word the alignment lacks, a number read oddly: reword the take and regenerate only it.

## 13. Checklist (copy it into your plan)

- [ ] Node 22+, Chrome/Edge, ffmpeg found; key present but never printed; `get-fonts.mjs` and `make-sfx.mjs` run.
- [ ] Brief in 3 lines: topic, the count, the turn, the CTA keyword and what it really sends.
- [ ] Script: 110-165 words, hook headline first, numbered beats with one visual each, the turn at ~60 %, the keyword CTA last; markup chunks of 1-4 words.
- [ ] `--estimate` timings, `scenes.js` written against the words; stills sheet (with and without `--safe`) looked at; scan: all text inside, EMPTY none; no ENGINE WARNING.
- [ ] Voice generated (180-190 wpm, 35-55 s), timeline rebuilt, stills + scan again.
- [ ] Full render in foreground chunks, one browser, idle priority, ALL FRAMES.
- [ ] Bed + effects; mix -14 LUFS, true peak ≤ -1 dBTP.
- [ ] Encode: ENCODE OK; frames from the final mp4 checked; cover is the finished hook.
- [ ] Delivered to a user folder with cover + caption; short honest report; nothing posted.
