# paper-reel

Adopt this skill: https://proskills.md/skills/other/haiderfarooq3-paper-reel

Page: https://proskills.md/skills/other/haiderfarooq3-paper-reel
This document is the public skill bundle for that page. It contains SKILL.md and every helper file the catalog carries. No login is required.

## File: SKILL.md
size: 8208
sha: 71689586b49df984e38f0adb4e18665c8fe70eb0
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/SKILL.md

````text
---
name: "paper-reel"
description: "Make a faceless 9:16 explainer reel in the cut-paper 'Claude mascot' style (flat textured paper shapes, a terracotta box mascot with a hat per scene, a screwed header sign, a typing terminal bar, counter cards, rubber stamps, word-by-word paper caption chips, hard cuts on every voice line, a soft lo-fi bed) — fully free and local: Kokoro voice, whisper.cpp word timing, canvas + Playwright + ffmpeg. Use when the user asks for a reel/short/TikTok in this paper or Claude-mascot style, a 'skill checkpoint'/tool-spotlight reel, or a faceless explainer with a comment-keyword CTA."
argument-hint: "[topic or tool] [CTA keyword]"
---

# paper-reel

You make 18–25s vertical reels drawn entirely in code. The voice is the clock: every spoken line is one scene, and props, stamps and counters land on the words that name them. No paid APIs, no API keys.

Everything runs through `bin/reel` in this skill folder (call it `REEL=<this skill dir>/bin/reel`). The pieces live in the user's project, e.g. `<project>/pieces/<name>/`.

## 0. Setup check
`$REEL doctor` checks node, ffmpeg, Playwright and Chromium, whisper-cli and its model, Kokoro (Python and model), and the fonts. Run it first. It prints the exact install command for anything missing; tell the user that command and stop until it's fixed.

## 1. Script (approve with the user before drawing)
6–8 lines, 18–25s total, about 3.5 words/s. The proven arc:
1. **Hook:** what it is plus the payoff in one line ("This free Claude skill turns any topic into a Khan Academy style video.")
2. **How it works,** 2–3 lines, one concrete step each.
3. **The catch:** one honest limitation (it builds trust).
4. **"And get this."** The kicker: free, open source, a big number.
5. **CTA:** "For the <thing>, comment <WORD>." One keyword, all caps in the script.

Rules:
- Every factual claim (stars, prices, features) must be checked from a primary source first: the repo API, the README, the vendor page.
- Write numbers the way they're said ("over a hundred stars").
- Avoid words Kokoro mangles; read the align output to catch them. "Mac" came out as "map", so the script said "laptop". "Claude" is heard as "clawed", which is the correct pronunciation.
- A line id can't be `voice`.

Put it in `voice/script.json`: `{ "lead": 0.15, "tail": 0.6, "lines": [{ "id": "hook", "text": "...", "after": 0.12 }, ...] }`.

## 2. Voice + timing
`$REEL new <project>/pieces/<name>` copies the starter. `--from khan-skill` copies the full worked example instead. Edit the script, then run `$REEL voice <piece> [--voice am_michael] [--speed 1.32]`:
- Kokoro writes one take per line. `voice.mjs` joins them into `voice/voice.wav`, and `align.mjs` writes every word's time into `voice.json` using whisper.cpp.
- Check the `align: N/M words heard` line and the heard text. Fix any mispronounced words by rewording, then rerun just those lines with `--only id1,id2`.
- Good Kokoro voices: `am_michael` (default, energetic male), `am_fenrir`, `af_heart` (female). Speed 1.25–1.35 hits about 3.5 words/s.
- `--say` uses the macOS voice instead. It only works from a normal Terminal, not inside sandboxed agents.

## 3. Scenes
Edit `src/scenes.js`: one `SCENES.<line id> = () => { ... }` per line, in that order. To cut again inside a line on a word, add `SPLITS.push([lineId, word, n, sceneKey])` plus `SCENES[sceneKey]`. Sound cues go in `SFX_CUES()`. Read [references/style.md](references/style.md) for the layout grid and the look's rules. The kit API is at the end of this file.

What every scene needs (the style's fingerprint):
1. **A full-bleed world** with its own wall colour and one visual metaphor for the line: a classroom, a security desk, a carnival, a studio, a corkboard, a dusk road, a checkout. Never a prop floating on blank paper.
2. **The sign:** `prSign('HEADLINE')`. 2–5 words, caps, a different headline per scene. The kicker defaults to `SKILL SPOTLIGHT`; pass `{ kicker }` to brand a series.
3. **One hero prop that acts on a spoken word:** cue it with `VW(line, word).t0` (write-on, `countUp` counter, `prStamp` slam, `prCheck`/`prCross` pop, a typing `prTerm`).
4. **The mascot** `clawd(CLAWD_X, CLAWD_Y, { hat, mood, hold, view })`, bottom right and cropped by the frame edge. Give it a different hat per scene: `guard`, `mortar`, `boater`, `ranger`, `cap`, `phones`, `beret`. Its mood changes on the payoff word (`happy`, `wow`, `flat`). Use `view: 'side', walk: true` when it walks or points.
5. **3+ details of background life:** a clock, bunting, books, a lamp, notes, stars, a moon, acoustic foam, lane marks moving.
6. **`prChips()` last.** The captions are automatic, one paper scrap per spoken word. Keep the bottom-left band (y 1450–1600, x < 930) free of other text.

## 4. Check, then export
- `$REEL still <piece> 2.6,6,10,14 look.png --scale 0.4`. Look at the frames, and check them against [references/style.md](references/style.md).
- `$REEL check <piece>` must say `text: PASS` and `deterministic: true`. Fix any overlaps it reports by moving props, not captions.
- `$REEL export <piece> <out.mp4>` renders a 1080x1920 30fps video at about -14 LUFS in about 30 seconds and deletes the frame cache.
- Tell the user what you can't verify: you can't hear the mix, so ask them to listen.

## 5. Deliver
Give the MP4 path, a contact strip, the checks' numbers, the weakest shot, and a post caption in this pattern:

```
<hook sentence>. Follow so the DM lands 📩👇
READ BELOW: <3–4 numbered plain facts from the source>
The catch: <limitation>.
For the link, comment "<WORD>".
```

Never post it anywhere without the user's explicit go-ahead.

## Kit API (styles/paper-reel/kit.js)
- **Shapes:** `pp(path, fill, { key, shadow, sb, sy, tex, rim, shade })`. Paths: `pR(x,y,w,h,r)`, `pE(cx,cy,rx,ry)`, `pG(points)`, `pA(cx,cy,r,a0,a1)`. Transforms: `withT(x,y,rot,scale,fn)`. Colours are in `PR.*`.
- **Text:** `txt(str,x,y,size,color,{ weight, font, align, frac })`, `txtW`, `fitSize`. Fonts: `F_HEAD`, `F_CHIP`, `F_CHALK`, `F_MONO`.
- **World:** `prWall(colour,{ streak, panel, gap })`, `prFloor(y, colour, { studs, trim })`, `prBunting(y)`, `prClock(x,y,r,h,m)`, `prCorkboard`, `prNote`, `prStar`, `prBoard(x,y,w,h)`.
- **Story props:** `prSign`, `prTerm(str, frac)`, `prCounter(label, value, x, y, { s, rot, big })`, `countUp(t0,d,a,b)`, `prStamp(str,x,y,colour,t0,{ rot, size })`, `prCheck`/`prCross(x,y,t)`, `prChecklist(x,y,w,title,items,t0,dt)`, `prRepoCard(x,y,repo,sub,stars,ta,tb)`, `prCommentBox(x,y,WORD,t)`, `prReceipt(x,y,rows,total)`, `prFolder(x,y,s,fill,{ label })`, `prBurst(x,y,t0)`, `chalk(str,x,y,size,frac)`, `chalkLine(points, frac)`, `sparkIcon`.
- **Mascot:** `clawd(x,y,{ s, hat, mood, look, view, walk, hold: propStick(angle) | propPencil(angle), hand: { x, y }, flip })`. Set `CLAWD_SCALE` once per piece.
- **Time:** `TT` (now), `E0` (scene start), `ev(t,d)` 0→1, `popS(t)` back-eased pop, `springMove(t0,a,b,SPRING.playful)`, `VL(id)`, `VW(id, word, n)`, `seg`, `EZ.*`.
- **Determinism:** seeded `RNG(...)` only; never `Math.random()` or the clock. `$REEL check` verifies it.

## Gotchas
- **Kokoro inside sandboxed agents:** phonemizer copies `libespeak-ng.dylib` to a temp folder that macOS refuses to load. `tools/kokoro_lines.py` symlinks the original signed library instead; keep that patch.
- **faster-whisper** can hang in sandboxes. The timing uses whisper.cpp (`tools/align.mjs`, model at `~/.cache/whisper/ggml-base.en.bin`, override with `WHISPER_MODEL`).
- **Old copies of the style:** `build.mjs` uses a `styles/paper-reel/` folder beside or above the piece before this skill's copy. Delete or refresh any old copy in the project.
- **Name clashes:** `kit/core.js` and `kit/morph.js` already define many short top-level names (`smooth`, `grain`, `ink`, `burst`, `push`, `layer`, `W`, `H`, `F`, `B`…), so prefix your own scene helpers. A clash shows up as a `syntax error` at build.
- **Reviewing frames:** the animate `review.mjs` sheets need ffmpeg's `drawtext`, which the Homebrew build lacks. Use `still` and `check` instead.

Renderer and tools are vendored from the MIT-licensed `animate` skill (cth9191/animate); see NOTICE.md.
````

## File: skills/paper-reel/SKILL.md
size: 8208
sha: 71689586b49df984e38f0adb4e18665c8fe70eb0
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/skills/paper-reel/SKILL.md

````text
---
name: "paper-reel"
description: "Make a faceless 9:16 explainer reel in the cut-paper 'Claude mascot' style (flat textured paper shapes, a terracotta box mascot with a hat per scene, a screwed header sign, a typing terminal bar, counter cards, rubber stamps, word-by-word paper caption chips, hard cuts on every voice line, a soft lo-fi bed) — fully free and local: Kokoro voice, whisper.cpp word timing, canvas + Playwright + ffmpeg. Use when the user asks for a reel/short/TikTok in this paper or Claude-mascot style, a 'skill checkpoint'/tool-spotlight reel, or a faceless explainer with a comment-keyword CTA."
argument-hint: "[topic or tool] [CTA keyword]"
---

# paper-reel

You make 18–25s vertical reels drawn entirely in code. The voice is the clock: every spoken line is one scene, and props, stamps and counters land on the words that name them. No paid APIs, no API keys.

Everything runs through `bin/reel` in this skill folder (call it `REEL=<this skill dir>/bin/reel`). The pieces live in the user's project, e.g. `<project>/pieces/<name>/`.

## 0. Setup check
`$REEL doctor` checks node, ffmpeg, Playwright and Chromium, whisper-cli and its model, Kokoro (Python and model), and the fonts. Run it first. It prints the exact install command for anything missing; tell the user that command and stop until it's fixed.

## 1. Script (approve with the user before drawing)
6–8 lines, 18–25s total, about 3.5 words/s. The proven arc:
1. **Hook:** what it is plus the payoff in one line ("This free Claude skill turns any topic into a Khan Academy style video.")
2. **How it works,** 2–3 lines, one concrete step each.
3. **The catch:** one honest limitation (it builds trust).
4. **"And get this."** The kicker: free, open source, a big number.
5. **CTA:** "For the <thing>, comment <WORD>." One keyword, all caps in the script.

Rules:
- Every factual claim (stars, prices, features) must be checked from a primary source first: the repo API, the README, the vendor page.
- Write numbers the way they're said ("over a hundred stars").
- Avoid words Kokoro mangles; read the align output to catch them. "Mac" came out as "map", so the script said "laptop". "Claude" is heard as "clawed", which is the correct pronunciation.
- A line id can't be `voice`.

Put it in `voice/script.json`: `{ "lead": 0.15, "tail": 0.6, "lines": [{ "id": "hook", "text": "...", "after": 0.12 }, ...] }`.

## 2. Voice + timing
`$REEL new <project>/pieces/<name>` copies the starter. `--from khan-skill` copies the full worked example instead. Edit the script, then run `$REEL voice <piece> [--voice am_michael] [--speed 1.32]`:
- Kokoro writes one take per line. `voice.mjs` joins them into `voice/voice.wav`, and `align.mjs` writes every word's time into `voice.json` using whisper.cpp.
- Check the `align: N/M words heard` line and the heard text. Fix any mispronounced words by rewording, then rerun just those lines with `--only id1,id2`.
- Good Kokoro voices: `am_michael` (default, energetic male), `am_fenrir`, `af_heart` (female). Speed 1.25–1.35 hits about 3.5 words/s.
- `--say` uses the macOS voice instead. It only works from a normal Terminal, not inside sandboxed agents.

## 3. Scenes
Edit `src/scenes.js`: one `SCENES.<line id> = () => { ... }` per line, in that order. To cut again inside a line on a word, add `SPLITS.push([lineId, word, n, sceneKey])` plus `SCENES[sceneKey]`. Sound cues go in `SFX_CUES()`. Read [references/style.md](references/style.md) for the layout grid and the look's rules. The kit API is at the end of this file.

What every scene needs (the style's fingerprint):
1. **A full-bleed world** with its own wall colour and one visual metaphor for the line: a classroom, a security desk, a carnival, a studio, a corkboard, a dusk road, a checkout. Never a prop floating on blank paper.
2. **The sign:** `prSign('HEADLINE')`. 2–5 words, caps, a different headline per scene. The kicker defaults to `SKILL SPOTLIGHT`; pass `{ kicker }` to brand a series.
3. **One hero prop that acts on a spoken word:** cue it with `VW(line, word).t0` (write-on, `countUp` counter, `prStamp` slam, `prCheck`/`prCross` pop, a typing `prTerm`).
4. **The mascot** `clawd(CLAWD_X, CLAWD_Y, { hat, mood, hold, view })`, bottom right and cropped by the frame edge. Give it a different hat per scene: `guard`, `mortar`, `boater`, `ranger`, `cap`, `phones`, `beret`. Its mood changes on the payoff word (`happy`, `wow`, `flat`). Use `view: 'side', walk: true` when it walks or points.
5. **3+ details of background life:** a clock, bunting, books, a lamp, notes, stars, a moon, acoustic foam, lane marks moving.
6. **`prChips()` last.** The captions are automatic, one paper scrap per spoken word. Keep the bottom-left band (y 1450–1600, x < 930) free of other text.

## 4. Check, then export
- `$REEL still <piece> 2.6,6,10,14 look.png --scale 0.4`. Look at the frames, and check them against [references/style.md](references/style.md).
- `$REEL check <piece>` must say `text: PASS` and `deterministic: true`. Fix any overlaps it reports by moving props, not captions.
- `$REEL export <piece> <out.mp4>` renders a 1080x1920 30fps video at about -14 LUFS in about 30 seconds and deletes the frame cache.
- Tell the user what you can't verify: you can't hear the mix, so ask them to listen.

## 5. Deliver
Give the MP4 path, a contact strip, the checks' numbers, the weakest shot, and a post caption in this pattern:

```
<hook sentence>. Follow so the DM lands 📩👇
READ BELOW: <3–4 numbered plain facts from the source>
The catch: <limitation>.
For the link, comment "<WORD>".
```

Never post it anywhere without the user's explicit go-ahead.

## Kit API (styles/paper-reel/kit.js)
- **Shapes:** `pp(path, fill, { key, shadow, sb, sy, tex, rim, shade })`. Paths: `pR(x,y,w,h,r)`, `pE(cx,cy,rx,ry)`, `pG(points)`, `pA(cx,cy,r,a0,a1)`. Transforms: `withT(x,y,rot,scale,fn)`. Colours are in `PR.*`.
- **Text:** `txt(str,x,y,size,color,{ weight, font, align, frac })`, `txtW`, `fitSize`. Fonts: `F_HEAD`, `F_CHIP`, `F_CHALK`, `F_MONO`.
- **World:** `prWall(colour,{ streak, panel, gap })`, `prFloor(y, colour, { studs, trim })`, `prBunting(y)`, `prClock(x,y,r,h,m)`, `prCorkboard`, `prNote`, `prStar`, `prBoard(x,y,w,h)`.
- **Story props:** `prSign`, `prTerm(str, frac)`, `prCounter(label, value, x, y, { s, rot, big })`, `countUp(t0,d,a,b)`, `prStamp(str,x,y,colour,t0,{ rot, size })`, `prCheck`/`prCross(x,y,t)`, `prChecklist(x,y,w,title,items,t0,dt)`, `prRepoCard(x,y,repo,sub,stars,ta,tb)`, `prCommentBox(x,y,WORD,t)`, `prReceipt(x,y,rows,total)`, `prFolder(x,y,s,fill,{ label })`, `prBurst(x,y,t0)`, `chalk(str,x,y,size,frac)`, `chalkLine(points, frac)`, `sparkIcon`.
- **Mascot:** `clawd(x,y,{ s, hat, mood, look, view, walk, hold: propStick(angle) | propPencil(angle), hand: { x, y }, flip })`. Set `CLAWD_SCALE` once per piece.
- **Time:** `TT` (now), `E0` (scene start), `ev(t,d)` 0→1, `popS(t)` back-eased pop, `springMove(t0,a,b,SPRING.playful)`, `VL(id)`, `VW(id, word, n)`, `seg`, `EZ.*`.
- **Determinism:** seeded `RNG(...)` only; never `Math.random()` or the clock. `$REEL check` verifies it.

## Gotchas
- **Kokoro inside sandboxed agents:** phonemizer copies `libespeak-ng.dylib` to a temp folder that macOS refuses to load. `tools/kokoro_lines.py` symlinks the original signed library instead; keep that patch.
- **faster-whisper** can hang in sandboxes. The timing uses whisper.cpp (`tools/align.mjs`, model at `~/.cache/whisper/ggml-base.en.bin`, override with `WHISPER_MODEL`).
- **Old copies of the style:** `build.mjs` uses a `styles/paper-reel/` folder beside or above the piece before this skill's copy. Delete or refresh any old copy in the project.
- **Name clashes:** `kit/core.js` and `kit/morph.js` already define many short top-level names (`smooth`, `grain`, `ink`, `burst`, `push`, `layer`, `W`, `H`, `F`, `B`…), so prefix your own scene helpers. A clash shows up as a `syntax error` at build.
- **Reviewing frames:** the animate `review.mjs` sheets need ffmpeg's `drawtext`, which the Homebrew build lacks. Use `still` and `check` instead.

Renderer and tools are vendored from the MIT-licensed `animate` skill (cth9191/animate); see NOTICE.md.
````

## File: README.md
size: 3736
sha: 924382bf24327e62c765b8004ac0b686d5ab5bd8
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/README.md

````text
# paper-reel

A Claude Code skill that makes faceless 9:16 explainer reels in a cut-paper style: flat textured paper shapes, a box mascot in a different hat every scene, a screwed-on headline sign, rubber stamps, counters, and captions that pop in one paper scrap per word. You give Claude a topic, it writes six or seven lines, and one command renders the MP4.

![Seven scenes from the example reel](docs/strip.jpg)

**[Watch the example reel](docs/demo.mp4)** (23 s, made with this skill, about the [khan-explainer](https://github.com/haiderfarooq3/khan-explainer) skill).

Write-up: [paper-reel: a free Claude Code skill that draws cut-paper explainer reels from one prompt](https://haiderfarooq.dev/blog/paper-reel-explainer-reels-in-code). How the skill explains itself, on YouTube: [wide cut](https://youtu.be/c6hUOxt4eXg) and [Short](https://www.youtube.com/shorts/HXAgpEai8pA).

Everything is drawn in code and runs on your Mac: Kokoro for the voice, whisper.cpp for word timing, an HTML canvas, Playwright and ffmpeg. No API keys, no accounts, no editor.

## How it stays in sync

The voice is the clock. Every spoken line is one scene, with a hard cut just before the line starts. After the voice is generated, whisper.cpp times every word, and the scenes cue props off those words: a stamp slams on "free", a counter runs up on "stars", the mascot's face changes on the payoff.

```js
SCENES.free = () => {
  const wl = VW('free', 'laptop').t0;              // when "laptop" is said
  prWall('#c9b28a');                               // the scene's world
  prSign('RUNS ON YOUR LAPTOP');                   // the headline sign
  prStamp('LOCAL', 450, 690, PR.green, wl, { rot: 0.08, size: 70 });
  clawd(CLAWD_X, CLAWD_Y, { hat: 'cap', mood: TT > wl ? 'happy' : 'neutral' });
  prChips();                                       // one paper caption per word
};
```

## Install

You need macOS, Node 18 or newer, ffmpeg and whisper.cpp.

```sh
git clone https://github.com/haiderfarooq3/paper-reel ~/.claude/skills/paper-reel
cd ~/.claude/skills/paper-reel && npm install && npx playwright install chromium
brew install ffmpeg whisper-cpp
~/.claude/skills/paper-reel/bin/reel doctor
```

`doctor` checks every piece and prints the exact command for anything missing: the whisper model, a Python with `kokoro-onnx`, and the Kokoro model files.

## Use

Ask Claude Code:

> Make a paper-reel about uv, the Python package manager. CTA keyword UV.

> Make a skill-spotlight reel for this repo, comment keyword REPO.

Claude drafts the script for you to approve, generates the voice, builds one scene per line, checks the frames and exports. Or run the pipeline yourself:

```sh
REEL=~/.claude/skills/paper-reel/bin/reel
$REEL new ~/reels/my-reel                  # copy the starter (or: --from khan-skill)
# edit ~/reels/my-reel/voice/script.json and src/scenes.js
$REEL voice ~/reels/my-reel                # Kokoro takes + word times
$REEL still ~/reels/my-reel 2,6,10 look.png
$REEL check ~/reels/my-reel                # text overlap + determinism
$REEL export ~/reels/my-reel ~/Desktop/my-reel.mp4
```

An export renders 1080x1920 at 30 fps with a ducked lo-fi bed at about -14 LUFS, in about 30 seconds on an M-series Mac.

[SKILL.md](SKILL.md) is the full reference: the script arc, the scene rules, the kit API and the gotchas. [references/style.md](references/style.md) has the palette and layout grid.

## Credits

Made by [Haider Farooq](https://github.com/haiderfarooq3). MIT licensed.

The renderer, frame tools and kit core are vendored from [animate](https://github.com/cth9191/animate) by cth9191 (MIT). See [NOTICE.md](NOTICE.md).

Not affiliated with or endorsed by Anthropic. Claude is a trademark of Anthropic.
````

## File: references/style.md
size: 2508
sha: 87fc1b5b6ca6fdfbe6502b56f99c31bc2dacdf10
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/references/style.md

```text
# The paper-reel look: measured numbers and layout

Matched from a reference reel by sampling its frames. Frame: 1080x1920, 30fps.

## Palette (`PR` in kit.js)
| role | hex |
|---|---|
| mascot (hero, never decoration) | `#d67555`, legs/shade `#c46b4c` |
| slate wall | `#869997` (light `#95a9a7`, dark `#788a89`) |
| navy (sign text, hats) | `#1c2b56`, card behind the sign `#192833` |
| ink | `#181b24`, terminal `#23262c` |
| cream sign / paper | `#f4f1e8`, `#e9e5da`, `#f9f8f4` |
| mustard / counter yellow | `#e6b82a` / `#f2c94c` |
| kicker green | `#3e8a2c`; tick green `#2f9e5b`; stamp red `#d2453a` |
| caption chip | `#d97a5b`, chip text `#f7efe2` |
| floor | concrete `#87857c`, wood `#4c321d` |

Change the wall colour every scene (slate, navy `#2b3b5e`, plum `#583656`, teal `#6c9e8f`, sand `#c9b28a`, dusk `#4a4566`, warm `#c79a5b`).

## Medium
- Flat fills with **no outlines**. Every shape gets a mottled paper texture (an overlay of multi-octave value noise at 0.5–0.8), a soft drop shadow (blur 14–22, 7–12px down), and an optional thin light rim.
- After every frame there's a global soft-light texture pass and a light vignette.
- Motion runs every frame (on 1s), so it's smooth. Things arrive with back-eased pops or playful springs. Nothing boils.

## Layout grid (y in px)
| band | what |
|---|---|
| 140–296 | the sign (x 70–930; a navy card offset up and right behind it) |
| 322–400 | the terminal bar (optional) |
| 420–1300 | the scene's hero props (boards, cards, screens) |
| 1450–1600 | **caption chips**, from x 70 and wrapping at x 930. Keep other text out of this band |
| 1260–1860 | the mascot: body centre x about 880 (cropped by the right edge), legs from y about 1640 |
| > 1620, x > 960 | phone UI (likes, comments, caption). No text there |

## Type
- Headline: Avenir Next 800, 66px, fitted to width, navy. Kicker: 22px 800, green.
- Chips: SuperClarendon 700, 70px, cream on salmon paper, rotated ±0.01–0.04, popping in on each word.
- Terminal: Avenir Next 600, 34px, with an orange block cursor and the Claude spark icon.
- Chalk: Chalkduster. Mono: Menlo.

## Rhythm
- One scene per voice line, with a hard cut 0.1s before the line starts. Optionally a second cut on a key word inside a long line.
- About 3.5 words/s; total 18–25s.
- Audio is about -14 LUFS: a 100 BPM lo-fi bed (pad, plucks, soft kick, hat) ducked under the voice, plus whooshes into cuts, typing clicks, counter ticks, stamp thuds, pops, and a chime on the CTA keyword.
```

## File: .github/workflows/render.yml
size: 831
sha: 5e08dd6099f49281b654d5bdb4cfeb0cd37445ee
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/.github/workflows/render.yml

```text
name: render
on:
  push:
    branches: [main]
  pull_request:
  workflow_dispatch:

jobs:
  render:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: brew install ffmpeg
      - name: Playwright
        run: npm ci && npx playwright install chromium
      - name: Check the example
        run: |
          bin/reel check examples/khan-skill | tee check.txt
          grep -q "text: PASS" check.txt
          grep -q "deterministic: true" check.txt
      - name: Export the example
        run: mkdir -p out && bin/reel export examples/khan-skill out/khan-skill.mp4
        env:
          WORKERS: 3
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: render
          path: out/
```

## File: .gitignore
size: 87
sha: b05c3fc7f58d8ff2b0d7cda73e63957fdbf2d558
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/.gitignore

```text
node_modules/
.DS_Store
frames*/
renders/
examples/*/index.html
templates/*/index.html
```

## File: bin/reel
size: 5144
sha: 4face0f4305680e1a833e67ac5519b273d6e0387
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/bin/reel

```text
#!/usr/bin/env bash
# reel — the paper-reel pipeline in one command. Free and local: Kokoro voice, whisper.cpp timing, canvas + Playwright + ffmpeg.
#   reel doctor                              check every dependency, print the fix for anything missing
#   reel new <piece-dir> [--from khan-skill] copy the starter (or an example) into <piece-dir>
#   reel voice <piece> [--voice am_michael] [--speed 1.32] [--only id1,id2] [--say]
#                                            one Kokoro take per script line -> voice.wav + word times (--say: macOS voice)
#   reel build <piece>                       assemble index.html (style + kit + scenes + VOICE)
#   reel still <piece> <t1,t2,..> <out.png> [--scale 0.4]   render frames to look at
#   reel check <piece>                       text overlap / safe-zone check + determinism
#   reel export <piece> [out.mp4]            render the MP4 (-14 LUFS), copy it to out.mp4, delete the frame cache
set -euo pipefail
SKILL="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
export NODE_PATH="$SKILL/node_modules${NODE_PATH:+:$NODE_PATH}"
if [ -z "${KOKORO_PY:-}" ]; then
  for KOKORO_PY in "$HOME/.venvs/kokoro/bin/python" "$HOME/motion-kits/.venv-voice/bin/python" python3; do [ -x "$KOKORO_PY" ] && break; done
fi
KOKORO_DIR="${KOKORO_DIR:-$HOME/.cache/hyperframes/tts}"; export KOKORO_DIR
WHISPER_MODEL="${WHISPER_MODEL:-$HOME/.cache/whisper/ggml-base.en.bin}"; export WHISPER_MODEL
T="$SKILL/tools"
cmd="${1:-help}"; shift || true

case "$cmd" in
  doctor)
    ok=1; c() { if eval "$2" >/dev/null 2>&1; then echo "  ok    $1"; else echo "  MISSING $1 -> $3"; ok=0; fi; }
    echo "paper-reel doctor ($SKILL)"
    c "node 18+"            "node -e 'process.exit(+process.versions.node.split(\".\")[0] >= 18 ? 0 : 1)'" "brew install node"
    c "ffmpeg"              "command -v ffmpeg" "brew install ffmpeg"
    c "playwright (skill)"  "node -e \"require('playwright')\"" "cd '$SKILL' && npm install"
    c "chromium"            "node -e \"const fs=require('fs');process.exit(fs.existsSync(require('playwright').chromium.executablePath())?0:1)\"" "cd '$SKILL' && npx playwright install chromium"
    c "whisper-cli"         "command -v whisper-cli" "brew install whisper-cpp"
    c "whisper model"       "test -f '$WHISPER_MODEL'" "mkdir -p ~/.cache/whisper && curl -L -o ~/.cache/whisper/ggml-base.en.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin"
    c "kokoro python ($KOKORO_PY)" "'$KOKORO_PY' -c 'import kokoro_onnx, soundfile, espeakng_loader'" "python3 -m venv ~/.venvs/kokoro && ~/.venvs/kokoro/bin/pip install kokoro-onnx soundfile (or set KOKORO_PY to a python that has them)"
    c "kokoro model"        "test -f '$KOKORO_DIR/models/kokoro-v1.0.onnx' -a -f '$KOKORO_DIR/voices/voices-v1.0.bin'" "mkdir -p $KOKORO_DIR/models $KOKORO_DIR/voices && curl -L -o $KOKORO_DIR/models/kokoro-v1.0.onnx https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx && curl -L -o $KOKORO_DIR/voices/voices-v1.0.bin https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin"
    c "fonts (SuperClarendon, Avenir Next, Chalkduster)" "ls /System/Library/Fonts/Supplemental/SuperClarendon.ttc /System/Library/Fonts/Avenir\ Next.ttc /System/Library/Fonts/Supplemental/Chalkduster.ttf" "macOS system fonts; on other systems the kit falls back to Rockwell/Georgia, Helvetica/Arial"
    [ $ok = 1 ] && echo "all good" || { echo "fix the MISSING lines above"; exit 1; } ;;
  new)
    dest="${1:?usage: reel new <piece-dir> [--from khan-skill]}"; shift; from="templates/starter"
    if [ "${1:-}" = "--from" ]; then from="examples/${2:?example name}"; fi
    [ -e "$dest" ] && { echo "$dest already exists"; exit 1; }
    mkdir -p "$(dirname "$dest")"; cp -R "$SKILL/$from" "$dest"
    echo "new piece: $dest (from $from). Next: edit voice/script.json, then: reel voice $dest" ;;
  voice)
    p="${1:?usage: reel voice <piece> [--voice V] [--speed S] [--only ids] [--say]}"; shift
    if [ "${1:-}" = "--say" ]; then node "$T/voice.mjs" "$p" --scratch; else "$KOKORO_PY" -u "$T/kokoro_lines.py" "$p" "$@" 2>&1 | grep -v -i warn || true; node "$T/voice.mjs" "$p"; fi
    node "$T/align.mjs" "$p" ;;
  build) node "$T/build.mjs" "${1:?piece}" ;;
  still) p="${1:?piece}"; ts="${2:?times}"; out="${3:?out.png}"; shift 3; node "$T/build.mjs" "$p" >/dev/null && node "$T/still.mjs" "$p" "$ts" "$out" --scale "${2:-0.4}" ;;
  check) p="${1:?piece}"; node "$T/build.mjs" "$p" >/dev/null; node "$T/textcheck.mjs" "$p" | tail -12; node "$T/tile.mjs" "$p" "/tmp/reel-tile.png" 30 300 600 | grep -i determin ;;
  export)
    p="${1:?piece}"; out="${2:-}"; node "$T/build.mjs" "$p" >/dev/null
    node "$T/export.mjs" "$p" --workers "${WORKERS:-6}" --no-captions | grep -E "written|wrote|voice:|error|Error" || true
    rm -rf "$p/frames"
    [ -n "$out" ] && cp "$p/renders/final.mp4" "$out" && echo "-> $out"
    ffmpeg -nostats -i "$p/renders/final.mp4" -af ebur128=framelog=quiet -f null - 2>&1 | grep -E "^\s+I:" || true ;;
  *) sed -n '2,12p' "$0" ;;
