---
name: photobooth
description: Make a 30-40 second vertical video inside a coin-op photo booth - the booth counts down 3, 2, 1, the flash fires, and a strip of four snapshots of one subject fills up across a year, until the worst photo gets a retake. The skill covers the booth's sound design, music fitted to the story and an optional voice like a cheerful machine that never notices anything is wrong. Use for 'a year in' stories, before and after, couples and friends, personal stories, events and parties, the progress of a plant, pet, home or project, or when asked for a photo booth or photo strip video, or when given the photobooth prompt.
---

# Photobooth

One subject sits in a photo booth, once a season, for a whole year. The booth counts down, the flash fires, and a snapshot prints on a strip with a pen note on it, while a cheerful machine voice tells the subject to sit up straight. The strip has four empty photos from the first second, so the viewer always knows how many are left. Then the worst one prints, the printer stops, and the booth asks if you'd like to try again.

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/photobooth/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/bungee`, `@fontsource/fraunces` 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/Photobooth.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 booth counts down, the flash fires, a snapshot prints on the strip, the worst photo gets a retake. 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 coin-op photo booth, seen from the stool. The subject sits on its little screen, and the strip prints beside it.

- **Palette:** velvet `#9e1b2f`, booth chrome `#c9ced6`, strip paper `#fbfaf6`, marquee yellow `#ffd34d`, ink `#1d1d1b`, pen pink `#ff4f8b`.
- **Type:** Bungee for the marquee, the screen and the month tab, Fraunces 500 italic for the captions, Caveat 700 for the pen notes.
- **Fixed parts, top to bottom:** a curtain rail with its rings, the marquee with chasing bulbs (y 44-220), the lens, the camera window (x 60-676, y 252-1064)
  with the strip bay to its right (x 694-958), the booth's screen and a YES button (y 1098-1224), captions (y 1250-1476, Fraunces italic at 90 px), then a chrome
  shelf, a coin plate and PHOTOS WHILE YOU WAIT as texture. The marquee sits above the safe area, so nothing on it has to be read.
- **The empty photos are the hook.** Four dark numbered squares are on the strip from the first second, so the viewer can count down with the booth.
- **The YES button is there all along,** unlit, so it pays off at the turn instead of arriving from nowhere.
- **The camera never moves between shots.** Same stool, same curtain, same framing. Only the subject changes, and only on a flash.
- **The flash stays comfortable:** a white that falls away in about 0.4 s, strongest in the window and only a faint veil over the rest of the frame. Keep flashes at
  least 0.5 s apart, so there are never 3 in a second.
- **Keep the coin plate bare:** a slot and a small light, with no amounts.
- **Small jokes live on the machine:** the screen reads 1 EAR DETECTED and DO NOT WATER, and the fridge notes read "less water" and "cat: no".

**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 camera's view, so this is the one place the film needs real pictures.

- **Use the user's own photos first.** Real photos of a real year beat anything generated: their plant, pet, kitchen, shop or the two of them. Four to six are enough. Crop them so
  the subject sits in the same place in each one, because the flash hides a change of picture but not a jump in framing.
- **Before you have pictures,** the frame still needs its six files to render, for the setup test in step 1 and for the animatic. Fill `public/img/` with placeholders:
  `for n in jan leaf apr jul oct shoot; do ffmpeg -y -f lavfi -i color=c=slategray:s=1200x1600 -frames:v 1 -update 1 public/img/$n.jpg; done`. A missing image stops the render.
- **Or generate about 6 stills** with any image tool (3:4, 2K) of the same subject. Make one hero image with lots of empty space above the subject, so it has room to grow, and look at it.
  Then make each of the others by editing the one before it: "The exact same photo booth shot: same camera position, same framing, same pleated curtain, same stool, same hard
  flash and film grain. Three months later..." References copy content as well as style, so finish every prompt with "The frame contains only..." and "No text, no logos". Hard
  direct flash and a little film grain make it look like a real booth print. Look at each one before you make the next.
- **What the set needs:**

| Picture | Ours | It does |
|---------|------|---------|
| The hero | A small plant in a plain pot on a booth stool | The start, and the live view before the first flash |
| A small win | The same plant with one new bright leaf | The joke that treats a tiny thing as a triumph |
| The new era | Much bigger, in a bright new pot | The high point, and the picture the photobomb lands on |
| The mistake | Drooping and yellow, water on the seat | Things going wrong |
| The low point | Dead and brown, the same bright pot | The worst photo. A strong color kept from the good times makes it sadder |
| The retake | The low point with one small new shoot | The turn. Always edit this one from the low point, or the zoom jumps |

- **Draw the joke in code.** The photobomb is an SVG (`Ear`), blurred because it's right up against the lens. Blur forgives a lot, so a drawn shape sits in a photo fine. It needs
  to show its point inside the window, or it reads as a thumb on the lens.
- **With people as the subject,** use only the user's own photos of people who agreed to it. Never generate a real person.
- **With the user's own clip,** take a still from the clip at each flash and use it as that shot's image. Between flashes the window can play the clip live, cropped to the same box.

## 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 a slow, private change like a series of passport photos, read out by a machine that means well. Ours is one year of a houseplant.

| # | Move | What it does | Our line | In the booth |
|---|------|--------------|----------|--------------|
| 1 | The curtain | The hook: seat the subject like a customer | "Welcome to the booth. Sit up straight, and close the curtain." | The crooked picture rights itself on "straight", the curtain slides across on its rings on "close", the empty strip feeds down |
| 2 | Photo one | Where it started, small | "Photo one. January. Look at the camera." | JANUARY tab, 3, 2, 1, flash on "camera", the photo prints |
| 3 | The small win | A tiny thing, taken seriously | "Smile! That's a leaf. Good." | Flash on "Smile", the photo prints again, and now there's a new leaf. Pen: "a leaf!" |
| 4 | Photo two | The high point | "Photo two. April. New pot, new you." | APRIL tab, 3, 2, 1, flash on "New": much bigger, bright pot. Pen: "new pot!" |
| 5 | The photobomb | A joke from outside the frame | "Photo two has a photobomb. That's the cat." | A blurry ear rises into the shot, the screen reads 1 EAR DETECTED, flash on "cat", and photo two prints with the ear in it |
| 6 | Photo three | The mistake | "Photo three. July. Someone's been overwatering." | JULY tab, 3, 2, 1, flash: drooping and yellow. Pen: "too wet" |
| 7 | Photo four | The low point | "Photo four. October. Hold still. You are very still." | 3, 2, 1, flash on "still": dead and brown. The print stops two thirds of the way down, the lights sag, the bulbs die, the screen reads PRINTER STOPPED. Music out; the booth's hum and a coin-slot tick keep running |
| 8 | The turn | The machine offers a way out | "Would you like to try again? Yes." | TRY AGAIN? The YES button lights on "try" and is pressed on "Yes", and the lights come back. Music returns on "again" |
| 9 | The retake | The voiceless moment, about 3 s | (music lifted, captions read CHEESE) | Flash, flash, flash, each one closer. The third finds a new shoot, photo four prints over the dead one, the strip shines and drops into the slot |
| 10 | Ready | The release | "Your photos are ready. Mind the drying time." | The strip rises back out, a wet shine runs down it, it shakes itself dry. The screen reads DO NOT WATER |
| 11 | The fridge | The last laugh | "Pinned to the fridge. Best year yet." | The window opens into a fridge door, the strip lands under a magnet, BEST YEAR YET stamps on, next to "less water" and "cat: no" |
| 12 | Call to action | One action | "Comment cheese and I'll send you the skill that made this." | The screen reads COMMENT CHEESE and stays, and letter magnets spell it on the fridge |

