---
name: lyric
description: Make a kinetic LYRIC VIDEO reel (1080x1920, 30-60 s) to a song - no voiceover - in the "Lyric" format - every sung word lands on screen on its beat while the camera cuts through a new three.js world every 1.5-3 s in an indigo duotone (pale periwinkle paper worlds and deep night-indigo worlds, one electric indigo accent) - extruded 3D words in a fisheye grid room, lyric lines wrapped round a spinning ring tower, slab cards and ransom letters in a dark grid tunnel, a pen writing the line on graph paper, a title whose letters slam in on a disc, word pills flying at the lens, a white-flash burst, 3D shapes raining on a floor; whip pans, flashes, zoom blur, bloom, grain and a tiny zoom on every beat. The song is the user's own (made in Suno from lyrics + a style prompt); Whisper gives every word's time, a beat tracker the grid; drawn frame by frame in headless Chrome and encoded with ffmpeg. Use it when someone asks for a lyric video, "lyric.md", a kinetic-typography music reel, a reel where the words hit on the beat, or wants to turn a script into a song + this kind of video.
---

# Lyric reel: how to make one

You will make a **30-60 second vertical video (1080x1920, 30 fps, H.264 + AAC, at most 60 s and 60 MB)** that is **a lyric video for a song**: there is no voiceover; the user's song (made in Suno from lyrics you write with them) carries everything, and **every sung word appears on screen exactly when it is sung**. Every 1.5-3 s the camera is in a **new 3D world**, alternating **pale periwinkle "paper" worlds** with **deep night-indigo worlds**, one **electric indigo** accent everywhere. Words are **extruded 3D type**, ring bands, slab cards, cut-out ransom letters, a handwritten line, slammed title letters, flying pills. The whole frame runs through a **fisheye** lens with bloom, grain, chromatic aberration and motion blur on the cuts, and it **pulses on every beat**.

**`reference.jpg`** (next to this file, if you have it) is a contact sheet of 12 frames from a finished Lyric reel: **this is what it should look like.** Left to right, top row first: (1) a pale grid room seen through a fisheye, a big indigo ring, "NEVER" in navy and "PAY" in indigo as chunky extruded 3D words, tiny mono labels "+FREE" and "$0.00"; (2) a ruler tower (the letter "I") in indigo with tick marks, a white pill "BUILT MORPHY" on top; (3) night: a tower of spinning bands, the sung line "…Y LINE," glowing white on a dark band, "skill.md" in white serif italic on a lavender band with a spark burst, dim repeats and mono deco bands; (4) a black grid tunnel, a pill "put a" and the word "PRICE" as cut-out letter cards (different fonts and colours, taped) popping in one by one; (5) graph paper, a pencil has just written "Thirty dollars for a gift." in navy script with an indigo double underline and a circle; (6) black, word pills fly at the lens, a big hero pill "text file"; (7) a pale disc, script "that was" with a drawn rule, the title "FREE" in tall extruded Impact letters, "right from" in indigo serif italic below, HUD brackets and "121.8 BPM · $0.00"; (8) a pale floor, striped balls, stars, cubes and a torus raining down, "ANSWER" extruded in indigo, a white pill "here's my"; (9) tunnel: "no" pill, "PAY / WALL" in white letter cards; (10) graph paper, "no fine pr…" being written, small print under it scribbled out; (11) the disc: "this one's called" / "LYRIC" / ".md"; (12) the floor with shapes, a white pill "comment", "LYRIC" extruded, a navy pill "I'll send it to you". If you only have this file, section 3 describes everything with numbers.

