<!-- ImuTechz skill: Fish Video Skill v2.1.0 (MIT)
     https://www.imutechz.in/skills/fish-video-skill
     Single-file edition: SKILL.md, then every reference and file it points at. -->

# Fish Video Skill v2

You make one video from one conversation. It must reach the quality of the top Shorts dissected in [reference breakdowns](references/reference-breakdowns.md):
- something new on screen every ~1 s
- real assets with depth (texture, contact shadows, glow)
- type with a 3× hero-word contrast
- object-as-portal transitions
- a swish that lands *before* every move, risers that *end* on reveals, impacts on flash cuts
- a voice that sits on top of an audible music bed

A slideshow with a generic voice is a failure even when QA passes.

## Router: load the guide when you reach the task

Read only what the current step needs:

- The user gave only a word, or wants topic ideas, or you are writing the script → [topic to video](references/topic-to-video.md)
- Choosing a style, palette or look → [style recipes](references/style-recipes.md), `config/styles.yaml`
- An idea needs explaining, or choosing colours by mood → [diagram design](references/diagram-design.md)
- Writing any scene → [scene catalogue](references/scene-catalogue.md), including Motion rules and Camera
- Anything about music, SFX or levels, or "I can't hear the music on my phone" → [sound design](references/sound-design.md)
- Choosing or directing a voice → [voice direction](references/voice-direction.md)
- Captions and type → [captions & typography](references/captions-and-typography.md)
- A website or app on screen → [website-tour capture](references/website-tour-capture.md)
- A reference video to match → [reference breakdowns](references/reference-breakdowns.md), [reference-video analysis](references/reference-video-analysis.md), [trending analysis](references/trending-analysis.md)
- Platform sizes and limits → [platform specs](references/platform-specs.md)
- Which open-source tool to reach for → [field guide](references/open-source-field-guide.md)

## Non-negotiable workflow

```
0 doctor ─▶ 1 INTERVIEW (all questions, once) ─▶ confirm ─▶ 2 research + trends + references (parallel)
  ─▶ 3 assets + screen capture + voice (parallel) ─▶ 4 storyboard (you write every word) ─▶ gate
  ─▶ 5 stills (look) ─▶ 6 render ─▶ 7 QA + REVIEW loop (fix, re-render, repeat) ─▶ 8 package ─▶ 9 final review ─▶ 10 clean (ask)
```

Rules:
- **One interview, at the start.** Every question that needs the user is asked in step 1, including voice and tone, colours, the screen steps, API keys (with how to get each one) and the optional extras to install. After `fishvideo interview <dir> --confirm`, you do **not** ask questions. Decide from the answers and the style recipe. Contact the user again only in these cases:
  - (a) an optional stop they chose in `checkpoints`
  - (b) a desktop recording they must perform; they agreed to it in the interview, and you tell them exactly when to start
  - (c) the final review
  - (d) permission to clean up

  If the user later says something different, follow the user.
- **Never write a script before the interview is confirmed.** `fishvideo script`, `all` and full `render` refuse until `fishvideo gate` passes.
- **Never ship template text.** The scaffold is a skeleton of TODOs. You write every on-screen word and every narration line fresh for this video, so the gate blocks TODOs, v1 stock phrases ("X, explained", "in 60 seconds", "Thanks for watching") and any line copied from `examples/`. Examples show structure, never content.
- **Explainers get a diagram.** Any `goal: explain` video needs at least one `diagram`, `whiteboard`, `neonline` or `split`-reveal mechanism scene. A list of bullets is not an explanation.

## Step 0: doctor

Setup (first time only) is in `README.md`: `npm install`, a venv, and `pip install -r requirements.txt`. Then run:

```bash
.venv/Scripts/python scripts/fishvideo.py doctor --keys      # Windows (macOS/Linux: .venv/bin/python)
```

Note which keys and extras are missing. You ask about exactly those in interview round 8.

> **Setup for the best result (do this, don't skip it):**
> - **Install the extras yourself.** If `doctor` shows a missing extra, install it into the skill's venv without making the user do it: `pip install "rembg[cpu]"` (cut-outs), `pip install kokoro soundfile` (natural local voices) and `pip install faster-whisper` (aligns the user's own voiceover). Also run `fishvideo assets sfx-pack` once if the SFX library is missing. Tell the user in one line what you are installing and why. Only skip an extra the user declines in round 8.
> - **Ask for the API keys.** They are **optional**: everything works without them through the free fallbacks. They do give the best result: ElevenLabs gives the most natural voices, Pexels/Pixabay give the most stock photos and video, Freesound gives the most SFX, and the YouTube Data API gives the trending charts. In round 8, ask for the ones that matter for this video, say how to get each one (free sign-up links are in `doctor --keys`), and accept "no" or "later" without pushing. Do this in the interview only, never once creation has started.

## Step 1: the interview (strict)

```bash
fishvideo new projects/<slug> --prompt "<the user's request, verbatim>"
fishvideo interview projects/<slug>            # JSON: the open questions, grouped in 8 rounds
```

The question bank is [config/interview.yaml](config/interview.yaml). It has 8 rounds and about 30 questions:

| Round | Covers |
|---|---|
| 1 | purpose |
| 2 | shape and style |
| 3 | colours, type, texture |
| 4 | voice, delivery, music, SFX |
| 5 | real assets and screen recording |
| 6 | content, facts, CTA, language |
| 7 | captions, thumbnail, approvals |
| 8 | API keys and extras |

**Only a word or an area?** Before round 1, generate 5 trending angles: `yt suggest`, `yt search --recent-days 30` and `trends` (haiku subagents, in parallel), following [topic to video](references/topic-to-video.md). Offer them as the `topic` question. The chosen angle drives everything after it.

How to ask:
- Ask **one round per `AskUserQuestion` call** (up to 4 questions), using the options exactly as listed. Recommended options come first. "Other" is always available.
- Free-text questions (`free_text: true`) go in a normal message for that round, together with the round's multiple-choice call.
- Skip a question only when the prompt already answers it unambiguously (`from_prompt` shows what was detected), and record it anyway.
- Record each answer: `fishvideo interview <dir> --set style=paper-collage --set platforms='["shorts"]'`. Answers map into `brief.yaml`: the palette goes to `brand.colors`, the mood to the score and tempo, the voice and delivery to the voice config and post chain, and the style to texture, pacing and motif.
- **Voice:** after rounds 4 and 6 (once the language and delivery are known), run `fishvideo voices <dir> --audition`. Give the user the file paths in `build/auditions/` so they can play them, and let them pick. This is still part of the interview.
- **Keys:** offer only the missing keys that matter for their answers. Explain how to get each one (the text in the bank) and what happens without it. If they paste keys, record them with `--set keys_paste=...`. Keys go to the skill's gitignored `.env` and are never echoed, logged or committed. If they choose extras, install them into the venv now (`pip install "rembg[cpu]"`, `kokoro soundfile`, `faster-whisper`).
- Finally, read `fishvideo interview <dir> --summary` back to the user. Ask for a plain "yes, go", then run `--confirm`.

## Step 2: research, trends and references (run in parallel)

Spawn these as parallel subagents. Model routing: **haiku** for fetch and lookup sweeps, **sonnet** for analysis and routine code, and you (**opus**) for the script, storyboard, design judgement and final review. Brief each agent with the project path and the exact output file it must produce. Subagents may not be able to write report files; then they return the text and you save it.

| Task | Command | Agent |
|---|---|---|
| Facts and sources | web search, then `fishvideo research <dir> --url ...` → `research/facts.md` | haiku |
| Trends | `fishvideo trends <dir> --topic "<topic>" --region <IN/US>` → `research/trends.md` (title formulas, tags, hooks, ideal length from what is ranking now) | haiku |
| YouTube look-ups | `fishvideo yt search "<q>" -n 20 [--recent-days 30]`, `yt info`, `yt subs`, `yt suggest` | haiku |
| Reference dissection (each link the user gave, plus 1–2 top trending examples in the chosen style) | `fishvideo yt download <url> --purpose reference --project <dir>`, then `fishvideo dissect <dir>/reference/<file>.mp4 --out <dir>/reference/dissect --fps 4 [--vtt subs.vtt]`. Read **every** sheet, the spectrogram and `timeline.md`, then write `reference/analysis.md` with the nine sections of [reference-video analysis](references/reference-video-analysis.md) | sonnet (vision) |

From the dissections, take numbers: change rate, swish timing and level, voice-over-bed dB, duck depth, loudness, caption style. Write them into `brief.yaml → match:` so the storyboard and mix can follow them. **Copy craft, never content.**

## Step 3: assets, screen and voice (run in parallel)

**Real assets** (when `assets.policy` is `mixed`):
- Search: `fishvideo assets search <dir> "<noun>" --kind photo|video|music|sfx [--providers wikimedia,pexels]`. Open the previews in `assets/_candidates/` and look at them.
- Download what you pick: `fishvideo assets get <dir> <ids>`. Every file is logged to `assets/credits.json` with its licence.
- Make cut-outs: `fishvideo assets cutout in.jpg out.png` (rembg with `isnet-general-use`; never the non-commercial default model).
- Prepare b-roll: `fishvideo assets clip <dir> <file> <clip_id> --start S --dur S` gives frames for the `footage` scene.
- Get a CC-BY YouTube section: `fishvideo yt clip <url> <start> <end> <dest> --project <dir>`. It refuses non-CC videos.
- Licences allowed: CC0, PD, Pexels/Pixabay licences, CC-BY and CC-BY-SA (credited). Never NC or ND. Never a non-CC YouTube video, a reference video's footage, a logo you redraw, or a real person's likeness in a misleading way.

**Screen:**
- `website`: list the shots under `captures:` and run `fishvideo capture <dir>`. It films read-only in virtual time.
- `desktop`: `fishvideo screenrec record <dir> <shot> --seconds N [--window "Title"] [--scale 2]`. Before it starts, tell the user exactly which steps to perform. F8 drops a mark and F9 stops.
- `files`: `fishvideo screenrec import <dir> <shot> recording.mp4`.

All three produce the same capture folder. The `tour` scene (`frame: inset`) adds the glow cursor, click ripples and click SFX, a 1.6–2× zoom to the cursor, a spotlight and STEP cards. Record at scale 2 so zooms stay sharp.

**Music:**
- A library track: `assets search <dir> "<brief.music.search>" --kind music`, then `assets get`, and put the file in `assets/music/`.
- Or leave the synthesised score. Set `music.source: synth` to force it.

**SFX:** run `fishvideo assets sfx-pack` once (Kenney CC0). Add more with `assets search --kind sfx` (Freesound CC0) into `assets/sfx/<cue-type>/`.

**Voice:** write the narration (step 4), then run `fishvideo voice <dir>`. Re-run the storyboard so scenes re-time to the real clips. If the user recorded their own narration, run `fishvideo voiceover <dir> recording.wav` instead: it aligns the recording to the script with faster-whisper and cuts it per line.

**Emotion:** give each scene a `mood:` (`curious`, `tense`, `urgent`, `excited`, `warm`, `serious`, `reflective`, `triumphant`, `neutral`). It sets speed and pitch per line, and style on ElevenLabs. Punctuation shapes the delivery on every engine: `...` pauses, `?` and `!` lift, short sentences punch. Vary mood across beats: one flat tone for 40 seconds is the "generic voice" complaint. Silent stretches inside a line are cut automatically (`pacing.max_word_gap`).

**Music timing:** for a library track, run `fishvideo beats <track>`. Set the storyboard `bpm` to its tempo and `music.offset` so a beat or the drop lands on your biggest reveal.

**Screen recordings:** run `fishvideo screenrec tighten <dir> <shot>` to speed through idle stretches. Never make the viewer wait for a loading spinner.

## Step 4: storyboard (you write it)

Run `fishvideo script <dir>`. It scaffolds `storyboard.yaml` from the style recipe ([config/styles.yaml](config/styles.yaml), [style recipes](references/style-recipes.md)) as a beat skeleton full of TODOs. Its timings are placeholders. Then write it properly, in this order:

1. Replace every TODO: copy, params and narration. The table at the top of the [scene catalogue](references/scene-catalogue.md) lists the required params.
2. Write `brief.publish` and check the fields the interview cannot fill: `title`, `facts` (`{text, value, source}`), `sources`, `cta`.
3. Run `fishvideo voice <dir>`, then `fishvideo storyboard <dir>`. Only now are the scene lengths real. Anything timed in beats (`at`) is set against the spoken words *after* this step.
4. Run `fishvideo gate <dir>`.

**Variety.** Never use the same scene type twice in a row in a Short, and never three stats or charts in a row. A real photo must never appear unprocessed. Use `assets cutout` and a treatment, because a dull rectangle kills the frame.

**One character.** Choose one recurring object or figure before writing any beat: a phone, a box, a dot, a letter. Everything in the video happens to it, and it appears in every scene (`anchor` / `carry`, and `match` transitions). This is the clearest difference between trending videos and generated slideshows: see [trending analysis](references/trending-analysis.md).

**Diagrams evolve.** Keep one persistent element and let it re-label, grow or travel (`neonline` with `follow`, `diagram` with `morph`, camera push-throughs). Don't assemble unrelated static nodes.

**Beats.** Split the idea into 4–8 meaning beats: hook, claim, human moment, concept, escalation, turn, payoff, end. Use one scene per beat, and repeat beats for screen steps.

**Style bible lock.** Every scene uses:
- the same palette, one accent, one type family
- the style's motif
- rising intensity until the payoff

**Hook.** Frame 0 is already full: a subject, a title slam or a striking cut-out. Nothing appears before it (no logo, no fade-in). Within the first 1–2 s it delivers the hook kind the user chose.

**Change rate.** Something new every `pacing.change_every` seconds (0.75–1.2 s for Shorts): an element entrance, a camera move, a reveal or a word pop. Never hold a static frame for more than 2 s on a Short.

**Copy:**
- Narration runs about 3 words/s (`punchy`), 2.4 (`calm`) or 2.2 (`cinematic`).
- One idea per sentence. Concrete nouns.
- No "revolutionary", "seamless" or "leverage".
- Respell numbers and acronyms with `{shown|spoken}`.

**On-screen text** is a subset of the narration, at most 6 words per line, with one `*accent*` word.

**Transitions.** Prefer object-as-portal transitions (`zoom`, `flip`, `split`, `defocus`) and `flash` for impact cuts. Avoid plain `fade`.

**Sound comes from cues.** Scenes emit cues automatically. Add explicit `cues:` for story moments:
- a `riser` that ends on the reveal
- an `impact` on the title slam
- a `boom` on the payoff
- silence after the final hit when the style calls for it

**Script from the topic.** Follow [topic to video](references/topic-to-video.md): one-sentence summary, beats, hook, open loop, payoff, then read it aloud and cut 10%.

**Diagrams and theme.** Choose the diagram from the sentence (flow, anatomy, cycle, split, chart, zoom-in layers, lanes, architecture), reveal its parts in narration order, and put the accent on one element per beat. When the user picked "style default", choose the style, palette, music and delivery from the script's mood. All of this is in [diagram design](references/diagram-design.md), and the mood table is `config/styles.yaml → mood_map`.

**Facts.** Every number on screen or in the narration must be in `brief.facts` with a source; `factcheck` enforces this.

**Publishing copy:**
- 5 title options in `brief.publish.titles`, using the formulas from `research/trends.md`. Keep each ≤ 60 chars with the hook word first.
- `publish.description`, the first 2 lines of which are the hook.
- `publish.tags` (≤ 15) and `publish.hashtags` (3–5).
- The `thumbnail:` block.

The scene catalogue, with params and examples for every scene, is [scene catalogue](references/scene-catalogue.md). The motion and sound rules are in [sound design](references/sound-design.md), [pacing & music](references/pacing-and-music.md) and [captions & typography](references/captions-and-typography.md).

Then run `fishvideo gate <dir>`. It must say `ready`.

## Step 5: stills (look at them)

Run `fishvideo stills <dir> --scenes` for every platform and **open every still**. Check:
- depth: texture, shadows, glow
- hierarchy: 3× contrast between the hero word and small text
- the safe area: no text under the Shorts/Reels UI
- that the caption plate is legible on busy frames
- that nothing looks like a default template

Fix the problems and look again. If the user chose the `stills` checkpoint, show them now.

## Step 6: render

Preview the hardest 10 s first: `fishvideo render <dir> --platform shorts --scale 0.5 --fast --from 8 --to 18`. Then run the full render: `fishvideo render <dir>`.

The audio is **three stems**:
- **voice** at −16 LUFS
- **music** 10 dB under the voice, mid-carved and ducked about 6 dB while speaking, present from frame 0 and faded at the end
- **sfx**, made of real samples plus synth, never ducked, placed by cue: swishes 0.1 s early, risers ending on the cue, at most one SFX per 80 ms

The master is −12 LUFS with −1 dBTP on Shorts, Reels and TikTok, and −14 LUFS with −1.5 dBTP on the other platforms. The knobs are `mix:` in `brief.yaml`.

## Step 7: QA and the review loop

Run `fishvideo qa <dir>`. Fix every FAIL, and treat every WARN as a fix unless you can justify it to the user. Then run the review loop **at least twice**:

1. `fishvideo review <dir> --platform <p>` writes `REVIEW.md` (measured numbers and findings tagged P0/P1/P2) and three sheets in `build/<p>/review/`: beats at phone width, a strip around each cut, and the hook.
2. Open every sheet. Under "What I looked at", describe each beat frame in one sentence and name every object. **An object that needs explaining is a P0.** Name the weakest shot.
3. Write notes as `t=4.2s · where · problem → exact change`. Fix P0s and worthwhile P1s in this order: text, timing, detail.
4. Re-render only what changed, and repeat. Read `REVIEW.md` before changing anything.

Never score the video out of 10; a score finds nothing to fix. Where the numbers and your eyes disagree, trust the numbers. You cannot hear, so audio is measured with commands.

Also:
- Open each `build/<platform>/contact.png`.
- Run `fishvideo dissect build/<platform>/final.mp4 --out build/<platform>/self-check`. Compare its balance and event density with the reference numbers:
  - voice over bed of 6–14 dB
  - music audible in gaps (above −38 dBFS)
  - a swish within 0.15 s before most cuts
  - SFX density matching the chosen level
- Check `speaker translation` in the QA report. `build/<p>/phone-monitor.wav` is what a phone plays. If the music or SFX vanish there, fix the mix (see [sound design](references/sound-design.md)); never ship a mix that only works on headphones.
- Watch the first 3 s and the end. Would this hold attention next to the references? If not, go back to step 4.

## Step 8: package

Run `fishvideo package <dir>`. It produces `out/<date>_<slug>/` containing:
- `README.md` (the index and an upload checklist)
- `video/`
- `captions/`
- `thumbnails/` (1280×720 plus vertical covers)
- `metadata/` (titles, per-platform post text with chapters, sources and credits, tags, transcript, `credits.txt` with every licence and the AI disclosure, `metadata.json`)
- `qa/`

## Step 9: final review

Send the user:
- the path to `out/<date>_<slug>/`
- the main video file
- the thumbnail
- the 5 titles
- the measured loudness and mix balance
- anything still open, such as an estimated voice or a WARN

Ask for approval or changes. On changes, edit and re-render only what changed.

## Step 10: clean up (only with permission)

After the user approves the final video:
1. Run `fishvideo clean <dir>` (a dry run). It lists what would be deleted (`build/`, reference downloads, unpicked candidates, raw pages) and what is kept (brief, storyboard, the assets used with their credits, research notes, and `out/`).
2. **Ask the user** whether to delete it, and give the size.
3. Only on a yes, run `fishvideo clean <dir> --yes` (add `--keep-voice` if they may re-render).

Never delete without that yes.

## Hard rules

- Never invent facts, numbers, quotes, customers or logos. Unsourced numbers fail the build.
- References are for analysis only. Never cut a reference's footage, music or voice into the video. Downloads stay in `reference/` and are cleaned afterwards.
- Every third-party asset is in `assets/credits.json` with a licence that allows commercial reuse. The package credits it.
- Film websites read-only: no logins, no form submits, no purchases, no private data. Desktop recordings never log typed text, only modifier combos.
- Keys live in `.env` and environment variables only. Never print or commit them.
- Never max out the user's machine. Renders run below normal priority under a CPU cap (`FISH_CPU_LIMIT`, default 60%), so they take longer but the PC stays usable. Don't raise it unless the user asks. Run heavy jobs one at a time, as tracked background tasks, so nothing is left running.
- Scene code is deterministic: no `Math.random`, `Date`, CSS animations or rAF.
- Do not upload or publish anything. The deliverable is the local folder.
- `--no-gate` exists for the test suite. Never use it for a user's video.

## Failure modes (v1 lessons)

| Symptom | Cause | Fix |
|---|---|---|
| Every video says "X, explained… in 60 seconds" | The v1 scaffold wrote copy | Scaffold is skeleton-only, and the gate blocks stock or example lines |
| Questions trickle in mid-render, or none are asked | There was no interview contract | One 8-round interview, confirmed before scripting |
| BGM inaudible; risers and clicks vanish | One bus, −5 dB bed, ratio-6 duck that also ducked the SFX | Three stems: music −10 dB re voice with a 6 dB duck; SFX unducked |
| Swishes feel late and mushy | 0.55 s whooshes placed on the cut | 150 ms swish, starting 0.1 s before the move |
| Looks flat, like a template | Vector boxes only, no assets, no texture | Real cut-outs (`assets cutout`), `texture: paper/grain`, contact shadows, glow; collage/split/neon scenes |
| A generic narrator | Fixed default voice | A delivery preset plus auditions in the interview, with ElevenLabs, Kokoro and edge Multilingual voices |
| Soft zooms in screen tutorials | Recorded at 1× | `screenrec --scale 2`, `capture` at DPR 2 |
| Captions unreadable over busy frames | No plate | `captions.style: pop` adds an automatic 40% plate |
| Plus every v1 renderer issue | | See [website-tour capture](references/website-tour-capture.md) (virtual time, fonts, decode, faststart, loudnorm) |

## Files

- `scripts/`:
  - `fishvideo.py` (CLI)
  - `interview.py` (interview, gate, approvals, auditions)
  - `youtube.py`, `trends.py`, `assets.py`, `screenrec.py`, `dissect.py`
  - `sfx.py` (sample SFX)
  - `music.mjs` (score plus synth SFX stem)
  - `render.py` (stem mix)
  - `qa.py` (including mix balance)
  - `package.py`, `clean.py`
  - `research.py`, `reference.py`, `storyboard.py`, `voice.py` (ElevenLabs, edge-tts, Kokoro), `captions.py`, `fvcore.py`, `fvnet.py`
  - the stage: `stage/` (scenes, transitions, textures, captions)
- `config/`: `interview.yaml`, `styles.yaml` (style bibles, palettes, music moods), `voices.yaml` (voice shortlist, delivery presets), `defaults.yaml`.
- `references/`:
  - [reference breakdowns](references/reference-breakdowns.md)
  - [topic to video](references/topic-to-video.md)
  - [diagram design](references/diagram-design.md)
  - [sound design](references/sound-design.md)
  - [style recipes](references/style-recipes.md)
  - [scene catalogue](references/scene-catalogue.md)
  - [pacing & music](references/pacing-and-music.md)
  - [captions & typography](references/captions-and-typography.md)
  - [voice direction](references/voice-direction.md)
  - [platform specs](references/platform-specs.md)
  - [reference-video analysis](references/reference-video-analysis.md)
  - [website-tour capture](references/website-tour-capture.md)
  - [storyboard templates](references/storyboard-templates.md)
- `sfx/library/`: Kenney CC0 interface, impact and UI packs plus `index.json`.
- `examples/`: `why-you-cant-put-it-down/` (the full narrated target: watch `sample-short-720p.mp4` and read its `storyboard.yaml`), `showcase/` (style demo: collage, split, neon, coin flip, glow tunnel) and `imutechz-explainer/` (a v1 production storyboard; structure only, never reuse its lines).
- `tests/`: unit tests (`test_units.py`, `test_tools.py`), node tests, and e2e smoke renders.


---


## references/topic-to-video.md


# From a single topic to a finished script

The user may give only a word ("habits", "black holes", "our app"), or ask for topic ideas. This is the method for getting from there to a script. Run it inside the interview, so the user picks the angle *before* creation starts.

