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.

Recipe Card

A recipe card on a kitchen table fills the screen, and your topic is the dish. Ingredients tick off, a tomato timer runs, and a taped photo of a window follows the light. Then the one thing the card warned about happens: the table goes gray and a coffee ring stains the card. The cook just says add water. It's plainly a metaphor, 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 recipe for What goes wrong The cook's fix Platform Narrator Ending AI tools
    Use the recipe-card skill to make a vertical video for Instagram Reels.
    
    - **The recipe for:** [TOPIC - the small human thing that's the dish, e.g. "a calm morning", "a good first date", "a Sunday with no plans"]
    - **The ingredients:** [INGREDIENTS - three silly amounts, one of them a "no", e.g. "one window, one mug, no phone", or "you write them"]
    - **What goes wrong:** [MISTAKE - the one thing the card warned about, e.g. "you checked your phone"]
    - **The cook's fix:** [FIX - a kitchen rule that's also true of the topic, e.g. "too salty? add water", or "you find it"]
    - **In the window:** [WINDOW - "no person: a kitchen drawn in code", or "my own clip or photo, attached"]
    - **Length:** [LENGTH - e.g. about 35 seconds]
    - **Voice:** [VOICE - e.g. "a warm home cook", "my own voice", or "no voice: captions and sound only"]
    - **Music:** [MUSIC - e.g. "the skill's warm kitchen 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 health claims, no diets or routines presented as advice, no real recipes or real people]
    
    This is plainly a metaphor. It makes no health claims, and nothing in it should read as advice someone could follow.
    
    Work in this order:
    
    1. Write the script on the skill's moves, with one line for each turn and what happens on the table. Start from the cook's fix, then write backward to the warning.
    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 table 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 · 1484 lines · 89 KB
Copy Raw file
---
name: recipe-card
description: Make a 30-40 second vertical video where a handwritten recipe card on a kitchen table tells the story - ingredients tick off, a tomato timer runs, a taped photo of a window follows the light, and when it all goes wrong a warm home cook just says add water. The skill covers the kitchen sound design, music fitted to the story and an optional voice like a warm home cook. A plain metaphor only, with no health claims, diets or real instructions. Use for 'my recipe for' stories, small human things that take a second try, a good first date, a Sunday, a first week at a job, or when asked for a recipe, cooking or kitchen table video, or when given the recipe-card prompt.
---

# Recipe Card

A recipe card on a gingham tablecloth fills the screen, and your topic is the dish. Ours is a calm morning. The ingredients tick off, a tomato timer runs, and a taped photo of the kitchen window follows the light. Then the one thing the card warned about happens: the table goes gray, the timer stops, and a coffee ring stains the card. The cook isn't surprised. Too salty? Add water. It's plainly a metaphor, told like an old family secret.

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/recipe-card/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/caveat` and `@fontsource/archivo-narrow`. 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/RecipeCard.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: ingredients tick off, a pencil underlines the warning, the tomato timer runs and stops, a coffee ring stains the card, and the sun crosses the table. 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 kitchen table seen from above, early in the morning, with one card on it.

- **Palette:** gingham red `#d94040` over tablecloth cream `#fbf3ea`, card `#fff8ec`, tomato `#e0412f`, basil `#3f8f4f`, pencil `#3d3d3d`, coffee `#5f3a22`, masking tape yellow. The phone is the only cold color in the film, so keep blue off everything else.
- **Type:** Caveat 700 for anything written by hand, including the captions. Archivo Narrow (600 and 700) only for what's printed on the card: Recipe, Serves, INGREDIENTS, METHOD.
- **Fixed parts, top to bottom:** a taped photo of the kitchen (y 104-688) on the left, the tomato timer (y 96-476) on the right, a free spot for the mug (y 480-690) under the timer, then the card (y 700-1580): red band with the recipe's name (y 700-800), three small fields (y 804-860), the ingredients (y 862-1290), and two ruled method lines where the captions get written (y 1324-1472). Below the card a wooden spoon and a salt shaker are texture, under the platform's buttons.
- **The tick rule:** every row starts in faint old ink. On its word the row inks in and a green tick lands. A row can also arrive late, written in as it's spoken.
- **The pencil rule:** a stub of pencil goes to whatever is being written next, and it goes back to its resting place when nothing is, so it never sits on the list.
- **Captions are the method:** the words are handwritten onto the ruled lines, one at a time, with the word being spoken in tomato red. The card doesn't rotate, so the writing stays on its lines.
- **The camera stays on the method lines:** it only pushes around y = 1400 (`CAM_Y`), so the captions never move. Keep it near 1.05x, and never past about 1.09x, or the list leaves the safe area.
- **Small jokes live on the paper:** "the good window", a strip of tape over the kettle reading "don't look", "Ready: when it's ready", "Family recipe. Do not laminate."

**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 photograph taped above the card, and it's a real window: ours shows the kitchen in the early morning. Drawn in code, it follows the story:

| Story moment | In the photo |
|---|---|
| Opening | Curtains closed, the room dim, a kettle on the counter |
| "one window" | The curtains pull open on a dark blue sky, and faint light lands on the table |
| The kettle | The burner glows. On "Don't watch it" a strip of tape lands across the kettle: don't look |
| The fold | Steam rises past the tape, and the sky warms a little with every line |
| The low point | The phone lands on top of the photo and covers most of the window. Everything left goes gray. The tap drips and the kitchen clock keeps going |
| The fix | The phone is gone, and the color comes back on "water" |
| The voiceless moment | The sun climbs over the hills, the hills turn green, and the wall turns warm |

The kitchen clock and the phone read the same time, from `clockMinutes`. It runs a little too fast on purpose: about 6:08 at the start, 6:35 on the low point, 7:00 at the end.

With the user's own clip, play it inside the photo's 600 x 476 box in place of `KitchenPhoto`, cropped to the print, with the white border, the note and the tape on top. Your clip sits inside the layer that goes gray, so it goes gray with the rest. A photo of the user's own window or desk works just as well.

## 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`.

**This frame is plainly a metaphor, and it makes no health claims.** The recipe is a way of talking, and nobody should be able to follow it as advice. "Calm" here is the name of a dish, never a promised result. Don't say or imply that anything improves sleep, stress, anxiety, focus, weight, mood or any other part of a person. No diets, doses, fasting, supplements, breathing plans or real medical or wellness routines, and no real recipe with real amounts. If a user asks for one, keep the words small and human, or suggest another frame. Keep the amounts silly on purpose ("1 window"), so it can't be mistaken for instructions.

**The moves.** Map any topic onto these. The film treats a small human thing as an old family recipe. Ours is a calm morning.

| # | Move | What it does | Our line | On the table |
|---|------|--------------|----------|--------------|
| 1 | The card | The hook: an ordinary thing, passed down like a secret | "This one's an old family recipe. A calm morning." | The card slides in, the photo lands and gets taped, Gran signs it, the pencil writes the title |
| 2 | Ingredients | The world's rules, with one that's a warning | "You'll need one window, one mug, and no phone." | The curtains open, a mug lands, a phone slides in and gets thrown back out, three ticks |
| 3 | The pinch | The first real instruction, small and funny | "First, a pinch of getting up before you have to." | A pinch of saffron falls down the card and melts into its line, which ticks |
| 4 | The kettle | Patience, in the world's own terms | "Let the kettle come up slowly. Don't watch it." | The burner glows, the tomato timer winds to 10, and tape lands over the kettle: don't look |
| 5 | The fold | The dish comes together, and it's nothing | "Now fold in ten minutes of nothing." | The mug fills and swirls, the timer runs, steam starts, the row ticks on "nothing" |
| 6 | The warning | Sets up the fall, in plain sight | "Keep your phone in the other room. This is important." | "(other room!!)" gets written, and the pencil underlines "no phone" twice |
| 7 | Low point | The one thing the card warned about | "Oh. You checked your phone." | The phone lands on the window, buzzing. A red X, a line through the row. The table goes gray, the timer stops dead, the mug lifts, and a coffee ring is left on the card |
| 8 | The fix | The turn: a shrug, not a lesson | "That's fine. Too salty? Add water." | The phone is gone. The note gets written inside the ring. The color comes back on "water" |
| 9 | Again | Forgiveness as a kitchen fact | "Nobody's morning comes out right the first time." | The row gets its tick back, the X and the line fade, and the timer winds to 10 again |
| 10 | The quiet | The voiceless moment, about 3 s | (no voice, the caption reads SERVES ONE) | A full mug lands and knocks the table, steam rises, four panes of sun cross the card, the timer runs out and rings: Ding! |
| 11 | The taste | The payoff, as recipe small print | "Serves one. Keeps all day. Goes well with Wednesday." | Serves and Keeps fill in, the last line gets written and it ticks |
| 12 | Call to action | One action | "Comment recipe and I'll send you the skill that made this." | The pencil writes COMMENT RECIPE in the margin, underlines it twice, and it stays |

**For other topics:** a good first date, a Sunday with no plans, a first week at a new job, a friendship kept alive by mail, a first stand-up set, a shop's first slow month. Each has silly ingredients, one of which is a "no", and a warning that gets broken.

**The trick that makes it land is the shrug.** The viewer watches the warning get underlined twice and knows what's coming. When it happens, the cook isn't angry or disappointed, and offers the kind of fix every cook knows. Find that line first: a real kitchen rule that's also true of the topic. "Too salty? Add water." "Burned the bottom? Don't scrape it." "Sauce too thin? Give it time." Write the film backward from it, and if the line would be just as true on a poster, keep looking.

Our film runs 38.6 s on the clock's estimate. For about 35 s, fold moves 4 and 5 into one line and cut "This is important", which leaves the underlining to say it.

## 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 table, and `core.tsx` and `clock.mjs` don't change. Read events by name and you won't touch a scene id: **there are none in the frame**, including the voiceless caption, which hangs on the `quiet` event.

| To change | Where |
|---|---|
| The words on the card | The constants at the top of the file: `KEYWORD`, `TITLE`, `KITCHEN`, `COOK`, `PHOTO_NOTE`, `QUIET_CAPTION`, `DING`, `WARNING_NOTE`, `MARGIN_NOTE`, `KETTLE_TAPE`, `META` |
| The ingredients | `LIST`: each row's text and the event that ticks it. A row with `late` isn't on the card until that event. `PHONE_ROW` is the row that goes wrong |
| The thing that goes wrong | `Phone`, `phoneK` and `PhoneGlow`, with `PHONE_AT` and `PHONE_PINGS`, and `grayAt` for when the color drains and returns. A new villain usually replaces `Phone` and keeps the rest |
| The window | `KitchenPhoto` draws the kitchen in SVG, and `Photo` frames it. Redrawing this is the biggest job. `dayAt`, `openAt` and `sunAt` drive its light |
| The timer | `minutesAt` is the whole story of the dial. `Timer` draws it, `NUMERALS` label it, and `DingWord` is the ring. Swap the tomato for an egg in `Timer` if you like |
| The mug and the stain | `Mugs` and `MugG`, `Wisps` for the steam, `Stain` for the ring, and `RING` and `MUG_TABLE` for where they sit |
| The pinch | `Saffron`, `THREADS` and `PINCH` |
| The notes | `PhoneRowNotes` (the note and the two underlines), `MarginNote`, `CtaNote` |
| The pencil | `PEN`, in time order: each row says when, where the tip goes and how far it writes, and `tick(n)` names a `LIST` row by number. `REST` is its resting place. Move these whenever a note or a row moves |
| Camera and knocks | `KEYS` and `zoom()` for the camera, `HITS` for which events knock the table |
| Light on the table | `SunPatch` and `Light`. The film's morning is `dayAt` and `sunAt` above |
| The table itself | `Cloth`, `Under`, `CardPaper`, `Band` |
| The comment keyword | `KEYWORD` at the top, and the `text` and `say` of your last line in `plan.json`. `CtaNote` sizes itself from it, and `Captions` lights it up during the ending |

- **Paper is sized to our words.** A row holds about 21 letters before the margin line (`a pinch of getting up`). The card holds 7 rows, set by `LIST_Y` and `STEP`. `TITLE` holds about 16 letters at 92 px before it reaches `KITCHEN`. `WARNING_NOTE` sits to the right of the phone row and holds about 14 letters. `MARGIN_NOTE` holds about 10 letters a line, to stay in the ring. `PHONE_PINGS` hold about 15 letters. Check `META`'s `x` values if a label grows. `PER_ROW` is what breaks the captions, and at 78 px about 28 letters fit a line.
- **Two things follow the words by hand.** The underlines and the crossing-out in `PhoneRowNotes` are drawn to the width of "no phone", and each `PEN` row's `dx` is the width of what it writes. Change them when the words change.
- **Delete an event along with what uses it.** A `story.json` event that no longer matches a word stops the render, which is what you want.

### The code

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

```json
{
 "lines": [
  {
   "id": "l01",
   "text": "This one's an old family recipe. A calm morning."
  },
  {
   "id": "l02",
   "text": "You'll need one window, one mug, and no phone."
  },
  {
   "id": "l03",
   "text": "First, a pinch of getting up before you have to."
  },
  {
   "id": "l04",
   "text": "Let the kettle come up slowly. Don't watch it."
  },
  {
   "id": "l05",
   "text": "Now fold in ten minutes of nothing."
  },
  {
   "id": "l06",
   "text": "Keep your phone in the other room. This is important."
  },
  {
   "id": "l07",
   "text": "Oh. You checked your phone."
  },
  {
   "id": "l08",
   "text": "That's fine. Too salty? Add water."
  },
  {
   "id": "l09",
   "text": "Nobody's morning comes out right the first time."
  },
  {
   "id": "l10",
   "text": "Serves one. Keeps all day. Goes well with Wednesday."
  },
  {
   "id": "l11",
   "text": "Comment RECIPE and I'll send you the skill that made this.",
   "say": "Comment, recipe, and I'll send you the skill that made this."
  }
 ],
 "scenes": [
  {
   "id": "s01",
   "lines": [
    "l01"
   ],
   "lead": 0.3,
   "tail": 0.15
  },
  {
   "id": "s02",
   "lines": [
    "l02"
   ],
   "lead": 0.1,
   "tail": 0.25
  },
  {
   "id": "s03",
   "lines": [
    "l03"
   ],
   "lead": 0.1,
   "tail": 0.15
  },
  {
   "id": "s04",
   "lines": [
    "l04"
   ],
   "lead": 0.1,
   "tail": 0.15
  },
  {
   "id": "s05",
   "lines": [
    "l05"
   ],
   "lead": 0.1,
   "tail": 0.2
  },
  {
   "id": "s06",
   "lines": [
    "l06"
   ],
   "lead": 0.1,
   "tail": 0.1
  },
  {
   "id": "s07",
   "lines": [
    "l07"
   ],
   "lead": 0.6,
   "tail": 0.25
  },
  {
   "id": "s08",
   "lines": [
    "l08"
   ],
   "lead": 0.35,
   "tail": 0.15
  },
  {
   "id": "s09",
   "lines": [
    "l09"
   ],
   "lead": 0.1,
   "tail": 0.1
  },
  {
   "id": "s10",
   "sec": 3.0
  },
  {
   "id": "s11",
   "lines": [
    "l10"
   ],
   "lead": 0.05,
   "tail": 0.15
  },
  {
   "id": "s12",
   "lines": [
    "l11"
   ],
   "lead": 0.1,
   "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 { RecipeCard } from './frames/RecipeCard';
import spec from './spec.json';

export const Root: React.FC = () => (
  <Composition id="Film-9x16" component={RecipeCard} 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": "photo", "scene": "s01", "word": "one's", "off": -0.05 },
  { "id": "family", "scene": "s01", "word": "family" },
  { "id": "calm", "scene": "s01", "word": "calm" },

  { "id": "window", "scene": "s02", "word": "window" },
  { "id": "mug", "scene": "s02", "word": "mug" },
  { "id": "nophone", "scene": "s02", "word": "no" },
  { "id": "phonegone", "scene": "s02", "word": "phone", "off": 0.34 },

  { "id": "pinch", "scene": "s03", "word": "pinch" },
  { "id": "before", "scene": "s03", "word": "before" },

  { "id": "kettle", "scene": "s04", "word": "kettle" },
  { "id": "wind", "scene": "s04", "word": "up" },
  { "id": "dont", "scene": "s04", "word": "Don't" },

  { "id": "fold", "scene": "s05", "word": "fold" },
  { "id": "nothing", "scene": "s05", "word": "nothing" },

  { "id": "room", "scene": "s06", "word": "room" },
  { "id": "important", "scene": "s06", "word": "important" },

  { "id": "lowlead", "scene": "s07" },
  { "id": "oh", "scene": "s07", "word": "Oh" },
  { "id": "checked", "scene": "s07", "word": "checked" },
  { "id": "ring", "scene": "s07", "word": "phone", "off": -0.05 },

  { "id": "fine", "scene": "s08", "word": "fine" },
  { "id": "salty", "scene": "s08", "word": "Too" },
  { "id": "water", "scene": "s08", "word": "water" },

  { "id": "again", "scene": "s09", "word": "morning" },
  { "id": "first", "scene": "s09", "word": "first" },

  { "id": "quiet", "scene": "s10" },
  { "id": "ding", "scene": "s10", "off": 2.2 },

  { "id": "serves", "scene": "s11", "word": "Serves" },
  { "id": "keeps", "scene": "s11", "word": "Keeps" },
  { "id": "goes", "scene": "s11", "word": "Goes" },
  { "id": "wed", "scene": "s11", "word": "Wednesday" },
  { "id": "lasttick", "scene": "s11", "word": "Wednesday", "off": 0.4 },

  { "id": "cta", "scene": "s12" }
 ]
}
```

`src/frames/RecipeCard.tsx`:

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

/* Recipe Card: a kitchen table seen from above. A handwritten card ticks itself off, a tomato timer runs, a taped photo shows the
   window. 1080 x 1920. Time comes from the spoken words through story.json, so a re-voice moves every tick with its word. */
const E = makeE(story as StoryJson);

/* ---------- the story's strings: change these first ---------- */
const KEYWORD = 'RECIPE';
const TITLE = 'A calm morning';
const KITCHEN = 'from the kitchen of';
const COOK = 'Gran';
const PHOTO_NOTE = 'the good window';
const QUIET_CAPTION = 'SERVES ONE'; // the caption on the voiceless moment
const DING = 'Ding!';
const WARNING_NOTE = '(other room!!)';
const MARGIN_NOTE = ['too salty?', 'add water'];
const KETTLE_TAPE = "don't look";
const META = [
  { label: 'Serves:', value: 'one', ev: 'serves', x: 78 },
  { label: 'Keeps:', value: 'all day', ev: 'keeps', x: 306 },
  { label: 'Ready:', value: "when it's ready", ev: '', x: 590 },
];
/* One row per ingredient: `ev` is the event that ticks it. A `late` row isn't on the card until `late`. PHONE_ROW is the row that goes wrong. */
const LIST: { text: string; ev: string; late?: string }[] = [
  { text: '1 window', ev: 'window' },
  { text: '1 mug', ev: 'mug' },
  { text: 'no phone', ev: 'phonegone' },
  { text: 'a pinch of getting up', ev: 'before' },
  { text: '1 kettle, slowly', ev: 'wind' },
  { text: '10 minutes of nothing', ev: 'nothing' },
  { text: 'goes with Wednesday', ev: 'lasttick', late: 'goes' },
];
const PHONE_ROW = 2;
const PHONE_PINGS = ['83 unread', 'quick one?', 'Re: Re: Re:', 'you were tagged'];

const GINGHAM = '#d94040', CLOTH = '#fbf3ea', CARD = '#fff8ec', TOMATO = '#e0412f', BASIL = '#3f8f4f', PENCIL = '#3d3d3d', COFFEE = '#5f3a22', LABEL = '#8a6d5a', TAPE = 'rgba(255,209,102,.78)';
const HAND = 'Caveat, cursive', PRINT = '"Archivo Narrow", sans-serif';
const FACES = [
  { family: 'Caveat', file: 'caveat-latin-700-normal.woff2', weight: '700' },
  { family: 'Archivo Narrow', file: 'archivo-narrow-latin-600-normal.woff2', weight: '600' },
  { family: 'Archivo Narrow', file: 'archivo-narrow-latin-700-normal.woff2', weight: '700' },
];

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 things sit. The list and the method lines are rows on the card; the ring and the margin note share the card's right column. */
const LIST_Y = 900, STEP = 56, BOX_X = 78, TEXT_X = 130;
const RING: P = [790, 985];
const MUG_TABLE: P = [850, 590];
const PHONE_AT: P = [270, 402];
const CAP_X = 84, CAP_Y = [1324, 1398], CAP_LINE = 74, CAP_FONT = 78, PER_ROW = 28;
const rowY = (i: number) => LIST_Y + i * STEP;
const slideAt = (t: number) => lerp(560, 0, easeOut(ramp(t, 0, 0.8))); // the card slides in on frame 0

/* ---------- the day: every light in the film reads these ---------- */

const openAt = (t: number) => easeInOut(ramp(t, E('window') - 0.3, E('window') + 0.3));
const sunAt = (t: number) => easeInOut(ramp(t, E('quiet') - 0.1, E('ding') + 0.5));
const dayAt = (t: number) => lerp(lerp(0.06, 0.55, ramp(t, E('window'), E('important'))), 1, sunAt(t));
const clockMinutes = (t: number) => 5 + 55 * dayAt(t); // past six: the kitchen clock and the phone read the same time
const grayAt = (t: number) => ramp(t, E('checked') - 0.1, E('checked') + 0.45) * (1 - ramp(t, E('water'), E('water') + 1.2));

/** The timer's minutes: wound to 10, runs, stops dead on the low point, wound again, then runs out on the voiceless moment. */
const minutesAt = (t: number) => {
  const first = 10 * easeInOut(ramp(t, E('wind'), E('wind') + 0.8)) - 2.4 * ramp(t, E('fold'), E('checked'));
  const second = lerp(first, 10, easeInOut(ramp(t, E('again'), E('again') + 0.75)));
  return lerp(second, 0, ramp(t, E('quiet') + 0.05, E('ding')));
};

/* Everything hits the same table: a mug knocks it a little, the timer buzzes it. */
const HITS: [number, number][] = ([['photo', 4], ['mug', 6], ['oh', 3], ['fine', 2], ['ding', 4]] as [string, number][]).map(([e, a]) => [E(e), a] as [number, number]).concat([[E('quiet') + 0.14, 9]]);
const shake = (t: number) => HITS.reduce((acc, [e, a]) => (t >= e ? acc + a * Math.sin((t - e) * 52) * Math.exp(-(t - e) * 12) : acc), 0);

/* The camera always pushes around the method lines, so the captions keep their place and the table's edges stay off screen. */
type View = { s: number; tx: number; ty: number };
const CAM_Y = 1400;
const zoom = (s: number, fx: number): View => ({ s, tx: fx * (1 - s), ty: CAM_Y * (1 - s) });
const KEYS: { at: number; d: number; v: View }[] = [
  { at: E('on'), d: 0.01, v: zoom(1.012, 540) },
  { at: E('photo'), d: 3.2, v: zoom(1.035, 300) },
  { at: E('pinch') - 0.2, d: 2.4, v: zoom(1.05, 160) },
  { at: E('lowlead'), d: 1.3, v: zoom(1.085, 240) },
  { at: E('water'), d: 1.6, v: zoom(1.025, 540) },
  { at: E('quiet'), d: 2.9, v: zoom(1.05, 300) },
  { at: E('serves'), d: 1.1, v: zoom(1.015, 300) },
];
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 j = shake(t);
  return { s: v.s, tx: Math.min(0, v.tx + 3 * Math.sin(t * 0.41)) + j, ty: Math.min(0, v.ty + 2 * Math.sin(t * 0.29)) + j * 0.5 };
};

