---
name: home-video
description: Make a 30-40 second vertical video shot through a 1990s home camcorder on one last walk through an empty place - REC blinks, the date stamp jumps and rolls through the years, the tape rewinds and gets taped over, the battery runs low, and the picture folds into a bright line and soft snow. The skill covers the user's own footage or AI stills, tape and room sound design, music fitted to the story and a quiet voice from whoever holds the camera. Use for a childhood home, a last day somewhere, a place that's closing or changing hands, family throwbacks and 'how it started' stories, or when asked for a home video, camcorder or old tape look, or when given the home-video prompt.
---

# Home Video

The whole screen is the viewfinder of an old home camcorder, and the film is one walk through one empty place. Nobody is ever in the picture: the person telling the story is the one holding the camera. The date stamp does the heavy lifting. It reads 1996, rolls back, jumps to now, and in the last room it runs forward through the years while the furniture fades out. Every camera move is code, on top of your own footage or a few generated stills, so the frame needs pictures before it can render: section 4 shows how to make them, and how to make stand-ins for your first test.

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/home-video/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/vt323`, `@fontsource/permanent-marker` 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/HomeVideo.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 date stamp jumps and rolls, the focus hunts, the tape rewinds and gets taped over, the battery runs down, and the picture folds into a line of light. That's why it holds attention: the viewer learns the world in the first second, then
  watches it change.
- **One clock: the spoken words.** Each line is voiced as its own take with word timings, scenes are laid end to end on
  those takes, and every change is keyed to a word in `story.json`. Re-voice a line and everything moves with it.
- **A function of time.** Every part of the frame computes what it shows from the current time, with no stored state,
  `Math.random` or `Date`. Use `hash()` for anything that looks random, so two renders match.
- **Sound off first.** Most people start muted, so the frame and the captions have to tell the whole story on their own.
  Sound makes it better, never necessary.
- **Safe area.** 1080 x 1920. Platform buttons and captions cover the bottom 440 px, and the right 130 px from about
  y = 900 down. Keep captions and anything that must be read inside x 60-950, y 250-1480. Above y = 900 the frame can run to
  the right edge. Below y = 1480, draw only texture. The top 250 px can hold the world's title and small jokes, as long as nothing
  there has to be read.
- **Pacing rules.** No voice gap over 1.0 s, except one voiceless moment of 2 to 5 s with the music lifted. No still stretch
  over 1.2 s. Never cut to black. Something moves on the very first frame.

## 3. The world

The viewfinder of a 1990s home camcorder, on a worn tape, walking through one empty place. Tender and quiet, from whoever holds the camera. Nobody is ever in the picture.

- **Palette:** viewfinder white `#f4f4f4`, REC red `#ff2a2a`, date stamp amber `#ffb238`, caption black with a lit word in `#ffe14d`, tape label cream `#ede6d6`, marker ink `#1d1d1d`.
- **Type:** VT323 for the on-screen display, the stamp and the captions, Permanent Marker for the tape label, Caveat 700 for pencil handwriting.
- **Fixed parts, top to bottom:** corner brackets (y 226-1500), the mode line with its icon (y 262: REC, PAUSE, REW, PLAY, STOP), the tape counter, the L and R audio meter, SP and the battery icon top right, the W-T zoom bar that follows the camera, the amber date stamp (y 1056-1224, right edge at x = 950), closed captions in black boxes (bottom edge y = 1476), then the cassette spine with its marker label as texture below y = 1690.
- **The date stamp is the storyteller.** It's the biggest thing on screen after the captions, and it's outlined so it reads on a pale wall or a blown-out window. Every jump in it is a story event.
- **The tape does things to the picture:** focus hunts, a whip pan, a freeze with jitter, a rewind that runs earlier shots backward, horizontal tears, tracking bars, a dimming battery, a bright line. The display stays steady on top of all of it, because a real camcorder draws it electronically.
- **Keep it tender.** No jokes on screen. The small details are true and plain: a pencil mark, a pulled-out chair, "do not tape over" on the label.
- **Snow is even, never flashing.** Its brightness stays level from frame to frame. Keep any noise under 3 full-frame flashes a second.

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

In this frame the window is the whole picture, so this is the one place the film needs real images: rooms with nobody in them.

**Whose memories.** Use only places and memories the user has a right to share: their own home, or one where the people it belongs to said yes. Keep street numbers, names on mail and other people's faces out of the picture. If the place is generated and the memories are invented, that's fine for a piece of fiction, but never present it as someone's true story.

**Your own footage comes first.** It's what makes this film hit.

- **A phone walk-through of the real place, or old home movies you've digitized.** Replace the `<Img>` in `Still` with `<OffthreadVideo muted>` from `remotion`, trimmed to the moment. Keep the code's camera moves small, since your footage already moves.
- **Your own photos.** Scanned or from your phone, cropped to 9:16 and named like ours. The code moves them. Old family photos of empty rooms are rare, so shoot the rooms as they are today, and let the look do the rest.
- **Before you have pictures,** the frame still needs its eight files to render, for the setup test in step 1 and for the animatic. Fill `public/img/` with placeholders:
  `mkdir -p public/img ; for n in hall marks kitchen birthday yard boxes bedroom bare; do ffmpeg -y -f lavfi -i color=c=slategray:s=1080x1935 -frames:v 1 -update 1 public/img/$n.jpg; done`. A missing image stops the render.

**Or generate about seven or eight stills** (9:16, 2K) of one house, with any image tool. Make one hero image first and look at it. Then make the others by editing it, so the house stays one house: "the same house, same trim, same light" for a new room, and "the exact same room, same framing" for a pair. References copy content as well as style, so finish every prompt with "The frame contains only..." and "No people, no faces, no hands, no text, no logos".

- **The look goes in every prompt:** "a frame from a 1990s home camcorder: soft focus, low resolution, washed-out warm colors, gentle bloom on the highlights, a real and lived-in house, not a modern photograph". Never ask for a date stamp or a display: the code draws those, and the model's would be wrong and couldn't move.
- **Ban what sneaks a person in.** Family photos on the wall, portraits, mirrors, dolls and teddy bears all have faces or show the camera. Name them in the prompt: "no portraits, no mirrors, no dolls".
- **Look at every image.** Throw out anything with a person, stray text or warped objects, and zoom in on the small things. Models miscount: ours drew nine candles on a seven-candle cake, and we painted two out by hand. Alphabet blocks carry letters, so we softened ours.

| Image | What it shows | Made from | Used for |
|---|---|---|---|
| `hall` | The hero. A hallway looking straight at a closed door in the center | Description | The start, the rewind |
| `marks` | A door frame up close, with seven plain pencil lines and no writing | `hall` | The wall. The code writes the ages beside the lines |
| `kitchen` | A small table and chairs from the doorway, afternoon sun | `hall` | The kitchen, the freeze, the rewind |
| `birthday` | The exact same kitchen at dusk, a cake with lit candles, one chair pulled out | `kitchen` | The birthday |
| `yard` | One huge tree with a rope swing, the corner of the house at the edge | `hall`, for its look only | The backyard |
| `boxes` | The exact same hallway years later: cold light, moving boxes, pale rectangles where the prints hung | `hall` | The low point, the walk to the door |
| `bedroom` | A child's room from the doorway: truly awful wallpaper, a bright window in the center | `hall` | The last room |
| `bare` | The exact same room, emptied: same wallpaper, same window, a peeling seam | `bedroom` | The voiceless push |

**Keep it moving.** Every still drifts by hand and pushes slowly, and a change of picture is always a camera event: a whip pan, a rewind, a jump, a door.

## 5. The story

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

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

**The moves.** Map any topic onto these. The film is one last walk through a place, by someone who doesn't say how they feel. Ours is the house they grew up in, sold on Friday.

