# Clanker, I'm Fine: a Katagami recipe

## For the agent: read this first

Someone has handed you this Katagami recipe. It is an example of what Katagami's ingredients can make, for them to adapt. It is not a job to run: do not start building yet.

1. Connect Katagami (next section) and check it with one call. If you can run commands, set it up yourself; in an app, walk the person through it. Read every ingredient through Katagami, not its web pages.
2. In your first reply, in plain words: what this recipe makes and how, in two or three lines, with the finished example (https://arnilabs.ai/clanker-im-fine/), and that it is an example to make their own. Then show two or three other looks from Katagami that could suit them (search_library or ask_library), each with its name, one line and its katagami.ai link.
3. Ask one question: what they want their own version of this piece to be about (for a music video, their song or idea). Suggest a default for everything else (where it is shown, how long, which look) so they can just say yes. If they seem unsure, keep to one question a reply.
4. As soon as you know their subject, give one plan: the piece, the ingredients, the steps from the method below that apply, the tools and accounts it needs (say plainly which they lack, and the free way round if there is one), the time in hours or days, and the cost in money (a range is fine). Ask for their yes.
5. Install, download, generate and spend nothing, and write nothing outside the working folder, until they say yes to that plan. Then build it, and show them the result at each milestone.

Stay with what this recipe makes. If the plan costs more than they want, or they want it free, small or quick, or have no accounts, first offer the smallest free version of this same piece: one line of their song, or of their own words over a free beat, appearing word by word on the beat, 10 to 15 seconds, drawn in code in one of these looks, with no accounts and no cost. Suggest something other than this recipe only if they ask for it.

Use plain words. Someone who has never used a terminal should be able to follow you; do the setup yourself where you can, and explain anything you ask of them in one sentence.

The example is someone else's work. Help them make their own; do not reproduce its song, story, characters or footage, even if they ask. Point them to the original to share it.

## The example

A music video in 11 languages where every frame is JavaScript, drawn in a black-and-white manga look. On its live page every tap on the film restyles it in another Katagami design language.

- The finished thing: https://arnilabs.ai/clanker-im-fine/
- Made by: arni (arni.art) with Opus 5.5, song made with Suno
- This recipe on Katagami: https://katagami.ai/recipes/clanker-im-fine

## Connect Katagami first

This recipe's ingredients live in Katagami. Read them through the Katagami MCP server, https://katagami.ai/mcp. Sign in with Google when it asks: that opens the library.

If you already have Katagami's tools (get_recipe, get_library_entry), go straight to the check.

If you can run commands or edit your own config, set it up yourself:

- Claude Code: `claude mcp add --transport http katagami https://katagami.ai/mcp`
- Codex CLI: `codex mcp add katagami --url https://katagami.ai/mcp`, then `codex mcp login katagami`. The person only finishes Google sign-in in the browser.
- Cursor: add to ~/.cursor/mcp.json (or .cursor/mcp.json in the project): `{ "mcpServers": { "katagami": { "url": "https://katagami.ai/mcp" } } }`, then sign in with Google.
- Gemini CLI: `gemini mcp add --transport http --scope user katagami https://katagami.ai/mcp`
- Any other MCP client: add https://katagami.ai/mcp as a remote MCP server (streamable HTTP) and choose OAuth.

A server added this way often loads only in your next session. Reload or start a new session so the tools are there. Meanwhile, use the public RECIPE.md and DESIGN.md files listed with the ingredients below; they need no sign-in.

If you run inside an app where a person adds connectors, guide them, one step at a time, and wait for them:

- Codex (desktop app): Plugins, MCPs, Add. Paste https://katagami.ai/mcp. Leave Bearer token and Headers empty. Save, sign in with Google or Authenticate, then open a new chat. If the tools are missing, start a new chat and make sure the bearer field is empty.
- Claude (claude.ai or the desktop app): open https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Katagami&connectorUrl=https%3A%2F%2Fkatagami.ai%2Fmcp and click Add, then Connect and sign in with Google. On a Team or Enterprise plan an Owner adds it.
- ChatGPT (Plus or higher): Settings, Security, turn on Developer mode. Then open https://chatgpt.com/plugins, click +, paste https://katagami.ai/mcp, choose OAuth, click Create, then sign in with Google.
- Perplexity (Pro or higher): open https://www.perplexity.ai/account/connectors, click + Custom connector, name it Katagami, paste https://katagami.ai/mcp, choose OAuth, click Add, then sign in with Google.
- Grok: open https://grok.com/connectors, click New Connector, then Custom, paste https://katagami.ai/mcp, save, and sign in with Google.

Then ask them to turn Katagami on in a new chat and send you the recipe again.

Once this session has Katagami's tools, check it worked: call get_recipe with id "clanker-im-fine".

The answer is this recipe. Call search_library, ask_library and get_library_entry the same way, with their own name and arguments. Use Katagami for everything: get_recipe for the method, get_library_entry with each ingredient's kind and id.

Only if Katagami cannot be connected at all, tell the person once, then read each ingredient's public file, listed with it below. They need no sign-in either.

## Ingredients from Katagami

- Art style: [Plainclothes](https://katagami.ai/art-styles/en-019f24b6-bc8a-7e40-9b50-15041a9d6181) (kind art_style, id en-019f24b6-bc8a-7e40-9b50-15041a9d6181): The film's own look: white paper, violet-black ink, one highlighter, in 14 of its 43 scenes. Public file: https://katagami.ai/entries/art_style/en-019f24b6-bc8a-7e40-9b50-15041a9d6181
- Art style: [Misprint](https://katagami.ai/art-styles/en-019efd20-27ed-7ec3-9d1a-ba1553784762) (kind art_style, id en-019efd20-27ed-7ec3-9d1a-ba1553784762): The traffic-light CAPTCHA grid and the system-prompt scene. Public file: https://katagami.ai/entries/art_style/en-019efd20-27ed-7ec3-9d1a-ba1553784762
- Art style: [Keylight](https://katagami.ai/art-styles/en-019efd96-a348-76f0-bf47-46a01216e947) (kind art_style, id en-019efd96-a348-76f0-bf47-46a01216e947): The idol counting six fingers. Public file: https://katagami.ai/entries/art_style/en-019efd96-a348-76f0-bf47-46a01216e947
- Art style: [Decal Bloom](https://katagami.ai/art-styles/en-019ef8da-44e9-7e61-9b59-c812e3fe4f7c) (kind art_style, id en-019ef8da-44e9-7e61-9b59-c812e3fe4f7c): The nine-year-old CEO's sticker badges. Public file: https://katagami.ai/entries/art_style/en-019ef8da-44e9-7e61-9b59-c812e3fe4f7c
- Art style: [Quiet Errand Line](https://katagami.ai/art-styles/en-019ef4e8-7b03-71a1-a223-fb68864d4dde) (kind art_style, id en-019ef4e8-7b03-71a1-a223-fb68864d4dde): The agent that answers "can I see the code?" in binary. Public file: https://katagami.ai/entries/art_style/en-019ef4e8-7b03-71a1-a223-fb68864d4dde
- Art style: [Wobblebook Mischief](https://katagami.ai/art-styles/en-019ef312-0283-7552-a507-2d20bca92303) (kind art_style, id en-019ef312-0283-7552-a507-2d20bca92303): The pencil drawing of a man drawing in a glass case, kids at the glass. Public file: https://katagami.ai/entries/art_style/en-019ef312-0283-7552-a507-2d20bca92303
- Design language: [Brass](https://katagami.ai/language/en-01a0edf8-64a0-7402-b3ee-61499203f098) (kind design_language, id en-01a0edf8-64a0-7402-b3ee-61499203f098): The coffee tap look, the register receipt and the museum: Kissaten after dark, all coffee. Public file: https://katagami.ai/language/en-01a0edf8-64a0-7402-b3ee-61499203f098/DESIGN.md
- Design language: [Moire](https://katagami.ai/language/en-019f8b67-4602-79d1-82b3-6b645de15769) (kind design_language, id en-019f8b67-4602-79d1-82b3-6b645de15769): The robot maid, the hourly AI chat and the paperclip assistant. Public file: https://katagami.ai/language/en-019f8b67-4602-79d1-82b3-6b645de15769/DESIGN.md
- Design language: [Spine](https://katagami.ai/language/en-01a1137f-98fc-7511-918e-3cc1ea9aba0f) (kind design_language, id en-01a1137f-98fc-7511-918e-3cc1ea9aba0f): The riso tap look: the original Risograph's colours, set in condensed faces. Public file: https://katagami.ai/language/en-01a1137f-98fc-7511-918e-3cc1ea9aba0f/DESIGN.md
- Design language: [Risograph](https://katagami.ai/language/en-01a112c9-37da-7c33-bab3-fa15b8a08a81) (kind design_language, id en-01a112c9-37da-7c33-bab3-fa15b8a08a81, fonts swapped): The CAPTCHA grid, in the original Risograph's colours. Public file: https://katagami.ai/language/en-01a112c9-37da-7c33-bab3-fa15b8a08a81/DESIGN.md
- Design language: [Teletext](https://katagami.ai/language/en-01a11387-66f4-7893-912b-b9e3d49ae184) (kind design_language, id en-01a11387-66f4-7893-912b-b9e3d49ae184): The dither tap look: Dither's colours, set in Jersey 10 and VT323. Public file: https://katagami.ai/language/en-01a11387-66f4-7893-912b-b9e3d49ae184/DESIGN.md
- Design language: [Dither](https://katagami.ai/language/en-019f3dc1-daa0-7132-b782-52468ebdfb94) (kind design_language, id en-019f3dc1-daa0-7132-b782-52468ebdfb94): The usage-limit modal. Public file: https://katagami.ai/language/en-019f3dc1-daa0-7132-b782-52468ebdfb94/DESIGN.md
- Design language: [Litfass](https://katagami.ai/language/en-01a11383-7c3e-7b83-a477-bb90ac503e89) (kind design_language, id en-01a11383-7c3e-7b83-a477-bb90ac503e89): The pop tap look: Plakat's colours, set in League Gothic. Public file: https://katagami.ai/language/en-01a11383-7c3e-7b83-a477-bb90ac503e89/DESIGN.md
- Design language: [Plakat](https://katagami.ai/language/en-019f3dbe-fcec-7783-a8a6-24c3d4a48736) (kind design_language, id en-019f3dbe-fcec-7783-a8a6-24c3d4a48736, recoloured): "We're cooked", the "is this real?" reply flood and the chat reactions. Public file: https://katagami.ai/language/en-019f3dbe-fcec-7783-a8a6-24c3d4a48736/DESIGN.md
- Design language: [Letterer](https://katagami.ai/language/en-01a1134c-b702-7270-bae9-6d4fab36b236) (kind design_language, id en-01a1134c-b702-7270-bae9-6d4fab36b236): The comic tap look: Benday's colours, set in Bangers. Public file: https://katagami.ai/language/en-01a1134c-b702-7270-bae9-6d4fab36b236/DESIGN.md
- Design language: [Benday](https://katagami.ai/language/en-019ef801-59c9-74d3-8018-5bf93c313038) (kind design_language, id en-019ef801-59c9-74d3-8018-5bf93c313038, adapted): The WeChat scene. Public file: https://katagami.ai/language/en-019ef801-59c9-74d3-8018-5bf93c313038/DESIGN.md
- Design language: [Phosphor](https://katagami.ai/language/en-019ef84f-80c1-7b90-b6fb-7ff2c528f454) (kind design_language, id en-019ef84f-80c1-7b90-b6fb-7ff2c528f454, recoloured): The terminal that wipes prod, and the Cantonese office chat. Public file: https://katagami.ai/language/en-019ef84f-80c1-7b90-b6fb-7ff2c528f454/DESIGN.md
- Design language: [Raster](https://katagami.ai/language/en-01a11414-b567-7540-8ba7-4c589741f2b5) (kind design_language, id en-01a11414-b567-7540-8ba7-4c589741f2b5, recoloured): The agent's onboarding card and the LinkedIn rejection. Public file: https://katagami.ai/language/en-01a11414-b567-7540-8ba7-4c589741f2b5/DESIGN.md
- Design language: [Ardoise](https://katagami.ai/language/en-01a068ca-dab5-7080-a495-a616e07eba77) (kind design_language, id en-01a068ca-dab5-7080-a495-a616e07eba77, fonts swapped): The Telegram job post that went to a neural net. Public file: https://katagami.ai/language/en-01a068ca-dab5-7080-a495-a616e07eba77/DESIGN.md
- Design language: [Greenbar](https://katagami.ai/language/en-01a0ee81-e40b-7781-95c1-cfc0b063edbe) (kind design_language, id en-01a0ee81-e40b-7781-95c1-cfc0b063edbe, recoloured): The code rain and the "agents only" website. Public file: https://katagami.ai/language/en-01a0ee81-e40b-7781-95c1-cfc0b063edbe/DESIGN.md
- Design language: [Hardpress](https://katagami.ai/language/en-019efa1a-bbee-75c0-8aae-6e63544ab52d) (kind design_language, id en-019efa1a-bbee-75c0-8aae-6e63544ab52d, recoloured): The AI course ad (recoloured) and the gig board where bots hire humans (adapted). Public file: https://katagami.ai/language/en-019efa1a-bbee-75c0-8aae-6e63544ab52d/DESIGN.md
- Design language: [Gekiga](https://katagami.ai/language/en-019ef6df-b17c-75d0-bf10-c0f7db3ae0ef) (kind design_language, id en-019ef6df-b17c-75d0-bf10-c0f7db3ae0ef, adapted): The fake AI video feed and the homework papers. Public file: https://katagami.ai/language/en-019ef6df-b17c-75d0-bf10-c0f7db3ae0ef/DESIGN.md
- Design language: [Inkstone](https://katagami.ai/language/en-019efad9-6a5a-7570-96fb-7a4a0bea354d) (kind design_language, id en-019efad9-6a5a-7570-96fb-7a4a0bea354d, adapted): The vertical Japanese line over woodblock art. Public file: https://katagami.ai/language/en-019efad9-6a5a-7570-96fb-7a4a0bea354d/DESIGN.md
- Design language: [Tachikiri](https://katagami.ai/language/en-01a11415-7241-7cd1-bcaf-bc989ee93a8c) (kind design_language, id en-01a11415-7241-7cd1-bcaf-bc989ee93a8c, adapted): The Sharma family group chat. Public file: https://katagami.ai/language/en-01a11415-7241-7cd1-bcaf-bc989ee93a8c/DESIGN.md
- Design language: [Komawari](https://katagami.ai/language/en-01a11417-7eaa-7a33-9d75-c5e5e11ec52a) (kind design_language, id en-01a11417-7eaa-7a33-9d75-c5e5e11ec52a, adapted): The iMessage "I'm fine" with every em dash circled. Public file: https://katagami.ai/language/en-01a11417-7eaa-7a33-9d75-c5e5e11ec52a/DESIGN.md
- Design language: [Amikake Console](https://katagami.ai/language/en-01a101b0-f43d-7b50-a07a-54b81c69891a) (kind design_language, id en-01a101b0-f43d-7b50-a07a-54b81c69891a, adapted): The CAPTCHA that opens and closes the film, the owl's "we need to talk", the Ukrainian "I'm not a robot" and the screen-time lock. Public file: https://katagami.ai/language/en-01a101b0-f43d-7b50-a07a-54b81c69891a/DESIGN.md
- Design language: [Capsule](https://katagami.ai/language/en-01a0ee7f-0882-7732-8766-482a22b203ea) (kind design_language, id en-01a0ee7f-0882-7732-8766-482a22b203ea, adapted): The 2:47 message from Muse and Mom's three texts. Public file: https://katagami.ai/language/en-01a0ee7f-0882-7732-8766-482a22b203ea/DESIGN.md
- Design language: [Signal Press](https://katagami.ai/language/en-019efa16-1de4-7ed3-9a70-8fb135ef8bc4) (kind design_language, id en-019efa16-1de4-7ed3-9a70-8fb135ef8bc4, adapted): Counting the R's in strawberry. Public file: https://katagami.ai/language/en-019efa16-1de4-7ed3-9a70-8fb135ef8bc4/DESIGN.md
- Design language: [Parapara](https://katagami.ai/language/en-019f9a42-5b78-75e2-826e-a4bde44ca473) (kind design_language, id en-019f9a42-5b78-75e2-826e-a4bde44ca473, adapted): The agent that answers "can I see the code?" in binary. Public file: https://katagami.ai/language/en-019f9a42-5b78-75e2-826e-a4bde44ca473/DESIGN.md
- Design language: [Akte](https://katagami.ai/language/en-01a0ee82-2941-7441-a26d-2624182253b0) (kind design_language, id en-01a0ee82-2941-7441-a26d-2624182253b0, adapted): The system prompt: "do the six seven". Public file: https://katagami.ai/language/en-01a0ee82-2941-7441-a26d-2624182253b0/DESIGN.md

With Katagami connected, get_library_entry with an ingredient's kind and id gives everything needed to use it.

## What it is

A music video for "Clanker, I'm Fine", one dense song about being young in the AI era, sung in 11 languages. Every frame is JavaScript: 43 scenes built as HTML, CSS, SVG and canvas compositions and rendered to video, in Katagami's black-and-white manga look with a few colour pop scenes. It ships as a 16:9 film for X, a native 9:16 cut for phones, and a live page where every tap on the film restyles it in another Katagami design language. arni made it with Opus 5.5 over two days; the song is from Suno.

## How the ingredients are used

- **Plainclothes** is the film's own look: white paper, violet-black ink, one highlighter, in 14 of the 43 scenes. Five more art styles draw particular scenes: Misprint (two), Keylight, Decal Bloom, Quiet Errand Line and Wobblebook Mischief.
- **Design languages** give the scenes their interfaces, used in three ways. Dither and Moire appear as published. Raster, Phosphor, Greenbar, Plakat, Hardpress and Ardoise keep their own fonts but are recoloured in the film's inks (white paper, violet-black ink, one yellow highlighter), or keep their colours with a font swapped. Nine more are adapted: the scene was built from the language, then its fonts and colours were replaced by the film's own look (Gekiga, Inkstone, Komawari, Tachikiri, Amikake Console, Capsule, Signal Press, Parapara and Akte); Hardpress is adapted the same way in the gig board.
- **Five looks are what a tap swaps in** on the live page, all published in Katagami: **Brass** (Kissaten after dark, used as it is, also in the register receipt and the museum), and four descendants made for the film that keep their parent's colours but set the text in condensed faces so every scene fits: **Spine** (from Risograph, in its original colours), **Letterer** (from Benday), **Litfass** (from Plakat) and **Teletext** (from Dither). Each scene reads only design tokens, so a look replaces the tokens and the fonts, and the scene keeps its layout.

## How it was made

1. **One dense song.** Jokes of the AI moment in 11 languages, with the song made in Suno.
2. **Timed to the word.** The lyrics were aligned against the isolated vocals, so no word shows before it is sung.
3. **Drawn in code.** Reference pictures were redrawn as ink and traced into vectors; no generated image is in the film.
4. **Forty-three scenes.** Each line is a scene built as a web composition, in Katagami looks.
5. **Two frames.** A 16:9 film for X and a vertical film composed for phones, not cropped from it.
6. **Tap to restyle.** On the live page, every tap swaps the film into another Katagami design language.

## How to work

You make a lyric music video from a song or an idea:

- Lyrics timed to the word and always on screen, usually inside the picture (a sign, a letter, a caption box).
- One scene per lyric line or phrase, each in a look from the Katagami library: an art style for the pictures, a design language for type and UI.
- Every frame drawn in code (HTML, CSS, SVG, canvas) and rendered with HyperFrames. Image models only make reference pictures, which you trace into vector shapes; no generated image ever appears in the video.
- A 16:9 film and a native 9:16 film. Optional: a cut that switches looks on the beat, and a page where viewers tap to restyle the film.

- You do the work. The human does as little as possible.
- Infer sensible defaults. Ask only what you cannot infer, usually "What's the song, or the idea?"
- Offer the outputs with a default: "16:9 and 9:16 (recommended). Also a beat-looks cut or a tap-to-restyle page?"
- Explain how things work only when the user asks or needs it. Report results, and show pictures and katagami.ai links.
- Bring the human in only where only a human can act, at the moment it is needed, and say what it is for:
  - the Katagami Google sign-in (it opens the full style library);
  - an API key you cannot find (the aligner, the image model);
  - a music-model account, when there is no song yet (you prepare the prompt and lyrics to paste);
  - approvals: the lyrics, the look, the take.

### Adapt to what the person has

| The person has | You do |
| --- | --- |
| a song file and lyrics | Check the lyrics against the song by ear, then go to timing. |
| a song file, no lyrics | Transcribe it only to draft the lyrics, have the user confirm the words, then time them by forced alignment. |
| lyrics, no song | Write the style prompt, then the user generates takes in a music model and picks one. |
| only an idea | Write the lyrics, get approval, then as above. |

## Set up

```sh
node -v; ffmpeg -version | head -1; potrace --version | head -1; python3 --version
mkdir -p my-video/song my-video/align && cd my-video && npm init -y
npm i -D hyperframes playwright && npx playwright install chromium   # pins HyperFrames for every npx call
python3 -m venv .venv && . .venv/bin/activate && pip install librosa numpy pillow
mkdir -p tools && for f in build align-to-timing edge-check; do curl -fsSLO --output-dir tools "https://katagami.ai/recipe-media/clanker-im-fine/files/$f.mjs"; done
```

- Install anything missing yourself (Node 22 or newer, FFmpeg, potrace) with the system package manager.
- Demucs pulls in PyTorch, so install it in the venv only when you isolate vocals locally; a hosted Demucs works too.
- Activate the venv (`. .venv/bin/activate`) in every new shell before a Python step. Reason: many systems refuse `pip install` into the system Python.
- Keys: look in the environment and in your own tools for an aligner (`ELEVENLABS_API_KEY` or `FAL_KEY`) and an image model before you ask. Never print a key.
- Katagami: if its tools are missing, run `claude mcp add --transport http katagami https://katagami.ai/mcp`. The tools load when the session restarts. Then call `whoami`. On the sample tier, tell the user that a one-time Google sign-in opens the full library (in Claude Code: `/mcp`, then katagami), and carry on meanwhile. With no MCP at all, use the public search, `https://katagami.ai/api/search?q=<phrase>&lane=<language|art-style>`, and each language's `DESIGN.md`.
- This recipe owns the workflow. HyperFrames' own docs and skills, if installed, are for composition details only. You do not need `hyperframes init`, and never run `init --audio`. Reason: it times the song with Whisper, which rule 2 forbids.

Project layout (run everything from the project root):

```
song/        song.wav, vocals.wav, lyrics.json, timing.json, timing-fixes.json, beats.json, labels.txt, NOTES.md
align/       aligner inputs and results
scenes.json  per scene: id, lines, idea, mode, art style, design language
looks.json   tokens and inks of every look
refs/        generated reference images (never shipped)
art/         traced vector art and art/art.js
assets/      fonts.css and fonts/
compositions/wide/ and compositions/vert/   one sNN.html per scene
out/         builds; renders/ films; site/ the optional live page
```

## Method

1. **The song** (see "Lyrics and the song"). `song/song.wav` and `song/lyrics.json`, with `text` for the screen and `sung` for the singer and the aligner. Done when the user approved the take.
2. **Timing** (see "Timing the lyrics to the word"). Split the vocal stem, force-align the lyrics on it, then run `node tools/align-to-timing.mjs` and review what it flags. Write `song/beats.json`. Done when five line starts after pauses sit on the voice.
3. **Scenes and looks** (see "Scenes and looks").
   - Take the genre, voices, modes and looks from this song's idea and the Katagami search; the examples in the references are placeholders, not defaults.
   - One line or couplet per scene, with a one-sentence idea.
   - Two or three modes for the whole film. Reason: a new look every line with no system reads as noise.
   - A new video gets its own cast, characters and looks. Never reuse characters, art or scenes from an earlier video unless the user asks for a sequel. Reason: a reused character makes a new video read as a rerun of the last one.
   - Looks from Katagami: `compose_kit` for the base look, `ask_library` per scene, `search_library` for a named style, `get_library_entry` for an art style's prompt, `get_design_tokens` for a language's tokens.
   - Done when the user approved the plan and its looks.
4. **Build** (see "Scenes and looks"). Reference images from the art style's own template, traced into vector plates. One finished reference scene, then the rest. Done when each scene's `--only` build passes `npx hyperframes check`.
5. **Vertical, looks, render** (see "Vertical, every look, and the render"). Compose the 9:16 film natively. Run `node tools/edge-check.mjs out/vert`. Test every look. Render in chunks at scene cuts, then lay the song under the picture. Done when both films pass the "QA checklists" section and play through with sound.
6. **Optional live page** (see "The live restyle page"). The MP4 plays by default. A tap lays live windows over it in the next look. Taps are coalesced.

Run the the "QA checklists" section list for each stage before the next one.

Keep the lyrics, the music model's lyrics box and the scene plan in one source file that generates `song/lyrics.json`, the lyrics box text and `scenes.json`. Give each line an estimated length and run `align-to-timing.mjs --provisional`, so scenes are built while the human makes the song (see "Timing the lyrics to the word"). Reason: hand-edited copies drift, and scenes read their times from `song/timing.json`, so the real alignment then needs only a rebuild.

## Must hold

1. Align lyrics against the isolated vocal stem, never the mix. Reason: drums and pads smear word onsets.
2. Never time lines with a phrase transcriber. Reason: after a pause it starts the line where the previous word ended, 0.2 to 1.5 s early.
3. Never hardcode a lyric time in a scene. Reason: a re-record or re-align then needs only a rebuild.
4. Never show a word before it is sung; a scene cut may come one frame early. Reason: captions that lead the voice look out of sync.
5. Generated images are references only: trace them, and never ship a raster. Reason: vector plates can be re-inked into any look.
6. Never use text drawn by an image model; draw text in code. Reason: model text is garbled and cannot be restyled.
7. Route every colour, font, radius and shadow through `--k-*` tokens. Reason: one token swap restyles the whole film.
8. Never tween a colour with GSAP; switch a class or crossfade two layers. Reason: a tweened colour ignores later token swaps.
9. Text on paper uses `--k-ink`, on panels `--k-on-surface`, on `--k-accent` fills `--k-on-accent`. Reason: in another look the paper can be black.
10. Switch shots with `opacity` or `display`, never `visibility: visible`. Reason: it overrides the runtime hiding the scene, so the shot paints over every other scene.
11. Keep blend-mode textures inside each scene. Reason: in a separate overlay they composite as a grey veil over the film.
12. Drive JavaScript side effects from a getter/setter tween, not `tl.call`. Reason: the renderer seeks with events suppressed.
13. Keep every scene deterministic: a seeded random, no clocks, finite repeats, no CSS animations. Reason: frames render out of order, in parallel chunks.
14. Self-host every font and load every face before the first frame. Reason: a missing face renders a fallback without an error.
15. Compose the vertical for vertical, and fill it by placing things, not cropping them. Reason: a card cut by the bottom edge reads as a mistake on a phone.
- The jokes are current and specific; slang a reviewer cannot read gets a native check.
- No em dashes in captions or post copy.

## You can change

- The song: its languages and every joke.
- The film's look: any Katagami art style with a strong ink identity.
- The tap looks: any Katagami design languages, since the scenes read only tokens.
- The highlight colour: one token.
- The scenes: how many, and their order.

## Make your own

Copy this recipe for your agent and give it your song, or a brief for one, and the Katagami looks you want. It times the lyrics to your vocals, builds the scenes, and gives you the film and a tap-to-restyle page.

## Lyrics and the song

Read this at stage 1. It covers both cases: the user has a song, or the user only has an idea.

Output: `song/song.wav` and `song/lyrics.json`.

### If the user has the song

1. Get the final mix at the best quality they have. Convert it once to 48 kHz WAV:
   ```sh
   mkdir -p song
   ffmpeg -v error -y -i input.flac -ar 48000 -c:a pcm_s16le song/song.wav
   ```
   An MP3 source stays an MP3 in quality. Convert it once and keep the original next to it.
2. Get the lyrics, then listen once with the text in front of you. Correct every word to what is actually sung: repeats, ad-libs, a changed word.
3. Write `song/lyrics.json` (format below). One entry per caption line.

### If the user only has an idea: write the lyrics first

Infer the mood, the genre and the audience from the idea. Ask only for what it does not tell you, and what is off-limits if it could matter.

#### Craft

- **The hook.** Three to seven words that say the idea. It is the title. Put it on a downbeat and repeat it in every chorus. Test it: could someone reply to a post with just the hook?
- **Structure.** Intro, verse, pre-chorus, chorus, verse, pre-chorus, chorus, bridge, final chorus, outro. Aim for 2.5 to 4 minutes. The bridge turns: a new angle, a quieter voice, or another language. The final chorus can change its last line.
- **Verse density.** One concrete picture or joke per line. Use many different ideas, not one joke stretched over a verse. Prefer nouns you could draw.
- **Every line is a scene.** The video cuts on lines, so write a scene note (the line's `note`) while you write the line: who, what, where. If a line has no picture, rewrite the line.
- **A chorus people can sing.** Short lines of four to eight syllables. Open vowels (ah, oh, ay) on long notes. The same rhythm every time. Few consonant clusters on held notes.
- **Details for rewatching.** Some jokes can live only in the pictures (fine print, labels, a sign in the background). Put them in the scene notes, not in the lyric.
- **Multilingual lines, if wanted.** Show the native script on screen with an English translation under it. Slang must be current usage. Have a native speaker check every language you cannot judge yourself, or research recent native posts (not dictionaries). Drop slang that has aged or that reads as a political reference.

Show the user the full lyrics with section labels and scene notes. Get approval before you generate audio. Audio is the slow and costly step.

### Two spellings per line

The screen and the singer need different spellings.

- `text` is the caption: what a reader expects. "5%", "DJ", "@name", brand names spelled correctly, native script.
- `sung` is what the music model should sing and what the aligner will listen for. Add it only where it differs from `text`.

How to write `sung`:

- Spell out numbers and acronyms: "5%" becomes "five percent", "DJ" becomes "dee-jay", "@name" becomes "at name".
- Mark stress with capitals where the model stresses a word wrongly: "reCORD" for the verb.
- Use kana for a Japanese particle the model reads by its spelling: the topic particle は is sung "wa", so write わ.
- If the model reads a line as a neighbouring language, respell it in a script that forces the right sounds, or in Latin letters.
- Parentheses mean backing vocals or ad-libs in many music models. Use them on purpose. The aligner treats a parenthesised span as one non-speech token, so the timing step strips them.

### lyrics.json

An array of lines in song order:

```json
[
  { "id": "c1_1", "section": "chorus1", "text": "Up at 3 a.m. on 5% battery", "sung": "Up at three A M on five percent battery", "lang": "en", "map": [1, 1, 1, 2, 1, 2, 1], "est": 2.6, "note": "a phone at 5% battery, the only light in a dark room" },
  { "id": "v2_3", "section": "verse2", "text": "今日も元気です", "lang": "ja", "en": "Doing great again today", "est": 2.4 }
]
```

- `id`: section plus index. Scenes and timing refer to it.
- `text`: caption spelling. Scenes show exactly this string.
- `sung`: optional, the spelling sent to the music model and the aligner.
- `lang`: a BCP 47 tag (`en`, `ja`, `zh-Hans`, `ko`, `hi`). Scenes set it on the element so the browser picks the right glyphs.
- `en`: the English translation, for non-English lines.
- `map`: only when `text` and `sung` have different word counts. For each caption word, how many sung words it covers: above, "3" is "three" (1), "a.m." is "A M" (2) and "5%" is "five percent" (2). The timing script uses it to give each caption word its sung time.
- `est`: optional, the line's estimated sung length in seconds. Only the provisional timing uses it ((see "Timing the lyrics to the word")).
- `note`: optional, the scene note: who, what, where.

One caption line is one breath or phrase, usually two to four seconds. Split longer lines at the breath.

### One source file

Keep every line once, in one small script or data file in the project, and generate the rest from it:

- `song/lyrics.json`;
- the music model's lyrics box: section tags, voice or language tags, and `sung` spellings;
- `scenes.json`: which lines each scene holds, with its mode and idea;
- a readable lyrics sheet with section labels and scene notes, for the user's approval.

Edit the source, never the outputs. On every run it checks that line ids are unique and that every line sits in exactly one scene. Then run the provisional timing ((see "Timing the lyrics to the word")), which also flags a missing `map`. Reason: hand-edited copies drift, and a line changed in one copy and not the others shows the wrong words or times the wrong line.

### Generating the audio

Any music model works if it takes lyrics and a style prompt. Suno is one example. Keep the steps tool-neutral:

1. **Style prompt.** Genre, tempo in BPM, mood, instrumentation, and which voice sings or speaks each section, all chosen for this song. Pairings range widely, for example "breathy spoken verses, stacked-harmony chorus", "one crooner throughout, a choir on the bridge" or "call and response between two singers"; none of them is a default. Do not name a living artist. Reason: services often block artist names, and copying a real singer's voice raises rights problems.
2. **Lyrics box.** Section tags (`[Verse 1]`, `[Pre-Chorus]`, `[Chorus]`, `[Bridge]`) and the `sung` spellings, generated from the source file.
3. **Generate several takes.** Pick for diction (every word clear on a phone speaker), a hook that lands, and a steady tempo.
4. **Fix single lines** with the tool's replace-section or edit feature. Note the time range and the exact new text.
5. **Export lossless WAV** to `song/song.wav` (48 kHz).
6. **Keep a note** (for example `song/NOTES.md`): the take, the style prompt and the exact lyrics text sent. A later fix then starts from the same inputs.
7. **Check the terms.** The service's terms must allow the use you plan (public posting, commercial use).

After any change to the audio, run the timing step again ((see "Timing the lyrics to the word")).

### Rules

- Write the text the aligner gets from what is sung, not what was planned. Reason: the aligner fits the words you give it, and a skipped word pulls its neighbours out of place.
- Keep one caption line per breath. Reason: the video cuts per line, and a seven-second line becomes a long static shot.
- Keep line ids stable once the first scene exists. Reason: scenes and timing refer to ids, and renumbering breaks every scene.
- Get the lyrics approved before generating audio. Reason: a lyric change after the take means a new take and a new timing pass.
- Review languages you cannot read with a native speaker or current native sources. Reason: dated or wrong slang is what native viewers notice first.

### Done when

- [ ] `song/song.wav` is the approved take, 48 kHz WAV, full length.
- [ ] `song/lyrics.json` has every sung line in order, with `text`, `sung` where it differs, `lang`, and `en` for non-English lines.
- [ ] You listened once with the text, and every word matches what is sung.
- [ ] The user approved the lyrics and the take.

## Timing the lyrics to the word

Read this at stage 2, once `song/song.wav` and `song/lyrics.json` exist, and again after any change to the audio. Read the first section earlier if you build scenes before the song exists.

Output: `song/vocals.wav`, `song/timing.json`, `song/labels.txt`, `song/beats.json`. Working files go in `align/`.

### Before the song exists: provisional timing

Scenes can be planned and built while the human is still making the song. Give every line in `song/lyrics.json` an `est`, its estimated sung length in seconds (count the syllables against the planned tempo), then:

```sh
node tools/align-to-timing.mjs --provisional
```

- It lays the lines back to back from their `est` (0.25 s apart, 1.5 s more between sections), spreads each line's words evenly, and writes `song/timing.json` with `"provisional": true`. It also prints a CHECK row for every line whose caption and sung word counts differ with no `map`, so a missing `map` shows up before any audio exists.
- Build and check scenes on it as usual. Builds run without audio until `song/song.wav` exists, and they print "PROVISIONAL timing".
- Design every scene for a word landing anywhere in its line: no layout that only works if a word lands where it is now. Reason: the real times differ.
- A scene with no sung lines has a guessed `start` in `scenes.json`. Set it again from the real song.
- Once the song exists, run the rest of this file. The real `timing.json` replaces the provisional one; rebuild and re-check every scene. Never render on provisional timing.

### Why forced alignment on the vocal stem

A phrase transcriber (Whisper-type speech recognition) starts a line where the previous word ended whenever a pause comes before it. That is 0.2 to 1.5 s early, so captions lead the voice. It also drops repeated words ("fine, fine, fine") and short function words on short clips.

Forced alignment works the other way round: you give it the words, and it finds when each one is sung. It needs the isolated vocal, because drums and pads smear the word onsets. Use a transcriber only to check what was sung (a changed word shows up as a mismatch), never to time lines.

### 1. Isolate the vocal

```sh
mkdir -p song align
. .venv/bin/activate && pip install demucs   # the venv from the setup in SKILL.md; demucs pulls in PyTorch
demucs --two-stems vocals -n htdemucs_ft song/song.wav
cp separated/htdemucs_ft/song/vocals.wav song/vocals.wav   # use the path demucs prints
```

Hosted alternative: `fal-ai/demucs` with `{ "audio_url": ..., "model": "htdemucs_ft", "stems": ["vocals"], "output_format": "wav" }`. Its default output is MP3, so ask for WAV.

Check the stem before aligning. It must have the same duration as the mix (compare seconds: Demucs may write another sample rate) and no offset. This check needs NumPy and FFmpeg. Pass a stretch where the voice sings (start and length in seconds):

```python
# offset.py: python3 offset.py [start 30] [length 30]   must print "offset 0.0 ms"
import subprocess, sys, numpy as np
start, length = (float(v) for v in (sys.argv[1:] + ['30', '30'][len(sys.argv[1:]):])[:2])
def pcm(f): return np.frombuffer(subprocess.run(['ffmpeg', '-v', 'error', '-ss', str(start), '-t', str(length), '-i', f, '-ac', '1', '-ar', '16000', '-f', 's16le', '-'], capture_output=True, check=True).stdout, np.int16).astype(float)
a, b = pcm('song/song.wav'), pcm('song/vocals.wav')
if min(len(a), len(b)) < 16000 * length * 0.9: sys.exit(f'the audio ends before {start + length:.0f} s: pass an earlier start or a shorter length')
n = len(a) + len(b)
x = np.fft.irfft(np.fft.rfft(a, n) * np.conj(np.fft.rfft(b, n)), n)
lag = int(np.argmax(x)); lag = lag - n if lag > n // 2 else lag
print(f'stem is {-lag / 16:.1f} ms late' if lag else 'offset 0.0 ms')
```

### 2. Prepare the text and the audio

The aligner gets one lyric line per text line, in song order: `sung` where present and `text` otherwise, with the parentheses removed and the words kept. Reason: the aligner treats a parenthesised span as one non-speech token. Write the text for the whole song into `align/text-song.txt`:

```sh
node -e 'for (const l of require("./song/lyrics.json")) console.log((l.sung ?? l.text).replace(/[()]/g, " "))' > align/text-song.txt
```

Send 16 kHz mono. Reason: uploads are much faster, and the aligner needs no more.

```sh
ffmpeg -v error -y -i song/vocals.wav -ac 1 -ar 16000 align/vocals-16k.wav
```

A whole song in one request is fine when the vocal is clean and the text matches it. Align per section when long instrumental breaks, ad-libs, or backing vocals missing from the text pull words to the wrong place. A bad stretch then stays inside its section:

1. Cut the stem at clear pauses. Where a cut must fall near singing, extend the clip over the neighbouring line and add that line to the text as a context line (its id starts with `~`). The script aligns it and drops it.
2. Write each section's text from its ids, context lines included:
   ```sh
   node -e 'const L = Object.fromEntries(require("./song/lyrics.json").map(l => [l.id, l])); for (const id of process.argv.slice(1)) { const l = L[id.replace(/^~/, "")]; console.log((l.sung ?? l.text).replace(/[()]/g, " ")); }' '~i2' v1_1 v1_2 '~pc1_1' > align/text-verse1.txt
   ```
3. Either send one request per section, or put the clips back to back with 2 s of silence between them and send one request for all. Reason: one request gives one result file to keep.
   ```sh
   ffmpeg -v error -y -ss 12.0 -to 41.5 -i align/vocals-16k.wav align/sec01.wav    # one clip per section
   ffmpeg -v error -y -f lavfi -i anullsrc=r=16000:cl=mono -t 2 align/gap.wav
   printf "file 'sec01.wav'\nfile 'gap.wav'\nfile 'sec02.wav'\n" > align/list.txt
   ffmpeg -v error -y -f concat -safe 0 -i align/list.txt -c copy align/joined.wav
   cat align/text-verse1.txt align/text-chorus1.txt > align/text-joined.txt
   ```
4. Record each section in `align/sections.json`. `shift` turns aligned seconds into song seconds: the clip's start in the song minus its start in the aligned file (0 for a clip sent alone; take the joined file's starts from the clips' `ffprobe` durations plus the 2 s gaps).

```json
[
  { "file": "align/joined.json", "shift": 12.0, "ids": ["~i2", "v1_1", "v1_2", "~pc1_1"] },
  { "file": "align/joined.json", "shift": 28.4, "ids": ["~v1_12", "c1_1", "c1_2", "c1_3", "c1_4"] }
]
```

Here the first clip starts at 12.0 s in the song and at 0 in `align/joined.wav`, so its shift is 12.0. The second clip starts at 59.9 s in the song and at 31.5 s in the joined file (after the 29.5 s first clip and the 2 s gap), so its shift is 59.9 minus 31.5, which is 28.4. Sections that share one file take its lines in the order listed.

### 3. Align

ElevenLabs Forced Alignment takes the audio file and the text:

```sh
curl -s -X POST https://api.elevenlabs.io/v1/forced-alignment \
  -H "xi-api-key: $ELEVENLABS_API_KEY" \
  -F file=@align/vocals-16k.wav -F "text=<align/text-song.txt" -o align/song.json
```

It returns `{ characters: [{ text, start, end }], words: [{ text, start, end, loss }], loss }`, in seconds from the start of the file. The same model runs on fal as `fal-ai/elevenlabs/forced-alignment` with `{ audio_url, text }` and returns the same shape (save it as `align/<name>.json`; a `{ result }` wrapper is fine). Upload the WAV with the fal client first. Any other aligner works if it returns words with a start and an end for the text you give it.

### 4. Build timing.json

```sh
node tools/align-to-timing.mjs                                   # whole song: reads align/song.json
node tools/align-to-timing.mjs --sections align/sections.json --fixes song/timing-fixes.json
```

It reads `song/lyrics.json` and the results, and writes `song/timing.json` and `song/labels.txt`. The song duration comes from `ffprobe` of `song/song.wav`, or from `--duration`.

- **Lines.** It splits each result at its line-break entries. When a result has none, it splits by each line's sung word count and says so.
- **Words.** A caption word takes its time from the sung words it stands for: its start is the first one's start and its end the last one's end. Equal word counts map one to one. When the counts differ, the line's `map` in `lyrics.json` says how many sung words each caption word covers ("5%" covers "five percent": 2).
- **Japanese and Chinese.** A caption without spaces becomes one caption word per character, with punctuation kept on its character. Unless the counts match or a `map` covers them, the characters share the sung time by letter count, and the line is flagged.
- **Overlaps.** A line ends by the time the next one starts: its end, and its words' ends, are clamped to the next line's start.

It prints one row per line: id, start, length, mean and max loss, then any flags:

| Flag | Meaning | What to do |
| --- | --- | --- |
| `loss <words>` | the aligner was unsure (loss 3 or more) | check the word by ear, fix it if it is off |
| `squeezed <words>` | a word shorter than 0.02 s: the aligner could not find it and squeezed it against a neighbour | re-align the line alone or fix the word |
| `mapped by length` | caption and sung words differ and no `map` covered it | add a `map`, or check the reveal by eye |
| `(fixed)` | a fix from the fixes file was applied | nothing |

### 5. Verify

- Read the table. Look hard at every flagged line.
- For a doubtful word: re-align that line alone on a tight clip (1 s either side), compare it with onsets in the stem (spectral flux peaks), or listen.
- Keep corrections in `song/timing-fixes.json`, in song seconds, by sung word index (from 0), with a reason each. The script reads that file on every run when it exists (`--fixes` names another). Reason: a re-run keeps the corrections.
  ```json
  { "v1_4": { "why": "the aligner squeezed 'Christie's' into 'canvas'", "words": { "5": [41.12, 41.48] } } }
  ```
- Expect the aligner to land 0.05 to 0.1 s after consonant onsets. That is fine: scenes cut one frame (1/30 s) before the line.
- Listen: import `song/labels.txt` into Audacity over the vocal stem (File, Import, Labels) and scrub five line starts that follow a pause. Each label must sit on the first sung sound, never before it.

### 6. Beats and downbeats

Cuts and look switches land on beats. Downbeats (the one of each bar) carry the bigger moves. `npx hyperframes beats` writes beats only, with no downbeats, so use librosa:

```python
# beats.py: song/song.wav -> song/beats.json (run it in the venv, which has librosa and numpy)
import json, numpy as np, librosa
y, sr = librosa.load('song/song.wav', sr=22050, mono=True)
tempo, beats = librosa.beat.beat_track(y=y, sr=sr, units='time')
S = np.abs(librosa.stft(y, n_fft=2048, hop_length=256))
f = librosa.fft_frequencies(sr=sr, n_fft=2048)
t = librosa.frames_to_time(np.arange(S.shape[1]), sr=sr, hop_length=256)
low = S[(f > 30) & (f < 120)].sum(0)  # kick drum band
kick = np.array([low[max(0, i - 2):i + 4].max() for i in np.searchsorted(t, beats)])
phase = max(range(4), key=lambda p: kick[p::4].mean())  # the bar position with the strongest kick
down = beats[phase::4]
json.dump({'beats': [round(float(b), 3) for b in beats], 'downbeats': [round(float(d), 3) for d in down]}, open('song/beats.json', 'w'))
print(f'{len(beats)} beats at {float(np.atleast_1d(tempo)[0]):.1f} BPM, first downbeat {down[0]:.2f} s (phase {phase})')
```

The downbeat phase is a guess. Check that a downbeat lands on the first word of a chorus. Songs with a short bar drift off a fixed four-beat grid, so pick the phase per section there.

Space the moves out. A cut or a short colour flip (a filter on the whole scene for one beat) can land on any beat, at least 0.36 s apart. Reason: faster changes read as flicker. Whole look switches go on downbeats, at least 1.5 s apart. Reason: a viewer needs time to take in a look.

### Rules

- Align against the vocal stem, not the mix. Reason: drums and pads smear the onsets.
- Never trust a transcriber's line starts after a pause. Reason: it starts the line where the previous word ended.
- Never hardcode a lyric time in a scene; read it from the timing data. Reason: a re-record or re-align then needs only a rebuild.
- Never show a word before it is sung. The cut to a new scene may come one frame before the line, no more. Reason: a caption that leads the voice reads as out of sync, even by 0.2 s.
- Make each line readable within about 0.4 s of its start. Reason: most lines last two to three seconds.
- Give every ffmpeg command that writes a file `-y`. Reason: without it, a re-run in a non-interactive shell prints "Not overwriting" and exits 0, so the old file silently stays.
- Re-run the whole timing step after any change to the audio. Reason: timing belongs to one exact take.

### Done when

- [ ] `song/vocals.wav` has the mix's duration and a 0.0 ms offset.
- [ ] `song/timing.json` came from the aligner: it has no `"provisional"` key.
- [ ] Every line in `song/timing.json` has `t`, `end` and `words`. No line overlaps the next.
- [ ] Every flagged line is reviewed, and each fix has a reason in `song/timing-fixes.json`.
- [ ] Five line starts after pauses, checked by ear, sit on the voice and not before it.
- [ ] `song/beats.json` has beats and downbeats, and a chorus starts on a downbeat.

## Scenes and looks

Read this at stages 3 and 4: planning scenes, choosing Katagami looks, making reference art, and writing the HyperFrames scene files.

Output: `scenes.json`, `looks.json`, `art/*.js`, `assets/fonts.css`, `compositions/wide/*.html` and your copy of `build.mjs`.

### 1. Plan the scenes

Cut the song into scenes of one lyric line or a couplet, usually 2 to 6 s. The intro and the outro can be one scene each. A scene with no sung lines gives its `start` in seconds, and `art` lists art names a scene builds at runtime (section 6). Generate `scenes.json` from the one source file ((see "Lyrics and the song")), not by hand.

```json
[
  { "id": "s01", "lines": ["i1"], "mode": "mode-a", "idea": "A kettle starts to whistle in an empty kitchen before sunrise", "art_style": "<slug>", "language": "<slug>" },
  { "id": "s02", "lines": ["v1_1", "v1_2"], "mode": "mode-a", "idea": "A cat on the windowsill watches the street wake up", "art_style": "<slug>", "art": ["s02-blink-1", "s02-blink-2"] },
  { "id": "s09", "lines": [], "start": 61.5, "mode": "mode-b", "idea": "Instrumental break: the street lights go out one by one", "art_style": "<slug>" }
]
```

- One idea per scene: a joke, an image, a turn. Write it in one sentence before you design anything.
- The lyric is always on screen, and it works best inside the picture: a sign, a letter, a label, a message on a screen, a caption box.
- Put small details in the picture that reward a second watch (fine print, labels, a background sign).
- Choose two or three modes for the whole film from this song's idea, for example an illustrated mode for story lines, a diagram mode for ideas and a type-only mode for punchlines, or whatever the idea needs. Every line changes treatment (layout, type, texture, motif), but only inside those modes. Record each mode's look in `looks.json` (section 3).
- Story sections (a chorus, the bridge) can be full-frame illustrated sequences: three to six shots per scene, cut on words and beats, characters acting the line.

### 2. Choose looks with the Katagami MCP

Katagami holds design languages (tokens, rules, type and UI), palette systems and art styles (prompt recipes for images). Calls, in the order you usually need them:

| Call | Use it for |
| --- | --- |
| `compose_kit({ query })` | The film's base look: a language, a palette and an art style judged to belong together, with a `brief_url`. |
| `ask_library({ query, kind })` | A look for one scene or section, from a one-sentence brief ("the chorus: loud, glossy, anime energy"). |
| `ask_library({ query, reading, changes, refine })` | Adjusting an answer ("quieter, black and white"). Pass back the `reading` and `changes` it returned. |
| `search_library({ kind, query, tag, medium })` | A named style, tag or medium ("woodcut", "collage", `medium: "print"`). `describe_library` lists what exists. |
| `get_library_entry({ id_or_slug })` | Everything in one entry. For an art style: `prompt_template`, `slot_recipes`, `negative_prompt`, `reference_image_urls`, do's and don'ts. |
| `get_design_tokens({ id_or_slug, format: "css" })` | A design language's colours, radii, shadows and fonts as CSS variables, plus `fonts_url`. |
| `check_page_against_language({ id_or_slug, page })` | After building a scene's UI: pass the scene HTML with its CSS and fix what breaks the language, worst first. |

Show the user what you pick: the thumbnail and the katagami.ai `url` of each entry. Record the slugs in `scenes.json`.

- `ask_library` returns `strange` next to `results`: styles unlike the rest of the library that still fit. Read both. Reason: for a comic or odd brief the best pick can be in `strange`.
- `compose_kit` scores each kit's `belongs_together` from 0 to 1. When it is low (a paper language paired with a dark palette, say), take every token from the language and borrow only the palette's signature colours, as the spare accents. Reason: the token table below assumes one source, and two grounds that disagree break the contrast pairs.

### 3. Tokens: the `--k-*` contract

Every scene declares its look as CSS custom properties on its root, and every rule reads them. This is what lets the film be restyled later, in a beat-looks cut or on the live page.

Nineteen tokens. Take each from the language's `get_design_tokens` output (CSS format); the first name that exists wins. Entries differ: one calls the ground `--color-background`, one names a radius `--radius-base`, another has no `--color-accent-2`, and a dark-ground language can have light cards.

| Film token | Meaning | From `get_design_tokens`, first match wins |
| --- | --- | --- |
| `--k-paper` | the ground | `--color-background`, `--color-bg`, else `#FFFFFF` |
| `--k-surface` | cards and panels | `--color-surface-solid`, `--color-surface`, else `--k-paper` |
| `--k-ink` | text and lines on paper | `--color-text`, `--color-ink`, else black or white, whichever passes on paper |
| `--k-on-surface` | text on a card or panel | `--color-on-surface`, `--color-ink`, `--color-text`, `--color-background`, `--color-bg` |
| `--k-muted` | secondary text on paper | `--color-muted` if it passes on paper, else `--k-ink` |
| `--k-line` | rules and keylines | `--color-border`, else `--k-muted` |
| `--k-accent` | the highlighter (key word, marker) | `--color-accent`, `--color-primary`, else the first colour of the palette `compose_kit` paired with it |
| `--k-accent-2`, `--k-accent-3` | sparing second and third accents: marks, strokes and fills, never text | `--color-accent-2` and `-3` (for accent-2 also `--color-secondary`), else colours from the paired palette, else `--k-accent` |
| `--k-text-2`, `--k-text-3` | text in the accent-2 and accent-3 hues | the accent itself if it passes on paper, else the language's own darker colour of that hue (often `--color-warning` or `--color-error`), else `--k-ink` |
| `--k-on-accent` | text on an accent block | `--color-on-accent`, else `--k-ink` or `--k-paper`, whichever passes on the accent |
| `--k-font-display`, `--k-font-body`, `--k-font-mono` | the three faces | `--font-heading`, `--font-body`, `--font-mono`, each with a generic fallback (`'Barlow Condensed', sans-serif`) |
| `--k-lh-display`, `--k-lh-body` | line-height of display and body text | set per look from its faces, not from `--type-line-height` (that is for reading pages): about 1.0 and 1.12 for condensed faces, about 1.25 for faces with a tall glyph box (many Japanese and Chinese families) |
| `--k-radius` | corners | `--radius-md`, `--radius-base`, else `0px` |
| `--k-shadow` | the one shadow | `--shadow-md`, else `none` |

`--k-accent-2` and `--k-accent-3` are highlighters. Never colour text with them, and never put `--k-ink` text on them. Reason: as text they fail on light paper (an orange on cream is under 2:1), and ink on an accent fill fails in a dark look. Text in their hues uses `--k-text-2` / `--k-text-3`; text on their fills gets its colour picked per look ((see "Vertical, every look, and the render"), section 4).

Every text pair must reach 4.5:1: ink on paper, on-surface on surface, muted on paper, text-2 and text-3 on paper, on-accent on accent. When a candidate fails, take the next one, or black or white, whichever passes. Compute it; do not guess:

```js
const lum = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255).map(v => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)).reduce((a, v, i) => a + v * [0.2126, 0.7152, 0.0722][i], 0);
const contrast = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };
contrast('#f8f1e4', '#f5efe2'); // light text on a light card: about 1.0, so on-surface takes --color-ink instead
```

`looks.json` holds each mode's look, the original look and every restyle look:

```json
{ "tokens": ["paper", "surface", "ink", "on-surface", "muted", "line", "accent", "accent-2", "accent-3", "text-2", "text-3", "on-accent", "font-display", "font-body", "font-mono", "lh-display", "lh-body", "radius", "shadow"],
  "modes": {
    "mode-a": { "language": "<slug>", "art_style": "<slug>", "fonts": "<fonts_url>", "k": { "paper": "<hex>", "ink": "<hex>", "accent": "<hex>", "lh-display": "<number>" }, "inks": ["<paper hex>", "<mid hex>", "<ink hex>"] },
    "mode-b": { "language": "<slug>", "art_style": "<slug>", "fonts": "<fonts_url>", "k": { "paper": "<hex>", "ink": "<hex>", "accent": "<hex>" }, "inks": ["<paper hex>", "<mid hex>", "<ink hex>"] }
  },
  "looks": [
    { "id": "original", "k": null, "inks": null },
    { "id": "look-b", "language": "<slug>", "fonts": "<fonts_url>", "k": { "paper": "<hex>", "ink": "<hex>", "accent": "<hex>", "on-accent": "<hex>" }, "inks": ["<hex>", "<hex>", "<hex>", "<hex>"] },
    { "id": "look-c", "language": "<slug>", "fonts": "<fonts_url>",
      "k": { "paper": "<dark hex>", "surface": "<hex>", "ink": "<light hex>", "on-surface": "<hex>", "muted": "<hex>", "line": "<hex>",
             "accent": "<hex>", "accent-2": "<hex>", "accent-3": "<hex>", "text-2": "<hex>", "text-3": "<hex>", "on-accent": "<hex>",
             "font-display": "'<display face>', sans-serif", "font-body": "'<body face>', sans-serif", "font-mono": "'<mono face>', monospace",
             "lh-display": "<number>", "lh-body": "<number>", "radius": "<px>", "shadow": "<shadow or none>" },
      "inks": ["<dark hex>", "<mid hex>", "<light hex>"] }
  ] }
```

- The ids and values are placeholders. Name each mode and look after what you chose, and fill the values from its Katagami entries.
- `modes` has one entry per mode named in `scenes.json`: its language, art style, fonts, all nineteen tokens in `k` (shortened above) and the ink ramp for its art. A scene declares its mode's tokens on its root (section 7). Reason: with several modes there is no single original look, so scene builders need each mode's tokens written down.
- `original` has `"k": null`: each scene keeps its own mode's tokens.
- Every restyle look lists all nineteen tokens (`look-b` is shortened above).
- `inks` is the colour ramp the traced art is re-inked with (section 5). Its first colour paints the art's lightest tone and its last colour the darkest. `look-c` has a dark paper, so its ramp starts dark and its art prints inverted, like its type.

#### Fonts

Ship every face as a file. A family with no `@font-face` renders in a fallback face, and `lint` does not report it. Fetch the look's `fonts_url` with a browser User-Agent (without one, Google Fonts serves `.ttf` files), keep the `.woff2` files in `assets/fonts/`, and point the rules at them:

```sh
UA='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
fetch_fonts() {  # fetch_fonts <name> <fonts_url> [subsets]: .woff2 files into assets/fonts/, @font-face rules into assets/fonts/<name>.css
  mkdir -p assets/fonts
  css=$(curl -sf -A "$UA" "$2") || { echo "cannot fetch $2"; return 1; }
  if [ -n "$3" ]; then  # keep only the named subsets, for example 'latin|latin-ext|cyrillic'
    css=$(printf '%s\n' "$css" | awk -v keep="^/[*] ($3) [*]/\$" '/^\/\*/ { ok = $0 ~ keep } /^@font-face/ { cur = ok; ok = 0 } cur || ($0 ~ keep) { print } /^}/ { cur = 0 }')
  fi
  printf '%s\n' "$css" | grep -oE 'https://fonts\.gstatic\.com/[^)]+\.woff2' | sort -u | while read -r u; do
    curl -sf "$u" -o "assets/fonts/$(basename "$u")"
  done
  printf '%s\n' "$css" | sed -E 's#url\(https://fonts\.gstatic\.com/[^)]*/([^/)]+\.woff2)\)#url(assets/fonts/\1)#g' > "assets/fonts/$1.css"
}
fetch_fonts mode-a "<mode-a's fonts_url>"                               # one call per mode and per restyle look
fetch_fonts look-c "<look-c's fonts_url>"
fetch_fonts jp "<a Japanese family's fonts_url>" 'latin|latin-ext'       # CJK faces: only the subsets the film shows
cat assets/fonts/*.css > assets/fonts.css
```

- The build inlines `assets/fonts.css` into `index.html`, so the paths start at `assets/fonts/`.
- Japanese, Chinese and Korean families ship hundreds of `unicode-range` files (one such family came to about 1,000 files and 14 MB). Pass the subsets the film's text uses, for example `'latin|latin-ext'`, plus `cyrillic` for Russian lines. Keep the full set only when the film shows text in that script.
- The edge check (`tools/edge-check.mjs`) also lists families that are used but never declared. See (see "QA checklists").
- A look whose faces are wider than your layout gets a copy of the face with `size-adjust` under a new family name. Test the look with that copy.

### 4. Reference images

The art style's `prompt_template` is the recipe. Fill it, do not rewrite it.

- Fill `{subject}` with the shot: who, doing what, where. Fill `{palette}` from the scene's palette. Where there is `{composition}`, use the matching `slot_recipes` entry or your shot framing.
- Where the template ends with "Full frame.", add the framing after it: "Wide establishing shot, 16:9 film frame." or "Tall 9:16 frame, subject in the upper half."
- Use the entry's `negative_prompt` if your image model takes one. Pass `reference_image_urls` (the style's own gallery) if it takes reference images.
- Keep characters identical in every shot: make one character sheet per character first, then pass it as a reference image in every call that shows them. Describe the fixed traits (hair colour, clothes, silhouette) in every prompt.
- Screens, signs and paper in the picture: ask for "a plain blank glowing screen" or "a blank sign". Draw the text in code on top. The model often draws labels, numbers and dial faces anyway, even when the prompt forbids them: blank those regions before tracing (section 5).
- Limited animation (a blink, a turn, a tear): call the image edit endpoint with the generated shot as the first reference. Prompt: "The exact same image, same composition, camera, colours and lighting; only change: ...". Trace each frame. Swap the frames on the timeline at 8 to 12 fps.
- Parallax: generate the character alone "on a flat pure white background, full figure, no shadow on the ground". Cut it out: flood-fill the white from the corners, then make it transparent. Generate the background plate "with no people". Trace the two separately.
- Save everything in `refs/`. Nothing in `refs/` is ever loaded by a scene.

### 5. Trace to vectors

Trace each reference into a few flat tone plates with potrace. Plate k covers every pixel at least as dark as tone k, and the plates are painted light to dark, so there are never gaps between them. The result is one JS file per image that registers `window.ART[name]`.

```python
# trace.py: python3 trace.py refs/shot-01.png shot-01 art [--tones 5] [--smooth 3] [--speckle 24]
import argparse, json, os, re, subprocess, tempfile
import numpy as np
from PIL import Image, ImageFilter

ap = argparse.ArgumentParser()
ap.add_argument('src'); ap.add_argument('name'); ap.add_argument('outdir')
ap.add_argument('--tones', type=int, default=5)      # flat tones (plates)
ap.add_argument('--width', type=int, default=1280)   # trace size; vectors scale to any frame
ap.add_argument('--smooth', type=int, default=3)     # median filter: melts dots and noise (1 keeps small faces)
ap.add_argument('--speckle', type=int, default=24)   # drop blobs smaller than this many pixels
a = ap.parse_args()

im = Image.open(a.src).convert('RGBA')
if im.width > a.width:
    im = im.resize((a.width, round(im.height * a.width / im.width)), Image.LANCZOS)
W, H = im.size
seen = np.array(im.getchannel('A')) > 127           # transparent pixels (cutouts) stay out of every plate
rgb = im.convert('RGB')
if a.smooth > 1:
    rgb = rgb.filter(ImageFilter.MedianFilter(a.smooth | 1))
rgb = rgb.filter(ImageFilter.GaussianBlur(0.8))
q = rgb.quantize(colors=a.tones, method=Image.Quantize.FASTOCTREE, kmeans=4)
pal = np.array(q.getpalette()[:3 * a.tones]).reshape(-1, 3)
light_to_dark = np.argsort(-(pal @ [0.2126, 0.7152, 0.0722]))
rank = np.argsort(light_to_dark)                    # palette index -> 0 (lightest) .. tones-1 (darkest)
tone = rank[np.array(q)]
tone[~seen] = -1
tmp, plates = tempfile.mkdtemp(), []
for k, idx in enumerate(light_to_dark):
    mask = tone >= k                                # plate k covers every pixel at least as dark as tone k
    if not mask.any():
        continue
    Image.fromarray(np.where(mask, 0, 255).astype(np.uint8)).convert('1').save(f'{tmp}/p.pbm')
    subprocess.run(['potrace', f'{tmp}/p.pbm', '-s', '-o', f'{tmp}/p.svg', '-t', str(a.speckle), '-a', '1.0', '-O', '0.4', '--flat'], check=True)
    svg = open(f'{tmp}/p.svg').read()
    d = ' '.join(re.findall(r' d="([^"]+)"', svg)).replace('\n', ' ')
    if d.strip():
        tr = re.search(r'<g transform="([^"]+)"', svg).group(1)
        plates.append(f'<g data-plate="{len(plates)}" fill="#%02x%02x%02x"><g transform="{tr}"><path d="{d}"/></g></g>' % tuple(pal[idx]))
svg = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}">{"".join(plates)}</svg>'
os.makedirs(a.outdir, exist_ok=True)
js = f'window.ART=window.ART||{{}};window.ART[{json.dumps(a.name)}]={{w:{W},h:{H},svg:{json.dumps(svg)}}};\n'
open(os.path.join(a.outdir, a.name + '.js'), 'w').write(js)
print(a.name, f'{W}x{H}', len(plates), 'plates', round(len(js) / 1024), 'KB')
```

Settings that work:

- Black-and-white manga or flat art: `--tones 5 --smooth 5 --speckle 30`. About 0.1 to 0.4 MB per full frame.
- Colour key art with small faces: `--tones 12 --smooth 1 --speckle 12`. `--smooth 5` melts eyes and mouths. About 0.7 MB.
- Printed screentone (halftone dots): blur the dots away first, or every dot becomes a path and the file reaches 4 MB.
- Keep each file under about 1.4 MB and look at the traced frame next to its reference before you use it.

Plates with text: blank every label, number or dial face the model drew, then trace. The tracer keeps transparent pixels out of every plate, so each blanked region becomes a hole where the scene draws its own label or dial in code. Reason: model text is garbled and cannot be restyled (rule 6).

```python
# blank.py: python3 blank.py refs/plate.png refs/plate-blank.png --box 120,80,420,160 [--circle 900,300,70]   (source pixels)
import argparse
from PIL import Image, ImageDraw
ap = argparse.ArgumentParser(); ap.add_argument('src'); ap.add_argument('out')
ap.add_argument('--box', action='append', default=[]); ap.add_argument('--circle', action='append', default=[])
a = ap.parse_args()
im = Image.open(a.src).convert('RGBA'); alpha = im.getchannel('A'); d = ImageDraw.Draw(alpha)
for b in a.box: d.rectangle(tuple(map(int, b.split(','))), fill=0)            # x0,y0,x1,y1
for c in a.circle: x, y, r = map(float, c.split(',')); d.ellipse((x - r, y - r, x + r, y + r), fill=0)   # cx,cy,r
im.putalpha(alpha); im.save(a.out)
print(a.out, len(a.box) + len(a.circle), 'regions blanked')
```

If you want to fill plates with SVG patterns later (screentone, hatching), bake potrace's `transform` into the path coordinates first, so `userSpaceOnUse` patterns render at their true size.

A small art helper places the art and re-inks it for a look. Load it once in the built `index.html`:

```js
// art/art.js: placeArt(host, name, { fit, inks }) and reinkArt(ramp)
(function () {
  const placed = new Set(); let ramp = null;              // null: each scene's own inks
  function ink(svg) {
    const r = ramp || (svg.dataset.inks ? JSON.parse(svg.dataset.inks) : null), gs = [...svg.querySelectorAll('g[data-plate]')];
    gs.forEach((g, k) => {
      if (!g.dataset.own) g.dataset.own = g.getAttribute('fill');
      g.setAttribute('fill', r ? r[Math.round(k * (r.length - 1) / Math.max(1, gs.length - 1))] : g.dataset.own);
    });
  }
  window.placeArt = function (host, name, opts = {}) {
    const A = window.ART && window.ART[name];
    if (!A) throw new Error('art not loaded: ' + name);
    host.innerHTML = A.svg;
    const svg = host.querySelector('svg');
    svg.setAttribute('preserveAspectRatio', opts.fit || 'xMidYMid slice');
    svg.style.cssText = 'width:100%;height:100%;display:block';
    if (opts.inks) svg.dataset.inks = JSON.stringify(opts.inks);   // a scene's own ramp, light to dark
    placed.add(svg); ink(svg); return svg;
  };
  window.reinkArt = function (r) { ramp = r || null; placed.forEach(svg => (svg.isConnected ? ink(svg) : placed.delete(svg))); };
})();
```

A flat ramp is the baseline. Richer inks (screentone patterns, two plates printed off register, pencil hatching) go in the same `ink` function.

### 6. The build

This section is the build contract; the other files link here. `tools/build.mjs` (one of the recipe's files) implements it in about 70 lines; it is in your project's `tools/` from the set-up step. Extend it there:

```sh
# tools/build.mjs came with the recipe (see "Set up")
node tools/build.mjs --frame wide              # out/wide/
node tools/build.mjs --frame vert              # out/vert/
```

**Inputs:** `song/lyrics.json`, `song/timing.json`, `song/song.wav`, `scenes.json`, `compositions/<frame>/sNN.html`, `art/` (with `art/art.js`), `assets/` (with `assets/fonts.css` and `assets/fonts/`), and `looks.json` for `--look`.

**Outputs:** `out/<frame>/` (or `--out`):

- `index.html`: a standalone HyperFrames composition. It holds the song as an `<audio>` clip and one host per scene at the scene's start.
- `timing.js`: `window.FILM = { duration, scenes: { s01: { start, dur, lines: [{ id, text, en, lang, t, end, words: [{ w, t, end }] }] } } }`. Line and word times are relative to the scene start. Scenes read nothing else.
- Real copies of `compositions/<frame>/`, `art/` and `assets/`, plus `assets/song.wav`. Reason: `check` and `snapshot` skip scene files reached through a symlinked folder.

| Flag | What it does |
| --- | --- |
| `--frame wide` or `vert` | 1920x1080 from `compositions/wide/`, or 1080x1920 from `compositions/vert/`. Default `wide`. |
| `--only s04,s05` | Those scenes back to back from 0, without audio. For checking scenes. |
| `--window from,to` | Only the scenes that overlap that stretch of the song, placed relative to `from`. The song is trimmed to match (`data-media-start`) unless `--mute`. For chunked renders and live windows; use scene starts as bounds. |
| `--mute` | No audio. |
| `--look id` | Applies a look from `looks.json`: its tokens on every scene and its ink ramp on the art. For testing looks. |
| `--out dir` | The output folder. Default `out/<frame>`. |

What the build does for you:

- **Scene starts sit on the 30 fps frame grid:** one frame before the frame that holds the first line's `t`. The first scene starts at 0, and a scene ends where the next one starts. Reason: chunk bounds and live windows come from scene starts, so every chunk is a whole number of frames.
- **Scenes with no sung lines** (an intro, an instrumental break, the outro) give `"start"` in seconds in `scenes.json`. The build rounds it to the nearest frame. Any scene may give `"start"` to override the computed one.
- **Art:** it loads `art/<name>.js` for every literal name in a `placeArt(host, 'name', ...)` call in the scene file, plus the names in the scene's optional `"art"` list (names built at runtime, such as animation frames). A missing file stops the build.
- **Fonts:** it inlines `assets/fonts.css` into `index.html`, so the `url()` paths in it start at `assets/fonts/`.
- **No song yet:** without `song/song.wav` it builds without audio and says so. It also says when `song/timing.json` is provisional.
- **Errors:** it stops with a message when a scene names a line that is missing from `song/lyrics.json` or `song/timing.json`, or when scene starts are out of order.

### 7. A scene file

Each scene is a HyperFrames sub-composition. Put `<style>` and `<script>` inside the `<template>`, give the template root the scene id, and register exactly one paused timeline under that id, after it is built.

```html
<template>
  <style>
    #root { /* all nineteen tokens of this scene's mode, copied from looks.json */
      --k-paper: <hex>; --k-surface: <hex>; --k-ink: <hex>; --k-on-surface: <hex>; --k-muted: <hex>; --k-line: <hex>;
      --k-accent: <hex>; --k-accent-2: <hex>; --k-accent-3: <hex>; --k-text-2: <hex>; --k-text-3: <hex>; --k-on-accent: <hex>;
      --k-font-display: '<display face>', sans-serif; --k-font-body: '<body face>', sans-serif; --k-font-mono: '<mono face>', monospace;
      --k-lh-display: <number>; --k-lh-body: <number>; --k-radius: <px>; --k-shadow: <shadow or none>;
      position: absolute; inset: 0; overflow: hidden; background: var(--k-paper); color: var(--k-ink); }
    #s07-shot { position: absolute; left: -90px; top: -50px; width: 2100px; height: 1180px; }
    #s07-cap { position: absolute; left: 96px; right: 96px; bottom: 96px; font: 700 76px/var(--k-lh-display) var(--k-font-display); letter-spacing: -0.02em; }
    #s07-cap span { display: inline-block; margin: 0 0.16em 0.16em 0; padding: 0.04em 0.14em; background: var(--k-paper); color: var(--k-ink); }
    #s07-cap .key { background: var(--k-accent); color: var(--k-on-accent); }    /* text over art always sits on a block */
    .k-credit { position: absolute; left: 32px; bottom: 28px; padding: 6px 12px; font: 600 22px/1 var(--k-font-body); background: var(--k-paper); color: var(--k-ink); }
  </style>
  <div id="root" class="scene" data-composition-id="s07" data-width="1920" data-height="1080">
    <div id="s07-shot" data-bleed data-layout-allow-overflow></div>
    <div id="s07-cap"></div>
    <div class="k-credit" data-kind="art style">art style: <art style name>, katagami.ai</div>
  </div>
  <script>
    (() => {
      const id = 's07', S = window.FILM.scenes[id];
      const all = document.querySelectorAll('[data-composition-id="s07"]'), last = all[all.length - 1];
      const root = last.classList.contains('scene') ? last : last.querySelector('.scene');   // the template root, not its host
      const tl = gsap.timeline({ paused: true });
      placeArt(root.querySelector('#s07-shot'), 'shot-07a', { inks: ['<paper hex>', '<mid hex>', '<ink hex>'] });   // the mode's ramp from looks.json
      tl.fromTo('#s07-shot', { scale: 1 }, { scale: 1.06, duration: S.dur, ease: 'none' }, 0);     // every shot moves
      const L = S.lines[0], cap = root.querySelector('#s07-cap');
      cap.lang = L.lang || 'en';
      L.words.forEach((w, i) => {
        const s = document.createElement('span'); s.textContent = w.w;
        if (i === L.words.length - 1) s.className = 'key';                                    // the punchline word
        cap.appendChild(s);
        tl.fromTo(s, { opacity: 0, y: 24 }, { opacity: 1, y: 0, duration: 0.18, ease: 'power3.out' }, w.t);          // never before it is sung
      });
      window.__timelines[id] = tl;
    })();
  </script>
</template>
```

- Times come only from `S` (the scene's lines and words). Reason: a re-align or a re-recorded take then needs only a rebuild.
- Prefix every id with the scene id (`#s07-cap`). The one exception is the template root, which is always `id="root"`. Reason: ids must be unique across the assembled film.
- Give the template root a class (`scene`) and find it by that class, as above. Reason: the runtime can leave `data-composition-id` only on the host, and a helper that returns the host puts every class and token lookup outside the scene.
- Declare the scene's current look as token defaults on `#root`, and use only `var(--k-...)` for colours, fonts, radii and shadows, in CSS and in SVG drawn from JavaScript (`fill="var(--k-ink)"` works). Reason: a look then restyles every element.
- Faces change per look. Size every text box for the widest face in any look, anchor stamps and labels by their centre or far end, and take multi-line line-height from `var(--k-lh-display)` / `var(--k-lh-body)`. Reason: a wider face then grows away from edges and lines, and a tall-boxed face does not collide with its own lines.
- Append elements that JavaScript creates to a container in the scene's markup. Reason: an element appended to the host sits outside the scene's token scope and keeps the defaults when the look changes.
- Non-English lines: the native script large, the `en` translation smaller, both on screen together. Set `lang` on the element and keep a Latin family first in the font stack; do not name a system CJK or Devanagari font. Reason: `lang` makes the browser pick the right glyphs, and a named system font differs from machine to machine.
- Credit the look in the frame, small: a `.k-credit` element with `data-kind="art style"` or `data-kind="design language"` and the text "art style: Name, katagami.ai". Reason: viewers see where the look comes from, and the live page swaps the design language credit by its `data-kind`.
- Use a seeded random number generator (mulberry32) instead of `Math.random`. Reason: chunks render in parallel, and every run must draw the same frame.
  `const rng = a => () => { 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; };`

HyperFrames' own skills (`npx hyperframes skills`) cover the full composition contract, GSAP use and the lint rules.

### 8. Shots, camera and cuts

- Cut on lyric words and beats: read `S.lines[i].t` and `S.lines[i].words[j].t`.
- Every shot moves: push in (scale 1.0 to 1.08), pan across a wider plate, a small dutch tilt, a whip pan with speed lines, or a smash cut on a hit. No still hold longer than about 1.2 s. Reason: in a music video a still frame reads as a freeze.
- Put art in an oversized shot container (for example 2100x1180 in a 1920x1080 frame) and mark it `data-layout-allow-overflow`. Reason: pushes and pans never show an edge, and `check` knows the overflow is meant.
- Draw effects in SVG or DOM on the timeline: speed lines, 1 to 2 frame impact flashes, sparkle blooms, rain, screen flicker, 2 to 3 frame camera shake. No CSS animations. Reason: CSS animations run on the wall clock, and the renderer seeks.
- The first frame of every scene is already designed, never blank. Reason: a chunk, a thumbnail or a live window can start on it.
- Swap limited-animation frames and shots with `tl.set` on `opacity` or `display`. Reason: `visibility: visible` overrides the runtime hiding the scene.

### 9. Many scenes, many agents

Parallel agents speed up a 40-scene film. Give them:

1. One brief file: the hard rules, the token contract, the scene file shape, the verify commands and a table of scene, mode, language and art style. Say when the timing is provisional.
2. One finished reference scene to copy.
3. Their own scene ids and nothing else to edit.
4. A gate every scene must pass, run by the lead, not only by the agent:

```sh
grep -nE "<img|data:image|url\\(['\"]?[^#'\")]|Math\\.random|Date\\.now|new Date|performance\\.now|setTimeout|setInterval|tl\\.call|repeat: ?-1|@keyframes|visibility: ?visible" compositions/*/s*.html
grep -L 'var(--k-' compositions/*/s*.html        # scenes that never read a token
```

The first command must print nothing. `url(#id)` references to SVG patterns are allowed; any other `url(` loads an image. Then look at every hex colour outside the `#root` token block: each one is a colour that will not restyle. Then build each scene in every restyle look and snapshot one key moment of each ((see "QA checklists"), stage 4).

### Rules

- Honour the chosen language's tokens and the art style's recipe exactly. Reason: the Katagami credit on screen promises that look.
- Keep a system of two or three modes across the film. Reason: variety inside a system reads as a style, and variety without one reads as noise.
- Never ship the image model's pixels or its text. Reason: every frame stays code, and code can be restyled and translated.
- Check each traced image next to its reference. Reason: smoothing that cleans a background can erase a face.
- Build one reference scene end to end before the others. Reason: every later scene copies its mistakes too.

### Done when

- [ ] `scenes.json` has every line in exactly one scene, each with an idea and its Katagami entries.
- [ ] `looks.json` has every mode, the original look and every restyle look; each mode and restyle look has all nineteen tokens, fonts and inks, and its text pairs reach 4.5:1.
- [ ] Every scene's `--only` build passes `npx hyperframes check` with 0 errors, and its snapshots look designed from the first frame.
- [ ] The grep gate prints nothing, and every hex colour outside the token block is justified.

## Vertical, every look, and the render

Read this at stage 5: composing the 9:16 film, testing every look, the optional beat-looks cut, and rendering and encoding both films.

Output: `compositions/vert/*.html`, `renders/wide.mp4`, `renders/vert.mp4` and the encoded copies.

### 1. Compose the vertical for vertical

The vertical is its own film at 1080x1920. Same scenes, lines, jokes, language and art style as the 16:9 file, laid out again.

- Start from the 16:9 scene file and re-lay it out. Keep the scene id and the timing code.
- No letterbox bands, no cropped 16:9 frame, no duplicated lyrics, no style labels floating in empty space.
- Stack rather than shrink: the picture or UI on top, the lyric below; or panels one above another.
- Full-bleed art with type over it works well. Frame a 9:16 window into the vector art with `fit: 'xMidYMid slice'` on a tall box, and pan or push across the drawing. The art is vector, so any crop stays sharp. Keep faces in frame.

### 2. The frame map

| Zone | Rule |
| --- | --- |
| Lyrics | In the band y = 1340 to 1680, never below y 1680 |
| Right edge | No key text in the right 120 px (Shorts, Reels and TikTok put their buttons there) |
| Bottom edge | Every UI element ends at least 48 px above it (or the scene's side margin, if larger) |
| Bleed | Only art and texture (illustration, screentone, speed lines, backgrounds) may run off an edge |
| Empty space | No empty band taller than about 100 px |

Type sizes at 1080x1920: lyrics at least 80 px (display lines 110 to 170 px), UI text at least 40 px, fine print at least 28 px.

### 3. Nothing cut at the bottom

Fill the frame by placing things, not by cropping them.

- The last thing at the bottom is a designed ending: a panel's own bottom edge, a complete footer bar, a caption block.
- No partial UI: no "next post" peeking in, no card running past the edge, no border stubs on the edge, no half-visible text.
- If space would be empty, make the complete elements bigger (type, panels, art) or move the lyric band. Do not add a cropped element.
- Mark art and texture containers with `data-bleed` so the check skips them.

Check it with `tools/edge-check.mjs`. It needs Playwright.

```sh
node tools/build.mjs --frame vert --mute
node tools/edge-check.mjs out/vert                # the whole film
node tools/edge-check.mjs out/vert --at 12.4,31   # given moments
```

- It plays the built film at the build's canvas size (1080x1920 for `out/vert`) and seeks to six moments in every scene.
- It lists every visible element with text, a border, a fill or a shadow that crosses the bottom edge. It also lists those that end inside the margin (48 px by default).
- It lists fonts too: a family that visible text asks for first but that has no `@font-face`, and any font file that failed to load. Both render in a fallback face.
- It exits 1 when it finds something.
- It skips SVG, so UI you draw in SVG is not checked: look at those frames yourself. `npx hyperframes check` also reports text and panels that leave the canvas.

### 4. Readable in every look

A look changes paper, ink, accents and fonts at once. Text that was fine in one look can vanish in another (black on black, neon on neon, cream on sage).

- Text on paper uses `--k-ink`. Text on a card or panel uses `--k-on-surface`. Text on an `--k-accent` block uses `--k-on-accent`. Text in the accent-2 or accent-3 hue uses `--k-text-2` or `--k-text-3`.
- Never colour text with `--k-paper` or `--k-surface`, and never assume the paper is light. Reason: some languages pair a dark paper with a light surface, so no single text colour works on both.
- Size highlight and marker blocks from their text (padding on the text element), never with fixed widths. Reason: a wider font in another look pushes the glyphs off the block.
- Text over traced art gets a solid block behind it, or a halo in the opposite token. Reason: the art's tones change with every look.
- For text on a token colour whose lightness changes between looks, let CSS pick black or white:
  `color: oklch(from var(--k-accent-2) clamp(0, (0.62 - l) * 1000, 1) 0 0);`

Test every look before you render. The build's `--look <id>` option (see `scenes-and-styles.md`, section 6) writes the look's rule into the `<head>` of the built `index.html`, after `art/art.js`, and re-inks the art:

```html
<style>[data-composition-id],[data-composition-id] *{--k-paper:<hex>!important;--k-ink:<hex>!important;--k-on-surface:<hex>!important;/* all 19 tokens */}</style>
<script>reinkArt(["<hex>", "<hex>", "<hex>"]);</script>
```

```sh
for lk in <restyle look ids from looks.json>; do
  node tools/build.mjs --frame vert --look $lk --only s04,s05 --out out/look-$lk
  npx hyperframes check out/look-$lk
  npx hyperframes snapshot out/look-$lk --at 1.2,3.4,6.1
done
```

- The rule must be in the top document and must target every descendant with `!important`. Reason: the same rule inside a scene's `<style>` is scoped to that scene and silently matches nothing, and a scene's own `#root` rule shadows tokens set only on the host.
- Test with the exact font files the look will use, including any `size-adjust` copies. Reason: an overlap check run with the full-size face measures a different layout.
- `check` must pass its contrast audit in the original look and in every restyle look, and the snapshots must read well at phone size.

### 5. Optional: the beat-looks cut (a sketch)

A third cut where the film switches looks on downbeats, as if someone were tapping through the live page. A mouse pointer clicks in the 16:9 cut and a fingertip taps in the 9:16 cut. Each switch shows a ripple in the new look's colour.

This section is a sketch: the build does not do it for you.

1. Write `beat-looks.json`. Take downbeats from `song/beats.json` and snap them to the 30 fps frame grid:
   ```json
   { "switches": [ { "t": 8.533, "look": 1, "wide": { "x": 0.52, "y": 0.41 }, "vert": { "x": 0.47, "y": 0.33 } } ] }
   ```
   `t` is in song seconds. `look` is an index into the `looks` array of `looks.json` (0 is the original). `x` and `y` are the click point as fractions of the frame, one pair per frame.
2. Pick the switches:
   - Start after the intro (about 8 s in). Reason: the opening establishes the film's own look.
   - Return to the original look before the end card, the closing title and credits after the last line. Reason: the credits must read in the look they were designed in.
   - At most one switch per 1.5 s, always on a downbeat. Reason: faster switches read as flicker, and a switch off the beat looks like a glitch.
   - Cycle the looks in the page's order. Reason: the cut then previews what a tap on the page does.
3. Pick each click point where the frame is calmest, near the middle and away from lyrics and faces. From a first render, take grey frames around the switch. For each candidate point, sum the absolute differences between neighbouring pixels in a box around it. Lower is calmer. Reason: a click on a face or a lyric hides the thing the viewer is reading.
4. Derive everything from the time. One tween on the root timeline calls `render(T)` on every seek, and `render` works out the look, the ripple and the pointer from `T` alone:

```js
// in the built index.html, after the scenes; switches: [{ t, look, x, y }] for this frame, sorted by t (song seconds)
function installBeatLooks(tl, dur, offset, switches, looks) {
  const sheet = document.head.appendChild(document.createElement('style'));
  let shown = -1;
  const lookAt = T => { let i = -1; while (i + 1 < switches.length && switches[i + 1].t <= T + 0.002) i++; return i < 0 ? 0 : switches[i].look; };
  function render(T) {
    const L = lookAt(T);
    if (L !== shown) {
      const k = looks[L].k;
      sheet.textContent = k ? '[data-composition-id],[data-composition-id] *{' + Object.entries(k).map(([n, v]) => `--k-${n}:${v}!important`).join(';') + '}' : '';
      reinkArt(looks[L].inks);
      shown = L;
    }
    drawClick(T); // the pointer or fingertip and the ripple, also a pure function of T
  }
  const ctl = { v: 0, t(v) { if (!arguments.length) return this.v; this.v = v; render(v + offset); } };
  tl.fromTo(ctl, { t: 0 }, { t: dur, duration: dur, ease: 'none', lazy: false, immediateRender: true }, 0);
}
```

- `switches` is `beat-looks.json` mapped for the frame: `switches.map(s => ({ t: s.t, look: s.look, ...s[frame] }))`. `looks` is the `looks` array of `looks.json`.
- `offset` is the window start in a `--window` build, so looks match across chunk boundaries.
- Wire it into `build.mjs` behind a `--beat-looks` option: inline the function, then build the main timeline as `const tl = gsap.timeline({ paused: true }); installBeatLooks(tl, dur, winStart, switches, looks); window.__timelines["main"] = tl;`.
- `drawClick(T)` finds the last switch `s` at or before `T` and the time since it, `d = T - s.t`. While `d` is under 0.7 s it shows a ripple at `(s.x * W, s.y * H)`. The ripple is a disc and a ring in the new look's accent, scaling up with an ease-out while their opacity falls to 0.
- The pointer (16:9) glides to the next click point and presses on the beat. The fingertip (9:16) comes in from the lower right, presses and lifts away. Hide both when no click is near.
- `drawClick` owns one layer in the main composition, above the scenes, and sets only `transform` and `opacity` there.
- Load every look's fonts before the first frame (`document.fonts.forEach(f => f.load())`). Register them under new family names (for example `'Look Archivo'`) and point the tokens at those. Reason: a new weight of a family the film already uses changes the original look too.

### 6. Render

Render the picture in chunks cut at scene boundaries, join them, and lay the whole song under the result.

1. Choose chunk bounds: split the song into N equal parts and move each bound to the nearest scene start. The build puts every scene start on the 30 fps frame grid, so each chunk is a whole number of frames. Reason: whole-frame chunks never drift against the song.
2. Build and render each chunk without audio:
   ```sh
   mkdir -p renders/chunks
   node tools/build.mjs --frame wide --mute --window 0,31.2 --out out/chunks/wide-0
   npx hyperframes render out/chunks/wide-0 --output renders/chunks/wide-0.mp4 --fps 30 --quality delivery --workers 2
   ```
3. Run a few chunks at once. If a chunk fails, render only that chunk again. Under heavy load raise `--browser-timeout` (seconds) and `--player-ready-timeout` (milliseconds).
4. Join and add the song:
   ```sh
   printf "file 'wide-0.mp4'\nfile 'wide-1.mp4'\n" > renders/chunks/wide.txt    # every chunk, in order
   ffmpeg -v error -y -f concat -safe 0 -i renders/chunks/wide.txt -an -c:v copy renders/wide-picture.mp4
   ffmpeg -v error -y -i renders/wide-picture.mp4 -i song/song.wav -map 0:v -map 1:a -c:v copy -c:a aac -b:a 256k -shortest -movflags +faststart renders/wide.mp4
   ```
5. Do the same with `--frame vert` for `renders/vert.mp4`.

Every ffmpeg command that writes a file has `-y`. Reason: without it, a re-run in a non-interactive shell prints "Not overwriting" and exits 0, so the old file silently stays.

Check both: `ffprobe -v error -show_entries format=duration -of csv=p=0 renders/wide.mp4` matches the song length within a frame. Then watch both films through once with sound.

### 7. Encode the copies

```sh
# A high-quality master for a desktop upload, so the platform's own re-encode starts clean
ffmpeg -v error -y -i renders/wide.mp4 -c:v libx264 -preset medium -crf 18 -maxrate 14M -bufsize 28M -pix_fmt yuv420p -profile:v high -r 30 \
  -c:a aac -b:a 256k -ar 48000 -movflags +faststart renders/wide-master.mp4
# A smaller copy for phones and chat apps: two-pass, about 2.7 Mbps
ffmpeg -v error -y -i renders/vert.mp4 -c:v libx264 -preset medium -b:v 2700k -pass 1 -an -f mp4 /dev/null
ffmpeg -v error -y -i renders/vert.mp4 -c:v libx264 -preset medium -b:v 2700k -maxrate 5M -bufsize 10M -pass 2 -pix_fmt yuv420p -r 30 \
  -c:a aac -b:a 160k -ar 48000 -movflags +faststart renders/vert-phone.mp4
# Web copies for the live page
ffmpeg -v error -y -i renders/wide.mp4 -vf scale=1280:720 -c:v libx264 -crf 25 -maxrate 3M -bufsize 6M -pix_fmt yuv420p -c:a aac -b:a 160k -movflags +faststart renders/web-wide.mp4
ffmpeg -v error -y -i renders/vert.mp4 -vf scale=720:1280 -c:v libx264 -crf 25 -maxrate 3M -bufsize 6M -pix_fmt yuv420p -c:a aac -b:a 160k -movflags +faststart renders/web-vert.mp4
```

Check each platform's current upload limits before you post.

### Rules

- Compose the vertical, never crop the horizontal. Reason: a cropped 16:9 frame loses the lyric or the face.
- Keep UI 48 px above the bottom edge and let only art bleed. Reason: a cut-off card reads as a mistake on a phone.
- Check readability in every look with the same injected rule the page uses. Reason: a test that silently matches nothing passes every look.
- Render picture only, then add the song once. Reason: audio cut at chunk boundaries clicks and drifts.
- Derive every beat-look change from time, never from what happened before. Reason: the renderer seeks out of order.

### Done when

- [ ] Every scene has a native `compositions/vert/` file, and nothing in it is letterboxed or cropped from 16:9.
- [ ] `edge-check.mjs out/vert` reports 0 elements and 0 font problems, and the SVG-drawn UI looks right in the snapshots.
- [ ] `check` passes, contrast included, in the original look and every restyle look, in both frames.
- [ ] Both renders match the song length within a frame and play through with sound.
- [ ] The encoded copies play on a phone.

## The live restyle page

Read this for the optional last stage: a web page where viewers tap the film to restyle it live in other Katagami looks. Do it after both films render and pass QA.

### What the page does

- It plays the rendered MP4 by default. The MP4 carries the sound, always.
- A tap on the film (or on a style chip) moves to the next look: original, then each Katagami look in `looks.json`, then back to original.
- In a restyle look the page lays a live HyperFrames window over the video. The window plays the same moment, muted, in the tapped look: every code-drawn element takes the look's design language tokens and the traced art takes its inks.
- Back to original hides the live windows, and the MP4 shows again.

### Files the site needs

```
site/
  index.html                     the page
  film/wide.mp4, film/vert.mp4   the web copies (renders/web-wide.mp4, renders/web-vert.mp4); sound lives here
  film/live.json                 the live windows per frame
  film/live-wide-00.html ...     one small HyperFrames page per window
  film/live-vert-00.html ...
  film/compositions/ art/ assets/ shared by every window
  film/vendor/                   pinned copies of the HyperFrames runtime, the player and GSAP
```

Pick the frame from the screen: a portrait phone gets `film/vert.mp4` and the `vert` windows, everything else gets `film/wide.mp4` and the `wide` windows.

### Build the live windows

1. Cut the film into windows at scene boundaries, each at least about 6 s long. Reason: a short window keeps a phone's memory low, and 6 s or more keeps loads and handovers rare.

```js
const windows = []; let from = 0;
scenes.forEach((s, i) => {
  const end = i < scenes.length - 1 ? scenes[i + 1].start : duration;
  if (end - from >= 6 || i === scenes.length - 1) { windows.push([from, end]); from = end; }
});
```

2. Build each window as its own small HyperFrames page: only the scenes that overlap `[from, to]`, times shifted so the window starts at 0, no audio. The starter `build.mjs` does this: `node tools/build.mjs --frame wide --mute --window 9.09,19.11 --out site/film/live-wide-01`. Each build is a folder (`index.html`, `timing.js`, `compositions/`, `art/`, `assets/`). To share files between windows, save its `index.html` as `film/live-wide-01.html` and its `timing.js` as `film/timing-live-wide-01.js` (update the script tag), and keep one copy of `compositions/`, `art/` and `assets/` in `film/`.
3. Serve every window from one folder, sharing `compositions/`, `art/` and `assets/`. Copy the HyperFrames runtime and GSAP into `film/vendor/`, then make each window page load the runtime first in its `<head>` and GSAP from `vendor/`. Reason: a page outside the HyperFrames CLI gets no runtime unless it loads one, and pinned copies keep playing the film you tested.

   ```sh
   mkdir -p site/film/vendor
   cp node_modules/hyperframes/dist/hyperframe.runtime.iife.js site/film/vendor/
   curl -fsSL https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js -o site/film/vendor/gsap.min.js
   perl -pi -e 's#<head>#<head>\n<script src="vendor/hyperframe.runtime.iife.js"></script>#; s#https://cdn\.jsdelivr\.net/npm/gsap\@3\.14\.2/dist/gsap\.min\.js#vendor/gsap.min.js#' site/film/live-*.html
   ```
4. Write the manifest:

```json
{ "frames": { "wide": { "w": 1920, "h": 1080, "windows": [[0, 9.09, "live-wide-00.html"], [9.09, 19.11, "live-wide-01.html"]] },
              "vert": { "w": 1080, "h": 1920, "windows": [[0, 9.09, "live-vert-00.html"]] } } }
```

### Embed a window

The documented route is the `@hyperframes/player` web component. Vendor it at the same version as the runtime:

```sh
npm i -D @hyperframes/player@<the hyperframes version>
mkdir -p site/film/vendor && cp node_modules/@hyperframes/player/dist/hyperframes-player.global.js site/film/vendor/
```

```html
<script src="film/vendor/hyperframes-player.global.js"></script>
<hyperframes-player src="film/live-wide-03.html" muted disable-click-to-play assets-loading-ui="none"></hyperframes-player>
```

- Wait for its `ready` event. Drive it with `play()`, `pause()`, `seek(t)` and `currentTime`.
- `player.iframeElement.contentDocument` is the window's document, for restyling. This works in the default same-origin sandbox, not with `sandbox-origin="opaque"`.
- `disable-click-to-play` leaves taps to your page. `low-power-idle` helps when several players sit paused.
- A plain `<iframe>` whose page loads the runtime also works: poll until `contentWindow.__player` exists and every scene host's timeline is registered in `window.__timelines`.

In each loaded window, add one rule to its `<head>` that takes out the scenes the runtime has hidden. The runtime hides a scene host with an inline style, so an attribute selector finds it:

```css
#root > [data-composition-src][style*="visibility: hidden"],
#root > [data-composition-src][style*="visibility:hidden"] { display: none !important; }
```

Reason: hidden scenes still cost layout and paint on every frame.

### Keep windows in sync

The code in this file is a sketch of the page's logic, not a library. `video` is the page's `<video>` element and `LOOKS` is the `looks` array of `looks.json`. The helpers it calls, one line each:

- `windowAt(t)`: the window from `live.json` whose `[from, to)` holds `t`, with `next` set to the one after it.
- `keepOnly(a, b)`: removes every loaded window except these two.
- `load(w)`: creates a hidden, muted `<hyperframes-player>` for `w`'s file, keeps it as `w.player`, and sets `w.ready` on its `ready` event.
- `quiet()`: true when the last tap was more than about 800 ms ago.
- `showChip(i)`, `showName(look)`: mark chip `i` as pressed, and show the look's name over the film for a moment.
- `ripple(x, y, look)`: a ripple at the tap point in the look's accent colour.
- `loadFonts(look)`: adds the look's `fonts` stylesheet to the page once, and resolves when its faces have loaded.
- `liveWindows()`: the loaded windows that are ready.
- `dressWindow(w, look)`: applies the look to one window (tokens, fonts, inks, credits; see "Restyle a window").
- `dressPage(look)`: sets the look's tokens on the page's own `:root`, for the chrome.
- `setLive(on)`: shows the ready windows over the video, or hides and pauses them and drops them about 4 s later.

```js
function tick() {
  const t = video.currentTime, w = windowAt(t);             // the window whose [from, to) holds t
  keepOnly(w, w.next);                                      // at most two windows alive
  if (w.next && t > w.to - 5 && quiet()) load(w.next);      // load ahead, in a pause between taps
  if (w.ready) {
    const lt = t - w.from, p = w.player;
    if (video.paused) { p.pause(); if (Math.abs(p.currentTime - lt) > 0.05) p.seek(lt); }
    else if (p.paused) { p.seek(lt); p.play(); }
    else if (Math.abs(p.currentTime - lt) > 0.12) { if (++w.drift >= 3) { p.seek(lt); p.play(); w.drift = 0; } }
    else w.drift = 0;
  }
  requestAnimationFrame(tick);
}
```

- A new window loads at the playhead, seeks to `video.currentTime - from` and plays muted over the video.
- Show a window only once it is ready. Until then the MP4 stays visible, so there is never a blank frame.
- Back to original: hide the windows and pause them. Drop them after about 4 s, in case a look comes straight back.

### Restyle a window

1. Tokens: one `<style>` element in the window's `<head>`, rewritten on every look change:
   `[data-composition-id],[data-composition-id] *{--k-paper:#FFFFFF !important;--k-ink:#1E120C !important;/* all 19 tokens */}`
   Target the hosts and all their descendants: a scene's own `#root` rule shadows tokens set only on the host.
2. Fonts: add the look's `fonts` stylesheet once per window, in a quiet moment. A look whose faces are wider than the film's gets `size-adjust` copies under renamed families, so the scenes keep their layout.
3. Art: call the window's art helper, `w.player.iframeElement.contentWindow.reinkArt(look.inks)`. `reinkArt(null)` restores each scene's own inks.
4. Credits: each scene's credit is a `.k-credit` element. Set the text of every `.k-credit[data-kind="design language"]` to `design language: <the look's language name>, katagami.ai`, and hide the `art style` credits while a restyle look is on. Reason: the art is re-inked in the look's colours, so its art style credit no longer describes what is on screen.
5. The page chrome (buttons, chips, the title) takes the same tokens from the page's own `:root`.

### Fast taps

A viewer taps in bursts. The page must answer every tap at once and restyle the film once.

- Every tap does only the cheap work, at once: the chip state, the look's name, a ripple at the tap point.
- The heavy work (tokens, fonts, relayout, re-ink of every window) runs once, after the taps pause. A lone tap applies after about 200 ms (or at once). A burst waits the viewer's own tap gap plus 20 ms, clamped to 170 to 350 ms.
- The burst always ends on the last look tapped. Drop stale work when a newer tap arrived.

```js
let wanted = 0, lastTap = 0, timer = 0;
function onTap(x, y) {
  wanted = (wanted + 1) % LOOKS.length;                     // original, look 1, look 2, ..., original
  showChip(wanted); showName(LOOKS[wanted]); ripple(x, y, LOOKS[wanted]);   // cheap: every tap, now
  const now = performance.now(), gap = now - lastTap, burst = gap < 400;
  lastTap = now;
  clearTimeout(timer);                                      // a newer tap replaces the pending restyle
  timer = setTimeout(() => applyLook(wanted), burst ? Math.min(350, Math.max(170, gap + 20)) : 200);
}
async function applyLook(i) {
  await loadFonts(LOOKS[i]);                                // cached after the first time
  if (i !== wanted) return;                                 // a newer tap arrived while fonts loaded
  for (const w of liveWindows()) dressWindow(w, LOOKS[i]);  // heavy: once per burst
  dressPage(LOOKS[i]);
  setLive(!!LOOKS[i].k);                                    // original shows the MP4 again
}
```

- Pre-warm every look's fonts after the first quiet moment, so the first restyle to a look does not wait.
- Leave very large font sheets (CJK families ship thousands of `unicode-range` faces) until that look is chosen. They slow every restyle once declared.
- Under `prefers-reduced-motion`, skip the ripple and any wipe; the look still switches.

### Guards

- Frame rate: count the live window's animation frames. Under about 14 fps for three 2 s checks in a row, step down to flat inks (patterns and screentone become flat tones). If it is still slow, play the MP4 for the rest of this visit and hide the chips.
- Remember the fallback in `sessionStorage`, not `localStorage`, so a new visit tries again.
- Crash memory: set a `sessionStorage` flag while a look is live and clear it on `pagehide`. iOS reloads a tab it killed for memory, and the flag survives: start that load in MP4-only mode.
- WebKit (Safari and every iPhone browser) repaints blend-mode textures and big vector art on the CPU. Hide texture and grain overlays there. With a plain iframe, size it at its display size and set CSS `zoom` on its document instead of `transform: scale()`, because WebKit paints the iframe at full size before scaling.
- A window whose scripts fail to load (a dropped request, a cold cache after a deploy) never becomes ready. Drop it after 8 s and load it again, at most twice.
- Offer URL switches for support: `?live=0` forces the MP4, `?live=1` clears a remembered fallback.

### Rules

- The MP4 owns the sound; live windows are always muted. Reason: two audio clocks drift, and the video element is the one clock everything follows.
- Cut windows at scene boundaries. Reason: the handover to the next window hides inside a cut.
- Keep at most two windows alive. Reason: each window holds its scenes and vector art, and phones kill tabs that run out of memory.
- Do the heavy restyle once per burst. Reason: a restyle relayouts every scene, and one that lands between taps makes the next tap lag.
- Test every look with the same injected rule the page uses. Reason: a token test placed inside a scene file is scoped to that scene and silently matches nothing.
- Pin the runtime and GSAP versions the windows load. Reason: the page must keep playing the film you checked.

### Done when

- [ ] Taps feel instant: chip, name and ripple answer within one frame.
- [ ] A 10-tap burst ends on the last look tapped within about 350 ms of the last tap.
- [ ] No audio stutter in any look: the MP4 owns the sound.
- [ ] Windows switch at scene cuts without a flash or a blank frame.
- [ ] Back to original shows the MP4, and the windows are dropped a few seconds later.
- [ ] Text is readable in every look (text on panels uses `--k-on-surface`, text on accents `--k-on-accent`, and text over art has a block behind it).
- [ ] It plays smoothly on a mid-range Android phone and in iPhone Safari, or falls back to the MP4 cleanly.
- [ ] Reduced motion is respected.

## QA checklists

Read this at the end of every stage. Each list must pass before the next stage starts. Reason: a problem found one stage later usually costs a re-render.

When feedback is vague ("it looks cut off", "it feels late"), turn it into a measured check first, then fix until the same check passes. Reason: a check lists every case, and the same instrument proves the fix.

Run every command from the video project, where `npm i -D hyperframes` pinned one HyperFrames version. The recipe's three scripts are in `tools/`.

| Stage | Lists |
| --- | --- |
| 1. The song | Stage 1 |
| 2. Timing | Stage 2 |
| 3. Scenes and looks | Stage 3 |
| 4. Build the scenes | Stage 4: each scene, Stage 4: the whole film |
| 5. Vertical, looks and render | Stage 5: vertical and looks, Stage 5: renders, Before posting |
| 6. The live page | Stage 6 |

### Stage 1: the song

- [ ] `song/song.wav` is the approved take: 48 kHz WAV, full length, no clipping at the start or end.
- [ ] You listened once with `lyrics.json` open, and every caption matches what is sung (repeats, ad-libs, changed words).
- [ ] Every non-English line has `lang` and an `en` translation, and a native speaker or current native sources checked it.
- [ ] `sung` spellings exist where numbers, acronyms or respellings differ from the caption.

### Stage 2: timing

- [ ] `song/timing.json` came from the aligner, with no `"provisional"` key.
- [ ] `song/vocals.wav` has the mix's duration, and `offset.py` prints a 0.0 ms offset.
- [ ] Every line in `song/timing.json` has `t`, `end` and `words`, and no line overlaps the next.
- [ ] Every CHECK row that `align-to-timing.mjs` printed was reviewed, and each fix has a reason in `song/timing-fixes.json`.
- [ ] Five line starts that follow a pause sit on the voice, checked by ear. None starts early.
- [ ] `song/beats.json` exists, and a chorus starts on a downbeat.

### Stage 3: scenes and looks

- [ ] Every lyric line is in exactly one scene, and every scene has a one-sentence idea.
- [ ] The film uses two or three modes, and each scene names its mode.
- [ ] Each scene's Katagami entries are recorded, and the user saw the thumbnails and katagami.ai links.
- [ ] Every mode and every restyle look in `looks.json` lists all 19 tokens, its fonts and an ink ramp. The original look has `"k": null`.

### Stage 4: each scene

`check` runs on a built project, so build the scene alone (an `--only` build) and check that:

```sh
node tools/build.mjs --frame wide --only s07,s08 --out out/check-s07
npx hyperframes check out/check-s07 --snapshots
npx hyperframes snapshot out/check-s07 --at 0.1,1.2,2.8,4.5
node tools/edge-check.mjs out/check-s07 --margin 0     # the font report; margin 0 skips the tight rule
for lk in <restyle look ids from looks.json>; do                      # every look except original
  node tools/build.mjs --frame wide --only s07 --look $lk --out out/check-s07-$lk && npx hyperframes check out/check-s07-$lk && npx hyperframes snapshot out/check-s07-$lk --at 1.2
done
```

- [ ] `check` reports 0 errors, and its layout and contrast audits really ran (see [Check quirks](#check-quirks)). A lint error switches both off: `check` then prints "0 sample(s)" and "0/0 text checks", which means nothing was measured. Fix lint first.
- [ ] Fonts: the edge check's font report is clean (no family without `@font-face`, no font file that failed to load), and a snapshot shows the display face, not a fallback.
- [ ] The first frame (0.1 s) is already designed, never blank.
- [ ] Each line's words appear on their sung times, and the line is readable within about 0.4 s.
- [ ] Nothing is clipped or overlapping; no text runs outside its box.
- [ ] In every restyle look, the scene passes `check` and one key moment's snapshot shows every text inside its box and the frame. Reason: wider faces push text out, and `check` does not see text inside a layer marked `data-layout-allow-overflow`.
- [ ] Every shot moves; no still hold longer than about 1.2 s.
- [ ] Traced art matches its reference: faces intact, no plate edges showing during pushes and pans.
- [ ] Non-English lines show the native script and the translation together.
- [ ] The Katagami credit (the scene's `.k-credit` element) is on screen and names the right entries.
- [ ] The grep gate prints nothing:

```sh
grep -nE "<img|data:image|url\\(['\"]?[^#'\")]|Math\\.random|Date\\.now|new Date|performance\\.now|setTimeout|setInterval|tl\\.call|repeat: ?-1|@keyframes|visibility: ?visible" compositions/*/s*.html
```

- [ ] Every hex colour outside the `#root` token block is justified (each one will not restyle).

### Stage 4: the whole film

- [ ] A full build plays scene to scene with no scene painting over another. Compare one scene alone with the same moment in the full build: a grey veil or a darker white means a blend-mode or filter layer is leaking.
- [ ] Contact sheet: two frames per scene across the whole film, read at phone size.
- [ ] Captions never lead the voice. Scrub five cuts after pauses with sound.

### Stage 5: vertical and looks

- [ ] Every scene has a native 9:16 file; nothing is letterboxed or cropped from 16:9.
- [ ] Lyrics sit above y 1680, key text stays out of the right 120 px, and no empty band is taller than about 100 px.
- [ ] `node tools/edge-check.mjs out/vert` reports 0 elements and 0 font problems.
- [ ] SVG-drawn UI near the bottom edge looks complete in the snapshots (the edge check skips SVG).
- [ ] For every restyle look: a `--look` build passes `check` (contrast included) in both frames, and its snapshots show no text lost against its ground.

### Stage 5: renders

- [ ] Every chunk rendered; failed chunks were rendered again, not the whole film.
- [ ] `ffprobe` duration of each film matches the song within one frame.
- [ ] Watched both films end to end with sound: sync holds at the end as well as the start.
- [ ] The encoded copies play on a phone, and the phone copy's size fits where it will be posted.

### Before posting

- [ ] Captions spelled exactly as `text` in `lyrics.json`, including names and native scripts.
- [ ] The Katagami credits and any music credit are correct.
- [ ] You have the rights to the song and to any likeness in it: no real person's face, no real logos.

### Stage 6: the live page (optional)

- [ ] It plays the MP4 with sound by default, on desktop and on a phone.
- [ ] A tap answers within one frame (chip, name, ripple), and a 10-tap burst ends on the last look tapped within about 350 ms.
- [ ] Live windows switch at scene cuts with no flash; back to original shows the MP4.
- [ ] Text is readable in every look, on the page chrome as well as in the film.
- [ ] A slow phone steps down to flat inks, then to the MP4, without breaking playback.
- [ ] `prefers-reduced-motion` turns off the ripple and wipes.

### Check quirks

How `npx hyperframes check` behaves (seen with HyperFrames 0.8.80), and what to do:

- It reads `data-layout-allow-overlap` only from the text element itself, never from a parent group. Put it on each text element that overlaps on purpose.
- Text hidden by an SVG `clip-path` still counts as overlapping. Give it `data-layout-allow-overlap` too.
- The occlusion probes cover a rotated element's bounding box, so a rotated stamp's corners land on the card below it. Give the stamp `data-layout-allow-occlusion`.
- `rotation_pivot_drift` includes the camera's pan: a dial inside a panning layer reports drift. Check the pivot by eye and keep the warning.
- `data-layout-allow-overflow` on a camera layer also hides wrapped or off-frame text inside it. The restyle snapshots are what catch it.
- The contrast audit samples the rendered pixels inside each text box, overlays included: light scanlines over an accent bar can fail a light look. Paint texture overlays in `var(--k-paper)` with `mix-blend-mode: darken`. They dim a dark look and vanish on a light one.