/* ---------- small paper things ---------- */

/** Handwriting wipes on from the left. Once it ends the clip opens past its box, or a letter's overhang stays cut off. */
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;
  const k = ramp(t, at, at + d);
  return <div style={{ position: 'absolute', whiteSpace: 'nowrap', clipPath: `inset(-25% ${k >= 1 ? '-20%' : `${(1 - k) * 100}%`} -25% -8%)`, ...style }}>{children}</div>;
};

const Stroke: React.FC<{ d: string; at: number; dur: number; color?: string; width: number; opacity?: number }> = ({ d, at, dur, color = TOMATO, width, opacity = 1 }) => {
  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} />;
};

const Tape: React.FC<{ at: number; x: number; y: number; rot: number }> = ({ at, x, y, rot }) => {
  const t = useT();
  if (t < at) return null;
  const s = lerp(1.4, 1, easeOut(ramp(t, at, at + 0.1)));
  return <div style={{ position: 'absolute', left: x, top: y, width: 188, height: 54, background: TAPE, boxShadow: '0 2px 4px rgba(0,0,0,.1)', transform: `rotate(${rot}deg) scale(${s})` }} />;
};

/* ---------- the table ---------- */

const CREASES = ['M-20 356 L1100 332', 'M430 -20 L470 1940', 'M-20 1660 L1100 1690'];
const Cloth: React.FC = () => (
  <>
    <div style={{ position: 'absolute', left: -60, top: -60, width: 1200, height: 2040, backgroundColor: CLOTH, backgroundImage: `linear-gradient(90deg,rgba(217,64,64,.52) 50%,transparent 50%),linear-gradient(rgba(217,64,64,.52) 50%,transparent 50%)`, backgroundSize: '96px 96px' }} />
    <div style={{ position: 'absolute', left: -60, top: -60, width: 1200, height: 2040, backgroundImage: 'repeating-linear-gradient(0deg,rgba(90,30,20,.05) 0 2px,transparent 2px 6px),repeating-linear-gradient(90deg,rgba(90,30,20,.05) 0 2px,transparent 2px 6px)' }} />
    <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible' }}>
      {/* where the cloth was folded, and last week's crumbs */}
      {CREASES.map((d, i) => <g key={i}><path d={d} stroke="rgba(120,40,30,.13)" strokeWidth="9" fill="none" /><path d={d} transform="translate(0 6)" stroke="rgba(255,250,240,.2)" strokeWidth="5" fill="none" /></g>)}
      {Array.from({ length: 26 }, (_, i) => <circle key={i} cx={hash(i + 4) * 1080} cy={hash(i + 40) * 690} r={1.6 + hash(i + 80) * 2.6} fill="#c79a63" opacity={0.75} />)}
    </svg>
  </>
);

