---
name: vitals
description: Make a 30-40 second vertical comedy video where a bedside monitor and a doctor's chart treat a small everyday problem as a medical emergency - the numbers fall, the alarm fires, the trace goes flat, and the cure is something obvious. The skill covers the ward sound design, music fitted to the story and an optional voice like a flat, tired doctor. Comedy or metaphor only, with no medical advice, health claims or real hospitals. Use for deadpan jokes about neglected things, a dying houseplant, side project, habit or group chat, 'this thing is in critical condition' stories, or when asked for a hospital, ER or heart monitor parody, or when given the vitals prompt.
---

# Vitals

A bedside monitor fills the screen, and your small problem is the patient. Ours is a fern. The trace follows the words, the numbers fall, an alarm fires, and a chart under the monitor writes itself in a doctor's handwriting. Then the line goes flat and the room goes dim, and the rescue is the most obvious thing in the world. It's plainly a joke, told with a completely straight face.

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/vitals/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/rajdhani` and `@fontsource/caveat`. Copy the font files you need from `node_modules/@fontsource/<name>/files/` into `public/fonts/`, so a
render never fetches a font over the network. Then lay it out like this:

```
plan.json              the lines and the scenes (step 6)
takes/<id>.json        one per line: the voice take's length and word timings (step 8)
scripts/clock.mjs      turns plan.json and the voice takes into src/spec.json (step 6)
src/index.ts           registers the composition (step 7)
src/Root.tsx           the 1080 x 1920, 30 fps composition (step 7)
src/spec.json          the clock, written by clock.mjs
src/frames/core.tsx    the shared runtime (step 7)
src/frames/story.json  the events: which word each change lands on (step 6)
src/frames/Vitals.tsx   this frame (step 7)
```

Render without audio, then mux your own mix (step 10):
`npx remotion render src/index.ts Film-9x16 out/silent.mp4 --muted`. Set TEMP and TMP to a folder on a roomy drive first;
Remotion writes a lot of temporary files.

**Prove the setup before you write anything.** Copy the code and our `plan.json` from step 7, run the clock from step 6, and render
the first few seconds (add `--frames=0-90`). Only then start on your own story, at step 5. The font files you need are named in
`FACES` at the top of the frame.

**For the voice, images, music and sound effects, use whatever the user prefers.** Every step works the same with any
provider. If the user hasn't said, ask which tools they want to use, or use what is already set up.

- **Your own providers.** A text-to-speech that returns word timestamps (for example ElevenLabs with timestamps), a music
  model or a licensed track, and a sound-effects generator (for example ElevenLabs sound effects) or a licensed sound
  library. Keep API keys in environment variables, never in the project. No AI media at all is fine too: the picture is
  code, the narration can be the user's own recording (align its words with Whisper), and the film works with no voice.
- **HYVIRAL (optional).** If the user has it connected (hyviral.com), one connection covers the media:

| You need | HYVIRAL tool |
|----------|--------------|
| Narration with word timestamps (stock voices or your own cloned voice) | `generate_voiceover` (`content_type: "shorts"`), `manage_voice_clone` |
| Still images | `generate_image` |
| Music bed | `generate_music` |
| Sound library, placing sounds, final render | `video_audio`, `video_export` |
| Long jobs | `get_job_status` (poll until delivered) |

  To connect it: in Claude.ai or Claude Desktop, Customize → Connectors → add a custom connector with the URL
  `https://api.hyviral.com/mcp`; in Claude Code, `claude mcp add --transport http hyviral https://api.hyviral.com/mcp` then
  `/mcp` to sign in. Start a session with `get_connection_info`. HYVIRAL never runs your code: you render the animation
  yourself.

## 2. How a frame film works

- **One frame, the whole film.** A frame is a world with fixed parts on screen, and it never cuts away. The story happens
  to the world: the numbers fall, the alarm fires, the chart writes itself, the trace goes flat and jumps back, and the patient gets better. That's why it holds attention: the viewer learns the world in the first second, then
  watches it change.
- **One clock: the spoken words.** Each line is voiced as its own take with word timings, scenes are laid end to end on
  those takes, and every change is keyed to a word in `story.json`. Re-voice a line and everything moves with it.
- **A function of time.** Every part of the frame computes what it shows from the current time, with no stored state,
  `Math.random` or `Date`. Use `hash()` for anything that looks random, so two renders match.
- **Sound off first.** Most people start muted, so the frame and the captions have to tell the whole story on their own.
  Sound makes it better, never necessary.
- **Safe area.** 1080 x 1920. Platform buttons and captions cover the bottom 440 px, and the right 130 px from about
  y = 900 down. Keep captions and anything that must be read inside x 60-950, y 250-1480. Above y = 900 the frame can run to
  the right edge. Below y = 1480, draw only texture. The top 250 px can hold the world's title and small jokes, as long as nothing
  there has to be read.
- **Pacing rules.** No voice gap over 1.0 s, except one voiceless moment of 2 to 5 s with the music lifted. No still stretch
  over 1.2 s. Never cut to black. Something moves on the very first frame.

## 3. The world

A ward at about noon, seen as one bedside monitor and one chart on a clipboard.

**This frame is comedy or metaphor only, and you must keep it that way.** The patient is a plant, a side project, a habit or a group chat, never a person or a pet. No medical advice, no health claims, no real conditions, medicines, doses or treatments, no real hospital, clinic or doctor. Don't take a topic about someone's real health, however lightly it's asked for. If the user's topic is one, tell them this frame is the wrong one and offer another. The sticker on the monitor, FOR HOUSEPLANTS ONLY, is part of how the joke stays plain, so change it to fit your patient and keep it.

- **Palette:** ward mint wall `#cfe3dc`, monitor bezel `#2a2f33`, screen `#05080a`, trace green `#3cff7a`, cyan `#3ad7ff`, alarm red `#ff4e5c`, amber `#ffe44d`, chart ink blue `#1f3a93`, pen red `#d0302a`, wood clipboard `#8a5a2b`.
- **Type:** Rajdhani for everything on the monitor and everything printed, Caveat 700 for anything a doctor wrote, captions included.
- **Fixed parts:** the monitor fills the top, from y 24 to 1068. Inside it, top to bottom: the patient's name, the ward and a clock, a ticker of small jokes (all above the safe area, so nothing there has to be read), the alarm banner (y 252-326), the window on the left and four big numbers on the right (y 334-804), then two traces. A printer slot sits in the lower bezel. Under it hangs the chart: three form rows, then the captions in one place on the ruled lines (y 1318-1480), and below that only old notes as texture.
- **The light says the state:** the room is mint, flashes red on the alarm, dims on the low point, and goes warm on the recovery. A screen makes its own light, so the sun lands on the wall, the paper and the bezel and never washes over the black of the screen.
- **A real monitor draws with a sweep:** the trace head runs left to right and wipes the old line just ahead of it, so a flat line replaces the heartbeats one beat at a time. It also means something always moves, even at the low point.
- **Small jokes live on the paper and the ticker:** ALLERGIES: DRAFTS, NEXT OF KIN: ONE CACTUS, SIGHS /min, a trace called FROND II, a bag labeled TAP with a spider on it, and old chart notes below the safe area ("day 45: see day 44").

**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 bed camera, labeled POT 4 CAM, and it follows the story. Ours is drawn in code:

| Story moment | In the window |
|---|---|
| Admission | The whole bed rolls into shot on the first frame and bumps to a stop: a fern in a terracotta pot, under a tiny blanket that rises and falls |
| The history | The camera dives onto the drip bag. It says TAP, it's nearly empty, and it has a cobweb and a spider |
| The decline | Leaves come off and land on the blanket, and they stay there. The fronds start to sag |
| The alarm | Everything flashes red, the shot shakes, and one big frond snaps sideways |
| The low point | The room goes dim, the blanket stops moving, that frond droops all the way down, and a glow finds the calendar: MARCH |
| The rescue | A gloved hand slides a green watering can along the table. It lifts and trembles while a dashed ring lights around the saucer: STAND CLEAR |
| The silence | The can tips, water falls, the room falls back, and one bright fiddlehead unrolls in close-up |
| The recovery | A long pan takes the bed to a big bright window. The fronds lift and go green |
| Discharge | The blanket folds down, and a sign swings in over it: NO WORK TALK |

The window also works with the user's own clip: play it inside the frame as a bed camera, with the label and the red dot on top. A real plant on a real windowsill is very funny here. Never film a real person or animal as the patient.

## 5. The story

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

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

**The moves.** Map any topic onto these. The film treats something small and neglected as a hospital emergency, and the cure is the thing everyone already knew. Ours is about a fern.

| # | Move | What it does | Our line | On the monitor |
|---|------|--------------|----------|----------------|
| 1 | Admission | The hook: a small patient, in hospital language | "Patient is a fern, age three, admitted from a windowsill." | The monitor wakes, the bed rolls in, the chart fills in Fern, 3, windowsill |
| 2 | How it got here | Someone else noticed | "Brought in by a roommate who noticed it was crunchy." | CRUNCH DETECTED. The note writes itself, and the camera punches in on a shaking frond |
| 3 | The history | The real cause, and the excuse | "Last watered: unclear. Owner says, quote, recently." | The cobwebbed bag, then "recently" gets underlined twice |
| 4 | The decline | One number, falling on each word | "Leaf count falling. Forty. Thirty-one. Twelve." | The big number drops on each word and leaves fall |
| 5 | The alarm | It gets out of hand | "We're losing a frond." | CRITICAL: FROND, the beeps race, red lights |
| 6 | Low point | The sad fact, said flat | "Time of last new leaf: March." | The trace goes flat, the room dims, a frond droops. Music out, with ward air and a steady tone still running |
| 7 | The turn | The obvious cure, ordered like a crash cart | "Get the watering can." | The hand slides the can in. CODE GREEN, and the lights come back on "can". The music returns on "can" |
| 8 | Charging | The cure gets treated like a defibrillator | "Charging. Stand clear of the saucer." | The banner fills as a bar, CHARGING 0 to 200 mL. The ring lights around the saucer |
| 9 | The silence | The voiceless moment, about 3.5 s, with the music at its fullest | (the caption reads "We have a shoot") | Water pours, the trace jumps, the numbers climb, one green shoot unrolls |
| 10 | Stable | The release | "Patient stable. Moved to a window with more light." | The bed moves, the window fills with sun, the trace settles |
| 11 | Discharge | The moral, as paperwork, and the last laugh | "Discharge notes: water weekly. Stop telling it about work." | Notes print out of the monitor and SIGHS falls to 0 |
| 12 | Call to action | One action | "Comment vitals and I'll send you the skill that made this." | The notes are torn away, and the chart's last line reads COMMENT VITALS and stays |

**For other topics:** a side project nobody has opened since March, a gym habit, a group chat, a sourdough starter, a company's blog, a New Year's resolution. Pick one number that can fall on three words, a low point that's a plain date or fact, and a cure so obvious it's insulting. **The trick that makes it land is the distance between the voice and the stakes:** the doctor never smiles and never winks, and the most urgent line in the film is "Get the watering can." Line 8 is what proves the joke, so find your own version: the dull thing, powered up with everyone told to stand clear. The other joke to keep is line 11, a small personal detail the viewer didn't see coming.

Our film runs 38.6 s on the clock's estimate, with no voice recorded. For about 35 s, cut line 2 and put "crunchy" into line 1. Keep moves 6, 7, 8 and 9 whole.

Use real facts if the patient is real, and invent freely if it's plainly a joke. Keep the medical language to the frame's own world: leaf counts and saucers, never a real condition or a dose that could be mistaken for advice.

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

Almost all the words are in one block at the top of the file, and the biggest job is redrawing the patient. When the patient isn't a plant, `Fern`, `Shoot`, `FallingLeaves`, the pot in `Bed` and `Can` are what you replace. `Room`, the bed, `Monitor`, `Chart`, `Trace` and `Captions` stay as they are.

