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.

Call Now

A home-shopping channel fills the screen, and your thing is tonight's only item. It spins on a turntable while the stock counter races, then the live demo goes wrong on air and the set dies on a technical difficulties card. BUT WAIT slams across the frame, and every phone lights up. 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

    What's on the channel tonight What goes wrong live What "but wait" adds Platform Narrator Ending AI tools
    Use the call-now skill to make a vertical video for Instagram Reels.
    
    - **What's on the channel tonight:** [TOPIC - the thing being sold, real or silly, e.g. "a spoon that does everything", "my new app", "a seven-step morning routine"]
    - **Parody or real ad:** [MODE - "plainly a parody, invent everything", or "a real ad: here are my real stock numbers, deadline, phone line and terms"]
    - **The problem nobody has:** [PROBLEM - the hook, said in deadly earnest, e.g. "tired of owning more than one spoon?", or "you write it"]
    - **Three features:** [FEATURES - two real ones and one silly one, e.g. "stirs, scoops, points at things", or "you write them"]
    - **What goes wrong live:** [FLAW - the flaw the live demo exposes, e.g. "the spoon has holes", or "you write it"]
    - **What "but wait" adds:** [OFFER - the offer after the turn, e.g. "a second spoon, which also has holes", or "you write it"]
    - **In the window:** [WINDOW - "no person: a studio set drawn in code", or "my own clip, attached"]
    - **Length:** [LENGTH - e.g. about 35 seconds]
    - **Voice:** [VOICE - e.g. "a shopping-channel host who never breathes", "my own voice", or "no voice: captions and sound only"]
    - **Music:** [MUSIC - e.g. "the skill's shopping-channel 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 real brands, no invented numbers in a real ad, phone numbers only from the 555-01XX range unless it's my real line]
    
    Work in this order:
    
    1. Write the script on the skill's moves, with one line for each turn and what changes on the set.
    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 set 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 · 1936 lines · 118 KB
Copy Raw file
---
name: call-now
description: Make a 30-40 second vertical video where a home-shopping channel sells your thing live and loud - a turntable under spotlights, an offer panel, a racing stock counter, a live demo that goes wrong, a BUT WAIT banner and a wall of phones. The skill covers the studio sound design, music fitted to the turn and an optional voice like a host who never breathes. Use for product launches and offers, satire and parody, jokes about your own product, 'is it worth it' takes, or when asked for an infomercial, shopping channel or as-seen-on-TV video, or when given the call-now prompt.
---

# Call Now

A home-shopping channel fills the screen, and your thing is tonight's only item. It spins on a turntable while the offer panel, the stock counter and the phone lamps all keep moving. Then the live demo goes wrong on air, the set dies on a technical difficulties card, and BUT WAIT slams across the frame and brings every light back. Something always happens on the next word, so nobody gets a chance to look away.

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/call-now/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/archivo-black` 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/CallNow.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: the offer panel changes, the stock counter races, the live demo goes wrong, the set dies on air, BUT WAIT slams across it and the phones light up. 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 home-shopping channel on air at 3 a.m., selling one thing, forever.

- **Palette:** studio blue `#1b2a8c` with lighter streaks, panel navy `#0f1a5c`, hot magenta `#ff2e88`, deal gold `#ffc93c`, sale red `#e4002b`, caption yellow `#ffe14d`.
- **Type:** Archivo Black for anything shouted, Archivo Narrow 700 italic for small print and labels. Captions are Archivo Black, skewed, in yellow with a thick navy outline.
- **Fixed parts:** the channel bug and clock at the top, the starburst on the window's corner, the gold-framed window, the offer panel and the stock panel, the call bar, then captions in one place (y 1292-1460). The bug sits above the safe area, so nothing on it has to be read.
- **Below the safe area:** a ticker of fine print and a glossy checked studio floor. At the right edge from y = 900, under the platform's buttons, a vertical marquee sign spells the product. It's all texture.
- **The set is one machine:** every part reads the same few functions (`isDead`, `spinAt`, `flowAt`, `chaseAt`, `stockAt`). That's why the low point works: the streaks stop, the bulbs die, the counter freezes and the lamps go dark together, then all of it slams back on one word.
- **One change per word.** The eye goes to what moved, so let only one big thing land on each word.
- **Small jokes live in the fine print:** "THE ONE-ITEM CHANNEL", "ITEM 1 OF 1", "*Spoon may vary", "*Results typical of Carol", "*Second spoon also has holes", "*Spoon not included", and a CAUTION SOUP sign that stays on the set after the demo.

**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 the broadcast itself, and it cuts between shots with a magenta and gold wipe. Ours are drawn in code:

| Story moment | In the window |
|---|---|
| Opening | The spoon spins on a marble turntable under two sweeping spotlights, with the set still dim. On "more than one", a heap of dull spoons crashes onto the floor |
| The reveal | The lights come up, the heap is swept off, a banner slams in with sparkles |
| Features | A starburst on each word while the spoon does the thing: stirs a tub, comes up with a scoop, swings around to point at A THING, then ALSO A THING |
| Testimonial | A gold oval portrait of Carol, five stars, eleven small spoons that fly off until one is left, DRAWER SPACE: YES |
| Before and after | A split screen of the same drawer twice. Only the noise meter differs |
| The rush | The spoon spins fast, and 400 LEFT! and 300 LEFT! slam in |
| Live demo | A bowl of soup slides onto the turntable and the spoon goes in |
| Low point | The camera pushes in, soup runs straight through five holes, each hole gets a red ring, then a hard cut to color bars and TECHNICAL DIFFICULTIES |
| The turn | A hard cut back to the set with every light on, plus a CAUTION SOUP sign |
| The offer | A second spoon drops in, and the two orbit on the turntable |
| Voiceless | A wall of 32 phones lights up row by row |
| The button | A deadpan operator in picture-in-picture, soup on the shirt. The spoon slides in and points at it |

The window also works with the user's own clip: play it inside the frame as the host or the product demo, and keep the panels and the call bar reacting around it. Cut to the bars card for the low point, because a card needs no footage.

## 5. The story

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

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

**The moves.** Map any topic onto these. The film sells something with total confidence, gets caught out live, and doesn't slow down. Ours is plainly a joke about a spoon.

| # | Move | What it does | Our line | On the set |
|---|------|--------------|----------|------------|
| 1 | The problem | The hook: a problem nobody has, said in deadly earnest | "Are you tired of owning more than one spoon?" | The spoon spins under spotlights, and a heap of dull spoons crashes down |
| 2 | The reveal | The product arrives at full volume | "Introducing the Everything Spoon!" | Lights slam up, banner, sparkles, ONLY ONE! |
| 3 | Features | Two real ones, then a silly one | "It stirs! It scoops! It points at things!" | One starburst on each word, and the spoon does each one |
| 4 | Testimonial | A satisfied customer with an unimpressive result | "Carol owned eleven spoons. Now Carol has drawer space." | CAROL, SATISFIED CUSTOMER. Spoons fly off |
| 5 | Before and after | The proof that proves nothing | "Before: a drawer. After: also a drawer, but quieter." | Two identical drawers, split screen |
| 6 | The rush | Pressure, as fast as the voice can go | "We're down to four hundred! Three hundred!" | The counter races, the frame jolts on each number |
| 7 | Live demo | The host's own idea, and the mistake | "Let's try it live, with soup!" | The bowl slides in. The panel reads ONE SPOON, ONE SOUP |
| 8 | Low point | The flaw, exposed on air | "It has holes. Why does it have holes?" | Soup runs through. The counter freezes, the lamps go dark, the call bar reads PLEASE HOLD, the set dims to TECHNICAL DIFFICULTIES. Music out; the studio hum keeps running |
| 9 | The turn | Nothing is solved, and it doesn't matter | "But wait." | BUT lands on the dead set, WAIT! slams, and every light comes back. Music returns on "wait" |
| 10 | The offer | Callback to line 1 | "Call now and we'll send a second spoon!" | A second spoon lands, ONE SPOON gets struck out, SECOND SPOON INCLUDED, burst: MORE THAN ONE! |
| 11 | The stampede | The voiceless moment, about 3 s | (music at its fullest, captions read CALL NOW) | The wall of phones lights up row by row, the number flashes, the counter falls to zero, ALL GONE |
| 12 | The button | The last laugh | "Operators are standing by. One of them has soup." | The deadpan operator, and the spoon points at the stain |
| 13 | Call to action | One action | "Comment spoon and I'll send you the skill that made this." | The call bar flips to COMMENT SPOON and stays until the end |

**For other topics:** a seven-step morning routine, a new meeting format, a side project's launch, your own app with its real flaw, a habit you keep trying to sell yourself on. It also works for a real product that can laugh at itself.

**The trick that makes it land is that the offer never fixes the flaw.** It adds more of the same thing, louder: the second spoon also has holes, and line 1's problem is now worse. Plant the flaw too. Our holes are on the spoon from the first frame, small enough to pass for decoration, so the viewer feels they could have seen it coming. Find the flaw first, then write backward from it.

**In a real ad, the counters and deals must be real.** The stock number, the deadline, the terms and the phone line all have to be true, or the film is a false claim and not a joke. If the user can't give you real figures, take the counter and the countdown off the screen, or make the film plainly a parody. Ours has a One-Item Channel and a spoon that points at things, so nobody can mistake it. Word every deal in the parody as an "offer" and show no figure. In a parody, use a phone number from the 555-01XX range, which is set aside for fiction, and never a number the user doesn't own. No real brands, and no real shopping channel's name or look.

Our film runs 37 s on the clock's estimate. For about 33 s, drop move 5: delete its line, its events, `DrawersShot` and the rows that read those events in `OFFER`, `TAB` and `SHOTS`. It's the one joke the story can lose. Don't drop move 7 or 8: everything after them depends on the demo going wrong.

## 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 draws one spoon's night on air. The panels and the machinery work for any product, and the shots are separate components, so replace them one at a time. `core.tsx` and `clock.mjs` shouldn't need to change.

| To change | Where |
|---|---|
| The words on the set | The top of the file: `KEYWORD`, `PHONE`, `PRODUCT`, `PRODUCT_LINE`, `CHANNEL`, `ITEM`, `LOGO`, `SIGN`, `UNIT`, `FINE_PRINT`, and `COPY` for every string in the shots |
| The offer panel | `OFFER`: what it says from each event on. `old` is the struck-out line. The strike itself is timed in `OfferPanel`, on `second` |
| The starburst and the window's tab | `BURST` and `TAB` |
| The stock counter | `STOCK` holds the numbers and `stockAt` runs them. In a real ad, these numbers must be real |
| The product | `Spoon` draws it, with `HANDLE` and `HOLES`. Redrawing this is the biggest job, along with the props below |
| What the product does | `POSES`: one row per move, with `REST` as its default. The stirring wiggle is in `poseAt`, and the scoop is `mound` and `ball` in `Studio` |
| The problem's heap | `Clutter`, with `CLUTTER` for where each spoon lands |
| The features | `FEATURE` for the three starbursts, `Tub`, `Plant`, `Stool` and the `Placard` rows in `Studio` |
| The live demo | `SoupBowl`, `Streams`, the red rings and the `Slam` rows in `Studio`, and `SoupSign` for the joke that stays |
| The other shots | `CarolShot`, `DrawersShot` (with `DrawerArt` and `Meter`), `BarsShot`, `PhonesShot` (with `PhoneIcon`, `rowAt`), `Pip` and `OperatorArt`. `SHOTS` says which one plays from which event |
| The second spoon | `two`, `drop` and `orbit` in `Studio` |
| Speeds and knocks | `spinAt` for the turntable, `flowAt` for the streaks and ticker, `chaseAt` for the bulbs, `HITS` for which events shake the frame, `camAt` for the push-in |
| When the set is dead | `isDead`, from `why` to `wait`. `dim` in `Stage` and the `CallBar` and `StockPanel` lamps read those same events |
| The turn | `ButWait`, on `but` and `wait`. `flash` and `rays` in `Stage` |
| The comment keyword | `plan.json` and `KEYWORD`, which drives the call bar and lights every use of the word in `CapRow` once the ending starts |

- **Scene ids in the code:** only one: `s11` in `QUIET_WORDS`, the voiceless caption. Its text is `QUIET_CAPTION`. Change its id if you renumber scenes.
- **What resizes itself:** `fit()` shrinks the type to the box for `OFFER`'s big lines, `BURST`, `PRODUCT`, `PHONE`, the keyword and `FEATURE`, so a longer word still fits.
- **What's sized to our words:** `COPY.who` at 92 px holds about 7 letters before it reaches the starburst. `COPY.card` at 76 px holds about 12 letters a line, `COPY.gone` about 8 and `COPY.plenty` about 6. The counter has three digit cells: change `padStart` for more. Check each against the contact sheet.
- **Our events that nothing draws on:** `on`, `one`, `product` and `standing` only give a sound somewhere to land. Delete them if you don't use them.

### The code

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

```json
{
 "lines": [
  {
   "id": "l01",
   "text": "Are you tired of owning more than one spoon?"
  },
  {
   "id": "l02",
   "text": "Introducing the Everything Spoon!"
  },
  {
   "id": "l03",
   "text": "It stirs! It scoops! It points at things!"
  },
  {
   "id": "l04",
   "text": "Carol owned eleven spoons. Now Carol has drawer space."
  },
  {
   "id": "l05",
   "text": "Before: a drawer. After: also a drawer, but quieter.",
   "say": "Before, a drawer. After, also a drawer, but quieter."
  },
  {
   "id": "l06",
   "text": "We're down to four hundred! Three hundred!"
  },
  {
   "id": "l07",
   "text": "Let's try it live, with soup!"
  },
  {
   "id": "l08",
   "text": "It has holes. Why does it have holes?"
  },
  {
   "id": "l09",
   "text": "But wait."
  },
  {
   "id": "l10",
   "text": "Call now and we'll send a second spoon!"
  },
  {
   "id": "l11",
   "text": "Operators are standing by. One of them has soup."
  },
  {
   "id": "l12",
   "text": "Comment spoon and I'll send you the skill that made this.",
   "say": "Comment, spoon, and I'll send you the skill that made this."
  }
 ],
 "scenes": [
  {
   "id": "s01",
   "lines": [
    "l01"
   ],
   "lead": 0.2,
   "tail": 0.05
  },
  {
   "id": "s02",
   "lines": [
    "l02"
   ],
   "lead": 0.03,
   "tail": 0.05
  },
  {
   "id": "s03",
   "lines": [
    "l03"
   ],
   "lead": 0.03,
   "tail": 0.05
  },
  {
   "id": "s04",
   "lines": [
    "l04"
   ],
   "lead": 0.05,
   "tail": 0.05
  },
  {
   "id": "s05",
   "lines": [
    "l05"
   ],
   "lead": 0.05,
   "tail": 0.05
  },
  {
   "id": "s06",
   "lines": [
    "l06"
   ],
   "lead": 0.05,
   "tail": 0.05
  },
  {
   "id": "s07",
   "lines": [
    "l07"
   ],
   "lead": 0.03,
   "tail": 0.05
  },
  {
   "id": "s08",
   "lines": [
    "l08"
   ],
   "lead": 0.05,
   "tail": 0.35
  },
  {
   "id": "s09",
   "lines": [
    "l09"
   ],
   "lead": 0.6,
   "tail": 0.25
  },
  {
   "id": "s10",
   "lines": [
    "l10"
   ],
   "lead": 0.03,
   "tail": 0.05
  },
  {
   "id": "s11",
   "sec": 3.2
  },
  {
   "id": "s12",
   "lines": [
    "l11"
   ],
   "lead": 0.05,
   "tail": 0.1
  },
  {
   "id": "s13",
   "lines": [
    "l12"
   ],
   "lead": 0.08,
   "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 { CallNow } from './frames/CallNow';
import spec from './spec.json';

export const Root: React.FC = () => (
  <Composition id="Film-9x16" component={CallNow} 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": "more",
   "scene": "s01",
   "word": "more"
  },
  {
   "id": "one",
   "scene": "s01",
   "word": "one"
  },
  {
   "id": "intro",
   "scene": "s02",
   "word": "Introducing"
  },
  {
   "id": "name",
   "scene": "s02",
   "word": "Everything"
  },
  {
   "id": "product",
   "scene": "s02",
   "word": "Spoon"
  },
  {
   "id": "stirs",
   "scene": "s03",
   "word": "stirs"
  },
  {
   "id": "scoops",
   "scene": "s03",
   "word": "scoops"
  },
  {
   "id": "points",
   "scene": "s03",
   "word": "points"
  },
  {
   "id": "things",
   "scene": "s03",
   "word": "things"
  },
  {
   "id": "carol",
   "scene": "s04"
  },
  {
   "id": "eleven",
   "scene": "s04",
   "word": "eleven"
  },
  {
   "id": "nowc",
   "scene": "s04",
   "word": "Now"
  },
  {
   "id": "drawer",
   "scene": "s04",
   "word": "drawer"
  },
  {
   "id": "space",
   "scene": "s04",
   "word": "space"
  },
  {
   "id": "before",
   "scene": "s05",
   "word": "Before"
  },
  {
   "id": "after",
   "scene": "s05",
   "word": "After"
  },
  {
   "id": "quieter",
   "scene": "s05",
   "word": "quieter"
  },
  {
   "id": "rush",
   "scene": "s06"
  },
  {
   "id": "four",
   "scene": "s06",
   "word": "four"
  },
  {
   "id": "three",
   "scene": "s06",
   "word": "Three"
  },
  {
   "id": "live",
   "scene": "s07"
  },
  {
   "id": "soup",
   "scene": "s07",
   "word": "soup"
  },
  {
   "id": "lift",
   "scene": "s08",
   "word": "It"
  },
  {
   "id": "holes",
   "scene": "s08",
   "word": "holes"
  },
  {
   "id": "why",
   "scene": "s08",
   "word": "Why"
  },
  {
   "id": "have",
   "scene": "s08",
   "word": "have"
  },
  {
   "id": "but",
   "scene": "s09",
   "word": "But"
  },
  {
   "id": "wait",
   "scene": "s09",
   "word": "wait"
  },
  {
   "id": "offer",
   "scene": "s10"
  },
  {
   "id": "second",
   "scene": "s10",
   "word": "second"
  },
  {
   "id": "included",
   "scene": "s10",
   "word": "spoon"
  },
  {
   "id": "quiet",
   "scene": "s11"
  },
  {
   "id": "zero",
   "scene": "s11",
   "off": 2.75
  },
  {
   "id": "ops",
   "scene": "s12",
   "word": "Operators"
  },
  {
   "id": "standing",
   "scene": "s12",
   "word": "standing"
  },
  {
   "id": "onethem",
   "scene": "s12",
   "word": "One"
  },
  {
   "id": "stain",
   "scene": "s12",
   "word": "soup"
  },
  {
   "id": "cta",
   "scene": "s13"
  }
 ]
}
```

`src/frames/CallNow.tsx`:

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

/* Call Now: a home-shopping channel selling one silly thing, live, forever. The set never leaves the screen: the window, the offer panel,
   the stock counter, the phone lamps and the call bar all react to the host's words. 1080 x 1920. Time comes from the spoken words through
   story.json, so a re-voice moves every slam with its word. */
const E = makeE(story as StoryJson);

/* ---------- the story's words: change these first ---------- */
const KEYWORD = 'SPOON';
const PHONE = '1-555-0142';
const PRODUCT = 'THE EVERYTHING SPOON', PRODUCT_LINE = 'As seen on this channel';
const CHANNEL = 'THE ONE-ITEM CHANNEL', ITEM = 'ITEM 1 OF 1', LOGO = '1';
const SIGN = 'SPOON'; // the vertical marquee at the right edge: texture, nothing has to be read there
const UNIT = 'SPOONS LEFT';
const COPY = {
  thingA: 'A THING', thingB: 'ALSO A THING', sign: ['CAUTION', 'SOUP'], why: 'WHY?', flaw: 'HOLES',
  who: 'CAROL', whoTag: 'SATISFIED CUSTOMER', owned: 'SPOONS OWNED:', result: 'DRAWER SPACE: YES',
  before: 'BEFORE', after: 'AFTER', beforeLine: 'A DRAWER', afterLine: 'ALSO A DRAWER', meter: 'NOISE', afterTag: '(quieter)',
  card: ['TECHNICAL', 'DIFFICULTIES'], cardLine: 'PLEASE STAND BY', cardJoke: 'We are looking into the holes',
  operator: 'OPERATOR 7', stain: 'SOUP', hold: 'PLEASE HOLD', gone: 'ALL GONE', wait: ['BUT', 'WAIT!'], skills: 'SKILLS LEFT', plenty: 'PLENTY',
};
/* The stock counter's story: where it starts, the two numbers the host shouts, where it freezes, and where it picks up after the turn. In a real ad these numbers must be real. */
const STOCK = { start: 999, near: 412, first: 400, second: 300, freeze: 287, after: 244 };
const FINE_PRINT = 'Not a fork.  Spoon may contain holes.  Offer void where soup is served.  Drawer not included.  Carol is not a spokesperson.  Do not stir what you cannot scoop.  ';

type Offer = { at: number; head: string; big: string[]; fine: string; old?: string };
const OFFER: Offer[] = [
  { at: 0, head: "TODAY'S OFFER", big: ['ONE', 'SPOON'], fine: '*Spoon may vary' },
  { at: E('carol'), head: "TODAY'S OFFER", big: ['ONE', 'SPOON'], fine: '*Results typical of Carol' },
  { at: E('before'), head: "TODAY'S OFFER", big: ['ONE', 'SPOON'], fine: '*Drawer not included' },
  { at: E('four'), head: 'HURRY', big: ['WHILE SPOONS', 'LAST'], fine: '*They are not lasting' },
  { at: E('live'), head: 'LIVE DEMO', big: ['ONE SPOON', 'ONE SOUP'], fine: '*Soup not included' },
  { at: E('why'), head: "TODAY'S OFFER", big: ['UNDER', 'REVIEW'], fine: '*Please stand by' },
  { at: E('wait'), head: "TODAY'S OFFER", big: ['ONE', 'SPOON'], fine: '*Call now' },
  { at: E('included') - 0.02, head: "TODAY'S OFFER", big: ['SECOND SPOON', 'INCLUDED'], fine: '*Second spoon also has holes', old: 'ONE SPOON' },
  { at: E('cta'), head: "TODAY'S OFFER", big: ['THE SKILL', 'INCLUDED'], fine: '*Spoon not included', old: 'SPOON' },
];
const BURST: { at: number; lines: string[] }[] = [
  { at: 0, lines: ['NEW!'] },
  { at: E('name'), lines: ['ONLY', 'ONE!'] },
  { at: E('four'), lines: ['HURRY!'] },
  { at: E('live'), lines: ['LIVE!'] },
  { at: E('included'), lines: ['MORE', 'THAN', 'ONE!'] },
  { at: E('quiet'), lines: ['CALL!'] },
  { at: E('cta'), lines: ['COMMENT!'] },
];
const TAB: { at: number; s: string }[] = [
  { at: 0, s: 'TONIGHT ONLY' },
  { at: E('more'), s: 'TOO MANY SPOONS' },
  { at: E('intro'), s: 'PRESENTING' },
  { at: E('name'), s: 'THE EVERYTHING SPOON' },
  { at: E('carol'), s: 'ONE REAL CAROL' },
  { at: E('before') - 0.06, s: '' },
  { at: E('rush'), s: 'SELLING FAST' },
  { at: E('live'), s: 'LIVE DEMO' },
  { at: E('why'), s: 'LIVE DEMO: HOLES' },
  { at: E('have') + 0.25, s: '' },
  { at: E('wait'), s: 'AND WE ARE BACK' },
  { at: E('included'), s: 'NOW WITH TWO' },
  { at: E('quiet'), s: 'ALL LINES OPEN' },
  { at: E('ops'), s: 'STANDING BY' },
  { at: E('cta'), s: '' },
];
const pick = <T extends { at: number }>(list: T[], t: number): T => list.reduce((a, x) => (t >= x.at ? x : a), list[0]);

/* ---------- the look ---------- */
const STUDIO = '#1b2a8c', PANEL = '#0f1a5c', HOT = '#ff2e88', GOLD = '#ffc93c', SALE = '#e4002b', CAP = '#ffe14d', ICE = '#cfd8ff', DEEP = '#0a1040', DARKGOLD = '#9c6a00', SOUP = '#e8862e';
const BLACK = '"Archivo Black", sans-serif', NARROW = '"Archivo Narrow", sans-serif';
const FACES = [
  { family: 'Archivo Black', file: 'archivo-black-latin-400-normal.woff2' },
  { family: 'Archivo Narrow', file: 'archivo-narrow-latin-700-italic.woff2', weight: '700', style: 'italic' },
];
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
/** The biggest type that fits these lines in a box. Archivo Black runs about 0.76 em a letter in capitals, so a longer word shrinks instead of overflowing. */
const fit = (lines: string[], w: number, h: number, max: number) => Math.min(max, w / (Math.max(...lines.map((l) => l.length)) * 0.76), h / (lines.length * 1.02));

/* ---------- what the set is doing: every part below reads these ---------- */
const FPS = 30;
const integrate = (speed: (t: number) => number) => {
  const n = Math.ceil(DURATION * FPS) + 2;
  const out = [0];
  for (let i = 1; i < n; i++) out.push(out[i - 1] + speed((i - 1) / FPS) / FPS);
  return (t: number) => out[clamp(Math.round(t * FPS), 0, n - 1)];
};
const isDead = (t: number) => t >= E('why') && t < E('wait');
const spinAt = integrate((t) => {
  if (t < E('intro')) return 1.5;
  if (t < E('rush')) return 2.5;
  if (t < E('live')) return lerp(2.5, 10, ramp(t, E('rush'), E('four')));
  if (t < E('wait')) return lerp(10, 0, ramp(t, E('live'), E('live') + 0.3));
  return 2.6;
});
const flowAt = integrate((t) => {
  if (isDead(t)) return 0;
  if (t < E('rush')) return 90;
  if (t < E('why')) return lerp(90, 340, ramp(t, E('rush'), E('four')));
  if (t < E('quiet')) return 180;
  if (t < E('ops')) return 460;
  return 120;
});
const chaseAt = integrate((t) => {
  if (isDead(t)) return 0;
  if (t < E('intro')) return 3;
  if (t < E('rush')) return 7;
  if (t < E('wait')) return 15;
  if (t < E('quiet')) return 10;
  if (t < E('ops')) return 24;
  return 7;
});
const clockAt = integrate((t) => (isDead(t) ? 0 : 1));
/** A marquee bulb, 0 to 1: chases while the set is live, and all but dead in the low point. */
const bulb = (t: number, i: number) => {
  if (isDead(t)) return t < E('why') + 0.3 && hash(i + Math.floor(t * 18)) > 0.6 ? 0.6 : i % 11 === 0 ? 0.14 : 0.03;
  if (t < E('intro')) return (Math.floor(chaseAt(t)) + i) % 4 === 0 ? 0.8 : 0.16;
  return (Math.floor(chaseAt(t)) + i) % 3 === 0 ? 1 : 0.28;
};
/** The stock counter. It always moves, except when the set dies. */
const stockAt = (t: number) => {
  if (t < E('four')) return Math.round(lerp(STOCK.start, STOCK.near, ramp(t, 0, E('four'))));
  if (t < E('three')) return Math.round(lerp(STOCK.first, STOCK.second, easeIn(ramp(t, E('four') + 0.3, E('three')))));
  if (t < E('why')) return Math.round(lerp(STOCK.second, STOCK.freeze, ramp(t, E('three'), E('why'))));
  if (t < E('wait')) return STOCK.freeze;
  if (t < E('quiet')) return Math.round(lerp(STOCK.freeze, STOCK.after, ramp(t, E('wait'), E('quiet'))));
  return Math.round(lerp(STOCK.after, 0, easeInOut(ramp(t, E('quiet') + 0.1, E('zero')))));
};
/* Everything hits the same set: a slam knocks the whole frame, and BUT WAIT knocks it hard. */
const HITS: [number, number][] = ([['name', 7], ['stirs', 3], ['scoops', 3], ['points', 3], ['four', 8], ['three', 9], ['holes', 3], ['but', 5], ['wait', 30], ['included', 9], ['quiet', 5], ['zero', 12], ['stain', 3], ['cta', 6]] as [string, number][]).map(([e, a]) => [E(e), a]);
const shake = (t: number) => HITS.reduce((acc, [e, a]) => (t >= e ? acc + a * Math.sin((t - e) * 58) * Math.exp(-(t - e) * 11) : acc), 0);

/* ---------- the spoon: one pose list runs it, so every gag is one row ---------- */
type Pose = { x: number; y: number; rot: number; s: number; spin: number };
const REST: Pose = { x: 500, y: 226, rot: 0, s: 1, spin: 1 };
const POSES: { at: number; d: number; p: Partial<Pose> }[] = [
  { at: E('stirs') - 0.14, d: 0.16, p: { y: 262, rot: 197, spin: 0 } },
  { at: E('scoops') - 0.1, d: 0.2, p: { y: 212, rot: 360 } },
  { at: E('points') - 0.1, d: 0.18, p: { x: 530, y: 214, rot: 270 } },
  { at: E('things') - 0.08, d: 0.16, p: { x: 470, rot: 450 } },
  { at: E('carol') + 0.3, d: 0.01, p: { ...REST, rot: 720 } },
  { at: E('live') + 0.05, d: 0.25, p: { y: 196, spin: 0 } },
  { at: E('soup') - 0.25, d: 0.3, p: { y: 292, rot: 900 } },
  { at: E('lift') - 0.02, d: 0.42, p: { y: 100, s: 1.25 } },
  { at: E('wait'), d: 0.01, p: { ...REST, rot: 1080 } },
];
const poseAt = (t: number): Pose => {
  let v = REST;
  for (const k of POSES) {
    if (t < k.at) break;
    const q = easeInOut(ramp(t, k.at, k.at + k.d));
    v = { x: lerp(v.x, k.p.x ?? v.x, q), y: lerp(v.y, k.p.y ?? v.y, q), rot: lerp(v.rot, k.p.rot ?? v.rot, q), s: lerp(v.s, k.p.s ?? v.s, q), spin: lerp(v.spin, k.p.spin ?? v.spin, q) };
  }
  const stir = ramp(t, E('stirs') - 0.02, E('stirs') + 0.05) * (1 - ramp(t, E('scoops') - 0.12, E('scoops') - 0.06));
  return { ...v, x: v.x + Math.sin(t * 19) * 26 * stir, y: v.y + Math.cos(t * 19) * 7 * stir, rot: v.rot + Math.sin(t * 19) * 6 * stir };
};
/* The five holes. They are on the spoon from the first frame, small enough to pass for decoration, so the low point pays off something already on screen. */
const HOLES: [number, number][] = [[0, -118], [-24, -142], [24, -142], [-24, -94], [24, -94]];
const HANDLE = 'M -11 -58 C -9 -30 -8 20 -12 90 C -15 135 -24 158 -17 174 C -11 186 11 186 17 174 C 24 158 15 135 12 90 C 8 20 9 -30 11 -58 Z';

const Spoon: React.FC<{ x: number; y: number; rot?: number; s?: number; wide?: number; soup?: number; mound?: number }> = ({ x, y, rot = 0, s = 1, wide = 1, soup = 0, mound = 0 }) => (
  <g transform={`translate(${x} ${y}) rotate(${rot}) scale(${s * wide} ${s})`}>
    <path d={HANDLE} fill="url(#chrome)" stroke="#59627f" strokeWidth={1.5} />
    <rect x={-13} y={-48} width={26} height={11} rx={3} fill="url(#goldV)" />
    <circle cx={0} cy={158} r={8} fill={HOT} stroke={GOLD} strokeWidth={3} />
    <ellipse cx={0} cy={-118} rx={62} ry={72} fill="url(#chrome)" stroke="#59627f" strokeWidth={1.5} />
    <ellipse cx={0} cy={-116} rx={51} ry={61} fill="url(#bowlIn)" />
    {soup > 0 ? <ellipse cx={0} cy={-116} rx={51 * (0.45 + 0.55 * soup)} ry={61 * (0.3 + 0.7 * soup)} fill="url(#soupG)" opacity={Math.min(1, soup * 3)} /> : null}
    <path d="M -36 -148 C -30 -170 -8 -176 8 -172 C -10 -166 -26 -158 -30 -134 Z" fill="#fff" opacity={0.75} />
    {HOLES.map(([a, b], i) => <g key={i}><circle cx={a} cy={b} r={6.5} fill={DEEP} /><path d={`M ${a - 5} ${b + 3} A 6 6 0 0 0 ${a + 5} ${b + 3}`} fill="none" stroke="#fff" strokeOpacity={0.6} strokeWidth={1.4} /></g>)}
    {mound > 0 ? (
      <g transform={`translate(0 -128) scale(${mound})`}>
        <circle cx={0} cy={0} r={54} fill="#ff9ac6" /><circle cx={-16} cy={-18} r={18} fill="#fff" opacity={0.45} />
        {Array.from({ length: 9 }, (_, i) => <rect key={i} x={hash(i + 2) * 70 - 35} y={hash(i + 12) * 70 - 35} width={11} height={4} rx={2} fill={[GOLD, '#fff', HOT][i % 3]} transform={`rotate(${hash(i + 22) * 180} ${hash(i + 2) * 70 - 35} ${hash(i + 12) * 70 - 35})`} />)}
      </g>
    ) : null}
  </g>
);

const Bulb: React.FC<{ x: number; y: number; r: number; on: number }> = ({ x, y, r, on }) => (
  <g>
    <circle cx={x} cy={y} r={r * 2.3} fill="#ffe9a0" opacity={on * 0.28} />
    <circle cx={x} cy={y} r={r} fill="#4a3a14" />
    <circle cx={x} cy={y} r={r} fill="url(#bulbG)" opacity={on} />
  </g>
);
const Star4: React.FC<{ x: number; y: number; r: number; fill?: string; opacity?: number }> = ({ x, y, r, fill = '#fff', opacity = 1 }) => {
  const q = r * 0.16;
  return <path d={`M ${x} ${y - r} L ${x + q} ${y - q} L ${x + r} ${y} L ${x + q} ${y + q} L ${x} ${y + r} L ${x - q} ${y + q} L ${x - r} ${y} L ${x - q} ${y - q} Z`} fill={fill} opacity={opacity} />;
};
const Sparkles: React.FC<{ at: number; n?: number; seed?: number; box?: [number, number, number, number] }> = ({ at, n = 16, seed = 0, box = [40, 40, 960, 460] }) => {
  const t = useT();
  if (t < at || t > at + 1.2) return null;
  return <>{Array.from({ length: n }, (_, i) => {
    const t0 = at + hash(i + seed) * 0.5, k = ramp(t, t0, t0 + 0.55);
    if (k <= 0 || k >= 1) return null;
    return <Star4 key={i} x={lerp(box[0], box[2], hash(i + seed + 40))} y={lerp(box[1], box[3], hash(i + seed + 80))} r={(16 + hash(i + seed + 120) * 34) * Math.sin(Math.PI * k)} fill={i % 3 ? '#fff' : CAP} />;
  })}</>;
};
/** The gold defs every window drawing shares. */
const Defs: React.FC = () => (
  <svg width={0} height={0} style={{ position: 'absolute' }}>
    <defs>
      <linearGradient id="chrome" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stopColor="#7f89ab" /><stop offset="0.28" stopColor="#f6f8ff" /><stop offset="0.46" stopColor="#ffffff" /><stop offset="0.68" stopColor="#a7b0cc" /><stop offset="1" stopColor="#68718f" /></linearGradient>
      <radialGradient id="bowlIn" cx="0.38" cy="0.32" r="0.85"><stop offset="0" stopColor="#ffffff" /><stop offset="0.5" stopColor="#b6bfd9" /><stop offset="1" stopColor="#555e7c" /></radialGradient>
      <radialGradient id="soupG" cx="0.4" cy="0.35" r="0.8"><stop offset="0" stopColor="#ffc27a" /><stop offset="0.6" stopColor={SOUP} /><stop offset="1" stopColor="#b85a14" /></radialGradient>
      <linearGradient id="goldV" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#fff0a8" /><stop offset="0.45" stopColor={GOLD} /><stop offset="1" stopColor="#a8700a" /></linearGradient>
      <radialGradient id="bulbG"><stop offset="0" stopColor="#ffffff" /><stop offset="0.5" stopColor="#ffec9c" /><stop offset="1" stopColor={GOLD} /></radialGradient>
      <linearGradient id="cone" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#fff8d8" stopOpacity="0.5" /><stop offset="1" stopColor="#fff8d8" stopOpacity="0.02" /></linearGradient>
      <radialGradient id="stage" cx="0.5" cy="0.55" r="0.75"><stop offset="0" stopColor="#4a5ff0" /><stop offset="0.55" stopColor="#2030a8" /><stop offset="1" stopColor="#101a70" /></radialGradient>
      <linearGradient id="floor" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#141f78" /><stop offset="1" stopColor="#060a33" /></linearGradient>
      <radialGradient id="marble" cx="0.45" cy="0.35" r="0.8"><stop offset="0" stopColor="#ffffff" /><stop offset="0.6" stopColor="#e2e5f6" /><stop offset="1" stopColor="#aab1d8" /></radialGradient>
      <linearGradient id="wood" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="#c48a48" /><stop offset="0.5" stopColor="#a56f35" /><stop offset="1" stopColor="#8a5a28" /></linearGradient>
      <linearGradient id="ceramic" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stopColor="#c6cbe6" /><stop offset="0.4" stopColor="#ffffff" /><stop offset="1" stopColor="#a7aed2" /></linearGradient>
      <clipPath id="carolClip"><ellipse cx={205} cy={305} rx={160} ry={222} /></clipPath>
    </defs>
  </svg>
);

/* ---------- the window: the studio ---------- */
const TT = { cx: 500, cy: 420, rx: 310, ry: 60, h: 34 };

/* The camera in the window: it stays wide, and pushes in on the spoon for the low point. */
const camAt = (t: number) => {
  const k = t < E('wait') ? easeInOut(ramp(t, E('lift'), E('holes') + 0.1)) : 0;
  const s = lerp(1, 1.6, k), fx = 500, fy = 300;
  return { s, tx: fx * (1 - s), ty: fy * (1 - s) };
};

const Turntable: React.FC<{ a: number; t: number }> = ({ a, t }) => {
  const { cx, cy, rx, ry, h } = TT;
  return (
    <g>
      <ellipse cx={cx} cy={cy + h + 26} rx={rx + 60} ry={ry + 16} fill="#000" opacity={0.35} />
      <path d={`M ${cx - rx} ${cy} A ${rx} ${ry} 0 0 0 ${cx + rx} ${cy} L ${cx + rx} ${cy + h} A ${rx} ${ry} 0 0 1 ${cx - rx} ${cy + h} Z`} fill="url(#goldV)" stroke={DARKGOLD} strokeWidth={2} />
      <ellipse cx={cx} cy={cy} rx={rx} ry={ry} fill="url(#marble)" stroke={DARKGOLD} strokeWidth={3} />
      {Array.from({ length: 12 }, (_, i) => {
        const q = a + (i * Math.PI) / 6, c = Math.cos(q), n = Math.sin(q);
        return <line key={i} x1={cx + c * rx * 0.72} y1={cy + n * ry * 0.72} x2={cx + c * rx * 0.94} y2={cy + n * ry * 0.94} stroke={DARKGOLD} strokeWidth={3} strokeLinecap="round" opacity={0.55} />;
      })}
      <ellipse cx={cx} cy={cy} rx={rx * 0.62} ry={ry * 0.62} fill="none" stroke={DARKGOLD} strokeWidth={2} opacity={0.35} />
      {Array.from({ length: 21 }, (_, i) => {
        const q = (i / 20) * Math.PI;
        return <Bulb key={i} x={cx + Math.cos(q) * rx} y={cy + Math.sin(q) * ry + h * 0.56} r={6} on={bulb(t, i)} />;
      })}
    </g>
  );
};

const StudioSet: React.FC<{ a: number; t: number; light: number }> = ({ a, t, light }) => {
  const sway = Math.sin(t * 1.15), sway2 = Math.sin(t * 0.9 + 2);
  const tx1 = lerp(500 + 220 * sway, 500 + 34 * sway, light), tx2 = lerp(500 - 240 * sway2, 500 - 34 * sway2, light);
  return (
    <>
      <rect x={0} y={0} width={1000} height={600} fill="url(#stage)" />
      <g transform={`rotate(${a * 1.6 * 2} 500 300)`}>
        {Array.from({ length: 16 }, (_, i) => { const q0 = (i / 16) * Math.PI * 2, q1 = q0 + Math.PI / 16; return <polygon key={i} points={`500,300 ${500 + Math.cos(q0) * 900},${300 + Math.sin(q0) * 900} ${500 + Math.cos(q1) * 900},${300 + Math.sin(q1) * 900}`} fill="#8fb0ff" opacity={0.13} />; })}
      </g>
      <path d="M 26 410 A 474 396 0 0 1 974 410" fill="none" stroke="url(#goldV)" strokeWidth={22} />
      <path d="M 26 410 A 474 396 0 0 1 974 410" fill="none" stroke={DARKGOLD} strokeWidth={2} />
      {Array.from({ length: 27 }, (_, i) => { const q = Math.PI + (i / 26) * Math.PI; return <Bulb key={i} x={500 + Math.cos(q) * 474} y={410 + Math.sin(q) * 396} r={6.5} on={bulb(t, i + 3)} />; })}
      <rect x={0} y={402} width={1000} height={198} fill="url(#floor)" />
      <rect x={0} y={400} width={1000} height={4} fill={GOLD} opacity={0.5} />
      <rect x={0} y={0} width={1000} height={600} fill="#050824" opacity={(1 - light) * 0.62} />
      <polygon points={`150,-40 200,-40 ${tx1 + 130},${TT.cy + 10} ${tx1 - 130},${TT.cy + 10}`} fill="url(#cone)" opacity={0.55 + 0.35 * light} />
      <polygon points={`800,-40 850,-40 ${tx2 + 130},${TT.cy + 10} ${tx2 - 130},${TT.cy + 10}`} fill="url(#cone)" opacity={0.55 + 0.35 * light} />
    </>
  );
};

const Tub: React.FC<{ k: number }> = ({ k }) => (
  <g transform={`translate(500 ${lerp(470, 360, k)}) scale(${Math.max(0.01, k)})`} opacity={k > 0.02 ? 1 : 0}>
    <path d="M -86 0 L 86 0 L 68 84 L -68 84 Z" fill="#fff" stroke={HOT} strokeWidth={4} />
    {[-48, -16, 16, 48].map((x) => <path key={x} d={`M ${x - 9} 3 L ${x + 9} 3 L ${x * 0.8 + 7} 82 L ${x * 0.8 - 7} 82 Z`} fill={HOT} opacity={0.75} />)}
    <ellipse cx={0} cy={0} rx={86} ry={19} fill="#ffc2dd" stroke={HOT} strokeWidth={4} />
  </g>
);

const Plant: React.FC = () => (
  <g>
    {[-34, -16, 2, 18, 34].map((a, i) => <path key={i} d={`M 0 0 C ${a * 0.4} -40 ${a} -70 ${a * 1.6} ${-92 + Math.abs(a)}`} fill="none" stroke="#3fa35b" strokeWidth={13} strokeLinecap="round" />)}
    <path d="M -34 -2 L 34 -2 L 26 50 L -26 50 Z" fill="#c9673b" stroke="#7d3c1f" strokeWidth={3} />
    <rect x={-38} y={-10} width={76} height={13} rx={3} fill="#d9774a" stroke="#7d3c1f" strokeWidth={3} />
  </g>
);
const Stool: React.FC = () => (
  <g fill="none" stroke="#c48a48" strokeWidth={10} strokeLinecap="round">
    <line x1={-32} y1={-6} x2={-42} y2={56} /><line x1={32} y1={-6} x2={42} y2={56} /><line x1={-37} y1={28} x2={37} y2={28} strokeWidth={7} />
    <ellipse cx={0} cy={-14} rx={50} ry={13} fill="#8a5a28" stroke="#5f3c16" strokeWidth={3} />
  </g>
);

const SoupBowl: React.FC<{ x: number; t: number; drained: number }> = ({ x, t, drained }) => (
  <g>
    {[-52, 6, 58].map((d, i) => {
      const w = Math.sin(t * 2.6 + i * 2) * 11;
      return <path key={i} d={`M ${x + d} 384 C ${x + d - 18 + w} 350 ${x + d + 18 - w} 322 ${x + d + w} 286`} fill="none" stroke="#fff" strokeWidth={7} strokeLinecap="round" opacity={0.22 * (1 - drained * 0.5)} />;
    })}
    <path d={`M ${x - 132} 402 C ${x - 124} 478 ${x + 124} 478 ${x + 132} 402 Z`} fill="url(#ceramic)" stroke="#8d94bb" strokeWidth={2} />
    <ellipse cx={x} cy={402} rx={132} ry={27} fill="#f4f6ff" stroke={GOLD} strokeWidth={5} />
    <ellipse cx={x} cy={404} rx={116} ry={20} fill="url(#soupG)" />
  </g>
);

/* The soup going straight through the spoon: one stream per hole, with its splash. */
const Streams: React.FC<{ p: Pose; t: number }> = ({ p, t }) => {
  const k = ramp(t, E('holes') - 0.08, E('holes') + 0.06);
  if (k <= 0) return null;
  return (
    <g>
      {k >= 1 ? Array.from({ length: 12 }, (_, i) => {
        const q = (t * (1.5 + hash(i) * 0.9) + hash(i + 9)) % 1, side = (hash(i + 20) - 0.5) * 2;
        return <circle key={i} cx={500 + side * (30 + 210 * q)} cy={404 - 4 * q * (1 - q) * (30 + hash(i + 30) * 70)} r={3 + hash(i + 40) * 3.5} fill={SOUP} />;
      }) : null}
      {HOLES.map(([a, b], i) => {
        const X = p.x - a * p.s, Y = p.y - b * p.s, end = lerp(Y, 404, k);
        const w = Math.sin(t * 11 + i * 1.7) * 7, mid = (Y + end) / 2;
        const d = `M ${X} ${Y} C ${X + w} ${mid} ${X - w} ${mid} ${X} ${end}`;
        return (
          <g key={i}>
            <path d={d} fill="none" stroke={SOUP} strokeWidth={9} strokeLinecap="round" />
            <path d={d} fill="none" stroke="#ffd9a0" strokeWidth={2.6} strokeLinecap="round" strokeDasharray="16 22" strokeDashoffset={-t * 330} />
            {k >= 1 ? [0, 1].map((q) => { const rk = ((t * 2.4 + i * 0.37 + q * 0.5) % 1); return <ellipse key={q} cx={X} cy={405} rx={10 + rk * 34} ry={3 + rk * 7} fill="none" stroke="#ffd9a0" strokeWidth={2.4} opacity={(1 - rk) * 0.8} />; }) : null}
          </g>
        );
      })}
    </g>
  );
};

/* The small joke that stays: after the soup, the set has a warning sign. */
const SoupSign: React.FC = () => (
  <div style={{ position: 'absolute', left: 806, top: 392, width: 146, height: 176, background: `linear-gradient(${CAP},${GOLD})`, clipPath: 'polygon(18% 0,82% 0,100% 100%,0 100%)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: PANEL, font: `26px/1.02 ${BLACK}`, textAlign: 'center', paddingTop: 18 }}>
    <div style={{ fontSize: 22, color: SALE }}>{COPY.sign[0]}</div>{COPY.sign[1]}
  </div>
);

/* Line one's problem, in the picture: on "more than one", every spoon you already own lands on the set, and the reveal sweeps them off. */
const CLUTTER: [number, number, number][] = [[96, 500, 76], [205, 548, -62], [322, 566, 98], [438, 574, -84], [566, 572, 70], [684, 566, -98], [800, 546, 64], [905, 498, -74], [150, 446, 112], [850, 440, -116]];
const Clutter: React.FC<{ t: number }> = ({ t }) => (
  <g style={{ filter: 'saturate(.3) brightness(.72)' }}>
    {CLUTTER.map(([x, y, rot], i) => {
      const at = E('more') + i * 0.045, k = ramp(t, at, at + 0.2);
      if (k <= 0) return null;
      const bounce = t >= at + 0.2 ? Math.abs(Math.sin((t - at - 0.2) * 16)) * 20 * Math.exp(-(t - at - 0.2) * 9) : 0;
      const gone = easeIn(ramp(t, E('intro') + i * 0.012, E('intro') + i * 0.012 + 0.24));
      return <Spoon key={i} x={x + gone * (x < 500 ? -700 : 700)} y={y - easeIn(1 - k) * 760 - bounce} rot={rot + (1 - k) * 90 + gone * 120} s={0.56} />;
    })}
  </g>
);

const Studio: React.FC = () => {
  const t = useT();
  const p = poseAt(t);
  const a = spinAt(t);
  const light = Math.max(ramp(t, E('intro'), E('intro') + 0.14), t >= E('wait') ? 1 : 0);
  const cam = camAt(t);
  const liveOn = t >= E('live') && t < E('wait');
  const bowlX = lerp(1260, 500, back(ramp(t, E('live') + 0.2, E('live') + 0.72)));
  const wide = (q: number) => lerp(1, 0.12 + 0.88 * Math.abs(Math.cos(q)), p.spin);
  const tub = ramp(t, E('stirs') - 0.24, E('stirs') - 0.06) * (1 - ramp(t, E('points') - 0.05, E('points') + 0.12));
  const mound = ramp(t, E('scoops') - 0.04, E('scoops') + 0.06) * (t < E('points') - 0.08 ? 1 : 0);
  const ball = ramp(t, E('points') - 0.08, E('points') + 0.55);
  const soup = t >= E('soup') && t < E('holes') + 0.45 ? 1 - ramp(t, E('holes'), E('holes') + 0.45) : 0;
  // the second spoon lands, then the two orbit on the turntable, closer spoon on top
  const two = t >= E('second') - 0.05;
  const drop = ramp(t, E('second') - 0.05, E('second') + 0.18);
  const orbit = easeOut(ramp(t, E('second'), E('second') + 0.4));
  const A = a * 0.42, ox = Math.cos(A) * 150 * orbit, oy = Math.sin(A) * 22 * orbit, os = Math.sin(A) * 0.06 * orbit;
  const spoonA = <Spoon x={p.x + ox} y={p.y + oy} rot={p.rot} s={p.s * (1 + os)} wide={wide(a)} soup={soup} mound={mound} />;
  const spoonB = two ? <Spoon x={500 - ox} y={REST.y - oy - easeIn(1 - drop) * 560} s={REST.s * (1 - os)} wide={wide(a + 1.1)} /> : null;
  const holders = (x: number, y: number, s: number) => <g transform={`translate(${x} ${y}) scale(${s})`}><path d="M -26 -4 L 26 -4 L 36 22 L -36 22 Z" fill="url(#goldV)" stroke={DARKGOLD} strokeWidth={2} /><ellipse cx={0} cy={-4} rx={26} ry={7} fill="#8a5a10" /></g>;
  const rush = t >= E('four') && t < E('live');
  const rushN = t >= E('three') ? STOCK.second : STOCK.first;
  const rushAt = t >= E('three') ? E('three') : E('four');
  const rk = back(ramp(t, rushAt, rushAt + 0.14));
  return (
    <div style={{ position: 'absolute', left: 0, top: 0, width: 1000, height: 600, overflow: 'hidden' }}>
      <div style={{ position: 'absolute', left: 0, top: 0, width: 1000, height: 600, transformOrigin: '0 0', transform: `translate(${cam.tx}px, ${cam.ty}px) scale(${cam.s})` }}>
        <svg viewBox="0 0 1000 600" width={1000} height={600} style={{ position: 'absolute', left: 0, top: 0, display: 'block' }}>
          <StudioSet a={a} t={t} light={light} />
          <Turntable a={a} t={t} />
          {!liveOn ? holders(500 + ox, TT.cy - 16 + oy, 1 + os) : null}
          {two ? holders(500 - ox, TT.cy - 16 - oy, 1 - os) : null}
          <g opacity={tub > 0 ? 1 : 0}><Tub k={tub} /></g>
          {liveOn ? <SoupBowl x={bowlX} t={t} drained={ramp(t, E('holes'), E('have'))} /> : null}
          {t >= E('points') - 0.05 && t < E('carol') + 0.3 ? <g transform={`translate(162 ${lerp(470, 352, back(ramp(t, E('points') - 0.05, E('points') + 0.12)))})`}><Plant /></g> : null}
          {t >= E('things') - 0.05 && t < E('carol') + 0.3 ? <g transform={`translate(838 ${lerp(470, 358, back(ramp(t, E('things') - 0.05, E('things') + 0.1)))})`}><Stool /></g> : null}
          {t >= E('more') && t < E('intro') + 0.4 ? <Clutter t={t} /> : null}
          {oy >= 0 ? <>{spoonB}{spoonA}</> : <>{spoonA}{spoonB}</>}
          {liveOn ? <Streams p={p} t={t} /> : null}
          {ball > 0 && ball < 1 ? <g transform={`translate(${390 - ball * 240} ${150 + 720 * ball * ball})`}><circle r={40} fill="#ff9ac6" /><circle cx={-12} cy={-14} r={13} fill="#fff" opacity={0.45} /></g> : null}
          {/* the callouts: each hole gets a red ring */}
          {t >= E('why') && t < E('have') + 0.3 ? HOLES.map(([x, y], i) => {
            const k = back(ramp(t, E('why') + i * 0.08, E('why') + i * 0.08 + 0.13));
            return <circle key={i} cx={p.x - x * p.s} cy={p.y - y * p.s} r={Math.max(0.1, 15 * k)} fill="none" stroke={SALE} strokeWidth={4.5} />;
          }) : null}
          <Sparkles at={E('name')} n={18} seed={3} />
          <Sparkles at={E('second')} n={12} seed={40} box={[220, 60, 780, 400]} />
          <Sparkles at={E('wait')} n={18} seed={70} />
          {/* a glint on the bowl of the spoon, all the time */}
          {!liveOn && t % 1.3 < 0.28 && p.spin > 0.5 ? <Star4 x={p.x + ox - 30} y={p.y + oy - 138 * p.s} r={30 * Math.sin((Math.PI * (t % 1.3)) / 0.28)} /> : null}
        </svg>
        {t >= E('points') - 0.05 && t < E('carol') + 0.3 ? <Placard x={92} y={218} at={E('points')} s={COPY.thingA} /> : null}
        {t >= E('things') - 0.05 && t < E('carol') + 0.3 ? <Placard x={742} y={272} at={E('things')} s={COPY.thingB} /> : null}
        {t >= E('wait') ? <SoupSign /> : null}
      </div>
      {t >= E('why') && t < E('have') + 0.3 ? (
        <>
          <Slam at={E('why')} x={40} y={190} rot={-6} style={{ font: `96px/1 ${BLACK}`, color: CAP, WebkitTextStroke: `10px ${PANEL}`, paintOrder: 'stroke fill' }}>{COPY.why}</Slam>
          <Slam at={E('why') + 0.4} x={680} y={250} rot={5} style={{ font: `46px/1 ${BLACK}`, color: '#fff', background: SALE, padding: '12px 18px', borderRadius: 8 }}>{COPY.flaw}: {HOLES.length}</Slam>
        </>
      ) : null}
      {rush ? <div style={{ position: 'absolute', left: 40, top: 440, transform: `rotate(-5deg) scale(${lerp(1.6, 1, rk)})`, transformOrigin: '0 100%', font: `124px/1 ${BLACK}`, color: CAP, WebkitTextStroke: `12px ${SALE}`, paintOrder: 'stroke fill', whiteSpace: 'nowrap' }}>{rushN} LEFT!</div> : null}
    </div>
  );
};

const Placard: React.FC<{ x: number; y: number; at: number; s: string }> = ({ x, y, at, s }) => {
  const t = useT();
  return <div style={{ position: 'absolute', left: x, top: y, background: PANEL, boxShadow: `inset 0 0 0 3px ${GOLD}`, color: GOLD, font: `italic 700 36px/1 ${NARROW}`, padding: '8px 16px 9px', whiteSpace: 'nowrap', borderRadius: 6, transform: `scale(${lerp(0.4, 1, back(ramp(t, at, at + 0.14)))})` }}>{s}</div>;
};
/** Anything that lands: big, then settled. */
const Slam: React.FC<{ at: number; x: number; y: number; rot?: number; d?: number; style?: React.CSSProperties; children: React.ReactNode }> = ({ at, x, y, rot = 0, d = 0.14, style, children }) => {
  const t = useT();
  if (t < at) return null;
  return <div style={{ position: 'absolute', left: x, top: y, whiteSpace: 'nowrap', transform: `rotate(${rot}deg) scale(${lerp(1.9, 1, back(ramp(t, at, at + d)))})`, opacity: ramp(t, at, at + 0.03), ...style }}>{children}</div>;
};

/* Reveal banner and feature bursts sit on top of the studio shot. */
const burstPts = (R: number, r: number, n: number) => Array.from({ length: n * 2 }, (_, i) => { const a = (i / (n * 2)) * Math.PI * 2, q = i % 2 ? r : R; return `${q * Math.cos(a)},${q * Math.sin(a)}`; }).join(' ');
const FEATURE: { at: string; s: string; x: number }[] = [
  { at: 'stirs', s: 'STIRS!', x: 134 },
  { at: 'scoops', s: 'SCOOPS!', x: 500 },
  { at: 'points', s: 'POINTS!', x: 866 },
];
const Features: React.FC = () => {
  const t = useT();
  if (t < E('stirs') - 0.06 || t >= E('carol') + 0.3) return null;
  return (
    <>
      {FEATURE.map((f, i) => {
        const at = E(f.at) - 0.03;
        if (t < at) return null;
        const k = back(ramp(t, at, at + 0.16));
        return (
          <div key={i} style={{ position: 'absolute', left: f.x - 118, top: 372, width: 236, height: 236, transform: `scale(${lerp(0.2, 1, k)}) rotate(${-8 + i * 6}deg)` }}>
            <svg viewBox="-118 -118 236 236" width={236} height={236} style={{ position: 'absolute', left: 0, top: 0, transform: `rotate(${t * 26}deg)` }}><polygon points={burstPts(114, 92, 18)} fill={HOT} stroke={CAP} strokeWidth={6} /></svg>
            <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: CAP, font: `${fit([f.s], 190, 60, 46)}px/1 ${BLACK}` }}>{f.s}</div>
          </div>
        );
      })}
    </>
  );
};
const RevealBanner: React.FC = () => {
  const t = useT();
  const a = E('name') - 0.06, z = E('stirs') - 0.2;
  if (t < a || t > z + 0.2) return null;
  const inK = easeOut(ramp(t, a, a + 0.13)), outK = easeIn(ramp(t, z, z + 0.18));
  const fs = fit([PRODUCT], 960, 90, 72);
  return (
    <div style={{ position: 'absolute', left: -30, top: 452, width: 1060, height: 132, transform: `translateX(${lerp(-1120, 0, inK) + outK * 1200}px) rotate(-2deg) scale(${lerp(1.12, 1, ramp(t, a + 0.1, a + 0.24))})`, background: `linear-gradient(90deg,${HOT},#c0126a)`, boxShadow: `0 0 0 7px ${CAP}, 0 18px 30px rgba(0,0,40,.55)`, padding: '14px 50px', boxSizing: 'border-box', whiteSpace: 'nowrap' }}>
      <div style={{ font: `${fs}px/1 ${BLACK}`, color: '#fff', textShadow: `4px 4px 0 ${PANEL}` }}>{PRODUCT}</div>
      <div style={{ font: `italic 700 34px/1.1 ${NARROW}`, color: CAP, marginTop: 4 }}>{PRODUCT_LINE}</div>
    </div>
  );
};

/* ---------- the window: Carol ---------- */
const CarolShot: React.FC = () => {
  const t = useT();
  const blink = (t + 0.4) % 2.1 < 0.11;
  const count = t >= E('nowc') ? 1 : t >= E('eleven') ? 11 : 0;
  const bob = Math.sin(t * 1.3) * 3;
  return (
    <div style={{ position: 'absolute', left: 0, top: 0, width: 1000, height: 600, overflow: 'hidden', background: 'linear-gradient(120deg,#3b1a8c,#8c1f8c 52%,#ff5aa0)' }}>
      <svg viewBox="0 0 1000 600" width={1000} height={600} style={{ position: 'absolute', left: 0, top: 0 }}>
        {Array.from({ length: 12 }, (_, i) => <circle key={i} cx={(hash(i + 3) * 1100 + t * (10 + hash(i) * 22)) % 1100 - 50} cy={hash(i + 30) * 600} r={26 + hash(i + 60) * 58} fill="#fff" opacity={0.05 + hash(i + 90) * 0.06} />)}
        {/* the portrait: soft studio light, a very large perm, and total peace */}
        <g clipPath="url(#carolClip)">
          <rect x={30} y={70} width={350} height={470} fill="#ffe1c9" />
          <circle cx={205} cy={250} r={190} fill="#fff" opacity={0.55} />
          <g transform={`translate(0 ${bob})`}>
            {[[205, 200, 112], [118, 262, 66], [292, 262, 66], [108, 186, 52], [302, 186, 52], [150, 140, 54], [260, 140, 54], [100, 330, 46], [310, 330, 46]].map(([x, y, r], i) => <circle key={i} cx={x} cy={y} r={r} fill="#a8481f" />)}
            <rect x={178} y={340} width={54} height={70} fill="#eab48c" />
            <path d="M 30 560 C 40 430 128 408 178 400 L 205 462 L 232 400 C 282 408 370 430 380 560 Z" fill={HOT} />
            <path d="M 178 400 L 205 462 L 232 400 L 214 396 L 205 416 L 196 396 Z" fill="#fff" />
            {Array.from({ length: 11 }, (_, i) => <circle key={i} cx={158 + i * 9.4} cy={402 + Math.sin((i / 10) * Math.PI) * 30} r={5} fill="#fff" stroke="#d8d4c8" strokeWidth={1} />)}
            <ellipse cx={205} cy={272} rx={78} ry={92} fill="#f4c7a3" />
            {[[150, 186, 34], [190, 170, 38], [232, 172, 36], [264, 196, 30]].map(([x, y, r], i) => <circle key={i} cx={x} cy={y} r={r} fill="#bd5526" />)}
            <circle cx={150} cy={304} r={17} fill="#ff8a8a" opacity={0.35} /><circle cx={260} cy={304} r={17} fill="#ff8a8a" opacity={0.35} />
            {blink ? <><line x1={160} y1={268} x2={184} y2={268} stroke="#40241a" strokeWidth={3.5} /><line x1={226} y1={268} x2={250} y2={268} stroke="#40241a" strokeWidth={3.5} /></> : <><circle cx={172} cy={268} r={6.5} fill="#40241a" /><circle cx={238} cy={268} r={6.5} fill="#40241a" /></>}
            <circle cx={172} cy={268} r={30} fill="#fff" fillOpacity={0.14} stroke={GOLD} strokeWidth={6} /><circle cx={238} cy={268} r={30} fill="#fff" fillOpacity={0.14} stroke={GOLD} strokeWidth={6} /><line x1={202} y1={266} x2={208} y2={266} stroke={GOLD} strokeWidth={6} />
            <path d="M 176 326 Q 205 350 234 326" fill="none" stroke="#a03a3a" strokeWidth={5} strokeLinecap="round" />
          </g>
        </g>
        <ellipse cx={205} cy={305} rx={160} ry={222} fill="none" stroke="url(#goldV)" strokeWidth={14} />
        <Spoon x={342} y={382 + bob} rot={10} s={0.62} />
        <circle cx={332} cy={448 + bob} r={21} fill="#f4c7a3" stroke="#d99f76" strokeWidth={2} />
        {/* eleven spoons arrive, ten leave */}
        {count > 0 ? Array.from({ length: 11 }, (_, i) => {
          const at = E('eleven') + i * 0.03, k = back(ramp(t, at, at + 0.12));
          const gone = i === 0 ? 0 : easeIn(ramp(t, E('nowc') + i * 0.025, E('nowc') + i * 0.025 + 0.3));
          return t >= at ? <Spoon key={i} x={428 + i * 52 + gone * 760} y={432 - gone * 80} rot={-6 + hash(i) * 12 + gone * 80} s={0.32 * k} /> : null;
        }) : null}
        {Array.from({ length: 5 }, (_, i) => { const k = back(ramp(t, E('carol') + 0.25 + i * 0.06, E('carol') + 0.25 + i * 0.06 + 0.14)); return k > 0 ? <polygon key={i} transform={`translate(${424 + i * 62} 268) scale(${k})`} points={Array.from({ length: 10 }, (_, j) => { const q = -Math.PI / 2 + (j / 10) * Math.PI * 2, r = j % 2 ? 11 : 26; return `${r * Math.cos(q)},${r * Math.sin(q)}`; }).join(' ')} fill={GOLD} stroke={DARKGOLD} strokeWidth={2} /> : null; })}
        <Sparkles at={E('space')} n={12} seed={110} box={[470, 450, 960, 590]} />
      </svg>
      <div style={{ position: 'absolute', left: 396, top: 72, font: `92px/1 ${BLACK}`, color: '#fff', textShadow: `6px 6px 0 ${PANEL}`, whiteSpace: 'nowrap' }}>{COPY.who}</div>
      <div style={{ position: 'absolute', left: 400, top: 176, background: CAP, color: PANEL, font: `38px/1 ${BLACK}`, padding: '9px 16px', whiteSpace: 'nowrap', transform: 'rotate(-1deg)' }}>{COPY.whoTag}</div>
      {count > 0 ? <div style={{ position: 'absolute', left: 404, top: 306, font: `italic 700 44px/1 ${NARROW}`, color: CAP, whiteSpace: 'nowrap' }}>{COPY.owned} <span style={{ font: `50px/1 ${BLACK}`, color: '#fff' }}>{count}</span></div> : null}
      <Slam at={E('drawer')} x={404} y={510} rot={-2} style={{ background: `linear-gradient(${CAP},${GOLD})`, color: PANEL, font: `44px/1 ${BLACK}`, padding: '11px 18px', boxShadow: `0 0 0 5px ${PANEL}` }}>{COPY.result}</Slam>
    </div>
  );
};

/* ---------- the window: before and after, which are the same drawer ---------- */
const DrawerArt: React.FC<{ cx: number; rattle: number; t: number }> = ({ cx, rattle, t }) => {
  const j = Math.sin(t * 57) * 5 * rattle, j2 = Math.cos(t * 43) * 2.4 * rattle;
  return (
    <g transform={`translate(${cx} 508) scale(.84) translate(${-cx} -516)`}>
      <rect x={cx - 196} y={150} width={392} height={366} rx={8} fill="#6f4720" />
      <path d={`M ${cx - 158} 170 L ${cx + 158} 170 L ${cx + 188} 262 L ${cx - 188} 262 Z`} fill="#2d1a0a" />
      <g transform={`translate(${j} ${j2})`}>
        {[-108, -60, -12, 36, 84].map((x, i) => <Spoon key={i} x={cx + x + 12} y={226} rot={84 + (i % 2) * 8} s={0.22} />)}
        <rect x={cx - 192} y={258} width={384} height={118} rx={7} fill="url(#wood)" stroke="#5f3c16" strokeWidth={3} />
        {[0, 1, 2].map((q) => <path key={q} d={`M ${cx - 180} ${280 + q * 30} C ${cx - 90} ${272 + q * 30} ${cx + 60} ${292 + q * 30} ${cx + 180} ${280 + q * 30}`} fill="none" stroke="#7f5324" strokeWidth={2} opacity={0.6} />)}
        <circle cx={cx} cy={316} r={15} fill="url(#goldV)" stroke={DARKGOLD} strokeWidth={2} />
      </g>
      <rect x={cx - 182} y={388} width={364} height={116} rx={7} fill="url(#wood)" stroke="#5f3c16" strokeWidth={3} />
      <circle cx={cx} cy={446} r={15} fill="url(#goldV)" stroke={DARKGOLD} strokeWidth={2} />
      {rattle > 0 ? [0, 1, 2].map((q) => {
        const o = 0.35 + 0.65 * Math.abs(Math.sin(t * 15 + q));
        return <g key={q} stroke={SALE} strokeWidth={6} fill="none" strokeLinecap="round" opacity={o}><path d={`M ${cx - 206 - q * 14} ${276 - q * 2} q -12 40 0 80`} /><path d={`M ${cx + 206 + q * 14} ${276 - q * 2} q 12 40 0 80`} /></g>;
      }) : null}
    </g>
  );
};
const Meter: React.FC<{ x: number; y: number; level: number; t: number; seed: number }> = ({ x, y, level, t, seed }) => (
  <div style={{ position: 'absolute', left: x, top: y, display: 'flex', alignItems: 'flex-end', gap: 4, height: 66 }}>
    <div style={{ font: `italic 700 34px/66px ${NARROW}`, color: ICE, marginRight: 8, height: 66 }}>{COPY.meter}</div>
    {Array.from({ length: 8 }, (_, i) => {
      const on = (i + 1) / 8 <= level * (0.72 + 0.28 * hash(Math.floor(t * 12) + i + seed));
      return <div key={i} style={{ width: 14, height: 16 + i * 7, borderRadius: 2, background: on ? (i > 5 ? SALE : i > 3 ? CAP : '#3fd27b') : 'rgba(255,255,255,.12)' }} />;
    })}
  </div>
);
const DrawersShot: React.FC = () => {
  const t = useT();
  const afterK = easeOut(ramp(t, E('after') - 0.05, E('after') + 0.16));
  return (
    <div style={{ position: 'absolute', left: 0, top: 0, width: 1000, height: 600, overflow: 'hidden', background: `linear-gradient(${STUDIO},${PANEL})` }}>
      <svg viewBox="0 0 1000 600" width={1000} height={600} style={{ position: 'absolute', left: 0, top: 0 }}>
        <DrawerArt cx={250} rattle={1} t={t} />
      </svg>
      <div style={{ position: 'absolute', left: 500, top: 0, width: 500, height: 600, overflow: 'hidden', transform: `translateX(${lerp(520, 0, afterK)}px)`, background: `linear-gradient(${STUDIO},${PANEL})` }}>
        <svg viewBox="500 0 500 600" width={500} height={600} style={{ position: 'absolute', left: 0, top: 0 }}>
          <DrawerArt cx={750} rattle={0} t={t} />
        </svg>
        <div style={{ position: 'absolute', left: 14, top: 30, background: '#159a54', color: '#fff', font: `52px/1 ${BLACK}`, padding: '10px 22px', boxShadow: `0 0 0 4px ${CAP}` }}>{COPY.after}</div>
        <div style={{ position: 'absolute', left: 0, right: 0, top: 524, textAlign: 'center', color: CAP, font: `42px/1 ${BLACK}` }}>{COPY.afterLine}</div>
      </div>
      <div style={{ position: 'absolute', left: 36, top: 30, background: SALE, color: '#fff', font: `52px/1 ${BLACK}`, padding: '10px 22px', boxShadow: `0 0 0 4px ${CAP}` }}>{COPY.before}</div>
      <div style={{ position: 'absolute', left: 0, width: 500, top: 524, textAlign: 'center', color: CAP, font: `42px/1 ${BLACK}` }}>{COPY.beforeLine}</div>
      <Meter x={36} y={112} level={1} t={t} seed={1} />
      {afterK > 0.98 ? <Meter x={514} y={112} level={0.26} t={t} seed={9} /> : null}
      <div style={{ position: 'absolute', left: 494, top: 0, width: 12, height: 600, background: `linear-gradient(${CAP},${GOLD},${DARKGOLD})`, transform: `translateX(${lerp(520, 0, afterK)}px)`, boxShadow: '0 0 18px rgba(0,0,0,.5)' }} />
      <Slam at={E('quieter')} x={650} y={424} rot={-4} style={{ background: PANEL, color: CAP, font: `italic 700 52px/1 ${NARROW}`, padding: '10px 22px', boxShadow: `inset 0 0 0 4px ${GOLD}` }}>{COPY.afterTag}</Slam>
    </div>
  );
};

/* ---------- the window: technical difficulties ---------- */
const BARS = ['#c9c9c9', '#c9c600', '#00c6c9', '#00c600', '#c900c9', '#c90000', '#0000c9'];
const BarsShot: React.FC = () => {
  const t = useT();
  const roll = ((t * 150) % 720) - 120;
  return (
    <div style={{ position: 'absolute', left: 0, top: 0, width: 1000, height: 600, overflow: 'hidden', background: '#111' }}>
      {BARS.map((c, i) => <div key={i} style={{ position: 'absolute', left: (i * 1000) / 7, top: 0, width: 1000 / 7 + 1, height: 410, background: c }} />)}
      {['#0000c9', '#111', '#c900c9', '#111', '#00c6c9', '#111', '#c9c9c9'].map((c, i) => <div key={i} style={{ position: 'absolute', left: (i * 1000) / 7, top: 410, width: 1000 / 7 + 1, height: 60, background: c }} />)}
      {[['#0a2a5c', 180], ['#e8e8e8', 180], ['#2c0a66', 180], ['#111', 460]].reduce<{ c: string; x: number; w: number }[]>((a, [c, w]) => [...a, { c: c as string, w: w as number, x: a.length ? a[a.length - 1].x + a[a.length - 1].w : 0 }], []).map((b, i) => <div key={i} style={{ position: 'absolute', left: b.x, top: 470, width: b.w, height: 130, background: b.c }} />)}
      {/* a slow drip of soup down the card, and the hum bar rolling through it: the picture stays alive */}
      <svg viewBox="0 0 1000 600" width={1000} height={600} style={{ position: 'absolute', left: 0, top: 0 }}>
        {[[148, 0.2, 1.0], [640, 0.55, 0.8], [872, 0, 1.15]].map(([x, d, sp], i) => { const k = ramp(t, E('have') + 0.25 + d, E('have') + 0.25 + d + 2.2 / sp); const y = easeOut(k) * 300 + 8; return <g key={i}><path d={`M ${x - 12} 0 L ${x + 12} 0 L ${x + 7} ${y} A 7 7 0 0 1 ${x - 7} ${y} Z`} fill={SOUP} /></g>; })}
      </svg>
      <div style={{ position: 'absolute', left: 56, top: 150, width: 790, height: 346, background: PANEL, borderRadius: 16, boxShadow: `inset 0 0 0 6px ${GOLD}, 0 18px 40px rgba(0,0,0,.6)`, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', transform: `translateX(${hash(Math.floor(t * 12)) > 0.86 ? 7 : 0}px)` }}>
        <div style={{ font: `76px/1.04 ${BLACK}`, color: '#fff' }}>{COPY.card[0]}<br />{COPY.card[1]}</div>
        <div style={{ font: `italic 700 46px/1 ${NARROW}`, color: GOLD, marginTop: 18 }}>{COPY.cardLine}</div>
        <div style={{ font: `italic 700 30px/1 ${NARROW}`, color: ICE, marginTop: 12 }}>{COPY.cardJoke}</div>
      </div>
      <div style={{ position: 'absolute', left: 0, top: roll, width: 1000, height: 120, background: 'linear-gradient(rgba(255,255,255,0),rgba(255,255,255,.2) 50%,rgba(255,255,255,0))' }} />
      <div style={{ position: 'absolute', inset: 0, background: 'repeating-linear-gradient(rgba(0,0,0,.14) 0 2px, transparent 2px 5px)', transform: `translateY(${Math.floor(t * 40) % 5}px)` }} />
    </div>
  );
};

/* ---------- the window: the wall of phones ---------- */
const PHONE_ROWS = 4, PHONE_COLS = 8;
const rowAt = (r: number) => E('quiet') + 0.12 + r * 0.62;
const PhoneIcon: React.FC<{ x: number; y: number; lit: boolean; t: number; seed: number }> = ({ x, y, lit, t, seed }) => {
  const ring = lit ? Math.sin(t * 34 + seed) : 0;
  const body = lit ? SALE : '#27327f', hand = lit ? '#ff5a4f' : '#323e94', trim = lit ? CAP : '#465099';
  return (
    <g transform={`translate(${x} ${y})`}>
      <rect x={-54} y={-62} width={108} height={124} rx={9} fill={lit ? '#1a2494' : '#101a66'} stroke={lit ? GOLD : '#2a3590'} strokeWidth={2} />
      {lit ? <circle r={58} fill={CAP} opacity={0.14 + 0.08 * Math.abs(ring)} /> : null}
      <path d="M -24 -10 L 24 -10 L 38 36 L -38 36 Z" fill={body} stroke={DEEP} strokeWidth={2} />
      <circle cx={0} cy={12} r={13} fill={trim} /><circle cx={0} cy={12} r={5} fill={DEEP} />
      <g transform={`translate(0 ${-Math.abs(ring) * 6}) rotate(${ring * 7})`}><path d="M -40 -22 C -30 -42 30 -42 40 -22 L 40 -9 L 22 -9 L 20 -19 L -20 -19 L -22 -9 L -40 -9 Z" fill={hand} stroke={DEEP} strokeWidth={2} /></g>
      <circle cx={40} cy={-48} r={6} fill={lit && Math.floor(t * 7 + seed) % 2 === 0 ? CAP : '#3b4286'} />
      {lit && Math.abs(ring) > 0.55 ? <g stroke={CAP} strokeWidth={3} strokeLinecap="round" fill="none"><path d="M -47 -32 q -6 8 0 16" /><path d="M 47 -32 q 6 8 0 16" /></g> : null}
    </g>
  );
};
const PhonesShot: React.FC = () => {
  const t = useT();
  return (
    <div style={{ position: 'absolute', left: 0, top: 0, width: 1000, height: 600, overflow: 'hidden', background: `linear-gradient(${PANEL},${DEEP})` }}>
      <svg viewBox="0 0 1000 600" width={1000} height={600} style={{ position: 'absolute', left: 0, top: 0 }}>
        {Array.from({ length: PHONE_ROWS }, (_, r) => Array.from({ length: PHONE_COLS }, (_, c) => <PhoneIcon key={`${r}-${c}`} x={72 + c * 122.5} y={70 + r * 153} lit={t >= rowAt(r) + c * 0.012} t={t} seed={r * 8 + c} />))}
      </svg>
    </div>
  );
};

/* ---------- picture in picture: the operator ---------- */
const OperatorArt: React.FC = () => {
  const t = useT();
  const blink = (t + 0.2) % 1.9 < 0.1;
  const look = t >= E('onethem') + 0.2 && t < E('stain') + 0.7 ? 1 : 0;
  const k = easeInOut(ramp(t, E('onethem'), E('stain')));
  const spoon = easeOut(ramp(t, E('stain') - 0.18, E('stain') + 0.08));
  const drip = ramp(t, E('stain') + 0.1, E('stain') + 1.6);
  return (
    <svg viewBox="0 0 460 500" width={460} height={500} style={{ position: 'absolute', left: 0, top: 0 }}>
      <rect width={460} height={500} fill="#24307f" />
      {Array.from({ length: 6 }, (_, i) => <PhoneIcon key={i} x={40 + i * 76} y={92} lit={i % 2 === 0} t={t} seed={i} />)}
      <rect y={150} width={460} height={350} fill={DEEP} opacity={0.5} />
      <g transform={`translate(230 300) scale(${lerp(1, 1.16, k)}) translate(-230 -300)`}>
        <path d="M 40 520 C 50 370 120 346 170 336 L 290 336 C 340 346 410 370 420 520 Z" fill="#eef1ff" />
        <path d="M 196 336 L 230 386 L 264 336 L 250 324 L 210 324 Z" fill="#d6dbf2" />
        <rect x={198} y={276} width={64} height={72} fill="#d79a6e" />
        <ellipse cx={230} cy={208} rx={80} ry={94} fill="#e6ad80" />
        <path d="M 146 200 C 138 104 322 104 314 200 C 300 158 270 146 230 146 C 190 146 160 158 146 200 Z" fill="#2b1d16" />
        {/* deadpan: half lids, flat mouth, and a slow look down at the shirt, then back at you */}
        {blink ? <><line x1={180} y1={206} x2={208} y2={206} stroke="#2b1d16" strokeWidth={4} /><line x1={252} y1={206} x2={280} y2={206} stroke="#2b1d16" strokeWidth={4} /></> : (
          <>
            <ellipse cx={194} cy={208} rx={16} ry={10} fill="#fff" /><ellipse cx={266} cy={208} rx={16} ry={10} fill="#fff" />
            <circle cx={194} cy={208 + look * 4} r={6.5} fill="#2b1d16" /><circle cx={266} cy={208 + look * 4} r={6.5} fill="#2b1d16" />
            <rect x={174} y={194} width={40} height={11} fill="#e6ad80" /><rect x={246} y={194} width={40} height={11} fill="#e6ad80" />
            <line x1={176} y1={204} x2={212} y2={204} stroke="#8b5a3a" strokeWidth={3} /><line x1={248} y1={204} x2={284} y2={204} stroke="#8b5a3a" strokeWidth={3} />
          </>
        )}
        <line x1={205} y1={258} x2={255} y2={258} stroke="#8a4a3a" strokeWidth={5} strokeLinecap="round" />
        <path d="M 142 206 C 134 96 326 96 318 206" fill="none" stroke="#10163f" strokeWidth={11} />
        <rect x={128} y={186} width={24} height={50} rx={10} fill="#10163f" /><rect x={308} y={186} width={24} height={50} rx={10} fill="#10163f" />
        <path d="M 140 234 C 140 268 170 276 196 272" fill="none" stroke="#10163f" strokeWidth={6} /><circle cx={198} cy={272} r={8} fill="#10163f" />
        {/* the soup: a stain, a splash and a drip that keeps going */}
        <path d="M 176 412 C 164 396 188 388 198 394 C 214 378 240 392 236 410 C 254 414 250 442 230 440 C 222 458 192 452 192 438 C 172 442 164 424 176 412 Z" fill={SOUP} />
        <circle cx={258} cy={398} r={7} fill={SOUP} /><circle cx={154} cy={438} r={5} fill={SOUP} />
        <path d={`M 204 448 L 216 448 L 214 ${450 + drip * 56} A 4.5 4.5 0 0 1 206 ${450 + drip * 56} Z`} fill={SOUP} />
        <rect x={316} y={388} width={52} height={28} rx={4} fill={PANEL} stroke={GOLD} strokeWidth={3} />
      </g>
      {spoon > 0 ? <Spoon x={lerp(760, 426, spoon)} y={404 + Math.sin(t * 3) * 3} rot={270} s={0.74} /> : null}
    </svg>
  );
};
const Pip: React.FC = () => {
  const t = useT();
  if (t < E('ops') - 0.05) return null;
  const inK = back(ramp(t, E('ops') - 0.05, E('ops') + 0.24));
  const q = easeInOut(ramp(t, E('cta'), E('cta') + 0.4));
  const x = lerp(lerp(1100, 270, inK), 28, q), y = lerp(36, 300, q), s = lerp(1, 0.5, q);
  const tagK = t >= E('stain') - 0.02 ? back(ramp(t, E('stain') - 0.02, E('stain') + 0.14)) : 0;
  return (
    <div style={{ position: 'absolute', left: x, top: y, width: 460, height: 530, transformOrigin: '0 0', transform: `scale(${s})`, borderRadius: 12, overflow: 'hidden', boxShadow: `0 0 0 9px ${GOLD}, 0 0 0 14px ${DARKGOLD}, 0 22px 44px rgba(0,0,0,.6)`, background: PANEL }}>
      <OperatorArt />
      <div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: 64, background: `linear-gradient(90deg,${HOT},#c0126a)`, color: '#fff', font: `36px/64px ${BLACK}`, textAlign: 'center', whiteSpace: 'nowrap' }}>{COPY.operator}</div>
      {tagK > 0 ? <div style={{ position: 'absolute', left: 22, top: 330, background: SALE, color: '#fff', font: `40px/1 ${BLACK}`, padding: '10px 16px', borderRadius: 6, transform: `rotate(-6deg) scale(${tagK})` }}>{COPY.stain}</div> : null}
    </div>
  );
};

/* ---------- the window: which shot, and the wipe between shots ---------- */
type ShotId = 'studio' | 'carol' | 'drawers' | 'bars' | 'phones';
const SHOTS: { id: ShotId; at: number; cut?: boolean }[] = [
  { id: 'studio', at: 0 },
  { id: 'carol', at: E('carol') },
  { id: 'drawers', at: E('before') - 0.06 },
  { id: 'studio', at: E('rush') },
  { id: 'bars', at: E('have') + 0.25, cut: true },
  { id: 'studio', at: E('wait'), cut: true },
  { id: 'phones', at: E('quiet') },
  { id: 'studio', at: E('cta') },
];
const Shot: React.FC<{ id: ShotId }> = ({ id }) => (id === 'studio' ? <Studio /> : id === 'carol' ? <CarolShot /> : id === 'drawers' ? <DrawersShot /> : id === 'bars' ? <BarsShot /> : <PhonesShot />);
const Screen: React.FC = () => {
  const t = useT();
  let i = 0;
  SHOTS.forEach((s, k) => { if (t >= s.at) i = k; });
  const cur = SHOTS[i], prev = i > 0 ? SHOTS[i - 1] : null;
  const k = easeInOut(ramp(t, cur.at, cur.at + 0.26));
  const wiping = !!prev && !cur.cut && k < 1;
  const X = lerp(-200, 1100, k);
  const tab = pick(TAB, t);
  const tabK = tab.at === 0 ? 1 : back(ramp(t, tab.at, tab.at + 0.14));
  return (
    <div style={{ position: 'absolute', left: 40, top: 222, width: 1000, height: 600, borderRadius: 18, overflow: 'hidden', boxShadow: `0 0 0 10px ${GOLD}, 0 0 0 18px ${DARKGOLD}, 0 30px 60px rgba(0,0,40,.6)`, background: PANEL }}>
      {wiping && prev ? <Shot id={prev.id} /> : null}
      <div style={{ position: 'absolute', left: 0, top: 0, width: 1000, height: 600, clipPath: wiping ? `polygon(-20px -20px, ${X + 150}px -20px, ${X}px 620px, -20px 620px)` : undefined }}><Shot id={cur.id} /></div>
      {wiping ? <div style={{ position: 'absolute', left: X - 40, top: -40, width: 46, height: 680, background: `linear-gradient(${CAP},${HOT})`, transform: 'skewX(-14deg)', transformOrigin: '50% 100%', boxShadow: '0 0 26px rgba(255,46,136,.8)' }} /> : null}
      {t >= E('stirs') - 0.2 ? <Features /> : null}
      <RevealBanner />
      <Pip />
      {tab.s ? <div style={{ position: 'absolute', left: 0, top: 34, background: CAP, color: PANEL, font: `34px/1 ${BLACK}`, padding: '9px 20px 9px 22px', borderRadius: '0 10px 10px 0', whiteSpace: 'nowrap', transform: `translateX(${lerp(-40, 0, tabK)}px)`, boxShadow: '0 6px 12px rgba(0,0,40,.4)' }}>{tab.s}</div> : null}
    </div>
  );
};

/* ---------- the set around the window ---------- */
const Backdrop: React.FC = () => {
  const t = useT();
  // the streaks repeat every 180 px along their angle, which is 198.6 px sideways
  const run = flowAt(t) % 198.6;
  return (
    <>
      <div style={{ position: 'absolute', left: -220, top: -220, width: 1520, height: 2360, transform: `translateX(${run}px)`, background: 'repeating-linear-gradient(115deg,rgba(255,255,255,0) 0 120px,rgba(120,160,255,.14) 120px 180px)' }} />
      <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse 80% 50% at 50% 32%,rgba(110,140,255,.45),rgba(27,42,140,0) 70%)' }} />
    </>
  );
};

