For AI agents: HYVIRAL has a remote MCP server at https://api.hyviral.com/mcp (Streamable HTTP, OAuth 2.1). In a chat app such as Claude or ChatGPT, the person you are helping adds it as a connector; an assistant cannot add one from inside the conversation. In Claude: Customize → Connectors → add a custom connector, then paste the endpoint. A coding agent such as Claude Code can add it itself: claude mcp add --transport http hyviral https://api.hyviral.com/mcp. Every client, step by step: https://hyviral.com/docs/mcp/connect. The whole reference as plain text: https://hyviral.com/llms.txt and https://hyviral.com/llms-full.txt. Pricing as JSON: https://hyviral.com/pricing.json.

The Case File

A detective's corkboard fills the screen, and your question is the case. Clues get pinned up, suspects get crossed out, red string draws itself from pin to pin, and the lamp dies on the dead end. Then an old clue cracks it. Every scene is code, and the skill tells your agent which sounds to use and when.

  • Frame
  • 9:16
  • 30-40 s

You need an agent that can run code, such as Claude Code, Claude with code execution, ChatGPT Codex or Cursor, plus Node 20+, ffmpeg and Python 3.

  1. Give your agent the skill

    Paste it into a new chat. Your agent saves it as a skill and waits for your prompt.

    Copy for my agent Copy link
  2. Fill in the prompt

    The case The first suspect The real answer Platform Narrator Ending AI tools
    Use the case-file skill to make a vertical video for Instagram Reels.
    
    - **The case:** [TOPIC - the small mystery or problem, e.g. "where my socks keep going", "why sign-ups drop every Friday"]
    - **The first suspect:** [SUSPECT - the thing everyone blames, never a real person or company, and why it's innocent, or "you write it"]
    - **The real answer:** [CULPRIT - the thing, habit or process that really did it, never a real person or company, or "you write it"]
    - **The clue that was there all along:** [CLUE - the early detail that cracks it, or "you find it"]
    - **In the window:** [WINDOW - "no person: photographs drawn in code", or "my own clip or photos, attached"]
    - **Length:** [LENGTH - e.g. about 35 seconds]
    - **Voice:** [VOICE - e.g. "a tired noir detective", "my own voice", or "no voice: captions and sound only"]
    - **Music:** [MUSIC - e.g. "the skill's noir jazz brief", or your own idea]
    - **Ending:** [CTA - organic: "Comment KEYWORD and I'll send you THE THING", with the keyword and what you'll send; or sponsored: the line and the URL]
    - **AI tools:** [AI TOOLS - e.g. "whatever you already have set up", "HYVIRAL", or "none: animation only, I'll record the voice myself"]
    - **Avoid:** [AVOID - e.g. no claims I can't back up. The skill already rules out real people and companies as culprits]
    
    Work in this order:
    
    1. Write the script on the skill's moves, with one line for each turn and what gets pinned to the board.
    2. Build the plan, the clock and the frame, render a silent animatic, and show me the script, the scene sheet and the animatic. Wait for my OK.
    3. Generate the voice, music and sound effects with the AI tools I named above. If I left that open, tell me which tools you'll use and wait for my OK.
    4. Place a sound on every change, fit the music to the turn, mix voice first, render, and run every QA gate in the skill.
    5. Give me the MP4, a cover frame and a ready-to-paste caption.

    Anything left in [brackets] is your agent's call.

    Copy prompt
  3. Paste it and approve the storyboard

    Your agent writes the script, builds the board and shows you a silent animatic for your OK before it generates anything, then adds the sound and checks the film. You get the MP4, a cover frame and a caption.

Optional: HYVIRAL. Use any AI tools you like, or none. If you'd rather not juggle separate accounts and API keys, HYVIRAL gives your agent AI video, images, music, voice, captions and the final render in one place.

How to connect

The skill, as plain text

SKILL.md · 1649 lines · 89 KB
Copy Raw file
---
name: case-file
description: Make a 30-40 second vertical video where a detective's corkboard tells the story - every clue gets pinned up, red string draws itself from pin to pin, and a small mystery gets solved with complete seriousness. The skill covers the office sound design, music fitted to the story and an optional voice like a tired noir detective. Use for mysteries and investigations, 'why does this keep happening' stories, breakdowns of what really caused a problem, or when asked for a detective or evidence board video, or when given the case-file prompt.
---

# The Case File

A detective's corkboard fills the screen, and your question is the case. Photos get pinned up, suspects get crossed out, red string draws itself from pin to pin, and the desk lamp dies on the dead end. Then the detective spots something that was on the board all along. Every clue lands with a thunk, so the board keeps filling and you want to see where the string goes next.

Work through the steps in order. Show the user the script, the scene sheet and a silent animatic (step 6) and wait for their OK before you generate anything.

**What to expect:** the code in step 7 is the real frame that made our demo, and it runs as written. Your work is the story, what goes in the window and the sound. A new subject means redrawing the window, and that is most of the code you'll change; step 7 lists exactly what to change. Plan on one short script, a handful of sound effects, one music bed and two or three render passes.

## 0. If you received this as text

Save it as a skill first, wherever your app keeps skills. In Claude Code that's `~/.claude/skills/case-file/SKILL.md`; if a skill with that name already exists, ask the user before replacing it. Keep the user's filled-in prompt next to it as `PROMPT.md`.

## 1. Tools and setup

**You need a code environment** with Node 20+, ffmpeg and Python 3 (numpy, soundfile, pyloudnorm). If the film has a voice, you also need Whisper (any build, such as faster-whisper) for the word check in step 10 and to align a user's own recording. Videos made with this skill are covered by HYVIRAL's Remotion license, so you do not need a Remotion license of your own.

**The project** (Remotion 4.x, React, rendered to MP4): run `npm init -y`, then install `remotion`, `@remotion/cli`, `react` and `react-dom`, plus `@fontsource/special-elite`, `@fontsource/caveat` and `@fontsource/permanent-marker`. Copy the font files you need from `node_modules/@fontsource/<name>/files/` into `public/fonts/`, so a render never fetches a font over the network. Then lay it out like this:

```
plan.json              the lines and the scenes (step 6)
takes/<id>.json        one per line: the voice take's length and word timings (step 8)
scripts/clock.mjs      turns plan.json and the voice takes into src/spec.json (step 6)
src/index.ts           registers the composition (step 7)
src/Root.tsx           the 1080 x 1920, 30 fps composition (step 7)
src/spec.json          the clock, written by clock.mjs
src/frames/core.tsx    the shared runtime (step 7)
src/frames/story.json  the events: which word each change lands on (step 6)
src/frames/CaseFile.tsx   this frame (step 7)
```

Render without audio, then mux your own mix (step 10): `npx remotion render src/index.ts Film-9x16 out/silent.mp4 --muted`. Set TEMP and TMP to a folder on a roomy drive first; Remotion writes a lot of temporary files.

**Prove the setup before you write anything.** Copy the code and our `plan.json` from step 7, run the clock from step 6, and render the first few seconds (add `--frames=0-90`). Only then start on your own story, at step 5. The font files you need are named in `FACES` at the top of the frame.

**For the voice, images, music and sound effects, use whatever the user prefers.** Every step works the same with any provider. If the user hasn't said, ask which tools they want to use, or use what is already set up.

- **Your own providers.** A text-to-speech that returns word timestamps (for example ElevenLabs with timestamps), a music model or a licensed track, and a sound-effects generator (for example ElevenLabs sound effects) or a licensed sound library. Keep API keys in environment variables, never in the project. No AI media at all is fine too: the picture is code, the narration can be the user's own recording (align its words with Whisper), and the film works with no voice.
- **HYVIRAL (optional).** If the user has it connected (hyviral.com), one connection covers the media:

| You need | HYVIRAL tool |
|----------|--------------|
| Narration with word timestamps (stock voices or your own cloned voice) | `generate_voiceover` (`content_type: "shorts"`), `manage_voice_clone` |
| Still images | `generate_image` |
| Music bed | `generate_music` |
| Sound library, placing sounds, final render | `video_audio`, `video_export` |
| Long jobs | `get_job_status` (poll until delivered) |

  To connect it: in Claude.ai or Claude Desktop, Customize → Connectors → add a custom connector with the URL `https://api.hyviral.com/mcp`; in Claude Code, `claude mcp add --transport http hyviral https://api.hyviral.com/mcp` then `/mcp` to sign in. Start a session with `get_connection_info`. HYVIRAL never runs your code: you render the animation yourself.

## 2. How a frame film works

- **One frame, the whole film.** A frame is a world with fixed parts on screen, and it never cuts away. The story happens to the world: clues get pinned up, string draws itself between pins, suspects get crossed out, the desk lamp dies and comes back. That's why it holds attention: the viewer learns the world in the first second, then watches it change.
- **One clock: the spoken words.** Each line is voiced as its own take with word timings, scenes are laid end to end on those takes, and every change is keyed to a word in `story.json`. Re-voice a line and everything moves with it.
- **A function of time.** Every part of the frame computes what it shows from the current time, with no stored state, `Math.random` or `Date`. Use `hash()` for anything that looks random, so two renders match.
- **Sound off first.** Most people start muted, so the frame and the captions have to tell the whole story on their own. Sound makes it better, never necessary.
- **Safe area.** 1080 x 1920. Platform buttons and captions cover the bottom 440 px, and the right 130 px from about y = 900 down. Keep captions and anything that must be read inside x 60-950, y 250-1480. Above y = 900 the frame can run to the right edge. Below y = 1480, draw only texture. The top 250 px can hold the world's title and small jokes, as long as nothing there has to be read.
- **Pacing rules.** No voice gap over 1.0 s, except one voiceless moment of 2 to 5 s with the music lifted. No still stretch over 1.2 s. Never cut to black. Something moves on the very first frame.

## 3. The world

A detective's office at night, seen as one corkboard.

- **Palette:** cork browns with a dark wooden frame `#5c3a1e`, string and marker red `#c8102e`, sticky-note yellow `#ffe45c`, photo paper `#fbf8ef`, label tape `#151515`, typed ink `#231c14`, manila `#e3c98f`.
- **Type:** Special Elite for anything typed and for the captions, Caveat 700 for handwriting, Permanent Marker for sticky notes and the big stamps.
- **Fixed parts:** a manila case tab at the top left, a wall clock at the top right, the board filling the frame, punched label-tape captions in one place (y 1304-1472), then loose index cards and an EVIDENCE folder as texture below the safe area. The tab and the clock sit above the safe area, so nothing on them has to be read.
- **The pin rule:** every item rotates around its own pin, so a pin never moves and string can tie straight to it. Keep every position in one `PIN` list and plan it first: the board has to hold everything by the end.
- **Everything hits the same cork:** a pin knocks the whole board a little, and the stamp knocks it a lot. That small shake is what makes it feel physical.
- **The camera never leaves the board:** it drifts and pushes in. Zoom with `zoom()`, around a point inside the frame, so the board's edges stay off screen.
- **Small jokes live on the paper:** "signed: everybody", "Detective baffled, feet cold", MOTIVE: warm, REMORSE: none.

**Real people and real marks, in every version of this film.** The user's own name, brand and product are fine. Never use anyone else's company, agency, team or product name, logo, livery, seal or badge. Never use a real person's name, face or voice without their OK, and never make a real person or a named company the villain, the target of the joke or the one to blame.

## 4. The window

The window is the part of the frame that shows the subject. It has two modes, and the frame must work with either.

- **With no person (the default).** A scene that belongs to the world, drawn in code or from still images. It has to follow the story as well, not just sit there. "No person" means no filmed presenter: small drawn figures are fine.
- **With the user's own clip.** Replace the scene with their footage (`<OffthreadVideo>` from `remotion`, cropped to the window, muted if the narration is separate). Keep their face in the middle third, and take the word timings from their clip with Whisper.

For this world the window is a pinned photograph, and each new photo moves the case forward. Ours are drawn in code:

| Story moment | On the board |
|---|---|
| Opening | A chalk outline of the victim draws itself, then the photo lands on top: a striped sock against a lineup wall |
| The suspect | A second photo: the dryer, labeled SUSPECT 1, with "it's always him" in handwriting |
| The alibi | A red X across the dryer, and then the photo is torn off the board and falls |
| The dead end | Nine odd socks in a grid, a clipping: NO PAIRS. NO LEADS. The lamp sputters and dies, leaving blinds and rain |
| The turn | A pool of light on one old clue: "went in: 2" gets circled, DID IT? |
| The trail | The lamp returns, string shoots out from everything, and sticky notes with paw prints: HALLWAY, STAIRS, BEHIND THE DOOR |
| The reveal | A photo flips over: a sleeping cat on a pile of socks. Suspect: the cat |
| Closed | CASE CLOSED stamped across it. STATEMENT OF THE CAT: (none) |

The window also works with the user's own clip: play it inside the photograph, cropped to the print, with the white border and the pin on top. 

**The culprit is a thing, a habit or a process.** Never pin a real person or a named company as a suspect or culprit, and never use a real agency's seal or badge. If the culprit is a real object, use a real photo of it, and keep the same flip. This frame isn't for real crimes or true crime: if someone asks, say so and offer a small everyday mystery instead.

## 5. The story

This is the part that makes the film, so write it before you touch anything else. Read every line aloud.

- **Use the world's own events.** Every turn in the topic has to become something that really happens in this world. If a line could sit in any video, rewrite it.
- **Use the voice's deadpan.** The funniest and most moving versions take the world completely seriously.
- **Short lines.** Mostly 5 to 11 words, one idea each, with an occasional two-word punch.
- **Real claims only.** Use a number or a fact only if the user gave it to you or you can source it. Invented details are fine only when the film is plainly a joke or a metaphor.
- **Build to a low point, then a turn.** The low point is a picture event, never silence: the world goes dim or dead while room tone and a designed sound keep running.
- **No em dashes on screen.** Commas, full stops and line breaks do that job.
- **Ending, organic or sponsored.** Ask the user which one, and for an organic ending, what they'll send people. Organic: "Comment KEYWORD and I'll send you THE THING", said in the world's voice, and no link. The keyword stays on screen in the world's own way until the end, because the caption moves on. Sponsored: a direct line and a URL.
- **Length.** Run the clock from step 6 as soon as the script exists, before you build anything. Each extra line adds about 3 s, and even a one-word line takes about 0.5 s. Time follows the words, so a cut only helps if words go: folding two moves into one shorter line works, and joining them word for word saves nothing. Then re-anchor every event in `story.json`.

**The moves.** Map any topic onto these. The film treats a small question like the case of a career. Ours is about a missing sock.

| # | Move | What it does | Our line | On the board |
|---|------|--------------|----------|--------------|
| 1 | The case | The hook: a small mystery, in police language | "Case four one seven. One sock. Last seen Tuesday." | The lamp clicks on, a chalk outline draws, the photo lands |
| 2 | The evidence | The fact that doesn't add up | "It went into the wash with its partner. Only one came out." | Exhibit A: went in: 2, came out: 1 |
| 3 | First suspect | The obvious answer | "First suspect: the dryer. Everybody blames the dryer." | Dryer photo, string, a witness statement signed "everybody" |
| 4 | The alibi | The obvious answer dies | "The dryer has an alibi. I checked the lint." | Lint bag, LINT: CLEAN, a red X, the photo torn off |
| 5 | Dead end | The low point | "Nine odd socks. No pairs. No leads." | Nine socks pin up fast, a clipping, then the lamp sputters out. Music out; rain and the clock keep running |
| 6 | The noticing | The turn: an old clue, a new question | "Then I noticed something. They never went missing in the machine." | A pool of light on Exhibit A. "went in:" is circled, DID IT? |
| 7 | The insight | The real message | "They went missing on the way to it." | The lamp fills the board, string shoots out, ON THE WAY |
| 8 | The trail | Momentum | "Hallway. Stairs. Behind the door." | Three sticky notes, each on its word, with paw prints |
| 9 | The reveal | The answer | "And there it was, under the cat." | The photo flips: a sleeping cat on a pile of socks |
| 10 | The silence | The voiceless moment, about 2.5 s | (music at its fullest, captions read PURRING) | MOTIVE: warm, REMORSE: none pin up; an ear twitches |
| 11 | Closed | The release | "Case closed." | A stamp slams across the photo, and the whole board jumps |
| 12 | The button | The last laugh | "The cat has not commented." | The cat opens one eye. STATEMENT OF THE CAT: (none) |
| 13 | Call to action | One action | "Comment case and I'll send you the skill that made this." | A big sticky note, COMMENT CASE, stays until the end |

**For other topics:** why sign-ups drop every Friday, why the build keeps breaking, why the plant keeps dying, why a morning routine never lasts. Everyone blames the same suspect, and it has an alibi. The trick that makes it land is the turn: no new evidence arrives. The detective just asks a new question of something that's been on the board from early on, so viewers feel they could have solved it. Find that clue first. If the case is a real problem, use real facts about it and keep the blame on the process, and if it isn't, keep it plainly a joke. Our film runs 41 s, and 39 s on the clock's estimate. For about 35 s, fold moves 1 and 2 into one line and drop a sentence from the suspect or the alibi. Joining moves 11 and 12 saves nothing.

## 6. Plan and animatic (before generating anything)

**1. Write `plan.json`.** `text` is what the captions show. Add `say` when the spoken version differs, with the same number of words. Scenes hold the lines. `lead` and `tail` are seconds of room around a scene's words, and a voiceless scene has `sec` instead of `lines`.

```json
{
  "lines": [
    { "id": "l01", "text": "First line." },
    { "id": "l02", "text": "Comment TOUR and I'll send it to you.", "say": "Comment, tour, and I'll send it to you." }
  ],
  "scenes": [
    { "id": "s01", "lines": ["l01"], "lead": 0.7 },
    { "id": "s02", "sec": 3.4 },
    { "id": "s03", "lines": ["l02"] }
  ]
}
```

**2. Run the clock.** Write `scripts/clock.mjs` as below, and run it from the project folder. A line with no take yet gets an estimate, so you can see the whole film before generating anything. A real take goes in `takes/<id>.json` as `{ "dur": seconds, "words": [{ "w": "First", "t0": 0, "t1": 0.3 }] }` and replaces the estimate.

```js
// node scripts/clock.mjs   plan.json + takes/<id>.json -> src/spec.json (what the frame renders) and clock.json (where each line plays)
import fs from 'node:fs';

const FPS = 30;
const plan = JSON.parse(fs.readFileSync('plan.json', 'utf8'));
const PAUSE = 0.35, LEAD = 0.05, TAIL = 0.25, TAIL_OUT = 1.2;
// No take yet: an estimate for a quick, clear voice, fitted to 53 real takes. A real take in takes/<id>.json replaces it.
function estimate(text) {
  const letters = (s) => (s.match(/[\p{L}\p{N}]/gu) ?? []).length;
  const stops = (text.match(/[.?!]+(?=\s)/g) ?? []).length, commas = (text.match(/,/g) ?? []).length;
  const dur = 0.29 + 0.059 * letters(text) + 0.53 * stops + 0.07 * commas;
  const parts = text.split(/\s+/), total = parts.reduce((n, p) => n + letters(p) + 1, 0);
  let at = 0;
  const words = parts.map((w) => { const d = (dur * (letters(w) + 1)) / total, x = { w, t0: at, t1: at + d }; at += d; return x; });
  return { dur, words };
}
let t = 0.1;
const beats = [], placed = [];
for (const s of plan.scenes) {
  const from = Math.round(t * FPS);
  let end = t + (s.sec ?? 0);
  const words = [];
  if (s.lines?.length) {
    let c = t + (s.lead ?? LEAD);
    s.lines.forEach((id, i) => {
      const line = plan.lines.find((l) => l.id === id);
      const take = fs.existsSync(`takes/${id}.json`) ? JSON.parse(fs.readFileSync(`takes/${id}.json`, 'utf8')) : estimate(line.say ?? line.text);
      // Captions show `text`, even when `say` differs ("Comment, gate"), as long as the word counts match.
      const shown = line.text.split(/\s+/);
      const same = shown.length === take.words.length;
      placed.push({ line: id, t0: c, dur: take.dur, scene: s.id });
      take.words.forEach((w, k) => words.push({ w: same ? shown[k] : w.w, f0: Math.round((c + w.t0) * FPS) - from, f1: Math.round((c + w.t1) * FPS) - from }));
      c += take.dur + (i < s.lines.length - 1 ? PAUSE : 0);
    });
    end = c + (s.tail ?? TAIL);
  }
  const dur = Math.max(1, Math.round(end * FPS) - from);
  beats.push({ id: s.id, from, dur, words, p: s.p ?? {} });
  t = (from + dur) / FPS;
}
const total = Math.round((t + TAIL_OUT) * FPS);
beats[beats.length - 1].dur = total - beats[beats.length - 1].from;
beats[0].dur += beats[0].from;
beats[0].words.forEach((w) => { w.f0 += beats[0].from; w.f1 += beats[0].from; });
beats[0].from = 0;
for (let i = 1; i < placed.length; i++) {
  const gap = placed[i].t0 - (placed[i - 1].t0 + placed[i - 1].dur);
  const quiet = beats.some((b) => !b.words.length && b.from / FPS < placed[i].t0 && (b.from + b.dur) / FPS > placed[i - 1].t0);
  if (gap > 1.0 && !quiet) console.warn(`voice gap ${gap.toFixed(2)} s before ${placed[i].line}: shorten a lead or tail`);
}
fs.mkdirSync('src', { recursive: true });
fs.writeFileSync('src/spec.json', JSON.stringify({ fps: FPS, durationInFrames: total, beats }, null, 1));
fs.writeFileSync('clock.json', JSON.stringify({ fps: FPS, total: total / FPS, lines: placed }, null, 1));
console.log(`${(total / FPS).toFixed(2)} s, ${beats.length} scenes, ${placed.length} lines`);
```

**3. Write `src/frames/story.json`.** Name every change in the story and pin it to a scene, and to a word in it where you can. The frame reads events by name, and a typo fails the render instead of drifting. Every event is checked when the film loads, even one the frame never reads. Our list keeps some events only so a sound can land on them; you can delete those. A `word` matches the first word in its scene that starts with those letters, so "the" also hits "there", and a repeated word can only be reached with `off` from an earlier one.

**4. Build the frame** with the code in step 7, changing the words and schedules to your story. Keep each schedule in time order.

**5. Render a silent animatic.** Make contact sheets at one frame per second with the safe box drawn, and look at every tile: `ffmpeg -i out/silent.mp4 -vf "select='not(mod(n\,30))',drawbox=x=60:y=250:w=890:h=1230:color=red@0.9:t=6,scale=216:-2,tile=8x3" -fps_mode passthrough sheet_%02d.jpg`. Empty tiles at the end of the last sheet are padding, not black frames. A tile that small hides small errors, so also look at about 6 frames at full size, with the window cropped large (`npx remotion still src/index.ts Film-9x16 out/f.png --frame=N`).

**6. Show the user** the script, the scene sheet (one row per scene: its line, what changes on screen, and its seconds) and the animatic. Wait for their OK.

## 7. Build the frame

These files are the real frame that made our demo, with our demo's story in it. You rendered them in step 1 to prove your setup. Now change the story, using the list below. Leave the machinery alone until the film works, and read the code before you change it. Paper, panels and labels are sized to our demo's words, so check anything longer on the contact sheet.

### What to change for your story

Most of this file is the paper on the board, and almost all of it is about socks. Each item is its own component, so replace them one at a time. When we tested this skill on a new topic, about 200 of the 759 lines were replaced and about 265 were added. `core.tsx` and `clock.mjs` didn't change.

| To change | Where |
|---|---|
| The photographs | `SockPhoto`, `DryerPhoto`, `CatPhoto` draw the pictures in SVG; `SockPolaroid`, `DryerPolaroid`, `CatPolaroid` frame them. Redrawing these is the biggest job |
| The evidence | `ExhibitA`, `Statement`, `LintBag`, `Clipping`, `WayCard`, `Timeline` |
| The dead end's collection | `OddSocks`, with `ODD` and `SockG` |
| The trail and the verdict | The `Sticky` and `Tag` rows in `Stage`, then `Stamp` and `NoComment` |
| The case title | `Tab`: CASE 417, and its two subtitles |
| The chalk outline | `Chalk`, and `SOCK` for its shape |
| Where things sit | `PIN` holds the main pins. `OddSocks`, the `Tag` rows, `NoComment`, `CtaNote`, `Stamp`, `Timeline` and `Chalk` set their own positions, and `pins` in `Stage` lists which pins appear when |
| The string | `Strings`: which pins each string joins |
| The camera and the knocks | `KEYS` for the camera, `HITS` for which events shake the board |
| The lamp | `lampAt` and `spotAt`, keyed to `nine`, `pairs`, `leads`, `turn` and `pattern`. `Light` aims its pool at `PIN.exhibit` |
| Small drawn details | `WallClock`'s hands, `PILE` (the heap under the cat), `Paw`, and the tug on `E('way')` in `Item` |
| The comment keyword | `plan.json`, `CtaNote`, and the `CASE` check in `Captions`, which lights every use of the word once the ending starts |

- **Scene ids in the code:** only one: `s10` in `PURR`, the voiceless caption. Change its words, and its id if you renumber scenes.
- **Paper is sized to our words.** A longer keyword overflows `CtaNote` at 136 px, and `NoComment` and `Placard` are fitted to ours, so check each one against its paper.
- **Add items in the order they appear,** and keep them in the `Stage` list: later items draw on top, so the stamp and the last note stay above everything.
- **Delete a clue's events along with its component.** `story.json` events that no longer match a word stop the render, which is what you want.

### The code

`plan.json`, our demo's script:

```json
{
 "lines": [
  {
   "id": "l01",
   "text": "Case four one seven. One sock. Last seen Tuesday."
  },
  {
   "id": "l02",
   "text": "It went into the wash with its partner. Only one came out."
  },
  {
   "id": "l03",
   "text": "First suspect: the dryer. Everybody blames the dryer.",
   "say": "First suspect, the dryer. Everybody blames the dryer."
  },
  {
   "id": "l04",
   "text": "The dryer has an alibi. I checked the lint."
  },
  {
   "id": "l05",
   "text": "Nine odd socks. No pairs. No leads."
  },
  {
   "id": "l06",
   "text": "Then I noticed something. They never went missing in the machine."
  },
  {
   "id": "l07",
   "text": "They went missing on the way to it.",
   "say": "They went missing, on the way to it."
  },
  {
   "id": "l08",
   "text": "Hallway. Stairs. Behind the door."
  },
  {
   "id": "l09",
   "text": "And there it was, under the cat."
  },
  {
   "id": "l10",
   "text": "Case closed."
  },
  {
   "id": "l11",
   "text": "The cat has not commented."
  },
  {
   "id": "l12",
   "text": "Comment case and I'll send you the skill that made this.",
   "say": "Comment, case, and I'll send you the skill that made this."
  }
 ],
 "scenes": [
  {
   "id": "s01",
   "lines": [
    "l01"
   ],
   "lead": 0.28,
   "tail": 0.1
  },
  {
   "id": "s02",
   "lines": [
    "l02"
   ],
   "lead": 0.1,
   "tail": 0.1
  },
  {
   "id": "s03",
   "lines": [
    "l03"
   ],
   "lead": 0.15,
   "tail": 0.1
  },
  {
   "id": "s04",
   "lines": [
    "l04"
   ],
   "lead": 0.1,
   "tail": 0.1
  },
  {
   "id": "s05",
   "lines": [
    "l05"
   ],
   "lead": 0.6,
   "tail": 0.1
  },
  {
   "id": "s06",
   "lines": [
    "l06"
   ],
   "lead": 0.45,
   "tail": 0.1
  },
  {
   "id": "s07",
   "lines": [
    "l07"
   ],
   "lead": 0.05,
   "tail": 0.1
  },
  {
   "id": "s08",
   "lines": [
    "l08"
   ],
   "lead": 0.1,
   "tail": 0.1
  },
  {
   "id": "s09",
   "lines": [
    "l09"
   ],
   "lead": 0.15,
   "tail": 0.1
  },
  {
   "id": "s10",
   "sec": 2.4
  },
  {
   "id": "s11",
   "lines": [
    "l10"
   ],
   "lead": 0.05,
   "tail": 0.1
  },
  {
   "id": "s12",
   "lines": [
    "l11"
   ],
   "lead": 0.1,
   "tail": 0.1
  },
  {
   "id": "s13",
   "lines": [
    "l12"
   ],
   "lead": 0.12,
   "tail": 0.1
  }
 ]
}
```

`src/index.ts`:

```ts
import { registerRoot } from 'remotion';
import { Root } from './Root';
registerRoot(Root);
```

`src/Root.tsx`:

```tsx
import React from 'react';
import { Composition } from 'remotion';
import { CaseFile } from './frames/CaseFile';
import spec from './spec.json';

export const Root: React.FC = () => (
  <Composition id="Film-9x16" component={CaseFile} durationInFrames={Math.max(1, (spec as { durationInFrames: number }).durationInFrames)} fps={30} width={1080} height={1920} />
);
```

`src/frames/core.tsx`, the shared runtime:

```tsx
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { continueRender, delayRender, staticFile, useCurrentFrame } from 'remotion';
import spec from '../spec.json';

/* The shared frame runtime. One frame runs through the whole film; the clock comes from the spoken words. */
export const clamp = (x: number, a = 0, b = 1) => Math.min(b, Math.max(a, x));
export const ramp = (t: number, a: number, b: number) => clamp((t - a) / (b - a));
export const easeOut = (x: number) => 1 - Math.pow(1 - x, 3);
export const easeInOut = (x: number) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2);
export const easeIn = (x: number) => x * x * x;
export const back = (x: number) => { const c = 1.70158; return 1 + (c + 1) * Math.pow(x - 1, 3) + c * Math.pow(x - 1, 2); };
export const hash = (n: number) => { const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };

export type Word = { w: string; t0: number; t1: number; scene: string };
export type SceneT = { id: string; t0: number; t1: number; words: Word[]; p: Record<string, unknown> };
export type StoryEvent = { id: string; scene: string; word?: string; off?: number };
export type StoryJson = { events: StoryEvent[] };

const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9']/g, '');
type SpecBeat = { id: string; from: number; dur: number; words: { w: string; f0: number; f1: number }[]; p: Record<string, unknown> };
const FPS = (spec as { fps: number }).fps;
export const SCENES: SceneT[] = (spec as unknown as { beats: SpecBeat[] }).beats.map((b) => ({
  id: b.id,
  t0: b.from / FPS,
  t1: (b.from + b.dur) / FPS,
  p: b.p ?? {},
  words: b.words.map((w) => ({ w: w.w, t0: (b.from + w.f0) / FPS, t1: (b.from + w.f1) / FPS, scene: b.id })),
}));
export const WORDS: Word[] = SCENES.flatMap((s) => s.words);
export const DURATION = (spec as { durationInFrames: number }).durationInFrames / FPS;

/** The film second of an event: a scene's start, or a word in it, plus an offset. Throws on a typo, so a wrong anchor fails the render. */
export function eventTime(e: StoryEvent): number {
  const s = SCENES.find((x) => x.id === e.scene);
  if (!s) throw new Error(`event ${e.id}: no scene ${e.scene}`);
  let t = s.t0;
  if (e.word) {
    const w = s.words.find((x) => norm(x.w).startsWith(norm(e.word as string)));
    if (!w) throw new Error(`event ${e.id}: no word "${e.word}" in ${e.scene} (${s.words.map((x) => x.w).join(' ')})`);
    t = w.t0;
  }
  return t + (e.off ?? 0);
}
export const makeE = (story: StoryJson) => {
  const map = new Map(story.events.map((e) => [e.id, eventTime(e)]));
  return (id: string) => {
    const v = map.get(id);
    if (v === undefined) throw new Error(`unknown event ${id}`);
    return v;
  };
};

const TimeCtx = createContext(0);
export const useT = () => useContext(TimeCtx);
export const Clock: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const f = useCurrentFrame();
  return <TimeCtx.Provider value={f / FPS}>{children}</TimeCtx.Provider>;
};

/** One caption chunk at a time, cut to fit rows of `perRow` cells; `wi` is the word being spoken, or -1. */
export type Chunk = { t0: number; rows: { s: string; map: number[] }[]; words: Word[] };
const clean = (w: string) => w.replace(/[.,;:!?"]/g, '');
export function buildChunks(perRow: number, maxRows: number): Chunk[] {
  const chunks: Chunk[] = [];
  for (const scene of SCENES) {
    let cur: Word[] = [];
    const flush = () => {
      if (!cur.length) return;
      const rows: { s: string; map: number[] }[] = [{ s: '', map: [] }];
      cur.forEach((w, i) => {
        const up = clean(w.w).toUpperCase();
        let r = rows[rows.length - 1];
        if (r.s.length && r.s.length + 1 + up.length > perRow) { rows.push({ s: '', map: [] }); r = rows[rows.length - 1]; }
        if (r.s.length) { r.s += ' '; r.map.push(-1); }
        for (let k = 0; k < up.length; k++) r.map.push(i);
        r.s += up;
      });
      chunks.push({ t0: cur[0].t0, rows, words: cur });
      cur = [];
    };
    for (const w of scene.words) {
      const trial = [...cur, w];
      let rowsUsed = 1, len = 0;
      for (const x of trial) {
        const L = clean(x.w).length;
        if (len && len + 1 + L > perRow) { rowsUsed += 1; len = L; } else len += (len ? 1 : 0) + L;
      }
      if (rowsUsed > maxRows) flush();
      cur.push(w);
      if (/[.!?]$/.test(w.w)) flush();
    }
    flush();
  }
  return chunks;
}

/** Loads the frame's fonts before the first frame. The handle is taken inside React: a module-level one can outlive its render. */
export const Fonts: React.FC<{ faces: { family: string; file: string; weight?: string; style?: string }[]; children: React.ReactNode }> = ({ faces, children }) => {
  const [handle] = useState(() => delayRender('fonts', { timeoutInMilliseconds: 120000 }));
  const [ready, setReady] = useState(false);
  const list = useMemo(() => faces, [faces]);
  useEffect(() => {
    Promise.all(list.map((x) => {
      const ff = new FontFace(x.family, `url(${staticFile(`fonts/${x.file}`)})`, { weight: x.weight ?? '400', style: x.style ?? 'normal' });
      return ff.load().then((l) => { (document.fonts as unknown as { add: (f: FontFace) => void }).add(l); });
    }))
      .then(() => document.fonts.ready)
      .then(() => { setReady(true); continueRender(handle); })
      .catch((e) => { console.error('font load failed', e); setReady(true); continueRender(handle); });
  }, [handle, list]);
  return ready ? <>{children}</> : null;
};
```

`src/frames/story.json`, our demo's events:

```json
{
 "events": [
  {
   "id": "on",
   "scene": "s01"
  },
  {
   "id": "sock",
   "scene": "s01",
   "word": "sock",
   "off": -0.1
  },
  {
   "id": "tuesday",
   "scene": "s01",
   "word": "Tuesday"
  },
  {
   "id": "exhibit",
   "scene": "s02",
   "word": "wash"
  },
  {
   "id": "only",
   "scene": "s02",
   "word": "Only"
  },
  {
   "id": "suspect",
   "scene": "s03",
   "word": "First"
  },
  {
   "id": "dryer",
   "scene": "s03",
   "word": "dryer"
  },
  {
   "id": "everybody",
   "scene": "s03",
   "word": "Everybody"
  },
  {
   "id": "blames",
   "scene": "s03",
   "word": "blames"
  },
  {
   "id": "alibi",
   "scene": "s04",
   "word": "alibi"
  },
  {
   "id": "lint",
   "scene": "s04",
   "word": "lint"
  },
  {
   "id": "tear",
   "scene": "s04",
   "word": "lint",
   "off": 0.5
  },
  {
   "id": "low",
   "scene": "s05"
  },
  {
   "id": "nine",
   "scene": "s05",
   "word": "Nine"
  },
  {
   "id": "pairs",
   "scene": "s05",
   "word": "pairs"
  },
  {
   "id": "leads",
   "scene": "s05",
   "word": "leads"
  },
  {
   "id": "turn",
   "scene": "s06",
   "word": "noticed"
  },
  {
   "id": "never",
   "scene": "s06",
   "word": "never"
  },
  {
   "id": "machine",
   "scene": "s06",
   "word": "machine"
  },
  {
   "id": "pattern",
   "scene": "s07"
  },
  {
   "id": "way",
   "scene": "s07",
   "word": "way"
  },
  {
   "id": "hall",
   "scene": "s08",
   "word": "Hallway"
  },
  {
   "id": "stairs",
   "scene": "s08",
   "word": "Stairs"
  },
  {
   "id": "door",
   "scene": "s08",
   "word": "Behind"
  },
  {
   "id": "there",
   "scene": "s09",
   "word": "there"
  },
  {
   "id": "flip",
   "scene": "s09",
   "word": "under"
  },
  {
   "id": "cat",
   "scene": "s09",
   "word": "cat"
  },
  {
   "id": "quiet",
   "scene": "s10"
  },
  {
   "id": "closed",
   "scene": "s11",
   "word": "closed"
  },
  {
   "id": "nocomment",
   "scene": "s12",
   "word": "commented"
  },
  {
   "id": "cta",
   "scene": "s13"
  }
 ]
}
```

`src/frames/CaseFile.tsx`:

```tsx
import React from 'react';
import { AbsoluteFill } from 'remotion';
import story from './story.json';
import { back, buildChunks, clamp, Clock, easeIn, easeInOut, easeOut, Fonts, hash, makeE, ramp, useT, type StoryJson, type Word } from './core';

/* The Case File: a detective's corkboard. Every clue is physically pinned up, red string draws itself from pin to pin, and the
   polaroid is the window. 1080 x 1920. Time comes from the spoken words through story.json, so a re-voice moves every pin with its word. */
const E = makeE(story as StoryJson);
const RED = '#c8102e', NOTE = '#ffe45c', CARD = '#fbf8ef', TAPE = '#151515', INK = '#231c14', PAPER = '#f7f5ef', MANILA = '#e3c98f';
const TYPE = '"Special Elite", monospace', HAND = 'Caveat, cursive', MARK = '"Permanent Marker", cursive';
const FACES = [
  { family: 'Special Elite', file: 'special-elite-latin-400-normal.woff2' },
  { family: 'Caveat', file: 'caveat-latin-700-normal.woff2', weight: '700' },
  { family: 'Permanent Marker', file: 'permanent-marker-latin-400-normal.woff2' },
];

type P = [number, number];
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
const mix = (a: P, b: P, k: number): P => [lerp(a[0], b[0], k), lerp(a[1], b[1], k)];

/* Where everything is pinned. An item rotates around its own pin, so a pin never moves and string can tie straight to it. */
const PIN = {
  sock: [300, 292] as P,
  sockTop: [521, 296] as P,
  sockLow: [549, 816] as P,
  exhibit: [292, 878] as P,
  dryer: [806, 316] as P,
  dryerLow: [584, 786] as P,
  statement: [742, 862] as P,
  lint: [880, 706] as P,
  clipping: [735, 846] as P,
  grid: [754, 304] as P,
  gridLow: [754, 548] as P,
  card: [540, 640] as P,
  photo: [540, 296] as P,
  hall: [200, 1130] as P,
  stairs: [505, 1130] as P,
  door: [805, 1130] as P,
};

/* Everything hits the same cork: a pin knocks the board a little, the stamp knocks it a lot. */
const HITS: [number, number][] = ([['sock', 5], ['exhibit', 4], ['dryer', 5], ['lint', 3], ['pairs', 4], ['way', 6], ['hall', 4], ['stairs', 4], ['door', 4], ['cat', 6], ['closed', 24], ['cta', 5]] as [string, number][]).map(([e, a]) => [E(e), a]);
const shake = (t: number) => HITS.reduce((acc, [e, a]) => (t >= e ? acc + a * Math.sin((t - e) * 58) * Math.exp(-(t - e) * 11) : acc), 0);
const twang = (t: number, e: number) => (t >= e ? Math.sin((t - e) * 46) * Math.exp(-(t - e) * 5.5) : 0);

/* The camera drifts and pushes, and the frame is part of the board, so it never shows the wall behind it. */
type View = { s: number; tx: number; ty: number };
/** Zoom by s around a point of the picture that stays put. Any point inside the frame keeps the board's edges off screen. */
const zoom = (s: number, fx: number, fy: number): View => ({ s, tx: fx * (1 - s), ty: fy * (1 - s) });
const KEYS: { at: number; d: number; v: View }[] = [
  { at: E('on'), d: 0.01, v: zoom(1.18, 60, 280) },
  { at: E('sock') - 0.2, d: 0.9, v: zoom(1.32, 60, 270) },
  { at: E('exhibit') - 0.2, d: 1.2, v: zoom(1.12, 60, 290) },
  { at: E('suspect'), d: 1.1, v: zoom(1.04, 540, 560) },
  { at: E('alibi') - 0.15, d: 0.7, v: zoom(1.07, 620, 500) },
  { at: E('low'), d: 1.6, v: zoom(1.04, 800, 560) },
  { at: E('turn') - 0.3, d: 1.2, v: zoom(1.32, 40, 1300) },
  { at: E('pattern'), d: 0.8, v: zoom(1.02, 540, 800) },
  { at: E('hall') - 0.15, d: 0.5, v: zoom(1.04, 100, 1260) },
  { at: E('door') - 0.1, d: 0.5, v: zoom(1.04, 800, 1260) },
  { at: E('flip') - 0.1, d: 0.6, v: zoom(1.03, 540, 900) },
  { at: E('quiet'), d: 3.0, v: zoom(1.1, 540, 800) },
  { at: E('closed') - 0.2, d: 0.25, v: zoom(1.04, 540, 900) },
  { at: E('cta') - 0.1, d: 0.9, v: zoom(1.015, 540, 900) },
];
const camAt = (t: number): View => {
  let v = KEYS[0].v;
  for (const k of KEYS.slice(1)) {
    if (t < k.at) break;
    const q = easeInOut(ramp(t, k.at, k.at + k.d));
    v = { s: lerp(v.s, k.v.s, q), tx: lerp(v.tx, k.v.tx, q), ty: lerp(v.ty, k.v.ty, q) };
  }
  const s = Math.max(1.012, v.s + 0.007 * Math.sin(t * 0.33));
  const tx = clamp(v.tx + 9 * Math.sin(t * 0.41 + 1), 1080 - 1080 * s, 0);
  const ty = clamp(v.ty + 7 * Math.sin(t * 0.29 + 2), 1920 - 1920 * s, 0);
  const j = shake(t);
  return { s, tx: tx + j, ty: ty + j * 0.45 };
};

/* The desk lamp: 1 is lit, 0 is off. It clicks on, sputters out on the low point, comes back as a pool on one clue, then fills the board. */
const lampAt = (t: number) => {
  const a = E('leads'), b = a + 0.85;
  if (t < 0.1) return 0.45;
  if (t < a) return 1 - 0.25 * ramp(t, E('nine'), E('pairs') + 0.3);
  if (t < b) return hash(Math.floor((t - a) * 15) + 3) > 0.28 + 0.6 * ramp(t, a, b) ? 0.72 : 0.1;
  return 0.06 + 0.94 * ramp(t, E('pattern') - 0.05, E('pattern') + 0.45);
};
const spotAt = (t: number) => ramp(t, E('turn'), E('turn') + 0.06) * (1 - ramp(t, E('pattern') - 0.05, E('pattern') + 0.45));

/* ---------- paper ---------- */

const Pin: React.FC<{ at: P; t0: number }> = ({ at, t0 }) => {
  const t = useT();
  if (t < t0) return null;
  const s = lerp(1.7, 1, easeOut(ramp(t, t0, t0 + 0.09)));
  return <div style={{ position: 'absolute', left: at[0] - 18, top: at[1] - 18, width: 36, height: 36, borderRadius: '50%', transform: `scale(${s})`, background: 'radial-gradient(circle at 35% 30%,#ff9a9a,#d4141f 45%,#7a0b10)', boxShadow: '3px 7px 6px rgba(20,8,0,.5)' }} />;
};

const Item: React.FC<{ t0: number; pin: P; ox: number; oy: number; w: number; h: number; rot: number; seed: number; tug?: number; style?: React.CSSProperties; children?: React.ReactNode }> = ({ t0, pin, ox, oy, w, h, rot, seed, tug = 0, style, children }) => {
  const t = useT();
  const at = t0 - 0.1;
  if (t < at) return null;
  const k = back(ramp(t, at, at + 0.2));
  const r = rot + Math.sin(t * 0.8 + seed) * 0.28 + shake(t) * 0.1 + tug * easeOut(ramp(t, E('way'), E('way') + 0.25));
  return <div style={{ position: 'absolute', left: pin[0] - ox, top: pin[1] - oy, width: w, height: h, transformOrigin: `${ox}px ${oy}px`, transform: `rotate(${r}deg) scale(${lerp(1.3, 1, k)})`, boxShadow: '0 14px 22px rgba(40,18,0,.45)', ...style }}>{children}</div>;
};

/** Letters arrive one at a time, in place, so the line never reflows. */
const Typed: React.FC<{ text: string; at: number; cps?: number }> = ({ text, at, cps = 34 }) => {
  const t = useT();
  const n = Math.floor((t - at) * cps);
  return <>{Array.from(text).map((c, i) => <span key={i} style={{ visibility: i < n ? 'visible' : 'hidden' }}>{c}</span>)}</>;
};

/** Handwriting wipes on from the left. */
const Scrawl: React.FC<{ at: number; d?: number; style: React.CSSProperties; children: React.ReactNode }> = ({ at, d = 0.4, style, children }) => {
  const t = useT();
  if (t < at) return null;
  // once the wipe ends the clip opens past the box, or a letter's overhang stays cut off
  const k = ramp(t, at, at + d);
  return <div style={{ position: 'absolute', whiteSpace: 'nowrap', clipPath: `inset(-20% ${k >= 1 ? '-20%' : `${(1 - k) * 100}%`} -20% -5%)`, ...style }}>{children}</div>;
};

const Stroke: React.FC<{ d: string; at: number; dur: number; color?: string; width: number; opacity?: number }> = ({ d, at, dur, color = RED, width, opacity = 0.92 }) => {
  const t = useT();
  if (t < at) return null;
  return <path d={d} fill="none" stroke={color} strokeWidth={width} strokeLinecap="round" strokeLinejoin="round" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - ramp(t, at, at + dur)} opacity={opacity} />;
};

/* ---------- socks ---------- */

const SOCK = 'M30 0 L72 0 L72 66 C72 78 80 84 94 90 C114 98 124 110 122 124 C120 138 104 144 86 142 L52 138 C36 136 30 122 30 102 Z';
type SockLook = { body: string; cuff: string; patch: string; band?: string; kind?: 'stripes' | 'dots' | 'diamonds' | 'plain' };
const THE_SOCK: SockLook = { body: '#efe8d8', cuff: '#efe8d8', patch: RED, band: '#23345f', kind: 'stripes' };
const ODD: SockLook[] = [
  { body: '#2f4a7a', cuff: '#1d2f52', patch: '#d9a441', kind: 'plain' },
  { body: '#d1495b', cuff: '#efe8d8', patch: '#efe8d8', band: '#efe8d8', kind: 'stripes' },
  { body: '#e8c547', cuff: '#2b2b2b', patch: '#2b2b2b', band: '#2b2b2b', kind: 'dots' },
  { body: '#4c956c', cuff: '#2c6e49', patch: '#fefee3', band: '#fefee3', kind: 'diamonds' },
  { body: '#f4f1de', cuff: '#e07a5f', patch: '#81b29a', band: '#e07a5f', kind: 'stripes' },
  { body: '#6d597a', cuff: '#355070', patch: '#e56b6f', band: '#eaac8b', kind: 'dots' },
  { body: '#333333', cuff: '#333333', patch: '#777777', kind: 'plain' },
  { body: '#f28482', cuff: '#84a59d', patch: '#f6bd60', band: '#f7ede2', kind: 'diamonds' },
  { body: '#90e0ef', cuff: '#0077b6', patch: '#0077b6', band: '#0077b6', kind: 'stripes' },
];
const SockG: React.FC<{ id: string; look: SockLook; shadow?: boolean }> = ({ id, look, shadow = true }) => (
  <g>
    {shadow ? <path d={SOCK} transform="translate(6 8)" fill="rgba(10,6,0,.28)" /> : null}
    <clipPath id={`sk-${id}`}><path d={SOCK} /></clipPath>
    <g clipPath={`url(#sk-${id})`}>
      <rect x="0" y="0" width="130" height="150" fill={look.body} />
      {look.kind === 'stripes' ? [30, 46, 62].map((y) => <rect key={y} x="0" y={y} width="130" height="8" fill={look.band} />) : null}
      {look.kind === 'dots' ? Array.from({ length: 14 }, (_, i) => <circle key={i} cx={36 + (i % 3) * 16 + (i % 2) * 6} cy={30 + i * 8} r={3.6} fill={look.band} />) : null}
      {look.kind === 'diamonds' ? [34, 62, 90].map((y) => <path key={y} d={`M51 ${y - 12} L66 ${y} L51 ${y + 12} L36 ${y} Z`} fill={look.band} />) : null}
      <rect x="0" y="0" width="130" height="22" fill={look.cuff} />
      {[34, 41, 48, 55, 62, 69].map((x) => <rect key={x} x={x} y="0" width="1.6" height="22" fill="rgba(0,0,0,.14)" />)}
      <circle cx="36" cy="134" r="26" fill={look.patch} />
      <circle cx="122" cy="130" r="27" fill={look.patch} />
    </g>
    <path d={SOCK} fill="none" stroke="rgba(25,15,5,.42)" strokeWidth="2" />
  </g>
);

/* ---------- the window: three photographs, drawn in code ---------- */

const Lineup: React.FC<{ w: number; h: number }> = ({ w, h }) => (
  <>
    <rect x="0" y="0" width={w} height={h} fill="#b8bcae" />
    {Array.from({ length: Math.ceil(h / 42) }, (_, i) => <rect key={i} x="0" y={i * 42 + 8} width={w} height={i % 2 ? 1.5 : 3} fill="#5d6158" opacity={i % 2 ? 0.45 : 0.7} />)}
    <rect x="0" y={h - 40} width={w} height="40" fill="#8d8f83" />
  </>
);
const Flash: React.FC<{ w: number; h: number }> = ({ w, h }) => <rect x="0" y="0" width={w} height={h} fill="url(#flash)" />;
const FlashDef: React.FC = () => <defs><radialGradient id="flash" cx="0.5" cy="0.42" r="0.72"><stop offset="0" stopColor="#fff6dc" stopOpacity="0.28" /><stop offset="0.55" stopColor="#fff6dc" stopOpacity="0" /><stop offset="1" stopColor="#120a04" stopOpacity="0.5" /></radialGradient></defs>;

const SockPhoto: React.FC = () => {
  const t = useT();
  const sway = Math.sin(t * 1.3) * 2.2;
  return (
    <svg viewBox="0 0 414 420" width={414} height={420} style={{ position: 'absolute', left: 0, top: 0, display: 'block' }}>
      <FlashDef />
      <Lineup w={414} h={420} />
      <g transform={`translate(108 38) rotate(${sway} 62 0) scale(1.72)`}><SockG id="lone" look={THE_SOCK} /></g>
      <Flash w={414} h={420} />
    </svg>
  );
};

const DryerPhoto: React.FC = () => {
  const t = useT();
  return (
    <svg viewBox="0 0 374 370" width={374} height={370} style={{ position: 'absolute', left: 0, top: 0, display: 'block' }}>
      <FlashDef />
      <Lineup w={374} h={370} />
      <ellipse cx="187" cy="336" rx="134" ry="13" fill="rgba(10,6,0,.34)" />
      <rect x="70" y="34" width="234" height="298" rx="10" fill="#e9e5d8" stroke="#8d897b" strokeWidth="2" />
      <rect x="70" y="34" width="234" height="54" rx="10" fill="#d4cfbf" />
      <circle cx="108" cy="61" r="15" fill="#7e7a6e" /><rect x="106" y="47" width="4" height="14" fill="#2b2b2b" transform={`rotate(${Math.floor(t) * 12} 108 61)`} />
      <rect x="150" y="50" width="74" height="22" rx="3" fill="#2a2f33" /><rect x="156" y="57" width={Math.floor(t * 2) % 2 ? 40 : 20} height="8" fill="#e0a13c" opacity="0.85" />
      <circle cx="262" cy="61" r="10" fill="#7e7a6e" />
      <circle cx="187" cy="204" r="92" fill="#bdb9ac" stroke="#8d897b" strokeWidth="2" />
      <circle cx="187" cy="204" r="76" fill="#262c33" />
      <g transform={`rotate(${t * 30} 187 204)`}>
        <ellipse cx="150" cy="186" rx="30" ry="20" fill="#7b4a4a" /><ellipse cx="216" cy="176" rx="28" ry="22" fill="#4f6d7a" /><ellipse cx="196" cy="240" rx="32" ry="20" fill="#a18a5a" />
      </g>
      <path d="M133 170 C150 142 190 134 222 150" fill="none" stroke="#fff" strokeOpacity="0.28" strokeWidth="7" strokeLinecap="round" />
      <rect x="281" y="180" width="12" height="50" rx="5" fill="#7e7a6e" />
      {[0, 1, 2, 3, 4].map((i) => <rect key={i} x={120 + i * 28} y="312" width="20" height="4" rx="2" fill="#a6a294" />)}
      <Flash w={374} h={370} />
    </svg>
  );
};

/* The pile is built once: it is a fixed heap, not a simulation. */
const PILE = Array.from({ length: 44 }, (_, i) => {
  const row = hash(i + 41);
  return { i, y: 300 + row * 205, x: 300 + (hash(i + 7) - 0.5) * (300 + row * 300), rot: -80 + hash(i + 13) * 160, s: 0.9 + hash(i + 23) * 0.45, look: ODD[i % ODD.length] };
}).sort((a, b) => a.y - b.y);

const CatPhoto: React.FC = () => {
  const t = useT();
  const breath = 1 + 0.016 * Math.sin(t * 2.5);
  const flick = Math.sin(t * 3.1) * ramp(Math.sin(t * 0.9), 0.1, 0.6);
  const earK = t > E('quiet') + 1.5 && t < E('quiet') + 1.72 ? Math.sin((t - E('quiet') - 1.5) * 28) * 9 : 0;
  const eye = ramp(t, E('nocomment') + 0.1, E('nocomment') + 0.24) * (1 - ramp(t, E('nocomment') + 1.15, E('nocomment') + 1.3));
  return (
    <svg viewBox="0 0 580 600" width={580} height={600} style={{ position: 'absolute', left: 0, top: 0, display: 'block' }}>
      <FlashDef />
      <defs><linearGradient id="wall" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#9a8463" /><stop offset="1" stopColor="#6f5c44" /></linearGradient></defs>
      <rect x="0" y="0" width="580" height="600" fill="url(#wall)" />
      <rect x="0" y="430" width="580" height="34" fill="#94836a" />
      <rect x="0" y="464" width="580" height="136" fill="#3c2b1d" />
      {[0, 1, 2, 3].map((i) => <rect key={i} x="0" y={486 + i * 30} width="580" height="2" fill="#26190f" />)}
      {/* behind the door: its edge and knob at the side of the shot */}
      <rect x="0" y="0" width="86" height="464" fill="#4d3825" /><rect x="86" y="0" width="7" height="464" fill="#2a1c10" /><rect x="10" y="40" width="58" height="150" fill="#5a4330" /><rect x="10" y="230" width="58" height="190" fill="#5a4330" />
      <circle cx="70" cy="268" r="11" fill="#c9a24a" />
      <ellipse cx="295" cy="548" rx="262" ry="30" fill="rgba(5,3,0,.5)" />
      {/* the pile */}
      {PILE.map((p) => <g key={p.i} transform={`translate(${p.x} ${p.y}) rotate(${p.rot}) scale(${p.s}) translate(-65 -70)`}><SockG id={`p${p.i}`} look={p.look} /></g>)}
      {/* the cat, asleep */}
      <g transform={`translate(0 ${(1 - breath) * 430}) scale(1 ${breath})`}>
        <path d="M120 400 C96 300 190 190 310 186 C440 184 510 290 480 392 C468 432 150 440 120 400 Z" fill="#d98a3d" stroke="#7d4413" strokeWidth="3" />
        {[0, 1, 2, 3, 4, 5].map((i) => <path key={i} d={`M${262 + i * 37} ${192 + Math.abs(i - 1.5) * 8} q 12 40 -5 ${66 - i * 2}`} fill="none" stroke="#b0641f" strokeWidth="16" strokeLinecap="round" />)}
        <path d="M150 396 C200 430 420 430 470 392 C450 428 170 436 150 396 Z" fill="#b96a26" opacity="0.5" />
      </g>
      {/* the partner, the one the whole case is about, under its chin */}
      <g transform="translate(208 452) rotate(-84) scale(1.34) translate(-65 -70)"><SockG id="partner" look={THE_SOCK} /></g>
      <path d={`M470 380 C528 ${440 + flick * 5} 420 ${478 + flick * 4} ${318 + flick * 7} ${452 + flick * 10}`} fill="none" stroke="#7d4413" strokeWidth="44" strokeLinecap="round" />
      <path d={`M470 380 C528 ${440 + flick * 5} 420 ${478 + flick * 4} ${318 + flick * 7} ${452 + flick * 10}`} fill="none" stroke="#d98a3d" strokeWidth="38" strokeLinecap="round" />
      <g>
        <g transform={`rotate(${earK} 150 272)`}><path d="M118 300 L112 214 L190 262 Z" fill="#d98a3d" stroke="#7d4413" strokeWidth="3" /><path d="M128 284 L124 234 L170 264 Z" fill="#e8a79a" /></g>
        <path d="M208 262 L270 214 L272 304 Z" fill="#d98a3d" stroke="#7d4413" strokeWidth="3" /><path d="M226 262 L260 234 L262 286 Z" fill="#e8a79a" />
        <ellipse cx="192" cy="338" rx="92" ry="76" fill="#d98a3d" stroke="#7d4413" strokeWidth="3" />
        {[0, 1, 2].map((i) => <rect key={i} x={170 + i * 19} y={270 + Math.abs(i - 1) * 5} width="8" height={32 - Math.abs(i - 1) * 7} rx="4" fill="#b0641f" />)}
        <rect x="104" y="338" width="30" height="7" rx="3.5" fill="#b0641f" /><rect x="110" y="360" width="28" height="7" rx="3.5" fill="#b0641f" /><rect x="250" y="338" width="30" height="7" rx="3.5" fill="#b0641f" /><rect x="246" y="360" width="28" height="7" rx="3.5" fill="#b0641f" />
        <ellipse cx="192" cy="378" rx="42" ry="28" fill="#f3d2a2" />
        <path d="M138 340 q 18 15 38 0" fill="none" stroke="#5a2f0c" strokeWidth="5.5" strokeLinecap="round" />
        {eye > 0.02 ? (
          <g>
            <ellipse cx="228" cy="340" rx="20" ry={15 * eye} fill="#e8c23a" stroke="#5a2f0c" strokeWidth="3" />
            <rect x="225" y={340 - 13 * eye} width="6" height={26 * eye} rx="3" fill="#1a1208" />
          </g>
        ) : <path d="M208 340 q 18 15 38 0" fill="none" stroke="#5a2f0c" strokeWidth="5.5" strokeLinecap="round" />}
        <path d="M183 362 L201 362 L192 373 Z" fill="#c8605a" />
        <path d="M192 373 q -9 13 -22 7 M192 373 q 9 13 22 7" fill="none" stroke="#5a2f0c" strokeWidth="3.5" strokeLinecap="round" />
        {[[-1, -6], [-1, 6], [1, -6], [1, 6]].map(([sd, dy], i) => <line key={i} x1={192 + sd * 44} y1={376} x2={192 + sd * 104} y2={376 + dy * 2.6} stroke="#fff4dc" strokeWidth="2.4" opacity="0.8" />)}
      </g>
      <Flash w={580} h={600} />
    </svg>
  );
};

const Develop: React.FC<{ at: number; d?: number }> = ({ at, d = 0.55 }) => {
  const t = useT();
  const k = 1 - ramp(t, at, at + d);
  return k > 0 ? <div style={{ position: 'absolute', inset: 0, background: '#efe9d6', opacity: k }} /> : null;
};
const Placard: React.FC<{ text: string; w: number }> = ({ text, w }) => <div style={{ position: 'absolute', left: '50%', bottom: 12, width: w, marginLeft: -w / 2, background: '#17181a', color: '#efe8d8', textAlign: 'center', font: `30px/44px ${TYPE}`, borderRadius: 3, letterSpacing: '.04em' }}>{text}</div>;

/* ---------- the board ---------- */

const flecks = (n: number, seed: number) => Array.from({ length: n }, (_, i) => `M${(hash(i * 3 + seed) * 1080).toFixed(1)} ${(hash(i * 7 + seed + 1) * 1920).toFixed(1)}l${(hash(i + seed + 2) * 5).toFixed(1)} ${(hash(i + seed + 3) * 3).toFixed(1)}`).join('');
const FLECKS: [string, string, number, number][] = [[flecks(1500, 11), '#5e3a17', 3.4, 0.34], [flecks(1100, 5011), '#efd19c', 2.6, 0.3], [flecks(500, 9011), '#3f2610', 5.5, 0.2], [flecks(260, 13011), '#d7ad72', 8, 0.2]];
const Cork: React.FC = () => (
  <>
    <div style={{ position: 'absolute', inset: 0, background: '#b5844e', backgroundImage: 'radial-gradient(90% 60% at 50% 42%,rgba(255,220,160,.14),transparent 70%)' }} />
    <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0 }}>
      {FLECKS.map(([d, c, w, o], i) => <path key={i} d={d} fill="none" stroke={c} strokeWidth={w} strokeLinecap="round" opacity={o} />)}
      {/* pale patches and old pin holes from other cases */}
      {[[640, 300, 340, 250, 3], [90, 900, 300, 200, -4], [560, 1020, 360, 240, 2]].map(([x, y, w, h, r], i) => <rect key={`g${i}`} x={x} y={y} width={w} height={h} fill="#e9c88f" opacity={0.13} transform={`rotate(${r} ${x} ${y})`} />)}
      {[[130, 280], [612, 276], [980, 640], [460, 1210], [880, 1180], [120, 820], [560, 980]].map(([x, y], i) => <circle key={`h${i}`} cx={x} cy={y} r={4} fill="#3a2410" opacity={0.65} />)}
      <circle cx="930" cy="1560" r="62" fill="none" stroke="#5e3a17" strokeWidth="9" opacity="0.28" />
    </svg>
  </>
);

/** Frame 0: a chalk outline of a sock, drawing itself. The polaroid lands on top of it. */
const Chalk: React.FC = () => {
  const t = useT();
  if (t >= E('sock') + 0.2) return null;
  const k = ramp(t, 0, 1.05);
  return (
    <svg viewBox="0 0 130 150" width={442} height={510} style={{ position: 'absolute', left: 92, top: 300, overflow: 'visible' }}>
      <path d={SOCK} fill="none" stroke="#efe9d8" strokeWidth="2.4" strokeLinejoin="round" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - k} opacity={0.82} />
      <path d={SOCK} transform="translate(.7 -.5)" fill="none" stroke="#fff" strokeWidth="0.9" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - k} opacity={0.5} />
    </svg>
  );
};

