---
name: lantern
description: Make a narrated vertical reel (1080x1920, 40-60 s) in the "Lantern" format - a calm, funny "let's do the rough maths" explainer drawn as moonlit flat silhouettes - ink-black people and props with a thin gold rim light on deep teal, a moon (or a street lamp) with stepped glow rings, letterboxed shots with the subtitle in the black under the picture (2-4 words at a time, numbers in amber), a big condensed headline at the top for the hook and the name, "let's do the maths" as a gold light-line wipe into a blueprint (FIG. labels, line art drawn on, a pencil, a ruler), every number on an amber badge that counts up, a glowing lantern, a forest-at-night cast that sits, walks and hands things over, and a last shot that is the first one so the reel loops. One British explainer voice from ElevenLabs, a synthesized low-passed chord pad, drawn frame by frame in headless Chrome and encoded with ffmpeg. Use it when someone asks for a lantern reel, "lantern.md", a silhouette / moonlit / "rough maths" explainer, a reel in the style of a calm night-time illustrated explainer, or wants to turn a script into this kind of video.
---

# Lantern reel: how to make one

You will make a **40-60 second vertical video (1080x1920, 30 fps, H.264 + AAC, at most 60 s and 60 MB)** that feels like **a friend explaining something absurd, calmly, at night, with a pencil**: a hook, "don't panic, buddy, we've got you", "well, let's do the maths", every number on its own amber badge, a turn, the answer, the ask, and "Anyway." over the first shot so it loops. The picture is **flat night illustration**: ink silhouettes with a **thin gold rim light**, deep teal, a moon with **stepped glow rings**, one **amber** for everything that matters. **Every spoken claim gets its literal picture at its word**, all drawn from SVG and a little 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 Lantern reel: **this is what it should look like.** Left to right, top row first: (1) the hook: a black hand holds up a phone with a "Viral content skill · $30 · BUY NOW" screen inside warm amber stepped rings, the headline "NEVER PAY FOR A / CLAUDE CONTENT / SKILL AGAIN" in big condensed caps at the top, NEVER in amber; (2) a moonlit pine forest in a letterboxed panel: a guy on a log with a glowing laptop, a mint "Checkout · Viral Claude content skill · SKILL.md · $30.00 · BUY NOW" card over him, his buddy walking in with a lantern, the subtitle "Don't panic" under the picture; (3) close-up over his shoulder: the screen with "$30.00" in amber, a beam of lantern light from above, a big amber "?", subtitle "Is it worth it?" on a dark pill; (4) the blueprint: "FIG. 1 (THE OFFER)", the checkout drawn in gold line art, a pencil drawing a dimension line, a ruler; (5) a desk by a big window with the moon outside, a builder at a glowing monitor, a badge "MORPHY · AGENTIC SUPER APP"; (6) a SKILL.md page floating over a stand in mint rings, a film-strip icon, a gift disc, a "$0.00 · PRICE" badge; (7) a street lamp's warm cone over a stall: a seller in a fedora behind a box with a PREMIUM label and a swinging "$30" tag, the PRICE badge counting up; (8) "FIG. 2 (FROM ABOVE)": the box opened flat, one page of text lines, numbered leaders "1 TEXT / 2 MORE TEXT / 3 STILL TEXT", a "PLAIN TEXT · FILE TYPE" badge; (9) a balance in the rings: a weightless page against a pile of coins, slammed down on the coins' side, "$30"; (10) tall shelves lit by a hanging lantern, glowing format files sliding into the slots, "12 · FORMATS"; (11) the name: "THIS FORMAT IS CALLED / LANTERN.MD" over a big lantern in warm rings; (12) the ask: comment bubbles stacked in a phone frame, the top one "LANTERN ♥ 999" with a crown, a message "Here you go, completely free: LANTERN.MD", a solid "$0.00 · FREE" badge. If you only have this file, section 3 describes everything with numbers.

Everything is drawn **one frame at a time** as SVG + HTML 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 shot shows).

Work in this order and don't skip ahead: **setup -> script -> fake timings -> scenes -> stills -> voice -> real timings -> bed -> fix the shots -> 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, ~850 for a 56 s reel). Takes are cached: only a section whose words changed is made again.
4. **Stills before film.** Direct the shots 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, no real faces.** Everything is drawn: silhouettes, never likenesses; 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 shot 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, and **the voice on that account**: open ElevenLabs' Voice Library, search **"Daniel - Warm, Clear & Dynamic Guide"** and add it (or pick another voice, section 5). 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 (Bebas Neue, Open Sans) are free downloads (`setup.mjs` fetches them); on Windows the subtitles use Segoe UI Bold, which is already there. The 13 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