const Bug: React.FC = () => {
  const t = useT();
  const dead = isDead(t);
  const s = 12 * 60 + 7 + Math.floor(clockAt(t));
  const left = 599 - Math.floor(clockAt(t) * 3);
  const p2 = (n: number) => String(n).padStart(2, '0');
  return (
    <>
      <div style={{ position: 'absolute', left: 40, top: 34, width: 112, height: 112, borderRadius: '50%', background: HOT, boxShadow: `0 0 0 6px ${GOLD}`, display: 'flex', alignItems: 'center', justifyContent: 'center', font: `72px/1 ${BLACK}`, color: '#fff', textShadow: `3px 3px 0 ${PANEL}` }}>{LOGO}</div>
      <div style={{ position: 'absolute', left: 176, top: 42, background: dead ? '#4a4f78' : SALE, color: '#fff', font: `38px/1 ${BLACK}`, padding: '6px 16px', borderRadius: 8, opacity: dead ? 1 : Math.floor(t * 2) % 2 ? 0.6 : 1 }}>LIVE</div>
      <div style={{ position: 'absolute', left: 312, top: 48, font: `italic 700 40px/1 ${NARROW}`, color: ICE, whiteSpace: 'nowrap' }}>3:{p2(Math.floor(s / 60))}:{p2(s % 60)} AM</div>
      <div style={{ position: 'absolute', left: 176, top: 100, font: `italic 700 40px/1 ${NARROW}`, color: '#fff', whiteSpace: 'nowrap' }}>{CHANNEL}</div>
      <div style={{ position: 'absolute', left: 44, top: 166, font: `italic 700 34px/1 ${NARROW}`, color: GOLD, letterSpacing: '.04em', whiteSpace: 'nowrap' }}>{ITEM}&nbsp;&nbsp;&nbsp;OFFER ENDS IN 00:{p2(Math.floor(left / 60))}:{p2(left % 60)}</div>
    </>
  );
};