esac
```

## File: docs/demo.mp4
size: 2187756
sha: fd82c19ea58a43e57845adee33d6c896c325bc56
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/docs/demo.mp4

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/docs/demo.mp4

## File: docs/strip.jpg
size: 485535
sha: d0b55370b89fbbbce4ccb3314d3d0d51bd855f3c
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/docs/strip.jpg

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/docs/strip.jpg

## File: examples/khan-skill/piece.json
size: 419
sha: 335e5afef8e3a93a89a7df1f7cec5bcc2d4e41fd
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/piece.json

```text
{
  "name": "khan-skill",
  "title": "khan-explainer skill reel (paper-reel style experiment)",
  "format": { "width": 1080, "height": 1920, "fps": 30 },
  "storyFormat": "voice-cut list (one scene per spoken line, hook -> how -> catch -> payoff -> CTA)",
  "style": "paper-reel",
  "voice": { "file": "voice/voice.wav", "music": -13 },
  "review": { "safe": { "top": 120, "bottom": 300, "right": 120 }, "refs": {} }
}
```

## File: examples/khan-skill/src/bridges.js
size: 161
sha: 33528560f8ba6e0049435e127877d39806f3c004
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/src/bridges.js

```text
// hard cuts only: one scene per line, like the reference
const ERA_BG = ERA_LIST.map(() => '#869997');
function pieceCam() { return null; }
const BRIDGES = [];
```

## File: examples/khan-skill/src/head.html
size: 3431
sha: 41b94fcf4d7099c27aec1635c4a808ae5ca07fad
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/src/head.html

```text
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>khan-explainer skill reel (paper-reel experiment)</title>
<style>
  html, body { margin: 0; height: 100%; background: #1b1714; overflow: hidden; }
  body { display: flex; align-items: center; justify-content: center; }
  canvas { height: min(100vh, calc(100vw * 16 / 9)); width: auto; aspect-ratio: 9 / 16; display: block; cursor: pointer; }
</style>
</head>
<body>
<canvas id="c" width="1080" height="1920"></canvas>
<script>
"use strict";
// =====================================================================
//  KHAN-SKILL REEL — 9:16, 30fps, one scene per spoken line (hard cuts on the line breaks, like the reference),
//  two lines split by a second cut on a named word. The voice (Kokoro, local) is the clock: VOICE comes from voice.json.
// =====================================================================
const FPS = 30, W = 1080, H = 1920;
const DURATION = VOICE.duration;
const NFRAMES = Math.round(DURATION * FPS);
const LOOP_T = NFRAMES / FPS;
const SAFE = { top: 120, bottom: 300, right: 120 };
const HAND = '"Chalkboard SE", "Comic Sans MS", cursive';
const CX = 540;
const VL = (id) => VOICE.lines.find((l) => l.id === id);
// VW(id, word, n): the n-th (0-based) occurrence of word in line id -> { t0, t1 }
const VW = (id, word, n = 0) => { const L = VL(id), k = word.toLowerCase(); const hits = L.words.filter((w) => w.w.toLowerCase().replace(/[^a-z0-9']/g, '') === k); return hits[n] || hits[0] || { t0: L.t0, t1: L.t1 }; };
const CUT = (id) => Math.max(0, VL(id).t0 - 0.1);    // a scene starts just before its line

// the shot list: [t0, scene]
const SHOT_LIST = [
  [0.0,                         'hook'],
  [CUT('draw'),                 'describe'],
  [VW('draw', 'claude').t0 - 0.08, 'board'],
  [CUT('sync'),                 'sync'],
  [CUT('free'),                 'keys'],
  [VW('free', 'it').t0 - 0.08,  'laptop'],
  [CUT('catch'),                'catch'],
  [CUT('repo'),                 'repo'],
  [CUT('cta'),                  'cta'],
];
const SCENE = {
  hook: () => sceneHook(), describe: () => sceneDescribe(), board: () => sceneBoard(), sync: () => sceneSync(),
  keys: () => sceneKeys(), laptop: () => sceneLaptop(), catch: () => sceneCatch(), repo: () => sceneRepo(), cta: () => sceneCta(),
};
const ERA_LIST = SHOT_LIST.map(([t0, sc], i) => [t0, i + 1 < SHOT_LIST.length ? SHOT_LIST[i + 1][0] : DURATION, SCENE[sc]]);
const SHOTS = SHOT_LIST.map(([t0, sc], i) => [sc, i, t0, ERA_LIST[i][1], sc]);
const TIMELINE = {
  width: W, height: H, fps: FPS, duration: DURATION, frames: NFRAMES, loop: LOOP_T, safe: SAFE,
  format: 'voice-cut',
  shots: SHOTS.map(([key, era, t0, t1, title], i) => ({ id: i + 1, key, era, t0, t1, title })),
  cuts: SHOT_LIST.slice(1).map(([t0]) => t0),
  morphs: [],
  cues: {},
  acts: [
    { name: '1 Hook',    role: 'build',   t0: 0, t1: CUT('draw') },
    { name: '2 How',     role: 'journey', t0: CUT('draw'), t1: CUT('catch') },
    { name: '3 Catch',   role: 'turn',    t0: CUT('catch'), t1: CUT('repo') },
    { name: '4 Payoff',  role: 'gift',    t0: CUT('repo'), t1: DURATION },
  ],
  board: SHOT_LIST.map(([t0, sc], i) => ({ t: (t0 + ERA_LIST[i][1]) / 2 + 0.3, title: sc, sound: '', next: 'cut' })),
  narration: VOICE.lines.map((l) => ({ t0: l.t0, t1: l.t1, text: l.text })),
};
window.TIMELINE = TIMELINE;
```

## File: examples/khan-skill/src/scenes.js
size: 21628
sha: d41b7c3e010e623c6748e7bb28b5d06db306c054
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/src/scenes.js

```text

// =====================================================================
//  SCENES — one per spoken line (two lines split on a word). World = screen (no cameras).
//  Every scene: a full-bleed world, the sign, the mascot in a scene-specific hat, 3+ details of life, the chips.
// =====================================================================
const CLAWD_X = 880, CLAWD_Y = 1640;
CLAWD_SCALE = 1.12;
// a pointer stick held in the mascot's hand (local coords), aimed at angle a
const stick = (a, len = 360) => (hx, hy, behind) => { if (!behind) return; withT(hx, hy, a, 1, () => { pp(pR(-10, -len, 20, len, 8), PR.char, { key: 'stick', sb: 8, sy: 4, tex: 0.3 }); pp(pR(-12, -len - 6, 24, 70, 8), PR.orange, { key: 'sticktip', shadow: false, tex: 0.4 }); pp(pR(-12, -len + 64, 24, 18, 2), PR.creamL, { shadow: false, tex: false }); }); };
const pencilProp = (a) => (hx, hy, behind) => { if (!behind) return; withT(hx, hy, a, 1, () => { pp(pR(-14, -230, 28, 200, 3), PR.yellow, { key: 'pcl', sb: 6, sy: 3, tex: 0.4 }); pp(pG([[-14, -30], [14, -30], [0, 10]]), PR.tan, { shadow: false, tex: 0.4 }); pp(pR(-14, -250, 28, 26, 4), PR.pink, { shadow: false, tex: 0.4 }); }); };

// ---- 1. HOOK: a classroom. A prompt types, the blackboard draws "why is the sky blue?", a counter ticks.
function sceneHook() {
  const t0 = E0;
  prWall(PR.slate, { streak: 620 });
  prFloor(1560, PR.floor, { studs: true });
  // the blackboard
  pp(pR(60, 450, 720, 640, 10), PR.woodD, { key: 'bbf', sb: 20, sy: 10 });
  pp(pR(84, 474, 672, 592, 4), PR.board, { key: 'bb', shadow: false, tex: 0.8 });
  pp(pR(70, 1084, 700, 26, 4), PR.woodL, { key: 'bbtray', sb: 8, sy: 5 });
  pp(pR(150, 1072, 46, 14, 4), PR.creamL, { shadow: false, tex: false }); pp(pR(230, 1074, 32, 12, 4), PR.yellow, { shadow: false, tex: false });
  chalk('Why is the sky blue?', 120, 560, 46, ev(t0 + 0.5, 1.0));
  const sun = ev(t0 + 1.2, 0.6);
  if (sun > 0) { chalkLine(ellipsePts(230, 760, 70, 70, 0, 40), sun, { color: 'rgba(246,207,85,0.95)', w: 8 }); for (let i = 0; i < 8; i++) { const a = i / 8 * TAU; chalkLine([[230 + Math.cos(a) * 92, 760 + Math.sin(a) * 92], [230 + Math.cos(a) * 122, 760 + Math.sin(a) * 122]], seg(TT, t0 + 1.5 + i * 0.04, t0 + 1.7 + i * 0.04), { color: 'rgba(246,207,85,0.95)', w: 7 }); } }
  for (let k = 0; k < 3; k++) { const P = []; for (let i = 0; i <= 30; i++) { const u = i / 30; P.push([330 + u * 270, 740 + k * 34 + Math.sin(u * 18) * 10 + u * 70]); } chalkLine(P, seg(TT, t0 + 1.9 + k * 0.15, t0 + 2.6 + k * 0.15), { color: k === 1 ? 'rgba(130,190,255,0.95)' : 'rgba(244,244,236,0.85)', w: 6 }); }
  chalkLine(ellipsePts(640, 900, 82, 82, 0, 40), ev(t0 + 2.5, 0.6), { color: 'rgba(130,190,255,0.95)', w: 8 });
  chalk('blue scatters most', 360, 1030, 34, ev(t0 + 2.9, 0.7), { color: 'rgba(244,244,236,0.85)' });
  // background life: a clock, an apple and books on the floor
  prClock(960, 1020, 70, 10, 10 + TT * 6, 'clk1');
  pp(pR(110, 1470, 230, 40, 4), PR.blue, { key: 'bk1', sb: 8, sy: 4 }); pp(pR(124, 1432, 200, 40, 4), PR.mustard, { key: 'bk2', sb: 8, sy: 4 }); pp(pR(104, 1394, 216, 40, 4), PR.red, { key: 'bk3', sb: 8, sy: 4 });
  pp(pE(212, 1352, 40, 36), PR.red, { key: 'apple', sb: 8, sy: 4 }); pp(pE(226, 1312, 16, 8, -0.5), PR.green, { shadow: false, tex: 0.4 });
  prCounter('VIDEOS MADE', countUp(t0 + 0.3, 3.4, 1, 7), 860, 640, { w: 290, h: 186, rot: 0.04, s: popS(t0 + 0.15) });
  clawd(CLAWD_X, CLAWD_Y, { hat: 'mortar', key: 'c1', hold: stick(-0.75), mood: TT > t0 + 2.9 ? 'happy' : 'neutral', look: -14 });
  prSign('ANY TOPIC, KHAN STYLE');
  prTerm('/khan-explainer why is the sky blue', ev(t0 + 0.15, 1.9));
  prChips();
}

// ---- 2a. DESCRIBE: a script page on a desk; the prompt types the brief, beats write themselves on.
function sceneDescribe() {
  const t0 = E0;
  prWall('#2b3b5e', { panel: 'rgba(255,255,255,0.04)', streak: 300 });
  // a departures-style board of lesson topics behind (life)
  pp(pR(60, 440, 960, 150, 6), PR.ink, { key: 'dep', sb: 16, sy: 8 });
  txt('LESSONS', 90, 500, 34, PR.mustard, { weight: 800 });
  [['DNS', 'READY'], ['BLACK HOLES', 'READY'], ['SKY BLUE', 'DRAWING']].forEach(([a, b], i) => { txt(a, 280 + i * 250, 500, 24, '#d9d4c6', { weight: 700 }); txt(b, 280 + i * 250, 552, 24, i === 2 ? PR.orange : PR.green, { weight: 800 }); });
  prFloor(1500, PR.woodD, { trim: false });
  // the script page
  withT(390, 960, -0.035, springMove(t0, 0.6, 1, SPRING.playful), () => {
    pp(pR(-300, -330, 600, 660, 6), PR.paperW, { key: 'page', sb: 22, sy: 12, tex: 0.5 });
    pp(pR(-90, -350, 180, 44, 8), PR.greyD, { key: 'clip', sb: 6, sy: 3 });
    txt('scene.js', -250, -250, 40, PR.navy, { weight: 800 });
    const lines = ['beat 1: the sun', 'beat 2: light scatters', 'beat 3: blue wins', 'voice: explain it'];
    lines.forEach((s, i) => { const f = ev(t0 + 0.25 + i * 0.28, 0.3); if (f <= 0) return; pp(pR(-250, -180 + i * 110, 30, 30, 4), i < 3 ? PR.green : PR.orange, { shadow: false, tex: 0.3 }); txt(s, -200, -154 + i * 110, 36, PR.ink, { weight: 600, frac: f, font: F_MONO }); });
  });
  // a desk lamp and a mug (life)
  pp(pR(860, 1150, 26, 360, 6), PR.char, { key: 'lampst', sb: 8, sy: 4 }); pp(pG([[790, 1120], [960, 1120], [920, 1020], [830, 1020]]), PR.mustard, { key: 'lamps', sb: 10, sy: 6 });
  ctx.save(); const gl = ctx.createRadialGradient(875, 1150, 10, 875, 1250, 260); gl.addColorStop(0, 'rgba(255,230,150,0.35)'); gl.addColorStop(1, 'rgba(255,230,150,0)'); ctx.fillStyle = gl; ctx.fillRect(600, 1120, 560, 400); ctx.restore();
  clawd(CLAWD_X + 30, CLAWD_Y + 20, { hat: 'beret', key: 'c2', s: 0.9, view: 'side', walk: true, hold: pencilProp(-0.5), mood: 'neutral' });
  prSign('YOU DESCRIBE IT');
  prTerm('/khan-explainer 30s on why the sky is blue', ev(t0 + 0.05, 1.0));
  prChips();
}

// ---- 2b. BOARD: a wall-wide blackboard; each step is drawn on the word that names it.
function sceneBoard() {
  const t0 = E0, w1 = VW('draw', 'draws').t0, w2 = VW('draw', 'every').t0, w3 = VW('draw', 'step').t0, w4 = VW('draw', 'blackboard').t0;
  prWall(PR.slateD, { streak: 800 });
  prFloor(1600, PR.floor);
  pp(pR(40, 420, 1000, 860, 10), PR.woodD, { key: 'bbf2', sb: 22, sy: 12 });
  pp(pR(66, 446, 948, 808, 4), PR.board, { key: 'bb2', shadow: false, tex: 0.85 });
  const box = (x, y, label, t, col, bw = 300) => { const f = ev(t, 0.45); if (f <= 0) return; chalkLine(rrectPts(x, y, bw, 150, 18, 4).concat([[x + 18, y]]), f, { color: col, w: 7 }); chalk(label, x + bw / 2, y + 92, 38, ev(t + 0.2, 0.35), { align: 'center', color: col }); };
  box(100, 540, '1 SUN', w1, 'rgba(246,207,85,0.95)', 230);
  chalkLine([[350, 615], [450, 615]], ev(w2 - 0.05, 0.25)); chalkLine([[425, 595], [450, 615], [425, 635]], ev(w2 + 0.15, 0.1));
  box(480, 540, '2 SCATTER', w2 + 0.05, 'rgba(244,244,236,0.92)');
  chalkLine([[630, 700], [630, 800]], ev(w3 - 0.05, 0.25)); chalkLine([[610, 775], [630, 800], [650, 775]], ev(w3 + 0.15, 0.1));
  box(480, 820, '3 BLUE', w3 + 0.05, 'rgba(130,190,255,0.95)');
  chalk('= the sky', 300, 1150, 46, ev(w4, 0.5), { color: 'rgba(130,190,255,0.95)' });
  // the chalk itself rides the newest stroke (life)
  const cx = TT < w2 ? 360 : TT < w3 ? 740 : 740, cy = TT < w2 ? 700 : TT < w3 ? 700 : 980;
  if (TT < w4 + 0.6) withT(cx + Math.sin(TT * 14) * 10, cy + Math.cos(TT * 11) * 8, 0.6, 1, () => pp(pR(-14, -50, 28, 100, 8), PR.creamL, { key: 'chk', sb: 8, sy: 4, tex: 0.3 }));
  pp(pR(60, 1278, 960, 24, 4), PR.woodL, { key: 'bbtray2', sb: 8, sy: 5 });
  prCounter('STEPS DRAWN', TT < w2 ? (TT < w1 ? 0 : 1) : TT < w3 ? 2 : 3, 210, 1395, { w: 280, h: 176, rot: -0.05, big: 96 });
  clawd(CLAWD_X, CLAWD_Y + 40, { hat: 'mortar', key: 'c3', s: 0.82, hold: stick(-0.55, 300), look: -12, mood: TT > w4 ? 'happy' : 'neutral' });
  prSign('CLAUDE DRAWS IT');
  prChips();
}

// ---- 3. SYNC: a little studio. The waveform plays; words light up as the pen reaches them.
function sceneSync() {
  const t0 = E0, L = VL('sync');
  prWall(PR.plum, { panel: 'rgba(0,0,0,0.10)', gap: 120, streak: 420 });
  // acoustic foam (life)
  for (let r = 0; r < 2; r++) for (let c = 0; c < 6; c++) pp(pR(80 + c * 160, 1080 + r * 130, 130, 110, 6), r % 2 === c % 2 ? '#3c2448' : '#4a2e57', { key: 'foam' + r + c, sb: 6, sy: 4, tex: 0.6 });
  // ON AIR light
  const on = Math.floor(TT * 2) % 2 === 0;
  pp(pR(700, 430, 250, 92, 12), on ? PR.red : PR.redD, { key: 'onair', sb: on ? 30 : 10, sy: 4, shc: on ? 'rgba(255,70,60,0.6)' : undefined });
  txt('ON AIR', 825, 492, 46, PR.creamL, { weight: 800, align: 'center' });
  // the waveform strip with a playhead
  pp(pR(60, 600, 960, 300, 14), PR.char, { key: 'wave', sb: 18, sy: 9, tex: 0.35 });
  const u = seg(TT, L.t0, L.t1), R = RNG('wv');
  for (let i = 0; i < 60; i++) { const x = 92 + i * 15.6, a = (0.25 + 0.75 * Math.abs(Math.sin(i * 0.7) * Math.sin(i * 0.23 + 1))) * R.r(0.6, 1), h = 200 * a; ctx.fillStyle = i / 60 < u ? PR.orange : 'rgba(240,235,220,0.45)'; ctx.fillRect(x, 750 - h / 2, 9, h); }
  const px = 92 + u * 936; ctx.fillStyle = PR.yellow; ctx.fillRect(px - 3, 610, 6, 280);
  // the pen riding the playhead
  withT(px + 20, 600, 0.5, 1, () => { pp(pR(-14, -120, 28, 110, 4), PR.yellow, { key: 'pen', sb: 8, sy: 4, tex: 0.4 }); pp(pG([[-14, -10], [14, -10], [0, 24]]), PR.creamL, { shadow: false, tex: false }); });
  // the words of the line as tiles: each lights when spoken
  const ws = ['the', 'pen', 'lands', 'right', 'on', 'the', 'words'];
  let x = 80; ws.forEach((w, i) => { const t = VW('sync', w, i === 5 ? 1 : 0).t0, lit = TT >= t; const tw = txtW(w, 38) + 34; pp(pR(x, 930, tw, 70, 8), lit ? PR.mustard : '#5b4a63', { key: 'wt' + i, sb: 8, sy: 4, tex: 0.5 }); txt(w, x + tw / 2, 978, 38, lit ? PR.ink : '#a99bb0', { align: 'center', weight: 800 }); x += tw + 12; });
  prCounter('IN SYNC', countUp(VW('sync', 'pen').t0, 1.0, 0, 100) + '%', 230, 1400, { w: 300, h: 180, rot: 0.04, big: 92 });
  // the microphone (life)
  pp(pR(470, 1300, 18, 240, 6), PR.greyD, { key: 'micst', sb: 8, sy: 4 }); pp(pR(430, 1180, 98, 140, 46), '#3a3d45', { key: 'mic', sb: 12, sy: 6, tex: 0.5 });
  clawd(CLAWD_X, CLAWD_Y, { hat: 'phones', key: 'c4', mood: TT > VW('sync', 'words').t0 ? 'happy' : 'neutral', look: -10 });
  prSign('THE PEN WAITS FOR THE VOICE');
  prChips();
}

// ---- 4a. KEYS: a security desk. An API key gets NOT NEEDED, credits get $0.
function sceneKeys() {
  const t0 = E0, wk = VW('free', 'keys').t0, wc = VW('free', 'credits').t0;
  prWall(PR.teal, { panel: 'rgba(255,255,255,0.06)', streak: 380 });
  prBunting(-10, [PR.red, PR.cream, PR.blue, PR.mustard, PR.green], 'bunk');
  prClock(940, 470, 78, 3, 40 + TT * 8, 'clk4');
  prFloor(1540, PR.woodD, { trim: false });
  // the key card
  withT(330, 720, -0.04, popS(t0 + 0.05), () => {
    pp(pR(-260, -150, 520, 300, 14), PR.paperW, { key: 'kc', sb: 20, sy: 10 });
    pp(pR(-260, -150, 520, 76, 14), PR.navy, { key: 'kch', shadow: false, tex: 0.5 });
    txt('API KEY', -180, -96, 38, PR.creamL, { weight: 800 }); sparkIcon(-220, -112, 18);
    // a brass key
    pp(pE(-130, 30, 62, 62), PR.mustard, { key: 'kring', sb: 10, sy: 5 }); pp(pE(-130, 30, 26, 26), PR.paperW, { shadow: false, tex: false });
    pp(pR(-74, 16, 270, 30, 4), PR.mustard, { key: 'kshaft', sb: 8, sy: 4 }); pp(pR(140, 40, 26, 46, 3), PR.mustard, { shadow: false, tex: 0.4 }); pp(pR(176, 40, 20, 32, 3), PR.mustard, { shadow: false, tex: 0.4 });
  });
  prStamp('NOT NEEDED', 430, 830, PR.red, wk, { rot: -0.12, size: 58 });
  // the credits card
  withT(400, 1160, 0.05, popS(wc - 0.25), () => {
    pp(pR(-250, -130, 500, 260, 18), PR.orange, { key: 'cc', sb: 20, sy: 10 });
    pp(pR(-250, -70, 500, 50, 0), PR.ink, { shadow: false, tex: 0.3 });
    txt('CREDITS', -210, 90, 42, PR.creamL, { weight: 800 }); pp(pR(110, 40, 90, 64, 8), PR.mustard, { key: 'chipc', shadow: false, tex: 0.4 });
  });
  prStamp('$0', 520, 1120, PR.green, wc + 0.05, { rot: 0.1, size: 76 });
  clawd(CLAWD_X + 20, CLAWD_Y, { hat: 'guard', key: 'c5', s: 0.92, hold: stick(-0.35, 260), look: -12, mood: 'flat' });
  prSign('NO API KEYS, NO CREDITS');
  prChips();
}

// ---- 4b. LAPTOP: it renders at home. A receipt adds up to nothing.
function sceneLaptop() {
  const t0 = E0, wl = VW('free', 'laptop').t0;
  prWall('#c9b28a', { panel: 'rgba(255,255,255,0.07)', streak: 700 });
  // a window with the night outside (life)
  pp(pR(80, 380, 300, 320, 6), PR.woodD, { key: 'win4', sb: 16, sy: 8 }); pp(pR(102, 402, 256, 276, 2), '#24305a', { key: 'win4g', shadow: false, tex: 0.6 });
  for (let i = 0; i < 9; i++) { const R = RNG('st', i); prStar(120 + R.r(0, 220), 430 + R.r(0, 220), R.r(5, 10) * (0.7 + 0.3 * Math.sin(TT * 4 + i)), '#f6e7b0'); }
  pp(pE(290, 480, 34, 34), '#f2ecd0', { key: 'moon4', shadow: false, tex: 0.4 });
  pp(pR(-40, 1180, W + 80, 800, 0), PR.wood, { key: 'desk4', sy: -6, sb: 16, tex: 0.8 });
  // the laptop
  withT(440, 1060, 0, springMove(t0, 0.85, 1, SPRING.playful), () => {
    pp(pR(-330, -300, 660, 420, 18), '#34373f', { key: 'lid', sb: 22, sy: 12 });
    pp(pR(-300, -272, 600, 364, 6), PR.board, { key: 'scr', shadow: false, tex: 0.7 });
    chalk('Why is the sky blue?', -270, -200, 34, 1);
    chalkLine(ellipsePts(-170, -80, 50, 50, 0, 30), 1, { color: 'rgba(246,207,85,0.95)', w: 6 }); chalkLine(ellipsePts(170, -40, 60, 60, 0, 30), 1, { color: 'rgba(130,190,255,0.95)', w: 6 });
    const p = ev(t0 + 0.1, 1.1); pp(pR(-270, 40, 540, 30, 8), '#1a2b22', { shadow: false, tex: false }); pp(pR(-270, 40, 540 * p, 30, 8), PR.green, { shadow: false, tex: false });
    txt(p < 1 ? `rendering ${Math.round(p * 100)}%` : 'saved: sky-blue.mp4', 0, 26, 26, '#e8f0e8', { align: 'center', weight: 700, font: F_MONO });
    pp(pG([[-380, 120], [380, 120], [420, 170], [-420, 170]]), '#9a9ca4', { key: 'base', sb: 14, sy: 8 });
  });
  // the receipt
  withT(830, 560, 0.06, 0.92 * popS(t0 + 0.2), () => {
    pp(pG([[-150, -170], [150, -170], [150, 160], [120, 175], [90, 160], [60, 175], [30, 160], [0, 175], [-30, 160], [-60, 175], [-90, 160], [-120, 175], [-150, 160]]), PR.paperW, { key: 'rcpt', sb: 14, sy: 7 });
    [['API keys', '0'], ['credits', '0'], ['cloud', '0']].forEach(([a, b], i) => { txt(a, -126, -110 + i * 52, 28, PR.ink, { weight: 600, font: F_MONO }); txt(b, 126, -110 + i * 52, 28, PR.ink, { weight: 700, font: F_MONO, align: 'right' }); });
    ctx.fillStyle = PR.ink; ctx.fillRect(-126, 50, 252, 3); txt('TOTAL', -126, 104, 30, PR.ink, { weight: 800, font: F_MONO }); txt('$0.00', 126, 104, 30, PR.green, { weight: 800, font: F_MONO, align: 'right' });
  });
  prStamp('LOCAL', 450, 690, PR.green, wl, { rot: 0.08, size: 70 });
  clawd(CLAWD_X + 30, CLAWD_Y + 30, { hat: 'cap', key: 'c6', s: 0.85, look: -14, mood: TT > wl ? 'happy' : 'neutral' });
  prSign('RUNS ON YOUR LAPTOP');
  prChips();
}

// ---- 5. CATCH: dusk. Diagrams get a tick, faces get a cross.
function sceneCatch() {
  const t0 = E0, wd = VW('catch', 'diagrams').t0, wf = VW('catch', 'faces').t0, wt = VW('catch', 'teaching').t0;
  pp(pR(-40, -40, W + 80, 1100, 0), '#4a4566', { key: 'sky5', shadow: false, tex: 0.7 });
  pp(pR(-40, 700, W + 80, 500, 0), '#6a5873', { key: 'sky5b', shadow: false, tex: 0.6 });
  pp(pG([[-40, 1180], [200, 960], [420, 1100], [700, 900], [1120, 1120], [1120, 1300], [-40, 1300]]), '#3b3855', { key: 'hills', sb: 14, sy: -4 });
  pp(pR(-40, 1260, W + 80, 700, 0), PR.char, { key: 'road', sy: -6, sb: 16, tex: 0.6 });
  for (let i = 0; i < 6; i++) pp(pR(30 + i * 190 - mod(TT * 120, 190), 1560, 110, 16, 3), PR.cream, { key: 'lane' + i, shadow: false, tex: 0.4 });
  // a warning sign (life)
  pp(pR(990, 760, 16, 520, 4), PR.greyD, { key: 'wpost', sb: 8, sy: 4 }); pp(pR(908, 610, 180, 180, 6), PR.mustard, { key: 'wsign', sb: 12, sy: 6 });
  pp(pG([[998, 640], [1060, 760], [936, 760]]), PR.ink, { shadow: false, tex: 0.3 }); pp(pR(992, 680, 12, 46, 3), PR.mustard, { shadow: false, tex: false }); pp(pR(992, 734, 12, 12, 3), PR.mustard, { shadow: false, tex: false });
  // an easel blackboard: diagrams, yes
  pp(pG([[180, 1260], [230, 1260], [300, 760], [270, 760]]), PR.woodD, { key: 'easel1', sb: 10, sy: 5 }); pp(pG([[520, 1260], [470, 1260], [400, 760], [430, 760]]), PR.woodD, { key: 'easel2', sb: 10, sy: 5 });
  withT(350, 860, -0.03, 1, () => { pp(pR(-240, -190, 480, 330, 8), PR.woodD, { key: 'eb', sb: 18, sy: 9 }); pp(pR(-222, -172, 444, 294, 3), PR.board, { key: 'ebb', shadow: false, tex: 0.8 }); chalkLine(ellipsePts(-90, -30, 60, 60, 0, 30), 1, { color: 'rgba(246,207,85,0.95)', w: 7 }); chalkLine([[0, -30], [130, -30]], 1); chalkLine([[110, -50], [130, -30], [110, -10]], 1); chalk('diagram', -150, 100, 38, 1); });
  if (TT >= wd) withT(560, 700, 0.1, popS(wd), () => { pp(pE(0, 0, 66, 66), PR.green, { key: 'tick', sb: 12, sy: 6 }); ctx.save(); ctx.strokeStyle = PR.creamL; ctx.lineWidth = 16; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(-28, 2); ctx.lineTo(-6, 26); ctx.lineTo(32, -22); ctx.stroke(); ctx.restore(); });
  // a vlog camera on a tripod: faces, no
  withT(790, 470 + 330, 0, popS(t0 + 0.4), () => {
    pp(pG([[-10, 120], [10, 120], [80, 470], [60, 470]]), PR.greyD, { key: 'tp1', sb: 6, sy: 3 }); pp(pG([[-10, 120], [10, 120], [-60, 470], [-80, 470]]), PR.greyD, { key: 'tp2', sb: 6, sy: 3 });
    pp(pR(-130, -60, 260, 180, 16), '#2a2c33', { key: 'cam', sb: 16, sy: 8 }); pp(pE(0, 30, 62, 62), '#41444d', { shadow: false, tex: 0.4 }); pp(pE(0, 30, 36, 36), '#6d8fb5', { shadow: false, tex: 0.4 });
    pp(pE(96, -32, 12, 12), PR.red, { shadow: false, tex: false });
  });
  prStamp('NO FACES', 740, 880, PR.red, wf, { rot: -0.14, size: 56 });
  clawd(CLAWD_X - 10, CLAWD_Y + 60, { hat: 'ranger', key: 'c7', s: 0.82, look: -12, mood: TT > wt ? 'happy' : 'flat' });
  prSign('THE CATCH: NO FACES');
  prChips();
}

// ---- 6. REPO: a corkboard. The GitHub card's star count runs up; FREE is stamped on.
function sceneRepo() {
  const t0 = E0, wo = VW('repo', 'open').t0, wg = VW('repo', 'github').t0, wh = VW('repo', 'stars').t1;
  prWall(PR.slate, { streak: 300 });
  prBunting(0, [PR.red, PR.mustard, PR.blue, PR.green, PR.cream], 'bun6');
  prCorkboard(70, 400, 940, 700, 'cork6');
  prNote(180, 520, 160, 120, '#f3e3a5', -0.08, 'n1'); prNote(900, 980, 150, 150, '#a9cbe0', 0.06, 'n2'); prNote(170, 1000, 150, 120, '#e9a3a3', 0.05, 'n3');
  prFloor(1300, '#4f8a55', { trim: false });
  // the repo card
  withT(560, 760, 0, springMove(t0, 0.7, 1, SPRING.playful), () => {
    pp(pR(-330, -210, 660, 420, 14), PR.paperW, { key: 'gh', sb: 22, sy: 12 });
    pp(pR(-330, -210, 660, 90, 14), PR.char, { key: 'ghh', shadow: false, tex: 0.4 });
    pp(pE(-280, -165, 26, 26), PR.creamL, { shadow: false, tex: false }); pp(pE(-280, -160, 15, 17), PR.char, { shadow: false, tex: false });
    txt('open source on GitHub', -240, -150, 34, PR.creamL, { weight: 700 });
    sparkIcon(-282, -68, 18); txt('haiderfarooq3 / khan-explainer', -250, -56, 32, PR.ink, { weight: 800 });
    txt('MIT  ·  Claude Code skill', -300, -6, 26, PR.greyD, { weight: 600 });
    const n = countUp(wg, Math.max(0.6, wh - wg), 0, 130); prStar(-250, 120, 46);
    txt(n >= 130 ? '130+' : String(n), -186, 156, 104, PR.ink, { weight: 800 }); txt('stars', 300, 170, 26, PR.greyD, { weight: 600, align: 'right' });
  });
  if (TT > wg) { const R = RNG('st6'); for (let i = 0; i < 8; i++) { const a = R.r(0, TAU), d = 360 + 60 * Math.sin(TT * 3 + i); prStar(560 + Math.cos(a) * d * 0.95, 780 + Math.sin(a) * d * 0.6, 16 + 6 * Math.sin(TT * 6 + i)); } }
  pp(pE(560, 380, 16, 16), PR.red, { key: 'pin', sb: 6, sy: 3 });
  prStamp('FREE', 300, 540, PR.green, wo, { rot: -0.16, size: 76 });
  clawd(CLAWD_X + 40, CLAWD_Y - 20, { hat: 'cap', key: 'c8', s: 0.9, view: 'side', mood: TT > wh ? 'happy' : 'wow', hand: { x: -330, y: -200 } });
  prSign('FREE  ·  OPEN SOURCE');
  prChips();
}

// ---- 7. CTA: comment BOARD. The word types into a comment box; a green card pops with confetti.
function sceneCta() {
  const t0 = E0, wc = VW('cta', 'comment').t0, wb = VW('cta', 'board').t0;
  prWall('#c79a5b', { panel: 'rgba(0,0,0,0.05)', streak: 540 });
  for (let i = 0; i < 6; i++) pp(pR(70 + i * 190, 380, 28, 1300, 4), '#2b2a2e', { key: 'bar' + i, sb: 10, sy: 5, tex: 0.5 });
  pp(pR(-40, 1180, W + 80, 120, 0), '#6a5a8a', { key: 'hill7', shadow: false, tex: 0.6 });
  prFloor(1600, PR.floorD, { trim: false });
  // the comment box
  const typed = 'BOARD', k = Math.floor(typed.length * ev(wc + 0.15, 0.45));
  withT(450, 640, 0, popS(t0 + 0.05), () => {
    pp(pR(-380, -60, 760, 120, 60), PR.paperW, { key: 'cbox', sb: 16, sy: 8, tex: 0.4 });
    if (k === 0) txt('Add a comment...', -330, 14, 38, PR.grey, { weight: 600 }); else txt(typed.slice(0, k), -330, 18, 52, PR.ink, { weight: 800 });
    pp(pE(318, 0, 40, 40), PR.blue, { key: 'send', shadow: false, tex: 0.3 }); pp(pG([[304, -18], [334, 0], [304, 18]]), PR.paperW, { shadow: false, tex: false });
  });
  // the green card: a mini blackboard + tick
  withT(430, 1050, -0.04, popS(wb), () => {
    pp(pR(-100, -200, 140, 50, 8), PR.green, { key: 'gct', sb: 6, sy: 3 });
    pp(pR(-220, -170, 440, 330, 14), PR.green, { key: 'gc', sb: 22, sy: 11, rim: 'rgba(255,255,255,0.35)' });
    pp(pR(-160, -110, 320, 190, 6), PR.board, { shadow: false, tex: 0.6 }); chalk('BOARD', 0, 10, 54, 1, { align: 'center' });
    ctx.save(); ctx.strokeStyle = PR.creamL; ctx.lineWidth = 16; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(-120, 120); ctx.lineTo(-90, 150); ctx.lineTo(-40, 96); ctx.stroke(); ctx.restore();
  });
  prBurst(430, 1000, wb, { n: 26, life: 1.4 });
  clawd(CLAWD_X, CLAWD_Y, { hat: 'mortar', key: 'c9', look: -16, mood: TT > wb ? 'happy' : 'neutral' });
  prSign('COMMENT BOARD');
  prChips();
}
```

## File: examples/khan-skill/src/score.js
size: 2467
sha: 69eea5503668795b627392771b7d530dffb0d3f6
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/src/score.js

```text
  const E_ = (sc) => SHOT_LIST.find((s) => s[1] === sc)[0];
  // SCORE BODY — a light lo-fi bed under the voice (ducked by score-head), plus sound effects tied to the picture.
  const BPMs = 100, BT = 60 / BPMs, END = DURATION;
  const prog = [['D3', 'Fs3', 'A3'], ['B2', 'D3', 'Fs3'], ['G2', 'B2', 'D3'], ['A2', 'Cs3', 'E3']];
  to = 'm';
  for (let bar = 0; bar * 4 * BT < END; bar++) {
    const t = bar * 4 * BT, ch = prog[bar % 4];
    pad(t, Math.min(END + 0.2, t + 4 * BT + 0.05), ch, 0.022, { type: 'triangle', cut: 1100, att: 0.08, rel: 0.12, send: 0.3 });
    bass(t, nz(ch[0].replace(/\d/, (d) => String(+d - 1))), 0.22); bass(t + 2 * BT, nz(ch[0].replace(/\d/, (d) => String(+d - 1))), 0.16);
    for (let k = 0; k < 8; k++) { const tk = t + k * BT / 2; if (tk > END) break; pluck(tk, nz(ch[k % 3].replace(/\d/, (d) => String(+d + 2))), k % 2 ? 0.045 : 0.07, 0, 0.22, 2600, 0.15); }
    for (let b = 0; b < 4; b++) { const tb = t + b * BT; if (tb > END) break; if (b % 2 === 0) sub(tb, 0.16); noiseHit(tb + BT / 2, 0.035, 'highpass', 7000, 0.7, 0.05); }
  }
  // sound effects (sfx bus)
  to = 's';
  for (const [t0] of SHOT_LIST.slice(1)) sweep(t0 - 0.16, t0, 0.02, 900, 4200, 0);                          // a soft whoosh into every cut
  const typing = (a, b, n) => { for (let i = 0; i < n; i++) noiseHit(a + (b - a) * i / n, 0.018, 'bandpass', 3800 + (i % 3) * 600, 2, 0.06); };
  typing(E_('hook') + 0.15, E_('hook') + 2.05, 30); typing(E_('describe') + 0.05, E_('describe') + 1.05, 18);
  for (let i = 1; i < 7; i++) blip(E_('hook') + 0.3 + 3.4 * (1 - Math.cbrt(1 - i / 6)), 1300, 1700, 0.05, 0.07);   // the counter ticks
  for (const w of ['draws', 'every', 'step']) blip(VW('draw', w).t0, 900, 1400, 0.07, 0.08);
  const thud = (t) => { sub(t, 0.35); noiseHit(t, 0.09, 'lowpass', 1600, 0.8, 0.22); };              // stamps
  thud(VW('free', 'keys').t0 + 0.16); thud(VW('free', 'credits').t0 + 0.21); thud(VW('free', 'laptop').t0 + 0.16); thud(VW('catch', 'faces').t0 + 0.16); thud(VW('repo', 'open').t0 + 0.16);
  blip(VW('catch', 'diagrams').t0, 700, 1400, 0.12, 0.1);
  { const a = VW('repo', 'github').t0, b = VW('repo', 'stars').t1; for (let i = 0; i < 14; i++) blip(a + (b - a) * (1 - (1 - i / 14) ** 3), 1500 + i * 40, 1900 + i * 40, 0.04, 0.05); }
  chime(VW('cta', 'board').t0, [nz('D5'), nz('Fs5'), nz('A5')], 0.09);
  for (let i = 0; i < 5; i++) noiseHit(VW('cta', 'comment').t0 + 0.15 + i * 0.09, 0.018, 'bandpass', 4000, 2, 0.06);
```

## File: examples/khan-skill/voice.json
size: 6713
sha: 382e6da3795b52d879709d8a798c3ffe83db0f91
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/voice.json

```text
{
 "duration": 23.254,
 "lead": 0.15,
 "tail": 0.6,
 "lines": [
  {
   "id": "hook",
   "text": "This free Claude skill turns any topic into a Khan Academy style video.",
   "t0": 0.15,
   "t1": 3.902,
   "take": "voice/hook.wav",
   "words": [
    {
     "w": "This",
     "t0": 0.16,
     "t1": 0.28
    },
    {
     "w": "free",
     "t0": 0.28,
     "t1": 0.57
    },
    {
     "w": "Claude",
     "t0": 0.57,
     "t1": 1,
     "est": true
    },
    {
     "w": "skill",
     "t0": 1,
     "t1": 1.46
    },
    {
     "w": "turns",
     "t0": 1.46,
     "t1": 1.65
    },
    {
     "w": "any",
     "t0": 1.65,
     "t1": 1.9
    },
    {
     "w": "topic",
     "t0": 1.9,
     "t1": 2.25
    },
    {
     "w": "into",
     "t0": 2.25,
     "t1": 2.49
    },
    {
     "w": "a",
     "t0": 2.49,
     "t1": 2.56
    },
    {
     "w": "Khan",
     "t0": 2.56,
     "t1": 2.8
    },
    {
     "w": "Academy",
     "t0": 2.8,
     "t1": 3.25
    },
    {
     "w": "style",
     "t0": 3.25,
     "t1": 3.57
    },
    {
     "w": "video.",
     "t0": 3.57,
     "t1": 3.92
    }
   ]
  },
  {
   "id": "draw",
   "text": "You describe the video, and Claude draws every step on a blackboard.",
   "t0": 4.022,
   "t1": 7.118,
   "take": "voice/draw.wav",
   "words": [
    {
     "w": "You",
     "t0": 3.92,
     "t1": 4.09
    },
    {
     "w": "describe",
     "t0": 4.09,
     "t1": 4.56
    },
    {
     "w": "the",
     "t0": 4.56,
     "t1": 4.73
    },
    {
     "w": "video,",
     "t0": 4.73,
     "t1": 5.3
    },
    {
     "w": "and",
     "t0": 5.3,
     "t1": 5.38
    },
    {
     "w": "Claude",
     "t0": 5.38,
     "t1": 5.61,
     "est": true
    },
    {
     "w": "draws",
     "t0": 5.61,
     "t1": 5.88
    },
    {
     "w": "every",
     "t0": 5.88,
     "t1": 6.17
    },
    {
     "w": "step",
     "t0": 6.17,
     "t1": 6.36
    },
    {
     "w": "on",
     "t0": 6.36,
     "t1": 6.48
    },
    {
     "w": "a",
     "t0": 6.48,
     "t1": 6.51
    },
    {
     "w": "blackboard.",
     "t0": 6.51,
     "t1": 7.12
    }
   ]
  },
  {
   "id": "sync",
   "text": "Then a voice explains it, and the pen lands right on the words.",
   "t0": 7.238,
   "t1": 10.245,
   "take": "voice/sync.wav",
   "words": [
    {
     "w": "Then",
     "t0": 7.12,
     "t1": 7.35
    },
    {
     "w": "a",
     "t0": 7.35,
     "t1": 7.4
    },
    {
     "w": "voice",
     "t0": 7.4,
     "t1": 7.69
    },
    {
     "w": "explains",
     "t0": 7.69,
     "t1": 8.15
    },
    {
     "w": "it,",
     "t0": 8.15,
     "t1": 8.57
    },
    {
     "w": "and",
     "t0": 8.57,
     "t1": 8.58
    },
    {
     "w": "the",
     "t0": 8.58,
     "t1": 8.74
    },
    {
     "w": "pen",
     "t0": 8.74,
     "t1": 8.91
    },
    {
     "w": "lands",
     "t0": 8.91,
     "t1": 9.19
    },
    {
     "w": "right",
     "t0": 9.19,
     "t1": 9.47
    },
    {
     "w": "on",
     "t0": 9.47,
     "t1": 9.58
    },
    {
     "w": "the",
     "t0": 9.58,
     "t1": 9.75
    },
    {
     "w": "words.",
     "t0": 9.75,
     "t1": 10.24
    }
   ]
  },
  {
   "id": "free",
   "text": "No API keys, no credits. It all runs on your own laptop.",
   "t0": 10.365,
   "t1": 13.752,
   "take": "voice/free.wav",
   "words": [
    {
     "w": "No",
     "t0": 10.24,
     "t1": 10.41
    },
    {
     "w": "API",
     "t0": 10.41,
     "t1": 10.67
    },
    {
     "w": "keys,",
     "t0": 10.67,
     "t1": 11.18
    },
    {
     "w": "no",
     "t0": 11.18,
     "t1": 11.35
    },
    {
     "w": "credits.",
     "t0": 11.35,
     "t1": 12.24
    },
    {
     "w": "It",
     "t0": 12.24,
     "t1": 12.4
    },
    {
     "w": "all",
     "t0": 12.4,
     "t1": 12.51
    },
    {
     "w": "runs",
     "t0": 12.51,
     "t1": 12.73
    },
    {
     "w": "on",
     "t0": 12.73,
     "t1": 12.87
    },
    {
     "w": "your",
     "t0": 12.87,
     "t1": 13.06
    },
    {
     "w": "own",
     "t0": 13.06,
     "t1": 13.24
    },
    {
     "w": "laptop.",
     "t0": 13.24,
     "t1": 13.76
    }
   ]
  },
  {
   "id": "catch",
   "text": "The catch: it draws diagrams, not faces, so it's built for teaching.",
   "t0": 13.872,
   "t1": 17.514,
   "take": "voice/catch.wav",
   "words": [
    {
     "w": "The",
     "t0": 13.76,
     "t1": 13.93
    },
    {
     "w": "catch:",
     "t0": 13.93,
     "t1": 14.4
    },
    {
     "w": "it",
     "t0": 14.4,
     "t1": 14.56
    },
    {
     "w": "draws",
     "t0": 14.56,
     "t1": 14.89
    },
    {
     "w": "diagrams,",
     "t0": 14.89,
     "t1": 15.59
    },
    {
     "w": "not",
     "t0": 15.59,
     "t1": 15.86
    },
    {
     "w": "faces,",
     "t0": 15.86,
     "t1": 16.31
    },
    {
     "w": "so",
     "t0": 16.31,
     "t1": 16.41
    },
    {
     "w": "it's",
     "t0": 16.41,
     "t1": 16.59
    },
    {
     "w": "built",
     "t0": 16.59,
     "t1": 16.83
    },
    {
     "w": "for",
     "t0": 16.83,
     "t1": 16.97
    },
    {
     "w": "teaching.",
     "t0": 16.97,
     "t1": 17.52
    }
   ]
  },
  {
   "id": "repo",
   "text": "And get this. It's open source on GitHub, with over a hundred stars.",
   "t0": 17.634,
   "t1": 21.149,
   "take": "voice/repo.wav",
   "words": [
    {
     "w": "And",
     "t0": 17.52,
     "t1": 17.72
    },
    {
     "w": "get",
     "t0": 17.72,
     "t1": 17.92
    },
    {
     "w": "this.",
     "t0": 17.92,
     "t1": 18.4
    },
    {
     "w": "It's",
     "t0": 18.4,
     "t1": 18.64
    },
    {
     "w": "open",
     "t0": 18.64,
     "t1": 18.88
    },
    {
     "w": "source",
     "t0": 18.88,
     "t1": 19.27
    },
    {
     "w": "on",
     "t0": 19.27,
     "t1": 19.4
    },
    {
     "w": "GitHub,",
     "t0": 19.4,
     "t1": 19.86
    },
    {
     "w": "with",
     "t0": 19.86,
     "t1": 19.98
    },
    {
     "w": "over",
     "t0": 19.98,
     "t1": 20.22
    },
    {
     "w": "a",
     "t0": 20.22,
     "t1": 20.53,
     "est": true
    },
    {
     "w": "hundred",
     "t0": 20.53,
     "t1": 20.839,
     "est": true
    },
    {
     "w": "stars.",
     "t0": 20.839,
     "t1": 21.149,
     "est": true
    }
   ]
  },
  {
   "id": "cta",
   "text": "For the repo link, comment BOARD.",
   "t0": 21.269,
   "t1": 22.654,
   "take": "voice/cta.wav",
   "words": [
    {
     "w": "For",
     "t0": 21.12,
     "t1": 21.28
    },
    {
     "w": "the",
     "t0": 21.28,
     "t1": 21.51
    },
    {
     "w": "repo",
     "t0": 21.51,
     "t1": 21.69
    },
    {
     "w": "link,",
     "t0": 21.69,
     "t1": 21.99
    },
    {
     "w": "comment",
     "t0": 21.99,
     "t1": 22.57
    },
    {
     "w": "BOARD.",
     "t0": 22.57,
     "t1": 22.72
    }
   ]
  }
 ]
}
```

## File: examples/khan-skill/voice/catch.wav
size: 182316
sha: e4407b0b9eed114e7f070ac8d657eb351d07d37b
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/voice/catch.wav

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/voice/catch.wav

## File: examples/khan-skill/voice/cta.wav
size: 84012
sha: 40c270a4f271d6724a6ac9a745601f5fe5fa4739
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/voice/cta.wav

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/voice/cta.wav

## File: examples/khan-skill/voice/draw.wav
size: 157740
sha: f3b61232a08efb15c272a4138eec84542ffa60e5
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/voice/draw.wav

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/voice/draw.wav

## File: examples/khan-skill/voice/free.wav
size: 170028
sha: fa01b3bb1a9940793f68e47af294c7df54c4025c
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/voice/free.wav

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/voice/free.wav

## File: examples/khan-skill/voice/hook.wav
size: 189484
sha: 7ebf2534401edd9ce902af7fddf543c1ad45533c
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/voice/hook.wav

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/voice/hook.wav

## File: examples/khan-skill/voice/repo.wav
size: 173100
sha: c3c8aae19626303b7b4f32a4f8976b805e544dd8
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/voice/repo.wav

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/voice/repo.wav

## File: examples/khan-skill/voice/script.json
size: 1039
sha: 2b04f5d43f11644a601d12fa0ee614e25f809e5d
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/voice/script.json

```text
{
 "lead": 0.15,
 "tail": 0.6,
 "lines": [
  {
   "id": "hook",
   "text": "This free Claude skill turns any topic into a Khan Academy style video.",
   "after": 0.12,
   "file": "hook.wav"
  },
  {
   "id": "draw",
   "text": "You describe the video, and Claude draws every step on a blackboard.",
   "after": 0.12,
   "file": "draw.wav"
  },
  {
   "id": "sync",
   "text": "Then a voice explains it, and the pen lands right on the words.",
   "after": 0.12,
   "file": "sync.wav"
  },
  {
   "id": "free",
   "text": "No API keys, no credits. It all runs on your own laptop.",
   "after": 0.12,
   "file": "free.wav"
  },
  {
   "id": "catch",
   "text": "The catch: it draws diagrams, not faces, so it's built for teaching.",
   "after": 0.12,
   "file": "catch.wav"
  },
  {
   "id": "repo",
   "text": "And get this. It's open source on GitHub, with over a hundred stars.",
   "after": 0.12,
   "file": "repo.wav"
  },
  {
   "id": "cta",
   "text": "For the repo link, comment BOARD.",
   "after": 0.1,
   "file": "cta.wav"
  }
 ]
}
```

## File: examples/khan-skill/voice/sync.wav
size: 149548
sha: 2ad59f83e7df8f1c1e968a917ae53d3c59188019
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/voice/sync.wav

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/voice/sync.wav

## File: examples/khan-skill/voice/voice.wav
size: 2232428
sha: 1d4de49010e7916d00ad0ac4b97680e9c961f71a
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/voice/voice.wav

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/examples/khan-skill/voice/voice.wav

## File: kit/board.js
size: 2583
sha: e44383fef2a54b6d1d409430dd3923488facc893
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/kit/board.js

```text

// =====================================================================
//  kit/board.js — the storyboard: every beat in TIMELINE.board drawn as a 9:16 key frame with its notes.
//  The panels ARE the piece: each one is renderFrame(t) at the beat's key time, so an approved board
//  carries straight into the video.  TIMELINE.board = [{ t, title, sound, next }]
//  Render: node tools/storyboard.mjs <piece dir> [out.png] [panel numbers...]
// =====================================================================
function drawPanel(k) { renderFrame(TIMELINE.board[k].t, ctx.canvas); }
function renderBoard() {
  const BD = TIMELINE.board || [], COLS = 5, PW = 360, PH = Math.round(PW * H / W), GAP = 30, NOTE = 130, HEAD = 150;
  const rows = Math.ceil(BD.length / COLS), bc = document.createElement('canvas');
  bc.width = GAP + COLS * (PW + GAP); bc.height = HEAD + rows * (PH + NOTE + GAP);
  const g = bc.getContext('2d'), SANS = '"Segoe UI", Arial, sans-serif';
  g.fillStyle = '#2a211b'; g.fillRect(0, 0, bc.width, bc.height);
  g.fillStyle = '#efe5cf'; g.font = `400 60px ${HAND}`; g.fillText(`${document.title}: storyboard`, GAP, 80);
  g.font = `600 22px ${SANS}`; g.fillStyle = '#d9c9a8';
  g.fillText(`${TIMELINE.duration}s · ${W}×${H} · ${TIMELINE.bpm} BPM · ${BD.length} beats. Thumbs up or down by panel number.`, GAP, 122);
  const wrap = (str, x, y, maxW, lh) => { let line = ''; for (const w of str.split(' ')) { const t = line ? line + ' ' + w : w; if (g.measureText(t).width > maxW && line) { g.fillText(line, x, y); y += lh; line = w; } else line = t; } if (line) g.fillText(line, x, y); return y + lh; };
  const pc = document.createElement('canvas'); pc.width = W; pc.height = H;
  BD.forEach((b, k) => {
    renderFrame(b.t, pc);
    const x = GAP + (k % COLS) * (PW + GAP), y = HEAD + Math.floor(k / COLS) * (PH + NOTE + GAP);
    g.drawImage(pc, x, y, PW, PH);
    g.fillStyle = '#f0b27c'; g.font = `700 26px ${SANS}`; g.fillText(`${k + 1}`, x, y + PH + 32);
    g.fillStyle = '#efe5cf'; g.font = `600 20px ${SANS}`;
    let ttl = `${b.t.toFixed(1)}s · ${b.title || ''}`; while (ttl.length > 4 && g.measureText(ttl).width > PW - 44) ttl = ttl.slice(0, -2) + '…';   // fits the panel
    g.fillText(ttl, x + 40, y + PH + 30);
    g.font = `400 18px ${SANS}`; g.fillStyle = '#cdbd9c';
    const yy = b.sound ? wrap('sound: ' + b.sound, x, y + PH + 58, PW, 22) : y + PH + 58;
    if (b.next) { g.fillStyle = '#a8977a'; wrap('→ ' + b.next, x, yy + 2, PW, 22); }
  });
  return bc;
}
window.renderBoard = renderBoard; window.drawPanel = drawPanel;
```

## File: kit/core.js
size: 28964
sha: 6929ea37e41bb6f85bf8ea16e0427c1e5c836fb9
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/kit/core.js

```text
// =====================================================================
//  kit/core.js — seeded RNG, math, easing, palette, geometry, the hand-drawn drawing kit
//  (wobble on the boil, ink, paint, hatch, pencil, stipple, grain), cameras, mono text.
//  Needs from the piece head: FPS, W, H. Render state: ctx, F (frame), B (boil = floor(F/2)).
// =====================================================================

// ---------------------------------------------------------------------
//  seeded RNG
// ---------------------------------------------------------------------
function mulberry32(a) {
  return function () {
    a |= 0; a = (a + 0x6D2B79F5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
function hash(...xs) {
  let h = 2166136261 >>> 0;
  for (const x of xs) {
    const s = String(x);
    for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
    h ^= 124; h = Math.imul(h, 16777619);
  }
  return h >>> 0;
}
function RNG(...xs) {
  const f = mulberry32(hash(...xs));
  return {
    f,
    r: (a, b) => a + (b - a) * f(),
    n: (amt) => (f() * 2 - 1) * amt,
    i: (a, b) => a + Math.floor(f() * (b - a + 1)),
    pick: (arr) => arr[Math.floor(f() * arr.length)],
  };
}

// ---------------------------------------------------------------------
//  math
// ---------------------------------------------------------------------
const TAU = Math.PI * 2, DEG = Math.PI / 180;
const clamp = (x, a = 0, b = 1) => (x < a ? a : x > b ? b : x);
const lerp = (a, b, t) => a + (b - a) * t;
const seg = (t, a, b) => clamp((t - a) / (b - a));
// has absolute time t reached cut time c? (cuts snap to the nearest frame, like shot boundaries)
const past = (t, c) => Math.round(t * FPS + 1e-6) >= Math.round(c * FPS);
const dist = (a, b) => Math.hypot(b[0] - a[0], b[1] - a[1]);
const EZ = {
  o2: (t) => 1 - (1 - clamp(t)) ** 2,
  o3: (t) => 1 - (1 - clamp(t)) ** 3,
  i2: (t) => clamp(t) ** 2,
  i3: (t) => clamp(t) ** 3,
  io: (t) => { t = clamp(t); return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2; },
  back: (t) => { t = clamp(t) - 1; return 1 + 2.70158 * t * t * t + 1.70158 * t * t; },
};

// ---------------------------------------------------------------------
//  render state
// ---------------------------------------------------------------------
let ctx = null;   // current 2d context
let F = 0;        // absolute frame index
let B = 0;        // boil index — reseeds every 2 frames (Math.floor(t*12))

const C = {
  ink: '#3a2417', cream: '#efe5cf', paper: '#f4ecd9', paperLine: 'rgba(90,130,190,0.32)',
  orange: '#e58a4e', orangeD: '#c3622e', orangeL: '#f4b27c', terra: '#d97757',
  rose: '#d98b8b', roseD: '#b0616a', roseL: '#efb9b0',
  wood: '#d6a466', woodD: '#a8773f', woodL: '#e9c48f',
  sky: '#9cc4d6', skyD: '#6f9fb8', skyL: '#c9e1ea',
  teal: '#3d9f96', tealL: '#86c9be',
  yellow: '#f0c55a', yellowL: '#f7dd92',
  green: '#5f9c5c', greenL: '#a3cf92',
  red: '#d4382c', redL: '#ea7a6c',
  skin: '#ebb68f', skinD: '#c7815d', skinL: '#f7d4b4',
  lav: 'rgba(104,112,214,0.7)', lavS: 'rgba(104,112,214,0.42)',
  navy: '#0c0d1c', navy2: '#15173a', peri: '#c8d0f0', periD: '#7c86c4', periS: 'rgba(150,164,230,0.20)',
  cyan: '#52d6c4', mag: '#e0368f', glow: '#5de37a', white: '#f4f6ff',
};
const RAINBOW = ['#f0d042', '#e050b8', '#46cde0', '#7ad866', '#a47ef0'];
const MONO = 'Consolas, "Cascadia Mono", Menlo, "DejaVu Sans Mono", "Courier New", monospace';   // Windows first, then macOS / Linux

// ---------------------------------------------------------------------
//  geometry
// ---------------------------------------------------------------------
function ellipsePts(cx, cy, rx, ry, rot = 0, n = 64, a0 = 0, a1 = TAU) {
  const P = [], c = Math.cos(rot), s = Math.sin(rot), full = Math.abs(a1 - a0 - TAU) < 1e-6;
  for (let i = 0; i < n; i++) {
    const a = a0 + (a1 - a0) * i / (full ? n : n - 1);
    const x = Math.cos(a) * rx, y = Math.sin(a) * ry;
    P.push([cx + x * c - y * s, cy + x * s + y * c]);
  }
  return P;
}
function rrectPts(x, y, w, h, r, n = 6) {
  r = Math.min(r, w / 2, h / 2);
  const P = [], cs = [[x + w - r, y + r, -Math.PI / 2], [x + w - r, y + h - r, 0], [x + r, y + h - r, Math.PI / 2], [x + r, y + r, Math.PI]];
  for (const [cx, cy, a0] of cs) for (let i = 0; i <= n; i++) { const a = a0 + (Math.PI / 2) * i / n; P.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]); }
  return P;
}
function arcPts(cx, cy, r, a0, a1, n = 18, sy = 1) {
  const P = [];
  for (let i = 0; i <= n; i++) { const a = a0 + (a1 - a0) * i / n; P.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r * sy]); }
  return P;
}
function xform(P, tx, ty, rot = 0, sx = 1, sy = sx) {
  const c = Math.cos(rot), s = Math.sin(rot);
  return P.map(([x, y]) => [tx + x * sx * c - y * sy * s, ty + x * sx * s + y * sy * c]);
}
function bbox(P, pad = 0) {
  let x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9;
  for (const [x, y] of P) { if (x < x0) x0 = x; if (y < y0) y0 = y; if (x > x1) x1 = x; if (y > y1) y1 = y; }
  return { x0: x0 - pad, y0: y0 - pad, x1: x1 + pad, y1: y1 + pad };
}
function resample(P, closed, step) {
  const out = [], n = P.length, m = closed ? n : n - 1;
  for (let i = 0; i < m; i++) {
    const a = P[i], b = P[(i + 1) % n], k = Math.max(1, Math.ceil(dist(a, b) / step));
    for (let j = 0; j < k; j++) out.push([a[0] + (b[0] - a[0]) * j / k, a[1] + (b[1] - a[1]) * j / k]);
  }
  if (!closed) out.push(P[n - 1]);
  return out;
}
function pathLen(P) { let L = 0; for (let i = 1; i < P.length; i++) L += dist(P[i - 1], P[i]); return L; }
function subPath(P, frac) {
  let want = pathLen(P) * clamp(frac);
  const out = [P[0]];
  for (let i = 1; i < P.length; i++) {
    const d = dist(P[i - 1], P[i]);
    if (want >= d) { out.push(P[i]); want -= d; }
    else { const k = want / (d || 1); out.push([lerp(P[i - 1][0], P[i][0], k), lerp(P[i - 1][1], P[i][1], k)]); break; }
  }
  return out;
}
function pointAt(P, frac) { const S = subPath(P, frac); return S[S.length - 1]; }

// ---------------------------------------------------------------------
//  drawing kit — boil, sketchy lines, hatching, stipple, pencil, grain
// ---------------------------------------------------------------------
// smooth hand wobble along the normal; reseeded every boil step
function wobble(P, closed, amt, key, step = 7) {
  const Q = resample(P, closed, step), n = Q.length;
  const R = RNG('wob', key, B);
  const f1 = R.r(0.012, 0.03), f2 = R.r(0.05, 0.1), p1 = R.r(0, TAU), p2 = R.r(0, TAU);
  const sx = R.n(amt * 0.5), sy = R.n(amt * 0.5);
  const out = new Array(n); let s = 0;
  for (let i = 0; i < n; i++) {
    const a = Q[closed ? (i - 1 + n) % n : Math.max(0, i - 1)], b = Q[closed ? (i + 1) % n : Math.min(n - 1, i + 1)];
    const nx = a[1] - b[1], ny = b[0] - a[0], l = Math.hypot(nx, ny) || 1;
    if (i) s += dist(Q[i - 1], Q[i]);
    const o = amt * (0.75 * Math.sin(s * f1 + p1) + 0.35 * Math.sin(s * f2 + p2));
    out[i] = [Q[i][0] + nx / l * o + sx, Q[i][1] + ny / l * o + sy];
  }
  return out;
}
function trace(P, closed) {
  ctx.beginPath();
  const n = P.length; if (n < 2) return;
  if (closed) {
    ctx.moveTo((P[n - 1][0] + P[0][0]) / 2, (P[n - 1][1] + P[0][1]) / 2);
    for (let i = 0; i < n; i++) { const p = P[i], q = P[(i + 1) % n]; ctx.quadraticCurveTo(p[0], p[1], (p[0] + q[0]) / 2, (p[1] + q[1]) / 2); }
    ctx.closePath();
  } else {
    ctx.moveTo(P[0][0], P[0][1]);
    for (let i = 1; i < n - 1; i++) { const p = P[i], q = P[i + 1]; ctx.quadraticCurveTo(p[0], p[1], (p[0] + q[0]) / 2, (p[1] + q[1]) / 2); }
    ctx.lineTo(P[n - 1][0], P[n - 1][1]);
  }
}
// sketchy stroke. o: closed, color, w, amt, key, alpha, frac (write-on), double
function ink(P, o = {}) {
  const closed = !!o.closed, key = o.key ?? 'ink';
  let Q = o.raw ? P : wobble(P, closed, o.amt ?? 1.8, key);
  let cl = closed;
  if (o.frac != null && o.frac < 1) { if (o.frac <= 0) return Q; Q = subPath(closed ? [...Q, Q[0]] : Q, o.frac); cl = false; }
  ctx.strokeStyle = o.color || C.ink; ctx.lineWidth = o.w ?? 3; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
  ctx.globalAlpha = o.alpha ?? 1;
  trace(Q, cl); ctx.stroke();
  if (o.double) {
    let Q2 = wobble(P, closed, (o.amt ?? 1.8) * 1.6 + 0.8, key + '~');
    if (!cl && closed) Q2 = subPath([...Q2, Q2[0]], o.frac ?? 1);
    ctx.lineWidth = (o.w ?? 3) * 0.4; ctx.globalAlpha = (o.alpha ?? 1) * 0.55; trace(Q2, cl); ctx.stroke();
  }
  ctx.globalAlpha = 1;
  return Q;
}
// filled shape + textures (clipped) + ink outline
function paint(P, o = {}) {
  const key = o.key || 'paint';
  const Q = o.raw ? P : wobble(P, true, o.amt ?? 1.6, key);
  ctx.save();
  trace(Q, true);
  if (o.fill) { ctx.globalAlpha = o.fillAl ?? 1; ctx.fillStyle = o.fill; ctx.fill(); ctx.globalAlpha = 1; }
  if (o.tex) { ctx.clip(); const bb = bbox(Q, 6); o.tex.forEach((t, i) => texture(bb, { key: key + ':' + i, ...t })); }
  ctx.restore();
  if (o.line !== false) {
    ctx.strokeStyle = o.line || C.ink; ctx.lineWidth = o.w ?? 3; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
    ctx.globalAlpha = o.lineAl ?? 1; trace(Q, true); ctx.stroke(); ctx.globalAlpha = 1;
  }
  return Q;
}
function texture(bb, t) {
  if (t.k === 'hatch') hatch(bb, t);
  else if (t.k === 'cross') { hatch(bb, t); hatch(bb, { ...t, a: (t.a ?? 0.7) + (t.a2 ?? 1.25), key: t.key + 'x' }); }
  else if (t.k === 'pencil') pencil(bb, t);
  else if (t.k === 'stipple') stipple(bb, t);
  else if (t.k === 'fn') t.fn(bb);
}
// parallel hatching inside current clip. t: a angle, gap, c, w, al, dash [min,max], skip, dens(x,y)
function hatch(bb, t) {
  const a = t.a ?? 0.7, gap = t.gap ?? 7, S = RNG('hs', t.key), R = RNG('hj', t.key, B);
  const cx = (bb.x0 + bb.x1) / 2, cy = (bb.y0 + bb.y1) / 2, rad = Math.hypot(bb.x1 - bb.x0, bb.y1 - bb.y0) / 2 + 4;
  const ca = Math.cos(a), sa = Math.sin(a), dens = t.dens, jit = t.jit ?? 0.9, dash = t.dash;
  ctx.beginPath();
  for (let d = -rad; d <= rad; d += gap) {
    const dd = d + S.n(gap * 0.3);
    let u = -rad + (dash ? S.r(0, dash[1]) : 0);
    while (u < rad) {
      const len = dash ? S.r(dash[0], dash[1]) : rad * 2, u2 = Math.min(rad, u + len);
      const mu = (u + u2) / 2, mx = cx + ca * mu - sa * dd, my = cy + sa * mu + ca * dd, p = S.f();
      if (!dens || p < dens(mx, my)) {
        const e1 = R.n(jit), e2 = R.n(jit);
        ctx.moveTo(cx + ca * u - sa * (dd + e1), cy + sa * u + ca * (dd + e1));
        ctx.lineTo(cx + ca * u2 - sa * (dd + e2), cy + sa * u2 + ca * (dd + e2));
      }
      u = u2 + (dash ? S.r(t.skip?.[0] ?? 3, t.skip?.[1] ?? 12) : 0);
    }
  }
  ctx.strokeStyle = t.c || C.ink; ctx.lineWidth = t.w ?? 1.2; ctx.globalAlpha = t.al ?? 0.5; ctx.lineCap = 'round';
  ctx.stroke(); ctx.globalAlpha = 1;
}
// colored-pencil diagonal strokes
function pencil(bb, t) {
  const S = RNG('ps', t.key), R = RNG('pj', t.key, B);
  const area = (bb.x1 - bb.x0) * (bb.y1 - bb.y0);
  const n = Math.min(t.max ?? 6000, t.n ?? Math.floor(area / (t.per ?? 700)));
  const a = t.a ?? -0.95, L = t.len ?? 14, dens = t.dens;
  ctx.beginPath();
  for (let i = 0; i < n; i++) {
    const x = S.r(bb.x0, bb.x1), y = S.r(bb.y0, bb.y1), p = S.f(), aa = a + S.n(0.22), l = L * (0.5 + S.f() * 0.7);
    if (dens && p > dens(x, y)) continue;
    const jx = R.n(1.1), jy = R.n(1.1), dx = Math.cos(aa) * l / 2, dy = Math.sin(aa) * l / 2;
    ctx.moveTo(x - dx + jx, y - dy + jy); ctx.lineTo(x + dx + jx, y + dy + jy);
  }
  ctx.strokeStyle = t.c; ctx.lineWidth = t.w ?? 1.4; ctx.globalAlpha = t.al ?? 0.45; ctx.lineCap = 'round';
  ctx.stroke(); ctx.globalAlpha = 1;
}
// square stipple dots
function stipple(bb, t) {
  const S = RNG('ss', t.key), R = RNG('sj', t.key, B);
  const n = t.n ?? 400, sz = t.s ?? 2.2, dens = t.dens, jit = t.jit ?? 0.8;
  ctx.fillStyle = t.c; ctx.globalAlpha = t.al ?? 1; ctx.beginPath();
  for (let i = 0; i < n; i++) {
    const x = S.r(bb.x0, bb.x1), y = S.r(bb.y0, bb.y1), p = S.f(), s = sz * (0.6 + S.f() * 0.8);
    if (dens && p > dens(x, y)) continue;
    ctx.rect(x + R.n(jit), y + R.n(jit), s, s);
  }
  ctx.fill(); ctx.globalAlpha = 1;
}
// screen-space paper grain, reseeded with the boil
function grain(o = {}) {
  ctx.save(); screen();
  const R = RNG('grain', B), n = o.n ?? 2600;
  ctx.fillStyle = o.dark || 'rgba(70,45,25,0.13)'; ctx.beginPath();
  for (let i = 0; i < n; i++) ctx.rect(R.r(0, W), R.r(0, H), 1.7, 1.7);
  ctx.fill();
  ctx.fillStyle = o.light || 'rgba(255,250,236,0.20)'; ctx.beginPath();
  for (let i = 0; i < n; i++) ctx.rect(R.r(0, W), R.r(0, H), 1.9, 1.9);
  ctx.fill();
  // a few paper fibres
  ctx.strokeStyle = 'rgba(80,55,35,0.07)'; ctx.lineWidth = 1; ctx.beginPath();
  for (let i = 0; i < 90; i++) { const x = R.r(0, W), y = R.r(0, H), a = R.r(0, TAU), l = R.r(6, 22); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(a) * l, y + Math.sin(a) * l); }
  ctx.stroke();
  ctx.restore();
}

// ---------------------------------------------------------------------
//  construction lines, rulers, sparks, bursts, rainbow contours
// ---------------------------------------------------------------------
function construct(cx, cy, r, o = {}) {
  const R = RNG('con', o.key ?? 'c', B);
  ctx.save();
  ctx.strokeStyle = o.c || C.lav; ctx.lineWidth = o.w ?? 1.4; ctx.globalAlpha = o.al ?? 1; ctx.lineCap = 'round';
  const a0 = (o.a0 ?? R.r(0, TAU)), sw = o.sweep ?? 1.05;
  ctx.beginPath(); ctx.ellipse(cx + R.n(1.2), cy + R.n(1.2), r, r * (o.sq ?? 1), o.rot ?? 0, a0, a0 + TAU * sw); ctx.stroke();
  if (o.cross) { const k = o.cross; ctx.beginPath(); ctx.moveTo(cx - k, cy); ctx.lineTo(cx + k, cy); ctx.moveTo(cx, cy - k); ctx.lineTo(cx, cy + k); ctx.stroke(); }
  if (o.ticks) {
    ctx.beginPath();
    for (let i = 0; i < o.ticks; i++) { const a = i / o.ticks * TAU + (o.rot ?? 0); ctx.moveTo(cx + Math.cos(a) * (r - 6), cy + Math.sin(a) * (r - 6)); ctx.lineTo(cx + Math.cos(a) * (r + 6), cy + Math.sin(a) * (r + 6)); }
    ctx.stroke();
  }
  ctx.restore();
}
function cline(x1, y1, x2, y2, o = {}) {
  const R = RNG('cl', o.key ?? 'l', B);
  ctx.save(); ctx.strokeStyle = o.c || C.lavS; ctx.lineWidth = o.w ?? 1.2; ctx.globalAlpha = o.al ?? 1;
  ctx.beginPath(); ctx.moveTo(x1 + R.n(1), y1 + R.n(1)); ctx.lineTo(x2 + R.n(1), y2 + R.n(1)); ctx.stroke(); ctx.restore();
}
function ruler(x1, y1, x2, y2, o = {}) {
  const R = RNG('rul', o.key ?? 'r', B);
  const L = Math.hypot(x2 - x1, y2 - y1) || 1, ux = (x2 - x1) / L, uy = (y2 - y1) / L, nx = -uy, ny = ux;
  const frac = clamp(o.frac ?? 1), step = o.step ?? 16, tk = o.tick ?? 7, side = o.side ?? 1;
  ctx.save(); ctx.strokeStyle = o.c || C.periD; ctx.lineWidth = o.w ?? 1.5; ctx.globalAlpha = o.al ?? 1; ctx.lineCap = 'round';
  const jx = R.n(0.8), jy = R.n(0.8);
  ctx.beginPath(); ctx.moveTo(x1 + jx, y1 + jy); ctx.lineTo(x1 + ux * L * frac + jx, y1 + uy * L * frac + jy);
  const n = Math.floor(L * frac / step);
  for (let i = 0; i <= n; i++) {
    const k = i % 5 === 0 ? tk * 1.9 : tk, px = x1 + ux * i * step + jx, py = y1 + uy * i * step + jy;
    ctx.moveTo(px, py); ctx.lineTo(px + nx * k * side, py + ny * k * side);
  }
  ctx.moveTo(x1 - nx * tk * 1.6, y1 - ny * tk * 1.6); ctx.lineTo(x1 + nx * tk * 2.2, y1 + ny * tk * 2.2);
  if (frac >= 1) { ctx.moveTo(x2 - nx * tk * 1.6, y2 - ny * tk * 1.6); ctx.lineTo(x2 + nx * tk * 2.2, y2 + ny * tk * 2.2); }
  ctx.stroke(); ctx.restore();
}
// glowing radial spark (dark world)
function sparkBurst(cx, cy, r, o = {}) {
  if (r <= 0.5) return;
  const R = RNG('spk', o.key ?? 's', B), n = o.n ?? 18, rays = [];
  for (let i = 0; i < n; i++) { const a = i / n * TAU + R.n(0.14); rays.push([a, r * R.r(0.28, 0.5), r * R.r(0.72, 1.15)]); }
  const path = () => { ctx.beginPath(); for (const [a, r0, r1] of rays) { ctx.moveTo(cx + Math.cos(a) * r0, cy + Math.sin(a) * r0); ctx.lineTo(cx + Math.cos(a) * r1, cy + Math.sin(a) * r1); } };
  ctx.save(); ctx.lineCap = 'round';
  path(); ctx.strokeStyle = o.glow || 'rgba(82,214,196,0.22)'; ctx.lineWidth = (o.w ?? 2.4) * 3.4; ctx.stroke();
  path(); ctx.strokeStyle = o.c || '#8ff5e6'; ctx.lineWidth = o.w ?? 2.4; ctx.stroke();
  if (o.core !== false) {
    const cr = Math.max(2.5, r * (o.coreR ?? 0.15));
    ctx.beginPath(); ctx.arc(cx, cy, cr * 2.2, 0, TAU); ctx.fillStyle = 'rgba(190,215,255,0.16)'; ctx.fill();
    ctx.beginPath(); ctx.arc(cx, cy, cr, 0, TAU); ctx.fillStyle = C.white; ctx.fill(); ctx.lineWidth = 2; ctx.strokeStyle = C.periD; ctx.stroke();
  }
  if (o.specks) { ctx.fillStyle = o.c || '#8ff5e6'; for (let i = 0; i < o.specks; i++) { const a = R.r(0, TAU), d = r * R.r(0.2, 1.4); ctx.fillRect(cx + Math.cos(a) * d, cy + Math.sin(a) * d, 2, 2); } }
  ctx.restore();
}
// radial tick burst (warm world impact)
function burst(cx, cy, r0, r1, o = {}) {
  const R = RNG('bur', o.key ?? 'b', B), n = o.n ?? 12;
  ctx.save(); ctx.strokeStyle = o.c || C.ink; ctx.lineWidth = o.w ?? 3; ctx.lineCap = 'round'; ctx.globalAlpha = o.al ?? 1; ctx.beginPath();
  for (let i = 0; i < n; i++) {
    const a = i / n * TAU + R.n(0.15) + (o.rot ?? 0), a1 = r0 * R.r(0.85, 1.1), b1 = r1 * R.r(0.75, 1.15);
    ctx.moveTo(cx + Math.cos(a) * a1, cy + Math.sin(a) * a1); ctx.lineTo(cx + Math.cos(a) * b1, cy + Math.sin(a) * b1);
  }
  ctx.stroke(); ctx.restore();
}
function star4(x, y, s, c) {
  ctx.save(); ctx.fillStyle = c; ctx.beginPath();
  ctx.moveTo(x, y - s); ctx.quadraticCurveTo(x, y, x + s, y); ctx.quadraticCurveTo(x, y, x, y + s);
  ctx.quadraticCurveTo(x, y, x - s, y); ctx.quadraticCurveTo(x, y, x, y - s); ctx.fill(); ctx.restore();
}
// rainbow contour strokes around a moving part
function rainbow(P, o = {}) {
  const bb = bbox(P), cx = o.cx ?? (bb.x0 + bb.x1) / 2, cy = o.cy ?? (bb.y0 + bb.y1) / 2, n = o.n ?? 3;
  for (let i = 0; i < n; i++) {
    const S = RNG('rb', o.key, i, B), sc = 1 + (o.spread ?? 0.07) * (i + 1) + S.n(0.02);
    const Q = P.map(([x, y]) => [cx + (x - cx) * sc + S.n(o.drift ?? 3), cy + (y - cy) * sc + S.n(o.drift ?? 3)]);
    ink(Q, { closed: o.closed ?? true, color: RAINBOW[(i + (o.off ?? 0)) % RAINBOW.length], w: o.w ?? 1.6, amt: o.amt ?? 3.5, key: o.key + 'rb' + i, alpha: o.al ?? 0.85 });
  }
}
// hex cell grid inside current clip
function hexGrid(bb, size, o = {}) {
  const h = size * Math.sqrt(3), R = RNG('hex', o.key ?? 'h', B);
  ctx.save(); ctx.strokeStyle = o.c || C.periS; ctx.lineWidth = o.w ?? 1.2; ctx.globalAlpha = o.al ?? 1; ctx.beginPath();
  let row = 0;
  for (let y = bb.y0 - h; y < bb.y1 + h; y += h * 0.5, row++) {
    for (let x = bb.x0 - size * 3 + (row % 2) * size * 1.5; x < bb.x1 + size * 3; x += size * 3) {
      const jx = R.n(0.6), jy = R.n(0.6);
      for (let k = 0; k < 6; k++) {
        const a = k / 6 * TAU, b = (k + 1) / 6 * TAU;
        if (k === 0) ctx.moveTo(x + Math.cos(a) * size + jx, y + Math.sin(a) * size + jy);
        ctx.lineTo(x + Math.cos(b) * size + jx, y + Math.sin(b) * size + jy);
      }
    }
  }
  ctx.stroke(); ctx.restore();
}

// ---------------------------------------------------------------------
//  iris, camera, text
// ---------------------------------------------------------------------
// ragged hatched rim; dir=+1 strokes reach outward, -1 inward
function irisRim(cx, cy, r, dir, o = {}) {
  const R = RNG('irisR', o.key ?? 'i', B), n = Math.min(1600, Math.floor(r * TAU / 2.0));
  ctx.strokeStyle = o.c || '#0b0c17'; ctx.lineWidth = o.w ?? 1.6; ctx.lineCap = 'round'; ctx.beginPath();
  for (let i = 0; i < n; i++) {
    const a = R.r(0, TAU), L = R.r(3, 11) + (R.f() < 0.28 ? R.r(10, 34) : 0), sl = R.n(0.4);
    const r0 = r - dir * R.r(2, 6), r1 = r + dir * L, ax = Math.cos(a), ay = Math.sin(a);
    ctx.moveTo(cx + ax * r0, cy + ay * r0); ctx.lineTo(cx + ax * r1 - ay * L * sl, cy + ay * r1 + ax * L * sl);
  }
  ctx.stroke();
}
// iris into a black hole (screen space)
function irisHole(cx, cy, r, o = {}) {
  if (r <= 0.5) return;
  ctx.save(); screen();
  const col = o.c || '#0b0c17';
  const P = wobble(ellipsePts(cx, cy, r, r, 0, 140), true, Math.min(4, 0.6 + r * 0.02), 'ih' + (o.key ?? ''), 9);
  trace(P, true); ctx.fillStyle = col; ctx.fill();
  irisRim(cx, cy, r, +1, { c: col, key: o.key });
  if (o.inner) { ctx.save(); trace(P, true); ctx.clip(); o.inner(r); ctx.restore(); }
  ctx.restore();
}
// world point (cx,cy) lands at screen centre
function camera(cx, cy, zoom = 1, rot = 0, sx = 0, sy = 0) {
  const c = Math.cos(rot) * zoom, s = Math.sin(rot) * zoom;
  ctx.setTransform(c, s, -s, c, W / 2 + sx - (c * cx - s * cy), H / 2 + sy - (s * cx + c * cy));
}
function screen() { ctx.setTransform(1, 0, 0, 1, 0, 0); }
function toScreen(x, y) { const p = ctx.getTransform().transformPoint(new DOMPoint(x, y)); return [p.x, p.y]; }
function fillAll(color) { ctx.save(); screen(); ctx.fillStyle = color; ctx.fillRect(0, 0, W, H); ctx.restore(); }

// monospace text with per-glyph hand jitter
function monoText(str, x, y, size, color, o = {}) {
  ctx.save();
  ctx.font = `${o.weight ?? 700} ${size}px ${MONO}`; ctx.fillStyle = color; ctx.textBaseline = 'alphabetic';
  const cw = ctx.measureText('M').width, R = RNG('txt', o.key ?? str, B), jit = o.jit ?? 0.9;
  const n = Math.min(o.count ?? str.length, str.length);
  ctx.globalAlpha = o.al ?? 1;
  for (let i = 0; i < n; i++) {
    const ch = str[i]; if (ch === ' ') continue;
    ctx.save(); ctx.translate(x + i * cw + R.n(jit), y + R.n(jit)); ctx.rotate(R.n(0.045)); ctx.fillText(ch, 0, 0); ctx.restore();
  }
  ctx.restore();
  return cw;
}
function monoW(size) { ctx.save(); ctx.font = `700 ${size}px ${MONO}`; const w = ctx.measureText('M').width; ctx.restore(); return w; }

// single-stroke hand lettering (x-height 0.5, ascender 1.0, baseline 0)
const GLYPHS = {
  ' ': { w: 0.26, s: [] },
  c: { w: 0.5, s: [arcPts(0.26, -0.25, 0.25, -35 * DEG, -322 * DEG, 22)] },
  o: { w: 0.52, s: [arcPts(0.26, -0.25, 0.25, -95 * DEG, -460 * DEG, 30)] },
  a: { w: 0.54, s: [[...arcPts(0.25, -0.25, 0.235, -28 * DEG, -335 * DEG, 24)], [[0.49, -0.5], [0.49, -0.05], [0.55, 0.0]]] },
  d: { w: 0.56, s: [[...arcPts(0.25, -0.25, 0.235, -28 * DEG, -335 * DEG, 24)], [[0.49, -1.0], [0.49, -0.05], [0.56, 0.0]]] },
  l: { w: 0.24, s: [[[0.08, -1.0], [0.08, -0.1], [0.12, -0.01], [0.2, 0.0]]] },
  u: { w: 0.54, s: [[[0.03, -0.5], [0.03, -0.22], ...arcPts(0.255, -0.22, 0.225, 180 * DEG, 0, 14), [0.48, -0.5]], [[0.48, -0.5], [0.48, -0.04], [0.54, 0.0]]] },
  e: { w: 0.52, s: [[[0.03, -0.26], [0.5, -0.26], ...arcPts(0.265, -0.25, 0.245, -4 * DEG, -318 * DEG, 26)]] },
};
function handwrite(str, x, y, size, o = {}) {
  const strokes = []; let pen = 0;
  for (const ch of str) {
    const g = GLYPHS[ch] || GLYPHS[' '];
    for (const s of g.s) strokes.push(s.map(([u, v]) => [x + (pen + u) * size, y + v * size]));
    pen += g.w + 0.1;
  }
  const lens = strokes.map(pathLen), total = lens.reduce((a, b) => a + b, 0);
  let budget = clamp(o.frac ?? 1) * total;
  for (let i = 0; i < strokes.length && budget > 0; i++) {
    const f = Math.min(1, budget / lens[i]); budget -= lens[i];
    if (o.glow) ink(strokes[i], { color: o.glow, w: (o.w ?? size * 0.06) * 3, amt: o.amt ?? 1.0, key: 'hw' + i + str, frac: f });
    ink(strokes[i], { color: o.c, w: o.w ?? size * 0.06, amt: o.amt ?? 1.0, key: 'hw' + i + str, frac: f });
  }
  return pen * size;
}

// =====================================================================
// ---- small props and helpers
function drawPencil(tx, ty, ang, len, k = 1, key = 'pencil') {
  ctx.save(); ctx.translate(tx, ty); ctx.rotate(ang); ctx.scale(k, k);
  paint([[46, -16], [len, -16], [len, 16], [46, 16]], {
    fill: C.yellow, key: key + 'b', w: 3,
    tex: [{ k: 'hatch', a: 0.05, gap: 5, c: '#a8741d', al: 0.55, w: 1.1, dash: [20, 90], dens: (x, y) => (y > 5 ? 1 : 0.08) }, { k: 'pencil', c: '#fff1b8', al: 0.5, a: 0.05, len: 30, per: 120, dens: (x, y) => (y < -4 ? 1 : 0.1) }],
  });
  ink([[50, -5], [len, -5]], { w: 1.6, key: key + 'f1', alpha: 0.7 });
  ink([[50, 6], [len, 6]], { w: 1.6, key: key + 'f2', alpha: 0.7 });
  paint([[1, 0], [46, -15], [46, 15]], { fill: '#efcf9f', key: key + 'c', w: 2.6, tex: [{ k: 'hatch', a: 1.2, gap: 4, al: 0.35, dens: (x, y) => (y > 2 ? 1 : 0.1) }] });
  paint([[0, 0], [15, -5], [15, 5]], { fill: '#3b3438', key: key + 't', w: 2 });
  paint([[len, -17], [len + 26, -17], [len + 26, 17], [len, 17]], { fill: '#bcb7ab', key: key + 'fe', w: 2.6, tex: [{ k: 'hatch', a: Math.PI / 2, gap: 5, al: 0.5 }] });
  paint(rrectPts(len + 24, -16, 34, 32, 10), { fill: '#e58f8c', key: key + 'e', w: 2.6, tex: [{ k: 'pencil', c: C.roseD, al: 0.5, per: 60 }] });
  ctx.restore();
}
// smoothstep of x between a and b (NOT manim's `smooth` rate function — the math style calls that smoothM)
const smooth = (a, b, x) => { const u = clamp((x - a) / (b - a)); return u * u * (3 - 2 * u); };
const mod = (a, n) => ((a % n) + n) % n;
function asterisk(x, y, r0, r1, n, rot, color, w) {
  ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = w; ctx.lineCap = 'round'; ctx.beginPath();
  for (let i = 0; i < n; i++) { const a = i / n * TAU + rot; ctx.moveTo(x + Math.cos(a) * r0, y + Math.sin(a) * r0); ctx.lineTo(x + Math.cos(a) * r1, y + Math.sin(a) * r1); }
  ctx.stroke(); ctx.restore();
}

// ---------------------------------------------------------------------
//  time (set by the renderer): TT = absolute time on the motion step (on 2s unless STYLE.ones);
//  E0 = the current era's start; DEFER = overlay queue (captions drawn after the camera); SPARK_AT = the hero's screen spot
//  ev(t, d) = 0..1 progress of an event at t over d seconds; popS(t) = back-eased pop-in scale
// ---------------------------------------------------------------------
let TT = 0, E0 = 0, DEFER = null, SPARK_AT = null;
const ev = (t, d = 0.25) => clamp((TT - t) / d);
const popS = (t) => (TT < t ? 0 : EZ.back(clamp((TT - t) / 0.25)));

// ---------------------------------------------------------------------
//  springs: closed-form, so any frame can be drawn on its own (deterministic, no state)
//  springEase(t, k, d)       0 -> 1 over time t >= 0 (stiffness k, damping d): accelerates, overshoots a hair, settles
//  springMove(t0, a, b, p)   a value moving from a to b, starting at time t0 (p = a SPRING preset)
//  springTrack(keys, p)      a value with many targets: keys = [[t, value], ...] sorted by t. One spring per change,
//                            added up, so a target change mid-move stays continuous (cursors, widths, positions)
// ---------------------------------------------------------------------
const SPRING = {
  snappy: { k: 320, d: 30 },    // UI: buttons, toggles, leading edges
  smooth: { k: 170, d: 26 },    // cards, containers, the camera
  heavy: { k: 90, d: 19 },      // big type, logos, large objects
  playful: { k: 260, d: 14 },   // mascots, stickers: visible overshoot
};
function springEase(t, k = 170, d = 26) {
  if (t <= 0) return 0;
  const w0 = Math.sqrt(k), z = d / (2 * w0);
  if (z < 1) { const wd = w0 * Math.sqrt(1 - z * z); return 1 - Math.exp(-z * w0 * t) * (Math.cos(wd * t) + (z * w0 / wd) * Math.sin(wd * t)); }
  return 1 - Math.exp(-w0 * t) * (1 + w0 * t);
}
const springMove = (t0, a, b, p = SPRING.smooth) => a + (b - a) * springEase(TT - t0, p.k, p.d);
function springTrack(keys, p = SPRING.smooth) {
  let v = keys[0][1];
  for (let i = 1; i < keys.length; i++) v += (keys[i][1] - keys[i - 1][1]) * springEase(TT - keys[i][0], p.k, p.d);
  return v;
}

// ---------------------------------------------------------------------
//  layout for several formats (9:16, 1:1, 16:9, 4:5 from one piece): place things by fractions of the frame,
//  size them by UNIT (1 at 1080 on the short side). LX(0.5), LY(0.62), 120 * UNIT. PORTRAIT / WIDE pick layouts.
// ---------------------------------------------------------------------
const LX = (f) => f * W, LY = (f) => f * H, UNIT = Math.min(W, H) / 1080, PORTRAIT = H > W * 1.2, WIDE = W > H * 1.2;

// ---------------------------------------------------------------------
//  the user's own assets (screenshots, logos): files in <piece>/assets/ that tools/build.mjs embeds in the page.
//  asset('logo.png') -> the Image (or null); drawAsset(name, x, y, w, h, { fit: 'contain'|'cover', r, alpha })
//  Real product UI only: never redraw a product screen from imagination when a screenshot exists.
// ---------------------------------------------------------------------
const asset = (name) => (typeof ASSETS !== 'undefined' && ASSETS[name] && ASSETS[name].complete ? ASSETS[name] : null);
function drawAsset(name, x, y, w, h, o = {}) {
  const img = asset(name); if (!img) return false;
  const iw = img.naturalWidth || img.width, ih = img.naturalHeight || img.height;
  const s = (o.fit === 'cover' ? Math.max : Math.min)(w / iw, h / ih), dw = iw * s, dh = ih * s;
  ctx.save(); ctx.globalAlpha *= o.alpha ?? 1;
  if (o.r || o.fit === 'cover') { ctx.beginPath(); ctx.roundRect(x, y, w, h, o.r || 0); ctx.clip(); }
  ctx.drawImage(img, x + (w - dw) / 2, y + (h - dh) / 2, dw, dh);
  ctx.restore(); return true;
}
```

## File: kit/morph.js
size: 9520
sha: 71439c9c0afb69d358d7253f83c8fab927d62fa2
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/kit/morph.js

```text

// =====================================================================
//  kit/morph.js — the renderer for pieces built from ERAS (scenes) joined by SHAPE MORPHS.
//
//  At each bridge time tc:
//    shrink [tc-d, tc-m]      the old era is seen through a window shaped like the bridge object (STYLE.window),
//                             shrinking onto the object while the backdrop fades to blank paper
//    morph  [tc-m, tc+m]      on blank paper, the object's outline blends into its counterpart's
//                             (both resampled by angle around their centroids; colour lerped). Hero-to-hero
//                             bridges draw the hero itself with blended position, size and ray count.
//    grow   [tc+m, tc+d]      the new era opens out of a window shaped like the counterpart
//    d = the bridge's half-length (default 0.6s, so a bridge costs 1.2s), m = the morph's half (default 0.2s, or d/3 when d is set).
//    Short pieces: { tc, d: 0.4 } (0.8s) or fewer bridges — see craft.md -> Transitions.
//  Era boundaries WITHOUT a bridge are hard cuts (keep at most one: the slam).
//
//  The piece defines (in its head / src):
//    ERA_LIST = [[t0, t1, () => drawScene()], ...]      scenes draw in world coords, call yearTag()/capStrip()
//    ERA_BG   = ['#hex', ...]                          each era's dominant colour (for the fade to paper)
//    BRIDGES  = [{ tc, A: () => shape, B: () => shape, d?, m? }]   shape = { P: points, c: '#hex' } or SP(x, y, r, rays)
//    function pieceCam(era, t) { return null | CAM }   optional; use push() / bump() below
//  Shapes are in each era's world coords; the bridge object must be in frame at tc (pull push-ins back first).
//  The look comes from the style kit's STYLE hooks (paper, backdrop, window, blob, hero, post, ones):
//  this file draws nothing itself, so the same piece structure works in any style.
// =====================================================================
// ---- cameras: CAM = { z, tx, ty }; camOf({ z, p, to }) puts world point p on screen point to
const CAM0 = { z: 1, tx: 0, ty: 0 };
const camOf = (c) => ({ z: c.z, tx: c.to[0] - c.z * c.p[0], ty: c.to[1] - c.z * c.p[1] });
const camMix = (a, b, u) => ({ z: lerp(a.z, b.z, u), tx: lerp(a.tx, b.tx, u), ty: lerp(a.ty, b.ty, u) });
// an eased push-in over [a, b] to cam, optionally pulled back over [oa, ob]
function push(t, a, b, cam, oa, ob) { let u = EZ.io(seg(t, a, b)); if (oa != null) u *= 1 - EZ.io(seg(t, oa, ob)); return u > 0 ? camMix(CAM0, camOf(cam), u) : null; }
// a zoom bump on every beat from t0, centred on point p (the hero keeps its screen spot)
function bump(t, t0, p, amt = 0.3, every = 0.5) { return camOf({ z: 1 + amt * Math.exp(-mod(t - t0, every) * 7), p, to: p }); }
function camAt(era, t) { return (typeof pieceCam === 'function' && pieceCam(era, t)) || CAM0; }
const camPts = (P, c) => P.map(([x, y]) => [c.z * x + c.tx, c.z * y + c.ty]);
// ---- bridge shapes
function starPts(x, y, r, n, r0 = -Math.PI / 2, m = 96) {
  const P = []; for (let i = 0; i < m; i++) { const a = i / m * TAU, k = 0.42 + 0.58 * (0.5 + 0.5 * Math.cos(n * (a - r0))) ** 3; P.push([x + Math.cos(a) * r * k, y + Math.sin(a) * r * k]); }
  return P;
}
const SP = (x, y, r, n) => ({ spark: [x, y, r, n] });
const shapeOf = (s) => (s.spark ? { P: (STYLE.heroPts || starPts)(...s.spark), c: STYLE.heroColor, spark: s.spark } : s);
function byAngle(P, m = 96) {   // resample a closed outline to m points by angle around its centroid
  const c = P.reduce((a, p) => [a[0] + p[0] / P.length, a[1] + p[1] / P.length], [0, 0]), Q = resample(P, true, 4), out = [];
  for (let i = 0; i < m; i++) {
    const a = -Math.PI + i / m * TAU; let best = null, bd = 1e9;
    for (const q of Q) { let d = Math.atan2(q[1] - c[1], q[0] - c[0]) - a; d = Math.abs(Math.atan2(Math.sin(d), Math.cos(d))); const r = Math.hypot(q[0] - c[0], q[1] - c[1]); if (d < bd - 1e-4 || (Math.abs(d - bd) < 1e-4 && r > best[2])) { bd = d; best = [q[0], q[1], r]; } }
    out.push([best[0], best[1]]);
  }
  return { P: out, c };
}
const mixHex = (a, b, u) => { const p = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)); const x = p(a), y = p(b); return `rgb(${x.map((v, i) => Math.round(lerp(v, y[i], u))).join(',')})`; };
function maskK(P) {   // how far the window must scale about its centroid to cover the frame
  const c = P.reduce((a, p) => [a[0] + p[0] / P.length, a[1] + p[1] / P.length], [0, 0]);
  const rmin = Math.max(8, Math.min(...byAngle(P, 48).P.map((p) => Math.hypot(p[0] - c[0], p[1] - c[1]))));
  const far = Math.max(...[[0, 0], [W, 0], [0, H], [W, H]].map((q) => Math.hypot(q[0] - c[0], q[1] - c[1])));
  return { c, kmax: far / rmin * 1.1 };
}
const scaled = (P, c, k) => P.map(([x, y]) => [c[0] + (x - c[0]) * k, c[1] + (y - c[1]) * k]);
// ---- layers and windows
const LAYER = [];
function layer(i) { if (!LAYER[i]) { const c = document.createElement('canvas'); c.width = W; c.height = H; LAYER[i] = c; } return LAYER[i]; }
function renderEra(e, target) {   // draws era e into target; returns its overlay queue (year tag, caption)
  const saved = ctx; ctx = target.getContext('2d');
  ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = 1; ctx.filter = 'none'; ctx.setLineDash([]);
  ctx.fillStyle = STYLE.paper; ctx.fillRect(0, 0, W, H);   // a clean sheet every frame: nothing carries over between frames
  const cam = camAt(e, TT); ctx.setTransform(cam.z, 0, 0, cam.z, cam.tx, cam.ty);
  DEFER = []; ERA_LIST[e][2](); const q = DEFER; DEFER = null;
  ctx.restore(); ctx = saved;
  return q;
}
function flush(q, e) { const keep = E0; E0 = ERA_LIST[e][0]; ctx.setTransform(1, 0, 0, 1, 0, 0); q.forEach((f) => f()); E0 = keep; }
function shotAtFrame(fr) {
  for (const s of TIMELINE.shots) if (fr >= Math.round(s.t0 * FPS) && fr < Math.round(s.t1 * FPS)) return s;
  return TIMELINE.shots[TIMELINE.shots.length - 1];
}
const eraAt = (t) => ERA_LIST.findIndex(([a, b]) => t >= a - 1e-6 && t < b - 1e-6);
// renderFrame(t, canvas, sub): sub = true draws the exact time t between frames (motion blur sub-frames, styles on 1s)
function renderFrame(t, canvas, sub) {
  const cv = canvas || document.getElementById('c');
  ctx = cv.getContext('2d');
  F = ((Math.floor(sub ? t * FPS + 1e-6 : Math.round(t * FPS)) % NFRAMES) + NFRAMES) % NFRAMES;
  B = Math.floor(F / 2);
  TT = (STYLE.ones ? (sub ? F + (t * FPS - Math.floor(t * FPS + 1e-6)) : F) : B * 2) / FPS;
  // the era is picked by the real frame, so a cut on an odd frame (a 16th at 24fps) lands on that frame;
  // on its first frame the new era is drawn at its own start time (motion stays on 2s inside it)
  const s = shotAtFrame(F), e = Math.max(0, eraAt(F / FPS));
  E0 = ERA_LIST[e][0];
  if (TT < E0 - 1e-6) TT = E0;
  ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over'; ctx.setLineDash([]); ctx.filter = 'none';
  SPARK_AT = null;
  const bd = (r) => r.d ?? 0.6, bm = (r) => r.m ?? (r.d != null ? r.d / 3 : 0.2);
  const tr = BRIDGES.find((r) => TT >= r.tc - bd(r) - 1e-6 && TT < r.tc + bd(r) - 1e-6);
  if (!tr) {
    const q = renderEra(e, layer(0)); ctx.drawImage(layer(0), 0, 0); flush(q, e);
  } else {
    const ea = eraAt(tr.tc - 0.01), eb = eraAt(tr.tc + 0.01);
    const sa = shapeOf(tr.A()), sb = shapeOf(tr.B());
    const PA = camPts(sa.P, camAt(ea, TT)), PB = camPts(sb.P, camAt(eb, TT));
    const D = bd(tr), M = bm(tr);
    if (TT < tr.tc - M) {
      const u = EZ.i2(seg(TT, tr.tc - D, tr.tc - M)), { c, kmax } = maskK(PA), k = Math.exp(lerp(Math.log(kmax), 0, u));
      STYLE.backdrop(mixHex(ERA_BG[ea], STYLE.paper, 0.5 + u * 0.5), { eraA: ea, eraB: eb, u, phase: 'shrink' });
      const q = renderEra(ea, layer(0));
      STYLE.window(scaled(PA, c, k), 'mA' + tr.tc, layer(0), { c: sa.c, u, phase: 'shrink', eraA: ea, eraB: eb });
      flush(q, ea);
    } else if (TT < tr.tc + M) {
      const u = EZ.io(seg(TT, tr.tc - M, tr.tc + M));
      STYLE.backdrop(STYLE.paper, { eraA: ea, eraB: eb, u, phase: 'morph' });
      if (sa.spark && sb.spark) {
        const ca = camAt(ea, TT), cb = camAt(eb, TT), [ax, ay] = camPts([sa.spark.slice(0, 2)], ca)[0], [bx, by] = camPts([sb.spark.slice(0, 2)], cb)[0];
        STYLE.hero(lerp(ax, bx, u), lerp(ay, by, u), lerp(sa.spark[2] * ca.z, sb.spark[2] * cb.z, u), { rays: Math.round(lerp(sa.spark[3], sb.spark[3], u)), mood: 'wow', key: 'morphspark' });
      } else {
        const A2 = byAngle(PA), B2 = byAngle(PB), M = A2.P.map((p, i) => [lerp(p[0], B2.P[i][0], u), lerp(p[1], B2.P[i][1], u)]);
        STYLE.blob(M, mixHex(sa.c, sb.c, u), 'morph' + tr.tc, u, { eraA: ea, eraB: eb, phase: 'morph' });
        if (sb.spark && u > 0.6) STYLE.hero(...camPts([sb.spark.slice(0, 2)], camAt(eb, TT))[0], sb.spark[2] * clamp((u - 0.6) / 0.4), { rays: sb.spark[3], mood: 'wow', key: 'morphspark2' });
      }
    } else {
      const u = EZ.o2(seg(TT, tr.tc + M, tr.tc + D)), { c, kmax } = maskK(PB), k = Math.exp(lerp(0, Math.log(kmax), u));
      STYLE.backdrop(mixHex(STYLE.paper, ERA_BG[eb], u * 0.5), { eraA: ea, eraB: eb, u, phase: 'grow' });
      const q = renderEra(eb, layer(0));
      STYLE.window(scaled(PB, c, k), 'mB' + tr.tc, layer(0), { c: sb.c, u, phase: 'grow', eraA: ea, eraB: eb });
      flush(q, eb);
    }
  }
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  if (STYLE.post) STYLE.post();
  ctx.restore();
  return s.id;
}
window.renderFrame = renderFrame;
window.anchorAt = () => (SPARK_AT ? { x: SPARK_AT[0], y: SPARK_AT[1] } : null);
```

## File: kit/score-head.js
size: 11474
sha: 5853ce94ff0230ab401ce48362a3bbb2ce545293
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/kit/score-head.js

```text
// =====================================================================
//  kit/score-head.js — live preview (click to hear), noise/impulse/WAV helpers, and the synth.
//  buildScore() opens here; the piece's score body follows (src/score.js), then kit/score-tail.js closes it.
//  Helpers inside buildScore: pluck, noiseHit, sub, sweep, riser, chime, bass, blip, pad, drone, out, osc, env.
//  Set `to = 'm'` (music bus) or `to = 's'` (sfx bus) before calling them. Notes: nz('D4').
// =====================================================================
// live preview: rAF on the 24fps grid; click to hear the score (?t=1.5 freezes a frame)
const QS = new URLSearchParams(location.search);
const LIVE = { audio: null, t0: 0, start: performance.now() };
if (!QS.has('export')) {
  const cv = document.getElementById('c');
  if (QS.has('t')) renderFrame(parseFloat(QS.get('t')));
  else {
    const tick = () => {
      const sec = LIVE.audio ? LIVE.audio.currentTime - LIVE.t0 : (performance.now() - LIVE.start) / 1000;
      const t = ((sec % LOOP_T) + LOOP_T) % LOOP_T;
      renderFrame(Math.floor(t * FPS) / FPS);
      requestAnimationFrame(tick);
    };
    requestAnimationFrame(tick);
  }
  cv.addEventListener('click', async () => {
    if (LIVE.audio || typeof buildScore !== 'function') return;
    const ac = new AudioContext(), score = await buildScore(ac.sampleRate);
    const buf = ac.createBuffer(2, score.left.length, score.sampleRate);
    buf.copyToChannel(score.left, 0); buf.copyToChannel(score.right, 1);
    const src = ac.createBufferSource(); src.buffer = buf; src.loop = true; src.connect(ac.destination);
    LIVE.t0 = ac.currentTime + 0.05; src.start(LIVE.t0); LIVE.audio = ac;
  });
}

const HZ = {
  A1: 55.0, D2: 73.42, F2: 87.31, A2: 110.0, D3: 146.83, F3: 174.61, A3: 220.0, C4: 261.63, D4: 293.66, E4: 329.63, F4: 349.23,
  Fs4: 369.99, A4: 440.0, B4: 493.88, C5: 523.25, D5: 587.33, E5: 659.25, Fs5: 739.99, A5: 880.0, D6: 1174.66, A6: 1760.0,
};
function makeNoise(ac, sec) {
  const b = ac.createBuffer(1, Math.floor(sec * ac.sampleRate), ac.sampleRate), d = b.getChannelData(0), R = RNG('noise');
  for (let i = 0; i < d.length; i++) d[i] = R.f() * 2 - 1;
  return b;
}
function makeImpulse(ac, sec, decay) {
  const n = Math.floor(sec * ac.sampleRate), b = ac.createBuffer(2, n, ac.sampleRate);
  for (let c = 0; c < 2; c++) { const d = b.getChannelData(c), R = RNG('ir', c); for (let i = 0; i < n; i++) d[i] = (R.f() * 2 - 1) * Math.pow(1 - i / n, decay); }
  return b;
}
function encodeWav(left, right, sr) {
  const n = left.length, buf = new ArrayBuffer(44 + n * 4), v = new DataView(buf);
  const ws = (o, s) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); };
  ws(0, 'RIFF'); v.setUint32(4, 36 + n * 4, true); ws(8, 'WAVE'); ws(12, 'fmt ');
  v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 2, true); v.setUint32(24, sr, true);
  v.setUint32(28, sr * 4, true); v.setUint16(32, 4, true); v.setUint16(34, 16, true);
  ws(36, 'da' + 'ta'); v.setUint32(40, n * 4, true);
  let o = 44;
  for (let i = 0; i < n; i++) { v.setInt16(o, Math.round(clamp(left[i], -1, 1) * 32767), true); v.setInt16(o + 2, Math.round(clamp(right[i], -1, 1) * 32767), true); o += 4; }
  return new Uint8Array(buf);
}