/** Below the safe area the platform's buttons cover everything, so only kitchen things live there. */
const Under: React.FC = () => (
  <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0 }}>
    <g transform="translate(236 1790) rotate(-68)">
      <rect x="-12" y="-16" width="330" height="32" rx="14" transform="translate(5 9)" fill="rgba(70,15,10,.28)" />
      <rect x="0" y="-13" width="330" height="26" rx="13" fill="#c98f55" />
      <ellipse cx="-58" cy="0" rx="76" ry="54" transform="translate(5 9)" fill="rgba(70,15,10,.28)" />
      <ellipse cx="-58" cy="0" rx="76" ry="54" fill="#c98f55" /><ellipse cx="-58" cy="0" rx="56" ry="38" fill="#b17a43" />
    </g>
    <g transform="translate(760 1760)">
      <circle cx="7" cy="11" r="82" fill="rgba(70,15,10,.28)" /><circle r="82" fill="#e9e6df" /><circle r="66" fill="#c7c4bb" />
      {Array.from({ length: 7 }, (_, i) => <circle key={i} cx={i ? Math.cos(i) * 36 : 0} cy={i ? Math.sin(i) * 36 : 0} r="6" fill="#55524c" />)}
    </g>
  </svg>
);

/* ---------- the window: a photograph of the kitchen, drawn in code. Its light follows the story. ---------- */