## 1. Topic suggestions (when the user has no topic or only a broad area)

Run these in parallel, as haiku subagents:

```bash
fishvideo yt suggest "<area>"                       # what people type: autocomplete
fishvideo yt search "<area>" --n 25 --recent-days 30
fishvideo trends <dir> --topic "<area>" --region <IN|US> --days 30   # research/trends.md: ranked by views/day
```

From these, build **5 topic options**. Each option has:
- **an angle**: a specific question, tension or claim, never just the subject
- **evidence it is trending**: the top recent video's views per day, and how many autocomplete hits it has
- **the format it suits**: one of the style recipes
- **a working hook line**: at most 10 words

Offer them as one `AskUserQuestion` question (4 options plus Other). This is interview question `topic`.

Angle formulas that rank (pick the one that fits the evidence):

| Formula | Example shape |
|---|---|
| Hidden mechanism | "Why X actually happens" / "What really happens when X" |
| Expectation vs reality | "X: what you think vs what's true" |
| Comparison | "A vs B vs C" (works well as a stick-fable or neon chart) |
| Number list | "3 X that Y" (only when the items are real and sourced) |
| Mistake | "Stop doing X" / "You're doing X wrong" |
| Transformation | "From X to Y in N steps" |
| Origin | "The X nobody tells you about" |

## 2. Research the chosen angle

- Facts come from primary sources and are copied into `brief.facts` with their source.
- Top 3 videos on the angle: run `yt subs` on each and read their hooks and their structure. Note what they **miss**; that gap becomes your edge.
- Nouns to visualise: list every concrete object the script will name. Each one becomes an asset search or a drawn element.

## 3. Write the script (you, the main agent)

1. **One sentence.** "This video shows ___ so that the viewer ___." If you can't write it, go back to step 2.
2. **Beats.** Use 4–8 meaning beats from the style recipe: hook, claim, human moment, concept, escalation, turn, payoff, end. Give each beat **one idea** and **one hero visual** (an object, a diagram or a screen moment).
3. **Hook (0–2 s).** Use the chosen hook kind.
   - Name the tension, not the subject.
   - It is the first line of the narration *and* the first frame.
   - Never start with a greeting, the channel name or "In this video".
4. **Narration.**
   - Speech rate by delivery: about 3 words/s (punchy), 2.4 (calm), 2.2 (cinematic).
   - A 30 s Short holds 65–85 words.
   - Use short sentences and second person ("you").
   - Use concrete verbs. Put one surprising fact or number (sourced) by 40% of the runtime.
5. **Open loop.** Promise something in the hook that is paid off in the payoff beat ("…and the third one is why most people fail").
6. **Payoff and end.** Resolve the loop. End with a CTA in one line, or a loop line that flows back into the hook.
7. **On-screen words.** For each beat, pick 1–4 words from its narration as the hero text, and choose the one accent word.
8. **Read it aloud** at the target pace and time it. Cut 10%.

Write the script into `storyboard.yaml` (`say:` per scene), and its one-sentence summary into `brief.summary`.

## 4. Titles, description and tags (from the same research)