| To change | Where |
|---|---|
| The monitor's words | The constants at the top: `PATIENT`, `AGE`, `WARD`, `BED`, `CAM`, `BAND`, `BAG`, `MONTH`, `MODEL`, `STICKER`, `TICKER`, and `NUMS` and `TRACES` for the labels |
| The alarm banner | `ALARMS`: event, text and level, in time order. `STOPS` holds each level's colors and blink. Charging has its own bar: `CHARGING`, `UNIT`, `READY` and the 200 in `Banner` |
| The chart | `CHART`: `head` for the top row, `rows` for the form (label, its x, value, its x, event, row y, ink), `follow` and `last` for the last line. The two strokes under "recently" are hard-coded in `Chart`, so move them with the word |
| The discharge notes | `DISCHARGE`: three lines for `Sheet`, and `sign` for the sign in the window. `CLEAR` is the saucer's label |
| The voiceless caption | `SHOUT` |
| The old notes below the safe area | `OLD_NOTES` |
| The numbers' story | `LEAVES` (event and value), then `soilAt`, `sunHoursAt`, `sighsAt`. `flatAt` says when they read `--` |
| The trace's story | `bpmAt` for the rate, `ampAt` for the height and the jump, `flatAt` for the flat stretch. `ENV` reads `WORDS` from the clock, so the trace follows the voice with no audio at all |
| The patient | `FRONDS`, `buildFrond`, `keepAt`, `dryAt`, `sagAt`, `heroSagAt` in `Fern`. `SHED` lists which events drop how many leaves. `Shoot` and `ShootSpot` are the recovery |
| The rescue | `Can`, `canPose` and `Water`. The glove and sleeve are part of `Can`. The ring and the `CLEAR` label are in `Bed` and `BedLabels` |
| The room and the bed | `Room` holds the curtain, the calendar and the bright window. `panAt` is the move. `Bed` holds the drip stand, the spider, the table, the bed and the blanket |
| The camera | `KEYS` in units of the 640 x 470 window, using `zoom(scale, x, y)`, where (x, y) stays put. The dive onto the bag is the `last` row |
| The light | `dimAt`, `redAt` and `sunAt`, read by `Window`, `Light` and the wall in `Stage` |
| The comment keyword | `plan.json`, `KEYWORD` at the top, and the `cta` and `this` events if the line changes. `CHART.last` is built from `KEYWORD`, and `Captions` lights every use of the word once the ending starts |

- **Scene ids in the code:** none. It only reads events, so you can renumber scenes freely, as long as `story.json` follows.
- **Words in `story.json` belong to our script.** `fern`, `three`, `sill`, `roommate`, `crunchy`, `last`, `unclear`, `owner`, `recently`, `falling`, `forty`, `thirty`, `twelve`, `losing`, `frond`, `march`, `get`, `can`, `charging`, `stand`, `saucer`, `stable`, `moved`, `light`, `discharge`, `weekly`, `stop`, `work`, `keyword` and `this` all point at words, so re-anchor every one. Keep the event names, or the code that reads them has to change too. `on`, `low`, `pour` and `cta` are scene starts, and `shoot` is an offset into the voiceless scene.
- **Panels are sized to our words, and they shrink rather than overflow.** `fit()` sets the type: the banner has 930 px, each `Sheet` line 780 px, the chart's last line 630 px, `SHOUT` 800 px, and the sign 226 px. A much longer word still fits, but small, so check it. Not covered by `fit()`: `CHART.rows` and `CHART.head` sit at fixed x positions fitted to our words, `BAND` and `BAG` are small labels on the pot and the bag, `MONTH` has to fit the calendar's top band, and `Num` holds two digits at 168 px or 88 px.
- **Captions** break into rows at `PER_ROW` letters. At 28 the longest row just clears x 950, so keep it there unless you change the size.
- **Layers:** `Stage` draws back to front. `Light` sits above the monitor and the chart, and the traces, the banner and the captions sit above `Light` so they stay bright when the room goes dark. Keep them there.

### The code

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

```json
{
 "lines": [
  {
   "id": "l01",
   "text": "Patient is a fern, age three, admitted from a windowsill."
  },
  {
   "id": "l02",
   "text": "Brought in by a roommate who noticed it was crunchy."
  },
  {
   "id": "l03",
   "text": "Last watered: unclear. Owner says, quote, recently.",
   "say": "Last watered, unclear. Owner says, quote, recently."
  },
  {
   "id": "l04",
   "text": "Leaf count falling. Forty. Thirty-one. Twelve."
  },
  {
   "id": "l05",
   "text": "We're losing a frond."
  },
  {
   "id": "l06",
   "text": "Time of last new leaf: March.",
   "say": "Time of last new leaf, March."
  },
  {
   "id": "l07",
   "text": "Get the watering can."
  },
  {
   "id": "l08",
   "text": "Charging. Stand clear of the saucer."
  },
  {
   "id": "l09",
   "text": "Patient stable. Moved to a window with more light."
  },
  {
   "id": "l10",
   "text": "Discharge notes: water weekly. Stop telling it about work.",
   "say": "Discharge notes, water weekly. Stop telling it about work."
  },
  {
   "id": "l11",
   "text": "Comment vitals and I'll send you the skill that made this.",
   "say": "Comment, vitals, and I'll send you the skill that made this."
  }
 ],
 "scenes": [
  {
   "id": "s01",
   "lines": [
    "l01"
   ],
   "lead": 0.2,
   "tail": 0.1
  },
  {
   "id": "s02",
   "lines": [
    "l02"
   ],
   "lead": 0.1,
   "tail": 0.1
  },
  {
   "id": "s03",
   "lines": [
    "l03"
   ],
   "lead": 0.1,
   "tail": 0.1
  },
  {
   "id": "s04",
   "lines": [
    "l04"
   ],
   "lead": 0.1,
   "tail": 0.12
  },
  {
   "id": "s05",
   "lines": [
    "l05"
   ],
   "lead": 0.05,
   "tail": 0.2
  },
  {
   "id": "s06",
   "lines": [
    "l06"
   ],
   "lead": 0.7,
   "tail": 0.7
  },
  {
   "id": "s07",
   "lines": [
    "l07"
   ],
   "lead": 0.2,
   "tail": 0.15
  },
  {
   "id": "s08",
   "lines": [
    "l08"
   ],
   "lead": 0.05,
   "tail": 0.25
  },
  {
   "id": "s09",
   "sec": 3.5
  },
  {
   "id": "s10",
   "lines": [
    "l09"
   ],
   "lead": 0.1,
   "tail": 0.1
  },
  {
   "id": "s11",
   "lines": [
    "l10"
   ],
   "lead": 0.1,
   "tail": 0.1
  },
  {
   "id": "s12",
   "lines": [
    "l11"
   ],
   "lead": 0.12,
   "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 { Vitals } from './frames/Vitals';
import spec from './spec.json';

export const Root: React.FC = () => (
  <Composition id="Film-9x16" component={Vitals} durationInFrames={Math.max(1, (spec as { durationInFrames: number }).durationInFrames)} fps={30} width={1080} height={1920} />
);
```

`src/frames/core.tsx`, the shared runtime:

```tsx
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { continueRender, delayRender, staticFile, useCurrentFrame } from 'remotion';
import spec from '../spec.json';

/* The shared frame runtime. One frame runs through the whole film; the clock comes from the spoken words. */
export const clamp = (x: number, a = 0, b = 1) => Math.min(b, Math.max(a, x));
export const ramp = (t: number, a: number, b: number) => clamp((t - a) / (b - a));
export const easeOut = (x: number) => 1 - Math.pow(1 - x, 3);
export const easeInOut = (x: number) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2);
export const easeIn = (x: number) => x * x * x;
export const back = (x: number) => { const c = 1.70158; return 1 + (c + 1) * Math.pow(x - 1, 3) + c * Math.pow(x - 1, 2); };
export const hash = (n: number) => { const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };

export type Word = { w: string; t0: number; t1: number; scene: string };
export type SceneT = { id: string; t0: number; t1: number; words: Word[]; p: Record<string, unknown> };
export type StoryEvent = { id: string; scene: string; word?: string; off?: number };
export type StoryJson = { events: StoryEvent[] };

const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9']/g, '');
type SpecBeat = { id: string; from: number; dur: number; words: { w: string; f0: number; f1: number }[]; p: Record<string, unknown> };
const FPS = (spec as { fps: number }).fps;
export const SCENES: SceneT[] = (spec as unknown as { beats: SpecBeat[] }).beats.map((b) => ({
  id: b.id,
  t0: b.from / FPS,
  t1: (b.from + b.dur) / FPS,
  p: b.p ?? {},
  words: b.words.map((w) => ({ w: w.w, t0: (b.from + w.f0) / FPS, t1: (b.from + w.f1) / FPS, scene: b.id })),
}));
export const WORDS: Word[] = SCENES.flatMap((s) => s.words);
export const DURATION = (spec as { durationInFrames: number }).durationInFrames / FPS;

/** The film second of an event: a scene's start, or a word in it, plus an offset. Throws on a typo, so a wrong anchor fails the render. */
export function eventTime(e: StoryEvent): number {
  const s = SCENES.find((x) => x.id === e.scene);
  if (!s) throw new Error(`event ${e.id}: no scene ${e.scene}`);
  let t = s.t0;
  if (e.word) {
    const w = s.words.find((x) => norm(x.w).startsWith(norm(e.word as string)));
    if (!w) throw new Error(`event ${e.id}: no word "${e.word}" in ${e.scene} (${s.words.map((x) => x.w).join(' ')})`);
    t = w.t0;
  }
  return t + (e.off ?? 0);
}
export const makeE = (story: StoryJson) => {
  const map = new Map(story.events.map((e) => [e.id, eventTime(e)]));
  return (id: string) => {
    const v = map.get(id);
    if (v === undefined) throw new Error(`unknown event ${id}`);
    return v;
  };
};

const TimeCtx = createContext(0);
export const useT = () => useContext(TimeCtx);
export const Clock: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const f = useCurrentFrame();
  return <TimeCtx.Provider value={f / FPS}>{children}</TimeCtx.Provider>;
};

/** One caption chunk at a time, cut to fit rows of `perRow` cells; `wi` is the word being spoken, or -1. */
export type Chunk = { t0: number; rows: { s: string; map: number[] }[]; words: Word[] };
const clean = (w: string) => w.replace(/[.,;:!?"]/g, '');
export function buildChunks(perRow: number, maxRows: number): Chunk[] {
  const chunks: Chunk[] = [];
  for (const scene of SCENES) {
    let cur: Word[] = [];
    const flush = () => {
      if (!cur.length) return;
      const rows: { s: string; map: number[] }[] = [{ s: '', map: [] }];
      cur.forEach((w, i) => {
        const up = clean(w.w).toUpperCase();
        let r = rows[rows.length - 1];
        if (r.s.length && r.s.length + 1 + up.length > perRow) { rows.push({ s: '', map: [] }); r = rows[rows.length - 1]; }
        if (r.s.length) { r.s += ' '; r.map.push(-1); }
        for (let k = 0; k < up.length; k++) r.map.push(i);
        r.s += up;
      });
      chunks.push({ t0: cur[0].t0, rows, words: cur });
      cur = [];
    };
    for (const w of scene.words) {
      const trial = [...cur, w];
      let rowsUsed = 1, len = 0;
      for (const x of trial) {
        const L = clean(x.w).length;
        if (len && len + 1 + L > perRow) { rowsUsed += 1; len = L; } else len += (len ? 1 : 0) + L;
      }
      if (rowsUsed > maxRows) flush();
      cur.push(w);
      if (/[.!?]$/.test(w.w)) flush();
    }
    flush();
  }
  return chunks;
}

/** Loads the frame's fonts before the first frame. The handle is taken inside React: a module-level one can outlive its render. */
export const Fonts: React.FC<{ faces: { family: string; file: string; weight?: string; style?: string }[]; children: React.ReactNode }> = ({ faces, children }) => {
  const [handle] = useState(() => delayRender('fonts', { timeoutInMilliseconds: 120000 }));
  const [ready, setReady] = useState(false);
  const list = useMemo(() => faces, [faces]);
  useEffect(() => {
    Promise.all(list.map((x) => {
      const ff = new FontFace(x.family, `url(${staticFile(`fonts/${x.file}`)})`, { weight: x.weight ?? '400', style: x.style ?? 'normal' });
      return ff.load().then((l) => { (document.fonts as unknown as { add: (f: FontFace) => void }).add(l); });
    }))
      .then(() => document.fonts.ready)
      .then(() => { setReady(true); continueRender(handle); })
      .catch((e) => { console.error('font load failed', e); setReady(true); continueRender(handle); });
  }, [handle, list]);
  return ready ? <>{children}</> : null;
};
```

`src/frames/story.json`, our demo's events:

```json
{
 "events": [
  {"id": "on", "scene": "s01"},
  {"id": "fern", "scene": "s01", "word": "fern"},
  {"id": "three", "scene": "s01", "word": "three"},
  {"id": "sill", "scene": "s01", "word": "windowsill"},
  {"id": "roommate", "scene": "s02", "word": "roommate"},
  {"id": "crunchy", "scene": "s02", "word": "crunchy"},
  {"id": "last", "scene": "s03", "word": "Last"},
  {"id": "unclear", "scene": "s03", "word": "unclear"},
  {"id": "owner", "scene": "s03", "word": "Owner"},
  {"id": "recently", "scene": "s03", "word": "recently"},
  {"id": "falling", "scene": "s04", "word": "falling"},
  {"id": "forty", "scene": "s04", "word": "Forty"},
  {"id": "thirty", "scene": "s04", "word": "Thirty"},
  {"id": "twelve", "scene": "s04", "word": "Twelve"},
  {"id": "losing", "scene": "s05", "word": "losing"},
  {"id": "frond", "scene": "s05", "word": "frond"},
  {"id": "low", "scene": "s06"},
  {"id": "march", "scene": "s06", "word": "March"},
  {"id": "get", "scene": "s07", "word": "Get"},
  {"id": "can", "scene": "s07", "word": "can"},
  {"id": "charging", "scene": "s08", "word": "Charging"},
  {"id": "stand", "scene": "s08", "word": "Stand"},
  {"id": "saucer", "scene": "s08", "word": "saucer"},
  {"id": "pour", "scene": "s09"},
  {"id": "shoot", "scene": "s09", "off": 1.15},
  {"id": "stable", "scene": "s10", "word": "stable"},
  {"id": "moved", "scene": "s10", "word": "Moved"},
  {"id": "light", "scene": "s10", "word": "light"},
  {"id": "discharge", "scene": "s11", "word": "Discharge"},
  {"id": "weekly", "scene": "s11", "word": "weekly"},
  {"id": "stop", "scene": "s11", "word": "Stop"},
  {"id": "work", "scene": "s11", "word": "work"},
  {"id": "cta", "scene": "s12"},
  {"id": "keyword", "scene": "s12", "word": "vitals"},
  {"id": "this", "scene": "s12", "word": "this"}
 ]
}
```

`src/frames/Vitals.tsx`:

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

/* Vitals: a bedside monitor and a chart, with a houseplant as the patient. The trace, the numbers and the alarm tell the story, the window is the
   bed camera, and the captions are the doctor's handwriting on the chart. 1080 x 1920. Time comes from the spoken words through story.json, so
   a re-voice moves every alarm with its word. Plainly a joke: no medical advice, no health claims, no real hospital. */
const E = makeE(story as StoryJson);

/* ---------- the story's words: start here for a new topic ---------- */
const KEYWORD = 'VITALS';
const SHOUT = 'We have a shoot';
const PATIENT = 'FERN', AGE = '3 YRS', WARD = 'WINDOWSILL WING', BED = 'BED 4';
const CAM = 'POT 4 CAM', BAND = 'FERN, 3', BAG = 'TAP', MONTH = 'MARCH', MODEL = 'FERNVIEW 9', STICKER = 'FOR HOUSEPLANTS ONLY';
const NUMS = { leaves: 'LEAVES', soil: ['SOIL', 'H2O %'], sun: ['SUN', 'hrs'], sighs: ['SIGHS', '/min'] }, TRACES = ['FROND II', 'SAP'];
const TICKER = 'ALLERGIES: DRAFTS   ◆   DIET: TAP WATER   ◆   NEXT OF KIN: ONE CACTUS   ◆   VISITING HOURS: WHEN YOU REMEMBER   ◆   ';
type Level = 'info' | 'warn' | 'crit' | 'dead' | 'code' | 'ok';
/** The alarm banner, in time order: [event, text, level]. Charging has its own bar, in `Banner`. */
const ALARMS: [string, string, Level][] = [
  ['fern', 'ADMITTED', 'info'], ['crunchy', 'CRUNCH DETECTED', 'warn'], ['last', 'WATER HISTORY: UNCLEAR', 'warn'], ['falling', 'LEAF COUNT FALLING', 'warn'],
  ['losing', 'CRITICAL: FROND', 'crit'], ['low', 'NO NEW GROWTH', 'dead'], ['march', `LAST NEW LEAF: ${MONTH}`, 'dead'], ['can', 'CODE GREEN', 'code'],
  ['pour', 'DELIVERING 200 mL', 'code'], ['shoot', 'SHOOT DETECTED', 'ok'], ['stable', 'STABLE', 'ok'], ['light', 'NORMAL LEAF RHYTHM', 'ok'],
  ['discharge', 'PRINTING DISCHARGE', 'info'], ['cta', 'SEE CHART', 'ok'],
];
const STOPS: Record<Level, { bg: string; fg: string; blink?: number }> = {
  info: { bg: '#0b3340', fg: '#3ad7ff' },
  warn: { bg: '#ffe44d', fg: '#05080a', blink: 2 },
  crit: { bg: '#ff4e5c', fg: '#ffffff', blink: 5 },
  dead: { bg: '#2a0e12', fg: '#ff8a93' },
  code: { bg: '#1bb55c', fg: '#04140a', blink: 3 },
  ok: { bg: '#0f3d22', fg: '#3cff7a' },
};
/* The chart: printed labels, handwritten values. Each value writes itself on its event. */
const CHART = {
  head: [['PATIENT', 'Fern', 'fern', 130, 92], ['AGE', '3', 'three', 400, 58], ['FROM', 'windowsill', 'sill', 540, 76]] as [string, string, string, number, number][],
  // [label, label x, value, value x, event, row y, ink]
  rows: [['BROUGHT BY', 130, 'roommate', 310, 'roommate', 1160, '#1f3a93'], ['C/O', 620, 'crunchy', 680, 'crunchy', 1160, '#d0302a'], ['LAST WATERED', 130, 'unclear', 310, 'unclear', 1214, '#1f3a93'], ['OWNER SAYS', 466, '"recently"', 626, 'recently', 1214, '#1f3a93']] as [string, number, string, number, string, number, string][],
  follow: 'FOLLOW-UP',
  last: `COMMENT ${KEYWORD}`,
};
const OLD_NOTES = ['day 41: still a fern', 'day 42: owner visited, took a photo', 'day 43: owner visited, did not water', 'day 44: asked it how it was. nothing', 'day 45: see day 44'];
const DISCHARGE = { head: 'DISCHARGE NOTES', one: '1. WATER WEEKLY', two: '2. STOP TELLING IT ABOUT WORK', sign: 'NO WORK TALK' };
const CLEAR = 'STAND CLEAR', READY = 'READY: STAND CLEAR', CHARGING = 'CHARGING', UNIT = 'mL';

/* ---------- look ---------- */
const WALL = '#cfe3dc', BEZEL = '#2a2f33', SCREEN = '#05080a', GREEN = '#3cff7a', CYAN = '#3ad7ff', RED = '#ff4e5c', AMBER = '#ffe44d', INK = '#1f3a93', PEN = '#d0302a';
const MON = 'Rajdhani, sans-serif', HAND = 'Caveat, cursive';
const FACES = [
  { family: 'Rajdhani', file: 'rajdhani-latin-600-normal.woff2', weight: '600' },
  { family: 'Rajdhani', file: 'rajdhani-latin-700-normal.woff2', weight: '700' },
  { family: 'Caveat', file: 'caveat-latin-700-normal.woff2', weight: '700' },
];
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
const tint = (a: string, b: string, k: number) => interpolateColors(clamp(k), [0, 1], [a, b]);
/** Type size for a panel: our words get `max`, and a longer word shrinks to stay inside `w` pixels. `em` is the font's average letter width. */
const fit = (text: string, w: number, max: number, em: number) => Math.min(max, Math.floor(w / (Math.max(1, text.length) * em)));

/* ---------- the room's light: every state of the film in one place ---------- */
const dimAt = (t: number) => ramp(t, E('low'), E('low') + 0.55) * (1 - ramp(t, E('can') - 0.02, E('can') + 0.2));
const redAt = (t: number) => (t >= E('losing') && t < E('low') + 0.1 ? 0.5 + 0.5 * Math.sin((t - E('losing')) * Math.PI * 5.2) : 0);
const sunAt = (t: number) => ramp(t, E('moved'), E('light') + 0.5);
const flatAt = (tt: number) => tt >= E('low') + 0.12 && tt < E('pour') + 0.38;

/* ---------- the trace: it reads the words from the clock, so it follows the voice with no audio at all ---------- */
const ENV: number[] = (() => {
  const a: number[] = new Array(Math.ceil(DURATION * 30) + 60).fill(0);
  WORDS.forEach((w, i) => {
    for (let f = Math.floor((w.t0 - 0.03) * 30); f <= Math.ceil((w.t1 + 0.1) * 30); f++) {
      if (f < 0 || f >= a.length) continue;
      const k = clamp((f / 30 - w.t0 + 0.03) / (w.t1 - w.t0 + 0.13));
      a[f] = Math.max(a[f], Math.pow(Math.sin(k * Math.PI), 0.6) * (0.7 + 0.3 * hash(i + 3)));
    }
  });
  return a;
})();
const env = (tt: number) => ENV[clamp(Math.round(tt * 30), 0, ENV.length - 1)];

const bpmAt = (x: number) => {
  if (x < E('falling')) return 74;
  if (x < E('losing')) return lerp(74, 108, ramp(x, E('falling'), E('losing')));
  if (x < E('low')) return 172;
  if (x < E('pour')) return 74;
  return lerp(158, 72, ramp(x, E('pour') + 0.5, E('light')));
};
const BEATS: number[] = (() => {
  const b: number[] = [];
  let x = -8;
  while (x < DURATION + 2) { b.push(x); x += 60 / bpmAt(x); }
  return b;
})();
const beatIndex = (tt: number) => {
  let lo = 0, hi = BEATS.length - 1;
  while (lo < hi) { const m = Math.ceil((lo + hi) / 2); if (BEATS[m] <= tt) lo = m; else hi = m - 1; }
  return lo;
};
const gauss = (x: number, m: number, w: number) => Math.exp(-Math.pow((x - m) / w, 2) / 2);
const ampAt = (tt: number, i: number) => {
  if (flatAt(tt) || tt < 0) return 0;
  const shaky = tt >= E('falling') && tt < E('low') ? (hash(i * 3 + 1) > 0.5 ? 1.2 : 0.62) : 1;
  const jump = 1 + 1.25 * (1 - ramp(tt, E('pour') + 0.38, E('stable')));
  return tt >= E('pour') ? jump * (0.8 + env(tt) * 0.5) : (0.72 + env(tt) * 0.62) * shaky;
};
const ecg = (tt: number) => {
  const i = beatIndex(tt), ph = (tt - BEATS[i]) / 0.81;
  return ampAt(tt, i) * (0.12 * gauss(ph, 0.15, 0.03) - 0.18 * gauss(ph, 0.28, 0.008) + gauss(ph, 0.3, 0.009) - 0.32 * gauss(ph, 0.32, 0.008) + 0.26 * gauss(ph, 0.55, 0.045));
};
const sap = (tt: number) => {
  const i = beatIndex(tt), ph = (tt - BEATS[i]) / 0.81;
  return Math.min(1.5, ampAt(tt, i)) * (gauss(ph, 0.36, 0.1) + 0.42 * gauss(ph, 0.62, 0.08));
};
/** A real monitor draws with a sweep, not a scroll: the head runs left to right and wipes the old line just ahead of it. */
const SWEEP = 300, TW = 950;
const Trace: React.FC<{ x: number; base: number; scale: number; color: string; fn: (tt: number) => number; width: number }> = ({ x, base, scale, color, fn, width }) => {
  const t = useT();
  const head = (t * SWEEP) % TW;
  const bands = [{ d: '', o: 1 }, { d: '', o: 0.72 }, { d: '', o: 0.42 }];
  let prev = '', prevBand = -1;
  for (let px = 0; px <= TW; px += 3) {
    const age = ((head - px + TW) % TW) / SWEEP;
    if (age > (TW - 34) / SWEEP) { prev = ''; prevBand = -1; continue; } // the wipe just ahead of the head
    const band = age < 0.6 ? 0 : age < 1.8 ? 1 : 2;
    const p = `${px} ${(base - fn(t - age) * scale).toFixed(1)}`;
    bands[band].d += prev && prevBand === band ? `L${p}` : prev ? `M${prev}L${p}` : `M${p}`;
    prev = p; prevBand = band;
  }
  return (
    <svg viewBox={`0 0 ${TW} 500`} width={TW} height={500} style={{ position: 'absolute', left: x, top: base - 400, overflow: 'visible' }}>
      <g transform={`translate(0 ${-(base - 400)})`} fill="none" stroke={color} strokeLinejoin="round" strokeLinecap="round">
        <path d={bands[0].d + bands[1].d} strokeWidth={width * 3.4} opacity={0.2} />
        {bands.map((b, i) => <path key={i} d={b.d} strokeWidth={width} opacity={b.o} />)}
        <circle cx={head} cy={base - fn(t) * scale} r={width * 1.2} fill={color} stroke="none" />
        <circle cx={head} cy={base - fn(t) * scale} r={width * 3.4} fill={color} stroke="none" opacity={0.22} />
      </g>
    </svg>
  );
};