**For other topics:** a couple's first year, a shop's first four seasons, a kitchen renovation, a dog from puppy to couch, four attempts at a sourdough. The photobomb is whoever or whatever
keeps turning up: "That's my mother." The trick that makes it land is the retake. A photo booth really does offer one, so the low point already has a way out, and the worst photo on the strip gets
replaced by the best one. Find the small thing that was still alive in the user's worst moment, and make that the third flash. The voice never notices anything is wrong, which is the other half
of the joke: "You are very still" has to be said as brightly as "Good." Our film runs 41 s on the clock's estimate. For about 37 s, cut move 3 and "Mind the drying time". For about 34 s, cut move 5 as well, and
take their events out of `story.json` and their rows out of `SHOTS`, `SCREEN` and `DOODLES`, and point their notes in `PHOTOS` at another event.

## 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 of the booth stays as it is. Your story lives in the tables at the top of the file, so most of the work is your pictures and those tables.

| To change | Where |
|---|---|
| The comment keyword | `KEYWORD` at the top, and `plan.json`. It feeds the screen's last line, the voiceless caption, the letter magnets and the caption highlight |
| The marquee, the stamp, the fridge notes | `MARQUEE`, `STAMP`, `NOTES` |
| What the booth's screen says | `SCREEN`: each row is an event and what the screen reads from then on. Keep it in time order |
| The months | `MONTHS`: the event each tab appears on |
| The strip | `PHOTOS`: the small label, the pen note and the event it lands on |
| The pictures and flashes | `SHOTS`: one row per change of picture, with its event and image file (in `public/img/`). `flash` fires the bulb, `photo` prints it on that photo of the strip, `ear` puts the photobomb in the print, `stall` stops the printer part way, `zoom` makes a closer retake |
| Where the retakes zoom to | `ZOOM_AT`: the spot in your picture where the good news is |
| Which flashes get a 3, 2, 1 | `COUNTED` |
| The pen notes on the window | `DOODLES`: event range, words, position and arrow, in window pixels. Redo these for your pictures, because they point at things |
| The photobomb | `Ear` draws it, `earY` and the ear line in `Window` move it, and the ear line in `Frame` prints it. Redraw it, or delete it with move 5 |
| The fridge | `Fridge`. If your film doesn't end on a fridge, replace this with a wall, a wallet or a dashboard, and change the strip's landing spot in `stripPose` |
| The low point | `lowAt`, `flickerAt` and the `stall` value in `SHOTS` |
| How the strip moves | `stripPose`, and the gloss in `Strip` |
| The texture | `Shelf`: the coin plate and PHOTOS WHILE YOU WAIT |

- **Scene ids in the code:** none. It only reads events, so you can renumber scenes freely.
- **Sized to our words:** `fit()` shrinks the marquee, the month tab, the screen, the stamp and the voiceless caption for a longer word, and the letter magnets shrink to fit. The pen notes on the
  window are a fixed size, and Caveat runs wider than it looks: a note past x = 588 in `DOODLES` gets cut off by the window's edge. The fridge `NOTES` fit about 11 letters at 66 px.
- **Four photos is built in.** `MARQUEE`, `SCREEN`, `PHOTOS` and the `STRIP` sizes all assume four. For a different count, change them together and set `frame` so the strip still ends above the slot.
- **Delete a move's events along with its rows.** `story.json` events that no longer match a word stop the render, which is what you want.

### The code

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

```json
{
 "lines": [
  {
   "id": "l01",
   "text": "Welcome to the booth. Sit up straight, and close the curtain."
  },
  {
   "id": "l02",
   "text": "Photo one. January. Look at the camera."
  },
  {
   "id": "l03",
   "text": "Smile! That's a leaf. Good."
  },
  {
   "id": "l04",
   "text": "Photo two. April. New pot, new you."
  },
  {
   "id": "l05",
   "text": "Photo two has a photobomb. That's the cat."
  },
  {
   "id": "l06",
   "text": "Photo three. July. Someone's been overwatering."
  },
  {
   "id": "l07",
   "text": "Photo four. October. Hold still. You are very still."
  },
  {
   "id": "l08",
   "text": "Would you like to try again? Yes."
  },
  {
   "id": "l09",
   "text": "Your photos are ready. Mind the drying time."
  },
  {
   "id": "l10",
   "text": "Pinned to the fridge. Best year yet."
  },
  {
   "id": "l11",
   "text": "Comment cheese and I'll send you the skill that made this.",
   "say": "Comment, cheese, and I'll send you the skill that made this."
  }
 ],
 "scenes": [
  {
   "id": "s01",
   "lines": [
    "l01"
   ],
   "lead": 0.3,
   "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.1
  },
  {
   "id": "s05",
   "lines": [
    "l05"
   ],
   "lead": 0.1,
   "tail": 0.1
  },
  {
   "id": "s06",
   "lines": [
    "l06"
   ],
   "lead": 0.1,
   "tail": 0.1
  },
  {
   "id": "s07",
   "lines": [
    "l07"
   ],
   "lead": 0.1,
   "tail": 0.2
  },
  {
   "id": "s08",
   "lines": [
    "l08"
   ],
   "lead": 0.3,
   "tail": 0.15
  },
  {
   "id": "s09",
   "sec": 3.3
  },
  {
   "id": "s10",
   "lines": [
    "l09"
   ],
   "lead": 0.1,
   "tail": 0.1
  },
  {
   "id": "s11",
   "lines": [
    "l10"
   ],
   "lead": 0.15,
   "tail": 0.1
  },
  {
   "id": "s12",
   "lines": [
    "l11"
   ],
   "lead": 0.1,
   "tail": 0.1
  }
 ]
}
```

`src/index.ts`:

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

`src/Root.tsx`:

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