Everything is drawn **one frame at a time** in three.js in headless Chrome (`window.setT(t)` draws the frame at film time t; every scene is a pure function of t), then encoded with the song by ffmpeg. This file gives you the look (exact colours, fonts, numbers), the grammar of the lyrics and the edit, the complete tested engine (Appendix A: write it once) and a complete example (Appendix B). With the engine written, **a video is one file**: `film.js` (the scenes, with every word's time) next to the song.

Work in this order and don't skip ahead: **setup -> lyrics -> the song (the user makes it in Suno) -> word times -> beat grid -> film.js -> stills + scan -> full render -> 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). It refuses to render without a GPU (a CPU render of this film pegs every core for half an hour). Never run two renders at once. Run Whisper once per song.
2. **The song is the user's.** You write the lyrics and the style prompt with them; they generate the song in Suno (or bring their own track) and give you the MP3. Songs made on Suno's free plan are for non-commercial use; a video that promotes anything needs a song made on a paid plan: tell the user, don't decide for them.
3. **Stills before film.** Direct every scene on stills and never start the full render until the contact sheet and the safe-zone scan are right.
4. **Background jobs die when your turn ends.** Render in foreground chunks under your tool timeout (`full 0 600`, then `full 600`); the render skips frames that exist.
5. **No real people, no real brands.** Everything is drawn: no screenshots, logos, handles or view counts. A number on screen must be one the lyrics say.
6. **Don't publish anything.** You deliver files. Posting is the user's call.
7. If the user's instructions conflict with this file, the user wins. If something is missing (no song yet, no GPU, no ffmpeg), say exactly what and offer the closest thing (stills on guessed times work without a song). Never fake success.

## 1. Requirements

- **Node 22+** (global `fetch` and `WebSocket`), **ffmpeg + ffprobe** on PATH, **Chrome or Edge** with WebGL on the GPU (or set `CHROME=<path>`), **three.js 0.170** (`npm i three@0.170.0` in the project folder; the only package). Debug ports 9671 and 9698 on 127.0.0.1.
- **Python 3 + numpy** for the beat grid (`beats.py`), and **faster-whisper** (`pip install faster-whisper`; a ~1.6 GB model downloads once; an NVIDIA GPU makes it ~20 s, the CPU a few minutes) for the word times (`words.py`). Without Whisper you can time the words by ear, slowly.
- **A song**: an MP3 of 30-60 s, ideally made in Suno from the lyrics of section 4.
- The fonts (Archivo Black, Anton, Lora Italic, Kalam Bold, JetBrains Mono; all SIL Open Font License) are free downloads: `setup.mjs` fetches them.

## 2. Project layout and setup

```
lyric-reel/                      one project folder, many videos
  film.html main.js core.js kinds.js post.js     the page, the film clock + transitions + text scan, helpers, the 9 scene kinds, the post chain
  setup.mjs cdp.mjs render.mjs encode.mjs words.py beats.py
  node_modules/three/            npm i three@0.170.0
  fonts/                         made by setup.mjs
  my-video/                      one folder per video (Appendix B is a complete one)
    film.js lyrics.txt           the scenes with every word's time · the lyrics as sung
    audio/song.mp3               the song
    audio/words.json audio/beats.json stills/ frames/ mix.wav video.mp4 cover.jpg   (made by the tools)
```

```bash
mkdir lyric-reel && cd lyric-reel
# write every file of Appendix A, then:
npm init -y && npm i three@0.170.0   # npm init first, or npm installs into a parent folder's package.json
node setup.mjs                   # checks Node/ffmpeg/three/Chrome/Python, downloads the fonts, probes the GPU
```

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

```bash
mkdir -p my-video/audio          # the song -> my-video/audio/song.mp3, the lyrics -> my-video/lyrics.txt
python words.py --video my-video                                      # every sung word with its time -> my-video/audio/words.json
python beats.py my-video/audio/song.mp3                               # BPM + beat grid -> my-video/audio/beats.json + the BEAT line
#   write my-video/film.js (section 6; Appendix B is a complete one)
node render.mjs --video my-video stills scenes --safe                 # one still per scene -> my-video/stills/sheet.jpg (look at it)
node render.mjs --video my-video scan                                 # every visible text every 0.25 s against the safe zone
node render.mjs --video my-video full 0 600 && node render.mjs --video my-video full 600   # my-video/frames/
node encode.mjs --video my-video --cover 3.9                          # my-video/video.mp4 + cover.jpg (+ mix.wav)
```

## 3. The look, pinned down

### 3.1 The frame (1080x1920 px)

| Layer | What |
|---|---|
| The world | one three.js scene per beat of the song: a pale grid room, a dark ring tower, a dark grid tunnel, graph paper, a pale disc, a dark pill swarm, a dark burst, a pale floor with shapes (3.4) |
| The words | the sung words, in the world, as 3D objects: extruded type, ring bands, cards, letters, handwriting, pills |
| The lens | a fisheye (barrel warp, k 0.24 light / 0.28 dark) around the composition centre (510, 865) px, bloom, chromatic aberration, vignette, film grain |
| Cuts | whip pans with directional motion blur, white flashes, radial zoom blur, zoom punches |
| The beat | every beat: zoom +0.9 % and bloom +15 %, decaying |

**The composition centre is (510, 865), not the middle of the frame:** `makeCam` shifts the camera's view so that what the camera looks at lands there, and the fisheye is centred on it too. That keeps the subject inside the safe zone.

**Safe zone (every platform covers the edges):** all text inside **x 60-960, y 250-1480**; nothing important in the top 250 px, the bottom 440 px or the right 120 px. `render.mjs stills --safe` draws the zone and outlines every visible text outside it in red; `scan` tests every text every 0.25 s; `full` prints the frames whose text left the zone (3.6 says which flags are fine).

### 3.2 Colours (`C` in core.js)

| Token | Hex | Used for |
|---|---|---|
| paper | `#eceeff` (`paper2 #e1e4ff`) | light worlds: background, fog, grid paper |
| grid | `#c2c7ff`, major `#9aa2ff` | grid lines in light rooms (every 4th line major) |
| accent | `#5458ff` (`acc2 #8b91ff`, `soft #cfd2ff`, `glow #b3b8ff`, `line #4d56f0`) | the one colour: words, rings, the ruler tower, pills, underlines, shapes |
| ink | `#17185a` (`inkDeep #0c0d3a`) | words on paper, extrusion sides, the title |
| white | `#fbfbff` | pills, highlights |
| night | `#05061a` (`night2 #0d0f3e`) | dark worlds: background, fog, grid (lines `rgba(110,118,255,.85)` major / `rgba(80,88,230,.28)` minor) |

Dark worlds: never put pure white on a large surface (bloom turns it into glare): card and pill backgrounds in dark worlds stay at or below `#e2e5ff`.

### 3.3 Type

Five fonts, all drawn into canvases and mapped onto planes: **heavy** = Archivo Black (900: extruded words, cards, band lines), **cond** = Anton (the slammed title, ransom letters), **serif** = Lora Italic (400/700: band lines, cards, pills, bottom words), **script** = Kalam Bold (the handwriting, the disc's top line), **mono** = JetBrains Mono (labels, HUD, ruler numbers, pills). Uppercase for the heavy and cond words; lowercase serif and script for the small connecting words ("put a", "here's my", "that was"). That case contrast is the look.

### 3.4 The worlds (scene kinds; section 6.3 has every option)

- **words** (light): a fisheye grid room, a big indigo ring, thin rods with dots, floor rings, sparkles; 1-4 **extruded words** (a front plane + 22 stacked planes in a darker side colour, depth 0.3) pop in with a back-eased scale and a swing, or draw their outline first and fill after; mono labels; the camera orbits slowly and swings on every group.
- **pillar** (light): the letter "I" as an indigo **ruler tower** with white ticks and numbers, a base and a cap, dashed orbit rings; the camera rises from the floor to a white pill label on top. For "I'm …" lines.
- **rings** (dark): a tower of **spinning bands**; each lyric line is a band (bold white on navy, serif italic navy on lavender, heavy indigo on black, italic white on navy); the sung word lights up and **the band turns so the sung word faces the camera**, which steps down to the next band on its first word; dim repeats of the line fill the band; deco bands (ticks, chevrons, mono tags), city bars with lit windows below, a white spark burst on every word.
- **tunnel** (dark): a long night **grid corridor** the camera flies down; cut-out letters drift on the walls; **slab cards** (dark navy with an accent bar and white serif italic, or pale lavender with an indigo frame and heavy indigo, or solid accent) and **ransom words** (one taped card per letter, random font/colour each) pop in on their words.
- **write** (light): a close-up of **graph paper** with a margin line; a **pencil writes** each line left to right, then an underline / circle / wave / strike, a star or an arrow draws itself; props (a note, an eraser, a torus, a ball); small print that gets scribbled out.
- **pills** (dark): a **swarm of word pills** flies at the lens out of a dark room; hero pills fly in big on their words; a typing bubble.
- **disc** (light): a **pale disc** with rings; a script top line with a drawn rule and dot; a **title whose letters slam in one by one** (tall extruded Anton, from depth with a spin, a shake and a zoom blur); serif-italic bottom words pop in; corner brackets and a mono HUD line.
- **flash** (dark, ~0.5 s): a **burst**: an accent pill with a glow, 150 shards and rays fly out, a white flash and a zoom blur.
- **shapes** (light): a **pale floor**; glossy 3D shapes (spheres, striped and checked balls, striped tori, cubes, cones, octahedra, stars, cylinders) **rain down in waves and bounce**, each landing sends ripples; words drop in and bounce (or pop in as pills) and draw on top of the shapes.

### 3.5 Motion

| Move | How |
|---|---|
| whip (`in: 'whip'`) | the last 0.11 s of the old shot and the first 0.16 s of the new one: directional motion blur (14 taps) + camera yaw and roll; direction and angle random per scene (seeded) |
| cut (`in: 'cut'`) | a hard cut with a 6 % zoom punch that settles in 0.22 s |
| flash (`in: 'flash'`) | white, 0.9 → 0 in 0.1 s |
| zoom (`in: 'zoom'`) | radial zoom blur 0.12 s before and 0.2 s after the cut |
| word entrances | back-eased pops (0.2-0.26 s) with a Y swing, letter-by-letter staggers (0.04-0.07 s), slams from depth (0.15 s), handwriting at the sung length |
| the beat | zoom +0.9 %, bloom +15 %, decaying ×e^(-7t) after each beat |
| the end | the last 0.5 s fades to paper |

### 3.6 What the safe-zone scan flags (and what's fine)

The scan projects every text object's glyph box through the camera and the fisheye. It skips decoration (ring bands, the pill swarm, deco bands, wall letters). It also reports **transients**: a whip swings words out for 3-4 frames, a dropped word falls in from above, a tunnel card flies past the lens, a pill rises into view. Those are fine. **Fix anything that holds still outside the zone**: a `fit` too wide, a pill too far left (`x ≥ -0.3`), a disc top line too far left (`topX: -1.7`), the disc HUD too low (`hudY: -2.1`), a burst pill too big (`flash` `h: 0.7`).

## 4. Writing the lyrics and the song

1. **The script → lyrics (~50-55 s sung, ~100-120 words).** Keep the message, the order, every point and number, and the call to action; cut it into short sung lines (4-8 words). Open with a **repeated hook line** ("Never pay, never pay."), put a **chorus** of short parallel phrases in the middle ("No paywalls, no subscriptions, no strings, no fine print."), and end on the call to action, twice ("Comment LYRIC, I'll send it to you … Comment LYRIC.").
2. **Write everything the way it is sung**: ".md" = "dot M D", "$30" = "thirty dollars", "lyric.md" = "lyric dot M D", numbers in words, no symbols. Suno sings what it reads.
3. **The style prompt** (Suno's "Style of Music"; the example's): `nu-disco dance-pop, 122 BPM, four-on-the-floor kick, funky slap bass, glossy synth stabs, handclaps, confident male pop vocal, sassy half-spoken verses, huge catchy chorus, tight modern pop production, punchy short intro`. A fixed BPM and "punchy short intro" make the first word come in fast and the beat steady. Change the genre and the voice to the user's taste; keep the BPM, the four-on-the-floor and the short intro.
4. The user generates it (Suno custom mode: lyrics + style) and **picks the take whose first word comes in within ~0.3 s** and that's under 60 s (or you cut it: the film ends at `TOTAL`, and the encode fades the song out there). Save it as `my-video/audio/song.mp3` and the lyrics exactly as sung as `my-video/lyrics.txt`.

## 5. Word times and the beat grid

- `python words.py --video my-video` runs Whisper (large-v3-turbo, word timestamps) on the song with **the lyrics as its prompt** and writes `audio/words.json`: one `{w, t0, t1, p}` per sung word, printed with `?` when Whisper is unsure. **Sung words are misheard** (the example: "Morphy" came out "morphine", "Comment LYRIC" came out "Count a Lyric" and "Come in, Lyric"): the times are what you use; check names, numbers and the keyword by ear, and nudge times in film.js, never in words.json. Long held notes smear `t1`; trust `t0`.
- `python beats.py my-video/audio/song.mp3` finds the BPM (80-170) and the first beat, writes `audio/beats.json`, prints `export const BEAT = { bpm, t0 }` for film.js and a drift check per 10 s (phase offsets near 0 or 1 = one grid fits the song). The grid drives only the pulse; **the cuts follow the words**.

## 6. Directing (film.js)

### 6.1 The file

```js
import { FONT, C } from '/core.js';             // '/core.js' = the engine at the page root, from any video folder
export const TOTAL = 57.56;                      // seconds of the song you keep (≤ 60)
export const BEAT = { bpm: 121.8, t0: 0.467 };   // from beats.py
const W = (...pairs) => pairs.map(([w, t]) => ({ w, t }));
export const SCENES = [
  { kind: 'words', start: 0.0, end: 4.3, in: 'cut', o: { … } },
  { kind: 'pillar', start: 4.3, end: 6.04, in: 'whip', o: { … } },
  …
];
```

Scenes play back to back: scene i is on from its `start` until the next scene's `start`; set each `end` to the next `start` and the last `end` to `TOTAL`. Every time inside `o` is a film time from words.json.

### 6.2 Transitions (`in`)

`whip` between worlds (the default; always into a dark world) · `cut` (a zoom punch; after a flash burst, or between two words in one world) · `flash` (into a burst and into a chorus) · `zoom` (into a disc title).

### 6.3 The scene kinds and their options

| Kind | Options |
|---|---|
| `words` | `groups: [{ at, swing, words: [{ t, text, style: 'acc'\|'ink'\|'white', x, y, z, fit (world width 2-3.2), rot (entry swing), rz, outline: true + fillDelay (outline first, fill after), cond (Anton), font }], labels: [{ t, text, x, y, h, color }] }]` (each group replaces the last at `at`), `a0` (start angle), `spin` (orbit speed), `r` (8.3) |
| `pillar` | `labels: [{ t, text }]` (each replaces the last) |
| `rings` | `lines: [{ style: 'bold'\|'serif'\|'black'\|'italic', words: W([word, t], …) }]` (≤ ~25 characters a line), `tags: [...]`, `names: [...]` (deco band texts), `phi0`, `gap` (1.75), `D` (9.6), `dive` (a time: falls into the drop) |
| `tunnel` | `items: [{ t, type: 'card', text, style: 'dark'\|'white'\|'acc', h, x, y, rot, rz, keep, until, dist }` or `{ t, type: 'ransom', text \| rows: ['PAY', 'WALLS'], h, stagger, small (pill above), after + afterT (pill below), y }]`, `speed` (1.6), `roll` |
| `write` | `lines: [{ text, t0, t1 (the pen writes between them), y (canvas px 0-2048), size (px), dx, deco: 'underline'\|'circle'\|'wave'\|'strike', star, arrow, color, font, weight }]`, `focusY` (canvas y the camera looks at), `z0` → `z1` (camera distance), `roll`, `cx`, `fine: { t, y, scribble, lines: [...] }` |
| `pills` | `texts: [...]` (the swarm), `n` (64), `heroes: [{ t, text, style: 0-4, s, x, y, rz }` or `{ t, typing: true, s, y }]` |
| `disc` | `top, topT, topH, topX (-1.9), topY, big, bigT, bigT1 (last letter), fit (3.3-3.7), tall (1.25), track, bigFont, bigY, stagger, bottom: [{ text, t, font }], bottomH, bottomFit, bottomY, hud, hudT, hudY` |
| `flash` | `text, pillT, burst, h` (pill height 0.95; 0.7 keeps a long word inside the zone) |
| `shapes` | `waves: [[from, to, count]]`, `words: [{ t, text, fit, x, y, z, until, front, side, font, drop: false (pop in instead of falling), pill: true + h, bg, fg, rz }]`, `r0`/`r1` (camera distance), `a0`, `spinC`, `camY`, `lookY` |

Short examples (all from Appendix B):

```js
{ kind: 'tunnel', start: 10.0, end: 11.66, in: 'whip', o: { items: [
  { t: 10.06, type: 'card', text: 'Then they', style: 'dark', h: 0.7, y: 0.2 },
  { t: 10.62, type: 'ransom', text: 'PRICE', small: 'put a', after: 'on it', afterT: 11.2, h: 0.6, stagger: 0.07 } ] } },
{ kind: 'write', start: 11.66, end: 13.62, in: 'whip', o: { focusY: 1010, z0: 14.5, z1: 13, lines: [
  { text: 'Thirty', t0: 11.72, t1: 12.04, y: 760, size: 160, dx: -60 },
  { text: 'gift.', t0: 13.12, t1: 13.4, y: 1290, size: 170, dx: 40, deco: 'circle', star: true } ] } },
{ kind: 'disc', start: 15.76, end: 17.95, in: 'zoom', o: { top: 'that was', topT: 15.82, big: 'FREE', bigT: 16.08, bigT1: 16.36, fit: 3.3,
  bottom: [{ text: 'right', t: 16.38 }, { text: 'from', t: 16.62 }], hud: '121.8 BPM  ·  $0.00', hudT: 15.85 } },
{ kind: 'flash', start: 17.95, end: 18.5, in: 'flash', o: { text: 'so…', pillT: 18.0, burst: 17.95 } },
```

### 6.4 The edit follows the song

- **Every word on its own time.** A word appears on its `t0` (a pop, a slam, the pen starting), never before it, never later. A scene starts on (or ~0.05 s before) the first word it shows.
- **~1.5-3 s per scene** (a burst 0.5 s, a ring tower up to 4 s). One sung line = one world; a chorus can cut on every phrase.
- **Alternate light and dark worlds** (words/pillar/write/disc/shapes are light; rings/tunnel/pills/flash are dark). **Whip into dark worlds.**
- **A flash burst right before a shapes rain** ("So…" → the rain lands with "here's my answer"; "Comment…" → the keyword drops in), so the rain lands as a release.
- Use each world for what it says: an "I am" line → the pillar; a list → the ring tower or the tunnel; a price or a sum → handwriting; a name or a keyword → the disc; a hit word → slammed letters; the call to action → shapes with the keyword, then callbacks (the opening room, the ring tower) to the end.
- **Frame 0 is never empty:** the first word starts at a negative t (`t: -0.2`).

### 6.5 Sizes that fit

- Words wider than ~3.4 world units leave the frame: `fit` ≤ 3.4 for words and titles (3.7 only for a long title with `tall`). Anton is a little wider than the Impact the example was tuned on: a 10+ letter disc title ("SUBSCRIPTIONS") wants `fit` ≤ 3.3; the scan tells you.
- Tunnel ransom `h ≤ 0.62` for words of 5+ letters (split long ones into `rows` of ≤ 5 letters); write sizes ≤ 170 px with `z0 ≥ 13.5`.
- In shapes, the floor keeps a centre lane free when the scene has words (shapes with |x| < 2.4 are pushed back), and words draw on top.

## 7. Stills and the safe zone

`node render.mjs --video my-video stills <times | scenes | spread:8> [--safe] [--boxes]` writes `my-video/stills/<t>.jpg` and `sheet.jpg` (`scenes` = one still per scene, 60 % in; `--boxes` prints every text box). Each still takes 0.2-0.75 s. `scan [step]` checks every visible text every 0.25 s without screenshots (~10 s for a 57 s film). Look at the sheet every time: a word cut by the frame, a word that hasn't appeared yet, glare in a dark world, an empty frame.

## 8. The full render

`node render.mjs --video my-video full [from] [to] [--force]` writes `my-video/frames/f00000.jpg …` (~140 ms a frame on a laptop GPU: a 57 s film ≈ 4-5 minutes). It skips frames that exist, so split it to stay under your tool timeout, and use `--force` after you change film.js. It ends with the frames whose text left the safe zone (3.6).

## 9. Encode, check, deliver

`node encode.mjs --video my-video --cover 3.9` normalizes the song (two-pass loudnorm to **−14 LUFS**, aiming at −2 dBTP because AAC adds up to ~1 dB; the delivered file must stay at or under **−1 dBTP**), cuts it to `TOTAL` with a 0.6 s fade-out (`mix.wav`), and encodes **H.264 High, yuv420p, 1080x1920, 30 fps, `-crf 20 -maxrate 10M -bufsize 20M`, AAC 192k, `+faststart`** (the grain makes ~50 MB for 57 s; over 60 MB it re-encodes at maxrate 7M), copies the frame at `--cover` seconds as `cover.jpg`, and prints the size, the loudness and the true peak (✗ if off target). `--to 6 --out clip.mp4` encodes only the first 6 s: a quick end-to-end test. Then: watch it with sound; frame 0 isn't empty; every word lands on its sung word. Deliver `video.mp4`, `cover.jpg` and the lyrics + style prompt; posting is the user's call.

## 10. Checklist

- [ ] `setup.mjs` all ✓ · lyrics written the way they're sung · the song's first word within ~0.3 s · lyrics.txt saved
- [ ] words.json checked by ear for names, numbers and the keyword · BEAT from beats.py
- [ ] every scene starts on its first word · light/dark alternate · whips into dark worlds · a flash before each shapes rain
- [ ] `stills scenes --safe` sheet looked at · `scan` clean apart from transients
- [ ] full render · encode ≤ 60 s and ≤ 60 MB · −14 LUFS, true peak ≤ −1 dBTP · watched with sound

---

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

Write every file below into the project folder (flat, next to each other). They are the same for every video: never edit them per video.

### `film.html`

```html
<!doctype html><html><head><meta charset="utf-8"><title>lyric</title>
<style>html,body{margin:0;background:#000;overflow:hidden}canvas{display:block}
@font-face { font-family: 'Archivo Black'; src: url(/fonts/ArchivoBlack.ttf) format('truetype'); font-weight: 100 900; }
@font-face { font-family: 'Anton'; src: url(/fonts/Anton.ttf) format('truetype'); font-weight: 100 900; }
@font-face { font-family: 'Lora'; src: url(/fonts/Lora-Italic.ttf) format('truetype'); font-style: italic; font-weight: 400 700; }
@font-face { font-family: 'Kalam'; src: url(/fonts/Kalam-Bold.ttf) format('truetype'); font-weight: 100 900; }
@font-face { font-family: 'JetBrains Mono'; src: url(/fonts/JetBrainsMono.ttf) format('truetype'); font-weight: 100 800; }</style>
<script type="importmap">{"imports":{"three":"/node_modules/three/build/three.module.js","three/addons/":"/node_modules/three/examples/jsm/"}}</script>
</head><body><script type="module">
const fonts = ['900 40px "Archivo Black"', '40px Anton', 'italic 700 40px Lora', 'italic 400 40px Lora', '700 40px Kalam', '700 40px "JetBrains Mono"'];
Promise.all(fonts.map(f => document.fonts.load(f))).then(() => import('./main.js')).catch(e => console.error('boot ' + e.message + ' ' + e.stack));
</script></body></html>
```

### `main.js`

```js
// Lyric format: the film clock. window.setT(t) draws the frame at film time t.
// film.html?video=<dir> loads <dir>/film.js (default: the folder itself). ?safe=1 draws the RULES safe zone and outlines
// every visible text that leaves it. window.textBoxes() / safeReport() = the projected text boxes (render.mjs scan).
import * as THREE from 'three';
import * as K from './kinds.js';
import { E, ramp, rng, C } from './core.js';
import { makePost } from './post.js';
const Q = new URLSearchParams(location.search), VIDEO = (Q.get('video') || '.').replace(/\\/g, '/').replace(/\/+$/, '');
const { SCENES, TOTAL, BEAT } = await import(`./${VIDEO}/film.js`);

const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(1); renderer.setSize(1080, 1920);
document.body.appendChild(renderer.domElement);
const post = makePost(renderer);

const built = SCENES.map((s, i) => {
  const k = K[s.kind]({ ...s.o, start: s.start, end: s.end, seed: 100 + i * 7 });
  return { ...s, k, dir: rng(900 + i)() < 0.5 ? -1 : 1, ang: (rng(500 + i)() - 0.5) * 0.9 };
});

const col = c => new THREE.Color(c);
let cur = null;
function setT(t) {
  t = Math.max(0, Math.min(TOTAL - 1e-4, t));
  let i = 0; for (let j = 0; j < built.length; j++) if (t >= built[j].start) i = j;
  const S = built[i], k = S.k, N = built[i + 1]; cur = S;
  k.update(t);
  const P = { ...k.post }, U = post.U;
  let blur = 0, zblur = 0, zoom = 1, flash = 0, yaw = 0;
  const c = S.start, dIn = t - c;
  // incoming
  if (S.in === 'whip' && dIn < 0.16) { const a = 1 - E.out(ramp(t, c, 0.16)); blur += a; yaw -= S.dir * 0.3 * a; }
  if (S.in === 'zoom' && dIn < 0.2) zblur += 0.3 * (1 - E.out(ramp(t, c, 0.2)));
  if (S.in === 'flash' && dIn < 0.1) flash = Math.max(flash, 0.9 * (1 - dIn / 0.1));
  if (S.in === 'cut' && dIn < 0.22) zoom *= 1 + 0.06 * (1 - E.out(ramp(t, c, 0.22)));
  // outgoing (a whip starts in the old shot)
  let ang = S.ang;
  if (N && N.in === 'whip' && N.start - t < 0.11) { const a = E.in(ramp(t, N.start - 0.11, 0.11)); blur += a; yaw += N.dir * 0.36 * a; ang = N.ang; }
  if (N && N.in === 'zoom' && N.start - t < 0.12) zblur += 0.3 * E.in(ramp(t, N.start - 0.12, 0.12));
  if (k.fx) { zblur += k.fx.zblur || 0; flash = Math.max(flash, k.fx.flash || 0); }
  if (yaw) { k.cam.rotateY(yaw); k.cam.rotateZ(yaw * 0.4); }
  // beat pulse
  const p = 60 / BEAT.bpm, lb = BEAT.t0 + Math.floor((t - BEAT.t0) / p) * p;
  const pulse = t >= BEAT.t0 ? Math.exp(-(t - lb) * 7) : 0;
  zoom *= 1 + 0.009 * pulse;
  const dirSign = (N && N.start - t < 0.11) ? N.dir : S.dir;
  U.blur.value.set(Math.cos(ang) * 0.12 * blur * dirSign, Math.sin(ang) * 0.12 * blur * 0.5);
  U.zblur.value = Math.min(0.6, zblur); U.zoom.value = zoom; U.flash.value = Math.min(1, flash);
  U.k.value = P.k; U.vig.value = P.vig; U.grain.value = P.grain; U.ca.value = P.ca * (1 + blur * 4); U.seed.value = (t * 30) % 97;
  post.bloom.strength = P.bloom * (1 + 0.15 * pulse); post.bloom.threshold = P.thr; post.bloom.radius = 0.4;
  U.tint.value = E.inOut(ramp(t, TOTAL - 0.5, 0.48)); U.tintCol.value = col(C.paper);
  post.render(k.scene, k.cam);
  if (marks) drawMarks();
}

// ── safe-zone scan: every tagged text (core.js userData.text/ink) projected through the camera and the post fisheye ──
const SAFE = { x0: 60, x1: 960, y0: 250, y1: 1480 };
const V3 = new THREE.Vector3(), CEN = post.U.center.value, ASP = 1080 / 1920;
function toScreen(v, cam) {   // world point -> output pixel (after the fisheye + zoom), or null behind the camera
  V3.copy(v).applyMatrix4(cam.matrixWorldInverse); if (V3.z > -0.01) return null;
  V3.applyMatrix4(cam.projectionMatrix);
  const sx = (V3.x + 1) / 2, sy = (V3.y + 1) / 2, k = post.U.k.value, z = post.U.zoom.value;
  let ux = sx, uy = sy;   // the shader maps output uv -> source uv; invert it by fixed-point iteration
  for (let i = 0; i < 8; i++) { const dx = (ux - CEN.x) / ASP, dy = uy - CEN.y, f = (1 + k * (dx * dx + dy * dy)) / (1 + k * 0.42) / z; ux = CEN.x + (sx - CEN.x) / f; uy = CEN.y + (sy - CEN.y) / f; }
  return [ux * 1080, (1 - uy) * 1920];
}
function shown(o) { for (let p = o; p; p = p.parent) if (!p.visible) return false; let a = 0; o.traverse(q => { if (q.isMesh && q.visible && q.material) a = Math.max(a, q.material.transparent ? q.material.opacity : 1); }); return a > 0.05; }
function textBoxes() {
  if (!cur) return [];
  const cam = cur.k.cam, out = [], P = new THREE.Vector3();
  cur.k.scene.traverse(o => {
    if (!o.userData.text || !shown(o)) return;
    const rects = o.userData.rects || [[...(o.userData.ink || [0, 0, 1, 1]), o.userData.text]];
    for (const [fx0, fy0, fx1, fy1, label] of rects) {
      let x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9, ok = true;
      o.traverse(m => {
        if (!ok || !m.isMesh || m.geometry?.type !== 'PlaneGeometry') return;
        const g = m.geometry; if (!g.boundingBox) g.computeBoundingBox(); const b = g.boundingBox;
        for (const [fx, fy] of [[fx0, fy0], [fx1, fy0], [fx0, fy1], [fx1, fy1]]) {
          P.set(b.min.x + fx * (b.max.x - b.min.x), b.max.y - fy * (b.max.y - b.min.y), 0).applyMatrix4(m.matrixWorld);
          const s = toScreen(P, cam); if (!s) { ok = false; return; }
          x0 = Math.min(x0, s[0]); x1 = Math.max(x1, s[0]); y0 = Math.min(y0, s[1]); y1 = Math.max(y1, s[1]);
        }
      });
      if (!ok || x1 < x0 || (x1 - x0) * (y1 - y0) < 16) continue;
      if (x1 < 0 || x0 > 1080 || y1 < 0 || y0 > 1920) continue;   // entirely off screen: not seen
      out.push({ text: String(label), x0: Math.round(x0), y0: Math.round(y0), x1: Math.round(x1), y1: Math.round(y1) });
    }
  });
  return out;
}
const safeReport = () => textBoxes().filter(b => b.x0 < SAFE.x0 || b.x1 > SAFE.x1 || b.y0 < SAFE.y0 || b.y1 > SAFE.y1);
let marks = null;
function drawMarks() {
  marks.replaceChildren(...safeReport().map(b => { const d = document.createElement('div'); d.style.cssText = `position:fixed;left:${b.x0}px;top:${b.y0}px;width:${b.x1 - b.x0}px;height:${b.y1 - b.y0}px;outline:4px solid #ff2d55;background:rgba(255,45,85,0.12)`; return d; }));
}
window.textBoxes = textBoxes;
window.safeReport = safeReport;
window.setT = setT;
window.TOTAL = TOTAL;
window.META = { total: TOTAL, scenes: SCENES.map(s => ({ kind: s.kind, start: s.start, end: s.end, in: s.in })) };
if (Q.get('safe')) {
  const d = document.createElement('div'); d.style.cssText = 'position:fixed;left:60px;top:250px;width:900px;height:1230px;outline:4px dashed #ff2d55;pointer-events:none;z-index:9';
  const b = document.createElement('div'); b.style.cssText = 'position:fixed;left:0;top:1480px;width:1080px;height:440px;background:rgba(255,45,85,0.18);z-index:9';
  const r = document.createElement('div'); r.style.cssText = 'position:fixed;left:960px;top:0;width:120px;height:1920px;background:rgba(255,45,85,0.18);z-index:9';
  marks = document.createElement('div'); marks.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:10';
  document.body.append(d, b, r, marks);
}
setT(0);
window.__ready = true;
```

### `core.js`

```js
// Lyric format: shared helpers (palette, easing, canvas textures, extruded type, rooms, post).
// Text meshes carry userData.text + userData.ink (the glyph box as fractions of the plane: [x0, y0, x1, y1], y from the top),
// so main.js can project them to the screen and check the safe zone (window.textBoxes / safeReport).
import * as THREE from 'three';

export const C = {
  paper: '#eceeff', paper2: '#e1e4ff', grid: '#c2c7ff', gridMaj: '#9aa2ff',
  acc: '#5458ff', acc2: '#8b91ff', soft: '#cfd2ff', ink: '#17185a', inkDeep: '#0c0d3a', white: '#fbfbff',
  night: '#05061a', night2: '#0d0f3e', line: '#4d56f0', glow: '#b3b8ff',
};
export const FONT = {   // the free fonts setup.mjs downloads (film.html declares them), then the system fonts they stand in for
  heavy: '"Archivo Black", "Arial Black", sans-serif',
  cond: 'Anton, Impact, "Arial Narrow", sans-serif',
  serif: 'Lora, Georgia, serif',
  script: 'Kalam, "Segoe Script", cursive',
  mono: '"JetBrains Mono", Consolas, monospace',
};

export const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
export const lerp = (a, b, k) => a + (b - a) * k;
export const ramp = (t, at, dur) => clamp((t - at) / dur);
export const E = {
  out: k => 1 - Math.pow(1 - k, 3),
  expo: k => (k >= 1 ? 1 : 1 - Math.pow(2, -10 * k)),
  inOut: k => (k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2),
  in: k => k * k * k,
  back: k => { const c1 = 1.9, c3 = c1 + 1; return 1 + c3 * Math.pow(k - 1, 3) + c1 * Math.pow(k - 1, 2); },
};
export function rng(seed) { let a = seed >>> 0; return () => { a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
// smooth deterministic noise in [-1,1]
export const wob = (t, s = 1) => Math.sin(t * 1.31 * s + 0.7) * 0.6 + Math.sin(t * 2.17 * s + 2.1) * 0.4;

export function canvas(w, h) { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; }
export function tex(c, srgb = true) { const t = new THREE.CanvasTexture(c); if (srgb) t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 8; t.generateMipmaps = true; t.minFilter = THREE.LinearMipmapLinearFilter; return t; }
export function rr(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); }

// One line of text on a transparent canvas. Returns { c, w, h, ink } (canvas px; ink = the glyph box as fractions).
export function textCanvas(text, { font = `900 200px ${FONT.heavy}`, fill = '#fff', stroke = null, lw = 0, pad = 24, track = 0, shadow = null } = {}) {
  const m = canvas(8, 8).getContext('2d'); m.font = font;
  const size = parseFloat(font.match(/(\d+)px/)[1]);
  const chars = [...text]; const widths = chars.map(ch => m.measureText(ch).width);
  const w = Math.ceil(m.measureText(text).width + track * (chars.length - 1) + pad * 2 + lw * 2 + (shadow ? Math.abs(shadow.x) : 0));
  const h = Math.ceil(size * 1.32 + pad * 2);
  m.textBaseline = 'middle'; const mm = m.measureText(text), ox = pad + lw;
  const ink = [clamp((ox - (mm.actualBoundingBoxLeft || 0) - lw) / w), clamp((h / 2 - (mm.actualBoundingBoxAscent || size * 0.5) - lw) / h), clamp((ox + track * (chars.length - 1) + (mm.actualBoundingBoxRight || mm.width) + lw) / w), clamp((h / 2 + (mm.actualBoundingBoxDescent || size * 0.5) + lw) / h)];
  const c = canvas(w, h), x = c.getContext('2d'); x.font = font; x.textBaseline = 'middle'; x.lineJoin = 'round';
  const draw = (dx, dy, f, s) => {
    let px = pad + lw + dx;
    if (!track) { if (s) { x.strokeStyle = s; x.lineWidth = lw; x.strokeText(text, px, h / 2 + dy); } if (f) { x.fillStyle = f; x.fillText(text, px, h / 2 + dy); } return; }
    chars.forEach((ch, i) => { if (s) { x.strokeStyle = s; x.lineWidth = lw; x.strokeText(ch, px, h / 2 + dy); } if (f) { x.fillStyle = f; x.fillText(ch, px, h / 2 + dy); } px += widths[i] + track; });
  };
  if (shadow) draw(shadow.x, shadow.y, shadow.color, stroke ? shadow.color : null);
  draw(0, 0, fill, stroke);
  return { c, w, h, ink };
}

// A flat text plane (h = world height of the text box).
export function textPlane(text, h, opts = {}) {
  const { c, w, h: ch, ink } = textCanvas(text, opts);
  const geo = new THREE.PlaneGeometry(h * w / ch, h);
  const mat = new THREE.MeshBasicMaterial({ map: tex(c), transparent: true, depthWrite: false, side: THREE.DoubleSide, toneMapped: false, fog: opts.fog ?? true });
  const m = new THREE.Mesh(geo, mat); m.userData.w = h * w / ch; m.userData.text = text; m.userData.ink = ink; return m;
}

// Extruded type: the same glyph mask stacked in depth. Front colour + darker sides.
export function extruded(text, h, { font = `900 220px ${FONT.heavy}`, front = C.acc, side = C.ink, depth = 0.32, layers = 22, outline = null, track = 0, rim = null } = {}) {
  const { c, w, h: ch, ink } = textCanvas(text, { font, fill: '#fff', track, stroke: outline ? '#fff' : null, lw: outline ? 10 : 0 });
  const t = tex(c), g = new THREE.Group(), W = h * w / ch;
  const geo = new THREE.PlaneGeometry(W, h);
  const sideC = new THREE.Color(side), frontC = new THREE.Color(front), deep = sideC.clone().multiplyScalar(0.55);
  for (let i = layers; i >= 0; i--) {
    const k = i / layers;
    const col = i === 0 ? frontC : (i === 1 && rim ? new THREE.Color(rim) : sideC.clone().lerp(deep, k));
    const mat = new THREE.MeshBasicMaterial({ map: t, color: col, transparent: true, alphaTest: 0.45, side: THREE.DoubleSide, toneMapped: false });
    const p = new THREE.Mesh(geo, mat); p.position.z = -depth * k; g.add(p);
  }
  g.userData.w = W; g.userData.front = g.children[g.children.length - 1]; g.userData.text = text; g.userData.ink = ink;
  return g;
}

// an outline-only version (for the "outline, then fill" entrance)
export function outlinePlane(text, h, { font = `900 220px ${FONT.heavy}`, color = C.ink, lw = 7, track = 0 } = {}) {
  const { c, w, h: ch, ink } = textCanvas(text, { font, fill: null, stroke: '#fff', lw, track });
  const m = new THREE.Mesh(new THREE.PlaneGeometry(h * w / ch, h), new THREE.MeshBasicMaterial({ map: tex(c), color, transparent: true, depthWrite: false, side: THREE.DoubleSide, toneMapped: false }));
  m.userData.text = text; m.userData.ink = ink; return m;
}

// rounded "pill" label
export function pill(text, h, { font = `800 120px ${FONT.mono}`, bg = C.white, fg = C.ink, border = null, bw = 8, padX = 60, radius = null, shadow = null, glow = null } = {}) {
  const m = canvas(8, 8).getContext('2d'); m.font = font;
  const size = parseFloat(font.match(/(\d+)px/)[1]);
  const tw = m.measureText(text).width, ph = size * 1.55, pw = tw + padX * 2, pad = glow ? 60 : 16;
  const c = canvas(Math.ceil(pw + pad * 2), Math.ceil(ph + pad * 2 + (shadow ? 22 : 0))), x = c.getContext('2d');
  const r = radius ?? ph / 2;
  if (glow) { x.shadowColor = glow; x.shadowBlur = 50; }
  if (shadow) { x.fillStyle = shadow; rr(x, pad, pad + 22, pw, ph, r); x.fill(); }
  x.fillStyle = bg; rr(x, pad, pad, pw, ph, r); x.fill(); x.shadowBlur = 0;
  if (border) { x.strokeStyle = border; x.lineWidth = bw; rr(x, pad + bw / 2, pad + bw / 2, pw - bw, ph - bw, r - bw / 2); x.stroke(); }
  x.font = font; x.fillStyle = fg; x.textBaseline = 'middle'; x.textAlign = 'center'; x.fillText(text, pad + pw / 2, pad + ph / 2 + size * 0.04);
  const H = h * c.height / ph;
  const mesh = new THREE.Mesh(new THREE.PlaneGeometry(H * c.width / c.height, H), new THREE.MeshBasicMaterial({ map: tex(c), transparent: true, depthWrite: false, side: THREE.DoubleSide, toneMapped: false }));
  mesh.userData.w = H * c.width / c.height; mesh.userData.text = text; mesh.userData.ink = [pad / c.width, pad / c.height, (pad + pw) / c.width, (pad + ph) / c.height]; return mesh;
}

// grid texture for rooms
export function gridTex(dark, { cells = 4, minor = 4, repeat = [1, 1] } = {}) {
  const S = 512, c = canvas(S, S), x = c.getContext('2d');
  x.fillStyle = dark ? C.night : C.paper; x.fillRect(0, 0, S, S);
  const step = S / (cells * minor);
  for (let i = 0; i <= cells * minor; i++) {
    const maj = i % minor === 0;
    x.strokeStyle = dark ? (maj ? 'rgba(110,118,255,0.85)' : 'rgba(80,88,230,0.28)') : (maj ? C.gridMaj : C.grid);
    x.lineWidth = maj ? 3 : 1.4;
    x.beginPath(); x.moveTo(i * step, 0); x.lineTo(i * step, S); x.stroke();
    x.beginPath(); x.moveTo(0, i * step); x.lineTo(S, i * step); x.stroke();
  }
  const t = tex(c); t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(...repeat); return t;
}

// a room: the inside of a box, grid on every face. Floor at y = 0.
export function room(dark, { w = 70, h = 40, d = 70, rep = 9 } = {}) {
  const g = new THREE.Group();
  const mk = (W, H, rx, ry, pos, r) => {
    const t = gridTex(dark, { repeat: [rx, ry] });
    const m = new THREE.Mesh(new THREE.PlaneGeometry(W, H), new THREE.MeshBasicMaterial({ map: t, side: THREE.DoubleSide, toneMapped: false }));
    m.position.set(...pos); m.rotation.set(...r); g.add(m); return m;
  };
  const k = rep / w;
  mk(w, d, w * k, d * k, [0, 0, 0], [-Math.PI / 2, 0, 0]);
  mk(w, d, w * k, d * k, [0, h, 0], [Math.PI / 2, 0, 0]);
  mk(w, h, w * k, h * k, [0, h / 2, -d / 2], [0, 0, 0]);
  mk(w, h, w * k, h * k, [0, h / 2, d / 2], [0, Math.PI, 0]);
  mk(d, h, d * k, h * k, [-w / 2, h / 2, 0], [0, Math.PI / 2, 0]);
  mk(d, h, d * k, h * k, [w / 2, h / 2, 0], [0, -Math.PI / 2, 0]);
  return g;
}

export function ring(r, tube, color, { seg = 160, opacity = 1, additive = false } = {}) {
  const m = new THREE.Mesh(new THREE.TorusGeometry(r, tube, 8, seg), new THREE.MeshBasicMaterial({ color, transparent: opacity < 1 || additive, opacity, blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending, depthWrite: !additive, toneMapped: false }));
  return m;
}

// 4-point sparkle sprite texture
let _spark;
export function sparkTex() {
  if (_spark) return _spark;
  const c = canvas(128, 128), x = c.getContext('2d'); x.fillStyle = '#fff';
  x.beginPath(); x.moveTo(64, 0); x.quadraticCurveTo(70, 58, 128, 64); x.quadraticCurveTo(70, 70, 64, 128); x.quadraticCurveTo(58, 70, 0, 64); x.quadraticCurveTo(58, 58, 64, 0); x.fill();
  return (_spark = tex(c));
}
let _dot;
export function dotTex() {
  if (_dot) return _dot;
  const c = canvas(64, 64), x = c.getContext('2d'); x.fillStyle = '#fff'; x.beginPath(); x.arc(32, 32, 28, 0, 7); x.fill();
  return (_dot = tex(c));
}
export function sprite(t, color, s, { additive = false, opacity = 1 } = {}) {
  const m = new THREE.Sprite(new THREE.SpriteMaterial({ map: t, color, transparent: true, opacity, depthWrite: false, blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending, toneMapped: false }));
  m.scale.set(s, s, 1); return m;
}

export function billboard(obj, cam) { obj.quaternion.copy(cam.quaternion); }

// screen-space shift: the composition centre sits at (510, 865) instead of (540, 960) — RULES safe zone.
export function makeCam(fov = 50) {
  const c = new THREE.PerspectiveCamera(fov, 1080 / 1920, 0.05, 400);
  c.setViewOffset(1080, 1920, 30, 95, 1080, 1920);
  return c;
}
```

### `kinds.js`

```js
// Lyric format: the scene kinds. Each returns { scene, cam, update(t), post, fx }.
// update(t) sets everything from the absolute film time t (no state between frames).
import * as THREE from 'three';
import { C, FONT, clamp, lerp, ramp, E, rng, wob, canvas, tex, rr, textCanvas, textPlane, extruded, outlinePlane, pill, gridTex, room, ring, sparkTex, dotTex, sprite, billboard, makeCam } from './core.js';

const LIGHT = { k: 0.24, bloom: 0.1, thr: 0.9, vig: 0.2, grain: 0.035, ca: 0.0016 };
const DARK = { k: 0.28, bloom: 0.5, thr: 0.93, vig: 0.5, grain: 0.05, ca: 0.003 };
const TAU = Math.PI * 2;

function lightScene(fogNear = 16, fogFar = 60) {
  const s = new THREE.Scene(); s.background = new THREE.Color(C.paper); s.fog = new THREE.Fog(C.paper, fogNear, fogFar); return s;
}
function darkScene(fogNear = 10, fogFar = 55) {
  const s = new THREE.Scene(); s.background = new THREE.Color(C.night); s.fog = new THREE.Fog(C.night, fogNear, fogFar); return s;
}
function lights(scene) {
  scene.add(new THREE.HemisphereLight(0xffffff, new THREE.Color('#bfc4ff'), 1.5));
  const d = new THREE.DirectionalLight(0xffffff, 2.1); d.position.set(3, 9, 6); scene.add(d);
  scene.add(new THREE.AmbientLight(0xffffff, 0.35));
}
// a box with flat-shaded faces (front may carry a texture)
function box(w, h, d, { front = C.acc, side = C.ink, top = null, map = null }) {
  const sideC = new THREE.Color(side), topC = new THREE.Color(top || front);
  const mk = (c, m) => new THREE.MeshBasicMaterial({ color: c, map: m || null, toneMapped: false });
  const mats = [mk(sideC), mk(sideC), mk(topC), mk(sideC.clone().multiplyScalar(0.6)), mk(new THREE.Color(map ? '#ffffff' : front), map), mk(sideC)];
  return new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mats);
}
// pop entrance: back-eased scale, swing in on Y
function pop(obj, t, at, { dur = 0.24, rot = -0.9, z = -1.2, base = 1 } = {}) {
  const k = ramp(t, at - 0.02, dur);
  obj.visible = t >= at - 0.02;
  const s = Math.max(0.001, E.back(k)) * base;
  obj.scale.setScalar(s);
  obj.rotation.y = (1 - E.out(k)) * rot;
  obj.position.z = (obj.userData.z0 || 0) + (1 - E.out(k)) * z;
  return k;
}
function sparkles(scene, R, n, box3, color) {
  const out = [];
  for (let i = 0; i < n; i++) {
    const s = sprite(sparkTex(), color, 0.12 + R() * 0.22);
    s.position.set(lerp(box3[0], box3[1], R()), lerp(box3[2], box3[3], R()), lerp(box3[4], box3[5], R()));
    s.userData.ph = R() * 10; scene.add(s); out.push(s);
  }
  return t => out.forEach(s => { const k = 0.5 + 0.5 * Math.sin(t * 3 + s.userData.ph); s.material.opacity = 0.25 + 0.75 * k; s.material.rotation = t * 0.4 + s.userData.ph; });
}
function monoLabel(text, h, color = C.ink) { return textPlane(text, h, { font: `700 90px ${FONT.mono}`, fill: color, pad: 10 }); }

// ───────────────────────── WORDS: light fisheye room, stacked extruded words ─────────────────────────
export function words(o) {
  const scene = lightScene(), cam = makeCam(48), R = rng(o.seed || 7);
  scene.add(room(false));
  const T = new THREE.Vector3(0, 3.3, 0);
  const big = ring(3.0, 0.05, C.acc); big.position.set(0.25, 3.5, -1.8); scene.add(big);
  const big2 = ring(3.9, 0.018, C.gridMaj); big2.position.set(-0.3, 3.2, -2.6); scene.add(big2);
  const circles = [];
  for (let i = 0; i < 10; i++) { const c = ring(0.15 + R() * 0.45, 0.014, i % 3 ? C.gridMaj : C.acc); c.position.set(lerp(-5, 5, R()), lerp(1, 8, R()), lerp(-7, 1.5, R())); c.userData.ph = R() * 9; scene.add(c); circles.push(c); }
  for (let i = 0; i < 8; i++) {
    const g = new THREE.Group(), hgt = 2 + R() * 5;
    const rod = new THREE.Mesh(new THREE.CylinderGeometry(0.014, 0.014, hgt, 6), new THREE.MeshBasicMaterial({ color: C.acc, toneMapped: false })); rod.position.y = hgt / 2; g.add(rod);
    const tip = new THREE.Mesh(new THREE.SphereGeometry(0.06, 12, 8), new THREE.MeshBasicMaterial({ color: C.acc, toneMapped: false })); tip.position.y = hgt; g.add(tip);
    const base = ring(0.28, 0.012, C.acc); base.rotation.x = -Math.PI / 2; base.position.y = 0.01; g.add(base);
    const a = R() * TAU, r = 2.4 + R() * 4.5; g.position.set(Math.cos(a) * r, 0, Math.sin(a) * r - 1); scene.add(g);
  }
  for (let i = 0; i < 4; i++) { const f = ring(2 + i * 1.3, 0.02, C.acc, { opacity: 0.55 - i * 0.1 }); f.rotation.x = -Math.PI / 2; f.position.set(0, 0.02, -0.5); scene.add(f); }
  const tw = sparkles(scene, R, 12, [-5, 5, 0.5, 8, -5, 2], C.acc);

  const groups = o.groups.map((gr, gi) => {
    const G = new THREE.Group(); G.position.copy(T); scene.add(G);
    const items = gr.words.map(w => {
      const font = w.font || `900 220px ${w.cond ? FONT.cond : FONT.heavy}`;
      const front = w.style === 'acc' ? C.acc : w.style === 'white' ? C.white : C.ink;
      const side = w.style === 'acc' ? C.ink : w.style === 'white' ? C.acc : C.acc;
      const m = extruded(w.text, 1, { font, front, side, depth: 0.3, layers: 22 });
      const sc = (w.fit || 3.1) / m.userData.w; m.scale.setScalar(sc);
      const holder = new THREE.Group(); holder.add(m); holder.position.set(w.x || 0, w.y || 0, w.z || 0); holder.userData.z0 = w.z || 0; holder.rotation.z = w.rz || 0;
      G.add(holder);
      let out = null;
      if (w.outline) { out = outlinePlane(w.text, 1, { font, color: C.ink, lw: 6 }); out.scale.setScalar(sc); out.position.copy(holder.position); out.position.z += 0.02; out.rotation.z = holder.rotation.z; G.add(out); }
      return { w, holder, out };
    });
    const labels = (gr.labels || []).map(l => { const m = monoLabel(l.text, l.h || 0.2, l.color || C.ink); m.position.set(l.x, l.y, 0.25); G.add(m); return { l, m }; });
    return { G, at: gr.at, until: o.groups[gi + 1] ? o.groups[gi + 1].at : Infinity, items, labels, swing: gr.swing ?? (gi % 2 ? 0.45 : -0.45) };
  });

  return {
    scene, cam, post: { ...LIGHT },
    update(t) {
      let swing = 0;
      for (const g of groups) if (t >= g.at && g.at > o.start + 0.05) swing += g.swing * E.out(ramp(t, g.at - 0.04, 0.3));
      const u = t - o.start;
      const a = (o.a0 ?? -0.35) + (o.spin ?? 0.09) * u + swing;
      const r = (o.r ?? 8.3) - 0.25 * u / (o.end - o.start);
      cam.position.set(T.x + Math.sin(a) * r, T.y + 0.9 + 0.35 * Math.sin(u * 0.9), T.z + Math.cos(a) * r);
      cam.lookAt(T.x, T.y + 0.1, T.z); cam.rotateZ(0.03 * Math.sin(u * 1.3));
      for (const g of groups) g.G.rotation.y = a * 0.45;
      for (const g of groups) {
        const on = t >= g.at && t < g.until; g.G.visible = on;
        if (!on) continue;
        for (const it of g.items) {
          const fillAt = it.w.outline ? it.w.t + (it.w.fillDelay ?? 0.3) : it.w.t;
          if (it.out) {
            const ko = ramp(t, it.w.t, 0.18); it.out.visible = t >= it.w.t - 0.02 && t < fillAt + 0.06;
            it.out.scale.setScalar(it.holder.children[0].scale.x * (1.12 - 0.12 * E.out(ko)));
            it.out.material.opacity = ko;
          }
          pop(it.holder, t, fillAt, { rot: it.out ? 0 : (it.w.rot ?? -0.8), z: it.out ? 0 : -1.4, dur: it.out ? 0.16 : 0.26 });
          it.holder.position.y = (it.w.y || 0) + 0.035 * Math.sin(t * 2.2 + (it.w.y || 0) * 3);
        }
        for (const { l, m } of g.labels) { const k = ramp(t, l.t, 0.15); m.visible = t >= l.t; m.material.opacity = k; m.position.x = l.x + (1 - E.out(k)) * 0.3; billboard(m, cam); m.quaternion.copy(cam.quaternion); }
      }
      big.rotation.y = 0.2 * Math.sin(u * 0.8); big.rotation.x = 0.15 * Math.sin(u * 0.6);
      circles.forEach(c => { c.position.y += 0; c.rotation.y = t * 0.5 + c.userData.ph; });
      tw(t);
    },
  };
}

// ───────────────────────── PILLAR: the letter "I" as a ruler tower, a pill label on top ─────────────────────────
export function pillar(o) {
  const scene = lightScene(18, 70), cam = makeCam(50), R = rng(o.seed || 3);
  scene.add(room(false, { h: 46 }));
  // ruler texture
  const rc = canvas(256, 2048), x = rc.getContext('2d');
  x.fillStyle = C.acc; x.fillRect(0, 0, 256, 2048);
  for (let i = 0; i <= 64; i++) {
    const y = 2048 - i * 32, maj = i % 8 === 0;
    x.fillStyle = 'rgba(255,255,255,0.92)'; x.fillRect(maj ? 30 : 46, y - 3, maj ? 196 : 56, maj ? 7 : 5); x.fillRect(maj ? 30 : 154, y - 3, maj ? 0 : 56, maj ? 0 : 5);
    if (maj && i) { x.font = `700 46px ${FONT.mono}`; x.fillText(String(i / 8).padStart(2, '0'), 40, y - 22); x.font = `700 22px ${FONT.mono}`; x.fillText('V', 205, y - 14); }
  }
  const rt = tex(rc);
  const H = 9;
  const col = box(1.35, H, 0.9, { front: C.acc, side: '#3a3dd6', map: rt }); col.position.y = 0.55 + H / 2; scene.add(col);
  const cap = box(3.1, 0.62, 1.7, { front: C.acc2, side: C.acc, top: '#a9aeff' }); cap.position.y = 0.55 + H + 0.31; scene.add(cap);
  const base = box(3.4, 0.55, 1.9, { front: C.ink, side: C.inkDeep, top: '#2a2c7a' }); base.position.y = 0.275; scene.add(base);
  for (let i = 0; i < 5; i++) { const f = ring(2.2 + i * 1.1, 0.02 + (i === 1 ? 0.03 : 0), C.acc, { opacity: 0.7 - i * 0.12 }); f.rotation.x = -Math.PI / 2; f.position.y = 0.02; scene.add(f); }
  // dashed orbit rings around the column
  const dashC = canvas(1024, 32), dx = dashC.getContext('2d'); dx.fillStyle = C.acc; for (let i = 0; i < 64; i++) dx.fillRect(i * 16, 6, i % 4 ? 5 : 9, i % 4 ? 12 : 20);
  const dashT = tex(dashC);
  const orbits = [];
  for (let i = 0; i < 3; i++) {
    const m = new THREE.Mesh(new THREE.CylinderGeometry(2.6 + i * 0.7, 2.6 + i * 0.7, 0.18, 96, 1, true), new THREE.MeshBasicMaterial({ map: dashT, transparent: true, side: THREE.DoubleSide, depthWrite: false, toneMapped: false }));
    m.position.y = 2.5 + i * 3.2; scene.add(m); orbits.push(m);
  }
  const labels = o.labels.map(l => { const p = pill(l.text, 0.42, { bg: C.white, fg: C.ink, border: C.acc, bw: 10, font: `800 110px ${FONT.mono}` }); scene.add(p); return { l, p }; });
  const tw = sparkles(scene, R, 14, [-4, 4, 1, 12, -4, 3], C.acc);
  const yTop = 0.55 + H + 0.62;
  return {
    scene, cam, post: { ...LIGHT },
    update(t) {
      const u = t - o.start, k = clamp(u / (o.end - o.start));
      const e = E.inOut(k);
      const cy = lerp(1.2, yTop + 1.4, e), a = lerp(-0.7, 0.35, e);
      const r = lerp(5.4, 6.4, e);
      cam.position.set(Math.sin(a) * r, cy, Math.cos(a) * r);
      cam.lookAt(0, lerp(3.2, yTop + 0.25, E.inOut(clamp(k * 1.15))), 0); cam.rotateZ(-0.05 + 0.08 * k);
      orbits.forEach((m, i) => { m.rotation.y = t * (0.6 + i * 0.3) * (i % 2 ? -1 : 1); });
      labels.forEach(({ l, p }, i) => {
        const nx = o.labels[i + 1];
        const on = t >= l.t && (!nx || t < nx.t);
        p.visible = on; if (!on) return;
        const kk = ramp(t, l.t, 0.2); p.scale.setScalar(Math.max(0.01, E.back(kk)));
        p.position.set(0, yTop + 0.45, 0.2); billboard(p, cam);
      });
      tw(t);
    },
  };
}

// ───────────────────────── RINGS: a dark tower of spinning bands, the lyric runs around them ─────────────────────────
const RING_R = 3.6, RING_H = 1.0;
function bandStyle(style) {
  return ({
    bold: { bg: 'rgba(12,14,60,0.92)', font: `900 104px ${FONT.heavy}`, on: '#ffffff', off: 'rgba(255,255,255,0.22)', sh: C.acc, edge: C.glow },
    serif: { bg: '#c3c7ff', font: `italic 400 128px ${FONT.serif}`, on: C.ink, off: 'rgba(23,24,90,0.25)', sh: null, under: C.acc, edge: '#ffffff' },
    black: { bg: '#020210', font: `900 104px ${FONT.heavy}`, on: C.acc2, on2: '#ffffff', off: 'rgba(255,255,255,0.18)', sh: null, edge: C.acc2, stroke: true },
    italic: { bg: 'rgba(14,16,64,0.9)', font: `italic 700 118px ${FONT.serif}`, on: '#ffffff', off: 'rgba(255,255,255,0.2)', sh: C.acc, edge: C.glow },
  })[style];
}
function lyricBand(line) {
  const S = bandStyle(line.style), W = 4096, Hh = 180;
  const c = canvas(W, Hh), x = c.getContext('2d'), t = tex(c);
  x.font = S.font; const sp = x.measureText(' ').width * 1.4;
  let px = 140; const pos = [];
  for (const w of line.words) { const ww = x.measureText(w.w).width; pos.push({ x: px, w: ww }); px += ww + sp; }
  const lineW = px;
  // the leftover band: a dim repeat of the line after a separator
  const fill = [];
  let qx = lineW + 60; while (qx < W - 200) { for (const w of line.words) { const ww = x.measureText(w.w).width; if (qx + ww > W - 60) break; fill.push({ x: qx, w: w.w }); qx += ww + sp; } qx += 120; }
  let last = -2;
  function draw(n) {
    if (n === last) return; last = n;
    x.clearRect(0, 0, W, Hh); x.fillStyle = S.bg; x.fillRect(0, 0, W, Hh);
    x.font = S.font; x.textBaseline = 'middle';
    x.fillStyle = S.off; fill.forEach(f => x.fillText(f.w, f.x, Hh / 2 + 6));
    line.words.forEach((w, i) => {
      const on = i < n;
      const col = on ? (S.on2 && i % 2 ? S.on2 : S.on) : S.off;
      if (on && S.sh) { x.fillStyle = S.sh; x.fillText(w.w, pos[i].x + 6, Hh / 2 + 12); }
      if (S.stroke && on) { x.lineWidth = 3; x.strokeStyle = '#ffffff'; x.strokeText(w.w, pos[i].x, Hh / 2 + 6); }
      x.fillStyle = col; x.fillText(w.w, pos[i].x, Hh / 2 + 6);
      if (on && S.under && i === n - 1) { x.fillStyle = S.under; x.fillRect(pos[i].x, Hh - 34, pos[i].w, 10); }
    });
    t.needsUpdate = true;
  }
  draw(0);
  return { t, draw, centers: pos.map(p => (p.x + p.w / 2) / W), S };
}
function decoBand(kind, R, text) {
  const W = 4096, Hh = kind === 'line' ? 16 : 120, c = canvas(W, Hh), x = c.getContext('2d');
  x.fillStyle = 'rgba(10,12,48,0.75)'; x.fillRect(0, 0, W, Hh);
  x.fillStyle = C.glow; x.strokeStyle = C.glow;
  if (kind === 'ticks') { for (let i = 0; i < 256; i++) { const h = i % 8 ? 26 : 60; x.globalAlpha = i % 8 ? 0.5 : 0.9; x.fillRect(i * 16, Hh - h - 10, 3, h); } x.globalAlpha = 1; }
  if (kind === 'chev') { x.lineWidth = 9; x.globalAlpha = 0.75; for (let i = 0; i < 52; i++) { const px = 30 + i * 78; x.beginPath(); x.moveTo(px, 28); x.lineTo(px + 28, 60); x.lineTo(px, 92); x.stroke(); } x.globalAlpha = 1; }
  if (kind === 'label' || kind === 'names') {
    x.font = `700 46px ${FONT.mono}`; x.textBaseline = 'middle'; x.globalAlpha = 0.85;
    let px = 40; const items = text; let i = 0;
    while (px < W - 300) { const s = items[i % items.length]; x.fillText(s, px, Hh / 2); px += x.measureText(s).width + 110; x.fillRect(px - 64, Hh / 2 - 5, 10, 10); i++; }
    x.globalAlpha = 1; x.fillRect(0, 6, W, 3); x.fillRect(0, Hh - 9, W, 3);
  }
  if (kind === 'blocks') { for (let i = 0; i < 90; i++) { x.globalAlpha = 0.3 + R() * 0.6; x.fillRect(i * 46, 30, 26 + R() * 12, 14); x.fillRect(i * 46 + 6, 70, 18, 14); } x.globalAlpha = 1; }
  return tex(c);
}
function bandMesh(t, r, h, { light = false, opacity = 1 } = {}) {
  const m = new THREE.Mesh(new THREE.CylinderGeometry(r, r, h, 160, 1, true), new THREE.MeshBasicMaterial({ map: t, side: THREE.DoubleSide, transparent: true, opacity, depthWrite: true, toneMapped: false }));
  return m;
}
export function rings(o) {
  const scene = darkScene(9, 46), cam = makeCam(50), R = rng(o.seed || 11);
  const rm = room(true, { w: 90, h: 70, d: 90, rep: 14 }); rm.position.y = -30; scene.add(rm);
  // city bars
  const winC = canvas(64, 256), wx = winC.getContext('2d'); wx.fillStyle = C.night2; wx.fillRect(0, 0, 64, 256);
  for (let i = 0; i < 16; i++) { wx.fillStyle = `rgba(140,150,255,${0.25 + (i % 3) * 0.2})`; wx.fillRect(12, i * 16 + 4, 16, 5); wx.fillRect(36, i * 16 + 4, 16, 5); }
  const winT = tex(winC); winT.wrapT = THREE.RepeatWrapping;
  const bars = [];
  for (let i = 0; i < 46; i++) {
    const a = R() * TAU, r = 8 + R() * 12, h = 3 + R() * 16, w = 0.8 + R() * 1.4;
    const t2 = winT.clone(); t2.repeat.set(1, h / 3); t2.needsUpdate = true;
    const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, w), new THREE.MeshBasicMaterial({ map: t2, color: '#9aa0ff', toneMapped: false }));
    m.position.set(Math.cos(a) * r, -30 + h / 2 + (o.barsLift || 18), Math.sin(a) * r); scene.add(m); bars.push(m);
    const e = new THREE.LineSegments(new THREE.EdgesGeometry(m.geometry), new THREE.LineBasicMaterial({ color: C.line, transparent: true, opacity: 0.8 })); e.position.copy(m.position); scene.add(e);
  }
  // centre beam + spark
  const beam = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 60, 8, 1, true), new THREE.MeshBasicMaterial({ color: C.glow, transparent: true, opacity: 0.6, blending: THREE.AdditiveBlending, depthWrite: false })); scene.add(beam);
  const sparkG = new THREE.Group(); scene.add(sparkG);
  for (let i = 0; i < 14; i++) { const l = new THREE.Mesh(new THREE.PlaneGeometry(0.03, 0.5 + R() * 0.5), new THREE.MeshBasicMaterial({ color: '#ffffff', transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide })); const a = (i / 14) * Math.PI - Math.PI / 2 + (R() - 0.5) * 0.2; l.position.set(Math.sin(a) * 0.35, Math.cos(a) * 0.35, 0); l.rotation.z = -a; sparkG.add(l); }
  // lyric rings
  const gapY = o.gap || 1.75;
  const L = o.lines.map((line, i) => {
    const b = lyricBand(line); const m = bandMesh(b.t, RING_R, RING_H); const y = -i * gapY; m.position.y = y; scene.add(m);
    const e1 = ring(RING_R, 0.016, b.S.edge, { additive: true }), e2 = e1.clone(); e1.rotation.x = e2.rotation.x = Math.PI / 2; e1.position.y = y + RING_H / 2; e2.position.y = y - RING_H / 2; scene.add(e1, e2);
    return { line, b, m, y, t0: line.words[0].t };
  });
  // deco rings between and around
  const decos = [];
  const kinds = ['ticks', 'chev', 'label', 'line', 'blocks', 'names', 'ticks', 'chev', 'label'];
  const yTop = 2.6, yBot = -(o.lines.length - 1) * gapY - 2.8;
  for (let i = 0; i < 14; i++) {
    const kind = kinds[i % kinds.length];
    const y = lerp(yTop, yBot, (i + 0.5) / 14) + (R() - 0.5) * 0.4;
    if (L.some(l => Math.abs(l.y - y) < 0.75)) continue;
    const r = RING_R * (0.75 + R() * 0.45), h = kind === 'line' ? 0.05 : 0.32 + R() * 0.2;
    const t2 = decoBand(kind, R, kind === 'names' ? (o.names || ['FREE', 'LYRIC.MD']) : (o.tags || ['RING 01 · FREE', '121.8 BPM', 'NO PAYWALL']));
    const m = bandMesh(t2, r, h, { opacity: 0.9 }); m.position.y = y; scene.add(m);
    const ed = ring(r, 0.012, C.line, { additive: true }); ed.rotation.x = Math.PI / 2; ed.position.y = y + h / 2; scene.add(ed);
    decos.push({ m, ed, sp: (0.15 + R() * 0.35) * (R() < 0.5 ? -1 : 1) });
  }
  for (let i = 0; i < 26; i++) { const l = ring(RING_R * (0.9 + R() * 0.9), 0.01, C.line, { additive: true, opacity: 0.5 }); l.rotation.x = Math.PI / 2 + (R() - 0.5) * 0.25; l.rotation.y = R(); l.position.y = lerp(yTop + 2, yBot - 2, R()); scene.add(l); decos.push({ m: l, sp: (R() - 0.5) * 0.3, line: true }); }

  // where the camera is: on the ring of the current line
  const lineAt = t => { let i = 0; for (let j = 0; j < L.length; j++) if (t >= L[j].t0 - 0.12) i = j; return i; };
  return {
    scene, cam, post: { ...DARK },
    update(t) {
      const u = t - o.start;
      const i = lineAt(t), li = L[i];
      const prevY = i > 0 ? L[i - 1].y : li.y + gapY * 0.6;
      const k = E.out(ramp(t, li.t0 - 0.14, 0.32));
      const ty = lerp(prevY, li.y, k) + (o.dive ? -E.in(ramp(t, o.dive, o.end - o.dive)) * 9 : 0);
      const phi = (o.phi0 ?? 0.2) + 0.08 * Math.sin(u * 0.7) + 0.05 * u;
      const D = (o.D ?? 9.6) - (o.dive ? E.in(ramp(t, o.dive, o.end - o.dive)) * 4 : 0);
      cam.position.set(Math.sin(phi) * D, ty + 1.15, Math.cos(phi) * D);
      cam.lookAt(0, ty - 0.05, 0); cam.rotateZ(0.06 * Math.sin(u * 0.8) - 0.03);
      // each lyric ring turns so the sung word faces the camera
      for (const l of L) {
        const ws = l.line.words; let n = 0; ws.forEach((w, j) => { if (t >= w.t) n = j + 1; });
        l.b.draw(n);
        const c = l.b.centers; let uu;
        if (t < ws[0].t) uu = c[0] - 0.04 * (1 - ramp(t, ws[0].t - 0.6, 0.6));
        else { let j = 0; for (let q = 0; q < ws.length; q++) if (t >= ws[q].t) j = q; const nx = ws[j + 1]; uu = nx ? lerp(c[j], c[j + 1], E.inOut(ramp(t, ws[j].t, Math.max(0.12, nx.t - ws[j].t)))) : c[j] + 0.006 * (t - ws[j].t); }
        l.m.rotation.y = phi - uu * TAU + Math.PI * 0; // ring local angle 2πu faces the camera azimuth
      }
      decos.forEach(d => { d.m.rotation.y = (d.line ? d.m.rotation.y * 0 + t * d.sp : t * d.sp); if (d.ed) d.ed.rotation.z = t * d.sp; });
      beam.position.y = ty;
      sparkG.position.set(0, ty + 0.05, 0); sparkG.lookAt(cam.position.x, ty + 0.05, cam.position.z);
      const lastW = Math.max(...L.flatMap(l => l.line.words.map(w => (t >= w.t ? w.t : -9))));
      const sk = clamp(1 - (t - lastW) / 0.35); sparkG.scale.setScalar(0.4 + 1.3 * E.out(1 - sk) * (sk > 0 ? 1 : 0) + 0.3);
      sparkG.children.forEach(c => (c.material.opacity = 0.35 + 0.65 * sk));
    },
  };
}