/* ---------- the numbers ---------- */
const stepTo = (t: number, steps: [string, number][], start: number, d = 0.28) => {
  let v = start;
  for (const [ev, to] of steps) if (t >= E(ev)) v = lerp(v, to, easeOut(ramp(t, E(ev), E(ev) + d)));
  return Math.round(v);
};
const LEAVES: [string, number][] = [['forty', 40], ['thirty', 31], ['twelve', 12], ['shoot', 13], ['this', 14]];
const leavesAt = (t: number) => stepTo(t, LEAVES, 52);
const soilAt = (t: number) => Math.round(lerp(stepTo(t, [['recently', 3]], 9, 0.5), 64, ramp(t, E('pour') + 0.4, E('pour') + 2.8)));
const sunHoursAt = (t: number) => Math.round(lerp(2, 9, ramp(t, E('moved'), E('light') + 0.3)));
const sighsAt = (t: number) => (t >= E('work') ? 0 : t >= E('pour') ? 12 + Math.round(env(t) * 3) : t >= E('losing') ? 31 : 14 + Math.round(env(t) * 6));
const flashOf = (t: number, evs: string[]) => evs.reduce((a, e) => (t >= E(e) ? Math.max(a, 1 - ramp(t, E(e), E(e) + 0.34)) : a), 0);

const Num: React.FC<{ y: number; h: number; label: string; unit: string; color: string; value: string; size: number; flash?: number; flashColor?: string }> = ({ y, h, label, unit, color, value, size, flash = 0, flashColor = color }) => (
  <div style={{ position: 'absolute', left: 724, top: y, width: 296, height: h, borderBottom: '2px solid #1a2226', color, overflow: 'hidden', background: flash > 0 ? `${flashColor}${Math.round(flash * 90).toString(16).padStart(2, '0')}` : undefined }}>
    <div style={{ position: 'absolute', left: 8, top: 8, font: `700 32px/1 ${MON}`, letterSpacing: '.06em', whiteSpace: 'nowrap' }}>{label}<div style={{ font: `600 28px/1 ${MON}`, marginTop: 6, letterSpacing: '.02em', opacity: 0.8 }}>{unit}</div></div>
    <div style={{ position: 'absolute', right: 10, bottom: 3, font: `700 ${size}px/.92 ${MON}`, whiteSpace: 'nowrap', transform: `scale(${1 + flash * 0.14})`, transformOrigin: '100% 100%' }}>{value}</div>
  </div>
);

const Numerics: React.FC = () => {
  const t = useT();
  const off = flatAt(t);
  const drops = flashOf(t, ['forty', 'thirty', 'twelve']);
  const lv = leavesAt(t);
  const lvColor = t >= E('shoot') ? GREEN : lv <= 12 ? RED : lv <= 40 ? AMBER : GREEN;
  return (
    <>
      <Num y={334} h={190} label={NUMS.leaves} unit="" color={lvColor} value={String(lv)} size={168} flash={Math.max(drops, flashOf(t, ['shoot', 'this']))} />
      <Num y={524} h={94} label={NUMS.soil[0]} unit={NUMS.soil[1]} color={CYAN} value={off ? '--' : String(soilAt(t))} size={88} flash={flashOf(t, ['recently'])} />
      <Num y={618} h={94} label={NUMS.sun[0]} unit={NUMS.sun[1]} color={AMBER} value={off ? '--' : String(sunHoursAt(t))} size={88} />
      <Num y={712} h={92} label={NUMS.sighs[0]} unit={NUMS.sighs[1]} color="#ff9aa2" value={off ? '--' : String(sighsAt(t))} size={88} flash={flashOf(t, ['work'])} />
    </>
  );
};

/* ---------- the banner ---------- */
const Banner: React.FC = () => {
  const t = useT();
  const charging = t >= E('charging') && t < E('pour');
  let text = 'MONITORING', lv: Level = 'info';
  for (const [e, s, l] of ALARMS) if (t >= E(e)) { text = s; lv = l; }
  const st = STOPS[lv];
  const hz = st.blink ?? 0;
  const on = hz ? Math.floor(t * hz * 2) % 2 === 0 : true;
  const fill = ramp(t, E('charging'), E('saucer') + 0.45);
  const shown = charging ? (fill >= 1 ? READY : `${CHARGING} ${Math.round(fill * 200)} ${UNIT}`) : text;
  return (
    <div style={{ position: 'absolute', left: 52, top: 252, width: 976, height: 74, overflow: 'hidden', background: charging ? '#07222e' : st.bg, opacity: on ? 1 : 0.62, textAlign: 'center', color: charging ? '#fff' : st.fg, font: `700 ${fit(shown, 930, 58, 0.6)}px/76px ${MON}`, letterSpacing: '.05em', whiteSpace: 'nowrap' }}>
      {charging ? <div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: `${fill * 100}%`, background: 'linear-gradient(90deg,#0e6f94,#20b6e8)', boxShadow: '0 0 30px #3ad7ff' }} /> : null}
      <span style={{ position: 'relative' }}>{shown}</span>
    </div>
  );
};

/* ---------- the fern ---------- */
type Fr = { a: number; len: number; curl: number; seed: number; tone: number; hero?: boolean };
const FRONDS: Fr[] = [
  { a: -84, len: 160, curl: -12, seed: 1, tone: 0.62 }, { a: 82, len: 166, curl: 14, seed: 2, tone: 0.62 }, { a: -58, len: 214, curl: -20, seed: 3, tone: 0.72 }, { a: 58, len: 208, curl: 22, seed: 4, tone: 0.72 },
  { a: -34, len: 236, curl: -24, seed: 5, tone: 0.86 }, { a: 33, len: 232, curl: 24, seed: 6, tone: 0.86 }, { a: -11, len: 244, curl: -22, seed: 7, tone: 1 }, { a: 11, len: 250, curl: 34, seed: 8, tone: 1, hero: true },
];
const fx = (n: number) => n.toFixed(1);
/** One frond is a bending spine with paired leaflets. `keep` is the share still attached, `dry` the share gone brown. */
const buildFrond = (f: Fr, a: number, sag: number, keep: number, dry: number) => {
  const N = 20, dl = f.len / N, side0 = f.a >= 0 ? 1 : -1;
  let x = 320, y = 250, stem = `M${x} ${y}`, g = '', b = '';
  for (let i = 0; i < N; i++) {
    const s = (i + 1) / N;
    const th = ((a + f.curl * s + side0 * sag * (0.3 + 0.7 * s * s)) * Math.PI) / 180;
    x += Math.sin(th) * dl; y -= Math.cos(th) * dl;
    stem += `L${fx(x)} ${fx(y)}`;
    const L = f.len * 0.27 * Math.pow(1 - s, 0.62) * Math.min(1, 0.4 + s * 3.4);
    for (const side of [-1, 1]) {
      if (hash(f.seed * 97 + i * 2 + side * 3) > keep) continue;
      const pa = th + side * (1.12 - 0.42 * s);
      const tx = x + Math.sin(pa) * L, ty = y - Math.cos(pa) * L, mx = x + Math.sin(pa) * L * 0.5, my = y - Math.cos(pa) * L * 0.5, wx = Math.cos(pa) * L * 0.2, wy = Math.sin(pa) * L * 0.2;
      const seg = `M${fx(x)} ${fx(y)}Q${fx(mx + wx)} ${fx(my + wy)} ${fx(tx)} ${fx(ty)}Q${fx(mx - wx)} ${fx(my - wy)} ${fx(x)} ${fx(y)}Z`;
      if (hash(f.seed * 53 + i * 5 + side) < dry * (0.45 + s * 0.9)) b += seg; else g += seg;
    }
  }
  return { stem, g, b, x, y };
};
const keepAt = (t: number) => 0.97 - 0.08 * ramp(t, E('crunchy'), E('crunchy') + 0.2) - 0.13 * ramp(t, E('forty'), E('forty') + 0.3) - 0.15 * ramp(t, E('thirty'), E('thirty') + 0.3) - 0.2 * ramp(t, E('twelve'), E('twelve') + 0.4) - 0.05 * ramp(t, E('frond'), E('frond') + 0.3) + 0.42 * ramp(t, E('moved'), E('discharge') + 1.5);
const dryAt = (t: number) => lerp(lerp(0.2, 0.62, ramp(t, E('crunchy') - 0.05, E('crunchy') + 0.15)), 0.04, ramp(t, E('pour') + 1.4, E('light') + 0.5));
const sagAt = (t: number) => lerp(30 * ramp(t, E('falling'), E('low')) + 26 * ramp(t, E('low'), E('low') + 2.2), 0, easeInOut(ramp(t, E('pour') + 1.2, E('light') + 0.4)));
const heroSagAt = (t: number) => {
  const fall = t >= E('frond') ? 58 * back(ramp(t, E('frond'), E('frond') + 0.28)) : 0;
  return lerp(fall + 112 * easeInOut(ramp(t, E('low') + 0.2, E('low') + 2.5)), 0, easeInOut(ramp(t, E('stable'), E('light') + 0.6)));
};

const Fern: React.FC = () => {
  const t = useT();
  const keep = keepAt(t), dry = dryAt(t), sag = sagAt(t);
  const alarm = t >= E('losing') && t < E('low') ? 1 : 0;
  const knock = (at: number, n: number) => (t >= at ? Math.exp(-(t - at) * 8) * Math.sin((t - at) * 40) * n : 0);
  const crack = knock(E('crunchy'), 3) + knock(0.95, 3.4);
  const leaf = tint('#3f8a38', '#58b53c', sunAt(t)), brown = '#a2803c';
  const clipTip = { x: 0, y: 0 };
  const paths = FRONDS.map((f) => {
    const a = f.a + Math.sin(t * 0.9 + f.seed) * 1.3 + alarm * Math.sin(t * 37 + f.seed) * 1.7 + crack;
    const r = buildFrond(f, a, f.hero ? heroSagAt(t) + sag * 0.3 : sag, keep, dry);
    if (f.seed === 6) { clipTip.x = r.x; clipTip.y = r.y; }
    return { f, r };
  });
  return (
    <g>
      {paths.map(({ f, r }) => (
        <g key={f.seed} opacity={f.tone < 0.8 ? 0.94 : 1}>
          <path d={r.g} fill={tint('#1f4d29', leaf, f.tone)} />
          <path d={r.b} fill={tint('#6a5428', brown, f.tone)} />
          <path d={r.stem} fill="none" stroke={tint('#223f1e', '#3c5f25', f.tone)} strokeWidth={3.2} strokeLinecap="round" strokeLinejoin="round" />
        </g>
      ))}
      {/* the sensor clipped to a frond, wired off camera */}
      <path d={`M${fx(clipTip.x)} ${fx(clipTip.y)}C${fx(clipTip.x + 60)} ${fx(clipTip.y - 30)} 580 44 760 26`} fill="none" stroke="#2a3a3a" strokeWidth={2} />
      <rect x={clipTip.x - 9} y={clipTip.y - 7} width={18} height={14} rx={4} fill="#d8e2df" />
      <circle cx={clipTip.x} cy={clipTip.y} r={4} fill={RED} opacity={flatAt(t) ? 0.3 : 1} />
    </g>
  );
};

/* Every leaf that falls stays where it lands until the bed moves. */
const SHED = ([['crunchy', 2], ['recently', 2], ['forty', 4], ['thirty', 5], ['twelve', 8], ['frond', 4], ['march', 1]] as [string, number][]).flatMap(([e, n], k) => Array.from({ length: n }, (_, i) => {
  const h = (m: number) => hash(k * 31 + i * 7 + m);
  return { at: E(e) + i * 0.06, x: 320 + (h(1) - 0.5) * 300, y: 50 + h(2) * 150, lx: 188 + h(3) * 268, ly: 378 + h(4) * 62, r: h(5) * 360, s: 1.1 + h(6) * 0.9, d: e === 'march' ? 2.1 : 0.85 + h(7) * 0.6 };
}));
const FallingLeaves: React.FC = () => {
  const t = useT();
  const gone = 1 - ramp(t, E('moved'), E('moved') + 0.4);
  return (
    <g opacity={gone}>
      {SHED.map((l, i) => {
        if (t < l.at) return null;
        const k = ramp(t, l.at, l.at + l.d);
        const x = lerp(l.x, l.lx, k) + Math.sin(k * 7 + i) * 26 * (1 - k), y = lerp(l.y, l.ly, k * k * (3 - 2 * k));
        return <path key={i} d="M0 -10 Q7 0 0 12 Q-7 0 0 -10Z" fill={i % 3 ? '#a2803c' : '#8a6a2e'} transform={`translate(${fx(x)} ${fx(y)}) rotate(${fx(l.r + k * 260)}) scale(${l.s})`} />;
      })}
    </g>
  );
};

