---
name: cutout
description: Make a narrated vertical reel (1080x1920, 40-55 s) in the "Cutout" format - a calm "what if...?" explainer on grey graph paper (light and charcoal, on a camera that pans and pushes), monochrome halftone cut-out stickers with a white die-cut rim and a thick grey edge, a charcoal circle wipe, a chat on a phone that zooms out, a red stamp, a light beam, app tiles wired to a file, a dark price card, a wireframe file next to a yellow meter, a light bulb with a dimmer dial, a whip pan, a laptop, corner-bracket boxes that type, a line-art lock on navy, a bokeh space with big italic serif words, and one centred caption block typed word by word at the top, ending on white with highlighter pills. One ElevenLabs narrator, a synthesized groove that comes in after a dry hook, drawn frame by frame in headless Chrome and encoded with ffmpeg. Use it when someone asks for a cutout reel, "cutout.md", a graph-paper / halftone-sticker explainer, a "what if the biggest problem with X isn't..." reel, or wants to turn a script into this kind of video.
---

# Cutout reel: how to make one

You will make a **40-55 second vertical video (1080x1920, 30 fps, H.264 + AAC, at most 60 s and 60 MB)** that feels like **a calm, curious explainer on a designer's desk**: grey graph paper, black-and-white printed cut-outs, and one centred caption at the top that types itself on the voice. It opens on a question ("What if the biggest problem with X isn't A, but B?"), shows one fact, asks "are we A, or just B?", then shows the answer and ends on white with the key words on highlighter pills. **Every spoken claim gets its literal prop**, all drawn from scratch: no images, no footage, no real logos.

**`reference.jpg`** (next to this file, if you have it) is a contact sheet of 12 frames from a finished Cutout reel: **this is what it should look like.** Left to right, top row first: (1) light graph paper with + crosses, "What if the biggest problem" black at the top with the newest word grey, a blurred grey spark floating; (2) charcoal paper, the caption in white, a cream serif bubble "Write me a viral reel script." and the answer typing under it; (3) light paper, a dark phone (zoomed out, blurred) behind a halftone SKILL.md sticker with a white rim and a grey edge, a red rubber stamp "FREE" on it; (4) the sticker big on the left with a grey light beam to the right edge; (5) charcoal: four app tiles (orange bag, white star, white cart, black tag) wired with white cables to a small SKILL.md sticker, the near tiles blurred; (6) light: a wireframe "plexus" file next to a yellow meter labelled "Price"; (7) a glowing light bulb and a dimmer dial; (8) a laptop showing a misty site with a thin spaced name "MORPHY"; (9) the thin wordmark "LIBRARY" with an overline and corner-bracket boxes "kinetic.md", "halftone.md" with a cursor; (10) black: a white line-art padlock (shackle open) on a navy grid panel, a wire, "LIBRARY", an input pill "$0 / month"; (11) a dark bokeh space, "free" in huge italic serif behind the caption; (12) white, "Comment CUTOUT and" in the middle, CUTOUT on a lime highlighter pill. If you only have this file, section 3 describes everything with numbers.

Everything is drawn **one frame at a time** as HTML + 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), the grammar of the script and of the edit, the complete tested engine (Appendix A: write it once) and a complete example video (Appendix B). With the engine written, **a video is two files**: `script.mjs` (what the voice says and the captions show) and `scenes.js` (what each beat shows).

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

---

## 0. Rules of engagement (read first)

1. **Be gentle with the user's computer.** One headless browser at a time, at the lowest priority (`cdp.mjs` does this; on Windows it also pins Chrome to one CPU). Never run two renders at once.
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` is a dry run unless you pass `--yes`. One take per section (~1 credit per character, ~820 for a 52 s reel). Takes are cached: only a section whose words changed is made again.
4. **Stills before film.** Direct the scenes on fake timings (`build-timeline.mjs --fake`) 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 under your tool timeout (`--full --to 700`, then `--full --from 700`); the render resumes where it stopped.
6. **No real people, no real brands.** Everything is drawn: no screenshots, real app icons, logos, handles or view counts. A number on screen must be one the script says (or a plain counter the beat implies).
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 9671 and 9698 on 127.0.0.1. No npm packages.
- **An ElevenLabs API key** for the voice. 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 `--fake` timings and ask for the key.
- The fonts (Outfit, EB Garamond) are free downloads (`setup.mjs` fetches them). The 13 sound effects (`sounds.mjs`) and the music bed (`bed.mjs`, a 100 bpm groove made per video to its exact length) are synthesized: no licences, no credits.
- Optional: Python + `pip install faster-whisper` lets `check.mjs --whisper` compare the finished audio with the script.

## 2. Project layout and setup

```
cutout-reel/                     one project folder, many videos
  .env                           ELEVENLABS_API_KEY=...   (never print it)
  film.html                      the page: timeline.js -> engine -> scenes.js -> window.setT(t)
  engine/core.js cutout.js film.css markup.mjs   clock + graph paper + camera + captions + scenes + safe zone · every part · the look · script markup + voice
  setup.mjs sounds.mjs bed.mjs cdp.mjs gen-vo.mjs build-timeline.mjs render.mjs check.mjs whisper.py
  fonts/ sfx/                    made by setup.mjs
  assets/mark.webp               optional: your logo for the end card (it hides itself when missing)
  my-video/                      one folder per video (Appendix B is a complete one)
    script.mjs scenes.js         what the voice says · what each beat shows
    vo/ timeline.js(on) vo.wav bed.wav cues.json stills/ frames/ mix.wav video.mp4 cover.jpg   (made by the tools)
```

```bash
mkdir cutout-reel && cd cutout-reel
# write every file of Appendix A, then:
node setup.mjs                   # checks Node/ffmpeg/Chrome/key, downloads the fonts, makes sfx/
```

A video (every tool takes `--video <folder>`, a folder inside the project):

```bash
mkdir my-video                   # write my-video/script.mjs (section 4) and my-video/scenes.js (Appendix B shows both)
node build-timeline.mjs --video my-video --fake                          # guessed timings + a silent vo.wav: direct before you pay
node render.mjs --video my-video --stills --at 1,3.6,6.7,8.5,13.9,17.6,21.2,26.5,31.6,35.8,39.4,47.9 --safe --sheet sheet   # look at my-video/stills/sheet.jpg
node gen-vo.mjs --video my-video                                         # dry run: the text of every take + the character count
node gen-vo.mjs --video my-video --yes                                   # one take per section -> my-video/vo/
node build-timeline.mjs --video my-video                                 # real timings: prints every word with its time
node bed.mjs --video my-video                                            # the groove, cut to this length, dry under the hook
node render.mjs --video my-video --scan                                  # every visible text, every 0.25 s, against the safe zone
node render.mjs --video my-video --stills --at beats --safe --sheet sheet
node render.mjs --video my-video --full --to 700 && node render.mjs --video my-video --full --from 700
node render.mjs --video my-video --mix                                   # -14 LUFS; prints how far the bed sits under the voice
node render.mjs --video my-video --encode --out video.mp4 --cover 6.75  # my-video/video.mp4 + my-video/cover.jpg
node check.mjs --video my-video --whisper                                # specs, frame 0, 5 random frames with the safe box, words heard
```

## 3. The look, pinned down

### 3.1 The frame (1080x1920 px)

| Layer | Where | What |
|---|---|---|
| Background | everywhere, on a camera | **light graph paper** `#ececec` or **charcoal graph paper** `#363636` (a fine 1 px mesh every 8.375 px, a 2 px line every 134 px, an 18 px `+` on every crossing), **pure black** `#000` for the product act, **pure white** `#fdfdfd` for the ending; crossfaded 0.04-0.55 s. The paper sits on `F.cam(t) → { s, x, y }`: it pushes in, pans and whips with the art. |
| Captions | centred on x 510, 720 px wide, from y 384 (the ending: y 830) | one block = one clause, typed word by word (3.4) |
| The art | full-bleed, from y ~620 down | one subject per beat, centred near x 510, y 900-1300 |

**Safe zone (every platform covers the edges):** all text inside **x 60-960, y 250-1480**; captions inside x 100-920; nothing important in the top 250 px, the bottom 440 px or the right 120 px. `render.mjs --stills --safe` draws the zone and outlines any visible text outside it in red; `--scan` tests every text every 0.25 s. Art may run off the edges; text may not.

### 3.2 Colours

| Token | Hex | Used for |
|---|---|---|
| paper | `#ececec`, mesh `#e3e3e3`, lines `#d9d9d9`, crosses `#8f8f8f` | light beats |
| charcoal | `#363636`, mesh `#3d3d3d`, lines `#464646`, crosses `#8c8c8c` | the chat, the wired tiles |
| black / white | `#000` / `#fdfdfd` | the product act / the ending |
| ink | `#0e0e0e` on paper and white, `#f6f6f6` on charcoal and black; the newest caption word `#848484` / `#969696` | captions, sticker words |
| yellow | `#f4d23e` (glow `rgba(245,212,62,0.65)`) | the bulb's aura, the meter, a caret |
| red | `#e0352b` at 86-90 % | the rubber stamp |
| navy | `#0d1530`, glow `rgba(28,44,96,0.95)`, grid `rgba(120,150,255,0.12)` | the lock panel |
| pills | lime `#d4ee7e`, green `#b4dda2`, lavender `#aba7ec`, cyan `#36bccf`, yellow `#f4de62` | highlighter pills on key words |
| cards | `#23262c → #1b1d22`, button `#737d86` | the price card |
| chat | bubble `#f7f2e7`, answer `#ececec` | the chat |

### 3.3 Type

**Outfit** (variable, SIL OFL): 700 for captions, 800 for words printed on stickers, 250 for the thin spaced wordmarks (letter-spacing 0.14em), 400-600 for UI. **EB Garamond** (SIL OFL): 500 for the chat's user bubble, italic 500 for the big "zen" words, 600 for the end wordmark.

### 3.4 The captions (this format's headline)

- A **block** = one clause, **centred** on x 510 in a 720 px box, **Outfit 700 52 px**, line-height 1.3, from y 384. Markup `/` starts a new block.
- **Words appear on the voice**: each fades up from a 6 px blur and 9 px lower over 0.15 s, **grey** first, and turns **ink** (black on paper/white, white on charcoal/black) when the next word lands. Words are laid out where they will end, so a half-typed centred line sits left of centre and grows (the reference does the same).
- The block **holds until the next block starts**, then blurs out in 0.2 s. The last block clears at `F.cap.end` (for the end card).
- **Highlighter pills**: `*word*` puts a word on a rounded pill (10 px radius, 9 px padding) in the colour `F.cap.pill(token)` returns (`lime`, `green`, `lav`, `cyan`, `yellow`). The reference saves them for the ending.
- Colours follow the look per frame: during a 0.1-0.2 s crossfade a caption is briefly grey on grey; flip looks when the art behind the captions changes.
- **Hooks:** `F.cap.hide(token, section)` drops a word, `F.cap.pos(block)` → `{ top, cls }` moves a block (`top: 830` for the ending), `F.cap.top` is the default top, `F.cap.blocks` lists every block.

### 3.5 The parts (section 7.3 lists their options)

- **The cut-out sticker** (`K.sticker`): any grayscale painting (drawn on a canvas, lit from the top left) becomes a **45° halftone screen**: round ink dots on white paper whose size follows the painting's darkness (cell 4-6 px), inside the painting's own shape. Around it: a **13 px white die-cut rim** with a 1.8 px grey line, an **11 px thick extruded edge** (light grey near, dark grey far, down and to the right) and a soft drop shadow (`16px 26px 20px rgba(0,0,0,.30)`). Words on a sticker are painted **crisp on top of the dots** (`over`), or the screen eats them. Paintings in the kit: a document page with a folded corner and text lines, a shopping bag with one sheet of paper sticking out, a price tag, a chrome serif "?" (no halftone).
- **The circle wipe**: an ink blob streaks in from the left, then a charcoal circle with a soft edge rises from below the frame and covers it in ~0.6 s; the look flips to charcoal under it.
- **The chat on a phone**: a phone ~1180 × 2380 px whose screen fills the frame at scale 1 (its screen transparent, so the charcoal paper shows through), a cream serif user bubble on the right, an answer that types in Outfit 34 px; then the phone **zooms out** to half size (the screen fills in, the paper turns light) and blurs behind the next sticker.
- **The red stamp**: a 340 px ring (22 px red border) with "FREE" in Outfit 900, rotated −14°, slammed from 1.6× with an 8 px blur and a small camera shake.
- **The light beam**: a grey trapezoid (`clip-path`, gradient `rgba(48,48,48,.88) → .25`, 5 px blur) from the sticker to the frame's edge; the camera swings and it ends pointing up.
- **Wired app tiles**: rounded tiles (22 % radius) in orange `#e8613c`, white and black with line glyphs, white 8 px cables (SVG, `stroke-dashoffset`) drawing themselves to the hub; the near tiles blur as the camera pushes in (depth of field).
- **The price card**: dark card, a name, a big price that counts up, ticks, a grey pill button; it zooms from 0.82× to 1.42× (never past the safe zone).
- **The plexus file + meter**: a wireframe page (95 points, each tied to its 4 nearest, 1.8 px lines, dots 2-7 px) that slowly turns in 3D; a 40 px dark rounded meter with a yellow fill and a label above it, filling and dropping on the words.
- **Question card**: "Question" (Outfit 600 50 px grey) over a dark rounded card (`#3e3e3e`, 40 px radius) with a little yellow caret; the chrome "?" sticker pops into it.
- **Bulb + dial**: an SVG bulb (glass, filament, a ridged metal base) whose yellow aura and glass glow with `v`; a knurled knob in a ring of 36 ticks that turns with `v`.
- **The laptop**: a dark bezel and a base with a notch; its screen holds a misty site hero (thin spaced name with an overline, a line, a rounded input) that sharpens from a 9 px blur as the laptop grows.
- **Tile + sparkles, wordmark + bracket boxes**: a pale blue-grey tile with a thin ".md", three black four-point sparkles; a thin spaced wordmark (Outfit 250, 104 px) with an overline over part of it; boxes with a 2.5 px outline, thick corner brackets and mid ticks that open on their word and type a file name, a cursor following them.
- **The lock panel**: on black, a navy disc with a fading blue grid, a white line-art padlock (the shackle lifts and turns), a wire that draws itself and snaps (a red ×), and under it the wordmark and a rounded input pill that types.
- **The zen space**: blurred bokeh discs on near-black drifting slowly, a dim horizon glow, and big italic serif words (150-250 px) breathing in behind the captions.
- **The end card**: white, your mark (optional image) and a serif wordmark fading in once the last caption clears.

### 3.6 Motion

| Move | How |
|---|---|
| between beats | scenes fade/sharpen in over 0.14 s from a 14 px blur, out over 0.12 s; a slow push (+3 %) inside each |
| the camera | the graph paper follows `F.cam`: a push on the wired tiles (1 → 1.2), a pan when the beam swings, a whip (520 px in 0.3 s) on "or…", a push on the laptop |
| a sticker arrives | pops in (`back` easing) or flips in 3D (`rotateY` 88° → 0 in 0.4 s) |
| a whip pan | the old props slide out 1,100 px with a motion blur, the new one slides in from the other side |
| count-ups | the price and the meter count on their word with ticks |
| the turn | the look goes to black (0.55 s), the voice gets calmer, the bokeh breathes |
| the end | white, captions in the middle, pills, then the wordmark |

## 4. Writing for this format