| # | Move | What it does | Our line | In the viewfinder |
|---|------|--------------|----------|-------------------|
| 1 | Start | The hook: someone starts filming, plainly, a little awkwardly | "Okay. It's recording. This is the hallway." | REC blinks, focus hunts into the hallway, the stamp reads JUN 14 1996 |
| 2 | The wall | One small true thing only this person would notice | "That mark on the wall is how tall I was." | A whip pan, then a tilt down past the pencil marks; the focus box locks on one |
| 3 | Kitchen | Memory against the real size of things | "Kitchen. The table's smaller than I remember." | The zoom pulls back on "smaller", and the table shrinks in the frame |
| 4 | Rewind | Going back on purpose | "Hang on. Let me go back." | PAUSE, the picture freezes and jitters; REW, the shots run backward with tracking bars, and the date rolls back to 1993 |
| 5 | Birthday | The absence: the people are gone from the picture | "My seventh birthday. Someone's just out of frame." | Dusk, a cake with lit candles on the table, nobody. The focus box slides off the cake to a pulled-out chair and finds no one |
| 6 | Backyard | How big it all was | "Backyard. That tree was a whole country once." | A tilt up the trunk, past the rope swing, into the branches |
| 7 | Taped over | Memory fails | "This part got taped over." | Snow eats the picture, a jump, and another date: NOV 1 2026. The same hallway, with moving boxes in it |
| 8 | Low point | The one hard fact, said flat | "Low battery. They sell the house on Friday." | BATTERY blinks, the picture dims and tears. Music out; the motor whirr and room hiss keep running |
| 9 | The turn | One more try | "So. One more room." | The camera starts to walk toward the far door, and the picture comes back. Music returns on "room" |
| 10 | Last room | The door opens | "My room. Still that awful wallpaper." | The door swings open onto window light, the camera goes through, and the stamp is back at JUN 14 1996 |
| 11 | The voiceless moment | About 4 s, music at its fullest | (no words; the caption reads STOP) | A slow push to the window while the date rolls forward through the years to today. The bed, the desk and the toys fade out. REC turns to STOP |
| 12 | End | The release | "End of tape." | The picture folds into one bright line, and the line fades into soft snow. Never black |
| 13 | Call to action | One action | "Comment tape and I'll send you the skill that made this." | A cassette rises out of the snow. Its label reads COMMENT TAPE, and it stays |

**For other topics:** a shop on its last day, a grandparent's apartment before it's cleared, a first office before the move, a school about to come down, a car being sold. It works for any place you can only visit a few more times. The trick that makes it land: the voice never says what it feels. It names rooms and small facts, gives the one hard fact flat at the low point, and the thing it complained about (the awful wallpaper) is the one thing still there when everything else has faded. Find that thing first. Keep the dates true to the story: the age in the birthday line has to fit the years on the stamp, and the last date is the day you post. Our film runs 40.8 s on the clock's estimate. For about 35 s, drop the backyard or the kitchen, each about 3.4 s, along with its row in `SHOTS` and its events. The kitchen is also the picture that freezes and the first rewind row, so point those at the shot before it.

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

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

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

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

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

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

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

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

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

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

## 7. Build the frame

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

### What to change for your story

The words are few and sit at the top of the file. The real work is your images and where things sit in them, because the camera, the door and the focus box are all aimed at points in our stills.

| To change | Where |
|---|---|
| The comment keyword | `KEYWORD` and `CTA_WORD` at the top, and `plan.json`. `Cassette` fits a longer word on the label, and `Captions` lights it by itself |
| The dates | `TODAY` and `STAMPS`, in time order. A row with `rollFrom` runs the numbers to its date from that second |
| The tape's labels | `SPINE_LABEL`, `SPINE_NOTE` and `TAPE_NO` at the top; `T-120` in `Spine` |
| The two words that aren't spoken | `LOW_WORD` and `STOP_WORD` at the top |
| The pictures | `SHOTS`: one row per shot with its image file. Images go in `public/img/`, as JPEGs |
| The camera | The `keys` in each `SHOTS` row: second, zoom, then the point of the still at frame center (x, y in a 1080 x 1935 box). The rewind rows replay earlier shots; a whip pan needs its event in `WHIPS` |
| The pencil marks | `MARKS` and `MARK_FOUND`: the y of each line in `marks.jpg` and the age beside it. `PencilAges` writes them |
| The focus box | `FocusBox`, using `MARK_FOUND` and `CAKE` and `CHAIR` |
| The candles | `FLAMES` and the glow in `Candles`, both points in `birthday.jpg`. Delete them if your still has no flames |
| The far door | `DOOR`: the door's box in `hall.jpg` and `boxes.jpg`, which must share their framing. `OpeningDoor` draws the opening |
| The emptying | `Emptying` fades `bedroom` to `bare`. Its glow sits on our window |
| Extras tied to an image name | The `s.img === ...` checks in `Still` and `FocusBox`. Rename them along with the files |
| Focus hunts | `HUNTS`: second, how far out, how long |

- **Scene ids in the code:** none. It only reads events, so you can renumber scenes freely. Every event a row uses must exist in `story.json`, so a typo stops the render.
- **Sized to our words:** `LOW_WORD` fits about 11 letters at 176 px; `Spine` fits about 28 letters across its label and note; the captions fit 22 letters a row. Check anything longer on the contact sheet.
- **Your door may not be straight ahead.** If it isn't, drop `OpeningDoor`, walk the camera up to any door, and whip pan into the last room.

### The code

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

```json
{
 "lines": [
  {
   "id": "l01",
   "text": "Okay. It's recording. This is the hallway."
  },
  {
   "id": "l02",
   "text": "That mark on the wall is how tall I was."
  },
  {
   "id": "l03",
   "text": "Kitchen. The table's smaller than I remember."
  },
  {
   "id": "l04",
   "text": "Hang on. Let me go back."
  },
  {
   "id": "l05",
   "text": "My seventh birthday. Someone's just out of frame."
  },
  {
   "id": "l06",
   "text": "Backyard. That tree was a whole country once."
  },
  {
   "id": "l07",
   "text": "This part got taped over."
  },
  {
   "id": "l08",
   "text": "Low battery. They sell the house on Friday."
  },
  {
   "id": "l09",
   "text": "So. One more room."
  },
  {
   "id": "l10",
   "text": "My room. Still that awful wallpaper."
  },
  {
   "id": "l11",
   "text": "End of tape."
  },
  {
   "id": "l12",
   "text": "Comment tape and I'll send you the skill that made this.",
   "say": "Comment, tape, and I'll send you the skill that made this."
  }
 ],
 "scenes": [
  {
   "id": "s01",
   "lines": [
    "l01"
   ],
   "lead": 0.5
  },
  {
   "id": "s02",
   "lines": [
    "l02"
   ],
   "lead": 0.3
  },
  {
   "id": "s03",
   "lines": [
    "l03"
   ],
   "lead": 0.2
  },
  {
   "id": "s04",
   "lines": [
    "l04"
   ],
   "lead": 0.15,
   "tail": 0.5
  },
  {
   "id": "s05",
   "lines": [
    "l05"
   ],
   "lead": 0.2
  },
  {
   "id": "s06",
   "lines": [
    "l06"
   ],
   "lead": 0.2
  },
  {
   "id": "s07",
   "lines": [
    "l07"
   ],
   "lead": 0.2,
   "tail": 0.4
  },
  {
   "id": "s08",
   "lines": [
    "l08"
   ],
   "lead": 0.3,
   "tail": 0.4
  },
  {
   "id": "s09",
   "lines": [
    "l09"
   ],
   "lead": 0.3
  },
  {
   "id": "s10",
   "lines": [
    "l10"
   ],
   "lead": 0.3
  },
  {
   "id": "s11",
   "sec": 4.2
  },
  {
   "id": "s12",
   "lines": [
    "l11"
   ],
   "lead": 0.1,
   "tail": 0.55
  },
  {
   "id": "s13",
   "lines": [
    "l12"
   ],
   "lead": 0.3
  }
 ]
}
```

