---
name: chibi
description: Make a narrated vertical reel (1080x1920, 25-45 s) in the "Chibi" format - a cute, glowing chibi narrator on a warm dark stage explains something chapter by chapter. He walks, hops with squash and stretch, looks around, lip-syncs, changes pose and face on nearly every word, and flings his sunglasses off or catches them back on. Around him: a perspective floor grid, a mono chapter bar ("● 03 / 11 — PRICE") with a progress line, tilted dark-glass UI cards and mono tags landing one per word, and mid-screen captions in 1-3 word chunks (the spoken word in a hopping orange pill, key words in orange serif italic, unspoken words dim) over a giant outlined ghost word. The camera pushes and drifts, light sweeps run between chapters, white-outs land on reveals, red/blue/spotlight acts mark the turns, and a "Comment “KEYWORD”" end card closes it. One energetic ElevenLabs voice, a synthesized sound kit and music bed, drawn frame by frame in headless Chrome and encoded with ffmpeg. Use it when someone asks for a chibi reel, "chibi.md", a cute mascot / character explainer, a reel like a glowing chibi explaining a tool, or wants to turn a script into this kind of video.
---

# Chibi reel: how to make one

You will make a **25-45 second vertical video (1080x1920, 30 fps, H.264 + AAC, at most 60 s and 60 MB)** that feels like **a tiny, very expressive friend walking you through a tool**: a counted hook, "and it's totally free!", "here's the thing" in close-up, the problem in a few beats, the fix, "it's called…", the name, the install in three verbs, and "comment X and I'll send it to you".
- **The picture:** a warm, dark stage with one **orange** accent. A shaded chibi with a cream rim and an orange glow stands in the lower half. Dark-glass cards float above him, and captions sit mid-screen between the two.
- **It never stands still.** Something happens on nearly every word: a card lands, a stamp slams, a check pops, he changes pose or face, the camera punches in. And a sound goes with each.
- **Everything is drawn:** SVG and a little HTML, no images, no footage.

**`reference.jpg`** (next to this file, if you have it) is a contact sheet of 12 frames from a finished Chibi reel: **this is what it should look like.** Left to right, top row first:
1. "1,000s" in glowing gold over a blurred wall of skill tags, "CONTENT SKILLS" spaced under it, the chibi in sunglasses with open arms;
2. a tilted checkout card "Pro Content Skill $29.99 · Buy now" with a red "NEVER" stamp slammed across it and a cursor knocked away;
3. a chrome "$0" with a tilted orange "FREE" stamp and an orange swoosh, sparkles;
4. the close-up: he fills the frame, the sunglasses fly off spinning up and right;
5. a tilted app window "MORPHY" with mini cards, three orange tags flying in, a gold "SUPER APP" badge, him with a thumbs-up;
6. a folder card "my-format/" with five files and a glowing "SKILL.md" card;
7. the red act: three relabelled locked copies (ULTIMATE PACK, PRO FORMAT, SKILL BUNDLE) and a red digital "$30.00" price card, him clutching his chest, eyes wide;
8. the blue act: he has walked left; a globe, a terminal "build library --free" and four format cards with green "SKILL.md" checks;
9. three rows (Paywall, Subscription, Strings attached), each struck through with a red ✕;
10. the spotlight: "Chibi" in white and ".md" in orange serif, sparkles, him small in the light cone;
11. a Skills panel with "chibi.md" outlined in green and a big "✓ Done.";
12. the end card "Comment “CHIBI”" with an orange arrow ↘, the shades back on.

If you only have this file, section 3 describes everything with numbers.

Everything is drawn **one frame at a time** in headless Chrome (`window.setT(t)`), then muxed with a mix made in ffmpeg. This file gives you:
- the look (exact colours, fonts, sizes),
- the grammar of the script and of the edit,
- the complete tested engine (Appendix A: write it once),
- a complete example video (Appendix B).

