---
name: the-gallery
description: Make a 30-40 second vertical video where a grand museum room treats something small and silly as a masterpiece - a gilded frame, a placard that types itself, an X-ray that finds a secret, lamps that go out one by one, then one paddle rising in the dark. The skill covers the room's sound design, music fitted to the story and an optional voice like a hushed audio guide. Only original works go on the wall - the painting is drawn in code, never a real artwork, artist or museum. Use for mock-serious stories about small things, the last of anything, what everyone wants and nobody will take, or when asked for a museum, art gallery or auction video, or when given the-gallery prompt.
---

# The Gallery

One room of a grand museum fills the screen, and your small silly thing is the masterpiece on the wall. Ours is the last slice of pizza, painted in oils in a gilded frame. A refined audio guide explains it, visitors reach for it and pretend they didn't, and an X-ray finds out someone was here first. Then the museum closes, the lamps go out one by one and the rope drops. One paddle rises in the dark. The lights slam on, the room was full all along, and a gavel lands on the placard. Nobody ever admits it's only pizza.

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/the-gallery/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/cormorant-garamond` and `@fontsource/archivo-narrow`. 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/Gallery.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 placard types itself, visitors drift past and reach for the painting, an X-ray crosses it, the lamps go out one by one, a rope drops, a paddle rises in the dark and a gavel lands. 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

One room of a grand museum, late in the day, seen from where the visitors stand.

- **Only original works on the wall. This is the rule of this world.** The painting is drawn in code, or it's the user's own artwork. Never put a real artwork on
  the wall, never name a real artist, museum, gallery or auction house, and never ask an image model for the style of a named painter. Invent the title, the room
  and the collection every time.
- **Palette:** gallery green wall `#24433a` with a darker damask, gilt `#b8913f` and `#e8c874`, placard `#f5f2ea`, wall lettering in linen `#e9e1cf`, liner
  `#cdc2a4`, sold red `#d0202a`, oak floor `#8a5a34`, velvet rope `#7c1019`, silhouettes `#08110e`.
- **Type:** Cormorant Garamond 600 for the captions and the wall text, its italic for the work's title, Archivo Narrow 600 and 700 for everything on the placard
  and the guide, and for the paddle numbers.
- **Fixed parts:** a ceiling track with five lamps, the room sign at the top left and an EXIT sign at the top right (both above the safe area, so nothing there has
  to be read), the gilded frame at x 100-980, y 190-1030, then under it the audio guide on the left and the placard on the right, the rope, the captions in one
  place (y 1328-1480), and the oak floor below as texture.
- **One light map:** everything in the room is painted fully lit, then multiplied by `LightMap`, a list of soft pools. A lamp going out really darkens what it was
  lighting, and the flashlight really reveals what it passes. Anything that has to glow by itself goes in `Glow` or `ExitSign`, on top of the map.
- **The camera never leaves the room.** It moves with `zoom()`, around a point inside the frame, so the room's edges stay off screen.
- **The captions are the audio guide's script,** lettered on the wall, and the guide's little meter moves whenever a word is being spoken.
- **Small jokes live on the walls:** "Late Works" for a cold slice, "No flash. No napkins." long before the napkin arrives, "Long looks. No action.", "Fig. 2: tooth
  marks", "Performance, three bites". The guide reads "No audio for this moment" in the voiceless moment.

**Real people and real marks, in every version of this film.** The user's own name, brand and product are fine. Never use anyone else's company, agency, team or product name, logo, livery, seal or badge. Never use a real person's name, face or voice without their OK, and never make a real person or a named company the villain, the target of the joke or the one to blame.

## 4. The window

The window is the part of the frame that shows the subject. It has two modes, and the frame must work with either.

- **With no person (the default).** A scene that belongs to the world, drawn in code or from still images. It has to follow
  the story as well, not just sit there. "No person" means no filmed presenter: small drawn figures are fine.
- **With the user's own clip.** Replace the scene with their footage (`<OffthreadVideo>` from `remotion`, cropped to the window,
  muted if the narration is separate). Keep their face in the middle third, and take
  the word timings from their clip with Whisper.

For this world the window is the painting in the gilded frame, and what happens to it is the story. Ours is drawn in code: paths for the shapes, hundreds of hashed brush
strokes, then one SVG filter that warps it and lights it through a bumpy surface, so it reads as thick oil.

| Story moment | In the frame |
|---|---|
| Opening | The still life in half light: a slice on torn cardboard, an oxblood drape, spilled flakes. It blooms to full light on the word "pizza" |
| The riddle | A visitor's arm crosses the frame and stops a finger's width from the tip of the slice |
| The X-ray | A bright bar sweeps across, and behind it the picture turns blue-white: tacks, stretcher bars, the slice as a ghost, and a second slice under it. Curator's labels point at it, then the bite marks. The bar sweeps back |
| The low point | The middle lamp drops to a glow, and a flashlight crosses the varnish |
| The turn | The same lamp swings off the painting and down onto the bidder, leaving the picture dark |
| The button | The lights blink and the frame is empty: the wall behind it, a nail, a swinging wire, crumbs still falling |

The window also works with the user's own clip or artwork: put it where `{PAINTING}` sits in `Canvas`, cropped to the canvas, keep the X-ray layer as a second version of it,
and keep the empty frame.

## 5. The story

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

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

**The moves.** Map any topic onto these. The film treats something small and silly as the greatest work in the building, and nobody in it ever admits otherwise. Ours is the last slice of pizza.

| # | Move | What it does | Our line | In the room |
|---|------|--------------|----------|-------------|
| 1 | Welcome | The hook: reverence for the wrong thing | "Welcome to room nine. Please lower your voice near the pizza." | The middle lamp flickers on, the guide beeps, the camera pulls back, and the painting blooms on "pizza" |
| 2 | The work | The joke in museum language | "Untitled. Last slice. Cold pepperoni on cardboard, artist unknown." | The camera pushes in, and the placard types itself, one row per phrase |
| 3 | Visitors | The world fills up | "Visitors often ask why it's still here." | Silhouettes drift past, between us and the wall |
| 4 | The riddle | The real human truth | "Everyone wanted it. Nobody would take it." | One reaches over the rope, freezes at the tip of the slice, and turns it into fixing her hair. The others look away |
| 5 | The period | Naming it, with a straight face | "Scholars call this the politeness period." | Wall text sets itself in gilt, with a note: Long looks. No action. |
| 6 | The X-ray | A secret under the surface | "Under X-ray, we find a second slice. Someone was here first." | A bar sweeps the painting. Fig. 1: a second slice. Fig. 2: tooth marks |
| 7 | Low point | It's all ending | "The museum closes tonight. The work will be thrown away." | The lamps go out one by one, the visitors leave, the rope drops, a guard's flashlight finds a strip of tape on the placard: TO BE THROWN AWAY. Music out; room tone and slow footsteps keep running |
| 8 | The turn | One small word | "Unless someone bids." | In the near dark, a single paddle rises and catches the only light. Music returns on "bids" |
| 9 | The bid | The laugh inside the turn | "I have one paddle. The other hand has a napkin." | The middle lamp swings down and finds a seated figure, paddle up, then a white napkin in the other hand |
| 10 | The sale | The voiceless moment, about 3 s | (music at its fullest, the caption reads SOLD) | The lamps slam on and the room was full all along: two rows of seats, paddles up everywhere. They drop, a gavel lands on the placard, and a red dot is there when it lifts |
| 11 | The button | The last laugh | "The buyer ate it in the lobby. Critics call it his best work." | The lights blink and the frame is empty. A security monitor shows the lobby: three bites and a dab. The placard retitles itself, and three paddles come back up as scores: 10, 10, 10 |
| 12 | Call to action | One action | "Comment gallery and I'll send you the skill that made this." | The placard reads COMMENT GALLERY, in the room's own way, and stays until the end |

**For other topics:** the last donut in the office kitchen, the one chair nobody sits in at every meeting, the reply-all thread nobody will end, the side project everyone admires and nobody ships. Your thing needs a
reason everybody wants it and nobody takes it, and one small secret an X-ray could find. The trick that makes it land is the auction: the room you thought had emptied was full all along, and the
same lamp that left the painting is the one that finds the bidder. So keep the seats in the dark from the moment the visitors leave, and let only one paddle catch the light. Never let the voice wink. It's plainly a joke, so
invented scholars and critics are fine; use real facts if the story is real. Our film runs 41 s on the clock's estimate. For about 35 s, drop move 5 (keep its wall text on move 4 if you like) and cut the first sentence of move 7.

## 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 room stays. A new topic means a new painting, new words on the walls and new walkers. Start at the top of the file.