const Frame: React.FC = () => <div style={{ position: 'absolute', inset: 0, boxShadow: 'inset 0 0 0 30px #5c3a1e,inset 0 0 0 36px #3e2611,inset 0 0 60px 40px rgba(40,20,5,.55)', pointerEvents: 'none' }} />;

/** Below the safe area the platform's buttons cover everything, so only old paperwork lives there. */
const Under: React.FC = () => (
  <>
    <div style={{ position: 'absolute', left: 560, top: 1660, width: 500, height: 250, background: 'linear-gradient(#d9bd80,#cdb06f)', transform: 'rotate(-3deg)', borderRadius: 6, boxShadow: '0 14px 22px rgba(40,18,0,.4)', padding: '26px 34px' }}>
      <span style={{ display: 'inline-block', border: `5px solid ${RED}`, color: RED, font: `52px/1 ${TYPE}`, padding: '8px 14px 2px', transform: 'rotate(-4deg)', opacity: 0.8 }}>EVIDENCE</span>
    </div>
    {[[70, 1530, -4], [300, 1610, 3], [90, 1730, 5]].map(([x, y, r], i) => <div key={i} style={{ position: 'absolute', left: x, top: y, width: 300, height: 180, background: CARD, transform: `rotate(${r}deg)`, opacity: 0.9, boxShadow: '0 10px 16px rgba(40,18,0,.4)', backgroundImage: 'repeating-linear-gradient(transparent 0 32px,#9ec0e6 32px 34px)' }} />)}
  </>
);