const KitchenPhoto: React.FC = () => {
  const t = useT();
  const day = dayAt(t), open = openAt(t), sun = sunAt(t);
  const top = interpolateColors(day, [0, 0.55, 1], ['#27305a', '#8ea6cf', '#f8d48f']);
  const low = interpolateColors(day, [0, 0.55, 1], ['#57507c', '#f4bd92', '#fff1c4']);
  const hill = interpolateColors(day, [0, 1], ['#2d3557', '#7ea35f']);
  const hill2 = interpolateColors(day, [0, 1], ['#3c4468', '#5c8a4c']);
  const wall = interpolateColors(day, [0, 1], ['#8f8780', '#f2e2c4']);
  const sunY = lerp(236, 84, sun);
  const burner = ramp(t, E('kettle') - 0.1, E('kettle') + 0.5) * (1 - grayAt(t) * 0.9);
  const steam = ramp(t, E('fold'), E('fold') + 1) * (1 - grayAt(t));
  const drop = ((t + 7) % 1.15) / 0.4;
  const minutes = clockMinutes(t);
  return (
    <svg viewBox="0 0 600 476" width={600} height={476} style={{ position: 'absolute', left: 0, top: 0, display: 'block' }}>
      <defs>
        <linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor={top} /><stop offset="1" stopColor={low} /></linearGradient>
        <radialGradient id="sunG" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stopColor="#fff6cf" /><stop offset="0.3" stopColor="#ffdf8a" stopOpacity="0.85" /><stop offset="1" stopColor="#ffd98f" stopOpacity="0" /></radialGradient>
        <pattern id="check" width="28" height="28" patternUnits="userSpaceOnUse"><rect width="28" height="28" fill="#f7e9dc" /><rect width="14" height="28" fill={GINGHAM} opacity="0.55" /><rect width="28" height="14" fill={GINGHAM} opacity="0.55" /></pattern>
        <clipPath id="pane"><rect x="104" y="24" width="352" height="256" /></clipPath>
      </defs>
      <rect width="600" height="476" fill={wall} />
      {/* the view */}
      <g clipPath="url(#pane)">
        <rect x="104" y="24" width="352" height="256" fill="url(#sky)" />
        <circle cx={330} cy={sunY} r={120} fill="url(#sunG)" opacity={0.15 + 0.85 * sun} />
        <ellipse cx={((t * 9) % 420) + 70} cy="74" rx="52" ry="13" fill="#fff" opacity={0.1 + 0.3 * day} />
        <path d="M60 240 C150 180 230 216 310 196 C380 178 450 200 500 188 L500 300 L60 300 Z" fill={hill2} />
        <path d="M60 268 C160 226 260 262 340 240 C400 226 470 244 500 232 L500 300 L60 300 Z" fill={hill} />
        <g fill={interpolateColors(day, [0, 1], ['#20263f', '#3b6d3f'])}><circle cx="178" cy="226" r="26" /><rect x="174" y="226" width="8" height="30" /><circle cx="404" cy="214" r="18" /><rect x="401" y="214" width="6" height="28" /></g>
      </g>
      {/* frame, mullions, sill */}
      <path d="M92 12 H468 V292 H92 Z M104 24 V280 H456 V24 Z" fill="#f5efe3" fillRule="evenodd" />
      <rect x="274" y="24" width="12" height="256" fill="#f5efe3" /><rect x="104" y="144" width="352" height="12" fill="#f5efe3" />
      <rect x="78" y="288" width="404" height="18" fill="#e7dfcf" />
      {/* basil on the sill */}
      <path d="M124 288 L168 288 L162 248 L130 248 Z" fill="#c26a3f" />
      {Array.from({ length: 9 }, (_, i) => <ellipse key={i} cx={146 + Math.cos(i * 1.3) * 26} cy={226 + Math.sin(i * 1.9) * 19 + Math.sin(t * 1.1 + i) * 1.4} rx="15" ry="10" fill={i % 2 ? BASIL : '#57a865'} transform={`rotate(${i * 40} ${146 + Math.cos(i * 1.3) * 26} ${226 + Math.sin(i * 1.9) * 19})`} />)}
      {/* curtains */}
      <rect x="84" y="4" width={lerp(196, 46, open)} height="290" fill="url(#check)" />
      <rect x={476 - lerp(196, 46, open)} y="4" width={lerp(196, 46, open)} height="290" fill="url(#check)" />
      <rect x="66" y="2" width="428" height="9" rx="4" fill="#8b6239" />
      {/* the clock, a little faster than the real one */}
      <g transform="translate(538 86)">
        <circle r="40" fill="#fbf6ea" stroke="#6b5136" strokeWidth="6" />
        {Array.from({ length: 12 }, (_, i) => <rect key={i} x="-1.5" y="-34" width="3" height={i % 3 ? 5 : 10} fill={PENCIL} transform={`rotate(${i * 30})`} />)}
        <rect x="-2.5" y="-20" width="5" height="22" rx="2" fill={PENCIL} transform={`rotate(${180 + minutes * 0.5})`} />
        <rect x="-1.8" y="-30" width="3.6" height="32" rx="2" fill={PENCIL} transform={`rotate(${minutes * 6})`} />
        <rect x="-0.8" y="-33" width="1.6" height="38" fill={TOMATO} transform={`rotate(${Math.floor(t) * 6})`} />
      </g>
      {/* counter, burner, tap */}
      <rect y="372" width="600" height="104" fill="#a97a4e" /><rect y="372" width="600" height="8" fill="#c89a6c" />
      {[0, 1, 2].map((i) => <rect key={i} y={404 + i * 26} width="600" height="2" fill="#845b35" opacity="0.7" />)}
      <rect x="240" y="366" width="170" height="14" rx="4" fill="#2c2a28" />
      <ellipse cx="325" cy="364" rx="66" ry="7" fill="#ff8a2a" opacity={burner} />
      {/* the kettle */}
      <g>
        <path d="M250 362 C240 296 280 274 325 274 C370 274 410 296 400 362 Z" fill="#c0703a" />
        <path d="M268 352 C262 306 288 288 312 284" stroke="#eab27f" strokeWidth="8" fill="none" strokeLinecap="round" opacity="0.8" />
        <path d="M398 318 L448 280 L456 292 L402 346 Z" fill="#a95f2e" />
        <path d="M278 282 C282 226 368 226 372 282" stroke="#2c2a28" strokeWidth="11" fill="none" />
        <circle cx="325" cy="270" r="9" fill="#2c2a28" />
      </g>
      {steam > 0 ? <g style={{ filter: 'blur(5px)' }}>{[0, 1, 2, 3].map((i) => {
        const u = (t * 0.55 + i * 0.25) % 1;
        const x = 458 + i * 5 + Math.sin(u * 7 + i) * 10 + u * 26, y = 276 - u * 170;
        return <circle key={i} cx={x} cy={y} r={7 + u * 25} fill="#fff" opacity={steam * 0.62 * (1 - u)} />;
      })}</g> : null}
      <path d="M520 276 L520 240 C520 212 566 212 566 240 L566 262" stroke="#b39a5a" strokeWidth="13" fill="none" strokeLinecap="round" />
      <rect x="486" y="374" width="104" height="18" rx="4" fill="#7c8083" /><rect x="514" y="276" width="12" height="96" fill="#b39a5a" />
      {drop < 1 ? <ellipse cx="566" cy={270 + drop * drop * 102} rx="4.5" ry="7" fill="#9fc6e8" /> : null}
    </svg>
  );
};

const Photo: React.FC = () => {
  const t = useT();
  const at = E('photo');
  if (t < at) return null;
  const k = back(ramp(t, at, at + 0.26));
  const tapeK = t >= E('dont') ? back(ramp(t, E('dont'), E('dont') + 0.14)) : 0;
  return (
    <div style={{ position: 'absolute', left: 60, top: 104, width: 640, height: 584, background: '#fffdf8', transform: `rotate(-1.6deg) scale(${lerp(1.28, 1, k)})`, boxShadow: '0 16px 30px rgba(90,20,20,.36)' }}>
      <div style={{ position: 'absolute', left: 20, top: 20, width: 600, height: 476, overflow: 'hidden', background: '#111' }}>
        <KitchenPhoto />
        {t >= E('dont') ? <div style={{ position: 'absolute', left: 226, top: 300, width: 210, height: 60, textAlign: 'center', background: '#ffd166', color: PENCIL, font: `700 46px/60px ${HAND}`, boxShadow: '0 3px 6px rgba(0,0,0,.25)', transform: `rotate(-7deg) scale(${lerp(1.5, 1, tapeK)})` }}>{KETTLE_TAPE}</div> : null}
      </div>
      <div style={{ position: 'absolute', left: 36, top: 504, font: `700 62px/70px ${HAND}`, color: PENCIL }}>{PHOTO_NOTE}</div>
      <Tape at={at + 0.2} x={-52} y={-14} rot={-32} />
      <Tape at={at + 0.34} x={504} y={-14} rot={30} />
    </div>
  );
};

/* ---------- the tomato timer ---------- */