- **5 titles**, using the formulas that rank in `research/trends.md`. Each is at most 60 characters, with the hook word first. Put numbers in digits. Don't write clickbait the video doesn't deliver.
- **Description.** The first 2 lines restate the hook and the promise, followed by chapters (for long videos), sources and credits.
- **Tags.** Up to 15: the top recurring tags of the ranking videos, plus your own specific nouns.
- **Hashtags.** 3–5: one broad, two niche, and the format (#shorts).


---


## references/reference-breakdowns.md


# Reference breakdowns: what top Shorts actually do

These are forensic breakdowns of 7 reference videos (6 Shorts and one 9-minute tutorial). Each was made with `scripts/dissect.py`, which extracts frame sheets at 4 fps, a spectrogram, classified sound events and voice/music balance numbers. They set the quality bar. We copy **craft**: timing, layering, sound placement and mix. We never copy content, footage, characters or music.

To dissect a new reference:

```bash
fishvideo yt download <project> <url> --purpose reference
python scripts/dissect.py <project>/reference/src.mp4 --out <project>/reference/dissect --fps 4 [--vtt subs.vtt]
```

Then read every `sheets/*.jpg`, `spectrogram.png` and `timeline.md`, and write `reference/analysis.md` using the nine sections in [reference-video analysis](reference-video-analysis.md).

## The numbers that repeat across all of them

| Element | What the good ones do | What a generic pipeline does wrong |
|---|---|---|
| Visual change rate | Something new every **0.75–1.2 s**. Hard cuts are rare (1–7 per 15 s), and the camera or an element is moving about 80% of the time. | One static layout per 4–8 s. |
| Hook | Frame 0 already has the subject or a title slam. There is no logo and no fade-in. | A title card, then a fade-in. |
| Depth | Real cut-outs with **contact shadows** (offset 10–20 px, blur about 25 px, key light top-left), paper or grain texture, a vignette of 35–45%, and glow on dark backgrounds. | Flat vector boxes on a flat colour. |
| Type | One family (heavy grotesk, Inter 800/900), about 3× size contrast between the hero word and small words, one accent colour, and a blur-to-sharp or scale-0.8→1 word reveal staggered about 6 frames. | Uniform sizes and many colours. |
| Transitions | Object-as-portal: zoom-through with motion blur, a 2-frame white flash, then a pull-back. Also split reveals, rack-focus defocus and 3D flips. | Crossfades. |
| Swish SFX | **140–250 ms**, centroid 5–6 kHz, starting **50–120 ms before** the move, at −18 to −28 dBFS. | Long 0.6 s whooshes, late. |
| Pops and ticks | 40–140 ms, on element arrival, −20 to −30 dB (subliminal, felt rather than heard). | Loud pops on everything. |
| Risers | 0.75–1.2 s, **ending exactly on** the reveal, often cut dead into silence or an impact. | A riser that starts at the reveal. |
| Impacts | 60–120 Hz thump or boom with a 240–500 ms tail, on flash cuts and title slams, −12 to −15 dBFS. | None, or a thin click. |
| Voice | Measured at −15 to −16 LUFS (short-term while speaking), compressed, presence-lifted, short room. Pace runs 2.3–2.9 words/s; motivational and story pieces run faster. | −20 LUFS and dry. |
| Music bed | Present from **frame 0**, **8–12 dB under the voice**, sidechain duck of about **6 dB** (attack 5–20 ms, release 250 ms), 250 Hz–3 kHz carved out of the music, and a fade over the last 1–2.5 s. | Either inaudible (−5 dB bed then ratio 6 duck) or masking the voice. |
| SFX ducking | SFX are **never ducked** by the voice; they are placed in the gaps between words. | SFX on the music bus, so they get ducked. |
| Master | Integrated loudness of the references ranges from −10 to −29 LUFS. Several are badly mastered (too quiet or clipped). | Copying their loudness. |
| End | A loop back to frame 0, or a 0.3–1.3 s hold with silence. | A long logo outro. |

## The seven, one by one

### R1: Paper-craft documentary collage (14 s, male documentary voice)
- **Look.** Crumpled paper from #C8C8C8 at the centre to #707070 at the edges. B&W halftone photo cut-outs sit in chamfered (octagonal) cards with a thin outline offset. Labels are bold grotesk with hand-drawn curved arrows. Every object has a contact shadow.
- **Motion.** One continuous virtual camera. A card fades in at scale 0.9→1 over 12 frames. At 2.25 s the card **splits** down the middle (expo-out) to reveal a brain with hairline connectors. Small words rise while the hero word ("mind", about 9% of frame height, ExtraBold) resolves from blur. A **rack-focus** into an eyeball follows. A hard cut goes to near-black, where a **spotlight cone** shows text only inside the light. A disc scales down to a laser line.
- **Sound.** Airy swishes of 0.14–0.16 s at −27 dB on the split, the cut and the cone sweeps. A tick of 100 ms. A sub swell under the split. Two soft thuds as the line closes. A dark drone bed with no drums. Voice runs at 2.3 words/s.
- **Lesson.** Subliminal SFX, a monochrome palette and real assets with light and shadow make it feel expensive.

### R2: Clean studio infographic (10 s, punchy US male TTS)
- **Look.** A radial grey studio gradient. A B&W cut-out subject with **neon-lime** (#B6FF7A) accent spheres orbiting. Then a **3D coin flip** carousel landing five logos one by one, each labelled on an arc.
- **Motion.** Title words slide in with a blur trail. Subtitle words scale 0.8→1 with a 4 px blur over 5 frames, staggered 6 frames. The cut-out rises from the bottom. A slow push-in from 1.0 to 1.08 with a bob. Each logo lands within about 3 frames of the word that names it.
- **Sound.** A title slam at 0.02 s (−10 dB). A rising whoosh plus a thud for the figure. A swish (150 ms) and a click per coin flip. A bass-heavy groove that is too loud: voice sits 3 dB *below* the bass. **Do not copy that balance.**

### R3: 3D camera promo (29 s, Spanish-accented human male voice, 2.9 words/s)
- **Look.** Light vignette from #F2F2F2 to #808080. A clock rolls into a coin through a 3D Y-flip **match cut**. A chalkboard with struck-through words and a yellow "NOW" sticky note. A squircle card with corner brackets. Icon tiles **zoom through** (300% with motion blur) into a road scene.
- **Sound.** Sparse swishes (140–200 ms, −27 to −33 dB) on moves. The biggest impact (−15 dBFS) lands exactly on a white-flash cut. The music carries most transitions and stops dead at the end.
- **Lesson.** Each scene hands off through an object; there is no "cut, then new layout".

### R4: Neon "expectation vs reality" chart (8 s, no voice)
- **Look.** #111 with a 24 px grid, dust and a vignette. A glowing white line draws straight, then **zigzags** through glow pictograms that pop at each vertex (scale 0→1.1→1 over 6 frames). A yellow lightning arc trails the draw head. Neon amber labels have a 20 px glow.
- **Sound.** A drone that builds sub energy over the clip. A swish on each line start. A glassy **zap** (40 ms, >5 kHz) on each vertex. A thump on the slide-up. A 0.75 s **riser into an impact**, then hard silence.
- **Lesson.** Sync every SFX to a vertex or arrival, and end with silence after the impact.

### R5: Stick-figure fable (14 s, music only)
- **Look.** Three stacked lanes, white round-cap stick figures, tiny red accents. Animation is **stepped at about 12 fps**. A one-line punchline ("Hell nah.") appears near the end. A fade to black that reads as a loop.
- **Sound.** A dense trap/phonk loop. One bass impact on the story beat. No voice and no SFX.
- **Lesson.** Some formats want **no** SFX. Silence is part of the style, so `sfx: minimal` exists for this.

### R6: Geometric glow metaphor (12 s, calm cinematic male TTS, about 150 wpm)
- **Look.** Black, with a glowing #F5F5F5 sphere as the protagonist. It drops into a box ("trapped"), then an **expo-out zoom-through** into a rotating tunnel of about 25 nested squares, then a gradient cone with an infinity glyph, a starfield, a tree growing with trails, and a **ring bloom** of 10 circles. Captions are word by word at the top. They lack a backing plate on busy frames, which is a flaw to fix.
- **Sound.** A **sub riser cut dead** into the zoom-through. An air whoosh with a sub swell on the zoom. A pop on each new shape. A sub bloom as the cone sinks. A boom and a pop as the tree vanishes. Ring spawn is ticks at about 16 per second with rising pitch. No drums: drones and swells sit in the word gaps, and the bass ducks about 7 dB under the voice.
- **Lesson.** Morphs instead of cuts, and sound that swells into the gaps between words.

### R7: Tutorial, "AI motion graphics with a master prompt" (9 min, screen recording)
- **Method taught.** A **locked style bible** (palette hex, one type family, one recurring motif, format) kept separate from per-beat content. The script is split into 4–8 *meaning beats* (hook, claim, human moment, abstract concept, escalation, turning point, payoff). Each beat gets a block: stage, hero subject, motif, camera/transition, timed lines on a 2 s grid, **sound design**, and a **final-frame spec** that bridges into the next beat. Intensity must keep rising to the finale. We implement this as `style` + `beats` in the storyboard and the `lint` checks.
- **Production.** The recording sits in a white-bordered inset on a solid brand background. It uses 1.5–2× punch-ins (soft, because it was recorded at 1×, so **record at 2× scale**), a glow cursor, "STEP N" cards and pops on zooms and clicks. Voice sits about 3 dB over the bass, with a 6 dB duck. About 40 whooshes in the first 2 minutes. Screen segments run 20–60 s with only zoom changes.
- **Weaknesses to beat.** Blurry zooms, no click ripples, no spotlight and no captions in screen segments.

## Mapping to Fish Video features

| Reference trait | Feature |
|---|---|
| Paper, halftone cut-outs, arrows, split reveal, cone | `style: paper-collage`; scenes `collage`, `split`, `cone`; `texture: paper`; `assets cutout` |
| Studio kinetic plus coin carousel | `style: studio-kinetic`; scenes `kinetic`, `coinflip`, `orbit` |
| Object-as-portal transitions | transitions `zoom`, `flash`, `flip`, `defocus` |
| Neon chart | `style: neon-diagram`; scene `neonline`; cues `zap`, `riser` → `impact` |
| Geometric glow metaphor | `style: glow-metaphor`; scenes `glowshapes`, `tunnel`, `ringbloom` |
| Screen tutorial | `style: screen-explainer`; scene `tour` with `frame: inset`, spotlight, glow cursor, click ripple, STEP cards; `fishvideo screenrec` |
| Real b-roll | scene `footage` with `assets search/get/clip` and `yt clip` (CC-BY only) |
| Word-pop captions | `captions.style: pop` |
| Mix balance | `config/defaults.yaml` `mix:` (stems, see [sound design](sound-design.md)) |


---


## references/style-recipes.md


# Style recipes

A style is a **locked style bible** plus a **beat pattern**. It defines the look, type, motif, sound and pacing. The machine-readable version is in [config/styles.yaml](../config/styles.yaml). The interview offers these styles; the chosen one sets defaults the user can override (palette, texture, captions, SFX density, music mood). The scaffold builds the beat skeleton. **You write all the words.**

| Style | Looks like | Sounds like | Change every | Best for | From reference |
|---|---|---|---|---|---|
| **paper-collage** | Crumpled grey paper; B&W halftone photo cut-outs with contact shadows; chamfered cards; bold grotesk labels and hand-drawn arrows; split reveals; rack-focus; a spotlight cone on black | A dark drone with no drums; subliminal swishes (−27 dB, 150 ms); a sub swell under reveals; soft thuds at the end; documentary narrator | 1.1 s | facts, psychology, history, "how X really works" | R1 |
| **studio-kinetic** | Light studio gradient; words slam in with a blur trail; cut-out subject rises; glow orbit; 3D coin carousel; slow push-in and bob | Title impact at 0.0 s; swish and click per coin flip; a groove bed **10 dB under the voice** (not the reference's muddy balance) | 0.9 s | people, companies, products | R2 |
| **neon-diagram** | #111 grid; glowing line draws; glow icons pop at vertices; electric arc; neon labels | A building drone; a zap on every vertex; a riser into an impact, then silence | 0.75 s | expectation vs reality, growth, processes, data | R4 |
| **glow-metaphor** | Black; one glowing sphere as the hero; morphs, a nested-square tunnel zoom-through, a starfield, a ring bloom | Sub risers cut dead into zooms; air whooshes; pops on shapes; swells in the word gaps; calm cinematic voice | 1.2 s | motivation, habits, mindset | R6 |
| **screen-explainer** | A real recording in an inset frame on a brand background; glow cursor; click ripple; 1.6–2× focus zoom; spotlight; STEP cards | A click SFX per click; a swish per zoom; lo-fi bed; clear narrator | 2.0 s | app and web tutorials, demos | R7 (minus its weaknesses) |
| **whiteboard-explainer** | Grid paper; marker-drawn diagrams built live; Caveat hand lettering | Marker scribbles; pops; calm pulse | 1.5 s | mechanisms, architecture | v1 ImuTechz |
| **stick-fable** | Black lanes; simple figures performing the same action and diverging; one punchline | Music only (phonk/trap loop); one impact on the story beat; no voice | 1.8 s | A vs B vs C comparisons, loops | R5 |

## The bible lock (enforced while you write)

- **One palette.** Use the interview palette or the brand hex values. The accent appears in every scene, on exactly the word or shape that matters.
- **One type family**, with about a 3× size contrast between the hero word and small words.
- **One motif**, the style's `motif`, which recurs in every beat.
- **Intensity rises** until the payoff. Raise `energy` scene by scene, then drop to 0 on the end card (or hard silence after the final impact).
- **Final-frame hand-off.** The last frame of each beat sets up the next one: the object that becomes the portal, or the word that becomes the next title.

## Screen-explainer rules

Never stay on the raw screen for more than 12 s. Zoom 1.6–2× on clicks and typing, at most 8 zooms a minute. Show the finished result by 3 s. Details and numbers are in [trending analysis](trending-analysis.md).

## Mixing styles

A style can borrow a scene from another style for one beat. For example, a `paper-collage` explainer can use a `neonline` chart for its proof beat if it keeps the palette and type. Never mix more than two styles in one video.


---


## references/storyboard-templates.md


# Storyboard templates

A storyboard is a list of scenes. Each scene does **one** job. The templates below give working scene orders. Bar counts assume 120 BPM, where one bar is 2 s. They are *starting* counts: the real counts come from the narration (`fishvideo storyboard`).

## Picking a structure

Ask, in this order:
1. **What should the viewer do or believe afterwards?** If they should *understand* something, use an explainer. If they should *try* or *buy* it, use a promo or tour. If they should *notice* something new, use an announcement.
2. **Is there something real to show?** A site or app means at least one `tour` scene, because live proof beats claims. Without one, use the `whiteboard`, `diagram`, `stat` and `terminal` scenes.
3. **How long?** As a rule of thumb:
   - 15 s: hook, one idea, CTA.
   - 30 s: hook plus three beats.
   - 60 s: hook plus five beats.
   - 2–3 min: hook, three chapters of 3–5 scenes each, then the end.
4. **Which platforms?** Build the longest cut first. Shorter cuts *subtract* scenes (`platforms: [youtube]` on the scenes that only YouTube gets). Nothing is re-written per platform except, sometimes, the hook.

## 1. Explainer (60 s ≈ 30 bars)

| # | scene | type | bars | job |
|---|---|---|---|---|
| 1 | hook | kinetic | 2 | the outcome or the tension, ≤ 8 words |
| 2 | title | title | 2–3 | name + one-line promise + chips |
| 3 | idea | bullets / diagram | 3 | the 3 parts, at a glance (`chapter: 01 · What it is`, `wipe`) |
| 4–7 | how-1…how-4 | whiteboard | 3 each | one mechanism per board, with a takeaway line (`chapter: 02 · How it works`) |
| 8 | proof | stat / chart | 2–3 | one sourced number |
| 9 | get | terminal / tour | 3 | how to start |
| 10 | end | end | 2–3 | brand, tagline, links |

## 2. Product tour (45 s ≈ 22 bars)

| # | scene | type | bars | job |
|---|---|---|---|---|
| 1 | hook | kinetic or tour (bare, zoomed) | 2 | "Here's X doing Y" |
| 2 | title | title | 2 | name + promise |
| 3–6 | tour-1…tour-4 | tour | 3–4 each | one page / one feature each, with **one** callout per scene |
| 7 | cta | terminal / bullets | 2 | install, sign up, or the price (sourced) |
| 8 | end | end | 2 | |

Capture plan: one shot per tour scene. Each shot runs about 1.5× the scene length, so the scene can start late and run at `speed: 1.2–1.6`.

## 3. Promo / launch (30 s ≈ 15 bars)

| # | scene | type | bars |
|---|---|---|---|
| 1 | hook | kinetic | 2 |
| 2 | feature | steps (3 cards) | 4 |
| 3 | proof | stat | 2 |
| 4 | see it | tour | 4 |
| 5 | end | end | 3 |

## 4. Listicle ("5 things…", 60 s)

hook (kinetic) → five scenes of 2–3 bars each, all of one type (bullets with a single item, stat, whiteboard) and each with `chapter: "1 / 5"` → recap (bullets) → end.

## 5. Story (90 s)

then (kinetic + image) → the problem (whiteboard) → the turn (quote / kinetic) → now (tour) → proof (chart) → next (bullets) → end.

## 6. Announcement (20–30 s)

kinetic "Introducing …" → title (with a version chip) → 2–3 bullets of what's new → terminal (upgrade command) → end.

## The ImuTechz explainer, as a storyboard (2:40, 80 bars)

```
hook(3) title(4) | 01 process(5) ui(4) | 02 fishTitle(2) fishMmap(3) fishQuant(3) fishInt8(3) fishAvx(3) fishTokens(2) fishKv(3) fishPrefetch(3) models(3)
| 03 agents(6) | 04 look(2) tourHome(4) tourImu(3) tourRd(3) tourDocs(6) tourDir(5) tourTheme(3) | 05 install(4) end(3)
```

- The LinkedIn 4:5 cut (1:12, 36 bars) keeps: hook, title, ui, fishMmap, fishAvx, fishKv, agents, tourHome, tourDocs, install and end.
- The full reproduction brief is in `examples/imutechz-explainer/`.

## storyboard.yaml anatomy

```yaml
title: Acme in 60 seconds
bpm: 120
hud: {chapter: true, rail: true}      # chapter label top-right, progress rail
grain: true
background: dots                      # dots | plain | wash
custom_scenes: [scenes/custom.js]     # optional project scene code
scenes:
  - id: hook
    type: kinetic
    energy: 1                         # 0 calm … 3 full (music)
    params: {lines: ["Boil water", "~without a flame.~"]}
    say: "Boil water, without a flame."
  - id: how
    type: whiteboard
    chapter: "01 · How it works"
    transition: wipe                  # cut | fade (default) | wipe (chapter cuts)
    platforms: [youtube, linkedin]    # omit = every platform
    params: {tag: How it works, title: "Sun → mirror → pot", flow: [Sun, Mirror, Pot], take: "No fuel, *no smoke*."}
    say:
      - "Sunlight hits a curved mirror."
      - {text: "The mirror focuses it on the pot.", at: 6}    # at = beat, optional
  - id: end
    type: end
    params: {name: Acme, tagline: "Boil with the sun.", links: [acme.dev], cta: "Order at acme.dev"}
    say: "Acme. Boil with the sun."
captures:
  - {id: home, url: "https://acme.dev", auto: {seconds: 8}}
thumbnail: {kicker: "ACME", title: "Boil without fire", accent: fire, badge: "60-second explainer", shot: home}
facts_ok: []                          # numbers you have verified out-of-band (prefer brief.facts)
```

## Scene-writing checklist

- [ ] One idea per scene, and the `say` lines carry exactly that idea.
- [ ] The on-screen words are a subset of the spoken words, or a label for the picture.
- [ ] Headline ≤ 8 words. Bullets ≤ 7 words. Kinetic lines ≤ 6 words.
- [ ] Every number has a source (`source:` on stat/chart; `brief.facts` for numbers in the narration).
- [ ] Tour scenes have exactly one callout at a time.
- [ ] The last scene is `end`.


---


## references/scene-catalogue.md


# Scene catalogue

## Scene types at a glance

Pick by what the beat has to do. Everything else on this page is detail. A scene type needs its required params, and all others are optional. In tall mode (9:16) every type fills the frame down to the caption band.

| type | required params | best used for | looks weak when |
|---|---|---|---|
| `collage` | `items: [{src, kind: cutout}]`, `title` | a real object with one idea; hooks; paper styles | no real asset; more than 3 labels |
| `split` | `front: {src, title}`, `back: {title}`, `at` | a reveal or turn ("but...") | the two sides say the same thing |
| `cone` | `title` | a dark, quiet turn; one line | long titles; repeated use |
| `coinflip` | `items: [{icon, label}]`, `title` | 2-4 options or ingredients | items need explaining |
| `orbit` | `center`, `title` | one thing surrounded by many | no real centre |
| `neonline` | `steps: [{label, icon}]`, `title` | a loop or a sequence in 3-5 words | labels longer than one word |
| `glowshape` | `title` | an abstract, cinematic idea | nothing to hold on to |
| `tunnel` | `title` | "deeper", repetition, transitions | used as content |
| `ringbloom` | `value` | a value with a many-parts feel | no number |
| `kinetic` | `lines: ["..", ".."]` | hooks and payoffs; 2-6 words a line, `*one accent*` | a full sentence on a line |
| `title` | `title` | an opening card with a real brand | as a hook (too calm) |
| `stat` | `value` (sourced), `label`; `prefix`, `suffix`, `of`, `visual: grid\|ring\|bar\|none` | one striking figure | three in a row; a label over 12 words |
| `chart` | `data: [{label, value}]`, `title`, `source` | 2-6 comparable values | one value (use `stat`) |
| `diagram` | `nodes: [{label, sub}]`, `title`; `morph: true` | a chain of 2-4 steps | over 4 nodes; long subs |
| `whiteboard` | `items` or `flow` | hand-drawn explaining | little to draw |
| `bullets` | `items: [".."]`, `title` | 2-4 short points | over 4 items; sentences |
| `steps` | `items: [{t, d}]`, `title` | how-to, in order | over 4 steps |
| `compare` | `left` and `right: {label, items}`, `title` | before / after, us / them | over 3 rows a side |
| `quote` | `text`, `by` | a real quotation | over 25 words; unattributed |
| `terminal` | `lines: [{cmd} \| {out}]` | developer content | non-developer audience |
| `image` | `src`; `title`, `caption`, `credit`; `treat: none`, `frame: cover\|card` | a real photo that proves the point | a dull photo; no title |
| `footage` | `clip` or `frames` | real moving pictures | stock with nothing happening |
| `tour` | `shot` (a capture) | software and websites | no clicks to follow |
| `stepcard` | `n`, `title` | a step label inside a tour | outside a tour |
| `end` | `cta`, and `name` or `tagline` | the close; loops back to the hook's hero | long taglines |
| `thumbnail` | `title` (3 words), `accent`; `image` | the storyboard's `thumbnail:` block | over 5 words |

**Good hooks:** `collage` (a real object), `kinetic` (a short claim with one accent), `split` (a reveal), `stat` (a surprising sourced figure), `footage` (real pictures). **Weak hooks:** `title`, `bullets`, `steps`, `diagram`, `chart`, `end`.

Every scene type lives in `scripts/stage/scenes/*.js`. Each one works at 16:9, 1:1, 4:5 and 9:16, because it lays out inside the platform safe area (`c.safe`) with flex boxes and fits its headlines with `L.fit`.

On-screen text supports light markup:
- `*accent*` colours a word with the accent.
- `~serif italic~` sets a word in serif italic.
- `**bold**` makes it bold.
- `\n` breaks the line.

Timing is in seconds of *scene time*. `c.beat` is 0.5 s at 120 BPM, and entrances land on beats.

## The scene contract

```js
window.SCENES.myScene = {
  build(root, c) {            // once per activation: create DOM under root, return state
    const st = {};
    const box = L.H.box(root, c, { center: true });       // flex column inside c.safe
    st.h = L.H.headline(box, c.p.title, c, { lines: 2 });   // kinetic words, fitted
    return st;
  },
  update(st, t, c) {          // PURE function of t (any order, any worker)
    st.h.at(t, c.beat);       // words rise in from beat 1
    L.H.outAll([st.h.node], t, c);
  },
  cues: (c) => [{ lt: c.beat, type: "hit" }],   // SFX cues in scene time
};
```

`c` has these fields:

| field | meaning |
|---|---|
| `W, H` | design size |
| `u` | `min(W,H)/1080`, the type scale |
| `mode` | `land`, `square`, `port` or `tall` |
| `P` | `true` for portrait (`port` or `tall`) |
| `safe {x,y,w,h}` | the layout area, already above any burned-in captions |
| `dur` | scene length |
| `beat, bar, fps` | timing |
| `p` | the scene's params |
| `brand, theme, lang` | from the brief |

### Helpers

- **`L.words(parent, text, cls)`**: words rising out of a mask. Animate with `.at(t, t0)` and `.outAt(t, t1)`.
- **`L.letters`**: letters popping in one by one.
- **`L.typeAt(node, text, t, t0, cps)`**: typed text with a caret.
- **`L.enter(el, t, t0, d, "rise"|"pop"|"left"|"fade"|"blur")`**: one-line entrances.
- **`L.rough(svg, "rectangle"|"circle"|"line"|"path"|"linearPath", args, opts)`**: a rough.js drawable. `.draw(p)` reveals it by stroke-dash and returns the pen tip.
- **`L.pen(svg).at(tip, vis)`**: the marker that follows the tip.
- **`L.Win(parent, {shot, url})`**: a browser window playing a capture. `.layout(x, y, w, maxH)` sizes it, `.at(frame, cam)` shows a frame, and `.map(pagePoint, r)` converts page coordinates.
- **`L.cam(shot, keys, f)`**: camera keyframes, with `{m|f, d, z, x, y}` (marks or frames). If `x` and `y` are omitted the camera follows the smoothed cursor.
- **`L.logo(parent, brand, size)`**: the brand logo file, or a typographic badge when there is none.
- **`L.fit(node, maxPx, minPx, w, h)`**: binary-searches the font size until the text fits.
- **`L.md(text)`**: the markup above, escaped.
- **`L.rng(seed)`**: deterministic random numbers. Use it instead of `Math.random`.

## The library

### `title`
The brand moment.
- Params: `kicker`, `title`, `subtitle`, `chips[]`, `logo` (default `true`).
- Use it once, near the start.
- Cues: swell, hit, tick, pops.

### `kinetic`
Big words that rise in line by line. Hooks, turns of thought, the thesis.
- Params: `lines[]`, `kicker`, `align` (`center` or `left`), `sub`, `size`, `accent`.
- In tall mode or a styled video, each line is fitted to the safe width, words slam in on a spring and kick the camera, and a line lands on its first word when that is spoken. `*accent*` works; with none marked, the last word takes the accent (`accent: false` turns that off).
- Keep each line ≤ 6 words. A `~serif~` line gives contrast.

```yaml
- {id: hook, type: kinetic, params: {lines: ["AI that lives", "~on your machine.~"]}, say: "AI that lives on your machine."}
```

### `bullets`
Numbered points that arrive one per narration beat.
- Params: `kicker`, `title`, `items[]`, `source`.
- 3–5 items, each ≤ 7 words.

### `steps`
Numbered cards: a row in landscape, a column in portrait.
- Params: `kicker`, `title`, `items: [{t, d}]`.

### `stat`
One number counting up, with a label and a **required** source.
- Params: `value`, `prefix`, `suffix`, `decimals`, `label`, `source`, `grouping`, `of`, `visual`.
- In tall mode or a styled video, the number is fitted to the safe width and counts up on a spring, over a visual that fills the room below. `visual`: `grid` (out of 100 for a percentage or with `of`, else one tile per unit up to 200), `ring`, `bar` (filled to `value / of`; without `of` it only sweeps), or `none`. The default is `grid` for a percentage or a whole number up to 200, else `bar`.
- The value must come from `brief.facts` or `research/`.

### `chart`
Animated bars: vertical in landscape, horizontal otherwise.
- Params: `title`, `data: [{label, value}]`, `unit`, `prefix`, `decimals`, `highlight` (a label), `source`, `kind` (`bar` or `hbar`).
- 2–6 bars. Highlight the one that matters (by default, the last one).

### `diagram`
Boxes joined by arrows (rough or clean), 3–5 nodes.
- Params: `title`, `nodes: [{label, sub, hi}]`, `caption`, `style` (`rough` or `clean`).
- Use it for architecture or a pipeline.

`morph: true` on `diagram`: one box re-labels and grows into each next step (`nodes[].label`, `sub`), with the text swap and the growth on springs.

### `whiteboard`
The hand-drawn explainer board: grid paper, a Caveat title, rough.js strokes drawn by a marker pen, and a highlighted takeaway.
- **Fast path:** `flow: [A, B, C]` plus `notes: [...]` gives boxes and arrows. They lay out horizontally in landscape and vertically in portrait.
- **Full control:** `items` in a 1600×700 board space, each timed in beats. Kinds:
  - `rect: [x, y, w, h]`
  - `circle: [cx, cy, d]`
  - `line: [x1, y1, x2, y2]`
  - `arrow: [x1, y1, x2, y2]`
  - `path: "M… C…"`
  - `note: "text"` with `x`, `y`, `size`, and `acc` for the accent colour
  - `cells: {x, y, cols, rows, w, h, gap}`: memory cells lighting up
  - Common keys: `at` (beat), `dur` (beats), `color` (`ink`, `accent` or `muted`), `fill` (`accentSoft`, `accent` or a CSS colour), `seed`.

```yaml
- id: mmap
  type: whiteboard
  params:
    step: 1
    tag: "Inside FISH"
    title: Memory-map the GGUF
    take: "The file is *mapped*, not copied."
    items:
      - {path: "M60 60 L330 60 L400 130 L400 640 L60 640 Z", at: 0.5, dur: 1.4}
      - {note: "model.gguf", x: 98, y: 92, size: 34, at: 1.6, dur: 1}
      - {arrow: [440, 350, 760, 350], at: 4, dur: 0.9}
      - {note: "mmap", x: 540, y: 240, size: 64, acc: true, at: 4.4, dur: 0.8}
      - {rect: [800, 80, 740, 560], at: 2.4, dur: 1.4}
      - {cells: {x: 840, y: 120, cols: 9, rows: 6, w: 62, h: 66, gap: 14}, at: 6, dur: 4.4}
```

**When to use it:** for a *process*, where seeing it built teaches more than seeing it finished.
- Avoid it for lists: use bullets.
- Avoid it for UI: use tour.

### `compare`
Two columns: the old way (dimmed) against the new way (accent border).
- Params: `title`, `left: {label, items}`, `right: {label, items}`.

### `quote`
A large serif quote with attribution.
- Params: `text`, `by`, `role`.
- Real, sourced quotes only.

### `terminal`
Commands typed live, with output and comments.
- Params: `kicker`, `title`, `lines: [{cmd}|{out}|{comment}]`, `chips[]`, `cps`.

### `tour`
A recorded live shot inside a browser frame:
- the camera zooms on `cam` keys or follows the cursor
- the drawn cursor, click ripples and key-combo badges replay what was recorded
- callouts are anchored to page coordinates
- a side panel (landscape) or top panel (portrait) holds the eyebrow, title and bullets

Params: `shot`, `url`, `from` (a frame number or a mark), `speed`, `eyebrow`, `title`, `bullets`, `cam`, `callouts: [{text, m|t0, d, len, page: {x,y} | x,y, align}]`, `full`, `cursor`.

```yaml
- id: docs
  type: tour
  params:
    shot: docs
    url: example.com/docs
    from: index
    speed: 1.0
    eyebrow: Documentation
    title: "23 guides, *one search*."
    cam: [{m: index, z: 1}, {m: ctrlk, d: 24, z: 1.32, x: 720, y: 300}, {m: landed, d: 10, z: 1}]
    callouts:
      - {text: "“streming”, a typo, *still found*", m: type, d: 30, len: 2.6, page: {x: 1060, y: 120}, align: right}
```

### `image`
A still image with a slow push-in.
- Params: `src` (a path relative to the project), `title`, `caption`, `credit`, `fit`, `push`, `pan`, `treat` (`none` turns the treatment off), `frame` (`cover` or `card`), `contrast`.
- In tall mode or a styled video it takes the style's treatment. On a paper ground it is a large halftone card with a shadow. Otherwise the photo covers the frame on a far plane (parallax), graded, with a scrim, a glow frame and a slow Ken Burns.
- Use only images the user owns or provided.

### `end`
The brand close.
- Params: `name`, `tagline`, `links[]`, `cta`, `logo`, `bg`, `loop`, `size`, `lines`.
- In tall mode, or in any styled video, it is the strong close: a huge title fitted to the safe width, a CTA pill, and a textured background from the brief. The hook's hero waits below and grows back to its hook pose over the last 0.85 s, so the last frame leads into the first (`loop: false` removes this, and the film-end fade comes back).
- The hero rests clear of the CTA pill (the title shrinks if it needs the room). The title clears when the hero starts growing; the CTA stays until the last 0.3 s.
- `lines`: a number is the title's line limit; a list is the title's lines. With no `name` and no brand name, the tagline becomes the title. With no hero to return to, the block is centred inside a slowly drawn accent ring. Ink follows the ground on every style.
- A badge appears only with a logo file or an explicit `brand.name`. `brand.name` falls back to the video title, and the timeline's `brand.explicit_name` tells the two apart. `logo: true` forces it.
- Music energy 0 (the outro hit and bell figure).

### `thumbnail`
A static 1280×720 composition (the `thumbnail:` block in the storyboard).
- Params: `kicker`, `title` (2–5 words), `accent` (one word to colour), `badge`, `shot` or `image`.

## Transitions and HUD

- **`transition`** sets how a scene arrives:
  - `fade` (default): a 0.22 s soft cross through the background, with a little blur on the way out.
  - `wipe`: an ink panel with an accent edge sweeps across, centred on the cut. Use it for chapter changes; it gets a whoosh.
  - `cut`: a hard cut.
  - `zoom` (portal): the camera flies into scene A (a real camera z-move plus motion blur over 0.32 s), a 2-frame white flash covers the cut (no wash after it in a styled video), and scene B starts tight and pulls back over 0.75 s on cubic-bezier(.16,1,.3,1). Cues: whoosh (0.42 s before), flash, impact.
  - **No blank frames (styled videos):** a scene holds its content up to the cut unless the transition is `fade`, and entrances run ahead of the clock by `lead` seconds (0.45, 0.6 on the first scene, 0 for tour and footage), converging on real time by 1.2 s, so a scene opens with its subject up. `lead` is a scene field.
  - `flash`: a hard cut under a 2-frame white flash; B settles from 1.06 to 1. Cues: flash, hit.
  - `defocus`: a rack-focus cross-dissolve. B is sharp at once while A blurs to 14 px and fades over it for 0.36 s, so no frame is empty. Cue: whoosh.
  - `flip`: a 3D Y-flip match cut. A turns to 90°, B turns in from -90° (perspective 1400 px). Cue: swish.
  - `flood`: a colour flood. The accent closes over A in a circle for 0.2 s and lifts off B for 0.2 s, power2.inOut. Cue: whoosh.
  - `dip`: a dip cut. A hard cut with the frame darkened 40% for 3 frames. Cue: hit.
  - `iris`: a clip-path circle, 0.15 s shut on A and 0.15 s open on B, over black. Cue: swish.
  - `through`: zoom through a target. A tracks its hero to the centre over 0.6 s, then scales it through the frame in 0.35 s (power3.in, ending at 250% of the frame) and B lands sharp in 0.13 s. A scene names its target with `st.target = {x, y, share}`. Cues: whoosh, impact.
  - `match`: the outgoing hero stays on screen across the cut and flies, scales and turns to its place in the next scene (0.5 s; `params.match_dur` 0.2-1.2 on the incoming scene) while the rest of A dissolves in 0.22 s. Pairing: the same `src`, else an item with `match: true`, else the first hero (different sources cross-fade in flight). Collage items and the end card's hero take part; with no hero on either side it is a plain dissolve. Cues: swish, pop.
  - `world`: the object becomes the world. B starts as an object at 8% over black and scales to full frame in 0.35 s (power3.in). Cue: whoosh.
- **HUD**:
  - `chapter: "02 · Inside FISH"` shows a mono label at the top right (it is hidden on title, end and kinetic scenes unless `hud: true`).
  - A progress rail runs along the bottom edge.
- Grain and vignette are on by default (`grain: false` turns grain off).

## Writing a custom scene (checklist)

- [ ] Put it in `<project>/scenes/custom.js` and list it in `custom_scenes`.
- [ ] Lay out inside `c.safe`. Size type as `N * c.u`. Fit headlines.
- [ ] Make `update` a pure function of `t`. Use no timers, no `Math.random`, no CSS transitions.
- [ ] Load images through `L.setImg`, which waits for the decode.
- [ ] Return `cues` for every visible event (pop, whoosh, scribble, typing, click).
- [ ] Check it with `fishvideo stills <dir> --scenes` on every platform, then with `qa` (the text lint).

## Looks: collage, studio, neon, footage, screen explainer

The scenes below come from `scenes/collage.js`, `scenes/fx.js`, `scenes/footage.js` and `scenes/capture.js`, with helpers in `looks.js` (textures, halftone, icons, arrows, kinetic type) and `camera.js` (the 3D camera rig). Times are in beats unless the name says seconds. Positions (`x`, `y`, `w`) are fractions of the safe area.

### Cues and density

Every scene emits cues from this vocabulary: `pop`, `tick`, `click`, `swish` (a 150 ms move), `whoosh` (a longer move), `wipe`, `riser {len}` (it ends at `lt`, so `lt` sits on the reveal), `impact`, `boom`, `thump`, `zap`, `flash`, `coin`, `paper`, `ripple`, `typing {len}`, `scribble {len}`, `chime`, `hit`, `swell`. Swishes and whooshes are placed 0.1 s before the motion they belong to.

Each cue carries `lvl`: 1 = an entrance (kept by `rich`), 2 = a transition or reveal (kept by `standard`), 3 = a big moment (kept by `minimal`). The density comes from `sfx: {density: rich | standard | minimal}` in the brief or storyboard (a bare string also works), and a scene can override it with `params.sfx`.

### `collage`
Crumpled paper, B&W halftone photo cut-outs with contact shadows, chamfered or octagonal cards with an offset outline, bold Inter 900 labels with hand-drawn curved arrows, and hairline connectors.
- Params: `title`, `title_at` (beat), `title_pos` (`top` or `bottom`), `kicker`, `bg` (`paper`, `studio` or `none`), `items[]`, `labels[]`, `lines[]`.
- `items`: `{src, kind: card|cutout, shape: chamfer|octagon|rect, x, y, w, h|ratio, rot, at, tag, text, fill, halftone (default true), contrast, cell, outline (px, cut-outs), z, small}`. A cut-out is a transparent PNG/SVG that gets a white outline and a drop-shadow contact shadow (offset down-right, 24 px blur). Its opaque bbox is measured on load, so `w` means the visible object, whatever padding the file has.
- Layout: the title is sized first (paper strips in portrait; `title_strip` overrides that). Items then get the room from the title down to the caption band, across the whole frame width, with `y` as a fraction of that room, and they shrink to fit it. In portrait the first item fills 60-85% of the frame width (`small: true` opts out), and only a subject too tall for the room tucks under the title's strips. `layout: free` keeps the old fractions of `c.safe`.
- `labels`: `{text, x, y, align, size, rot, at, on, sync, z, color (a colour or accent), bg, plain, arrow: {to: item index | [x, y], bend, at, inset}}`. `x, y` are wishes: at build time each label is measured against the object masks, the title, the other labels and earlier arrows, and moved to the nearest free space inside `c.safe`. If no spot is free, the subject slides aside to open a column, then shrinks. In portrait a label is a paper sticker of at least 4% of the frame height (`plain: true` removes the sticker). A styled video lands a label on its own word when the narration says it (`on: word` names another word, `sync: false` keeps `at`), so timing survives re-voicing.
- Arrows are hand-drawn curves of 100-300 px from the label's edge to just off the object's edge, bowing away from the object, with a proper head.
- `lines`: `[[i, j]]` or `{a, b, at}`. Each one draws a hairline edge to edge with dot ends.
- Items arrive on a premium spring, labels on a playful one. Title words use the per-word blur reveal. With a style, the subject floats and takes a small nudge whenever a label lands.
- Depth: background -800, items 0 (or `z`), title +120, arrows +150, labels +180.
- Cues: swish (title), thump (first item, lvl 3), paper + pop (other items), pop + scribble (labels), tick (lines).

```yaml
- id: hook
  type: collage
  bars: 3
  params:
    title: "YOUR BRAIN RUNS ON *AUTOPILOT*"
    items:
      - {src: assets/person.svg, kind: cutout, x: 0.5, y: 0.66, w: 0.62, ratio: 1.25, at: 1, outline: 6, z: 40}
      - {src: assets/clock.svg, shape: octagon, x: 0.2, y: 0.42, w: 0.36, ratio: 1, rot: -7, at: 2, tag: CUE, z: -120}
    labels:
      - {text: "the trigger", x: 0.02, y: 0.66, size: 54, at: 3.6, arrow: {to: 1, bend: 0.35}}
    lines: [[0, 1]]
```

### `split`
A card splits down the middle and the halves slide apart (expo-out) to reveal a second layer behind it.
- Params: `front: {src, title, tag, fill, halftone}`, `back: {src, kicker, title, sub, fill}`, `at` (the beat of the split, 3), `dir` (`v` or `h`), `shape` (`chamfer`, `octagon` or `rect`), `spread` (how far the halves part, 0.46 of the card), `bg` (`paper` or `studio`).
- A white tear line draws down the card in the 0.35 s before the split. The back layer resolves from 0.9 with a blur.
- Depth: back layer -90, halves +30.
- Cues: paper, riser (it ends on the split), swish, impact, pop, tick.

```yaml
- {id: split, type: split, bars: 2, params: {at: 2.2, front: {src: assets/phone.svg, title: WILLPOWER, tag: "PLAN A"}, back: {kicker: "PLAN B", title: "*SYSTEMS*", sub: "beat willpower every time."}}}
```

### `cone`
A near-black stage and a spotlight cone from above. The text exists only inside the light (a conic-gradient mask), with dust in the beam and a pool of light on the floor. The light switches on with a flicker. The title is hero type (85% of the safe width), and the cone fits itself to the measured type, with margin for its feather and swing, so no letter is clipped. A darker beam sits behind the type and the accent is lifted so it stays readable. A line holding an accent word waits until that word is spoken.
- Params: `title`, `sub`, `kicker`, `at` (the beat the light comes on, 1), `angle` (degrees, the minimum), `swing` (degrees, 2.5), `x` (the source position; default the centre of the type), `pool`, `size`, `lines`.
- Cues: thump (lvl 3), zap, swell, tick.

```yaml
- {id: close, type: cone, bars: 2, params: {kicker: "Habit Lab", title: "START SMALL.\nSTART *TODAY.*", at: 0.4}}
```

### `coinflip`
The kinetic studio: a radial #F4F4F4 → #D9D9D9 background, title words that slide in with a motion-blur trail, and subtitle words that scale 0.8 → 1 with a 4 px blur fade over 5 frames, staggered 6 frames. 3D coins (rotateY, perspective 1000 px, a shadow ellipse) land one by one on an arc, each with a label.
- Params: `title`, `sub`, `kicker`, `items: [{icon | src | text, label, color, face}]`, `at` (the beat of the first coin, 2), `gap` (beats between coins, 1), `arc` (0-1 sag), `subject` (a cut-out that rises from the bottom), `halftone`, `bg` (`studio` or `dark`).
- Icons: `bell flame gear star check bolt heart clock target user phone chart lock repeat trophy coffee book sun brain spark arrow up play`.
- Cues: swish (title), tick (sub), whoosh (subject), swish (before each coin) and coin (as each one lands).

```yaml
- {id: wins, type: coinflip, bars: 2, params: {title: "STACK TINY *WINS*", sub: "Small enough that you never skip.", at: 1.6, gap: 0.9, items: [{icon: clock, label: "2 min"}, {icon: check, label: Daily}, {icon: trophy, label: Streak}]}}
```

### `orbit`
A ring of glowing spheres orbiting a centre sphere that holds a word, an icon or an image.
- Params: `title`, `sub`, `kicker`, `center: {text | icon | src}`, `n` (7), `items: [{label}]`, `radius` (0.42), `tilt` (0.32), `speed` (turns per second, 0.07), `ball` (px), `bg` (`dark` or `studio`).
- Cues: pop, swell, a pop for each sphere, chime.

```yaml
- {id: orbit, type: orbit, bars: 2, params: {title: "EVERYTHING *CONNECTS*", center: {icon: brain}, items: [{label: Sleep}, {label: Food}, {label: Move}]}}
```

### `neonline`
The neon diagram: #111 with a 24 px grid (#2A2A2A at 35%), dust and a vignette. A glowing white line draws segment by segment (stroke-dashoffset, easeOutCubic). A glow icon pops at each vertex (scale 0 → 1.1 → 1 over 6 frames, with a feGaussianBlur bloom merged under the source). Labels are neon (`text-shadow 0 0 6px accent, 0 0 20px accent 60%`). An electric arc, seeded per frame and displaced by feTurbulence, trails the draw head.
- Params: `title`, `sub`, `kicker`, `steps: [{label, icon, side}]` (an automatic rising zigzag) or `points: [{x, y, label, icon}]` (0-1 of the chart box, with y = 0 at the bottom), `kind` (`zigzag` or `path`), `seg` (seconds per segment, 0.6), `at` (beat, 1.5), `color`, `glow`, `zap`, `baseline`, `node` (icon radius), `label_size`, `sync`, `pulse`.
- In portrait, `steps` become a tall side-to-side zigzag across the safe width, with node radius 92 and labels of 58 px at 1080 wide.
- A vertex lands on its own label word when the narration says it (`sync: false` keeps `at` and `seg`). Once drawn, a pulse keeps running the line and each vertex answers it (`pulse: false` turns this off).
- `follow` (default true in tall mode): the camera pushes along to the active node, one dot rides the whole path and becomes each next node, passed nodes dim, and a closing pull-back shows the whole diagram for at least 0.8 s (the last node moves earlier if it must).
- The camera's `neon` style follows the draw head.
- Cues: swish, pop (first vertex), zap {len} for each segment, pop at each vertex, chime on the last one.

```yaml
- {id: loop, type: neonline, bars: 2, params: {title: "FOUR STEPS, *ON REPEAT*", at: 1.2, steps: [{label: Cue, icon: bell}, {label: Craving, icon: flame}, {label: Routine, icon: repeat}, {label: Reward, icon: star}]}}
```

### `glowshape`
A glowing #F5F5F5 sphere on a starfield that morphs into a box (the border-radius goes from 50% to 14% while it turns and squashes).
- Params: `title`, `sub`, `kicker`, `label`, `morph` (`box` or `none`), `morph_at` (beat, 3), `stars`, `size`.
- Cues: swish, swell, pop, swish + thump (the morph), pop (the label).

```yaml
- {id: idea, type: glowshape, bars: 2, params: {title: "ONE IDEA", label: "becomes a *system*"}}
```

### `tunnel`
About 25 nested squares (each layer x0.86 and +6°), with an expo-out zoom-through, a starfield and a dark core behind the title.
- Params: `title`, `sub`, `kicker`, `layers` (25), `shrink` (0.86), `twist` (6), `fly` (layers flown through, 7), `zoom` (seconds, 1.8), `color`, `accent_every` (5), `reveal` (seconds, 1.5).
- Cues: whoosh, riser (it ends on the reveal), boom, tick.

```yaml
- {id: repeat, type: tunnel, bars: 2, params: {kicker: Then, title: "REPEAT UNTIL IT'S *AUTOMATIC*", reveal: 1.2}}
```

### `ringbloom`
N circles bloom around a ring, each 30 ms after the last, around a centre value. A shockwave ring follows when the last one lands.
- Params: `title`, `sub`, `kicker`, `value`, `label`, `n` (14), `radius` (0.36), `stagger` (seconds, 0.03), `at` (beat, 1), `dot`.
- Cues: swish, swell, tick, chime, ripple.

```yaml
- {id: ring, type: ringbloom, bars: 2, params: {title: "IN 30 DAYS", value: "30", label: "days of reps", n: 15}}
```

### `footage`
A stock clip played from pre-extracted frames: `build/clips/<clip>/f_00001.jpg`, and so on, at the project fps. Every frame goes through `L.setImg`.
- Params: `clip`, `frames`, `fps`, `from` (1-based), `speed`, `loop` (true), `fit` (`full` or `card`), `treat: [bw, halftone, grain]`, `grade` (`warm`, `cool` or `teal-orange`), `title`, `kicker`, `title_at` (beat, 1), `title_pos` (`bottom`, `top` or `center`), `push` (0.06), `shape` (card).
- `halftone` on footage is a 45° printed dot screen multiplied over the B&W frame, which keeps it cheap per frame.
- Full-bleed footage sits on an overscanned plane, so camera moves never reveal an edge.
- Cues: whoosh, swish + hit (title), pop (kicker).

```yaml
- {id: clip, type: footage, bars: 2, params: {clip: sunset, frames: 48, fps: 30, treat: [bw, halftone, grain], title: "GOLDEN *HOUR*", kicker: Footage}}
- {id: clip2, type: footage, bars: 2, params: {clip: sunset, frames: 48, fit: card, grade: teal-orange, treat: [grain], title: "IN A *CARD*"}}
```

### `tour`: screen explainer additions
These extend the `tour` above (L.Win, L.cam and the capture meta). Desktop-recorder captures with the same layout (`NNNNN.jpg` + `meta.json` with `x, y, d, c, k, s` per frame) work unchanged.
- `frame`:
  - `browser` (default): window chrome.
  - `inset`: no chrome. A 12 px white border and a drop shadow on a brand-coloured background (`bg` overrides it).
  - `none`: the bare picture.
- `fit`:
  - `contain` (default).
  - `cover`: the view fills its box, which makes a portrait crop that pans with the camera. Portrait `inset`/`none` default to `cover`.
- `cursor: glow`: a white arrow at about 1.5x with a soft halo, and glowing click ripples.
- `zoom: auto` or `{z, ease, hold, gap}`: a virtual camera that zooms to the cursor (1.6-2x) over 0.5 s before each cluster of clicks and pulls back after it, or on a scroll jump. It applies when `cam` is not set.
- `spotlight: [{m|t0, d, len, rect: {x,y,w,h} | follow: true, size, pad, r, dim}]`: everything outside a soft-edged rounded cut-out dims to 66%. The cut-out shrinks onto its target and stays inside the recording.
- `highlight: [{m|t0, d, len, rect, kind: marker|underline|box}]`: marks the text being read.
- Cues: click (lvl 2); typing {len} (runs of key presses); keycombo; whoosh; pop (callouts); swish (each auto-zoom move); swell (spotlights); scribble or pop (highlights).

```yaml
- id: demo
  type: tour
  params:
    shot: app
    frame: inset
    cursor: glow
    zoom: {z: 1.8}
    full: true
    spotlight: [{m: task, d: -10, len: 1.2, rect: {x: 280, y: 160, w: 900, h: 60}}]
    highlight: [{m: task, d: 5, len: 1.4, rect: {x: 320, y: 170, w: 300, h: 40}, kind: marker}]
```

### `stepcard`
A "STEP N" card between the steps of a screen explainer. The number slides in with motion blur and the title resolves word by word.
- Params: `n`, `title`, `sub`, `label` ("Step"), `bg` (`ink`, `accent` or `paper`).
- Cues: swish, hit, pop.

```yaml
- {id: step2, type: stepcard, bars: 1, transition: flash, params: {n: 2, title: "Add a new task", bg: accent}}
```

## Captions

`captions.style` in the brief (or the storyboard) chooses how burned-in captions look:
- `karaoke` (default): the boxed line with words lit as they are spoken.
- `pop`: Shorts-style. Group size follows `voice.delivery`: 2-3 words for high energy, 3-5 conversational, 4-6 calm. A group breaks at a sentence end, a gap of 150 ms or more, or the word limit, and a stranded "the" or "of" moves to the next group. It opens at its first word, closes at or after its last, fades for 0.12 s if it has time, then goes hard off. The type is Inter 900, uppercase, auto-fitted from 78 down to 42 px (at most 900 wide in portrait), with a heavy stroke and a shadow. The spoken word is in the accent colour, and each group scale-pops 0.85 → 1 in 4 frames. The band (10% of the height) is reserved: `c.safe` ends above it. Only scenes over moving pictures (footage, tour) get a 40% backing plate, because local contrast is low there; `params.plate: true|false` overrides that.
- `minimal`: small white type with a shadow and no box.
- `none`: no burned-in captions. The caption area is given back to the safe area.

A silent scene can carry on-screen captions with `caption: "text"` (or a list, like `say`). The words are timed at reading speed, and nothing is spoken.

```yaml
# brief.yaml
captions: {burn: true, style: pop}
# storyboard.yaml
- {id: hook, type: collage, caption: "Most of your day runs on autopilot.", params: {...}}
```

## Textures

`texture` in the brief (or the storyboard) sets the global background. Every texture is generated in the page; none is fetched from a CDN.
- `paper`: crumpled paper, a #C8C8C8 → #707070 radial with a 35% vignette. Two seeded feTurbulence fields are lit by feDiffuseLighting from the top-left and rasterised once to a canvas. Faceted planes, creases, speckle and fibres are added on top.
- `grain`: the plain wash with stronger per-frame grain.
- `clean`: the wash with no grain.
- `grid`: a 24 px ink grid.
- `blueprint`: blue with 24 px and 120 px white grids and a vignette.

Grain is a deterministic per-frame tile (seeded), so it is always the same for a given frame. Images can use `L.treat(url, {bw, halftone, contrast, cell, mix, lift})`, a single cached canvas pass after decode, through `L.setImgFx(img, url, fx)`. Cut-outs use `L.cutFilter({outline, color, shadow})`, an outline built from a stack of drop-shadows plus a contact shadow.

```yaml
texture: paper    # paper | grain | clean | grid | blueprint
sfx: {density: standard}     # rich | standard | minimal
```

## Motion rules

Values in `L.EASE`, `L.SPRING` and `L.TIMING` (`scripts/stage/lib.js`). Sources: the published HyperFrames guidance (Apache-2.0) and the analytic spring maths; reimplemented here.

**Spring.** `L.spring(t, {stiffness: 100, mass: 1, damping: 10})` is the analytic damped spring x(t) = 1 - e^(-ζω0 t)(cos(ω1 t) + (ζω0/ω1) sin(ω1 t)), with ω0 = √(k/m), ζ = c/(2√(km)), ω1 = ω0√(1-ζ²). It is deterministic at any fps. Use ζ 0.7-0.85 for premium arrivals (`L.SPRING.premium`) and ζ 0.4-0.6 for playful pops (`L.SPRING.playful`).

**Easing by role.**

| role | ease | `L.EASE` |
|---|---|---|
| arrivals | expo.out, power4.out | `arrive`, `arrive2` |
| camera moves, colour floods | power2.inOut | `camera`, `flood` |
| zoom-throughs, exits | power3.in | `through`, `exit` |
| rotation, progress | linear (only these) | `linear` |

**Timing.**
- Fast moves take 0.3-0.6 s.
- Exits are 40-50% shorter than entrances.
- The next element starts when the previous one is 30-50% done.
- Stagger: letters 1 frame, words 60-120 ms, list items 40-80 ms.
- Reading hold: 0.5 s + 0.3 s per word (`L.readHold(n)`).
- No stretch over 1.2 s without a new visual event.

**Type.**
- Title reveal (`L.revealWords`): per word, blur 6 px → 0 and opacity 0 → 1 in about 0.13 s, 0.067 s apart.
- Hero words fill 60-90% of the width (`L.fitHero`).
- Body type is 4-9% of the frame height.
- Text stays sharp at rest: depth of field applies to non-text planes unless `dof_text` is set.

**Motion blur.** Peaks are 6-12 px at text size and 15-20 px full frame. Whip smears last 0.13-0.3 s and land sharp in about 0.13 s.

**Transitions.** Zoom through a target: track 0.6 s, then scale from 8% to 250% in 0.35 s with a strong ease-in (`through`). Object becomes world (`world`). Colour flood, 0.4 s (`flood`). Dip cut, darken 40% for 3 frames (`dip`). Iris, clip-path circle 0.15 s (`iris`).

**Captions.** Group 2-3 words for high energy, 3-5 conversational, 4-6 calm. Break at a sentence end, a gap of 150 ms or more, or the word limit. A group opens at or before its first word and closes at or after its last. Exit fade 0.12 s, then hard off. Auto-fit from base 78 down to 42 px, max width 900 in portrait. Use a scrim only when contrast is low.

**Layout.** Action-safe is 90% and title-safe is 80%. `fishvideo qa` (`stills.mjs --lint`, `STAGE.lint`) checks:
- errors: text outside the platform safe area, `text-overlap` (free text on free text) and `text-over-item` (a label on an object's opaque pixels; a title on its own paper strips is exempt)
- warnings (`warnings` in `lint.json`): `outside-action-safe`, and `outside-title-safe` for large type

Mark free-standing text with `data-lb` to include it in the overlap checks.

## Character, accent, hook

- **`anchor: {src, corner: tl|tr|bl|br, x, y, w, rot, at}`** (storyboard level, default off): a small cut-out of the recurring object in every scene, with a contact shadow and a slight drift, reacting to the scene's impact, boom, thump and hit cues. `w` is a fraction of the safe width (0.2). It is placed against the scene's finished layout with `L.placeLabel`, keeping off text and objects; it shrinks, and is left out of a scene where nothing is free or where that object is already the hero.
- **`carry: {src, at, x, y, w, rot, corner}`** (scene field) sets or overrides it for one scene, and `carry: false` removes it. `at` is in seconds.
- **`accent: "#hex"`** (scene field) overrides the brand accent for that scene's CSS variables, captions, glows and `flood`. The type system stays fixed.
- **Hook motion** (first scene with a style or `camera`): frame 0 is full, the camera takes a fast spring push and the title slams 1.14 → 1 from t = 0, with an `impact` cue. `params.hook_motion: false` turns this off.

## Camera

Every scene is filmed by one 3D virtual camera. The scene root has a CSS `perspective` (P = 1100 px at u = 1), and the scene is built in a `preserve-3d` world that moves opposite to the camera. `L.layer(el, z)` puts an element on a depth plane. Each plane is pre-scaled by (P - z) / P, so at the rest pose the layout is exactly what the scene laid out, and the lint checks that layout. Any camera move then gives real parallax. Planes at z ≤ -300 are overscanned by 22%, so lateral moves never reveal an edge.

Typical depths:
- background -800
- the subject 0
- labels and arrows +120 to +250

Layers can be set per item: `z:` on collage items and labels, `bg_z`, `title_z` and `ink_z`.

**Scene fields** (on the scene, or in `params`):
- `camera`, one of:
  - a preset: `static`, `drift`, `dolly-in`, `dolly-out`, `push-in`, `orbit-left`, `orbit-right`, `crane-up`, `crane-down`, `push-through`, `whip-left` or `whip-right`. Combine presets with `+`.
  - a style: `paper` (= drift + dolly-in), `studio` (= push-in 1.0 → 1.08 + bob), `glow` (= push-through), `neon` (= crane-up + follow the draw head) or `screen` (= static; the tour's own zoom-to-cursor does the work).
  - keyframes: `[{t | beat | m, x, y, z, rx, ry, rz, fov, focus_z, ease}]`. Values carry forward, and `ease` (`expo-out`, `cubic`, `cubic-in`, `cubic-out`, `back-out` or `linear`) shapes the segment arriving at that key. `m` is a capture mark (in tour scenes).
  - an object: `{preset, amount, keys, follow}`. `amount` scales the presets, and `keys` add on top of them.
- `camera_from: prev`: start from the previous scene's end pose and blend into this scene's move over `camera_blend` seconds (0.9). One move then carries through the cut.
- `camera_shake: 0..1`: handheld micro-motion. It is seeded smooth noise (a sum of sines), about ±4 px and ±0.3° at 1.
- `camera_shake_on: impact`: adds a short decaying kick on the scene's impact, boom, thump and hit cues.
- `dof` (blur per unit of depth, 0.03) and `dof_max` (6 px): depth of field once a key sets `focus_z`. Each plane blurs by |z - focus_z|. Keyframe `focus_z` for a rack focus. Planes holding text (and the arrow ink) stay sharp; `dof_text: true` blurs them too.

**Default style by scene type:**
- `paper`: collage, split
- `studio`: coinflip, orbit
- `neon`: neonline, ringbloom
- `glow`: glowshape, tunnel
- `drift`: cone, footage
- `screen`: tour
- the original scenes (title, kinetic, bullets, stat, chart, diagram, whiteboard, end and the rest): a subtle `drift`, `drift+dolly-in` or `drift+push-in` when `brief.style` is set
- `static`: the original scenes in a legacy brief with no style and no `camera`, so those projects render as before

**Liveliness**: with a style or a `camera`, every scene but `tour` also gets spring micro-pushes on its spoken phrases (alternating, so they stay bounded). Gaps are filled so that no stretch exceeds about 1.1 s without a new visual event.

**Lens**:
- Motion blur follows the camera's speed above 9 px per frame. Fast lateral moves get a directional blur; z moves get a soft blur.
- The vignette tightens as the camera pushes in.
- `zoom` transitions are real camera z-moves into scene A and out of scene B.

**Cues**: a fast camera move gets a `whoosh` (or a `swish` if it lasts under 0.3 s) 0.1 s before it starts. `push-through` lands an `impact` on arrival.

**Determinism**: the pose is a pure function of (scene, t), with no timers. Fonts used by the stage are preloaded, so the first layout matches every later one.

```yaml
- id: hook
  type: collage
  camera: {preset: drift+dolly-in+orbit-right, amount: 0.55}
- id: split
  type: split
  transition: zoom
  camera_shake: 0.35
  camera_shake_on: impact
  camera:                       # rack focus: the front card, then the layer behind it
    preset: drift
    keys: [{t: 0, focus_z: 30}, {t: 1.0, focus_z: 30}, {t: 1.6, focus_z: -90, ease: cubic}]
- id: repeat
  type: tunnel                  # glow = push-through: the camera flies through at the end
- id: close
  type: cone
  camera_from: prev             # starts inside the push-through and pulls back into the spotlight
  camera_blend: 1.2
- id: whip
  type: coinflip
  camera: whip-left+push-in
- id: keyed
  type: neonline
  camera: [{t: 0, z: 0, ry: -4}, {t: 2, z: 90, ry: 4, ease: expo-out}, {at: end, y: -40}]
```

In a custom scene, call `L.layer(el, z)` after building `el` under the scene root. Set `cam: "paper"` (or any style or preset) on the scene definition to give it a default move, and set `st.camHint = {x, y}` (stage px) in `update` to give the `follow` styles a target.


---


## references/diagram-design.md


# Diagram and visual-element design

Explainers need a **real diagram**: a picture of the mechanism. A list of bullets does not count. These rules decide which diagram to use, how to order and colour it, and how to animate it so it teaches.

## Pick the diagram from the sentence

| The narration says… | Diagram | Scene |
|---|---|---|
| "A leads to B leads to C" | **flow**, 3–5 nodes, arrows in reading order | `diagram` (clean) / `whiteboard` `flow:` / `neonline` path |
| "X is made of a, b, c" | **anatomy**: the object in the centre, labels with arrows on the parts | `collage` labels + arrows |
| "It repeats / feeds itself" | **cycle**: 3–5 nodes on a circle, arrows clockwise | `diagram` with `layout: cycle` / `whiteboard` circle + arrows |
| "Before vs after" / "you think vs reality" | **split** or a **two-line chart** | `split`, `compare`, `neonline` (straight vs zigzag) |
| "It grows / falls over time" | **line or bar chart** of sourced values | `chart`, `neonline` |
| "Inside X there is Y" | **zoom-in layers**: the outer object, then a push-through to the inner one | camera push-through, `split` reveal |
| "Three options" | **lanes**: the same action in parallel rows | `compare` / stick-fable lanes |
| "Where it sits in a system" | **architecture**: boxes, one highlighted | `diagram` with `hi: true` on one node |

## Ordering

- Follow **reading order**: left→right in landscape and top→bottom in portrait. Cycles run clockwise from 12 o'clock.
- **Reveal in narration order.** Each node or label appears on the beat where its word is spoken (within 3 frames), never all at once.
- Use **3–5 elements** per diagram. More than that gets split into two scenes, or the first ones are collapsed into a single node.
- Build the **cause before the effect**. The arrow draws *after* its source node and *before* its target appears.

## Colour

- Neutral ink for structure: boxes, arrows and labels use `ink` / `muted`.
- Use the **accent on one thing only**: the node, arrow or word the beat is about. When the beat moves on, the accent moves with it and the previous element returns to ink.
- A second colour (`accent2`) only marks a contrast pair (before/after, expected/real).
- On dark styles, glow is the emphasis: bloom only the active element.
- The ink-to-background contrast ratio must be at least 4.5:1.

## Shape language per style

| Style | Nodes | Connectors | Labels |
|---|---|---|---|
| paper-collage | real cut-out objects with contact shadows | hairlines and hand-drawn curved arrows | heavy grotesk, about 3× hero size |
| neon-diagram | glow icons and rounded boxes | a glowing line drawn by a head (dashoffset) | neon caps, +8% tracking |
| whiteboard | rough.js boxes and circles | marker arrows | Caveat |
| studio-kinetic | white cards with soft shadows; coin discs | thin grey arcs | bold sans |
| glow-metaphor | spheres and rings | light trails | small caps at the top |

## Motion that teaches

- Draw, don't fade: strokes reveal by length (0.5–0.75 s per segment, ease-out).
- Each new node pops with scale 0→1.1→1 over about 6 frames and gets a `pop` cue (a `zap` on neon).
- Each arrow gets a `swish` cue as it draws. The final takeaway gets a `riser` that ends on it, and an `impact`.
- The camera follows the build. Push in slightly on the active node, then pull back to show the whole diagram at the end of the beat, so the viewer gets one *finished* frame to read for at least 0.8 s.

## Theme selection from mood and script

The interview's style and palette answers win. When the user chose "style default" or the agent must choose, map the **dominant mood of the script** to these defaults:

| Mood of the script | Style | Palette | Music mood | Delivery |
|---|---|---|---|---|
| curious, factual, "how it really works" | paper-collage | mono-paper | dark-drone | calm |
| bold, business, people, products | studio-kinetic | studio-lime | hiphop-groove | punchy |
| tension, failure vs success, data | neon-diagram | neon-amber | dark-build | punchy |
| reflective, motivational, mindset | glow-metaphor | mono-glow | cinematic-swell | cinematic |
| practical, step-by-step software | screen-explainer | brand or ocean-tech | lofi-tech | calm |
| technical teaching | whiteboard-explainer | warm-paper | calm-pulse | calm |
| funny or blunt comparison | stick-fable | mono-red | phonk-loop | none |

This map is also in `config/styles.yaml → mood_map`. Say which mood you detected and what you chose in the interview summary, so the user can override it before creation starts.


---


## references/pacing-and-music.md


# Pacing and music: editing on a 120 BPM bar grid

## The grid

- At 120 BPM, one **beat** is 0.5 s and one **bar** (4 beats) is **2.0 s**. At 30 fps that is 15 frames a beat and 60 frames a bar.
- **Every scene is a whole number of bars**, so every cut lands on a downbeat.
- Inside a scene, **entrances land on beats**: title words at beat 1, the subtitle at beat 3, chips every half beat from beat 5.
- `storyboard.py` sizes scenes in bars:

```
need  = max(min_visual(scene), lead_in + Σ(narration lines + gaps) + tail)
bars  = ceil(need / 2.0)
```

The defaults are `lead_in` 0.35 s, `line_gap` 0.30 s and `tail` 0.55 s. The first word arrives just after the cut, and the picture holds a breath after the last word.

Why this works:
- The music synthesiser builds its arrangement bar by bar, so a scene change *is* a musical phrase change.
- Risers end exactly on the cut, and impacts hit on it.
- The viewer feels the edit as intentional even when nobody notices the grid.

## Pacing rules of thumb

| Scene | Bars | Notes |
|---|---|---|
| hook | 1.5–3 | Under 3 s to the first payoff. No logo first. |
| title | 2–4 | Logo → name → promise → chips, on beats. |
| kinetic | 1–3 | About 6 words a line; hold ≥ 1 s after the last word. |
| bullets / steps | 2–4 | One item per narration beat; ≥ 1 s per item. |
| whiteboard | 3–6 | Draw while speaking. The takeaway arrives last and holds ≥ 1.5 s. |
| stat | 2 | Count up over 2 beats, then hold. |
| tour | 2–6 | One callout at a time, ≥ 2 s each. Speed 1.0–1.6×. |
| end | 2–3 | Energy 0, hold ≥ 2 s on the final frame. |

Further rules:
- **Never hold a frame without motion for more than about 4 s.** If the narration is long, add drawing, a camera move or a callout.
- **Reading time for on-screen text:** at least `0.3 s + 0.06 s × characters`, at the latest after the element has fully arrived.
- **Narration rate:** en-IN about 145 wpm (edge-tts Neerja at -6%; ElevenLabs at speed 0.96). Budget about 2.4 words per second of *scene*, which leaves the music room.
- **Cut length variety:** alternate long and short scenes (3, 2, 4, 2…). Five equal scenes in a row feel mechanical.
- **Chapter wipes:** use them every 20–40 s in long cuts. The wipe straddles the cut (±0.34 s) and carries a whoosh.
- **Reference matching:** `reference.py` reports the median shot length in bars. If the reference cuts every 1.5 bars and yours every 3, your video will feel slower than the reference. Either split scenes or speed the inner animation.

## Energy curve

Each scene has an `energy` from 0 to 3, which the music follows:

| energy | arrangement |
|---|---|
| 0 | pads, sparse plucks, a bell figure at the outro |
| 1 | kick on 1 and 3, off-beat hats, long bass notes, 8th-note plucks |
| 2 | four-on-the-floor kick, clap on 2 and 4, 16th plucks, syncopated bass |
| 3 | + shaker 16ths, open hat, bell motif every other bar |

Transitions follow these rules:
- **Before a lift** (when the next bar's energy is higher), a 0.75-bar riser plays, with a clap roll in the second half.
- **On the lift**, an impact lands.
- **Into energy 0 at the end**, an impact plays, followed by a falling bell figure.

Defaults by scene:
- The first scene is 1, and `end` is 0.
- `tour` and `stat` are 3.
- Most others are 2. `quote` and `image` are 1.

A good curve for a 60 s explainer: `1 → 2 → 2 → 2 → 3 (proof) → 2 → 0`. Lift into the proof and the tour, and land on 0 for the end card.

## The score (music.mjs)

The score is fully synthesised in JavaScript, 48 kHz stereo float, and seeded so a re-render is identical. That means no samples, no stems and no licences. Settings live in `score:` in the brief or storyboard:

```yaml
score: {key: D, mode: minor, progression: i-VI-III-VII, seed: 12345, drums: true, sfx: true}
```

| progression | mood |
|---|---|
| `i-VI-III-VII` (minor, default) | driving, hopeful-serious, the "tech explainer" sound |
| `i-iv-VI-V` (minor) | tense, building |
| `I-V-vi-IV` (major) | bright, upbeat promo |
| `vi-IV-I-V` (major) | warm, emotional |
| `I-vi-IV-V` (major) | classic, friendly |

Chords are diatonic 9th voicings (root, 3rd, 5th, 7th, 9th). Bass is the root at octave 2.

The layers are:
- kick (sine sweep 156→46 Hz, with a sidechain duck on everything tonal)
- clap (three noise bursts through a band-pass)
- hats and a shaker
- sub bass (sine + 2nd + 3rd harmonics, filtered)
- pads (3 detuned saws per note, a two-pole low-pass with an LFO)
- a pluck arpeggio with a dotted-eighth ping-pong delay
- an FM bell
- risers and impacts
- a Freeverb room on a send bus
- tanh bus saturation and a 1.2 s fade at the end

## Sound design cues

Scenes export cues (`cues(c) → [{lt, type, len}]`), and the mixer places them:

| cue | sound | use for |
|---|---|---|
| `hit` | low impact | a title landing, a big reveal |
| `swell` / `rise` / `riseShort` | riser | before a reveal |
| `whoosh` / `swoosh` / `wipe` | filtered noise sweep (panned) | movement, a window entering, chapter wipes |
| `pop` | short pitched blip | a chip, card or callout appearing |
| `tick` | click | a small text entrance |
| `click` | UI click | recorded clicks (automatic from captures) |
| `key` / `keycombo` / `typing` / `enter` | keyboard | recorded keys, terminal typing |
| `scribble` / `write` / `slash` | marker on a board | whiteboard strokes, handwriting, strike-through |
| `blips` | pentatonic blips in the key | counters, cells lighting up |
| `sweep` | rising tone | chart bars growing |
| `chime` / `alert` | bells | success, approval card |

Rule: **every visible event gets a sound, and no sound plays without a visible event.** At most one prominent SFX per beat.

## Mixing with a voice

- The music bed sits 10 dB under the voice (`mix.music_under_voice_db`), carved and ducked about 6 dB while speaking; SFX are a separate, unducked stem. See [sound design](sound-design.md).
- `sidechaincompress` keyed by the voice ducks the bed further while the voice speaks: threshold 0.04, ratio 6, attack 20 ms, release 380 ms.
- The voice post chain: high-pass at 85 Hz, -2 dB at 280 Hz, +1.5 dB at 4.8 kHz, +3 dB shelf at 9 kHz, gentle 2.5:1 compression, a short 23/41 ms room.
- The master gets two-pass `loudnorm` to I = -14 LUFS, TP = -1.5 dBTP, LRA = 11, linear mode, measured first.


---


## references/sound-design.md


# Sound design and mix

The audio is **three stems**, mixed separately and never on one bus:

| Stem | Source | Processing | Level target |
|---|---|---|---|
| **voice** | TTS clips (ElevenLabs, edge-tts or Kokoro) or the user's own VO | HPF 85 Hz, −2 dB at 280 Hz, +1.5 dB presence at 4.8 kHz, air shelf, comp 2.5:1, short room | about −16 LUFS short-term while speaking |
| **music** (BGM) | a library track (`assets/music/*`, logged in credits) **or** the synthesised score (`music.mjs`) | bass harmonics, gentle −1.5 dB mid dip (`mix.music_carve_db`), mid-band sidechain duck from the voice | `mix.music_under_voice_db` (default **−10 dB** under the voice, measured on a simulated phone) |
| **sfx** | samples from `assets/sfx/`, `sfx/library/` (Kenney CC0, Freesound CC0), else synthesised | none; **not ducked** | per cue type, see the table below |

The sum is loudness-normalised to the platform target (−14 LUFS with true peak −1.5 dBTP on long formats, −12 LUFS with −1 dBTP on Shorts, Reels and TikTok) and limited.

Why it is built this way: in v1 the voice ducked the whole bed, SFX included, at ratio 6 on a −5 dB bed. The BGM vanished under narration and the risers and clicks were swallowed. The references keep music clearly audible, about 8–12 dB under the voice, and keep SFX on top in the word gaps.

## Translating to phone and laptop speakers

Headphones reproduce everything. A phone speaker plays almost nothing below 200–300 Hz, and usually plays mono. A bed of pads and sub bass can sound right on headphones and disappear on a phone. A mix that only works on headphones is a failed mix. The renderer does five things about it:

1. **Levels are balanced through a phone simulation** (`render.PHONE_SIM`: mono, high-pass around 250 Hz, +3 dB at 2.5 kHz, low-pass at 10 kHz). `mix.music_under_voice_db` is measured there, capped by `mix.music_max_fullband_db` so headphones stay balanced.
2. **Psychoacoustic bass.** The low band is saturated, and its 150–1200 Hz harmonics are blended back (`mix.translate`). The ear infers the missing fundamental, so bass stays audible on speakers that cannot play it.
3. **A multiband duck.** Only the mid band (150 Hz–4 kHz, where words live) ducks under the voice. A full-band duck also pulled down the harmonics, which is exactly what a phone relies on.
4. **SFX are calibrated by events, not by integrated loudness**, which hides short sounds. On a phone, a typical SFX event peaks `mix.sfx_under_voice_db` (default −7 dB) below typical speech. Every impact, boom and thump also has a 150–400 Hz body and a 2–4 kHz click, so a phone gets the click and headphones get the body.
5. **Louder masters for vertical platforms:** −12 LUFS with a −1 dBTP ceiling on Shorts, Reels and TikTok.

QA's `speaker translation` check measures how much of the mix a phone throws away, the voice-to-bed gap on a phone, and mono safety. Listen to `build/<platform>/phone-monitor.wav`, or play the final video on a real phone at low volume. A simulation is not a phone.

If the music still vanishes: raise `mix.translate.music_harmonics_db` (−4 → −2), choose a brighter track (a sub-heavy drone has little for a phone to play), or lower `mix.music_under_voice_db` (−10 → −8).

## Cue vocabulary (scene `cues`) and default levels

| cue | sound | length | level (dBFS before master) | place it |
|---|---|---|---|---|
| `swish` | airy noise sweep, 5–6 kHz centroid | 150 ms | −24 | 0.08–0.12 s **before** an element moves |
| `whoosh` | fuller sweep with low body | 350 ms | −20 | on camera moves and zoom-throughs |
| `wipe` | long whoosh | 600 ms | −18 | chapter wipes |
| `riser` (`len`) | noise and tone rising, cut dead | 0.6–1.2 s | −22 → −14 | **ends** on the reveal (`lt` is the end) |
| `impact` / `hit` | sub thump with a short noise burst | 400 ms | −13 | title slams, flash cuts |
| `boom` | deep sub with a long tail | 1.2 s | −15 | big reveals, end card |
| `thump` | 80 Hz, short | 250 ms | −16 | slides, landings |
| `pop` | short tonal blip | 70 ms | −26 | element arrival (rich density) |
| `tick` | 3.6 kHz click | 30 ms | −30 | small labels, counters |
| `click` | mouse click | 40 ms | −20 | every recorded click (screen scenes) |
| `ripple` | soft bubble | 120 ms | −28 | click ripples |
| `zap` | glassy, >5 kHz, crackle tail | 60 ms | −22 | neon vertex hits |
| `coin` | metallic flip | 150 ms | −22 | coin flips |
| `paper` | paper slide or rustle | 250 ms | −24 | collage cards entering |
| `flash` | air burst with impact | 300 ms | −16 | white flash transitions |
| `typing` (`len`) | key run | len | −26 | terminal and screen typing |
| `scribble` (`len`) | marker | len | −24 | whiteboard strokes |
| `chime`, `swell`, `blips`, `sweep`, `key`, `keycombo`, `enter`, `tok`, `alert` | v1 synth cues, kept | | | |

### Density (`sfx.density` from the interview)

- **rich**: every cue is placed. Use it for Shorts and motivational or explainer pieces.
- **standard**: transitions, reveals, impacts, risers and clicks only. Pops and ticks are dropped.
- **minimal**: impacts, risers and booms only (as in R5, stick-figure or music-led pieces).

Two rules apply at every density:
- Never stack more than 2 SFX within 80 ms. The later one is dropped.
- No `pop` or `tick` is placed within 60 ms of a stressed spoken word.

## Music

There are two sources, chosen in the interview (`music.mood`, `music.source`).

1. **A library track** (preferred when real assets are allowed).
   - `fishvideo assets search <dir> --kind music --q "<mood words>"`.
   - Or the user drops a file in `assets/music/`.
   - The renderer finds its tempo, trims it to length on a bar line, fades the last 1.5 s, and logs it in `credits.json`.
   - Licences allowed: CC0, Pixabay Content License, CC-BY (credited). Never NC or ND, and never anything ripped from YouTube.
2. **Synthesised score** (`music.mjs`).
   - Moods map to key/mode/progression/tempo presets in `config/styles.yaml`.

Bed rules:
- The music starts at frame 0 (no fade-in on Shorts).
- It is audible in every gap.
- It is ducked about 6 dB under the voice (attack 10 ms, release 250 ms).
- It fades over the last 1.5 s, or cuts dead after a final impact when the style says `end: silence`.

## Mix settings (`config/defaults.yaml` → `mix:`)

```yaml
mix:
  voice_lufs: -16            # voice stem normalised to this before summing
  music_under_voice_db: -10  # music under the voice, on a simulated phone
  music_max_fullband_db: -6  # never louder than this under the voice, full range
  music_carve_db: -1.5       # gentle static mid dip
  translate: {music_drive_db: 14, music_harmonics_db: -4, music_width: 0.85, sfx_drive_db: 16, sfx_harmonics_db: -6}
  duck: {threshold: 0.05, ratio: 3, attack: 10, release: 250, makeup: 1}   # mid band only
  sfx_gain_db: 0             # manual trim on the SFX stems
  sfx_under_voice_db: -7     # automatic: a typical SFX event on a phone, relative to speech
  sfx_max_boost_db: 12
```

QA measures the stems: the voice-to-music gap while speaking should be between 6 and 14 dB, music in the gaps must be no quieter than −30 LUFS short-term, and SFX must be audible (at least 3 events above −35 dBFS for rich density). A WARN tells you which knob to turn.

## Listening check (do it yourself)

Use `dissect.py` on your own `final.mp4` and compare its `timeline.md` balance numbers with a reference's. Then open `spectrogram.png`. You should see:
- the voice harmonics on top
- a steady music layer underneath that never disappears
- thin swish smears just before each cut


---


## references/captions-and-typography.md


# Captions and typography

## Type system

| Role | Face (bundled) | Weight | 16:9 size (u = 1) | 9:16 size |
|---|---|---|---|---|
| Display / headline | Inter (`--display`) | 700, tracking -0.035em | 128 (fitted) | 100 (fitted) |
| Serif accent (contrast word, quotes, tagline) | Fraunces italic | 400 | 0.8–1.2 × display | same |
| Section title (h2) | Inter / Fraunces | 600–700 | 72 | 60 |
| Body / bullets | Inter | 550 | 34–40 | 36–42 |
| Eyebrow / kicker / step tag | JetBrains Mono, uppercase, +0.2em | 400–600 | 22 | 28 |
| Whiteboard | Caveat | 600–700 | 84–96 title, 38–64 notes | scaled board |
| Code / terminal | JetBrains Mono, ligatures off | 400–700 | 32 | 32 |
| Hindi / Devanagari | Noto Sans Devanagari (fallback in every stack) | 400–700 | as above | |

Rules:
1. **Use two families plus mono.** Contrast comes from size, weight and the one serif-italic word, not from more fonts.
2. **Use one accent colour per scene**, on the single most important word or shape.
3. **Headline ≤ 8 words. Bullet ≤ 7 words. Kinetic line ≤ 6 words.** If the text does not fit at the size above, *cut words*. Do not shrink the text below the minimum.
4. **Minimum on-screen size:** 22 px on a 1080-tall 16:9 frame, 24 px on 4:5 and 1:1, and 28 px on 9:16. The QA lint enforces this.
5. **Contrast** ≥ 4.5:1 for body text and ≥ 3:1 for display text. Check `ink` on `bg` and `muted` on `bg` when you choose brand colours.
6. **Safe areas:** every text element lies inside the platform's safe margins (see [platform-specs](platform-specs.md)). The lint fails any text outside them.
7. **Numbers** use tabular figures in counters (`font-variant-numeric: tabular-nums`). Use the thousands separator for the locale (10,631 / 10 631 / 10.631). Put units next to the number.
8. **Line length:** use about 45 characters maximum for body text on 16:9 and about 28 on 9:16.
9. **Text never moves while it is meant to be read.** Animate it in, hold it, then animate it out. Read time is at least 0.3 s + 0.06 s per character.

## Captions

### Cue building (`captions.chunk_words`)

Input: word timings `[{w, t0, t1}]` from the TTS, mapped back to *displayed* words through `{shown|spoken}`.

| Rule | 16:9 | 4:5 / 1:1 | 9:16 |
|---|---|---|---|
| max characters per line | 42 | 32 | 26 |
| max lines | 2 | 2 | 2 |
| min cue duration | 0.9 s | 0.9 s | 0.9 s |
| max cue duration | 6 s | 6 s | 6 s |
| max reading speed | 20 chars/s | 20 | 20 |

Further rules:
- Break after `. ! ? : ;`, or after a comma once the cue is about 45% full.
- Never span a pause longer than 0.6 s.
- A cue holds up to 0.45 s after its last word, but never overlaps the next cue.
- Line wrap is balanced: two lines of similar length beat one long line and one short one, with a bonus for breaking at punctuation.

### Outputs

- **SRT** (`HH:MM:SS,mmm`) and **VTT** (`WEBVTT`, `HH:MM:SS.mmm`) ship for every platform. Upload the SRT to YouTube as subtitles; it is also good for LinkedIn's caption upload.
- **Burned-in** captions are drawn by the stage on `linkedin`, `linkedin-square`, `shorts`, `reels`, `tiktok` and `x`. Override with `captions: {burn: true|false}` in the brief. How the burned-in captions look and sit:
  - Inter 700 at 44 px × u, on a surface-coloured rounded box.
  - Each word lights (opacity 0.38 → 1) as it is spoken, and the current word takes the accent colour.
  - The caption band is reserved: scenes lay out *above* it (`c.safe` excludes it), so captions never cover content.
  - Position: above the platform's bottom safe margin, so app UI never covers them.
- **Fallback burn-in** for footage the stage did not render: `python scripts/captions.py burn in.mp4 subs.srt out.mp4` (libass `subtitles` filter, styled box).

### Checks (`qa.py`)

- The SRT parses, and no cue overlaps the next.
- No line is longer than the platform's maximum, and no cue has more than 2 lines.
- Reading speed is ≤ 25 characters/s (20 × 1.25 tolerance).
- No cue ends after the video.
- **Sync:** every spoken word's midpoint falls inside a cue (±0.25 s).

## Accessibility

- Captions are on by default. They should always be available, even where they are not burned in.
- Never put essential information only in sound, or only in colour.
- Avoid flashing above 3 Hz. Keep wipes ≥ 0.6 s.
- Spell brand words in captions the way the brand spells them: the `shown` side of `{shown|spoken}`.


---


## references/voice-direction.md


# Voice direction

## The default voice

| | |
|---|---|
| Provider | **ElevenLabs** (default) |
| Voice | `fydPJq00SigRIaPxWgiS`, English (India): https://elevenlabs.io/voices/fydPJq00SigRIaPxWgiS |
| Model | `eleven_multilingual_v2` |
| Settings | stability 0.42 · similarity_boost 0.8 · style 0.18 · use_speaker_boost true · speed 0.96 (max 1.12) |
| Context | `previous_text` / `next_text` sent with every line (`use_context: true`), so the intonation carries across lines |
| Endpoint | `POST https://api.elevenlabs.io/v1/text-to-speech/{voice_id}/with-timestamps?output_format=mp3_44100_128`, header `xi-api-key` |
| Key | `ELEVENLABS_API_KEY` from the environment, or from a **gitignored** `.env` in the project or skill folder. Never printed, logged, written to metadata, or committed. |
| Fallback | **edge-tts** `en-IN-NeerjaNeural`, rate -6%, pitch +0 Hz. It is free and needs internet. It is used automatically when no key is set, or when an ElevenLabs call fails; the run prints a `!!` line saying so, and `metadata.json` records `stand_in_for`. |

These settings match `Promotion/src/explainer/voice.config.json`, which is used for the ImuTechz explainer. Change them per project in `brief.yaml` under `voice:`.

```yaml
voice:
  provider: elevenlabs          # elevenlabs | edge-tts | none
  language: en-IN
  elevenlabs: {voice_id: fydPJq00SigRIaPxWgiS, model_id: eleven_multilingual_v2,
               voice_settings: {stability: 0.42, similarity_boost: 0.8, style: 0.18, use_speaker_boost: true, speed: 0.96}}
  fallback: {provider: edge-tts, voice: en-IN-NeerjaNeural, rate: "-6%"}
  pronunciations: {IMURUN: "Imu run", GGUF: "G G U F", ImuTechz: "Imu techs"}
```

## Word timings

- **ElevenLabs** returns `alignment.characters` with start and end times per character. `voice.eleven_words` groups characters into words on whitespace. A word is timed by its *letters and digits only*, so a trailing comma does not stretch the word.
- **edge-tts** emits `WordBoundary` events (offset and duration in 100 ns ticks). The script locates each one in the spoken string to get a character index.
- Both are mapped back to **displayed** words through the `{shown|spoken}` units. Captions therefore show "GGUF" while the voice says "G G U F".

## Respelling: `{shown|spoken}`

Write what the viewer should *read* on the left and what the voice should *say* on the right.

| Shown | Spoken | Why |
|---|---|---|
| `{GGUF\|G G U F}` | letters | Acronyms spelled out |
| `{SQL\|sequel}` | a word | Acronyms said as words, where that is the convention |
| `{v0.3\|version zero point three}` | expanded | Versions |
| `{10,631\|ten thousand six hundred and thirty-one}` | words | Big numbers (en-IN may otherwise say "ten thousand, six thirty-one") |
| `{2.1×\|two point one times}` | words | Symbols |
| `{C++\|C plus plus}` | words | Punctuation in names |
| `{Ctrl K\|Control K}` | full | Keys |
| `{imutechz.in\|Imu techs dot in}` | spoken URL | Domains |
| `{Qwen\|Kwen}`, `{Phi\|Fye}`, `{LoRA\|Lora}` | phonetic | Brand and model names |
| `{R&D\|R and D}` | words | Ampersands |
| `{₹499\|four hundred and ninety-nine rupees}` | words | Currency, in the locale's order |

Put recurring words in `voice.pronunciations` once. They are applied to every line as whole words, but never inside an existing pair.

## Directing the read

- **Write for the ear.** Use short sentences, and put the verb early. Give one idea per line, because each line is one TTS call, one clip and one caption group.
- **Punctuation is direction.**
  - A comma is a short breath.
  - An ellipsis `…` is a held, thinking pause; use it before a reveal.
  - A colon sets up a list or payoff.
  - An em dash is an aside.
  - Question marks lift the pitch, so avoid them unless you mean them.
- **Emphasis:** the TTS stresses the new information at the end of a sentence. Put the key word last: "…runs on *your machine*."
- **Numbers:** say them once, clearly. Don't stack two numbers in one sentence.
- **Pace:** ElevenLabs speed 0.92–1.0 for explainers, 1.0–1.08 for promos. edge-tts rate -8% to +4%.
  - If a scene has a fixed `bars` and the line overruns, cut words before you speed up.
  - Never exceed `max_speed` 1.12 or `max_rate` +8%.
- **Consistency:** keep one voice per video, and keep settings identical across lines. Changing the text of one line re-voices only that line, because clips are cached by text and settings.

## Locales

| Locale | ElevenLabs | edge-tts fallback voices | Notes |
|---|---|---|---|
| en-IN (default) | `fydPJq00SigRIaPxWgiS` | `en-IN-NeerjaNeural` (f), `en-IN-PrabhatNeural` (m) | about 145 wpm; Indian number words ("lakh", "crore") only if the brand uses them |
| hi-IN | any multilingual_v2 voice with `language_code: hi` | `hi-IN-SwaraNeural` (f), `hi-IN-MadhurNeural` (m) | about 130 wpm; captions in Devanagari (Noto Sans Devanagari is bundled); keep product names in Latin script |
| en-US | multilingual voices | `en-US-AriaNeural`, `en-US-AndrewMultilingualNeural`, `en-US-GuyNeural` | about 155 wpm |
| en-GB | | `en-GB-SoniaNeural`, `en-GB-RyanNeural` | about 150 wpm |
| ta-IN, bn-IN, mr-IN, te-IN | multilingual_v2 | `ta-IN-PallaviNeural`, `bn-IN-TanishaaNeural`, `mr-IN-AarohiNeural`, `te-IN-ShrutiNeural` | check whether the caption font covers the script; add a Noto face via `brand.font_files` |
| es, fr, de | multilingual_v2 | `es-ES-ElviraNeural`, `fr-FR-DeniseNeural`, `de-DE-KatjaNeural` | text expands 15–30%; recheck the fits |

List voices with `python scripts/voice.py voices --locale en-IN`. The command lists edge-tts voices, plus your ElevenLabs voices when a key is set.

## No narration

Set `voice: {provider: none}` for a music-only cut. Scenes then size from their animation, captions are empty, and on-screen text has to carry the message. Use kinetic and bullets more, and add 1–2 bars to every text scene.

## Checklist

- [ ] Every acronym, number, symbol and brand word is respelled.
- [ ] I listened to the first and last line and to every number.
- [ ] No line is longer than about 20 words.
- [ ] The run says which provider was used. If it was the fallback, the user knows.


---


## references/platform-specs.md


# Platform specs

These are the profiles in `config/defaults.yaml`. Override fields per project with `brief.platform_profiles.<name>`. Platforms change their limits. **Check the platform's current help page before relying on a hard limit for a client delivery**, and update `max_seconds` if it has changed.

| key | Frame | fps | Captions | Safe margins (top/right/bottom/left px) | Length limit used | Video | Audio |
|---|---|---|---|---|---|---|---|
| `youtube` | 1920×1080 (16:9) | 30 | SRT upload (not burned) | 70/110/80/110 | none | H.264 High, CRF 18, maxrate 16M | AAC 192k 48 kHz stereo |
| `linkedin` | 1080×1350 (4:5) | 30 | burned + SRT | 70/64/80/64 | 600 s | CRF 19, maxrate 10M | AAC 192k |
| `linkedin-square` | 1080×1080 (1:1) | 30 | burned + SRT | 60/64/64/64 | 600 s | CRF 19, 10M | AAC 192k |
| `shorts` | 1080×1920 (9:16) | 30 | burned | 240/140/420/64 | 180 s | CRF 19, 12M | AAC 192k |
| `reels` | 1080×1920 (9:16) | 30 | burned | 250/140/480/64 | 90 s | CRF 19, 10M | AAC 192k |
| `tiktok` | 1080×1920 (9:16) | 30 | burned | 220/160/460/64 | 600 s | CRF 19, 10M | AAC 192k |
| `x` | 1920×1080 (16:9) | 30 | burned + SRT | 70/110/90/110 | 140 s | CRF 20, 12M | AAC 128k |
| `thumbnail` | 1280×720 PNG | n/a | n/a | 40/56/40/56 | n/a | n/a | n/a |

Common to every cut:
- H.264 High profile, `yuv420p`
- constant 30 fps, GOP 2 s (`-g 60`)
- AAC-LC 48 kHz stereo
- `-movflags +faststart` (the moov atom first, for progressive playback)
- integrated loudness **-14 LUFS**, true peak **≤ -1.5 dBTP**, two-pass `loudnorm`, linear mode

## Why these numbers

- **-14 LUFS:** YouTube, Spotify and most social apps normalise playback to roughly -14 LUFS. Mastering there means the platform neither turns you down (wasting dynamics) nor up (raising noise). -1.5 dBTP leaves headroom for AAC encoding overshoot.
- **4:5 for LinkedIn:** in a desktop or mobile feed it takes about 25% more screen than 16:9. Square is the safe alternative.
- **Burned captions on social:** most feed video autoplays muted. YouTube viewers expect selectable subtitles, so YouTube gets an SRT instead.
- **9:16 safe zones:**
  - The top band holds the account and search UI.
  - The right column holds like, comment and share.
  - The bottom band holds the caption, music ticker and CTA.
  - Keep text and faces inside the safe rectangle. Burned captions sit *above* the bottom band.
- **CRF + maxrate:** quality-targeted encoding with a ceiling. The platform re-encodes anyway, so feed it a clean, high-quality master and keep files uploadable.

## Delivery checklist per platform

- **YouTube:**
  - the MP4
  - SRT uploaded as subtitles (language set)
  - a 1280×720 thumbnail (< 2 MB)
  - a title of ≤ 70 characters with the keyword first
  - chapters in the description: the first is `0:00`, there are at least 3, and each is ≥ 10 s
  - sources and credits
- **LinkedIn:**
  - the 4:5 MP4 with burned captions, plus the SRT for the caption upload
  - post text in which the first 2 lines carry the hook (they show before "…see more")
  - ≤ 3 hashtags
- **Shorts, Reels, TikTok:**
  - the 9:16 cut, with the hook in the first second
  - burned captions
  - nothing important inside the UI bands
  - a loopable ending helps
- **X:**
  - ≤ 140 s (2:20) for standard accounts
  - 16:9 or 1:1
  - burned captions

## QA thresholds (`qa.py`)

| Check | Pass |
|---|---|
| codec / profile / pix_fmt | h264 / High / yuv420p |
| resolution | profile W×H × render scale (±2 px) |
| fps | profile fps ± 0.01 |
| audio | AAC, 48000 Hz, 2 ch |
| faststart | moov before mdat |
| duration | timeline ± 0.15 s; ≤ `max_seconds` |
| loudness | target ± 1.0 LU |
| true peak | ≤ target + 0.5 dB |
| dropouts | no silence > 1.5 s (−50 dB) overlapping narration |
| black frames | none outside the first and last 0.5 s (WARN) |
| captions, text lint, facts | zero issues |


---


## references/reference-video-analysis.md


# Reference-video analysis

Users often say "make it like this one". This method extracts the *rhythm* of a reference so you can match its feel with entirely original material.

## Copyright and ethics (non-negotiable)

- The reference is downloaded **for analysis only**, into `<project>/reference/`. Never cut its footage, music, voice or graphics into the new video, and never trace its layouts or characters.
- The download stays local. It is listed in the project's `.gitignore` (`reference/src.*`). Delete it when the project ends.
- Respect the platform's terms. If the user can't legally access the video, don't download it. Ask for a local file the user has rights to, or analyse it by watching only, using its public metadata.
- What you take away are **numbers and observations**:
  - shot lengths and the energy curve
  - how long type holds
  - colour temperature
  - loudness

## Command

```bash
fishvideo reference <project> --src https://youtu.be/VIDEO_ID     # yt-dlp, ≤ 720p, analysis only
fishvideo reference <project> --src path/to/reference.mp4
python scripts/reference.py analyse clip.mp4 --out some/dir --threshold 0.3
```

## What it does

1. **Download** (YouTube or other URLs): `yt-dlp -f "bv*[height<=720][ext=mp4]+ba[ext=m4a]/b[height<=720]"`. It writes `reference/src.mp4` and `src.info.json` (title, uploader, duration, URL, licence).
2. **Probe**: duration, resolution, fps, and whether there is audio.
3. **Scene detection**: `ffmpeg -vf "scale=320:-2,select='gt(scene,0.3)',showinfo"`. The `pts_time` values of the selected frames are the cuts.
   - A threshold of 0.3 suits hard cuts.
   - Use 0.2 for soft motion graphics, and 0.4 for footage with flashes.
   - Cuts closer than 0.25 s merge (flashes, dissolves).
4. **Shot list**: `shots.csv` (n, start, end, duration, palette).
5. **Pacing**:
   - shots per minute
   - mean, median and 90th-percentile shot length
   - the three longest holds
   - the **median shot length in 120-BPM bars**, and a suggested bars-per-scene
6. **Palette**: 5 dominant colours for each shot's middle frame (k-means on a 48×27 thumbnail), plus an overall palette of 6.
7. **Type-card share**: the share of shots whose frame is mostly one flat colour. This is a proxy for how much of the video is motion-graphics or type, against footage.
8. **Loudness**: `ebur128` integrated LUFS, true peak and LRA.
9. **Tempo**: the onset-strength envelope at 100 Hz, autocorrelated over 60–180 BPM and folded into 80–170.
10. **Contact sheet**: one thumbnail per shot (the first 48), as `reference/contact.png`.
11. **Report**: `reference/report.md`.

## Turning the analysis into decisions

| Reference says | Do |
|---|---|
| median shot ≈ 1 bar (2 s) | high energy: kinetic and tour scenes of 1–2 bars, cut on every bar, energy 2–3 |
| median shot ≈ 2–3 bars | explainer pace: the default templates |
| p90 shot ≫ median | it has "breather" holds; use 1–2 long whiteboards at those moments |
| type-card share > 50% | it is a motion-graphics piece; use kinetic, bullets and stat rather than tours |
| tempo ≈ 100–110 | set `bpm: 105` in the storyboard (one bar is then 2.29 s) and keep everything on the grid |
| palette warm/cream vs cold/dark | choose `brand.mode` and `bg`/`ink` tokens with the same temperature, but **your own** brand accent |
| loudness well below -14 | it was mastered quietly; still deliver at -14 LUFS |

Write a short paragraph in the project notes that names the reference, states the numbers you are matching, and confirms that nothing was reused.

## Watching it yourself

The numbers don't tell you *why* a cut works. Open the contact sheet and the video at the points the analysis flags (the longest holds, the energy lifts), and note:
- what the hook does in the first 3 s
- where the first number appears
- how the end card resolves


---


## references/website-tour-capture.md


# Website-tour capture

Filming a real website or web app is the strongest proof a video can offer. This method produces smooth, repeatable, frame-exact footage of any page, including canvas, WebGL and CSS-animated pages, plus a data track of the cursor, clicks and keys that the stage turns into a drawn cursor, ripples, key badges and camera moves.

## Virtual time (the core trick)

A headless screenshot takes 50–300 ms. If the page's clocks keep running in real time, every animation jumps between frames. `scripts/vt.js` is injected with `evaluateOnNewDocument` *before* any page script, and it replaces:
- `performance.now`, `Date` and `Date.now`, which return `epoch + virtual now`
- `setTimeout`, `setInterval` and their clear functions, which go into a queue ordered by virtual due time
- `requestAnimationFrame` and `cancelAnimationFrame`, which are queued and run once per advance
- **Web Animations** (CSS transitions and keyframes), through `document.getAnimations()`: each animation is paused and its `currentTime` set from virtual time. An animation past its end is really `finish()`ed, so its finish events fire.
- `fetch`, which is wrapped to count in-flight requests (`__vt.pending()`)

`__vt.advance(ms)` runs every timer due within the window in order, then one rAF batch, then syncs the animations. The recorder calls it with exactly `1000/fps` before each screenshot, then waits for `pending() == 0` (network answers arrive while page time stands still). The result:
- Particle heroes, counters and transitions are perfectly smooth at 30 fps, however slow the capture is.
- A re-capture is close to identical.

Limits:
- Animations driven by `<video>` playback, CSS `scroll-timeline`, or Web Workers' own clocks are not virtualised. Prefer pages without those, or hold still while they play.
- Pages that poll the wall clock through `new Date()` *inside workers* will drift; this is rare.

## Recording a shot (`capture.mjs`)

Shots are listed in the storyboard. `fishvideo storyboard` writes `build/captures.json`, and `fishvideo capture <dir> [shot…]` records them to `build/cap/<id>/00000.jpg …` plus `meta.json`, which holds:
- `w`, `h`, `dpr`, `fps`, `count`
- `marks {name: frame}`
- `frames[{x, y, d, c, k, s}]`: the pointer, button down, a click this frame, a key this frame, and scrollY
- `errors`

```yaml
captures:
  - id: docs
    url: https://example.com/docs
    viewport: {w: 1440, h: 810}     # 16:9 page; 1280x720 is lighter
    dpr: 1.5                        # 1.5–2 keeps text sharp when the camera zooms
    theme: light                    # prefers-color-scheme
    storage: {theme: light}         # localStorage before load
    session: {intro-played: "1"}    # sessionStorage before load (skip intros)
    hide: [".cookie-banner", "#chat-widget"]
    settle: 1500                    # ms of virtual time before recording
    actions:
      - {mark: index}
      - {hold: 30}                                  # frames
      - {move: {text: "Search"}, frames: 22}        # move to the element whose text starts with "Search"
      - {click: true, after: 10}
      - {mark: ctrlk}
      - {key: k, mods: [Control]}                   # recorded as a key-combo badge
      - {type: "streming", per_char: 3}
      - {mark: type}
      - {wait: 1.2}                                 # seconds
      - {key: Enter}
      - {mark: landed}
      - {scroll: {text: "Streaming"}, offset: 120, frames: 40}
      - {move: {sel: "h2#api"}, dx: -40, frames: 18}
      - {eval: "document.querySelector('details')?.setAttribute('open','')"}
  - {id: home, url: "https://example.com", auto: {seconds: 10}}   # automatic tour
```

Each action:
- `hold: n` holds for n frames; `wait: s` holds for s seconds.
- `mark: name` names the current frame. Use it for scene `from`, `cam` keys and callout timing.
- `move: {sel | text | x,y, dx, dy}` moves along a gentle quadratic arc with ease-in-out, like a hand, over `frames` (or `s`). Hover states fire, because it is a real `page.mouse`.
- `click` presses for 2 frames, then holds for `after`. `fake: true` draws a ripple without clicking.
- `type` types per character, with key sounds.
- `key` presses with modifiers.
- `scroll: {sel | text | y | by}` is an eased scroll over `frames`.
- `eval` runs a script in the page (use it sparingly, for example to open a disclosure).

`auto: {seconds, stops, step}`: holds on the hero, sweeps the cursor, then scrolls about 0.85 viewport per stop, marking `s0`, `s1`, …

## Directing the tour

1. **Plan the shot list from the script.** Each `tour` scene needs one shot (or one marked section of a shot) that shows exactly what is being said.
2. **Record about 1.5× the scene length.** The scene picks a window with `from` and `speed` (1.0–1.6). `storyboard` warns when the footage is shorter than the scene.
3. **Move with purpose.** The cursor goes to what the narration names, rests 0.5–1 s, then acts. Keep the path short, and don't circle.
4. **Camera:**
   - Start wide (`z: 1`).
   - Push in (`z: 1.15–1.35`) on the element being discussed, with explicit `x,y` in page pixels so the frame is stable.
   - Pull back before the cut.
   - Don't zoom past 1.5 unless `dpr ≥ 2`.
5. **Callouts:**
   - One at a time, ≤ 6 words, with one `*accent*` word.
   - Anchor to page coordinates (`page: {x, y}`) so the callout follows the camera.
   - Time it from a mark (`m`, `d`).
6. **Light and dark:** to show both, capture two shots, or click the site's own theme toggle on camera.

## Safety and etiquette (read-only)

- Film public pages, the user's own product, or the user's local dev server only.
- Never sign in, submit forms, purchase, post, or click anything destructive. Typing into a search box is fine.
- Don't film personal data (other people's names, emails or dashboards). Use staging or demo data.
- Hide cookie banners and chat widgets rather than clicking them away.
- A dev server is the user's: never stop or restart it. Capture against it as a visitor.
- If the UI shows numbers it measures *from the recording browser* (FPS, tokens/s, latency), hide them. They describe the headless capture, not the product.

## Troubleshooting

| Problem | Fix |
|---|---|
| Black WebGL canvas | The launch flags `--use-angle=swiftshader --enable-unsafe-swiftshader` (already set). |
| Intro animation or splash on every shot | `session`/`storage` flags that the site uses to skip it, or a longer `settle`. |
| Text selector not found | Use `sel:` with a stable selector. `text:` matches the start of an element's text, case-insensitively. |
| Lazy images still blank | Increase `settle`, add `{wait: 0.5}` after scrolls, or `{eval: "window.scrollTo(0, document.body.scrollHeight)"}` then back during settle. |
| Footage jumps when the page loads a font late | Add `wait_for: "body.fonts-ready"`, or a longer settle. |
| Captures too heavy | Use `dpr: 1` and quality 80. A 10 s shot at 1440×810 dpr 1.5 is about 300 JPEGs, about 60 MB. |


---


## references/open-source-field-guide.md


# Open-source field guide

This is what the best open-source projects in each field do, and what this skill took from them. We **reimplement ideas in our own code**. Nothing here is copied, and GPL/AGPL/non-commercial projects supplied ideas only.

Tags: **[read]** = verified in the project's files or GitHub metadata on 2026-11-04. **[memory]** = from general knowledge and not re-verified; check it before relying on it, especially licences. Stars change, so don't choose by them alone.

## What we adopted

| Field | Best project | Licence | What we took | Where |
|---|---|---|---|---|
| Agent-driven HTML video | heygen-com/hyperframes (78.8k★) | Apache-2.0 [read] | Motion numbers, easing by role, caption grouping, the review loop | `references/scene-catalogue.md` Motion rules, `scripts/review.py` |
| Springs | remotion (custom licence, ideas only), revideo (MIT) | [read] | An analytic damped spring, deterministic at any fps | `L.spring` |
| Silence cutting | auto-editor (5.8k★) | Unlicense [read] | Cut by real audio level, not word timings; speed up idle stretches | `voice.tighten`, `screenrec tighten` |
| Scene detection | PySceneDetect (5.4k★) | BSD-3 [read] | AdaptiveDetector ratio: change ÷ neighbours' change ≥ 3, change ≥ a floor, minimum gap (no false cuts on pans) | `dissect.py` |
| Word alignment | WhisperX (25k★, BSD-2), faster-whisper | [read] | Align a user's voiceover to the script | `voiceover.py` |
| Music analysis | librosa (8.8k★) | ISC [read] | Beats, downbeat, drop detection, so cuts land on the music | `beats.py` |
| Mastering by reference | matchering | GPL-3 [read], ideas only | Average spectrum of the loudest parts and a bounded matching EQ (not yet built; see gaps) | none yet |
| Natural local voice | Kokoro (9.9k★) | Apache-2.0 [read]; weights [memory] | Per-line speed, punctuation prosody, voice blending | `voice.py`, `mood:` |
| Script prompts | MoneyPrinterTurbo, ShortGPT | [read] prompts; licences not confirmed, so ideas only | Strict output constraints, banned openers, search terms in narration order | `references/topic-to-video.md` |
| Research | gpt-researcher (30k★) | Apache-2.0 [read], ideas only | Sub-questions → sources → numbered citations → a reviewer pass that drops uncorroborated claims | SKILL step 2 |
| SKILL.md design | remotion-best-practices, hyperframes skill | [read] | A router (when X, load Y), short numbered hard rules, an explicit work loop | `SKILL.md` |
| Licensed cut-outs | rembg | MIT code [memory] | `isnet-general-use`; never the non-commercial default model | `assets cutout` |

## Evaluated and rejected, or postponed

| Project | Why |
|---|---|
| MusicGen | Weights are CC-BY-NC [memory]: not usable commercially. |
| ACE-Step (Apache-2.0 [read]), YuE, DiffRhythm | Need a GPU; impractical on a CPU-only PC. Library tracks and the synth score are better. Reconsider on a GPU. |
| F5-TTS | Code is MIT [read], but the weights are CC-BY-NC [memory]. Skip for commercial use. |
| Chatterbox | `exaggeration` / `cfg_weight` look like the best emotion control [read]. Its licence was not confirmed, and it watermarks output. Evaluate next. |
| Dia, Orpheus | GPU-bound [memory]. |
| OpenVoice v2 | Tone-colour conversion only; P3. |
| madmom, essentia | Non-commercial model files / AGPL [memory]. Ideas only; librosa is used instead. |
| openSMILE | Research licence [memory]. |
| Stable Audio Open, AudioLDM | Licence limits and GPU [memory]. Use CC0 packs and synthesis. |
| Real-ESRGAN (dormant), RIFE | Too slow on CPU [memory]. Use `lanczos` + `unsharp` from ffmpeg. |
| open_clip visual stock ranking | Feasible (~600 MB with torch [memory]). Only worthwhile if keyword ranking misses often. |
| GSAP | Free but not OSI open source [memory]. We use our own eases. |

## Worth adding next

- **Bounded reference-matching EQ:** 1/3-octave average spectra of a reference and our mix, with a correction of at most ±4 dB per band.
- **Lottie assets:** `lottie-web` (MIT [read]; stale since 2025) can be driven frame by frame with `goToAndStop(frame, true)`. Check each animation's own licence.
- **Sonniss GDC packs and Freesound CC0** for risers, paper and typing (Kenney has none). Verify each pack's terms before bundling.

## Limits of this research

Each project was read through a small number of files. Many items are marked [memory], and no project's claims were benchmarked here. Mixing numbers are standard practice checked against one source (HyperFrames), and they were not tested on physical phones; only a speaker simulation was used.


---


## references/trending-analysis.md


# What trending videos do that generated ones don't

Analysis of successful YouTube videos, sampled with `scripts/dissect.py` (frame sheets, spectrogram, loudness). Long videos were sampled only every 4 seconds, so their cut counts mean little, but the visual system and loudness numbers are reliable. Audio was measured, not heard.

Re-run this for each new genre; trends shift:

```bash
fishvideo yt search "<genre>" -n 15 --recent-days 90 --json     # rank by views_per_day; prefer distinct channels
fishvideo yt download <url> --purpose reference --project <dir>
fishvideo dissect <file> --out <dir>/reference/dissect --fps 4
```

Search results include off-format videos (full short films, tutorials about the format). Check the format before learning from one.

## The patterns

**1. One recurring character carries the film.** 3Blue1Brown's nameless box, a white dot in "Emotions", the red V in the V for Vendetta typography piece, the face and eye in Bright Side. The viewer follows *something*, and everything happens to it. A generated video that introduces a new object each scene feels like a slideshow. Use `anchor` or `carry` and the `match` transition, and choose the character when you write the beats.

**2. Diagrams evolve; they aren't assembled.** 3Blue1Brown keeps one persistent box and re-labels it, zooms into it and fills it in, instead of adding unrelated nodes. Bright Side pushes the camera from the face into the eye, and each part gets one thin label. Prefer `neonline` with `follow`, `diagram` with `morph`, and camera push-throughs between levels.

**3. No dead frames at a transition.** Top videos keep a subject on screen across the cut (match cuts, flips, type-to-type). Blurring to an empty background for several frames breaks attention.

**4. The first seconds have motion and a hit.** Examples are a set-piece gag, a word slam on a beat, or "open your eyes" on a closed-eye face. Hooks are not always claims: 3Blue1Brown opens on a story prompt, and Bright Side opens on an instruction to the viewer. Choose the hook from the idea, not from a template.

**5. One type system, while colour can move.** "Emotions" changes the background colour per emotion while the system stays recognisable. Many others keep one accent. Decide deliberately: per-beat `accent` when each beat is a different *state*, one accent when the video is a single argument.

**6. A word can just sit there.** Fast kinetic pieces still let a single centred word hold for 0.5–1 s, on the beat. Reading speed and impact beat constant motion.

**7. Level is a style choice, ducking is not.** Integrated loudness across these six runs from −9 to −22.5 LUFS, with loudness range from about 2 to 20 LU. Explainers carry a quiet bed with a deep duck (3Blue1Brown: about 13 dB on the bass) or a steady low bed (Bright Side: about 2 dB). Music-driven type pieces put bass well over the voice. Nothing here justifies copying a reference's master level: platforms normalise, and phones decide what is audible.

## Per video

| Video | Format | Hook | Visual system | Sound |
|---|---|---|---|---|
| 3Blue1Brown, "Large Language Models explained briefly" (8.4M) | Long explainer | Story prompt over a curling 3D scroll | Black, cream serif, one cyan accent; one box re-labelled and zoomed | −19.8 LUFS, LRA 4.3, ~13 dB bass duck, quiet bed |
| Bright Side, "What Happens Inside Your Eyes" (4.2M) | Long 3D explainer | "Open your eyes", push into the eye by 8 s | 3D renders, one upper-third label per beat, glowing accent on near-black | −15.1 LUFS, LRA 2.3, constant bed |
| "Story behind youtube shorts" (7.8M) | Animated storytime | Gag set-piece | New location or angle about every 4 s; flat colour; outlines; reacting eyes | −13.1 LUFS, LRA 7.6, voice 7.8 dB over bass |
| "Don't Blink" (1.2M) | Rhythmic kinetic type, 43 s | Riser into the first beat at 7.2 s (after a logo) | Centred type at one size; background flips black/white on the beat; cuts every 0.5–1 s | −8.9 LUFS, bass 8.9 dB over voice |
| "V" typography (2.6M) | Kinetic type, 80 s | Dramatic line | Words stack into blocks; red V anchor; old lines blur and scroll away | −21 LUFS, LRA 19.5, drama-led |
| "Emotions" (0.9M) | Kinetic type, 44 s | A dot moving on kicks | Dot as camera; each emotion changes colour, font and micro-animation; cuts about every 2.5 s | −22.5 LUFS, deep duck |

## Screen tutorials, app demos and business/science explainers

Sampled at 4 s per frame from about 11 videos, so pace is approximate. Two of the how-to videos are in Hindi, with no transcript; their workflow notes come from on-screen text only.

**What they look like.** The screen sits in an inset or a phone mock-up, often with a face bubble. Word-highlight captions run in the lower third (current word in yellow). They push in on the cursor, return to full view, and cut away to something else every 8–60 s. Short app demos change visuals every 2–4 s and leave the raw screen within 8–12 s. None of the ones sampled showed a glow cursor, click ripples or click sounds, so ours is a real upgrade if it is used well.

**Rules for `screen-explainer`:**

1. **Hook:** by 3 s show the finished result or a before/after split ("NORMAL vs WITH X"), and say the promise in under 12 words. No logo-only intro over 1.5 s.
2. **Cut-aways:** never stay on the raw screen for more than 12 s. Every 8–12 s, insert a 1.5–3 s shot: a zoom-punch, a detail, or a keyword card for the noun being named.
3. **Zoom:** 1.6–2× on each click and typing burst, ease in over about 0.35 s, hold 2–4 s, return to 1×. At most 8 zooms per minute, or it gets tiring.
4. **Captions and progress:** burned-in groups of 3–5 words with the active word highlighted, plus a corner step counter.
5. **Sound:** a whoosh at each cut and a click per ripple, music ducked 6–10 dB under the voice.

**Rules for business and science explainers:** one idea per shot, a new visual every 3–4 s, and a year, number or label as an anchor at the start of each new scene. Use a flat palette that shifts per section, and open on a quote or a story beat.

**The common workflow** in how-to videos: LLM script, then TTS, then generated visuals, then auto-edit, then layered riser and whoosh SFX with music pushed under the voice. AI-clip makers reuse **one style reference** across all beats, with one camera move per beat (push-in, tilt, dolly, tracking, pull-back). That is the same idea as our style bible and camera presets.

These numbers are one sampling run, not a law. Look at the current top videos in the user's own genre before deciding.


---


## config/defaults.yaml


````yaml
# Fish Video Skill defaults. A project's brief.yaml overrides any of these
# keys (deep merge). Numbers here are the tested defaults, not suggestions.

tempo:
  bpm: 120            # one bar = 2.0 s; every cut lands on a bar line
  beats_per_bar: 4

pacing:
  lead_in: 0.35       # s from scene start to the first spoken word
  line_gap: 0.30      # s breath between narration lines inside a scene
  tail: 0.55          # s after the last word before the cut
  wpm:                # speaking rate used to estimate before TTS exists
    default: 150
    en-IN: 145
    en-US: 155
    en-GB: 150
    hi-IN: 130
    es: 160
    fr: 150
    de: 140
  min_scene:          # s a scene needs on screen even without narration
    title: 4.0
    kinetic: 3.0
    bullets: 4.0
    stat: 4.0
    chart: 5.0
    diagram: 5.0
    whiteboard: 6.0
    steps: 5.0
    quote: 4.0
    compare: 5.0
    terminal: 5.0
    tour: 4.0
    image: 4.0
    end: 4.0
    thumbnail: 1.0

# Voice. Default: the ElevenLabs voice chosen for ImuTechz (English, India).
# Without ELEVENLABS_API_KEY (environment or the project/skill .env) the
# free edge-tts fallback is used and the build says so.
voice:
  provider: elevenlabs
  language: en-IN
  elevenlabs:
    voice_id: fydPJq00SigRIaPxWgiS
    voice_url: https://elevenlabs.io/voices/fydPJq00SigRIaPxWgiS
    model_id: eleven_multilingual_v2
    language_code: null
    output_format: mp3_44100_128
    voice_settings:
      stability: 0.42
      similarity_boost: 0.8
      style: 0.18
      use_speaker_boost: true
      speed: 0.96
    use_context: true
    max_speed: 1.12
  fallback:
    provider: edge-tts
    voice: en-IN-NeerjaNeural
    rate: "-6%"
    pitch: "+0Hz"
    volume: "+0%"
    max_rate: "+8%"
  # applied to the voice track: rumble cut, low-mid cleanup, presence + air, gentle compression, a short soft room
  post: "highpass=f=85,equalizer=f=280:t=q:w=1.2:g=-2,equalizer=f=4800:t=q:w=1.4:g=1.5,highshelf=f=9000:g=3,acompressor=threshold=-20dB:ratio=2.5:attack=8:release=120:makeup=2,aecho=0.9:0.9:23|41:0.07|0.04"

# Three stems (references/sound-design.md). Numbers come from measuring the reference Shorts.
mix:
  voice_lufs: -16            # voice stem level before summing
  music_under_voice_db: -10  # music under the voice, measured ON A SIMULATED PHONE SPEAKER, so it stays audible everywhere
  music_max_fullband_db: -8  # ...but never louder than this under the voice full-range (headphones stay balanced)
  music_carve_db: -1.5       # gentle static mid dip; the duck makes room for words. A deep dip kills music on phones
  translate:                 # small-speaker translation: bass harmonics the ear hears as bass (references/sound-design.md)
    music_drive_db: 14
    music_harmonics_db: -4
    music_width: 0.85        # slightly narrower stereo, so a mono phone speaker does not cancel it
    sfx_drive_db: 16
    sfx_harmonics_db: -6
  sfx_gain_db: 0             # manual trim on the SFX stems (never ducked)
  sfx_under_voice_db: -7     # on a phone, a typical SFX event peaks this far under typical speech (auto-calibrated)
  sfx_max_boost_db: 12
  duck:                      # sidechain: the voice ducks the music ~6 dB while speaking
    threshold: 0.05
    ratio: 3
    attack: 10
    release: 250
    makeup: 1
  loudness: -14            # LUFS integrated (overridden per platform)
  true_peak: -1.5          # dBTP
  lra: 11

score:
  key: D
  mode: minor
  progression: i-VI-III-VII
  seed: 12345
  drums: true
  sfx: true

captions:
  max_chars: 42
  max_lines: 2
  min_cue: 0.9            # s
  max_cps: 20             # characters per second (reading speed)

# Platform profiles. safe = margins (px) kept clear of UI overlays; scenes
# lay out inside them, burned-in captions sit just above the bottom one.
platforms:
  youtube:
    label: YouTube
    W: 1920
    H: 1080
    fps: 30
    safe: {top: 70, right: 110, bottom: 80, left: 110}
    burn_captions: false
    crf: 18
    maxrate: 16M
    bufsize: 32M
    audio_bitrate: 192k
    loudness: -14
    true_peak: -1.5
    max_seconds: null
    captions: {max_chars: 42, max_lines: 2}
    min_text_px: 22
  linkedin:
    label: LinkedIn (4:5)
    W: 1080
    H: 1350
    fps: 30
    safe: {top: 70, right: 64, bottom: 80, left: 64}
    burn_captions: true
    crf: 19
    maxrate: 10M
    bufsize: 20M
    audio_bitrate: 192k
    loudness: -14
    true_peak: -1.5
    max_seconds: 600
    captions: {max_chars: 32, max_lines: 2}
    min_text_px: 24
  linkedin-square:
    label: LinkedIn (1:1)
    W: 1080
    H: 1080
    fps: 30
    safe: {top: 60, right: 64, bottom: 64, left: 64}
    burn_captions: true
    crf: 19
    maxrate: 10M
    bufsize: 20M
    audio_bitrate: 192k
    loudness: -14
    true_peak: -1.5
    max_seconds: 600
    captions: {max_chars: 32, max_lines: 2}
    min_text_px: 24
  shorts:
    label: YouTube Shorts (9:16)
    W: 1080
    H: 1920
    fps: 30
    safe: {top: 240, right: 140, bottom: 420, left: 64}
    burn_captions: true
    crf: 19
    maxrate: 12M
    bufsize: 24M
    audio_bitrate: 192k
    loudness: -12
    true_peak: -1.0
    max_seconds: 180
    captions: {max_chars: 26, max_lines: 2}
    min_text_px: 28
  reels:
    label: Instagram Reels (9:16)
    W: 1080
    H: 1920
    fps: 30
    safe: {top: 250, right: 140, bottom: 480, left: 64}
    burn_captions: true
    crf: 19
    maxrate: 10M
    bufsize: 20M
    audio_bitrate: 192k
    loudness: -12
    true_peak: -1.0
    max_seconds: 90
    captions: {max_chars: 26, max_lines: 2}
    min_text_px: 28
  tiktok:
    label: TikTok (9:16)
    W: 1080
    H: 1920
    fps: 30
    safe: {top: 220, right: 160, bottom: 460, left: 64}
    burn_captions: true
    crf: 19
    maxrate: 10M
    bufsize: 20M
    audio_bitrate: 192k
    loudness: -12
    true_peak: -1.0
    max_seconds: 600
    captions: {max_chars: 26, max_lines: 2}
    min_text_px: 28
  x:
    label: X (16:9)
    W: 1920
    H: 1080
    fps: 30
    safe: {top: 70, right: 110, bottom: 90, left: 110}
    burn_captions: true
    crf: 20
    maxrate: 12M
    bufsize: 24M
    audio_bitrate: 128k
    loudness: -14
    true_peak: -1.5
    max_seconds: 140
    captions: {max_chars: 42, max_lines: 2}
    min_text_px: 22
  thumbnail:
    label: Thumbnail
    W: 1280
    H: 720
    fps: 30
    safe: {top: 40, right: 56, bottom: 40, left: 56}
    burn_captions: false
````


---


## config/interview.yaml


````yaml
# The Fish Video interview. The agent MUST run it before writing a script.
#
# How it is used:
#   fishvideo interview <dir>                 -> prints the questions still open (JSON), round by round
#   fishvideo interview <dir> --set id=value  -> records an answer (repeatable), maps it into brief.yaml
#   fishvideo interview <dir> --answers a.json-> records many answers at once
#   fishvideo gate <dir>                      -> fails while any `required` question is open
#
# Ask with the AskUserQuestion tool: up to 4 questions per call, one call per round,
# options exactly as below (labels <= 5 words, the description says the trade-off),
# the recommended option first with "(Recommended)". The user can always answer "Other".
# Skip a question only when the user's prompt already answers it unambiguously
# (record that answer with --set anyway, so the brief shows where it came from).
# Never invent an answer for a `required` question. After the last round, read the
# summary back to the user and get an explicit "yes, go" before scripting.
#
# ONE INTERVIEW, AT THE START. Everything that needs the user (voice + tone, colours,
# assets, screen steps, API keys, installs, approvals) is asked here. Once creation
# starts, do NOT ask further questions: decide from the answers + the style recipe.
# The only later contacts are (a) optional stops the user chose in `checkpoints`,
# (b) a desktop screen recording the user must perform (they chose it here, and are
# told then exactly when to act), (c) the final review, then (d) permission to clean up.
# If the user's own message later overrides an answer, follow the user.
#
# `maps_to` is the brief.yaml key path the answer is written to. `when` limits a
# question to projects where an earlier answer matches.

rounds:
  - id: purpose
    title: "Round 1 - What is this video for?"
    questions:
      - id: topic
        required: true
        header: Topic
        question: "In one sentence, what is the video about, and what should a viewer know or do after watching it? (Or give just a word or area, and I will suggest 5 trending angles first: references/topic-to-video.md)"
        free_text: true
        maps_to: summary
      - id: goal
        required: true
        header: Goal
        question: "What is the main job of this video?"
        maps_to: goal
        options:
          - {label: "Explain / teach (Recommended)", value: explain, description: "How something works; diagrams, steps, proof"}
          - {label: "Promote a product", value: promote, description: "Show the product, its benefits, one strong CTA"}
          - {label: "Motivate / story", value: story, description: "An emotional arc: tension, turn, payoff"}
          - {label: "Announce / launch", value: announce, description: "What is new, why now, where to get it"}
      - id: audience
        required: true
        header: Audience
        question: "Who is watching, and how much do they already know about the topic?"
        maps_to: audience
        options:
          - {label: "Curious beginners", value: "curious beginners; no jargon, one idea at a time", description: "Plain words, analogies, slower pace"}
          - {label: "Practitioners", value: "practitioners who know the basics", description: "Skip basics, show specifics and numbers"}
          - {label: "Experts / developers", value: "experts and developers", description: "Dense, technical, code and architecture"}
          - {label: "Buyers / decision makers", value: "buyers and decision makers", description: "Outcomes, proof, cost, trust"}
      - id: platforms
        required: true
        header: Platforms
        multi: true
        question: "Where will it be posted? (pick all; each gets its own cut and safe zones)"
        maps_to: platforms
        options:
          - {label: "YouTube Shorts 9:16", value: shorts, description: "1080x1920, <= 3 min, burned captions"}
          - {label: "YouTube 16:9", value: youtube, description: "1920x1080, SRT captions, chapters"}
          - {label: "Instagram Reels / TikTok", value: "reels,tiktok", description: "9:16, UI-safe layouts"}
          - {label: "LinkedIn 4:5", value: linkedin, description: "1080x1350, burned captions"}

  - id: shape
    title: "Round 2 - Shape and style"
    questions:
      - id: length
        required: true
        header: Length
        question: "How long should the main cut be?"
        maps_to: length
        options:
          - {label: "15-30 s Short (Recommended)", value: 25, description: "One idea, a hook in 1 s, loops well"}
          - {label: "30-60 s", value: 50, description: "Hook + 3 beats + payoff"}
          - {label: "1-3 min", value: 120, description: "A full explainer with chapters"}
          - {label: "3-10 min", value: 300, description: "Tutorial / deep dive; needs real screen footage"}
      - id: style
        required: true
        header: Style
        question: "Which visual style? (see references/style-recipes.md for what each looks and sounds like)"
        maps_to: style
        options_from: styles   # filled from config/styles.yaml: label, one-line look, best for
      - id: references
        required: false
        header: References
        question: "Any reference videos whose feel (pace, sound, look) I should match? Paste links or file paths, or say none."
        free_text: true
        maps_to: reference.videos
      - id: hook
        required: true
        header: Hook
        question: "How should the first 1-2 seconds grab attention?"
        maps_to: hook.kind
        options:
          - {label: "Bold claim / question", value: claim, description: "A surprising line in huge type, with an impact hit"}
          - {label: "Visual surprise", value: visual, description: "A striking image or cut-out reveal before any words"}
          - {label: "Pain point", value: pain, description: "Name the viewer's problem in their words"}
          - {label: "Result first", value: result, description: "Show the finished outcome, then rewind to how"}

  - id: look
    title: "Round 3 - Colours, type and texture"
    questions:
      - id: palette
        required: true
        header: Colours
        question: "Which colours? Give brand hex codes, or pick a palette (previews in references/style-recipes.md)."
        maps_to: palette
        options_from: palettes   # config/styles.yaml palettes; "Other" = user hex codes
      - id: mode
        required: true
        header: Light/dark
        question: "Light or dark background?"
        maps_to: brand.mode
        options:
          - {label: "Style default (Recommended)", value: auto, description: "Use what the chosen style is built for"}
          - {label: "Light", value: light, description: "Paper, cream or white grounds"}
          - {label: "Dark", value: dark, description: "Near-black grounds, glowing accents"}
      - id: type
        required: true
        header: Typography
        question: "What should the type feel like?"
        maps_to: type_feel
        options:
          - {label: "Heavy grotesk (Recommended)", value: grotesk, description: "Big, tight, bold sans (Inter Display 800/900): Shorts standard"}
          - {label: "Editorial serif mix", value: editorial, description: "Serif italic accents with a clean sans"}
          - {label: "Hand-drawn", value: hand, description: "Marker / handwriting for whiteboard energy"}
          - {label: "Mono / technical", value: mono, description: "Code and terminal feel"}
      - id: texture
        required: true
        header: Texture
        question: "Surface texture and finish?"
        maps_to: texture
        options:
          - {label: "Paper + halftone", value: paper, description: "Crumpled paper, halftone photos, soft shadows"}
          - {label: "Film grain + vignette", value: grain, description: "Cinematic, moody"}
          - {label: "Clean flat", value: clean, description: "No texture; crisp UI look"}
          - {label: "Grid / blueprint", value: grid, description: "Graph paper or blueprint lines"}

  - id: sound
    title: "Round 4 - Voice, music and sound effects"
    questions:
      - id: voice
        required: true
        header: Voice
        question: "Which narrator? (I render a 6-second audition of each option right now, before creation starts: fishvideo voices <dir> --audition, and give you the files to play.)"
        maps_to: voice.pick
        options_from: voices     # config/voices.yaml shortlist for the chosen language
      - id: voice_energy
        required: true
        header: Delivery
        question: "How should the narrator deliver it?"
        maps_to: voice.delivery
        options:
          - {label: "Punchy storyteller (Recommended)", value: punchy, description: "Fast (~3 words/s), dynamic, short pauses: Shorts style"}
          - {label: "Calm explainer", value: calm, description: "~2.4 words/s, warm, clear"}
          - {label: "Deep cinematic", value: cinematic, description: "Low, slow, dramatic pauses"}
          - {label: "No voice, music + text", value: none, description: "Kinetic type carries the story"}
      - id: music
        required: true
        header: Music
        question: "What background music?"
        maps_to: music.mood
        options_from: music_moods  # config/styles.yaml music moods (synth presets + library search terms)
      - id: sfx
        required: true
        header: SFX
        question: "How much sound design (whooshes, pops, risers, clicks, impacts)?"
        maps_to: sfx.density
        options:
          - {label: "Rich (Recommended)", value: rich, description: "Every entrance, cut and reveal has a sound, like top Shorts"}
          - {label: "Standard", value: standard, description: "Transitions and key reveals only"}
          - {label: "Minimal", value: minimal, description: "A few accents; music does the work"}

  - id: assets
    title: "Round 5 - Real footage, assets and screen recording"
    questions:
      - id: real_assets
        required: true
        header: Real assets
        question: "May I use real photos, stock video, icons and SFX from free-licence libraries (Pexels, Pixabay, Openverse, Wikimedia, NASA, Freesound CC0, YouTube CC-BY)? Every file is logged with its licence."
        maps_to: assets.policy
        options:
          - {label: "Yes, mix real + animated (Recommended)", value: mixed, description: "Cut-outs, b-roll and textures merged with motion graphics"}
          - {label: "Only my own files", value: own, description: "I will drop files into assets/"}
          - {label: "Animation only", value: none, description: "Everything drawn in code"}
      - id: screen
        required: true
        header: Screen rec
        question: "Should the video show a real screen (website, app or desktop) with cursor, click sounds and focus-zoom?"
        maps_to: screen.kind
        options:
          - {label: "Website tour", value: website, description: "I film a URL in headless Chromium (read-only)"}
          - {label: "Desktop / app recording", value: desktop, description: "Records your real screen; you perform the steps"}
          - {label: "I have recordings", value: files, description: "You give me .mp4 screen recordings"}
          - {label: "No screen footage", value: none, description: "Motion graphics only"}
      - id: screen_target
        required: true
        when: {screen: [website, desktop, files]}
        header: What to show
        question: "Which URL / app / files, and what are the 3-5 actions to show (e.g. open search, type 'x', click result)?"
        free_text: true
        maps_to: screen.target
      - id: brand_files
        required: false
        header: Brand files
        question: "Logo, fonts or product images to use? Give paths, or say none (I never redraw a logo)."
        free_text: true
        maps_to: brand.files

  - id: content
    title: "Round 6 - What it says"
    questions:
      - id: points
        required: true
        header: Key points
        question: "What 3-5 points must the viewer remember, in order? (rough notes are fine)"
        free_text: true
        maps_to: points
      - id: facts
        required: false
        header: Facts
        question: "Any numbers, names or claims that must appear? Give each with a source link (unsourced numbers are blocked)."
        free_text: true
        maps_to: facts_raw
      - id: cta
        required: true
        header: Call to action
        question: "What should the viewer do at the end?"
        maps_to: cta.kind
        options:
          - {label: "Follow / subscribe", value: follow, description: "Short end card, loop back to the hook"}
          - {label: "Visit a link", value: link, description: "URL on the end card + description"}
          - {label: "Comment a keyword", value: comment, description: "Drives engagement on Shorts"}
          - {label: "No CTA", value: none, description: "End on the payoff"}
      - id: language
        required: true
        header: Language
        question: "Narration and caption language / accent?"
        maps_to: language
        options:
          - {label: "English (US)", value: en-US, description: "Neutral US narrator"}
          - {label: "English (India)", value: en-IN, description: "Indian English narrator"}
          - {label: "English (UK)", value: en-GB, description: "British narrator"}
          - {label: "Hindi", value: hi-IN, description: "Devanagari captions"}

  - id: packaging
    title: "Round 7 - Captions, packaging, approvals"
    questions:
      - id: captions
        required: true
        header: Captions
        question: "Caption style on screen?"
        maps_to: captions.style
        options:
          - {label: "Bold word-pop (Recommended)", value: pop, description: "1-3 huge words at a time, the spoken word highlighted"}
          - {label: "Karaoke lines", value: karaoke, description: "Two lines, each word lit as spoken"}
          - {label: "Minimal subtitles", value: minimal, description: "Small, clean, bottom"}
          - {label: "None (SRT only)", value: none, description: "No burned captions"}
      - id: channel
        required: false
        header: Channel
        question: "Channel / brand name for the title, description and end card? (or none)"
        free_text: true
        maps_to: brand.name
      - id: thumbnail
        required: true
        header: Thumbnail
        question: "Thumbnail approach?"
        maps_to: thumbnail.style
        options:
          - {label: "Big 3-word text + subject", value: text_subject, description: "Cut-out subject, 3 huge words, one accent"}
          - {label: "Best frame + title", value: frame, description: "Strongest frame of the video with a title"}
          - {label: "Before / after", value: compare, description: "Split frame showing the change"}
      - id: checkpoints
        required: true
        header: Approvals
        question: "Once creation starts I will not ask anything else. Do you want any optional stop before the final video?"
        maps_to: approvals.required
        multi: true
        options:
          - {label: "No stops, final only (Recommended)", value: final, description: "I build everything; you review the finished video, then I ask about cleanup"}
          - {label: "Also show script first", value: storyboard, description: "One pause to read the narration + scene list"}
          - {label: "Also show style frames", value: stills, description: "One pause with a still per scene"}

  - id: keys
    title: "Round 8 - Accounts and API keys (asked now, never later)"
    note: >
      Run `fishvideo doctor --keys` first and only ask about keys that are missing AND
      matter for the answers above. For each, tell the user exactly how to get it (the
      `how` text) and what happens without it (`fallback`). Keys go into the skill's
      gitignored .env (the agent writes it only if the user pastes a key and agrees);
      never echo a key back, never commit it.
    questions:
      - id: keys_have
        required: true
        header: API keys
        multi: true
        question: "Which of these keys can you provide now? (all optional; each has a free fallback)"
        maps_to: keys.provided
        options:
          - {label: "ElevenLabs (best voices)", value: ELEVENLABS_API_KEY, description: "elevenlabs.io > Profile > API keys. Paid plan needed for commercial use. Without: free edge-tts / Kokoro voices"}
          - {label: "Pexels + Pixabay (stock)", value: "PEXELS_API_KEY,PIXABAY_API_KEY", description: "Free: pexels.com/api and pixabay.com/api/docs (sign up, key shown). Without: Openverse, Wikimedia, NASA (key-less)"}
          - {label: "Freesound (SFX)", value: FREESOUND_API_KEY, description: "Free: freesound.org/apiv2/apply. Without: Kenney CC0 packs + synthesised SFX"}
          - {label: "YouTube Data API", value: YOUTUBE_API_KEY, description: "Free: console.cloud.google.com > enable YouTube Data API v3 > Credentials. Without: yt-dlp search (slower, no 'most popular' chart)"}
      - id: keys_paste
        required: false
        when: {keys_have: [ELEVENLABS_API_KEY, "PEXELS_API_KEY,PIXABAY_API_KEY", FREESOUND_API_KEY, YOUTUBE_API_KEY]}
        header: Paste keys
        question: "Paste the keys (KEY_NAME=value, one per line), or say 'I will put them in .env myself'. I store them only in the skill's gitignored .env."
        free_text: true
        secret: true          # never write this answer into brief.yaml or any log
        maps_to: null
      - id: optional_installs
        required: false
        header: Extras
        multi: true
        question: "Install optional extras now? (each is a one-time pip install into the skill's venv)"
        maps_to: installs
        options:
          - {label: "Background removal", value: rembg, description: "rembg (~200 MB model) for photo cut-outs; without it I use provided PNGs or framed photos"}
          - {label: "Local natural voices", value: kokoro, description: "Kokoro-82M TTS (Apache-2.0, ~350 MB), offline"}
          - {label: "Word alignment", value: faster-whisper, description: "Align your own recorded voiceover to captions"}
          - {label: "None", value: none, description: "Use what is installed"}
````


---


## config/styles.yaml


````yaml
# Style recipes. A style is a LOCKED STYLE BIBLE (look, type, motif, sound, pacing)
# plus a BEAT PATTERN (scene types per meaning-beat). It contains NO copy: every word
# on screen and in the narration is written fresh for each video from the interview.
# `fishvideo script <dir>` builds the storyboard skeleton from the chosen style with
# TODO placeholders; `fishvideo gate` refuses to render while any TODO remains.

styles:
  paper-collage:
    label: "Paper collage (Recommended)"
    look: "Crumpled paper, B&W halftone cut-outs with shadows, bold labels + hand arrows, split reveals"
    best_for: "documentary facts, psychology, history, 'how X really works'"
    defaults: {texture: paper, mode: light, palette: mono-paper, type_feel: grotesk, captions: pop, sfx: rich, music: dark-drone, assets: mixed}
    pacing: {change_every: 1.1, hard_cuts_per_15s: 2, transitions: [defocus, split, zoom, cut]}
    motif: "one recurring real object per idea (photo cut-out), contact shadow top-left light"
    beats:
      - {beat: hook,      scene: collage,  note: "subject cut-out on paper + 1 bold label with an arrow; frame 0 already full"}
      - {beat: claim,     scene: split,    note: "the subject card splits open to reveal the hidden mechanism"}
      - {beat: concept,   scene: collage,  note: "2-3 objects joined by hairline connectors, labels"}
      - {beat: escalate,  scene: whiteboard, alt: diagram, note: "the mechanism drawn on paper (diagram is mandatory in explainers)"}
      - {beat: turn,      scene: cone,     note: "cut to dark: spotlight cone reveals the key line"}
      - {beat: payoff,    scene: collage,  note: "callback to the hook object, changed"}
      - {beat: end,       scene: end,      note: "one line CTA, or loop to frame 0"}
  studio-kinetic:
    label: "Studio kinetic"
    look: "Bright studio gradient, big kinetic words, cut-out subject, 3D coin/logo carousel, orbit glow"
    best_for: "people, companies, products, 'X isn't just Y'"
    defaults: {texture: clean, mode: light, palette: studio-lime, type_feel: grotesk, captions: pop, sfx: rich, music: hiphop-groove, assets: mixed}
    pacing: {change_every: 0.9, hard_cuts_per_15s: 2, transitions: [zoom, flip, cut]}
    motif: "accent-coloured glow spheres orbiting the subject"
    beats:
      - {beat: hook,    scene: kinetic,  note: "title slam at 0.0 s with impact"}
      - {beat: subject, scene: collage,  note: "cut-out rises from bottom + orbit ring"}
      - {beat: list,    scene: coinflip, note: "items land one by one on the word that names them"}
      - {beat: proof,   scene: stat,     alt: chart, note: "one sourced number"}
      - {beat: end,     scene: end}
  neon-diagram:
    label: "Neon diagram"
    look: "Dark grid, glowing line charts and flows, glow icons popping at vertices, electric arcs"
    best_for: "expectation vs reality, growth, processes, data stories"
    defaults: {texture: grid, mode: dark, palette: neon-amber, type_feel: grotesk, captions: pop, sfx: rich, music: dark-build, assets: none}
    pacing: {change_every: 0.75, hard_cuts_per_15s: 1, transitions: [zoom, flash, cut]}
    motif: "one glowing line that keeps drawing across scenes"
    beats:
      - {beat: hook,     scene: neonline, note: "straight line = the expectation"}
      - {beat: reality,  scene: neonline, note: "zigzag with icons at each vertex, zap per vertex"}
      - {beat: mechanism, scene: diagram, note: "the flow behind it, glowing boxes"}
      - {beat: proof,    scene: chart}
      - {beat: payoff,   scene: kinetic, note: "riser into impact, then silence"}
  glow-metaphor:
    label: "Glow metaphor"
    look: "Black, one glowing sphere as the hero, morphs, tunnels, starfields, ring blooms"
    best_for: "motivation, habits, mindset, abstract ideas"
    defaults: {texture: grain, mode: dark, palette: mono-glow, type_feel: grotesk, captions: pop, sfx: rich, music: cinematic-swell, assets: none}
    pacing: {change_every: 1.2, hard_cuts_per_15s: 0, transitions: [zoom, defocus]}
    motif: "the sphere is the viewer; every scene changes what happens to it"
    beats:
      - {beat: hook,    scene: glowshapes, note: "sphere moving alone"}
      - {beat: trap,    scene: glowshapes, note: "sphere boxed in"}
      - {beat: spiral,  scene: tunnel,     note: "zoom-through on the turning line"}
      - {beat: growth,  scene: glowshapes, note: "something grows from the sphere"}
      - {beat: payoff,  scene: ringbloom}
  screen-explainer:
    label: "Screen explainer"
    look: "Real screen recording in an inset frame, glow cursor, click ripples + sounds, zoom-to-focus, spotlight, STEP cards"
    best_for: "app/website tutorials, product demos, 'how to do X in Y'"
    defaults: {texture: clean, mode: dark, palette: brand, type_feel: grotesk, captions: pop, sfx: standard, music: lofi-tech, assets: mixed}
    pacing: {change_every: 2.0, hard_cuts_per_15s: 3, transitions: [zoom, cut, wipe]}
    motif: "brand-coloured inset frame; every click = ripple + click sound + 1.6-2x focus zoom"
    beats:
      - {beat: hook,   scene: kinetic, note: "show the RESULT first (a frame of the finished thing)"}
      - {beat: step,   scene: tour,    repeat: true, note: "STEP N card -> recording, spotlight on the control, zoom to cursor, callout"}
      - {beat: recap,  scene: steps}
      - {beat: end,    scene: end}
  whiteboard-explainer:
    label: "Whiteboard explainer"
    look: "Grid paper, marker-drawn diagrams built live, hand lettering, one accent"
    best_for: "technical mechanisms, architecture, step-by-step teaching"
    defaults: {texture: grid, mode: light, palette: warm-paper, type_feel: hand, captions: karaoke, sfx: standard, music: calm-pulse, assets: none}
    pacing: {change_every: 1.5, hard_cuts_per_15s: 1, transitions: [wipe, fade]}
    motif: "the marker pen; every idea is drawn, never just shown"
    beats:
      - {beat: hook,      scene: kinetic}
      - {beat: idea,      scene: whiteboard}
      - {beat: mechanism, scene: whiteboard, repeat: true}
      - {beat: proof,     scene: chart, alt: stat}
      - {beat: end,       scene: end}
  stick-fable:
    label: "Minimal fable (music only)"
    look: "Black lanes, simple figures/shapes compared side by side, one punchline"
    best_for: "comparisons (A vs B vs C), loops, no-voice Shorts"
    defaults: {texture: clean, mode: dark, palette: mono-red, type_feel: grotesk, captions: none, sfx: minimal, music: phonk-loop, assets: none, voice: none}
    pacing: {change_every: 1.8, hard_cuts_per_15s: 0, transitions: [cut]}
    motif: "the same action performed by each lane, diverging"
    beats:
      - {beat: setup,     scene: compare}
      - {beat: diverge,   scene: compare}
      - {beat: punchline, scene: kinetic, note: "one line, hard cut on"}

palettes:
  mono-paper:  {label: "Mono paper", mode: light, colors: {bg: "#c9c6bf", surface: "#e4e1da", paper: "#d8d4cc", ink: "#0d0d0d", fg: "#151515", muted: "#4a4740", dim: "#77736b", accent: "#d63a1f", accent2: "#1f1f1f"}}
  studio-lime: {label: "Studio + lime", mode: light, colors: {bg: "#ececec", surface: "#f6f6f6", paper: "#f2f2f2", ink: "#2b2b2b", fg: "#333333", muted: "#5f5f5f", dim: "#8a8a8a", accent: "#9be15d", accent2: "#3a3a3a"}}
  neon-amber:  {label: "Neon amber on black", mode: dark, colors: {bg: "#0f0f10", surface: "#17171a", paper: "#121214", ink: "#f5f5f5", fg: "#eeeeee", muted: "#9a9a9a", dim: "#5c5c5c", accent: "#f2b722", accent2: "#ffe600"}}
  mono-glow:   {label: "White glow on black", mode: dark, colors: {bg: "#050505", surface: "#0e0e0e", paper: "#0a0a0a", ink: "#f5f5f5", fg: "#f0f0f0", muted: "#a0a0a0", dim: "#5a5a5a", accent: "#f5f5f5", accent2: "#8fb7ff"}}
  warm-paper:  {label: "Warm paper + orange", mode: light, colors: {bg: "#f3ede2", surface: "#faf7f0", paper: "#f8f4ec", ink: "#1a1714", fg: "#1c1915", muted: "#58524a", dim: "#847c70", accent: "#d9481e", accent2: "#15858d"}}
  mono-red:    {label: "Black + blood red", mode: dark, colors: {bg: "#000000", surface: "#0b0b0b", paper: "#060606", ink: "#ffffff", fg: "#f2f2f2", muted: "#6b6b6b", dim: "#3d3d3d", accent: "#b3202a", accent2: "#6b6b6b"}}
  ocean-tech:  {label: "Deep blue tech", mode: dark, colors: {bg: "#07111f", surface: "#0d1b2e", paper: "#0a1626", ink: "#eaf2ff", fg: "#dce8fb", muted: "#8aa0bf", dim: "#4d6280", accent: "#3fa9ff", accent2: "#7cf2d4"}}
  brand:       {label: "My brand colours", mode: auto, colors: {}}   # filled from the user's hex codes

music_moods:
  dark-drone:      {label: "Dark documentary drone", search: "dark ambient cinematic documentary", score: {key: D, mode: minor, progression: i-VI-III-VII, bpm: 120, drums: false}}
  hiphop-groove:   {label: "Bass hip-hop groove", search: "hip hop beat corporate energetic", score: {key: F, mode: minor, progression: i-VII-VI-VII, bpm: 92, drums: true}}
  dark-build:      {label: "Dark build-up", search: "dark build up tension", score: {key: E, mode: minor, progression: i-i-VI-VII, bpm: 100, drums: false}}
  cinematic-swell: {label: "Cinematic swells", search: "cinematic emotional ambient swell", score: {key: C, mode: minor, progression: i-VI-III-VII, bpm: 80, drums: false}}
  lofi-tech:       {label: "Lo-fi tech", search: "lofi chill tech background", score: {key: A, mode: dorian, progression: i-IV-i-VII, bpm: 85, drums: true}}
  calm-pulse:      {label: "Calm pulse", search: "calm corporate minimal pulse", score: {key: D, mode: major, progression: I-V-vi-IV, bpm: 110, drums: true}}
  phonk-loop:      {label: "Phonk / gym loop", search: "phonk trap gym", score: {key: G, mode: minor, progression: i-i-VI-VII, bpm: 140, drums: true}}
  upbeat-promo:    {label: "Upbeat promo", search: "upbeat inspiring corporate", score: {key: G, mode: major, progression: I-V-vi-IV, bpm: 120, drums: true}}

# Script mood -> defaults, used when the user picks "style default" (references/diagram-design.md)
mood_map:
  curious:      {style: paper-collage,        palette: mono-paper,  music: dark-drone,      delivery: calm}
  bold:         {style: studio-kinetic,       palette: studio-lime, music: hiphop-groove,   delivery: punchy}
  tension:      {style: neon-diagram,         palette: neon-amber,  music: dark-build,      delivery: punchy}
  reflective:   {style: glow-metaphor,        palette: mono-glow,   music: cinematic-swell, delivery: cinematic}
  practical:    {style: screen-explainer,     palette: ocean-tech,  music: lofi-tech,       delivery: calm}
  technical:    {style: whiteboard-explainer, palette: warm-paper,  music: calm-pulse,      delivery: calm}
  blunt:        {style: stick-fable,          palette: mono-red,    music: phonk-loop,      delivery: none}
````


---


## config/voices.yaml


````yaml
# Narrator shortlist offered in the interview (Round 4). The agent shows the 4 best
# matches for the chosen language + delivery, and renders auditions BEFORE creation
# starts:  fishvideo voices <dir> --audition   (same line, every shortlisted voice,
# build/auditions/<id>.mp3; play/list them for the user, who picks one).
#
# Delivery presets set rate/pitch/post-processing so no voice sounds "stock":
#   punchy    ~3.0 words/s, tighter compression, +presence    (Shorts storytelling)
#   calm      ~2.4 words/s, warmer, a touch more room          (explainers)
#   cinematic ~2.2 words/s, -2 st pitch, longer pauses, deeper (motivational / documentary)

delivery:
  punchy:    {edge_rate: "+2%",  edge_pitch: "+0Hz", eleven_speed: 1.08, eleven_style: 0.45, stability: 0.32, line_gap: 0.18, wps: 3.0,
              post: "highpass=f=90,equalizer=f=250:t=q:w=1.2:g=-2.5,equalizer=f=5000:t=q:w=1.2:g=2.5,highshelf=f=9500:g=3,acompressor=threshold=-22dB:ratio=3.5:attack=5:release=90:makeup=3,aecho=0.9:0.9:18:0.05"}
  calm:      {edge_rate: "-4%",  edge_pitch: "+0Hz", eleven_speed: 0.96, eleven_style: 0.2, stability: 0.45, line_gap: 0.32, wps: 2.4,
              post: "highpass=f=80,equalizer=f=280:t=q:w=1.2:g=-2,equalizer=f=4800:t=q:w=1.4:g=1.5,highshelf=f=9000:g=2.5,acompressor=threshold=-20dB:ratio=2.5:attack=8:release=120:makeup=2,aecho=0.9:0.9:23|41:0.07|0.04"}
  cinematic: {edge_rate: "-10%", edge_pitch: "-6Hz", eleven_speed: 0.9, eleven_style: 0.35, stability: 0.5, line_gap: 0.45, wps: 2.2,
              post: "highpass=f=60,lowshelf=f=140:g=2,equalizer=f=320:t=q:w=1.2:g=-2,equalizer=f=4200:t=q:w=1.4:g=1.5,acompressor=threshold=-21dB:ratio=3:attack=10:release=160:makeup=2.5,aecho=0.85:0.9:40|73:0.12|0.07"}

# provider: edge (free, needs internet, word timings) | elevenlabs (ELEVENLABS_API_KEY; paid plan
# for commercial use and library voices) | kokoro (local, Apache-2.0, `pip install kokoro soundfile`;
# word timings via faster-whisper alignment)
voices:
  en-US:
    - {id: andrew,  provider: edge, voice: en-US-AndrewMultilingualNeural, label: "Andrew - warm, confident male", fits: [calm, punchy]}
    - {id: brian,   provider: edge, voice: en-US-BrianMultilingualNeural,  label: "Brian - casual, sincere male", fits: [punchy, calm]}
    - {id: ava,     provider: edge, voice: en-US-AvaMultilingualNeural,    label: "Ava - expressive, caring female", fits: [calm, punchy]}
    - {id: emma,    provider: edge, voice: en-US-EmmaMultilingualNeural,   label: "Emma - cheerful, clear female", fits: [punchy]}
    - {id: christopher, provider: edge, voice: en-US-ChristopherNeural,    label: "Christopher - deep authority male", fits: [cinematic]}
    - {id: guy,     provider: edge, voice: en-US-GuyNeural,                label: "Guy - passionate male", fits: [cinematic, punchy]}
    - {id: adam11,  provider: elevenlabs, voice: pNInz6obpgDQGcFmaJgB,     label: "ElevenLabs Adam - deep narrator (key)", fits: [cinematic, calm]}
    - {id: kokoro_heart, provider: kokoro, voice: af_heart,                label: "Kokoro Heart - natural female (local)", fits: [calm]}
    - {id: kokoro_michael, provider: kokoro, voice: am_michael,            label: "Kokoro Michael - natural male (local)", fits: [calm, cinematic]}
  en-GB:
    - {id: ryan,    provider: edge, voice: en-GB-RyanNeural,   label: "Ryan - British male", fits: [calm, cinematic]}
    - {id: thomas,  provider: edge, voice: en-GB-ThomasNeural, label: "Thomas - British male, lighter", fits: [punchy, calm]}
    - {id: sonia,   provider: edge, voice: en-GB-SoniaNeural,  label: "Sonia - British female", fits: [calm]}
    - {id: libby,   provider: edge, voice: en-GB-LibbyNeural,  label: "Libby - British female, bright", fits: [punchy]}
    - {id: kokoro_george, provider: kokoro, voice: bm_george,  label: "Kokoro George - British male (local)", fits: [cinematic, calm]}
  en-IN:
    - {id: neerja_x, provider: edge, voice: en-IN-NeerjaExpressiveNeural, label: "Neerja Expressive - Indian female", fits: [punchy, calm]}
    - {id: prabhat,  provider: edge, voice: en-IN-PrabhatNeural,          label: "Prabhat - Indian male", fits: [calm, cinematic]}
    - {id: eleven_in, provider: elevenlabs, voice: fydPJq00SigRIaPxWgiS,  label: "ElevenLabs India narrator (key, paid plan)", fits: [calm, punchy]}
    - {id: neerja,   provider: edge, voice: en-IN-NeerjaNeural,           label: "Neerja - Indian female, neutral", fits: [calm]}
  hi-IN:
    - {id: madhur, provider: edge, voice: hi-IN-MadhurNeural, label: "Madhur - Hindi male", fits: [calm, punchy, cinematic]}
    - {id: swara,  provider: edge, voice: hi-IN-SwaraNeural,  label: "Swara - Hindi female", fits: [calm, punchy]}

audition_line:
  en: "Most people never notice this. But once you see it, you can't unsee it."
  hi: "ज़्यादातर लोग इसे कभी नोटिस नहीं करते। लेकिन एक बार देख लिया, तो भूल नहीं पाओगे।"
````


---


## templates/brief.yaml


````yaml
# Fish Video brief. `fishvideo new` fills this from a prompt; edit anything.
# Every number that will appear on screen or in the narration must be in
# `facts` with a source, or be present in a fetched source (research/).

title: ""                     # working title; becomes the package folder name
prompt: ""                    # the original one-line request, kept for the record
goal: explain                 # explain | promote | announce | teach
audience: "developers and technical founders"
structure: explainer          # explainer | product-tour | promo | listicle | story | announcement
length: 60                    # target seconds for the main cut (snapped to 2 s bars at 120 BPM)
platforms: [youtube]          # youtube | linkedin | linkedin-square | shorts | reels | tiktok | x
language: en-IN               # BCP-47; drives voice, wpm, captions, fonts
tone: "warm, precise, confident; no hype words"

brand:
  name: ""
  url: ""
  tagline: ""
  logo: ""                    # project-relative path, e.g. assets/logo.svg (empty = typographic badge)
  mode: light                 # light | dark
  colors:                     # the whole design system is these tokens
    bg: "#f3ede2"
    surface: "#faf7f0"
    paper: "#f8f4ec"
    ink: "#1a1714"
    fg: "#1c1915"
    muted: "#58524a"
    dim: "#847c70"
    accent: "#d9481e"
    accent2: "#15858d"
  fonts:                      # bundled: Inter, Fraunces, JetBrains Mono, Caveat, Noto Sans Devanagari
    sans: Inter
    serif: Fraunces
    mono: JetBrains Mono
    hand: Caveat
  font_files: []              # [{family, src: assets/MyFont.woff2, weight: 700, style: normal}]

points: []                    # the 3-6 things the viewer must remember, in order
facts: []                     # [{text: "10,631 agents and skills", value: "10,631", source: "https://..."}]
sources: []                   # URLs to fetch for research and fact-checking

website:                      # optional: a site or product to tour live (read-only)
  url: ""
  theme: light
  pages: []                   # [{path: /pricing, seconds: 6}]; empty = automatic tour of url
  hide: []                    # CSS selectors to hide while filming (cookie banners, chat widgets)
  storage: {}                 # localStorage keys to set before load (e.g. dismiss an intro)

reference:                    # optional: a video whose pacing to study (analysis only; never reuse footage or music)
  src: ""                     # local file or YouTube URL

cta:
  text: ""
  url: ""

voice:                        # default: ElevenLabs voice fydPJq00SigRIaPxWgiS (English, India); edge-tts fallback
  provider: elevenlabs        # elevenlabs | edge-tts | none
  language: en-IN
  elevenlabs:
    voice_id: fydPJq00SigRIaPxWgiS
    model_id: eleven_multilingual_v2
    voice_settings: {stability: 0.42, similarity_boost: 0.8, style: 0.18, use_speaker_boost: true, speed: 0.96}
  fallback:
    provider: edge-tts
    voice: en-IN-NeerjaNeural
    rate: "-6%"
  pronunciations: {}          # {"IMURUN": "Imu run", "GGUF": "G G U F"}: applied to every line

score:                        # original synthesised score (no samples)
  key: D
  mode: minor                 # minor | major | dorian | mixolydian
  progression: i-VI-III-VII
  seed: 12345

captions:
  burn: auto                  # auto = per platform (LinkedIn/Shorts/Reels/X burn in, YouTube ships an SRT)

hashtags: []
````


---


## templates/description.md


````md
TITLE
{{title}}

DESCRIPTION
{{summary}}

{{points}}

{{cta}}
{{links}}

{{chapters}}

{{sources}}

{{hashtags}}

{{credits}}
````


---


## templates/storyboard.yaml


````yaml
# Storyboard template. Copy to <project>/storyboard.yaml and rewrite.
# Scene types: title kinetic bullets steps stat chart diagram whiteboard compare quote terminal tour image end
# Timing is derived from narration (`say`) + animation and snapped to 2 s bars at 120 BPM.
# Markup in on-screen text: *accent*  ~serif italic~  **bold**  \n
# Narration markup: {shown|spoken}, e.g. {GGUF|G G U F}
title: "<working title>"
bpm: 120
hud: {chapter: true, rail: true}
scenes:
  - id: hook
    type: kinetic
    params: {lines: ["<outcome in ≤ 6 words>", "~<the twist>~"]}
    say: "<the hook, one sentence>"

  - id: title
    type: title
    params: {kicker: "<site or category>", title: "<Name>", subtitle: "<one-line promise>", chips: ["<fact 1>", "<fact 2>", "<the key benefit>"]}
    say: "<Name>: <promise>."

  - id: idea
    type: bullets
    chapter: "01 · What it is"
    transition: wipe
    params: {kicker: "In short", title: "<Name> in three parts", items: ["<part 1>", "<part 2>", "<part 3>"]}
    say: ["<part 1, spoken>.", "<part 2>.", "<part 3>."]

  - id: how
    type: whiteboard
    chapter: "02 · How it works"
    transition: wipe
    params: {tag: "How it works", step: 1, title: "<mechanism>", flow: ["<A>", "<B>", "<C>"], notes: ["<a note>", "", "<a note>"], take: "<the takeaway, with one *accent* word>"}
    say: "<explain A to B to C in one or two sentences>"

  - id: proof
    type: stat
    params: {value: 0, suffix: "", label: "<what the number means>", source: "<URL or brief.facts source>"}
    say: "<the number, said once — must be in brief.facts>"

  - id: get
    type: terminal
    chapter: "03 · Get it"
    transition: wipe
    params: {kicker: "Get it", title: "One command.", lines: [{cmd: "<install command>"}, {comment: "then"}, {cmd: "<run command>"}]}
    say: "<how to start>"

  - id: end
    type: end
    params: {name: "<Name>", tagline: "<tagline>", links: ["<url>"], cta: "<one action>"}
    say: "<Name>. <tagline>"

captures: []
#  - {id: home, url: "https://…", auto: {seconds: 8}}

thumbnail: {kicker: "<NAME · TOPIC>", title: "<2–5 big words>", accent: "<one word>", badge: "<format, e.g. 60-second explainer>"}
````


---

## Install

This skill runs code (Python + Node). Download the folder edition,
https://www.imutechz.in/api/skill/fish-video-skill/zip, unzip it as `fish-video-skill/` inside your
agent's skills directory (`~/.claude/skills/`, `.cursor/skills/`, `.opencode/skills/`, ...),
then follow README.md: `npm install`, a Python venv with `pip install -r requirements.txt`,
and `python scripts/fishvideo.py doctor`.