The reference grammar (and the example's): **the hook "What if the biggest problem with X isn't A, but B?" → one fact ("Recently, I gave away…" / "A 2025 study found…") → the sting ("$30, for something I gave away for free.") → "Are we A, or just B?" → "So here's my response." + who you are + the product → what it does (2-3 beats) → "No…, no…, no…" → the moral → "Here's the best part." → the name → "Comment KEYWORD and I'll send it to you."**

- One clause per caption block (`/`); 130-150 words for 45-55 s. Calm and curious, never shouty.
- Markup: `/` a new block · `*word*` a highlighter pill · `{shown|said}` when the caption and the voice differ (`{$30.|thirty dollars.}`, `{cutout.md.|cutout dot M D.}`, `{format's|format is}` when the voice contracts).

## 5. The voice (ElevenLabs)

**Chris** (`iP95p4xoKVk53GoZ742B`, "Charming, Down-to-Earth"), `eleven_multilingual_v2`, stability 0.5, similarity 0.8, style 0.28, speaker boost, **speed 0.95**: a friendly American narrator (the reference: male, F0 ~110-145 Hz, ~170 wpm with the gaps). These are `engine/markup.mjs` DEFAULTS; a `script.mjs` may export `VOICE`, `MODEL`, `SETTINGS` or `SPEED` to override them. Chris reads at ~185 wpm whatever the speed: **calm it down with GAP (0.42-0.6 s between sections)**, not the speed. One take per section, word times from ElevenLabs' character alignment. Whisper will hear "$30" as "30", ".md" as "md" and names loosely: judge those by ear.

## 6. Timings

`build-timeline.mjs` lays the takes end to end (LEAD before the first, GAP before each section, HOLD after the last) and writes `timeline.js` (every caption token with its `t0`/`t1` on the film clock) and `vo.wav`. Markup-only edits (where `/` and `*` fall) need no new voice: it re-applies them to the takes. `--fake` guesses times so you can direct before the voice exists; every scene time comes from a word, so the real timeline moves every beat automatically. Run `bed.mjs` again after any timing change.

## 7. Directing

### 7.1 Times come from words

`T0('s2', 'selling')` is the start of the first token in section s2 whose text starts with "selling" (case and punctuation blind), `T1(…)` its end, `T0('s4', 'for', 1)` the second "for". `S.s3.start` / `S.s3.until` are a section's bounds; `END` is the film's length. The example uses `st = id => S[id].start - 0.06` as every beat's start.

### 7.2 scenes.js

```js
const { S, T0, T1, END, scene, cue, div, add, h, set, pop, rise, clamp, lerp, eo, eio, ease, back, rng, esc } = F;
const K = F.cut;
const st = id => S[id].start - 0.06;
const C = { full: true, din: 0.14, dout: 0.12, push: 0.03 };
F.looks([[-1, 'paper'], [T0('s0', 'skills'), 'char', 0.04], [st('s3'), 'paper', 0.12], [st('s9'), 'white', 0.25]]);  // paper char black white
F.cam = t => ({ s: 1 + 0.04 * t / END, x: 0, y: 0 });         // the graph paper's camera
F.cap.pos = p => p.s.id === 'cta' ? { top: 830 } : null;     // the ending's captions in the middle
F.cap.pill = tk => /^free/i.test(tk.d) ? 'green' : 'lime';
F.cap.end = T1('cta', 'free') + 0.5;
const DOC = K.sticker(K.paintDoc({ title: false, tone: 0.42 }), 380, 486, { ht: { cell: 5.6 }, over: K.inkText([['SKILL.md', 0.1, 0.19, 0.15]]) });
{
  const sc = scene(st('s1'), st('s2'), C);
  const doc = K.stick(sc.el, DOC, 540, 1060);
  sc.on(t => K.place(doc, 1, { s: 1 + 0.03 * Math.sin(t), r: -4 }));
  cue('pop', st('s1'), 0.08);
}
```

- A scene is shown from `tin` to `tout` (plus its short fade). Make each end where the next begins (`st(next)`).
- Draw everything per frame in `sc.on(t => …)`: `set(el, opacity, transform, blur)` styles an element for a frame (it replaces the whole transform), `K.place(el, opacity, { x, y, s, r, ry, blur })` does it for a sticker, `pop`, `rise`, `F.html(el, s)`, `F.typeIn(el, text, t, t0, t1)`. Easing: `eo`, `eio`, `ease`, `back`; `clamp`, `lerp`, `rng(seed)`.
- A canvas sticker can only be in one place: to show it twice, copy its canvas (`docCopy` in Appendix B).
- Sounds: `cue(name, time, gain[, length])` with `pop` (stickers, tiles, pills, 0.05-0.1), `swish` / `whoosh` (wipes, zooms, whip pans), `typing` (with a length) + `click` (typed text, boxes), `stamp` + `thump` (the stamp), `tick` (count-ups), `riser`, `implode`, `glitch`, `chime`, `beep`.

### 7.3 What the kit gives you (`K = F.cut`)

| Part | Options and use |
|---|---|
| `K.sticker(paint, w, h, { ht: { cell, gain, lift, angle } \| false, over, border, depth, dir, edge })` → `{ canvas, w, h, M }` | the cut-out sticker; `paint(g, w, h)` draws the grayscale art; `over(g, w, h)` paints crisp on top of the dots |
| `K.stick(p, stk, cx, cy, { shadow })` → element · `K.place(el, op, { x, y, s, r, ry, blur })` | put a sticker in a scene · draw it for a frame |
| `K.paintDoc({ title, tone, lines, seed })` · `K.paintBag({ label, price })` · `K.paintTag({ text })` · `K.paintQ` | paintings (`title: false`, `label: false`, `price: false` leave the words to `over`) |
| `K.inkText([[text, x, y, size, weight, colour, align, rot]…])` | crisp words for `over`; x, y and size are fractions of the sticker |
| `K.bulb(p, cx, cy, s)` → `{ el, draw(v) }` · `K.dial(p, cx, cy, s)` → `{ el, draw(v) }` | the bulb (0 off … 1 glowing) · the dimmer dial (0 … 1 turned) |
| `K.meter(p, x, y, h, label)` → `{ el, draw(v, text) }` · `K.plexus(p, cx, cy, w, h, seed)` → element | the meter (v 0 … 1, a value beside the fill) · the wireframe file |
| `K.phone(p, cx, cy, w, h)` → `{ el, screen }` | a phone; its screen is a div you fill |
| `K.laptop(p, cx, cy, w)` → `{ el, screen }` · `K.siteHero(screen, { name, sub, w })` | the laptop · a site hero for its screen |
| `K.tile(p, cx, cy, size, bg, glyph)` · `K.G.bag / cart / tag / star / md / spark4 / plus / mic / check (size, colour)` | app tiles and their line glyphs |
| `K.cables(p, [[x0, y0, x1, y1, bend]…])` → `{ el, draw(u or [u…]) }` | white cables that draw themselves (0 … 1) |
| `K.brk(p, cx, y, w, h)` → `{ el, span }` · `K.word(p, cx, y, text, size, colour)` → `{ el, fit() }` | a corner-bracket box (type into `span`) · a thin wordmark with an overline (call `fit()` once while it's visible) |
| `K.price(p, x, y, w, { name, price, per, items, button })` → element | the price card (`card.children[1].firstChild` is the price text, for a count-up) |
| `K.lockPanel(p, cx, cy, size)` → `{ el, draw({ open, wire, cut }) }` · `K.bokeh(p, seed)` → `{ draw(t) }` | the line-art lock on navy · the bokeh night |
| `F.cursor(p)` + `F.drive(cur, t, [[t, x, y]…], [clickT…])` · `F.spark(size, colour)` · `F.shake(t, amp, dur)` · `F.flash(t)` · `F.img(src, style)` | a cursor · a spark glyph · a camera shake · a white flash · an image that hides itself if missing |
| `div(p, cls, x, y, w, h, html, style)` | an absolutely placed element (`'k-abs'`, `'k-label'`, `'k-bub'`, `'k-reply'`, `'k-input'`) |

### 7.4 The edit follows the words

Every claim gets its literal picture at that moment: "Claude content skills" → the charcoal circle wipes in and a chat asks for a reel script; "isn't paying for them" → "Sorry, you'll need the $30 skill for that."; "given away for free" → the SKILL.md sticker flips in and the FREE stamp slams on "free"; "I gave away" → the beam; "completely free" → copies fly out along it; "repackaged" → tiles wire themselves to the file; "selling it for $30" → the price card counts up; "$30 … free" → the meter fills, then drops to $0; "a better skill" → the bulb brightens as the dial turns; "or just for a text file" → the whip pan to a bag with one sheet; "Morphy" → the laptop's site comes into focus; "library" → sparkles; "ready-to-use … .md files" → the boxes type file names; "no paywalls" → the shackle opens; "no subscriptions" → "$0 / month"; "no strings" → the wire snaps; "free" → the huge italic word; "Comment CUTOUT" → the lime pill.

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

- Nothing appears before its word, nor later than the word.
- One subject per beat; leave the top band to the captions.
- Keep it monochrome: colour only for the yellow light, the red stamp, the navy panel, the tiles' own colours and the pills.
- Every frame has its subject on screen (the scenes' fades overlap).

## 8. Stills and the safe zone

`render.mjs --stills --at <times | beats | spread:8> --safe --sheet sheet` writes `my-video/stills/<t>.jpg` and a contact sheet; `--safe` draws the zone and prints any visible text outside it. `--scan` tests every visible text every 0.25 s without screenshots. Look at the sheet every time: a caption wrapping into the art, sticker words eaten by the dots, a price card zoomed past the zone.

## 9. The full render

`render.mjs --full` writes `my-video/frames/f00000.jpg …` (~155 ms a frame on a laptop GPU: a 52 s film ≈ 4 minutes). It skips frames that exist, so split it (`--to 700`, `--from 700`) to stay under your tool timeout, and use `--force` after you change the scenes.

## 10. Sound

`bed.mjs` makes `my-video/bed.wav`: a 100 bpm groove (a soft kick on 1 and 3, a brushed clap on 2 and 4, quiet off-beat hats, a round bass on 1 and the "and" of 2, a warm low-passed pad over Am7 - Fmaj7 - Cmaj7 - G6, a little air), **silent under the hook**, fading in at the second section, lifting under the end card. `render.mjs --mix` mixes the voice, that bed and every `cue()`, then brings it to **−14 LUFS integrated, true peak ≤ −1 dBTP**. It prints how far the bed sits under the voice: aim for **~13 LU** (`--music 0.17` is the default).

## 11. Encode, check, deliver

`render.mjs --encode --out video.mp4 --cover 6.75` makes **H.264 High, yuv420p, 1080x1920, 30 fps, `-crf 20 -maxrate 10M -bufsize 20M`, AAC 192k, `+faststart`** (~16 MB for 52 s) and `cover.jpg` (the stamped sticker). `check.mjs --video my-video --whisper` checks the specs, that frame 0 isn't black, writes 5 random frames with the safe box (`my-video/check/frames.jpg`: look at it) and compares the words heard with the script. Deliver `video.mp4` and `cover.jpg`; posting is the user's call.

## 12. Checklist

- [ ] `setup.mjs` all ✓ · the script follows the grammar of section 4 · one clause per caption block
- [ ] fake-timing stills right before any credit is spent · `gen-vo.mjs` dry run read, then `--yes`
- [ ] `bed.mjs` run after the real timings · the hook is dry, the groove comes in after it
- [ ] every claim has its picture on its word · sticker words crisp (`over`) · looks flip where the art changes
- [ ] `--scan` and the safe stills clean · full render · mix at −14 LUFS, bed ~13 LU under the voice
- [ ] encode ≤ 60 s and ≤ 60 MB · `check.mjs` all ok · frames.jpg looked at · every word checked (Whisper or by ear)

---

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

Write every file below into the project folder at the path in its heading. They are the same for every video: never edit them per video.

### `film.html`

```html
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>cutout</title>
<style>
@font-face { font-family: 'Outfit'; src: url(fonts/Outfit.ttf) format('truetype'); font-weight: 100 900; }
@font-face { font-family: 'EB Garamond'; src: url(fonts/EBGaramond.ttf) format('truetype'); font-weight: 400 800; }
@font-face { font-family: 'EB Garamond'; src: url(fonts/EBGaramond-Italic.ttf) format('truetype'); font-weight: 400 800; font-style: italic; }
</style>
<link rel="stylesheet" href="engine/film.css">
</head>
<body>
<div id="film"><div id="stage"></div></div>
<script>
  // film.html?v=<video folder> (default: video). The engine is the same for every video; the folder brings
  // timeline.js (from build-timeline.mjs) and scenes.js (the scene list). Flags: &nocap (no captions), &safe (safe-zone overlay).
  window.VIDEO_DIR = (new URLSearchParams(location.search).get('v') || 'video').replace(/\\/g, '/').replace(/\/$/, '');
  for (const s of [VIDEO_DIR + '/timeline.js', 'engine/core.js', 'engine/cutout.js', VIDEO_DIR + '/scenes.js'])
    document.write(`<script src="${s}"><\/script>`);
</script>
</body>
</html>
```

### `engine/core.js`

```js
// Cutout template · ENGINE CORE (from Halftone). Same for every video: don't edit it while making one (a video's own scenes are
// <video>\scenes.js). One page, one clock: window.setT(t) draws film time t (s). Every beat is keyed to a spoken
// word from <video>\timeline.js via T0(section, word[, n]) / T1(...).
// Load order (film.html): <video>/timeline.js → core.js → cutout.js → <video>/scenes.js → F.start() on DOMContentLoaded.
(() => {
  const F = window.F = {};
  F.errors = [];
  addEventListener('error', e => F.errors.push(String(e.message || e.error || e)));
  const Q = new URLSearchParams(location.search);
  F.DIR = window.VIDEO_DIR || 'video';
  F.NOCAP = Q.has('nocap'); F.SAFE = Q.has('safe');
  const film = document.getElementById('film'), stage = document.getElementById('stage');
  F.film = film; F.stage = stage;

  // ---------- maths ----------
  const TAU = Math.PI * 2;
  const clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
  const lerp = (a, b, u) => a + (b - a) * u;
  const ease = x => { x = clamp(x); return x * x * (3 - 2 * x); };
  const eio = x => { x = clamp(x); return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; };
  const eo = x => 1 - Math.pow(1 - clamp(x), 3);
  const back = (x, c = 1.7) => { x = clamp(x); return 1 + (c + 1) * Math.pow(x - 1, 3) + c * Math.pow(x - 1, 2); };
  const px = v => v + 'px';
  const rng = seed => { let s = seed >>> 0; return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; };
  const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;');
  Object.assign(F, { TAU, clamp, lerp, ease, eio, eo, back, px, rng, esc });

  // ---------- DOM ----------
  const h = (tag, attrs = {}, html) => {
    const e = document.createElement(tag);
    for (const [k, v] of Object.entries(attrs)) {
      if (k === 'style' && typeof v === 'object') Object.assign(e.style, v);
      else if (k === 'class') e.className = v;
      else e.setAttribute(k, v);
    }
    if (html !== undefined) e.innerHTML = html;
    return e;
  };
  const add = (p, ...k) => { k.forEach(x => p.appendChild(x)); return k[0]; };
  const box = (e, x, y, w, hh) => { if (x != null) e.style.left = px(x); if (y != null) e.style.top = px(y); if (w != null) e.style.width = px(w); if (hh != null) e.style.height = px(hh); return e; };
  // div(parent, classes, x, y, w, h, innerHTML, style) — absolutely placed when classes include abs/gcard/lcard/pill/…
  const div = (p, cls, x, y, w, hh, html, style) => { const e = add(p, h('div', { class: cls }, html)); box(e, x, y, w, hh); if (style) Object.assign(e.style, style); return e; };
  // set(el, opacity, transform, blur) — the one way to draw an element's state for a frame
  const set = (el, op, tr, bl = 0) => {
    el.style.opacity = op.toFixed(3);
    el.style.visibility = op < 0.004 ? 'hidden' : 'visible';
    if (tr != null) el.style.transform = tr;
    el.style.filter = bl > 0.3 ? `blur(${bl.toFixed(1)}px)` : 'none';
    return op;
  };
  // pop: spring in from scale s0 with overshoot and blur; optional exit at t1
  const pop = (el, t, t0, o = {}) => {
    const { d = 0.42, s0 = 0.55, blur = 12, t1 = null, d1 = 0.22, x = 0, y = 0, rot = 0, dy = 0 } = o;
    const a = clamp((t - t0) / d);
    let op = clamp(a * 2.4), sc = s0 + (1 - s0) * back(a), bl = (1 - eo(a)) * blur, yy = y + (1 - eo(a)) * dy;
    if (t1 != null) { const z = eo((t - t1) / d1); op *= 1 - z; sc *= 1 - 0.15 * z; bl += z * blur; }
    return set(el, op, `translate(${x}px, ${yy.toFixed(1)}px) rotate(${rot}deg) scale(${sc.toFixed(4)})`, bl);
  };
  // rise: fade + slide up in, blur in; optional exit at t1
  const rise = (el, t, t0, o = {}) => {
    const { d = 0.34, dy = 40, blur = 10, t1 = null, d1 = 0.24, dy1 = -30, x = 0 } = o;
    const a = eo((t - t0) / d);
    let op = a, y = (1 - a) * dy, b = (1 - a) * blur;
    if (t1 != null) { const z = ease((t - t1) / d1); op *= 1 - z; y += z * dy1; b += z * blur; }
    return set(el, op, `translate(${x}px, ${y.toFixed(1)}px)`, b);
  };
  // typeIn: types `text` into el between t0 and t1 (with a caret while typing). Returns true when done.
  const typeIn = (el, text, t, t0, t1, caret = true) => {
    const ch = [...text], n = Math.round(clamp((t - t0) / Math.max(0.05, t1 - t0)) * ch.length);
    const html = esc(ch.slice(0, n).join('')) + (caret && t > t0 - 0.4 && n < ch.length ? '<span class="caret"></span>' : '');
    if (el.__h !== html) { el.innerHTML = html; el.__h = html; }
    return n >= ch.length;
  };
  const html = (el, s) => { if (el.__h !== s) { el.innerHTML = s; el.__h = s; } };
  const centerX = (el, cx) => { el.style.left = px(cx - el.offsetWidth / 2); };
  // <img> that hides itself if the file is missing (prep shots before the first --prep)
  const img = (src, style = '') => `<img src="${src}" style="${style}" onerror="this.style.visibility='hidden'">`;
  Object.assign(F, { h, add, box, div, set, pop, rise, typeIn, html, centerX, img });

  // ---------- glyphs ----------
  const spark = (s = 60, c = '#d97757') => {
    const L = [1, 0.8, 0.96, 0.78, 1, 0.84, 0.92, 0.8, 0.98, 0.83, 0.9];
    let d = ''; L.forEach((l, i) => { const a = (i / L.length) * TAU - Math.PI / 2; d += `<line x1="${(Math.cos(a) * 0.12).toFixed(3)}" y1="${(Math.sin(a) * 0.12).toFixed(3)}" x2="${(Math.cos(a) * l).toFixed(3)}" y2="${(Math.sin(a) * l).toFixed(3)}"/>`; });
    return `<svg viewBox="-1.12 -1.12 2.24 2.24" width="${s}" height="${s}" style="display:block"><g stroke="${c}" stroke-width="0.21" stroke-linecap="round">${d}</g></svg>`;
  };
  const claudeBadge = (s = 56) => `<div style="width:${s}px;height:${s}px;border-radius:${s * 0.3}px;background:#f6eee8;display:flex;align-items:center;justify-content:center;box-shadow:0 6px 16px rgba(0,0,0,0.25)">${spark(s * 0.66)}</div>`;
  const svg = (s, body, o = '') => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" ${o}>${body}</svg>`;
  const G = {
    CURSOR: '<svg viewBox="0 0 24 24" width="56" height="56"><path d="M4 2.5l15 9.2-6.6 1.3 3.9 7.4-2.9 1.5-3.9-7.4L4.8 19z" fill="#fff" stroke="#111" stroke-width="1.2" stroke-linejoin="round"/></svg>',
    XMARK: (s = 54) => svg(s, '<path d="M6 6l12 12M18 6L6 18"/>', 'fill="none" stroke="#fff" stroke-width="3.4" stroke-linecap="round"'),
    CHECK: (s = 54, c = '#fff') => svg(s, '<path d="M5 12.5l4.5 4.5L19 7.5"/>', `fill="none" stroke="${c}" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"`),
    SEND: (s = 44) => svg(s, '<path d="M12 19V5M5.5 11.5L12 5l6.5 6.5"/>', 'fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"'),
    MIC: (s = 44) => svg(s, '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21"/>', 'fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round"'),
    LOCK: (s = 28, c = '#a4a6aa') => svg(s, '<rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/>', `fill="none" stroke="${c}" stroke-width="2.2"`),
    HEART: (s = 40, c = '#fff') => svg(s, '<path d="M12 21s-7.5-4.6-9.3-9.2C1.4 8.5 3.5 5 7 5c2 0 3.6 1.2 5 3 1.4-1.8 3-3 5-3 3.5 0 5.6 3.5 4.3 6.8C19.5 16.4 12 21 12 21z"/>', `fill="${c}"`),
    PLAY: (s = 56, c = '#fff') => svg(s, '<path d="M7 4.5v15l12-7.5z"/>', `fill="${c}" style="margin-left:${Math.round(s * 0.14)}px"`),
    UPLOAD: (s = 54) => svg(s, '<path d="M12 16V5M7 9.5L12 5l5 4.5M5 19h14"/>', 'fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"'),
    COMMENT: (s = 38) => svg(s, '<path d="M4 18l1.4-4A8 8 0 1 1 9 19.2z"/>', 'fill="none" stroke="#fff" stroke-width="2"'),
    SHARE: (s = 36) => svg(s, '<path d="M21 4L3 11l7 3 3 7z"/>', 'fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"'),
    PERSON: c => `<svg viewBox="0 0 100 100" width="100%" height="100%"><circle cx="50" cy="40" r="17" fill="${c}"/><path d="M18 92c4-20 18-30 32-30s28 10 32 30z" fill="${c}"/></svg>`,
  };
  // app glyphs for P.appTile (white line icons, 24x24 viewBox)
  const GLYPH = {
    capcut: '<svg width="110" height="110" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4L8.1 15.9M14.5 14.5L20 20M8.1 8.1L12 12"/></svg>',
    premiere: '<svg width="110" height="110" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/></svg>',
    finalcut: '<svg width="110" height="110" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"><path d="M3 10h18v10H3z"/><path d="M3 10l2-5 16-1-1 5"/><path d="M8 5.5l3 4M13.5 5l3 4.2"/></svg>',
    chat: '<svg width="110" height="110" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"><path d="M4 5h16v11H9l-5 4z"/></svg>',
    mail: '<svg width="110" height="110" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3.5 6l8.5 7 8.5-7"/></svg>',
    doc: '<svg width="110" height="110" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"><path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4M9 12h6M9 16h6"/></svg>',
    sheet: '<svg width="110" height="110" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 10h18M3 15h18M9 4v16"/></svg>',
    camera: '<svg width="110" height="110" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"><rect x="3" y="7" width="13" height="11" rx="2"/><path d="M16 11l5-3v9l-5-3z"/></svg>',
    code: '<svg width="110" height="110" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5M13.5 4l-3 16"/></svg>',
  };
  Object.assign(F, { spark, claudeBadge, GLYPH }, G);
  // caption icon chips: TAKES markup @name:*word* → F.ICONS[name](). Add your own in scenes.js.
  F.ICONS = { claude: () => spark(44) };

  // ---------- timeline lookups ----------
  const TL = F.TL = window.TL || { total: 1, sections: [] };
  if (!window.TL) F.errors.push(`no ${F.DIR}/timeline.js: run node build-timeline.mjs --video ${F.DIR}`);
  const S = F.S = Object.fromEntries(TL.sections.map(s => [s.id, s]));
  const norm = s => s.toLowerCase().replace(/[^a-z0-9 ]/g, '');
  // K(section, word, n): the n-th token in that section whose shown text starts with `word` (case/punctuation-blind)
  F.K = (id, w, n = 0) => {
    if (!S[id]) throw new Error(`no section "${id}" in the timeline`);
    let c = 0;
    for (const t of S[id].tokens) if (norm(t.d).startsWith(norm(w))) { if (c === n) return t; c++; }
    throw new Error(`no token "${w}"${n ? ' #' + n : ''} in ${id}`);
  };
  F.T0 = (id, w, n) => F.K(id, w, n).t0;
  F.T1 = (id, w, n) => F.K(id, w, n).t1;
  F.END = TL.total;
  // sound cues → <video>/cues.json → render.mjs --mix.  cue(sfx name, time, gain, length?, fade-in?)
  F.CUES = [];
  F.cue = (f, t, g = 0.1, len, fin) => F.CUES.push([f, +t.toFixed(3), g, len ?? null, fin ?? null]);
  const updaters = [];
  F.on = f => updaters.push(f);

  // ---------- layout: where scenes and captions sit in the safe zone ----------
  // Scenes are authored on the 1080 canvas (content within x 40–1040, y 520–1480, centred on x 540);
  // the engine scales them by k around (540, 1000) and shifts them so they land inside x 60–960.
  const LAYOUTS = {
    top: { cap: 'top', k: 0.92, dx: -30, dy: -10 },      // captions from y 270 (this format's look), scenes in y ≈ 540–1445
    band: { cap: 'band', k: 0.86, dx: -30, dy: -300 },   // captions in the RULES caption band (y 1170+), scenes in y ≈ 270–1125
  };
  F.LAYOUT = LAYOUTS.top;
  F.layout = (mode, over = {}) => { F.LAYOUT = { ...(LAYOUTS[mode] || LAYOUTS.top), ...over }; };

  // ---------- looks: paper (light graph paper) / char (charcoal graph paper) / black / white, crossfaded by keys [t, look, fade] ----------
  // The graph paper is the reference's: a fine 8.4 px mesh, a line every 134 px and a + on every crossing. It sits on the
  // camera: F.cam = t => ({ s, x, y }) moves and scales it (scenes.js may read the same camera for its own layers).
  const MODES = ['paper', 'char', 'black', 'white'];
  let BG = [[-9, 'paper', 0.2]];
  F.looks = keys => {
    BG = keys.map(([t, m, d = 0.2]) => { if (!MODES.includes(m)) throw new Error('unknown look ' + m); return [t, m, d]; }).sort((a, b) => a[0] - b[0]);
    if (!BG.length || BG[0][0] > -1) BG.unshift([-9, BG.length ? BG[0][1] : 'paper', 0.2]);
  };
  const lookAt = F.lookAt = t => { let m = BG[0][1]; for (const [k, mm] of BG) if (t >= k) m = mm; return m; };
  const weights = F.lookWeights = t => {
    const w = { paper: 0, char: 0, black: 0, white: 0 };
    let i = 0; for (let j = 0; j < BG.length; j++) if (t >= BG[j][0]) i = j;
    const u = i === 0 ? 1 : ease((t - BG[i][0]) / BG[i][2]);
    w[BG[i][1]] += u; if (i > 0) w[BG[i - 1][1]] += 1 - u;
    return w;
  };
  F.cam = null;
  const PAD = 270, GW = 1080 + PAD * 2, GH = 1920 + PAD * 2;
  const graph = (bg, fine, major, cross) => {
    const c = document.createElement('canvas'); c.width = GW; c.height = GH; const g = c.getContext('2d');
    g.fillStyle = bg; g.fillRect(0, 0, GW, GH);
    const ox = (PAD + 540) % 134, oy = (PAD + 960) % 134;
    g.fillStyle = fine;
    for (let x = ox % 8.375; x < GW; x += 8.375) g.fillRect(Math.round(x), 0, 1, GH);
    for (let y = oy % 8.375; y < GH; y += 8.375) g.fillRect(0, Math.round(y), GW, 1);
    g.fillStyle = major;
    for (let x = ox; x < GW; x += 134) g.fillRect(Math.round(x) - 1, 0, 2, GH);
    for (let y = oy; y < GH; y += 134) g.fillRect(0, Math.round(y) - 1, GW, 2);
    g.fillStyle = cross;
    for (let x = ox; x < GW; x += 134) for (let y = oy; y < GH; y += 134) { g.fillRect(Math.round(x) - 9, Math.round(y) - 1.5, 18, 3); g.fillRect(Math.round(x) - 1.5, Math.round(y) - 9, 3, 18); }
    return c.toDataURL('image/png');
  };
  const BGL = {};
  const camRoot = add(stage, h('div', { class: 'layer' }));
  for (const m of MODES) BGL[m] = add(camRoot, h('div', { class: 'bg bg-' + m }));
  BGL.paper.style.backgroundImage = `url(${graph('#ececec', '#e3e3e3', '#d9d9d9', '#8f8f8f')})`;
  BGL.char.style.backgroundImage = `url(${graph('#363636', '#3d3d3d', '#464646', '#8c8c8c')})`;
  const grain = add(stage, h('div', { class: 'grain' }));
  {
    const c = document.createElement('canvas'); c.width = c.height = 256; const g = c.getContext('2d'), d = g.createImageData(256, 256), r = rng(5);
    for (let i = 0; i < d.data.length; i += 4) { const v = 128 + (r() - 0.5) * 220; d.data[i] = d.data[i + 1] = d.data[i + 2] = v; d.data[i + 3] = 255; }
    g.putImageData(d, 0, 0); grain.style.backgroundImage = `url(${c.toDataURL()})`;
  }
  const drawLook = t => {
    const w = weights(t);
    for (const m in BGL) { BGL[m].style.opacity = w[m].toFixed(3); BGL[m].style.display = w[m] < 0.002 ? 'none' : 'block'; }
    const c = F.cam ? F.cam(t) : null;
    camRoot.style.transform = c ? `translate(${(c.x || 0).toFixed(1)}px, ${(c.y || 0).toFixed(1)}px) scale(${(c.s || 1).toFixed(4)})` : '';
    const k = Math.floor(t * 24), r = rng(k * 7 + 3);
    grain.style.backgroundPosition = `${Math.floor(r() * 64)}px ${Math.floor(r() * 64)}px`;
    grain.style.opacity = (0.03 + 0.03 * (w.char + w.black)).toFixed(3);
  };

  // ---------- scenes: one container each; blur-dissolve in and out, slow push, fitted into the safe zone ----------
  const scenesRoot = add(stage, h('div', { class: 'layer' }));
  const scenes = [];
  // scene(tin, tout, { zoom: exit by zooming into the camera, full: no safe-zone fit (full-bleed art only; its text must still sit in the zone) })
  F.scene = (tin, tout, o = {}) => {
    const el = add(scenesRoot, h('div', { class: 'scene' }));
    const sc = { el, tin, tout, o, upd: [] };
    sc.on = f => sc.upd.push(f);
    scenes.push(sc); return sc;
  };
  const drawScene = (sc, t) => {
    const { el, tin, tout, o } = sc;
    if (t < tin - 0.02 || t > tout + (o.zoom ? 0.45 : (o.dout ?? 0.24) + 0.06)) { if (el.style.display !== 'none') el.style.display = 'none'; return false; }
    el.style.display = 'block';
    const a = tin < 0 ? 1 : eo((t - tin) / (o.din ?? 0.28)), z = clamp((t - tout) / (o.zoom ? 0.4 : (o.dout ?? 0.24)));
    const push = 1 + (o.push ?? 0.028) * clamp((t - tin) / Math.max(1, tout - tin));
    let s = (0.94 + 0.06 * a) * push, op = a, bl = (1 - a) * 14;
    if (o.zoom) { const zz = eio(z); s *= 1 + 6 * zz; op *= 1 - clamp((z - 0.35) / 0.65); bl += zz * 10; }
    else { const zz = eo(z); s *= 1 + 0.06 * zz; op *= 1 - zz; bl += zz * 14; }
    const L = o.full ? { k: 1, dx: 0, dy: 0 } : F.LAYOUT;
    set(el, op, `translate(${L.dx}px, ${L.dy}px) scale(${(L.k * s).toFixed(4)})`, bl);
    return true;
  };

  // ---------- cursor: keys [[t, x, y], …], clicks [t, …] ----------
  F.cursor = p => ({ c: add(p, h('div', { class: 'cursor' }, G.CURSOR)), r: add(p, h('div', { class: 'ripple' })) });
  F.drive = (cur, t, keys, clicks = []) => {
    const t0 = keys[0][0] - 0.25, t1 = keys.at(-1)[0] + 0.5;
    if (t < t0 || t > t1) { cur.c.style.opacity = 0; cur.r.style.opacity = 0; return; }
    let x = keys[0][1], y = keys[0][2];
    for (let i = 1; i < keys.length; i++) if (t >= keys[i - 1][0]) { const u = eio((t - keys[i - 1][0]) / (keys[i][0] - keys[i - 1][0])); x = lerp(keys[i - 1][1], keys[i][1], u); y = lerp(keys[i - 1][2], keys[i][2], u); }
    const op = clamp((t - t0) / 0.2) * (1 - clamp((t - (t1 - 0.25)) / 0.25));
    let sc = 1; for (const c of clicks) { const d = t - c; if (d > -0.08 && d < 0.14) sc = 0.84; }
    cur.c.style.opacity = op.toFixed(3); cur.c.style.transform = `translate(${(x - 10).toFixed(1)}px, ${(y - 6).toFixed(1)}px) scale(${sc})`;
    let rop = 0, rs = 1;
    for (const c of clicks) { const u = (t - c) / 0.5; if (u >= 0 && u <= 1) { rop = 1 - u; rs = 0.3 + 1.3 * eo(u); } }
    cur.r.style.opacity = rop.toFixed(3); cur.r.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) scale(${rs.toFixed(3)})`;
  };

  // ---------- whole-frame effects ----------
  const flashEl = add(stage, h('div', { class: 'flash' }));
  const flashes = [], shakes = [];
  F.flash = (t, peak = 0.75) => flashes.push([t, peak]);              // camera-flash white-out at t
  F.shake = (t, amp = 16, dur = 0.35) => shakes.push([t, amp, dur]);  // camera shake starting at t
  const drawFx = t => {
    let fl = 0; for (const [t0, pk] of flashes) fl = Math.max(fl, pk * clamp((t - t0) / 0.05) * (1 - clamp((t - t0 - 0.05) / 0.3)));
    flashEl.style.opacity = fl.toFixed(3);
    let tr = '';
    for (const [t0, amp, dur] of shakes) { const u = (t - t0) / dur; if (u >= 0 && u <= 1) { const a = amp * (1 - u); tr = `translate(${(Math.sin(u * 47) * a).toFixed(1)}px, ${(Math.cos(u * 61) * a * 0.7).toFixed(1)}px)`; } }
    stage.style.transform = tr;
  };

  // ---------- captions (Cutout): a block = one clause, centred on x 510, typed word by word on the voice. The newest word
  // fades up grey and turns ink (black on paper/white, white on char/black) when the next word lands; the block clears just
  // before the next block. *key* words sit on a highlighter pill (F.cap.pill(token) → lime | green | lav | cyan | yellow).
  // F.cap.hide(token, section) drops a word · F.cap.pos(block) → { top, cls } · F.cap.end = t clears the last block at t
  F.cap = { hide: null, pos: null, pill: null, blocks: [], top: 384, end: null };
  const capRoot = add(stage, h('div', { class: 'over' }));
  const phrases = F.cap.blocks;
  const PILL = { lime: '#d4ee7e', green: '#b4dda2', lav: '#aba7ec', cyan: '#36bccf', yellow: '#f4de62' };
  const buildCaptions = () => {
    if (F.NOCAP) return;
    for (const s of TL.sections) { let cur = null; s.tokens.forEach(tk => { if (tk.br || !cur) { cur = { s, toks: [] }; phrases.push(cur); } cur.toks.push(tk); }); }
    phrases.forEach((p, i) => {
      const nx = phrases[i + 1]; p.t0 = p.toks[0].t0;
      p.t1 = nx ? nx.toks[0].t0 - 0.1 : (F.cap.end ?? TL.total + 1);
      p.el = add(capRoot, h('div', { class: 'cap' }));
      const o = F.cap.pos && F.cap.pos(p);
      p.el.style.top = px(o && o.top != null ? o.top : F.cap.top);
      if (o && o.cls) p.el.classList.add(...o.cls.split(' '));
      p.w = p.toks.filter(tk => !(F.cap.hide && F.cap.hide(tk, p.s))).map(tk => {
        const sp = add(p.el, h('span', { class: 'w' + (tk.key ? ' k' : '') }, esc(tk.d)));
        if (tk.key) sp.style.setProperty('--pill', PILL[(F.cap.pill && F.cap.pill(tk)) || 'lime'] || PILL.lime);
        p.el.appendChild(document.createTextNode(' '));
        return { tk, sp };
      });
      p.w.forEach((w, j) => { w.tn = j + 1 < p.w.length ? p.w[j + 1].tk.t0 : w.tk.t1 + 0.1; });
      p.hidden = !p.w.length;
    });
  };
  const mixc = (a, b, u) => `rgb(${Math.round(a[0] + (b[0] - a[0]) * u)},${Math.round(a[1] + (b[1] - a[1]) * u)},${Math.round(a[2] + (b[2] - a[2]) * u)})`;
  const drawCaptions = t => {
    const w = weights(t), dk = clamp(w.char + w.black);
    const ink = [lerp(14, 246, dk), lerp(14, 246, dk), lerp(14, 246, dk)], grey = [lerp(132, 150, dk), lerp(132, 150, dk), lerp(132, 150, dk)];
    for (const p of phrases) {
      if (p.hidden || t < p.t0 - 0.1 || t >= p.t1 + 0.2) { if (p.el.style.display !== 'none') p.el.style.display = 'none'; continue; }
      p.el.style.display = 'block';
      const out = eo((t - p.t1) / 0.2);
      p.el.style.opacity = (1 - out).toFixed(3);
      p.el.style.filter = out > 0.02 ? `blur(${(out * 7).toFixed(1)}px)` : 'none';
      for (const x of p.w) {
        const a = x.tk.t0 < 0.3 ? 1 : eo((t - (x.tk.t0 - 0.05)) / 0.15), g = eo((t - x.tn) / 0.12);
        x.sp.style.opacity = a.toFixed(3);
        x.sp.style.transform = a < 0.999 ? `translateY(${((1 - a) * 9).toFixed(1)}px)` : '';
        x.sp.style.filter = a < 0.98 ? `blur(${((1 - a) * 6).toFixed(1)}px)` : 'none';
        x.sp.style.color = x.tk.key ? mixc([60, 60, 60], [12, 12, 12], g) : mixc(grey, ink, g);
      }
    }
  };

  // ---------- safe zone (RULES.md rule 7): overlay + a report of every visible text outside it ----------
  const ZONE = { x0: 60, x1: 960, y0: 250, y1: 1480, cx0: 100, cx1: 920, band0: 1150 };
  F.ZONE = ZONE;
  let safeEl = null;
  const buildSafe = () => {
    safeEl = add(film, h('div', { id: 'safe' }));
    const r = (cls, x, y, w, hh, txt) => div(safeEl, cls, x, y, w, hh, txt);
    r('no', 0, 0, 1080, 250); r('no', 0, 1480, 1080, 440); r('no', 960, 250, 120, 1230); r('no', 0, 250, 60, 1230);
    r('zone', 60, 250, 900, 1230); r('band', 60, 1150, 900, 330); r('mid', 509, 250, 2, 1230);
    r('lbl', 60, 214, null, null, 'text zone x 60–960 · y 250–1480'); r('lbl', 60, 1486, null, null, 'caption band 1150–1480 (yellow)');
  };
  const visibleOp = el => {
    let op = 1;
    for (let e = el; e && e !== document.body; e = e.parentElement) {
      const cs = getComputedStyle(e);
      if (cs.display === 'none' || cs.visibility === 'hidden') return 0;
      op *= +cs.opacity;
    }
    return op;
  };
  // F.safeReport(mark): [{ text, box:[x0,y0,x1,y1], cap }] for visible text outside the zone (captions: outside x 100–920).
  F.safeReport = (mark = false) => {
    const bad = [], tw = document.createTreeWalker(stage, NodeFilter.SHOW_TEXT);
    for (let n; (n = tw.nextNode());) {
      if (!n.nodeValue.trim()) continue;
      const el = n.parentElement, op = visibleOp(el);
      if (op < 0.2) continue;
      const cap = !!el.closest('.cap');
      const rg = document.createRange(); rg.selectNodeContents(n);
      let x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9;
      for (const b of rg.getClientRects()) { if (b.width < 1 || b.height < 1) continue; x0 = Math.min(x0, b.left); y0 = Math.min(y0, b.top); x1 = Math.max(x1, b.right); y1 = Math.max(y1, b.bottom); }
      if (x0 > x1) continue;
      if (x1 < 0 || y1 < 0 || x0 > 1080 || y0 > 1920) continue; // fully off-screen
      const L = cap ? ZONE.cx0 : ZONE.x0, R = cap ? ZONE.cx1 : ZONE.x1, tol = 2;
      if (x0 < L - tol || x1 > R + tol || y0 < ZONE.y0 - tol || y1 > ZONE.y1 + tol) bad.push({ text: n.nodeValue.trim().slice(0, 40), box: [x0, y0, x1, y1].map(v => Math.round(v)), cap });
    }
    if (mark && safeEl) {
      safeEl.querySelectorAll('.bad').forEach(e => e.remove());
      for (const b of bad) div(safeEl, 'bad', b.box[0] - 4, b.box[1] - 4, b.box[2] - b.box[0] + 8, b.box[3] - b.box[1] + 8);
    }
    return bad;
  };

  // ---------- prep shots: frames of this very film that the film shows (a phone showing it, storyboard panels) ----------
  // F.prep([{ name: 'self', at: 1.75 }, { name: 'sb', at: [1.9, 5.1, …], nocap: true, width: 432 }]) → render.mjs --prep
  // writes <video>/prep/self.jpg and <video>/prep/sb/01.jpg …; use them with F.prepSrc('self') / F.prepSrc('sb', 1).
  F.PREP = [];
  F.prep = list => { F.PREP = list; };
  F.prepSrc = (name, i) => `${F.DIR}/prep/${name}${i != null ? '/' + String(i).padStart(2, '0') : ''}.jpg`;

  // ---------- start ----------
  F.start = async () => {
    if (Q.get('layout')) F.layout(Q.get('layout'));   // preview another layout without editing scenes.js (render.mjs --layout band)
    buildCaptions();
    if (F.SAFE) buildSafe();
    film.classList.toggle('cap-band', F.LAYOUT.cap === 'band');
    window.TOTAL = TL.total;
    window.BEATS = scenes.map(s => [Math.max(0, s.tin), Math.min(TL.total, s.tout)]);
    window.CUES = F.CUES;
    window.LOOKS = BG;
    window.PREP = F.PREP;
    window.setT = async t => {
      drawFx(t);
      drawLook(t);
      for (const s of scenes) if (drawScene(s, t)) for (const u of s.upd) u(t);
      for (const u of updaters) await u(t);
      drawCaptions(t);
      await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
      return true;
    };
    await document.fonts.ready;
    await Promise.all([...document.images].map(i => i.decode().catch(() => { })));
    await window.setT(0);
    return true;
  };
  window.ready = new Promise(res => addEventListener('DOMContentLoaded', () => F.start().then(res, e => { F.errors.push('start: ' + (e && e.stack || e)); res(false); })));
})();
```

### `engine/cutout.js`

```js
// Cutout template · PARTS (window.F.cut, use it as K in scenes.js). Everything is drawn from scratch, nothing is a photo:
// halftone cut-out stickers come from a grayscale painting turned into a 45° dot screen on white paper, with a white die-cut
// rim, a thick grey extruded edge and a soft drop shadow (K.sticker); the props (bulb, dial, laptop, phone, cards, tiles,
// bracket boxes, plexus doc, meter, line-art lock, bokeh) are CSS / SVG / canvas.
(() => {
  const F = window.F, { h, add, div, set, clamp, lerp, eo, eio, ease, back, rng, esc } = F;
  const K = F.cut = {};
  const TAU = Math.PI * 2;
  const canvas = (w, hh) => { const c = document.createElement('canvas'); c.width = Math.ceil(w); c.height = Math.ceil(hh); return c; };
  K.canvas = canvas;
  const tint = (src, col) => { const c = canvas(src.width, src.height), g = c.getContext('2d'); g.drawImage(src, 0, 0); g.globalCompositeOperation = 'source-in'; g.fillStyle = col; g.fillRect(0, 0, c.width, c.height); return c; };
  // grow a shape by r px (stamping its silhouette round a circle)
  const dilate = (src, r, col) => {
    const c = canvas(src.width, src.height), g = c.getContext('2d'), m = tint(src, col);
    for (const rr of [r, r * 0.7, r * 0.4]) for (let i = 0; i < 28; i++) { const a = i / 28 * TAU; g.drawImage(m, Math.cos(a) * rr, Math.sin(a) * rr); }
    g.drawImage(m, 0, 0); return c;
  };
  K.dilate = dilate; K.tint = tint;

  // ---------- halftone: the painting's darkness as round dots on white paper, inside the painting's own shape ----------
  // o: cell (dot pitch, px) · ink · paper · gain (contrast) · lift (darkness ignored below it) · angle (screen, deg)
  K.halftone = (src, o = {}) => {
    const { cell = 7, ink = '#111111', paper = '#fbfbf8', gain = 1.15, lift = 0.05, angle = 45 } = o;
    const w = src.width, hh = src.height, d = src.getContext('2d').getImageData(0, 0, w, hh).data;
    const c = canvas(w, hh), g = c.getContext('2d');
    g.drawImage(tint(src, paper), 0, 0);
    g.globalCompositeOperation = 'source-atop'; g.fillStyle = ink;
    const a = angle * Math.PI / 180, ux = Math.cos(a), uy = Math.sin(a), R = Math.hypot(w, hh) / 2 + cell, cx = w / 2, cy = hh / 2;
    g.beginPath();
    for (let v = -R; v <= R; v += cell) for (let u = -R; u <= R; u += cell) {
      const x = cx + u * ux - v * uy, y = cy + u * uy + v * ux;
      if (x < 0 || y < 0 || x >= w || y >= hh) continue;
      const i = ((y | 0) * w + (x | 0)) * 4;
      if (d[i + 3] < 110) continue;
      const lum = (0.3 * d[i] + 0.59 * d[i + 1] + 0.11 * d[i + 2]) / 255;
      const k = clamp((1 - lum - lift) * gain / (1 - lift));
      const r = cell * 0.5 * Math.sqrt(k) * 1.3;
      if (r < 0.45) continue;
      g.moveTo(x + r, y); g.arc(x, y, r, 0, TAU);
    }
    g.fill();
    return c;
  };

  // ---------- the cut-out sticker ----------
  // K.sticker(paint(g, w, h), w, h, { ht: {halftone opts} | false, over: paint drawn crisp on top, border, depth, dir: [dx, dy], edge: [near, far] })
  //   → { canvas, w, h, M }  (M = the margin round the art; the art's (0,0) is at (M, M) in the canvas)
  K.sticker = (paint, w, hh, o = {}) => {
    const B = o.border ?? 13, D = o.depth ?? 11, [dx, dy] = o.dir ?? [0.45, 1], M = Math.ceil(B + D + 8);
    const art = canvas(w, hh); paint(art.getContext('2d'), w, hh);
    const face = o.ht === false ? art : K.halftone(art, o.ht || {});
    if (o.over) o.over(face.getContext('2d'), w, hh);   // crisp ink on top of the dots (words stay readable)
    const pad = canvas(w + 2 * M, hh + 2 * M); pad.getContext('2d').drawImage(art, M, M);
    const rim = dilate(pad, B, '#ffffff'), line = dilate(pad, B + 1.8, o.line ?? '#a9a9a9');
    const c = canvas(w + 2 * M, hh + 2 * M), g = c.getContext('2d');
    const near = tint(line, (o.edge || [])[0] ?? '#cfcfcf'), far = tint(line, (o.edge || [])[1] ?? '#7d7d7d');
    for (let k = D; k >= 1; k--) g.drawImage(k > D * 0.45 ? far : near, k * dx, k * dy);
    g.drawImage(line, 0, 0); g.drawImage(rim, 0, 0); g.drawImage(face, M, M);
    return { canvas: c, w: c.width, h: c.height, M };
  };
  // place a sticker: a wrapper (move / scale / blur it with F.set or K.place) holding the shadowed canvas
  K.stick = (p, stk, cx, cy, o = {}) => {
    const el = add(p, h('div', { class: 'k-sticker' }));
    el.style.left = (cx - stk.w / 2) + 'px'; el.style.top = (cy - stk.h / 2) + 'px'; el.style.width = stk.w + 'px'; el.style.height = stk.h + 'px';
    const c = stk.canvas.cloneNode ? stk.canvas : stk.canvas;
    c.className = 'k-stk'; if (o.shadow === false) c.style.filter = 'none'; else if (o.shadow) c.style.filter = o.shadow;
    el.appendChild(c);
    return el;
  };
  // K.place(el, op, { x, y, s, r, ry, blur }) — one call per frame
  K.place = (el, op, o = {}) => set(el, op, `translate(${(o.x || 0).toFixed(1)}px, ${(o.y || 0).toFixed(1)}px) perspective(1400px) rotateY(${(o.ry || 0).toFixed(2)}deg) rotate(${(o.r || 0).toFixed(2)}deg) scale(${(o.s ?? 1).toFixed(4)})`, o.blur || 0);

  // ---------- paintings (grayscale, lit from the top left) ----------
  const rr = (g, x, y, w, hh, r) => { g.beginPath(); g.moveTo(x + r, y); g.arcTo(x + w, y, x + w, y + hh, r); g.arcTo(x + w, y + hh, x, y + hh, r); g.arcTo(x, y + hh, x, y, r); g.arcTo(x, y, x + w, y, r); g.closePath(); };
  K.rr = rr;
  // a document page with a folded corner, a bold title and text lines (o.title, o.lines, o.seed, o.tone 0 light … 1 dark)
  K.paintDoc = (o = {}) => (g, w, hh) => {
    const f = w * 0.24, r = w * 0.05, tone = o.tone ?? 0.5;
    g.save();
    g.beginPath(); g.moveTo(r, 0); g.lineTo(w - f, 0); g.lineTo(w, f); g.lineTo(w, hh - r); g.arcTo(w, hh, w - r, hh, r); g.lineTo(r, hh); g.arcTo(0, hh, 0, hh - r, r); g.lineTo(0, r); g.arcTo(0, 0, r, 0, r); g.closePath();
    const L = g.createLinearGradient(0, 0, w, hh); L.addColorStop(0, `hsl(0,0%,${lerp(96, 80, tone)}%)`); L.addColorStop(0.55, `hsl(0,0%,${lerp(80, 58, tone)}%)`); L.addColorStop(1, `hsl(0,0%,${lerp(55, 32, tone)}%)`);
    g.fillStyle = L; g.fill();
    g.clip();
    const rg = g.createRadialGradient(w * 0.2, hh * 0.15, 10, w * 0.2, hh * 0.15, w * 0.9); rg.addColorStop(0, 'rgba(255,255,255,0.55)'); rg.addColorStop(1, 'rgba(255,255,255,0)'); g.fillStyle = rg; g.fillRect(0, 0, w, hh);
    // text
    if (o.title !== false) { g.fillStyle = '#121212'; g.font = `800 ${Math.round(w * (o.titleSize ?? 0.135))}px Outfit, sans-serif`; g.textBaseline = 'alphabetic'; g.fillText(o.title ?? 'SKILL.md', w * 0.1, hh * 0.2); }
    g.fillStyle = 'rgba(20,20,20,0.85)'; const R = rng(o.seed ?? 3), n = o.lines ?? 7;
    for (let i = 0; i < n; i++) { const y = hh * 0.3 + i * hh * 0.085, ww = w * (i % 3 === 0 ? 0.36 : 0.5 + R() * 0.3); rr(g, w * 0.1, y, ww, hh * 0.032, hh * 0.016); g.fill(); if (i % 3 === 0) { g.fillRect(w * 0.1 - 2, y - 2, 0, 0); } }
    g.restore();
    // the fold
    g.beginPath(); g.moveTo(w - f, 0); g.lineTo(w - f, f * 0.82); g.quadraticCurveTo(w - f, f, w - f * 0.82, f); g.lineTo(w, f); g.closePath();
    const FL = g.createLinearGradient(w - f, 0, w, f); FL.addColorStop(0, '#f4f4f4'); FL.addColorStop(1, '#6a6a6a'); g.fillStyle = FL; g.fill();
  };
  // crisp words for a sticker (o.over): [[text, x, y, size (all × w), weight, colour, align, rotate], …]
  K.inkText = list => (g, w, hh) => { for (const [t, x, y, sz, wt = 800, c = '#0f0f0f', al = 'left', rot = 0] of list) { g.save(); g.translate(x * w, y * hh); g.rotate(rot); g.fillStyle = c; g.font = `${wt} ${Math.round(sz * w)}px Outfit, sans-serif`; g.textAlign = al; g.textBaseline = 'alphabetic'; g.fillText(t, 0, 0); g.restore(); } };
  // a price tag ($30): angled left end with a hole
  K.paintTag = (o = {}) => (g, w, hh) => {
    g.beginPath(); g.moveTo(w * 0.24, 0); g.lineTo(w - 14, 0); g.quadraticCurveTo(w, 0, w, 14); g.lineTo(w, hh - 14); g.quadraticCurveTo(w, hh, w - 14, hh); g.lineTo(w * 0.24, hh); g.lineTo(0, hh / 2); g.closePath();
    const L = g.createLinearGradient(0, 0, w, hh); L.addColorStop(0, '#dcdcdc'); L.addColorStop(1, '#8a8a8a'); g.fillStyle = L; g.fill();
    g.globalCompositeOperation = 'destination-out'; g.beginPath(); g.arc(w * 0.17, hh / 2, hh * 0.1, 0, TAU); g.fill(); g.globalCompositeOperation = 'source-over';
    g.fillStyle = '#0f0f0f'; g.font = `800 ${Math.round(hh * 0.5)}px Outfit, sans-serif`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(o.text ?? '$30', w * 0.62, hh * 0.53);
  };
  // a shopping bag with one sheet of paper sticking out of it (o.label on the sheet)
  K.paintBag = (o = {}) => (g, w, hh) => {
    // the sheet
    g.save(); g.translate(w * 0.52, hh * 0.36); g.rotate(-0.12);
    const sw = w * 0.42, sh = hh * 0.5; g.fillStyle = '#e9e9e9'; rr(g, -sw / 2, -sh, sw, sh * 1.2, 8); g.fill();
    if (o.label !== false) { g.fillStyle = '#151515'; g.font = `800 ${Math.round(sw * 0.2)}px Outfit, sans-serif`; g.textAlign = 'center'; g.fillText(o.label ?? '.txt', 0, -sh * 0.62); }
    g.fillStyle = 'rgba(20,20,20,0.75)'; for (let i = 0; i < 3; i++) { rr(g, -sw * 0.32, -sh * 0.45 + i * sh * 0.13, sw * (i === 2 ? 0.4 : 0.64), sh * 0.05, 4); g.fill(); }
    g.restore();
    // handles
    g.strokeStyle = '#2a2a2a'; g.lineWidth = w * 0.035; g.lineCap = 'round';
    for (const x of [0.33, 0.67]) { g.beginPath(); g.moveTo(w * (x - 0.09), hh * 0.44); g.bezierCurveTo(w * (x - 0.09), hh * 0.24, w * (x + 0.09), hh * 0.24, w * (x + 0.09), hh * 0.44); g.stroke(); }
    // the bag
    g.beginPath(); g.moveTo(w * 0.1, hh * 0.4); g.lineTo(w * 0.9, hh * 0.4); g.lineTo(w * 0.97, hh); g.lineTo(w * 0.03, hh); g.closePath();
    const L = g.createLinearGradient(0, hh * 0.4, w, hh); L.addColorStop(0, '#cfcfcf'); L.addColorStop(0.6, '#8d8d8d'); L.addColorStop(1, '#4d4d4d'); g.fillStyle = L; g.fill();
    g.fillStyle = 'rgba(0,0,0,0.28)'; g.fillRect(w * 0.1, hh * 0.4, w * 0.8, hh * 0.05);
    if (o.price !== false) { g.fillStyle = '#121212'; g.font = `800 ${Math.round(w * 0.11)}px Outfit, sans-serif`; g.textAlign = 'center'; g.fillText(o.price ?? '$30', w * 0.5, hh * 0.78); }
  };
  // a big serif question mark in brushed chrome (draw it without halftone: { ht: false })
  K.paintQ = (g, w, hh) => {
    g.font = `800 ${Math.round(hh * 0.98)}px 'EB Garamond', Georgia, serif`; g.textAlign = 'center'; g.textBaseline = 'alphabetic';
    const L = g.createLinearGradient(0, 0, w, hh); L.addColorStop(0, '#f4f4f4'); L.addColorStop(0.45, '#9c9c9c'); L.addColorStop(0.7, '#d0d0d0'); L.addColorStop(1, '#4c4c4c');
    g.lineJoin = 'round'; g.lineWidth = hh * 0.05; g.strokeStyle = '#111'; g.strokeText('?', w / 2, hh * 0.86);
    g.fillStyle = L; g.fillText('?', w / 2, hh * 0.86);
  };

  // ---------- plain parts ----------
  // a light bulb that glows with v (0 off … 1 on) — glass, filament, screw base, a soft yellow aura
  K.bulb = (p, cx, cy, s = 1) => {
    const el = add(p, h('div', { class: 'k-abs' })); el.style.left = (cx - 150 * s) + 'px'; el.style.top = (cy - 230 * s) + 'px'; el.style.width = 300 * s + 'px'; el.style.height = 460 * s + 'px';
    const aura = add(el, h('div', { class: 'k-abs' })); Object.assign(aura.style, { left: '-55%', top: '-38%', width: '210%', height: '150%', borderRadius: '50%', background: 'radial-gradient(closest-side, rgba(255,226,92,0.9), rgba(255,214,60,0.35) 45%, rgba(255,214,60,0) 75%)', filter: 'blur(6px)' });
    el.insertAdjacentHTML('beforeend', `<svg viewBox="0 0 300 460" width="${300 * s}" height="${460 * s}" style="position:absolute;left:0;top:0;overflow:visible">
      <defs>
        <radialGradient id="kbOff" cx="38%" cy="32%" r="70%"><stop offset="0" stop-color="#ffffff" stop-opacity="0.95"/><stop offset="0.5" stop-color="#e2e2dc" stop-opacity="0.85"/><stop offset="1" stop-color="#a9a9a2" stop-opacity="0.9"/></radialGradient>
        <radialGradient id="kbOn" cx="45%" cy="40%" r="65%"><stop offset="0" stop-color="#fffbe6"/><stop offset="0.45" stop-color="#ffe680"/><stop offset="1" stop-color="#e2b52a"/></radialGradient>
        <linearGradient id="kbMetal" x1="0" x2="1"><stop offset="0" stop-color="#6d6d6d"/><stop offset="0.35" stop-color="#e9e9e9"/><stop offset="0.6" stop-color="#9a9a9a"/><stop offset="1" stop-color="#4a4a4a"/></linearGradient>
      </defs>
      <path id="kbGlass" d="M150 14 C72 14 30 72 30 140 C30 198 66 228 86 268 C96 290 98 306 98 322 L202 322 C202 306 204 290 214 268 C234 228 270 198 270 140 C270 72 228 14 150 14 Z" fill="url(#kbOff)" stroke="rgba(60,60,50,0.35)" stroke-width="3"/>
      <path class="on" d="M150 14 C72 14 30 72 30 140 C30 198 66 228 86 268 C96 290 98 306 98 322 L202 322 C202 306 204 290 214 268 C234 228 270 198 270 140 C270 72 228 14 150 14 Z" fill="url(#kbOn)" opacity="0"/>
      <path d="M128 320 L128 230 L116 160 M172 320 L172 230 L184 160" stroke="#6b6250" stroke-width="3" fill="none"/>
      <path class="fil" d="M116 160 C124 140 132 180 140 160 C148 140 156 180 164 160 C172 140 178 178 184 160" stroke="#7a5a2a" stroke-width="4" fill="none"/>
      <ellipse cx="108" cy="92" rx="22" ry="40" fill="#fff" opacity="0.55" transform="rotate(24 108 92)"/>
      <rect x="96" y="322" width="108" height="78" rx="10" fill="url(#kbMetal)"/>
      ${[0, 1, 2, 3].map(i => `<path d="M96 ${336 + i * 16} L204 ${330 + i * 16}" stroke="#3d3d3d" stroke-width="5" opacity="0.6"/>`).join('')}
      <path d="M112 400 L188 400 L170 432 L130 432 Z" fill="#2c2c2c"/>
    </svg>`);
    const on = el.querySelector('.on'), fil = el.querySelector('.fil');
    return { el, draw(v) { v = clamp(v); aura.style.opacity = (v * v).toFixed(3); aura.style.transform = `scale(${(0.7 + 0.35 * v).toFixed(3)})`; on.setAttribute('opacity', (v * 0.92).toFixed(3)); fil.setAttribute('stroke', v > 0.15 ? '#fff4c4' : '#7a5a2a'); } };
  };
  // a dimmer dial: a knurled knob inside a ring of ticks; draw(v) turns it (0 … 1)
  K.dial = (p, cx, cy, s = 1) => {
    const el = add(p, h('div', { class: 'k-abs' })); el.style.left = (cx - 110 * s) + 'px'; el.style.top = (cy - 110 * s) + 'px';
    const ticks = Array.from({ length: 36 }, (_, i) => { const a = (-135 + i * 270 / 35) * Math.PI / 180, r0 = 86, r1 = i % 5 ? 98 : 104; return `<line x1="${110 + Math.sin(a) * r0}" y1="${110 - Math.cos(a) * r0}" x2="${110 + Math.sin(a) * r1}" y2="${110 - Math.cos(a) * r1}"/>`; }).join('');
    el.innerHTML = `<svg width="${220 * s}" height="${220 * s}" viewBox="0 0 220 220" style="overflow:visible"><defs><radialGradient id="kdK" cx="40%" cy="35%" r="70%"><stop offset="0" stop-color="#bdbdbd"/><stop offset="0.6" stop-color="#6e6e6e"/><stop offset="1" stop-color="#2d2d2d"/></radialGradient></defs>
      <g stroke="#222" stroke-width="4" stroke-linecap="round">${ticks}</g>
      <circle cx="110" cy="114" r="70" fill="#1a1a1a" opacity="0.35" filter="blur(4px)"/>
      <g class="knob"><circle cx="110" cy="110" r="68" fill="url(#kdK)" stroke="#1d1d1d" stroke-width="3"/>
      <circle cx="110" cy="110" r="60" fill="none" stroke="rgba(255,255,255,0.18)" stroke-width="2" stroke-dasharray="2 5"/>
      <circle cx="110" cy="62" r="9" fill="#efefef" stroke="#333" stroke-width="2"/></g></svg>`;
    const knob = el.querySelector('.knob');
    return { el, draw(v) { knob.setAttribute('transform', `rotate(${(-120 + 240 * clamp(v)).toFixed(1)} 110 110)`); } };
  };
  // a vertical meter (yellow fill in a dark rounded track) with a label above and a value beside the fill
  K.meter = (p, x, y, hh, label) => {
    const el = add(p, h('div', { class: 'k-abs' })); el.style.left = x + 'px'; el.style.top = y + 'px';
    const lab = add(el, h('div', { class: 'k-label' }, esc(label))); lab.style.left = '-60px'; lab.style.top = '-66px'; lab.style.width = '160px'; lab.style.textAlign = 'center';
    const tr = add(el, h('div', { class: 'k-abs' })); Object.assign(tr.style, { left: '0px', top: '0px', width: '40px', height: hh + 'px', borderRadius: '20px', background: '#3a3a3a', border: '5px solid #262626', boxShadow: '0 10px 24px rgba(0,0,0,0.25)' });
    const fill = add(tr, h('div', { class: 'k-abs' })); Object.assign(fill.style, { left: '3px', right: '3px', bottom: '3px', borderRadius: '14px', background: 'linear-gradient(90deg, #e9c12e, #fbe36a 45%, #e3b81f)', boxShadow: '0 0 18px rgba(245,212,62,0.65)' });
    const val = add(el, h('div', { class: 'k-label' })); Object.assign(val.style, { left: '64px', fontSize: '44px', fontWeight: 700, color: '#2a2a2a' });
    return { el, draw(v, text) { v = clamp(v); fill.style.height = Math.max(0, (hh - 16) * v).toFixed(1) + 'px'; val.style.top = (hh - 16 - (hh - 16) * v - 6).toFixed(1) + 'px'; F.html(val, esc(text ?? '')); } };
  };
  // a wireframe "plexus" document: points inside a page outline, each tied to its nearest neighbours
  K.plexus = (p, cx, cy, w, hh, seed = 7) => {
    const c = canvas(w + 40, hh + 40), g = c.getContext('2d'), R = rng(seed), pts = [], f = w * 0.24;
    const inside = (x, y) => x > 0 && y > 0 && x < w && y < hh && !(x > w - f && y < f - (w - x) * 0) && !(x - (w - f) > y);
    const edge = [[0, 0], [w - f, 0], [w, f], [w, hh], [0, hh]];
    for (let i = 0; i < 5; i++) { const [a, b] = [edge[i], edge[(i + 1) % 5]]; const n = 7; for (let k = 0; k < n; k++) pts.push([a[0] + (b[0] - a[0]) * k / n, a[1] + (b[1] - a[1]) * k / n, 1]); }
    while (pts.length < 95) { const x = R() * w, y = R() * hh; if (inside(x, y)) pts.push([x, y, 0]); }
    g.translate(20, 20); g.lineCap = 'round';
    const sh = g.createRadialGradient(w / 2, hh / 2, 10, w / 2, hh / 2, w * 0.8); sh.addColorStop(0, 'rgba(0,0,0,0.10)'); sh.addColorStop(1, 'rgba(0,0,0,0)'); g.fillStyle = sh; g.fillRect(-20, -20, w + 40, hh + 40);
    for (const a of pts) {
      const near = pts.map(b => [Math.hypot(a[0] - b[0], a[1] - b[1]), b]).sort((u, v) => u[0] - v[0]).slice(1, 5);
      for (const [d, b] of near) { g.strokeStyle = `rgba(18,18,18,${(0.75 - d / w).toFixed(2)})`; g.lineWidth = 1.8; g.beginPath(); g.moveTo(a[0], a[1]); g.lineTo(b[0], b[1]); g.stroke(); }
    }
    for (const a of pts) { const r = a[2] ? 3.2 : 2 + R() * 5; g.fillStyle = '#111'; g.beginPath(); g.arc(a[0], a[1], r, 0, TAU); g.fill(); if (r > 5.5) { g.fillStyle = 'rgba(0,0,0,0.18)'; g.beginPath(); g.arc(a[0], a[1], r * 2.4, 0, TAU); g.fill(); } }
    // the folded corner, in line art
    g.strokeStyle = '#111'; g.lineWidth = 2.6; g.beginPath(); g.moveTo(w - f, 0); g.lineTo(w - f, f); g.lineTo(w, f); g.stroke();
    c.className = 'k-abs'; c.style.left = (cx - w / 2 - 20) + 'px'; c.style.top = (cy - hh / 2 - 20) + 'px';
    return add(p, c);
  };
  // a laptop (dark bezel + base) whose screen is a div to fill → { el, screen }
  K.laptop = (p, cx, cy, w) => {
    const hh = w * 0.64, el = add(p, h('div', { class: 'k-abs' }));
    Object.assign(el.style, { left: (cx - w / 2) + 'px', top: (cy - hh / 2) + 'px', width: w + 'px', height: hh + 'px', filter: 'drop-shadow(0 30px 34px rgba(0,0,0,0.35))' });
    const lid = add(el, h('div', { class: 'k-abs' })); Object.assign(lid.style, { left: '0px', top: '0px', width: w + 'px', height: (hh * 0.9) + 'px', borderRadius: (w * 0.028) + 'px', background: '#0d0d0f', border: `${Math.round(w * 0.012)}px solid #232327`, overflow: 'hidden' });
    const screen = add(lid, h('div', { class: 'k-abs' })); Object.assign(screen.style, { left: (w * 0.012) + 'px', top: (w * 0.012) + 'px', right: (w * 0.012) + 'px', bottom: (w * 0.012) + 'px', overflow: 'hidden', borderRadius: '4px' });
    const base = add(el, h('div', { class: 'k-abs' })); Object.assign(base.style, { left: (-w * 0.07) + 'px', top: (hh * 0.9 - 2) + 'px', width: (w * 1.14) + 'px', height: (hh * 0.06) + 'px', borderRadius: `0 0 ${w * 0.05}px ${w * 0.05}px`, background: 'linear-gradient(180deg, #3b3b40, #18181b 60%, #0b0b0c)' });
    const notch = add(base, h('div', { class: 'k-abs' })); Object.assign(notch.style, { left: (w * 0.57 - w * 0.08) + 'px', top: '0px', width: (w * 0.16) + 'px', height: (hh * 0.022) + 'px', borderRadius: '0 0 10px 10px', background: '#0a0a0b' });
    return { el, screen };
  };
  // a product site hero for a laptop screen: misty dark sky, a thin spaced-caps name with an overline, a line, an input
  K.siteHero = (screen, o = {}) => {
    screen.style.background = 'radial-gradient(70% 55% at 50% 30%, #6b7078 0%, #3d4148 45%, #1b1d21 100%)';
    const w = screen.offsetWidth || o.w || 800;
    screen.insertAdjacentHTML('beforeend', `
      <div style="position:absolute;left:0;right:0;top:${w * 0.07}px;text-align:center;font:200 ${w * 0.15}px/1 Outfit,sans-serif;letter-spacing:.12em;color:#f2f2f2">${esc(o.name ?? 'MORPHY')}</div>
      <div style="position:absolute;left:${w * 0.53}px;top:${w * 0.055}px;width:${w * 0.25}px;height:${Math.max(2, w * 0.006)}px;background:#f2f2f2"></div>
      <div style="position:absolute;left:0;right:0;top:${w * 0.27}px;text-align:center;font:400 ${w * 0.045}px/1.15 Outfit,sans-serif;color:#eaeaea">${o.sub ?? 'An agentic super app.'}</div>
      <div style="position:absolute;left:${w * 0.27}px;width:${w * 0.46}px;top:${w * 0.42}px;height:${w * 0.055}px;border-radius:999px;background:rgba(255,255,255,0.14);border:1px solid rgba(255,255,255,0.25)"><div style="position:absolute;right:${w * 0.01}px;top:${w * 0.0085}px;width:${w * 0.038}px;height:${w * 0.038}px;border-radius:50%;background:#f2f2f2"></div></div>
      <div style="position:absolute;left:${w * 0.47}px;top:${w * 0.505}px;width:${w * 0.06}px;height:${w * 0.1}px;border-radius:${w * 0.02}px ${w * 0.02}px 0 0;background:rgba(10,10,12,0.55)"></div>`);
  };
  // a rounded app tile with an svg / html glyph
  K.tile = (p, cx, cy, size, bg, glyph) => {
    const el = add(p, h('div', { class: 'k-tile' }, glyph));
    Object.assign(el.style, { left: (cx - size / 2) + 'px', top: (cy - size / 2) + 'px', width: size + 'px', height: size + 'px', background: bg });
    return el;
  };
  K.G = {
    bag: (s, c = '#fff') => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2.2" stroke-linejoin="round"><path d="M5 8h14l-1.2 12H6.2z"/><path d="M9 10V6.5a3 3 0 0 1 6 0V10"/></svg>`,
    cart: (s, c = '#3a5bdc') => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4h2.5l2.2 10.5h10.6L20.5 7H7"/><circle cx="9.5" cy="19" r="1.5" fill="${c}"/><circle cx="17" cy="19" r="1.5" fill="${c}"/></svg>`,
    tag: (s, c = '#fff') => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2.2" stroke-linejoin="round"><path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="8.5" r="1.6" fill="${c}"/></svg>`,
    star: s => `<svg width="${s}" height="${s}" viewBox="0 0 24 24"><defs><linearGradient id="kgS" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f0473a"/><stop offset="0.35" stop-color="#f7b43a"/><stop offset="0.65" stop-color="#3db46d"/><stop offset="1" stop-color="#3a7bf0"/></linearGradient></defs><path d="M12 1.5C12.6 7 17 11.4 22.5 12 17 12.6 12.6 17 12 22.5 11.4 17 7 12.6 1.5 12 7 11.4 11.4 7 12 1.5z" fill="url(#kgS)"/></svg>`,
    md: s => `<div style="font:250 ${s * 0.5}px/1 Outfit,sans-serif;color:#1d2530;letter-spacing:-.02em">.md</div>`,
    spark4: (s, c = '#111') => `<svg width="${s}" height="${s}" viewBox="0 0 24 24"><path d="M12 0C12.7 7.5 16.5 11.3 24 12 16.5 12.7 12.7 16.5 12 24 11.3 16.5 7.5 12.7 0 12 7.5 11.3 11.3 7.5 12 0z" fill="${c}"/></svg>`,
    plus: (s, c) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" stroke="${c}" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>`,
    mic: (s, c) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round"><rect x="9" y="3" width="6" height="11" rx="3" fill="${c}"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21"/></svg>`,
    check: (s, c = '#cfcfcf') => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>`,
  };
  // white cables from points to a hub: K.cables(p, [[x0,y0,x1,y1,bend], …]) → { draw(u 0…1) }
  K.cables = (p, list, o = {}) => {
    const sv = add(p, h('div', { class: 'k-abs' })); sv.style.left = '0px'; sv.style.top = '0px';
    sv.innerHTML = `<svg width="1080" height="1920" viewBox="0 0 1080 1920" style="overflow:visible">${list.map(([x0, y0, x1, y1, b = 120]) => {
      const mx = (x0 + x1) / 2, my = (y0 + y1) / 2, nx = -(y1 - y0), ny = x1 - x0, L = Math.hypot(nx, ny) || 1;
      return `<path d="M${x0} ${y0} C ${x0 + (x1 - x0) * 0.1 + nx / L * b} ${y0 + (y1 - y0) * 0.1 + ny / L * b}, ${mx - nx / L * b} ${my - ny / L * b}, ${x1} ${y1}" fill="none" stroke="${o.color ?? '#f3f3f3'}" stroke-width="${o.width ?? 8}" stroke-linecap="round"/>`;
    }).join('')}</svg>`;
    const paths = [...sv.querySelectorAll('path')]; const lens = paths.map(q => q.getTotalLength ? q.getTotalLength() : 1000);
    paths.forEach((q, i) => { q.style.strokeDasharray = lens[i]; q.style.strokeDashoffset = lens[i]; });
    return { el: sv, draw(u, i) { paths.forEach((q, k) => { if (i == null || i === k) q.style.strokeDashoffset = (lens[k] * (1 - clamp(Array.isArray(u) ? u[k] : u))).toFixed(1); }); } };
  };
  // a corner-bracket text box (the reference's "Understanding your context…"): typing text + its width animates
  K.brk = (p, cx, y, w, hh) => {
    const el = add(p, h('div', { class: 'k-brk' }, '<i class="a"></i><i class="b"></i><i class="c"></i><i class="d"></i><b class="t"></b><b class="u"></b><span></span>'));
    Object.assign(el.style, { left: (cx - w / 2) + 'px', top: y + 'px', width: w + 'px', height: hh + 'px' });
    return { el, span: el.querySelector('span') };
  };
  // a dark price card (product name, big price, ticks, a button)
  K.price = (p, x, y, w, o = {}) => {
    const el = add(p, h('div', { class: 'k-card' }));
    Object.assign(el.style, { left: x + 'px', top: y + 'px', width: w + 'px', padding: `${w * 0.07}px ${w * 0.075}px` });
    const items = (o.items ?? ['SKILL.md', 'One content format', 'Instant download', '"Exclusive" pack']).map(s => `<div style="display:flex;align-items:center;gap:${w * 0.03}px;font:400 ${w * 0.052}px/1.6 Outfit,sans-serif;color:#d9d9d9">${K.G.check(w * 0.06)}<span>${esc(s)}</span></div>`).join('');
    el.innerHTML = `<div style="display:flex;justify-content:space-between;align-items:center"><div style="font:600 ${w * 0.062}px/1 Outfit,sans-serif">${esc(o.name ?? 'Viral Reels Skill')}</div><div class="k-ic" style="width:${w * 0.13}px;height:${w * 0.13}px;border-radius:50%;border:2px solid rgba(255,255,255,0.35);display:flex;align-items:center;justify-content:center">${K.G.tag(w * 0.07)}</div></div>
      <div style="margin-top:${w * 0.05}px;font:600 ${w * 0.15}px/1 Outfit,sans-serif;letter-spacing:-.01em">${esc(o.price ?? '$30.00')}<span style="font:400 ${w * 0.05}px/1 Outfit,sans-serif;margin-left:${w * 0.02}px;color:#cfcfcf">${esc(o.per ?? '/ one-time')}</span></div>
      <div style="height:1px;background:rgba(255,255,255,0.12);margin:${w * 0.05}px 0"></div>${items}
      <div class="k-btn" style="height:${w * 0.13}px;margin-top:${w * 0.06}px;font-size:${w * 0.052}px">${esc(o.button ?? 'Buy now')}</div>`;
    return el;
  };
  // a phone (rounded dark bezel) whose screen is a div → { el, screen }  (centre cx, cy; size w × hh)
  K.phone = (p, cx, cy, w, hh) => {
    const el = add(p, h('div', { class: 'k-abs' }));
    Object.assign(el.style, { left: (cx - w / 2) + 'px', top: (cy - hh / 2) + 'px', width: w + 'px', height: hh + 'px', borderRadius: (w * 0.16) + 'px', background: '#26262c', boxShadow: '0 40px 70px rgba(0,0,0,0.35), inset 0 0 0 3px #4a4a52' });
    const screen = add(el, h('div', { class: 'k-abs' }));
    Object.assign(screen.style, { left: (w * 0.035) + 'px', top: (w * 0.035) + 'px', right: (w * 0.035) + 'px', bottom: (w * 0.035) + 'px', borderRadius: (w * 0.13) + 'px', background: '#363636', overflow: 'hidden' });
    return { el, screen };
  };
  // the line-art lock on a navy grid panel (paywall): draw({ open 0…1, cut 0…1, wire 0…1, t })
  K.lockPanel = (p, cx, cy, size) => {
    const el = add(p, h('div', { class: 'k-abs' })); Object.assign(el.style, { left: (cx - size / 2) + 'px', top: (cy - size / 2) + 'px', width: size + 'px', height: size + 'px' });
    el.innerHTML = `<div style="position:absolute;inset:0;background:radial-gradient(closest-side, rgba(28,44,96,0.95), rgba(10,16,38,0.92) 70%, rgba(0,0,0,0) 100%);"></div>
      <div style="position:absolute;inset:6%;background-image:linear-gradient(rgba(120,150,255,0.12) 1.5px, transparent 1.5px),linear-gradient(90deg, rgba(120,150,255,0.12) 1.5px, transparent 1.5px);background-size:${size / 12}px ${size / 12}px;-webkit-mask-image:radial-gradient(closest-side, #000 55%, transparent 100%)"></div>
      <svg viewBox="0 0 200 200" width="${size}" height="${size}" style="position:absolute;left:0;top:0;overflow:visible" fill="none" stroke="#f4f4f4" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round">
        <path class="wire" d="M-30 120 C 0 120 6 70 30 78 C 52 86 40 136 62 132 C 74 130 70 112 74 104"/>
        <g class="shackle"><path d="M78 96 V72 a22 22 0 0 1 44 0 V96"/></g>
        <rect x="64" y="96" width="72" height="62" rx="10" fill="#0d1530"/>
        <circle cx="100" cy="122" r="7"/><path d="M100 129 v12"/>
        ${Array.from({ length: 7 }, (_, i) => `<path d="M${70 + i * 10} 104 v3" stroke-width="2" opacity="0.6"/>`).join('')}
        <g class="cutx" opacity="0"><path d="M58 170 l14 -14 M58 156 l14 14" stroke="#ff5a4e" stroke-width="4"/></g>
      </svg>`;
    const shackle = el.querySelector('.shackle'), wire = el.querySelector('.wire'), cutx = el.querySelector('.cutx');
    const L = wire.getTotalLength ? wire.getTotalLength() : 260; wire.style.strokeDasharray = `${L}`;
    return { el, draw(o) { shackle.setAttribute('transform', `translate(0 ${(-16 * clamp(o.open || 0)).toFixed(2)}) rotate(${(-22 * clamp(o.open || 0)).toFixed(2)} 122 96)`); wire.style.strokeDashoffset = (L * (1 - clamp(o.wire ?? 1))).toFixed(1); wire.setAttribute('opacity', (1 - clamp(o.cut || 0)).toFixed(3)); cutx.setAttribute('opacity', (Math.min(1, (o.cut || 0) * 3) * (1 - clamp(((o.cut || 0) - 0.6) / 0.4))).toFixed(3)); } };
  };
  // a blurred bokeh night (zen space): soft discs on near-black, slowly drifting → draw(t)
  K.bokeh = (p, seed = 9) => {
    const c = canvas(1300, 2200), g = c.getContext('2d'), R = rng(seed);
    const bg = g.createLinearGradient(0, 0, 0, 2200); bg.addColorStop(0, '#08090b'); bg.addColorStop(0.55, '#16181c'); bg.addColorStop(1, '#0a0b0d'); g.fillStyle = bg; g.fillRect(0, 0, 1300, 2200);
    for (let i = 0; i < 26; i++) { const x = R() * 1300, y = R() * 2200, r = 40 + R() * 170, a = 0.05 + R() * 0.12, tint = R() < 0.3 ? '120,150,160' : '200,200,205'; const rg = g.createRadialGradient(x, y, 0, x, y, r); rg.addColorStop(0, `rgba(${tint},${a})`); rg.addColorStop(0.7, `rgba(${tint},${a * 0.8})`); rg.addColorStop(1, `rgba(${tint},0)`); g.fillStyle = rg; g.beginPath(); g.arc(x, y, r, 0, TAU); g.fill(); }
    // a dim horizon of light
    const hz = g.createRadialGradient(650, 1500, 50, 650, 1500, 900); hz.addColorStop(0, 'rgba(150,155,165,0.22)'); hz.addColorStop(1, 'rgba(150,155,165,0)'); g.fillStyle = hz; g.fillRect(0, 900, 1300, 1200);
    c.className = 'k-abs'; c.style.left = '-110px'; c.style.top = '-140px'; c.style.filter = 'blur(10px)';
    add(p, c);
    return { el: c, draw(t) { c.style.transform = `translate(${(Math.sin(t * 0.21) * 30).toFixed(1)}px, ${(Math.cos(t * 0.17) * 24).toFixed(1)}px) scale(${(1.04 + 0.02 * Math.sin(t * 0.3)).toFixed(4)})`; } };
  };
  // a thin, widely spaced wordmark with an overline over part of it (the reference's product name)
  K.word = (p, cx, y, text, size, color = '#1a1a1a', over = [0.5, 0.82]) => {
    const el = add(p, h('div', { class: 'k-word' }, esc(text))); Object.assign(el.style, { fontSize: size + 'px', color, top: y + 'px', left: (cx - 540) + 'px', width: '1080px', textAlign: 'center', letterSpacing: '0.14em' });
    const bar = add(el, h('div', { class: 'k-abs' }));
    return { el, bar, fit() { const r = document.createRange(); r.selectNodeContents(el.firstChild); const b = r.getBoundingClientRect(), e = el.getBoundingClientRect(); const x0 = b.left - e.left, w = b.width; Object.assign(bar.style, { left: (x0 + w * over[0]) + 'px', width: (w * (over[1] - over[0])) + 'px', top: (-size * 0.1) + 'px', height: Math.max(2, size * 0.035) + 'px', background: color }); } };
  };
})();
```

### `engine/film.css`

```css
/* Cutout template · STYLE. Motion's base rules first, then the CUTOUT look at the bottom, which wins.
   Fonts (fonts/, loaded in film.html): Outfit (captions, UI, thin wordmarks) · EB Garamond (serif bubble, big italic words). */