`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 { HomeVideo } from './frames/HomeVideo';
import spec from './spec.json';

export const Root: React.FC = () => (
  <Composition id="Film-9x16" component={HomeVideo} 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": "hallway",
   "scene": "s01",
   "word": "hallway"
  },
  {
   "id": "marks",
   "scene": "s02"
  },
  {
   "id": "mark",
   "scene": "s02",
   "word": "mark"
  },
  {
   "id": "tall",
   "scene": "s02",
   "word": "tall"
  },
  {
   "id": "kitchen",
   "scene": "s03"
  },
  {
   "id": "smaller",
   "scene": "s03",
   "word": "smaller"
  },
  {
   "id": "hang",
   "scene": "s04",
   "word": "Hang"
  },
  {
   "id": "back",
   "scene": "s04",
   "word": "back"
  },
  {
   "id": "bday",
   "scene": "s05"
  },
  {
   "id": "seventh",
   "scene": "s05",
   "word": "seventh"
  },
  {
   "id": "someone",
   "scene": "s05",
   "word": "Someone"
  },
  {
   "id": "frame",
   "scene": "s05",
   "word": "frame"
  },
  {
   "id": "yard",
   "scene": "s06"
  },
  {
   "id": "tree",
   "scene": "s06",
   "word": "tree"
  },
  {
   "id": "country",
   "scene": "s06",
   "word": "country"
  },
  {
   "id": "part",
   "scene": "s07",
   "word": "part"
  },
  {
   "id": "over",
   "scene": "s07",
   "word": "over"
  },
  {
   "id": "low",
   "scene": "s08",
   "word": "Low"
  },
  {
   "id": "battery",
   "scene": "s08",
   "word": "battery"
  },
  {
   "id": "friday",
   "scene": "s08",
   "word": "Friday"
  },
  {
   "id": "so",
   "scene": "s09",
   "word": "So"
  },
  {
   "id": "room",
   "scene": "s09",
   "word": "room"
  },
  {
   "id": "door",
   "scene": "s10",
   "word": "My"
  },
  {
   "id": "still",
   "scene": "s10",
   "word": "Still"
  },
  {
   "id": "wallpaper",
   "scene": "s10",
   "word": "wallpaper"
  },
  {
   "id": "push",
   "scene": "s11"
  },
  {
   "id": "today",
   "scene": "s11",
   "off": 3.0
  },
  {
   "id": "end",
   "scene": "s12",
   "word": "End"
  },
  {
   "id": "tape",
   "scene": "s12",
   "word": "tape"
  },
  {
   "id": "cta",
   "scene": "s13"
  }
 ]
}
```

`src/frames/HomeVideo.tsx`:

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

/* Home Video: one old camcorder tape of one empty house. 1080 x 1920. The viewfinder never leaves the screen, the stills only move by
   code (slow pushes, handheld drift, a whip pan, a rewind), and the date stamp tells the story: 1996, back to 1993, a jump to now, 1996
   again, then a slow roll forward to today while the room empties. Nobody is ever in the picture. */
const E = makeE(story as StoryJson);

// The words on screen. Change these first.
const KEYWORD = 'TAPE';
const CTA_WORD = 'COMMENT';
const TODAY: Stamp = [2026, 11, 5, 16, 41]; // year, month, day, hour, minute
const SPINE_LABEL = 'THE HOUSE';
const SPINE_NOTE = 'do not tape over';
const TAPE_NO = 'TAPE 03';
const LOW_WORD = 'BATTERY';
const STOP_WORD = 'STOP';
/** The pencil marks: where each line sits in marks.jpg, and the age written beside it. */
const MARKS: [number, string][] = [[738, '10'], [835, '9'], [937, '8'], [1049, '7'], [1153, '6'], [1221, '5'], [1356, '4']];
const MARK_FOUND = 1049;
/** On the birthday the focus box slides from the cake to the empty chair. Both are points in birthday.jpg. */
const CAKE = [548, 830], CHAIR = [731, 1120];
/** The date stamp, in order. `rollFrom` makes the numbers run to this date from that second instead of jumping. */
type Stamp = [number, number, number, number, number];
const STAMPS: { at: () => number; d: Stamp; rollFrom?: () => number }[] = [
  { at: () => 0, d: [1996, 6, 14, 16, 12] },
  { at: () => E('bday'), d: [1993, 3, 9, 18, 48], rollFrom: () => E('back') },
  { at: () => E('yard'), d: [1993, 7, 18, 17, 30] },
  { at: () => E('over') + 0.25, d: [2026, 11, 1, 10, 5] },
  { at: () => E('door') + 0.3, d: [1996, 6, 14, 16, 40] },
  { at: () => E('today'), d: TODAY, rollFrom: () => E('push') + 0.2 },
];

const OSD = '#f4f4f4', RED = '#ff2a2a', AMBER = '#ffb238', CC = '#ffe14d', LABEL = '#ede6d6', INK = '#1d1d1d';
const VT = 'VT323, monospace', MARKER = '"Permanent Marker", cursive', HAND = 'Caveat, cursive';
const FACES = [
  { family: 'VT323', file: 'vt323-latin-400-normal.woff2' },
  { family: 'Permanent Marker', file: 'permanent-marker-latin-400-normal.woff2' },
  { family: 'Caveat', file: 'caveat-latin-700-normal.woff2', weight: '700' },
];
const img = (name: string) => staticFile(`img/${name}.jpg`);
const OSD_SHADOW = '3px 3px 0 rgba(0,0,0,.72)';
const STAMP_SHADOW = '4px 4px 0 rgba(0,0,0,.85), -2px -2px 0 rgba(0,0,0,.55), 2px -2px 0 rgba(0,0,0,.55), -2px 2px 0 rgba(0,0,0,.55)';

/* ---------- the camera: every still sits in a 1080 x 1935 box, and a key is [second, zoom, x, y], the point of the still at frame center ---------- */
type Key = [() => number, number, number, number, boolean?]; // the last flag means walking: the zoom grows the way a door grows as you walk at it
type Shot = { at: () => number; img: string; keys: Key[]; rewind?: boolean };
const DOOR = { x: 395, y: 586, w: 253, h: 689 }; // the far door, in hall.jpg and boxes.jpg
const DOOR_Z = 4.27, ROOM_Z = 1.12; // the room's scale inside the door is ROOM_Z / DOOR_Z, so the camera flies through it with no cut
const SHOTS: Shot[] = [
  { at: () => 0, img: 'hall', keys: [[() => 0, 1.1, 525, 900], [() => E('marks'), 1.24, 521, 905]] },
  { at: () => E('marks'), img: 'marks', keys: [[() => E('marks'), 1.35, 520, 640], [() => E('tall') - 0.1, 1.85, 545, MARK_FOUND], [() => E('kitchen'), 1.92, 545, MARK_FOUND]] },
  { at: () => E('kitchen'), img: 'kitchen', keys: [[() => E('kitchen'), 1.75, 560, 980], [() => E('smaller') - 0.1, 1.68, 560, 985], [() => E('hang'), 1.12, 540, 930]] },
  // Hang on: the same picture freezes. Then the rewind runs the earlier shots backward.
  { at: () => E('back'), img: 'kitchen', rewind: true, keys: [[() => E('back'), 1.12, 540, 930], [() => E('back') + 0.32, 1.75, 560, 980]] },
  { at: () => E('back') + 0.32, img: 'marks', rewind: true, keys: [[() => E('back') + 0.32, 1.92, 545, MARK_FOUND], [() => E('back') + 0.62, 1.35, 520, 640]] },
  { at: () => E('back') + 0.62, img: 'hall', rewind: true, keys: [[() => E('back') + 0.62, 1.24, 521, 905], [() => E('bday'), 1.1, 525, 900]] },
  { at: () => E('bday'), img: 'birthday', keys: [[() => E('bday'), 2.05, 548, 850], [() => E('someone'), 1.62, 548, 900], [() => E('frame'), 1.42, 700, 1130], [() => E('yard'), 1.42, 700, 1140]] },
  { at: () => E('yard'), img: 'yard', keys: [[() => E('yard'), 1.6, 480, 1120], [() => E('tree') - 0.1, 1.58, 480, 1090], [() => E('part'), 1.12, 500, 700], [() => E('over') + 0.25, 1.12, 500, 700]] },
  { at: () => E('over') + 0.25, img: 'boxes', keys: [[() => E('over') + 0.25, 1.18, 560, 1000], [() => E('so'), 1.26, 555, 1000], [() => E('door') + 0.85, DOOR_Z, 521, 930, true]] },
  { at: () => E('door') + 0.85, img: 'bedroom', keys: [[() => E('door') + 0.85, ROOM_Z, 540, 967], [() => E('wallpaper') - 0.1, 1.2, 500, 930], [() => E('push'), 1.3, 400, 860], [() => E('tape'), 1.8, 541, 840]] },
];
const WHIPS = [() => E('marks'), () => E('kitchen')];
const WHIP = 0.17;
const shotAt = (t: number) => SHOTS.reduce((a, s, i) => (t >= s.at() ? i : a), 0);