// ───────────────────────── TUNNEL: dark grid corridor, slab cards + ransom letters ─────────────────────────
function cardMesh(text, style, h = 1.0) {
  const S = {
    dark: { bg: C.night2, fg: '#ffffff', font: `italic 700 210px ${FONT.serif}`, bar: C.acc, border: C.line },
    white: { bg: '#dfe2ff', fg: C.acc, font: `900 200px ${FONT.heavy}`, border: C.acc, bw: 22 },
    acc: { bg: C.acc, fg: '#ffffff', font: `900 200px ${FONT.heavy}`, border: '#ffffff', bw: 10 },
  }[style];
  const m = canvas(8, 8).getContext('2d'); m.font = S.font; const tw = m.measureText(text).width;
  const W = Math.ceil(tw + 240), Hh = 360, c = canvas(W, Hh), x = c.getContext('2d');
  x.fillStyle = S.bg; rr(x, 8, 8, W - 16, Hh - 16, 46); x.fill();
  if (S.border) { x.strokeStyle = S.border; x.lineWidth = S.bw || 6; rr(x, 8 + (S.bw || 6) / 2 + 10, 8 + (S.bw || 6) / 2 + 10, W - 36 - (S.bw || 6), Hh - 36 - (S.bw || 6), 36); x.stroke(); }
  if (S.bar) { x.fillStyle = S.bar; x.fillRect(70, Hh * 0.62, W - 140, 70); }
  x.font = S.font; x.textBaseline = 'middle'; x.textAlign = 'center';
  if (style === 'dark') { x.fillStyle = C.acc; x.fillText(text, W / 2 + 8, Hh / 2 + 12); }
  x.fillStyle = S.fg; x.fillText(text, W / 2, Hh / 2 + 4);
  const t = tex(c), g = new THREE.Group(), w = h * W / Hh;
  for (let i = 6; i >= 0; i--) {
    const mat = new THREE.MeshBasicMaterial({ map: i === 0 ? t : null, alphaMap: null, color: i === 0 ? '#ffffff' : (style === 'white' ? '#b9bdff' : C.inkDeep), transparent: i !== 0, opacity: 1, side: THREE.DoubleSide, toneMapped: false });
    const p = new THREE.Mesh(i === 0 ? new THREE.PlaneGeometry(w, h) : roundedPlane(w * 0.985, h * 0.96, h * 0.12), mat); p.position.z = -i * 0.025; g.add(p);
  }
  g.userData.w = w; g.userData.text = text; g.userData.ink = [0.02, 0.02, 0.98, 0.98]; return g;
}
function roundedPlane(w, h, r) { const s = new THREE.Shape(); const x = -w / 2, y = -h / 2; s.moveTo(x + r, y); s.lineTo(x + w - r, y); s.quadraticCurveTo(x + w, y, x + w, y + r); s.lineTo(x + w, y + h - r); s.quadraticCurveTo(x + w, y + h, x + w - r, y + h); s.lineTo(x + r, y + h); s.quadraticCurveTo(x, y + h, x, y + h - r); s.lineTo(x, y + r); s.quadraticCurveTo(x, y, x + r, y); return new THREE.ShapeGeometry(s); }
const RANSOM = [
  { bg: '#e2e5ff', fg: C.ink, font: `900 170px ${FONT.heavy}` },
  { bg: C.acc, fg: '#ffffff', font: `italic 700 190px ${FONT.serif}` },
  { bg: C.ink, fg: '#ffffff', font: `190px ${FONT.cond}` },
  { bg: C.soft, fg: C.acc, font: `700 180px ${FONT.mono}` },
  { bg: '#e2e5ff', fg: C.acc, font: `700 170px ${FONT.script}` },
  { bg: C.acc2, fg: C.ink, font: `900 170px ${FONT.heavy}` },
  { bg: '#e2e5ff', fg: C.ink, font: `italic 400 200px ${FONT.serif}` },
];
function letterCard(ch, st, R) {
  const c = canvas(220, 250), x = c.getContext('2d');
  x.fillStyle = st.bg; x.fillRect(6, 6, 208, 238);
  x.font = st.font; x.textBaseline = 'middle'; x.textAlign = 'center'; x.fillStyle = st.fg;
  const lower = R() < 0.35 && st.font.includes('serif') ? ch.toLowerCase() : ch;
  x.fillText(lower, 110, 132);
  const m = new THREE.Mesh(new THREE.PlaneGeometry(0.88, 1), new THREE.MeshBasicMaterial({ map: tex(c), side: THREE.DoubleSide, toneMapped: false, transparent: true }));
  return m;
}
function tape(R) {
  const m = new THREE.Mesh(new THREE.PlaneGeometry(0.55, 0.16), new THREE.MeshBasicMaterial({ color: '#dfe2ff', transparent: true, opacity: 0.7, side: THREE.DoubleSide, depthWrite: false, toneMapped: false }));
  m.rotation.z = (R() - 0.5) * 1.4; return m;
}
export function tunnel(o) {
  const scene = darkScene(6, 42), cam = makeCam(52), R = rng(o.seed || 5);
  const rm = room(true, { w: 11, h: 11, d: 140, rep: 2.2 }); rm.position.set(0, -5.5, -40); scene.add(rm);
  const speed = o.speed || 1.6;
  const camZ = t => 20 - (t - o.start) * speed;
  // floating letter debris along the walls
  const debris = [];
  for (let i = 0; i < 18; i++) { const m = letterCard('ABCDEFGHKLMNOPRSTUVWY'[Math.floor(R() * 21)], RANSOM[i % RANSOM.length], R); m.scale.setScalar(0.4 + R() * 0.3); const side = i % 2 ? 1 : -1; m.position.set(side * (2.2 + R() * 2), lerp(-4, 4, R()), camZ(o.start) - 3 - R() * 20); m.userData.r = [(R() - 0.5) * 2, (R() - 0.5) * 2]; scene.add(m); debris.push(m); }
  const items = o.items.map((it, idx) => {
    const g = new THREE.Group(); scene.add(g);
    const z = camZ(it.t) - (it.dist || 6.2);
    g.position.set(it.x || 0, it.y || 0, z);
    if (it.type === 'card') { const m = cardMesh(it.text, it.style || 'dark', it.h || 1.0); g.add(m); g.userData.parts = [m]; }
    if (it.type === 'ransom') {
      const rows = it.rows || [it.text]; const parts = [];
      const lh = it.h || 0.95, lw = lh * 0.92;
      rows.forEach((row, ri) => {
        const chars = [...row];
        chars.forEach((ch, ci) => {
          if (ch === ' ') return;
          const st = RANSOM[Math.floor(R() * RANSOM.length)];
          const m = letterCard(ch, st, R); m.userData.text = ch; m.userData.ink = [0, 0, 1, 1]; const s = lh * (0.88 + R() * 0.2); m.scale.set(s * (st.font.includes('Impact') ? 0.8 : 1), s, 1);
          const holder = new THREE.Group(); holder.add(m);
          holder.position.set((ci - (chars.length - 1) / 2) * lw + (R() - 0.5) * 0.08 + (row.length % 2 ? 0 : 0), -(ri - (rows.length - 1) / 2) * lh * 1.08 + (R() - 0.5) * 0.1, (R() - 0.5) * 0.2);
          holder.rotation.z = (R() - 0.5) * 0.24;
          if (R() < 0.45) { const tp = tape(R); tp.position.set((R() - 0.5) * 0.5, 0.45, 0.02); holder.add(tp); }
          g.add(holder); parts.push(holder);
        });
      });
      g.userData.parts = parts;
      if (it.small) { const s = pill(it.small, 0.34, { bg: '#dcdfff', fg: C.acc, font: `italic 700 120px ${FONT.serif}` }); s.position.set(-0.9, (rows.length / 2) * lh * 1.08 + 0.25, 0.1); s.rotation.z = 0.06; g.add(s); g.userData.small = s; }
      if (it.after) { const s = pill(it.after, 0.34, { bg: C.acc, fg: '#ffffff', font: `italic 700 120px ${FONT.serif}` }); s.position.set(0.9, -(rows.length / 2) * lh * 1.08 - 0.25, 0.1); s.rotation.z = -0.05; g.add(s); g.userData.after = s; }
    }
    return { it, g, until: it.until ?? (it.keep || !o.items[idx + 1] ? Infinity : o.items[idx + 1].t) };
  });
  return {
    scene, cam, post: { ...DARK, k: 0.3 },
    update(t) {
      const u = t - o.start;
      const cz = camZ(t);
      cam.position.set(0.25 * Math.sin(u * 1.1), 0.15 * Math.sin(u * 0.8), cz);
      cam.lookAt(0.1 * Math.sin(u * 0.7), 0, cz - 10); cam.rotateZ(0.07 * Math.sin(u * 0.9) + (o.roll || 0));
      debris.forEach((m, i) => { m.rotation.x = t * m.userData.r[0]; m.rotation.y = t * m.userData.r[1]; });
      for (const { it, g, until } of items) {
        const on = t >= it.t - 0.02 && t < until; g.visible = on; if (!on) continue;
        g.lookAt(cam.position.x * 0.3, g.position.y, cam.position.z + 4);
        if (it.type === 'card') {
          const k = ramp(t, it.t - 0.02, 0.26), p = g.userData.parts[0];
          p.scale.setScalar(Math.max(0.01, E.back(k)));
          p.rotation.y = (1 - E.out(k)) * (it.rot ?? 1.3) + 0.18 * Math.sin(u * 1.2 + 1);
          p.rotation.x = (1 - E.out(k)) * -0.4 + 0.06 * Math.sin(u);
          p.rotation.z = it.rz || 0;
        } else {
          const st = it.stagger ?? 0.06;
          g.userData.parts.forEach((p, i) => { const at = it.t + i * st; const k = ramp(t, at, 0.18); p.visible = t >= at; p.scale.setScalar(Math.max(0.01, E.back(k))); p.rotation.y = (1 - E.out(k)) * 1.5; });
          if (g.userData.small) { const k = ramp(t, it.t - 0.05, 0.2); g.userData.small.scale.setScalar(Math.max(0.01, E.back(k))); }
          if (g.userData.after) { const k = ramp(t, it.afterT, 0.2); g.userData.after.visible = t >= it.afterT; g.userData.after.scale.setScalar(Math.max(0.01, E.back(k))); }
          g.rotation.y += 0.12 * Math.sin(u * 1.1);
        }
      }
    },
  };
}