export const Root: React.FC = () => (
  <Composition id="Film-9x16" component={Photobooth} 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": "straight",
   "scene": "s01",
   "word": "straight"
  },
  {
   "id": "close",
   "scene": "s01",
   "word": "close"
  },
  {
   "id": "curtain",
   "scene": "s01",
   "word": "curtain"
  },
  {
   "id": "jan",
   "scene": "s02"
  },
  {
   "id": "snap1",
   "scene": "s02",
   "word": "camera"
  },
  {
   "id": "smile",
   "scene": "s03",
   "word": "smile"
  },
  {
   "id": "leaf",
   "scene": "s03",
   "word": "leaf"
  },
  {
   "id": "good",
   "scene": "s03",
   "word": "good"
  },
  {
   "id": "apr",
   "scene": "s04"
  },
  {
   "id": "snap2",
   "scene": "s04",
   "word": "new"
  },
  {
   "id": "pot",
   "scene": "s04",
   "word": "pot"
  },
  {
   "id": "bomb",
   "scene": "s05",
   "word": "photobomb"
  },
  {
   "id": "snapCat",
   "scene": "s05",
   "word": "cat"
  },
  {
   "id": "jul",
   "scene": "s06"
  },
  {
   "id": "snap3",
   "scene": "s06",
   "word": "someone"
  },
  {
   "id": "wet",
   "scene": "s06",
   "word": "overwatering"
  },
  {
   "id": "oct",
   "scene": "s07"
  },
  {
   "id": "snap4",
   "scene": "s07",
   "word": "still"
  },
  {
   "id": "very",
   "scene": "s07",
   "word": "very"
  },
  {
   "id": "ask",
   "scene": "s08"
  },
  {
   "id": "try",
   "scene": "s08",
   "word": "try"
  },
  {
   "id": "again",
   "scene": "s08",
   "word": "again"
  },
  {
   "id": "yes",
   "scene": "s08",
   "word": "yes"
  },
  {
   "id": "retake",
   "scene": "s09"
  },
  {
   "id": "r1",
   "scene": "s09",
   "off": 0.15
  },
  {
   "id": "r2",
   "scene": "s09",
   "off": 0.95
  },
  {
   "id": "r3",
   "scene": "s09",
   "off": 1.75
  },
  {
   "id": "drop",
   "scene": "s09",
   "off": 2.55
  },
  {
   "id": "rise",
   "scene": "s10",
   "word": "photos"
  },
  {
   "id": "mind",
   "scene": "s10",
   "word": "mind"
  },
  {
   "id": "pinned",
   "scene": "s11",
   "word": "pinned"
  },
  {
   "id": "fridge",
   "scene": "s11",
   "word": "fridge"
  },
  {
   "id": "best",
   "scene": "s11",
   "word": "best"
  },
  {
   "id": "cta",
   "scene": "s12",
   "word": "comment"
  },
  {
   "id": "cheese",
   "scene": "s12",
   "word": "cheese"
  }
 ]
}
```

`src/frames/Photobooth.tsx`:

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

/* Photobooth: a coin-op booth takes four photos of one subject across a year. 1080 x 1920. The sitter never moves: the months change, the
   flash fires, the strip fills up, and the worst photo gets a retake. Time comes from the spoken words through story.json. */
const E = makeE(story as StoryJson);

/* ---- The words on screen. Change these first. ---- */
const KEYWORD = 'CHEESE';
const MARQUEE = '4 PHOTOS';
const STAMP = 'BEST YEAR YET';
const NOTES = ['less water', 'cat: no'];
/** The booth's screen, in time order: from this event on, it reads this. */
const SCREEN: [string, string][] = [
  ['on', 'PLEASE BE SEATED'],
  ['jan', 'PHOTO 1 OF 4'],
  ['smile', 'SAY CHEESE'],
  ['apr', 'PHOTO 2 OF 4'],
  ['bomb', '1 EAR DETECTED'],
  ['jul', 'PHOTO 3 OF 4'],
  ['oct', 'PHOTO 4 OF 4'],
  ['snap4', 'PRINTING...'],
  ['very', 'PRINTER STOPPED'],
  ['ask', 'TRY AGAIN?'],
  ['retake', 'RETAKE'],
  ['drop', 'DEVELOPING'],
  ['rise', 'READY'],
  ['mind', 'DO NOT WATER'],
  ['pinned', 'THANK YOU'],
  ['cta', `COMMENT ${KEYWORD}`],
];
/** The tab on the window. */
const MONTHS: [string, string][] = [['jan', 'JANUARY'], ['apr', 'APRIL'], ['jul', 'JULY'], ['oct', 'OCTOBER']];
/** The four photos on the strip: a small label, and the pen note that lands on it at its event. */
const PHOTOS: { label: string; note: string; noteAt: string }[] = [
  { label: 'JAN', note: 'a leaf!', noteAt: 'leaf' },
  { label: 'APR', note: 'the cat', noteAt: 'snapCat' },
  { label: 'JUL', note: 'too wet', noteAt: 'wet' },
  { label: 'OCT', note: 'new shoot!', noteAt: 'r3' },
];
/** What the camera sees. `flash` fires the bulb, `photo` prints it on that photo of the strip, `stall` stops the printer part way. `zoom` is a closer retake. Images are in public/img. */
type Shot = { at: () => number; img: string; flash?: boolean; photo?: number; ear?: boolean; stall?: number; zoom?: number };
const SHOTS: Shot[] = [
  { at: () => 0, img: 'jan' },
  { at: () => E('snap1'), img: 'jan', flash: true, photo: 0 },
  { at: () => E('smile'), img: 'leaf', flash: true, photo: 0 },
  { at: () => E('snap2'), img: 'apr', flash: true, photo: 1 },
  { at: () => E('snapCat'), img: 'apr', flash: true, photo: 1, ear: true },
  { at: () => E('snap3'), img: 'jul', flash: true, photo: 2 },
  { at: () => E('snap4'), img: 'oct', flash: true, photo: 3, stall: 0.62 },
  { at: () => E('r1'), img: 'oct', flash: true, zoom: 1.28 },
  { at: () => E('r2'), img: 'oct', flash: true, zoom: 1.58 },
  { at: () => E('r3'), img: 'shoot', flash: true, photo: 3, zoom: 1.9 },
];
/** Where the closer retakes zoom to, in the picture: the spot where the new shoot comes up. */
const ZOOM_AT = '47% 40%';
/** The events that get a 3, 2, 1 before their flash. */
const COUNTED = ['snap1', 'snap2', 'snap3', 'snap4'];
/** Pen notes on the window, in window pixels, with an arrow from the words to the thing. */
const DOODLES: { from: string; to: string; text: string; x: number; y: number; rot: number; size?: number; arrow: string }[] = [
  { from: 'leaf', to: 'apr', text: 'a leaf!', x: 352, y: 118, rot: -6, arrow: 'M392 192 C372 232 340 256 312 268' },
  { from: 'pot', to: 'bomb', text: 'new pot!', x: 346, y: 664, rot: -6, size: 72, arrow: 'M470 656 C470 596 440 544 394 510' },
  { from: 'snapCat', to: 'jul', text: 'the cat', x: 330, y: 366, rot: -5, size: 80, arrow: 'M456 446 C462 454 468 462 472 474' },
  { from: 'wet', to: 'oct', text: 'too wet', x: 384, y: 668, rot: -5, size: 72, arrow: 'M446 664 C442 640 434 622 422 606' },
  { from: 'r3', to: 'rise', text: 'new shoot!', x: 14, y: 304, rot: -6, arrow: 'M176 300 C198 284 214 268 228 246' },
];
const HALO = '-3px -3px 0 #fff, 3px -3px 0 #fff, -3px 3px 0 #fff, 3px 3px 0 #fff, 0 5px 0 #fff';

const VELVET = '#9e1b2f', CHROME = '#c9ced6', PAPER = '#fbfaf6', YEL = '#ffd34d', INK = '#1d1d1b', PINK = '#ff4f8b';
const MARQ = 'Bungee, sans-serif', SERIF = 'Fraunces, serif', PEN = 'Caveat, cursive';
const FACES = [
  { family: 'Bungee', file: 'bungee-latin-400-normal.woff2' },
  { family: 'Fraunces', file: 'fraunces-latin-500-italic.woff2', weight: '500', style: 'italic' },
  { family: 'Fraunces', file: 'fraunces-latin-700-italic.woff2', weight: '700', style: 'italic' },
  { family: 'Caveat', file: 'caveat-latin-700-normal.woff2', weight: '700' },
];
const CHROME_G = 'linear-gradient(135deg,#f1f3f5,#c9ced6 30%,#8d94a0 55%,#e3e6ea 76%,#a4abb5)';
const WIN = { x: 74, y: 266, w: 588, h: 784 };
const BAY = { x: 694, y: 236, w: 264, h: 830 };
const STRIP = { x: 702, y: 252, w: 248, h: 812, pad: 18, frame: 180, gap: 10 };

const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
const img = (n: string) => staticFile(`img/${n}.jpg`);
/** The font size that fits a Bungee line in a box, so a longer word still fits. */
const fit = (s: string, width: number, max: number) => Math.min(max, width / (s.length * 0.72));
const shotAt = (t: number) => SHOTS.reduce((a, s) => (t >= s.at() ? s : a), SHOTS[0]);
/** The flash: soft, and well under three a second. Keep flash events at least 0.5 s apart. */
const flashAt = (t: number) => SHOTS.reduce((v, s) => (s.flash && t >= s.at() ? Math.max(v, Math.pow(1 - ramp(t, s.at(), s.at() + 0.42), 2)) : v), 0);
/** The low point, 0 to 1: the booth's lights sag after photo four and come back on the yes. */
const lowAt = (t: number) => easeInOut(ramp(t, E('snap4') + 0.5, E('very'))) * (1 - ramp(t, E('yes'), E('yes') + 0.35));
/** The tired bulb: slow, shallow dips, never more than about one and a half a second. */
const flickerAt = (t: number) => {
  const b = Math.floor(t / 0.7), ph = t / 0.7 - b;
  return 1 - (hash(b + 9) > 0.3 ? 0.24 * Math.pow(Math.sin(Math.PI * ph), 2) * lowAt(t) : 0);
};
const fridgeAt = (t: number) => easeInOut(ramp(t, E('pinned'), E('pinned') + 0.5));

const Curtain: React.FC = () => {
  const t = useT();
  const width = lerp(330, 1090, easeInOut(ramp(t, E('close'), E('curtain') + 0.45)));
  const sway = Math.sin(t * 0.7) * 9;
  const rings = 16;
  return (
    <>
      <AbsoluteFill style={{ background: '#2a2120', backgroundImage: 'linear-gradient(rgba(255,255,255,.035) 2px,transparent 2px),linear-gradient(90deg,rgba(255,255,255,.035) 2px,transparent 2px)', backgroundSize: '120px 120px' }} />
      <div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width, background: VELVET, backgroundImage: 'repeating-linear-gradient(90deg,rgba(0,0,0,.26) 0 26px,rgba(255,255,255,.06) 26px 40px,rgba(0,0,0,0) 40px 70px),linear-gradient(180deg,rgba(0,0,0,.24),rgba(0,0,0,0) 22%,rgba(0,0,0,0) 72%,rgba(0,0,0,.34))', backgroundPosition: `${sway}px 0, 0 0` }}>
        <div style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 34, background: 'linear-gradient(90deg,rgba(0,0,0,0),rgba(0,0,0,.5))' }} />
      </div>
      <div style={{ position: 'absolute', left: 0, right: 0, top: 12, height: 10, background: CHROME_G }} />
      {Array.from({ length: rings }, (_, i) => (
        <div key={i} style={{ position: 'absolute', left: (i + 0.5) * (width / rings) - 13, top: 2, width: 26, height: 36, borderRadius: '50%', border: `5px solid ${CHROME}`, boxShadow: '0 2px 0 rgba(0,0,0,.45)' }} />
      ))}
    </>
  );
};

const BULBS = (() => {
  const out: [number, number][] = [];
  for (let i = 0; i < 17; i++) out.push([16 + i * 46.5, 12]);
  for (let i = 1; i < 3; i++) out.push([760, 12 + i * 51]);
  for (let i = 16; i >= 0; i--) out.push([16 + i * 46.5, 160]);
  for (let i = 2; i > 0; i--) out.push([16, 12 + i * 51]);
  return out;
})();
const Marquee: React.FC = () => {
  const t = useT();
  const low = lowAt(t);
  const glow = (1 - 0.72 * low) * flickerAt(t);
  return (
    <div style={{ position: 'absolute', left: 150, top: 44, width: 780, height: 176, borderRadius: 18, background: INK, boxShadow: `inset 0 0 0 5px ${YEL}, 0 8px 0 rgba(0,0,0,.35)` }}>
      {BULBS.map(([x, y], i) => {
        const on = (i + Math.floor(t * 5)) % 3 !== 0;
        return <div key={i} style={{ position: 'absolute', left: x - 7, top: y - 2, width: 14, height: 14, borderRadius: '50%', background: on ? '#fff3c4' : '#6b5a2a', opacity: 0.25 + 0.75 * (1 - low), boxShadow: on && low < 0.5 ? '0 0 10px #ffd34d' : 'none' }} />;
      })}
      <div style={{ position: 'absolute', left: 0, right: 0, top: 38, textAlign: 'center', font: `400 ${fit(MARQUEE, 660, 100)}px/100px ${MARQ}`, color: YEL, opacity: glow, whiteSpace: 'nowrap' }}>{MARQUEE}</div>
    </div>
  );
};

/** The cat, in code: one blurry ear, close to the lens. */
const Ear: React.FC<{ size: number; blur: number; rot: number }> = ({ size, blur, rot }) => (
  <svg viewBox="0 0 200 240" width={size} height={size * 1.2} style={{ display: 'block', overflow: 'visible', filter: `blur(${blur}px)`, transform: `rotate(${rot}deg)`, transformOrigin: '50% 100%' }}>
    <defs>
      <linearGradient id="earFur" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#8a5a2e" /><stop offset="1" stopColor="#c08a4f" /></linearGradient>
      <linearGradient id="earIn" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#9c5f58" /><stop offset="1" stopColor="#d9a093" /></linearGradient>
    </defs>
    <path d="M0 250 C14 150 54 44 122 4 C158 62 196 150 200 250 Z" fill="url(#earFur)" />
    <path d="M62 250 C70 176 92 104 120 62 C136 110 152 176 154 250 Z" fill="url(#earIn)" />
    <path d="M70 232 L92 196 M92 240 L104 190 M122 240 L116 186 M144 232 L128 194" stroke="#f3e4d2" strokeWidth={9} strokeLinecap="round" />
    <path d="M12 214 L44 196 M24 170 L56 156 M186 208 L160 188 M176 164 L150 150" stroke="#5c3a1c" strokeWidth={14} strokeLinecap="round" />
  </svg>
);

const Doodle: React.FC<{ d: (typeof DOODLES)[number] }> = ({ d }) => {
  const t = useT();
  const a = E(d.from);
  if (t < a || t >= E(d.to)) return null;
  const k = back(ramp(t, a, a + 0.28));
  const draw = ramp(t, a + 0.1, a + 0.4);
  return (
    <>
      <svg viewBox={`0 0 ${WIN.w} ${WIN.h}`} style={{ position: 'absolute', left: 0, top: 0, width: WIN.w, height: WIN.h, overflow: 'visible' }}>
        <path d={d.arrow} fill="none" stroke="#fff" strokeWidth={15} strokeLinecap="round" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - draw} />
        <path d={d.arrow} fill="none" stroke={PINK} strokeWidth={8} strokeLinecap="round" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - draw} />
      </svg>
      <div style={{ position: 'absolute', left: d.x, top: d.y, font: `700 ${d.size ?? 84}px/1 ${PEN}`, color: PINK, whiteSpace: 'nowrap', textShadow: HALO, transform: `rotate(${d.rot}deg) scale(${k})`, transformOrigin: '0 50%' }}>{d.text}</div>
    </>
  );
};

const Fridge: React.FC<{ k: number; width: number }> = ({ k, width }) => {
  const t = useT();
  const knock = t >= E('fridge') ? Math.sin((t - E('fridge')) * 40) * 5 * (1 - ramp(t, E('fridge'), E('fridge') + 0.3)) : 0;
  const letters = KEYWORD.split('');
  const size = Math.min(84, 560 / letters.length);
  const paper = (text: string, x: number, y: number, rot: number, bg: string, pin: string) => (
    <div style={{ position: 'absolute', left: x, top: y, width: 270, padding: '34px 12px 20px', background: bg, textAlign: 'center', font: `700 66px/1 ${PEN}`, color: INK, transform: `rotate(${rot}deg)`, boxShadow: '4px 6px 0 rgba(0,0,0,.14)' }}>
      {text}
      <div style={{ position: 'absolute', left: 113, top: -18, width: 44, height: 44, borderRadius: '50%', background: pin, boxShadow: '0 4px 0 rgba(0,0,0,.25), inset -4px -5px 0 rgba(0,0,0,.14)' }} />
    </div>
  );
  return (
    <div style={{ position: 'absolute', left: 0, top: 0, width: 862, height: WIN.h, transform: `translate(${(1 - k) * width}px, ${knock}px)`, background: 'linear-gradient(100deg,#e6efe4,#dce6da 58%,#c8d3c6)' }}>
      <div style={{ position: 'absolute', left: 0, right: 0, top: 150, height: 7, background: '#8d988b' }} />
      <div style={{ position: 'absolute', left: 34, top: 44, width: 20, height: 88, borderRadius: 10, background: CHROME_G }} />
      <div style={{ position: 'absolute', left: 34, top: 176, width: 20, height: 280, borderRadius: 10, background: CHROME_G }} />
      {paper(NOTES[0], 96, 214, -4, '#fff7cf', YEL)}
      {paper(NOTES[1], 110, 452, 3, '#d7e8f2', PINK)}
      {letters.map((c, i) => {
        const a = E('cheese') + i * 0.1;
        return t >= a ? <div key={i} style={{ position: 'absolute', left: 722 + (i % 2) * 12, top: 120 + i * (size + 8), width: size, textAlign: 'center', font: `400 ${size}px/1 ${MARQ}`, color: [VELVET, '#1b6ec2', '#d98a00', '#2c8a4b'][i % 4], textShadow: '3px 4px 0 rgba(0,0,0,.22)', transform: `rotate(${(hash(i + 3) - 0.5) * 16}deg) scale(${back(ramp(t, a, a + 0.22))})` }}>{c}</div> : null;
      })}
    </div>
  );
};

const Window: React.FC = () => {
  const t = useT();
  const s = shotAt(t);
  const local = t - s.at();
  const low = lowAt(t), f = flashAt(t), fridge = fridgeAt(t);
  const tilt = (1 - back(ramp(t, E('straight'), E('straight') + 0.5))) * 3.6;
  const closer = s.zoom ? (s.img === 'shoot' ? lerp(s.zoom, 1.22, easeInOut(ramp(t, E('rise'), E('pinned')))) : s.zoom) : 1;
  const scale = closer * (1.06 + 0.012 * Math.min(local, 6) + tilt * 0.012);
  const fresh = s.flash ? 1 - ramp(local, 0.3, 0.9) : 0;
  const bright = (0.88 + 0.14 * fresh) * (1 - 0.34 * low) * flickerAt(t);
  const next = COUNTED.map(E).find((a) => a - t > 0 && a - t <= 0.9);
  const digit = next !== undefined ? Math.ceil((next - t) / 0.3) : 0;
  const digitK = next !== undefined ? 1 - ((next - t) % 0.3) / 0.3 : 0;
  const month = MONTHS.reduce((a, [ev, m]) => (t >= E(ev) ? m : a), '');
  const earY = (1 - back(ramp(t, E('bomb'), E('bomb') + 0.5))) * 400 + easeIn(ramp(t, E('jul'), E('jul') + 0.3)) * 400;
  const width = lerp(WIN.w, 862, fridge);
  return (
    <div style={{ position: 'absolute', left: WIN.x - 14, top: WIN.y - 14, width: width + 28, height: WIN.h + 28, borderRadius: 18, background: CHROME_G, boxShadow: '0 0 0 2px #6d737b, 8px 12px 0 rgba(0,0,0,.3)' }}>
      <div style={{ position: 'absolute', left: 14, top: 14, width, height: WIN.h, overflow: 'hidden', background: '#111', borderRadius: 6 }}>
        <Img src={img(s.img)} style={{ position: 'absolute', left: 0, top: 0, width: WIN.w, height: WIN.h, objectFit: 'cover', transform: `rotate(${tilt}deg) scale(${scale})`, transformOrigin: s.zoom ? ZOOM_AT : '50% 52%', filter: `brightness(${bright}) saturate(${1 - 0.28 * low})` }} />
        {t >= E('bomb') && t < E('jul') + 0.3 ? <div style={{ position: 'absolute', left: 276, top: 452, transform: `translateY(${earY}px)` }}><Ear size={300} blur={7} rot={6 + Math.sin(t * 9) * 3} /></div> : null}
        {DOODLES.map((d) => <Doodle key={d.text} d={d} />)}
        {month && fridge < 1 ? <div style={{ position: 'absolute', left: 0, top: 22, padding: '12px 22px 6px 18px', background: YEL, color: INK, font: `400 ${fit(month, 400, 54)}px/1 ${MARQ}`, boxShadow: '4px 4px 0 rgba(0,0,0,.3)' }}>{month}</div> : null}
        {digit ? <div style={{ position: 'absolute', left: 0, top: 230, width: WIN.w, textAlign: 'center', font: `400 320px/1 ${MARQ}`, color: '#fff', textShadow: '0 8px 0 rgba(0,0,0,.45)', transform: `scale(${1.22 - 0.22 * easeOut(digitK)})` }}>{digit}</div> : null}
        {fridge > 0 ? <Fridge k={fridge} width={width} /> : null}
        <div style={{ position: 'absolute', inset: 0, background: '#fff', opacity: 0.92 * f }} />
      </div>
    </div>
  );
};

const Lens: React.FC = () => {
  const f = flashAt(useT());
  return (
    <div style={{ position: 'absolute', left: 340, top: 228, width: 56, height: 56, borderRadius: '50%', background: 'radial-gradient(circle at 38% 34%,#5b6a78 0 10%,#10151a 22%,#050608 70%)', boxShadow: `0 0 0 7px ${CHROME}, 0 0 0 9px #6d737b, 0 0 ${10 + f * 40}px rgba(255,255,255,${f})` }} />
  );
};