| To change | Where |
|---|---|
| The words on the walls | `TXT` at the top: the room sign, the guide, the placard rows (`title`, `title2`, `medium`, `artist`, `status`), `tape`, `sold`, `soldCaption`, `period`, `periodNote`, `figA`, `figB`, `after` (the placard after the sale), `cam`, `camTime`, `bidder`, `score` |
| The comment keyword | `KEYWORD` at the top, and the `text` and `say` of the last line in `plan.json`, plus the `keyword` event's `word` in `story.json`. `Placard` sizes it to fit (the word COMMENT is written there too), and `Captions` lights it in gilt |
| The painting | `PAINTING`, with `CHEESE`, `CRUST`, `PEP`, `SLICE_POSE`, `CARDBOARD`, `inSlice` and `Pepperoni`. Redrawing this is the biggest job. Keep the `p-oil` filter, the `p-ground` and `p-varnish` gradients, `brush` and `CRACKS`: they're what make any drawing read as oil |
| The X-ray | `XRAY` (the ghost of your picture), `Second` and `SECOND` (what's hidden, and its bite), and the two `Fig` rows in `XRayLayer`, with their `x`, `y`, `w` and `to` |
| The empty frame | `Empty`: the nail, the wire, the crumbs |
| The visitors | `VISITORS` and `REACHER`, each a path of event times and x positions. The reach and the hair are `rArm` in `Walkers`, and its angles and `REACHER`'s x are aimed at the tip of our slice, so re-aim them at your own |
| The seats and paddles | `SEATS`: `x`, `y`, `r`, `hat`, the paddle's number `n`, `at` (seconds into the voiceless moment), `px` and `py` (where the paddle goes), `score` and `sx` (the three that come back as scores) |
| The bidder | `BIDDER` for the seat, `BidderSeat` for the paddle and the napkin. Swap the napkin for the thing that gives your bidder away |
| The lobby monitor | `LobbyCam`, and the slice drawn inside it. Move 11's joke lives here, so redraw it |
| The lamps | `LAMPS`: each lamp's `on` and `off` event, `rest` and `back`. `spotK` and `aimAt` swing the middle lamp, and `lampAt` blooms it from `lower` to `pizza` |
| The night guard | `GUARD`, `guardX`, and `flashOn`, `flashX`, `flashY` for what the flashlight finds, keyed to `tonight`, `thrown`, `away` and `unless` |
| The placard | `Placard` for what it shows and when, `PLACARD` for where it hangs, `DOT` for where the gavel lands |
| The wall text | `WallText`, keyed to `polite` and `period` |
| The rope | `Rope`: swings on `wanted`, drops on `work` |
| The camera and the shake | `KEYS` for the camera, `HITS` for which events shake the room |
| The room | `ROOM` for the wall, floor, ceiling and signs, `FRAME_ART` for the gilt |

- **Scene ids in the code:** none. The frame only reads events by name, and the voiceless moment's `gavel` and `dot` events are offsets from its scene start in `story.json`, so a renumbered scene only touches that file.
- **Panels are sized to our words.** `Placard` is 380 px wide, and the title at 40 px and the medium at 28 px just fit. `tape` is fitted to its 424 px strip, the `Fig` widths to their words, and `periodNote` sits right after our short second row. Check each one against its panel. The paddle `Plate` holds two digits. `KEYWORD` shrinks by itself.
- **Keep every read inside the safe box when the camera pushes.** `zoom(1.62, 950, 1300)` keeps the placard's right edge at x 950 and the rope above the captions. Move the placard and that point moves with it.

### The code

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

```json
{
 "lines": [
  {
   "id": "l01",
   "text": "Welcome to room nine. Please lower your voice near the pizza."
  },
  {
   "id": "l02",
   "text": "Untitled. Last slice. Cold pepperoni on cardboard, artist unknown."
  },
  {
   "id": "l03",
   "text": "Visitors often ask why it's still here."
  },
  {
   "id": "l04",
   "text": "Everyone wanted it. Nobody would take it."
  },
  {
   "id": "l05",
   "text": "Scholars call this the politeness period."
  },
  {
   "id": "l06",
   "text": "Under X-ray, we find a second slice. Someone was here first."
  },
  {
   "id": "l07",
   "text": "The museum closes tonight. The work will be thrown away."
  },
  {
   "id": "l08",
   "text": "Unless someone bids."
  },
  {
   "id": "l09",
   "text": "I have one paddle. The other hand has a napkin."
  },
  {
   "id": "l11",
   "text": "The buyer ate it in the lobby. Critics call it his best work."
  },
  {
   "id": "l12",
   "text": "Comment GALLERY and I'll send you the skill that made this.",
   "say": "Comment, gallery, and I'll send you the skill that made this."
  }
 ],
 "scenes": [
  {
   "id": "s01",
   "lines": [
    "l01"
   ],
   "lead": 0.12,
   "tail": 0.1
  },
  {
   "id": "s02",
   "lines": [
    "l02"
   ],
   "lead": 0.1,
   "tail": 0.08
  },
  {
   "id": "s03",
   "lines": [
    "l03"
   ],
   "lead": 0.08,
   "tail": 0.08
  },
  {
   "id": "s04",
   "lines": [
    "l04"
   ],
   "lead": 0.08,
   "tail": 0.1
  },
  {
   "id": "s05",
   "lines": [
    "l05"
   ],
   "lead": 0.08,
   "tail": 0.08
  },
  {
   "id": "s06",
   "lines": [
    "l06"
   ],
   "lead": 0.1,
   "tail": 0.1
  },
  {
   "id": "s07",
   "lines": [
    "l07"
   ],
   "lead": 0.45,
   "tail": 0.12
  },
  {
   "id": "s08",
   "lines": [
    "l08"
   ],
   "lead": 0.35,
   "tail": 0.08
  },
  {
   "id": "s09",
   "lines": [
    "l09"
   ],
   "lead": 0.12,
   "tail": 0.1
  },
  {
   "id": "s10",
   "sec": 3.2
  },
  {
   "id": "s11",
   "lines": [
    "l11"
   ],
   "lead": 0.22,
   "tail": 0.1
  },
  {
   "id": "s12",
   "lines": [
    "l12"
   ],
   "lead": 0.1,
   "tail": 0.1
  }
 ]
}
```

`src/index.ts`:

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

`src/Root.tsx`:

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

export const Root: React.FC = () => (
  <Composition id="Film-9x16" component={Gallery} 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": "room",
   "scene": "s01",
   "word": "room"
  },
  {
   "id": "lower",
   "scene": "s01",
   "word": "lower"
  },
  {
   "id": "pizza",
   "scene": "s01",
   "word": "pizza"
  },
  {
   "id": "untitled",
   "scene": "s02",
   "word": "Untitled"
  },
  {
   "id": "last",
   "scene": "s02",
   "word": "Last"
  },
  {
   "id": "cold",
   "scene": "s02",
   "word": "Cold"
  },
  {
   "id": "artist",
   "scene": "s02",
   "word": "artist"
  },
  {
   "id": "visitors",
   "scene": "s03",
   "word": "Visitors"
  },
  {
   "id": "still",
   "scene": "s03",
   "word": "still"
  },
  {
   "id": "everyone",
   "scene": "s04",
   "word": "Everyone"
  },
  {
   "id": "wanted",
   "scene": "s04",
   "word": "wanted"
  },
  {
   "id": "nobody",
   "scene": "s04",
   "word": "Nobody"
  },
  {
   "id": "take",
   "scene": "s04",
   "word": "take"
  },
  {
   "id": "scholars",
   "scene": "s05",
   "word": "Scholars"
  },
  {
   "id": "polite",
   "scene": "s05",
   "word": "politeness"
  },
  {
   "id": "period",
   "scene": "s05",
   "word": "period"
  },
  {
   "id": "xray",
   "scene": "s06",
   "word": "Under"
  },
  {
   "id": "second",
   "scene": "s06",
   "word": "second"
  },
  {
   "id": "someone",
   "scene": "s06",
   "word": "Someone"
  },
  {
   "id": "first",
   "scene": "s06",
   "word": "first"
  },
  {
   "id": "low",
   "scene": "s07"
  },
  {
   "id": "museum",
   "scene": "s07",
   "word": "museum"
  },
  {
   "id": "closes",
   "scene": "s07",
   "word": "closes"
  },
  {
   "id": "tonight",
   "scene": "s07",
   "word": "tonight"
  },
  {
   "id": "work",
   "scene": "s07",
   "word": "work"
  },
  {
   "id": "thrown",
   "scene": "s07",
   "word": "thrown"
  },
  {
   "id": "away",
   "scene": "s07",
   "word": "away"
  },
  {
   "id": "unless",
   "scene": "s08",
   "word": "Unless"
  },
  {
   "id": "bidder",
   "scene": "s08",
   "word": "someone"
  },
  {
   "id": "bids",
   "scene": "s08",
   "word": "bids"
  },
  {
   "id": "paddle",
   "scene": "s09",
   "word": "paddle"
  },
  {
   "id": "other",
   "scene": "s09",
   "word": "other"
  },
  {
   "id": "napkin",
   "scene": "s09",
   "word": "napkin"
  },
  {
   "id": "quiet",
   "scene": "s10"
  },
  {
   "id": "gavel",
   "scene": "s10",
   "off": 1.75
  },
  {
   "id": "dot",
   "scene": "s10",
   "off": 1.85
  },
  {
   "id": "blink",
   "scene": "s11"
  },
  {
   "id": "buyer",
   "scene": "s11",
   "word": "buyer"
  },
  {
   "id": "ate",
   "scene": "s11",
   "word": "ate"
  },
  {
   "id": "lobby",
   "scene": "s11",
   "word": "lobby"
  },
  {
   "id": "critics",
   "scene": "s11",
   "word": "Critics"
  },
  {
   "id": "best",
   "scene": "s11",
   "word": "best"
  },
  {
   "id": "cta",
   "scene": "s12"
  },
  {
   "id": "keyword",
   "scene": "s12",
   "word": "GALLERY"
  }
 ]
}
```

`src/frames/Gallery.tsx`:

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

/* The Gallery: one room of a grand museum, one gilded painting, and an audio guide that takes it all very seriously. The camera never
   leaves the room. Everything lit comes from one light map, so a lamp going out really darkens what it was lighting. 1080 x 1920. Time
   comes from the spoken words through story.json, so a re-voice moves every lamp with its word. The painting is our own, drawn below. */
const E = makeE(story as StoryJson);

/* ---------- the words on the walls: a new story changes these first ---------- */
const KEYWORD = 'GALLERY';
const TXT = {
  room: 'ROOM 9', wing: 'Late Works', rules: 'No flash. No napkins.',
  guide: 'Audio guide', guideQuiet: 'No audio for this moment',
  title: 'Untitled', title2: ' (Last Slice)', medium: 'Cold pepperoni on cardboard', artist: 'Artist unknown', status: 'On view until closing',
  tape: 'TO BE THROWN AWAY', sold: 'Sold to paddle 1', soldCaption: 'SOLD',
  period: ['The Politeness', 'Period'], periodNote: 'Long looks. No action.',
  figA: 'Fig. 1: a second slice', figB: 'Fig. 2: tooth marks',
  after: ['Untitled (Lobby)', 'Performance, three bites', 'The buyer', 'Sold'],
  cam: 'LOBBY CAM 2', camTime: '9:14 PM', bidder: '1', score: '10',
};

const WALLC = '#24433a', GILT = '#b8913f', GILTHI = '#e8c874', LINEN = '#e9e1cf', LINER = '#cdc2a4', CARD = '#f5f2ea', RED = '#d0202a', INK = '#08110e', TEXT = '#1d1d1b', OAK = '#8a5a34';
const SERIF = '"Cormorant Garamond", serif', NARROW = '"Archivo Narrow", sans-serif';
const FACES = [
  { family: 'Cormorant Garamond', file: 'cormorant-garamond-latin-600-normal.woff2', weight: '600' },
  { family: 'Cormorant Garamond', file: 'cormorant-garamond-latin-600-italic.woff2', weight: '600', style: 'italic' },
  { family: 'Cormorant Garamond', file: 'cormorant-garamond-latin-700-normal.woff2', weight: '700' },
  { family: 'Archivo Narrow', file: 'archivo-narrow-latin-600-normal.woff2', weight: '600' },
  { family: 'Archivo Narrow', file: 'archivo-narrow-latin-700-normal.woff2', weight: '700' },
];

const AT0 = { position: 'absolute', left: 0, top: 0 } as const;
const OVER = { ...AT0, overflow: 'visible' } as const;

type P = [number, number];
type Angles = [number, number];
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
/** Walks a list of [time, value] keys in time order. */
const track = (keys: [number, number][], t: number) => {
  if (t <= keys[0][0]) return keys[0][1];
  for (let i = 1; i < keys.length; i++) if (t < keys[i][0]) return lerp(keys[i - 1][1], keys[i][1], (t - keys[i - 1][0]) / (keys[i][0] - keys[i - 1][0]));
  return keys[keys.length - 1][1];
};

/* Where things hang. The frame is the window; the placard and the left column sit under it, above the captions. */
const FRAME = { x: 100, y: 190, w: 880, h: 840, gilt: 78, liner: 26 };
const CANVAS = { x: FRAME.x + FRAME.gilt + FRAME.liner, y: FRAME.y + FRAME.gilt + FRAME.liner, w: FRAME.w - 2 * (FRAME.gilt + FRAME.liner), h: FRAME.h - 2 * (FRAME.gilt + FRAME.liner) };
const PLACARD = { x: 570, y: 1060, w: 380, h: 190 };
const DOT: P = [PLACARD.x + 330, PLACARD.y + 145];
const BIDDER = { x: 430, y: 1312, r: 46 };

/* ---------- the camera ---------- */
const HITS: [number, number][] = ([['gavel', 30], ['dot', 6]] as [string, number][]).map(([e, a]) => [E(e), a]);
const shake = (t: number) => HITS.reduce((acc, [e, a]) => (t >= e ? acc + a * Math.sin((t - e) * 58) * Math.exp(-(t - e) * 10) : acc), 0);
type View = { s: number; tx: number; ty: number };
/** Zoom by s around a point of the picture that stays put, so the room's edges stay off screen. */
const zoom = (s: number, fx: number, fy: number): View => ({ s, tx: fx * (1 - s), ty: fy * (1 - s) });
const KEYS: { at: number; d: number; v: View }[] = [
  { at: E('on'), d: 0.01, v: zoom(1.2, 540, 590) },
  { at: E('on'), d: 3.6, v: zoom(1.05, 540, 620) },
  { at: E('untitled') - 0.3, d: 0.8, v: zoom(1.62, 950, 1300) },
  { at: E('visitors') - 0.25, d: 0.9, v: zoom(1.02, 540, 900) },
  { at: E('wanted'), d: 0.9, v: zoom(1.16, 470, 940) },
  { at: E('scholars') - 0.25, d: 0.7, v: zoom(1.55, 60, 1190) },
  { at: E('xray') - 0.25, d: 0.7, v: zoom(1.1, 540, 480) },
  { at: E('low'), d: 2.6, v: zoom(1.015, 540, 900) },
  { at: E('paddle') - 0.7, d: 1.4, v: zoom(1.22, 380, 1190) },
  { at: E('quiet'), d: 0.3, v: zoom(1.012, 540, 1000) },
  { at: E('gavel') - 0.6, d: 0.5, v: zoom(1.5, 950, 1300) },
  { at: E('blink'), d: 0.01, v: zoom(1.03, 540, 700) },
  { at: E('cta') - 0.1, d: 0.9, v: zoom(1.62, 950, 1300) },
];
const camAt = (t: number): View => {
  let v = KEYS[0].v;
  for (const k of KEYS.slice(1)) {
    if (t < k.at) break;
    const q = easeInOut(ramp(t, k.at, k.at + k.d));
    v = { s: lerp(v.s, k.v.s, q), tx: lerp(v.tx, k.v.tx, q), ty: lerp(v.ty, k.v.ty, q) };
  }
  const s = Math.max(1.012, v.s + 0.006 * Math.sin(t * 0.37));
  const tx = clamp(v.tx + 7 * Math.sin(t * 0.43 + 1), 1080 - 1080 * s, 0);
  const ty = clamp(v.ty + 6 * Math.sin(t * 0.31 + 2), 1920 - 1920 * s, 0);
  const j = shake(t);
  return { s, tx: tx + j, ty: ty + j * 0.5 };
};

/* ---------- the lamps ---------- */
/* Five cans on the ceiling track. They go out one by one on the low point and sweep back on in the voiceless moment. `rest` is what a lamp keeps when "off". */
const LAMPS = [
  { x: 110, on: () => E('lower'), off: 'museum', rest: 0, back: 0, pool: [150, 860, 620, 800] },
  { x: 325, on: () => E('room'), off: 'tonight', rest: 0, back: 0.2, pool: [320, 1160, 500, 430] },
  { x: 540, on: () => 0, off: 'thrown', rest: 0.13, back: 0.4, pool: [540, 610, 660, 640] },
  { x: 755, on: () => E('untitled') - 0.3, off: 'work', rest: 0, back: 0.3, pool: [760, 1160, 470, 400] },
  { x: 970, on: () => E('lower'), off: 'closes', rest: 0, back: 0.1, pool: [930, 860, 620, 800] },
];
const flick = (t: number, t0: number, seed: number) => (t < t0 ? 0 : t > t0 + 0.2 ? 1 : hash(Math.floor(t * 30) + seed) > 0.45 ? 0.9 : 0.15);
const blink = (t: number) => (t >= E('blink') && t < E('blink') + 0.2 ? 0.1 : 1);
/** 0 is the middle lamp on the painting, 1 is the same lamp swung onto the bidder. */
const spotK = (t: number) => easeInOut(ramp(t, E('paddle') - 0.55, E('paddle') + 0.2)) * (1 - easeInOut(ramp(t, E('quiet') + 0.9, E('quiet') + 1.3)));
const lampAt = (i: number, t: number) => {
  const l = LAMPS[i];
  const lit = flick(t, l.on(), i + 1) * (1 - flick(t, E(l.off), i + 9) * (1 - l.rest));
  const again = flick(t, E('quiet') + l.back, i + 20);
  const glow = i === 2 ? 0.5 + 0.5 * ramp(t, E('lower'), E('pizza')) : 1;
  return Math.max(lit * glow, again, i === 2 ? spotK(t) : 0) * blink(t);
};
const aimAt = (t: number) => { const k = spotK(t); return { x: lerp(540, 440, k), y: lerp(610, 1130, k), rx: lerp(660, 400, k), ry: lerp(640, 440, k) }; };

/* The night guard walks through the low point with a flashlight. Its pool of light keeps the dark moving. */
const GUARD = { h: 700, foot: 1680, right: [76, 6] as Angles };
const guardX = (t: number) => track([[E('tonight') - 0.2, -170], [E('bids') + 1, 900]], t);
const flashOn = (t: number) => ramp(t, E('tonight') - 0.2, E('tonight')) * (1 - ramp(t, E('unless') - 0.05, E('unless') + 0.1));
const flashX = (t: number) => track([[E('tonight'), 230], [E('thrown') - 0.15, 760], [E('away') + 0.1, 760], [E('unless'), 470]], t);
const flashY = (t: number) => track([[E('tonight'), 1100], [E('thrown') - 0.15, 1165], [E('away') + 0.1, 1165], [E('unless'), 640]], t);
const paddleUp = (t: number) => easeOut(ramp(t, E('bidder'), E('bids') + 0.1));

const LightMap: React.FC = () => {
  const t = useT();
  const lamps = LAMPS.map((_, i) => lampAt(i, t));
  const avg = lamps.reduce((a, b) => a + b, 0) / lamps.length;
  const amb = 0.05 + 0.27 * avg;
  const aim = aimAt(t);
  const pools = LAMPS.map((l, i) => (i === 2 ? { ...aim, a: lamps[i] } : { x: l.pool[0], y: l.pool[1], rx: l.pool[2], ry: l.pool[3], a: lamps[i] * 0.86 }));
  pools.push({ x: 540, y: 1660, rx: 800, ry: 300, a: 0.5 * avg });
  pools.push({ x: 220, y: 130, rx: 320, ry: 110, a: 0.75 * lamps[0] });
  pools.push({ x: flashX(t), y: flashY(t), rx: 230, ry: 190, a: 0.85 * flashOn(t) });
  pools.push({ x: guardX(t) + 40, y: 1420, rx: 300, ry: 300, a: 0.34 * flashOn(t) });
  pools.push({ x: BIDDER.x + 96, y: 1090, rx: 190, ry: 190, a: 0.62 * paddleUp(t) * (t < E('quiet') + 1 ? 1 : 0) });
  return (
    <div style={{ position: 'absolute', left: -60, top: -60, width: 1200, height: 2040, mixBlendMode: 'multiply', background: `rgb(${Math.round(amb * 225)},${Math.round(amb * 238)},${Math.round(amb * 255)})` }}>
      {pools.map((p, i) => <div key={i} style={{ position: 'absolute', left: p.x - p.rx + 60, top: p.y - p.ry + 60, width: 2 * p.rx, height: 2 * p.ry, mixBlendMode: 'screen', background: `radial-gradient(closest-side, rgba(255,240,214,${p.a.toFixed(3)}) 0%, rgba(255,240,214,${(p.a * 0.72).toFixed(3)}) 45%, rgba(255,240,214,0) 100%)` }} />)}
    </div>
  );
};

/* What shines on top of the light map: soft beams, the lens of each can, the flashlight and the exit sign. */
/** A cone with soft edges: a conic gradient from the lamp, faded toward its far end. */
const Beam: React.FC<{ x0: number; y0: number; x1: number; y1: number; w: number; a: number; c?: string }> = ({ x0, y0, x1, y1, w, a, c = '255,238,205' }) => {
  if (a < 0.004) return null;
  const len = Math.hypot(x1 - x0, y1 - y0), dir = 180 - Math.atan2(x1 - x0, y1 - y0) * 57.3, sp = Math.atan2(w, len) * 57.3;
  const mask = `radial-gradient(circle at ${x0}px ${y0}px, #000 0, rgba(0,0,0,.55) ${len * 0.45}px, transparent ${len * 1.05}px)`;
  return <div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1920, background: `conic-gradient(from ${dir - sp * 1.5}deg at ${x0}px ${y0}px, rgba(${c},0) 0deg, rgba(${c},${a.toFixed(3)}) ${sp * 0.95}deg, rgba(${c},${a.toFixed(3)}) ${sp * 2.05}deg, rgba(${c},0) ${sp * 3}deg, rgba(${c},0) 360deg)`, WebkitMaskImage: mask, maskImage: mask }} />;
};
const LENS_Y = 52;
const Glow: React.FC = () => {
  const t = useT();
  const aim = aimAt(t), mid = lampAt(2, t), on = spotK(t) * mid;
  const g = arm(31, 56, GUARD.right, 56), gs = GUARD.h / 300;
  return (
    <>
      {LAMPS.map((l, i) => (i === 2 ? <Beam key={i} x0={l.x} y0={LENS_Y} x1={aim.x} y1={aim.y + aim.ry * 0.5} w={aim.rx * 0.62} a={mid * (0.17 + 0.1 * spotK(t))} /> : <Beam key={i} x0={l.x} y0={LENS_Y} x1={l.pool[0]} y1={l.pool[1]} w={l.pool[2] * 0.42} a={lampAt(i, t) * 0.07} />))}
      {on > 0.01 ? <div style={{ position: 'absolute', left: aim.x - aim.rx, top: aim.y - aim.ry, width: 2 * aim.rx, height: 2 * aim.ry, background: `radial-gradient(closest-side, rgba(255,236,200,${(0.22 * on).toFixed(3)}), rgba(255,236,200,0))` }} /> : null}
      {flashOn(t) > 0.01 ? <Beam x0={guardX(t) + g.hx * gs} y0={GUARD.foot - GUARD.h + g.hy * gs} x1={flashX(t)} y1={flashY(t)} w={150} a={flashOn(t) * 0.3} c="214,226,255" /> : null}
      <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={OVER}>
        <defs><radialGradient id="lens"><stop offset="0" stopColor="#fffbe8" /><stop offset="0.35" stopColor="#ffe6aa" stopOpacity="0.8" /><stop offset="1" stopColor="#ffd98a" stopOpacity="0" /></radialGradient></defs>
        {LAMPS.map((l, i) => <circle key={i} cx={l.x} cy={LENS_Y} r={44} fill="url(#lens)" opacity={lampAt(i, t)} />)}
      </svg>
    </>
  );
};
const ExitSign: React.FC = () => <div style={{ position: 'absolute', left: 852, top: 112, width: 150, height: 58, background: '#0d2a1c', borderRadius: 4, boxShadow: '0 0 34px 10px rgba(70,220,140,.2), inset 0 0 0 3px #123f2a', textAlign: 'center', font: `700 44px/58px ${NARROW}`, letterSpacing: '.08em', color: '#62e8a4', textShadow: '0 0 14px rgba(98,232,164,.8)' }}>EXIT</div>;