const MIDI = (m) => 440 * 2 ** ((m - 69) / 12);
const NOTE = { D2: 38, A2: 45, D3: 50, E3: 52, Fs3: 54, A3: 57, D4: 62, E4: 64, Fs4: 66, G4: 67, A4: 69, B4: 71, Cs5: 73, D5: 74, E5: 76, Fs5: 78, A5: 81, B5: 83, D6: 86, E6: 88, Fs6: 90, A6: 93 };
// any note name: 'D4', 'Fs4' / 'F#4' (sharp), 'Bb3' (flat); the table above is the fast path
function noteNum(n) {
  if (NOTE[n] != null) return NOTE[n];
  const m = /^([A-G])(s|#|b)?(-?\d)$/.exec(n); if (!m) throw new Error(`unknown note "${n}"`);
  return 12 * (Number(m[3]) + 1) + { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 }[m[1]] + (m[2] === 'b' ? -1 : m[2] ? 1 : 0);
}
const nz = (n) => MIDI(noteNum(n));
async function buildScore(sampleRate = 48000, part = 'mix', normIn = null) {
  const TAIL = 3.0, len = Math.ceil((LOOP_T + TAIL) * sampleRate);
  const ac = new OfflineAudioContext(2, len, sampleRate);
  const master = ac.createGain(); master.gain.value = 0.8;
  const comp = ac.createDynamicsCompressor();
  comp.threshold.value = -16; comp.knee.value = 10; comp.ratio.value = 3.5; comp.attack.value = 0.004; comp.release.value = 0.2;
  master.connect(comp); comp.connect(ac.destination);
  const musicBus = ac.createGain(), sfxBus = ac.createGain();
  musicBus.gain.value = part === 'sfx' ? 0 : 1; sfxBus.gain.value = part === 'music' ? 0 : 1;
  musicBus.connect(master); sfxBus.connect(master);
  // ducking + presence dip, driven by the narration (lines closer than 0.29s are merged)
  const duck = ac.createGain(), dip = ac.createBiquadFilter();
  dip.type = 'peaking'; dip.frequency.value = 2500; dip.Q.value = 1;
  duck.connect(dip); dip.connect(musicBus);
  const lines = [];
  for (const n of TIMELINE.narration) { const L = lines[lines.length - 1]; if (L && n.t0 - L.t1 < 0.29) L.t1 = n.t1; else lines.push({ t0: n.t0, t1: n.t1 }); }
  const DUCK = 10 ** (-3 / 20);
  duck.gain.setValueAtTime(1, 0); dip.gain.setValueAtTime(0, 0);
  for (const L of lines) {
    duck.gain.setValueAtTime(1, Math.max(0, L.t0 - 0.1)); duck.gain.linearRampToValueAtTime(DUCK, L.t0);
    duck.gain.setValueAtTime(DUCK, L.t1 + 0.03); duck.gain.linearRampToValueAtTime(1, L.t1 + 0.18);
    dip.gain.setValueAtTime(0, Math.max(0, L.t0 - 0.1)); dip.gain.linearRampToValueAtTime(-6, L.t0);
    dip.gain.setValueAtTime(-6, L.t1 + 0.03); dip.gain.linearRampToValueAtTime(0, L.t1 + 0.18);
  }
  const mkVerb = (dest, sec = 1.6, decay = 2.8, level = 0.3) => { const v = ac.createConvolver(); v.buffer = makeImpulse(ac, sec, decay); const g = ac.createGain(); g.gain.value = level; v.connect(g); g.connect(dest); return v; };
  const verbs = { m: mkVerb(duck), s: mkVerb(sfxBus) }, shortVerb = verbs.m, longVerb = mkVerb(duck, 3.6, 1.6, 0.55);
  const noise = makeNoise(ac, 3.0);
  let to = 'm';   // which bus the helpers below write to

  // the gain is silent until the envelope starts (a GainNode defaults to 1: a note whose oscillator starts first would click)
  const env = (g, t, a, peak, d) => { g.gain.value = 0.0001; g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), t + a); g.gain.exponentialRampToValueAtTime(0.0001, t + a + d); };
  const out = (node, pan = 0, send = 0) => {
    const p = ac.createStereoPanner(); p.pan.value = clamp(pan, -1, 1); node.connect(p); p.connect(to === 'm' ? duck : sfxBus);
    if (send) { const s = ac.createGain(); s.gain.value = send; node.connect(s); s.connect(verbs[to]); }
  };
  const osc = (type, f, t, stop) => { const o = ac.createOscillator(); o.type = type; o.frequency.setValueAtTime(f, t); o.start(t); o.stop(stop); return o; };
  function pluck(t, f, vel, pan = 0, dur = 0.32, bright = 3200, send = 0.25) {
    const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.Q.value = 2.5;
    lp.frequency.setValueAtTime(bright, t); lp.frequency.exponentialRampToValueAtTime(380, t + dur);
    const g = ac.createGain(); env(g, t, 0.003, vel, dur);
    const g2 = ac.createGain(); g2.gain.value = 0.3;
    osc('triangle', f, t, t + dur + 0.05).connect(lp);
    osc('sine', f * 2.004, t, t + dur + 0.05).connect(g2); g2.connect(lp);
    lp.connect(g); out(g, pan, send);
  }
  function noiseHit(t, dur, type, freq, q, vel, pan = 0, send = 0, sweepTo = 0) {
    const src = ac.createBufferSource(); src.buffer = noise;
    const f = ac.createBiquadFilter(); f.type = type; f.Q.value = q; f.frequency.setValueAtTime(freq, t);
    if (sweepTo) f.frequency.exponentialRampToValueAtTime(sweepTo, t + dur);
    const g = ac.createGain(); env(g, t, Math.min(0.004, dur * 0.3), vel, dur);
    src.connect(f); f.connect(g); out(g, pan, send);
    src.start(t, (t * 7.31) % 2.0); src.stop(t + dur + 0.05);
  }
  function sub(t, vel) {
    const o = osc('sine', 120, t, t + 0.8); o.frequency.exponentialRampToValueAtTime(36, t + 0.3);
    const g = ac.createGain(); env(g, t, 0.004, vel, 0.6); o.connect(g); out(g);
    noiseHit(t, 0.07, 'lowpass', 1100, 0.7, vel * 0.45);
  }
  function sweep(t0, t1, vel, f0, f1, pan = 0) {
    const src = ac.createBufferSource(); src.buffer = noise; src.loop = true;
    const f = ac.createBiquadFilter(); f.type = 'bandpass'; f.Q.value = 1.1;
    f.frequency.setValueAtTime(f0, t0); f.frequency.exponentialRampToValueAtTime(f1, t1);
    const g = ac.createGain(); g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(vel, t1 - 0.03); g.gain.linearRampToValueAtTime(0.0001, t1 + 0.005);
    src.connect(f); f.connect(g); out(g, pan, 0.2); src.start(t0, (t0 * 3.1) % 1.0); src.stop(t1 + 0.05);
  }
  function riser(t0, t1, vel) {
    const o = osc('sawtooth', 110, t0, t1 + 0.02); o.frequency.exponentialRampToValueAtTime(1320, t1);
    const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.Q.value = 6; lp.frequency.setValueAtTime(300, t0); lp.frequency.exponentialRampToValueAtTime(5000, t1);
    const g = ac.createGain(); g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(vel, t1 - 0.01); g.gain.linearRampToValueAtTime(0.0001, t1 + 0.01);
    o.connect(lp); lp.connect(g); out(g, 0, 0.3);
    sweep(t0, t1, vel * 0.9, 400, 7000);
  }
  function chime(t, freqs, vel, pan = 0) {
    freqs.forEach((f, i) => { const g = ac.createGain(), ti = t + i * 0.012; env(g, ti, 0.002, vel / (1 + i * 0.4), 1.3); osc('sine', f, ti, ti + 1.5).connect(g); out(g, pan + (i - 1) * 0.25, 0.6); });
  }
  function bass(t, f, vel) { const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 320; const g = ac.createGain(); env(g, t, 0.006, vel, 0.28); osc('triangle', f, t, t + 0.35).connect(lp); lp.connect(g); out(g); }
  function blip(t, f0, f1, dur, vel, pan = 0) { const o = osc('sine', f0, t, t + dur + 0.05); o.frequency.exponentialRampToValueAtTime(f1, t + dur); const g = ac.createGain(); env(g, t, 0.002, vel, dur); o.connect(g); out(g, pan, 0.2); }

  // ---- v2 instruments built from the helpers above
  function pad(t0, t1, notes, peak, o = {}) {
    const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = o.cut ?? 1200; lp.Q.value = 0.7;
    const g = ac.createGain(), hold = o.swellTo ?? peak;
    g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(peak, t0 + (o.att ?? 0.4));
    if (o.swellTo) g.gain.linearRampToValueAtTime(o.swellTo, o.swellAt);
    g.gain.setValueAtTime(hold, t1 - (o.rel ?? 0.3)); g.gain.exponentialRampToValueAtTime(0.0001, t1);
    notes.forEach((n, i) => { for (const det of [-7, 7]) { const oo = osc(o.type ?? 'sawtooth', nz(n), t0, t1 + 0.05); oo.detune.value = det + i; oo.connect(lp); } });
    lp.connect(g); out(g, o.pan ?? 0, o.send ?? 0.4);
  }
  function drone(t0, t1, f, peak, o = {}) {
    const g = ac.createGain(); g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(peak, t0 + (o.att ?? 0.5));
    g.gain.setValueAtTime(peak, t1 - (o.rel ?? 0.4)); g.gain.exponentialRampToValueAtTime(0.0001, t1);
    osc('sine', f, t0, t1 + 0.05).connect(g); out(g, 0, 0);
  }
  const claudeMotif = (t, notes, vel) => notes.forEach(([n, dt], i) => pluck(t + dt, nz(n), vel, -0.15 + i * 0.1, 0.6, 1300, 0.35));
  const childMotif = (t, notes, vel, bright = 5200, send = 0.3) => notes.forEach(([n, dt], i) => pluck(t + dt, nz(n), vel, 0.2 - i * 0.1, 0.32, bright, send));