/** Hand wobble in screen pixels, the same kind on every shot. */
const sway = (t: number, amt: number) => ({
  x: amt * (7 * Math.sin(t * 0.83 + 1) + 4 * Math.sin(t * 2.1 + 2) + 1.6 * Math.sin(t * 5.3)),
  y: amt * (6 * Math.sin(t * 0.67 + 4) + 3.5 * Math.sin(t * 1.9) + 1.4 * Math.sin(t * 4.7 + 1)),
  r: amt * (0.22 * Math.sin(t * 0.53) + 0.1 * Math.sin(t * 1.7 + 3)),
});
const paused = (t: number) => t >= E('hang') && t < E('back');
const walking = (t: number) => ramp(t, E('so'), E('so') + 0.4) * (1 - ramp(t, E('door') - 0.1, E('door') + 0.6));
const camAt = (s: Shot, t: number) => {
  const K = s.keys;
  let i = 0;
  while (i < K.length - 2 && t >= K[i + 1][0]()) i++;
  const lin = ramp(t, K[i][0](), K[i + 1][0]());
  const k = K[i + 1][4] ? lin : easeInOut(lin);
  const z = K[i + 1][4] ? 1 / (1 / K[i][1] + (1 / K[i + 1][1] - 1 / K[i][1]) * lin) : K[i][1] + (K[i + 1][1] - K[i][1]) * k, cx = K[i][2] + (K[i + 1][2] - K[i][2]) * k, cy = K[i][3] + (K[i + 1][3] - K[i][3]) * k;
  const m = Math.min(40, (1080 * (z - 1)) / 2); // a margin so the wobble never shows an edge of the still
  const x = clamp(540 - cx * z, 1080 * (1 - z) + m, -m), y = clamp(860 - cy * z, 1920 - 1935 * z + m, -m);
  return { z, x, y };
};
/** Where a point of the still lands on screen, for the focus box. */
const toScreen = (s: Shot, t: number, px: number, py: number) => {
  const c = camAt(s, t), w = sway(t, 1);
  return { x: c.x + px * c.z + w.x, y: c.y + py * c.z + w.y, z: c.z };
};

/* ---------- things that live inside a still and move with it ---------- */
const PencilAges: React.FC = () => (
  <>
    {MARKS.map(([y, a], i) => (
      <div key={a} style={{ position: 'absolute', left: 384 + (i % 2) * 6, top: y - 28, font: `700 42px/1 ${HAND}`, color: 'rgba(58,52,48,.8)', transform: `rotate(${-4 + i}deg)` }}>{a}</div>
    ))}
  </>
);

const FLAMES = [[508, 791], [520, 786], [539, 786], [558, 785], [570, 796], [578, 788], [587, 790]];
const Candles: React.FC = () => {
  const t = useT();
  const f = Math.floor(t * 30);
  return (
    <>
      <div style={{ position: 'absolute', left: 248, top: 560, width: 600, height: 500, background: 'radial-gradient(closest-side, rgba(255,170,80,.42), rgba(255,170,80,0))', opacity: 0.68 + 0.32 * hash(Math.floor(t * 11)), mixBlendMode: 'screen' }} />
      {FLAMES.map(([x, y], i) => {
        const q = 0.72 + 0.28 * hash(i * 9.1 + f * 0.37);
        return <div key={i} style={{ position: 'absolute', left: x - 8, top: y - 13, width: 16, height: 26, borderRadius: '50%', background: 'radial-gradient(closest-side, rgba(255,246,205,1), rgba(255,190,100,0))', opacity: q, transform: `translateX(${(hash(i + f * 0.51) - 0.5) * 2.4}px) scale(${0.85 + 0.3 * q})` }} />;
      })}
    </>
  );
};

/** The far door opens inward onto the bedroom. The room is scaled down inside the doorway, so the push carries straight into it. */
const OpeningDoor: React.FC = () => {
  const t = useT();
  const open = easeInOut(ramp(t, E('door') - 0.22, E('door') + 0.72));
  if (open <= 0) return null;
  const roomW = 1080 * (ROOM_Z / DOOR_Z), roomH = 1935 * (ROOM_Z / DOOR_Z);
  return (
    <>
      <div style={{ position: 'absolute', left: DOOR.x, top: DOOR.y, width: DOOR.w, height: DOOR.h, overflow: 'hidden', background: 'linear-gradient(#efe6d5 20%, #6b6f3c 80%)' }}>
        <Img src={img('bedroom')} style={{ position: 'absolute', left: DOOR.w / 2 - roomW / 2, top: DOOR.h / 2 - roomH / 2, width: roomW, height: roomH }} />
        <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(closest-side, rgba(255,246,222,.9), rgba(255,246,222,.1))', opacity: open * (1 - ramp(t, E('door') + 0.5, E('door') + 0.85)) }} />
        <div style={{ position: 'absolute', inset: 0, transformOrigin: '0 50%', transform: `perspective(1500px) rotateY(${open * 82}deg)`, overflow: 'hidden' }}>
          <Img src={img('boxes')} style={{ position: 'absolute', left: -DOOR.x, top: -DOOR.y, width: 1080, height: 1935 }} />
        </div>
      </div>
      <div style={{ position: 'absolute', left: DOOR.x - 240, top: DOOR.y + 60, width: DOOR.w + 480, height: DOOR.h + 340, background: 'radial-gradient(closest-side, rgba(255,240,205,.55), rgba(255,240,205,0))', opacity: open * (1 - ramp(t, E('door') + 0.4, E('door') + 0.85)), mixBlendMode: 'screen' }} />
    </>
  );
};

/** The voiceless push: everything but the wallpaper and the window fades out as the years run, and the window light grows. */
const Emptying: React.FC = () => {
  const t = useT();
  const gone = easeInOut(ramp(t, E('push') + 0.3, E('today') + 0.1));
  const glow = 0.08 + 0.36 * ramp(t, E('push'), E('tape'));
  return (
    <>
      <Img src={img('bare')} style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1935, opacity: gone }} />
      <div style={{ position: 'absolute', left: 60, top: 180, width: 960, height: 1240, background: 'radial-gradient(closest-side, rgba(255,246,222,.95), rgba(255,246,222,0))', opacity: glow, mixBlendMode: 'screen' }} />
    </>
  );
};

const Still: React.FC<{ s: Shot; dx?: number }> = ({ s, dx = 0 }) => {
  const t = useT();
  const c = camAt(s, t);
  return (
    <div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1935, transformOrigin: '0 0', transform: `translate(${c.x + dx}px, ${c.y}px) scale(${c.z})` }}>
      <Img src={img(s.img)} style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1935 }} />
      {s.img === 'marks' ? <PencilAges /> : null}
      {s.img === 'birthday' ? <Candles /> : null}
      {s.img === 'boxes' ? <OpeningDoor /> : null}
      {s.img === 'bedroom' ? <Emptying /> : null}
    </div>
  );
};