const Burst: React.FC = () => {
  const t = useT();
  const b = pick(BURST, t);
  const dead = isDead(t);
  const k = back(ramp(t, b.at, b.at + 0.2));
  const hang = easeOut(ramp(t, E('why') + 0.1, E('why') + 0.5)) * (dead ? 1 : 0);
  const fs = fit(b.lines, 236, 190, 80);
  const spin = flowAt(t) * 0.16;
  return (
    <div style={{ position: 'absolute', left: 776, top: 90 + hang * 54, width: 300, height: 300, zIndex: 5, transform: `rotate(${hang * 24}deg) scale(${(b.at === 0 ? 1 : lerp(0.45, 1, k)) * (dead ? 1 : 1 + Math.sin(t * 5) * 0.03)})` }}>
      <svg viewBox="-150 -150 300 300" width={300} height={300} style={{ position: 'absolute', left: 0, top: 0, transform: `rotate(${spin}deg)`, filter: dead ? 'saturate(.35) brightness(.7)' : undefined }}><polygon points={burstPts(148, 118, 24)} fill={GOLD} stroke={SALE} strokeWidth={6} /></svg>
      <div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', color: dead ? '#7a2030' : SALE, font: `${fs}px/.98 ${BLACK}`, transform: 'rotate(-12deg)' }}>
        {b.lines.map((l, i) => <div key={i}>{l}</div>)}
      </div>
    </div>
  );
};