const Tab: React.FC = () => {
  const t = useT();
  const closed = t >= E('closed');
  const k = back(ramp(t, 0, 0.18));
  return (
    <div style={{ position: 'absolute', left: 66, top: 112, width: 640, height: 134, background: MANILA, borderRadius: '10px 10px 4px 4px', padding: '14px 26px', transform: `rotate(${-1.5 + shake(t) * 0.1}deg) scale(${lerp(1.18, 1, k)})`, transformOrigin: '20% 50%', boxShadow: '0 12px 20px rgba(40,18,0,.45)' }}>
      <div style={{ font: `58px/62px ${TYPE}`, color: '#3a2a14' }}>CASE 417</div>
      <div style={{ font: `700 48px/48px ${HAND}`, color: '#7a1414' }}>{closed ? 'closed. one sock, one cat' : 'one (1) missing sock'}</div>
    </div>
  );
};

/** The wall clock ticks once a second, on the same seconds as the tick sound. */
const WallClock: React.FC = () => {
  const t = useT();
  const tick = Math.floor(t - E('low'));
  const kick = 1 - ramp(t - E('low') - tick, 0, 0.09);
  const sec = (tick + 14) * 6 + kick * -2.5;
  return (
    <svg viewBox="-70 -70 140 140" width={140} height={140} style={{ position: 'absolute', left: 868, top: 104 }}>
      <circle cx="4" cy="8" r="66" fill="rgba(30,12,0,.45)" />
      <circle cx="0" cy="0" r="66" fill="#3e2611" /><circle cx="0" cy="0" r="58" fill="#efe8d8" />
      {Array.from({ length: 12 }, (_, i) => <rect key={i} x="-1.6" y="-54" width="3.2" height={i % 3 ? 7 : 12} fill="#231c14" transform={`rotate(${i * 30})`} />)}
      <rect x="-2.6" y="-30" width="5.2" height="34" rx="2" fill="#231c14" transform="rotate(94)" />
      <rect x="-2" y="-46" width="4" height="50" rx="2" fill="#231c14" transform={`rotate(${42 + t * 0.1})`} />
      <rect x="-0.9" y="-50" width="1.8" height="60" fill={RED} transform={`rotate(${sec})`} />
      <circle cx="0" cy="0" r="4" fill={RED} />
    </svg>
  );
};