```

## File: kit/score-tail.js
size: 2366
sha: 25cffc96455061283d031f002fe57b6bcbd3127a
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/kit/score-tail.js

```text
  // =====================================================================
  //  kit/score-tail.js — render, fold the tail over the loop point, loudness stage
  //  (RMS-normalise to -17 dBFS, look-ahead limiter at -1 dBFS; stems share the mix gain curve)
  // =====================================================================
  const buf = await ac.startRendering();
  // fold the tail over the loop point so the loop is seamless
  const N = Math.round(LOOP_T * sampleRate), L = new Float32Array(N), Rr = new Float32Array(N);
  const c0 = buf.getChannelData(0), c1 = buf.getChannelData(1);
  for (let i = 0; i < N; i++) { L[i] = c0[i] + (i + N < len ? c0[i + N] : 0); Rr[i] = c1[i] + (i + N < len ? c1[i + N] : 0); }
  // loudness for a phone (subagents-short): RMS-normalise to -17 dBFS, then a look-ahead limiter at -1 dBFS.
  // Stems reuse the mix's gain and gain curve, so they sum back to the mix.
  let k = normIn?.k, g = normIn?.g;
  if (!g) {
    let sum = 0; for (let i = 0; i < N; i++) sum += (L[i] * L[i] + Rr[i] * Rr[i]) / 2;
    k = 10 ** (-17 / 20) / Math.sqrt(sum / N || 1e-12);
    const ceil = 0.89; g = new Float32Array(N);
    for (let i = 0; i < N; i++) { const p = Math.max(Math.abs(L[i]), Math.abs(Rr[i])) * k; g[i] = p > ceil ? ceil / p : 1; }
    const att = Math.exp(-1 / (0.0015 * sampleRate)), rel = Math.exp(-1 / (0.09 * sampleRate));
    for (let i = N - 2; i >= 0; i--) g[i] = Math.min(g[i], 1 - (1 - g[i + 1]) * att);
    let r = 1; for (let i = 0; i < N; i++) { r = Math.min(g[i], 1 - (1 - r) * rel); g[i] = r; }
  }
  for (let i = 0; i < N; i++) { L[i] *= k * g[i]; Rr[i] *= k * g[i]; }
  return { left: L, right: Rr, sampleRate, norm: { k, g } };
}
function wavB64(s) {
  const bytes = encodeWav(s.left, s.right, s.sampleRate);
  let bin = '';
  for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
  return btoa(bin);
}
async function renderAudioWav() { return wavB64(await buildScore(48000, 'mix')); }
async function renderAudioStems() {
  const mix = await buildScore(48000, 'mix');
  const music = await buildScore(48000, 'music', mix.norm), sfx = await buildScore(48000, 'sfx', mix.norm);
  return { music: wavB64(music), sfx: wavB64(sfx) };
}
window.renderAudioWav = renderAudioWav;
window.renderAudioStems = renderAudioStems;
</script>
</body>
</html>
```

## File: LICENSE
size: 1070
sha: 038915c8de2ebc21242df6598deb0eec25f98793
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/LICENSE

```text
MIT License

