For AI agents: HYVIRAL has a remote MCP server at https://api.hyviral.com/mcp (Streamable HTTP, OAuth 2.1). In a chat app such as Claude or ChatGPT, the person you are helping adds it as a connector; an assistant cannot add one from inside the conversation. In Claude: Customize → Connectors → add a custom connector, then paste the endpoint. A coding agent such as Claude Code can add it itself: claude mcp add --transport http hyviral https://api.hyviral.com/mcp. Every client, step by step: https://hyviral.com/docs/mcp/connect. The whole reference as plain text: https://hyviral.com/llms.txt and https://hyviral.com/llms-full.txt. Pricing as JSON: https://hyviral.com/pricing.json.

Old Desktop

A home computer that never existed fills the screen, and your first attempt is what it's installing. The time remaining goes up, warnings stack across the screen, everything freezes, and a cat's paw presses the key. Then it connects, and one small true thing is left. Every scene is code, and the skill tells your agent which sounds to use and when.

  • Frame
  • 9:16
  • 30-40 s

You need an agent that can run code, such as Claude Code, Claude with code execution, ChatGPT Codex or Cursor, plus Node 20+, ffmpeg and Python 3.

  1. Give your agent the skill

    Paste it into a new chat. Your agent saves it as a skill and waits for your prompt.

    Copy for my agent Copy link
  2. Fill in the prompt

    What's being installed What the computer warns you about The small win at the end Platform Narrator Ending AI tools
    Use the old-desktop skill to make a vertical video for Instagram Reels.
    
    - **What's being installed:** [TOPIC - the first attempt or the thing that went wrong, e.g. "my first website", "my first podcast", "the launch nobody came to"]
    - **What the computer warns you about:** [WARNINGS - three true, embarrassing details, e.g. "it had a visitor counter, and it said four", or "you write them"]
    - **The small win at the end:** [PAYOFF - the tiny real thing that still happened, e.g. "one visitor who wasn't me", or "you write it"]
    - **In the window:** [WINDOW - "no person: everything drawn in code", or "my own clip or photos, attached"]
    - **Length:** [LENGTH - e.g. about 35 seconds]
    - **Voice:** [VOICE - e.g. "a flat machine voice, imitating no real system", "my own voice", or "no voice: captions and sound only"]
    - **Music:** [MUSIC - e.g. "the skill's hold music 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 people or companies, no claims I can't back up]
    
    Keep the computer invented: it must not look or sound like any real operating system, so make your own startup chord and modem sound.
    
    Work in this order:
    
    1. Write the script on the skill's moves, with one line for each turn and what lands on the screen.
    2. Build the plan, the clock and the frame, render a silent animatic, and show me the script, the scene sheet and the animatic. Wait for my OK.
    3. Generate the voice, music and sound effects with the AI tools I named above. If I left that open, tell me which tools you'll use and wait for my OK.
    4. Place a sound on every change, fit the music to the turn, mix voice first, render, and run every QA gate in the skill.
    5. Give me the MP4, a cover frame and a ready-to-paste caption.

    Anything left in [brackets] is your agent's call.

    Copy prompt
  3. Paste it and approve the storyboard

    Your agent writes the script, builds the computer and shows you a silent animatic for your OK before it generates anything, then adds the sound and checks the film. You get the MP4, a cover frame and a caption.

Optional: HYVIRAL. Use any AI tools you like, or none. If you'd rather not juggle separate accounts and API keys, HYVIRAL gives your agent AI video, images, music, voice, captions and the final render in one place.

How to connect

The skill, as plain text

SKILL.md · 1624 lines · 92 KB
Copy Raw file
---
name: old-desktop
description: Make a 30-40 second vertical video where an invented old home computer reads out everything wrong with your first attempt in a flat system voice - the time remaining gets worse, warnings stack up, everything freezes, and one small win gets through. It is its own made-up machine, never any real operating system. The skill covers the sound design, including your own startup chord and modem sound, music fitted to the story and an optional flat machine voice. Use for first attempts and things that went wrong, internet and software nostalgia, tech humor, 'everything wrong with my first...' stories, or when asked for a late-nineties computer, error dialog or installer video, or when given the old-desktop prompt.
---

# Old Desktop

A home computer that never existed fills the screen, and your first attempt is the thing it's installing. The time remaining goes up instead of down, warnings stack across the screen, everything freezes, and a cat's paw presses the key. Then it connects, the windows fall away and one small true thing is left. Each window lands with a clack, so you keep watching to see what it finds next.

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/old-desktop/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/jersey-15`, `@fontsource/doto` and `@fontsource/caprasimo`. 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/OldDesktop.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: windows land and stack up, a progress bar runs backward, everything freezes and dims, a key gets pressed and the windows fall away. 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

An old home computer that never existed: ours is the Crumpet, a harvest-gold machine from an imaginary late nineties, seen head on. Its screen fills the film.

**The rule, and it comes before anything else: this computer is invented, and the film must not read as any real operating system.** Design your own chrome, colors and icons. That means:

- **Not 1990s Windows:** no gray beveled panels, no blue gradient title bars, no minimize, maximize and close trio, no Start button or taskbar, no teal desktop, no Notepad, Recycle Bin or My Computer, no yellow warning triangle, no real system fonts.
- **Not a classic Mac:** no black and white, no pinstriped title bars, no menu bar across the top, no smiling computer, no bomb dialog, no real system fonts.
- **Nothing real at all:** no real product, app or browser names, no real logos, and no real startup or error sound.
- **The test:** before you show the user anything, put your frame next to your memory of 1990s Windows and Mac screenshots, and change whatever resembles them. If the user asks for a real system's look, explain the rule and offer their own invented computer. "Late nineties" should come from the windows, the dialogs, the progress bar and the pixel type, never from copying anyone.

**How ours stays invented.** Keep these, or make your own just as odd:

- **Palette:** a warm kitchen. Case harvest gold `#d9a02e`, desktop clay `#c2622f`, windows cream `#fbeed2`, outlines cocoa `#36190f`. The title color is the severity: olive `#7c8c32` is fine, mustard `#ecae2a` is a warning, tomato `#d23c24` is an error, so the screen gets hotter as things get worse. The terrible website alone gets hot pink `#ff5a7a`.
- **Windows are lunchboxes:** a carry handle on top, a scalloped awning under the title, one round latch, no bevels, and leaf corners (`LEAF`: two round, two square, on every panel). Buttons are pills with a lip, and they say what you mean: HURRY, FINE, I KNOW.
- **The desktop is a kitchen shelf,** with no bar along any edge: COMPOST, DRAWER, JAM, a framed CAT, KETTLE.
- **Its own icons:** a warning is a kettle coming to the boil, an error is a cracked egg, the progress bar is a rail of round beads, the pointer is a fat tomato pick, the wait cursor is a cup of tea, and a connection is two tin cans on a string.
- **Type:** Jersey 15 for the system, Caprasimo for anything shouted, Doto for the amber display. None of them belongs to a real computer.

**Fixed parts:** the case with its name at the top, the screen, an amber display on the chin that holds the captions in one place (y 1268-1472), three lights, and a keyboard below the safe area, where only the top of it shows until the turn.

**Everything knocks the machine:** each window that lands jolts the whole case a little, and the key jolts it most. **The camera leaves the screen once,** for the turn. **Small jokes live on the keys and the page:** SIGH, OOPS, TEA, "BEST VIEWED FROM FAR AWAY", "UNDER CONSTRUCTION since forever".

**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 screen, and the thing being installed gets worse in front of you. All of ours is drawn in code, including the "repeating photo of your cat":

| Story moment | On the screen |
|---|---|
| Boot | The desktop draws in blocks around a spinning crumpet. Welcome. A window delivers a parcel: 1 NEW WEBSITE |
| Install | The installer, a rail of beads filling, a kitchen timer under it |
| Worse | The pointer clicks HURRY. The beads slide backward, the time goes up, the timer winds back |
| Worse again | HURRY again. 11 DAYS, a cobweb and a spider grow in the corner, a spring pops out of the timer |
| The warning | The terrible website loads behind the installer. A kettle warning, and the counter rolls to 0000004 |
| The error | A cracked egg on top: ALL FOUR ARE YOU, with four YOU chips |
| The pop-ups | The same cat photo tiles across the whole page, and nine small warnings stack up, each with the cat in it |
| The low point | Everything stops. The screen dims, the cat photo drifts as a screen saver with a trail, the pointer is a cup of tea, and only the disk light flickers |
| The turn | The camera tilts down to the keyboard. PRESS ANY KEY on the screen above, one big key labeled ANY, and a cat's paw comes in and presses it |
| Connecting | Two tin cans, ME and ANYONE, and a line of dots crawls along a shaking string |
| The quiet | CONNECTED. Every window falls off the screen, the counter rolls to 5, and one small window opens: ONE VISITOR. NOT YOU. "they wrote: nice cat." |
| The button | The page's title comes off a screw, the marquee turns to ONLINE, and the beads fill all the way: INSTALL COMPLETE |

The window also works with the user's own clip or photo: put it in a plain `Box` as a little player, or tile their real photo in place of `CatPhoto`, which is funnier when the photo is theirs. Keep it in the window and let the lunchboxes land on top.

## 5. The story

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

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

**The moves.** Map any topic onto these. A flat machine voice reads out everything wrong with somebody's first attempt, and it never once jokes. Ours is about a first website.

| # | Move | What it does | Our line | On the screen |
|---|------|--------------|----------|---------------|
| 1 | Boot | The hook: a machine greets you, and you know this is going to go badly | "Welcome. You have one new website." | Blocks draw the desktop, a parcel window lands |
| 2 | Install | The promise | "Installing my first website. Time remaining, four minutes." | Installer, beads filling, 4 MINUTES |
| 3 | Worse | The first betrayal | "Time remaining, two hours." | HURRY is clicked and the beads go backward |
| 4 | Worse again | The joke's rule is now clear | "Time remaining, eleven days." | Further back, a cobweb, the timer breaks |
| 5 | Warning | The first embarrassing true detail | "Warning. Your homepage has a visitor counter. It says four." | The page loads, a kettle dialog, the counter rolls |
| 6 | Error | The knife: the same fact, made personal | "All four are you." | A cracked egg on top, four YOU chips |
| 7 | Pop-ups | Escalation | "Warning. Your background is a repeating photo of your cat." | Cats tile the page, nine dialogs stack across the screen |
| 8 | Low point | It stops being about the computer | "This program has stopped responding. So have you." | Freeze, dim, the screen saver drifts. Music out; hard-drive chatter and room hum keep running |
| 9 | The turn | One small action | "Press any key." | The camera tilts down, the paw presses ANY, and the music returns on "key" |
| 10 | Connecting | Suspense | "Connecting. Please hold." | Tin cans, crawling dots, your own modem sound |
| 11 | The quiet | The voiceless payoff, about 3 s | (music at its fullest, the display reads CONNECTED) | The windows fall away, the counter hits 5: ONE VISITOR. NOT YOU. |
| 12 | The button | The real message | "It was terrible. It was online. Install complete." | The title comes loose, the marquee says ONLINE, the beads fill |
| 13 | Call to action | One action | "Comment install and I'll send you the skill that made this." | A last window, COMMENT INSTALL, stays until the end |

**For other topics:** "installing" any first attempt: my first podcast, first app, first business, first apartment, a language you're learning, a launch that went badly. The counter becomes listeners, downloads or customers, and the cat becomes the most embarrassing true detail. "One listener. Not your mother." The trick that makes it land is that nothing here is a joke on its face. Each line is a flat, true statement from a machine, and they move from the thing (the progress bar) to the person (all four are you, so have you). Then the win is tiny on purpose, one real stranger, and that's why it works. Use the user's true details if they give you any, because those are what make people send it to a friend. Our film runs 40 s on the clock's estimate. For about 35 s, drop move 4 and shorten move 7; the bar still goes backward on move 3.

## 6. Plan and animatic (before generating anything)

**1. Write `plan.json`.** `text` is what the captions show. Add `say` when the spoken version differs, with the same number of words. Scenes hold the lines. `lead` and `tail` are seconds of room around a scene's words, and a voiceless scene has `sec` instead of `lines`.

```json
{
  "lines": [
    { "id": "l01", "text": "First line." },
    { "id": "l02", "text": "Comment TOUR and I'll send it to you.", "say": "Comment, tour, and I'll send it to you." }
  ],
  "scenes": [
    { "id": "s01", "lines": ["l01"], "lead": 0.7 },
    { "id": "s02", "sec": 3.4 },
    { "id": "s03", "lines": ["l02"] }
  ]
}
```

**2. Run the clock.** Write `scripts/clock.mjs` as below, and run it from the project folder. A line with no take yet gets an estimate, so you can see the whole film before generating anything. A real take goes in `takes/<id>.json` as `{ "dur": seconds, "words": [{ "w": "First", "t0": 0, "t1": 0.3 }] }` and replaces the estimate.

```js
// node scripts/clock.mjs   plan.json + takes/<id>.json -> src/spec.json (what the frame renders) and clock.json (where each line plays)
import fs from 'node:fs';

const FPS = 30;
const plan = JSON.parse(fs.readFileSync('plan.json', 'utf8'));
const PAUSE = 0.35, LEAD = 0.05, TAIL = 0.25, TAIL_OUT = 1.2;
// No take yet: an estimate for a quick, clear voice, fitted to 53 real takes. A real take in takes/<id>.json replaces it.
function estimate(text) {
  const letters = (s) => (s.match(/[\p{L}\p{N}]/gu) ?? []).length;
  const stops = (text.match(/[.?!]+(?=\s)/g) ?? []).length, commas = (text.match(/,/g) ?? []).length;
  const dur = 0.29 + 0.059 * letters(text) + 0.53 * stops + 0.07 * commas;
  const parts = text.split(/\s+/), total = parts.reduce((n, p) => n + letters(p) + 1, 0);
  let at = 0;
  const words = parts.map((w) => { const d = (dur * (letters(w) + 1)) / total, x = { w, t0: at, t1: at + d }; at += d; return x; });
  return { dur, words };
}
let t = 0.1;
const beats = [], placed = [];
for (const s of plan.scenes) {
  const from = Math.round(t * FPS);
  let end = t + (s.sec ?? 0);
  const words = [];
  if (s.lines?.length) {
    let c = t + (s.lead ?? LEAD);
    s.lines.forEach((id, i) => {
      const line = plan.lines.find((l) => l.id === id);
      const take = fs.existsSync(`takes/${id}.json`) ? JSON.parse(fs.readFileSync(`takes/${id}.json`, 'utf8')) : estimate(line.say ?? line.text);
      // Captions show `text`, even when `say` differs ("Comment, gate"), as long as the word counts match.
      const shown = line.text.split(/\s+/);
      const same = shown.length === take.words.length;
      placed.push({ line: id, t0: c, dur: take.dur, scene: s.id });
      take.words.forEach((w, k) => words.push({ w: same ? shown[k] : w.w, f0: Math.round((c + w.t0) * FPS) - from, f1: Math.round((c + w.t1) * FPS) - from }));
      c += take.dur + (i < s.lines.length - 1 ? PAUSE : 0);
    });
    end = c + (s.tail ?? TAIL);
  }
  const dur = Math.max(1, Math.round(end * FPS) - from);
  beats.push({ id: s.id, from, dur, words, p: s.p ?? {} });
  t = (from + dur) / FPS;
}
const total = Math.round((t + TAIL_OUT) * FPS);
beats[beats.length - 1].dur = total - beats[beats.length - 1].from;
beats[0].dur += beats[0].from;
beats[0].words.forEach((w) => { w.f0 += beats[0].from; w.f1 += beats[0].from; });
beats[0].from = 0;
for (let i = 1; i < placed.length; i++) {
  const gap = placed[i].t0 - (placed[i - 1].t0 + placed[i - 1].dur);
  const quiet = beats.some((b) => !b.words.length && b.from / FPS < placed[i].t0 && (b.from + b.dur) / FPS > placed[i - 1].t0);
  if (gap > 1.0 && !quiet) console.warn(`voice gap ${gap.toFixed(2)} s before ${placed[i].line}: shorten a lead or tail`);
}
fs.mkdirSync('src', { recursive: true });
fs.writeFileSync('src/spec.json', JSON.stringify({ fps: FPS, durationInFrames: total, beats }, null, 1));
fs.writeFileSync('clock.json', JSON.stringify({ fps: FPS, total: total / FPS, lines: placed }, null, 1));
console.log(`${(total / FPS).toFixed(2)} s, ${beats.length} scenes, ${placed.length} lines`);
```

**3. Write `src/frames/story.json`.** Name every change in the story and pin it to a scene, and to a word in it where you can. The frame reads events by name, and a typo fails the render instead of drifting. Every event is checked when the film loads, even one the frame never reads. Our list keeps some events only so a sound can land on them; you can delete those. A `word` matches the first word in its scene that starts with those letters, so "the" also hits "there", and a repeated word can only be reached with `off` from an earlier one.

**4. Build the frame** with the code in step 7, changing the words and schedules to your story. Keep each schedule in time order.

**5. Render a silent animatic.** Make contact sheets at one frame per second with the safe box drawn, and look at every tile: `ffmpeg -i out/silent.mp4 -vf "select='not(mod(n\,30))',drawbox=x=60:y=250:w=890:h=1230:color=red@0.9:t=6,scale=216:-2,tile=8x3" -fps_mode passthrough sheet_%02d.jpg`. Empty tiles at the end of the last sheet are padding, not black frames. A tile that small hides small errors, so also look at about 6 frames at full size, with the window cropped large (`npx remotion still src/index.ts Film-9x16 out/f.png --frame=N`).

**6. Show the user** the script, the scene sheet (one row per scene: its line, what changes on screen, and its seconds) and the animatic. Wait for their OK.

## 7. Build the frame

These files are the real frame that made our demo, with our demo's story in it. You rendered them in step 1 to prove your setup. Now change the story, using the list below. Leave the machinery alone until the film works, and read the code before you change it. Paper, panels and labels are sized to our demo's words, so check anything longer on the contact sheet.

### What to change for your story

Most of this file is the machine and its windows, which you can keep. The story is in the strings at the top, the website and the cat. `core.tsx` and `clock.mjs` don't need to change.

| To change | Where |
|---|---|
| The words in the dialogs | The block at the top: `SITE_NAME`, `NEW_THING`, `TIMES` (each estimate, and the event it lands on), `W1` to `W5`, `MINIS` (the nine stacked warnings), `PROMPT`, `ANY_LABEL`, `HOLD`, `CAN_A`, `CAN_B`, `VISITOR`, `QUIET_CAPTION`, `JOKE_KEYS`. The window titles and button labels are in `Installer`, `Warnings`, `Stopped`, `Connecting`, `OneVisitor`, `Greeting` and `CtaBox` |
| The comment keyword | `KEYWORD` at the top, and `plan.json`. `CtaBox` sizes it and `Captions` lights it |
| The thing that gets installed | `Site`, with `SITE_TITLE`, `MARQUEE`, `MARQUEE_END`, `SITE_SIGN`, `SITE_LINKS`, `SITE_FOOT`. The words MY LINKS, GUESTBOOK and VISITORS are written in `Site` itself |
| The repeating photo | `CatPhoto`, a 150 x 150 drawing, used by `Cats` (the tiled wall), `Saver`, `CatThumb` and the shelf. Redrawing this is the biggest job |
| The counter | `visitorsAt`: it rolls to 4 on `number`, then one more after `fall` |
| The progress bar | `progressAt`: how far it fills and how far each worse estimate throws it back |
| The kitchen timer | `EggTimer`: `wind` is how far it spins on each event, `broke` pops the spring |
| The paw | `Paw` and `ANY`. Replace the cat with your story's culprit, or delete `Paw`: `AnyKey` still goes down on `key` |
| The computer's name and icons | `MACHINE`, `Crumpet`, `Kettle`, `Egg`, `Parcel`, `Can`, and the `ShelfIcon` rows in `Desktop` |
| The pointer | `CUR`: a list of time, x and y in screen pixels, and each stop has to land on its button. `CLICKS` lists when it presses, and `hurry` in `Installer` presses the button |
| The knocks | `HITS`: which events jolt the machine, and how hard |
| The freeze and the dim | `screenTime` and `dimAt`, keyed to `stopped`, `sohave` and `key`. Anything drawn from `st` freezes; anything drawn from `t` keeps moving |
| The camera | `tiltAt` and `TILT`, keyed to `turn` and `key` |

- **Scene ids in the code:** only one: `s11` in `QUIET_WORD`, the voiceless caption. Change it if you renumber scenes.
- **Panels are sized to our words.** `TIMES` holds about 12 characters at 158 px. `W1` wraps to 2 lines, `W3` to 3 and `W4` to 2, and `Say` never shrinks text, so a longer warning runs into the odometer or the button. `MINIS` holds two rows of about 9 characters, `VISITOR` lines about 12. `KEYWORD` and `SITE_TITLE` size themselves.
- **Stack order is the list in `Stage`,** and later items draw on top. `OneVisitor` sits under `Installer` so "complete" can land over it.
- **`boot`, `counter` and `you` are in `story.json` only for sounds to land on.** Delete them if you don't use them.
- **Delete a window's events along with its component.** An event that no longer matches a word stops the render, which is what you want.

### The code

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

```json
{
 "lines": [
  {
   "id": "l01",
   "text": "Welcome. You have one new website."
  },
  {
   "id": "l02",
   "text": "Installing my first website. Time remaining, four minutes."
  },
  {
   "id": "l03",
   "text": "Time remaining, two hours."
  },
  {
   "id": "l04",
   "text": "Time remaining, eleven days."
  },
  {
   "id": "l05",
   "text": "Warning. Your homepage has a visitor counter. It says four."
  },
  {
   "id": "l06",
   "text": "All four are you."
  },
  {
   "id": "l07",
   "text": "Warning. Your background is a repeating photo of your cat."
  },
  {
   "id": "l08",
   "text": "This program has stopped responding. So have you."
  },
  {
   "id": "l09",
   "text": "Press any key."
  },
  {
   "id": "l10",
   "text": "Connecting. Please hold."
  },
  {
   "id": "l11",
   "text": "It was terrible. It was online. Install complete."
  },
  {
   "id": "l12",
   "text": "Comment install and I'll send you the skill that made this.",
   "say": "Comment, install, and I'll send you the skill that made this."
  }
 ],
 "scenes": [
  {
   "id": "s01",
   "lines": [
    "l01"
   ],
   "lead": 0.6,
   "tail": 0.12
  },
  {
   "id": "s02",
   "lines": [
    "l02"
   ],
   "lead": 0.1,
   "tail": 0.1
  },
  {
   "id": "s03",
   "lines": [
    "l03"
   ],
   "lead": 0.08,
   "tail": 0.08
  },
  {
   "id": "s04",
   "lines": [
    "l04"
   ],
   "lead": 0.08,
   "tail": 0.12
  },
  {
   "id": "s05",
   "lines": [
    "l05"
   ],
   "lead": 0.25,
   "tail": 0.08
  },
  {
   "id": "s06",
   "lines": [
    "l06"
   ],
   "lead": 0.1,
   "tail": 0.1
  },
  {
   "id": "s07",
   "lines": [
    "l07"
   ],
   "lead": 0.15,
   "tail": 0.1
  },
  {
   "id": "s08",
   "lines": [
    "l08"
   ],
   "lead": 0.45,
   "tail": 0.3
  },
  {
   "id": "s09",
   "lines": [
    "l09"
   ],
   "lead": 0.6,
   "tail": 0.2
  },
  {
   "id": "s10",
   "lines": [
    "l10"
   ],
   "lead": 0.3,
   "tail": 0.1
  },
  {
   "id": "s11",
   "sec": 3.2
  },
  {
   "id": "s12",
   "lines": [
    "l11"
   ],
   "lead": 0.1,
   "tail": 0.1
  },
  {
   "id": "s13",
   "lines": [
    "l12"
   ],
   "lead": 0.1,
   "tail": 0.1
  }
 ]
}
```

`src/index.ts`:

```ts
import { registerRoot } from 'remotion';
import { Root } from './Root';
registerRoot(Root);
```

`src/Root.tsx`:

```tsx
import React from 'react';
import { Composition } from 'remotion';
import { OldDesktop } from './frames/OldDesktop';
import spec from './spec.json';

export const Root: React.FC = () => (
  <Composition id="Film-9x16" component={OldDesktop} 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": "boot",
   "scene": "s01"
  },
  {
   "id": "welcome",
   "scene": "s01",
   "word": "Welcome"
  },
  {
   "id": "website",
   "scene": "s01",
   "word": "website"
  },
  {
   "id": "install",
   "scene": "s02",
   "word": "Installing"
  },
  {
   "id": "time",
   "scene": "s02",
   "word": "Time"
  },
  {
   "id": "four",
   "scene": "s02",
   "word": "four"
  },
  {
   "id": "two",
   "scene": "s03",
   "word": "two"
  },
  {
   "id": "eleven",
   "scene": "s04",
   "word": "eleven"
  },
  {
   "id": "days",
   "scene": "s04",
   "word": "days"
  },
  {
   "id": "warn1",
   "scene": "s05",
   "word": "Warning"
  },
  {
   "id": "counter",
   "scene": "s05",
   "word": "visitor"
  },
  {
   "id": "says",
   "scene": "s05",
   "word": "says"
  },
  {
   "id": "number",
   "scene": "s05",
   "word": "four"
  },
  {
   "id": "err",
   "scene": "s06",
   "word": "All"
  },
  {
   "id": "you",
   "scene": "s06",
   "word": "you"
  },
  {
   "id": "warn2",
   "scene": "s07",
   "word": "Warning"
  },
  {
   "id": "bg",
   "scene": "s07",
   "word": "background"
  },
  {
   "id": "repeating",
   "scene": "s07",
   "word": "repeating"
  },
  {
   "id": "photo",
   "scene": "s07",
   "word": "photo"
  },
  {
   "id": "cat",
   "scene": "s07",
   "word": "cat"
  },
  {
   "id": "low",
   "scene": "s08"
  },
  {
   "id": "stopped",
   "scene": "s08",
   "word": "stopped"
  },
  {
   "id": "sohave",
   "scene": "s08",
   "word": "So"
  },
  {
   "id": "turn",
   "scene": "s09"
  },
  {
   "id": "press",
   "scene": "s09",
   "word": "Press"
  },
  {
   "id": "key",
   "scene": "s09",
   "word": "key"
  },
  {
   "id": "connecting",
   "scene": "s10",
   "word": "Connecting"
  },
  {
   "id": "hold",
   "scene": "s10",
   "word": "Please"
  },
  {
   "id": "quiet",
   "scene": "s11"
  },
  {
   "id": "fall",
   "scene": "s11",
   "off": 0.25
  },
  {
   "id": "visitor",
   "scene": "s11",
   "off": 1.3
  },
  {
   "id": "terrible",
   "scene": "s12",
   "word": "terrible"
  },
  {
   "id": "online",
   "scene": "s12",
   "word": "online"
  },
  {
   "id": "complete",
   "scene": "s12",
   "word": "complete"
  },
  {
   "id": "cta",
   "scene": "s13"
  }
 ]
}
```

`src/frames/OldDesktop.tsx`:

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

/* Old Desktop: an invented home computer from the late nineties, the Crumpet, and nothing else. It is not any real system: no gray
   bevels, no blue title bars, no bar along the bottom. Windows are little lunchboxes with a carry handle and an awning, the desktop is a
   kitchen shelf, warnings are a boiling kettle, errors are a cracked egg, and the wait cursor is a cup of tea. 1080 x 1920.
   Time comes from the spoken words through story.json, so a re-voice moves every window with its word. */
const E = makeE(story as StoryJson);

/* ---------- the words on screen: change these first ---------- */
const KEYWORD = 'INSTALL';
const MACHINE = 'CRUMPET';
const SITE_TITLE = 'WELCOME TO MY WEBSITE';
const SITE_NAME = 'MY FIRST WEBSITE';
const NEW_THING = '1 NEW WEBSITE';
const MARQUEE = '*** HELLO *** THANKS FOR VISITING *** THIS PAGE IS NEW *** ';
const MARQUEE_END = '*** ONLINE *** ONLINE *** ONLINE *** ONLINE *** ';
const TIMES: { at: number; text: string; bad?: boolean }[] = [
  { at: -1, text: '...' },
  { at: E('four'), text: '4 MINUTES' },
  { at: E('two'), text: '2 HOURS', bad: true },
  { at: E('eleven'), text: '11 DAYS', bad: true },
  { at: E('complete'), text: 'NONE' },
];
const W1 = 'YOUR HOMEPAGE HAS A VISITOR COUNTER.';
const W2 = 'ALL FOUR ARE YOU.';
const W3 = 'YOUR BACKGROUND IS A REPEATING PHOTO OF YOUR CAT.';
const W4 = 'THIS PROGRAM HAS STOPPED RESPONDING.';
const W5 = 'SO HAVE YOU.';
const MINIS: [string, string][] = [['WARNING', 'CAT.'], ['WARNING', 'ALSO CAT.'], ['ALSO WARNING', 'SAME CAT.'], ['WARNING', 'CAT AGAIN.'], ['STILL WARNING', 'STILL CAT.'], ['WARNING', 'MORE CAT.'], ['WARNING', 'CAT (7).'], ['WARNING', 'IS IT A CAT?'], ['WARNING', 'YES.']];
const QUIET_CAPTION = 'CONNECTED';
const PROMPT = 'PRESS ANY KEY', ANY_LABEL = 'ANY';
const HOLD = 'PLEASE HOLD', CAN_A = 'ME', CAN_B = 'ANYONE';
const VISITOR = ['ONE VISITOR.', 'NOT YOU.', 'they wrote: nice cat.'];
const SITE_SIGN = ['UNDER', 'CONSTRUCTION', 'since forever'];
const SITE_LINKS = ['my cat', 'more of my cat', 'links (coming soon)'];
const SITE_FOOT = ['BEST VIEWED', 'FROM FAR AWAY'];
const JOKE_KEYS = ['SIGH', 'OOPS', 'TEA', 'MORE', 'UNDO', 'NO', 'AGAIN', 'HELP', 'WAIT', 'WHY', 'LATER', 'YES?', 'NOPE', 'OK?'];

/* ---------- the look ---------- */
const CLAY = '#c2622f', CLAY2 = '#ad5226', CREAM = '#fbeed2', SAND = '#e8d3a6', INK = '#36190f', TOMATO = '#d23c24', MUSTARD = '#ecae2a', OLIVE = '#7c8c32';
const GOLD = '#d9a02e', GOLD2 = '#b97f1c', GLASS = '#2a130c', AMBER = '#ffb53d', HOT = '#ff5a7a', GINGER = '#d98a3d', STRIPE = '#b0641f', FUR = '#7d4413';
const UI = '"Jersey 15", monospace', DOT = 'Doto, monospace', FAT = 'Caprasimo, serif';
const FACES = [
  { family: 'Jersey 15', file: 'jersey-15-latin-400-normal.woff2' },
  { family: 'Doto', file: 'doto-latin-800-normal.woff2', weight: '800' },
  { family: 'Caprasimo', file: 'caprasimo-latin-400-normal.woff2' },
];
/* Every panel in this world has two round corners and two square ones. Keep it: it is half of why nothing here looks like a real system. */
const LEAF = '34px 9px 34px 9px';
type Tone = 'ok' | 'warn' | 'err';
const TONE: Record<Tone, string> = { ok: OLIVE, warn: MUSTARD, err: TOMATO };

const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
const abs = (x: number, y: number, w?: number, h?: number): React.CSSProperties => ({ position: 'absolute', left: x, top: y, width: w, height: h });

/* The screen. Everything inside it is positioned in the screen's own pixels: add SX and SY to get the film's. */
const SX = 30, SY = 84, SW = 1020, SH = 1152;
/* How far the camera tilts down to the keyboard for the turn, and how tall the world is under it. */
const WORLD_H = 2900;
const TILT = 800;
const tiltAt = (t: number) => TILT * (easeInOut(ramp(t, E('turn') - 0.05, E('turn') + 0.5)) - easeInOut(ramp(t, E('key') + 0.3, E('key') + 0.85)));

/* Everything that lands knocks the whole machine a little. The key knocks it most. */
const HITS: [number, number][] = ([['website', 3], ['four', 2], ['two', 8], ['eleven', 11], ['warn1', 5], ['number', 3], ['err', 9], ['warn2', 5], ['cat', 7], ['stopped', 5], ['key', 16], ['fall', 5], ['complete', 9], ['cta', 5]] as [string, number][]).map(([e, a]) => [E(e), a]);
const shake = (t: number) => HITS.reduce((acc, [e, a]) => (t >= e ? acc + a * Math.sin((t - e) * 60) * Math.exp(-(t - e) * 12) : acc), 0);

/* It freezes for real: from "stopped" until the key, the screen's clock stands still and only the screen saver and the disk light live. */
const screenTime = (t: number) => (t >= E('stopped') && t < E('key') ? E('stopped') : t);
const dimAt = (t: number) => easeOut(ramp(t, E('stopped'), E('sohave') + 0.5)) * (1 - ramp(t, E('key'), E('key') + 0.3));

const progressAt = (t: number) => 0.34 * easeOut(ramp(t, E('install'), E('four')))
  - 0.14 * easeInOut(ramp(t, E('two') - 0.05, E('two') + 0.55)) // it moves backward
  - 0.13 * easeInOut(ramp(t, E('eleven') - 0.05, E('eleven') + 0.55))
  + 0.93 * easeInOut(ramp(t, E('online'), E('complete') + 0.15));
const visitorsAt = (t: number) => 4 * easeOut(ramp(t, E('says'), E('number') + 0.1)) + easeInOut(ramp(t, E('fall') + 0.45, E('fall') + 1.05));

/* ---------- small drawings ---------- */

/* The machine's own mark: a crumpet, seen from above. */
const Crumpet: React.FC<{ size: number }> = ({ size }) => (
  <svg viewBox="0 0 100 100" width={size} height={size} style={{ display: 'block' }}>
    <circle cx="50" cy="50" r="46" fill={MUSTARD} stroke={INK} strokeWidth="5" />
    <circle cx="50" cy="50" r="35" fill="#f4c866" />
    {Array.from({ length: 13 }, (_, i) => <circle key={i} cx={50 + Math.cos(i * 2.4) * (6 + i * 2.2)} cy={50 + Math.sin(i * 2.4) * (6 + i * 2.2)} r={2.4 + hash(i) * 2.2} fill={INK} />)}
  </svg>
);

/** A flash photo of a cat looking straight at the camera, drawn in 150 x 150. It repeats, so it stays simple. */
const CatPhoto: React.FC = () => (
  <g>
    <rect width="150" height="150" fill="#8f8a63" />
    <rect y="108" width="150" height="42" fill="#6b6846" />
    <path d="M24 150 C24 108 126 108 126 150 Z" fill="#c9782f" />
    <path d="M36 70 L30 20 L68 44 Z" fill={GINGER} stroke={FUR} strokeWidth="2.5" /><path d="M114 70 L120 20 L82 44 Z" fill={GINGER} stroke={FUR} strokeWidth="2.5" />
    <path d="M42 56 L38 32 L58 46 Z" fill="#e8a79a" /><path d="M108 56 L112 32 L92 46 Z" fill="#e8a79a" />
    <ellipse cx="75" cy="74" rx="47" ry="40" fill={GINGER} stroke={FUR} strokeWidth="2.5" />
    {[0, 1, 2].map((i) => <rect key={i} x={63 + i * 10} y={36 + Math.abs(i - 1) * 3} width="5" height={20 - Math.abs(i - 1) * 5} rx="2.5" fill={STRIPE} />)}
    <ellipse cx="75" cy="94" rx="22" ry="15" fill="#f3d2a2" />
    {[56, 94].map((x) => <g key={x}><circle cx={x} cy="68" r="13.5" fill="#fbf7a8" stroke={FUR} strokeWidth="2.5" /><circle cx={x} cy="68" r="4" fill={INK} /><circle cx={x + 4} cy="64" r="2.4" fill="#fff" /></g>)}
    <path d="M69 86 L81 86 L75 93 Z" fill="#c8605a" />
    {[[-1, -3], [-1, 3], [1, -3], [1, 3]].map(([s, d], i) => <line key={i} x1={75 + s * 24} y1={95} x2={75 + s * 56} y2={95 + d * 3} stroke="#fff4dc" strokeWidth="1.6" />)}
    <rect width="150" height="150" fill="none" stroke="#efe6c8" strokeWidth="3" />
  </g>
);
const CatThumb: React.FC<{ size: number }> = ({ size }) => <svg viewBox="0 0 150 150" width={size} height={size} style={{ display: 'block' }}><CatPhoto /></svg>;

/** A warning is a kettle coming to the boil. */
const Kettle: React.FC<{ size: number }> = ({ size }) => {
  const st = screenTime(useT());
  return (
    <svg viewBox="0 0 120 120" width={size} height={size} style={{ display: 'block', overflow: 'visible' }}>
      {[0, 1, 2].map((i) => { const u = (st * 1.4 + i / 3) % 1; return <circle key={i} cx={104 + u * 14 + Math.sin(u * 9) * 3} cy={34 - u * 34} r={5 + u * 9} fill={CREAM} stroke={INK} strokeWidth="2.5" opacity={1 - u} />; })}
      <path d="M34 34 C34 8 78 8 78 34" fill="none" stroke={INK} strokeWidth="9" strokeLinecap="round" />
      <path d="M92 66 L114 40 L120 48 L102 90 Z" fill={MUSTARD} stroke={INK} strokeWidth="5" strokeLinejoin="round" />
      <path d="M10 112 C2 62 18 32 56 32 C94 32 110 62 102 112 Z" fill={MUSTARD} stroke={INK} strokeWidth="5" strokeLinejoin="round" />
      <rect x="48" y="24" width="16" height="10" rx="4" fill={INK} />
      <rect x="50" y="50" width="12" height="34" rx="5" fill={INK} /><circle cx="56" cy="97" r="7" fill={INK} />
    </svg>
  );
};

/** An error is a cracked egg. */
const Egg: React.FC<{ size: number }> = ({ size }) => (
  <svg viewBox="0 0 120 120" width={size} height={size} style={{ display: 'block' }}>
    <path d="M60 6 C90 6 106 48 106 72 C106 98 86 114 60 114 C34 114 14 98 14 72 C14 48 30 6 60 6 Z" fill={CREAM} stroke={INK} strokeWidth="5" />
    <path d="M18 62 L38 52 L50 68 L66 50 L80 66 L103 56 L106 72 C106 98 86 114 60 114 C34 114 14 98 14 72 Z" fill={TOMATO} stroke={INK} strokeWidth="5" strokeLinejoin="round" />
    <circle cx="42" cy="88" r="7" fill={MUSTARD} /><circle cx="72" cy="94" r="5" fill={MUSTARD} />
  </svg>
);

const Parcel: React.FC<{ size: number }> = ({ size }) => (
  <svg viewBox="0 0 120 120" width={size} height={size} style={{ display: 'block' }}>
    <rect x="12" y="28" width="96" height="82" rx="10" fill="#cf9455" stroke={INK} strokeWidth="5" />
    <rect x="52" y="28" width="16" height="82" fill={TOMATO} /><rect x="12" y="60" width="96" height="16" fill={TOMATO} />
    <path d="M60 28 C40 2 22 18 60 28 C98 18 80 2 60 28 Z" fill={TOMATO} stroke={INK} strokeWidth="4" />
    <rect x="12" y="28" width="96" height="82" rx="10" fill="none" stroke={INK} strokeWidth="5" />
  </svg>
);

const Can: React.FC<{ size: number; lit?: boolean }> = ({ size, lit }) => (
  <svg viewBox="0 0 100 120" width={size} height={size * 1.2} style={{ display: 'block' }}>
    <path d="M12 22 L12 100 C12 118 88 118 88 100 L88 22 Z" fill={lit ? OLIVE : SAND} stroke={INK} strokeWidth="5" />
    <ellipse cx="50" cy="22" rx="38" ry="14" fill="#a98a56" stroke={INK} strokeWidth="5" />
    {[48, 66, 84].map((y) => <path key={y} d={`M13 ${y} C30 ${y + 12} 70 ${y + 12} 87 ${y}`} fill="none" stroke={INK} strokeWidth="2.5" opacity="0.5" />)}
  </svg>
);

/** The pointer is a fat tomato pick, and the wait cursor is a cup of tea. Neither is an arrow, a watch or an hourglass. */
const CUR: [number, number, number][] = [
  [0, 820, 1000], [E('website'), 880, 1000], [E('two') - 0.12, 862, 650], [E('eleven') - 0.12, 862, 650], [E('warn1') + 0.3, 802, 704], [E('warn2') - 0.14, 812, 974],
  [E('bg'), 520, 560], [E('photo'), 320, 880], [E('stopped'), 770, 640], [E('quiet') + 1.2, 770, 640], [E('terrible'), 300, 300], [E('online'), 720, 340], [E('cta') + 0.3, 532, 928],
];
const CLICKS = [E('two') - 0.12, E('eleven') - 0.12, E('warn2') - 0.14];
const Cursor: React.FC = () => {
  const t = useT(), st = screenTime(t);
  if (t > E('key') && t < E('quiet') + 1.2) return null;
  let x = CUR[0][1], y = CUR[0][2];
  for (let i = 1; i < CUR.length; i++) { const k = easeInOut(ramp(st, CUR[i][0] - 0.34, CUR[i][0])); x = lerp(x, CUR[i][1], k); y = lerp(y, CUR[i][2], k); }
  const dip = CLICKS.some((c) => t >= c && t < c + 0.1) ? 0.84 : 1;
  const waiting = t >= E('stopped') && t < E('key');
  return (
    <div style={{ ...abs(x - 8, y - 8), transform: `scale(${dip})`, transformOrigin: '8px 8px' }}>
      {waiting ? (
        <svg viewBox="0 0 80 80" width={84} height={84}>
          <path d="M60 36 C80 32 78 60 56 56" fill="none" stroke={INK} strokeWidth="7" /><path d="M10 28 L64 28 L58 66 C56 74 18 74 16 66 Z" fill={CREAM} stroke={INK} strokeWidth="5" strokeLinejoin="round" />
          <path d="M22 18 q6 -6 0 -14 M40 18 q6 -6 0 -14" fill="none" stroke={CREAM} strokeWidth="5" strokeLinecap="round" />
        </svg>
      ) : (
        <svg viewBox="0 0 60 60" width={68} height={68}><path d="M6 6 C24 4 50 14 54 26 C56 34 42 34 36 38 C32 42 32 54 26 54 C14 52 4 26 6 6 Z" fill={TOMATO} stroke={INK} strokeWidth="5" strokeLinejoin="round" /></svg>
      )}
    </div>
  );
};

/* ---------- the window: a lunchbox with a handle and an awning ---------- */

const Box: React.FC<{ x: number; y: number; w: number; h: number; title: string; tone: Tone; at: number; gone?: number; fall?: number; seed?: number; bh?: number; fs?: number; children?: React.ReactNode }> = ({ x, y, w, h, title, tone, at, gone, fall, seed = 0.5, bh = 66, fs = 48, children }) => {
  const t = useT();
  if (t < at) return null;
  const q = fall === undefined ? 0 : ramp(t, fall, fall + 0.85);
  const g = gone === undefined ? 0 : ramp(t, gone, gone + 0.12);
  if (q >= 1 || g >= 1) return null;
  const k = back(ramp(t, at, at + 0.14));
  const col = TONE[tone];
  return (
    <div style={{ ...abs(x, y, w, h), transform: `translateY(${easeIn(q) * 1500}px) rotate(${(seed - 0.5) * 60 * q}deg) scale(${lerp(0.84, 1, k) * (1 - g)})` }}>
      <svg width={w * 0.2} height={34} viewBox="0 0 150 34" preserveAspectRatio="none" style={{ position: 'absolute', left: w * 0.4, top: -28 }}><path d="M12 36 C12 4 138 4 138 36" fill="none" stroke={INK} strokeWidth="15" /><path d="M12 36 C12 4 138 4 138 36" fill="none" stroke={col} strokeWidth="6" /></svg>
      <div style={{ position: 'absolute', inset: 0, background: CREAM, border: `5px solid ${INK}`, borderRadius: LEAF, overflow: 'hidden', boxShadow: '10px 12px 0 rgba(54,25,15,.5)', boxSizing: 'border-box' }}>
        <div style={{ height: bh, background: col, display: 'flex', alignItems: 'center', padding: '0 24px', font: `${fs}px/1 ${UI}`, letterSpacing: '.05em', color: tone === 'warn' ? INK : CREAM, whiteSpace: 'nowrap' }}>
          {title}
          <div style={{ marginLeft: 'auto', width: bh * 0.46, height: bh * 0.46, borderRadius: '50%', background: CREAM, border: `4px solid ${INK}`, boxSizing: 'border-box', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><div style={{ width: 5, height: bh * 0.2, background: INK }} /></div>
        </div>
        <div style={{ height: 14, background: `radial-gradient(circle at 14px 0, ${col} 13.5px, transparent 14.5px)`, backgroundSize: '28px 14px' }} />
        {children}
      </div>
    </div>
  );
};

/* Buttons have a lip, and they say what you really mean. */
const Pill: React.FC<{ x: number; y: number; w: number; label: string; down?: boolean; col?: string }> = ({ x, y, w, label, down, col = SAND }) => (
  <div style={{ ...abs(x, y + (down ? 6 : 0), w, 58), background: col, border: `4px solid ${INK}`, borderRadius: 30, boxSizing: 'border-box', boxShadow: `0 ${down ? 1 : 7}px 0 ${INK}`, textAlign: 'center', font: `44px/50px ${UI}`, color: INK, letterSpacing: '.04em' }}>{label}</div>
);

const Say: React.FC<{ x: number; y: number; w: number; size: number; color?: string; children: React.ReactNode }> = ({ x, y, w, size, color = INK, children }) => <div style={{ ...abs(x, y, w), font: `${size}px/${size * 0.96}px ${UI}`, color, letterSpacing: '.015em' }}>{children}</div>;

const Pips: React.FC<{ p: number; col: string }> = ({ p, col }) => {
  const n = 20;
  return (
    <div style={{ display: 'flex', gap: 5, padding: '8px 14px', background: SAND, border: `5px solid ${INK}`, borderRadius: 40 }}>
      {Array.from({ length: n }, (_, i) => { const k = clamp(p * n - i); return <div key={i} style={{ width: 32, height: 32, borderRadius: '50%', background: '#c5a873' }}><div style={{ width: 32, height: 32, borderRadius: '50%', background: col, transform: `scale(${k})` }} /></div>; })}
    </div>
  );
};

/** The visitor counter: rolling digits, the last one moves. */
const Odo: React.FC<{ size: number }> = ({ size }) => {
  const v = visitorsAt(useT());
  const cell: React.CSSProperties = { width: size * 0.68, height: size, background: GLASS, borderRadius: size * 0.14, overflow: 'hidden', position: 'relative', font: `${size * 0.92}px/${size}px ${DOT}`, color: AMBER, textAlign: 'center' };
  return (
    <div style={{ display: 'flex', gap: size * 0.07 }}>
      {[0, 1, 2, 3, 4, 5].map((i) => <div key={i} style={cell}>0</div>)}
      <div style={cell}>{[0, 1, 2, 3, 4, 5, 6].map((d) => <div key={d} style={{ position: 'absolute', left: 0, right: 0, top: (d - v) * size }}>{d}</div>)}</div>
    </div>
  );
};

/* ---------- the machine itself ---------- */

const Case: React.FC = () => {
  const t = useT();
  const disk = t >= E('stopped') && t < E('key') ? hash(Math.floor(t * 17)) > 0.42 : hash(Math.floor(t * 6) + 40) > 0.7;
  const line = t >= E('connecting') ? (t < E('quiet') ? Math.floor(t * 11) % 2 === 0 : true) : false;
  const led = (y: number, on: boolean, col: string) => <div style={{ ...abs(1000, y, 38, 38), borderRadius: '50%', border: `4px solid ${INK}`, background: on ? col : '#6d4a24', boxSizing: 'border-box' }} />;
  return (
    <>
      <div style={{ ...abs(0, 0, 1080, WORLD_H), background: '#70401f', backgroundImage: 'repeating-linear-gradient(92deg, rgba(40,18,6,.16) 0 3px, transparent 3px 58px, rgba(255,210,150,.05) 58px 64px, transparent 64px 131px)' }} />
      <div style={{ ...abs(-30, -60, 1140, 1560), background: GOLD, borderRadius: 70, boxShadow: `inset 0 -22px 0 ${GOLD2}, 0 16px 0 rgba(40,18,6,.5)` }} />
      <svg viewBox="0 0 1080 70" width={1080} height={70} style={{ position: 'absolute', left: 0, top: 6 }}>
        {Array.from({ length: 26 }, (_, i) => <circle key={i} cx={420 + i * 24} cy={18 + (i % 2) * 18} r="6.5" fill={GOLD2} />)}
      </svg>
      <div style={{ ...abs(46, 14), display: 'flex', alignItems: 'center', gap: 12 }}><Crumpet size={52} /><span style={{ font: `42px/1 ${FAT}`, color: INK }}>{MACHINE}</span></div>
      <div style={{ ...abs(SX - 14, SY - 14, SW + 28, SH + 28), background: GLASS, borderRadius: 48 }} />
      <div style={{ ...abs(60, 1268, 890, 204), textAlign: 'center', font: `110px/204px ${FAT}`, color: GOLD2 }}>{MACHINE}</div>
      {led(1290, true, '#b6d34a')}{led(1344, disk, AMBER)}{led(1398, line, TOMATO)}
      <svg viewBox="0 0 60 40" width={60} height={40} style={{ position: 'absolute', left: 990, top: 1446 }}>{[0, 1, 2].map((i) => <rect key={i} x="4" y={4 + i * 12} width="52" height="6" rx="3" fill={GOLD2} />)}</svg>
    </>
  );
};

/* The keyboard is under the desk's edge for most of the film. The camera tilts down to it once, for the turn. */
const KEYW = 138, KEYH = 116, KX = [84, 236, 388, 540, 692, 844];
const KEYBED: [number, number, string][] = [
  ...KX.map((x, i) => [x, 1596, JOKE_KEYS[i]] as [number, number, string]),
  [KX[0], 1726, JOKE_KEYS[6]], [KX[1], 1726, JOKE_KEYS[7]], [KX[0], 1856, JOKE_KEYS[8]], [KX[1], 1856, JOKE_KEYS[9]],
  [KX[4], 1726, JOKE_KEYS[10]], [KX[5], 1726, JOKE_KEYS[11]], [KX[4], 1856, JOKE_KEYS[12]], [KX[5], 1856, JOKE_KEYS[13]],
];
const ANY = { x: KX[2], y: 1726, w: 290, h: 246 };
const Keyboard: React.FC = () => (
  <>
    <div style={{ ...abs(52, 1566, 976, 560), background: GOLD, borderRadius: '50px 14px 50px 14px', boxShadow: `inset 0 -14px 0 ${GOLD2}, 0 14px 0 rgba(40,18,6,.5)` }} />
    {KEYBED.map(([x, y, s], i) => <div key={i} style={{ ...abs(x, y, KEYW, KEYH), background: CREAM, border: `4px solid ${INK}`, boxSizing: 'border-box', borderRadius: '24px 7px 24px 7px', boxShadow: '0 9px 0 #a8834a', textAlign: 'center', font: `40px/96px ${UI}`, color: INK }}>{s}</div>)}
    <div style={{ ...abs(KX[1], 1986, 594, 100), background: CREAM, border: `4px solid ${INK}`, boxSizing: 'border-box', borderRadius: '24px 7px 24px 7px', boxShadow: '0 9px 0 #a8834a' }} />
  </>
);
const AnyKey: React.FC = () => {
  const t = useT(), at = E('key');
  const down = ramp(t, at - 0.03, at + 0.02) * (1 - ramp(t, at + 0.3, at + 0.42));
  const lit = t >= E('press') - 0.1 && t < at + 0.45;
  const burst = ramp(t, at, at + 0.28);
  return (
    <>
      <div style={{ ...abs(ANY.x, ANY.y + down * 12, ANY.w, ANY.h), background: lit ? TOMATO : CREAM, border: `5px solid ${INK}`, boxSizing: 'border-box', borderRadius: '34px 9px 34px 9px', boxShadow: `0 ${lerp(14, 2, down)}px 0 ${lit ? '#8e2410' : '#a8834a'}`, textAlign: 'center', font: `104px/170px ${FAT}`, color: lit ? CREAM : INK }}>{ANY_LABEL}</div>
      {burst > 0 && burst < 1 ? (
        <svg viewBox="-260 -240 520 480" width={520} height={480} style={{ position: 'absolute', left: ANY.x + ANY.w / 2 - 260, top: ANY.y + ANY.h / 2 - 240 }}>
          {Array.from({ length: 12 }, (_, i) => { const a = (i / 12) * Math.PI * 2, r0 = 170 + burst * 40, r1 = r0 + 34 * (1 - burst); return <line key={i} x1={Math.cos(a) * r0} y1={Math.sin(a) * r0} x2={Math.cos(a) * r1} y2={Math.sin(a) * r1} stroke={CREAM} strokeWidth="10" strokeLinecap="round" />; })}
        </svg>
      ) : null}
    </>
  );
};
/** The cat, who has been on this desk all along, presses it. */
const Paw: React.FC = () => {
  const t = useT(), at = E('key');
  const inK = easeOut(ramp(t, E('press') - 0.05, at - 0.02)), outK = easeIn(ramp(t, at + 0.34, at + 0.8));
  if (inK <= 0 || outK >= 1) return null;
  const k = inK * (1 - outK);
  return (
    <svg viewBox={`0 0 1080 ${WORLD_H}`} width={1080} height={WORLD_H} style={{ position: 'absolute', left: 0, top: 0 }}>
      <g transform={`translate(${lerp(1320, ANY.x + ANY.w * 0.84, k)} ${lerp(2320, ANY.y + ANY.h * 0.94, k)}) rotate(-42)`}>
        <rect x="-82" y="10" width="164" height="1500" rx="70" fill={GINGER} stroke={FUR} strokeWidth="6" />
        {[150, 260, 370, 480].map((y) => <path key={y} d={`M-80 ${y} q42 22 84 0`} fill="none" stroke={STRIPE} strokeWidth="24" strokeLinecap="round" />)}
        <ellipse cx="0" cy="10" rx="108" ry="92" fill={GINGER} stroke={FUR} strokeWidth="6" />
        {[-69, -24, 24, 69].map((x, i) => <g key={x}><ellipse cx={x} cy={-58 + Math.abs(i - 1.5) * 14} rx="33" ry="36" fill={GINGER} stroke={FUR} strokeWidth="6" /><ellipse cx={x} cy={-62 + Math.abs(i - 1.5) * 14} rx="17" ry="20" fill="#e8a79a" /></g>)}
        <path d="M0 -14 C22 -14 52 18 44 40 C38 56 18 46 0 46 C-18 46 -38 56 -44 40 C-52 18 -22 -14 0 -14 Z" fill="#e8a79a" />
      </g>
    </svg>
  );
};

/* ---------- the desktop: a kitchen shelf, not a bar ---------- */

const ShelfIcon: React.FC<{ x: number; label: string; children: React.ReactNode }> = ({ x, label, children }) => (
  <div style={{ ...abs(x, 18, 130) }}>
    <div style={{ height: 96, display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}>{children}</div>
    <div style={{ ...abs(-14, 124, 158), textAlign: 'center', font: `30px/1 ${UI}`, color: CREAM, letterSpacing: '.03em' }}>{label}</div>
  </div>
);
const Desktop: React.FC = () => {
  const st = screenTime(useT());
  return (
    <>
      <svg viewBox={`0 0 ${SW} ${SH}`} width={SW} height={SH} style={{ position: 'absolute', left: 0, top: 0 }}>
        <defs><pattern id="weave" width="68" height="68" patternUnits="userSpaceOnUse"><rect width="68" height="68" fill={CLAY} /><path d="M34 6 C52 18 52 50 34 62 C16 50 16 18 34 6 Z" fill={CLAY2} /><circle cx="0" cy="0" r="9" fill={CLAY2} /><circle cx="68" cy="0" r="9" fill={CLAY2} /><circle cx="0" cy="68" r="9" fill={CLAY2} /><circle cx="68" cy="68" r="9" fill={CLAY2} /></pattern></defs>
        <rect width={SW} height={SH} fill="url(#weave)" />
      </svg>
      <ShelfIcon x={60} label="COMPOST">
        <svg viewBox="0 0 100 100" width={92} height={92}><path d={`M50 52 C${46 + Math.sin(st * 2) * 3} 34 ${34 + Math.sin(st * 2) * 4} 28 24 28 C26 44 38 50 50 52 C54 36 66 22 80 22 C78 40 66 50 50 52`} fill={OLIVE} stroke={INK} strokeWidth="4" /><path d="M20 54 L80 54 L72 98 L28 98 Z" fill="#a84f2a" stroke={INK} strokeWidth="5" strokeLinejoin="round" /></svg>
      </ShelfIcon>
      <ShelfIcon x={214} label="DRAWER">
        <svg viewBox="0 0 100 100" width={92} height={92}><rect x="6" y="24" width="88" height="72" rx="8" fill={SAND} stroke={INK} strokeWidth="5" /><rect x="6" y="58" width="88" height="5" fill={INK} /><rect x="40" y="36" width="20" height="8" rx="4" fill={INK} /><rect x="40" y="72" width="20" height="8" rx="4" fill={INK} /></svg>
      </ShelfIcon>
      <ShelfIcon x={368} label="JAM"><svg viewBox="0 0 100 100" width={92} height={92}><rect x="20" y="10" width="60" height="16" rx="4" fill={MUSTARD} stroke={INK} strokeWidth="5" /><path d="M16 30 L84 30 L84 92 C84 98 16 98 16 92 Z" fill={TOMATO} stroke={INK} strokeWidth="5" /><rect x="30" y="48" width="40" height="26" fill={CREAM} stroke={INK} strokeWidth="3" /></svg></ShelfIcon>
      <ShelfIcon x={690} label="CAT"><div style={{ border: `4px solid ${INK}`, lineHeight: 0 }}><CatThumb size={82} /></div></ShelfIcon>
      <ShelfIcon x={850} label="KETTLE"><Kettle size={84} /></ShelfIcon>
      <div style={{ ...abs(0, 116, SW, 18), background: '#6b3a1b', borderTop: `4px solid ${INK}`, borderBottom: `4px solid ${INK}`, boxSizing: 'border-box' }} />
    </>
  );
};

/* The screen draws itself in blocks, and the first ones are already gone on frame 0. */
const Blocks: React.FC = () => {
  const t = useT();
  if (t >= E('welcome') + 0.1) return null;
  const end = E('welcome') + 0.05;
  return <>{Array.from({ length: 120 }, (_, i) => { const col = i % 10, row = Math.floor(i / 10); const at = ((row / 12) * 0.55 + hash(i + 5) * 0.45) * end - 0.06; return t < at ? <div key={i} style={{ ...abs(col * 102, row * 96, 103, 97), background: GLASS }} /> : null; })}</>;
};

const Greeting: React.FC = () => {
  const t = useT();
  const k = 1 - ramp(t, E('install') - 0.22, E('install') - 0.04);
  if (k <= 0) return null;
  const pop = back(ramp(t, -0.08, 0.26));
  const said = ramp(t, E('welcome') - 0.03, E('welcome') + 0.12);
  return (
    <>
      <div style={{ ...abs(SW / 2 - 110, 240 - (1 - k) * 40), transform: `scale(${pop * k}) rotate(${t * 22}deg)` }}><Crumpet size={220} /></div>
      {said > 0 ? <div style={{ ...abs(0, 490, SW), textAlign: 'center', font: `146px/1 ${FAT}`, color: CREAM, textShadow: `7px 8px 0 ${INK}`, opacity: k, transform: `scale(${lerp(0.9, 1, back(said))})` }}>Welcome.</div> : null}
      <Box x={120} y={690} w={780} h={260} title="NEW ARRIVAL" tone="ok" at={E('website') - 0.75} gone={E('install') - 0.2}>
        <div style={{ ...abs(26, 92) }}><Parcel size={134} /></div>
        <Say x={180} y={108} w={570} size={100}>{NEW_THING}</Say>
      </Box>
    </>
  );
};

/* ---------- the installer ---------- */

const Installer: React.FC = () => {
  const t = useT(), st = screenTime(t);
  if (t < E('install') - 0.14) return null;
  const dock = easeInOut(ramp(t, E('warn1') - 0.32, E('warn1') + 0.02));
  const undock = easeInOut(ramp(t, E('online') - 0.15, E('online') + 0.25));
  const x = lerp(lerp(80, 40, dock), 70, undock), y = lerp(lerp(220, 905, dock), 480, undock), s = lerp(lerp(1, 0.5, dock), 1, undock);
  const done = t >= E('complete');
  const cur = TIMES.reduce((a, b, i) => (t >= b.at ? i : a), 0);
  const row = TIMES[cur];
  const flip = easeOut(ramp(t, row.at, row.at + 0.14));
  const web = easeOut(ramp(t, E('eleven') + 0.1, E('days') + 0.5));
  const col = row.bad ? TOMATO : OLIVE;
  const hurry = [E('two') - 0.12, E('eleven') - 0.12].some((c) => t >= c && t < c + 0.14);
  return (
    <div style={{ ...abs(x, y), transformOrigin: '0 0', transform: `scale(${s})` }}>
      <Box x={0} y={0} w={860} h={480} title={done ? 'INSTALL COMPLETE' : 'INSTALLER'} tone="ok" at={E('install') - 0.14}>
        <Say x={36} y={88} w={790} size={50}>{done ? 'INSTALLED' : 'INSTALLING'}: {SITE_NAME}</Say>
        <Say x={36} y={138} w={500} size={44} color="#7a5a3a">TIME REMAINING</Say>
        <div style={{ ...abs(30, 172, 790, 148), overflow: 'hidden' }}>
          <div style={{ font: `158px/148px ${UI}`, color: row.bad ? TOMATO : INK, transform: `translateY(${(1 - flip) * 120}px)`, whiteSpace: 'nowrap' }}>{row.text}</div>
        </div>
        <div style={{ ...abs(30, 322) }}><Pips p={progressAt(t)} col={col} /></div>
        <Say x={36} y={396} w={300} size={62}>{Math.round(clamp(progressAt(t)) * 100)} %</Say>
        <Pill x={608} y={396} w={210} label={done ? 'THANKS' : 'HURRY'} down={hurry} col={MUSTARD} />
        {/* eleven days later: a cobweb in the corner, and whoever made it */}
        {web > 0 ? (
          <svg viewBox="0 0 200 200" width={200} height={200} style={{ position: 'absolute', right: 0, top: 80 }}>
            {[[200, 140], [130, 170], [70, 195], [140, 100], [30, 70], [90, 20]].map(([a, b], i) => <line key={i} x1="200" y1="0" x2={200 - (200 - a) * web} y2={b * web} stroke={INK} strokeWidth="2.5" opacity="0.6" />)}
            {[0.4, 0.7, 1].map((r, i) => <path key={i} d={`M${200 - 170 * r} ${10 * r} Q${200 - 90 * r} ${60 * r} ${200 - 60 * r} ${170 * r}`} fill="none" stroke={INK} strokeWidth="2.5" opacity={0.6 * web} />)}
            <line x1="150" y1="0" x2="150" y2={70 + Math.sin(st * 2.2) * 14} stroke={INK} strokeWidth="2.5" opacity={web} />
            <circle cx="150" cy={78 + Math.sin(st * 2.2) * 14} r="9" fill={INK} opacity={web} />
          </svg>
        ) : null}
      </Box>
    </div>
  );
};

/* Under the installer, a wind-up kitchen timer. Each time the estimate gets worse it winds itself further back, and on the last one it breaks. */
const EggTimer: React.FC = () => {
  const t = useT();
  const at = E('time') - 0.1;
  if (t < at || t > E('warn1') + 0.1) return null;
  const pop = back(ramp(t, at, at + 0.18));
  const wind = 30 * easeOut(ramp(t, E('four') - 0.1, E('four') + 0.3)) + 720 * easeOut(ramp(t, E('two') - 0.05, E('two') + 0.8)) + 2160 * easeOut(ramp(t, E('eleven') - 0.05, E('eleven') + 1));
  const broke = ramp(t, E('eleven') + 0.2, E('eleven') + 0.5);
  const tremble = (Math.sin(t * 47) * 2.2 + Math.sin(t * 31) * 1.4) * (ramp(t, E('two'), E('two') + 0.2) * 0.45 + broke * 0.9);
  return (
    <svg viewBox="0 0 340 400" width={340} height={400} style={{ position: 'absolute', left: 340, top: 700, overflow: 'visible', transform: `rotate(${tremble}deg) scale(${pop})`, transformOrigin: '170px 230px' }}>
      {broke > 0 ? <path d={`M170 80 l-26 ${-14 * broke} l52 ${-14 * broke} l-52 ${-14 * broke} l52 ${-14 * broke} l-26 ${-14 * broke}`} fill="none" stroke={INK} strokeWidth="8" strokeLinejoin="round" /> : null}
      {broke > 0 ? <circle cx="170" cy={80 - 72 * broke} r="17" fill={TOMATO} stroke={INK} strokeWidth="5" /> : null}
      <rect x="146" y="60" width="48" height="34" rx="9" fill={INK} />
      <path d="M58 374 L34 396 M282 374 L306 396" stroke={INK} strokeWidth="14" strokeLinecap="round" />
      <circle cx="170" cy="230" r="158" fill={MUSTARD} stroke={INK} strokeWidth="7" />
      <circle cx="170" cy="230" r="124" fill={CREAM} stroke={INK} strokeWidth="6" />
      {Array.from({ length: 24 }, (_, i) => <rect key={i} x="167" y="114" width="6" height={i % 6 ? 14 : 28} fill={INK} transform={`rotate(${i * 15} 170 230)`} />)}
      <g transform={`rotate(${wind} 170 230)`}>
        <path d="M170 132 L194 214 L146 214 Z" fill={TOMATO} stroke={INK} strokeWidth="5" strokeLinejoin="round" />
        <rect x="150" y="200" width="40" height="110" rx="18" fill={INK} />
        <circle cx="170" cy="230" r="9" fill={CREAM} />
      </g>
    </svg>
  );
};

/* ---------- the website itself, in all its glory ---------- */

const TILE = 150;
const CELLS = Array.from({ length: 49 }, (_, i) => ({ x: (i % 7) * TILE, y: 134 + Math.floor(i / 7) * TILE, k: hash(i + 9) }));
const Cats: React.FC = () => {
  const t = useT();
  if (t < E('bg')) return null;
  return (
    <svg viewBox={`0 0 ${SW} ${SH}`} width={SW} height={SH} style={{ position: 'absolute', left: 0, top: 0 }}>
      <defs><pattern id="cat" width={TILE} height={TILE} patternUnits="userSpaceOnUse" patternTransform="translate(0 134)"><CatPhoto /></pattern></defs>
      {CELLS.map((c, i) => (lerp(E('bg'), E('repeating') + 0.3, c.k) <= t ? <rect key={i} x={c.x} y={c.y} width={TILE} height={TILE} fill="url(#cat)" /> : null))}
    </svg>
  );
};
const Plate: React.FC<{ x: number; y: number; w: number; h: number; bg: string; children: React.ReactNode }> = ({ x, y, w, h, bg, children }) => <div style={{ ...abs(x, y, w, h), background: bg, border: `5px solid ${INK}`, boxSizing: 'border-box', overflow: 'hidden' }}>{children}</div>;
const RAINBOWISH = [MUSTARD, '#fff2c9', '#b6d34a', '#ff9ad6'];

const Site: React.FC = () => {
  const t = useT(), st = screenTime(t);
  const at = E('warn1') - 0.3;
  if (t < at) return null;
  const k = ramp(t, at, at + 0.34);
  const blink = Math.floor(st * 3) % 2 === 0;
  const online = t >= E('online');
  const text = online ? MARQUEE_END : MARQUEE;
  return (
    <div style={{ position: 'absolute', left: 0, top: 0, width: SW, height: SH, clipPath: `inset(134px 0 ${(1 - k) * (SH - 134)}px 0)` }}>
      <div style={{ ...abs(0, 134, SW, SH - 134), background: HOT }} />
      <Cats />
      <div style={{ position: 'absolute', inset: 0, transformOrigin: '60px 226px', transform: `rotate(${5.5 * back(ramp(t, E('terrible'), E('terrible') + 0.32))}deg)` }}>
      <Plate x={40} y={166} w={850} h={120} bg={INK}>
        <div style={{ textAlign: 'center', font: `${Math.min(66, 1180 / SITE_TITLE.length)}px/110px ${FAT}`, whiteSpace: 'nowrap' }}>{Array.from(SITE_TITLE).map((c, i) => <span key={i} style={{ color: RAINBOWISH[(i + Math.floor(st * 5)) % 4] }}>{c}</span>)}</div>
      </Plate>
      </div>
      <Plate x={40} y={296} w={850} h={62} bg={online ? OLIVE : '#5c2a12'}>
        <div style={{ font: `50px/52px ${UI}`, color: online ? CREAM : AMBER, whiteSpace: 'nowrap', transform: `translateX(${-((st * 150) % 620)}px)` }}>{text + text + text}</div>
      </Plate>
      <Plate x={40} y={372} w={410} h={250} bg={MUSTARD}>
        <div style={{ position: 'absolute', inset: 0, background: `repeating-linear-gradient(45deg, ${MUSTARD} 0 26px, ${INK} 26px 52px)` }} />
        <div style={{ ...abs(26, 28, 348, 184), background: CREAM, textAlign: 'center', font: `62px/60px ${UI}`, color: INK, paddingTop: 16, boxSizing: 'border-box' }}>{SITE_SIGN[0]}<br />{SITE_SIGN[1]}<div style={{ font: `36px/42px ${UI}`, color: '#7a5a3a' }}>{SITE_SIGN[2]}</div></div>
      </Plate>
      <Plate x={470} y={372} w={420} h={250} bg={SAND}>
        <div style={{ textAlign: 'center', font: `66px/76px ${UI}`, color: INK, marginTop: 18 }}>VISITORS</div>
        <div style={{ ...abs(21, 100), transform: `scale(${1 + 0.1 * Math.sin(Math.PI * ramp(t, E('fall') + 0.45, E('fall') + 1.05))})` }}><Odo size={70} /></div>
      </Plate>
      <Plate x={40} y={636} w={410} h={240} bg={CREAM}>
        <div style={{ font: `62px/74px ${UI}`, color: TOMATO, marginLeft: 24 }}>MY LINKS</div>
        {SITE_LINKS.map((s) => <div key={s} style={{ font: `44px/50px ${UI}`, color: '#7a2a8a', textDecoration: 'underline', marginLeft: 24 }}>{s}</div>)}
      </Plate>
      <Plate x={470} y={636} w={420} h={240} bg={CREAM}>
        <div style={{ font: `62px/74px ${UI}`, color: TOMATO, marginLeft: 24 }}>GUESTBOOK</div>
        <div style={{ font: `44px/50px ${UI}`, color: INK, marginLeft: 24 }}>{t >= E('visitor') ? '1 entry' : '0 entries'}<br />please sign it</div>
      </Plate>
      <Plate x={470} y={892} w={420} h={124} bg={'#b6d34a'}>
        <div style={{ textAlign: 'center', font: `52px/54px ${UI}`, color: INK, marginTop: 6 }}>{SITE_FOOT[0]}<br />{SITE_FOOT[1]}</div>
      </Plate>
      {blink ? (
        <div style={{ ...abs(868, 150, 146, 92), background: TOMATO, border: `5px solid ${INK}`, borderRadius: '50%', transform: 'rotate(-12deg)', textAlign: 'center', font: `54px/80px ${UI}`, color: CREAM }}>NEW!</div>
      ) : null}
    </div>
  );
};

/* ---------- the warnings ---------- */

const You: React.FC<{ i: number }> = ({ i }) => {
  const t = useT();
  const at = E('err') + 0.1 + i * 0.2;
  return t < at ? null : <div style={{ width: 132, height: 76, background: SAND, border: `4px solid ${INK}`, boxSizing: 'border-box', borderRadius: '18px 5px 18px 5px', textAlign: 'center', font: `52px/66px ${UI}`, color: INK, transform: `scale(${lerp(1.3, 1, easeOut(ramp(t, at, at + 0.1)))})` }}>YOU</div>;
};

const FALL = (i: number) => E('fall') + i * 0.05;
const Warnings: React.FC = () => {
  const t = useT();
  const miniAt = (i: number) => lerp(E('repeating') + 0.05, E('cat') + 0.3, i / (MINIS.length - 1));
  const gone = E('warn2') - 0.1;
  return (
    <>
      <Box x={50} y={340} w={830} h={430} title="WARNING" tone="warn" at={E('warn1') - 0.02} gone={gone}>
        <div style={{ ...abs(28, 96) }}><Kettle size={130} /></div>
        <Say x={186} y={90} w={610} size={70}>{W1}</Say>
        <div style={{ ...abs(186, 232) }}><Odo size={92} /></div>
        <Pill x={596} y={340} w={190} label="FINE" />
      </Box>
      <Box x={110} y={650} w={780} h={400} title="ERROR" tone="err" at={E('err') - 0.04} gone={gone}>
        <div style={{ ...abs(28, 96) }}><Egg size={130} /></div>
        <Say x={182} y={92} w={570} size={84}>{W2}</Say>
        <div style={{ ...abs(182, 190), display: 'flex', gap: 10 }}>{[0, 1, 2, 3].map((i) => <You key={i} i={i} />)}</div>
        <Pill x={540} y={300} w={190} label="I KNOW" down={t >= gone - 0.04} />
      </Box>
      <Box x={40} y={176} w={850} h={304} title="WARNING" tone="warn" at={E('warn2') - 0.04} fall={FALL(9)} seed={0.8}>
        <div style={{ ...abs(26, 104) }}><Kettle size={130} /></div>
        <Say x={176} y={90} w={650} size={66}>{W3}</Say>
      </Box>
      {MINIS.map(([title, text], i) => (
        <Box key={i} x={i % 2 ? 470 + (i >> 1) * 36 : 36 + (i >> 1) * 62} y={i % 2 ? 490 + (i >> 1) * 112 : 520 + (i >> 1) * 112} w={330} h={196} title={title} tone={i % 3 === 2 ? 'err' : 'warn'} at={miniAt(i)} fall={FALL(8 - i)} seed={hash(i + 3)} bh={46} fs={34}>
          <div style={{ ...abs(14, 64) }}><CatThumb size={108} /></div>
          <Say x={134} y={66} w={186} size={50}>{text}</Say>
        </Box>
      ))}
    </>
  );
};

/* The low point's dialog sits above the dimming, so its words stay readable. */
const Stopped: React.FC = () => {
  const t = useT();
  return (
    <Box x={70} y={370} w={800} h={350} title="STOPPED" tone="err" at={E('low') + 0.1} fall={FALL(10)} seed={0.3}>
      <div style={{ ...abs(28, 104) }}><Egg size={130} /></div>
      <Say x={182} y={94} w={600} size={70}>{W4}</Say>
      {t >= E('sohave') ? <Say x={182} y={236} w={600} size={92} color={TOMATO}>{W5}</Say> : null}
    </Box>
  );
};

/* The screen saver is the only thing still moving: the photo of the cat, leaving a trail of itself. */
const Saver: React.FC = () => {
  const t = useT();
  const at = E('stopped') + 0.15;
  if (t < at || t >= E('key')) return null;
  const k = ramp(t, at, at + 0.8);
  const pos = (u: number): [number, number] => [lerp(60, 760, 0.5 + 0.5 * Math.sin(u * 0.62 + 2.4)), lerp(740, 950, 0.5 + 0.5 * Math.sin(u * 0.47 + 4))];
  return (
    <>
      {[5, 4, 3, 2, 1, 0].map((i) => { const [x, y] = pos(t - at - i * 0.16); return <div key={i} style={{ ...abs(x, y), opacity: k * (i ? 0.16 + (5 - i) * 0.07 : 1) }}><CatThumb size={190} /></div>; })}
    </>
  );
};

const Prompt: React.FC = () => {
  const t = useT();
  if (t < E('press') - 0.04 || t >= E('key') + 0.3) return null;
  const cur = Math.floor(t * 8) % 2 === 0;
  return (
    <div style={{ ...abs(30, 986, 890, 136), background: GLASS, border: `5px solid ${INK}`, boxSizing: 'border-box', borderRadius: LEAF, textAlign: 'center', font: `90px/124px ${FAT}`, color: CREAM, whiteSpace: 'nowrap' }}>
      {PROMPT}<span style={{ display: 'inline-block', width: 30, height: 76, marginLeft: 12, background: cur ? AMBER : 'transparent' }} />
    </div>
  );
};

/* ---------- the way out: two tin cans ---------- */

const Connecting: React.FC = () => {
  const t = useT();
  const at = E('connecting') - 0.12, done = E('quiet') + 0.12;
  if (t < at) return null;
  const p = ramp(t, at + 0.1, done);
  const live = t < done;
  const wig = (x: number) => (live ? Math.sin(x * 0.09 + t * 41) * 9 * Math.sin(x * 0.012) + Math.sin(x * 0.21 + t * 73) * 4 * Math.sin(x * 0.012) : 0);
  const n = 22, X0 = 158, X1 = 646, Y = 142;
  const d = Array.from({ length: 41 }, (_, i) => { const x = lerp(X0, X1, i / 40); return `${i ? 'L' : 'M'}${x.toFixed(1)} ${(Y + 26 * Math.sin((i / 40) * Math.PI) + wig(x)).toFixed(1)}`; }).join(' ');
  return (
    <Box x={70} y={300} w={810} h={440} title={live ? 'CONNECTING' : QUIET_CAPTION} tone="ok" at={at} fall={FALL(11)} seed={0.65}>
      <svg viewBox="0 0 800 300" width={800} height={300} style={{ position: 'absolute', left: 0, top: 0 }}>
        <path d={d} fill="none" stroke={INK} strokeWidth="5" />
        {Array.from({ length: n }, (_, i) => { const u = (i + 0.5) / n; if (u > p) return null; const x = lerp(X0, X1, u); const pulse = live && Math.floor(t * 12 - i) % 5 === 0; return <circle key={i} cx={x} cy={Y + 26 * Math.sin(u * Math.PI) + wig(x)} r={pulse ? 13 : 9} fill={TOMATO} stroke={INK} strokeWidth="3" />; })}
      </svg>
      <div style={{ ...abs(48, 92) }}><Can size={112} /></div>
      <div style={{ ...abs(640, 92) }}><Can size={112} lit={!live} /></div>
      <Say x={48} y={232} w={120} size={50}>{CAN_A}</Say>
      <Say x={600} y={232} w={200} size={50}>{CAN_B}</Say>
      {t >= E('hold') - 0.02 || !live ? <div style={{ ...abs(0, 286, 800), textAlign: 'center', font: `112px/120px ${UI}`, color: live ? INK : OLIVE }}>{live ? HOLD : QUIET_CAPTION}</div> : null}
    </Box>
  );
};

const OneVisitor: React.FC = () => {
  const t = useT();
  const at = E('visitor');
  if (t < at) return null;
  const u = Math.floor((t - at - 0.9) * 16);
  return (
    <Box x={170} y={566} w={650} h={336} title="VISITORS" tone="ok" at={at}>
      <Say x={40} y={80} w={580} size={112}>{VISITOR[0]}</Say>
      <Say x={40} y={180} w={580} size={112} color={TOMATO}>{VISITOR[1]}</Say>
      <div style={{ ...abs(40, 276, 580), font: `46px/1 ${UI}`, color: '#7a5a3a', whiteSpace: 'nowrap' }}>{Array.from(VISITOR[2]).map((c, i) => <span key={i} style={{ visibility: i < u ? 'visible' : 'hidden' }}>{c}</span>)}</div>
    </Box>
  );
};

const CtaBox: React.FC = () => {
  const t = useT();
  const at = E('cta') + 0.06;
  if (t < at) return null;
  const size = Math.min(170, 1120 / KEYWORD.length);
  return (
    <Box x={40} y={440} w={850} h={570} title="ONE LAST THING" tone="ok" at={at}>
      <div style={{ ...abs(0, 100, 840), textAlign: 'center', font: `126px/1 ${UI}`, color: INK }}>COMMENT</div>
      <div style={{ ...abs(0, 226, 840), textAlign: 'center', font: `${size}px/1.1 ${FAT}`, color: TOMATO, whiteSpace: 'nowrap', transform: `scale(${1 + 0.02 * Math.sin(t * 5)})` }}>{KEYWORD}</div>
      <Pill x={320} y={456} w={200} label="FINE" down={Math.floor(t * 1.5) % 2 === 1} />
    </Box>
  );
};

/* ---------- the picture tube ---------- */

const Tube: React.FC = () => {
  const t = useT();
  const roll = ((t * 110) % (SH + 300)) - 200;
  const flash = t >= E('key') ? Math.exp(-(t - E('key')) * 7) : 0;
  return (
    <>
      <div style={{ position: 'absolute', inset: 0, background: 'repeating-linear-gradient(rgba(54,25,15,.08) 0 3px, transparent 3px 6px)' }} />
      <div style={{ ...abs(0, roll, SW, 200), background: 'linear-gradient(transparent, rgba(255,235,190,.07), transparent)' }} />
      <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse 72% 72% at 50% 50%, transparent 66%, rgba(40,15,5,.42) 100%)' }} />
      {flash > 0.01 ? <div style={{ position: 'absolute', inset: 0, background: CREAM, opacity: flash * 0.7 }} /> : null}
    </>
  );
};

/* ---------- captions: an amber display, always in the same place ---------- */

const PER_ROW = 20, CELL = 42.4;
const QUIET_T0 = E('quiet') + 0.32;
const QUIET_WORD: Word = { w: QUIET_CAPTION, t0: QUIET_T0, t1: QUIET_T0 + 1.2, scene: 's11' };
const CHUNKS = [...buildChunks(PER_ROW, 2), { t0: QUIET_WORD.t0, rows: [{ s: QUIET_CAPTION, map: Array.from(QUIET_CAPTION, () => 0) }], words: [QUIET_WORD] }].sort((a, b) => a.t0 - b.t0);
const Captions: React.FC = () => {
  const t = useT();
  const idx = CHUNKS.reduce((a, c, i) => (t >= c.t0 - 0.06 ? i : a), -1);
  const c = idx < 0 ? null : CHUNKS[idx];
  const key = (w: Word) => w.w.toUpperCase().replace(/[^A-Z]/g, '') === KEYWORD;
  const jolt = shake(t);
  return (
    <div style={{ ...abs(60, 1268, 890, 204), background: GLASS, border: `6px solid ${INK}`, boxSizing: 'border-box', borderRadius: LEAF, boxShadow: `inset 0 0 0 4px #4a2414, 0 7px 0 ${GOLD2}`, transform: `translate(${jolt}px, ${jolt * 0.4}px)`, overflow: 'hidden' }}>
      {c?.rows.map((row, ri) => (
        <div key={`${idx}-${ri}`} style={{ position: 'absolute', left: 20, top: c.rows.length === 1 ? 52 : 8 + ri * 90, height: 90 }}>
          {Array.from(row.s).map((ch, i) => {
            const wi = row.map[i];
            if (wi < 0) return null;
            const w = c.words[wi];
            if (t < w.t0 - 0.05) return null;
            const hot = (t >= w.t0 - 0.03 && t < w.t1 + 0.1) || (t >= E('cta') && key(w));
            return <span key={i} style={{ position: 'absolute', left: i * CELL, top: 0, width: CELL, textAlign: 'center', font: `76px/90px ${DOT}`, color: hot ? '#fff3cf' : AMBER }}>{ch}</span>;
          })}
        </div>
      ))}
      <div style={{ position: 'absolute', inset: 0, background: 'repeating-linear-gradient(rgba(42,19,12,.16) 0 2px, transparent 2px 7px)' }} />
    </div>
  );
};

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

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

const Stage: React.FC = () => {
  const t = useT();
  const jolt = shake(t), dim = dimAt(t);
  return (
    <AbsoluteFill style={{ background: '#70401f', overflow: 'hidden' }}>
      <div style={{ ...abs(0, 0, 1080, WORLD_H), transform: `translate(${jolt}px, ${jolt * 0.45 - tiltAt(t)}px)` }}>
        <Case />
        <Keyboard />
        <div style={{ ...abs(0, SY + SH + 14, 1080, WORLD_H - SY - SH), background: `rgba(24,10,6,${0.5 * dim})` }} />
        <AnyKey />
        <Paw />
        <div style={{ ...abs(SX, SY, SW, SH), borderRadius: 36, overflow: 'hidden', background: CLAY }}>
          <Desktop />
          <Blocks />
          <Greeting />
          <EggTimer />
          <Site />
          <OneVisitor />
          <Installer />
          <Warnings />
          {dim > 0 ? <div style={{ position: 'absolute', inset: 0, background: `rgba(30,12,8,${0.74 * dim})` }} /> : null}
          <Stopped />
          <Saver />
          <Prompt />
          <Connecting />
          <CtaBox />
          <Cursor />
          <Tube />
        </div>
      </div>
      <Captions />
    </AbsoluteFill>
  );
};
```

## 8. Voice (optional)

A voice is worth adding when the world has a speaker of its own, because the deadpan delivery is half the joke. Skip it for a sound-off post, or when the user would rather not have one: the film still works.

**The persona:** a machine's built-in voice, flat, polite and entirely unbothered, reading everything at one level as if it were the weather. It isn't any real assistant, phone or computer voice, so never ask for one or imitate one; choose a plain voice and take the life out of it.

**When to use it:** on every line except the quiet moment. Keep the pitch level, leave a clean stop at each full stop, and read "So have you." in exactly the same tone as the time remaining, because that is the whole joke. Numbers are plain words. Give it a touch of machine: roll off the lows and highs a little, as if through a small speaker, and add a very short room. Don't warm up at the end. The line "It was terrible. It was online." works best with nothing added.

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

**Make the startup chord and the modem sound yourself, and never use a real one.** No real computer's startup, error or shutdown sound, and no recording of a real modem. If a sound reminds you of one, change it.

| Sound | Length | Prompt idea | When it plays |
|-------|--------|-------------|---------------|
| startup chord | 2.5 s | an original warm startup chord: three soft rising notes on a felt mallet and an old electric piano, opening into a slightly wobbly major chord, tape warmth, unlike any known computer startup sound | frame 0, swelling into "Welcome", on `boot` |
| blocks | 0.5 s | a quick run of tiny soft wooden ticks, dry | under the desktop drawing in |
| window lands | 0.4 s | a small tin lunchbox lid clicked shut, a soft metallic clack, warm, dark | every window that lands, quick runs of it on the pop-ups |
| pointer click | 0.2 s | one small plastic button clicked, close and dry | HURRY, I KNOW |
| beads back | 0.6 s | wooden abacus beads sliding back along a rail | the bar moving backward, on `two` and `eleven` |
| timer wind | 1 s | a wind-up kitchen timer being wound fast, a dry ratchet, close | under the worse estimates, longer on `eleven` |
| spring | 0.7 s | a small metal spring popping out of a clock, a soft low boing | `eleven`, about 0.3 s after the word |
| kettle | 1.5 s | a kettle starting to whistle, a low, soft rising hiss, cut short, not piercing | each warning, on `warn1` and `warn2` |
| egg | 0.6 s | one egg cracked on the rim of a bowl, close and wet | the error, on `err` |
| counter | 0.6 s | a small mechanical counter clicking over, a few dry ticks | `number`, and again when it reaches 5 |
| hard drive | 6 s | an old hard drive seeking, dry irregular clicks and a faint whir, close, low | the low point, from `stopped` to `key`, rising as the music drains |
| room hum | 10 s | a quiet room at night, a faint computer fan and a soft picture-tube whine, filtered low, no voices, no music | the bed, all the way through |
| key | 0.4 s | one big chunky mechanical key pressed once, a deep plastic thock, close | exactly on `key`, the loudest sound in the film |
| modem | 3 s | an invented dial-up handshake, made up: two soft rising tones, a warble, a fuzzy warm burst, low in pitch, not a recording of any real modem, no real telephone tones | `connecting` until `quiet`, under the voice |
| connected | 1.2 s | a warm two-note wood block chime, rising, gentle | `quiet`, when the dots reach the far can |
| windows fall | 1.5 s | a stack of tin lunchboxes sliding off a shelf and clattering away below, soft and distant | `fall` |
| complete | 1.2 s | an original bright three-note marimba flourish, warm, unlike any known system sound | `complete` |

- **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:** "Innocent home-computer hold music: warm soft synth pads, a plinky electric piano, a lazy drum machine with brushed hats, gently wobbling tape, a little out of tune, then a section that opens into a hopeful, warm major chord, no vocals."

**When it plays:** low and cheerful after the startup chord, and getting fussier under the warnings. It drains to nothing over 0.7 s on "responding" and stays out through the low point, so only hard-drive chatter and room hum remain. It comes back on "key", low at first, and climbs under the modem. It is at its fullest on the voiceless moment, on a bar where the track opens up, then ducks under the button 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

- **Drifting into a real system:** gray, blue, bevels, a bar along the bottom or a stripe across a title bar will do it in one edit. Run the test in the rule every time you change the chrome.
- **A freeze that doesn't freeze:** the screen's clock is `screenTime`. Draw anything on the screen that should stop from `st`, and only the saver and the disk light from `t`.
- **A black low point:** the dim stops at 74 %, and the dialog, the cat photo and the disk light stay lit. It's never black.
- **The saver over the words:** the saver's path in `Saver` stays below the dialog. If you move the dialog, move the path.
- **The tilt leaves the desk short:** `WORLD_H` has to be at least 1920 plus `TILT`. `Prompt` has to sit where it's still inside the safe area once tilted: its y in screen pixels plus 84, minus `TILT`, must be at least 250.
- **The display is not on the machine:** `Captions` sits in film space, so it stays put while the case moves behind it. That's why it has its own frame.
- **Tiled photos as image URLs can drop tiles for a frame:** ours are one inline SVG `<pattern>`. The pattern's id has to be unique in the page.
- **Windows that look stuck on a contact sheet:** dialogs don't move by design, so check that something is moving nearby. The marquee, the title colors and the pointer keep the page alive.

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

## 12. Deliver

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

The prompt

PROMPT.md · 34 lines · 3 KB
Copy Raw file
# The prompt

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

- **What's being installed:** [TOPIC - the first attempt or the thing that went wrong, e.g. "my first website", "my first podcast", "the launch nobody came to"]
- **What the computer warns you about:** [WARNINGS - three true, embarrassing details, e.g. "it had a visitor counter, and it said four", or "you write them"]
- **The small win at the end:** [PAYOFF - the tiny real thing that still happened, e.g. "one visitor who wasn't me", or "you write it"]
- **In the window:** [WINDOW - "no person: everything drawn in code", or "my own clip or photos, attached"]
- **Length:** [LENGTH - e.g. about 35 seconds]
- **Voice:** [VOICE - e.g. "a flat machine voice, imitating no real system", "my own voice", or "no voice: captions and sound only"]
- **Music:** [MUSIC - e.g. "the skill's hold music 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 people or companies, no claims I can't back up]

Keep the computer invented: it must not look or sound like any real operating system, so make your own startup chord and modem sound.

Work in this order:

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

Cookies for ads and referrals

We use the Meta Pixel to measure our ads and Tolt to credit the partner whose link you followed. Accept to allow them; decline and the site works the same. Privacy Policy

Decline Accept