const SockPolaroid: React.FC = () => (
  <Item t0={E('sock')} pin={PIN.sock} ox={235} oy={16} w={470} h={566} rot={-3} seed={1} tug={1.4} style={{ background: PAPER }}>
    <div style={{ position: 'absolute', left: 28, top: 30, width: 414, height: 420, overflow: 'hidden', background: '#111' }}>
      <SockPhoto />
      <Placard text="LAST SEEN TUE" w={270} />
      <Develop at={E('sock')} />
    </div>
    <Scrawl at={E('tuesday')} d={0.45} style={{ left: 0, width: 470, top: 462, textAlign: 'center', font: `700 72px/1 ${HAND}`, color: INK }}>the victim</Scrawl>
  </Item>
);

const DryerPolaroid: React.FC = () => {
  const t = useT();
  const at = E('dryer') - 0.1;
  if (t < at) return null;
  const q = ramp(t, E('tear'), E('tear') + 0.95);
  if (q >= 1) return null;
  const k = back(ramp(t, at, at + 0.2));
  const fall = easeIn(q);
  const torn = t >= E('tear');
  return (
    <div style={{ position: 'absolute', left: PIN.dryer[0] - 215, top: PIN.dryer[1] - 16, width: 430, height: 512, background: PAPER, transformOrigin: '215px 16px', transform: `translate(${q * 170}px, ${fall * 1750}px) rotate(${2.6 + Math.sin(t * 0.8 + 2) * 0.28 + shake(t) * 0.1 + easeOut(ramp(t, E('tear'), E('tear') + 0.2)) * 13 + fall * 30}deg) scale(${lerp(1.3, 1, k)})`, boxShadow: torn ? undefined : '0 14px 22px rgba(40,18,0,.45)', clipPath: torn ? 'polygon(0 0,38% 0,42% 3%,46% 1%,50% 6.5%,55% 2%,58% 5%,62% 0,100% 0,100% 100%,0 100%)' : undefined }}>
      <div style={{ position: 'absolute', left: 28, top: 30, width: 374, height: 370, overflow: 'hidden', background: '#111' }}>
        <DryerPhoto />
        <Placard text="SUSPECT 1" w={200} />
        <Develop at={E('dryer')} d={0.35} />
      </div>
      <div style={{ position: 'absolute', left: 8, top: 462, width: 30, height: 30, borderRadius: '50%', background: 'radial-gradient(circle at 35% 30%,#ff9a9a,#d4141f 45%,#7a0b10)', boxShadow: '2px 5px 5px rgba(20,8,0,.5)' }} />
      <Scrawl at={E('blames')} d={0.4} style={{ left: 0, width: 430, top: 412, textAlign: 'center', font: `700 72px/1 ${HAND}`, color: INK }}>it&apos;s always him</Scrawl>
      <svg viewBox="0 0 430 512" width={430} height={512} style={{ position: 'absolute', left: 0, top: 0 }}>
        <Stroke d="M36 44 L394 470" at={E('alibi') - 0.05} dur={0.16} width={30} />
        <Stroke d="M398 40 L32 474" at={E('alibi') + 0.14} dur={0.16} width={30} />
      </svg>
    </div>
  );
};
const TornScrap: React.FC = () => {
  const t = useT();
  if (t < E('tear')) return null;
  return <div style={{ position: 'absolute', left: PIN.dryer[0] - 48, top: PIN.dryer[1] - 16, width: 96, height: 44, background: PAPER, clipPath: 'polygon(0 0,100% 0,88% 50%,76% 22%,60% 76%,44% 30%,28% 92%,12% 40%)', transform: 'rotate(4deg)' }} />;
};

