---
name: ink
description: Make a 35-60 s vertical (1080x1920, 30 fps) faceless voiceover explainer reel in the "ink" kinetic-type look - black, charcoal and cream slides that flip every ~2 s, one giant word, number or line drawing per spoken beat that resolves on its key word, a word-by-word caption with a boxed key word, a small "EDITED BY CLAUDE" header with a progress line, and an ElevenLabs voice. Covers the script, the voice with word timings, an HTML/CSS engine rendered frame by frame in headless Chrome, sound, the -14 LUFS mix, the encode and the checks. Use it when asked for a kinetic typography / animated text / faceless explainer reel, Short or TikTok, or for an "ink" / "ink.md" video.
---

# ink: a kinetic-type explainer reel

You will produce a **35-60 second vertical video (1080x1920, 30 fps, H.264 + AAC)**: a first-person voiceover explainer told in minimal kinetic typography. Every spoken beat gets its own slide (one giant word, numeral or line drawing on black, charcoal or cream) and every slide **resolves on its key word** at the moment the voice says it. Under the slide, a caption shows the voice word by word with the key word in an inverted box. A small "✱ EDITED BY CLAUDE" tag and a thin progress line sit at the top. The video is an HTML page whose every frame is a pure function of time, rendered one frame at a time in headless Chrome and muxed with ffmpeg.

**This is what it should look like:** if `reference.jpg` sits next to this file, open it now: 12 frames of a finished ink reel, in order. Without it, section 3 pins the look down to the pixel, and the worked example (sections 4-10) renders it.

Everything you need is in this file. Each code block is one file, named in its first line: copy them verbatim into one folder (they were tested together), then write three files per video: `script.mjs`, `scenes.js`, `caption.md`. **On a new machine, first build the worked example with `--fake`** (no key, no credits, about five minutes): when its stills and 6-second test pass, the setup works, and you replace its script and scenes with the real ones.

Work in this order and don't skip ahead: **brief -> script -> voice -> timeline -> scenes -> stills -> sound -> 6-second test -> full render -> mix -> encode -> check -> deliver.**

---

## 0. Rules of engagement (read first)

1. **Be gentle with the user's computer.** One headless browser, on the GPU, at low priority, no parallel workers (`cdp.mjs` does this: it lowers every browser process and, on Windows, pins them to one logical processor). The film is DOM, not WebGL, so it still renders without a GPU, just slower: if `render.mjs` says there is no GPU, tell the user before a full render.
2. **Never print secrets.** The key is `ELEVENLABS_API_KEY` (environment, or a `.env` file in the reel folder). Load it inside scripts; never echo it, log it or write it into a file you show.
3. **Stills before film.** Never start a full render before the contact sheets are clean (section 13).
4. **Background jobs die when your turn ends.** Render in the foreground. `render.mjs --full` is resumable: if a call would outlast your tool timeout, render in ranges (`--from 0 --to 20`, then `--from 20`).
5. **Spend credits once.** Count characters first (`gen-vo.mjs --dry`), cache every take, redo only a take that is wrong. `gen-vo.mjs --fake` lets you build and check the whole video before spending anything.
6. **Draw everything.** No screenshots, logos, photos or footage of anyone's work: products, apps, files, people and prices are drawn as line art in this style. The user's own brand mark only if they ask, and only on the CTA.
7. **Don't publish anything.** You deliver files. Posting is the user's call.
8. If this file conflicts with what the user asks, the user wins. If something is missing (no key, no ffmpeg, no browser), say exactly what and offer the closest alternative. Never fake success.

## 1. Requirements check (first minute)

- **Node 22+** (`node -v`): global `fetch` and `WebSocket` are used. **No npm packages**: don't run `npm install` (with no `package.json` here, npm would walk up and install into a parent folder).
- **Chrome or Edge.** `cdp.mjs` looks in the usual places; otherwise set `CHROME=<full path>`.
- **ffmpeg + ffprobe 6 or newer** on PATH, or `FFMPEG` / `FFPROBE` set to full paths (`ffmpeg -version`).
- **ElevenLabs API key.** The free plan works (about 10,000 credits a month, API access, premade voices, no commercial licence: monetised videos need a paid plan). About 1 credit per character; one reel is 600-1,100 characters. Check it exists without printing it: `node -e "console.log(!!process.env.ELEVENLABS_API_KEY)"` (or that `.env` has the line). No key: do everything with `--fake`, then ask for one.
- **Internet once**, for the fonts (`node fonts.mjs`). After that only the voice calls use the network.

## 2. Project layout

```
reel/                      one folder per video, plain files, no build step
  fonts.mjs -> fonts/      Inter Tight, JetBrains Mono, Source Serif 4 (all free, SIL OFL), fetched once
  lib.mjs                  shared helpers: markup parser, loudness meter, empty-frame meter
  script.mjs               THE WORDS                                     (you write it per video)
  gen-vo.mjs -> vo/        ElevenLabs takes + word timings (cached)
  build-timeline.mjs       -> timeline.js, timeline.json, vo.wav
  film.html film.css engine.js parts.js       page, look, engine, components (never edit per video)
  scenes.js                THE SCENES + video-wide settings              (you write it per video)
  cdp.mjs render.mjs       -> stills/, frames/, meta.json
  sounds.mjs -> sfx/ music/   a free synthesised sound kit (or your own files, same names)
  mix.mjs encode.mjs check.mjs   -> mix.wav, video.mp4, cover.jpg
  caption.md               the post caption                              (you write it per video)
```

The commands, in order (run all of them **from the reel folder**):

```
node fonts.mjs                                   # once
node gen-vo.mjs --dry                            # characters = credits (free)
node gen-vo.mjs                                  # the voice (spends credits)  |  --fake: silent, estimated timings
node build-timeline.mjs                          # prints every word with its time: write scenes.js from it
node render.mjs --stills --at starts --sheet     # every scene's first frame
node render.mjs --stills --at scenes --safe --sheet
node sounds.mjs --len <total + 2> --lift <time the beat comes in>
node render.mjs --full --to 6 && node mix.mjs --to 6 && node encode.mjs --to 6 && node check.mjs --file test-6s.mp4
node render.mjs --full                           # after a scene change: add --force (or delete frames/)
node mix.mjs && node encode.mjs && node check.mjs
```

Two helpers first: `fonts.mjs` (run once) and `lib.mjs` (imported by every command).

```js
// fonts.mjs: downloads the free (SIL OFL) fonts into fonts/ once. The render never touches the network.
import fs from 'node:fs';
const CDN = 'https://cdn.jsdelivr.net/npm/', FILES = [
  '@fontsource-variable/inter-tight/files/inter-tight-latin-wght-normal.woff2',          // Inter Tight 100-900: everything
  '@fontsource-variable/inter-tight/files/inter-tight-latin-ext-wght-normal.woff2',      // accented letters
  '@fontsource/jetbrains-mono/files/jetbrains-mono-latin-700-normal.woff2',             // mono labels, file names, prompts
  '@fontsource-variable/source-serif-4/files/source-serif-4-latin-wght-italic.woff2',   // serif italic (warm look only)
];
fs.mkdirSync('fonts', { recursive: true });
for (const f of FILES) { const r = await fetch(CDN + f); if (!r.ok) throw new Error(`${f}: ${r.status}`); fs.writeFileSync('fonts/' + f.split('/').pop(), Buffer.from(await r.arrayBuffer())); }
console.log('fonts/:', fs.readdirSync('fonts').join(', '));
```

```js
// lib.mjs: shared by every command. Run all commands from the reel folder.
import fs from 'node:fs';
import os from 'node:os';
import { spawnSync } from 'node:child_process';

try { os.setPriority(os.constants.priority.PRIORITY_LOW); } catch { }   // ffmpeg children inherit it
export const FPS = 30;
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 readJSON = f => JSON.parse(fs.readFileSync(f, 'utf8'));
export function need(f, hint) { if (!fs.existsSync(f)) { console.error(`missing ${f} -> ${hint}`); process.exit(2); } return f; }

// a secret from the environment or ./.env. Never print it.
export function secret(key) {
  if (process.env[key]) return process.env[key].trim();
  if (fs.existsSync('.env')) { const m = fs.readFileSync('.env', 'utf8').match(new RegExp(`^\\s*${key}\\s*=\\s*(.+)$`, 'm')); if (m) return m[1].trim().replace(/^["']|["']$/g, ''); }
  return null;
}

// TAKES markup -> tokens { d (shown), said (spoken), key (boxed), br (starts a caption phrase) }
//   /  phrase break  ·  *word*  key word (boxed)  ·  two_words  one token  ·  {shown|said}  screen and voice differ
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 key = false, core = raw, tail = '';
    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;
}

// integrated loudness, LRA and true peak (ffmpeg ebur128)
export function loudness(file) {
  const r = spawnSync(FF, ['-hide_banner', '-nostats', '-i', file, '-af', 'ebur128=peak=true', '-f', 'null', '-'], { encoding: 'utf8', maxBuffer: 64 << 20 });
  const s = r.stderr.slice(r.stderr.lastIndexOf('Summary:')), num = re => { const m = s.match(re); return m ? +m[1] : NaN; };
  return { I: num(/I:\s+(-?[\d.]+) LUFS/), LRA: num(/LRA:\s+(-?[\d.]+) LU/), TP: num(/Peak:\s+(-?[\d.]+|-inf) dBFS/) };
}

// how much of the stage (x 60-960, y 310-1130) differs from the background, 0-1, per frame.
// A frame under 0.03 is "near-empty". `input` is an image, or a video sampled at `fps`.
export function coverage(input, { fps = 0, crop = '900:820:60:310', first = false } = {}) {
  const w = 90, hh = 82, vf = `${fps ? `fps=${fps},` : ''}crop=${crop},scale=${w}:${hh}:flags=area,format=gray`;
  const r = spawnSync(FF, ['-v', 'error', '-i', input, ...(first ? ['-frames:v', '1'] : []), '-vf', vf, '-f', 'rawvideo', '-'], { maxBuffer: 256 << 20 });
  const out = [], n = w * hh;
  for (let o = 0; o + n <= r.stdout.length; o += n) {
    const px = r.stdout.subarray(o, o + n), hist = new Array(256).fill(0); px.forEach(v => hist[v]++);
    let acc = 0, med = 0; for (; med < 256; med++) { acc += hist[med]; if (acc >= n / 2) break; }
    let c = 0; for (const v of px) if (Math.abs(v - med) > 40) c++;
    out.push(c / n);
  }
  return out;
}
```

---

## 3. The look (pin these exactly)

### 3.1 Canvas and layout

