---
name: glow
description: Make a narrated vertical reel (1080x1920, 35-60 s) in the "Glow" format - pure black with one electric blue that glows - a line-art shop and a crowd of thousands of glowing people the camera pulls back into, a blue card in the layout of a loyalty card whose slots stamp in, a paper card that flips and falls to dust, dark wallet cards, a calm app window with working chat cards, a night skyline whose windows light up blue, a glowing chip on a dark counter, iPhone list / compose / lock screens with a notification dropping in, and an end card with a huge glowing keyword over the crowd; captions at the top typed letter by letter, a white line and a periwinkle line, with struck icons. One calm ElevenLabs voice, a synthesized music bed and sounds, drawn frame by frame in headless Chrome and encoded with ffmpeg. Use it when someone asks for a glow reel, "glow.md", a dark blue glowing explainer, a wallet-card / loyalty-card style reel, or wants to turn a script into this kind of video.
---

# Glow reel: how to make one

You will make a **35-60 second vertical video (1080x1920, 30 fps, H.264 + AAC, at most 60 s and 60 MB)** that looks like **a calm product explainer in the dark: pure black, one electric blue that glows**. It feels like one continuous take: every beat dissolves into the next with a soft blur, and nothing ever stands still (cards breathe in 3D, the phone tilts, the camera pulls back). **Every spoken claim gets its literal picture**, all drawn from scratch: glowing blue line art, a crowd of glowing people, a blue card with ten "stamp" slots, a beige paper card that falls to dust, a night skyline, an iPhone. **Captions sit at the top**: a block is a white first line plus a periwinkle second line, typed letter by letter on the voice. It ends on **"Comment" + a huge glowing keyword** over the crowd.

**`reference.jpg`** (next to this file, if you have it) is a contact sheet of 12 frames from a finished Glow reel: **this is what it should look like.** Left to right, top row first: (1) the hook: a blue line-art shop with a "$30" tag, two glowing people walking out, the caption "Imagine never paying"; (2) the camera pulled far back: the shop lost in a crowd of thousands of glowing people; (3) a beige "Content Course" paper card beside the blue card "Morphy formats 9/10"; (4) the paper card falling to dust, flakes drifting left; (5) the blue card in front of three dark wallet cards "Chat · Claude", "Chat · Codex", "Notes"; (6) a dark app window with six chat cards, one blue; (7) a night skyline: a small shop with a white "Free" pill; (8) the towers lit blue with "$30" pills on the roofs; (9) a glowing ".md" chip on a dark counter with a pulse ring; (10) the blue card stamping in its formats, "7/10"; (11) an iPhone with a "Library" list and three blue "No …" chips; (12) "Comment / GLOW / I'll send it to you. / Free" over the crowd. If you only have this file, section 3 describes everything with numbers.

Everything is drawn **one frame at a time** as HTML + canvas in headless Chrome (`window.setT(t)`), then muxed with a mix made in ffmpeg. This file gives you the look (exact colours, fonts, sizes), the grammar of the script and of the edit, the complete tested engine (Appendix A: write it once) and a complete example video (Appendix B). With the engine written, **a video is two files**: `script.mjs` (what the voice says and the captions show) and `scenes.js` (what each beat shows).

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

---

## 0. Rules of engagement (read first)

1. **Be gentle with the user's computer.** One headless browser at a time, at the lowest priority (`cdp.mjs` does this; on Windows it also pins Chrome to one CPU). Never run two renders at once.
2. **Never print secrets.** The key is `ELEVENLABS_API_KEY` (environment, or `.env` in the project folder). Scripts read it; you never echo it, log it or put it in a file you show.
3. **Spend credits deliberately.** `gen-vo.mjs` is a dry run unless you pass `--yes`. One take per section (~1 credit per character, ~600 for a 40 s reel). Takes are cached: only a section whose words changed is made again.
4. **Stills before film.** Direct the scenes on fake timings (`build-timeline.mjs --fake`) and check stills before you pay for the voice, and never start the full render until the stills contact sheet is right.
5. **Background jobs die when your turn ends.** Render in foreground chunks under your tool timeout (`--full --to 600`, then `--full --from 600`); the render resumes where it stopped.
6. **No real people, no real brands.** Everything is drawn: no screenshots, real app icons, logos, handles or view counts. A number on screen must be one the script says.
7. **Don't publish anything.** You deliver files. Posting is the user's call.
8. If the user's instructions conflict with this file, the user wins. If something is missing (no key, no ffmpeg), say exactly what and offer the closest thing. Never fake success.

## 1. Requirements

- **Node 22+** (global `fetch` and `WebSocket`), **ffmpeg + ffprobe** on PATH, **Chrome or Edge** (or set `CHROME=<path>`). Debug ports 9671 and 9698 on 127.0.0.1. No npm packages.
- **An ElevenLabs API key** for the voice. The free plan is enough to try the format but has no commercial licence; videos that promote anything need a paid plan. No key yet: do everything up to the stills with `--fake` timings and ask for the key.
- The font (Inter Tight) is a free download (`setup.mjs` fetches it). The music bed and the 8 sound effects are synthesized by `sounds.mjs`: 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

```
glow-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 glow.js film.css markup.mjs      clock + captions + scenes + safe zone · every part · the look · script markup + voice
  setup.mjs sounds.mjs cdp.mjs gen-vo.mjs build-timeline.mjs render.mjs check.mjs whisper.py
  fonts/ sfx/ music/             made by setup.mjs
  my-video/                      one folder per video (Appendix B is a complete one)
    script.mjs scenes.js         what the voice says · what each beat shows
    vo/ timeline.js(on) vo.wav cues.json stills/ frames/ mix.wav video.mp4 cover.jpg   (made by the tools)
```

```bash
mkdir glow-reel && cd glow-reel
# write every file of Appendix A, then:
node setup.mjs                   # checks Node/ffmpeg/Chrome/key, downloads the font, makes sfx/ and music/bed.wav
```

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 0.6,2.2,4.5,6.2,9.9,13.8,18.9,24.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 render.mjs --video my-video --scan                                  # every visible text, every 0.25 s, against the safe zone
node render.mjs --video my-video --stills --at beats --safe --sheet sheet
node render.mjs --video my-video --full --to 600 && node render.mjs --video my-video --full --from 600
node render.mjs --video my-video --mix                                   # -14 LUFS; prints how far the bed sits under the voice
node render.mjs --video my-video --encode --out video.mp4 --cover 1.2   # my-video/video.mp4 + my-video/cover.jpg
node check.mjs --video my-video --whisper                                # specs, frame 0, 5 random frames with the safe box, words heard
```

## 3. The look, pinned down

### 3.1 The frame (1080x1920 px)

| Layer | Where | What |
|---|---|---|
| Background | everywhere | `#000`. Under every subject a soft pool of blue light (`G.spot`: radial `rgba(38,64,255,0.2-0.4)`, fading out) |
| Captions | centred on x 510, ≤ 820 px wide, first line from y 296 | Inter Tight 500, 60 px, line-height 1.16, letter-spacing −0.012em. Line 1 `#f4f5f8`, line 2 periwinkle `#8f9cff` |
| The stage | x 40-1040, y 520-1480 (authored), scaled ×0.92 and shifted −30 px by the engine | the beat's picture, centred under the captions |
| Full-bleed beats | the whole frame | the crowd, the skyline: their text must still sit in the safe zone by itself |

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

### 3.2 Colours

| Token | Hex | Used for |
|---|---|---|
| black | `#000` | the background |
| blue | `#2b4bff`; card gradient `#3557ff → #2443f5 → #1a34e2` | the card, pills, chips, the crowd, line art, lit windows |
| glow | `rgba(43,75,255,0.6-0.9)` as box/text shadow | everything blue glows |
| periwinkle | `#8f9cff` | caption line 2, the slash of struck icons, "Free" labels, "Comment" |
| ink | `#f4f5f8` | caption line 1, white UI text |
| dark UI | `#0d0e12` window, `#1b1c22 → #15161b` cards, `#2b2c31` keyboard, `#5b5c62` keys | the app window, the phone |
| wallet cards | `#4a4566 → #2d2a44`, `#1f5c4b → #143c31`, `#3b3d45 → #25262c` | the dark cards behind the blue card |
| paper | `#efe3c8 → #dcc9a4`, ink `#5a4329`, stamp `#9b2a1c` | the card that falls to dust (Georgia) |
| sky | `#03050c → #0b1430`, ground `#121318`, moon `#ffd9a0 → #d98a48`, unlit window `#2a2d38` | the skyline |

### 3.3 Type

**Inter Tight** (variable, SIL OFL) for everything; weights 500 (captions), 600-760 (UI), 820 (the CTA keyword). Georgia (system) only on the paper card; a mono (Cascadia Mono / Consolas / monospace) only in the phone's file editor.

### 3.4 The captions