const NUMERALS = [0, 5, 10, 15];
const Timer: React.FC = () => {
  const t = useT();
  const m = minutesAt(t);
  const d = t >= E('ding') ? t - E('ding') : -1;
  const buzz = d >= 0 ? Math.sin(d * 62) * 8 * Math.exp(-d * 1.8) : 0;
  const winding = (t > E('wind') && t < E('wind') + 0.8) || (t > E('again') && t < E('again') + 0.75) ? Math.sin(t * 44) * 1.4 : 0;
  const cx = 172, cy = 212, R = 66, C = 2 * Math.PI * R, angle = -m * 18;
  return (
    <div style={{ position: 'absolute', left: 708, top: 96, width: 344, height: 380, transform: `rotate(${5 + buzz + winding}deg)`, transformOrigin: '50% 58%' }}>
      <svg viewBox="0 0 344 380" width={344} height={380} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible' }}>
        <defs><radialGradient id="tom" cx="0.34" cy="0.3" r="0.82"><stop offset="0" stopColor="#ff8469" /><stop offset="0.5" stopColor={TOMATO} /><stop offset="1" stopColor="#951f15" /></radialGradient></defs>
        <path d="M172 58 C246 26 338 92 336 204 C334 306 254 362 172 362 C90 362 10 306 8 204 C6 92 98 26 172 58 Z" transform="translate(9 15)" fill="rgba(70,15,10,.34)" />
        <path d="M172 58 C246 26 338 92 336 204 C334 306 254 362 172 362 C90 362 10 306 8 204 C6 92 98 26 172 58 Z" fill="url(#tom)" />
        <path d="M112 70 C70 104 52 142 54 196 M232 70 C274 104 292 142 290 196" stroke="#a82a1c" strokeWidth="4" fill="none" opacity="0.45" />
        <ellipse cx="82" cy="108" rx="30" ry="17" fill="#ffc4b2" opacity="0.5" transform="rotate(-38 82 108)" />
        <path d="M172 62 L148 34 L146 62 L112 52 L134 78 L172 72 L210 78 L232 52 L198 62 L196 34 Z" fill={BASIL} stroke="#2c6a39" strokeWidth="3" strokeLinejoin="round" />
        <rect x="165" y="12" width="14" height="42" rx="6" fill="#2c6a39" />
        {/* the dial turns under a fixed pointer; the wedge is the time left */}
        <circle cx={cx} cy={cy} r="112" fill="#fff3e0" stroke="#b98a5f" strokeWidth="4" />
        {m > 0.02 ? <circle cx={cx} cy={cy} r={R} fill="none" stroke={TOMATO} strokeWidth="44" opacity="0.8" strokeDasharray={`${(m / 20) * C} ${C}`} transform={`rotate(${-90 + angle} ${cx} ${cy})`} /> : null}
        <g transform={`rotate(${angle} ${cx} ${cy})`}>
          {Array.from({ length: 20 }, (_, i) => <rect key={i} x={cx - (i % 5 ? 1.5 : 3)} y={cy - 110} width={i % 5 ? 3 : 6} height={i % 5 ? 11 : 18} fill={PENCIL} transform={`rotate(${i * 18} ${cx} ${cy})`} />)}
        </g>
        <polygon points={`${cx},${cy - 92} ${cx - 15},${cy - 126} ${cx + 15},${cy - 126}`} fill={PENCIL} />
        <circle cx={cx} cy={cy} r="36" fill="#b92c1e" /><circle cx={cx - 5} cy={cy - 6} r="27" fill={TOMATO} />
      </svg>
      <div style={{ position: 'absolute', left: cx, top: cy, width: 0, height: 0, transform: `rotate(${angle}deg)` }}>
        {NUMERALS.map((n) => <div key={n} style={{ position: 'absolute', left: -40, top: -40, width: 80, height: 80, textAlign: 'center', font: `700 40px/80px ${PRINT}`, color: PENCIL, transform: `rotate(${n * 18}deg) translateY(-70px)` }}>{n}</div>)}
      </div>
    </div>
  );
};

const DingWord: React.FC = () => {
  const t = useT();
  const at = E('ding');
  if (t < at) return null;
  const k = back(ramp(t, at, at + 0.14));
  const lines = [0, 1, 2, 3, 4];
  return (
    <>
      <div style={{ position: 'absolute', left: 462, top: 556, font: `700 136px/1 ${HAND}`, color: TOMATO, transform: `rotate(-10deg) scale(${lerp(0.4, 1, k)})`, textShadow: '0 4px 0 #fff8ec,0 -4px 0 #fff8ec,4px 0 0 #fff8ec,-4px 0 0 #fff8ec', opacity: 1 - ramp(t, E('serves') + 0.3, E('serves') + 0.8) }}>{DING}</div>
      <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0 }}>
        {lines.map((i) => <Stroke key={i} d={`M${880 + Math.cos(-2.5 + i * 0.55) * 200} ${306 + Math.sin(-2.5 + i * 0.55) * 200} L${880 + Math.cos(-2.5 + i * 0.55) * 245} ${306 + Math.sin(-2.5 + i * 0.55) * 245}`} at={at + i * 0.02} dur={0.1} width={9} color={TOMATO} opacity={1 - ramp(t, at + 0.4, at + 0.9)} />)}
      </svg>
    </>
  );
};

/* ---------- the mug ---------- */

const MugG: React.FC<{ fill: number; spin: number }> = ({ fill, spin }) => (
  <g>
    <circle cx="9" cy="14" r="104" fill="rgba(70,15,10,.32)" />
    <rect x="86" y="-24" width="62" height="48" rx="22" fill="none" stroke={BASIL} strokeWidth="19" />
    <circle r="104" fill={BASIL} /><circle r="104" fill="none" stroke="#2c6a39" strokeWidth="4" />
    <circle r="90" fill="#f6efdf" />
    <circle r="90" fill="none" stroke="rgba(90,60,30,.25)" strokeWidth="9" />
    {fill > 0 ? (
      <g>
        <circle r={82 * fill} fill={COFFEE} />
        <g transform={`rotate(${spin})`} fill="none" stroke="#8a5d38" strokeLinecap="round" opacity={fill}>
          <path d="M-52 -14 C-34 -52 22 -54 46 -24" strokeWidth="7" /><path d="M42 32 C14 58 -30 52 -44 26" strokeWidth="5" />
        </g>
        <ellipse cx="-30" cy="-40" rx="20" ry="8" fill="#fff" opacity={0.22 * fill} transform="rotate(-30 -30 -40)" />
      </g>
    ) : null}
  </g>
);

const Wisps: React.FC<{ x: number; y: number; k: number; tall: number }> = ({ x, y, k, tall }) => {
  const t = useT();
  if (k <= 0) return null;
  return (
    <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, pointerEvents: 'none', filter: 'blur(5px)' }}>
      {[0, 1, 2, 3].map((i) => {
        const u = (t * 0.42 + i * 0.25) % 1;
        const a = x - 42 + i * 28, b = y - u * tall;
        const s = 1 + Math.sin(t * 1.4 + i * 2) * 26;
        return <path key={i} d={`M${a} ${b} c${s} -50 ${-s} -90 ${s * 0.6} -150`} fill="none" stroke="#fff" strokeWidth={14 + u * 16} strokeLinecap="round" opacity={k * 0.6 * Math.sin(u * Math.PI)} />;
      })}
    </svg>
  );
};

const Mugs: React.FC = () => {
  const t = useT();
  const inA = back(ramp(t, E('mug') - 0.08, E('mug') + 0.16));
  const fill = easeOut(ramp(t, E('fold'), E('fold') + 1));
  const off = easeIn(ramp(t, E('ring') - 0.02, E('ring') + 0.5));
  const firstAt = mix(RING, [1290, 660], off);
  const landed = E('quiet') + 0.14;
  const inB = back(ramp(t, landed - 0.16, landed + 0.08));
  const steamA = fill * (1 - off) * (1 - grayAt(t));
  return (
    <>
      {t >= E('mug') - 0.08 && off < 1 ? (
        <svg viewBox="-160 -160 320 320" width={320} height={320} style={{ position: 'absolute', left: firstAt[0] - 160, top: firstAt[1] - 160, overflow: 'visible', transform: `scale(${lerp(1.5, 1, inA) * lerp(1, 1.14, off)}) rotate(${lerp(-12, 0, inA)}deg)` }}><MugG fill={fill} spin={t * 40} /></svg>
      ) : null}
      <Wisps x={RING[0]} y={RING[1] - 50} k={steamA} tall={120} />
      {t >= landed - 0.16 ? (
        <>
          <svg viewBox="-160 -160 320 320" width={288} height={288} style={{ position: 'absolute', left: MUG_TABLE[0] - 144, top: MUG_TABLE[1] - 144, overflow: 'visible', transform: `scale(${lerp(1.7, 1, inB)}) rotate(${lerp(-14, 0, inB)}deg)` }}><MugG fill={1} spin={t * 30} /></svg>
          <Wisps x={MUG_TABLE[0]} y={MUG_TABLE[1] - 60} k={ramp(t, landed, landed + 0.5)} tall={190} />
        </>
      ) : null}
    </>
  );
};

/* A pinch of saffron threads, because it's the one ingredient that looks like sun. */
const PINCH: P = [360, 1088];
const THREADS = Array.from({ length: 36 }, (_, i) => ({ delay: hash(i + 3) * 0.55, x: PINCH[0] + (hash(i + 9) - 0.5) * 340, y: PINCH[1] + (hash(i + 17) - 0.5) * 34, r: hash(i + 29) * 360, l: 5 + hash(i + 41) * 8, c: i % 3 ? '#de7a1e' : '#c94a18' }));
const Saffron: React.FC = () => {
  const t = useT();
  const at = E('pinch');
  const melt = ramp(t, E('before') - 0.1, E('before') + 0.3); // the pinch melts into its line as the line inks
  if (t < at || melt >= 1) return null;
  return (
    <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, pointerEvents: 'none', opacity: 1 - melt }}>
      {THREADS.map((g, i) => {
        const q = ramp(t, at + g.delay, at + g.delay + 0.46);
        if (q <= 0) return null;
        const x = lerp(PINCH[0] + (g.x - PINCH[0]) * 0.05, g.x, q * q * q), y = lerp(600, g.y, easeIn(q)), a = (g.r + (1 - q) * 500) * (Math.PI / 180);
        return <line key={i} x1={x - Math.cos(a) * g.l} y1={y - Math.sin(a) * g.l} x2={x + Math.cos(a) * g.l} y2={y + Math.sin(a) * g.l} stroke={g.c} strokeWidth="3.8" strokeLinecap="round" />;
      })}
    </svg>
  );
};

/* ---------- the card ---------- */