Copyright (c) 2026 Haider Farooq

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```

## File: NOTICE.md
size: 1717
sha: 496fecacdd2dd92423d106775f39b95561129c61
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/NOTICE.md

````text
# Notices

`kit/` (core.js, morph.js, board.js, score-head.js, score-tail.js) and `tools/` (build, still, tile, textcheck, export, gpu, voice, storyboard) are vendored from **animate** by cth9191 (https://github.com/cth9191/animate), MIT License. `tools/voice.mjs` was changed to skip faster-whisper unless `REEL_FASTER_WHISPER=1` is set.

New in paper-reel: `styles/paper-reel/kit.js`, `tools/kokoro_lines.py`, `tools/align.mjs`, `bin/reel`, `templates/starter`, `examples/khan-skill`.

Third-party runtime pieces, installed separately: Kokoro (kokoro-onnx, Apache-2.0), whisper.cpp (MIT), Playwright (Apache-2.0), ffmpeg.

## animate licence

```
MIT License

Copyright (c) 2026 cth9191

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
````

## File: package-lock.json
size: 1633
sha: 5016b263a210688fd3bea79861f78de0d90cbaad
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/package-lock.json

```text
{
  "name": "paper-reel-skill",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "paper-reel-skill",
      "dependencies": {
        "playwright": "1.58.0"
      }
    },
    "node_modules/fsevents": {
      "version": "2.3.2",
      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
      "hasInstallScript": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
      }
    },
    "node_modules/playwright": {
      "version": "1.58.0",
      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
      "integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
      "license": "Apache-2.0",
      "dependencies": {
        "playwright-core": "1.58.0"
      },
      "bin": {
        "playwright": "cli.js"
      },
      "engines": {
        "node": ">=18"
      },
      "optionalDependencies": {
        "fsevents": "2.3.2"
      }
    },
    "node_modules/playwright-core": {
      "version": "1.58.0",
      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
      "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
      "license": "Apache-2.0",
      "bin": {
        "playwright-core": "cli.js"
      },
      "engines": {
        "node": ">=18"
      }
    }
  }
}
```

