---
name: halftone
description: Make a narrated vertical reel (1080x1920, 25-45 s) in the "Halftone" format - hard cuts between near-black and warm cream, one red as the only colour (glowing on black), flat illustrations whose dark parts are halftone dots (a hand holding a glowing phone, a fast-scrolling phone, red equaliser bars climbing from the bottom, a flat line in the silence, a swinging price tag, a receipt that gets stamped, puppet strings that snap), a mono HUD with signal bars and a night clock, and left-aligned captions where a small grey line sets up one huge slammed word. One hushed ElevenLabs voice with long pauses, a synthesized drone + heartbeat bed that falls silent on the turn, drawn frame by frame in headless Chrome and encoded with ffmpeg. Use it when someone asks for a halftone reel, "halftone.md", a moody doomscroll / 3 AM style reel, a big-red-word kinetic reel with halftone art, or wants to turn a script into this kind of video.
---

# Halftone reel: how to make one

You will make a **25-45 second vertical video (1080x1920, 30 fps, H.264 + AAC, at most 60 s and 60 MB)** that feels like **a quiet confession at 3 AM**: short spoken fragments, long dark pauses, and on every beat **one huge word slams in** under a small grey line. The frame **cuts hard between near-black and warm cream**; **one red is the only colour** (it glows on black); every illustration is flat, and **its dark parts are halftone dots**. A mono **HUD** sits on every frame: signal bars and a label top left, a clock top right that creeps on through the night, four registration crosses. **Every spoken claim gets its literal picture**, all drawn from plain HTML: no images, no icons, no footage.

**`reference.jpg`** (next to this file, if you have it) is a contact sheet of 12 frames from a finished Halftone reel: **this is what it should look like.** Left to right, top row first: (1) black, "Just one more" grey + "skill." huge red glowing, a halftone hand holding a glowing phone that shows a "$30 SKILL.MD / BUY NOW" card; (2) "$30." red with "for a text file." grey under it, faint ghost phones, a "$0 —— $30" gauge; (3) cream, "I'm" + "Casper." huge black, "I built Morphy, an agentic super app." grey, a tilted black phone with dotted outline scrolling labelled tiles; (4) "I gave my format away." + "Free." red, the phone motion-blurred, a black pulse line running across with "SHARED 2.6×"; (5) black, red equaliser bars from the bottom, "COPIES ▲ 224 / PRICE $30"; (6) almost empty black: one flat white line with a "$30" tick, "So... here's my response." grey below it; (7) cream, "It's" + "free." huge black, a price tag with a dotted rim hanging on a string, "$0"; (8) a receipt "Free Library" whose rows appear ("LIBRARY ..... .MD FILES", "WHERE ..... IN MORPHY", "PAYWALLS ..... NONE"); (9) black, puppet strings from a white control bar down to the dim phone, "You shouldn't pay..." + "for a text" in white; (10) cream, "This format is called" + "halftone.md" red, the phone stopped on a red tile; (11)-(12) black, "Comment" + "HALFTONE." huge red glowing, "I'll send it to you. Free.", the phone's screen turns red: "SENT · FREE". 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, ~450 for a 37 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 600`, then `--full --from 600`); 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 (Inter Tight, IBM Plex Mono) are free downloads (`setup.mjs` fetches them). The 11 sound effects (`sounds.mjs`) and the music bed (`bed.mjs`, 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

```
halftone-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 halftone.js film.css markup.mjs   clock + captions + HUD + 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
  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 halftone-reel && cd halftone-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.4,3.6,6.9,9.9,16.6,20.9,27.1,33.4 --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 bed, cut to this length, silent under QUIET
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 600 && node render.mjs --video my-video --full --from 600
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 1.3   # 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 | **black** `#0a0a0b` (radial `#141416` centre → `#070708`) or **cream** `#efece6` (radial `#f6f4ef` → `#e4e0d8`), flipped hard at section starts (0.08 s) |
| HUD | y 303 | 4 signal bars (6 px wide, 8/13/19/26 px tall) at x 120 + a bold mono label at x 164; a mono clock right-aligned to x 960; 25 px, letter-spacing 0.12-0.14em; crosses (40 px, 2 px lines) at (120, 270) (960, 270) (120, 1248) (960, 1248) |
| Captions | left-aligned at x 120, ≤ 800 px wide, from y 392 | a small line, one big line, optionally a small line under it (3.4) |
| The art | full-bleed, mostly from y ~760 down | one subject per beat, often low and to the right, bleeding off the bottom or right edge |

**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 |
|---|---|---|
| black | `#0a0a0b` | dark beats |
| cream | `#efece6` | light beats |
| red | `#e8211c`, glow `rgba(232,33,28,0.55)` + `0.28` (text-shadow 36 px + 90 px, black beats only); tiles `#c62a20` | the big red word, bars, the stamp, progress lines, the SENT screen |
| ink | `#f4f4f2` on black, `#121212` on cream | the big ink word, receipt text |
| grey | `#a3a3a3` on black, `#565656` on cream | the small caption lines |
| phone | bezel `#f4f5ff → #d9ddfa → #b9bff0`, screen `#1a1d24`, UI bars `#8a8ea3` `#a3a7bb`, halo `rgba(205,212,255,0.55)` | the glowing phone in the hand |
| scroll phone | body `#111`, tiles `#8a8d96` `#5b5e66` `#c62a20` | the cream-beat phone |
| receipt | paper `#faf9f5`, rules `#151515` / `#bdbab2`, dotted leaders `#9a9a9a` | the form |

### 3.3 Type

**Inter Tight** (variable, SIL OFL): 400 for the small lines, 800 for the big lines, receipt titles and the price tag. **IBM Plex Mono** (SIL OFL) for every HUD and read-out label (UPPERCASE, letter-spacing 0.1-0.16em).

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

- A **block** = up to three lines, left-aligned: a **small line** (68 px, weight 400, grey, line-height 1.1, −0.02em), **one big line** (250 px, weight 800, −0.055em, line-height 0.86) in **ink** (`|`) or **red** (`!`), and optionally a **small line under it** (`^`, 64 px grey). A block may carry an ink big line and then a red one ("It's | free. ! Again.").
- **The big line shrinks to fit** 792 px (measured once per text): one short word stays huge, "for a text file" lands near 110 px. That size contrast is the look.
- **Small words fade in on the voice** (0.16 s, from an 8 px blur). **Big words slam in** on their own word: from ~1.2× scale and 18 px blur to 1× in 0.17 s (smaller start scale when the line is already full width, so it never leaves the zone).
- A block **holds** until the next block of its section starts (or the section ends), then blurs out in 0.14 s. One beat = one block is the norm.
- Colours follow the background at the block's start, so flip the looks exactly at section starts.
- **Hooks:** `F.cap.hide` drops words the screen already shows (the receipt beat); `F.cap.fmt(tk, t)` changes a word live (the `$0 → $30` count-up); `F.cap.pos(block)` moves a block (`{ top: 960, cls: 'sm' }` puts the silence line under the flat line) or shrinks its big line (`cls: 'mid'` = 132 px); `F.cap.blocks` lets a scene style a block per frame (grey echo copies of a big line via `text-shadow`).

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