- A **block** is one or two lines: the white line sets something up, the **periwinkle line** pays it off ("A library of formats, | right next to your chats."). Markup: `/` starts a block, `|` starts its blue line.
- **Typed on the voice:** each word's letters appear across the word's spoken time, each letter fading in from grey over 0.13 s. Letters not typed yet keep their space, so a line begins at the left of where it will sit and ends centred (that is the look, not a bug).
- **Out:** when the next block of the same section starts, the old one greys (opacity → 15 %), blurs (7 px) and lifts 10 px over 0.28 s. The last block of a section stays until 0.9 s after its last word.
- **Struck icons:** `@pay:No paywalls.` puts a 64 px white line icon with a periwinkle slash before the word (the reference's "No app." look). Built in: `course` (a cap), `pay` (a lock), `sub` (repeat arrows), `string` (a link). Add one in scenes.js: `F.ICONS.form = () => G.struck('note')` (any `G.I` icon).
- **Hide words the screen says:** `F.cap.hide = (tk, s) => …` (the CTA keyword is shown huge, so its caption hides).

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

- **The crowd** (`G.crowd`): thousands of glowing people (a blue head-and-shoulders sprite with a light-blue phone glowing at its side, drawn additively) on a canvas. World point (0,0) is the shop door. Zoom `s` 1 shows the door and the first ten people walking out of it; zoom 0.07 shows the whole town. The standing crowd only appears once the camera pulls back (`reveal`), and it dims under the captions (above y 470).
- **The shop** (`G.shop`): a blue line drawing (awning, two windows, a door filled with blue) with a double blue glow, and a hanging price pill that can swing and change ("$30" → "$0").
- **The blue card** (`G.card`, 500 × 625, radius 30): an outlined icon tile + title + subtitle, a small-caps label and a big counter top right ("FORMATS 9/10"), a divider, **ten slots** (white discs with blue line icons; empty ones are dashed rings) that **stamp in** with an overshoot, two label pairs ("INSIDE / SKILL.md + kit", "PRICE / Free"), a white code square with the markdown mark.
- **The paper card** (`G.paper`, 380 × 480, Georgia): title, italic subtitle, grey text lines, a "Buy now" outline button, a red price stamp, a coffee ring. **`G.dust`** breaks a rectangle into ~520 paper flakes that leave right-to-left, drifting left and up and fading.
- **Wallet cards** (`G.darkCard`): dark gradient cards with an icon + label and a right-hand meta; they rise from behind the blue card so only their headers show.
- **The app window** (`G.appWindow` + `G.chatCard`): a dark rounded window, three grey dots and a centred title, a dot-grid canvas; chat cards (icon + label, three text bars, three "working" dots that pulse).
- **The skyline** (`G.skyline`): a night sky, an orange moon, four towers whose window grids light up blue **from the bottom row up**, a small shop whose windows and door light up, **price pills** that pop on the roofs and on the shop.
- **The counter** (`G.floor` + `G.chip`): a dark floor with a lit edge and a blue light pool; a glowing square chip (an icon + a label) with a pulse ring.
- **The iPhone** (`G.phone`, 400 wide, radius 64, Dynamic Island, 9:41 status bar): screens that cross-fade (`G.screen`), an iOS keyboard (`G.keyboard`), a lock screen (`G.lockScreen`: blue-violet wallpaper, date, a 118 px "9:41") and notifications (`G.notif`) that drop in from above.
- **The CTA** (`G.cta`): a black shade behind the text, "Comment" (50 px, periwinkle), the keyword (206 px, weight 820, white with a blue glow), a white line (44 px, typed), a blue "Free" pill.

### 3.6 Motion

| Move | How |
|---|---|
| between beats | the old scene grows 6 % and blurs out (0.24 s) while the new one fades and sharpens in (0.28 s): it reads as one take |
| in a beat | a slow push (+2.8 % over the beat); cards breathe (`rotateY` ±8-9°, `rotate` ±2°, in `perspective(1600px)`); the phone tilts ±5° |
| pops | back-eased, 0.3-0.45 s, from 40-60 % scale with a little blur |
| stamps | each slot 0.32 s back-ease (overshoot 2.2), a pop sound each |
| the pull-back | the crowd's zoom from 1 to 0.07 over ~2.3 s (ease in-out) |
| the end | the keyword lands from 125 % with 18 px blur, back-eased over 0.45 s |

## 4. Writing for this format

The reference grammar (and the example's): **"Imagine …" hook → "Not X. Not Y, | Z." → the turn ("So here's my response." / "Here's how it works.") → three short beats → "No A. No B. No C." with struck icons → the payoff → "Want it? / Comment KEYWORD | and I'll send it to you. / Free."**

- Sections of 1-3 blocks; one block = one idea; a line ≤ ~26 characters (60 px at ≤ 820 wide), so two-line blocks stay two lines.
- The white line is the setup, the blue line the payoff. Short lines alone ("So here's my response.") are white only.
- 100-120 words for 40-45 s. The voice is calm; let the GAPs breathe (0.4-0.5 s between sections).
- `{shown|said}` when the caption and the voice differ: `{$30|thirty-dollar}`, `{glow.md.|glow dot M D.}`.

## 5. The voice (ElevenLabs)

**Brian** (`nPczCjzI2devNBz1zQrb`), `eleven_multilingual_v2`, stability 0.5, similarity 0.8, style 0.3, speaker boost, **speed 1.06**: deep and calm, ~173 wpm with the gaps (the reference: a male narrator, F0 ~129 Hz, ~160 wpm). These are `engine/markup.mjs` DEFAULTS; a `script.mjs` may export `VOICE`, `MODEL`, `SETTINGS` or `SPEED` to override them. One take per section, word times from ElevenLabs' character alignment. Whisper will hear "$30" as "30" and ".md" as "md": judge those by ear.

## 6. Timings

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

## 7. Directing

### 7.1 Times come from words

`T0('s4', 'thirty')` is the start of the first token in section s4 whose text starts with "thirty" (case and punctuation blind), `T1(…)` its end, `T0('s4', 'i', 1)` the second "I". `S.s3.start` / `S.s3.until` are a section's bounds; `END` is the film's length.

### 7.2 scenes.js

```js
const { S, T0, T1, END, scene, cue, div, add, h, set, pop, rise, typeIn, clamp, lerp, eo, eio, ease, back, rng, esc } = F;
const G = F.glow;
F.looks([[0, 'dark']]);
F.cap.hide = (tk, s) => s.id === 'cta' && tk.t0 >= T0('cta', 'comment') - 0.01;

{
  const sc = scene(T0('s5', 'so') - 0.12, T0('s6', "i'm") - 0.12);   // fitted under the captions; { full: true } = full-bleed
  G.floor(sc.el, 1180);
  const chip = G.chip(sc.el, 400, 1062, 230, { label: 'Free .md' });
  const tResp = T0('s5', 'response');
  sc.on(t => { const u = eo((t - sc.tin) / 0.5); set(chip.el, u, `translateY(${(1 - u) * 30}px)`); chip.pulse(t, tResp); });
  cue('chime', tResp, 0.07);
}
```

- A scene is shown from `tin` to `tout` (plus its dissolve). Make each scene end where the next begins, ~0.12 s before the next section's first word.
- Draw everything per frame in `sc.on(t => …)`: `set(el, opacity, transform, blur)` is the one way to style an element for a frame (it replaces the whole transform), `pop(el, t, t0, { d, s0, blur, t1 })`, `rise(el, t, t0, { d, dy, blur, t1 })`, `typeIn(el, text, t, t0, t1, caret)`, `F.html(el, s)` (only touches the DOM when it changed).
- Easing: `eo` (out-cubic), `eio` (in-out-cubic), `ease` (smoothstep), `back(x, overshoot)`; `clamp`, `lerp`, `rng(seed)`.
- 3D: start the transform with `perspective(1600px)`.
- Sounds: `cue(name, time, gain[, length])` with `pop`, `click`, `swish`, `whoosh`, `chime`, `typing`, `implode`, `impact` (gains 0.06-0.16).

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

| Part | Options and use |
|---|---|
| `G.spot(p, x, y, w, h, alpha, 'r,g,b')` | a light pool; `'0,0,0'` = a shade behind text |
| `G.crowd(p, { n, spread, seed })` → `.draw({ s, cx, cy, t, walkT, reveal, burst })` | the crowd; (cx, cy) = where the door is on screen; `walkT` drives the ten walkers (they leave the door at 0.35 + 0.12·i s); `reveal` 0.85 = the standing crowd fades in below that zoom; `burst` spreads it toward the camera |
| `G.shop(p, cx, cy, w)` → `{ el, tag }` | the line-art shop (w × 0.62 tall); set `el.style.transformOrigin` to the door and scale it with the crowd's `s` |
| `G.card(p, cx, cy, { title, sub, rl, rv, bl, bv, br, bvr, filled, glyphs })` → `{ el, fill(n or [t0…t9], t), val(s), price(s) }` | the blue card; `fill([...times], t)` stamps each slot in at its time |
| `G.paper(p, cx, cy, { title, sub, price })` → `{ el }` · `G.dust(p, { x, y, w, h })` → `{ draw(u) }` | the paper card · its dust (u 0 → 1.6) |
| `G.darkCard(p, cx, cy, w, h, { label, meta, icon, bg })` | a wallet card |
| `G.appWindow(p, cx, cy, w, h, { title })` → `{ el, canvas }` · `G.chatCard(canvas, x, y, w, h, { label, kind, lines, accent })` → `{ el, work(t, on) }` | the window and its cards |
| `G.skyline(p, { ground, towers: [{ x, w, top }], shop: { x, w, top } })` → `{ root, draw({ t, lit: [0-1…], shopOn, shopT, badgeT: [t…] }) }` | full-bleed; move the camera with `root.style.transform`; keep roofs below y ~620 (the pills sit 76 px above them) |
| `G.floor(p, y)` · `G.chip(p, cx, cy, size, { icon, label })` → `{ el, pulse(t, t0) }` | the counter |
| `G.phone(p, cx, cy, w)` → `{ el, scr, sw, sh }` · `G.screen(ph, bg)` · `G.keyboard(scr, y, sw)` · `G.lockScreen(ph, { date })` · `G.notif(scr, x, y, w, { title, text, when, icon, bg })` | the iPhone; show one screen at a time with `set(screen, opacity)` |
| `G.cta(p, cx, y, { word, line, pill })` → `{ shade, top, big, sub, pl }` | the end card (y = the top of "Comment") |
| `G.icon(name, size, colour, stroke)` · `G.struck(name)` · `G.I` | 24×24 line icons: kinetic motion voxel light terracotta sandbox paper pixel glow md chat note cap lock repeat link store check send |
| `div(p, cls, x, y, w, h, html, style)` | an absolutely placed element (`'abs'`, `'gl-pill'` = a pill: pass `null` as width, never 0) |

### 7.4 The edit follows the words

Every claim gets its literal picture at that moment: "never paying" → the price tag swings to $0; "by Friday" → the paper falls to dust; "right next to your chats" → the chat cards rise behind the card; "selling it for thirty dollars" → $30 pills pop on every roof on "thirty"; "an entire library" → the slots stamp in one by one, the counter counts; "No paywalls" → a "No paywall" chip pops on "paywalls"; "free" → the $30 is struck and "free" is typed; "called glow.md" → the notification lands on "glow". If the voice says it changes, the picture changes.

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

- Nothing pops in before its word: start a pop 0.03-0.1 s before the word, never later than the word.
- Never cut: scenes dissolve (the engine does it); inside a scene, things move, they don't jump.
- Nothing stands still for more than a second: breathe, tilt, push.
- Keep the stage busy but calm: one subject per beat, centred, with its light pool.
- Every frame has its subject on screen (no empty frames between beats: scenes overlap their dissolves).

## 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 (red = no text, cyan = the text zone, yellow = the bottom band) and prints any visible text outside it (red boxes). `--scan` tests every visible text every 0.25 s without screenshots. Look at the sheet every time: empty builds, overlapping text, a caption covering a roof pill.

## 9. The full render

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

## 10. Sound

`render.mjs --mix` mixes the voice, the bed (`music/bed.wav`, looped, faded at the end, ducked under the voice) and every `cue()`, then brings it to **−14 LUFS integrated, true peak ≤ −1 dBTP** (a measured gain and a 4×-oversampled limiter, repeated until within 0.15 LU). It prints how far the bed sits under the voice: aim for **~13 LU** (`--music 0.075` is the default for the synthesized bed; lower it to push the bed down).

## 11. Encode, check, deliver

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

## 12. Checklist

- [ ] `setup.mjs` all ✓ · the script reads as the grammar of section 4 · lines ≤ ~26 characters
- [ ] fake-timing stills right before any credit is spent · `gen-vo.mjs` dry run read, then `--yes`
- [ ] every claim has its picture on its word · no empty frame · nothing still for > 1 s
- [ ] `--scan` and the safe stills clean · full render · mix at −14 LUFS, bed ~13 LU under the voice
- [ ] encode ≤ 60 s and ≤ 60 MB · `check.mjs` all ok · frames.jpg looked at · every word checked (Whisper or by ear)

---

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

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

### `film.html`

```html
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>glow</title>
<style>
@font-face { font-family: 'Inter Tight'; src: url(fonts/InterTight.ttf) format('truetype'); font-weight: 100 900; }
</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/glow.js', VIDEO_DIR + '/scenes.js'])
    document.write(`<script src="${s}"><\/script>`);
</script>
</body>
</html>
```

### `engine/core.js`

```js
// Glow template · ENGINE CORE. 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 → glow.js → <video>/scenes.js → F.start() on DOMContentLoaded.
(() => {
  const F = window.F = {};
  F.errors = [];
  addEventListener('error', e => F.errors.push(String(e.message || e.error || e)));
  const Q = new URLSearchParams(location.search);
  F.DIR = window.VIDEO_DIR || 'video';
  F.NOCAP = Q.has('nocap'); F.SAFE = Q.has('safe');
  const film = document.getElementById('film'), stage = document.getElementById('stage');
  F.film = film; F.stage = stage;

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

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

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

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

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

  // ---------- looks: dark / light / warm / moody, crossfaded by keys [t, look, fade] ----------
  const MODES = ['dark', 'light', 'warm', 'moody'];
  let BG = [[-9, 'dark', 0.2]];
  F.looks = keys => {
    BG = keys.map(([t, m, d = 0.2]) => { if (!MODES.includes(m)) throw new Error('unknown look ' + m); return [t, m, d]; }).sort((a, b) => a[0] - b[0]);
    if (!BG.length || BG[0][0] > -1) BG.unshift([-9, BG.length ? BG[0][1] : 'dark', 0.2]);
  };
  const lookAt = F.lookAt = t => { let m = BG[0][1]; for (const [k, mm] of BG) if (t >= k) m = mm; return m; };
  const weights = F.lookWeights = t => {
    const w = { dark: 0, light: 0, warm: 0, moody: 0 };
    let i = 0; for (let j = 0; j < BG.length; j++) if (t >= BG[j][0]) i = j;
    const u = i === 0 ? 1 : ease((t - BG[i][0]) / BG[i][2]);
    w[BG[i][1]] += u; if (i > 0) w[BG[i - 1][1]] += 1 - u;
    return w;
  };
  const BGL = {};
  for (const m of MODES) { BGL[m] = add(stage, h('div', { class: 'layer bg-' + m })); BGL[m].style.inset = '-40px'; }
  const leak = add(stage, h('div', { class: 'leak' }));
  const grain = add(stage, h('div', { class: 'grain' }));
  {
    const c = document.createElement('canvas'); c.width = c.height = 256; const g = c.getContext('2d'), d = g.createImageData(256, 256), r = rng(5);
    for (let i = 0; i < d.data.length; i += 4) { const v = 128 + (r() - 0.5) * 220; d.data[i] = d.data[i + 1] = d.data[i + 2] = v; d.data[i + 3] = 255; }
    g.putImageData(d, 0, 0); grain.style.backgroundImage = `url(${c.toDataURL()})`;
  }
  const rain = add(stage, h('canvas', { class: 'layer', width: 1080, height: 1920 }));
  const rctx = rain.getContext('2d');
  const drops = (() => { const r = rng(77); return Array.from({ length: 170 }, () => ({ x: r() * 1300 - 110, y: r() * 2200, l: 50 + r() * 110, v: 1900 + r() * 900, a: 0.08 + r() * 0.2, w: r() < 0.2 ? 2.2 : 1.2 })); })();
  const drawLook = t => {
    const w = weights(t);
    for (const m in BGL) { BGL[m].style.opacity = w[m].toFixed(3); BGL[m].style.display = w[m] < 0.002 ? 'none' : 'block'; }
    const k = Math.floor(t * 24), r = rng(k * 7 + 3);
    grain.style.backgroundPosition = `${Math.floor(r() * 64)}px ${Math.floor(r() * 64)}px`;
    grain.style.opacity = (0.035 + 0.1 * w.warm + 0.16 * w.moody).toFixed(3);
    leak.style.display = w.warm < 0.01 ? 'none' : 'block';
    if (w.warm >= 0.01) {
      let tw = 0; for (const [k2, m] of BG) if (m === 'warm' && k2 <= t) tw = k2;
      leak.style.opacity = (w.warm * (0.75 + 0.25 * Math.sin(t * 1.3))).toFixed(3);
      leak.style.transform = `translate(${(-560 + Math.sin(t * 0.5) * 140 + (t - tw) * 30).toFixed(0)}px, ${(-620 + Math.cos(t * 0.4) * 90).toFixed(0)}px)`;
    }
    rain.style.display = w.moody < 0.01 ? 'none' : 'block';
    if (w.moody >= 0.01) {
      rctx.clearRect(0, 0, 1080, 1920); rctx.lineCap = 'round';
      for (const d of drops) {
        const y = ((d.y + t * d.v) % 2300) - 200, x = d.x + y * 0.16;
        rctx.strokeStyle = `rgba(170,200,222,${(d.a * w.moody).toFixed(3)})`; rctx.lineWidth = d.w;
        rctx.beginPath(); rctx.moveTo(x, y); rctx.lineTo(x + d.l * 0.16, y + d.l); rctx.stroke();
      }
    }
  };

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

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

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

  // ---------- captions (Glow): a block = a white first line + an optional blue second line (markup "|"), typed
  // letter by letter on the voice, each letter fading in from grey; the block greys and blurs out as the next one comes.
  // F.cap.hide = (token, section) => true drops a word (e.g. the CTA keyword the screen shows in giant type).
  F.cap = { hide: null, big: null };
  const capRoot = add(stage, h('div', { class: 'over' }));
  const phrases = [];
  const buildCaptions = () => {
    if (F.NOCAP) return;
    for (const s of TL.sections) { let cur = null; s.tokens.forEach(tk => { if (tk.br || !cur) { cur = { s, toks: [] }; phrases.push(cur); } cur.toks.push(tk); }); }
    phrases.forEach((p, i) => {
      const nx = phrases[i + 1]; p.t0 = p.toks[0].t0;
      p.t1 = nx && nx.s === p.s ? nx.toks[0].t0 - 0.24 : Math.min(p.s.until - 0.05, p.toks.at(-1).t1 + 0.9);   // gone before the next block types
      p.el = add(capRoot, h('div', { class: 'cap' }));
      const L1 = add(p.el, h('div', { class: 'l1' })), L2 = add(p.el, h('div', { class: 'l2' }));
      p.w = p.toks.filter(tk => !(F.cap.hide && F.cap.hide(tk, p.s))).map(tk => {
        const line = tk.l2 ? L2 : L1;
        let ic = null;
        if (tk.icon) ic = add(line, h('span', { class: 'ic' }, (F.ICONS[tk.icon] || F.ICONS.claude)()));
        const sp = add(line, h('span', { class: 'w' }));
        const ch = [...tk.d].map(c => add(sp, h('span', { class: 'c' }, esc(c))));
        line.appendChild(document.createTextNode(' '));
        return { tk, sp, ic, ch };
      });
      if (!L2.childNodes.length) L2.remove();
      p.hidden = !p.w.length;
    });
  };
  const drawCaptions = t => {
    for (const p of phrases) {
      if (p.hidden || t < p.t0 - 0.12 || t >= p.t1 + 0.18) { if (p.el.style.display !== 'none') p.el.style.display = 'none'; continue; }
      p.el.style.display = 'block';
      const out = eo((t - p.t1) / 0.18);
      p.el.style.opacity = (1 - out).toFixed(3);
      p.el.style.filter = out > 0.02 ? `blur(${(out * 7).toFixed(1)}px)` : 'none';
      p.el.style.transform = `translateY(${(-out * 10).toFixed(1)}px)`;
      for (const w of p.w) {
        const a0 = w.tk.t0 - 0.06, span = Math.max(0.12, (w.tk.t1 - w.tk.t0) * 0.85), n = w.ch.length;
        w.ch.forEach((c, i) => {
          const at = a0 + span * i / n, u = clamp((t - at) / 0.13);
          const v = w.tk.t0 < 0.35 ? 1 : u;
          if (c.__v !== v) { c.style.opacity = v.toFixed(3); c.style.color = v < 0.999 ? `rgba(150,152,160,${v.toFixed(3)})` : ''; c.__v = v; }
        });
        if (w.ic) { const b = w.tk.t0 < 0.35 ? 1 : back((t - a0) / 0.3); w.ic.style.opacity = clamp(b * 1.5).toFixed(3); w.ic.style.transform = `scale(${(0.6 + 0.4 * b).toFixed(3)})`; }
      }
    }
  };

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

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

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

### `engine/glow.js`

```js
// Glow template · PARTS. Same for every video. The look: pure black, one electric blue that glows, line art, a crowd of
// glowing people, blue loyalty-style cards, iOS phones. Every part is built once and drawn per frame by the scene's
// sc.on(t => …). Coordinates are the 1080x1920 scene canvas (centre x 540; content in y 520–1480).
(() => {
  const { h, add, div, set, clamp, lerp, eo, eio, ease, back, rng, esc, px } = F;
  const G = F.glow = {};
  const BLUE = '#2b4bff', PERI = '#8f9cff';
  G.BLUE = BLUE; G.PERI = PERI;

  // ---------- icons (24x24 line art) ----------
  const ico = (body, s = 40, c = '#fff', sw = 2) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
  const I = G.I = {
    kinetic: '<path d="M4 18l4.5-12L13 18M5.7 13.5h5.6"/><path d="M19.5 18v-5.2a2.5 2.5 0 0 0-5 0M14.5 15.6c0-1.4 1.1-2.1 2.6-2.1h2.4v2.2a2.4 2.4 0 0 1-2.5 2.3c-1.4 0-2.5-.8-2.5-2.4z"/>',
    motion: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="M10 9.3v5.4l4.6-2.7z"/>',
    voxel: '<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z"/><path d="M4 7.5l8 4.5 8-4.5M12 12v9"/>',
    light: '<circle cx="12" cy="12" r="4"/><path d="M12 2.5v2.5M12 19v2.5M2.5 12H5M19 12h2.5M5.2 5.2l1.8 1.8M17 17l1.8 1.8M5.2 18.8L7 17M17 7l1.8-1.8"/>',
    terracotta: '<path d="M8.5 3.5h7M9.5 3.5c0 2-3.5 3.5-3.5 8 0 5 2.6 8.5 6 8.5s6-3.5 6-8.5c0-4.5-3.5-6-3.5-8"/><path d="M6.4 11h11.2"/>',
    sandbox: '<path d="M6.5 3.5h11M6.5 20.5h11M7.5 3.5c0 4.5 4.5 5.5 4.5 8.5s-4.5 4-4.5 8.5M16.5 3.5c0 4.5-4.5 5.5-4.5 8.5s4.5 4 4.5 8.5"/>',
    paper: '<path d="M6 3h8.5L19 7.5V21H6z"/><path d="M14 3v5h5M9 12.5h6.5M9 16h6.5"/>',
    pixel: '<rect x="4" y="4" width="7" height="7"/><rect x="13" y="4" width="7" height="7"/><rect x="4" y="13" width="7" height="7"/><rect x="13" y="13" width="7" height="7" fill="currentColor"/>',
    glow: '<path d="M12 2.8l2.1 6.1 6.1 2.1-6.1 2.1L12 19.2l-2.1-6.1L3.8 11l6.1-2.1z"/><path d="M19 17.5v3M17.5 19h3"/>',
    md: '<rect x="2.5" y="5.5" width="19" height="13" rx="2.5"/><path d="M6 15V9l2.5 3L11 9v6M16.5 9v6M14.2 12.8l2.3 2.3 2.3-2.3"/>',
    chat: '<path d="M4 5h16v11H9l-5 4z"/>',
    note: '<path d="M6 3h12v18H6z"/><path d="M9 8h6M9 12h6M9 16h4"/>',
    cap: '<path d="M2.5 9.5L12 5l9.5 4.5L12 14z"/><path d="M6.5 11.5v4.5c0 1.5 2.5 3 5.5 3s5.5-1.5 5.5-3v-4.5M21.5 9.5v5"/>',
    lock: '<rect x="5" y="11" width="14" height="10" rx="2.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/>',
    repeat: '<path d="M4 11V9a3 3 0 0 1 3-3h12M16 3l3 3-3 3M20 13v2a3 3 0 0 1-3 3H5M8 21l-3-3 3-3"/>',
    link: '<path d="M10 14a4.5 4.5 0 0 0 6.4 0l3-3a4.5 4.5 0 0 0-6.4-6.4l-1.2 1.2"/><path d="M14 10a4.5 4.5 0 0 0-6.4 0l-3 3a4.5 4.5 0 0 0 6.4 6.4l1.2-1.2"/>',
    store: '<path d="M4 9.5V20h16V9.5M3 9.5L5 4h14l2 5.5c0 1.5-1.2 2.5-2.6 2.5S16 11 16 9.5c0 1.5-1.2 2.5-2.6 2.5h-2.8C9.2 12 8 11 8 9.5c0 1.5-1.2 2.5-2.6 2.5S3 11 3 9.5z"/><path d="M10 20v-4.5h4V20"/>',
    check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
    send: '<path d="M21 3L10 14M21 3l-7 18-4-7-7-4z"/>',
  };
  G.icon = (name, s = 40, c = '#fff', sw = 2) => ico(I[name] || '', s, c, sw);
  // caption icon chips: a white line icon struck through in periwinkle (markup @name:word)
  const struck = name => `<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round"><g stroke="#fff" stroke-width="1.7">${I[name]}</g><path d="M3 21L21 3" stroke="#000" stroke-width="4.2"/><path d="M3 21L21 3" stroke="${PERI}" stroke-width="2"/></svg>`;
  F.ICONS.course = () => struck('cap');
  F.ICONS.pay = () => struck('lock');
  F.ICONS.sub = () => struck('repeat');
  F.ICONS.string = () => struck('link');
  G.struck = struck;

  // ---------- light ----------
  // a soft pool of blue light (the glow under every subject)
  G.spot = (p, x, y, w, hh, a = 0.32, c = '38,64,255') =>
    div(p, 'gl-spot', x - w / 2, y - hh / 2, w, hh, '', { background: `radial-gradient(closest-side, rgba(${c},${a}), rgba(${c},${(a * 0.4).toFixed(3)}) 55%, transparent)` });

  // ---------- the crowd: thousands of glowing people on a canvas, seen by a camera that zooms ----------
  // G.crowd(p, { n, seed, spread }) → { draw({ s, cx, cy, ox, oy, alpha, burst, t }) }  world (0,0) = the shop door
  const sprite = (() => {
    const c = document.createElement('canvas'); c.width = 128; c.height = 128; const g = c.getContext('2d');
    g.shadowColor = 'rgba(60,100,255,0.95)'; g.shadowBlur = 16;
    g.fillStyle = '#2a4bff';
    g.beginPath(); g.arc(54, 40, 15, 0, Math.PI * 2); g.fill();
    g.beginPath(); g.moveTo(26, 98); g.quadraticCurveTo(28, 62, 54, 62); g.quadraticCurveTo(80, 62, 82, 98); g.closePath(); g.fill();
    g.shadowColor = 'rgba(170,200,255,1)'; g.shadowBlur = 14; g.fillStyle = '#cfe0ff';
    g.beginPath(); g.roundRect(76, 66, 18, 24, 4); g.fill();
    return c;
  })();
  G.crowd = (p, o = {}) => {
    const { n = 2400, seed = 9, spread = 5200 } = o;
    const cv = add(p, h('canvas', { width: 1080, height: 1920, class: 'abs', style: 'left:0;top:0;width:1080px;height:1920px' }));
    const g = cv.getContext('2d'), r = rng(seed);
    const P = [];
    for (let i = 0; i < n; i++) {
      // the first few walk out of the door; a third stand around the shop, the rest fill the whole town evenly
      let x, y;
      if (i < n * 0.35) { const a = r() * Math.PI * 2, d = 260 + Math.pow(r(), 0.8) * spread * 0.45; x = Math.cos(a) * d; y = Math.sin(a) * d * 0.85 + 120; }
      else { x = (r() - 0.5) * spread * 2.4; y = (r() - 0.5) * spread * 3.6; if (Math.hypot(x, y) < 300) x += 600; }
      P.push({ x, y, z: 0.6 + r() * 0.9, ph: r() * 6.28, sz: 0.8 + r() * 0.45 });
    }
    // walkers: start at the door (0, 60), end in front of the shop
    const W = [[-250, 330], [230, 360], [-120, 470], [120, 520], [-330, 520], [330, 470], [10, 410], [-30, 600], [260, 640], [-260, 660]];
    W.forEach(([x, y], i) => { P[i] = { x, y, z: 1, ph: i, sz: 1, walk: 0.35 + i * 0.12, fx: 0, fy: 40 }; });
    const draw = ({ s = 1, cx = 540, cy = 900, alpha = 1, burst = 0, t = 0, walkT = 99, reveal = 9 }) => {
      g.clearRect(0, 0, 1080, 1920);
      g.globalCompositeOperation = 'lighter';
      for (const q of P) {
        let x = q.x, y = q.y, a = alpha;
        if (q.walk != null) { const u = eio((walkT - q.walk) / 1.1); if (u <= 0) continue; x = lerp(q.fx, q.x, u); y = lerp(q.fy, q.y, u); a *= clamp(u * 3); }
        if (q.walk == null) a *= clamp((reveal - s) / 0.25);   // the standing crowd shows only once the camera pulls back
        const k = 1 + burst * (q.z * 2.2);
        const sx = cx + x * s * k + Math.sin(t * 0.8 + q.ph) * 6 * s, sy = cy + y * s * k + Math.cos(t * 0.7 + q.ph) * 4 * s;
        const size = clamp(110 * s * q.sz * (1 + burst * q.z), 15, 130);
        if (a < 0.01) continue;
        if (sx < -size || sx > 1080 + size || sy < -size || sy > 1920 + size) continue;
        g.globalAlpha = clamp(a * (0.55 + 0.45 * q.z / 1.5) * (sy < 470 ? 0.35 + 0.65 * clamp((sy - 250) / 220) : 1));   // quieter under the captions
        g.drawImage(sprite, sx - size / 2, sy - size / 2, size, size);
      }
      g.globalAlpha = 1; g.globalCompositeOperation = 'source-over';
    };
    return { cv, draw };
  };

  // ---------- line-art shop (glowing blue outline) ----------
  G.shop = (p, cx, cy, w = 560, o = {}) => {
    const hh = w * 0.62, el = div(p, 'abs', cx - w / 2, cy - hh / 2, w, hh);
    el.style.transformOrigin = '50% 50%';
    el.innerHTML = `<svg width="${w}" height="${hh}" viewBox="0 0 200 124" fill="none" style="overflow:visible;filter:drop-shadow(0 0 6px rgba(50,90,255,0.9)) drop-shadow(0 0 18px rgba(40,70,255,0.55))">
      <g stroke="#3a62ff" stroke-width="1.6" stroke-linejoin="round">
        <path d="M4 22h192l-8 -16H12z"/><path d="M14 22v100h172V22"/>
        <rect x="28" y="40" width="34" height="34"/><rect x="138" y="40" width="34" height="34"/>
        <rect x="80" y="44" width="40" height="78" fill="rgba(30,60,255,0.22)"/>
      </g></svg>`;
    // the price tag that hangs from the awning (text in DOM so the safe scan sees it)
    const tag = add(el, h('div', { class: 'gl-pill', style: `left:${w * 0.7}px;top:${hh * 0.03}px;height:${w * 0.09}px;padding:0 ${w * 0.03}px;font-size:${w * 0.05}px;background:rgba(10,14,40,0.9);border:2px solid #3a62ff;color:#cfd8ff;box-shadow:0 0 18px rgba(50,90,255,0.7);transform-origin:50% 0` }, '$30'));
    return { el, tag, w, hh };
  };

  // ---------- the blue format card (a loyalty card's layout) ----------
  // 500x625 design, centred on (cx, cy); draw it with set(c.el, op, transform) — transforms are around its centre.
  G.card = (p, cx, cy, o = {}) => {
    const { title = 'Morphy formats', sub = 'Free library', rl = 'FORMATS', rv = '9/10', bl = 'INSIDE', bv = 'SKILL.md + kit', br = 'PRICE', bvr = 'Free', filled = 9, glyphs = ['kinetic', 'motion', 'voxel', 'light', 'terracotta', 'sandbox', 'paper', 'pixel', 'glow', 'md'] } = o;
    const el = div(p, 'gl-card', cx - 250, cy - 312, 500, 625);
    el.style.transformOrigin = '50% 50%';
    el.innerHTML = `
      <div class="abs" style="left:34px;top:34px;width:56px;height:56px;border-radius:15px;border:2px solid rgba(255,255,255,0.55);display:flex;align-items:center;justify-content:center">${G.icon('store', 30, '#fff', 1.9)}</div>
      <div class="abs" style="left:106px;top:34px;font-size:30px;font-weight:720;letter-spacing:-0.01em">${esc(title)}</div>
      <div class="abs" style="left:106px;top:72px;font-size:19px;opacity:0.72">${esc(sub)}</div>
      <div class="abs" style="right:34px;top:30px;font-size:14px;font-weight:700;letter-spacing:0.16em;opacity:0.75;text-align:right">${esc(rl)}</div>
      <div class="abs rv" style="right:34px;top:50px;font-size:46px;font-weight:760;letter-spacing:-0.02em;text-align:right">${esc(rv)}</div>
      <div class="abs" style="left:34px;right:34px;top:126px;height:1px;background:rgba(255,255,255,0.22)"></div>
      <div class="abs" style="left:34px;top:332px;font-size:14px;font-weight:700;letter-spacing:0.16em;opacity:0.7">${esc(bl)}</div>
      <div class="abs" style="left:34px;top:352px;font-size:22px;font-weight:650">${esc(bv)}</div>
      <div class="abs" style="right:34px;top:332px;font-size:14px;font-weight:700;letter-spacing:0.16em;opacity:0.7;text-align:right">${esc(br)}</div>
      <div class="abs bvr" style="right:34px;top:352px;font-size:22px;font-weight:650;text-align:right">${esc(bvr)}</div>
      <div class="abs" style="left:170px;top:410px;width:160px;height:160px;border-radius:18px;background:#fff;display:flex;align-items:center;justify-content:center;box-shadow:0 6px 18px rgba(0,0,30,0.3)">${G.icon('md', 112, '#1d36e8', 1.6)}</div>`;
    const rvEl = el.querySelector('.rv'), bvrEl = el.querySelector('.bvr');
    const slots = [];
    for (let i = 0; i < 10; i++) {
      const x = 34 + (i % 5) * 88 + 2, y = 152 + Math.floor(i / 5) * 84;
      const e = div(el, 'abs', x, y, 68, 68, '', { borderRadius: '50%' });
      const f = div(e, 'abs', 0, 0, 68, 68, G.icon(glyphs[i] || 'md', 38, '#1d36e8', 2), { borderRadius: '50%', background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 4px 12px rgba(0,0,40,0.3)' });
      slots.push({ e, f });
    }
    // fill(n or [t per slot], t): slot i filled from time ti with a stamp pop
    const fill = (times, t) => slots.forEach((s, i) => {
      const ti = Array.isArray(times) ? times[i] : (i < times ? -99 : 99);
      const u = ti == null ? 0 : back((t - ti) / 0.32, 2.2), on = ti != null && t >= ti;
      s.e.style.border = on && u > 0.6 ? 'none' : '2px dashed rgba(255,255,255,0.45)';
      set(s.f, on ? clamp(u * 2) : 0, `scale(${on ? (0.4 + 0.6 * u).toFixed(3) : 0.4})`);
    });
    fill(filled, 0);
    const val = (s) => F.html(rvEl, esc(s)), price = s => F.html(bvrEl, s);
    return { el, slots, fill, val, price };
  };

  // ---------- the paper card (what people sell) and its dust ----------
  G.paper = (p, cx, cy, o = {}) => {
    const { title = 'Content Skill Pack', sub = 'SKILL.md · 1 file', price = '$30' } = o;
    const el = div(p, 'gl-paper', cx - 190, cy - 240, 380, 480);
    el.style.transformOrigin = '50% 50%';
    el.innerHTML = `
      <div class="abs" style="left:28px;top:28px;font-family:Georgia,serif;font-size:27px;font-weight:700;color:#5a4329">${esc(title)}</div>
      <div class="abs" style="left:28px;top:66px;font-family:Georgia,serif;font-style:italic;font-size:18px;color:#8a7152">${esc(sub)}</div>
      <div class="abs" style="left:28px;right:28px;top:104px;height:1px;background:rgba(90,67,41,0.3)"></div>
      ${[0, 1, 2, 3, 4].map(i => `<div class="abs" style="left:28px;top:${130 + i * 40}px;width:${[300, 260, 290, 210, 250][i]}px;height:12px;border-radius:6px;background:rgba(90,67,41,${i ? 0.16 : 0.24})"></div>`).join('')}
      <div class="abs" style="left:28px;top:356px;width:150px;height:44px;border-radius:22px;border:2px solid rgba(150,40,30,0.55);color:#8f2a1e;font-size:20px;font-weight:700;display:flex;align-items:center;justify-content:center;font-family:Georgia,serif">Buy now</div>
      <div class="abs" style="right:24px;top:300px;width:132px;height:132px;border-radius:50%;border:4px solid rgba(160,40,30,0.75);color:#9b2a1c;display:flex;align-items:center;justify-content:center;font-family:Georgia,serif;font-size:46px;font-weight:700;transform:rotate(-14deg);box-shadow:inset 0 0 0 6px rgba(239,227,200,1),inset 0 0 0 8px rgba(160,40,30,0.5)">${esc(price)}</div>
      <div class="abs" style="left:150px;top:380px;width:150px;height:150px;border-radius:50%;border:7px solid rgba(150,110,50,0.18)"></div>`;
    return { el };
  };
  // dust: a rectangle (scene coords) breaking into paper flakes that drift left and fade. draw(u 0→1)
  G.dust = (p, rect, o = {}) => {
    const { n = 520, seed = 4, colors = ['#e8d9b8', '#d9c49c', '#cdb488', '#a8473a', '#efe2c4'], dx = -520, dy = -120 } = o;
    const cv = add(p, h('canvas', { width: 1080, height: 1920, class: 'abs', style: 'left:0;top:0;width:1080px;height:1920px' }));
    const g = cv.getContext('2d'), r = rng(seed);
    const Q = Array.from({ length: n }, () => ({ x: rect.x + r() * rect.w, y: rect.y + r() * rect.h, s: 5 + r() * 13, d: r(), c: colors[Math.floor(r() * colors.length)], rx: 0.6 + r() * 0.8, ry: r() * 2 - 1, rot: r() * 6 }));
    const draw = u => {
      g.clearRect(0, 0, 1080, 1920);
      if (u <= 0 || u >= 1.6) return;
      for (const q of Q) {
        // flakes leave right-to-left: the right side of the card breaks first
        const start = (1 - (q.x - rect.x) / rect.w) * 0.45, v = clamp((u - start) / 0.9);
        if (v <= 0) continue;
        const e = eo(v), x = q.x + dx * q.rx * e + Math.sin(q.d * 9 + v * 4) * 30 * v, y = q.y + dy * q.ry * e - 60 * v * q.d;
        g.globalAlpha = clamp(1.2 - v * 1.25) * clamp(v * 6);
        g.fillStyle = q.c;
        g.save(); g.translate(x, y); g.rotate(q.rot + v * 3); g.fillRect(-q.s / 2, -q.s / 2, q.s, q.s * 0.8); g.restore();
      }
      g.globalAlpha = 1;
    };
    return { cv, draw };
  };

  // ---------- dark cards (the "wallet" stack behind the blue card) ----------
  G.darkCard = (p, cx, cy, w, hh, o = {}) => {
    const { bg = 'linear-gradient(160deg,#4a4566,#2f2c45)', label = 'Chat', meta = '', icon = 'chat' } = o;
    const el = div(p, 'gl-dark', cx - w / 2, cy - hh / 2, w, hh, `
      <div class="abs" style="left:24px;top:16px;display:flex;align-items:center;gap:10px;font-size:21px;font-weight:650">${G.icon(icon, 22, '#e9eaee', 2)}${esc(label)}</div>
      <div class="abs" style="right:24px;top:18px;font-size:18px;opacity:0.7;font-weight:600">${esc(meta)}</div>`, { background: bg });
    el.style.transformOrigin = '50% 50%';
    return el;
  };

  // ---------- an app window: a calm dark canvas with chat cards (no real screenshots) ----------
  G.appWindow = (p, cx, cy, w = 880, hh = 620, o = {}) => {
    const el = div(p, 'gl-dark', cx - w / 2, cy - hh / 2, w, hh, '', { background: '#0d0e12', borderRadius: '30px', boxShadow: '0 50px 110px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.09) inset' });
    el.style.transformOrigin = '50% 50%';
    div(el, 'abs', 0, 0, w, 58, `<div class="abs" style="left:26px;top:22px;display:flex;gap:10px">${['#3a3c44', '#3a3c44', '#3a3c44'].map(c => `<span style="width:14px;height:14px;border-radius:50%;background:${c};display:block"></span>`).join('')}</div>
      <div class="abs" style="left:0;right:0;top:15px;text-align:center;font-size:22px;font-weight:650;color:#c9cbd2">${esc(o.title || 'Morphy')}</div>`, { borderBottom: '1px solid rgba(255,255,255,0.07)' });
    const canvas = div(el, 'abs', 0, 59, w, hh - 59, '', { backgroundImage: 'radial-gradient(rgba(255,255,255,0.09) 1.4px, transparent 1.6px)', backgroundSize: '30px 30px' });
    return { el, canvas };
  };
  // a chat card on that canvas: header dot + label, a few text lines, a working indicator
  G.chatCard = (p, x, y, w = 240, hh = 190, o = {}) => {
    const { label = 'Chat', lines = [0.85, 0.6, 0.72], accent = BLUE, kind = 'chat' } = o;
    const el = div(p, 'abs', x, y, w, hh, `
      <div class="abs" style="left:18px;top:16px;display:flex;align-items:center;gap:9px;font-size:18px;font-weight:650;color:#e6e7ec">${G.icon(kind, 20, '#e6e7ec', 2)}${esc(label)}</div>
      ${lines.map((l, i) => `<div class="abs" style="left:18px;top:${58 + i * 26}px;width:${((w - 36) * l).toFixed(0)}px;height:10px;border-radius:5px;background:rgba(255,255,255,${i ? 0.1 : 0.16})"></div>`).join('')}
      <div class="abs dots" style="left:18px;bottom:16px;display:flex;gap:6px">${[0, 1, 2].map(() => `<span style="width:8px;height:8px;border-radius:50%;background:${accent};display:block"></span>`).join('')}</div>`,
      { background: 'linear-gradient(180deg,#1b1c22,#15161b)', borderRadius: '20px', border: '1px solid rgba(255,255,255,0.08)', boxShadow: '0 18px 40px rgba(0,0,0,0.55)' });
    el.style.transformOrigin = '50% 50%';
    const dots = [...el.querySelectorAll('.dots span')];
    const work = (t, on = 1) => dots.forEach((d, i) => { d.style.opacity = (on ? 0.3 + 0.7 * Math.max(0, Math.sin(t * 6 - i * 0.9)) : 0.25).toFixed(3); });
    return { el, work };
  };

  // ---------- night skyline: towers whose windows light up blue, a small shop, a moon ----------
  // full-bleed scene. towers: [{ x, w, top }]; draw({ lit: [0..1 per tower], shop: 0..1, cam: { s, x, y } })
  G.skyline = (p, o = {}) => {
    const ground = o.ground || 1250;
    const T = o.towers || [{ x: 90, w: 150, top: 560 }, { x: 262, w: 160, top: 730 }, { x: 444, w: 150, top: 470 }, { x: 616, w: 140, top: 680 }];
    const shop = o.shop || { x: 790, w: 210, top: 1060 };
    const root = div(p, 'abs', 0, 0, 1080, 1920);
    root.style.transformOrigin = `540px ${ground}px`;
    div(root, 'abs', -400, -400, 1880, 2720, '', { background: 'linear-gradient(180deg,#03050c 0%,#070c1e 45%,#0b1430 70%,#0a0f22 100%)' });
    const moon = div(root, 'abs', 850, 640, 96, 96, '', { borderRadius: '50%', background: 'radial-gradient(circle at 40% 38%, #ffd9a0, #f0aa62 60%, #d98a48)', boxShadow: '0 0 40px rgba(255,170,90,0.45)' });
    div(root, 'abs', -400, ground, 1880, 900, '', { background: 'linear-gradient(180deg,#121318 0%,#0a0a0d 40%,#050506 100%)', borderTop: '2px solid #22242c' });
    const cv = add(root, h('canvas', { width: 1080, height: 1920, class: 'abs', style: 'left:0;top:0;width:1080px;height:1920px' }));
    const g = cv.getContext('2d');
    const badges = T.map(tw => { const b = div(root, 'gl-pill', tw.x + tw.w / 2 - 50, tw.top - 76, 100, 52, '$30', { fontSize: '24px', background: BLUE, color: '#fff', boxShadow: '0 0 26px rgba(43,75,255,0.9)' }); b.style.transformOrigin = '50% 100%'; return b; });
    const shopBadge = div(root, 'gl-pill', shop.x + shop.w / 2 - 55, shop.top - 84, 110, 52, 'Free', { fontSize: '24px', background: '#fff', color: BLUE, boxShadow: '0 0 30px rgba(140,170,255,0.9)' });
    shopBadge.style.transformOrigin = '50% 100%';
    const draw = ({ lit = [], shopOn = 0, t = 0, badgeT = [], shopT = null }) => {
      g.clearRect(0, 0, 1080, 1920);
      T.forEach((tw, i) => {
        const L = clamp(lit[i] || 0);
        g.fillStyle = '#12141b'; g.fillRect(tw.x, tw.top, tw.w, ground - tw.top);
        const cols = Math.floor((tw.w - 24) / 24), rows = Math.floor((ground - tw.top - 30) / 30);
        for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
          const wy = tw.top + 20 + r * 30, wx = tw.x + 14 + c * 24 + (tw.w - 24 - cols * 24) / 2;
          const rowU = 1 - r / rows; // bottom rows light first
          const on = clamp((L * 1.25 - (1 - rowU)) * 4);
          g.fillStyle = on > 0.01 ? `rgba(${Math.round(lerp(42, 60, on))},${Math.round(lerp(45, 95, on))},${Math.round(lerp(58, 255, on))},1)` : '#2a2d38';
          if (on > 0.5) { g.shadowColor = 'rgba(50,90,255,0.9)'; g.shadowBlur = 8; } else g.shadowBlur = 0;
          g.fillRect(wx, wy, 13, 9);
        }
        g.shadowBlur = 0;
        if (L > 0.3) { g.fillStyle = `rgba(43,75,255,${(0.08 * L).toFixed(3)})`; g.fillRect(tw.x - 6, tw.top - 6, tw.w + 12, ground - tw.top + 6); }
        const bt = badgeT[i]; const b = badges[i];
        const u = bt == null ? 0 : back((t - bt) / 0.35, 2);
        set(b, bt == null || t < bt ? 0 : clamp(u * 2), `scale(${(0.5 + 0.5 * u).toFixed(3)})`);
      });
      // the small shop: awning, windows and door light up blue
      const s = shop, sh = ground - s.top;
      g.fillStyle = '#1b1d24'; g.fillRect(s.x, s.top, s.w, sh);
      g.fillStyle = '#353844'; g.fillRect(s.x - 10, s.top, s.w + 20, 30);
      for (let k = 0; k < 5; k++) { g.fillStyle = k % 2 ? '#2a2c36' : '#4a4d5a'; g.fillRect(s.x - 10 + k * (s.w + 20) / 5, s.top + 30, (s.w + 20) / 5, 16); }
      const on = clamp(shopOn);
      const wc = `rgb(${Math.round(lerp(50, 45, on))},${Math.round(lerp(52, 85, on))},${Math.round(lerp(62, 255, on))})`;
      if (on > 0.3) { g.shadowColor = 'rgba(50,90,255,0.95)'; g.shadowBlur = 24 * on; }
      g.fillStyle = wc; g.fillRect(s.x + 18, s.top + 66, 44, 60); g.fillRect(s.x + s.w - 62, s.top + 66, 44, 60); g.fillRect(s.x + s.w / 2 - 26, s.top + 64, 52, sh - 64);
      g.shadowBlur = 0;
      const su = shopT == null ? 0 : back((t - shopT) / 0.35, 2);
      set(shopBadge, shopT == null || t < shopT ? 0 : clamp(su * 2), `scale(${(0.5 + 0.5 * su).toFixed(3)})`);
      moon.style.transform = `translateY(${(Math.sin(t * 0.3) * 4).toFixed(1)}px)`;
    };
    return { root, draw, T, shop, ground };
  };

  // ---------- the counter: a dark floor with a blue light pool ----------
  G.floor = (p, y = 1180) => {
    div(p, 'abs', -300, y, 1680, 900, '', { background: 'linear-gradient(180deg, rgba(22,26,52,0.95) 0%, rgba(8,9,16,0.9) 30%, rgba(0,0,0,0) 85%)', borderTop: '1px solid rgba(90,110,255,0.35)' });
    G.spot(p, 540, y + 40, 1100, 300, 0.42);
  };
  // a glowing chip tile (".md" tap point) and its pulse ring
  G.chip = (p, cx, cy, s = 220, o = {}) => {
    const el = div(p, 'abs', cx - s / 2, cy - s / 2, s, s, `
      <div style="position:absolute;inset:0;border-radius:${s * 0.2}px;background:linear-gradient(180deg,#14151b,#0b0c10);border:2px solid #3456ff;box-shadow:0 0 30px rgba(43,75,255,0.6),inset 0 0 24px rgba(43,75,255,0.25)"></div>
      <div style="position:absolute;left:0;right:0;top:${s * 0.16}px;display:flex;justify-content:center">${G.icon(o.icon || 'md', s * 0.42, '#5d7dff', 1.7)}</div>
      <div style="position:absolute;left:0;right:0;top:${s * 0.66}px;text-align:center;font-size:${s * 0.11}px;font-weight:650;color:#fff">${esc(o.label || 'Free .md')}</div>`);
    const ring = div(p, 'abs', cx - s * 0.75, cy - s * 0.75, s * 1.5, s * 1.5, '', { borderRadius: '50%', border: '3px solid rgba(120,150,255,0.8)', boxShadow: '0 0 20px rgba(80,110,255,0.6)' });
    const pulse = (t, t0) => { const u = (t - t0) / 1.1; set(ring, u < 0 || u > 1 ? 0 : (1 - u) * 0.9, `scale(${(0.7 + 0.6 * eo(u)).toFixed(3)})`); };
    return { el, ring, pulse };
  };

  // ---------- iPhone ----------
  G.phone = (p, cx, cy, w = 420) => {
    const hh = Math.round(w * 2.06);
    const el = div(p, 'gl-phone', cx - w / 2, cy - hh / 2, w, hh);
    el.style.transformOrigin = '50% 50%';
    const scr = div(el, 'scr');
    add(el, h('div', { class: 'island' }));
    const sw = w - 28, sh = hh - 28;
    return { el, scr, w, hh, sw, sh };
  };
  G.statusBar = (scr, dark = true) => div(scr, 'gl-sb', null, null, null, null, `<span>9:41</span><span style="display:flex;gap:6px;align-items:center"><span style="letter-spacing:2px">●●●</span><span style="width:30px;height:15px;border-radius:4px;border:2px solid #fff;display:inline-block;position:relative"><span style="position:absolute;inset:2px;background:#fff;border-radius:1px;display:block"></span></span></span>`);
  // a screen layer that cross-fades in at t0 (and out at t1)
  G.screen = (ph, bg = '#000') => { const e = div(ph.scr, 'abs', 0, 0, ph.sw, ph.sh, '', { background: bg }); G.statusBar(e); return e; };
  // iOS keyboard (three letter rows) from y in the screen
  G.keyboard = (scr, y, sw) => {
    const kb = div(scr, 'abs', 0, y, sw, 300, '', { background: '#2b2c31' });
    const rows = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'];
    const kw = (sw - 16) / 10 - 6;
    rows.forEach((r, ri) => { const off = (sw - r.length * (kw + 6)) / 2; [...r].forEach((c, ci) => div(kb, 'gl-key', off + ci * (kw + 6), 14 + ri * 62, kw, 50, c)); });
    div(kb, 'gl-key', (sw - 260) / 2, 14 + 3 * 62, 260, 50, 'space', { fontSize: '18px' });
    return kb;
  };
  // the lock screen wallpaper + time + date
  G.lockScreen = (ph, o = {}) => {
    const e = G.screen(ph, 'radial-gradient(380px 320px at 30% 22%, rgba(70,90,230,0.85), transparent 70%), radial-gradient(300px 300px at 85% 70%, rgba(140,70,200,0.7), transparent 70%), linear-gradient(180deg,#141b4a,#1b1440 60%,#0e0c22)');
    div(e, 'abs', 0, 96, ph.sw, 34, esc(o.date || 'Saturday 17 October'), { textAlign: 'center', fontSize: '24px', fontWeight: 600, color: 'rgba(255,255,255,0.85)' });
    div(e, 'abs', 0, 120, ph.sw, 130, '9:41', { textAlign: 'center', fontSize: '118px', fontWeight: 700, color: '#fff', letterSpacing: '-0.02em' });
    return e;
  };
  // a notification on the lock screen
  G.notif = (scr, x, y, w, o = {}) => div(scr, 'abs', x, y, w, 92, `
    <div class="abs" style="left:14px;top:16px;width:58px;height:58px;border-radius:14px;background:${o.bg || BLUE};display:flex;align-items:center;justify-content:center">${G.icon(o.icon || 'glow', 32, '#fff', 2)}</div>
    <div class="abs" style="left:86px;top:16px;font-size:20px;font-weight:700;color:#fff">${esc(o.title || '')}</div>
    <div class="abs" style="right:16px;top:17px;font-size:16px;color:rgba(255,255,255,0.6)">${esc(o.when || 'now')}</div>
    <div class="abs" style="left:86px;top:46px;right:14px;font-size:18px;color:rgba(255,255,255,0.85);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(o.text || '')}</div>`,
    { borderRadius: '24px', background: 'rgba(60,60,80,0.55)', backdropFilter: 'blur(10px)', border: '1px solid rgba(255,255,255,0.1)' });

  // ---------- CTA: "Comment / KEYWORD / line / Free" over the crowd ----------
  G.cta = (p, cx, y, o = {}) => {
    const { word = 'GLOW', line = "I'll send it to you.", pill = 'Free' } = o;
    const shade = G.spot(p, cx, y + 150, 980, 620, 0.85, '0,0,0');
    const top = div(p, 'abs', cx - 300, y, 600, 60, 'Comment', { textAlign: 'center', fontSize: '50px', fontWeight: 500, color: PERI });
    const big = div(p, 'abs', cx - 420, y + 60, 840, 230, esc(word), { textAlign: 'center', fontSize: '206px', fontWeight: 820, letterSpacing: '-0.035em', color: '#fff', lineHeight: '230px', textShadow: '0 0 28px rgba(60,100,255,0.95), 0 0 70px rgba(43,75,255,0.8)' });
    const sub = div(p, 'abs', cx - 400, y + 296, 800, 60, esc(line), { textAlign: 'center', fontSize: '44px', fontWeight: 500, color: '#fff' });
    const pl = div(p, 'gl-pill', cx - 70, y + 380, 140, 62, esc(pill), { fontSize: '30px', background: BLUE, color: '#fff', boxShadow: '0 0 30px rgba(43,75,255,0.9)' });
    [top, big, sub, pl].forEach(e => e.style.transformOrigin = '50% 50%');
    return { shade, top, big, sub, pl };
  };
})();
```

### `engine/film.css`

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

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

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

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

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

/* ===================== GLOW look (overrides the Motion base above) =====================
   Pure black, one electric blue (#2b4bff) that glows, periwinkle second caption line (#8f9cff), Inter Tight 500 captions
   typed letter by letter, iOS-style phones, blue loyalty-style cards, line-art that glows. */
:root { --g-blue: #2b4bff; --g-blue2: #1733e0; --g-peri: #8f9cff; --g-ink: #f4f5f8; --g-dim: #8a8d96; }
html, body { background: #000; }
.bg-dark { background: #000; }
.cap { top: 296px; font-size: 60px; font-weight: 500; line-height: 1.16; letter-spacing: -0.012em; color: var(--g-ink); text-wrap: balance; }
.cap .l1 { color: var(--g-ink); }
.cap .l2 { color: var(--g-peri); }
.cap .w { display: inline-block; white-space: pre; }
.cap .c { display: inline; }
.cap .ic { display: inline-flex; width: 64px; height: 64px; margin-right: 14px; vertical-align: -10px; background: none !important; border-radius: 0; }
.gl-spot { position: absolute; border-radius: 50%; pointer-events: none; }
.gl-card { position: absolute; border-radius: 30px; color: #fff; overflow: hidden;
  background: linear-gradient(160deg, #3557ff 0%, #2443f5 45%, #1a34e2 100%);
  box-shadow: 0 40px 90px rgba(0,0,0,0.65), 0 0 0 1px rgba(255,255,255,0.08) inset, 0 1px 0 rgba(255,255,255,0.25) inset; }
.gl-dark { position: absolute; border-radius: 26px; color: #e9eaee; overflow: hidden; box-shadow: 0 30px 70px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.06) inset; }
.gl-paper { position: absolute; border-radius: 14px; color: #5a4632; overflow: hidden;
  background: radial-gradient(140px 120px at 75% 80%, rgba(160,120,60,0.18), transparent 70%), linear-gradient(170deg, #efe3c8 0%, #e6d6b4 60%, #dcc9a4 100%);
  box-shadow: 0 30px 60px rgba(0,0,0,0.55), inset 0 0 40px rgba(120,90,50,0.22); }
.gl-phone { position: absolute; border-radius: 64px; background: #0b0b0d; box-shadow: 0 0 0 3px #2a2b30, 0 0 0 5px #111, 0 50px 110px rgba(0,0,0,0.7); overflow: hidden; }
.gl-phone .scr { position: absolute; inset: 14px; border-radius: 52px; overflow: hidden; background: #000; }
.gl-phone .island { position: absolute; left: 50%; top: 22px; width: 120px; height: 34px; margin-left: -60px; border-radius: 20px; background: #000; z-index: 5; }
.gl-sb { position: absolute; left: 0; right: 0; top: 18px; height: 40px; padding: 0 44px; display: flex; justify-content: space-between; align-items: center; font-size: 22px; font-weight: 650; color: #fff; z-index: 4; }
.gl-pill { position: absolute; display: inline-flex; align-items: center; justify-content: center; gap: 10px; border-radius: 999px; font-weight: 650; white-space: nowrap; }
.gl-key { position: absolute; border-radius: 8px; background: #5b5c62; color: #fff; font-size: 20px; display: flex; align-items: center; justify-content: center; }
```

### `engine/markup.mjs`

```js
// Glow · SCRIPT MARKUP (Node side: gen-vo, build-timeline, check). Same for every video.
//   /  a new caption block   |  the rest of the block is the blue second line   @icon:word  a struck icon chip before the word
//   {shown|said}  the caption shows one thing, the voice says another   (*word* still parses but Glow draws no boxes)
// The voice + its settings live here as the template's defaults; a video's script.mjs may override VOICE / MODEL / SETTINGS / SPEED.
import { pathToFileURL } from 'node:url';
import path from 'node:path';
import fs from 'node:fs';

export const DEFAULTS = {
  VOICE: 'nPczCjzI2devNBz1zQrb',   // ElevenLabs "Brian": deep and calm (the reference: F0 ~129 Hz, ~160 wpm)
  MODEL: 'eleven_multilingual_v2',
  SETTINGS: { stability: 0.5, similarity_boost: 0.8, style: 0.3, use_speaker_boost: true },
  SPEED: 1.06,
  GAP: 0.45, LEAD: 0.15, HOLD: 2.4,
};

// 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;
  const parts = src.match(/[^\s{]*\{[^}]*\}\S*|\S+/g) || [];
  for (let raw of parts) {
    if (raw === '/') { br = true; l2 = false; continue; }
    if (raw === '|') { l2 = true; 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 }); 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
// Glow · requirements check + the free font + the synthesized sounds. Run once per project:  node setup.mjs
// Node 22+, ffmpeg/ffprobe on PATH, Chrome or Edge, the ElevenLabs key (never printed); downloads Inter Tight (SIL OFL,
// google/fonts) to fonts/, makes sfx/*.wav and music/bed.wav (sounds.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 font = path.join(HERE, 'fonts', 'InterTight.ttf');
if (fs.existsSync(font)) ok(true, 'fonts/InterTight.ttf');
else {
  fs.mkdirSync(path.dirname(font), { recursive: true });
  const url = 'https://raw.githubusercontent.com/google/fonts/main/ofl/intertight/InterTight%5Bwght%5D.ttf';
  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/InterTight.ttf downloaded'); }
  catch (e) { ok(false, `fonts/InterTight.ttf: 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
// Glow · the sounds, synthesized (no downloads, no licences, no credits):  node sounds.mjs [--force]
// sfx/pop click swish whoosh chime typing implode impact .wav and music/bed.wav (a calm 92 bpm bed, 64 s: soft pad,
// a muted pluck arpeggio, a sub bass, quiet off-beat hats). Seeded, so every project gets the same sounds.
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 = {
  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; },
  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.9, u => 250 + 2200 * Math.sin(Math.PI * Math.min(1, u * 1.2)), u => Math.sin(Math.PI * u) ** 1.6),
  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; },
  typing: () => { const a = buf(2.6); let t = 0.02; while (t < 2.5) { const s = Math.round(t * SR), g = 0.5 + rnd() * 0.5, f = 2500 + rnd() * 3000; let lp = 0, pr = 0; for (let i = 0; i < 0.025 * SR && s + i < a.length; i++) { const k = 1 - Math.exp(-2 * Math.PI * f / SR); lp += k * (rnd() * 2 - 1 - lp); a[s + i] += (lp - pr) * g * Math.exp(-i / SR * 260); pr = lp; } t += 0.07 + rnd() * 0.08; } 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; },
  impact: () => { const a = buf(1.2); let ph = 0; for (let i = 0; i < a.length; i++) { const t = i / SR, f = 48 + 60 * Math.exp(-t * 18); ph += 2 * Math.PI * f / SR; a[i] = Math.sin(ph) * Math.exp(-t * 4.5) * Math.min(1, t * 800); } const n = filtered(0.15, () => 3000, u => Math.exp(-u * 6)); for (let i = 0; i < n.length; i++) a[i] += 0.35 * n[i]; 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');
}

// the bed: Am9 – Fmaj7 – Cmaj7 – G6, two bars each, 92 bpm
const bedFile = path.join(HERE, 'music', 'bed.wav');
if (fs.existsSync(bedFile) && !FORCE) console.log('kept music/bed.wav');
else {
  const BPM = 92, beat = 60 / BPM, bar = 4 * beat, dur = 64, L = buf(dur), R = buf(dur);
  const midi = m => 440 * 2 ** ((m - 69) / 12);
  const CH = [[57, 60, 64, 67, 71], [53, 57, 60, 64, 69], [48, 55, 59, 64, 67], [55, 59, 62, 64, 71]];
  const add = (arr, s, v) => { if (s >= 0 && s < arr.length) arr[s] += v; };
  // pad: detuned soft saws (5 harmonics), slow swell per chord, one-pole smoothed
  for (let c = 0; c * 2 * bar < dur; c++) {
    const notes = CH[c % 4], t0 = c * 2 * bar, len = 2 * bar + 0.6;
    for (const [side, det] of [[L, -0.0035], [R, 0.0035]]) for (const m of notes) {
      const f = midi(m) * (1 + det); let lp = 0;
      for (let i = 0; i < len * SR; i++) {
        const t = i / SR; let v = 0; for (let h = 1; h <= 5; h++) v += Math.sin(2 * Math.PI * f * h * t) / (h * h);
        const env = Math.min(1, t / 0.9) * Math.min(1, (len - t) / 0.7);
        lp += 0.08 * (v - lp); add(side, Math.round((t0 + t) * SR), lp * env * 0.05);
      }
    }
    // sub bass on the root, each bar
    for (let b = 0; b < 2; b++) { const f = midi(notes[0] - 12), s0 = (t0 + b * bar) * SR; for (let i = 0; i < bar * SR; i++) { const t = i / SR, v = Math.sin(2 * Math.PI * f * t) * Math.min(1, t * 30) * Math.exp(-t * 0.9) * 0.16; add(L, Math.round(s0 + i), v); add(R, Math.round(s0 + i), v); } }
    // muted pluck arpeggio in eighths (from the 3rd chord on, so the bed opens quietly)
    if (c >= 1) for (let k = 0; k < 16; k++) { const m = notes[[0, 2, 4, 3, 1, 3, 2, 4][k % 8]] + 12, f = midi(m), s0 = (t0 + k * beat / 2) * SR, pan = k % 2 ? 0.7 : 1;
      for (let i = 0; i < 0.5 * SR; i++) { const t = i / SR, v = (Math.sin(2 * Math.PI * f * t) + 0.3 * Math.sin(4 * Math.PI * f * t)) * Math.exp(-t * 9) * Math.min(1, t * 600) * 0.05; add(L, Math.round(s0 + i), v * pan); add(R, Math.round(s0 + i), v * (1.7 - pan)); } }
    // off-beat hats (very quiet)
    if (c >= 2) for (let k = 0; k < 8; k++) { const s0 = Math.round((t0 + (k + 0.5) * beat) * SR); let pr = 0; for (let i = 0; i < 0.05 * SR; i++) { const n = rnd() * 2 - 1, v = (n - pr) * Math.exp(-i / SR * 90) * 0.02; pr = n; add(L, s0 + i, v); add(R, s0 + i, v); } }
  }
  for (let i = 0; i < SR * 2; i++) { const g = i / (SR * 2); L[i] *= g; R[i] *= g; const j = L.length - 1 - i; L[j] *= g; R[j] *= g; }
  let m = 0; for (let i = 0; i < L.length; i++) m = Math.max(m, Math.abs(L[i]), Math.abs(R[i]));
  for (let i = 0; i < L.length; i++) { L[i] *= 0.8 / m; R[i] *= 0.8 / m; }
  wav(bedFile, L, R); console.log('made music/bed.wav (64 s, 92 bpm)');
}
```

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

### `build-timeline.mjs`

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

### `render.mjs`

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

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

### `check.mjs`

```js
// Glow · 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" (40.4 s, 11 sections, 8 scenes). Copy it to start a new video, then rewrite the takes and every beat for your script.

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

```js
// GLOW · "Never pay for a Claude content skill again" — the script.
// Markup: /  a new caption block   |  the rest of the block is the blue second line   @icon:word  struck icon chip before the word
//   {shown|said}  the caption shows one thing, the voice says another
export const VOICE = 'nPczCjzI2devNBz1zQrb';   // ElevenLabs "Brian": deep, calm (reference F0 ~129 Hz, ~160 wpm)
export const SETTINGS = { stability: 0.5, similarity_boost: 0.8, style: 0.3, use_speaker_boost: true };
export const SPEED = 1.06;
export const ORDER = ['s0', 's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8', 's9', 'cta'];
export const GAP = { s1: 0.5, s2: 0.4, s3: 0.5, s4: 0.45, s5: 0.5, s6: 0.4, s7: 0.4, s8: 0.45, s9: 0.45, cta: 0.5 };
export const LEAD = 0.15, HOLD = 2.4;
export const TAKES = {
  s0: "Imagine never paying | for a Claude content skill again.",
  s1: "@course:Not a course. / Not a {$30|thirty-dollar} file | someone resold by Friday.",
  s2: "A library of formats, | right next to your chats.",
  s3: "I'm Casper, | and I built Morphy, / an agentic | super app.",
  s4: "I gave my own format | away for free. / People started selling it | for thirty dollars.",
  s5: "So here's my response.",
  s6: "I'm building an entire library | of ready-to-use {.md|dot M D} files.",
  s7: "@pay:No paywalls. / @sub:No subscriptions. / @string:No strings attached.",
  s8: "Because you shouldn't pay | for a text file / that was free | to begin with.",
  s9: "This format is called | {glow.md.|glow dot M D.}",
  cta: "Want it? / Comment GLOW | and I'll send it to you. / Free.",
};
```

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

```js
// GLOW · "Never pay for a Claude content skill again" — the scenes. One scene per spoken beat; every time is a word.
const { S, T0, T1, END, scene, cue, div, add, h, set, pop, rise, typeIn, clamp, lerp, eo, eio, ease, back, rng, esc } = F;
const G = F.glow;
F.looks([[0, 'dark']]);
// the CTA keyword is shown in giant type, so only "Want it?" stays a caption there
F.cap.hide = (tk, s) => s.id === 'cta' && tk.t0 >= T0('cta', 'comment') - 0.01;

const tB = T0('s1', 'not') - 0.12, tD = T0('s3', "i'm") - 0.12, tE = T0('s4', 'i') - 0.12, tF = T0('s5', 'so') - 0.12;
const tG = T0('s6', "i'm") - 0.12, tH = T0('s7', 'no') - 0.14, tK = T0('cta', 'want') - 0.12;

// ---------- 1 · "Imagine never paying for a Claude content skill again." — a glowing shop, people walk out, the camera pulls back into a crowd
{
  const sc = scene(-1, tB, { full: true });
  const DX = 510, DY = 1010;                       // the shop door = world (0, 0)
  G.spot(sc.el, DX, DY + 60, 980, 520, 0.22);
  const crowd = G.crowd(sc.el, { n: 3600, spread: 6500 });
  const shop = G.shop(sc.el, DX, DY - 0.5 * 560 * 0.62 + 4, 560);
  shop.el.style.transformOrigin = `50% ${(560 * 0.62 - 4).toFixed(0)}px`;
  const tPay = T0('s0', 'paying'), tZ0 = T0('s0', 'for') - 0.15, tZ1 = tB + 0.1;
  sc.on(t => {
    const s = lerp(1, 0.07, eio((t - tZ0) / (tZ1 - tZ0)));
    crowd.draw({ s, cx: DX, cy: DY, t, walkT: t + 0.2, reveal: 0.85 });
    set(shop.el, 1, `scale(${s.toFixed(4)})`);
    // the price tag swings and drops to $0 on "paying"
    const sw = t < tPay ? 0 : Math.sin((t - tPay) * 11) * 16 * Math.exp(-(t - tPay) * 3.2);
    shop.tag.style.transform = `rotate(${sw.toFixed(2)}deg)`;
    F.html(shop.tag, t < tPay ? '$30' : '$0');
    shop.tag.style.color = t < tPay ? '#cfd8ff' : '#ffffff';
    shop.tag.style.background = t < tPay ? 'rgba(10,14,40,0.9)' : '#2b4bff';
  });
  cue('swish', tPay - 0.05, 0.12); cue('pop', tPay, 0.1);
  cue('whoosh', tZ0, 0.16);
}

// ---------- 2 · "Not a course. Not a $30 file someone resold by Friday." → the paper falls to dust;
//                "A library of formats, right next to your chats." → the blue card moves in front of the chat cards
{
  const sc = scene(tB, tD);
  G.spot(sc.el, 540, 1040, 1000, 900, 0.2, '60,70,120');
  const spot = G.spot(sc.el, 560, 1000, 900, 820, 0.34);
  const tFile = T0('s1', 'file'), tFri = T0('s1', 'friday'), tLib = T0('s2', 'a'), tRight = T0('s2', 'right');
  // the chat cards that slide up behind the blue card on "chats"
  const stack = [
    G.darkCard(sc.el, 540, 905, 470, 300, { label: 'Chat · Claude', meta: 'working', bg: 'linear-gradient(160deg,#4a4566,#2d2a44)' }),
    G.darkCard(sc.el, 540, 945, 470, 300, { label: 'Chat · Codex', meta: 'done', bg: 'linear-gradient(160deg,#1f5c4b,#143c31)' }),
    G.darkCard(sc.el, 540, 985, 470, 300, { label: 'Notes', meta: '3', icon: 'note', bg: 'linear-gradient(160deg,#3b3d45,#25262c)' }),
  ];
  const course = G.paper(sc.el, 320, 1010, { title: 'Content Course', sub: '12 modules · video', price: '$199' });
  const pack = G.paper(sc.el, 320, 1010, { title: 'Content Skill Pack', sub: 'SKILL.md · 1 file', price: '$30' });
  const dust = G.dust(sc.el, { x: 130, y: 770, w: 380, h: 480 });
  const card = G.card(sc.el, 540, 1000, { filled: 9 });
  sc.on(t => {
    // the two cards: paper left, blue right, both a little tilted, breathing
    const b = Math.sin(t * 1.3) * 1.5;
    const flip = clamp((t - (tFile - 0.18)) / 0.3);     // course → $30 pack, a card flip
    const fu = clamp((t - tFri) / 0.5);                 // the pack falls to dust
    const inP = eo((t - tB) / 0.4);
    const sx1 = Math.cos(Math.min(flip, 0.5) * Math.PI), sx2 = Math.cos((1 - Math.max(flip, 0.5)) * Math.PI);
    set(course.el, flip < 0.5 ? inP : 0, `translate(0px, ${((1 - inP) * 40).toFixed(1)}px) rotate(${(-6 + b).toFixed(2)}deg) scaleX(${Math.abs(sx1).toFixed(3)})`);
    set(pack.el, flip >= 0.5 ? (1 - fu) : 0, `rotate(${(-6 + b - fu * 8).toFixed(2)}deg) scaleX(${Math.abs(sx2).toFixed(3)})`, fu * 6);
    dust.draw((t - tFri + 0.05) / 1.1);
    // the blue card: right of the paper, then to the centre in front of the chats
    const m = eio((t - (tFri + 0.45)) / 0.8);
    const x = lerp(220, 0, m), y = lerp(-10, 120 * eio((t - tRight + 0.1) / 0.6), m), k = lerp(0.76, 0.86, m);
    const rot = lerp(6 + b, Math.sin(t * 0.9) * 2, m), ry = lerp(-14, Math.sin(t * 0.7) * 8, m);
    set(card.el, inP, `perspective(1600px) translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${rot.toFixed(2)}deg) rotateY(${ry.toFixed(2)}deg) scale(${k.toFixed(4)})`);
    spot.style.transform = `translate(${lerp(200, 0, m).toFixed(0)}px, ${lerp(0, 60, m).toFixed(0)}px)`;
    // the chats rise from behind the card, one by one
    stack.forEach((e, i) => {
      const u = eo((t - (tRight + i * 0.16)) / 0.5);
      set(e, u, `translate(${(i % 2 ? 1 : -1) * (1 - u) * 30}px, ${((1 - u) * 160 - 60 + i * 0).toFixed(1)}px) rotate(${(Math.sin(t * 0.9) * 2).toFixed(2)}deg)`);
    });
  });
  cue('swish', tFile - 0.2, 0.1); cue('implode', tFri, 0.12); cue('whoosh', tFri + 0.45, 0.1);
  [0, 1, 2].forEach(i => cue('click', tRight + i * 0.16, 0.06));
}

// ---------- 3 · "I'm Casper, and I built Morphy, an agentic super app." — a calm canvas window fills with working chats
{
  const sc = scene(tD, tE);
  G.spot(sc.el, 540, 1060, 1100, 760, 0.3);
  const win = G.appWindow(sc.el, 540, 1000, 900, 640);
  const C = [
    ['Chat · Claude', 'chat', 40, 40], ['Chat · Codex', 'chat', 330, 40], ['Notes', 'note', 620, 40],
    ['Chat · Grok', 'chat', 40, 300], ['Tasks', 'note', 330, 300], ['Library', 'glow', 620, 300],
  ].map(([label, kind, x, y], i) => G.chatCard(win.canvas, x, y, 240, 220, { label, kind, accent: i === 5 ? '#ffffff' : G.BLUE }));
  C[5].el.style.background = 'linear-gradient(160deg,#3557ff,#1a34e2)';
  C[5].el.style.boxShadow = '0 0 40px rgba(43,75,255,0.7), 0 18px 40px rgba(0,0,0,0.5)';
  const at = [T0('s3', 'built'), T0('s3', 'morphy'), T0('s3', 'morphy') + 0.22, T0('s3', 'agentic'), T0('s3', 'super'), T0('s3', 'app')];
  const tAg = T0('s3', 'agentic');
  sc.on(t => {
    const u = eo((t - tD) / 0.45);
    set(win.el, u, `perspective(1800px) translateY(${((1 - u) * 50).toFixed(1)}px) rotateX(${(6 - 6 * u + Math.sin(t * 0.6) * 1.5).toFixed(2)}deg) scale(${(0.94 + 0.06 * u).toFixed(4)})`);
    C.forEach((c, i) => { F.pop(c.el, t, at[i], { d: 0.4, s0: 0.6, blur: 8 }); c.work(t, t > tAg); });
  });
  at.forEach((x, i) => cue('pop', x - 0.03, 0.07 + (i === 5 ? 0.04 : 0)));
}

// ---------- 4 · "I gave my own format away for free. People started selling it for thirty dollars." — a night skyline:
//                my little shop lights up "Free", then the towers light up, each with a $30 tag
{
  const sc = scene(tE, tF, { full: true });
  const sky = G.skyline(sc.el, { ground: 1300, towers: [{ x: 90, w: 150, top: 690 }, { x: 262, w: 160, top: 840 }, { x: 444, w: 150, top: 620 }, { x: 616, w: 140, top: 780 }], shop: { x: 790, w: 210, top: 1110 } });
  const tOwn = T0('s4', 'own'), tFree = T0('s4', 'free'), tPeople = T0('s4', 'people'), tThirty = T0('s4', 'thirty');
  const litAt = [0, 1, 2, 3].map(i => tPeople + 0.05 + i * 0.32);
  sc.on(t => {
    const p = clamp((t - tE) / (tF - tE));
    sky.root.style.transform = `translate(${lerp(-40, 30, ease(p)).toFixed(1)}px, ${lerp(30, -10, ease(p)).toFixed(1)}px) scale(${lerp(1.1, 0.98, ease(p)).toFixed(4)})`;
    sky.draw({
      t, shopOn: eo((t - tOwn) / 0.7), shopT: tFree,
      lit: litAt.map(a => eo((t - a) / 0.9)),
      badgeT: [0, 1, 2, 3].map(i => tThirty - 0.05 + i * 0.09),
    });
  });
  cue('chime', tFree, 0.08);
  [0, 1, 2, 3].forEach(i => cue('pop', tThirty - 0.05 + i * 0.09, 0.08));
  cue('swish', tPeople, 0.08);
}

// ---------- 5 · "So here's my response." — on a dark counter, one glowing .md chip lights up
{
  const sc = scene(tF, tG);
  G.floor(sc.el, 1180);
  const chip = G.chip(sc.el, 400, 1062, 230, { label: 'Free .md' });
  const card = G.card(sc.el, 700, 1040, { filled: 0, rv: '0/10' });
  const tResp = T0('s5', 'response');
  sc.on(t => {
    const u = eo((t - tF) / 0.5);
    set(chip.el, u, `translateY(${((1 - u) * 30).toFixed(1)}px)`);
    set(card.el, u, `perspective(1400px) translateY(${((1 - u) * 30).toFixed(1)}px) rotateY(-18deg) rotate(3deg) scale(0.44)`);
    chip.pulse(t, tResp);
    chip.el.firstElementChild.style.boxShadow = `0 0 ${(30 + 40 * Math.max(0, 1 - Math.abs(t - tResp - 0.2) * 2)).toFixed(0)}px rgba(43,75,255,0.8), inset 0 0 24px rgba(43,75,255,0.25)`;
  });
  cue('chime', tResp, 0.07); cue('whoosh', tF, 0.06);
}

// ---------- 6 · "I'm building an entire library of ready-to-use .md files." — the library card fills, one format at a time
{
  const sc = scene(tG, tH);
  const spot = G.spot(sc.el, 540, 1010, 1000, 900, 0.36);
  const card = G.card(sc.el, 540, 1000, { filled: 0, rv: '0/10' });
  const ready = div(sc.el, 'gl-pill', 610, 1195, 210, 50, `${G.icon('check', 22, G.BLUE, 3)} 9 ready · free`, { background: '#fff', color: G.BLUE, fontSize: '20px' });
  const tA = T0('s6', 'building'), tZ = T1('s6', 'files') - 0.15;
  const fills = Array.from({ length: 10 }, (_, i) => i < 9 ? lerp(tA, tZ, i / 8) : null);
  const sparks = div(sc.el, 'abs', 0, 0, 1080, 1920);
  const r = rng(3), SP = Array.from({ length: 26 }, () => ({ a: r() * 6.28, d: 260 + r() * 260, s: 4 + r() * 6, k: r() }));
  const dots = SP.map(p => div(sparks, 'abs', 0, 0, p.s, p.s, '', { borderRadius: '50%', background: '#cfe0ff', boxShadow: '0 0 10px #8fb0ff' }));
  sc.on(t => {
    const u = eo((t - tG) / 0.45);
    const ry = Math.sin(t * 0.8) * 9, rx = Math.cos(t * 0.6) * 4;
    set(card.el, u, `perspective(1600px) rotateY(${ry.toFixed(2)}deg) rotateX(${rx.toFixed(2)}deg) rotate(${(Math.sin(t * 0.5) * 2).toFixed(2)}deg) scale(${(1.02 + 0.03 * u).toFixed(4)})`);
    card.fill(fills, t);
    const n = fills.filter(x => x != null && t >= x).length;
    card.val(`${n}/10`);
    const full = clamp((t - tZ) / 0.4);
    card.el.style.boxShadow = `0 40px 90px rgba(0,0,0,0.65), 0 0 ${(90 * full).toFixed(0)}px rgba(60,100,255,${(0.8 * full).toFixed(2)})`;
    spot.style.opacity = (0.75 + 0.35 * full).toFixed(3);
    F.pop(ready, t, tZ + 0.05, { d: 0.35, s0: 0.6 });
    SP.forEach((p, i) => { const v = clamp((t - tZ - p.k * 0.3) / 1.2); set(dots[i], v > 0 && v < 1 ? (1 - v) : 0, `translate(${(540 + Math.cos(p.a) * p.d * eo(v)).toFixed(1)}px, ${(1000 + Math.sin(p.a) * p.d * 1.2 * eo(v)).toFixed(1)}px)`); });
  });
  fills.forEach(x => x != null && cue('pop', x - 0.02, 0.07));
  cue('chime', tZ + 0.05, 0.09);
}

// ---------- 7 · one phone: "No paywalls. No subscriptions. No strings attached." (the free library)
//                → "Because you shouldn't pay for a text file that was free to begin with." ($30 struck, "free" typed)
//                → "This format is called glow.md." (it lands on the lock screen)
{
  const sc = scene(tH, tK);
  const spot = G.spot(sc.el, 540, 1080, 1000, 1000, 0.3);
  const ph = G.phone(sc.el, 540, 1010, 400);
  const W = ph.sw;
  // screen 1: the library
  const lib = G.screen(ph, '#000');
  div(lib, 'abs', 34, 76, 300, 50, 'Library', { fontSize: '40px', fontWeight: 750, color: '#fff' });
  div(lib, 'abs', 34, 126, 300, 30, 'Content formats', { fontSize: '19px', color: 'rgba(255,255,255,0.55)' });
  const chips = ['No paywall', 'No subscription', 'No strings'].map((s, i) => div(lib, 'gl-pill', 34, 168 + i * 44, null, 38, `${G.icon('check', 18, '#fff', 3)}${s}`, { padding: '0 14px', fontSize: '17px', background: G.BLUE, color: '#fff' }));
  const names = ['kinetic', 'motion', 'voxel', 'light', 'terracotta', 'sandbox', 'paper', 'pixel', 'glow'];
  const rows = names.map((n, i) => div(lib, 'abs', 20, 220 + i * 50, W - 40, 48, `
    <div class="abs" style="left:10px;top:7px;width:40px;height:40px;border-radius:11px;background:${n === 'glow' ? G.BLUE : '#1d1e24'};display:flex;align-items:center;justify-content:center">${G.icon(n, 24, n === 'glow' ? '#fff' : '#8f9cff', 2)}</div>
    <div class="abs" style="left:62px;top:13px;font-size:21px;font-weight:600;color:#fff">${n}.md</div>
    <div class="abs" style="right:10px;top:12px;font-size:16px;font-weight:700;color:#8f9cff">Free</div>`, { borderBottom: '1px solid rgba(255,255,255,0.07)' }));
  // screen 2: the file, with its price
  const ed = G.screen(ph, '#1c1c1e');
  div(ed, 'abs', 26, 76, W - 52, 40, `<span style="color:#8f9cff">Cancel</span><span style="position:absolute;left:0;right:0;text-align:center;font-weight:700;color:#fff">glow.md</span>`, { fontSize: '20px' });
  const box = div(ed, 'abs', 20, 140, W - 40, 300, '', { borderRadius: '20px', background: '#2c2c2e' });
  const l1 = div(box, 'abs', 20, 18, W - 80, 36, '', { fontFamily: 'var(--mono)', fontSize: '22px', color: '#fff', fontWeight: 700 });
  const l2 = div(box, 'abs', 20, 60, W - 80, 64, '', { fontFamily: 'var(--mono)', fontSize: '17px', color: 'rgba(255,255,255,0.75)', lineHeight: '1.45' });
  const priceRow = div(box, 'abs', 20, 156, W - 80, 50, '', { display: 'flex', alignItems: 'center', gap: '12px', fontSize: '22px', color: '#fff', fontWeight: 600 });
  priceRow.innerHTML = `<span style="opacity:0.7">Price:</span><span class="old" style="position:relative;color:#ff8a8a;font-weight:700">$30<span class="bar" style="position:absolute;left:-4px;right:-4px;top:50%;height:3px;background:#8f9cff;transform-origin:0 50%"></span></span><span class="nw" style="color:#8f9cff;font-weight:700"></span>`;
  const old = priceRow.querySelector('.old'), bar = priceRow.querySelector('.bar'), nw = priceRow.querySelector('.nw');
  const save = div(ed, 'gl-pill', W - 150, 456, 126, 50, `${G.icon('send', 18, '#fff', 2.4)} Save`, { background: G.BLUE, color: '#fff', fontSize: '19px' });
  G.keyboard(ed, ph.sh - 320, W);
  // screen 3: the lock screen
  const lock = G.lockScreen(ph);
  const n1 = G.notif(lock, 16, 470, W - 32, { title: 'Calendar', text: 'Post the new reel at 7', when: '2m ago', icon: 'note', bg: '#e8483f' });
  const n2 = G.notif(lock, 16, 572, W - 32, { title: 'Morphy', text: 'glow.md is in your library', when: 'now', icon: 'glow' });
  const tPay = T0('s7', 'paywalls'), tSub = T0('s7', 'subscriptions'), tStr = T0('s7', 'strings');
  const t2 = T0('s8', 'because') - 0.2, tPayW = T0('s8', 'pay'), tFree = T0('s8', 'free'), tBegin = T1('s8', 'with');
  const t3 = T0('s9', 'this') - 0.2, tGlow = T0('s9', 'glow');
  sc.on(t => {
    const u = eo((t - tH) / 0.5);
    const tilt = Math.sin(t * 0.7) * 5, rz = Math.sin(t * 0.45) * 2;
    set(ph.el, u, `perspective(1800px) translateY(${((1 - u) * 60).toFixed(1)}px) rotateY(${tilt.toFixed(2)}deg) rotate(${rz.toFixed(2)}deg)`);
    spot.style.transform = `translateX(${(tilt * 6).toFixed(0)}px)`;
    // screen 1
    rows.forEach((e, i) => rise(e, t, tH + 0.15 + i * 0.07, { d: 0.3, dy: 24, blur: 6 }));
    [tPay, tSub, tStr].forEach((a, i) => F.pop(chips[i], t, a - 0.05, { d: 0.32, s0: 0.5, blur: 6 }));
    const e1 = eo((t - tSub + 0.05) / 0.3), e2 = eo((t - tStr + 0.05) / 0.3);
    rows.forEach((e, i) => { e.style.top = (220 + 44 * (e1 + e2) + i * 50) + 'px'; });
    set(lib, 1 - eo((t - t2) / 0.3), null);
    // screen 2
    set(ed, eo((t - t2) / 0.3) * (1 - eo((t - t3) / 0.3)), null);
    typeIn(l1, '# glow.md', t, t2 + 0.1, t2 + 0.45, false);
    typeIn(l2, 'A glowing explainer format. Copy it, use it.', t, t2 + 0.45, tPayW - 0.1, false);
    set(priceRow, eo((t - tPayW + 0.05) / 0.2), null);
    F.pop(old, t, tPayW - 0.05, { d: 0.3, s0: 0.6, blur: 4 });
    bar.style.transform = `scaleX(${eo((t - tFree + 0.1) / 0.22).toFixed(3)})`;
    typeIn(nw, 'free', t, tFree + 0.05, tFree + 0.35, t < tBegin);
    F.pop(save, t, tBegin - 0.1, { d: 0.3, s0: 0.7 });
    // screen 3
    set(lock, eo((t - t3) / 0.3), null);
    rise(n1, t, t3 + 0.15, { d: 0.3, dy: 20 });
    const nu = back((t - tGlow + 0.08) / 0.45, 1.6);
    set(n2, t < tGlow - 0.08 ? 0 : clamp(nu * 2), `translateY(${((1 - nu) * -140).toFixed(1)}px) scale(${(0.9 + 0.1 * nu).toFixed(3)})`);
    n2.style.boxShadow = `0 0 ${(40 * clamp((t - tGlow) / 0.3)).toFixed(0)}px rgba(43,75,255,0.7)`;
  });
  [tPay, tSub, tStr].forEach(a => cue('pop', a - 0.06, 0.08));
  cue('swish', t2, 0.08); cue('typing', t2 + 0.1, 0.08, tPayW - t2); cue('click', tFree - 0.1, 0.07); cue('typing', tFree + 0.05, 0.07, 0.35);
  cue('swish', t3, 0.08); cue('chime', tGlow - 0.02, 0.1);
}

// ---------- 8 · "Want it? Comment GLOW and I'll send it to you. Free." — back into the crowd, the keyword glows
{
  const sc = scene(tK, END + 1, { full: true });
  const crowd = G.crowd(sc.el, { n: 3600, spread: 6500, seed: 21 });
  const C = G.cta(sc.el, 510, 640, { word: 'GLOW', line: "I'll send it to you.", pill: 'Free' });
  const tC = T0('cta', 'comment'), tW = T0('cta', 'glow'), tL = T0('cta', 'and'), tFree = T0('cta', 'free');
  sc.on(t => {
    const s = lerp(0.2, 0.1, eio((t - tK) / 1.4)) * (1 + 0.04 * clamp((t - tW) / 3));
    crowd.draw({ s, cx: 510, cy: 1000, t, burst: 0.25 * (1 - eo((t - tK) / 1.2)) });
    set(C.shade, eo((t - tC + 0.3) / 0.5), null);
    rise(C.top, t, tC - 0.05, { d: 0.35, dy: 20, blur: 8 });
    const b = back((t - tW + 0.05) / 0.45, 1.4);
    set(C.big, t < tW - 0.05 ? 0 : clamp(b * 1.6), `scale(${(1.25 - 0.25 * b).toFixed(4)})`, (1 - clamp(b)) * 18);
    typeIn(C.sub, "I'll send it to you.", t, tL - 0.05, T1('cta', 'you') - 0.05, false);
    F.pop(C.pl, t, tFree - 0.05, { d: 0.4, s0: 0.4 });
  });
  cue('whoosh', tK, 0.12); cue('impact', tW - 0.04, 0.09); cue('pop', tFree - 0.05, 0.1);
}
```