/* ---------- the painting: our own still life, drawn here. It is not after any real artwork or artist ---------- */
const CW = CANVAS.w, CH = CANVAS.h;
const CV = { viewBox: `0 0 ${CW} ${CH}`, width: CW, height: CH, style: AT0 };
const A: P = [100, 204], B: P = [518, 158], TIP: P = [296, 588];
const CHEESE = 'M98 210 C150 176 230 150 306 146 C390 142 460 146 520 160 C508 196 482 232 470 276 C452 318 424 346 408 392 C386 438 352 470 334 522 C322 552 306 574 296 592 C282 566 262 556 246 512 C222 470 196 452 176 402 C156 356 124 330 116 280 C110 256 100 236 98 210Z';
const CRUST = 'M78 212 C72 188 92 168 122 152 C150 132 186 124 214 112 C250 100 300 104 340 98 C390 92 440 102 490 108 C524 112 552 128 548 154 C544 176 518 172 492 164 C440 150 390 150 330 152 C270 152 210 166 166 190 C136 206 112 224 96 226 C86 226 80 222 78 212Z';
const PEP: [number, number, number][] = [[214, 262, 52], [392, 226, 50], [316, 352, 47], [290, 474, 31]];
const SLICE_POSE = 'rotate(-13 306 350) translate(34 26)';
const inSlice = (i: number): P => { const r = Math.sqrt(hash(i * 2 + 1) * 0.92 + 0.04), s = hash(i * 3 + 2); return [(1 - r) * TIP[0] + r * (1 - s) * A[0] + r * s * B[0], (1 - r) * TIP[1] + r * (1 - s) * A[1] + r * s * B[1]]; };
const anywhere = (i: number): P => [hash(i * 5 + 3) * CW, hash(i * 7 + 9) * CH];
const brush = (n: number, seed: number, at: (i: number) => P, len: number, ang: number) => Array.from({ length: n }, (_, i) => {
  const [x, y] = at(i + seed);
  const a = ang + (hash(i + seed + 5) - 0.5) * 1.1, l = len * (0.5 + hash(i + seed + 6));
  return `M${x.toFixed(1)} ${y.toFixed(1)}q${(Math.cos(a) * l * 0.5 + (hash(i + seed + 7) - 0.5) * 6).toFixed(1)} ${(Math.sin(a) * l * 0.5 + (hash(i + seed + 8) - 0.5) * 6).toFixed(1)} ${(Math.cos(a) * l).toFixed(1)} ${(Math.sin(a) * l).toFixed(1)}`;
}).join('');
const rough = (pts: P[], amp: number, seed: number) => `M${pts.map((p, i) => { const q = pts[(i + 1) % pts.length]; return Array.from({ length: 9 }, (_, k) => `${(lerp(p[0], q[0], k / 9) + (hash(seed + i * 9 + k) - 0.5) * amp).toFixed(1)} ${(lerp(p[1], q[1], k / 9) + (hash(seed + i * 7 + k + 50) - 0.5) * amp).toFixed(1)}`).join(' L'); }).join(' L')}Z`;
const CARDBOARD = rough([[58, 96], [612, 62], [640, 500], [24, 548]], 16, 7);
const CRACKS = Array.from({ length: 60 }, (_, i) => { let [x, y] = anywhere(i + 400); let d = `M${x.toFixed(1)} ${y.toFixed(1)}`; for (let k = 0; k < 6; k++) { x += (hash(i * 11 + k) - 0.5) * 54; y += (hash(i * 13 + k) - 0.5) * 54; d += `L${x.toFixed(1)} ${y.toFixed(1)}`; } return d; }).join('');

const Pepperoni: React.FC<{ x: number; y: number; r: number; i: number }> = ({ x, y, r, i }) => {
  const edge = rough(Array.from({ length: 7 }, (_, k): P => [x + Math.cos(k * 0.9) * r, y + Math.sin(k * 0.9) * r * 0.94]), r * 0.09, i * 31 + 5);
  return (
    <g>
      <path d={edge} transform="translate(5 9)" fill="#5e340c" opacity="0.7" />
      <path d={edge} fill="url(#p-pep)" stroke="#3f0c07" strokeWidth="5" strokeLinejoin="round" />
      {Array.from({ length: 22 }, (_, k) => { const a = hash(i * 20 + k) * 6.28, d = hash(i * 30 + k) * r * 0.84; return <ellipse key={k} cx={x + Math.cos(a) * d} cy={y + Math.sin(a) * d * 0.94} rx={1.6 + hash(i * 40 + k) * 4.4} ry={1.2 + hash(i * 41 + k) * 3} fill={k % 4 ? '#6e170d' : '#d9785a'} opacity="0.75" />; })}
      <path d={`M${x - r * 0.66} ${y - r * 0.38} C${x - r * 0.42} ${y - r * 0.74} ${x - r * 0.1} ${y - r * 0.8} ${x + r * 0.12} ${y - r * 0.74}`} fill="none" stroke="#f6c4a8" strokeWidth={r * 0.1} strokeLinecap="round" opacity="0.8" />
    </g>
  );
};