```
lantern-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 lantern.js film.css markup.mjs   clock + cuts + captions + 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 shot 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 lantern-reel && cd lantern-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,2.6,4.2,9,10.6,12.3,15.6,19.6,24.6,28.6,34,48.6 --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 chord pad, cut to this length
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 --to 1200 && node render.mjs --video my-video --full --from 1200
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.9   # 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 |
|---|---|---|
| Night | everywhere | `#02070b`; the film background fades `#071b23` (top) → `#02070b` (from y ~1200) |
| The panel (the shot's picture) | y 195-1195 by default | a letterboxed rectangle with its own background; its camera (`cam`) is the full frame in global coordinates, clipped to the panel. Close-ups and blueprints run it to y 1500-1520 (then the subtitle sits on a dark pill). The hook and the name shots are full-frame (0-1920). |
| Subtitle | centred on x 510, y 1352-1464 | one chunk of 2-4 words (3.4) |
| Headline | from y 330, centred on x 510, ≤ 820 px | the hook and the name only (3.4) |

**Safe zone (every platform covers the edges):** all text inside **x 60-960, y 250-1480**; subtitles 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 (text clipped away by a panel doesn't count).

### 3.2 Colours

| Token | Hex | Used for |
|---|---|---|
| ink | `#03080b`, back limbs `#0a151b`, seams `#1d3440` | every silhouette and prop |
| gold rim | `#e9a93a` as `drop-shadow(3-5px -3px 0 #e9a93a)` on the SVG group | the rim light on the side facing the light |
| amber | `#f2b13b` (hi `#ffd27a`) | badges, numbers in subtitles, key headline words, no-signs, coins, lamp light |
| mint | `#d7fbf4`, `#a9e6dd` | the headline, UI cards, labels, strings, screen glow |
| sky | `#0b2730 → #123b45 → #1a4c56` | forest panels; rings panels `#0b2730 → #18454f` |
| moon rings (9) | `#e6fdf8 #bdeee6 #97d4cd #76b8b5 #5b9c9e #478388 #376d75 #2b5b64 #224c55` | core → outside, steps of 50-70 px |
| lamp rings (9) | `#f3e0b2 #dcc58e #bea873 #97895e #6f6c4c #4d5340 #364337 #273830 #1d302c` | the hook, the name, the street lamp |
| pines | `#2f727a` far, `#225c66`, `#173f49` near; fog bands `#7fc4c0` at 16 % | the forest |
| blueprint | `#0c2a33 → #091f27`, gold grid at 16 % every 210 px and 5 % every 42 px, a teal glow pool | the maths |

### 3.3 Type

**Segoe UI Bold** (Windows) or **Open Sans** 700 (SIL OFL, `fonts/`) for subtitles and UI cards. **Bebas Neue** (SIL OFL) for the headline, badges, FIG. labels, prices and buttons (UPPERCASE, letter-spacing 0.01-0.16em).

### 3.4 The captions

- **Subtitles** (most sections): 74 px bold white, **one chunk at a time**, 2-4 words and at most 24 characters, cut at punctuation or at a `/` in the script, **sentence case** (the chunk's first letter capitalised), trailing `, . ; :` dropped (`?` and `!` kept), **digits and `*key*` words amber**. A chunk appears whole on its first word (no word-by-word reveal) and holds until the next chunk (at most 1.4 s after its last word). Too wide for 800 px: it shrinks. When the shot is `pill`, the chunk sits on a dark rounded pill (`rgba(12,21,27,0.84)`, radius 16).
- **Headline** (sections in `HEAD`): Bebas Neue 168 px caps, line-height 0.93, mint with amber key words, a soft dark shadow; lines split at `/`; **each word lands on its own time** (fade, a 14 px drop and 1.1 → 1 scale in 0.12 s); a line wider than 800 px shrinks. A HEAD section has no subtitle. The headline holds until the next section starts.
- **Hooks:** `F.cap.hide(tk, section)` drops a word; `F.cap.fmt(tk, t)` changes a word live; `F.cap.headTop.s13 = 340` moves a headline.

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

- **Panels + camera:** each shot is a panel with a slow push (4-6 %) or a framing (`L.cam`); the forest is reused with different framings (wide, closer, over the shoulder).
- **The moon and its rings** (`L.rings`): a core and 8 stepped rings that breathe slightly; the warm version for lamps. **Motes**: slow drifting dots (mint, amber, white).
- **The forest** (`L.forest`, `L.ground`): three rows of stylised pines (4 drooping tiers each) with fog bands between rows, a dark ground with grass tufts.
- **People** (`L.person`): a silhouette rig, ~520 px tall standing: torso (hoodie / coat / puffer with seam lines / trench / shirt), a head with a profile nose and an optional glinting eye, a hat (messy hair, beanie with a pompom, fedora, cap, hood), tapered limbs with round hands and shoes. It sits (`L.sit`), walks (`L.walk`), raises a lantern, hands a page over. The gold rim comes from a drop-shadow on the whole figure.
- **The lantern** (`L.lantern`): a ring handle, a cap, a glowing glass with a flickering flame and a warm halo. It is the format's mascot: carried, raised on the turn, hanging in the library, big on the name shot.
- **Props:** a glowing document (`L.doc`), a laptop on the knees (in the example's `forestShot`), a phone held up by a hand (the example's `hookShot`), a street lamp with a warm light cone, a stall, a box with a ribbon and a PREMIUM label, a price tag on a string (`L.priceTag`), a balance with coins, shelves with glowing files, a brick wall with a padlock, a puppet bar with strings, comment bubbles (`L.bubble`) with a crown and floating hearts.
- **The maths** (`L.blueprint` + line art): a gold line wipe opens a blueprint; `FIG. 1 (…)` labels in Bebas; gold line art drawn on with `L.draw`; a pencil (`L.pencil`) that follows the line it draws; a ruler; dimension lines with arrow heads (`L.dim`); numbered leaders to labels.
- **Badges** (`L.badge`): a dark plate with a 4 px amber border, a Bebas value (78 px) and a small spaced label; `solid` = amber plate, dark text; they pop in with a little overshoot and count up live. **Discs** (`L.disc`): round amber icons (gift, file, tag, lock, heart, books). **No-sign** (`L.noSign`), **impact rings** (`L.burst`), **sparkles**.

### 3.6 Motion

| Move | How |
|---|---|
| between shots | **hard cuts**, a soft whoosh under them; the maths opens with a gold light line sweeping right → left (0.42 s) |
| in a shot | a slow push (+4-6 %), breathing rings, drifting motes, a flickering lantern, people that bob, walk or type |
| a number | its badge pops in on the word and counts up over ~0.5 s with ticks |
| the turn | the lantern rises into the rings and flares (impact rings, sparkles, a chime) |
| a list ("No X. No Y. No Z.") | one hard cut per item, each item does its thing on its word (a wall crumbles, a no-sign draws, strings snap) |
| the end | "Anyway." cuts back to the first shot (without its headline) and the music fades over the last 0.9 s, so the reel loops |

## 4. Writing for this format

The reference grammar (and the example's): **a hook (a viewer's mess, or a hook line as the headline) → "don't panic, buddy, we've got you" → "Now, the first thing you're probably wondering is, …?" → "Well, let's do the maths." → the numbers, one per sentence, each with its badge → "But here's the catch" (or "So here's my response") → the answer → the name ("This format is called lantern.md") → the ask ("Comment LANTERN and I'll send it to you") → "Anyway."**

- One sentence per section; plain, dry, a little cheeky. Numbers are the stars: say them exactly ("thirty dollars", "four hundredths of a second").
- 150-160 words ≈ 55 s with the GAPs (0.3-0.5 s). Never over 60 s.
- Markup: `/` a new subtitle chunk (in a HEAD section: a new headline line) · `*word*` amber · `{shown|said}` when the caption and the voice differ (`{$30|thirty dollars}`, `{SKILL.md,|skill dot M D,}`, `{LANTERN,|lantern,}`). Digits are amber by themselves.
- `HEAD: ['s0', 's13']` in script.mjs names the headline sections (the hook and the name).

## 5. The voice (ElevenLabs)

**Daniel - Warm, Clear & Dynamic Guide** (`ynTHHllfDmGsZ3G8QJt9`, from the Voice Library: add it to your account first), `eleven_multilingual_v2`, stability 0.5, similarity 0.8, style 0.2, speaker boost, **speed 1.0**: a young British explainer, warm and quick (~170 wpm with the gaps; the reference: a male narrator, F0 ~129 Hz, ~165 wpm). These are `engine/markup.mjs` DEFAULTS; a `script.mjs` may export `VOICE`, `MODEL`, `SETTINGS` or `SPEED` to override them (no Daniel: George `JBFqnCBsd6RMkjVDRZzb` is a premade British narrator every account has). One take per section, word times from ElevenLabs' character alignment. Whisper will hear "thirty dollars" 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 which sections are headlines) and `vo.wav`. Markup-only edits (where `/` and `*` fall) need no new voice: it re-applies them to the takes. `--fake` guesses times so you can direct before the voice exists; every shot time comes from a word, so the real timeline moves every shot automatically. Run `bed.mjs` again after any timing change.

## 7. Directing

### 7.1 Times come from words

`T0('s6', 'took')` is the start of the first token in section s6 whose text starts with "took" (case and punctuation blind), `T1(…)` its end, `T0('s11', 'no', 1)` the second "No". `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.04` as every shot'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, s, svg, keys } = F;
const L = F.L, C = L.C;
const st = id => S[id].start - 0.04;
const rimF = (x = 3, y = -3) => `drop-shadow(${x}px ${y}px 0 ${C.gold})`;
{
  const sc = scene(st('s5'), st('s6'));                    // { pill: true } when the panel runs under the subtitle; { wipe: 'line' } for the maths
  const p = L.panel(sc, { top: 195, bottom: 1195, bg: 'linear-gradient(180deg, #0b2730 0%, #123b45 50%, #18454f 100%)', oy: 720 });
  const R = L.rings(p.svg, 540, 700, { r: 140, step: 64, n: 9 });
  const doc = L.doc(p.svg, 0, 0, 200, 262);
  const bd = L.badge(p.cam, 820, 340, '$0.00', 'PRICE');
  const tS = T0('s5', 'skill');
  sc.on(t => {
    R.draw(t);
    doc.g.setAttribute('transform', `translate(470 ${(640 + Math.sin(t * 2) * 8).toFixed(1)})`);
    pop(bd.el, t, tS, { s0: 0.3 });
    L.push(p, t, st('s5'), st('s6'), 1, 1.05);
  });
  cue('pop', tS, 0.1);
}
```

- A shot is shown from `tin` to `tout` with hard cuts. Make each end where the next begins (`st(next)`).
- Draw everything in **global 1080x1920 coordinates** on `p.svg` (SVG) or `p.cam` (HTML: badges, cards, labels); the panel clips it. Animate per frame in `sc.on(t => …)`: `set(el, opacity, transform)`, `pop(el, t, t0, { s0, d, t1, dy, rot })`, `rise`, `F.html(el, s)` (only touches the DOM when it changed). Easing: `eo`, `eio`, `ease`, `back`; `clamp`, `lerp`, `rng(seed)`, `keys([[t, v]…], t)`.
- Sounds: `cue(name, time, gain[, length])` with `whoosh` (a cut, 0.05-0.12), `swish` (a move, 0.04-0.07), `pop` (a badge or card, 0.08-0.1), `tick` (count-ups, 0.04-0.06), `thump` (a reveal, 0.16-0.24), `chime` (the lantern, 0.05-0.09), `riser`, `impact` (the hook, 0.14), `click`, `stamp`, `beep`, `glitch`, `implode`.
- The example's helpers are the fastest start: `hookShot(tin, tout, { noAt, pill })` (the hand + phone in warm rings), `forestShot(tin, tout, { buddy: 'walkin' | 'stand' | 'hand', card, closed, cam: t => [scale, x, y], … })` (the forest with both characters) and `rigShot(tin, tout, draw)` (a frame inside mint rings). Copy them and change what they show.

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

| Part | Options and use |
|---|---|
| `L.panel(sc, { top, bottom, bg, ox, oy })` → `{ el, cam, svg }` · `L.cam(p, scale, x, y)` · `L.push(p, t, t0, t1, s0, s1, x0, x1, y0, y1)` | the shot's picture (default y 195-1195) and its camera (scale around `ox, oy`) |
| `L.rings(p, cx, cy, { r, step, n, tone: 'mint' \| 'amber', alpha, core })` → `{ g, draw(t, k) }` | the moon or lamp with stepped rings; `k` scales them (a flare) |
| `L.glow(p, cx, cy, r, colour, alpha)` · `L.linear(p, [[offset, colour, alpha]…], x1, y1, x2, y2)` | a soft light pool (returns the circle) · a gradient fill (returns a `url(#…)`) |
| `L.forest(p, { y, seed, rows: [[count, hMin, hMax, colour, yOffset]…], fog })` · `L.pine(x, y, w, h)` · `L.ground(p, y, { tufts, fill, edge, seed })` · `L.motes(p, { n, seed, box: [x0, y0, x1, y1], colors })` → `{ draw(t) }` | pines, ground and grass, drifting dots |
| `L.person(p, { kind, hat, rim: [dx, dy], eye, scale, fill })` → `{ g, pose(P) }` | the rig. `P = { x, y, dir, lean, head, a, b, l, m, fa, fb, scale }`: (x, y) is the hip; `dir` 1 faces right, -1 left; `a`/`b` = front/back arm `[shoulder, elbow]`, `l`/`m` = front/back leg `[hip, knee]`, in degrees from straight down, positive = towards where they face; `pose()` returns the joints `hip neck head hand hand2 elbow knee ankle ankle2` in global coordinates (to attach props) |
| `L.walk(phase, stride)` → `{ l, m, a, b, bob }` · `L.sit({ a, b, lean })` | a walk cycle · a seated pose (a seat ~110 px tall, the hip on it) |
| `L.lantern(p)` → `{ g, draw(t, glow) }` | the lantern (its handle at the group's 0,0; ~150 tall at scale 1); `glow` 0.5-2 |
| `L.doc(p, x, y, w, h, { title, lines, fs })` → `{ g, glow }` | a glowing document with a folded corner |
| `L.draw(el, u)` | draws any SVG line art on as u goes 0 → 1 |
| `L.noSign(p, cx, cy, r, { w })` → `{ draw(u) }` · `L.burst(p, x, y, { r })` → `{ draw(t, t0) }` · `L.sparkle(p, x, y, r)` | a gold no-sign drawn on · impact rings from t0 · a 4-point sparkle |
| `L.badge(p, cx, cy, value, label, { solid })` → `{ el, b, v, l }` | an amber badge centred on (cx, cy): pop `el`, set `v` live |
| `L.label(p, x, y, text, { mint, style })` · `L.disc(p, cx, cy, r, html)` + `L.ICON.gift / file / tag / lock / heart / books()` | a Bebas label · an amber icon disc |
| `L.blueprint(p, { glow: [x, y] })` · `L.pencil(p)` · `L.ruler(p, x, y, w)` · `L.dim(p, x1, x2, y)` → `{ draw(u) }` | the blueprint background (a div), a pencil (tip at its 0,0), a ruler, a dimension line |
| `L.priceTag(p, text, { w, h, fs })` → `{ g, tx }` · `L.bubble(p, x, y, w, { text })` | a tag on a string (hung from its 0,0) · a mint comment bubble |
| `L.smooth(points, closed, k)` | a smooth SVG path through points (for your own shapes) |

### 7.4 The edit follows the words

Every claim gets its literal picture at that moment: "pay thirty dollars for one" → the checkout card with $30.00; "don't panic, buddy" → the buddy walks in with the lantern; "is it worth it?" → a close-up of the price and a big amber "?"; "let's do the maths" → the blueprint; "I built Morphy, an agentic super app" → a builder at a glowing app, a badge with the name; "I gave away … completely free" → copies fly out, PRICE $0.00, FREE stamps on; "took it, repackaged it, selling it for thirty dollars" → caught, boxed, tagged, the PRICE badge counts to $30.00; "just a text file" → the box opened flat: one page of text; "thirty dollars for something free" → the balance slams down; "here's my response" → the lantern flares; "an entire library" → files fill the shelves, a counter; "no paywalls / subscriptions / strings" → a wall crumbles, a no-sign, the strings snap; "free to begin with" → the page handed over, "$0"; "comment LANTERN" → the comment with the crown; "Anyway." → the first shot again.

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

- Nothing appears before its word, nor later than the word.
- One subject per shot, with room around it; the rim light on the side facing the light.
- Keep it calm: slow pushes, gentle bobs; the only fast things are the cuts, the pops and the snaps.
- Every frame has its subject on screen; a cut lands on a word.

## 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 (it catches a label that slides in from the side). Look at the sheet every time: a head cut off by the panel top, a subtitle over a busy picture without its pill, a label past x 960.

## 9. The full render

`render.mjs --full` writes `my-video/frames/f00000.jpg …` (~155 ms a frame on a laptop GPU: a 56 s film ≈ 4.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 shots.

## 10. Sound

`bed.mjs` makes `my-video/bed.wav`: a soft chord pad (Am9 → Fmaj7 → Cadd9 → G6, one every 4.8 s, `--bar` to change), each note two detuned voices of a few soft harmonics with a slow attack and release, a quiet sub under the root, low-passed at ~800 Hz. `render.mjs --mix` mixes the voice, that bed (ducked under the voice) and every `cue()`, fades the bed over the last 0.9 s, then brings it all to **−14 LUFS integrated, true peak ≤ −1 dBTP**. It prints how far the bed sits under the voice: aim for **~12 LU** (`--music 0.25` is the default).

## 11. Encode, check, deliver

`render.mjs --encode --out video.mp4 --cover 1.9` makes **H.264 High, yuv420p, 1080x1920, 30 fps, `-crf 20 -maxrate 10M -bufsize 20M`, AAC 192k, `+faststart`** (~11 MB for 56 s) and `cover.jpg` (the hook frame, with its headline). `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 voice is on the ElevenLabs account · the script follows the grammar of section 4 · one sentence per section · ≤ 60 s
- [ ] fake-timing stills right before any credit is spent · `gen-vo.mjs` dry run read, then `--yes`
- [ ] `bed.mjs` run after the real timings
- [ ] every claim has its picture on its word · every number on a badge · the last shot is the first
- [ ] `--scan` and the safe stills clean · full render · mix at −14 LUFS, bed ~12 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>lantern</title>
<style>
@font-face { font-family: 'Bebas Neue'; src: url(fonts/BebasNeue-Regular.ttf) format('truetype'); }
@font-face { font-family: 'Lantern Sans'; src: local('Segoe UI Bold'), local('SegoeUI-Bold'), url(fonts/OpenSans.ttf) format('truetype'); font-weight: 700; }
@font-face { font-family: 'Lantern Sans'; src: local('Segoe UI Semibold'), local('SegoeUI-Semibold'), url(fonts/OpenSans.ttf) format('truetype'); font-weight: 600; }
@font-face { font-family: 'Lantern Sans'; src: local('Segoe UI'), local('SegoeUI'), url(fonts/OpenSans.ttf) format('truetype'); font-weight: 400; }
</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/lantern.js', VIDEO_DIR + '/scenes.js'])
    document.write(`<script src="${s}"><\/script>`);
</script>
</body>
</html>
```

### `engine/core.js`

```js
// Lantern template · ENGINE CORE (from Halftone). Same for every video: don't edit it while making one (a video's own scenes are
// <video>\scenes.js). One page, one clock: window.setT(t) draws film time t (s). Every beat is keyed to a spoken
// word from <video>\timeline.js via T0(section, word[, n]) / T1(...).
// Load order (film.html): <video>/timeline.js → core.js → lantern.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 ei = x => Math.pow(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;');
  // keys: [[t, v], …] → the value at t, eased between keys (numbers or arrays of numbers)
  const keys = (k, t, e = eio) => {
    if (t <= k[0][0]) return k[0][1];
    for (let i = 1; i < k.length; i++) if (t < k[i][0]) {
      const u = e((t - k[i - 1][0]) / (k[i][0] - k[i - 1][0])), a = k[i - 1][1], b = k[i][1];
      return Array.isArray(a) ? a.map((x, j) => lerp(x, b[j], u)) : lerp(a, b, u);
    }
    return k.at(-1)[1];
  };
  Object.assign(F, { TAU, clamp, lerp, ease, eio, eo, ei, back, px, rng, esc, keys });

  // ---------- 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; };
  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)` : '';
    return op;
  };
  // pop: spring in from scale s0 with overshoot; optional exit at t1
  const pop = (el, t, t0, o = {}) => {
    const { d = 0.38, s0 = 0.4, t1 = null, d1 = 0.18, x = 0, y = 0, rot = 0, dy = 0 } = o;
    const a = clamp((t - t0) / d);
    let op = clamp(a * 3), sc = s0 + (1 - s0) * back(a), yy = y + (1 - eo(a)) * dy;
    if (t1 != null) { const z = eo((t - t1) / d1); op *= 1 - z; sc *= 1 - 0.3 * z; }
    return set(el, op, `translate(${x}px, ${yy.toFixed(1)}px) rotate(${rot}deg) scale(${sc.toFixed(4)})`);
  };
  const rise = (el, t, t0, o = {}) => {
    const { d = 0.3, dy = 30, t1 = null, d1 = 0.2, x = 0 } = o;
    const a = eo((t - t0) / d);
    let op = a, y = (1 - a) * dy;
    if (t1 != null) { const z = ease((t - t1) / d1); op *= 1 - z; }
    return set(el, op, `translate(${x}px, ${y.toFixed(1)}px)`);
  };
  const html = (el, s) => { if (el.__h !== s) { el.innerHTML = s; el.__h = s; } };
  const SVGNS = 'http://www.w3.org/2000/svg';
  // s(parent, tag, attrs) — an SVG element
  const s = (p, tag, attrs = {}) => { const e = document.createElementNS(SVGNS, tag); for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v); if (p) p.appendChild(e); return e; };
  // svg(parent, x, y, w, h) — an absolutely placed <svg> with a matching viewBox (global coordinates when x = y = 0)
  const svg = (p, x = 0, y = 0, w = 1080, hh = 1920, vb) => { const e = s(p, 'svg', { width: w, height: hh, viewBox: vb || `${x} ${y} ${w} ${hh}` }); e.style.position = 'absolute'; e.style.left = px(x); e.style.top = px(y); e.style.overflow = 'visible'; return e; };
  Object.assign(F, { h, add, box, div, set, pop, rise, html, s, svg, SVGNS });

  // ---------- 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, '');
  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);

  // ---------- scenes: hard cuts (the reference cuts hard), a slow push, optional gold line wipe in ----------
  // scene(tin, tout, { din: fade in (0.04 = a cut), dout, push: zoom over the shot, wipe: 'line' (a gold light line sweeps
  //   right→left revealing the shot), pill: subtitle on a dark pill (the picture runs under it), panel: {top, bottom} })
  const scenesRoot = add(stage, h('div', { class: 'layer' }));
  const scenes = [];
  F.scenes = scenes;
  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);
    if (o.wipe === 'line') sc.line = add(stage, h('div', { class: 'wipeline' }));
    scenes.push(sc); return sc;
  };
  const drawScene = (sc, t) => {
    const { el, tin, tout, o } = sc;
    const live = !(t < tin - 0.001 || t >= tout + (o.dout ?? 0));
    if (!live) { if (el.style.display !== 'none') el.style.display = 'none'; if (sc.line) sc.line.style.display = 'none'; return false; }
    el.style.display = 'block';
    const a = tin < 0 ? 1 : (o.din ?? 0) > 0 ? eo((t - tin) / o.din) : 1;
    const z = (o.dout ?? 0) > 0 ? clamp((t - tout) / o.dout) : 0;
    el.style.opacity = (a * (1 - z)).toFixed(3);
    if (o.wipe === 'line') {
      const W = o.wipeDur ?? 0.42, u = eio((t - tin) / W), x = 1100 - 1140 * u;
      el.style.clipPath = u >= 1 ? '' : `inset(0 0 0 ${Math.max(0, x).toFixed(1)}px)`;
      sc.line.style.display = u >= 1 || u <= 0 ? 'none' : 'block';
      sc.line.style.left = px(x - 4); sc.line.style.opacity = (1 - clamp((u - 0.85) / 0.15)).toFixed(3);
    }
    return true;
  };
  // the scene that owns time t (the latest one that started)
  const sceneAt = t => { let c = null; for (const sc of scenes) if (t >= sc.tin - 0.001 && t < sc.tout) c = sc; return c; };
  F.sceneAt = sceneAt;

  // ---------- whole-frame effects ----------
  const flashEl = add(stage, h('div', { class: 'flash' }));
  const flashes = [], shakes = [];
  F.flash = (t, peak = 0.55, d = 0.28) => flashes.push([t, peak, d]);
  F.shake = (t, amp = 12, dur = 0.3) => shakes.push([t, amp, dur]);
  const drawFx = t => {
    let fl = 0; for (const [t0, pk, d] of flashes) fl = Math.max(fl, pk * clamp((t - t0) / 0.04) * (1 - clamp((t - t0 - 0.04) / d)));
    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) * (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 ----------
  // SUB (every section not in HEAD): chunks of 2–4 words (≤ 24 characters), cut at punctuation or a `/`; a chunk shows
  //   whole on its first word and holds until the next chunk (at most 1.4 s after its last word). Sentence case,
  //   trailing , . ; : dropped, ? ! kept, digits and *key* words amber. On a dark pill when the shot says pill.
  // HEAD (sections in HEAD): big condensed caps at the top; lines split at `/`; each word lands on its own time.
  // F.cap.hide(token, section) drops a word · F.cap.fmt(token, t) → live text · F.cap.headTop[id] = y
  F.cap = { hide: null, fmt: null, headTop: {}, chunks: [], heads: [] };
  const capRoot = add(stage, h('div', { class: 'over' }));
  const FIT = 800;
  const clean = d => d.replace(/[,.;:]+$/, '');
  const amberize = (txt, key) => key ? `<span class="a">${esc(txt)}</span>` : esc(txt).replace(/(\$?\d[\d.,]*%?)/g, '<span class="a">$1</span>');
  const buildCaptions = () => {
    if (F.NOCAP) return;
    const chunks = F.cap.chunks;
    for (const sec of TL.sections) {
      if (sec.head) continue;
      let cur = null, prevPunct = false;
      for (const tk of sec.tokens) {
        if (F.cap.hide && F.cap.hide(tk, sec)) { prevPunct = /[.,?!;:]$/.test(tk.d); continue; }
        const words = cur ? cur.toks.length : 0, chars = cur ? cur.toks.reduce((a, k) => a + clean(k.d).length + 1, 0) : 0;
        if (!cur || tk.br || prevPunct || words >= 4 || chars + clean(tk.d).length > 24) { cur = { s: sec, toks: [] }; chunks.push(cur); }
        cur.toks.push(tk); prevPunct = /[.,?!;:]$/.test(tk.d);
      }
    }
    for (const c of chunks) c.t0 = c.toks[0].t0;
    chunks.forEach((c, i) => {
      const nx = chunks[i + 1];
      const lastEnd = c.toks.at(-1).t1;
      c.t1 = Math.min(nx ? nx.t0 : TL.total + 1, Math.max(lastEnd + 0.25, Math.min(c.s.until, lastEnd + 1.4)));
      if (nx && nx.s !== c.s && nx.t0 - lastEnd < 1.4) c.t1 = nx.t0;
      c.el = add(capRoot, h('div', { class: 'sub' }));
      c.t = add(c.el, h('span', { class: 't' }));
      c.render = (t) => {
        const parts = c.toks.map((tk, j) => {
          let d = (F.cap.fmt && F.cap.fmt(tk, t)) ?? tk.d;
          d = clean(d); if (j === 0) d = d.charAt(0).toUpperCase() + d.slice(1);
          return amberize(d, tk.key);
        });
        return parts.join(' ');
      };
    });
    for (const sec of TL.sections) {
      if (!sec.head) continue;
      const hd = { s: sec, el: add(capRoot, h('div', { class: 'head' })), w: [] };
      if (F.cap.headTop[sec.id] != null) hd.el.style.top = px(F.cap.headTop[sec.id]);
      let ln = null;
      sec.tokens.forEach((tk, j) => {
        if (F.cap.hide && F.cap.hide(tk, sec)) return;
        if (!ln || tk.br) ln = add(hd.el, h('div', { class: 'ln' }));
        const sp = add(ln, h('span', { class: 'w' + (tk.key ? ' a' : '') }, esc(clean(tk.d).toUpperCase())));
        ln.appendChild(document.createTextNode(' '));
        hd.w.push({ tk, sp });
      });
      hd.t0 = sec.tokens[0].t0; hd.t1 = sec.until - 0.02;
      F.cap.heads.push(hd);
    }
  };
  const fitHead = hd => {
    if (hd.__fit) return; hd.__fit = 1;
    for (const ln of hd.el.querySelectorAll('.ln')) { const w = ln.scrollWidth; if (w > FIT) ln.style.fontSize = px(Math.floor(168 * FIT / w)); }
  };
  const drawCaptions = t => {
    const sc = sceneAt(t), pill = !!(sc && sc.o.pill);
    for (const c of F.cap.chunks) {
      if (t < c.t0 - 0.02 || t >= c.t1 - 0.02) { if (c.el.style.display !== 'none') c.el.style.display = 'none'; continue; }
      c.el.style.display = 'flex';
      html(c.t, c.render(t));
      c.t.classList.toggle('pill', pill);
      if (c.__fitKey !== c.t.textContent) { c.__fitKey = c.t.textContent; c.t.style.fontSize = ''; const w = c.t.scrollWidth; if (w > FIT) c.t.style.fontSize = px(Math.floor(74 * (FIT - 52) / (w - 52))); }
    }
    for (const hd of F.cap.heads) {
      if (t < hd.t0 - 0.05 || t >= hd.t1) { if (hd.el.style.display !== 'none') hd.el.style.display = 'none'; continue; }
      hd.el.style.display = 'block'; fitHead(hd);
      for (const w of hd.w) {
        const u = clamp((t - (w.tk.t0 - 0.03)) / 0.12);
        w.sp.style.opacity = u.toFixed(3);
        w.sp.style.transform = `translateY(${((1 - eo(u)) * 14).toFixed(1)}px) scale(${(1.1 - 0.1 * eo(u)).toFixed(4)})`;
      }
    }
  };

  // ---------- 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 = 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('.sub, .head');
      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;
      // a clipped panel hides what's outside it: only count the visible part
      const pn = el.closest('.panel');
      if (pn) { const pr = pn.getBoundingClientRect(); if (y1 < pr.top || y0 > pr.bottom || x1 < pr.left || x0 > pr.right) continue; }
      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;
  };

  F.PREP = [];
  F.prep = list => { F.PREP = list; };

  // ---------- start ----------
  F.start = async () => {
    buildCaptions();
    if (F.SAFE) buildSafe();
    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 = [];
    window.PREP = F.PREP;
    window.setT = async t => {
      drawFx(t);
      for (const s of scenes) if (drawScene(s, t)) for (const u of s.upd) u(t);
      for (const u of updaters) await u(t);
      drawCaptions(t);
      await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
      return true;
    };
    await document.fonts.ready;
    await Promise.all([...document.images].map(i => i.decode().catch(() => { })));
    await window.setT(0);
    return true;
  };
  window.ready = new Promise(res => addEventListener('DOMContentLoaded', () => F.start().then(res, e => { F.errors.push('start: ' + (e && e.stack || e)); res(false); })));
})();
```

### `engine/lantern.js`

```js
// Lantern template · THE PARTS (F.L). Flat night illustration from SVG: stepped moon rings, layered pines, silhouette people with a
// gold rim light, glowing props, blueprint figures, amber badges. Every part is drawn in GLOBAL 1080x1920 coordinates
// (a panel's cam is the full frame, clipped to the panel), and animated by the scene's sc.on(t => …).
(() => {
  const { s, svg, div, add, h, set, clamp, lerp, eo, eio, ease, back, rng, keys, px, esc } = F;
  const L = F.L = {};
  const C = L.C = {
    ink: '#03080b', ink2: '#0a151b', ink3: '#132630', gold: '#e9a93a', amber: '#f2b13b', amberHi: '#ffd27a',
    mint: '#d7fbf4', mint2: '#a9e6dd', teal: '#1d4f59', night: '#02070b',
    // the moon's stepped rings, core → outside (the reference's halo), and the warm lamp version (the "3 reasons" reel)
    RINGS: { mint: ['#e6fdf8', '#bdeee6', '#97d4cd', '#76b8b5', '#5b9c9e', '#478388', '#376d75', '#2b5b64', '#224c55'],
      amber: ['#f3e0b2', '#dcc58e', '#bea873', '#97895e', '#6f6c4c', '#4d5340', '#364337', '#273830', '#1d302c'] },
    SKY: { mint: ['#0c2a33', '#16434d'], amber: ['#0b1e25', '#1a2c2d'] },
  };
  const D = (v, n = 1) => v.toFixed(n);

  // ---------- panel + camera ----------
  // L.panel(sc, { top = 195, bottom = 1195, bg }) → { el, cam, svg } — the picture of a shot. cam is full-frame (global
  // coordinates) and clipped to the panel. L.cam(p, t, { s, x, y }) frames it: scale s around (x, y).
  L.panel = (sc, o = {}) => {
    const top = o.top ?? 195, bottom = o.bottom ?? 1195;
    const el = div(sc.el, 'panel', 0, top, 1080, bottom - top);
    if (o.bg) el.style.background = o.bg;
    const cam = div(el, 'cam', 0, -top);
    cam.style.transformOrigin = `${o.ox ?? 540}px ${o.oy ?? (top + bottom) / 2}px`;
    const sv = svg(cam);
    return { el, cam, svg: sv, top, bottom };
  };
  L.cam = (p, sc, x = 0, y = 0, rot = 0) => { p.cam.style.transform = `translate(${D(x, 2)}px, ${D(y, 2)}px) scale(${sc.toFixed(5)}) rotate(${rot}deg)`; };
  // a slow push over a shot: from s0 to s1 between t0 and t1
  L.push = (p, t, t0, t1, s0 = 1, s1 = 1.06, x0 = 0, x1 = 0, y0 = 0, y1 = 0) => { const u = ease((t - t0) / Math.max(0.1, t1 - t0)); L.cam(p, lerp(s0, s1, u), lerp(x0, x1, u), lerp(y0, y1, u)); };

  // ---------- smooth closed / open paths through points (Catmull-Rom → cubic Bézier) ----------
  const smooth = (P, closed = true, k = 0.5) => {
    const n = P.length; if (n < 3) return 'M' + P.map(p => p.map(v => D(v)).join(' ')).join('L');
    const g = i => closed ? P[(i + n) % n] : P[Math.max(0, Math.min(n - 1, i))];
    let d = `M${D(P[0][0])} ${D(P[0][1])}`;
    for (let i = 0; i < (closed ? n : n - 1); i++) {
      const p0 = g(i - 1), p1 = g(i), p2 = g(i + 1), p3 = g(i + 2);
      const c1 = [p1[0] + (p2[0] - p0[0]) * k / 3, p1[1] + (p2[1] - p0[1]) * k / 3], c2 = [p2[0] - (p3[0] - p1[0]) * k / 3, p2[1] - (p3[1] - p1[1]) * k / 3];
      d += `C${D(c1[0])} ${D(c1[1])} ${D(c2[0])} ${D(c2[1])} ${D(p2[0])} ${D(p2[1])}`;
    }
    return d + (closed ? 'Z' : '');
  };
  L.smooth = smooth;

  // ---------- sky: the moon (or a lamp) and its stepped rings ----------
  // L.rings(parent, cx, cy, { r = 120, step = 70, n = 8, tone: 'mint'|'amber', soft = 2 }) → { g, draw(t, pulse) }
  L.rings = (p, cx, cy, o = {}) => {
    const r = o.r ?? 120, st = o.step ?? 70, tone = C.RINGS[o.tone || 'mint'], n = Math.min(o.n ?? 8, tone.length);
    const g = s(p, 'g');
    const cs = [];
    for (let i = n - 1; i >= 0; i--) cs.push(s(g, 'circle', { cx, cy, r: r + i * st, fill: tone[i], opacity: o.alpha ?? 1 }));
    if (o.core !== false) s(g, 'circle', { cx, cy, r: r * 0.86, fill: o.tone === 'amber' ? '#fbefcf' : '#f2fffc', opacity: 0.55 });
    return { g, draw: (t, k = 1) => { cs.forEach((c, j) => { const i = n - 1 - j; c.setAttribute('r', D((r + i * st) * (1 + 0.012 * Math.sin(t * 1.3 - i * 0.5)) * k)); }); } };
  };
  // a soft glow pool (radial gradient)
  let gid = 0;
  L.glow = (p, cx, cy, r, color = '#9fe9df', a = 0.5) => {
    const id = 'lg' + (gid++), defs = s(p, 'defs'), rg = s(defs, 'radialGradient', { id });
    s(rg, 'stop', { offset: '0', 'stop-color': color, 'stop-opacity': a }); s(rg, 'stop', { offset: '1', 'stop-color': color, 'stop-opacity': 0 });
    return s(p, 'circle', { cx, cy, r, fill: `url(#${id})` });
  };
  L.linear = (p, stops, x1 = 0, y1 = 0, x2 = 0, y2 = 1) => {
    const id = 'lg' + (gid++), defs = s(p, 'defs'), lg = s(defs, 'linearGradient', { id, x1, y1, x2, y2 });
    for (const [o, c, a = 1] of stops) s(lg, 'stop', { offset: o, 'stop-color': c, 'stop-opacity': a });
    return `url(#${id})`;
  };

  // ---------- pines, ground, grass, fog ----------
  const pine = (x, y, w, hh) => {   // a stylised pine: 4 drooping tiers on a short trunk, base at (x, y)
    const tiers = 4, top = y - hh; let d = `M${D(x - w * 0.05)} ${D(y)}L${D(x - w * 0.05)} ${D(y - hh * 0.12)}L${D(x + w * 0.05)} ${D(y - hh * 0.12)}L${D(x + w * 0.05)} ${D(y)}Z`;
    for (let i = 0; i < tiers; i++) {
      const ty = top + (i / tiers) * hh * 0.82, by = top + ((i + 1.35) / tiers) * hh * 0.82, hw = w * (0.2 + 0.8 * (i + 1) / tiers) / 2;
      d += `M${D(x)} ${D(ty)}L${D(x + hw)} ${D(by)}L${D(x + hw * 0.55)} ${D(by - hh * 0.03)}L${D(x + hw * 0.3)} ${D(by + hh * 0.012)}L${D(x)} ${D(by - hh * 0.02)}L${D(x - hw * 0.3)} ${D(by + hh * 0.012)}L${D(x - hw * 0.55)} ${D(by - hh * 0.03)}L${D(x - hw)} ${D(by)}Z`;
    }
    return d;
  };
  L.pine = pine;
  // L.forest(parent, { y: ground line, seed, rows: [[count, hMin, hMax, color, yOff]], x0, x1 })
  L.forest = (p, o = {}) => {
    const g = s(p, 'g'), r = rng(o.seed ?? 7), y = o.y ?? 1100, x0 = o.x0 ?? -120, x1 = o.x1 ?? 1200;
    const rows = o.rows ?? [[10, 230, 330, '#2f727a', -50], [8, 300, 420, '#225c66', -22], [6, 360, 500, '#173f49', 0]];
    rows.forEach(([n, h0, h1, col, yo], ri) => {
      let d = ''; for (let i = 0; i < n; i++) { const hh = lerp(h0, h1, r()), x = lerp(x0, x1, (i + 0.15 + r() * 0.7) / n); d += pine(x, y + yo, hh * 0.42, hh); }
      s(g, 'path', { d, fill: col });
      if (o.fog !== false && ri < rows.length - 1) s(g, 'rect', { x: -200, y: y + yo - 40, width: 1480, height: 90, fill: L.linear(g, [[0, '#7fc4c0', 0], [0.5, '#7fc4c0', 0.16], [1, '#7fc4c0', 0]]) });
    });
    return g;
  };
  // ground band + grass tufts along y
  L.ground = (p, y, o = {}) => {
    const g = s(p, 'g'), r = rng(o.seed ?? 3);
    s(g, 'rect', { x: -200, y, width: 1480, height: 1000, fill: o.fill ?? '#071419' });
    s(g, 'rect', { x: -200, y: y - 3, width: 1480, height: 5, fill: o.edge ?? '#1d4a52', opacity: 0.8 });
    let d = '';
    for (let i = 0; i < (o.tufts ?? 26); i++) {
      const x = -60 + r() * 1200, hh = 18 + r() * 30;
      for (let k = 0; k < 5; k++) { const bx = x + k * 7, lean = (k - 2) * 4 + (r() - 0.5) * 6; d += `M${D(bx - 3)} ${D(y + 2)}L${D(bx + lean)} ${D(y - hh * (0.6 + r() * 0.4))}L${D(bx + 3)} ${D(y + 2)}Z`; }
    }
    s(g, 'path', { d, fill: o.grass ?? C.ink });
    return g;
  };
  // drifting motes (stars / fireflies / dust): L.motes(parent, { n, seed, box:[x0,y0,x1,y1], colors }) → { draw(t) }
  L.motes = (p, o = {}) => {
    const g = s(p, 'g'), r = rng(o.seed ?? 11), [x0, y0, x1, y1] = o.box ?? [0, 200, 1080, 1100];
    const cols = o.colors ?? ['#e9fffb', '#f2b13b', '#bfeee6'];
    const m = Array.from({ length: o.n ?? 22 }, () => ({ x: lerp(x0, x1, r()), y: lerp(y0, y1, r()), r: 2 + r() * 3.2, c: cols[Math.floor(r() * cols.length)], ph: r() * 6.3, sp: 6 + r() * 14, el: null }));
    for (const q of m) q.el = s(g, 'circle', { r: D(q.r), fill: q.c });
    return { g, draw: t => { for (const q of m) { q.el.setAttribute('cx', D(q.x + Math.sin(t * 0.5 + q.ph) * 14)); q.el.setAttribute('cy', D(q.y - ((t * q.sp) % 80) + Math.cos(t * 0.7 + q.ph) * 6)); q.el.setAttribute('opacity', D(0.35 + 0.45 * (0.5 + 0.5 * Math.sin(t * 2 + q.ph * 3)), 2)); } } };
  };

  // ---------- people: a silhouette rig with a gold rim light ----------
  // L.person(parent, { kind: 'hoodie'|'coat'|'puffer'|'trench'|'shirt', hat: 'none'|'beanie'|'fedora'|'hood'|'messy'|'cap',
  //   rim: [dx, dy] (the light side; [0,0] = no rim), scale, glasses }) → { g, pose({ x, y, dir, lean, head, a, b, l, m }) → joints }
  // Angles in degrees from straight down; positive = towards where the person faces. a/b = front/back arm [shoulder, elbow],
  // l/m = front/back leg [hip, knee]. (x, y) is the hip, dir 1 faces right, -1 left. Units: a standing person is ~520 tall.
  L.person = (p, o = {}) => {
    const outer = s(p, 'g'), g = s(outer, 'g');
    const rim = o.rim ?? [4, -3];
    if (rim[0] || rim[1]) outer.style.filter = `drop-shadow(${rim[0]}px ${rim[1]}px 0 ${o.rimColor ?? C.gold})`;
    const fill = o.fill ?? C.ink, fill2 = o.fill2 ?? C.ink2;
    const backArm = s(g, 'path', { fill: fill2 }), backLeg = s(g, 'path', { fill: fill2 }), backShoe = s(g, 'path', { fill: fill2 });
    const frontLeg = s(g, 'path', { fill }), frontShoe = s(g, 'path', { fill });
    const torso = s(g, 'path', { fill }), seams = s(g, 'path', { fill: 'none', stroke: '#1d3440', 'stroke-width': 3, opacity: o.kind === 'puffer' ? 0.9 : 0 });
    const head = s(g, 'path', { fill }), hat = s(g, 'path', { fill }), hatLine = s(g, 'path', { fill: 'none', stroke: '#1d3440', 'stroke-width': 3 });
    const eye = s(g, 'circle', { r: 0, fill: '#cfe9f0' });
    const frontArm = s(g, 'path', { fill }), hands = s(g, 'path', { fill });
    const k = o.scale ?? 1, kind = o.kind ?? 'hoodie', hatK = o.hat ?? 'none';
    const R = a => a * Math.PI / 180;
    const limb = (A, B, w0, w1) => {   // a tapered rounded limb from A to B
      const dx = B[0] - A[0], dy = B[1] - A[1], l = Math.hypot(dx, dy) || 1, nx = -dy / l, ny = dx / l;
      return `M${D(A[0] + nx * w0)} ${D(A[1] + ny * w0)}L${D(B[0] + nx * w1)} ${D(B[1] + ny * w1)}A${D(w1)} ${D(w1)} 0 0 1 ${D(B[0] - nx * w1)} ${D(B[1] - ny * w1)}L${D(A[0] - nx * w0)} ${D(A[1] - ny * w0)}A${D(w0)} ${D(w0)} 0 0 1 ${D(A[0] + nx * w0)} ${D(A[1] + ny * w0)}Z`;
    };
    const chain = (O, l1, l2, a1, a2) => { const E = [O[0] + Math.sin(R(a1)) * l1, O[1] + Math.cos(R(a1)) * l1]; const W = [E[0] + Math.sin(R(a1 + a2)) * l2, E[1] + Math.cos(R(a1 + a2)) * l2]; return [E, W]; };
    const shoe = (A, ang, len = 64, hh = 26) => {   // toe points forward (+x), heel at the ankle
      const c = Math.cos(R(ang)), sn = Math.sin(R(ang)), P = [[-14, -hh * 0.4], [len * 0.55, -hh * 0.55], [len, -hh * 0.05], [len, hh * 0.55], [-16, hh * 0.55]];
      return smooth(P.map(([x, y]) => [A[0] + x * c - y * sn, A[1] + x * sn + y * c]), true, 0.35);
    };
    const pose = (P = {}) => {
      const dir = P.dir ?? 1, x = P.x ?? 0, y = P.y ?? 0, lean = P.lean ?? 0, sc = (P.scale ?? 1) * k;
      g.setAttribute('transform', `translate(${D(x)} ${D(y)}) scale(${D(dir * sc, 4)} ${D(sc, 4)})`);
      const H = [0, 0], up = [Math.sin(R(lean)), -Math.cos(R(lean))], side = [-up[1], up[0]];   // side = forward
      const at = (u, v) => [H[0] + up[0] * u + side[0] * v, H[1] + up[1] * u + side[1] * v];
      const N = at(190, 0);
      // torso outline (u along the spine from the hip, v forward)
      const wide = kind === 'puffer' ? 1.22 : kind === 'shirt' ? 0.92 : 1;
      let T = [[-6, -48], [70, -54], [150, -50], [192, -36], [204, -6], [196, 26], [160, 46], [90, 44], [20, 50], [-6, 48]];
      if (kind === 'coat' || kind === 'trench') T = [[-120, -64], [-40, -54], [70, -54], [150, -50], [192, -36], [204, -6], [196, 26], [160, 48], [90, 46], [20, 54], [-40, 60], [-120, 70]];
      if (kind === 'hoodie') T.splice(4, 0, [214, -44]);
      torso.setAttribute('d', smooth(T.map(([u, v]) => at(u, v * wide)), true, 0.55));
      if (kind === 'puffer') { let d = ''; for (const u of [36, 82, 128, 170]) { const a = at(u, -50 * wide), b = at(u, 46 * wide); d += `M${D(a[0])} ${D(a[1])}L${D(b[0])} ${D(b[1])}`; } seams.setAttribute('d', d); }
      // head + face profile
      const hl = R(lean + (P.head ?? 0)), hu = [Math.sin(hl), -Math.cos(hl)], hs = [-hu[1], hu[0]];
      const HC = [N[0] + hu[0] * 62, N[1] + hu[1] * 62];
      const hp = (u, v) => [HC[0] + hu[0] * u + hs[0] * v, HC[1] + hu[1] * u + hs[1] * v];
      const face = [[-56, -6], [-40, -34], [-2, -46], [36, -40], [50, -10], [46, 18], [30, 36], [16, 40], [10, 52], [-6, 50], [-16, 46], [-30, 44], [-44, 34]];
      head.setAttribute('d', smooth(face.map(([v, u]) => hp(u, v)), true, 0.5) + smooth([[-38, 8], [-36, -14], [-58, -12], [-50, 10]].map(([u, v]) => hp(u, v)), true));
      // nose (a small bump forward)
      head.setAttribute('d', head.getAttribute('d') + smooth([[4, 44], [-8, 58], [-20, 50]].map(([u, v]) => hp(u, v)), true, 0.4));
      let hd = '', hld = '';
      if (hatK === 'beanie') { hd = smooth([[10, -50], [44, -40], [60, 0], [44, 40], [12, 50], [16, 0]].map(([u, v]) => hp(u, v)), true) + `M${D(hp(70, -4)[0])} ${D(hp(70, -4)[1])}a16 16 0 1 0 0.1 0Z`; const a = hp(18, -50), b = hp(20, 50); hld = `M${D(a[0])} ${D(a[1])}L${D(b[0])} ${D(b[1])}`; }
      if (hatK === 'fedora') { hd = smooth([[8, -78], [16, -40], [52, -36], [64, 0], [52, 34], [16, 40], [8, 80], [2, 0]].map(([u, v]) => hp(u, v)), true, 0.4); const a = hp(20, -40), b = hp(20, 38); hld = `M${D(a[0])} ${D(a[1])}L${D(b[0])} ${D(b[1])}`; }
      if (hatK === 'cap') hd = smooth([[14, -50], [46, -34], [54, 0], [44, 30], [22, 48], [12, 84], [4, 60]].map(([u, v]) => hp(u, v)), true, 0.4);
      if (hatK === 'messy') hd = smooth([[6, -54], [40, -52], [52, -30], [66, -16], [54, -2], [62, 14], [48, 26], [50, 44], [30, 40], [16, 44], [20, 10]].map(([u, v]) => hp(u, v)), true, 0.45);
      if (hatK === 'hood') hd = smooth([[-70, -40], [-10, -66], [44, -58], [70, -10], [56, 40], [24, 34], [30, -10], [-6, -30], [-40, -20], [-80, -10]].map(([u, v]) => hp(u, v)), true, 0.5);
      hat.setAttribute('d', hd); hatLine.setAttribute('d', hld);
      const E1 = hp(10, 24); eye.setAttribute('cx', D(E1[0])); eye.setAttribute('cy', D(E1[1])); eye.setAttribute('r', o.eye ? 4.5 : 0);
      // arms
      const Sh = at(172, -4), [a1, a2] = P.a ?? [12, 18], [b1, b2] = P.b ?? [-8, 14];
      const [Ea, Wa] = chain(Sh, 92, 88, a1 + lean, a2), [Eb, Wb] = chain(Sh, 92, 88, b1 + lean, b2);
      const aw = kind === 'puffer' ? 24 : 18;
      frontArm.setAttribute('d', limb(Sh, Ea, aw + 2, aw) + limb(Ea, Wa, aw, aw - 4));
      backArm.setAttribute('d', limb(Sh, Eb, aw + 2, aw) + limb(Eb, Wb, aw, aw - 4));
      const hand = W => `M${D(W[0] + 15)} ${D(W[1])}a15 15 0 1 0 0.1 0Z`;
      const Ha = [Wa[0] + Math.sin(R(a1 + lean + a2)) * 12, Wa[1] + Math.cos(R(a1 + lean + a2)) * 12], Hb = [Wb[0] + Math.sin(R(b1 + lean + b2)) * 12, Wb[1] + Math.cos(R(b1 + lean + b2)) * 12];
      hands.setAttribute('d', hand(Ha) + hand(Hb));
      // legs (from the hip, ignoring the lean)
      const [l1, l2] = P.l ?? [6, -4], [m1, m2] = P.m ?? [-6, 4];
      const Hp = at(8, 6);
      const [Ka, Aa] = chain(Hp, 118, 122, l1, l2), [Kb, Ab] = chain(Hp, 118, 122, m1, m2);
      frontLeg.setAttribute('d', limb(Hp, Ka, 26, 22) + limb(Ka, Aa, 22, 17));
      backLeg.setAttribute('d', limb(Hp, Kb, 26, 22) + limb(Kb, Ab, 22, 17));
      frontShoe.setAttribute('d', shoe(Aa, P.fa ?? 0)); backShoe.setAttribute('d', shoe(Ab, P.fb ?? 0));
      // joints in global coordinates
      const G = q => [x + dir * sc * q[0], y + sc * q[1]];
      return { hip: G(H), neck: G(N), head: G(HC), hand: G(Ha), hand2: G(Hb), elbow: G(Ea), knee: G(Ka), ankle: G(Aa), ankle2: G(Ab), sc, dir };
    };
    return { g: outer, inner: g, pose };
  };
  // a walk cycle pose at phase ph (radians); stride in degrees
  L.walk = (ph, st = 24) => ({ l: [Math.sin(ph) * st, -Math.max(0, Math.sin(ph + 1.4)) * st * 1.4], m: [-Math.sin(ph) * st, -Math.max(0, -Math.sin(ph + 1.4)) * st * 1.4],
    a: [-Math.sin(ph) * st * 0.8, 20], b: [Math.sin(ph) * st * 0.8, 20], bob: Math.abs(Math.cos(ph)) * 8 });
  L.sit = (o = {}) => ({ l: [88, -86], m: [80, -80], a: o.a ?? [40, -50], b: o.b ?? [30, -40], lean: o.lean ?? 8 });

  // ---------- props ----------
  // the format's lantern: handle on top at (0, 0) of its group, ~150 tall at scale 1. → { g, draw(t, glow) }
  L.lantern = (p, o = {}) => {
    const g = s(p, 'g'), halo = L.glow(g, 0, 92, 230, '#ffc867', 0.55);
    const glass = s(g, 'path', { d: 'M-30 52 Q-36 92 -30 132 L30 132 Q36 92 30 52 Z', fill: '#ffd98a' });
    const flame = s(g, 'path', { d: 'M0 70 Q14 98 0 118 Q-14 98 0 70 Z', fill: '#fff4cf' });
    const frame = s(g, 'g', { fill: C.ink, stroke: C.gold, 'stroke-width': 2.4 });
    s(frame, 'path', { d: 'M-26 6 A26 30 0 0 1 26 6', fill: 'none', stroke: C.ink, 'stroke-width': 7 });
    s(frame, 'path', { d: 'M-26 6 A26 30 0 0 1 26 6', fill: 'none', stroke: C.gold, 'stroke-width': 2 });
    s(frame, 'path', { d: 'M-14 22 L14 22 L40 52 L-40 52 Z' });
    s(frame, 'rect', { x: -6, y: 0, width: 12, height: 24, rx: 3 });
    s(frame, 'path', { d: 'M-44 132 L44 132 L38 150 L-38 150 Z' });
    for (const x of [-31, 31]) s(frame, 'rect', { x: x - 3.5, y: 52, width: 7, height: 80 });
    return { g, draw: (t, k = 1) => {
      const f = 0.85 + 0.15 * Math.sin(t * 11) * Math.sin(t * 7.3 + 1);
      halo.setAttribute('r', D(230 * k * (0.96 + 0.06 * f))); halo.setAttribute('opacity', D(Math.min(1, k) * f, 3));
      glass.setAttribute('opacity', D(0.55 + 0.45 * Math.min(1, k) * f, 3)); flame.setAttribute('transform', `translate(0 ${D(118 - 118 * (0.92 + 0.08 * f))}) scale(${D(0.92 + 0.08 * f, 3)})`);
    } };
  };
  // a glowing document (a SKILL.md): top-left at (x, y) → { g, glow }
  L.doc = (p, x, y, w = 220, hh = 290, o = {}) => {
    const g = s(p, 'g', { transform: `translate(${x} ${y})` }), f = 34;
    const glow = L.glow(g, w / 2, hh / 2, Math.max(w, hh) * 1.05, o.glow ?? '#c6fff5', 0.55);
    s(g, 'path', { d: `M0 0 L${w - f} 0 L${w} ${f} L${w} ${hh} L0 ${hh} Z`, fill: o.fill ?? '#dcfbf5', stroke: o.stroke ?? C.ink, 'stroke-width': 5 });
    s(g, 'path', { d: `M${w - f} 0 L${w - f} ${f} L${w} ${f}`, fill: '#a9e6dd', stroke: o.stroke ?? C.ink, 'stroke-width': 4 });
    if (o.title !== false) { const tx = s(g, 'text', { x: 22, y: 58, fill: '#0c1a20', 'font-family': 'Bebas Neue', 'font-size': o.fs ?? 44, 'letter-spacing': 1 }); tx.textContent = o.title ?? 'SKILL.MD'; }
    for (let i = 0; i < (o.lines ?? 6); i++) s(g, 'rect', { x: 22, y: 84 + i * 30, width: (w - 44) * (i % 3 === 2 ? 0.62 : 0.95), height: 11, rx: 5, fill: '#6f989a' });
    return { g, glow };
  };
  // line-art version of anything: stroke-draw an element (path, line, rect, circle) by u (0–1)
  L.draw = (el, u) => {
    if (el.__len == null) el.__len = el.getTotalLength ? el.getTotalLength() : 1000;
    el.style.strokeDasharray = `${el.__len}`; el.style.strokeDashoffset = D(el.__len * (1 - clamp(u)), 2);
    el.style.opacity = u <= 0 ? 0 : 1;
  };
  // a gold "no" sign (circle + slash) drawn on by u
  L.noSign = (p, cx, cy, r, o = {}) => {
    const g = s(p, 'g', { fill: 'none', stroke: o.color ?? C.amber, 'stroke-width': o.w ?? 18, 'stroke-linecap': 'round' });
    g.style.filter = 'drop-shadow(0 0 14px rgba(242,177,59,0.75))';
    const c = s(g, 'circle', { cx, cy, r, transform: `rotate(-90 ${cx} ${cy})` }), a = r * 0.707;
    const l = s(g, 'line', { x1: cx - a, y1: cy - a, x2: cx + a, y2: cy + a });
    return { g, draw: u => { L.draw(c, u / 0.7); L.draw(l, (u - 0.68) / 0.32); } };
  };
  // impact rings at (x, y): gold + white circles that expand and fade from t0
  L.burst = (p, x, y, o = {}) => {
    const g = s(p, 'g', { fill: 'none' }), a = s(g, 'circle', { cx: x, cy: y, stroke: C.amber, 'stroke-width': 7 }), b = s(g, 'circle', { cx: x, cy: y, stroke: '#e9fffb', 'stroke-width': 4 });
    const R0 = o.r ?? 150;
    return { g, draw: (t, t0) => { const u = (t - t0) / 0.55; const on = u >= 0 && u <= 1; a.setAttribute('r', D(R0 * (0.3 + 0.9 * eo(u)))); b.setAttribute('r', D(R0 * (0.5 + 1.2 * eo(u)))); a.setAttribute('opacity', on ? D(1 - u, 3) : 0); b.setAttribute('opacity', on ? D(0.8 * (1 - u), 3) : 0); } };
  };
  // a 4-point sparkle
  L.sparkle = (p, x, y, r = 22, c = '#fff6da') => s(p, 'path', { d: `M${x} ${y - r}Q${x + r * 0.16} ${y - r * 0.16} ${x + r} ${y}Q${x + r * 0.16} ${y + r * 0.16} ${x} ${y + r}Q${x - r * 0.16} ${y + r * 0.16} ${x - r} ${y}Q${x - r * 0.16} ${y - r * 0.16} ${x} ${y - r}Z`, fill: c });

  // ---------- badges, labels, discs (DOM, inside a cam) ----------
  // L.badge(parent, cx, cy, value, label, { solid }) → { el (the 0x0 anchor to pop/set), b, v, l }
  L.badge = (p, cx, cy, value, label, o = {}) => {
    const el = div(p, 'abs', cx, cy, 0, 0, '', { transformOrigin: '0 0' });
    const b = div(el, 'badge' + (o.solid ? ' solid' : ''), 0, 0, null, null, `<div class="v">${esc(value)}</div>${label ? `<div class="l">${esc(label)}</div>` : ''}`, { transform: 'translate(-50%, -50%)' });
    return { el, b, v: b.querySelector('.v'), l: b.querySelector('.l') };
  };
  L.label = (p, x, y, text, o = {}) => div(p, 'lbl' + (o.mint ? ' mint' : ''), x, y, null, null, esc(text), o.style);
  L.disc = (p, cx, cy, r, inner, o = {}) => {
    const el = div(p, 'abs', cx, cy, 0, 0, '', { transformOrigin: '0 0' });
    div(el, 'disc', -r, -r, r * 2, r * 2, inner, o.style);
    return { el };
  };
  // icons for discs (dark on amber), 100x100 viewBox
  L.ICON = {
    gift: c => `<svg viewBox="0 0 100 100" width="62%" height="62%"><g fill="${c || '#10181c'}"><rect x="14" y="40" width="72" height="20" rx="3"/><rect x="20" y="60" width="60" height="30" rx="3"/><rect x="45" y="40" width="10" height="50" fill="#f2b13b"/><path d="M50 40 C30 14 14 30 30 40 Z M50 40 C70 14 86 30 70 40 Z"/></g></svg>`,
    file: c => `<svg viewBox="0 0 100 100" width="58%" height="58%"><path d="M24 10 H62 L80 28 V90 H24 Z" fill="${c || '#10181c'}"/><path d="M34 46 H70 M34 60 H70 M34 74 H58" stroke="#f2b13b" stroke-width="7" stroke-linecap="round"/></svg>`,
    tag: c => `<svg viewBox="0 0 100 100" width="60%" height="60%"><path d="M14 46 L46 14 H86 V54 L54 86 Z" fill="${c || '#10181c'}"/><circle cx="70" cy="30" r="7" fill="#f2b13b"/></svg>`,
    lock: c => `<svg viewBox="0 0 100 100" width="56%" height="56%"><rect x="22" y="44" width="56" height="44" rx="8" fill="${c || '#10181c'}"/><path d="M34 44 V32 a16 16 0 0 1 32 0 V44" fill="none" stroke="${c || '#10181c'}" stroke-width="10"/></svg>`,
    heart: c => `<svg viewBox="0 0 24 24" width="56%" height="56%"><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 || '#10181c'}"/></svg>`,
    books: c => `<svg viewBox="0 0 100 100" width="62%" height="62%"><g fill="${c || '#10181c'}"><rect x="16" y="20" width="14" height="64" rx="2"/><rect x="34" y="28" width="14" height="56" rx="2"/><rect x="52" y="16" width="14" height="68" rx="2"/><path d="M70 30 l12 -4 l14 54 l-12 4 Z"/></g></svg>`,
  };
  // a blueprint background: grid lines in faint gold over deep teal, with a soft glow pool → the div
  L.blueprint = (p, o = {}) => {
    const [gx, gy] = o.glow ?? [540, 820];
    return div(p, 'abs', 0, 0, 1080, 1920, '', { background: `radial-gradient(560px 460px at ${gx}px ${gy}px, rgba(120, 190, 190, 0.32), transparent 72%),
      repeating-linear-gradient(90deg, rgba(233,169,58,0.16) 0 2px, transparent 2px 210px), repeating-linear-gradient(0deg, rgba(233,169,58,0.16) 0 2px, transparent 2px 210px),
      repeating-linear-gradient(90deg, rgba(233,169,58,0.05) 0 1px, transparent 1px 42px), repeating-linear-gradient(0deg, rgba(233,169,58,0.05) 0 1px, transparent 1px 42px),
      linear-gradient(180deg, #0c2a33 0%, #0b2630 55%, #091f27 100%)`, backgroundPosition: '33px 25px, 33px 25px, 33px 25px, 33px 25px, 33px 25px, 0 0' });
  };
  // a pencil (gold line art) with its tip at (0, 0) of its group, pointing down-left
  L.pencil = p => {
    const g = s(p, 'g', { fill: 'none', stroke: C.amber, 'stroke-width': 4, 'stroke-linejoin': 'round' });
    s(g, 'path', { d: 'M0 0 L12 -30 L104 -122 L126 -100 L34 -8 Z', fill: '#0c2a33' });
    s(g, 'path', { d: 'M0 0 L12 -30 L34 -8 Z', fill: C.amber });
    s(g, 'path', { d: 'M94 -112 L116 -90 M104 -122 L114 -132 L136 -110 L126 -100', fill: C.amber });
    return g;
  };
  // a ruler (gold line art) from (x, y), length w
  L.ruler = (p, x, y, w) => {
    const g = s(p, 'g', { fill: 'none', stroke: C.amber, 'stroke-width': 3 });
    s(g, 'rect', { x, y, width: w, height: 44, rx: 3, fill: 'rgba(12,42,51,0.6)' });
    let d = ''; for (let i = 0; i <= w / 14; i++) { const xx = x + 8 + i * 14; if (xx > x + w - 6) break; d += `M${xx} ${y} L${xx} ${y + (i % 5 === 0 ? 20 : 11)}`; }
    s(g, 'path', { d, 'stroke-width': 2 });
    return g;
  };
  // a dimension line with arrow heads from (x1, y) to (x2, y) and a label above (DOM label returned)
  L.dim = (p, x1, x2, y) => {
    const g = s(p, 'g', { stroke: C.amber, 'stroke-width': 4, fill: C.amber });
    const ln = s(g, 'line', { x1, y1: y, x2, y2: y });
    const a1 = s(g, 'path', { d: `M${x1} ${y} l16 -9 l0 18 Z`, stroke: 'none' }), a2 = s(g, 'path', { d: `M${x2} ${y} l-16 -9 l0 18 Z`, stroke: 'none' });
    return { g, draw: u => { L.draw(ln, u); a1.style.opacity = a2.style.opacity = u >= 1 ? 1 : 0; } };
  };
  // a price tag on a string, hung from (0, 0) of its group; text in Bebas
  L.priceTag = (p, text, o = {}) => {
    const g = s(p, 'g'), w = o.w ?? 190, hh = o.h ?? 110;
    s(g, 'line', { x1: 0, y1: 0, x2: 0, y2: 70, stroke: C.mint2, 'stroke-width': 3 });
    const tag = s(g, 'g', { transform: 'translate(0 70)' });
    s(tag, 'path', { d: `M0 0 L${w / 2} 30 L${w / 2} ${hh + 30} L${-w / 2} ${hh + 30} L${-w / 2} 30 Z`, fill: C.amber, stroke: C.ink, 'stroke-width': 5, 'stroke-linejoin': 'round' });
    s(tag, 'circle', { cx: 0, cy: 22, r: 8, fill: C.ink });
    const tx = s(tag, 'text', { x: 0, y: hh + 8, 'text-anchor': 'middle', fill: '#10181c', 'font-family': 'Bebas Neue', 'font-size': o.fs ?? 92 }); tx.textContent = text;
    return { g, tx };
  };
  // comment bubble (mint, avatar + grey lines); DOM in a cam → the element
  L.bubble = (p, x, y, w = 420, o = {}) => {
    const b = div(p, 'card', x, y, w, 96, `<div style="position:absolute;left:22px;top:24px;width:46px;height:46px;border-radius:50%;background:#7fa2a3"></div>
      <div style="position:absolute;left:86px;top:${o.text ? 18 : 30}px;width:${w - 130}px;height:${o.text ? 'auto' : '12px'};border-radius:6px;${o.text ? '' : 'background:#8aaeb0'}">${o.text ? `<div class="b" style="font-size:38px;letter-spacing:.02em">${esc(o.text)}</div>` : ''}</div>
      ${o.text ? '' : `<div style="position:absolute;left:86px;top:56px;width:${(w - 130) * 0.6}px;height:12px;border-radius:6px;background:#a8c6c6"></div>`}
      <div style="position:absolute;left:${w - 80}px;bottom:-16px;width:0;height:0;border-left:22px solid transparent;border-right:8px solid transparent;border-top:20px solid #d4f8f1"></div>`);
    return b;
  };
})();
```

### `engine/film.css`

```css
/* Lantern · STYLE. Moonlit flat silhouettes: deep teal night, mint moon rings, one amber that glows, gold rim lines.
   Fonts (film.html): 'Lantern Sans' = Segoe UI Bold (falls back to the bundled Open Sans) for subtitles,
   'Bebas Neue' (bundled, OFL) for the headline, badges and labels. */