/* ---------- what the tape does to the picture ---------- */
/** Focus hunts: [second, how far out, how long]. */
const HUNTS: [() => number, number, number][] = [[() => 0, 16, 0.95], [() => E('mark') + 0.1, 9, 0.9], [() => E('bday'), 7, 0.6], [() => E('frame') - 0.15, 7, 0.75], [() => E('yard'), 6, 0.5], [() => E('wallpaper'), 5, 0.6]];
const focusAt = (t: number) => HUNTS.reduce((m, [at, a, d]) => {
  const k = (t - at()) / d;
  return k < 0 || k > 1 ? m : Math.max(m, a * (1 - k) * Math.abs(Math.cos(k * Math.PI * 1.5)));
}, 0);
/** How dim the low point gets. It sags, it never reaches black. */
const dimAt = (t: number) => (0.55 * ramp(t, E('battery'), E('friday')) + 0.45 * ramp(t, E('friday'), E('friday') + 0.45)) * (1 - ramp(t, E('so'), E('room') + 0.15));
const tearK = (t: number) => {
  if (paused(t)) return 0.22;
  if (SHOTS[shotAt(t)].rewind) return 0.85;
  const taped = ramp(t, E('part'), E('over')) * (1 - ramp(t, E('over') + 0.25, E('low')));
  const low = 0.3 * ramp(t, E('battery'), E('friday')) * (1 - ramp(t, E('so'), E('so') + 0.35));
  const fri = 0.5 * Math.max(0, 1 - Math.abs(t - E('friday') - 0.25) / 0.5);
  return Math.max(taped, low, t < E('so') ? fri : 0);
};
const tearsAt = (t: number) => {
  const k = tearK(t);
  if (k <= 0.02) return [];
  const step = Math.floor(t * 12);
  return Array.from({ length: 2 + Math.round(k * 4) }, (_, i) => {
    const h = 26 + hash(step * 3.1 + i) * 120;
    return { y: Math.floor(250 + hash(step * 1.7 + i * 5.3) * 1300), h: Math.floor(h), dx: Math.round((hash(step * 2.3 + i * 7.9) - 0.4) * 190 * k) };
  });
};
/** The jump where the next recording starts: the picture lands low and snaps back up. */
const jumpAt = (t: number) => (t >= E('over') + 0.25 ? 90 * (1 - easeOut(ramp(t, E('over') + 0.25, E('over') + 0.5))) : 0);

const Picture: React.FC = () => {
  const t = useT();
  const i = shotAt(t);
  const s = SHOTS[i];
  const end = ramp(t, E('tape'), E('tape') + 0.42);
  if (end >= 1) return null;
  const whip = WHIPS.map((w) => ramp(t, w() - WHIP, w() + WHIP)).find((k) => k > 0 && k < 1);
  const wk = whip === undefined ? 0 : easeInOut(whip);
  const w = whip === undefined ? null : t >= WHIPS.map((x) => x()).find((x) => Math.abs(t - x) < WHIP)! ? { a: SHOTS[i - 1], b: s } : { a: s, b: SHOTS[i + 1] };
  const dim = dimAt(t);
  const blur = 1.1 + focusAt(t) + (whip === undefined ? 0 : 16 * Math.sin(wk * Math.PI));
  const bright = (1 - 0.58 * dim) * (1 + 0.04 * Math.sin(t * 2.3) * dim) * (1 + 0.9 * easeIn(end));
  const sat = 1.12 - 0.62 * Math.min(1, dim);
  const amt = paused(t) ? 0 : 1 - 0.3 * dim;
  const wob = sway(t, amt);
  const bob = walking(t);
  const jit = paused(t) ? (hash(Math.floor(t * 30)) - 0.5) * 12 : 0;
  const tx = wob.x + bob * 7 * Math.sin(t * 5.2), ty = wob.y + bob * 15 * Math.sin(t * 10.4) + jit + jumpAt(t);
  return (
    <div style={{ position: 'absolute', inset: 0, overflow: 'hidden', filter: `contrast(1.05) saturate(${sat}) sepia(.07) brightness(${bright}) blur(${blur}px)`, transform: end > 0 ? `scaleY(${1 - easeIn(end) * 0.998})` : undefined, transformOrigin: '50% 860px' }}>
      <div style={{ position: 'absolute', inset: -2, transform: `translate(${tx}px, ${ty}px) rotate(${wob.r + bob * 0.3 * Math.sin(t * 5.2)}deg)`, transformOrigin: '540px 860px' }}>
        {w ? (
          <>
            <Still s={w.a} dx={-1080 * wk} />
            <Still s={w.b} dx={1080 - 1080 * wk} />
          </>
        ) : <Still s={s} />}
        {tearsAt(t).map((r, n) => (
          <div key={n} style={{ position: 'absolute', left: 0, top: r.y, width: 1080, height: r.h, overflow: 'hidden' }}>
            <div style={{ position: 'absolute', left: r.dx, top: -r.y, width: 1080, height: 1920 }}><Still s={s} /></div>
          </div>
        ))}
      </div>
    </div>
  );
};

/** One noise tile, built once from `hash`, so two renders match. Moved at random each frame, the mean stays even and nothing flashes. */
const NOISE = (() => {
  const c = document.createElement('canvas');
  c.width = 320; c.height = 180;
  const g = c.getContext('2d') as CanvasRenderingContext2D;
  const im = g.createImageData(320, 180);
  for (let y = 0; y < 180; y++) {
    let run = 0, v = 0;
    for (let x = 0; x < 320; x++) {
      if (run-- <= 0) { run = Math.floor(hash(x * 0.71 + y * 13.3) * 5); v = 52 + hash(x * 3.77 + y * 17.1) * 150; } // short runs read as tape streaks
      const i = (y * 320 + x) * 4;
      im.data[i] = v; im.data[i + 1] = v; im.data[i + 2] = v + 6; im.data[i + 3] = 255;
    }
  }
  g.putImageData(im, 0, 0);
  return c.toDataURL('image/png');
})();
const Noise: React.FC<{ style: React.CSSProperties; seed: number; scale?: number; rate?: number }> = ({ style, seed, scale = 4, rate = 30 }) => {
  const f = Math.floor(useT() * rate + 0.001);
  return <div style={{ position: 'absolute', backgroundImage: `url(${NOISE})`, backgroundSize: `${320 * scale}px ${180 * scale}px`, imageRendering: 'pixelated', backgroundPosition: `${Math.floor(hash(f + seed) * 1280)}px ${Math.floor(hash(f * 1.3 + seed + 5) * 720)}px`, pointerEvents: 'none', ...style }} />;
};
/** The soft snow behind the collapsing picture, and for the last seconds of the film. */
const EndSnow: React.FC = () => {
  const t = useT();
  if (t < E('tape') - 0.06) return null;
  return <Noise seed={3} style={{ inset: 0, filter: 'blur(1.8px) contrast(.8) brightness(1.02)' }} />;
};
/** Tape noise over the picture: the snow that swallows a recording, and the tracking bars. */
const TapeNoise: React.FC = () => {
  const t = useT();
  const snow = 0.8 * ramp(t, E('part'), E('over')) * (1 - ramp(t, E('over') + 0.25, E('low') + 0.1));
  const heavy = SHOTS[shotAt(t)].rewind ? 1 : Math.max(snow, 0.4 * ramp(t, E('battery'), E('friday')) * (1 - ramp(t, E('so'), E('so') + 0.4)));
  const count = heavy > 0.05 ? 3 : 1;
  return (
    <>
      {snow > 0.01 ? <Noise seed={9} style={{ inset: 0, opacity: snow, filter: 'contrast(.9)' }} /> : null}
      {Array.from({ length: count }, (_, i) => {
        const y = ((t * (300 + i * 170) + hash(i + 2) * 1900) % 1960) - 60;
        return <Noise key={i} seed={20 + i} scale={3} style={{ left: 0, width: 1080, top: y, height: 34 + heavy * 70 + i * 16, opacity: 0.2 + heavy * 0.7, mixBlendMode: 'screen' }} />;
      })}
      {paused(t) ? <Noise seed={40} scale={3} style={{ left: 0, width: 1080, top: 1040 + (hash(Math.floor(t * 30)) - 0.5) * 16, height: 96, opacity: 0.9, mixBlendMode: 'screen' }} /> : null}
    </>
  );
};
const Texture: React.FC = () => (
  <>
    <Noise seed={60} scale={2} rate={15} style={{ inset: 0, opacity: 0.07, mixBlendMode: 'overlay' }} />
    <AbsoluteFill style={{ background: 'repeating-linear-gradient(0deg, rgba(0,0,0,.14) 0 2px, rgba(0,0,0,0) 2px 5px)' }} />
    <AbsoluteFill style={{ background: 'radial-gradient(ellipse 78% 62% at 50% 44%, rgba(0,0,0,0) 56%, rgba(0,0,0,.42)), linear-gradient(90deg, rgba(255,40,80,.08), rgba(0,0,0,0) 8%, rgba(0,0,0,0) 92%, rgba(40,120,255,.08))' }} />
  </>
);
/** End of tape: the picture folds into one bright line, and the line fades into the snow. */
const BrightLine: React.FC = () => {
  const t = useT();
  const a = E('tape') + 0.2;
  if (t < a || t > a + 0.95) return null;
  const k = ramp(t, a + 0.25, a + 0.95);
  return (
    <>
      <div style={{ position: 'absolute', left: 0, width: 1080, top: 790, height: 140, opacity: 0.7 * (1 - k), background: 'linear-gradient(rgba(255,244,214,0), rgba(255,244,214,.55) 50%, rgba(255,244,214,0))' }} />
      <div style={{ position: 'absolute', left: 540 - 540 * (1 - easeIn(k) * 0.9), width: 1080 * (1 - easeIn(k) * 0.9), top: 856, height: 8, background: '#fff8e6', opacity: 1 - easeIn(k) }} />
    </>
  );
};