:root {
  --blue: #3446f0; --mid: #7d89f7; --lav: #c3caff; --red: #f0384b; --ink: #f3f3f1;
  --claude: #d97757; --mono: 'Cascadia Mono', 'Cascadia Code', Consolas, monospace;
  --serif: 'Sitka Banner', 'Sitka Display', Georgia, serif;
  --cond: Bahnschrift, 'Inter Tight', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 1080px; height: 1920px; overflow: hidden; background: #0c0c0e; }
body { font-family: 'Inter Tight', system-ui, sans-serif; color: var(--ink); -webkit-font-smoothing: antialiased; }
#film { position: relative; width: 1080px; height: 1920px; overflow: hidden; }
#stage { position: absolute; inset: 0; }
.layer, .scene, .over { position: absolute; inset: 0; }
/* a scene is authored on the 1080x1920 canvas around (540, 1000); the engine scales it into the safe zone */
.scene { transform-origin: 540px 1000px; will-change: transform, opacity, filter; }
.abs { position: absolute; }

/* looks (backgrounds), crossfaded by F.looks() */
.bg-dark { background: radial-gradient(760px 520px at 50% 1080px, rgba(52,70,240,0.13), transparent 70%), radial-gradient(1200px 900px at 50% 40%, #141519 0%, #0b0b0d 70%); }
.bg-light { background: radial-gradient(900px 700px at 50% 900px, #ffffff 0%, #f1f1ef 60%, #e9e9e6 100%); }
.bg-warm { background: radial-gradient(900px 700px at 22% 12%, rgba(255,196,140,0.55), transparent 65%), radial-gradient(1000px 900px at 80% 95%, rgba(214,140,96,0.28), transparent 70%), linear-gradient(170deg, #f6eadb 0%, #efdcc6 55%, #e6cdb2 100%); }
.bg-moody { background: radial-gradient(900px 700px at 50% 30%, rgba(46,86,104,0.32), transparent 70%), linear-gradient(180deg, #070a0d 0%, #0b1116 60%, #05070a 100%); }
.grain { position: absolute; inset: -64px; pointer-events: none; mix-blend-mode: overlay; }
.leak { position: absolute; width: 1400px; height: 1400px; border-radius: 50%; background: radial-gradient(closest-side, rgba(255,170,90,0.55), rgba(255,120,60,0.18) 50%, transparent 72%); mix-blend-mode: screen; pointer-events: none; }
.flash { position: absolute; inset: 0; background: #fff; opacity: 0; pointer-events: none; }

/* captions: word by word, centred on x 510, at most 820 wide (RULES safe zone). Top: from y 270. Band: from y 1170. */
.cap { position: absolute; left: 100px; width: 820px; top: 270px; text-align: center; font-size: 56px; font-weight: 620; line-height: 1.24; letter-spacing: -0.022em; text-wrap: balance; transform-origin: 50% 0; }
#film.cap-band .cap { top: 1170px; }
.cap .w { display: inline-block; position: relative; white-space: pre; }
.cap .k { padding: 0 12px; margin: 0 3px; font-weight: 700; isolation: isolate; }
.cap .k .bx { position: absolute; inset: -1px 0 -2px 0; border-radius: 6px; z-index: -1; }
.cap .k .hd { position: absolute; width: 13px; height: 13px; border-radius: 2px; background: #fff; }
.cap .ic { display: inline-flex; align-items: center; justify-content: center; width: 66px; height: 66px; margin-right: 12px; border-radius: 17px; vertical-align: -11px; }
.cap.m-dark, .cap.m-moody { color: #6f727a; }
.cap.m-dark .cur, .cap.m-moody .cur, .cap.m-dark .k, .cap.m-moody .k { color: #f3f3f1; }
.cap.m-dark .k .bx, .cap.m-moody .k .bx { background: rgba(52,70,240,0.42); border: 2.5px solid #6f7cf5; }
.cap.m-dark .k .hd, .cap.m-moody .k .hd { border: 3px solid #3446f0; }
.cap.m-light { color: #a3a5aa; }
.cap.m-light .cur, .cap.m-light .k { color: #121316; }
.cap.m-light .k .bx { background: rgba(52,70,240,0.13); border: 2.5px solid #3446f0; }
.cap.m-light .k .hd { border: 3px solid #3446f0; }
.cap.m-warm { font-family: var(--serif); font-weight: 600; font-size: 64px; letter-spacing: -0.01em; color: #b39c86; }
.cap.m-warm .cur, .cap.m-warm .k { color: #3a281c; }
.cap.m-warm .k { font-style: italic; font-weight: 700; }
.cap.m-warm .k .bx { background: rgba(196,104,62,0.14); border: 2.5px solid #c4683e; }
.cap.m-warm .k .hd { border: 3px solid #c4683e; }
.cap.m-moody { font-family: var(--cond); font-stretch: 75%; font-weight: 700; font-size: 96px; letter-spacing: 0.01em; text-transform: uppercase; line-height: 1.02; }
.cap.m-light .ic, .cap.m-warm .ic { background: #fff; box-shadow: 0 6px 18px rgba(0,0,0,0.1); }
.cap.m-dark .ic, .cap.m-moody .ic { background: #f6efe9; }

/* cards & chips */
.gcard { position: absolute; background: linear-gradient(180deg, #202126 0%, #15161a 100%); border: 1px solid rgba(255,255,255,0.09); border-radius: 36px; box-shadow: 0 50px 110px rgba(0,0,0,0.62), inset 0 1px 0 rgba(255,255,255,0.09); overflow: hidden; }
.lcard { position: absolute; background: linear-gradient(180deg, #ffffff 0%, #f6f6f8 100%); border: 1px solid rgba(20,22,40,0.07); border-radius: 36px; box-shadow: 0 46px 90px rgba(28,36,90,0.16), 0 10px 24px rgba(0,0,0,0.06), inset 0 1px 0 #fff; overflow: hidden; color: #141518; }
.pill { position: absolute; display: inline-flex; align-items: center; gap: 12px; height: 62px; padding: 0 26px; border-radius: 999px; font-size: 26px; font-weight: 650; white-space: nowrap; }
.pill.dk { background: rgba(30,31,36,0.96); border: 1px solid rgba(255,255,255,0.12); box-shadow: 0 18px 44px rgba(0,0,0,0.5); color: #e7e8ea; }
.pill.lt { background: #fff; border: 1px solid rgba(0,0,0,0.07); box-shadow: 0 16px 40px rgba(20,30,80,0.14); color: #141518; }
.pill.bl { background: linear-gradient(180deg, #4a5bff, #2f40e6); color: #fff; box-shadow: 0 16px 44px rgba(52,70,240,0.5), inset 0 1px 0 rgba(255,255,255,0.3); }
.pill.rd { background: linear-gradient(180deg, #ff4b5c, #e02a3d); color: #fff; box-shadow: 0 14px 36px rgba(240,56,75,0.45), inset 0 1px 0 rgba(255,255,255,0.3); }
.dot { display: inline-block; width: 14px; height: 14px; border-radius: 50%; }
.mono { font-family: var(--mono); }
.cursor { position: absolute; width: 56px; height: 56px; z-index: 60; filter: drop-shadow(0 6px 12px rgba(0,0,0,0.5)); }
.ripple { position: absolute; width: 90px; height: 90px; margin: -45px 0 0 -45px; border-radius: 50%; border: 4px solid #7d89f7; opacity: 0; z-index: 59; }
.stamp { position: absolute; width: 110px; height: 110px; border-radius: 50%; background: linear-gradient(180deg, #ff4b5c, #d9263a); box-shadow: 0 14px 34px rgba(240,56,75,0.55), inset 0 2px 0 rgba(255,255,255,0.3); display: flex; align-items: center; justify-content: center; }
.check { position: absolute; border-radius: 50%; background: linear-gradient(180deg, #4a5bff, #2f40e6); box-shadow: 0 12px 30px rgba(52,70,240,0.55), inset 0 2px 0 rgba(255,255,255,0.35); display: flex; align-items: center; justify-content: center; }
.photo { position: absolute; background: #fbf7f1; padding: 14px 14px 46px; border-radius: 6px; box-shadow: 0 30px 60px rgba(80,44,20,0.28), 0 6px 14px rgba(80,44,20,0.12); }
.photo img { display: block; width: 100%; height: 100%; object-fit: cover; border-radius: 2px; }
.bubble { position: absolute; padding: 26px 34px; border-radius: 34px; font-size: 36px; font-weight: 600; line-height: 1.25; white-space: nowrap; }
.bubble.me { background: linear-gradient(180deg, #4a5bff, #3446f0); color: #fff; border-bottom-right-radius: 10px; box-shadow: 0 18px 40px rgba(52,70,240,0.4); }
.caret { display: inline-block; width: 3px; height: 1em; background: currentColor; vertical-align: -0.12em; margin-left: 3px; }

/* safe-zone overlay (film.html?safe, render.mjs --safe): RULES.md rule 7 */
#safe { position: absolute; inset: 0; pointer-events: none; z-index: 1000; }
#safe .no { position: absolute; background: rgba(255,40,80,0.16); }
#safe .zone { position: absolute; left: 60px; top: 250px; width: 900px; height: 1230px; border: 3px solid #19e3ff; }
#safe .band { position: absolute; left: 60px; top: 1150px; width: 900px; height: 330px; border: 3px dashed #ffd400; background: rgba(255,212,0,0.07); }
#safe .mid { position: absolute; left: 509px; top: 250px; width: 2px; height: 1230px; background: rgba(25,227,255,0.45); }
#safe .capbox { position: absolute; left: 100px; width: 820px; border: 2px dotted rgba(25,227,255,0.8); }
#safe .lbl { position: absolute; font: 700 22px/1 Consolas, monospace; color: #19e3ff; background: rgba(0,0,0,0.6); padding: 4px 8px; }
#safe .bad { position: absolute; border: 4px solid #ff2050; background: rgba(255,32,80,0.18); }

/* ===================== CUTOUT look (overrides the Motion base above) =====================
   Graph paper (light #ececec / charcoal #363636, fine mesh + crosses), pure black and pure white acts; monochrome halftone
   cut-out stickers with a white die-cut rim and a thick grey edge; one yellow (bulb, meter), one red (stamp); highlighter
   pills only on the white ending. Captions: Outfit 700, 52 px, centred on x 510, word by word, newest word grey. */
:root { --k-ink: #0e0e0e; --k-paper: #ececec; --k-char: #363636; --k-yellow: #f4d23e; --k-red: #e0352b; --k-sans: 'Outfit', 'Inter Tight', system-ui, sans-serif; --k-serif: 'EB Garamond', Garamond, Georgia, serif; }
html, body { background: #ececec; }
body { font-family: var(--k-sans); }
.bg { position: absolute; left: -270px; top: -270px; width: 1620px; height: 2460px; background-size: 1620px 2460px; }
.bg-black { background: #000; }
.bg-white { background: #fdfdfd; }
.cap { left: 150px; width: 720px; top: 384px; text-align: center; font: 700 52px/1.3 var(--k-sans); letter-spacing: -0.004em; text-wrap: wrap; transform-origin: 50% 0; color: #0e0e0e; }
.cap .w { display: inline-block; white-space: pre; }
.cap .k { padding: 0 9px; margin: 0 -1px; border-radius: 10px; background: var(--pill); }
.cap.mid { top: 820px; }
.k-abs { position: absolute; }
.k-stk { position: absolute; left: 0; top: 0; filter: drop-shadow(16px 26px 20px rgba(0,0,0,0.30)); }
.k-sticker { position: absolute; will-change: transform, opacity, filter; }
.k-word { position: absolute; font: 250 92px/1 var(--k-sans); letter-spacing: 0.14em; white-space: nowrap; }
.k-label { position: absolute; font: 600 40px/1 var(--k-sans); color: #4a4a4a; white-space: nowrap; }
.k-bub { position: absolute; padding: 14px 26px 16px; border-radius: 16px; background: #f7f2e7; color: #141414; font: 500 40px/1.15 var(--k-serif); white-space: nowrap; box-shadow: 0 10px 26px rgba(0,0,0,0.25); }
.k-reply { position: absolute; color: #ececec; font: 400 34px/1.35 var(--k-sans); }
.k-card { position: absolute; background: linear-gradient(180deg, #23262c, #1b1d22); border: 1.5px solid rgba(255,255,255,0.08); border-radius: 26px; color: #f1f1f1; box-shadow: 0 40px 80px rgba(0,0,0,0.55); }
.k-btn { border-radius: 999px; background: #737d86; color: #f3f3f3; font: 600 30px/1 var(--k-sans); display: flex; align-items: center; justify-content: center; }
.k-brk { position: absolute; border: 2.5px solid #1c1c1c; display: flex; align-items: center; justify-content: center; text-align: center; font: 700 38px/1.2 'Montserrat', var(--k-sans); color: #111; padding: 10px 16px; }
.k-brk i { position: absolute; width: 18px; height: 18px; border: 6px solid #111; }
.k-brk i.a { left: -6px; top: -6px; border-right: 0; border-bottom: 0; } .k-brk i.b { right: -6px; top: -6px; border-left: 0; border-bottom: 0; }
.k-brk i.c { left: -6px; bottom: -6px; border-right: 0; border-top: 0; } .k-brk i.d { right: -6px; bottom: -6px; border-left: 0; border-top: 0; }
.k-brk b { position: absolute; width: 26px; height: 6px; background: #111; left: calc(50% - 13px); }
.k-brk b.t { top: -4px; } .k-brk b.u { bottom: -4px; }
.k-tile { position: absolute; border-radius: 22%; display: flex; align-items: center; justify-content: center; box-shadow: 0 18px 36px rgba(0,0,0,0.32), inset 0 1px 0 rgba(255,255,255,0.35); }
.k-input { position: absolute; border: 2px solid rgba(255,255,255,0.32); border-radius: 999px; display: flex; align-items: center; justify-content: space-between; padding: 0 30px; color: #e9e9e9; font: 300 44px/1 var(--k-sans); }
.k-input.lt { border-color: rgba(0,0,0,0.18); color: #333; background: rgba(255,255,255,0.65); }
.caret { display: inline-block; width: 3px; height: 1em; background: currentColor; vertical-align: -0.12em; margin-left: 3px; }
```

### `engine/markup.mjs`

```js
// Cutout · SCRIPT MARKUP (Node side: gen-vo, build-timeline, check). Same for every video.
//   /  a new caption block   |  the big line (ink)   !  the big line (red)   ^  a small grey line under it   @icon:word  icon chip
//   {shown|said}  the caption shows one thing, the voice says another   *word*  a highlighter pill (scenes.js F.cap.pill picks its colour)
//   (| ! ^ still parse, from Halftone, but Cutout's captions ignore them: one style of line)
// The voice + its settings live here as the template's defaults; a video's script.mjs may override VOICE / MODEL / SETTINGS / SPEED.
import { pathToFileURL } from 'node:url';
import path from 'node:path';
import fs from 'node:fs';

export const DEFAULTS = {
  VOICE: 'iP95p4xoKVk53GoZ742B',   // ElevenLabs "Chris": charming, down-to-earth American male (the reference: male, F0 ~110-145 Hz, ~170 wpm)
  MODEL: 'eleven_multilingual_v2',
  SETTINGS: { stability: 0.5, similarity_boost: 0.8, style: 0.28, use_speaker_boost: true },
  SPEED: 0.95,
  GAP: 0.45, LEAD: 0.2, HOLD: 2.0, QUIET: [],
};

// tokens: [{ d: shown text, said: spoken text, key, icon, br (a phrase starts here) }]
export function parse(src) {
  const out = []; let br = true, l2 = false, ln = 1;
  const parts = src.match(/[^\s{]*\{[^}]*\}\S*|\S+/g) || [];
  for (let raw of parts) {
    if (raw === '/') { br = true; l2 = false; ln = 1; continue; }
    if (raw === '|') { l2 = true; ln = 2; continue; }   // Halftone: the big line in ink (white on dark, black on cream)
    if (raw === '!') { l2 = true; ln = 3; continue; }   // Halftone: the big line in red
    if (raw === '^') { l2 = true; ln = 4; continue; }   // Halftone: a small grey line under the big one
    let icon = null, key = false;
    if (raw.startsWith('@')) { const i = raw.indexOf(':'); icon = raw.slice(1, i); raw = raw.slice(i + 1); }
    let core = raw, tail = '';
    if (raw.startsWith('*')) { const j = raw.lastIndexOf('*'); key = true; core = raw.slice(1, j); tail = raw.slice(j + 1); }
    let d = core, said = core;
    const m = core.match(/^(.*)\{([^|]*)\|([^}]*)\}(.*)$/);
    if (m) { d = m[1] + m[2] + m[4]; said = m[1] + m[3] + m[4]; }
    d = d.replace(/_/g, ' ') + tail; said = said.replace(/_/g, ' ') + tail;
    out.push({ d, said, key, icon, br, l2, ln }); br = false;
  }
  return out;
}
export const spoken = src => parse(src).map(t => t.said).join(' ');

// Re-apply new markup (boxes, phrase breaks, icons) to an existing take without new voice: the spoken words must be the
// same. Word times come from the take's tokens (a multi-word token's span is shared out by characters).
export function retime(toks, old) {
  const words = [];
  for (const k of old) {
    const ws = k.said.split(' '), total = ws.reduce((a, w) => a + w.length, 0) || 1; let acc = 0;
    for (const w of ws) { const a = k.t0 + (k.t1 - k.t0) * acc / total; acc += w.length; words.push({ w, t0: a, t1: k.t0 + (k.t1 - k.t0) * acc / total }); }
  }
  let wi = 0;
  return toks.map(t => { const n = t.said.split(' ').length, span = words.slice(wi, wi + n); wi += n; return { ...t, t0: +span[0].t0.toFixed(3), t1: +span.at(-1).t1.toFixed(3) }; });
}

// load <video>/script.mjs with the template defaults filled in
export async function loadScript(vdir) {
  const f = path.join(vdir, 'script.mjs');
  if (!fs.existsSync(f)) throw new Error(`no ${f}: write script.mjs and scenes.js in your video folder first (Appendix B is a complete example)`);
  const m = await import(pathToFileURL(f).href + '?t=' + Date.now());
  const s = { ...DEFAULTS, ...m };
  for (const id of s.ORDER) if (!s.TAKES[id]) throw new Error(`script.mjs: ORDER has "${id}" but TAKES has no such take`);
  return s;
}
// --video <dir> (default: video) → absolute path, must sit inside the template/work folder (film.html loads it relatively)
export function videoDir(here, argv = process.argv) {
  const i = argv.indexOf('--video'), rel = i > 0 ? argv[i + 1] : 'video';
  const abs = path.resolve(here, rel), r = path.relative(here, abs);
  if (!r || r.startsWith('..') || path.isAbsolute(r)) throw new Error(`--video must be a folder inside ${here} (got ${rel})`);
  return { abs, rel: r.split(path.sep).join('/') };
}
```

### `setup.mjs`

```js
// Cutout · requirements check + the free font + the synthesized sounds. Run once per project:  node setup.mjs
// Node 22+, ffmpeg/ffprobe on PATH, Chrome or Edge, the ElevenLabs key (never printed); downloads Outfit + EB Garamond (SIL OFL,
// google/fonts) to fonts/, makes sfx/*.wav (sounds.mjs; the bed is made per video by bed.mjs), and starts the browser once to prove it works.
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { CHROME, launch } from './cdp.mjs';
const HERE = import.meta.dirname;
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(HERE, '.env')) && /^ELEVENLABS_API_KEY=./m.test(fs.readFileSync(path.join(HERE, '.env'), 'utf8')))), 'ELEVENLABS_API_KEY (environment or .env) — not needed for --fake timings and stills');
const FONTS = {
  'Outfit.ttf': 'https://raw.githubusercontent.com/google/fonts/main/ofl/outfit/Outfit%5Bwght%5D.ttf',
  'EBGaramond.ttf': 'https://raw.githubusercontent.com/google/fonts/main/ofl/ebgaramond/EBGaramond%5Bwght%5D.ttf',
  'EBGaramond-Italic.ttf': 'https://raw.githubusercontent.com/google/fonts/main/ofl/ebgaramond/EBGaramond-Italic%5Bwght%5D.ttf',
};
for (const [name, url] of Object.entries(FONTS)) {
  const font = path.join(HERE, 'fonts', name);
  if (fs.existsSync(font)) { ok(true, 'fonts/' + name); continue; }
  fs.mkdirSync(path.dirname(font), { recursive: true });
  try { const r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0' } }); if (!r.ok) throw new Error(r.status); fs.writeFileSync(font, Buffer.from(await r.arrayBuffer())); ok(true, 'fonts/' + name + ' downloaded'); }
  catch (e) { ok(false, `fonts/${name}: download failed (${e.message}) from ${url}`); }
}
spawnSync(process.execPath, [path.join(HERE, 'sounds.mjs')], { stdio: 'inherit' });
if (CHROME) try { const api = await launch({ port: 9698 }); ok(true, 'Chrome starts headless · renderer: ' + api.gpu); await api.close(); } catch (e) { ok(false, e.message); }
setTimeout(() => process.exit(0), 300);
```

### `sounds.mjs`

```js
// Cutout · the sounds, synthesized (no downloads, no licences, no credits):  node sounds.mjs [--force]
// sfx/thump tick beep stamp click swish whoosh typing riser glitch implode pop chime .wav. Seeded, so every project gets the same sounds.
// (The music bed is made per video, to its length, by bed.mjs.)
import fs from 'node:fs'; import path from 'node:path';
const HERE = import.meta.dirname, SR = 48000, FORCE = process.argv.includes('--force');
let seed = 7; const rnd = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
const wav = (file, L, R = L) => {
  const n = L.length, b = Buffer.alloc(44 + n * 4);
  b.write('RIFF', 0); b.writeUInt32LE(36 + n * 4, 4); b.write('WAVEfmt ', 8); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(2, 22);
  b.writeUInt32LE(SR, 24); b.writeUInt32LE(SR * 4, 28); b.writeUInt16LE(4, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 4, 40);
  for (let i = 0; i < n; i++) { b.writeInt16LE(Math.round(Math.max(-1, Math.min(1, L[i])) * 32767), 44 + i * 4); b.writeInt16LE(Math.round(Math.max(-1, Math.min(1, R[i])) * 32767), 46 + i * 4); }
  fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, b);
};
const norm = (a, peak = 0.89) => { let m = 0; for (const v of a) m = Math.max(m, Math.abs(v)); if (m) for (let i = 0; i < a.length; i++) a[i] *= peak / m; return a; };
const buf = s => new Float32Array(Math.round(s * SR));
// noise through a one-pole low-pass whose cutoff follows f(u), shaped by env(u)
const filtered = (dur, f, env, hp = 0) => { const a = buf(dur); let lp = 0, prev = 0; for (let i = 0; i < a.length; i++) { const u = i / a.length, k = 1 - Math.exp(-2 * Math.PI * f(u) / SR); lp += k * (rnd() * 2 - 1 - lp); const v = hp ? lp - prev : lp; prev = lp; a[i] = v * env(u); } return a; };

const S = {
  thump: () => { const a = buf(0.7); let ph = 0; for (let i = 0; i < a.length; i++) { const t = i / SR, f = 38 + 70 * Math.exp(-t * 18); ph += 2 * Math.PI * f / SR; a[i] = 0.9 * Math.sin(ph) * Math.exp(-t * 7) + 0.25 * Math.sin(2 * Math.PI * 90 * t) * Math.exp(-t * 25); } return a; },
  tick: () => { const a = buf(0.06); for (let i = 0; i < a.length; i++) { const t = i / SR; a[i] = (0.6 * Math.sin(2 * Math.PI * 2400 * t) + 0.4 * (rnd() * 2 - 1)) * Math.exp(-t * 160); } return a; },
  beep: () => { const a = buf(0.6); for (let i = 0; i < a.length; i++) { const t = i / SR; a[i] = 0.5 * Math.sin(2 * Math.PI * 1000 * t) * Math.min(1, t * 80) * Math.min(1, (0.6 - t) * 40); } return a; },
  stamp: () => { const a = filtered(0.25, () => 1800, u => Math.exp(-u * 14)); let ph = 0; for (let i = 0; i < a.length; i++) { const t = i / SR; ph += 2 * Math.PI * (120 + 200 * Math.exp(-t * 60)) / SR; a[i] += 0.9 * Math.sin(ph) * Math.exp(-t * 22); } return a; },
  click: () => filtered(0.03, () => 6000, u => Math.exp(-u * 9), 1),
  swish: () => filtered(0.38, u => 600 + 5000 * Math.sin(Math.PI * u), u => Math.sin(Math.PI * u) ** 2),
  whoosh: () => filtered(0.7, u => 250 + 2600 * Math.sin(Math.PI * Math.min(1, u * 1.3)), u => Math.sin(Math.PI * u) ** 1.5),
  typing: () => { const a = buf(1.4); let t = 0.01; while (t < 1.36) { const i0 = Math.round(t * SR), g = 0.5 + rnd() * 0.5, f = 3000 + rnd() * 3000; for (let i = 0; i < 900 && i0 + i < a.length; i++) a[i0 + i] += g * (rnd() * 2 - 1) * Math.exp(-i / 90) + 0.3 * g * Math.sin(2 * Math.PI * f * i / SR) * Math.exp(-i / 60); t += 0.055 + rnd() * 0.07; } return a; },
  riser: () => { const a = filtered(2.2, u => 300 + 6000 * u * u, u => u ** 1.8); let ph = 0; for (let i = 0; i < a.length; i++) { const u = i / a.length; ph += 2 * Math.PI * (110 + 330 * u * u) / SR; a[i] += 0.35 * Math.sin(ph) * u ** 2; } return a; },
  glitch: () => { const a = buf(0.35); let hold = 0, v = 0; for (let i = 0; i < a.length; i++) { if (hold-- <= 0) { hold = 40 + Math.floor(rnd() * 400); v = rnd() * 2 - 1; } const t = i / SR; a[i] = (v * 0.6 + 0.4 * Math.sign(Math.sin(2 * Math.PI * (200 + 1800 * rnd()) * t))) * (rnd() < 0.85 ? 1 : 0) * Math.exp(-t * 6); } return a; },
  implode: () => { const a = filtered(0.7, u => 300 + 3000 * u * u, u => u ** 2.2); let ph = 0; for (let i = Math.round(0.62 * SR); i < a.length; i++) { const t = i / SR - 0.62; ph += 2 * Math.PI * 70 / SR; a[i] += 0.8 * Math.sin(ph) * Math.exp(-t * 30); } return a; },
  pop: () => { const a = buf(0.16); let ph = 0; for (let i = 0; i < a.length; i++) { const t = i / SR, f = 300 + 650 * Math.exp(-t * 45); ph += 2 * Math.PI * f / SR; a[i] = Math.sin(ph) * Math.exp(-t * 32) * Math.min(1, t * 2000); } return a; },
  chime: () => { const a = buf(1.6); [[1318.5, 1], [1975.5, 0.6], [2637, 0.25]].forEach(([f, g]) => { for (let i = 0; i < a.length; i++) { const t = i / SR; a[i] += g * Math.sin(2 * Math.PI * f * t) * Math.exp(-t * 3.2) * Math.min(1, t * 400); } }); return a; },
};
for (const [name, make] of Object.entries(S)) {
  const f = path.join(HERE, 'sfx', name + '.wav'); if (fs.existsSync(f) && !FORCE) { console.log('kept sfx/' + name + '.wav'); continue; }
  const a = norm(make(), 0.7); wav(f, a); console.log('made sfx/' + name + '.wav');
}
```

### `bed.mjs`

```js
// Cutout · THE BED, synthesized per video to its exact length (no downloads, no licences, no credits) → <video>/bed.wav.
// A calm 100 bpm groove under the narrator: a soft kick on 1 and 3, a brushed clap on 2 and 4, quiet off-beat hats, a round
// bass and a warm low-passed pad over Am7 - Fmaj7 - Cmaj7 - G6. Like the reference, the hook stays dry: the bed is silent
// until IN (default: the second section's start), fades in over 0.6 s, and lifts +5 dB under the end card from OUT
// (default: 2.1 s before the end), fading out over the last 1.4 s.
//   node bed.mjs --video <dir> [--in <s>] [--out <s>]     (after build-timeline.mjs; run it again whenever the timing changes)
import fs from 'node:fs'; import path from 'node:path';
import { videoDir } from './engine/markup.mjs';
const HERE = import.meta.dirname, { abs: VD } = videoDir(HERE), SR = 48000, TAU = Math.PI * 2;
const arg = (k, d) => { const i = process.argv.indexOf(k); return i > 0 ? +process.argv[i + 1] : d; };
const TLf = path.join(VD, 'timeline.json');
if (!fs.existsSync(TLf)) { console.error('no timeline.json: run build-timeline.mjs first'); process.exit(1); }
const TL = JSON.parse(fs.readFileSync(TLf, 'utf8'));
const dur = TL.total + 0.3, n = Math.round(dur * SR), L = new Float32Array(n), R = new Float32Array(n);
const tin = arg('--in', (TL.sections[1] ? TL.sections[1].start : 0) - 0.15), tout = arg('--out', TL.total - 2.1);
let seed = 23; const rnd = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
const BEAT = 0.6, BAR = BEAT * 4;
const CHORDS = [[220, 261.63, 329.63, 392], [174.61, 220, 261.63, 329.63], [261.63, 329.63, 392, 493.88], [196, 246.94, 293.66, 329.63]];
const BASS = [110, 87.31, 130.81, 98];
const add = (i, l, r = l) => { if (i >= 0 && i < n) { L[i] += l; R[i] += r; } };
// the pad: three soft harmonics per note, a slow swell per bar, low-passed
{
  let lpL = 0, lpR = 0;
  for (let i = 0; i < n; i++) {
    const t = i / SR, b = Math.floor(t / BAR), u = (t % BAR) / BAR, ch = CHORDS[b % 4], prev = CHORDS[(b + 3) % 4];
    const env = Math.min(1, u / 0.18), xf = Math.min(1, u / 0.12);
    let v = 0, w = 0;
    ch.forEach((f, k) => { const d = 1 + (k % 2 ? 0.0025 : -0.002); v += Math.sin(TAU * f * d * t) + 0.3 * Math.sin(TAU * 2 * f * t) + 0.12 * Math.sin(TAU * 3 * f * d * t); w += Math.sin(TAU * f / d * t + k) + 0.3 * Math.sin(TAU * 2 * f * t + 1); });
    let p = 0; prev.forEach(f => { p += Math.sin(TAU * f * t) + 0.3 * Math.sin(TAU * 2 * f * t); });
    const sL = (v * xf + p * (1 - xf)) * (0.75 + 0.25 * env) * 0.05, sR = (w * xf + p * (1 - xf)) * (0.75 + 0.25 * env) * 0.05;
    lpL += 0.09 * (sL - lpL); lpR += 0.09 * (sR - lpR);
    L[i] += lpL; R[i] += lpR;
  }
}
// drums + bass, beat by beat
for (let beat = 0; beat * BEAT < dur; beat++) {
  const t0 = beat * BEAT, i0 = Math.round(t0 * SR), inBar = beat % 4, bar = Math.floor(beat / 4);
  if (inBar === 0 || inBar === 2) { let ph = 0; for (let i = 0; i < 0.45 * SR; i++) { const t = i / SR; ph += TAU * (45 + 75 * Math.exp(-t * 28)) / SR; const v = 0.55 * Math.sin(ph) * Math.exp(-t * 9); add(i0 + i, v); } }
  if (inBar === 1 || inBar === 3) { let lp = 0, prev = 0; for (let i = 0; i < 0.22 * SR; i++) { const t = i / SR; lp += 0.35 * (rnd() * 2 - 1 - lp); const hp = lp - prev; prev = lp; const v = 0.16 * hp * Math.exp(-t * 22); add(i0 + i, v, v * 0.9); } }
  for (const half of [0.5]) { const j0 = Math.round((t0 + half * BEAT) * SR); let prev = 0; for (let i = 0; i < 0.05 * SR; i++) { const x = rnd() * 2 - 1, v = 0.05 * (x - prev) * Math.exp(-i / SR * 90); prev = x; add(j0 + i, v * 0.8, v); } }
  // bass: the root on 1 and on the "and" of 2
  if (inBar === 0 || inBar === 1) {
    const f = BASS[bar % 4], j0 = Math.round((t0 + (inBar === 1 ? 0.5 * BEAT : 0)) * SR), len = (inBar === 1 ? 0.9 : 0.7) * SR;
    for (let i = 0; i < len; i++) { const t = i / SR, e = Math.min(1, t * 120) * Math.exp(-t * 2.2) * Math.min(1, (len - i) / (0.05 * SR)); const v = 0.26 * e * (Math.sin(TAU * f * t) + 0.25 * Math.sin(TAU * 2 * f * t)); add(j0 + i, v); }
  }
}
// air
{ let lp = 0; for (let i = 0; i < n; i++) { lp += 0.02 * (rnd() * 2 - 1 - lp); L[i] += lp * 0.05; R[i] -= lp * 0.05; } }
// the shape over the film: dry hook, fade in, steady, a lift under the end card, fade out
let m = 0;
for (let i = 0; i < n; i++) {
  const t = i / SR, g = (t < tin ? 0 : Math.min(1, (t - tin) / 0.6)) * (t < tout ? 1 : 1 + 0.78 * Math.min(1, (t - tout) / 0.4)) * Math.min(1, (dur - t) / 1.4);
  L[i] *= g; R[i] *= g; m = Math.max(m, Math.abs(L[i]), Math.abs(R[i]));
}
const b = Buffer.alloc(44 + n * 4);
b.write('RIFF', 0); b.writeUInt32LE(36 + n * 4, 4); b.write('WAVEfmt ', 8); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(2, 22);
b.writeUInt32LE(SR, 24); b.writeUInt32LE(SR * 4, 28); b.writeUInt16LE(4, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 4, 40);
const k = m ? 0.8 / m : 1;
for (let i = 0; i < n; i++) { b.writeInt16LE(Math.round(Math.max(-1, Math.min(1, L[i] * k)) * 32767), 44 + i * 4); b.writeInt16LE(Math.round(Math.max(-1, Math.min(1, R[i] * k)) * 32767), 46 + i * 4); }
fs.writeFileSync(path.join(VD, 'bed.wav'), b);
console.log(`bed.wav ${dur.toFixed(1)} s · 100 bpm groove · in at ${tin.toFixed(2)} s · lift at ${tout.toFixed(2)} s`);
```

### `cdp.mjs`

```js
// Headless Chrome/Edge over the DevTools protocol, no dependencies. ONE browser at a time, at the lowest priority.
// The film is HTML + 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.CUTOUT_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(), 'cutout-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, console: logs,
    async shot(file, quality = 90) { const r = await send('Page.captureScreenshot', { format: 'jpeg', quality }); fs.writeFileSync(file, Buffer.from(r.data, 'base64')); },
    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 { }
      // wait for Chrome to let go of its profile folder, then delete it (a timer would never fire if the script exits first)
      for (let k = 0; k < 20; k++) { await sleep(150); try { fs.rmSync(udd, { recursive: true, force: true }); break; } catch { } } } };
  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;
}
```

### `gen-vo.mjs`

```js
// Cutout · VOICE. One ElevenLabs take per section, with word timings → <video>\vo\<id>.mp3 + <id>.json.
//   node gen-vo.mjs --video <dir> [--yes] [--only s1,s3] [--fresh]   (a dry run unless --yes)
// --dry prints what would be sent and the character count, and sends nothing. A take is cached: it is only made again
// when its spoken words changed (or with --fresh). Markup-only edits (boxes, phrase breaks) need no new take:
// build-timeline.mjs re-applies them. Voice + settings: engine/markup.mjs DEFAULTS (Chris, multilingual v2, speed 0.95)
// unless script.mjs exports VOICE / MODEL / SETTINGS / SPEED. Never prints the key.
import fs from 'node:fs';
import path from 'node:path';
import { parse, loadScript, videoDir } from './engine/markup.mjs';
const has0 = k => process.argv.includes(k);
const HERE = import.meta.dirname;
const { abs: VD } = videoDir(HERE);
const S = await loadScript(VD);
if (fs.existsSync(path.join(VD, 'vo', 'FROZEN')) && !has0('--dry')) { console.error(`${path.join(VD, 'vo')} is FROZEN (the template's example voice): never regenerate it. Make your own video folder (new-video.mjs).`); process.exit(1); }
const arg = k => { const i = process.argv.indexOf(k); return i > 0 ? process.argv[i + 1] : null; };
const has = k => process.argv.includes(k);
const only = arg('--only') ? arg('--only').split(',') : S.ORDER;
const speed = +(arg('--speed') || S.SPEED);
fs.mkdirSync(path.join(VD, 'vo'), { recursive: true });
const todo = [];
for (const id of only) {
  if (!S.TAKES[id]) { console.log(id, 'is not in TAKES'); continue; }
  const toks = parse(S.TAKES[id]), text = toks.map(t => t.said).join(' '), js = path.join(VD, 'vo', id + '.json');
  const old = fs.existsSync(js) ? JSON.parse(fs.readFileSync(js, 'utf8')) : null;
  if (old && old.text === text && !has('--fresh')) { console.log(id, 'cached'); continue; }
  todo.push({ id, toks, text, why: old ? (old.text === text ? 'fresh' : 'changed') : 'new' });
}
const chars = todo.reduce((a, t) => a + t.text.length, 0);
console.log(`${todo.length} take(s) to make, ${chars} characters (${todo.map(t => `${t.id}:${t.why}`).join(' ') || '-'})`);
if (!has('--yes') && todo.length) console.log('dry run: nothing sent. Add --yes to make these takes (~1 credit per character).');
if (has('--dry') || !has('--yes') || !todo.length) { for (const t of todo) console.log(` ${t.id}: ${t.text}`); process.exit(0); }
const envFile = path.join(HERE, '.env');
const env = Object.fromEntries((fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf8') : '').split(/\r?\n/).filter(l => /^[A-Z_]+=/.test(l)).map(l => { const i = l.indexOf('='); return [l.slice(0, i), l.slice(i + 1).trim().replace(/^["']|["']$/g, '')]; }));
env.ELEVENLABS_API_KEY ||= process.env.ELEVENLABS_API_KEY;
if (!env.ELEVENLABS_API_KEY) { console.error('no ELEVENLABS_API_KEY (environment, or .env in the project folder)'); process.exit(1); }
await Promise.all([0, 1, 2].map(async w => {
  for (let i = w; i < todo.length; i += 3) {
    const { id, toks, text } = todo[i];
    const body = { text, model_id: S.MODEL, voice_settings: { ...S.SETTINGS, speed } };
    const r = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${S.VOICE}/with-timestamps?output_format=mp3_44100_128`, { method: 'POST', headers: { 'xi-api-key': env.ELEVENLABS_API_KEY, 'content-type': 'application/json' }, body: JSON.stringify(body) });
    if (!r.ok) { console.log(id, r.status, (await r.text()).slice(0, 300)); continue; }
    const j = await r.json();
    fs.writeFileSync(path.join(VD, 'vo', id + '.mp3'), Buffer.from(j.audio_base64, 'base64'));
    const A = j.alignment, words = []; let cur = null;
    A.characters.forEach((c, k) => {
      if (/\s/.test(c)) { if (cur) { words.push(cur); cur = null; } return; }
      if (!cur) cur = { w: '', t0: A.character_start_times_seconds[k] };
      cur.w += c; cur.t1 = A.character_end_times_seconds[k];
    });
    if (cur) words.push(cur);
    let wi = 0;
    for (const t of toks) { const n = t.said.split(' ').length, span = words.slice(wi, wi + n); wi += n; t.t0 = +span[0].t0.toFixed(3); t.t1 = +span.at(-1).t1.toFixed(3); }
    if (wi !== words.length) console.log(id, 'WARNING word count', wi, 'vs', words.length);
    const dur = A.character_end_times_seconds.at(-1);
    fs.writeFileSync(path.join(VD, 'vo', id + '.json'), JSON.stringify({ id, text, voice: S.VOICE, speed, dur, tokens: toks }, null, 1));
    console.log(id, 'ok', dur.toFixed(2) + 's', (text.split(' ').length / dur * 60).toFixed(0), 'wpm');
  }
}));
console.log('characters sent', chars);
```

### `build-timeline.mjs`

```js
// Cutout · TIMELINE. Lays the takes end to end → <video>\timeline.json + timeline.js (every caption word on
// the film clock) and <video>\vo.wav (the voice track).   node build-timeline.mjs --video <dir> [--fake]
// It re-applies the current markup of script.mjs to the takes, so box / phrase-break edits need no new voice; if a
// take's spoken words changed, it stops and tells you to run gen-vo.mjs. Gaps: GAP (per section or one number), LEAD, HOLD.
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { parse, retime, loadScript, videoDir } from './engine/markup.mjs';
const FF = 'ffmpeg';
const HERE = import.meta.dirname;
const { abs: VD } = videoDir(HERE);
const S = await loadScript(VD);
const gap = id => typeof S.GAP === 'number' ? S.GAP : (S.GAP[id] ?? 0.3);
let t = S.LEAD; const sections = [], inputs = [], fc = [], bad = [];
const FAKE = process.argv.includes('--fake');
// --fake: guessed word times (~175 wpm, a breath after . , ? !) and a silent vo.wav, to direct the scenes before paying for a voice
const fakeTake = toks => { let x = 0; return toks.map(k => { const n = k.said.split(' ').length, d = 0.08 + 0.042 * k.said.length + 0.04 * (n - 1); const o = { ...k, t0: +x.toFixed(3), t1: +(x + d).toFixed(3) }; x += d + (/[.?!]$/.test(k.said) ? 0.26 : /[,;:]$/.test(k.said) ? 0.12 : 0.02); return o; }); };
S.ORDER.forEach((id, i) => {
  const jf = path.join(VD, 'vo', id + '.json');
  if (FAKE) {
    const tk = fakeTake(parse(S.TAKES[id])); if (i) t += gap(id);
    const start = +t.toFixed(3), speechEnd = tk.at(-1).t1;
    sections.push({ id, start, end: +(start + speechEnd).toFixed(3), tokens: tk.map(k => ({ ...k, t0: +(start + k.t0).toFixed(3), t1: +(start + k.t1).toFixed(3) })) });
    t = start + speechEnd; return;
  }
  if (!fs.existsSync(jf)) { bad.push(`${id}: no take yet`); return; }
  const j = JSON.parse(fs.readFileSync(jf, 'utf8'));
  const toks = parse(S.TAKES[id]);
  if (toks.map(k => k.said).join(' ') !== j.text) { bad.push(`${id}: the spoken words changed`); return; }
  const tk = retime(toks, j.tokens);
  if (i) t += gap(id);
  const start = +t.toFixed(3), speechEnd = tk.at(-1).t1;
  sections.push({ id, start, end: +(start + speechEnd).toFixed(3), tokens: tk.map(k => ({ ...k, t0: +(start + k.t0).toFixed(3), t1: +(start + k.t1).toFixed(3) })) });
  inputs.push('-i', path.join(VD, 'vo', id + '.mp3'));
  const ms = Math.round(start * 1000), n = inputs.length / 2 - 1;
  fc.push(`[${n}:a]atrim=0:${(speechEnd + 0.12).toFixed(3)},afade=t=out:st=${(speechEnd + 0.02).toFixed(3)}:d=0.1,adelay=${ms}|${ms}[a${n}]`);
  t = start + speechEnd;
});
if (bad.length) { console.error('Make the voice first (node gen-vo.mjs --video ' + path.relative(HERE, VD) + '):\n  ' + bad.join('\n  ')); process.exit(1); }
sections.forEach((s, i) => { s.until = i + 1 < sections.length ? sections[i + 1].start : +(t + S.HOLD).toFixed(3); });
const total = +(t + S.HOLD).toFixed(3);
fs.writeFileSync(path.join(VD, 'timeline.json'), JSON.stringify({ total, sections }, null, 1));
fs.writeFileSync(path.join(VD, 'timeline.js'), `window.TL = ${JSON.stringify({ total, sections })};`);
if (FAKE) {
  execFileSync(FF, ['-y', '-v', 'error', '-f', 'lavfi', '-i', 'anullsrc=r=48000:cl=mono', '-t', String(total), path.join(VD, 'vo.wav')]);
  console.log('FAKE timings (no voice yet): direct the scenes and take stills, then gen-vo.mjs + build-timeline.mjs for the real ones');
} else {
  fc.push(`${sections.map((_, i) => `[a${i}]`).join('')}amix=inputs=${sections.length}:normalize=0,apad=whole_dur=${total},atrim=0:${total}[vo]`);
  execFileSync(FF, ['-y', '-v', 'error', ...inputs, '-filter_complex', fc.join(';'), '-map', '[vo]', '-ar', '48000', '-ac', '1', path.join(VD, 'vo.wav')]);
}
const words = sections.reduce((a, s) => a + s.tokens.reduce((b, k) => b + k.said.split(' ').length, 0), 0);
console.log(`total ${total} s · ${words} words · ${(words / (t - S.LEAD) * 60).toFixed(0)} wpm · ` + sections.map(s => `${s.id}@${s.start}`).join(' '));
if (total > 60) console.log('WARNING: over 60 s (RULES 10). Tighten GAP/HOLD or the script.');
for (const s of sections) console.log(s.id, s.tokens.map(k => `${k.d}@${k.t0.toFixed(2)}`).join(' '));
```

### `render.mjs`

```js
// Cutout · RENDER + MIX + ENCODE. Run from the project folder, one render at a time:
//   node render.mjs --video <dir> <steps…>
// Steps (any of them, done in this order):
//   --prep                    the frames this film shows of itself (F.prep in scenes.js) → <dir>\prep\
//   --scan [--step 0.25]      safe-zone check of every visible text through the whole film, no screenshots (~1 min)
//   --stills [--at 0,3.5,8 | beats | spread:8] [--safe] [--nocap] [--sheet sheet]
//                             stills → <dir>\stills\<t>.jpg (+ a contact sheet); --safe draws the RULES safe zone and
//                             reports every visible text outside it (<dir>\stills\safe-report.txt)
//   --full [--from f] [--to f] [--force]   every frame → <dir>\frames\f00000.jpg … (resumable, ~150 ms/frame)
//   --cover <t> [--cover-out cover.jpg]   the 1080x1920 hook frame at film time t (deliverable)
//   --mix [--bed <dir>\bed.wav] [--music 0.17] [--dur s]   voice + bed (bed.mjs makes it) + sfx → <dir>\mix.wav at −14 LUFS
//   --encode [--out video.mp4]      frames + mix → H.264 with the RULES settings, then probes it (size, length, loudness)
// Any step: --layout band previews the captions in the RULES caption band (set it for real with F.layout('band') in scenes.js).
// Chrome only through cdp.mjs: one browser per call, idle priority, one thread.
import { launch } from './cdp.mjs';
import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { videoDir } from './engine/markup.mjs';
try { os.setPriority(os.constants.priority.PRIORITY_LOW); } catch { }
const HERE = import.meta.dirname, FF = 'ffmpeg', FP = 'ffprobe';
process.chdir(HERE);
const arg = (k, d) => { const i = process.argv.indexOf(k); return i > 0 ? process.argv[i + 1] : d; };
const has = k => process.argv.includes(k);
const FPS = 30, port = +arg('--port', 9671);
const { abs: VD, rel: VREL } = videoDir(HERE);
const V = (...p) => path.join(VD, ...p);
for (const f of ['scenes.js', 'timeline.js']) if (!fs.existsSync(V(f))) { console.error(`no ${V(f)}${f === 'timeline.js' ? ' — run build-timeline.mjs first' : ''}`); process.exit(1); }
const url = q => 'file:///' + path.join(HERE, 'film.html').split(path.sep).join('/') + '?v=' + encodeURIComponent(VREL) + (arg('--layout') ? '&layout=' + arg('--layout') : '') + (q ? '&' + q : '');
const ff = (args, o = {}) => execFileSync(FF, ['-y', '-v', 'error', ...args], { stdio: 'inherit', ...o });
const probe = f => JSON.parse(execFileSync(FP, ['-v', 'error', '-show_entries', 'format=duration,size,bit_rate:stream=codec_type,codec_name,profile,pix_fmt,width,height,r_frame_rate,sample_rate,channels', '-of', 'json', f], { encoding: 'utf8' }));
const loud = (f, extra = []) => { const p = spawnSync(FF, ['-hide_banner', '-nostats', ...extra, '-i', f, '-af', 'ebur128=peak=true', '-f', 'null', '-'], { encoding: 'utf8' }); const s = p.stderr.slice(p.stderr.lastIndexOf('Summary:')); return { I: +(s.match(/I:\s+(-?[\d.]+) LUFS/) || [])[1], TP: +(s.match(/Peak:\s+(-?[\d.]+) dBFS/) || [])[1] }; };

// ---------- the page ----------
let page = null;
async function load(q) {
  if (!page) page = await launch({ width: 1080, height: 1920, port });
  page.console.length = 0;
  await page.goto(url(q));
  const ok = await page.eval('window.ready');
  const errs = await page.eval('window.F ? F.errors : ["engine did not load"]');
  if (!ok || errs.length || !(await page.eval('typeof window.setT === "function"'))) { console.error('The film did not build:\n  ' + (errs.length ? errs.join('\n  ') : 'unknown error')); await page.close(); process.exit(1); }
  // what the mix needs from the film: its sound cues and its looks (warm/moody windows restyle the bed)
  fs.writeFileSync(V('cues.json'), JSON.stringify({ total: await page.eval('window.TOTAL'), cues: await page.eval('window.CUES'), looks: await page.eval('window.LOOKS') }));
}
const pageErrors = () => { const e = page.console.filter(c => /error|exception/i.test(c.type)); if (e.length) console.log('PAGE ERRORS:', JSON.stringify(e.slice(-6), null, 1)); };
const sheet = (files, out) => {
  const tmp = V('stills', '_seq'); fs.rmSync(tmp, { recursive: true, force: true }); fs.mkdirSync(tmp, { recursive: true });
  files.forEach((f, i) => fs.copyFileSync(f, path.join(tmp, `${String(i).padStart(3, '0')}.jpg`)));
  const n = files.length, cols = n <= 4 ? n : n <= 8 ? 4 : n <= 12 ? 6 : 8, rows = Math.ceil(n / cols);
  ff(['-i', path.join(tmp, '%03d.jpg'), '-vf', `scale=270:480,tile=${cols}x${rows}:padding=6:color=0x444444`, '-frames:v', '1', out]);
  fs.rmSync(tmp, { recursive: true, force: true });
};

try {
  // ---------- prep ----------
  if (has('--prep')) {
    await load('');
    const list = await page.eval('window.PREP');
    if (!list.length) console.log('scenes.js asks for no prep shots');
    fs.mkdirSync(V('prep'), { recursive: true });
    for (const nocap of [false, true]) {
      const items = list.filter(p => !!p.nocap === nocap);
      if (!items.length) continue;
      if (nocap) await load('nocap');
      for (const p of items) {
        const ats = Array.isArray(p.at) ? p.at : [p.at], multi = Array.isArray(p.at);
        if (multi) fs.mkdirSync(V('prep', p.name), { recursive: true });
        for (let i = 0; i < ats.length; i++) {
          const f = multi ? V('prep', p.name, `${String(i + 1).padStart(2, '0')}.jpg`) : V('prep', p.name + '.jpg');
          await page.eval(`setT(${ats[i]})`); await page.shot(f, 88);
          if (p.width) ff(['-i', f, '-vf', `scale=${p.width}:-1`, '-q:v', '3', f + '.tmp.jpg']), fs.renameSync(f + '.tmp.jpg', f);
        }
      }
    }
    console.log('prep done:', list.map(p => p.name).join(', '));
  }

  // ---------- safe-zone scan: every text on screen, every 0.25 s, no screenshots (fast) ----------
  if (has('--scan')) {
    await load('safe');
    const total = await page.eval('window.TOTAL'), step = +arg('--step', 0.25), hits = new Map();
    for (let t = 0; t <= total; t += step) for (const b of await page.eval(`setT(${t.toFixed(3)}).then(() => F.safeReport())`)) {
      const k = `${b.cap ? 'caption' : 'text'} "${b.text}"`, e = hits.get(k) || { t0: t, t1: t, box: b.box }; e.t1 = t; hits.set(k, e);
    }
    const lines = [...hits].map(([k, e]) => `${e.t0.toFixed(2)}–${e.t1.toFixed(2)}s  ${k}  box ${e.box.join(',')}`);
    console.log(lines.length ? `SAFE ZONE: ${lines.length} text(s) outside at some point:\n  ` + lines.join('\n  ') : `safe zone scan (every ${step} s): all visible text inside`);
    pageErrors();
  }

  // ---------- stills ----------
  if (has('--stills')) {
    const safe = has('--safe');
    await load([has('--nocap') ? 'nocap' : '', safe ? 'safe' : ''].filter(Boolean).join('&'));
    const total = await page.eval('window.TOTAL'), A = String(arg('--at', 'spread:8'));
    const ats = A === 'beats' ? (await page.eval('window.BEATS')).map(([a, b]) => +((a + b) / 2).toFixed(2))
      : A.startsWith('spread:') ? Array.from({ length: +A.slice(7) }, (_, i) => +(total * (i + 0.5) / +A.slice(7)).toFixed(2))
      : A.split(',').map(Number);
    fs.mkdirSync(V('stills'), { recursive: true });
    const files = [], report = [];
    for (const at of ats) {
      await page.eval(`setT(${at})`);
      if (safe) { const bad = await page.eval('F.safeReport(true)'); for (const b of bad) report.push(`${at}s  ${b.cap ? 'caption' : 'text'} "${b.text}"  box ${b.box.join(',')}`); }
      const f = V('stills', `${at}.jpg`); await page.shot(f, 88); files.push(f);
    }
    if (arg('--sheet')) sheet(files, V('stills', arg('--sheet') + '.jpg'));
    if (safe) {
      fs.writeFileSync(V('stills', 'safe-report.txt'), report.length ? report.join('\n') + '\n' : 'all visible text inside the safe zone\n');
      console.log(report.length ? `SAFE ZONE: ${report.length} text box(es) outside (red in the stills):\n  ` + report.join('\n  ') : 'safe zone: all visible text inside');
    }
    pageErrors();
    console.log('stills', ats.join(','), arg('--sheet') ? '+ sheet ' + V('stills', arg('--sheet') + '.jpg') : '');
  }

  // ---------- full render ----------
  if (has('--full')) {
    await load('');
    const total = await page.eval('window.TOTAL'), n = Math.round(total * FPS);
    fs.mkdirSync(V('frames'), { recursive: true });
    const from = +arg('--from', 0), to = Math.min(n, +arg('--to', n));
    const t0 = Date.now(); let done = 0;
    for (let i = from; i < to; i++) {
      const f = V('frames', `f${String(i).padStart(5, '0')}.jpg`);
      if (fs.existsSync(f) && !has('--force')) continue;
      await page.eval(`setT(${(i / FPS).toFixed(5)})`); await page.shot(f, 92); done++;
      if (done % 150 === 0) console.log(`${i + 1}/${n} frames, ${((Date.now() - t0) / done).toFixed(0)} ms/frame`);
    }
    pageErrors();
    console.log(`frames ${from}–${to - 1} of ${n} done (${done} new) in ${((Date.now() - t0) / 1000).toFixed(0)} s`);
  }

  // ---------- cover ----------
  if (has('--cover')) {
    await load('');
    const at = +arg('--cover'), out = path.resolve(VD, arg('--cover-out', 'cover.jpg'));
    if (!Number.isFinite(at)) { console.error('--cover needs a time: --cover 1.9'); process.exit(1); }
    await page.eval(`setT(${at})`); await page.shot(out, 95);
    console.log('cover', at + 's →', out);
  }
} finally { if (page) await page.close(); }

// ---------- mix ----------
if (has('--mix')) {
  if (!fs.existsSync(V('vo.wav'))) { console.error('no vo.wav: run build-timeline.mjs'); process.exit(1); }
  if (!fs.existsSync(V('cues.json'))) { console.error('no cues.json: run any page step first (--stills or --full)'); process.exit(1); }
  const { total, cues, looks } = JSON.parse(fs.readFileSync(V('cues.json'), 'utf8'));
  const DUR = Math.min(total, +arg('--dur', total));
  const bed = arg('--bed') ? path.resolve(HERE, arg('--bed')) : V('bed.wav'), MV = +arg('--music', 0.17);
  if (!fs.existsSync(bed)) { console.error('no ' + bed + ': run node bed.mjs --video <dir> first'); process.exit(1); }
  // sfx: <video>\sfx, then the project's sfx\ (made by sounds.mjs)
  const sfx = name => [V('sfx', name + '.wav'), path.join(HERE, 'sfx', name + '.wav')].find(f => fs.existsSync(f));
  // the score obeys the film: low-passed + vinyl crackle in a warm window, low-passed + echo in a moody one
  const win = (a, b, fi, fo) => `min(1\\,max(0\\,(t-${a.toFixed(3)})/${fi}))*min(1\\,max(0\\,(${b.toFixed(3)}-t)/${fo}))`;
  const segs = { warm: [], moody: [] };
  looks.forEach(([t, m, d], i) => { if (segs[m]) { const nx = looks[i + 1]; segs[m].push({ a: Math.max(0, t), b: nx ? nx[0] : DUR + 1, fi: d, fo: nx ? nx[2] : 0.3 }); } });
  const W = s => s.length ? s.map(x => win(x.a, x.b, x.fi, x.fo)).join('+') : '0';
  const args = ['-i', V('vo.wav'), '-stream_loop', '-1', '-i', bed];
  const fc = [
    `[0:a]aformat=channel_layouts=stereo,atrim=0:${DUR.toFixed(3)},asplit=2[vo][sc]`,
    `[1:a]aformat=sample_rates=48000:channel_layouts=stereo,atrim=0:${DUR.toFixed(3)},asplit=3[mn][mw][md]`,
    `[mn]volume='max(0\\,1-(${W(segs.warm)})-(${W(segs.moody)}))':eval=frame[mn2]`,
    `[mw]lowpass=f=1500,highpass=f=70,acompressor=threshold=0.2:ratio=2,volume='0.9*(${W(segs.warm)})':eval=frame[mw2]`,
    `[md]lowpass=f=380,aecho=0.8:0.6:140|300:0.35|0.22,volume='1.5*(${W(segs.moody)})':eval=frame[md2]`,
    `[mn2][mw2][md2]amix=inputs=3:normalize=0,afade=t=out:st=${Math.max(0, DUR - 1.8).toFixed(3)}:d=1.8,volume=${MV}[m0]`,
    `[m0][sc]sidechaincompress=threshold=0.035:ratio=5:attack=15:release=300,asplit=2[m][mst]`,
  ];
  const all = cues.filter(c => c[1] < DUR);
  for (const s of segs.warm) if (s.a < DUR) all.push(['crackle', s.a, 0.22, Math.min(DUR, s.b) - s.a + 0.3, 0.4]);
  const ins = [];
  all.forEach(([f, t, g, len, fin]) => {
    const file = sfx(f); if (!file) { console.log('missing sfx', f); return; }
    const k = ins.length + 2; ins.push(k);
    if (f === 'crackle') args.push('-stream_loop', '-1');
    args.push('-i', file);
    const ms = Math.max(0, Math.round(t * 1000));
    const trim = len ? `atrim=0:${len.toFixed(3)},afade=t=out:st=${Math.max(0, len - (fin ? 0.35 : 0.12)).toFixed(3)}:d=${fin ? 0.35 : 0.12},` : '';
    fc.push(`[${k}:a]aformat=sample_rates=48000:channel_layouts=stereo,${trim}${fin ? `afade=t=in:d=${fin},` : ''}volume=${g},adelay=${ms}|${ms},apad=whole_dur=${DUR}[s${k}]`);
  });
  fc.push(`[vo][m]${ins.map(k => `[s${k}]`).join('')}amix=inputs=${ins.length + 2}:normalize=0:duration=first,atrim=0:${DUR}[pre]`);
  fs.writeFileSync(V('mix.filter'), fc.join(';\n'));
  ff([...args, '-filter_complex_script', V('mix.filter'), '-map', '[pre]', '-ar', '48000', '-ac', '2', V('pre.wav'), '-map', '[mst]', '-ar', '48000', '-ac', '2', V('bedstem.wav')]);
  // −14 LUFS integrated, true peak ≤ −1 dBTP (RULES 9): measured gain, then a 4x-oversampled limiter at −2 dBFS
  // (a plain linear loudnorm stops short when sfx peaks hit the ceiling); repeat until within 0.15 LU
  const target = -14; let g = target - loud(V('pre.wav')).I, L;
  for (let k = 0; k < 4; k++) {
    ff(['-i', V('pre.wav'), '-af', `volume=${g.toFixed(2)}dB,aresample=192000,alimiter=limit=0.794:attack=1:release=60:level=0,aresample=48000`, '-c:a', 'pcm_s16le', V('mix.wav')]);
    L = loud(V('mix.wav')); if (Math.abs(L.I - target) <= 0.15) break; g += target - L.I;
  }
  // how far the bed sits under the voice after ducking (RULES 9: about 13 LU; change it with --music)
  const lv = loud(V('vo.wav'), ['-t', String(DUR)]).I, lm = loud(V('bedstem.wav')).I;
  fs.rmSync(V('pre.wav'), { force: true }); fs.rmSync(V('bedstem.wav'), { force: true });
  console.log(`mix.wav ${DUR.toFixed(2)} s · ${L.I} LUFS · true peak ${L.TP} dBFS · bed ${(lv - lm).toFixed(1)} LU under the voice (--music ${MV}) · ${ins.length} sfx`);
}

// ---------- encode ----------
if (has('--encode')) {
  const frames = fs.existsSync(V('frames')) ? fs.readdirSync(V('frames')).filter(f => /^f\d{5}\.jpg$/.test(f)).sort() : [];
  let n = 0; while (n < frames.length && frames[n] === `f${String(n).padStart(5, '0')}.jpg`) n++;
  if (!n) { console.error('no frames: run --full'); process.exit(1); }
  if (!fs.existsSync(V('mix.wav'))) { console.error('no mix.wav: run --mix'); process.exit(1); }
  const out = path.resolve(VD, arg('--out', 'video.mp4'));
  const mixDur = +probe(V('mix.wav')).format.duration;
  if (Math.abs(mixDur - n / FPS) > 0.2) console.log(`note: ${n} frames = ${(n / FPS).toFixed(2)} s but mix.wav is ${mixDur.toFixed(2)} s; the shorter one wins (use --mix --dur for a test clip)`);
  ff(['-framerate', String(FPS), '-i', V('frames', 'f%05d.jpg'), '-i', V('mix.wav'), '-map', '0:v', '-map', '1:a', '-frames:v', String(n),
    // Chrome's JPEG frames are full-range BT.601: convert to limited-range BT.709 yuv420p, tagged, so players don't wash it out
    '-c:v', 'libx264', '-preset', 'slow', '-profile:v', 'high', '-vf', 'scale=1080:1920:flags=lanczos:in_range=full:out_range=limited:in_color_matrix=bt601:out_color_matrix=bt709,format=yuv420p,setparams=range=tv:color_primaries=bt709:color_trc=bt709:colorspace=bt709,setsar=1',
    '-pix_fmt', 'yuv420p', '-color_range', 'tv', '-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709', '-r', String(FPS),
    '-crf', '20', '-maxrate', '10M', '-bufsize', '20M', '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-shortest', '-movflags', '+faststart', out]);
  const P = probe(out), v = P.streams.find(s => s.codec_type === 'video'), a = P.streams.find(s => s.codec_type === 'audio');
  const mb = +P.format.size / 1048576, dur = +P.format.duration, L = loud(out);
  console.log(`${out}\n  ${v.codec_name} ${v.profile} ${v.pix_fmt} ${v.width}x${v.height} ${v.r_frame_rate} fps · ${a ? `${a.codec_name} ${a.sample_rate} Hz ${a.channels} ch` : 'NO AUDIO'} · ${dur.toFixed(2)} s · ${mb.toFixed(1)} MB · ${(+P.format.bit_rate / 1e6).toFixed(1)} Mb/s · ${L.I} LUFS · TP ${L.TP} dBFS`);
  if (mb > 60) console.log('  WARNING: over 60 MB (RULES 10)');
  if (dur > 60.05) console.log('  WARNING: over 60 s (RULES 10)');
  if (!(L.I > -15 && L.I < -13)) console.log('  WARNING: loudness is not −14 LUFS ±1');
}
```

### `check.mjs`

```js
// Cutout · FINAL CHECKS on a finished file:
//   node check.mjs --video <dir> [--file video.mp4] [--whisper]   (--whisper needs Python + `pip install faster-whisper`)
// Prints the encode facts against RULES (H.264 High, yuv420p, 1080x1920, 30 fps, AAC, ≤ 60 MB, ≤ 60 s, −14 LUFS,
// TP ≤ −1), checks that frame 0 isn't black or empty, pulls 5 random frames with the safe zone drawn on them
// (<dir>\check\frames.jpg, look at it), and with --whisper transcribes the audio and diffs it against the script.
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { execFileSync, spawnSync } from 'node:child_process';
import { loadScript, spoken, videoDir } from './engine/markup.mjs';
try { os.setPriority(os.constants.priority.PRIORITY_LOW); } catch { }
const HERE = import.meta.dirname, FF = 'ffmpeg', FP = 'ffprobe';
const PY = process.env.PYTHON || (process.platform === 'win32' ? 'python' : 'python3');
const arg = (k, d) => { const i = process.argv.indexOf(k); return i > 0 ? process.argv[i + 1] : d; };
const { abs: VD } = videoDir(HERE);
const file = path.resolve(VD, arg('--file', 'video.mp4'));
if (!fs.existsSync(file)) { console.error('no ' + file); process.exit(1); }
const out = path.join(VD, 'check'); fs.mkdirSync(out, { recursive: true });
const ok = (c, s) => console.log(`${c ? ' ok ' : 'FAIL'}  ${s}`);

const P = JSON.parse(execFileSync(FP, ['-v', 'error', '-show_entries', 'format=duration,size:stream=codec_type,codec_name,profile,pix_fmt,width,height,r_frame_rate,sample_rate', '-of', 'json', file], { encoding: 'utf8' }));
const v = P.streams.find(s => s.codec_type === 'video'), a = P.streams.find(s => s.codec_type === 'audio');
const dur = +P.format.duration, mb = +P.format.size / 1048576;
ok(v.codec_name === 'h264' && v.profile === 'High' && v.pix_fmt === 'yuv420p', `video ${v.codec_name} ${v.profile} ${v.pix_fmt}`);
ok(v.width === 1080 && v.height === 1920 && v.r_frame_rate === '30/1', `${v.width}x${v.height} @ ${v.r_frame_rate}`);
ok(a && a.codec_name === 'aac', `audio ${a ? a.codec_name + ' ' + a.sample_rate + ' Hz' : 'missing'}`);
ok(dur <= 60.05, `length ${dur.toFixed(2)} s (≤ 60)`);
ok(mb <= 60, `size ${mb.toFixed(1)} MB (≤ 60)`);
const e = spawnSync(FF, ['-hide_banner', '-nostats', '-i', file, '-af', 'ebur128=peak=true', '-f', 'null', '-'], { encoding: 'utf8' }).stderr;
const sum = e.slice(e.lastIndexOf('Summary:')), I = +(sum.match(/I:\s+(-?[\d.]+) LUFS/) || [])[1], TP = +(sum.match(/Peak:\s+(-?[\d.]+) dBFS/) || [])[1];
ok(I > -15 && I < -13, `loudness ${I} LUFS (−14 ±1)`);
ok(TP <= -1, `true peak ${TP} dBFS (≤ −1)`);
// frame 0: not black (mean luma) and not flat (darkest vs brightest pixels); saved as <dir>/check/frame0.jpg to look at
execFileSync(FF, ['-y', '-v', 'error', '-i', file, '-frames:v', '1', path.join(out, 'frame0.jpg')]);
const st = spawnSync(FF, ['-hide_banner', '-i', file, '-vf', 'select=eq(n\\,0),signalstats,metadata=print', '-frames:v', '1', '-f', 'null', '-'], { encoding: 'utf8' }).stderr;
const yavg = +(st.match(/YAVG=([\d.]+)/) || [])[1], ymin = +(st.match(/YMIN=([\d.]+)/) || [])[1], ymax = +(st.match(/YMAX=([\d.]+)/) || [])[1];
ok(yavg > 10 && ymax - ymin > 100, `frame 0 not black/empty (mean luma ${yavg.toFixed(0)}, range ${ymin}–${ymax}; look at ${path.join(out, 'frame0.jpg')})`);
// 5 random frames with the safe zone drawn (cyan: all text x 60–960 · y 250–1480; yellow: the caption band)
const r = (() => { let s = Math.round(mb * 1000) >>> 0; return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; })();
const ts = Array.from({ length: 5 }, () => +(0.5 + r() * (dur - 1)).toFixed(2)).sort((x, y) => x - y);
ts.forEach((t, i) => execFileSync(FF, ['-y', '-v', 'error', '-ss', String(t), '-i', file, '-frames:v', '1', '-vf', 'drawbox=x=60:y=250:w=900:h=1230:color=cyan@0.9:t=4,drawbox=x=60:y=1150:w=900:h=330:color=yellow@0.8:t=3', path.join(out, `r${i}.jpg`)]));
execFileSync(FF, ['-y', '-v', 'error', '-i', path.join(out, 'r%d.jpg'), '-vf', 'scale=270:480,tile=5x1:padding=6:color=0x444444', '-frames:v', '1', path.join(out, 'frames.jpg')]);
console.log(`look at ${path.join(out, 'frames.jpg')} (frames at ${ts.join(', ')} s): captions and labels must sit inside the cyan box`);

if (process.argv.includes('--whisper')) {
  const S = await loadScript(VD);
  const wav = path.join(out, 'audio16k.wav'), js = path.join(out, 'whisper.json');
  execFileSync(FF, ['-y', '-v', 'error', '-i', file, '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', wav]);
  const p = spawnSync(PY, [path.join(HERE, 'whisper.py'), wav, js], { encoding: 'utf8', env: { ...process.env, PYTHONIOENCODING: 'utf-8' } });
  if (p.status) { console.log('whisper could not run (needs Python + `pip install faster-whisper`), so listen to the video instead:', (p.stderr || '').slice(-300)); process.exit(0); }
  const heard = JSON.parse(fs.readFileSync(js, 'utf8')).segments.map(s => s.text).join(' ');
  const W = s => s.toLowerCase().replace(/[’']/g, '').replace(/[^a-z0-9]+/g, ' ').trim().split(' ').filter(Boolean);
  // the script's words spoken within this file's length (a short test clip is compared with its part of the script)
  const TLf = path.join(VD, 'timeline.json');
  const said = fs.existsSync(TLf) ? JSON.parse(fs.readFileSync(TLf, 'utf8')).sections.flatMap(s => s.tokens).filter(k => k.t0 < dur - 0.15).map(k => k.said).join(' ') : S.ORDER.map(id => spoken(S.TAKES[id])).join(' ');
  const A = W(said), B = W(heard);
  // word diff (LCS)
  const L = Array.from({ length: A.length + 1 }, () => new Int16Array(B.length + 1));
  for (let i = A.length - 1; i >= 0; i--) for (let j = B.length - 1; j >= 0; j--) L[i][j] = A[i] === B[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
  const diff = []; let i = 0, j = 0, miss = [], extra = [], joined = 0;
  const flush = () => {   // "capcut" vs "cap cut" is the same word: count it, don't report it
    if (miss.length && miss.join('') === extra.join('')) joined += miss.length;
    else if (miss.length || extra.length) diff.push(`script "${miss.join(' ')}" → heard "${extra.join(' ')}"`);
    miss = []; extra = [];
  };
  while (i < A.length || j < B.length) {
    if (i < A.length && j < B.length && A[i] === B[j]) { flush(); i++; j++; }
    else if (j < B.length && (i >= A.length || L[i][j + 1] >= L[i + 1][j])) extra.push(B[j++]);
    else miss.push(A[i++]);
  }
  flush();
  fs.writeFileSync(path.join(out, 'whisper.txt'), heard.trim() + '\n');
  const got = L[0][0] + joined;
  ok(got / A.length > 0.95, `whisper: ${got}/${A.length} script words heard (${(100 * got / A.length).toFixed(1)} %)`);
  for (const d of diff) console.log('       ' + d);
  console.log('       (numbers and spellings like "1" vs "one" show up here too: judge them by ear)');
}
```

### `whisper.py`

```python
# Transcribe one audio file with faster-whisper on the GPU (CPU if that fails).
#   python whisper.py <audio> <out.json>
# WHISPER_MODEL picks the model (default: large-v3-turbo, downloaded once; 'small' is lighter).
import ctypes, json, os, site, sys

# Idle priority (Windows), so the machine stays usable.
try:
    ctypes.windll.kernel32.SetPriorityClass(ctypes.windll.kernel32.GetCurrentProcess(), 0x40)
except Exception:
    pass

# cuDNN/cuBLAS from the nvidia-* pip wheels.
for base in site.getsitepackages() + [site.getusersitepackages()]:
    for sub in ("nvidia/cudnn/bin", "nvidia/cublas/bin"):
        p = os.path.join(base, sub)
        if os.path.isdir(p):
            os.add_dll_directory(p)
            os.environ["PATH"] = p + os.pathsep + os.environ["PATH"]

import wave

import numpy as np
from faster_whisper import WhisperModel

audio, out = sys.argv[1], sys.argv[2]

# The 16 kHz mono WAV from ffmpeg, as samples: faster-whisper's own decoder (PyAV)
# breaks on av 19 (open() lost `metadata_errors`).
with wave.open(audio, "rb") as w:
    pcm = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(np.float32) / 32768.0
name = os.environ.get("WHISPER_MODEL", "large-v3-turbo")


def run(device, compute):
    model = WhisperModel(name, device=device, compute_type=compute, cpu_threads=2)
    # Not conditioning on earlier text stops loops like "Go! Go! Go!" over music.
    segments, info = model.transcribe(pcm, beam_size=5, vad_filter=True, condition_on_previous_text=False)
    end = len(pcm) / 16000
    segs = [{"start": round(s.start, 2), "end": round(min(s.end, end), 2), "text": s.text} for s in segments if s.start < end - 0.2]
    return segs, info


try:
    # int8 weights, float32 maths: GTX 16-series cards are unreliable in float16.
    segs, info = run("cuda", "int8_float32")
    device = "GPU"
except Exception as e:
    print(f"GPU failed ({e}); using the CPU", file=sys.stderr)
    segs, info = run("cpu", "int8")
    device = "CPU"

with open(out, "w", encoding="utf-8") as f:
    json.dump({"model": name, "device": device, "language": info.language, "probability": info.language_probability, "segments": segs}, f, ensure_ascii=False, indent=1)
```


## Appendix B: an example video (complete)

"Never pay for a Claude content skill again" (52 s, 11 sections, 12 scenes). Copy it to start a new video, then rewrite the takes and every beat for your script. Its end card shows `assets/mark.webp` if you put your own logo there.

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

```js
// CUTOUT · "Never pay for a Claude content skill again" — the standard script, reworked in the reference's rhythm
// (@the_marginal_perspective, "Is AI making us less intelligent?": a "What if the biggest problem with X isn't A, but B?"
// hook, a fact, "but it raises a question", "are we A, or just B?", then the product, its features, "here's the best part",
// and the ask). Markup: /  a new caption block   *word*  a highlight pill (colour from F.cap.pill in scenes.js)
//   {shown|said}  the caption shows one thing, the voice says another
export const VOICE = 'iP95p4xoKVk53GoZ742B';   // ElevenLabs "Chris": charming, down-to-earth American male (reference: male, F0 ~110–145 Hz, ~170 wpm, calm explainer)
export const MODEL = 'eleven_multilingual_v2';
export const SETTINGS = { stability: 0.5, similarity_boost: 0.8, style: 0.28, use_speaker_boost: true };
export const SPEED = 0.95;
export const ORDER = ['s0', 's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8', 's9', 'cta'];
export const GAP = { s1: 0.6, s2: 0.42, s3: 0.45, s4: 0.45, s5: 0.55, s6: 0.42, s7: 0.5, s8: 0.45, s9: 0.6, cta: 0.42 };
export const LEAD = 0.2, HOLD = 2.0;
export const TAKES = {
  s0: "What if the biggest problem with Claude content skills isn't paying for them, / but paying for one that was given away for free?",
  s1: "Recently, I gave away my own content format / and its {SKILL.md,|skill dot M D,} completely free.",
  s2: "And people repackaged it, / and started selling it for {$30.|thirty dollars.}",
  s3: "{$30,|Thirty dollars,} for something I gave away for free.",
  s4: "Are we paying for a better skill, / or just for a text file?",
  s5: "So here's my response. / I'm Casper, and I built Morphy, an agentic super app.",
  s6: "Inside it, I'm building an entire library / of ready-to-use content-format {.md|dot M D} files.",
  s7: "No paywalls. No subscriptions. / No strings attached.",
  s8: "Because you shouldn't pay for a text file / that was free to begin with.",
  s9: "Here's the best part. / This {format's|format is} called *{cutout.md.|cutout dot M D.}*",
  cta: "Never pay for a content skill again. / Comment *{CUTOUT|cutout,}* and I'll send it to you. / Completely *free.*",
};
```

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

```js
// CUTOUT · "Never pay for a Claude content skill again" — the scenes, beat by beat on the spoken words, in the reference's
// order: graph paper → a circle wipe to charcoal → a chat in a phone that zooms out → a halftone sticker with a red stamp →
// a beam → app tiles wired to the file → a dark price card → a plexus file + a meter → Question / bulb + dial / whip pan →
// a laptop → an app tile + bracket boxes → black: a line-art lock → a bokeh "zen" space with big italic serif → white:
// captions with highlighter pills → the wordmark.
const { S, T0, T1, END, scene, cue, div, add, h, set, pop, rise, clamp, lerp, eo, eio, ease, back, rng, esc } = F;
const K = F.cut;
const st = id => S[id].start - 0.06;
const C = { full: true, din: 0.14, dout: 0.12, push: 0.03 };
const W0 = st('s0');

// ---------- looks, camera, captions ----------
const tWipe = T0('s0', 'skills') - 0.02;           // the charcoal circle has covered the frame
const tZoom = T0('s0', 'one') - 0.12;               // the phone zooms out onto paper
const tSell = T0('s2', 'selling') - 0.05;
const tDim = T1('s6', 'files') + 0.35;
const tEnd = T1('cta', 'free') + 0.5;
F.looks([[-1, 'paper'], [tWipe, 'char', 0.04], [tZoom + 0.24, 'paper', 0.12], [st('s2'), 'char', 0.12], [tSell, 'black', 0.18], [st('s3'), 'paper', 0.12],
  [tDim, 'black', 0.55], [st('s9'), 'white', 0.25]]);
// the graph paper moves with the camera: [t, scale, x, y] keys, eased between
const CAM = [[0, 1, 0, 0], [st('s1'), 1.06, 30, 40], [T0('s1', 'and') - 0.1, 1.06, 30, 40], [T0('s1', 'and') + 0.5, 1.0, -40, -260], [st('s2'), 1, 0, 0],
  [T0('s2', 'repackaged'), 1, 0, 0], [tSell, 1.2, 0, -40], [st('s3'), 1, 0, 0], [st('s4'), 1.02, 0, 0], [T0('s4', 'or') - 0.04, 1.02, 0, 0], [T0('s4', 'or') + 0.26, 1.02, 520, 0],
  [st('s5'), 1, 0, 0], [T0('s5', 'Morphy') - 0.2, 1, 0, 0], [T0('s5', 'Morphy') + 0.5, 1.12, 0, 0], [st('s6'), 1, 0, 0], [END, 1.04, 0, 0]];
const camAt = t => { let i = 0; while (i + 1 < CAM.length && t >= CAM[i + 1][0]) i++; const a = CAM[i], b = CAM[i + 1] || a; const u = b === a ? 0 : eio(clamp((t - a[0]) / Math.max(0.01, b[0] - a[0]))); return { s: lerp(a[1], b[1], u), x: lerp(a[2], b[2], u), y: lerp(a[3], b[3], u) }; };
// hard cuts stay hard: inside a cut, the camera jumps
F.cam = t => camAt(t);
F.cap.pos = p => (p.s.id === 's9' || p.s.id === 'cta') ? { top: 830 } : null;
F.cap.pill = tk => /^cutout\.md/i.test(tk.d) ? 'lav' : /^CUTOUT/.test(tk.d) ? 'lime' : /^free/i.test(tk.d) ? 'green' : 'lime';
F.cap.end = tEnd;

// stickers made once (canvas): the SKILL.md file (big and small), a price tag, a bag with one sheet, the question mark
const DOC = K.sticker(K.paintDoc({ title: false, tone: 0.42 }), 380, 486, { ht: { cell: 5.6 }, over: K.inkText([['SKILL.md', 0.1, 0.19, 0.15]]) });
const docCopy = (s) => { const c = K.canvas(s.w, s.h); c.getContext('2d').drawImage(s.canvas, 0, 0); return { ...s, canvas: c }; };
const DOC_S = K.sticker(K.paintDoc({ title: false, tone: 0.5, lines: 6 }), 190, 244, { ht: { cell: 4.2 }, border: 9, depth: 8, over: K.inkText([['SKILL.md', 0.1, 0.19, 0.15]]) });
const BAG = K.sticker(K.paintBag({ label: false, price: false }), 380, 470, { ht: { cell: 5.6 }, over: K.inkText([['.txt', 0.43, 0.165, 0.085, 800, '#111', 'center', -0.12], ['$30', 0.5, 0.79, 0.15, 800, '#f4f4f4', 'center']]) });
const Q = K.sticker(K.paintQ, 220, 300, { ht: false, border: 12, depth: 7 });

// ---------- 1 · "What if the biggest problem with Claude content skills" — paper, a blurred grey spark; a charcoal circle wipes up
{
  const sc = scene(-1, tWipe + 0.08, { ...C, din: 0.01, dout: 0.04 });
  const sp = div(sc.el, 'k-abs', 470, 840, 220, 220, F.spark(220, '#7c7c7c'));
  const blob = div(sc.el, 'k-abs', 0, 0, 120, 74, '', { background: '#151515', borderRadius: '50%', filter: 'blur(2px)' });
  const circ = div(sc.el, 'k-abs', 0, 0, 100, 100, '', { background: '#363636', borderRadius: '50%', boxShadow: '0 0 40px 30px #363636' });
  const tB = T0('s0', 'with') - 0.1, tC = T0('s0', 'Claude') + 0.02;
  sc.on(t => {
    const fl = Math.sin(t * 1.3);
    set(sp, 1, `translate(${(-30 * t).toFixed(1)}px, ${(fl * 18 + 40 * t).toFixed(1)}px) rotate(${(t * 40).toFixed(1)}deg) scale(${(0.82 + 0.05 * t).toFixed(3)})`, 7);
    // the ink blob streaks in from the left toward the spark, then the circle rises from the bottom
    const u = clamp((t - tB) / 0.4);
    set(blob, u > 0 && u < 1 ? 1 : 0, `translate(${lerp(-160, 400, eo(u)).toFixed(1)}px, ${lerp(1150, 900, eo(u)).toFixed(1)}px) rotate(-18deg) scale(${lerp(0.7, 1.1, u).toFixed(2)}, ${lerp(0.7, 1.1, u).toFixed(2)})`, (1 - u) * 6);
    const v = eio(clamp((t - tC) / (tWipe - tC)));
    const r = lerp(0, 2300, v);
    set(circ, v > 0 ? 1 : 0, `translate(${(540 - 50).toFixed(1)}px, ${(2350 - 50).toFixed(1)}px) scale(${(r / 50).toFixed(3)})`);
  });
  cue('swish', tB, 0.08); cue('whoosh', tC, 0.1);
}

// ---------- 2 · "isn't paying for them, but paying for one that was given away for free?" — charcoal: a chat on a phone;
//   the phone zooms out onto paper, the SKILL.md sticker flips in, a red FREE stamp slams on it
{
  const sc = scene(tWipe, st('s1'), { ...C, din: 0.01 });
  const ph = K.phone(sc.el, 540, 1140, 1180, 2380);
  const scr = ph.screen;   // 1098 × 2298, its (0,0) = phone (41, 41)
  const ox = 540 - 590 + 41, oy = 1140 - 1190 + 41;   // screen origin in film coordinates at scale 1
  const sx = x => x - ox, sy = y => y - oy;
  const bub = div(scr, 'k-bub', sx(470), sy(690), null, null, 'Write me a viral reel script.');
  const rep = div(scr, 'k-reply', sx(100), sy(800), 800, null, '');
  const lst = div(scr, 'k-reply', sx(100), sy(780), 820, null, '');
  const bar = div(scr, 'k-input', sx(90), sy(1340), 830, 96, `${K.G.plus(40, '#ddd')}${K.G.mic(36, '#ddd')}`);
  const doc = K.stick(sc.el, DOC, 540, 1090);
  const stamp = div(sc.el, 'k-abs', 540 - 170, 1090 - 170 + 90, 340, 340, '<div style="position:absolute;inset:0;border-radius:50%;border:22px solid rgba(224,53,43,0.86)"></div><div style="position:absolute;left:0;right:0;top:118px;text-align:center;font:900 92px/1 Outfit,sans-serif;letter-spacing:.04em;color:rgba(224,53,43,0.9)">FREE</div>', { filter: 'drop-shadow(0 0 10px rgba(224,53,43,0.35))' });
  const tR = T0('s0', 'isn\'t'), tB = T0('s0', 'but'), tG = T0('s0', 'given') - 0.08, tF = T0('s0', 'free?');
  const reply = "Sorry, you'll need the $30 skill for that.";
  const list = ['1. Buy the Viral Reels SKILL.md: $30', '2. Paste it into Claude', '3. Hope it works'];
  sc.on(t => {
    // the chat (inside the phone)
    rise(bub, t, tWipe + 0.12, { dy: 30 });
    bub.style.marginTop = (-110 * eio(clamp((t - tB) / 0.3))).toFixed(1) + 'px';
    F.typeIn(rep, reply, t, tR, tR + 0.75, false);
    set(rep, 1 - clamp((t - tB) / 0.15));
    set(lst, clamp((t - tB) / 0.12));
    const n = clamp((t - tB) / 0.55) * list.length;
    F.html(lst, list.map((s, i) => `<div style="margin-top:22px;opacity:${clamp(n - i).toFixed(2)}">${esc(s)}</div>`).join(''));
    set(bar, 1);
    // the zoom out: the chat sits on the charcoal graph paper until the zoom, then the screen fills in as the paper turns light
    const z = eio(clamp((t - tZoom) / 0.5)), zb = eo(clamp((t - tG) / 0.5));
    scr.style.background = `rgba(54,54,54,${clamp((t - tZoom) / 0.12).toFixed(3)})`;
    const s = lerp(1, 0.5, z) * lerp(1, 0.78, zb);
    set(ph.el, 1, `translate(0px, ${(z * 40).toFixed(1)}px) scale(${s.toFixed(4)})`, zb * 7);
    ph.el.style.transformOrigin = '590px 1190px';
    // the sticker flips in (rotateY 90 → 0) on "given", the stamp slams on "free"
    const f = clamp((t - tG) / 0.4);
    K.place(doc, f > 0 ? clamp(f * 3) : 0, { ry: (1 - eo(f)) * 88, s: lerp(0.86, 1, back(f, 1.4)), y: 30 });
    const k = clamp((t - (tF - 0.04)) / 0.16);
    set(stamp, k > 0 ? clamp(k * 2) : 0, `rotate(-14deg) scale(${lerp(1.6, 1, eo(k)).toFixed(3)})`, (1 - k) * 8);
  });
  cue('pop', tWipe + 0.12, 0.07); cue('typing', tR, 0.06, 0.7); cue('swish', tZoom, 0.08); cue('pop', tG + 0.05, 0.1); cue('stamp', tF, 0.16); cue('thump', tF, 0.12);
  F.shake(tF + 0.02, 10, 0.25);
}

// ---------- 3 · "Recently, I gave away my own content format, and its SKILL.md, completely free." — the sticker under a
//   grey light beam; the camera swings so the beam points up, and copies of the file fly out along it
{
  const sc = scene(st('s1'), st('s2'), C);
  const beam = div(sc.el, 'k-abs', 0, 0, 1400, 900, '', { background: 'linear-gradient(90deg, rgba(48,48,48,0.88), rgba(48,48,48,0.55) 60%, rgba(48,48,48,0.25))', clipPath: 'polygon(0 44%, 100% 0, 100% 100%, 0 56%)', filter: 'blur(5px)', transformOrigin: '0 450px' });
  const doc = K.stick(sc.el, docCopy(DOC), 0, 0);
  const copies = [0, 1, 2, 3].map(() => K.stick(sc.el, docCopy(DOC_S), 0, 0));
  const tA = T0('s1', 'and') - 0.1, tCF = T0('s1', 'completely') - 0.05;
  sc.on(t => {
    const u = eio(clamp((t - tA) / 0.6));
    // the file: big at the left with the beam to the right → small at the bottom with the beam up
    const x = lerp(380, 540, u), y = lerp(1060, 1290, u), s = lerp(1.18, 0.8, u), ang = lerp(0, -90, u);
    K.place(doc, 1, { x: x - 540, y: y - 960, s, r: lerp(-4, 6, u) });
    doc.style.left = (540 - DOC.w / 2) + 'px'; doc.style.top = (960 - DOC.h / 2) + 'px';
    set(beam, clamp((t - st('s1')) / 0.3) * 0.95, `translate(${(x + 60 * (1 - u)).toFixed(1)}px, ${(y - 450 - 40 * u).toFixed(1)}px) rotate(${ang.toFixed(1)}deg) scaleY(${lerp(1, 0.75, u).toFixed(3)})`);
    // copies fly out along the beam on "completely free"
    copies.forEach((c, i) => {
      const a = clamp((t - tCF - i * 0.16) / 0.9);
      c.style.left = (540 - DOC_S.w / 2) + 'px'; c.style.top = (960 - DOC_S.h / 2) + 'px';
      K.place(c, a > 0 && a < 1 ? Math.min(1, a * 5) * (1 - clamp((a - 0.75) / 0.25)) : 0, { x: lerp(0, (i - 1.5) * 150, eo(a)), y: lerp(1290 - 960, 640 - 960 + (i % 2) * 70, eo(a)), s: lerp(0.5, 0.9, a), r: (i - 1.5) * 9 * a });
    });
  });
  cue('whoosh', tA, 0.07); [0, 1, 2, 3].forEach(i => cue('pop', tCF + i * 0.16, 0.06));
}

// ---------- 4 · "And people repackaged it, and started selling it for $30." — charcoal: shop tiles wired to the file;
//   then black: the price card zooms in and its price counts up
{
  const sc = scene(st('s2'), tSell + 0.04, { ...C, push: 0.0 });
  const hub = K.stick(sc.el, docCopy(DOC_S), 540, 1060);
  const T = [[250, 790, 170, '#e8613c', K.G.bag(92)], [835, 760, 210, '#f7f7f7', K.G.star(120)], [215, 1360, 200, '#f7f7f7', K.G.cart(110)], [850, 1330, 170, '#0f0f0f', K.G.tag(86)]];
  const tiles = T.map(([x, y, s, bg, g]) => K.tile(sc.el, x, y, s, bg, g));
  const cab = K.cables(sc.el, T.map(([x, y], i) => [x, y, 540, 1060, i % 2 ? -90 : 90]));
  sc.el.insertBefore(cab.el, sc.el.firstChild);
  const tP = T0('s2', 'repackaged') - 0.05;
  sc.on(t => {
    const z = eio(clamp((t - st('s2')) / (tSell - st('s2'))));
    sc.el.style.transformOrigin = '540px 1060px';
    tiles.forEach((e, i) => {
      const a = clamp((t - st('s2') - i * 0.07) / 0.3);
      const d = Math.hypot(T[i][0] - 540, T[i][1] - 1060) / 500;
      set(e, a, `scale(${(back(a, 1.6) * (1 + 0.2 * z * d)).toFixed(3)})`, (1 - a) * 8 + z * 9 * d);
    });
    cab.draw(T.map((_, i) => eo(clamp((t - tP - i * 0.08) / 0.5))));
    K.place(hub, clamp((t - st('s2') - 0.1) / 0.2), { s: 0.95 + 0.25 * z });
  });
  T.forEach((_, i) => cue('pop', st('s2') + i * 0.07, 0.05)); cue('swish', tP, 0.06);
}
{
  const sc = scene(tSell, st('s3'), { ...C, din: 0.18, push: 0 });
  const card = K.price(sc.el, 540 - 290, 760, 580, { name: 'Viral Reels Skill', price: '$30.00', per: '/ one-time', items: ['SKILL.md', 'One content format', '"Exclusive" pack', 'Instant download'], button: 'Buy now' });
  const priceEl = card.children[1].firstChild;
  const t30 = T0('s2', '$30');
  sc.on(t => {
    const u = eio(clamp((t - tSell) / (st('s3') - tSell)));
    card.style.transformOrigin = '290px 300px';
    set(card, 1, `translate(${lerp(0, -30, u).toFixed(1)}px, ${lerp(30, -20, u).toFixed(1)}px) scale(${lerp(0.82, 1.42, u).toFixed(4)})`);
    const v = eo(clamp((t - (t30 - 0.05)) / 0.45));
    priceEl.nodeValue = '$' + (30 * v).toFixed(2);
  });
  cue('whoosh', tSell, 0.07); for (let i = 0; i < 6; i++) cue('tick', t30 + i * 0.07, 0.05); cue('thump', t30 + 0.45, 0.12);
}

// ---------- 5 · "$30, for something I gave away for free." — paper: a wireframe plexus file and a yellow "Price" meter
//   that fills to $30 … and drops to $0 on "free"
{
  const sc = scene(st('s3'), st('s4'), C);
  const px = K.plexus(sc.el, 420, 1060, 430, 550, 11);
  const m = K.meter(sc.el, 790, 820, 520, 'Price');
  const t30 = T0('s3', '$30'), tF = T0('s3', 'free');
  sc.on(t => {
    const a = clamp((t - st('s3')) / 0.4);
    set(px, a, `perspective(1600px) rotateY(${(Math.sin(t * 0.8) * 14 - 8).toFixed(2)}deg) rotateX(${(Math.cos(t * 0.6) * 5).toFixed(2)}deg) scale(${lerp(0.9, 1.06, (t - st('s3')) / 3).toFixed(3)})`, (1 - a) * 10);
    set(m.el, clamp((t - st('s3') - 0.15) / 0.3));
    const up = eo(clamp((t - t30) / 0.55)), dn = eio(clamp((t - tF) / 0.45));
    const v = up * lerp(1, 0.035, dn);
    m.draw(v, t < tF ? `$${Math.round(30 * up)}` : `$${Math.round(30 * (1 - dn))}`);
  });
  cue('riser', t30 - 0.1, 0.05, 0.6); cue('implode', tF, 0.08);
}

// ---------- 6 · "Are we paying for a better skill, or just for a text file?" — Question card with a chrome "?";
//   a bulb brightens as its dial turns; a whip pan to a shopping bag holding one sheet of paper
{
  const sc = scene(st('s4'), st('s5'), C);
  const lab = div(sc.el, 'k-label', 0, 640, 1080, null, 'Question', { textAlign: 'center', paddingRight: '60px', fontSize: '50px', color: '#4a4a4a' });
  const card = div(sc.el, 'k-abs', 510 - 280, 710, 560, 420, '<div style="position:absolute;left:26px;bottom:26px;width:26px;height:5px;background:#f4d23e"></div>', { borderRadius: '40px', background: '#3e3e3e', boxShadow: '0 30px 60px rgba(0,0,0,0.22)' });
  const q = K.stick(sc.el, Q, 510, 920);
  const bulb = K.bulb(sc.el, 440, 900, 0.95), dial = K.dial(sc.el, 680, 1190, 0.95);
  const bag = K.stick(sc.el, BAG, 520, 1050);
  const tP = T0('s4', 'paying'), tB = T0('s4', 'better') - 0.12, tO = T0('s4', 'or') - 0.04, tT = T0('s4', 'text');
  sc.on(t => {
    // the question card, then it drops away for the bulb
    const qa = clamp((t - st('s4')) / 0.25), out = eio(clamp((t - tB) / 0.35));
    set(lab, qa * (1 - out), `translateY(${(out * 700).toFixed(1)}px)`);
    set(card, qa * (1 - out), `translateY(${(out * 700).toFixed(1)}px) scale(${lerp(0.92, 1, eo(qa)).toFixed(3)})`, (1 - qa) * 8);
    const qq = clamp((t - tP) / 0.3);
    K.place(q, qq * (1 - out), { y: out * 700, s: lerp(0.5, 1, back(qq, 1.8)), r: Math.sin(t * 2) * 3 });
    // the bulb + dial come down with the camera; the dial turns, the bulb glows; then the whip pan left
    const b = eio(clamp((t - tB) / 0.35)), w = eio(clamp((t - tO) / 0.28));
    const v = eo(clamp((t - tB - 0.15) / 0.7));
    set(bulb.el, b, `translate(${(-1100 * w).toFixed(1)}px, ${((1 - b) * -600).toFixed(1)}px)`, w * 18 * (1 - w) * 4);
    set(dial.el, b, `translate(${(-1100 * w).toFixed(1)}px, ${((1 - b) * -600).toFixed(1)}px)`, w * 18 * (1 - w) * 4);
    bulb.draw(v * (0.25 + 0.75 * clamp((t - tB) / 0.9))); dial.draw(v * 0.85);
    // the bag slides in from the right
    const g = eio(clamp((t - tO - 0.04) / 0.3));
    K.place(bag, g > 0 ? 1 : 0, { x: (1 - g) * 1100, s: 1 + 0.04 * Math.sin((t - tT) * 3) * (t > tT ? 1 : 0), r: (1 - g) * 8 - 2, blur: (1 - g) * g * 4 * 14 });
  });
  cue('pop', st('s4') + 0.02, 0.06); cue('pop', tP, 0.08); cue('whoosh', tB, 0.06); cue('click', tB + 0.2, 0.06); cue('click', tB + 0.45, 0.05); cue('swish', tO, 0.1);
}

// ---------- 7 · "So here's my response. I'm Casper, and I built Morphy, an agentic super app." — a laptop with a blurred
//   site that comes into focus on "Morphy" as the laptop grows
{
  const sc = scene(st('s5'), st('s6'), C);
  const lp = K.laptop(sc.el, 510, 1080, 760);
  K.siteHero(lp.screen, { name: 'MORPHY', sub: 'An agentic super app.', w: 730 });
  const tM = T0('s5', 'Morphy') - 0.1, tA = T0('s5', 'agentic');
  sc.on(t => {
    const a = clamp((t - st('s5')) / 0.35), g = eio(clamp((t - tM) / 0.5));
    lp.el.style.transformOrigin = '380px 240px';
    set(lp.el, a, `translateY(${lerp(40, 0, eo(a)).toFixed(1)}px) scale(${lerp(0.62, 0.66, clamp((t - st('s5')) / 2)) * lerp(1, 1.62, g)})`, (1 - a) * 8);
    lp.screen.style.filter = g < 0.98 ? `blur(${lerp(9, 0, g).toFixed(1)}px)` : 'none';
    lp.screen.style.opacity = (0.82 + 0.18 * g).toFixed(3);
  });
  cue('pop', st('s5') + 0.05, 0.05); cue('whoosh', tM, 0.06);
}

// ---------- 8 · "Inside it, I'm building an entire library of ready-to-use content-format .md files." — an app tile with
//   sparkles, then the LIBRARY wordmark and corner-bracket boxes that type file names with a cursor
{
  const sc = scene(st('s6'), st('s7'), C);
  const tile = K.tile(sc.el, 510, 900, 210, 'linear-gradient(160deg, #d6e0ea, #b9c7d4)', K.G.md(210));
  const sp = [[640, 790, 70], [690, 850, 40], [600, 760, 34]].map(([x, y, s]) => div(sc.el, 'k-abs', x - s / 2, y - s / 2, s, s, K.G.spark4(s, '#151515')));
  const wm = K.word(sc.el, 510, 690, 'LIBRARY', 104, '#1a1a1a');
  const names = ['kinetic.md', 'halftone.md', 'lantern.md'];
  const boxes = names.map((n, i) => K.brk(sc.el, 510, 850 + i * 160, 440, 104));
  const cur = F.cursor(sc.el);
  const tL = T0('s6', 'library'), tO = T0('s6', 'of') - 0.05;
  const tN = [T0('s6', 'ready'), T0('s6', 'content'), T0('s6', '.md')];
  let fitted = false;
  sc.on(t => {
    if (!fitted) { wm.fit(); fitted = true; }
    const a = clamp((t - st('s6')) / 0.3), o = eio(clamp((t - tO) / 0.3));
    set(tile, a * (1 - o), `translateY(${(-o * 120).toFixed(1)}px) scale(${(back(a, 1.7) * (1 + 0.04 * Math.sin(t * 2))).toFixed(3)})`, (1 - a) * 8 + o * 10);
    sp.forEach((e, i) => { const k = clamp((t - tL - i * 0.08) / 0.25); set(e, k * (1 - o), `rotate(${(k * 90 + t * 30).toFixed(1)}deg) scale(${back(k, 2).toFixed(3)})`); });
    set(wm.el, o, `translateY(${((1 - o) * 30).toFixed(1)}px)`, (1 - o) * 8);
    // the boxes: each opens on its word and types its file name
    let cx = 760, cy = 900;
    boxes.forEach((b, i) => {
      const k = clamp((t - tN[i] + 0.08) / 0.18), w = lerp(110, 440, eo(clamp((t - tN[i]) / 0.5)));
      set(b.el, k, null);
      b.el.style.width = w.toFixed(1) + 'px'; b.el.style.left = (510 - w / 2).toFixed(1) + 'px';
      F.typeIn(b.span, names[i], t, tN[i], tN[i] + 0.45, false);
      if (t >= tN[i] - 0.1) { cx = 510 + w / 2 + 6; cy = 850 + i * 160 + 98; }
    });
    F.drive(cur, t, [[tO + 0.05, 900, 1100], [tN[0], 735, 948], [tN[0] + 0.5, 735, 948], [tN[1], 735, 1108], [tN[1] + 0.5, 735, 1108], [tN[2], 735, 1268], [tN[2] + 0.6, 735, 1268]], tN.map(x => x + 0.02));
  });
  cue('pop', st('s6') + 0.03, 0.07); cue('chime', tL, 0.05); tN.forEach(x => { cue('click', x, 0.06); cue('typing', x + 0.02, 0.05, 0.45); });
}

// ---------- 9 · "No paywalls. No subscriptions. No strings attached." — black: a line-art lock on a navy grid; the shackle
//   opens, the input shows $0 / month, the wire it hangs from snaps
{
  const sc = scene(st('s7'), st('s8'), { ...C, din: 0.3 });
  const lock = K.lockPanel(sc.el, 510, 880, 560);
  const wm = K.word(sc.el, 510, 1150, 'LIBRARY', 84, '#f2f2f2');
  const inp = div(sc.el, 'k-input', 510 - 300, 1290, 600, 92, `${K.G.plus(36, '#ddd')}<span style="flex:1;text-align:center;font:400 38px/1 Outfit,sans-serif;color:#f0f0f0"></span>${K.G.mic(32, '#ddd')}`);
  const txt = inp.querySelector('span');
  const tP = T0('s7', 'paywalls'), tS = T0('s7', 'subscriptions'), tC = T0('s7', 'strings') + 0.1;
  let fitted = false;
  sc.on(t => {
    if (!fitted) { wm.fit(); fitted = true; }
    const a = clamp((t - st('s7')) / 0.45);
    set(lock.el, a, `scale(${lerp(0.94, 1.02, clamp((t - st('s7')) / 3.3)).toFixed(4)})`, (1 - a) * 10);
    set(wm.el, a); set(inp, a);
    lock.draw({ wire: eo(clamp((t - st('s7')) / 0.8)), open: eo(clamp((t - tP) / 0.35)), cut: clamp((t - tC) / 0.5) });
    F.typeIn(txt, '$0 / month', t, tS, tS + 0.45, true);
  });
  cue('click', tP + 0.05, 0.09); cue('typing', tS, 0.05, 0.45); cue('glitch', tC, 0.06); cue('implode', tC, 0.07);
}

// ---------- 10 · "Because you shouldn't pay for a text file that was free to begin with." — a calm bokeh space; big italic
//   serif words breathe in behind the captions: "a text file" … then "free / to begin with."
{
  const sc = scene(st('s8'), st('s9'), { ...C, din: 0.35 });
  const bk = K.bokeh(sc.el, 21);
  const l1 = div(sc.el, 'k-abs', 0, 820, 1020, null, 'a text file', { textAlign: 'center', font: "italic 500 150px/1 'EB Garamond', serif", color: '#d8d8d8', textShadow: '0 0 30px rgba(0,0,0,0.6)' });
  const l2 = div(sc.el, 'k-abs', 0, 790, 1020, null, 'free', { textAlign: 'center', font: "italic 500 250px/1 'EB Garamond', serif", color: '#ececec', textShadow: '0 0 40px rgba(0,0,0,0.6)' });
  const l3 = div(sc.el, 'k-abs', 0, 1080, 1020, null, 'to begin with.', { textAlign: 'center', font: "400 76px/1 'EB Garamond', serif", color: '#bdbdbd' });
  const tT = T0('s8', 'text') - 0.05, tF = T0('s8', 'free') - 0.05, tB = T0('s8', 'to');
  sc.on(t => {
    bk.draw(t);
    const a = clamp((t - tT) / 0.45), b = clamp((t - tF) / 0.4);
    set(l1, a * (1 - b), `scale(${lerp(1.06, 1, eo(a)).toFixed(3)})`, (1 - a) * 16 + b * 14);
    set(l2, b, `scale(${lerp(1.08, 1, eo(b)).toFixed(3)})`, (1 - b) * 18);
    const c = clamp((t - tB) / 0.5); set(l3, c, null, (1 - c) * 10);
  });
  cue('riser', st('s8'), 0.04, 1.2); cue('chime', tF + 0.05, 0.05);
}

// ---------- 11 · "Here's the best part. This format's called cutout.md." + the ask — white: the captions alone, in the middle,
//   key words on highlighter pills; then the wordmark
{
  const sc = scene(st('s9'), END + 1, { ...C, din: 0.25, push: 0 });
  const mark = div(sc.el, 'k-abs', 510 - 70, 760, 140, 140, F.img('assets/mark.webp', 'width:140px;height:140px'));
  const wm = div(sc.el, 'k-abs', 0, 935, 1020, null, 'MORPHY', { textAlign: 'center', font: "600 104px/1 'EB Garamond', serif", letterSpacing: '0.06em', color: '#2b2f86' });
  sc.on(t => {
    const a = clamp((t - tEnd) / 0.45);
    set(mark, a, `scale(${lerp(0.9, 1, eo(a)).toFixed(3)})`, (1 - a) * 8);
    set(wm, a, `translateY(${((1 - a) * 14).toFixed(1)}px)`, (1 - a) * 6);
  });
  cue('pop', T0('s9', 'cutout'), 0.06); cue('pop', T0('cta', 'CUTOUT'), 0.07); cue('pop', T0('cta', 'free'), 0.06); cue('chime', tEnd, 0.07);
}
```

