---
name: prism
description: Make a narrated vertical reel (1080x1920, 35-60 s) in the "Prism" format, a glass-neon explainer on one synthwave night. A violet-navy sky with nebula clouds and stars over a teal perspective grid floor; one neon-rimmed glass object per beat that arrives on its word (3D glass slabs stacked like a structure, app tiles with locks that flip open, a spinning glass cube, a wireframe globe over breathing floor rings, terminal windows that type, list rows, a count-up card, a glass prism splitting a beam into a neon fan); a big headline at the top whose words blur in as they are said, key words in neon; a dark glass caption pill of 2-3 words with the spoken word underlined; blur and light-streak transitions; the ask as a COMMENT pill, a keyword slab, a typed comment and a "sent" DM. A quick male ElevenLabs voice, a synthesized synth bed and soft UI sounds. Drawn frame by frame in headless Chrome, encoded with ffmpeg. Use it when someone asks for a prism reel, "prism.md", a glass / neon / synthwave explainer reel, a "this AI tool lets you..." reel, or wants to turn a script into this kind of video.
---

# Prism 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 explains one thing in short, punchy beats: a claim ("This library lets Claude make your reels in any content format, even the ones people sell for $30, without paying for a single skill"), what it is, what happened or what it does, the answer as a list, then the ask. The picture is **one continuous synthwave night** with **one glass object per beat** that lands on its word, a **big headline at the top** whose words appear as they are said, and a **caption pill at the bottom**. Everything is drawn from HTML/CSS/SVG/canvas: no images, no footage, no real brands or faces.

**`reference.jpg`** (next to this file, if you have it) is a contact sheet of 12 frames from a finished Prism reel. **This is what it should look like.** Left to right, top row first:
1. **The hook:** "One library, / any format" (the second line in cyan), a cyan wireframe globe with lit dots and a pink orbit ring over glowing floor rings, little glass ".md" chips orbiting it, tags "AI skills" and "Claude".
2. "Even **paid** ones" (pink): four glossy app tiles (blue hooks.md, pink viral.md, violet story.md, amber edit.md), each with a pink lock badge and a "$30" chip.
3. A kicker "I'M CASPER · I BUILT" and a giant "Morphy" in a cyan-to-pink gradient, a spinning cyan glass cube with an app-grid glyph over floor rings, tags "agentic" and "super app".
4. "I gave it **away**" (green): a green glass terminal "SKILL.MD" with `$ cat SKILL.md` and four "✓ ... ready" rows, a giant green "$0" and a tag "free for everyone".
5. "They / **repackaged** it": a pink glass cube "PRO" with a tag "premium skill", and an amber count-up card "SOLD FOR · PER COPY $30.00".
6. A giant pink "$30" over "for something / I gave away **free**", two glass cards side by side: "MINE · SKILL.md · $0" glowing green and "THEIRS · SKILL.md · $30" cracked in red.
7. A header row "CONTENT FORMATS · LIBRARY  07/08" and a stack of 3D glass slabs (kinetic.md, motion.md, voxel.md, pixel.md, glow.md, cutout.md, stamp.md), each a different neon.
8. "Inside **Morphy**" with a tag "ready-to-use .md files" over the full stack of eight, now smaller.
9. "No **catch**" (green): three glass rows "No paywalls" (an open lock), "No subscriptions" ($9.99 struck, $0 / month), "No strings attached" (a cut string), wires running to a cyan pill.
10. "It's just a / **text file**": a glass "PRISM.MD" window with markdown lines, a struck pink "$30" tag and a green "FREE" sticker.
11. "THIS FORMAT IS CALLED / **prism** / **.md**" (cyan, then pink) over a glass prism: a white beam in from the left, a fan of pink, amber, green, cyan and violet out to the right.
12. **The ask:** "Want **prism.md**?", a cyan COMMENT pill, a pink glass "PRISM" slab, a comment box with "PRISM" and a green check, a DM "Your prism.md · sent", little "PRISM" chips with avatars around.

The caption pill at the bottom of every frame shows the 2-3 words being said. If you only have this file, section 3 describes everything with numbers.

Everything is drawn **one frame at a time** in headless Chrome (`window.setT(t)`), then muxed with a mix made in ffmpeg. This file gives you the look (exact colours, sizes, motion), 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 scene shows, keyed to the words).

Work in this order and don't skip ahead: **setup → script → fake timings → scenes → stills → voice → tighten → real timings → bed → fix the scenes → stills on the key words + 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`. The voice is one take per section (~1 credit per character, ~770 for a 40 s reel); takes are cached. The sounds and the music bed are synthesized: no credits.
4. **Stills before film.** Direct on fake timings (`build-timeline.mjs --fake`) and check stills before you pay for the voice; never start the full render until a stills sheet on the key words 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, brands or numbers you can't stand behind.** Everything is drawn: neutral person silhouettes, made-up file and app names, no screenshots, logos, real handles or real view counts. A number on screen is 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 (`render.mjs --port <n>` picks another). No npm packages.
- **An ElevenLabs API key** for the voice. The voice is **Liam**, a premade voice every account has. 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.
- **Fonts:** Inter Tight and JetBrains Mono (SIL OFL) are free downloads (`setup.mjs` fetches them).
- **Sound:** the 21 sound effects (`sounds.mjs`) and the music bed (`bed.mjs`, made per video to its length) are synthesized: no licences, no credits.
- Optional: Python + `pip install faster-whisper` lets `check.mjs --whisper` compare the finished audio with the script.

## 2. Project layout and setup

```
prism-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 prism.js fx.js film.css markup.mjs   clock + the night + scenes/camera + caption pill + safe zone · the glass parts · motion + effects · the look · script markup + voice
  setup.mjs sounds.mjs bed.mjs cdp.mjs gen-vo.mjs votight.mjs build-timeline.mjs render.mjs check.mjs whisper.py
  fonts/ sfx/                    made by setup.mjs
  my-video/                      one folder per video (Appendix B is a complete one)
    script.mjs scenes.js         what the voice says · what each scene shows
    vo/ timeline.js(on) vo.wav bed.wav cues.json stills/ frames/ mix.wav video.mp4 cover.jpg   (made by the tools)
```

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

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

```bash
mkdir my-video                   # write my-video/script.mjs (section 4) and my-video/scenes.js (Appendix B shows both)
node build-timeline.mjs --video my-video --fake                     # guessed timings + a silent vo.wav: direct before you pay
node bed.mjs --video my-video                                       # the synth bed, cut to this length
node render.mjs --video my-video --stills --at beats --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 votight.mjs --video my-video                                   # shorten the pauses: the reference's fast, even pace
node build-timeline.mjs --video my-video                            # real timings: prints every word with its time
node bed.mjs --video my-video                                       # again, for the real length
node render.mjs --video my-video --scan                             # every visible text, every 0.25 s, against the safe zone
node render.mjs --video my-video --stills --at 0,2.8,6,9.2,… --sheet words   # 12-20 stills on the key words
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 4.45   # 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 |
|---|---|---|
| The sky | everywhere | a vertical gradient `#05061a → #0a0b26 → #0d0b25 → #07061a → #090718`, five nebula clouds (radial gradients, blurred 60 px, screen-blended, drifting slowly): violet `rgb(107,63,209)` at 55 % around (230, 560), teal `rgb(26,167,184)` 42 % around (840, 840), blue `rgb(42,63,176)` 42 % around (700, 330), magenta `rgb(194,40,127)` 24 % on the floor around (290, 1580), violet 14 % around (820, 1720). 170 stars (0.8-3.2 px, a few cyan or pink) twinkle and drift up-left |
| The floor | from the horizon at **y 1150** down | a dark floor (`rgba(6,5,20,0.55)` → `rgba(3,3,12,0.72)`), a magenta glow (330, 1530) and a faint cyan one; a **teal perspective grid**: rows at y = 1150 + 1500 / z (z = 1.95, 2.95, … sliding toward the camera at 0.22 a second), each `rgba(70,210,235, 0.42 × ((y − 1150) / 520)^1.3)`, and 15 lines from the vanishing point to the bottom edge, 330 px apart there; a 2.5 px horizon line from magenta to cyan |
| The accent glow | behind the headline | an 800 × 420 ellipse at (510, 420), blurred 70 px, in the scene's accent at 30 % (it crossfades in 0.4 s at each scene) |
| The headline | from y ≈ 300, centred on x 510, ≤ 900 px wide | 3.3 |
| The objects | y ≈ 560-1290, standing on the floor | 3.5 |
| The caption pill | top y 1356, centred on x 510 | 3.4 |

**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. That is why everything centres on x 510, not 540. `render.mjs --stills --safe` draws the zone and outlines any visible text outside it in red; `--scan` tests every text every 0.25 s. A transition may leave a blurred, moving text outside for one frame; anything static outside is a bug.

### 3.2 Colours

| Token | Hex | Partner (gradients) |
|---|---|---|
| cyan | `#3fe0ff` | `#4d9bff` |
| pink | `#ff4fb8` | `#c76bff` |
| amber | `#ffc24d` | `#ff8a3d` |
| violet | `#9b7bff` | `#5f8bff` |
| green | `#39f0a0` | `#3fe0ff` |
| blue | `#4d7bff` | `#9b7bff` |
| ink · dim | `#f2f4ff` · `#7d82a6` | |

Each scene has **one accent** (`scene(…, { accent: 'pink' })`): it colours the glow behind the headline, the headline's key words and the caption underline. Change the accent at every beat, the way the reference walks through its colours. Claude's orange `#d97757` only for Claude's own spark.

### 3.3 The headline