/* The oil: the whole picture is warped a little, then lit from the side through a bumpy surface, so it reads as thick paint under varnish. */
const PAINTING = (
  <svg {...CV}>
    <defs>
      <filter id="p-oil" x="0" y="0" width="100%" height="100%" colorInterpolationFilters="sRGB">
        <feTurbulence type="fractalNoise" baseFrequency="0.02 0.034" numOctaves="2" seed="4" result="warp" />
        <feDisplacementMap in="SourceGraphic" in2="warp" scale="13" xChannelSelector="R" yChannelSelector="G" result="wob" />
        <feTurbulence type="fractalNoise" baseFrequency="0.07 0.13" numOctaves="3" seed="9" result="bump" />
        <feDiffuseLighting in="bump" surfaceScale="2" diffuseConstant="1.1" lightingColor="#fff" result="lit"><feDistantLight azimuth="235" elevation="56" /></feDiffuseLighting>
        <feComposite in="wob" in2="lit" operator="arithmetic" k1="1.22" k2="0" k3="0" k4="0" />
      </filter>
      <radialGradient id="p-ground" cx="0.36" cy="0.34" r="0.86"><stop offset="0" stopColor="#4c3420" /><stop offset="0.45" stopColor="#21150b" /><stop offset="1" stopColor="#070403" /></radialGradient>
      <linearGradient id="p-board" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="#8b6c46" /><stop offset="0.5" stopColor="#5a4128" /><stop offset="1" stopColor="#2c1e11" /></linearGradient>
      <linearGradient id="p-cheese" x1="0.15" y1="0" x2="0.75" y2="1"><stop offset="0" stopColor="#f4c95c" /><stop offset="0.5" stopColor="#d6962e" /><stop offset="1" stopColor="#8c5216" /></linearGradient>
      <linearGradient id="p-crust" x1="0.1" y1="0" x2="0.5" y2="1"><stop offset="0" stopColor="#f0c07a" /><stop offset="0.45" stopColor="#bd7c34" /><stop offset="1" stopColor="#5e3412" /></linearGradient>
      <radialGradient id="p-pep" cx="0.36" cy="0.3" r="0.8"><stop offset="0" stopColor="#b73a22" /><stop offset="0.65" stopColor="#84210f" /><stop offset="1" stopColor="#4c0f07" /></radialGradient>
      <radialGradient id="p-grease"><stop offset="0" stopColor="#1e1007" stopOpacity="0.75" /><stop offset="1" stopColor="#1e1007" stopOpacity="0" /></radialGradient>
      <linearGradient id="p-glaze" x1="0.1" y1="0.1" x2="0.9" y2="0.95"><stop offset="0.35" stopColor="#0d0502" stopOpacity="0" /><stop offset="1" stopColor="#0d0502" stopOpacity="0.62" /></linearGradient>
      <radialGradient id="p-varnish" cx="0.42" cy="0.42" r="0.72"><stop offset="0.42" stopColor="#0c0602" stopOpacity="0" /><stop offset="1" stopColor="#050201" stopOpacity="0.93" /></radialGradient>
      <clipPath id="p-c-board"><path d={CARDBOARD} /></clipPath>
      <clipPath id="p-c-cheese"><path d={CHEESE} /></clipPath>
    </defs>
    <g filter="url(#p-oil)">
      <rect width={CW} height={CH} fill="url(#p-ground)" />
      <path d={brush(240, 11, anywhere, 60, 0.5)} fill="none" stroke="#0c0603" strokeWidth="13" strokeLinecap="round" opacity="0.3" />
      <path d={brush(200, 511, anywhere, 52, 0.4)} fill="none" stroke="#6a4a2a" strokeWidth="9" strokeLinecap="round" opacity="0.14" />
      {/* an oxblood drape with a gold hem, because every old still life has one */}
      <path d="M672 0 L300 0 C360 44 440 60 508 50 C556 104 612 136 672 150Z" fill="#561813" />
      {[[372, 22, 548, 116], [462, 12, 612, 122], [548, 8, 664, 92]].map(([x0, y0, x1, y1], i) => <path key={i} d={`M${x0} ${y0} C${x0 + 44} ${y0 + 40} ${x1 - 44} ${y1 - 24} ${x1} ${y1}`} fill="none" stroke={i % 2 ? '#8d2c22' : '#1f0604'} strokeWidth={i % 2 ? 10 : 18} strokeLinecap="round" opacity="0.8" />)}
      <path d="M300 0 C360 44 440 60 508 50 C556 104 612 136 672 150" fill="none" stroke="#c49a42" strokeWidth="6" opacity="0.8" />
      {/* the cardboard, with its torn edge showing ribs */}
      <path d={CARDBOARD} transform="translate(9 16)" fill="#030201" opacity="0.72" />
      <path d={CARDBOARD} fill="url(#p-board)" />
      <g clipPath="url(#p-c-board)">
        {Array.from({ length: 30 }, (_, i) => <line key={i} x1={i * 25} y1="30" x2={i * 25 - 60} y2="600" stroke="#21140a" strokeWidth="3" opacity="0.26" />)}
        <path d={brush(150, 811, anywhere, 46, 1.4)} fill="none" stroke="#b3936b" strokeWidth="5" strokeLinecap="round" opacity="0.2" />
        <ellipse cx="130" cy="400" rx="140" ry="120" fill="url(#p-grease)" /><ellipse cx="520" cy="350" rx="170" ry="130" fill="url(#p-grease)" /><ellipse cx="340" cy="100" rx="270" ry="80" fill="url(#p-grease)" /><ellipse cx="370" cy="540" rx="140" ry="70" fill="url(#p-grease)" />
        {Array.from({ length: 44 }, (_, i) => <path key={i} d={`M${604 + (i * 0.64)} ${70 + i * 10} q14 5 26 0`} fill="none" stroke="#c9a877" strokeWidth="3" opacity="0.5" />)}
      </g>
      {/* flakes, spilled the way an old master spills salt */}
      <path d={brush(52, 1200, (i) => [120 + hash(i) * 420, 500 + hash(i + 300) * 100], 12, 0.3)} fill="none" stroke="#a02a16" strokeWidth="4" strokeLinecap="round" />
      <g transform={SLICE_POSE}>
        <path d={CHEESE} transform="translate(6 10)" fill="#080402" opacity="0.5" />
        <path d={CRUST} transform="translate(5 9)" fill="#080402" opacity="0.5" />
        <path d={CHEESE} fill="url(#p-cheese)" />
        <g clipPath="url(#p-c-cheese)">
          {Array.from({ length: 40 }, (_, i) => { const p = inSlice(i + 20); return <circle key={i} cx={p[0]} cy={p[1]} r={7 + hash(i + 70) * 20} fill="#94521a" opacity={0.2 + hash(i + 90) * 0.3} />; })}
          {Array.from({ length: 30 }, (_, i) => { const p = inSlice(i + 120); return <ellipse key={i} cx={p[0]} cy={p[1]} rx={6 + hash(i + 170) * 16} ry={4 + hash(i + 190) * 8} fill="#ffe99a" opacity={0.28 + hash(i + 210) * 0.42} transform={`rotate(${hash(i + 230) * 80} ${p[0]} ${p[1]})`} />; })}
          <path d={brush(190, 1500, inSlice, 38, 1.2)} fill="none" stroke="#fbdc7c" strokeWidth="5" strokeLinecap="round" opacity="0.32" />
          <path d={brush(170, 1900, inSlice, 34, 0.9)} fill="none" stroke="#935615" strokeWidth="5" strokeLinecap="round" opacity="0.3" />
          <path d="M92 226 C200 160 330 150 524 172" fill="none" stroke="#7e2412" strokeWidth="20" opacity="0.72" />
        </g>
        {PEP.map(([x, y, r], i) => <Pepperoni key={i} x={x} y={y} r={r} i={i} />)}
        <path d={CRUST} fill="url(#p-crust)" stroke="#3f220c" strokeWidth="4" />
        <path d="M100 196 C150 150 214 128 300 118 C380 112 470 116 526 136" fill="none" stroke="#f9e0ac" strokeWidth="8" strokeLinecap="round" opacity="0.6" />
        {Array.from({ length: 26 }, (_, i) => { const u = hash(i + 700), x = 100 + u * 430, y = 196 - 76 * Math.sin(u * 3.14) * 0.9 + (hash(i + 720) - 0.5) * 34; return <ellipse key={i} cx={x} cy={y} rx={4 + hash(i + 740) * 13} ry={3 + hash(i + 760) * 6} fill="#2a1508" opacity={0.34 + hash(i + 780) * 0.5} />; })}
        {/* cold cheese, setting as it slides off the edge */}
        <path d="M278 548 C282 590 286 626 296 636 C308 630 312 592 318 540Z" fill="#b5782a" />
        <path d={CHEESE} fill="url(#p-glaze)" />
      </g>
      <path d={brush(130, 2300, anywhere, 30, 0.8)} fill="none" stroke="#000" strokeWidth="3" strokeLinecap="round" opacity="0.1" />
    </g>
    <path d={CRACKS} fill="none" stroke="#0a0502" strokeWidth="0.8" opacity="0.26" />
    <rect width={CW} height={CH} fill="url(#p-varnish)" />
  </svg>
);

/* ---------- the X-ray: the same shapes, plus the slice under the paint ---------- */
const XRAY = (
  <svg {...CV}>
    <defs><radialGradient id="x-g" cx="0.5" cy="0.45" r="0.8"><stop offset="0" stopColor="#23405a" /><stop offset="1" stopColor="#060e18" /></radialGradient></defs>
    <rect width={CW} height={CH} fill="url(#x-g)" />
    <path d={`M0 ${CH / 2}H${CW}M${CW / 2} 0V${CH}`} stroke="#86a8c6" strokeWidth="26" opacity="0.1" />
    {Array.from({ length: 34 }, (_, i) => { const s = i % 2, u = (i + 0.5) / 34; return <circle key={i} cx={s ? u * CW : i % 4 < 2 ? 12 : CW - 12} cy={s ? (i % 4 < 2 ? 12 : CH - 12) : u * CH} r="4.6" fill="#e6f4ff" opacity="0.8" />; })}
    <path d={CARDBOARD} fill="#a8cbe6" opacity="0.08" />
    <g transform={SLICE_POSE}>
      <path d={CHEESE} fill="#cfe6fa" opacity="0.13" stroke="#e8f6ff" strokeWidth="3" />
      <path d={CRUST} fill="#dcefff" opacity="0.2" />
      {PEP.map(([x, y, r], i) => <ellipse key={i} cx={x} cy={y} rx={r} ry={r * 0.95} fill="#e8f6ff" opacity="0.2" stroke="#f4fbff" strokeWidth="2.5" />)}
    </g>
  </svg>
);
/* The first slice, under the paint: same shape, turned and shifted, with a bite out of its tip. */
const SECOND = 'translate(376 300) rotate(118) scale(0.72) translate(-306 -350)';
const Second: React.FC<{ k: number; bite: number }> = ({ k, bite }) => (
  <svg {...CV} style={{ ...AT0, opacity: k }}>
    <defs><mask id="x-bite" maskUnits="userSpaceOnUse" x="-300" y="-300" width="1300" height="1300"><rect x="-300" y="-300" width="1300" height="1300" fill="#fff" />{[[262, 548, 46], [318, 520, 50], [372, 462, 44]].map(([x, y, r], i) => <circle key={i} cx={x} cy={y} r={r * bite} fill="#000" />)}</mask></defs>
    <g transform={SECOND} mask="url(#x-bite)">
      <path d={CHEESE} fill="#f2fbff" opacity="0.5" stroke="#ffffff" strokeWidth="4" />
      <path d={CRUST} fill="#ffffff" opacity="0.62" />
      {PEP.slice(0, 3).map(([x, y, r], i) => <ellipse key={i} cx={x} cy={y} rx={r} ry={r * 0.95} fill="#ffffff" opacity="0.62" />)}
    </g>
  </svg>
);
/** A curator's label: a strip of text and a line to what it points at. */
const Fig: React.FC<{ at: number; x: number; y: number; w: number; text: string; to: P }> = ({ at, x, y, w, text, to }) => {
  const t = useT();
  if (t < at) return null;
  const k = easeOut(ramp(t, at, at + 0.25)), tx = lerp(x + w / 2, to[0], k), ty = lerp(y, to[1], k);
  return (
    <>
      <svg {...CV}><line x1={x + w / 2} y1={y} x2={tx} y2={ty} stroke="#ffe58a" strokeWidth="4" /><circle cx={tx} cy={ty} r="8" fill="#ffe58a" /></svg>
      <div style={{ position: 'absolute', left: x, top: y - 44, width: w, height: 48, background: '#ffe58a', color: '#0b1a26', textAlign: 'center', font: `700 34px/48px ${NARROW}`, whiteSpace: 'nowrap', clipPath: `inset(0 ${(1 - k) * 100}% 0 0)` }}>{text}</div>
    </>
  );
};
const XRayLayer: React.FC = () => {
  const t = useT();
  const open = easeInOut(ramp(t, E('xray') - 0.05, E('xray') + 0.85));
  const close = easeInOut(ramp(t, E('low') - 0.2, E('low') + 0.35));
  if (open <= 0 || close >= 1) return null;
  return (
    <>
      <div style={{ position: 'absolute', inset: 0, clipPath: `inset(0 ${(1 - open) * 100}% 0 ${close * 100}%)`, overflow: 'hidden' }}>
        {XRAY}
        <Second k={ramp(t, E('xray') + 0.4, E('xray') + 0.9)} bite={ramp(t, E('someone') - 0.05, E('someone') + 0.15)} />
        <Fig at={E('second')} x={250} y={84} w={374} text={TXT.figA} to={[452, 214]} />
        <Fig at={E('first')} x={30} y={600} w={290} text={TXT.figB} to={[318, 402]} />
      </div>
      {open < 1 || close > 0 ? <div style={{ position: 'absolute', left: (open < 1 ? open : close) * CW - 10, top: 0, width: 20, height: CH, background: 'linear-gradient(90deg,transparent,#c8ecff,#ffffff,#c8ecff,transparent)', boxShadow: '0 0 40px 14px rgba(150,220,255,.45)' }} /> : null}
    </>
  );
};