// ───────────────────────── WRITE: notebook paper, a pen writes the line ─────────────────────────
export function write(o) {
  const scene = lightScene(20, 60), cam = makeCam(50), R = rng(o.seed || 9);
  lights(scene);
  const CW = 1152, CH = 2048, PX = 128; // canvas px per world unit
  const paper = canvas(CW, CH), px = paper.getContext('2d');
  px.fillStyle = '#f4f5ff'; px.fillRect(0, 0, CW, CH);
  for (let i = 0; i <= CW / 48; i++) { px.strokeStyle = i % 4 ? 'rgba(130,140,255,0.35)' : 'rgba(110,120,255,0.6)'; px.lineWidth = i % 4 ? 1.5 : 2.5; px.beginPath(); px.moveTo(i * 48, 0); px.lineTo(i * 48, CH); px.stroke(); }
  for (let i = 0; i <= CH / 48; i++) { px.strokeStyle = i % 4 ? 'rgba(130,140,255,0.35)' : 'rgba(110,120,255,0.6)'; px.lineWidth = i % 4 ? 1.5 : 2.5; px.beginPath(); px.moveTo(0, i * 48); px.lineTo(CW, i * 48); px.stroke(); }
  px.strokeStyle = C.acc2; px.lineWidth = 4; px.beginPath(); px.moveTo(120, 0); px.lineTo(120, CH); px.stroke();
  const pm = new THREE.Mesh(new THREE.PlaneGeometry(CW / PX * 2.2, CH / PX * 1.6), new THREE.MeshBasicMaterial({ map: (() => { const t = tex(paper); t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(2.2, 1.6); return t; })(), toneMapped: false }));
  scene.add(pm);
  const ink = canvas(CW, CH), ix = ink.getContext('2d'), inkT = tex(ink);
  const im = new THREE.Mesh(new THREE.PlaneGeometry(CW / PX, CH / PX), new THREE.MeshBasicMaterial({ map: inkT, transparent: true, depthWrite: false, toneMapped: false })); im.position.z = 0.01; scene.add(im);
  im.userData.text = 'write'; im.userData.rects = [];   // [fx0, fy0, fx1, fy1, text] of the ink drawn so far (safe-zone scan)
  // props
  const pencil = new THREE.Group();
  const body = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.12, 2.6, 6), new THREE.MeshStandardMaterial({ color: C.acc, roughness: 0.4 })); body.position.y = 1.55; pencil.add(body);
  const wood = new THREE.Mesh(new THREE.ConeGeometry(0.12, 0.3, 6), new THREE.MeshStandardMaterial({ color: '#f1e4d0', roughness: 0.6 })); wood.rotation.x = Math.PI; wood.position.y = 0.11; pencil.add(wood);
  const tip = new THREE.Mesh(new THREE.ConeGeometry(0.045, 0.1, 6), new THREE.MeshStandardMaterial({ color: C.ink })); tip.rotation.x = Math.PI; tip.position.y = -0.01; pencil.add(tip);
  const cap = new THREE.Mesh(new THREE.CylinderGeometry(0.125, 0.125, 0.3, 12), new THREE.MeshStandardMaterial({ color: C.soft, roughness: 0.5 })); cap.position.y = 3.0; pencil.add(cap);
  scene.add(pencil);
  const props = [];
  const note = canvas(400, 400), nx = note.getContext('2d'); nx.fillStyle = C.soft; nx.fillRect(0, 0, 400, 400); nx.strokeStyle = C.acc; nx.lineWidth = 8; for (let i = 0; i < 4; i++) { nx.beginPath(); nx.moveTo(50, 110 + i * 60); nx.bezierCurveTo(150, 90 + i * 60, 250, 130 + i * 60, 330 - i * 40, 110 + i * 60); nx.stroke(); } nx.fillStyle = C.acc; nx.font = `700 80px ${FONT.script}`; nx.fillText('♡', 290, 360);
  const nm = new THREE.Mesh(new THREE.PlaneGeometry(1.7, 1.7), new THREE.MeshBasicMaterial({ map: tex(note), toneMapped: false })); nm.position.set(2.7, 4.3, 0.4); nm.rotation.z = -0.12; scene.add(nm); props.push(nm);
  const er = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.5, 0.6), new THREE.MeshStandardMaterial({ color: C.acc2, roughness: 0.5 })); er.position.set(2.4, -4.2, 0.6); er.rotation.set(0.5, 0.3, -0.4); scene.add(er);
  const tor = new THREE.Mesh(new THREE.TorusGeometry(0.42, 0.16, 16, 40), new THREE.MeshStandardMaterial({ color: C.acc, roughness: 0.35 })); tor.position.set(3.0, 1.2, 0.8); scene.add(tor);
  const sph = new THREE.Mesh(new THREE.SphereGeometry(0.32, 24, 16), new THREE.MeshStandardMaterial({ color: C.acc, roughness: 0.3 })); sph.position.set(3.0, 5.6, 0.9); scene.add(sph);
  const pen2 = pencil.clone(); pen2.rotation.z = 1.35; pen2.position.set(-1.2, -5.3, 0.3); pen2.scale.setScalar(0.8); scene.add(pen2);
  for (let i = 0; i < 12; i++) { const d = sprite(dotTex(), i % 3 ? C.acc : C.ink, 0.06 + R() * 0.08); d.position.set(lerp(-3.5, 3.5, R()), lerp(-6, 6, R()), 0.05); scene.add(d); }

  // lines → canvas coordinates (cx = 576 is the composition centre)
  ix.font = 'x';
  const Ls = o.lines.map(l => {
    const font = `${l.weight || 700} ${l.size || 170}px ${l.font || FONT.script}`;
    ix.font = font; const w = ix.measureText(l.text).width;
    const x0 = 576 + (l.dx || 0) - w / 2;
    return { ...l, font, w, x0 };
  });
  const toWorld = (cx, cy) => new THREE.Vector3((cx - CW / 2) / PX, -(cy - CH / 2) / PX, 0);
  function stroke(path, q, color, lw) {
    if (q <= 0) return; ix.save(); ix.strokeStyle = color; ix.lineWidth = lw; ix.lineCap = 'round'; ix.lineJoin = 'round';
    ix.setLineDash([3000 * q, 3000]); ix.beginPath(); path(ix); ix.stroke(); ix.restore();
  }
  return {
    scene, cam, post: { ...LIGHT, k: 0.3 },
    update(t) {
      const u = t - o.start;
      ix.clearRect(0, 0, CW, CH);
      let pen = null; const rects = im.userData.rects = [];
      for (const l of Ls) {
        const p = clamp((t - l.t0) / (l.t1 - l.t0));
        if (p <= 0) continue;
        ix.save(); ix.beginPath(); ix.rect(l.x0 - 40, l.y - l.size, (l.w + 60) * p + 20, l.size * 2); ix.clip();
        ix.font = l.font; ix.textBaseline = 'middle';
        if (l.shadow !== false) { ix.fillStyle = 'rgba(84,88,255,0.25)'; ix.fillText(l.text, l.x0 + 6, l.y + 8); }
        ix.fillStyle = l.color || C.ink; ix.fillText(l.text, l.x0, l.y); ix.restore();
        if (p < 1) pen = [l.x0 + l.w * p, l.y + l.size * 0.25];
        rects.push([(l.x0 - 10) / CW, (l.y - l.size * 0.62) / CH, (l.x0 + l.w * p + 10) / CW, (l.y + l.size * 0.62) / CH, l.text]);
        const dq = clamp((t - l.t1) / 0.3);
        const x0 = l.x0, x1 = l.x0 + l.w, yy = l.y;
        if (l.deco === 'underline') stroke(c => { c.moveTo(x0 - 10, yy + l.size * 0.5); c.bezierCurveTo(x0 + l.w * 0.3, yy + l.size * 0.62, x0 + l.w * 0.7, yy + l.size * 0.42, x1 + 30, yy + l.size * 0.5); c.moveTo(x0 + 20, yy + l.size * 0.62); c.bezierCurveTo(x0 + l.w * 0.4, yy + l.size * 0.7, x1 - 40, yy + l.size * 0.58, x1, yy + l.size * 0.66); }, dq, C.acc, 10);
        if (l.deco === 'circle') stroke(c => { c.ellipse((x0 + x1) / 2, yy + 6, l.w / 2 + 50, l.size * 0.62, -0.06, 0, Math.PI * 2.1); }, dq, C.acc, 9);
        if (l.deco === 'wave') stroke(c => { c.moveTo(x0, yy + l.size * 0.6); for (let i = 0; i <= 40; i++) c.lineTo(x0 + l.w * i / 40, yy + l.size * 0.6 + Math.sin(i * 1.3) * 14); }, dq, C.acc, 8);
        if (l.deco === 'strike') stroke(c => { c.moveTo(x0 - 20, yy + 6); c.lineTo(x1 + 20, yy - 8); c.moveTo(x0 - 10, yy + 22); c.lineTo(x1 + 10, yy + 4); }, dq, C.acc, 12);
        if (l.star) stroke(c => { const sx = x1 + 70, sy = yy - l.size * 0.55, r1 = 34, r2 = 14; for (let i = 0; i <= 10; i++) { const a = -Math.PI / 2 + i * Math.PI / 5, r = i % 2 ? r2 : r1; i ? c.lineTo(sx + Math.cos(a) * r, sy + Math.sin(a) * r) : c.moveTo(sx + Math.cos(a) * r, sy + Math.sin(a) * r); } }, clamp((t - l.t1 - 0.1) / 0.25), C.acc, 7);
        if (l.arrow) stroke(c => { c.moveTo(x1 + 30, yy - 40); c.quadraticCurveTo(x1 + 110, yy - 10, x1 + 70, yy + 80); c.moveTo(x1 + 50, yy + 60); c.lineTo(x1 + 70, yy + 82); c.lineTo(x1 + 98, yy + 62); }, clamp((t - l.t1 - 0.05) / 0.25), C.acc, 7);
      }
      if (o.fine) {
        const f = o.fine, k = ramp(t, f.t, 0.12);
        if (k > 0) {
          ix.save(); ix.globalAlpha = k; ix.font = `600 30px ${FONT.mono}`; ix.fillStyle = 'rgba(23,24,90,0.75)';
          f.lines.forEach((s, i) => { ix.fillText(s, 576 - 330, f.y + i * 40); rects.push([(576 - 330) / CW, (f.y + i * 40 - 22) / CH, (576 - 330 + ix.measureText(s).width) / CW, (f.y + i * 40 + 12) / CH, s]); }); ix.restore();
          const q = clamp((t - f.scribble) / 0.45);
          stroke(c => { let yy = f.y - 14; c.moveTo(576 - 340, yy); for (let i = 0; i < f.lines.length * 2; i++) { yy += 20; c.lineTo(i % 2 ? 576 - 330 : 576 + 340, yy); } }, q, C.acc, 14);
        }
      }
      inkT.needsUpdate = true;
      // pen follows the writing; rests to the side after
      const rest = [900, 1500];
      const pp = pen || rest; const w = toWorld(pp[0], pp[1]);
      pencil.position.set(w.x, w.y, 0.05 + (pen ? 0.04 * Math.abs(Math.sin(t * 40)) : 0.6));
      pencil.rotation.set(-0.55, 0.0, -0.5);
      // camera: close on the paper, slow roll + drift (fisheye does the rest)
      const fy = o.focusY != null ? -(o.focusY - CH / 2) / PX : 0;
      const cz = lerp(o.z0 || 11.5, o.z1 || 10.2, E.out(clamp(u / (o.end - o.start))));
      cam.position.set(0.6 * Math.sin(u * 0.6) + (o.cx || 0), fy - 1.2 + 0.3 * Math.sin(u * 0.5), cz);
      cam.lookAt((o.cx || 0) * 0.5, fy, 0); cam.rotateZ((o.roll ?? -0.06) + 0.04 * Math.sin(u * 0.8));
      tor.rotation.set(t * 0.8, t * 0.5, 0); sph.position.y = 5.6 + 0.15 * Math.sin(t * 2);
    },
  };
}