| | |
|---|---|
| Canvas | 1080x1920, 30 fps, deviceScaleFactor 1 |
| Header | y 258-292: the tag at x 60, the progress line at x 720-960 |
| **Stage** | x 60-960, y 310-1130 (900x820). Scenes are written in **stage px**: stage x 450 = frame x 510 |
| **Caption band** | x 100-920 (820 wide, centred on x 510), text from y 1170, the block stays inside y 1150-1480 |
| Bottom 440 px | background only (the platforms' captions and buttons live there) |
| **Safe zone** | all text inside x 60-960, y 250-1480. Nothing important in the top 250 px, the bottom 440 px or the right 120 px (Instagram, TikTok and YouTube Shorts buttons). Everything is centred on x 510, not 540, because of the right-hand button column |
| `hideCap: true` scene | no caption; its stage runs down to y 1470 (1160 px tall) |

### 3.2 Colours (the base look: strictly monochrome)

| token | `dark` | `char` (charcoal) | `cream` |
|---|---|---|---|
| ground `--bg` | `#030303` | `#0b0b0b` | `#e3e2dd` |
| glow (centre of the ground) | `#1a1a1a` | `#262626` | `#fbfbf8` |
| ink `--ink` (text, strokes `--line`) | `#f4f4f1` | `#f4f4f1` | `#0c0c0c` |
| dim `--dim` (labels, not-yet-said words) | `#7f7f7c` | `#858582` | `#8d8c87` |
| key box `--box-bg` / its text `--box-ink` | `#f4f4f1` / `#0a0a0a` | `#f4f4f1` / `#0a0a0a` | `#0c0c0c` / `#f4f4f1` |
| panel `--panel` (card fills) | `#121212` | `#171717` | `#efeeea` |
| track `--track` (progress track, empty bars) | `#2b2b2b` | `#303030` | `#cbcac4` |

- Every slide: `radial-gradient(1150px 1250px at 47% 40%, glow, transparent 72%)` over the ground (the middle is lighter than the edges), then a vignette over everything: `radial-gradient(ellipse 78% 62% at 50% 46%, transparent 52%, rgba(0,0,0,.22) 100%)`. The header tag is ink at 62 % opacity.
- No accent colour in the base look. Colour appears only when the script asks for it, through two optional looks that blend in over 0.6 s (`K.look`): **warm** (cream becomes `#e9cfb1`, dark `#120904`, ink `#fff0de` / `#2b1306`, accent `#f0975a`, a drifting orange light leak, 9 % grain, serif-italic key words) and **moody** (every ground goes blue-black, `#010205` / `#02050a` / `#0f1722`, ink `#dbe5f0`, accent `#8fb2d8`, a cold top light, a heavy vignette, 12 % grain, the caption in capitals). All tokens are in `engine.js`.

### 3.3 Grounds and when they flip

- Each scene picks `dark`, `char` or `cream`; the ground changes with the scene, as a hard cut.
- **Flip light/dark about every 2-3 s** (15-25 flips a minute). Avoid three same-side scenes in a row (`char` counts as dark; dark -> char is a soft change, -> cream is the big one). Cream is on screen roughly a third to a half of the time.
- The hook is `dark` (it is also the cover). The CTA is `char` or `dark`.
- On cream everything inverts: black ink, black key box with cream text. Nothing else changes.

### 3.4 Type (all fonts free and embedded; the page never relies on a system font)

| Use | Font, weight | Size | Tracking / line height |
|---|---|---|---|
| Caption | Inter Tight 800 | 56 px (72 when the voice says "bigger captions") | -0.012em / 1.16, words 0.34em apart, lines 6 px apart |
| Giant words | Inter Tight 900, often CAPS | 160-300 px, one line, fitted to 740 px | -0.05em / 1 |
| Giant numerals, prices | Inter Tight 900 | 380-500 px | -0.05em / 1 |
| Box word on the stage | Inter Tight 900 in a 12 px-radius box | 50-72 px, padding 8/28/12 px | -0.01em |
| Label | Inter Tight 800, CAPS, dim (ink for emphasis) | 30 px (24-40) | 0.3em |
| Header tag | Inter Tight 800, CAPS | 22 px | 0.2em |
| Mono: file names, prompts typed, readouts | JetBrains Mono 700 (the original used Cascadia Mono, a Windows font) | 22-42 px | 0-0.2em |
| Serif italic: warm-look key words, magazine titles | Source Serif 4 italic 700 (the original used Sitka Banner, Windows-only) | | 0 |
| Outlined pill (a word to strike) | Inter Tight 900, 6 px border, 22 px radius | 64-92 px | -0.03em |
| Prompt bar | Inter Tight 650 in a 140 px-tall, 900 px pill with a 4 px border | 48 px, smaller for long prompts | -0.015em |

### 3.5 The header

On every frame: an 8-spoke asterisk (24 px, strokes 4.2 with round caps) + `EDITED BY CLAUDE`, at x 60, y 260, Inter Tight 800, 22 px, CAPS, tracked 0.2em, ink at 62 %. The progress line: x 720, y 273, 240x5 px, radius 3, track colour, its ink fill growing linearly from 0 to 240 px over the whole video. The tag is the series name: keep it, or give the user's series its own (2-4 words, CAPS, max 22 characters) with `K.header('...')`.

### 3.6 The caption

- One **phrase** at a time: the `/` chunks of the script (max 5 words, max 2 lines in 820 px). A phrase appears 0.1 s before its first word and stays until the next phrase; the caption clears 0.9 s after the last word of the video.
- Words arrive **one by one, 0.1 s before they are spoken**: over 0.17 s from invisible to 30 % to 100 % opacity (cubic out), blur 7 px -> 0, colour dim -> ink. Words not yet due are invisible, so the phrase builds left to right.
- The **key word** (`*word*`) gets a box: inset -0.18em left and right, -0.02em top, 0 bottom, radius 0.2em, `--box-bg`. It pops at the word's start -0.02 s: scale 0.7 -> 1 with a back-ease over 0.22 s; once it is a quarter in, the word's text turns `--box-ink`. About two of every three phrases have a key word, never more than one.
- **The box trick:** no mask and no sliding box. Each word is its own inline-block with two layers, the box (absolute, behind) and the text (relative, on top). The box always fits its own word, wraps with it, needs no measuring, and the colour flip happens under it. Phrase divs are built with `display:none` and the current one is shown with `display:contents` (built visible, frame 0 shows every caption at once).

### 3.7 Motion

| Motion | Used for | Duration, easing | From |
|---|---|---|---|
| **blur-in** (the house entrance) | every subject | 0.30-0.32 s, cubic out; opacity full at 2/3 | blur 16 px, scale 1.07; giant words and numerals: blur 22 px, scale 1.18-1.25 |
| **pop-in** | key boxes, stamps, checks, pills, counters | 0.30-0.36 s, back-out (~8 % overshoot) | scale 0.55 (boxes 0.6-0.7). Stamps slam: scale 1.7 and +10° -> -6..-10° |
| strike | a denied word | 0.22 s, cubic out, right after the word | a 9 px bar with 14 px overhang |
| numeral park | a list's chapter number | 0.45 s, cubic in-out | to the stage's top-left (70, 50) at 20 % |
| tile flip | the CTA keyword | 0.34 s a tile, 0.07 s apart, back-out | rotateX 85° -> 0°, 40 px below |
| typewriter | a prompt, word by word with the voice | each word over its spoken length | caret blinks every 0.53 s |
| roll | a number counting to its value | 0.8 s, cubic out | |
| X draw | an app crossed out | 2 strokes of 0.16 s, 0.12 s apart | 14 px strokes |
| stagger | items of a group | 0.04-0.13 s apart | |
| drift | the hook's subject | the first ~2 s | grows 3-3.5 % so frame 1 never sits still |
| look blend / caption grow | when the voice says so | 0.6 s in-out / 0.4 s back-out | |

**Never:** whips, flashes, shakes, wipes, crossfades or slides between scenes, spins, looping bounces. Scenes change with a hard cut; the life comes from the entrances landing on words.

---

## 4. Writing the script

The format is a **first-person voiceover explainer**: one person tells the viewer one useful thing in 35-60 seconds. The user may give you a topic, notes or a draft; otherwise propose one in two lines and go.

1. **Hook in the first line.** A claim, a number or a contradiction the viewer feels, said in under 2 s ("Claude doesn't write like you."). Frame 0 shows it already.
2. **One idea per sentence**, and write ideas that can be drawn: a number, an object, an action, a comparison, a yes/no. Each sentence (sometimes each phrase) becomes its own slide, so a sentence with nothing to show is a sentence to cut.
3. **Phrases of 2-5 words**, split with `/`, each with at most one `*key*` word: the word the slide lands on (the number, the noun, the verdict).
4. **Numbers:** spoken as words, shown as digits: `{$30|thirty dollars}`, `{10x|ten times}`. A name the voice gets wrong is respelt on the spoken side only: `{PostHog|Post Hog}`.
5. **Lists** of 3-5 points, numbered aloud ("Prompt one:", "Two."): the numeral fills the slide and then parks as a chapter mark. Optional: a ticking recap before the CTA.
6. **The video does what the voice says.** If a line says "watch it get bigger", "darker", "warmer", write it only if the video will do it (`K.captionSize`, `K.look`).
7. **Tone:** calm, specific, a little dry. No hype words (insane, game-changer, 10x), no emojis, no exclamation marks. Specifics beat adjectives.
8. **Length:** 100-170 spoken words. The voice below speaks about 178 words a minute; with 0.3-0.6 s between sections and a 2.3 s hold on the CTA, 100 words is about 38 s and 165 words about 58 s. Over 60 s: cut words, never speed up.
9. **CTA, always the last line:** `Comment KEYWORD and I'll send you <the real thing>.` KEYWORD is one word of 3-6 letters (it is shown as tiles). The thing must exist and be sendable today: the prompt, the file, the checklist, the template. Never promise a video, a course or a walkthrough that doesn't exist. Write what you'll send on the `DM:` line of `caption.md`.
10. **Sections (takes):** `s0` is the hook (1-3 sentences), then 2-4 sentences a take, `cta` last. One take = one ElevenLabs call: short takes are cheap to redo.

The worked example used in the rest of this file (100 words, 37.8 s):

```js
// script.mjs: the words of this video, nothing else. One ElevenLabs take per section, played in ORDER.
//   /  new caption phrase (max 5 words)  ·  *word*  key word, gets the box (one per phrase)  ·  two_words  one token
//   {shown|said}  the caption shows one thing, the voice says another: {$20|twenty dollars}, {10x|ten times}
export const TAKES = {
  s0: 'Claude doesn\'t write / like *you.* / It writes like *Claude.* / Three prompts / *fix* that.',
  s1: 'Prompt *one:* / paste ten of your / *best* posts, / and ask Claude / to describe your *voice* / in five *rules.*',
  s2: 'Save those rules. / That\'s your *style_guide,* / and it goes on top / of *every* chat.',
  s3: 'Prompt *two:* / give it a *banned* list. / *Delve,* / *game-changer,* / *unlock.* / Every word / you\'d *never* say.',
  s4: 'Prompt *three:* / ask for *five* versions, / pick the *closest* one, / and tell Claude *why.* / It learns *fast.*',
  s5: 'Two weeks in, / my drafts need / *one* edit, / not *ten.*',
  cta: 'Comment *VOICE* / and I\'ll send you / all three *prompts,* / ready to *paste.*',
};
export const ORDER = ['s0', 's1', 's2', 's3', 's4', 's5', 'cta'];
export const GAP = { s1: 0.4, s2: 0.35, s3: 0.45, s4: 0.4, s5: 0.45, cta: 0.4 };   // silence before each section (0.3-0.6 s)
export const PADS = { s3: { 'unlock.': 0.25 } };   // extra pause after a word inside a take: { section: { 'token as shown, with punctuation': s } }
export const LEAD = 0.18;                          // silence before the first word
export const HOLD = 2.3;                           // the last frame holds this long after the last word
```

`PADS` stretch a pause inside a take without re-voicing (the take is cut after that word and the rest slides later): use them after you've seen the timeline, when a visual beat needs room. Keys are the token as shown, with its punctuation; a boxed two-word token is one key (`'style guide,'`).

---

## 5. The voice (`gen-vo.mjs`)

- **Will** (`bIHbv24MWmeRgasZH58o`, an ElevenLabs premade voice, young American male), model `eleven_multilingual_v2`, stability 0.5, similarity 0.8, style 0.35, speaker boost on, **speed 1.12**, mp3 44.1 kHz, through `/with-timestamps` so every caption word gets its time. Keep these for every video of a series: they are part of the format.
- The user may prefer another voice: list them with `GET https://api.elevenlabs.io/v1/voices` (header `xi-api-key`), pick one with `category: "premade"` (Liam suits the format too), and keep it for the whole series.
- No audio tags (`[excited]`): this model would read them aloud. Punctuation is the direction: a full stop is a beat, a comma a breath.
- You cannot hear the takes, so check what you can: the WARNING lines (word counts that don't match = the voice skipped or added words), each take's duration (about 3 words a second), and the word list `build-timeline.mjs` prints. Redo one take with `--only s3 --fresh`. If the user has a local Whisper (faster-whisper), transcribe `mix.wav` and compare it with the script; never use a paid speech-to-text without asking.
- `vo/<id>.json` = `{ id, text, dur, tokens: [{ d (shown), said (spoken), key, br (starts a phrase), t0, t1 }] }`, times in seconds inside the take.

```js
// gen-vo.mjs: one ElevenLabs take per section, with timestamps, so every caption word has its time.
//   node gen-vo.mjs --dry                  count characters, spend nothing
//   node gen-vo.mjs [--only s1,s3] [--fresh]   -> vo/<id>.mp3 + vo/<id>.json { id, text, dur, tokens: [{ d, said, key, br, t0, t1 }] }
//   node gen-vo.mjs --fake                 silent takes with estimated timings (no key, no credits) to build scenes early
// SPENDS CREDITS (about 1 per character). Takes that exist are skipped unless --fresh.
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
import { FF, parse, secret, arg, has } from './lib.mjs';
import { TAKES } from './script.mjs';

export const VOICE = 'bIHbv24MWmeRgasZH58o';          // "Will", an ElevenLabs premade voice. Another premade voice id works too: keep one per series.
export const MODEL = 'eleven_multilingual_v2';
export const SETTINGS = { stability: 0.5, similarity_boost: 0.8, style: 0.35, use_speaker_boost: true, speed: 1.12 };

const ids = arg('--only') ? arg('--only').split(',') : Object.keys(TAKES);
for (const id of ids) if (!TAKES[id]) { console.error('no take ' + id); process.exit(2); }
const isReal = id => fs.existsSync(`vo/${id}.json`) && !JSON.parse(fs.readFileSync(`vo/${id}.json`, 'utf8')).fake;
const todo = ids.filter(id => has('--fresh') || !isReal(id));     // fake takes count as missing; --fake never overwrites a real take
const chars = todo.reduce((n, id) => n + parse(TAKES[id]).map(t => t.said).join(' ').length, 0);
console.log(`${todo.length} take(s): ${todo.join(', ') || '-'} · ${chars} characters`);
if (has('--dry') || !todo.length) process.exit(0);
fs.mkdirSync('vo', { recursive: true });

if (has('--fake')) {                                 // ~3.2 words/s, a beat after commas and full stops
  for (const id of todo) {
    const toks = parse(TAKES[id]); let t = 0.08;
    for (const k of toks) { const n = k.said.split(' ').length, len = k.said.replace(/[^\w]/g, '').length; k.t0 = +t.toFixed(3); t += 0.07 * n + 0.045 * len; k.t1 = +t.toFixed(3); t += /[.?!]$/.test(k.said) ? 0.32 : /[,:;]$/.test(k.said) ? 0.16 : 0.03; }
    const dur = +(t + 0.1).toFixed(3);
    execFileSync(FF, ['-y', '-v', 'error', '-f', 'lavfi', '-i', `anullsrc=r=44100:cl=mono`, '-t', String(dur), '-c:a', 'libmp3lame', '-b:a', '64k', `vo/${id}.mp3`]);
    fs.writeFileSync(`vo/${id}.json`, JSON.stringify({ id, text: toks.map(k => k.said).join(' '), dur, fake: true, tokens: toks }, null, 1));
  }
  console.log('fake takes written (silent). With a key, node gen-vo.mjs replaces them with the real voice.');
  process.exit(0);
}

const KEY = secret('ELEVENLABS_API_KEY');
if (!KEY) { console.error('ELEVENLABS_API_KEY is not set (environment or .env)'); process.exit(2); }
for (const id of todo) {
  const toks = parse(TAKES[id]), text = toks.map(t => t.said).join(' ');
  const r = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${VOICE}/with-timestamps?output_format=mp3_44100_128`, {
    method: 'POST', headers: { 'xi-api-key': KEY, 'content-type': 'application/json' },
    body: JSON.stringify({ text, model_id: MODEL, voice_settings: 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(`vo/${id}.mp3`, Buffer.from(j.audio_base64, 'base64'));
  const words = []; let cur = null;                 // spoken words from the character alignment
  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);
  let wi = 0;                                        // tokens onto words (a token like "editing app" spans two)
  for (const t of toks) {
    const n = t.said.split(' ').length, span = words.slice(wi, wi + n); wi += n;
    if (!span.length) { console.log(id, 'WARNING: ran out of spoken words at', t.said); break; }
    t.t0 = +span[0].t0.toFixed(3); t.t1 = +span.at(-1).t1.toFixed(3);
  }
  if (wi !== words.length) console.log(id, `WARNING: ${wi} words in the tokens vs ${words.length} spoken: check the take`);
  const dur = A.character_end_times_seconds.at(-1);
  fs.writeFileSync(`vo/${id}.json`, JSON.stringify({ id, text, dur, tokens: toks }, null, 1));
  console.log(id, 'ok', dur.toFixed(2) + ' s');
}
```

## 6. The timeline (`build-timeline.mjs`)

The takes are laid end to end: `LEAD` (0.18 s) before the first word, `GAP[id]` (0.3-0.6 s) before each section, `PADS` inside takes, `HOLD` (2.3 s) after the last word. The result is the film clock: `timeline.js` (`window.TL`, loaded by the page), `timeline.json` and `vo.wav`. It prints every token with its start time (`*key*` marked, `/` where a phrase starts): **write scenes.js with that list open.** Rerun it, then re-render, whenever a take or `script.mjs` changes.

```js
// build-timeline.mjs: lays the takes end to end -> timeline.json, timeline.js (window.TL) and vo.wav (48 kHz mono).
// ORDER, GAP, PADS, LEAD and HOLD come from script.mjs. A PAD stretches the pause after one word inside a take
// (the take is cut there and the rest slides later, no re-voicing), so a visual beat gets room.
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
import { FF, readJSON, need } from './lib.mjs';

const S = await import('./script.mjs');
const ORDER = S.ORDER || Object.keys(S.TAKES), GAP = S.GAP || {}, PADS = S.PADS || {}, LEAD = S.LEAD ?? 0.18, HOLD = S.HOLD ?? 2.3;
let t = LEAD, n = 0; const sections = [], inputs = [], fc = [], labels = [];
ORDER.forEach((id, i) => {
  const j = readJSON(need(`vo/${id}.json`, 'node gen-vo.mjs'));
  if (i) t += GAP[id] ?? 0.4;
  const start = +t.toFixed(3), pads = PADS[id] || {};
  for (const k of Object.keys(pads)) if (!j.tokens.slice(0, -1).some(x => x.d === k)) console.log(`WARNING PADS.${id}['${k}'] matches no token (${j.tokens.map(x => x.d).join(' | ')})`);
  const cuts = [];                                   // cut halfway into the natural gap after a padded word
  j.tokens.forEach((k, ti) => { const nx = j.tokens[ti + 1]; if (pads[k.d] && nx) cuts.push({ at: Math.min(k.t1 + 0.04, (k.t1 + nx.t0) / 2), pad: pads[k.d], after: ti }); });
  const segs = []; let from = 0, shift = 0;
  for (const c of cuts) { segs.push({ a: from, b: c.at, shift }); from = c.at; shift += c.pad; }
  segs.push({ a: from, b: j.tokens.at(-1).t1 + 0.12, shift });
  const toks = j.tokens.map((k, ti) => { const sh = cuts.filter(c => c.after < ti).reduce((s, c) => s + c.pad, 0); return { ...k, t0: +(start + k.t0 + sh).toFixed(3), t1: +(start + k.t1 + sh).toFixed(3) }; });
  for (const s of segs) {
    inputs.push('-i', `vo/${id}.mp3`);
    const ms = Math.round((start + s.a + s.shift) * 1000), len = s.b - s.a;
    fc.push(`[${n}:a]atrim=${s.a.toFixed(3)}:${s.b.toFixed(3)},asetpts=PTS-STARTPTS,afade=t=in:d=0.012,afade=t=out:st=${Math.max(0, len - 0.02).toFixed(3)}:d=0.02,adelay=${ms}|${ms}[a${n}]`);
    labels.push(`[a${n++}]`);
  }
  sections.push({ id, start, end: toks.at(-1).t1, tokens: toks });
  t = toks.at(-1).t1;
});
const total = +(t + HOLD).toFixed(3);
fs.writeFileSync('timeline.json', JSON.stringify({ total, sections }, null, 1));
fs.writeFileSync('timeline.js', `window.TL = ${JSON.stringify({ total, sections })};`);
fc.push(`${labels.join('')}amix=inputs=${labels.length}:normalize=0,apad=whole_dur=${total},atrim=0:${total}[vo]`);
execFileSync(FF, ['-y', '-v', 'error', ...inputs, '-filter_complex', fc.join(';'), '-map', '[vo]', '-ar', '48000', '-ac', '1', 'vo.wav']);
const words = sections.reduce((a, s) => a + s.tokens.reduce((b, k) => b + k.said.split(' ').length, 0), 0);
console.log(`total ${total} s · ${words} words · ${Math.round(words / (t - LEAD) * 60)} wpm`);
for (const s of sections) console.log(`${s.id} @${s.start}: ` + s.tokens.map(k => `${k.br ? '/ ' : ''}${k.key ? '*' + k.d + '*' : k.d} ${k.t0.toFixed(2)}`).join('  '));
if (total > 60) console.log('WARNING: longer than 60 s: cut words, not speed');
```

---

## 7. The page (`film.html`, `film.css`)

**The one rule that makes everything work: `window.setT(t)` is a pure function of time.** Given `t` in seconds it shows the right scene, sets every animated property of it, draws the caption, header and progress, and returns. No clocks (`performance.now`, `requestAnimationFrame`), no CSS transitions or animations, no `Math.random()` (seed by index), 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 sets `window.ready` (a promise: fonts loaded, layout measured, frame 0 drawn) and `window.META` (total, fps, cover time, music, sfx cues, scene list) for the Node side. `film.html?safe=1` draws the safe zone over the frame.

```html
<!doctype html>
<html><head><meta charset="utf-8"><title>ink</title><link rel="stylesheet" href="film.css"></head>
<body><div id="film"></div>
<script src="timeline.js"></script><script src="engine.js"></script><script src="parts.js"></script><script src="scenes.js"></script>
<script>K.boot()</script>
</body></html>
```

```css
/* film.css: the look. Layout (1080x1920): header y 258-292 · stage x 60-960, y 310-1130 · caption band y 1150-1480 · bottom 440 px empty */
@font-face { font-family: 'Inter Tight'; src: url(fonts/inter-tight-latin-wght-normal.woff2) format('woff2'); font-weight: 100 900; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: 'Inter Tight'; src: url(fonts/inter-tight-latin-ext-wght-normal.woff2) format('woff2'); font-weight: 100 900; unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+1E00-1E9F, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF; }
@font-face { font-family: 'Ink Mono'; src: url(fonts/jetbrains-mono-latin-700-normal.woff2) format('woff2'); font-weight: 700; }
@font-face { font-family: 'Ink Serif'; src: url(fonts/source-serif-4-latin-wght-italic.woff2) format('woff2'); font-weight: 200 900; font-style: italic; }
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 1080px; height: 1920px; overflow: hidden; background: #000; }
body { font-family: 'Inter Tight', sans-serif; -webkit-font-smoothing: antialiased; }
#film { position: relative; width: 1080px; height: 1920px; overflow: hidden; color: var(--ink); }
.layer { position: absolute; inset: 0; pointer-events: none; }
#bg { background: radial-gradient(1150px 1250px at 47% 40%, var(--glow), transparent 72%), var(--bg); }
#leak { mix-blend-mode: screen; }
#grain { width: 1080px; height: 1920px; mix-blend-mode: overlay; }

/* the stage: scenes are laid out in stage px (900 x 820, centre x 450 = frame x 510) */
#stage { position: absolute; left: 60px; top: 310px; width: 900px; height: 820px; }
.scene { position: absolute; inset: 0; display: none; }
.scene.tall { bottom: -340px; }                       /* hideCap scenes may use the caption band (stage to y 1470) */
.abs { position: absolute; }
.row { position: absolute; left: 0; width: 900px; text-align: center; }
.mono { font-family: 'Ink Mono', 'Cascadia Mono', Consolas, monospace; font-weight: 700; }
.serif { font-family: 'Ink Serif', Georgia, serif; font-style: italic; }
.label { font-size: 30px; font-weight: 800; letter-spacing: .3em; text-transform: uppercase; color: var(--dim); }
.big { font-weight: 900; letter-spacing: -.05em; line-height: .86; }
.box { display: inline-block; background: var(--box-bg); color: var(--box-ink); border-radius: 12px; }
.pillbtn { display: inline-block; background: var(--ink); color: var(--bg); border-radius: 999px; padding: 14px 34px 16px; font-size: 34px; font-weight: 800; }
.pill-out { display: inline-block; position: relative; border: 6px solid var(--line); border-radius: 22px; padding: 10px 34px 14px; font-weight: 900; font-size: 78px; letter-spacing: -.03em; }
.strike { position: absolute; left: -14px; top: 50%; height: 9px; margin-top: -4px; background: var(--line); border-radius: 5px; }
.card { position: absolute; border: 6px solid var(--line); border-radius: 26px; background: var(--panel); color: var(--ink); overflow: hidden; }
.bar { position: absolute; border: 4px solid var(--line); border-radius: 80px; background: var(--panel); }
.bar .txt { position: absolute; left: 50px; right: 140px; top: 0; bottom: 0; display: flex; align-items: center; font-size: 48px; font-weight: 650; letter-spacing: -.015em; white-space: nowrap; overflow: hidden; }
.caret { display: inline-block; width: 4px; height: 56px; background: var(--ink); margin-left: 6px; border-radius: 2px; }
.send { position: absolute; right: 18px; top: 50%; width: 100px; height: 100px; margin-top: -50px; border-radius: 50%; background: var(--ink); color: var(--bg); display: flex; align-items: center; justify-content: center; }
.tile { position: absolute; border-radius: 22px; background: var(--ink); color: var(--bg); display: flex; align-items: center; justify-content: center; font-weight: 900; }
svg { overflow: visible; }

/* caption: one phrase at a time, word by word, key word boxed. Centred on x 510, 820 wide, from y 1170 */
#cap { position: absolute; left: 100px; width: 820px; top: 1170px; height: 300px; display: flex; flex-wrap: wrap; justify-content: center; align-content: flex-start; align-items: center; column-gap: .34em; row-gap: 6px; font-weight: 800; font-size: 56px; line-height: 1.16; letter-spacing: -.012em; }
#cap .w { position: relative; display: inline-block; white-space: nowrap; }
#cap .w .bx { position: absolute; left: -.18em; right: -.18em; top: -.02em; bottom: 0; border-radius: .2em; background: var(--box-bg); }
#cap .w .tx { position: relative; }
#cap.warm .w.key .tx { font-family: 'Ink Serif', Georgia, serif; font-style: italic; font-weight: 700; letter-spacing: 0; }
#cap.moody { text-transform: uppercase; letter-spacing: .04em; }

/* header: series tag (left) + progress line (right), y 258-292 */
#tag { position: absolute; left: 60px; top: 260px; display: flex; align-items: center; gap: 12px; font-size: 22px; font-weight: 800; letter-spacing: .2em; color: var(--tag); white-space: nowrap; }
#prog { position: absolute; left: 720px; top: 273px; width: 240px; height: 5px; border-radius: 3px; background: var(--track); overflow: hidden; }
#prog i { position: absolute; left: 0; top: 0; bottom: 0; background: var(--ink); border-radius: 3px; }

/* safe-zone overlay (film.html?safe=1) */
#safe { z-index: 50; }
#safe .no { position: absolute; background: rgba(255, 0, 90, .22); }
#safe .zone { position: absolute; left: 60px; top: 250px; width: 900px; height: 1230px; outline: 4px dashed #00e5ff; }
#safe .band { position: absolute; left: 100px; top: 1150px; width: 820px; height: 330px; background: rgba(0, 229, 255, .10); outline: 3px solid #ffd400; }
```

## 8. The engine (`engine.js`)

What a scene uses (`window.K`):

| | |
|---|---|
| `W(sec, word, n)` | the n-th token in section `sec` shown as `word` (case and punctuation ignored): `{ d, said, key, br, t0, t1 }`. A typo throws and lists what the section says |
| `t0(sec, word, n)`, `t1(...)` | that word's start / end on the film clock |
| `at(sec, word, n)` | `t0 - 0.12`: where a scene starts (it cuts in just before its first word) |
| `scene({ id, start, bg, hideCap, build(el), update(t) })` | `el` is the scene's stage (900x820). `this.start`, `this.end`, `this.in` (= start - 0.1) are set for you |
| `h(parent, class, css, html)` | make a div |
| `fx(el, { o, x, y, r, s, blur, extra })` | set opacity + transform + blur in one go |
| `blurIn(el, t, at, dur, f)`, `popIn(el, t, at, dur, f)` | the two entrances (section 3.7) |
| `ramp(t, at, dur)`, `lerp`, `clamp`, `E.out / inOut / back / expo` | timing and easing |
| `typeWords(tokens, t)`, `typeOut(text, t, at, dur)`, `blink(t, at)` | typewriters, caret |
| `cue(name, t, gain, len?)` | a sound effect `sfx/<name>.wav` at film time `t` |
| `K.header(text)`, `K.cover(t)`, `K.music({...})`, `K.look(name, at, dur)`, `K.captionSize(px, at, dur)`, `K.layout(fn)` | video-wide settings; `layout` runs `fn` once the fonts are in, to measure text |
| `STAR(size)`, `CHECK`, `ARROW_UP`, `CURSOR`, `DOC(w)` | line icons (SVG with `currentColor`) |

```js
// engine.js: the ink engine. Don't edit per video; a video's scenes go in scenes.js and use window.K.
// Every frame is a pure function of the film clock t: window.setT(t). No clocks, no requestAnimationFrame,
// no CSS transitions/animations, no Math.random, no state carried from the previous frame.
(function () {
  'use strict';
  const TL = window.TL; if (!TL) throw new Error('timeline.js missing: run node build-timeline.mjs');
  const TOTAL = TL.total, FPS = 30, Q = new URLSearchParams(location.search);
  const film = document.getElementById('film');
  const CUES = [], LAYOUTS = [];

  // ---------- maths
  const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
  const lerp = (a, b, k) => a + (b - a) * k;
  const ramp = (t, a, d) => d <= 0 ? (t >= a ? 1 : 0) : clamp((t - a) / d);
  const E = {
    out: k => 1 - Math.pow(1 - k, 3),                                       // entrances
    inOut: k => k < .5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2,  // moves, look blends
    back: k => 1 + 2.5 * Math.pow(k - 1, 3) + 1.5 * Math.pow(k - 1, 2),   // pops: ~8 % overshoot
    expo: k => k >= 1 ? 1 : 1 - Math.pow(2, -10 * k),                      // things that fly in and settle
  };
  const hex = s => [1, 3, 5].map(i => parseInt(s.slice(i, i + 2), 16));
  const mixc = (cols, ws) => { const o = [0, 0, 0]; cols.forEach((c, i) => hex(c).forEach((v, j) => { o[j] += v * ws[i]; })); return o.map(Math.round); };
  const rgb = (a, al = 1) => al >= 1 ? `rgb(${a})` : `rgba(${a},${al})`;

  // ---------- words: W('s3', 'drop', n) = the n-th token in section s3 that shows "drop": { d, said, key, br, t0, t1 }
  const TOK = []; TL.sections.forEach(s => s.tokens.forEach(k => TOK.push({ ...k, sec: s.id })));
  const norm = s => s.toLowerCase().replace(/[^a-z0-9]/g, '');
  function W(sec, word, n = 0) {
    let c = 0; for (const k of TOK) if (k.sec === sec && norm(k.d) === norm(word) && c++ === n) return k;
    throw new Error(`W('${sec}', '${word}', ${n}): no such word. ${sec} says: ${TOK.filter(k => k.sec === sec).map(k => k.d).join(' ')}`);
  }
  const t0 = (s, w, n) => W(s, w, n).t0, t1 = (s, w, n) => W(s, w, n).t1;
  const SEC = id => TL.sections.find(x => x.id === id);
  const LEADCUT = 0.12, at = (s, w, n) => t0(s, w, n) - LEADCUT;   // a scene cuts in 0.12 s before its first word
  const LASTWORD = TOK.at(-1).t1;

  // ---------- DOM + motion
  function h(parent, cls, css, html) { const e = document.createElement('div'); if (cls) e.className = cls; if (css) e.style.cssText = css; if (html != null) e.innerHTML = html; parent.appendChild(e); return e; }
  function fx(el, o) {                               // o: { o opacity, x, y (px), r (deg), s (scale), blur (px), extra }
    el.style.opacity = o.o ?? 1;
    const b = o.blur || 0; el.style.filter = b > 0.15 ? `blur(${b.toFixed(1)}px)` : 'none';
    el.style.transform = `translate(${(o.x || 0).toFixed(1)}px,${(o.y || 0).toFixed(1)}px) rotate(${(o.r || 0).toFixed(2)}deg) scale(${(o.s ?? 1).toFixed(4)})${o.extra || ''}`;
  }
  // blur-in, the house entrance: from blurred, a little big and transparent to sharp. f: start offsets
  // { blur 16, s 1.07, x, y, r } and resting offsets { ds, dx, dy, dr } (multiply / add after the entrance)
  function blurIn(el, t, a, d = 0.32, f = {}) {
    const k = E.out(ramp(t, a, d));
    fx(el, { o: t < a ? 0 : Math.min(1, k * 1.5), blur: (f.blur ?? 16) * (1 - k), x: (f.x ?? 0) * (1 - k) + (f.dx || 0), y: (f.y ?? 0) * (1 - k) + (f.dy || 0), s: lerp(f.s ?? 1.07, 1, k) * (f.ds || 1), r: (f.r ?? 0) * (1 - k) + (f.dr || 0), extra: f.extra });
    return k;
  }
  // pop-in with a small overshoot (stamps, boxes, checks, pills). f: { s 0.55 start scale, r, y, ds, dr, dx, dy }
  function popIn(el, t, a, d = 0.36, f = {}) {
    const k = ramp(t, a, d), e = E.back(k);
    fx(el, { o: t < a ? 0 : Math.min(1, k * 3), s: lerp(f.s ?? 0.55, 1, e) * (f.ds || 1), r: (f.r ?? 0) * (1 - e) + (f.dr || 0), x: f.dx || 0, y: (f.y ?? 0) * (1 - e) + (f.dy || 0) });
    return k;
  }
  const hide = el => { el.style.opacity = 0; };
  const typeOut = (text, t, a, d) => text.slice(0, Math.round(text.length * ramp(t, a, d)));
  function typeWords(toks, t) {                      // a prompt typed word by word while it is spoken
    let out = '';
    toks.forEach((k, i) => { const w = k.said.replace(/[.,:?!]$/, ''), n = Math.round(w.length * ramp(t, k.t0 - 0.03, Math.max(0.08, k.t1 - k.t0))); if (n > 0) out += (i ? ' ' : '') + w.slice(0, n); });
    return out;
  }
  const blink = (t, a) => t < a ? 1 : (Math.floor((t - a) / 0.53) % 2 ? 0.15 : 1);
  const fmt = s => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
  // line icons: always currentColor (var() does not work in SVG presentation attributes)
  const STAR = (size = 26) => { let p = ''; for (let i = 0; i < 8; i++) { const a = i * Math.PI / 4; p += `<line x1="0" y1="0" x2="${(Math.cos(a) * 11).toFixed(2)}" y2="${(Math.sin(a) * 11).toFixed(2)}"/>`; } return `<svg width="${size}" height="${size}" viewBox="-13 -13 26 26"><g stroke="currentColor" stroke-width="4.2" stroke-linecap="round">${p}</g></svg>`; };
  const CHECK = `<svg width="100%" height="100%" viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
  const ARROW_UP = `<svg width="46" height="46" viewBox="0 0 24 24"><path d="M12 19V5M5.5 11.5L12 5l6.5 6.5" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
  const CURSOR = `<svg width="64" height="80" viewBox="0 0 16 20"><path d="M1 1 L1 16.5 L5 12.8 L7.6 18.6 L10.2 17.4 L7.7 11.8 L13 11.8 Z" fill="#fff" stroke="#000" stroke-width="1.1" stroke-linejoin="round"/></svg>`;
  const DOC = (w = 60) => `<svg width="${w}" height="${Math.round(w * 1.25)}" viewBox="0 0 48 60"><path d="M6 3h24l12 12v39a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3zM30 3v12h12" fill="none" stroke="currentColor" stroke-width="4.5" stroke-linejoin="round"/><path d="M12 31h22M12 40h22M12 49h13" stroke="currentColor" stroke-width="4.5" stroke-linecap="round"/></svg>`;

  // ---------- looks: base (the reference) · warm · moody. Each has three grounds a scene picks with bg:
  const LOOKS = {
    base: {
      dark: { bg: '#030303', glow: '#1a1a1a', ink: '#f4f4f1', dim: '#7f7f7c', boxBg: '#f4f4f1', boxInk: '#0a0a0a', panel: '#121212', track: '#2b2b2b' },
      char: { bg: '#0b0b0b', glow: '#262626', ink: '#f4f4f1', dim: '#858582', boxBg: '#f4f4f1', boxInk: '#0a0a0a', panel: '#171717', track: '#303030' },
      cream: { bg: '#e3e2dd', glow: '#fbfbf8', ink: '#0c0c0c', dim: '#8d8c87', boxBg: '#0c0c0c', boxInk: '#f4f4f1', panel: '#efeeea', track: '#cbcac4' },
      accent: '#f4f4f1',
    },
    warm: {
      dark: { bg: '#120904', glow: '#43230f', ink: '#fff0de', dim: '#a8846a', boxBg: '#f9d9b4', boxInk: '#2b1306', panel: '#24140a', track: '#3d2615' },
      char: { bg: '#190d05', glow: '#5a3014', ink: '#fff0de', dim: '#ad8a6f', boxBg: '#f9d9b4', boxInk: '#2b1306', panel: '#2a180c', track: '#442a17' },
      cream: { bg: '#e9cfb1', glow: '#fff4e4', ink: '#2b1306', dim: '#a07c5e', boxBg: '#2b1306', boxInk: '#fff0de', panel: '#f6e3cb', track: '#d4b592' },
      accent: '#f0975a',
    },
    moody: {
      dark: { bg: '#010205', glow: '#0d1726', ink: '#dbe5f0', dim: '#5c6b7c', boxBg: '#dbe5f0', boxInk: '#02050b', panel: '#0a111b', track: '#18222f' },
      char: { bg: '#02050a', glow: '#15233a', ink: '#dbe5f0', dim: '#62728a', boxBg: '#dbe5f0', boxInk: '#02050b', panel: '#0c1420', track: '#1a2535' },
      cream: { bg: '#0f1722', glow: '#273950', ink: '#e4ecf5', dim: '#6c7e93', boxBg: '#e4ecf5', boxInk: '#02050b', panel: '#162130', track: '#223144' },
      accent: '#8fb2d8',
    },
  };
  const NAMES = ['base', 'warm', 'moody'], LOOKSEQ = [];
  function look(name, a, d = 0.6) { if (!LOOKS[name]) throw new Error('no look ' + name); LOOKSEQ.push({ name, a, d }); LOOKSEQ.sort((x, y) => x.a - y.a); }
  function lookAt(t) {                               // weights of [base, warm, moody] at time t
    let w = [1, 0, 0];
    for (const q of LOOKSEQ) { const k = E.inOut(ramp(t, q.a, q.d)); if (k <= 0) break; w = w.map((v, i) => lerp(v, NAMES[i] === q.name ? 1 : 0, k)); }
    return w;
  }
  let LW = [1, 0, 0];
  function applyVars(kind) {
    const L = NAMES.map(n => LOOKS[n]), v = key => mixc(L.map(l => l[kind][key]), LW), S = film.style, ink = v('ink');
    for (const [css, key] of [['--bg', 'bg'], ['--glow', 'glow'], ['--ink', 'ink'], ['--line', 'ink'], ['--dim', 'dim'], ['--box-bg', 'boxBg'], ['--box-ink', 'boxInk'], ['--panel', 'panel'], ['--track', 'track']]) S.setProperty(css, rgb(v(key)));
    S.setProperty('--tag', rgb(ink, 0.62));
    S.setProperty('--accent', rgb(mixc(L.map(l => l.accent), LW)));
  }

  // ---------- frame layers, back to front
  const bgL = h(film, 'layer'); bgL.id = 'bg';
  const stage = h(film); stage.id = 'stage';
  const leak = h(film, 'layer'); leak.id = 'leak';
  const vig = h(film, 'layer');
  const grainC = document.createElement('canvas'); grainC.id = 'grain'; grainC.className = 'layer'; grainC.width = 540; grainC.height = 960; film.appendChild(grainC);
  const gctx = grainC.getContext('2d'), gimg = gctx.createImageData(540, 960);
  const tag = h(film); tag.id = 'tag';
  const cap = h(film); cap.id = 'cap';
  const prog = h(film, '', '', '<i></i>'); prog.id = 'prog';
  function header(text) { tag.innerHTML = text ? `${STAR(24)}<span>${text}</span>` : ''; }
  header('EDITED BY CLAUDE');
  if (Q.has('safe')) {
    const s = h(film, 'layer'); s.id = 'safe';
    for (const r of ['0,0,1080,250', '0,1480,1080,440', '960,250,120,1230', '0,250,60,1230']) { const [l, tp, w, hh] = r.split(','); h(s, 'no', `left:${l}px;top:${tp}px;width:${w}px;height:${hh}px`); }
    h(s, 'zone'); h(s, 'band');
  }
  function grain(frame, amt) {                       // seeded by the frame number: same frame, same grain
    grainC.style.opacity = amt; if (amt <= 0.001) return;
    let s = (frame * 2654435761) >>> 0; const d = gimg.data;
    for (let i = 0; i < d.length; i += 4) { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; const g = 128 + ((s & 255) - 128) * 0.9; d[i] = d[i + 1] = d[i + 2] = g; d[i + 3] = 255; }
    gctx.putImageData(gimg, 0, 0);
  }

  // ---------- caption: one phrase ('/' in the script) at a time, word by word, key word boxed
  const PHR = [];
  TOK.forEach(k => { if (k.br || !PHR.length) PHR.push({ toks: [] }); PHR.at(-1).toks.push(k); });
  PHR.forEach(p => {
    p.t0 = p.toks[0].t0; p.el = h(cap, '', 'display:none');      // display:none at build, or frame 0 shows every phrase
    p.ws = p.toks.map(k => { const w = h(p.el, 'w' + (k.key ? ' key' : '')); const bx = k.key ? h(w, 'bx') : null; return { k, bx, w, tx: h(w, 'tx', '', k.d) }; });
  });
  const CAPSIZE = []; let CAP0 = 56;
  function captionSize(px, a, d = 0.4) { if (a == null) CAP0 = px; else { CAPSIZE.push({ px, a, d }); CAPSIZE.sort((x, y) => x.a - y.a); } }
  let capHidden = false, shown = null;
  function drawCaption(t) {
    let p = null; for (const q of PHR) if (q.t0 - 0.1 <= t) p = q;
    if (t > LASTWORD + 0.9 || capHidden) p = null;
    if (p !== shown) { PHR.forEach(q => { q.el.style.display = q === p ? 'contents' : 'none'; }); shown = p; }
    let size = CAP0; for (const c of CAPSIZE) size = lerp(size, c.px, E.back(ramp(t, c.a, c.d)));
    cap.style.fontSize = size.toFixed(2) + 'px';
    cap.classList.toggle('warm', LW[1] > 0.5); cap.classList.toggle('moody', LW[2] > 0.5);
    if (!p) return;
    for (const { k, w, bx, tx } of p.ws) {
      const r = ramp(t, k.t0 - 0.1, 0.17);                         // each word arrives 0.1 s before it is spoken
      w.style.opacity = r <= 0 ? 0 : lerp(0.3, 1, E.out(r)).toFixed(3);
      w.style.filter = r < 1 ? `blur(${(7 * (1 - r)).toFixed(1)}px)` : 'none';
      let col = r < 1 ? 'var(--dim)' : 'var(--ink)';
      if (bx) {                                                     // the key word's box pops in on the word
        const kb = ramp(t, k.t0 - 0.02, 0.22);
        bx.style.opacity = clamp(kb * 2.5); bx.style.transform = `scale(${lerp(0.7, 1, E.back(kb)).toFixed(3)})`;
        if (kb > 0.25) col = 'var(--box-ink)';
      }
      tx.style.color = col;
    }
  }

  // ---------- scenes: K.scene({ id, start, bg: 'dark'|'char'|'cream', hideCap?, build(el) {...}, update(t) {...} })
  // el = the scene's stage (900 x 820). this.start, this.end and this.in (= start - 0.1) are set for you.
  const SC = []; let building = null, active = null, MUSIC = null, COVER = 1.2;
  function scene(o) {
    if (!o.id || typeof o.start !== 'number' || isNaN(o.start)) throw new Error('scene needs an id and a numeric start: ' + o.id);
    o.bg = o.bg || 'dark'; o.el = h(stage, 'scene' + (o.hideCap ? ' tall' : '')); o.in = o.start - 0.1;
    SC.push(o); return o;
  }
  const cue = (name, t, gain = 0.2, len) => { CUES.push(len ? [name, +t.toFixed(3), gain, +len.toFixed(3)] : [name, +t.toFixed(3), gain]); };
  const music = o => { MUSIC = o; }, cover = t => { COVER = t; };
  const layout = fn => LAYOUTS.push({ fn, s: building });          // runs once the fonts are in (to measure text)

  // ---------- safe zone: every visible text box inside x 60-960, y 250-1480; caption words inside y 1150-1480
  function visible(el) { for (let e = el; e && e !== document.body; e = e.parentElement) { const cs = getComputedStyle(e); if (cs.display === 'none' || +cs.opacity < 0.05) return false; } return true; }
  function safeCheck() {
    const bad = [], tw = document.createTreeWalker(film, NodeFilter.SHOW_TEXT); let n;
    while ((n = tw.nextNode())) {
      if (!n.nodeValue.trim() || n.parentElement.closest('#safe') || !visible(n.parentElement)) continue;
      const rg = document.createRange(); rg.selectNodeContents(n); const r = rg.getBoundingClientRect(); if (!r.width) continue;
      const top = n.parentElement.closest('#cap') ? 1150 : 250;
      if (r.left < 58 || r.right > 962 || r.top < top - 2 || r.bottom > 1482) bad.push(`"${n.nodeValue.trim().slice(0, 30)}" x ${Math.round(r.left)}-${Math.round(r.right)} y ${Math.round(r.top)}-${Math.round(r.bottom)}`);
    }
    return bad;
  }

  // ---------- the frame
  window.setT = function (t) {
    LW = lookAt(t);
    let s = SC[0]; for (const q of SC) if (q.start <= t) s = q;
    if (s !== active) { SC.forEach(q => { q.el.style.display = q === s ? 'block' : 'none'; }); active = s; }
    applyVars(s.bg); capHidden = !!s.hideCap;
    s.update && s.update(t);
    drawCaption(t);
    const [, wa, mo] = LW;                                           // look extras: light leak (warm), cold top light (moody), vignette, grain
    const lx = 18 + 8 * Math.sin(t * 0.37), ly = 12 + 6 * Math.cos(t * 0.29);
    leak.style.background = `radial-gradient(760px 980px at ${lx}% ${ly}%, rgba(255,150,70,.55), transparent 70%), radial-gradient(640px 760px at ${92 - lx * 0.3}% ${88 - ly * 0.4}%, rgba(255,95,110,.32), transparent 70%), radial-gradient(1200px 700px at 50% -8%, rgba(110,150,205,${(0.5 * mo / Math.max(0.01, wa + mo)).toFixed(3)}), transparent 70%)`;
    leak.style.opacity = (0.62 * wa + 0.35 * mo).toFixed(3);
    vig.style.background = `radial-gradient(ellipse 78% 62% at 50% 46%, transparent 52%, rgba(0,0,0,${(0.22 * LW[0] + 0.34 * wa + 0.78 * mo).toFixed(3)}) 100%)`;
    grain(Math.round(t * FPS), 0.09 * wa + 0.12 * mo);
    prog.firstChild.style.width = (240 * clamp(t / TOTAL)).toFixed(1) + 'px';
  };

  function boot() {
    SC.sort((a, b) => a.start - b.start);
    if (!SC.length || SC[0].start > 0) throw new Error('the first scene must start at 0');
    SC.forEach((s, i) => { s.end = i + 1 < SC.length ? SC[i + 1].start : TOTAL; });
    SC.forEach(s => { building = s; s.build && s.build(s.el); }); building = null;
    const fonts = ['800 56px "Inter Tight"', '900 200px "Inter Tight"', '700 30px "Ink Mono"', 'italic 700 56px "Ink Serif"'].map(f => document.fonts.load(f));
    window.ready = Promise.all(fonts).then(() => document.fonts.ready)
      .then(() => LAYOUTS.forEach(({ fn, s }) => { if (s) s.el.style.display = 'block'; fn(); if (s) s.el.style.display = 'none'; }))
      .then(() => {
        active = null; window.setT(0);
        window.META = { total: TOTAL, fps: FPS, cover: COVER, music: MUSIC, cues: CUES, fontsOk: document.fonts.check('800 56px "Inter Tight"'), scenes: SC.map(s => ({ id: s.id, start: +s.start.toFixed(3), end: +s.end.toFixed(3), bg: s.bg })) };
        return true;
      });
  }

  window.K = {
    TL, TOTAL, FPS, TOK, W, t0, t1, at, SEC, LEADCUT, LASTWORD,
    clamp, lerp, ramp, E, mixc, rgb, h, fx, blurIn, popIn, hide, typeOut, typeWords, blink, fmt,
    STAR, CHECK, ARROW_UP, CURSOR, DOC, LOOKS, look, header, captionSize, scene, cue, music, cover, layout, safeCheck, boot,
  };
})();
```

## 9. The components (`parts.js`)

`label`, `boxWord`, `counter`, `bigWord` (fits itself), `numeral` (parks), `struck` (outlined pill + strike), `stamp`, `doc` (a file card), `window` (an app in line art, `.cross()`), `roll` (a counting number), `checklist`, `tiles` (the CTA keyword), `promptBar` (typed in sync with the voice, then sent). Draw anything else the same way: DOM or SVG with `currentColor`, strokes 4-9 px in `--line`, radius 18-30 px, fills `--panel`, the chosen item inverted (`--box-bg` fill, `--box-ink` content). New parts go in `scenes.js`.

```js
// parts.js: scene components (K.P). Stage px: 900 x 820, x 450 = centre. Everything is drawn (DOM/SVG), no photos.
// Pattern: const p = K.P.thing(el, {...}) in build(), then p.update(t, ...) (or blurIn/popIn on it) in update().
(function () {
  'use strict';
  const { h, fx, blurIn, popIn, hide, E, ramp, lerp, typeWords, blink, STAR, CHECK, ARROW_UP, CURSOR, DOC } = K;
  const P = K.P = {};

  // ---------- text rows
  P.label = (el, text, y, css = '') => h(el, 'row label', `top:${y}px;${css}`, text);                       // spaced caps label
  P.boxWord = (el, text, y, size = 54) => h(el, 'row', `top:${y}px`, `<span class="box" style="font-size:${size}px;font-weight:900;padding:8px 28px 12px;letter-spacing:-.01em">${text}</span>`);
  P.counter = (el, label, value, y) => h(el, 'row', `top:${y}px`, `<span class="label" style="color:var(--ink)">${label}&nbsp;&nbsp;</span><span class="box" style="font-size:64px;font-weight:900;padding:2px 26px 6px;vertical-align:middle">${value}</span>`);
  // a giant word on one line, shrunk to fit once the fonts are in
  P.bigWord = (el, text, y, size = 200, css = '', max = 740) => {   // max 740: room for a 1.2x blur-in entrance inside 900
    const r = h(el, 'row big', `top:${y}px;font-size:${size}px;line-height:1;${css}`, `<span style="display:inline-block;white-space:nowrap">${text}</span>`);
    K.layout(() => { const w = r.firstChild.offsetWidth; if (w > max) r.style.fontSize = (size * max / w).toFixed(1) + 'px'; });
    return r;
  };

  // ---------- a giant numeral that blurs in, then parks top-left as a chapter mark.  update(t, at, parkAt?)
  P.numeral = (el, ch, o = {}) => {
    const size = o.size || 500, y = o.y ?? 0, cy = y + size / 2, to = o.park || [70, 50, 0.2];
    const e = h(el, 'row big', `top:${y}px;font-size:${size}px;line-height:1`, ch);
    return { el: e, update(t, a, park) {
      blurIn(e, t, a, 0.3, { s: 1.25, blur: 22 });
      const k = park == null ? 0 : E.inOut(ramp(t, park, 0.45));
      if (k > 0) fx(e, { x: (to[0] - 450) * k, y: (to[1] - cy) * k, s: lerp(1, to[2], k) });
    } };
  };

  // ---------- a word in an outlined pill, struck through on a word.  update(t, at, strikeAt)
  P.struck = (el, text, y, size = 78) => {
    const r = h(el, 'row', `top:${y}px`, `<span class="pill-out" style="font-size:${size}px">${text}<span class="strike"></span></span>`), s = r.querySelector('.strike');
    return { el: r, update(t, a, sAt) { blurIn(r, t, a, 0.3, { s: 1.05 }); s.style.width = `calc(${(E.out(ramp(t, sAt, 0.22)) * 100).toFixed(1)}% + 28px)`; s.style.opacity = t < sAt ? 0 : 1; } };
  };

  // ---------- a rubber stamp (REJECTED, FREE, APPROVED): popIn(stamp, t, at, 0.3, { s: 1.7, r: 10, dr: -8 })
  P.stamp = (el, text, o = {}) => h(el, 'abs', `left:${o.x ?? 260}px;top:${o.y ?? 0}px;width:${o.w ?? 380}px;height:${o.h ?? 140}px;display:flex;align-items:center;justify-content:center;z-index:2`,
    `<span style="border:7px solid var(--line);border-radius:18px;padding:6px 22px 10px;font-size:${o.size ?? 58}px;font-weight:900;letter-spacing:-.02em;background:var(--panel);white-space:nowrap">${text}</span>`);

  // ---------- a file card: doc icon, two grey lines, a mono file name.  blurIn/popIn its .el
  P.doc = (el, o = {}) => {
    const w = o.w ?? 150, hh = o.h ?? Math.round(w * 1.3);
    const c = h(el, 'card', `left:${o.x ?? 450 - w / 2}px;top:${o.y ?? 0}px;width:${w}px;height:${hh}px;border-width:${Math.max(4, Math.round(w / 30))}px;border-radius:${Math.round(w / 7)}px`);
    h(c, 'abs', `left:${w * 0.13}px;top:${w * 0.1}px`, DOC(Math.round(w * 0.36)));
    for (const [i, f] of [[0, 0.6], [1, 0.42]]) h(c, 'abs', `left:${w * 0.13}px;top:${hh * 0.55 + i * w * 0.12}px;width:${w * f}px;height:${Math.max(6, w * 0.055)}px;border-radius:5px;background:var(--dim);opacity:.6`);
    if (o.name) h(c, 'abs mono', `left:${w * 0.12}px;bottom:${w * 0.1}px;font-size:${Math.round(w * 0.12)}px;white-space:nowrap`, o.name);
    return { el: c };
  };

  // ---------- an app/browser window in line art; cross(t, at) draws a big X over it
  P.window = (el, o = {}) => {
    const x = o.x ?? 250, y = o.y ?? 0, w = o.w ?? 400, hh = o.h ?? 290;
    const win = h(el, 'abs', `left:${x}px;top:${y}px;width:${w}px;height:${hh}px;border:7px solid var(--line);border-radius:30px;background:var(--panel)`);
    h(win, 'abs', 'left:0;right:0;top:52px;height:7px;background:var(--line)');
    for (let i = 0; i < 3; i++) h(win, 'abs', `left:${26 + i * 34}px;top:18px;width:16px;height:16px;border-radius:50%;background:var(--line)`);
    if (o.title) h(win, 'abs mono', 'right:24px;top:12px;font-size:22px;letter-spacing:.1em', o.title);
    const L = Math.hypot(w - 40, hh - 40);
    const X = h(el, 'abs', `left:${x}px;top:${y}px;width:${w}px;height:${hh}px;color:var(--line);z-index:2;opacity:0`, `<svg width="${w}" height="${hh}"><line x1="20" y1="20" x2="${w - 20}" y2="${hh - 20}" stroke="currentColor" stroke-width="14" stroke-linecap="round"/><line x1="${w - 20}" y1="20" x2="20" y2="${hh - 20}" stroke="currentColor" stroke-width="14" stroke-linecap="round"/></svg>`);
    const lines = X.querySelectorAll('line');
    return { el: win, x: X, cross(t, a) { X.style.opacity = t < a ? 0 : 1; [0, 1].forEach(i => lines[i].setAttribute('stroke-dasharray', `${(L * E.out(ramp(t, a + i * 0.12, 0.16))).toFixed(1)} ${L}`)); } };
  };

  // ---------- a number that rolls to its value (counts, prices, views).  update(t, at, dur)
  P.roll = (el, o) => {
    const e = h(el, 'row big', `top:${o.y ?? 0}px;font-size:${o.size ?? 300}px;line-height:1`, '');
    const f = o.fmt || (v => Math.round(v).toLocaleString('en-US'));
    return { el: e, update(t, a, d = 0.8) { e.textContent = f(lerp(o.from ?? 0, o.to, E.out(ramp(t, a, d)))); blurIn(e, t, a - 0.1, 0.3, { s: 1.15, blur: 20 }); } };
  };

  // ---------- a checklist: rows readable (dimmed) from the start, ticked in turn.  update(t, at, tickAt, step)
  P.checklist = (el, o) => {
    const x = o.x ?? 60, y = o.y ?? 0, gap = o.gap ?? 140, size = o.size ?? 56;
    const rows = o.items.map((it, i) => {
      const box = h(el, 'abs', `left:${x}px;top:${y + i * gap}px;width:76px;height:76px;border-radius:18px;border:6px solid var(--line)`);
      const ck = h(box, 'abs', 'inset:2px;padding:4px;color:var(--bg)', CHECK);
      const tx = h(el, 'abs', `left:${x + 110}px;top:${y + i * gap + 38 - size * 0.62}px;font-size:${size}px;font-weight:800;letter-spacing:-.02em;white-space:nowrap`, it);
      return { box, ck, tx, i };
    });
    return { update(t, a, tickAt, step = 0.16) {
      rows.forEach(({ box, ck, tx, i }) => {
        blurIn(box, t, a + i * 0.04, 0.22, { x: -20 }); blurIn(tx, t, a + i * 0.04, 0.22, { x: -20 });
        const k = ramp(t, tickAt + i * step, 0.18);
        box.style.background = k > 0 ? 'var(--ink)' : 'transparent'; ck.style.opacity = k; ck.style.transform = `scale(${lerp(0.4, 1, E.back(k))})`;
        if (k <= 0) tx.style.opacity = Math.min(+tx.style.opacity, 0.45);
      });
    } };
  };

  // ---------- the CTA keyword in tiles, one per letter, flipping in.  update(t, at, step)
  P.tiles = (el, word, o = {}) => {
    const n = word.length, gap = o.gap ?? 12, tw = Math.min(150, Math.floor(((o.w ?? 860) - (n - 1) * gap) / n)), th = Math.round(tw * 1.2), x0 = 450 - (n * tw + (n - 1) * gap) / 2;
    const tiles = [...word].map((ch, i) => h(el, 'tile', `left:${x0 + i * (tw + gap)}px;top:${o.y ?? 0}px;width:${tw}px;height:${th}px;font-size:${Math.round(tw * 0.8)}px`, ch));
    return { tiles, h: th, update(t, a, step = 0.07) {
      tiles.forEach((e, i) => { const s = a + i * step, k = ramp(t, s, 0.34); fx(e, { o: t < s ? 0 : Math.min(1, k * 3), y: 40 * (1 - E.out(k)), extra: ` perspective(600px) rotateX(${(85 * (1 - E.back(k))).toFixed(1)}deg)` }); });
    } };
  };

  // ---------- a prompt bar: the prompt typed while it is spoken, a cursor that clicks send, "Claude is working..."
  // o: { y (bar top, 300), label, toks: [W(...), ...], click: time, status }.  update(t, at)
  P.promptBar = (el, o) => {
    const y = o.y ?? 300, sx = 828, sy = y + 70, r = {};
    r.label = h(el, 'row label', `top:${y - 60}px`, o.label || 'PROMPT');
    r.bar = h(el, 'bar', `left:0;top:${y}px;width:900px;height:140px`); r.txt = h(r.bar, 'txt'); r.send = h(r.bar, 'send', '', ARROW_UP);
    r.txt.style.fontSize = Math.min(48, Math.floor(1300 / o.toks.map(k => k.said).join(' ').length)) + 'px';   // a long prompt shrinks to fit
    r.ring = h(el, 'abs', `left:${sx - 66}px;top:${sy - 66}px;width:132px;height:132px;border-radius:50%;border:5px solid var(--line)`);
    r.cur = h(el, 'abs', 'left:0;top:0;width:64px;height:80px;z-index:3', CURSOR);
    r.status = h(el, 'row mono', `top:${y + 180}px;font-size:32px;color:var(--ink)`);
    r.update = (t, a) => {
      blurIn(r.label, t, a, 0.3); blurIn(r.bar, t, a, 0.34, { s: 1.04, y: 20 });
      const typed = typeWords(o.toks, t), click = o.click;
      r.txt.innerHTML = typed ? `<span>${typed}</span><span class="caret" style="opacity:${t >= click ? 0 : blink(t, o.toks.at(-1).t1)}"></span>`
        : `<span class="caret" style="margin:0 14px 0 0;opacity:${blink(t, a)}"></span><span style="color:var(--dim);font-size:40px">Ask Claude...</span>`;
      const ca = click - 0.75, k = E.inOut(ramp(t, ca, 0.62));
      fx(r.cur, { o: t < ca ? 0 : Math.min(1, (t - ca) * 5), x: lerp(640, sx + 6, k), y: lerp(sy + 330, sy + 6, k), s: t >= click && t < click + 0.12 ? 0.86 : 1 });
      r.send.style.transform = `scale(${t >= click && t < click + 0.16 ? lerp(0.86, 1, ramp(t, click, 0.16)) : 1})`;
      const rk = ramp(t, click, 0.55); r.ring.style.opacity = t < click ? 0 : (1 - rk) * 0.8; r.ring.style.transform = `scale(${lerp(0.8, 1.9, E.out(rk))})`;
      if (t >= click + 0.05) { r.status.innerHTML = `<span style="display:inline-flex;align-items:center;gap:14px">${STAR(30)}${o.status || 'Claude is working'}${'.'.repeat(1 + Math.floor((t - click) * 6) % 3)}</span>`; blurIn(r.status, t, click + 0.05, 0.2, { y: 12 }); }
      else hide(r.status);
    };
    return r;
  };
})();
```

---

## 10. Writing scenes (`scenes.js`)

1. **One scene per spoken beat**: a sentence, or a phrase when the sentence has two ideas. 2.0-2.5 s on average (0.8-4.4 s), a new set-up at least every 2.5 s, about 15 scenes in 40 s and 25 in 58 s.
2. **A scene starts at `at(sec, firstWord)`** (0.12 s before its first word). Its subject enters at `this.in`, so its first frame already carries it. The first scene starts at 0; start its entrances at -0.3 so frame 0 is sharp: frame 0 is the first impression.
3. **The payoff lands on the boxed word**: the stage's own box, stamp, strike, tick or count fires at `t0(key) - 0.02` (a strike or a cross right after the word ends). Secondary items arrive on their own words, never all at once.
4. **The edit follows the words, literally.** "Ten posts" shows ten files; "no subscriptions" strikes SUBSCRIPTION; "free" stamps FREE; "$30" shows $30; "five versions" shows v1-v5. When the voice says the video changes ("warmer", "darker", "bigger captions"), the whole video does it at that word (`K.look`, `K.captionSize`).
5. **No empty frames.** Every frame has its subject on screen: never a ground with only a caption, never a stage waiting for its first item. `render.mjs` flags any frame whose stage is under 3 % covered.
6. **Stay inside the stage.** Text must stay in the safe zone even mid-entrance: the check measures the line box times the entrance scale. Use `line-height:1` on big type, keep numerals at `y >= 30` and size <= 460, and let `bigWord` fit long words.
7. **Alternate grounds** (section 3.3) and keep one subject per slide: one giant thing, at most one label, one box, one supporting row. Compose top-down: the subject in the upper part of the stage (y 0-480), the label and box under it (y 460-700), and the rest of the stage mostly empty so the slide never crowds the caption.
8. `update(t)` sets **every** property it animates, from `t` alone, on every frame.
9. Sound cues go right under each scene (`cue(...)`, levels in section 12).

From spoken beat to slide (what the finished reels do):

| The voice says | The slide shows |
|---|---|
| a number, a price, a count | a giant numeral or a `roll`, a label under it, the box on the unit |
| "no X", "without X", "never X" | X in an outlined pill, struck on the word; an app `window` crossed out |
| "free", "rejected", "banned", "done" | a `stamp`, slammed and tilted |
| a list of N | numeral N fills the slide, then parks as a chapter mark; a `checklist` recap ticks item by item |
| a file, a prompt, a skill, a doc | a `doc` card with a mono file name; a `promptBar` typed word by word with the voice |
| an app, a site, a tool | a line-art `window` (title bar dots, a few grey bars) |
| a comparison (theirs vs mine, before/after) | two columns split by a 3 px rule, labels on top, the loser dimmed or struck |
| people, roles, an audience | simple person glyphs (circle head, rounded shoulders) or role cards |
| a question ("why?") | the giant word with its question mark |
| growth, speed, quality | bars that climb, a meter of 10 segments filling |
| the CTA | `COMMENT` label, the keyword in `tiles`, a pill "<the thing>, in your DMs ->", a box like `COMPLETELY FREE` |

The worked example, complete (15 scenes for the script in section 4):

```js
// scenes.js: this video's scenes, keyed to its spoken words. Stage px: 900 x 820, x 450 = centre.
const { W, t0, t1, at, h, fx, blurIn, popIn, E, ramp, lerp, scene, cue, P, CHECK } = K;

// ---------- video-wide settings
K.header('EDITED BY CLAUDE');
K.cover(1.75);                                           // cover.jpg = the hook with its key word boxed
K.music({ file: 'music/bed.wav', gain: 0.14, start: 0 });        // the sounds.mjs bed; a track of your own: start near 0.07

// 1 · "Claude doesn't write like you." Frame 0 already shows the subject: two drafts that don't match.
scene({ id: 'hook', start: 0, bg: 'dark',
  build(el) {
    this.a = P.doc(el, { x: 110, y: 40, w: 260, name: 'you.md' });
    this.b = P.doc(el, { x: 530, y: 40, w: 260, name: 'claude.md' });
    this.ne = h(el, 'row big', 'top:150px;font-size:150px;line-height:1', '&ne;');
    this.box = P.boxWord(el, 'NOT YOUR VOICE', 480, 64);
    this.tY = t0('s0', 'you');
  },
  update(t) {
    blurIn(this.a.el, t, -0.3, 0.3, { x: -30, ds: 1 + 0.03 * ramp(t, 0, 1.6) });
    blurIn(this.b.el, t, -0.3, 0.3, { x: 30, ds: 1 + 0.03 * ramp(t, 0, 1.6) });
    popIn(this.ne, t, this.tY - 0.04, 0.32, { s: 0.4 });
    popIn(this.box, t, this.tY - 0.02, 0.3, { s: 0.7 });
  } });
cue('pop', t0('s0', 'you'), 0.06);

// 2 · "It writes like Claude." A giant word resolves on its key word
scene({ id: 'claude', start: at('s0', 'it'), bg: 'cream',
  build(el) { this.lab = P.label(el, 'IT WRITES LIKE', 120); this.word = P.bigWord(el, 'CLAUDE', 200, 230); this.box = P.boxWord(el, 'BY DEFAULT', 470, 54); this.tC = t0('s0', 'claude'); },
  update(t) { blurIn(this.lab, t, this.in, 0.25, { y: 14 }); blurIn(this.word, t, this.in + 0.05, 0.3, { s: 1.18, blur: 22 }); popIn(this.box, t, this.tC - 0.02, 0.3, { s: 0.7 }); } });

// 3 · "Three prompts fix that." The numeral, then the payoff on "fix"
scene({ id: 'three', start: at('s0', 'three'), bg: 'dark',
  build(el) { this.num = P.numeral(el, '3', { y: 30, size: 440 }); this.lab = P.label(el, 'PROMPTS', 470, 'color:var(--ink);font-size:40px'); this.box = P.boxWord(el, 'FIXED', 580, 72); this.tF = t0('s0', 'fix'); },
  update(t) { this.num.update(t, this.in); blurIn(this.lab, t, this.in + 0.1, 0.3, { y: 16 }); popIn(this.box, t, this.tF - 0.02, 0.32, { s: 0.6 }); } });
cue('impact', t0('s0', 'fix'), 0.2);

// 4 · "Prompt one: paste ten of your best posts," The 1 parks as a chapter mark, ten posts drop in, three get picked
scene({ id: 'one', start: at('s1', 'prompt'), bg: 'cream',
  build(el) {
    this.num = P.numeral(el, '1', { y: 60, size: 460 });
    this.docs = Array.from({ length: 10 }, (_, i) => P.doc(el, { x: 75 + (i % 5) * 155, y: 190 + Math.floor(i / 5) * 210, w: 130 }).el);
    this.box = P.boxWord(el, 'YOUR BEST POSTS', 640, 54);
    this.tT = t0('s1', 'ten'); this.tB = t0('s1', 'best');
  },
  update(t) {
    this.num.update(t, this.in, this.tT - 0.25);
    this.docs.forEach((d, i) => { popIn(d, t, this.tT - 0.25 + i * 0.04, 0.3, { s: 0.6, y: -40 }); d.style.background = t >= this.tB && [1, 4, 7].includes(i) ? 'var(--box-bg)' : 'var(--panel)'; d.style.color = t >= this.tB && [1, 4, 7].includes(i) ? 'var(--box-ink)' : 'var(--ink)'; });
    popIn(this.box, t, this.tB - 0.02, 0.3, { s: 0.7 });
  } });
[0, 1, 2, 3, 4].forEach(i => cue('pop', t0('s1', 'ten') + i * 0.08, 0.04));

// 5 · "and ask Claude to describe your voice in five rules." The prompt is typed while it is spoken, then sent
const P1 = ['describe', 'your', 'voice', 'in', 'five', 'rules'].map(w => W('s1', w, w === 'your' ? 1 : 0)), click1 = P1.at(-1).t1 + 0.07;
scene({ id: 'ask', start: at('s1', 'and'), bg: 'dark',
  build(el) { this.pb = P.promptBar(el, { label: 'PROMPT 01', toks: P1, click: click1, status: 'Claude is reading' }); },
  update(t) { this.pb.update(t, this.in); } });
cue('typing', P1[0].t0, 0.09, P1.at(-1).t1 - P1[0].t0); cue('click', click1, 0.12);

// 6 · "Save those rules. That's your style guide," One file, five rules writing themselves in
scene({ id: 'guide', start: at('s2', 'save'), bg: 'cream',
  build(el) {
    this.card = h(el, 'card', 'left:170px;top:0;width:560px;height:560px', '');
    h(this.card, 'abs mono', 'left:40px;top:34px;font-size:30px', 'style-guide.md');
    this.rules = ['short sentences', 'no hype words', 'one idea a post', 'say "I", not "we"', 'end on a question'].map((r, i) => h(this.card, 'abs mono', `left:40px;top:${110 + i * 82}px;font-size:32px;color:var(--ink)`, `${i + 1}. ${r}`));
    this.box = P.boxWord(el, 'STYLE GUIDE', 620, 60); this.tS = t0('s2', 'style guide');
  },
  update(t) {
    blurIn(this.card, t, this.in, 0.32, { s: 1.05, y: 30 });
    this.rules.forEach((r, i) => blurIn(r, t, this.start + 0.25 + i * 0.18, 0.25, { x: -20 }));
    popIn(this.box, t, this.tS - 0.02, 0.32, { s: 0.6 });
  } });
cue('pop', t0('s2', 'style guide'), 0.06);

// 7 · "and it goes on top of every chat." Three chats, the guide pinned on each
scene({ id: 'every', start: at('s2', 'and'), bg: 'dark',
  build(el) {
    this.wins = [0, 1, 2].map(i => P.window(el, { x: 20 + i * 295, y: 90, w: 270, h: 360, title: `CHAT ${i + 1}` }));
    this.pins = [0, 1, 2].map(i => P.doc(el, { x: 20 + i * 295 + 65, y: 190, w: 140 }).el);
    this.box = P.boxWord(el, 'EVERY CHAT', 560, 60); this.tE = t0('s2', 'every');
  },
  update(t) {
    this.wins.forEach((w, i) => blurIn(w.el, t, this.in + i * 0.06, 0.3, { y: 30 }));
    this.pins.forEach((p, i) => popIn(p, t, t0('s2', 'top') - 0.1 + i * 0.1, 0.32, { s: 0.5, y: -80 }));
    popIn(this.box, t, this.tE - 0.02, 0.3, { s: 0.7 });
  } });

// 8 · "Prompt two: give it a banned list."
scene({ id: 'two', start: at('s3', 'prompt'), bg: 'cream',
  build(el) { this.num = P.numeral(el, '2', { y: 30, size: 440 }); this.stamp = P.stamp(el, 'BANNED', { x: 260, y: 470, size: 72 }); this.tB = t0('s3', 'banned'); },
  update(t) { this.num.update(t, this.in); popIn(this.stamp, t, this.tB - 0.02, 0.3, { s: 1.7, r: 10, dr: -6 }); } });
cue('impact', t0('s3', 'banned'), 0.2);

// 9 · "Delve, game-changer, unlock." Each word arrives on its word and is struck right after
scene({ id: 'words', start: at('s3', 'delve'), bg: 'dark',
  build(el) { this.w = ['delve', 'game-changer', 'unlock'].map((w, i) => P.struck(el, w.toUpperCase(), 40 + i * 230, 92)); },
  update(t) { this.w.forEach((p, i) => { const k = W('s3', ['delve', 'game-changer', 'unlock'][i]); p.update(t, i ? k.t0 - 0.1 : this.in, k.t1 - 0.05); }); } });
['delve', 'game-changer', 'unlock'].forEach(w => cue('click', t1('s3', w) - 0.05, 0.1));

// 10 · "Every word you'd never say."
scene({ id: 'never', start: at('s3', 'every'), bg: 'cream',
  build(el) { this.big = P.bigWord(el, 'NEVER', 120, 240); this.c = P.counter(el, 'TIMES YOU SAID "DELVE"', '0', 470); this.tN = t0('s3', 'never'); },
  update(t) { blurIn(this.big, t, this.in, 0.3, { s: 1.2, blur: 22 }); popIn(this.c, t, this.tN - 0.02, 0.32, { s: 0.6 }); } });

// 11 · "Prompt three: ask for five versions,"
scene({ id: 'five', start: at('s4', 'prompt'), bg: 'dark',
  build(el) { this.num = P.numeral(el, '3', { y: 60, size: 460 }); this.docs = [0, 1, 2, 3, 4].map(i => P.doc(el, { x: 40 + i * 168, y: 330, w: 148, name: `v${i + 1}` }).el); this.tF = t0('s4', 'five'); },
  update(t) { this.num.update(t, this.in, this.tF - 0.35); this.docs.forEach((d, i) => popIn(d, t, this.tF - 0.25 + i * 0.05, 0.32, { s: 0.5, y: 40 })); } });
[0, 1, 2, 3, 4].forEach(i => cue('pop', t0('s4', 'five') + i * 0.06, 0.04));

// 12 · "pick the closest one, and tell Claude why."
scene({ id: 'pick', start: at('s4', 'pick'), bg: 'cream',
  build(el) {
    this.docs = [0, 1, 2, 3, 4].map(i => P.doc(el, { x: 40 + i * 168, y: 120, w: 148, name: `v${i + 1}` }).el);
    this.ck = h(el, 'abs', 'left:400px;top:60px;width:100px;height:100px;border-radius:50%;background:var(--ink);color:var(--bg);padding:16px;z-index:3', CHECK);
    this.box = P.boxWord(el, 'TELL IT WHY', 520, 64); this.tC = t0('s4', 'closest'); this.tW = t0('s4', 'why');
  },
  update(t) {
    const k = E.back(ramp(t, this.tC, 0.3));
    this.docs.forEach((d, i) => { blurIn(d, t, this.in + i * 0.03, 0.25, { y: 20 }); if (i === 2) fx(d, { y: -40 * k, s: 1 + 0.12 * k }); else if (t > this.tC) d.style.opacity = lerp(1, 0.35, ramp(t, this.tC, 0.2)); });
    popIn(this.ck, t, this.tC + 0.05, 0.3, { s: 0.3 }); popIn(this.box, t, this.tW - 0.02, 0.3, { s: 0.7 });
  } });
cue('pop', t0('s4', 'closest'), 0.06);

// 13 · "It learns fast." Five bars climb
scene({ id: 'fast', start: at('s4', 'it'), bg: 'dark',
  build(el) { this.lab = P.label(el, 'SOUNDS LIKE YOU', 600, 'color:var(--ink)'); this.bars = [0, 1, 2, 3, 4].map(i => h(el, 'abs', `left:${130 + i * 135}px;bottom:260px;width:100px;height:400px;border-radius:18px;background:var(--ink);transform-origin:50% 100%`)); },
  update(t) { blurIn(this.lab, t, this.in, 0.3); this.bars.forEach((b, i) => { b.style.transform = `scaleY(${(0.12 + 0.88 * (i + 1) / 5 * E.out(ramp(t, this.in + i * 0.07, 0.5))).toFixed(3)})`; b.style.opacity = 0.35 + 0.65 * (i + 1) / 5; }); } });

// 14 · "Two weeks in, my drafts need one edit, not ten."
scene({ id: 'edits', start: at('s5', 'two'), bg: 'cream',
  build(el) { this.lab = P.label(el, 'EDITS PER DRAFT', 0, 'color:var(--ink)'); this.n = P.roll(el, { y: 70, size: 380, from: 10, to: 1 }); this.ten = P.struck(el, '10 EDITS', 560, 64); this.tO = t0('s5', 'one'); this.tT = t0('s5', 'ten'); },
  update(t) { blurIn(this.lab, t, this.in, 0.25); this.n.update(t, this.in + 0.1, this.tO + 0.2 - this.in); this.ten.update(t, this.in + 0.05, this.tT); } });

// 15 · CTA: "Comment VOICE and I'll send you all three prompts, ready to paste."
scene({ id: 'cta', start: at('cta', 'comment'), bg: 'char',
  build(el) { this.lab = P.label(el, 'COMMENT', 110, 'font-size:36px;color:var(--ink)'); this.tiles = P.tiles(el, 'VOICE', { y: 180 }); this.pill = h(el, 'row', 'top:430px', '<span class="pillbtn">All 3 prompts, in your DMs &rarr;</span>'); this.tS = t0('cta', 'send'); },
  update(t) { blurIn(this.lab, t, this.in, 0.28, { y: 12 }); this.tiles.update(t, this.in, 0.07); popIn(this.pill, t, this.tS - 0.05, 0.36, { s: 0.7 }); } });
[0, 1, 2, 3, 4].forEach(i => cue('pop', at('cta', 'comment') + i * 0.07, 0.04)); cue('chime', t0('cta', 'send') - 0.05, 0.18);
```

## 11. The rules that stop it looking buggy

1. **Stale frames**: `--full` keeps existing frames. After any change to scenes, script or timeline, render with `--force` (or delete `frames/`), or old frames get encoded.
2. **Words that don't exist**: `W()` throws with the section's words; read the error, fix the word or the index (`W('s1', 'your', 1)` is the second "your").
3. **A frame that depends on the previous one** looks right in the full render and wrong in stills (or the reverse). Set every animated property every frame; never toggle a class and leave it.
4. **SVG colours**: use `currentColor`; `var(--ink)` doesn't work in SVG presentation attributes.
5. **Overflow mid-entrance**: a 1.25x blur-in makes a line box 25 % bigger for a moment; the safe check catches it, fix the size or the `y`.
6. **Prompts that don't fit**: keep typed prompts under ~35 characters (the bar shrinks the text, but not forever).
7. **Captions**: max 5 words a phrase, or it wraps to 3 lines; one key word a phrase; no key word on filler ("the", "and").
8. **Nothing between scenes**: no transitions, no fade to or from black. Frame 0 is a finished picture; the last frame is the CTA, held.
9. **Full-range JPEG frames**: `encode.mjs` converts them to tv-range BT.709; don't remove that filter (washed-out or crushed blacks otherwise).
10. **Fonts**: if `render.mjs` warns that Inter Tight did not load, fix `fonts/` before anything else; a fallback font changes the whole look.
11. **Gaps inside a scene**: a numeral that parks (or anything that leaves) before the next item arrives leaves the stage empty for a few frames. Start the next item while the first one moves; `--at spread:40` finds these gaps.

---

## 12. Sound: effects, music and levels

The voice carries the video. Music is a quiet pulse bed about **13 LU under the voice**, ducked further whenever the voice speaks; effects are small and sparse, and nothing is ever louder than the voice.

**Where to get them.** You don't have the original beds, so either:
- run `node sounds.mjs` (below): it synthesises, offline and free, ten effects (`pop click swish whoosh riser implode impact chime typing glitch`) and a calm 96 bpm bed (a soft Am-F-C-G pad; a kick, sub bass and quiet hats come in at `--lift`, which you set to the film time of the first big beat, e.g. the first numbered point); or
- use the user's own files under the same names: royalty-free only (YouTube Audio Library, Pixabay Music, or a library they license). For music pick a minimal electronic or lo-fi pulse, 90-110 bpm, no vocals, with a build you can land on a beat (`K.music({ file, gain, lift: <s in the track>, liftAt: <film time> })`). Peak-normalise effects to -1 dBFS. ElevenLabs' sound-generation endpoint can also make effects (it costs credits: ask first).

**Levels** (effect files at -1 dBFS peak; `cue(name, t, gain)`):

| Effect | gain | When |
|---|---|---|
| `pop` | 0.04-0.06 | a box or stamp lands; 0.04 for each of a staggered group |
| `click` | 0.10-0.12 | a strike, a cross, a send button, a toggle |
| `typing` | 0.09, `len` = the typing time | a prompt being typed |
| `swish`, `whoosh` | 0.18-0.25 | a big entrance or a theme change, on its word |
| `impact` | 0.20 | a numeral or verdict landing |
| `chime` | 0.18 | the CTA's promise |
| `riser` (`len` ~1 s), `implode`, `glitch` | 0.08-0.10 | rarely: a build-up, things collapsing, an error |

Music: `K.music({ file: 'music/bed.wav', gain: 0.14, start: 0 })` for the synthesised bed; a track of your own starts near `gain: 0.07`. `mix.mjs` prints how many LU the music sits under the voice: aim for 12-14 and change the gain. It fades the music in over 0.6 s and out over the last 1.8 s, and ducks it under the voice (sidechain: threshold 0.04, ratio 5, attack 15 ms, release 320 ms).

```js
// sounds.mjs: synthesises a free sound kit offline: sfx/<name>.wav (10 one-shots) + music/bed.wav (a calm pulse bed).
//   node sounds.mjs [--len 45] [--bpm 96] [--lift 5.2]     len = bed length (s), lift = film time where the beat comes in
// Every file is peak-normalised to -1 dBFS; mix.mjs sets the levels. Replace any of them with your own files (same names).
import fs from 'node:fs';
import { arg } from './lib.mjs';
const SR = 48000, TAU = Math.PI * 2;
let seed = 7; const noise = () => ((seed = (seed * 16807) % 2147483647) / 2147483647) * 2 - 1;
const lpA = fc => 1 - Math.exp(-TAU * fc / SR);                  // one-pole low-pass coefficient
function wav(file, x) {                                           // mono float -> 16-bit PCM WAV at -1 dBFS peak
  let pk = 0; for (const v of x) pk = Math.max(pk, Math.abs(v)); const g = pk ? 0.891 / pk : 0, b = Buffer.alloc(44 + x.length * 2);
  b.write('RIFF', 0); b.writeUInt32LE(36 + x.length * 2, 4); b.write('WAVEfmt ', 8); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
  b.writeUInt32LE(SR, 24); b.writeUInt32LE(SR * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(x.length * 2, 40);
  x.forEach((v, i) => b.writeInt16LE(Math.round(Math.max(-1, Math.min(1, v * g)) * 32767), 44 + i * 2)); fs.writeFileSync(file, b);
}
function synth(sec, fn) { const x = new Float32Array(Math.round(sec * SR)), st = { y: 0, h: 0, p: 0 }; for (let i = 0; i < x.length; i++) x[i] = fn(i / SR, st); return x; }
// filtered noise whose cutoff follows cut(t), shaped by env(t): swishes, whooshes, risers
const sweep = (sec, cut, env) => synth(sec, (t, s) => { s.y = s.y + lpA(cut(t)) * (noise() - s.y); s.h = s.h + lpA(150) * (s.y - s.h); return (s.y - s.h) * env(t); });
const bell = (t, d, p = 0.5) => { const k = t / d; return k < p ? Math.sin(Math.PI / 2 * k / p) ** 2 : Math.cos(Math.PI / 2 * (k - p) / (1 - p)) ** 2; };
const tick = (t, s) => { s.y = s.y + lpA(2500) * (noise() - s.y); return (noise() - s.y) * Math.exp(-t / 0.004); };   // high-passed noise tick

fs.mkdirSync('sfx', { recursive: true }); fs.mkdirSync('music', { recursive: true });
const SFX = {
  pop: synth(0.15, (t, s) => { s.p = s.p + TAU * (300 + 700 * Math.exp(-t / 0.02)) / SR; return Math.sin(s.p) * (1 - Math.exp(-t / 0.002)) * Math.exp(-t / 0.04); }),
  click: synth(0.06, (t, s) => tick(t, s) + 0.4 * Math.sin(TAU * 2200 * t) * Math.exp(-t / 0.01)),
  swish: sweep(0.5, t => 500 + 5000 * Math.sin(Math.PI * t / 0.5) ** 2, t => bell(t, 0.5, 0.45)),
  whoosh: sweep(1.1, t => 300 + 3000 * bell(t, 1.1, 0.65), t => bell(t, 1.1, 0.65)),
  riser: sweep(2.0, t => 200 * Math.pow(45, t / 2), t => (t / 2) ** 2 * Math.min(1, (2 - t) / 0.02)),
  implode: sweep(0.9, t => 6000 * Math.pow(0.05, t / 0.9), t => bell(t, 0.9, 0.8)),
  impact: synth(1.4, (t, s) => { s.p = s.p + TAU * (38 + 30 * Math.exp(-t / 0.08)) / SR; s.y = s.y + lpA(900) * (noise() - s.y); return Math.sin(s.p) * Math.exp(-t / 0.35) + 0.6 * s.y * Math.exp(-t / 0.06); }),
  chime: synth(2.2, t => [[1046.5, 1], [1568, 0.7], [2093, 0.4]].reduce((a, [f, d]) => a + Math.sin(TAU * f * t) * Math.exp(-t / d), 0) * (1 - Math.exp(-t / 0.003)) / 2),
  typing: (() => { const x = new Float32Array(2 * SR); let at = 0.02; while (at < 1.9) { const g = 0.5 + 0.5 * Math.abs(noise()), i0 = Math.round(at * SR), s = { y: 0 }; for (let i = 0; i < 0.04 * SR; i++) x[i0 + i] += g * tick(i / SR, s); at += 0.07 + 0.08 * Math.abs(noise()); } return x; })(),
  glitch: synth(0.45, t => { const seg = Math.floor(t / 0.06); const f = 200 + 1800 * Math.abs(Math.sin(seg * 12.9898) * 43758.5453 % 1); return Math.sign(Math.sin(TAU * f * Math.floor(t * 8000) / 8000)) * 0.5 * (seg % 2 ? 1 : 0.4); }),
};
for (const [k, x] of Object.entries(SFX)) wav(`sfx/${k}.wav`, x);

// the bed: a soft four-chord pad (Am F C G, two bars each); from `lift` a kick, a sub bass and quiet hats come in
const LEN = +arg('--len', 45), BPM = +arg('--bpm', 96), LIFT = +arg('--lift', 0), beat = 60 / BPM, span = 8 * beat;
const CH = [[57, 60, 64], [53, 57, 60], [55, 60, 64], [55, 59, 62]], hz = m => 440 * Math.pow(2, (m - 69) / 12);
const voice = (f, t) => Math.sin(TAU * f * t) + 0.3 * Math.sin(TAU * 2 * f * t) + 0.12 * Math.sin(TAU * 3 * f * t);
wav('music/bed.wav', synth(LEN, (t, s) => {
  const c = Math.floor(t / span); let pad = 0;
  for (const ci of [c - 1, c, c + 1]) {                            // chords cross-fade over 0.3 s at the changes
    const w = Math.max(0, Math.min(1, (t - ci * span + 0.3) / 0.6, (ci * span + span + 0.3 - t) / 0.6)); if (w <= 0) continue;
    for (const m of CH[((ci % 4) + 4) % 4]) pad += w * (voice(hz(m) * 1.0023, t) + voice(hz(m) / 1.0023, t));
  }
  const on = t >= LIFT ? Math.min(1, (t - LIFT) / 0.05) : 0, open = Math.min(1, Math.max(0, (t - LIFT + 1) / 2));
  s.y = s.y + lpA(700 + 1100 * open) * (pad - s.y);            // the pad opens up into the lift
  const bt = ((t - LIFT) % beat + beat) % beat, ht = ((t - LIFT - beat / 2) % beat + beat) % beat;
  const kick = Math.sin(TAU * (45 * bt + 75 * 0.03 * (1 - Math.exp(-bt / 0.03)))) * Math.exp(-bt / 0.16);
  const sub = Math.sin(TAU * hz(CH[((c % 4) + 4) % 4][0] - 24) * t) * Math.exp(-bt / 0.35);
  s.h = s.h + lpA(7000) * (noise() - s.h);
  const hat = (noise() - s.h) * Math.exp(-ht / 0.025);
  const fade = Math.min(1, t / 1.0, (LEN - t) / 2.5);
  return fade * (0.09 * s.y + on * (0.55 * kick + 0.3 * sub + 0.06 * hat));
}));
console.log(`sfx/: ${Object.keys(SFX).join(', ')} · music/bed.wav ${LEN} s, ${BPM} bpm, beat from ${LIFT} s`);
```

## 13. Rendering (`cdp.mjs`, `render.mjs`)

`render.mjs` serves the reel folder on `127.0.0.1` (so fonts and scripts load as on a site), opens **one** headless browser through `cdp.mjs`, waits for `window.ready`, writes `meta.json`, then calls `setT(t)` and takes a JPEG screenshot per frame (quality 92 for frames, 88 for stills). Expect 0.1-0.2 s a frame: 1,800 frames is 3-6 minutes.

**Stills first, and look at them yourself:**
- `--stills --at starts --sheet`: each scene's first frame. Nothing may be empty or half-built.
- `--stills --at scenes --safe --sheet`: one telling moment per scene, with the safe zone drawn.
- `--stills --at spread:40`: 40 even samples, for the coverage and safe-zone checks.
- Every still prints `ok`, **TEXT OUTSIDE SAFE ZONE** (with the text and its box) or **NEAR-EMPTY STAGE**. Then open `stills/sheet.jpg` and check what code can't: the subject reads at phone size, every slide matches its words, grounds alternate, the box lands on the right word, nothing overlaps, text isn't cut off.

```js
// cdp.mjs: a dependency-free headless Chrome/Edge driver (Chrome DevTools Protocol). One browser, GPU, 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', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
  'C:/Program Files (x86)/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/chromium', '/usr/bin/chromium-browser'].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 = 9451 } = {}) {
  if (!CHROME) throw new Error('No Chrome/Edge found: set CHROME=<path to the browser>');
  try { os.setPriority(os.constants.priority.PRIORITY_LOW); } catch { }
  const udd = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-chrome-'));
  const proc = spawn(CHROME, [`--remote-debugging-port=${port}`, `--user-data-dir=${udd}`, '--headless=new', '--no-first-run', '--no-default-browser-check',
    '--disable-extensions', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--mute-audio', '--hide-scrollbars',
    `--window-size=${width},${height}`, '--use-gl=angle', `--use-angle=${ANGLE}`, '--ignore-gpu-blocklist', '--enable-gpu', 'about:blank'], { stdio: 'ignore' });
  // Stay polite: lower the browser and every child process (Chrome raises its GPU process by itself), now and every 5 s.
  // On Windows also pin them to one logical processor, so a render never takes more than 1/N of the machine.
  const pinned = new Set();
  const pin = pids => {
    const ids = pids.filter(p => !pinned.has(p)); if (process.platform !== 'win32' || !ids.length) return; ids.forEach(p => pinned.add(p));
    const ps = `$m=[int64](Get-Process -Id ${proc.pid}).ProcessorAffinity; $b=1L -shl 62; while(($b -band $m) -eq 0){$b=$b -shr 1}; foreach($i in @(${ids.join(',')})){ try { (Get-Process -Id $i).ProcessorAffinity=[IntPtr]$b } catch {} }`;
    execFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps], { windowsHide: true }, () => { });
  };
  const polite = async () => {
    try {
      try { os.setPriority(proc.pid, os.constants.priority.PRIORITY_LOW); } catch { }
      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 = (info?.processInfo || []).map(p => p.id);
      for (const p of pids) { try { os.setPriority(p, os.constants.priority.PRIORITY_LOW); } catch { } }
      pin([proc.pid, ...pids]);
    } 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('the browser did not start (port busy? pass another --port)');
  await polite(); 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(' ') });
    else if (m.method === 'Log.entryAdded') logs.push({ type: 'log:' + m.params.entry.level, text: m.params.entry.text + ' ' + (m.params.entry.url || '') });
  };
  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('Log.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 t = Date.now(); while (Date.now() - t < 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, 200)); await api.waitFor('document.readyState === "complete"', 20000); },
    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(e ? g.getParameter(e.UNMASKED_RENDERER_WEBGL) : g.getParameter(g.RENDERER)) : ''; })()`); },
    async close() {                                   // close the browser cleanly (all its processes), then delete its temp profile
      clearInterval(timer);
      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; }); b.send(JSON.stringify({ id: 1, method: 'Browser.close' })); } catch { }
      if (proc.exitCode === null) await new Promise(r => { proc.once('exit', r); setTimeout(r, 4000); });
      try { ws.close(); } catch { } try { proc.kill(); } catch { }
      for (let i = 0; i < 20; i++) { try { fs.rmSync(udd, { recursive: true, force: true }); break; } catch { await new Promise(r => setTimeout(r, 300)); } }
    },
  };
  return api;
}
```