:root {
  --night: #02070b; --navy: #0a2129; --teal: #1d4f59; --mint: #d7fbf4; --mint2: #a9e6dd;
  --amber: #f2b13b; --gold: #e9a93a; --ink: #04090c; --cond: 'Bebas Neue', Bahnschrift, sans-serif;
  --sans: 'Lantern Sans', 'Segoe UI', 'Open Sans', system-ui, sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 1080px; height: 1920px; overflow: hidden; background: var(--night); }
body { font-family: var(--sans); color: #fff; -webkit-font-smoothing: antialiased; }
#film { position: relative; width: 1080px; height: 1920px; overflow: hidden; background: linear-gradient(180deg, #071b23 0%, #04121a 10%, #02070b 62%, #02070b 100%); }
#stage { position: absolute; inset: 0; }
.layer, .scene, .over { position: absolute; inset: 0; }
.scene { will-change: transform, opacity; }
.abs { position: absolute; }
svg { overflow: visible; }

/* a panel = the picture of one shot (the reference letterboxes it: y 195–1195 by default, the black under it holds the subtitle) */
.panel { position: absolute; left: 0; width: 1080px; overflow: hidden; }
.cam { position: absolute; left: 0; width: 1080px; height: 1920px; transform-origin: 540px 800px; will-change: transform; }

/* SUBTITLES (the "bear trap" reel): one chunk of 2–4 words at a time, sentence case, white, digits amber, centred on x 510 */
.sub { position: absolute; left: 100px; width: 820px; top: 1352px; height: 112px; display: flex; align-items: center; justify-content: center; pointer-events: none; }
.sub .t { display: inline-block; font-family: var(--sans); font-weight: 700; font-size: 74px; line-height: 1.12; letter-spacing: -0.006em; white-space: nowrap; color: #fff;
  padding: 4px 26px 10px; border-radius: 16px; text-shadow: 0 3px 12px rgba(0,0,0,0.45); }
.sub .t.pill { background: rgba(12, 21, 27, 0.84); box-shadow: 0 6px 22px rgba(0,0,0,0.35); }
.sub .a { color: var(--amber); }

/* HEADLINE (the "3 reasons" reel): big condensed caps at the top, words land as they are said, key words amber */
.head { position: absolute; left: 100px; width: 820px; top: 330px; text-align: center; pointer-events: none; }
.head .ln { display: block; white-space: nowrap; font-family: var(--cond); font-size: 168px; line-height: 0.93; letter-spacing: 0.004em; color: var(--mint);
  text-shadow: 0 6px 0 rgba(0,0,0,0.22), 0 0 34px rgba(0,0,0,0.45); }
.head .w { display: inline-block; white-space: pre; transform-origin: 50% 70%; }
.head .a { color: var(--amber); }

/* badges and labels (Bebas), like the reference's "0.04 S · JAWS SHUT IN" */
.badge { position: absolute; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 6px 26px 8px; border-radius: 14px;
  background: #0c171d; border: 4px solid var(--amber); color: var(--amber); font-family: var(--cond); box-shadow: 0 0 26px rgba(242,177,59,0.28), 0 10px 30px rgba(0,0,0,0.45); white-space: nowrap; }
.badge .v { font-size: 78px; line-height: 0.95; letter-spacing: 0.01em; }
.badge .l { font-size: 25px; letter-spacing: 0.16em; line-height: 1; margin-top: 2px; opacity: 0.92; }
.badge.solid { background: var(--amber); color: #10181c; border-color: #10181c; box-shadow: 0 0 34px rgba(242,177,59,0.5); }
.lbl { position: absolute; font-family: var(--cond); color: var(--amber); font-size: 40px; letter-spacing: 0.14em; white-space: nowrap; }
.lbl.mint { color: var(--mint2); }
.disc { position: absolute; border-radius: 50%; background: var(--amber); border: 5px solid #0b1418; box-shadow: 0 0 0 3px var(--amber), 0 0 30px rgba(242,177,59,0.55); display: flex; align-items: center; justify-content: center; }

/* mint UI cards (the reference's "Reply to a viewer's comment" bubble) */
.card { position: absolute; background: #d4f8f1; color: #0c1a20; border-radius: 22px; box-shadow: 0 14px 36px rgba(0,0,0,0.45), inset 0 -4px 0 rgba(0,0,0,0.08); font-family: var(--sans); }
.card .k { font-size: 22px; font-weight: 600; color: #4c6a70; }
.card .b { font-size: 36px; font-weight: 700; line-height: 1.18; }

.flash { position: absolute; inset: 0; background: #e9fffb; opacity: 0; pointer-events: none; }
.wipeline { position: absolute; top: 0; width: 8px; height: 1920px; background: #ffd27a; box-shadow: 0 0 18px 6px rgba(255,190,80,0.85), 0 0 80px 30px rgba(255,170,60,0.35); pointer-events: none; }

/* safe-zone overlay (render.mjs --safe) */
#safe { position: absolute; inset: 0; pointer-events: none; z-index: 50; }
#safe .no { position: absolute; background: rgba(255, 30, 60, 0.16); }
#safe .zone { position: absolute; border: 3px dashed rgba(0, 220, 255, 0.85); }
#safe .band { position: absolute; border: 2px dashed rgba(255, 220, 0, 0.8); background: rgba(255, 220, 0, 0.05); }
#safe .mid { position: absolute; background: rgba(0, 220, 255, 0.35); }
#safe .lbl { position: absolute; font: 600 22px/1 Consolas, monospace; color: rgba(0, 230, 255, 0.95); letter-spacing: 0; }
#safe .bad { position: absolute; border: 4px solid #ff2244; background: rgba(255, 34, 68, 0.18); }
```

### `engine/markup.mjs`

```js
// Lantern · SCRIPT MARKUP (Node side: gen-vo, build-timeline, check). Same for every video.
//   /  a new caption chunk (in a HEAD section: a new headline line)   *word*  amber   {shown|said}  the caption shows one
//   thing, the voice says another. Digits are amber by themselves. HEAD (script.mjs) lists the sections drawn as the big
//   condensed headline at the top; the rest are bottom subtitles, cut into chunks of 2–4 words.
// 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: 'ynTHHllfDmGsZ3G8QJt9',   // ElevenLabs "Daniel - Warm, Clear & Dynamic Guide": young British explainer (the reference: male, F0 ~130 Hz, quick phrases, ~165 wpm with gaps)
  MODEL: 'eleven_multilingual_v2',
  SETTINGS: { stability: 0.5, similarity_boost: 0.8, style: 0.2, use_speaker_boost: true },
  SPEED: 1.0,
  GAP: 0.5, LEAD: 0.2, HOLD: 1.3, HEAD: [],
};

// 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's line markers: Lantern parses them but draws no lines)
    if (raw === '!') { l2 = true; ln = 3; continue; }
    if (raw === '^') { l2 = true; ln = 4; continue; }
    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
// Lantern · 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 Bebas Neue + Open Sans (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 = {
  'BebasNeue-Regular.ttf': 'https://raw.githubusercontent.com/google/fonts/main/ofl/bebasneue/BebasNeue-Regular.ttf',
  'OpenSans.ttf': 'https://raw.githubusercontent.com/google/fonts/main/ofl/opensans/OpenSans%5Bwdth%2Cwght%5D.ttf',
};
for (const [name, url] of Object.entries(FONTS)) {
  const font = path.join(HERE, 'fonts', name);
  if (fs.existsSync(font)) { ok(true, 'fonts/' + name); continue; }
  fs.mkdirSync(path.dirname(font), { recursive: true });
  try { const r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0' } }); if (!r.ok) throw new Error(r.status); fs.writeFileSync(font, Buffer.from(await r.arrayBuffer())); ok(true, 'fonts/' + name + ' downloaded'); }
  catch (e) { ok(false, `fonts/${name}: download failed (${e.message}) from ${url}`); }
}
spawnSync(process.execPath, [path.join(HERE, 'sounds.mjs')], { stdio: 'inherit' });
if (CHROME) try { const api = await launch({ port: 9698 }); ok(true, 'Chrome starts headless · renderer: ' + api.gpu); await api.close(); } catch (e) { ok(false, e.message); }
setTimeout(() => process.exit(0), 300);
```

### `sounds.mjs`

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

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

### `bed.mjs`

```js
// Lantern · THE BED. A soft synthesized pad cut to this video's length → <video>\bed.wav (render.mjs --mix uses it):
// warm sustained chords (Am9 → Fmaj7 → Cadd9 → G6, one every BAR seconds, slow attack and release), each note two detuned
// voices of a few soft harmonics, a quiet sub under the root, all low-passed (~800 Hz) like the reference's muffled bed.
//   node bed.mjs --video <dir> [--bar 4.8]        (after build-timeline.mjs; run it again whenever the timing changes)
import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os';
import { videoDir } from './engine/markup.mjs';
try { os.setPriority(os.constants.priority.PRIORITY_LOW); } catch { }
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'));
const arg = (k, d) => { const i = process.argv.indexOf(k); return i > 0 ? process.argv[i + 1] : d; };
const BAR = +arg('--bar', 4.8), dur = TL.total + 0.5, n = Math.round(dur * SR);
const hz = m => 440 * Math.pow(2, (m - 69) / 12);
// MIDI notes per chord: root (sub) first
const CH = [[45, 57, 64, 67, 71, 72], [41, 53, 60, 64, 69], [48, 55, 62, 64, 67], [43, 55, 62, 64, 71]];
const L = new Float32Array(n), R = new Float32Array(n), TAU = Math.PI * 2;
const ATT = 1.4, REL = 1.8;
for (let b = 0; b * BAR < dur; b++) {
  const ch = CH[b % CH.length], t0 = b * BAR, t1 = t0 + BAR;
  const i0 = Math.max(0, Math.floor((t0 - 0.05) * SR)), i1 = Math.min(n, Math.ceil((t1 + REL) * SR));
  ch.forEach((m, k) => {
    const f = hz(m), sub = k === 0, pan = sub ? 0 : ((k % 2) ? -0.35 : 0.35), amp = sub ? 0.32 : 0.16;
    const ph = [Math.random() * TAU, Math.random() * TAU];
    for (let i = i0; i < i1; i++) {
      const t = i / SR, u = t - t0;
      const env = u < 0 ? 0 : u < ATT ? Math.sin(u / ATT * Math.PI / 2) ** 2 : t < t1 ? 1 : Math.max(0, 1 - (t - t1) / REL) ** 2;
      if (env <= 0) continue;
      let v = 0;
      if (sub) v = Math.sin(TAU * f * t + ph[0]);
      else for (let d = 0; d < 2; d++) { const fd = f * (d ? 1.0023 : 0.9977); for (let hN = 1; hN <= 4; hN++) v += Math.sin(TAU * fd * hN * t + ph[d] * hN) / (hN * hN); }
      v *= amp * env * (0.85 + 0.15 * Math.sin(TAU * 0.21 * t + k));
      L[i] += v * (1 - pan); R[i] += v * (1 + pan);
    }
  });
}
// two one-pole low-passes (~800 Hz) and a gentle fade in
const a = 1 - Math.exp(-TAU * 800 / SR);
let l1 = 0, l2 = 0, r1 = 0, r2 = 0, m = 0;
for (let i = 0; i < n; i++) { l1 += a * (L[i] - l1); l2 += a * (l1 - l2); r1 += a * (R[i] - r1); r2 += a * (r1 - r2); const f = Math.min(1, i / SR / 0.3); L[i] = l2 * f; R[i] = r2 * f; m = Math.max(m, Math.abs(L[i]), Math.abs(R[i])); }
const buf = Buffer.alloc(44 + n * 4);
buf.write('RIFF', 0); buf.writeUInt32LE(36 + n * 4, 4); buf.write('WAVEfmt ', 8); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(2, 22);
buf.writeUInt32LE(SR, 24); buf.writeUInt32LE(SR * 4, 28); buf.writeUInt16LE(4, 32); buf.writeUInt16LE(16, 34); buf.write('data', 36); buf.writeUInt32LE(n * 4, 40);
for (let i = 0; i < n; i++) { buf.writeInt16LE(Math.round(L[i] / m * 0.8 * 32767), 44 + i * 4); buf.writeInt16LE(Math.round(R[i] / m * 0.8 * 32767), 46 + i * 4); }
fs.writeFileSync(path.join(VD, 'bed.wav'), buf);
console.log(`bed.wav ${dur.toFixed(1)} s · ${Math.ceil(dur / BAR)} chords of ${BAR} s`);
```

### `cdp.mjs`

```js
// Headless Chrome/Edge over the DevTools protocol, no dependencies. ONE browser at a time, at the lowest priority.
// The film is HTML + a 2D canvas: Chrome draws it on the GPU when it can, and a CPU fallback is light enough to be fine.
import { spawn, execFile } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
export const CHROME = [process.env.CHROME, 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
  'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', 'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
  '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
  '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/microsoft-edge'].find(p => p && fs.existsSync(p));
const ANGLE = process.env.LANTERN_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(), 'lantern-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
// Lantern · 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 (Daniel Guide, multilingual v2, speed 1.0)
// 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) { const e = await r.text(); console.log(id, r.status, e.slice(0, 300)); if (/voice_not_found|not found/i.test(e)) console.log('  The voice is not on your ElevenLabs account yet: open the Voice Library, search "Daniel - Warm, Clear & Dynamic Guide" and add it, or set VOICE in script.mjs to a voice you have (George, JBFqnCBsd6RMkjVDRZzb, is a premade British narrator).'); 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
// Lantern · 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, head: (S.HEAD || []).includes(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, head: (S.HEAD || []).includes(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
// Lantern · 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.25] [--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.25);
  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 - 0.9).toFixed(3)}:d=0.9,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
// Lantern · 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" (56.2 s, 15 sections, 19 shots). Copy it to start a new video, then rewrite the takes and every shot for your script.

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

```js
// LANTERN · "Never pay for a Claude content skill again" — the script, in the reference's rhythm
// (@thedaoofthings "bear trap" + "3 reasons": a viewer's mess → "don't panic, buddy, we've got you" → "let's do the maths"
//  → numbers on badges → the turn → the ask → "Anyway." and it loops back to the first shot).
// Markup: /  a new caption chunk (in a HEAD section: a new headline line)   *word*  amber   {shown|said}  the caption shows
//   one thing, the voice says another. Digits in captions are amber by themselves. Sections in HEAD are the big condensed
//   headline at the top (the reference's "3 REASONS TO STOP DOING THIS"); every other section is a bottom subtitle.
export const VOICE = 'ynTHHllfDmGsZ3G8QJt9';   // ElevenLabs "Daniel - Warm, Clear & Dynamic Guide" (shared library, added as "Daniel Guide - Lantern")
export const SETTINGS = { stability: 0.5, similarity_boost: 0.8, style: 0.2, use_speaker_boost: true };
export const SPEED = 1.0;
export const HEAD = ['s0', 's13'];
export const ORDER = ['s0', 's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8', 's9', 's10', 's11', 's12', 's13', 'cta'];
export const GAP = { s1: 0.35, s2: 0.5, s3: 0.4, s4: 0.45, s5: 0.32, s6: 0.32, s7: 0.4, s8: 0.32, s9: 0.5, s10: 0.32, s11: 0.32, s12: 0.36, s13: 0.45, cta: 0.36 };
export const LEAD = 0.2, HOLD = 0.9;
export const TAKES = {
  s0: "*Never* pay for a / Claude content / skill again.",
  s1: "If you're about to pay {$30|thirty dollars} for one, don't panic, buddy. We've got you.",
  s2: "Now, the first thing you're probably wondering is, is it worth it?",
  s3: "Well, let's do the maths.",
  s4: "I'm Casper, and I built Morphy, an agentic super app.",
  s5: "I gave away my own content format and its {SKILL.md,|skill dot M D,} completely free.",
  s6: "And people took it, repackaged it, and started selling it for {$30.|thirty dollars.}",
  s7: "But a {SKILL.md|skill dot M D} is just a text file.",
  s8: "That's {$30,|thirty dollars,} for something I gave away for free.",
  s9: "So here's my response.",
  s10: "I'm building an entire library of content-format {.md|dot M D} files inside Morphy.",
  s11: "No paywalls. / No subscriptions. / No strings attached.",
  s12: "Because you shouldn't pay for a text file that was free to begin with.",
  s13: "This format is called / *{lantern.md.|lantern dot M D.}*",
  cta: "So comment {LANTERN,|lantern,} and I'll send it to you. Completely free. Anyway.",
};
```

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

```js
// LANTERN · example · "Never pay for a Claude content skill again" — the shots. One shot per beat, hard cuts, every time is a word.
// The reference (@thedaoofthings): moonlit flat silhouettes with a gold rim light, stepped glow rings, blueprint figures for
// "the maths", amber badges for every number, a subtitle under the picture, and the last shot is the first one ("Anyway.").
const { S, T0, T1, END, scene, cue, div, add, h, set, pop, rise, clamp, lerp, eo, eio, ease, back, rng, esc, s, svg, keys } = F;
const L = F.L, C = L.C;
const st = id => S[id].start - 0.04;   // a shot starts on its first word (a hair before)
const rimF = (x = 3, y = -3) => `drop-shadow(${x}px ${y}px 0 ${C.gold})`;
const G = (p, filter) => { const g = s(p, 'g'); if (filter) g.style.filter = filter; return g; };
const at = (el, x, y, r = 0, k = 1) => el.setAttribute('transform', `translate(${x.toFixed(1)} ${y.toFixed(1)}) rotate(${r.toFixed(2)}) scale(${k.toFixed(4)})`);
const ringsBg = 'linear-gradient(180deg, #0b2730 0%, #123b45 50%, #18454f 100%)';

// ---------- 1 · HOOK "Never pay for a Claude content skill again." — a hand holds up a phone with a $30 skill on it, in the
//   warm stepped halo; the headline lands word by word; on "again" a gold no-sign draws round the phone
const hookShot = (tin, tout, o = {}) => {
  const sc = scene(tin, tout, { pill: o.pill });
  const p = L.panel(sc, { top: 0, bottom: 1920, bg: 'radial-gradient(900px 900px at 540px 1180px, #15343a 0%, #0b2028 55%, #08171e 100%)', oy: 1150 });
  const R = L.rings(p.svg, 540, 1180, { r: 132, step: 50, n: 8, tone: 'amber' });
  const M = L.motes(p.svg, { n: 26, seed: 4, box: [80, 500, 1000, 1700], colors: ['#f6e2b0', '#cfe9e3'] });
  const back = G(p.svg, `drop-shadow(5px -3px 0 ${C.gold})`);
  s(back, 'path', { fill: C.ink, d: L.smooth([[452, 1960], [470, 1640], [430, 1470], [420, 1360], [690, 1360], [688, 1470], [650, 1560], [640, 1960]], true, 0.4) });
  const ph = G(p.svg, `drop-shadow(4px -4px 0 ${C.gold})`);
  s(ph, 'rect', { x: 392, y: 842, width: 296, height: 580, rx: 48, fill: C.ink });
  s(ph, 'rect', { x: 407, y: 857, width: 266, height: 550, rx: 36, fill: '#0f2b33' });
  L.glow(ph, 540, 1130, 300, '#9fe9df', 0.18);
  const ui = div(p.cam, 'abs', 407, 857, 266, 550, `
    <div style="position:absolute;left:24px;top:30px;display:flex;align-items:center;gap:12px;font:600 22px var(--sans);color:#8fbcbc"><span style="width:30px;height:38px;border-radius:5px;background:#a9e6dd;display:inline-block"></span>SKILL.md</div>
    <div style="position:absolute;left:24px;top:92px;width:220px;font:700 33px/1.12 var(--sans);color:#d7fbf4">Viral content skill</div>
    <div style="position:absolute;left:24px;top:180px;font:400 22px var(--sans);color:#7fa8aa">1 text file</div>
    <div style="position:absolute;left:0;width:266px;top:236px;text-align:center;font:150px/1 var(--cond);color:#f2b13b;text-shadow:0 0 30px rgba(242,177,59,.45)">$30</div>
    <div style="position:absolute;left:24px;top:440px;width:218px;height:72px;border-radius:36px;background:#f2b13b;color:#10181c;font:34px/72px var(--cond);letter-spacing:.08em;text-align:center">BUY NOW</div>`);
  const front = G(p.svg, `drop-shadow(4px -3px 0 ${C.gold})`);
  s(front, 'path', { fill: C.ink, d: L.smooth([[430, 1470], [392, 1360], [380, 1262], [398, 1230], [424, 1250], [440, 1350], [470, 1420]], true, 0.45) });
  for (const [y, l] of [[1150, 54], [1222, 60], [1294, 58], [1362, 50]]) s(front, 'rect', { x: 668, y: y - 22, width: l, height: 46, rx: 23, fill: C.ink });
  const no = L.noSign(p.svg, 540, 1135, 330, { w: 20 });
  sc.on(t => {
    R.draw(t); M.draw(t);
    const bob = Math.sin(t * 1.6) * 6;
    for (const e of [back, ph, front]) e.setAttribute('transform', `translate(0 ${bob.toFixed(1)})`);
    ui.style.transform = `translateY(${bob.toFixed(1)}px)`;
    no.draw(o.noAt == null ? 0 : (t - o.noAt) / 0.42);
    L.push(p, t, o.p0 ?? tin, o.p1 ?? tout, o.s0 ?? 1, o.s1 ?? 1.05);
  });
  return sc;
};
hookShot(-1, st('s1'), { noAt: T0('s0', 'again') - 0.1, p0: 0 });
cue('impact', 0.12, 0.14); cue('whoosh', T0('s0', 'again') - 0.14, 0.1); cue('thump', T0('s0', 'again') + 0.22, 0.24);

// ---------- the forest (shots 2, 3 and 16): he sits on a log with a laptop; his buddy has the lantern
//   forestShot(tin, tout, { cam: t => [scale, x, y], buddy: 'walkin' | 'stand' | 'hand', card, closed, tIn, tStop, tRaise, tHand, tFree })
const forestShot = (tin, tout, o = {}) => {
  const sc = scene(tin, tout, { pill: o.pill });
  const p = L.panel(sc, { top: 195, bottom: o.bottom ?? 1195, bg: 'linear-gradient(180deg, #0b2730 0%, #123b45 45%, #1a4c56 100%)', oy: 900 });
  const R = L.rings(p.svg, 560, 735, { r: 118, step: 62, n: 9 });
  const M = L.motes(p.svg, { n: 24, seed: 9, box: [0, 250, 1080, 1000] });
  L.forest(p.svg, { y: 1108, seed: 5 });
  L.ground(p.svg, 1102, { seed: 2 });
  const log = G(p.svg, rimF());
  s(log, 'rect', { x: 140, y: 992, width: 340, height: 110, rx: 40, fill: C.ink });
  s(log, 'circle', { cx: 452, cy: 1047, r: 34, fill: 'none', stroke: '#1d3440', 'stroke-width': 4 });
  const you = L.person(p.svg, { kind: 'hoodie', hat: 'messy', eye: true });
  const lapGlow = L.glow(p.svg, 0, 0, 170, '#bff7ee', 0.55);
  const lap = G(p.svg, rimF(3, -2));
  const lapBase = s(lap, 'path', { fill: C.ink }), lapLid = s(lap, 'path', { fill: C.ink });
  const lapScr = s(p.svg, 'path', { fill: '#c9fbf2', opacity: 0.9 });
  const buddy = L.person(p.svg, { kind: 'coat', hat: 'beanie' });
  const lan = L.lantern(p.svg);
  const page = o.buddy === 'hand' ? L.doc(p.svg, 0, 0, 70, 92, { title: false, lines: 3 }) : null;
  let card = null;
  if (o.card) card = div(p.cam, 'card', 92, 300, 690, 196, `
    <div class="k" style="position:absolute;left:30px;top:22px">Checkout</div>
    <div class="b" style="position:absolute;left:30px;top:56px;width:640px">Viral Claude content skill</div>
    <div style="position:absolute;left:30px;top:122px;height:52px;padding:0 18px;border-radius:12px;background:#a9e6dd;font:600 26px/52px var(--sans)">SKILL.md</div>
    <div style="position:absolute;left:220px;top:118px;font:64px/60px var(--cond);color:#0c1a20">$30.00</div>
    <div style="position:absolute;right:30px;top:122px;height:52px;padding:0 26px;border-radius:26px;background:#f2b13b;font:30px/52px var(--cond);letter-spacing:.08em">BUY NOW</div>
    <div style="position:absolute;left:250px;bottom:-24px;width:0;height:0;border-left:16px solid transparent;border-right:26px solid transparent;border-top:26px solid #d4f8f1"></div>`, { transformOrigin: '260px 220px' });
  const free = o.tFree != null ? L.badge(p.cam, 380, 610, '$0', 'TO BEGIN WITH', { solid: true }) : null;
  const burst = L.burst(p.svg, 380, 760, { r: 120 });
  sc.on(t => {
    R.draw(t); M.draw(t);
    const look = o.look ? o.look(t) : 14;
    const J = you.pose({ x: 320, y: 994, dir: 1, ...L.sit({ a: o.buddy === 'hand' && t > o.tHand ? [70, -40] : [56, -84], b: [46, -74], lean: 10 }), head: look });
    // laptop: base on his thighs, lid hinged at the knee end, the screen faces him
    const bx0 = J.hip[0] + 30, bx1 = J.knee[0] + 14, by = J.knee[1] - 30;
    const open = o.closed ? (o.closeAt != null ? 1 - eo((t - o.closeAt) / 0.3) : 0) : 1;
    const ang = lerp(84, -14, open) * Math.PI / 180, lx = bx1 - Math.sin(-ang) * 100, ly = by - Math.cos(ang) * 100;
    lapBase.setAttribute('d', `M${bx0} ${by}L${bx1} ${by}L${bx1} ${by + 12}L${bx0} ${by + 12}Z`);
    lapLid.setAttribute('d', `M${bx1 - 4} ${by + 2}L${(lx - 4).toFixed(1)} ${ly.toFixed(1)}L${(lx + 6).toFixed(1)} ${(ly + 2).toFixed(1)}L${bx1 + 6} ${by + 4}Z`);
    lapScr.setAttribute('d', `M${bx1 - 7} ${by - 2}L${(lx - 7).toFixed(1)} ${(ly + 4).toFixed(1)}`); lapScr.setAttribute('stroke', '#c9fbf2'); lapScr.setAttribute('stroke-width', 4 * open);
    lapGlow.setAttribute('cx', (bx1 - 50).toFixed(1)); lapGlow.setAttribute('cy', (by - 70).toFixed(1)); lapGlow.setAttribute('opacity', (open * (o.glowK ? o.glowK(t) : 1)).toFixed(2));
    // the buddy
    let B;
    if (o.buddy === 'walkin') {
      const u = clamp((t - o.tIn) / (o.tStop - o.tIn)), bx = lerp(1260, 790, eo(u)), ph = (t - o.tIn) * 7.5;
      const W = u < 1 ? L.walk(ph, 22 * (1 - u * 0.6)) : { l: [4, -2], m: [-4, 2], b: [0, 0], bob: 0 };
      const raise = clamp((t - o.tRaise) / 0.35);
      B = buddy.pose({ x: bx, y: 868 + (W.bob || 0) * 0.5, dir: -1, l: W.l, m: W.m, b: W.b, a: [lerp(36, 100, eo(raise)), lerp(-36, -60, eo(raise))], lean: -3, head: -6 });
      lan.g.setAttribute('transform', `translate(${B.hand[0].toFixed(1)} ${(B.hand[1] - 6).toFixed(1)}) rotate(${(Math.sin(t * 5) * 6 * (1 - raise)).toFixed(2)})`);
      lan.draw(t, u > 0 ? 0.6 + 0.6 * raise : 0.5);
    } else if (o.buddy === 'stand') {
      B = buddy.pose({ x: 780, y: 868, dir: -1, l: [4, -2], m: [-4, 2], a: [100 + Math.sin(t * 1.3) * 3, -60], b: [0, 0], lean: -6, head: -10 + (o.nod ? 8 * Math.sin(clamp((t - o.nod) / 0.5) * Math.PI) : 0) });
      lan.g.setAttribute('transform', `translate(${B.hand[0].toFixed(1)} ${(B.hand[1] - 6).toFixed(1)})`); lan.draw(t, 1.2);
    } else {
      // 'hand': lantern in the back hand, the page held out in the front hand, given on tHand
      const give = eo((t - o.tHand) / 0.45);
      B = buddy.pose({ x: 700, y: 868, dir: -1, l: [6, -3], m: [-6, 3], a: [lerp(50, 82, give), lerp(-20, -8, give)], b: [8, -4], lean: lerp(-4, -12, give), head: -14 });
      lan.g.setAttribute('transform', `translate(${B.hand2[0].toFixed(1)} ${(B.hand2[1] - 6).toFixed(1)})`); lan.draw(t, 1.1);
      const hx = lerp(B.hand[0] - 40, J.hand[0] - 30, give), hy = lerp(B.hand[1] - 70, J.hand[1] - 80, give);
      page.g.setAttribute('transform', `translate(${hx.toFixed(1)} ${hy.toFixed(1)})`);
      page.glow.setAttribute('opacity', (0.6 + 0.4 * Math.sin(t * 3)).toFixed(2));
    }
    if (card) pop(card, t, tin + 0.05, { d: 0.3, s0: 0.85, t1: o.cardOut ?? null, d1: 0.25 });
    if (free) { pop(free.el, t, o.tFree, { s0: 0.3 }); burst.draw(t, o.tFree); }
    const [k, x, y] = o.cam ? o.cam(t) : [1, 0, 0];
    L.cam(p, k, x, y);
  });
  return sc;
};
// 2 · "If you're about to pay $30 for one, don't panic, buddy. We've got you."
{
  const tIn = T0('s1', "don't") - 0.1, tStop = T0('s1', "we've"), tGot = T0('s1', 'got');
  forestShot(st('s1'), st('s2'), { card: true, cardOut: tGot + 0.1, buddy: 'walkin', tIn, tStop, tRaise: tGot - 0.2, look: t => t > tGot ? -14 : 14,
    cam: t => [lerp(1, 1.04, ease((t - st('s1')) / 5)), 0, lerp(0, -20, ease((t - st('s1')) / 5))] });
  cue('whoosh', st('s1'), 0.06); cue('pop', st('s1') + 0.06, 0.08); cue('swish', tIn, 0.06); cue('chime', tGot, 0.05);
}
// 3 · "Now, the first thing you're probably wondering is," — closer on the two of them
const tWorth = T0('s2', 'is', 1) - 0.06;
forestShot(st('s2'), tWorth, { buddy: 'stand', nod: T0('s2', 'first'), look: () => -12,
  cam: t => { const u = ease((t - st('s2')) / 3); return [lerp(1.3, 1.36, u), lerp(-40, -60, u), lerp(-80, -96, u)]; } });
cue('whoosh', st('s2'), 0.05);

// ---------- 4 · "is it worth it?" — close on the screen: the lantern's beam falls on the $30, a gold question mark
{
  const sc = scene(tWorth, st('s3'), { pill: true });
  const p = L.panel(sc, { top: 195, bottom: 1500, bg: 'linear-gradient(180deg, #0a242c 0%, #10343d 100%)', oy: 850 });
  const R = L.rings(p.svg, 700, 640, { r: 110, step: 58, n: 8 });
  L.forest(p.svg, { y: 1250, seed: 12, rows: [[7, 300, 420, '#225c66', -20], [5, 380, 520, '#173f49', 0]] });
  const beam = s(p.svg, 'path', { d: 'M930 140 L1010 140 L860 1180 L420 1180 Z', fill: L.linear(p.svg, [[0, '#ffd27a', 0.5], [1, '#ffd27a', 0.05]]) });
  const scr = div(p.cam, 'abs', 420, 600, 470, 330, `<div style="position:absolute;inset:0;border-radius:22px;background:#0f2b33;border:10px solid #03080b;box-shadow:0 0 0 3px #e9a93a, 0 0 80px rgba(160,240,225,.35)">
    <div style="position:absolute;left:30px;top:26px;font:600 26px var(--sans);color:#8fbcbc">Checkout</div>
    <div style="position:absolute;left:30px;top:66px;font:700 34px var(--sans);color:#d7fbf4">Viral Claude content skill</div>
    <div style="position:absolute;left:30px;top:118px;font:140px/1 var(--cond);color:#f2b13b;text-shadow:0 0 30px rgba(242,177,59,.5)">$30.00</div></div>`, { transform: 'perspective(900px) rotateY(-16deg)', transformOrigin: '0 50%' });
  const you = L.person(p.svg, { kind: 'hoodie', hat: 'messy', eye: true, rim: [5, -4] });
  const q = div(p.cam, 'abs', 860, 520, 0, 0, '<div style="position:absolute;transform:translate(-50%,-50%);font:220px/1 var(--cond);color:#f2b13b;text-shadow:0 0 40px rgba(242,177,59,.6)">?</div>', { transformOrigin: '0 0' });
  const sp = L.sparkle(p.svg, 930, 450, 26);
  const tQ = T0('s2', 'worth');
  sc.on(t => {
    R.draw(t);
    you.pose({ x: 120, y: 1290, dir: 1, scale: 2.3, lean: 12, head: -10, a: [-12, 8], b: [-18, 6], l: [6, 0], m: [-4, 0] });
    pop(q, t, tQ, { s0: 0.3, rot: 8 });
    sp.setAttribute('opacity', clamp((t - tQ - 0.15) / 0.1) * (0.6 + 0.4 * Math.sin(t * 9)));
    beam.setAttribute('opacity', (0.7 + 0.3 * Math.sin(t * 2.3)).toFixed(2));
    L.push(p, t, tWorth, st('s3'), 1.02, 1.1, 0, -10, 0, -20);
  });
  cue('swish', tWorth, 0.06); cue('pop', tQ, 0.1);
}

// ---------- 5 · "Well, let's do the maths." — a gold light line wipes to the blueprint: the checkout card drawn as a figure,
//   a pencil draws its price dimension, a ruler underneath
{
  const t0 = st('s3'), sc = scene(t0, st('s4'), { wipe: 'line', pill: true });
  const p = L.panel(sc, { top: 0, bottom: 1520, oy: 820 });
  L.blueprint(p.cam, { glow: [540, 820] });
  const sv = svg(p.cam);
  L.label(p.cam, 110, 300, 'FIG. 1 (THE OFFER)');
  const art = G(sv); art.setAttribute('fill', 'none'); art.setAttribute('stroke', C.amber); art.setAttribute('stroke-width', 5); art.setAttribute('stroke-linejoin', 'round');
  const parts = [s(art, 'rect', { x: 270, y: 620, width: 540, height: 360, rx: 26 }), s(art, 'path', { d: 'M310 690 H620' }), s(art, 'path', { d: 'M310 740 H540' }),
    s(art, 'rect', { x: 310, y: 800, width: 150, height: 70, rx: 12 }), s(art, 'rect', { x: 600, y: 890, width: 170, height: 56, rx: 28 }), s(art, 'path', { d: 'M330 805 v-0' })];
  const price = L.label(p.cam, 500, 795, '$30.00', { style: { fontSize: '86px', letterSpacing: '0.02em' } });
  const dim = L.dim(sv, 270, 810, 560), q = L.label(p.cam, 500, 486, 'PRICE = ?', { style: { fontSize: '50px' } });
  L.ruler(sv, 250, 1060, 580);
  const pen = L.pencil(sv);
  const tL = T0('s3', "let's"), tM = T0('s3', 'maths');
  sc.on(t => {
    parts.forEach((e, i) => L.draw(e, (t - (t0 + 0.15 + i * 0.12)) / 0.45));
    set(price, clamp((t - tL) / 0.2));
    dim.draw((t - tM + 0.1) / 0.45);
    const u = clamp((t - tM + 0.1) / 0.45);
    at(pen, lerp(270, 810, eo(u)), 556 + Math.sin(t * 30) * 2);
    pen.style.opacity = u > 0 && u < 1.2 ? 1 : 0.0;
    pop(q, t, tM + 0.3, { s0: 0.6 });
    L.push(p, t, t0, st('s4'), 1, 1.05);
  });
  cue('whoosh', t0, 0.12); cue('swish', tM - 0.1, 0.05);
}

// ---------- 6 · "I'm Casper, and I built Morphy, an agentic super app." — a desk by the big window at night; a builder at a
//   glowing monitor (an app of chats and cards); a badge names it
{
  const t0 = st('s4'), sc = scene(t0, st('s5'));
  const p = L.panel(sc, { top: 195, bottom: 1195, bg: '#071a21', oy: 760 });
  // outside, through the window
  const out = G(p.svg);
  s(out, 'rect', { x: 60, y: 250, width: 960, height: 660, fill: '#3f8e93' });
  const R = L.rings(out, 640, 560, { r: 96, step: 50, n: 9 });
  L.forest(out, { y: 910, seed: 21, rows: [[9, 160, 240, '#2a6870', -10], [7, 200, 300, '#1b4f59', 0]], fog: false });
  // the room: walls round the window, the window bars, the desk
  const room = G(p.svg);
  s(room, 'path', { d: 'M-100 0 H1180 V2000 H-100 Z M60 250 V910 H1020 V250 Z', fill: '#081c23', 'fill-rule': 'evenodd' });
  const bars = G(room, rimF(2, -2));
  for (const x of [372, 692]) s(bars, 'rect', { x, y: 250, width: 22, height: 660, fill: C.ink });
  s(bars, 'rect', { x: 60, y: 560, width: 960, height: 22, fill: C.ink });
  s(bars, 'rect', { x: 40, y: 900, width: 1000, height: 26, fill: C.ink });
  const desk = G(room, rimF(0, -3));
  s(desk, 'rect', { x: -20, y: 1000, width: 1120, height: 300, fill: C.ink });
  // the monitor and its app
  const mon = G(p.svg, rimF(3, -3));
  s(mon, 'rect', { x: 540, y: 640, width: 430, height: 290, rx: 16, fill: C.ink });
  s(mon, 'rect', { x: 735, y: 930, width: 40, height: 70, fill: C.ink }); s(mon, 'rect', { x: 680, y: 990, width: 150, height: 14, rx: 6, fill: C.ink });
  L.glow(p.svg, 755, 790, 330, '#bff7ee', 0.28);
  const app = div(p.cam, 'abs', 556, 656, 398, 258, `<div style="position:absolute;inset:0;border-radius:8px;background:#11323b;overflow:hidden">
    <div style="position:absolute;left:14px;top:14px;width:120px;height:230px;border-radius:8px;background:#1d4a52"></div>
    <div style="position:absolute;left:24px;top:28px;width:96px;height:22px;border-radius:11px;background:#d7fbf4"></div>
    <div style="position:absolute;left:24px;top:62px;width:80px;height:22px;border-radius:11px;background:#7fb3b0"></div>
    <div style="position:absolute;left:34px;top:96px;width:86px;height:22px;border-radius:11px;background:#f2b13b"></div>
    <div style="position:absolute;left:150px;top:14px;width:110px;height:110px;border-radius:10px;background:#d7fbf4"></div>
    <div style="position:absolute;left:272px;top:14px;width:112px;height:150px;border-radius:10px;background:#a9e6dd"></div>
    <div style="position:absolute;left:150px;top:136px;width:110px;height:108px;border-radius:10px;background:#7fb3b0"></div>
    <div style="position:absolute;left:272px;top:176px;width:112px;height:68px;border-radius:10px;background:#f2b13b"></div></div>`);
  const chair = G(p.svg, rimF());
  s(chair, 'path', { d: 'M150 760 L200 760 L215 960 L175 960 Z', fill: C.ink });
  s(chair, 'rect', { x: 170, y: 950, width: 200, height: 28, rx: 10, fill: C.ink });
  s(chair, 'rect', { x: 255, y: 975, width: 24, height: 120, fill: C.ink });
  const me = L.person(p.svg, { kind: 'shirt', hat: 'cap', rim: [4, -3] });
  const tM = T0('s4', 'morphy'), tA = T0('s4', 'agentic');
  const bd = L.badge(p.cam, 300, 380, 'MORPHY', 'AGENTIC SUPER APP');
  sc.on(t => {
    R.draw(t);
    const type = Math.sin(t * 16) * 4;
    me.pose({ x: 300, y: 948, dir: 1, ...L.sit({ a: [78 + type, -76], b: [70 - type, -70], lean: 14 }), head: -4 + Math.sin(t * 1.1) * 2 });
    pop(bd.el, t, tM, { s0: 0.3 });
    if (bd.l) bd.l.style.opacity = clamp((t - tA) / 0.15);
    L.push(p, t, t0, st('s5'), 1, 1.05, 0, -10);
  });
  cue('whoosh', t0, 0.06); cue('pop', tM, 0.1); cue('tick', tA, 0.06);
}

// ---------- 7 · "I gave away my own content format and its SKILL.md, completely free." — the format (a film strip) and its
//   SKILL.md float on a stand in the rings; copies fly out to everyone; PRICE $0.00; FREE stamps on
{
  const t0 = st('s5'), sc = scene(t0, st('s6'));
  const p = L.panel(sc, { top: 195, bottom: 1195, bg: ringsBg, oy: 720 });
  const R = L.rings(p.svg, 540, 700, { r: 140, step: 64, n: 9 });
  const M = L.motes(p.svg, { n: 20, seed: 31, box: [0, 250, 1080, 1100] });
  const stand = G(p.svg, rimF(3, -3));
  s(stand, 'path', { d: 'M380 1102 L420 940 L660 940 L700 1102 Z', fill: C.ink });
  s(stand, 'rect', { x: 400, y: 925, width: 280, height: 26, rx: 6, fill: C.ink });
  L.ground(p.svg, 1102, { seed: 5, tufts: 0 });
  const copies = [0, 1, 2, 3, 4].map(() => L.doc(p.svg, 0, 0, 120, 156, { title: false, lines: 4 }));
  const doc = L.doc(p.svg, 0, 0, 200, 262, { fs: 40 });
  const film = G(p.svg, rimF(3, -3));
  s(film, 'rect', { x: -70, y: -56, width: 140, height: 112, rx: 12, fill: C.ink });
  for (let i = 0; i < 4; i++) { s(film, 'rect', { x: -60 + i * 34, y: -48, width: 18, height: 12, rx: 3, fill: '#2a5560' }); s(film, 'rect', { x: -60 + i * 34, y: 36, width: 18, height: 12, rx: 3, fill: '#2a5560' }); }
  s(film, 'path', { d: 'M-14 -22 L22 0 L-14 22 Z', fill: C.amber });
  const gift = L.disc(p.cam, 180, 340, 56, L.ICON.gift());
  const bd = L.badge(p.cam, 820, 340, '$0.00', 'PRICE');
  const fr = L.badge(p.cam, 540, 470, 'FREE', null, { solid: true });
  const burst = L.burst(p.svg, 540, 470, { r: 160 });
  const tG = T0('s5', 'gave'), tS = T0('s5', 'skill'), tF = T0('s5', 'free');
  const dirs = [[-430, -260], [400, -300], [-460, 120], [440, 140], [0, -460]];
  sc.on(t => {
    R.draw(t); M.draw(t);
    const bob = Math.sin(t * 2) * 8;
    at(doc.g, 470, 640 + bob, 0); at(film, 330, 820 + bob * 0.6, -8);
    copies.forEach((c, i) => {
      const u = clamp((t - tG - 0.1 - i * 0.12) / 0.9);
      at(c.g, 510 + dirs[i][0] * eo(u), 700 + dirs[i][1] * eo(u), (i - 2) * 9 * u, 1 - 0.3 * u);
      c.g.style.opacity = u <= 0 ? 0 : (1 - clamp((u - 0.7) / 0.3)).toFixed(3);
    });
    pop(gift.el, t, t0 + 0.08); pop(bd.el, t, tS, { s0: 0.3 });
    pop(fr.el, t, tF, { s0: 1.8, d: 0.25 }); burst.draw(t, tF + 0.05);
    L.push(p, t, t0, st('s6'), 1, 1.05);
  });
  cue('whoosh', t0, 0.06); for (let i = 0; i < 5; i++) cue('swish', tG + 0.1 + i * 0.12, 0.03); cue('pop', tS, 0.1); cue('thump', tF, 0.22);
}

// ---------- 8 · "And people took it, repackaged it, and started selling it for $30." — a stall under a street lamp: a seller
//   catches the file, boxes it, ties a ribbon, and a $30 tag swings down; the PRICE badge counts $0.00 → $30.00
{
  const t0 = st('s6'), sc = scene(t0, st('s7'));
  const p = L.panel(sc, { top: 195, bottom: 1195, bg: 'linear-gradient(180deg, #08171d 0%, #0c2329 60%, #132e31 100%)', oy: 800 });
  const R = L.rings(p.svg, 770, 470, { r: 34, step: 46, n: 8, tone: 'amber', core: false });
  // skyline
  const sky = G(p.svg);
  for (const [x, w, hh] of [[0, 160, 380], [150, 120, 300], [260, 150, 440], [700, 140, 360], [880, 200, 420]]) s(sky, 'rect', { x, y: 1102 - hh, width: w, height: hh, fill: '#0f2a31' });
  for (const [x, y] of [[40, 800], [90, 860], [300, 720], [330, 800], [740, 820], [930, 760], [960, 840]]) s(sky, 'rect', { x, y, width: 18, height: 22, fill: '#e9c27a', opacity: 0.6 });
  L.ground(p.svg, 1102, { seed: 8, tufts: 0, fill: '#060f13', edge: '#2a3a35' });
  const cone = s(p.svg, 'path', { d: 'M740 470 L800 470 L1010 1102 L470 1102 Z', fill: L.linear(p.svg, [[0, '#ffd27a', 0.42], [1, '#ffd27a', 0.04]]) });
  const lamp = G(p.svg, rimF(3, -3));
  s(lamp, 'rect', { x: 880, y: 420, width: 18, height: 690, fill: C.ink }); s(lamp, 'path', { d: 'M889 430 Q889 400 860 400 L770 400 L770 420 L860 420 Q869 420 869 430 Z', fill: C.ink });
  s(lamp, 'path', { d: 'M735 418 L805 418 L792 456 L748 456 Z', fill: C.ink }); s(lamp, 'ellipse', { cx: 770, cy: 458, rx: 22, ry: 8, fill: '#ffe2a1' });
  const seller = L.person(p.svg, { kind: 'trench', hat: 'fedora', rim: [-4, -3] });
  const table = G(p.svg, rimF(2, -3));
  s(table, 'rect', { x: 330, y: 905, width: 470, height: 26, rx: 6, fill: C.ink }); s(table, 'path', { d: 'M340 930 L790 930 L770 1102 L360 1102 Z', fill: '#081419' });
  for (const x of [372, 742]) s(table, 'rect', { x, y: 930, width: 16, height: 172, fill: C.ink });
  const doc = L.doc(p.svg, 0, 0, 96, 126, { title: false, lines: 3 });
  const box = G(p.svg, rimF(3, -3));
  s(box, 'rect', { x: -95, y: -120, width: 190, height: 120, rx: 6, fill: C.ink });
  const ribbon = s(box, 'rect', { x: -12, y: -120, width: 24, height: 120, fill: C.amber, opacity: 0 });
  const lbl = s(box, 'text', { x: 0, y: -46, 'text-anchor': 'middle', 'font-family': 'Bebas Neue', 'font-size': 34, fill: C.amber, opacity: 0, 'letter-spacing': 2 }); lbl.textContent = 'PREMIUM';
  const lid = G(p.svg, rimF(2, -3)); s(lid, 'rect', { x: -102, y: -16, width: 204, height: 22, rx: 5, fill: C.ink });
  const bow = s(p.svg, 'path', { d: 'M0 0 C-30 -34 -52 -6 0 0 C52 -6 30 -34 0 0 Z', fill: C.amber, opacity: 0 });
  const tag = L.priceTag(p.svg, '$30', { w: 160, h: 96, fs: 84 });
  const bd = L.badge(p.cam, 250, 360, '$0.00', 'PRICE');
  const tT = T0('s6', 'took'), tR = T0('s6', 'repackaged'), tS = T0('s6', 'selling'), t30 = T0('s6', '30');
  sc.on(t => {
    R.draw(t);
    cone.setAttribute('opacity', (0.85 + 0.15 * Math.sin(t * 3)).toFixed(2));
    const catchU = eo((t - tT) / 0.35), drop = eo((t - tR) / 0.3);
    const armUp = clamp((t - tT + 0.1) / 0.25) * (1 - clamp((t - tR) / 0.25));
    const P = seller.pose({ x: 600, y: 860, dir: -1, lean: -4, head: lerp(-6, -24, armUp), a: [lerp(30, 150, armUp), lerp(-40, -20, armUp)], b: [10, 0], l: [0, 0], m: [0, 0] });
    // the file flies in from the left into his raised hand, then drops into the box
    let dx, dy;
    if (t < tR) { dx = lerp(-160, P.hand[0] - 48, catchU); dy = lerp(560, P.hand[1] - 120, catchU); }
    else { dx = lerp(P.hand[0] - 48, 512, drop); dy = lerp(P.hand[1] - 120, 790, drop); }
    at(doc.g, dx, dy, 0, t < tR ? 1 : 1 - 0.4 * drop);
    doc.g.style.opacity = t > tR + 0.32 ? 0 : 1;
    at(box, 560, 905);
    const shut = eo((t - tR - 0.3) / 0.2);
    at(lid, 560, lerp(700, 785, shut), lerp(-30, 0, shut));
    ribbon.setAttribute('opacity', clamp((t - tR - 0.55) / 0.15)); lbl.setAttribute('opacity', clamp((t - tR - 0.6) / 0.15));
    at(bow, 560, 780, 0, clamp((t - tR - 0.55) / 0.15)); bow.setAttribute('opacity', clamp((t - tR - 0.55) / 0.1));
    const sw = Math.exp(-(t - tS - 0.3) * 2.2) * Math.sin((t - tS) * 9) * 14;
    at(tag.g, 690, lerp(330, 560, eo((t - tS) / 0.35)), t > tS ? sw : 0);
    tag.g.style.opacity = t > tS - 0.02 ? 1 : 0;
    pop(bd.el, t, t0 + 0.1);
    const v = clamp((t - t30 + 0.05) / 0.5);
    F.html(bd.v, '$' + (30 * eo(v)).toFixed(2));
    L.push(p, t, t0, st('s7'), 1, 1.05, 0, -10);
  });
  cue('whoosh', t0, 0.06); cue('swish', tT, 0.07); cue('thump', tR + 0.42, 0.18); cue('swish', tS, 0.05);
  for (let i = 0; i < 7; i++) cue('tick', t30 + i * 0.07, 0.05);
}

// ---------- 9 · "But a SKILL.md is just a text file." — FIG. 2 (FROM ABOVE): the box opens, inside is one page of text;
//   numbered leaders read TEXT, MORE TEXT, EVEN MORE TEXT; FILE TYPE · PLAIN TEXT
{
  const t0 = st('s7'), sc = scene(t0, st('s8'), { pill: true });
  const p = L.panel(sc, { top: 0, bottom: 1520, oy: 800 });
  L.blueprint(p.cam, { glow: [500, 780] });
  const sv = svg(p.cam);
  L.label(p.cam, 110, 300, 'FIG. 2 (FROM ABOVE)');
  const art = G(sv); art.setAttribute('fill', 'none'); art.setAttribute('stroke', C.amber); art.setAttribute('stroke-width', 5); art.setAttribute('stroke-linejoin', 'round');
  const boxR = s(art, 'rect', { x: 170, y: 560, width: 400, height: 440 });
  const flaps = [s(art, 'path', { d: 'M170 560 L225 470 L515 470 L570 560' }), s(art, 'path', { d: 'M170 1000 L225 1090 L515 1090 L570 1000' }),
    s(art, 'path', { d: 'M170 560 L100 620 L100 940 L170 1000' }), s(art, 'path', { d: 'M570 560 L640 620 L640 940 L570 1000' })];
  const pageR = s(art, 'rect', { x: 245, y: 615, width: 250, height: 330, rx: 6, 'stroke-width': 4, fill: 'rgba(215,251,244,0.06)' });
  const lines = [0, 1, 2, 3, 4, 5].map(i => s(art, 'path', { d: `M275 ${680 + i * 42} H${i % 3 === 2 ? 390 : 465}`, 'stroke-width': 6, 'stroke-linecap': 'round' }));
  const leads = [[465, 680, 690, 600, '1'], [390, 764, 690, 760, '2'], [465, 848, 690, 920, '3']].map(([x1, y1, x2, y2, n]) => ({ l: s(art, 'path', { d: `M${x1} ${y1} L${x2 - 14} ${y2}`, 'stroke-width': 3 }), n }));
  const names = ['TEXT', 'MORE TEXT', 'STILL TEXT'];
  const lab = names.map((nm, i) => L.label(p.cam, 684, [578, 738, 898][i], `${i + 1}  ${nm}`, { style: { fontSize: '40px' } }));
  const bd = L.badge(p.cam, 510, 1200, 'PLAIN TEXT', 'FILE TYPE');
  const tK = T0('s7', 'skill'), tJ = T0('s7', 'just'), tX = T0('s7', 'text'), tF = T0('s7', 'file');
  sc.on(t => {
    L.draw(boxR, (t - t0) / 0.3);
    flaps.forEach((f, i) => L.draw(f, (t - tK - i * 0.06) / 0.3));
    L.draw(pageR, (t - tJ + 0.1) / 0.25);
    lines.forEach((l, i) => L.draw(l, (t - tJ - 0.05 - i * 0.05) / 0.15));
    leads.forEach((d, i) => L.draw(d.l, (t - tX + 0.1 - i * 0.12) / 0.2));
    lab.forEach((e, i) => set(e, clamp((t - tX - i * 0.12 + 0.05) / 0.15)));
    pop(bd.el, t, tF, { s0: 0.3 });
    L.push(p, t, t0, st('s8'), 1, 1.05, 0, -10);
  });
  cue('swish', t0, 0.05); cue('swish', tK, 0.04); cue('tick', tX, 0.05); cue('tick', tX + 0.12, 0.05); cue('tick', tX + 0.24, 0.05); cue('pop', tF, 0.1);
}

// ---------- 10 · "That's $30, for something I gave away for free." — a balance in the rings: a weightless page against $30 in
//   coins; it slams down on the coins' side; a $0 disc lights over the page on "free"
{
  const t0 = st('s8'), sc = scene(t0, st('s9'));
  const p = L.panel(sc, { top: 195, bottom: 1195, bg: ringsBg, oy: 700 });
  const R = L.rings(p.svg, 540, 640, { r: 130, step: 62, n: 9 });
  L.ground(p.svg, 1102, { seed: 4, tufts: 0 });
  const post = G(p.svg, rimF());
  s(post, 'rect', { x: 528, y: 560, width: 24, height: 480, fill: C.ink }); s(post, 'path', { d: 'M440 1102 L470 1030 L610 1030 L640 1102 Z', fill: C.ink });
  s(post, 'circle', { cx: 540, cy: 560, r: 26, fill: C.ink });
  const beam = G(p.svg, rimF());
  s(beam, 'rect', { x: -330, y: -12, width: 660, height: 24, rx: 12, fill: C.ink });
  const panL = G(p.svg, rimF()), panR = G(p.svg, rimF());
  for (const pn of [panL, panR]) { s(pn, 'path', { d: 'M0 0 L-90 170 M0 0 L90 170', stroke: C.ink, 'stroke-width': 5 }); s(pn, 'path', { d: 'M-120 170 L120 170 Q100 220 0 222 Q-100 220 -120 170 Z', fill: C.ink }); }
  const pg = L.doc(panL, -36, 70, 72, 96, { title: false, lines: 3 });
  for (let i = 0; i < 6; i++) { s(panR, 'ellipse', { cx: (i % 3 - 1) * 34, cy: 160 - Math.floor(i / 3) * 22, rx: 30, ry: 12, fill: C.amber, stroke: C.ink, 'stroke-width': 3 }); }
  const b30 = L.badge(p.cam, 0, 0, '$30', null);
  const z = L.disc(p.cam, 0, 0, 52, '<div style="font:52px/1 var(--cond);color:#10181c">$0</div>');
  const t30 = T0('s8', '30'), tFr = T0('s8', 'free');
  sc.on(t => {
    R.draw(t);
    const tilt = lerp(0, 16, back(clamp((t - t30) / 0.4), 2.2));
    at(beam, 540, 560, tilt);
    const a = tilt * Math.PI / 180, lx = 540 - 300 * Math.cos(a), ly = 560 - 300 * Math.sin(a), rx = 540 + 300 * Math.cos(a), ry = 560 + 300 * Math.sin(a);
    at(panL, lx, ly); at(panR, rx, ry);
    b30.el.style.left = (rx).toFixed(1) + 'px'; b30.el.style.top = (ry + 300).toFixed(1) + 'px';
    pop(b30.el, t, t30 + 0.2, { s0: 0.3 });
    z.el.style.left = lx.toFixed(1) + 'px'; z.el.style.top = (ly - 50).toFixed(1) + 'px';
    pop(z.el, t, tFr, { s0: 0.3 });
    pg.glow.setAttribute('opacity', t > tFr ? 1 : 0.5);
    L.push(p, t, t0, st('s9'), 1, 1.05);
  });
  cue('whoosh', t0, 0.06); cue('thump', t30 + 0.25, 0.2); cue('pop', t30 + 0.2, 0.08); cue('chime', tFr, 0.05);
}

// ---------- 11 · "So here's my response." — the buddy's lantern rises into the rings and flares
{
  const t0 = st('s9'), sc = scene(t0, st('s10'));
  const p = L.panel(sc, { top: 195, bottom: 1195, bg: ringsBg, oy: 700 });
  const R = L.rings(p.svg, 540, 700, { r: 150, step: 70, n: 9 });
  const M = L.motes(p.svg, { n: 30, seed: 41, box: [0, 250, 1080, 1100], colors: ['#ffd27a', '#e9fffb'] });
  const arm = G(p.svg, rimF(4, -3)), armP = s(arm, 'path', { fill: C.ink });
  const lan = L.lantern(p.svg);
  const burst = L.burst(p.svg, 540, 800, { r: 220 });
  const sp = [L.sparkle(p.svg, 650, 690, 30), L.sparkle(p.svg, 420, 760, 20)];
  const tR = T0('s9', 'response');
  sc.on(t => {
    R.draw(t, 1 + 0.06 * clamp((t - tR) / 0.2) * Math.exp(-(t - tR) * 2));
    M.draw(t);
    const u = eo((t - t0) / 0.6), y = lerp(1300, 640, u);
    at(lan.g, 540, y, Math.sin(t * 4) * 3 * (1 - u), 2.1);
    lan.draw(t, 0.8 + 1.0 * clamp((t - tR + 0.05) / 0.15) * (0.75 + 0.25 * Math.exp(-(t - tR) * 3)));
    armP.setAttribute('d', `M${540 - 22} ${y + 6} L${540 + 22} ${y + 6} L${760} ${y + 640} L${640} ${y + 680} Z`);
    burst.draw(t, tR);
    sp.forEach((e, i) => e.setAttribute('opacity', clamp((t - tR - i * 0.08) / 0.1) * (0.5 + 0.5 * Math.sin(t * 8 + i))));
    L.push(p, t, t0, st('s10'), 1, 1.06);
  });
  cue('riser', t0, 0.05, 0.9); cue('chime', tR, 0.09); cue('whoosh', tR - 0.05, 0.06);
}

// ---------- 12 · "I'm building an entire library of content-format .md files inside Morphy." — tall dark shelves; the lantern
//   lights them and glowing format files slide into the slots one by one; FORMATS counts to 12; INSIDE MORPHY
{
  const t0 = st('s10'), sc = scene(t0, st('s11'));
  const p = L.panel(sc, { top: 195, bottom: 1195, bg: 'linear-gradient(180deg, #071a21 0%, #0b252d 100%)', oy: 760 });
  const R = L.rings(p.svg, 540, 700, { r: 110, step: 70, n: 9, alpha: 0.35 });
  const shelf = G(p.svg, rimF(0, -3));
  const X0 = 90, X1 = 990, Y0 = 470, rows = 4, rowH = 150;
  s(shelf, 'rect', { x: X0 - 20, y: Y0 - 30, width: X1 - X0 + 40, height: 30, fill: C.ink });
  for (let r = 0; r <= rows; r++) s(shelf, 'rect', { x: X0 - 20, y: Y0 + r * rowH, width: X1 - X0 + 40, height: 16, fill: C.ink });
  for (const x of [X0 - 20, 380, 690, X1]) s(shelf, 'rect', { x, y: Y0 - 30, width: 22, height: rows * rowH + 46, fill: C.ink });
  L.ground(p.svg, Y0 + rows * rowH + 16, { seed: 6, tufts: 0, fill: '#04090c' });
  // books already there (dark spines)
  const books = G(p.svg); const r0 = rng(17);
  for (let r = 0; r < rows; r++) for (let x = X0 + 6; x < X1 - 30; x += 26 + Math.floor(r0() * 10)) { if (r0() < 0.45) continue; const hh = 70 + r0() * 50; s(books, 'rect', { x, y: Y0 + r * rowH + rowH - hh, width: 20, height: hh, fill: r0() < 0.5 ? '#12262d' : '#0c1c22' }); }
  const NAMES = ['INK', 'VOXEL', 'CLAY', 'SANDBOX', 'PAPER', 'PIXEL', 'GLOW', 'CUTSCENE', 'HALFTONE', 'LYRIC', 'TOYBOX', 'DEV SKIT'];
  const slots = NAMES.map((n, i) => ({ x: [120, 250, 410, 540, 700, 830][i % 6], y: Y0 + (i < 6 ? 0 : 2) * rowH + rowH - 112, n }));
  const files = slots.map(sl => div(p.cam, 'abs', sl.x, sl.y, 96, 112, `<div style="position:absolute;inset:0;border-radius:8px;background:#d7fbf4;box-shadow:0 0 26px rgba(191,247,238,.55);border:3px solid #03080b"></div>
    <div style="position:absolute;left:8px;top:10px;width:58px;height:10px;border-radius:5px;background:#7fa8aa"></div><div style="position:absolute;left:8px;top:28px;width:70px;height:10px;border-radius:5px;background:#a9c8c8"></div>
    <div style="position:absolute;left:0;width:96px;bottom:8px;text-align:center;font:24px/1 var(--cond);letter-spacing:.06em;color:#10181c">${sl.n}</div>`));
  const lan = L.lantern(p.svg);
  const cord = s(p.svg, 'line', { x1: 540, y1: 150, x2: 540, y2: 300, stroke: C.ink, 'stroke-width': 5 });
  const books2 = L.disc(p.cam, 170, 372, 52, L.ICON.books());
  const cnt = L.badge(p.cam, 800, 372, '00', 'FORMATS');
  const inside = L.badge(p.cam, 540, 1040, 'INSIDE MORPHY', null, { solid: true });
  const tB = T0('s10', 'entire'), tE = T0('s10', 'files'), tI = T0('s10', 'inside');
  const step = (tE - tB) / NAMES.length;
  sc.on(t => {
    R.draw(t);
    at(lan.g, 540 + Math.sin(t * 1.4) * 10, 300, Math.sin(t * 1.4) * -4, 0.9); lan.draw(t, 1.3);
    cord.setAttribute('x2', (540 + Math.sin(t * 1.4) * 10).toFixed(1));
    let n = 0;
    files.forEach((f, i) => { const ti = tB + i * step, u = eo((t - ti) / 0.28); if (t >= ti) n = i + 1; f.style.opacity = u > 0 ? 1 : 0; f.style.transform = `translate(0px, ${((1 - u) * -90).toFixed(1)}px) scale(${(0.7 + 0.3 * u).toFixed(3)})`; });
    F.html(cnt.v, String(n).padStart(2, '0'));
    pop(books2.el, t, t0 + 0.08); pop(cnt.el, t, tB - 0.1, { s0: 0.4 });
    pop(inside.el, t, tI, { s0: 0.3 });
    L.push(p, t, t0, st('s11'), 1.02, 1.08, 0, -20);
  });
  cue('whoosh', t0, 0.05); NAMES.forEach((_, i) => cue('tick', tB + i * step + 0.1, 0.04)); cue('pop', tI, 0.1);
}

// ---------- 13–15 · "No paywalls. / No subscriptions. / No strings attached." — three rig shots in the rings, a hard cut each
const rigShot = (tin, tout, draw) => {
  const sc = scene(tin, tout);
  const p = L.panel(sc, { top: 195, bottom: 1195, bg: ringsBg, oy: 700 });
  const R = L.rings(p.svg, 540, 700, { r: 130, step: 62, n: 9 });
  L.ground(p.svg, 1102, { seed: 4, tufts: 0 });
  const frame = G(p.svg, rimF());
  s(frame, 'rect', { x: 150, y: 330, width: 780, height: 26, fill: C.ink }); for (const x of [150, 904]) s(frame, 'rect', { x, y: 330, width: 26, height: 772, fill: C.ink });
  const f = draw(p, sc);
  sc.on(t => { R.draw(t); f(t); L.push(p, t, tin, tout, 1, 1.06); });
  return sc;
};
const tN1 = st('s11'), tN2 = T0('s11', 'no', 1) - 0.05, tN3 = T0('s11', 'no', 2) - 0.05;
rigShot(tN1, tN2, (p) => {
  const doc = L.doc(p.svg, 440, 560, 200, 262);
  const wall = G(p.svg, rimF(2, -2)), bricks = [];
  for (let r = 0; r < 6; r++) for (let c = 0; c < 6; c++) { const x = 230 + c * 104 + (r % 2 ? 52 : 0), y = 520 + r * 62; if (x > 800) continue; bricks.push({ e: s(wall, 'rect', { x: 0, y: 0, width: 100, height: 58, rx: 4, fill: C.ink2, stroke: C.gold, 'stroke-width': 2 }), x, y, k: (r * 7 + c * 3) % 5 }); }
  const lock = L.disc(p.cam, 540, 690, 70, L.ICON.lock());
  const burst = L.burst(p.svg, 540, 700, { r: 200 });
  const tP = T0('s11', 'paywalls');
  return t => {
    const u = t - tP - 0.05;
    for (const b of bricks) { const v = Math.max(0, u - b.k * 0.03); at(b.e, b.x + (b.x - 520) * 0.4 * v, b.y + 900 * v * v, (b.x - 520) * 0.12 * v); }
    const lv = Math.max(0, u);
    lock.el.style.transform = `translate(0px, ${(900 * lv * lv).toFixed(1)}px) rotate(${(lv * 60).toFixed(1)}deg)`;
    lock.el.style.opacity = u > 0.7 ? 0 : 1;
    burst.draw(t, tP);
  };
});
cue('thump', T0('s11', 'paywalls'), 0.22); cue('swish', T0('s11', 'paywalls') + 0.05, 0.05);
rigShot(tN2, tN3, (p) => {
  const card = div(p.cam, 'card', 300, 520, 480, 330, `<div class="k" style="position:absolute;left:34px;top:30px">Subscription</div>
    <div style="position:absolute;left:34px;top:70px;font:120px/1 var(--cond);color:#0c1a20">$ / MONTH</div>
    <div style="position:absolute;left:34px;top:210px;font:600 30px var(--sans);color:#4c6a70">Renews every month</div>`);
  const spin = G(p.svg); spin.setAttribute('fill', 'none'); spin.setAttribute('stroke', C.amber); spin.setAttribute('stroke-width', 12); spin.setAttribute('stroke-linecap', 'round');
  s(spin, 'path', { d: 'M-60 0 A60 60 0 1 1 0 60' }); s(spin, 'path', { d: 'M-20 60 L0 60 L-4 38', 'stroke-width': 10 });
  const no = L.noSign(p.svg, 540, 690, 300, { w: 22 });
  const tS = T0('s11', 'subscriptions');
  return t => { at(spin, 780, 520, t * 260); no.draw((t - tS - 0.05) / 0.38); };
});
cue('whoosh', tN2, 0.05); cue('thump', T0('s11', 'subscriptions') + 0.3, 0.18);
rigShot(tN3, st('s12'), (p) => {
  const bar = G(p.svg, rimF()); s(bar, 'rect', { x: 330, y: 400, width: 420, height: 22, rx: 8, fill: C.ink }); s(bar, 'rect', { x: 528, y: 380, width: 24, height: 80, rx: 8, fill: C.ink });
  const strings = [[350, 470], [540, 470], [730, 470]].map(([x]) => s(p.svg, 'line', { x1: x, y1: 420, x2: 0, y2: 0, stroke: C.mint2, 'stroke-width': 3 }));
  const doc = L.doc(p.svg, 0, 0, 200, 262);
  const burst = L.burst(p.svg, 540, 600, { r: 160 });
  const tA = T0('s11', 'attached');
  return t => {
    const cut = t > tA, fall = Math.max(0, t - tA);
    const dy = 600 + Math.sin(t * 2) * 10 + 700 * fall * fall, rot = Math.sin(t * 1.7) * 4 + fall * 20;
    at(doc.g, 440, dy, rot);
    strings.forEach((l, i) => { const x = [350, 540, 730][i], ex = 440 + [10, 100, 190][i], u = cut ? clamp(fall / 0.25) : 0; l.setAttribute('x1', x); l.setAttribute('x2', lerp(ex, x, eo(u)).toFixed(1)); l.setAttribute('y2', lerp(dy + 4, 440, eo(u)).toFixed(1)); });
    burst.draw(t, tA);
  };
});
cue('swish', tN3, 0.05); cue('thump', T0('s11', 'attached'), 0.2);

// 16 · "Because you shouldn't pay for a text file that was free to begin with." — back in the forest: the laptop shuts, the
//   buddy hands him the glowing page; "$0 · TO BEGIN WITH"
forestShot(st('s12'), st('s13'), { buddy: 'hand', closed: true, closeAt: T0('s12', 'pay'), tHand: T0('s12', 'text'), tFree: T0('s12', 'free'), look: () => -10,
  cam: t => { const u = ease((t - st('s12')) / 4); return [lerp(1.12, 1.2, u), lerp(-20, -40, u), lerp(-90, -110, u)]; } });
cue('whoosh', st('s12'), 0.05); cue('click', T0('s12', 'pay') + 0.2, 0.08); cue('swish', T0('s12', 'text'), 0.05); cue('chime', T0('s12', 'free'), 0.06);

// ---------- 17 · HEADLINE "This format is called lantern.md." — the lantern itself, big, in the warm rings
{
  const t0 = st('s13'), sc = scene(t0, st('cta'));
  const p = L.panel(sc, { top: 0, bottom: 1920, bg: 'radial-gradient(900px 900px at 540px 1180px, #15343a 0%, #0b2028 55%, #08171e 100%)', oy: 1150 });
  const R = L.rings(p.svg, 540, 1190, { r: 150, step: 52, n: 8, tone: 'amber' });
  const M = L.motes(p.svg, { n: 28, seed: 51, box: [80, 600, 1000, 1700], colors: ['#ffd27a', '#e9fffb'] });
  const cord = s(p.svg, 'line', { x1: 540, y1: -40, x2: 540, y2: 900, stroke: C.ink, 'stroke-width': 8 });
  const lan = L.lantern(p.svg);
  const tL = T0('s13', 'lantern');
  sc.on(t => {
    R.draw(t); M.draw(t);
    const sw = Math.sin(t * 1.5) * 5;
    at(lan.g, 540, 930, sw, 2.6);
    cord.setAttribute('x2', (540 - Math.sin(sw * Math.PI / 180) * 0).toFixed(1));
    lan.draw(t, 1 + 0.5 * clamp((t - tL) / 0.2));
    L.push(p, t, t0, st('cta'), 1, 1.05);
  });
  cue('whoosh', t0, 0.06); cue('chime', tL, 0.07);
}
F.cap.headTop.s13 = 340;

// ---------- 18 · "So comment LANTERN, and I'll send it to you. Completely free." — comments stack up in a phone frame, the
//   LANTERN one gets the crown; a message with lantern.md slides in; $0.00 · FREE
{
  const t0 = st('cta'), tAny = T0('cta', 'anyway') - 0.05, sc = scene(t0, tAny, { pill: true });
  const p = L.panel(sc, { top: 0, bottom: 1520, bg: 'radial-gradient(700px 700px at 540px 760px, #16404a 0%, #0b2229 65%, #08181e 100%)', oy: 800 });
  const frame = div(p.cam, 'abs', 170, 300, 700, 1060, '', { borderRadius: '60px', background: 'rgba(8,24,30,0.65)', border: '3px solid rgba(169,230,221,0.18)' });
  const bubs = [0, 1, 2, 3, 4].map(i => L.bubble(p.cam, 220 + (i % 2) * 40, 0, 560));
  const top = L.bubble(p.cam, 210, 0, 600, { text: 'LANTERN' });
  top.style.border = '4px solid #f2b13b'; top.style.boxShadow = '0 0 34px rgba(242,177,59,0.5), 0 14px 36px rgba(0,0,0,0.45)';
  const heart = div(top, 'abs', 420, 26, 140, 44, '', { font: '40px/44px var(--cond)', color: '#c8862a', textAlign: 'right' });
  const crown = div(p.cam, 'abs', 450, 0, 120, 80, `<svg viewBox="0 0 120 80" width="120" height="80"><path d="M10 70 L18 22 L42 46 L60 10 L78 46 L102 22 L110 70 Z" fill="#f2b13b" stroke="#03080b" stroke-width="5" stroke-linejoin="round"/></svg>`, { transformOrigin: '60px 70px' });
  const dm = div(p.cam, 'card', 180, 1010, 680, 230, `<div class="k" style="position:absolute;left:30px;top:24px">Message</div>
    <div class="b" style="position:absolute;left:30px;top:58px">Here you go, completely free:</div>
    <div style="position:absolute;left:30px;top:130px;height:70px;padding:0 26px;border-radius:14px;background:#03080b;color:#f2b13b;font:44px/70px var(--cond);letter-spacing:.06em;box-shadow:0 0 22px rgba(242,177,59,.35)">LANTERN.MD</div>`);
  const free = L.badge(p.cam, 760, 935, '$0.00', 'FREE', { solid: true });
  const sv = svg(p.cam), hearts = [0, 1, 2, 3, 4, 5].map(i => s(sv, 'path', { d: 'M0 6 C-10 -6 -22 4 0 20 C22 4 10 -6 0 6 Z', fill: C.amber }));
  const tK = T0('cta', 'lantern'), tSend = T0('cta', 'send'), tFree = T0('cta', 'completely');
  sc.on(t => {
    // bubbles rise into the frame one after another; the LANTERN one lands on top with the crown
    bubs.forEach((b, i) => { const u = eo((t - t0 - i * 0.13) / 0.35); b.style.top = (lerp(1500, 560 + i * 112, u) - eo((t - tSend) / 0.4) * 80).toFixed(1) + 'px'; b.style.opacity = u > 0 ? (1 - 0.6 * eo((t - tSend) / 0.3)).toFixed(3) : 0; });
    const uk = eo((t - tK) / 0.3);
    top.style.top = (lerp(1500, 432, uk) - eo((t - tSend) / 0.4) * 80).toFixed(1) + 'px'; top.style.opacity = uk > 0 ? 1 : 0;
    F.html(heart, '♥ ' + Math.round(999 * eo(clamp((t - tK - 0.2) / 0.8))));
    crown.style.top = (lerp(1500, 370, uk) - eo((t - tSend) / 0.4) * 80).toFixed(1) + 'px';
    pop(crown, t, tK + 0.25, { s0: 0.2, rot: -6 });
    hearts.forEach((e, i) => { const u = (t - tK - 0.3 - i * 0.1) % 1.6; at(e, 230 + i * 110, 470 - u * 140, 0, 1.4); e.setAttribute('opacity', t > tK + 0.3 + i * 0.1 ? Math.max(0, 1 - u / 1.6) : 0); });
    pop(dm, t, tSend - 0.05, { s0: 0.7, dy: 120 });
    pop(free.el, t, tFree, { s0: 0.3 });
    L.push(p, t, t0, tAny, 1, 1.04);
  });
  cue('whoosh', t0, 0.05); for (let i = 0; i < 5; i++) cue('pop', t0 + i * 0.13 + 0.2, 0.04); cue('pop', tK + 0.1, 0.1); cue('chime', tK + 0.3, 0.05);
  cue('swish', tSend - 0.05, 0.06); cue('thump', tFree + 0.05, 0.16);
}

// ---------- 19 · "Anyway." — back to the first shot (no headline): the reel loops
hookShot(T0('cta', 'anyway') - 0.05, END + 0.1, { p0: T0('cta', 'anyway') - 0.05, s0: 1, s1: 1.012, pill: true });
cue('whoosh', T0('cta', 'anyway') - 0.06, 0.05);
```