const FOXING = Array.from({ length: 46 }, (_, i) => ({ x: 56 + hash(i + 200) * 970, y: 810 + hash(i + 300) * 750, r: 1.5 + hash(i + 400) * 4.5 }));
const CardPaper: React.FC = () => (
  <div style={{ position: 'absolute', left: 40, top: 700, width: 1000, height: 880, borderRadius: 12, overflow: 'hidden', background: `linear-gradient(170deg,${CARD},#f9eeda 70%,#f3e3c7)`, boxShadow: '0 18px 34px rgba(90,20,20,.38)' }}>
    <svg viewBox="0 0 1000 880" width={1000} height={880} style={{ position: 'absolute', left: 0, top: 0 }}>
      {LIST.map((_, i) => <rect key={i} x="24" y={rowY(i) - 700 + 50} width="952" height="2" fill="#8fb0d8" opacity="0.42" />)}
      <rect x="580" y="160" width="2.5" height="432" fill={GINGHAM} opacity="0.32" />
      {CAP_Y.map((y) => <rect key={y} x="24" y={y - 700 + CAP_LINE - 8} width="952" height="2.5" fill="#8fb0d8" opacity="0.6" />)}
      {FOXING.map((f, i) => <circle key={i} cx={f.x - 40} cy={f.y - 700} r={f.r} fill="#a9783f" opacity="0.13" />)}
      <path d="M0 448 L1000 438" stroke="#b79f7a" strokeWidth="2" opacity="0.28" /><path d="M0 451 L1000 441" stroke="#fff" strokeWidth="2" opacity="0.5" />
    </svg>
    <div style={{ position: 'absolute', left: 38, top: 800, font: `600 30px/1 ${PRINT}`, color: LABEL }}>Family recipe. Do not laminate.</div>
  </div>
);

const Band: React.FC = () => {
  const t = useT();
  return (
    <>
      <div style={{ position: 'absolute', left: 40, top: 700, width: 1000, height: 100, borderRadius: '12px 12px 0 0', background: GINGHAM, boxShadow: 'inset 0 -5px 0 rgba(120,20,20,.25)' }} />
      <div style={{ position: 'absolute', left: 78, top: 725, font: `700 44px/56px ${PRINT}`, letterSpacing: '.02em', color: CARD }}>Recipe:</div>
      <Scrawl at={E('calm') - 0.08} d={0.6} style={{ left: 232, top: 698, font: `700 92px/104px ${HAND}`, color: CARD }}>{TITLE}</Scrawl>
      <div style={{ position: 'absolute', left: 736, top: 712, width: 214, textAlign: 'right', font: `600 28px/32px ${PRINT}`, color: CARD, opacity: 0.88 * ramp(t, 0.2, 0.7), whiteSpace: 'nowrap' }}>{KITCHEN}</div>
      <Scrawl at={E('family')} d={0.3} style={{ left: 736, top: 738, width: 214, textAlign: 'right', font: `700 58px/58px ${HAND}`, color: CARD }}>{COOK}</Scrawl>
    </>
  );
};

const Meta: React.FC = () => (
  <>
    {META.map((m) => (
      <React.Fragment key={m.label}>
        <div style={{ position: 'absolute', left: m.x, top: 816, font: `600 32px/44px ${PRINT}`, color: LABEL }}>{m.label}</div>
        {m.ev ? <Scrawl at={E(m.ev)} d={0.32} style={{ left: m.x + 104, top: 804, font: `700 58px/60px ${HAND}`, color: PENCIL }}>{m.value}</Scrawl> : <div style={{ position: 'absolute', left: m.x + 94, top: 808, font: `700 52px/56px ${HAND}`, color: PENCIL, whiteSpace: 'nowrap' }}>{m.value}</div>}
      </React.Fragment>
    ))}
  </>
);

/** A row: faint old ink until its word, then inked and ticked. The phone's row goes wrong on the low point and is ticked again on the fix. */
const Row: React.FC<{ i: number }> = ({ i }) => {
  const t = useT();
  const l = LIST[i];
  const y = rowY(i), at = E(l.ev);
  const late = l.late ? E(l.late) : null;
  if (late !== null && t < late) return null;
  const ink = late !== null ? 1 : 0.34 + 0.66 * ramp(t, at - 0.15, at + 0.05);
  const isPhone = i === PHONE_ROW;
  const wrong = isPhone && t >= E('oh') + 0.08 && t < E('again');
  return (
    <>
      <div style={{ position: 'absolute', left: BOX_X, top: y + 7, width: 36, height: 36, boxSizing: 'border-box', border: `4px solid ${PENCIL}`, borderRadius: 6, opacity: 0.45 + 0.55 * ink }} />
      {late !== null
        ? <Scrawl at={late} d={Math.max(0.3, at - late - 0.24)} style={{ left: TEXT_X, top: y, font: `700 58px/52px ${HAND}`, color: PENCIL }}>{l.text}</Scrawl>
        : <div style={{ position: 'absolute', left: TEXT_X, top: y, font: `700 58px/52px ${HAND}`, color: PENCIL, opacity: ink, whiteSpace: 'nowrap' }}>{l.text}</div>}
      <svg viewBox="0 0 80 70" width={80} height={70} style={{ position: 'absolute', left: BOX_X - 8, top: y - 12, overflow: 'visible' }}>
        {!wrong ? <Stroke d="M14 38 L25 51 L52 8" at={isPhone && t >= E('again') ? E('again') : at} dur={0.16} width={7.5} color={BASIL} /> : null}
        {isPhone ? <><Stroke d="M12 18 L50 54" at={E('oh') + 0.08} dur={0.1} width={8} opacity={wrong ? 1 : 0} /><Stroke d="M50 18 L12 54" at={E('oh') + 0.16} dur={0.1} width={8} opacity={wrong ? 1 : 0} /></> : null}
      </svg>
    </>
  );
};

const PhoneRowNotes: React.FC = () => {
  const t = useT();
  const y = rowY(PHONE_ROW);
  const a = E('important');
  return (
    <>
      <Scrawl at={E('room')} d={0.34} style={{ left: 318, top: y + 2, font: `700 50px/50px ${HAND}`, color: TOMATO }}>{WARNING_NOTE}</Scrawl>
      <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, pointerEvents: 'none' }}>
        <Stroke d={`M${TEXT_X} ${y + 47} C${TEXT_X + 60} ${y + 42} ${TEXT_X + 120} ${y + 50} ${TEXT_X + 176} ${y + 45}`} at={a} dur={0.18} width={6} />
        <Stroke d={`M${TEXT_X + 178} ${y + 53} C${TEXT_X + 120} ${y + 58} ${TEXT_X + 60} ${y + 50} ${TEXT_X + 4} ${y + 56}`} at={a + 0.22} dur={0.18} width={6} />
        {/* the low point crosses it all out */}
        <Stroke d={`M${TEXT_X - 6} ${y + 28} L${TEXT_X + 176} ${y + 24}`} at={E('oh') + 0.1} dur={0.14} width={7} opacity={1 - ramp(t, E('again'), E('again') + 0.4)} />
      </svg>
    </>
  );
};

/** The stain the mug leaves when it goes. It's uneven on purpose: a clean ring looks like a drawn circle. */
const Stain: React.FC = () => {
  const t = useT();
  if (t < E('ring') - 0.1) return null;
  return (
    <svg viewBox="-130 -130 260 260" width={260} height={260} style={{ position: 'absolute', left: RING[0] - 130, top: RING[1] - 130, overflow: 'visible' }}>
      <circle r="98" fill="#b78a56" opacity="0.13" />
      <circle r="98" fill="none" stroke={COFFEE} strokeWidth="9" opacity="0.58" strokeDasharray="330 12 190 8" />
      <circle r="92" fill="none" stroke={COFFEE} strokeWidth="3" opacity="0.32" transform="translate(3 -2)" />
      <circle cx="104" cy="58" r="10" fill={COFFEE} opacity="0.42" /><circle cx="-96" cy="72" r="5" fill={COFFEE} opacity="0.4" />
    </svg>
  );
};

const MarginNote: React.FC = () => (
  <>
    <Scrawl at={E('salty')} d={0.42} style={{ left: 686, top: 918, font: `700 58px/60px ${HAND}`, color: PENCIL, transform: 'rotate(-4deg)' }}>{MARGIN_NOTE[0]}</Scrawl>
    <Scrawl at={E('water') - 0.08} d={0.4} style={{ left: 688, top: 980, font: `700 62px/62px ${HAND}`, color: TOMATO, transform: 'rotate(-4deg)' }}>{MARGIN_NOTE[1]}</Scrawl>
  </>
);

/** The call to action. Sized from the keyword, so a longer word still fits the column. */
const CtaNote: React.FC = () => {
  const at = E('cta') + 0.05;
  const big = Math.min(112, 296 / (KEYWORD.length * 0.52));
  return (
    <div style={{ position: 'absolute', left: 640, top: 1098, width: 320, height: 196, transform: 'rotate(-3deg)' }}>
      <Scrawl at={at} d={0.5} style={{ left: 0, top: 0, font: `700 72px/72px ${HAND}`, color: PENCIL }}>COMMENT</Scrawl>
      <Scrawl at={at + 0.5} d={0.5} style={{ left: 0, top: 66, font: `700 ${big}px/${big + 6}px ${HAND}`, color: TOMATO }}>{KEYWORD}</Scrawl>
      <svg viewBox="0 0 320 196" width={320} height={196} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible' }}>
        <Stroke d="M2 176 C90 166 200 180 292 168" at={at + 1} dur={0.2} width={7} />
        <Stroke d="M292 182 C210 190 110 178 26 190" at={at + 1.22} dur={0.2} width={6} />
      </svg>
    </div>
  );
};

const Method: React.FC = () => <div style={{ position: 'absolute', left: 78, top: 1292, font: `700 30px/32px ${PRINT}`, letterSpacing: '.12em', color: GINGHAM }}>METHOD</div>;

/* ---------- the stub of pencil: it goes wherever the next thing gets written ---------- */