const Screen: React.FC = () => {
  const t = useT();
  const idx = SCREEN.reduce((a, [ev], i) => (t >= E(ev) ? i : a), 0);
  const [ev, text] = SCREEN[idx];
  const shown = idx === 0 ? text : text.slice(0, Math.max(1, Math.ceil(ramp(t, E(ev), E(ev) + 0.28) * text.length)));
  const cta = easeOut(ramp(t, E('cta'), E('cta') + 0.3));
  const width = lerp(716, 890, cta);
  const size = fit(text, width - 56, 64 + 22 * cta);
  const pulse = cta > 0 ? 0.9 + 0.1 * Math.sin(t * 4) : 1;
  return (
    <div style={{ position: 'absolute', left: 60, top: 1098, width, height: 126, borderRadius: 14, background: CHROME_G, padding: 8, boxSizing: 'border-box', boxShadow: '0 0 0 2px #6d737b, 6px 8px 0 rgba(0,0,0,.3)' }}>
      <div style={{ width: '100%', height: '100%', borderRadius: 8, background: '#100c08', backgroundImage: 'repeating-linear-gradient(0deg,rgba(255,255,255,.04) 0 2px,rgba(0,0,0,0) 2px 5px)', display: 'flex', alignItems: 'center', justifyContent: 'center', font: `400 ${size}px/1 ${MARQ}`, color: YEL, opacity: pulse, whiteSpace: 'nowrap', textShadow: '0 0 16px rgba(255,211,77,.5)' }}>{shown}</div>
    </div>
  );
};