/* ---------- the viewfinder ---------- */
const Corners: React.FC<{ x: number; y: number; w: number; h: number; len: number; th: number; color?: string; opacity?: number }> = ({ x, y, w, h, len, th, color = OSD, opacity = 1 }) => {
  const side = (left: boolean, top: boolean) => ({ position: 'absolute' as const, width: len, height: len, left: left ? 0 : w - len, top: top ? 0 : h - len, borderStyle: 'solid', borderColor: color, borderWidth: `${top ? th : 0}px ${left ? 0 : th}px ${top ? 0 : th}px ${left ? th : 0}px` });
  return (
    <div style={{ position: 'absolute', left: x, top: y, width: w, height: h, opacity, filter: 'drop-shadow(3px 3px 0 rgba(0,0,0,.6))' }}>
      <div style={side(true, true)} /><div style={side(false, true)} /><div style={side(true, false)} /><div style={side(false, false)} />
    </div>
  );
};
/** The focus box goes looking: it locks on the pencil mark, and on the birthday it slides off the cake to the chair and finds no one. */
const FocusBox: React.FC = () => {
  const t = useT();
  const s = SHOTS[shotAt(t)];
  if (s.img === 'marks' && !s.rewind && t >= E('mark')) {
    const p = toScreen(s, t, 545, MARK_FOUND);
    const lock = easeOut(ramp(t, E('tall') - 0.1, E('tall') + 0.1));
    const w = (520 - lock * 40) * (p.z / 1.85), h = (250 - lock * 90) * (p.z / 1.85);
    return <Corners x={p.x - w / 2} y={p.y - h / 2} w={w} h={h} len={44} th={6} color={lock >= 1 ? CC : OSD} />;
  }
  if (s.img === 'birthday' && t >= E('someone') - 0.1) {
    const k = easeInOut(ramp(t, E('someone'), E('frame') + 0.2));
    const p = toScreen(s, t, CAKE[0] + (CHAIR[0] - CAKE[0]) * k, CAKE[1] + (CHAIR[1] - CAKE[1]) * k);
    const hunt = (t > E('frame') + 0.2 ? 1 : 0) * 10 * Math.sin(t * 7) * (1 - ramp(t, E('frame') + 0.2, E('frame') + 1));
    const w = (300 + 110 * k) * (p.z / 1.42) + hunt, h = (240 + 240 * k) * (p.z / 1.42) + hunt;
    return <Corners x={p.x - w / 2} y={p.y - h / 2} w={w} h={h} len={44} th={6} />;
  }
  return null;
};

type Mode = 'REC' | 'PAUSE' | 'REW' | 'PLAY' | 'STOP';
const modeAt = (t: number): Mode => {
  if (t >= E('today')) return 'STOP';
  if (t >= E('over') + 0.25) return 'REC';
  if (t >= E('bday')) return 'PLAY';
  if (t >= E('back')) return 'REW';
  return paused(t) ? 'PAUSE' : 'REC';
};
const ModeIcon: React.FC<{ m: Mode; on: boolean }> = ({ m, on }) => {
  const box: React.CSSProperties = { display: 'block', width: 56, height: 56, filter: 'drop-shadow(3px 3px 0 rgba(0,0,0,.72))' };
  const paths: Record<Mode, React.ReactNode> = {
    REC: <circle cx="28" cy="28" r="22" fill={RED} opacity={on ? 1 : 0.15} />,
    PAUSE: <path d="M10 8h13v40H10zM33 8h13v40H33z" fill={OSD} />,
    REW: <path d="M28 8v40L4 28zM54 8v40L30 28z" fill={OSD} />,
    PLAY: <path d="M10 6v44l38-22z" fill={OSD} />,
    STOP: <rect x="9" y="9" width="38" height="38" fill={OSD} />,
  };
  return <svg viewBox="0 0 56 56" style={box}>{paths[m]}</svg>;
};
const levelAt = (t: number, ch: number) => {
  if (t >= E('push') && t < E('end')) return 1;
  const w = WORDS.find((x) => t >= x.t0 - 0.02 && t < x.t1 + 0.04);
  return w ? 4 + Math.floor(hash(Math.floor(t * 13) * 1.7 + ch * 31) * 6) : 1;
};
const Meter: React.FC<{ y: number; ch: string; n: number }> = ({ y, ch, n }) => (
  <div style={{ position: 'absolute', left: 84, top: y, display: 'flex', alignItems: 'center', gap: 6, font: `400 34px/1 ${VT}`, color: OSD, textShadow: OSD_SHADOW }}>
    <span style={{ width: 22 }}>{ch}</span>
    {Array.from({ length: 10 }, (_, i) => <span key={i} style={{ width: 26, height: 14, background: i < n ? OSD : 'rgba(244,244,244,.2)', boxShadow: '2px 2px 0 rgba(0,0,0,.5)' }} />)}
  </div>
);