/** Gold type that catches the light: the gradient drifts across it. */
const GoldText: React.FC<{ fs: number; children: React.ReactNode; t: number; dead?: boolean }> = ({ fs, children, t, dead }) => (
  <div style={{ position: 'relative', font: `${fs}px/1.02 ${BLACK}`, whiteSpace: 'nowrap' }}>
    <div style={{ position: 'absolute', left: 4, top: 5, color: DEEP }}>{children}</div>
    <div style={{ position: 'relative', color: 'transparent', backgroundImage: dead ? 'linear-gradient(#a0a6cc,#6a71a0)' : `linear-gradient(100deg,#fff6c8 0%,${CAP} 22%,${GOLD} 44%,#fff6c8 54%,${GOLD} 66%,#ffb000 100%)`, backgroundSize: '220% 100%', backgroundPositionX: `${dead ? 0 : (t * 40) % 220}%`, WebkitBackgroundClip: 'text', backgroundClip: 'text' }}>{children}</div>
  </div>
);
const panelBox: React.CSSProperties = { position: 'absolute', background: 'linear-gradient(#17256f,#0f1a5c 45%,#0b1448)', borderRadius: 18, boxShadow: `inset 0 0 0 6px ${GOLD}, 0 14px 26px rgba(0,0,40,.55)`, overflow: 'hidden' };
const Sheen: React.FC<{ t: number; ph: number }> = ({ t, ph }) => {
  const k = ((t + ph) % 3.4) / 0.7;
  if (k >= 1 || isDead(t)) return null;
  return <div style={{ position: 'absolute', left: lerp(-200, 720, k), top: -20, width: 70, height: 340, background: 'rgba(255,255,255,.11)', transform: 'skewX(-20deg)' }} />;
};