// ───────────────────────── PILLS: dark swarm of word pills flying at the camera ─────────────────────────
export function pills(o) {
  const scene = darkScene(6, 40), cam = makeCam(55), R = rng(o.seed || 21);
  const rm = room(true, { w: 40, h: 30, d: 90, rep: 8 }); rm.position.set(0, -15, -20); scene.add(rm);
  const styles = [
    t => pill(t, 0.5, { bg: C.acc, fg: '#ffffff', font: `900 110px ${FONT.heavy}`, shadow: C.inkDeep }),
    t => pill(t, 0.5, { bg: '#dcdfff', fg: C.acc, font: `900 110px ${FONT.heavy}`, shadow: '#9ea4ff' }),
    t => pill(t, 0.5, { bg: C.night2, fg: C.acc2, font: `900 100px ${FONT.heavy}`, border: C.acc2, bw: 8 }),
    t => pill(t, 0.55, { bg: C.soft, fg: C.ink, font: `italic 700 120px ${FONT.serif}`, radius: 40, shadow: C.acc }),
    t => pill(t, 0.45, { bg: '#dcdfff', fg: C.ink, font: `700 100px ${FONT.mono}`, radius: 30 }),
  ];
  const N = o.n || 64, dur = o.end - o.start + 1.6, swarm = [];
  for (let i = 0; i < N; i++) {
    const txt = o.texts[i % o.texts.length];
    const m = styles[Math.floor(R() * styles.length)](txt); m.userData.text = null;   // decoration: not in the safe-zone scan
    const s = 0.55 + R() * 0.7; m.scale.setScalar(s);
    const a = R() * TAU, rad = 0.9 + R() * 3.4;
    m.userData = { ...m.userData, t0: o.start - 1.6 + (i / N) * dur, x: Math.cos(a) * rad, y: Math.sin(a) * rad * 1.5, v: 9 + R() * 7, r: [(R() - 0.5) * 1.6, (R() - 0.5) * 1.6, (R() - 0.5) * 0.8] };
    scene.add(m); swarm.push(m);
  }
  const bubbles = [];
  for (let i = 0; i < 10; i++) { const b = ring(0.25 + R() * 0.3, 0.02, '#ffffff', { opacity: 0.6 }); b.userData = { x: (R() - 0.5) * 6, y: (R() - 0.5) * 9, t0: o.start - 1 + R() * dur, v: 6 + R() * 4 }; scene.add(b); bubbles.push(b); }
  const heroes = o.heroes.map((h, i) => {
    const m = h.typing ? typingBubble() : styles[h.style ?? (i % 2)](h.text);
    const g = new THREE.Group(); g.add(m); g.scale.setScalar(h.s || 1.25); scene.add(g);
    return { h, g, m, until: o.heroes[i + 1] ? o.heroes[i + 1].t : o.end + 1, side: i % 2 ? 1 : -1 };
  });
  function typingBubble() {
    const g = new THREE.Group();
    const c = canvas(600, 300), x = c.getContext('2d'); x.fillStyle = '#dcdfff'; rr(x, 10, 10, 580, 280, 120); x.fill();
    const p = new THREE.Mesh(new THREE.PlaneGeometry(2.0, 1.0), new THREE.MeshBasicMaterial({ map: tex(c), transparent: true, toneMapped: false, side: THREE.DoubleSide })); g.add(p);
    const dots = [];
    for (let i = 0; i < 3; i++) { const d = new THREE.Mesh(new THREE.SphereGeometry(0.17, 24, 16), new THREE.MeshBasicMaterial({ color: C.acc, toneMapped: false })); d.position.set((i - 1) * 0.5, 0, 0.12); g.add(d); dots.push(d); }
    g.userData.dots = dots; return g;
  }
  return {
    scene, cam, post: { ...DARK, bloom: 0.45 },
    update(t) {
      const u = t - o.start;
      cam.position.set(0.3 * Math.sin(u * 0.9), 0.2 * Math.sin(u * 0.7), 6);
      cam.lookAt(0.2 * Math.sin(u * 0.6), 0, -10); cam.rotateZ(0.12 * Math.sin(u * 0.5) - 0.05);
      for (const m of swarm) {
        const d = m.userData, tt = t - d.t0; const z = -30 + tt * d.v;
        m.visible = tt > 0 && z < 5.5;
        m.position.set(d.x * (1 + Math.max(0, z + 30) * 0.02), d.y * (1 + Math.max(0, z + 30) * 0.02), z);
        m.rotation.set(d.r[0] * tt * 0.4, d.r[1] * tt * 0.5, d.r[2] + d.r[2] * tt * 0.3);
      }
      for (const b of bubbles) { const d = b.userData, tt = t - d.t0, z = -25 + tt * d.v; b.visible = tt > 0 && z < 5; b.position.set(d.x, d.y, z); }
      for (const H of heroes) {
        const { h, g } = H; const on = t >= h.t - 0.25 && t < H.until + 0.2; g.visible = on; if (!on) continue;
        const kin = E.out(ramp(t, h.t - 0.25, 0.3)), kout = E.in(ramp(t, H.until - 0.05, 0.22));
        g.position.set(lerp(H.side * 1.5, h.x ?? 0, kin) + kout * H.side * 1.2, lerp(-0.5, h.y ?? 0, kin), lerp(-18, 1.2, kin) + kout * 5);
        g.rotation.set(0.15 * Math.sin(t * 2), (1 - kin) * 0.8 * H.side + 0.2 * Math.sin(t * 1.3), (h.rz ?? 0.06 * H.side));
        if (H.m.userData.dots) H.m.userData.dots.forEach((d, i) => { d.position.y = 0.12 * Math.max(0, Math.sin((t * 7) - i * 0.9)); });
      }
    },
  };
}