/** While the shoot unrolls the room falls back behind it, so the one bright thing on screen is the new leaf. */
const ShootSpot: React.FC = () => {
  const t = useT();
  const k = ramp(t, E('pour') + 0.5, E('pour') + 1.2) * (1 - ramp(t, E('stable') - 0.3, E('stable') + 0.3));
  return k > 0 ? <rect x="-100" y="-100" width="900" height="700" fill="#16302c" opacity={0.5 * k} /> : null;
};

/** The fiddlehead: a real fern's new leaf arrives coiled, and it unrolls. */
const Shoot: React.FC = () => {
  const t = useT();
  const g = easeOut(ramp(t, E('pour') + 0.45, E('pour') + 2.0));
  if (g <= 0) return null;
  const u = easeInOut(ramp(t, E('pour') + 0.95, E('stable') + 0.25));
  const N = 46, dl = (186 * g) / N;
  let x = 322, y = 250;
  const pts: { x: number; y: number; s: number; th: number }[] = [{ x, y, s: 0, th: 0 }];
  for (let i = 0; i < N; i++) {
    const s = (i + 1) / N;
    const th = (-17 + 22 * s) * (Math.PI / 180) + (1 - u) * Math.pow(s, 3) * Math.PI * 3.6;
    x += Math.sin(th) * dl; y -= Math.cos(th) * dl;
    pts.push({ x, y, s, th });
  }
  const line = pts.map((p, i) => `${i ? 'L' : 'M'}${fx(p.x)} ${fx(p.y)}`).join('');
  const sprout = ramp(u, 0.45, 1);
  return (
    <g fill="none" strokeLinecap="round" strokeLinejoin="round">
      <path d={line} stroke="#c9ff8a" strokeWidth={36} opacity={0.1} /><path d={line} stroke="#c9ff8a" strokeWidth={24} opacity={0.14} />
      {pts.slice(1).map((p, i) => <line key={i} x1={pts[i].x} y1={pts[i].y} x2={p.x} y2={p.y} stroke={p.s > 0.72 && u < 0.9 ? '#d2ff6e' : '#8df25a'} strokeWidth={lerp(13, 5, p.s)} />)}
      {sprout > 0 ? pts.filter((_, i) => i % 4 === 2 && i > 6).flatMap((p, i) => [-1, 1].map((sd) => {
        const L = 40 * Math.pow(1 - p.s, 0.5) * sprout, pa = p.th + sd * 1.1;
        const tx = p.x + Math.sin(pa) * L, ty = p.y - Math.cos(pa) * L, wx = Math.cos(pa) * L * 0.22, wy = Math.sin(pa) * L * 0.22, mx = (p.x + tx) / 2, my = (p.y + ty) / 2;
        return <path key={`${i}${sd}`} d={`M${fx(p.x)} ${fx(p.y)}Q${fx(mx + wx)} ${fx(my + wy)} ${fx(tx)} ${fx(ty)}Q${fx(mx - wx)} ${fx(my - wy)} ${fx(p.x)} ${fx(p.y)}Z`} fill="#8df25a" stroke="none" />;
      })) : null}
    </g>
  );
};

/* ---------- the watering can, and the nurse's hand that brings it ---------- */
const canPose = (t: number) => {
  const slide = easeOut(ramp(t, E('get'), E('can') + 0.05));
  const lift = easeInOut(ramp(t, E('charging'), E('charging') + 0.55));
  const tilt = easeOut(ramp(t, E('pour') - 0.1, E('pour') + 0.32)) * (1 - easeInOut(ramp(t, E('stable') - 0.45, E('stable'))));
  const out = easeIn(ramp(t, E('stable') - 0.1, E('stable') + 0.5));
  return { cx: lerp(860, 500, slide) + out * 440, cy: 216 - lift * 46 + Math.sin(t * 34) * ramp(t, E('charging'), E('saucer')) * (t < E('pour') ? 1.2 : 0), rot: -26 * tilt, tilt };
};
const Can: React.FC = () => {
  const t = useT();
  if (t < E('get') || t > E('stable') + 0.7) return null;
  const { cx, cy, rot } = canPose(t);
  const glow = ramp(t, E('charging'), E('saucer') + 0.45) * (t < E('pour') + 0.3 ? 1 : 0);
  return (
    <g transform={`translate(${fx(cx)} ${fx(cy)}) rotate(${fx(rot)})`}>
      {glow > 0 ? <circle cx="0" cy="8" r={86 + 6 * Math.sin(t * 30)} fill="#3ad7ff" opacity={0.2 * glow} /> : null}
      <path d="M-42 12 L-126 -50 L-114 -64 L-36 -22 Z" fill="#3e8f6c" />
      <path d="M-138 -70 L-108 -40 L-98 -52 L-126 -80 Z" fill="#7ad2a8" stroke="#2c6d51" strokeWidth={2} />
      <path d="M-46 -34 L46 -34 L54 50 Q0 62 -54 50 Z" fill="#57b58a" stroke="#2c6d51" strokeWidth={3} />
      <path d="M-30 -30 L-24 50 L-40 48 L-44 -30 Z" fill="#8bdcb8" opacity={0.7} />
      <path d="M-50 4 Q0 12 50 4 L51 16 Q0 24 -51 16 Z" fill="#2c6d51" opacity={0.55} />
      <ellipse cx="0" cy="-34" rx="46" ry="9" fill="#3e8f6c" stroke="#2c6d51" strokeWidth={3} />
      <path d="M-16 -40 C-14 -96 66 -92 52 -8" fill="none" stroke="#2c6d51" strokeWidth={11} strokeLinecap="round" />
      {/* the sleeve runs off camera, and the glove grips the handle */}
      <path d="M92 -96 L460 -130 L460 -38 L100 -26 Z" fill="#2f8f83" />
      <path d="M92 -98 L110 -100 L118 -24 L100 -24 Z" fill="#1f6d63" />
      <path d="M36 -100 C60 -112 96 -106 100 -92 L104 -36 C88 -24 56 -30 44 -44 Z" fill="#a9b6f2" stroke="#5f6cb8" strokeWidth={2.5} />
      {[0, 1, 2, 3].map((i) => <rect key={i} x={20 + i * 3} y={-96 + i * 15} width={38} height={14} rx={7} fill="#bcc8fa" stroke="#5f6cb8" strokeWidth={2} />)}
    </g>
  );
};
const Water: React.FC = () => {
  const t = useT();
  const a = E('pour') + 0.14, b = E('stable') - 0.35;
  if (t < a || t > b + 0.5) return null;
  const { cx, cy, rot } = canPose(t);
  const r = (rot * Math.PI) / 180, rx = -136, ry = -66;
  const sx = cx + rx * Math.cos(r) - ry * Math.sin(r), sy = cy + rx * Math.sin(r) + ry * Math.cos(r);
  return (
    <g>
      {Array.from({ length: 64 }, (_, i) => {
        const k = (t * 2.6 + hash(i)) % 1;
        const born = t - k / 2.6;
        if (born < a || born > b) return null;
        const spread = 26 + (hash(i + 40) - 0.5) * 66;
        return <ellipse key={i} cx={fx(lerp(sx, 322 + spread, k) - 8 * Math.sin(k * 3))} cy={fx(lerp(sy, 250, k * k))} rx={2.6} ry={5} fill="#bfeaff" opacity={0.92} />;
      })}
      {Array.from({ length: 14 }, (_, i) => {
        const k = (t * 3.4 + hash(i + 90)) % 1;
        const dir = (hash(i + 110) - 0.5) * 2;
        return <circle key={`s${i}`} cx={348 + dir * 44 * k} cy={248 - 22 * Math.sin(k * Math.PI)} r={2.2} fill="#d9f4ff" opacity={1 - k} />;
      })}
    </g>
  );
};

/* ---------- the window: the bed camera ---------- */
const VW = 640, VH = 470;
const zoom = (s: number, x: number, y: number) => ({ s, tx: x * (1 - s), ty: y * (1 - s) });
const KEYS: { at: number; d: number; v: ReturnType<typeof zoom> }[] = [
  { at: E('on'), d: 0.01, v: zoom(1.1, 320, 220) },
  { at: E('on'), d: 2.8, v: zoom(1, 320, 235) },
  { at: E('crunchy') - 0.1, d: 0.22, v: zoom(1.3, 320, 110) },
  { at: E('last') - 0.05, d: 0.42, v: zoom(2.5, 17, 12) },
  { at: E('owner') - 0.05, d: 0.5, v: zoom(1, 320, 235) },
  { at: E('falling'), d: 2.6, v: zoom(1.18, 320, 200) },
  { at: E('losing'), d: 0.2, v: zoom(1.32, 320, 210) },
  { at: E('low'), d: 2.2, v: zoom(1.16, 330, 225) },
  { at: E('get'), d: 0.8, v: zoom(1, 320, 235) },
  { at: E('pour'), d: 0.5, v: zoom(1.16, 370, 200) },
  { at: E('pour') + 0.85, d: 1.1, v: zoom(2.05, 322, 150) },
  { at: E('stable') - 0.1, d: 0.8, v: zoom(1, 320, 235) },
  { at: E('discharge'), d: 4, v: zoom(1.07, 320, 300) },
];
const camAt = (t: number) => {
  let v = KEYS[0].v;
  for (const k of KEYS.slice(1)) {
    if (t < k.at) break;
    const q = easeInOut(ramp(t, k.at, k.at + k.d));
    v = { s: lerp(v.s, k.v.s, q), tx: lerp(v.tx, k.v.tx, q), ty: lerp(v.ty, k.v.ty, q) };
  }
  return v;
};
const panAt = (t: number) => -600 * easeInOut(ramp(t, E('moved') - 0.05, E('moved') + 1.25));

/** The room is wider than the shot: the ward on the left, the bright window on the right. Moving the patient is one long pan. */
const Room: React.FC = () => {
  const t = useT();
  const sun = sunAt(t);
  const march = t >= E('march') ? Math.min(1, ramp(t, E('march'), E('march') + 0.2)) : 0;
  return (
    <div style={{ position: 'absolute', left: panAt(t), top: 0, width: 1240, height: VH }}>
      <svg viewBox={`0 0 1240 ${VH}`} width={1240} height={VH} style={{ position: 'absolute', left: 0, top: 0 }}>
        <defs>
          <linearGradient id="rw" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#bcd8cf" /><stop offset="1" stopColor="#a6c5ba" /></linearGradient>
          <linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#8fcdf2" /><stop offset="1" stopColor="#fff2c8" /></linearGradient>
          <radialGradient id="sn" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stopColor="#fffbe2" /><stop offset="0.3" stopColor="#ffe89a" stopOpacity="0.85" /><stop offset="1" stopColor="#ffe89a" stopOpacity="0" /></radialGradient>
        </defs>
        <rect x="0" y="0" width="1240" height="352" fill="url(#rw)" />
        <rect x="0" y="300" width="1240" height="52" fill="#8fb1a6" /><rect x="0" y="296" width="1240" height="5" fill="#dcebe5" />
        <rect x="0" y="352" width="1240" height="118" fill="#6f8d86" />
        {Array.from({ length: 4 }, (_, i) => <rect key={i} x="0" y={372 + i * i * 9 + i * 12} width="1240" height="2" fill="#5d7a73" />)}
        {Array.from({ length: 26 }, (_, i) => <rect key={i} x={i * 50} y="352" width="2" height="118" fill="#5d7a73" opacity={0.5} />)}
        {/* the curtain */}
        <rect x="0" y="16" width="130" height="5" fill="#dcebe5" />
        {Array.from({ length: 6 }, (_, i) => <rect key={i} x={i * 16} y="20" width="16" height="330" fill={i % 2 ? '#6d9a90' : '#7fada2'} />)}
        {/* the calendar nobody has turned */}
        <g transform="rotate(1.5 540 60)">
          <rect x="492" y="56" width="104" height="116" fill="#fbfbf7" stroke="#7c938c" strokeWidth={2} />
          <rect x="492" y="56" width="104" height="34" fill={PEN} />
          {Array.from({ length: 20 }, (_, i) => <rect key={i} x={502 + (i % 5) * 18} y={100 + Math.floor(i / 5) * 16} width={11} height={10} fill={i === 8 ? '#3f8a38' : '#b8c6c1'} />)}
        </g>
        {march > 0 ? <circle cx="544" cy="114" r={84} fill="#ffd97a" opacity={0.32 * march} /> : null}
        {/* the bright window */}
        <rect x="720" y="24" width="470" height="282" fill="#f4f6f0" />
        <rect x="734" y="38" width="442" height="256" fill="url(#sky)" />
        <circle cx="1100" cy="96" r="150" fill="url(#sn)" opacity={0.5 + 0.5 * sun} />
        {[[800, 120, 1], [960, 80, 0.8], [1060, 190, 0.7]].map(([x, y, s], i) => <g key={i} transform={`translate(${x + Math.sin(t * 0.2 + i) * 8} ${y}) scale(${s})`} fill="#fff" opacity={0.75}><ellipse cx="0" cy="0" rx="46" ry="15" /><ellipse cx="24" cy="-11" rx="26" ry="15" /><ellipse cx="-22" cy="-7" rx="20" ry="11" /></g>)}
        <rect x="873" y="38" width="10" height="256" fill="#f4f6f0" /><rect x="1024" y="38" width="10" height="256" fill="#f4f6f0" />
        <rect x="706" y="298" width="498" height="16" rx="3" fill="#fbfbf7" />
        {/* the window's other patients, doing fine */}
        <g transform="translate(790 256)"><path d="M-16 8 L16 8 L12 42 L-12 42 Z" fill="#c96f43" /><path d="M0 8 C-4 -22 -26 -28 -30 -36 C-12 -34 0 -24 0 8 C2 -24 16 -38 30 -40 C24 -26 6 -18 0 8" fill="#58b53c" /></g>
        <g transform="translate(1120 256)"><path d="M-16 8 L16 8 L12 42 L-12 42 Z" fill="#c96f43" /><rect x="-13" y="-40" width="26" height="48" rx="13" fill="#4f9c5a" /></g>
        <path d="M640 470 L840 352 L1190 352 L1100 470 Z" fill="#ffe9a6" opacity={0.42 * sun} />
      </svg>
      <div style={{ position: 'absolute', left: 492, top: 57, width: 104, height: 34, textAlign: 'center', transform: 'rotate(1.5deg)', font: `700 28px/34px ${MON}`, letterSpacing: '.06em', color: '#fff' }}>{MONTH}</div>
    </div>
  );
};