const OfferPanel: React.FC = () => {
  const t = useT();
  const o = pick(OFFER, t);
  const dead = isDead(t);
  const k = back(ramp(t, o.at, o.at + 0.16));
  const fs = fit(o.big, 472, 150, 92);
  const strike = easeOut(ramp(t, E('second'), E('second') + 0.14));
  return (
    <div style={{ ...panelBox, left: 60, top: 856, width: 530, height: 268 }}>
      <Sheen t={t} ph={0} />
      <div style={{ position: 'absolute', left: 30, top: 22, font: `italic 700 40px/1 ${NARROW}`, color: dead ? '#9aa0c8' : GOLD, whiteSpace: 'nowrap' }}>{o.head}</div>
      {o.old ? (
        <div style={{ position: 'absolute', right: 28, top: 26, font: `30px/1 ${BLACK}`, color: ICE, whiteSpace: 'nowrap' }}>
          {o.old}
          <div style={{ position: 'absolute', left: -4, right: -4, top: 12, height: 7, background: SALE, transform: `rotate(-5deg) scaleX(${t >= E('cta') ? 1 : strike})`, transformOrigin: '0 50%' }} />
        </div>
      ) : null}
      <div style={{ position: 'absolute', left: 30, top: 68, width: 472, height: 150, display: 'flex', flexDirection: 'column', justifyContent: 'center', transform: `scale(${o.at === 0 ? 1 : lerp(1.28, 1, k)})`, transformOrigin: '0 50%' }}>
        {o.big.map((l, i) => <GoldText key={i} fs={fs} t={t} dead={dead}>{l}</GoldText>)}
      </div>
      <div style={{ position: 'absolute', left: 30, bottom: 16, font: `italic 700 30px/1 ${NARROW}`, color: ICE, whiteSpace: 'nowrap' }}>{o.fine}</div>
    </div>
  );
};

