⏳ This skill is pending AI review.
Scores will appear once the review pipeline completes.
paper-reel
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.
Choose how to use this skill
You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.
1. Native installer
This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.
Do not guess an installer command or replace an existing version without reviewing the diff.
2. Complete package recommended
Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.
No complete ProSkills package is published for this listing yet.3. Prompt-only
Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.
Need only the instruction file?
Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.
No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.
// RATINGS
// README
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.

Watch the example reel (23 s, made with this skill, about the khan-explainer skill).
Write-up: paper-reel: a free Claude Code skill that draws cut-paper explainer reels from one prompt. How the skill explains itself, on YouTube: wide cut and Short.
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.
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.
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:
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 is the full reference: the script arc, the scene rules, the kit API and the gotchas. references/style.md has the palette and layout grid.
Credits
Made by Haider Farooq. MIT licensed.
The renderer, frame tools and kit core are vendored from animate by cth9191 (MIT). See NOTICE.md.
Not affiliated with or endorsed by Anthropic. Claude is a trademark of Anthropic.
// HOW IT'S BUILT
// FILES
50 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.
SKILL.md8.0 KB71689586b49dupstream ↗
--- 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.skills/paper-reel/SKILL.md8.0 KB71689586b49dupstream ↗
--- 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.README.md3.6 KB924382bf2432upstream ↗
# 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.  **[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.references/style.md2.4 KB87fc1b5b6ca6upstream ↗
# 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..github/workflows/render.yml831 B5e08dd6099f4upstream ↗
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/.gitignore87 Bb05c3fc7f58dupstream ↗
node_modules/ .DS_Store frames*/ renders/ examples/*/index.html templates/*/index.htmlbin/reel5.0 KB4face0f43056upstream ↗
#!/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" ;; esacdocs/demo.mp42.1 MBfd82c19ea58aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
docs/strip.jpg474 KBd0b55370b89fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
examples/khan-skill/piece.json419 B335e5afef8e3upstream ↗
{ "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": {} } }examples/khan-skill/src/bridges.js161 B33528560f8baupstream ↗
// hard cuts only: one scene per line, like the reference const ERA_BG = ERA_LIST.map(() => '#869997'); function pieceCam() { return null; } const BRIDGES = [];examples/khan-skill/src/head.html3.4 KB41b94fcf4d70upstream ↗
<!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;examples/khan-skill/src/scenes.js21 KBd41b7c3e010eupstream ↗
// ===================================================================== // 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(); }examples/khan-skill/src/score.js2.4 KB69eea5503668upstream ↗
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);examples/khan-skill/voice.json6.6 KB382e6da3795bupstream ↗
{ "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 } ] } ] }examples/khan-skill/voice/catch.wav178 KBe4407b0b9eedupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
examples/khan-skill/voice/cta.wav82 KB40c270a4f271upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
examples/khan-skill/voice/draw.wav154 KBf3b61232a08eupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
examples/khan-skill/voice/free.wav166 KBfa01b3bb1a99upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
examples/khan-skill/voice/hook.wav185 KB7ebf2534401eupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
examples/khan-skill/voice/repo.wav169 KBc3c8aae19626upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
examples/khan-skill/voice/script.json1.0 KB2b04f5d43f11upstream ↗
{ "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" } ] }examples/khan-skill/voice/sync.wav146 KB2ad59f83e7dfupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
examples/khan-skill/voice/voice.wav2.1 MB1d4de49010e7upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
kit/board.js2.5 KBe44383fef2a5upstream ↗
// ===================================================================== // 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;kit/core.js28 KB6929ea37e41bupstream ↗
// ===================================================================== // 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; }kit/morph.js9.3 KB71439c9c0afbupstream ↗
// ===================================================================== // 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);kit/score-head.js11 KB5853ce94ff02upstream ↗
// ===================================================================== // 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));kit/score-tail.js2.3 KB25cffc964550upstream ↗
// ===================================================================== // 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>LICENSE1.0 KB038915c8de2eupstream ↗
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.NOTICE.md1.7 KB496fecacdd2dupstream ↗
# 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. ```package-lock.json1.6 KB5016b263a210upstream ↗
{ "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" } } } }package.json145 Ba67f78a939f3upstream ↗
{ "name": "paper-reel-skill", "private": true, "description": "paper-reel Claude Code skill tools", "dependencies": { "playwright": "1.58.0" } }styles/paper-reel/kit.js29 KBcbc4b60d1122upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
templates/starter/piece.json376 B30edb10d8aecupstream ↗
{ "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": {} } }templates/starter/src/bridges.js154 Bc3a9c137cdf9upstream ↗
// hard cuts only (one scene per line); no cameras const ERA_BG = ERA_LIST.map(() => '#869997'); function pieceCam() { return null; } const BRIDGES = [];templates/starter/src/head.html1.7 KB4c5ec490a42aupstream ↗
<!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 spokentemplates/starter/src/scenes.js8.0 KB5fa6ea6fa0cfupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
templates/starter/src/score.js1.6 KB4e94708cc039upstream ↗
// 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); }templates/starter/voice/script.json658 B6da94dbd3b79upstream ↗
{ "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 } ] }tools/align.mjs2.7 KB97db67a2bbd1upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
tools/build.mjs7.6 KB375644f79d44upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
tools/export.mjs15 KB1338167bd20aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
tools/gpu.mjs1.5 KBa1a46539a8a7upstream ↗
// 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; }tools/kokoro_lines.py1.8 KB9bb29a1e460dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
tools/still.mjs2.5 KBa76634e2fbceupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
tools/storyboard.mjs2.1 KB5d51d82a3ebaupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
tools/textcheck.mjs8.5 KB84c8e9e67b83upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
tools/tile.mjs3.4 KBf97dd87b278dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
tools/voice.mjs7.8 KBc7842f09af62upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
Plain-text copy, no login: /skill-bundles/haiderfarooq3-paper-reel.md