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.

Mission Control

A countdown runs, stations report go or no-go, and a camera on the pad watches what's really there: in our film, a very small bakery. One silly thing stops the clock, then the final poll comes back go, go, go. 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 launching What goes wrong The six stations Platform Narrator Ending AI tools
    Use the mission-control skill to make a vertical video for Instagram Reels.
    
    - **What's launching:** [TOPIC - the small project treated like a space launch, e.g. "the first morning of a very small bakery"]
    - **The six stations:** [STATIONS - six things that must be ready, e.g. "oven, dough, coffee, sign, cash, weather", or "you choose"]
    - **What goes wrong:** [NO GO - the small real thing that stops the clock, e.g. "a letter falls off the sign", or "you write it"]
    - **What I want to say:** [MESSAGE - the turn in the story, in a sentence or two, or "you write it"]
    - **In the window:** [WINDOW - "no person: a camera on the pad, drawn in code", or "my own clip, attached"]
    - **Length:** [LENGTH - e.g. about 35 seconds]
    - **Voice:** [VOICE - e.g. "a calm flight director on the loop", "my own voice", or "no voice: captions and sound only"]
    - **Music:** [MUSIC - e.g. "the skill's launch score 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 space agencies or logos, no claims I can't back up]
    
    Work in this order:
    
    1. Write the script on the skill's moves, with one line for each turn and what changes on the console.
    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 console 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 · 1773 lines · 97 KB
Copy Raw file
---
name: mission-control
description: Make a 30-40 second vertical video where a launch console tells the story - a countdown clock, a go/no-go board and a camera on the pad treat your small project like a space launch. The skill covers the control-room sound design, music fitted to the story and an optional voice like a calm flight director. Use for launches and openings, big days with many things that must be ready, plans that nearly fell apart, or when asked for a countdown, rocket launch or mission control video, or when given the mission-control prompt.
---

# Mission Control

A launch console fills the screen, and your small project is the rocket. A clock counts down, stations report go or no-go, and a camera on the pad watches what's really there: in our film, a very small bakery. One station fails for a silly reason, the clock stops, and a flat voice on the loop reports all of it with complete gravity. Then the final poll comes back go, go, go, and it's hard not to root for it.

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/mission-control/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/barlow-condensed` and `@fontsource/vt323`. 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/MissionControl.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: stations flip between go and no-go, the clock runs and holds, the signal drops and comes back, the pad camera goes from before dawn to rain to morning. 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 launch control room, seen as one console.

- **Palette:** console slate `#4e5d66`, panel `#2f3a40`, screen `#0b0f0c`, countdown amber `#ffb000`, go green `#39d353`, no-go red `#ff4d3d`, labels `#d6d3c6`.
- **Type:** Barlow Condensed (600 and 700) for labels and captions, VT323 for anything on a screen.
- **Fixed parts, top to bottom:** the console title (y 40-88), a status screen with the local time and a flight path (y 100-238), the countdown strip (y 250-362), the pad camera (y 372-858), six station tiles (y 878-1084), a telemetry trace, the CAPCOM caption panel (y 1214-1466), then push buttons and a vent as texture below the safe area, and a rack of toggles down the right edge.
- **The go rule:** a tile starts at `--`, turns amber POLL when the poll opens, then settles on GO or NO-GO with its lamp. A status holds 8 letters at most. Stagger a whole-board change by about 0.06 s a tile, so the eye runs along it.
- **The clock rule:** the countdown runs fast between lines, because the film compresses time. It stops dead and turns red on the hold, and it doesn't move again until the final poll. It reaches zero on liftoff and becomes a T+ clock. The strip's big word then tracks the story: LIFTOFF, MAX Q, STAGE SEP, ORBIT, SOLD OUT.
- **Nothing real on the console:** never a real space agency's name, patch or logo.
- **The status screen is for small jokes:** ALT 0 FT, always. A counter that matters to your topic, like CROISSANTS. It sits above the safe area, so nothing there has to be read.

**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 no-person scene is a camera on the launch pad, labeled PAD CAM 2, pointed at the small project being launched. Ours is a bakery between two floodlight towers, and it tracks the story:

| Story moment | In the window |
|---|---|
| Opening | Before dawn: the shop on its pad, floodlights on, smoke from the chimney, the sign reading OPEN |
| No-go | The camera zooms in 2x on the sign, and the N falls off and lands on the pavement: OPE |
| Weather | Rain starts and the sky goes dark |
| The low point | NO SIGNAL over a torn, flickering picture. A tracking bracket finds POSSIBLE DOG |
| The call | The signal comes back, and the shutter lifts halfway as the owner walks out |
| The workaround | The owner on a stepladder, and a cardboard N goes up |
| Final poll | The rain thins and the first light arrives |
| Liftoff | The shutter rolls up, steam pours out, the floodlights swing to the sky, the shop floods with light, the sun rises |
| Customers | People arrive with tracking tags: CUST 001, CUST 002, then a queue |
| Stage separation | The dog trots off with a croissant: CUST 001 DEPARTING |
| Orbit | Full daylight, the shutter down, a SOLD OUT card in the door |

Keep the subject small and grounded. There's no real rocket, and nothing ever leaves the ground: that's the joke. With the user's own clip, the window is the same camera, with PAD CAM 2 and the tracking brackets still over it.

## 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 takes a very small project with total seriousness. Ours is the first morning of a bakery.

| # | Move | What it does | Our line | On the console |
|---|------|--------------|----------|----------------|
| 1 | Countdown | The hook: the small project, at launch scale | "T minus ten minutes to the launch of a very small bakery." | The clock starts, and the pad shows the shop |
| 2 | The poll | Opens the checklist | "Go, no-go poll." | Every tile turns amber POLL |
| 3 | Go, go, go | Quick wins, a rhythm to break | "Oven, go." "Dough, go." "Coffee, go." | Tiles flip green, one on each word |
| 4 | No-go | The thing that fails, as silly as it is real | "Sign is no-go. I repeat, the letter N has fallen off." | The camera zooms in and the N drops. SIGN NO-GO, red |
| 5 | Hold | Everything stops | "Hold, hold, hold. The clock is stopped." | The clock freezes in red at T- 00:04:12, on HOLD |
| 6 | Low point | Bad news, reported flat | "Weather reports rain, and one possible customer. It may be a dog." | Rain, LOSS OF SIGNAL, NO SIGNAL, the console dims and flickers, POSSIBLE DOG. Music out; static and rain keep running |
| 7 | The call | The turn: someone decides | "Flight, the owner says we go with what we have." | SIGNAL ACQUIRED, the shutter lifts halfway. Music returns on "go" |
| 8 | The workaround | The fix is humble, and that's the point | "Sign team reports a marker and a piece of cardboard." | A cardboard N goes up. SIGN GO |
| 9 | Final poll | Momentum | "All stations, final poll." "Go." "Go." "Go." | The clock runs again, and the last tiles go green on each word |
| 10 | Liftoff | The voiceless moment, about 3 s | (music peaks, captions read LIFTOFF, THE DOOR IS OPEN) | The door rolls up, steam and light, the console shakes |
| 11 | Telemetry | The payoff, still deadpan | "We have a customer. We have a second customer." | MAX Q, the customer count climbs |
| 12 | Stage separation | The last laugh pays off the setup | "Stage separation. The dog has left with a croissant." | STAGE SEP, the dog trots off |
| 13 | Orbit | The win | "Orbit achieved. Sold out by noon." | SOLD OUT, huge. The stations read COOL, NONE, NONE, SOLD OUT, FULL, SUN |
| 14 | Call to action | One action | "Comment liftoff and I'll send you the skill that made this." | COMMENT: LIFTOFF on the strip, and the keyword stays big in the panel |

**For other topics:** a first newsletter, a wedding morning, moving day, a kid's first day at school, a product launch told straight. Choose six stations that really must be ready, and let the one that fails be small and real. The trick that makes it land is the turn: nothing gets fixed properly, and someone decides to go anyway with a marker and a piece of cardboard. Find your cardboard first. Our script reads about 46 s on the clock's estimate, and our voice ran it in 41 s. For 35 s, keep one early go, merge moves 11 and 12 into one line, and shorten the no-go and the low point to one sentence each. Run the clock after each cut.

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

The window is the real work here. When we tested this skill on a new topic, about 30 % of this file changed, nearly all of it in `Pad`, `Lens` and `Window`.

| To change | Where |
|---|---|
| What's on the pad | `Pad` draws the whole scene, with `Person`, `Dog` and `Letter`. Keep the towers, the floodlights and the weather, and redraw the rest |
| What the tracking brackets follow | `Lens`: the positions, and labels like POSSIBLE DOG and CUST 001 |
| Where the camera zooms | `Window`: `fx` and `fy` are aimed at our sign, then the shopfront. Aim them at your no-go |
| The stations | `STATIONS`: each tile's label and schedule |
| The numbers | `HOLD_AT`, `tMinus`, `tPlus`, `localTime`, `croissants` and `customers`, near the top |
| The status screen | `TopBand`: MISSION BAKERY-1, CROISSANTS, and `PHASES` for the flight path |
| The strip's big word | `CountdownStrip`: LIFTOFF, MAX Q, STAGE SEP, ORBIT, SOLD OUT, NEXT MISSION |
| The trace's readout | `Telemetry`: `read` holds OVEN 220 C, DOOR OPEN, CUSTOMERS, SIGNAL ACQUIRED and UPLINK OPEN |
| The big captions | `Captions`: LIFTOFF, THE DOOR IS OPEN, SOLD OUT, BY NOON |
| The comment keyword | `plan.json`, the `keyword` event in `story.json`, `CTA_REST` and the last block of `Captions`, `CountdownStrip`, and `CUT_AFTER.s19` |

- **Scene ids in the code:** `s13` and `s14` (the final GOs stack on one line), `s19` (the call to action) and every key in `CUT_AFTER`. If you renumber scenes, update these too: a stale id doesn't fail the render, it just loses that behavior.
- **`pw()` needs its points in time order and doesn't check.** After reordering events, watch the clock and the counters on the animatic.
- **Layers:** `Stage` draws back to front. In `Window`, `Lens` sits outside the zoomed layer, so its labels keep their size while the camera zooms, and it takes the same `tx`, `ty` and `z` to stay on target.
- **A story longer than a morning:** the clocks assume one morning. Over weeks, count T+ in days and replace LOCAL with DAY.

### The code

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

```json
{
 "lines": [
  {
   "id": "l01",
   "text": "T minus ten minutes to the launch of a very small bakery."
  },
  {
   "id": "l02",
   "text": "Go, no-go poll."
  },
  {
   "id": "l03",
   "text": "Oven, go."
  },
  {
   "id": "l04",
   "text": "Dough, go."
  },
  {
   "id": "l05",
   "text": "Coffee, go."
  },
  {
   "id": "l06",
   "text": "Sign is no-go. I repeat, the letter N has fallen off."
  },
  {
   "id": "l07",
   "text": "Hold, hold, hold. The clock is stopped."
  },
  {
   "id": "l08",
   "text": "Weather reports rain, and one possible customer. It may be a dog."
  },
  {
   "id": "l09",
   "text": "Flight, the owner says we go with what we have."
  },
  {
   "id": "l10",
   "text": "Sign team reports a marker and a piece of cardboard."
  },
  {
   "id": "l11",
   "text": "All stations, final poll."
  },
  {
   "id": "l12",
   "text": "Go."
  },
  {
   "id": "l13",
   "text": "Go."
  },
  {
   "id": "l14",
   "text": "Go."
  },
  {
   "id": "l15",
   "text": "We have a customer. We have a second customer."
  },
  {
   "id": "l16",
   "text": "Stage separation. The dog has left with a croissant."
  },
  {
   "id": "l17",
   "text": "Orbit achieved. Sold out by noon."
  },
  {
   "id": "l18",
   "text": "Comment liftoff and I'll send you the skill that made this.",
   "say": "Comment, liftoff, and I'll send you the skill that made this."
  }
 ],
 "scenes": [
  {
   "id": "s01",
   "lines": [
    "l01"
   ],
   "lead": 0.22,
   "tail": 0.14
  },
  {
   "id": "s02",
   "lines": [
    "l02"
   ],
   "lead": 0.24,
   "tail": 0.12
  },
  {
   "id": "s03",
   "lines": [
    "l03"
   ],
   "lead": 0.05,
   "tail": 0.14
  },
  {
   "id": "s04",
   "lines": [
    "l04"
   ],
   "lead": 0.05,
   "tail": 0.14
  },
  {
   "id": "s05",
   "lines": [
    "l05"
   ],
   "lead": 0.05,
   "tail": 0.14
  },
  {
   "id": "s06",
   "lines": [
    "l06"
   ],
   "lead": 0.24,
   "tail": 0.14
  },
  {
   "id": "s07",
   "lines": [
    "l07"
   ],
   "lead": 0.24,
   "tail": 0.14
  },
  {
   "id": "s08",
   "lines": [
    "l08"
   ],
   "lead": 0.24,
   "tail": 0.16
  },
  {
   "id": "s09",
   "lines": [
    "l09"
   ],
   "lead": 0.5,
   "tail": 0.14
  },
  {
   "id": "s10",
   "lines": [
    "l10"
   ],
   "lead": 0.24,
   "tail": 0.14
  },
  {
   "id": "s11",
   "lines": [
    "l11"
   ],
   "lead": 0.24,
   "tail": 0.1
  },
  {
   "id": "s12",
   "lines": [
    "l12"
   ],
   "lead": 0.05,
   "tail": 0.12
  },
  {
   "id": "s13",
   "lines": [
    "l13"
   ],
   "lead": 0.05,
   "tail": 0.12
  },
  {
   "id": "s14",
   "lines": [
    "l14"
   ],
   "lead": 0.05,
   "tail": 0.06
  },
  {
   "id": "s15",
   "sec": 3.1
  },
  {
   "id": "s16",
   "lines": [
    "l15"
   ],
   "lead": 0.2,
   "tail": 0.14
  },
  {
   "id": "s17",
   "lines": [
    "l16"
   ],
   "lead": 0.24,
   "tail": 0.14
  },
  {
   "id": "s18",
   "lines": [
    "l17"
   ],
   "lead": 0.24,
   "tail": 0.14
  },
  {
   "id": "s19",
   "lines": [
    "l18"
   ],
   "lead": 0.24,
   "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 { MissionControl } from './frames/MissionControl';
import spec from './spec.json';

export const Root: React.FC = () => (
  <Composition id="Film-9x16" component={MissionControl} 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": "xa",
   "scene": "s01"
  },
  {
   "id": "tminus",
   "scene": "s01",
   "word": "ten"
  },
  {
   "id": "xb",
   "scene": "s02"
  },
  {
   "id": "poll",
   "scene": "s02",
   "word": "poll"
  },
  {
   "id": "oven",
   "scene": "s03",
   "word": "go"
  },
  {
   "id": "dough",
   "scene": "s04",
   "word": "go"
  },
  {
   "id": "coffee",
   "scene": "s05",
   "word": "go"
  },
  {
   "id": "xc",
   "scene": "s06"
  },
  {
   "id": "sign",
   "scene": "s06",
   "word": "sign"
  },
  {
   "id": "nogo",
   "scene": "s06",
   "word": "no-go"
  },
  {
   "id": "fallen",
   "scene": "s06",
   "word": "fallen"
  },
  {
   "id": "xd",
   "scene": "s07"
  },
  {
   "id": "hold",
   "scene": "s07",
   "word": "hold"
  },
  {
   "id": "stopped",
   "scene": "s07",
   "word": "stopped"
  },
  {
   "id": "xe",
   "scene": "s08"
  },
  {
   "id": "weather",
   "scene": "s08",
   "word": "weather"
  },
  {
   "id": "rain",
   "scene": "s08",
   "word": "rain"
  },
  {
   "id": "contact",
   "scene": "s08",
   "word": "customer"
  },
  {
   "id": "dog",
   "scene": "s08",
   "word": "dog"
  },
  {
   "id": "xf",
   "scene": "s09"
  },
  {
   "id": "flight",
   "scene": "s09",
   "word": "flight"
  },
  {
   "id": "owner",
   "scene": "s09",
   "word": "owner"
  },
  {
   "id": "goturn",
   "scene": "s09",
   "word": "go"
  },
  {
   "id": "xg",
   "scene": "s10"
  },
  {
   "id": "marker",
   "scene": "s10",
   "word": "marker"
  },
  {
   "id": "cardboard",
   "scene": "s10",
   "word": "cardboard"
  },
  {
   "id": "xh",
   "scene": "s11"
  },
  {
   "id": "final",
   "scene": "s11",
   "word": "final"
  },
  {
   "id": "goa",
   "scene": "s12",
   "word": "go"
  },
  {
   "id": "gob",
   "scene": "s13",
   "word": "go"
  },
  {
   "id": "goc",
   "scene": "s14",
   "word": "go"
  },
  {
   "id": "liftoff",
   "scene": "s15"
  },
  {
   "id": "door",
   "scene": "s15",
   "off": 0.12
  },
  {
   "id": "xi",
   "scene": "s16"
  },
  {
   "id": "custa",
   "scene": "s16",
   "word": "customer"
  },
  {
   "id": "custb",
   "scene": "s16",
   "word": "second"
  },
  {
   "id": "xj",
   "scene": "s17"
  },
  {
   "id": "stage",
   "scene": "s17",
   "word": "stage"
  },
  {
   "id": "dogleft",
   "scene": "s17",
   "word": "dog"
  },
  {
   "id": "croissant",
   "scene": "s17",
   "word": "croissant"
  },
  {
   "id": "xk",
   "scene": "s18"
  },
  {
   "id": "orbit",
   "scene": "s18",
   "word": "orbit"
  },
  {
   "id": "sold",
   "scene": "s18",
   "word": "sold"
  },
  {
   "id": "cta",
   "scene": "s19"
  },
  {
   "id": "keyword",
   "scene": "s19",
   "word": "liftoff"
  }
 ]
}
```

`src/frames/MissionControl.tsx`:

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

/* Mission Control: a launch console where every turn in the story is a go/no-go. 1080 x 1920. The window is the pad camera, and
   what's on the pad follows the story. Time comes from the spoken words through story.json, so a re-voice moves every change with its word. */
const E = makeE(story as StoryJson);
const SLATE = '#4e5d66', PANEL = '#2f3a40', CRT = '#0b0f0c', AMBER = '#ffb000', GO = '#39d353', RED = '#ff4d3d', OFF = '#6b7478', LABEL = '#d6d3c6', INK = '#e8e6dc';
const COND = '"Barlow Condensed", sans-serif';
const MONO = 'VT323, monospace';
const FACES = [
  { family: 'Barlow Condensed', file: 'barlow-condensed-latin-600-normal.woff2', weight: '600' },
  { family: 'Barlow Condensed', file: 'barlow-condensed-latin-700-normal.woff2', weight: '700' },
  { family: 'VT323', file: 'vt323-latin-400-normal.woff2', weight: '400' },
];

/** Piecewise line through [time, value] points. */
const pw = (t: number, pts: [number, number][]) => {
  if (t <= pts[0][0]) return pts[0][1];
  for (let i = 1; i < pts.length; i++) if (t < pts[i][0]) return pts[i - 1][1] + (pts[i][1] - pts[i - 1][1]) * ramp(t, pts[i - 1][0], pts[i][0]);
  return pts[pts.length - 1][1];
};
const p2 = (n: number) => String(Math.floor(n)).padStart(2, '0');
const hms = (s: number) => `${p2(s / 3600)}:${p2((s % 3600) / 60)}:${p2(s % 60)}`;
const hhmm = (s: number) => `${p2(s / 3600)}:${p2((s % 3600) / 60)}`;

/* ---- the story's numbers ---- */
const HOLD_AT = 252; // the countdown stops at T- 00:04:12
const tMinus = (t: number) => pw(t, [
  [0, 600], [E('poll') - 0.3, 600 - (E('poll') - 0.3)], [E('oven'), 540], [E('dough'), 480], [E('coffee'), 420], [E('nogo'), HOLD_AT + (E('hold') - E('nogo'))], [E('hold'), HOLD_AT],
  [E('xh'), HOLD_AT], [E('goa'), 3], [E('gob'), 2], [E('goc'), 1], [E('liftoff'), 0],
]);
const tPlus = (t: number) => pw(t, [[E('liftoff'), 0], [E('xi'), E('xi') - E('liftoff')], [E('custb'), 40], [E('stage'), 1100], [E('croissant'), 3900], [E('orbit'), 15000], [E('sold'), 17520]]);
const localTime = (t: number) => (t >= E('liftoff') ? 7 * 3600 + tPlus(t) : pw(t, [[0, 6.5 * 3600], [E('hold'), 6.6 * 3600], [E('final'), 6.95 * 3600], [E('liftoff'), 7 * 3600]]));
const croissants = (t: number) => Math.round(pw(t, [[E('liftoff'), 48], [E('custb'), 47], [E('dogleft'), 41], [E('croissant'), 30], [E('orbit'), 6], [E('sold'), 0]]));
const customers = (t: number) => (t < E('custa') ? 0 : t < E('custb') ? 1 : Math.round(pw(t, [[E('custb'), 2], [E('stage'), 7], [E('croissant'), 22], [E('sold'), 48]])));

type St = [number, string, string];
const STATIONS: { n: string; s: St[] }[] = [
  { n: 'OVEN', s: [[0, '--', OFF], [E('poll'), 'POLL', AMBER], [E('oven'), 'GO', GO], [E('final'), 'POLL', AMBER], [E('goa'), 'GO', GO], [E('sold'), 'COOL', GO]] },
  { n: 'DOUGH', s: [[0, '--', OFF], [E('poll') + 0.06, 'POLL', AMBER], [E('dough'), 'GO', GO], [E('final') + 0.06, 'POLL', AMBER], [E('goa') + 0.12, 'GO', GO], [E('sold') + 0.08, 'NONE', AMBER]] },
  { n: 'COFFEE', s: [[0, '--', OFF], [E('poll') + 0.12, 'POLL', AMBER], [E('coffee'), 'GO', GO], [E('final') + 0.12, 'POLL', AMBER], [E('gob'), 'GO', GO], [E('sold') + 0.16, 'NONE', AMBER]] },
  { n: 'SIGN', s: [[0, '--', OFF], [E('poll') + 0.18, 'POLL', AMBER], [E('nogo'), 'NO-GO', RED], [E('cardboard'), 'GO', GO], [E('final') + 0.18, 'POLL', AMBER], [E('gob') + 0.12, 'GO', GO], [E('sold') + 0.24, 'SOLD OUT', GO]] },
  { n: 'CASH', s: [[0, '--', OFF], [E('poll') + 0.24, 'POLL', AMBER], [E('goc'), 'GO', GO], [E('sold') + 0.32, 'FULL', GO]] },
  { n: 'WEATHER', s: [[0, '--', OFF], [E('poll') + 0.3, 'POLL', AMBER], [E('weather'), 'NO-GO', RED], [E('goc') + 0.12, 'GO', GO], [E('sold') + 0.4, 'SUN', GO]] },
];
const LOS_ON = E('contact'), LOS_OFF = E('goturn');
const isDead = (tt: number) => tt >= LOS_ON && tt < LOS_OFF;
const losK = (t: number) => ramp(t, LOS_ON - 0.1, LOS_ON + 0.5) * (1 - ramp(t, LOS_OFF, LOS_OFF + 0.35));

/* ---- the pad camera: a tiny bakery on its launch pad, drawn in code. 976 x 462. ---- */
const VW = 976, VH = 462, GROUND = 388;
const LETTER_X = [364, 426, 488, 550];

const Puffs: React.FC<{ t: number; x: number; y: number; n: number; rise: number; wide: number; r0: number; r1: number; a: number; drift: number; seed: number }> = ({ t, x, y, n, rise, wide, r0, r1, a, drift, seed }) => (
  <>
    {Array.from({ length: n }, (_, i) => {
      const ph = (t * (0.3 + hash(i + seed) * 0.22) + hash(i + seed + 50)) % 1;
      const px = x + Math.sin(ph * 5 + i) * (5 + ph * wide * 0.4) + ph * (drift + wide * (hash(i + seed + 9) - 0.5));
      const py = y - ph * rise;
      const r = r0 + ph * r1;
      const op = a * (1 - ph) * ramp(ph, 0, 0.08);
      return <circle key={i} cx={px} cy={py} r={r} fill="url(#puff)" opacity={op} />;
    })}
  </>
);

const Mast: React.FC<{ x: number; dir: number; angle: number; beam: number; t: number }> = ({ x, dir, angle, beam, t }) => {
  const top = 112;
  return (
    <>
      <g stroke="#10141c" strokeWidth={3} fill="none">
        <path d={`M${x - 11} ${GROUND} L${x - 5} ${top} M${x + 11} ${GROUND} L${x + 5} ${top}`} />
        {Array.from({ length: 7 }, (_, i) => { const y0 = top + 8 + i * 38, k = (y0 - top) / (GROUND - top), w = 5 + 6 * k; return <path key={i} strokeWidth={1.6} d={`M${x - w} ${y0} L${x + w} ${y0 + 38} M${x + w} ${y0} L${x - w} ${y0 + 38}`} />; })}
      </g>
      <rect x={x - 26} y={top - 18} width={52} height={22} rx={3} fill="#0d1016" />
      {[-16, 0, 16].map((d) => <circle key={d} cx={x + d} cy={top - 7} r={6.5} fill={interpolateColors(beam, [0, 1], ['#3a3f46', '#fff6d8'])} />)}
      <g opacity={beam} transform={`rotate(${angle} ${x} ${top - 6})`} style={{ mixBlendMode: 'screen', filter: 'blur(5px)' }}>
        <polygon points={`${x - 20},${top - 6} ${x + 20},${top - 6} ${x + 86},${top + 560} ${x - 86},${top + 560}`} fill="url(#beam)" />
      </g>
      <circle cx={x} cy={top - 7} r={46 + 3 * Math.sin(t * 7 + dir)} fill="url(#lamp)" opacity={beam * 0.85} />
    </>
  );
};

const Person: React.FC<{ x: number; s: number; coat: string; skin: string; walk: number; t: number; seed: number; apron?: boolean; op?: number; y?: number }> = ({ x, s, coat, skin, walk, t, seed, apron, op = 1, y = GROUND + 8 }) => {
  const sw = Math.sin(t * 11 + seed) * 9 * walk;
  const bob = walk ? Math.abs(Math.sin(t * 11 + seed)) * 2.5 * walk : Math.sin(t * 1.6 + seed) * 0.6;
  return (
    <g transform={`translate(${x} ${y - bob}) scale(${s})`} opacity={op}>
      <path d={`M-5 -26 L${-5 + sw * 0.7} 0 M5 -26 L${5 - sw * 0.7} 0`} stroke="#14171f" strokeWidth={7} strokeLinecap="round" />
      <rect x={-11} y={-62} width={22} height={40} rx={9} fill={coat} />
      {apron ? <rect x={-8} y={-48} width={16} height={26} rx={3} fill="#f1e9d8" /> : null}
      <circle cx={0} cy={-72} r={9.5} fill={skin} />
      {apron ? <rect x={-9} y={-86} width={18} height={9} rx={3} fill="#f1e9d8" /> : hash(seed + 3) > 0.5 ? <path d="M-10 -74 A10 10 0 0 1 10 -74 Z" fill="#23272f" /> : null}
    </g>
  );
};

const Dog: React.FC<{ x: number; s: number; walk: number; t: number; sit: number; croissant: boolean }> = ({ x, s, walk, t, sit, croissant }) => {
  const sw = Math.sin(t * 15) * 6 * walk;
  const wag = Math.sin(t * 13) * 14;
  const COAT = '#94683f', DARK = '#6a4628';
  const crumb = croissant ? <g transform="translate(24 -17) scale(1.3) translate(-24 16)"><path d="M24 -16 C28 -28 44 -28 48 -16 C44 -21 41 -21 39 -20 L37 -15 C36 -19 33 -20 31 -20 C28 -20 26 -19 24 -16 Z" fill="#e7a84a" stroke="#a96a22" strokeWidth={1.2} /></g> : null;
  return (
    <g transform={`translate(${x} ${GROUND + 14}) scale(${s})`}>
      {sit > 0.5 ? (
        <>
          <g transform={`rotate(${wag * 0.6} -14 -3)`}><path d="M-14 -3 L-27 -4" stroke={COAT} strokeWidth={4} strokeLinecap="round" /></g>
          <circle cx={-9} cy={-9} r={10} fill={COAT} />
          <g transform="rotate(32 -2 -17)"><ellipse cx={-2} cy={-17} rx={8.5} ry={14} fill={COAT} /></g>
          <path d="M6 -14 L6 0 M10 -14 L10 0" stroke={DARK} strokeWidth={4.2} strokeLinecap="round" />
          <circle cx={7} cy={-33} r={8} fill={COAT} />
          <rect x={10} y={-34} width={11} height={6.5} rx={3} fill={DARK} />
          <path d="M2 -40 L0 -29 L8 -35 Z" fill={DARK} />
        </>
      ) : (
        <>
          <path d={`M-13 -11 L${-13 + sw} 0 M-8 -11 L${-8 - sw} 0 M9 -11 L${9 + sw} 0 M14 -11 L${14 - sw} 0`} stroke={DARK} strokeWidth={4.2} strokeLinecap="round" />
          <ellipse cx={0} cy={-15} rx={19} ry={8.5} fill={COAT} />
          <g transform={`rotate(${wag + 18} -17 -17)`}><path d="M-17 -17 L-29 -26" stroke={COAT} strokeWidth={4} strokeLinecap="round" /></g>
          <circle cx={19} cy={-23} r={8} fill={COAT} />
          <rect x={22} y={-24} width={11} height={6.5} rx={3} fill={DARK} />
          <path d="M14 -30 L12 -20 L19 -26 Z" fill={DARK} />
          {crumb}
        </>
      )}
    </g>
  );
};

const Pad: React.FC = () => {
  const t = useT();
  // the weather and the time of day: dawn, then rain, the dark low point, a clearing dawn, a bright morning
  const storm = ramp(t, E('weather'), E('weather') + 1.2) * (1 - ramp(t, E('cardboard'), E('goa')));
  const los = losK(t);
  const dawn = ramp(t, E('xh') - 0.4, E('xi') + 0.6);
  const day = ramp(t, E('stage'), E('sold'));
  const noon = (a: string, b: string, c: string, s: string, l: string) => interpolateColors(los, [0, 1], [interpolateColors(storm, [0, 1], [interpolateColors(day, [0, 1], [interpolateColors(dawn, [0, 1], [a, b]), c]), s]), l]);
  const top = noon('#1a2350', '#3a6ea8', '#7ab8ea', '#161b27', '#06080e');
  const bot = noon('#d07f58', '#ffcf91', '#fff0d2', '#3d4656', '#10141c');
  const amb = clamp(0.3 - 0.24 * dawn - 0.06 * day + 0.26 * storm + 0.3 * los);
  const lightUp = (night: string, noonColor: string) => interpolateColors(ramp(day, 0.25, 1), [0, 1], [night, noonColor]);
  // the floodlights hold on the shop, then swing up to the sky at liftoff and go out in the daylight
  const swing = easeInOut(ramp(t, E('liftoff'), E('liftoff') + 1.25));
  const sway = swing * Math.sin((t - E('liftoff')) * 1.3) * 7;
  const beam = clamp(1 - 0.72 * los - ramp(t, E('stage'), E('croissant') + 0.6)) * (t < E('goturn') && t > LOS_ON && hash(Math.floor(t * 11)) > 0.72 ? 0.55 : 1);
  const steamUp = ramp(t, E('liftoff'), E('liftoff') + 0.5) * (1 - ramp(t, E('liftoff') + 2.6, E('custb')));
  const rollA = ramp(t, E('liftoff') - 0.03, E('liftoff') + 0.12), rollB = ramp(t, E('liftoff') + 2.3, E('custa') + 0.2);
  const dropHose = easeOut(ramp(t, E('liftoff') + 0.1, E('liftoff') + 0.75));
  // door and light
  const doorOwner = ramp(t, E('owner'), E('owner') + 0.35) * (1 - ramp(t, E('marker') - 0.3, E('marker'))) * 0.45;
  const doorLift = easeOut(ramp(t, E('door'), E('door') + 0.55)) * (1 - ramp(t, E('sold') - 0.25, E('sold') + 0.1));
  const door = Math.max(doorOwner, doorLift);
  const inside = Math.max(ramp(t, E('owner'), E('owner') + 0.2) * 0.55, ramp(t, E('liftoff'), E('liftoff') + 0.4)) * (1 - 0.65 * ramp(t, E('orbit'), E('sold')));
  const pour = doorLift * (1 - 0.72 * day);
  const flash = ramp(t, E('door'), E('door') + 0.25) * (1 - ramp(t, E('door') + 0.25, E('xi') + 0.5));
  const rainFade = storm;
  // the sun comes up behind the shop at liftoff
  const sunY = 250 - 170 * easeOut(dawn) - 120 * day;
  // the sign: the N lets go on "no-go" and the cardboard one goes up on "cardboard"
  const fall = t - E('nogo');
  const nDrop = fall < 0 ? 0 : Math.min(GROUND - 22 - 106, 0.5 * 980 * fall * fall);
  const nLand = fall > 0 && nDrop >= GROUND - 22 - 106;
  const nRot = fall < 0 ? 0 : nLand ? 96 : clamp(fall / 0.46) * 96;
  const nX = fall < 0 ? 0 : clamp(fall / 0.46) * 34;
  const cardIn = ramp(t, E('cardboard') - 0.06, E('cardboard') + 0.2);
  const cardDraw = ramp(t, E('cardboard') + 0.02, E('cardboard') + 0.5);
  const soldCard = back(ramp(t, E('sold') - 0.05, E('sold') + 0.28));
  // the owner: in the doorway on the turn, on a stepladder for the fix, in the doorway again at liftoff
  const onLadder = ramp(t, E('marker') - 0.35, E('marker')) * (1 - ramp(t, E('xh'), E('xh') + 0.3));
  const inDoorA = ramp(t, E('owner'), E('owner') + 0.3) * (1 - ramp(t, E('marker') - 0.35, E('marker') - 0.1));
  const inDoorB = ramp(t, E('door') + 0.1, E('door') + 0.5) * (1 - ramp(t, E('sold') - 0.3, E('sold')));
  // the dog
  const dogIn = ramp(t, E('contact') - 1.0, E('contact'));
  const dogToDoor = easeInOut(ramp(t, E('custa') - 0.85, E('custa') + 0.15));
  const dogOut = ramp(t, E('dogleft') - 0.05, E('dogleft') + 3.2);
  const dogX = -40 + dogIn * 210 + dogToDoor * 330 + dogOut * 668;
  const dogWalk = (dogIn > 0 && dogIn < 1) || (dogToDoor > 0 && dogToDoor < 1) || dogOut > 0 ? 1 : 0;
  const dogSit = dogWalk ? 0 : 1;
  // the queue
  const QUEUE = [
    { c: '#4a6a8a', s: 'E0B896', at: E('custb') - 0.75 }, { c: '#8a4a48', s: 'C89874', at: E('custb') + 0.2 }, { c: '#5f7d58', s: 'E0B896', at: E('custb') + 0.75 }, { c: '#a97f3c', s: '9A6F50', at: E('stage') - 0.1 },
    { c: '#6a5a86', s: 'E0B896', at: E('stage') + 0.45 }, { c: '#3f6f73', s: 'C89874', at: E('dogleft') - 0.2 }, { c: '#88604a', s: 'E0B896', at: E('dogleft') + 0.45 },
  ];
  // birds off the roof at liftoff
  const birds = ramp(t, E('door'), E('door') + 2.6);
  return (
    <>
      <svg viewBox={`0 0 ${VW} ${VH}`} width={VW} height={VH} style={{ position: 'absolute', left: 0, top: 0, display: 'block' }}>
        <defs>
          <linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor={top} /><stop offset="0.42" stopColor={bot} /></linearGradient>
          <radialGradient id="sun" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stopColor="#fff7da" stopOpacity="1" /><stop offset="0.3" stopColor="#ffd98f" stopOpacity="0.7" /><stop offset="1" stopColor="#ffb860" stopOpacity="0" /></radialGradient>
          <radialGradient id="puff" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stopColor="#fff6e6" stopOpacity="0.95" /><stop offset="0.6" stopColor="#f3e7d6" stopOpacity="0.5" /><stop offset="1" stopColor="#e9dccb" stopOpacity="0" /></radialGradient>
          <radialGradient id="lamp" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stopColor="#fff8df" stopOpacity="0.95" /><stop offset="1" stopColor="#fff0c0" stopOpacity="0" /></radialGradient>
          <radialGradient id="glow" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stopColor="#ffd27a" stopOpacity="0.95" /><stop offset="0.5" stopColor="#ffb257" stopOpacity="0.42" /><stop offset="1" stopColor="#ff9a3c" stopOpacity="0" /></radialGradient>
          <linearGradient id="beam" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#fff4cc" stopOpacity="0.62" /><stop offset="0.55" stopColor="#fff0c0" stopOpacity="0.22" /><stop offset="0.9" stopColor="#fff0c0" stopOpacity="0" /></linearGradient>
          <linearGradient id="pour" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#ffd884" stopOpacity="0.95" /><stop offset="1" stopColor="#ffb85c" stopOpacity="0.1" /></linearGradient>
        </defs>
        <rect x={0} y={0} width={VW} height={GROUND} fill="url(#sky)" />
        <g opacity={(1 - dawn) * (1 - storm)}>
          {Array.from({ length: 46 }, (_, i) => <circle key={i} cx={hash(i + 90) * VW} cy={hash(i + 190) * 170} r={0.7 + hash(i + 290) * 1.2} fill="#dfe8ff" opacity={0.3 + 0.5 * Math.abs(Math.sin(t * (0.6 + hash(i) * 1.4) + i))} />)}
        </g>
        <circle cx={500} cy={sunY} r={250} fill="url(#sun)" opacity={clamp(dawn * 1.1 + 0.14) * (1 - storm * 0.9) * (1 - los)} />
        {/* the far roofs */}
        <g fill={lightUp('#232a3d', '#9aa3ad')}>{Array.from({ length: 22 }, (_, i) => { const w = 34 + hash(i) * 46, h = 80 + hash(i + 40) * 96; return <rect key={i} x={i * 46 - 10} y={GROUND - h} width={w} height={h} />; })}</g>
        {/* the neighbors, shut */}
        <g>
          <rect x={0} y={176} width={302} height={GROUND - 176} fill={lightUp('#39405a', '#c2a98f')} />
          <polygon points="-10,176 150,132 312,176" fill={lightUp('#2a2f44', '#7d5a4b')} />
          <rect x={676} y={158} width={310} height={GROUND - 158} fill={lightUp('#3a3a52', '#a9b0a4')} />
          <polygon points="666,158 830,110 986,158" fill={lightUp('#2a2a40', '#6b5a58')} />
          {Array.from({ length: 8 }, (_, i) => { const x = (i < 4 ? 26 + i * 70 : 704 + (i - 4) * 70), lit = hash(i + 7) > 0.62; const y = i < 4 ? 206 : 190; return <g key={i}><rect x={x} y={y} width={38} height={58} rx={2} fill={lit && day < 0.5 ? '#d9a65a' : lightUp('#1d2133', '#3f4a52')} opacity={lit && day < 0.5 ? 0.78 : 1} /><path d={`M${x + 19} ${y} v58 M${x} ${y + 26} h38`} stroke="#141724" strokeWidth={2.4} /></g>; })}
          {[0, 1].map((k) => <g key={k}><rect x={k ? 700 : 30} y={296} width={k ? 250 : 236} height={GROUND - 296} fill={lightUp('#2a2e40', '#6f7880')} />{Array.from({ length: 9 }, (_, i) => <rect key={i} x={k ? 700 : 30} y={302 + i * 10} width={k ? 250 : 236} height={2} fill={lightUp('#1b1e2c', '#566068')} />)}</g>)}
        </g>
        {/* the street */}
        <rect x={0} y={GROUND} width={VW} height={30} fill="#7a7468" />
        <rect x={0} y={GROUND} width={VW} height={3} fill="#a39b8c" opacity={0.6} />
        <rect x={0} y={GROUND + 30} width={VW} height={VH - GROUND - 30} fill="#31343c" />
        <rect x={0} y={GROUND + 30} width={VW} height={4} fill="#b9b19f" opacity={0.55} />
        {/* the service tower and its fuel line */}
        <g stroke="#1a1e27" strokeWidth={3} fill="none">
          <path d={`M272 ${GROUND} L272 92 M306 ${GROUND} L306 92`} />
          {Array.from({ length: 9 }, (_, i) => <path key={i} strokeWidth={1.7} d={`M272 ${92 + i * 33} L306 ${92 + (i + 1) * 33} M306 ${92 + i * 33} L272 ${92 + (i + 1) * 33} M272 ${92 + i * 33} L306 ${92 + i * 33}`} />)}
        </g>
        <circle cx={289} cy={84} r={5} fill="#ff4b3b" opacity={Math.floor(t * 1.5) % 2 === 0 ? 1 : 0.2} />
        {/* the bakery */}
        <g>
          <polygon points="302,150 352,86 624,86 674,150" fill="#4b3a33" />
          {Array.from({ length: 5 }, (_, i) => <path key={i} d={`M${316 + i * 7} ${138 - i * 10} L${660 - i * 7} ${138 - i * 10}`} stroke="#3a2c27" strokeWidth={2} />)}
          <rect x={566} y={52} width={36} height={44} fill="#7d4a39" /><rect x={560} y={46} width={48} height={9} rx={1} fill="#4d2e25" />
          <rect x={372} y={66} width={8} height={24} fill="#55585f" /><path d="M372 66 L354 66" stroke="#55585f" strokeWidth={8} />
          <rect x={318} y={150} width={340} height={GROUND - 150} fill="#ead9bd" />
          <rect x={318} y={150} width={340} height={36} fill="#c7b08c" />
          <rect x={318} y={340} width={340} height={GROUND - 340} fill="#a4553f" />
          {Array.from({ length: 5 }, (_, r) => Array.from({ length: 16 }, (_, c) => <rect key={`${r}-${c}`} x={318 + c * 22 + (r % 2) * 10} y={342 + r * 9.6} width={19} height={7} fill="#8f4634" opacity={0.7} />))}
          {/* windows */}
          {[332, 540].map((x, wi) => (
            <g key={x}>
              <rect x={x} y={232} width={104} height={106} fill="#141a24" />
              <rect x={x} y={232} width={104} height={106} fill="#ffcf7a" opacity={inside * 0.92} />
              <path d={`M${x} 286 h104 M${x} 322 h104`} stroke="#6e4a2c" strokeWidth={3} />
              {Array.from({ length: 9 }, (_, i) => <ellipse key={i} cx={x + 14 + (i % 5) * 19 + (i > 4 ? 8 : 0)} cy={i > 4 ? 316 : 280} rx={8.5} ry={5.6} fill={hash(i + wi * 9) > 0.5 ? '#a9692b' : '#c78a3f'} opacity={0.35 + 0.65 * inside} />)}
              <rect x={x} y={232} width={104} height={106} fill="none" stroke="#3f2d24" strokeWidth={5} />
            </g>
          ))}
          {/* the door: open, the inside is pure light */}
          <rect x={450} y={232} width={76} height={GROUND - 232} fill="#ffe6a8" opacity={Math.max(door > 0.01 ? 0.9 : 0, 0)} />
          <rect x={450} y={232} width={Math.max(10, 76 * (1 - door * 0.84))} height={GROUND - 232} fill="#2f5d55" stroke="#183a35" strokeWidth={3} />
          <rect x={456} y={242} width={Math.max(2, 64 * (1 - door * 0.84))} height={62} fill="#141a24" />
          <rect x={456} y={242} width={Math.max(2, 64 * (1 - door * 0.84))} height={62} fill="#ffcf7a" opacity={inside * 0.85} />
          {/* the awning */}
          {Array.from({ length: 10 }, (_, i) => <polygon key={i} points={`${322 + i * 33.2},186 ${322 + (i + 1) * 33.2},186 ${312 + (i + 1) * 35.2},224 ${312 + i * 35.2},224`} fill={i % 2 ? '#f0e4cf' : '#b3412f'} />)}
          <rect x={312} y={224} width={352} height={5} fill="#7b2d20" />
          <rect x={350} y={96} width={276} height={6} fill="#2c221d" opacity={0} />
          <rect x={356} y={148} width={264} height={5} fill="#2c221d" />
        </g>
        {/* cones around the pad */}
        {[232, 256, 700, 724].map((x) => <g key={x}><polygon points={`${x - 7},${GROUND + 14} ${x + 7},${GROUND + 14} ${x + 2},${GROUND - 8} ${x - 2},${GROUND - 8}`} fill="#d9682c" /><rect x={x - 5} y={GROUND + 1} width={10} height={4} fill="#f4efe2" /></g>)}
        {/* the fallen hose */}
        <g transform={`rotate(${dropHose * 88} 306 98)`}><path d={`M306 98 Q${306 + 130} ${122 - dropHose * 24} 566 ${84 + dropHose * 12}`} stroke="#14171d" strokeWidth={4} fill="none" /></g>
        {/* night, storm and the dark sit over everything except the lights */}
        <rect x={0} y={0} width={VW} height={VH} fill="#070b18" opacity={amb} />
        {/* the door's light on the street */}
        <polygon points={`450,${GROUND} 526,${GROUND} 660,${VH} 316,${VH}`} fill="url(#pour)" opacity={pour} />
        <circle cx={488} cy={300} r={280} fill="url(#glow)" opacity={Math.max(pour * 0.62, flash * 0.9, inside * 0.2 * (1 - day))} />
        {/* puddles shine while it rains */}
        {[120, 400, 780].map((x, i) => <ellipse key={x} cx={x} cy={GROUND + 52 + i * 3} rx={76} ry={7} fill="#8fa4bd" opacity={rainFade * 0.22} />)}
      </svg>
      {/* the sign letters. HTML, because they move under the camera's transform */}
      {'OPE'.split('').map((c, i) => <Letter key={i} ch={c} x={LETTER_X[i]} y={94} lit={clamp(1 - 0.62 * los)} />)}
      <Letter ch="N" x={LETTER_X[3] + nX} y={94 + nDrop} rot={nRot} lit={fall > 0.5 ? 0.5 : clamp(1 - 0.62 * los)} />
      <div style={{ position: 'absolute', left: 318, top: 154, width: 340, textAlign: 'center', font: `700 25px/28px ${COND}`, letterSpacing: '.34em', color: '#5a3a2a', opacity: 1 - amb * 0.7 }}>BAKERY</div>
      {soldCard > 0 ? <div style={{ position: 'absolute', left: 454, top: 256, width: 72, height: 44, background: '#c9a26c', boxShadow: '0 2px 3px rgba(0,0,0,.4)', transform: `rotate(-4deg) scale(${soldCard})`, textAlign: 'center', font: `700 21px/20px ${COND}`, color: '#1c1a17', paddingTop: 2 }}>SOLD<br />OUT</div> : null}
      <svg viewBox={`0 0 ${VW} ${VH}`} width={VW} height={VH} style={{ position: 'absolute', left: 0, top: 0, display: 'block', overflow: 'visible' }}>
        {/* the cardboard N: taped on, in marker */}
        {cardIn > 0 ? (
          <g transform={`translate(${LETTER_X[3] + 31} 122) rotate(${-5 + (1 - back(cardIn)) * -18}) scale(${0.3 + 0.7 * back(cardIn)})`}>
            <rect x={-32} y={-31} width={64} height={62} fill="#c9a26c" stroke="#9c7a4c" strokeWidth={1.5} />
            <path d="M-32 -31 L-24 -36 M32 -31 L24 -36" stroke="#e6dcc0" strokeWidth={6} />
            <path d="M-17 21 L-15 -20 L15 19 L17 -22" fill="none" stroke="#17140f" strokeWidth={8} strokeLinecap="round" strokeLinejoin="round" strokeDasharray={180} strokeDashoffset={180 * (1 - cardDraw)} />
          </g>
        ) : null}
        {/* the stepladder and the owner */}
        {onLadder > 0 ? (
          <g opacity={onLadder}>
            <path d={`M634 ${GROUND + 6} L664 212 L694 ${GROUND + 6} M640 340 L688 340 M646 290 L682 290 M652 240 L676 240`} stroke="#b9bcc2" strokeWidth={5} fill="none" />
            <Person x={664} s={1.15} coat="#3f4a5a" skin="#E0B896" walk={0} t={t} seed={3} apron y={236} />
            <path d={`M654 172 L${LETTER_X[3] + 54} 136`} stroke="#3f4a5a" strokeWidth={7} strokeLinecap="round" />
          </g>
        ) : null}
        {inDoorA > 0 ? <Person x={490} s={1.32} coat="#3f4a5a" skin="#E0B896" walk={0} t={t} seed={3} apron op={inDoorA} y={GROUND + 2} /> : null}
        {inDoorB > 0 ? <Person x={488} s={1.32} coat="#3f4a5a" skin="#E0B896" walk={0} t={t} seed={3} apron op={inDoorB} y={GROUND + 2} /> : null}
        {/* the queue walks in from the left, and goes through the door when the morning jumps on */}
        {QUEUE.map((q, i) => {
          const walkIn = ramp(t, q.at, q.at + 1.1);
          if (walkIn <= 0) return null;
          const out = ramp(t, E('orbit') - 0.15 + i * 0.09, E('orbit') + 0.35 + i * 0.09);
          if (out >= 1) return null;
          const home = 420 - i * 46;
          const x = -30 + (home + 30) * easeOut(walkIn) + (488 - home) * out;
          const moving = walkIn < 1 || out > 0 ? 1 : 0;
          return <Person key={i} x={x} s={1.12 + hash(i + 3) * 0.2} coat={q.c} skin={`#${q.s}`} walk={moving} t={t} seed={i * 3 + 1} op={1 - ramp(out, 0.7, 1)} />;
        })}
        {dogIn > 0 && dogX < 1040 ? <Dog x={dogX} s={1.5} walk={dogWalk} t={t} sit={dogSit} croissant={dogOut > 0} /> : null}
        {/* the floodlights */}
        <Mast x={96} dir={1} angle={-64 - swing * 84 - sway} beam={beam} t={t} />
        <Mast x={880} dir={-1} angle={64 + swing * 84 + sway} beam={beam} t={t} />
        {/* the steam: venting on the pad, then a bloom at liftoff */}
        <g opacity={clamp(1 - 0.65 * los)}>
          <Puffs t={t} x={584} y={44} n={30} rise={120 + steamUp * 220} wide={70 + steamUp * 420} r0={13 + steamUp * 10} r1={40 + steamUp * 120} a={0.72 + steamUp * 0.25} drift={46 + steamUp * 30} seed={11} />
          <Puffs t={t} x={354} y={66} n={8} rise={18} wide={30} r0={4} r1={18} a={0.5} drift={-92} seed={71} />
        </g>
        {rollA > 0 ? (
          <g opacity={rollA * (1 - rollB)}>
            {Array.from({ length: 36 }, (_, i) => {
              const side = i % 2 ? 1 : -1, age = clamp((t - E('liftoff')) / (1.4 + hash(i + 300) * 1.6)), k = easeOut(age);
              const x = 488 + side * (150 + k * (70 + hash(i + 310) * 430)), y = GROUND + 12 - k * (8 + hash(i + 320) * 92), r = 18 + k * (34 + hash(i + 330) * 52);
              return <circle key={i} cx={x} cy={y} r={r} fill="url(#puff)" opacity={0.92 * ramp(age, 0, 0.1)} />;
            })}
          </g>
        ) : null}
        {birds > 0 && birds < 1 ? (
          <g fill="none" stroke="#14171d" strokeWidth={2.4} strokeLinecap="round">
            {Array.from({ length: 9 }, (_, i) => {
              const k = easeOut(birds), bx = 420 + i * 22 + k * (hash(i) - 0.5) * 900, by = 84 - hash(i + 4) * 8 - k * (70 + hash(i + 8) * 110), f = Math.sin(t * 18 + i) * 5;
              return <path key={i} d={`M${bx - 8} ${by + f} Q${bx - 3} ${by - 3} ${bx} ${by} Q${bx + 3} ${by - 3} ${bx + 8} ${by + f}`} />;
            })}
          </g>
        ) : null}
      </svg>
    </>
  );
};

const Letter: React.FC<{ ch: string; x: number; y: number; rot?: number; lit: number }> = ({ ch, x, y, rot = 0, lit }) => (
  <div style={{ position: 'absolute', left: x, top: y, width: 62, height: 56, textAlign: 'center', font: `700 66px/56px ${COND}`, color: '#ffe7b4', transform: `rotate(${rot}deg)`, opacity: 0.35 + 0.65 * lit, textShadow: `0 0 ${16 * lit}px rgba(255,190,90,${0.9 * lit}), 0 2px 0 #4a2c1d` }}>{ch}</div>
);

/** The rain and the broken signal sit on the lens, so they don't zoom. */
const Lens: React.FC<{ tx: number; ty: number; z: number }> = ({ tx, ty, z }) => {
  const t = useT();
  const rain = ramp(t, E('weather'), E('weather') + 1.0) * (1 - ramp(t, E('cardboard') + 0.3, E('goa')));
  const los = losK(t);
  const breakup = clamp(pw(t, [[LOS_ON - 0.3, 0], [LOS_ON + 0.4, 0.32], [E('dog') + 0.3, 0.4], [E('dog') + 0.8, 0.85], [E('owner'), 0.8], [E('owner') + 0.5, 0.42], [LOS_OFF, 0.42], [LOS_OFF + 0.3, 0]]));
  const fr = Math.floor(t * 30);
  // the reticle: on the possible customer, then on the customers
  const dogIn = ramp(t, E('contact') - 1.0, E('contact'));
  const toDoor = easeInOut(ramp(t, E('custa') - 0.85, E('custa') + 0.15));
  const dogWorldX = -40 + dogIn * 210 + toDoor * 330;
  const lock = ramp(t, E('contact') - 0.05, E('contact') + 0.3);
  const leaving = t >= E('dogleft') && t < E('croissant') + 0.9;
  const on = (t >= E('contact') - 0.05 && t < E('marker') - 0.35) || (t >= E('custa') - 0.2 && t < E('custb')) || leaving;
  const onB = t >= E('custb') - 0.1 && t < E('stage');
  const size = (onB ? 62 : 58) + (1 - lock) * 90;
  const dogOut = ramp(t, E('dogleft') - 0.05, E('dogleft') + 3.2);
  const wx = onB ? 420 : dogWorldX + dogOut * 668;
  const wy = onB ? GROUND - 40 : GROUND - 8;
  const sx = tx + wx * z, sy = ty + wy * z;
  const label = onB ? 'CUST 002' : leaving ? 'CUST 001 DEPARTING' : t >= E('custa') - 0.2 ? 'CUST 001' : t >= E('dog') ? 'POSSIBLE DOG' : 'CUST 001 ?';
  const rc = onB || (t >= E('custa') - 0.2 && !leaving) ? GO : AMBER;
  return (
    <>
      <svg viewBox={`0 0 ${VW} ${VH}`} width={VW} height={VH} style={{ position: 'absolute', left: 0, top: 0, display: 'block' }}>
        {rain > 0.01 ? (
          <g opacity={rain * 0.72} stroke="#c6d6ee" strokeLinecap="round">
            {Array.from({ length: 80 }, (_, i) => { const x = hash(i) * (VW + 60), sp = 620 + hash(i + 7) * 500, y = ((hash(i + 13) * 560 + t * sp) % 560) - 50, l = 18 + hash(i + 3) * 40; return <line key={i} x1={x} y1={y} x2={x - 7} y2={y + l} strokeWidth={1.2 + hash(i + 11) * 1.2} opacity={0.3 + hash(i + 2) * 0.6} />; })}
          </g>
        ) : null}
        {rain > 0.01 ? (
          <g opacity={rain}>
            {Array.from({ length: 22 }, (_, i) => { const x = hash(i + 200) * VW, y0 = hash(i + 220) * VH, sl = 14 + hash(i + 240) * 40, y = (y0 + Math.max(0, t - E('weather')) * sl) % VH, r = 3.5 + hash(i + 260) * 7; return <g key={i}><ellipse cx={x} cy={y} rx={r} ry={r * 1.25} fill="#dfe9f7" opacity={0.16} /><path d={`M${x - r * 0.5} ${y + r * 0.7} A${r} ${r} 0 0 0 ${x + r * 0.5} ${y + r * 0.7}`} fill="none" stroke="#eef4fc" strokeWidth={1.1} opacity={0.5} /></g>; })}
          </g>
        ) : null}
        {breakup > 0.01 ? (
          <g>
            {Array.from({ length: 34 }, (_, i) => { const y = hash(i * 3 + fr) * VH, h = 2 + hash(i * 5 + fr + 1) * 13, x = hash(i * 7 + fr) * VW * 0.7, w = VW * (0.2 + hash(i + fr * 2) * 0.8), g = hash(i * 11 + fr) > 0.5; return <rect key={i} x={x - 120} y={y} width={w} height={h} fill={g ? '#c9d4cf' : '#05070a'} opacity={breakup * (0.14 + hash(i + 3 * fr) * 0.42)} />; })}
            {Array.from({ length: 150 }, (_, i) => <rect key={i} x={hash(i + fr * 13) * VW} y={hash(i * 2 + fr * 17) * VH} width={2 + hash(i) * 3} height={2} fill="#dfe6e2" opacity={breakup * 0.5 * hash(i + fr)} />)}
          </g>
        ) : null}
        <rect x={0} y={0} width={VW} height={VH} fill="#04060a" opacity={los * 0.2 + breakup * 0.34} />
        {on || onB ? (
          <g stroke={rc} strokeWidth={4} fill="none" opacity={Math.floor(t * 8) % 2 === 0 || lock >= 1 ? 1 : 0.4}>
            {[[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([a, b], i) => <path key={i} d={`M${sx + (a * size) / 2} ${sy + (b * size) / 2 - (b * size) / 4} L${sx + (a * size) / 2} ${sy + (b * size) / 2} L${sx + (a * size) / 2 - (a * size) / 4} ${sy + (b * size) / 2}`} />)}
          </g>
        ) : null}
      </svg>
      {on || onB ? <div style={{ position: 'absolute', left: clamp(sx - size / 2, 8, VW - (label.length * 16 + 30)), top: Math.max(8, sy - size / 2 - 46), padding: '0 6px', background: 'rgba(6,9,8,.72)', font: `40px/40px ${MONO}`, color: rc, whiteSpace: 'nowrap', opacity: lock }}>{label}</div> : null}
    </>
  );
};

const Window: React.FC = () => {
  const t = useT();
  // the camera zooms to the sign for the bad news and the fix, and leans in on the shopfront for the last word
  const zA = easeInOut(ramp(t, E('sign') - 0.1, E('sign') + 0.3)) * (1 - easeInOut(ramp(t, E('hold') - 0.25, E('hold') + 0.25)));
  const zB = easeInOut(ramp(t, E('marker') - 0.3, E('marker') + 0.15)) * (1 - easeInOut(ramp(t, E('xh') - 0.05, E('xh') + 0.45)));
  const zC = easeInOut(ramp(t, E('sold') - 0.3, E('sold') + 0.6));
  const kick = ramp(t, E('door'), E('door') + 0.16) * (1 - ramp(t, E('door') + 0.16, E('door') + 1.4));
  const signZ = 1 + 1.25 * Math.max(zA, zB);
  const z = signZ + 0.16 * zC + 0.06 * kick;
  const fx = 488;
  const fy = zC > 0 ? 220 : 26;
  // the shake: hard at liftoff, a little at max Q
  const lift = t >= E('liftoff') ? clamp(1 - (t - E('liftoff')) / 3.4) : 0;
  const mq = ramp(t, E('xi'), E('custb')) * (1 - ramp(t, E('stage'), E('orbit')));
  const shake = lift * lift * 8 + mq * 2.4;
  const dx = Math.sin(t * 61) * shake + Math.sin(t * 37 + 1) * shake * 0.6 + Math.sin(t * 0.7) * 3;
  const dy = Math.sin(t * 73 + 2) * shake * 0.8 + Math.cos(t * 0.5) * 1.5;
  const tx = fx - fx * z + dx, ty = fy - fy * z + dy;
  const los = losK(t);
  const zoomed = zA > 0.5 || zB > 0.5;
  return (
    <div style={{ position: 'absolute', left: 12, top: 12, width: VW, height: VH, borderRadius: '44px / 32px', overflow: 'hidden', background: '#000' }}>
      <div style={{ position: 'absolute', left: 0, top: 0, width: VW, height: VH, transform: `translate(${tx}px, ${ty}px) scale(${z})`, transformOrigin: '0 0' }}>
        <Pad />
      </div>
      <Lens tx={tx} ty={ty} z={z} />
      <div style={{ position: 'absolute', left: 44, top: 22, font: `46px/46px ${MONO}`, color: los > 0.5 ? RED : '#f4f4f4', textShadow: '2px 2px 0 #000', whiteSpace: 'nowrap' }}>
        PAD CAM 2 <span style={{ color: los > 0.5 ? RED : '#ff4b3b', opacity: Math.floor(t * 2) % 2 === 0 ? 1 : 0.25 }}>●</span> {los > 0.5 ? 'NO SIGNAL' : 'LIVE'}
      </div>
      {zoomed ? <div style={{ position: 'absolute', right: 44, top: 22, font: `46px/46px ${MONO}`, color: '#f4f4f4', textShadow: '2px 2px 0 #000' }}>ZOOM 2X</div> : null}
      <div style={{ position: 'absolute', inset: 0, background: 'repeating-linear-gradient(0deg,rgba(0,0,0,.18) 0 2px,transparent 2px 5px),radial-gradient(ellipse at 50% 50%,transparent 62%,rgba(0,0,0,.55))' }} />
    </div>
  );
};

/* ---- the console ---- */
const CountdownStrip: React.FC = () => {
  const t = useT();
  const held = t >= E('hold') && t < E('xh');
  const lifted = t >= E('liftoff');
  let big = `T- ${hms(Math.ceil(tMinus(t)))}`, col = AMBER, small = 'HOLD: NONE', scol = '#ffcf66', blink = 1;
  if (held) { col = RED; small = 'HOLD'; scol = RED; blink = Math.floor(t * 3) % 2 === 0 ? 1 : 0.45; }
  if (lifted) { big = 'LIFTOFF'; col = GO; small = `T+ ${hms(tPlus(t))}`; scol = '#9fe9ac'; }
  if (t >= E('xi')) { big = 'MAX Q'; }
  if (t >= E('stage')) { big = 'STAGE SEP'; }
  if (t >= E('orbit')) { big = 'ORBIT'; }
  if (t >= E('sold')) { big = 'SOLD OUT'; }
  if (t >= E('keyword') - 0.15) { big = 'COMMENT: LIFTOFF'; col = AMBER; small = ''; }
  else if (t >= E('cta')) { big = 'NEXT MISSION'; col = AMBER; small = ''; }
  const flash = held ? 0.2 + 0.2 * Math.abs(Math.sin(t * 9)) : 0;
  const pop = t >= E('liftoff') && t < E('liftoff') + 0.2 ? 1.05 : 1;
  return (
    <div style={{ position: 'absolute', left: 40, top: 250, width: 1000, height: 112, background: CRT, borderRadius: 10, boxShadow: `inset 0 0 0 4px #1d2427, inset 0 0 40px rgba(255,77,61,${flash})`, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 30px', boxSizing: 'border-box', overflow: 'hidden' }}>
      <div style={{ font: `112px/112px ${MONO}`, color: col, textShadow: `0 0 14px ${col}99`, transform: `scale(${pop})`, transformOrigin: 'left center', whiteSpace: 'nowrap' }}>{big}</div>
      <div style={{ font: `${held ? 92 : 56}px/1 ${MONO}`, color: scol, opacity: blink, textShadow: `0 0 10px ${scol}66`, whiteSpace: 'nowrap' }}>{small}</div>
    </div>
  );
};

const Tile: React.FC<{ n: string; s: St[]; i: number; dim: number }> = ({ n, s, i, dim }) => {
  const t = useT();
  let cur = s[0];
  for (const e of s) if (t >= e[0]) cur = e;
  const age = t - cur[0];
  const [, text, col] = cur;
  const lit = col !== OFF;
  const flick = age < 0.14 && cur[0] > 0 ? (Math.floor(age * 60) % 2 === 0 ? 0.3 : 1) : 1;
  const blink = col === RED && age < 1.5 ? (Math.floor(age * 5) % 2 === 0 ? 1 : 0.38) : col === AMBER && text === 'POLL' ? 0.72 + 0.28 * Math.abs(Math.sin(t * 5 + i)) : 1;
  const pop = age < 0.22 && cur[0] > 0 ? 1 + 0.04 * (1 - age / 0.22) : 1;
  return (
    <div style={{ position: 'relative', width: 290, height: 100, background: PANEL, borderRadius: 10, boxShadow: `inset 0 0 0 3px #26302f${lit ? `, inset 0 0 26px ${col}33` : ''}`, transform: `scale(${pop})`, filter: dim < 1 ? `brightness(${dim})` : undefined, overflow: 'hidden' }}>
      <div style={{ position: 'absolute', left: 16, top: 8, font: `700 33px/34px ${COND}`, letterSpacing: '.08em', color: LABEL }}>{n}</div>
      <div style={{ position: 'absolute', right: 15, top: 13, width: 24, height: 24, borderRadius: 12, background: lit ? col : '#1b2124', opacity: blink * flick, boxShadow: lit ? `0 0 14px ${col}` : 'inset 0 1px 2px rgba(0,0,0,.8)' }} />
      <div style={{ position: 'absolute', left: 14, right: 14, top: 42, height: 50, borderRadius: 6, padding: '0 10px', background: lit ? `${col}22` : '#1b2124', font: `58px/50px ${MONO}`, color: col, textShadow: lit ? `0 0 12px ${col}` : 'none', opacity: blink * flick, whiteSpace: 'nowrap' }}>{text}</div>
    </div>
  );
};

const envAt = (tt: number) => {
  let v = 0;
  for (const w of WORDS) {
    if (tt >= w.t0 - 0.03 && tt <= w.t1 + 0.05) {
      const m = clamp((tt - w.t0) / Math.max(0.05, w.t1 - w.t0));
      v = Math.max(v, Math.pow(Math.sin(m * Math.PI), 0.6));
    }
  }
  return v;
};
/** The trace: it follows the voice, drops dead with the signal, then climbs with the customers. */
const traceAt = (tt: number) => {
  if (isDead(tt)) return 0.06 + Math.sin(tt * 41) * 0.004;
  const climb = pw(tt, [[E('liftoff'), 0.2], [E('liftoff') + 0.25, 0.92], [E('xi') - 0.5, 0.3], [E('custa'), 0.28], [E('custa') + 0.18, 0.46], [E('custb'), 0.46], [E('custb') + 0.18, 0.6], [E('stage'), 0.64], [E('croissant'), 0.76], [E('sold'), 0.86]]);
  const noise = Math.sin(tt * 37) * 0.018 + Math.sin(tt * 23 + 1) * 0.012;
  const voice = envAt(tt) * (tt > E('liftoff') ? 0.13 : 0.52) * (0.72 + 0.28 * Math.sin(tt * 19));
  return clamp(climb + voice + noise);
};

const Telemetry: React.FC = () => {
  const t = useT();
  const W = 890, H = 108;
  const dead = isDead(t);
  const pts: string[] = [];
  for (let k = 0; k <= 120; k++) {
    const tt = t - 2.4 + (k / 120) * 2.4;
    pts.push(`${(k / 120) * W},${H - 12 - traceAt(tt) * (H - 28)}`);
  }
  let read = 'OVEN 220 C', rc = GO;
  if (dead) { read = ''; }
  else if (t >= E('cta')) { read = 'UPLINK OPEN'; rc = AMBER; }
  else if (t >= E('sold')) { read = 'CUSTOMERS 048'; }
  else if (t >= E('door') && t < E('xi')) { read = 'DOOR OPEN'; }
  else if (t >= E('liftoff')) { read = `CUSTOMERS ${String(customers(t)).padStart(3, '0')}`; }
  else if (t >= E('goturn')) { read = 'SIGNAL ACQUIRED'; }
  return (
    <div style={{ position: 'absolute', left: 60, top: 1096, width: W, height: H, background: CRT, borderRadius: 10, overflow: 'hidden', boxShadow: 'inset 0 0 0 4px #1d2427' }}>
      <svg viewBox={`0 0 ${W} ${H}`} width={W} height={H} style={{ position: 'absolute', left: 0, top: 0 }}>
        {Array.from({ length: 12 }, (_, i) => <path key={i} d={`M${i * 80} 0 V${H}`} stroke="#14241a" strokeWidth={1.5} />)}
        {[1, 2].map((i) => <path key={i} d={`M0 ${(i * H) / 3} H${W}`} stroke="#14241a" strokeWidth={1.5} />)}
        <polyline fill="none" stroke={dead ? RED : GO} strokeWidth={4} points={pts.join(' ')} style={{ filter: `drop-shadow(0 0 5px ${dead ? RED : GO})` }} />
      </svg>
      <div style={{ position: 'absolute', left: 12, top: 6, padding: '0 6px', background: `${CRT}cc`, font: `34px/34px ${MONO}`, color: dead ? RED : GO }}>TELEMETRY</div>
      {read ? <div style={{ position: 'absolute', right: 12, top: 4, padding: '0 8px', background: `${CRT}e0`, font: `44px/44px ${MONO}`, color: rc, textShadow: `0 0 8px ${rc}88`, whiteSpace: 'nowrap' }}>{read}</div> : null}
      {dead ? <div style={{ position: 'absolute', left: 0, right: 0, top: 2, textAlign: 'center', font: `86px/86px ${MONO}`, color: RED, textShadow: '0 0 16px rgba(255,77,61,.75)', opacity: Math.floor((t - LOS_ON) * 2.4) % 2 === 0 ? 1 : 0.55 }}>LOSS OF SIGNAL</div> : null}
    </div>
  );
};

/* Caption chunks. A chunk ends at every full stop and after the words listed here, so a punchline gets its own card. Rows are balanced. */
const PER_ROW = 21;
const CUT_AFTER: Record<string, string[]> = { s01: ['minutes'], s06: ['repeat'], s08: ['rain'], s09: ['flight', 'says'], s10: ['reports'], s11: ['stations'], s19: ['liftoff'] };
const clean = (w: string) => w.replace(/[.,;:!?"]/g, '').toUpperCase();
const asRows = (ws: { w: string }[]) => {
  const ups = ws.map((w) => clean(w.w));
  const len = (a: number, b: number) => ups.slice(a, b).join(' ').length;
  let cut = ups.length;
  if (len(0, ups.length) > PER_ROW) {
    let best = 1e9;
    for (let k = 1; k < ups.length; k++) { const m = Math.max(len(0, k), len(k, ups.length)); if (m < best) { best = m; cut = k; } }
  }
  const row = (a: number, b: number) => { const map: number[] = []; for (let i = a; i < b; i++) { if (i > a) map.push(-1); for (let c = 0; c < ups[i].length; c++) map.push(i); } return { s: ups.slice(a, b).join(' '), map }; };
  return cut === ups.length ? [row(0, cut)] : [row(0, cut), row(cut, ups.length)];
};
const loopChunks = (): Chunk[] => {
  const out: Chunk[] = [];
  for (const sc of SCENES) {
    let cur: typeof sc.words = [];
    const flush = () => { if (cur.length) out.push({ t0: cur[0].t0, rows: asRows(cur), words: cur }); cur = []; };
    for (const w of sc.words) {
      if ([...cur, w].map((x) => clean(x.w)).join(' ').length > PER_ROW * 2) flush();
      cur.push(w);
      if (/[.!?]$/.test(w.w) || (CUT_AFTER[sc.id] ?? []).includes(clean(w.w).toLowerCase())) flush();
    }
    flush();
    // the three final GOs stack up on one line instead of replacing each other
    if (sc.id === 's13' || sc.id === 's14') {
      const last = out.pop() as Chunk, prev = out[out.length - 1];
      const words = [...prev.words, ...last.words];
      out.push({ t0: last.t0, rows: asRows(words), words });
    }
  }
  return out;
};
const CHUNKS = loopChunks();

const Row: React.FC<{ s: string; map: number[]; live: number; words: { w: string }[]; done: number; fs?: number; lh?: number }> = ({ s, map, live, words, done, fs = 86, lh = 88 }) => {
  const parts: { txt: string; wi: number }[] = [];
  s.split(' ').reduce((at, txt) => { parts.push({ txt, wi: map[at] }); return at + txt.length + 1; }, 0);
  return (
    <div style={{ height: lh, font: `700 ${fs}px/${lh}px ${COND}`, letterSpacing: '.012em', whiteSpace: 'nowrap', color: INK }}>
      {parts.map((p, i) => {
        const hot = p.wi === live;
        return <span key={i} style={{ color: hot ? AMBER : p.wi > done ? '#a9b4ae' : INK, textShadow: hot ? '0 0 14px rgba(255,176,0,.5)' : 'none', marginRight: '.24em' }}>{p.txt}</span>;
      })}
    </div>
  );
};

const CTA_REST = (() => { const w = SCENES.find((x) => x.id === 's19')?.words ?? []; const i = w.findIndex((x) => clean(x.w) === 'LIFTOFF'); const rest = w.slice(i + 1); return { words: rest, rows: asRows(rest) }; })();

const Captions: React.FC = () => {
  const t = useT();
  const voiceless = t >= E('liftoff') && t < E('xi');
  const idx = CHUNKS.reduce((a, c, i) => (t >= c.t0 - 0.1 ? i : a), -1);
  const c = idx >= 0 ? CHUNKS[idx] : null;
  const live = c ? c.words.findIndex((w) => t >= w.t0 - 0.03 && t < w.t1 + 0.1) : -1;
  const spoken = c ? c.words.reduce((a, w, i) => (t >= w.t0 ? i : a), -1) : -1;
  const ptt = WORDS.some((w) => t >= w.t0 - 0.1 && t < w.t1 + 0.1);
  const big: React.CSSProperties = { font: `700 196px/186px ${COND}`, letterSpacing: '.005em', whiteSpace: 'nowrap' };
  let body: React.ReactNode = c ? c.rows.map((r, i) => <Row key={i} s={r.s} map={r.map} live={live} words={c.words} done={spoken} />) : null;
  if (voiceless) {
    // the payoff word, as big as the panel allows
    const pop = 1 + 0.07 * (1 - easeOut(ramp(t, E('liftoff'), E('liftoff') + 0.3)));
    body = (
      <div style={{ display: 'flex', alignItems: 'center', gap: 34 }}>
        <div style={{ ...big, color: GO, textShadow: '0 0 26px rgba(57,211,83,.55)', transform: `scale(${pop})`, transformOrigin: 'left center' }}>LIFTOFF</div>
        <div style={{ font: `700 58px/62px ${COND}`, color: INK, whiteSpace: 'nowrap', opacity: ramp(t, E('door') + 0.25, E('door') + 0.5) }}>THE DOOR<br />IS OPEN</div>
      </div>
    );
  } else if (t >= E('sold') - 0.1 && t < E('cta')) {
    body = (
      <>
        <div style={{ font: `700 132px/126px ${COND}`, color: AMBER, textShadow: '0 0 20px rgba(255,176,0,.5)', whiteSpace: 'nowrap' }}>SOLD OUT</div>
        <div style={{ font: `700 66px/68px ${COND}`, color: INK, whiteSpace: 'nowrap' }}>BY NOON</div>
      </>
    );
  } else if (t >= E('cta')) {
    // the keyword stays huge until the end, and the rest of the line rolls under it
    const rows = CTA_REST.rows;
    const second = t >= CTA_REST.words[rows[0].map[rows[0].map.length - 1] + 1]?.t0;
    const r = second ? rows[1] : rows[0];
    const lv = CTA_REST.words.findIndex((w) => t >= w.t0 - 0.03 && t < w.t1 + 0.1);
    const dn = CTA_REST.words.reduce((a, w, i) => (t >= w.t0 ? i : a), -1);
    body = (
      <>
        <div style={{ font: `700 118px/112px ${COND}`, whiteSpace: 'nowrap', color: INK }}>COMMENT <span style={{ color: AMBER, textShadow: '0 0 20px rgba(255,176,0,.55)' }}>LIFTOFF</span></div>
        {t >= (CTA_REST.words[0]?.t0 ?? 0) - 0.1 ? <Row s={r.s} map={r.map} live={lv} words={CTA_REST.words} done={dn} fs={68} lh={74} /> : null}
      </>
    );
  }
  return (
    <div style={{ position: 'absolute', left: 60, top: 1214, width: 890, height: 252, background: PANEL, borderRadius: 10, boxShadow: 'inset 0 0 0 3px #26302f', overflow: 'hidden' }}>
      <div style={{ position: 'absolute', left: 26, top: 14, display: 'flex', alignItems: 'center', gap: 14, font: `700 32px/36px ${COND}`, letterSpacing: '.16em', color: AMBER }}>
        <span style={{ width: 18, height: 18, borderRadius: 9, background: ptt ? AMBER : '#1b2124', boxShadow: ptt ? `0 0 12px ${AMBER}` : 'inset 0 1px 2px rgba(0,0,0,.8)' }} />
        CAPCOM
      </div>
      <div style={{ position: 'absolute', right: 26, top: 14, font: `600 30px/36px ${COND}`, letterSpacing: '.12em', color: '#8d9a9e' }}>FLIGHT LOOP</div>
      <div style={{ position: 'absolute', left: 26, top: 58 }}>{body}</div>
    </div>
  );
};

const PHASES: [string, number][] = [['T-10', 0], ['HOLD', 0.2], ['LIFTOFF', 0.47], ['MAX Q', 0.68], ['ORBIT', 0.93]];
const progress = (t: number) => pw(t, [[0, 0.012], [E('hold'), 0.2], [E('xh'), 0.2], [E('liftoff'), 0.47], [E('custa'), 0.56], [E('custb'), 0.68], [E('orbit'), 0.93], [E('sold'), 0.97]]);
const curve = (p: number) => (p <= 0.47 ? 0 : easeIn(clamp((p - 0.47) / 0.53)) * 0.25 + ((p - 0.47) / 0.53) * 0.75);

/** Top band: outside the safe area, so nothing here has to be read. The small jokes live here. */
const TopBand: React.FC = () => {
  const t = useT();
  const X0 = 16, PW = 640, Y0 = 72, RISE = 58;
  const p = progress(t);
  const at = (q: number) => [X0 + q * PW, Y0 - curve(q) * RISE] as const;
  const n = 60;
  const all: string[] = [], done: string[] = [];
  for (let i = 0; i <= n; i++) { const q = (i / n); const [x, y] = at(q); all.push(`${x},${y}`); if (q <= p) done.push(`${x},${y}`); }
  const [mx, my] = at(p);
  const held = t >= E('hold') && t < E('xh');
  const mc = held ? RED : t >= E('liftoff') ? GO : AMBER;
  return (
    <>
      <div style={{ position: 'absolute', left: 40, right: 40, top: 40, display: 'flex', justifyContent: 'space-between', font: `700 46px/48px ${COND}`, letterSpacing: '.14em', color: LABEL }}>
        <span>FLIGHT CONTROL</span>
        <span>MISSION BAKERY-1</span>
      </div>
      <div style={{ position: 'absolute', left: 40, top: 100, width: 1000, height: 138, background: CRT, borderRadius: 10, boxShadow: 'inset 0 0 0 4px #1d2427', overflow: 'hidden' }}>
        <div style={{ position: 'absolute', left: 22, top: 10, font: `40px/40px ${MONO}`, color: '#9fe9ac', whiteSpace: 'pre' }}>
          {`LOCAL      ${hhmm(localTime(t))}\nALT         0 FT\nCROISSANTS   ${String(croissants(t)).padStart(2, '0')}`}
        </div>
        <div style={{ position: 'absolute', left: 340, top: 0, width: 660, height: 138 }}>
          <svg viewBox="0 0 660 138" width={660} height={138} style={{ position: 'absolute', left: 0, top: 0 }}>
            <polyline fill="none" stroke="#2c3a31" strokeWidth={4} points={all.join(' ')} />
            {done.length > 1 ? <polyline fill="none" stroke={mc} strokeWidth={4} points={done.join(' ')} /> : null}
            {PHASES.map(([l, q]) => { const [x, y] = at(q); return <circle key={l} cx={x} cy={y} r={5} fill={p >= q ? mc : '#2c3a31'} />; })}
            <circle cx={mx} cy={my} r={16 + 3 * Math.sin(t * 6)} fill={mc} opacity={0.25} />
            <circle cx={mx} cy={my} r={8} fill={mc} />
          </svg>
          {PHASES.map(([l, q]) => <div key={l} style={{ position: 'absolute', left: X0 + q * PW - 60, width: 120, top: 98, textAlign: 'center', font: `30px/30px ${MONO}`, color: p >= q ? '#c8d8cc' : '#4a5a50' }}>{l}</div>)}
        </div>
      </div>
    </>
  );
};

/** The right edge beside the board: toggles and lamps. Under the platform's buttons, so only texture. */
const SideRack: React.FC<{ dim: number }> = ({ dim }) => {
  const t = useT();
  const tog = [E('oven'), E('dough'), E('coffee'), E('goa'), E('gob'), E('goc'), E('door')];
  return (
    <div style={{ position: 'absolute', left: 976, top: 868, width: 64, height: 598, background: '#39464e', borderRadius: 10, boxShadow: 'inset 0 0 0 3px #2f3a40', filter: dim < 1 ? `brightness(${dim})` : undefined }}>
      {tog.map((ev, i) => {
        const up = t >= ev;
        return (
          <div key={i} style={{ position: 'absolute', left: 14, top: 16 + i * 58, width: 36, height: 46, background: '#222b30', borderRadius: 6 }}>
            <div style={{ position: 'absolute', left: 13, top: up ? 4 : 22, width: 10, height: 20, borderRadius: 5, background: 'linear-gradient(#d9dcdd,#7d8588)' }} />
          </div>
        );
      })}
      {Array.from({ length: 6 }, (_, i) => {
        const on = hash(i * 3 + Math.floor(t * 0.9 + hash(i))) > 0.4;
        const c = t >= E('goc') ? GO : i % 3 === 0 ? RED : AMBER;
        return <div key={i} style={{ position: 'absolute', left: 21, top: 430 + i * 26, width: 22, height: 14, borderRadius: 3, background: on ? c : '#1b2124', boxShadow: on ? `0 0 8px ${c}` : 'none', opacity: on ? 0.85 : 1 }} />;
      })}
    </div>
  );
};

/** Below the safe area: the desk. Rows of push buttons and a vent. */
const Desk: React.FC<{ dim: number }> = ({ dim }) => {
  const t = useT();
  const allGo = t >= E('goc');
  return (
    <div style={{ position: 'absolute', left: 0, top: 1484, width: 1080, height: 436, filter: dim < 1 ? `brightness(${dim})` : undefined }}>
      <div style={{ position: 'absolute', left: 40, top: 0, width: 1000, height: 196, background: PANEL, borderRadius: 10, boxShadow: 'inset 0 0 0 3px #26302f' }}>
        {Array.from({ length: 3 * 15 }, (_, i) => {
          const r = Math.floor(i / 15), c = i % 15, on = hash(i + Math.floor(t * (0.5 + hash(i) * 0.7) + hash(i + 2) * 9)) > 0.55;
          const col = allGo ? (hash(i + 4) > 0.3 ? GO : AMBER) : hash(i + 5) > 0.72 ? RED : hash(i + 6) > 0.42 ? AMBER : GO;
          return <div key={i} style={{ position: 'absolute', left: 22 + c * 64, top: 18 + r * 58, width: 50, height: 42, borderRadius: 5, background: on ? col : '#1b2124', opacity: on ? 0.34 : 1, boxShadow: on ? `0 0 10px ${col}55` : 'inset 0 1px 2px rgba(0,0,0,.8)' }} />;
        })}
      </div>
      <div style={{ position: 'absolute', left: 40, top: 212, width: 1000, height: 90, background: '#39464e', borderRadius: 10 }}>
        {Array.from({ length: 34 }, (_, i) => <div key={i} style={{ position: 'absolute', left: 22 + i * 28, top: 18, width: 14, height: 54, borderRadius: 4, background: '#1f272b' }} />)}
      </div>
      <div style={{ position: 'absolute', left: 0, top: 322, width: 1080, height: 114, background: 'linear-gradient(#2b3439,#1a2023)' }} />
    </div>
  );
};

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

const Stage: React.FC = () => {
  const t = useT();
  const push = 1 + 0.01 * (t / DURATION);
  // the frame shakes at liftoff and settles
  const lift = t >= E('liftoff') ? clamp(1 - (t - E('liftoff')) / 2.8) : 0;
  const sx = Math.sin(t * 47) * lift * lift * 7, sy = Math.sin(t * 59 + 1) * lift * lift * 6;
  // the low point: the console dims and flickers while the air keeps running
  const los = losK(t);
  const dim = 1 - 0.4 * los * (hash(Math.floor(t * 9)) > 0.85 ? 1.25 : 1);
  return (
    <AbsoluteFill style={{ background: SLATE, backgroundImage: 'linear-gradient(#45535b 2px,transparent 2px)', backgroundSize: '100% 320px', fontFamily: COND }}>
      <AbsoluteFill style={{ transform: `translate(${sx}px, ${sy}px) scale(${push})`, transformOrigin: '50% 45%' }}>
        <div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1920, filter: dim < 1 ? `brightness(${dim})` : undefined }}>
          <TopBand />
        </div>
        <CountdownStrip />
        <div style={{ position: 'absolute', left: 40, top: 372, width: 1000, height: 486, borderRadius: 26, background: '#14191b', boxShadow: 'inset 0 0 0 6px #232b2e, 0 12px 26px rgba(0,0,0,.35)' }}>
          <Window />
        </div>
        <div style={{ position: 'absolute', left: 40, top: 868, width: 926, height: 598, background: '#39464e', borderRadius: 10, boxShadow: 'inset 0 0 0 3px #2f3a40' }} />
        <div style={{ position: 'absolute', left: 60, top: 878, display: 'grid', gridTemplateColumns: 'repeat(3, 290px)', gridTemplateRows: 'repeat(2, 100px)', gap: '6px 10px' }}>
          {STATIONS.map((s, i) => <div key={s.n} style={{ width: 290, height: 100, overflow: 'visible' }}><Tile n={s.n} s={s.s} i={i} dim={dim} /></div>)}
        </div>
        <Telemetry />
        <Captions />
        <SideRack dim={dim} />
        <Desk dim={dim} />
      </AbsoluteFill>
      <AbsoluteFill style={{ background: 'radial-gradient(120% 80% at 50% 45%, transparent 62%, rgba(0,0,0,.38) 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 flight director on the comms loop, level and unhurried, who reports a falling letter exactly as they'd report a fuel leak. Never excited, even at liftoff, which is why it's funny.

**When to use it:** on every line except liftoff. Each "go" is its own take, so it can land on its tile, and the poll answers can be a second voice if you have one. Give it radio color: high-pass at about 240 Hz, low-pass at about 4.8 kHz, a small lift near 2.2 kHz, firm compression and a very short slapback, light enough that every word stays clear.

- **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 |
|-------|--------|-------------|---------------|
| comms beep | 0.5 s | one short soft radio transmission tone, a single pure sine, warm and slightly muffled, no voice | trimmed to 0.2 s, at the start of nearly every line; pitched up a little on the turn |
| countdown tick | 0.5 s | one soft mechanical clock tick, a small relay click, warm and dry | once a second before the hold, then every 0.5 s for the last seconds before liftoff; none during the hold |
| switch | 0.5 s | a single heavy vintage console toggle switch click, warm and solid, close | every GO, on the word |
| relays | 1.6 s | a bank of old electromechanical relays clattering in a short burst, warm and dry, no voices | whenever many tiles change: the poll, the hold, the turn, the final GO |
| buzzer | 1 s | a low two-pulse console buzzer, soft, never piercing | the no-go, quietly |
| falling letter | 1.2 s | a small metal sign letter falling and clattering on a pavement, one bounce | 0.5 s after the no-go, when it lands |
| static | 10 s | soft radio static, a quiet open channel with faint crackle, no voices, no music | a very low bed; rises at the clock stop and drops at the turn |
| control room | 12 s | quiet control room tone, soft fans and air, no voices | the bed, all the way through |
| rain | 10 s | rain on a metal camera housing, soft and low | from the weather line until the final poll |
| marker | 1 s | a felt marker drawn on cardboard, soft squeaks | the workaround |
| rumble | 9 s | a deep far-off rocket rumble, felt more than heard, low-passed | liftoff, 0.35 s early, with a soft fade in |
| door bell | 2 s | a small brass shop door bell, a few soft jingles | full level as the door opens, quietly on each customer and the dog |
| street | 8 s | a small-town morning street, birds, a far-off car, soft | from liftoff to the end |

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

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

**The brief:** "Patient analog synth score for a launch control room: a slow pulsing bass, soft ticking percussion, held strings, tense and restrained for a long stretch, then a wide warm lift with brass-like synths, triumphant but small, no vocals."

**When it plays:** low from the first frame. It drains to nothing over 0.7 s on "stopped", stays out through the low point so only static and rain remain, and comes back under the "go" of "we go with what we have". It climbs through the final poll, peaks on the voiceless liftoff, on a bar where the track opens up, then settles under the customers.

**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 status that won't fit:** a tile holds 8 letters. SOLD OUT fits, OUT OF STOCK doesn't. Count before you write it.
- **A dead hold:** the clock stops, but the rain, the flicker and the tracking bracket keep moving, or the low point reads as a frozen film.
- **Blinking captions on one-word lines:** hold each "Go" on screen until the next one arrives, and keep the lead on those scenes near 0.05 s.
- **The wrong sky:** the LOCAL clock and the sky have to agree: before dawn until liftoff, sunrise after it, noon by the time it sells out.
- **A real rocket:** adding one kills it. The shop stays on the ground, and ALT stays at 0 FT.

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

## 12. Deliver

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

The prompt

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

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

- **What's launching:** [TOPIC - the small project treated like a space launch, e.g. "the first morning of a very small bakery"]
- **The six stations:** [STATIONS - six things that must be ready, e.g. "oven, dough, coffee, sign, cash, weather", or "you choose"]
- **What goes wrong:** [NO GO - the small real thing that stops the clock, e.g. "a letter falls off the sign", or "you write it"]
- **What I want to say:** [MESSAGE - the turn in the story, in a sentence or two, or "you write it"]
- **In the window:** [WINDOW - "no person: a camera on the pad, drawn in code", or "my own clip, attached"]
- **Length:** [LENGTH - e.g. about 35 seconds]
- **Voice:** [VOICE - e.g. "a calm flight director on the loop", "my own voice", or "no voice: captions and sound only"]
- **Music:** [MUSIC - e.g. "the skill's launch score 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 space agencies or logos, no claims I can't back up]

Work in this order:

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

Cookies for ads and referrals

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

Decline Accept