const StockPanel: React.FC = () => {
  const t = useT();
  const dead = isDead(t);
  const cta = t >= E('cta');
  const n = stockAt(t);
  const digits = String(n).padStart(3, '0').split('');
  const hit = Math.max(t >= E('four') ? 1 - ramp(t, E('four'), E('four') + 0.25) : 0, t >= E('three') ? 1 - ramp(t, E('three'), E('three') + 0.25) : 0, t >= E('zero') ? 1 - ramp(t, E('zero'), E('zero') + 0.3) : 0);
  const zero = n === 0 && t >= E('zero') - 0.2;
  const low = t >= E('four');
  const rate = t >= E('quiet') ? 12 : t >= E('rush') ? 9 : 3;
  const lamp = (i: number) => {
    if (t >= E('why') + i * 0.06 && t < E('wait')) return false;
    if (t >= E('quiet')) return true;
    return hash(i * 7 + Math.floor(t * rate)) > (t >= E('rush') ? 0.22 : 0.48);
  };
  return (
    <div style={{ ...panelBox, left: 604, top: 856, width: 346, height: 268, boxShadow: `inset 0 0 0 4px #3d4fc4, 0 14px 26px rgba(0,0,40,.55)` }}>
      <Sheen t={t} ph={1.5} />
      <div style={{ position: 'absolute', left: 0, right: 0, top: 18, textAlign: 'center', font: `italic 700 38px/1 ${NARROW}`, color: cta ? GOLD : ICE, whiteSpace: 'nowrap' }}>{cta ? COPY.skills : UNIT}</div>
      <div style={{ position: 'absolute', left: 0, right: 0, top: 62, display: 'flex', justifyContent: 'center', gap: 8, transform: `scale(${1 + hit * 0.14})` }}>
        {cta ? <div style={{ height: 112, font: `64px/112px ${BLACK}`, color: CAP }}>{COPY.plenty}</div> : digits.map((d, i) => (
          <div key={i} style={{ width: 92, height: 112, borderRadius: 8, background: 'linear-gradient(#050a30,#0b1448 48%,#000 50%,#0b1448 52%,#050a30)', boxShadow: 'inset 0 0 0 2px #2a3590', textAlign: 'center', font: `92px/112px ${BLACK}`, color: dead ? '#6d74a8' : zero ? '#ff4b4b' : low ? HOT : '#fff' }}>{d}</div>
        ))}
      </div>
      {!cta ? <Slam at={E('zero')} x={34} y={132} rot={-7} style={{ font: `42px/1 ${BLACK}`, color: '#fff', background: SALE, padding: '9px 16px', borderRadius: 8, boxShadow: `0 0 0 4px ${CAP}` }}>{COPY.gone}</Slam> : null}
      <div style={{ position: 'absolute', left: 22, right: 22, top: 188, display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 7 }}>
        {Array.from({ length: 16 }, (_, i) => {
          const on = lamp(i);
          return <div key={i} style={{ height: 25, borderRadius: 5, background: on ? `radial-gradient(circle at 50% 40%,#fff6c8,${GOLD})` : '#1a2252', boxShadow: on ? '0 0 10px rgba(255,201,60,.8)' : 'inset 0 0 0 2px #2a3590' }} />;
        })}
      </div>
    </div>
  );
};