/** The empty frame: the wall behind it, a nail, a swinging wire, crumbs still falling. */
const Empty: React.FC = () => {
  const t = useT();
  const u = t - E('blink');
  return (
    <>
      <div style={{ position: 'absolute', inset: 0, background: '#2b5046', boxShadow: 'inset 0 18px 30px rgba(0,0,0,.5), inset 0 0 0 2px #1a332b' }} />
      <svg {...CV} style={OVER}>
        <circle cx={CW / 2} cy={70} r="8" fill="#14110c" /><circle cx={CW / 2 - 2} cy={68} r="3" fill="#8f8a7c" />
        <g transform={`rotate(${10 * Math.sin(u * 5.2) * Math.exp(-u * 0.5)} ${CW / 2} 70)`}><path d={`M${CW / 2} 70 L${CW / 2 - 90} 190 M${CW / 2} 70 L${CW / 2 + 90} 190`} stroke="#c4bfae" strokeWidth="2.5" fill="none" /></g>
        {Array.from({ length: 9 }, (_, i) => { const q = clamp((u - hash(i + 900) * 0.8) / 0.8); return <circle key={i} cx={180 + hash(i + 920) * 320 + Math.sin(q * 9 + i) * 8} cy={lerp(360 + hash(i + 940) * 120, CH - 4, easeIn(q))} r={2.4 + hash(i + 960) * 3.6} fill="#b98a4a" />; })}
      </svg>
    </>
  );
};

const Canvas: React.FC = () => {
  const t = useT();
  return (
    <div style={{ position: 'absolute', left: CANVAS.x, top: CANVAS.y, width: CW, height: CH, overflow: 'hidden', background: '#0c0705' }}>
      {t >= E('blink') ? <Empty /> : (
        <>
          {PAINTING}
          <div style={{ position: 'absolute', inset: 0, background: 'repeating-linear-gradient(45deg,rgba(255,255,255,.035) 0 2px,rgba(0,0,0,.05) 2px 4px)' }} />
          <div style={{ position: 'absolute', inset: 0, background: `radial-gradient(ellipse 34% 24% at ${30 + 16 * Math.sin(t * 0.7)}% 34%, rgba(255,248,220,.16), rgba(255,248,220,0))` }} />
          <XRayLayer />
        </>
      )}
    </div>
  );
};

/* ---------- the gilded frame ---------- */
const GILTS: [string, string][] = [['0', '#6e4f17'], ['0.18', '#e8c874'], ['0.36', '#a8822f'], ['0.56', '#f3dc92'], ['0.78', '#9a7426'], ['1', '#5e4210']];
const FW = FRAME.w, FH = FRAME.h;
const band = (a: number, b: number): [string, string][] => [
  [`${a},${a} ${FW - a},${a} ${FW - b},${b} ${b},${b}`, 'g-t'], [`${FW - a},${a} ${FW - a},${FH - a} ${FW - b},${FH - b} ${FW - b},${b}`, 'g-r'],
  [`${FW - a},${FH - a} ${a},${FH - a} ${b},${FH - b} ${FW - b},${FH - b}`, 'g-b'], [`${a},${FH - a} ${a},${a} ${b},${b} ${b},${FH - b}`, 'g-l'],
];
const LEAF = <><path d="M0 0 C6 -16 24 -28 48 -24 C36 -14 28 -2 26 12 C16 8 6 6 0 0Z" fill="url(#g-leaf)" stroke="#3f2d0b" strokeWidth="1.3" /><path d="M6 -2 C14 -10 26 -16 38 -20" fill="none" stroke="#fff0b8" strokeWidth="1.4" opacity="0.75" /></>;
const Fan: React.FC<{ n: number; from: number; to: number; s: number }> = ({ n, from, to, s }) => (
  <>
    {Array.from({ length: n }, (_, i) => { const k = i / (n - 1); return <g key={i} transform={`rotate(${lerp(from, to, k)}) scale(${s * (0.82 + 0.5 * Math.sin(k * 3.14))})`}>{LEAF}</g>; })}
    <circle r={8 * s} fill="url(#g-bead)" stroke="#3f2d0b" strokeWidth="1.2" />
  </>
);
/** Leaves trailing along a molding from its corner, large to small. */
const SPRAY = <>{[0, 1, 2, 3, 4].map((i) => <g key={i} transform={`translate(${6 + i * 25} ${-3 + i * 1.5}) rotate(${-8 + i * 3}) scale(${1.05 - i * 0.14})`}>{LEAF}</g>)}</>;
const beads = (inset: number, step: number) => { const out: P[] = []; for (let x = inset + step; x < FW - inset; x += step) out.push([x, inset], [x, FH - inset]); for (let y = inset + step; y < FH - inset; y += step) out.push([inset, y], [FW - inset, y]); return out; };
const FRAME_ART = (
  <svg viewBox={`0 0 ${FW} ${FH}`} width={FW} height={FH} style={{ position: 'absolute', left: FRAME.x, top: FRAME.y, overflow: 'visible' }}>
    <defs>
      {([['g-t', 0, 0, 0, 1], ['g-b', 0, 1, 0, 0], ['g-l', 0, 0, 1, 0], ['g-r', 1, 0, 0, 0]] as [string, number, number, number, number][]).map(([id, x1, y1, x2, y2]) => <linearGradient key={id} id={id} x1={x1} y1={y1} x2={x2} y2={y2}>{GILTS.map(([o, c]) => <stop key={o} offset={o} stopColor={c} />)}</linearGradient>)}
      <linearGradient id="g-leaf" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="#f6e2a0" /><stop offset="0.5" stopColor="#c89e45" /><stop offset="1" stopColor="#6e4f17" /></linearGradient>
      <radialGradient id="g-bead" cx="0.35" cy="0.3" r="0.8"><stop offset="0" stopColor="#fff0b8" /><stop offset="0.55" stopColor="#b88f38" /><stop offset="1" stopColor="#57400f" /></radialGradient>
    </defs>
    {band(0, 40).map(([p, g], i) => <polygon key={i} points={p} fill={`url(#${g})`} />)}
    {band(40, 54).map(([p], i) => <polygon key={i} points={p} fill="#4d380e" />)}
    {band(54, 78).map(([p, g], i) => <polygon key={i} points={p} fill={`url(#${g})`} />)}
    {/* the two sides away from the light, the miters, the beads */}
    {band(0, 78).map(([p], i) => (i === 1 || i === 2 ? <polygon key={i} points={p} fill="#1a0f02" opacity="0.2" /> : null))}
    {[[0, 0, 40, 40], [FW, 0, FW - 40, 40], [FW, FH, FW - 40, FH - 40], [0, FH, 40, FH - 40], [54, 54, 78, 78], [FW - 54, 54, FW - 78, 78], [FW - 54, FH - 54, FW - 78, FH - 78], [54, FH - 54, 78, FH - 78]].map(([a, b, c, d], i) => <line key={i} x1={a} y1={b} x2={c} y2={d} stroke="#3f2d0b" strokeWidth="1.4" opacity="0.8" />)}
    {beads(47, 15).map(([x, y], i) => <circle key={i} cx={x} cy={y} r="5.4" fill="url(#g-bead)" />)}
    {/* carved corners and crests: one spray and one fan of leaves, mirrored */}
    {[[34, 34, 1, 1], [FW - 34, 34, -1, 1], [FW - 34, FH - 34, -1, -1], [34, FH - 34, 1, -1]].map(([x, y, sx, sy], i) => <g key={i} transform={`translate(${x} ${y}) scale(${sx} ${sy})`}>{SPRAY}<g transform="rotate(90) scale(1 -1)">{SPRAY}</g><g transform="rotate(45)"><Fan n={5} from={-70} to={-20} s={1.05} /></g><g transform="rotate(45) scale(1 -1)"><Fan n={5} from={-70} to={-20} s={1.05} /></g></g>)}
    <g transform={`translate(${FW / 2} 22)`}><Fan n={9} from={-172} to={-8} s={1.25} /></g>
    <g transform={`translate(${FW / 2} ${FH - 22}) scale(1 -1)`}><Fan n={9} from={-172} to={-8} s={1.1} /></g>
    {[[22, FH / 2, 90], [FW - 22, FH / 2, -90]].map(([x, y, r], i) => <g key={i} transform={`translate(${x} ${y}) rotate(${r})`}><Fan n={7} from={-160} to={-20} s={0.8} /></g>)}
  </svg>
);
const RING = `M0 0H${FW}V${FH}H0Z M${FRAME.gilt} ${FRAME.gilt}V${FH - FRAME.gilt}H${FW - FRAME.gilt}V${FRAME.gilt}Z`;
const HungFrame: React.FC = () => {
  const t = useT();
  const sx = 200 + 420 * Math.sin(t * 0.55);
  return (
    <>
      <div style={{ position: 'absolute', left: FRAME.x + 6, top: FRAME.y + 10, width: FW - 12, height: FH - 10, boxShadow: '0 44px 70px rgba(0,0,0,.62), 0 10px 18px rgba(0,0,0,.5)' }} />
      <div style={{ position: 'absolute', left: FRAME.x + FRAME.gilt, top: FRAME.y + FRAME.gilt, width: FW - 2 * FRAME.gilt, height: FH - 2 * FRAME.gilt, background: LINER, boxShadow: 'inset 0 0 0 2px #a8996f, inset 0 12px 20px rgba(50,35,10,.55)' }} />
      <Canvas />
      <div style={{ position: 'absolute', left: CANVAS.x, top: CANVAS.y, width: CW, height: CH, boxShadow: 'inset 0 0 0 2px #8a7a54, inset 0 8px 16px rgba(0,0,0,.5)' }} />
      {FRAME_ART}
      <svg viewBox={`0 0 ${FW} ${FH}`} width={FW} height={FH} style={{ position: 'absolute', left: FRAME.x, top: FRAME.y }}>
        <defs><linearGradient id="g-sheen" gradientUnits="userSpaceOnUse" x1={sx} y1="0" x2={sx + 340} y2="340"><stop offset="0" stopColor="#fff8d8" stopOpacity="0" /><stop offset="0.5" stopColor="#fff8d8" stopOpacity="0.42" /><stop offset="1" stopColor="#fff8d8" stopOpacity="0" /></linearGradient><clipPath id="g-ring"><path d={RING} clipRule="evenodd" /></clipPath></defs>
        <rect width={FW} height={FH} fill="url(#g-sheen)" clipPath="url(#g-ring)" />
      </svg>
    </>
  );
};

/* ---------- the room ---------- */
const vp = (xb: number, y: number) => 540 + (xb - 540) * ((y - 1060) / (1920 - 1060));
const ROOM = (
  <>
    <div style={{ position: 'absolute', left: -40, top: 0, width: 1160, height: 1500, background: WALLC }} />
    <svg viewBox="0 0 1160 2000" width={1160} height={2000} style={{ position: 'absolute', left: -40, top: -40 }}>
      <defs>
        {/* a damask wall covering: one motif, repeated */}
        <pattern id="damask" width="200" height="300" patternUnits="userSpaceOnUse">
          {[[100, 150], [0, 0], [200, 0], [0, 300], [200, 300]].map(([x, y], i) => (
            <g key={i} transform={`translate(${x} ${y})`} fill="#2f5a4d">
              <path d="M0 -130 C34 -86 76 -50 76 0 C76 50 34 86 0 130 C-34 86 -76 50 -76 0 C-76 -50 -34 -86 0 -130Z" fill="none" stroke="#2f5a4d" strokeWidth="5" />
              {[0, 45, 90, 135, 180, 225, 270, 315].map((a) => <path key={a} d="M0 0 C8 -12 10 -32 0 -52 C-10 -32 -8 -12 0 0Z" transform={`rotate(${a})`} />)}
              <circle r="7" fill={WALLC} />
            </g>
          ))}
        </pattern>
        <linearGradient id="oak" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#5e3a1f" /><stop offset="1" stopColor={OAK} /></linearGradient>
      </defs>
      <rect x="0" y="40" width="1160" height="1500" fill="url(#damask)" opacity="0.8" />
      <rect x="0" y="1570" width="1160" height="430" fill="url(#oak)" />
      {Array.from({ length: 34 }, (_, i) => { const a = -1560 + i * 124, b = a + 124; return <polygon key={i} points={`${vp(a, 1530) + 40},1570 ${vp(b, 1530) + 40},1570 ${b + 40},2000 ${a + 40},2000`} fill={['#7a4e2c', '#93623a', '#6e4526', '#855631'][i % 4]} opacity="0.55" stroke="#3a2212" strokeWidth="2" />; })}
      <rect x="0" y="1530" width="1160" height="44" fill="#16261f" /><rect x="0" y="1530" width="1160" height="5" fill="#3f6455" />
      {/* ceiling, cornice, the lamp track and the picture wires */}
      <rect x="0" y="0" width="1160" height="110" fill="#d7cfbb" />
      <rect x="0" y="110" width="1160" height="10" fill="#b2a88f" /><rect x="0" y="120" width="1160" height="7" fill={GILT} /><rect x="0" y="127" width="1160" height="5" fill="#16261f" opacity="0.7" />
      <rect x="0" y="56" width="1160" height="10" fill="#17140f" />
      {LAMPS.map((l) => <g key={l.x} transform={`translate(${l.x + 40} 92)`}><rect x="-26" y="-22" width="52" height="42" rx="7" fill="#1f1c16" /><rect x="-20" y="16" width="40" height="8" fill="#3b362b" /></g>)}
      <path d="M300 132 L330 232 M860 132 L830 232" stroke="#1c2a25" strokeWidth="3" />
    </svg>
    <div style={{ position: 'absolute', left: 64, top: 100, font: `700 52px/58px ${SERIF}`, color: GILTHI, letterSpacing: '.03em', whiteSpace: 'nowrap' }}>{TXT.room}</div>
    <div style={{ position: 'absolute', left: 284, top: 104, font: `600 21px/25px ${NARROW}`, color: LINEN, whiteSpace: 'nowrap', opacity: 0.86 }}>{TXT.wing}<br />{TXT.rules}</div>
  </>
);