**Inter Tight 800**, 100-170 px, letter-spacing −0.035em, line-height 0.98, centred, a white → `#eef0ff` → `#b9b8f2` vertical gradient with a soft lavender glow (`drop-shadow(0 0 22px rgba(170,175,255,0.42))`). **Key words** (`*word*`) take the accent: `linear-gradient(180deg, #fff -30%, accent 45%, partner 115%)`; a name can take its own gradient (`grad`), like the cyan-to-pink "Morphy". **Each word appears as it is said**: over 0.3 s it slides 46 px in from the right, un-stretches from scaleX 1.28 and un-blurs from 16 px (the reference's smear). A **kicker** above it ("THIS FORMAT IS CALLED", "I'M CASPER · I BUILT") is JetBrains Mono 25 px, uppercase, letter-spacing 0.26em, 78 % white, typed in over 0.28 s. The headline usually summarises the beat in 2-4 words ("Even paid ones", "No catch", "It's just a text file"); it doesn't have to be the spoken words.

### 3.4 The caption pill

A dark glass pill (`linear-gradient(rgba(20,21,46,0.86), rgba(9,10,26,0.9))`, a 1.5 px border at 16 % white, radius 26, padding 18/30/22, a faint violet glow) at **top y 1356, centred on x 510**, showing **2-3 words at a time**: Inter Tight 700, 50 px. The engine chunks by itself: at most 3 words and 22 characters, a new chunk after `, . ? !` and at every `/` in the script. Words not yet said are dim (`#7d82a6`); said words are white; **the word being said gets a 5 px underline** 10 px below it in the accent → partner gradient with a glow, growing from the left over 0.14 s. A new chunk pops in (scale 0.9 → 1 with a spring, 6 px blur → 0) 0.06 s before its first word.

### 3.5 The glass

- **Glass cards** (`P.glass`): a 2.5 px rim in an accent gradient around a frosted fill (`linear-gradient(160deg, accent 28 %, rgba(26,30,86,0.9) 38 %, rgba(18,16,54,0.94) 72 %, partner 22 %)`), two blurred colour pools inside (the reference's "liquid" light), an inner glow, a 1.5 px top highlight and an outer neon glow (22 px and 70 px) plus a deep drop shadow. `solid: true` makes a saturated tile (app tiles, the keyword slab).
- **3D slabs** (`P.slab`): a CSS 3D box (front, top, right end) turned rotateX(−16°) rotateY(−20°), each face a translucent neon with a 2.5 px rim, the label in white 750 with a number in mono and a glowing dot at the right end. Stacked 86 px apart they make the reference's "structure".
- **Objects:** app tiles (`P.tile`, white icon badges, a lock badge that flips from pink to an open green lock, a "$30" chip), a spinning glass cube (`P.cube`), a wireframe globe (`P.orb`) over breathing floor rings (`P.rings`), terminal and file windows with traffic lights (`P.term`), list rows with an icon badge (`P.row`), a count-up card (`P.counter`), a glass prism with a beam and a neon fan (`P.prism`), dark tags with a glowing dot (`P.tag`), giant neon words (`P.big`), the ask (`P.commentPill`, `P.keyword`, `P.input`, `P.dm`, `P.chip`).

### 3.6 Motion

| Move | How |
|---|---|
| between scenes | `blur` (0.26 s: the old scene blurs and grows 5 %, the new one sharpens from 95 %) or **`streak`**: the old scene collapses vertically into a horizontal line at `sy` (getting brighter), a light streak (white in the middle, violet and pink ends, glowing) flashes across the frame, and the next scene opens from it. Use the streak at turns (after the hook, before the reveal, into the name); blur otherwise. Always with a `whoosh` or `swoosh` |
| inside a scene | every object enters **on its word** with a spring (`pop`), a slide (`up/left/right`), a `slam` (from 1.7-2.6x), a `drop` or a `zoom`, then **floats** (a 5 px bob and a 0.4° sway); headline words smear in on their words; the camera pushes 2.5-7 % and punches 4-5 % on hits |
| a number | counts on a curve while the voice says it (`$0.00 → $30.00`), with ticks |
| a price | a chip slams on, then a neon strike draws across it and a green "$0" or "FREE" slams in |
| a list | one glass row per item, sliding in from the right on its word, each with its small literal (a lock opening, a struck price, a string cut in two) |
| the ask | on "comment": the COMMENT pill pops, a dim keyword slab and an empty comment box appear; on the keyword: the slab slams in lit, the box types it, the send button turns into a check; on "send it to you": the DM slides up, "sent" pops on "directly"; keyword chips with avatars pop around it all |

## 4. Writing for this format

**The pattern** (the reference's and the example's), ~130-140 words ≈ 40 s:
- **s0, the claim:** "This <thing> lets <you / Claude> <do X>, / even <the hard case>, / without paying for a single <Y>." One scene per clause.
- **s1, what it is or who made it:** "It's called <name>. It's a free, open-source <thing>." or "I'm <name>, and I built <thing>, <what it is>." The name gets a kicker and a giant two-tone headline.
- **s2, what happened or what it does:** short sentences, each with its object (a terminal that types, a card someone takes, a counter that counts up).
- **s3-s4, the answer as a list,** then the one-line reason ("Because you shouldn't pay for a text file that was free to begin with.").
- **cta:** "This format is called *{prism.md.|prism dot M D.}* / So if you want it, just comment *{PRISM|prism}* down below, / and I'll send it to you directly. Completely *free.*"

**Markup:** `/` starts a new caption chunk · `*word*` marks a key word · `{shown|said}` when the caption and the voice differ (`{$30,|thirty dollars,}`, `{.md|dot M D}`, `{PRISM|prism}`). Say numbers as words in the voice.

## 5. The voice (ElevenLabs)

**Liam** (`TX3LPaxmHKxFdv7VOQHJ`, premade), `eleven_multilingual_v2`, stability 0.4 · similarity 0.8 · style 0.45 · speaker boost · **speed 1.15**: a young American male, quick and bright. Then **`votight.mjs`** shortens every pause inside each take (it cuts only real silence, with 4 ms fades, and remaps the word times): 0.2 s after `. ! ?`, 0.1 s after `, ; :`, 0.04 s anywhere else. The reference talks in a fast, even stream at 205-227 words a minute; the example reads at 214. These are the `engine/markup.mjs` DEFAULTS; a `script.mjs` may export `VOICE`, `MODEL`, `SETTINGS` or `SPEED` (and `GAP`, `LEAD`, `HOLD`) to override them. `GAP` (silence before a section) 0.2 s, `HOLD` 1.6 s. Whisper will hear "Morphy" as "Morphe" and names loosely: judge those by ear.

## 6. Timings

`build-timeline.mjs` lays the takes end to end (LEAD before the first, GAP before each section, HOLD after the last) and writes `timeline.js` (every caption token with its `t0`/`t1` on the film clock) and `vo.wav`. It uses the tightened takes (`vo/<id>.t.wav`) while their words still match the script. Markup-only edits (where `/` and `*` fall) need no new voice. `--fake` guesses times at the tightened pace so you can direct before the voice exists. Every scene time comes from a word, so the real timeline moves every scene automatically. Run `bed.mjs` again after any timing change.

## 7. Directing

### 7.1 Times come from words

`T0('s1', 'word')` is the start of the first token in section s1 whose text **starts with** "word" (case and punctuation blind), `T1(…)` its end, `T0('s5', 'no', 2)` the third. **Prefix matching bites:** "pay" finds "paywalls", "PRISM" finds "prism.md", "And" finds an earlier "and". The example defines `X(id, word, n)`, which matches the whole word: use it whenever a word repeats or starts another word. `S.s3.start` / `S.s3.until` are a section's bounds; `END` is the film's length. **Check that a beat's payoff lands well before its scene ends**: something that arrives in the last 0.3 s is never seen.

### 7.2 scenes.js

```js
const { T0, T1, cue, scene, cam, enter, clamp, lerp, eo, back, div, add, h, html, typeIn } = { ...F, enter: F.enter };
const P = F.parts;
{
  const tIn = T0('s0', 'even') - 0.1, tOut = T0('s1', "I'm") - 0.3;
  const sc = scene(tIn, tOut, { accent: 'pink', out: 'streak', sy: 860 }), el = sc.el;   // el is the scene's camera rig
  const head = P.head(el, 300, 'Even *paid* ones', { at: [T0('s0', 'even'), T0('s0', 'the'), T0('s0', 'ones')], size: 104, accent: 'pink' });
  const tile = P.tile(el, 380, 640, 220, { c: 'blue', icon: 'bolt', label: 'hooks.md', price: '$30' });
  const tPrice = T0('s0', '$30'), tFree = T0('s0', 'single');
  cam(sc, [[tIn, 1, 0, 0], [tFree, 1, 0, 0], [tFree + 0.12, 1.04, 0, 0]]);   // [t, scale, x, y, rot], eased; a punch on the hit
  sc.on(t => {
    head.update(t);
    enter(tile.el, t, T0('s0', 'the'), { from: 'pop', s0: 0.4, spin: -8 });   // pops in on its word, then floats
    tile.lock(t, tFree + 0.1);                                                  // the lock flips open
    tile.price.style.opacity = t < tPrice ? 0 : 1;
  });
  cue('whoosh', tIn - 0.06, 0.09); cue('uipop', T0('s0', 'the'), 0.07); cue('kaching', tPrice, 0.11);
}
```

- A scene is shown from `tin` to `tout` (plus its exit). Make each end where the next begins. `scene(tin, tout, { accent, in, out, sy, push })`: in/out `'blur' | 'streak' | 'up' | 'zoom' | 'cut'`; `sy` is where a streak collapses (default 760); push (default 0.025) is a slow zoom over the scene.
- Draw on `sc.el` in real coordinates: headline from y ≈ 300, objects in y 560-1290, nothing under y ~1300 (the caption pill). Centre on **x 510**. Animate per frame in `sc.on(t => …)`.
- The camera (`cam`) scales around (510, 330), the headline line, so a push grows the frame downward and keeps the headline in the zone.
- `F.enter` sets an element's transform every frame. For centred things use `P.anchor` / `P.tagC` / `P.big` (F.enter moves the anchor; the content stays centred on it).

### 7.3 What the kit gives you

| Part | Options and use |
|---|---|
| `F.enter(el, t, t0, { from, d, dist, s0, s, x, y, rot, spin, blur, float, seed, t1, d1, to })` | every element's entry, idle float and exit. from `'pop' 'up' 'down' 'left' 'right' 'slam' 'drop' 'zoom' 'fade'`; to `'fade' 'pop' 'up' …` at t1 |
| `F.cam(sc, keys)` · `F.shake(t, amp, dur)` · `F.streak(t, y, peak)` · `F.flash(t, peak)` · `F.tw(t, t0, t1, ease)` | the scene camera · a shake · a light line across the frame · a white flash (use sparingly: it washes the neon out) · a 0-1 progress |
| `P.head(el, y, text, { at, size, accent, keyc, grad, kicker, kat, kgap, lh, gap })` → `{ el, words, kick, update(t) }` | the headline; `\n` for a second line; `at` one time per word (or a start, words 0.1 s apart) |
| `P.big(el, cx, cy, text, { size, c })` → `{ el, txt }` | a giant neon word or number |
| `P.glass(el, x, y, w, h, { c, c2, r, tint, glow, solid })` → `{ el, fill, in }` | a glass card; put content in `.in`. Its rim is the outer box's background, so a transparent fill makes a solid slab: for a rim-only highlight use a div with a border and a glow |
| `P.tag(el, x, y, text, c)` · `P.tagC(el, cx, cy, text, c)` → `{ el, tag }` | "• free" tags |
| `P.slab(el, cx, cy, w, h, d, { c, label, num, rx, ry })` → `{ el, box }` | a 3D glass bar; put a stack in a `P.anchor` group to move or shrink it together |
| `P.cube(el, cx, cy, s, { c, glyph, spin, tilt, y0 })` → `{ el, update(t) }` | a spinning glass cube (glyph: text or `P.icon(…)` html) |
| `P.orb(el, cx, cy, r, { c, c2 })` → `{ el, update(t) }` · `P.rings(el, cx, cy, rx, { c, n })` → `{ el, update(t) }` | the wire globe · floor rings |
| `P.prism(el, cx, cy, s)` → `{ el, update(t, tBeam, tFan) }` | the glass prism, its beam and its fan |
| `P.tile(el, cx, cy, s, { c, icon, label, price, lock })` → `{ el, lock(t, tOpen), price }` | an app tile; `lock: false` for none |
| `P.term(el, x, y, w, h, { c, title, right })` → `{ el, body }` · `P.lines(body, [[text, right, c]…])` → `[{ el }]` | a terminal or file window, its ✓ rows |
| `P.row(el, x, y, w, h, { c, icon, title })` → `{ el, sub }` · `P.counter(el, x, y, w, { c, icon, label })` → `{ el, num }` | a list row (fill `sub`) · a count-up card (write `num` each frame) |
| `P.commentPill(el, cx, cy)` · `P.keyword(el, cx, cy, text, c, { w, h, size })` · `P.input(el, x, y, w)` → `{ el, text, done(on) }` · `P.dm(el, x, y, w, { from, text, pfp })` → `{ el, badge }` · `P.chip(el, cx, cy, text, k)` | the ask (anchors: move them with F.enter) |
| `P.badge(icon, size, c)` · `P.icon(name, size, c)` · `P.avatar(el, cx, cy, s, k)` · `P.strike(el, x, y, w, { c })` → `{ update(t, t0) }` | icon badges and glyphs (reel bolt book cut lock unlock card link file chat grid box tag cart star spark terminal globe cal send) · a neutral person · a neon strike |
| `P.wire(el, d, { t0, dur, width, pulses, color })` · `P.burst` · `P.shock(el, cx, cy, t0, { r, color })` · `P.confetti(el, cx, cy, t0, { n, spread, up })` · `P.crack(el, x, y, w, h, t0, { seed, n })` · `P.cursor2(el, label, colour)` + `F.drive(cur, t, [[t, x, y]…], [clicks])` | connectors, hits, rings, confetti, cracks, labelled cursors (each has `update(t)`) |
| `div(parent, cls, x, y, w, h, html, style)` · `html(el, s)` · `typeIn(el, text, t, t0, t1, caret)` | your own elements |

### 7.4 The edit follows the words

Every spoken claim gets its literal picture at that moment. In the example:
- "this library … any content format" → the globe with ".md" chips flying into orbit.
- "even the ones people sell" → four tiles pop, one per word; "$30" → price chips slam on.
- "without paying" → a price bar slides up and its "$30.00" is struck; "a single skill" → a green "$0" slams and every lock flips open.
- "I'm Casper" → the kicker types; "Morphy" → the name; "agentic", "super app" → their tags.
- "my own content format and its SKILL.md" → a terminal types `cat SKILL.md` and its rows tick in; "completely free" → "$0".
- "people took it" → two labelled cursors grab the file; "repackaged it" → it shrinks into a "PRO" box; "selling it for $30" → the counter runs to $30.00, "bought" chips pop.
- "$30, for something I gave away for free" → the giant "$30", then "MINE $0" vs "THEIRS $30", which cracks on "free".
- "an entire library" → eight slabs stack one per word with a counter 01/08 → 08/08; "inside Morphy" → the stack shrinks under the new headline.
- "no paywalls / subscriptions / strings attached" → a row each; the string is cut on "attached".
- "a text file" → the file types its lines; "pay" → a "$30" tag; "free" → struck, "FREE" slams.
- "this format is called" → the beam enters the prism; "prism.md" → the fan opens.
- "comment PRISM … send it to you directly" → the comment box types it, the DM arrives, "sent".

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

- **Something arrives on almost every content word**: a headline word, an object, a tag, a row, a tick. A scene that only shows one card and holds it reads as lazy next to the reference.
- Nothing appears before its word, nor later than its word. Bring the first object in with the scene's first word, not a second later (an empty frame under a lone kicker is dead air).
- Vary the accents and the transitions (streak at the turns), and give every scene a camera move; punch in on hits.
- Elements float after they land; nothing is ever perfectly still. Inside a window, set `float: 0`.
- Keep the white flash for nothing or almost nothing: on this dark neon look it greys the whole frame.

## 8. Stills and the safe zone

`render.mjs --stills --at <times | beats | spread:8> --safe --sheet sheet` writes `my-video/stills/<t>.jpg` and a contact sheet. `--safe` draws the zone and prints any visible text outside it. `--scan` tests every visible text every 0.25 s without screenshots. **Take 12-20 stills on the key words** (every reveal, every scene's middle) before the full render. After encoding, review the film as contact sheets at 5 fps (`ffmpeg -ss 0 -t 10 -i my-video/video.mp4 -vf "fps=5,scale=144:256,tile=10x5" -frames:v 1 s0.png`, then `-ss 10`, `20`, `30`): that is where empty moments and late arrivals show.

## 9. The full render

`render.mjs --full` writes `my-video/frames/f00000.jpg …` (~150-300 ms a frame; a 40 s film takes ~4-6 minutes). It skips frames that exist, so split it (`--to 600`, `--from 600`) to stay under your tool timeout. After changing one scene, re-render just its frames: `--full --from <first> --to <last> --force`. If Chrome doesn't answer on its debug port ("Chrome devtools not reachable"), run the step again with `--port 9683`.

## 10. Sound

- **`sounds.mjs`** makes the 21 sounds, every one starting on its first sample and peaking at -1 dB: `boom bassdrop whoosh swoosh uipop uitick tick tick1 tick2 tick3 mouse send notif kaching error counter typing stopwatch cards riser sparkle`.
- **`bed.mjs`** makes `my-video/bed.wav`: a soft synthwave bed at 100 bpm (a detuned-saw pad on Am → F → C → G under a breathing low-pass, a plucked arpeggio with a dotted-eighth echo, a soft sub pulse, quiet off-beat hats).
- **`render.mjs --mix`** mixes the voice, the bed (ducked under the voice) and every `cue()`, fades the bed over the last 1.8 s, then brings it all to **-14 LUFS integrated, true peak ≤ -1 dBTP**. It prints how far the bed sits under the voice: the reference's bed is quiet, **~15-16 LU under** (`--music 0.085`, the default); ~13 LU is `--music 0.12`.
- **Cue a sound for every arrival**, softly (the reference is clean, not dense): ~120-130 cues in 40 s with `cue(name, t, gain, length)`:
  - every scene start: a `whoosh` or `swoosh` (0.06-0.1);
  - `uipop` on a tag or tile (0.06-0.08), a tick (`tick1/2/3`, 0.04-0.06) on rows, chips and slabs;
  - `typing` under typed text, `mouse` on clicks, `send` on a send, `notif` on the DM's "sent";
  - `kaching` on a price, `counter` under a count;
  - `bassdrop` (0.12-0.16) + `sparkle` on the big reveals ($0, FREE, the keyword), a `riser` 0.7 s before the name, `boom` (0.05-0.08) on it.
- `F.SFX_GAIN` (in `engine/fx.js`) trims the dense sounds for every cue. The sound should never cover a word.

## 11. Encode, check, deliver

`render.mjs --encode --out video.mp4 --cover 4.45` makes **H.264 High, yuv420p, 1080x1920, 30 fps, `-crf 20 -maxrate 10M -bufsize 20M`, AAC 192k, `+faststart`** (~16 MB for 40 s) and `cover.jpg` (pick the hook frame with the most on it). `check.mjs --video my-video --whisper` checks:
- the specs, and that frame 0 isn't black;
- 5 random frames with the safe box (`my-video/check/frames.jpg`): look at it;
- the words heard against the script's shown words (Whisper writes "$30" and ".md", which the check expects).

Deliver `video.mp4` and `cover.jpg`; posting is the user's call.

## 12. Checklist

- [ ] `setup.mjs` all ✓ · the script follows the pattern (section 4) · ≤ 60 s
- [ ] fake-timing stills right before any credit is spent · `gen-vo.mjs` dry run read, then `--yes` · `votight.mjs` run
- [ ] `bed.mjs` run after the real timings
- [ ] something arrives on almost every content word · every headline word on its word · every scene has an accent, a camera move and a whoosh · streaks at the turns
- [ ] `--scan` clean · stills on the key words right · full render
- [ ] mix at -14 LUFS, bed ~15-16 LU under the voice, no sound covering a word
- [ ] encode ≤ 60 s and ≤ 60 MB · `check.mjs` all ok · frames.jpg and the 5 fps sheets 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>prism</title>
<style>
@font-face { font-family: 'Inter Tight'; src: url(fonts/InterTight.ttf) format('truetype'); font-weight: 100 900; }
@font-face { font-family: 'JetBrains Mono'; src: url(fonts/JetBrainsMono.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/prism.js', 'engine/fx.js', VIDEO_DIR + '/scenes.js'])
    document.write(`<script src="${s}"><\/script>`);
</script>
</body>
</html>
```

### `engine/core.js`

```js
// Prism template · ENGINE CORE (grown from Stamp's core: the clock, scenes with a camera rig, cues, the safe zone; new: the
// synthwave night with its grid floor, per-scene neon accents, the light-streak transition and the glass caption pill).
// 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]).
// Load order (film.html): <video>/timeline.js → core.js → prism.js → fx.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 });

  // ---------- colours: the neon accents (name → hex) ----------
  const A = F.A = { cyan: '#3fe0ff', pink: '#ff4fb8', amber: '#ffc24d', violet: '#9b7bff', green: '#39f0a0', blue: '#4d7bff', white: '#eef0ff', red: '#ff4d6a' };
  // each accent's partner for gradients (headline key words, caption underlines)
  F.PAIR = { cyan: '#4d9bff', pink: '#c76bff', amber: '#ff8a3d', violet: '#5f8bff', green: '#3fe0ff', blue: '#9b7bff', white: '#b9b8f2', red: '#ff9a4d' };
  const hex = c => { c = A[c] || c; const n = parseInt(c.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
  F.rgb = c => hex(c).join(',');
  F.rgba = (c, a) => `rgba(${F.rgb(c)},${a})`;
  F.col = c => A[c] || c;

  // ---------- DOM ----------
  const h = (tag, attrs = {}, html) => {
    const e = document.createElement(tag);
    for (const [k, v] of Object.entries(attrs)) {
      if (k === 'style' && typeof v === 'object') Object.assign(e.style, v);
      else if (k === 'class') e.className = v;
      else e.setAttribute(k, v);
    }
    if (html !== undefined) e.innerHTML = html;
    return e;
  };
  const add = (p, ...k) => { k.forEach(x => p.appendChild(x)); return k[0]; };
  const box = (e, x, y, w, hh) => { if (x != null) e.style.left = px(x); if (y != null) e.style.top = px(y); if (w != null) e.style.width = px(w); if (hh != null) e.style.height = px(hh); return e; };
  const div = (p, cls, x, y, w, hh, html, style) => { const e = add(p, h('div', { class: cls }, html)); box(e, x, y, w, hh); if (style) Object.assign(e.style, style); return e; };
  // set(el, opacity, transform, blur): the one way to draw an element's state for a frame
  const set = (el, op, tr, bl = 0) => {
    el.style.opacity = op.toFixed(3);
    el.style.visibility = op < 0.004 ? 'hidden' : 'visible';
    if (tr != null) el.style.transform = tr;
    el.style.filter = bl > 0.3 ? `blur(${bl.toFixed(1)}px)` : 'none';
    return op;
  };
  // 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 img = (src, style = '') => `<img src="${src}" style="${style}" onerror="this.style.visibility='hidden'">`;
  Object.assign(F, { h, add, box, div, set, typeIn, html, 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 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>',
    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"`),
    XMARK: (s = 54, c = '#fff') => svg(s, '<path d="M6 6l12 12M18 6L6 18"/>', `fill="none" stroke="${c}" stroke-width="3.4" stroke-linecap="round"`),
    ARROW: (s = 44, c = '#fff') => svg(s, '<path d="M5 12h14M13 6l6 6-6 6"/>', `fill="none" stroke="${c}" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"`),
    LOCK: (s = 28, c = '#fff', open = false) => svg(s, `<rect x="5" y="11" width="14" height="10" rx="2.5"/><path d="${open ? 'M8 11V8a4 4 0 0 1 7.6-1.8' : 'M8 11V8a4 4 0 0 1 8 0v3'}"/>`, `fill="none" stroke="${c}" stroke-width="2.4" stroke-linecap="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>`,
  };
  Object.assign(F, { spark, svg }, G);

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

  // ---------- the night: sky, nebula, a glow behind the headline in the scene's accent, stars, the grid floor ----------
  F.HORIZON = 1150;           // the floor's horizon (y); scenes stand their objects on it
  const sky = div(stage, 'sky'); sky.style.inset = '0';
  const nebs = [
    { c: [107, 63, 209], a: 0.55, x: 230, y: 560, r: 760 }, { c: [26, 167, 184], a: 0.42, x: 840, y: 840, r: 680 },
    { c: [42, 63, 176], a: 0.42, x: 700, y: 330, r: 620 }, { c: [194, 40, 127], a: 0.24, x: 290, y: 1580, r: 680 },
    { c: [90, 40, 170], a: 0.14, x: 820, y: 1720, r: 560 },
  ].map((n, i) => ({ ...n, e: div(stage, 'neb', 0, 0, n.r, n.r * 0.8, '', { background: `radial-gradient(closest-side, rgba(${n.c},${n.a}), rgba(${n.c},0) 100%)` }), ph: i * 1.7 }));
  const gh = div(stage, 'glowhead');
  const stars = add(stage, h('canvas', { class: 'layer', width: 1080, height: 1920 })), sctx = stars.getContext('2d');
  const STARS = (() => { const r = rng(17); return Array.from({ length: 170 }, () => ({ x: r() * 1080, y: r() * 1900, s: 0.8 + Math.pow(r(), 3) * 2.4, ph: r() * TAU, sp: 0.6 + r() * 1.6, c: r() < 0.15 ? [120, 230, 255] : r() < 0.25 ? [255, 160, 220] : [235, 238, 255] })); })();
  const floor = add(stage, h('canvas', { class: 'layer', width: 1080, height: 1920 })), fctx = floor.getContext('2d');
  const fog = div(stage, 'floorfog');
  add(stage, h('div', { class: 'vignette' }));
  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()})`;
  }
  // the floor drifts toward the camera; F.floorSpeed (units/s) and the scene camera's tilt move it
  F.floorSpeed = 0.22;
  const drawNight = t => {
    const H = F.HORIZON;
    for (const n of nebs) { const x = n.x + Math.sin(t * 0.11 + n.ph) * 40, y = n.y + Math.cos(t * 0.09 + n.ph) * 30; n.e.style.transform = `translate(${(x - n.r / 2).toFixed(1)}px, ${(y - n.r * 0.4).toFixed(1)}px)`; }
    const [c0, c1, u] = accentMix(t), mix = hex(c0).map((v, i) => Math.round(lerp(v, hex(c1)[i], u)));
    gh.style.background = `radial-gradient(closest-side, rgba(${mix},0.30), rgba(${mix},0) 100%)`;
    // stars
    sctx.clearRect(0, 0, 1080, 1920);
    for (const s of STARS) {
      const y = s.y - t * 3, yy = ((y % 1920) + 1920) % 1920, x = s.x - t * 1.5, xx = ((x % 1080) + 1080) % 1080;
      const k = 0.35 + 0.65 * Math.pow(0.5 + 0.5 * Math.sin(t * s.sp * 2 + s.ph), 2), below = yy > H ? 0.35 : 1;
      sctx.fillStyle = `rgba(${s.c},${(k * below * 0.9).toFixed(3)})`; sctx.beginPath(); sctx.arc(xx, yy, s.s * (0.7 + 0.3 * k), 0, TAU); sctx.fill();
    }
    // the grid floor
    fctx.clearRect(0, 0, 1080, 1920);
    const fg = fctx.createLinearGradient(0, H, 0, 1920); fg.addColorStop(0, 'rgba(6,5,20,0.55)'); fg.addColorStop(0.3, 'rgba(6,5,20,0.45)'); fg.addColorStop(1, 'rgba(3,3,12,0.72)');
    fctx.fillStyle = fg; fctx.fillRect(0, H, 1080, 1920 - H);
    for (const [x, y, r, c, a] of [[330, H + 380, 520, '255,60,170', 0.30], [860, H + 260, 380, '40,200,230', 0.10]]) { const g = fctx.createRadialGradient(x, y, 0, x, y, r); g.addColorStop(0, `rgba(${c},${a})`); g.addColorStop(1, `rgba(${c},0)`); fctx.fillStyle = g; fctx.fillRect(0, H, 1080, 1920 - H); }
    fctx.lineWidth = 2;
    const FH = 1500, ph = (t * F.floorSpeed) % 1;
    for (let k = 0; k < 30; k++) {
      const z = 1.95 + k - ph; if (z <= 0.4) continue;
      const y = H + FH / z; if (y > 1930) continue;
      const a = Math.pow(clamp((y - H) / 520), 1.3) * 0.42;
      fctx.strokeStyle = `rgba(70,210,235,${a.toFixed(3)})`; fctx.beginPath(); fctx.moveTo(0, y); fctx.lineTo(1080, y); fctx.stroke();
    }
    for (let i = -7; i <= 7; i++) {
      const xb = 540 + i * 330, g = fctx.createLinearGradient(0, H, 0, 1920);
      g.addColorStop(0, 'rgba(70,210,235,0)'); g.addColorStop(0.2, 'rgba(70,210,235,0.14)'); g.addColorStop(1, 'rgba(70,210,235,0.40)');
      fctx.strokeStyle = g; fctx.beginPath(); fctx.moveTo(540 + i * 6, H); fctx.lineTo(xb, 1920); fctx.stroke();
    }
    const hz = fctx.createLinearGradient(0, 0, 1080, 0); hz.addColorStop(0, 'rgba(255,79,184,0.05)'); hz.addColorStop(0.35, 'rgba(255,79,184,0.5)'); hz.addColorStop(0.65, 'rgba(63,224,255,0.45)'); hz.addColorStop(1, 'rgba(63,224,255,0.05)');
    fctx.fillStyle = hz; fctx.fillRect(0, H - 1, 1080, 2.5);
    fog.style.top = px(H - 60);
    const k = Math.floor(t * 24), r = rng(k * 7 + 3);
    grain.style.backgroundPosition = `${Math.floor(r() * 64)}px ${Math.floor(r() * 64)}px`;
  };

  // ---------- scenes: one container each, a camera rig, transitions ----------
  // F.scene(tin, tout, { in, out, accent, push, sy })
  //   in:  'blur' (default: from a soft blur) · 'streak' (opens out of a horizontal light line) · 'up' (rises in) · 'zoom' (flies in) · 'cut'
  //   out: 'blur' (default) · 'streak' (collapses into a light line that flashes across the frame) · 'up' · 'zoom' (into the camera) · 'cut'
  //   accent: the scene's neon (cyan · pink · amber · violet · green · blue): the glow behind the headline and the caption underline
  //   sy: the y the streak collapses to (default 760). Each scene has a camera rig: F.cam(sc, [[t, scale, x, y, rot], …]),
  //   scaled around (510, 330), the headline line: a push grows the frame downward and keeps the headline in the zone.
  const scenesRoot = add(stage, h('div', { class: 'layer' }));
  const scenes = F.SCENES = [];
  F.scene = (tin, tout, o = {}) => {
    const box = add(scenesRoot, h('div', { class: 'scene' }));
    const el = add(box, h('div', { class: 'rig' }));
    const sc = { box, el, tin, tout, o, upd: [], cam: null, seed: scenes.length * 1.7 + 0.4 };
    sc.on = f => sc.upd.push(f);
    scenes.push(sc); return sc;
  };
  F.cam = (sc, keys) => { sc.cam = keys; };
  const camAt = (keys, t) => {
    if (!keys || !keys.length) return [1, 0, 0, 0];
    if (t <= keys[0][0]) return keys[0].slice(1);
    for (let i = 1; i < keys.length; i++) if (t < keys[i][0]) { const u = eio((t - keys[i - 1][0]) / (keys[i][0] - keys[i - 1][0])); return [1, 2, 3, 4].map(j => lerp(keys[i - 1][j] ?? (j === 1 ? 1 : 0), keys[i][j] ?? (j === 1 ? 1 : 0), u)); }
    return keys.at(-1).slice(1);
  };
  // the accent at time t: [previous, current, mix] (a 0.4 s crossfade at each scene start)
  const accentMix = t => {
    let cur = null, prev = null;
    for (const s of scenes) if (s.tin <= t + 0.001) { prev = cur; cur = s; }
    const c1 = cur?.o.accent || 'violet', c0 = prev?.o.accent || c1;
    return [c0, c1, cur ? clamp((t - Math.max(0, cur.tin)) / 0.4) : 1];
  };
  F.accentAt = t => accentMix(t)[1];
  const streakEl = div(stage, 'streak'), streaks = [];
  const LEN = { blur: 0.26, streak: 0.3, up: 0.32, zoom: 0.38, cut: 0.01 };
  const drawScene = (sc, t) => {
    const { box, el, tin, tout, o } = sc;
    const kin = o.in || 'blur', kout = o.out || 'blur', inLen = o.inD || LEN[kin] || 0.28, outLen = o.outD || LEN[kout] || 0.26;
    if (t < tin - 0.02 || t > tout + outLen + 0.02) { if (box.style.display !== 'none') box.style.display = 'none'; return false; }
    box.style.display = 'block';
    const ai = tin <= 0 ? 1 : clamp((t - tin) / inLen), ao = clamp((t - tout) / outLen);
    // the slow push lives on the rig (anchored on the headline line, so it never lifts the headline out of the zone);
    // the box only does the transitions, around the streak line (streak) or the middle of the frame (the others)
    const push = 1 + (o.push ?? 0.025) * clamp((t - tin) / Math.max(1, tout - tin));
    let s = 1, x = 0, y = 0, op = 1, bl = 0, sy = 1, br = 1;
    const SY = o.sy ?? 760, kind = ai < 1 ? kin : kout;
    if (kin === 'blur') { const a = eo(ai); s *= 0.95 + 0.05 * a; op *= a; bl += (1 - a) * 16; }
    else if (kin === 'streak') { const a = eo(ai); sy = 0.02 + 0.98 * a; op *= clamp(ai * 3); bl += (1 - a) * 10; br = 1 + (1 - a) * 1.5; }
    else if (kin === 'up') { const a = eo(ai); y += (1 - a) * 260; op *= clamp(ai * 2.5); bl += (1 - a) * 22; }
    else if (kin === 'zoom') { const a = eo(ai); s *= 0.55 + 0.45 * a; op *= clamp(ai * 2.6); bl += (1 - a) * 20; }
    if (kout === 'blur') { const z = eo(ao); s *= 1 + 0.05 * z; op *= 1 - z; bl += z * 16; }
    else if (kout === 'streak') { const z = eio(ao); sy *= 1 - 0.98 * z; br += z * 2; op *= 1 - clamp((ao - 0.7) / 0.3); bl += z * 4; }
    else if (kout === 'up') { const z = eio(ao); y -= z * 300; op *= 1 - z; bl += z * 22; }
    else if (kout === 'zoom') { const z = eio(ao); s *= 1 + 3 * z; op *= 1 - clamp((ao - 0.3) / 0.7); bl += z * 14; }
    else if (kout === 'cut') { op *= ao < 0.5 ? 1 : 0; }
    box.style.transformOrigin = `510px ${kind === 'streak' ? SY : 560}px`;
    box.style.opacity = op.toFixed(3); box.style.visibility = op < 0.004 ? 'hidden' : 'visible';
    box.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) scale(${s.toFixed(4)}, ${(s * sy).toFixed(4)})`;
    const f = []; if (bl > 0.3) f.push(`blur(${bl.toFixed(1)}px)`); if (br > 1.01) f.push(`brightness(${br.toFixed(2)})`);
    box.style.filter = f.length ? f.join(' ') : 'none';
    const [cs, cx, cy, cr] = camAt(sc.cam, t), ph = sc.seed;
    const dx = Math.sin(t * 0.55 + ph) * 4, dy = Math.cos(t * 0.43 + ph * 1.3) * 3, dr = Math.sin(t * 0.37 + ph) * 0.12;
    el.style.transform = `translate(${(cx + dx).toFixed(1)}px, ${(cy + dy).toFixed(1)}px) rotate(${(cr + dr).toFixed(3)}deg) scale(${(cs * push).toFixed(4)})`;
    return true;
  };
  const drawStreak = t => {
    let best = 0, yy = 760;
    for (const sc of scenes) {
      if (sc.o.out === 'streak') { const u = (t - sc.tout) / 0.42; if (u > 0 && u < 1) { const k = Math.sin(u * Math.PI) * (u < 0.55 ? 1 : 1 - (u - 0.55) / 0.45 * 0.6); if (k > best) { best = k; yy = sc.o.sy ?? 760; } } }
      if (sc.o.in === 'streak' && sc.tin > 0) { const u = (t - sc.tin + 0.08) / 0.3; if (u > 0 && u < 1) { const k = (1 - u) * 0.9; if (k > best) { best = k; yy = sc.o.sy ?? 760; } } }
    }
    for (const [t0, y0, pk] of streaks) { const u = (t - t0) / 0.4; if (u > 0 && u < 1) { const k = pk * Math.sin(u * Math.PI); if (k > best) { best = k; yy = y0; } } }
    streakEl.style.opacity = best.toFixed(3); streakEl.style.top = px(yy - 3);
    streakEl.style.transform = `scaleX(${(0.6 + 0.4 * best).toFixed(3)}) scaleY(${(0.6 + 0.8 * best).toFixed(3)})`;
  };
  F.streak = (t, y = 760, peak = 1) => streaks.push([t, y, peak]);   // a light line across the frame at y (a hit, a reveal)

  // ---------- 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.5) => flashes.push([t, peak]);
  F.shake = (t, amp = 12, dur = 0.3) => shakes.push([t, amp, dur]);
  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: a glass pill of 2–3 words at a time, the word being said white and underlined ----------
  // Chunks follow the script's / breaks, at most F.cap.max words (3) and F.cap.chars characters (22), and end after , . ? !
  // F.cap.hide = (token, section) => true drops a word; F.cap.y moves the pill (default 1356, inside the RULES band).
  F.cap = { hide: null, max: 3, chars: 22, y: 1356 };
  const capRoot = add(stage, h('div', { class: 'over' }));
  const chunks = [];
  const buildCaptions = () => {
    if (F.NOCAP) return;
    for (const s of TL.sections) {
      let cur = null;
      const toks = s.tokens.filter(tk => !(F.cap.hide && F.cap.hide(tk, s)));
      toks.forEach((tk, i) => {
        const len = cur ? cur.toks.reduce((a, k) => a + k.d.length + 1, 0) + tk.d.length : 0;
        const prevEnds = cur && /[,.!?;:]$/.test(cur.toks.at(-1).d);
        if (!cur || tk.br || prevEnds || cur.toks.length >= F.cap.max || len > F.cap.chars) { cur = { s, toks: [] }; chunks.push(cur); }
        cur.toks.push(tk);
      });
    }
    chunks.forEach((c, i) => {
      const nx = chunks[i + 1]; c.t0 = c.toks[0].t0 - 0.06;
      c.t1 = nx ? Math.min(nx.toks[0].t0 - 0.06, c.toks.at(-1).t1 + 0.7) : Math.min(TL.total, c.toks.at(-1).t1 + 1.2);
      c.el = add(capRoot, h('div', { class: 'cap' }));
      c.el.style.top = px(F.cap.y);
      c.w = c.toks.map(tk => { const sp = add(c.el, h('span', { class: 'w' })); sp.appendChild(document.createTextNode(tk.d)); const ul = add(sp, h('span', { class: 'ul' })); return { tk, sp, ul }; });
    });
  };
  const layoutCaptions = () => { for (const c of chunks) { c.el.style.display = 'inline-flex'; c.wd = c.el.offsetWidth; c.el.style.left = px(510 - c.wd / 2); c.el.style.display = 'none'; } };
  const drawCaptions = t => {
    for (const c of chunks) {
      if (t < c.t0 || t >= c.t1) { if (c.el.style.display !== 'none') c.el.style.display = 'none'; continue; }
      c.el.style.display = 'inline-flex';
      const a = eo((t - c.t0) / 0.12), q = c.t1 - t < 0.08 && c.t1 < c.toks.at(-1).t1 + 0.69 ? 1 : eo((c.t1 - t) / 0.1);
      const sc = 0.9 + 0.1 * back((t - c.t0) / 0.18);
      c.el.style.opacity = Math.min(a, q).toFixed(3);
      c.el.style.transform = `translate(${(c.wd * (1 - sc) / 2).toFixed(1)}px, ${((1 - a) * 10).toFixed(1)}px) scale(${sc.toFixed(4)})`;
      c.el.style.filter = a < 0.97 ? `blur(${((1 - a) * 6).toFixed(1)}px)` : 'none';
      const ac = F.accentAt(c.toks[0].t0 + 0.01), grad = `linear-gradient(90deg, ${F.col(ac)}, ${F.PAIR[ac] || '#c76bff'})`;
      let cur = -1; c.w.forEach((w, j) => { if (t >= w.tk.t0 - 0.03) cur = j; });
      c.w.forEach((w, j) => {
        w.sp.classList.toggle('on', j <= cur);
        if (j === cur) { const u = eo((t - w.tk.t0 + 0.03) / 0.14); w.ul.style.opacity = '1'; w.ul.style.background = grad; w.ul.style.boxShadow = `0 0 10px ${F.rgba(ac, 0.7)}`; w.ul.style.transform = `scaleX(${u.toFixed(3)})`; }
        else w.ul.style.opacity = '0';
      });
    }
  };

  // ---------- 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;
      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 this film shows of itself (render.mjs --prep; unused by the example) ----------
  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 () => {
    buildCaptions();
    if (F.SAFE) buildSafe();
    window.TOTAL = TL.total;
    window.BEATS = scenes.map(s => [Math.max(0, s.tin), Math.min(TL.total, s.tout)]);
    window.CUES = F.CUES;
    window.LOOKS = [];
    window.PREP = F.PREP;
    window.setT = async t => {
      drawFx(t);
      drawNight(t);
      for (const s of scenes) if (drawScene(s, t)) for (const u of s.upd) u(t);
      drawStreak(t);
      for (const u of updaters) await u(t);
      drawCaptions(t);
      await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
      return true;
    };
    await document.fonts.ready;
    layoutCaptions();
    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/prism.js`

```js
// Prism template · THE PARTS. Every glass-neon object the format draws, on the 1080x1920 canvas (text inside x 60–960,
// y 250–1480, centred on x 510). A scene composes them and animates what they return in sc.on(t => …), usually with
// F.enter (fx.js). Colours are accent names (cyan · pink · amber · violet · green · blue) or any #hex.
// P.anchor(p, cx, cy) is the pattern for centred things: an outer point that F.enter moves, the content centred on it.
(() => {
  const { h, add, div, px, rng, clamp, lerp, eo, eio, back, TAU, rgba, col, esc } = F;
  const P = F.parts = {};
  const M = P.M = { ...F.A, ink: '#f2f4ff', dim: '#7d82a6', mag2: F.A.pink };
  const pair = c => F.PAIR[c] || '#c76bff';

  // ---------- icons: 24×24 line glyphs (stroke = currentColor) ----------
  const I = P.ICON = {
    reel: '<rect x="4" y="3" width="16" height="18" rx="3"/><path d="M10 9l5 3-5 3z" fill="currentColor"/>',
    bolt: '<path d="M13 2L4.5 13.5H11L10 22l8.5-11.5H12z" fill="currentColor" stroke="none"/>',
    book: '<path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H20v15H6.5A2.5 2.5 0 0 0 4 20.5z"/><path d="M4 20.5A2.5 2.5 0 0 1 6.5 18H20v3H6.5"/>',
    cut: '<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"/>',
    lock: '<rect x="5" y="11" width="14" height="10" rx="2.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/>',
    unlock: '<rect x="5" y="11" width="14" height="10" rx="2.5"/><path d="M8 11V8a4 4 0 0 1 7.6-1.8"/>',
    card: '<rect x="3" y="5" width="18" height="14" rx="2.5"/><path d="M3 10h18M7 15h4"/>',
    link: '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>',
    file: '<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4M9 12h6M9 16h6"/>',
    chat: '<path d="M4 5h16v11H9l-5 4z"/>',
    grid: '<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>',
    box: '<path d="M3 7.5L12 3l9 4.5v9L12 21l-9-4.5z"/><path d="M3 7.5l9 4.5 9-4.5M12 12v9"/>',
    tag: '<path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="8.5" r="1.6"/>',
    cart: '<path d="M3 4h3l2.4 11h10l2-8H7"/><circle cx="10" cy="19.5" r="1.4"/><circle cx="17" cy="19.5" r="1.4"/>',
    star: '<path d="M12 3l2.8 5.8 6.2.9-4.5 4.4 1 6.2L12 17.4 6.5 20.3l1-6.2L3 9.7l6.2-.9z" fill="currentColor" stroke="none"/>',
    spark: '<path d="M12 3v5M12 16v5M3 12h5M16 12h5M6 6l3 3M15 15l3 3M18 6l-3 3M9 15l-3 3"/>',
    terminal: '<rect x="3" y="4" width="18" height="16" rx="2.5"/><path d="M7 9l3 3-3 3M12.5 15H17"/>',
    globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>',
    cal: '<rect x="3.5" y="5" width="17" height="15" rx="2.5"/><path d="M3.5 10h17M8 3v4M16 3v4"/>',
    send: '<path d="M21 3L3 10.5l7.5 3L13.5 21z"/><path d="M10.5 13.5L21 3"/>',
  };
  P.icon = (k, s = 40, c = 'currentColor', w = 2.2) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round" style="display:block;color:${c}">${I[k] || k}</svg>`;
  // a white rounded badge with a coloured glyph (the app-icon look of the reference's LinkedIn / YouTube squares)
  P.badge = (k, s = 76, c = 'cyan') => `<div style="width:${s}px;height:${s}px;border-radius:${s * 0.24}px;background:linear-gradient(180deg,#ffffff,#e9ecff);display:flex;align-items:center;justify-content:center;box-shadow:0 6px 18px rgba(0,0,0,0.35), inset 0 -2px 0 rgba(0,0,0,0.08);flex:none">${P.icon(k, s * 0.56, col(c), 2.4)}</div>`;

  // ---------- containers ----------
  // P.anchor(p, cx, cy) → { el, in }: el sits at (cx, cy) (move it with F.enter); put content in `in`, which is centred on it
  P.anchor = (p, cx, cy, z) => {
    const el = div(p, 'abs', cx, cy, 0, 0, '', z != null ? { zIndex: z } : null);
    const inn = add(el, h('div', { style: { position: 'absolute', left: '0', top: '0', transform: 'translate(-50%, -50%)' } }));
    return { el, in: inn };
  };
  // P.glass(p, x, y, w, h, { c, c2, r, tint, glow, solid }) → { el, fill, in }: a neon rim (c → c2) around a frosted fill.
  //   tint: how much of the colour the fill takes (0.22) · solid: a saturated fill (the keyword slab, app tiles)
  P.glass = (p, x, y, w, hh, o = {}) => {
    const c = o.c || 'cyan', c2 = o.c2 || pair(c), r = o.r ?? 30, tint = o.tint ?? 0.22, g = o.glow ?? 1;
    const el = div(p, 'glass', x, y, w, hh, '', {
      borderRadius: px(r), background: `linear-gradient(135deg, ${rgba(c, 0.95)} 0%, ${rgba(c2, 0.85)} 100%)`,
      boxShadow: `0 0 ${22 * g}px ${rgba(c, 0.55 * g)}, 0 0 ${70 * g}px ${rgba(c, 0.2 * g)}, 0 34px 70px rgba(0,0,0,0.45)`,
    });
    const fill = add(el, h('div', { class: 'fill' }));
    fill.style.borderRadius = px(r - 2.5);
    fill.style.background = o.solid
      ? `linear-gradient(165deg, ${rgba(c, 0.95)} 0%, ${rgba(c, 0.7)} 45%, ${rgba(c2, 0.75)} 100%)`
      : `linear-gradient(160deg, ${rgba(c, tint + 0.06)} 0%, rgba(26,30,86,0.9) 38%, rgba(18,16,54,0.94) 72%, ${rgba(c2, tint)} 100%)`;
    fill.style.boxShadow = `inset 0 0 28px ${rgba(c, 0.3)}, inset 0 1.5px 0 rgba(255,255,255,0.4)`;
    if (!o.solid) { // the reference's "liquid" light inside the glass
      add(fill, h('div', { style: { position: 'absolute', left: '-10%', top: '30%', width: '70%', height: '90%', borderRadius: '50%', background: `radial-gradient(closest-side, ${rgba(c, 0.25)}, transparent)`, filter: 'blur(14px)' } }));
      add(fill, h('div', { style: { position: 'absolute', right: '-12%', top: '-30%', width: '60%', height: '90%', borderRadius: '50%', background: `radial-gradient(closest-side, ${rgba(c2, 0.22)}, transparent)`, filter: 'blur(16px)' } }));
    }
    const inn = add(el, h('div', { class: 'in' }));
    inn.style.borderRadius = px(r - 2.5);
    return { el, fill, in: inn };
  };
  // P.tag(p, x, y, text, c): a dark pill with a neon rim and a glowing dot ("• free"). P.tagC centres it on (cx, cy).
  P.tag = (p, x, y, text, c = 'violet') => { const e = div(p, 'tag', x, y, null, null, `<span class="d"></span>${text}`); e.style.setProperty('--c', col(c)); return e; };
  P.tagC = (p, cx, cy, text, c = 'violet') => { const a = P.anchor(p, cx, cy); const e = P.tag(a.in, 0, 0, text, c); e.style.position = 'relative'; return { el: a.el, tag: e }; };

  // ---------- the headline ----------
  // P.head(p, y, text, { at, size, accent, kicker, kat, lh }) → { el, words, update(t) }
  //   text: lines split by \n; *word* (or *two_words*) = the accent's neon gradient. at: the time of the first word (the others
  //   follow 0.1 s apart) or one time per word. Each word blurs in from the right like the reference's smear.
  //   kicker: a small mono line above (typed in from kat, default at − 0.2). grad: a CSS gradient for the key words instead.
  P.head = (p, y, text, o = {}) => {
    const size = o.size || 104, ac = o.accent || 'cyan';
    const el = div(p, 'head', 60, y, 900, null); el.style.fontSize = px(size); el.style.lineHeight = String(o.lh || 0.98);
    const words = [];
    text.split('\n').forEach(line => {
      const ln = add(el, h('span', { class: 'ln' }));
      line.split(' ').filter(Boolean).forEach((w, i, arr) => {
        const key = /^\*.*\*[^*]*$/.test(w) || w.startsWith('*');
        const shown = w.replace(/\*/g, '').replace(/_/g, ' ');
        const sp = add(ln, h('span', { class: 'hw' }, esc(shown)));
        if (key) sp.style.backgroundImage = o.grad || `linear-gradient(180deg, #ffffff -30%, ${col(o.keyc || ac)} 45%, ${pair(o.keyc || ac)} 115%)`;
        if (i < arr.length - 1) ln.appendChild(document.createTextNode(' '));
        words.push(sp);
      });
    });
    const at = Array.isArray(o.at) ? o.at : words.map((_, i) => (o.at ?? 0) + i * (o.gap ?? 0.1));
    let kick = null;
    if (o.kicker) { kick = div(p, 'kicker', 60, y - 52, 900, null); kick.style.top = px(y - (o.kgap ?? 40)); }
    const kat = o.kat ?? (at[0] - 0.2);
    return {
      el, words, kick, update: t => {
        words.forEach((w, i) => {
          const a = clamp((t - at[i]) / 0.3), e = eo(a);
          w.style.opacity = clamp(a * 2.2).toFixed(3); w.style.visibility = a <= 0 ? 'hidden' : 'visible';
          w.style.transform = `translateX(${((1 - e) * 46).toFixed(1)}px) scaleX(${(1 + (1 - e) * 0.28).toFixed(3)})`;
          w.style.filter = e < 0.98 ? `blur(${((1 - e) * 16).toFixed(1)}px)` : 'none';
        });
        if (kick) { const n = Math.round(clamp((t - kat) / 0.28) * o.kicker.length); F.html(kick, esc(o.kicker.slice(0, n))); kick.style.opacity = t < kat ? 0 : 1; }
      },
    };
  };
  // P.big(p, cx, cy, text, { size, c }) → { el, txt }: a giant neon gradient word or number ("$0", "40")
  P.big = (p, cx, cy, text, o = {}) => {
    const a = P.anchor(p, cx, cy), c = o.c || 'green';
    const t = add(a.in, h('div', { style: { fontSize: px(o.size || 200), fontWeight: 800, letterSpacing: '-0.045em', lineHeight: 1, whiteSpace: 'nowrap', background: `linear-gradient(180deg, #ffffff -20%, ${col(c)} 50%, ${pair(c)} 120%)`, webkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent', filter: `drop-shadow(0 0 26px ${rgba(c, 0.65)})`, padding: '0.05em 0.04em 0.12em' } }, esc(text)));
    return { el: a.el, txt: t };
  };

  // ---------- 3D glass ----------
  const faceCss = (c, k) => `background:linear-gradient(160deg, ${rgba(c, 0.55 * k)}, ${rgba(c, 0.3 * k)} 60%, ${rgba(pair(c), 0.34 * k)});border:2.5px solid ${rgba(c, 0.95)};box-shadow:0 0 18px ${rgba(c, 0.55)}, inset 0 0 22px ${rgba(c, 0.35)}, inset 0 1.5px 0 rgba(255,255,255,0.35);`;
  // P.slab(p, cx, cy, w, h, d, { c, label, num, rx, ry }) → { el, box }: a glass bar (front, top and right end; reference 1's blocks)
  P.slab = (p, cx, cy, w, hh, d, o = {}) => {
    const c = o.c || 'cyan', a = P.anchor(p, cx, cy);
    const box = add(a.in, h('div', { class: 'p3', style: { position: 'relative', width: px(w), height: px(hh), transform: `perspective(1700px) rotateX(${o.rx ?? -16}deg) rotateY(${o.ry ?? -20}deg)` } }));
    const f = (cls, fw, fh, tr, k, inner = '') => add(box, h('div', { class: 'face', style: `left:${(w - fw) / 2}px;top:${(hh - fh) / 2}px;width:${fw}px;height:${fh}px;border-radius:12px;transform:${tr};${faceCss(c, k)}` }, inner));
    f('top', w, d, `translateY(${-hh / 2}px) rotateX(90deg)`, 1.25);
    f('right', d, hh, `translateX(${w / 2}px) rotateY(90deg)`, 0.8);
    f('front', w, hh, `translateZ(${d / 2}px)`, 1, `<div style="position:absolute;inset:0;display:flex;align-items:center;gap:16px;padding:0 26px;white-space:nowrap"><span class="mono" style="font-size:${hh * 0.26}px;color:rgba(255,255,255,0.6);font-weight:700">${o.num || ''}</span><span style="font-size:${hh * 0.5}px;font-weight:750;letter-spacing:-0.02em;color:#fff;text-shadow:0 0 12px ${rgba(c, 0.8)}">${esc(o.label || '')}</span><span style="margin-left:auto;width:${hh * 0.2}px;height:${hh * 0.2}px;border-radius:50%;background:${col(c)};box-shadow:0 0 12px ${col(c)}, 0 0 4px #fff"></span></div>`);
    return { el: a.el, box };
  };
  // P.cube(p, cx, cy, s, { c, glyph, spin, tilt }) → { el, update(t) }: a rotating glass cube with a glyph on its faces
  P.cube = (p, cx, cy, s, o = {}) => {
    const c = o.c || 'cyan', a = P.anchor(p, cx, cy);
    const box = add(a.in, h('div', { class: 'p3', style: { position: 'relative', width: px(s), height: px(s) } }));
    const g = o.glyph || '>_';
    const glyph = `<div style="position:absolute;inset:0;backface-visibility:hidden;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-weight:800;font-size:${s * 0.3}px;color:#eaffff;text-shadow:0 0 14px ${col(c)}">${g}</div>`;
    const T = [`translateZ(${s / 2}px)`, `rotateY(180deg) translateZ(${s / 2}px)`, `rotateY(90deg) translateZ(${s / 2}px)`, `rotateY(-90deg) translateZ(${s / 2}px)`, `rotateX(90deg) translateZ(${s / 2}px)`, `rotateX(-90deg) translateZ(${s / 2}px)`];
    T.forEach((tr, i) => add(box, h('div', { class: 'face', style: `left:0;top:0;width:${s}px;height:${s}px;border-radius:${s * 0.16}px;transform-style:preserve-3d;transform:${tr};${faceCss(c, i > 3 ? 1.2 : 0.9)}` }, i < 4 ? glyph : '')));
    return { el: a.el, box, update: t => { box.style.transform = `perspective(1500px) rotateX(${(o.tilt ?? -18) + Math.sin(t * 0.8) * 4}deg) rotateY(${(o.y0 ?? 30) + t * (o.spin ?? 40)}deg)`; } };
  };
  // P.orb(p, cx, cy, r, { c, c2 }) → { el, update(t) }: a wireframe globe with lit dots and a tilted orbit ring (a canvas)
  P.orb = (p, cx, cy, r, o = {}) => {
    const c = F.rgb(o.c || 'cyan'), c2 = F.rgb(o.c2 || 'pink'), W = r * 2 + 160;
    const cv = add(p, h('canvas', { width: W, height: W, style: `position:absolute;left:${cx - W / 2}px;top:${cy - W / 2}px;filter:drop-shadow(0 0 10px rgba(${c},0.7))` })), g = cv.getContext('2d');
    const R = rng(23), dots = Array.from({ length: 16 }, () => ({ la: (R() - 0.5) * 2.6, lo: R() * TAU, k: ['63,224,255', '255,79,184', '255,194,77', '57,240,160'][Math.floor(R() * 4)] }));
    return {
      el: cv, update: t => {
        g.clearRect(0, 0, W, W); const X = W / 2, Y = W / 2, rot = t * 0.5, tilt = 0.32;
        g.lineWidth = 2;
        for (let i = 0; i < 9; i++) { // meridians
          const a = rot + i * Math.PI / 9, k = Math.cos(a);
          g.strokeStyle = `rgba(${c},${(0.25 + 0.45 * Math.abs(Math.sin(a))).toFixed(3)})`; g.beginPath(); g.ellipse(X, Y, Math.abs(k) * r + 0.5, r, 0, 0, TAU); g.stroke();
        }
        for (let j = -3; j <= 3; j++) { // latitudes
          const la = j * 0.4, yy = Y + Math.sin(la) * r, rr = Math.cos(la) * r;
          g.strokeStyle = `rgba(${c},0.35)`; g.beginPath(); g.ellipse(X, yy, rr, rr * tilt, 0, 0, TAU); g.stroke();
        }
        g.strokeStyle = `rgba(${c},0.95)`; g.lineWidth = 3; g.beginPath(); g.arc(X, Y, r, 0, TAU); g.stroke();
        for (const d of dots) { const lo = d.lo + rot, x = X + Math.cos(d.la) * Math.sin(lo) * r, y = Y + Math.sin(d.la) * r * 0.95, z = Math.cos(lo); g.fillStyle = `rgba(${d.k},${z > 0 ? 1 : 0.3})`; g.beginPath(); g.arc(x, y, z > 0 ? 7 : 4.5, 0, TAU); g.fill(); }
        g.save(); g.translate(X, Y); g.rotate(-0.22); g.strokeStyle = `rgba(${c2},0.8)`; g.lineWidth = 3; g.beginPath(); g.ellipse(0, 0, r * 1.22, r * 0.3, 0, 0, TAU); g.stroke(); g.restore();
      },
    };
  };
  // P.rings(p, cx, cy, rx, { c, n, ry }) → { el, update(t) }: glowing ellipses on the floor that breathe outward
  P.rings = (p, cx, cy, rx, o = {}) => {
    const n = o.n || 3, ry = o.ry || rx * 0.24, c = col(o.c || 'cyan'), W = rx * 2 + 60, H = ry * 2 + 60;
    const el = div(p, 'abs', cx - W / 2, cy - H / 2, W, H, `<svg width="${W}" height="${H}" style="overflow:visible">${Array.from({ length: n }, (_, i) => `<ellipse cx="${W / 2}" cy="${H / 2}" rx="${rx}" ry="${ry}" fill="none" stroke="${c}" stroke-width="${i ? 2.5 : 4}" style="filter:drop-shadow(0 0 8px ${c})"/>`).join('')}</svg>`);
    const es = [...el.querySelectorAll('ellipse')];
    return { el, update: t => es.forEach((e, i) => { const u = ((t * 0.35 + i / n) % 1); const k = 0.35 + 0.65 * u; e.setAttribute('rx', (rx * k).toFixed(1)); e.setAttribute('ry', (ry * k).toFixed(1)); e.style.opacity = (Math.sin(u * Math.PI) * 0.95).toFixed(3); }) };
  };
  // P.prism(p, cx, cy, s) → { el, update(t, tBeam, tFan) }: a glass prism: a white beam in from the left, a neon fan out to the right
  P.prism = (p, cx, cy, s) => {
    const W = 1080, H = s * 2.2, x0 = cx - W / 2 + 0, y0 = cy - H / 2;
    const el = div(p, 'abs', cx - W / 2, y0, W, H);
    const X = W / 2, Y = H / 2, hgt = s * 0.95, A = [X, Y - hgt * 0.62], B = [X - s * 0.56, Y + hgt * 0.38], C = [X + s * 0.56, Y + hgt * 0.38], d = [s * 0.16, -s * 0.12];
    const pt = q => q.map(v => v.toFixed(1)).join(','), off = q => [q[0] + d[0], q[1] + d[1]];
    const hit = [lerp(A[0], B[0], 0.5), lerp(A[1], B[1], 0.5)], out = [lerp(A[0], C[0], 0.55), lerp(A[1], C[1], 0.55)];
    const fan = ['#ff4fb8', '#ffc24d', '#39f0a0', '#3fe0ff', '#9b7bff'];
    el.innerHTML = `<svg width="${W}" height="${H}" style="overflow:visible"><defs>
      <linearGradient id="pg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="rgba(160,240,255,0.55)"/><stop offset="0.5" stop-color="rgba(120,110,255,0.22)"/><stop offset="1" stop-color="rgba(255,79,184,0.4)"/></linearGradient>
      <linearGradient id="beam" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="rgba(255,255,255,0)"/><stop offset="1" stop-color="#ffffff"/></linearGradient></defs>
      <line class="bm" x1="${-60}" y1="${(hit[1] + 70).toFixed(1)}" x2="${pt(hit).split(',')[0]}" y2="${hit[1].toFixed(1)}" stroke="url(#beam)" stroke-width="10" stroke-linecap="round" style="filter:drop-shadow(0 0 10px #fff) drop-shadow(0 0 22px rgba(200,200,255,0.9))" pathLength="100" stroke-dasharray="100" stroke-dashoffset="100"/>
      <g class="fan">${fan.map((c, i) => `<polygon class="fb" points="${pt(out)} ${W + 80},${(out[1] - 260 + i * 120).toFixed(1)} ${W + 80},${(out[1] - 200 + i * 120).toFixed(1)}" fill="${c}" opacity="0" style="filter:drop-shadow(0 0 12px ${c})"/>`).join('')}</g>
      <polygon points="${pt(off(A))} ${pt(off(B))} ${pt(off(C))}" fill="rgba(110,120,255,0.12)" stroke="rgba(170,190,255,0.55)" stroke-width="2.5"/>
      <polygon points="${pt(A)} ${pt(off(A))} ${pt(off(C))} ${pt(C)}" fill="rgba(255,79,184,0.18)" stroke="rgba(255,140,210,0.7)" stroke-width="2.5"/>
      <polygon points="${pt(B)} ${pt(off(B))} ${pt(off(C))} ${pt(C)}" fill="rgba(63,224,255,0.14)" stroke="rgba(120,230,255,0.6)" stroke-width="2.5"/>
      <polygon class="front" points="${pt(A)} ${pt(B)} ${pt(C)}" fill="url(#pg)" stroke="#dff7ff" stroke-width="4" stroke-linejoin="round" style="filter:drop-shadow(0 0 14px rgba(140,230,255,0.9))"/>
      <polygon points="${pt([lerp(A[0], B[0], 0.2), lerp(A[1], B[1], 0.2)])} ${pt([lerp(A[0], B[0], 0.75), lerp(A[1], B[1], 0.75)])} ${pt([lerp(A[0], C[0], 0.2) - 10, lerp(A[1], C[1], 0.2) + 30])}" fill="rgba(255,255,255,0.22)"/>
    </svg>`;
    const bm = el.querySelector('.bm'), fb = [...el.querySelectorAll('.fb')], fr = el.querySelector('.front');
    return {
      el, update: (t, tBeam, tFan) => {
        bm.setAttribute('stroke-dashoffset', (100 * (1 - eo((t - tBeam) / 0.35))).toFixed(1));
        fb.forEach((f, i) => { const u = eo((t - tFan - i * 0.05) / 0.35); f.setAttribute('opacity', (u * (0.75 + 0.1 * Math.sin(t * 3 + i))).toFixed(3)); f.style.transformOrigin = `${out[0]}px ${out[1]}px`; f.style.transform = `scaleX(${u.toFixed(3)})`; });
        fr.style.filter = `drop-shadow(0 0 ${(14 + 18 * clamp((t - tFan) / 0.3) * (0.8 + 0.2 * Math.sin(t * 4))).toFixed(1)}px rgba(140,230,255,0.95))`;
      },
    };
  };

  // ---------- cards ----------
  // P.tile(p, cx, cy, s, { c, icon, label, price, lock }) → { el, lock(t, tOpen), price }: a glossy app-icon tile with a lock badge (lock: false = none)
  P.tile = (p, cx, cy, s, o = {}) => {
    const a = P.anchor(p, cx, cy), c = o.c || 'blue';
    const g = P.glass(a.in, 0, 0, s, s, { c, c2: o.c2 || c, r: s * 0.17, solid: true, glow: 0.9 });
    g.el.style.position = 'relative';
    g.in.innerHTML = `<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:${s * 0.06}px">${P.badge(o.icon || 'reel', s * 0.42, c)}<div style="font-size:${s * 0.12}px;font-weight:750;color:#fff;letter-spacing:-0.01em;text-shadow:0 1px 6px rgba(0,0,0,0.35)">${esc(o.label || '')}</div></div>`;
    const lk = o.lock === false ? h('div') : add(g.el, h('div', { style: { position: 'absolute', right: px(-s * 0.06), top: px(-s * 0.06), width: px(s * 0.2), height: px(s * 0.2), borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#1a1030', border: '2.5px solid #ff4fb8', boxShadow: '0 0 12px #ff4fb8' } }, P.icon('lock', s * 0.11, '#ff7ccc', 2.8)));
    let price = null;
    if (o.price) price = add(g.el, h('div', { style: { position: 'absolute', left: '50%', bottom: px(-s * 0.13), transform: 'translateX(-50%)', padding: '6px 16px', borderRadius: '999px', background: 'linear-gradient(180deg,#ff6fc6,#d9269a)', color: '#fff', fontWeight: 800, fontSize: px(s * 0.13), whiteSpace: 'nowrap', boxShadow: '0 0 16px rgba(255,79,184,0.8)', border: '2px solid rgba(255,255,255,0.8)' } }, esc(o.price)));
    return {
      el: a.el, glass: g, price, lock: (t, tOpen) => {
        const on = t >= tOpen, k = back((t - tOpen) / 0.3);
        F.html(lk, P.icon(on ? 'unlock' : 'lock', s * 0.11, on ? '#7dffc8' : '#ff7ccc', 2.8));
        lk.style.borderColor = on ? '#39f0a0' : '#ff4fb8'; lk.style.boxShadow = `0 0 12px ${on ? '#39f0a0' : '#ff4fb8'}`;
        lk.style.transform = on ? `scale(${(0.6 + 0.4 * k).toFixed(3)})` : '';
      },
    };
  };
  // P.term(p, x, y, w, h, { c, title, right }) → { el, glass, body }: a glass terminal / file window with traffic lights
  P.term = (p, x, y, w, hh, o = {}) => {
    const g = P.glass(p, x, y, w, hh, { c: o.c || 'cyan', c2: o.c2, r: 26 });
    g.in.innerHTML = `<div style="height:62px;display:flex;align-items:center;gap:11px;padding:0 24px;border-bottom:1.5px solid rgba(255,255,255,0.1)"><span style="width:15px;height:15px;border-radius:50%;background:#ff5f7a"></span><span style="width:15px;height:15px;border-radius:50%;background:#ffc24d"></span><span style="width:15px;height:15px;border-radius:50%;background:#39f0a0"></span><span class="mono" style="margin-left:14px;font-size:20px;font-weight:700;letter-spacing:0.14em;color:rgba(230,235,255,0.75)">${o.title || ''}</span><span class="mono" style="margin-left:auto;font-size:17px;font-weight:700;letter-spacing:0.14em;color:rgba(230,235,255,0.45)">${o.right || ''}</span></div>`;
    const body = add(g.in, h('div', { class: 'mono', style: { position: 'absolute', left: '30px', right: '30px', top: '84px', fontSize: '31px', fontWeight: 600, color: '#eef6ff', lineHeight: 1.55 } }));
    return { el: g.el, glass: g, body };
  };
  // P.lines(body, rows) → [{ el }]: terminal rows: [text, right, color]; reveal them with F.enter(row.el, …)
  P.lines = (body, rows) => rows.map(([txt, right, c = 'green']) => {
    const el = add(body, h('div', { style: { display: 'flex', alignItems: 'center', gap: '14px', borderBottom: '1.5px solid rgba(255,255,255,0.07)', padding: '4px 0', fontSize: '28px' } }, `<span style="color:${col(c)}">✓</span><span>${txt}</span><span style="margin-left:auto;color:${col(c)};font-size:24px">${right || ''}</span>`));
    el.style.position = 'relative';
    return { el };
  });
  // P.row(p, x, y, w, h, { c, icon, title }) → { el, glass, sub }: a list card (icon badge, title, a sub line to fill)
  P.row = (p, x, y, w, hh, o = {}) => {
    const g = P.glass(p, x, y, w, hh, { c: o.c || 'cyan', c2: o.c2, r: 24 });
    g.in.innerHTML = `<div style="position:absolute;left:24px;top:50%;transform:translateY(-50%)">${P.badge(o.icon || 'file', hh * 0.62, o.c || 'cyan')}</div><div style="position:absolute;left:${hh * 0.62 + 50}px;top:${hh * 0.16}px;font-size:${o.size || 38}px;font-weight:750;letter-spacing:-0.02em;color:#fff;white-space:nowrap">${esc(o.title || '')}</div>`;
    const sub = add(g.in, h('div', { style: { position: 'absolute', left: px(hh * 0.62 + 50), right: '26px', top: px(hh * 0.58), height: px(hh * 0.3), display: 'flex', alignItems: 'center', gap: '10px' } }));
    return { el: g.el, glass: g, sub };
  };
  // P.counter(p, x, y, w, { c, icon, label }) → { el, num }: the reference's "STARS ON GITHUB · OVER 60,000 ★" card
  P.counter = (p, x, y, w, o = {}) => {
    const c = o.c || 'amber', g = P.glass(p, x, y, w, 150, { c, c2: o.c2 || 'pink', r: 30 });
    g.in.innerHTML = `<div style="position:absolute;left:26px;top:50%;transform:translateY(-50%)">${P.badge(o.icon || 'tag', 94, o.ic || '#111')}</div><div class="mono" style="position:absolute;left:146px;top:24px;font-size:17px;font-weight:700;letter-spacing:0.16em;color:rgba(235,238,255,0.7)">${o.label || ''}</div>`;
    const num = add(g.in, h('div', { style: { position: 'absolute', left: '144px', top: '44px', fontSize: '82px', fontWeight: 800, letterSpacing: '-0.035em', lineHeight: 1.05, whiteSpace: 'nowrap', background: `linear-gradient(180deg,#ffffff 0%,#fff4dc 40%,${col(c)} 100%)`, webkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent', filter: `drop-shadow(0 0 12px ${rgba(c, 0.6)})`, padding: '0 0.04em 0.1em' } }));
    return { el: g.el, glass: g, num };
  };
  // P.avatar(p, cx, cy, s, hue): a neutral person in a glowing circle (never a real face)
  const HUES = [['#ff8ac8', '#7a2bd0'], ['#7fe8ff', '#2a56d8'], ['#ffd27a', '#d4542f'], ['#9dffcf', '#1f8f8a'], ['#c9a6ff', '#4b2fc0'], ['#ffb3a1', '#b8326e']];
  P.avatar = (p, cx, cy, s = 60, k = 0) => { const [a, b] = HUES[k % HUES.length]; return div(p, 'avatar', cx - s / 2, cy - s / 2, s, s, F.PERSON('rgba(255,255,255,0.92)'), { background: `radial-gradient(circle at 35% 28%, ${a}, ${b} 120%)` }); };
  P.avatarHTML = (s = 60, k = 0) => { const [a, b] = HUES[k % HUES.length]; return `<div style="width:${s}px;height:${s}px;border-radius:50%;overflow:hidden;border:2.5px solid rgba(255,255,255,0.85);background:radial-gradient(circle at 35% 28%, ${a}, ${b} 120%);flex:none">${F.PERSON('rgba(255,255,255,0.92)')}</div>`; };
  // P.strike(p, x, y, w, { c, width }) → { el, update(t, t0) }: a neon line that draws across something (a price, a word)
  P.strike = (p, x, y, w, o = {}) => {
    const c = col(o.c || 'pink'), el = div(p, 'abs', x, y - 6, w, 12, `<div style="position:absolute;left:0;top:3px;height:${o.width || 6}px;width:100%;border-radius:6px;background:${c};box-shadow:0 0 12px ${c}, 0 0 4px #fff;transform-origin:0 50%;transform:scaleX(0)"></div>`, { zIndex: 20 });
    const ln = el.firstChild;
    return { el, update: (t, t0) => { ln.style.transform = `scaleX(${eo((t - t0) / 0.26).toFixed(3)})`; } };
  };

  // ---------- the ask ----------
  // P.commentPill(p, cx, cy) → anchor: the cyan "COMMENT" pill, tilted
  P.commentPill = (p, cx, cy, text = 'COMMENT') => { const a = P.anchor(p, cx, cy); add(a.in, h('div', { style: { padding: '18px 40px 20px', borderRadius: '999px', background: 'linear-gradient(180deg,#7bf0ff,#25b8e8)', color: '#071b2a', fontSize: '50px', fontWeight: 850, letterSpacing: '0.01em', transform: 'rotate(-4deg)', whiteSpace: 'nowrap', boxShadow: '0 0 30px rgba(63,224,255,0.75), inset 0 2px 0 rgba(255,255,255,0.6)' } }, text)); return a; };
  // P.keyword(p, cx, cy, text, c) → anchor: the giant glass keyword slab ("SKILL" in the reference)
  P.keyword = (p, cx, cy, text, c = 'pink', o = {}) => {
    const a = P.anchor(p, cx, cy), w = o.w || 420, hh = o.h || 170;
    const g = P.glass(a.in, 0, 0, w, hh, { c, c2: o.c2 || '#ff8ad6', r: 22, solid: true, glow: 1.3 });
    g.el.style.position = 'relative'; g.el.style.transform = 'perspective(1200px) rotateX(8deg) rotateY(-6deg)';
    g.in.innerHTML = `<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:${o.size || 118}px;font-weight:850;letter-spacing:-0.02em;color:#fff;text-shadow:0 3px 0 rgba(120,0,70,0.4), 0 0 24px rgba(255,255,255,0.5)">${esc(text)}</div>`;
    return a;
  };
  // P.input(p, x, y, w, { k }) → { el, text, btn }: the comment box: an avatar, the typed text, a send button (→ ✓)
  P.input = (p, x, y, w, o = {}) => {
    const g = P.glass(p, x, y, w, 104, { c: o.c || 'cyan', c2: 'violet', r: 52 });
    g.in.innerHTML = `<div style="position:absolute;left:16px;top:50%;transform:translateY(-50%)">${P.avatarHTML(70, o.k || 0)}</div>`;
    const text = add(g.in, h('div', { style: { position: 'absolute', left: '108px', top: '50%', transform: 'translateY(-50%)', fontSize: '40px', fontWeight: 800, color: '#fff', whiteSpace: 'nowrap' } }));
    const btn = add(g.in, h('div', { style: { position: 'absolute', right: '18px', top: '50%', width: '66px', height: '66px', marginTop: '-33px', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'linear-gradient(180deg,#7bf0ff,#25b8e8)', boxShadow: '0 0 16px rgba(63,224,255,0.8)' } }, F.ARROW(38, '#071b2a')));
    return { el: g.el, text, btn, done: on => { F.html(btn, on ? F.CHECK(40, '#05261a') : F.ARROW(38, '#071b2a')); btn.style.background = on ? 'linear-gradient(180deg,#8dffd0,#21d68a)' : 'linear-gradient(180deg,#7bf0ff,#25b8e8)'; btn.style.boxShadow = `0 0 16px ${on ? 'rgba(57,240,160,0.85)' : 'rgba(63,224,255,0.8)'}`; } };
  };
  // P.dm(p, x, y, w, { from, text, pfp }) → { el, badge }: the DM that answers the comment ("sent"). pfp: a picture of the
  //   sender; a drawn avatar sits under it and shows when there is none or the file is missing
  P.dm = (p, x, y, w, o = {}) => {
    const g = P.glass(p, x, y, w, 120, { c: 'green', c2: 'cyan', r: 28 });
    const pic = `<div style="position:relative;width:76px;height:76px">${P.avatarHTML(76, 3)}${o.pfp ? `<img src="${o.pfp}" style="position:absolute;inset:0;width:76px;height:76px;border-radius:50%;object-fit:cover;border:2.5px solid rgba(255,255,255,0.85);display:block" onerror="this.style.visibility='hidden'">` : ''}</div>`;
    g.in.innerHTML = `<div style="position:absolute;left:20px;top:50%;transform:translateY(-50%)">${pic}</div><div class="mono" style="position:absolute;left:114px;top:22px;font-size:17px;font-weight:700;color:rgba(230,240,255,0.6);letter-spacing:0.04em">${esc(o.from || '')} · now</div><div style="position:absolute;left:114px;top:48px;font-size:38px;font-weight:780;color:#fff;letter-spacing:-0.02em;white-space:nowrap">${esc(o.text || '')}</div>`;
    const badge = add(g.in, h('div', { style: { position: 'absolute', right: '22px', top: '50%', transform: 'translateY(-50%)', padding: '8px 20px', borderRadius: '999px', background: 'linear-gradient(180deg,#8dffd0,#21d68a)', color: '#05261a', fontSize: '24px', fontWeight: 800, boxShadow: '0 0 14px rgba(57,240,160,0.8)' } }, 'sent'));
    return { el: g.el, badge };
  };
  // P.chip(p, cx, cy, text, k) → anchor: a small floating comment chip with an avatar ("PRISM" from the crowd)
  P.chip = (p, cx, cy, text, k = 0) => { const a = P.anchor(p, cx, cy); add(a.in, h('div', { style: { display: 'flex', alignItems: 'center', gap: '10px', padding: '6px 18px 6px 6px', borderRadius: '999px', background: 'rgba(20,20,48,0.88)', border: '1.5px solid rgba(255,255,255,0.18)', boxShadow: '0 0 12px rgba(155,123,255,0.35)', fontSize: '22px', fontWeight: 800, color: '#f2f4ff', whiteSpace: 'nowrap' } }, P.avatarHTML(40, k) + esc(text))); return a; };
})();
```

### `engine/fx.js`

```js
// Prism · MOTION + EFFECTS (Stamp's, recoloured neon). Every element enters with a spring, a slide or a blur and then keeps
// floating; counters tick, wires draw and carry pulses, big moments burst. Loaded after prism.js (film.html).
(() => {
  const { h, add, div, px, rng, set, clamp, lerp, eo, eio, back, TAU } = F;
  const P = F.parts, M = P.M;
  // the mix: per-sound trims so hits punch without covering a word (the bass sounds are long and dense). Every cue() goes
  // through this; change a level for one video with F.SFX_GAIN.name = x at the top of its scenes.js.
  F.SFX_GAIN = { bassdrop: 0.45, boom: 0.65, uipop: 0.6, riser: 0.8, cards: 0.8, sparkle: 0.8 };
  { const raw = F.cue; F.cue = (f, t, g = 0.1, len, fin) => raw(f, t, g * (F.SFX_GAIN[f] ?? 1), len, fin); }

  // F.enter(el, t, t0, { from, d, dist, s0, rot, blur, float, seed, t1, d1, to, x, y }) → opacity
  //   from: 'pop' (spring scale) · 'up' / 'down' / 'left' / 'right' (whip-slide with blur) · 'slam' (big → 1) · 'drop' (falls and
  //   bounces) · 'zoom' (small → 1) · 'fade'. After it lands it floats (a slow bob and sway) unless float: 0.
  //   to (exit at t1): 'fade' · 'up' / 'down' / 'left' / 'right' · 'pop' (shrinks away)
  F.enter = (el, t, t0, o = {}) => {
    const { from = 'pop', dist = 140, rot = 0, blur = 12, float = 1, seed = 0, t1 = null, d1 = 0.24, to = 'fade', x = 0, y = 0, s = 1 } = o;
    const d = o.d ?? (from === 'pop' ? 0.42 : from === 'drop' ? 0.55 : from === 'slam' ? 0.22 : 0.36);
    if (t < t0 - 0.01 || (t1 != null && t > t1 + d1 + 0.02)) { if (el.style.visibility !== 'hidden') { el.style.opacity = '0'; el.style.visibility = 'hidden'; } return 0; }
    const a = clamp((t - t0) / d);
    let tx = x, ty = y, sc = s, r = rot, op = 1, bl = 0;
    switch (from) {
      case 'pop': { const s0 = o.s0 ?? 0.55; sc *= s0 + (1 - s0) * back(a); op = clamp(a * 2.4); bl = (1 - eo(a)) * blur; r += (o.spin ?? 0) * (1 - eo(a)); break; }
      case 'up': case 'down': case 'left': case 'right': {
        const k = 1 - eo(a), dir = { up: [0, 1], down: [0, -1], left: [-1, 0], right: [1, 0] }[from];
        tx += dir[0] * k * dist; ty += dir[1] * k * dist; op = clamp(a * 2.5); bl = k * blur * 1.6; r += (o.spin ?? 0) * k; break;
      }
      case 'slam': { sc *= lerp(o.s0 ?? 1.9, 1, eo(a)); op = clamp(a * 3); bl = (1 - a) * blur; const q = clamp((t - t0 - d) / 0.25); sc *= 1 - 0.05 * Math.sin(q * Math.PI) * (1 - q); break; }
      case 'drop': { const k = clamp(a / 0.6); ty -= (1 - k * k) * dist; if (a > 0.6) ty -= Math.abs(Math.sin((a - 0.6) / 0.4 * Math.PI)) * dist * 0.08 * (1 - a); op = clamp(a * 4); r += (o.spin ?? 0) * (1 - eo(a)); break; }
      case 'zoom': { sc *= lerp(o.s0 ?? 0.2, 1, back(a, 1.2)); op = clamp(a * 2.5); bl = (1 - eo(a)) * blur * 1.5; break; }
      default: { op = eo(a); bl = (1 - eo(a)) * blur; }
    }
    if (float) { const k = clamp((t - t0 - d) / 0.6), ph = seed * 1.913 + 0.7; ty += Math.sin(t * 1.25 + ph) * 5 * float * k; tx += Math.cos(t * 0.8 + ph * 1.4) * 2.5 * float * k; r += Math.sin(t * 0.9 + ph * 1.3) * 0.4 * float * k; }
    if (t1 != null && t > t1) {
      const z = eo((t - t1) / d1);
      if (to === 'pop') { sc *= 1 - 0.7 * z; op *= 1 - z; }
      else if (to === 'fade') { op *= 1 - z; bl += z * blur; sc *= 1 + 0.06 * z; }
      else { const dir = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[to]; tx += dir[0] * z * dist * 1.5; ty += dir[1] * z * dist * 1.5; op *= 1 - z; bl += z * blur * 1.6; }
    }
    el.style.visibility = op < 0.004 ? 'hidden' : 'visible';
    el.style.opacity = op.toFixed(3);
    el.style.transform = `translate(${tx.toFixed(1)}px, ${ty.toFixed(1)}px) rotate(${r.toFixed(2)}deg) scale(${sc.toFixed(4)})`;
    el.style.filter = bl > 0.3 ? `blur(${bl.toFixed(1)}px)` : 'none';
    return op;
  };
  F.tw = (t, t0, t1, e = eio) => e(clamp((t - t0) / Math.max(1e-3, t1 - t0)));
  // sound helpers: ticks spread over a span (alternating three tick samples), and a typing bed for a typing span
  let tk = 0;
  F.ticks = (t0, t1, n, g = 0.05) => { for (let i = 0; i < n; i++) F.cue(['tick1', 'tick2', 'tick3'][tk++ % 3], lerp(t0, t1, n > 1 ? i / (n - 1) : 0), g); };
  F.typeCue = (t0, t1, g = 0.07) => F.cue('typing', t0, g, Math.max(0.25, t1 - t0 + 0.08));
  // F.swing(t, t0, amp, freq, damp) → degrees: a hanging tag that was just hit swings and settles
  F.swing = (t, t0, amp = 16, freq = 7.5, damp = 3.2) => t < t0 ? 0 : amp * Math.exp(-(t - t0) * damp) * Math.sin((t - t0) * freq);

  // P.wire(p, d, { w, h, color, width, dash, t0, dur, pulses, glow }) → { el, path, update(t) }: an SVG connector that draws
  //   itself from t0 over dur, then carries glowing pulses along it (pulses: how many travel at once)
  P.wire = (p, d, o = {}) => {
    const { color = M.mag2, width = 4, dash = '', t0 = 0, dur = 0.4, pulses = 0, glow = true, x = 0, y = 0, w = 1080, hh = 1920, speed = 0.9 } = o;
    const el = div(p, 'abs', x, y, w, hh, '', { pointerEvents: 'none' });
    el.innerHTML = `<svg width="${w}" height="${hh}" style="overflow:visible"><path d="${d}" fill="none" stroke="${color}" stroke-width="${width}" stroke-linecap="round" ${dash ? `stroke-dasharray="${dash}"` : ''} style="${glow ? `filter:drop-shadow(0 0 6px ${color})` : ''}"/>${Array.from({ length: pulses }, () => `<circle class="pl" r="${width + 3}" fill="#fff" style="filter:drop-shadow(0 0 8px ${color})"/>`).join('')}</svg>`;
    const svg = el.firstChild, line = svg.querySelector('path'), pls = [...svg.querySelectorAll('.pl')];
    let len = null;
    return {
      el, path: line, update: t => {
        if (len == null) { try { len = line.getTotalLength(); } catch { len = 1000; } }
        const u = clamp((t - t0) / dur);
        if (dash) { line.style.opacity = eo(u).toFixed(2); line.setAttribute('stroke-dashoffset', (-(t - t0) * 50).toFixed(1)); }
        else { line.setAttribute('stroke-dasharray', `${(len * eo(u)).toFixed(1)} ${(len + 20).toFixed(0)}`); line.style.opacity = u > 0 ? 1 : 0; }
        pls.forEach((c, i) => { const q = (((t - t0 - dur) * speed + i / pls.length) % 1 + 1) % 1; if (t < t0 + dur) { c.style.opacity = 0; return; } const pt = line.getPointAtLength(q * len); c.setAttribute('cx', pt.x.toFixed(1)); c.setAttribute('cy', pt.y.toFixed(1)); c.style.opacity = Math.sin(q * Math.PI).toFixed(2); });
      },
    };
  };
  // P.burst(p, cx, cy, t0, { n, r0, r1, len, color, width }) → { update(t) }: radial dashes flying out (a "hit" moment)
  P.burst = (p, cx, cy, t0, o = {}) => {
    const { n = 16, r0 = 80, r1 = 300, len = 34, color = M.mag2, width = 6, rx = 1, ry = 1, dur = 0.5 } = o;
    const el = div(p, 'abs', cx - r1 - 60, cy - r1 - 60, 2 * r1 + 120, 2 * r1 + 120, '', { pointerEvents: 'none', zIndex: 45 });
    const c = r1 + 60, rr = rng(Math.round(cx + cy * 3));
    el.innerHTML = `<svg width="${2 * c}" height="${2 * c}">${Array.from({ length: n }, (_, i) => { const a = (i / n) * TAU + rr() * 0.2; return `<line data-a="${a}" data-k="${(0.8 + rr() * 0.4).toFixed(2)}" stroke="${color}" stroke-width="${width}" stroke-linecap="round" style="filter:drop-shadow(0 0 6px ${color})"/>`; }).join('')}</svg>`;
    const ls = [...el.querySelectorAll('line')];
    return {
      el, update: t => {
        const u = clamp((t - t0) / dur);
        el.style.display = u <= 0 || u >= 1 ? 'none' : 'block'; if (u <= 0 || u >= 1) return;
        ls.forEach(l => { const a = +l.dataset.a, k = +l.dataset.k, r = lerp(r0, r1 * k, eo(u)), L2 = len * (1 - u); l.setAttribute('x1', (c + Math.cos(a) * r * rx).toFixed(1)); l.setAttribute('y1', (c + Math.sin(a) * r * ry).toFixed(1)); l.setAttribute('x2', (c + Math.cos(a) * (r + L2) * rx).toFixed(1)); l.setAttribute('y2', (c + Math.sin(a) * (r + L2) * ry).toFixed(1)); l.style.opacity = (1 - u).toFixed(2); });
      },
    };
  };
  // P.shock(p, cx, cy, t0, { r, color, width, rx, ry }) → { update(t) }: an expanding ring
  P.shock = (p, cx, cy, t0, o = {}) => {
    const { r = 300, color = M.mag2, width = 5, rx = 1, ry = 1, dur = 0.55 } = o;
    const el = div(p, 'abs', cx - r * rx, cy - r * ry, 2 * r * rx, 2 * r * ry, '', { borderRadius: '50%', border: `${width}px solid ${color}`, boxShadow: `0 0 30px ${color}`, pointerEvents: 'none', zIndex: 44, opacity: 0 });
    return { el, update: t => { const k = clamp((t - t0) / dur); set(el, k > 0 && k < 1 ? (1 - k) * 0.85 : 0, `scale(${(0.2 + 0.8 * eo(k)).toFixed(3)})`); } };
  };
  // P.confetti(p, cx, cy, t0, { n, spread, colors }) → { update(t) }: little paper bits thrown up that fall with gravity
  P.confetti = (p, cx, cy, t0, o = {}) => {
    const { n = 36, spread = 520, colors = ['#3fe0ff', '#ff4fb8', '#ffc24d', '#39f0a0', '#9b7bff', '#ffffff'], up = 900, dur = 1.6 } = o;
    const r = rng(Math.round(cx * 3 + cy));
    const bits = Array.from({ length: n }, () => ({ e: div(p, 'abs', cx, cy, 10 + r() * 10, 14 + r() * 12, '', { background: colors[Math.floor(r() * colors.length)], borderRadius: '3px', zIndex: 46, opacity: 0 }), vx: (r() - 0.5) * spread * 2, vy: -up * (0.5 + r() * 0.6), vr: (r() - 0.5) * 900, d: r() * 0.12 }));
    return { update: t => bits.forEach(b => { const q = t - t0 - b.d; if (q <= 0 || q > dur) { b.e.style.opacity = 0; return; } b.e.style.opacity = (1 - clamp((q - dur * 0.6) / (dur * 0.4))).toFixed(2); b.e.style.transform = `translate(${(b.vx * q).toFixed(1)}px, ${(b.vy * q + 1500 * q * q).toFixed(1)}px) rotate(${(b.vr * q).toFixed(0)}deg) rotateX(${(q * 720).toFixed(0)}deg)`; }) };
  };
  // P.shine(el, { color }) → { update(t, t0, dur) }: a glossy light band sweeping across an element (put on cards and buttons)
  P.shine = (host, o = {}) => {
    host.style.overflow = 'hidden';
    const s = add(host, h('div', { style: { position: 'absolute', top: '-20%', bottom: '-20%', width: '38%', left: '0', background: `linear-gradient(100deg, transparent, ${o.color || 'rgba(255,255,255,0.55)'}, transparent)`, transform: 'translateX(-200%) skewX(-18deg)', pointerEvents: 'none', zIndex: 20 } }));
    return { update: (t, t0, dur = 0.55) => { const u = clamp((t - t0) / dur); s.style.transform = `translateX(${lerp(-200, 340, eio(u)).toFixed(1)}%) skewX(-18deg)`; s.style.opacity = u > 0 && u < 1 ? 1 : 0; } };
  };
  // P.crack(p, x, y, w, h, t0, { seed }) → { update(t) }: jagged red cracks that draw across a failing card
  P.crack = (p, x, y, w, hh, t0, o = {}) => {
    const r = rng(o.seed || 7), paths = [];
    for (let k = 0; k < (o.n || 3); k++) { let px0 = r() * w, py0 = r() < 0.5 ? 0 : hh, d = `M${px0.toFixed(0)} ${py0.toFixed(0)}`; const steps = 6; for (let i = 1; i <= steps; i++) { px0 += (r() - 0.5) * w * 0.35; py0 = py0 === 0 || py0 < hh / 2 ? py0 + hh / steps * (0.6 + r() * 0.6) : py0 - hh / steps * (0.6 + r() * 0.6); d += ` L${clamp(px0, 0, w).toFixed(0)} ${clamp(py0, 0, hh).toFixed(0)}`; } paths.push(d); }
    const el = div(p, 'abs', x, y, w, hh, `<svg width="${w}" height="${hh}" style="overflow:visible">${paths.map(d => `<path d="${d}" fill="none" stroke="#ff4d5e" stroke-width="3.5" stroke-linejoin="round" pathLength="100" stroke-dasharray="100" stroke-dashoffset="100" style="filter:drop-shadow(0 0 6px #e5283a)"/>`).join('')}</svg>`, { pointerEvents: 'none', zIndex: 30 });
    const ps = [...el.querySelectorAll('path')];
    return { el, update: t => ps.forEach((pp, i) => pp.setAttribute('stroke-dashoffset', (100 * (1 - eo((t - t0 - i * 0.06) / 0.3))).toFixed(1))) };
  };
  // P.orbiters(p, cx, cy, n, { rx, ry, t0, s, hues }) → { els, update(t) }: little avatar bubbles drifting around a card ("people watching")
  P.orbiters = (p, cx, cy, n, o = {}) => {
    const { rx = 420, ry = 300, t0 = 0, s = 56, stagger = 0.12 } = o, r = rng(n * 13 + Math.round(cx));
    const cols = ['#3fe0ff', '#ff4fb8', '#ffc24d', '#39f0a0', '#9b7bff'];
    const els = Array.from({ length: n }, (_, i) => ({ e: div(p, 'abs', cx - s / 2, cy - s / 2, s, s, F.PERSON('rgba(255,255,255,0.92)'), { borderRadius: '50%', background: `radial-gradient(circle at 35% 30%, ${cols[i % cols.length]}, #3a1036 140%)`, border: '3px solid rgba(255,255,255,0.75)', boxShadow: '0 10px 24px rgba(0,0,0,0.35)', overflow: 'hidden', zIndex: 35 }), a: (i / n) * TAU + r() * 0.5, sp: 0.18 + r() * 0.2, k: 0.85 + r() * 0.3, t: t0 + i * stagger }));
    return { els, update: t => els.forEach((b, i) => { const a = b.a + t * b.sp * (i % 2 ? -1 : 1); F.enter(b.e, t, b.t, { from: 'pop', float: 0, x: Math.cos(a) * rx * b.k, y: Math.sin(a) * ry * b.k + Math.sin(t * 2 + i) * 8 }); }) };
  };
  // P.cursor2(p, label, color) → cursor with a name tag (someone else's cursor); drive it with F.drive like F.cursor
  P.cursor2 = (p, label = 'someone', color = M.red) => {
    const c = F.cursor(p);
    c.c.innerHTML = `<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="${color}" stroke="#fff" stroke-width="1.4" stroke-linejoin="round"/></svg><span style="position:absolute;left:44px;top:44px;background:${color};color:#fff;font-size:22px;font-weight:750;padding:4px 12px;border-radius:10px;white-space:nowrap">${label}</span>`;
    return c;
  };
  // P.skel(host, x, y, w, h, { light, r }) → { el, update(t, tDone) }: a shimmering loading placeholder (fades out at tDone)
  P.skel = (host, x, y, w, hh, o = {}) => {
    const el = div(host, 'abs', x, y, w, hh, '', { borderRadius: px(o.r ?? 18), overflow: 'hidden', background: o.light ? '#ece7ec' : 'rgba(255,255,255,0.06)', border: o.light ? '1px solid rgba(0,0,0,0.04)' : '1px solid rgba(255,255,255,0.08)' });
    const band = add(el, h('div', { style: { position: 'absolute', top: 0, bottom: 0, width: '60%', background: `linear-gradient(90deg, transparent, ${o.light ? 'rgba(255,255,255,0.9)' : 'rgba(63,224,255,0.25)'}, transparent)` } }));
    return { el, update: (t, tDone) => { band.style.transform = `translateX(${(((t * 1.6) % 1.4) * 100 * 2 - 120).toFixed(0)}%)`; el.style.opacity = (1 - clamp((t - tDone) / 0.15)).toFixed(2); } };
  };
  // P.vib(p, cx, cy, { w, h, color }) → { update(t, on) }: "((( )))" buzz lines either side of a phone
  P.vib = (p, cx, cy, o = {}) => {
    const { w = 420, color = 'rgba(255,255,255,0.75)' } = o;
    const mk = (side) => div(p, 'abs', cx + side * w / 2 - 40, cy - 60, 80, 120, `<svg width="80" height="120"><g fill="none" stroke="${color}" stroke-width="5" stroke-linecap="round">${[0, 1, 2].map(i => `<path d="M${40 + side * (8 + i * 14)} ${30 - i * 10} Q ${40 + side * (22 + i * 14)} 60 ${40 + side * (8 + i * 14)} ${90 + i * 10}"/>`).join('')}</g></svg>`, { zIndex: 30 });
    const L = mk(-1), R = mk(1);
    return { update: (t, on) => [L, R].forEach((e, i) => { e.style.opacity = (on * (0.5 + 0.5 * Math.abs(Math.sin(t * 14)))).toFixed(2); e.style.transform = `translateX(${((i ? 1 : -1) * Math.sin(t * 40) * 3).toFixed(1)}px)`; }) };
  };
})();
```

### `engine/film.css`

```css
/* Prism · STYLE. 1080x1920, glass-neon explainers on a synthwave night (after @robin_linkerpro): a deep violet-navy sky with
   nebula clouds and stars over a teal perspective grid floor; one glass object per beat with a neon rim and glow (slabs,
   tiles, cubes, cards, a wire globe); a big Inter Tight headline at the top whose words blur in as they are said, key words
   in a neon gradient; a dark glass caption pill (2–3 words, the word being said underlined).
   Tokens (also in prism.js M): cyan #3fe0ff · pink #ff4fb8 · amber #ffc24d · violet #9b7bff · green #39f0a0 · blue #4d7bff ·
   ink #f2f4ff · dim #7d82a6 · night #070818. Fonts: Inter Tight (../../shared/fonts) · JetBrains Mono (fonts/). */
:root {
  --cyan: #3fe0ff; --pink: #ff4fb8; --amber: #ffc24d; --violet: #9b7bff; --green: #39f0a0; --blue: #4d7bff;
  --ink: #f2f4ff; --dim: #7d82a6; --night: #070818;
  --mono: 'JetBrains Mono', 'Cascadia Mono', Consolas, monospace;
  --emoji: 'Segoe UI Emoji', 'Apple Color Emoji', 'Noto Color Emoji', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 1080px; height: 1920px; overflow: hidden; background: var(--night); }
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; }
.scene { transform-origin: 510px 560px; }
.rig { position: absolute; inset: 0; transform-origin: 510px 330px; }
.abs { position: absolute; }
.mono { font-family: var(--mono); }

/* ---------- the night: sky, nebula, stars, grid floor ---------- */
.sky { position: absolute; inset: 0; background: linear-gradient(180deg, #05061a 0%, #0a0b26 38%, #0d0b25 58%, #07061a 61%, #090718 100%); }
.neb { position: absolute; border-radius: 50%; filter: blur(60px); mix-blend-mode: screen; }
.glowhead { position: absolute; left: 110px; top: 210px; width: 800px; height: 420px; border-radius: 50%; filter: blur(70px); mix-blend-mode: screen; }
.floorfog { position: absolute; left: 0; right: 0; height: 120px; background: linear-gradient(180deg, transparent, rgba(255,79,184,0.10) 50%, transparent); filter: blur(8px); }
.vignette { position: absolute; inset: 0; background: radial-gradient(1200px 1500px at 50% 46%, transparent 55%, rgba(0,0,8,0.55) 100%); pointer-events: none; }
.grain { position: absolute; inset: -64px; pointer-events: none; mix-blend-mode: overlay; opacity: 0.05; }
.flash { position: absolute; inset: 0; background: #fff; opacity: 0; pointer-events: none; mix-blend-mode: screen; }
.streak { position: absolute; left: -40px; width: 1160px; height: 6px; border-radius: 6px; pointer-events: none; opacity: 0; background: linear-gradient(90deg, transparent, rgba(155,123,255,0.6) 20%, #ffffff 50%, rgba(255,79,184,0.6) 80%, transparent); box-shadow: 0 0 18px rgba(200,180,255,0.9), 0 0 60px rgba(155,123,255,0.6); }

/* ---------- headline: big, tight, blurred in word by word ---------- */
.head { position: absolute; left: 60px; width: 900px; text-align: center; font-weight: 800; letter-spacing: -0.035em; line-height: 0.98; filter: drop-shadow(0 0 22px rgba(170,175,255,0.42)); }
.head .ln { display: block; white-space: nowrap; }
.head .hw { display: inline-block; white-space: pre; background: linear-gradient(180deg, #ffffff 0%, #eef0ff 45%, #b9b8f2 100%); -webkit-background-clip: text; background-clip: text; color: transparent; padding: 0.04em 0.02em 0.12em; }
.kicker { position: absolute; left: 60px; width: 900px; text-align: center; font-family: var(--mono); font-size: 25px; font-weight: 700; letter-spacing: 0.26em; color: rgba(225,228,255,0.78); text-transform: uppercase; white-space: nowrap; }

/* ---------- caption pill: 2–3 words, centred on x 510, the spoken word underlined ---------- */
.cap { position: absolute; left: 510px; top: 1356px; transform-origin: 0 0; display: inline-flex; gap: 0 16px; align-items: baseline; white-space: nowrap; padding: 18px 30px 22px; border-radius: 26px; font-size: 50px; font-weight: 700; letter-spacing: -0.02em; line-height: 1; background: linear-gradient(180deg, rgba(20,21,46,0.86), rgba(9,10,26,0.9)); border: 1.5px solid rgba(220,225,255,0.16); box-shadow: 0 0 28px rgba(120,110,255,0.18), 0 18px 40px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.08); }
.cap .w { position: relative; display: inline-block; color: var(--dim); }
.cap .w.on { color: #ffffff; }
.cap .w .ul { position: absolute; left: 0; right: 0; bottom: -10px; height: 5px; border-radius: 4px; transform-origin: 0 50%; opacity: 0; }

/* ---------- glass: a neon rim (outer) around a frosted fill (inner) ---------- */
.glass { position: absolute; border-radius: 30px; padding: 2.5px; }
.glass > .fill { position: absolute; inset: 2.5px; border-radius: 28px; overflow: hidden; }
.glass > .fill::before { content: ''; position: absolute; inset: 0; background: radial-gradient(70% 55% at 22% 8%, rgba(255,255,255,0.20), transparent 60%), radial-gradient(60% 70% at 90% 100%, rgba(255,255,255,0.08), transparent 70%); pointer-events: none; }
.glass > .in { position: absolute; inset: 2.5px; border-radius: 28px; }
.tag { position: absolute; display: inline-flex; align-items: center; gap: 12px; height: 58px; padding: 0 26px 0 22px; border-radius: 999px; font-size: 30px; font-weight: 700; white-space: nowrap; color: #f3f4ff; background: linear-gradient(180deg, rgba(26,26,58,0.92), rgba(12,12,32,0.92)); border: 2px solid var(--c, #9b7bff); box-shadow: 0 0 18px color-mix(in srgb, var(--c, #9b7bff) 55%, transparent), inset 0 1px 0 rgba(255,255,255,0.12); }
.tag .d { width: 12px; height: 12px; border-radius: 50%; background: var(--c, #9b7bff); box-shadow: 0 0 10px var(--c, #9b7bff); }
.face { position: absolute; backface-visibility: visible; }
.p3 { position: absolute; transform-style: preserve-3d; }
.avatar { position: absolute; border-radius: 50%; overflow: hidden; border: 3px solid rgba(255,255,255,0.85); box-shadow: 0 0 14px rgba(120,140,255,0.45); }
.caret { display: inline-block; width: 0.5em; height: 1em; background: var(--cyan); vertical-align: -0.14em; margin-left: 2px; box-shadow: 0 0 8px var(--cyan); }
.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 var(--cyan); opacity: 0; z-index: 59; }

/* 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 .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); }
```

### `engine/markup.mjs`

```js
// Prism · SCRIPT MARKUP (Node side: gen-vo, build-timeline, check). Same for every video.
//   /  a new caption chunk (the pill also chunks by itself: ≤ 3 words)   *word*  key word   {shown|said}  spelling
//   (@icon:word parses, but Prism's caption pill shows no icons)
// 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: 'TX3LPaxmHKxFdv7VOQHJ',   // ElevenLabs "Liam" (premade): the reference narrator measures ~175 Hz and 205-227 wpm; Liam ~178 Hz
  MODEL: 'eleven_multilingual_v2',
  SETTINGS: { stability: 0.4, similarity_boost: 0.8, style: 0.45, use_speaker_boost: true },
  SPEED: 1.15,                     // then votight.mjs shortens the pauses: ~215 wpm, the reference's pace
  GAP: 0.2, LEAD: 0.08, HOLD: 1.6,
};

// tokens: [{ d: shown text, said: spoken text, key, icon, br (a phrase starts here) }]
export function parse(src) {
  const out = []; let br = true;
  const parts = src.match(/[^\s{]*\{[^}]*\}\S*|\S+/g) || [];
  for (let raw of parts) {
    if (raw === '/') { br = 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 }); 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
// Prism · requirements check + the free fonts + 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 + JetBrains Mono
// (SIL OFL, google/fonts) to fonts/, makes sfx/*.wav (sounds.mjs; the bed is made per video by bed.mjs), and starts the
// browser once to prove it works.
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { CHROME, launch } from './cdp.mjs';
const HERE = import.meta.dirname;
const ok = (good, msg) => console.log(good ? '✓' : '✗', msg);
ok(+process.versions.node.split('.')[0] >= 22, 'Node ' + process.versions.node + ' (22+ needed: global fetch and WebSocket)');
for (const c of ['ffmpeg', 'ffprobe']) ok(spawnSync(c, ['-version']).status === 0, c + ' on PATH');
ok(!!CHROME, 'Chrome or Edge: ' + (CHROME || 'not found, set CHROME=<path to the browser>'));
ok(!!(process.env.ELEVENLABS_API_KEY || (fs.existsSync(path.join(HERE, '.env')) && /^ELEVENLABS_API_KEY=./m.test(fs.readFileSync(path.join(HERE, '.env'), 'utf8')))), 'ELEVENLABS_API_KEY (environment or .env): not needed for --fake timings and stills');
const FONTS = {
  'InterTight.ttf': 'https://raw.githubusercontent.com/google/fonts/main/ofl/intertight/InterTight%5Bwght%5D.ttf',
  'JetBrainsMono.ttf': 'https://raw.githubusercontent.com/google/fonts/main/ofl/jetbrainsmono/JetBrainsMono%5Bwght%5D.ttf',
};
for (const [name, url] of Object.entries(FONTS)) {
  const font = path.join(HERE, 'fonts', name);
  if (fs.existsSync(font)) { ok(true, 'fonts/' + name); continue; }
  fs.mkdirSync(path.dirname(font), { recursive: true });
  try { const r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0' } }); if (!r.ok) throw new Error(r.status); fs.writeFileSync(font, Buffer.from(await r.arrayBuffer())); ok(true, 'fonts/' + name + ' downloaded'); }
  catch (e) { ok(false, `fonts/${name}: download failed (${e.message}) from ${url}`); }
}
spawnSync(process.execPath, [path.join(HERE, 'sounds.mjs')], { stdio: 'inherit' });
if (CHROME) try { const api = await launch({ port: 9698 }); ok(true, 'Chrome starts headless · renderer: ' + api.gpu); await api.close(); } catch (e) { ok(false, e.message); }
setTimeout(() => process.exit(0), 300);
```

### `sounds.mjs`

```js
// Prism · the sounds, synthesized (no downloads, no licences, no credits):  node sounds.mjs [--force]
// The 21 sounds the scenes cue, in sfx/<name>.wav: boom bassdrop whoosh swoosh uipop uitick tick tick1 tick2 tick3
// mouse send notif kaching error counter typing stopwatch cards riser sparkle. Seeded, so every project gets the same set.
// Every sound starts on its first sample (a cue lands on its frame) and peaks at -1 dB. The music bed is made per video by
// bed.mjs.
import fs from 'node:fs'; import path from 'node:path';
const HERE = import.meta.dirname, SR = 48000, FORCE = process.argv.includes('--force'), TAU = Math.PI * 2;
let seed = 11; 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));
const add = (a, b, at = 0, g = 1) => { const o = Math.round(at * SR); for (let i = 0; i < b.length && o + i < a.length; i++) a[o + i] += b[i] * g; return a; };
// noise through a one-pole low-pass whose cutoff follows f(u) (hp: keep only the highs), 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(-TAU * f(u) / SR); lp += k * (rnd() * 2 - 1 - lp); const v = hp ? lp - prev : lp; prev = lp; a[i] = v * env(u); } return a; };
// a sine whose pitch glides from f0 to f1 (rate r), with an exponential decay d
const sweep = (dur, f0, f1, r, d, sat = 0) => { const a = buf(dur); let ph = 0; for (let i = 0; i < a.length; i++) { const t = i / SR; ph += TAU * (f1 + (f0 - f1) * Math.exp(-t * r)) / SR; let v = Math.sin(ph) * Math.exp(-t * d) * Math.min(1, t * 3000); a[i] = sat ? Math.tanh(v * sat) / Math.tanh(sat) : v; } return a; };
const bell = (dur, f, d, parts = [[1, 1], [2.76, 0.3], [5.4, 0.12]]) => { const a = buf(dur); for (const [m, g] of parts) for (let i = 0; i < a.length; i++) { const t = i / SR; a[i] += g * Math.sin(TAU * f * m * t) * Math.exp(-t * d * m ** 0.5) * Math.min(1, t * 1500); } return a; };
const click = (dur = 0.03, f = 6000, d = 9) => filtered(dur, () => f, u => Math.exp(-u * d), 1);
const tick = f => { const a = buf(0.05); for (let i = 0; i < a.length; i++) { const t = i / SR; a[i] = (0.65 * Math.sin(TAU * f * t) + 0.35 * (rnd() * 2 - 1)) * Math.exp(-t * 170); } return a; };

const S = {
  // the stamp: a hard knock, a paper slap and a sub thump under it
  boom: () => { const a = sweep(1.1, 75, 34, 6, 4.8, 1.3); add(a, filtered(0.8, u => 400 * Math.exp(-u * 3) + 60, u => Math.exp(-u * 6)), 0, 0.35); return a; },
  bassdrop: () => sweep(1.2, 110, 49, 9, 1.6, 2.2),
  whoosh: () => filtered(0.6, u => 300 + 3200 * Math.sin(Math.PI * u) ** 2, u => Math.sin(Math.PI * Math.min(1, u * 1.3)) ** 2 * (1 - u * 0.3)),
  swoosh: () => filtered(0.65, u => 500 + 5200 * u, u => Math.sin(Math.PI * Math.min(1, u * 1.6)) ** 2 * (1 - u)),
  uipop: () => { const a = buf(0.18); let ph = 0; for (let i = 0; i < a.length; i++) { const t = i / SR, f = 340 + 760 * Math.exp(-t * 50); ph += TAU * f / SR; a[i] = Math.sin(ph) * Math.exp(-t * 30) * Math.min(1, t * 2500); } return a; },
  uitick: () => { const a = click(0.025, 7000, 12); add(a, tick(3100), 0, 0.6); return a; },
  tick: () => tick(2400), tick1: () => tick(2200), tick2: () => tick(2700), tick3: () => tick(3200),
  mouse: () => { const a = buf(0.12); add(a, click(0.02, 5000, 14)); add(a, sweep(0.03, 1800, 1200, 80, 120), 0, 0.4); add(a, click(0.02, 6500, 14), 0.055, 0.6); return a; },
  send: () => { const a = filtered(0.45, u => 900 + 7000 * u * u, u => Math.sin(Math.PI * u) ** 1.5); add(a, sweep(0.15, 900, 1700, 12, 18), 0.18, 0.35); return a; },
  notif: () => { const a = bell(0.8, 1318.5, 5); add(a, bell(0.7, 1975.5, 6), 0.11, 0.8); return a; },
  kaching: () => { const a = buf(1.0); add(a, filtered(0.06, () => 3000, u => Math.exp(-u * 6), 1), 0, 0.8); for (let k = 0; k < 14; k++) add(a, bell(0.5, 2600 + rnd() * 4200, 14, [[1, 1]]), 0.02 + rnd() * 0.12, 0.25); add(a, bell(1.0, 2093, 4), 0.1, 0.9); add(a, bell(0.9, 3136, 5), 0.1, 0.6); return a; },
  error: () => { const a = buf(0.6); for (let i = 0; i < a.length; i++) { const t = i / SR; a[i] = (0.6 * Math.sign(Math.sin(TAU * 150 * t)) + 0.4 * Math.sign(Math.sin(TAU * 226 * t))) * (0.6 + 0.4 * Math.sign(Math.sin(TAU * 18 * t))) * Math.min(1, t * 200) * Math.min(1, (0.6 - t) * 30) * 0.5; } return a; },
  counter: () => { const a = buf(1.5); for (let k = 0; k < 27; k++) add(a, tick(2600 + (k % 3) * 300), k / 18, 0.7 + 0.3 * (k % 2)); return a; },
  typing: () => { const a = buf(2.0); let t = 0; while (t < 1.95) { add(a, filtered(0.035, () => 2400 + rnd() * 2600, u => Math.exp(-u * 7), 1), t, 0.6 + rnd() * 0.4); add(a, sweep(0.03, 420 + rnd() * 140, 300, 60, 90), t, 0.25); t += 0.06 + rnd() * 0.09; } return a; },
  stopwatch: () => { const a = buf(2.0); for (let k = 0; k < 16; k++) add(a, sweep(0.03, 3600, 3000, 90, 160), k * 0.125, k % 2 ? 0.7 : 1); return a; },
  cards: () => { const a = buf(0.8); [0, 0.07, 0.15, 0.26].forEach((at, k) => add(a, filtered(0.16, () => 1500 + 900 * k, u => Math.exp(-u * 9) * Math.min(1, u * 60)), at, 1 - k * 0.15)); return a; },
  riser: () => { const a = filtered(1.5, u => 300 + 6500 * u * u, u => u ** 1.8); let ph = 0; for (let i = 0; i < a.length; i++) { const u = i / a.length; ph += TAU * (110 + 360 * u * u) / SR; a[i] += 0.35 * Math.sin(ph) * u ** 2; } return a; },
  sparkle: () => { const a = buf(1.0); for (let k = 0; k < 9; k++) add(a, bell(0.5, 3000 + rnd() * 3500, 10, [[1, 1], [2.01, 0.25]]), k * 0.06 + rnd() * 0.03, 0.7 - k * 0.05); 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()); wav(f, a); console.log('made sfx/' + name + '.wav');
}
```

### `bed.mjs`

```js
// Prism · THE BED. A soft synthwave bed synthesized to this video's length → <video>\bed.wav (render.mjs --mix
// --bed <video>\bed.wav): a warm detuned-saw pad (Am → F → C → G, two bars each at 100 bpm), a plucked arpeggio of the
// chord in eighths with a dotted-eighth echo, a soft sub pulse on the beats and quiet off-beat hats. Nothing to download,
// no credits; the reference's bed is a quiet synth pad ~16 dB under the voice.
//   node bed.mjs --video <dir> [--bpm 100] [--seed 1]      (after build-timeline.mjs; run it again whenever the timing changes)
import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os';
import { videoDir } from './engine/markup.mjs';
try { os.setPriority(os.constants.priority.PRIORITY_LOW); } catch { }
const HERE = import.meta.dirname, { abs: VD } = videoDir(HERE), SR = 48000, TAU = Math.PI * 2;
const TLf = path.join(VD, 'timeline.json');
if (!fs.existsSync(TLf)) { console.error('no timeline.json: run build-timeline.mjs first'); process.exit(1); }
const TL = JSON.parse(fs.readFileSync(TLf, 'utf8'));
const arg = (k, d) => { const i = process.argv.indexOf(k); return i > 0 ? +process.argv[i + 1] : d; };
const BPM = arg('--bpm', 100), BEAT = 60 / BPM, BAR = BEAT * 4, dur = TL.total + 0.6, n = Math.round(dur * SR);
let seed = arg('--seed', 1) >>> 0; const rnd = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
const hz = m => 440 * Math.pow(2, (m - 69) / 12);
// chords (MIDI): Am, F, C, G: two bars each
const CH = [[57, 60, 64, 69], [53, 57, 60, 65], [48, 55, 60, 64], [55, 59, 62, 67]];
const chordAt = t => CH[Math.floor(t / (BAR * 2)) % CH.length];
const L = new Float32Array(n), R = new Float32Array(n);
// 1. the pad: each chord note as two detuned saws (8 harmonics), slow swell in and out per chord
for (let c = 0; c * BAR * 2 < dur; c++) {
  const ch = CH[c % CH.length], t0 = c * BAR * 2, t1 = t0 + BAR * 2, ATT = 0.5, REL = 0.7;
  const i0 = Math.floor(t0 * SR), i1 = Math.min(n, Math.ceil((t1 + REL) * SR));
  ch.forEach((m, k) => {
    const f = hz(m), pan = [-0.4, 0.4, -0.15, 0.15][k], ph = [rnd() * TAU, rnd() * TAU];
    for (let i = i0; i < i1; i++) {
      const t = i / SR, u = t - t0, env = u < ATT ? Math.sin(u / ATT * Math.PI / 2) ** 2 : t < t1 ? 1 : Math.max(0, 1 - (t - t1) / REL) ** 2;
      let v = 0;
      for (let d = 0; d < 2; d++) { const fd = f * (d ? 1.004 : 0.996); for (let hN = 1; hN <= 8; hN++) v += Math.sin(TAU * fd * hN * t + ph[d] * hN) / hN; }
      v *= 0.05 * env;
      L[i] += v * (1 - pan); R[i] += v * (1 + pan);
    }
  });
}
// a slowly breathing low-pass on the pad (two one-poles, 900–1600 Hz)
{ let l1 = 0, l2 = 0, r1 = 0, r2 = 0; for (let i = 0; i < n; i++) { const fc = 1250 + 350 * Math.sin(TAU * 0.07 * i / SR), a = 1 - Math.exp(-TAU * fc / SR); l1 += a * (L[i] - l1); l2 += a * (l1 - l2); r1 += a * (R[i] - r1); r2 += a * (r1 - r2); L[i] = l2; R[i] = r2; } }
// 2. the arpeggio: eighth-note plucks up and down the chord (an octave up), with a dotted-eighth ping-pong echo
const AL = new Float32Array(n), AR = new Float32Array(n), STEP = BEAT / 2, ORDER = [0, 1, 2, 3, 2, 1, 2, 3];
for (let s = 0; s * STEP < dur; s++) {
  const t0 = s * STEP, ch = chordAt(t0 + 0.001), m = ch[ORDER[s % ORDER.length]] + 12, f = hz(m), i0 = Math.floor(t0 * SR), i1 = Math.min(n, i0 + Math.floor(0.45 * SR));
  const acc = s % 4 === 0 ? 1 : 0.7;
  for (let i = i0; i < i1; i++) { const u = (i - i0) / SR, env = Math.min(1, u / 0.004) * Math.exp(-u / 0.12); const v = (Math.sin(TAU * f * u) + 0.35 * Math.sin(TAU * 2 * f * u) + 0.12 * Math.sin(TAU * 3 * f * u)) * 0.07 * env * acc; AL[i] += v; AR[i] += v; }
}
{ const D = Math.round(BEAT * 0.75 * SR); for (let i = D; i < n; i++) { AL[i] += AR[i - D] * 0.42; AR[i] += AL[i - D] * 0.42 * 0.9; } }
for (let i = 0; i < n; i++) { L[i] += AL[i] * 0.9; R[i] += AR[i] * 1.0; }
// 3. a soft sub pulse on every beat (the chord's root, two octaves down)
for (let b = 0; b * BEAT < dur; b++) {
  const t0 = b * BEAT, f = hz(chordAt(t0 + 0.001)[0] - 12), i0 = Math.floor(t0 * SR), i1 = Math.min(n, i0 + Math.floor(BEAT * SR));
  for (let i = i0; i < i1; i++) { const u = (i - i0) / SR, env = Math.min(1, u / 0.01) * Math.exp(-u / 0.22); const v = Math.sin(TAU * f * u) * 0.16 * env; L[i] += v; R[i] += v; }
}
// 4. quiet off-beat hats (high-passed noise ticks)
for (let b = 0; b * BEAT < dur; b++) {
  const t0 = b * BEAT + BEAT / 2, i0 = Math.floor(t0 * SR), i1 = Math.min(n, i0 + Math.floor(0.05 * SR)); let prev = 0;
  for (let i = i0; i < i1; i++) { const u = (i - i0) / SR, w = rnd() * 2 - 1, hp = w - prev; prev = w; const v = hp * 0.018 * Math.exp(-u / 0.012); L[i] += v * 0.8; R[i] += v; }
}
// fade in over 0.25 s, normalise to −2 dBFS peak, write 16-bit stereo
let m = 0; for (let i = 0; i < n; i++) { const f = Math.min(1, i / SR / 0.25); L[i] *= f; R[i] *= f; m = Math.max(m, Math.abs(L[i]), Math.abs(R[i])); }
const g = 0.79 / (m || 1), buf = Buffer.alloc(44 + n * 4);
buf.write('RIFF', 0); buf.writeUInt32LE(36 + n * 4, 4); buf.write('WAVEfmt ', 8); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(2, 22);
buf.writeUInt32LE(SR, 24); buf.writeUInt32LE(SR * 4, 28); buf.writeUInt16LE(4, 32); buf.writeUInt16LE(16, 34); buf.write('data', 36); buf.writeUInt32LE(n * 4, 40);
for (let i = 0; i < n; i++) { buf.writeInt16LE(Math.round(L[i] * g * 32767), 44 + i * 4); buf.writeInt16LE(Math.round(R[i] * g * 32767), 46 + i * 4); }
fs.writeFileSync(path.join(VD, 'bed.wav'), buf);
console.log(`bed.wav ${dur.toFixed(1)} s · ${BPM} bpm · Am F C G`);
```

### `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.PRISM_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(), 'prism-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
// Prism · 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 (Liam, multilingual v2, speed 1.15)
// unless script.mjs exports VOICE / MODEL / SETTINGS / SPEED. Then run votight.mjs (the reference's short pauses). Never prints the key.
import fs from 'node:fs';
import path from 'node:path';
import { parse, loadScript, videoDir } from './engine/markup.mjs';
const HERE = import.meta.dirname;
const { abs: VD } = videoDir(HERE);
const S = await loadScript(VD);
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);
```

### `votight.mjs`

```js
// Prism · TIGHTEN THE VOICE. The reference talks in a rapid, even stream (~205–227 wpm, short breaths); an
// ElevenLabs take leaves long pauses after sentences. This shortens every pause inside each take (it cuts only real
// silence, with short fades) and remaps the word times, so no new voice is paid for.
//   node votight.mjs --video <dir> [--stop 0.2] [--comma 0.1] [--word 0.04] [--tempo 1]
// --stop: the pause kept after . ! ?   --comma: after , ; :   --word: anywhere else   --tempo: an extra speed factor (pitch kept)
// Writes vo\<id>.t.wav + vo\<id>.t.json next to each take; build-timeline.mjs uses them while their words still match the
// script (delete them to go back to the raw takes). Run it again after a gen-vo.mjs re-take.
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync, execFileSync } from 'node:child_process';
import { loadScript, videoDir } from './engine/markup.mjs';
const HERE = import.meta.dirname, FF = 'ffmpeg';
const { abs: VD } = videoDir(HERE);
const S = await loadScript(VD);
if (fs.existsSync(path.join(VD, 'vo', 'FROZEN'))) { console.error('this voice is FROZEN (the example): tighten takes in your own video folder'); process.exit(1); }
const arg = (k, d) => { const i = process.argv.indexOf(k); return i > 0 ? +process.argv[i + 1] : d; };
const STOP = arg('--stop', 0.2), COMMA = arg('--comma', 0.1), WORD = arg('--word', 0.04), TEMPO = arg('--tempo', 1), SR = 48000;
let before = 0, after = 0;
for (const id of S.ORDER) {
  const jf = path.join(VD, 'vo', id + '.json'), mp3 = path.join(VD, 'vo', id + '.mp3');
  if (!fs.existsSync(jf)) { console.log(id, 'no take'); continue; }
  const J = JSON.parse(fs.readFileSync(jf, 'utf8'));
  const r = spawnSync(FF, ['-v', 'error', '-i', mp3, '-ac', '1', '-ar', String(SR), '-f', 's16le', '-'], { maxBuffer: 1 << 30 });
  const P = new Int16Array(r.stdout.buffer, r.stdout.byteOffset, r.stdout.length / 2);
  // 5 ms energy frames; silence = 38 dB under the take's loud frames
  const FR = SR / 200, nF = Math.floor(P.length / FR), dB = new Float32Array(nF);
  for (let f = 0; f < nF; f++) { let s = 0; for (let i = f * FR; i < (f + 1) * FR; i++) s += (P[i] / 32768) ** 2; dB[f] = 10 * Math.log10(s / FR + 1e-12); }
  const THR = [...dB].sort((a, b) => a - b)[Math.floor(nF * 0.9)] - 38;
  const end = J.tokens.at(-1).t1;
  // the word a pause follows: the last token that starts before the pause's middle
  const after_ = t => { let k = null; for (const tk of J.tokens) if (tk.t0 < t) k = tk; return k; };
  const cuts = [];
  for (let f = 0; f < nF;) {
    if (dB[f] > THR) { f++; continue; }
    let g = f; while (g < nF && dB[g] <= THR) g++;
    const a = f / 200, b = g / 200; f = g;
    if (b - a < 0.07 || a >= end) continue;
    const lead = a < 0.01, tk = after_((a + b) / 2), w = tk ? tk.said : '';
    const keep = lead ? 0.02 : /[.!?]$/.test(w) ? STOP : /[,;:]$/.test(w) ? COMMA : WORD;
    const s0 = lead ? 0 : a + 0.015, s1 = b - 0.015, drop = (s1 - s0) - keep;
    if (drop > 0.01) cuts.push(lead ? [0, drop] : [s0 + (s1 - s0 - drop) / 2, s0 + (s1 - s0 + drop) / 2]);
  }
  // splice with 4 ms fades at every cut
  const out = [], fade = Math.round(0.004 * SR), map = []; let p0 = 0, dst = 0;
  for (const [a, b] of [...cuts, [P.length / SR, P.length / SR]]) {
    const i0 = Math.round(p0 * SR), i1 = Math.round(a * SR), seg = Array.from(P.subarray(i0, i1));
    for (let i = 0; i < Math.min(fade, seg.length); i++) { if (p0 > 0) seg[i] *= i / fade; if (a < P.length / SR) seg[seg.length - 1 - i] *= i / fade; }
    map.push([p0, a, dst]); dst += (i1 - i0) / SR; for (const v of seg) out.push(Math.round(v)); p0 = b;
  }
  const T = t => { let d = null; for (const [s0, s1, d0] of map) if (t <= s1) { d = d0 + Math.max(0, t - s0); break; } if (d == null) { const [s0, , d0] = map.at(-1); d = d0 + (t - s0); } return +(d / TEMPO).toFixed(3); };
  const raw = path.join(VD, 'vo', id + '.t.raw'); fs.writeFileSync(raw, Buffer.from(new Int16Array(out).buffer));
  execFileSync(FF, ['-v', 'error', '-y', '-f', 's16le', '-ar', String(SR), '-ac', '1', '-i', raw, ...(TEMPO !== 1 ? ['-af', `atempo=${TEMPO}`] : []), '-ar', String(SR), path.join(VD, 'vo', id + '.t.wav')]);
  fs.rmSync(raw);
  const toks = J.tokens.map(k => ({ ...k, t0: T(k.t0), t1: T(k.t1) }));
  fs.writeFileSync(path.join(VD, 'vo', id + '.t.json'), JSON.stringify({ ...J, tokens: toks, dur: +(dst / TEMPO).toFixed(3), tight: { stop: STOP, comma: COMMA, word: WORD, tempo: TEMPO, cuts: cuts.length } }, null, 1));
  const n = J.text.split(' ').length; before += end; after += toks.at(-1).t1;
  console.log(`${id}: ${cuts.length} pauses shortened · ${end.toFixed(2)} → ${toks.at(-1).t1.toFixed(2)} s · ${(n / toks.at(-1).t1 * 60).toFixed(0)} wpm`);
}
console.log(`speech ${before.toFixed(2)} → ${after.toFixed(2)} s. Now: node build-timeline.mjs`);
```

### `build-timeline.mjs`

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

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

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

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

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

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

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

// ---------- mix ----------
if (has('--mix')) {
  if (!fs.existsSync(V('vo.wav'))) { console.error('no vo.wav: run build-timeline.mjs'); process.exit(1); }
  if (!fs.existsSync(V('cues.json'))) { console.error('no cues.json: run any page step first (--stills or --full)'); process.exit(1); }
  const { total, cues, looks } = JSON.parse(fs.readFileSync(V('cues.json'), 'utf8'));
  const DUR = Math.min(total, +arg('--dur', total));
  const bed = arg('--bed') ? path.resolve(HERE, arg('--bed')) : V('bed.wav'), MV = +arg('--music', 0.085);
  if (!fs.existsSync(bed)) { console.error('no ' + bed + ': run node bed.mjs --video <dir> first'); process.exit(1); }
  // sfx: <video>\sfx, then the project's sfx\ (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
// Prism · 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.d).join(' ') : S.ORDER.map(id => spoken(S.TAKES[id])).join(' ');   // the shown words: Whisper writes "$30" and ".md", not "thirty dollars"
  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 content skill" (40.2 s, 6 sections, 12 scenes, Liam). Copy it to start a new video, then rewrite the takes and every scene for your script. The DM's picture (`assets/pfp-appmorphy.jpg`) is optional: without it the card shows a drawn avatar.

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

```js
// PRISM · "Never pay for a content skill" (after @robin_linkerpro's glass-neon explainers: "This AI skill lets you scrape the
// entire internet…", "The perfect structure for short-form content", "Someone released a repo that runs your LinkedIn…").
// The pattern: a claim about a thing ("This X lets you …, even …, without paying for a single …") → who / what it is →
// what happened → the answer as a list → "This format is called …" → "comment KEYWORD and I'll send it to you directly".
// Markup (engine/markup.mjs):  /  a new caption chunk (the engine also chunks by itself: ≤ 3 words)   *word*  key word
//   {shown|said}  caption shows one thing, voice says another
export const VOICE = 'TX3LPaxmHKxFdv7VOQHJ';    // ElevenLabs "Liam": the reference narrator measures ~175 Hz / 205–227 wpm, Liam ~178 Hz
export const MODEL = 'eleven_multilingual_v2';
export const SETTINGS = { stability: 0.4, similarity_boost: 0.8, style: 0.45, use_speaker_boost: true };
export const SPEED = 1.15;
export const ORDER = ['s0', 's1', 's2', 's3', 's4', 'cta'];
export const GAP = { s1: 0.2, s2: 0.2, s3: 0.22, s4: 0.2, cta: 0.24 };
export const LEAD = 0.08, HOLD = 1.6;
export const TAKES = {
  s0: "This library lets Claude make your reels / in *any* content format, / even the ones / people sell for *{$30,|thirty dollars,}* / without paying / for a *single* skill.",
  s1: "I'm Casper, / and I built *Morphy,* / an *agentic* / super app.",
  s2: "Recently, / I gave away / my own content format / and its *{SKILL.md,|skill dot M D,}* / completely *free.* / And people took it, / *repackaged* it, / and started selling it / for *{$30.|thirty dollars.}* / *{$30,|Thirty dollars,}* / for something / I gave away / for *free.*",
  s3: "So here's my *response.* / An *entire* library / of ready-to-use / content-format / *{.md|dot M D}* files, / inside *Morphy.*",
  s4: "No *paywalls.* / No *subscriptions.* / No strings / *attached.* / Because you shouldn't pay / for a *text* file / that was *free* / to begin with.",
  cta: "This format is called / *{prism.md.|prism dot M D.}* / So if you want it, / just comment / *{PRISM|prism}* / down below, / and I'll send it / to you *directly.* / Completely *free.*",
};
```

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

```js
// PRISM · "Never pay for a content skill": the scenes. Every beat is keyed to a spoken word: T0('s1', 'word').
// Layout: headline from y ≈ 300 (kicker above it), one glass object per beat in y ≈ 560–1290, the caption pill at y 1356.
// The floor's horizon is at y 1150 (F.HORIZON). Text stays inside x 60–960; centre things on x 510.
(() => {
  const { T0, T1, cue, scene, cam, enter, tw, clamp, lerp, eo, eio, back, div, add, h, html, typeIn, px } = { ...F, enter: F.enter };
  const P = F.parts;
  const whoosh = (t, g = 0.09) => cue('whoosh', t - 0.06, g);
  const pop = (t, g = 0.07) => cue('uipop', t, g);
  const tick = (t, g = 0.06) => cue(['tick1', 'tick2', 'tick3'][Math.floor(t * 10) % 3], t, g);
  // X: the n-th token that IS this word (T0 matches by prefix: 'pay' would find 'paywalls')
  const X = (id, w, n = 0) => { const nm = q => q.toLowerCase().replace(/[^a-z0-9]/g, ''); let c = 0; for (const k of F.S[id].tokens) if (nm(k.d) === nm(w)) { if (c === n) return k.t0; c++; } throw new Error(`no word "${w}" #${n} in ${id}`); };

  // ───────────── s0a · "This library lets Claude make your reels in any content format," ─────────────
  {
    const tOut = T0('s0', 'even') - 0.12;
    const sc = scene(-1, tOut, { accent: 'cyan', out: 'blur' }), el = sc.el;
    const head = P.head(el, 300, 'One library,\n*any_format*', { at: [0.1, T0('s0', 'library'), T0('s0', 'any')], size: 108 });
    const rings = P.rings(el, 510, 1112, 300, { c: 'cyan' });
    const orb = P.orb(el, 510, 845, 250, { c: 'cyan', c2: 'pink' });
    const tg1 = P.tagC(el, 235, 590, 'AI skills', 'pink'), tg2 = P.tagC(el, 800, 600, '<span style="display:inline-flex">' + F.spark(26) + '</span>Claude', 'amber');
    tg2.tag.querySelector('.d').remove();
    const names = ['kinetic.md', 'voxel.md', 'pixel.md', 'glow.md', 'comic.md', 'cutout.md'];
    const chips = names.map((n, i) => ({ a: P.anchor(el, 510, 845), n, i, ph: i / names.length * F.TAU }));
    chips.forEach(c => add(c.a.in, h('div', { style: { padding: '8px 18px', borderRadius: '14px', background: 'linear-gradient(160deg, rgba(63,224,255,0.28), rgba(26,30,86,0.92))', border: '2px solid rgba(63,224,255,0.9)', boxShadow: '0 0 14px rgba(63,224,255,0.6)', fontFamily: 'var(--mono)', fontWeight: 700, fontSize: '24px', color: '#eaffff', whiteSpace: 'nowrap' } }, c.n)));
    const tc = [T0('s0', 'any'), T0('s0', 'content'), T0('s0', 'content') + 0.2, T0('s0', 'format'), T0('s0', 'format') + 0.14, T0('s0', 'format') + 0.28];
    cam(sc, [[0, 1.0, 0, 0], [T0('s0', 'any') - 0.1, 1.03, 0, 6], [tOut, 1.06, 0, 14]]);
    sc.on(t => {
      head.update(t); rings.update(t); orb.update(t);
      enter(orb.el, t, -0.2, { from: 'zoom', s0: 0.6, float: 0.6 });
      enter(rings.el, t, -0.2, { from: 'fade', float: 0 });
      enter(tg1.el, t, T0('s0', 'library'), { from: 'pop', seed: 1 });
      enter(tg2.el, t, T0('s0', 'Claude'), { from: 'pop', seed: 2 });
      chips.forEach((c, i) => { const a = c.ph + t * 0.7, x = Math.cos(a) * 318, y = Math.sin(a) * 100 + 46, front = Math.sin(a) > -0.1; enter(c.a.el, t, tc[i], { from: 'pop', float: 0, x, y, s: front ? 1 : 0.86 }); c.a.el.style.zIndex = front ? 5 : 0; if (t >= tc[i]) c.a.el.style.opacity = (+c.a.el.style.opacity * (front ? 1 : 0.55)).toFixed(3); });
    });
    cue('sparkle', 0.02, 0.06); pop(T0('s0', 'library')); pop(T0('s0', 'Claude'));
    cue('swoosh', T0('s0', 'any') - 0.05, 0.08); tc.forEach(t => tick(t, 0.05));
  }

  // ───────────── s0b · "even the ones people sell for $30, without paying for a single skill." ─────────────
  {
    const tIn = T0('s0', 'even') - 0.1, tOut = T0('s1', "I'm") - 0.3;
    const sc = scene(tIn, tOut, { accent: 'pink', out: 'streak', sy: 860 }), el = sc.el;
    const head = P.head(el, 300, 'Even *paid* ones', { at: [T0('s0', 'even'), T0('s0', 'the') + 0.06, T0('s0', 'ones') + 0.04], size: 104, accent: 'pink' });
    const spec = [[380, 'hooks.md', 'blue', 'bolt', 'the'], [640, 'viral.md', 'pink', 'reel', 'ones'], [380, 'story.md', 'violet', 'book', 'people'], [640, 'edit.md', 'amber', 'cut', 'sell']];
    const tiles = spec.map(([x, label, c, icon, w], i) => ({ tl: P.tile(el, x, i < 2 ? 640 : 900, 220, { c, icon, label, price: '$30' }), t: T0('s0', w), i }));
    const tPrice = T0('s0', '$30'), tWo = T0('s0', 'without'), tPay = T0('s0', 'paying'), tZero = T0('s0', 'single');
    const bar = P.glass(el, 150, 1078, 720, 128, { c: 'pink', c2: 'violet', r: 28 });
    bar.in.innerHTML = `<div class="mono" style="position:absolute;left:30px;top:20px;font-size:18px;font-weight:700;letter-spacing:0.18em;color:rgba(235,238,255,0.65)">PRICE PER SKILL</div><div class="mono" style="position:absolute;left:28px;top:46px;font-size:56px;font-weight:800;color:#ff8ad6;text-shadow:0 0 14px rgba(255,79,184,0.7)">$30.00</div>`;
    const barOk = { el: div(el, 'abs', 150, 1078, 720, 128, '', { borderRadius: '28px', border: '3px solid #39f0a0', boxShadow: '0 0 26px rgba(57,240,160,0.75), inset 0 0 26px rgba(57,240,160,0.3)' }) };
    const strike = P.strike(el, 172, 1162, 250, { c: 'pink' });
    const zero = P.big(el, 760, 1144, '$0', { size: 110, c: 'green' });
    cam(sc, [[tIn, 1.0, 0, 0], [tWo - 0.05, 1.0, 0, 0], [tWo + 0.35, 0.97, 0, -20], [tZero, 0.97, 0, -20], [tZero + 0.12, 1.0, 0, -24], [tOut + 0.3, 1.02, 0, -26]]);
    sc.on(t => {
      head.update(t);
      tiles.forEach(({ tl, t: t0, i }) => { enter(tl.el, t, t0, { from: 'pop', s0: 0.4, seed: i + 3, spin: i % 2 ? 8 : -8 }); tl.lock(t, tZero + 0.1 + i * 0.07); const k = back((t - tPrice - i * 0.05) / 0.3); tl.price.style.opacity = t < tPrice + i * 0.05 ? 0 : 1; tl.price.style.transform = `translateX(-50%) scale(${(0.3 + 0.7 * k).toFixed(3)}) rotate(${(i % 2 ? 4 : -4)}deg)`; });
      enter(bar.el, t, tWo, { from: 'up', dist: 120, float: 0.4 });
      enter(barOk.el, t, tZero, { from: 'fade', float: 0.4 });
      strike.update(t, tPay + 0.05);
      enter(zero.el, t, tZero, { from: 'slam', s0: 2.2, float: 0.5 });
    });
    whoosh(tIn); tiles.forEach(({ t }, i) => pop(t, 0.07));
    cue('kaching', tPrice, 0.11); [0, 1, 2, 3].forEach(i => tick(tPrice + 0.05 * i + 0.02, 0.04));
    cue('swoosh', tWo - 0.05, 0.08); cue('swoosh', tPay + 0.04, 0.05);
    cue('bassdrop', tZero, 0.16); cue('sparkle', tZero + 0.05, 0.07); [0, 1, 2, 3].forEach(i => tick(tZero + 0.1 + i * 0.07, 0.05));
    F.shake(tZero, 6, 0.22);
  }

  // ───────────── s1 · "I'm Casper, and I built Morphy, an agentic super app." ─────────────
  {
    const tIn = T0('s1', "I'm") - 0.05, tOut = T0('s2', 'Recently') - 0.25;
    const sc = scene(tIn, tOut, { accent: 'cyan', in: 'streak', sy: 860 }), el = sc.el;
    const head = P.head(el, 318, '*Morphy*', { at: [T0('s1', 'Morphy')], size: 170, kicker: "I'm Casper · I built", kat: T0('s1', "I'm"), kgap: 30, grad: 'linear-gradient(180deg, #e8fdff 0%, #3fe0ff 42%, #ff4fb8 100%)' });
    const rings = P.rings(el, 510, 1110, 250, { c: 'cyan' });
    const cube = P.cube(el, 510, 850, 220, { c: 'cyan', glyph: P.icon('grid', 92, '#eaffff', 2) });
    const t1 = P.tagC(el, 235, 690, 'agentic', 'violet'), t2 = P.tagC(el, 790, 690, 'super app', 'pink'), t3 = P.tagC(el, 510, 1236, 'every AI in one place', 'cyan');
    const shock = F.parts.shock(el, 510, 850, T0('s1', 'app'), { r: 260, color: '#3fe0ff' });
    cam(sc, [[tIn, 1.0, 0, 0], [T0('s1', 'Morphy'), 1.02, 0, 0], [T0('s1', 'Morphy') + 0.12, 1.05, 0, 0], [tOut + 0.3, 1.07, 0, 6]]);
    sc.on(t => {
      head.update(t); rings.update(t); cube.update(t); shock.update(t);
      enter(cube.el, t, tIn + 0.05, { from: 'zoom', s0: 0.3, float: 0.8 });
      enter(rings.el, t, tIn + 0.1, { from: 'fade', float: 0 });
      enter(t1.el, t, T0('s1', 'agentic'), { from: 'pop', seed: 4 });
      enter(t2.el, t, T0('s1', 'super'), { from: 'pop', seed: 5 });
      enter(t3.el, t, T0('s1', 'app') + 0.05, { from: 'up', dist: 40, seed: 6 });
    });
    whoosh(tIn, 0.1); cue('typing', T0('s1', "I'm"), 0.05, 0.32); cue('sparkle', T0('s1', 'Morphy'), 0.07); pop(T0('s1', 'Morphy'), 0.06);
    pop(T0('s1', 'agentic')); pop(T0('s1', 'super')); cue('swoosh', T0('s1', 'app'), 0.06); cue('boom', T0('s1', 'app'), 0.05);
  }

  // ───────────── s2a · "Recently, I gave away my own content format and its SKILL.md, completely free." ─────────────
  {
    const tIn = T0('s2', 'Recently') - 0.2, tOut = X('s2', 'and', 1) - 0.15;
    const sc = scene(tIn, tOut, { accent: 'green' }), el = sc.el;
    const head = P.head(el, 318, 'I gave it *away*', { at: [T0('s2', 'I'), T0('s2', 'gave'), T0('s2', 'gave') + 0.1, T0('s2', 'away')], size: 104, accent: 'green', kicker: 'Recently', kat: T0('s2', 'Recently') });
    const term = P.term(el, 125, 530, 770, 470, { c: 'green', c2: 'cyan', title: 'SKILL.MD', right: 'MY OWN FORMAT' });
    const cmd = add(term.body, h('div', { style: { color: '#eef6ff', marginBottom: '10px' } }));
    const rows = P.lines(term.body, [['the hook', 'ready'], ['the look', 'ready'], ['the voice', 'ready'], ['the edit', 'ready']]);
    const tCmd = T0('s2', 'own'), tR = [T0('s2', 'format') - 0.05, T0('s2', 'and') + 0.05, T0('s2', 'its') + 0.08, T0('s2', 'SKILL.md') + 0.2];
    const tFree = T0('s2', 'free');
    const zero = P.big(el, 330, 1118, '$0', { size: 150, c: 'green' }), tg = P.tagC(el, 668, 1118, 'free for everyone', 'green');
    cam(sc, [[tIn, 1.0, 0, 0], [T0('s2', 'SKILL.md'), 1.02, 0, 0], [T0('s2', 'SKILL.md') + 0.12, 1.045, 0, -4], [tOut + 0.3, 1.06, 0, -8]]);
    sc.on(t => {
      head.update(t);
      enter(term.el, t, T0('s2', 'Recently') + 0.15, { from: 'up', dist: 140, float: 0.5 });
      typeIn(cmd, '$ cat SKILL.md', t, tCmd, tCmd + 0.45);
      rows.forEach((r, i) => enter(r.el, t, tR[i], { from: 'left', dist: 40, float: 0 }));
      const gl = clamp((t - T0('s2', 'SKILL.md')) / 0.15) * (1 - clamp((t - T0('s2', 'SKILL.md') - 0.3) / 0.6));
      term.el.style.boxShadow = `0 0 ${22 + 40 * gl}px rgba(57,240,160,${(0.55 + 0.4 * gl).toFixed(2)}), 0 0 70px rgba(57,240,160,0.2), 0 34px 70px rgba(0,0,0,0.45)`;
      enter(zero.el, t, T0('s2', 'completely'), { from: 'slam', s0: 2, float: 0.5 });
      enter(tg.el, t, tFree - 0.05, { from: 'pop', seed: 7 });
    });
    cue('swoosh', tIn, 0.07); cue('typing', tCmd, 0.06, 0.45); tR.forEach(t => tick(t, 0.06)); cue('sparkle', T0('s2', 'SKILL.md'), 0.06);
    cue('bassdrop', T0('s2', 'completely'), 0.12); pop(tFree - 0.05, 0.07);
  }

  // ───────────── s2b · "And people took it, repackaged it, and started selling it for $30." ─────────────
  {
    const tIn = X('s2', 'and', 1) - 0.1, tOut = T0('s2', '$30', 1) - 0.14;
    const sc = scene(tIn, tOut, { accent: 'pink', out: 'streak', sy: 760 }), el = sc.el;
    const head = P.head(el, 300, 'They\n*repackaged* it', { at: [T0('s2', 'people'), T0('s2', 'repackaged'), T0('s2', 'repackaged') + 0.2], size: 100, accent: 'pink' });
    const tTook = T0('s2', 'took'), tRep = T0('s2', 'repackaged'), tSell = T0('s2', 'selling'), t30 = T0('s2', '$30');
    const file = P.term(el, 120, 590, 380, 300, { c: 'green', c2: 'cyan', title: 'SKILL.MD' });
    P.lines(file.body, [['hook', ''], ['look', ''], ['voice', '']]);
    file.body.style.fontSize = '26px';
    const box = P.cube(el, 735, 735, 210, { c: 'pink', glyph: 'PRO', spin: 26, y0: 20 });
    const ribbon = P.tagC(el, 735, 885, 'premium skill', 'pink');
    const curs = [['anon', '#ff4fb8', [[tTook - 0.4, 120, 1000], [tTook, 300, 760], [tRep, 560, 740]]], ['copycat', '#ffc24d', [[tTook - 0.35, 760, 1030], [tTook + 0.06, 420, 840], [tRep + 0.05, 620, 820]]]].map(([n, c, k]) => ({ c: F.parts.cursor2(el, n, c), k }));
    const ctr = P.counter(el, 140, 1000, 740, { c: 'amber', c2: 'pink', icon: 'cart', ic: 'amber', label: 'SOLD FOR · PER COPY' });
    const buyers = [[160, 965, 0], [845, 955, 1], [800, 1192, 2], [210, 1200, 4]].map(([x, y, k]) => ({ a: P.chip(el, x, y, 'bought', k) }));
    cam(sc, [[tIn, 1.0, 0, 0], [tRep, 1.01, 0, 0], [tSell - 0.05, 1.0, 0, -10], [t30, 1.02, 0, -16], [t30 + 0.12, 1.055, 0, -20], [tOut + 0.4, 1.06, 0, -22]]);
    sc.on(t => {
      head.update(t); box.update(t);
      const pull = eio((t - tRep) / 0.4);
      enter(file.el, t, tIn + 0.08, { from: 'pop', float: 0.4, x: lerp(0, 430, pull), y: lerp(0, 50, pull), s: lerp(1, 0.25, pull), t1: tRep + 0.32, d1: 0.12 });
      enter(box.el, t, tRep - 0.08, { from: 'zoom', s0: 0.2, float: 0.7 });
      enter(ribbon.el, t, X('s2', 'it', 1), { from: 'drop', dist: 80, seed: 8 });
      curs.forEach(({ c, k }) => F.drive(c, t, k, [tTook + 0.02]));
      enter(ctr.el, t, tSell - 0.08, { from: 'up', dist: 120, float: 0.4 });
      const v = 30 * eo((t - tSell) / Math.max(0.2, t30 - tSell));
      html(ctr.num, '$' + v.toFixed(2));
      buyers.forEach(({ a }, i) => enter(a.el, t, t30 + 0.08 + i * 0.12, { from: 'pop', seed: 9 + i }));
    });
    cue('swoosh', tIn, 0.07); cue('mouse', tTook + 0.02, 0.1); cue('mouse', tTook + 0.08, 0.08);
    cue('whoosh', tRep - 0.05, 0.07); cue('cards', tRep + 0.25, 0.08); pop(X('s2', 'it', 1), 0.06);
    cue('swoosh', tSell - 0.1, 0.07); cue('counter', tSell, 0.07, t30 - tSell); cue('kaching', t30, 0.12);
    [0, 1, 2, 3].forEach(i => tick(t30 + 0.08 + i * 0.12, 0.05));
  }

  // ───────────── s2c · "$30, for something I gave away for free." ─────────────
  {
    const tIn = T0('s2', '$30', 1) - 0.06, tOut = T0('s3', 'So') - 0.25;
    const sc = scene(tIn, tOut, { accent: 'pink', in: 'streak', sy: 760 }), el = sc.el;
    const head = P.head(el, 280, '*$30*', { at: [T0('s2', '$30', 1)], size: 210, accent: 'pink' });
    const sub = P.head(el, 510, 'for something\nI gave away *free*', { at: [T0('s2', 'for', 1), T0('s2', 'something'), X('s2', 'I', 1), T0('s2', 'gave', 1), T0('s2', 'away', 1), T0('s2', 'free', 1)], size: 56, accent: 'green', lh: 1.08 });
    const tTheirs = T0('s2', 'something'), tMine = T0('s2', 'gave', 1), tFree = T0('s2', 'free', 1);
    const card = (x, c, c2, who, price, pc) => {
      const g = P.glass(el, x, 690, 380, 430, { c, c2, r: 30 });
      g.in.innerHTML = `<div class="mono" style="position:absolute;left:0;right:0;top:26px;text-align:center;font-size:20px;font-weight:700;letter-spacing:0.2em;color:rgba(235,238,255,0.7)">${who}</div><div style="position:absolute;left:50%;top:76px;transform:translateX(-50%)">${P.badge('file', 112, c)}</div><div style="position:absolute;left:0;right:0;top:206px;text-align:center;font-family:var(--mono);font-size:28px;font-weight:700;color:#eef">SKILL.md</div><div style="position:absolute;left:0;right:0;top:250px;text-align:center;font-size:118px;font-weight:850;letter-spacing:-0.04em;color:${pc};text-shadow:0 0 22px ${pc}">${price}</div>`;
      return g;
    };
    const theirs = card(535, 'pink', 'violet', 'THEIRS', '$30', '#ff6fc6'), mine = card(115, 'green', 'cyan', 'MINE', '$0', '#5dffb4');
    const crack = F.parts.crack(el, 535, 690, 380, 430, tFree + 0.05, { seed: 11, n: 4 });
    cam(sc, [[tIn, 1.06, 0, 0], [tIn + 0.4, 1.0, 0, 0], [tFree, 1.0, 0, 0], [tFree + 0.12, 1.035, 0, 0], [tOut + 0.3, 1.04, 0, 0]]);
    sc.on(t => {
      head.update(t); sub.update(t); crack.update(t);
      enter(theirs.el, t, tTheirs - 0.06, { from: 'right', dist: 160, seed: 12 });
      enter(mine.el, t, tMine - 0.06, { from: 'left', dist: 160, seed: 13 });
      const gl = clamp((t - tFree) / 0.15);
      mine.el.style.boxShadow = `0 0 ${22 + 40 * gl}px rgba(57,240,160,${(0.55 + 0.35 * gl).toFixed(2)}), 0 0 ${70 + 60 * gl}px rgba(57,240,160,${(0.2 + 0.2 * gl).toFixed(2)}), 0 34px 70px rgba(0,0,0,0.45)`;
      theirs.el.style.filter = t > tFree ? `saturate(${(1 - 0.5 * clamp((t - tFree) / 0.3)).toFixed(2)})` : 'none';
    });
    cue('bassdrop', tIn + 0.06, 0.15); cue('kaching', tIn + 0.08, 0.08); F.shake(tIn + 0.06, 8, 0.25);
    cue('swoosh', tTheirs - 0.08, 0.07); cue('swoosh', tMine - 0.08, 0.07); cue('sparkle', tFree, 0.07); cue('error', tFree + 0.05, 0.04);
  }

  // ───────────── s3a · "So here's my response." ─────────────
  {
    const tIn = T0('s3', 'So') - 0.2, tOut = T0('s3', 'An') - 0.15;
    const sc = scene(tIn, tOut, { accent: 'violet', out: 'streak', sy: 840 }), el = sc.el;
    const head = P.head(el, 318, "Here's my\n*response*", { at: [T0('s3', "here's"), T0('s3', 'my'), T0('s3', 'response')], size: 116, accent: 'violet' });
    const rings = P.rings(el, 510, 1110, 260, { c: 'violet', n: 4 });
    const tile = P.tile(el, 510, 850, 250, { c: 'violet', c2: 'blue', icon: 'send', label: 'reply', lock: false });
    cam(sc, [[tIn, 1.0, 0, 0], [tOut, 1.08, 0, 10]]);
    sc.on(t => { head.update(t); rings.update(t); enter(tile.el, t, T0('s3', 'my'), { from: 'zoom', s0: 0.2, float: 0.8 }); enter(rings.el, t, tIn + 0.1, { from: 'fade', float: 0 }); });
    cue('swoosh', tIn, 0.06); pop(T0('s3', 'my'), 0.07); cue('riser', tOut - 0.75, 0.07); whoosh(tOut + 0.04, 0.1);
  }

  // ───────────── s3b · "An entire library of ready-to-use content-format .md files, inside Morphy." ─────────────
  {
    const tIn = T0('s3', 'An') - 0.1, tOut = T0('s4', 'No') - 0.15, tShrink = T0('s3', 'files') - 0.05;
    const sc = scene(tIn, tOut, { accent: 'violet', in: 'streak', sy: 840 }), el = sc.el;
    const kick = div(el, 'abs', 80, 286, 600, 40, 'CONTENT FORMATS · LIBRARY', { fontFamily: 'var(--mono)', fontSize: '21px', fontWeight: 700, letterSpacing: '0.2em', color: 'rgba(225,228,255,0.75)', whiteSpace: 'nowrap' });
    const count = div(el, 'abs', 690, 272, 240, 60, '', { textAlign: 'right', fontSize: '50px', fontWeight: 850, letterSpacing: '-0.03em', whiteSpace: 'nowrap' });
    const group = P.anchor(el, 510, 730);
    const spec = [['kinetic.md', 'pink'], ['motion.md', 'amber'], ['voxel.md', 'cyan'], ['pixel.md', 'violet'], ['glow.md', 'blue'], ['cutout.md', 'violet'], ['stamp.md', 'pink'], ['prism.md', 'green']];
    const ts = [T0('s3', 'An'), T0('s3', 'entire'), T0('s3', 'library'), T0('s3', 'of'), T0('s3', 'ready-to-use') + 0.15, T0('s3', 'content-format'), T0('s3', 'content-format') + 0.45, T0('s3', '.md')];
    const slabs = spec.map(([label, c], i) => ({ s: P.slab(group.in, 0, -300 + i * 86, 640, 74, 86, { c, label, num: String(i + 1).padStart(2, '0') }), t: ts[i], i }));
    const head = P.head(el, 318, 'Inside *Morphy*', { at: [T0('s3', 'inside'), T0('s3', 'Morphy')], size: 112, accent: 'violet', grad: 'linear-gradient(180deg, #ffffff -20%, #9b7bff 45%, #ff4fb8 110%)' });
    const tg = P.tagC(el, 510, 492, 'ready-to-use .md files', 'violet');
    cam(sc, [[tIn, 1.0, 0, 0], [ts[7], 1.0, 0, 0], [ts[7] + 0.12, 1.03, 0, 0], [tShrink, 1.0, 0, 0], [tOut + 0.3, 1.03, 0, 0]]);
    sc.on(t => {
      let n = 0; ts.forEach(x => { if (t >= x) n++; });
      html(count, `<span style="color:#fff;text-shadow:0 0 14px rgba(155,123,255,0.8)">${String(Math.max(1, n)).padStart(2, '0')}</span><span style="color:rgba(200,205,255,0.45)">/08</span>`);
      enter(kick, t, tIn + 0.05, { from: 'fade', float: 0, t1: tShrink - 0.1 }); enter(count, t, tIn + 0.05, { from: 'fade', float: 0, t1: tShrink - 0.1 });
      slabs.forEach(({ s, t: t0, i }) => enter(s.el, t, t0, { from: 'right', dist: 220, float: 0.3, seed: i, rot: 0 }));
      const k = eio((t - tShrink) / 0.45);
      group.el.style.transform = `translate(0px, ${(lerp(0, 196, k)).toFixed(1)}px) scale(${lerp(1, 0.8, k).toFixed(4)})`;
      head.update(t); enter(tg.el, t, tShrink + 0.08, { from: 'pop', seed: 20 });
      // the newest slab glows; prism.md keeps glowing
      slabs.forEach(({ s, t: t0, i }) => { const g = i === 7 ? clamp((t - t0) / 0.2) : clamp((t - t0) / 0.1) * (1 - clamp((t - t0 - 0.3) / 0.4)); s.box.style.filter = g > 0.01 ? `brightness(${(1 + 0.45 * g).toFixed(2)}) drop-shadow(0 0 ${(18 * g).toFixed(0)}px rgba(255,255,255,0.4))` : 'none'; });
    });
    ts.forEach((t, i) => { tick(t, 0.06); if (i % 2) cue('swoosh', t - 0.04, 0.04); });
    cue('sparkle', ts[7] + 0.02, 0.08); cue('whoosh', tShrink, 0.07); pop(tShrink + 0.08, 0.06); cue('sparkle', T0('s3', 'Morphy'), 0.05);
  }

  // ───────────── s4a · "No paywalls. No subscriptions. No strings attached." ─────────────
  {
    const tIn = T0('s4', 'No') - 0.1, tOut = T0('s4', 'Because') + 0.02;
    const sc = scene(tIn, tOut, { accent: 'green' }), el = sc.el;
    const head = P.head(el, 318, 'No *catch*', { at: [T0('s4', 'No'), T0('s4', 'paywalls')], size: 120, accent: 'green' });
    const tP = T0('s4', 'paywalls'), tS = T0('s4', 'subscriptions'), tSt = T0('s4', 'strings'), tA = T0('s4', 'attached');
    const r1 = P.row(el, 135, 520, 750, 150, { c: 'pink', icon: 'lock', title: 'No paywalls' });
    const r2 = P.row(el, 135, 700, 750, 150, { c: 'amber', icon: 'cal', title: 'No subscriptions' });
    const r3 = P.row(el, 135, 880, 750, 150, { c: 'cyan', icon: 'link', title: 'No strings attached' });
    const s1 = add(r1.sub, h('div', { class: 'mono', style: { fontSize: '24px', fontWeight: 700, color: '#7dffc8', display: 'flex', alignItems: 'center', gap: '10px' } }, P.icon('unlock', 26, '#7dffc8', 2.6) + 'open to everyone'));
    const s2 = add(r2.sub, h('div', { class: 'mono', style: { fontSize: '24px', fontWeight: 700, color: '#ffe2a6', position: 'relative' } }, '<span class="old" style="position:relative;color:#ff8ad6">$9.99</span>&nbsp;&nbsp;→&nbsp;&nbsp;$0 / month'));
    const cut = add(r3.sub, h('div', { style: { position: 'relative', width: '420px', height: '30px' } }, `<svg width="420" height="30" style="overflow:visible"><path class="l" d="M0 15 Q 50 0 100 15 T 200 15" fill="none" stroke="#3fe0ff" stroke-width="4" style="filter:drop-shadow(0 0 6px #3fe0ff)"/><path class="r" d="M220 15 Q 270 30 320 15 T 420 15" fill="none" stroke="#3fe0ff" stroke-width="4" style="filter:drop-shadow(0 0 6px #3fe0ff)"/></svg>`));
    const cl = cut.querySelector('.l'), cr = cut.querySelector('.r');
    const old = s2.querySelector('.old'), strike = add(old, h('span', { style: { position: 'absolute', left: '-4px', right: '-4px', top: '50%', height: '4px', background: '#ff4fb8', boxShadow: '0 0 8px #ff4fb8', transformOrigin: '0 50%' } }));
    const tPill = tA + 0.04;
    const pill = P.anchor(el, 510, 1130);
    add(pill.in, h('div', { style: { display: 'flex', alignItems: 'center', gap: '12px', padding: '16px 34px', borderRadius: '999px', background: 'linear-gradient(180deg,#7bf0ff,#25b8e8)', color: '#071b2a', fontSize: '34px', fontWeight: 800, whiteSpace: 'nowrap', boxShadow: '0 0 26px rgba(63,224,255,0.75)' } }, '<span class="mono" style="font-weight:800">&gt;_</span> every format · free'));
    const wires = [600, 780, 960].map((y, i) => F.parts.wire(el, `M135 ${y} C 70 ${y}, 70 ${y + 60}, 70 ${(y + 1130) / 2} S 160 1130, 300 1130`, { color: ['#ff4fb8', '#ffc24d', '#3fe0ff'][i], width: 3.5, t0: tPill - 0.1 + i * 0.05, dur: 0.4, pulses: 1 }));
    cam(sc, [[tIn, 1.0, 0, 0], [tPill, 1.0, 0, 0], [tPill + 0.3, 0.98, 0, -10], [tOut + 0.3, 0.99, 0, -12]]);
    sc.on(t => {
      head.update(t);
      enter(r1.el, t, tP - 0.06, { from: 'right', dist: 180, seed: 21 }); enter(r2.el, t, tS - 0.06, { from: 'right', dist: 180, seed: 22 }); enter(r3.el, t, tSt - 0.06, { from: 'right', dist: 180, seed: 23 });
      strike.style.transform = `scaleX(${eo((t - tS - 0.25) / 0.25).toFixed(3)})`;
      const u = eo((t - tA) / 0.35); cl.style.transform = `translate(${(-u * 30).toFixed(1)}px, ${(u * 10).toFixed(1)}px) rotate(${(u * 6).toFixed(1)}deg)`; cr.style.transform = `translate(${(u * 30).toFixed(1)}px, ${(u * 10).toFixed(1)}px) rotate(${(-u * 6).toFixed(1)}deg)`; cl.style.opacity = cr.style.opacity = (1 - 0.5 * u).toFixed(2);
      wires.forEach(w => w.update(t));
      enter(pill.el, t, tPill, { from: 'pop', seed: 24 });
    });
    cue('swoosh', tP - 0.1, 0.07); pop(tP + 0.05); cue('swoosh', tS - 0.1, 0.07); cue('swoosh', tS + 0.22, 0.04); cue('swoosh', tSt - 0.1, 0.07); cue('mouse', tA, 0.1);
    cue('send', tPill - 0.1, 0.06); pop(tPill, 0.08);
  }

  // ───────────── s4b · "Because you shouldn't pay for a text file that was free to begin with." ─────────────
  {
    const tIn = T0('s4', 'Because') + 0.06, tOut = T0('cta', 'This') - 0.25;
    const sc = scene(tIn, tOut, { accent: 'cyan', out: 'streak', sy: 820 }), el = sc.el;
    const head = P.head(el, 300, "It's just a\n*text_file*", { at: [T0('s4', 'Because') + 0.16, T0('s4', 'you') + 0.04, T0('s4', 'shouldn'), T0('s4', 'text')], size: 108, accent: 'cyan' });
    const tPay = X('s4', 'pay'), tText = T0('s4', 'text'), tFree = T0('s4', 'free');
    const doc = P.term(el, 160, 580, 700, 520, { c: 'cyan', c2: 'violet', title: 'PRISM.MD', right: 'TEXT · 4 KB' });
    const lines = ['# prism.md', '## the look', '- glass cards, neon rims', '## the voice', '- fast, warm, clear', '## the edit'];
    const ls = lines.map(l => add(doc.body, h('div', { style: { whiteSpace: 'nowrap', color: l.startsWith('#') ? '#8fefff' : '#e6ecff', fontSize: '29px' } })));
    const tag = P.anchor(el, 780, 610, 30);
    add(tag.in, h('div', { style: { padding: '10px 26px', borderRadius: '16px', background: 'linear-gradient(180deg,#ff6fc6,#d9269a)', color: '#fff', fontSize: '50px', fontWeight: 850, boxShadow: '0 0 22px rgba(255,79,184,0.8)', border: '3px solid rgba(255,255,255,0.85)', transform: 'rotate(8deg)', position: 'relative' } }, '$30<span class="st" style="position:absolute;left:10px;right:10px;top:50%;height:6px;background:#fff;border-radius:4px;box-shadow:0 0 8px #fff;transform-origin:0 50%;transform:scaleX(0)"></span>'));
    const st = tag.in.querySelector('.st');
    const free = P.anchor(el, 640, 1000, 31);
    add(free.in, h('div', { style: { padding: '14px 38px', borderRadius: '20px', background: 'linear-gradient(180deg,#8dffd0,#21d68a)', color: '#05261a', fontSize: '74px', fontWeight: 900, letterSpacing: '0.02em', boxShadow: '0 0 34px rgba(57,240,160,0.85)', border: '4px solid rgba(255,255,255,0.9)', transform: 'rotate(-7deg)' } }, 'FREE'));
    cam(sc, [[tIn, 1.0, 0, 0], [tFree, 1.0, 0, 0], [tFree + 0.12, 1.04, 0, 0], [tOut + 0.3, 1.05, 0, 0]]);
    sc.on(t => {
      head.update(t);
      enter(doc.el, t, tIn + 0.1, { from: 'up', dist: 140, float: 0.4 });
      ls.forEach((l, i) => typeIn(l, lines[i], t, tText - 0.1 + i * 0.16, tText + 0.06 + i * 0.16, false));
      enter(tag.el, t, tPay, { from: 'slam', s0: 2.4, float: 0.6 });
      st.style.transform = `scaleX(${eo((t - tFree + 0.12) / 0.2).toFixed(3)})`;
      enter(free.el, t, tFree, { from: 'slam', s0: 2.6, float: 0.5 });
    });
    cue('swoosh', tIn, 0.07); cue('kaching', tPay, 0.08); cue('typing', tText - 0.1, 0.05, 0.95);
    cue('swoosh', tFree - 0.12, 0.05); cue('bassdrop', tFree, 0.14); cue('sparkle', tFree + 0.04, 0.07); F.shake(tFree, 7, 0.22);
  }

  // ───────────── cta1 · "This format is called prism.md." ─────────────
  {
    const tIn = T0('cta', 'This') - 0.08, tOut = T0('cta', 'So') - 0.2, tName = T0('cta', 'prism.md');
    const sc = scene(tIn, tOut, { accent: 'cyan', in: 'streak', sy: 820 }), el = sc.el;
    const head = P.head(el, 300, '*prism*\n*.md*', { at: [tName, tName + 0.16], size: 150, lh: 0.92, kicker: 'This format is called', kat: T0('cta', 'This'), kgap: 34 });
    head.words[0].style.backgroundImage = 'linear-gradient(180deg, #ffffff -10%, #3fe0ff 50%, #4d9bff 120%)';
    head.words[1].style.backgroundImage = 'linear-gradient(180deg, #ffffff -10%, #ff4fb8 50%, #c76bff 120%)';
    const prism = P.prism(el, 510, 930, 300);
    const tags = [[215, 760, 'free', 'green'], [805, 760, 'SKILL.md', 'pink'], [510, 1220, 'made for Claude', 'amber']].map(([x, y, s, c], i) => ({ a: P.tagC(el, x, y, s, c), t: tName + 0.3 + i * 0.16 }));
    cam(sc, [[tIn, 1.0, 0, 0], [tName, 1.0, 0, 0], [tName + 0.12, 1.05, 0, 0], [tOut + 0.3, 1.07, 0, 4]]);
    sc.on(t => {
      head.update(t); prism.update(t, T0('cta', 'format'), tName);
      enter(prism.el, t, tIn + 0.05, { from: 'zoom', s0: 0.5, float: 0.5 });
      tags.forEach(({ a, t: t0 }, i) => enter(a.el, t, t0, { from: 'pop', seed: 30 + i }));
    });
    whoosh(tIn, 0.1); cue('typing', T0('cta', 'This'), 0.05, 0.3); cue('whoosh', T0('cta', 'format'), 0.06);
    cue('riser', tName - 0.7, 0.06); cue('boom', tName, 0.08); cue('sparkle', tName + 0.02, 0.08); F.streak(tName, 930, 0.6);
    tags.forEach(({ t }) => pop(t, 0.06));
  }

  // ───────────── cta2 · "So if you want it, just comment PRISM down below, and I'll send it to you directly. Completely free." ─────────────
  {
    const tIn = T0('cta', 'So') - 0.15, tJ = T0('cta', 'just'), tC = T0('cta', 'comment'), tK = X('cta', 'PRISM'), tSend = T0('cta', 'send'), tDir = T0('cta', 'directly'), tFree = T0('cta', 'Completely');
    const sc = scene(tIn, F.END + 2, { accent: 'pink' }), el = sc.el;
    const head = P.head(el, 300, 'Want *prism.md*?', { at: [T0('cta', 'want'), T0('cta', 'it')], size: 100, accent: 'pink' });
    const cp = P.commentPill(el, 510, 545), kw = P.keyword(el, 510, 715, 'PRISM', 'pink', { w: 520, h: 170 });
    const inp = P.input(el, 145, 860, 730, { k: 1 });
    const dm = P.dm(el, 145, 1000, 730, { from: 'appmorphy', text: 'Your prism.md', pfp: 'assets/pfp-appmorphy.jpg' });
    const fr = P.tagC(el, 510, 1205, 'completely free', 'green');
    const chips = [[160, 470, 0], [850, 478, 2], [140, 640, 3], [870, 650, 4], [170, 812, 5], [850, 815, 0], [190, 1262, 2], [830, 1262, 3]].map(([x, y, k], i) => ({ a: P.chip(el, x, y, 'PRISM', k), t: tK + 0.15 + i * 0.22 }));
    const conf = F.parts.confetti(el, 510, 1100, tFree + 0.05, { n: 40, spread: 520, up: 1000 });
    cam(sc, [[tIn, 1.0, 0, 0], [tK, 1.0, 0, 0], [tK + 0.1, 1.04, 0, 0], [tK + 0.6, 1.02, 0, 0], [F.END, 1.05, 0, 0]]);
    sc.on(t => {
      head.update(t); conf.update(t);
      enter(cp.el, t, tJ - 0.02, { from: 'pop', s0: 0.3, seed: 40 });
      if (t < tK - 0.02) { const o = enter(kw.el, t, tJ + 0.1, { from: 'fade', s: 0.9, float: 0 }); kw.el.style.opacity = (o * 0.28).toFixed(3); kw.el.style.filter = 'saturate(0.3) blur(1.5px)'; }
      else { enter(kw.el, t, tK - 0.02, { from: 'slam', s0: 1.7, seed: 41 }); if (t > tK + 0.2) kw.el.style.filter = 'none'; }
      enter(inp.el, t, tJ + 0.16, { from: 'up', dist: 80, seed: 42 });
      typeIn(inp.text, 'PRISM', t, tK + 0.25, tK + 0.6);
      inp.done(t >= tSend - 0.18);
      enter(dm.el, t, tSend, { from: 'up', dist: 90, seed: 43 });
      const b = back((t - tDir) / 0.3); dm.badge.style.transform = `translateY(-50%) scale(${(t < tDir ? 0 : 0.4 + 0.6 * b).toFixed(3)})`;
      enter(fr.el, t, tFree, { from: 'pop', seed: 44 });
      chips.forEach(({ a, t: t0 }, i) => enter(a.el, t, t0, { from: 'pop', seed: 45 + i }));
    });
    cue('swoosh', tIn, 0.07); pop(T0('cta', 'want'), 0.05); cue('uipop', tJ - 0.02, 0.08); cue('swoosh', tJ + 0.14, 0.05);
    cue('bassdrop', tK - 0.02, 0.14); cue('cards', tK, 0.06); F.shake(tK - 0.02, 8, 0.24);
    cue('typing', tK + 0.25, 0.05, 0.35); cue('mouse', tSend - 0.18, 0.09); cue('send', tSend, 0.07); cue('notif', tDir, 0.09);
    chips.forEach(({ t }) => tick(t, 0.035)); cue('sparkle', tFree, 0.08); pop(tFree, 0.06);
  }
})();
```