- **Halftone** = a CSS dot grid: `radial-gradient(circle, <colour> 36%, transparent 41%) 0 0 / 13px 13px`. White dots on black, `#111` dots on cream, cells 11-13 px. Every "dark" surface of the drawings (fingers, palm, sleeve, the ring round the phone, a tag's rim) is a shape filled with it.
- **The hand + glowing phone** (`H.handPhone`, a 600×680 box): a dotted sleeve wedge at the bottom, three dotted fingers on the left, a dotted thumb/palm on the right, the phone tilted −7° inside a dotted ring, lavender-white bezel, a dark screen (a white play triangle, or a `SKILL.MD / $30 / BUY NOW` card, or dim, or off, or a red `✓ SENT · FREE`), a red progress line, two grey UI bars. On black it throws a pale halo (drop-shadow 30 px).
- **The scrolling phone** (`H.scrollPhone`, 420×780, tilted −14 to −16°): black body, dotted outline, rounded tiles in grey/red with mono labels, a dotted thumb on the right edge; the tiles scroll and motion-blur when it goes fast.
- **Red bars** (`H.bars`): 11 red bars from the bottom with 7 px off-white caps and a red glow, wobbling; a two-line mono read-out at x 120.
- **The flat line** (`H.flatline`): a 3 px white line across the frame with a soft glow, a small wave packet that fades out, a tick + label above it.
- **The price tag** (`H.priceTag`): a string from a pin, a pentagon tag with a dotted rim, a punched hole, a huge red price and a mono sub-label; it swings and settles.
- **The receipt** (`H.receipt`): paper with a perforated top edge, `DEPT. / FORM` header, a 96 px title, mono rows with dotted leaders that appear on their words, a footer, and a red rubber stamp that thumps on (2.2× → 1×, rotated −12°).
- **Puppet strings** (`H.puppet`): a white cross control bar at the top, four strings down to the phone; they sway together and **snap** upward on the turn.

### 3.6 Motion

| Move | How |
|---|---|
| between beats | near-hard cuts: the new scene fades/sharpens in over 0.1 s, the old one out over 0.08 s; the background flips in 0.08 s |
| in a beat | a slow push (+3.5 % over the beat); the phone bobs (±6 px, ±1.2°) |
| a count-up | the big number and the gauge count together over 0.75 s, ticks under it |
| fast scroll | the tiles accelerate and blur up to 14 px; the pulse line's spikes get closer |
| the silence | everything drops: a flat line, the bed falls to 3 % (`QUIET`), the HUD's signal bars drop to 2 |
| the turn | the strings snap, the phone drops 60 px and lights up |
| the end | the keyword slams in red; on "send" the phone's screen turns red with a check and the HUD clock reads SENT |

## 4. Writing for this format

The reference grammar (and the example's): **a hook fragment ("Just one more ! skill.") → a number that hurts ("! $30. ^ for a text file.") → who you are → the escalation, getting faster and louder → the silence ("So... here's my response." under a flat line) → the turn, said flatly ("It's | free. ! Again.") → the list (on a receipt) → the moral ("You shouldn't pay... | for a text file ! that was free.") → the name ("This format is called ! halftone.md") → "Comment ! KEYWORD. ^ I'll send it to you. Free."**

- One block per section, sometimes two; one idea each. The big line is **one word** when it can be.
- Short: 70-90 words for 30-40 s. Let the GAPs breathe (0.45-0.6 s); the silence beat is part of the script.
- Markup: `/` a new block · `|` the big line in ink · `!` the big line in red · `^` a small grey line under it · `{shown|said}` when the caption and the voice differ (`{$30.|Thirty dollars,}`, `{halftone.md|halftone dot M D.}`, `{HALFTONE.|halftone,}`).
- `QUIET: ['s5']` in script.mjs names the section(s) where the music falls silent.

## 5. The voice (ElevenLabs)

**Brian** (`nPczCjzI2devNBz1zQrb`), `eleven_multilingual_v2`, stability 0.55, similarity 0.8, style 0.22, speaker boost, **speed 0.98**: deep, hushed, unhurried (~140 wpm with the gaps; the reference: a male narrator, F0 ~133 Hz, slow with long pauses). These are `engine/markup.mjs` DEFAULTS; a `script.mjs` may export `VOICE`, `MODEL`, `SETTINGS` or `SPEED` to override them. 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 `/ | ! ^` 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('s4', 'sold')` is the start of the first token in section s4 whose text starts with "sold" (case and punctuation blind), `T1(…)` its end, `T0('s4', 'it', 1)` the second "it". `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 H = F.ht;
const st = id => S[id].start - 0.06;
const C = { full: true, din: 0.1, dout: 0.08, push: 0.035 };       // near-hard cuts, full-bleed
F.looks([[-1, 'dark', 0.06], [st('s2'), 'light', 0.08], [st('s4'), 'dark', 0.08]]);   // 'dark' = black, 'light' = cream
F.hud({ label: 'STUDIO', right: [[-1, '03:12 AM'], [st('s2'), '03:26 AM'], [st('s4'), '03:47 AM', 'red']], bars: [[-1, 4], [st('s3'), 2]] });

{
  const sc = scene(st('s0'), st('s1'), C);
  const hp = H.handPhone(sc.el, 250, 830, 1.08);
  const tS = T0('s0', 'skill');
  sc.on(t => { hp.screen(t < tS ? 'play' : 'card'); hp.el.style.transform = `translate(0px, ${(Math.sin(t * 1.4) * 6).toFixed(1)}px) scale(1.08)`; });
  cue('thump', tS, 0.22);
}
```

- 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), `pop`, `rise`, `F.html(el, s)` (only touches the DOM when it changed). Easing: `eo`, `eio`, `ease`, `back`; `clamp`, `lerp`, `rng(seed)`.
- Sounds: `cue(name, time, gain[, length])` with `thump` (every slammed big word, 0.16-0.24), `tick` (count-ups, 0.05), `beep` (the flat line, 0.05), `stamp`, `click`, `swish`, `riser`, `glitch`, `implode`, `pop`, `chime` (0.06-0.16).
- `F.HUD.off = t` fades the HUD out from t if a beat needs a clean top.

### 7.3 What the kit gives you (`H = F.ht`)

| Part | Options and use |
|---|---|
| `H.dots(colour, cell, r)` · `H.blob(p, x, y, w, h, { c, cell, r, br, clip, tr })` | a dot-grid background · a dotted shape (border-radius `br`, or a `clip-path`) |
| `H.spot(p, cx, cy, w, h, alpha, 'r,g,b')` | a soft light pool (red by default; `'190,200,255'` = the phone's pale light) |
| `H.handPhone(p, x, y, scale)` → `{ el, card, screen(name), glow(v), progress(u) }` | the hand + phone (600×680 box at x, y); screens `play` `card` `dim` `off` `sent`; `card.children[1]` is the price text |
| `H.scrollPhone(p, x, y, scale, { rot, tiles: [colour…], label: i => html })` → `{ draw({ y, blur, tx, ty, rot }) }` | the scrolling phone (420×780 box); `y` = scroll offset in px (a tile is 200 px); labels outside the safe zone hide themselves |
| `H.bars(p, { n, seed, lx, ly })` → `{ draw({ t, level, labels: [line…], min, range }) }` | red bars from the bottom; `level` 0.5-1.1 |
| `H.flatline(p, y)` → `{ draw({ t, amp, x, label, dim, spike }) }` | the flat line; `amp` 1 → 0 fades the packet |
| `H.priceTag(p, cx, top, scale)` → `{ draw({ swing, value, label }) }` | the tag (pin at cx, top; the tag hangs 240 px below) |
| `H.receipt(p, x, y, w, { dept, form, title, rows: [[key, value, red?]…], foot, stamp })` → `{ el, draw({ t, times: [t…], stampT }) }` | the form; each row appears at its time |
| `H.puppet(p, cx, y)` → `{ el, draw({ ends: [[x, y]×4], cut, sway, t }) }` | the control bar at (cx, y) + four strings; `cut` 0 → 1 snaps them |
| `div(p, cls, x, y, w, h, html, style)` | an absolutely placed element (`'ht-abs'`, `'ht-abs ht-mono'` for a mono label) |

### 7.4 The edit follows the words

Every claim gets its literal picture at that moment: "one more skill" → the phone shows a $30 skill card; "thirty dollars" → the number counts up; "I gave my format away" → the phone scrolls faster, a pulse line reads "SHARED 2.6×"; "sold it for thirty" → the bars climb, the copies counter runs, the price echoes; "here's my response" → silence, a flat line; "free" → the tag drops to $0; "no paywalls / no subscriptions / no strings" → each row appears on its word, the stamp lands after "strings"; "that was free" → the strings snap; "send" → the screen turns red, SENT.

### 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 half to the type.
- Keep the black beats black: the only light is the phone's pale halo and the red.
- 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 small line wrapping into the art, a big line that shrank too far, a label off the edge.

## 9. The full render

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

## 10. Sound

`bed.mjs` makes `my-video/bed.wav`: a drone (55 Hz + 82.4 Hz + a wobbling 110 Hz), a heartbeat (lub-dub every 0.92 s) and a little air, low-passed, falling to 3 % in the `QUIET` sections. `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.11` is the default).

## 11. Encode, check, deliver

`render.mjs --encode --out video.mp4 --cover 1.3` makes **H.264 High, yuv420p, 1080x1920, 30 fps, `-crf 20 -maxrate 10M -bufsize 20M`, AAC 192k, `+faststart`** (~9 MB for 37 s) and `cover.jpg` (the hook frame). `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 reads as the grammar of section 4 · one big word per block where possible
- [ ] 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 silence beat really is silent
- [ ] every claim has its picture on its word · black/cream flips on the section starts
- [ ] `--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>halftone</title>
<style>
@font-face { font-family: 'Inter Tight'; src: url(fonts/InterTight.ttf) format('truetype'); font-weight: 100 900; }
@font-face { font-family: 'IBM Plex Mono'; src: url(fonts/IBMPlexMono-Regular.ttf) format('truetype'); font-weight: 400 500; }
@font-face { font-family: 'IBM Plex Mono'; src: url(fonts/IBMPlexMono-Bold.ttf) format('truetype'); font-weight: 600 800; }
</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/halftone.js', VIDEO_DIR + '/scenes.js'])
    document.write(`<script src="${s}"><\/script>`);
</script>
</body>
</html>
```

### `engine/core.js`

```js
// Halftone template · ENGINE CORE (from Glow). 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 → halftone.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: dark / light / warm / moody, crossfaded by keys [t, look, fade] ----------
  const MODES = ['dark', 'light', 'warm', 'moody'];
  let BG = [[-9, 'dark', 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] : 'dark', 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 = { dark: 0, light: 0, warm: 0, moody: 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;
  };
  const BGL = {};
  for (const m of MODES) { BGL[m] = add(stage, h('div', { class: 'layer bg-' + m })); BGL[m].style.inset = '-40px'; }
  const leak = add(stage, h('div', { class: 'leak' }));
  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 rain = add(stage, h('canvas', { class: 'layer', width: 1080, height: 1920 }));
  const rctx = rain.getContext('2d');
  const drops = (() => { const r = rng(77); return Array.from({ length: 170 }, () => ({ x: r() * 1300 - 110, y: r() * 2200, l: 50 + r() * 110, v: 1900 + r() * 900, a: 0.08 + r() * 0.2, w: r() < 0.2 ? 2.2 : 1.2 })); })();
  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 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.035 + 0.1 * w.warm + 0.16 * w.moody).toFixed(3);
    leak.style.display = w.warm < 0.01 ? 'none' : 'block';
    if (w.warm >= 0.01) {
      let tw = 0; for (const [k2, m] of BG) if (m === 'warm' && k2 <= t) tw = k2;
      leak.style.opacity = (w.warm * (0.75 + 0.25 * Math.sin(t * 1.3))).toFixed(3);
      leak.style.transform = `translate(${(-560 + Math.sin(t * 0.5) * 140 + (t - tw) * 30).toFixed(0)}px, ${(-620 + Math.cos(t * 0.4) * 90).toFixed(0)}px)`;
    }
    rain.style.display = w.moody < 0.01 ? 'none' : 'block';
    if (w.moody >= 0.01) {
      rctx.clearRect(0, 0, 1080, 1920); rctx.lineCap = 'round';
      for (const d of drops) {
        const y = ((d.y + t * d.v) % 2300) - 200, x = d.x + y * 0.16;
        rctx.strokeStyle = `rgba(170,200,222,${(d.a * w.moody).toFixed(3)})`; rctx.lineWidth = d.w;
        rctx.beginPath(); rctx.moveTo(x, y); rctx.lineTo(x + d.l * 0.16, y + d.l); rctx.stroke();
      }
    }
  };

  // ---------- 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 (Halftone): a block = a small grey line (l1) + one BIG line (| ink or ! red) + an optional small grey
  // line under it (^). The small words fade in on the voice, the big line slams in (scale + blur) on its first word.
  // The block holds until the next block of its section (or the section's end), then blurs out fast.
  // F.cap.hide(token, section) drops a word · F.cap.fmt(token, t) → text shown live (a count-up) · F.cap.pos(block) → { top, cls }
  F.cap = { hide: null, fmt: null, pos: null, blocks: [] };
  const capRoot = add(stage, h('div', { class: 'over' }));
  const phrases = F.cap.blocks;
  const FIT = 792;   // the caption zone is x 100–920 and the block starts at 120
  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.s === p.s ? nx.toks[0].t0 - 0.1 : p.s.until - 0.06;
      p.el = add(capRoot, h('div', { class: 'cap m-' + (lookAt(p.t0 + 0.05) === 'light' ? 'light' : 'dark') }));
      const o = F.cap.pos && F.cap.pos(p);
      if (o && o.top != null) p.el.style.top = px(o.top);
      if (o && o.cls) p.el.classList.add(...o.cls.split(' '));
      p.lines = {};
      const line = n => p.lines[n] || (p.lines[n] = add(p.el, h('div', { class: 'l' + n })));
      p.w = p.toks.filter(tk => !(F.cap.hide && F.cap.hide(tk, p.s))).map(tk => {
        const n = tk.ln || (tk.l2 ? 2 : 1), L = line(n);
        let ic = null;
        if (tk.icon) ic = add(L, h('span', { class: 'ic' }, (F.ICONS[tk.icon] || F.ICONS.claude)()));
        const sp = add(L, h('span', { class: 'w' }, esc(tk.d)));
        L.appendChild(document.createTextNode(' '));
        return { tk, sp, ic, n };
      });
      // order the lines l1, big, l4 whatever the markup order was
      for (const n of [1, 2, 3, 4]) if (p.lines[n]) p.el.appendChild(p.lines[n]);
      p.hidden = !p.w.length;
      p.big = p.lines[2] || p.lines[3] || null;
      p.bigT = p.big ? Math.min(...p.w.filter(w => w.n === 2 || w.n === 3).map(w => w.tk.t0)) : 0;
    });
  };
  // shrink the big line until it fits 820 px (measured once per text, so live count-ups stay put)
  const fitBig = p => {
    for (const L of [p.lines[2], p.lines[3]]) {
      if (!L) continue;
      const key = L.textContent; if (L.__fit === key) continue; L.__fit = key;
      L.style.fontSize = ''; const f0 = parseFloat(getComputedStyle(L).fontSize);
      const w = L.scrollWidth; if (w > FIT) L.style.fontSize = px(Math.floor(f0 * FIT / w));
      L.__slam = 1 + clamp((FIT - Math.min(FIT, L.scrollWidth)) / FIT, 0.03, 0.22);   // a full-width line slams in smaller
    }
  };
  const drawCaptions = t => {
    for (const p of phrases) {
      if (p.hidden || t < p.t0 - 0.12 || t >= p.t1 + 0.14) { if (p.el.style.display !== 'none') p.el.style.display = 'none'; continue; }
      p.el.style.display = 'block';
      const out = eo((t - p.t1) / 0.14);
      p.el.style.opacity = (1 - out).toFixed(3);
      p.el.style.filter = out > 0.02 ? `blur(${(out * 9).toFixed(1)}px)` : 'none';
      for (const w of p.w) {
        if (F.cap.fmt) { const v = F.cap.fmt(w.tk, t, p); if (v != null) html(w.sp, esc(v)); }
        if (w.n === 2 || w.n === 3) continue;
        const u = w.tk.t0 < 0.35 ? 1 : eo((t - (w.tk.t0 - 0.05)) / 0.16);
        w.sp.style.opacity = u.toFixed(3);
        w.sp.style.filter = u < 0.98 ? `blur(${((1 - u) * 8).toFixed(1)}px)` : 'none';
        if (w.ic) w.ic.style.opacity = u.toFixed(3);
      }
      if (p.big) {
        fitBig(p);
        // each big word slams in on its own word time
        for (const w of p.w) if (w.n === 2 || w.n === 3) {
          const u = w.tk.t0 < 0.35 ? 1 : clamp((t - (w.tk.t0 - 0.04)) / 0.17), e = eo(u);
          w.sp.style.opacity = clamp(u * 2.2).toFixed(3);
          const k = (p.lines[w.n] && p.lines[w.n].__slam) || 1.22;
          w.sp.style.transform = `scale(${(k - (k - 1) * e).toFixed(4)})`;
          w.sp.style.filter = e < 0.98 ? `blur(${((1 - e) * 18).toFixed(1)}px)` : 'none';
        }
      }
    }
  };

  // ---------- the HUD: signal bars + a label top left, a clock (or any text) top right, four registration crosses ----------
  // F.hud({ label: 'MORPHY', right: [[t, '03:12 AM', 'red'?], …], bars: [[t, 0–4], …] })
  F.HUD = null;
  F.hud = o => { F.HUD = o; };
  let hudEl = null;
  const buildHud = () => {
    if (!F.HUD) return;
    hudEl = add(stage, h('div', { class: 'hud' }));
    hudEl.innerHTML = `<div class="sig"><i></i><i></i><i></i><i></i></div><div class="lbl">${esc(F.HUD.label || '')}</div><div class="rt"></div>` +
      '<div class="x" style="left:120px;top:270px"></div><div class="x" style="left:960px;top:270px"></div><div class="x" style="left:120px;top:1248px"></div><div class="x" style="left:960px;top:1248px"></div>';
    hudEl.__rt = hudEl.querySelector('.rt'); hudEl.__bars = [...hudEl.querySelectorAll('.sig i')];
  };
  const keyAt = (keys, t, d) => { let v = d; for (const k of keys || []) if (t >= k[0]) v = k; return v; };
  const drawHud = t => {
    if (!hudEl) return;
    const w = weights(t), lt = w.light;
    hudEl.style.setProperty('--hc', `rgb(${Math.round(lerp(235, 26, lt))},${Math.round(lerp(235, 26, lt))},${Math.round(lerp(235, 26, lt))})`);
    hudEl.style.setProperty('--hd', `rgba(${Math.round(lerp(150, 60, lt))},${Math.round(lerp(150, 60, lt))},${Math.round(lerp(150, 60, lt))},0.9)`);
    const r = keyAt(F.HUD.right, t, null);
    if (r) { html(hudEl.__rt, esc(r[1])); hudEl.__rt.classList.toggle('red', r[2] === 'red'); hudEl.__rt.classList.toggle('hi', r[2] === 'hi'); }
    const b = keyAt(F.HUD.bars, t, [0, 4]);
    hudEl.__bars.forEach((e, i) => e.classList.toggle('off', i >= b[1]));
    const op = F.HUD.off && t >= F.HUD.off ? 1 - clamp((t - F.HUD.off) / 0.3) : 1;
    hudEl.style.opacity = op.toFixed(3);
  };

  // ---------- 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)
    buildHud();
    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);
      drawHud(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/halftone.js`

```js
// Halftone template · PARTS. Same for every video. The look: near-black and warm cream flipped hard per beat, one red
// that glows on black, flat illustrations whose dark parts are HALFTONE DOTS (CSS dot grids, no images), a mono HUD.
// Every part is built once (plain divs on the 1080x1920 canvas) and drawn per frame by the scene's sc.on(t => …).
// Use { full: true } scenes: the parts are placed on the real canvas, under the captions (y 390–~900) and above y 1480.
(() => {
  const { h, add, div, set, clamp, lerp, eo, eio, ease, back, rng, esc, px } = F;
  const H = F.ht = {};
  const RED = H.RED = '#e8211c';

  // ---------- halftone dot fills ----------
  // dots(colour, cell px, dot radius as a share of the cell) → a CSS background
  const dots = H.dots = (c = '#fff', cell = 13, r = 0.36) => `radial-gradient(circle at 50% 50%, ${c} ${(r * 100).toFixed(0)}%, transparent ${(r * 100 + 5).toFixed(0)}%) 0 0 / ${cell}px ${cell}px`;
  // a halftone blob: an absolutely placed div filled with dots, any border-radius / clip-path
  const blob = H.blob = (p, x, y, w, hh, o = {}) => div(p, 'ht-abs', x, y, w, hh, '', {
    background: dots(o.c || '#fff', o.cell || 13, o.r || 0.36), borderRadius: o.br || '999px', clipPath: o.clip || '', transform: o.tr || '',
  });
  // a light pool behind a subject (red by default)
  H.spot = (p, cx, cy, w, hh, a = 0.3, rgb = '232,33,28') => div(p, 'ht-abs', cx - w / 2, cy - hh / 2, w, hh, '', {
    borderRadius: '50%', background: `radial-gradient(closest-side, rgba(${rgb},${a}), rgba(${rgb},${a * 0.35}) 55%, transparent)`,
  });

  // ---------- the hand holding a glowing phone (dark beats). Designed in a 600x680 box; (x, y) = its top-left, s = scale.
  // returns { el, phone, scr, play, bar, card, sent, glow(v), screen(name) }; screens: 'play' | 'card' | 'off' | 'sent' | 'dim'
  H.handPhone = (p, x, y, s = 1, o = {}) => {
    const el = div(p, 'ht-abs', x, y, 600, 680, '', { transformOrigin: '300px 600px', transform: `scale(${s})` });
    const ink = o.ink || '#fff';
    // sleeve (bottom), dots
    blob(el, 40, 470, 520, 230, { c: ink, cell: 11, r: 0.34, br: '0', clip: 'polygon(6% 100%, 0% 62%, 18% 22%, 60% 8%, 92% 0%, 100% 100%)' });
    // the phone, tilted
    const phone = div(el, 'ht-abs', 150, 40, 300, 520, '', { transform: 'rotate(-7deg)', transformOrigin: '50% 60%' });
    const ring = blob(phone, -16, -16, 332, 552, { c: ink, cell: 12, r: 0.34, br: '64px' });
    const body = div(phone, 'ht-abs', 0, 0, 300, 520, '', { borderRadius: '50px', background: 'linear-gradient(170deg, #f4f5ff 0%, #d9ddfa 55%, #b9bff0 100%)' });
    const scr = div(phone, 'ht-abs', 22, 26, 256, 330, '', { borderRadius: '30px', background: '#1a1d24', overflow: 'hidden' });
    const play = div(scr, 'ht-abs', 0, 0, 256, 330, `<svg width="256" height="330" viewBox="0 0 256 330"><path d="M112 145v42l34-21z" fill="#e9eaee"/></svg>`);
    const bar = div(phone, 'ht-abs', 22, 354, 256, 6, '', { background: '#3a3d48', borderRadius: '3px', overflow: 'hidden' });
    const barFill = div(bar, 'ht-abs', 0, 0, 90, 6, '', { background: RED });
    div(phone, 'ht-abs', 34, 384, 190, 16, '', { background: '#8a8ea3', borderRadius: '8px' });
    div(phone, 'ht-abs', 34, 414, 140, 16, '', { background: '#a3a7bb', borderRadius: '8px' });
    div(phone, 'ht-abs', 60, 452, 196, 60, '', { background: '#1a1d24', borderRadius: '14px 14px 0 0' });
    // the screen's other states
    const card = div(scr, 'ht-abs', 26, 70, 204, 190, `<div style="font:700 22px/1 var(--h-mono);letter-spacing:.12em;color:#9aa0b4">SKILL.MD</div>
      <div style="font:800 92px/1 'Inter Tight';letter-spacing:-.04em;color:#fff;margin-top:14px">$30</div>
      <div style="margin-top:14px;height:44px;border-radius:12px;background:${RED};color:#fff;font:700 24px/44px 'Inter Tight';text-align:center">BUY NOW</div>`);
    const sent = div(scr, 'ht-abs', 0, 0, 256, 330, `<div style="position:absolute;left:0;right:0;top:110px;text-align:center"><svg width="86" height="86" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>
      <div style="font:700 24px/1 var(--h-mono);letter-spacing:.14em;color:#fff;margin-top:16px">SENT · FREE</div></div>`, { background: RED });
    // fingers left, thumb + palm right (dots)
    for (let i = 0; i < 3; i++) blob(el, 112 - i * 4, 300 + i * 52, 92, 46, { c: ink, cell: 11, r: 0.36 });
    blob(el, 395, 300, 130, 200, { c: ink, cell: 11, r: 0.36, br: '60px 60px 40px 90px' });
    const R = { el, phone, scr, play, bar, barFill, card, sent, ring, body };
    R.screen = name => {
      set(play, name === 'play' || name === 'dim' ? 1 : 0); set(card, name === 'card' ? 1 : 0); set(sent, name === 'sent' ? 1 : 0);
      scr.style.background = name === 'off' ? '#0d0e12' : '#1a1d24';
      body.style.filter = name === 'off' || name === 'dim' ? 'brightness(0.32)' : 'none';
    };
    // glow: 0 = none, 1 = the phone lights the dark
    R.glow = v => { el.style.filter = v > 0.01 ? `drop-shadow(0 0 ${(30 * v).toFixed(0)}px rgba(205,212,255,${(0.55 * v).toFixed(2)}))` : 'none'; };
    R.progress = u => { barFill.style.width = px(Math.round(256 * clamp(u))); };
    R.screen('play'); R.glow(1);
    return R;
  };

  // ---------- the fast-scrolling phone (cream beats): a tilted black phone, a dotted outline, tiles that scroll; thumb
  // on the right edge. (x, y) top-left of a 420x780 box. draw({ y: scroll px, blur }) ; tiles: [colour, …]
  H.scrollPhone = (p, x, y, s = 1, o = {}) => {
    const el = div(p, 'ht-abs', x, y, 420, 780, '', { transformOrigin: '50% 50%', transform: `rotate(${o.rot ?? -14}deg) scale(${s})` });
    blob(el, -18, -18, 456, 816, { c: '#111', cell: 12, r: 0.36, br: '70px' });
    const body = div(el, 'ht-abs', 0, 0, 420, 780, '', { borderRadius: '56px', background: '#111' });
    const scr = div(body, 'ht-abs', 22, 22, 376, 736, '', { borderRadius: '38px', overflow: 'hidden', background: '#1c1c1e' });
    const strip = div(scr, 'ht-abs', 0, 0, 376, 4000);
    const cols = o.tiles || ['#8a8d96', '#5b5e66', '#c62a20', '#8a8d96', '#5b5e66', '#8a8d96', '#c62a20', '#5b5e66'];
    const TH = 200, n = 20, tiles = [];
    for (let i = 0; i < n; i++) tiles.push(div(strip, 'ht-abs', 14, 14 + i * TH, 348, TH - 24, o.label ? o.label(i) : '', { borderRadius: '24px', background: cols[i % cols.length] }));
    // thumb on the right edge
    const thumb = blob(p, 0, 0, 120, 190, { c: '#111', cell: 11, r: 0.38, br: '60px 50px 30px 70px' });
    const R = { el, strip, tiles, thumb, TH };
    R.draw = ({ y: sy = 0, blur = 0, tx = 0, ty = 0, rot } = {}) => {
      const off = -(sy % (TH * cols.length));
      strip.style.transform = `translateY(${off.toFixed(1)}px)`;
      strip.style.filter = blur > 0.3 ? `blur(${blur.toFixed(1)}px)` : 'none';
      el.style.transform = `translate(${tx}px, ${ty}px) rotate(${(rot ?? o.rot ?? -14)}deg) scale(${s})`;
      thumb.style.left = px(x + 385 * s + tx); thumb.style.top = px(y + 400 * s + ty);
      // tile labels are text: show only those inside the safe zone (RULES 7)
      if (o.label) for (const tl of tiles) { const lb = tl.firstElementChild; if (!lb) continue; const b = lb.getBoundingClientRect(); lb.style.visibility = b.left >= 64 && b.right <= 956 && b.top >= 254 && b.bottom <= 1476 ? 'visible' : 'hidden'; }
    };
    R.draw({});
    return R;
  };

  // ---------- equaliser bars from the bottom (dark beats): red bars with white caps + a mono read-out
  // draw({ t, level 0–1.4, labels: ['COPIES ▲ 100%', 'SOLD 248'] })
  H.bars = (p, o = {}) => {
    const n = o.n || 11, W = 1080 / (n - 0.6), el = div(p, 'ht-abs', 0, 0, 1080, 1920);
    const bars = Array.from({ length: n }, (_, i) => {
      const b = div(el, 'ht-abs', i * W - W * 0.3, 1920, W - 6, 1000, '', { background: `linear-gradient(180deg, ${RED} 0%, #c81812 100%)`, boxShadow: '0 0 40px rgba(232,33,28,0.35)' });
      div(b, 'ht-abs', 0, 0, W - 6, 7, '', { background: '#fff2ee' });
      return b;
    });
    const lab = div(el, 'ht-abs ht-mono', o.lx ?? 120, o.ly ?? 1040, 600, 80, '', { font: "500 28px/40px 'IBM Plex Mono', Consolas, monospace", color: '#fff', letterSpacing: '0.14em' });
    const r = rng(o.seed || 9), ph = bars.map(() => [r() * 6, 0.6 + r() * 0.8, r()]);
    const R = { el, bars, lab };
    // each bar's height from the bottom of the frame = (min + range · wobble) · level
    R.draw = ({ t, level = 1, labels, min = 380, range = 820 }) => {
      bars.forEach((b, i) => {
        const [a, f, base] = ph[i];
        const v = clamp(0.25 + 0.45 * base + 0.3 * Math.sin(t * 9 * f + a) * Math.sin(t * 3.1 + a * 2), 0.05, 1);
        const hgt = (min + range * v) * level;
        b.style.top = px(1920 - Math.max(30, hgt));
      });
      if (labels) F.html(lab, labels.map(esc).join('<br>'));
    };
    return R;
  };

  // ---------- a flat line across the frame with a little packet of waves (the "silence" beat); draw({ t, amp, x, label })
  H.flatline = (p, y = 810, o = {}) => {
    const c = add(p, h('canvas', { width: 1080, height: 400, class: 'ht-abs' })); c.style.left = '0px'; c.style.top = px(y - 200);
    const g = c.getContext('2d');
    const lab = div(p, 'ht-abs ht-mono', 0, y - 160, 200, 40, '', { font: "400 25px/1 'IBM Plex Mono', Consolas, monospace", color: '#8b8b8b', textAlign: 'center', letterSpacing: '0.1em' });
    const tick = div(p, 'ht-abs', 0, y - 120, 2, 70, '', { background: '#8b8b8b' });
    const R = { c, lab, tick };
    R.draw = ({ t, amp = 1, x = 540, label = '', dim = 0, spike = 0 }) => {
      g.clearRect(0, 0, 1080, 400);
      g.lineWidth = 3; g.strokeStyle = `rgba(240,240,240,${(1 - dim * 0.6).toFixed(2)})`; g.shadowColor = 'rgba(255,255,255,0.6)'; g.shadowBlur = 10;
      g.beginPath();
      for (let X = 0; X <= 1080; X += 2) {
        const d = (X - x) / 70, env = Math.exp(-d * d) * amp + Math.exp(-(((X - x) / 26) ** 2)) * spike;
        const Y = 200 + Math.sin(X * 0.55 + t * 30) * 34 * env;
        X ? g.lineTo(X, Y) : g.moveTo(X, Y);
      }
      g.stroke();
      F.html(lab, esc(label)); lab.style.left = px(x - 100); tick.style.left = px(x - 1);
      const op = 1 - dim * 0.7; lab.style.opacity = tick.style.opacity = op.toFixed(2);
    };
    return R;
  };

  // ---------- a swinging price tag on a string (cream beats): halftone rim, white face, red price. (cx, top) = the pin.
  // draw({ t, swing deg, price })
  H.priceTag = (p, cx, top, s = 1) => {
    const el = div(p, 'ht-abs', cx - 260, top, 520, 900, '', { transformOrigin: '260px 0px' });
    div(el, 'ht-abs', 258, 0, 3, 250, '', { background: '#2a2a2a' });
    const tag = div(el, 'ht-abs', 60, 240, 400, 560, '', { transform: `scale(${s})`, transformOrigin: '200px 0' });
    const shape = 'polygon(50% 0%, 100% 22%, 100% 100%, 0% 100%, 0% 22%)';
    blob(tag, -16, -16, 432, 592, { c: '#111', cell: 12, r: 0.38, br: '0', clip: shape });
    div(tag, 'ht-abs', 0, 0, 400, 560, '', { background: '#fbfaf6', clipPath: shape, boxShadow: '0 30px 60px rgba(0,0,0,0.2)' });
    div(tag, 'ht-abs', 175, 70, 50, 50, '', { borderRadius: '50%', background: '#e8e4dc', boxShadow: 'inset 0 4px 8px rgba(0,0,0,0.25)' });
    const price = div(tag, 'ht-abs', 0, 230, 400, 200, '', { textAlign: 'center', font: "800 170px/1 'Inter Tight'", letterSpacing: '-0.05em', color: RED });
    const sub = div(tag, 'ht-abs ht-mono', 0, 440, 400, 40, 'SKILL.MD', { textAlign: 'center', font: "500 26px/1 'IBM Plex Mono', Consolas, monospace", letterSpacing: '0.16em', color: '#555' });
    const R = { el, tag, price, sub };
    R.draw = ({ swing = 0, value, label }) => {
      el.style.transform = `rotate(${swing.toFixed(2)}deg)`;
      if (value != null) F.html(price, esc(value));
      if (label != null) F.html(sub, esc(label));
    };
    return R;
  };

  // ---------- a receipt / form (cream beats). rows: [[key, value, red?], …]; show(n rows, t) ; stamp(text, u)
  H.receipt = (p, x, y, w, o = {}) => {
    const el = div(p, 'ht-receipt', x, y, w, null, '', { padding: '42px 44px 40px', transformOrigin: '50% 0' });
    el.innerHTML = `<div style="display:flex;justify-content:space-between;font:700 25px/1 var(--h-mono);letter-spacing:.14em"><span>${esc(o.dept || '')}</span><span>${esc(o.form || '')}</span></div>
      <div style="height:3px;background:#151515;margin:18px 0 22px"></div>
      <div style="font:800 96px/1 'Inter Tight';letter-spacing:-.045em">${esc(o.title || '')}</div><div class="rows"></div>
      <div style="height:2px;background:#bdbab2;margin:40px 0 18px"></div>
      <div style="font:500 22px/1 var(--h-mono);letter-spacing:.16em;color:#666">${esc(o.foot || '')}</div>`;
    const rowsEl = el.querySelector('.rows');
    const rows = (o.rows || []).map(([k, v, red]) => {
      const r = add(rowsEl, h('div', { class: 'ht-row' }, `<span>${esc(k)}</span><span class="dots"></span><span class="v${red ? ' red' : ''}">${esc(v)}</span>`));
      return r;
    });
    const stamp = div(el, 'ht-stamp', w - 380, 0, null, null, esc(o.stamp || 'FREE'));
    const R = { el, rows, stamp };
    // row i appears at times[i] (fade + type-like reveal of the value)
    R.draw = ({ t, times = [], stampT = 1e9 }) => {
      rows.forEach((r, i) => {
        const u = eo((t - (times[i] ?? 1e9)) / 0.22);
        r.style.opacity = u.toFixed(3); r.style.transform = `translateY(${((1 - u) * 14).toFixed(1)}px)`;
        r.style.filter = u < 0.98 ? `blur(${((1 - u) * 6).toFixed(1)}px)` : 'none';
      });
      const su = clamp((t - stampT) / 0.16);
      stamp.style.top = px(el.offsetHeight - 170);
      set(stamp, su > 0 ? 1 : 0, `rotate(-12deg) scale(${(2.2 - 1.2 * eo(su)).toFixed(3)})`, (1 - su) * 6);
    };
    return R;
  };

  // ---------- puppet strings: a control bar at the top, strings down to points you pass each frame; cut(u) drops them
  H.puppet = (p, cx, y, o = {}) => {
    const svg = add(p, h('div', { class: 'ht-abs' })); svg.style.left = '0px'; svg.style.top = '0px';
    svg.innerHTML = `<svg width="1080" height="1920" viewBox="0 0 1080 1920" style="position:absolute;left:0;top:0;overflow:visible"><g class="s" stroke="#cfcfcf" stroke-width="2.2" fill="none" opacity=".75"></g>
      <g class="bar"><rect x="${cx - 190}" y="${y - 14}" width="380" height="28" rx="14" fill="#e6e6e6"/><rect x="${cx - 14}" y="${y - 72}" width="28" height="144" rx="14" fill="#e6e6e6"/></g></svg>`;
    const g = svg.querySelector('.s'), bar = svg.querySelector('.bar');
    const tops = o.tops || [[cx - 180, y], [cx - 60, y], [cx + 60, y], [cx + 180, y]];
    const R = { el: svg, bar };
    // ends: [[x, y], …] one per top; cut 0–1: the strings snap at the bottom and spring up
    R.draw = ({ ends, cut = 0, sway = 0, t = 0 }) => {
      bar.setAttribute('transform', `rotate(${sway.toFixed(2)} ${cx} ${y})`);
      let d = '';
      tops.forEach(([tx, ty], i) => {
        const a = (sway * Math.PI / 180), rx = cx + (tx - cx) * Math.cos(a), ry = y + (tx - cx) * Math.sin(a);
        const [ex, ey] = ends[i % ends.length];
        const k = eo(cut), my = lerp(ey, ry + 120 + Math.sin(t * 7 + i) * 30 * (1 - k), k), mx = lerp(ex, rx + (i - 1.5) * 40 * k, k);
        d += `M${rx.toFixed(1)} ${ry.toFixed(1)} L${mx.toFixed(1)} ${my.toFixed(1)} `;
      });
      g.innerHTML = `<path d="${d}"/>`;
    };
    return R;
  };
})();
```

### `engine/film.css`

```css
/* Halftone template · STYLE. Motion's base rules first, then the HALFTONE look at the bottom, which wins.
   Fonts: Inter Tight (../../shared/fonts, loaded in film.html) · a mono (IBM Plex Mono / Cascadia Mono / Consolas). */