const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
const stampOf = (t: number) => {
  let cur = STAMPS[0], from: Stamp | null = null, k = 1;
  for (let i = 0; i < STAMPS.length; i++) {
    const s = STAMPS[i];
    const start = s.rollFrom ? s.rollFrom() : s.at();
    if (t >= start) {
      from = cur === s ? null : cur.d;
      cur = s;
      k = s.rollFrom ? ramp(t, start, s.at()) : 1;
    }
  }
  const d = cur.d;
  if (!from || k >= 1) return { y: d[0], mo: d[1], day: d[2], h: d[3], mi: d[4], rolling: false };
  const m0 = from[0] * 12 + from[1] - 1, m1 = d[0] * 12 + d[1] - 1;
  const mm = Math.round(m0 + (m1 - m0) * ((1 - Math.cos(Math.PI * k)) / 2));
  return { y: Math.floor(mm / 12), mo: (mm % 12) + 1, day: 1 + Math.floor(hash(mm * 1.3) * 27), h: 1 + Math.floor(hash(mm * 0.7) * 22), mi: Math.floor(hash(mm * 2.1) * 59), rolling: true };
};
const DateStamp: React.FC = () => {
  const t = useT();
  const s = stampOf(t);
  const secs = s.rolling ? Math.floor(hash(Math.floor(t * 30)) * 59) : Math.floor(t) % 60;
  const h12 = s.h % 12 === 0 ? 12 : s.h % 12;
  const pad = (n: number) => String(n).padStart(2, '0');
  const sag = dimAt(t) > 0.3 ? 0.45 + 0.55 * hash(Math.floor(t * 9)) : 1;
  return (
    <div style={{ position: 'absolute', right: 130, top: 1056, textAlign: 'right', color: AMBER, textShadow: STAMP_SHADOW, opacity: sag, fontVariantNumeric: 'tabular-nums' }}>
      <div style={{ font: `400 104px/1 ${VT}` }}>{MONTHS[s.mo - 1]} {s.day} {s.y}</div>
      <div style={{ font: `400 62px/1 ${VT}`, marginTop: 2 }}>{h12}:{pad(s.mi)}:{pad(secs)} {s.h >= 12 ? 'PM' : 'AM'}</div>
    </div>
  );
};
const counterAt = (t: number) => {
  const base = 727;
  if (t < E('back')) return base + t;
  if (t < E('bday')) return base + E('back') - (t - E('back')) * 180;
  return base + E('back') - (E('bday') - E('back')) * 180 + (t - E('bday'));
};
const Viewfinder: React.FC = () => {
  const t = useT();
  if (t >= E('tape') + 0.14) return null;
  const m = modeAt(t);
  const c = Math.max(0, Math.floor(counterAt(t)));
  const pad = (n: number) => String(n).padStart(2, '0');
  const bat = t < E('over') ? 3 : t < E('low') ? 1 : 0;
  const lowOn = t >= E('battery') - 0.1 && t < E('today');
  const blink = Math.floor(t * 3) % 2 === 0;
  const big = t >= E('battery') - 0.1 && t < E('so');
  const s = SHOTS[shotAt(t)];
  const zoom = clamp((camAt(s, t).z - 1) / 1.6);
  return (
    <>
      <Corners x={40} y={226} w={1000} h={1274} len={90} th={6} />
      <div style={{ position: 'absolute', left: 84, top: 262, display: 'flex', alignItems: 'center', gap: 16, font: `400 92px/1 ${VT}`, color: OSD, textShadow: OSD_SHADOW }}>
        <ModeIcon m={m} on={Math.floor(t * 1.6) % 2 === 0} />{m}
      </div>
      <div style={{ position: 'absolute', left: 88, top: 352, font: `400 56px/1 ${VT}`, color: OSD, textShadow: OSD_SHADOW }}>{Math.floor(c / 3600)}:{pad(Math.floor(c / 60) % 60)}:{pad(c % 60)} &nbsp;{TAPE_NO}</div>
      <Meter y={416} ch="L" n={levelAt(t, 0)} />
      <Meter y={446} ch="R" n={levelAt(Math.max(0, t - 0.05), 1)} />
      <div style={{ position: 'absolute', right: 70, top: 270, display: 'flex', alignItems: 'center', gap: 22, font: `400 72px/1 ${VT}`, color: OSD, textShadow: OSD_SHADOW }}>
        SP
        <div style={{ position: 'relative', width: 100, height: 48, border: `5px solid ${lowOn && blink ? RED : OSD}`, borderRadius: 4, display: 'flex', gap: 5, padding: 4, boxSizing: 'border-box', boxShadow: '3px 3px 0 rgba(0,0,0,.7)' }}>
          {[0, 1, 2].map((i) => <b key={i} style={{ flex: 1, background: i < bat ? OSD : 'transparent' }} />)}
          <span style={{ position: 'absolute', right: -14, top: 10, width: 9, height: 18, background: lowOn && blink ? RED : OSD }} />
        </div>
      </div>
      <div style={{ position: 'absolute', right: 70, top: 342, display: 'flex', alignItems: 'center', gap: 8, font: `400 44px/1 ${VT}`, color: OSD, textShadow: OSD_SHADOW }}>
        W
        <div style={{ position: 'relative', width: 200, height: 12, background: 'rgba(244,244,244,.28)', boxShadow: '2px 2px 0 rgba(0,0,0,.5)' }}>
          <div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 200 * zoom, background: OSD }} />
        </div>
        T
      </div>
      {big ? <div style={{ position: 'absolute', left: 60, width: 890, top: 590, textAlign: 'center', font: `400 176px/1 ${VT}`, letterSpacing: '.05em', color: blink ? RED : OSD, textShadow: '5px 5px 0 rgba(0,0,0,.75)', opacity: blink ? 1 : 0.28 }}>{LOW_WORD}</div> : null}
      {lowOn && !big ? <div style={{ position: 'absolute', right: 70, top: 396, font: `400 52px/1 ${VT}`, color: RED, textShadow: OSD_SHADOW, opacity: blink ? 1 : 0.2 }}>{LOW_WORD}</div> : null}
      <FocusBox />
      <DateStamp />
    </>
  );
};