const CallBar: React.FC = () => {
  const t = useT();
  const dead = isDead(t);
  const flip = ramp(t, E('cta') - 0.02, E('cta') + 0.22);
  const showCta = flip >= 0.5;
  const sy = Math.max(0.02, Math.abs(1 - flip * 2));
  const rate = t >= E('quiet') && t < E('ops') ? 5 : t >= E('rush') && t < E('why') ? 4 : 2.5; // two states per cycle: under three flashes a second
  const on = Math.floor(t * rate) % 2 === 0;
  const fast = t >= E('quiet') && t < E('ops');
  const pulse = fast ? 1 + 0.035 * Math.abs(Math.sin(t * 11)) : 1;
  const bg = showCta ? `linear-gradient(${CAP},${GOLD})` : dead ? '#2a2f58' : on ? SALE : HOT;
  const word = `COMMENT ${KEYWORD}`;
  return (
    <div style={{ position: 'absolute', left: 60, top: 1138, width: 890, height: 108, borderRadius: 14, background: bg, boxShadow: `0 0 0 5px ${dead ? '#4a4f78' : showCta ? PANEL : GOLD}, 0 12px 22px rgba(0,0,40,.55)`, transform: `scaleY(${sy}) scale(${pulse})`, display: 'flex', alignItems: 'center', justifyContent: 'center', whiteSpace: 'nowrap', overflow: 'hidden' }}>
      {showCta ? <div style={{ font: `${fit([word], 840, 100, 88)}px/1 ${BLACK}`, color: PANEL }}>{word}</div>
        : dead ? <div style={{ font: `70px/1 ${BLACK}`, color: '#9aa0c8' }}>{COPY.hold}</div>
          : (
            <>
              <div style={{ background: CAP, color: PANEL, font: `40px/.94 ${BLACK}`, padding: '8px 14px', borderRadius: 8, textAlign: 'center', marginRight: 24 }}>CALL<br />NOW</div>
              <div style={{ font: `${fit([PHONE], 650, 100, 92)}px/1 ${BLACK}`, color: fast && on ? CAP : '#fff', textShadow: `4px 4px 0 ${PANEL}` }}>{PHONE}</div>
            </>
          )}
    </div>
  );
};

/* The right edge from y = 900 is under the platform's buttons, so it holds a sign nobody has to read. */
const Marquee: React.FC = () => {
  const t = useT();
  const letters = Array.from(SIGN);
  const dead = isDead(t);
  return (
    <>
      <div style={{ position: 'absolute', left: 966, top: 856, width: 98, height: 640, borderRadius: 14, background: 'linear-gradient(#17256f,#0a1040)', boxShadow: `inset 0 0 0 4px ${GOLD}, 0 14px 26px rgba(0,0,40,.5)` }} />
      {letters.map((c, i) => {
        const on = bulb(t, i * 2) > 0.5 || (!dead && t >= E('intro'));
        const hot = !dead && (Math.floor(chaseAt(t) * 0.5) % letters.length) === i;
        return <div key={i} style={{ position: 'absolute', left: 966, width: 98, top: 876 + (i * 600) / letters.length, textAlign: 'center', font: `88px/1 ${BLACK}`, color: dead ? '#2a3070' : hot ? '#fff' : on ? CAP : '#5a6090', textShadow: !dead && (on || hot) ? `0 0 26px ${hot ? '#fff' : GOLD}` : undefined }}>{c}</div>;
      })}
      <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0 }}>
        {Array.from({ length: 15 }, (_, i) => <Bulb key={i} x={29} y={866 + i * 43} r={8} on={bulb(t, i)} />)}
      </svg>
    </>
  );
};