const YesButton: React.FC = () => {
  const t = useT();
  const on = ramp(t, E('try') - 0.1, E('try') + 0.2) * (1 - ramp(t, E('rise'), E('rise') + 0.4));
  const press = t >= E('yes') && t < E('yes') + 0.22 ? 0.9 : 1;
  const beat = t >= E('try') && t < E('yes') ? 0.5 + 0.5 * Math.sin((t - E('try')) * 9) : 1;
  if (t >= E('cta')) return null;
  return (
    <div style={{ position: 'absolute', left: 806, top: 1100, width: 122, height: 122, borderRadius: '50%', background: CHROME_G, boxShadow: '0 0 0 2px #6d737b, 5px 7px 0 rgba(0,0,0,.3)' }}>
      <div style={{ position: 'absolute', inset: 10, borderRadius: '50%', background: on > 0 ? YEL : '#5e1420', opacity: on > 0 ? 0.55 + 0.45 * beat * on : 1, transform: `scale(${press})`, boxShadow: on > 0 ? `0 0 ${34 * on}px rgba(255,211,77,.9)` : 'inset 0 -6px 0 rgba(0,0,0,.3)', display: 'flex', alignItems: 'center', justifyContent: 'center', font: `400 34px/1 ${MARQ}`, color: on > 0 ? INK : '#a65a64' }}>YES</div>
    </div>
  );
};