const REST: P = [690, 1284];
const tick = (i: number): P => [BOX_X + 24, rowY(i) + 36];
const PEN: { at: number; to: P; dx?: number; d?: number }[] = [
  { at: E('family'), to: [840, 782], dx: 100, d: 0.3 },
  { at: E('calm') - 0.08, to: [240, 770], dx: 500, d: 0.6 },
  { at: E('window'), to: tick(0) },
  { at: E('mug'), to: tick(1) },
  { at: E('phonegone'), to: tick(PHONE_ROW) },
  { at: E('before'), to: tick(3) },
  { at: E('wind'), to: tick(4) },
  { at: E('nothing'), to: tick(5) },
  { at: E('room'), to: [324, rowY(PHONE_ROW) + 36], dx: 240, d: 0.34 },
  { at: E('important'), to: [TEXT_X, rowY(PHONE_ROW) + 47], dx: 176, d: 0.18 },
  { at: E('important') + 0.22, to: [TEXT_X + 178, rowY(PHONE_ROW) + 55], dx: -174, d: 0.18 },
  { at: E('salty'), to: [690, 962], dx: 210, d: 0.42 },
  { at: E('water') - 0.08, to: [692, 1026], dx: 210, d: 0.4 },
  { at: E('again'), to: tick(PHONE_ROW) },
  { at: E('serves'), to: [186, 850], dx: 70, d: 0.32 },
  { at: E('keeps'), to: [404, 850], dx: 130, d: 0.32 },
  { at: E('goes'), to: [TEXT_X, rowY(6) + 36], dx: 430, d: E('lasttick') - E('goes') - 0.24 },
  { at: E('lasttick'), to: tick(6) },
  { at: E('cta') + 0.05, to: [650, 1160], dx: 250, d: 0.5 },
  { at: E('cta') + 0.55, to: [650, 1240], dx: 280, d: 0.5 },
];
const penAt = (t: number): P => {
  let p = REST;
  PEN.forEach((k, i) => {
    p = mix(p, k.to, easeInOut(ramp(t, k.at - 0.24, k.at)));
    if (k.dx) p = mix(p, [k.to[0] + k.dx, k.to[1]], ramp(t, k.at, k.at + (k.d ?? 0.3)));
    const end = k.at + (k.d ?? 0) + 0.2, next = PEN[i + 1];
    if (next && next.at - end > 0.85) p = mix(p, REST, easeInOut(ramp(t, end, end + 0.32))); // off the list while nothing's being written
  });
  return p;
};
const Pencil: React.FC = () => {
  const t = useT();
  const [x, y0] = penAt(t);
  const y = y0 + slideAt(t);
  const rock = Math.sin(t * 1.3) * 1.2;
  return (
    <svg viewBox="-60 -320 400 360" width={400} height={360} style={{ position: 'absolute', left: x - 60, top: y - 320, overflow: 'visible', pointerEvents: 'none' }}>
      <g transform={`rotate(${44 + rock})`}>
        <path d="M0 0 L-13 -36 L-13 -226 L13 -226 L13 -36 Z" transform="translate(16 12)" fill="rgba(70,15,10,.22)" />
        <rect x="-13" y="-206" width="26" height="172" fill="#f2b632" /><rect x="-13" y="-206" width="8" height="172" fill="#fbd063" /><rect x="5" y="-206" width="8" height="172" fill="#d8961e" />
        <polygon points="0,0 -13,-36 13,-36" fill="#ecc99d" /><polygon points="0,0 -4.5,-12 4.5,-12" fill={PENCIL} />
        <rect x="-13" y="-226" width="26" height="22" fill="#b4afa4" /><rect x="-13" y="-218" width="26" height="4" fill="#8a867d" />
        <rect x="-13" y="-252" width="26" height="28" rx="7" fill="#e58a8a" />
      </g>
    </svg>
  );
};

/* ---------- the phone: the one cold thing on the table. It sits above the gray, so it keeps its color. ---------- */

const phoneK = (t: number) => (t < E('lowlead')
  ? easeOut(ramp(t, E('nophone') - 0.05, E('nophone') + 0.2)) * (1 - easeIn(ramp(t, E('phonegone') - 0.16, E('phonegone') + 0.06)))
  : back(ramp(t, E('lowlead') + 0.05, E('oh'))) * (1 - easeIn(ramp(t, E('fine') - 0.1, E('fine') + 0.26))));
const Phone: React.FC = () => {
  const t = useT();
  const k = phoneK(t);
  if (k <= 0) return null;
  const lit = t >= E('lowlead');
  const a = E('lowlead');
  const buzz = lit && Math.floor((t - a) * 1.7) % 2 === 0 ? Math.sin(t * 74) * 4 : 0;
  const x = lerp(-340, PHONE_AT[0], k), y = PHONE_AT[1];
  return (
    <div style={{ position: 'absolute', left: x - 142 + buzz, top: y - 276, width: 284, height: 552, borderRadius: 46, background: '#14161c', transform: `rotate(${-9 + buzz * 0.2}deg)`, boxShadow: '12px 22px 34px rgba(10,10,30,.5)' }}>
      <div style={{ position: 'absolute', left: 12, top: 12, width: 260, height: 528, borderRadius: 36, overflow: 'hidden', background: lit ? 'linear-gradient(#17315f,#3f74cf)' : '#0d0f15' }}>
        {lit ? (
          <>
            <div style={{ position: 'absolute', left: 0, top: 44, width: 260, textAlign: 'center', font: `600 76px/1 ${PRINT}`, color: '#eaf2ff' }}>6:{Math.floor(clockMinutes(t))}</div>
            {PHONE_PINGS.map((p, i) => {
              const at = a + 0.25 + i * 0.32;
              if (t < at) return null;
              const label = i === 0 ? p.replace(/\d+/, (n) => String(Number(n) + Math.floor((t - a) * 7))) : p; // the first ping's number keeps climbing
              return <div key={p} style={{ position: 'absolute', left: 12, top: 148 + i * 88, width: 236, height: 78, boxSizing: 'border-box', padding: '0 14px', borderRadius: 16, background: 'rgba(238,245,255,.95)', color: '#14233f', font: `600 30px/78px ${PRINT}`, whiteSpace: 'nowrap', overflow: 'hidden', transform: `scale(${lerp(0.82, 1, easeOut(ramp(t, at, at + 0.12)))})` }}>{label}</div>;
            })}
          </>
        ) : <div style={{ position: 'absolute', left: -60, top: -20, width: 180, height: 620, background: 'rgba(255,255,255,.07)', transform: 'rotate(18deg)' }} />}
      </div>
    </div>
  );
};

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

/** Sun through the window lands on the table as four panes, and crosses the card on the voiceless moment. */
const SunPatch: React.FC = () => {
  const t = useT();
  const open = openAt(t), sun = sunAt(t), g = grayAt(t);
  const o = open * (1 - g) * (0.34 + 0.66 * sun);
  if (o <= 0.005) return null;
  const x = lerp(lerp(-470, -250, ramp(t, E('window'), E('important'))), 330, sun) + 7 * Math.max(0, t - E('ding'));
  return (
    <div style={{ position: 'absolute', left: x, top: 420, width: 800, height: 1280, transform: 'skewX(-15deg)', filter: 'blur(7px)', opacity: o, pointerEvents: 'none' }}>
      {[[0, 0], [1, 0], [0, 1], [1, 1]].map(([c, r], i) => <div key={i} style={{ position: 'absolute', left: c * 410, top: r * 650, width: 390, height: 630, background: 'rgba(255,224,140,.38)' }} />)}
    </div>
  );
};

const Light: React.FC = () => {
  const t = useT();
  const day = dayAt(t), open = openAt(t), sun = sunAt(t);
  const dim = (1 - day) * 0.2 + (1 - open) * 0.12;
  return (
    <>
      <AbsoluteFill style={{ background: `rgba(40,38,84,${dim})`, pointerEvents: 'none' }} />
      <AbsoluteFill style={{ background: `rgba(255,178,92,${0.1 * sun})`, pointerEvents: 'none' }} />
    </>
  );
};

/* The phone's cold light on everything around it. */
const PhoneGlow: React.FC = () => {
  const t = useT();
  const k = t >= E('lowlead') ? phoneK(t) : 0;
  if (k <= 0) return null;
  return <div style={{ position: 'absolute', left: -60, top: -60, width: 1200, height: 2040, background: `radial-gradient(ellipse 760px 900px at ${PHONE_AT[0] + 60}px ${PHONE_AT[1] + 60}px, rgba(110,165,255,${0.44 * k}), rgba(60,90,170,${0.13 * k}) 70%)`, pointerEvents: 'none' }} />;
};

/* ---------- captions: handwritten into the method, word by word ---------- */