/* Below y = 1480 only texture: the fine print on a ticker, and a glossy studio floor running to the bottom edge. */
const FLOOR = (() => {
  const HZ = 1556, VX = 540, rows = 7, cols = 18, out: { d: string; on: boolean }[] = [];
  const yAt = (r: number) => HZ + 364 * Math.pow(r / rows, 1.9);
  const xAt = (c: number, y: number) => VX + (c - cols / 2) * lerp(58, 300, (y - HZ) / 364);
  for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
    const y0 = yAt(r), y1 = yAt(r + 1);
    out.push({ d: `M ${xAt(c, y0)} ${y0} L ${xAt(c + 1, y0)} ${y0} L ${xAt(c + 1, y1)} ${y1} L ${xAt(c, y1)} ${y1} Z`, on: (r + c) % 2 === 0 });
  }
  return out;
})();
const Floor: React.FC = () => {
  const t = useT();
  const dead = isDead(t);
  const flow = flowAt(t);
  const pool = 50 + Math.sin(t * 0.8) * 22;
  return (
    <>
      <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0 }}>
        {FLOOR.map((q, i) => <path key={i} d={q.d} fill={q.on ? '#2436b4' : '#0a1248'} />)}
      </svg>
      <div style={{ position: 'absolute', left: 0, top: 1556, width: 1080, height: 364, background: `radial-gradient(ellipse 50% 60% at ${pool}% 0%, rgba(255,201,60,${dead ? 0.04 : 0.34}), rgba(255,201,60,0) 100%), linear-gradient(rgba(8,12,60,0),rgba(8,12,60,.55))` }} />
      <div style={{ position: 'absolute', left: -20, top: 1498, width: 1120, height: 58, background: dead ? '#4a4f78' : `linear-gradient(${CAP},${GOLD})`, color: PANEL, overflow: 'hidden', font: `italic 700 34px/58px ${NARROW}`, whiteSpace: 'pre', boxShadow: `0 -4px 0 ${DARKGOLD}, 0 4px 0 ${DARKGOLD}, 0 14px 24px rgba(0,0,40,.5)` }}>
        <div style={{ position: 'absolute', left: 0, top: 0, transform: `translateX(${-flow * 0.7 - t * 60}px)` }}>{FINE_PRINT.repeat(6)}</div>
      </div>
    </>
  );
};

/* ---------- the turn ---------- */
const ButWait: React.FC = () => {
  const t = useT();
  const a = E('but') - 0.02, w = E('wait'), out = E('offer') + 0.3;
  if (t < a || t > out + 0.3) return null;
  const inK = easeOut(ramp(t, a, a + 0.1)), outK = easeIn(ramp(t, out, out + 0.26));
  const waitK = back(ramp(t, w, w + 0.11));
  return (
    <div style={{ position: 'absolute', left: -200, top: 500, width: 1480, height: 208, background: `linear-gradient(90deg,#c0126a,${HOT} 30%,${HOT} 70%,#c0126a)`, transform: `rotate(-8deg) translateX(${lerp(-1560, 0, inK) - outK * 1760}px)`, boxShadow: `0 0 0 12px ${CAP}, 0 0 0 20px ${SALE}, 0 34px 60px rgba(0,0,40,.7)`, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 8, gap: 34, whiteSpace: 'nowrap', font: `136px/1 ${BLACK}`, color: '#fff', textShadow: `7px 7px 0 ${PANEL}` }}>
      <span style={{ transform: 'skewX(-8deg)' }}>{COPY.wait[0]}</span>
      {t >= w ? <span style={{ transform: `skewX(-8deg) scale(${lerp(2.3, 1, waitK)})`, color: CAP }}>{COPY.wait[1]}</span> : <span style={{ opacity: 0 }}>{COPY.wait[1]}</span>}
    </div>
  );
};

/* ---------- captions: chunky italic, in one place ---------- */
const PER_ROW = 16;
const QUIET_CAPTION = 'CALL NOW';
const QUIET_T0 = E('quiet') + 0.1;
const QUIET_WORDS: Word[] = QUIET_CAPTION.split(' ').map((w, i) => ({ w, t0: QUIET_T0 + i * 0.1, t1: E('ops') - 0.1, scene: 's11' }));
const QUIET_MAP = Array.from(QUIET_CAPTION).reduce<number[]>((m, c) => { const wi = m.filter((x) => x === -1).length; m.push(c === ' ' ? -1 : wi); return m; }, []);
const CHUNKS = [...buildChunks(PER_ROW, 2), { t0: QUIET_T0, rows: [{ s: QUIET_CAPTION, map: QUIET_MAP }], words: QUIET_WORDS }].sort((a, b) => a.t0 - b.t0);

const CapRow: React.FC<{ row: { s: string; map: number[] }; words: Word[]; y: number; pulse: number }> = ({ row, words, y, pulse }) => {
  const t = useT();
  let at = 0;
  const parts = row.s.split(' ').map((p) => { const wi = row.map[at]; at += p.length + 1; return { p, w: words[wi] }; });
  return (
    <div style={{ position: 'absolute', left: 50, width: 910, top: y, textAlign: 'center', font: `72px/1 ${BLACK}`, color: CAP, WebkitTextStroke: `14px ${PANEL}`, paintOrder: 'stroke fill', transform: `skewX(-8deg) scale(${pulse})`, whiteSpace: 'nowrap', textShadow: `0 7px 0 ${DEEP}` }}>
      {parts.map(({ p, w }, i) => {
        const key = t >= E('cta') && p === KEYWORD;
        const on = key || (t >= w.t0 - 0.03 && t < w.t1 + 0.08);
        return <span key={i} style={{ display: 'inline-block', margin: '0 14px', color: key ? '#fff' : on ? '#fff' : undefined, transform: on && !key ? `scale(${1 + 0.08 * (1 - ramp(t, w.t0, w.t0 + 0.18))})` : undefined }}>{p}</span>;
      })}
    </div>
  );
};
const Captions: React.FC = () => {
  const t = useT();
  const idx = CHUNKS.reduce((a, c, i) => (t >= c.t0 - 0.06 ? i : a), -1);
  if (idx < 0) return null;
  const c = CHUNKS[idx];
  const quiet = t >= E('quiet') && t < E('ops');
  const single = c.rows.length === 1;
  return <>{c.rows.map((r, ri) => <CapRow key={`${idx}-${ri}`} row={r} words={c.words} y={single || ri === 1 ? 1388 : 1292} pulse={quiet ? 1.25 + 0.07 * Math.abs(Math.sin(t * 11)) : 1} />)}</>;
};

/* ---------- the film ---------- */
export const CallNow: React.FC = () => (
  <Clock>
    <Fonts faces={FACES}>
      <Stage />
    </Fonts>
  </Clock>
);

const Stage: React.FC = () => {
  const t = useT();
  const push = 1 + 0.012 * (t / DURATION);
  const j = shake(t);
  const dim = ramp(t, E('have'), E('have') + 0.5) * (t < E('wait') ? 1 : 0);
  const flash = t >= E('wait') ? 1 - ramp(t, E('wait'), E('wait') + 0.3) : 0;
  const rays = t >= E('wait') ? 1 - ramp(t, E('wait'), E('wait') + 0.75) : 0;
  return (
    <AbsoluteFill style={{ background: STUDIO, overflow: 'hidden', fontFamily: BLACK }}>
      <Defs />
      <AbsoluteFill style={{ transform: `translate(${j}px, ${j * 0.5}px) scale(${push})`, transformOrigin: '50% 40%' }}>
        <Backdrop />
        <Bug />
        <Screen />
        <Burst />
        <OfferPanel />
        <StockPanel />
        <CallBar />
        <Marquee />
        <Floor />
      </AbsoluteFill>
      {dim > 0 ? <AbsoluteFill style={{ background: `radial-gradient(ellipse 66% 34% at 50% 27%, rgba(6,8,40,${0.14 * dim}), rgba(6,8,40,${0.68 * dim}) 100%)` }} /> : null}
      {rays > 0 ? <AbsoluteFill style={{ background: 'repeating-conic-gradient(from 0deg at 50% 31%, rgba(255,225,77,.42) 0 9deg, rgba(255,225,77,0) 9deg 18deg)', opacity: rays, transform: `scale(${1 + (1 - rays) * 0.2})` }} /> : null}
      {flash > 0 ? <AbsoluteFill style={{ background: '#fff4c8', opacity: flash * 0.8 }} /> : null}
      <ButWait />
      <Captions />
      <AbsoluteFill style={{ background: 'repeating-linear-gradient(rgba(0,0,30,.07) 0 2px, rgba(0,0,0,0) 2px 5px)', pointerEvents: 'none' }} />
      <AbsoluteFill style={{ background: 'radial-gradient(120% 80% at 50% 45%, transparent 62%, rgba(4,6,40,.45) 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 shopping-channel host at 3 a.m. who never breathes. Bright, loud, a smile you can hear, every line sold to the back row, and completely sincere about the spoon. Never imitate a real host.

**When to use it:** on every line except the stampede, with almost no gap between lines. The takes should run into each other, so ask for fast, trim hard and keep `lead` and `tail` small, as our `plan.json` does. The one exception is the low point: "It has holes." drops the smile mid-sentence, and "Why does it have holes?" is a real question to somebody off camera. Then "But wait." comes back at full wattage, as if nothing happened. The operator's line is the host again, and the call to action can slow down just enough for the keyword to land. Keep the voice dry and close, with no room on it.

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

## 9. Sound

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

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

| Sound | Length | Prompt idea | When it plays |
|-------|--------|-------------|---------------|
| show sting | 1.5 s | a short brassy TV shopping-show fanfare, three rising notes, warm and round, no voices | the first frame, then quietly on `intro` |
| spoon crash | 1.5 s | a big handful of metal spoons dropped on a studio floor, a clattering pile that settles, warm, no ring | `more`, once, as the heap lands |
| slam | 0.7 s | a heavy padded whoosh ending in a low thump, a banner hitting a stage, warm and dark | `name`, `four`, `three`, `included` |
| sparkle | 1 s | a soft chime run going up, gentle and round, not shrill | `product`, `second` |
| pop | 0.3 s | one round low cork pop, close and dry | `stirs`, `scoops`, `points`, one on each starburst |
| swipe | 0.4 s | a fast soft air whoosh | every wipe between shots, and the spoon's whip on `things` |
| applause | 2 s | a small studio audience clapping politely, warm, a little too eager | `product`, and a shorter burst on `drawer` |
| drawer rattle | 1.5 s | cutlery rattling loose in a wooden drawer, steady and close | `before`, cut dead on `after`, so the "after" really is quieter |
| counter | 1.5 s | a fast mechanical tally counter clicking, dry, not sharp | from `four` to `three`, quietly under `zero` too |
| bowl slide | 0.6 s | a ceramic bowl skidding on marble and settling, one small wobble | `live`, as the bowl arrives |
| soup | 2.5 s | a few thin streams of liquid running into a full bowl, close and plain | `holes`, until the cut to the bars |
| relay clicks | 1 s | small electric relays dropping out one after another, dry | `why`, as the lamps die |
| lights down | 1 s | a bank of studio lights powering down, a low thunk and a falling hum | `have`, as the set dims |
| one cough | 0.5 s | a single person in a small silent audience clearing their throat, distant | on the bars card, once, about 0.5 s after the cut |
| studio hum | 10 s | a low steady electrical hum in an empty TV studio, a very faint test tone behind it, no music | from `why` to `wait`, up as the music drains: this is what keeps the low point alive |
| BUT WAIT | 1.5 s | a huge low brass hit with a deep thump and a short tail, warm, not harsh | `wait`, the loudest thing in the film, with a softer thud on `but` |
| lights on | 0.5 s | banks of heavy stage lights clunking on all at once | `wait`, just under the hit |
| spoon lands | 0.6 s | a metal spoon dropping into a metal holder, a short warm ring | `second` |
| phones | 4 s | many old desk phones ringing at once, muffled and warm, building | starts thin on `quiet` and adds one layer per row of phones, cut on `ops` |
| shop bell | 1 s | a single warm counter bell, one strike | `zero` |
| flip | 0.4 s | a stiff card flipped over, one soft flutter | `cta`, as the call bar turns |
| room tone | 10 s | a busy TV studio between takes, soft air handling, far-off crew, no voices you can make out, no music | the bed, all the way through |

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

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

**The brief:** "Relentlessly cheerful home-shopping TV library music: bouncy synth brass, slap bass, a busy drum machine and bright electric piano, major key, never stopping for breath, with a big lift into a higher key at the top of a bar, no vocals."

**When it plays:** from the first frame, under the voice at a steady level, and a little higher through the rush. On "holes" it warps down and drains to nothing over about 0.5 s, and it stays out while the card is up, so only the hum and the room remain. It comes back on "wait", on the bar where the track lifts, as if nothing happened. It's at its fullest on the voiceless stampede, ducks for the operator's line, and sits low under the call to action until the end.

**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 false claim:** the frame looks like a real ad, so it reads like one. In a real ad, every counter, deadline and deal must be true. If it's a parody, make it plainly a parody.
- **A real number or a real name:** a phone number the user doesn't own rings a stranger. In a parody, stay in 555-01XX. Don't name a real channel or brand, even as the joke.
- **The flaw appears from nowhere:** then the low point is random. Keep it on screen from the first frame, too small to notice, and show it large only in the demo.
- **The turn fixes the flaw:** then the joke is gone. The offer adds more of the same.
- **A black low point:** the set dims, but the bars card, its rolling hum bar and a slow drip of soup keep moving. Never let it go black, and keep the captions at full brightness.
- **Flashing:** keep the call bar under three flashes a second, and let the whole frame flash only once, on the turn. Some viewers are sensitive to it.
- **Everything moving at once:** the set is busy on purpose, so the story needs one clear change per word. If a tile on the contact sheet doesn't tell you what just happened, quiet something else.
- **Readable text under the buttons:** the panels and the call bar stop at x = 950 from y = 900 down. The marquee sign is the only thing out there.
- **Words touching:** the captions' thick outline eats the word spaces. Keep the margin in `CapRow`, and check the longest row against the safe box.

| 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 call-now 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 call-now skill to make a vertical video for [PLATFORM: Instagram Reels / TikTok / YouTube Shorts].

- **What's on the channel tonight:** [TOPIC - the thing being sold, real or silly, e.g. "a spoon that does everything", "my new app", "a seven-step morning routine"]
- **Parody or real ad:** [MODE - "plainly a parody, invent everything", or "a real ad: here are my real stock numbers, deadline, phone line and terms"]
- **The problem nobody has:** [PROBLEM - the hook, said in deadly earnest, e.g. "tired of owning more than one spoon?", or "you write it"]
- **Three features:** [FEATURES - two real ones and one silly one, e.g. "stirs, scoops, points at things", or "you write them"]
- **What goes wrong live:** [FLAW - the flaw the live demo exposes, e.g. "the spoon has holes", or "you write it"]
- **What "but wait" adds:** [OFFER - the offer after the turn, e.g. "a second spoon, which also has holes", or "you write it"]
- **In the window:** [WINDOW - "no person: a studio set drawn in code", or "my own clip, attached"]
- **Length:** [LENGTH - e.g. about 35 seconds]
- **Voice:** [VOICE - e.g. "a shopping-channel host who never breathes", "my own voice", or "no voice: captions and sound only"]
- **Music:** [MUSIC - e.g. "the skill's shopping-channel 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 real brands, no invented numbers in a real ad, phone numbers only from the 555-01XX range unless it's my real line]

Work in this order:

1. Write the script on the skill's moves, with one line for each turn and what changes on the set.
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