const ExhibitA: React.FC = () => (
  <Item t0={E('exhibit')} pin={PIN.exhibit} ox={230} oy={14} w={460} h={250} rot={2} seed={3} tug={1.2} style={{ background: CARD, backgroundImage: 'linear-gradient(transparent 62px,#e04848 62px,#e04848 64px,transparent 64px),repeating-linear-gradient(transparent 0 44px,#9ec0e6 44px 46px)' }}>
    <div style={{ position: 'absolute', left: 26, top: 18, font: `40px/1 ${TYPE}`, color: INK }}>EXHIBIT A</div>
    <Scrawl at={E('exhibit') + 0.05} d={0.35} style={{ left: 40, top: 70, font: `700 74px/1 ${HAND}`, color: RED }}>went in: 2</Scrawl>
    <Scrawl at={E('only')} d={0.4} style={{ left: 40, top: 150, font: `700 74px/1 ${HAND}`, color: RED }}>came out: 1</Scrawl>
    <svg viewBox="0 0 460 250" width={460} height={250} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible' }}>
      <Stroke d="M24 104 C56 54 252 50 288 90 C318 130 226 156 116 150 C44 146 6 134 32 90" at={E('turn')} dur={0.75} width={8} color="#d7263d" />
    </svg>
    <Scrawl at={E('never')} d={0.5} style={{ left: 306, top: 66, font: `52px/1 ${MARK}`, color: INK, transform: 'rotate(-5deg)' }}>did it?</Scrawl>
  </Item>
);