/* A brass post at each edge and a velvet rope. It swings when somebody reaches over it, and drops at closing. */
const Rope: React.FC = () => {
  const t = useT();
  const at = E('work') + 0.25, k = easeIn(ramp(t, at, at + 0.5)), u = t - at - 0.5;
  const ex = lerp(1044, 790, k), ey = lerp(1262, 1652, k) - (u > 0 ? 34 * Math.abs(Math.sin(u * 8)) * Math.exp(-u * 4) : 0);
  const sway = (t >= E('wanted') ? 26 * Math.sin((t - E('wanted')) * 6) * Math.exp(-(t - E('wanted')) * 0.9) : 0) + 3 * Math.sin(t * 1.1);
  const d = `M36 1262 Q${(36 + ex) / 2} ${(1262 + ey) / 2 + lerp(62, 210, k) + sway} ${ex} ${ey}`;
  return (
    <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={OVER}>
      <defs><linearGradient id="brass" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stopColor="#7a5a1e" /><stop offset="0.4" stopColor="#f3dc92" /><stop offset="1" stopColor="#6e4f17" /></linearGradient></defs>
      {[36, 1044].map((x) => <g key={x}><ellipse cx={x} cy={1652} rx="58" ry="17" fill="url(#brass)" /><rect x={x - 10} y={1248} width="20" height="404" fill="url(#brass)" /><circle cx={x} cy={1240} r="21" fill="url(#brass)" /></g>)}
      <path d={d} transform="translate(4 10)" fill="none" stroke="#020605" strokeWidth="22" strokeLinecap="round" opacity="0.4" />
      <path d={d} fill="none" stroke="#7c1019" strokeWidth="22" strokeLinecap="round" />
      <path d={d} transform="translate(0 -5)" fill="none" stroke="#b92c38" strokeWidth="5" strokeLinecap="round" opacity="0.85" />
      <circle cx={ex} cy={ey} r="12" fill="url(#brass)" />
    </svg>
  );
};

/* ---------- people: silhouettes only, seen from behind ---------- */
type Look = { wide?: number; hat?: 'brim' | 'beret' | 'bun' | 'bob' | 'cap'; coat?: boolean; tote?: boolean };
const R = Math.PI / 180;
/** A two-part limb from a joint: `a` is the angle from straight down, `b` bends the lower part. */
function arm(sx: number, sy: number, [a, b]: Angles, l = 54) {
  const ex = sx + Math.sin(a * R) * l, ey = sy + Math.cos(a * R) * l;
  const hx = ex + Math.sin((a + b) * R) * l, hy = ey + Math.cos((a + b) * R) * l;
  return { hx, hy, up: `M${sx} ${sy}L${ex.toFixed(1)} ${ey.toFixed(1)}`, lo: `M${ex.toFixed(1)} ${ey.toFixed(1)}L${hx.toFixed(1)} ${hy.toFixed(1)}` };
}
const Hair: React.FC<{ hat?: Look['hat']; c: number; r: number }> = ({ hat, c, r }) => (
  <>
    <circle cx="0" cy={c} r={r} fill={INK} />
    {hat === 'brim' ? <><ellipse cx="0" cy={c - r * 0.42} rx={r * 1.55} ry={r * 0.2} fill={INK} /><path d={`M${-r * 0.86} ${c - r * 0.42} L${-r * 0.74} ${c - r * 1.42} Q0 ${c - r * 1.2} ${r * 0.74} ${c - r * 1.42} L${r * 0.86} ${c - r * 0.42}Z`} fill={INK} /></> : null}
    {hat === 'beret' ? <ellipse cx={r * 0.22} cy={c - r * 0.82} rx={r * 1.25} ry={r * 0.46} fill={INK} transform={`rotate(-12 0 ${c})`} /> : null}
    {hat === 'bun' ? <circle cx="0" cy={c - r * 1.22} r={r * 0.52} fill={INK} /> : null}
    {hat === 'bob' ? <path d={`M${-r * 1.18} ${c - r * 0.3} Q0 ${c - r * 1.75} ${r * 1.18} ${c - r * 0.3} L${r * 1.28} ${c + r * 0.98} L${-r * 1.28} ${c + r * 0.98}Z`} fill={INK} /> : null}
    {hat === 'cap' ? <><path d={`M${-r} ${c - r * 0.3} Q0 ${c - r * 1.5} ${r} ${c - r * 0.3}Z`} fill={INK} /><rect x={-r * 1.1} y={c - r * 0.42} width={r * 2.2} height={r * 0.2} fill={INK} /></> : null}
  </>
);
const Figure: React.FC<{ x: number; foot: number; h: number; look: Look; stride: number; right: Angles; left: Angles; lean?: number; fingers?: number }> = ({ x, foot, h, look, stride, right, left, lean = 0, fingers = 0 }) => {
  const s = h / 300, w = look.wide ?? 1;
  const legs = [1, -1].map((d) => arm(d * 13, 150, [d * stride, Math.max(0, d * stride) * -0.7], 70));
  const arms = [arm(31 * w, 56, right, 56), arm(-31 * w, 56, left, 56)];
  return (
    <svg viewBox="-110 0 220 300" width={220 * s} height={h} style={{ position: 'absolute', left: x - 110 * s, top: foot - h, overflow: 'visible', transform: `rotate(${lean}deg)`, transformOrigin: '50% 100%' }}>
      <ellipse cx="0" cy="296" rx={70 * w} ry="9" fill="#020605" opacity="0.5" />
      <g fill="none" stroke={INK} strokeLinecap="round" strokeLinejoin="round">
        {legs.map((l, i) => <React.Fragment key={i}><path d={l.up} strokeWidth="27" /><path d={l.lo} strokeWidth="17" /><path d={`M${l.hx.toFixed(1)} ${l.hy.toFixed(1)}h${i ? -9 : 9}`} strokeWidth="12" /></React.Fragment>)}
        {arms.map((a, i) => <React.Fragment key={i}><path d={a.up} strokeWidth="17" /><path d={a.lo} strokeWidth="12" /><circle cx={a.hx} cy={a.hy} r="7.5" fill={INK} stroke="none" /></React.Fragment>)}
        {fingers > 0.3 ? [-34, -12, 10, 32].map((d) => <path key={d} d={`M${arms[0].hx.toFixed(1)} ${arms[0].hy.toFixed(1)}l${(Math.sin((right[0] + right[1] + d) * R) * 15).toFixed(1)} ${(Math.cos((right[0] + right[1] + d) * R) * 15).toFixed(1)}`} strokeWidth="4" />) : null}
      </g>
      <path d={look.coat ? `M${-33 * w} 54 C${-18 * w} 44 ${18 * w} 44 ${33 * w} 54 L${26 * w} 116 L${37 * w} 214 L${-37 * w} 214 L${-26 * w} 116Z` : `M${-33 * w} 54 C${-18 * w} 44 ${18 * w} 44 ${33 * w} 54 L${24 * w} 118 L${29 * w} 160 L${-29 * w} 160 L${-24 * w} 118Z`} fill={INK} />
      <rect x="-7.5" y="30" width="15" height="20" fill={INK} />
      <Hair hat={look.hat} c={20} r={17.5} />
      {look.tote ? <path d="M-40 112 h30 v48 h-30z M-34 112 q9 -26 18 0" fill={INK} stroke={INK} strokeWidth="3" /> : null}
    </svg>
  );
};

type Walker = { h: number; foot: number; look: Look; path: [number, number][] };
const VISITORS: Walker[] = [
  { h: 724, foot: 1650, look: { hat: 'brim', coat: true, wide: 1.06 }, path: [[E('visitors') - 0.3, -160], [E('still') + 0.3, 700], [E('everyone') + 0.3, 745], [E('closes') - 0.2, 755], [E('tonight') + 0.5, 1260]] },
  { h: 730, foot: 1655, look: { hat: 'beret', tote: true }, path: [[E('visitors') - 0.2, 1260], [E('everyone') + 0.1, 905], [E('museum'), 915], [E('closes') + 0.6, 1300]] },
  { h: 440, foot: 1690, look: { wide: 0.86 }, path: [[E('visitors') + 0.1, 1240], [E('still') + 0.6, 826], [E('closes') - 0.3, 830], [E('tonight') + 0.5, 1240]] },
  { h: 706, foot: 1640, look: { hat: 'bun', wide: 0.9, coat: true }, path: [[E('visitors') + 0.25, -160], [E('nobody') + 0.2, 1270]] },
];
/* The one who reaches for the slice, stops, and pretends it was about her hair. */
const REACHER: Walker = { h: 730, foot: 1660, look: { hat: 'bob' }, path: [[E('visitors') - 0.05, -160], [E('everyone') + 0.1, 322], [E('scholars') - 0.1, 322], [E('polite') + 0.2, 600], [E('museum'), 610], [E('tonight') + 0.5, 1260]] };
const REST: Angles = [7, 6], REST_L: Angles = [-7, -6];
const mixArm = (a: Angles, b: Angles, k: number): Angles => [lerp(a[0], b[0], k), lerp(a[1], b[1], k)];
const pace = (w: Walker, t: number) => { const x = track(w.path, t), v = clamp(Math.abs(track(w.path, t + 0.08) - track(w.path, t - 0.08)) / 8); return { x, stride: 13 * Math.sin(x / 27) * v, bob: Math.abs(Math.sin(x / 27)) * 5 * v }; };
const Walkers: React.FC = () => {
  const t = useT();
  const reach = easeOut(ramp(t, E('wanted'), E('wanted') + 0.55)), hair = easeInOut(ramp(t, E('nobody') + 0.08, E('nobody') + 0.42));
  const pat = hair * (t < E('scholars') ? 7 * Math.sin(t * 15) : 0);
  const rArm = mixArm(mixArm(mixArm(REST, [125, 20], reach), [132, 128 + pat], hair), REST, easeInOut(ramp(t, E('scholars') - 0.1, E('scholars') + 0.3)));
  const r = pace(REACHER, t), shy = easeInOut(ramp(t, E('nobody'), E('nobody') + 0.4)), g = guardX(t);
  return (
    <>
      {VISITORS.map((w, i) => { const p = pace(w, t); return p.x < -150 || p.x > 1250 ? null : <Figure key={i} x={p.x} foot={w.foot + p.bob} h={w.h} look={w.look} stride={p.stride} right={REST} left={REST_L} lean={(i % 2 ? -1 : 1) * 3 * shy} />; })}
      {r.x > -150 && r.x < 1250 ? <Figure x={r.x} foot={REACHER.foot + r.bob} h={REACHER.h} look={REACHER.look} stride={r.stride} right={rArm} left={REST_L} lean={lerp(0, 3, reach) * (1 - hair)} fingers={reach * (1 - hair)} /> : null}
      {g > -160 && g < 890 && t > E('tonight') - 0.3 ? <Figure x={g} foot={GUARD.foot + Math.abs(Math.sin(g / 34)) * 5} h={GUARD.h} look={{ hat: 'cap', wide: 1.1 }} stride={15 * Math.sin(g / 34)} right={GUARD.right} left={REST_L} /> : null}
    </>
  );
};