const Bed: React.FC = () => {
  const t = useT();
  const pan = ramp(t, E('moved') - 0.05, E('moved') + 1.25);
  const jolt = pan > 0 && pan < 1 ? Math.sin(t * 42) * 1.4 : 0;
  const breath = flatAt(t) ? 0 : Math.sin(t * 2.1) * 2.4;
  const wet = ramp(t, E('pour') + 0.25, E('pour') + 1.3);
  const folded = easeInOut(ramp(t, E('discharge'), E('discharge') + 0.8));
  const ring = ramp(t, E('stand'), E('stand') + 0.15) * (1 - ramp(t, E('pour') + 0.2, E('pour') + 0.5));
  const bag = ramp(t, E('pour') + 0.3, E('stable'));
  const spiderY = lerp(lerp(64, 118, easeInOut(ramp(t, E('last') + 0.2, E('owner')))), 50, ramp(t, E('pour'), E('pour') + 0.5));
  return (
    <svg viewBox={`0 0 ${VW} ${VH}`} width={VW} height={VH} style={{ position: 'absolute', left: 0, top: 0, overflow: 'hidden' }}>
      <defs>
        <linearGradient id="pot" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stopColor="#a4552f" /><stop offset="0.45" stopColor="#d27c4e" /><stop offset="1" stopColor="#9a4f2b" /></linearGradient>
      </defs>
      <g transform={`translate(0 ${fx(jolt)})`}>
        {/* the drip stand, with a bag that says what it should have been */}
        <rect x="126" y="44" width="6" height="312" fill="#c9d6d3" /><path d="M96 366 L129 350 L162 366" fill="none" stroke="#c9d6d3" strokeWidth={6} /><path d="M104 44 L154 44" stroke="#c9d6d3" strokeWidth={5} />
        <path d="M100 50 L158 50 L154 146 Q129 158 104 146 Z" fill="rgba(235,245,248,.55)" stroke="#8fa6a6" strokeWidth={2} />
        <path d={`M${lerp(112, 103, bag)} ${lerp(138, 76, bag)} L${lerp(146, 155, bag)} ${lerp(138, 76, bag)} L154 146 Q129 158 104 146 Z`} fill="#7cc9ee" opacity={0.25 + 0.6 * bag} />
        <path d="M129 152 C129 240 210 262 262 300" fill="none" stroke="rgba(235,245,248,.8)" strokeWidth={3} />
        {/* nothing has dripped from this bag in a long time */}
        <g fill="none" stroke="#ffffff" strokeWidth={1.3} opacity={0.9}><path d="M132 44 L132 92 M132 44 L146 86 M132 44 L158 74 M132 44 L164 58 M132 58 Q141 60 146 52 M132 70 Q145 72 154 58 M132 82 Q150 84 160 62" /></g>
        <path d={`M146 44 L146 ${fx(spiderY)}`} stroke="#f4f8f8" strokeWidth={0.8} /><g transform={`translate(146 ${fx(spiderY)}) scale(1.4)`} stroke="#1c2422" strokeWidth={0.9}><path d="M-6 -3 L6 3 M-6 3 L6 -3 M-7 0 L7 0" /><circle r={3.2} fill="#1c2422" stroke="none" /></g>
        {/* the over-bed table the can slides along */}
        <rect x="430" y="270" width="226" height="11" rx="3" fill="#e7eeec" /><rect x="430" y="281" width="226" height="5" fill="#a8bab5" /><rect x="590" y="286" width="12" height="112" fill="#b8c8c4" />
        {/* the bed */}
        <rect x="176" y="192" width="288" height="154" rx="14" fill="rgba(255,255,255,.2)" stroke="#e4ece9" strokeWidth={10} />
        {[244, 320, 396].map((x) => <rect key={x} x={x - 3} y={196} width={6} height={150} fill="#e4ece9" opacity={0.7} />)}
        <path d="M176 338 L464 338 L536 470 L104 470 Z" fill="#eef3f2" />
        <rect x="212" y="274" width="216" height="72" rx="34" fill="#fbfdfc" stroke="#d4dedb" strokeWidth={2} />
        <ellipse cx="320" cy="352" rx="84" ry="19" fill="#94492a" /><ellipse cx="320" cy="348" rx="78" ry="15" fill="#b9623a" />
        {/* the pot */}
        <path d="M260 262 L380 262 L362 350 Q320 360 278 350 Z" fill="url(#pot)" />
        <rect x="250" y="240" width="140" height="28" rx="8" fill="url(#pot)" stroke="#8d4726" strokeWidth={1.5} />
        <ellipse cx="320" cy="243" rx="63" ry="10" fill={tint('#bda680', '#3b2a1c', wet)} />
        <g opacity={1 - wet} fill="none" stroke="#8a7551" strokeWidth={1.6}><path d="M276 243 L292 239 L300 246 L316 241" /><path d="M330 247 L346 241 L360 245" /></g>
        <rect x="272" y="288" width="96" height="27" rx="3" fill="#f7fbfa" stroke="#b9c8c4" strokeWidth={1.5} /><rect x="272" y="288" width="9" height="27" fill="#3ad7ff" />
      </g>
      <g transform={`translate(0 ${fx(jolt)})`}><Fern /><ShootSpot /><Water /><Shoot /></g>
      <g transform={`translate(0 ${fx(jolt + breath * (1 - folded) + folded * 62)})`}>
        <path d="M144 372 Q320 344 496 372 L560 490 L80 490 Z" fill="#dfe9f2" />
        <path d="M138 370 Q320 340 502 370 L506 388 Q320 360 134 388 Z" fill="#f4f8fb" stroke="#c6d3de" strokeWidth={1.5} />
        <path d="M127 402 Q320 374 513 402 L519 414 Q320 386 121 414 Z" fill="#7f95c9" /><path d="M116 424 Q320 396 524 424 L528 432 Q320 404 112 432 Z" fill="#7f95c9" />
      </g>
      <FallingLeaves />
      <g transform={`translate(0 ${fx(jolt)})`}>
        <path d="M122 356 L92 470" stroke="#d6e0dd" strokeWidth={8} /><path d="M518 356 L548 470" stroke="#d6e0dd" strokeWidth={8} />
        <rect x="84" y="436" width="472" height="44" rx="8" fill="#e4ece9" stroke="#b5c4c0" strokeWidth={2} />
      </g>
      {ring > 0 ? <ellipse cx="320" cy="350" rx={116} ry={34} fill="none" stroke={AMBER} strokeWidth={6} strokeDasharray="18 13" strokeDashoffset={-t * 90} opacity={ring} /> : null}
      <Can />
    </svg>
  );
};

/* Words inside the shot move with the camera, so they stay HTML in the same wrapper. */
const BedLabels: React.FC = () => {
  const t = useT();
  const ring = ramp(t, E('stand'), E('stand') + 0.15) * (1 - ramp(t, E('pour') + 0.2, E('pour') + 0.5));
  const sign = back(ramp(t, E('stop'), E('stop') + 0.24));
  const sway = Math.sin((t - E('stop')) * 5) * Math.exp(-(t - E('stop')) * 1.4) * 7;
  return (
    <>
      <div style={{ position: 'absolute', left: 283, top: 288, width: 85, height: 27, textAlign: 'center', font: `700 22px/28px ${MON}`, color: '#223' }}>{BAND}</div>
      <div style={{ position: 'absolute', left: 100, top: 88, width: 58, textAlign: 'center', font: `700 24px/1 ${MON}`, color: '#31545f' }}>{BAG}</div>
      {ring > 0 ? <div style={{ position: 'absolute', left: 220, top: 388, width: 200, textAlign: 'center', background: AMBER, color: '#111', font: `700 30px/38px ${MON}`, letterSpacing: '.04em', opacity: ring, borderRadius: 4 }}>{CLEAR}</div> : null}
      {t >= E('stop') ? <div style={{ position: 'absolute', left: 196, top: 384, width: 248, height: 70, background: '#fbfbf7', border: `4px solid ${PEN}`, boxSizing: 'border-box', textAlign: 'center', color: PEN, font: `700 ${fit(DISCHARGE.sign, 226, 34, 0.56)}px/60px ${MON}`, letterSpacing: '.03em', whiteSpace: 'nowrap', transformOrigin: '50% 0', transform: `rotate(${sway}deg) scale(${sign})` }}>{DISCHARGE.sign}</div> : null}
    </>
  );
};