## File: package.json
size: 145
sha: a67f78a939f3fe25711337498fb16325c29c5aa9
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/package.json

```text
{ "name": "paper-reel-skill", "private": true, "description": "paper-reel Claude Code skill tools", "dependencies": { "playwright": "1.58.0" } }
```

## File: styles/paper-reel/kit.js
size: 29315
sha: cbc4b60d1122553505ebe5630b2c74333266e71c
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/styles/paper-reel/kit.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/styles/paper-reel/kit.js

## File: templates/starter/piece.json
size: 376
sha: 30edb10d8aecba525ce636f9142c612ed0bf50f6
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/templates/starter/piece.json

```text
{
  "name": "starter",
  "title": "paper-reel starter",
  "format": { "width": 1080, "height": 1920, "fps": 30 },
  "storyFormat": "voice-cut list: hook -> how -> proof -> catch -> 'and get this' -> comment CTA",
  "style": "paper-reel",
  "voice": { "file": "voice/voice.wav", "music": -13 },
  "review": { "safe": { "top": 120, "bottom": 300, "right": 120 }, "refs": {} }
}
```

## File: templates/starter/src/bridges.js
size: 154
sha: c3a9c137cdf93fe75218354f55f8fb6d519f67ec
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/templates/starter/src/bridges.js