```js
// render.mjs: renders the film in ONE headless browser, frame by frame, at low priority.
//   node render.mjs --stills [--at 0.5,3.2 | spread:8 | starts | scenes] [--safe] [--sheet]   -> stills/<t>.jpg (+ stills/sheet.jpg)
//   node render.mjs --full [--from 0] [--to 20] [--force]                            -> frames/f00000.jpg ... (resumable)
// Every run writes meta.json (scenes, sfx cues, music, cover time) for mix.mjs and encode.mjs.
import fs from 'node:fs'; import path from 'node:path'; import http from 'node:http';
import { launch } from './cdp.mjs';
import { FPS, arg, has, coverage, need } from './lib.mjs';

need('timeline.js', 'node build-timeline.mjs'); need('scenes.js', 'write scenes.js');
const TYPES = { '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.woff2': 'font/woff2', '.jpg': 'image/jpeg', '.png': 'image/png', '.svg': 'image/svg+xml' };
const server = http.createServer((q, r) => {                 // serve this folder on 127.0.0.1 (fonts and scripts load like on a site)
  const f = path.join(process.cwd(), decodeURIComponent(q.url.split('?')[0]));
  if (!f.startsWith(process.cwd()) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { r.writeHead(404); return r.end(); }
  r.writeHead(200, { 'content-type': TYPES[path.extname(f)] || 'application/octet-stream', 'cache-control': 'no-store' }); fs.createReadStream(f).pipe(r);
}).listen(0, '127.0.0.1');
await new Promise(r => server.once('listening', r));
const base = `http://127.0.0.1:${server.address().port}`;