// ───────────────────────── DISC: light disc, a title whose letters slam in ─────────────────────────
export function disc(o) {
  const scene = lightScene(16, 55), cam = makeCam(48), R = rng(o.seed || 31);
  const rm = room(false); rm.position.y = -5.5; scene.add(rm);
  const DG = new THREE.Group(); scene.add(DG);
  const d = new THREE.Mesh(new THREE.CircleGeometry(3.3, 96), new THREE.MeshBasicMaterial({ color: '#d9dcff', toneMapped: false })); d.position.z = -0.7; DG.add(d);
  const dr = ring(3.45, 0.16, '#ffffff'); dr.position.z = -0.68; DG.add(dr);
  const dr2 = ring(3.95, 0.014, C.gridMaj); dr2.position.z = -0.7; DG.add(dr2);
  const dr3 = ring(4.4, 0.01, C.acc, { opacity: 0.6 }); dr3.position.z = -0.9; DG.add(dr3);
  // big word: one extruded mesh per letter
  const letters = []; let total = 0; const gap = o.track ?? 0.04;
  const font = o.bigFont || `400 260px ${FONT.cond}`;
  for (const ch of [...o.big]) { const m = extruded(ch, 1, { font, front: C.ink, side: C.acc, depth: 0.42, layers: 22 }); letters.push(m); total += m.userData.w * 0.78 + gap; }
  const BG = new THREE.Group(); DG.add(BG);
  const sc = (o.fit || 3.6) / total; let cx = -total / 2;
  letters.forEach(m => { const w = m.userData.w * 0.78; const h = new THREE.Group(); h.add(m); h.position.set((cx + w / 2) * sc, 0, 0); m.scale.setScalar(sc * (o.tall || 1.25)); m.scale.x = sc; cx += w + gap; BG.add(h); });
  BG.position.y = o.bigY ?? 0.15;
  const top = textPlane(o.top, o.topH || 0.5, { font: `700 150px ${FONT.script}`, fill: C.ink, shadow: { x: 5, y: 6, color: 'rgba(84,88,255,0.35)' } });
  top.position.set((o.topX ?? -1.9) + top.userData.w / 2, (o.bigY ?? 0.15) + (o.topY ?? 1.75), 0.1); DG.add(top);
  const tline = new THREE.Mesh(new THREE.PlaneGeometry(1, 0.03), new THREE.MeshBasicMaterial({ color: C.acc, toneMapped: false })); DG.add(tline);
  const tdot = new THREE.Mesh(new THREE.CircleGeometry(0.07, 20), new THREE.MeshBasicMaterial({ color: C.acc, toneMapped: false })); DG.add(tdot);
  const bottomWords = (o.bottom || []).map(b => { const m = extruded(b.text, 1, { font: b.font || `italic 700 200px ${FONT.serif}`, front: C.acc, side: C.ink, depth: 0.25, layers: 18 }); return { b, m }; });
  let bw = bottomWords.reduce((s, { m }) => s + m.userData.w, 0) + Math.max(0, bottomWords.length - 1) * 0.25;
  const bsc = Math.min(o.bottomH || 0.75, (o.bottomFit || 3.6) / Math.max(0.01, bw));
  let bx = -bw * bsc / 2;
  bottomWords.forEach(({ m }) => { const h = new THREE.Group(); h.add(m); m.scale.setScalar(bsc); h.position.set(bx + m.userData.w * bsc / 2, (o.bigY ?? 0.15) - (o.bottomY ?? 1.75), 0.15); bx += m.userData.w * bsc + 0.25 * bsc; DG.add(h); m.userData.h = h; });
  // HUD
  const hud = new THREE.Group(); DG.add(hud);
  const bracket = (x, y, sx, sy) => { const g = new THREE.Group(); const a = new THREE.Mesh(new THREE.PlaneGeometry(0.5, 0.035), new THREE.MeshBasicMaterial({ color: C.ink, toneMapped: false })); a.position.set(sx * 0.25, 0, 0); const b = new THREE.Mesh(new THREE.PlaneGeometry(0.035, 0.5), new THREE.MeshBasicMaterial({ color: C.ink, toneMapped: false })); b.position.set(0, sy * 0.25, 0); g.add(a, b); g.position.set(x, y, 0.2); hud.add(g); };
  const bx0 = 2.05, by0 = 2.75; bracket(-bx0, by0, 1, -1); bracket(bx0, by0, -1, -1); bracket(-bx0, -by0, 1, 1); bracket(bx0, -by0, -1, 1);
  const hudT = monoLabel(o.hud || '121.8 BPM  ·  LYRIC.MD', 0.24); hudT.position.set(0, o.hudY ?? -by0 + 0.3, 0.2); hud.add(hudT);   // the default sits at y ~1535 px, under the safe zone: hudY -2.1 lifts it in
  const dots = []; for (let i = 0; i < 18; i++) { const s = sprite(dotTex(), i % 3 ? C.acc : C.ink, 0.05 + R() * 0.07); s.position.set(lerp(-3.4, 3.4, R()), lerp(-3.5, 3.5, R()), lerp(-0.4, 1.2, R())); s.userData.ph = R() * 9; DG.add(s); dots.push(s); }
  const slamDirs = letters.map(() => [(R() - 0.5) * 2.4, (R() - 0.5) * 2.4, (R() - 0.5) * 1.2]);
  return {
    scene, cam, post: { ...LIGHT }, fx: { zblur: 0, shake: 0 },
    update(t) {
      const u = t - o.start; let slam = 0;
      const ts = o.bigT, st = o.stagger ?? Math.min(0.07, ((o.bigT1 ?? ts + 0.3) - ts) / Math.max(1, letters.length - 1));
      BG.children.forEach((h, i) => {
        const at = ts + i * st, k = ramp(t, at, 0.15); h.visible = t >= at;
        const e = E.out(k); const dd = slamDirs[i];
        h.position.z = (1 - e) * 6; h.rotation.set((1 - e) * dd[0], (1 - e) * dd[1], (1 - e) * dd[2]);
        h.position.y = 0;
        slam = Math.max(slam, (1 - ramp(t, at + 0.12, 0.18)) * (t >= at + 0.1 ? 1 : 0));
      });
      const kt = ramp(t, o.topT, 0.22); top.visible = t >= o.topT; top.material.opacity = kt; top.position.x = (o.topX ?? -1.9) + top.userData.w / 2 - (1 - E.out(kt)) * 0.4;
      const lx0 = (o.topX ?? -1.9) + top.userData.w + 0.15, lx1 = 1.95, lk = E.out(ramp(t, o.topT + 0.15, 0.35));
      tline.visible = tdot.visible = lk > 0; tline.scale.x = Math.max(0.001, (lx1 - lx0) * lk); tline.position.set(lx0 + (lx1 - lx0) * lk / 2, top.position.y - 0.02, 0.1); tdot.position.set(lx0 + (lx1 - lx0) * lk, top.position.y - 0.02, 0.1);
      bottomWords.forEach(({ b, m }) => { const k = ramp(t, b.t, 0.22); m.userData.h.visible = t >= b.t - 0.01; m.userData.h.scale.setScalar(Math.max(0.01, E.back(k))); m.userData.h.rotation.y = (1 - E.out(k)) * -0.9; });
      const hk = ramp(t, o.hudT ?? o.start + 0.1, 0.25); hud.visible = hk > 0; hud.scale.setScalar(lerp(1.15, 1, E.out(hk))); hudT.material.opacity = hk;
      dots.forEach(s => { s.position.y += 0; s.material.opacity = 0.5 + 0.5 * Math.sin(t * 2 + s.userData.ph); });
      const k = clamp(u / (o.end - o.start));
      const shake = slam * 0.06;
      cam.position.set(Math.sin(-0.35 + 0.45 * k) * 9.6 + shake * Math.sin(t * 90), 0.5 + 0.4 * Math.sin(u * 0.8) + shake * Math.cos(t * 77), Math.cos(-0.35 + 0.45 * k) * (9.6 - 0.9 * E.out(k)));
      cam.lookAt(0, 0.2, 0); cam.rotateZ(-0.04 + 0.05 * k);
      DG.rotation.y = 0.1 * Math.sin(u * 0.9);
      this.fx.zblur = slam * 0.08;
    },
  };
}

// ───────────────────────── FLASH: dark burst, a glowing pill, shards fly out ─────────────────────────
export function flash(o) {
  const scene = darkScene(4, 30), cam = makeCam(60), R = rng(o.seed || 41);
  const rm = room(true, { w: 30, h: 30, d: 30, rep: 7 }); rm.position.y = -15; scene.add(rm);
  const p = pill(o.text, o.h ?? 0.95, {   // 0.95 = the burst fills the width (≥ 7 letters leave x 60–960 for ~0.4 s); 0.7 keeps it inside
     bg: C.acc, fg: '#ffffff', font: `italic 700 150px ${FONT.serif}`, glow: C.acc2, padX: 80 }); p.renderOrder = 10; p.material.depthTest = false; scene.add(p);
  const shards = [];
  const geos = [new THREE.TetrahedronGeometry(0.12), new THREE.BoxGeometry(0.16, 0.16, 0.03), new THREE.PlaneGeometry(0.2, 0.08)];
  for (let i = 0; i < 150; i++) {
    const m = new THREE.Mesh(geos[i % 3], new THREE.MeshBasicMaterial({ color: [C.acc, '#ffffff', C.acc2, C.soft][i % 4], side: THREE.DoubleSide, toneMapped: false }));
    const th = R() * TAU, ph = Math.acos(2 * R() - 1), v = 3 + R() * 9;
    m.userData = { d: new THREE.Vector3(Math.sin(ph) * Math.cos(th), Math.sin(ph) * Math.sin(th) * 1.4, Math.cos(ph) * 0.6 + 0.3).multiplyScalar(v), r: [R() * 9, R() * 9], s: 0.6 + R() * 1.6 };
    m.scale.setScalar(m.userData.s); scene.add(m); shards.push(m);
  }
  const rays = new THREE.Group(); scene.add(rays);
  for (let i = 0; i < 26; i++) { const l = new THREE.Mesh(new THREE.PlaneGeometry(0.025, 1.2 + R() * 1.6), new THREE.MeshBasicMaterial({ color: '#ffffff', transparent: true, blending: THREE.AdditiveBlending, depthWrite: false })); const a = R() * TAU; l.position.set(Math.sin(a) * 1.6, Math.cos(a) * 1.6, 0); l.rotation.z = -a; rays.add(l); }
  return {
    scene, cam, post: { ...DARK, bloom: 0.45, thr: 0.88, k: 0.38 }, fx: { zblur: 0, flash: 0 },
    update(t) {
      const u = t - o.start, b = o.burst ?? o.start;
      cam.position.set(0, 0, lerp(6.2, 4.6, E.out(clamp(u / (o.end - o.start))))); cam.lookAt(0, 0, 0); cam.rotateZ(0.08 * Math.sin(u * 3));
      const kp = ramp(t, o.pillT ?? o.start, 0.2); p.scale.setScalar(Math.max(0.01, E.back(kp)) * (1 + 0.05 * Math.sin(t * 20))); p.visible = t >= (o.pillT ?? o.start);
      const tt = Math.max(0, t - b);
      shards.forEach(m => { const d = m.userData; const s = (1 - Math.exp(-tt * 3)) / 3; m.position.copy(d.d).multiplyScalar(s); m.rotation.set(d.r[0] * tt, d.r[1] * tt, 0); m.visible = t >= b; });
      rays.scale.setScalar(0.5 + tt * 3); rays.children.forEach(l => (l.material.opacity = Math.max(0, 1 - tt * 2.5))); rays.visible = t >= b;
      this.fx.zblur = 0.35 * Math.exp(-tt * 6) * (t >= b ? 1 : 0);
      this.fx.flash = t >= b ? Math.max(0, 1 - tt / 0.1) * 0.85 : 0;
    },
  };
}

// ───────────────────────── SHAPES: light floor, playful 3D shapes rain down and bounce ─────────────────────────
function stripeTex(a, b, n, vertical) { const c = canvas(256, 256), x = c.getContext('2d'); for (let i = 0; i < n; i++) { x.fillStyle = i % 2 ? a : b; vertical ? x.fillRect(i * 256 / n, 0, 256 / n + 1, 256) : x.fillRect(0, i * 256 / n, 256, 256 / n + 1); } return tex(c); }
function checkTex(a, b, n) { const c = canvas(256, 256), x = c.getContext('2d'); for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) { x.fillStyle = (i + j) % 2 ? a : b; x.fillRect(i * 256 / n, j * 256 / n, 256 / n + 1, 256 / n + 1); } return tex(c); }
function starGeo(r1 = 0.5, r2 = 0.22, d = 0.25) { const s = new THREE.Shape(); for (let i = 0; i <= 10; i++) { const a = Math.PI / 2 + i * Math.PI / 5, r = i % 2 ? r2 : r1; i ? s.lineTo(Math.cos(a) * r, Math.sin(a) * r) : s.moveTo(Math.cos(a) * r, Math.sin(a) * r); } const g = new THREE.ExtrudeGeometry(s, { depth: d, bevelEnabled: true, bevelSize: 0.04, bevelThickness: 0.04, bevelSegments: 2 }); g.center(); return g; }
function bounceY(tt, y0, rest, g = 30) {
  const h = y0 - rest; if (tt <= 0) return y0;
  const tf = Math.sqrt(2 * h / g); if (tt < tf) return y0 - 0.5 * g * tt * tt;
  let v = g * tf * 0.48, t1 = tt - tf;
  for (let b = 0; b < 4; b++) { const d = 2 * v / g; if (t1 < d) return rest + v * t1 - 0.5 * g * t1 * t1; t1 -= d; v *= 0.45; }
  return rest;
}
function landTimes(y0, rest, g = 30) { const tf = Math.sqrt(2 * (y0 - rest) / g); const v = g * tf * 0.48; return [tf, tf + 2 * v / g]; }
export function shapes(o) {
  const scene = lightScene(14, 50), cam = makeCam(50), R = rng(o.seed || 51);
  scene.add(room(false, { rep: 12 })); lights(scene);
  const mat = (opts) => new THREE.MeshStandardMaterial({ roughness: 0.38, metalness: 0, ...opts });
  const kinds = [
    () => [new THREE.SphereGeometry(0.55, 40, 24), mat({ color: C.acc }), 0.55],
    () => [new THREE.SphereGeometry(0.45, 40, 24), mat({ map: stripeTex('#ffffff', C.acc, 10, false) }), 0.45],
    () => [new THREE.SphereGeometry(0.42, 40, 24), mat({ map: checkTex('#ffffff', C.acc2, 8) }), 0.42],
    () => [new THREE.TorusGeometry(0.5, 0.22, 24, 64), mat({ map: stripeTex(C.ink, C.acc2, 28, true) }), 0.72],
    () => [new THREE.BoxGeometry(0.75, 0.75, 0.75), mat({ color: C.ink }), 0.38],
    () => [new THREE.BoxGeometry(0.6, 0.6, 0.6), mat({ color: '#ffffff' }), 0.3],
    () => [new THREE.ConeGeometry(0.4, 0.85, 32), mat({ map: stripeTex(C.acc, '#ffffff', 8, false) }), 0.42],
    () => [new THREE.OctahedronGeometry(0.5), mat({ color: C.inkDeep, flatShading: true }), 0.5],
    () => [starGeo(), mat({ color: C.acc }), 0.3],
    () => [new THREE.CylinderGeometry(0.32, 0.32, 0.55, 32), mat({ color: C.acc2 }), 0.28],
    () => [new THREE.SphereGeometry(0.3, 32, 16), mat({ color: C.ink }), 0.3],
    () => [new THREE.IcosahedronGeometry(0.42), mat({ color: C.soft, flatShading: true }), 0.42],
  ];
  const N = o.n || 46, list = [];
  const waves = o.waves || [[o.start - 0.15, o.start + 1.4, N]];
  let idx = 0;
  for (const [a, b, n] of waves) for (let i = 0; i < n; i++) {
    const [geo, m, rest] = kinds[idx++ % kinds.length]();
    const mesh = new THREE.Mesh(geo, m); const s = 0.75 + R() * 0.75; mesh.scale.setScalar(s); scene.add(mesh);
    const x = lerp(-4.2, 4.2, R()), z = Math.abs(x) < 2.4 && (o.words || []).length ? lerp(-7, -3.2, R()) : lerp(-5.5, 1.4, R()), y0 = 7 + R() * 5;
    const td = lerp(a, b, Math.pow(R(), 1.3));
    const rip = new THREE.Mesh(new THREE.RingGeometry(0.85, 1, 64), new THREE.MeshBasicMaterial({ color: C.acc, transparent: true, depthWrite: false, side: THREE.DoubleSide, toneMapped: false })); rip.rotation.x = -Math.PI / 2; rip.position.set(x, 0.015, z); scene.add(rip);
    const rip2 = rip.clone(); rip2.material = rip.material.clone(); scene.add(rip2);
    list.push({ mesh, x, z, y0, rest: rest * s, td, rot: [R() * 6, R() * 6, R() * 6], spin: [(R() - 0.5) * 6, (R() - 0.5) * 6], rip, rip2, lt: landTimes(y0, rest * s) });
  }
  for (let i = 0; i < 6; i++) { const f = ring(1.5 + i * 1.2, 0.03, C.acc, { opacity: 0.4 }); f.rotation.x = -Math.PI / 2; f.position.set(0, 0.02, -1); scene.add(f); }
  const W = (o.words || []).map(w => {
    const m = w.pill ? pill(w.text, w.h || 0.5, { bg: w.bg || C.acc, fg: w.fg || '#ffffff', font: w.font || `italic 700 130px ${FONT.serif}`, shadow: C.ink })
      : extruded(w.text, 1, { font: w.font || `900 220px ${FONT.heavy}`, front: w.front || C.acc, side: w.side || C.ink, depth: 0.35, layers: 22 });
    if (!w.pill) m.scale.setScalar((w.fit || 3.2) / m.userData.w);
    const hh = w.pill ? (w.h || 0.5) * 1.1 : (w.fit || 3.2) / m.userData.w * 0.5;
    m.traverse(q => { if (q.material) { q.material.depthTest = false; q.material.transparent = true; } q.renderOrder = 10; });
    const g = new THREE.Group(); g.add(m); scene.add(g);
    return { w, g, m, rest: (w.y ?? 1.4) + 0 * hh };
  });
  return {
    scene, cam, post: { ...LIGHT, k: 0.3 },
    update(t) {
      const u = t - o.start;
      const a = (o.a0 ?? 0.15) + (o.spinC ?? -0.06) * u;
      const r = lerp(o.r0 ?? 8.6, o.r1 ?? 9.8, E.out(clamp(u / (o.end - o.start))));
      cam.position.set(Math.sin(a) * r, o.camY ?? 3.0, Math.cos(a) * r);
      cam.lookAt(0, o.lookY ?? 2.3, 0); cam.rotateZ(0.04 * Math.sin(u * 0.9));
      for (const s of list) {
        const tt = t - s.td; s.mesh.visible = tt > -0.0;
        const y = bounceY(tt, s.y0, s.rest);
        s.mesh.position.set(s.x, y, s.z);
        const spinT = Math.min(Math.max(tt, 0), s.lt[0]) + Math.max(0, Math.min(tt - s.lt[0], 1.2)) * 0.35;
        s.mesh.rotation.set(s.rot[0] + s.spin[0] * spinT, s.rot[1] + s.spin[1] * spinT, s.rot[2]);
        [[s.rip, s.lt[0]], [s.rip2, s.lt[1]]].forEach(([r, lt], j) => { const k = (tt - lt) / 0.8; r.visible = k > 0 && k < 1; if (r.visible) { r.scale.setScalar((0.25 + E.out(k) * (j ? 1.1 : 1.8)) * 1); r.material.opacity = (1 - k) * (j ? 0.5 : 0.85); } });
      }
      for (const { w, g, rest } of W) {
        const until = w.until ?? Infinity; const on = t >= w.t - 0.5 && t < until + 0.2; g.visible = on; if (!on) continue;
        const y = w.drop === false ? rest : bounceY(t - (w.t - Math.sqrt(2 * 9 / 30)), rest + 9, rest);
        const ko = E.in(ramp(t, until, 0.2));
        g.position.set(w.x || 0, w.drop === false ? rest : y, (w.z ?? 2.6));
        g.scale.setScalar(Math.max(0.001, (w.drop === false ? E.back(ramp(t, w.t, 0.22)) : 1) * (1 - ko)));
        if (w.drop === false) g.visible = t >= w.t;
        g.lookAt(cam.position.x, g.position.y, cam.position.z); g.rotateZ(w.rz || 0);
      }
    },
  };
}
```

### `post.js`

```js
// Post: bloom, then fisheye + whip/zoom blur + chromatic aberration + vignette + grain + flash, then sRGB out.
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