const QUIET_T0 = E('quiet') + 0.4;
const quietWords: Word[] = QUIET_CAPTION.split(' ').map((w, i) => ({ w, t0: QUIET_T0 + i * 0.42, t1: QUIET_T0 + (i + 1) * 0.42, scene: '' }));
const quietRow = { s: QUIET_CAPTION, map: QUIET_CAPTION.split(' ').flatMap((w, i) => [...(i ? [-1] : []), ...Array.from(w, () => i)]) };
const CHUNKS: Chunk[] = [...buildChunks(PER_ROW, 2), { t0: QUIET_T0, rows: [quietRow], words: quietWords }].sort((a, b) => a.t0 - b.t0);
const bare = (w: string) => w.replace(/[^\w']/g, '').toUpperCase();

const Captions: React.FC = () => {
  const t = useT();
  const idx = CHUNKS.reduce((a, c, i) => (t >= c.t0 - 0.05 ? i : a), -1);
  if (idx < 0) return null;
  const c = CHUNKS[idx];
  return (
    <>
      {c.rows.map((row, ri) => (
        <div key={ri} style={{ position: 'absolute', left: CAP_X, top: CAP_Y[c.rows.length === 1 ? 1 : ri], height: CAP_LINE, whiteSpace: 'nowrap', font: `700 ${CAP_FONT}px/${CAP_LINE}px ${HAND}`, color: PENCIL }}>
          {[...new Set(row.map.filter((i) => i >= 0))].map((i) => {
            const w = c.words[i];
            const k = ramp(t, w.t0 - 0.05, w.t0 - 0.05 + Math.min(0.32, Math.max(0.1, w.t1 - w.t0)));
            const hot = (t >= w.t0 - 0.03 && t < w.t1 + 0.1) || (t >= E('cta') && bare(w.w) === KEYWORD);
            return <span key={i} style={{ display: 'inline-block', marginRight: 17, visibility: k > 0 ? 'visible' : 'hidden', color: hot ? TOMATO : PENCIL, clipPath: `inset(-25% ${k >= 1 ? '-20%' : `${(1 - k) * 100}%`} -25% -8%)` }}>{w.w}</span>;
          })}
        </div>
      ))}
    </>
  );
};

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

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

const Stage: React.FC = () => {
  const t = useT();
  const cam = camAt(t);
  const g = grayAt(t);
  const slide = slideAt(t);
  return (
    <AbsoluteFill style={{ background: CLOTH, 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})` }}>
        <div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1920, filter: g > 0.001 ? `grayscale(${g * 0.94}) brightness(${1 - g * 0.2})` : undefined }}>
          <Cloth />
          <Under />
          <div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1920, transform: `translateY(${slide}px)` }}><CardPaper /></div>
          <SunPatch />
          <div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1920, transform: `translateY(${slide}px)` }}>
            <Band />
            <Meta />
            <div style={{ position: 'absolute', left: 78, top: 862, font: `700 30px/32px ${PRINT}`, letterSpacing: '.12em', color: GINGHAM }}>INGREDIENTS</div>
            <Method />
            <Stain />
            {LIST.map((_, i) => <Row key={i} i={i} />)}
            <PhoneRowNotes />
            <MarginNote />
            <CtaNote />
          </div>
          <Photo />
          <Timer />
          <Saffron />
          <Mugs />
          <DingWord />
          <Pencil />
          <Light />
        </div>
        <PhoneGlow />
        <Phone />
      </div>
      <Captions />
      <AbsoluteFill style={{ background: 'radial-gradient(120% 80% at 50% 45%, transparent 66%, rgba(60,20,10,.26) 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 warm home cook, older, close to the mic, reading a card aloud to someone across the table and half doing something else. Amused, unhurried, and impossible to shock. She has seen every morning go wrong, and she doesn't lecture.

**When to use it:** on every line except the quiet. Three lines carry it. "This is important" is said lightly, because she already knows. "Oh." is small and flat, with no scolding in it. "That's fine" comes right after, with a smile you can hear, and it's the best read in the film, so audition on that line. Add a small kitchen room with a very short echo. Avoid a TV chef's bounce, and don't imitate any real cook.

- **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.

Every sound is pinned to an event in `story.json`, so a re-voice moves it with the picture.

| Sound | Length | Prompt idea | When it plays |
|-------|--------|-------------|---------------|
| room tone | 10 s | a quiet kitchen at dawn, a faint fridge hum, one bird far outside, no voices, no music | the bed, all the way through, and the only thing under the low point besides the drip and the clock |
| card slide | 0.8 s | a stiff index card pushed across a cotton tablecloth, a soft papery hiss | the first frame, `on` |
| tape | 0.6 s | a short strip of masking tape pulled off a roll and pressed down with a thumb | `photo`, twice, 0.2 s and 0.34 s after it lands, and on `dont` |
| pencil tick | 0.4 s | one quick check mark in soft pencil on thick paper, two short strokes, close and dry | every tick: `window`, `mug`, `phonegone`, `before`, `wind`, `nothing`, `lasttick`, and again on `again` |
| pencil writing | 1.0 s | a pencil writing quickly on thick card, light and dry, no paper rustle | handwriting: `family`, `calm`, `room`, `salty`, `water`, `serves`, `keeps`, `goes`, `cta`, and two hard strokes on `important` |
| mug down | 0.6 s | a heavy ceramic mug set down on a wooden table through a thin cloth, a soft warm clunk, no ring | `mug`, then bigger just after `quiet`, when the table takes the knock |
| phone slide | 0.5 s | a phone sliding fast across a cotton tablecloth and stopping | `nophone` going in and `phonegone` going out, then `lowlead` and `fine` |
| phone buzz | 1.2 s | a phone vibrating on a wooden table in two short bursts, a low rattle, dark, not harsh | from `lowlead` through `ring`, in bursts, with the phone's shaking |
| pinch | 1.0 s | a small pinch of dry spice falling on paper, a tiny soft patter | `pinch` |
| burner | 1.2 s | a gas stove burner lighting, two small clicks and a soft whoomp | `kettle` |
| kettle | 8 s | a kettle slowly coming up to heat, a low rumble rising, no whistle | from `kettle` until `checked`, then back low after `water` |
| timer wind | 0.9 s | a small wind-up kitchen timer being turned, quick ratcheting clicks, plastic and warm | `wind` and `again` |
| timer tick | 5 s | the steady small tick of a wind-up kitchen timer, close and dry, loops | from `fold` until it stops dead on `checked`, and from `again`. Faster on the quiet, as the dial runs out |
| spoon | 1.5 s | a spoon stirring slowly in a ceramic mug, two soft clinks | `fold` |
| mug lift | 0.5 s | a mug lifted off paper, a small soft peel, nothing else | `ring` |
| tap drip | 0.5 s | one drop falling from a tap into a steel sink in a quiet room, a small round plink | every 1.15 s from `checked` until `water`, on the falling drop in the photo (`drop` in `KitchenPhoto`) |
| far clock | 0.5 s | one tick of a wall clock in another room, faint and wooden | once a second from `checked` until `water`, under the drip |
| ding | 2 s | a small mechanical timer bell, a quick brass rattle that fades, warm, not piercing | `ding`, the loudest thing in the film |
| birds | 6 s | a few small birds outside a kitchen window in the morning, soft and far away | rising from `quiet`, and staying low to the end |

- **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:** "Warm home kitchen instrumental: fingerpicked nylon string guitar, a soft upright piano slightly out of tune, brushes barely touching a snare, gentle and unhurried, opening up warmly with one simple melody, ending resolved, no vocals."

**When it plays:** low under the first lines, a little fuller by the fold. It drains to nothing over 0.7 s on `oh` and stays out through "You checked your phone" and "That's fine", so only room tone, the drip and the far clock remain. It returns on `water`, with one soft piano note first. It builds through "Nobody's morning", is at its fullest on the quiet with a bar that opens right on the mug's landing, and has a downbeat under the ding. It ducks for the taste, then resolves gently under the call to action.

**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 health claim creeping in:** a cheerful recipe turns into advice very quickly. Read the script once only for this. If a line tells a person what will happen to their body or mind, cut it. If an ingredient could really be measured and eaten, make it sillier.
- **Faded handwriting:** `SunPatch` sits between the paper and the ink on purpose, because light on top of the ink washes the words out. Keep the order in `Stage`.
- **Captions sliding off their lines:** the captions are fixed to the screen and the ruled lines are on the card. They agree only because `zoom()` holds y = 1400 still. Never pan vertically, and keep the push small.
- **The list leaving the safe area:** a push around the right side of the frame throws the check boxes past x = 60. Push around the left, as `KEYS` does.
- **A dead phone:** `Phone` and `PhoneGlow` sit outside the layer that goes gray, so the phone stays lit while everything around it dies. Inside that layer, the low point loses its one bright thing.
- **A black low point:** gray is not dark. Keep the table readable, the clock and the tap moving, and the phone buzzing. It's never black.
- **Clutter on the list:** anything left on top of the writing looks like a mistake, so the pinch melts away as its row inks in.
- **Words that look cut off on a contact sheet:** that's writing in progress. Watch the video to check that each chunk finishes well before the next starts.
- **A one-frame scan flagging the pencil or the phone:** a fast pencil and the phone's buzz both jump for one frame, and so does the knock when the mug lands. Look at each flagged frame, because a browser dropout looks the same to the scan.

| 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 · 35 lines · 3 KB
Copy Raw file
# The prompt

First give your agent the recipe-card 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 recipe-card skill to make a vertical video for [PLATFORM: Instagram Reels / TikTok / YouTube Shorts].

- **The recipe for:** [TOPIC - the small human thing that's the dish, e.g. "a calm morning", "a good first date", "a Sunday with no plans"]
- **The ingredients:** [INGREDIENTS - three silly amounts, one of them a "no", e.g. "one window, one mug, no phone", or "you write them"]
- **What goes wrong:** [MISTAKE - the one thing the card warned about, e.g. "you checked your phone"]
- **The cook's fix:** [FIX - a kitchen rule that's also true of the topic, e.g. "too salty? add water", or "you find it"]
- **In the window:** [WINDOW - "no person: a kitchen drawn in code", or "my own clip or photo, attached"]
- **Length:** [LENGTH - e.g. about 35 seconds]
- **Voice:** [VOICE - e.g. "a warm home cook", "my own voice", or "no voice: captions and sound only"]
- **Music:** [MUSIC - e.g. "the skill's warm kitchen 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 health claims, no diets or routines presented as advice, no real recipes or real people]

This is plainly a metaphor. It makes no health claims, and nothing in it should read as advice someone could follow.

Work in this order:

1. Write the script on the skill's moves, with one line for each turn and what happens on the table. Start from the cook's fix, then write backward to the warning.
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 the partner whose link you followed. Accept to allow them; decline and the site works the same. Privacy Policy

Decline Accept