/* The room in the dark was never empty: two rows of seats, there all along, seen as heads and shoulders across the bottom. */
type Seat = { x: number; y: number; r: number; hat?: Look['hat']; n?: string; at?: number; px?: number; py?: number; score?: number; sx?: number };
const SEATS: Seat[] = [
  { x: 92, y: 1312, r: 44, hat: 'bob', n: '14', at: 0.34, px: 70, py: 1056 },
  { x: 246, y: 1304, r: 42, hat: 'brim', n: '7', at: 0.1, px: 208, py: 1050, score: 0.3, sx: 88 },
  { x: 604, y: 1310, r: 44, n: '22', at: 0.26, px: 672, py: 1052, score: 0, sx: 520 },
  { x: 772, y: 1316, r: 42, hat: 'beret', n: '31', at: 0.5, px: 812, py: 1060, score: 0.18, sx: 930 },
  { x: 960, y: 1312, r: 46, hat: 'cap', n: '9', at: 0.18, px: 985, py: 1050 },
  { x: 0, y: 1436, r: 62 },
  { x: 168, y: 1428, r: 60, hat: 'beret', n: '3', at: 0.42, px: 140, py: 1182 },
  { x: 340, y: 1442, r: 58, n: '18', at: 0.6, px: 410, py: 1186 },
  { x: 530, y: 1430, r: 62, hat: 'bob', n: '26', at: 0.3, px: 600, py: 1182 },
  { x: 704, y: 1440, r: 60, hat: 'brim', n: '35', at: 0.68, px: 745, py: 1190 },
  { x: 878, y: 1428, r: 60, n: '41', at: 0.54, px: 900, py: 1186 },
  { x: 1074, y: 1440, r: 62, hat: 'bun' },
];
const Plate: React.FC<{ x: number; y: number; w: number; n: string; lean?: number }> = ({ x, y, w, n, lean = 0 }) => <div style={{ position: 'absolute', left: x - w / 2, top: y - w * 0.58, width: w, height: w * 1.16, borderRadius: '48% 48% 44% 44%', background: CARD, boxShadow: `inset 0 0 0 ${w * 0.04}px ${TEXT}, 0 8px 14px rgba(0,0,0,.45)`, transform: `rotate(${lean}deg)`, textAlign: 'center', font: `700 ${w * 0.62}px/${w * 1.16}px ${NARROW}`, color: TEXT }}>{n}</div>;
const Bust: React.FC<{ x: number; y: number; r: number; hat?: Look['hat'] }> = ({ x, y, r, hat }) => (
  <svg width="4" height="4" style={{ position: 'absolute', left: x, top: y, overflow: 'visible' }}>
    <Hair hat={hat} c={0} r={r} />
    <rect x={-r * 0.38} y={r * 0.6} width={r * 0.76} height={r} fill={INK} />
    <path d={`M${-r * 2.3} 900 L${-r * 2.3} ${r * 2.7} C${-r * 2.1} ${r * 1.6} ${-r * 1.1} ${r * 1.4} 0 ${r * 1.35} C${r * 1.1} ${r * 1.4} ${r * 2.1} ${r * 1.6} ${r * 2.3} ${r * 2.7} L${r * 2.3} 900Z`} fill={INK} />
  </svg>
);
const Seats: React.FC = () => {
  const t = useT();
  if (t < E('unless') - 0.2) return null;
  const a = E('quiet'), g = E('gavel'), done = t >= E('blink');
  const out: React.ReactNode[] = [<div key="floor" style={{ position: 'absolute', left: -40, top: 1500, width: 1160, height: 240, background: INK }} />];
  SEATS.forEach((s, i) => {
    if (i === 5) out.push(<BidderSeat key="bidder" />);
    // Paddles go up one by one and drop just before the gavel lands; after the blink, three of them come back as scores.
    let up = s.at !== undefined && !done ? back(ramp(t, a + s.at, a + s.at + 0.24)) * (1 - easeIn(ramp(t, g - 0.42, g - 0.14))) : 0;
    let label = s.n ?? '', px = s.px ?? s.x, py = s.py ?? 1100;
    if (s.score !== undefined && done) { up = back(ramp(t, E('critics') + 0.1 + s.score, E('critics') + 0.34 + s.score)) * (1 - easeIn(ramp(t, E('cta') - 0.2, E('cta') + 0.1))); label = TXT.score; px = s.sx ?? px; py = 985; }
    const w = s.r * 2.6, plateY = lerp(s.y + 130, py, up), handY = plateY + w * 0.58 + (done ? 200 : s.r > 50 ? 110 : 130);
    out.push(
      <React.Fragment key={i}>
        {up > 0.01 ? (
          <>
            <div style={{ position: 'absolute', left: px - 7, top: plateY, width: 14, height: handY - plateY, background: '#3a2413' }} />
            <Plate x={px} y={plateY} w={w} n={label} lean={(px - s.x) * 0.05} />
            <svg width="4" height="4" style={OVER}><path d={`M${s.x + Math.sign(px - s.x || 1) * s.r * 1.5} ${s.y + s.r * 2.3} L${px} ${handY}`} stroke={INK} strokeWidth={s.r * 0.36} strokeLinecap="round" fill="none" /><circle cx={px} cy={handY} r={s.r * 0.27} fill={INK} /></svg>
          </>
        ) : null}
        <Bust x={s.x} y={s.y} r={s.r} hat={s.hat} />
      </React.Fragment>,
    );
  });
  out.push(<svg key="chairs" width="4" height="4" style={OVER}>{[-40, 150, 340, 530, 720, 910, 1100].map((x) => <g key={x}><rect x={x - 82} y="1612" width="164" height="360" rx="26" fill="#37121a" stroke="#0b0606" strokeWidth="6" /><rect x={x - 62} y="1632" width="124" height="340" rx="16" fill="#4d1a24" /></g>)}</svg>);
  return <>{out}</>;
};

/* The one who bids: a paddle in one hand, a napkin in the other. Gone in the blink, and the seat stays empty. */
const BidderSeat: React.FC = () => {
  const t = useT();
  const { x, y, r } = BIDDER;
  if (t >= E('blink')) return <svg width="4" height="4" style={OVER}><rect x={x - 66} y={y + 66} width="132" height="300" rx="22" fill="#37121a" stroke="#0b0606" strokeWidth="5" /></svg>;
  const up = paddleUp(t), nap = back(ramp(t, E('other'), E('napkin') + 0.05));
  const hx = x + 96, hy = lerp(y + 320, y - 30, up), nx = x - 124, ny = lerp(y + 240, y - 92, nap);
  const w = (k: number) => Math.sin(t * 7 + k) * 7;
  const armTo = (side: number, tx: number, ty: number) => <path d={`M${x + side * r * 1.3} ${y + r * 2.2} L${tx} ${ty + 10}`} stroke={INK} strokeWidth={r * 0.36} strokeLinecap="round" />;
  return (
    <>
      {up > 0.01 ? <>
        <div style={{ position: 'absolute', left: hx - 7, top: hy - 130, width: 14, height: 130, background: '#3a2413' }} />
        <Plate x={hx} y={hy - 196} w={136} n={TXT.bidder} lean={3 + Math.sin(t * 7) * 0.5 * (1 - ramp(t, E('quiet'), E('quiet') + 1))} />
      </> : null}
      <svg width="4" height="4" style={OVER}>
        {armTo(1, hx, hy)}
        {nap > 0.01 ? <>
          {armTo(-1, nx, ny)}
          <g transform={`translate(${nx} ${ny})`}>
            <path d={`M-16 -6 L58 -2 Q${70 + w(0)} 60 ${50 + w(1)} 124 Q${6 + w(2)} 134 ${-44 + w(3)} 118 Q${-34 + w(4)} 50 -16 -6Z`} fill="#f8f4ea" />
            <path d={`M10 0 Q${6 + w(1)} 60 ${w(2)} 126 M36 0 Q${40 + w(2)} 60 ${30 + w(3)} 126`} fill="none" stroke="#cfc6b2" strokeWidth="3" />
            <circle r={r * 0.26} fill={INK} />
          </g>
        </> : null}
      </svg>
      <Bust x={x} y={y} r={r} />
    </>
  );
};

/* The gavel lands on the placard, and when it lifts the red dot is there. */
const Gavel: React.FC = () => {
  const t = useT();
  const a = E('gavel');
  if (t < a - 0.62 || t > a + 0.95) return null;
  const ang = t < a ? lerp(lerp(78, 44, easeOut(ramp(t, a - 0.62, a - 0.2))), 0, easeIn(ramp(t, a - 0.15, a))) : lerp(0, 86, easeInOut(ramp(t, a + 0.12, a + 0.9)));
  return (
    <div style={{ position: 'absolute', left: DOT[0] + 372, top: DOT[1] - 76, width: 1, height: 1, transform: `rotate(${ang}deg)`, transformOrigin: '0 0' }}>
      <svg width="4" height="4" style={{ overflow: 'visible' }}>
        <defs><linearGradient id="v-oak" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stopColor="#4a2a13" /><stop offset="0.4" stopColor="#a86e38" /><stop offset="1" stopColor="#3f2310" /></linearGradient><linearGradient id="v-h" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#a86e38" /><stop offset="1" stopColor="#4a2a13" /></linearGradient></defs>
        <rect x="-372" y="-13" width="372" height="26" rx="10" fill="url(#v-h)" />
        <rect x="-420" y="-74" width="96" height="150" rx="14" fill="url(#v-oak)" stroke="#2a1608" strokeWidth="3" />
        <rect x="-422" y="-46" width="100" height="13" fill={GILTHI} /><rect x="-422" y="35" width="100" height="13" fill={GILTHI} />
      </svg>
    </div>
  );
};

/* ---------- what's written on the wall ---------- */
const Typed: React.FC<{ text: string; at: number; cps?: number }> = ({ text, at, cps = 34 }) => {
  const n = Math.floor((useT() - at) * cps);
  return <>{Array.from(text).map((c, i) => <span key={i} style={{ visibility: i < n ? 'visible' : 'hidden' }}>{c}</span>)}</>;
};

/* The placard types itself, gets taped over, gets the dot, and becomes the call to action. Sized to our words: check a longer title. */
const Placard: React.FC = () => {
  const t = useT();
  const cta = t >= E('cta') + 0.05, late = t >= E('lobby'), u = t - E('dot');
  const dot = u > 0 ? lerp(1.9, 1, back(clamp(u / 0.14))) : 0;
  const fall = easeIn(ramp(t, E('gavel') + 0.02, E('gavel') + 0.5));
  const row = (y: number, font: string, node: React.ReactNode, c = TEXT) => <div style={{ position: 'absolute', left: 24, top: y, font, color: c, whiteSpace: 'nowrap' }}>{node}</div>;
  const size = Math.min(104, 332 / (KEYWORD.length * 0.58));
  const rows = [`italic 600 40px/44px ${SERIF}`, `600 28px/34px ${NARROW}`, `700 28px/34px ${NARROW}`, `600 26px/32px ${NARROW}`];
  return (
    <div style={{ position: 'absolute', left: PLACARD.x, top: PLACARD.y, width: PLACARD.w, height: PLACARD.h, background: CARD, boxShadow: '0 12px 22px rgba(0,0,0,.42), inset 0 0 0 1.5px #d6cfbd' }}>
      {cta ? (
        <>
          {row(16, `700 38px/40px ${NARROW}`, <Typed text="COMMENT" at={E('cta') + 0.05} cps={30} />)}
          {row(56, `700 ${size}px/${size + 8}px ${NARROW}`, <Typed text={KEYWORD} at={E('keyword')} cps={22} />, RED)}
        </>
      ) : late ? (
        <>
          {row(16, rows[0], <Typed text={TXT.after[0]} at={E('lobby')} />)}
          {row(64, rows[1], <Typed text={TXT.after[1]} at={E('lobby') + 0.25} />)}
          {row(98, rows[2], <Typed text={TXT.after[2]} at={E('critics')} />)}
          {row(136, rows[3], TXT.after[3], '#5a554b')}
        </>
      ) : (
        <>
          {row(16, rows[0], <><Typed text={TXT.title} at={E('untitled')} cps={22} /><Typed text={TXT.title2} at={E('last')} cps={28} /></>)}
          {row(64, rows[1], <Typed text={TXT.medium} at={E('cold')} cps={22} />)}
          {row(98, rows[2], <Typed text={TXT.artist} at={E('artist')} cps={22} />)}
          {t >= E('artist') + 0.5 ? row(136, rows[3], t >= E('dot') ? TXT.sold : TXT.status, t >= E('dot') ? RED : '#5a554b') : null}
        </>
      )}
      {t >= E('thrown') - 0.05 && fall < 1 ? <div style={{ position: 'absolute', left: -22, top: 92, width: 424, height: 62, background: 'rgba(224,206,146,.96)', boxShadow: '0 3px 6px rgba(0,0,0,.35)', transform: `translateY(${fall * 620}px) rotate(${-3 - fall * 34}deg) scaleX(${easeOut(ramp(t, E('thrown') - 0.05, E('thrown') + 0.12))})`, transformOrigin: '0 50%', textAlign: 'center', font: `700 38px/62px ${NARROW}`, letterSpacing: '.02em', color: '#7c1019', whiteSpace: 'nowrap' }}>{TXT.tape}</div> : null}
      {dot > 0 ? <div style={{ position: 'absolute', left: cta ? 308 : DOT[0] - PLACARD.x - 29, top: cta ? 8 : DOT[1] - PLACARD.y - 29, width: 58, height: 58, borderRadius: '50%', background: `radial-gradient(circle at 36% 30%,#f25560,${RED} 55%,#9c1219)`, boxShadow: '0 3px 5px rgba(0,0,0,.4)', transform: `scale(${dot})` }} /> : null}
    </div>
  );
};