const Statement: React.FC = () => (
  <Item t0={E('everybody')} pin={PIN.statement} ox={205} oy={14} w={410} h={300} rot={-2} seed={4} tug={-1} style={{ background: '#f2eedf', padding: '34px 26px' }}>
    <div style={{ font: `30px/1 ${TYPE}`, color: INK, borderBottom: `2px solid ${INK}`, paddingBottom: 8 }}>WITNESS STATEMENT</div>
    <div style={{ font: `42px/50px ${TYPE}`, color: INK, marginTop: 18 }}><Typed text="It's always the dryer." at={E('everybody') + 0.1} /></div>
    <Scrawl at={E('everybody') + 0.95} d={0.3} style={{ left: 26, top: 198, font: `700 54px/1 ${HAND}`, color: '#23345f' }}>signed: everybody</Scrawl>
  </Item>
);

const LintBag: React.FC = () => (
  <Item t0={E('lint')} pin={PIN.lint} ox={100} oy={12} w={200} h={186} rot={6} seed={5} tug={-1.6} style={{ background: 'rgba(238,240,236,.86)', border: '2px solid rgba(255,255,255,.55)', boxSizing: 'border-box' }}>
    <div style={{ position: 'absolute', left: 0, right: 0, top: 26, height: 3, background: 'rgba(90,90,90,.5)' }} />
    <svg viewBox="0 0 200 110" width={196} height={108} style={{ position: 'absolute', left: 0, top: 34 }}>
      {Array.from({ length: 34 }, (_, i) => <circle key={i} cx={100 + (hash(i + 3) - 0.5) * 120} cy={54 + (hash(i + 8) - 0.5) * 60} r={7 + hash(i + 11) * 13} fill={i % 3 ? '#9a9a9e' : '#7d7d84'} opacity={0.75} />)}
    </svg>
    <div style={{ position: 'absolute', left: 8, bottom: 6, right: 8, textAlign: 'center', font: `30px/1 ${TYPE}`, color: INK }}>LINT: CLEAN</div>
  </Item>
);

const OddSocks: React.FC = () => {
  const t = useT();
  return (
    <>
      {ODD.map((look, i) => {
        const at = E('nine') + i * 0.075;
        if (t < at) return null;
        const col = i % 3, row = Math.floor(i / 3);
        const k = back(ramp(t, at, at + 0.14));
        const x = 590 + col * 120, y = 296 + row * 122;
        return (
          <div key={i} style={{ position: 'absolute', left: x, top: y, width: 116, height: 132, transformOrigin: '56px 10px', transform: `rotate(${-8 + hash(i + 31) * 16 + Math.sin(t * 0.9 + i) * 0.7 + shake(t) * 0.14}deg) scale(${lerp(1.5, 1, k)})` }}>
            <svg viewBox="0 -4 136 158" width={116} height={132} style={{ overflow: 'visible' }}><SockG id={`o${i}`} look={look} /></svg>
            <div style={{ position: 'absolute', left: 34, top: -6, width: 22, height: 22, borderRadius: '50%', background: 'radial-gradient(circle at 35% 30%,#ff9a9a,#d4141f 45%,#7a0b10)', boxShadow: '2px 4px 4px rgba(20,8,0,.5)' }} />
          </div>
        );
      })}
    </>
  );
};

const Clipping: React.FC = () => (
  <Item t0={E('pairs')} pin={PIN.clipping} ox={215} oy={14} w={430} h={320} rot={-3} seed={6} tug={-1.2} style={{ background: '#e4dec9', boxShadow: undefined, clipPath: 'polygon(0 2%,8% 0,22% 1.5%,40% 0,63% 1%,80% 0,100% 1.5%,99% 30%,100% 62%,98.5% 100%,70% 98.5%,44% 100%,20% 98.6%,0 100%,1.2% 60%)', padding: '30px 26px' }}>
    <div style={{ font: `24px/1 ${TYPE}`, color: INK, textAlign: 'center', borderBottom: `3px double ${INK}`, paddingBottom: 6 }}>THE EVENING LAUNDRY</div>
    <div style={{ font: `64px/66px ${TYPE}`, color: '#111', WebkitTextStroke: '1.4px #111', marginTop: 12, letterSpacing: '-.02em' }}>NO PAIRS.<br />NO LEADS.</div>
    <div style={{ font: `27px/32px ${TYPE}`, color: '#3d3a33', marginTop: 10 }}>Detective baffled, feet cold.</div>
    {[0, 1].map((i) => <div key={i} style={{ height: 9, background: '#b1aa97', marginTop: 9 }} />)}
  </Item>
);

/** The card every string ends up tied to. It is face up until the reveal, then it flips and becomes the photograph. */
const flipK = (t: number) => ramp(t, E('flip'), E('flip') + 0.55);
const cardPin = (t: number): P => mix(PIN.card, PIN.photo, easeInOut(flipK(t)));

const WayCard: React.FC = () => {
  const t = useT();
  const k = flipK(t);
  if (t < E('way') - 0.1 || k >= 0.5) return null;
  const pin = cardPin(t);
  const land = back(ramp(t, E('way') - 0.1, E('way') + 0.1));
  const grow = lerp(1, 1.25, easeInOut(k * 2));
  return (
    <div style={{ position: 'absolute', left: pin[0] - 200, top: pin[1] - 14, width: 400, height: 196, background: CARD, transformOrigin: '200px 14px', transform: `perspective(1600px) rotateY(${k * 2 * 90}deg) rotate(${-2 + shake(t) * 0.1}deg) scale(${lerp(1.3, 1, land) * grow})`, boxShadow: '0 16px 26px rgba(40,18,0,.55)', textAlign: 'center' }}>
      <div style={{ font: `28px/1 ${TYPE}`, color: INK, marginTop: 34 }}>EXHIBIT C</div>
      <div style={{ font: `64px/1 ${MARK}`, color: RED, marginTop: 16 }}>ON THE WAY</div>
    </div>
  );
};

const Timeline: React.FC = () => {
  const t = useT();
  const at = E('pattern') + 0.1;
  if (t < at) return null;
  const k = easeOut(ramp(t, at, at + 0.7));
  return (
    <div style={{ position: 'absolute', left: 60, top: 1092, width: 890 * k, height: 48, overflow: 'hidden', background: 'rgba(236,224,190,.96)', boxShadow: '0 4px 8px rgba(40,18,0,.4)', transform: `rotate(${-0.4 + shake(t) * 0.04}deg)`, transformOrigin: '0 50%' }}>
      <div style={{ position: 'absolute', left: 14, top: 2, width: 860, height: 44, display: 'flex', justifyContent: 'space-between', font: `700 44px/44px ${HAND}`, color: INK, whiteSpace: 'nowrap' }}>
        <span>laundry basket</span>
        <span style={{ flex: 1, margin: '21px 16px 0', height: 4, background: INK, opacity: 0.8 }} />
        <span>the machine</span>
      </div>
    </div>
  );
};

const Paw: React.FC<{ x: number; y: number; r: number; s?: number }> = ({ x, y, r, s = 1 }) => (
  <g transform={`translate(${x} ${y}) rotate(${r}) scale(${s})`} fill="#3a2a14" opacity="0.8">
    <ellipse cx="0" cy="5" rx="9" ry="7.5" /><circle cx="-10" cy="-4" r="3.6" /><circle cx="-3.5" cy="-9" r="3.8" /><circle cx="4" cy="-9" r="3.8" /><circle cx="10.5" cy="-4" r="3.6" />
  </g>
);

const Sticky: React.FC<{ at: number; pin: P; rot: number; seed: number; size: number; lines: string[]; paws: [number, number, number][] }> = ({ at, pin, rot, seed, size, lines, paws }) => (
  <Item t0={at} pin={pin} ox={135} oy={12} w={270} h={150} rot={rot} seed={seed} style={{ background: `linear-gradient(160deg,#fff07a,${NOTE} 60%,#f2d23e)`, textAlign: 'center' }}>
    <svg viewBox="0 0 270 150" width={270} height={150} style={{ position: 'absolute', left: 0, top: 0 }}>{paws.map(([x, y, r], i) => <Paw key={i} x={x} y={y} r={r} />)}</svg>
    <div style={{ position: 'absolute', left: 0, right: 0, top: lines.length > 1 ? 24 : 42, font: `${size}px/${size + 4}px ${MARK}`, color: '#1d1d1d' }}>{lines.map((l) => <div key={l}>{l}</div>)}</div>
  </Item>
);

/* ---------- red string ---------- */