const page = await launch({ port: +arg('--port', 9451) });
let failed = false;
try {
  const gpu = await page.gpu();
  if (!gpu || /swiftshader|software|basic render/i.test(gpu)) console.log(`NOTE: no GPU in the browser (${gpu || 'none'}): it still renders (it's DOM, not 3D) but slower and heavier on the CPU. Tell the user.`);
  await page.goto(`${base}/film.html${has('--safe') && has('--stills') ? '?safe=1' : ''}`);
  try { await page.eval('window.ready'); } catch (e) { console.error('page failed:', e.message.split('\n')[0]); }
  if (!(await page.eval('!!window.META'))) throw new Error('The film did not build:\n' + page.logs.map(l => '  ' + l.type + ' ' + l.text).join('\n'));
  const META = await page.eval('window.META');
  if (!META.fontsOk) console.log('WARNING: Inter Tight did not load: check fonts/ and film.css (a fallback font changes the look)');
  fs.writeFileSync('meta.json', JSON.stringify(META, null, 1));
  const sceneAt = t => (META.scenes.filter(s => s.start <= t).at(-1) || META.scenes[0]).id;

  if (has('--stills')) {
    fs.mkdirSync('stills', { recursive: true });
    const spec = arg('--at', 'spread:8');
    const ats = spec === 'scenes' ? META.scenes.map(s => s.start + Math.min(1.2, (s.end - s.start) * 0.72))                 // one telling moment per scene
      : spec === 'starts' ? META.scenes.map(s => Math.ceil((s.start + 0.001) * FPS) / FPS)          // each scene's first frame
      : spec.startsWith('spread:') ? Array.from({ length: +spec.slice(7) }, (_, i) => (i + 0.5) / +spec.slice(7) * META.total)
      : spec.split(',').map(Number);
    const rows = []; let problems = 0;
    for (const at of ats) {
      await page.eval(`setT(${at})`);
      const f = `stills/${at.toFixed(2).padStart(5, '0')}.jpg`; await page.shot(f, 88);
      const bad = await page.eval('K.safeCheck()'), cov = coverage(f)[0] ?? 0, flags = [];
      if (bad.length) flags.push('TEXT OUTSIDE SAFE ZONE: ' + bad.join('; '));
      if (cov < 0.03) flags.push(`NEAR-EMPTY STAGE (${(cov * 100).toFixed(1)} %)`);
      problems += flags.length; rows.push({ at, f, id: sceneAt(at) });
      console.log(`${at.toFixed(2).padStart(6)} s  ${sceneAt(at).padEnd(12)} stage ${(cov * 100).toFixed(0).padStart(3)} %  ${flags.join(' · ') || 'ok'}`);
    }
    console.log(`${rows.length} stills${problems ? ` · ${problems} PROBLEM(S)` : ' · all inside the safe zone, none empty'}`);
    if (has('--sheet')) {                                       // a contact sheet, laid out by the same browser
      const n = rows.length, cols = n <= 8 ? 4 : n <= 18 ? 6 : 8, cw = Math.floor(1080 / cols), ch = Math.round(cw * 16 / 9), lh = 30;
      fs.writeFileSync('stills/sheet.html', `<meta charset="utf-8"><body style="margin:0;background:#1b1b1b;width:1080px"><div style="display:grid;grid-template-columns:repeat(${cols},${cw}px)">` +
        rows.map(r => `<div><img src="${path.basename(r.f)}" style="display:block;width:${cw}px;height:${ch}px"><div style="height:${lh}px;font:600 14px monospace;color:#ddd;padding:6px">${r.at.toFixed(2)} s · ${r.id}</div></div>`).join('') + '</div></body>');
      await page.goto(`${base}/stills/sheet.html`); await page.waitFor('[...document.images].every(i => i.complete)');
      const s = await page.send('Page.captureScreenshot', { format: 'jpeg', quality: 86, captureBeyondViewport: true, clip: { x: 0, y: 0, width: 1080, height: Math.ceil(n / cols) * (ch + lh), scale: 1 } });
      fs.writeFileSync('stills/sheet.jpg', Buffer.from(s.data, 'base64')); fs.rmSync('stills/sheet.html');
      console.log('contact sheet -> stills/sheet.jpg (look at it)');
    }
  }

  if (has('--full')) {
    fs.mkdirSync('frames', { recursive: true });
    const N = Math.round(META.total * FPS), from = Math.max(0, Math.floor(+arg('--from', 0) * FPS)), to = Math.min(N, Math.ceil(+arg('--to', META.total) * FPS));
    const t0 = Date.now(); let done = 0;
    for (let i = from; i < to; i++) {
      const f = `frames/f${String(i).padStart(5, '0')}.jpg`;
      if (!has('--force') && 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}/${to} · ${((Date.now() - t0) / done).toFixed(0)} ms/frame`);
    }
    console.log(`frames ${from}-${to} done (${done} rendered in ${((Date.now() - t0) / 1000).toFixed(0)} s)`);
  }
  const errs = page.logs.filter(l => /error|exception/i.test(l.type) && !/favicon/.test(l.text));
  if (errs.length) { console.log('PAGE ERRORS:\n' + errs.slice(-5).map(l => '  ' + l.text).join('\n')); failed = true; }
} finally { await page.close(); server.close(); }
process.exit(failed ? 1 : 0);
```

## 14. The mix (`mix.mjs`)

Voice on top, the bed under it (ducked, faded), every `cue()` placed at its time, then a two-pass `loudnorm` to **-14 LUFS integrated, true peak -1.5 dBTP** and a final oversampled limiter trim. It also prints how far the music sits under the voice. `--to 6` mixes only the first 6 s for the test clip. The mix needs only `vo.wav` and `meta.json`, so you can mix before the full render.

```js
// mix.mjs: voice on top, the music bed ducked under it, the sfx cues -> mix.wav at -14 LUFS, true peak <= -1.5 dBTP.
//   node mix.mjs [--to 6]        (--to: only the first N s -> mix-6s.wav, for a test clip)
// Needs vo.wav (build-timeline.mjs) and meta.json (any render.mjs run). Music + cues are set in scenes.js:
//   K.music({ file: 'music/bed.wav', gain, start | lift + liftAt, lowpass: [[from, to]] })  ·  cue(name, time, gain, len?) -> sfx/<name>.wav
import fs from 'node:fs';
import { execFileSync, spawnSync } from 'node:child_process';
import { FF, arg, readJSON, need, loudness } from './lib.mjs';

const meta = readJSON(need('meta.json', 'node render.mjs --stills'));
need('vo.wav', 'node build-timeline.mjs');
const to = arg('--to') ? +arg('--to') : null, DUR = Math.min(to || meta.total, meta.total), sfx = to ? `-${to}s` : '';
const M = { gain: 0.07, fadeIn: 0.6, fadeOut: 1.8, lowpass: [], ...(meta.music || {}) };
const pre = `pre${sfx}.wav`, out = `mix${sfx}.wav`;
const args = ['-y', '-v', 'error', '-i', 'vo.wav'];
const fc = [`[0:a]aformat=channel_layouts=stereo,atrim=0:${DUR.toFixed(3)},asplit=3[vo][sc][vstem]`];
let mus = false;
if (M.file) {
  need(M.file, 'node sounds.mjs, or put your own track there');
  const start = M.start ?? (M.lift != null ? M.lift - M.liftAt : 0), lead = Math.max(0, -start), ms = Math.round(lead * 1000);   // seconds into the track at film t = 0
  args.push('-ss', Math.max(0, start).toFixed(3), '-t', (DUR + 1).toFixed(3), '-i', M.file); mus = true;
  fc.push(`[1:a]aformat=channel_layouts=stereo,adelay=${ms}|${ms},atrim=0:${DUR.toFixed(3)},afade=t=in:st=${lead.toFixed(3)}:d=${M.fadeIn},afade=t=out:st=${Math.max(0, DUR - M.fadeOut).toFixed(3)}:d=${M.fadeOut},volume=${M.gain}[mraw]`);
  if (M.lowpass.length) {                            // muffled inside these windows (a "moody" stretch)
    const g = '(' + M.lowpass.map(([a, b]) => `clip((t-${a.toFixed(3)})/0.8,0,1)*clip((${b.toFixed(3)}-t)/0.35,0,1)`).join('+') + ')';
    fc.push(`[mraw]asplit=2[md][mw]`, `[mw]lowpass=f=650,lowpass=f=650,volume=1.5,volume='${g}':eval=frame[mwl]`, `[md]volume='1-${g}':eval=frame[mdd]`, `[mdd][mwl]amix=inputs=2:normalize=0[m0]`);
  } else fc.push(`[mraw]anull[m0]`);
  fc.push(`[m0][sc]sidechaincompress=threshold=0.04:ratio=5:attack=15:release=320,asplit=2[m][mstem]`);   // ducks under the voice
} else fc.push(`[sc]anullsink`);
const cues = (meta.cues || []).filter(c => c[1] < DUR);
cues.forEach(([name, t, gain, len], i) => {
  args.push('-i', need(`sfx/${name}.wav`, 'node sounds.mjs, or add your own sfx/' + name + '.wav'));
  const ms = Math.max(0, Math.round(t * 1000)), trim = len ? `atrim=0:${len.toFixed(3)},afade=t=out:st=${Math.max(0, len - 0.12).toFixed(3)}:d=0.12,` : '';
  fc.push(`[${i + 1 + (mus ? 1 : 0)}:a]aformat=channel_layouts=stereo,${trim}volume=${gain},adelay=${ms}|${ms},apad=whole_dur=${DUR}[s${i}]`);
});
const ins = ['[vo]', ...(mus ? ['[m]'] : []), ...cues.map((_, i) => `[s${i}]`)];
fc.push(`${ins.join('')}amix=inputs=${ins.length}:normalize=0:duration=first,atrim=0:${DUR.toFixed(3)}[pre]`);
const maps = ['-map', '[pre]', '-ar', '48000', '-ac', '2', pre, '-map', '[vstem]', '-ar', '48000', 'stem-vo.wav', ...(mus ? ['-map', '[mstem]', '-ar', '48000', 'stem-music.wav'] : [])];
execFileSync(FF, [...args, '-filter_complex', fc.join(';'), ...maps], { stdio: 'inherit' });

// two-pass loudnorm to -14 LUFS, then trim the last tenths with an oversampled limiter
const p1 = spawnSync(FF, ['-hide_banner', '-i', pre, '-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));
execFileSync(FF, ['-y', '-v', 'error', '-i', pre, '-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', out]);
let L = loudness(out);
for (let pass = 0; pass < 3 && Math.abs(-14 - L.I) > 0.1; pass++) {
  fs.renameSync(out, 'tmp-' + out);
  execFileSync(FF, ['-y', '-v', 'error', '-i', 'tmp-' + out, '-af', `aresample=192000,volume=${(-14 - L.I).toFixed(2)}dB,alimiter=limit=0.84:attack=1:release=60:level=false,aresample=48000`, out]);
  fs.rmSync('tmp-' + out); L = loudness(out);
}
const lv = loudness('stem-vo.wav'), lm = mus ? loudness('stem-music.wav') : null;
for (const f of [pre, 'stem-vo.wav', 'stem-music.wav']) fs.rmSync(f, { force: true });
console.log(`mix -> ${out} · ${DUR.toFixed(2)} s · ${L.I} LUFS · true peak ${L.TP} dBTP` + (lm ? (lv.I < -60 ? ' · the voice is silent (fake takes): no music level yet' : ` · music ${(lv.I - lm.I).toFixed(1)} LU under the voice (aim 12-14: change K.music gain)`) : ''));
if (Math.abs(L.I + 14) > 0.6 || L.TP > -1) console.log('WARNING: not at -14 LUFS / <= -1 dBTP');
```

## 15. Encode, check, deliver

`encode.mjs`: H.264 High, `yuv420p`, tv range, BT.709, 1080x1920, 30 fps, `-preset slow -crf 20 -maxrate 10M -bufsize 20M`, AAC 192k 48 kHz, `+faststart`, at most 60 MB (re-encodes with a lower maxrate if over), at most 60 s. It also writes `cover.jpg` from the frame at `K.cover(t)`: the hook, its key word boxed (1.2-2.8 s).

`check.mjs` must print `ALL CHECKS PASS`: the streams, faststart, size, length, -14 LUFS and true peak <= -1 dBTP, frame 0 not empty, no near-empty stage anywhere (sampled 3 times a second), `cover.jpg` and `caption.md` present.

```js
// encode.mjs: frames + mix -> video.mp4 (H.264 High, yuv420p tv-range BT.709, 1080x1920, 30 fps, AAC 192k, +faststart, <= 60 MB)
// and cover.jpg (the frame at K.cover(t)).   node encode.mjs [--to 6]  (-> test-6s.mp4 from mix-6s.wav)
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
import { FF, FPS, arg, readJSON, need } from './lib.mjs';

const meta = readJSON(need('meta.json', 'node render.mjs --full'));
const to = arg('--to') ? +arg('--to') : null, DUR = Math.min(to || meta.total, meta.total), sfx = to ? `-${to}s` : '';
const mix = need(`mix${sfx}.wav`, 'node mix.mjs' + (to ? ' --to ' + to : '')), out = to ? `test${sfx}.mp4` : 'video.mp4';
const n = Math.round(DUR * FPS), missing = [];
for (let i = 0; i < n; i++) if (!fs.existsSync(`frames/f${String(i).padStart(5, '0')}.jpg`)) missing.push(i);
if (missing.length) { console.error(`${missing.length} of ${n} frames missing (first: ${missing[0]}): run render.mjs --full`); process.exit(2); }
const enc = maxrate => execFileSync(FF, ['-y', '-v', 'error', '-framerate', String(FPS), '-i', 'frames/f%05d.jpg', '-i', mix, '-frames:v', String(n), '-t', DUR.toFixed(3),
  '-vf', 'scale=1080:1920:flags=lanczos:in_range=full:out_range=tv:in_color_matrix=bt601:out_color_matrix=bt709,format=yuv420p',   // JPEG frames are full-range
  '-c:v', 'libx264', '-preset', 'slow', '-profile:v', 'high', '-pix_fmt', 'yuv420p', '-r', String(FPS), '-crf', '20', '-maxrate', maxrate, '-bufsize', '20M',
  '-color_range', 'tv', '-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709',
  '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-movflags', '+faststart', out], { stdio: 'inherit' });
enc('10M');
let mb = fs.statSync(out).size / 1e6;
if (mb > 60) { const r = Math.max(4, Math.floor(10 * 57 / mb)); console.log(`${mb.toFixed(1)} MB > 60 MB: again with -maxrate ${r}M`); enc(r + 'M'); mb = fs.statSync(out).size / 1e6; }
console.log(`video -> ${out} · ${DUR.toFixed(2)} s · ${mb.toFixed(1)} MB`);
if (!to) {
  const ci = Math.min(n - 1, Math.round((meta.cover ?? 1.2) * FPS));
  execFileSync(FF, ['-y', '-v', 'error', '-i', `frames/f${String(ci).padStart(5, '0')}.jpg`, '-q:v', '2', 'cover.jpg']);
  console.log(`cover -> cover.jpg (${(ci / FPS).toFixed(2)} s)`);
}
```

```js
// check.mjs: the finished file against the rules.  node check.mjs [--file test-6s.mp4]
// ffprobe (H.264 High yuv420p tv, 1080x1920, 30 fps, AAC (192k asked; quiet audio averages less), faststart, <= 60 MB, <= 60 s) · -14 LUFS, TP <= -1 ·
// frame 0 not empty · no near-empty stage sampled 3x/s · cover.jpg + caption.md present
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
import { FP, arg, need, loudness, coverage } from './lib.mjs';

const file = need(arg('--file', 'video.mp4'), 'node encode.mjs'), bad = [];
const pr = JSON.parse(execFileSync(FP, ['-v', 'error', '-show_streams', '-show_format', '-of', 'json', file], { encoding: 'utf8' }));
const v = pr.streams.find(s => s.codec_type === 'video'), a = pr.streams.find(s => s.codec_type === 'audio'), dur = +pr.format.duration, mb = +pr.format.size / 1e6;
if (!v || v.codec_name !== 'h264' || v.profile !== 'High' || v.pix_fmt !== 'yuv420p' || v.color_range === 'pc' || v.width !== 1080 || v.height !== 1920 || v.r_frame_rate !== '30/1') bad.push(`video stream: ${v?.codec_name} ${v?.profile} ${v?.pix_fmt}/${v?.color_range} ${v?.width}x${v?.height} ${v?.r_frame_rate}`);
if (!a || a.codec_name !== 'aac' || a.bit_rate < 120000) bad.push(`audio stream: ${a?.codec_name} ${Math.round(a?.bit_rate / 1000)}k`);
if (mb > 60) bad.push(`${mb.toFixed(1)} MB > 60 MB`); if (dur > 60.05) bad.push(`${dur.toFixed(2)} s > 60 s`);
const head = fs.readFileSync(file).subarray(0, 1 << 16), moov = head.indexOf('moov'), mdat = head.indexOf('mdat');
if (!(moov > 0 && (mdat < 0 || moov < mdat))) bad.push('no +faststart (moov after mdat)');
const L = loudness(file); if (Math.abs(L.I + 14) > 1) bad.push(`loudness ${L.I} LUFS`); if (!(L.TP <= -1)) bad.push(`true peak ${L.TP} dBTP`);
const f0 = coverage(file, { crop: '1080:1230:0:250', first: true })[0] ?? 0; if (f0 < 0.03) bad.push(`frame 0 looks empty (${(f0 * 100).toFixed(1)} %)`);
const low = coverage(file, { fps: 3 }).map((c, i) => [i / 3, c]).filter(([, c]) => c < 0.03);
if (low.length) bad.push('near-empty stage at ' + low.slice(0, 8).map(([t, c]) => `${t.toFixed(2)} s (${(c * 100).toFixed(1)} %)`).join(', '));
if (file === 'video.mp4') for (const d of ['cover.jpg', 'caption.md']) if (!fs.existsSync(d)) bad.push('missing ' + d);
console.log(`${file}: ${dur.toFixed(2)} s · ${mb.toFixed(1)} MB · ${v?.width}x${v?.height} ${v?.profile} ${v?.pix_fmt}/${v?.color_range} · ${L.I} LUFS, TP ${L.TP} · frame 0 ${(f0 * 100).toFixed(0)} %`);
console.log(bad.length ? 'PROBLEMS:\n  ' + bad.join('\n  ') : 'ALL CHECKS PASS');
process.exit(bad.length ? 1 : 0);
```

**Before you say it's done:** pull ~12 frames from the final mp4 (`ffmpeg -ss <t> -i video.mp4 -frames:v 1 f<t>.jpg`) into one contact sheet and look at it once more (compression, legibility, the box on the right words).

**`caption.md`** (the deliverable next to the video):

```
# <title, max 60 characters>
## Script (as voiced)
<the final script, one sentence a line>
## Caption
<line 1: the hook in other words>
<2-4 short lines: the points>
Comment <KEYWORD> and I'll send you <the thing>.
<3-5 hashtags>
DM: <exactly what you will send to commenters>
```

**Deliver:** copy `video.mp4`, `cover.jpg` and `caption.md` into a normal user folder (e.g. `Videos/<series>/`), named after the title; never overwrite an earlier version (add `v2`, `v3`). Open the video in the default player, and give a short report: title, length, scene count, voice and settings, anything that was a choice (takes redone, pads added), what you would push further, and honestly any flaw you saw. Don't post it anywhere.

---

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

- [ ] Requirements: Node 22+, Chrome/Edge, ffmpeg 6+, fonts fetched, key present (never printed).
- [ ] Brief in 3 lines: topic, the one useful thing, the CTA keyword and what it sends.
- [ ] Script: hook first, one drawable idea per sentence, phrases of 2-5 words with one key word, 100-170 words, CTA last.
- [ ] `gen-vo.mjs --dry`, then the takes (or `--fake` first); no WARNING lines.
- [ ] `build-timeline.mjs`: 35-60 s; the word list is open while writing scenes.
- [ ] `scenes.js`: one scene per beat, `at()` starts, entrances at `this.in`, payoff on the boxed word, grounds alternate, the hook sharp at frame 0, the CTA tiles at the end.
- [ ] Stills: `--at starts`, `--at scenes --safe`, `--at spread:40` all clean, sheets looked at.
- [ ] Sound: effects and bed made or chosen, cues under each scene.
- [ ] 6-second test passes `check.mjs`.
- [ ] Full render (`--force` after changes), mix at -14 LUFS with the music 12-14 LU under, encode, `check.mjs` ALL CHECKS PASS.
- [ ] Final contact sheet from the mp4, `caption.md`, delivered to a user folder, short honest report. Nothing posted.