With the engine written, **a video is two files**: `script.mjs` (what the voice says and the captions show) and `scenes.js` (the chapters, the character's acting and every card).

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

---

## 0. Rules of engagement (read first)

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

## 1. Requirements

- **Node 22+** (global `fetch` and `WebSocket`), **ffmpeg + ffprobe** on PATH, **Chrome or Edge** (or set `CHROME=<path>`). No npm packages.
- **An ElevenLabs API key** and **the voice on that account**: open ElevenLabs' Voice Library, search **"Arthur - Energetic American Male Narrator"** and add it. Or set `VOICE` in `gen-vo.mjs` to the premade **Liam** (`TX3LPaxmHKxFdv7VOQHJ`), also young and energetic.
  - The free plan is enough to try the format but has no commercial licence; videos that promote anything need a paid plan.
  - No key yet: do everything up to the stills with `--fake` timings and ask for the key.
- **Fonts:** free downloads that `setup.mjs` fetches (Inter Tight, Instrument Serif italic, IBM Plex Mono). On Windows the key words use Bodoni MT Condensed italic and the labels Consolas when installed.
- **Sound:** the 32 sound effects (`sfxgen.mjs`) and the music bed (`bed.mjs`) are synthesized: no licences, no credits. To use your own music, put it in `music/` and name it in `E.music({ track })`.
- **Optional:** Python + `pip install faster-whisper` lets `check-vo.mjs` and `whisper-check.mjs` compare the audio with the script.

## 2. Project layout and setup

```
chibi-reel/                      one project folder, many videos
  .env                           ELEVENLABS_API_KEY=...   (never print it)
  edit.html edit.css engine.js   the page: timeline.js -> engine -> scenes.js -> window.setT(t) · the look · the engine
  lib.mjs markup.mjs cdp.mjs     shared helpers · script markup · the headless browser
  setup.mjs sfxgen.mjs bed.mjs   requirements + fonts · the sound kit · the music bed
  gen-vo.mjs check-vo.mjs retag.mjs build-timeline.mjs render-short.mjs mix.mjs encode.mjs whisper-check.mjs whisper.py
  fonts/ sfx/ music/             made by setup.mjs
  my-video/                      one folder per video (Appendix B is a complete one)
    script.mjs scenes.js         what the voice says · what the film shows
    vo/ timeline.js(on) vo.wav cues.json stills/ frames/ mix.wav video.mp4 cover.jpg   (made by the tools)
```

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

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

```bash
mkdir my-video                   # write my-video/script.mjs (section 4) and my-video/scenes.js (section 6; Appendix B shows both)
node build-timeline.mjs --dir my-video --fake                    # guessed timings + a silent vo.wav: direct before you pay
node render-short.mjs --dir my-video --stills --at 1,3.3,5.6,6.5,9.8,12.6,16.7,20.3,23.5,26.2,29.8,34 --safe   # my-video/stills/sheet-safe.jpg
node gen-vo.mjs --dir my-video                                   # dry run: the text of every take + the character count
node gen-vo.mjs --dir my-video --yes                             # one take per section -> my-video/vo/
node check-vo.mjs --dir my-video                                 # optional: whisper every take (needs faster-whisper)
node build-timeline.mjs --dir my-video                           # real timings -> timeline.js, vo.wav
node -e "const j=require('./my-video/timeline.json');for(const s of j.sections)console.log(s.id,s.tokens.map(k=>k.d+'@'+k.t0).join(' '))"   # every word's time
node render-short.mjs --dir my-video --stills --at <one per beat> --safe
node render-short.mjs --dir my-video --stills --at 6.0,6.1,6.2,6.3,6.4,6.5,6.6,6.7,6.8,6.9,7.0,7.1 --sheet my-video/stills/motion.jpg   # a 10 fps motion strip of a busy beat
node render-short.mjs --dir my-video --scan --step 0.0333       # every visible text, every frame, against the safe zone
node render-short.mjs --dir my-video --full --to 12 && node render-short.mjs --dir my-video --full --from 12 --to 24 && node render-short.mjs --dir my-video --full --from 24
node mix.mjs --dir my-video                                      # voice + bed 13 LU under (ducked) + every sound cue -> -14 LUFS
node encode.mjs --dir my-video                                   # my-video/video.mp4 + cover.jpg, with the checks
node whisper-check.mjs --dir my-video                            # optional: the words heard vs the script
```

## 3. The look, pinned down

### 3.1 The frame (1080x1920 px)

| Layer | Where | What |
|---|---|---|
| Stage | everywhere | `#0b0705`; a radial glow `rgb(214,96,40)` at ~0.6 centred on the character's chest, a wider one at the top third, a pool at his feet; a vignette |
| Floor | from y 1060 down | a perspective grid (37 lines from a vanishing point at x 540, 15 rows getting denser to the horizon), orange at up to 20 %; slow twinkling stars everywhere |
| Chapter bar | x 66, y 262 | `● 03 / 11 — PRICE`: Consolas/Plex Mono 700 22 px caps +0.12em, number white, rest muted `#8f8379`; a 2 px line under it (y 304, 880 px) filling orange to n/N with a glowing head |
| Cards | x 110-920, y 340-820 | the UI of each beat (3.4) |
| Captions | centred on x 510, bottom at y 952 | 70 px (3.3) over a ghost word |
| Character | centre x 510, feet y 1470 | 320x430 SVG units at 1.32 × scale (~420x570 px); scale 0.8-1.05 normally, ~2.25 in the close-up (feet ~y 2140) |

**Safe zone (every platform covers the edges):** all text inside **x 60-960, y 250-1480**. Nothing important goes in the top 250 px, the bottom 440 px or the right 120 px. `--stills --safe` draws the zone and outlines any visible text outside it in red; `--scan` tests every text on every frame. Art may run off the edges; text may not. Text clipped away by an `overflow:hidden` box gets `dataset.free = 1`.

### 3.2 Colours

| Token | Hex | Used for |
|---|---|---|
| orange | `#ef7a3c` (light `#ff9a5c`, `#ffb37a`) | the pill, key words, borders of hot cards, tags, stamps, the glow on the character |
| gold | `#ffc93c` → gradient `#fff3b0 → #ffd23f → #f29a12` + glow | big numbers ("1,000s"), the SUPER-type badge |
| green | `#3ddc84` | checks, "downloaded ✓", the row that just landed |
| red | `#ff4d4d` | NEVER, strikes, ✕, the price that hurts |
| ink / dim / muted | `#f4ece4` · `rgba(244,236,228,.26)` · `#8f8379` | spoken words · unspoken words · labels |
| glass | `linear-gradient(160deg, #241c18, #16110f)`, border `rgba(255,255,255,.09)` 1.5 px, radius 18, shadow `0 24px 60px rgba(0,0,0,.55)` | every card |
| acts | blue `#060912` + glow `52,82,190` · red `#100405` + `200,36,36` · spot `#040302` (a light cone with dust) | the fix · the problem · the name |

### 3.3 Captions

- **Chunks:** one chunk at a time, **1-3 words**, cut at a `/` in the script; Inter Tight 800, 70 px, centred on x 510.
- **Word by word:** unspoken words are dim and slightly blurred. A spoken word turns white and pops (rises 14 px, 1.12 → 1 scale).
- **The pill:** while a plain word is spoken it sits in an **orange rounded pill** (white text, glow). The pill **slides from the previous word** and hides between words.
- **Key words** (`*word*`) are **orange serif italic at 1.2em** with a soft glow and never get the pill.
- **Behind the captions:** a **ghost word** per chapter, 250 px outlined caps at 13 % orange, drifting left.
- **Ticks:** a soft tick sounds on every plain word (automatic).

### 3.4 The character

A chibi: a big head (hair with a tuft on top, bangs, side locks), a cream tee with an orange V-collar and stripes, dark trousers, white sneakers with orange soles. He has shading gradients, a cream rim and an orange glow (`#ff8a45`, reddish in the red act).
- **Poses** (arms blend in 0.28 s with a little overshoot): idle, wave, wave2, up, wag (a finger wag), present, presentL, reach, reachL, point, pointL, open, cheer, shrug, chest, thumb, hips, cheeks, think, give.
- **Eyes:** open (blinks, a double blink now and then), happy ^^, calm, wide, flat (unimpressed), wink, sad (with a tear).
- **Mouth:** lip flaps on every spoken word; or smile, grin, open, o, flat, wavy for a moment.
- **Moves:**
  - `at()` walks sideways (legs swing, he leans, footsteps) or glides in and out for close-ups.
  - `hop()` = squash, jump, stretch, land + a spring boing.
  - He also tilts his head, looks left or right (the face slides), blushes, and his hair tuft lags behind every move.
- **Sunglasses:** `on`, `throw` (flung up and away, spinning, with a whoosh) and `drop` (fall back on with a bounce and a click).

### 3.5 Motion

| Move | How |
|---|---|
| between chapters | the bar flips (letters glitch in), a **diagonal light sweep** crosses with a whoosh, the old cards fly or blur out upward |
| in a chapter | a slow camera push (+4.5 %), a handheld drift, cards that hover, the glow breathing |
| on a hit | a **camera punch** (+5 % and back in 0.28 s), a **shake**, a **flash** (a warm white-out), a stamp |
| a list ("No X. No Y. No Z.") | one card per item on its word, a red strike and a ✕ on each; the last one snaps and falls |
| a number | it counts or rolls (a slot-machine reel, a price counting up to $30.00) with a tick-roll |
| the name | spotlight, "It's called…" alone with pulsing dots, a riser and a swell into a white-out, the name slams in layered: white sans + orange serif |
| the end | the comment box types the keyword letter by letter, the send button squashes, a plane flies off, a DM card arrives; a white-out into **"Comment “KEYWORD”"** (130 px white + 220 px orange serif) with a bouncing arrow ↘, held ~2 s |

### 3.6 Sound

The voice sits on top, with the bed **13 LU under** it and ducked while he speaks. On top of that go **~6 sound cues a second**:
- automatic ones: ticks, footsteps, boings, sweeps, the shades;
- your own `cue()` on every card, stamp and check.

The 32 sounds:
- **UI:** tick1-3, blip, blip2, blipdown, bubble, click, key1-3, ding1-2;
- **impacts:** boing, thud, stamp, boom;
- **whooshes:** whoosh2, swup, swdown, throw, drag, send;
- **specials:** kaching, snap, shimmer, sparkle, step1-2, swell, riser2, tickroll.

The final mix is −14 LUFS, true peak ≤ −1 dBTP.

## 4. Writing for this format

The reference grammar:
1. **a counted hook** ("There are thousands of X out there, but you only need one");
2. **"And it's totally free!"**;
3. **"Here's the thing."** (the close-up), then the problem in 2-3 short beats;
4. **the fix** (what the thing does, in literal steps);
5. **"It's called…"** as its own section, then **the name alone**;
6. **the install in three verbs** ("Just download it, upload it to your skills, and you're done");
7. **"Comment X and I'll send it to you."**

- 90-110 words ≈ 30-36 s at ~190 wpm with gaps of 0.12-0.45 s. Never over 60 s.
- Markup:
  - `/` starts a new caption chunk (1-3 words; a key word often stands alone);
  - `*word*` is a key word;
  - `*two_words*` is one token over two spoken words;
  - `{shown|said}` is for when the caption and the voice differ (`{$30.|thirty dollars.}`, `{SKILL.md,|skill dot M D,}`, `{CHIBI,|Chibi,}`).
- `GAP` sets the silence before a section; `HOLD = 2.2` keeps the end card on screen after the last word.

## 5. The voice

ElevenLabs **Arthur** (`TtRFBnwQdH1k01vR0hMz`), `eleven_multilingual_v2`, stability 0.45, similarity 0.8, style 0.4, speaker boost, **speed 1.1**: young, bright, quick (~190 wpm). One take per section, so every caption word gets its own time from the timestamps. Other voices: set `VOICE` in `gen-vo.mjs`; keep it young and energetic.

## 6. scenes.js: directing a video

`window.VIDEO = E => { … }` runs once and registers everything; the engine then draws any time `t`. All times come from the words: `T(id, word, n)` is the start of the n-th token in that section starting with `word`, `TE(...)` its end, `S.s4.start/end` the section. **A word matches by prefix**, so `T('s2', 'I')` finds "I'm". Use a longer word.

**Direct in this order:**
1. **Chapters, ghosts, acts and music:** one chapter per section or idea, 8-12 in all.
2. **The character, start to end:** a pose, face or look change every 1-2 words. Big moments get a hop. "Here's the thing" gets the close-up with the shades thrown. The fix walks him aside, and he walks back after.
3. **Camera punches** on the hits.
4. **One `scene()` per chapter:** the cards of that beat, each landing on its word, with a sound.

```js
chapter(3, 'PRICE', S.s1.start - 0.05);                         // the bar; a sweep + whoosh on every change after the first
ghost('FREE', S.s1.start, S.s2.start - 0.1);                     // the outlined word behind the captions
act('red', S.s4.start - 0.05, S.s5.start - 0.2, 0.3);            // 'red' | 'blue' | 'spot' | 'dark'
punch(T('s4', '$30'), 0.05);  shake(t, 16, 0.4);  flash(t, 0.16, 0.2);  sweep(t, 1);
E.music({ track: 'bed', under: 13, fadeOut: 1.4, beds: [{ at: 0, from: 0 }], muffle: [[a, b]] });   // muffle = low-passed (the spotlight)

chibi.shades(-9, 'on').eyes(-9, 'open').pose(-9, 'idle').at(-9, 510, 1440, 0.8)
  .at(-0.3, 510, 1470, 1, 2.0, { walk: false })                  // the push in on the hook
  .pose(T('s0', 'thousands') - 0.1, 'open').look(T('s0', 'skills'), -0.6, 3)
  .at(S.s2.start - 0.12, 510, 2140, 2.25, 0.4, { walk: false })  // the close-up
  .shades(T('s2', 'the'), 'throw').eyes(T('s2', 'the'), 'wide')
  .at(S.s5.start - 0.12, 262, 1470, 0.96, 0.75)                  // walks left (legs + footsteps)
  .hop(T('s3', 'free') - 0.06, 0.8).eyes(T('s3', 'free'), 'happy').mouth(t0, t1, 'o');

scene(S.s4.start - 0.05, S.s5.start + 0.35, (fr, bk) => {       // fr = in front of him, bk = behind him
  const price = card(fr, 290, 625, 440, 170, `<div style="padding:24px 30px;font:700 80px Consolas,monospace;color:#ff4d4d">$30.00</div>`, { hot: COL.RED });
  cue('kaching', T('s4', '$30'), 0.09);
  return t => { show(price, t, T('s4', 'sold'), S.s5.start - 0.05, { kin: 'rise', dy: 60, base: TILT(0, 6) }); };
});
```

**Components (all on `E`):**
- **The character:** `chibi.at(t, x, y, scale, d, { walk })`, `.pose`, `.eyes`, `.shades`, `.mouth(t0, t1, kind)`, `.hop(t, k, sound)`, `.tilt(t, deg)`, `.look(t, x −1…1, deg)`, `.blush(t, k)`; `charPos(t)`.
- **Layout:**
  - `A(parent, x, y, w, h, cls, html, style)`;
  - `card(...)` (dark glass; `.hot(k, colour)` lights its border per frame);
  - `tag(parent, x, y, text, { hot })`;
  - classes `.gold` (gradient numbers), `.chrome` (extruded white numbers), `.mono`, `.serif`, `.tg`.
- **Motion:**
  - `show(el, t, t0, t1, { kin, kout, din, dout, from, dx, dy, base, blur })`. `kin` is pop · rise · drop · slam · fade · flip · fly; `kout` is up · down · shrink · fade · fly. `base` is the resting transform, e.g. `perspective(1100px) rotateY(-10deg) rotateX(6deg)` plus `hover(t, seed, px)`.
  - Also `typed(el, text, t, t0, t1)`, `caretOn(t)`, `st(el, opacity, transform, blur)`. Easings: `ease eo ei eio back elastic`.
- **Overlays:** `topL` is a layer over everything (end cards); hide its elements with `st(e, 0)` when you make them, because they live outside any scene. `hideCaptions(t0, t1)`.
- **Icons:** `ICON.check cross file zip send globe giftBox giftLid tag lock card string spark cursor user arrow`; `MARK` (the CTA avatar, a data URI: swap in your logo).
- **Sound:** `cue(name, t, gain 0.03-0.11)`; `capTicks(gain)` (0 turns the word ticks off).

**Timing rules:**
- A card lands 0.05-0.15 s before its word.
- In a list: placeholders first, then one card per word, then a check per card 0.1 s apart.
- Exits overlap the next chapter's entrances by ~0.3 s.
- Frame 0 must already show the hook (start the first scene at −1 and the counter at −0.34).
- Never leave a chapter empty between its words. If in doubt, add a pose or face change and a tick.

## 7. Checks before you deliver

- `--stills --safe` shows **no red boxes**, and `--scan --step 0.0333` says **all text inside**.
- **Motion strips** (10 fps) of the close-up and the busiest chapter look smooth: no pops, no frozen character, no card stuck at an odd angle.
- `encode.mjs` says **ENCODE OK** (H.264 High, yuv420p, 1080x1920, 30 fps, ≤ 60 MB, ≤ 60 s, −14 LUFS ±0.7, true peak ≤ −1 dBTP, frame 0 not black).
- `whisper-check.mjs` (if faster-whisper is installed) shows only spelling differences. Otherwise listen.
- Deliver `my-video/video.mp4` and `cover.jpg` (the frame at `E.coverAt`), plus a caption ending on "Comment KEYWORD". Post nothing.

---

## Appendix A: the engine (write every file exactly)

### `edit.html`

```html
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Chibi reel</title>
<link rel="stylesheet" href="edit.css">
</head>
<body>
<div id="film"></div>
<script>
  // edit.html?dir=<video folder, relative to this file>&safe=1&v=<cache-bust>. The video's own files come from that folder.
  (() => {
    const q = new URLSearchParams(location.search);
    window.DIR = (q.get('dir') || '.').replace(/\\/g, '/').replace(/\/?$/, '/');
    window.BUST = q.get('v') || '';
    const files = [window.DIR + 'timeline.js', 'engine.js', window.DIR + 'scenes.js'];
    let left = files.length;
    for (const f of files) {
      const s = document.createElement('script'); s.src = f + (window.BUST ? '?v=' + window.BUST : ''); s.async = false;
      s.onload = () => { if (--left === 0) window.LUI.boot(); };
      s.onerror = () => { console.error('missing ' + f); window.ready = Promise.reject(new Error('missing ' + f)); };
      document.head.appendChild(s);
    }
  })();
</script>
</body>
</html>
```

### `edit.css`

```css
/* Chibi style tokens. Inter Tight (fonts/) for everything; Bodoni MT Condensed italic for the key word;
   Consolas bold caps for labels; Segoe Print for handwritten notes. Off Windows, Instrument Serif italic and IBM Plex Mono stand in (SIL OFL). */
@font-face { font-family: 'Inter Tight'; src: url(fonts/InterTight.ttf) format('truetype'); font-weight: 100 900; }
/* the key-word serif and the mono labels: the Windows faces if installed, else the free ones in fonts/ */
@font-face { font-family: 'Bodoni MT Condensed'; font-style: italic; src: local('Bodoni MT Condensed Italic'), local('BodoniMTCondensed-Italic'), url(fonts/InstrumentSerif-Italic.ttf) format('truetype'); }
@font-face { font-family: 'Consolas'; font-weight: 400 700; src: local('Consolas'), url(fonts/IBMPlexMono-Bold.ttf) format('truetype'); }
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 1080px; height: 1920px; overflow: hidden; background: #000; }
#film { position: absolute; left: 0; top: 0; width: 1080px; height: 1920px; overflow: hidden; font-family: 'Inter Tight', system-ui, sans-serif; color: #f4ece4; -webkit-font-smoothing: antialiased; }
.L { position: absolute; left: 0; top: 0; width: 1080px; height: 1920px; }
.A { position: absolute; }
.sc { position: absolute; left: 0; top: 0; width: 1080px; height: 1920px; display: none; }
.mono { font-family: Consolas, 'Cascadia Mono', monospace; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
.serif { font-family: 'Bodoni MT Condensed', 'Bodoni MT', Georgia, serif; font-style: italic; font-weight: 400; letter-spacing: 0; }
/* gc: the dark-glass card every UI mock sits on (tilt it with show()'s base) */
.gc { background: linear-gradient(160deg, #241c18 0%, #16110f 100%); border: 1.5px solid rgba(255, 255, 255, 0.09); border-radius: 18px; box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55); color: #f4ece4; overflow: hidden; }
.gc .mono { color: #8f8379; }
/* tg: a small mono skill tag; .hot = orange */
.tg { display: inline-flex; align-items: center; gap: 9px; white-space: nowrap; background: rgba(28, 22, 19, 0.92); border: 1.5px solid rgba(255, 255, 255, 0.08); border-radius: 10px; padding: 9px 15px; font: 700 20px Consolas, monospace; color: #a99c90; box-shadow: 0 10px 24px rgba(0, 0, 0, 0.45); }
.tg i { display: block; width: 7px; height: 7px; border-radius: 50%; background: #6a5f57; }
.tg.hot { background: rgba(110, 48, 20, 0.85); border-color: rgba(239, 122, 60, 0.7); color: #ffd9c2; }
.tg.hot i { background: #ef7a3c; box-shadow: 0 0 8px #ef7a3c; }
/* ghost: the giant outlined word behind the caption */
.ghost { font: 900 250px 'Inter Tight', sans-serif; letter-spacing: -0.02em; color: transparent; -webkit-text-stroke: 2px rgba(255, 150, 100, 0.13); text-transform: uppercase; line-height: 1; }
/* big numbers */
.gold { font-weight: 900; letter-spacing: -0.04em; background: linear-gradient(180deg, #fff3b0 0%, #ffd23f 45%, #f29a12 100%); -webkit-background-clip: text; background-clip: text; color: transparent; filter: drop-shadow(0 0 22px rgba(255, 190, 40, 0.75)) drop-shadow(0 6px 0 #9a5208); }
.chrome { font-weight: 900; letter-spacing: -0.05em; color: #f6f1ec; text-shadow: 0 2px 0 #cfc6bf, 0 4px 0 #b9aea6, 0 6px 0 #a2958c, 0 8px 0 #8a7c73, 0 10px 0 #72645b, 0 24px 40px rgba(0, 0, 0, 0.7); }
/* captions: the active-word pill is a mask over a second copy of the line */
#cap > .A { line-height: 0; font-size: 0; }
#cap .cw { display: inline-block; position: relative; z-index: 2; white-space: pre; padding: 0 10px; }
#cap .box { position: absolute; z-index: 1; border-radius: 12px; }
```

### `engine.js`

```js
// engine.js — the Chibi engine (frozen: a video never edits this file; it writes its own scenes.js).
// After @jako.frames' "find-skills" reel: a shaded, glowing chibi narrator on a warm dark stage with a perspective floor
// grid, a mono chapter bar ("● 03 / 11 — PRICE" + a progress line), tilted dark-glass UI cards above the character,
// mid-screen word-by-word captions (the spoken word in an orange pill that hops word to word, key words in orange serif
// italic, unspoken words dim) over a giant outlined ghost word, a camera that drifts and pushes in, light sweeps between
// chapters, white-outs, and a synthesized sound kit (sfx/). Acts recolour the stage: warm (base) · blue · red · spot.
// The chibi walks (legs swing, footsteps), hops (squash and stretch, spring), looks left/right, blinks, lip-syncs on the
// spoken words, changes pose and face on cue, and flings his shades off or catches them back on.
// edit.html?dir=<video folder>[&safe=1] loads <dir>/timeline.js and <dir>/scenes.js, then LUI.boot() builds the film.
(() => {
  'use strict';
  const W = 1080, H = 1920, PI = Math.PI;
  // RULES.md safe zone. Text: x 60–960 × y 250–1480. Chibi keeps its captions mid-screen (its look, like Motion's top
  // caption): centred on x 510, ≤ 820 wide, bottom at capBottom. The character stands under them, feet at y 1470.
  const SAFE = { x0: 60, x1: 960, y0: 250, y1: 1480, cx: 510, capW: 820, capBottom: 952, chapY: 262, cardY0: 330, cardY1: 800 };
  const COL = { OR: '#ef7a3c', OR2: '#ff9a5c', GOLD: '#ffc93c', INK: '#f4ece4', DIM: 'rgba(244,236,228,0.26)', GREEN: '#3ddc84', RED: '#ff4d4d', BLUE: '#6f8dff', NAVY: '#3b4a8a', GLASS: '#1a1512', MUTED: '#8f8379' };
  const { OR } = COL;

  // ---------- maths + DOM helpers ----------
  const clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
  const lerp = (a, b, k) => a + (b - a) * k;
  const ease = x => { x = clamp(x); return x * x * (3 - 2 * x); };
  const eo = x => 1 - Math.pow(1 - clamp(x), 3);
  const ei = x => Math.pow(clamp(x), 3);
  const eio = x => { x = clamp(x); return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; };
  const back = (x, c = 1.7) => { x = clamp(x); return 1 + (c + 1) * Math.pow(x - 1, 3) + c * Math.pow(x - 1, 2); };
  const elastic = x => { x = clamp(x); return x === 0 || x === 1 ? x : Math.pow(2, -10 * x) * Math.sin((x * 10 - 0.75) * (2 * PI / 3)) + 1; };
  const rng = seed => { let s = seed >>> 0; return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; };
  const px = v => v + 'px';
  const hex = c => [1, 3, 5].map(i => parseInt(c.slice(i, i + 2), 16));
  const mixA = (A_, B, k) => A_.map((v, i) => lerp(v, B[i], clamp(k)));
  const rgb = (A_, a = 1) => `rgba(${A_.map(v => Math.round(v)).join(',')},${a})`;
  const fmt = s => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}`;
  const h = (tag, cls, style, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (style) Object.assign(e.style, style); if (html != null) e.innerHTML = html; return e; };
  const A = (p, x, y, w, hh, cls = '', html, style = {}) => { const e = h('div', 'A ' + cls, { left: px(x), top: px(y), ...(w != null ? { width: px(w) } : {}), ...(hh != null ? { height: px(hh) } : {}), ...style }, html); p.appendChild(e); return e; };
  const st = (el, op, tf = '', blur = 0) => {
    const o = clamp(op);
    el.style.visibility = o <= 0.002 ? 'hidden' : 'visible';
    el.style.opacity = o.toFixed(3); el.style.transform = tf; el.style.filter = blur > 0.3 ? `blur(${blur.toFixed(1)}px)` : 'none';
  };

  // ---------- icons (SVG strings) ----------
  const ICON = {
    check: (s, c = COL.GREEN) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24"><circle cx="12" cy="12" r="11" fill="${c}"/><path d="M6.8 12.4l3.4 3.3 7-7.2" fill="none" stroke="#0b1a10" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
    cross: (s, c = COL.RED) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24"><circle cx="12" cy="12" r="11" fill="${c}"/><path d="M8 8l8 8M16 8l-8 8" stroke="#2a0606" stroke-width="2.6" stroke-linecap="round"/></svg>`,
    file: (s, c = OR) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2.5h8l4.5 4.5v14.5H6z"/><path d="M14 2.5V7h4.5M9 12h6M9 15.5h6M9 19h4"/></svg>`,
    zip: s => `<svg width="${s}" height="${s * 1.25}" viewBox="0 0 40 50"><path d="M3 3h24l10 10v34H3z" fill="${OR}"/><path d="M27 3v10h10" fill="#c45a24"/><g fill="#2a1408"><rect x="14" y="6" width="6" height="4"/><rect x="18" y="11" width="6" height="4"/><rect x="14" y="16" width="6" height="4"/><rect x="18" y="21" width="6" height="4"/><rect x="14" y="26" width="10" height="11" rx="2"/></g><rect x="17" y="31" width="4" height="3" fill="${OR}"/></svg>`,
    send: (s, c = '#fff') => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="${c}"><path d="M3.5 19.5l17-7.5-17-7.5 2.2 7.5z"/></svg>`,
    globe: (s, c = 'rgba(255,170,120,0.75)') => { let g = ''; for (let i = 1; i < 6; i++) g += `<ellipse cx="50" cy="50" rx="${(i * 8).toFixed(0)}" ry="44" fill="none"/>`; for (let i = -2; i <= 2; i++) g += `<line x1="${50 - Math.sqrt(44 * 44 - (i * 16) ** 2).toFixed(1)}" y1="${50 + i * 16}" x2="${50 + Math.sqrt(44 * 44 - (i * 16) ** 2).toFixed(1)}" y2="${50 + i * 16}"/>`; return `<svg width="${s}" height="${s}" viewBox="0 0 100 100" stroke="${c}" stroke-width="1.6"><circle cx="50" cy="50" r="44" fill="none"/>${g}</svg>`; },
    giftBox: s => `<svg width="${s}" height="${s}" viewBox="0 0 48 48"><rect x="6" y="20" width="36" height="25" rx="4" fill="${OR}"/><rect x="21" y="20" width="6" height="25" fill="#ffe2cc"/><rect x="6" y="20" width="36" height="5" fill="rgba(0,0,0,0.18)"/></svg>`,
    giftLid: s => `<svg width="${s}" height="${s * 0.5}" viewBox="0 0 48 24"><rect x="3" y="12" width="42" height="10" rx="3" fill="#ff9a5c"/><rect x="21" y="12" width="6" height="10" fill="#ffe2cc"/><path d="M24 12c-4-8-14-9-12-2 1 3 7 3 12 2zm0 0c4-8 14-9 12-2-1 3-7 3-12 2z" fill="none" stroke="#ffe2cc" stroke-width="3"/></svg>`,
    tag: (s, c = COL.RED) => `<svg width="${s}" height="${s}" viewBox="0 0 48 48"><path d="M4 22V6a2 2 0 0 1 2-2h16l22 22-18 18z" fill="${c}"/><circle cx="14" cy="14" r="4" fill="#1a0606"/></svg>`,
    lock: (s, c = COL.MUTED) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2.2"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>`,
    card: (s, c = COL.MUTED) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2"><rect x="2.5" y="5" width="19" height="14" rx="2.5"/><path d="M2.5 9.5h19M6 15h4"/></svg>`,
    string: (s, c = COL.MUTED) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round"><path d="M12 2v6M7 2v4M17 2v5"/><path d="M12 8c-3 2-3 5 0 7s3 5 0 7M7 6c-2 3-1 6 1 8M17 7c2 3 1 6-1 8"/></svg>`,
    spark: (s, c = OR) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="${c}"><path d="M12 1.5l2.4 7.1 7.1 2.4-7.1 2.4L12 20.5l-2.4-7.1L2.5 11l7.1-2.4z"/></svg>`,
    cursor: s => `<svg width="${s}" height="${s}" viewBox="0 0 24 24"><path d="M4 2.5l15 9.2-6.6 1.3 3.9 7.2-2.8 1.5-3.9-7.2-5.2 4.3z" fill="#fff" stroke="#111" stroke-width="1.4" stroke-linejoin="round"/></svg>`,
    user: (s, c = '#8f8379') => `<svg width="${s}" height="${s}" viewBox="0 0 24 24"><circle cx="12" cy="12" r="11.5" fill="#2a211d"/><circle cx="12" cy="9.5" r="4" fill="${c}"/><path d="M4.5 19.5c1.6-3.6 4.3-5 7.5-5s5.9 1.4 7.5 5" fill="${c}"/></svg>`,
    arrow: (s, c = OR) => `<svg width="${s}" height="${s}" viewBox="0 0 48 48" fill="none" stroke="${c}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"><path d="M8 8 L38 38 M38 16 V38 H16"/></svg>`,
  };
  const MARK = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84 84"><defs><radialGradient id="g" cx="40%" cy="35%"><stop offset="0" stop-color="#ffb37a"/><stop offset="1" stop-color="#ef7a3c"/></radialGradient></defs><circle cx="42" cy="42" r="42" fill="url(#g)"/><path d="M42 16l6.5 19.5L68 42l-19.5 6.5L42 68l-6.5-19.5L16 42l19.5-6.5z" fill="#fff"/></svg>'); // the CTA avatar (swap in your own logo as a data URI)

  // ---------- entrance / exit, one call per frame ----------
  // kin: pop · rise · drop · slam · fade · flip · fly   kout: up · down · shrink · fade · fly   (base = resting transform)
  const show = (el, t, t0, t1, o = {}) => {
    const { kin = 'pop', kout = 'up', din = 0.34, dout = 0.24, base = '', dy = 60, dx = 0, from = 0.6, blur = 10 } = o;
    if (t < t0 || (t1 != null && t >= t1 + dout)) { st(el, 0); return 0; }
    let op = 1, tf = base, bl = 0; const ki = (t - t0) / din;
    if (kin === 'pop') { const k = back(ki); tf += ` scale(${(from + (1 - from) * k).toFixed(4)})`; op = clamp(ki * 3.5); bl = (1 - eo(ki)) * blur; }
    else if (kin === 'rise') { const k = back(ki, 1.0); tf += ` translate(${((1 - k) * dx).toFixed(1)}px, ${((1 - k) * dy).toFixed(1)}px)`; op = clamp(ki * 2.2); bl = (1 - eo(ki)) * blur; }
    else if (kin === 'drop') { const k = back(ki, 1.25); tf += ` translateY(${((1 - k) * -dy).toFixed(1)}px)`; op = clamp(ki * 4); }
    else if (kin === 'slam') { const k = eo(ki); tf += ` scale(${lerp(from, 1, k).toFixed(4)})`; op = clamp(ki * 5); bl = (1 - k) * blur; }
    else if (kin === 'flip') { const k = back(ki, 1.2); tf = `perspective(900px) rotateX(${((1 - k) * -70).toFixed(1)}deg) translateY(${((1 - k) * 30).toFixed(1)}px) ` + tf; op = clamp(ki * 3); bl = (1 - eo(ki)) * 6; }
    else if (kin === 'fly') { const k = back(ki, 1.1); tf = `translate(${((1 - k) * dx).toFixed(1)}px, ${((1 - k) * dy).toFixed(1)}px) rotate(${((1 - k) * 14).toFixed(2)}deg) ` + tf; op = clamp(ki * 3); bl = (1 - eo(ki)) * blur; }
    else if (kin === 'fade') { op = ease(ki); }
    if (t1 != null && t >= t1) {
      const ko = ei((t - t1) / dout), ke = eo((t - t1) / dout);
      if (kout === 'up') tf += ` translateY(${(-ke * 70).toFixed(1)}px) scale(${(1 - 0.06 * ke).toFixed(3)})`;
      else if (kout === 'down') tf += ` translateY(${(ke * 60).toFixed(1)}px)`;
      else if (kout === 'shrink') tf += ` scale(${(1 - 0.3 * ke).toFixed(4)})`;
      else if (kout === 'fly') tf += ` translate(${(ko * 260).toFixed(1)}px, ${(-ko * 300).toFixed(1)}px) rotate(${(ko * 18).toFixed(1)}deg)`;
      op *= 1 - ke; bl += kout === 'fade' ? 0 : ke * 12;
    }
    st(el, op, tf, bl); return op;
  };
  const typed = (el, text, t, t0, t1) => { const n = Math.round(clamp((t - t0) / Math.max(0.05, t1 - t0)) * [...text].length); el.textContent = [...text].slice(0, n).join(''); return n; };
  const caretOn = t => Math.floor(t * 2.4) % 2 === 0;
  // a gentle hover for anything floating
  const hover = (t, s = 1, a = 6) => `translate(${(Math.sin(t * 0.9 + s) * a).toFixed(1)}px, ${(Math.cos(t * 1.1 + s * 2) * a * 0.8).toFixed(1)}px)`;

  // ---------- the chibi (SVG, posed per frame) ----------
  // Pose = per arm [shoulder angle out from hanging down, elbow bend] in degrees; positive bend keeps turning out/up.
  const POSES = {
    idle: { L: [14, 6], R: [14, 6] }, wave: { L: [12, 4], R: [148, 26], wig: 'R' }, up: { L: [12, 4], R: [168, -4], fin: 'R' },
    wag: { L: [12, 4], R: [158, -8], fin: 'R', wag: 'R' }, present: { L: [12, 4], R: [80, 26] }, presentL: { L: [80, 26], R: [12, 4] },
    reach: { L: [12, 4], R: [128, 12], fin: 'R' }, reachL: { L: [128, 12], R: [12, 4], fin: 'L' }, point: { L: [12, 4], R: [96, 4], fin: 'R' }, pointL: { L: [96, 4], R: [12, 4], fin: 'L' },
    open: { L: [58, 16], R: [58, 16] }, cheer: { L: [152, 12], R: [152, 12], wig: 'LR' }, shrug: { L: [40, 80], R: [40, 80] }, chest: { L: [12, 4], R: [28, -150] },
    thumb: { L: [12, 4], R: [62, 96], fin: 'R' }, hips: { L: [34, -95], R: [34, -95] }, cheeks: { L: [26, -166], R: [26, -166] }, think: { L: [20, -120], R: [26, -160] },
    give: { L: [52, 40], R: [52, 40] }, wave2: { L: [148, 26], R: [12, 4], wig: 'L' },
  };
  const EYES = ['open', 'happy', 'calm', 'wide', 'flat', 'wink', 'sad', 'shades'];
  const chibiSVG = () => {
    const O = 'stroke="#2a1810" stroke-width="3.2" stroke-linejoin="round" stroke-linecap="round"';
    const SK = 'url(#gSkin)', HR = 'url(#gHair)', TEE = 'url(#gTee)', PANT = 'url(#gPant)';
    const arm = s => `<g id="arm${s}"><path d="M-17 -8 C-19 10 -18 22 -15 32 L15 32 C18 22 19 10 17 -8 Z" fill="${TEE}" ${O}/><path d="M-14 26 L14 26" stroke="${OR}" stroke-width="4"/>` +
      `<rect x="-9" y="30" width="18" height="16" rx="6" fill="#f6d7c2" ${O}/><g id="fore${s}" transform="translate(0,42)"><rect x="-9" y="-4" width="18" height="30" rx="8" fill="#f6d7c2" ${O}/>` +
      `<rect id="fin${s}" x="-3.6" y="38" width="7.2" height="20" rx="3.6" fill="#f9e0cc" ${O}/><circle cx="0" cy="34" r="12.5" fill="#f9e0cc" ${O}/><path d="M-6 30 Q0 27 6 30" stroke="#e4b79c" stroke-width="2" fill="none"/></g></g>`;
    const leg = s => { const x = s === 'L' ? -1 : 1; return `<g id="leg${s}"><path d="M${x * 3} 300 L${x * 50} 300 L${x * 49} 368 L${x * 6} 370 Z" fill="${PANT}" ${O}/><path d="M${x * 28} 312 L${x * 27} 362" stroke="#3b3d46" stroke-width="2.4"/><path d="M${x * 44} 330 Q${x * 36} 340 ${x * 42} 352" stroke="#3b3d46" stroke-width="2.2" fill="none"/>` +
      `<g><ellipse cx="${x * 27}" cy="380" rx="29" ry="13" fill="url(#gShoe)" ${O}/><path d="M${x * 54} 386 Q${x * 27} 395 0 386" stroke="${OR}" stroke-width="4" fill="none"/><path d="M${x * 38} 375 L${x * 22} 372" stroke="#2a1810" stroke-width="2.2"/><path d="M${x * 30} 370 L${x * 34} 378 M${x * 24} 370 L${x * 28} 378" stroke="#c9c2ba" stroke-width="2"/></g></g>`; };
    const eyes = `<g id="e-open"><g id="eo"><ellipse cx="-30" cy="144" rx="10" ry="13" fill="url(#gIris)" stroke="#120a06" stroke-width="1.5"/><ellipse cx="30" cy="144" rx="10" ry="13" fill="url(#gIris)" stroke="#120a06" stroke-width="1.5"/><circle cx="-26.5" cy="139" r="4" fill="#fff"/><circle cx="33.5" cy="139" r="4" fill="#fff"/><circle cx="-32" cy="150" r="1.8" fill="#fff" opacity=".75"/><circle cx="28" cy="150" r="1.8" fill="#fff" opacity=".75"/></g><path d="M-42 133 Q-30 127 -18 132 M18 132 Q30 127 42 133" stroke="#1d120c" stroke-width="3.4" fill="none" stroke-linecap="round"/></g>` +
      `<g id="e-wide"><ellipse cx="-31" cy="143" rx="12.5" ry="15.5" fill="url(#gIris)" stroke="#120a06" stroke-width="1.5"/><ellipse cx="31" cy="143" rx="12.5" ry="15.5" fill="url(#gIris)" stroke="#120a06" stroke-width="1.5"/><circle cx="-27" cy="137" r="5" fill="#fff"/><circle cx="35" cy="137" r="5" fill="#fff"/><circle cx="-34" cy="150" r="2.2" fill="#fff"/><circle cx="28" cy="150" r="2.2" fill="#fff"/></g>` +
      `<g id="e-happy" fill="none" stroke="#1d120c" stroke-width="5" stroke-linecap="round"><path d="M-42 148 Q-30 132 -18 148"/><path d="M18 148 Q30 132 42 148"/></g>` +
      `<g id="e-calm" fill="none" stroke="#1d120c" stroke-width="4.6" stroke-linecap="round"><path d="M-42 143 Q-30 152 -18 143"/><path d="M18 143 Q30 152 42 143"/><path d="M-44 139 l-5 -3M44 139 l5 -3" stroke-width="3"/></g>` +
      `<g id="e-flat"><path d="M-42 139 L-18 141" stroke="#1d120c" stroke-width="4.6" stroke-linecap="round"/><path d="M18 141 L42 139" stroke="#1d120c" stroke-width="4.6" stroke-linecap="round"/><path d="M-39 141 Q-30 156 -21 141 Z" fill="#1d120c"/><path d="M21 141 Q30 156 39 141 Z" fill="#1d120c"/></g>` +
      `<g id="e-wink"><ellipse cx="-30" cy="144" rx="10" ry="13" fill="url(#gIris)" stroke="#120a06" stroke-width="1.5"/><circle cx="-26.5" cy="139" r="4" fill="#fff"/><path d="M18 148 Q30 134 42 148" fill="none" stroke="#1d120c" stroke-width="5" stroke-linecap="round"/></g>` +
      `<g id="e-sad"><ellipse cx="-30" cy="146" rx="9.5" ry="11.5" fill="url(#gIris)"/><ellipse cx="30" cy="146" rx="9.5" ry="11.5" fill="url(#gIris)"/><circle cx="-27" cy="142" r="3.6" fill="#fff"/><circle cx="33" cy="142" r="3.6" fill="#fff"/><path d="M-44 128 L-20 134 M20 134 L44 128" stroke="#1d120c" stroke-width="4" stroke-linecap="round"/><path d="M-20 156 q-3 7 0 11 q3 -4 0 -11" fill="#9fd4ff" opacity="0.9"/></g>`;
    const shades = `<g id="shades"><path d="M-80 134 L-62 132 M80 134 L62 132" stroke="#0b0b0d" stroke-width="5"/><rect x="-64" y="124" width="56" height="34" rx="13" fill="url(#gLens)" stroke="#000" stroke-width="3"/><rect x="8" y="124" width="56" height="34" rx="13" fill="url(#gLens)" stroke="#000" stroke-width="3"/><path d="M-8 133 Q0 127 8 133" stroke="#0c0c0f" stroke-width="5" fill="none"/><path d="M-54 150 L-38 128 M-46 152 L-34 135 M18 150 L34 128 M26 152 L38 135" stroke="rgba(255,255,255,0.28)" stroke-width="3"/></g>`;
    const mouths = `<path id="m-smile" d="M-11 175 Q0 185 11 175" fill="none" stroke="#2a1810" stroke-width="3.6" stroke-linecap="round"/>` +
      `<g id="m-open"><path d="M-14 172 Q0 170 14 172 Q12 192 0 193 Q-12 192 -14 172 Z" fill="#7a2620" stroke="#2a1810" stroke-width="3"/><ellipse cx="0" cy="187" rx="7" ry="4" fill="#ec7d72"/></g>` +
      `<g id="m-grin"><path d="M-20 170 Q0 168 20 170 Q16 192 0 193 Q-16 192 -20 170 Z" fill="#7a2620" stroke="#2a1810" stroke-width="3"/><path d="M-17 171 Q0 169 17 171 L15 176 Q0 175 -15 176 Z" fill="#fff"/><ellipse cx="0" cy="187" rx="9" ry="4" fill="#ec7d72"/></g>` +
      `<ellipse id="m-o" cx="0" cy="180" rx="6.5" ry="8" fill="#7a2620" stroke="#2a1810" stroke-width="3"/>` +
      `<path id="m-flat" d="M-9 179 L9 179" stroke="#2a1810" stroke-width="3.6" stroke-linecap="round"/>` +
      `<path id="m-wavy" d="M-12 179 q4 -4 8 0 q4 4 8 0 q4 -4 8 0" fill="none" stroke="#2a1810" stroke-width="3.4" stroke-linecap="round"/>`;
    return `<svg width="100%" height="100%" viewBox="-160 -20 320 430" overflow="visible">
<defs>
<filter id="stk" x="-30%" y="-30%" width="160%" height="160%"><feMorphology in="SourceAlpha" operator="dilate" radius="4.5" result="d"/><feFlood flood-color="#ffe6d4"/><feComposite in2="d" operator="in" result="rim"/>
<feMorphology in="SourceAlpha" operator="dilate" radius="7" result="d2"/><feGaussianBlur in="d2" stdDeviation="9" result="g"/><feFlood id="glowCol" flood-color="#ff8a45" flood-opacity="0.85"/><feComposite in2="g" operator="in" result="glow"/>
<feMerge><feMergeNode in="glow"/><feMergeNode in="rim"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
<radialGradient id="gSkin" cx="45%" cy="40%" r="70%"><stop offset="0" stop-color="#fdeadb"/><stop offset="0.7" stop-color="#f7d9c3"/><stop offset="1" stop-color="#ecc0a3"/></radialGradient>
<linearGradient id="gHair" x1="0.2" y1="0" x2="0.8" y2="1"><stop offset="0" stop-color="#3e2c22"/><stop offset="0.45" stop-color="#1f1612"/><stop offset="1" stop-color="#0e0907"/></linearGradient>
<linearGradient id="gTee" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#d9d0c4"/><stop offset="0.25" stop-color="#f6f0e6"/><stop offset="0.7" stop-color="#f4ede2"/><stop offset="1" stop-color="#cfc5b8"/></linearGradient>
<linearGradient id="gPant" x1="0" y1="0" x2="1" y2="0.3"><stop offset="0" stop-color="#1b1c21"/><stop offset="0.5" stop-color="#2d2e35"/><stop offset="1" stop-color="#18191d"/></linearGradient>
<linearGradient id="gShoe" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#d8d2ca"/></linearGradient>
<radialGradient id="gIris" cx="50%" cy="70%" r="70%"><stop offset="0" stop-color="#6b3d24"/><stop offset="0.55" stop-color="#2a170d"/><stop offset="1" stop-color="#120a06"/></radialGradient>
<linearGradient id="gLens" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#2a2a33"/><stop offset="0.5" stop-color="#0b0b0e"/><stop offset="1" stop-color="#18181f"/></linearGradient>
</defs>
<ellipse id="shadow" cx="0" cy="398" rx="78" ry="11" fill="rgba(0,0,0,0.55)"/>
<g id="body" filter="url(#stk)">
 <g id="legs">${leg('L')}${leg('R')}<path d="M-50 294 L50 294 L50 306 L-50 306 Z" fill="#202127" ${O}/></g>
 <g id="torso"><rect x="-13" y="192" width="26" height="22" fill="#e9bfa3" ${O}/><path d="M-46 212 C-30 203 30 203 46 212 L54 300 C30 307 -30 307 -54 300 Z" fill="${TEE}" ${O}/>
  <path d="M-30 280 Q0 290 30 280" stroke="rgba(120,100,80,0.25)" stroke-width="3" fill="none"/><path d="M-20 250 Q-14 262 -22 272" stroke="rgba(120,100,80,0.2)" stroke-width="2.5" fill="none"/>
  <path d="M-25 207 L0 240 L25 207 L16 205 L0 227 L-16 205 Z" fill="${OR}" stroke="#2a1810" stroke-width="2.4" stroke-linejoin="round"/>
  <path d="M-47 230 L-51 296 M47 230 L51 296" stroke="${OR}" stroke-width="5"/><path d="M-42 232 L-45 297 M42 232 L45 297" stroke="${COL.NAVY}" stroke-width="3.4"/>
  <rect x="16" y="240" width="17" height="19" rx="4" fill="${OR}" stroke="#2a1810" stroke-width="2.2"/><path d="M24.5 244.5 l1.4 4 4 1.4 -4 1.4 -1.4 4 -1.4 -4 -4 -1.4 4 -1.4z" fill="#fff"/></g>
 <g id="armFrontL" transform="translate(-45,218)">${arm('L')}</g><g id="armFrontR" transform="translate(45,218)">${arm('R')}</g>
 <g id="head">
  <path d="M-97 150 C-106 58 -60 16 0 14 C60 16 106 58 97 150 C94 174 86 186 76 190 L-76 190 C-86 186 -94 174 -97 150 Z" fill="${HR}" ${O}/>
  <ellipse cx="-80" cy="146" rx="10" ry="14" fill="#f3cdb5" ${O}/><ellipse cx="80" cy="146" rx="10" ry="14" fill="#f3cdb5" ${O}/>
  <ellipse cx="0" cy="134" rx="78" ry="71" fill="${SK}" ${O}/>
  <path d="M-74 112 Q0 140 74 112 L74 100 Q0 128 -74 100 Z" fill="rgba(200,130,100,0.28)"/>
  <g id="face"><ellipse id="blushL" cx="-47" cy="166" rx="15" ry="8.5" fill="#f59a9a" opacity="0.62"/><ellipse id="blushR" cx="47" cy="166" rx="15" ry="8.5" fill="#f59a9a" opacity="0.62"/>
   <g id="eyes">${eyes}</g><g id="mouth">${mouths}</g></g>
  <g id="hairF"><path d="M-88 124 C-94 60 -50 24 0 24 C50 24 94 60 88 124 L80 104 L72 124 L60 96 L46 118 L32 92 L17 114 L3 90 L-12 114 L-27 92 L-41 116 L-55 96 L-67 122 L-77 104 Z" fill="${HR}" ${O}/>
  <path d="M-88 118 C-95 152 -88 178 -76 194 L-71 160 Z M88 118 C95 152 88 178 76 194 L71 160 Z" fill="${HR}" ${O}/>
  <path d="M-62 60 Q-36 40 -4 40 M20 44 Q50 50 66 70" stroke="rgba(255,235,220,0.22)" stroke-width="6" fill="none" stroke-linecap="round"/>
  <path d="M-46 92 L-40 112 M-16 90 L-12 110 M26 90 L30 110" stroke="rgba(255,235,220,0.12)" stroke-width="3" fill="none" stroke-linecap="round"/></g>
  <g id="ahoge"><path d="M2 26 C6 4 24 -2 32 -12 C26 6 18 16 14 28 Z" fill="${HR}" ${O}/></g>
  <g id="shadesG">${shades}</g>
 </g>
</g></svg>`;
  };

  function boot() {
    const TL = window.TL, DIR = window.DIR || './';
    if (!TL) throw new Error('no timeline.js in ' + DIR + ' — run build-timeline.mjs first');
    if (typeof window.VIDEO !== 'function') throw new Error('no scenes.js in ' + DIR + ' (it must set window.VIDEO = E => { … })');
    const film = document.getElementById('film');

    // ---------- timeline lookups ----------
    const S = Object.fromEntries(TL.sections.map(s => [s.id, s]));
    const norm = s => s.toLowerCase().replace(/[^a-z0-9 ]/g, '');
    const K = (id, w, n = 0) => { if (!S[id]) throw new Error(`no section "${id}"`); let c = 0; const q = norm(w); for (const t of S[id].tokens) { const d = norm(t.d); if (d === q || d.startsWith(q + ' ') || d.startsWith(q)) { if (c === n) return t; c++; } } throw new Error(`no token "${w}" in ${id}`); };
    const T = (id, w, n) => K(id, w, n).t0;
    const TE = (id, w, n) => K(id, w, n).t1;
    const mid = (a, b) => +((S[a].end + S[b].start) / 2).toFixed(3);
    const CUES = []; const cue = (f, t, g = 0.08, len, off = 0) => CUES.push([f, +Math.max(0, t).toFixed(3), g, len ?? null, off]);
    const ALLTOK = TL.sections.flatMap(s => s.tokens);

    // ---------- assets ----------
    const IM = {}; const loads = [];
    const load = (src, opt) => { if (IM[src] !== undefined) return; IM[src] = null; loads.push(new Promise(res => { const i = new Image(); i.onload = () => i.decode().then(() => { IM[src] = i; res(); }, () => { IM[src] = i; res(); }); i.onerror = () => { if (!opt) console.error('missing ' + src); res(); }; i.src = src; })); };
    load(MARK, true);

    // ---------- layers: world (static stage) → cam (moves with the camera) → film overlays ----------
    const world = h('div', 'L'); film.appendChild(world);
    const L = (p = world) => { const e = h('div', 'L'); p.appendChild(e); return e; };
    const bg = L(), glow = L(), spotL = L();
    const cam = L(); cam.style.transformOrigin = '540px 1100px';
    const gridEl = h('canvas', 'L'); gridEl.width = W; gridEl.height = H; cam.appendChild(gridEl); const grid = gridEl.getContext('2d');
    const starEl = h('canvas', 'L'); starEl.width = W; starEl.height = H; cam.appendChild(starEl); const star = starEl.getContext('2d');
    const ghostL = L(cam); ghostL.dataset.free = '1';
    const backL = L(cam); // scene pieces behind the character
    const charL = L(cam);
    const sceneL = L(cam); // scene pieces in front of the character (cards)
    const vign = A(world, 0, 0, W, H, '', '', { background: 'radial-gradient(ellipse 85% 75% at 50% 52%, transparent 50%, rgba(0,0,0,0.72) 100%)' });
    const sweepEl = A(world, 0, 0, W, H, '', '', { mixBlendMode: 'screen', pointerEvents: 'none' });
    const chapL = L(film);
    const capL = h('div', 'L'); capL.id = 'cap'; film.appendChild(capL);
    const topL = L(film); // overlays over everything except the flash (end cards)
    const flashEl = A(film, 0, 0, W, H, '', '', { background: 'radial-gradient(ellipse at 50% 45%, #fff6ee, #ffd9bf)', opacity: 0, pointerEvents: 'none' });
    const STARS = []; { const r = rng(5); for (let i = 0; i < 110; i++) STARS.push({ x: r() * W, y: r() * H, s: 0.8 + r() * 2.2, v: 3 + r() * 9, a: 0.15 + r() * 0.55, ph: r() * 6.3, z: 0.4 + r() * 0.8 }); }
    const DUST = []; { const r = rng(9); for (let i = 0; i < 40; i++) DUST.push({ x: 380 + r() * 260, y: 700 + r() * 760, s: 1 + r() * 2.5, v: 6 + r() * 16, ph: r() * 6.3 }); }

    // the floor: a perspective grid that fades up into the dark (redrawn when its colour changes)
    let gridKey = '';
    const drawGrid = c => {
      const key = c.map(Math.round).join(','); if (key === gridKey) return; gridKey = key;
      grid.clearRect(0, 0, W, H); const hz = 1060, vx = 540;
      grid.lineWidth = 1.4;
      for (let i = -18; i <= 18; i++) { const g = grid.createLinearGradient(0, hz, 0, H); g.addColorStop(0, rgb(c, 0)); g.addColorStop(1, rgb(c, 0.2)); grid.strokeStyle = g; grid.beginPath(); grid.moveTo(vx + i * 14, hz); grid.lineTo(vx + i * 150, H); grid.stroke(); }
      for (let j = 1; j < 16; j++) { const y = hz + Math.pow(j / 15, 2.1) * (H - hz); grid.strokeStyle = rgb(c, 0.02 + 0.17 * j / 15); grid.beginPath(); grid.moveTo(0, y); grid.lineTo(W, y); grid.stroke(); }
    };

    // ---------- registries a video fills ----------
    const SCENES = [], CHAPS = [], ACTS = [], GHOSTS = [], FLASH = [], SHAKE = [], SWEEPS = [], CAMK = [{ t: -9, z: 1, x: 0, y: 0, r: 0, d: 0 }], PUNCH = [];
    let MUSIC = null, CAPTICK = 0.035; const CAPHIDE = [];
    const act = (kind, t0, t1, fade = 0.3) => { if (!['blue', 'red', 'spot', 'dark'].includes(kind)) throw new Error('act kind ' + kind); ACTS.push({ kind, t0, t1, fade }); };
    const theme = t => { const o = { blue: 0, red: 0, spot: 0, dark: 0 }; for (const a of ACTS) if (t >= a.t0 - a.fade && t < a.t1 + a.fade) { const k = Math.min(ease((t - a.t0 + a.fade) / Math.max(0.01, a.fade)), ease((a.t1 + a.fade - t) / Math.max(0.01, a.fade))); o[a.kind] = Math.max(o[a.kind], k); } return o; };
    // scene(t0, t1, build): build(front, back) gets two roots: front (over the character) and back (behind it)
    const scene = (t0, t1, build) => { const root = h('div', 'sc'); sceneL.appendChild(root); const rb = h('div', 'sc'); backL.appendChild(rb); const upd = build(root, rb); SCENES.push({ t0, t1, root, rb, upd }); };
    const center = (p, y, html, cls = '', style = {}) => { const row = A(p, SAFE.x0, y, SAFE.x1 - SAFE.x0, null, '', '', { display: 'flex', justifyContent: 'center' }); const e = h('div', cls, style, html); row.appendChild(e); return { row, e }; };
    // a dark-glass card; card.hot(k, colour) per frame lights its border
    const card = (p, x, y, w, hh, html = '', o = {}) => { const e = A(p, x, y, w, hh, 'gc', html, o.style || {}); e.hot = (k, c = OR) => { e.style.borderColor = k > 0.01 ? c : ''; e.style.boxShadow = k > 0.01 ? `0 24px 60px rgba(0,0,0,0.55), 0 0 ${(34 * k).toFixed(0)}px ${c}${Math.round(k * 130).toString(16).padStart(2, '0')}, inset 0 0 0 1px ${c}${Math.round(k * 200).toString(16).padStart(2, '0')}` : ''; }; if (o.hot) e.hot(1, o.hot === true ? OR : o.hot); return e; };
    const tag = (p, x, y, text, o = {}) => A(p, x, y, null, null, 'tg' + (o.hot ? ' hot' : ''), `<i></i>${text}`, o.style || {});
    // chapter(n, label, t0): the bar flips to it; o.sweep (default on, not for 1) runs a light sweep + whoosh on the change
    const chapter = (n, label, t0, o = {}) => { CHAPS.push({ n, label, t0 }); if (n > 1 && o.sweep !== false) sweep(t0 - 0.12, o.dir ?? (n % 2 ? 1 : -1)); };
    const ghost = (text, t0, t1, o = {}) => { const e = A(ghostL, 0, (o.y ?? SAFE.capBottom - 170), W, null, 'ghost', text, { textAlign: 'center', whiteSpace: 'nowrap' }); GHOSTS.push({ e, t0, t1, drift: o.drift ?? 34 }); };
    const flash = (t, k = 0.6, d = 0.22, hold = 0) => FLASH.push([t, k, d, hold]);
    const shake = (t, k = 14, d = 0.3) => SHAKE.push([t, k, d]);
    // sweep(t, dir): a diagonal light band crosses the frame, centred at t (0.6 s), with a whoosh
    const sweep = (t, dir = 1, k = 1) => { SWEEPS.push([t, dir, k]); cue('whoosh2', t - 0.25, 0.05 * k); };
    // camera: cam(t, { z, x, y, r }, d) eases to that framing over d seconds; punch(t, k) is a quick zoom kick
    const camTo = (t, o = {}, d = 0.6) => (CAMK.push({ t, z: o.z ?? 1, x: o.x ?? 0, y: o.y ?? 0, r: o.r ?? 0, d }), E);
    const punch = (t, k = 0.06, d = 0.28) => PUNCH.push([t, k, d]);

    // ---------- the character ----------
    const chEl = A(charL, 0, 0, 320 * 1.32, 430 * 1.32, '', chibiSVG(), { transformOrigin: '50% 95.3%' });
    const q = id => chEl.querySelector('#' + id);
    const CH = { body: q('body'), head: q('head'), face: q('face'), hairF: q('hairF'), ahoge: q('ahoge'), shadow: q('shadow'), shades: q('shadesG'), legs: { L: q('legL'), R: q('legR') }, blush: [q('blushL'), q('blushR')], eyes: Object.fromEntries(EYES.filter(e => e !== 'shades').map(e => [e, q('e-' + e)])), mouth: { smile: q('m-smile'), open: q('m-open'), grin: q('m-grin'), o: q('m-o'), flat: q('m-flat'), wavy: q('m-wavy') }, arm: { L: q('armFrontL'), R: q('armFrontR') }, fore: { L: q('foreL'), R: q('foreR') }, fin: { L: q('finL'), R: q('finR') }, glowCol: q('glowCol') };
    const CK = { at: [{ t: -9, x: SAFE.cx, y: 1470, s: 1, d: 0 }], pose: [{ t: -9, p: 'idle' }], eyes: [{ t: -9, e: 'open' }], shades: [{ t: -9, m: 'off', d: 0 }], mouth: [], hop: [], look: [{ t: -9, r: 0, x: 0 }], blush: [{ t: -9, k: 0.62 }] };
    const chibi = {
      // at(t, x, y, scale, d): walk/slide there over d s (legs swing + footsteps when the move is sideways)
      at: (t, x, y = 1470, s = 1, d = 0.5, o = {}) => (CK.at.push({ t, x, y, s, d, walk: o.walk ?? null }), chibi),
      pose: (t, p) => { if (!POSES[p]) throw new Error('pose ' + p); CK.pose.push({ t, p }); return chibi; },
      eyes: (t, e) => { if (!EYES.includes(e) || e === 'shades') throw new Error('eyes ' + e); CK.eyes.push({ t, e }); return chibi; },
      // shades(t, 'drop' | 'throw' | 'on' | 'off'): drop = fall onto the face with a bounce; throw = flung off spinning
      shades: (t, m) => { if (m === true) m = 'on'; if (m === false) m = 'off'; CK.shades.push({ t, m }); if (m === 'throw') cue('throw', t - 0.05, 0.06); if (m === 'drop') { cue('swdown', t - 0.1, 0.04); cue('click', t + 0.3, 0.05); } return chibi; },
      mouth: (t0, t1, m) => (CK.mouth.push({ t: t0, t0, t1, m }), chibi),
      hop: (t, k = 1, snd = true) => { CK.hop.push({ t, k }); if (snd) cue('boing', t - 0.02, 0.035 * Math.min(1.4, k)); return chibi; },
      tilt: (t, r) => (CK.look.push({ t, r, x: lastBefore(CK.look, t)[0].x }), chibi),
      look: (t, x, r) => (CK.look.push({ t, x, r: r ?? lastBefore(CK.look, t)[0].r }), chibi),
      blush: (t, k) => (CK.blush.push({ t, k }), chibi),
    };
    function lastBefore(arr, t) { let a = arr[0], i0 = 0; arr.forEach((k, i) => { if (k.t <= t) { a = k; i0 = i; } }); return [a, arr[i0 - 1] || a, i0]; }
    const sortCK = () => Object.values(CK).forEach(a => a.sort((x, y) => x.t - y.t));
    const charPos = t => { const [a, pa] = lastBefore(CK.at, t), k = a.d ? eio((t - a.t) / a.d) : 1; return { x: lerp(pa.x, a.x, k), y: lerp(pa.y, a.y, k), s: lerp(pa.s, a.s, k), moving: a.d && t > a.t && t < a.t + a.d ? a : null, k, pa, a }; };
    const updChibi = t => {
      const P0 = charPos(t), sc = P0.s * 1.32;
      // walking: a step every 0.2 s while a sideways move runs (legs swing, body bobs)
      let walk = 0, stepBob = 0;
      if (P0.moving && Math.abs(P0.a.x - P0.pa.x) > 30 && P0.a.walk !== false) { const u = t - P0.a.t, fade = Math.sin(PI * clamp(u / P0.a.d)); walk = Math.sin(u * PI / 0.2) * 16 * Math.min(1, fade * 2.5); stepBob = -Math.abs(Math.sin(u * PI / 0.2)) * 14 * Math.min(1, fade * 2.5); }
      let hopY = 0, squash = 0;
      for (const hp of CK.hop) { const u = (t - hp.t) / 0.4; if (u >= -0.15 && u < 0) squash = Math.max(squash, 0.08 * hp.k * (1 + u / 0.15) * 0); if (u >= -0.12 && u < 0) squash = Math.max(squash, 0.07 * hp.k * (u + 0.12) / 0.12); if (u >= 0 && u < 1) { hopY -= Math.sin(u * PI) * 46 * hp.k; squash = Math.min(squash, -0.06 * hp.k * Math.sin(u * PI)); } if (u >= 1 && u < 1.45) squash = Math.max(squash, 0.09 * hp.k * Math.sin((u - 1) / 0.45 * PI)); }
      const bob = Math.sin(t * 2 * PI / 1.6) * 3.2, breathe = 1 + Math.sin(t * 2 * PI / 1.6 + 0.6) * 0.01;
      const sy = breathe * (1 - squash), sx = (1 / breathe) * (1 + squash * 0.8);
      const wpx = 320 * sc, hpx = 430 * sc;
      chEl.style.width = px(wpx); chEl.style.height = px(hpx);
      chEl.style.left = px(P0.x - wpx / 2); chEl.style.top = px(P0.y - hpx * (410 / 430));
      const lean = P0.moving ? Math.sign(P0.a.x - P0.pa.x) * 4 * Math.sin(PI * clamp((t - P0.a.t) / P0.a.d)) : 0;
      chEl.style.transform = `translateY(${(hopY + bob + stepBob).toFixed(1)}px) rotate(${lean.toFixed(2)}deg) scale(${sx.toFixed(4)}, ${sy.toFixed(4)})`;
      CH.shadow.setAttribute('transform', `translate(0 ${((-hopY - bob - stepBob) / sc).toFixed(1)}) translate(0 398) scale(${(1 + hopY / 260).toFixed(3)} 1) translate(0 -398)`);
      CH.legs.L.setAttribute('transform', `rotate(${walk.toFixed(2)} -26 300)`); CH.legs.R.setAttribute('transform', `rotate(${(-walk).toFixed(2)} 26 300)`);
      // pose: blend from the previous pose with a little overshoot
      const [p, pp] = lastBefore(CK.pose, t), kp = back(clamp((t - p.t) / 0.28), 1.25);
      const P = POSES[p.p];
      for (const s of ['L', 'R']) {
        const A0 = POSES[pp.p][s], A1 = P[s];
        let a1 = lerp(A0[0], A1[0], kp), a2 = lerp(A0[1], A1[1], kp);
        if (P.wig && P.wig.includes(s)) a2 += Math.sin(t * 2 * PI * 2.6 + (s === 'L' ? 1.4 : 0)) * 22 * clamp((t - p.t) / 0.2);
        if (P.wag === s) a1 += Math.sin(t * 2 * PI * 3.2) * 10 * clamp((t - p.t) / 0.2);
        a1 += Math.sin(t * 2 * PI / 1.6 + (s === 'L' ? 0 : 1)) * 2.2;
        if (P0.moving && p.p === 'idle') a1 += (s === 'L' ? 1 : -1) * walk * 0.9;
        const sg = s === 'R' ? -1 : 1;
        CH.arm[s].setAttribute('transform', `translate(${s === 'R' ? 45 : -45},218) rotate(${(sg * a1).toFixed(2)})`);
        CH.fore[s].setAttribute('transform', `translate(0,42) rotate(${(sg * a2).toFixed(2)})`);
        CH.fin[s].style.display = P.fin === s && kp > 0.5 ? 'block' : 'none';
      }
      // head: tilt + look + nod on the spoken words; the face slides toward where he looks; the ahoge lags behind
      const [lk, plk] = lastBefore(CK.look, t), kl = eio((t - lk.t) / 0.3);
      const tilt = lerp(plk.r, lk.r, kl), lx = lerp(plk.x, lk.x, kl);
      const talking = ALLTOK.find(k => t >= k.t0 && t < k.t1);
      const nod = talking ? Math.sin((t - talking.t0) * PI * 3.4) * 1.8 : 0;
      const headR = tilt + Math.sin(t * 2 * PI / 3.1) * 1.6 + nod + lean * 0.5;
      CH.head.setAttribute('transform', `rotate(${headR.toFixed(2)} 0 200) translate(0 ${(-hopY * 0.04).toFixed(1)})`);
      CH.face.setAttribute('transform', `translate(${(lx * 9).toFixed(1)} ${(Math.abs(lx) * 1.5).toFixed(1)})`);
      CH.hairF.setAttribute('transform', `translate(${(lx * 3).toFixed(1)} 0)`);
      const sway = Math.sin(t * 2 * PI * 0.9) * 7 - headR * 1.6 + (hopY < -2 ? 14 : 0) + walk * 0.6;
      CH.ahoge.setAttribute('transform', `rotate(${sway.toFixed(2)} 8 28)`);
      const [bl, pbl] = lastBefore(CK.blush, t); const bk = lerp(pbl.k, bl.k, clamp((t - bl.t) / 0.3)); CH.blush.forEach(b => b.setAttribute('opacity', bk.toFixed(2)));
      // eyes + blink (+ a double blink now and then)
      const [ey] = lastBefore(CK.eyes, t); const bp = (t + 0.7) % 3.3, blink = (ey.e === 'open' || ey.e === 'wide' || ey.e === 'sad') && (bp < 0.12 || (Math.floor((t + 0.7) / 3.3) % 3 === 1 && bp > 0.22 && bp < 0.32)) && t - ey.t > 0.3;
      const eyeIn = back(clamp((t - ey.t) / 0.16), 2);
      for (const [k, el] of Object.entries(CH.eyes)) { el.style.display = k === ey.e ? 'inline' : 'none'; if (k === ey.e) el.setAttribute('transform', blink ? 'translate(0 127) scale(1 0.12)' : `translate(0 ${(144 * (1 - eyeIn)).toFixed(1)}) scale(1 ${eyeIn.toFixed(3)})`); }
      // shades: drop (fall + bounce onto the face) · throw (flung up and away, spinning) · on/off (instant)
      const [sh] = lastBefore(CK.shades, t), u = t - sh.t;
      let sTf = 'translate(0 -900)', covered = false;
      if (sh.m === 'on') { sTf = ''; covered = true; }
      else if (sh.m === 'drop') { const k = clamp(u / 0.42); const yy = k < 1 ? lerp(-330, 0, eo(k)) + (k > 0.6 ? -Math.sin((k - 0.6) / 0.4 * PI) * 14 : 0) : 0; sTf = `translate(0 ${yy.toFixed(1)}) rotate(${((1 - eo(k)) * -18).toFixed(1)} 0 140)`; covered = k > 0.75; }
      else if (sh.m === 'throw') { const k = clamp(u / 0.75); sTf = k >= 1 ? 'translate(0 -900)' : `translate(${(eo(k) * 210).toFixed(1)} ${(-k * 900 + Math.sin(k * PI) * -60).toFixed(1)}) rotate(${(k * 560).toFixed(1)} 0 140)`; covered = k < 0.04; }
      CH.shades.setAttribute('transform', sTf); CH.shades.style.display = sTf === 'translate(0 -900)' ? 'none' : 'inline';
      if (covered) for (const el of Object.values(CH.eyes)) el.style.display = 'none';
      // mouth: an override, else lip flaps on the spoken words, else a smile (grin while happy)
      const mo = CK.mouth.find(m => t >= m.t0 && t < m.t1);
      let m = ey.e === 'happy' ? 'grin' : 'smile', open = 0;
      if (mo) m = mo.m;
      else if (talking) { const uu = (t - talking.t0) / Math.max(0.08, talking.t1 - talking.t0); open = Math.abs(Math.sin(uu * PI * Math.max(1, Math.round((talking.t1 - talking.t0) / 0.15)))); m = open > 0.3 ? (ey.e === 'happy' ? 'grin' : 'open') : 'o'; }
      else if (ey.e === 'happy') m = 'smile';
      for (const [k, el] of Object.entries(CH.mouth)) el.style.display = k === m ? 'inline' : 'none';
      if (m === 'open' || m === 'grin') { const el = CH.mouth[m]; el.setAttribute('transform', `translate(0 171) scale(${(0.85 + 0.25 * open).toFixed(3)} ${(mo ? 1 : 0.45 + 0.6 * open).toFixed(3)}) translate(0 -171)`); }
    };

    // ---------- the video ----------
    const E = {
      W, H, PI, SAFE, COL, ...COL, TL, S, K, T, TE, mid, fmt, DIR, ICON, MARK, POSES,
      clamp, lerp, ease, eo, ei, eio, back, elastic, rng, px, hex, mixA, rgb, h, A, st, show, typed, caretOn, hover,
      IM, load, cue, scene, center, card, tag, chapter, ghost, act, flash, shake, sweep, cam: camTo, punch, chibi, charPos, topL,
      capTicks: g => { CAPTICK = g; }, hideCaptions: (a, b) => CAPHIDE.push([a, b]),
      music: plan => { MUSIC = plan; },
      stats: { edits: 0 }, title: document.title, coverAt: null,
    };
    window.VIDEO(E);
    sortCK(); CHAPS.sort((a, b) => a.t0 - b.t0); CAMK.sort((a, b) => a.t - b.t);
    const NCH = Math.max(...CHAPS.map(c => c.n), 1);
    // automatic sounds: a soft tick each time the caption pill lands on a word; footsteps on every walk
    if (CAPTICK > 0) ALLTOK.forEach((k, i) => { if (!k.key) cue('tick' + (1 + (i % 3)), k.t0 - 0.01, CAPTICK); });
    for (const a of CK.at) { const i = CK.at.indexOf(a), pa = CK.at[i - 1]; if (!pa || !a.d || a.walk === false || Math.abs(a.x - pa.x) <= 30) continue; for (let u = 0.1, n = 0; u < a.d - 0.05; u += 0.2, n++) cue('step' + (1 + n % 2), a.t + u, 0.05); }

    // ---------- chapter bar ("● 03 / 11 — PRICE" + progress line) ----------
    const chapRow = A(chapL, SAFE.x0 + 6, SAFE.chapY, 880, null, 'mono', '', { fontSize: '22px', color: COL.MUTED, letterSpacing: '0.12em', display: 'flex', alignItems: 'center', gap: '12px' });
    const chDot = h('i', '', { display: 'block', width: '10px', height: '10px', borderRadius: '50%', background: OR, boxShadow: `0 0 12px ${OR}` });
    const chNum = h('b', '', { color: COL.INK, display: 'inline-block' }); const chLab = h('span');
    chapRow.append(chDot, chNum, chLab);
    const chLine = A(chapL, SAFE.x0 + 6, SAFE.chapY + 42, 880, 2, '', '', { background: 'rgba(255,255,255,0.1)' });
    const chFill = A(chLine, 0, 0, 0, 2, '', '', { background: `linear-gradient(90deg, ${OR}, #ffb37a)`, boxShadow: `0 0 10px ${OR}` });
    const chHead = A(chLine, 0, -3, 8, 8, '', '', { borderRadius: '50%', background: '#ffd2b0', boxShadow: `0 0 14px ${OR}` });
    const GL = '█▓▒░#%&*/\\<>';
    const updChapter = t => {
      let c = null, pc = null; for (const x of CHAPS) if (t >= x.t0 - 0.05) { pc = c; c = x; }
      if (!c) { chapRow.style.opacity = 0; chLine.style.opacity = 0; return; }
      chapRow.style.opacity = chLine.style.opacity = 1;
      const pad = String(c.n).padStart(2, '0'), ofs = String(NCH).padStart(2, '0'), u = t - c.t0;
      chNum.textContent = pad; chNum.style.transform = `translateY(${(u < 0.2 ? (1 - eo(u / 0.2)) * -14 : 0).toFixed(1)}px)`;
      const lab = ` / ${ofs} — ${c.label}`, k = clamp(u / 0.35), n = Math.round(lab.length * k);
      chLab.textContent = lab.slice(0, n) + (k < 1 ? [...Array(Math.min(3, lab.length - n))].map((_, i) => GL[(Math.floor(t * 40) + i) % GL.length]).join('') : '');
      const w0 = pc ? pc.n / NCH : 0, w1 = c.n / NCH, fw = 880 * lerp(w0, w1, eo(u / 0.6));
      chFill.style.width = px(fw); chHead.style.left = px(fw - 4); chHead.style.opacity = (0.6 + 0.4 * Math.sin(t * 6)).toFixed(2);
      chDot.style.opacity = (0.6 + 0.4 * Math.sin(t * 5)).toFixed(2);
    };

    // ---------- captions: mid-screen chunks; the pill hops word to word, key words orange serif italic ----------
    const CHK = [];
    TL.sections.forEach(s => { let cur = null; s.tokens.forEach(k => { if (k.br || !cur) { cur = { toks: [], sec: s.id }; CHK.push(cur); } cur.toks.push(k); }); });
    CHK.forEach(c => { c.t0 = c.toks[0].t0 - 0.05; }); CHK.forEach((c, i) => { c.t1 = i + 1 < CHK.length ? Math.min(CHK[i + 1].t0, c.toks.at(-1).t1 + 0.9) : c.toks.at(-1).t1 + 1.0; });
    const capRow = A(capL, SAFE.x0, 0, SAFE.x1 - SAFE.x0, null, '', '', { textAlign: 'center' });
    const capLine = h('div', '', { position: 'relative', display: 'inline-block', fontSize: '70px', fontWeight: 800, letterSpacing: '-0.015em', lineHeight: '1.3', whiteSpace: 'nowrap', transformOrigin: '50% 100%' });
    capRow.appendChild(capLine);
    const capBox = h('div', 'box'); capLine.appendChild(capBox);
    const capOn = h('div', '', { position: 'absolute', left: 0, top: 0, whiteSpace: 'nowrap', zIndex: 3, color: '#fff' }); capLine.appendChild(capOn);
    let capIdx = -1, capWords = [], capOnW = [], capNatW = 0;
    const drawCaptions = (t, th) => {
      let ci = -1; for (let i = 0; i < CHK.length; i++) if (t >= CHK[i].t0 && t < CHK[i].t1) { ci = i; break; }
      if (ci < 0 || CAPHIDE.some(([a, b]) => t >= a && t < b)) { capRow.style.display = 'none'; return; }
      capRow.style.display = 'block';
      const c = CHK[ci];
      const mk = k => { const e = h('span', 'cw' + (k.key ? ' serif' : ''), {}, k.d); if (k.key) { e.style.fontSize = '1.2em'; e.style.lineHeight = '1.0'; } return e; };
      if (ci !== capIdx) { capIdx = ci; capWords.forEach(e => e.remove()); capOnW.forEach(e => e.remove()); capWords = c.toks.map(k => { const e = mk(k); capLine.insertBefore(e, capOn); return e; }); capOnW = c.toks.map(k => { const e = mk(k); capOn.appendChild(e); return e; }); capNatW = capLine.offsetWidth; }
      const sc = Math.min(1, SAFE.capW / Math.max(1, capNatW));
      capRow.style.top = px(SAFE.capBottom - capLine.offsetHeight);
      const ent = eo((t - c.t0) / 0.16), out = c.t1 - t < 0.12 ? clamp((c.t1 - t) / 0.12) : 1;
      capLine.style.transform = `translateY(${((1 - ent) * 22).toFixed(1)}px) scale(${(sc * lerp(0.94, 1, ent)).toFixed(3)})`;
      let a_ = -1; c.toks.forEach((k, i) => { if (t >= k.t0 - 0.02) a_ = i; });
      const accent = th.blue > 0.5 ? '#ff9a5c' : OR;
      capWords.forEach((e, i) => { const k = c.toks[i]; const pop = i <= a_ ? eo((t - k.t0 + 0.02) / 0.18) : 0; e.style.color = i <= a_ ? (k.key ? accent : COL.INK) : COL.DIM; e.style.textShadow = i <= a_ && k.key ? `0 0 28px ${accent}99` : '0 4px 18px rgba(0,0,0,0.5)'; e.style.transform = i === a_ || (i < a_ && pop < 1) ? `translateY(${((1 - pop) * 14).toFixed(1)}px) scale(${lerp(k.key ? 1.25 : 1.12, 1, back(pop, 2)).toFixed(3)})` : ''; e.style.filter = i > a_ ? 'blur(0.7px)' : 'none'; });
      capOnW.forEach((e, i) => { e.style.transform = capWords[i].style.transform; });
      capLine.style.opacity = (ent * out).toFixed(3); capLine.style.filter = ent < 0.95 ? `blur(${((1 - ent) * 6).toFixed(1)}px)` : 'none';
      // the pill: on a spoken non-key word; it slides from the previous word
      const k = a_ >= 0 ? c.toks[a_] : null, inWord = k && t < k.t1 + 0.14;
      if (!k || k.key || !inWord) { capBox.style.opacity = 0; capOn.style.display = 'none'; return; }
      capOn.style.display = 'block';
      const r = i => ({ l: capWords[i].offsetLeft, w: capWords[i].offsetWidth });
      const cur = r(a_), prv = a_ > 0 && !c.toks[a_ - 1].key ? r(a_ - 1) : cur, u = eo((t - k.t0 + 0.02) / 0.11);
      const L1 = lerp(prv.l, cur.l, eo(u * 0.9)) + 4, R1 = lerp(prv.l + prv.w, cur.l + cur.w, eo(u * 1.3)) - 4, cy = prv === cur ? lerp(0.6, 1, back(u, 2)) : 1;
      capBox.style.opacity = Math.min(1, u * 3).toFixed(3); capBox.style.background = accent; capBox.style.boxShadow = `0 0 24px ${accent}aa`;
      capBox.style.left = px(L1); capBox.style.width = px(Math.max(10, R1 - L1)); capBox.style.top = '10px'; capBox.style.height = 'calc(100% - 16px)';
      capBox.style.transform = capWords[a_].style.transform + ` scaleY(${cy.toFixed(3)})`;
      capOn.style.clipPath = `inset(10px ${(capLine.offsetWidth - R1).toFixed(1)}px 6px ${L1.toFixed(1)}px round 10px)`; capOn.style.height = px(capLine.offsetHeight);
    };

    // ---------- one frame ----------
    E.stats.edits = SCENES.length + CHAPS.length + CUES.length + CK.pose.length + CK.eyes.length;
    const C = { warm: hex('#0b0705'), blue: hex('#060912'), red: hex('#100405'), spot: hex('#040302'), dark: hex('#030202') };
    const G = { warm: [214, 96, 40], blue: [52, 82, 190], red: [200, 36, 36], spot: [230, 120, 60] };
    const setT = t => {
      const th = theme(t);
      let c = C.warm; c = mixA(c, C.blue, th.blue); c = mixA(c, C.red, th.red); c = mixA(c, C.spot, th.spot); c = mixA(c, C.dark, th.dark);
      bg.style.background = rgb(c);
      let g = G.warm; g = mixA(g, G.blue, th.blue); g = mixA(g, G.red, th.red);
      const ga = (0.6 + 0.07 * Math.sin(t * 1.3)) * (1 - th.spot * 0.85) * (1 - th.dark * 0.6);
      const P0 = charPos(t);
      glow.style.background = `radial-gradient(${(560 * P0.s).toFixed(0)}px ${(700 * P0.s).toFixed(0)}px at ${P0.x.toFixed(0)}px ${(P0.y - 300 * P0.s).toFixed(0)}px, ${rgb(g, ga)}, transparent 72%), radial-gradient(900px 600px at 50% 36%, ${rgb(g, ga * 0.3)}, transparent 75%), radial-gradient(700px 300px at ${P0.x.toFixed(0)}px 1500px, ${rgb(g, ga * 0.35)}, transparent 70%)`;
      CH.glowCol.setAttribute('flood-color', th.blue > 0.5 ? '#ff9a5c' : th.red > 0.5 ? '#ff6a4a' : '#ff8a45');
      // spotlight (act 'spot'): a cone from above with dust in it
      spotL.style.opacity = th.spot.toFixed(3);
      if (!spotL.dataset.on) { spotL.dataset.on = 1; const pool = A(spotL, 0, 0, W, H, '', '', { background: 'radial-gradient(300px 80px at 510px 1468px, rgba(255,170,100,0.6), transparent 70%)' }); const cone = A(spotL, 300, 560, 420, 940, '', '', { background: 'linear-gradient(180deg, rgba(255,220,185,0.5), rgba(255,160,90,0.14) 70%, transparent)', clipPath: 'polygon(40% 0, 60% 0, 100% 100%, 0 100%)', filter: 'blur(12px)' }); spotL.dust = Array.from({ length: DUST.length }, () => A(spotL, 0, 0, 4, 4, '', '', { borderRadius: '50%', background: '#ffe6cc' })); }
      if (th.spot > 0.01) DUST.forEach((d, i) => { const y = ((d.y - t * d.v) % 780 + 780) % 780 + 700, half = 0.1 + (y - 560) / 940 * 0.4, x = 510 + (d.x - 510) * half * 2.3; st(spotL.dust[i], (0.25 + 0.5 * Math.sin(t * 2 + d.ph) ** 2), `translate(${x.toFixed(0)}px, ${y.toFixed(0)}px) scale(${d.s.toFixed(2)})`); });
      drawGrid(mixA(mixA([255, 140, 80], [120, 150, 255], th.blue), [255, 80, 70], th.red));
      gridEl.style.opacity = (1 - th.spot * 0.7).toFixed(3);
      star.clearRect(0, 0, W, H);
      for (const s of STARS) { const y = ((s.y - t * s.v) % H + H) % H, a = s.a * (0.5 + 0.5 * Math.sin(t * 1.7 + s.ph)); star.fillStyle = `rgba(255,236,220,${a.toFixed(3)})`; star.beginPath(); star.arc(s.x, y, s.s, 0, PI * 2); star.fill(); }
      // camera: keyframed framing + a slow push that restarts each chapter + a handheld drift + punches + shake
      const [ck, pck] = lastBefore(CAMK, t), kc = ck.d ? eio((t - ck.t) / ck.d) : 1;
      let z = lerp(pck.z, ck.z, kc), cx = lerp(pck.x, ck.x, kc), cy = lerp(pck.y, ck.y, kc), cr = lerp(pck.r, ck.r, kc);
      let chp = null, nxt = null; for (const x of CHAPS) { if (t >= x.t0) chp = x; else if (!nxt) nxt = x; }
      if (chp) { const len = (nxt ? nxt.t0 : TL.total) - chp.t0; z *= 1 + 0.045 * ease((t - chp.t0) / Math.max(0.5, len)); }
      for (const [a, k, d] of PUNCH) { const u = (t - a) / d; if (u >= 0 && u < 1) z *= 1 + k * Math.sin(PI * Math.pow(u, 0.6)) * (1 - u); }
      cx += Math.sin(t * 0.37) * 6; cy += Math.cos(t * 0.29) * 5; cr += Math.sin(t * 0.23) * 0.25;
      let sk = 0; for (const [a, k, d] of SHAKE) { const u = (t - a) / d; if (u >= 0 && u < 1) sk = Math.max(sk, k * (1 - u)); }
      if (sk > 0.3) { cx += Math.sin(t * 97) * sk; cy += Math.cos(t * 83) * sk * 0.6; }
      cam.style.transform = `translate(${cx.toFixed(1)}px, ${cy.toFixed(1)}px) rotate(${cr.toFixed(3)}deg) scale(${z.toFixed(4)})`;
      for (const gh of GHOSTS) { const on = t >= gh.t0 - 0.2 && t < gh.t1 + 0.3; gh.e.style.display = on ? 'block' : 'none'; if (on) { const k = Math.min(eo((t - gh.t0 + 0.2) / 0.4), clamp((gh.t1 + 0.3 - t) / 0.3)); gh.e.style.opacity = (k * 0.95).toFixed(3); gh.e.style.transform = `translateX(${((t - gh.t0) * -gh.drift + 50).toFixed(1)}px) scale(${lerp(1.08, 1, eo((t - gh.t0 + 0.2) / 0.6)).toFixed(3)})`; } }
      for (const s of SCENES) { const on = t >= s.t0 && t < s.t1; s.root.style.display = s.rb.style.display = on ? 'block' : 'none'; if (on) s.upd && s.upd(t); }
      updChibi(t);
      updChapter(t);
      drawCaptions(t, th);
      // light sweep
      let sw = null; for (const x of SWEEPS) if (t >= x[0] - 0.35 && t < x[0] + 0.35) sw = x;
      if (sw) { const u = (t - sw[0] + 0.35) / 0.7, p = lerp(-40, 140, eio(u)), d = sw[1]; sweepEl.style.opacity = (Math.sin(PI * u) * sw[2]).toFixed(3); sweepEl.style.background = `linear-gradient(${d > 0 ? 115 : 65}deg, transparent ${(p - 22).toFixed(1)}%, rgba(255,190,140,0.08) ${(p - 12).toFixed(1)}%, rgba(255,226,200,0.42) ${p.toFixed(1)}%, rgba(255,190,140,0.08) ${(p + 12).toFixed(1)}%, transparent ${(p + 22).toFixed(1)}%)`; } else sweepEl.style.opacity = 0;
      let fl = 0; for (const [a, k, d, hold] of FLASH) { const u = (t - a); if (u >= -0.06 && u < 0) fl = Math.max(fl, k * (1 + u / 0.06)); else if (u >= 0 && u < hold) fl = Math.max(fl, k); else if (u >= hold && u < hold + d) fl = Math.max(fl, k * (1 - eo((u - hold) / d))); }
      flashEl.style.opacity = fl.toFixed(3);
      if (safeL) safeMark();
    };

    // ---------- safe-zone overlay (?safe=1) + text check ----------
    let safeL = null, safeBad = null;
    if (/[?&]safe=1/.test(location.search)) {
      safeL = h('div', 'L', { pointerEvents: 'none', zIndex: 50 }); film.appendChild(safeL);
      const band = (x, y, w_, hh, bgc) => A(safeL, x, y, w_, hh, '', '', { background: bgc });
      band(0, 0, W, SAFE.y0, 'rgba(229,72,77,0.28)'); band(0, SAFE.y1, W, H - SAFE.y1, 'rgba(229,72,77,0.28)'); band(SAFE.x1, SAFE.y0, W - SAFE.x1, SAFE.y1 - SAFE.y0, 'rgba(229,72,77,0.28)'); band(0, SAFE.y0, SAFE.x0, SAFE.y1 - SAFE.y0, 'rgba(229,72,77,0.12)');
      A(safeL, SAFE.x0, SAFE.y0, SAFE.x1 - SAFE.x0, SAFE.y1 - SAFE.y0, '', '', { outline: '3px dashed rgba(31,163,91,0.95)' });
      A(safeL, SAFE.cx - 1, SAFE.y0, 2, SAFE.y1 - SAFE.y0, '', '', { background: 'rgba(22,98,214,0.45)' });
      safeBad = A(safeL, 0, 0, W, H);
    }
    const safeReport = () => {
      const bad = [], tw = document.createTreeWalker(film, NodeFilter.SHOW_TEXT), tol = 2;
      const visible = el => { let op = 1; for (let e = el; e && e !== film; e = e.parentElement) { if (e.dataset && e.dataset.free) return false; if (safeL && e === safeL) return false; const cs = getComputedStyle(e); if (cs.display === 'none' || cs.visibility === 'hidden') return false; op *= +cs.opacity; } return op > 0.15; };
      for (let n; (n = tw.nextNode());) {
        if (!n.textContent.trim() || !visible(n.parentElement)) continue;
        const rg = document.createRange(); rg.selectNodeContents(n);
        for (const b of rg.getClientRects()) {
          if (b.width < 2 || b.height < 2) continue;
          if (b.left < SAFE.x0 - tol || b.right > SAFE.x1 + tol || b.top < SAFE.y0 - tol || b.bottom > SAFE.y1 + tol) bad.push({ text: n.textContent.trim().slice(0, 40), x0: Math.round(b.left), y0: Math.round(b.top), x1: Math.round(b.right), y1: Math.round(b.bottom) });
        }
      }
      return bad;
    };
    const safeMark = () => { safeBad.innerHTML = ''; for (const b of safeReport()) A(safeBad, b.x0, b.y0, b.x1 - b.x0, b.y1 - b.y0, '', '', { outline: '4px solid #ff0040', background: 'rgba(255,0,64,0.15)' }); };

    window.setT = setT; window.safeReport = safeReport;
    window.CUES = CUES; window.TOTAL = TL.total; window.PREPASS = { next: null, droste: null, sb: [] };
    window.META = { title: E.title, total: TL.total, coverAt: E.coverAt ?? Math.min(2.5, TL.total / 2), music: MUSIC, wipes: [], zooms: [], sections: TL.sections.map(s => [s.id, s.start, s.end]) };
    const fonts = ['800 60px "Inter Tight"', '900 60px "Inter Tight"', 'italic 100px "Bodoni MT Condensed"', 'italic 100px "Bodoni MT"', '700 24px Consolas'];
    window.ready = Promise.all([...fonts.map(f => document.fonts.load(f)), ...loads]).then(() => document.fonts.ready).then(() => { setT(0); return true; });
  }

  window.LUI = { boot };
})();
```

### `lib.mjs`

```js
// Chibi · lib.mjs — paths and helpers every tool shares. Everything lives in the project folder (this file's folder):
// fonts/, sfx/, music/, cdp.mjs, .env, and one folder per video (--dir my-video).
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';