const quad = (a: P, b: P, sag: number, p: number) => {
  const c: P = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2 + sag];
  const ab = mix(a, c, p), bc = mix(c, b, p), e = mix(ab, bc, p);
  return `M${a[0].toFixed(1)} ${a[1].toFixed(1)} Q${ab[0].toFixed(1)} ${ab[1].toFixed(1)} ${e[0].toFixed(1)} ${e[1].toFixed(1)}`;
};
const Thread: React.FC<{ d: string }> = ({ d }) => (
  <>
    <path d={d} transform="translate(4 8)" fill="none" stroke="rgba(30,12,0,.32)" strokeWidth="6" strokeLinecap="round" />
    <path d={d} fill="none" stroke={RED} strokeWidth="6" strokeLinecap="round" />
    <path d={d} fill="none" stroke="#ff6a6a" strokeWidth="1.6" strokeDasharray="7 9" strokeLinecap="round" />
  </>
);

/** A cut string hangs from its one remaining pin and swings. */
const swing = (t: number, t0: number, anchor: P, from: P, len: number): P => {
  const u = t - t0;
  const start = Math.atan2(from[0] - anchor[0], from[1] - anchor[1]);
  const th = start * Math.cos(u * 4.6) * Math.exp(-u * 1.1);
  const hang: P = [anchor[0] + Math.sin(th) * len, anchor[1] + Math.cos(th) * len];
  return mix(from, hang, easeOut(ramp(u, 0, 0.22)));
};

const Strings: React.FC = () => {
  const t = useT();
  const way = E('way'), tear = E('tear');
  const card = cardPin(t);
  const pull = easeOut(ramp(t, way + 0.05, way + 0.3));
  const hum = 22 * twang(t, way + 0.28) + 18 * twang(t, E('closed'));
  const threads: { a: P; b: P; sag: number; p: number }[] = [];
  const add = (a: P, b: P, sag: number, at: number, d = 0.35) => { if (t >= at) threads.push({ a, b, sag: sag * (1 - pull) + hum, p: easeOut(ramp(t, at, at + d)) }); };
  add(PIN.sockLow, PIN.exhibit, 34, E('exhibit') + 0.12);
  // the two strings that die with the dryer, then come back up tight to the card
  if (t >= E('dryer') + 0.1) {
    const back1 = t < tear ? PIN.dryer : mix(swing(t, tear, PIN.sockTop, PIN.dryer, 330), card, easeOut(ramp(t, way + 0.05, way + 0.3)));
    threads.push({ a: PIN.sockTop, b: back1, sag: (t < tear ? 44 : 90) * (1 - pull) + hum, p: easeOut(ramp(t, E('dryer') + 0.1, E('dryer') + 0.45)) });
  }
  if (t >= E('everybody') + 0.12) {
    const back2 = t < tear ? PIN.dryerLow : mix(swing(t, tear + 0.04, PIN.statement, PIN.dryerLow, 200), card, easeOut(ramp(t, way + 0.1, way + 0.34)));
    threads.push({ a: PIN.statement, b: back2, sag: (t < tear ? 30 : 40) * (1 - pull) + hum, p: easeOut(ramp(t, E('everybody') + 0.12, E('everybody') + 0.42)) });
  }
  add(PIN.gridLow, PIN.clipping, 40, E('pairs') + 0.12, 0.3);
  // on the pattern, every clue ties to the one card
  add(PIN.exhibit, card, 30, way + 0.03, 0.22);
  add(PIN.clipping, card, 30, way + 0.07, 0.22);
  add(PIN.lint, card, 30, way + 0.11, 0.22);
  add(PIN.grid, card, 30, way + 0.15, 0.22);
  add(PIN.hall, card, 0, E('hall') + 0.08, 0.22);
  add(PIN.stairs, card, 0, E('stairs') + 0.08, 0.22);
  add(PIN.door, card, 0, E('door') + 0.08, 0.22);
  return (
    <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, pointerEvents: 'none' }}>
      {threads.map((x, i) => <Thread key={i} d={quad(x.a, x.b, x.sag, x.p)} />)}
    </svg>
  );
};

/* ---------- the reveal and the verdict ---------- */

const CatPolaroid: React.FC = () => {
  const t = useT();
  const k = flipK(t);
  if (k < 0.5) return null;
  const pin = cardPin(t);
  const q = (k - 0.5) * 2;
  return (
    <>
      <div style={{ position: 'absolute', left: pin[0] - 320, top: pin[1] - 16, width: 640, height: 776, background: PAPER, transformOrigin: '320px 16px', transform: `perspective(1600px) rotateY(${lerp(-90, 0, easeOut(q))}deg) rotate(${-2 + shake(t) * 0.1 + Math.sin(t * 0.8) * 0.2}deg) scale(${lerp(0.78, 1, back(q))})`, boxShadow: '0 22px 38px rgba(30,12,0,.6)' }}>
        <div style={{ position: 'absolute', left: 30, top: 34, width: 580, height: 600, overflow: 'hidden', background: '#111' }}>
          <CatPhoto />
          <Develop at={E('flip') + 0.2} d={0.3} />
        </div>
        <Scrawl at={E('cat') + 0.1} d={0.5} style={{ left: 0, width: 640, top: 646, textAlign: 'center', font: `700 96px/1 ${HAND}`, color: INK }}>suspect: the cat</Scrawl>
      </div>
      <Pin at={pin} t0={E('flip')} />
    </>
  );
};

const Tag: React.FC<{ at: number; pin: P; rot: number; seed: number; a: string; b: string }> = ({ at, pin, rot, seed, a, b }) => {
  const t = useT();
  if (t < at - 0.1) return null;
  return (
    <>
      <Item t0={at} pin={pin} ox={125} oy={12} w={250} h={138} rot={rot} seed={seed} style={{ background: CARD, textAlign: 'center' }}>
        <div style={{ font: `32px/1 ${TYPE}`, color: INK, marginTop: 36 }}>{a}</div>
        <div style={{ font: `700 66px/1 ${HAND}`, color: RED }}>{b}</div>
      </Item>
      <Pin at={pin} t0={at} />
    </>
  );
};

const Stamp: React.FC = () => {
  const t = useT();
  const at = E('closed') - 0.14;
  if (t < at) return null;
  const k = easeIn(ramp(t, at, at + 0.14));
  return (
    <div style={{ position: 'absolute', left: 130, top: 716, width: 820, boxSizing: 'border-box', textAlign: 'center', padding: '22px 0 8px', border: `14px double ${RED}`, background: 'rgba(247,245,239,.5)', color: RED, font: `122px/1 ${TYPE}`, WebkitTextStroke: `5px ${RED}`, whiteSpace: 'nowrap', opacity: 0.96, transform: `rotate(-9deg) scale(${lerp(2.4, 1, k)})`, transformOrigin: '50% 50%' }}>CASE CLOSED</div>
  );
};

const NoComment: React.FC = () => {
  const t = useT();
  const at = E('nocomment') - 0.35;
  if (t < at - 0.1) return null;
  const pin: P = [254, 1036];
  return (
    <>
      <Item t0={at} pin={pin} ox={190} oy={12} w={380} h={134} rot={-3} seed={8} style={{ background: '#f2eedf', padding: '32px 18px 0' }}>
        <div style={{ font: `29px/1 ${TYPE}`, color: INK, whiteSpace: 'nowrap' }}>STATEMENT OF THE CAT:</div>
        <div style={{ font: `700 62px/1 ${HAND}`, color: '#23345f', marginTop: 2 }}>(none)</div>
      </Item>
      <Pin at={pin} t0={at} />
    </>
  );
};

const CtaNote: React.FC = () => {
  const t = useT();
  const at = E('cta') + 0.1;
  if (t < at - 0.1) return null;
  const pin: P = [750, 950];
  return (
    <>
      <Item t0={at} pin={pin} ox={185} oy={14} w={370} h={316} rot={4} seed={9} style={{ background: `linear-gradient(160deg,#fff07a,${NOTE} 60%,#f2d23e)`, textAlign: 'center' }}>
        <div style={{ font: `58px/1 ${MARK}`, color: '#1d1d1d', marginTop: 48 }}>COMMENT</div>
        <div style={{ font: `136px/1 ${MARK}`, color: RED, marginTop: 18 }}>CASE</div>
      </Item>
      <Pin at={pin} t0={at} />
    </>
  );
};

/* ---------- light ---------- */

const RAIN = Array.from({ length: 64 }, (_, i) => ({ x: hash(i + 5) * 1100 - 10, sp: 300 + hash(i + 77) * 340, ph: hash(i + 150) * 2300, l: 40 + hash(i + 220) * 90, o: 0.3 + hash(i + 300) * 0.6 }));

const Light: React.FC = () => {
  const t = useT();
  const L = lampAt(t);
  const spot = spotAt(t);
  const dark = (1 - L) * 0.76;
  const cam = camAt(t);
  const hx = cam.s * PIN.exhibit[0] + cam.tx, hy = cam.s * (PIN.exhibit[1] + 110) + cam.ty;
  const lx = 50 + Math.sin(t * 0.7) * 8, ly = 40 + Math.cos(t * 0.5) * 5;
  const rain = 0.12 + 0.62 * (1 - L);
  return (
    <>
      <AbsoluteFill style={{ background: `radial-gradient(ellipse 72% 54% at ${lx}% ${ly}%, rgba(255,232,180,${0.22 * L}), rgba(255,232,180,0) 60%, rgba(30,12,0,${0.35 * L}))` }} />
      <AbsoluteFill style={{ background: `radial-gradient(ellipse 520px 380px at ${hx}px ${hy}px, rgba(9,9,24,${dark * (1 - 0.92 * spot)}) 40%, rgba(9,9,24,${dark}) 100%)` }} />
      {/* the blinds of the office window, and the rain running down them */}
      <div style={{ position: 'absolute', left: -160, top: -520, width: 1400, height: 2800, transform: `rotate(-9deg) translateY(${t * 5}px)`, opacity: 0.1 + 0.7 * (1 - L), background: 'repeating-linear-gradient(rgba(164,190,226,.2) 0 58px, rgba(0,0,10,.1) 58px 118px)' }} />
      <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, opacity: rain }}>
        {RAIN.map((r, i) => { const y = ((r.ph + t * r.sp) % 2300) - 180; return <line key={i} x1={r.x} y1={y} x2={r.x - 3} y2={y + r.l} stroke="#b9cdea" strokeWidth={2} opacity={r.o * 0.55} strokeLinecap="round" />; })}
      </svg>
    </>
  );
};

/* ---------- captions: punched label tape ---------- */

const PER_ROW = 22, CELL = 38, PAD = 18;
const PURR_T0 = E('quiet') + 0.3;
const PURR: Word = { w: '(purring)', t0: PURR_T0, t1: PURR_T0 + 0.7, scene: 's10' };
const CHUNKS = [...buildChunks(PER_ROW, 2), { t0: PURR.t0, rows: [{ s: '(PURRING)', map: Array.from('(PURRING)', () => 0) }], words: [PURR] }].sort((a, b) => a.t0 - b.t0);

const TapeRow: React.FC<{ row: { s: string; map: number[] }; words: Word[]; y: number; seed: number; hot: (w: Word) => boolean }> = ({ row, words, y, seed, hot }) => {
  const t = useT();
  const when = (i: number) => {
    const wi = row.map[i];
    if (wi < 0) return Infinity;
    const w = words[wi];
    const first = row.map.indexOf(wi);
    return w.t0 - 0.05 + (i - first) * Math.min(0.045, (w.t1 - w.t0) / Math.max(1, w.w.length));
  };
  let shown = 0;
  for (let i = 0; i < row.s.length; i++) if (when(i) <= t) shown = i + 1;
  if (!shown) return null;
  return (
    <div style={{ position: 'absolute', left: 62, top: y, height: 80, width: PAD * 2 + shown * CELL, background: `linear-gradient(#262626,${TAPE} 18%,${TAPE} 84%,#050505)`, borderRadius: 3, boxShadow: '0 9px 15px rgba(25,10,0,.55)', transform: `rotate(${-0.55 + hash(seed) * 1.1}deg)`, transformOrigin: '0 50%', whiteSpace: 'nowrap' }}>
      {Array.from(row.s.slice(0, shown)).map((c, i) => {
        const wi = row.map[i];
        const age = t - when(i);
        const on = wi >= 0 && hot(words[wi]);
        return <span key={i} style={{ position: 'absolute', left: PAD + i * CELL, top: 9, width: CELL, textAlign: 'center', font: `66px/66px ${TYPE}`, color: on ? NOTE : '#f3f1e8', textShadow: '0 3px 0 rgba(0,0,0,.65),0 -1px 0 rgba(255,255,255,.3)', transform: age < 0.05 ? 'scale(1.16)' : undefined }}>{c}</span>;
      })}
    </div>
  );
};

const Captions: React.FC = () => {
  const t = useT();
  const idx = CHUNKS.reduce((a, c, i) => (t >= c.t0 - 0.06 ? i : a), -1);
  if (idx < 0) return null;
  const c = CHUNKS[idx];
  const hot = (w: Word) => (t >= w.t0 - 0.03 && t < w.t1 + 0.1) || (t >= E('cta') && w.w.toUpperCase() === 'CASE');
  const single = c.rows.length === 1;
  return (
    <>
      {c.rows.map((row, ri) => <TapeRow key={`${idx}-${ri}`} row={row} words={c.words} y={single || ri === 1 ? 1392 : 1304} seed={idx * 3 + ri + 1} hot={hot} />)}
    </>
  );
};

/* ---------- the film ---------- */

export const CaseFile: React.FC = () => (
  <Clock>
    <Fonts faces={FACES}>
      <Stage />
    </Fonts>
  </Clock>
);