/** The audio guide is the one who's talking: its meter reads the spoken words, so it works with no audio. */
const Guide: React.FC = () => {
  const t = useT();
  if (t >= E('gavel') - 0.5) return null;
  const on = WORDS.some((w) => t >= w.t0 && t < w.t1), quiet = t >= E('quiet') - 0.2, beep = ramp(t, 0.02, 0.5);
  const hush = 1 - 0.5 * ramp(t, E('lower'), E('lower') + 0.3) * (1 - ramp(t, E('pizza') + 0.3, E('pizza') + 0.8));
  return (
    <div style={{ position: 'absolute', left: 70, top: 1060, width: 470, height: 64 }}>
      <div style={{ position: 'absolute', left: -beep * 26, top: -beep * 26, width: 64 + beep * 52, height: 64 + beep * 52, borderRadius: '50%', border: `3px solid ${GILTHI}`, boxSizing: 'border-box', opacity: 1 - beep }} />
      <div style={{ position: 'absolute', left: 0, top: 0, width: 64, height: 64, boxSizing: 'border-box', borderRadius: '50%', border: `4px solid ${LINEN}`, textAlign: 'center', font: `700 38px/56px ${NARROW}`, color: LINEN }}>{TXT.room.replace(/\D/g, '')}</div>
      <div style={{ position: 'absolute', left: 78, top: 0, font: `600 30px/64px ${NARROW}`, color: LINEN, whiteSpace: 'nowrap' }}>{quiet ? TXT.guideQuiet : TXT.guide}</div>
      {quiet ? null : Array.from({ length: 16 }, (_, i) => { const h = 4 + (on ? 42 * hush * hash(i * 5 + Math.floor(t * 14)) * (0.45 + 0.55 * Math.sin(((i + 0.5) / 16) * 3.14)) : 0); return <div key={i} style={{ position: 'absolute', left: 240 + i * 14, top: 32 - h / 2, width: 8, height: h, borderRadius: 4, background: GILTHI }} />; })}
    </div>
  );
};

const WallText: React.FC = () => {
  const t = useT();
  if (t < E('polite') - 0.1 || t >= E('xray')) return null;
  const title = { font: `600 58px/58px ${SERIF}`, color: GILTHI, whiteSpace: 'nowrap' } as const;
  return (
    <div style={{ position: 'absolute', left: 70, top: 1128, width: 490, opacity: 1 - ramp(t, E('xray') - 0.25, E('xray') - 0.05) }}>
      <div style={title}><Typed text={TXT.period[0]} at={E('polite') - 0.05} cps={30} /></div>
      <div style={title}><Typed text={TXT.period[1]} at={E('period')} cps={22} /></div>
      <div style={{ position: 'absolute', left: 164, top: 72, font: `600 26px/32px ${NARROW}`, color: LINEN, whiteSpace: 'nowrap', opacity: ramp(t, E('period') + 0.3, E('period') + 0.6) }}>{TXT.periodNote}</div>
      <div style={{ position: 'absolute', left: 0, top: -6, width: 470 * ramp(t, E('period'), E('period') + 0.45), height: 3, background: GILT }} />
    </div>
  );
};

/** The lobby, on the security monitor: three bites and a dab with the napkin. */
const LobbyCam: React.FC = () => {
  const t = useT();
  const at = E('ate') - 0.1;
  if (t < at || t > E('cta') + 0.3) return null;
  const u = t - at, bite = Math.min(3, Math.floor(u / 0.62));
  const chew = Math.sin(u * 18) * (bite < 3 ? 2.4 : 0), reach = bite < 3 ? 0.5 + 0.5 * Math.cos(((u % 0.62) / 0.62) * 6.28) : 0;
  const label = { position: 'absolute', font: `700 26px/30px ${NARROW}`, color: '#d9e6df', whiteSpace: 'nowrap' } as const;
  return (
    <div style={{ position: 'absolute', left: 130, top: 1056, width: 360, height: 196, background: '#141a18', borderRadius: 8, boxShadow: '0 12px 22px rgba(0,0,0,.42), inset 0 0 0 4px #2d3431', overflow: 'hidden', transform: `scaleY(${easeOut(ramp(t, at, at + 0.14)) * (1 - ramp(t, E('cta') + 0.05, E('cta') + 0.25))})` }}>
      <svg viewBox="20 0 360 196" width={360} height={196} style={AT0}>
        <rect x="8" y="8" width="384" height="180" fill="#2a3a36" />
        <rect x="8" y="150" width="384" height="38" fill="#1b2724" /><rect x="110" y="136" width="180" height="16" fill="#101815" />
        <g transform={`translate(196 ${48 + chew})`} fill="#0a100e"><circle r="27" /><path d="M24 -2 l10 7 -10 3z" /><path d="M-30 96 Q-34 34 0 28 Q34 34 30 96Z" /></g>
        <path d={`M214 104 L${250 - reach * 16} ${112 - reach * 32} L${242 - reach * 20} ${84 - reach * 38}`} fill="none" stroke="#0a100e" strokeWidth="13" strokeLinecap="round" strokeLinejoin="round" />
        {bite < 3 ? <path transform={`translate(${244 - reach * 20} ${74 - reach * 38}) rotate(-40) scale(${1 - bite * 0.3})`} d="M-22 -12 Q0 -24 22 -12 L0 30Z" fill="#e0a53a" stroke="#7c4a1c" strokeWidth="4" /> : <path transform={`translate(${232 + Math.sin(u * 9) * 3} 58)`} d="M-12 -12 L16 -9 L10 18 L-16 14Z" fill="#f8f4ea" />}
      </svg>
      <div style={{ position: 'absolute', inset: 8, background: 'repeating-linear-gradient(0deg,rgba(0,0,0,.2) 0 2px,transparent 2px 5px)' }} />
      <div style={{ ...label, left: 18, top: 14 }}>{TXT.cam}</div>
      <div style={{ ...label, right: 18, bottom: 12 }}>{TXT.camTime}</div>
      <div style={{ position: 'absolute', right: 18, top: 18, width: 18, height: 18, borderRadius: '50%', background: RED, opacity: Math.floor(t * 2.5) % 2 ? 1 : 0.25 }} />
    </div>
  );
};

/* ---------- captions: the guide's script, lettered on the wall ---------- */
const CHUNKS = buildChunks(27, 2);
const Captions: React.FC = () => {
  const t = useT();
  const u = t - E('gavel');
  if (u >= -0.02 && t < E('blink')) {
    return (
      <div style={{ position: 'absolute', left: 62, top: 1330, height: 150, display: 'flex', alignItems: 'center', gap: 34, transform: `scale(${lerp(1.3, 1, back(clamp((u + 0.02) / 0.14)))})`, transformOrigin: '0 100%' }}>
        <div style={{ width: 92, height: 92, borderRadius: '50%', background: `radial-gradient(circle at 36% 30%,#f25560,${RED} 55%,#9c1219)` }} />
        <div style={{ font: `700 150px/150px ${SERIF}`, color: LINEN, letterSpacing: '.04em', textShadow: '0 3px 18px rgba(0,0,0,.8)' }}>{TXT.soldCaption}</div>
      </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 end = c.words[c.words.length - 1].t1, fade = 1 - ramp(t, end + 0.4, end + 0.65);
  if (fade <= 0) return null;
  return (
    <div style={{ opacity: fade }}>
      {c.rows.map((row, ri) => (
        <div key={ri} style={{ position: 'absolute', left: 62, top: c.rows.length === 1 || ri === 1 ? 1404 : 1328, height: 76, font: `600 72px/76px ${SERIF}`, whiteSpace: 'nowrap', textShadow: '0 2px 14px rgba(3,10,8,.85), 0 0 3px rgba(3,10,8,.7)' }}>
          {Array.from(new Set(row.map.filter((i) => i >= 0))).map((i) => {
            const w = c.words[i], hot = t >= w.t0 - 0.03 && t < w.t1 + 0.08, key = t >= E('cta') && w.w.toUpperCase() === KEYWORD;
            return <span key={i} style={{ color: hot || key ? GILTHI : LINEN, opacity: t >= w.t0 - 0.03 ? 1 : 0.3 }}>{w.w}{' '}</span>;
          })}
        </div>
      ))}
    </div>
  );
};

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

const Stage: React.FC = () => {
  const cam = camAt(useT());
  return (
    <AbsoluteFill style={{ background: '#050807', overflow: 'hidden' }}>
      <div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1920, transformOrigin: '0 0', transform: `translate(${cam.tx}px, ${cam.ty}px) scale(${cam.s})` }}>
        {ROOM}
        <HungFrame />
        <Guide />
        <WallText />
        <LobbyCam />
        <Placard />
        <Rope />
        <Walkers />
        <Seats />
        <Gavel />
        <LightMap />
        <Glow />
        <ExitSign />
      </div>
      <Captions />
      <AbsoluteFill style={{ background: 'radial-gradient(120% 80% at 50% 45%, transparent 62%, rgba(2,6,5,.5) 100%)', pointerEvents: 'none' }} />
    </AbsoluteFill>
  );
};
```

## 8. Voice (optional)

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

**The persona:** a refined museum audio guide, hushed and close to the mic, unhurried in tone, as if reading a label to one person in an empty room. Reverent about everything, surprised by nothing, and never in on the joke.

**When to use it:** on every line except the sale. Hushed voices read slowly, so speed the takes up until the long placard line lands in under 5 s. Give room to "Unless someone bids", the shortest line in the film. Say it slower than the rest, and let it sit on the dark. Put a touch of a large quiet room on it, with a very short echo, and compress before the room. Commas around the keyword help: "Comment, gallery, and".

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

## 9. Sound

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

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

| Sound | Length | Prompt idea | When it plays |
|-------|--------|-------------|---------------|
| guide beep | 0.4 s | a small soft electronic beep from a handheld museum audio guide, one gentle tone, warm, no echo | the first frame, and again at `cta` |
| lamp clack | 0.4 s | a heavy old stage lamp switched once, a dry metallic clack, small and close | every lamp: on at `room` and `lower`, off at `museum`, `closes`, `tonight`, `work`, `thrown`; a fast run of them at `quiet` |
| murmur | 12 s | hushed crowd murmur in a large museum room, soft shoes, no clear words, warm, no music | a bed from the first frame; it fades out between `closes` and `work` |
| room tone | 12 s | an empty museum gallery at night, a faint air handling hum, a huge quiet room, no voices, no music | the bed, all the way through, rising as the murmur leaves |
| slow footsteps | 6 s | one person's slow leather-soled footsteps on an oak floor in an empty hall, unhurried, distant, warm | the guard, from `tonight` to `bids`; this is what keeps the low point alive |
| placard clicks | 1.2 s | a small label printer, soft quick dry clicks, close, warm | `untitled`, `cold`, `artist`, and the wall text at `polite` |
| throat clear | 0.8 s | one person quietly clearing their throat in a big room, embarrassed, polite | just after `nobody`, on the hair |
| X-ray hum | 1.5 s | a low electric hum rising with a soft scanning sweep, warm, not science fiction | `xray`, and a small tick on `someone` |
| tape | 0.8 s | a strip of masking tape pulled off the roll and pressed onto card | `thrown` |
| rope drop | 1.2 s | a brass hook unclipped, then a heavy velvet rope falling on a wooden floor, one small bounce | just after `work` |
| paddle | 0.5 s | a light wooden paddle lifted, a soft cloth rustle | `bidder` |
| napkin | 0.6 s | a cloth napkin shaken open once, soft | `napkin` |
| paddles | 1.5 s | many light wooden paddles flicked up at once, chairs creaking, a crowd shifting, no voices | `quiet`, as the room appears |
| gavel | 1.2 s | a single heavy wooden gavel strike on a sound block, deep and solid, a short room ring, warm and dark | `gavel`, the loudest thing in the film |
| crumbs | 1 s | a few small crumbs falling on a wooden ledge, tiny and dry | `blink` |
| chewing | 1.5 s | muffled chewing through a tiny security monitor speaker, soft and comic, not gross | `ate` |
| polite applause | 3 s | a small refined audience clapping politely in a museum room, no cheering | from `critics`, low, gone by `cta` |

- **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 stately little chamber piece: solo harpsichord over a soft string quartet, a polite and slightly pompous minuet, dry and courtly for a long stretch, then a warm build with fuller strings and soft timpani into one bright, grand cadence, no vocals."

**When it plays:** under the first lines at a low level, courtly and polite. It drains to nothing over 0.7 s across "thrown away", and stays out through the low point, so only room tone and the footsteps remain. It comes back on "bids" as one low held note, climbs under the napkin line, and is at its fullest on the voiceless moment, on a bar where the track opens up, with the gavel on a downbeat. It ducks right after SOLD so the button sits clear, and ends on a small flourish under the call to action.

**Mix, voice first.**

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

## 10. Render and QA

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

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

## 11. Traps

- **A real artwork on the wall:** never. No real painting, artist, museum or auction house, in the picture, the script or the caption, and no "in the style of" a named painter. Our painting is shapes and a filter. Invented titles only.
- **A black low point:** the lamps go out, but the middle lamp keeps `rest`, the EXIT sign keeps its glow and the flashlight keeps crossing the room. It's never black.
- **The seats give the reveal away:** they're there from the low point on, and only the dark hides them. Don't brighten the ambient light before the sale, and keep the pool on the bidder's paddle small.
- **Silhouettes eating the placard:** a head in front of the bright placard leaves white fragments that look like a render fault. Stand visitors so their shoulders cover it, and keep them away while it's being read. The paddles drop just before the gavel for the same reason.
- **Something that should glow goes dark:** it's under `LightMap`. Move it to `Glow`, or give it a pool.
- **A word lost behind the rope:** a camera push can carry the rope behind the captions. The text's shadow keeps most of it readable, but on a line that matters, choose the zoom's point so the rope stays above y 1328.
- **Flicker on a scanner:** the lamps stutter for a few frames when they change, and one-frame checks flag it as a dark block. Look at the frame: a whole-room dip is the lamp, and a hard-edged rectangle is a real dropout.
- **The filter is the slow part:** keep `p-oil` on the painting only, with anything that moves outside it.
- **Typed text looks cut off on a contact sheet:** that's letters arriving. Watch the video.

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