/** One photo on the strip: the print head wipes the newest shot over the last one. */
const Frame: React.FC<{ i: number }> = ({ i }) => {
  const t = useT();
  const shots = SHOTS.filter((s) => s.photo === i && t >= s.at());
  const cur = shots[shots.length - 1], prev = shots[shots.length - 2];
  const p = PHOTOS[i];
  const pic = (s: Shot, clip?: string, filter?: string) => (
    <div style={{ position: 'absolute', inset: 0, clipPath: clip, filter }}>
      <Img src={img(s.img)} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: '50% 50%' }} />
      {s.ear ? <div style={{ position: 'absolute', left: 104, top: 58 }}><Ear size={106} blur={3} rot={6} /></div> : null}
    </div>
  );
  let body: React.ReactNode = <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', font: `400 86px/1 ${MARQ}`, color: '#4d4d4d' }}>{i + 1}</div>;
  let head = -1;
  if (cur) {
    const k0 = ramp(t, cur.at() + 0.12, cur.at() + 0.75);
    const k = cur.stall !== undefined ? Math.min(cur.stall, k0) : k0;
    const dev = ramp(t, cur.at() + 0.12, cur.at() + 1.2);
    head = k < 1 ? k : -1;
    body = (
      <>
        {prev ? pic(prev) : null}
        {pic(cur, k >= 1 ? undefined : `inset(0 0 ${(1 - k) * 100}% 0)`, `saturate(${0.3 + 0.7 * dev}) brightness(${1.35 - 0.35 * dev})`)}
      </>
    );
  }
  const noted = t >= E(p.noteAt) + 0.15;
  return (
    <div style={{ position: 'relative', width: STRIP.w - STRIP.pad * 2, height: STRIP.frame, background: '#2a2a2a', overflow: 'hidden', marginBottom: STRIP.gap }}>
      {body}
      {head >= 0 ? <div style={{ position: 'absolute', left: 0, right: 0, top: `${head * 100}%`, height: 5, background: YEL }} /> : null}
      {cur ? <div style={{ position: 'absolute', left: 6, top: 6, padding: '3px 6px 0', background: INK, color: PAPER, font: `400 19px/1.1 ${MARQ}` }}>{p.label}</div> : null}
      {noted ? <div style={{ position: 'absolute', left: 0, right: 0, bottom: 2, textAlign: 'center', font: `700 ${Math.min(50, 300 / p.note.length)}px/1 ${PEN}`, color: PINK, textShadow: '-2px -2px 0 #fff, 2px -2px 0 #fff, -2px 2px 0 #fff, 2px 2px 0 #fff', transform: `rotate(-5deg) scale(${back(ramp(t, E(p.noteAt) + 0.15, E(p.noteAt) + 0.4))})` }}>{p.note}</div> : null}
    </div>
  );
};