const Stage: React.FC = () => {
  const t = useT();
  const cam = camAt(t);
  const pins: [P, number][] = [[PIN.sock, E('sock')], [PIN.sockTop, E('dryer')], [PIN.sockLow, E('exhibit')], [PIN.exhibit, E('exhibit')], [PIN.dryer, E('dryer')], [PIN.statement, E('everybody')], [PIN.lint, E('lint')], [PIN.clipping, E('pairs')], [PIN.hall, E('hall')], [PIN.stairs, E('stairs')], [PIN.door, E('door')]];
  return (
    <AbsoluteFill style={{ background: '#3e2611', overflow: 'hidden' }}>
      <div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1920, transformOrigin: '0 0', transform: `translate(${cam.tx}px, ${cam.ty}px) scale(${cam.s})` }}>
        <Cork />
        <Under />
        <Chalk />
        <Tab />
        <WallClock />
        <SockPolaroid />
        <DryerPolaroid />
        <ExhibitA />
        <Statement />
        <LintBag />
        <OddSocks />
        <Clipping />
        <Timeline />
        <Sticky at={E('hall')} pin={PIN.hall} rot={-3} seed={11} size={50} lines={['HALLWAY']} paws={[[226, 124, 40]]} />
        <Sticky at={E('stairs')} pin={PIN.stairs} rot={2} seed={12} size={54} lines={['STAIRS']} paws={[[34, 122, 50], [236, 126, 30]]} />
        <Sticky at={E('door')} pin={PIN.door} rot={-2} seed={13} size={42} lines={['BEHIND', 'THE DOOR']} paws={[[22, 132, 30], [250, 132, 60]]} />
        <TornScrap />
        <Strings />
        <WayCard />
        {pins.map(([p, at], i) => <Pin key={i} at={p} t0={at} />)}
        {t >= E('way') - 0.02 && flipK(t) < 0.5 ? <Pin at={cardPin(t)} t0={E('way') - 0.02} /> : null}
        <CatPolaroid />
        <Tag at={E('quiet') + 0.45} pin={[880, 420]} rot={5} seed={14} a="MOTIVE:" b="warm" />
        <Tag at={E('quiet') + 1.35} pin={[190, 470]} rot={-5} seed={15} a="REMORSE:" b="none" />
        <Stamp />
        <NoComment />
        <CtaNote />
        <Frame />
      </div>
      <Light />
      <Captions />
      <AbsoluteFill style={{ background: 'radial-gradient(120% 80% at 50% 45%, transparent 62%, rgba(20,8,0,.42) 100%)', pointerEvents: 'none' }} />
    </AbsoluteFill>
  );
};
```

## 8. Voice (optional)

A voice is worth adding when the world has a speaker of its own, because the deadpan delivery is half the joke. Skip it for a sound-off post, or when the user would rather not have one: the film still works.

**The persona:** a tired hard-boiled detective, low and close to the mic, who treats a missing sock as the case that ruined their life. Dry, unhurried in tone, and never in on the joke.

**When to use it:** on every line except the silence. Noir voices read slowly, so speed the takes up until a short line lands in 2 to 3 s; ours ran about 30 % faster than the voice's normal speed. The one line to give room is the insight, "They went missing, on the way to it", where a comma buys the pause. Add a small office room to it with a very short echo and a little warmth around 160 Hz, and compress before the room.

- **Never clone or imitate a real person.** Audition 3 or 4 voices on two lines of the script and let the user pick, or use the user's own voice.
- **One take per line,** with word timestamps. Pass the neighboring lines as context if your provider allows it, so the delivery flows. Trim each take to its speech, and tighten any pause inside it longer than 0.5 s.
- **Listen for your call to action.** Keywords blur easily. Commas around one in the spoken text ("Comment, gate, and...") usually fix it. Check the final mix with Whisper, line by line.
- **With no voice.** Keep the estimated timings as the clock. If there's music, put the captions on its grid. Raise the effects and the music: they carry the film now. Make sure the words stay on screen long enough to read, about 0.3 s a word. The estimate runs close to that, so add `tail` after a line that feels rushed.

## 9. Sound

The picture tells the story without sound, so a film can ship silent. Sound is what makes each change land, though, so add it unless the user says not to. This section says what to use and when it plays. Add it after the user approves the animatic.

**Effects: make a set for this world.** Generate them with the sound-effects tool the user chose, or pick from a licensed library. Prompt the thing itself, and keep it warm, never bright or harsh.

| Sound | Length | Prompt idea | When it plays |
|-------|--------|-------------|---------------|
| pin | 0.6 s | a single pushpin pressed hard into a cork board, a short dull wooden thunk, close and dry, no ring | every item that lands on the board; a run of them on the odd socks |
| typewriter | 1.6 s | a short burst of an old manual typewriter, quick key clacks, close, warm, dry, no bell | typed statements, and very quietly under the captions |
| string | 1.2 s | a thick taut cotton string pulled tight and plucked once, a low woody twang, short decay | whenever string draws itself; the biggest one on the insight |
| marker | 1.2 s | a felt tip marker drawn quickly on paper, soft squeaky strokes | handwriting, the circle on the old clue |
| paper tear | 1.2 s | a photograph ripped off a cork board, a fast paper tear with a small pin pop | the alibi |
| paper slap | 0.6 s | a newspaper clipping slapped flat onto a cork board, soft and papery | the clipping at the dead end |
| lamp click | 0.5 s | a desk lamp's metal pull chain switch clicked once, small and dry | the first frame, when the lamp dies, and when it comes back |
| lamp buzz | 1.5 s | an old desk lamp bulb sputtering, a soft electric buzz cutting in and out, not harsh | as the lamp dies |
| clock tick | 0.5 s | one tick of an old mechanical wall clock, a small dry wooden click | once a second through the low point, loudest in the middle |
| photo flip | 0.7 s | a stiff photograph flicked over in the air, a quick soft flutter | the reveal |
| cat purr | 5 s | a cat purring, slow and contented, very close, warm, no meow | the silence, 2.5 s |
| stamp | 1.5 s | a heavy rubber stamp slammed on a wooden desk, a deep solid thud, small objects rattle, warm and dark | CASE CLOSED, the loudest thing in the film |
| rain | 10 s | steady rain against an old office window at night from inside, soft, low and warm, no thunder | a low bed throughout, rising while the lamp is out |
| room tone | 10 s | a quiet old office at night, a faint radiator hum, a distant city, no voices, no music | the bed, all the way through |

- **Place a sound on every visible change,** at the same event the picture uses, so a re-voice moves both.
- **Beds run under everything:** a world without room tone feels empty, and room tone is what keeps the low point alive.
- **The same sound twice within 80 ms:** keep the louder one.
- **Warm, never bright:** low-pass effects to about 1.5-3.5 kHz if they bite.

**Music: one instrumental bed.** Describe the sound, never an artist. Generators ignore requested timestamps, so fit the track instead: find its real downbeats from bar-to-bar energy jumps, pick the bar where it lifts, place that bar on the story's turn, and stretch (keeping the pitch, within about 4 %) so a second downbeat lands on the voiceless moment.

**The brief:** "Smoky noir jazz instrumental: brushed drums, a slow walking upright bass, a muted trumpet low in the mix, sparse and tired for a long stretch, then a patient build with a piano figure that opens up warmly, ending resolved, no vocals."

**When it plays:** under the first lines at a low level. It drains to nothing over 0.7 s on "leads", stays out while the lamp is off so only rain and the clock remain, and comes back low on the insight. It climbs up the trail, is at its fullest on the voiceless silence, on a bar where the track opens up, and ducks right after the stamp so the last lines sit clear.

**Mix, voice first.**

- **Voice:** compress lightly so consonants stay off the limiter, about -14 LUFS.
- **Music:** about 16 to 20 LU under the voice while it speaks, lifted in gaps and on the voiceless moment.
- **Effects:** 7 to 10 LU under the voice overall, ducked about 6 dB under speech, so nothing masks a word.
- **Master:** -14 LUFS integrated, sample-peak ceiling about -2.4 dBFS. AAC encoding adds about 0.8 dB, so check the true peak of the final file. Give the master a 10 ms fade-in, or every view opens on a click.

## 10. Render and QA

1. **Render** without audio. Mux your mix onto it, trimmed to the exact length (`-t`, never `-shortest`), as H.264 at high quality with AAC 320k and `+faststart`.
2. **Sync and length:** the frame count matches the clock, and a cross-correlation of the muxed audio against your mix shows an offset of 0 ms.
3. **Contact sheets:** one frame per second with the safe box drawn. Look at every sheet: nothing cut off, nothing important outside the box, no empty or black frames, and the time of day and the story still agree.
4. **Determinism:** render about 10 spread frames twice with `npx remotion still src/index.ts Film-9x16 out/a.png --frame=N`, and compare each pair with `cmp`; they should match.
5. **Loudness:** -14 LUFS integrated, true peak at or below -1 dBTP.
6. **Words:** transcribe the mix line by line with Whisper. A difference on a plain word is a masked or swallowed word.
7. **Gaps and stills:** no voice gap over 1.0 s outside the voiceless moment (`clock.mjs` warns about these), and no still stretch over 1.2 s or black stretch (`ffmpeg -i out/silent.mp4 -vf "freezedetect=n=0.001:d=1.2,blackdetect=d=0.1" -an -f null -` prints any). A small moving corner can hide a freeze from that check, so trust the contact sheets over it.

For a silent film, skip the checks that need audio (the offset in 2, and 5 and 6) and use `out/silent.mp4` as the final file.

## 11. Traps

- **A full board too early:** plan the pins on paper first. Ours needs room for nine socks and three notes late in the film, on top of everything already there.
- **String off its pin:** only tie string to the `PIN` list, and rotate items around their pins, or a shake pulls the string loose.
- **The wall showing:** a hand-made pan shows the wall behind the board. Use `zoom()`, and check the first frame of every camera move.
- **A black low point:** the lamp goes out, but the blinds and the rain keep light on the board, and the pool of light lands on a clue. It's never black.
- **Typed captions look cut off on a contact sheet:** that's letters arriving. Watch the video to check that each chunk finishes well before the next starts.
- **Too many words on the clues:** with handwriting over busy paper, a short phrase is all anyone can read. Keep it to 2 or 3 words.

| Symptom | Fix |
|---------|-----|
| A few random frames show text at the wrong size | moving text as HTML, not SVG `<text>`, under per-frame transforms |
| A shape keeps a style from an earlier frame | a negative CSS length was rejected: clamp at 0 |
| A render dies on a font timeout | take `delayRender` inside React, as `core.tsx` does, never at module level |
| Sound drifts against the picture | mux your own mix with `-t` and check the offset by cross-correlation |
| The music's lift lands late | fit by real downbeats; do not trust requested timestamps |
| Two renders of a frame differ slightly along a panel's edge | a large blurred `box-shadow` isn't exactly repeatable: use a shadow with no blur on big panels |
| A caption breaks after "a.m." | The caption chunker (`buildChunks`, or the frame's own if it has one) ends a chunk at any word ending in a full stop: write "am" in `text`, or break the chunks yourself |

## 12. Deliver

The MP4, a cover frame and a ready-to-paste caption. For organic posts the caption repeats the comment keyword and carries no link.
Show less

The prompt

PROMPT.md · 33 lines · 2 KB
Copy Raw file
# The prompt

First give your agent the case-file skill (paste the skill text and ask it to save it as a skill). Then replace everything in [BRACKETS] below and paste it in.

**Tip:** use any AI tools you like for the voice, music and sound effects; the skill works the same with all of them, or with none (every scene is code, and the film works with no voice). If you'd rather not set up separate AI accounts and API keys, HYVIRAL (optional) is one way to get them behind one connection (hyviral.com; in Claude.ai → Customize → Connectors → add `https://api.hyviral.com/mcp`).

---

Use the case-file skill to make a vertical video for [PLATFORM: Instagram Reels / TikTok / YouTube Shorts].

- **The case:** [TOPIC - the small mystery or problem, e.g. "where my socks keep going", "why sign-ups drop every Friday"]
- **The first suspect:** [SUSPECT - the thing everyone blames, never a real person or company, and why it's innocent, or "you write it"]
- **The real answer:** [CULPRIT - the thing, habit or process that really did it, never a real person or company, or "you write it"]
- **The clue that was there all along:** [CLUE - the early detail that cracks it, or "you find it"]
- **In the window:** [WINDOW - "no person: photographs drawn in code", or "my own clip or photos, attached"]
- **Length:** [LENGTH - e.g. about 35 seconds]
- **Voice:** [VOICE - e.g. "a tired noir detective", "my own voice", or "no voice: captions and sound only"]
- **Music:** [MUSIC - e.g. "the skill's noir jazz brief", or your own idea]
- **Ending:** [CTA - organic: "Comment KEYWORD and I'll send you THE THING", with the keyword and what you'll send; or sponsored: the line and the URL]
- **AI tools:** [AI TOOLS - e.g. "whatever you already have set up", "HYVIRAL", or "none: animation only, I'll record the voice myself"]
- **Avoid:** [AVOID - e.g. no claims I can't back up. The skill already rules out real people and companies as culprits]

Work in this order:

1. Write the script on the skill's moves, with one line for each turn and what gets pinned to the board.
2. Build the plan, the clock and the frame, render a silent animatic, and show me the script, the scene sheet and the animatic. Wait for my OK.
3. Generate the voice, music and sound effects with the AI tools I named above. If I left that open, tell me which tools you'll use and wait for my OK.
4. Place a sound on every change, fit the music to the turn, mix voice first, render, and run every QA gate in the skill.
5. Give me the MP4, a cover frame and a ready-to-paste caption.
Show less

Cookies for ads and referrals

We use the Meta Pixel to measure our ads and Tolt to credit partners who refer you. Accept to allow them; decline and the site works the same. Privacy Policy

Decline Accept