```text
// hard cuts only (one scene per line); no cameras
const ERA_BG = ERA_LIST.map(() => '#869997');
function pieceCam() { return null; }
const BRIDGES = [];
```

## File: templates/starter/src/head.html
size: 1751
sha: 4c5ec490a42aa8e72da5b4e36c122eb4a4cd46b6
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/templates/starter/src/head.html

```text
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>paper-reel piece</title>
<style>
  html, body { margin: 0; height: 100%; background: #1b1714; overflow: hidden; }
  body { display: flex; align-items: center; justify-content: center; }
  canvas { height: min(100vh, calc(100vw * 16 / 9)); width: auto; aspect-ratio: 9 / 16; display: block; cursor: pointer; }
</style>
</head>
<body>
<canvas id="c" width="1080" height="1920"></canvas>
<script>
"use strict";
// =====================================================================
//  PAPER-REEL PIECE — 9:16, 30fps. The voice is the clock: VOICE (voice.json) is injected by build.mjs.
//  One scene per spoken line (SCENES[<line id>] in src/scenes.js); SPLITS add a second cut inside a line on a word.
//  The shot list and TIMELINE are assembled at the end of src/scenes.js.
// =====================================================================
const FPS = 30, W = 1080, H = 1920;
const DURATION = VOICE.duration;
const NFRAMES = Math.round(DURATION * FPS);
const LOOP_T = NFRAMES / FPS;
const SAFE = { top: 120, bottom: 300, right: 120 };
const HAND = '"Chalkboard SE", "Comic Sans MS", cursive';
const CX = 540;
const VL = (id) => VOICE.lines.find((l) => l.id === id);
// VW(id, word, n): the n-th (0-based) occurrence of `word` in line `id` -> { t0, t1 } (falls back to the line)
const VW = (id, word, n = 0) => { const L = VL(id), k = word.toLowerCase(); const hits = L.words.filter((w) => w.w.toLowerCase().replace(/[^a-z0-9']/g, '') === k); return hits[n] || hits[0] || { t0: L.t0, t1: L.t1 }; };
const CUT = (id) => Math.max(0, VL(id).t0 - 0.1);   // a line's scene starts just before it is spoken
```