const FinalShader = {
  uniforms: {
    tDiffuse: { value: null }, k: { value: 0.18 }, zoom: { value: 1 }, center: { value: new THREE.Vector2(510 / 1080, 1 - 865 / 1920) },
    blur: { value: new THREE.Vector2(0, 0) }, zblur: { value: 0 }, ca: { value: 0.002 }, vig: { value: 0.35 }, grain: { value: 0.04 },
    seed: { value: 0 }, flash: { value: 0 }, flashCol: { value: new THREE.Color(1, 1, 1) }, aspect: { value: 1080 / 1920 }, tint: { value: 0 }, tintCol: { value: new THREE.Color(1, 1, 1) },
  },
  vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.); }`,
  fragmentShader: `
    uniform sampler2D tDiffuse; uniform float k, zoom, zblur, ca, vig, grain, seed, flash, aspect, tint; uniform vec2 center, blur; uniform vec3 flashCol, tintCol;
    varying vec2 vUv;
    float h(vec2 p){ return fract(sin(dot(p, vec2(12.9898,78.233)) + seed) * 43758.5453); }
    vec2 warp(vec2 uv){
      vec2 d = uv - center; d.x /= aspect; float r2 = dot(d,d);
      float f = (1. + k * r2) / (1. + k * 0.42);
      d *= f / zoom; d.x *= aspect; return center + d;
    }
    vec3 samp(vec2 uv){
      vec2 d = uv - center;
      float r = texture2D(tDiffuse, uv + d * ca).r, g = texture2D(tDiffuse, uv).g, b = texture2D(tDiffuse, uv - d * ca).b;
      return vec3(r,g,b);
    }
    void main(){
      vec2 uv = warp(vUv);
      vec3 col = vec3(0.); float n = 0.;
      for (int i = 0; i < 14; i++) {
        float s = float(i) / 13. - 0.5;
        vec2 u = uv + blur * s;
        u = center + (u - center) * (1. - zblur * (s + 0.5));
        u = clamp(u, vec2(0.001), vec2(0.999));
        col += samp(u); n += 1.;
      }
      col /= n;
      vec2 v = (vUv - center); v.x /= aspect * 1.25;
      col *= 1. - vig * smoothstep(0.25, 0.95, length(v));
      col = mix(col, tintCol, tint);
      col += (h(vUv * 1000.) - 0.5) * grain;
      col = mix(col, flashCol, clamp(flash, 0., 1.));
      gl_FragColor = vec4(col, 1.);
    }`,
};

export function makePost(renderer) {
  const composer = new EffectComposer(renderer);
  composer.setPixelRatio(1); composer.setSize(1080, 1920);
  const rp = new RenderPass(new THREE.Scene(), new THREE.PerspectiveCamera());
  const bloom = new UnrealBloomPass(new THREE.Vector2(540, 960), 0.6, 0.55, 0.62);
  const fin = new ShaderPass(FinalShader);
  composer.addPass(rp); composer.addPass(bloom); composer.addPass(fin); composer.addPass(new OutputPass());
  return {
    composer, rp, bloom, U: fin.uniforms,
    render(scene, camera) { rp.scene = scene; rp.camera = camera; composer.render(); },
  };
}
```

### `setup.mjs`

```js
// Lyric · requirements check + the free fonts. Run once per project:  node setup.mjs
// Node 22+, ffmpeg/ffprobe, three.js, Chrome/Edge with WebGL on the GPU, Python + numpy (beats.py) and faster-whisper (words.py);
// downloads the five fonts (SIL Open Font License, google/fonts) into fonts/. No keys, no credits: the song is yours (Suno).
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process';
import { CHROME, launch } from './cdp.mjs';
const ROOT = import.meta.dirname, 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(fs.existsSync(path.join(ROOT, 'node_modules/three/build/three.module.js')), 'three.js (npm i three@0.170.0)');
ok(!!CHROME, 'Chrome or Edge: ' + (CHROME || 'not found, set CHROME=<path to the browser>'));
const PY = ['python', 'python3', 'py'].find(p => spawnSync(p, ['--version']).status === 0);
ok(!!PY, 'Python ' + (PY ? '(' + PY + ')' : 'not found'));
if (PY) {
  ok(spawnSync(PY, ['-c', 'import numpy']).status === 0, 'numpy (pip install numpy) for beats.py');
  ok(spawnSync(PY, ['-c', 'import faster_whisper']).status === 0, 'faster-whisper (pip install faster-whisper) for words.py');
}
const F = 'https://raw.githubusercontent.com/google/fonts/main/ofl/';
const FONTS = {   // free look-alikes of Arial Black, Impact, Georgia Italic, Segoe Script, Consolas
  'ArchivoBlack.ttf': F + 'archivoblack/ArchivoBlack-Regular.ttf',
  'Anton.ttf': F + 'anton/Anton-Regular.ttf',
  'Lora-Italic.ttf': F + 'lora/Lora-Italic%5Bwght%5D.ttf',
  'Kalam-Bold.ttf': F + 'kalam/Kalam-Bold.ttf',
  'JetBrainsMono.ttf': F + 'jetbrainsmono/JetBrainsMono%5Bwght%5D.ttf',
};
for (const [name, url] of Object.entries(FONTS)) {
  const f = path.join(ROOT, 'fonts', name); if (fs.existsSync(f)) { ok(true, 'fonts/' + name); continue; }
  fs.mkdirSync(path.dirname(f), { recursive: true });
  try { const r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0' } }); if (!r.ok) throw new Error(r.status); fs.writeFileSync(f, Buffer.from(await r.arrayBuffer())); ok(true, 'fonts/' + name + ' downloaded'); }
  catch (e) { ok(false, `fonts/${name}: download failed (${e.message}) from ${url}`); }
}
if (CHROME) try { const api = await launch({ port: 9698 }); ok(true, 'WebGL on the GPU: ' + api.gpu); await api.close(); } catch (e) { ok(false, e.message); }
setTimeout(() => process.exit(0), 1200);
```

### `cdp.mjs`

```js
// Headless Chrome/Edge over the DevTools protocol, no dependencies. ONE browser, on the GPU, at the lowest priority.
// Refuses software WebGL (SwiftShader): a CPU render of a lyric film pegs every core for half an hour.
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.LYRIC_ANGLE || (process.platform === 'win32' ? 'd3d11' : process.platform === 'darwin' ? 'metal' : 'gl');   // Linux: try LYRIC_ANGLE=vulkan if the GPU probe fails
const sleep = ms => new Promise(r => setTimeout(r, ms));

export async function launch({ width = 1920, height = 1080, 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(), 'lyric-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 (Chrome raises its GPU process by itself), 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,
    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 { } setTimeout(() => fs.rmSync(udd, { recursive: true, force: true }), 800); } };
  // GPU probe
  const gl = await api.eval(`(() => { const c = document.createElement('canvas'), g = c.getContext('webgl2') || c.getContext('webgl'); if (!g) return ''; const e = g.getExtension('WEBGL_debug_renderer_info'); return String(e ? g.getParameter(e.UNMASKED_RENDERER_WEBGL) : g.getParameter(g.RENDERER)); })()`);
  if (!gl || /swiftshader|software|basic render|llvmpipe/i.test(gl)) { await api.close(); throw new Error(`WebGL is not on the GPU (${gl || 'none'}). Not rendering on the CPU: check the graphics driver.`); }
  api.gpu = gl; return api;
}
```

### `render.mjs`

```js
// Lyric · renders a video's film.js through cdp.mjs (ONE GPU Chrome, lowest priority). One render at a time:
//   node render.mjs --video <dir> stills 0.5,3.2,9 [--safe]      → <dir>/stills/<t>.jpg + sheet.jpg
//   node render.mjs --video <dir> stills spread:8 | scenes [--safe] [--boxes]   (--boxes prints every text box)
//   node render.mjs --video <dir> scan [0.25]       every visible text every 0.25 s vs the safe zone (no screenshots)
//   node render.mjs --video <dir> full [fromFrame] [toFrame] [--force]   → <dir>/frames/f00000.jpg …
// <dir> (default .) holds film.js, audio/, stills/, frames/; it must sit inside the project folder.
// The page is served from the project folder (so film.js can import '/core.js'), three.js from ./node_modules.
import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { pathToFileURL } from 'node:url';
try { os.setPriority(os.constants.priority.PRIORITY_LOW); } catch { }
const ROOT = import.meta.dirname, NM = path.join(ROOT, 'node_modules');
import { launch } from './cdp.mjs';

const argv = process.argv.slice(2), has = k => argv.includes(k);
const vi = argv.indexOf('--video'), VDIR = path.resolve(vi >= 0 ? argv[vi + 1] : '.');
const [mode = 'stills', a1, a2] = argv.filter((x, i) => !x.startsWith('--') && !(vi >= 0 && i === vi + 1));
const REL = path.relative(ROOT, VDIR).split(path.sep).join('/') || '.';
if (REL.startsWith('..') || path.isAbsolute(REL)) { console.error(`✗ --video ${VDIR} must be inside ${ROOT}`); process.exit(1); }
if (!fs.existsSync(path.join(VDIR, 'film.js'))) { console.error(`✗ no film.js in ${VDIR}`); process.exit(1); }

const MIME = { '.ttf': 'font/ttf', '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.jpg': 'image/jpeg', '.png': 'image/png' };
const srv = http.createServer((req, res) => {
  const u = decodeURIComponent(req.url.split('?')[0]);
  if (u === '/favicon.ico') { res.writeHead(204); return res.end(); }
  const p = u.startsWith('/node_modules/') ? path.join(NM, u.slice(14)) : path.join(ROOT, u);
  if (!fs.existsSync(p) || !fs.statSync(p).isFile()) { res.writeHead(404); return res.end(); }
  res.writeHead(200, { 'Content-Type': MIME[path.extname(p)] || 'application/octet-stream', 'Cache-Control': 'no-store' }); fs.createReadStream(p).pipe(res);
});
await new Promise(r => srv.listen(0, '127.0.0.1', r));
const api = await launch({ width: 1080, height: 1920, port: 9671 });
const logs = () => api.console || api.logs || [];
const shot = async (f, q) => fs.writeFileSync(f, Buffer.from((await api.send('Page.captureScreenshot', { format: 'jpeg', quality: q })).data, 'base64'));
const fmt = v => v.map(b => `"${b.text}" x ${b.x0}–${b.x1} y ${b.y0}–${b.y1}`).join('; ');
let code = 0;
try {
  if (api.gl && api.gl !== 'gpu') throw new Error('no GPU WebGL (refusing a CPU render)');
  await api.goto(`http://127.0.0.1:${srv.address().port}/film.html?video=${encodeURIComponent(REL)}${has('--safe') ? '&safe=1' : ''}`);
  try { await api.waitFor('window.__ready === true', 120000); } catch (e) { console.log(logs().slice(-15).map(c => c.type + ': ' + c.text).join('\n')); throw e; }
  const FPS = 30, TOTAL = await api.eval('window.TOTAL'), META = await api.eval('window.META');
  if (mode === 'stills') {
    const spec = a1 || '1';
    const ats = spec.startsWith('spread:') ? Array.from({ length: +spec.slice(7) }, (_, i) => +((i + 0.5) / +spec.slice(7) * TOTAL).toFixed(2))
      : spec === 'scenes' ? META.scenes.map(s => +Math.min(s.end - 0.05, s.start + Math.max(0.35, (s.end - s.start) * 0.6)).toFixed(2))
      : spec.split(',').map(Number);
    const dir = path.join(VDIR, 'stills'); fs.mkdirSync(dir, { recursive: true });
    const files = []; let bad = 0;
    for (const t of ats) {
      const t0 = Date.now(); await api.eval(`setT(${t})`); const f = path.join(dir, `${t.toFixed(2)}.jpg`); await shot(f, 88); files.push([t, f]);
      const v = await api.eval('safeReport()'); bad += v.length;
      console.log(t.toFixed(2), (Date.now() - t0) + ' ms', v.length ? '✗ OUTSIDE THE SAFE ZONE: ' + fmt(v) : '');
      if (has('--boxes')) console.log('  every text: ' + fmt(await api.eval('textBoxes()')));
    }
    const cols = files.length <= 4 ? files.length : files.length <= 12 ? 4 : 6, cw = Math.floor(1080 / cols), ch = Math.round(cw * 16 / 9);
    fs.writeFileSync(path.join(dir, 'sheet.html'), `<body style="margin:0;background:#111;width:1080px"><div style="display:grid;grid-template-columns:repeat(${cols},${cw}px)">` + files.map(([t, f]) => `<div style="position:relative"><img src="${path.basename(f)}" style="display:block;width:${cw}px;height:${ch}px"><span style="position:absolute;left:4px;top:4px;font:700 14px Consolas,monospace;color:#fff;background:#000a;padding:2px 4px">${t.toFixed(2)}</span></div>`).join('') + '</div></body>');
    await api.goto(pathToFileURL(path.join(dir, 'sheet.html')).href);
    await api.waitFor('[...document.images].every(i => i.complete)', 15000);
    const height = Math.ceil(files.length / cols) * ch;
    const s = await api.send('Page.captureScreenshot', { format: 'jpeg', quality: 85, captureBeyondViewport: true, clip: { x: 0, y: 0, width: cols * cw, height, scale: 1 } });
    fs.writeFileSync(path.join(dir, 'sheet.jpg'), Buffer.from(s.data, 'base64')); fs.rmSync(path.join(dir, 'sheet.html'));
    console.log('sheet →', path.join(dir, 'sheet.jpg'), bad ? `· ✗ ${bad} text boxes outside the safe zone` : '· ✓ text inside the safe zone');
  } else if (mode === 'scan') {
    const step = +(a1 || 0.25), hits = [];
    for (let t = 0; t < TOTAL; t += step) { await api.eval(`setT(${t.toFixed(3)})`); const v = await api.eval('safeReport()'); if (v.length) hits.push([t, v]); }
    for (const [t, v] of hits) console.log(t.toFixed(2).padStart(6), fmt(v));
    console.log(hits.length ? `✗ text outside the safe zone at ${hits.length} of ${Math.ceil(TOTAL / step)} times` : `✓ every visible text inside the safe zone (${Math.ceil(TOTAL / step)} times, every ${step} s)`);
  } else if (mode === 'full') {
    const dir = path.join(VDIR, 'frames'); fs.mkdirSync(dir, { recursive: true });
    const n = Math.round(TOTAL * FPS), from = +(a1 || 0), to = Math.min(n, +(a2 || n)); const t0 = Date.now(); let done = 0; const out = [];
    for (let i = from; i < to; i++) {
      const f = path.join(dir, `f${String(i).padStart(5, '0')}.jpg`); if (fs.existsSync(f) && !has('--force')) continue;
      await api.eval(`setT(${(i / FPS).toFixed(5)})`); await shot(f, 93); done++;
      const v = await api.eval('safeReport()'); if (v.length) out.push([i, v]);
      if (done % 150 === 0) console.log(`${i + 1}/${to} · ${((Date.now() - t0) / done).toFixed(0)} ms/frame`);
    }
    console.log(`frames ${from}→${to}, ${done} rendered in ${((Date.now() - t0) / 1000).toFixed(0)} s`);
    if (out.length) { console.log(`✗ text outside the safe zone in ${out.length} frames, e.g.:`); out.filter((_, j) => j % Math.ceil(out.length / 8) === 0).forEach(([i, v]) => console.log(`  f${i} (${(i / FPS).toFixed(2)} s) ${fmt(v)}`)); }
    else if (done) console.log('✓ text inside the safe zone in every rendered frame');
  } else throw new Error('mode: stills | scan | full');
  const errs = logs().filter(c => /error|exception/i.test(c.type)); if (errs.length) { console.log('PAGE ERRORS:\n' + errs.slice(-6).map(c => '  ' + c.text).join('\n')); code = 1; }
} catch (e) { console.error(e.message); code = 1; } finally { await api.close(); srv.close(); }
process.exit(code);
```

### `encode.mjs`

```js
// Lyric · the song → −14 LUFS (loudnorm aims at −2 dBTP: AAC adds up to ~1 dB, the delivered file must stay ≤ −1), frames + mix → video.mp4 (RULES encode: H.264 High, yuv420p, 1080×1920, 30 fps,
// crf 20, maxrate 10M, AAC 192k, +faststart, ≤ 60 MB) + cover.jpg. Reads TOTAL from <dir>/film.js.
//   node encode.mjs [--video <dir>] [--cover 3.9] [--to 6] [--out video.mp4]
// --to s encodes only the first s seconds (a test clip; the cover is skipped unless it falls inside).
import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
try { os.setPriority(os.constants.priority.PRIORITY_LOW); } catch { }
const FF = 'ffmpeg';   // on PATH
const argv = process.argv.slice(2), opt = (k, d) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : d; };
const DIR = path.resolve(opt('--video', '.')), at = (...p) => path.join(DIR, ...p);
const run = a => { const r = spawnSync(FF, ['-hide_banner', '-y', ...a], { encoding: 'utf8', maxBuffer: 64 << 20 }); if (r.status) { console.error(r.stderr.slice(-2000)); process.exit(1); } return r.stderr; };
const film = fs.readFileSync(at('film.js'), 'utf8'), m0 = film.match(/export\s+const\s+TOTAL\s*=\s*([\d.]+)/);
if (!m0) { console.error('✗ no "export const TOTAL = <seconds>;" in film.js'); process.exit(1); }
const FULL = +m0[1], TOTAL = Math.min(FULL, +opt('--to', FULL)), N = Math.round(TOTAL * 30);
const song = at('audio', 'song.mp3'); if (!fs.existsSync(song)) { console.error('✗ no audio/song.mp3 in ' + DIR); process.exit(1); }
const missing = []; for (let i = 0; i < N; i++) if (!fs.existsSync(at('frames', `f${String(i).padStart(5, '0')}.jpg`))) missing.push(i);
if (missing.length) { console.error(`✗ ${missing.length} of ${N} frames missing (first f${missing[0]}): run render.mjs full first`); process.exit(1); }
// two-pass loudnorm of the song, cut to the film, faded out
const mix = at('mix.wav'), out = at(opt('--out', 'video.mp4'));
const m = run(['-i', song, '-t', String(TOTAL), '-af', 'loudnorm=I=-14:TP=-2:LRA=11:print_format=json', '-f', 'null', '-']);
const j = JSON.parse(m.slice(m.lastIndexOf('{'), m.lastIndexOf('}') + 1));
run(['-i', song, '-t', String(TOTAL), '-af', `loudnorm=I=-14:TP=-2:LRA=11:measured_I=${j.input_i}:measured_TP=${j.input_tp}:measured_LRA=${j.input_lra}:measured_thresh=${j.input_thresh}:offset=${j.target_offset}:linear=true,afade=t=out:st=${(TOTAL - 0.6).toFixed(3)}:d=0.6`, '-ar', '48000', mix]);
const v = rate => run(['-framerate', '30', '-i', at('frames', 'f%05d.jpg'), '-i', mix, '-map', '0:v', '-map', '1:a', '-frames:v', String(N),
  '-vf', 'scale=1080:1920:out_range=tv:out_color_matrix=bt709,format=yuv420p', '-color_range', 'tv', '-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709',
  '-c:v', 'libx264', '-profile:v', 'high', '-preset', 'slow', '-crf', '20', '-maxrate', rate, '-bufsize', '20M', '-r', '30',
  '-c:a', 'aac', '-b:a', '192k', '-shortest', '-movflags', '+faststart', out]);