/* ---------- the cassette ---------- */
const Spine: React.FC = () => {
  const t = useT();
  const k = easeIn(ramp(t, E('cta') - 0.5, E('cta') - 0.05));
  return (
    <div style={{ position: 'absolute', left: 0, top: 1690 + k * 240, width: 1080, height: 230, background: 'repeating-linear-gradient(90deg, #141414 0 10px, #1d1d1d 10px 12px)' }}>
      <div style={{ position: 'absolute', left: 70, top: 34, width: 760, height: 150, background: LABEL, backgroundImage: 'repeating-linear-gradient(transparent 0 46px, #c9bfae 46px 48px)', transform: 'rotate(-.8deg)', padding: '22px 32px', boxSizing: 'border-box', font: `400 60px/1.2 ${MARKER}`, color: INK, whiteSpace: 'nowrap' }}>
        {SPINE_LABEL} <span style={{ font: `700 46px/1 ${HAND}`, color: '#b3261e', marginLeft: 16 }}>{SPINE_NOTE}</span>
      </div>
      <div style={{ position: 'absolute', right: 60, top: 60, font: `400 64px/1 ${VT}`, color: '#d9d9d9', letterSpacing: '.06em' }}>T-120</div>
    </div>
  );
};
const Spool: React.FC<{ cx: number; a: number; pack: number }> = ({ cx, a, pack }) => (
  <g transform={`translate(${cx} 85)`}>
    <circle r={pack} fill="#16110e" />
    <circle r="27" fill="#d6d2c6" />
    <g transform={`rotate(${a})`}>
      {[0, 60, 120, 180, 240, 300].map((r) => <rect key={r} x="-4" y="-27" width="8" height="14" fill="#141414" transform={`rotate(${r})`} />)}
    </g>
    <circle r="9" fill="#141414" />
  </g>
);
/** The tape itself comes up out of the spine and stays: the keyword is written on its label in marker. */
const Cassette: React.FC = () => {
  const t = useT();
  if (t < E('cta')) return null;
  const k = backEase(ramp(t, E('cta'), E('cta') + 0.6));
  const size = Math.min(152, 640 / (KEYWORD.length * 0.68)); // fits a longer keyword on the label
  return (
    <div style={{ position: 'absolute', left: 95, top: 1780 - (1780 - 500) * k + Math.sin(t * 1.6) * 3, width: 820, height: 640, borderRadius: 18, transform: `rotate(${-1.5 + Math.sin(t * 0.9) * 0.3}deg)`, background: 'repeating-linear-gradient(90deg, #171717 0 10px, #1f1f1f 10px 12px)', boxShadow: '0 16px 0 rgba(0,0,0,.35)' }}>
      <div style={{ position: 'absolute', left: 40, top: 36, width: 740, height: 340, background: LABEL, backgroundImage: 'repeating-linear-gradient(transparent 0 54px, #c9bfae 54px 56px)', transform: 'rotate(-.6deg)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: INK }}>
        <div style={{ font: `400 88px/1.05 ${MARKER}` }}>{CTA_WORD}</div>
        <div style={{ font: `400 ${size}px/1.05 ${MARKER}`, color: '#b3261e', whiteSpace: 'nowrap' }}>{KEYWORD}</div>
      </div>
      <svg viewBox="0 0 620 170" style={{ position: 'absolute', left: 100, top: 416, width: 620, height: 170 }}>
        <rect x="0" y="0" width="620" height="170" rx="12" fill="#34343a" />
        <Spool cx={155} a={t * 70} pack={78} />
        <Spool cx={465} a={t * 70} pack={52} />
        <path d="M0 0 L190 0 L90 170 L0 170 Z" fill="rgba(255,255,255,.06)" />
      </svg>
    </div>
  );
};

/* ---------- closed captions: one fixed place, black boxes, the spoken word lit ---------- */
const CHUNKS = buildChunks(22, 2);
const CcRow: React.FC<{ children: React.ReactNode }> = ({ children }) => <div style={{ display: 'inline-block', background: '#000', padding: '2px 18px 0', marginTop: 4 }}>{children}</div>;
const Captions: React.FC = () => {
  const t = useT();
  const box: React.CSSProperties = { position: 'absolute', left: 60, top: 1246, width: 890, height: 230, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', alignItems: 'center', font: `400 96px/1.08 ${VT}`, color: OSD, textAlign: 'center' };
  if (t >= E('push') && t < E('end') - 0.06) {
    return t >= E('today') ? <div style={box}><CcRow>{STOP_WORD}</CcRow></div> : null;
  }
  const idx = CHUNKS.reduce((a, c, i) => (t >= c.t0 - 0.06 ? i : a), -1);
  if (idx < 0) return null;
  const c = CHUNKS[idx];
  if (t >= E('cta') && c.t0 < E('cta')) return null;
  const live = c.words.findIndex((w) => t >= w.t0 - 0.03 && t < w.t1 + 0.08);
  let w = 0;
  return (
    <div style={box}>
      {c.rows.map((r, ri) => {
        const ws = r.s.split(' ');
        return (
          <div key={ri}>
            <CcRow>
              {ws.map((word, wi) => {
                const on = w === live || (word.startsWith(KEYWORD) && t >= E('cta'));
                w += 1;
                return <span key={wi} style={{ color: on ? CC : OSD }}>{word}{wi < ws.length - 1 ? ' ' : ''}</span>;
              })}
            </CcRow>
          </div>
        );
      })}
    </div>
  );
};

const Stage: React.FC = () => (
  <AbsoluteFill style={{ background: '#2a2a2e', overflow: 'hidden' }}>
    <EndSnow />
    <Picture />
    <TapeNoise />
    <Texture />
    <BrightLine />
    <Viewfinder />
    <Spine />
    <Cassette />
    <Captions />
  </AbsoluteFill>
);

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

## 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:** whoever holds the camera. An adult, close to the mic, speaking quietly to themselves in an empty house. Plain, unhurried and a little awkward at first, as if they feel odd talking out loud in there. Never sad on purpose and never a performer: the empty rooms carry the feeling, so the voice doesn't have to.

**When to use it:** on every line except the voiceless moment. The user's own voice is the best choice, recorded in the real room on a phone if they can. Otherwise audition quiet, natural voices and keep the delivery flat. "They sell the house on Friday" gets no extra weight at all. Treat it like a small camera mic: mono, a touch of the room, and gentle cuts below about 120 Hz and above about 8 kHz.

- **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 |
|-------|--------|-------------|---------------|
| motor whirr | 12 s | a small camcorder tape motor running, a faint steady mechanical whirr, close and soft | the bed, all the way through, rising on `low` |
| room hiss | 12 s | the room tone of an empty house, soft still air with faint tape hiss, no voices | the bed, all the way through; with the whirr it's all you hear in the low point |
| record click | 0.5 s | a small plastic camcorder record button pressed, one soft click and a quiet beep | `on`, and again on `today` for the stop |
| focus motor | 0.7 s | a tiny autofocus motor hunting, a soft short whine, very quiet | every row in `HUNTS` |
| whip | 0.4 s | a small camera swung fast through a room, a low soft rush of air | `marks`, `kitchen` |
| zoom motor | 1.5 s | a small camcorder zoom motor, a low mechanical hum | `smaller` |
| tape clunk | 0.6 s | an old tape deck's mechanism engaging, one soft plastic clunk | `hang`, `back`, `bday` |
| rewind | 1.2 s | a tape rewinding fast, a warm chattering whir, soft, never sharp | `back` to `bday` |
| candles | 3 s | small candle flames in a quiet room, the faintest flutter, a little warm room air | `bday` |
| yard air | 4 s | a summer backyard, soft leaves in a light wind, a rope creaking once, far-off birds, no voices | `yard` |
| tape noise | 2.5 s | soft warbling tape static rising slowly, low and warm | `part` to `over` |
| battery beep | 0.4 s | a small low-battery beep, soft and dull | each blink from `battery` to `so`, quietly |
| footsteps | 2.5 s | slow footsteps on old carpet in an empty house, soft and close, one floorboard creak | `so` to `door` |
| door | 1.5 s | an old wooden bedroom door opening slowly, a soft latch and a low hinge creak | `door` |
| outside | 5 s | the outdoors heard through a closed window from an empty room, far-off birds, a quiet street | `push`, under the voiceless moment |
| power down | 1 s | an old picture tube collapsing, a soft electric thump and a falling whine, warm | `tape` |
| snow | 6 s | soft even tape snow, a warm hiss with no harsh top | `tape` to the end, low |
| eject | 1 s | a tape cassette ejecting, a soft mechanical clunk and a slide | `cta` |

Keep the tape sounds small and close. They're the camera in your hand, not effects.

- **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:** "A slow, tender instrumental that sounds like it's playing off a worn tape: one felt piano with sparse notes, a warm pad with a slight pitch wobble, soft room around it, very quiet and unresolved for a long stretch, then a gentle lift where a soft second instrument joins and the chords open, no drums, no vocals."

**When it plays:** low and sparse from the hallway on. It drains to nothing over 0.7 s on "Low battery" and stays out, so only the motor whirr and the room hiss keep running. It comes back on the word "room", climbs through the door, and is at its fullest on the voiceless moment, on a bar where the track opens up. On "End of tape" let it slow and sag downward like a tape stopping, as the picture folds into its line. One last quiet chord can sit 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

- **It reads as a slideshow:** a still that only zooms is a slideshow. Keep the hand wobble on every shot, and make each change of picture something the camera does.
- **The stills drift:** always edit from the hero. The pairs that must share their framing exactly are `hall` and `boxes`, `kitchen` and `birthday`, `bedroom` and `bare`.
- **The room doesn't visibly empty:** if the push goes too far in, the bed and the desk leave the frame before they fade. Stay wide enough to watch them go. Ours ends at zoom 1.8.
- **A person sneaks in:** a face in a framed photo, a reflection, a doll. Check every still at full size before you build on it.
- **A black ending:** the snow sits behind the picture before it folds, so the frame never goes black. Keep it there.
- **Noise that flashes:** move the noise tile, don't change its brightness. Graph the mean brightness per frame, and if you see more than 2 swings a second across the whole picture, ease it off.
- **Text lost on a pale wall:** the amber stamp and the white display need their hard shadows. Check them over your window and your brightest wall.
- **Dates that don't add up:** viewers do the math. The age in the birthday line, the year on the stamp and today's date have to agree.
- **Two renders of a frame differ slightly:** Chrome's image scaling can move a pixel by 2 or 3 levels out of 255 on some frames. That's invisible, and text and positions still match. Anything bigger is a real bug.
- **A heavy file:** snow and grain eat bitrate. Keep the grain faint and the snow soft, and don't worry about the master's size. Encode the delivery file at normal quality.

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