export const ROOT = path.dirname(fileURLToPath(import.meta.url));
export const SHARED = ROOT; // music/ and sfx/ live in the project
export const CDP = path.join(ROOT, 'cdp.mjs');
export const ENV_FILE = path.join(ROOT, '.env');
export const FF = process.env.FFMPEG || 'ffmpeg';
export const FP = process.env.FFPROBE || 'ffprobe';
export const PY = process.env.PYTHON || (process.platform === 'win32' ? 'python' : 'python3');
export const FPS = 30;

export const arg = (k, d) => { const i = process.argv.indexOf(k); return i > 0 && i + 1 < process.argv.length ? process.argv[i + 1] : d; };
export const has = k => process.argv.includes(k);
// the video's folder: --dir <folder> (default: the current folder), e.g. --dir my-video
export const DIR = path.resolve(arg('--dir', '.'));
export const V = (...p) => path.join(DIR, ...p);
export const low = () => { try { os.setPriority(os.constants.priority.PRIORITY_LOW); } catch { } };

export function need(file, hint) { if (!fs.existsSync(file)) { console.error(`missing ${file}${hint ? ' — ' + hint : ''}`); process.exit(1); } }
export async function loadScript() {
  need(V('script.mjs'), 'write script.mjs in the video folder (--dir my-video); Appendix B of SKILL.md is a complete example');
  return import(pathToFileURL(V('script.mjs')).href + '?t=' + Date.now());
}
export function readEnv() { // never print it
  return Object.fromEntries((fs.existsSync(ENV_FILE) ? fs.readFileSync(ENV_FILE, 'utf8') : '').split(/\r?\n/).filter(l => /^[A-Z_]+=/.test(l)).map(l => { const i = l.indexOf('='); return [l.slice(0, i), l.slice(i + 1).trim().replace(/^["']|["']$/g, '')]; }));
}
export function pageUrl(extra = '') {
  let rel = path.relative(ROOT, DIR).split(path.sep).join('/'); if (!rel) rel = '.';
  return pathToFileURL(path.join(ROOT, 'edit.html')).href + '?dir=' + encodeURIComponent(rel) + extra;
}
// one headless Chrome through cdp.mjs (idle priority). Never run two renders at once.
export async function browser(width = 1080, height = 1920) {
  const { launch } = await import(pathToFileURL(CDP).href);
  for (let i = 0; ; i++) { try { return await launch({ width, height, port: 9650 + (i % 3) }); } catch (e) { if (i > 3) throw e; await new Promise(r => setTimeout(r, 2000)); } }
}
export async function openFilm(page, extra = '') {
  await page.goto(pageUrl(extra));
  try { await page.eval('window.ready'); } catch (e) { console.error('page failed:', e.message, JSON.stringify(page.console.slice(-5))); process.exit(1); }
  if (!(await page.eval('typeof window.setT === "function"'))) { console.error('page did not build', JSON.stringify(page.console.slice(-5))); process.exit(1); }
  const errs = page.console.filter(c => /error|exception/i.test(c.type) && !/ERR_FILE_NOT_FOUND/.test(c.text));
  if (errs.length) console.log('PAGE ERRORS:', JSON.stringify(errs.slice(-5), null, 1));
}
export const ff = (args, opt = {}) => { const r = spawnSync(FF, ['-hide_banner', '-y', '-v', 'error', ...args], { encoding: 'utf8', maxBuffer: 1 << 26, ...opt }); if (r.status !== 0) { console.error('ffmpeg failed:', (r.stderr || '').slice(-1500)); process.exit(1); } return r; };
// integrated loudness + true peak of a file (EBU R128)
export function loudness(file, extra = []) {
  const r = spawnSync(FF, ['-hide_banner', '-nostats', ...extra, '-i', file, '-af', 'ebur128=peak=true', '-f', 'null', '-'], { encoding: 'utf8', maxBuffer: 1 << 26 });
  const s = r.stderr.slice(r.stderr.lastIndexOf('Summary:'));
  const I = +(s.match(/I:\s+(-?[\d.]+|-inf) LUFS/) || [])[1], TP = +(s.match(/Peak:\s+(-?[\d.]+|-inf) dBFS/) || [])[1], LRA = +(s.match(/LRA:\s+(-?[\d.]+) LU/) || [])[1];
  return { I, TP, LRA };
}
export function probe(file) {
  const r = spawnSync(FP, ['-v', 'error', '-show_entries', 'format=duration,size,bit_rate:stream=codec_type,codec_name,profile,width,height,pix_fmt,r_frame_rate,sample_rate,channels,bit_rate', '-of', 'json', file], { encoding: 'utf8' });
  return JSON.parse(r.stdout);
}
export const norm = s => s.toLowerCase().replace(/[’']/g, "'").replace(/[^a-z0-9' ]/g, ' ').replace(/\s+/g, ' ').trim();
// word-level diff (LCS) → { wer, out: '… [-missing-] {+extra+} …' }
export function wordDiff(want, got) {
  const sp = w => w.replace(/our(s?)$/, 'or$1');                         // colour/color, favourite/favorite
  const a = norm(want).split(' ').filter(Boolean).map(sp), b0 = norm(got).split(' ').filter(Boolean).map(sp), A_ = new Set(a), b = [];
  for (let j = 0; j < b0.length; j++) { if (j + 1 < b0.length && A_.has(b0[j] + b0[j + 1]) && !(A_.has(b0[j]) && A_.has(b0[j + 1]))) { b.push(b0[j] + b0[j + 1]); j++; } else b.push(b0[j]); } // "cap cut" = CapCut
  const n = a.length, m = b.length, L = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
  for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) L[i][j] = a[i] === b[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
  const out = []; let i = 0, j = 0, errs = 0;
  while (i < n || j < m) {
    if (i < n && j < m && a[i] === b[j]) { out.push(a[i]); i++; j++; }
    else if (j < m && (i >= n || L[i][j + 1] >= L[i + 1][j])) { out.push(`{+${b[j]}+}`); j++; errs++; }
    else { out.push(`[-${a[i]}-]`); i++; errs++; }
  }
  return { wer: n ? errs / n : 0, out: out.join(' '), words: n };
}
// transcribe wav/mp3 files with the local faster-whisper (GPU, idle priority) → [{ file, text, segments }]
export function whisper(files) {
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'chibi-wh-')), wavs = [];
  files.forEach((f, i) => { const w = path.join(tmp, `${i}.wav`); ff(['-i', f, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', w]); wavs.push(w); });
  const out = path.join(tmp, 'out.json');
  const r = spawnSync(PY, [path.join(ROOT, 'whisper.py'), out, ...wavs], { encoding: 'utf8', stdio: ['ignore', 'inherit', 'pipe'] });
  if (r.status !== 0) { console.log('whisper could not run (it needs Python + `pip install faster-whisper`), so listen to the audio instead.', (r.stderr || '').slice(-300)); process.exit(0); }
  const j = JSON.parse(fs.readFileSync(out, 'utf8'));
  try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { }
  return j.files.map((x, i) => ({ file: files[i], text: x.segments.map(s => s.text).join(' ').trim(), segments: x.segments, device: j.device }));
}
```

### `markup.mjs`

```js
// The script markup (script.mjs TAKES) → caption tokens.
//   /            phrase break: a new caption chunk starts here
//   *word*       key word (gets the caption box emphasis in your scenes / headlines)
//   *two_words*  one token over two spoken words (underscore = space) — a headline or cue can then find "two words"
//   {shown|said} show one spelling, say another: Comment *{CHIBI,|Chibi,}* → caption CHIBI,, voice says "Morphy"
export function parse(src) {
  const out = []; let br = true;
  const parts = src.match(/[^\s{]*\{[^}]*\}\S*|\S+/g);
  for (let raw of parts) {
    if (raw === '/') { br = true; continue; }
    let icon = null, key = false;
    if (raw.startsWith('@')) { const i = raw.indexOf(':'); icon = raw.slice(1, i); raw = raw.slice(i + 1); }
    let core = raw, tail = '';
    if (raw.startsWith('*')) { const j = raw.lastIndexOf('*'); key = true; core = raw.slice(1, j); tail = raw.slice(j + 1); }
    let d = core, said = core;
    const m = core.match(/^(.*)\{([^|]*)\|([^}]*)\}(.*)$/);
    if (m) { d = m[1] + m[2] + m[4]; said = m[1] + m[3] + m[4]; }
    d = d.replace(/_/g, ' ') + tail; said = said.replace(/_/g, ' ') + tail;
    out.push({ d, said, key, icon, br }); br = false;
  }
  return out;
}
export const spoken = src => parse(src).map(t => t.said).join(' ');
```

### `cdp.mjs`

```js
// Headless Chrome/Edge over the DevTools protocol, no dependencies. ONE browser at a time, at the lowest priority.
// The film is HTML + a 2D canvas: Chrome draws it on the GPU when it can, and a CPU fallback is light enough to be fine.
import { spawn, execFile } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
export const CHROME = [process.env.CHROME, 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
  'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', 'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
  '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
  '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/microsoft-edge'].find(p => p && fs.existsSync(p));
const ANGLE = process.env.CHIBI_ANGLE || (process.platform === 'win32' ? 'd3d11' : process.platform === 'darwin' ? 'metal' : 'gl');
const sleep = ms => new Promise(r => setTimeout(r, ms));

export async function launch({ width = 1080, height = 1920, port = 9650 } = {}) {
  if (!CHROME) throw new Error('Chrome or Edge not found (set CHROME=<path>)');
  try { os.setPriority(os.constants.priority.PRIORITY_LOW); } catch { }
  const udd = fs.mkdtempSync(path.join(os.tmpdir(), 'chibi-chrome-'));
  const proc = spawn(CHROME, [`--remote-debugging-port=${port}`, `--user-data-dir=${udd}`, '--headless=new', '--no-first-run', '--no-default-browser-check', '--disable-extensions',
    '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--mute-audio', `--window-size=${width},${height}`,
    '--use-gl=angle', `--use-angle=${ANGLE}`, '--ignore-gpu-blocklist', '--enable-gpu', 'about:blank'], { stdio: 'ignore' });
  let list = null; for (let i = 0; i < 100 && !list?.length; i++) { try { list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); } catch { await sleep(150); } }
  if (!list?.length) { proc.kill(); throw new Error('Chrome devtools not reachable on port ' + port); }
  // polite: lower the browser and every child process, now and every 5 s; on Windows pin them to one logical CPU
  const pinned = new Set();
  const polite = async () => { try {
    const v = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json(), bws = new WebSocket(v.webSocketDebuggerUrl);
    await new Promise((res, rej) => { bws.onopen = res; bws.onerror = rej; });
    const info = await new Promise(res => { bws.onmessage = ev => { const m = JSON.parse(ev.data); if (m.id === 1) res(m.result); }; bws.send(JSON.stringify({ id: 1, method: 'SystemInfo.getProcessInfo' })); setTimeout(() => res(null), 10000); });
    bws.close(); const pids = [proc.pid, ...(info?.processInfo || []).map(p => p.id)];
    for (const id of pids) { try { os.setPriority(id, os.constants.priority.PRIORITY_LOW); } catch { } }
    const fresh = pids.filter(id => !pinned.has(id)); fresh.forEach(id => pinned.add(id));
    if (process.platform === 'win32' && fresh.length) execFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', `$ErrorActionPreference='SilentlyContinue'; foreach ($id in @(${fresh.join(',')})) { (Get-Process -Id $id).ProcessorAffinity = [IntPtr]${2 ** (Math.min(os.cpus().length, 52) - 1)} }`], { windowsHide: true }, () => { });
  } catch { } };
  await polite(); setTimeout(polite, 1200).unref(); const timer = setInterval(polite, 5000); timer.unref();
  const ws = new WebSocket(list.find(t => t.type === 'page').webSocketDebuggerUrl); await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
  let id = 0; const pending = new Map(), logs = [];
  ws.onmessage = ev => { const m = JSON.parse(ev.data);
    if (m.id && pending.has(m.id)) { const { res, rej } = pending.get(m.id); pending.delete(m.id); m.error ? rej(new Error(m.error.message)) : res(m.result); }
    else if (m.method === 'Runtime.exceptionThrown') logs.push({ type: 'exception', text: m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text });
    else if (m.method === 'Runtime.consoleAPICalled') logs.push({ type: m.params.type, text: m.params.args.map(a => a.value ?? a.description).join(' ') }); };
  const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
  await send('Runtime.enable'); await send('Page.enable');
  await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
  const api = { send, logs, console: logs,
    async shot(file, quality = 90) { const r = await send('Page.captureScreenshot', { format: 'jpeg', quality }); fs.writeFileSync(file, Buffer.from(r.data, 'base64')); },
    async eval(expr) { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error('eval: ' + (r.exceptionDetails.exception?.description || r.exceptionDetails.text)); return r.result.value; },
    async waitFor(expr, ms = 10000) { const t0 = Date.now(); while (Date.now() - t0 < ms) { try { if (await api.eval(expr)) return; } catch { } await sleep(100); } throw new Error('timeout: ' + expr); },
    async goto(url) { await send('Page.navigate', { url }); await sleep(300); await api.waitFor('document.readyState === "complete"', 15000); },
    async close() { clearInterval(timer); try { ws.close(); } catch { } try { proc.kill(); } catch { }
      // wait for Chrome to let go of its profile folder, then delete it (a timer would never fire if the script exits first)
      for (let k = 0; k < 20; k++) { await sleep(150); try { fs.rmSync(udd, { recursive: true, force: true }); break; } catch { } } } };
  api.gpu = await api.eval(`(() => { const c = document.createElement('canvas'), g = c.getContext('webgl2') || c.getContext('webgl'); if (!g) return 'none'; const e = g.getExtension('WEBGL_debug_renderer_info'); return String(e ? g.getParameter(e.UNMASKED_RENDERER_WEBGL) : g.getParameter(g.RENDERER)); })()`);
  return api;
}
```

### `setup.mjs`

```js
// Chibi · requirements check + the free fonts + the synthesized sounds and bed. Run once per project:  node setup.mjs
// Node 22+, ffmpeg/ffprobe on PATH, Chrome or Edge, the ElevenLabs key (never printed); downloads Inter Tight, Instrument
// Serif italic and IBM Plex Mono (SIL OFL, google/fonts) to fonts/, makes sfx/*.wav (sfxgen.mjs) and music/bed.wav
// (bed.mjs), and starts the browser once to prove it works.
import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url';
import { CHROME, launch } from './cdp.mjs';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ok = (good, msg) => console.log(good ? '✓' : '✗', msg);
ok(+process.versions.node.split('.')[0] >= 22, 'Node ' + process.versions.node + ' (22+ needed: global fetch and WebSocket)');
for (const c of ['ffmpeg', 'ffprobe']) ok(spawnSync(c, ['-version']).status === 0, c + ' on PATH');
ok(!!CHROME, 'Chrome or Edge: ' + (CHROME || 'not found, set CHROME=<path to the browser>'));
ok(!!(process.env.ELEVENLABS_API_KEY || (fs.existsSync(path.join(HERE, '.env')) && /^ELEVENLABS_API_KEY=./m.test(fs.readFileSync(path.join(HERE, '.env'), 'utf8')))), 'ELEVENLABS_API_KEY (environment or .env) — not needed for --fake timings and stills');
const FONTS = {
  'InterTight.ttf': 'https://raw.githubusercontent.com/google/fonts/main/ofl/intertight/InterTight%5Bwght%5D.ttf',
  'InstrumentSerif-Italic.ttf': 'https://raw.githubusercontent.com/google/fonts/main/ofl/instrumentserif/InstrumentSerif-Italic.ttf',
  'IBMPlexMono-Bold.ttf': 'https://raw.githubusercontent.com/google/fonts/main/ofl/ibmplexmono/IBMPlexMono-Bold.ttf',
};
for (const [name, url] of Object.entries(FONTS)) {
  const font = path.join(HERE, 'fonts', name);
  if (fs.existsSync(font)) { ok(true, 'fonts/' + name); continue; }
  fs.mkdirSync(path.dirname(font), { recursive: true });
  try { const r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0' } }); if (!r.ok) throw new Error(r.status); fs.writeFileSync(font, Buffer.from(await r.arrayBuffer())); ok(true, 'fonts/' + name + ' downloaded'); }
  catch (e) { ok(false, `fonts/${name}: download failed (${e.message}) from ${url}`); }
}
for (const f of ['sfxgen.mjs', 'bed.mjs']) spawnSync(process.execPath, [path.join(HERE, f)], { stdio: 'inherit' });
if (CHROME) try { const api = await launch({ port: 9698 }); ok(true, 'Chrome starts headless · renderer: ' + api.gpu); await api.close(); } catch (e) { ok(false, e.message); }
setTimeout(() => process.exit(0), 300);
```

### `sfxgen.mjs`

```js
// Chibi · the sound kit, synthesized (no samples, no licences, no credits, no npm):  node sfxgen.mjs [--force]
// → sfx/<name>.wav, 48 kHz stereo. Seeded, so every project gets the same sounds. mix.mjs looks in sfx/ first.
// Soft, rounded UI sounds for a cute chibi explainer: ticks for the caption pill, blips for cards, a spring boing for
// hops, stamps, a ka-ching, a string snap, key clicks, dings, a glitter shimmer, footsteps and a white-out swell.
import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url)), OUT = path.join(HERE, 'sfx'), SR = 48000, FORCE = process.argv.includes('--force');
let seed = 7; const rnd = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
const gauss = () => { let s = 0; for (let i = 0; i < 4; i++) s += rnd(); return (s - 2) * 1.7; };
const N = d => Math.round(SR * d);
const buf = d => new Float64Array(N(d));
const noise = d => buf(d).map(() => gauss());
const env = (n, a = 0.002, d = 0.1, shape = 4) => { const e = new Float64Array(n); for (let i = 0; i < n; i++) { const t = i / SR; e[i] = Math.min(1, t / Math.max(a, 1e-4)) * Math.exp(-Math.max(0, t - a) * shape / Math.max(d, 1e-4)); } return e; };
const sweep = (f0, f1, d, curve = 1) => { const a = buf(d); let ph = 0; for (let i = 0; i < a.length; i++) { const k = (i / SR / d) ** curve; ph += 2 * Math.PI * f0 * (f1 / f0) ** k / SR; a[i] = Math.sin(ph); } return a; };
const lp = (x, fc) => { const y = new Float64Array(x.length); let s = 0; for (let i = 0; i < x.length; i++) { const f = typeof fc === 'function' ? fc(i) : fc; s += (1 - Math.exp(-2 * Math.PI * f / SR)) * (x[i] - s); y[i] = s; } return y; };
const hp = (x, fc) => { const l = lp(x, fc); return x.map((v, i) => v - l[i]); };
const bp = (x, lo, hi) => hp(lp(x, hi), lo);
const mul = (a, b) => a.map((v, i) => v * (typeof b === 'function' ? b(i) : b[i] ?? 0));
const add = (a, b, g = 1, at = 0) => { const o = Math.round(at * SR); for (let i = 0; i < b.length && i + o < a.length; i++) a[i + o] += g * b[i]; return a; };
const sine = (f, d) => buf(d).map((_, i) => Math.sin(2 * Math.PI * f * i / SR));
const pad = (x, d) => { const a = buf(d); a.set(x.subarray(0, Math.min(x.length, a.length))); return a; };
const save = (name, x, peak = 0.89) => {
  const file = path.join(OUT, name + '.wav'); if (fs.existsSync(file) && !FORCE) return false;
  const fade = Math.min(x.length, N(0.004)); for (let i = 0; i < fade; i++) x[x.length - 1 - i] *= i / fade;
  let m = 0; for (const v of x) m = Math.max(m, Math.abs(v)); const g = m ? peak / m : 1;
  const n = x.length, b = Buffer.alloc(44 + n * 4);
  b.write('RIFF', 0); b.writeUInt32LE(36 + n * 4, 4); b.write('WAVEfmt ', 8); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(2, 22);
  b.writeUInt32LE(SR, 24); b.writeUInt32LE(SR * 4, 28); b.writeUInt16LE(4, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 4, 40);
  for (let i = 0; i < n; i++) { const v = Math.round(Math.max(-1, Math.min(1, x[i] * g * Math.SQRT1_2)) * 32767); b.writeInt16LE(v, 44 + i * 4); b.writeInt16LE(v, 46 + i * 4); }
  fs.mkdirSync(OUT, { recursive: true }); fs.writeFileSync(file, b); return true;
};
const S = {};
// tick: the caption pill landing on a word (3 pitches)
[2400, 2900, 3400].forEach((f, i) => { S['tick' + (i + 1)] = () => { const d = 0.06, n = N(d); return add(mul(sine(f, d), env(n, 0.0008, 0.018)), mul(bp(noise(d), 2000, 7000), env(n, 0.0003, 0.004)), 0.35); }; });
// blips: a card popping in (up) / out (down)
for (const [name, f0, f1] of [['blip', 520, 1180], ['blip2', 680, 1500], ['blipdown', 1100, 480]]) S[name] = () => { const d = 0.12, n = N(d); return add(mul(sweep(f0, f1, d, 0.6), env(n, 0.003, 0.05)), mul(sweep(f0 * 2, f1 * 2, d, 0.6), env(n, 0.002, 0.03)), 0.25); };
S.bubble = () => mul(sweep(260, 980, 0.09, 0.4), env(N(0.09), 0.001, 0.035));
S.click = () => mul(bp(noise(0.03), 2500, 9000), env(N(0.03), 0.0003, 0.005));
// boing: a spring for hops
S.boing = () => { const d = 0.42, a = buf(d), e = env(a.length, 0.004, 0.16), e2 = env(a.length, 0.004, 0.08); let ph = 0; for (let i = 0; i < a.length; i++) { const t = i / SR, f = 180 + 260 * (1 - Math.exp(-t * 18)) + 40 * Math.sin(2 * Math.PI * 14 * t) * Math.exp(-t * 6); ph += 2 * Math.PI * f / SR; a[i] = Math.sin(ph) * e[i] + 0.3 * Math.sin(2 * ph) * e2[i]; } return a; };
// whooshes: filtered noise sweeps
for (const [name, d, f0, f1] of [['whoosh2', 0.55, 300, 4200], ['swup', 0.35, 500, 6000], ['swdown', 0.4, 5000, 400]]) S[name] = () => { const n = N(d); return mul(lp(bp(noise(d), 150, 9000), i => f0 * (f1 / f0) ** (i / n)), i => Math.sin(Math.PI * i / n) ** 1.6); };
S.throw = () => { const d = 0.6, n = N(d); return mul(lp(bp(noise(d), 300, 8000), i => 1500 + 3000 * Math.abs(Math.sin(2 * Math.PI * 9 * i / SR))), i => Math.sin(Math.PI * i / n) ** 1.3); };
// thud, stamp, boom
S.thud = () => { const d = 0.35, n = N(d); return add(mul(sweep(140, 48, d, 0.5), env(n, 0.002, 0.12)), mul(lp(noise(d), 900), env(n, 0.001, 0.02)), 0.5); };
S.stamp = () => { const d = 0.4, n = N(d), a = mul(sweep(110, 50, d, 0.4), env(n, 0.001, 0.1)); add(a, mul(bp(noise(d), 300, 5000), env(n, 0.0005, 0.03)), 0.9); return add(a, mul(bp(noise(d), 2000, 8000), env(n, 0.002, 0.06)), 0.3); };
S.boom = () => { const d = 1.2, n = N(d), a = mul(sweep(90, 32, d, 0.35), env(n, 0.003, 0.45)); add(a, mul(lp(noise(d), 400), env(n, 0.002, 0.18)), 0.6); return add(a, mul(bp(noise(d), 1500, 9000), env(n, 0.001, 0.08)), 0.25); };
// ka-ching (a price), snap (a string breaking)
S.kaching = () => { const d = 0.9, a = pad(mul(bp(noise(0.05), 1500, 6000), env(N(0.05), 0.0005, 0.012)), d); const bell = buf(d); for (const [f, g] of [[2093, 1], [2637, 0.7], [3136, 0.5], [4186, 0.35], [5274, 0.2]]) add(bell, sine(f, d), g); add(a, mul(bell, env(bell.length, 0.002, 0.35)), 0.6, 0.09); return add(a, mul(bp(noise(0.08), 3000, 9000), env(N(0.08), 0.001, 0.03)), 0.3); };
S.snap = () => { const d = 0.45, n = N(d); return add(mul(bp(noise(d), 1500, 9000), env(n, 0.0003, 0.012)), mul(sweep(900, 140, d, 0.3), env(n, 0.001, 0.12)), 0.7); };
// key clicks (typing), dings (a check lands)
[0, 1, 2].forEach(i => { S['key' + (i + 1)] = () => { const d = 0.05, n = N(d); return add(mul(bp(noise(d), 1800 + i * 500, 7000), env(n, 0.0003, 0.006)), mul(sine(180 + i * 30, d), env(n, 0.001, 0.012)), 0.4); }; });
[[1568, 2349], [1760, 2637]].forEach(([f1, f2], i) => { S['ding' + (i + 1)] = () => { const d = 0.5, a = sine(f1, d); add(a, sine(f2, d), 0.6); add(a, sine(f1 * 3, d), 0.2); return mul(a, env(a.length, 0.002, 0.16)); }; });
// shimmer (reveals), sparkle (small)
S.shimmer = () => { const a = buf(1.1); for (let j = 0; j < 26; j++) { const f = 2400 + rnd() * 3600, s = mul(sine(f, 0.25), env(N(0.25), 0.001, 0.07)); add(a, s, 1 - j / 30, j * 0.03 + rnd() * 0.02); } return a; };
S.sparkle = () => { const a = buf(0.35); [2637, 3136, 3951].forEach((f, j) => add(a, mul(sine(f, 0.2), env(N(0.2), 0.001, 0.06)), 1, j * 0.06)); return a; };
// steps (walking), swell (into a white-out), riser2 (into the name), tickroll (a counter rolling), drag, send
[0, 1].forEach(i => { S['step' + (i + 1)] = () => { const d = 0.12, n = N(d); return add(mul(sweep(120 + i * 25, 60, d), env(n, 0.002, 0.03)), mul(lp(noise(d), 1200 + i * 300), env(n, 0.001, 0.015)), 0.4); }; });
S.swell = () => { const d = 1, n = N(d); return mul(bp(noise(d), 2000, 12000), i => (i / n) ** 3); };
S.riser2 = () => { const d = 1.4, n = N(d); return add(mul(sweep(220, 880, d, 1.4), i => 0.6 * (i / n) ** 2), mul(bp(noise(d), 1500, 9000), i => (i / n) ** 2.5), 0.5); };
S.tickroll = () => { const a = buf(0.95); let tt = 0, k = 0; while (tt < 0.88) { tt += 0.075 * (1 - tt) + 0.018; add(a, mul(sine(2200 + k * 40, 0.02), env(N(0.02), 0.0005, 0.006)), 1, tt); k++; } return a; };
S.drag = () => { const d = 0.5, n = N(d); return mul(bp(noise(d), 600, 3000), i => 0.6 * Math.sin(Math.PI * i / n) ** 2); };
S.send = () => { const d = 0.4, n = N(d), a = mul(lp(bp(noise(d), 500, 9000), i => 800 + 9000 * i / n), i => Math.sin(Math.PI * i / n)); return add(a, mul(sweep(900, 1800, 0.08), env(N(0.08), 0.001, 0.03)), 0.5); };
let made = 0; for (const [name, f] of Object.entries(S)) { seed = 7 + name.length * 131 + name.charCodeAt(0); if (save(name, f())) made++; }
console.log(`sfx: ${made} made, ${Object.keys(S).length - made} already there → ${OUT}`);
```

### `bed.mjs`

```js
// Chibi · the music bed, synthesized (no licences): a light, bouncy lo-fi loop at 96 BPM, 75 s long → music/bed.wav
//   node bed.mjs [--force]      (mix.mjs uses it when scenes.js names a track that isn't in music/)
// Fmaj7 – Em7 – Dm7 – Cmaj7, one bar each: a soft pluck arpeggio in eighths, a warm pad, a round sub bass,
// a soft kick on 1 and 3, a brushed snare on 2 and 4, hats in eighths. Seeded, so every project gets the same bed.
import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url)), OUT = path.join(HERE, 'music', 'bed.wav'), SR = 48000;
if (fs.existsSync(OUT) && !process.argv.includes('--force')) { console.log('music/bed.wav already there'); process.exit(0); }
let seed = 11; const rnd = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296;
const BPM = 96, BEAT = 60 / BPM, BAR = 4 * BEAT, DUR = 75, n = Math.round(DUR * SR);
const L = new Float64Array(n), R = new Float64Array(n);
const hz = m => 440 * 2 ** ((m - 69) / 12);
const CH = [[53, 57, 60, 64], [52, 55, 59, 62], [50, 53, 57, 60], [48, 52, 55, 59]]; // Fmaj7 Em7 Dm7 Cmaj7
const put = (t0, len, fn, pan = 0, g = 1) => { const i0 = Math.round(t0 * SR), m = Math.round(len * SR), gl = g * Math.sqrt((1 - pan) / 2), gr = g * Math.sqrt((1 + pan) / 2); for (let i = 0; i < m && i0 + i < n; i++) { const v = fn(i / SR); L[i0 + i] += v * gl; R[i0 + i] += v * gr; } };
const pluck = f => t => (Math.sin(2 * Math.PI * f * t) + 0.35 * Math.sin(4 * Math.PI * f * t) + 0.12 * Math.sin(6 * Math.PI * f * t)) * Math.exp(-t * 6) * Math.min(1, t * 400);
const pad = f => t => { const a = Math.min(1, t / 0.6) * Math.min(1, (BAR - t) / 0.4); return a * (Math.sin(2 * Math.PI * f * t) + Math.sin(2 * Math.PI * f * 1.003 * t) + 0.3 * Math.sin(4 * Math.PI * f * 0.998 * t)) / 2.3; };
const kick = t => Math.sin(2 * Math.PI * (45 * t + 75 * (1 - Math.exp(-t * 25)) / 25)) * Math.exp(-t * 9);
let lp = 0; const snare = t => { lp += 0.35 * ((rnd() * 2 - 1) - lp); return (lp * 0.8 + 0.2 * Math.sin(2 * Math.PI * 190 * t)) * Math.exp(-t * 22); };
let hp = 0, prev = 0; const hat = t => { const x = rnd() * 2 - 1; hp = x - prev; prev = x; return hp * Math.exp(-t * 70); };
const ARP = [0, 1, 2, 3, 2, 1, 3, 2];
for (let bar = 0; bar * BAR < DUR; bar++) {
  const c = CH[bar % 4], t0 = bar * BAR;
  c.forEach((m, k) => put(t0, BAR, pad(hz(m)), k % 2 ? 0.3 : -0.3, 0.045));
  put(t0, BEAT * 1.6, t => Math.sin(2 * Math.PI * hz(c[0] - 12) * t) * Math.min(1, t * 200) * Math.exp(-t * 1.6), 0, 0.22);
  put(t0 + BEAT * 2.5, BEAT * 1.4, t => Math.sin(2 * Math.PI * hz(c[0] - 12) * t) * Math.min(1, t * 200) * Math.exp(-t * 2), 0, 0.16);
  ARP.forEach((k, e) => put(t0 + e * BEAT / 2 + (e % 2 ? 0.012 : 0), BEAT, pluck(hz(c[k] + 12)), e % 2 ? 0.35 : -0.35, 0.09));
  for (let b = 0; b < 4; b++) {
    if (b % 2 === 0) put(t0 + b * BEAT, 0.5, kick, 0, 0.42);
    else put(t0 + b * BEAT, 0.25, snare, 0.05, 0.13);
    put(t0 + b * BEAT, 0.06, hat, 0.25, 0.05); put(t0 + b * BEAT + BEAT / 2 + 0.01, 0.05, hat, 0.25, 0.035);
  }
}
const fade = Math.round(1.5 * SR); for (let i = 0; i < fade; i++) { const k = i / fade; L[n - 1 - i] *= k; R[n - 1 - i] *= k; }
let m = 0; for (let i = 0; i < n; i++) m = Math.max(m, Math.abs(L[i]), Math.abs(R[i]));
const b = Buffer.alloc(44 + n * 4);
b.write('RIFF', 0); b.writeUInt32LE(36 + n * 4, 4); b.write('WAVEfmt ', 8); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(2, 22);
b.writeUInt32LE(SR, 24); b.writeUInt32LE(SR * 4, 28); b.writeUInt16LE(4, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 4, 40);
for (let i = 0; i < n; i++) { b.writeInt16LE(Math.round(L[i] / m * 0.8 * 32767), 44 + i * 4); b.writeInt16LE(Math.round(R[i] / m * 0.8 * 32767), 46 + i * 4); }
fs.mkdirSync(path.dirname(OUT), { recursive: true }); fs.writeFileSync(OUT, b);
console.log(`music/bed.wav: ${DUR} s at ${BPM} BPM`);
```

### `gen-vo.mjs`

```js
// Chibi · voice. One ElevenLabs take per section (with-timestamps), so every caption word has its time.
//   node gen-vo.mjs --dir <video> [--yes] [--only s1,s3] [--fresh]   (a dry run unless --yes)   → <video>/vo/<id>.mp3 + <id>.json { text, dur, tokens:[{…, t0, t1}] }
// The template's voice: Arthur (ElevenLabs Voice Library, young energetic US male), eleven_multilingual_v2, speed 1.1. Costs credits: takes that exist are skipped
// unless --fresh; regenerate only what's wrong (RULES.md: about 1,500 characters per video).
import fs from 'node:fs';
import { V, arg, has, loadScript, readEnv } from './lib.mjs';
import { parse } from './markup.mjs';
export const VOICE = { id: 'TtRFBnwQdH1k01vR0hMz', name: 'Arthur', model: 'eleven_multilingual_v2', settings: { stability: 0.45, similarity_boost: 0.8, style: 0.4, use_speaker_boost: true, speed: 1.1 } };

const { TAKES } = await loadScript();
const ids = Object.keys(TAKES);
if (ids.at(-1) !== 'cta') { console.error('script.mjs: the last take must be `cta`'); process.exit(1); }
const only = arg('--only') ? arg('--only').split(',') : ids;
if (fs.existsSync(V('vo', 'FROZEN'))) { console.error('vo/FROZEN: these are the takes of the template example; never regenerate them. Make a video with new-video.mjs instead.'); process.exit(1); }
const env = readEnv(); env.ELEVENLABS_API_KEY ||= process.env.ELEVENLABS_API_KEY;
if (!has('--yes')) {
  let n = 0; for (const id of only) { const text = parse(TAKES[id]).map(t => t.said).join(' '); const js = V('vo', `${id}.json`); if (fs.existsSync(js) && !has('--fresh') && JSON.parse(fs.readFileSync(js, 'utf8')).text === text) continue; console.log(` ${id}: ${text}`); n += text.length; }
  console.log(n ? `dry run: nothing sent. ${n} characters (~${n} credits). Add --yes to make these takes.` : 'every take is already made'); process.exit(0);
}
if (!env.ELEVENLABS_API_KEY) { console.error('no ELEVENLABS_API_KEY (environment, or .env in the project folder)'); process.exit(1); }
fs.mkdirSync(V('vo'), { recursive: true });
let chars = 0;
for (const id of only) {
  if (!TAKES[id]) { console.error('no take', id); process.exit(1); }
  const mp3 = V('vo', `${id}.mp3`), js = V('vo', `${id}.json`);
  const toks = parse(TAKES[id]);
  const text = toks.map(t => t.said).join(' ');
  if (fs.existsSync(js) && !has('--fresh') && JSON.parse(fs.readFileSync(js, 'utf8')).text === text) { console.log(id, 'cached'); continue; }
  chars += text.length;
  const body = { text, model_id: VOICE.model, voice_settings: VOICE.settings };
  const r = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${VOICE.id}/with-timestamps?output_format=mp3_44100_128`, { method: 'POST', headers: { 'xi-api-key': env.ELEVENLABS_API_KEY, 'content-type': 'application/json' }, body: JSON.stringify(body) });
  if (!r.ok) { const e = await r.text(); console.log(id, r.status, e.slice(0, 300)); if (/voice_not_found|not found/i.test(e)) console.log('  The voice is not on your ElevenLabs account: open the Voice Library, search "Arthur - Energetic American Male Narrator" and add it, or set VOICE in gen-vo.mjs to Liam (TX3LPaxmHKxFdv7VOQHJ, premade).'); process.exit(1); }
  const j = await r.json();
  fs.writeFileSync(mp3, Buffer.from(j.audio_base64, 'base64'));
  const A = j.alignment;
  const words = []; let cur = null;
  A.characters.forEach((c, i) => {
    if (/\s/.test(c)) { if (cur) { words.push(cur); cur = null; } return; }
    if (!cur) cur = { w: '', t0: A.character_start_times_seconds[i] };
    cur.w += c; cur.t1 = A.character_end_times_seconds[i];
  });
  if (cur) words.push(cur);
  let wi = 0;
  for (const t of toks) { const n = t.said.split(' ').length, span = words.slice(wi, wi + n); wi += n; t.t0 = +span[0].t0.toFixed(3); t.t1 = +span.at(-1).t1.toFixed(3); }
  if (wi !== words.length) console.log(id, 'WARNING word count', wi, 'vs', words.length);
  const dur = A.character_end_times_seconds.at(-1);
  fs.writeFileSync(js, JSON.stringify({ id, text, dur, tokens: toks }, null, 1));
  console.log(id, 'ok', dur.toFixed(2) + 's', (toks.length / (toks.at(-1).t1 - toks[0].t0)).toFixed(2), 'tok/s');
}
console.log('characters sent:', chars);
```

### `check-vo.mjs`

```js
// Chibi · check the takes: transcribe every vo/<id>.mp3 with the local whisper (no credits) and compare with the script.
//   node check-vo.mjs [--dir <video>] [--only s1,s3]   → MATCH / DIFF per take, with the word diff
// A DIFF on a proper noun or a number-vs-digits is usually fine; a missing/changed word means regenerate that take.
// Known: Whisper writes "dot M D" as "md" and spells names its own way: not voice errors. Other DIFFs: listen, reword or regenerate that take.
import fs from 'node:fs';
import { V, arg, loadScript, need, whisper, wordDiff, low } from './lib.mjs';
import { spoken } from './markup.mjs';
low();
const { TAKES } = await loadScript();
const ids = arg('--only') ? arg('--only').split(',') : Object.keys(TAKES);
ids.forEach(id => need(V('vo', `${id}.mp3`), 'run gen-vo.mjs --yes first'));
const res = whisper(ids.map(id => V('vo', `${id}.mp3`)));
let bad = 0;
ids.forEach((id, i) => {
  const want = fs.existsSync(V('vo', `${id}.json`)) ? JSON.parse(fs.readFileSync(V('vo', `${id}.json`), 'utf8')).text : spoken(TAKES[id]);
  const d = wordDiff(want, res[i].text);
  if (d.wer > 0) bad++;
  console.log(id.padEnd(4), d.wer === 0 ? 'MATCH' : `DIFF ${(d.wer * 100).toFixed(0)}%`, '|', d.wer === 0 ? res[i].text : d.out);
});
console.log(bad ? `${bad} take(s) differ — listen to them, regenerate with gen-vo.mjs --only <ids> --fresh if a word is wrong` : 'all takes match the script', `(${res[0]?.device})`);
```

### `retag.mjs`

```js
// Re-apply caption chunks (/) and key words (*) from script.mjs onto cached voice takes, without new credits.
//   node retag.mjs   (the spoken words must be unchanged; only markup may differ)
import fs from 'node:fs';
import { V, loadScript, need } from './lib.mjs';
import { parse } from './markup.mjs';
const { TAKES } = await loadScript();
for (const [id, src] of Object.entries(TAKES)) {
  const f = V('vo', id + '.json'); need(f, 'no voice take yet: run gen-vo.mjs first'); const j = JSON.parse(fs.readFileSync(f, 'utf8')), nt = parse(src);
  if (nt.length !== j.tokens.length || nt.some((k, i) => k.said !== j.tokens[i].said)) { console.error(id, 'spoken words changed — run gen-vo.mjs --only', id, '--fresh'); process.exit(1); }
  j.tokens = j.tokens.map((k, i) => ({ ...k, d: nt[i].d, key: nt[i].key, br: nt[i].br, icon: nt[i].icon }));
  fs.writeFileSync(f, JSON.stringify(j, null, 1));
}
console.log('retagged', Object.keys(TAKES).length, 'takes');
```

### `build-timeline.mjs`

```js
// Chibi · timeline. Lays the takes end to end (script.mjs order + GAP) →
//   <video>/timeline.json + timeline.js (sections + every caption word on the film clock) and <video>/vo.wav (the voice track).
//   node build-timeline.mjs --dir <video> [--fake]   (--fake: guessed word times + silence, before the voice exists)
import fs from 'node:fs';
import { V, ff, has, loadScript, need } from './lib.mjs';
import { parse } from './markup.mjs';
const FAKE = has('--fake');
// a guess at ~190 words a minute: 0.11 s per syllable + 0.07 s per word, a breath after punctuation
const guess = src => { let t = 0; return parse(src).map(k => { const syl = k.said.split(' ').reduce((n, w) => n + Math.max(1, (w.toLowerCase().match(/[aeiouy]+/g) || []).length), 0); const t0 = t, t1 = t0 + 0.07 * k.said.split(' ').length + 0.11 * syl; t = t1 + (/[.,!?…]$/.test(k.said) ? 0.16 : 0.03); return { ...k, t0: +t0.toFixed(3), t1: +t1.toFixed(3) }; }); };
const { TAKES, GAP = {}, LEAD = 0.1, HOLD = 1.25 } = await loadScript();
const ids = Object.keys(TAKES);
if (ids.at(-1) !== 'cta') { console.error('script.mjs: the last take must be `cta`'); process.exit(1); }
let t = LEAD; const sections = [], inputs = [], fc = [];
ids.forEach((id, i) => {
  if (!FAKE) need(V('vo', `${id}.json`), 'run gen-vo.mjs --yes first (or build-timeline.mjs --fake to direct before the voice)');
  const j = FAKE ? { tokens: guess(TAKES[id]) } : JSON.parse(fs.readFileSync(V('vo', `${id}.json`), 'utf8'));
  if (i) t += GAP[id] ?? 0.3;
  const start = +t.toFixed(3), speechEnd = j.tokens.at(-1).t1;
  sections.push({ id, start, end: +(start + speechEnd).toFixed(3), tokens: j.tokens.map(k => ({ ...k, t0: +(start + k.t0).toFixed(3), t1: +(start + k.t1).toFixed(3) })) });
  if (!FAKE) inputs.push('-i', V('vo', `${id}.mp3`));
  const ms = Math.round(start * 1000);
  fc.push(`[${i}:a]atrim=0:${(speechEnd + 0.12).toFixed(3)},afade=t=out:st=${(speechEnd + 0.02).toFixed(3)}:d=0.1,adelay=${ms}|${ms}[a${i}]`);
  t = start + speechEnd;
});
sections.forEach((s, i) => { s.until = i + 1 < sections.length ? sections[i + 1].start : +(t + HOLD).toFixed(3); });
const total = +(t + HOLD).toFixed(3);
fs.writeFileSync(V('timeline.json'), JSON.stringify({ total, sections }, null, 1));
fs.writeFileSync(V('timeline.js'), `window.TL = ${JSON.stringify({ total, sections })};`);
fc.push(`${ids.map((_, i) => `[a${i}]`).join('')}amix=inputs=${ids.length}:normalize=0,apad=whole_dur=${total},atrim=0:${total}[vo]`);
if (FAKE) ff(['-f', 'lavfi', '-i', 'anullsrc=r=48000:cl=mono', '-t', String(total), V('vo.wav')]);
else ff([...inputs, '-filter_complex', fc.join(';'), '-map', '[vo]', '-ar', '48000', '-ac', '1', V('vo.wav')]);
const words = sections.reduce((n, s) => n + s.tokens.reduce((m, k) => m + k.said.split(' ').length, 0), 0);
console.log(FAKE ? 'FAKE timings (guessed; rebuild after gen-vo.mjs --yes) ·' : '', 'total', total, 's ·', words, 'words ·', (words / (t - LEAD) * 60).toFixed(0), 'wpm ·', sections.map(s => `${s.id}@${s.start}`).join(' '));
if (total > 60) console.log('WARNING: longer than 60 s (RULES.md §10) — tighten the script or the gaps');
```

### `render-short.mjs`

```js
// Chibi · render. One headless Chrome through cdp.mjs (idle priority). One render at a time:
//   node render-short.mjs <mode> --dir <video>
//   --stills --at auto [--n 8] [--safe]  → <video>/stills/<t>.jpg + stills/sheet.jpg (contact sheet, built in Chrome)
//   --stills --at 0,3.2,8 [--safe]       → the same at exact times · --at sections → the middle of every section
//                                           --safe draws the RULES.md safe zone and boxes any text outside it in red
//   --full [--from 0] [--to 6]           → <video>/frames/f00000.jpg … (resumable; delete frames/ after a visual change)
//   --cues                               → <video>/cues.json only (sfx cues + music plan + cover time; --full writes it too)
//   --scan [--step 0.1]                  → the safe-zone text check on every 0.1 s of the film (no screenshots)
import fs from 'node:fs';
import path from 'node:path';
import { V, FPS, arg, has, browser, openFilm, low, need } from './lib.mjs';
low();
need(V('timeline.js'), 'run build-timeline.mjs first');
need(V('scenes.js'), 'write this video\'s scenes.js');
const safe = has('--safe');
const page = await browser();
const writeCues = async () => { const m = await page.eval('window.META'); fs.writeFileSync(V('cues.json'), JSON.stringify({ ...m, sfx: await page.eval('window.CUES') }, null, 1)); return m; };
try {
  await openFilm(page, safe ? '&safe=1' : '');
  if (has('--cues')) { await writeCues(); console.log('wrote', V('cues.json')); }
  if (has('--stills')) {
    const M = await page.eval('window.META'), total = M.total, at = arg('--at', 'auto');
    let ats;
    if (at === 'auto') { // spread over the film, nudged off wipe and zoom transitions
      const n = +arg('--n', 8), busy = t => M.wipes.some(w => Math.abs(t - w) < 0.4) || M.zooms.some(([a, b]) => t > a - 0.1 && t < b + 0.1);
      ats = Array.from({ length: n }, (_, i) => { let t = total * (i + 0.5) / n; for (let k = 0; busy(t) && k < 20; k++) t += 0.15; return +Math.min(total - 0.05, t).toFixed(2); });
    } else if (at === 'sections') ats = M.sections.map(([, a, b]) => +((a + b) / 2).toFixed(2));
    else ats = at.split(',').map(Number);
    const dir = V('stills'); fs.mkdirSync(dir, { recursive: true });
    const report = [];
    for (const t of ats) {
      await page.eval(`setT(${t})`);
      await page.shot(path.join(dir, `${t}.jpg`), 90);
      if (safe) { const bad = await page.eval('window.safeReport()'); report.push([t, bad]); }
    }
    // contact sheet in the same Chrome (ffmpeg's xstack/drawtext crash on this PC)
    const cols = Math.min(4, ats.length), rows = Math.ceil(ats.length / cols), cw = 360, ch = 640;
    const html = `<html><body style="margin:0;background:#111;display:grid;grid-template-columns:repeat(${cols},${cw}px);gap:6px;padding:6px">` + ats.map(t => `<div style="position:relative;width:${cw}px;height:${ch}px"><img src="${t}.jpg" style="width:100%;height:100%"><b style="position:absolute;left:6px;top:6px;background:#ff0;font:700 20px Consolas;padding:2px 8px">${t}s</b></div>`).join('') + '</body></html>';
    fs.writeFileSync(path.join(dir, '_sheet.html'), html);
    const W = cols * (cw + 6) + 6, H = rows * (ch + 6) + 6;
    await page.send('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 1, mobile: false });
    await page.goto('file:///' + path.join(dir, '_sheet.html').split(path.sep).join('/'));
    await new Promise(r => setTimeout(r, 800));
    const sheet = arg('--sheet', path.join(dir, safe ? 'sheet-safe.jpg' : 'sheet.jpg'));
    await page.shot(path.resolve(sheet), 86); fs.rmSync(path.join(dir, '_sheet.html'));
    console.log('stills', ats.join(' '), '→', sheet);
    if (safe) {
      const n = report.reduce((a, [, b]) => a + b.length, 0);
      for (const [t, bad] of report) if (bad.length) console.log(`  ${t}s: ${bad.length} text run(s) outside the safe zone:`, bad.slice(0, 4).map(b => `"${b.text}" [${b.x0},${b.y0}–${b.x1},${b.y1}]`).join(' · '));
      console.log(n ? `SAFE ZONE: ${n} problem(s) (red boxes on the stills)` : 'SAFE ZONE: all text inside');
    }
  }
  if (has('--scan')) { // the safe-zone text check on every --step seconds of the film, no screenshots (~1 min)
    const M = await page.eval('window.META'), step = +arg('--step', 0.1), hits = [];
    for (let t = 0; t < M.total; t += step) { const bad = await page.eval(`(setT(${t.toFixed(3)}), window.safeReport())`); if (bad.length) hits.push([+t.toFixed(2), bad]); }
    for (const [t, bad] of hits) console.log(`  ${t}s:`, bad.slice(0, 3).map(b => `"${b.text}" [${b.x0},${b.y0}–${b.x1},${b.y1}]`).join(' · '));
    console.log(hits.length ? `SCAN: text outside the safe zone at ${hits.length} of ${Math.ceil(M.total / step)} sampled times` : `SCAN: all text inside the safe zone at all ${Math.ceil(M.total / step)} sampled times`);
  }
  if (has('--full')) {
    const M = await writeCues(), n = Math.round(M.total * FPS);
    const f0 = Math.round(+arg('--from', 0) * FPS), f1 = Math.min(n, Math.round(+arg('--to', M.total) * FPS));
    const dir = V('frames'); fs.mkdirSync(dir, { recursive: true });
    const t0 = Date.now(); let done = 0;
    for (let i = f0; i < f1; i++) {
      const f = path.join(dir, `f${String(i).padStart(5, '0')}.jpg`);
      if (fs.existsSync(f)) continue;
      await page.eval(`setT(${(i / FPS).toFixed(5)})`); await page.shot(f, 92); done++;
      if (done % 150 === 0) console.log(`${i}/${n} frames, ${((Date.now() - t0) / done).toFixed(0)} ms/frame`);
    }
    console.log(`frames ${f0}–${f1 - 1} of ${n} done (${done} new) in ${((Date.now() - t0) / 1000).toFixed(0)} s →`, path.relative(process.cwd(), dir) || dir);
  }
} finally { await page.close(); }
process.exit(0);
```

### `mix.mjs`

```js
// Chibi · sound. Voice + the music bed edited to the story (scenes.js E.music) + the scene engine's SFX cues
// → <video>/mix.wav at −14 LUFS integrated, true peak ≤ −1 dBTP (RULES.md §9).
//   node mix.mjs [--dir <video>]          (needs vo.wav from build-timeline and cues.json from render-short --full / --cues)
// Music plan (E.music): { track: 'ig2', under: 13, fadeOut: 1.6, beds: [{ at, from, until?, fadeIn?, fadeOut? }], muffle: [[a, b]] }
//   beds: pieces of shared/music/<track>.mp3 placed on the film clock (at = film time, from = time in the track);
//   muffle: windows where the bed goes low-passed (e.g. "make it moody"); under: the bed sits this many LU under the voice.
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { V, ROOT, SHARED, FF, ff, loudness, low, need } from './lib.mjs';
low();
need(V('vo.wav'), 'run build-timeline.mjs first'); need(V('cues.json'), 'run render-short.mjs --cues (or --full) first');
const C = JSON.parse(fs.readFileSync(V('cues.json'), 'utf8')), DUR = C.total;
const P = { track: 'ig2', under: 13, fadeOut: 1.6, beds: [{ at: 0, from: 0 }], muffle: [], ...(C.music || {}) };
const SFX = path.join(SHARED, 'sfx');
let MUS = ['.mp3', '.wav', '.m4a'].map(e => path.join(SHARED, 'music', P.track + e)).find(f => fs.existsSync(f));
if (!MUS) { // no such track: the synthesized bed (made once by bed.mjs), played from its start
  MUS = path.join(SHARED, 'music', 'bed.wav');
  if (!fs.existsSync(MUS)) spawnSync(process.execPath, [path.join(ROOT, 'bed.mjs')], { stdio: 'inherit' });
  need(MUS, 'run node bed.mjs (or put your own track in music/ and name it in E.music({ track }))');
  console.log(`music/${P.track} not found: using the synthesized bed (music/bed.wav)`); P.beds = P.beds.map(b => ({ ...b, from: 0 }));
}

// 1 · the bed alone (no gain yet)
{
  const args = [], fc = [];
  P.beds.forEach((b, i) => {
    const until = b.until ?? DUR, len = Math.max(0.05, until - b.at), last = b.until == null;
    const fo = last ? P.fadeOut : (b.fadeOut ?? 0.05);
    args.push('-i', MUS);
    fc.push(`[${i}:a]aformat=channel_layouts=stereo,aresample=48000,atrim=start=${b.from}:end=${(b.from + len).toFixed(3)},asetpts=PTS-STARTPTS,afade=t=in:d=${b.fadeIn ?? 0.02},afade=t=out:st=${Math.max(0, len - fo).toFixed(3)}:d=${fo},adelay=delays=${Math.round(b.at * 1000)}:all=1,apad=whole_dur=${DUR}[b${i}]`);
  });
  fc.push(`${P.beds.map((_, i) => `[b${i}]`).join('')}amix=inputs=${P.beds.length}:normalize=0,atrim=0:${DUR}[bed0]`);
  if (P.muffle.length) {
    const M = P.muffle.map(([a, b]) => `clip((t-${a.toFixed(3)})/0.3,0,1)*clip((${b.toFixed(3)}-t)/0.05,0,1)`).join('+');
    fc.push(`[bed0]asplit=2[md][mw]`, `[mw]lowpass=f=420,lowpass=f=420,volume=1.9,volume='${M}':eval=frame[mw2]`, `[md]volume='1-(${M})':eval=frame[md2]`, `[md2][mw2]amix=inputs=2:normalize=0[bed]`);
  } else fc.push('[bed0]anull[bed]');
  ff([...args, '-filter_complex', fc.join(';'), '-map', '[bed]', '-ar', '48000', '-ac', '2', V('bed.wav')]);
}
// 2 · bed level: `under` LU below the voice (measured), then ducked under speech
const Lv = loudness(V('vo.wav')).I, Lb = loudness(V('bed.wav')).I;
const gdb = P.gain != null ? 20 * Math.log10(P.gain) : (Lv - P.under) - Lb, g = Math.pow(10, gdb / 20);
// 3 · voice + bed + sfx → pre.wav
{
  const sfx = C.sfx || [];
  const args = ['-i', V('vo.wav'), '-i', V('bed.wav')];
  const fc = [`[0:a]aformat=channel_layouts=stereo,aresample=48000,asplit=2[vo][sc]`, `[1:a]volume=${g.toFixed(5)}[m0]`, `[m0][sc]sidechaincompress=threshold=0.05:ratio=3:attack=20:release=320[m]`];
  // one input per sound file, split into as many copies as it has cues (hundreds of -i flags overflow the command line)
  const files = [...new Set(sfx.map(c => c[0]))], uses = Object.fromEntries(files.map(f => [f, []]));
  sfx.forEach((c, i) => uses[c[0]].push(i));
  files.forEach((f, j) => {
    const own = path.join(ROOT, 'sfx', f + '.wav'), file = fs.existsSync(own) ? own : path.join(SFX, f + '.wav'); need(file, 'no such sound in sfx/ (run node sfxgen.mjs)');
    args.push('-i', file);
    const n = uses[f].length;
    fc.push(`[${j + 2}:a]aformat=channel_layouts=stereo,aresample=48000${n > 1 ? `,asplit=${n}` : ''}${uses[f].map(i => `[r${i}]`).join('')}`);
  });
  sfx.forEach(([f, t, gg, len, off = 0], i) => {
    const ms = Math.max(0, Math.round(t * 1000));
    const trim = len ? `atrim=start=${off}:end=${(off + len).toFixed(3)},asetpts=PTS-STARTPTS,afade=t=out:st=${Math.max(0, len - 0.12).toFixed(3)}:d=0.12,` : '';
    fc.push(`[r${i}]${trim}volume=${gg},adelay=delays=${ms}:all=1,apad=whole_dur=${DUR}[s${i}]`);
  });
  fc.push(`[vo][m]${sfx.map((_, i) => `[s${i}]`).join('')}amix=inputs=${sfx.length + 2}:normalize=0:duration=first,atrim=0:${DUR}[pre]`);
  fs.writeFileSync(V('sfx-graph.txt'), fc.join(';\n')); // a file: the graph is too long for a command line
  ff([...args, '-filter_complex_script', V('sfx-graph.txt'), '-map', '[pre]', '-ar', '48000', '-ac', '2', V('pre.wav')]);
}
// 4 · two-pass loudnorm to −14 LUFS / −1.5 dBTP
{
  const p1 = spawnSync(FF, ['-hide_banner', '-i', V('pre.wav'), '-af', 'loudnorm=I=-14:TP=-1.5:LRA=11:print_format=json', '-f', 'null', '-'], { encoding: 'utf8' });
  const j = JSON.parse(p1.stderr.slice(p1.stderr.lastIndexOf('{'), p1.stderr.lastIndexOf('}') + 1));
  ff(['-i', V('pre.wav'), '-af', `loudnorm=I=-14:TP=-1.5: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,aresample=48000`, '-ar', '48000', V('norm.wav')]);
}
// 5 · loudnorm stops at the true-peak ceiling (SFX hits), often ~0.5 LU short: make up the gain into a peak limiter
{
  const L0 = loudness(V('norm.wav'));
  if (Math.abs(L0.I + 14) <= 0.1) fs.copyFileSync(V('norm.wav'), V('mix.wav'));
  else {
    let gain = -14 - L0.I, lim = 0.83;
    for (let k = 0; k < 5; k++) {
      ff(['-i', V('norm.wav'), '-af', `volume=${gain.toFixed(2)}dB,alimiter=limit=${lim.toFixed(3)}:attack=1.5:release=60:level=false:latency=true`, '-ar', '48000', V('mix.wav')]);
      const L1 = loudness(V('mix.wav'));
      if (L1.TP > -1.05) { lim *= 0.95; continue; }
      if (Math.abs(L1.I + 14) <= 0.1) break;
      gain += -14 - L1.I;
    }
  }
}
const L = loudness(V('mix.wav'));
const ok = Math.abs(L.I + 14) <= 0.6 && L.TP <= -1;
console.log(`mix.wav · ${L.I} LUFS · true peak ${L.TP} dBTP · LRA ${L.LRA} LU · voice ${Lv} LUFS · bed ${(Lb + gdb).toFixed(1)} LUFS (${(Lv - Lb - gdb).toFixed(1)} LU under) · ${(C.sfx || []).length} sfx`);
console.log(ok ? 'LOUDNESS OK (−14 LUFS, ≤ −1 dBTP)' : 'LOUDNESS OFF TARGET — check the mix');
for (const f of ['bed.wav', 'pre.wav', 'norm.wav', 'sfx-graph.txt']) fs.rmSync(V(f), { force: true });
```

### `encode.mjs`

```js
// Chibi · encode (H.264 High for Reels / TikTok / Shorts) + the checks:
//   node encode.mjs [--dir <video>] [--to 6 --out test-6s.mp4]
//   → <video>/video.mp4 (H.264 High, yuv420p, 1080×1920, 30 fps, crf 20, maxrate 10M / bufsize 20M, AAC 192k, +faststart)
//   → <video>/cover.jpg (the hook frame: scenes.js E.coverAt) — full encodes only
//   then prints ffprobe, size (≤ 60 MB), length (≤ 60 s), loudness and frame-0 brightness.
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { V, FF, FPS, arg, ff, loudness, probe, low, need } from './lib.mjs';
low();
need(V('mix.wav'), 'run mix.mjs first'); need(V('cues.json'), 'run render-short.mjs --full first');
const C = JSON.parse(fs.readFileSync(V('cues.json'), 'utf8'));
const part = arg('--to') != null, dur = part ? Math.min(+arg('--to'), C.total) : C.total, n = Math.round(dur * FPS);
const out = path.resolve(V('.'), arg('--out', 'video.mp4'));
const frame = i => V('frames', `f${String(i).padStart(5, '0')}.jpg`);
const missing = []; for (let i = 0; i < n; i++) if (!fs.existsSync(frame(i))) missing.push(i);
if (missing.length) { console.error(`${missing.length} frame(s) missing (first: ${missing[0]}) — run render-short.mjs --full${part ? ' --to ' + dur : ''}`); process.exit(1); }
ff(['-framerate', String(FPS), '-i', V('frames', 'f%05d.jpg'), '-i', V('mix.wav'), '-t', dur.toFixed(3),
  '-vf', 'scale=1080:1920:flags=lanczos:in_range=pc:out_range=tv,format=yuv420p', '-color_range', 'tv', '-c:v', 'libx264', '-preset', 'slow', '-profile:v', 'high', '-pix_fmt', 'yuv420p', '-r', String(FPS),
  '-crf', '20', '-maxrate', '10M', '-bufsize', '20M', '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-movflags', '+faststart', out]);
if (!part) { const ci = Math.min(n - 1, Math.round((C.coverAt ?? 0) * FPS)); fs.copyFileSync(frame(ci), V('cover.jpg')); console.log('cover.jpg = frame', ci, `(${(ci / FPS).toFixed(2)} s)`); }

const p = probe(out), v = p.streams.find(s => s.codec_type === 'video'), a = p.streams.find(s => s.codec_type === 'audio');
const mb = +p.format.size / 1048576, secs = +p.format.duration, L = loudness(out);
const y0 = spawnSync(FF, ['-hide_banner', '-i', frame(0), '-vf', 'signalstats,metadata=print', '-f', 'null', '-'], { encoding: 'utf8' }).stderr.match(/YAVG=([\d.]+)/);
console.log(`${path.basename(out)} · ${secs.toFixed(2)} s · ${mb.toFixed(1)} MB · ${v.codec_name} ${v.profile} ${v.width}x${v.height} ${v.pix_fmt} ${v.r_frame_rate} fps · ${a.codec_name} ${a.sample_rate} Hz ${a.channels} ch ${Math.round(a.bit_rate / 1000)} kb/s`);
console.log(`loudness ${L.I} LUFS · true peak ${L.TP} dBTP${part ? ' (an excerpt: the full mix is what must hit −14)' : ''} · frame 0 brightness ${y0 ? (+y0[1]).toFixed(0) : '?'}/255`);
const probs = [];
if (mb > 60) probs.push('bigger than 60 MB'); if (secs > 60.5) probs.push('longer than 60 s');
if (v.width !== 1080 || v.height !== 1920 || v.pix_fmt !== 'yuv420p' || !/High/.test(v.profile)) probs.push('video stream is not H.264 High yuv420p 1080x1920');
if (!part && (Math.abs(L.I + 14) > 0.7 || L.TP > -1)) probs.push('loudness off −14 LUFS / −1 dBTP');
if (y0 && +y0[1] < 20) probs.push('frame 0 is (nearly) black');
console.log(probs.length ? 'PROBLEMS: ' + probs.join(' · ') : 'ENCODE OK');
```

### `whisper-check.mjs`

```js
// Chibi · whisper the final video's sound and compare it with the script (RULES.md §12).
//   node whisper-check.mjs [--dir <video>] [--file video.mp4]   → word diff: [-missing-] {+extra+}, word error rate
import fs from 'node:fs';
import path from 'node:path';
import { V, arg, loadScript, need, probe, whisper, wordDiff, low } from './lib.mjs';
import { spoken } from './markup.mjs';
low();
const file = path.resolve(V('.'), arg('--file', 'video.mp4')); need(file, 'run encode.mjs first');
const { TAKES } = await loadScript();
let want = Object.values(TAKES).map(spoken).join(' ');
// a partial clip (e.g. a test render) is compared with the words spoken inside it
const dur = +probe(file).format.duration, TL = fs.existsSync(V('timeline.json')) ? JSON.parse(fs.readFileSync(V('timeline.json'), 'utf8')) : null;
if (TL && dur < TL.total - 1) want = TL.sections.flatMap(s => s.tokens).filter(k => k.t1 <= dur + 0.05).map(k => k.said).join(' ');
const [r] = whisper([file]);
const d = wordDiff(want, r.text);
fs.writeFileSync(V('whisper.txt'), r.segments.map(s => `[${s.start.toFixed(2)}–${s.end.toFixed(2)}] ${s.text}`).join('\n') + '\n');
console.log(d.out);
console.log(`${d.words} script words · word error rate ${(d.wer * 100).toFixed(1)} % (${r.device}) · transcript → whisper.txt`);
console.log(d.wer <= 0.03 ? 'WHISPER OK' : 'WHISPER DIFFERS — listen to the marked words');
```

### `whisper.py`

```python
# Transcribe 16 kHz mono WAVs with faster-whisper on the GPU (CPU if that fails), loading the model once.
#   python whisper.py <out.json> <a.wav> [<b.wav> ...]
# WHISPER_MODEL picks the model (default: small.en, ~0.5 GB, downloaded once by faster-whisper).
import ctypes, json, os, site, sys, wave

try:  # idle priority, like every heavy job on this PC
    ctypes.windll.kernel32.SetPriorityClass(ctypes.windll.kernel32.GetCurrentProcess(), 0x40)
except Exception:
    pass

for base in site.getsitepackages() + [site.getusersitepackages()]:  # cuDNN/cuBLAS from the nvidia-* wheels
    for sub in ("nvidia/cudnn/bin", "nvidia/cublas/bin"):
        p = os.path.join(base, sub)
        if os.path.isdir(p) and hasattr(os, "add_dll_directory"):
            os.add_dll_directory(p)
            os.environ["PATH"] = p + os.pathsep + os.environ["PATH"]

import numpy as np
from faster_whisper import WhisperModel

out, files = sys.argv[1], sys.argv[2:]
name = os.environ.get("WHISPER_MODEL", "small.en")


def pcm(path):  # faster-whisper's own decoder (PyAV) breaks on av 19, so read the WAV ourselves
    with wave.open(path, "rb") as w:
        return np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(np.float32) / 32768.0


def run(device, compute):
    model = WhisperModel(name, device=device, compute_type=compute, cpu_threads=2)
    res = []
    for f in files:
        a = pcm(f)
        segs, info = model.transcribe(a, language="en", beam_size=5, vad_filter=True, condition_on_previous_text=False)
        end = len(a) / 16000
        res.append({"segments": [{"start": round(s.start, 2), "end": round(min(s.end, end), 2), "text": s.text.strip()} for s in segs if s.start < end - 0.05]})
    return res


try:
    res, device = run("cuda", "int8_float32"), "GPU"
except Exception as e:
    print(f"GPU failed ({e}); using the CPU", file=sys.stderr)
    res, device = run("cpu", "int8"), "CPU"

with open(out, "w", encoding="utf-8") as f:
    json.dump({"model": name, "device": device, "files": res}, f, ensure_ascii=False, indent=1)
```


## Appendix B: a complete example video (`my-video/`)

"Never pay for a content skill · Chibi": 101 words, 11 chapters, 34.8 s. Its music line names a track you won't have (`ig2`), so `mix.mjs` uses the synthesized bed. Write your own script and scenes the same way.

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

```js
// PER VIDEO: the words. One ElevenLabs take per section; the last section must be `cta`.
// Chibi.md — Casper's "never pay for a content skill" script, reworked to @jako.frames' rhythm
// (thousands of X out there → only one / free → the thing → the fix → it's called… → download, done → comment).
// Markup (see markup.mjs): / caption chunk break · *word* key word · *two_words* one token over two words · {shown|said}
export const TAKES = {
  s0: 'There are *thousands* / of content *skills* / out there, / but you should / *never* / pay for one.',
  s1: "And mine are / totally *free!*",
  s2: "Here's the *thing.* / I'm *Casper,* / and I built / *Morphy,* / an agentic / *super_app.*",
  s3: 'I gave away / my own *format,* / and its / *{SKILL.md,|skill dot M D,}* / for *free.*',
  s4: 'People / took it, / *repackaged* it, / and sold it / for *{$30.|thirty dollars.}*',
  s5: "So I'm building / a whole *library* / of content / *formats* / inside *Morphy.*",
  s6: 'No *paywalls.* / No *subscriptions.* / No *strings.*',
  s7: "This one's / called...",
  s8: '*{Chibi.md.|Chibi dot M D.}*',
  s9: "Just *download* it, / add it / to your *skills,* / and you're *done.*",
  cta: "Comment / *{CHIBI,|Chibi,}* / and I'll / send it / to you.",
};
export const GAP = { s1: 0.12, s2: 0.25, s3: 0.2, s4: 0.2, s5: 0.3, s6: 0.2, s7: 0.3, s8: 0.45, s9: 0.3, cta: 0.3 };
export const HOLD = 2.2; // the end card ("Comment “CHIBI”") holds after the last word
```

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

```js
// PER VIDEO: everything the film shows. Chibi.md — "Never pay for a content skill" in @jako.frames' chibi look.
// 11 chapters; every word lands a beat (a card, a stamp, a pose, a face, a camera move, a sound).
window.VIDEO = E => {
  const { S, T, TE, A, h, card, tag, scene, show, st, typed, caretOn, cue, chapter, ghost, act, flash, shake, punch, chibi, ICON, MARK, clamp, lerp, eo, ei, eio, ease, back, elastic, hover, OR, COL, topL } = E;
  E.title = 'Chibi.md';
  const TILT = (y = -10, x = 6) => `perspective(1100px) rotateY(${y}deg) rotateX(${x}deg)`;
  const mono = (txt, size = 20, col = COL.MUTED, extra = '') => `<span class="mono" style="font-size:${size}px;color:${col};${extra}">${txt}</span>`;
  const END = E.TL.total;

  // ---------- chapters, ghosts, acts, music ----------
  const C = [
    [1, 'CONTENT SKILLS', -1], [2, 'NEVER PAY', T('s0', 'but') - 0.05], [3, 'PRICE', S.s1.start - 0.05], [4, 'REAL TALK', S.s2.start - 0.05],
    [5, 'THE GIFT', S.s3.start - 0.05], [6, 'THE COPY', S.s4.start - 0.05], [7, 'THE FIX', S.s5.start - 0.05], [8, 'NO STRINGS', S.s6.start - 0.05],
    [9, 'THE NAME', S.s7.start - 0.05], [10, 'INSTALL', S.s9.start - 0.05], [11, 'GET IT', S.cta.start - 0.05],
  ];
  C.forEach(([n, l, t]) => chapter(n, l, t));
  const at = n => C[n - 1][2], end = n => (n < C.length ? C[n][2] : END + 1);
  [['SKILLS', 1], ['NEVER', 2], ['FREE', 3], ['REAL TALK', 4], ['FORMAT', 5], ['COPIED', 6], ['LIBRARY', 7], ['NO STRINGS', 8], ['CHIBI', 9], ['INSTALL', 10], ['COMMENT', 11]].forEach(([g, n]) => ghost(g, Math.max(0, at(n)), end(n) - 0.1));
  act('red', at(6), end(6) - 0.15, 0.3);
  act('blue', at(7), end(7) - 0.15, 0.3);
  act('spot', at(9) + 0.05, S.s8.end + 0.2, 0.35);
  E.music({ track: 'ig2', under: 13, fadeOut: 1.4, beds: [{ at: 0, from: 0 }], muffle: [[at(9) + 0.05, S.s8.start - 0.02]] });
  E.coverAt = 1.3;

  // ---------- the chibi, start to end (poses, faces, moves) ----------
  const ch = chibi;
  ch.shades(-9, 'on').eyes(-9, 'open').pose(-9, 'idle').at(-9, E.SAFE.cx, 1440, 0.8);
  ch.at(-0.3, E.SAFE.cx, 1470, 1, 2.0, { walk: false });                                           // the camera pushes in on him
  ch.pose(T('s0', 'thousands') - 0.1, 'open').look(T('s0', 'thousands'), 0, -4).pose(T('s0', 'skills') - 0.05, 'idle').look(T('s0', 'skills'), -0.6, 3)
    .pose(T('s0', 'out') - 0.06, 'point').look(T('s0', 'out'), 0.7, -3).pose(T('s0', 'but') - 0.05, 'idle').look(T('s0', 'but'), 0, 0)
    .pose(T('s0', 'never') - 0.1, 'wag').tilt(T('s0', 'never'), -7).mouth(T('s0', 'never') + 0.36, T('s0', 'pay') - 0.02, 'flat').pose(T('s0', 'pay') + 0.1, 'hips').tilt(T('s0', 'pay'), 3);
  ch.pose(S.s1.start - 0.05, 'chest').tilt(S.s1.start, 0).pose(T('s1', 'totally') - 0.05, 'idle').pose(T('s1', 'free') - 0.1, 'cheer').hop(T('s1', 'free') - 0.06, 1.1).blush(T('s1', 'free'), 0.9);
  // REAL TALK: close-up, the shades get flung off, then he pulls back and introduces himself
  ch.at(S.s2.start - 0.12, E.SAFE.cx, 2140, 2.25, 0.4, { walk: false }).pose(S.s2.start - 0.1, 'idle').blush(S.s2.start, 0.62);
  ch.shades(T('s2', 'the') - 0.02, 'throw').eyes(T('s2', 'the') + 0.02, 'wide').pose(T('s2', 'the') - 0.08, 'reach').eyes(T('s2', 'thing') + 0.12, 'open').pose(T('s2', 'thing'), 'present').tilt(T('s2', 'thing'), 4).look(T('s2', 'thing'), 0.5, 4);
  ch.at(T('s2', "I'm") - 0.2, E.SAFE.cx, 1470, 1, 0.45, { walk: false }).pose(T('s2', "I'm") - 0.08, 'chest').eyes(T('s2', 'Casper'), 'happy').tilt(T('s2', 'Casper'), -3).look(T('s2', "I'm"), 0, -3).hop(T('s2', 'Casper') - 0.02, 0.4, false)
    .pose(T('s2', 'built') - 0.05, 'reach').eyes(T('s2', 'built'), 'open').look(T('s2', 'built'), 0.7, 5).pose(T('s2', 'agentic') - 0.05, 'point').eyes(T('s2', 'agentic'), 'wink')
    .pose(T('s2', 'super') - 0.06, 'thumb').eyes(T('s2', 'super'), 'happy').look(T('s2', 'super'), 0, 0).hop(T('s2', 'super') - 0.04, 0.6);
  // THE GIFT
  ch.pose(T('s3', 'gave') - 0.1, 'give').eyes(S.s3.start, 'calm').look(S.s3.start, -0.4, -3).pose(T('s3', 'format') - 0.05, 'presentL').eyes(T('s3', 'format'), 'open').look(T('s3', 'format'), -0.7, -4)
    .pose(T('s3', 'its') - 0.05, 'reach').look(T('s3', 'its'), 0.7, 4).eyes(T('s3', 'SKILL'), 'wide').pose(T('s3', 'free') - 0.1, 'cheer').eyes(T('s3', 'free') - 0.04, 'happy').hop(T('s3', 'free') - 0.06, 0.8).look(T('s3', 'free'), 0, 0);
  // THE COPY (red)
  ch.pose(S.s4.start - 0.05, 'idle').eyes(S.s4.start, 'flat').tilt(S.s4.start, -5).look(S.s4.start, 0.6, -5).pose(T('s4', 'took') - 0.05, 'pointL').look(T('s4', 'took'), -0.5, -3)
    .pose(T('s4', 'repackaged') - 0.06, 'hips').eyes(T('s4', 'repackaged'), 'flat').mouth(T('s4', 'repackaged') + 0.55, T('s4', 'and') - 0.02, 'wavy').pose(T('s4', 'sold') - 0.05, 'shrug').eyes(T('s4', 'sold'), 'sad')
    .pose(T('s4', '$30') - 0.08, 'cheeks').eyes(T('s4', '$30') - 0.06, 'wide').mouth(T('s4', '$30') - 0.04, S.s4.end + 0.28, 'o').tilt(T('s4', '$30'), 0).look(T('s4', '$30'), 0, 0).hop(T('s4', '$30') - 0.06, 0.35, false);
  // THE FIX (blue): he walks to the left, the library builds up top right
  ch.at(S.s5.start - 0.12, 262, 1470, 0.96, 0.75).pose(S.s5.start - 0.12, 'idle').eyes(S.s5.start, 'happy').look(S.s5.start, -0.6, 0)
    .pose(T('s5', 'building') - 0.04, 'reach').look(T('s5', 'building'), 0.8, 5).eyes(T('s5', 'library'), 'open').pose(T('s5', 'formats') - 0.05, 'up').eyes(T('s5', 'formats'), 'wide')
    .pose(T('s5', 'inside') - 0.05, 'thumb').eyes(T('s5', 'Morphy') - 0.05, 'happy').hop(T('s5', 'Morphy') - 0.04, 0.5);
  // NO STRINGS: back to the middle, a finger wag per "No"
  ch.at(S.s6.start - 0.15, E.SAFE.cx, 1470, 1, 0.55).pose(S.s6.start - 0.08, 'wag').eyes(S.s6.start, 'calm').look(S.s6.start, 0, 0).tilt(T('s6', 'No', 1), 6).tilt(T('s6', 'No', 2), -6)
    .pose(T('s6', 'strings') + 0.15, 'hips').eyes(T('s6', 'strings') + 0.1, 'happy').tilt(S.s6.end, 0);
  // THE NAME (spotlight): small in the light, looking up, then a big cheer
  ch.at(S.s7.start - 0.1, E.SAFE.cx, 1455, 0.82, 0.5, { walk: false }).pose(S.s7.start - 0.05, 'idle').eyes(S.s7.start, 'wide').tilt(S.s7.start, -4).look(S.s7.start, 0, -6)
    .mouth(S.s7.end + 0.02, S.s8.start - 0.02, 'o').pose(T('s7', 'called') - 0.05, 'cheeks');
  ch.pose(S.s8.start - 0.06, 'cheer').eyes(S.s8.start - 0.04, 'happy').hop(S.s8.start - 0.06, 1.3).tilt(S.s8.start, 0).blush(S.s8.start, 0.95);
  // INSTALL
  ch.at(S.s9.start - 0.15, E.SAFE.cx, 1470, 1.05, 0.45, { walk: false }).blush(S.s9.start, 0.62).pose(S.s9.start - 0.05, 'present').eyes(S.s9.start, 'open').look(S.s9.start, 0.4, 3)
    .pose(T('s9', 'add') - 0.05, 'reach').look(T('s9', 'add'), 0.6, 4).eyes(T('s9', 'skills'), 'happy').pose(T('s9', 'skills') - 0.04, 'point')
    .pose(T('s9', 'done') - 0.08, 'thumb').eyes(T('s9', 'done') - 0.04, 'wink').hop(T('s9', 'done') - 0.06, 0.7).look(T('s9', 'done'), 0, 0);
  // GET IT: the shades drop back on, he waves
  ch.at(S.cta.start - 0.12, E.SAFE.cx, 1470, 1, 0.4, { walk: false }).pose(S.cta.start - 0.06, 'chest').eyes(S.cta.start, 'open').shades(T('cta', 'CHIBI') - 0.25, 'drop')
    .pose(T('cta', 'send') - 0.08, 'wave').hop(TE('cta', 'you') + 0.55, 0.6).pose(END - 1.6, 'wave2');

  // ---------- camera: punches on the hits ----------
  [T('s0', 'thousands') + 0.1, T('s0', 'never'), T('s1', 'free'), T('s2', 'super'), T('s3', 'free'), T('s4', '$30'), T('s5', 'library'), T('s6', 'strings'), S.s8.start, T('s9', 'done'), T('cta', 'CHIBI')].forEach(t => punch(t, 0.05));

  // ======================================================================================================================
  // 01 · CONTENT SKILLS — a wall of skill tags flies past, a gold counter rolls up to 1,000s
  scene(-1, at(2) + 0.35, (fr, bk) => {
    const wall = A(bk, -60, 300, 1200, 520, '', '', { transformOrigin: '50% 0', filter: 'blur(1.4px)' });
    const NAMES = ['seo', 'docx', 'video', 'logo', 'charts', 'shopify', 'notion', 'finance', 'pitch-deck', 'invoices', 'recipes', 'brand-voice', 'scraper', 'podcast', 'shorts', 'security', 'mcp', 'blender', 'research', 'translate', 'copywriting', 'tiktok', 'legal', 'api', 'audio', 'sales', 'ui-ux', 'travel', 'python', 'calendar', 'figma', 'reels', 'emails', 'threads', 'slides', 'excel'];
    const hot = new Set([2, 4, 14, 19, 25, 31]);
    const tags = NAMES.map((n, i) => { const e = tag(wall, 20 + (i % 6) * 196 + (Math.floor(i / 6) % 2) * 60, Math.floor(i / 6) * 80, n, { hot: hot.has(i) }); e.dataset.free = 1; return e; });
    const FL = [['docs', 150, 1130, 1, -8], ['shorts', 230, 1350, 0, 6], ['fitness', 770, 1190, 0, -5], ['pdf', 820, 1400, 0, 9], ['research', 120, 820, 0, -10], ['branding', 820, 760, 0, 12]];
    const floaters = FL.map(([n, x, y, ho]) => { const e = tag(bk, x, y, n, { hot: !!ho }); e.dataset.free = 1; return e; });
    const num = A(fr, 120, 360, 780, null, 'gold', '908', { fontSize: '176px', textAlign: 'center', lineHeight: '1' });
    const lab = A(fr, 120, 556, 780, null, 'mono', 'CONTENT SKILLS', { fontSize: '28px', letterSpacing: '0.42em', color: '#ffcf6a', textAlign: 'center', textShadow: '0 0 18px rgba(255,190,60,0.7)' });
    const flare = A(fr, 260, 330, 500, 260, '', '', { background: 'radial-gradient(ellipse at 50% 50%, rgba(255,220,120,0.55), transparent 65%)', mixBlendMode: 'screen' });
    const t1k = T('s0', 'thousands') + 0.1, tOut = T('s0', 'out');
    cue('swup', -0.05, 0.05); cue('tickroll', 0.0, 0.05); cue('shimmer', t1k - 0.02, 0.05); cue('thud', t1k, 0.06); cue('whoosh2', tOut - 0.15, 0.04);
    return t => {
      const k0 = eo((t + 0.3) / 2.2);
      wall.style.transform = `perspective(900px) rotateX(${lerp(36, 20, k0).toFixed(1)}deg) rotateY(-6deg) translate(${(-t * 26).toFixed(1)}px, ${lerp(-40, 0, k0).toFixed(1)}px) scale(${lerp(1.25, 1, k0).toFixed(3)})`;
      tags.forEach((e, i) => { const k = clamp((t + 0.3 - i * 0.02) / 0.3); const lit = t > tOut && (i * 5 + Math.floor(t * 6)) % 9 === 0; e.style.opacity = (k * (lit ? 0.75 : 0.18 + 0.2 * ((i * 7) % 5) / 5)).toFixed(2); });
      floaters.forEach((e, i) => show(e, t, -0.3 + i * 0.1, at(2) - 0.1, { kin: 'pop', base: hover(t, i, 10) + ` rotate(${FL[i][4]}deg) translateY(${(-(t + 1) * (8 + i * 3)).toFixed(0)}px)`, kout: 'fly' }));
      const n = t < t1k ? Math.round(lerp(908, 999, eo((t + 0.3) / (t1k + 0.3)))) : null;
      num.textContent = n == null ? '1,000s' : String(n);
      const pk = t >= t1k ? back((t - t1k) / 0.32, 2.4) : 0;
      show(num, t, -0.34, at(2) + 0.1, { kin: 'pop', base: `scale(${(t >= t1k ? lerp(1.3, 1, pk) : 0.92 + 0.08 * eo((t + 0.3) / 1)).toFixed(3)})`, kout: 'up' });
      show(lab, t, -0.1, at(2) + 0.05, { kin: 'rise', dy: 20 });
      flare.style.opacity = t >= t1k ? (Math.max(0, 1 - (t - t1k) / 0.8) * 0.9 + 0.25 * (0.5 + 0.5 * Math.sin(t * 5))).toFixed(2) : 0;
    };
  });

  // 02 · NEVER PAY — a checkout card; a cursor heads for "Buy now"; NEVER stamps over it; the cursor gets slapped away
  scene(at(2) - 0.05, at(3) + 0.35, (fr) => {
    const c = card(fr, 190, 360, 640, 330, `<div style="padding:30px 34px">${mono('● content-skill.md · checkout', 19)}<div style="font-size:48px;font-weight:800;margin-top:22px">Pro Content Skill</div>${mono('one .md file · instant download', 18, '#6f655d', 'display:block;margin-top:8px')}<div style="display:flex;align-items:center;justify-content:space-between;margin-top:34px"><div class="pr" style="font-size:74px;font-weight:900;letter-spacing:-0.03em;position:relative">$29.99<i class="sk" style="position:absolute;left:-6px;top:50%;height:8px;width:0;background:${COL.RED};border-radius:4px;box-shadow:0 0 14px ${COL.RED}"></i></div><div class="buy" style="background:${OR};color:#fff;font-size:30px;font-weight:800;padding:18px 30px;border-radius:14px;box-shadow:0 0 20px ${OR}88">Buy now</div></div></div>`);
    const cur = A(fr, 0, 0, 54, 54, '', ICON.cursor(54));
    const stamp = A(fr, 300, 560, 400, null, '', 'NEVER', { fontSize: '104px', fontWeight: 900, color: COL.RED, border: `9px solid ${COL.RED}`, borderRadius: '20px', textAlign: 'center', letterSpacing: '0.04em', textShadow: '0 0 24px rgba(255,77,77,0.6)', boxShadow: '0 0 30px rgba(255,77,77,0.45), inset 0 0 20px rgba(255,77,77,0.3)', background: 'rgba(40,6,6,0.6)' });
    const sk = c.querySelector('.sk'), buy = c.querySelector('.buy');
    const tC = at(2) + 0.05, tN = T('s0', 'never') - 0.02, tP = T('s0', 'pay'), tO = T('s0', 'one');
    cue('blip', tC + 0.05, 0.05); cue('drag', T('s0', 'you'), 0.03); cue('stamp', tN + 0.1, 0.11); cue('snap', tN + 0.12, 0.03); shake(tN + 0.1, 14);
    cue('blipdown', tP + 0.05, 0.05); cue('swup', tO + 0.05, 0.035);
    return t => {
      show(c, t, tC, at(3) - 0.05, { kin: 'flip', base: TILT(-8, 4) + ' ' + hover(t, 2, 4) });
      // the cursor glides toward Buy now, then is knocked away when NEVER lands
      const ka = eio((t - T('s0', 'you')) / 0.6), kb = ei((t - tN - 0.1) / 0.35);
      const cx = lerp(980, 700, ka) + kb * 300, cy = lerp(840, 610, ka) + kb * 260;
      if (t < T('s0', 'you') - 0.05 || t > tN + 0.5) st(cur, 0); else st(cur, 1 - kb, `translate(${cx.toFixed(0)}px, ${cy.toFixed(0)}px) rotate(${(kb * 120).toFixed(0)}deg)`);
      sk.style.width = (clamp((t - tN - 0.12) / 0.18) * 300).toFixed(0) + 'px';
      buy.style.opacity = (1 - 0.7 * clamp((t - tP) / 0.2)).toFixed(2); buy.style.filter = t > tP ? 'grayscale(1)' : 'none';
      show(stamp, t, tN + 0.08, at(3) - 0.05, { kin: 'slam', from: 2.0, din: 0.16, base: 'rotate(-11deg)', blur: 4 });
      if (t > tO) c.style.transform += ` scale(${(1 - 0.06 * eo((t - tO) / 0.3)).toFixed(3)})`;
    };
  });

  // 03 · PRICE — the price rolls down to a chrome $0 like a slot machine; FREE stamp + swoosh + sparkles
  scene(at(3) - 0.05, at(4) + 0.35, (fr) => {
    const wrap = A(fr, 140, 355, 740, 260, '', '', { overflow: 'hidden', display: 'flex', justifyContent: 'center' });
    const reel = A(wrap, 0, 0, 740, null, 'chrome', ['$0', '$4', '$9', '$12', '$19', '$24', '$29'].reverse().map(s => `<div style="height:260px;line-height:260px;text-align:center">${s}</div>`).join(''), { fontSize: '250px', position: 'absolute', left: 0, top: 0 }); reel.dataset.free = 1; // clipped by its window
    const free = A(fr, 170, 380, null, null, 'mono', 'FREE', { fontSize: '42px', color: OR, border: `4px solid ${OR}`, borderRadius: '999px', padding: '6px 26px', background: 'rgba(60,20,6,0.75)', boxShadow: `0 0 22px ${OR}88` });
    const sw = A(fr, 300, 640, 420, 14, '', '', { background: `linear-gradient(90deg, transparent, ${OR}, #ffb37a)`, borderRadius: '8px', boxShadow: `0 0 18px ${OR}`, transformOrigin: '0 50%' });
    const sparks = Array.from({ length: 10 }, (_, i) => A(fr, 500, 480, 34, 34, '', ICON.spark(34, i % 2 ? '#ffd27a' : OR)));
    const tA = S.s1.start - 0.05, tT = T('s1', 'totally') - 0.05, tF = T('s1', 'free') - 0.06;
    cue('tickroll', tA + 0.05, 0.045); cue('thud', tT + 0.05, 0.07); cue('stamp', tF, 0.06); cue('sparkle', tF + 0.05, 0.06); cue('swup', tF + 0.02, 0.03);
    return t => {
      show(wrap, t, tA - 0.04, at(4) - 0.05, { kin: 'pop', from: 0.85 });
      const k = eio((t - tA) / (tT - tA + 0.05)); const y = -lerp(0, 6 * 260, k) + (t > tT ? Math.sin(clamp((t - tT) / 0.3) * Math.PI) * 18 : 0);
      reel.style.transform = `translateY(${(y).toFixed(1)}px)`; reel.style.filter = k > 0 && k < 1 ? `blur(${(Math.sin(k * Math.PI) * 4).toFixed(1)}px)` : 'none';
      show(free, t, tF, at(4) - 0.05, { kin: 'slam', from: 2.2, din: 0.16, base: 'rotate(-14deg)' });
      sw.style.transform = `scaleX(${eo((t - tF - 0.05) / 0.3).toFixed(3)}) rotate(-3deg)`; sw.style.opacity = t < tF || t > at(4) - 0.05 ? 0 : 1;
      sparks.forEach((s, i) => { const u = (t - tF) / 0.8, a = i / 10 * Math.PI * 2 + 0.3; if (u < 0 || u > 1) { st(s, 0); return; } const r = eo(u) * (220 + (i % 3) * 50); st(s, 1 - ease(u), `translate(${(Math.cos(a) * r).toFixed(0)}px, ${(Math.sin(a) * r * 0.5).toFixed(0)}px) rotate(${(u * 220).toFixed(0)}deg) scale(${(1.2 - u * 0.6).toFixed(2)})`); });
    };
  });

  // 04 · REAL TALK — close-up, a typed side comment, shades flung; a gallery frame around him; the app he built
  scene(at(4) - 0.05, at(5) + 0.35, (fr, bk) => {
    const note = A(fr, 610, 1000, 340, null, 'mono', '', { fontSize: '26px', color: '#e8a37c', letterSpacing: '0.04em', textTransform: 'none' });
    const frame = A(bk, 300, 990, 420, 520, '', `<svg width="420" height="520" viewBox="0 0 420 520" fill="none" stroke="${OR}" stroke-width="5" stroke-linecap="round"><path d="M4 70 V4 H70 M350 4 H416 V70 M416 450 V516 H350 M70 516 H4 V450"/></svg>`, { filter: `drop-shadow(0 0 10px ${OR})` });
    const plate = card(fr, 690, 1150, 240, 92, `<div style="padding:12px 16px"><div class="serif" style="font-size:34px;color:#f4ece4">Casper</div>${mono('builder · 2026', 14)}</div>`);
    const win = card(fr, 250, 380, 640, 360, `<div style="display:flex;align-items:center;gap:9px;padding:16px 20px;border-bottom:1.5px solid rgba(255,255,255,0.07)"><i style="width:13px;height:13px;border-radius:50%;background:#ff5f57"></i><i style="width:13px;height:13px;border-radius:50%;background:#febc2e"></i><i style="width:13px;height:13px;border-radius:50%;background:#28c840"></i><span class="mono" style="font-size:19px;margin-left:12px;color:#cdbfb3">MORPHY</span></div><div class="cv" style="position:relative;height:300px;background-image:radial-gradient(circle, rgba(255,255,255,0.08) 1.3px, transparent 1.8px);background-size:22px 22px"></div>`);
    const cv = win.querySelector('.cv');
    const MINI = [[24, 22, 190, 120, 'chat'], [232, 22, 170, 130, 'card'], [420, 22, 190, 236, 'agent'], [24, 160, 378, 98, 'notes']];
    const mini = MINI.map(([x, y, w, hh, k], i) => A(cv, x, y, w, hh, '', `<div style="padding:12px 14px">${mono(k, 15, i === 2 ? OR : '#8f8379')}<div style="height:9px;width:70%;background:rgba(255,255,255,0.13);border-radius:5px;margin-top:12px"></div><div style="height:9px;width:48%;background:rgba(255,255,255,0.08);border-radius:5px;margin-top:9px"></div></div>`, { background: '#211915', border: `1.5px solid ${i === 2 ? OR + '99' : 'rgba(255,255,255,0.08)'}`, borderRadius: '12px' }));
    const PL = [['agents', 150, 600], ['chats', 140, 690], ['cards', 170, 780]];
    const pills = PL.map(([n, x, y]) => tag(fr, x, y, n, { hot: true }));
    const sup = A(fr, 600, 350, null, null, 'mono', 'SUPER APP', { fontSize: '28px', color: '#2a1206', background: 'linear-gradient(180deg,#ffe08a,#ffb320)', padding: '10px 20px', borderRadius: '12px', boxShadow: '0 0 26px rgba(255,190,40,0.7)' });
    const tN = T('s2', 'the') + 0.1, tC = T('s2', "I'm") - 0.12, tB = T('s2', 'built') - 0.05, tM = T('s2', 'Morphy') - 0.06, tA = T('s2', 'agentic') - 0.08, tS = T('s2', 'super') - 0.05;
    cue('swup', S.s2.start - 0.15, 0.05); cue('key1', tN, 0.03); cue('key2', tN + 0.1, 0.03); cue('key3', tN + 0.2, 0.03); cue('key1', tN + 0.3, 0.03);
    cue('swdown', tC - 0.05, 0.04); cue('blip', tC + 0.12, 0.05); cue('ding1', T('s2', 'Casper') + 0.05, 0.04);
    cue('whoosh2', tB - 0.1, 0.04); mini.forEach((_, i) => cue('bubble', tM + 0.1 + i * 0.08, 0.04)); PL.forEach((_, i) => cue('blip2', tA + i * 0.11, 0.035)); cue('stamp', tS, 0.06); cue('shimmer', tS + 0.05, 0.04);
    return t => {
      show(note, t, tN - 0.02, tC, { kin: 'fade', din: 0.1, kout: 'fade', dout: 0.15 }); typed(note, "// here's the thing", t, tN, tN + 0.5); note.textContent += caretOn(t) ? '▍' : '';
      show(frame, t, tC + 0.1, tB, { kin: 'pop', from: 1.25, din: 0.3, kout: 'shrink', base: `perspective(900px) rotateY(${(Math.sin(t) * 4).toFixed(1)}deg)` });
      show(plate, t, T('s2', 'Casper') - 0.02, tB, { kin: 'fly', dx: 120, dy: 0, base: 'rotate(-3deg)', kout: 'fade' });
      show(win, t, tM, at(5) - 0.05, { kin: 'flip', base: TILT(-12, 6) + ' ' + hover(t, 1, 4) });
      win.hot(clamp((t - tS) / 0.25) * (0.8 + 0.2 * Math.sin(t * 7)), '#ffc93c');
      mini.forEach((m, i) => show(m, t, tM + 0.1 + i * 0.08, null, { kin: 'pop', from: 0.6 }));
      pills.forEach((p, i) => { const tt = tA + i * 0.11; show(p, t, tt, at(5) - 0.05, { kin: 'fly', dx: -70, dy: 50, base: hover(t, i, 5) + ` rotate(${[-6, 4, -3][i]}deg)` }); });
      show(sup, t, tS, at(5) - 0.05, { kin: 'slam', from: 2, din: 0.16, base: 'rotate(6deg)' });
    };
  });

  // 05 · THE GIFT — a gift box drops, its lid pops, my format folder slides out, SKILL.md lifts out, stamped FREE
  scene(at(5) - 0.05, at(6) + 0.35, (fr) => {
    const box = A(fr, 410, 470, 200, 200, '', ICON.giftBox(200), { filter: 'drop-shadow(0 0 26px rgba(239,122,60,0.55))' });
    const lid = A(fr, 410, 430, 200, 100, '', ICON.giftLid(200), { transformOrigin: '20% 100%' });
    const FILES = ['scenes.js', 'voice.mjs', 'render.mjs', 'mix.mjs', 'style.css'];
    const fold = card(fr, 120, 360, 390, 320, `<div style="padding:22px 26px">${mono('▾ my-format/', 20, '#cdbfb3')}${FILES.map((f, i) => `<div class="f${i}" style="display:flex;align-items:center;gap:12px;margin-top:${i ? 12 : 20}px;font:600 25px Consolas,monospace;color:#a99c90">${ICON.file(24, '#6f655d')}${f}</div>`).join('')}</div>`);
    const rows = FILES.map((_, i) => fold.querySelector('.f' + i));
    const sk = card(fr, 480, 420, 430, 160, `<div style="display:flex;align-items:center;gap:20px;padding:28px 28px">${ICON.file(64)}<div><div style="font:800 46px 'Inter Tight'">SKILL.md</div>${mono('the whole format in one file', 16)}</div></div>`, { hot: true });
    const stamp = A(fr, 330, 690, null, null, '', `<div style="display:flex;align-items:center;gap:12px">${ICON.check(40)}<span class="mono" style="font-size:30px;color:${COL.GREEN}">GIVEN AWAY · FREE</span></div>`, { padding: '10px 18px', border: `3px solid ${COL.GREEN}`, borderRadius: '14px', background: 'rgba(6,30,14,0.8)', boxShadow: '0 0 24px rgba(61,220,132,0.4)' });
    const tG = T('s3', 'gave') - 0.1, tW = T('s3', 'away'), tF = T('s3', 'format') - 0.12, tK = T('s3', 'SKILL') - 0.12, tR = T('s3', 'free') - 0.06;
    cue('swdown', tG - 0.1, 0.04); cue('thud', tG + 0.2, 0.05); cue('boing', tW + 0.02, 0.03); cue('bubble', tW + 0.05, 0.05);
    cue('swup', tF, 0.04); FILES.forEach((_, i) => cue('tick' + (1 + i % 3), tF + 0.25 + i * 0.07, 0.04)); cue('blip2', tK, 0.05); cue('shimmer', tK + 0.05, 0.035); cue('stamp', tR + 0.04, 0.08); cue('ding2', tR + 0.1, 0.05);
    return t => {
      // box drops in, bounces; lid flips open on "away"; both shrink away when the folder comes out
      const kd = clamp((t - tG) / 0.4), yy = lerp(-420, 0, elastic(kd) * 0.4 + back(kd, 1.4) * 0.6);
      const gone = t > tF ? ei((t - tF) / 0.25) : 0;
      st(box, t < tG ? 0 : 1 - gone, `translateY(${yy.toFixed(0)}px) scale(${(1 - 0.5 * gone).toFixed(3)})`);
      const kl = back(clamp((t - tW) / 0.3), 2);
      st(lid, t < tG ? 0 : 1 - gone, `translateY(${(yy - kl * 120).toFixed(0)}px) translateX(${(-kl * 60).toFixed(0)}px) rotate(${(-kl * 38).toFixed(1)}deg) scale(${(1 - 0.5 * gone).toFixed(3)})`);
      show(fold, t, tF, at(6) - 0.05, { kin: 'fly', dx: 260, dy: 120, base: TILT(12, 4) });
      rows.forEach((r, i) => { const k = eo((t - tF - 0.25 - i * 0.07) / 0.2); r.style.opacity = k.toFixed(2); r.style.transform = `translateX(${((1 - k) * -30).toFixed(0)}px)`; });
      show(sk, t, tK, at(6) - 0.05, { kin: 'fly', dx: -200, dy: 100, base: TILT(-10, 4) + ` translateY(${(-Math.max(0, Math.sin((t - tK) * 2)) * 6).toFixed(1)}px)` });
      show(stamp, t, tR, at(6) - 0.05, { kin: 'slam', from: 1.5, din: 0.16, base: 'rotate(-5deg)' });
    };
  });

  // 06 · THE COPY (red) — anonymous users grab it, copies fan out, get relabelled and locked, then $30.00 rolls up
  scene(at(6) - 0.05, at(7) + 0.35, (fr) => {
    const orig = card(fr, 300, 400, 420, 140, `<div style="display:flex;align-items:center;gap:18px;padding:26px">${ICON.file(54)}<div><div style="font:800 40px 'Inter Tight'">SKILL.md</div>${mono('free · yours', 16)}</div></div>`);
    const USERS = [['user_291', 150, 360], ['growth.guru', 640, 380], ['prompt_king', 380, 640]];
    const users = USERS.map(([n, x, y]) => A(fr, x, y, null, null, '', `<div style="display:flex;align-items:center;gap:10px;background:rgba(30,12,12,0.9);border:1.5px solid rgba(255,90,90,0.35);border-radius:999px;padding:6px 16px 6px 6px">${ICON.user(40, '#d68c8c')}<span class="mono" style="font-size:17px;color:#e7b0b0;text-transform:none">${n}</span></div>`));
    const COP = [['ULTIMATE PACK', 110, 370, -8], ['PRO FORMAT', 365, 430, 4], ['SKILL BUNDLE', 615, 365, 9]];
    const copies = COP.map(([n, x, y]) => card(fr, x, y, 290, 170, `<div style="padding:22px">${mono('repackaged', 15, COL.RED)}<div style="display:flex;align-items:center;gap:10px;margin-top:10px">${ICON.lock(30, '#d68c8c')}<span class="lbl" style="font:800 29px 'Inter Tight'">SKILL.md</span></div><div style="height:9px;width:76%;background:rgba(255,255,255,0.1);border-radius:5px;margin-top:16px"></div></div>`, { hot: COL.RED }));
    const lbls = copies.map(c => c.querySelector('.lbl'));
    const price = card(fr, 290, 625, 440, 170, `<div style="display:flex;align-items:center;gap:20px;padding:24px 30px">${ICON.tag(76)}<div><div class="mono" style="font-size:18px;color:#ff9a9a">PRICE</div><div class="pv" style="font:700 80px Consolas,monospace;color:${COL.RED};text-shadow:0 0 24px rgba(255,77,77,0.8);letter-spacing:0.02em">$0.00</div></div></div>`, { hot: COL.RED });
    const pv = price.querySelector('.pv');
    const tP = S.s4.start - 0.05, tT = T('s4', 'took') - 0.05, tR = T('s4', 'repackaged') - 0.06, tS = T('s4', 'sold') - 0.06, t30 = T('s4', '$30') - 0.06;
    USERS.forEach((_, i) => cue('blip2', tP + i * 0.09, 0.035)); cue('drag', tT, 0.04); COP.forEach((_, i) => { cue('swup', tT + 0.15 + i * 0.1, 0.03); cue('stamp', tR + 0.15 + i * 0.13, 0.04); });
    cue('tickroll', tS + 0.05, 0.05); cue('kaching', t30 + 0.02, 0.09); cue('boom', t30 + 0.05, 0.06); shake(t30 + 0.06, 18, 0.4); flash(t30 + 0.04, 0.16, 0.2);
    return t => {
      show(orig, t, tP - 0.02, tT + 0.15, { kin: 'pop', kout: 'shrink', base: TILT(0, 4) });
      users.forEach((u, i) => show(u, t, tP + i * 0.09, tR - 0.05, { kin: 'pop', base: hover(t, i, 6), kout: 'fade' }));
      copies.forEach((c, i) => { const t0 = tT + 0.15 + i * 0.1; show(c, t, t0, at(7) - 0.05, { kin: 'fly', dx: 300 - i * 260, dy: 40, from: 0.4, base: hover(t, i, 4) + ` rotate(${COP[i][3]}deg)` }); const k = t > tR + 0.15 + i * 0.13; lbls[i].textContent = k ? COP[i][0] : 'SKILL.md'; if (k && t < tR + 0.3 + i * 0.13) c.style.transform += ' scale(1.08)'; });
      const pk = clamp((t - tS - 0.05) / (t30 - tS)); pv.textContent = '$' + (t < t30 + 0.02 ? (pk * 29.99).toFixed(2) : '30.00');
      show(price, t, tS, at(7) - 0.05, { kin: 'rise', dy: 60, base: TILT(0, 6) + (t > t30 ? ` scale(${lerp(1.18, 1, back((t - t30) / 0.3, 2.5)).toFixed(3)})` : '') });
    };
  });

  // 07 · THE FIX (blue) — he walks left; a terminal builds the library; four format cards land and get their checks
  scene(at(7) - 0.05, at(8) + 0.35, (fr) => {
    const globe = A(fr, 330, 370, 150, 150, '', ICON.globe(150));
    const term = card(fr, 480, 360, 430, 170, `<div style="padding:16px 20px">${mono('● ● ●  content-formats', 15, '#8f8fb3')}<div class="ln" style="font:600 22px Consolas,monospace;color:#d8d8f0;margin-top:14px;white-space:pre"></div><div class="ln2" style="font:600 22px Consolas,monospace;color:#9aa0c8;margin-top:8px;white-space:pre"></div><div class="ln3" style="font:600 22px Consolas,monospace;color:${COL.GREEN};margin-top:8px;white-space:pre"></div></div>`);
    const F = [['Paper', 'paper.md'], ['Pixel', 'pixel.md'], ['Glow', 'glow.md'], ['Lantern', 'lantern.md']];
    const grid = F.map(([n, f], i) => card(fr, 370 + (i % 2) * 280, 555 + Math.floor(i / 2) * 124, 262, 108, `<div style="padding:16px 18px"><div style="display:flex;justify-content:space-between;align-items:center"><span style="font:800 31px 'Inter Tight'">${n}</span><span class="ck" style="display:flex;align-items:center;gap:6px">${ICON.check(20)}<span class="mono" style="font-size:13px;color:${COL.GREEN}">SKILL.md</span></span></div>${mono(`0${i + 1} — ${f}`, 15, '#8f8fb3', 'display:block;margin-top:10px')}</div>`));
    const cks = grid.map(g => g.querySelector('.ck'));
    const chip = A(fr, 380, 806, null, null, 'mono', 'INSIDE MORPHY · FREE', { fontSize: '22px', color: '#fff', background: 'rgba(80,100,220,0.35)', border: '2px solid #8fa3ff', borderRadius: '999px', padding: '6px 18px', boxShadow: '0 0 20px rgba(111,141,255,0.6)' });
    const ln = term.querySelector('.ln'), ln2 = term.querySelector('.ln2'), ln3 = term.querySelector('.ln3');
    const tB = T('s5', 'building') - 0.1, tL = T('s5', 'library') - 0.1, tF = T('s5', 'formats') - 0.15, tM = T('s5', 'inside') - 0.05;
    cue('blip', S.s5.start, 0.04); for (let i = 0; i < 9; i++) cue('key' + (1 + i % 3), tB + i * 0.06, 0.03); cue('swup', tL, 0.04);
    F.forEach((_, i) => { cue('bubble', tL + 0.15 + i * 0.12, 0.045); cue('ding' + (1 + i % 2), tF + 0.2 + i * 0.1, 0.03); }); cue('shimmer', tM + 0.1, 0.04); cue('ding2', T('s5', 'Morphy'), 0.05);
    return t => {
      show(globe, t, S.s5.start, at(8) - 0.05, { kin: 'pop', base: `rotate(${(t * 30).toFixed(1)}deg)` });
      show(term, t, S.s5.start - 0.02, at(8) - 0.05, { kin: 'flip', base: TILT(-14, 6) + ' ' + hover(t, 3, 4) });
      typed(ln, '> build library --free', t, tB, tB + 0.55); typed(ln2, '  formats ready: ' + Math.min(11, Math.max(0, Math.floor((t - tL) * 8))), t, tL - 0.1, tL + 0.15); typed(ln3, '  ✓ no paywall', t, tM, tM + 0.35);
      ln.textContent += t > tB && t < tL && caretOn(t) ? '▍' : '';
      grid.forEach((g, i) => { show(g, t, tL + 0.15 + i * 0.12, at(8) - 0.05, { kin: 'drop', dy: 120, base: TILT(-14, 6) }); const kc = clamp((t - tF - 0.2 - i * 0.1) / 0.18); cks[i].style.opacity = kc.toFixed(2); cks[i].style.transform = `scale(${back(kc, 2.5).toFixed(3)})`; g.hot(kc * (0.6 + 0.4 * Math.sin(t * 4 + i)), '#8fa3ff'); });
      show(chip, t, tM, at(8) - 0.05, { kin: 'pop', from: 0.3 });
    };
  });

  // 08 · NO STRINGS — a row per "No"; a red strike + ✕ on each; the strings row snaps and falls
  scene(at(8) - 0.05, at(9) + 0.3, (fr) => {
    const R = [['Paywall', ICON.lock(46, '#cdbfb3'), 'unlock for $29', 'paywalls'], ['Subscription', ICON.card(46, '#cdbfb3'), '$9.99 / month', 'subscriptions'], ['Strings attached', ICON.string(46, '#cdbfb3'), 'terms apply*', 'strings']];
    const rows = R.map(([n, ic, sub], i) => card(fr, 200, 360 + i * 148, 620, 126, `<div style="display:flex;align-items:center;gap:22px;padding:22px 26px">${ic}<div style="position:relative"><div style="font:800 40px 'Inter Tight'">${n}</div>${mono(sub, 17)}<i class="sk" style="position:absolute;left:-8px;top:28px;height:6px;width:0;background:${COL.RED};border-radius:3px;box-shadow:0 0 12px ${COL.RED}"></i></div><div class="x" style="margin-left:auto">${ICON.cross(58)}</div></div>`));
    const ts = R.map(([, , , w]) => T('s6', w) - 0.05), tsn = T('s6', 'strings') + 0.3;
    ts.forEach((tt, i) => { cue('swup', T('s6', 'No', i) - 0.1, 0.03); cue('blip', T('s6', 'No', i) - 0.04, 0.04); cue('stamp', tt + 0.12, 0.06); });
    cue('snap', tsn, 0.07); cue('swdown', tsn + 0.05, 0.04);
    return t => {
      rows.forEach((r, i) => {
        const tIn = T('s6', 'No', i) - 0.1;
        let base = TILT(i % 2 ? 8 : -8, 4);
        if (i === 2 && t > tsn) { const u = ei((t - tsn) / 0.6); base += ` translateY(${(u * 500).toFixed(0)}px) rotate(${(u * 22).toFixed(1)}deg)`; }
        show(r, t, tIn, at(9) - 0.05, { kin: 'fly', dx: i % 2 ? 140 : -140, dy: 0, base });
        const k = clamp((t - ts[i] - 0.05) / 0.2); r.querySelector('.sk').style.width = (k * 360).toFixed(0) + 'px';
        const x = r.querySelector('.x'); x.style.transform = `scale(${t < ts[i] + 0.1 ? 0 : back((t - ts[i] - 0.1) / 0.25, 2.6).toFixed(3)}) rotate(${(t < ts[i] + 0.1 ? 0 : (1 - eo((t - ts[i] - 0.1) / 0.3)) * -90).toFixed(0)}deg)`;
        r.style.opacity = (+r.style.opacity * (1 - 0.35 * k)).toFixed(3); r.hot(k, COL.RED);
      });
    };
  });

  // 09 · THE NAME (spotlight) — "This one's called…" (pulsing dots), a white-out, then Chibi .md slams in layered
  scene(at(9) - 0.05, at(10) + 0.35, (fr) => {
    const dots = A(fr, 60, 600, 900, null, '', '', { textAlign: 'center', fontSize: '84px', fontWeight: 900, color: OR, letterSpacing: '0.2em', textShadow: `0 0 20px ${OR}` });
    const t1 = A(fr, 100, 340, 820, null, '', 'Chibi', { fontSize: '200px', fontWeight: 900, letterSpacing: '-0.05em', color: '#fff6ee', textAlign: 'center', lineHeight: '1', textShadow: '0 10px 40px rgba(0,0,0,0.6)' });
    const t2 = A(fr, 330, 470, 560, null, 'serif', '.md', { fontSize: '210px', color: OR, textAlign: 'center', lineHeight: '1', textShadow: `0 0 40px ${OR}aa` });
    const t3 = A(fr, 100, 690, 820, null, 'serif', 'Chibi.md — a content format', { fontSize: '40px', color: '#e8a37c', textAlign: 'center' });
    const sparks = Array.from({ length: 14 }, (_, i) => A(fr, 500, 500, 40, 40, '', ICON.spark(40, i % 3 ? '#ffd27a' : OR)));
    const tN = S.s8.start - 0.06;
    cue('riser2', S.s7.start - 0.1, 0.045); cue('swell', tN - 0.55, 0.04); cue('boom', tN + 0.02, 0.08); cue('shimmer', tN + 0.1, 0.06); cue('thud', tN + 0.3, 0.05); cue('sparkle', tN + 0.45, 0.04);
    flash(tN, 0.85, 0.45, 0.06);
    return t => {
      const kd = clamp((t - T('s7', 'called')) / 0.75); dots.textContent = '.'.repeat(1 + Math.min(2, Math.floor(kd * 3)));
      show(dots, t, T('s7', 'called'), tN - 0.02, { kin: 'pop', din: 0.15, kout: 'fade', dout: 0.06, base: `scale(${(1 + 0.08 * Math.sin(t * 12)).toFixed(3)})` });
      show(t1, t, tN, at(10) - 0.05, { kin: 'slam', from: 1.35, din: 0.2, blur: 14, base: `rotate(-4deg)`, kout: 'fade' });
      show(t2, t, tN + 0.22, at(10) - 0.05, { kin: 'slam', from: 1.5, din: 0.2, blur: 10, base: `rotate(-4deg)`, kout: 'fade' });
      show(t3, t, tN + 0.45, at(10) - 0.05, { kin: 'rise', dy: 30 });
      sparks.forEach((s, i) => { const u = (t - tN) / 0.95, a = i / 14 * Math.PI * 2; if (u < 0 || u > 1) { st(s, 0); return; } const r = eo(u) * (260 + (i % 3) * 70); st(s, 1 - ease(u), `translate(${(Math.cos(a) * r).toFixed(0)}px, ${(Math.sin(a) * r * 0.55).toFixed(0)}px) rotate(${(u * 220).toFixed(0)}deg) scale(${(1.2 - u * 0.6).toFixed(2)})`); });
    };
  });

  // 10 · INSTALL — the zip downloads; a cursor drags it into Skills; the row lights up green; ✓ Done.
  scene(at(10) - 0.05, at(11) + 0.35, (fr) => {
    const zip = card(fr, 250, 360, 520, 126, `<div style="display:flex;align-items:center;gap:22px;padding:22px 28px">${ICON.zip(54)}<div style="flex:1"><div style="font:800 38px 'Inter Tight'">chibi.zip</div><div class="st mono" style="font-size:17px;margin-top:6px"></div><div style="height:8px;background:rgba(255,255,255,0.1);border-radius:4px;margin-top:10px;overflow:hidden"><i class="bar" style="display:block;height:100%;width:0;background:${OR};border-radius:4px"></i></div></div></div>`);
    const ROWS = ['paper.md', 'chibi.md', 'pixel.md', 'glow.md'];
    const panel = card(fr, 160, 400, 700, 340, `<div style="display:flex"><div style="width:150px;padding:18px 16px;border-right:1.5px solid rgba(255,255,255,0.06)">${['General', 'Skills', 'Memory', 'Agents', 'Library'].map((n, i) => `<div style="font:600 19px 'Inter Tight';color:${i === 1 ? '#fff' : '#6f655d'};margin:${i ? 14 : 4}px 0">${n}</div>`).join('')}</div><div style="flex:1;padding:16px 18px">${mono('Skills · upload a skill', 16)}<div class="drop" style="margin-top:10px;height:56px;border:2px dashed rgba(255,255,255,0.15);border-radius:12px;display:flex;align-items:center;justify-content:center;font:600 18px Consolas,monospace;color:#6f655d">drop a .zip here</div>${ROWS.map((r, i) => `<div class="r${i}" style="display:flex;align-items:center;gap:12px;margin-top:8px;padding:8px 12px;border-radius:10px;border:2px solid transparent;font:600 22px Consolas,monospace;color:#cdbfb3">${ICON.file(22, i === 1 ? OR : '#6f655d')}${r}<span style="margin-left:auto;font-size:15px;color:#6f655d">${i === 1 ? 'just added' : 'skill'}</span></div>`).join('')}</div></div>`);
    const row = panel.querySelector('.r1'), drop = panel.querySelector('.drop');
    const cur = A(fr, 0, 0, 52, 52, '', ICON.cursor(52));
    const done = A(fr, 330, 660, null, null, '', `<div style="display:flex;align-items:center;gap:16px">${ICON.check(100)}<span style="font:900 110px 'Inter Tight';color:#fff;letter-spacing:-0.04em;text-shadow:0 8px 30px rgba(0,0,0,0.6)">Done.</span></div>`);
    const stx = zip.querySelector('.st'), bar = zip.querySelector('.bar');
    const tD = T('s9', 'download') - 0.1, tA = T('s9', 'add') - 0.1, tK = T('s9', 'skills') - 0.05, tX = T('s9', 'done') - 0.06;
    cue('blip', tD, 0.05); cue('tickroll', tD + 0.1, 0.035); cue('ding1', tD + 0.6, 0.05); cue('swup', tA - 0.05, 0.04); cue('drag', tA + 0.15, 0.05); cue('click', tK - 0.05, 0.05); cue('bubble', tK, 0.05);
    cue('ding2', tK + 0.1, 0.05); cue('stamp', tX + 0.04, 0.07); cue('sparkle', tX + 0.1, 0.05);
    return t => {
      // the zip card shrinks and is dragged into the drop zone by the cursor
      const kz = eio((t - tA - 0.1) / 0.5);
      show(zip, t, tD, tK + 0.05, { kin: 'pop', base: TILT(0, 6) + ` translate(${(kz * 60).toFixed(0)}px, ${(kz * 110).toFixed(0)}px) scale(${lerp(1, 0.55, kz).toFixed(3)})`, kout: 'shrink', dout: 0.15 });
      const p = clamp((t - tD - 0.1) / 0.5); bar.style.width = (p * 100).toFixed(1) + '%'; bar.style.background = p >= 1 ? COL.GREEN : OR;
      stx.textContent = p < 1 ? `downloading ${Math.round(lerp(0, 99, p))}%` : 'downloaded ✓'; stx.style.color = p >= 1 ? COL.GREEN : COL.MUTED;
      show(panel, t, tA - 0.05, at(11) - 0.05, { kin: 'flip', base: TILT(-10, 8) });
      if (t > tA && t < tK + 0.3) st(cur, Math.min(1, (t - tA) / 0.1) * (1 - clamp((t - tK - 0.1) / 0.2)), `translate(${lerp(620, 690, kz).toFixed(0)}px, ${lerp(420, 520, kz).toFixed(0)}px) scale(${t > tK - 0.05 && t < tK + 0.08 ? 0.85 : 1})`); else st(cur, 0);
      const kd = clamp((t - tA - 0.2) / 0.3) * (1 - clamp((t - tK) / 0.2)); drop.style.borderColor = kd > 0.05 ? `rgba(239,122,60,${0.4 + 0.6 * kd})` : 'rgba(255,255,255,0.15)'; drop.style.boxShadow = kd > 0.05 ? `0 0 ${20 * kd}px ${OR}` : 'none';
      const k = clamp((t - tK) / 0.2); row.style.borderColor = k > 0 ? `rgba(61,220,132,${k})` : 'transparent'; row.style.boxShadow = k > 0 ? `0 0 ${18 * k}px rgba(61,220,132,0.6)` : 'none'; row.style.background = k > 0 ? `rgba(61,220,132,${0.08 * k})` : '';
      row.style.transform = `scale(${t > tK ? lerp(1.06, 1, back((t - tK) / 0.3, 2)).toFixed(3) : 1})`;
      show(done, t, tX, at(11) - 0.05, { kin: 'slam', from: 1.6, din: 0.16, base: 'rotate(-3deg)' });
    };
  });

  // 11 · GET IT — the comment box types CHIBI, sends, the file arrives in DMs; white-out → "Comment “CHIBI”" end card
  const tEnd = TE('cta', 'you') + 0.4;
  scene(at(11) - 0.05, END + 1, (fr) => {
    const box = card(fr, 130, 370, 760, 108, `<div style="display:flex;align-items:center;gap:18px;padding:18px 20px"><div style="width:68px;height:68px;border-radius:50%;background:linear-gradient(135deg,#3a2c24,#1e1612);border:2px solid rgba(255,255,255,0.1);display:flex;align-items:center;justify-content:center">${ICON.user(56)}</div><div style="flex:1;background:rgba(255,255,255,0.05);border-radius:999px;padding:14px 22px;font:700 32px 'Inter Tight';color:#fff"><span class="tx"></span><i class="car" style="display:inline-block;width:3px;height:34px;background:${OR};vertical-align:-6px;margin-left:3px"></i><span class="ph" style="color:#6f655d;font-weight:600">Add a comment…</span></div><div class="snd" style="width:64px;height:64px;border-radius:50%;background:${OR};display:flex;align-items:center;justify-content:center;box-shadow:0 0 22px ${OR}88">${ICON.send(32)}</div></div>`, { hot: true });
    const dm = card(fr, 170, 520, 680, 150, `<div style="display:flex;align-items:center;gap:20px;padding:22px 26px"><img src="${MARK}" style="width:84px;height:84px;border-radius:50%;object-fit:cover;background:#fff"><div style="flex:1"><div class="mono" style="font-size:17px;color:#a99c90;text-transform:none">@appmorphy sent you</div><div style="display:flex;align-items:center;gap:12px;margin-top:6px">${ICON.file(34)}<span style="font:800 40px 'Inter Tight'">chibi.md</span></div></div>${ICON.check(52)}</div>`, { hot: COL.GREEN });
    const plane = A(fr, 790, 390, 60, 60, '', ICON.send(60, OR));
    const tx = box.querySelector('.tx'), car = box.querySelector('.car'), ph = box.querySelector('.ph'), snd = box.querySelector('.snd');
    const tC = T('cta', 'CHIBI') - 0.05, tS = TE('cta', 'CHIBI') + 0.1, tDm = T('cta', 'send') - 0.08;
    cue('blip', S.cta.start - 0.05, 0.05); 'CHIBI'.split('').forEach((_, i) => cue('key' + (1 + i % 3), tC + i * 0.07, 0.05)); cue('click', tS - 0.02, 0.05); cue('send', tS, 0.07); cue('bubble', tDm, 0.05); cue('ding1', tDm + 0.12, 0.06);
    // end card
    const ec1 = A(topL, 100, 330, 820, null, '', 'Comment', { fontSize: '130px', fontWeight: 900, letterSpacing: '-0.04em', color: '#fff6ee', textAlign: 'center', lineHeight: '1', textShadow: '0 10px 40px rgba(0,0,0,0.6)' });
    const ec2 = A(topL, 100, 460, 820, null, 'serif', '“CHIBI”', { fontSize: '220px', color: OR, textAlign: 'center', lineHeight: '1', textShadow: `0 0 44px ${OR}aa` });
    const arr = A(topL, 790, 980, 110, 110, '', ICON.arrow(110)); [ec1, ec2, arr].forEach(e => st(e, 0));
    cue('swell', tEnd - 0.6, 0.035); cue('boom', tEnd, 0.06); cue('shimmer', tEnd + 0.1, 0.05); cue('swdown', tEnd + 0.5, 0.03);
    flash(tEnd, 0.7, 0.4, 0.04); E.hideCaptions(tEnd, END + 1);
    return t => {
      show(box, t, S.cta.start - 0.08, tEnd, { kin: 'flip', base: TILT(0, 6), kout: 'fade', dout: 0.06 });
      const n = typed(tx, 'CHIBI', t, tC, tC + 0.35); ph.style.display = n ? 'none' : 'inline'; car.style.opacity = caretOn(t) || n < 5 ? 1 : 0;
      if (t > tS) { tx.textContent = ''; ph.style.display = 'inline'; }
      snd.style.transform = `scale(${t > tS - 0.05 && t < tS + 0.25 ? 0.82 + 0.4 * Math.sin(clamp((t - tS + 0.05) / 0.3) * Math.PI) : 1})`;
      const u = (t - tS) / 0.6; if (u > 0 && u < 1) st(plane, 1 - u, `translate(${(u * 140).toFixed(0)}px, ${(-u * 120).toFixed(0)}px) rotate(-20deg) scale(${(1 - u * 0.4).toFixed(2)})`); else st(plane, 0);
      show(dm, t, tDm, tEnd, { kin: 'fly', dx: 0, dy: -120, base: TILT(0, 6), kout: 'fade', dout: 0.06 });
      show(ec1, t, tEnd, null, { kin: 'slam', from: 1.3, din: 0.22, blur: 10 });
      show(ec2, t, tEnd + 0.18, null, { kin: 'slam', from: 1.5, din: 0.22, blur: 10, base: `rotate(-3deg) scale(${(1 + 0.02 * Math.sin(t * 4)).toFixed(3)})` });
      show(arr, t, tEnd + 0.5, null, { kin: 'pop', base: `translate(${(Math.sin(t * 6) * 8).toFixed(1)}px, ${(Math.sin(t * 6) * 8).toFixed(1)}px)` });
    };
  });
};
```