const stripPose = (t: number) => {
  const drop = 830;
  const y0 = lerp(STRIP.y - drop, STRIP.y, easeOut(ramp(t, 0, 0.9))) + easeIn(ramp(t, E('drop'), E('drop') + 0.55)) * drop - back(ramp(t, E('rise'), E('rise') + 0.6)) * drop;
  const fly = easeInOut(ramp(t, E('pinned'), E('fridge')));
  const shake = t >= E('mind') ? Math.sin((t - E('mind')) * 15) * 2 * (1 - ramp(t, E('mind') + 0.9, E('pinned'))) : 0;
  const swing = t >= E('fridge') ? Math.sin((t - E('fridge')) * 2.1) * 0.7 : 0;
  return { x: lerp(STRIP.x, 474, fly), y: lerp(y0, 296, fly), r: lerp(shake, -3 + swing, fly), s: lerp(1, 0.9, fly) };
};

const Strip: React.FC = () => {
  const t = useT();
  const pose = stripPose(t);
  const low = lowAt(t);
  const free = t >= E('pinned');
  const shine = (a: number, b: number) => ramp(t, a, b) * 1.6 - 0.3;
  const glossA = t >= E('r3') + 0.15 && t < E('drop') + 0.6 ? shine(E('r3') + 0.15, E('drop') + 0.2) : -1;
  const glossB = t >= E('rise') && t < E('pinned') ? shine(E('rise') + 0.2, E('pinned') - 0.1) : -1;
  const gloss = Math.max(glossA, glossB);
  const stamped = t >= E('best');
  const paper = (
    <div style={{ position: 'absolute', left: free ? pose.x : pose.x - BAY.x, top: free ? pose.y : pose.y - BAY.y, width: STRIP.w, height: STRIP.h, padding: `16px ${STRIP.pad}px 0`, boxSizing: 'border-box', background: PAPER, overflow: 'hidden', boxShadow: '8px 10px 0 rgba(0,0,0,.28)', filter: low > 0 ? `brightness(${1 - 0.3 * low})` : undefined, transform: `rotate(${pose.r}deg) scale(${pose.s})`, transformOrigin: '50% 0' }}>
      {PHOTOS.map((_, i) => <Frame key={i} i={i} />)}
      {stamped ? <div style={{ position: 'absolute', left: 0, right: 0, bottom: 7, textAlign: 'center', font: `400 ${fit(STAMP, STRIP.w - 24, 26)}px/1 ${MARQ}`, color: VELVET, transform: `scale(${1.5 - 0.5 * easeOut(ramp(t, E('best'), E('best') + 0.18))})` }}>{STAMP}</div> : null}
      {gloss > -0.3 && gloss < 1.3 ? <div style={{ position: 'absolute', left: -120, top: `${gloss * 100 - 25}%`, width: STRIP.w + 240, height: 170, background: 'linear-gradient(180deg,rgba(255,255,255,0),rgba(255,255,255,.62),rgba(255,255,255,0))', transform: 'rotate(-18deg)' }} /> : null}
    </div>
  );
  const magnet = t >= E('fridge') ? (
    <div style={{ position: 'absolute', left: pose.x + 84, top: pose.y - 34, width: 62, height: 62, borderRadius: '50%', background: PINK, boxShadow: '0 5px 0 rgba(0,0,0,.25), inset -5px -6px 0 rgba(0,0,0,.14)', transform: `scale(${1.8 - 0.8 * easeOut(ramp(t, E('fridge'), E('fridge') + 0.16))})` }} />
  ) : null;
  return free ? (
    <>
      {paper}
      {magnet}
    </>
  ) : (
    <div style={{ position: 'absolute', left: BAY.x, top: BAY.y, width: BAY.w, height: BAY.h, overflow: 'hidden' }}>{paper}</div>
  );
};

const Bay: React.FC = () => {
  const gone = fridgeAt(useT());
  return (
    <div style={{ opacity: 1 - gone }}>
      <div style={{ position: 'absolute', left: BAY.x, top: BAY.y, width: BAY.w, height: BAY.h, background: 'rgba(16,10,8,.72)', boxShadow: 'inset 0 0 0 3px #6d737b' }} />
      <div style={{ position: 'absolute', left: BAY.x - 6, top: BAY.y + BAY.h - 6, width: BAY.w + 12, height: 22, borderRadius: 6, background: CHROME_G, boxShadow: '0 0 0 2px #6d737b' }} />
    </div>
  );
};