## File: templates/starter/src/scenes.js
size: 8188
sha: 5fa6ea6fa0cf6511efc153e1792eee15815dbb9b
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/templates/starter/src/scenes.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/templates/starter/src/scenes.js

## File: templates/starter/src/score.js
size: 1652
sha: 4e94708cc039341f693d3e3f05093cd24adcbeba
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/templates/starter/src/score.js

```text
  // SCORE BODY — a light lo-fi bed (ducked under the voice by score-head) + sound effects tied to the picture.
  const BPMs = 100, BT = 60 / BPMs, END = DURATION;
  const prog = [['D3', 'Fs3', 'A3'], ['B2', 'D3', 'Fs3'], ['G2', 'B2', 'D3'], ['A2', 'Cs3', 'E3']];
  const oct = (n, d) => n.replace(/-?\d/, (x) => String(+x + d));
  to = 'm';
  for (let bar = 0; bar * 4 * BT < END; bar++) {
    const t = bar * 4 * BT, ch = prog[bar % 4];
    pad(t, Math.min(END + 0.2, t + 4 * BT + 0.05), ch, 0.022, { type: 'triangle', cut: 1100, att: 0.08, rel: 0.12, send: 0.3 });
    bass(t, nz(oct(ch[0], -1)), 0.22); bass(t + 2 * BT, nz(oct(ch[0], -1)), 0.16);
    for (let k = 0; k < 8; k++) { const tk = t + k * BT / 2; if (tk > END) break; pluck(tk, nz(oct(ch[k % 3], 2)), k % 2 ? 0.045 : 0.07, 0, 0.22, 2600, 0.15); }
    for (let b = 0; b < 4; b++) { const tb = t + b * BT; if (tb > END) break; if (b % 2 === 0) sub(tb, 0.16); noiseHit(tb + BT / 2, 0.035, 'highpass', 7000, 0.7, 0.05); }
  }
  to = 's';
  for (const [t0] of SHOT_LIST.slice(1)) sweep(t0 - 0.16, t0, 0.02, 900, 4200, 0);      // whoosh into every cut
  for (const e of SFX_CUES()) {                                                         // cues from src/scenes.js
    if (e.k === 'type') for (let i = 0; i < e.n; i++) noiseHit(e.t + e.d * i / e.n, 0.018, 'bandpass', 3800 + (i % 3) * 600, 2, 0.06);
    if (e.k === 'tick') blip(e.t, 1300, 1700, 0.05, 0.07);
    if (e.k === 'pop') blip(e.t, 700, 1400, 0.1, 0.09);
    if (e.k === 'thud') { sub(e.t, 0.35); noiseHit(e.t, 0.09, 'lowpass', 1600, 0.8, 0.22); }
    if (e.k === 'chime') chime(e.t, [nz('D5'), nz('Fs5'), nz('A5')], 0.09);
  }
```

## File: templates/starter/voice/script.json
size: 658
sha: 6da94dbd3b79b3be2ee7e6c30d833e48fbf762d6
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/templates/starter/voice/script.json

```text
{ "lead": 0.15, "tail": 0.6, "lines": [
  { "id": "hook",   "text": "This Claude skill turns any topic into a paper style reel, for free.", "after": 0.12 },
  { "id": "how",    "text": "You write six lines, and it builds a scene for every one.", "after": 0.12 },
  { "id": "read",  "text": "A local voice reads them, and every word lands on screen.", "after": 0.12 },
  { "id": "catch",  "text": "The catch: it's a template, so the jokes are still on you.", "after": 0.12 },
  { "id": "payoff", "text": "And get this. No API keys, no credits, nothing to pay.", "after": 0.12 },
  { "id": "cta",    "text": "For the skill, comment PAPER.", "after": 0.1 }
] }
```

## File: tools/align.mjs
size: 2721
sha: 97db67a2bbd1945e1cab9883799fe7b7b7d27fd1
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/tools/align.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/tools/align.mjs

## File: tools/build.mjs
size: 7751
sha: 375644f79d4480029c9a282028c950fc4b1fdd13
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/tools/build.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/tools/build.mjs

## File: tools/export.mjs
size: 15576
sha: 1338167bd20a0253b2f18c8629d2b33dcbd7720c
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/tools/export.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/tools/export.mjs

## File: tools/gpu.mjs
size: 1531
sha: a1a46539a8a789665013c2f27c2d11f71cd58c0b
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/tools/gpu.mjs

```text
// GPU launch flags per platform, and a check that WebGL really runs on a GPU.
// Headless Chromium falls back to software rendering (SwiftShader / llvmpipe) when it can't reach the GPU:
// the frames are still correct, just many times slower. WebGL pieces set TIMELINE.gpu (or pass --gpu).
export const GPU_ARGS = process.platform === 'win32' ? ['--use-angle=d3d11', '--enable-gpu', '--ignore-gpu-blocklist']
  : process.platform === 'darwin' ? ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist']
  : ['--use-angle=vulkan', '--enable-features=Vulkan', '--enable-gpu', '--ignore-gpu-blocklist'];

export async function gpuReport(page) {
  const r = await page.evaluate(() => {
    const gl = document.createElement('canvas').getContext('webgl2') || document.createElement('canvas').getContext('webgl');
    if (!gl) return { ok: false, renderer: 'no WebGL' };
    const ext = gl.getExtension('WEBGL_debug_renderer_info');
    const renderer = String(ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER));
    return { ok: !/swiftshader|llvmpipe|software|basic render/i.test(renderer), renderer };
  });
  if (r.ok) console.log(`gpu: ${r.renderer}`);
  else console.warn(`WARNING: no GPU for WebGL (${r.renderer}). Rendering falls back to software: correct but much slower. ` +
    (process.platform === 'win32' ? 'Check the graphics driver.' : 'GPU flags are best on Windows (ANGLE/D3D11); on macOS/Linux expect software rendering in headless mode, or run with a display.'));
  return r;
}
```

## File: tools/kokoro_lines.py
size: 1881
sha: 9bb29a1e460d2b09b6b1cb707272d185a56f60d9
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/tools/kokoro_lines.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/tools/kokoro_lines.py

## File: tools/still.mjs
size: 2592
sha: a76634e2fbcea8d99d7e7f756288f7fcc390d9a4
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/tools/still.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/tools/still.mjs

## File: tools/storyboard.mjs
size: 2191
sha: 5d51d82a3ebaffb4ad9cdb681be74ef5d4283435
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/tools/storyboard.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/tools/storyboard.mjs

## File: tools/textcheck.mjs
size: 8698
sha: 84c8e9e67b83c7246c945f3a8058057512010c00
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/tools/textcheck.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/tools/textcheck.mjs

## File: tools/tile.mjs
size: 3503
sha: f97dd87b278da4734ea19b738b54e43d5990e5a0
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/tools/tile.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/tools/tile.mjs

## File: tools/voice.mjs
size: 7939
sha: c7842f09af62adf0b4a73970eb3a0ac20652cefb
upstream: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/tools/voice.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/haiderfarooq3/paper-reel/blob/HEAD/tools/voice.mjs
