---
name: pixel
description: Make a narrated vertical reel (1080x1920, 45-60 s) in the "Pixel" format - a pixel-Claude courtroom on graph paper - a judge in a white barrister wig with a gavel over a hard-shadow plaque, a file chip or a typed pitch card on an isometric orange slab, twelve pixel-Claude jurors on two benches with role icons, speech and quote bubbles, vote paddles that flip to a cross or a check while a meter counts them, GUILTY / NOT GUILTY stamps, stat chips and a comment box that types the keyword, with top-left headlines that change on every phrase and a camera that pans and zooms between the stations. One calm ElevenLabs voice and a synthesized music bed, drawn frame by frame on a 2D canvas in headless Chrome and encoded with ffmpeg. Use it when someone asks for a pixel reel, "pixel.md", a jury or courtroom of Claudes, "what happens if 12 Claudes judge X", or wants to turn a script into this kind of video.
---

# Pixel reel: how to make one

You will make a **45-60 second vertical video (1080x1920, 30 fps, H.264 + AAC, at most 60 s and 60 MB)** that looks like **a courtroom of pixel Claudes drawn on graph paper**. One long world sits on a light grid that zooms with the camera. At the top a **judge**, a pixel Claude (an orange block with two square black eyes, stubby arms and four legs) in a white barrister wig, holds a brown gavel and stands on a **white plaque with a hard black shadow** ("■ THE PIXEL COURT ■" in letter-spaced mono caps). Under it a **file chip** or a **pitch card** lies on an **isometric orange slab**. Below that sit **two black benches with twelve pixel-Claude jurors**, and under them the **vote meter** and **stat chips**. The camera **pans and zooms from station to station** and the world fades out at the top and bottom of the stage. A **bold headline in the top left** (black, with orange key words) changes on every spoken phrase. A small **footer** (a tiny pixel Claude + the format's file name) sits under the stage, and at the end a **comment box** rises and types the keyword.

**`reference.jpg`** (next to this file, if you have it) is a contact sheet of 12 frames from a finished Pixel reel: **this is what it should look like.** Left to right, top row first: (1) the hook: the judge with the gavel on the plaque, the slab's tip peeking in at the bottom, the headline "Never pay for a Claude / content skill again."; (2) the jury popping in on the two benches under the slab; (3) a file chip "Morphy · agentic super app" on the slab under the plaque; (4) two file chips stacked on the slab, "format.md" and "SKILL.md"; (5) a pitch card typing "Content Skill PRO" with an orange caret, chips "for sale" and "$30 a copy" below; (6) the jurors with white speech bubbles "$30?!" and "It was free."; (7) the GUILTY stamp on the card, the gavel down; (8) twelve orange paddles with crosses and the meter "GUILTY 12"; (9) one juror lit, the rest faded, an orange bubble "It's just a text file."; (10) the card flipped to an orange "v2" with chips under the slab; (11) NOT GUILTY in green with confetti; (12) the whole court from far, three stat chips "0 paywalls / 0 subscriptions / 0 strings" and the comment box typing "PIXEL". If you only have this file, section 3 describes everything with numbers.

Everything is drawn **one frame at a time** on a 2D canvas in headless Chrome (`window.setT(t)`), then muxed with a mix made in ffmpeg. This file gives you the look (exact colours, fonts, sizes, positions), the grammar of the script and of the edit, the rules that keep it from looking buggy, the complete tested engine (Appendix A: write it once) and a complete example video (Appendix B). With the engine written, **a video is six small files**: `script.mjs` (what the voice says and the headlines show), `times.mjs` (every beat's time, from the words), `scene.js` (the camera and what each beat shows), `cues.mjs` (sound effects), `music.mjs` (the bed's chords) and `video.json` (settings).

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

---

## 0. Rules of engagement (read first)

1. **Be gentle with the user's computer.** One headless browser, at the lowest priority (`cdp.mjs` pins it to one CPU on Windows), never parallel workers. The film is a 2D canvas: no WebGL needed, and it renders at ~9 frames a second on an ordinary PC.
2. **Never print secrets.** The key is `ELEVENLABS_API_KEY` (environment, or `.env` in the project folder). Scripts read it; you never echo it, log it or put it in a file you show.
3. **Spend credits deliberately.** `gen-vo.mjs` and `gen-sfx.mjs` are dry runs unless you pass `--yes`. One voice take of the whole script (~1 credit per character, ~800-900 for a reel). Never regenerate a take that exists: fix a wrong word with a one-line re-take (`--only`) spliced in with `voedit.mjs`.
4. **Stills before film.** Direct the whole scene on fake timings and check stills before you pay for the voice, and never start the full render until the stills contact sheet is right.
5. **Background jobs die when your turn ends.** Render in foreground chunks (`render.mjs my-video full 0 600`, then `full 600 1200`, then `full 1200`), each call under your tool timeout. The render resumes where it stopped.
6. **No real people, no real brands.** The jurors and the judge are drawn pixel characters. No screenshots, real app icons, logos, handles or view counts; every card, chip and file is drawn. A number on screen must be one the script says.
7. **Don't publish anything.** You deliver files. Posting is the user's call.
8. If the user's instructions conflict with this file, the user wins. If something is missing (no key, no ffmpeg), say exactly what and offer the closest thing. Never fake success.

## 1. Requirements

- **Node 22+** (global `fetch` and `WebSocket`), **ffmpeg + ffprobe** on PATH, **Chrome or Edge** (or set `CHROME=<path>`). Debug ports 9650-9699 on 127.0.0.1. No npm packages.
- **An ElevenLabs API key** for the voice and, once, the sound effects. The free plan is enough to try the format but has no commercial licence; videos that promote anything need a paid plan. No key yet: do everything up to the stills with `build.mjs --fake` and ask for the key.
- Fonts are free downloads (`setup.mjs` fetches them). The music bed is synthesized (`make-music.mjs`), no licence needed. The 18 sound effects are made once with ElevenLabs sound generation (`gen-sfx.mjs`, ~19 s of sound) and 3 more are synthesized (`synth-sfx.mjs`).

## 2. Project layout and setup

```
pixel-reel/                      one project folder, many videos
  .env                           ELEVENLABS_API_KEY=...   (never print it)
  engine/                        Appendix A: write these files once, never edit them per video
    film.html main.js kit.js lib.js              the page: fonts -> film.json + scene.js -> window.setT(t); every component; helpers
    common.mjs cdp.mjs setup.mjs script-text.mjs build.mjs gen-vo.mjs voedit.mjs votight.mjs
    gen-sfx.mjs synth-sfx.mjs make-music.mjs render.mjs mix.mjs encode.mjs check.mjs
  fonts/  sfx/                   downloaded / generated once
  my-video/                      one folder per video (Appendix B is a complete one)
    script.mjs  times.mjs        what the voice says + the headline markup · every beat's time
    scene.js  cues.mjs music.mjs the director file · the sound effects · the bed's chords and dips
    video.json                   settings
    vo/ film.json music/bed.wav stills/ frames/ mix.wav video.mp4 cover.jpg
```

```bash
mkdir pixel-reel && cd pixel-reel
# write engine/* from Appendix A, then:
node engine/setup.mjs            # checks Node/ffmpeg/Chrome/key, downloads the fonts, lists the sounds still to make
node engine/gen-sfx.mjs          # dry run: lists the 18 sounds; then --yes to make them once
node engine/synth-sfx.mjs        # the riser and the two pitched pops (needs sfx/pop.wav)
```

A video:

```bash
mkdir my-video                    # write my-video/script.mjs (section 4); copy times.mjs, scene.js, cues.mjs, music.mjs from Appendix B and rewrite them
node engine/build.mjs my-video --fake                                 # guessed timings: direct before you pay for a voice
node engine/render.mjs my-video stills auto 8 --safe                  # look at my-video/stills/sheet-safe.jpg; fix; repeat
node engine/gen-vo.mjs my-video                                       # dry run: the text the voice will read + its cost
node engine/gen-vo.mjs my-video --yes                                 # ONE take -> my-video/vo/a.*
node engine/votight.mjs my-video --from a --out b --set               # the format's pace (pauses kept short, x1.03)
node engine/voedit.mjs my-video --list b                              # the words with their indexes
node engine/voedit.mjs my-video --out c --use b:0- --pause 68:0.55,103:0.55   # a breath before each verdict
node engine/build.mjs my-video                                        # real timings: prints every headline with its words' times
node engine/render.mjs my-video stills 0.5,4.3,12.8,25.8,29.7,40.5,45.8,53.4 --safe
node engine/render.mjs my-video full 0 600 && node engine/render.mjs my-video full 600 1200 && node engine/render.mjs my-video full 1200
node engine/render.mjs my-video scan                                  # every frame's text against the safe zone
node engine/make-music.mjs my-video && node engine/mix.mjs my-video && node engine/encode.mjs my-video && node engine/check.mjs my-video
```

## 3. The look, pinned down

### 3.1 The frame, layer by layer (1080x1920 px)

| Layer | Where | What |
|---|---|---|
| Paper | everywhere | `#F9FBF6` |
| Headline | x 180, first baseline y 302, second y 368 (cap tops ~y 257) | Inter 860, 58 px (shrinks to fit 742 px), letter-spacing −1.1 px, ink with orange key words; one per spoken phrase; blurs in over 0.17 s, out over 0.09 s |
| The stage | y 383-1424 | the world: grid + court + jury, faded in over y 383-450 and out over 1352-1424 |
| Footer | centred on x 540, y 1458 | a 3.4-px-cell pixel Claude + the file name ("pixel.md") in Space Mono 700 22 px |
| Comment box | x 183-900, y 1347-1421 (the end only) | white, ink border 3 px, hard shadow 8 px, radius 9: an empty avatar circle, the typed keyword (Inter Tight 900 34 px, orange caret), an orange "Post" button |

**Safe zone (every platform covers the edges):** 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. The headline at the top left is this format's caption and its look. The kit records every text box it draws (world text only where the stage shows it); `render.mjs ... --safe` draws the zone and the boxes, `render.mjs ... scan` tests every frame, and `check.mjs` reads the result.

### 3.2 Colours

| Token | Hex | Used for |
|---|---|---|
| paper / grid | `#F9FBF6` / major `#E5E7E1`, minor `rgba(222,226,216,.55)` | the background and its grid |
| ink | `#0C0D09` | outlines, benches, shadows, text |
| orange | `#EB511A` | key words, plaque squares, solid chips, quote bubbles, crosses, the caret, Post |
| Claude | `#D4725A` body, `#0B0A05` eyes, `#D97757` the asterisk icon | jurors, judge, footer |
| wig / gavel | `#F2F3EE` / head `#6B3E26`, handle `#8C5634` | the judge |
| slab | top `#EF5016` (lighter `#F25A20` at the back), left `#BB380C`, right `#982C0F` | the pedestal |
| green | `#30CD5B`, dark `#21AE4B` | checks, NOT GUILTY, the meter's right side |
| greys | paddle `#E4E1DC`, meter off `#DCDDD6`, subtitle `#7A7B74`, icon ring `#8E908A`, icon ink `#4B4D46`, placeholder `#B9BAB3` | the quiet parts |

### 3.3 Fonts

**Inter** (variable, pinned to 860) for the headline, **Inter Tight** (variable, 800-900) for cards, chips, bubbles and stamps, **Space Mono** Regular and Bold for the plaque, chip titles and subtitles, the meter and the footer. All SIL OFL from google/fonts; `setup.mjs` downloads them to `fonts/`.

### 3.4 The world (world units = pixels at zoom 1; y grows downward)

The camera maps a world point to the screen: `sx = 540 + (wx − cam.x)·cam.z`, `sy = 960 + (wy − cam.y)·cam.z`. Everything is centred on x 0:

| Thing | Where | Size |
|---|---|---|
| grid | a major line every 126 units through x 0 and y −351, minor every 25.2 (shown when zoomed in) | 1.6 / 1 px on screen |
| judge | feet on the plaque's top (y −351.5), centred | the juror sprite at cell 17 + wig + gavel |
| plaque | centre y −304 | 95 tall, as wide as its text + 218 (≈ 564 for "THE PIXEL COURT"); border 2.9, shadow offset 9.7 |
| file chip / card | centre y −132 (lying on the slab's top) | chip 400 × 127 (min 320, grows with its text), card 560 × 146 |
| slab | centre (0, 0) | top rhombus 220 × 123, 29 deep |
| chip rows | y 157 and 253 | chips 58 tall, side by side, 22 apart |
| benches | y 462 and 654 | 804 × 12 |
| jurors | 6 per bench at x ±69.3, ±207.9, ±346.5 | 10 × 7 cells of 11.6 (116 × 81) |
| meter / stat chips | y 780 | meter from x −402 to 402; stat chips 98 tall |

### 3.5 The characters

- **The pixel Claude** (`K.claude`): a 10 × 7 grid of cells, its feet at the given point. Body 8 × 5 cells, one-cell arms sticking out on row 2, four legs (columns 1, 3, 6, 8 of the 10) two cells tall, two black one-cell eyes on row 1 (columns 3 and 6). Drawn as one path so no seams show. It blinks (the eyes squash to 20 % for 0.14 s, every 3-6 s, seeded per character) and pops in from nothing with an overshoot.
- **The judge** (`K.judge`): the same sprite at cell 17 with a wig: side flaps behind the arms (white, ink outline), two stacked white slabs on its head. The gavel pivots in its right hand: at rest it lies over the wig at −139°, on a strike it lifts upright (−90°) in 0.17 s, slams down to +22° onto the plaque in 0.09 s (three orange sparks spring up there), holds 0.1 s and settles back in 0.36 s; the judge squints on the hit.
- **Jurors** (`K.jurors`): pop in one by one (`pop: [t0, stagger]`), and dim to 36 % except the ones you keep (`dim`) when one of them speaks.

### 3.6 The components (section 7.3 lists their options)

The plaque (an orange tint flash on a key word), the slab, the **file chip** (an icon tile + a mono title + a grey subtitle, rotated −2.5°, orange border, black shadow; a new face flips it), the **pitch card** ("the pitch" header, a big title that types with an orange caret or shows a grey placeholder; its v2 face has an orange border and shadow), **stamps** (white box, 5 px coloured border, Inter Tight 900, rotated −5° to −6°, slam from 1.75× in 0.11 s then a damped settle; NOT GUILTY adds green and orange confetti squares), the **benches**, **role icons** over the jurors (white discs with a grey ring and a line icon), **bubbles** (white speech, white label with an orange icon, orange quote with white text; a tail toward the juror; a hard shadow), **paddles** (a 41-px rounded square on a stick, grey blank → orange ✕ or green ✓, flipping like a card), the **meter** ("GUILTY n" orange on the left, twelve segments, "n NOT" green on the right; it counts the paddles as they flip), **chips** under the slab (solid orange with white text, or white with an ink border and orange key words, an optional icon), **stat chips** (a big orange number + a label in a bordered box), the **comment box**.

### 3.7 The camera

Six framings (the world point at the screen centre, the zoom), measured on the reel this format comes from:

| Framing | x, y, z | Shows |
|---|---|---|
| open | 0, −297, 1.545 | judge + plaque big, the slab's tip at the bottom (the hook) |
| chip | 0, −112, 1.243 | plaque + the chip or card on the slab |
| jury | 0, 505, 1.0 | the slab at the top, both benches, the meter |
| judge | 0, −50, 1.067 | judge, plaque, card and slab, the jurors' heads at the bottom (the verdicts) |
| flip | 0, 188, 1.0 | card + slab + the chip rows, the benches at the bottom |
| whole | 0, 262, 0.663 | everything, with the stat chips (the end) |
| close-up | a juror's x + 144, 430, 1.25 | one juror speaking, the bench running off the edge |

Moves take 0.45-0.8 s, eased in and out, and start 0.1-0.25 s before the phrase that needs them; jurors pop in during the pan that reveals them. **The camera never quite stops:** once a move lands it keeps creeping in about 2 % (and a few pixels aside) over 3 s, until the next move. Strikes and stamps add a 2-3 % zoom punch that settles in 0.5 s.

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

1. **The hook (0-3 s).** The judge on the plaque, big; the headline is the hook; the gavel strikes on its last stressed word.
2. **The jury appears.** The camera pans down past the slab; the benches fade in and twelve jurors pop in one by one, left to right, top bench first.
3. **The thing on the slab.** Back up: a file chip lands on the slab and changes face with the story (who, what, which file); a second chip can land on top of it.
4. **The card types.** A pitch card types its title on the word; chips land under the slab with the facts.
5. **The jury debates and votes.** Role icons over each juror; two speech bubbles; blank paddles rise one by one.
6. **The verdict.** A breath, the camera reaches the judge, the gavel lifts and lands with the stamp (and a sub boom); then down to the jury: the paddles flip one by one and the meter counts them.
7. **Testimony.** The camera closes in on one juror, the others fade, a label then an orange quote bubble over it.
8. **The flip.** The stamp fades, the card flips to its v2, chips land under it; "Run it again"; NOT GUILTY with confetti; twelve green checks.
9. **The pull-back.** Everything from far, three stat chips one by one, the file chip shows the format's name, the comment box rises and types the keyword.

## 4. Writing for this format

- **The narrator speaks in the first person**, calmly, with a breath between phrases: **~150 words a minute**, so **130-150 words = 52-58 s** (60 s is the limit).
- **The shape** (the grammar the look was built for): **hook question** "What happens if twelve Claudes judge X?" → **what it is** "It's one file." → **the jury** "Twelve jurors. Each one's a different Claude with a different job." → **roll call** (4-5 roles, one juror lit each, "And yeah… even your competitor.") → **the rules** "You pitch once. They argue it out. Then they vote." → **"So let's try one."** + the example, typed → **"Guilty."** + the count → **testimony** "The customer says, '…'" → **the flip** "Okay. Fair. So I flip it." → **"Run it again."** → **"Not guilty. Twelve to zero."** → **three numbers** → **CTA** "Want it? Comment KEYWORD, and I'll send it to you."
- **Any message fits as a trial:** put what the script argues against on the card (GUILTY) and what it argues for as the v2 (NOT GUILTY). The example does exactly that.
- **Short phrases (2-7 words), one per line:** each is a headline, and the edit changes on every one. Every sentence needs a literal visual from the kit: a chip, a card that types, a stamp, a vote, a quote, a number.
- `script.mjs`: `LINES` = one headline each; inside a line `*word*` makes an orange key word (it may span words: `'*content skill again.*'`) and `|` breaks the headline onto its second line; the markup is never spoken. `SAID` = how the voice says a token with dots or symbols (`'SKILL.md': 'skill dot M D'`, `'$30': 'thirty dollars'`, `'.md': 'dot M D'`) and the keyword in quotes so it's stressed (`'PIXEL': '"Pixel"'`).

## 5. The voice (ElevenLabs)

- **Brian** (`nPczCjzI2devNBz1zQrb`) on **eleven_multilingual_v2**, stability 0.45, similarity 0.8, style 0.35, speaker boost, **speed 1.0**: a deep, calm narrator (median pitch ~115 Hz). Another voice: put `voice` in `video.json`; try it with `--test 2 --take t1` first.
- **One take of the whole script**, then **`votight.mjs`** (keeps 0.36 s after `. ! ?`, 0.16 s after `, ;`, 0.05 s between words, ×1.03 with the pitch kept), then **`voedit.mjs --pause`** to add ~0.55 s before each verdict (the riser needs the room). The example's take: 58.5 s raw → 54.2 s tightened → 55.75 s with four pauses.
- **A word that comes out wrong:** make a one-line re-take (`gen-vo.mjs my-video --yes --only 3 --take s1`), list the words (`voedit.mjs my-video --list a`) and splice it in (`--out c --use a:0-39,s1:0-4,a:45-`).

## 6. Timings: film.json

`build.mjs` maps the shown tokens onto the said words (so "SKILL.md" spans "skill dot M D"), moves each word's start to where its sound starts, and writes `film.json`: `words` (each token with `t0`/`t1`, its line, orange or not, a line break before it or not), `heads` (one headline per line: it takes over 0.1 s before its first word and holds until the next; the first one shows from frame 0), `env` (the voice's loudness per frame) and `END` (last word + 1.6 s). It prints every headline with its words' times: **you key everything to these.** `--fake` guesses the timings at Brian's pace so you can direct and render stills before the voice exists; re-run without `--fake` after the take and only nudge what moved.

## 7. Directing

### 7.1 `times.mjs`: every beat from the words

One function shared by the picture, the sound and the music: `makeTimes(film)` returns `T(line, word, n)` (when the n-th match of that word in that script line starts), `TE` (when it ends), `LS(line)` / `LE(line)` (a line's first start / last end) and every beat as a named time: the camera moves (`pans`), the strikes, the pops, each chip's and card's in and out, the stamps, the paddles' raises and flips, the bubbles, the stat chips, the comment box, the cover. A beat lands on its word (+0-0.1 s); a camera move starts 0.1-0.25 s before its phrase.

### 7.2 `scene.js`: the director file

```js
import { makeTimes } from './times.mjs';
export default function setup(E) {
  const { K, film, lib } = E, X = makeTimes(film);
  const F = { open: { x: 0, y: -297, z: 1.545 }, jury: { x: 0, y: 505, z: 1 }, … };          // the framings (3.7)
  const MOVES = [[X.pans.toJury1, F.jury, 0.78], …];                                         // [start, target, seconds]
  const camera = t => …;                                                                     // moves + creep + punches
  function world(g, t) { K.judge(…); K.plaque(…); K.slab(g); K.fileChip(…); K.card(…); K.stamp(…); K.chip(…); K.benches(…); K.jurorIcons(…); K.paddles(…); K.jurors(…); K.meter(…); K.bubble(…); K.stat(…); }
  function overlay(g, t) { K.commentBox(g, t, …); }
  return { camera, world, overlay };
}
```

Draw in that order every frame: judge, plaque, slab, the chips and card on the slab, stamps, chips under the slab, benches, icons, paddles, jurors, meters, bubbles, stat chips. The headline and the footer are drawn by the engine. Copy Appendix B's camera code (`lerpC`, `creep`, `step`, `FROM`, `camAt`, the punches) as it is; a new video rewrites `times.mjs`, the moves, the faces, the chips, the bubbles, the stats and the cues.

### 7.3 What the kit gives you (`K` in `setup(E)`)

| Call | What it does |
|---|---|
| `K.judge(g, x, y, t, { strikes: [t…] })` · `K.plaque(g, t, { text, flash: [[t0, t1]] })` · `K.slab(g)` | the judge (y = its feet: `L.plaqueY - L.plaqueH / 2`) and its gavel · the plaque with an orange flash · the pedestal |
| `K.fileChip(g, t, { faces: [[t, { title, sub, icon, iconCol, border }]], tIn, tOut, x, y, dx, dy, rot, drot, a, leaving })` | the chip on the slab; a new face flips it; `leaving: true` while it's thrown off screen (its text isn't tested then) |
| `K.card(g, t, { faces: [[t, { head, title, typed: [t0, t1], placeholder, caret, v2, size }]], tIn, tOut, rot })` | the pitch card; `title` takes `*orange*` markup; a new face flips it |
| `K.stamp(g, t, { text, t0, t1, x, y, rot, size, color, confetti })` | GUILTY (orange) / NOT GUILTY (`color: COL.GREEN_D`, `confetti: true`) |
| `K.benches(g, t, { tIn })` · `K.jurors(g, t, { pop: [t0, stagger], dim: [[t0, t1, [kept…]]] })` · `jurorPos(i)` | the jury (0-5 the top bench, 6-11 the lower) |
| `K.jurorIcons(g, t, { t0, stagger, t1, icons })` | a role icon over each juror (`K.ICONS` or 12 names of your own: trend user code scales swords mega doc tag question pen play shield check x app star cart box) |
| `K.bubble(g, t, { juror, kind: 'speech' \| 'label' \| 'quote', text, icon, size, t0, t1 })` | a bubble over a juror (`text` may be two lines: `['line 1', 'line 2']`) |
| `K.paddles(g, t, { raise: [[t0, t1, stagger]], flips: [[t0, stagger, 'x' \| 'v' \| [12 kinds], until]] })` · `K.meter(g, t, { tIn, tOut, paddles, green, labels: ['GUILTY', 'NOT'] })` | the vote, and its count |
| `K.chip(g, t, { text, kind: 'solid' \| 'white', icon, iconCol, x, y, t0, t1 })` · `K.chipW(g, chip)` | a chip under the slab (rows at `L.chipRowY`) |
| `K.stat(g, t, { num, label, x, y, t0, pulse })` · `K.statW(g, stat)` | a stat chip (the row at `L.statY`) |
| `K.commentBox(g, t, { t0, y: 1347, text, typed: [t0, t1], press })` | the comment box (screen space, in `overlay`) |
| `K.icon(g, name, x, y, size, colour, lineWidth)` · `K.hardBox` · `K.rich` · `K.spans` | the drawing helpers |
| `lib`: `env` `popS` `settle` `typedN` `caretOn` `eio` `eo` `back` `prog` `clamp` `lerp` `hash` · `K.COL` · `K.L` | timing helpers · the palette · the layout |

### 7.4 The edit follows the words

- **A new headline on every phrase**, with a breath between them.
- **On the word, not near it:** pops, flips, typing, stamps and strikes land on their word (+0-0.1 s), each with its sound.
- **Before a verdict:** ~0.9 s of silence (voedit), a riser under it, the bed dips, the camera reaches the judge before the word, the gavel lifts 0.26 s early and lands with the stamp and the boom.
- **Paddles** flip 0.064-0.07 s apart and the meter counts each one.
- **Frame 0 is the cover-worthy hook:** the judge, the plaque, the slab's tip, the first headline.

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

1. **Every frame is a pure function of `t`:** no `Math.random`, no state between frames (use `lib.hash`, seeds). Any frame renders alone, in any order.
2. **A chip row at y 253 sits in the stage's bottom fade in the `chip` framing:** show chip rows from the `flip` framing.
3. **Moves may overlap** (each starts from wherever the camera is), but don't start a move while a big one is still travelling.
4. **Text that flies off screen** crosses the safe edge for a few frames: give it `leaving`.

## 8. Stills and the safe zone

```bash
node engine/render.mjs my-video stills 0.5,4.3,12.8,25.8,29.7,40.5,45.8,53.4 --safe
```
Look at `stills/sheet-safe.jpg` (red = keep text out, every text box green or red). The run prints `OUTSIDE SAFE ZONE` lines and page errors: both must be empty. Check the hook (the judge and the headline), the jury pop-in, a chip beat, a typing beat, the verdict (the stamp on the card), the count (paddles + meter), the testimony (the bubble inside x 960), the end (stat chips and the comment box clear of each other). Then stills without `--safe` for colour.

## 9. The full render

`node engine/render.mjs my-video full [from] [to]` writes `frames/f0000.jpg…` (~0.11 s a frame: about 3.5 min for 57 s). It **resumes** (existing frames are skipped): after any change to `scene.js` or `times.mjs`, delete `frames/` (or just the frames you changed: `f%04d.jpg` = t × 30). Run it in chunks under your tool timeout, then `render.mjs my-video scan`.

## 10. Sound

- **The bed:** `make-music.mjs` synthesizes a warm pad (soft detuned partials) over a sub-bass root from `music.mjs`: the chords (`[t, 'Fmaj7' | 'G6' | 'Am7' | 'Em7' | 'Dm9' | 'E' | 'C' | 'Cmaj7' | 'F' | 'G' | 'Am', { hit }]`, a `hit` attacks fast for a verdict) and the dips (`[start, fully down, back]`: the bed sinks under each riser and returns with the boom). `video.json` music `db −7` puts it ~13 LU under the voice; `STEM=vo` / `STEM=music` / `STEM=sfx node engine/mix.mjs my-video` measure them (effects ~7 LU under the voice).
- **The kit** (`sfx/<name>.wav`): `pop` / `pop_hi` / `pop_hi2` (jurors, bubbles, chips), `click` (icons, keys), `flick` (a face change, a paddle flip), `slap` (a chip or card lands), `stamp`, `thud` + `clack` (the gavel), `impact` (the verdict boom), `riser` (into a verdict), `whoosh` (pans), `swoosh` (something thrown), `swish` (the gavel lifts, paddles rise), `kaching` (a price), `typing` (the card), `ding` (a good chip, the plaque's flash), `chime` + `burst` (NOT GUILTY), `clunk` (stat chips), `shimmer` (the format's name).
- **Levels and timing:** the example's cues carry a global −5 dB and most cues sit at −15 to −27 dB; `ev(name, t, dB, maxDur)` starts the file at `t`, so subtract each sound's onset (the example's `ON` table; `gen-sfx.mjs` shifts every generated sound so its attack lands at exactly that onset).
- **Keep effects off key words** (the product's name, the keyword): a click or a shimmer on top of a name can make it unintelligible. Move the cue, or lower it.

## 11. Encode, check, deliver

`node engine/encode.mjs my-video` -> `video.mp4` (H.264 High, yuv420p TV range, crf 20, maxrate ≤ 10 Mb/s, AAC 192k, faststart, ≤ 60 MB) + `cover.jpg` (the frame at `cover`: the finished hook). `node engine/check.mjs my-video` checks format, length, size, loudness (−14 LUFS, true peak ≤ −1), frame 0, the headline's place and the safe zone on every frame, and tells you how often the picture holds still. Then **watch it once with sound** (or run a speech recogniser over it and compare with the script) and deliver `video.mp4`, `cover.jpg` and the script. Delete `frames/` (~280 MB).

## 12. Checklist

- [ ] 130-150 words in the format's grammar, short phrases, every sentence showable; the CTA keyword one easy word.
- [ ] The voice tightened (`votight`), a breath before each verdict (`voedit`), every word clear.
- [ ] Frame 0 = the hook; a new headline on every phrase; the camera moves before the phrase that needs it and keeps creeping after.
- [ ] The jury pops in during its reveal; strikes, stamps, flips and typing land on their words; paddles flip one by one with the meter.
- [ ] Drawn characters only; no real logo, screenshot or handle.
- [ ] `--safe` stills clean, `scan` clean, `check.mjs` all ✓, watched once with sound.

---

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

The browser part (`film.html`, `main.js`, `kit.js`, `lib.js`) and the Node scripts (`.mjs`). Write every file once; a video never edits them.

### `engine/film.html`

```html
<!doctype html>
<html><head><meta charset="utf-8"><title>pixel</title>
<style>html,body{margin:0;background:#000;overflow:hidden}canvas{display:block}</style>
</head><body>
<script type="module">
window.addEventListener('error', e => console.error('ERR', e.message, e.filename, e.lineno));
window.addEventListener('unhandledrejection', e => console.error('REJ', String(e.reason && e.reason.stack || e.reason)));
import('./main.js');
</script>
</body></html>
```

### `engine/main.js`

```js
// Engine boot: fonts → the video's film.json + scene.js → window.setT(t). Engine file.
// The video folder comes from film.html?video=<folder> (relative to the kit root; '.' = the root).
// Every frame is a pure function of film time t, so any frame can be rendered alone, in any order.
import { makeKit, W, H, COL, STAGE, SAFE } from './kit.js';
import * as Lib from './lib.js';

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

// Fonts (SIL OFL, from google/fonts): Inter (variable) for the headline, Inter Tight for UI text, Space Mono for plaque, chips, meter
const FACES = [
  ['Inter Tight', 'InterTight.ttf', { weight: '100 900' }],
  ['Inter', 'Inter.ttf', { weight: '100 900' }],
  ['Space Mono', 'SpaceMono-Regular.ttf', { weight: '400' }],
  ['Space Mono', 'SpaceMono-Bold.ttf', { weight: '700' }],
];
for (const [family, file, desc] of FACES) {
  try { const ff = new FontFace(family, `url(${new URL(`../fonts/${file}`, import.meta.url)})`, desc); await ff.load(); document.fonts.add(ff); }
  catch (e) { console.error(`font "${family}" (${file}) missing: put it in fonts/`); }
}
const film = await (await fetch(new URL('film.json', base), { cache: 'no-store' })).json();
const mod = await import(new URL('scene.js', base).href);
const END = film.END;
window.TOTAL = END; window.film = film;

const cv = document.createElement('canvas'); cv.width = W; cv.height = H; document.body.appendChild(cv);
const g = cv.getContext('2d', { alpha: false });
const stage = document.createElement('canvas'); stage.width = W; stage.height = H; const sg = stage.getContext('2d');
const K = makeKit(g);
const mask = sg.createLinearGradient(0, 0, 0, H);
mask.addColorStop(0, 'rgba(0,0,0,0)'); mask.addColorStop(STAGE.top0 / H, 'rgba(0,0,0,0)'); mask.addColorStop(STAGE.top1 / H, 'rgba(0,0,0,1)');
mask.addColorStop(STAGE.bot1 / H, 'rgba(0,0,0,1)'); mask.addColorStop(STAGE.bot0 / H, 'rgba(0,0,0,0)'); mask.addColorStop(1, 'rgba(0,0,0,0)');

const E = { K, film, END, W, H, COL, lib: Lib };
const V = mod.default(E);
for (const k of ['camera', 'world']) if (!V[k]) throw new Error(`scene.js setup() must return ${k}`);
let safeOn = false;
window.setSafe = on => { safeOn = !!on; };

function setT(t) {
  t = Lib.clamp(t, 0, END);
  K.boxes = [];
  K.cam = V.camera(t);
  // the world on the stage canvas: grid, court, jury — then faded at the stage's edges
  sg.setTransform(1, 0, 0, 1, 0, 0); sg.globalCompositeOperation = 'source-over'; sg.globalAlpha = 1; sg.clearRect(0, 0, W, H);
  K.grid(sg);
  K.worldT(sg); V.world(sg, t);
  sg.setTransform(1, 0, 0, 1, 0, 0); sg.globalAlpha = 1; sg.globalCompositeOperation = 'destination-in'; sg.fillStyle = mask; sg.fillRect(0, 0, W, H); sg.globalCompositeOperation = 'source-over';
  // the frame: paper, the stage, screen-fixed overlays, the headline, the footer
  g.setTransform(1, 0, 0, 1, 0, 0); g.globalAlpha = 1; g.fillStyle = COL.BG; g.fillRect(0, 0, W, H);
  g.drawImage(stage, 0, 0);
  if (V.overlay) { g.save(); V.overlay(g, t); g.restore(); }
  K.headline(g, t, film);
  K.footer(g, t, V.footer ? V.footer(t) : {});
  if (safeOn) K.drawSafe(g, K.boxes);
  return true;
}
window.setT = setT;
window.framing = {};
window.textBoxes = () => K.boxes.map(b => ({ ...b, x0: Math.round(b.x0), x1: Math.round(b.x1), y0: Math.round(b.y0), y1: Math.round(b.y1) }));
window.safeReport = () => window.textBoxes().filter(b => b.x0 < SAFE.x0 || b.x1 > SAFE.x1 || b.y0 < SAFE.y0 || b.y1 > SAFE.y1);
setT(+(q.get('t') || 0));
window.__ready = true;
```

### `engine/kit.js`

```js
// The Pixel kit: everything the film draws, on one 2D canvas. Engine file — a video never edits it.
// World units = output pixels at camera zoom 1 (the jury framing). The camera maps a world point to the screen:
//   sx = 540 + (wx − cam.x)·cam.z,  sy = 960 + (wy − cam.y)·cam.z.  World y grows downward.
// The world (grid + court) is drawn on its own canvas and faded at the stage's top and bottom edges, like the reference;
// the headline (top left), the footer and the comment box are screen-fixed on top.
import { clamp, lerp, prog, eio, eo, eo5, ei, back, env, popS, settle, typedN, caretOn, hash } from './lib.js';

export const W = 1080, H = 1920;
export const COL = {
  BG: '#F9FBF6', GRID_MAJ: '#E5E7E1', GRID_MIN: '#F0F2EC', INK: '#0C0D09', ACC: '#EB511A', BODY: '#D4725A', EYE: '#0B0A05',
  WIG: '#F2F3EE', HEAD: '#6B3E26', HANDLE: '#8C5634', SLAB_T: '#EF5016', SLAB_L: '#BB380C', SLAB_R: '#982C0F',
  GREEN: '#30CD5B', GREEN_D: '#21AE4B', PADDLE: '#E4E1DC', GRAY: '#7A7B74', ICON_RING: '#8E908A', ICON_INK: '#4B4D46',
  SEG_OFF: '#DCDDD6', STAR: '#D97757', WHITE: '#FFFFFF', PALE_TXT: '#B9BAB3',
};
const FONT = { sans: '"Inter Tight"', head: '"Inter"', mono: '"Space Mono"' };
// the stage: where the world shows (faded edges), from the reference at 1080x1920
export const STAGE = { top0: 383, top1: 450, bot1: 1352, bot0: 1424 };
export const SAFE = { x0: 60, x1: 960, y0: 250, y1: 1480 };

// ---- layout of the court (world units) ----
export const L = {
  slab: { x: 0, y: 0, hw: 110, hh: 61.5, th: 29 },
  plaqueY: -304, plaqueH: 95,
  judgeCell: 17,
  chipY: -132,
  benchY: [462, 654], benchHW: 402, benchTh: 12,
  jurorCell: 11.6, jurorX: [-346.5, -207.9, -69.3, 69.3, 207.9, 346.5],
  meterY: 780, statY: 780,
  chipRowY: [157, 253],
};
export const jurorPos = i => ({ x: L.jurorX[i % 6], y: L.benchY[Math.floor(i / 6)] - L.benchTh / 2 });

export function makeKit(g) {
  const K = { g, cam: { x: 0, y: 0, z: 1 }, boxes: [], COL, L, W, H, STAGE, SAFE };

  // ---------- transforms ----------
  K.toScreen = (wx, wy) => ({ x: 540 + (wx - K.cam.x) * K.cam.z, y: 960 + (wy - K.cam.y) * K.cam.z });
  K.worldT = (c = g) => c.setTransform(K.cam.z, 0, 0, K.cam.z, 540 - K.cam.x * K.cam.z, 960 - K.cam.y * K.cam.z);
  K.screenT = (c = g) => c.setTransform(1, 0, 0, 1, 0, 0);
  // the stage mask's opacity at a screen y
  K.maskA = y => y < STAGE.top0 ? 0 : y < STAGE.top1 ? (y - STAGE.top0) / (STAGE.top1 - STAGE.top0) : y <= STAGE.bot1 ? 1 : y < STAGE.bot0 ? (STAGE.bot0 - y) / (STAGE.bot0 - STAGE.bot1) : 0;

  // ---------- text + the safe-zone registry ----------
  K.font = (c, size, weight = 800, fam = 'sans') => { c.font = `${weight} ${size}px ${FONT[fam]}`; };
  // record a text box drawn in the current transform (local box x0,y0,x1,y1); world text is tested against the stage mask
  K.reg = (c, kind, text, x0, y0, x1, y1, alpha = 1, world = true) => {
    if (alpha < 0.12 || !text) return;
    const m = c.getTransform(), P = [[x0, y0], [x1, y0], [x0, y1], [x1, y1]].map(([x, y]) => ({ x: m.a * x + m.c * y + m.e, y: m.b * x + m.d * y + m.f }));
    const b = { kind, text: String(text).slice(0, 40), x0: Math.min(...P.map(p => p.x)), x1: Math.max(...P.map(p => p.x)), y0: Math.min(...P.map(p => p.y)), y1: Math.max(...P.map(p => p.y)) };
    if (world) { const cy = (b.y0 + b.y1) / 2; if (K.maskA(cy) * alpha < 0.15) return; if (b.x1 < 0 || b.x0 > W) return; }
    K.boxes.push(b);
  };
  // one line of rich text: spans [{t, c}] drawn from x (left/center/right) at baseline y; returns its width
  K.rich = (c, spans, x, y, o = {}) => {
    const size = o.size || 40, weight = o.weight ?? 800, fam = o.fam || 'sans', ls = o.ls ?? 0;
    K.font(c, size, weight, fam); c.letterSpacing = `${ls}px`; c.textBaseline = 'alphabetic'; c.textAlign = 'left';
    const ws = spans.map(s => c.measureText(s.t).width), tw = ws.reduce((a, b) => a + b, 0) - (spans.length ? ls : 0);
    let cx = o.align === 'center' ? x - tw / 2 : o.align === 'right' ? x - tw : x;
    spans.forEach((s, i) => { c.fillStyle = s.c || COL.INK; c.fillText(s.t, cx, y); cx += ws[i]; });
    c.letterSpacing = '0px';
    if (o.reg !== false) K.reg(c, o.kind || 'text', spans.map(s => s.t).join(''), o.align === 'center' ? x - tw / 2 : o.align === 'right' ? x - tw : x, y - size * 0.78, (o.align === 'center' ? x + tw / 2 : o.align === 'right' ? x : x + tw), y + size * 0.24, o.alpha ?? 1, o.world ?? true);
    return tw;
  };
  K.measure = (c, text, size, weight = 800, fam = 'sans', ls = 0) => { K.font(c, size, weight, fam); c.letterSpacing = `${ls}px`; const w = c.measureText(text).width - (text ? ls : 0); c.letterSpacing = '0px'; return w; };
  // "Formats *for free*" → spans
  K.spans = (s, base = COL.INK, acc = COL.ACC) => { const out = []; s.split(/(\*[^*]*\*)/).forEach(p => { if (!p) return; if (p.startsWith('*')) out.push({ t: p.slice(1, -1), c: acc }); else out.push({ t: p, c: base }); }); return out; };

  // ---------- shapes ----------
  K.rrect = (c, x, y, w, h, r) => { r = Math.min(r, w / 2, h / 2); c.beginPath(); c.moveTo(x + r, y); c.arcTo(x + w, y, x + w, y + h, r); c.arcTo(x + w, y + h, x, y + h, r); c.arcTo(x, y + h, x, y, r); c.arcTo(x, y, x + w, y, r); c.closePath(); };
  // a box with a hard offset shadow (the reference's look): fill, border, shadow colour + offset
  K.hardBox = (c, x, y, w, h, o = {}) => {
    const r = o.r ?? 0, sx = o.sx ?? 8, sy = o.sy ?? 8;
    if (o.shadow !== false) { c.fillStyle = o.shadowCol || COL.INK; K.rrect(c, x + sx, y + sy, w, h, r); c.fill(); }
    c.fillStyle = o.fill || COL.WHITE; K.rrect(c, x, y, w, h, r); c.fill();
    if (o.border !== false) { c.lineWidth = o.lw ?? 2.9; c.strokeStyle = o.borderCol || COL.INK; K.rrect(c, x, y, w, h, r); c.stroke(); }
  };

  // ---------- the grid (screen-space lines that follow the world) ----------
  K.grid = (c) => {
    const z = K.cam.z, maj = 126, min = maj / 5, ox = 0, oy = -351;          // a major line through x 0 and the judge's feet
    const wx0 = K.cam.x - 540 / z, wx1 = K.cam.x + 540 / z, wy0 = K.cam.y - 960 / z, wy1 = K.cam.y + 960 / z;
    K.screenT(c);
    const line = (vert, w, col, lw) => { c.strokeStyle = col; c.lineWidth = lw; c.beginPath(); for (const v of w) { if (vert) { const x = Math.round(540 + (v - K.cam.x) * z) + 0.5; c.moveTo(x, 0); c.lineTo(x, H); } else { const y = Math.round(960 + (v - K.cam.y) * z) + 0.5; c.moveTo(0, y); c.lineTo(W, y); } } c.stroke(); };
    const vals = (a, b, step, o, skip) => { const out = []; for (let k = Math.ceil((a - o) / step); o + k * step <= b; k++) { if (skip && Math.abs(((k % 5) + 5) % 5) === 0) continue; out.push(o + k * step); } return out; };
    if (min * z > 9) { const a = clamp((min * z - 9) / 8); const col = `rgba(222,226,216,${(0.55 * a).toFixed(3)})`; line(true, vals(wx0, wx1, min, ox, true), col, 1); line(false, vals(wy0, wy1, min, oy, true), col, 1); }
    line(true, vals(wx0, wx1, maj, ox), COL.GRID_MAJ, 1.6); line(false, vals(wy0, wy1, maj, oy), COL.GRID_MAJ, 1.6);
  };

  // ---------- the pixel Claude (juror, judge) ----------
  // x = centre, y = feet. c = cell. 10 × 7 cells: body 8 × 5, arms one cell out on row 2, four legs, two eyes.
  K.claude = (c2, x, y, c, o = {}) => {
    const s = o.s ?? 1, a = o.a ?? 1; if (s <= 0.002 || a <= 0.003) return;
    c2.save(); c2.translate(x, y); c2.scale(s * (o.sx ?? 1), s * (o.sy ?? 1)); c2.globalAlpha *= a;
    const L0 = -5 * c, T = -7 * c, e = 0.35;
    if (o.wig) K.wigBack(c2, L0, T, c);
    c2.beginPath();
    c2.rect(L0 + c, T, 8 * c, 5 * c + e);
    c2.rect(L0, T + 2 * c, c + e, c); c2.rect(L0 + 9 * c - e, T + 2 * c, c + e, c);
    for (const lc of [1, 3, 6, 8]) c2.rect(L0 + lc * c, T + 5 * c, c, 2 * c);
    c2.fillStyle = o.col || COL.BODY; c2.fill();
    const b = clamp(o.blink || 0), eh = c * (1 - 0.8 * b), dx = (o.look || 0) * c * 0.25;
    c2.fillStyle = COL.EYE;
    c2.fillRect(L0 + 3 * c + dx, T + c + (c - eh) / 2, c, eh); c2.fillRect(L0 + 6 * c + dx, T + c + (c - eh) / 2, c, eh);
    if (o.wig) K.wigTop(c2, L0, T, c);
    c2.restore();
  };
  // the judge's barrister wig: side flaps behind the arms, two stacked slabs on top
  K.wigBack = (c2, L0, T, c) => {
    c2.lineWidth = c * 0.13; c2.strokeStyle = COL.INK; c2.fillStyle = COL.WIG;
    for (const [x0, x1] of [[-0.62, 1], [9, 10.62]]) { c2.beginPath(); c2.rect(L0 + x0 * c, T - 0.05 * c, (x1 - x0) * c, 3.05 * c); c2.fill(); c2.stroke(); }
  };
  K.wigTop = (c2, L0, T, c) => {
    c2.lineWidth = c * 0.13; c2.strokeStyle = COL.INK; c2.fillStyle = COL.WIG;
    c2.beginPath(); c2.rect(L0 + 0.02 * c, T - 1.02 * c, 9 * c, 1.02 * c); c2.fill(); c2.stroke();
    c2.beginPath(); c2.rect(L0 + 1 * c, T - 2.02 * c, 8 * c, 1.0 * c); c2.fill(); c2.stroke();
  };
  // deterministic blinks: ~every 3–6 s, 0.14 s each
  K.blink = (t, seed) => { const per = 3 + 3 * hash(seed * 7.3); const ph = (t + hash(seed) * per) % per; return ph < 0.14 ? Math.sin(ph / 0.14 * Math.PI) : 0; };

  // ---------- the gavel ----------
  // strikes: [t, …] — the gavel lifts upright, slams down onto the plaque to the right (sparks), and settles back
  K.gavelAngle = (t, strikes) => {
    const REST = -139 * Math.PI / 180, UP = -90 * Math.PI / 180, HIT = 22 * Math.PI / 180;
    for (const ts of strikes) {
      if (t < ts - 0.26 || t > ts + 0.46) continue;
      if (t < ts - 0.09) return lerp(REST, UP, eo(prog(t, ts - 0.26, 0.17)));
      if (t < ts) return lerp(UP, HIT, ei(prog(t, ts - 0.09, 0.09)));
      if (t < ts + 0.1) return HIT;
      return lerp(HIT, REST, eio(prog(t, ts + 0.1, 0.36)));
    }
    return REST;
  };
  K.judge = (c2, x, y, t, o = {}) => {
    const c = L.judgeCell, strikes = o.strikes || [];
    const squint = strikes.reduce((m, ts) => Math.max(m, env(t, ts - 0.04, ts + 0.22, 0.04, 0.08)), 0);
    K.claude(c2, x, y, c, { wig: true, blink: Math.max(squint, K.blink(t, 99)), a: o.a ?? 1 });
    // the gavel: pivot in the judge's right hand
    const px = x + 7.8 * c, py = y - 3 * c, th = K.gavelAngle(t, strikes);
    c2.save(); c2.globalAlpha *= o.a ?? 1; c2.translate(px, py); c2.rotate(th);
    c2.lineWidth = c * 0.14; c2.strokeStyle = COL.INK; c2.lineJoin = 'round';
    c2.fillStyle = COL.HANDLE; c2.beginPath(); c2.rect(0, -0.26 * c, 4.7 * c, 0.52 * c); c2.fill(); c2.stroke();
    c2.fillStyle = COL.HEAD; K.rrect(c2, 4.45 * c, -1.72 * c, 2.25 * c, 3.44 * c, 0.38 * c); c2.fill(); c2.stroke();
    c2.restore();
    // sparks where the head lands (on the plaque's top edge)
    for (const ts of strikes) {
      const k = prog(t, ts, 0.32); if (t < ts || k >= 1) continue;
      const hx = px + Math.cos(22 * Math.PI / 180) * 5.6 * c, hy = y;
      c2.save(); c2.globalAlpha *= (1 - k) * (o.a ?? 1); c2.strokeStyle = COL.ACC; c2.lineWidth = c * 0.22; c2.lineCap = 'butt';
      for (const ang of [-120, -90, -60]) { const r0 = c * (0.7 + 1.2 * eo(k)), r1 = r0 + c * (1.1 - 0.5 * k), a = ang * Math.PI / 180; c2.beginPath(); c2.moveTo(hx + Math.cos(a) * r0, hy + Math.sin(a) * r0); c2.lineTo(hx + Math.cos(a) * r1, hy + Math.sin(a) * r1); c2.stroke(); }
      c2.restore();
    }
  };

  // ---------- the plaque ----------
  K.plaque = (c2, t, o = {}) => {
    const text = o.text || 'THE PIXEL COURT', size = 28.6, ls = 5.6;
    const tw = K.measure(c2, text, size, 700, 'mono', ls), sq = 12, gap = 25, padX = 72;
    const w = tw + 2 * (padX + sq + gap), h = L.plaqueH, x = -w / 2, y = L.plaqueY - h / 2;
    const fl = o.flash ? o.flash.reduce((m, [a, b]) => Math.max(m, env(t, a, b, 0.12, 0.3)), 0) : 0;
    c2.save(); c2.globalAlpha *= o.a ?? 1;
    K.hardBox(c2, x, y, w, h, { sx: 9.7, sy: 9.7, lw: 2.9, fill: fl ? mix('#FFFFFF', '#FBD9C9', fl) : COL.WHITE, borderCol: fl ? mix(COL.INK, COL.ACC, fl) : COL.INK, shadowCol: fl ? mix(COL.INK, COL.ACC, fl) : COL.INK });
    c2.fillStyle = COL.ACC; const cy = L.plaqueY;
    c2.fillRect(x + padX, cy - sq / 2, sq, sq); c2.fillRect(x + w - padX - sq, cy - sq / 2, sq, sq);
    K.rich(c2, [{ t: text, c: COL.INK }], 0, cy + size * 0.36, { size, weight: 700, fam: 'mono', ls, align: 'center', kind: 'plaque', alpha: o.a ?? 1 });
    c2.restore();
    return { w, h, x, y };
  };

  // ---------- the iso slab ----------
  K.slab = (c2, o = {}) => {
    const { x, y, hw, hh, th } = L.slab, s = o.s ?? 1;
    c2.save(); c2.translate(x, y); c2.scale(s, s); c2.globalAlpha *= o.a ?? 1;
    c2.lineWidth = 2.6; c2.strokeStyle = COL.INK; c2.lineJoin = 'round';
    const poly = (pts, col) => { c2.beginPath(); pts.forEach(([px, py], i) => i ? c2.lineTo(px, py) : c2.moveTo(px, py)); c2.closePath(); c2.fillStyle = col; c2.fill(); c2.stroke(); };
    poly([[-hw, 0], [0, hh], [0, hh + th], [-hw, th]], COL.SLAB_L);
    poly([[0, hh], [hw, 0], [hw, th], [0, hh + th]], COL.SLAB_R);
    const gr = c2.createLinearGradient(0, -hh, 0, hh); gr.addColorStop(0, '#F25A20'); gr.addColorStop(1, COL.SLAB_T);
    poly([[0, -hh], [hw, 0], [0, hh], [-hw, 0]], gr);
    c2.restore();
  };

  // ---------- icons (24-unit line icons) ----------
  K.icon = (c2, name, x, y, size, col, lw = 2) => {
    const s = size / 24; c2.save(); c2.translate(x - size / 2, y - size / 2); c2.scale(s, s);
    c2.strokeStyle = col; c2.fillStyle = col; c2.lineWidth = lw / s; c2.lineCap = 'round'; c2.lineJoin = 'round';
    const P = (d) => { c2.beginPath(); d(); c2.stroke(); };
    switch (name) {
      case 'trend': P(() => { c2.moveTo(3, 17); c2.lineTo(9, 11); c2.lineTo(13, 15); c2.lineTo(21, 7); }); P(() => { c2.moveTo(15, 7); c2.lineTo(21, 7); c2.lineTo(21, 13); }); break;
      case 'user': P(() => c2.arc(12, 8, 4, 0, 7)); P(() => { c2.moveTo(4.5, 21); c2.bezierCurveTo(4.5, 15, 19.5, 15, 19.5, 21); }); break;
      case 'code': P(() => { c2.moveTo(8, 6); c2.lineTo(2.5, 12); c2.lineTo(8, 18); }); P(() => { c2.moveTo(16, 6); c2.lineTo(21.5, 12); c2.lineTo(16, 18); }); break;
      case 'scales': P(() => { c2.moveTo(12, 3); c2.lineTo(12, 21); c2.moveTo(7, 21); c2.lineTo(17, 21); c2.moveTo(4, 7); c2.lineTo(20, 7); }); P(() => { c2.moveTo(4, 7); c2.lineTo(1.5, 14); c2.lineTo(6.5, 14); c2.closePath(); }); P(() => { c2.moveTo(20, 7); c2.lineTo(17.5, 14); c2.lineTo(22.5, 14); c2.closePath(); }); break;
      case 'swords': P(() => { c2.moveTo(4, 4); c2.lineTo(18, 18); c2.moveTo(20, 4); c2.lineTo(6, 18); c2.moveTo(15, 20); c2.lineTo(20, 15); c2.moveTo(4, 15); c2.lineTo(9, 20); }); break;
      case 'mega': P(() => { c2.moveTo(3, 10); c2.lineTo(3, 14); c2.lineTo(7, 14); c2.lineTo(16, 19); c2.lineTo(16, 5); c2.lineTo(7, 10); c2.closePath(); }); P(() => { c2.moveTo(19, 9); c2.quadraticCurveTo(21.5, 12, 19, 15); }); break;
      case 'doc': P(() => { c2.moveTo(6, 3); c2.lineTo(14, 3); c2.lineTo(19, 8); c2.lineTo(19, 21); c2.lineTo(6, 21); c2.closePath(); }); P(() => { c2.moveTo(14, 3); c2.lineTo(14, 8); c2.lineTo(19, 8); c2.moveTo(9, 13); c2.lineTo(16, 13); c2.moveTo(9, 17); c2.lineTo(16, 17); }); break;
      case 'tag': P(() => { c2.moveTo(3, 12); c2.lineTo(12, 3); c2.lineTo(21, 3); c2.lineTo(21, 12); c2.lineTo(12, 21); c2.closePath(); }); c2.beginPath(); c2.arc(16.5, 7.5, 1.6, 0, 7); c2.fill(); break;
      case 'question': P(() => c2.arc(12, 12, 9.5, 0, 7)); P(() => { c2.moveTo(9, 9.5); c2.bezierCurveTo(9, 6, 15, 6, 15, 9.5); c2.bezierCurveTo(15, 12, 12, 12, 12, 14.5); }); c2.beginPath(); c2.arc(12, 17.8, 1.3, 0, 7); c2.fill(); break;
      case 'pen': P(() => { c2.moveTo(4, 20); c2.lineTo(5, 15); c2.lineTo(16, 4); c2.lineTo(20, 8); c2.lineTo(9, 19); c2.closePath(); c2.moveTo(13.5, 6.5); c2.lineTo(17.5, 10.5); }); break;
      case 'play': P(() => c2.arc(12, 12, 9.5, 0, 7)); P(() => { c2.moveTo(10, 8); c2.lineTo(16, 12); c2.lineTo(10, 16); c2.closePath(); }); break;
      case 'shield': P(() => { c2.moveTo(12, 3); c2.lineTo(20, 6); c2.lineTo(20, 12); c2.bezierCurveTo(20, 17, 16, 20, 12, 21.5); c2.bezierCurveTo(8, 20, 4, 17, 4, 12); c2.lineTo(4, 6); c2.closePath(); }); break;
      case 'check': P(() => { c2.moveTo(4, 12.5); c2.lineTo(9.5, 18); c2.lineTo(20, 6.5); }); break;
      case 'x': P(() => { c2.moveTo(6, 6); c2.lineTo(18, 18); c2.moveTo(18, 6); c2.lineTo(6, 18); }); break;
      case 'app': P(() => { K.rrect(c2, 3, 4, 18, 16, 3); }); P(() => { c2.moveTo(3, 9); c2.lineTo(21, 9); }); c2.beginPath(); c2.arc(6.3, 6.5, 0.9, 0, 7); c2.arc(9.1, 6.5, 0.9, 0, 7); c2.fill(); break;
      case 'star': for (let k = 0; k < 12; k++) { const a = k / 12 * Math.PI * 2 + 0.13, r0 = 2.2, r1 = k % 2 ? 8.6 : 10.4; c2.beginPath(); c2.moveTo(12 + Math.cos(a) * r0, 12 + Math.sin(a) * r0); c2.lineTo(12 + Math.cos(a) * r1, 12 + Math.sin(a) * r1); c2.stroke(); } break;
      case 'cart': P(() => { c2.moveTo(2, 4); c2.lineTo(5, 4); c2.lineTo(7.5, 15); c2.lineTo(19, 15); c2.lineTo(21, 7); c2.lineTo(6, 7); }); c2.beginPath(); c2.arc(9, 19.5, 1.5, 0, 7); c2.arc(17.5, 19.5, 1.5, 0, 7); c2.fill(); break;
      case 'box': P(() => { c2.moveTo(12, 3); c2.lineTo(20.5, 7.5); c2.lineTo(20.5, 16.5); c2.lineTo(12, 21); c2.lineTo(3.5, 16.5); c2.lineTo(3.5, 7.5); c2.closePath(); c2.moveTo(3.5, 7.5); c2.lineTo(12, 12); c2.lineTo(20.5, 7.5); c2.moveTo(12, 12); c2.lineTo(12, 21); }); break;
    }
    c2.restore();
  };

  // ---------- the file chip (on the slab) ----------
  // face: { title, sub, icon } — a face change flips the chip (scale x → 0 → 1); faces: [[t, face], …]
  K.fileChip = (c2, t, o) => {
    const faces = o.faces, idx = faces.findLastIndex(([ft]) => t >= ft); if (idx < 0) return;
    const [ft, face] = faces[idx], prev = idx > 0 ? faces[idx - 1] : null;
    const a = env(t, o.tIn ?? faces[0][0], o.tOut ?? Infinity, 0.16, 0.22) * (o.a ?? 1); if (a <= 0) return;
    let fx = 1, show = face;
    if (prev && t < ft + 0.12) { const k = prog(t, ft - 0.12, 0.24); fx = Math.abs(Math.cos(k * Math.PI)); show = k < 0.5 ? prev[1] : face; }
    const pop = (o.tIn ?? faces[0][0]) != null ? popS(t, o.tIn ?? faces[0][0], 0.3, 2.0) : 1;
    const size = 43, sub = 21;
    const tw = Math.max(K.measure(c2, show.title, size, 700, 'mono'), K.measure(c2, show.sub || '', sub, 400, 'mono'));
    const w = Math.max(320, 114 + tw + 30), h = 127, cx = o.x ?? 0, cy = o.y ?? L.chipY;
    c2.save(); c2.globalAlpha *= a; c2.translate(cx + (o.dx || 0), cy + (o.dy || 0)); c2.rotate(((o.rot ?? -2.5) + (o.drot || 0)) * Math.PI / 180); c2.scale(fx * pop, pop);
    const x = -w / 2, y = -h / 2, bc = show.border || COL.ACC;
    K.hardBox(c2, x, y, w, h, { sx: 6, sy: 8, lw: 2.4, borderCol: bc, fill: show.fill || COL.WHITE });
    // icon tile
    c2.lineWidth = 3; c2.strokeStyle = COL.INK; c2.fillStyle = COL.WHITE; K.rrect(c2, x + 28, y + (h - 66) / 2, 66, 66, 13); c2.fill(); c2.stroke();
    K.icon(c2, show.icon || 'star', x + 61, y + h / 2, 42, show.iconCol || COL.STAR, show.icon === 'star' || !show.icon ? 2.2 : 2.4);
    // o.leaving: the chip is being thrown off screen — its text isn't there to be read, so it isn't tested against the safe zone
    const ra = o.leaving ? 0 : a;
    K.rich(c2, [{ t: show.title, c: COL.INK }], x + 114, y + 61, { size, weight: 700, fam: 'mono', kind: 'chip', alpha: ra });
    if (show.sub) K.rich(c2, K.spans(show.sub, COL.GRAY), x + 114, y + 96, { size: sub, weight: 400, fam: 'mono', kind: 'chip', alpha: ra });
    c2.restore();
  };

  // ---------- the card (pitch / listing) ----------
  // o: { tIn, tOut, rot, faces: [[t, { head, title (markup), typed: [t0, t1], placeholder, v2 }]] } — a face change flips the card
  K.card = (c2, t, o) => {
    const faces = o.faces, idx = faces.findLastIndex(([ft]) => t >= ft); if (idx < 0) return;
    const [ft, face] = faces[idx], prev = idx > 0 ? faces[idx - 1] : null;
    const tIn = o.tIn ?? faces[0][0], a = env(t, tIn, o.tOut ?? Infinity, 0.14, o.dOut ?? 0.25) * (o.a ?? 1); if (a <= 0) return;
    let fx = 1, show = face;
    if (prev && t < ft + 0.13 && !face.noFlip) { const k = prog(t, ft - 0.13, 0.26); fx = Math.max(0.02, Math.abs(Math.cos(k * Math.PI))); show = k < 0.5 ? prev[1] : face; }
    const pop = popS(t, tIn, 0.32, 1.8);
    const w = 560, h = 146, v2 = !!show.v2;
    c2.save(); c2.globalAlpha *= a; c2.translate(o.x ?? 0, (o.y ?? L.chipY) + (o.dy || 0)); c2.rotate((o.rot ?? -1.5) * Math.PI / 180); c2.scale(fx * pop, pop);
    const x = -w / 2, y = -h / 2;
    K.hardBox(c2, x, y, w, h, { sx: 9, sy: 9, lw: v2 ? 3 : 2.9, borderCol: v2 ? COL.ACC : COL.INK, shadowCol: v2 ? COL.ACC : COL.INK });
    const hc = v2 ? COL.ACC : COL.GRAY;
    K.icon(c2, 'doc', x + 38, y + 36, 24, hc, 1.9);
    K.rich(c2, [{ t: show.head || 'the pitch', c: hc }], x + 56, y + 43, { size: 22, weight: 400, fam: 'mono', kind: 'card', alpha: a });
    // the title: typed (with a caret) or a grey placeholder
    const full = K.spans(show.title || '');
    const txt = full.map(s => s.t).join('');
    const n = show.typed ? typedN(txt, t, show.typed[0], show.typed[1]) : [...txt].length;
    let left = n; const vis = []; for (const s of full) { if (left <= 0) break; const ch = [...s.t]; vis.push({ t: ch.slice(0, left).join(''), c: s.c }); left -= ch.length; }
    const size = show.size || 44;
    let tw = 0;
    if (n === 0 && show.placeholder) tw = K.rich(c2, [{ t: show.placeholder, c: COL.PALE_TXT }], x + 30, y + 108, { size, weight: 850, ls: -1.4, kind: 'card', alpha: a });
    else tw = K.rich(c2, vis.length ? vis : [{ t: '', c: COL.INK }], x + 30, y + 108, { size, weight: 850, ls: -1.4, kind: 'card', alpha: a });
    const typing = show.typed && t >= show.typed[0] - 0.4 && t < show.typed[1] + (show.caretHold ?? 0.6);
    if ((typing && (t < show.typed[1] || caretOn(t))) || (show.caret && caretOn(t))) { c2.fillStyle = COL.ACC; c2.fillRect(x + 32 + tw, y + 70, 3.5, 48); }
    c2.restore();
    return { w, h };
  };

  // ---------- stamps (GUILTY / NOT GUILTY) ----------
  K.stamp = (c2, t, o) => {
    const t0 = o.t0, a0 = env(t, t0, o.t1 ?? Infinity, 0.06, 0.3); if (a0 <= 0) return;
    const k = prog(t, t0, 0.11), s = t < t0 + 0.11 ? lerp(1.75, 0.94, eo(k)) : settle(t, t0 + 0.11, 0.06, 10, 26) * 1.0, a = a0 * (t < t0 + 0.11 ? lerp(0.35, 1, k) : 1);
    const col = o.color || COL.ACC, size = o.size || 64, text = o.text;
    const tw = K.measure(c2, text, size, 900, 'sans', 1), w = tw + 46, h = size * 1.28;
    c2.save(); c2.globalAlpha *= a; c2.translate(o.x ?? 0, o.y ?? 0); c2.rotate((o.rot ?? -6) * Math.PI / 180); c2.scale(s, s);
    c2.fillStyle = COL.WHITE; c2.fillRect(-w / 2, -h / 2, w, h);
    c2.lineWidth = 5; c2.strokeStyle = col; c2.strokeRect(-w / 2, -h / 2, w, h);
    K.rich(c2, [{ t: text, c: col }], 0, size * 0.36, { size, weight: 900, ls: 1, align: 'center', kind: 'stamp', alpha: a });
    c2.restore();
    // confetti squares (NOT GUILTY)
    if (o.confetti) {
      for (let i = 0; i < 18; i++) {
        const kk = prog(t, t0 + 0.02, 0.75); if (t < t0 || kk >= 1) continue;
        const ang = hash(i * 3.1 + 1) * Math.PI * 2, d = (150 + 170 * hash(i * 5.7 + 2)) * eo5(kk), sz = 9 + 7 * hash(i * 1.3);
        c2.save(); c2.globalAlpha *= (1 - kk) * a0; c2.fillStyle = i % 3 === 0 ? COL.ACC : COL.GREEN;
        c2.fillRect((o.x ?? 0) + Math.cos(ang) * d * 1.5 - sz / 2, (o.y ?? 0) + Math.sin(ang) * d * 0.75 - sz / 2, sz, sz); c2.restore();
      }
    }
  };

  // ---------- benches, jurors, icons, paddles, meter ----------
  K.benches = (c2, t, o = {}) => {
    const a = env(t, o.tIn ?? -9, o.tOut ?? Infinity, 0.22, 0.2) * (o.a ?? 1); if (a <= 0) return;
    c2.save(); c2.globalAlpha *= a; c2.fillStyle = COL.INK;
    for (const by of L.benchY) c2.fillRect(-L.benchHW, by - L.benchTh / 2, 2 * L.benchHW, L.benchTh);
    c2.restore();
  };
  // o: { pop: [t0, stagger], dim: [[t0, t1, keep: [i…]]], tOut, look }
  K.jurors = (c2, t, o = {}) => {
    for (let i = 0; i < 12; i++) {
      const p = jurorPos(i), t0 = o.pop ? o.pop[0] + i * o.pop[1] : -9;
      const s = popS(t, t0, 0.26, 2.4); if (s <= 0) continue;
      let a = env(t, -99, o.tOut ?? Infinity, 0, 0.2) * (o.a ?? 1);
      for (const [d0, d1, keep] of (o.dim || [])) if (!keep.includes(i)) a *= 1 - 0.64 * env(t, d0, d1, 0.22, 0.25);
      K.claude(c2, p.x, p.y, L.jurorCell, { s, a, blink: K.blink(t, i + 1) });
    }
  };
  const ICONS = ['trend', 'user', 'code', 'scales', 'swords', 'mega', 'doc', 'tag', 'question', 'pen', 'play', 'shield'];
  K.ICONS = ICONS;
  // one role icon over each juror. o: { t0, stagger, t1, icons: [12 names] (default ICONS) }
  K.jurorIcons = (c2, t, o) => {
    for (let i = 0; i < 12; i++) {
      const p = jurorPos(i), t0 = o.t0 + i * o.stagger, s = popS(t, t0, 0.24, 2.2); if (s <= 0) continue;
      const a = env(t, t0, o.t1, 0.1, 0.22); if (a <= 0) continue;
      const y = p.y - 7 * L.jurorCell - 55;
      c2.save(); c2.globalAlpha *= a; c2.translate(p.x, y); c2.scale(s, s);
      c2.fillStyle = COL.WHITE; c2.beginPath(); c2.arc(0, 0, 21, 0, 7); c2.fill(); c2.lineWidth = 1.8; c2.strokeStyle = COL.ICON_RING; c2.stroke();
      K.icon(c2, (o.icons || ICONS)[i], 0, 0, 22, COL.ICON_INK, 1.9);
      c2.restore();
    }
  };
  // paddles: o.raise [[t0, t1, stagger]], o.flips [[t0, stagger, kind, until]] — kind 'x' | 'v' or an array of 12 (one per juror)
  K.paddleState = (t, o, i) => {
    let up = 0, face = 'blank', flipK = 1;
    for (const [r0, r1, st] of o.raise) { const tt = r0 + i * (st ?? 0.05); if (t < tt || t > r1) continue; up = Math.max(up, back(prog(t, tt, 0.24), 2.0) * eo(clamp((r1 - t) / 0.2))); }
    for (const fl of (o.flips || [])) {
      const [f0, st, kinds, f1] = fl, tt = f0 + i * st;
      if (t >= f1) continue;
      if (t >= tt - 0.08) { const k = prog(t, tt - 0.08, 0.16); flipK = Math.abs(Math.cos(k * Math.PI)); face = k < 0.5 ? face : (Array.isArray(kinds) ? kinds[i] : kinds); if (k >= 1) flipK = 1; }
    }
    return { up, face, flipK };
  };
  K.paddles = (c2, t, o) => {
    for (let i = 0; i < 12; i++) {
      const p = jurorPos(i), st = K.paddleState(t, o, i); if (st.up <= 0.001) continue;
      const top = p.y - 7 * L.jurorCell, u = st.up, ph = 41, cyP = top - 59 * u;
      c2.save();
      c2.fillStyle = COL.INK; c2.fillRect(p.x - 1.75, cyP + ph / 2 * u, 3.5, Math.max(0, top - (cyP + ph / 2 * u)));
      c2.translate(p.x, cyP); c2.scale(st.flipK * u, u);
      const fill = st.face === 'x' ? COL.ACC : st.face === 'v' ? COL.GREEN : COL.PADDLE;
      c2.fillStyle = fill; K.rrect(c2, -ph / 2, -ph / 2, ph, ph, 7); c2.fill(); c2.lineWidth = 3; c2.strokeStyle = COL.INK; c2.stroke();
      if (st.face !== 'blank') K.icon(c2, st.face === 'x' ? 'x' : 'check', 0, 0, 26, COL.WHITE, 4.2);
      c2.restore();
    }
  };
  // the vote meter: counts follow the paddles' flips. o: { tIn, tOut, paddles, green (the ✓ round), labels: ['GUILTY', 'NOT'] }
  K.meter = (c2, t, o) => {
    const a = env(t, o.tIn, o.tOut, 0.2, 0.22); if (a <= 0) return;
    let g = 0, n = 0; const segs = [];
    for (let i = 0; i < 12; i++) { const st = K.paddleState(t, o.paddles, i), done = st.face !== 'blank' && st.flipK > 0.6; segs.push(done ? st.face : null); if (done) st.face === 'x' ? g++ : n++; }
    const y = L.meterY;
    c2.save(); c2.globalAlpha *= a;
    const [lw, rw] = o.labels || ['GUILTY', 'NOT'];                       // the two sides: ✕ votes on the left, ✓ on the right
    K.rich(c2, [{ t: `${lw} ${g}`, c: o.green ? mix(COL.ACC, '#F2A383', 0.35) : COL.ACC }], -L.benchHW, y + 9, { size: 31, weight: 400, fam: 'mono', kind: 'meter', alpha: a });
    K.rich(c2, [{ t: `${n} ${rw}`, c: COL.GREEN_D }], L.benchHW, y + 9, { size: 31, weight: 400, fam: 'mono', align: 'right', kind: 'meter', alpha: a });
    const x0 = -190, x1 = 250, sw = (x1 - x0) / 12;
    // fill order: guilty votes from the left, not-guilty from the right, like the reference's bar
    const order = []; for (let i = 0; i < 12; i++) if (segs[i] === 'x') order.push('x'); const nv = segs.filter(s => s === 'v').length;
    const bar = Array(12).fill(null); order.forEach((_, k) => bar[k] = 'x'); for (let k = 0; k < nv; k++) bar[o.green ? k : 11 - k] = 'v';
    for (let k = 0; k < 12; k++) { c2.fillStyle = bar[k] === 'x' ? COL.ACC : bar[k] === 'v' ? COL.GREEN : COL.SEG_OFF; K.rrect(c2, x0 + k * sw + 3, y - 7, sw - 6, 15, 3); c2.fill(); }
    c2.restore();
  };

  // ---------- bubbles over a juror ----------
  // kind 'label' (white, icon + text), 'speech' (white text), 'quote' (orange, white text, 1–2 lines)
  K.bubble = (c2, t, o) => {
    const a = env(t, o.t0, o.t1, 0.14, 0.18); if (a <= 0) return;
    const s = lerp(0.82, 1, back(prog(t, o.t0, 0.22), 2.2));
    const p = jurorPos(o.juror), top = p.y - 7 * L.jurorCell, kind = o.kind || 'speech';
    const size = o.size || (kind === 'label' ? 37 : kind === 'quote' ? 35 : 32), lines = Array.isArray(o.text) ? o.text : [o.text];
    const lw = Math.max(...lines.map(l => K.measure(c2, l, size, 800, 'sans', -0.6)));
    const icon = kind === 'label' && o.icon, padX = kind === 'quote' ? 32 : kind === 'speech' ? 30 : 26, iw = icon ? 40 : 0;
    const w = lw + 2 * padX + iw, lh = size * 1.18, h = lines.length * lh + (kind === 'label' ? 30 : 26);
    const ax = p.x + (o.ax ?? -38), ay = top - 18;                // the tail's tip (above the juror's head, left)
    const bx = ax - 24 + (o.bx ?? 0), by = ay - 16 - h;               // the box's top-left
    c2.save(); c2.globalAlpha *= a; c2.translate(ax, ay); c2.scale(s, s); c2.translate(-ax, -ay);
    const fill = kind === 'quote' ? COL.ACC : COL.WHITE;
    // tail + box with one shadow
    const shape = (dx, dy) => { c2.beginPath(); c2.moveTo(bx + dx, by + dy); c2.lineTo(bx + w + dx, by + dy); c2.lineTo(bx + w + dx, by + h + dy); c2.lineTo(ax + 14 + dx, by + h + dy); c2.lineTo(ax + dx, ay + dy); c2.lineTo(ax - 4 + dx, by + h + dy); c2.lineTo(bx + dx, by + h + dy); c2.closePath(); };
    c2.fillStyle = COL.INK; shape(6, 6); c2.fill();
    c2.fillStyle = fill; shape(0, 0); c2.fill();
    if (kind !== 'quote') { c2.lineWidth = 2.6; c2.strokeStyle = COL.INK; shape(0, 0); c2.stroke(); }
    if (icon) K.icon(c2, icon, bx + padX + 14, by + h / 2, 26, COL.ACC, 2.4);
    const tc = kind === 'quote' ? COL.WHITE : COL.INK;
    lines.forEach((l, k) => K.rich(c2, [{ t: l, c: tc }], kind === 'label' ? bx + padX + iw : bx + w / 2, by + (kind === 'label' ? 15 : 13) + lh * (k + 0.78), { size, weight: 800, ls: -0.6, align: kind === 'label' ? 'left' : 'center', kind: 'bubble', alpha: a }));
    c2.restore();
  };

  // ---------- chips below the slab / stat chips ----------
  // o: { x, y, t0, t1, text (markup), kind 'solid'|'white', icon, num }
  K.chip = (c2, t, o) => {
    const a = env(t, o.t0, o.t1, 0.12, 0.2) * (o.a ?? 1); if (a <= 0) return null;
    const s = lerp(0.86, 1, back(prog(t, o.t0, 0.24), 2.0));
    const size = o.size || 33, solid = o.kind === 'solid', spans = K.spans(o.text, solid ? COL.WHITE : COL.INK, solid ? COL.WHITE : COL.ACC);
    const tw = K.measure(c2, spans.map(x => x.t).join(''), size, 800, 'sans', -0.6), iw = o.icon ? 34 : 0;
    const w = tw + 36 + iw, h = size * 1.75;
    c2.save(); c2.globalAlpha *= a; c2.translate(o.x, o.y); c2.scale(s, s); c2.rotate((o.rot || 0) * Math.PI / 180);
    K.hardBox(c2, -w / 2, -h / 2, w, h, { fill: solid ? COL.ACC : COL.WHITE, border: !solid, sx: 5, sy: 5, lw: 2.6 });
    if (o.icon) K.icon(c2, o.icon, -w / 2 + 18 + 12, 0, 24, o.iconCol || COL.GREEN_D, 3);
    K.rich(c2, spans, -w / 2 + 18 + iw, size * 0.36, { size, weight: 800, ls: -0.6, kind: 'chip', alpha: a });
    c2.restore();
    return { w, h };
  };
  K.chipW = (c2, o) => { const size = o.size || 33; return K.measure(c2, o.text.replace(/\*/g, ''), size, 800, 'sans', -0.6) + 36 + (o.icon ? 34 : 0); };
  // a stat chip: big orange number + label
  K.stat = (c2, t, o) => {
    const a = env(t, o.t0, o.t1, 0.12, 0.2); if (a <= 0) return;
    const s = lerp(0.84, 1, back(prog(t, o.t0, 0.26), 2.2)) * (o.pulse ? settle(t, o.pulse, 0.07, 8, 22) : 1);
    const nw = K.measure(c2, o.num, 62, 900, 'sans', -1), lw = K.measure(c2, o.label, 34, 800, 'sans', -0.6), w = nw + lw + 52, h = 98;
    c2.save(); c2.globalAlpha *= a; c2.translate(o.x, o.y); c2.scale(s, s);
    K.hardBox(c2, -w / 2, -h / 2, w, h, { sx: 0, sy: 0, shadow: false, lw: 3.2 });
    K.rich(c2, [{ t: o.num, c: COL.ACC }], -w / 2 + 20, 22, { size: 62, weight: 900, ls: -1, kind: 'stat', alpha: a });
    K.rich(c2, [{ t: o.label, c: COL.INK }], -w / 2 + 20 + nw + 12, 20, { size: 34, weight: 800, ls: -0.6, kind: 'stat', alpha: a });
    c2.restore();
  };
  K.statW = (c2, o) => K.measure(c2, o.num, 62, 900, 'sans', -1) + K.measure(c2, o.label, 34, 800, 'sans', -0.6) + 52;

  // ---------- the comment box (screen space) ----------
  K.commentBox = (c2, t, o) => {
    const a = env(t, o.t0, o.t1 ?? Infinity, 0.22, 0.3); if (a <= 0) return;
    const rise = (1 - eo(prog(t, o.t0, 0.28))) * 46;
    const x = 183, w = 717, h = 74, y = o.y + rise;
    K.screenT(c2); c2.save(); c2.globalAlpha *= a;
    K.hardBox(c2, x, y, w, h, { r: 9, sx: 8, sy: 8, lw: 3 });
    c2.lineWidth = 2.4; c2.strokeStyle = '#3A3B36'; c2.beginPath(); c2.arc(x + 38, y + h / 2, 17, 0, 7); c2.stroke();
    const txt = o.text || '', n = typedN(txt, t, o.typed[0], o.typed[1]);
    const tw = K.rich(c2, [{ t: [...txt].slice(0, n).join(''), c: COL.INK }], x + 72, y + h / 2 + 12, { size: 34, weight: 900, ls: -0.5, kind: 'comment', alpha: a, world: false });
    if (t < o.typed[1] + 0.25 || caretOn(t)) { c2.fillStyle = COL.ACC; c2.fillRect(x + 74 + tw, y + 20, 3, h - 40); }
    const press = o.press ? 1 - 0.12 * env(t, o.press, o.press + 0.24, 0.06, 0.16) : 1;
    const bw = 86, bh = 42, bxx = x + w - bw - 18, byy = y + (h - bh) / 2;
    c2.save(); c2.translate(bxx + bw / 2, byy + bh / 2); c2.scale(press, press);
    c2.fillStyle = COL.ACC; K.rrect(c2, -bw / 2, -bh / 2, bw, bh, 7); c2.fill();
    K.rich(c2, [{ t: 'Post', c: COL.WHITE }], 0, 9, { size: 25, weight: 800, align: 'center', kind: 'comment', alpha: a, world: false });
    c2.restore(); c2.restore();
  };

  // ---------- the headline (top left) ----------
  // film.heads: one per script line, film.words: tokens {w, o (orange), br (line break before)}
  K.headline = (c2, t, film) => {
    K.screenT(c2);
    const H0 = film.heads; let cur = null;
    for (const h of H0) if (t >= h.t0 && t < h.t1) cur = h;
    if (!cur) return;
    const fadeOut = cur.t1 - t < 0.09 ? clamp((cur.t1 - t) / 0.09) : 1;
    const end = film.END, tail = t > end - 0.55 ? clamp((end - t) / 0.45) : 1;
    const k = cur.t0 < 0 ? 1 : prog(t, cur.t0, 0.17), a = eo(k) * fadeOut * tail; if (a <= 0.003) return;
    // lines of spans
    const rows = [[]];
    for (const i of cur.words) { const w = film.words[i]; if (w.br && rows.at(-1).length) rows.push([]); rows.at(-1).push(w); }
    let size = 58; const maxW = 742, ls = -1.1;
    const rowSpans = rows.map(r => { const sp = []; r.forEach((w, j) => { const txt = (j ? ' ' : '') + w.w; const c = w.o ? COL.ACC : COL.INK; if (sp.length && sp.at(-1).c === c) sp.at(-1).t += txt; else sp.push({ t: txt, c }); }); return sp; });
    const widest = Math.max(...rowSpans.map(sp => K.measure(c2, sp.map(s => s.t).join(''), size, 860, 'head', ls)));
    if (widest > maxW) size = Math.max(44, size * maxW / widest);
    c2.save(); c2.globalAlpha = a;
    if (k < 1) c2.filter = `blur(${((1 - eo(k)) * 7).toFixed(2)}px)`;
    rowSpans.forEach((sp, r) => K.rich(c2, sp, 180, 302 + r * 66 * size / 58, { size, weight: 860, fam: 'head', ls, kind: 'headline', alpha: a, world: false }));
    c2.restore(); c2.filter = 'none';
  };

  // ---------- the footer ----------
  K.footer = (c2, t, o = {}) => {
    K.screenT(c2);
    const label = o.label || 'pixel.md', size = 22, tw = K.measure(c2, label, size, 700, 'mono'), gw = 34, gap = 12, x0 = 540 - (gw + gap + tw) / 2, y = 1458;
    c2.save(); c2.globalAlpha = o.a ?? 1;
    K.claude(c2, x0 + gw / 2, y + 9, 3.4, { blink: K.blink(t, 77) });
    K.rich(c2, [{ t: label, c: COL.INK }], x0 + gw + gap, y + 8, { size, weight: 700, fam: 'mono', kind: 'footer', world: false });
    c2.restore();
  };

  // ---------- the safe-zone overlay (stills with --safe) ----------
  K.drawSafe = (c2, boxes) => {
    K.screenT(c2); c2.save();
    c2.fillStyle = 'rgba(255,0,80,0.10)'; c2.fillRect(0, 0, W, SAFE.y0); c2.fillRect(0, SAFE.y1, W, H - SAFE.y1); c2.fillRect(0, SAFE.y0, SAFE.x0, SAFE.y1 - SAFE.y0); c2.fillRect(SAFE.x1, SAFE.y0, W - SAFE.x1, SAFE.y1 - SAFE.y0);
    c2.strokeStyle = 'rgba(255,0,80,0.8)'; c2.lineWidth = 3; c2.setLineDash([14, 10]); c2.strokeRect(SAFE.x0, SAFE.y0, SAFE.x1 - SAFE.x0, SAFE.y1 - SAFE.y0); c2.setLineDash([]);
    for (const b of boxes) { const bad = b.x0 < SAFE.x0 || b.x1 > SAFE.x1 || b.y0 < SAFE.y0 || b.y1 > SAFE.y1; c2.strokeStyle = bad ? 'rgba(255,0,0,0.95)' : 'rgba(0,170,60,0.75)'; c2.lineWidth = bad ? 4 : 2; c2.strokeRect(b.x0, b.y0, b.x1 - b.x0, b.y1 - b.y0); }
    c2.restore();
  };
  return K;
}

// colour mix of two hex colours
export function mix(a, b, k) {
  const p = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16)), A = p(a), B = p(b);
  return '#' + A.map((v, i) => Math.round(v + (B[i] - v) * clamp(k)).toString(16).padStart(2, '0')).join('');
}
```

### `engine/lib.js`

```js
// Small helpers shared by the kit and the scene. Every frame is a pure function of film time t: no state, no randomness
// (hash() gives stable pseudo-random numbers).
export const clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
export const lerp = (a, b, k) => a + (b - a) * k;
export const prog = (t, t0, d) => d <= 0 ? (t >= t0 ? 1 : 0) : clamp((t - t0) / d);
export const eio = x => x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;      // ease in-out cubic
export const eo = x => 1 - Math.pow(1 - x, 3);                                          // ease out cubic
export const eo5 = x => 1 - Math.pow(1 - x, 5);
export const ei = x => x * x * x;
export const back = (x, s = 1.9) => { if (x <= 0) return 0; if (x >= 1) return 1; const c = s + 1; return 1 + c * Math.pow(x - 1, 3) + s * Math.pow(x - 1, 2); };
export const ss = (t, a, b) => { const x = clamp((t - a) / (b - a)); return x * x * (3 - 2 * x); };
export const hash = n => { const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };
// in/out envelope: 0 before t0, eases to 1 over din, holds, eases back to 0 over dout ending at t1
export function env(t, t0, t1 = Infinity, din = 0.2, dout = 0.2, ein = eo, eout = eo) {
  if (t < t0 || t > t1) return 0;
  const a = din > 0 ? ein(clamp((t - t0) / din)) : 1, b = dout > 0 && t1 < Infinity ? eout(clamp((t1 - t) / dout)) : 1;
  return Math.min(a, b);
}
// a pop-in scale (0 → overshoot → 1) over d seconds from t0
export const popS = (t, t0, d = 0.26, s = 2.2) => t < t0 ? 0 : back(prog(t, t0, d), s);
// a damped settle after an impact: 1 + amp·e^(−k·dt)·cos(w·dt)
export const settle = (t, t0, amp = 0.06, k = 9, w = 30) => t < t0 ? 1 : 1 + amp * Math.exp(-k * (t - t0)) * Math.cos(w * (t - t0));
// how many characters of text are typed between t0 and t1 (linear)
export const typedN = (text, t, t0, t1) => t < t0 ? 0 : t >= t1 ? [...text].length : Math.floor([...text].length * (t - t0) / (t1 - t0) + 1e-6);
export const caretOn = (t, period = 0.9) => (t % period) < period * 0.55;
// the value of a step list [[t, v], …] at t (the last v whose t ≤ now), or def
export function stepAt(list, t, def = null) { let v = def; for (const [tt, x] of list) { if (t >= tt) v = x; else break; } return v; }
```

### `engine/common.mjs`

```js
// Shared bits for the Node scripts. Usage everywhere: node engine/<script>.mjs <video folder> [...]
import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { spawnSync } from 'node:child_process'; import { pathToFileURL } from 'node:url';
export const ENGINE = import.meta.dirname, ROOT = path.resolve(ENGINE, '..'), FPS = 30;
export function low() { try { os.setPriority(os.constants.priority.PRIORITY_LOW); } catch { } }   // children inherit it
export function args() {
  const a = process.argv.slice(2), pos = [], opt = {};
  for (let i = 0; i < a.length; i++) if (a[i].startsWith('--')) { const k = a[i].slice(2); opt[k] = a[i + 1] != null && !a[i + 1].startsWith('--') ? a[++i] : true; } else pos.push(a[i]);
  return { pos, opt };
}
// the video folder, relative to the project folder; `need` = a file it must hold
export function videoDir(name, { need } = {}) {
  if (!name) die('first argument: the video folder (e.g. my-video)');
  const dir = path.resolve(ROOT, name); if (!fs.existsSync(dir)) die('no folder ' + dir);
  if (need && !fs.existsSync(path.join(dir, need))) die(`${need} missing in ${dir}`);
  return { dir, rel: path.relative(ROOT, dir).replace(/\\/g, '/') || '.' };
}
export const DEFAULTS = {
  take: 'a', voOff: 0.12, tail: 1.6, cover: 1.9,
  music: { file: 'bed.wav', from: 0, db: -7, muffle: [] },   // the synthesized bed (make-music.mjs), ~13 LU under the voice after ducking
  // Brian: a deep, calm narrator at ~150 words a minute with a breath between phrases
  voice: { id: 'nPczCjzI2devNBz1zQrb', name: 'Brian', model_id: 'eleven_multilingual_v2', voice_settings: { stability: 0.45, similarity_boost: 0.8, style: 0.35, use_speaker_boost: true, speed: 1.0 } },
};
export function loadVideo(dir) {
  const f = path.join(dir, 'video.json'), v = fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : {};
  return { ...DEFAULTS, ...v, music: { ...DEFAULTS.music, ...v.music }, voice: { ...DEFAULTS.voice, ...v.voice, voice_settings: { ...DEFAULTS.voice.voice_settings, ...v.voice?.voice_settings } } };
}
export function saveVideo(dir, patch) { const f = path.join(dir, 'video.json'), v = fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : {}; fs.writeFileSync(f, JSON.stringify({ ...v, ...patch }, null, 2) + '\n'); }
export async function loadScript(dir) { const f = path.join(dir, 'script.mjs'); if (!fs.existsSync(f)) die('script.mjs missing in ' + dir); const m = await import(pathToFileURL(f).href + '?t=' + Date.now()); return { LINES: m.LINES || [], SAID: m.SAID || {} }; }
export function apiKey() {   // from the environment or the project's .env. Never print it.
  if (process.env.ELEVENLABS_API_KEY) return process.env.ELEVENLABS_API_KEY.trim();
  const f = path.join(ROOT, '.env'), line = fs.existsSync(f) && fs.readFileSync(f, 'utf8').split(/\r?\n/).find(l => l.startsWith('ELEVENLABS_API_KEY='));
  if (!line) die('ELEVENLABS_API_KEY is not set (environment or .env in the project folder)'); return line.slice(19).trim().replace(/^["']|["']$/g, '');
}
export function loud(file) {   // integrated loudness, LRA, true peak
  const e = spawnSync('ffmpeg', ['-hide_banner', '-nostats', '-i', file, '-af', 'ebur128=peak=true', '-f', 'null', '-'], { encoding: 'utf8' }).stderr.split('Summary:')[1] || '';
  const n = re => +(re.exec(e)?.[1] ?? NaN); return { I: n(/I:\s+(-?[\d.]+) LUFS/), LRA: n(/LRA:\s+(-?[\d.]+) LU/), TP: n(/Peak:\s+(-?[\d.inf]+) dBFS/) };
}
// a sound or the music: the video's own folder first, then the project's
export const findAsset = (dir, kind, file) => [path.join(dir, kind, file), path.join(ROOT, kind, file)].find(p => fs.existsSync(p));
export function run(cmd, a) { const r = spawnSync(cmd, a, { stdio: 'inherit' }); if (r.status !== 0) die(`${cmd} failed`); }
export function die(msg) { console.error('✗ ' + msg); process.exit(1); }
// a voice take that must never change (the example's): vo/FROZEN
export const frozen = dir => fs.existsSync(path.join(dir, 'vo', 'FROZEN'));
// words of an ElevenLabs alignment: [{ w, s, e, i }] (i = the word's first character index)
export function alignWords(A) { const out = []; let cur = null; A.characters.forEach((ch, k) => { if (/\s/.test(ch)) { if (cur) out.push(cur); cur = null; return; } if (!cur) cur = { w: '', s: A.character_start_times_seconds[k], e: 0, i: k }; cur.w += ch; cur.e = A.character_end_times_seconds[k]; }); if (cur) out.push(cur); return out; }
```

### `engine/cdp.mjs`

```js
// Headless Chrome/Edge over the DevTools protocol, no dependencies. ONE browser at a time, at the lowest priority.
// This film is a 2D canvas: Chrome draws it on the GPU when it can, and a CPU fallback is light enough to be fine.
import { spawn, execFile } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
export const CHROME = [process.env.CHROME, 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
  'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', 'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
  '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
  '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/microsoft-edge'].find(p => p && fs.existsSync(p));
const ANGLE = process.env.PIXEL_ANGLE || (process.platform === 'win32' ? 'd3d11' : process.platform === 'darwin' ? 'metal' : 'gl');
const sleep = ms => new Promise(r => setTimeout(r, ms));

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

### `engine/setup.mjs`

```js
// Requirements check + the free fonts. Run once per project:  node engine/setup.mjs
// Node 22+, ffmpeg/ffprobe, Chrome/Edge, the ElevenLabs key (never printed); downloads the four fonts (SIL OFL, google/fonts)
// and makes the synthesized sounds; lists the kit's sounds that are still missing (node engine/gen-sfx.mjs makes them once).
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { ROOT } from './common.mjs';
import { CHROME, launch } from './cdp.mjs';
import { KIT } from './gen-sfx.mjs';
const ok = (good, msg) => console.log(good ? '✓' : '✗', msg);
ok(+process.versions.node.split('.')[0] >= 22, 'Node ' + process.versions.node + ' (22+ needed: global fetch and WebSocket)');
for (const c of ['ffmpeg', 'ffprobe']) ok(spawnSync(c, ['-version']).status === 0, c + ' on PATH');
ok(!!CHROME, 'Chrome or Edge: ' + (CHROME || 'not found, set CHROME=<path to the browser>'));
ok(!!(process.env.ELEVENLABS_API_KEY || (fs.existsSync(path.join(ROOT, '.env')) && /^ELEVENLABS_API_KEY=./m.test(fs.readFileSync(path.join(ROOT, '.env'), 'utf8')))), 'ELEVENLABS_API_KEY (environment or .env)');
const GH = 'https://raw.githubusercontent.com/google/fonts/main/ofl/';
const GET = [
  ['fonts/Inter.ttf', GH + 'inter/Inter%5Bopsz,wght%5D.ttf'],                 // the headline (860)
  ['fonts/InterTight.ttf', GH + 'intertight/InterTight%5Bwght%5D.ttf'],       // cards, chips, bubbles, stamps (800-900)
  ['fonts/SpaceMono-Regular.ttf', GH + 'spacemono/SpaceMono-Regular.ttf'],    // the meter, chip subtitles
  ['fonts/SpaceMono-Bold.ttf', GH + 'spacemono/SpaceMono-Bold.ttf'],          // the plaque, chip titles, the footer
];
for (const [rel, url] of GET) {
  const f = path.join(ROOT, rel); if (fs.existsSync(f)) { ok(true, rel); continue; }
  fs.mkdirSync(path.dirname(f), { recursive: true });
  try { const r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0' } }); if (!r.ok) throw new Error(r.status); fs.writeFileSync(f, Buffer.from(await r.arrayBuffer())); ok(true, rel + ' downloaded'); }
  catch (e) { ok(false, `${rel}: download failed (${e.message}) from ${url}`); }
}
fs.mkdirSync(path.join(ROOT, 'sfx'), { recursive: true });
const missing = Object.keys(KIT).filter(n => !fs.existsSync(path.join(ROOT, 'sfx', n + '.wav')));
ok(!missing.length, missing.length ? `${missing.length} of ${Object.keys(KIT).length} sounds still to make: ${missing.join(' ')} (node engine/gen-sfx.mjs, then --yes)` : `all ${Object.keys(KIT).length} sounds in sfx/`);
if (fs.existsSync(path.join(ROOT, 'sfx', 'pop.wav'))) { spawnSync(process.execPath, [path.join(ROOT, 'engine', 'synth-sfx.mjs')], { stdio: 'inherit' }); }
else console.log('· the riser and the two pitched pops are made by engine/synth-sfx.mjs once sfx/pop.wav exists');
if (CHROME) try { const api = await launch({ port: 9699 }); ok(true, 'Chrome starts headless · WebGL renderer: ' + api.gpu); await api.close(); } catch (e) { ok(false, e.message); }
setTimeout(() => process.exit(0), 1200);
```

### `engine/script-text.mjs`

```js
// script.mjs LINES → tokens and the voice text. Markup (headline only, never spoken):
//   *word*  an orange key word (may span several tokens: "*content skill again.*")
//   |       a line break inside the headline
// A token keeps its quotes and punctuation ("“It's", "file.”"); SAID maps a bare token to its spoken words.
export function lineTokens(line) {
  const out = []; let orange = false, br = false;
  for (let raw of line.trim().split(/\s+/)) {
    if (raw === '|') { br = true; continue; }
    let o0 = orange, close = false;
    const m1 = /^([“"‘(]*)\*(.*)$/.exec(raw); if (m1) { raw = m1[1] + m1[2]; o0 = true; orange = true; }
    const m2 = /^(.*)\*([”"’)]*)$/.exec(raw); if (m2) { raw = m2[1] + m2[2]; close = true; }
    out.push({ w: raw, o: o0 || orange, br });
    br = false; if (close) orange = false;
  }
  return out;
}
export const plainLine = line => lineTokens(line).map(t => t.w).join(' ');
// a shown token → [bare, trailing punctuation] ("SKILL.md." → ["SKILL.md", "."], ".md" → [".md", ""])
export const splitPunct = tok => { const m = /^(.*?)([.,!?;:]*)$/.exec(tok); return [m[1], m[2]]; };
// how a token is said: SAID[bare] + its punctuation, quotes kept
export function saidTok(tok, said) {
  const q0 = /^[“"‘(]*/.exec(tok)[0], q1 = /[”"’)]*$/.exec(tok)[0], core = tok.slice(q0.length, tok.length - q1.length || undefined);
  const [bare, p] = splitPunct(core);
  return said[bare] != null ? q0 + said[bare] + p + q1 : tok;
}
export function saidText(lines, said) {
  return lines.map((l, i) => {
    const s = lineTokens(l).map(t => saidTok(t.w, said)).join(' ');
    const prev = i ? plainLine(lines[i - 1]).replace(/[”"’)]+$/, '') : '';
    return i === 0 || /[.!?]$/.test(prev) ? s.replace(/^([“"‘(]*)(\S)/, (_, q, c) => q + c.toUpperCase()) : s;
  }).join(' ');
}
// how many spoken words a token is
export const saidCount = (tok, said) => saidTok(tok, said).split(/\s+/).filter(Boolean).length;
```

### `engine/build.mjs`

```js
// Voice take -> <video>/film.json: the shown tokens of script.mjs (LINES with their headline markup) timed on the said words,
// one headline per LINE, and a 30 fps loudness envelope. Prints every headline with its words' times: key every beat to them.
//   node engine/build.mjs <video>           from vo/<take>.json (ElevenLabs alignment) + vo/<take>.wav
//   node engine/build.mjs <video> --fake    timings guessed from script.mjs at Brian's pace: direct and render stills before
//                                           you pay for a voice
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { FPS, args, videoDir, loadVideo, loadScript, alignWords, die } from './common.mjs';
import { lineTokens, saidTok } from './script-text.mjs';
const { pos, opt } = args(), { dir } = videoDir(pos[0]), cfg = loadVideo(dir), S = await loadScript(dir);
const take = opt.fake ? 'fake' : cfg.take, OFF = cfg.voOff || 0;
const TOK = []; S.LINES.forEach((line, li) => lineTokens(line).forEach(t => { const said = saidTok(t.w, S.SAID).split(/\s+/).filter(Boolean); TOK.push({ ...t, line: li, said, stop: /[.,!?;:][”"’)]*$/.test(t.w) }); }));
const said = TOK.flatMap(t => t.said);
let W = [], env = [];
if (opt.fake) {   // Brian at speed 1.0, tightened ×1.03: a word lasts 0.021 + 0.0574 s a letter; 0.36 s after . ! ?, 0.09 s after , ; :, else 0.05 s
  let t = 0.05; for (const w of said) { const d = Math.max(0.12, 0.021 + 0.0574 * w.replace(/[^A-Za-z0-9]/g, '').length); W.push({ w, s: t, e: t + d }); t += d + (/[.!?][”"’)]*$/.test(w) ? 0.36 : /[,;:][”"’)]*$/.test(w) ? 0.09 : 0.05); }
} else {
  const al = path.join(dir, 'vo', take + '.json'); if (!fs.existsSync(al)) die(`vo/${take}.json missing: node engine/gen-vo.mjs ${pos[0]} --yes (or build --fake)`);
  W = alignWords(JSON.parse(fs.readFileSync(al, 'utf8')).alignment);
  if (W.length !== said.length) die(`the take has ${W.length} words, script.mjs says ${said.length}: was the take made from this script?`);
  // ElevenLabs hands the silence before a word to that word: move each start to where its sound begins (10 ms RMS > 5 % of the loud level)
  const pcm = spawnSync('ffmpeg', ['-v', 'error', '-i', path.join(dir, 'vo', take + '.wav'), '-f', 'f32le', '-ac', '1', '-ar', '16000', '-'], { maxBuffer: 1 << 28 }).stdout;
  const X = new Float32Array(pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + (pcm.length & ~3))), rms = [];
  for (let i = 0; i + 160 <= X.length; i += 160) { let a = 0; for (let j = i; j < i + 160; j++) a += X[j] * X[j]; rms.push(Math.sqrt(a / 160)); }
  const thr = Math.max(0.004, 0.05 * [...rms].sort((a, b) => a - b)[Math.floor(rms.length * 0.95)]);
  for (const w of W) { let f = Math.round(w.s * 100); const lim = Math.round(Math.min(w.e - 0.05, w.s + 0.8) * 100); while (f < lim && (rms[f] ?? 1) < thr) f++; w.s = Math.max(w.s, f / 100 - 0.02); }
  const hop = 16000 / FPS; for (let f = 0; f * hop < X.length; f++) { let a = 0; const s0 = Math.floor(f * hop), s1 = Math.min(X.length, Math.floor((f + 1) * hop)); for (let i = s0; i < s1; i++) a += X[i] * X[i]; env.push(Math.sqrt(a / Math.max(1, s1 - s0))); }
  const pk = [...env].sort((a, b) => a - b)[Math.floor(env.length * 0.97)] || 1; env = env.map(v => +Math.min(1, v / pk).toFixed(3));
}
let k = 0;
const words = TOK.map((t, i) => { const a = W[k], b = W[k + t.said.length - 1]; k += t.said.length; return { i, w: t.w, o: t.o, br: t.br, line: t.line, t0: +(a.s + OFF).toFixed(3), t1: +(b.e + OFF).toFixed(3), stop: t.stop }; });
const END = +(words.at(-1).t1 + cfg.tail).toFixed(2);
// one headline per LINE: it takes over 0.1 s before its first word and holds until the next one takes over; frame 0 shows the first
const heads = S.LINES.map((_, li) => { const ws = words.filter(w => w.line === li); return { k: li, t0: +(ws[0].t0 - 0.1).toFixed(3), end: ws.at(-1).t1, words: ws.map(w => w.i) }; });
heads.forEach((h, j) => { h.t1 = j + 1 < heads.length ? heads[j + 1].t0 : END + 1; }); heads[0].t0 = -1;
if (opt.fake) for (let f = 0; f < Math.ceil((END + 1) * FPS); f++) { const s = f / FPS - OFF; env.push(W.some(w => s >= w.s && s < w.e) ? +(0.45 + 0.4 * Math.abs(Math.sin(f * 1.7))).toFixed(3) : 0); }
else env = [...Array(Math.round(OFF * FPS)).fill(0), ...env];
fs.writeFileSync(path.join(dir, 'film.json'), JSON.stringify({ take, voOff: OFF, END, words, heads, env }));
console.log(`${words.length} tokens · ${heads.length} headlines · END ${END} s${END > 60 ? '  ✗ over 60 s: cut the script' : ''}${opt.fake ? ' · FAKE timings (no voice yet)' : ''}`);
for (const h of heads) console.log(`L${String(h.k).padStart(2)} ${h.t0.toFixed(2).padStart(6)}  ${h.words.map(i => `${words[i].br ? '| ' : ''}${words[i].o ? '*' : ''}${words[i].w}@${words[i].t0.toFixed(2)}`).join(' ')}`);
```

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

```js
// ONE ElevenLabs take of script.mjs (LINES without their headline markup, with the SAID spellings), with character
// timestamps. Dry run unless --yes (~1 credit a character).
//   node engine/gen-vo.mjs <video> [--yes] [--take b]                  -> <video>/vo/<take>.mp3 .wav .json, video.json "take"
//   node engine/gen-vo.mjs <video> --yes --test 3 --take t1            just the first 3 lines (to try a voice; video.json is left alone)
//   node engine/gen-vo.mjs <video> --yes --only 3 --take s1            just LINES[3] (or 3-4): a re-take of a line to splice in with voedit.mjs
import fs from 'node:fs'; import path from 'node:path';
import { args, videoDir, loadVideo, saveVideo, loadScript, apiKey, run, die, frozen } from './common.mjs';
import { saidText } from './script-text.mjs';
const { pos, opt } = args(), { dir } = videoDir(pos[0]), cfg = loadVideo(dir), vo = path.join(dir, 'vo'); fs.mkdirSync(vo, { recursive: true });
if (frozen(dir)) die('this voice is frozen (vo/FROZEN): make takes in your own video folder');
const S = await loadScript(dir), only = opt.only != null ? String(opt.only).split('-').map(Number) : null;
const lines = only ? S.LINES.slice(only[0], (only[1] ?? only[0]) + 1) : opt.test ? S.LINES.slice(0, +opt.test) : S.LINES;
if (only) opt.test = true;
if (!lines.length) die('script.mjs has no LINES');
let take = opt.take || 'a'; if (!opt.take) while (fs.existsSync(path.join(vo, take + '.json'))) take = String.fromCharCode(take.charCodeAt(0) + 1);
if (fs.existsSync(path.join(vo, take + '.json'))) die(`vo/${take}.json exists: never pay for the same take twice`);
const text = saidText(lines, S.SAID), words = text.split(/\s+/).length;
console.log(`${cfg.voice.name} · ${cfg.voice.model_id} · take ${take} · ${text.length} characters · ${words} words ≈ ${(words / 2.55).toFixed(0)} s\n${text}`);
if (!opt.yes) { console.log('dry run: nothing sent. Add --yes to generate (≈ ' + text.length + ' credits).'); process.exit(0); }
const r = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${cfg.voice.id}/with-timestamps?output_format=mp3_44100_128`, { method: 'POST',
  headers: { 'xi-api-key': apiKey(), 'content-type': 'application/json' }, body: JSON.stringify({ text, model_id: cfg.voice.model_id, voice_settings: cfg.voice.voice_settings }) });
if (!r.ok) die(`ElevenLabs ${r.status}: ${(await r.text()).slice(0, 300)}`);
const j = await r.json();
fs.writeFileSync(path.join(vo, take + '.mp3'), Buffer.from(j.audio_base64, 'base64'));
fs.writeFileSync(path.join(vo, take + '.json'), JSON.stringify({ text, lines, voice: { id: cfg.voice.id, name: cfg.voice.name }, cfg: { model_id: cfg.voice.model_id, voice_settings: cfg.voice.voice_settings }, alignment: j.alignment }));
run('ffmpeg', ['-v', 'error', '-y', '-i', path.join(vo, take + '.mp3'), '-ar', '48000', '-ac', '1', path.join(vo, take + '.wav')]);
if (!opt.test) saveVideo(dir, { take });
const end = j.alignment.character_end_times_seconds.at(-1);
console.log(`ok · ${end.toFixed(2)} s · ${(words / end * 60).toFixed(0)} words/min${opt.test ? '' : ` · next: node engine/votight.mjs ${pos[0]} --from ${take} --out ${String.fromCharCode(take.charCodeAt(0) + 1)} --set, then voedit.mjs (the verdict pauses) and build.mjs`}`);
```

### `engine/voedit.mjs`

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

### `engine/votight.mjs`

```js
// The format's pace without paying for a new take: shorten the pauses that run long (cut only inside real silence), speed the
// take up a touch, and remap its word timings. Pixel keeps a breath between phrases: 0.36 s after . ! ?, 0.16 s after , ;
//   node engine/votight.mjs <video> --from a --out b [--stop 0.36] [--comma 0.16] [--word 0.05] [--tempo 1.03] [--pitch 1.0] [--set]
// --stop: the pause kept after . ! ? :   --comma: after , ;   --word: between other words (only longer silences are cut)
// --tempo: the total speed-up; --pitch: the part done by resampling (1.04 ≈ +0.7 semitone, a touch brighter), the rest by atempo
// (pitch kept). Pixel uses ×1.03 with the pitch kept. Writes vo/<out>.wav + .json (like gen-vo).
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { args, videoDir, saveVideo, alignWords, run, die, frozen } from './common.mjs';
const { pos, opt } = args(), { dir } = videoDir(pos[0]), vo = path.join(dir, 'vo'), SR = 48000;
if (frozen(dir)) die('this voice is frozen (vo/FROZEN): tighten takes in your own video folder');
const from = opt.from || 'a', out = opt.out || 'b'; if (out === from) die('--out must differ from --from');
const STOP = +(opt.stop ?? 0.36), COMMA = +(opt.comma ?? 0.16), WORD = +(opt.word ?? 0.05), TEMPO = +(opt.tempo ?? 1.03), PITCH = +(opt.pitch ?? 1.0);   // the format's calm pace: a breath between phrases
const r = spawnSync('ffmpeg', ['-v', 'error', '-i', path.join(vo, from + '.wav'), '-ac', '1', '-ar', String(SR), '-f', 's16le', '-'], { maxBuffer: 1 << 30 });
if (r.status !== 0) die(`vo/${from}.wav missing`);
const P = new Int16Array(r.stdout.buffer, r.stdout.byteOffset, r.stdout.length / 2);
const J = JSON.parse(fs.readFileSync(path.join(vo, from + '.json'), 'utf8')), A = J.alignment, W = alignWords(A);
// 5 ms loudness frames; silence = 40 dB under the take's loud frames
const nF = Math.floor(P.length / 240), dB = new Float32Array(nF);
for (let f = 0; f < nF; f++) { let s = 0; for (let i = f * 240; i < (f + 1) * 240; i++) s += (P[i] / 32768) ** 2; dB[f] = 10 * Math.log10(s / 240 + 1e-12); }
const THR = [...dB].sort((a, b) => a - b)[Math.floor(nF * 0.9)] - 40;
const cuts = [];
for (let k = 0; k + 1 < W.length; k++) {
  // the silence after word k (ElevenLabs often hands it to the next word, so look into that word's span too)
  let a = Math.ceil(W[k].e * 200); const lim = Math.floor(Math.min(W[k + 1].e - 0.05, W[k + 1].s + 0.8) * 200);
  while (a < lim && dB[a] > THR) a++;
  let b = a; while (b < lim && dB[b] <= THR) b++;
  const s0 = a / 200 + 0.015, s1 = b / 200 - 0.015, sil = s1 - s0; if (sil <= 0) continue;
  const keep = /[.!?:]["”]?$/.test(W[k].w) ? STOP : /[,;]["”]?$/.test(W[k].w) ? COMMA : WORD, drop = sil - keep;
  if (drop > 0.01) { const mid = (s0 + s1) / 2; cuts.push([mid - drop / 2, mid + drop / 2]); }
}
// splice with 4 ms fades on both sides of each cut, keeping a map from old to new time
const outPCM = [], fade = Math.round(0.004 * SR), map = []; let pos0 = 0, dst = 0;
for (const [a, b] of [...cuts, [P.length / SR, P.length / SR]]) {
  const i0 = Math.round(pos0 * SR), i1 = Math.round(a * SR), seg = Array.from(P.subarray(i0, i1)), last = a >= P.length / SR;
  for (let i = 0; i < Math.min(fade, seg.length); i++) { if (pos0 > 0) seg[i] *= i / fade; if (!last) seg[seg.length - 1 - i] *= i / fade; }
  map.push([pos0, a, dst]); dst += (i1 - i0) / SR; for (const v of seg) outPCM.push(Math.round(v)); pos0 = b;
}
const t2 = t => { for (const [s0, s1, d0] of map) if (t <= s1) return d0 + Math.max(0, t - s0); const [s0, , d0] = map.at(-1); return d0 + (t - s0); };
const raw = path.join(vo, out + '.raw'); fs.writeFileSync(raw, Buffer.from(new Int16Array(outPCM).buffer));
run('ffmpeg', ['-v', 'error', '-y', '-f', 's16le', '-ar', String(SR), '-ac', '1', '-i', raw, '-af', `asetrate=${SR * PITCH},aresample=${SR},atempo=${(TEMPO / PITCH).toFixed(5)}`, '-ar', String(SR), path.join(vo, out + '.wav')]);
fs.rmSync(raw);
const T = t => +(t2(t) / TEMPO).toFixed(3);
fs.writeFileSync(path.join(vo, out + '.json'), JSON.stringify({ ...J, edit: { from, stop: STOP, comma: COMMA, word: WORD, tempo: TEMPO, pitch: PITCH, cuts: cuts.length },
  alignment: { characters: A.characters, character_start_times_seconds: A.character_start_times_seconds.map(T), character_end_times_seconds: A.character_end_times_seconds.map(T) } }));
if (opt.set) saveVideo(dir, { take: out });
const len = dst / TEMPO, removed = cuts.reduce((s, [a, b]) => s + b - a, 0);
console.log(`vo/${out}.wav · ${cuts.length} pauses shortened (−${removed.toFixed(2)} s) · ×${TEMPO} · ${len.toFixed(2)} s · ${(W.length / len * 60).toFixed(0)} words/min (the reference: 287)${opt.set ? ' · video.json take = ' + out : ''}`);
```

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

```js
// The sound kit, made once with ElevenLabs sound generation and reused by every video -> sfx/<name>.wav. Dry run unless --yes.
//   node engine/gen-sfx.mjs [--yes] [name ...]      (existing files are never regenerated; ~19 s of sound in all)
// Each file is shifted so its attack lands at the onset in KIT (the example's cues.mjs subtracts the same values, ON),
// so a generated kit lines up with the picture exactly like the one the example was made with.
// riser.wav and the pitched pops (pop_hi, pop_hi2) are not generated: engine/synth-sfx.mjs makes them.
import fs from 'node:fs'; import path from 'node:path'; import { execFileSync, spawnSync } from 'node:child_process';
import { ROOT, args, apiKey } from './common.mjs';
export const KIT = {   // name: [prompt, seconds, onset s]
  pop: ['a soft cartoon pop, bubbly and light, dry', 0.5, 0],
  click: ['a single small dry click, like one computer key, close mic', 0.5, 0.14],
  flick: ['a stiff card flipped over quickly, short papery flick', 0.5, 0.1],
  slap: ['a small card dropped flat onto a wooden table, short dry slap', 0.5, 0.13],
  stamp: ['a rubber stamp pressed hard onto paper on a desk, single thump', 0.6, 0.03],
  thud: ["a judge's wooden gavel knocking once on a wooden block, solid dry knock", 0.8, 0.07],
  clack: ['a short dry wooden clack, two wooden blocks tapped together once', 0.5, 0.02],
  impact: ['a deep soft cinematic sub boom with a short rumble tail, a verdict lands', 1.5, 0],
  whoosh: ['a quick airy whoosh transition, soft', 0.8, 0.08],
  swoosh: ['a soft short swoosh of a card flying past fast', 0.7, 0.15],
  swish: ['a fast thin swish, like a card flicked through the air', 0.6, 0.11],
  kaching: ['vintage cash register ka-ching bell ring, short, dry, no music', 1.3, 0],
  shimmer: ['a warm magical shimmer, rising sparkly chimes', 2.2, 0.1],
  chime: ['a gentle sparkling chime arpeggio, bright and short, rising', 2.0, 0.14],
  ding: ['a bright clean notification ding', 0.6, 0],
  clunk: ['a big chunky button clunking down, short and solid', 0.5, 0.07],
  burst: ['a quick bright burst of small sparkles and confetti, no explosion boom', 0.8, 0],
  typing: ['fast typing on a laptop keyboard, light clicky keys, no music', 2.0, 0],
};
// the first moment a sound reaches 30 % of its peak
function onsetOf(file) {
  const raw = spawnSync('ffmpeg', ['-v', 'error', '-i', file, '-ac', '1', '-ar', '48000', '-f', 'f32le', '-'], { maxBuffer: 1 << 28 }).stdout;
  const x = new Float32Array(raw.buffer.slice(raw.byteOffset, raw.byteOffset + (raw.length & ~3))); let pk = 0; for (const v of x) pk = Math.max(pk, Math.abs(v));
  for (let i = 0; i < x.length; i++) if (Math.abs(x[i]) >= 0.3 * pk) return i / 48000; return 0;
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
  const { pos, opt } = args(), out = path.join(ROOT, 'sfx'); fs.mkdirSync(out, { recursive: true });
  const todo = (pos.length ? pos : Object.keys(KIT)).filter(n => KIT[n] && !fs.existsSync(path.join(out, n + '.wav')));
  console.log(`${todo.length} sounds to make: ${todo.join(' ') || '(none)'}`); if (!opt.yes) { console.log('dry run: add --yes to generate them (sound generation costs credits).'); process.exit(0); }
  for (const n of todo) {
    const [text, sec, want] = KIT[n], r = await fetch('https://api.elevenlabs.io/v1/sound-generation', { method: 'POST', headers: { 'xi-api-key': apiKey(), 'content-type': 'application/json' }, body: JSON.stringify({ text, duration_seconds: sec, prompt_influence: 0.6 }) });
    if (!r.ok) { console.log(n, r.status, (await r.text()).slice(0, 200)); continue; }
    const mp3 = path.join(out, n + '.mp3'), tmp = path.join(out, n + '.tmp.wav'); fs.writeFileSync(mp3, Buffer.from(await r.arrayBuffer()));
    execFileSync('ffmpeg', ['-v', 'error', '-y', '-i', mp3, '-ar', '48000', '-ac', '2', tmp]); fs.rmSync(mp3);
    const shift = want - onsetOf(tmp);   // > 0: add silence before it, < 0: cut the silence it starts with
    const af = shift > 0.002 ? [`adelay=${Math.round(shift * 1000)}|${Math.round(shift * 1000)}`] : shift < -0.002 ? [`atrim=start=${(-shift).toFixed(3)}`, 'asetpts=PTS-STARTPTS'] : [];
    execFileSync('ffmpeg', ['-v', 'error', '-y', '-i', tmp, ...(af.length ? ['-af', af.join(',')] : []), path.join(out, n + '.wav')]); fs.rmSync(tmp);
    console.log(n, 'ok', `(attack at ${want.toFixed(2)} s)`);
  }
}
```

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

```js
// Synthesized effects the kits don't have, written to <kit>/sfx/:
//   riser.wav    1.0 s build into a hit (like the reference's before GUILTY / NOT GUILTY): a noise sweep 400 Hz → 12 kHz
//                and a tone gliding 340 → 1400 Hz, both swelling, cut dead at the end (the boom covers the cut)
//   pop_hi.wav   pop.wav pitched up ×1.47, pop_hi2.wav ×1.79 (the jury pops in on alternating pitches)
//   node engine/synth-sfx.mjs
import fs from 'node:fs'; import path from 'node:path';
import { ROOT, run } from './common.mjs';
const SR = 48000;
function write(name, f32) {
  const raw = path.join(ROOT, 'sfx', name + '.f32'); fs.writeFileSync(raw, Buffer.from(f32.buffer));
  run('ffmpeg', ['-v', 'error', '-y', '-f', 'f32le', '-ar', String(SR), '-ac', '1', '-i', raw, '-c:a', 'pcm_s16le', path.join(ROOT, 'sfx', name + '.wav')]); fs.rmSync(raw);
}
{ // riser
  const D = 1.0, N = Math.round(D * SR), out = new Float32Array(N); let lp = 0, lp2 = 0, ph = 0, seed = 12345;
  const rnd = () => { seed = (seed * 1103515245 + 12345) >>> 0; return seed / 4294967296 * 2 - 1; };
  for (let i = 0; i < N; i++) {
    const t = i / SR, k = t / D, cut = 400 * Math.pow(30, k), a = Math.min(1, k * k * 1.15);
    const al = 1 - Math.exp(-2 * Math.PI * cut / SR); lp += al * (rnd() - lp); lp2 += al * (lp - lp2);
    const f = 340 * Math.pow(1400 / 340, Math.pow(k, 1.3)); ph += f / SR; ph -= Math.floor(ph);
    const tone = Math.sin(2 * Math.PI * ph) * 0.35 + Math.sin(4 * Math.PI * ph) * 0.08;
    const fade = i > N - 480 ? (N - i) / 480 : 1;
    out[i] = (lp2 * 1.6 + tone) * a * fade * 0.7;
  }
  write('riser', out);
}
// the pitched pops, from the kit's pop.wav
const pop = path.join(ROOT, 'sfx', 'pop.wav');
if (fs.existsSync(pop)) for (const [name, k] of [['pop_hi', 1.47], ['pop_hi2', 1.79]]) run('ffmpeg', ['-v', 'error', '-y', '-i', pop, '-af', `asetrate=48000*${k},aresample=48000`, path.join(ROOT, 'sfx', name + '.wav')]);
console.log('sfx/riser.wav' + (fs.existsSync(pop) ? ', sfx/pop_hi.wav, sfx/pop_hi2.wav' : ' (no sfx/pop.wav: the pitched pops were skipped)'));
```

### `engine/make-music.mjs`

```js
// The music bed, synthesized like the reference's: a warm sustained pad (soft detuned partials) over a sub-bass root,
// moving slowly through a chord list, dipping under the risers and coming back on each verdict's boom.
//   node engine/make-music.mjs <video>      → <video>/music/bed.wav (48 kHz stereo), the film's length + 1 s
// The video's music.mjs exports chords(X) → [[t, 'Am7'], …] and dips(X) → [[t0, t1, back], …] (X = times.mjs makeTimes).
import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url';
import { args, videoDir, run, low, die } from './common.mjs';
low();
const { pos } = args(); const { dir } = videoDir(pos[0], { need: 'music.mjs' });
const film = JSON.parse(fs.readFileSync(path.join(dir, 'film.json'), 'utf8'));
const { makeTimes } = await import(pathToFileURL(path.join(dir, 'times.mjs')).href);
const M = await import(pathToFileURL(path.join(dir, 'music.mjs')).href);
const X = makeTimes(film), SR = 48000, DUR = film.END + 1, N = Math.ceil(DUR * SR);

// chord names → MIDI notes [bass, pad…]
const CHORDS = {
  Fmaj7: [41, 53, 57, 60, 64, 69], F: [41, 53, 57, 60, 65, 69], G6: [43, 55, 59, 62, 64, 71], G: [43, 55, 59, 62, 67, 71],
  Am7: [45, 57, 60, 64, 67, 72], Am: [45, 57, 60, 64, 69, 72], Em7: [40, 52, 55, 59, 62, 67], Dm9: [38, 50, 53, 57, 60, 64],
  E: [40, 52, 56, 59, 64, 68], C: [36, 48, 52, 55, 60, 64], Cmaj7: [36, 48, 52, 55, 59, 64],
};
const hz = m => 440 * Math.pow(2, (m - 69) / 12);
// one band-limited single-cycle table per frequency (soft, warm: partials fall off fast, more above ~2 kHz)
const TBL = 4096, tables = new Map();
function table(f) {
  const key = Math.round(f * 10); if (tables.has(key)) return tables.get(key);
  const t = new Float32Array(TBL + 1);
  for (let k = 1; k <= 14; k++) { if (k * f > 9000) break; const a = Math.pow(k, -1.45) * Math.exp(-(k * f) / 2300) * (k % 2 ? 1 : 0.72); for (let i = 0; i <= TBL; i++) t[i] += a * Math.sin(2 * Math.PI * k * i / TBL); }
  let pk = 0; for (const v of t) pk = Math.max(pk, Math.abs(v)); for (let i = 0; i <= TBL; i++) t[i] /= pk;
  tables.set(key, t); return t;
}
const L = new Float32Array(N), R = new Float32Array(N);
// a voice: frequency f, from t0 to t1 (seconds), attack/release, gain, detune in cents per channel, vibrato
function voice(f, t0, t1, { att = 1.0, rel = 1.3, gain = 0.1, cents = [-5, 5], vib = 0.0025, sine = false } = {}) {
  const i0 = Math.max(0, Math.floor(t0 * SR)), i1 = Math.min(N, Math.ceil((t1 + rel) * SR));
  [L, R].forEach((buf, ch) => {
    const fc = f * Math.pow(2, cents[ch] / 1200), tb = table(fc); let ph = (ch * 0.37 + f * 0.001) % 1;
    for (let i = i0; i < i1; i++) {
      const t = i / SR, e = t < t0 + att ? (t - t0) / att : t < t1 ? 1 : Math.max(0, 1 - (t - t1) / rel);
      const env = e * e * (3 - 2 * e);
      ph += fc * (1 + vib * Math.sin(2 * Math.PI * 0.17 * t + ch)) / SR; ph -= Math.floor(ph);
      let v; if (sine) v = Math.sin(2 * Math.PI * ph); else { const x = ph * TBL, j = x | 0; v = tb[j] + (tb[j + 1] - tb[j]) * (x - j); }
      buf[i] += v * env * gain;
    }
  });
}
const list = M.chords(X).sort((a, b) => a[0] - b[0]);
list.forEach(([t0, name, opt = {}], k) => {
  const notes = CHORDS[name]; if (!notes) die('unknown chord ' + name);
  const t1 = k + 1 < list.length ? list[k + 1][0] : DUR, att = opt.hit ? 0.04 : 0.9;
  const [bass, ...pad] = notes;
  voice(hz(bass), t0, t1, { att: opt.hit ? 0.02 : 0.5, rel: 1.0, gain: 0.2, cents: [0, 0], sine: true, vib: 0 });
  voice(hz(bass + 12), t0, t1, { att: opt.hit ? 0.03 : 0.6, rel: 1.0, gain: 0.05, cents: [-3, 3] });
  pad.forEach((m, j) => voice(hz(m), t0, t1, { att: att + j * 0.06, rel: 1.4, gain: 0.075, cents: j % 2 ? [-6, 4] : [-4, 6] }));
  pad.slice(-2).forEach(m => voice(hz(m + 12), t0 + 0.2, t1, { att: 1.4, rel: 1.6, gain: 0.022, sine: true, cents: [-2, 2] }));
});
// gain automation: fade in, dips under the risers (back at the verdict), fade out
const dips = M.dips(X);
for (let i = 0; i < N; i++) {
  const t = i / SR; let g = Math.min(1, t / 0.35) * Math.min(1, Math.max(0, (DUR - 0.2 - t) / 1.6));
  for (const [d0, d1, back] of dips) { if (t >= d0 && t < back) g *= t < d1 ? 1 - 0.88 * (t - d0) / (d1 - d0) : 0.12; }
  L[i] *= g; R[i] *= g;
}
let pk = 0; for (let i = 0; i < N; i++) pk = Math.max(pk, Math.abs(L[i]), Math.abs(R[i]));
const inter = new Float32Array(2 * N); for (let i = 0; i < N; i++) { inter[2 * i] = L[i] / pk * 0.7; inter[2 * i + 1] = R[i] / pk * 0.7; }
fs.mkdirSync(path.join(dir, 'music'), { recursive: true });
const raw = path.join(dir, 'music', 'bed.f32'); fs.writeFileSync(raw, Buffer.from(inter.buffer));
run('ffmpeg', ['-v', 'error', '-y', '-f', 'f32le', '-ar', String(SR), '-ac', '2', '-i', raw,
  '-af', 'highpass=f=38,lowpass=f=7000,aecho=0.85:0.55:63|127|191:0.32|0.22|0.14,alimiter=limit=0.89:level=false', '-ar', String(SR), '-c:a', 'pcm_s16le', path.join(dir, 'music', 'bed.wav')]);
fs.rmSync(raw);
console.log(`music/bed.wav · ${DUR.toFixed(2)} s · ${list.length} chords · ${dips.length} dips`);
```

### `engine/render.mjs`

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

### `engine/mix.mjs`

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

### `engine/encode.mjs`

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

### `engine/check.mjs`

```js
// The finished-video checks:  node engine/check.mjs <video> [--file video.mp4]
// format + size + loudness · frame 0 is the hook, not empty · the headline in the text zone · text inside the safe zone on every frame
// (frames/safe.json: from the full render or render.mjs <video> scan) · how often the picture holds still (information)
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { args, videoDir, loud, low, die } from './common.mjs';
low();
const { pos, opt } = args(), { dir } = videoDir(pos[0]), file = path.resolve(dir, opt.file || 'video.mp4'); if (!fs.existsSync(file)) die('no ' + file);
let ok = true; const say = (good, msg) => { ok &&= good; console.log(good ? '✓' : '✗', msg); };
const p = JSON.parse(spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration,size:stream=codec_type,codec_name,profile,width,height,r_frame_rate,pix_fmt', '-of', 'json', file], { encoding: 'utf8' }).stdout);
const v = p.streams.find(s => s.codec_type === 'video'), au = p.streams.find(s => s.codec_type === 'audio'), dur = +p.format.duration, mb = p.format.size / 1e6, L = loud(file);
say(v.width === 1080 && v.height === 1920 && v.r_frame_rate === '30/1' && v.pix_fmt === 'yuv420p' && v.profile === 'High', `video ${v.codec_name} ${v.profile} ${v.pix_fmt} ${v.width}x${v.height} ${v.r_frame_rate}`);
say(au?.codec_name === 'aac', `audio ${au?.codec_name}`); say(dur <= 60.05, `${dur.toFixed(2)} s`); say(mb <= 60, `${mb.toFixed(1)} MB`);
say(Math.abs(L.I + 14) <= 1 && L.TP <= -1, `loudness ${L.I} LUFS · true peak ${L.TP} dBTP`);
// every frame at 54x96 grey: frame 0's contrast, and the frame-to-frame change
const raw = spawnSync('ffmpeg', ['-v', 'error', '-threads', '2', '-i', file, '-vf', 'scale=54:96,format=gray', '-f', 'rawvideo', '-'], { maxBuffer: 1 << 28 }).stdout, N = 54 * 96;
const f0 = [...raw.subarray(0, N)], m0 = f0.reduce((a, x) => a + x, 0) / N, sd = Math.sqrt(f0.reduce((a, x) => a + (x - m0) ** 2, 0) / N);
say(sd > 12, `frame 0: contrast ${sd.toFixed(0)} (it is the hook: the judge, the plaque, the first headline)`);
const diff = []; for (let k = 1; (k + 1) * N <= raw.length; k++) { let d = 0; for (let i = 0; i < N; i++) d += Math.abs(raw[k * N + i] - raw[(k - 1) * N + i]); diff.push(d / N); }
let run0 = 0, still = []; diff.forEach((d, k) => { if (d < 0.15) { if (++run0 === 30) still.push(((k - 28) / 30).toFixed(1)); } else run0 = 0; });
console.log(`· ${still.length} calm stretches of 1 s or more${still.length ? ' (from ' + still.slice(0, 8).join(', ') + ' s)' : ''}: fine in this format, where the camera only creeps between moves; check that each one has its subject on screen`);
const sf = path.join(dir, 'frames', 'safe.json');
if (fs.existsSync(sf)) {
  const S = JSON.parse(fs.readFileSync(sf, 'utf8')), bad = Object.keys(S.bad), cf = Object.keys(S.captions || {});
  say(!bad.length, `text outside the safe zone on ${bad.length} frames${bad.length ? ', e.g. f' + bad[0] + ' ' + JSON.stringify(S.bad[bad[0]][0]) : ''}`);
  // this format's caption is the headline at the top left: it must sit in the text zone x 60-960, y 250-1480
  for (const f of [...new Set(Array.from({ length: Math.min(5, cf.length) }, (_, i) => cf[Math.floor((i + 0.5) / 5 * cf.length)]))]) { const [x0, x1, y0, y1] = S.captions[f]; say(x0 >= 60 && x1 <= 960 && y0 >= 250 && y1 <= 1480, `headline f${f}: x ${x0}-${x1}, y ${y0}-${y1}`); }
} else console.log('· no frames/safe.json: run node engine/render.mjs ' + pos[0] + ' scan');
console.log(ok ? '\n✓ all checks passed. Now watch it once with sound.' : '\n✗ fix the ✗ lines');
```


## Appendix B: an example video (complete)

"Never pay for a Claude content skill again" (57.5 s, the reel in `reference.jpg`). It uses every station, every component and every signature moment. The names in it (the narrator, his app, the format list) are its creator's own: use yours. Copy the folder, write your own `script.mjs`, then rewrite `times.mjs`, the moves, faces, chips, bubbles and stats in `scene.js`, and the cues and chords for your words.

`example/video.json`: `{ "take": "c", "cover": 1.9, "music": { "file": "bed.wav", "from": 0, "db": -7 } }` (take `a` = the full take, `b` = `a` tightened by votight, `c` = `b` with four pauses before the verdicts from voedit; everything else comes from the defaults in `common.mjs`).

### `example/script.mjs`

```js
// Pixel · "Never pay for a Claude content skill again", reworked into the grammar of @flowmarqai's
// "What happens if 12 Claudes judge your startup idea?" (DeXNCUlAIHs): a pixel court of Claude jurors.
// The reference: hook → what it is → twelve jurors → "let's try one" → GUILTY 11–1 → a juror's quote → the flip
// → "Run it again" → NOT GUILTY 12–0 → three numbers → "Want it? Comment JURY, and I'll send it to you."
// Casper's message and order are kept: the hook, the free library, who he is and Morphy, the free format + SKILL.md,
// the $30 resellers, "$30 for something I gave away for free", his response (a library of ready-to-use content-format
// .md files inside Morphy), no paywalls / subscriptions / strings, the text-file line, this format's name, the CTA.
// The court only frames it: the resale goes on trial (guilty), the free library is the flip (not guilty).
//
// LINES: one spoken phrase each (the voice pauses between them). Markup for the headline (stripped for the voice):
//   *word*  orange key word ·  |  line break inside the headline ·  {shown|said} shows one spelling, says another
// Every LINE is one headline, like the reference (a new headline on every phrase).
export const LINES = [
  'Never pay for a Claude | *content skill again.*',
  "I'm making an entire | *library free.*",
  "I'm *Casper.*",
  'I built *Morphy,*',
  'an agentic super app.',
  'Recently, I gave away | my own *content format,*',
  'and its *SKILL.md.*',
  '*Completely free.*',
  'Then people *took it,*',
  '*repackaged it,*',
  'and started *selling it.*',
  'For *$30.*',
  "So let's put it | *on trial.*",
  'Twelve jurors.',
  '*$30* for something | I gave away for free.',
  '*Guilty.*',
  'Twelve to zero.',
  'The engineer says,',
  "“It's just a | *text file.*”",
  "So here's | *my response.*",
  "I'm building an | *entire library*",
  'of ready-to-use | *content-format .md files,*',
  '*free inside Morphy.*',
  'Run it *again.*',
  '*Not guilty.*',
  'Twelve to zero.',
  '*No paywalls.*',
  '*No subscriptions.*',
  '*No strings attached.*',
  "Because you shouldn't pay | for a *text file*",
  'that was free | *to begin with.*',
  "This one's called | *pixel.md.*",
  'Want it?',
  'Comment *PIXEL,*',
  "and I'll send it to you.",
  '*Completely free.*',
];
// shown token → how the voice says it (whole tokens; punctuation stays on the shown side)
export const SAID = {
  'SKILL.md': 'skill dot M D',
  '$30': 'thirty dollars',
  'content-format': 'content format',
  '.md': 'dot M D',
  'pixel.md': 'pixel dot M D',
  'PIXEL': '"Pixel"',
};
```

### `example/times.mjs`

```js
// The film's clock, shared by scene.js (the picture), cues.mjs (the sound effects) and the music bed.
// Every time comes from the words: T(line, word, n) = when the n-th match of that word in that script line starts,
// TE = when it ends, LS/LE = a line's first start / last end (line numbers = script.mjs LINES).
export function makeTimes(film) {
  const bare = s => s.toLowerCase().replace(/^[“"‘(*]+|[”"’)*.,!?;:]+$/g, '');
  const lineW = k => film.words.filter(w => w.line === k);
  const find = (k, w, n) => { const m = lineW(k).filter(x => bare(x.w) === bare(w)); if (!m[n]) throw new Error(`word "${w}" not in line ${k}`); return m[n]; };
  const T = (k, w, n = 0) => find(k, w, n).t0, TE = (k, w, n = 0) => find(k, w, n).t1;
  const LS = k => lineW(k)[0].t0, LE = k => lineW(k).at(-1).t1;

  const X = { T, TE, LS, LE, END: film.END };
  // the camera
  X.pans = {
    toJury1: LS(1) - 0.12, toChip: LS(2) - 0.2, pushChip: LS(5) + 0.2, backChip: LS(8) - 0.08, punch30: T(11, '$30.') - 0.06,
    toJudgeTrial: LS(12) - 0.25, toJury2: LS(13) - 0.16, toJudgeGuilty: T(15, 'Guilty.') - 0.5, toJury3: LS(16) - 0.24,
    toEngineer: LS(17) - 0.06, toFlip: LS(19) - 0.16, toJury4: LS(23) - 0.12, toJudgeNot: T(24, 'Not') - 0.46, toJury5: LS(25) - 0.22,
    toWhole: LS(26) - 0.3,
  };
  // the court
  X.strikes = [T(0, 'again.') + 0.04, T(15, 'Guilty.') + 0.02, T(24, 'Not') + 0.02];
  X.plaqueFlash = [[T(12, 'trial.') - 0.04, T(12, 'trial.') + 0.55], [T(31, 'pixel.md.') - 0.02, T(31, 'pixel.md.') + 0.6]];
  X.benchIn = LS(1) + 0.16;
  X.juryPop = [LS(1) + 0.32, 0.085];
  X.icons = { t0: T(13, 'Twelve') + 0.14, stagger: 0.058, t1: LS(14) + 0.02 };
  X.dimEng = [T(17, 'engineer') - 0.12, LS(19) - 0.12];
  // the votes
  X.raise1 = T(14, 'free.') - 0.2; X.down1 = LS(17) - 0.06; X.raiseSt1 = 0.045;
  X.flip1 = LS(16) + 0.12; X.flipSt = 0.07;
  X.raise2 = LS(25) - 0.08; X.down2 = LS(26) - 0.2; X.raiseSt2 = 0.024;
  X.flip2 = LS(25) + 0.26; X.flipSt2 = 0.064;
  // the file chips, the card, the stamps
  X.chipCasper = T(2, 'Casper.') - 0.1; X.chipMorphy = T(3, 'Morphy,') - 0.06; X.chipFormat = T(5, 'Recently,') + 0.08;
  X.chipSkill = T(6, 'SKILL.md.') - 0.06; X.took = T(8, 'took') - 0.02;
  X.cardIn = T(9, 'repackaged') - 0.06; X.cardType = [T(9, 'repackaged') + 0.04, TE(9, 'repackaged') - 0.05];
  X.cardV2 = T(19, 'response.') - 0.02; X.cardOut = T(29, 'text') - 0.1;
  X.stampG = T(15, 'Guilty.') + 0.02; X.stampGOut = LS(19) + 0.3;
  X.stampN = T(24, 'Not') + 0.02; X.stampNOut = T(29, 'text') - 0.22;
  X.chipText = T(29, 'text') - 0.06; X.chipFree = T(30, 'free') - 0.04; X.chipPixel = T(31, 'pixel.md.') - 0.04;
  // chips below the slab
  X.chip0 = T(7, 'Completely') - 0.04; X.chipSale = T(10, 'selling') - 0.05; X.chip30 = T(11, '$30.') - 0.04; X.chipsOut1 = LS(12) - 0.2;
  X.chipLib = T(20, 'entire') - 0.05; X.chipMd = T(21, 'content-format') - 0.08; X.chipMorphyFree = T(22, 'free') - 0.05; X.chipsOut2 = LS(23) - 0.06;
  // the jury's bubbles
  X.bub30 = [T(14, '$30') + 0.08, T(14, 'free.') - 0.06]; X.bubFree = [T(14, 'gave') - 0.04, T(14, 'free.') + 0.12];
  X.label = [T(17, 'engineer') - 0.04, LS(18) - 0.02]; X.quote = [LS(18) - 0.02, LS(19) - 0.14];
  // the end
  X.stats = [T(26, 'No') - 0.04, T(27, 'No') - 0.04, T(28, 'No') - 0.04];
  X.statPulse = T(35, 'Completely') + 0.12;
  X.comment = { t0: LS(32) - 0.1, typed: [T(33, 'PIXEL,') + 0.02, T(33, 'PIXEL,') + 0.4], press: T(34, 'send') - 0.02 };
  X.cover = 1.9;
  return X;
}
```

### `example/scene.js`

```js
// Pixel · "Never pay for a Claude content skill again" — the director file (what the film shows, on the words).
// The reference's beats, in order: the judge + plaque hook (gavel) → the jury pops in → the plaque + a file chip on the slab
// → the pitch card types → "Twelve jurors" with role icons → the jury argues (bubbles) and votes (paddles) → GUILTY stamp
// → 12 paddles flip + the meter → one juror's quote (zoom, others dim) → the card flips to v2 + chips → "Run it again"
// → NOT GUILTY → 12 green ✓ → zoom out + three numbers → the file's name → the comment box types the keyword.
// All times live in times.mjs (shared with cues.mjs and the music).
import { makeTimes } from './times.mjs';

export default function setup(E) {
  const { K, film, lib } = E, { L, COL } = K;
  const { clamp, lerp, prog, eio, eo } = lib;
  const X = makeTimes(film), P = X.pans;

  // ---- the camera: framings measured on the reference (world point at the screen centre, zoom) ----
  const F = {
    open: { x: 0, y: -297, z: 1.545 },     // judge + plaque, the slab's top peeking in at the bottom
    chip: { x: 0, y: -112, z: 1.243 },     // plaque + the file chip on the slab
    jury: { x: 0, y: 505, z: 1.0 },        // the slab at the top, two benches, the meter
    judge: { x: 0, y: -50, z: 1.067 },     // judge + plaque + card + slab (the verdicts)
    flip: { x: 0, y: 188, z: 1.0 },        // card + slab + the chips below it, the benches at the bottom
    whole: { x: 0, y: 262, z: 0.663 },     // everything, with the stat chips (the end)
  };
  const ENG = 2;                                                        // the engineer: row 1, third juror (the </> icon)
  const testimony = { x: L.jurorX[ENG] + 144, y: 430, z: 1.25 };
  const MOVES = [                                                       // [start, target, duration]
    [P.toJury1, F.jury, 0.78], [P.toChip, F.chip, 0.72],
    [P.pushChip, { ...F.chip, z: 1.3 }, 5.2],                           // a slow push while the format story is told
    [P.backChip, F.chip, 0.42], [P.punch30, { ...F.chip, z: 1.285 }, 0.35],
    [P.toJudgeTrial, F.judge, 0.62], [P.toJury2, F.jury, 0.66], [P.toJudgeGuilty, F.judge, 0.46], [P.toJury3, F.jury, 0.5],
    [P.toEngineer, testimony, 0.62], [P.toFlip, F.flip, 0.66], [P.toJury4, F.jury, 0.6], [P.toJudgeNot, F.judge, 0.45],
    [P.toJury5, F.jury, 0.5], [P.toWhole, F.whole, 0.78],
  ].sort((a, b) => a[0] - b[0]);
  const lerpC = (a, b, k) => ({ x: lerp(a.x, b.x, k), y: lerp(a.y, b.y, k), z: Math.exp(lerp(Math.log(a.z), Math.log(b.z), k)) });
  // the reference's camera never quite stops: once a move lands it keeps creeping in (~2 % over 3 s, a few px aside)
  const creep = (to, dt, j) => { if (dt <= 0) return to; const k = eo(clamp(dt / 3.2)); return { x: to.x + (j % 2 ? 5 : -5) * k / to.z, y: to.y + (j % 3 ? 3 : -3) * k / to.z, z: to.z * (1 + 0.018 * k) }; };
  const step = (from, j, t) => { const [t0, to, d] = MOVES[j]; return lerpC(from, creep(to, t - t0 - d, j), eio(prog(t, t0, d))); };
  // each move starts from wherever the camera is at its start time (moves may overlap)
  const FROM = []; for (let i = 0; i < MOVES.length; i++) { let c = F.open; for (let j = 0; j < i; j++) { if (MOVES[i][0] < MOVES[j][0]) break; c = step(FROM[j], j, MOVES[i][0]); } FROM.push(c); }
  const camAt = t => { let c = F.open; for (let i = 0; i < MOVES.length; i++) { if (t < MOVES[i][0]) break; c = step(FROM[i], i, t); } return c; };
  // small zoom punches on the impacts
  const PUNCH = [[X.strikes[0], 0.018], [X.strikes[1], 0.026], [X.strikes[2], 0.026], [X.chipPixel + 0.04, 0.014]];
  const camera = t => { const c = camAt(t); let p = 1; for (const [tp, a] of PUNCH) if (t >= tp && t < tp + 0.5) p += a * Math.exp(-7 * (t - tp)) * Math.cos(16 * (t - tp)); return { ...c, z: c.z * p }; };

  // ---- what everything is ----
  const dim = [[X.dimEng[0], X.dimEng[1], [ENG]]];
  const icons = X.icons;
  const paddles = {
    raise: [[X.raise1, X.down1, X.raiseSt1], [X.raise2, X.down2, X.raiseSt2]],
    flips: [[X.flip1, X.flipSt, 'x', X.down1], [X.flip2, X.flipSt2, 'v', X.down2]],
  };
  const meters = [{ tIn: X.flip1 - 0.04, tOut: X.down1, paddles }, { tIn: X.flip2 - 0.06, tOut: X.down2 + 0.02, paddles, green: true }];
  // the file chip, first life: casper → Morphy → format.md (+ SKILL.md on top) → taken away
  const chipA = { tIn: X.chipCasper, tOut: X.took + 0.36, faces: [
    [X.chipCasper, { title: 'casper', sub: 'the builder', icon: 'user', iconCol: COL.ACC }],
    [X.chipMorphy, { title: 'Morphy', sub: 'agentic super app', icon: 'app', iconCol: COL.ACC }],
    [X.chipFormat, { title: 'format.md', sub: 'my content format', icon: 'star' }],
  ] };
  const chipB = { tIn: X.chipSkill, tOut: X.took + 0.42, faces: [[X.chipSkill, { title: 'SKILL.md', sub: 'the skill · Claude Code', icon: 'doc', iconCol: COL.INK }]] };
  // the card: the reseller's listing → (GUILTY) → my response, v2
  const card = { tIn: X.cardIn, tOut: X.cardOut, faces: [
    [X.cardIn, { head: 'the listing', title: 'Content Skill *PRO*', typed: X.cardType }],
    [X.cardV2, { head: 'my response, v2', title: 'Formats *for free*', v2: true }],
  ] };
  const stampG = { text: 'GUILTY', t0: X.stampG, t1: X.stampGOut, x: 36, y: L.chipY + 6, rot: -6, size: 66 };
  const stampN = { text: 'NOT GUILTY', t0: X.stampN, t1: X.stampNOut, x: 14, y: L.chipY + 6, rot: -5, size: 62, color: COL.GREEN_D, confetti: true };
  // the file chip, second life: SKILL.md "a text file" → pixel.md
  const chipC = { tIn: X.chipText, faces: [
    [X.chipText, { title: 'SKILL.md', sub: '1 text file', icon: 'doc', iconCol: COL.INK }],
    [X.chipFree, { title: 'SKILL.md', sub: '1 text file · *$0*', icon: 'doc', iconCol: COL.INK }],
    [X.chipPixel, { title: 'pixel.md', sub: '1 file · Claude Code', icon: 'star' }],
  ] };
  // chips below the slab
  const Y0 = L.chipRowY[0], Y1 = L.chipRowY[1];
  const chips = [
    { text: '*$0*, completely free', kind: 'white', t0: X.chip0, t1: X.took + 0.3, x: 0, y: Y0 },
    { text: 'for sale', kind: 'solid', icon: 'tag', iconCol: COL.WHITE, t0: X.chipSale, t1: X.chipsOut1, x: -112, y: Y0 },
    { text: '*$30* a copy', kind: 'white', t0: X.chip30, t1: X.chipsOut1, x: 118, y: Y0 },
    { text: 'an entire library', kind: 'solid', t0: X.chipLib, t1: X.chipsOut2, x: 0, y: Y0 },
    { text: 'ready-to-use *.md* files', kind: 'white', t0: X.chipMd, t1: X.chipsOut2, y: Y1, row: 1 },
    { text: 'free inside Morphy', kind: 'white', icon: 'check', t0: X.chipMorphyFree, t1: X.chipsOut2, y: Y1, row: 1 },
  ];
  // stat chips at the end
  const stats = [{ num: '0', label: 'paywalls' }, { num: '0', label: 'subscriptions' }, { num: '0', label: 'strings' }]
    .map((s, i) => ({ ...s, t0: X.stats[i], pulse: X.statPulse + i * 0.13 }));
  // bubbles over the jury
  const bubbles = [
    { juror: 0, kind: 'speech', text: '$30?!', size: 37, t0: X.bub30[0], t1: X.bub30[1] },
    { juror: 4, kind: 'speech', text: 'It was free.', size: 37, t0: X.bubFree[0], t1: X.bubFree[1] },
    { juror: ENG, kind: 'label', icon: 'code', text: 'Engineer', t0: X.label[0], t1: X.label[1] },
    { juror: ENG, kind: 'quote', text: '“It’s just a text file.”', size: 36, t0: X.quote[0], t1: X.quote[1] },
  ];
  const comment = { ...X.comment, y: 1347, text: 'PIXEL' };

  // ---- the world, every frame ----
  function world(g, t) {
    K.judge(g, 0, L.plaqueY - L.plaqueH / 2, t, { strikes: X.strikes });
    K.plaque(g, t, { text: 'THE PIXEL COURT', flash: X.plaqueFlash });
    K.slab(g);
    // first life of the file chip(s): SKILL.md lands on top of format.md, then both are taken off to the right
    const k = eo(prog(t, X.took, 0.4)), kb = eo(prog(t, chipB.tIn - 0.04, 0.3)), kt = eo(prog(t, X.took + 0.05, 0.4));
    K.fileChip(g, t, { ...chipA, dx: -52 * kb + 760 * k, dy: -38 * kb, drot: -0.5 * kb + 16 * k, a: clamp(1 - 1.7 * k), leaving: k > 0.01 });
    K.fileChip(g, t, { ...chipB, rot: 2.4, dx: 58 + 800 * kt, dy: 44 - 16 * (1 - kb), drot: 18 * kt, a: clamp(1 - 1.7 * kt), leaving: kt > 0.01 });
    K.card(g, t, card);
    K.stamp(g, t, stampG);
    K.stamp(g, t, stampN);
    K.fileChip(g, t, chipC);
    // chips below the slab (row 1 is laid out side by side)
    const row1 = chips.filter(c => c.row === 1), wsum = row1.reduce((a, c) => a + K.chipW(g, c), 0) + 22 * (row1.length - 1);
    let x = -wsum / 2; for (const c of row1) { const w = K.chipW(g, c); c.x = x + w / 2; x += w + 22; }
    for (const c of chips) K.chip(g, t, c);
    // the jury
    K.benches(g, t, { tIn: X.benchIn });
    K.jurorIcons(g, t, icons);
    K.paddles(g, t, paddles);
    K.jurors(g, t, { pop: X.juryPop, dim });
    for (const m of meters) K.meter(g, t, m);
    for (const b of bubbles) K.bubble(g, t, b);
    // stat chips (side by side under the benches)
    const sw = stats.map(s => K.statW(g, s)), tot = sw.reduce((a, b) => a + b, 0) + 22 * (stats.length - 1);
    let sx = -tot / 2; stats.forEach((s, i) => { K.stat(g, t, { ...s, x: sx + sw[i] / 2, y: L.statY }); sx += sw[i] + 22; });
  }
  function overlay(g, t) { K.commentBox(g, t, comment); }
  return { camera, world, overlay, cover: X.cover, title: 'Never pay for a Claude content skill again · Pixel' };
}
```

### `example/cues.mjs`

```js
// Sound effects on the picture's events (times from times.mjs, the same clock as scene.js).
// ev(name, t, dB, maxDur) — t is when the file starts, so each sound's onset is subtracted (ON below, measured).
// Effects sit well under the voice; nothing loud lands on a key word (Claude, Morphy, agentic, SKILL.md, pixel.md, PIXEL).
import { makeTimes } from './times.mjs';
const ON = { click: 0.14, flick: 0.1, slap: 0.13, thud: 0.07, swoosh: 0.15, whoosh: 0.08, swish: 0.11, chime: 0.14, shimmer: 0.1, stamp: 0.03, clack: 0.02, clunk: 0.07 };
export default function cues(ev, film) {
  const X = makeTimes(film), P = X.pans;
  const G = -5;                                                       // all effects, against the voice (~7 LU under it)
  const ev0 = ev; ev = (name, t, db, dur) => ev0(name, t, db + G, dur);
  const at = (name, t, db, dur) => ev(name, Math.max(0, t - (ON[name] || 0)), db, dur);
  // camera moves: a soft air whoosh as each pan starts to travel
  for (const [k, t] of Object.entries(P)) if (!['pushChip', 'punch30', 'backChip'].includes(k)) at('whoosh', t + 0.12, -27);
  at('swish', P.backChip + 0.02, -26);
  // the gavel: lift (swish), the wooden knock (thud + clack)
  for (const ts of X.strikes) { at('swish', ts - 0.26, -27); at('thud', ts, -9); at('clack', ts + 0.005, -15); }
  // the jury pops in, one by one (two pitches, alternating), and the bench lines
  for (let i = 0; i < 12; i++) at(i % 2 ? 'pop_hi2' : 'pop_hi', X.juryPop[0] + i * X.juryPop[1], -17);
  // role icons over the jurors: quiet ticks
  for (let i = 0; i < 12; i++) at('click', X.icons.t0 + i * X.icons.stagger, -27);
  // the file chip: lands (slap), flips (flick, before the word), SKILL.md lands on top, both get yanked away
  at('slap', X.chipCasper + 0.02, -15);
  at('flick', X.chipMorphy - 0.2, -22); at('flick', X.chipFormat - 0.14, -22);
  at('slap', X.chipSkill - 0.16, -16);
  at('pop', X.chip0, -16);
  at('swoosh', X.took, -12);
  // the listing card: lands, types
  at('slap', X.cardIn + 0.03, -13); ev('typing', X.cardType[0], -21, X.cardType[1] - X.cardType[0] + 0.05);
  at('pop', X.chipSale, -16); ev('kaching', X.TE(11, '$30.') - 0.16, -17);
  // "on trial": the plaque lights up
  ev('ding', X.plaqueFlash[0][0], -3);
  // the jury argues (bubbles) and raises its paddles
  at('pop_hi', X.bub30[0], -16); at('pop_hi2', X.bubFree[0], -16);
  for (let i = 0; i < 12; i++) at('swish', X.raise1 + i * X.raiseSt1, -33);
  // GUILTY: riser into the gavel, the stamp and a sub boom
  ev('riser', X.stampG - 1.0, -14); at('stamp', X.stampG, -8); ev('impact', X.stampG - 0.01, -8);
  // the paddles flip to ✕ one by one
  for (let i = 0; i < 12; i++) at('flick', X.flip1 + i * X.flipSt - 0.06, -25);
  // the engineer: the label pops, then the quote
  at('pop', X.label[0], -16); at('pop_hi', X.quote[0], -15);
  // my response: the card flips to v2, the chips land
  at('flick', X.cardV2 - 0.13, -17);
  at('pop', X.chipLib, -15); at('pop_hi', X.chipMd, -17); at('pop_hi2', X.chipMorphyFree, -17); ev('ding', X.chipMorphyFree + 0.08, -6);
  // NOT GUILTY: riser, stamp, boom, a bright chime and the confetti
  ev('riser', X.stampN - 1.0, -14); at('stamp', X.stampN, -8); ev('impact', X.stampN - 0.01, -8); at('chime', X.stampN + 0.06, -14); ev('burst', X.stampN + 0.02, -22);
  for (let i = 0; i < 12; i++) { at('swish', X.raise2 + i * X.raiseSt2, -34); at('flick', X.flip2 + i * X.flipSt2 - 0.06, -25); }
  // the numbers, the text file, the name
  X.stats.forEach((t, i) => at('clunk', t, -2 - i));
  at('slap', X.chipText + 0.02, -16); at('flick', X.chipFree - 0.16, -23);
  at('flick', X.chipPixel - 0.2, -20); at('shimmer', X.chipPixel + 0.12, -19);
  // the comment box: rises, PIXEL is typed key by key, Post is pressed
  at('whoosh', X.comment.t0, -24);
  const [k0, k1] = X.comment.typed; for (let i = 0; i < 5; i++) at('click', k0 + (k1 - k0) * i / 5, -22);
  at('click', X.comment.press, -14);
  // "Completely free.": the three zeros pulse
  for (let i = 0; i < 3; i++) at('pop_hi', X.statPulse + i * 0.13, -21);
}
```

### `example/music.mjs`

```js
// The bed's harmony on the story (engine/make-music.mjs synthesizes it): calm and curious for the setup, tense under the
// charge, a dark hit on GUILTY, warm major from "my response" on, a bright hit on NOT GUILTY, resolving home at the end.
export function chords(X) {
  return [
    [0, 'Fmaj7'], [4.4, 'G6'], [8.8, 'Am7'], [13.2, 'Em7'], [X.took - 0.3, 'Fmaj7'],
    [X.pans.toJudgeTrial, 'Dm9'], [X.LS(14) - 0.15, 'E'],
    [X.stampG, 'Am', { hit: true }], [X.LS(17) - 0.1, 'Fmaj7'],
    [X.LS(19) - 0.1, 'C'], [X.T(21, 'ready-to-use'), 'G'], [X.LS(22) - 0.1, 'Am7'], [X.LS(23) - 0.1, 'E'],
    [X.stampN, 'C', { hit: true }], [X.LS(26) - 0.1, 'F'], [X.LS(28), 'C'], [X.LS(29) + 0.6, 'G'], [X.LS(31) - 0.1, 'Am7'],
    [X.LS(32) - 0.1, 'F'], [X.LS(35) - 0.1, 'C'],
  ];
}
// [start of the dip, fully down, back up]: the bed sinks under each riser and returns with the verdict's boom
export function dips(X) {
  return [[X.stampG - 0.95, X.stampG - 0.2, X.stampG], [X.stampN - 0.9, X.stampN - 0.2, X.stampN]];
}
```

