EVA
You're inside a helmet, and your plan is a waypoint dead center on the visor. Small errands float past as debris, the drift readout climbs, and the plan slides off the glass. Then the HUD goes red, nobody answers, and one small puff brings the sun up. Every scene is code, and the skill tells your agent which sounds to use and when.
- Frame
- 9:16
- 30-40 s
You need an agent that can run code, such as Claude Code, Claude with code execution, ChatGPT Codex or Cursor, plus Node 20+, ffmpeg and Python 3.
-
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
The plan you drifted off What pulled you off it The first small move back Platform Narrator Ending AI toolsUse the eva skill to make a vertical video for Instagram Reels. - **The plan you drifted off:** [TOPIC - the goal, and by when, e.g. "write the book by spring", "launch the side project this year"] - **What pulled you off it:** [DEBRIS - 3 to 9 small real things, e.g. "emails, errands, favors", or "you write it"] - **How long it's been:** [TIME - e.g. "three years", or "you pick, it's a metaphor"] - **The first small move back:** [MOVE - what "one small puff" means here, e.g. "open the file and write page one", or "you write it"] - **In the window:** [WINDOW - "no person: the sky and the station drawn in code", or "my own clip, attached, with the brackets on my face"] - **Length:** [LENGTH - e.g. about 35 seconds] - **Voice:** [VOICE - e.g. "a calm astronaut on a radio check", "my own voice", or "no voice: captions and sound only"] - **Music:** [MUSIC - e.g. "the skill's weightless ambient brief", or your own idea] - **Ending:** [CTA - organic: "Comment KEYWORD and I'll send you THE THING", with the keyword and what you'll send; or sponsored: the line and the URL] - **AI tools:** [AI TOOLS - e.g. "whatever you already have set up", "HYVIRAL", or "none: animation only, I'll record the voice myself"] - **Avoid:** [AVOID - e.g. no real space agency, logo or mission audio, no claims I can't back up] Work in this order: 1. Write the script on the skill's moves, with one line for each turn and what happens on the visor. 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 visor 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: eva
description: Make a 30-40 second vertical video seen from inside a space helmet, where drifting off a plan is something you watch happen - a visor HUD, drift numbers that climb, small errands floating past as debris, a plan that slides off the glass, then one small thruster puff and a sunrise. The skill covers the suit's sound design, music fitted to the story and an optional calm astronaut voice, all invented, with no real space agency, logo or mission audio. Use for stories about losing track of a goal, procrastination, burnout, a side project that stalled, starting again, or when asked for a space, astronaut or helmet POV video, or when given the eva prompt.
---
# EVA
You're inside a space helmet, and your plan is a waypoint dead center on the visor. Small errands float past as debris, the drift readout climbs, and the elapsed clock runs away while the voice stays calm. The plan slides off the glass. The HUD goes red, the stars tumble, and nobody answers. Then one small thruster puff, a sunrise over the edge of the Earth, and a docking ring that lines up. The HUD never moves and the sky does, so you can watch yourself drift with the sound off.
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/eva/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/chakra-petch`. 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/Eva.tsx this frame (step 7)
```
Render without audio, then mux your own mix (step 10): `npx remotion render src/index.ts Film-9x16 out/silent.mp4 --muted`. Set TEMP and TMP to a folder on a roomy drive first; Remotion writes a lot of temporary files.
**Prove the setup before you write anything.** Copy the code and our `plan.json` from step 7, run the clock from step 6, and render the first few seconds (add `--frames=0-90`). Only then start on your own story, at step 5. The font files you need are named in `FACES` at the top of the frame.
**For the voice, images, music and sound effects, use whatever the user prefers.** Every step works the same with any provider. If the user hasn't said, ask which tools they want to use, or use what is already set up.
- **Your own providers.** A text-to-speech that returns word timestamps (for example ElevenLabs with timestamps), a music model or a licensed track, and a sound-effects generator (for example ElevenLabs sound effects) or a licensed sound library. Keep API keys in environment variables, never in the project. No AI media at all is fine too: the picture is code, the narration can be the user's own recording (align its words with Whisper), and the film works with no voice.
- **HYVIRAL (optional).** If the user has it connected (hyviral.com), one connection covers the media:
| You need | HYVIRAL tool |
|----------|--------------|
| Narration with word timestamps (stock voices or your own cloned voice) | `generate_voiceover` (`content_type: "shorts"`), `manage_voice_clone` |
| Still images | `generate_image` |
| Music bed | `generate_music` |
| Sound library, placing sounds, final render | `video_audio`, `video_export` |
| Long jobs | `get_job_status` (poll until delivered) |
To connect it: in Claude.ai or Claude Desktop, Customize → Connectors → add a custom connector with the URL `https://api.hyviral.com/mcp`; in Claude Code, `claude mcp add --transport http hyviral https://api.hyviral.com/mcp` then `/mcp` to sign in. Start a session with `get_connection_info`. HYVIRAL never runs your code: you render the animation yourself.
## 2. How a frame film works
- **One frame, the whole film.** A frame is a world with fixed parts on screen, and it never cuts away. The story happens to the world: the sky drifts behind a still HUD, the numbers climb, debris floats past, the HUD goes red and the stars tumble, and the sun comes up. That's why it holds attention: the viewer learns the world in the first second, then watches it change.
- **One clock: the spoken words.** Each line is voiced as its own take with word timings, scenes are laid end to end on those takes, and every change is keyed to a word in `story.json`. Re-voice a line and everything moves with it.
- **A function of time.** Every part of the frame computes what it shows from the current time, with no stored state, `Math.random` or `Date`. Use `hash()` for anything that looks random, so two renders match.
- **Sound off first.** Most people start muted, so the frame and the captions have to tell the whole story on their own. Sound makes it better, never necessary.
- **Safe area.** 1080 x 1920. Platform buttons and captions cover the bottom 440 px, and the right 130 px from about y = 900 down. Keep captions and anything that must be read inside x 60-950, y 250-1480. Above y = 900 the frame can run to the right edge. Below y = 1480, draw only texture. The top 250 px can hold the world's title and small jokes, as long as nothing there has to be read.
- **Pacing rules.** No voice gap over 1.0 s, except one voiceless moment of 2 to 5 s with the music lifted. No still stretch over 1.2 s. Never cut to black. Something moves on the very first frame.
## 3. The world
The inside of a space helmet, in orbit, just before dawn. The viewer is the astronaut, and we never see them.
**No real space agency, logo, mission or mission audio, ever, and you must keep it that way.** The suit, the station and the voice on the radio in this frame are invented, and everything is drawn in code. Don't add an agency's name or mark, a flag patch, a real spacecraft or crew, recorded mission radio or a famous line from a real flight. The ground station is called CONTROL, never the name of a real city or center. If the user asks for a real agency's look or real mission audio, tell them this frame doesn't do that and offer the invented version.
- **Palette:** helmet `#070a0f`, HUD cyan `#5ef2ff`, warning orange `#ff8a3d`, alert red `#ff4557`, go green `#6dff9e`, visor gold `#d9a441`. The Earth is navy with gold city lights by night, and blue with white cloud once the sun is up.
- **Type:** Chakra Petch 600 and 700, for everything.
- **The one idea:** the HUD stays still on the glass, and the sky moves behind it. The brackets in the middle are where the plan is supposed to be, and they never move. So when the plan slides out of them, drifting is something you watch happen. Everything in the sky reads one list, `FLIGHT`, and a degree of drift is worth 32 px.
- **Fixed parts:** the gold-rimmed visor opening fills the frame down to y 1300. On the glass: a heading tape above the safe area, ELAPSED and PLAN ETA (y 262-350), a tilt ring and the brackets around (540, 690), oxygen and pressure arcs that hug the visor, OXYGEN, DRIFT and BREATH (y 978-1102), and the master alert (y 1132-1200). Below that a comms display is bolted to the chin (y 1226-1476). Below the safe area there's only the neck ring and a printed joke.
- **Captions stay readable because they aren't on the sky.** The visor covers the whole frame, so a caption laid over stars and a sunrise would fight both. Ours sit on the chin display, which is opaque, in one fixed place, and no HUD number ever goes inside it. A shade behind OXYGEN, DRIFT and BREATH keeps those readable over the sunrise.
- **The color says the state:** cyan is fine, orange is drifting, red is the low point, green is locked. The whole HUD reads one function, `toneAt`, so it changes all at once.
- **The breath is visible.** It's a real trace that the BREATH meter draws and that fogs the bottom of the glass, so you can see the breathing speed up and slow down with no sound at all.
- **Small jokes live on the HUD:** MOSTLY NOMINAL, then NOMINAL-ISH. PLAN ETA: SPRING, then NEXT SPRING, then SOME SPRING, then ???. The tasks list has TIDY DESK on it twice. The call sign is ME-1. A bracket marks EARTH? with a question mark. On the neck ring, IF FOUND, RETURN TO EARTH.
**Real people and real marks, in every version of this film.** The user's own name, brand and product are fine. Never use anyone else's company, agency, team or product name, logo, livery, seal or badge. Never use a real person's name, face or voice without their OK, and never make a real person or a named company the villain, the target of the joke or the one to blame.
## 4. The window
The window is the part of the frame that shows the subject. It has two modes, and the frame must work with either.
- **With no person (the default).** A scene that belongs to the world, drawn in code or from still images. It has to follow the story as well, not just sit there. "No person" means no filmed presenter: small drawn figures are fine.
- **With the user's own clip.** Replace the scene with their footage (`<OffthreadVideo>` from `remotion`, cropped to the window, muted if the narration is separate). Keep their face in the middle third, and take the word timings from their clip with Whisper.
For this world the window is the whole visor, and the sky follows the story. Ours is drawn in code:
| Story moment | Through the visor |
|---|---|
| Opening | Stars and the night side of the Earth, with city lights. A bright line drops down the glass as the HUD wakes |
| The plan | A speck of a station appears dead center in the brackets, under a diamond labeled PLAN |
| The tether | A line draws itself from the chin out to the station |
| Debris | An envelope, a shopping bag and a moving box tumble past, each with a tracking bracket: EMAIL, ERRAND, FAVOR |
| Drift | The whole sky slides and tilts. The station leaves the brackets, and the Earth shrinks into the corner |
| Gone | The station slides off the glass, the tether snaps and floats back with a frayed end |
| Low point | Red light, slow tumbling stars, the loose tether, and the Earth as one small marble |
| The burn | A burst of cold gas crosses the glass, the tumble stops, and the sky starts to swing back |
| The silence | The Earth comes up full size, the sun rises over its edge, the gold sun visor comes down by itself, and the station grows until its docking ring meets the HUD's ring |
| The ending | The ring keeps slowly coming closer |
The window also works with the user's own clip. Play it where `Stars` and `Earth` are, and keep `Glass`, the HUD, `Helmet` and `Console` on top. Put their face inside the brackets, so the lock is on them, and move the clip with `viewAt` so the drift slides them out of the brackets. It's still a helmet with no agency's marks on it.
## 5. The story
This is the part that makes the film, so write it before you touch anything else. Read every line aloud.
- **Use the world's own events.** Every turn in the topic has to become something that really happens in this world. If a line could sit in any video, rewrite it.
- **Use the voice's deadpan.** The funniest and most moving versions take the world completely seriously.
- **Short lines.** Mostly 5 to 11 words, one idea each, with an occasional two-word punch.
- **Real claims only.** Use a number or a fact only if the user gave it to you or you can source it. Invented details are fine only when the film is plainly a joke or a metaphor.
- **Build to a low point, then a turn.** The low point is a picture event, never silence: the world goes dim or dead while room tone and a designed sound keep running.
- **No em dashes on screen.** Commas, full stops and line breaks do that job.
- **Ending, organic or sponsored.** Ask the user which one, and for an organic ending, what they'll send people. Organic: "Comment KEYWORD and I'll send you THE THING", said in the world's voice, and no link. The keyword stays on screen in the world's own way until the end, because the caption moves on. Sponsored: a direct line and a URL.
- **Length.** Run the clock from step 6 as soon as the script exists, before you build anything. Each extra line adds about 3 s, and even a one-word line takes about 0.5 s. Time follows the words, so a cut only helps if words go: folding two moves into one shorter line works, and joining them word for word saves nothing. Then re-anchor every event in `story.json`.
**The moves.** Map any topic onto these. The film is about the moment you realize you've drifted off your plan, told as a calm radio check that slowly stops being calm. Ours is about a book that never got written.
| # | Move | What it does | Our line | On the visor |
|---|------|--------------|----------|--------------|
| 1 | Check-in | The hook: all is well, on the record | "Control, this is me. Everything's nominal." | The HUD wakes over the night side of the Earth. ALL NOMINAL |
| 2 | The plan | Names the goal, and when | "Plan says write the book by spring." | PLAN locks dead center. WRITE THE BOOK. PLAN ETA: SPRING |
| 3 | Tether | The first small slip, which looks like work | "Tether check. One small task, then another." | The tether draws, tasks tick off one per word, and each tick nudges the plan out of center |
| 4 | Debris | What's really pulling you off | "Just answering emails. Just for a minute." | EMAIL, ERRAND, FAVOR tumble past. ELAPSED starts to race |
| 5 | Drift | The first number, and the first lie | "Drift, two degrees. Nothing to worry about." | DRIFT 2.0 appears. MOSTLY NOMINAL. NEXT SPRING |
| 6 | More drift | The number jumps, and the voice doesn't | "Drift, eleven degrees. Is that the same Earth?" | DRIFT 11.0 in orange, the plan near the visor's edge. The Earth has shrunk, and a bracket marks EARTH? |
| 7 | Gone | The honest number | "It's been three years. I can't see the plan." | ELAPSED lands on 3 YR, the plan slides off the glass, the tether snaps. NO LOCK ON PLAN |
| 8 | Low point | Calling for help | "Oxygen warning. Control? Control?" | Red HUD, tumbling stars, a small Earth, oxygen falling, breath racing. NO REPLY. Music out; breath and suit beeps keep running |
| 9 | The turn | Nobody is coming, and that's the good news | "There's no control. There's just me. Okay." | CONTROL is struck out, and the address line becomes ME-1 > ME-1. On "okay" the HUD goes back to cyan and the breath slows. Music returns |
| 10 | The burn | The smallest possible action | "One small puff. Point at the plan." | Cold gas crosses the glass, the tumble stops, BURN, and a green arrow appears pointing at a plan you can't see |
| 11 | The silence | The voiceless moment, about 3.6 s | (music at its fullest; the caption fills in letter by letter: REALIGNED) | Sunrise over the Earth's edge, the gold visor comes down, the plan slides back to center, DRIFT counts to 0.0, the ring locks and knocks the whole suit |
| 12 | The button | What it all meant | "Drift, zero. Page one. Docking is just starting again." | ALL NOMINAL again. PAGE 1 inside the ring. ELAPSED resets to zero. Oxygen climbs |
| 13 | Call to action | One action | "Comment orbit and I'll send you the skill that made this." | A green plate in the ring reads COMMENT ORBIT and stays |
**For other topics:** the gym you joined, the language you were learning, the side project, the company's first idea, the friend you meant to call back. The waypoint is the goal. The debris is 3 to 9 small, real, forgivable things, and the more boring they are the better it works. Control is whoever you've been waiting on: a boss, a mentor, the right moment, a sign. Use a number or a time span only if the user gave it to you or the film is plainly a metaphor.
**The trick that makes it land:** the RX meter on the chin has been flat since the first frame. You've been talking to someone who never answered, and it's been on screen the whole film. So "There's no control" isn't new information, and a viewer who looks back can see it was true all along. When the line says "There's just me", the address changes to ME-1 > ME-1 and RX finally moves, because it's your own voice coming back. Keep that flat meter. Also keep the voice level all the way down: nothing bad ever happens here, just small numbers getting bigger while someone calmly reads them out.
**Length:** our film runs 41 s on the clock's estimate. For about 35 s, fold moves 5 and 6 into one line and start the drift at eleven (about 3 s), cut "Tether check." (about 1 s), and shorten move 12 to "Drift, zero. Page one." (about 2 s). Then fix the events those lines carried.
## 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 story sits in the constants at the top of the file, so start there. Then the objects: redrawing the debris and the station is the biggest job. `core.tsx` and `clock.mjs` don't change.
| To change | Where |
|---|---|
| The goal and the names | `WAYPOINT`, `GOAL`, `TARGET`, `CALLSIGN`, `CONTROL`, `EARTH_TAG` at the top |
| The tasks that tick off | `TASKS`, one row per event |
| The ETA's jokes | `ETA`, keyed to events |
| The master alert's jokes | `ALERTS`, keyed to events |
| The debris | `DEBRIS`: its label, its drawing, when it crosses and where from. The drawings are in `DRAW`, one `<g>` in a 200 x 200 box |
| The drift, the tilt and the Earth's size | `FLIGHT`. The DRIFT readout shows `yaw`, so the 2 and the 11 in it have to match what the voice says. The plan leaves the glass at about 17 degrees |
| The elapsed clock | `met`: 14 days on `two`, 230 days on `eleven`, 3 years on `three`, and back to zero on `page` |
| Oxygen and the breath | `o2At` and `rateAt`, both keyed to events |
| What knocks the suit | `HITS` |
| The place you're heading | `Station`. Redraw it for another destination, but keep a ring of radius 190 at its center so it meets `AlignRing` |
| The lock's words | `lock` in `Reticle`: SEARCHING, LOCKED, LOCK WEAK, NO LOCK, ACQUIRING |
| The HUD's labels | `TopRow` (ELAPSED, PLAN ETA), `LowerRow` (OXYGEN, DRIFT, BREATH), `Console` (TX, RX) |
| The voiceless caption | `QUIET_WORD`, about 14 letters at most |
| The neck ring's joke | IF FOUND, RETURN TO EARTH in `Helmet` |
| The comment keyword | `KEYWORD` at the top (the plate and the caption's highlight read it), and `plan.json` |
- **Scene ids in the code:** none. The frame only reads events, so if you renumber scenes, only `story.json` changes.
- **The sunrise runs off the voiceless scene.** `Q` is `quiet` and `ALIGN` is `aligned`, which is `s11` plus 2.6 s in `story.json`. `sunK`, `dayK`, `Glass` and `Station` count from `Q`, so a voiceless scene under about 3 s needs those offsets moved as well. Leave about 1 s between `aligned` and the next line, so REALIGNED holds.
- **Panels are sized to our words.** The keyword and `TARGET` shrink to fit, the alert grows with its text up to about 26 letters, and debris labels never wrap. `GOAL` holds about 28 letters, ETA about 18, and `TASKS` about 11 before they hit the brackets. Captions are cut at 20 cells a row, so a line heavy in wide letters like W and M needs a look on the contact sheet.
- **Route debris around the plan's label.** Ours pass above or below it. A piece crossing the middle hides the one word that has to be read.
- **Keep events that no longer match a word out of `story.json`.** Those stop the render, which is what you want.
### The code
`plan.json`, our demo's script:
```json
{
"lines": [
{
"id": "l01",
"text": "Control, this is me. Everything's nominal."
},
{
"id": "l02",
"text": "Plan says write the book by spring."
},
{
"id": "l03",
"text": "Tether check. One small task, then another."
},
{
"id": "l04",
"text": "Just answering emails. Just for a minute."
},
{
"id": "l05",
"text": "Drift, two degrees. Nothing to worry about."
},
{
"id": "l06",
"text": "Drift, eleven degrees. Is that the same Earth?"
},
{
"id": "l07",
"text": "It's been three years. I can't see the plan."
},
{
"id": "l08",
"text": "Oxygen warning. Control? Control?"
},
{
"id": "l09",
"text": "There's no control. There's just me. Okay."
},
{
"id": "l10",
"text": "One small puff. Point at the plan."
},
{
"id": "l11",
"text": "Drift, zero. Page one. Docking is just starting again."
},
{
"id": "l12",
"text": "Comment orbit and I'll send you the skill that made this.",
"say": "Comment, orbit, and I'll send you the skill that made this."
}
],
"scenes": [
{
"id": "s01",
"lines": [
"l01"
],
"lead": 0.1,
"tail": 0.03
},
{
"id": "s02",
"lines": [
"l02"
],
"lead": 0.05,
"tail": 0.03
},
{
"id": "s03",
"lines": [
"l03"
],
"lead": 0.05,
"tail": 0.03
},
{
"id": "s04",
"lines": [
"l04"
],
"lead": 0.05,
"tail": 0.03
},
{
"id": "s05",
"lines": [
"l05"
],
"lead": 0.05,
"tail": 0.03
},
{
"id": "s06",
"lines": [
"l06"
],
"lead": 0.05,
"tail": 0.03
},
{
"id": "s07",
"lines": [
"l07"
],
"lead": 0.05,
"tail": 0.03
},
{
"id": "s08",
"lines": [
"l08"
],
"lead": 0.4,
"tail": 0.03
},
{
"id": "s09",
"lines": [
"l09"
],
"lead": 0.15,
"tail": 0.03
},
{
"id": "s10",
"lines": [
"l10"
],
"lead": 0.08,
"tail": 0.05
},
{
"id": "s11",
"sec": 3.6
},
{
"id": "s12",
"lines": [
"l11"
],
"lead": 0.05,
"tail": 0.03
},
{
"id": "s13",
"lines": [
"l12"
],
"lead": 0.08,
"tail": 0.05
}
]
}
```
`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 { Eva } from './frames/Eva';
import spec from './spec.json';
export const Root: React.FC = () => (
<Composition id="Film-9x16" component={Eva} 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": "nominal",
"scene": "s01",
"word": "Everything"
},
{
"id": "plan",
"scene": "s02",
"word": "Plan"
},
{
"id": "book",
"scene": "s02",
"word": "book"
},
{
"id": "spring",
"scene": "s02",
"word": "spring"
},
{
"id": "tether",
"scene": "s03",
"word": "Tether"
},
{
"id": "t1",
"scene": "s03",
"word": "One"
},
{
"id": "t2",
"scene": "s03",
"word": "small"
},
{
"id": "t3",
"scene": "s03",
"word": "task"
},
{
"id": "t4",
"scene": "s03",
"word": "another"
},
{
"id": "debris",
"scene": "s04"
},
{
"id": "email",
"scene": "s04",
"word": "emails",
"off": -0.5
},
{
"id": "errand",
"scene": "s04",
"word": "for"
},
{
"id": "favor",
"scene": "s04",
"word": "minute"
},
{
"id": "drift",
"scene": "s05",
"word": "Drift"
},
{
"id": "two",
"scene": "s05",
"word": "two"
},
{
"id": "nothing",
"scene": "s05",
"word": "Nothing"
},
{
"id": "eleven",
"scene": "s06",
"word": "eleven"
},
{
"id": "earth",
"scene": "s06",
"word": "Earth"
},
{
"id": "three",
"scene": "s07",
"word": "three"
},
{
"id": "cant",
"scene": "s07",
"word": "can't"
},
{
"id": "gone",
"scene": "s07",
"word": "plan"
},
{
"id": "low",
"scene": "s08"
},
{
"id": "oxygen",
"scene": "s08",
"word": "Oxygen"
},
{
"id": "control",
"scene": "s08",
"word": "Control"
},
{
"id": "noreply",
"scene": "s08",
"word": "Control",
"off": 0.72
},
{
"id": "nocontrol",
"scene": "s09",
"word": "no"
},
{
"id": "me",
"scene": "s09",
"word": "me"
},
{
"id": "okay",
"scene": "s09",
"word": "Okay"
},
{
"id": "puff",
"scene": "s10",
"word": "puff"
},
{
"id": "point",
"scene": "s10",
"word": "Point"
},
{
"id": "quiet",
"scene": "s11"
},
{
"id": "aligned",
"scene": "s11",
"off": 2.6
},
{
"id": "zero",
"scene": "s12",
"word": "zero"
},
{
"id": "page",
"scene": "s12",
"word": "Page"
},
{
"id": "docking",
"scene": "s12",
"word": "Docking"
},
{
"id": "again",
"scene": "s12",
"word": "again"
},
{
"id": "cta",
"scene": "s13"
}
]
}
```
`src/frames/Eva.tsx`:
```tsx
import React from 'react';
import { AbsoluteFill, interpolateColors } from 'remotion';
import story from './story.json';
import { back, buildChunks, clamp, Clock, DURATION, easeIn, easeInOut, easeOut, Fonts, hash, makeE, ramp, useT, WORDS, type StoryJson, type Word } from './core';
/* EVA: the view from inside a space helmet. The HUD stays still on the glass and the sky moves behind it, so "drifting off the plan"
is something you watch happen. 1080 x 1920. Time comes from the spoken words through story.json, so a re-voice moves every change with its word. */
const E = makeE(story as StoryJson);
/* ---------- the story's words: change these first ---------- */
const KEYWORD = 'ORBIT';
const CALLSIGN = 'ME-1', CONTROL = 'CONTROL';
const WAYPOINT = 'PLAN', GOAL = 'WRITE THE BOOK', TARGET = 'PAGE 1', QUIET_WORD = 'REALIGNED', EARTH_TAG = 'EARTH?';
/** The tiny jobs that tick off on the tether check. The joke is that the same one comes up twice. */
const TASKS: [string, string][] = [['t1', 'TIDY DESK'], ['t2', 'NEW PEN'], ['t3', 'NEW FOLDER'], ['t4', 'TIDY DESK']];
/** The plan's ETA, top right. It only ever gets vaguer, until the end. */
const ETA: [string, string][] = [['spring', 'SPRING'], ['two', 'NEXT SPRING'], ['eleven', 'SOME SPRING'], ['cant', '???'], ['page', TARGET]];
/** What the master alert says, in time order. */
type Level = 'info' | 'go' | 'warn' | 'alert';
const ALERTS: [string, string, Level][] = [
['nominal', 'ALL NOMINAL', 'go'], ['tether', 'TETHER OK', 'go'], ['debris', 'MINOR DEBRIS', 'info'], ['two', 'MOSTLY NOMINAL', 'info'], ['eleven', 'NOMINAL-ISH', 'warn'],
['cant', 'NO LOCK ON PLAN', 'alert'], ['oxygen', 'OXYGEN LOW', 'alert'], ['noreply', 'NO REPLY', 'alert'], ['me', `${CONTROL}: ME`, 'info'], ['puff', 'BURN', 'info'],
['quiet', 'REALIGNING', 'info'], ['aligned', 'LOCKED', 'go'], ['zero', 'ALL NOMINAL', 'go'], ['docking', 'DOCKING', 'go'],
];
/** What the life-admin debris is called and when it crosses the visor. `s` is its size; a crossing takes about 3 s. */
type Kind = 'env' | 'bag' | 'box' | 'cal' | 'chat' | 'form';
const DEBRIS: { label: string; kind: Kind; at: number; from: P; to: P; d: number; s: number; spin: number }[] = [
{ label: 'EMAIL', kind: 'env', at: E('email'), from: [1220, 770], to: [-220, 880], d: 3.0, s: 1.05, spin: 62 },
{ label: 'ERRAND', kind: 'bag', at: E('errand') - 0.25, from: [-220, 430], to: [1220, 540], d: 3.1, s: 1, spin: -48 },
{ label: 'FAVOR', kind: 'box', at: E('favor') - 0.15, from: [1220, 950], to: [-220, 760], d: 3.1, s: 1.1, spin: 40 },
{ label: 'MEETING', kind: 'cal', at: E('nothing'), from: [-200, 860], to: [1200, 540], d: 2.8, s: 0.72, spin: 70 },
{ label: 'REPLY ALL', kind: 'env', at: E('eleven') - 0.5, from: [1200, 440], to: [-200, 640], d: 2.5, s: 0.7, spin: -80 },
{ label: 'DENTIST', kind: 'cal', at: E('eleven') + 0.2, from: [-200, 600], to: [1200, 880], d: 2.5, s: 0.66, spin: 90 },
{ label: 'TAXES', kind: 'form', at: E('earth') - 0.2, from: [1200, 800], to: [-200, 520], d: 2.3, s: 0.8, spin: 75 },
{ label: 'GROUP CHAT', kind: 'chat', at: E('three') - 0.3, from: [-200, 400], to: [1200, 520], d: 2.1, s: 0.86, spin: -60 },
{ label: 'ONE MORE EMAIL', kind: 'env', at: E('three') + 0.25, from: [1200, 760], to: [-200, 900], d: 2.1, s: 0.92, spin: 110 },
];
/* ---------- the world ---------- */
const HELMET = '#070a0f', HUD = '#5ef2ff', WARN = '#ff8a3d', ALERT = '#ff4557', GO = '#6dff9e', GOLD = '#d9a441', LEVEL: Record<Level, string> = { info: HUD, go: GO, warn: WARN, alert: ALERT };
const TYPE = '"Chakra Petch", sans-serif';
const FACES = [
{ family: 'Chakra Petch', file: 'chakra-petch-latin-600-normal.woff2', weight: '600' },
{ family: 'Chakra Petch', file: 'chakra-petch-latin-700-normal.woff2', weight: '700' },
];
type P = [number, number];
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
const mod = (x: number, m: number) => ((x % m) + m) % m;
const rotP = ([x, y]: P, deg: number): P => { const a = (deg * Math.PI) / 180, c = Math.cos(a), s = Math.sin(a); return [x * c - y * s, x * s + y * c]; };
/** Straight lines between keyed values. Keep the keys in time order. */
const curve = (keys: [number, number][], t: number) => {
if (t <= keys[0][0]) return keys[0][1];
for (let i = 1; i < keys.length; i++) if (t < keys[i][0]) return lerp(keys[i - 1][1], keys[i][1], ramp(t, keys[i - 1][0], keys[i][0]));
return keys[keys.length - 1][1];
};
/** The point the helmet is aimed at, and how many pixels a degree of drift is worth. The plan sits at exactly 0, 0. */
const C: P = [540, 690];
const K = 32;
const Q = E('quiet'), ALIGN = E('aligned');
/* The flight: drift, tilt and how big the Earth looks. Everything in the sky reads this, so the story is one list. */
type V = { yaw: number; pitch: number; roll: number; R: number; ex: number; ey: number };
const EARTH: Pick<V, 'R' | 'ex' | 'ey'> = { R: 2300, ex: 250, ey: 3090 };
const V0: V = { yaw: 0, pitch: 0, roll: -3, ...EARTH };
const FLIGHT: { at: number; d: number; v: Partial<V> }[] = [
{ at: E('t1'), d: 0.3, v: { yaw: 0.25 } },
{ at: E('t2'), d: 0.3, v: { yaw: 0.5, pitch: 0.1 } },
{ at: E('t3'), d: 0.3, v: { yaw: 0.8, pitch: 0.2 } },
{ at: E('t4'), d: 0.3, v: { yaw: 1.1, pitch: 0.3 } },
{ at: E('email'), d: 1.8, v: { yaw: 1.5, pitch: 0.4, roll: -1 } },
{ at: E('two') - 0.35, d: 0.6, v: { yaw: 2, pitch: 0.5, roll: 3, ey: 3150 } },
{ at: E('eleven') - 0.7, d: 1.0, v: { yaw: 11, pitch: 2.4, roll: 11, R: 1250, ex: -60, ey: 2110 } },
{ at: E('three'), d: 1.2, v: { yaw: 13.2, pitch: 3, roll: 16, R: 720, ex: -130, ey: 1600 } },
{ at: E('cant'), d: E('gone') - E('cant') + 0.45, v: { yaw: 38, pitch: 9, roll: 28, R: 300, ex: -40, ey: 1240 } },
{ at: E('low') - 0.2, d: 1.5, v: { R: 70, ex: 470, ey: 345 } },
{ at: E('low') + 1.3, d: Q - E('low') - 1.5, v: { ex: 205, ey: 640 } },
{ at: E('point') - 0.15, d: Q + 1 - E('point') + 0.15, v: { roll: 0 } },
{ at: E('point') - 0.15, d: ALIGN - E('point') + 0.15, v: { yaw: 0, pitch: 0 } },
{ at: Q - 0.25, d: 1.3, v: { ...EARTH } },
];
const SPIN = -13;
/** The slow tumble: starts with the low point, bleeds off from "okay", and the puff stops it. */
const tumbleAt = (t: number) => {
const a = E('low'), b = E('okay'), c = E('puff');
if (t < a) return 0;
const u = clamp(t - b, 0, c - b);
return SPIN * (Math.min(t, b) - a + u - (u * u) / (2 * (c - b)));
};
const keyedAt = (t: number): V => {
const v = { ...V0 };
for (const k of FLIGHT) {
if (t < k.at) break;
const q = easeInOut(ramp(t, k.at, k.at + k.d));
for (const key of Object.keys(k.v) as (keyof V)[]) v[key] = lerp(v[key], k.v[key] as number, q);
}
return v;
};
const viewAt = (t: number): V => {
const v = keyedAt(t);
const calm = 1 - ramp(t, ALIGN - 0.5, ALIGN);
v.roll += tumbleAt(t) * (1 - easeInOut(ramp(t, E('point') - 0.15, Q + 1))) + 0.5 * calm * Math.sin(t * 0.37);
v.yaw += 0.09 * calm * Math.sin(t * 0.71);
v.pitch += 0.07 * calm * Math.sin(t * 0.53 + 1);
return v;
};
const planAt = (v: V): P => { const p = rotP([v.yaw * K, -v.pitch * K], v.roll); return [C[0] + p[0], C[1] + p[1]]; };
/* The light: red through the low point, green once the ring lines up, and the sun coming up in the voiceless moment. */
const redK = (t: number) => ramp(t, E('low') - 0.1, E('low') + 0.2) * (1 - ramp(t, E('okay'), E('okay') + 0.5));
const toneAt = (t: number) => interpolateColors(ramp(t, ALIGN - 0.1, ALIGN + 0.2), [0, 1], [interpolateColors(redK(t), [0, 1], [HUD, ALERT]), GO]);
const sunK = (t: number) => easeOut(ramp(t, Q + 0.45, Q + 1.5));
const dayK = (t: number) => easeInOut(ramp(t, Q + 0.7, Q + 2.1));
const SUN_X = 770;
/* Everything that hits the suit knocks the whole picture: debris a little, the puff more, the ring most. */
const HITS: [number, number][] = [[E('email') + 1.2, 5], [E('errand') + 1.0, 5], [E('favor') + 1.1, 6], [E('three') + 0.9, 5], [E('puff') + 0.05, 13], [ALIGN, 17]];
const bump = (t: number) => HITS.reduce((acc, [e, a]) => (t >= e ? acc + a * Math.sin((t - e) * 44) * Math.exp(-(t - e) * 7) : acc), 0);
/* The breath is a real trace: a rate keyed to the story, added up once. The fog and the BREATH meter both read it. */
const rateAt = (t: number) => curve([[0, 0.3], [E('two'), 0.32], [E('eleven'), 0.42], [E('three'), 0.56], [E('low'), 0.92], [E('okay'), 0.92], [E('okay') + 0.9, 0.2], [ALIGN, 0.2], [DURATION, 0.27]], t);
const PHASE = (() => { const a = [0]; for (let i = 1; i < DURATION * 30 + 3; i++) a[i] = a[i - 1] + (2 * Math.PI * rateAt(i / 30)) / 30; return a; })();
const breath = (t: number) => (t < 0 ? Math.sin(2 * Math.PI * 0.3 * t) : Math.sin(PHASE[Math.min(PHASE.length - 1, Math.floor(t * 30))]));
/** How loud the voice is, from the words on the clock, so the meters work with no audio. */
const voiceAt = (t: number) => { const i = WORDS.findIndex((w) => t >= w.t0 && t < w.t1); return i < 0 ? 0.06 : 0.5 + 0.5 * hash(i * 3 + Math.floor(t * 11)); };
const met = (t: number) => {
const keys: [number, number][] = [[E('debris'), Math.log(E('debris') + 7)], [E('two'), Math.log(86400 * 14)], [E('eleven'), Math.log(86400 * 230)], [E('three') + 0.45, Math.log(86400 * 365 * 3)]];
const s = t >= E('page') ? t - E('page') : t < E('debris') ? t + 7 : Math.exp(curve(keys, t)) + (t > E('three') + 0.45 ? 86400 * (t - E('three')) * 2 : 0);
const two = (n: number) => String(Math.floor(n)).padStart(2, '0');
if (s < 86400) return `${two(s / 3600)}:${two((s / 60) % 60)}:${two(s % 60)}`;
if (s < 86400 * 365) return `${Math.floor(s / 86400)} DAYS`;
return `${Math.floor(s / (86400 * 365))} YR ${Math.floor((s % (86400 * 365)) / 86400)} D`;
};
const latest = <T extends [string, ...unknown[]]>(list: T[], t: number): T | null => list.reduce<T | null>((a, r) => (t >= E(r[0]) ? r : a), null);
/** Numbers in fixed cells, so a counter never jitters. */
const Mono: React.FC<{ s: string; w: number }> = ({ s, w }) => <>{Array.from(s).map((c, i) => <span key={i} style={{ display: 'inline-block', width: /[.:° ]/.test(c) ? w * 0.5 : w, textAlign: 'center' }}>{c}</span>)}</>;
const text = (size: number, weight = 700) => `${weight} ${size}px/1 ${TYPE}`;
const glow = (color: string, a = 10) => `0 0 ${a}px ${color}, 0 2px 6px rgba(0,0,0,.8)`;
/** A full-frame layer of drawing. */
const Layer: React.FC<{ o?: number; children: React.ReactNode }> = ({ o = 1, children }) => <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, opacity: o }}>{children}</svg>;
const Line: React.FC<{ d: string; color: string; w?: number; o?: number }> = ({ d, color, w = 5, o = 1 }) => (
<>
<path d={d} fill="none" stroke={color} strokeWidth={w * 3} strokeLinecap="round" strokeLinejoin="round" opacity={0.14 * o} />
<path d={d} fill="none" stroke={color} strokeWidth={w} strokeLinecap="round" strokeLinejoin="round" opacity={o} />
</>
);
/* ---------- the sky ---------- */
const FIELD = 2800;
const STARS = Array.from({ length: 900 }, (_, i) => ({ x: hash(i * 3 + 1) * FIELD, y: hash(i * 5 + 2) * FIELD, b: hash(i * 7 + 3) }));
const SCRAPS = Array.from({ length: 48 }, (_, i) => ({ x: hash(i * 11 + 4) * FIELD, y: hash(i * 13 + 5) * FIELD, s: 4 + hash(i * 17 + 6) * 15, sp: 30 + hash(i * 19 + 7) * 90, c: ['#d8d2c0', '#b98a55', '#8f9aa8', '#efe9d8'][i % 4] }));
const onSky = (x: number, y: number, v: V): P => { const p = rotP([mod(x + v.yaw * K, FIELD) - FIELD / 2, mod(y - v.pitch * K, FIELD) - FIELD / 2], v.roll); return [C[0] + p[0], C[1] + p[1]]; };
const Stars: React.FC = () => {
const t = useT();
const v = viewAt(t), w = viewAt(t - 0.05);
const paths = ['', '', ''];
for (const s of STARS) {
const a = onSky(s.x, s.y, v);
if (a[0] < -20 || a[0] > 1100 || a[1] < -20 || a[1] > 1340) continue;
const b = onSky(s.x, s.y, w);
const dx = a[0] - b[0], dy = a[1] - b[1];
const seg = Math.abs(dx) + Math.abs(dy) > 90 ? 'l0 .01' : `l${dx.toFixed(1)} ${dy.toFixed(1)}`;
paths[s.b > 0.97 ? 2 : s.b > 0.78 ? 1 : 0] += `M${a[0].toFixed(1)} ${a[1].toFixed(1)}${seg}`;
}
const fade = 1 - 0.85 * ramp(t, Q + 0.9, Q + 1.9);
const band = onSky(900, 500, v);
return (
<Layer o={fade}>
<defs><radialGradient id="mw"><stop offset="0" stopColor="#8ea6d8" stopOpacity="0.2" /><stop offset="0.5" stopColor="#6d84b8" stopOpacity="0.08" /><stop offset="1" stopColor="#6d84b8" stopOpacity="0" /></radialGradient></defs>
<ellipse cx={band[0]} cy={band[1]} rx={1500} ry={300} fill="url(#mw)" transform={`rotate(${v.roll + 28} ${band[0]} ${band[1]})`} />
<path d={paths[0]} stroke="#9fb6d6" strokeWidth={2.4} strokeLinecap="round" opacity={0.7} />
<path d={paths[1]} stroke="#e4eeff" strokeWidth={3.6} strokeLinecap="round" opacity={0.9} />
<path d={paths[2]} stroke="#fff" strokeWidth={6} strokeLinecap="round" />
</Layer>
);
};
/* The surface is one fixed cap of cloud, land and city lights in a 1000-radius disc. */
const patch = (n: number, seed: number, wide: number, deep: number, min: number, max: number, flat: number) => Array.from({ length: n }, (_, i) => {
const th = (hash(i + seed) - 0.5) * wide, d = Math.pow(hash(i + seed + 110), 1.3) * deep, r = 1000 * (1 - d), rx = min + hash(i + seed + 200) * max;
return { x: Math.sin(th) * r, y: -Math.cos(th) * r, rx, ry: rx * (flat + d * 1.15), deg: (th * 180) / Math.PI };
});
const CAP = patch(90, 401, 0.8, 0.26, 18, 64, 0.08), LAND = patch(11, 71, 0.78, 0.25, 50, 110, 0.1);
const CITIES = Array.from({ length: 160 }, (_, i) => {
const th = (hash(Math.floor(i / 9) + 31) - 0.5) * 0.74 + (hash(i + 900) - 0.5) * 0.05, d = 0.015 + hash(Math.floor(i / 9) + 47) * 0.2 + (hash(i + 1100) - 0.5) * 0.012, r = 1000 * (1 - d);
return `M${(Math.sin(th) * r).toFixed(1)} ${(-Math.cos(th) * r).toFixed(1)}l0 .01`;
}).join('');
const Earth: React.FC = () => {
const t = useT();
const { R, ex, ey, roll } = viewAt(t);
const day = dayK(t), sun = sunK(t);
const sc = Math.max(0.16, Math.sqrt(R / 2300)), A = 62 * sc;
const o = (px: number) => clamp(1 - (px * sc) / R);
const edge = interpolateColors(day, [0, 1], ['#5cc4ff', '#dff5ff']);
const limbY = ey - Math.sqrt(Math.max(1, R * R - (SUN_X - ex) * (SUN_X - ex)));
const sunY = limbY + 46 - 76 * sun, marble = 1 - ramp(R, 110, 460), lit = Math.max(day, marble);
return (
<Layer>
<defs>
<radialGradient id="eh"><stop offset={R / (R + A)} stopColor={edge} stopOpacity={0.82} /><stop offset={lerp(R / (R + A), 1, 0.28)} stopColor="#2f8fe8" stopOpacity={0.3} /><stop offset="1" stopColor="#1a5fc0" stopOpacity="0" /></radialGradient>
<radialGradient id="en"><stop offset={o(420)} stopColor="#040a15" /><stop offset={o(150)} stopColor="#061427" /><stop offset={o(46)} stopColor="#0a2a4c" /><stop offset={o(8)} stopColor="#1d6db0" /><stop offset="1" stopColor={edge} /></radialGradient>
<radialGradient id="ed"><stop offset={o(420)} stopColor="#0d4a86" /><stop offset={o(150)} stopColor="#1c6fb6" /><stop offset={o(40)} stopColor="#5db4ee" /><stop offset="1" stopColor="#e6f7ff" /></radialGradient>
<radialGradient id="sg"><stop offset="0" stopColor="#fff" /><stop offset="0.12" stopColor="#fff4cf" /><stop offset="0.34" stopColor="#ffc861" stopOpacity="0.55" /><stop offset="1" stopColor="#ff8a3d" stopOpacity="0" /></radialGradient>
<linearGradient id="band" gradientUnits="userSpaceOnUse" x1={SUN_X - 720} y1="0" x2={SUN_X + 720} y2="0"><stop offset="0" stopColor="#ff8a3d" stopOpacity="0" /><stop offset="0.36" stopColor="#ff9a4d" /><stop offset="0.5" stopColor="#fff6d8" /><stop offset="0.64" stopColor="#ff9a4d" /><stop offset="1" stopColor="#ff8a3d" stopOpacity="0" /></linearGradient>
<radialGradient id="es"><stop offset="0.7" stopColor="#02060c" /><stop offset="1" stopColor="#02060c" stopOpacity="0" /></radialGradient>
<clipPath id="ec"><circle cx={ex} cy={ey} r={R} /></clipPath>
</defs>
{sun > 0 ? <circle cx={SUN_X} cy={sunY} r={120 + 330 * sun} fill="url(#sg)" opacity={sun} /> : null}
<circle cx={ex} cy={ey} r={R + A} fill="url(#eh)" />
<circle cx={ex} cy={ey} r={R} fill="url(#en)" />
<circle cx={ex} cy={ey} r={R} fill="url(#ed)" opacity={lit} />
<g clipPath="url(#ec)">
<g transform={`translate(${ex} ${ey}) rotate(${roll * 0.25 + t * 0.012}) scale(${R / 1000})`}>
{LAND.map((c, i) => <ellipse key={i} cx={c.x} cy={c.y} rx={c.rx} ry={c.ry} transform={`rotate(${c.deg} ${c.x} ${c.y})`} fill="#2a7a5c" opacity={lit * 0.8} />)}
<path d={CITIES} stroke="#ffcf7a" strokeWidth={1.5} strokeLinecap="round" opacity={0.85 * (1 - day)} />
{CAP.map((c, i) => <ellipse key={i} cx={c.x} cy={c.y} rx={c.rx} ry={c.ry} transform={`rotate(${c.deg} ${c.x} ${c.y})`} fill="#f2f8ff" opacity={lerp(0.06, 0.66, lit)} />)}
</g>
{marble > 0 ? <circle cx={ex - R * 0.42} cy={ey + R * 0.42} r={R * 1.25} fill="url(#es)" opacity={marble} /> : null}
</g>
{sun > 0 ? <circle cx={ex} cy={ey} r={R} fill="none" stroke="url(#band)" strokeWidth={lerp(4, 17, sun)} opacity={sun * (1 - 0.45 * day)} /> : null}
</Layer>
);
};
/** The plan as a place. A speck early on, a station filling the visor by the end. Original design, no agency's or mission's shape. */
const Station: React.FC = () => {
const t = useT(), v = viewAt(t);
const [x, y] = planAt(v);
if (x < -700 || x > 1780 || t < E('plan') - 0.2) return null;
const s = lerp(0.15, 1, easeInOut(ramp(t, Q + 0.9, ALIGN))) + 0.1 * ramp(t, E('docking'), DURATION);
const lit = dayK(t);
const c = (a: string, b: string) => interpolateColors(lit, [0, 1], [a, b]);
const body = c('#2a3543', '#d8d6cc'), dark = c('#10161f', '#5a6470'), wing = c('#14233d', '#27437f'), grid = c('#2d4468', GOLD), hole = c('#05080d', '#0a0e14');
const turn = v.roll + 16 * (1 - easeInOut(ramp(t, Q + 0.9, ALIGN)));
const wings = [-1, 1].flatMap((side) => [240, 400].flatMap((x0) => [-1, 1].map((up) => ({ side, x0, up }))));
return (
<Layer>
<g transform={`translate(${x} ${y}) rotate(${turn}) scale(${s})`}>
<rect x={-640} y={-9} width={1280} height={18} fill={dark} />
{wings.map((w, i) => (
<g key={i} transform={`translate(${w.side * w.x0 + (w.side < 0 ? -140 : 0)} ${w.up < 0 ? -176 : 16})`}>
<rect width={140} height={160} fill={wing} stroke={grid} strokeWidth={3} />
{[35, 70, 105].map((gx) => <rect key={gx} x={gx} y={0} width={2} height={160} fill={grid} />)}
{[40, 80, 120].map((gy) => <rect key={gy} x={0} y={gy} width={140} height={2} fill={grid} />)}
</g>
))}
{[-1, 1].map((u) => <g key={u}><rect x={-62} y={u < 0 ? -420 : 190} width={124} height={230} rx={22} fill={body} /><rect x={-62} y={u < 0 ? -340 : 280} width={124} height={12} fill={dark} /></g>)}
<circle r={232} fill={dark} />
<circle r={190} fill="none" stroke={body} strokeWidth={46} />
<circle r={160} fill={hole} />
{[0, 90, 180, 270].map((a) => <rect key={a} x={-9} y={-224} width={18} height={68} fill={GOLD} transform={`rotate(${a})`} />)}
{[45, 135, 225, 315].map((a) => <rect key={a} x={-22} y={-214} width={44} height={20} rx={4} fill={dark} transform={`rotate(${a})`} />)}
<path d="M-70 0H70M0 -70V70" stroke={body} strokeWidth={5} />
<circle cy={-420} r={10 / s} fill={ALERT} opacity={Math.floor(t * 1.4) % 2 ? 1 : 0.25} />
</g>
</Layer>
);
};
/** The tether: tied to the plan from "tether" on. It snaps as the plan leaves the visor, floats loose through the low point, and reels in on "okay". */
const Tether: React.FC = () => {
const t = useT();
if (t < E('tether') - 0.05 || t > E('point')) return null;
const v = viewAt(t);
const a: P = [540, 1240], w = planAt(v), snap = ramp(t, E('gone'), E('gone') + 0.5), reel = easeIn(ramp(t, E('okay'), E('point')));
const loose: P = [790 + 74 * Math.sin(t * 0.8), 560 + 62 * Math.cos(t * 0.6)];
const e0: P = [lerp(lerp(w[0], loose[0], easeOut(snap)), a[0], reel), lerp(lerp(w[1], loose[1], easeOut(snap)), a[1] - 10, reel)];
const c1: P = [a[0] + 60 + 180 * snap * Math.sin(t * 1.1), a[1] - 190 - 120 * snap];
const c2: P = [e0[0] - 90 - 170 * snap * Math.cos(t * 0.9), e0[1] + 150 + 220 * snap * Math.sin(t * 0.7 + 1)];
const d = `M${a[0]} ${a[1]} C${c1[0].toFixed(1)} ${c1[1].toFixed(1)} ${c2[0].toFixed(1)} ${c2[1].toFixed(1)} ${e0[0].toFixed(1)} ${e0[1].toFixed(1)}`;
const draw = ramp(t, E('tether'), E('tether') + 0.5);
return (
<Layer>
<path d={d} fill="none" stroke="#10161f" strokeWidth={9} pathLength={1} strokeDasharray={1} strokeDashoffset={1 - draw} strokeLinecap="round" />
<path d={d} fill="none" stroke="#e6dfc9" strokeWidth={5} pathLength={1} strokeDasharray={1} strokeDashoffset={1 - draw} strokeLinecap="round" />
{snap > 0 && reel < 1 ? [-34, 8, 46].map((r, i) => <path key={i} d={`M${e0[0].toFixed(1)} ${e0[1].toFixed(1)}l${(Math.cos((r + t * 16) / 57) * 24).toFixed(1)} ${(Math.sin((r + t * 16) / 57) * 24).toFixed(1)}`} stroke="#e6dfc9" strokeWidth={3} strokeLinecap="round" />) : null}
</Layer>
);
};
/* ---------- debris ---------- */
const DRAW: Record<Kind, React.ReactNode> = {
env: <g><rect x={18} y={44} width={164} height={112} rx={5} fill="#efe9d8" /><path d="M18 150 L78 96 M182 150 L122 96" stroke="#cfc7b1" strokeWidth={3} /><path d="M18 46 L100 110 L182 46 Z" fill="#dfd8c2" stroke="#b9b19a" strokeWidth={3} /><rect x={150} y={126} width={22} height={22} fill={ALERT} /></g>,
bag: <g><path d="M44 62 L156 62 L168 180 L32 180 Z" fill="#c49a6c" /><path d="M44 62 L156 62 L152 84 L48 84 Z" fill="#a97f52" /><rect x={112} y={10} width={18} height={62} rx={8} fill="#78b35c" transform="rotate(12 120 40)" /><rect x={84} y={22} width={16} height={46} rx={7} fill="#e8dfa8" transform="rotate(-10 92 44)" /></g>,
box: <g><rect x={26} y={44} width={148} height={124} fill="#b98a55" /><path d="M26 44 L58 22 L142 22 L174 44 Z" fill="#cda06a" /><rect x={88} y={22} width={24} height={146} fill="#e4d6b4" opacity={0.75} /><path d="M46 140 l0 -22 m-7 7 l7 -7 l7 7" fill="none" stroke="#5a3f20" strokeWidth={4} /></g>,
cal: <g><rect x={32} y={30} width={136} height={144} rx={6} fill="#f5f2e8" /><rect x={32} y={30} width={136} height={38} rx={6} fill={ALERT} />{Array.from({ length: 16 }, (_, i) => <rect key={i} x={46 + (i % 4) * 28} y={80 + Math.floor(i / 4) * 22} width={18} height={12} fill={i === 9 ? ALERT : '#a7a397'} />)}</g>,
chat: <g><path d="M26 44 h148 a16 16 0 0 1 16 16 v70 a16 16 0 0 1 -16 16 h-96 l-34 32 v-32 h-18 a16 16 0 0 1 -16 -16 v-70 a16 16 0 0 1 16 -16 Z" fill="#cfe6ff" /><circle cx={66} cy={95} r={10} fill="#3a5a80" /><circle cx={100} cy={95} r={10} fill="#3a5a80" /><circle cx={134} cy={95} r={10} fill="#3a5a80" /></g>,
form: <g><rect x={40} y={18} width={120} height={164} fill="#f3f0e6" />{Array.from({ length: 8 }, (_, i) => <rect key={i} x={54} y={36 + i * 17} width={i % 3 === 2 ? 58 : 92} height={7} fill="#8d8a7f" />)}<rect x={108} y={150} width={40} height={20} fill="none" stroke={ALERT} strokeWidth={3} /></g>,
};
const Debris: React.FC = () => {
const t = useT();
return (
<>
{DEBRIS.map((d, i) => {
const u = (t - d.at) / d.d;
if (u < 0 || u > 1) return null;
const x = lerp(d.from[0], d.to[0], u), y = lerp(d.from[1], d.to[1], u) + Math.sin(u * 5 + i) * 14;
const near = d.s * lerp(0.92, 1.12, u), size = 200 * near, a = d.spin * (t - d.at) + i * 37;
const squash = 0.62 + 0.38 * Math.abs(Math.cos((t - d.at) * 0.9 + i));
return (
<div key={d.label} style={{ position: 'absolute', left: x - size / 2, top: y - size / 2, width: size, height: size }}>
<svg viewBox="0 0 200 200" width={size} height={size} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible', transform: `rotate(${a}deg) scaleX(${squash})` }}>{DRAW[d.kind]}</svg>
<svg viewBox="0 0 200 200" width={size} height={size} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible' }}><path d="M-6 26V-6H26M174 -6H206V26M206 174V206H174M26 206H-6V174" fill="none" stroke={WARN} strokeWidth={5 / near} /></svg>
<div style={{ position: 'absolute', left: -200, right: -200, bottom: size + 12, textAlign: 'center', font: text(i < 3 ? 46 : 36), letterSpacing: '.06em', color: WARN, textShadow: glow('#000', 8), whiteSpace: 'nowrap' }}>{d.label}</div>
</div>
);
})}
</>
);
};
/** Far-off bits of the same mess: more and more of it as the years go by, none of it left once the plan is gone. */
const Scraps: React.FC = () => {
const t = useT(), v = viewAt(t);
const n = Math.floor(SCRAPS.length * ramp(t, E('debris'), E('three')) * (1 - ramp(t, E('gone'), E('low') + 1.2)));
return (
<Layer>
{SCRAPS.slice(0, n).map((s, i) => {
const [x, y] = onSky(s.x + t * s.sp, s.y - t * s.sp * 0.4, v);
return <rect key={i} x={x} y={y} width={s.s} height={s.s * 0.7} fill={s.c} opacity={0.75} transform={`rotate(${t * 40 + i * 31} ${x} ${y})`} />;
})}
</Layer>
);
};
/** Cold gas from a thruster: white, quick, gone. */
const Puff: React.FC<{ at: number; from: P; deg: number; n?: number; reach?: number }> = ({ at, from, deg, n = 90, reach = 1050 }) => {
const t = useT();
if (t < at || t > at + 1.3) return null;
return (
<Layer>
<defs><radialGradient id={`pg${at}`}><stop offset="0" stopColor="#eef8ff" stopOpacity="0.55" /><stop offset="0.5" stopColor="#dff0ff" stopOpacity="0.2" /><stop offset="1" stopColor="#d5ecff" stopOpacity="0" /></radialGradient></defs>
{Array.from({ length: n }, (_, i) => {
const u = (t - at - hash(i + 11) * 0.16) / (0.55 + hash(i + 23) * 0.55);
if (u <= 0 || u >= 1) return null;
const a = ((deg + (hash(i + 37) - 0.5) * (10 + 26 * u)) * Math.PI) / 180, dist = easeOut(u) * reach * (0.5 + hash(i + 53) * 0.8);
return <circle key={i} cx={from[0] + Math.cos(a) * dist} cy={from[1] + Math.sin(a) * dist} r={10 + u * 80} fill={`url(#pg${at})`} opacity={(1 - u) * (1 - u)} />;
})}
</Layer>
);
};
/* ---------- what happens on the glass ---------- */
const Glass: React.FC = () => {
const t = useT();
const gold = easeInOut(ramp(t, Q + 1.35, Q + 2.3));
const flood = easeOut(ramp(t, Q + 0.85, Q + 1.75)) * (1 - 0.52 * easeInOut(ramp(t, Q + 1.5, Q + 2.6)));
const limbY = EARTH.ey - Math.sqrt(EARTH.R * EARTH.R - (SUN_X - EARTH.ex) * (SUN_X - EARTH.ex));
const br = 0.5 + 0.5 * breath(t);
const panic = ramp(t, E('three'), E('low')) * (1 - ramp(t, E('okay'), E('okay') + 0.7));
const fog = (0.15 + 0.3 * panic) * (0.1 + 0.9 * br * br);
const pulse = redK(t) * (0.55 + 0.45 * Math.cos((t - E('low')) * 2 * Math.PI));
return (
<>
{/* the gold sun visor, which comes down by itself once the sun is up */}
<div style={{ position: 'absolute', left: -200, top: lerp(-1900, -985, gold), width: 1480, height: 1900, background: '#eec26c', mixBlendMode: 'multiply', borderRadius: '0 0 50% 50% / 0 0 11% 11%' }} />
<div style={{ position: 'absolute', left: -200, top: lerp(-1900, -985, gold), width: 1480, height: 1900, borderRadius: '0 0 50% 50% / 0 0 11% 11%', boxShadow: `inset 0 -7px 12px rgba(255,226,150,${0.85 * gold})`, opacity: gold > 0 ? 1 : 0 }} />
{flood > 0 ? <div style={{ position: 'absolute', inset: 0, background: `radial-gradient(circle 1500px at ${SUN_X}px ${limbY - 24}px, rgba(255,246,214,${0.95 * flood}) 0, rgba(255,204,110,${0.62 * flood}) 11%, rgba(255,150,70,${0.26 * flood}) 34%, rgba(255,140,60,${0.06 * flood}) 62%, rgba(255,140,60,0) 80%)` }} /> : null}
{/* the glass itself: one long reflection that barely moves */}
<Layer>
<path d={`M-40 ${500 + 8 * Math.sin(t * 0.3)} C200 ${150 + 8 * Math.sin(t * 0.3)} 760 60 1130 250`} fill="none" stroke="#bfe4ff" strokeOpacity={0.055} strokeWidth={30} strokeLinecap="round" />
</Layer>
{/* the alarm lights the inside of the helmet, once a second */}
{pulse > 0 ? <div style={{ position: 'absolute', inset: 0, background: `radial-gradient(ellipse 720px 900px at 540px 680px, rgba(255,40,60,${0.05 * pulse}), rgba(255,40,60,${0.34 * pulse}) 100%)` }} /> : null}
{/* the chin shade, so the lower numbers read over any sky */}
<div style={{ position: 'absolute', left: 0, right: 0, top: 950, height: 350, background: 'linear-gradient(rgba(3,6,10,0),rgba(3,6,10,.7) 26%,rgba(3,6,10,.84))' }} />
{/* breath on the inside of the glass, always moving, so you can watch the breathing with the sound off */}
<div style={{ position: 'absolute', inset: 0, background: `radial-gradient(ellipse ${620 + 220 * panic}px ${300 + 260 * panic}px at 540px 1190px, rgba(218,234,255,${fog}) 0, rgba(218,234,255,${fog * 0.62}) 52%, rgba(218,234,255,0) 100%)` }} />
<div style={{ position: 'absolute', left: 0, right: 0, top: 80, height: 300, background: 'linear-gradient(rgba(3,6,10,.62),rgba(3,6,10,.5) 62%,rgba(3,6,10,0))' }} />
</>
);
};
/* ---------- the HUD ---------- */
const Tape: React.FC = () => {
const t = useT(), v = viewAt(t), tone = toneAt(t);
const heading = mod(70 + v.yaw * 3 + v.roll * 1.5, 360), cells = Array.from({ length: 72 }, (_, i) => i * 15);
return (
<>
<div style={{ position: 'absolute', left: 240, top: 96, width: 600, height: 52, overflow: 'hidden', WebkitMaskImage: 'linear-gradient(90deg,transparent,#000 20%,#000 80%,transparent)', opacity: lerp(0.4, 1, ramp(t, 0, 0.4)) }}>
<div style={{ position: 'absolute', left: 300 - 30, top: 6, whiteSpace: 'nowrap', transform: `translateX(${-(heading / 15) * 60}px)`, font: text(32), color: tone, textShadow: glow(tone, 8) }}>
{cells.map((d) => <span key={d} style={{ display: 'inline-block', width: 60, textAlign: 'center' }}>{d % 90 === 0 ? ['N', 'E', 'S', 'W'][(d / 90) % 4] : d % 45 === 0 ? d % 360 : '|'}</span>)}
</div>
</div>
<svg viewBox="0 0 24 16" width={24} height={16} style={{ position: 'absolute', left: 528, top: 150 }}><path d="M12 0 L24 16 H0 Z" fill={tone} /></svg>
</>
);
};
/* The two arcs that hug the visor: oxygen on the left, suit pressure on the right. */
const o2At = (t: number) => curve([[0, 87], [E('three'), 62], [E('low'), 41], [E('okay'), 14], [ALIGN, 14], [DURATION, 96]], t);
const Arcs: React.FC = () => {
const t = useT(), tone = toneAt(t);
const boot = easeOut(ramp(t, 0, 0.9)), L = 'M108 960 A900 900 0 0 1 108 420', Rr = 'M972 900 A900 900 0 0 0 972 420';
const o2 = o2At(t), psi = 0.72 + 0.012 * Math.sin(t) - 0.18 * redK(t), danger = o2 < 25 ? ALERT : o2 < 45 ? WARN : tone;
return (
<Layer>
<path d={L} fill="none" stroke={tone} strokeOpacity={0.25} strokeWidth={10} />
<path d={Rr} fill="none" stroke={tone} strokeOpacity={0.25} strokeWidth={10} />
<path d={L} fill="none" stroke={danger} strokeWidth={10} pathLength={1} strokeDasharray={1} strokeDashoffset={1 - (boot * o2) / 100} />
<path d={Rr} fill="none" stroke={tone} strokeWidth={10} pathLength={1} strokeDasharray={1} strokeDashoffset={1 - boot * psi} />
</Layer>
);
};
const Ring: React.FC = () => {
const t = useT(), v = viewAt(t), tone = toneAt(t);
const a = lerp(0.3, 1, ramp(t, 0, 0.6));
return (
<svg viewBox="-340 -340 680 680" width={680} height={680} style={{ position: 'absolute', left: C[0] - 340, top: C[1] - 340, opacity: (0.42 + 0.33 * redK(t)) * a }}>
<g transform={`rotate(${v.roll})`}>
{Array.from({ length: 36 }, (_, i) => <rect key={i} x={-1.5} y={-322} width={3} height={i % 9 === 0 ? 28 : i % 3 === 0 ? 16 : 8} fill={tone} transform={`rotate(${i * 10})`} />)}
</g>
<path d="M0 -296 L9 -280 H-9 Z" fill={tone} />
</svg>
);
};
/** The fixed brackets the plan is supposed to sit in. When it leaves, they stay, which is the whole picture. */
const Reticle: React.FC = () => {
const t = useT(), tone = toneAt(t);
const k = back(ramp(t, 0, 0.45));
const lock = t >= ALIGN ? 'LOCKED' : t >= Q + 0.9 ? 'ACQUIRING' : t >= E('cant') ? 'NO LOCK' : t >= E('eleven') ? 'LOCK WEAK' : t >= E('plan') ? 'LOCKED' : 'SEARCHING';
const hit = t >= ALIGN ? 1 + 0.08 * Math.exp(-(t - ALIGN) * 6) : 1, w = 360, h = 420;
return (
<div style={{ position: 'absolute', left: C[0] - w / 2, top: C[1] - h / 2, width: w, height: h, transform: `scale(${lerp(1.5, 1, k) * hit})`, opacity: lerp(0.35, 1, ramp(t, 0, 0.3)) }}>
<svg viewBox={`0 0 ${w} ${h}`} width={w} height={h} style={{ overflow: 'visible' }}>
{[`M0 70V0H70`, `M${w - 70} 0H${w}V70`, `M${w} ${h - 70}V${h}H${w - 70}`, `M70 ${h}H0V${h - 70}`].map((d) => <Line key={d} d={d} color={tone} w={6} />)}
</svg>
<div style={{ position: 'absolute', right: 0, top: -44, font: text(30, 600), letterSpacing: '.1em', color: tone, textShadow: glow(tone, 8), whiteSpace: 'nowrap' }}>{lock}</div>
</div>
);
};
/** The waypoint: a diamond and a name that stay on the plan, wherever the sky has taken it. */
const Waypoint: React.FC = () => {
const t = useT();
if (t < E('plan')) return null;
const v = viewAt(t);
const [x, y] = planAt(v);
if (x > 1320 || x < -240 || y < -240 || y > 1500) return null;
const k = back(ramp(t, E('plan'), E('plan') + 0.2)), grow = easeInOut(ramp(t, Q + 0.9, ALIGN));
const color = t >= ALIGN ? GO : t >= E('eleven') ? WARN : HUD;
const up = lerp(92, 322, grow);
return (
<>
<svg viewBox="-70 -70 140 140" width={140} height={140} style={{ position: 'absolute', left: x - 70, top: y - 70, opacity: 1 - grow, transform: `scale(${lerp(1.7, 1, k)}) rotate(${(1 - k) * 90}deg)`, overflow: 'visible' }}>
<Line d="M0 -56 L56 0 L0 56 L-56 0 Z" color={color} w={6} />
</svg>
<div style={{ position: 'absolute', left: x - 300, width: 600, top: y - up - 50, textAlign: 'center' }}><span style={{ display: 'inline-block', font: text(56), letterSpacing: '.08em', color, textShadow: glow(color, 12), padding: '6px 18px 2px', borderRadius: 10, background: `rgba(5,9,13,${0.78 * grow})` }}>{WAYPOINT}</span></div>
{t >= E('book') - 0.05 && t < E('gone') + 0.5 ? <div style={{ position: 'absolute', left: x - 300, width: 600, top: y + 76, textAlign: 'center', font: text(34, 600), letterSpacing: '.06em', color, textShadow: glow(color, 8) }}>{GOAL}</div> : null}
</>
);
};
const Tasks: React.FC = () => {
const t = useT();
const a = ramp(t, E('tether') + 0.1, E('tether') + 0.35) * (1 - ramp(t, E('two'), E('two') + 0.5));
if (a <= 0) return null;
return (
<div style={{ position: 'absolute', left: 124, top: 422, opacity: a, font: text(29, 600), letterSpacing: '.02em', color: HUD, textShadow: glow(HUD, 6) }}>
{TASKS.map(([e, name], i) => {
const on = t >= E(e), tick = ramp(t, E(e), E(e) + 0.12);
return (
<div key={i} style={{ position: 'absolute', left: 0, top: i * 46, whiteSpace: 'nowrap', height: 40 }}>
<svg viewBox="0 0 32 32" width={32} height={32} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible' }}>
<rect x={2} y={2} width={26} height={26} fill={on ? 'rgba(109,255,158,.18)' : 'rgba(7,10,15,.55)'} stroke={on ? GO : HUD} strokeWidth={3} />
{on ? <path d="M7 15 L14 23 L32 0" fill="none" stroke={GO} strokeWidth={5} strokeLinecap="round" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - tick} /> : null}
</svg>
<span style={{ position: 'absolute', left: 42, top: 1, opacity: on ? 1 : 0.6 }}>{name}</span>
</div>
);
})}
</div>
);
};
/** The HUD picks out the Earth, in case it's a different one. */
const EarthTag: React.FC = () => {
const t = useT();
if (t < E('earth') - 0.05 || t > E('cant')) return null;
const { ex, ey, R } = viewAt(t);
const x = 240, y = ey - Math.sqrt(Math.max(0, R * R - (x - ex) * (x - ex)));
const k = back(ramp(t, E('earth') - 0.05, E('earth') + 0.15));
return (
<div style={{ position: 'absolute', left: x - 70, top: y - 50, width: 140, height: 140, transform: `scale(${lerp(1.5, 1, k)})` }}>
<svg viewBox="0 0 140 140" width={140} height={140} style={{ overflow: 'visible' }}><Line d="M0 34V0H34M106 0H140V34M140 106V140H106M34 140H0V106" color={WARN} w={5} /></svg>
<div style={{ position: 'absolute', left: -100, right: -100, top: -50, textAlign: 'center', font: text(44), letterSpacing: '.05em', color: WARN, textShadow: glow('#000', 8) }}>{EARTH_TAG}</div>
</div>
);
};
/** The arrow to the plan, on "point at the plan": it swings with the sky, and retires when the plan is back inside the brackets. */
const Arrow: React.FC = () => {
const t = useT();
const a = ramp(t, E('point'), E('point') + 0.18);
if (a <= 0) return null;
const [x, y] = planAt(viewAt(t));
const dx = x - C[0], dy = y - C[1], dist = Math.hypot(dx, dy);
const out = 1 - ramp(dist, 380, 520);
if (out >= 1) return null;
const deg = (Math.atan2(dy, dx) * 180) / Math.PI, flow = mod(t * 2.2, 1);
return (
<>
<svg viewBox="-340 -340 680 680" width={680} height={680} style={{ position: 'absolute', left: C[0] - 340, top: C[1] - 340, opacity: a * (1 - out) }}>
<g transform={`rotate(${deg})`}>
{[0, 1, 2].map((i) => <Line key={i} d={`M${112 + i * 54} -46 L${160 + i * 54} 0 L${112 + i * 54} 46`} color={GO} w={10} o={0.35 + 0.65 * mod(flow - i * 0.33 + 1, 1)} />)}
</g>
</svg>
<div style={{ position: 'absolute', left: C[0] - 160, width: 320, top: C[1] - 26, textAlign: 'center', font: text(52), letterSpacing: '.08em', color: GO, textShadow: glow('#000', 8), opacity: a * (1 - out) }}>{WAYPOINT}</div>
</>
);
};
/** The station's ring against the HUD's ring: the marks turn until they meet, and the ring knocks the whole suit. */
const AlignRing: React.FC = () => {
const t = useT();
const a = ramp(t, Q + 1.1, Q + 1.5);
if (a <= 0) return null;
const ping = t >= ALIGN ? ramp(t, ALIGN, ALIGN + 0.9) : 0, color = t >= ALIGN ? GO : HUD;
return (
<svg viewBox="-540 -540 1080 1080" width={1080} height={1080} style={{ position: 'absolute', left: C[0] - 540, top: C[1] - 540, opacity: a, overflow: 'visible' }}>
<Line d="M0 -196 A196 196 0 1 1 -0.01 -196" color={color} w={t >= ALIGN ? 6 + 8 * Math.exp(-(t - ALIGN) * 5) : 4} />
{[0, 90, 180, 270].map((d) => <rect key={d} x={-6} y={-262} width={12} height={46} fill={color} transform={`rotate(${d})`} />)}
{ping > 0 && ping < 1 ? <circle r={196 + 300 * easeOut(ping)} fill="none" stroke={GO} strokeWidth={10 * (1 - ping)} opacity={1 - ping} /> : null}
</svg>
);
};
/** The HUD waking up: a bright line drops down the glass on the very first frames. */
const Boot: React.FC = () => {
const t = useT();
if (t > 0.8) return null;
const y = lerp(60, 1230, easeInOut(ramp(t, 0, 0.75)));
return <div style={{ position: 'absolute', left: 0, right: 0, top: y - 140, height: 150, background: `linear-gradient(transparent, ${HUD}26 80%, ${HUD} 99%)`, opacity: 1 - ramp(t, 0.55, 0.8) }} />;
};
const TopRow: React.FC = () => {
const t = useT(), tone = toneAt(t);
const a = ramp(t, 0.25, 0.6), eta = latest(ETA, t);
const racing = t >= E('debris') && t < E('three') + 0.45;
const etaColor = t >= E('page') ? GO : t >= E('cant') ? ALERT : t >= E('two') ? WARN : tone;
return (
<div style={{ opacity: a }}>
<div style={{ position: 'absolute', left: 70, top: 262, font: text(28, 600), letterSpacing: '.14em', color: tone, opacity: 0.85 }}>ELAPSED</div>
<div style={{ position: 'absolute', left: 70, top: 296, font: text(54), color: racing ? WARN : tone, textShadow: glow(racing ? WARN : tone, 10), whiteSpace: 'nowrap' }}><Mono s={met(t)} w={31} /></div>
{eta ? (
<>
<div style={{ position: 'absolute', right: 70, top: 262, font: text(28, 600), letterSpacing: '.14em', color: tone, opacity: 0.85 }}>PLAN ETA</div>
<div style={{ position: 'absolute', right: 70, top: 296, font: text(54), color: etaColor, textShadow: glow(etaColor, 10), whiteSpace: 'nowrap' }}>{eta[1]}</div>
</>
) : null}
</div>
);
};
const LowerRow: React.FC = () => {
const t = useT(), tone = toneAt(t);
const a = ramp(t, 0.35, 0.7), o2 = o2At(t), yaw = keyedAt(t).yaw;
const o2c = o2 < 25 ? (redK(t) > 0 && Math.floor(t * 4) % 2 ? '#ffb3ba' : ALERT) : o2 < 45 ? WARN : tone;
const lost = t >= E('low') && t < E('point');
const dc = t >= ALIGN - 0.05 ? GO : yaw < 5 ? HUD : yaw < 20 ? WARN : ALERT;
const pop = t >= E('eleven') ? 1 + 0.16 * Math.exp(-(t - E('eleven')) * 6) : t >= E('two') ? 1 + 0.16 * Math.exp(-(t - E('two')) * 6) : 1;
const samples = Array.from({ length: 58 }, (_, i) => `${i ? 'L' : 'M'}${(i * 3).toFixed(0)} ${(32 - 25 * breath(t - (57 - i) * 0.055)).toFixed(1)}`).join('');
return (
<div style={{ opacity: a }}>
<div style={{ position: 'absolute', left: 70, top: 992, font: text(30, 600), letterSpacing: '.14em', color: tone }}>OXYGEN</div>
<div style={{ position: 'absolute', left: 70, top: 1030, font: text(82), color: o2c, textShadow: glow(o2c, 12) }}><Mono s={`${Math.round(o2)}%`} w={49} /></div>
{t >= E('drift') ? (
<>
<div style={{ position: 'absolute', left: 330, width: 420, top: 978, textAlign: 'center', font: text(30, 600), letterSpacing: '.14em', color: dc }}>DRIFT</div>
<div style={{ position: 'absolute', left: 330, width: 420, top: 1014, textAlign: 'center', font: text(100), color: dc, textShadow: glow(dc, 14), transform: `scale(${pop})` }}>
{lost ? <Mono s={Math.floor(t * 9) % 2 ? '--.-°' : '??.?°'} w={60} /> : <Mono s={`${yaw.toFixed(1)}°`} w={60} />}
</div>
</>
) : null}
<div style={{ position: 'absolute', left: 776, top: 992, width: 176, font: text(30, 600), letterSpacing: '.06em', color: tone, whiteSpace: 'nowrap' }}>BREATH <Mono s={String(Math.round(rateAt(t) * 44))} w={20} /></div>
<svg viewBox="0 0 174 64" width={174} height={64} style={{ position: 'absolute', left: 776, top: 1038 }}><Line d={samples} color={tone} w={4} /></svg>
</div>
);
};
const AlertStrip: React.FC = () => {
const t = useT();
const cur = latest(ALERTS, t);
if (!cur || t >= E('cta')) return null;
const color = LEVEL[cur[2]], age = t - E(cur[0]);
const blink = cur[2] === 'alert' && Math.floor(t * 3) % 2 ? 0.5 : 1;
return (
<div style={{ position: 'absolute', left: 130, width: 820, top: 1132, textAlign: 'center' }}>
<span style={{ display: 'inline-block', font: text(46), letterSpacing: '.06em', color, background: 'rgba(7,10,15,.86)', border: `3px solid ${color}`, borderRadius: 10, padding: '9px 26px 8px', whiteSpace: 'nowrap', opacity: blink, transform: `scale(${lerp(1.16, 1, easeOut(clamp(age / 0.14)))})`, textShadow: glow(color, 8) }}>{cur[1]}</span>
</div>
);
};
/** The target inside the ring on "page", then the keyword, which stays. The keyword's size fits itself to its length. */
const Plate: React.FC = () => {
const t = useT();
const page = ramp(t, E('page'), E('page') + 0.16), cta = ramp(t, E('cta'), E('cta') + 0.2);
if (page <= 0) return null;
const big = Math.min(150, 560 / (KEYWORD.length * 0.66));
return (
<>
{cta < 1 ? <div style={{ position: 'absolute', left: C[0] - 170, width: 340, top: C[1] - 50, textAlign: 'center', font: text(Math.min(82, 480 / TARGET.length)), color: GO, textShadow: glow(GO, 16), opacity: page * (1 - cta), transform: `scale(${lerp(1.3, 1, easeOut(page))})` }}>{TARGET}</div> : null}
{cta > 0 ? (
<div style={{ position: 'absolute', left: C[0] - 330, width: 660, top: C[1] - 136, height: 272, background: 'rgba(5,9,13,.88)', border: `5px solid ${GO}`, borderRadius: 18, textAlign: 'center', boxShadow: `0 0 34px rgba(109,255,158,.34)`, transform: `scale(${lerp(1.22, 1, back(cta))})`, opacity: cta }}>
<div style={{ font: text(56), letterSpacing: '.16em', color: GO, marginTop: 30 }}>COMMENT</div>
<div style={{ font: text(big), letterSpacing: '.05em', color: '#f4fff8', textShadow: glow(GO, 18), marginTop: 12 }}>{KEYWORD}</div>
</div>
) : null}
</>
);
};
/* ---------- the helmet ---------- */
const VISOR = (() => {
const a = 640, b = 635, cx = 540, cy = 665, n = 2.6;
const pts = Array.from({ length: 120 }, (_, i) => { const th = (i / 120) * 2 * Math.PI, c = Math.cos(th), s = Math.sin(th); return `${(cx + a * Math.sign(c) * Math.pow(Math.abs(c), 2 / n)).toFixed(1)} ${(cy + b * Math.sign(s) * Math.pow(Math.abs(s), 2 / n)).toFixed(1)}`; });
return `M${pts.join('L')}Z`;
})();
const Helmet: React.FC = () => {
const t = useT();
const red = redK(t) * (0.55 + 0.45 * Math.cos((t - E('low')) * 2 * Math.PI)), warm = dayK(t);
return (
<>
<Layer>
<defs>
<linearGradient id="shell" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#0e131b" /><stop offset="0.68" stopColor="#0a0e14" /><stop offset="1" stopColor={HELMET} /></linearGradient>
<linearGradient id="rim" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="#7a5c26" /><stop offset="0.42" stopColor={GOLD} /><stop offset="0.7" stopColor="#6d5222" /><stop offset="1" stopColor="#c99a42" /></linearGradient>
</defs>
{/* soft shade around the opening, in plain strokes so it stays fast to render */}
{[120, 76, 38].map((w) => <path key={w} d={VISOR} fill="none" stroke="#03060a" strokeOpacity={0.24} strokeWidth={w} />)}
<path d={`M0 0H1080V1920H0Z${VISOR}`} fillRule="evenodd" fill="url(#shell)" />
<path d={VISOR} fill="none" stroke="#05080c" strokeWidth={16} />
<path d={VISOR} fill="none" stroke="url(#rim)" strokeWidth={5} />
<path d={`M0 0H1080V1920H0Z${VISOR}`} fillRule="evenodd" fill={ALERT} opacity={0.2 * red} />
<path d={`M0 0H1080V1920H0Z${VISOR}`} fillRule="evenodd" fill="#ffc46b" opacity={0.09 * warm} />
{/* the suit below the safe area: texture only */}
<ellipse cx={540} cy={2010} rx={690} ry={380} fill="#11171f" stroke="#2a3340" strokeWidth={6} />
<ellipse cx={540} cy={2040} rx={620} ry={330} fill="#0a0e14" stroke={GOLD} strokeOpacity={0.5} strokeWidth={3} />
{Array.from({ length: 19 }, (_, i) => { const a = -Math.PI * (0.16 + (i / 18) * 0.68); return <circle key={i} cx={540 + 655 * Math.cos(a)} cy={2010 + 356 * Math.sin(a)} r={6} fill="#39424f" />; })}
{Array.from({ length: 13 }, (_, i) => <path key={i} d={`M${-20 + i * 95} 1520 Q${20 + i * 95} 1590 ${-40 + i * 100} 1680`} fill="none" stroke="#141a22" strokeWidth={5} />)}
{[[46, 1242], [964, 1242], [46, 1462], [964, 1462]].map(([x, y]) => <circle key={`${x}${y}`} cx={x} cy={y} r={6} fill="#2e3743" />)}
</Layer>
<div style={{ position: 'absolute', left: 0, right: 0, top: 1544, textAlign: 'center', font: text(30, 600), letterSpacing: '.22em', color: '#46515f' }}>IF FOUND, RETURN TO EARTH</div>
</>
);
};
/* ---------- captions: the comms display on the chin ---------- */
const CHUNKS = buildChunks(20, 2);
const Bars: React.FC<{ level: number; color: string }> = ({ level, color }) => (
<span style={{ display: 'inline-flex', gap: 5, alignItems: 'flex-end', height: 30, verticalAlign: -5, marginLeft: 12 }}>
{[0, 1, 2, 3, 4].map((i) => <i key={i} style={{ display: 'block', width: 10, height: 8 + i * 5.5, background: color, opacity: i < Math.round(level * 5) ? 1 : 0.22 }} />)}
</span>
);
const Console: React.FC = () => {
const t = useT(), tone = toneAt(t);
const voice = voiceAt(t), self = t >= E('me'), dead = redK(t);
const rxC = self ? GO : dead > 0.5 ? ALERT : tone;
const qa = Q + 0.25, qb = E('zero') - 0.1;
const idx = CHUNKS.reduce((a, c, i) => (t >= c.t0 - 0.06 ? i : a), -1);
const c = idx >= 0 ? CHUNKS[idx] : null;
const say = (w: Word | undefined) => (w ? (t >= w.t0 - 0.06 && t < w.t1 + 0.06 ? '#ffffff' : t >= w.t1 ? tone : null) : null);
const key = (w: Word | undefined) => t >= E('cta') && w && w.w.toUpperCase().replace(/[^A-Z]/g, '') === KEYWORD;
const lit = Math.floor(ramp(t, qa, ALIGN) * QUIET_WORD.length + 0.001);
return (
<div style={{ position: 'absolute', left: 60, top: 1226, width: 890, height: 250, background: 'linear-gradient(#0c131b,#060a0f)', borderRadius: 22, border: '3px solid #232c37', boxShadow: `0 -8px 24px rgba(0,0,0,.65), inset 0 0 0 2px #05080c, inset 0 2px 0 ${tone}33`, overflow: 'hidden' }}>
<div style={{ position: 'absolute', left: 26, right: 26, top: 12, height: 40, display: 'flex', justifyContent: 'space-between', font: text(29, 600), letterSpacing: '.08em', color: tone, whiteSpace: 'nowrap', borderBottom: `2px solid ${tone}40` }}>
<span>TX<Bars level={voice} color={tone} /></span>
<span style={{ color: dead > 0.5 ? ALERT : self ? GO : tone }}>{CALLSIGN} > <span style={{ textDecoration: t >= E('nocontrol') && !self ? 'line-through' : undefined }}>{self ? CALLSIGN : CONTROL}</span></span>
<span style={{ color: rxC }}>RX<Bars level={self ? voice : 0} color={rxC} /></span>
</div>
{t >= qa && t < qb ? (
<div style={{ position: 'absolute', left: 0, right: 0, top: 100, textAlign: 'center', font: text(92), letterSpacing: '.05em' }}>
{Array.from(QUIET_WORD).map((ch, i) => <span key={i} style={{ color: i < lit ? GO : '#28333d', textShadow: i < lit ? glow(GO, 14) : undefined }}>{ch}</span>)}
</div>
) : c ? c.rows.map((row, ri) => {
const parts = row.s.split(' ');
let at = 0;
return (
<div key={`${idx}-${ri}`} style={{ position: 'absolute', left: 30, right: 20, top: c.rows.length === 1 ? 110 : ri === 0 ? 66 : 148, font: text(68), whiteSpace: 'nowrap' }}>
{parts.map((p, pi) => {
const w = c.words[row.map[at]];
at += p.length + 1;
const on = say(w);
return <span key={pi} style={{ color: key(w) ? GO : on ?? tone, opacity: on || key(w) ? 1 : 0.2, textShadow: on === '#ffffff' ? glow(tone, 14) : undefined }}>{p}{pi < parts.length - 1 ? ' ' : ''}</span>;
})}
</div>
);
}) : null}
{dead > 0 ? <div style={{ position: 'absolute', inset: 0, opacity: dead * (0.14 + 0.1 * hash(Math.floor(t * 20))), background: 'repeating-linear-gradient(0deg,rgba(255,255,255,.5) 0 2px,transparent 2px 5px)', backgroundPosition: `0 ${hash(Math.floor(t * 30)) * 40}px` }} /> : null}
</div>
);
};
/* ---------- the film ---------- */
export const Eva: React.FC = () => (
<Clock>
<Fonts faces={FACES}>
<Stage />
</Fonts>
</Clock>
);
const Stage: React.FC = () => {
const t = useT();
const b = bump(t), float = Math.sin(t * 0.6) * 3;
return (
<AbsoluteFill style={{ background: '#03060c', overflow: 'hidden', font: `600 30px ${TYPE}` }}>
<div style={{ position: 'absolute', inset: 0, isolation: 'isolate', transformOrigin: `${C[0]}px ${C[1]}px`, transform: `translate(${b}px, ${b * 0.5}px) scale(1.03)` }}>
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse 900px 620px at 680px 420px,#0a1224,#04070f 72%)' }} />
<Stars />
<Earth />
<Scraps />
<Station />
<Tether />
<Debris />
<Puff at={E('puff')} from={[-30, 1210]} deg={-38} />
<Puff at={E('point') + 0.5} from={[1110, 420]} deg={158} n={40} reach={560} />
<Glass />
</div>
<div style={{ position: 'absolute', inset: 0, transform: `translate(${b * 0.45}px, ${float + b * 0.25}px)` }}>
<Boot />
<Ring />
<Reticle />
<AlignRing />
<Waypoint />
<EarthTag />
<Arrow />
<Arcs />
<Tape />
<TopRow />
<Tasks />
<LowerRow />
<AlertStrip />
<Plate />
</div>
<Helmet />
<Console />
</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:** an astronaut on a routine radio check. Calm, level, a little bored, very close to the mic, and sealed in a helmet. The voice doesn't change when the numbers do, and that's the whole joke. Never imitate a real astronaut or use real mission audio.
**When to use it:** on every line except the silence. Only two moments show anything. "Control? Control?" gets a little thinner, with a breath before it, and "Okay." comes out on a long out-breath, the first calm one in the film. Keep it dry and small, with no room at all, because a helmet has none. A light telephone-like narrowing suits the radio, as long as every word stays clear. Commas around the keyword in the spoken text help: "Comment, orbit, and..."
- **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.
Everything is heard from inside the suit. There's no sound in a vacuum, so debris never whooshes past and the sunrise makes no noise of its own. What you hear is the helmet, and whatever touches the suit.
| Sound | Length | Prompt idea | When it plays |
|-------|--------|-------------|---------------|
| suit air | 10 s | air circulating inside a sealed helmet, a low soft fan, no hiss, no voices, no music | the bed, all the way through |
| breath | 6 s | slow calm breathing inside a sealed helmet, very close, soft and dry, no voice | under everything, on the rate in `rateAt`: about one breath in 3 s to start, close to one a second at `low`, about one in 5 s after `okay` |
| radio click | 0.3 s | one short two-way radio squelch click, small and warm | the start of every line from `on` to `control`, then never again after `me`, because nobody's on the other end |
| HUD wake | 1 s | a soft electronic power-up, a small rising chirp, warm | `on` |
| lock ping | 0.8 s | one low soft sonar ping, warm, short tail | `plan` |
| task tick | 0.2 s | one small soft electronic tick | `t1`, `t2`, `t3`, `t4` |
| clip | 0.8 s | a metal carabiner clipped onto a line, heard dull through a suit glove | `tether` |
| debris tap | 0.5 s | something light tapping once on the outside of a helmet, dull and muffled, from inside | the first three rows of `HITS`, and softly for later debris |
| readout blip | 0.3 s | a small two-note data blip, soft, low | `drift`, `two`, `eleven` |
| counter | 2 s | a mechanical counter spinning up fast and stopping dead, small and muted | up to `three`, stopping about 0.45 s after it |
| line snaps | 0.8 s | a taut cable going suddenly slack, one dull low twang through a suit | `gone` |
| suit beep | 0.4 s | one soft two-tone caution beep, warm, never piercing | once a second from `low` to `okay`, on the same beats as the red light |
| puff | 1.2 s | a short burst of compressed gas heard through a suit, a soft low thump and a small hiss, not bright | `puff`, then a smaller one at `point` plus 0.5 s |
| visor | 1 s | a helmet visor sliding down a smooth track, a soft glide and a small stop | `quiet` plus 1.35 s |
| ring lock | 1.5 s | a heavy metal docking latch closing, a deep warm clunk through the hull, a small rattle after | `aligned`, the loudest thing in the film |
| latches | 1 s | a row of small metal latches closing one after another, dull, through a hull | `docking` |
- **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:** "Sparse weightless ambient instrumental: one long held synth pad and a slow soft piano, patient and airy, a quiet pulse that grows slightly uneasy over a long stretch, then a slow warm lift as low strings and soft brass open up, ending resolved, no vocals, no drums."
**When it plays:** low under the opening, and it gets thinner from `eleven`. It drains to nothing over 0.7 s from `low`, and stays out, so only the suit air, the breath and the beeps remain. It comes back very low on the word "okay", grows through the burn, and is at its fullest on the voiceless moment, on a bar where the track lifts just as the sun comes up, at `quiet` plus about 0.5 s. It ducks right after the ring lock, so the last lines sit clear.
**Mix, voice first.**
- **Voice:** compress lightly so consonants stay off the limiter, about -14 LUFS.
- **Music:** about 16 to 20 LU under the voice while it speaks, lifted in gaps and on the voiceless moment.
- **Effects:** 7 to 10 LU under the voice overall, ducked about 6 dB under speech, so nothing masks a word.
- **Master:** -14 LUFS integrated, sample-peak ceiling about -2.4 dBFS. AAC encoding adds about 0.8 dB, so check the true peak of the final file. Give the master a 10 ms fade-in, or every view opens on a click.
## 10. Render and QA
1. **Render** without audio. Mux your mix onto it, trimmed to the exact length (`-t`, never `-shortest`), as H.264 at high quality with AAC 320k and `+faststart`.
2. **Sync and length:** the frame count matches the clock, and a cross-correlation of the muxed audio against your mix shows an offset of 0 ms.
3. **Contact sheets:** one frame per second with the safe box drawn. Look at every sheet: nothing cut off, nothing important outside the box, no empty or black frames, and the time of day and the story still agree.
4. **Determinism:** render about 10 spread frames twice with `npx remotion still src/index.ts Film-9x16 out/a.png --frame=N`, and compare each pair with `cmp`; they should match.
5. **Loudness:** -14 LUFS integrated, true peak at or below -1 dBTP.
6. **Words:** transcribe the mix line by line with Whisper. A difference on a plain word is a masked or swallowed word.
7. **Gaps and stills:** no voice gap over 1.0 s outside the voiceless moment (`clock.mjs` warns about these), and no still stretch over 1.2 s or black stretch (`ffmpeg -i out/silent.mp4 -vf "freezedetect=n=0.001:d=1.2,blackdetect=d=0.1" -an -f null -` prints any). A small moving corner can hide a freeze from that check, so trust the contact sheets over it.
For a silent film, skip the checks that need audio (the offset in 2, and 5 and 6) and use `out/silent.mp4` as the final file.
## 11. Traps
- **A real agency creeping in:** a flag on the sleeve, a familiar logo, a station that copies a real one, a real city's name on the radio or a quote from a real flight. Any of these breaks this frame's one hard rule. Check the words too: "Control" is fine, and a real place's name isn't.
- **Sound in a vacuum:** a whoosh on the debris or a roar on the sunrise makes it a trailer. Every sound comes from inside the suit or through it.
- **A caption on the sky:** moving the captions onto the visor looks better in a still, then dies at the sunrise. Keep them on the chin display, and keep HUD numbers off it.
- **A number the voice doesn't say:** `FLIGHT` sets DRIFT and `met` sets ELAPSED. If you change a spoken number, change those too, because people pause on these.
- **A black low point:** the sky goes dark, but the red light, the tilt ring, the tether, the stars and the small Earth all keep moving. It's dim and it's never black.
- **A tumble that's too fast:** ours turns 13 degrees a second. Faster reads as an action scene, and this is the quiet part. If it unwinds too far, the sky whips back on a line that's meant to be calm.
- **The plan coming back from below:** it passes through the lower numbers and fights them. Ours has unwound the tilt before the plan comes back, so it slides in from the right.
- **The gold visor over everything:** it stops at the horizon, so the Earth stays blue. All the way down, the Earth goes brown. It's a `multiply` layer, which needs the `isolation` on its parent.
- **A wall of HUD:** every readout here carries part of the story. If you add one, take one away, and check that the picture still reads with the sound off.
| 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 eva 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 eva skill to make a vertical video for [PLATFORM: Instagram Reels / TikTok / YouTube Shorts]. - **The plan you drifted off:** [TOPIC - the goal, and by when, e.g. "write the book by spring", "launch the side project this year"] - **What pulled you off it:** [DEBRIS - 3 to 9 small real things, e.g. "emails, errands, favors", or "you write it"] - **How long it's been:** [TIME - e.g. "three years", or "you pick, it's a metaphor"] - **The first small move back:** [MOVE - what "one small puff" means here, e.g. "open the file and write page one", or "you write it"] - **In the window:** [WINDOW - "no person: the sky and the station drawn in code", or "my own clip, attached, with the brackets on my face"] - **Length:** [LENGTH - e.g. about 35 seconds] - **Voice:** [VOICE - e.g. "a calm astronaut on a radio check", "my own voice", or "no voice: captions and sound only"] - **Music:** [MUSIC - e.g. "the skill's weightless ambient brief", or your own idea] - **Ending:** [CTA - organic: "Comment KEYWORD and I'll send you THE THING", with the keyword and what you'll send; or sponsored: the line and the URL] - **AI tools:** [AI TOOLS - e.g. "whatever you already have set up", "HYVIRAL", or "none: animation only, I'll record the voice myself"] - **Avoid:** [AVOID - e.g. no real space agency, logo or mission audio, no claims I can't back up] Work in this order: 1. Write the script on the skill's moves, with one line for each turn and what happens on the visor. 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 skillsOld Desktop
An invented old computer reads out everything wrong with your first try.
Frame · 9:16 · 30-40 s
The Reading
A hushed tarot reader takes your small problem as fate.
Frame · 9:16 · 30-40 s
Home Video
An old camcorder walks through one empty place, and the date stamp does the rest.
Frame · 9:16 · 30-40 s