On Air
A radio studio at three in the morning fills the screen, and your topic is the show. Callers ring in with small worries, the needles follow the words, and the city outside goes dark until one lit window is left. Then the last caller turns out to be the viewer. 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.
-
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 -
Fill in the prompt
What keeps them up The callers What they need to hear Platform Narrator Ending AI toolsUse the on-air skill to make a vertical video for Instagram Reels. - **What keeps them up:** [TOPIC - the hour and the worry, e.g. "overthinking at 3 a.m.", "Sunday night dread", "the night before a launch"] - **The callers:** [CALLERS - three small, specific worries and the short answer to each, or "you write them"] - **What they need to hear:** [MESSAGE - the one true thing the last caller needs, e.g. "nothing you decide at 3 a.m. is a real decision", or "you write it"] - **In the window:** [WINDOW - "no person: a night city and one lit room, drawn in code", or "my own clip, attached"] - **Length:** [LENGTH - e.g. about 35 seconds] - **Voice:** [VOICE - e.g. "a warm late-night radio host", "my own voice", or "no voice: captions and sound only"] - **Music:** [MUSIC - e.g. "the skill's late-night radio 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 stations, no real people as callers, nothing that sounds like medical advice] Work in this order: 1. Write the script on the skill's moves, with one line for each turn and what happens in the studio. Start from the message, then pick the callers it answers. 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 -
Paste it and approve the storyboard
Your agent writes the script, builds the studio 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 connectThe skill, as plain text
---
name: on-air
description: Make a 30-40 second vertical video set in a 3 a.m. radio call-in studio - the phone lines flash, caller cards drop onto the desk, VU needles follow the words, and the night city in the window goes dark until one lit window is left. It starts funny and turns tender. The skill covers the studio sound design, music fitted to the story and an optional voice like a warm late-night host. Use for overthinking, late-night worries, things people lie awake about, quiet reassurance, 'it's going to be fine' stories, or when asked for a radio, podcast studio or call-in show video, or when given the on-air prompt.
---
# On Air
A radio studio at three in the morning fills the screen, and your topic is the show. Callers ring in with small worries and get deadpan answers, the needles follow the host's voice, and the city outside goes dark one window at a time. Then the last line won't answer, and the one light left in the city turns out to be the viewer's. It's a joke that quietly becomes a kindness, and it ends with morning in the window and nobody on the phone.
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/on-air/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/big-shoulders-display` and `@fontsource/caveat`. 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/OnAir.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: phone lines ring and hold, caller cards drop onto the desk, needles follow the words, the city's windows go out one by one, and the night turns into 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 radio studio at 3 a.m., seen as one console in front of one window.
- **Palette:** studio wall `#2a2522`, walnut `#5a3a22`, brushed metal `#b9bdc2`, meter face `#f1e2b4`, sign red `#ff3b30`, amber `#ffb000`, hold green `#3fe07a`, card paper `#f7f0dd`, red pencil `#c21d12`.
- **Type:** Big Shoulders Display (700 and 800) for the sign, the plates, the cards and the captions, Caveat 700 for anything handwritten.
- **Fixed parts, top to bottom:** the show's name plate (y 118-240, above the safe area, so nothing on it has to be read), the sign (y 256-394) and the ring clock (x 730-1030, y 236-536), the studio window (y 418-858) with a microphone and a mug in front of it, a walnut sill, the caller card (x 60-650, y 906-1112) beside the phone lines (x 664-950), two VU meters with the master fader between them (y 1124-1330), the caption plate (y 1338-1476), then channel strips as texture below the safe area, and knobs down the right edge.
- **The sign rule:** one word shared by two lamps. ON and DEAD are stacked beside a big AIR, so the same sign reads ON AIR or DEAD AIR with nothing moving but the light. The unlit DEAD sits there from the first frame.
- **The line rule:** a line rings amber when its scene starts, its card drops on the spoken number, and once the host has answered it holds green. Answered lines stay lit until the voiceless moment, then go dark one by one.
- **The needles read the words.** The HOST meter follows `WORDS` from the clock, so it works from the estimate with no voice at all, and from real word timings once there is one. The CALLER meter chatters while a line is up, lies flat on the low point, and breathes slowly once we know who it is.
- **The clock rule:** one minute a call, stuck through the low point, running from the word "eight" and landing on 08:00 on the second "eight". The sky is computed from the clock, so the two can't disagree.
- **One layer is the room's light.** `studioTint` multiplies everything drawn before it. Lamps, meters and paper are drawn after it, so they stay lit when the room goes dark.
- **Small jokes live on the hardware:** CALLS WELCOME. SLEEP ENCOURAGED. The REGRET fader falls on "smaller", and the COFFEE fader never moves. A traffic signal blinks for nobody.
**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 the view through the studio glass: a night city, drawn in code, with one window in it that matters. It tracks the story:
| Story moment | In the window |
|---|---|
| Opening | Crescent moon, stars, three layers of skyline, the station's mast with a blinking beacon, lit windows, a car on the street |
| Each call | The mast fires a ring, and a few more windows go out as the city falls asleep |
| The low point | Every window goes out in a wave until one is left, with a soft halo. The buildings sink into the sky, and the glass pushes in slowly. The mast still fires, and nothing answers |
| The turn | The view dives into that last window: a small room, a bedside lamp, a person on the edge of a bed with a phone. On the phone is a red dot |
| The message | What's keeping them up appears on the wall behind them, huge: EVERYTHING, THAT EMAIL, 2014, NOT TIRED |
| The comfort | The words shrink to almost nothing, the phone goes down, the person lies back, and a blanket comes up |
| Voiceless | The view pulls back across the city. The window clicks off at the same moment as the last phone line. Dawn comes up behind the mast |
| Back on | Morning, a low sun, birds, ordinary traffic, and that one window with its curtains shut |
The person is the only figure in the film, and they're small and have no face. That's what lets anyone be them. With the user's own clip, play it in the room's place, cropped to the window, and keep the zoom, the wire and the card on top.
## 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 answers small worries with total calm, and the last caller is the person watching. Ours is about lying awake.
| # | Move | What it does | Our line | In the studio |
|---|------|--------------|----------|---------------|
| 1 | Sign-on | The hook: names who this is for | "It's three a.m., and this is the show for people who should be asleep." | ON AIR stutters on, the needles wake, the clock reads 03:00, a card says LINES OPEN and "go to bed." |
| 2 | Line one | A small, familiar worry, answered flat | "Line one wants to know if that email sounded rude. It didn't." | Line 1 rings, the card drops: RE: THAT EMAIL, "it didn't." in red pencil |
| 3 | Line two | A sillier one | "Line two is replaying 2014. You were fine." | Line 2 rings: RE: 2014, "you were fine." |
| 4 | Line three | The biggest laugh | "Line three says they're not tired. Line three's been up since Tuesday." | Line 3 rings: RE: NOT TIRED, "up since tuesday" |
| 5 | Talkback | The hinge: there's one more | "My producer says we have one more." | The ident fires (a glint on the plate, a ring from the mast), the TALK lamp blinks, a sticky note lands: "one more" |
| 6 | Low point | Nobody answers | "Line four. Line four, are you there?" | Line 4 flashes red. The card reads LINE 4: and a cursor. The needles drop flat, ON cuts out and DEAD AIR pulses, the room goes dark, the city's last windows go out. Music out; room tone and line hiss keep running |
| 7 | The turn | The caller is you | "Line four. That's you, isn't it." | A cord runs from the lamp to the last lit window, the view dives in, and the card fills in: LINE 4: YOU, "re: everything". Music returns, very low, on "you" |
| 8 | The message | The one true thing | "Nothing you decide at three a.m. is a real decision." | The worries loom on the wall. On "decision" a stamp lands, VOID UNTIL 8 A.M., and DEAD AIR goes out as ON comes back |
| 9 | The comfort | Why it's safe to stop | "It'll all still be there at eight. It'll just be smaller." | The clock starts to run on "eight". On "smaller" the worries shrink to almost nothing, and the person lies down |
| 10 | Voiceless | Letting go, about 4 s | (music at its fullest, the caption reads GOOD NIGHT) | The lines go dark one by one, the fader slides down, the window clicks off with the last line, ON AIR dims, night turns to dawn |
| 11 | Back on | The dry button | "And we're back at eight. Nobody called." | 08:00, morning light on the desk, ON AIR back on, all lines dark. The card: CALLS: 0, "told you." |
| 12 | Call to action | One action | "Comment air and I'll send you the skill that made this." | The card lands on the word: COMMENT AIR. ON goes out and AIR stays lit |
**For other topics:** Sunday night dread, the night before a launch, new parents at 4 a.m., a founder doing the numbers at midnight, the hour after sending a risky text. Each caller is one specific, slightly embarrassing worry, and the answer is shorter than the question. The trick that makes it land is that the turn adds nothing new: the lit window has been in the city since the first frame, and the audience has been looking past it while they laughed. Keep the callers specific and the host kind, because the laughs have to be with the callers or the turn won't be believed. Write the message first, then choose the callers it answers. Our film runs 41 s on the clock's estimate. For about 35 s, drop a caller, which saves about 4 s, and shorten the sign-on. Keep the low point and the voiceless moment at full length: they're what the film is for.
## 6. Plan and animatic (before generating anything)
**1. Write `plan.json`.** `text` is what the captions show. Add `say` when the spoken version differs, with the same number of words. Scenes hold the lines. `lead` and `tail` are seconds of room around a scene's words, and a voiceless scene has `sec` instead of `lines`.
```json
{
"lines": [
{ "id": "l01", "text": "First line." },
{ "id": "l02", "text": "Comment TOUR and I'll send it to you.", "say": "Comment, tour, and I'll send it to you." }
],
"scenes": [
{ "id": "s01", "lines": ["l01"], "lead": 0.7 },
{ "id": "s02", "sec": 3.4 },
{ "id": "s03", "lines": ["l02"] }
]
}
```
**2. Run the clock.** Write `scripts/clock.mjs` as below, and run it from the project folder. A line with no take yet gets an estimate, so you can see the whole film before generating anything. A real take goes in `takes/<id>.json` as `{ "dur": seconds, "words": [{ "w": "First", "t0": 0, "t1": 0.3 }] }` and replaces the estimate.
```js
// node scripts/clock.mjs plan.json + takes/<id>.json -> src/spec.json (what the frame renders) and clock.json (where each line plays)
import fs from 'node:fs';
const FPS = 30;
const plan = JSON.parse(fs.readFileSync('plan.json', 'utf8'));
const PAUSE = 0.35, LEAD = 0.05, TAIL = 0.25, TAIL_OUT = 1.2;
// No take yet: an estimate for a quick, clear voice, fitted to 53 real takes. A real take in takes/<id>.json replaces it.
function estimate(text) {
const letters = (s) => (s.match(/[\p{L}\p{N}]/gu) ?? []).length;
const stops = (text.match(/[.?!]+(?=\s)/g) ?? []).length, commas = (text.match(/,/g) ?? []).length;
const dur = 0.29 + 0.059 * letters(text) + 0.53 * stops + 0.07 * commas;
const parts = text.split(/\s+/), total = parts.reduce((n, p) => n + letters(p) + 1, 0);
let at = 0;
const words = parts.map((w) => { const d = (dur * (letters(w) + 1)) / total, x = { w, t0: at, t1: at + d }; at += d; return x; });
return { dur, words };
}
let t = 0.1;
const beats = [], placed = [];
for (const s of plan.scenes) {
const from = Math.round(t * FPS);
let end = t + (s.sec ?? 0);
const words = [];
if (s.lines?.length) {
let c = t + (s.lead ?? LEAD);
s.lines.forEach((id, i) => {
const line = plan.lines.find((l) => l.id === id);
const take = fs.existsSync(`takes/${id}.json`) ? JSON.parse(fs.readFileSync(`takes/${id}.json`, 'utf8')) : estimate(line.say ?? line.text);
// Captions show `text`, even when `say` differs ("Comment, gate"), as long as the word counts match.
const shown = line.text.split(/\s+/);
const same = shown.length === take.words.length;
placed.push({ line: id, t0: c, dur: take.dur, scene: s.id });
take.words.forEach((w, k) => words.push({ w: same ? shown[k] : w.w, f0: Math.round((c + w.t0) * FPS) - from, f1: Math.round((c + w.t1) * FPS) - from }));
c += take.dur + (i < s.lines.length - 1 ? PAUSE : 0);
});
end = c + (s.tail ?? TAIL);
}
const dur = Math.max(1, Math.round(end * FPS) - from);
beats.push({ id: s.id, from, dur, words, p: s.p ?? {} });
t = (from + dur) / FPS;
}
const total = Math.round((t + TAIL_OUT) * FPS);
beats[beats.length - 1].dur = total - beats[beats.length - 1].from;
beats[0].dur += beats[0].from;
beats[0].words.forEach((w) => { w.f0 += beats[0].from; w.f1 += beats[0].from; });
beats[0].from = 0;
for (let i = 1; i < placed.length; i++) {
const gap = placed[i].t0 - (placed[i - 1].t0 + placed[i - 1].dur);
const quiet = beats.some((b) => !b.words.length && b.from / FPS < placed[i].t0 && (b.from + b.dur) / FPS > placed[i - 1].t0);
if (gap > 1.0 && !quiet) console.warn(`voice gap ${gap.toFixed(2)} s before ${placed[i].line}: shorten a lead or tail`);
}
fs.mkdirSync('src', { recursive: true });
fs.writeFileSync('src/spec.json', JSON.stringify({ fps: FPS, durationInFrames: total, beats }, null, 1));
fs.writeFileSync('clock.json', JSON.stringify({ fps: FPS, total: total / FPS, lines: placed }, null, 1));
console.log(`${(total / FPS).toFixed(2)} s, ${beats.length} scenes, ${placed.length} lines`);
```
**3. Write `src/frames/story.json`.** Name every change in the story and pin it to a scene, and to a word in it where you can. The frame reads events by name, and a typo fails the render instead of drifting. Every event is checked when the film loads, even one the frame never reads. Our list keeps some events only so a sound can land on them; you can delete those. A `word` matches the first word in its scene that starts with those letters, so "the" also hits "there", and a repeated word can only be reached with `off` from an earlier one.
**4. Build the frame** with the code in step 7, changing the words and schedules to your story. Keep each schedule in time order.
**5. Render a silent animatic.** Make contact sheets at one frame per second with the safe box drawn, and look at every tile: `ffmpeg -i out/silent.mp4 -vf "select='not(mod(n\,30))',drawbox=x=60:y=250:w=890:h=1230:color=red@0.9:t=6,scale=216:-2,tile=8x3" -fps_mode passthrough sheet_%02d.jpg`. Empty tiles at the end of the last sheet are padding, not black frames. A tile that small hides small errors, so also look at about 6 frames at full size, with the window cropped large (`npx remotion still src/index.ts Film-9x16 out/f.png --frame=N`).
**6. Show the user** the script, the scene sheet (one row per scene: its line, what changes on screen, and its seconds) and the animatic. Wait for their OK.
## 7. Build the frame
These files are the real frame that made our demo, with our demo's story in it. You rendered them in step 1 to prove your setup. Now change the story, using the list below. Leave the machinery alone until the film works, and read the code before you change it. Paper, panels and labels are sized to our demo's words, so check anything longer on the contact sheet.
### What to change for your story
Most of the words are in one block at the top of the file, and most of the drawing is the room in the window. Replace them in that order.
| To change | Where |
|---|---|
| The words on the cards | The constants at the top: `OPEN_CARD`, `CALLS` (each caller's text, doodle and events), `LAST_LABEL`, `LAST_NAME`, `LAST_NOTE`, `STAMP`, `MORNING_CARD`, `CARD_LABEL`, `HOLD_LABEL`, `LIVE_LABEL`, `STICKY` |
| The words on the wall | `WORRIES`: each one's text, its place in the room, its size and the event that brings it in |
| The show and its small jokes | `SHOW`, `SHOW_LINE`, `METERS` and `STRIPS`. `stripLevel` and `stripSignal` give each strip its behavior by position: the fourth falls on "smaller", and the fifth never moves |
| The voiceless caption | `QUIET_WORDS`, shown from `QUIET_T0` in the first scene with no words |
| The clock | `START_MIN` and `END_MIN` at the top, and the schedule in `minutes`. `MORNING_CARD.head` and `STAMP` also say the hour, so change them together |
| How many callers | `CALLS`, the row list `[1, 2, 3, LAST_LINE]` in `LinePanel`, the event lists in `minutes` and `RING_EVENTS`, and `LAST_LINE`. `ROW` sets the height of a line row, and four is what fits the panel |
| The doodles | `Doodle`: `mail`, `talk` and `eye` are pencil paths. Add a kind for your own caller |
| The room | `Room` draws it in units of 600 x 340, `Figure` draws the person, and `SIT` and `LIE` are the two poses it eases between. `lieAt` and `phoneAt` say when |
| Which window, and how close | `HERO` must match a pane of the third `addBuilding` block, or a dark pane shows behind it. `ZMAX` and `ANCHOR` set how far the view dives and where it stops |
| The skyline | The `addBuilding` calls. Each pane's `off` time comes from `E('on')`, `E('low')` and `E('there')`. `CARS` and `STARS` are lists |
| The light | `lumAt` for the room, the `lamp` schedule in `Meters`, `light` in `Cards`, and `LightFx` for the red wash and the morning beam |
| The sign | `Sign` holds ON, DEAD and AIR. `signOn`, `signDead` and `signAir` say when each lights. Keep two words that share a last word, or rewrite the sign |
| The camera | `KEYS`, and the knock in `camAt` when the stamp lands |
| The comment keyword | `plan.json`, `KEYWORD` at the top, and the `key` event in `story.json`, which must point at the keyword as spoken. `CtaCard` holds the word COMMENT. If your keyword isn't AIR, the sign still leaves AIR lit at the end, so take the ramp on `key` out of `signOn` |
- **Scene ids in the code:** none. It only reads events, and the voiceless caption finds the first scene with no words, so you can renumber scenes freely.
- **Words in `story.json` belong to our script.** `people`, `card1`, `didn`, `card2`, `fine`, `card3`, `tuesday`, `producer`, `more`, `four`, `there`, `you`, `isn`, `decide`, `three2`, `decision`, `eight`, `just`, `smaller`, `back8`, `nobody` and `key` all point at a word. Re-point each one. A word that isn't there stops the render, which is what you want. Remember that `word` takes the first match in its scene: `didn` points at "It", the first word of the answer. `three`, `asleep`, `email`, `replay`, `tired`, `thats` and `real` are there only for sounds, so delete any you don't use.
- **Panels are sized to our words, and they shrink rather than overflow.** `fit()` sets the type: `Main` has 430 px beside the doodle, the last card's big row has 548 px, the stamp 330 px, `CtaCard` 520 px and `SHOW` 580 px. A much longer word still fits, but it gets small, so check it on a full-size still and shorten the word if it's hard to read.
- **Captions** break into rows at `PER_ROW` letters, and `rowsOf` balances the two rows so a line doesn't end on one orphan word.
- **`lumAt`, `Meters` and `Cards` read their schedules in time order and don't check.** After moving events around, watch the room's brightness on the animatic.
- **Layers:** `Stage` draws back to front, with everything before the tint layer unlit. `Glass` stacks the city, then `Worries`, then `Figure`, so the person stays in front of the words on the wall. `Room` defines the clip that `Figure` uses, so keep both.
### The code
`plan.json`, our demo's script:
```json
{
"lines": [
{
"id": "l01",
"text": "It's three a.m., and this is the show for people who should be asleep.",
"say": "It's three am, and this is the show for people who should be asleep."
},
{
"id": "l02",
"text": "Line one wants to know if that email sounded rude. It didn't."
},
{
"id": "l03",
"text": "Line two is replaying 2014. You were fine.",
"say": "Line two is replaying twenty-fourteen. You were fine."
},
{
"id": "l04",
"text": "Line three says they're not tired. Line three's been up since Tuesday."
},
{
"id": "l05",
"text": "My producer says we have one more."
},
{
"id": "l06",
"text": "Line four. Line four, are you there?"
},
{
"id": "l07",
"text": "Line four. That's you, isn't it."
},
{
"id": "l08",
"text": "Nothing you decide at three a.m. is a real decision.",
"say": "Nothing you decide at three am is a real decision."
},
{
"id": "l09",
"text": "It'll all still be there at eight. It'll just be smaller."
},
{
"id": "l10",
"text": "And we're back at eight. Nobody called."
},
{
"id": "l11",
"text": "Comment air and I'll send you the skill that made this.",
"say": "Comment, air, and I'll send you the skill that made this."
}
],
"scenes": [
{
"id": "s01",
"lines": [
"l01"
],
"lead": 0.15,
"tail": 0.1
},
{
"id": "s02",
"lines": [
"l02"
],
"lead": 0.15,
"tail": 0.1
},
{
"id": "s03",
"lines": [
"l03"
],
"lead": 0.1,
"tail": 0.1
},
{
"id": "s04",
"lines": [
"l04"
],
"lead": 0.1,
"tail": 0.1
},
{
"id": "s05",
"lines": [
"l05"
],
"lead": 0.15,
"tail": 0.1
},
{
"id": "s06",
"lines": [
"l06"
],
"lead": 0.55,
"tail": 0.35
},
{
"id": "s07",
"lines": [
"l07"
],
"lead": 0.1,
"tail": 0.15
},
{
"id": "s08",
"lines": [
"l08"
],
"lead": 0.15,
"tail": 0.15
},
{
"id": "s09",
"lines": [
"l09"
],
"lead": 0.15,
"tail": 0.2
},
{
"id": "s10",
"sec": 3.8
},
{
"id": "s11",
"lines": [
"l10"
],
"lead": 0.05,
"tail": 0.1
},
{
"id": "s12",
"lines": [
"l11"
],
"lead": 0.1,
"tail": 0.1
}
]
}
```
`src/index.ts`:
```ts
import { registerRoot } from 'remotion';
import { Root } from './Root';
registerRoot(Root);
```
`src/Root.tsx`:
```tsx
import React from 'react';
import { Composition } from 'remotion';
import { OnAir } from './frames/OnAir';
import spec from './spec.json';
export const Root: React.FC = () => (
<Composition id="Film-9x16" component={OnAir} 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": "three",
"scene": "s01",
"word": "three"
},
{
"id": "people",
"scene": "s01",
"word": "people"
},
{
"id": "asleep",
"scene": "s01",
"word": "asleep"
},
{
"id": "line1",
"scene": "s02"
},
{
"id": "card1",
"scene": "s02",
"word": "one"
},
{
"id": "email",
"scene": "s02",
"word": "email"
},
{
"id": "didn",
"scene": "s02",
"word": "It"
},
{
"id": "line2",
"scene": "s03"
},
{
"id": "card2",
"scene": "s03",
"word": "two"
},
{
"id": "replay",
"scene": "s03",
"word": "replaying"
},
{
"id": "fine",
"scene": "s03",
"word": "You"
},
{
"id": "line3",
"scene": "s04"
},
{
"id": "card3",
"scene": "s04",
"word": "three"
},
{
"id": "tired",
"scene": "s04",
"word": "tired"
},
{
"id": "tuesday",
"scene": "s04",
"word": "Tuesday"
},
{
"id": "talk",
"scene": "s05"
},
{
"id": "producer",
"scene": "s05",
"word": "producer"
},
{
"id": "more",
"scene": "s05",
"word": "more"
},
{
"id": "low",
"scene": "s06"
},
{
"id": "four",
"scene": "s06",
"word": "four"
},
{
"id": "there",
"scene": "s06",
"word": "there"
},
{
"id": "turn",
"scene": "s07"
},
{
"id": "thats",
"scene": "s07",
"word": "That"
},
{
"id": "you",
"scene": "s07",
"word": "you"
},
{
"id": "isn",
"scene": "s07",
"word": "isn"
},
{
"id": "msg",
"scene": "s08"
},
{
"id": "decide",
"scene": "s08",
"word": "decide"
},
{
"id": "three2",
"scene": "s08",
"word": "three"
},
{
"id": "real",
"scene": "s08",
"word": "real"
},
{
"id": "decision",
"scene": "s08",
"word": "decision"
},
{
"id": "comfort",
"scene": "s09"
},
{
"id": "eight",
"scene": "s09",
"word": "eight"
},
{
"id": "just",
"scene": "s09",
"word": "just"
},
{
"id": "smaller",
"scene": "s09",
"word": "smaller"
},
{
"id": "quiet",
"scene": "s10"
},
{
"id": "back",
"scene": "s11"
},
{
"id": "back8",
"scene": "s11",
"word": "eight"
},
{
"id": "nobody",
"scene": "s11",
"word": "Nobody"
},
{
"id": "cta",
"scene": "s12"
},
{
"id": "key",
"scene": "s12",
"word": "air"
}
]
}
```
`src/frames/OnAir.tsx`:
```tsx
import React from 'react';
import { AbsoluteFill } from 'remotion';
import story from './story.json';
import { back, buildChunks, clamp, Clock, easeIn, easeInOut, easeOut, Fonts, hash, makeE, ramp, SCENES, useT, WORDS, type StoryJson, type Word } from './core';
/* On Air: a 3 a.m. radio studio, seen as one console in front of one window. The needles read the spoken words, the phone lines flash,
and the night city through the glass goes dark until one lit window is left. 1080 x 1920. Time comes from the spoken words through
story.json, so a re-voice moves every lamp with its word. */
const E = makeE(story as StoryJson);
/* ---------- the story's words: a new topic starts here ---------- */
const KEYWORD = 'AIR';
const START_MIN = 180, END_MIN = 480; // the clock: 03:00 to 08:00, in minutes since midnight
const SHOW = 'THE SMALL HOURS';
const SHOW_LINE = 'CALLS WELCOME. SLEEP ENCOURAGED.';
const OPEN_CARD = { head: 'TONIGHT', main: 'LINES OPEN', note: 'go to bed.', noteAt: 'people' };
const CALLS = [
{ line: 1, ring: 'line1', card: 'card1', re: 'RE: THAT EMAIL', note: "it didn't.", noteAt: 'didn', doodle: 'mail' },
{ line: 2, ring: 'line2', card: 'card2', re: 'RE: 2014', note: 'you were fine.', noteAt: 'fine', doodle: 'talk' },
{ line: 3, ring: 'line3', card: 'card3', re: 'RE: NOT TIRED', note: 'up since tuesday', noteAt: 'tuesday', doodle: 'eye' },
];
const LAST_LINE = 4;
const CARD_LABEL = 'CALLER CARD', HOLD_LABEL = 'ON HOLD', LIVE_LABEL = 'ON THE LINE', METERS = ['HOST', 'CALLER'];
const STICKY = 'one more';
const LAST_LABEL = `LINE ${LAST_LINE}:`, LAST_NAME = 'YOU', LAST_NOTE = 're: everything';
const STAMP = 'VOID UNTIL 8 A.M.';
const MORNING_CARD = { head: '08:00', main: 'CALLS: 0', note: 'told you.' };
const QUIET_WORDS = 'GOOD NIGHT';
const STRIPS = ['MIC 1', 'PHONES', 'CART', 'REGRET', 'COFFEE'];
/* What keeps the last caller up, written on the wall of the room. u and v are room units (600 x 340), size is in the same units. */
const WORRIES = [
{ s: 'EVERYTHING', u: 318, v: 44, size: 84, rot: -2, at: 'isn' },
{ s: 'THAT EMAIL', u: 196, v: 118, size: 50, rot: 2, at: 'msg' },
{ s: '2014', u: 490, v: 128, size: 74, rot: -3, at: 'decide' },
{ s: 'NOT TIRED', u: 186, v: 170, size: 40, rot: -1, at: 'three2' },
];
/* ---------- look ---------- */
const WALNUT = ['#7a5232', '#5a3a22', '#3d2716'], RED = '#ff3b30', AMBER = '#ffb000', GREEN = '#3fe07a', INK = '#2a2018', PAPER = '#f7f0dd', PENCIL = '#c21d12', CREAM = '#f4f1ea';
const DISP = '"Big Shoulders Display", sans-serif', HAND = 'Caveat, cursive';
const FACES = [
{ family: 'Big Shoulders Display', file: 'big-shoulders-display-latin-800-normal.woff2', weight: '800' },
{ family: 'Big Shoulders Display', file: 'big-shoulders-display-latin-700-normal.woff2', weight: '700' },
{ family: 'Caveat', file: 'caveat-latin-700-normal.woff2', weight: '700' },
];
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
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 lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / Math.max(1e-6, pts[i][0] - pts[i - 1][0]));
return pts[pts.length - 1][1];
};
type RGB = [number, number, number];
const hex = (c: string): RGB => (c[0] === '#' ? [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)] : ((c.match(/[\d.]+/g) as string[]).slice(0, 3).map(Number) as RGB));
const css = (c: RGB) => `rgb(${c.map((x) => Math.round(x)).join(',')})`;
const mix = (a: string, b: string, k: number) => { const x = hex(a), y = hex(b); return css([lerp(x[0], y[0], k), lerp(x[1], y[1], k), lerp(x[2], y[2], k)]); };
/** Three stops: night, dawn, morning, on the time of day. */
const tod = (d: number, n: string, dw: string, m: string) => (d < 0.55 ? mix(n, dw, d / 0.55) : mix(dw, m, (d - 0.55) / 0.45));
/** A size that keeps a longer word inside its panel. */
const fit = (s: string, w: number, max: number, em = 0.47) => Math.min(max, w / (Math.max(1, s.length) * em));
/* ---------- the schedule: everything below reads these ---------- */
const LINE_OFF = (n: number) => E('quiet') + 0.25 + (n - 1) * 0.5;
const LAST_OFF = LINE_OFF(LAST_LINE);
const FADER_T0 = E('quiet') + 0.5, FADER_T1 = E('quiet') + 2.5;
const SIGN_DIM_T0 = E('quiet') + 2.1, SIGN_DIM_T1 = E('quiet') + 3.2;
/** The studio clock, in minutes since midnight: one minute a call, stuck through the low point, running from "eight" and landing on 08:00. */
const minutes = (t: number) => {
if (t < E('eight')) return START_MIN + [E('line1'), E('line2'), E('line3'), E('low')].filter((e) => t >= e).length;
if (t < E('quiet')) return START_MIN + 4 + 56 * easeIn(ramp(t, E('eight'), E('quiet')));
return START_MIN + 60 + (END_MIN - START_MIN - 60) * (1 - Math.pow(1 - ramp(t, E('quiet'), E('back8')), 1.5));
};
/** The sky follows the clock, so the window can never disagree with it. 0 is night, 1 is morning. */
const dayAt = (t: number) => ramp(minutes(t), START_MIN + 105, END_MIN);
const darkAt = (t: number) => ramp(t, E('low'), E('low') + 0.9) * (1 - ramp(t, E('you'), E('you') + 1.4));
const morningAt = (t: number) => ramp(t, E('quiet') + 2.4, E('back8'));
const stutter = (t: number, at: number, d: number, seed: number) => (t < at ? 0 : t >= at + d ? 1 : hash(Math.floor((t - at) * 24) + seed) > 0.62 - 0.6 * ((t - at) / d) ? 1 : 0.12);
const signOn = (t: number) => {
if (t < E('low')) return stutter(t, 0, 0.42, 3);
if (t < E('decision')) return t < E('low') + 0.32 && hash(Math.floor(t * 24)) > 0.5 ? 0.8 : 0;
if (t < E('back')) return stutter(t, E('decision'), 0.34, 9) * 0.88 * (1 - ramp(t, SIGN_DIM_T0, SIGN_DIM_T1));
return stutter(t, E('back8'), 0.3, 13) * (1 - 0.8 * ramp(t, E('key'), E('key') + 0.5));
};
const signDead = (t: number) => {
if (t < E('low') + 0.25 || t >= E('decision')) return 0;
return 0.16 + 0.84 * Math.pow(0.5 + 0.5 * Math.sin((t - E('low')) * 5.4 - 1.57), 1.5);
};
const signAir = (t: number) => {
const key = t >= E('key') ? 1 + 0.25 * Math.exp(-(t - E('key')) * 3) : 0;
return clamp(Math.max(signOn(t), signDead(t), key));
};
/** One lamp per phone line. Each rings, then holds, and they go dark one by one in the voiceless moment. */
const lineLamp = (n: number, t: number): { c: string; v: number; word: string; active: boolean } => {
const off = { c: '#2b2e31', v: 0, word: '', active: false };
if (t >= LINE_OFF(n)) return off;
const blink = Math.floor(t * 6) % 2 === 0 ? 1 : 0.22;
const dim = 1 - 0.6 * darkAt(t);
if (n === LAST_LINE) {
if (t < E('more')) return off;
if (t < E('you')) return { c: t < E('low') ? AMBER : RED, v: t < E('low') ? (Math.floor(t * 3) % 2 === 0 ? 0.9 : 0.2) : (Math.floor(t * 3) % 2 === 0 ? 1 : 0.14), word: 'RING', active: t >= E('low') };
return { c: AMBER, v: 1, word: LAST_NAME, active: true };
}
const call = CALLS[n - 1];
if (t < E(call.ring)) return off;
if (t < E(call.noteAt)) return { c: AMBER, v: blink, word: 'RING', active: true };
return { c: GREEN, v: 0.8 * dim, word: 'HOLD', active: false };
};
/* ---------- the needles read the words, so they work with or without a voice ---------- */
const envAt = (tt: number) => {
let v = 0;
for (const w of WORDS) {
if (tt < w.t0 - 0.02 || tt > w.t1 + 0.12) continue;
const shape = ramp(tt, w.t0 - 0.02, w.t0 + 0.05) * (1 - ramp(tt, w.t1 - 0.03, w.t1 + 0.12));
const syllable = 0.74 + 0.26 * Math.sin((tt - w.t0) * (24 + hash(w.t0 * 13) * 10));
v = Math.max(v, shape * syllable * (0.72 + 0.28 * hash(w.t0 * 7)));
}
return v;
};
const hostGain = (t: number) => pw(t, [[E('low'), 1], [E('low') + 0.2, 0], [E('you'), 0], [E('you') + 0.6, 0.6], [E('decision'), 0.62], [E('back'), 0.62], [E('back') + 0.2, 1]]);
const hostLevel = (t: number) => {
const wake = t < 0.5 ? 0.7 * Math.sin((t / 0.5) * Math.PI) : 0;
return Math.max(wake, ((envAt(t) + envAt(t - 0.034) + envAt(t - 0.067)) / 3) * hostGain(t));
};
/** The caller's needle: chatter while a line is up, flat on the low point, then a slow breath once we know who it is. */
const callerLevel = (t: number) => {
if (t < E('line1') || t >= E('talk')) {
if (t < E('you') || t >= LAST_OFF) return 0;
return (0.16 + 0.1 * Math.sin((t - E('you')) * 1.9)) * ramp(t, E('you'), E('you') + 0.7) * (1 - ramp(t, LAST_OFF - 0.5, LAST_OFF));
}
return 0.22 + 0.2 * hash(Math.floor(t * 7)) + 0.05 * Math.sin(t * 31);
};
const needle = (level: number) => lerp(-42, 38, Math.pow(clamp(level), 1.25));
/* ---------- the city ---------- */
const GX = 44, GY = 418, GW = 992, GH = 440;
const HERO = { x: 520, y: 274, w: 60, h: 34 };
const HC: [number, number] = [HERO.x + HERO.w / 2, HERO.y + HERO.h / 2];
const ZMAX = 12.5, ANCHOR: [number, number] = [420, 220];
const lens = (t: number) => {
const k = easeInOut(ramp(t, E('turn') - 0.05, E('you') + 0.45)) * (1 - easeInOut(ramp(t, E('quiet') + 0.05, E('quiet') + 1.5)));
const pre = easeInOut(ramp(t, E('low'), E('turn'))) * (1 - ramp(t, E('turn'), E('you')));
const z = Math.exp(Math.log(ZMAX) * k + Math.log(1.35) * pre * (1 - k));
const ax = lerp(HC[0], ANCHOR[0], k), ay = lerp(HC[1], ANCHOR[1], k);
return { z, ax, ay, k, tf: `translate(${ax} ${ay}) scale(${z}) translate(${-HC[0]} ${-HC[1]})` };
};
/** Room units to glass pixels. */
const roomX = (L: ReturnType<typeof lens>, u: number) => L.ax + (HERO.x + u * 0.1 - HC[0]) * L.z;
const roomY = (L: ReturnType<typeof lens>, v: number) => L.ay + (HERO.y + v * 0.1 - HC[1]) * L.z;
type Bld = { x: number; w: number; top: number; layer: number };
type Pane = { x: number; y: number; w: number; h: number; layer: number; lit: boolean; off: number; tv: boolean; c: string };
const BLD: Bld[] = [], PANES: Pane[] = [];
const WARM = ['#ffcf7a', '#ffb94d', '#ffe0a0', '#ffc766'];
const addBuilding = (layer: number, x: number, w: number, top: number, pw_: number, ph: number, gx: number, gy: number, litP: number, seed: number) => {
BLD.push({ x, w, top, layer });
let i = 0;
for (let y = top + gy; y < 400; y += ph + gy) {
for (let px = x + gx; px + pw_ <= x + w - gx + 0.01; px += pw_ + gx) {
i += 1;
const s = seed * 31 + i * 7.3;
if (layer === 2 && Math.abs(px - HERO.x) < 1 && Math.abs(y - HERO.y) < 1) continue;
const lit = hash(s) < litP;
const k = hash(s + 2);
const off = hash(s + 3) < 0.42 ? lerp(E('on') + 1.5, E('low') - 0.3, k) : lerp(E('low') + 0.15, E('there') + 0.3, k);
PANES.push({ x: px, y, w: pw_, h: ph, layer, lit, off, tv: hash(s + 4) > 0.93, c: WARM[Math.floor(hash(s + 5) * WARM.length)] });
}
}
};
for (let i = 0; i < 27; i++) addBuilding(0, i * 38 - 12 + hash(i + 2) * 8, 32 + hash(i + 40) * 24, 170 + hash(i + 80) * 110, 4, 6, 4, 6, 0.3, i + 100);
for (let i = 0; i < 12; i++) addBuilding(1, i * 88 - 30 + hash(i + 12) * 18, 62 + hash(i + 50) * 34, 232 + hash(i + 90) * 96, 9, 12, 7, 9, 0.34, i + 300);
addBuilding(2, -14, 180, 296, 30, 22, 14, 14, 0.4, 500);
addBuilding(2, 786, 240, 248, 44, 26, 16, 16, 0.4, 600);
addBuilding(2, 432, 314, 176, 60, 34, 14, 10, 0.24, 700);
const CARS: { at: number; dir: number; c: string }[] = [
{ at: 0, dir: 1, c: '#1b1e2c' }, { at: 6.2, dir: -1, c: '#2b1c20' }, { at: 12.8, dir: 1, c: '#1b1e2c' }, { at: 24.5, dir: -1, c: '#1f242e' }, { at: E('back') - 0.6, dir: 1, c: '#c75b3c' }, { at: E('nobody') + 1.2, dir: -1, c: '#3a6c8a' },
];
const STARS = Array.from({ length: 70 }, (_, i) => ({ x: hash(i + 90) * GW, y: hash(i + 190) * 230, r: 0.6 + hash(i + 290) * 1.3, p: hash(i) * 6, s: 0.6 + hash(i + 3) * 1.4 }));
const RING_EVENTS = ['on', 'line1', 'line2', 'line3', 'producer', 'four', 'there'];
const City: React.FC = () => {
const t = useT();
const d = dayAt(t), dk = darkAt(t), L = lens(t);
const sky = (n: string, dw: string, m: string, black: string) => mix(tod(d, n, dw, m), black, dk);
const sink = (c: string) => mix(c, '#04050f', dk * 0.82);
const far = sink(tod(d, '#161c40', '#554b70', '#b0bbcf')), midB = sink(tod(d, '#0f1433', '#3c3452', '#8a94a9')), near = sink(tod(d, '#0b0d24', '#2e2638', '#6f7283'));
const pane = sink(tod(d, '#141730', '#4b4562', '#b5d3ec'));
const sunY = lerp(370, 110, d);
const stars = (1 - ramp(d, 0, 0.45));
const blinkOn = Math.floor(t * 1.2) % 2 === 0;
return (
<svg viewBox={`0 0 ${GW} ${GH}`} width={GW} height={GH} 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={sky('#0b1233', '#3b4a86', '#7db4e8', '#05071a')} />
<stop offset="0.55" stopColor={sky('#151d49', '#b56f8d', '#b9d9f2', '#090b22')} />
<stop offset="0.8" stopColor={sky('#33295a', '#ffb27a', '#fdebc8', '#12102a')} />
</linearGradient>
<radialGradient id="sun"><stop offset="0" stopColor="#fff6d8" stopOpacity="0.95" /><stop offset="0.3" stopColor="#ffd98f" stopOpacity="0.5" /><stop offset="1" stopColor="#ffb27a" stopOpacity="0" /></radialGradient>
<radialGradient id="beaconGlow"><stop offset="0" stopColor="#ff5a4a" stopOpacity="0.75" /><stop offset="1" stopColor="#ff5a4a" stopOpacity="0" /></radialGradient>
<radialGradient id="streetGlow"><stop offset="0" stopColor="#ffc98a" stopOpacity="0.5" /><stop offset="1" stopColor="#ffc98a" stopOpacity="0" /></radialGradient>
<radialGradient id="halo"><stop offset="0" stopColor="#ffcf7a" stopOpacity="0.6" /><stop offset="1" stopColor="#ffcf7a" stopOpacity="0" /></radialGradient>
<radialGradient id="lampGlow"><stop offset="0" stopColor="#fff2c8" stopOpacity="0.95" /><stop offset="0.45" stopColor="#ffd088" stopOpacity="0.32" /><stop offset="1" stopColor="#ffd088" stopOpacity="0" /></radialGradient>
<radialGradient id="phoneGlow"><stop offset="0" stopColor="#dbe9ff" stopOpacity="0.85" /><stop offset="1" stopColor="#dbe9ff" stopOpacity="0" /></radialGradient>
</defs>
<g transform={L.tf}>
<rect x={-40} y={-40} width={GW + 80} height={GH + 80} fill="url(#sky)" />
<g opacity={stars}>{STARS.map((s, i) => <circle key={i} cx={s.x} cy={s.y} r={s.r} fill="#dfe8ff" opacity={0.3 + 0.6 * Math.abs(Math.sin(t * s.s + s.p))} />)}</g>
<path d="M118 44 A30 30 0 1 1 118 104 A46 46 0 0 0 118 44 Z" fill="#f4edd2" opacity={0.92 * stars + 0.06} />
<circle cx={330} cy={sunY} r={230} fill="url(#sun)" opacity={ramp(d, 0.18, 0.55)} />
<circle cx={330} cy={sunY} r={30} fill="#fff4d0" opacity={ramp(d, 0.25, 0.6)} />
{/* morning birds, a small proof that the world went on */}
{morningAt(t) > 0 ? [0, 1, 2].map((i) => { const x = 120 + i * 34 + (t - E('quiet')) * 48, y = 90 + i * 16 + Math.sin(t * 2 + i) * 4, f = Math.sin(t * 11 + i) * 5; return <path key={i} d={`M${x - 11} ${y + f} Q${x - 4} ${y - 4} ${x} ${y} Q${x + 4} ${y - 4} ${x + 11} ${y + f}`} fill="none" stroke="#3a3a4a" strokeWidth="2.4" strokeLinecap="round" opacity={morningAt(t)} />; }) : null}
{BLD.filter((b) => b.layer === 0).map((b, i) => <rect key={i} x={b.x} y={b.top} width={b.w} height={GH} fill={far} />)}
{PANES.filter((p) => p.layer === 0 && p.lit && t < p.off).map((p, i) => <rect key={i} x={p.x} y={p.y} width={p.w} height={p.h} fill={p.c} opacity={0.72 * (1 - ramp(d, 0.3, 0.6))} />)}
{/* the station's own mast, firing a ring on every call */}
<g stroke={midB} strokeWidth="2.6" fill="none">
<path d="M214 340 L236 54 L258 340" />
{[0, 1, 2, 3, 4, 5, 6, 7].map((i) => { const y = 88 + i * 32, w = 2.6 + (y - 54) * 0.077; return <path key={i} d={`M${236 - w} ${y} L${236 + w} ${y + 28} M${236 + w} ${y} L${236 - w} ${y + 28}`} strokeWidth="1.5" />; })}
</g>
<circle cx={236} cy={50} r={blinkOn ? 34 : 14} fill="url(#beaconGlow)" opacity={blinkOn ? 1 : 0.5} />
<circle cx={236} cy={50} r={4.6} fill={blinkOn ? '#ff7a6a' : '#7a2018'} />
{RING_EVENTS.map((e, i) => { const u = t - E(e); return u >= 0 && u < 1.25 ? <circle key={i} cx={236} cy={50} r={22 + u * 250} fill="none" stroke="#ff7a6a" strokeWidth={2.4} opacity={(1 - u / 1.25) * 0.65} /> : null; })}
{BLD.filter((b) => b.layer === 1).map((b, i) => <rect key={i} x={b.x} y={b.top} width={b.w} height={GH} fill={midB} />)}
{PANES.filter((p) => p.layer === 1).map((p, i) => <rect key={i} x={p.x} y={p.y} width={p.w} height={p.h} fill={p.lit && t < p.off && d < 0.5 ? p.c : pane} opacity={p.lit && t < p.off && d < 0.5 ? 0.9 : 0.35} />)}
{BLD.filter((b) => b.layer === 2).map((b, i) => <g key={i}><rect x={b.x} y={b.top} width={b.w} height={GH} fill={near} /><rect x={b.x - 3} y={b.top - 5} width={b.w + 6} height={7} fill={tod(d, '#191c38', '#403650', '#83869a')} /></g>)}
{PANES.filter((p) => p.layer === 2).map((p, i) => {
const on = p.lit && t < p.off && d < 0.5;
const flicker = p.tv ? 0.55 + 0.45 * hash(Math.floor(t * 7) + i) : 1;
return (
<g key={i}>
<rect x={p.x} y={p.y} width={p.w} height={p.h} fill={on ? (p.tv ? '#8fb6ff' : p.c) : pane} opacity={on ? flicker * 0.86 : 0.9} stroke={tod(d, '#20233f', '#3a3350', '#73768a')} strokeWidth="0.5" />
{on ? <rect x={p.x} y={p.y} width={p.w} height={p.h * (0.2 + hash(p.x + p.y) * 0.5)} fill="#2a1a24" opacity="0.5" /> : null}
<rect x={p.x + p.w / 2 - 0.5} y={p.y} width={1} height={p.h} fill={tod(d, '#12142c', '#3a3350', '#73768a')} opacity="0.7" />
</g>
);
})}
<Room />
{/* the last light, a halo so the eye can find it in the wide shot */}
{L.k < 0.25 && t < LAST_OFF ? <circle cx={HC[0]} cy={HC[1]} r={56} fill="url(#halo)" opacity={darkAt(t) * (0.7 + 0.3 * Math.sin(t * 2.2)) * (1 - L.k * 4)} /> : null}
{/* the street */}
<rect x={-40} y={404} width={GW + 80} height={60} fill={tod(d, '#0a0b1a', '#2c2734', '#6c6a6e')} />
<rect x={-40} y={402} width={GW + 80} height={3} fill={tod(d, '#20233a', '#544a58', '#a09c98')} />
{Array.from({ length: 12 }, (_, i) => <rect key={i} x={i * 90 - 10} y={424} width={46} height={3} fill="#e6d9a8" opacity={0.2 + 0.25 * d} />)}
{Array.from({ length: 7 }, (_, i) => { const x = 70 + i * 150; return <g key={i}><rect x={x} y={372} width={2.4} height={32} fill={tod(d, '#23263c', '#39344a', '#54566a')} /><circle cx={x + 1} cy={371} r={26} fill="url(#streetGlow)" opacity={1 - ramp(d, 0.3, 0.7)} /><circle cx={x + 1} cy={371} r={2.8} fill="#ffc98a" opacity={1 - ramp(d, 0.3, 0.7)} /></g>; })}
{/* the 3 a.m. traffic signal, blinking for nobody */}
<rect x={382} y={370} width={2.4} height={34} fill="#23263c" />
<rect x={378} y={358} width={10} height={16} rx={2} fill="#14162a" />
<circle cx={383} cy={366} r={3} fill={AMBER} opacity={Math.floor(t * 1.6) % 2 === 0 ? 1 - ramp(d, 0.4, 0.8) : 0.12} />
{CARS.map((c, i) => {
const u = t - c.at;
if (u < 0 || u > 7) return null;
const x = c.dir > 0 ? -60 + u * 190 : GW + 60 - u * 190, y = c.dir > 0 ? 428 : 412;
return (
<g key={i} transform={`translate(${x} ${y}) scale(${c.dir} 1)`}>
<path d="M0 0 L46 0 L54 -4 L54 -10 L42 -10 L34 -18 L10 -18 L2 -10 L-4 -8 L-4 -2 Z" fill={c.c} />
<path d="M54 -6 L120 -14 L120 2 Z" fill="#ffe8b0" opacity={0.26 * (1 - ramp(d, 0.4, 0.8))} />
<rect x={-5} y={-8} width={3} height={4} fill="#ff4a3a" opacity={1 - ramp(d, 0.4, 0.8)} />
</g>
);
})}
</g>
</svg>
);
};
/* ---------- the one lit window: drawn in room units, 600 x 340, shrunk into the facade ---------- */
const roomLit = (t: number) => (t < LAST_OFF ? 1 : t < LAST_OFF + 0.14 ? (hash(Math.floor(t * 30)) > 0.5 ? 0.5 : 0) : 0);
const phoneAt = (t: number) => 1 - ramp(t, E('eight'), E('eight') + 0.3);
const lieAt = (t: number) => easeInOut(ramp(t, E('just'), E('smaller') + 0.75));
const Room: React.FC = () => {
const t = useT();
const lit = roomLit(t), d = dayAt(t);
const c = (dark: string, warm: string) => mix(dark, warm, lit);
return (
<g transform={`translate(${HERO.x} ${HERO.y}) scale(0.1)`}>
<clipPath id="roomclip"><rect x="0" y="0" width="600" height="340" /></clipPath>
<g clipPath="url(#roomclip)">
<rect x="0" y="0" width="600" height="340" fill={c('#0b1026', '#c98440')} />
{Array.from({ length: 13 }, (_, i) => <rect key={i} x={i * 48 + 14} y="0" width="2" height="306" fill={c('#0e1330', '#b87436')} />)}
<circle cx="72" cy="150" r="440" fill="url(#lampGlow)" opacity={lit} />
{/* the floor, the night table, the lamp, the clock radio */}
<rect x="0" y="306" width="600" height="34" fill={c('#060814', '#5a3219')} />
<rect x="26" y="232" width="86" height="76" fill={c('#0a0c1c', '#46281a')} /><rect x="22" y="226" width="94" height="8" fill={c('#0c0e20', '#5a3421')} />
<rect x="60" y="178" width="10" height="48" fill={c('#0b0d1e', '#3a2418')} />
<path d="M34 182 L46 132 L84 132 L96 182 Z" fill={c('#141832', '#ffe2a0')} />
<rect x="74" y="206" width="36" height="20" rx="3" fill="#120d10" /><rect x="79" y="211" width="26" height="10" fill={lit > 0.5 ? '#ff3b30' : '#44140f'} />
{/* the bed */}
<rect x="112" y="186" width="16" height="122" fill={c('#0a0c1c', '#3f2416')} />
<rect x="120" y="240" width="384" height="48" rx="8" fill={c('#141832', '#efe2c8')} />
<rect x="120" y="282" width="384" height="26" fill={c('#0a0c1c', '#4a2a1a')} />
<rect x="498" y="222" width="12" height="86" fill={c('#0a0c1c', '#3f2416')} />
<rect x="132" y="216" width="72" height="30" rx="12" fill={c('#191d3a', '#fff5e0')} />
{/* the chair with the clothes on it */}
<path d="M522 306 L522 214 M574 306 L574 214 M522 256 L574 256" stroke={c('#0a0c1c', '#3f2416')} strokeWidth="8" fill="none" />
<path d="M514 212 C524 186 572 184 582 214 L578 262 C560 250 534 252 518 262 Z" fill={c('#10142c', '#7a4a52')} />
{/* curtains */}
<path d="M0 0 L58 0 C50 90 62 200 34 306 L0 306 Z" fill={c('#0e1228', '#8a3f2c')} />
<path d="M600 0 L546 0 C556 90 540 200 568 306 L600 306 Z" fill={c('#0e1228', '#8a3f2c')} />
{[14, 30, 44].map((x) => <path key={x} d={`M${x} 0 C${x - 4} 100 ${x + 2} 200 ${x - 10} 306`} stroke={c('#090c20', '#6d2f20')} strokeWidth="3" fill="none" />)}
{/* by morning the curtains are shut */}
<rect x="0" y="0" width="600" height="340" fill={tod(d, '#0c1026', '#b08a80', '#ecdcc6')} opacity={ramp(d, 0.35, 0.8)} />
</g>
<rect x="-13" y="-13" width="626" height="366" fill="none" stroke={tod(d, '#191b34', '#39324a', '#5e6072')} strokeWidth="26" />
<rect x="-30" y="352" width="660" height="20" fill={tod(d, '#1f2240', '#48405a', '#8f92a4')} />
</g>
);
};
/** The person is a stick of three joints that eases from sitting up to lying down, so one number moves the whole body. */
type Pose = { head: [number, number]; sh: [number, number]; hip: [number, number]; knee: [number, number]; foot: [number, number]; el: [number, number]; hand: [number, number] };
const SIT: Pose = { head: [352, 124], sh: [326, 172], hip: [322, 240], knee: [368, 254], foot: [372, 316], el: [362, 214], hand: [400, 190] };
const LIE: Pose = { head: [170, 214], sh: [222, 228], hip: [326, 232], knee: [412, 232], foot: [488, 234], el: [270, 236], hand: [312, 236] };
const at2 = (a: [number, number], b: [number, number], k: number): [number, number] => [lerp(a[0], b[0], k), lerp(a[1], b[1], k)];
const Figure: React.FC = () => {
const t = useT();
const L = lens(t), lit = roomLit(t), k = lieAt(t), ph = phoneAt(t), d = dayAt(t);
if (d > 0.3) return null;
const P = (n: keyof Pose) => at2(SIT[n], LIE[n], k);
const [hx, hy] = P('head'), sh = P('sh'), hip = P('hip'), knee = P('knee'), foot = P('foot'), el = P('el'), hand = P('hand');
const body = mix('#0a0d20', '#2b1b28', lit), legs = mix('#0a0d20', '#3b2733', lit);
const breath = k > 0.9 ? Math.sin(t * 1.7) * 2.4 : Math.sin(t * 2.3) * 1.2;
return (
<svg viewBox={`0 0 ${GW} ${GH}`} width={GW} height={GH} style={{ position: 'absolute', left: 0, top: 0, display: 'block' }}>
<g transform={L.tf}>
<g transform={`translate(${HERO.x} ${HERO.y}) scale(0.1)`} clipPath="url(#roomclip)">
{/* the far arm, the legs, the back, the hood, the head */}
<path d={`M${sh[0] + 6} ${sh[1] - breath} L${el[0] + 16} ${el[1] + 8} L${hand[0] + 4} ${hand[1] + 12}`} stroke={mix('#090b1c', '#20141e', lit)} strokeWidth="17" strokeLinecap="round" strokeLinejoin="round" fill="none" />
<path d={`M${hip[0]} ${hip[1]} L${knee[0]} ${knee[1]} L${foot[0]} ${foot[1]} L${foot[0] + 26 * (1 - k)} ${foot[1] + 2}`} stroke={legs} strokeWidth="27" strokeLinecap="round" strokeLinejoin="round" fill="none" />
<path d={`M${hip[0]} ${hip[1]} Q${lerp(sh[0] - 24, sh[0], k)} ${lerp((hip[1] + sh[1]) / 2, sh[1], k)} ${sh[0]} ${sh[1] - breath}`} stroke={body} strokeWidth="50" strokeLinecap="round" fill="none" />
<circle cx={lerp(hx - 12, hx + 6, k)} cy={lerp(hy + 6, hy + 2, k) - breath * 0.6} r={lerp(30, 26, k)} fill={mix('#0b0e20', '#3a2531', lit)} />
<circle cx={hx} cy={hy - breath * 0.6} r="24" fill={mix('#0a0d20', '#20141c', lit)} />
<path d={`M${sh[0]} ${sh[1] - breath} L${el[0]} ${el[1]} L${hand[0]} ${hand[1]}`} stroke={mix('#0c0f22', '#37222f', lit)} strokeWidth="20" strokeLinecap="round" strokeLinejoin="round" fill="none" />
{/* the phone, and what is on it: this */}
{ph > 0.02 ? (
<g opacity={ph}>
<circle cx={hand[0] + 8} cy={hand[1] - 16} r="96" fill="url(#phoneGlow)" />
<path d={`M${hx + 18} ${hy - 22} A27 27 0 0 1 ${hx + 18} ${hy + 22}`} stroke="#dbe9ff" strokeWidth="5" fill="none" strokeLinecap="round" />
<g transform={`rotate(-14 ${hand[0] + 8} ${hand[1] - 16})`}>
<rect x={hand[0] - 6} y={hand[1] - 40} width="28" height="48" rx="4" fill="#0b0d16" />
<rect x={hand[0] - 3} y={hand[1] - 37} width="22" height="42" rx="2" fill="#dbe9ff" />
<circle cx={hand[0] + 8} cy={hand[1] - 26} r="5" fill={RED} />
<rect x={hand[0] + 1} y={hand[1] - 14} width="14" height="3" fill="#6b7a94" /><rect x={hand[0] + 1} y={hand[1] - 8} width="10" height="3" fill="#6b7a94" />
</g>
</g>
) : null}
{/* the blanket, pulled up as they lie down */}
<path d="M206 246 C214 214 300 208 400 212 C462 214 502 222 508 246 L508 288 L206 288 Z" fill={mix('#10142c', '#2f4a78', lit)} opacity={ramp(k, 0.55, 1)} />
<path d="M206 246 C214 214 300 208 400 212" fill="none" stroke={mix('#141832', '#5474a8', lit)} strokeWidth="7" strokeLinecap="round" opacity={ramp(k, 0.55, 1)} />
</g>
</g>
</svg>
);
};
/** The worries are shadows on the wall of the room, huge at three, tiny by "smaller". HTML, because they scale every frame. */
const Worries: React.FC = () => {
const t = useT();
const L = lens(t);
if (L.k < 0.3 || t < E('isn') - 0.1 || t >= E('quiet') + 0.9) return null;
const s = L.z * 0.1;
return (
<>
{WORRIES.map((w, i) => {
const at = E(w.at);
if (t < at) return null;
const inK = easeOut(ramp(t, at, at + 0.35));
const small = easeInOut(ramp(t, E('smaller') - 0.1 + i * 0.06, E('smaller') + 0.55 + i * 0.06));
const size = fit(w.s, 520, w.size) * s;
return (
<div key={w.s} style={{ position: 'absolute', left: roomX(L, w.u), top: roomY(L, w.v), width: 0, height: 0, opacity: inK * ramp(L.k, 0.3, 0.7) }}>
<div style={{ position: 'absolute', left: 0, top: 0, whiteSpace: 'nowrap', font: `800 ${size}px/1 ${DISP}`, color: 'rgba(92,34,8,.7)', letterSpacing: '.02em', transform: `translate(-50%,-50%) rotate(${w.rot}deg) scale(${lerp(lerp(0.82, 1, inK) * (1 + 0.012 * Math.sin(t * 1.8 + i)), 0.17, small)})` }}>{w.s}</div>
</div>
);
})}
</>
);
};
const Glass: React.FC = () => {
const t = useT();
const air = signAir(t), d = dayAt(t);
return (
<div style={{ position: 'absolute', left: GX, top: GY, width: GW, height: GH, overflow: 'hidden', background: '#05071a', borderRadius: 4 }}>
<City />
<Worries />
<Figure />
{/* the glass: the sign's reflection, a sheen, and the dark of the frame */}
<div style={{ position: 'absolute', left: 24, top: -14, width: 560, height: 96, background: `rgba(255,59,48,${0.3 * air})`, filter: 'blur(26px)', mixBlendMode: 'screen' }} />
<div style={{ position: 'absolute', inset: 0, background: `linear-gradient(118deg,rgba(255,255,255,${0.07 - 0.03 * d}) 0%,rgba(255,255,255,0) 26%,rgba(255,255,255,${0.04 - 0.02 * d}) 34%,rgba(255,255,255,0) 44%)` }} />
<div style={{ position: 'absolute', inset: 0, boxShadow: 'inset 0 0 46px rgba(0,0,0,.55), inset 0 10px 18px rgba(0,0,0,.5)' }} />
</div>
);
};
/* ---------- the room around the glass ---------- */
const Screw: React.FC<{ x: number; y: number }> = ({ x, y }) => <div style={{ position: 'absolute', left: x, top: y, width: 14, height: 14, borderRadius: '50%', background: 'radial-gradient(circle at 35% 30%,#d8dbde,#6d7277 70%)', boxShadow: '0 1px 2px rgba(0,0,0,.6)' }}><div style={{ position: 'absolute', left: 2, top: 6, width: 10, height: 2, background: '#3b3e42', transform: 'rotate(35deg)' }} /></div>;
/** The station ident: a glint runs across the name plate, the mast fires, the IDENT lamp lights. */
const Ident: React.FC = () => {
const t = useT();
const u = t - E('producer');
if (u < -0.1 || u > 0.9) return null;
return (
<div style={{ position: 'absolute', left: 60, top: 118, width: 640, height: 122, borderRadius: 8, overflow: 'hidden', mixBlendMode: 'screen' }}>
<div style={{ position: 'absolute', left: lerp(-200, 720, ramp(u, -0.1, 0.8)), top: -30, width: 150, height: 190, transform: 'skewX(-22deg)', background: 'linear-gradient(90deg,rgba(255,220,170,0),rgba(255,220,170,.9),rgba(255,220,170,0))' }} />
</div>
);
};
const WallAndDesk: React.FC = () => (
<>
<div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 900, background: '#2a2522', backgroundImage: 'repeating-conic-gradient(from 45deg,#39312b 0 25%,#262120 0 50%)', backgroundSize: '64px 64px' }} />
{/* the conduit across the wall */}
<div style={{ position: 'absolute', left: 0, top: 54, width: 1080, height: 16, background: 'linear-gradient(#8d9196,#55595e 60%,#3a3d41)', boxShadow: '0 5px 8px rgba(0,0,0,.4)' }} />
{[150, 560, 960].map((x) => <div key={x} style={{ position: 'absolute', left: x, top: 46, width: 26, height: 32, borderRadius: 3, background: 'linear-gradient(#a7acb2,#6c7177)' }} />)}
{/* the show's name plate: above the safe area, so nothing here has to be read */}
<div style={{ position: 'absolute', left: 60, top: 118, width: 640, height: 122, borderRadius: 8, background: 'linear-gradient(#c5c9ce,#8d9196)', boxShadow: '0 8px 16px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.4)', textAlign: 'center' }}>
<div style={{ font: `800 ${fit(SHOW, 580, 74)}px/78px ${DISP}`, color: '#1d1b19', letterSpacing: '.06em', marginTop: 4 }}>{SHOW}</div>
<div style={{ font: `700 ${fit(SHOW_LINE, 580, 28)}px/30px ${DISP}`, color: '#3a3733', letterSpacing: '.08em' }}>{SHOW_LINE}</div>
<Screw x={10} y={10} /><Screw x={616} y={10} />
</div>
{/* the window's frame and the walnut sill */}
<div style={{ position: 'absolute', left: 30, top: 404, width: 1020, height: 468, borderRadius: 10, background: 'linear-gradient(#4a4a50,#232327)', boxShadow: '0 14px 30px rgba(0,0,0,.6)' }} />
<div style={{ position: 'absolute', left: 0, top: 898, width: 1080, height: 1022, background: 'repeating-linear-gradient(90deg,#b9bdc2 0 3px,#adb1b6 3px 5px,#b4b8bd 5px 9px)' }} />
<div style={{ position: 'absolute', left: 0, top: 866, width: 1080, height: 36, background: `linear-gradient(${WALNUT[0]},${WALNUT[1]} 60%,${WALNUT[2]})`, boxShadow: '0 8px 14px rgba(0,0,0,.5)' }} />
{/* knobs down the right edge, where the platform's buttons sit */}
{[960, 1090, 1220, 1350].map((y, i) => <div key={y} style={{ position: 'absolute', left: 980, top: y, width: 72, height: 72, borderRadius: '50%', background: 'radial-gradient(circle at 40% 32%,#6a6e73,#26282b 72%)', boxShadow: '0 6px 10px rgba(0,0,0,.5)' }}><div style={{ position: 'absolute', left: 34, top: 6, width: 4, height: 22, background: CREAM, transformOrigin: '2px 30px', transform: `rotate(${-50 + hash(i + 8) * 110}deg)` }} /></div>)}
{/* the walnut arm rest under the safe area */}
<div style={{ position: 'absolute', left: 0, top: 1484, width: 1080, height: 26, background: `linear-gradient(${WALNUT[0]},${WALNUT[1]} 60%,${WALNUT[2]})`, boxShadow: '0 6px 10px rgba(0,0,0,.45)' }} />
</>
);
/** Below the safe area: the channel strips. Texture, and a place for the small jokes. The REGRET fader falls on "smaller". COFFEE never moves. */
const stripLevel = (i: number, t: number) => {
const down = 1 - easeInOut(ramp(t, FADER_T0, FADER_T1)) * (1 - easeOut(ramp(t, E('back'), E('back') + 0.8)));
if (i === 0) return 0.08 + 0.72 * down;
if (i === 1) return 0.05 + 0.6 * (t >= E('back') ? 0.2 : down);
if (i === 2) return 0.08 + 0.4 * down;
if (i === 3) return lerp(0.97, 0.04, easeInOut(ramp(t, E('smaller') - 0.1, E('smaller') + 0.6)));
return 0.97;
};
const stripSignal = (i: number, t: number) => {
if (i === 0) return hostLevel(t);
if (i === 1) return callerLevel(t) * 1.4;
if (i === 2) { const u = t - E('producer'); return u >= 0 && u < 0.9 ? 0.9 * (1 - u / 0.9) : 0; }
if (i === 3) return (0.42 + 0.2 * hash(Math.floor(t * 5))) * (1 - ramp(t, E('smaller'), E('smaller') + 0.5));
return 0.55;
};
const Strips: React.FC = () => {
const t = useT();
return (
<>
{STRIPS.map((s, i) => {
const x = 36 + i * 188, top = 1534, H = 240;
return (
<div key={s} style={{ position: 'absolute', left: x, top: 0, width: 180, height: 1920 }}>
<div style={{ position: 'absolute', left: 46, top, width: 16, height: H, background: '#1d1d1d', borderRadius: 8, boxShadow: 'inset 0 2px 4px #000' }} />
{Array.from({ length: 9 }, (_, k) => <div key={k} style={{ position: 'absolute', left: 74, top: top + 8 + k * 28, width: 16, height: 2, background: '#3a3d40' }} />)}
<div style={{ position: 'absolute', left: 14, top: top - 18 + (1 - stripLevel(i, t)) * H, width: 80, height: 44, borderRadius: 6, background: 'linear-gradient(#4a4e54,#26292d)', boxShadow: '0 7px 11px rgba(0,0,0,.55)' }}><div style={{ position: 'absolute', left: 8, right: 8, top: 20, height: 4, background: CREAM }} /></div>
<div style={{ position: 'absolute', left: 4, top: top + H + 22, width: 170, height: 62, background: PAPER, transform: `rotate(${-1 + hash(i + 4) * 2}deg)`, font: `700 46px/62px ${HAND}`, color: '#1d1d1d', textAlign: 'center', boxShadow: '0 3px 5px rgba(0,0,0,.35)' }}>{s}</div>
</div>
);
})}
</>
);
};
const StripLeds: React.FC = () => {
const t = useT();
return (
<>
{STRIPS.map((s, i) => {
const x = 36 + i * 188, top = 1534;
const n = Math.round(clamp(stripSignal(i, t)) * 9);
return Array.from({ length: 9 }, (_, k) => {
const c = k >= 7 ? RED : k >= 5 ? AMBER : GREEN, on = k < n;
return <div key={`${s}${k}`} style={{ position: 'absolute', left: x + 110, top: top + 214 - k * 26, width: 30, height: 17, borderRadius: 3, background: on ? c : '#2f3235', boxShadow: on ? `0 0 8px ${c}` : 'none' }} />;
});
})}
</>
);
};
/* The sign shares one word, AIR, between two lamps: ON lights up the first half, DEAD lights up the other, and nobody has to explain it. */
const SignPanel: React.FC<{ x: number; y: number; w: number; h: number; v: number; size: number; text: string; track?: string }> = ({ x, y, w, h, v, size, text, track = '.05em' }) => (
<div style={{ position: 'absolute', left: x, top: y, width: w, height: h, borderRadius: 7, background: mix('#3a100c', RED, clamp(v)), color: mix('#74302a', '#fff3f0', clamp(v)), textAlign: 'center', font: `800 ${size}px/${h + 6}px ${DISP}`, letterSpacing: track, textShadow: v > 0.3 ? `0 0 ${18 * v}px rgba(255,235,225,${0.75 * v})` : 'none', boxShadow: v > 0.02 ? `0 0 ${16 + 40 * v}px rgba(255,59,48,${0.72 * v}), inset 0 0 ${28 * v}px rgba(255,255,255,${0.36 * v})` : 'inset 0 0 16px rgba(0,0,0,.65)' }}>{text}</div>
);
const Sign: React.FC = () => {
const t = useT();
return (
<div style={{ position: 'absolute', left: 60, top: 256, width: 640, height: 138, borderRadius: 16, background: '#16120f', boxShadow: '0 12px 22px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.1)' }}>
<SignPanel x={10} y={10} w={226} h={56} v={signOn(t)} size={58} text="ON" />
<SignPanel x={10} y={72} w={226} h={56} v={signDead(t)} size={58} text="DEAD" />
<SignPanel x={244} y={10} w={386} h={118} v={signAir(t)} size={132} text="AIR" track=".12em" />
</div>
);
};
/* The studio clock: a ring of sixty lights and four digits. Drawn, not typed, so it looks like hardware. */
const SEGS: Record<string, string> = { '0': 'abcdef', '1': 'bc', '2': 'abged', '3': 'abgcd', '4': 'fgbc', '5': 'afgcd', '6': 'afgedc', '7': 'abc', '8': 'abcdefg', '9': 'abcdfg', '-': 'g' };
const segPoly = (w: number, h: number, s: number) => {
const g = s * 0.14, m = h / 2;
const hz = (y: number) => `${g} ${y} ${g + s / 2} ${y - s / 2} ${w - g - s / 2} ${y - s / 2} ${w - g} ${y} ${w - g - s / 2} ${y + s / 2} ${g + s / 2} ${y + s / 2}`;
const vt = (x: number, a: number, b: number) => `${x} ${a} ${x + s / 2} ${a + s / 2} ${x + s / 2} ${b - s / 2} ${x} ${b} ${x - s / 2} ${b - s / 2} ${x - s / 2} ${a + s / 2}`;
return { a: hz(s / 2), g: hz(m), d: hz(h - s / 2), f: vt(s / 2, s / 2 + g, m - g), b: vt(w - s / 2, s / 2 + g, m - g), e: vt(s / 2, m + g, h - s / 2 - g), c: vt(w - s / 2, m + g, h - s / 2 - g) } as Record<string, string>;
};
const DW = 46, DH = 80, DS = 10, DIGIT = segPoly(DW, DH, DS);
const Digit: React.FC<{ ch: string; x: number; y: number; lit: number }> = ({ ch, x, y, lit }) => (
<g transform={`translate(${x} ${y})`}>
{Object.entries(DIGIT).map(([k, p]) => <polygon key={k} points={p} fill={(SEGS[ch] ?? '').includes(k) ? AMBER : '#3a2a0e'} opacity={(SEGS[ch] ?? '').includes(k) ? lit : 0.5} />)}
</g>
);
const RingClock: React.FC = () => {
const t = useT();
const m = minutes(t), wake = t < 0.28;
const hh = String(Math.floor(m / 60)).padStart(2, '0'), mm = String(Math.floor(m) % 60).padStart(2, '0');
const running = t >= E('eight') && t < E('back8');
const sec = t >= E('back8') ? Math.floor(t - E('back8')) % 60 : running ? Math.floor((m % 1) * 60) : Math.floor(t) % 60;
const lit = 1 - 0.35 * darkAt(t);
const R = 150;
return (
<div style={{ position: 'absolute', left: 730, top: 236, width: 300, height: 300 }}>
<svg viewBox="0 0 300 300" width={300} height={300} style={{ position: 'absolute', left: 0, top: 0, filter: 'drop-shadow(0 12px 16px rgba(0,0,0,.6))' }}>
<circle cx={R} cy={R} r={148} fill="#141210" stroke="#4a443e" strokeWidth="4" />
<circle cx={R} cy={R} r={120} fill="#0d0c0b" />
{Array.from({ length: 60 }, (_, i) => { const a = (i / 60) * Math.PI * 2, on = i <= sec; return <circle key={i} cx={R + Math.sin(a) * 133} cy={R - Math.cos(a) * 133} r={i % 5 === 0 ? 5 : 3.6} fill={on ? AMBER : '#3a2a0e'} opacity={on ? lit : 0.7} />; })}
<g style={{ filter: `drop-shadow(0 0 7px rgba(255,176,0,${0.65 * lit}))` }}>
<Digit ch={wake ? '-' : hh[0]} x={36} y={100} lit={lit} /><Digit ch={wake ? '-' : hh[1]} x={90} y={100} lit={lit} />
<Digit ch={wake ? '-' : mm[0]} x={164} y={100} lit={lit} /><Digit ch={wake ? '-' : mm[1]} x={218} y={100} lit={lit} />
{[122, 158].map((y) => <rect key={y} x={144} y={y} width={10} height={10} fill={AMBER} opacity={(running || Math.floor(t * 2) % 2 === 0 ? 1 : 0.2) * lit} />)}
</g>
</svg>
<div style={{ position: 'absolute', left: 0, right: 0, top: 190, textAlign: 'center', font: `800 34px/1 ${DISP}`, color: AMBER, opacity: 0.85 * lit, letterSpacing: '.12em' }}>{Math.floor(m / 60) < 12 ? 'AM' : 'PM'}</div>
</div>
);
};
const lumAt = (t: number) => pw(t, [[0, 0.5], [0.4, 0.62], [E('low'), 0.62], [E('low') + 0.7, 0.26], [E('you'), 0.26], [E('you') + 1.2, 0.46], [E('decision'), 0.46], [E('decision') + 0.5, 0.56], [E('quiet'), 0.56], [E('quiet') + 3.4, 0.8], [E('back8'), 1]]);
const studioTint = (t: number) => {
const v = lumAt(t);
const warm: RGB = [255, 244, 228], cold: RGB = [176, 150, 164];
const m = clamp(v);
return css([lerp(cold[0], warm[0], m) * v, lerp(cold[1], warm[1], m) * v, lerp(cold[2], warm[2], m) * v]);
};
/** The mic on its arm, in front of the glass. There is nobody at it, on purpose: the voice is the only presenter. */
const Mic: React.FC = () => {
const t = useT();
const d = dayAt(t), air = signAir(t);
const body = tod(d, '#141216', '#2a2830', '#4a4a52');
const hl = mix('#3c3a44', '#ff6a5a', air * 0.45 * (1 - d));
return (
<svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, pointerEvents: 'none' }}>
<path d="M1090 545 L934 566 L906 600" fill="none" stroke={body} strokeWidth="16" strokeLinejoin="round" />
<path d="M1090 560 C1010 590 960 590 916 640" fill="none" stroke="#0b0a0c" strokeWidth="5" />
<ellipse cx="872" cy="660" rx="66" ry="17" fill="none" stroke={body} strokeWidth="8" />
<ellipse cx="872" cy="736" rx="66" ry="17" fill="none" stroke={body} strokeWidth="8" />
<path d="M938 660 L938 736 M806 660 L806 736" stroke={body} strokeWidth="6" />
<path d="M906 600 L906 650" stroke={body} strokeWidth="12" />
<rect x="828" y="596" width="88" height="202" rx="40" fill={body} />
<rect x="836" y="606" width="72" height="104" rx="32" fill={mix('#1d1b20', '#5c5a62', d)} />
{Array.from({ length: 9 }, (_, i) => <rect key={i} x="840" y={616 + i * 10} width="64" height="2.4" fill="#0b0a0c" opacity="0.8" />)}
<rect x="828" y="716" width="88" height="7" fill={hl} />
<path d="M838 636 C836 668 838 700 840 770" fill="none" stroke={hl} strokeWidth="3.4" strokeLinecap="round" opacity="0.9" />
</svg>
);
};
const Mug: React.FC = () => {
const t = useT();
const lum = lumAt(t);
return (
<>
<svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, pointerEvents: 'none' }}>
{[0, 1, 2].map((i) => {
const b = 986 + i * 18, s = t * 1.3 + i * 2.1, a = 7 + i * 2;
const pts = Array.from({ length: 9 }, (_, k) => `${(b + Math.sin(s + k * 0.8) * a * (0.5 + k * 0.12)).toFixed(1)} ${790 - k * 18}`);
return <path key={i} d={`M${pts.join(' L')}`} fill="none" stroke="#efe6d8" strokeWidth={5 + i} strokeLinecap="round" opacity={0.16 + 0.07 * Math.sin(s)} />;
})}
</svg>
<div style={{ position: 'absolute', left: 962, top: 798, width: 82, height: 74, borderRadius: '6px 6px 16px 16px', background: 'linear-gradient(90deg,#d8cbb0,#f0e6d0 40%,#b8ab92)', filter: `brightness(${0.42 + 0.58 * lum})`, boxShadow: '0 6px 8px rgba(0,0,0,.45)' }}>
<div style={{ position: 'absolute', left: -22, top: 14, width: 26, height: 36, borderRadius: '50%', border: '8px solid #cdc0a6', boxSizing: 'border-box' }} />
<div style={{ position: 'absolute', left: 0, right: 0, top: 26, height: 20, background: PENCIL, opacity: 0.85 }} />
</div>
</>
);
};
const Sticky: React.FC = () => {
const t = useT();
const at = E('more');
if (t < at - 0.05 || t >= E('back')) return null;
const k = back(ramp(t, at - 0.05, at + 0.14));
const drop = easeIn(ramp(t, E('quiet') + 1.2, E('quiet') + 2.2));
return (
<div style={{ position: 'absolute', left: 742, top: 792, width: 188, height: 96, background: 'linear-gradient(170deg,#ffe07a,#ffcb4a)', transform: `translateY(${drop * 40}px) rotate(${3 + drop * 8}deg) scale(${lerp(1.3, 1, k)})`, transformOrigin: '50% 0', boxShadow: '0 8px 12px rgba(0,0,0,.45)', filter: `brightness(${0.5 + 0.5 * lumAt(t)})`, opacity: 1 - ramp(drop, 0.6, 1) }}>
<div style={{ position: 'absolute', left: 0, right: 0, top: 6, textAlign: 'center', font: `700 ${fit(STICKY, 170, 58, 0.36)}px/56px ${HAND}`, color: INK }}>{STICKY}</div>
<svg viewBox="0 0 188 96" width={188} height={96} style={{ position: 'absolute', left: 0, top: 0 }}><path d="M94 60 C92 70 96 74 94 86 M84 76 L94 88 L104 76" fill="none" stroke={PENCIL} strokeWidth="5" strokeLinecap="round" strokeLinejoin="round" /></svg>
</div>
);
};
/* ---------- the phone lines ---------- */
const LPX = 664, LPY = 906, LPW = 286, LPH = 206, ROW = 47;
const rowY = (n: number) => LPY + 10 + (n - 1) * ROW;
const LinePanel: React.FC = () => {
const t = useT();
return (
<div style={{ position: 'absolute', left: LPX, top: LPY, width: LPW, height: LPH, borderRadius: 10, background: 'linear-gradient(#232226,#141316)', boxShadow: '0 10px 18px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.08)' }}>
{[1, 2, 3, LAST_LINE].map((n) => {
const s = lineLamp(n, t), y = rowY(n) - LPY;
return (
<div key={n} style={{ position: 'absolute', left: 8, top: y, width: LPW - 16, height: ROW - 4, borderRadius: 6, background: s.active ? 'rgba(255,255,255,.07)' : 'transparent' }}>
<div style={{ position: 'absolute', left: 6, top: 5, width: 56, height: 33, borderRadius: 5, background: s.v > 0 ? mix('#2b2e31', s.c, s.v) : '#2b2e31', boxShadow: s.v > 0.4 ? `0 0 ${18 * s.v}px ${s.c}, inset 0 2px 3px rgba(255,255,255,.4)` : 'inset 0 2px 5px rgba(0,0,0,.8)' }} />
<div style={{ position: 'absolute', left: 74, top: 0, font: `800 40px/44px ${DISP}`, color: s.v > 0 ? CREAM : '#77736b', letterSpacing: '.03em', whiteSpace: 'nowrap' }}>LINE {n}</div>
<div style={{ position: 'absolute', right: 8, top: 0, font: `800 32px/44px ${DISP}`, color: s.v > 0 ? s.c : 'transparent', opacity: s.word === 'RING' ? Math.max(0.35, s.v) : 1, letterSpacing: '.04em' }}>{s.word}</div>
</div>
);
})}
</div>
);
};
/** The cord from the last line's lamp to the last lit window. It only exists once we know who is on the line. */
const Wire: React.FC = () => {
const t = useT();
if (t < E('turn') || t >= LAST_OFF + 0.05) return null;
const L = lens(t);
const ex = GX + roomX(L, 300), ey = GY + roomY(L, 340);
const d = `M${LPX + 14} ${rowY(LAST_LINE) + 21} L657 ${rowY(LAST_LINE) + 21} L657 900 C657 868 ${ex + 40} ${ey + 50} ${ex} ${ey}`;
const k = easeOut(ramp(t, E('turn'), E('thats')));
return (
<svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, pointerEvents: 'none' }}>
<path d={d} fill="none" stroke="rgba(0,0,0,.5)" strokeWidth="10" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - k} strokeLinecap="round" />
<path d={d} fill="none" stroke={AMBER} strokeWidth="5" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - k} strokeLinecap="round" style={{ filter: 'drop-shadow(0 0 7px rgba(255,176,0,.85))' }} />
{k >= 1 ? <path d={d} fill="none" stroke="#fff3d0" strokeWidth="5" pathLength={1} strokeDasharray="0.012 0.09" strokeDashoffset={t * 0.28} strokeLinecap="round" /> : null}
</svg>
);
};
/* ---------- the meters ---------- */
const VU_POINTS = Array.from({ length: 21 }, (_, i) => -42 + i * 4);
const VU_MARKS: [string, number][] = [['-20', -36], ['-10', -20], ['-5', -6], ['0', 14], ['+3', 32]];
const polar = (a: number, r: number): [number, number] => [156 + Math.sin((a * Math.PI) / 180) * r, 206 - Math.cos((a * Math.PI) / 180) * r];
const VU: React.FC<{ x: number; label: string; angle: number; lamp: number }> = ({ x, label, angle, lamp }) => (
<div style={{ position: 'absolute', left: x, top: 1124, width: 332, height: 206, borderRadius: 14, background: '#262626', boxShadow: '0 10px 18px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.1)' }}>
<div style={{ position: 'absolute', left: 10, top: 10, width: 312, height: 186, borderRadius: 8, overflow: 'hidden', background: 'radial-gradient(ellipse at 50% 90%,#fff3cf,#f1e2b4 60%,#e0cd98)', filter: `brightness(${lamp})` }}>
<svg viewBox="0 0 312 186" width={312} height={186} style={{ position: 'absolute', left: 0, top: 0 }}>
<path d={(() => { const [a, b] = polar(14, 158), [c, e] = polar(40, 158); return `M${a} ${b} A158 158 0 0 1 ${c} ${e}`; })()} fill="none" stroke="#c21d12" strokeWidth="9" />
{VU_POINTS.map((a) => { const [x1, y1] = polar(a, 158), [x2, y2] = polar(a, a % 8 === 0 ? 142 : 150); return <line key={a} x1={x1} y1={y1} x2={x2} y2={y2} stroke={a >= 14 ? '#c21d12' : INK} strokeWidth={a % 8 === 0 ? 3.4 : 1.8} />; })}
</svg>
{VU_MARKS.map(([s, a]) => { const [px, py] = polar(a, 172); return <div key={s} style={{ position: 'absolute', left: px - 24, top: py - 11, width: 48, textAlign: 'center', font: `800 22px/22px ${DISP}`, color: a >= 14 ? '#c21d12' : INK }}>{s}</div>; })}
<div style={{ position: 'absolute', left: 0, right: 0, top: 78, textAlign: 'center', font: `800 44px/44px ${DISP}`, color: INK }}>VU</div>
<div style={{ position: 'absolute', left: 0, right: 0, top: 122, textAlign: 'center', font: `700 25px/25px ${DISP}`, color: '#5a4a3a', letterSpacing: '.14em' }}>{label}</div>
<div style={{ position: 'absolute', left: 154, top: 34, width: 4, height: 172, background: '#141414', transformOrigin: '2px 172px', transform: `rotate(${angle}deg)`, boxShadow: '3px 2px 3px rgba(40,20,0,.35)' }} />
<div style={{ position: 'absolute', left: 96, top: 158, width: 120, height: 60, borderRadius: '60px 60px 0 0', background: 'linear-gradient(#2c2a28,#111)' }} />
</div>
</div>
);
const Meters: React.FC = () => {
const t = useT();
const lamp = pw(t, [[0, 0.7], [0.4, 1], [E('low'), 1], [E('low') + 0.5, 0.42], [E('you'), 0.42], [E('you') + 1, 0.82], [E('quiet') + 1, 0.82], [E('quiet') + 3, 0.72], [E('back8'), 1]]);
const drop = t >= E('low') + 0.18 && t < E('low') + 0.9 ? 2.4 * Math.sin((t - E('low') - 0.18) * 30) * Math.exp(-(t - E('low') - 0.18) * 8) : 0;
const hiss = darkAt(t) > 0.5 ? Math.sin(t * 71) * 0.5 : 0;
return (
<>
<VU x={60} label={METERS[0]} angle={needle(hostLevel(t)) + Math.abs(drop)} lamp={lamp} />
<VU x={618} label={METERS[1]} angle={needle(callerLevel(t)) + Math.abs(drop) * 0.8 + Math.abs(hiss)} lamp={lamp} />
</>
);
};
/* The master fader sits in the safe area, because it has to be seen falling. */
const masterAt = (t: number) => {
const down = lerp(0.9, 0.04, easeInOut(ramp(t, FADER_T0, FADER_T1)));
return lerp(down, 0.9, easeOut(ramp(t, E('back'), E('back') + 0.7)));
};
const MasterBody: React.FC = () => (
<div style={{ position: 'absolute', left: 406, top: 1124, width: 198, height: 206, borderRadius: 10, background: 'linear-gradient(#a3a7ac,#878b90)', boxShadow: 'inset 0 1px 0 rgba(255,255,255,.35), 0 6px 10px rgba(0,0,0,.35)' }}>
<div style={{ position: 'absolute', left: 46, top: 18, width: 14, height: 170, background: '#1d1d1d', borderRadius: 7, boxShadow: 'inset 0 2px 4px #000' }} />
{Array.from({ length: 7 }, (_, k) => <div key={k} style={{ position: 'absolute', left: 14, top: 22 + k * 27, width: 22, height: 2, background: '#35383b' }} />)}
<Screw x={176} y={6} /><Screw x={176} y={186} />
</div>
);
const MasterLive: React.FC = () => {
const t = useT();
const talk = t >= E('talk') && t < E('low') ? (Math.floor(t * 5) % 2 === 0 ? 1 : 0.15) : 0;
const u = t - E('producer');
const cart = u >= -0.1 && u < 0.8 ? 1 - u / 0.9 : 0;
const Lamp: React.FC<{ y: number; c: string; v: number; s: string }> = ({ y, c, v, s }) => (
<>
<div style={{ position: 'absolute', left: 522, top: y, width: 44, height: 44, borderRadius: '50%', background: v > 0.02 ? mix('#3a3d40', c, v) : '#3a3d40', boxShadow: `inset 0 -3px 6px rgba(0,0,0,.45), 0 0 0 4px #5b5f63${v > 0.2 ? `, 0 0 ${22 * v}px ${c}` : ''}` }} />
<div style={{ position: 'absolute', left: 490, top: y + 48, width: 108, textAlign: 'center', font: `800 25px/26px ${DISP}`, color: '#1d1f22', letterSpacing: '.08em' }}>{s}</div>
</>
);
return (
<>
<div style={{ position: 'absolute', left: 416, top: 1124 + 18 + (1 - masterAt(t)) * 150, width: 86, height: 40, borderRadius: 6, background: 'linear-gradient(#55585d,#26292d)', boxShadow: '0 8px 12px rgba(0,0,0,.55)' }}><div style={{ position: 'absolute', left: 8, right: 8, top: 18, height: 4, background: CREAM }} /></div>
<div style={{ filter: `brightness(${0.55 + 0.45 * lumAt(t)})` }}>
<Lamp y={1136} c={AMBER} v={talk} s="TALK" />
<Lamp y={1228} c={RED} v={clamp(cart)} s="IDENT" />
</div>
</>
);
};
/* ---------- the caller cards ---------- */
const CX = 60, CY = 906, CW = 590, CH = 206;
/** Handwriting wipes on from the left. Once the wipe ends the clip opens past the box, or a letter's overhang stays cut off. */
const Scrawl: React.FC<{ at: number; d?: number; style: React.CSSProperties; children: React.ReactNode }> = ({ at, d = 0.34, style, children }) => {
const t = useT();
if (t < at) return null;
const k = ramp(t, at, at + d);
return <div style={{ position: 'absolute', whiteSpace: 'nowrap', clipPath: `inset(-20% ${k >= 1 ? '-20%' : `${(1 - k) * 100}%`} -20% -5%)`, ...style }}>{children}</div>;
};
const Pencil: React.FC<{ d: string; at: number; dur?: number; w?: number }> = ({ d, at, dur = 0.3, w = 4.5 }) => {
const t = useT();
if (t < at) return null;
return <path d={d} fill="none" stroke={PENCIL} strokeWidth={w} strokeLinecap="round" strokeLinejoin="round" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - ramp(t, at, at + dur)} />;
};
const Doodle: React.FC<{ kind: string; at: number }> = ({ kind, at }) => (
<svg viewBox="0 0 110 110" width={110} height={110} style={{ position: 'absolute', right: 16, top: 66, overflow: 'visible' }}>
{kind === 'mail' ? <><Pencil d="M8 28 L102 28 L102 92 L8 92 Z" at={at} /><Pencil d="M8 28 L55 64 L102 28" at={at + 0.2} /><Pencil d="M46 8 C52 -4 72 0 66 12 C63 17 56 18 56 24" at={at + 0.4} dur={0.2} w={4} /></> : null}
{kind === 'talk' ? <><Pencil d="M8 20 L72 20 L72 62 L34 62 L18 78 L22 62 L8 62 Z" at={at} /><Pencil d="M44 44 L104 44 L104 84 L96 84 L98 100 L80 84 L44 84 Z" at={at + 0.22} /></> : null}
{kind === 'eye' ? <><Pencil d="M6 54 C30 16 80 16 104 54 C80 92 30 92 6 54 Z" at={at} /><Pencil d="M55 34 A20 20 0 1 1 54.9 34 Z" at={at + 0.2} /><Pencil d="M16 92 C40 106 70 106 94 92" at={at + 0.34} dur={0.2} /><Pencil d="M24 104 C44 114 66 114 86 104" at={at + 0.44} dur={0.2} /></> : null}
</svg>
);
const Sheet: React.FC<{ at: number; seed: number; until?: number; children: React.ReactNode }> = ({ at, seed, until = 1e9, children }) => {
const t = useT();
if (t < at - 0.02 || t >= until) return null;
const k = back(ramp(t, at - 0.02, at + 0.18));
const dx = (hash(seed) - 0.5) * 14, dy = (hash(seed + 1) - 0.5) * 7, rot = (hash(seed + 2) - 0.5) * 2.2;
return (
<div style={{ position: 'absolute', left: CX + dx, top: CY + dy, width: CW, height: CH, background: `linear-gradient(transparent 62px,${PENCIL}66 62px,${PENCIL}66 64px,transparent 64px),${PAPER}`, transform: `translateY(${(1 - k) * -64}px) rotate(${rot + (1 - k) * 3}deg) scale(${lerp(1.08, 1, k)})`, boxShadow: '0 10px 18px rgba(0,0,0,.5)', borderRadius: 3 }}>
{children}
</div>
);
};
const Head: React.FC<{ left: string; right?: string }> = ({ left, right = CARD_LABEL }) => (
<>
<div style={{ position: 'absolute', left: 22, top: 8, font: `800 46px/52px ${DISP}`, color: INK, letterSpacing: '.03em' }}>{left}</div>
<div style={{ position: 'absolute', right: 20, top: 16, font: `700 28px/36px ${DISP}`, color: '#8a7a66', letterSpacing: '.14em' }}>{right}</div>
</>
);
const Main: React.FC<{ s: string; w?: number; max?: number }> = ({ s, w = 430, max = 88 }) => <div style={{ position: 'absolute', left: 22, top: 66, height: 84, whiteSpace: 'nowrap', font: `800 ${fit(s, w, max)}px/84px ${DISP}`, color: INK, letterSpacing: '.01em' }}>{s}</div>;
const Note: React.FC<{ at: number; s: string; scale?: number }> = ({ at, s, scale = 1 }) => <Scrawl at={at} style={{ left: 26, top: 134, font: `700 ${fit(s, 440, 62, 0.36)}px/62px ${HAND}`, color: PENCIL, transform: `scale(${scale})`, transformOrigin: '0 50%' }}>{s}</Scrawl>;
const LastCard: React.FC = () => {
const t = useT();
if (t < E('low')) return null;
const typed = Math.max(0, Math.min(LAST_NAME.length, Math.ceil((t - E('you')) / 0.06)));
const cursor = t < E('you') && Math.floor(t * 3) % 2 === 0;
const smaller = lerp(lerp(1, 0.62, easeOut(ramp(t, E('decision'), E('decision') + 0.12))), 0.34, easeInOut(ramp(t, E('smaller') - 0.1, E('smaller') + 0.5)));
const u = t - E('decision');
const stampK = u < 0 ? 0 : easeIn(ramp(u, 0, 0.1));
const size = fit(`${LAST_LABEL} ${LAST_NAME}`, 548, 100, 0.42);
return (
<Sheet at={E('low')} seed={41} until={E('back8') + 0.25}>
<Head left={t < E('you') ? HOLD_LABEL : LIVE_LABEL} />
<div style={{ position: 'absolute', left: 22, top: 67, height: 92, whiteSpace: 'nowrap', font: `800 ${size}px/92px ${DISP}`, color: INK, letterSpacing: '.01em' }}>
{LAST_LABEL} <span style={{ color: RED }}>{LAST_NAME.slice(0, typed)}</span>
{cursor ? <span style={{ display: 'inline-block', width: size * 0.42, height: size * 0.72, marginLeft: 4, background: INK, verticalAlign: '-6%' }} /> : null}
</div>
<Note at={E('isn')} s={LAST_NOTE} scale={smaller} />
{u >= 0 ? <div style={{ position: 'absolute', right: 12, top: 138, padding: '2px 12px 0', whiteSpace: 'nowrap', font: `800 ${fit(STAMP, 330, 46, 0.42)}px/50px ${DISP}`, letterSpacing: '.03em', color: RED, border: `5px solid ${RED}`, borderRadius: 6, opacity: 0.88, transform: `rotate(-5deg) scale(${lerp(2.2, 1, stampK)})`, transformOrigin: '50% 50%' }}>{STAMP}</div> : null}
</Sheet>
);
};
const CtaCard: React.FC = () => (
<Sheet at={E('key') - 0.04} seed={67}>
<div style={{ position: 'absolute', left: 0, right: 0, top: 4, textAlign: 'center', font: `800 62px/62px ${DISP}`, color: INK, letterSpacing: '.16em', background: PAPER }}>COMMENT</div>
<div style={{ position: 'absolute', left: 0, right: 0, top: 62, height: 140, textAlign: 'center', font: `800 ${fit(KEYWORD, 520, 140)}px/140px ${DISP}`, color: RED, letterSpacing: '.08em', background: PAPER }}>{KEYWORD}</div>
</Sheet>
);
const Cards: React.FC = () => {
const t = useT();
const light = pw(t, [[0, 0.8], [0.4, 0.92], [E('low'), 0.92], [E('low') + 0.6, 0.56], [E('you'), 0.56], [E('you') + 0.8, 0.9], [E('quiet') + 1, 0.9], [E('quiet') + 3, 0.84], [E('back8'), 1]]);
return (
<div style={{ position: 'absolute', left: 0, top: 0, filter: `brightness(${light})` }}>
<Sheet at={0} seed={3}><Head left={OPEN_CARD.head} /><Main s={OPEN_CARD.main} /><Note at={E(OPEN_CARD.noteAt)} s={OPEN_CARD.note} /></Sheet>
{CALLS.map((c, i) => (
<Sheet key={c.line} at={E(c.card)} seed={13 + i * 9} until={E('back8') + 0.25}>
<Head left={`LINE ${c.line}`} />
<Main s={c.re} />
<Note at={E(c.noteAt)} s={c.note} />
<Doodle kind={c.doodle} at={E(c.card) + 0.25} />
</Sheet>
))}
<LastCard />
<Sheet at={E('back8')} seed={57}><Head left={MORNING_CARD.head} /><Main s={MORNING_CARD.main} /><Note at={E('nobody')} s={MORNING_CARD.note} /></Sheet>
<CtaCard />
</div>
);
};
/* ---------- light ---------- */
const MOTES = Array.from({ length: 46 }, (_, i) => ({ x: hash(i + 7) * 1080, y: 880 + hash(i + 17) * 640, r: 1.2 + hash(i + 27) * 2.4, s: 8 + hash(i + 37) * 18, p: hash(i + 47) * 6 }));
const LightFx: React.FC = () => {
const t = useT();
const air = signAir(t), dead = signDead(t), morn = morningAt(t);
return (
<>
{/* the sign tints the room, and on the low point its pulse is the only thing moving the light */}
<div style={{ position: 'absolute', inset: 0, background: `radial-gradient(ellipse 760px 520px at 380px 330px,rgba(255,59,48,${0.22 * air}),rgba(255,59,48,0) 100%)`, mixBlendMode: 'screen' }} />
{dead > 0 ? <div style={{ position: 'absolute', inset: 0, background: `linear-gradient(rgba(255,40,30,${0.13 * dead}),rgba(255,40,30,${0.07 * dead}) 60%,rgba(255,40,30,${0.03 * dead}))`, mixBlendMode: 'screen' }} /> : null}
{/* morning comes in through the glass and lies across the desk */}
{morn > 0 ? (
<svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, mixBlendMode: 'screen', opacity: morn }}>
<defs><linearGradient id="beam" x1="0" y1="0" x2="0.3" y2="1"><stop offset="0" stopColor="#ffe6b8" stopOpacity="0.42" /><stop offset="1" stopColor="#ffe6b8" stopOpacity="0" /></linearGradient></defs>
<polygon points="90,880 720,880 1120,1500 300,1500" fill="url(#beam)" style={{ filter: 'blur(22px)' }} />
{MOTES.map((m, i) => <circle key={i} cx={(m.x + Math.sin(t * 0.4 + m.p) * 22) % 1080} cy={m.y - ((t * m.s) % 500)} r={m.r} fill="#fff1cf" opacity={0.42} />)}
</svg>
) : null}
</>
);
};
/* The camera barely moves: a slow lean toward the console on the low point. The window does the real traveling. */
type View = { s: number; tx: number; ty: number };
const zoom = (s: number, fx: number, fy: number): View => ({ s, tx: fx * (1 - s), ty: fy * (1 - s) });
const KEYS: { at: number; d: number; v: View }[] = [
{ at: 0, d: 0.01, v: zoom(1.02, 540, 800) },
{ at: E('talk'), d: 2.0, v: zoom(1.012, 540, 800) },
{ at: E('low'), d: 3.2, v: zoom(1.026, 540, 800) },
{ at: E('you'), d: 1.2, v: zoom(1.012, 540, 800) },
{ at: E('quiet'), d: 3.2, v: zoom(1.02, 540, 700) },
{ at: E('back8'), d: 3.0, v: zoom(1.008, 540, 800) },
];
const camAt = (t: number): View => {
let v = KEYS[0].v;
for (const k of KEYS.slice(1)) {
if (t < k.at) break;
const q = easeInOut(ramp(t, k.at, k.at + k.d));
v = { s: lerp(v.s, k.v.s, q), tx: lerp(v.tx, k.v.tx, q), ty: lerp(v.ty, k.v.ty, q) };
}
const s = Math.max(1.004, v.s + 0.002 * Math.sin(t * 0.37));
const u = t - E('decision');
const knock = u >= 0 ? 5 * Math.sin(u * 52) * Math.exp(-u * 12) : 0;
return { s, tx: clamp(v.tx + 2 * Math.sin(t * 0.41), 1080 - 1080 * s, 0) + knock, ty: clamp(v.ty, 1920 - 1920 * s, 0) + knock * 0.5 };
};
/* ---------- captions: one fixed plate on the desk ---------- */
const PER_ROW = 30;
const clean = (w: string) => w.replace(/[.,;:!?"]/g, '').toUpperCase();
const QUIET_T0 = E('quiet') + 0.3;
const QUIET_WORD: Word = { w: QUIET_WORDS, t0: QUIET_T0, t1: E('back') - 0.1, scene: SCENES.find((s) => !s.words.length)?.id ?? '' };
const CHUNKS = [...buildChunks(PER_ROW, 2), { t0: QUIET_T0, rows: [{ s: QUIET_WORDS, map: Array.from(QUIET_WORDS, () => 0) }], words: [QUIET_WORD] }].sort((a, b) => a.t0 - b.t0);
const rowsOf = (words: Word[]) => {
const len = (a: Word[]) => a.map((w) => clean(w.w)).join(' ').length;
if (len(words) <= PER_ROW) return [words];
let best = 1, least = Infinity;
for (let i = 1; i < words.length; i++) {
const worst = Math.max(len(words.slice(0, i)), len(words.slice(i)));
if (worst < least) { least = worst; best = i; }
}
return [words.slice(0, best), words.slice(best)];
};
const Captions: React.FC = () => {
const t = useT();
const idx = CHUNKS.reduce((a, c, i) => (t >= c.t0 - 0.06 ? i : a), -1);
const c = idx >= 0 ? CHUNKS[idx] : null;
const quiet = c ? c.words[0] === QUIET_WORD : false;
const hot = (w: Word) => !quiet && ((t >= w.t0 - 0.03 && t < w.t1 + 0.08) || (t >= E('cta') && clean(w.w) === KEYWORD));
return (
<div style={{ position: 'absolute', left: 60, top: 1338, width: 890, height: 138, borderRadius: 8, background: 'linear-gradient(#17161a,#0e0d10)', boxShadow: '0 8px 14px rgba(0,0,0,.5), inset 0 2px 6px #000, inset 0 -1px 0 rgba(255,255,255,.08)', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
{c ? rowsOf(c.words).map((row, ri) => (
<div key={ri} style={{ font: `800 ${quiet ? 104 : 66}px/${quiet ? 110 : 64}px ${DISP}`, color: CREAM, letterSpacing: quiet ? '.1em' : '.015em', whiteSpace: 'nowrap', opacity: quiet ? ramp(t, QUIET_T0, QUIET_T0 + 0.5) : 1, textShadow: quiet ? '0 0 26px rgba(255,176,0,.45)' : 'none' }}>
{row.map((w, i) => <React.Fragment key={i}>{i ? ' ' : ''}<span style={{ color: hot(w) ? AMBER : undefined }}>{clean(w.w)}</span></React.Fragment>)}
</div>
)) : null}
</div>
);
};
/* ---------- the film ---------- */
export const OnAir: React.FC = () => (
<Clock>
<Fonts faces={FACES}>
<Stage />
</Fonts>
</Clock>
);
const Stage: React.FC = () => {
const t = useT();
const cam = camAt(t);
return (
<AbsoluteFill style={{ background: '#16120f', overflow: 'hidden' }}>
<div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1920, transformOrigin: '0 0', transform: `translate(${cam.tx}px, ${cam.ty}px) scale(${cam.s})` }}>
<WallAndDesk />
<MasterBody />
<Strips />
{/* everything above this line is unlit material; this one layer is the room's light */}
<div style={{ position: 'absolute', inset: 0, background: studioTint(t), mixBlendMode: 'multiply' }} />
<Ident />
<Glass />
<Mug />
<Mic />
<Sticky />
<Sign />
<RingClock />
<LinePanel />
<Meters />
<MasterLive />
<StripLeds />
<Cards />
<Wire />
<LightFx />
</div>
<Captions />
<AbsoluteFill style={{ background: 'radial-gradient(120% 80% at 50% 45%, transparent 62%, rgba(10,5,2,.42) 100%)', pointerEvents: 'none' }} />
</AbsoluteFill>
);
};
```
## 8. Voice (optional)
A voice is worth adding when the world has a speaker of its own, because the deadpan delivery is half the joke. Skip it for a sound-off post, or when the user would rather not have one: the film still works.
**The persona:** a late-night radio host, low and very close to the mic, warm and unhurried, who has heard every worry there is and is gently amused by all of them. Deadpan on the jokes, and never sentimental on the turn. It should sound like someone talking to one person.
**When to use it:** on every line except the voiceless moment. Let the first three callers move quickly. On the low point, slow right down and let the second "Line four" come out quieter than the first, as if the host is leaning in. Say "That's you, isn't it" as something they've just noticed, without a trace of accusation. Keep it almost flat on "Nobody called", because the joke is in not selling it. Treat it like a voice in a small dead room: a little warmth around 150 Hz, light compression and no reverb at all. Don't add a telephone filter, because the host isn't on the phone.
- **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 |
|-------|--------|-------------|---------------|
| room tone | 12 s | a small carpeted radio studio at night, faint air handling, the soft hum of old equipment, no voices, no music | the bed, all the way through, and it never stops |
| line hiss | 8 s | a quiet open telephone line, a soft steady hiss with faint distant crackle, no voices | rises from `low`, gone just after `you` |
| line ring | 1 s | a soft muted studio phone line warble, low and rounded, not a bell | `line1`, `line2`, `line3`; once quietly on `more`; slowly, over and over, from `low` to `you` |
| line key | 0.4 s | a heavy vintage phone button pressed once, a clean plastic click, warm and close | `card1`, `card2`, `card3`, and softly as each line goes dark in the voiceless moment |
| card drop | 0.5 s | an index card dropped flat on a metal desk, a soft papery slap | every card that lands: `card1`, `card2`, `card3`, `low`, `back8`, `key` |
| pencil | 0.8 s | a soft pencil writing a few quick words on card stock, close and dry | every handwritten note: `people`, `didn`, `fine`, `tuesday`, `isn`, `nobody` |
| talkback | 0.3 s | a small intercom button pressed, with a tiny burst of hiss | `talk` |
| station ident | 1.5 s | a short warm radio ident, three soft rising vibraphone notes with a gentle tail, no voice | `producer` |
| sticky note | 0.3 s | a sticky note pressed onto glass with a fingertip, a tiny dull tap | `more` |
| relay | 0.6 s | a heavy relay switching off, a single dull thunk, warm and low | `low`, as the needles drop and ON cuts out |
| dead air throb | 2 s | a very low soft electrical throb, slow and barely audible, no melody | on each pulse of the DEAD lamp, from `low` to `decision`. This is the designed sound that keeps the low point alive |
| lamp buzz | 1 s | a lit studio sign buzzing on with a small flicker, a low warm hum | the first frame, `decision`, `back8` |
| rubber stamp | 0.8 s | a rubber stamp pressed firmly onto a card on a metal desk, a dull solid thud | `decision`, the loudest effect in the film |
| clock run | 4 s | a mechanical clock running fast, soft quick ticks, muffled, warm | from `eight`, faint and speeding up, then easing to a stop on `back8` |
| fader | 1.6 s | a long mixing console fader drawn slowly down, a soft felt rub | 0.5 s into the voiceless moment |
| bedside switch | 0.3 s | a small bedside lamp switch clicked off in a quiet room, close | when the window goes dark, on the last line's key, 1.75 s into the voiceless moment |
| morning | 8 s | early morning heard through a studio window, a few distant birds, far-off traffic, soft | fades in during the voiceless moment and runs 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:** "Late-night radio instrumental: warm electric piano with a soft tremolo, upright bass far back, brushes barely there, slow, close, tender and a little amused, with a quiet string pad that opens like first light in the last third, ending resolved, no vocals."
**When it plays:** low under the callers, like the show's own bed. It drains to nothing over 0.7 s from `low`, stays out through "are you there?" so only the room, the hiss and the throb remain, and comes back very low on "you", ideally on one piano note. It stays well under the message and the comfort, rises at its fullest on the voiceless moment, on a bar where the strings open, and settles under "Nobody called" so the line sits dry. It stays warm and low under the call to action.
**Mix, voice first.**
- **Voice:** compress lightly so consonants stay off the limiter, about -14 LUFS.
- **Music:** about 16 to 20 LU under the voice while it speaks, lifted in gaps and on the voiceless moment.
- **Effects:** 7 to 10 LU under the voice overall, ducked about 6 dB under speech, so nothing masks a word.
- **Master:** -14 LUFS integrated, sample-peak ceiling about -2.4 dBFS. AAC encoding adds about 0.8 dB, so check the true peak of the final file. Give the master a 10 ms fade-in, or every view opens on a click.
## 10. Render and QA
1. **Render** without audio. Mux your mix onto it, trimmed to the exact length (`-t`, never `-shortest`), as H.264 at high quality with AAC 320k and `+faststart`.
2. **Sync and length:** the frame count matches the clock, and a cross-correlation of the muxed audio against your mix shows an offset of 0 ms.
3. **Contact sheets:** one frame per second with the safe box drawn. Look at every sheet: nothing cut off, nothing important outside the box, no empty or black frames, and the time of day and the story still agree.
4. **Determinism:** render about 10 spread frames twice with `npx remotion still src/index.ts Film-9x16 out/a.png --frame=N`, and compare each pair with `cmp`; they should match.
5. **Loudness:** -14 LUFS integrated, true peak at or below -1 dBTP.
6. **Words:** transcribe the mix line by line with Whisper. A difference on a plain word is a masked or swallowed word.
7. **Gaps and stills:** no voice gap over 1.0 s outside the voiceless moment (`clock.mjs` warns about these), and no still stretch over 1.2 s or black stretch (`ffmpeg -i out/silent.mp4 -vf "freezedetect=n=0.001:d=1.2,blackdetect=d=0.1" -an -f null -` prints any). A small moving corner can hide a freeze from that check, so trust the contact sheets over it.
For a silent film, skip the checks that need audio (the offset in 2, and 5 and 6) and use `out/silent.mp4` as the final file.
## 11. Traps
- **A low point that reads as a frozen frame:** the needles are flat by design, so the DEAD lamp's pulse, the ringing lamp, the cursor and the last windows going out have to carry the movement. Check them on the sheet.
- **A black low point:** the city goes dark, but the moon, the beacon, the street lamps and the last window stay on, and the sign's red lies on the glass. It's never black.
- **The last window hidden:** nothing may sit in front of `HERO` in the wide shot. The mic, the mug and the sticky note are all placed to clear it. Check the frame just before the turn.
- **The window lit too soon, or too bright:** other windows in the same building have to be lit in the opening, or it gives the turn away. It should be just one more light until the others go.
- **Words behind the person:** keep the wall's words clear of the figure's sitting pose, because a word half behind a head reads as a mistake.
- **Dawn too early:** the sky reads the clock, so if you move the run, the morning moves with it. Check that GOOD NIGHT still has night in it when it first appears.
- **Mocking the callers:** the first half has to laugh with them. If an answer sounds smug, the turn sounds smug too.
- **A real crisis:** this film is for ordinary 3 a.m. worries. Keep it plainly small, give no medical advice, and don't use it for a user's real distress or show a phone number. A call sign can belong to a real station, so name the show instead.
| 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.
The prompt
# The prompt First give your agent the on-air 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 on-air skill to make a vertical video for [PLATFORM: Instagram Reels / TikTok / YouTube Shorts]. - **What keeps them up:** [TOPIC - the hour and the worry, e.g. "overthinking at 3 a.m.", "Sunday night dread", "the night before a launch"] - **The callers:** [CALLERS - three small, specific worries and the short answer to each, or "you write them"] - **What they need to hear:** [MESSAGE - the one true thing the last caller needs, e.g. "nothing you decide at 3 a.m. is a real decision", or "you write it"] - **In the window:** [WINDOW - "no person: a night city and one lit room, drawn in code", or "my own clip, attached"] - **Length:** [LENGTH - e.g. about 35 seconds] - **Voice:** [VOICE - e.g. "a warm late-night radio host", "my own voice", or "no voice: captions and sound only"] - **Music:** [MUSIC - e.g. "the skill's late-night radio 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 stations, no real people as callers, nothing that sounds like medical advice] Work in this order: 1. Write the script on the skill's moves, with one line for each turn and what happens in the studio. Start from the message, then pick the callers it answers. 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.
More skills
All skillsTreasure Map
An old sea chart tells your long hunt as the voyage of a lifetime.
Vitals
A bedside monitor treats your small problem as an emergency.
Going Up
An elevator climbs through your topic, one room per floor, until a second door opens.