v('10M');
let mb = fs.statSync(out).size / 1e6;
if (mb > 60) { v('7M'); mb = fs.statSync(out).size / 1e6; }
const ct = +opt('--cover', 3.9);
if (ct < TOTAL) fs.copyFileSync(at('frames', `f${String(Math.round(ct * 30)).padStart(5, '0')}.jpg`), at('cover.jpg'));
const e = run(['-i', out, '-vn', '-af', 'ebur128=peak=true', '-f', 'null', '-']);
const I = +[...e.matchAll(/I:\s+(-?[\d.]+) LUFS/g)].pop()?.[1], TP = +[...e.matchAll(/Peak:\s+(-?[\d.]+) dBFS/g)].pop()?.[1];
console.log(`${path.basename(out)} ${mb.toFixed(1)} MB · ${TOTAL.toFixed(2)} s, ${N} frames · ${I} LUFS · true peak ${TP} dBTP${ct < TOTAL ? ` · cover.jpg at ${ct} s` : ''}`);
if (!(Math.abs(I + 14) <= 1 && TP <= -1)) {
  if (TOTAL < FULL) console.log('(a test clip: a few seconds with the fade can sit off −14 LUFS; the full film is the one that counts)');
  else { console.log('✗ loudness off target (−14 ±1 LUFS, true peak ≤ −1 dBTP)'); process.exitCode = 1; }
}
```

### `words.py`

```python
# Lyric · word timestamps of the song (faster-whisper, word_timestamps) -> <dir>/audio/words.json, one {w, t0, t1, p} per sung word.
#   python words.py [--video <dir>]        reads <dir>/audio/song.mp3 and <dir>/lyrics.txt (the lyrics = Whisper's initial prompt)
# Sung words are misheard: check every word that matters by ear (p < 0.6 is marked with ?). Low priority, NVIDIA GPU if there is one, else the CPU.
import ctypes, json, os, site, subprocess, sys
sys.stdout.reconfigure(encoding="utf-8")
try: ctypes.windll.kernel32.SetPriorityClass(ctypes.windll.kernel32.GetCurrentProcess(), 0x40)
except Exception: pass
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 numpy as np
from faster_whisper import WhisperModel
FF = "ffmpeg"
d = sys.argv[sys.argv.index("--video") + 1] if "--video" in sys.argv else "."
song, lyr, out = os.path.join(d, "audio", "song.mp3"), os.path.join(d, "lyrics.txt"), os.path.join(d, "audio", "words.json")
# decode with ffmpeg to 16 kHz mono and pass the samples (faster-whisper's own decoder breaks with av 19)
pcm = subprocess.run([FF, "-v", "error", "-i", song, "-f", "s16le", "-ac", "1", "-ar", "16000", "-"], capture_output=True, check=True).stdout
pcm = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
prompt = " ".join(open(lyr, encoding="utf8").read().split()) if os.path.exists(lyr) else None
if not prompt: print("! no lyrics.txt: transcribing without a prompt (more mishearings)")
MODEL = os.environ.get("WHISPER_MODEL", "large-v3-turbo")   # downloaded once (~1.6 GB); "small" is lighter
try: m = WhisperModel(MODEL, device="cuda", compute_type="int8_float32")
except Exception as e: print("! CUDA failed (%s), using the CPU" % e); m = WhisperModel(MODEL, device="cpu", compute_type="int8")
segs, info = m.transcribe(pcm, beam_size=5, word_timestamps=True, condition_on_previous_text=False, initial_prompt=prompt, language="en")
words = [{"w": w.word.strip(), "t0": round(w.start, 2), "t1": round(w.end, 2), "p": round(w.probability, 2)} for s in segs for w in s.words]
json.dump(words, open(out, "w", encoding="utf8"), indent=0)
for o in words: print(f'{o["t0"]:6.2f}-{o["t1"]:6.2f} {o["w"]}{"  ?" if o["p"] < 0.6 else ""}')
print(len(words), "words ->", out)
```

### `beats.py`

```python
# Lyric · tempo + beat grid of the song -> beats.json ({bpm, beats: [t…]}) and the BEAT line for film.js.
#   python beats.py <audio> [<out.json>]       e.g. python beats.py my-video/audio/song.mp3   (default out: beats.json next to the audio)
# Spectral flux autocorrelation for the BPM (80-170), the kick band (< 150 Hz) for the phase. Prints a drift check per 10 s:
# phase offsets near 0 (or 1) everywhere = one fixed grid fits the whole song.
import json, os, subprocess, sys
sys.stdout.reconfigure(encoding="utf-8")
import numpy as np
FF = "ffmpeg"
if len(sys.argv) < 2: sys.exit("usage: python beats.py <audio> [<out.json>]")
src = sys.argv[1]; out = sys.argv[2] if len(sys.argv) > 2 else os.path.join(os.path.dirname(os.path.abspath(src)), "beats.json")
sr = 22050
x = np.frombuffer(subprocess.run([FF, "-v", "error", "-i", src, "-f", "s16le", "-ac", "1", "-ar", str(sr), "-"], capture_output=True, check=True).stdout, np.int16).astype(np.float32) / 32768
hop, n = 256, 1024
frames = np.lib.stride_tricks.sliding_window_view(x, n)[::hop] * np.hanning(n)
S = np.log1p(100 * np.abs(np.fft.rfft(frames, axis=1)))
freqs = np.fft.rfftfreq(n, 1 / sr)
flux = np.maximum(0, np.diff(S, axis=0)).sum(1)
kick = np.maximum(0, np.diff(S[:, freqs < 150], axis=0)).sum(1)
fps = sr / hop
def tempo(o):
    o = o - o.mean(); ac = np.correlate(o, o, 'full')[len(o) - 1:]
    best = None
    for bpm in np.arange(80, 170, 0.05):
        lag = 60 / bpm * fps; v = sum(np.interp(lag * k, np.arange(len(ac)), ac) for k in (1, 2, 4))
        if best is None or v > best[1]: best = (bpm, v)
    return best[0]
def phase(seg):
    best = None
    for ph in np.arange(0, per, 0.25):
        idx = (ph + per * np.arange(int((len(seg) - ph) / per))).astype(int); v = seg[idx].sum()
        if best is None or v > best[1]: best = (ph, v)
    return best[0]
bpm = round(float(tempo(flux)), 2); per = 60 / bpm * fps
t0 = phase(kick) / fps + (n / 2) / sr
dur = len(x) / sr
beats = [round(t0 + i * 60 / bpm, 3) for i in range(int((dur - t0) / (60 / bpm)) + 1)]
json.dump({'bpm': bpm, 'beats': beats}, open(out, 'w'))
print(f'bpm {bpm} · first beat {t0:.3f} s · {len(beats)} beats · {dur:.2f} s')
for a in range(0, int(dur) - 4, 10):
    tb = a + phase(kick[int(a * fps):int((a + 10) * fps)]) / fps + (n / 2) / sr
    print(f'  {a:3d}-{a + 10:3d} s  beat phase offset {((tb - t0) / (60 / bpm)) % 1:.3f}')
print(f'-> {out}\nfilm.js:  export const BEAT = {{ bpm: {bpm}, t0: {t0:.3f} }};')
```


## Appendix B: an example video (complete)

"Never pay for a content skill" (57.56 s, 24 scenes, every kind), the reel in `reference.jpg`. The names in it (the singer, his app, the format list) are its creator's own: use yours. Write your own `lyrics.txt`, make the song, then rewrite every scene on your song's word times.

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

```js
// "Never pay" — lyric.md. Times are the song's words (Whisper, audio/words.json); beat grid 121.8 BPM from 0.467 s.
import { FONT, C } from '/core.js';   // '/core.js' = the engine at the page root, from any video folder

export const TOTAL = 57.56;
export const BEAT = { bpm: 121.8, t0: 0.467 };
const W = (...pairs) => pairs.map(([w, t]) => ({ w, t }));

// every scene: kind, start, end, transition in ('whip' | 'cut' | 'flash' | 'zoom'), opts
export const SCENES = [
  // 0 · "Never pay, never pay. Never pay for a skill again."
  { kind: 'words', start: 0.0, end: 4.3, in: 'cut', o: { a0: -0.4, spin: 0.08, groups: [
    { at: 0.0, words: [{ t: -0.2, text: 'NEVER', outline: true, fillDelay: 0.52, y: 0.55, fit: 3.2 }, { t: 0.72, text: 'PAY', style: 'acc', y: -0.75, fit: 2.4, rot: 0.9 }],
      labels: [{ t: 0.85, text: '$0.00', x: 1.25, y: -1.55 }, { t: 0.3, text: '+FREE', x: -1.3, y: 1.45, h: 0.17 }] },
    { at: 1.3, words: [{ t: 1.34, text: 'NEVER', style: 'acc', y: 0.6, fit: 3.0 }, { t: 1.72, text: 'PAY', style: 'ink', y: -0.7, fit: 2.6, rz: -0.04 }],
      labels: [{ t: 1.8, text: '-$30.00', x: 1.2, y: -1.6 }] },
    { at: 2.26, swing: -0.5, words: [{ t: 2.28, text: 'NEVER PAY', style: 'acc', y: 1.25, fit: 2.6 }, { t: 3.08, text: 'FOR A', style: 'ink', y: 0.42, fit: 1.25, x: -0.85 }, { t: 3.48, text: 'SKILL', style: 'ink', y: -0.45, fit: 3.1 }, { t: 3.62, text: 'AGAIN', style: 'acc', y: -1.45, fit: 2.7, rot: 0.9 }],
      labels: [{ t: 3.7, text: 'SKILL.MD', x: 1.15, y: 0.42, h: 0.2 }] },
  ] } },
  // 1 · "I'm Casper, I built Morphy." — the I is a ruler tower
  { kind: 'pillar', start: 4.3, end: 6.04, in: 'whip', o: { labels: [{ t: 4.3, text: "I'M CASPER" }, { t: 5.02, text: 'BUILT MORPHY' }] } },
  // 2 · "Gave my format away for free, every line, every skill dot MD."
  { kind: 'rings', start: 6.04, end: 10.0, in: 'whip', o: { phi0: 0.15, tags: ['RING 01 · FREE', '121.8 BPM', 'NO PAYWALL', 'SKILL.MD'], names: ['KINETIC.MD', 'VOXEL.MD', 'PAPER.MD', 'PIXEL.MD', 'GLOW.MD', 'HALFTONE.MD'], lines: [
    { style: 'bold', words: W(['GAVE', 6.08], ['MY', 6.34], ['FORMAT', 6.58]) },
    { style: 'serif', words: W(['away', 6.98], ['for', 7.46], ['free,', 7.72]) },
    { style: 'black', words: W(['EVERY', 8.14], ['LINE,', 8.3]) },
    { style: 'italic', words: W(['every', 8.62], ['skill.md', 8.76]) },
  ] } },
  // 3 · "Then they took it, put a price on it."
  { kind: 'tunnel', start: 10.0, end: 11.66, in: 'whip', o: { items: [
    { t: 10.06, type: 'card', text: 'Then they', style: 'dark', h: 0.7, y: 0.2 },
    { t: 10.3, type: 'card', text: 'TOOK IT', style: 'white', h: 0.72, y: 0.1, rot: -1.3 },
    { t: 10.62, type: 'ransom', text: 'PRICE', small: 'put a', after: 'on it', afterT: 11.2, h: 0.6, stagger: 0.07 },
  ] } },
  // 4 · "Thirty dollars for a gift."
  { kind: 'write', start: 11.66, end: 13.62, in: 'whip', o: { focusY: 1010, z0: 14.5, z1: 13, lines: [
    { text: 'Thirty', t0: 11.72, t1: 12.04, y: 760, size: 160, dx: -60 },
    { text: 'dollars', t0: 12.04, t1: 12.4, y: 950, size: 165, dx: 30, deco: 'underline' },
    { text: 'for a', t0: 12.4, t1: 13.1, y: 1120, size: 120, dx: -120 },
    { text: 'gift.', t0: 13.12, t1: 13.4, y: 1290, size: 170, dx: 40, deco: 'circle', star: true },
  ] } },
  // 5 · "Thirty dollars for a text file."
  { kind: 'pills', start: 13.62, end: 15.76, in: 'whip', o: { texts: ['$30', 'thirty dollars', 'text file', '$30', '.md', 'for a', 'text file', 'SKILL.MD', '$30.00', 'resold'], heroes: [
    { t: 13.72, text: 'thirty dollars', style: 0, s: 0.9, y: 0.3 },
    { t: 14.56, text: 'for a', style: 3, s: 1.0, y: 0.2 },
    { t: 15.12, text: 'text file', style: 1, s: 1.05, y: 0.1 },
    { t: 15.45, typing: true, s: 1.1, y: -0.2 },
  ] } },
  // 6 · "That was free right from the start."
  { kind: 'disc', start: 15.76, end: 17.95, in: 'zoom', o: { top: 'that was', topT: 15.82, big: 'FREE', bigT: 16.08, bigT1: 16.36, fit: 3.3, bottom: [{ text: 'right', t: 16.38 }, { text: 'from', t: 16.62 }, { text: 'the', t: 16.88 }, { text: 'start.', t: 17.08 }], bottomFit: 3.6, hud: '121.8 BPM  ·  $0.00', hudT: 15.85 } },
  // 7 · "So…" — burst
  { kind: 'flash', start: 17.95, end: 18.5, in: 'flash', o: { text: 'so…', pillT: 18.0, burst: 17.95 } },
  // 8 · "here's my answer. Here's my move."
  { kind: 'shapes', start: 18.5, end: 21.8, in: 'cut', o: { waves: [[18.3, 19.4, 30], [20.25, 21.0, 16]], words: [
    { t: 18.6, text: "here's my", pill: true, x: -0.6, y: 2.75, drop: false, until: 20.25, bg: '#ffffff', fg: C.acc },
    { t: 19.1, text: 'ANSWER', fit: 3.0, y: 1.55, until: 20.25 },
    { t: 20.3, text: "here's my", pill: true, x: 0.6, y: 2.7, drop: false, bg: C.acc, fg: '#ffffff' },
    { t: 21.02, text: 'MOVE', fit: 2.6, y: 1.6, front: C.ink, side: C.acc },
  ] } },
  // 9 · "A whole library, open, made for you." (dives into the drop)
  { kind: 'rings', start: 21.8, end: 25.4, in: 'whip', o: { phi0: -0.25, dive: 24.95, tags: ['LIBRARY', 'OPEN', 'MADE FOR YOU', '0 PAYWALLS'], names: ['KINETIC.MD', 'VOXEL.MD', 'LIGHT.MD', 'SANDBOX.MD', 'PAPER.MD', 'PIXEL.MD', 'GLOW.MD', 'CUTSCENE.MD', 'HALFTONE.MD'], lines: [
    { style: 'bold', words: W(['A', 21.86], ['WHOLE', 22.12]) },
    { style: 'black', words: W(['LIBRARY', 22.48]) },
    { style: 'serif', words: W(['open,', 23.42]) },
    { style: 'italic', words: W(['made', 23.94], ['for', 24.44], ['you.', 24.86]) },
  ] } },
  // 10 · CHORUS "No paywalls,"
  { kind: 'tunnel', start: 25.4, end: 27.05, in: 'flash', o: { speed: 2.4, items: [
    { t: 25.4, type: 'card', text: 'NO', style: 'white', h: 1.2, y: 0.1 },
    { t: 26.4, type: 'ransom', rows: ['PAY', 'WALLS'], h: 0.62, stagger: 0.055, small: 'no' },
  ] } },
  // 11 · "no subscriptions,"
  { kind: 'disc', start: 27.05, end: 27.98, in: 'whip', o: { top: 'no', topT: 27.08, big: 'SUBSCRIPTIONS', bigT: 27.18, bigT1: 27.6, fit: 3.7, tall: 1.9, hud: 'MONTHLY  ·  $0', hudT: 27.1, bottom: [] } },
  // 12 · "no strings,"
  { kind: 'tunnel', start: 27.98, end: 29.04, in: 'whip', o: { speed: 2.4, roll: 0.05, items: [
    { t: 28.0, type: 'card', text: 'no', style: 'dark', h: 1.0, y: 1.05, keep: true },
    { t: 28.4, type: 'ransom', rows: ['STR', 'INGS'], h: 0.6, stagger: 0.04, y: -0.35 },
  ] } },
  // 13 · "no fine print."
  { kind: 'write', start: 29.04, end: 30.06, in: 'whip', o: { focusY: 1000, z0: 13.5, z1: 12.4, roll: 0.05, lines: [
    { text: 'no fine print', t0: 29.08, t1: 29.76, y: 840, size: 100, deco: 'wave' },
  ], fine: { t: 29.06, y: 1010, scribble: 29.5, lines: ['* $30 per format. Billed monthly.', '* Resale rights not included.', '* Price may change without notice.', '* No refunds. Terms apply.', '* Subscription renews automatically.'] } } },
  // 14 · "Every format, every MD, free inside Morphy."
  { kind: 'rings', start: 30.06, end: 33.9, in: 'flash', o: { phi0: 0.3, tags: ['EVERY FORMAT', 'FREE', 'INSIDE MORPHY', '121.8 BPM'], names: ['KINETIC.MD', 'VOXEL.MD', 'LIGHT.MD', 'SANDBOX.MD', 'PAPER.MD', 'PIXEL.MD', 'GLOW.MD', 'CUTSCENE.MD', 'HALFTONE.MD', 'LYRIC.MD'], lines: [
    { style: 'bold', words: W(['EVERY', 30.1], ['FORMAT,', 30.42]) },
    { style: 'italic', words: W(['every', 31.1], ['.md', 31.26]) },
    { style: 'serif', words: W(['free', 31.68]) },
    { style: 'black', words: W(['INSIDE', 32.22], ['MORPHY', 32.92]) },
  ] } },
  // 15 · "This one's called lyric dot MD."
  { kind: 'disc', start: 33.9, end: 36.4, in: 'whip', o: { top: "this one's called", topT: 33.92, topH: 0.42, big: 'LYRIC', bigT: 34.9, bigT1: 35.2, fit: 3.4, bottom: [{ text: '.md', t: 35.34 }], bottomH: 1.05, hud: '121.8 BPM  ·  LYRIC.MD', hudT: 34.0 } },
  // 16 · "Comment…" — burst
  { kind: 'flash', start: 36.4, end: 37.0, in: 'flash', o: { text: 'comment', pillT: 36.42, burst: 36.4 } },
  // 17 · "Comment LYRIC, I'll send it to you. Free, free, completely free."
  { kind: 'shapes', start: 37.0, end: 41.62, in: 'cut', o: { waves: [[36.8, 38.0, 30], [39.6, 40.9, 18]], r1: 10.2, words: [
    { t: 37.1, text: 'comment', pill: true, x: -0.6, y: 2.75, drop: false, bg: '#ffffff', fg: C.acc, until: 39.65 },
    { t: 37.66, text: 'LYRIC', fit: 2.9, y: 1.6, until: 39.65 },
    { t: 38.52, text: "I'll send it to you", pill: true, y: 0.75, drop: false, h: 0.36, bg: C.ink, fg: '#ffffff', until: 39.65 },
    { t: 39.7, text: 'FREE', fit: 2.5, y: 1.7, until: 40.3, front: C.acc },
    { t: 40.34, text: 'FREE', fit: 2.5, y: 1.7, until: 40.7, front: C.ink, side: C.acc },
    { t: 40.72, text: 'completely', pill: true, y: 0.75, drop: false, bg: '#ffffff', fg: C.acc },
    { t: 41.14, text: 'FREE', fit: 2.7, y: 1.65, front: C.acc },
  ] } },
  // 18 · instrumental — the tower again, the keyword around it
  { kind: 'rings', start: 41.62, end: 44.45, in: 'whip', o: { phi0: -0.1, gap: 1.9, tags: ['COMMENT LYRIC', 'FREE', 'LYRIC.MD', '121.8 BPM'], names: ['LYRIC.MD', 'FREE', 'COMMENT LYRIC'], lines: [
    { style: 'black', words: W(['COMMENT', 41.7], ['LYRIC', 42.1]) },
    { style: 'serif', words: W(['completely', 42.9], ['free', 43.3]) },
  ] } },
  // 19 · "Comment LYRIC"
  { kind: 'tunnel', start: 44.45, end: 47.4, in: 'flash', o: { speed: 1.4, items: [
    { t: 44.5, type: 'card', text: 'Comment', style: 'dark', h: 0.7, y: 1.15, keep: true },
    { t: 45.34, type: 'ransom', text: 'LYRIC', h: 0.55, stagger: 0.06, y: -0.25 },
  ] } },
  // 20 · instrumental — the opening room, callback
  { kind: 'words', start: 47.4, end: 52.26, in: 'whip', o: { a0: 0.3, spin: -0.07, groups: [
    { at: 47.4, words: [{ t: 47.45, text: 'LYRIC', outline: true, fillDelay: 0.48, y: 0.5, fit: 3.1 }, { t: 48.4, text: '.MD', style: 'acc', y: -0.8, fit: 2.0, rot: 0.9 }], labels: [{ t: 48.5, text: 'FREE', x: 1.2, y: -1.6 }] },
    { at: 49.4, words: [{ t: 49.42, text: 'NEVER', style: 'acc', y: 0.6, fit: 3.0 }, { t: 49.9, text: 'PAY', style: 'ink', y: -0.7, fit: 2.6 }], labels: [{ t: 50.0, text: '$0.00', x: 1.25, y: -1.6 }] },
    { at: 50.9, words: [{ t: 50.92, text: 'COMMENT', style: 'ink', y: 0.55, fit: 3.2 }, { t: 51.4, text: 'LYRIC', style: 'acc', y: -0.65, fit: 2.9, rot: 0.9 }], labels: [{ t: 51.5, text: '↓ FREE', x: 1.2, y: -1.55 }] },
  ] } },
  // 21 · "Comment LYRIC"
  { kind: 'disc', start: 52.26, end: 54.2, in: 'whip', o: { top: 'comment', topT: 52.32, big: 'LYRIC', bigT: 53.2, bigT1: 53.5, fit: 3.4, bottom: [{ text: 'free', t: 53.7 }], bottomH: 0.9, hud: 'LYRIC.MD  ·  FREE', hudT: 52.3 } },
  // 22 · burst + the last fall
  { kind: 'flash', start: 54.2, end: 54.72, in: 'flash', o: { text: 'lyric.md', pillT: 54.2, burst: 54.2 } },
  { kind: 'shapes', start: 54.72, end: TOTAL, in: 'cut', o: { waves: [[54.5, 55.8, 40]], r0: 8.8, r1: 11, words: [
    { t: 54.95, text: 'comment', pill: true, x: -0.6, y: 2.75, drop: false, bg: '#ffffff', fg: C.acc },
    { t: 55.2, text: 'LYRIC', fit: 2.9, y: 1.6 },
  ] } },
];
```

### `my-video/lyrics.txt`

```text
Never pay, never pay. Never pay for a skill again. I'm Casper, I built Morphy. Gave my format away for free, every line, every skill dot MD. Then they took it, put a price on it. Thirty dollars for a gift. Thirty dollars for a text file. That was free right from the start. So here's my answer, here's my move. A whole library, open, made for you. No paywalls, no subscriptions, no strings, no fine print. Every format, every MD, free inside Morphy. This one's called lyric dot MD. Comment LYRIC, I'll send it to you. Free, free, completely free. Comment LYRIC.
```