const Window: React.FC = () => {
  const t = useT();
  const c = camAt(t);
  const dim = dimAt(t), red = redAt(t), sun = sunAt(t);
  const shake = t >= E('losing') && t < E('low') ? Math.sin(t * 57) * 3 : 0;
  return (
    <div style={{ position: 'absolute', left: 66, top: 334, width: VW, height: VH, overflow: 'hidden', background: '#000', boxShadow: '0 0 0 3px #1b8f8a' }}>
      <div style={{ position: 'absolute', left: 0, top: 0, width: VW, height: VH, transformOrigin: '0 0', transform: `translate(${c.tx + shake}px, ${c.ty}px) scale(${c.s})` }}>
        <Room />
        {/* the admission: the whole bed rolls into shot on the first frame */}
        <div style={{ position: 'absolute', left: 0, top: 0, width: VW, height: VH, transform: `translateX(${lerp(-300, 0, easeOut(ramp(t, 0, 0.95)))}px)` }}>
          <Bed />
          <BedLabels />
        </div>
      </div>
      {red > 0 ? <div style={{ position: 'absolute', inset: 0, background: '#ff2a3a', opacity: 0.3 * red }} /> : null}
      {sun > 0 ? <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(95% 95% at 82% 8%, rgba(255,240,180,.5), rgba(255,222,140,.12) 50%, rgba(255,222,140,0) 80%)', opacity: sun }} /> : null}
      {dim > 0 ? <div style={{ position: 'absolute', inset: 0, background: `radial-gradient(62% 62% at 50% 46%, rgba(90,140,150,${0.3 * dim}), rgba(4,14,20,${0.42 * dim}))` }} /> : null}
      <div style={{ position: 'absolute', inset: 0, background: 'repeating-linear-gradient(rgba(0,0,0,.05) 0 2px, transparent 2px 5px)' }} />
      <div style={{ position: 'absolute', left: 12, top: 8, font: `700 32px/1 ${MON}`, color: '#fff', textShadow: '0 0 5px #000,0 0 2px #000', letterSpacing: '.04em' }}>{CAM}</div>
      <div style={{ position: 'absolute', right: 14, top: 12, width: 20, height: 20, borderRadius: '50%', background: RED, opacity: Math.floor(t * 1.5) % 2 ? 0.3 : 1 }} />
    </div>
  );
};

/* ---------- the monitor ---------- */
const beatGlow = (t: number) => (flatAt(t) ? 0 : 1 - ramp(t - BEATS[beatIndex(t)], 0.2, 0.42));

const Header: React.FC = () => {
  const t = useT();
  const k = beatGlow(t);
  const clock = 42 + Math.floor((t + 7) / 60);
  const sec = Math.floor(t + 7) % 60;
  return (
    <>
      <svg viewBox="0 0 32 30" width={68} height={64} style={{ position: 'absolute', left: 72, top: 72, transform: `scale(${1 + k * 0.14})`, opacity: flatAt(t) ? 0.25 : 0.5 + 0.5 * k }}>
        <path d="M16 28 C-6 12 2 -2 16 8 C30 -2 38 12 16 28Z" fill={flatAt(t) ? '#7c8a84' : RED} />
      </svg>
      <div style={{ position: 'absolute', left: 156, top: 64, font: `700 86px/1 ${MON}`, color: '#eef6f2', letterSpacing: '.02em' }}>{PATIENT}<span style={{ font: `600 44px/1 ${MON}`, color: '#8fa39c', marginLeft: 18 }}>{AGE}</span></div>
      <div style={{ position: 'absolute', right: 66, top: 62, textAlign: 'right', font: `600 34px/40px ${MON}`, color: '#8fa39c', letterSpacing: '.05em' }}>{WARD}<br /><span style={{ color: CYAN }}>{BED}  11:{clock}:{String(sec).padStart(2, '0')}</span></div>
      <div style={{ position: 'absolute', left: 66, top: 166, width: 948, height: 58, overflow: 'hidden', background: '#0a1114', borderRadius: 6, font: `600 36px/58px ${MON}`, color: '#6f8a80', whiteSpace: 'pre', letterSpacing: '.04em' }}>
        <div style={{ position: 'absolute', left: 0, top: 0, transform: `translateX(${-t * 74}px)` }}>{TICKER.repeat(4)}</div>
      </div>
    </>
  );
};

const TraceLabels: React.FC = () => {
  const t = useT();
  const off = flatAt(t);
  const bpm = Math.round(bpmAt(t) + env(t) * 12 + (t >= E('losing') && t < E('low') ? hash(Math.floor(t * 5)) * 12 : 0));
  return (
    <>
      <div style={{ position: 'absolute', left: 72, top: 818, font: `700 30px/1 ${MON}`, color: GREEN, letterSpacing: '.04em' }}>{TRACES[0]}</div>
      <div style={{ position: 'absolute', left: 860, top: 816, width: 140, textAlign: 'right', font: `700 40px/1 ${MON}`, color: off ? '#ff8a93' : GREEN }}>{off ? '---' : bpm}</div>
      <div style={{ position: 'absolute', left: 72, top: 930, font: `700 28px/1 ${MON}`, color: CYAN, letterSpacing: '.04em' }}>{TRACES[1]}</div>
    </>
  );
};

const Monitor: React.FC = () => {
  const t = useT();
  const boot = ramp(t, 0, 0.4);
  const scan = t < 0.75 ? 48 + ramp(t, 0, 0.7) * 962 : -100;
  const red = redAt(t);
  const bar = red > 0 ? RED : t >= E('falling') && t < E('losing') ? AMBER : flatAt(t) && t < E('can') ? '#3a2226' : GREEN;
  const jolt = t >= E('losing') && t < E('low') ? Math.sin(t * 61) * 1.4 : 0;
  return (
    <div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1100, transform: `translateX(${jolt}px)` }}>
      <div style={{ position: 'absolute', left: 30, top: 24, width: 1020, height: 1044, borderRadius: 30, background: `linear-gradient(170deg,${tint('#3b4146', '#7c7664', sunAt(t))},${tint(BEZEL, '#45443d', sunAt(t))} 26%,#20252a)`, boxShadow: '0 22px 42px rgba(0,40,30,.42), inset 0 2px 0 rgba(255,255,255,.08)' }} />
      <div style={{ position: 'absolute', left: 400, top: 30, width: 280, height: 10, borderRadius: 5, background: bar, boxShadow: `0 0 18px ${bar}`, opacity: red > 0 ? 0.35 + 0.65 * red : 0.85 }} />
      <div style={{ position: 'absolute', left: 52, top: 46, width: 976, height: 964, background: SCREEN, borderRadius: 10, boxShadow: 'inset 0 0 0 2px #000' }} />
      <div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1010, opacity: lerp(0.35, 1, boot) }}>
        <Header />
        <Window />
        <Numerics />
        {[858, 906, 954, 1002].map((y) => <div key={y} style={{ position: 'absolute', left: 66, top: y, width: 950, height: 1, background: '#0f2a22', opacity: 0.8 }} />)}
        <TraceLabels />
      </div>
      <div style={{ position: 'absolute', left: 52, top: scan, width: 976, height: 18, background: 'linear-gradient(transparent,rgba(160,255,200,.5),transparent)' }} />
      {/* the glass */}
      <div style={{ position: 'absolute', left: 52, top: 46, width: 976, height: 964, borderRadius: 10, pointerEvents: 'none', background: 'linear-gradient(118deg, rgba(255,255,255,.07) 0%, rgba(255,255,255,0) 22%, rgba(255,255,255,0) 58%, rgba(255,255,255,.035) 70%, rgba(255,255,255,0) 82%)' }} />
      {/* the lower bezel: the name, the sticker, the printer slot */}
      <div style={{ position: 'absolute', left: 66, top: 1013, font: `700 24px/1 ${MON}`, letterSpacing: '.24em', color: '#8a949a' }}>{MODEL}</div>
      <div style={{ position: 'absolute', left: 318, top: 1012, padding: '0 10px', background: '#e8d9a0', color: '#3a3320', font: `700 20px/25px ${MON}`, letterSpacing: '.08em', transform: 'rotate(-1deg)' }}>{STICKER}</div>
      <div style={{ position: 'absolute', right: 56, top: 1014, width: 20, height: 20, borderRadius: '50%', background: GREEN, boxShadow: `0 0 10px ${GREEN}` }} />
      <div style={{ position: 'absolute', left: 116, top: 1044, width: 848, height: 12, borderRadius: 6, background: '#0b0d0f', boxShadow: 'inset 0 2px 3px #000, 0 1px 0 rgba(255,255,255,.1)' }} />
    </div>
  );
};

/* ---------- the chart ---------- */
const Scrawl: React.FC<{ at: number; d?: number; style: React.CSSProperties; children: React.ReactNode }> = ({ at, d = 0.35, style, children }) => {
  const t = useT();
  if (t < at) return null;
  const k = ramp(t, at, at + d);
  return <div style={{ position: 'absolute', whiteSpace: 'nowrap', clipPath: `inset(-30% ${k >= 1 ? '-30%' : `${(1 - k) * 100}%`} -30% -5%)`, ...style }}>{children}</div>;
};
const Stroke: React.FC<{ d: string; at: number; dur: number; color?: string; width: number }> = ({ d, at, dur, color = PEN, width }) => {
  const t = useT();
  if (t < at) return null;
  return <path d={d} fill="none" stroke={color} strokeWidth={width} strokeLinecap="round" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - ramp(t, at, at + dur)} />;
};
const Printed: React.FC<{ x: number; y: number; children: React.ReactNode }> = ({ x, y, children }) => <div style={{ position: 'absolute', left: x, top: y, font: `700 25px/1 ${MON}`, letterSpacing: '.07em', color: '#5d6a70' }}>{children}</div>;

const Chart: React.FC = () => {
  const t = useT();
  const paper = tint('#fbfbf7', '#fffaeb', sunAt(t));
  return (
  <>
    <div style={{ position: 'absolute', left: 80, top: 1040, width: 920, height: 900, background: 'linear-gradient(100deg,#7b4e24,#946233 40%,#7f5228)', borderRadius: 26, boxShadow: '0 14px 30px rgba(0,40,30,.38)' }} />
    <div style={{ position: 'absolute', left: 104, top: 1080, width: 872, height: 860, backgroundColor: paper, boxShadow: '0 3px 8px rgba(40,20,0,.35)', backgroundImage: 'repeating-linear-gradient(transparent 0 80px,#c9d6e8 80px 82px)', backgroundPosition: '0 312px', backgroundSize: '100% 82px', backgroundRepeat: 'repeat-y' }} />
    {/* the form above the notes covers the ruling */}
    <div style={{ position: 'absolute', left: 104, top: 1080, width: 872, height: 234, background: paper, borderBottom: '3px solid #c9d6e8' }} />
    <div style={{ position: 'absolute', left: 124, top: 1156, width: 832, height: 3, background: PEN }} />
    <div style={{ position: 'absolute', left: 124, top: 1212, width: 832, height: 2, background: '#dfe5ea' }} />
    <div style={{ position: 'absolute', left: 124, top: 1266, width: 832, height: 2, background: '#dfe5ea' }} />
    {CHART.head.map(([l, v, e, x, w]) => (
      <React.Fragment key={l}>
        <Printed x={x} y={1124}>{l}</Printed>
        <Scrawl at={E(e)} d={0.25} style={{ left: x + w + 16, top: 1102, font: `700 54px/1 ${HAND}`, color: INK }}>{v}</Scrawl>
      </React.Fragment>
    ))}
    {CHART.rows.map(([l, lx, v, vx, e, y, c]) => (
      <React.Fragment key={l}>
        <Printed x={lx} y={y + 16}>{l}</Printed>
        <Scrawl at={E(e)} style={{ left: vx, top: y - 4, font: `700 56px/1 ${HAND}`, color: c }}>{v}</Scrawl>
      </React.Fragment>
    ))}
    <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, pointerEvents: 'none' }}>
      <Stroke d="M640 1264 C712 1259 782 1266 858 1261" at={E('recently') + 0.3} dur={0.2} width={5} />
      <Stroke d="M646 1272 C718 1267 788 1274 852 1269" at={E('recently') + 0.56} dur={0.2} width={5} />
    </svg>
    <Printed x={130} y={1282}>{CHART.follow}</Printed>
    {OLD_NOTES.map((n, i) => <div key={n} style={{ position: 'absolute', left: 140, top: 1490 + i * 82, font: `700 58px/82px ${HAND}`, color: INK, opacity: 0.22 }}>{n}</div>)}
  </>
  );
};
const FollowUp: React.FC = () => <Scrawl at={E('cta') + 0.1} d={0.55} style={{ left: 310, top: 1267, font: `700 ${fit(CHART.last, 630, 54, 0.46)}px/1 ${HAND}`, color: PEN }}>{CHART.last}</Scrawl>;

const Clip: React.FC = () => (
  <div style={{ position: 'absolute', left: 420, top: 1058, width: 240, height: 58, borderRadius: 14, background: 'linear-gradient(#e4e7ea,#a1a8ae 60%,#7f868c)', boxShadow: '0 5px 9px rgba(0,0,0,.4)' }}>
    <div style={{ position: 'absolute', left: 92, top: 14, width: 56, height: 16, borderRadius: 8, background: '#5a6066', boxShadow: 'inset 0 2px 2px #2c3034' }} />
  </div>
);

/** The discharge notes feed down out of the slot, a line at a time, and get torn off for the last line of the chart. */
const Sheet: React.FC = () => {
  const t = useT();
  const at = E('discharge') - 0.04;
  if (t < at) return null;
  const fall = ramp(t, E('cta') - 0.05, E('cta') + 0.4);
  if (fall >= 1) return null;
  const feed = 72 * easeOut(ramp(t, at, at + 0.3)) + 90 * easeOut(ramp(t, E('weekly') - 0.3, E('weekly'))) + 102 * easeOut(ramp(t, E('stop'), E('stop') + 0.3));
  const h = Math.floor(feed / 6) * 6;
  return (
    <div style={{ position: 'absolute', left: 124, top: 1050, width: 832, height: h, overflow: 'hidden', background: 'linear-gradient(#e5e2d6,#f5f2e7 14px,#f7f5ea)', boxShadow: '0 12px 20px rgba(30,20,0,.4)', transformOrigin: '0 0', transform: `translate(${easeIn(fall) * 1150}px, ${fall * 60}px) rotate(${fall * 9}deg)`, color: '#14181c', zIndex: 2 }}>
      <div style={{ position: 'absolute', left: 26, top: 12, font: `700 ${fit(DISCHARGE.head, 780, 52, 0.56)}px/1 ${MON}`, letterSpacing: '.05em', whiteSpace: 'nowrap' }}>{DISCHARGE.head}</div>
      <div style={{ position: 'absolute', left: 26, top: 84, font: `700 ${fit(DISCHARGE.one, 780, 54, 0.52)}px/1 ${MON}`, letterSpacing: '.02em', whiteSpace: 'nowrap' }}>{DISCHARGE.one}</div>
      <div style={{ position: 'absolute', left: 26, top: 164, font: `700 ${fit(DISCHARGE.two, 780, 54, 0.5)}px/1 ${MON}`, letterSpacing: '.01em', whiteSpace: 'nowrap', color: PEN }}>{DISCHARGE.two}</div>
    </div>
  );
};