: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); }

/* ===================== HALFTONE look (overrides the Motion base above) =====================
   Near-black and warm cream, flipped hard per beat; ONE red (#e8211c) that glows on black; halftone-dot illustrations;
   a mono HUD (signal bars, label, clock, four crosses); captions = small grey line + one huge slammed word, left-aligned. */
:root { --h-red: #e8211c; --h-ink: #f2f2f0; --h-black: #111111; --h-cream: #efece6; --h-grey: #9b9b9b; --h-mono: 'IBM Plex Mono', 'Cascadia Mono', Consolas, monospace; }
html, body { background: #0a0a0b; }
.bg-dark { background: radial-gradient(1100px 900px at 50% 55%, #141416 0%, #0b0b0c 60%, #070708 100%); }
.bg-light { background: radial-gradient(1000px 1000px at 55% 45%, #f6f4ef 0%, #eeebe5 55%, #e4e0d8 100%); }
.cap { left: 120px; width: 800px; top: 392px; text-align: left; font-size: 68px; font-weight: 400; line-height: 1.1; letter-spacing: -0.02em; text-wrap: wrap; transform-origin: 0 0; }
.cap .w { display: inline-block; white-space: pre; transform-origin: 0 70%; }
.cap .l1 { color: #a3a3a3; }
.cap.m-light .l1 { color: #565656; }
.cap .l2, .cap .l3 { font-size: 250px; font-weight: 800; letter-spacing: -0.055em; line-height: 0.86; margin: 6px 0 0 -8px; white-space: nowrap; }
.cap .l2 { color: #f4f4f2; }
.cap.m-light .l2 { color: #121212; }
.cap .l3 { color: var(--h-red); text-shadow: 0 0 36px rgba(232,33,28,0.55), 0 0 90px rgba(232,33,28,0.28); }
.cap.m-light .l3 { text-shadow: none; }
.cap .l4 { color: #a3a3a3; font-size: 64px; margin-top: 18px; }
.cap.m-light .l4 { color: #565656; }
.cap.mid .l2, .cap.mid .l3 { font-size: 132px; letter-spacing: -0.045em; }
.cap.sm .l1 { color: #8e8e8e; }
.hud { position: absolute; inset: 0; pointer-events: none; --hc: #ebebeb; --hd: #969696; }
.hud .sig { position: absolute; left: 120px; top: 304px; height: 26px; display: flex; align-items: flex-end; gap: 4px; }
.hud .sig i { display: block; width: 6px; background: var(--hc); }
.hud .sig i:nth-child(1) { height: 8px; } .hud .sig i:nth-child(2) { height: 13px; } .hud .sig i:nth-child(3) { height: 19px; } .hud .sig i:nth-child(4) { height: 26px; }
.hud .sig i.off { opacity: 0.22; }
.hud .lbl { position: absolute; left: 164px; top: 303px; font: 700 25px/30px var(--h-mono); letter-spacing: 0.14em; color: var(--hc); }
.hud .rt { position: absolute; right: 120px; top: 303px; font: 400 25px/30px var(--h-mono); letter-spacing: 0.12em; color: var(--hd); text-align: right; }
.hud .rt.red { color: var(--h-red); }
.hud .rt.hi { color: var(--hc); font-weight: 700; }
.hud .x { position: absolute; width: 40px; height: 40px; margin: -20px 0 0 -20px; opacity: 0.55; }
.hud .x::before, .hud .x::after { content: ''; position: absolute; background: var(--hd); }
.hud .x::before { left: 19px; top: 0; width: 2px; height: 40px; } .hud .x::after { top: 19px; left: 0; height: 2px; width: 40px; }
.ht-mono { font-family: var(--h-mono); letter-spacing: 0.14em; }
.ht-abs { position: absolute; }
.ht-receipt { position: absolute; background: #faf9f5; color: #151515; box-shadow: 0 30px 60px rgba(60,50,30,0.22), 0 6px 14px rgba(0,0,0,0.08); }
.ht-receipt::before { content: ''; position: absolute; left: 0; right: 0; top: -9px; height: 10px; background: radial-gradient(circle at 7px 10px, #faf9f5 5px, transparent 5.5px) 0 0/14px 10px repeat-x; }
.ht-row { display: flex; align-items: baseline; gap: 14px; font: 500 34px/1 var(--h-mono); letter-spacing: 0.1em; margin-top: 30px; white-space: nowrap; }
.ht-row .dots { flex: 1; border-bottom: 2.5px dotted #9a9a9a; transform: translateY(-6px); }
.ht-row .v { font-weight: 700; }
.ht-row .v.red { color: var(--h-red); }
.ht-stamp { position: absolute; border: 7px solid var(--h-red); color: var(--h-red); border-radius: 10px; font: 900 88px/1 'Inter Tight', sans-serif; letter-spacing: 0.06em; padding: 10px 26px 12px; background: rgba(250,249,245,0.7); }
```

### `engine/markup.mjs`

```js
// Halftone · 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* still parses but Halftone draws no boxes)
// 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: 'nPczCjzI2devNBz1zQrb',   // ElevenLabs "Brian": deep, calm, hushed (the reference: male, F0 ~133 Hz, slow with long pauses)
  MODEL: 'eleven_multilingual_v2',
  SETTINGS: { stability: 0.55, similarity_boost: 0.8, style: 0.22, use_speaker_boost: true },
  SPEED: 0.98,
  GAP: 0.55, LEAD: 0.25, HOLD: 2.2, 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
// Halftone · 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 Inter Tight + IBM Plex Mono (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 = {
  'InterTight.ttf': 'https://raw.githubusercontent.com/google/fonts/main/ofl/intertight/InterTight%5Bwght%5D.ttf',
  'IBMPlexMono-Regular.ttf': 'https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Regular.ttf',
  'IBMPlexMono-Bold.ttf': 'https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Bold.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
// Halftone · the sounds, synthesized (no downloads, no licences, no credits):  node sounds.mjs [--force]
// sfx/thump tick beep stamp click swish 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),
  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
// Halftone · THE BED. A dark synthesized bed cut to this video's length → <video>\bed.wav (render.mjs --mix uses it):
// a low drone (A1 + E2 + a slow-wobbling A2), a heartbeat pulse (lub-dub every 0.92 s) and a little filtered air.
// It drops to almost nothing during the sections in script.mjs's QUIET (the reference's "Then… nothing." moment).
//   node bed.mjs --video <dir>          (after build-timeline.mjs; run it again whenever the timing changes)
import fs from 'node:fs'; import path from 'node:path';
import { loadScript, videoDir } from './engine/markup.mjs';
const HERE = import.meta.dirname, { abs: VD } = videoDir(HERE), SR = 48000;
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')), S = await loadScript(VD);
const quiet = (S.QUIET || []).map(id => { const s = TL.sections.find(x => x.id === id); if (!s) throw new Error('QUIET: no section ' + id); return [s.start - 0.06, s.until - 0.06]; });
const dur = TL.total + 0.5, n = Math.round(dur * SR), L = new Float32Array(n), R = new Float32Array(n);
let seed = 11; const rnd = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
const TAU = Math.PI * 2, P = 0.92;
let nz = 0, lpL = 0, lpR = 0, g = 1;
for (let i = 0; i < n; i++) {
  const t = i / SR;
  let v = 0.32 * Math.sin(TAU * 55 * t) * (0.7 + 0.3 * Math.sin(TAU * 0.25 * t)) + 0.18 * Math.sin(TAU * 82.4 * t) + 0.08 * Math.sin(TAU * 110.3 * t + Math.sin(TAU * 0.5 * t));
  const a = t % P, b = (t - 0.24 + P) % P;
  v += 0.9 * Math.exp(-a * 16) * Math.sin(TAU * 48 * t) + 0.55 * Math.exp(-b * 18) * Math.sin(TAU * 44 * t);
  nz += 0.12 * ((rnd() * 2 - 1) - nz);
  const air = nz * 0.05;
  // the quiet windows, with short ramps
  let target = 1; for (const [q0, q1] of quiet) if (t >= q0 && t < q1) target = 0.03;
  g += (target - g) * (1 - Math.exp(-1 / (0.03 * SR)));
  const fade = Math.min(1, t / 0.4);
  lpL += 0.11 * ((v + air) - lpL); lpR += 0.11 * ((v - air) - lpR);   // ~900 Hz one-pole low-pass
  L[i] = lpL * g * fade; R[i] = lpR * g * fade;
}
let m = 0; for (let i = 0; i < n; i++) 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);
for (let i = 0; i < n; i++) { b.writeInt16LE(Math.round(L[i] / m * 0.8 * 32767), 44 + i * 4); b.writeInt16LE(Math.round(R[i] / m * 0.8 * 32767), 46 + i * 4); }
fs.writeFileSync(path.join(VD, 'bed.wav'), b);
console.log(`bed.wav ${dur.toFixed(1)} s · quiet in ${quiet.map(q => q.map(x => x.toFixed(2)).join('–')).join(', ') || 'none'}`);
```

### `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.HALFTONE_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(), 'halftone-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
// Halftone · 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 (Brian, multilingual v2, speed 0.98)
// 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
// Halftone · 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
// Halftone · 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.11] [--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.11);
  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
// Halftone · 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" (37.5 s, 11 sections, 10 scenes). Copy it to start a new video, then rewrite the takes and every beat for your script.

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

```js
// HALFTONE · "Never pay for a Claude content skill again" — the script, in the reference's rhythm
// (@instinct_clothing_de "Just one more video": short fragments, one huge word per beat, long dark pauses).
// Markup: /  a new caption block   |  the big line in ink   !  the big line in red   ^  a small grey line under it
//   {shown|said}  the caption shows one thing, the voice says another
export const VOICE = 'nPczCjzI2devNBz1zQrb';   // ElevenLabs "Brian": deep, calm, hushed (reference: male, F0 ~133 Hz, slow with long pauses)
export const SETTINGS = { stability: 0.55, similarity_boost: 0.8, style: 0.22, use_speaker_boost: true };
export const SPEED = 0.98;
export const ORDER = ['s0', 's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8', 's9', 'cta'];
export const GAP = { s1: 0.55, s2: 0.6, s3: 0.5, s4: 0.45, s5: 0.55, s6: 0.6, s7: 0.45, s8: 0.55, s9: 0.5, cta: 0.5 };
export const LEAD = 0.25, HOLD = 2.2;
export const QUIET = ['s5'];   // bed.mjs: the bed drops out under "So... here's my response."
export const TAKES = {
  s0: "Just one more ! skill.",
  s1: "! {$30.|Thirty dollars,} ^ for a text file.",
  s2: "I'm | Casper. ^ I built Morphy, an agentic super app.",
  s3: "I gave my format away. ! Free.",
  s4: "People took it, / and sold it | for {$30.|thirty.}",
  s5: "So... here's my response.",
  s6: "It's | free. ! Again.",
  s7: "An entire library of {.md|dot M D} files, inside Morphy. / No paywalls. / No subscriptions. / No strings.",
  s8: "You shouldn't pay... | for a text file ! that was free.",
  s9: "This format is called ! {halftone.md|halftone dot M D.}",
  cta: "Comment ! {HALFTONE.|halftone,} ^ {I'll|and I'll} send it to you. Free.",
};
```

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

```js
// HALFTONE · "Never pay for a Claude content skill again" — the scenes. One scene per spoken beat; every time is a word.
// Black and cream flip hard per beat (F.looks), the HUD clock creeps on through the night, red is the only colour.
const { S, T0, T1, END, scene, cue, div, add, h, set, pop, rise, clamp, lerp, eo, eio, ease, back, rng, esc } = F;
const H = F.ht;
const st = id => S[id].start - 0.06;          // a beat starts just before its first word
const C = { full: true, din: 0.1, dout: 0.08, push: 0.035 };   // hard-ish cuts like the reference

F.looks([[-1, 'dark', 0.06], [st('s2'), 'light', 0.08], [st('s4'), 'dark', 0.08], [st('s6'), 'light', 0.08], [st('s8'), 'dark', 0.08], [st('s9'), 'light', 0.08], [st('cta'), 'dark', 0.08]]);
F.hud({
  label: 'MORPHY',
  right: [[-1, '03:12 AM'], [st('s1'), '03:19 AM'], [st('s2'), '03:26 AM'], [st('s4'), '03:33 AM'], [st('s5'), '03:41 AM'], [st('s6'), '03:47 AM', 'red'], [st('s7'), '03:47 AM'],
    [st('s8'), '03:48 AM'], [st('s9'), '03:49 AM'], [st('cta'), '03:49 AM'], [T0('cta', 'send'), 'SENT', 'hi'], [END - 1.2, '03:50 AM']],
  bars: [[-1, 4], [st('s5'), 2], [st('s6'), 2], [st('s8'), 1], [st('cta'), 4]],
});
// the receipt says it all in s7, so its words are not captions
F.cap.hide = (tk, s) => s.id === 's7';
// s1's $30 counts up from $0; s5's line sits under the flat line
F.cap.fmt = (tk, t) => {
  if (tk.d === '$30.' && tk.t0 < S.s2.start) { const u = clamp((t - (tk.t0 - 0.05)) / 0.75); return '$' + Math.round(30 * eo(u)) + (u >= 1 ? '.' : ''); }
  return null;
};
F.cap.pos = p => p.s.id === 's5' ? { top: 960, cls: 'sm' } : p.s.id === 's8' ? { cls: 'mid' } : null;

// ---------- 1 · "Just one more skill." — a hand holds a glowing phone, a $30 SKILL.md on its screen
{
  const sc = scene(-1, st('s1'), C);
  H.spot(sc.el, 560, 1180, 900, 900, 0.16, '190,200,255');
  const hp = H.handPhone(sc.el, 250, 830, 1.08);
  const tag = div(sc.el, 'ht-abs ht-mono', 120, 712, 600, 40, `<span style="display:inline-block;width:16px;height:16px;border-radius:50%;background:${H.RED};margin-right:16px;vertical-align:1px"></span>SKILL 001`, { font: "500 27px/40px 'IBM Plex Mono', Consolas, monospace", color: H.RED, letterSpacing: '0.16em' });
  const tS = T0('s0', 'skill');
  sc.on(t => {
    hp.screen(t < tS - 0.1 ? 'play' : 'card');
    hp.progress(0.35 + 0.1 * t);
    hp.el.style.transform = `translate(0px, ${(Math.sin(t * 1.4) * 6).toFixed(1)}px) rotate(${(Math.sin(t * 0.9) * 1.2).toFixed(2)}deg) scale(1.08)`;
    set(tag, clamp((t - tS) / 0.2));
  });
  cue('click', tS - 0.1, 0.1); cue('thump', tS, 0.22);
}

// ---------- 2 · "$30, for a text file." — the price counts up, ghost phones drift in the dark, a $0 —— $30 gauge
{
  const sc = scene(st('s1'), st('s2'), C);
  const r = rng(4), ghosts = Array.from({ length: 7 }, (_, i) => div(sc.el, 'ht-abs', 60 + r() * 900, 300 + r() * 1500, 300, 560, '', { border: '3px solid rgba(255,255,255,0.05)', borderRadius: '44px', transform: `rotate(${(r() * 40 - 20).toFixed(0)}deg)` }));
  const g = div(sc.el, 'ht-abs ht-mono', 120, 900, 760, 40, '', { font: "500 27px/40px 'IBM Plex Mono', Consolas, monospace", color: '#8a8a8a', letterSpacing: '0.12em', whiteSpace: 'nowrap' });
  const t0 = T0('s1', '$30');
  sc.on(t => {
    ghosts.forEach((e, i) => { e.style.marginTop = px((-(t - t0) * (40 + i * 12)).toFixed(1)); });
    const u = eo(clamp((t - (t0 - 0.05)) / 0.75)), v = Math.round(30 * u), len = Math.round(40 + 220 * u);
    F.html(g, `$0 <span style="display:inline-block;width:${len}px;height:2px;background:${H.RED};vertical-align:8px;margin:0 14px"></span><b style="color:#fff">$${v}</b>`);
  });
  for (let i = 0; i < 8; i++) cue('tick', t0 + i * 0.09, 0.05);
  cue('thump', t0 + 0.75, 0.2);
  function px(v) { return v + 'px'; }
}

// ---------- 3 · "I'm Casper. I built Morphy, an agentic super app." → "I gave my format away. Free." —
//   cream; a black phone scrolls an app of chats, then a pulse line runs faster and faster as it's shared
{
  const sc = scene(st('s2'), st('s4'), C);
  const labels = ['CHAT · CLAUDE', 'CHAT · CODEX', 'HALFTONE.MD', 'NOTES', 'CHAT · GROK', 'CARD · TASKS', 'FLOW', 'NOTES'];
  const ph = H.scrollPhone(sc.el, 600, 960, 0.98, {
    rot: -14,
    label: i => `<div style="position:absolute;left:24px;bottom:22px;font:600 22px/1 'IBM Plex Mono',Consolas,monospace;letter-spacing:.12em;color:rgba(255,255,255,.85)">${labels[i % 8]}</div>`,
  });
  const ecg = add(sc.el, h('canvas', { width: 1080, height: 200, class: 'ht-abs' })); ecg.style.left = '0px'; ecg.style.top = '960px';
  const g = ecg.getContext('2d');
  const tempo = div(sc.el, 'ht-abs ht-mono', 120, 1110, 500, 40, '', { font: "500 27px/40px 'IBM Plex Mono', Consolas, monospace", color: '#555', letterSpacing: '0.14em' });
  const tG = T0('s3', 'gave'), tF = T0('s3', 'free');
  let lastBeat = -1;
  sc.on(t => {
    const sp = t < tG ? 1 : lerp(1.5, 2.6, eo((t - tG) / (tF - tG + 0.4)));
    const y = t < tG ? (t - st('s2')) * 160 : (tG - st('s2')) * 160 + (t - tG) * (900 + 2400 * eo((t - tG) / 1.4));
    ph.draw({ y, blur: t < tG ? 0 : clamp((t - tG) / 0.6) * 14 });
    // the pulse line (only once it's shared)
    const on = clamp((t - (tG - 0.1)) / 0.2);
    ecg.style.opacity = on.toFixed(3); tempo.style.opacity = on.toFixed(3);
    g.clearRect(0, 0, 1080, 200);
    if (on > 0) {
      g.strokeStyle = '#151515'; g.lineWidth = 3.4; g.beginPath();
      const head = 380 + ((t - tG) * 420) % 700;
      for (let X = 0; X <= Math.min(1080, head); X += 2) {
        const ph2 = ((X / 110) * sp) % 1, beat = ph2 > 0.42 && ph2 < 0.58 ? Math.sin((ph2 - 0.42) / 0.16 * Math.PI * 2) * -70 : 0;
        X ? g.lineTo(X, 100 + beat) : g.moveTo(X, 100);
      }
      g.stroke(); g.fillStyle = '#151515'; g.beginPath(); g.arc(Math.min(1080, head), 100, 9, 0, 7); g.fill();
      F.html(tempo, `SHARED <b style="color:${H.RED};margin-left:18px">${sp.toFixed(1)}×</b>`);
    }
  });
  cue('swish', tG, 0.08); cue('swish', tG + 0.35, 0.09); cue('swish', tG + 0.6, 0.1); cue('swish', tG + 0.8, 0.1);
  cue('thump', tF, 0.2);
}

// ---------- 4 · "People took it, and sold it for $30." — red bars climb from the bottom, the price echoes like copies
{
  const sc = scene(st('s4'), st('s5'), C);
  const bars = H.bars(sc.el, { n: 11, seed: 3, lx: 120, ly: 1180 });
  const t0 = st('s4'), tS = T0('s4', 'sold'), tF = T0('s4', '$30');
  sc.on(t => {
    const lvl = lerp(0.55, 1.05, eo((t - t0) / (tF - t0 + 0.6)));
    const n = Math.round(lerp(1, 248, eo(clamp((t - t0) / (tF - t0 + 0.8)))));
    bars.draw({ t, level: lvl, labels: [`COPIES ▲ ${n}`, `PRICE  $${t < tS ? 0 : 30}`] });
    // ghost copies of the big price (the echo)
    const b = F.cap.blocks.find(p => p.s.id === 's4' && p.lines[2]);
    if (b) { const k = clamp((t - tF) / 0.4); b.lines[2].style.textShadow = k > 0 ? [1, 2, 3].map(i => `0 ${(-i * 16 * k).toFixed(1)}px 0 rgba(150,150,150,${(0.5 - i * 0.12).toFixed(2)})`).join(',') : 'none'; }
  });
  cue('riser', t0, 0.08); cue('glitch', tS, 0.08); cue('thump', tF, 0.24);
}

// ---------- 5 · "So... here's my response." — everything goes quiet: one flat line, a last little packet at $30
{
  const sc = scene(st('s5'), st('s6'), C);
  const fl = H.flatline(sc.el, 810);
  const t0 = st('s5'), tR = T0('s5', 'response');
  sc.on(t => { fl.draw({ t, amp: lerp(1, 0, eo((t - t0) / (tR - t0 + 0.3))), x: lerp(380, 560, (t - t0) / 2.2), label: '$30', dim: clamp((t - tR) / 0.5) }); });
  cue('beep', t0 + 0.05, 0.05, 0.6);
}

// ---------- 6 · "It's free. Again." — cream, a price tag swings on its string and drops to $0
{
  const sc = scene(st('s6'), st('s7'), C);
  const tg = H.priceTag(sc.el, 740, 760, 0.8);
  const tF = T0('s6', 'free'), tA = T0('s6', 'again');
  sc.on(t => {
    const k = t - tF, sw = k < 0 ? Math.sin(t * 2.2) * 4 : Math.sin(k * 9) * 14 * Math.exp(-k * 2.4) + Math.sin(t * 2.2) * 2;
    tg.draw({ swing: sw, value: k < 0 ? '$30' : '$0', label: k < 0 ? 'SKILL.MD' : 'SKILL.MD · FREE' });
  });
  cue('thump', tF, 0.24); cue('stamp', tA, 0.12);
}

// ---------- 7 · "An entire library of .md files, inside Morphy. No paywalls. No subscriptions. No strings." — a receipt
{
  const sc = scene(st('s7'), st('s8'), C);
  const rc = H.receipt(sc.el, 120, 420, 760, {
    dept: 'FORMATS DEPT.', form: 'FORM MD-01', title: 'Free Library', foot: 'THE MORPHY LIBRARY · 03:47 AM', stamp: 'FREE',
    rows: [['LIBRARY', '.MD FILES'], ['WHERE', 'IN MORPHY'], ['PAYWALLS', 'NONE'], ['SUBSCRIPTIONS', 'NONE'], ['STRINGS', 'NONE', true]],
  });
  const times = [T0('s7', 'library'), T0('s7', 'inside'), T0('s7', 'paywalls'), T0('s7', 'subscriptions'), T0('s7', 'strings')];
  const t0 = st('s7'), tSt = T1('s7', 'strings') + 0.1;
  sc.on(t => {
    const u = eo((t - t0) / 0.45);
    rc.el.style.transform = `translateY(${((1 - u) * -120).toFixed(1)}px) rotate(${(-2 + Math.sin(t * 0.8) * 0.6).toFixed(2)}deg)`;
    rc.draw({ t, times, stampT: tSt });
  });
  times.forEach(x => cue('click', x, 0.09)); cue('stamp', tSt, 0.16); cue('thump', tSt, 0.14);
}

// ---------- 8 · "You shouldn't pay... for a text file that was free." — the phone hangs on puppet strings; on "free" they snap
{
  const sc = scene(st('s8'), st('s9'), C);
  H.spot(sc.el, 540, 1320, 1000, 900, 0.22);
  const X = 250, Y = 900, s = 1.0;
  const hp = H.handPhone(sc.el, X, Y, s);
  hp.screen('dim'); hp.glow(0.4);
  const pp = H.puppet(sc.el, 540, 150);
  const tF = T0('s8', 'free');
  sc.on(t => {
    const cut = clamp((t - tF) / 0.5), sway = Math.sin(t * 1.6) * 4 * (1 - cut), drop = eio(clamp((t - tF - 0.05) / 0.5)) * 60;
    const dx = Math.sin(t * 1.6) * 18 * (1 - cut);
    hp.el.style.transform = `translate(${dx.toFixed(1)}px, ${(Math.sin(t * 3.2) * 8 + drop).toFixed(1)}px) rotate(${(sway * 0.6).toFixed(2)}deg) scale(${s})`;
    hp.screen(t < tF ? 'dim' : 'play'); hp.glow(t < tF ? 0.4 : 1);
    const ex = X + dx, ey = Y + drop;
    pp.draw({ t, sway, cut, ends: [[ex + 175, ey + 50], [ex + 250, ey + 36], [ex + 360, ey + 40], [ex + 440, ey + 60]] });
    set(pp.el, 1 - clamp((t - tF - 0.6) / 0.4));
  });
  cue('glitch', tF, 0.07); cue('implode', tF, 0.1); cue('thump', tF + 0.02, 0.16);
}

// ---------- 9 · "This format is called halftone.md." — cream, the phone scrolls the library and stops on a red tile
{
  const sc = scene(st('s9'), st('cta'), C);
  const names = ['KINETIC.MD', 'MOTION.MD', 'VOXEL.MD', 'PAPER.MD', 'PIXEL.MD', 'GLOW.MD', 'CUTSCENE.MD', 'HALFTONE.MD'];
  const cols = ['#8a8d96', '#5b5e66', '#8a8d96', '#5b5e66', '#8a8d96', '#5b5e66', '#8a8d96', '#c62a20'];
  const ph = H.scrollPhone(sc.el, 590, 900, 0.98, { rot: -16, tiles: cols, label: i => `<div style="position:absolute;left:24px;bottom:22px;font:600 22px/1 'IBM Plex Mono',Consolas,monospace;letter-spacing:.12em;color:rgba(255,255,255,.9)">${names[i % 8]}</div>` });
  const lab = div(sc.el, 'ht-abs ht-mono', 120, 1160, 400, 40, '', { font: "500 27px/40px 'IBM Plex Mono', Consolas, monospace", color: '#555', letterSpacing: '0.14em' });
  const t0 = st('s9'), tH = T0('s9', 'halftone');
  const yEnd = 200 * (7 + 8) - 200;           // the HALFTONE.MD tile (index 15) near the top
  sc.on(t => {
    const u = eio(clamp((t - t0) / (tH - t0 + 0.15)));
    const y = lerp(0, yEnd, u);
    ph.draw({ y, blur: (1 - Math.abs(u * 2 - 1)) * 12 * (u < 1 ? 1 : 0) });
    F.html(lab, `FORMAT <b style="color:${H.RED};margin-left:14px">${String(Math.min(11, 1 + Math.floor(u * 10.99))).padStart(3, '0')}</b>`);
  });
  cue('swish', t0 + 0.1, 0.08); cue('swish', t0 + 0.5, 0.08); cue('pop', tH, 0.1); cue('thump', tH, 0.16);
}

// ---------- 10 · "Comment HALFTONE, and I'll send it to you. Free." — the glowing phone; on "send" its screen turns red: SENT
{
  const sc = scene(st('cta'), END + 1, C);
  H.spot(sc.el, 560, 1200, 900, 900, 0.16, '190,200,255');
  const hp = H.handPhone(sc.el, 250, 900, 1.0);
  const tC = T0('cta', 'halftone'), tS = T0('cta', 'send');
  sc.on(t => {
    hp.screen(t < tS ? 'card' : 'sent');
    F.html(hp.card.children[1], 'FREE');
    hp.el.style.transform = `translate(0px, ${(Math.sin(t * 1.4) * 6).toFixed(1)}px) rotate(${(Math.sin(t * 0.9) * 1.2).toFixed(2)}deg)`;
    hp.glow(t < tS ? 1 : 1 + 0.4 * Math.exp(-(t - tS) * 3));
  });
  cue('thump', tC, 0.24); cue('chime', tS, 0.1);
}
```