const CHUNKS = buildChunks(19, 2);
const Captions: React.FC = () => {
  const t = useT();
  if (t >= E('retake') && t < E('rise') - 0.32) {
    const pop = SHOTS.filter((s) => s.flash && t >= s.at()).pop();
    const k = pop ? easeOut(ramp(t, pop.at(), pop.at() + 0.25)) : 1;
    return <div style={{ position: 'absolute', left: 60, top: 1250, width: 890, height: 226, display: 'flex', alignItems: 'center', justifyContent: 'center', font: `italic 700 ${fit(KEYWORD, 900, 196)}px/1 ${SERIF}`, color: YEL, transform: `scale(${1.1 - 0.1 * k})` }}>{KEYWORD}</div>;
  }
  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 k = easeOut(ramp(t, c.t0 - 0.06, c.t0 + 0.12));
  return (
    <div style={{ position: 'absolute', left: 60, top: 1250, width: 890, height: 226, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', alignItems: 'center', font: `italic 500 90px/1.12 ${SERIF}`, color: PAPER, textShadow: '0 4px 0 rgba(0,0,0,.35)', transform: `translateY(${(1 - k) * 12}px)`, opacity: k }}>
      {c.rows.map((r, ri) => {
        const ws = [...new Set(r.map.filter((x) => x >= 0))];
        return (
          <div key={ri} style={{ whiteSpace: 'nowrap' }}>
            {ws.map((wi, j) => {
              const w = c.words[wi];
              const on = t >= w.t0 - 0.03 && t < w.t1 + 0.06;
              const said = t >= w.t1;
              const kw = w.w.toUpperCase().startsWith(KEYWORD) && t >= E('cta');
              return <span key={wi} style={{ color: on || kw ? YEL : PAPER, opacity: on || said || kw ? 1 : 0.34 }}>{w.w}{j < ws.length - 1 ? ' ' : ''}</span>;
            })}
          </div>
        );
      })}
    </div>
  );
};

/** Below the safe area: texture only. */
const Shelf: React.FC = () => {
  const t = useT();
  const tick = Math.pow(1 - ((t * 1.4) % 1), 3);
  return (
    <>
      <div style={{ position: 'absolute', left: 0, right: 0, top: 1496, height: 20, background: CHROME_G }} />
      <div style={{ position: 'absolute', left: 0, right: 0, top: 1516, bottom: 0, background: '#241c1a', backgroundImage: 'repeating-linear-gradient(90deg,rgba(255,255,255,.03) 0 3px,rgba(0,0,0,0) 3px 90px)' }} />
      <div style={{ position: 'absolute', left: 110, top: 1560, width: 190, height: 230, borderRadius: 10, background: CHROME_G, boxShadow: '0 0 0 2px #6d737b' }}>
        <div style={{ position: 'absolute', left: 84, top: 34, width: 14, height: 84, borderRadius: 4, background: '#0c0c0c' }} />
        <div style={{ position: 'absolute', left: 80, top: 148, width: 24, height: 24, borderRadius: '50%', background: '#d9342b', opacity: 0.3 + 0.7 * tick }} />
      </div>
      <div style={{ position: 'absolute', left: 340, top: 1600, width: 580, font: `400 44px/1.2 ${MARQ}`, color: '#7f6f62' }}>PHOTOS WHILE YOU WAIT</div>
    </>
  );
};

const Stage: React.FC = () => {
  const t = useT();
  const low = lowAt(t), f = flashAt(t);
  return (
    <AbsoluteFill style={{ background: INK }}>
      <Curtain />
      <Marquee />
      <Shelf />
      <AbsoluteFill style={{ background: `rgba(14,6,8,${0.52 * low * (2 - flickerAt(t))})` }} />
      <Bay />
      <Window />
      <Lens />
      <Screen />
      <YesButton />
      <Strip />
      <Captions />
      <AbsoluteFill style={{ background: '#fff6e6', opacity: 0.16 * f, pointerEvents: 'none' }} />
    </AbsoluteFill>
  );
};

export const Photobooth: 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:** the booth itself. A bright, polite, pre-recorded prompt voice, the kind that has said the same lines in the same mall for forty years and is delighted every time. It never notices that
anything is wrong: "You are very still" gets exactly the same smile as "Good."

**When to use it:** on every line except the retake. Keep the delivery level through the low point, because the gap between that voice and the dead plant is the joke. Put it through a small speaker: roll off below
about 200 Hz and above about 5 kHz, and add a very short boxy echo. Pass commas around the keyword in the spoken text ("Comment, cheese, and...") so it doesn't blur.

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

Events are the names in `story.json`.

| Sound | Length | Prompt idea | When it plays |
|-------|--------|-------------|---------------|
| coin | 0.8 s | one coin dropped into an old vending slot, rattling down inside a metal mechanism, small and close | the first frame (`on`) |
| curtain rings | 1.2 s | metal curtain rings sliding fast along a chrome rail with heavy velvet swishing behind them, soft and close | `close` |
| countdown beep | 0.3 s | a soft low electronic beep from an old machine, warm and round, not piercing | each digit of a 3, 2, 1, in the 0.9 s before each event in `COUNTED` |
| shutter | 0.7 s | a big mechanical camera shutter clunk with a soft flash pop and a falling whine, warm, no sharp click | every flash in `SHOTS` |
| printer | 1 s | a small strip printer feeding paper, a low motor whir with a soft rhythmic ticking | just after each flash that prints |
| printer stop | 0.8 s | the same small motor grinding down and stopping with a dull clunk | `very` |
| ding | 0.5 s | one small cheerful machine chime, soft and round | `good` |
| pen | 0.7 s | a felt tip pen scribbling quickly on glossy paper, soft squeak | each pen note: `leaf`, `pot`, `snapCat`, `wet`, `r3` |
| fur | 0.8 s | soft fur brushing right against a microphone, a short muffled rustle | `bomb` |
| coin-slot tick | 0.3 s | a single dull metal tick from inside a coin mechanism, very short and dry | about 1.4 times a second through the low point, with the coin plate's light |
| button | 0.4 s | a chunky plastic arcade button pressed once, a soft thock | `yes` |
| strip drop | 0.7 s | a stiff strip of photo paper dropping into a metal chute, a soft flap and small clatter | `drop` |
| paper shake | 1.2 s | a damp strip of photo paper shaken in the air, soft quick flapping | `mind` |
| magnet | 0.4 s | a fridge magnet snapping onto a steel door, a short dull clack | `fridge`, and quieter for each letter from `cheese` |
| stamp | 0.5 s | a small rubber stamp pressed on card, soft and dry | `best` |
| booth hum | 12 s | the fluorescent tube and transformer of a coin-op booth, a steady low warm hum | the bed, all the way through, rising through the low point and flickering with the light |
| room tone | 12 s | a quiet shopping mall heard from behind a curtain, soft air, very faint distant activity, no voices, no music | the bed, a little louder until the curtain closes on `curtain` |

- **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:** "Cheerful shopping mall lounge instrumental: a bright electric organ, soft brushed drums and a bouncing upright bass, a little too happy and sparse for a long stretch, then a clear lift where warm
brass and full chords open up, ending resolved, no vocals."

**When it plays:** the bed plays low and sunny under January through July. It drains to nothing over 0.7 s on the flash at `snap4`, and stays out while the lights are low, so only the booth's hum and the coin-slot tick
remain. It comes back low on `again`, is at its fullest on the voiceless retake, on a bar where the track opens up at `r3`, and settles under the last lines so they sit clear.

**Mix, voice first.**

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

## 10. Render and QA

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

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

## 11. Traps

- **Too many flashes:** this film flashes on every shot, so measure it. Read each frame's brightness from the render and count the jumps that fall back: there should never be 3 in any one second. Count only jumps that fall, or
  the lights coming back at the turn count as flashes. Keep the low point's flicker slow and shallow, like `flickerAt`, because it is a flash too if you hurry it.
- **The stills drift:** always edit from a picture you've looked at, and keep the camera words the same in every prompt. Throw out any picture with a different plant or stray text, and hide small differences under the flash.
- **No room to grow:** a hero with the subject filling the frame leaves the later pictures nowhere to go. Leave the top half empty.
- **Dark bits on the strip:** at 212 x 180 px a photo is mostly silhouette. Use a pale backdrop, so a dead brown plant still reads at that size.
- **A pen note on the stool:** pink on a red seat disappears, so put notes over the backdrop, and check them on full-size frames. A contact sheet hides a note cut off by the window's edge.
- **The month changes before the picture does.** That's on purpose: the tab flips at the start of the line and the new picture waits for the flash, which is the suspense. Don't line them up.
- **A quiet start:** the first picture sits for about 7 s before the first flash. Keep the tilt, the curtain and the strip feeding down there, and don't add an early flash to fill it.

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