/* ---------- captions: the doctor's handwriting, in the chart's ruled lines ---------- */
const PER_ROW = 28;
const CHUNKS = buildChunks(PER_ROW, 2);
const CAP_ROWS = [1318, 1400];

const Captions: React.FC = () => {
  const t = useT();
  if (t >= E('pour') && t < E('stable') - 0.45) {
    if (t < E('shoot')) return null;
    const k = back(ramp(t, E('shoot'), E('shoot') + 0.22));
    return (
      <>
        <div style={{ position: 'absolute', left: 124, top: 1336, width: 832, textAlign: 'center', font: `700 ${fit(SHOUT, 800, 124, 0.42)}px/1 ${HAND}`, color: PEN, whiteSpace: 'nowrap', transform: `scale(${lerp(1.25, 1, k)}) rotate(-1deg)` }}>{SHOUT}</div>
        <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0 }}>
          <Stroke d="M214 1458 C430 1444 650 1466 866 1448" at={E('shoot') + 0.28} dur={0.26} width={7} />
        </svg>
      </>
    );
  }
  const idx = CHUNKS.reduce((a, c, i) => (t >= c.t0 - 0.06 ? i : a), -1);
  if (idx < 0) return null;
  const c = CHUNKS[idx];
  const live = c.words.findIndex((w) => t >= w.t0 - 0.03 && t < w.t1 + 0.08);
  const dim = dimAt(t);
  return (
    <>
      {c.rows.map((r, ri) => {
        const ids = Array.from(new Set(r.map.filter((i) => i >= 0)));
        return (
          <div key={`${idx}-${ri}`} style={{ position: 'absolute', left: 132, top: CAP_ROWS[ri], width: 816, height: 80, whiteSpace: 'nowrap', font: `700 76px/80px ${HAND}`, color: tint(INK, '#0a1440', dim) }}>
            {ids.map((wi) => {
              const w = c.words[wi];
              const said = t >= w.t0 - 0.03;
              const hot = wi === live || (t >= E('cta') && w.w.toUpperCase().replace(/[^A-Z]/g, '') === KEYWORD);
              return <span key={wi} style={{ color: hot ? PEN : undefined, opacity: said ? 1 : 0.16, marginRight: 15 }}>{w.w.replace(/[.,;:!?"]$/, '')}</span>;
            })}
          </div>
        );
      })}
    </>
  );
};

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

const Light: React.FC = () => {
  const t = useT();
  const d = dimAt(t), r = redAt(t), s = sunAt(t);
  return (
    <>
      {r > 0 ? <AbsoluteFill style={{ background: '#ff2030', opacity: 0.22 * r }} /> : null}
      {/* the sun lands on the paper; a screen makes its own light, so it never washes over the monitor */}
      {s > 0 ? <div style={{ position: 'absolute', left: 0, top: 1068, width: 1080, height: 852, background: 'linear-gradient(205deg, rgba(255,214,120,.24) 0%, rgba(255,214,120,.07) 46%, rgba(255,214,120,0) 80%)', opacity: s }} /> : null}
      {d > 0 ? <AbsoluteFill style={{ background: `linear-gradient(rgba(2,8,14,${0.6 * d}) 0, rgba(2,8,14,${0.52 * d}) 1040px, rgba(2,8,14,${0.3 * d}) 1110px, rgba(2,8,14,${0.3 * d}) 1480px, rgba(2,8,14,${0.5 * d}) 1920px)` }} /> : null}
    </>
  );
};

const Stage: React.FC = () => {
  const t = useT();
  return (
    <AbsoluteFill style={{ background: tint(WALL, '#f6edc6', sunAt(t)), backgroundImage: 'linear-gradient(90deg,rgba(0,0,0,.05) 2px,transparent 2px)', backgroundSize: '120px 100%', fontFamily: MON, overflow: 'hidden' }}>
      <Chart />
      <Monitor />
      <FollowUp />
      <Clip />
      <Sheet />
      <Light />
      {/* these three carry the film through the dark, so they sit above the room's light */}
      <Trace x={66} base={906} scale={76} color={GREEN} fn={ecg} width={flatAt(t) ? 7 : 5} />
      <Trace x={66} base={1002} scale={46} color={CYAN} fn={sap} width={4} />
      <Banner />
      <Captions />
      <AbsoluteFill style={{ background: 'radial-gradient(120% 80% at 50% 45%, transparent 66%, rgba(0,30,24,.22) 100%)', pointerEvents: 'none' }} />
    </AbsoluteFill>
  );
};
```

## 8. Voice (optional)

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

**The persona:** a tired on-call doctor reading a chart aloud, low, flat and unhurried, on the fourth fern of the shift. Never in on the joke. Dry when the news is bad, and only a little quicker when it's urgent. It must sound like someone who has done this many times and is not going to smile now.

**When to use it:** on every line except the silence. Read the history as routine, with a very slight pause around "quote". Let "Forty. Thirty-one. Twelve." fall in pitch, each a little lower. "We're losing a frond" and "Get the watering can" are the only two lines with any urgency in them, and it should be small, as if a raised voice would be unprofessional. Slow right down on "Time of last new leaf: March", and leave the smallest gap before the month. Say "Charging" like a single clipped order. "Stop telling it about work" is pure advice from one tired person to another: don't sell it. Treat the voice as a small tiled room, with a very short echo and a little warmth around 170 Hz. Compress before the room.

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

## 9. Sound

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

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

| Sound | Length | Prompt idea | When it plays |
|-------|--------|-------------|---------------|
| ward air | 12 s | the air of a quiet hospital ward at midday, soft ventilation, a far corridor, no voices, no music | the bed, all the way through, and it never stops |
| monitor beep | 0.3 s | one soft bedside monitor beep, a small round tone at middle pitch, dry and close, not piercing | on every beat in `BEATS`, quietly, so it speeds up on its own when `bpmAt` does, and stops when the trace goes flat |
| alarm | 2 s | a bedside monitor's warning chime, three soft falling tones on a loop, urgent but warm, not shrill | from `losing` to `low` |
| steady tone | 8 s | a single steady monitor tone, one soft held note with no wobble, low and quiet | from `low` to `pour`. This is the designed sound that keeps the low point alive, so duck it only a little under the voice |
| bed wheels | 1.2 s | a small hospital bed on rubber casters rolled across a tile floor, then a soft bump as it stops | the first frame, and `moved` |
| pen | 0.8 s | a ballpoint pen writing a few quick words on paper on a clipboard, close and dry | every chart note: `fern`, `three`, `sill`, `roommate`, `crunchy`, `unclear`, `recently`, and the last line just after `cta` |
| underline | 0.4 s | two hard quick pen strokes on paper | 0.3 s after `recently` |
| crunch | 0.4 s | a dry fern leaf crushed between two fingers, small, brittle, very close | `crunchy` |
| digit drop | 0.2 s | one small mechanical counter click, plastic and dry | `forty`, `thirty`, `twelve`, and softly on `shoot` and `this` |
| leaf fall | 0.7 s | a single dry leaf landing on a cotton blanket, a tiny papery rustle | under each digit drop, and alone on `march` as the last leaf lands |
| frond snap | 0.5 s | a dry green stem bending and giving way, a small soft crack | `frond` |
| droop | 2 s | a thin stem slowly bending under its own weight, a very quiet creak | from 0.2 s after `low` |
| can slide | 0.9 s | a metal watering can pushed along a laminate table, a short scrape with water sloshing inside, and a soft stop | from `get`, stopping on `can` |
| charging whine | 2.4 s | a soft rising electronic whine, like an old camera flash getting ready, warm, ending in a small click | from `charging`, with the click about 0.45 s after `saucer` |
| water | 3.5 s | water from a watering can falling on dry soil, a steady soft patter, close, then soaking in | from `pour` to just before `stable` |
| first beat | 0.8 s | one low warm thump, felt more than heard, like a heartbeat through a mattress, with no ring | 0.38 s after `pour`, when the trace jumps. It's the biggest sound in the film |
| printer | 2.6 s | a small thermal printer feeding paper, a soft buzz in short bursts | from `discharge` to `work` |
| paper tear | 0.5 s | a strip of thermal paper torn off cleanly against a metal edge | `cta` |
| birds | 8 s | a sunny day heard through a window that's a little open, a few distant birds, soft | from `light` to the end, and quiet |

- **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:** "Deadpan hospital drama underscore: soft felt piano repeating two patient notes over a low warm synth pad, a very slow brushed pulse like a resting heartbeat, tense in a small domestic way for a long stretch, then a warm string swell and a hopeful piano figure that opens up, ending resolved, no vocals."

**When it plays:** low and steady under the admission and the history. It tightens under "Leaf count falling", and it should feel like too much music for a fern. It drains to nothing over 0.7 s from `low`, and stays out through "March" and "Get the watering", so only the ward air and the steady tone remain. It comes back low on `can`, holds a tense note under the charging, and reaches its fullest on the voiceless moment, on a bar where the track opens up, with a second downbeat on `shoot` if you can fit one. It settles under "Patient stable", stays warm and low through the discharge notes, and ends resolved under the call to action.

**Mix, voice first.**

- **Voice:** compress lightly so consonants stay off the limiter, about -14 LUFS.
- **Music:** about 16 to 20 LU under the voice while it speaks, lifted in gaps and on the voiceless moment.
- **Effects:** 7 to 10 LU under the voice overall, ducked about 6 dB under speech, so nothing masks a word.
- **Master:** -14 LUFS integrated, sample-peak ceiling about -2.4 dBFS. AAC encoding adds about 0.8 dB, so check the true peak
  of the final file. Give the master a 10 ms fade-in, or every view opens on a click.

## 10. Render and QA

1. **Render** without audio. Mux your mix onto it, trimmed to the exact length (`-t`, never `-shortest`), as H.264 at high
   quality with AAC 320k and `+faststart`.
2. **Sync and length:** the frame count matches the clock, and a cross-correlation of the muxed audio against your mix shows
   an offset of 0 ms.
3. **Contact sheets:** one frame per second with the safe box drawn. Look at every sheet: nothing cut off, nothing important
   outside the box, no empty or black frames, and the time of day and the story still agree.
4. **Determinism:** render about 10 spread frames twice with `npx remotion still src/index.ts Film-9x16 out/a.png --frame=N`, and
   compare each pair with `cmp`; they should match.
5. **Loudness:** -14 LUFS integrated, true peak at or below -1 dBTP.
6. **Words:** transcribe the mix line by line with Whisper. A difference on a plain word is a masked or swallowed word.
7. **Gaps and stills:** no voice gap over 1.0 s outside the voiceless moment (`clock.mjs` warns about these), and no still stretch
   over 1.2 s or black stretch (`ffmpeg -i out/silent.mp4 -vf "freezedetect=n=0.001:d=1.2,blackdetect=d=0.1" -an -f null -` prints any).
   A small moving corner can hide a freeze from that check, so trust the contact sheets over it.

For a silent film, skip the checks that need audio (the offset in 2, and 5 and 6) and use `out/silent.mp4` as the final file.

## 11. Traps

- **A joke that stops being one:** this is the frame's main trap. No real conditions, doses, treatments or hospitals, nothing a worried person could take as advice, and no real person or pet as the patient. If the topic is someone's real health, don't use this frame.
- **A wink:** on-screen jokes are fine, but the voice can't play along. If the doctor sounds amused, the film stops being funny.
- **A low point that reads as a frozen frame:** the numbers read `--` and the trace is flat by design, so the sweep, the drooping frond, the slow push-in, the ticker and the clock's seconds have to carry the movement. Watch it, because a contact sheet won't show this.
- **A black low point:** the room dims, but the flat line, the banner and the captions sit above the dark. Keep them there, and it's never black.
- **A sun that washes out the screen:** a bright overlay across the whole frame turns the monitor's black to olive. Tint the wall, the paper and the bezel instead, as `Stage`, `Chart` and `Monitor` do.
- **A trace too tall:** a normal beat should just reach the bottom of the window, so the jump after the pour can break out of its row and mean something. Keep the `scale` on `Trace` in `Stage` where it is.
- **Reading the numbers against the picture:** the big number counts what the story says, not what's drawn. Keep the fronds thinning as it falls, and don't try to make them match.
- **Ghost words look like an error on a contact sheet:** words not yet spoken are faint pencil, on purpose. Watch the video to check that each one lands in time.
- **Words crowded into the form:** handwriting over printed labels gets hard to read fast. Each chart value should be one or two words.

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