---
name: book-of-hours
description: Make a 30-40 second vertical video on one illuminated manuscript page, where a candle burns down past its hour rings, a gilded miniature paints each hour's scene, and a knight fights a snail in the margin, all with complete medieval seriousness. The skill covers the candlelit sound design, music fitted to the story and an optional voice like an old monk reading aloud. Use for advice on surviving a small daily ordeal, Mondays, meetings, deadlines, habits and old wisdom for modern problems, or when asked for a medieval, manuscript, illuminated or Book of Hours video, or when given the book-of-hours prompt.
---

# Book of Hours

One illuminated page fills the screen, and your topic is the ordeal it has to get through. A candle burns past one ring for each hour, a gilded miniature paints each hour's scene, a scribe's mistake gets scraped off with a knife, and a knight fights a snail in the margin. Then the candle fails, the gold goes dead, and one small piece of advice relights the page. It treats a bad Monday as a medieval trial, so you stay to see how the knight gets out of it.

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/book-of-hours/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/unifrakturmaguntia`, `@fontsource/uncial-antiqua` and `@fontsource/im-fell-english`. 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/BookOfHours.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: a candle burns down past its hour rings, each hour paints a new miniature, a wrong word gets scraped off, a knight and a snail fight in the margin, the gold goes dead, then lights up across the page. 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 illuminated manuscript page, lit by one candle.

**Two rules that come before everything else in this frame:**

- **It never mocks a faith.** A book of hours is a prayer book, and people still pray from them. Keep the humor on Mondays, meetings, inboxes and small daily trials. No saints, angels, God or other sacred figures in the jokes or the pictures, no crosses or altars as props, no prayers or scripture parodied, and no monk or nun as the butt of a joke. The scribe in our miniature is tired, and the film is on his side.
- **Any Latin must be correct.** Ours uses four phrases and nothing else: "Liber Horarum" (Book of Hours), "Dies Lunae" (day of the Moon, Monday), "Incipit" (here begins) and "Explicit" (here ends). Don't write new Latin from memory or from a translator. If you can't confirm a phrase in a real source, write it in English: the blackletter and the candle already carry the period. "Hath" and "doth" are only for he, she or it.

The rest of the world:

- **Palette:** vellum `#eadbb8`, iron-gall ink `#2a1b12`, lapis `#1f3e8a`, vermilion `#c8341f`, gold leaf `#c9a23a`, verdigris `#3e7c6a`.
- **Type:** UnifrakturMaguntia blackletter for the title and the English headings, Uncial Antiqua capitals for the Latin headings and the voiceless caption, IM Fell English for the text, the drop cap and the margin notes.
- **Fixed parts:** the title at the top, a gilded arch holding the miniature, a candle in the right margin, a red heading under the arch, a margin band with the snail, the text block (y 1234-1470) with its gold drop cap, then the rest of the page as texture below the safe area. The title sits above the safe area, so nothing on it has to be read.
- **The captions are the text of the page.** Each chunk opens on a gold drop cap, and its words wait as faint ghosts until the voice inks them, one at a time, in vermilion and then in ink.
- **The candle is the clock and the light.** It has one ring for each hour, and the flame reaches a ring exactly when the voice names that hour. Candles marked this way were a real way to tell time. All the light on the page comes from the flame, so when it fails, the whole world fails.
- **The snail is there from the first frame.** It crawls along the margin the whole film, leaving a line, and nobody mentions it for 15 seconds. That wait is half of the joke.
- **The camera stays on the page:** it pushes in on the margin for the fight, and pulls out for the gold. It zooms around `FOCUS`, on the top edge of the text block, so nothing above the text ever slides under it.
- **Small jokes live in the margins**, in a tired hand: "too early", "this could have been a letter", "oh, my hand", "the snail has not noticed". Real scribes did complain in their margins about cold hands and thin ink, and a knight fighting a snail is a real medieval margin joke. On the agenda ribbon: "Item the first of forty".

**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 miniature inside the gilded arch, and each hour paints a new one over the last with a brush-edged wipe. Ours are drawn in code, in flat color with ink outlines:

| Story moment | In the arch |
|---|---|
| Opening | Night over a walled town. A grumpy moon rises out of the rooftops, and opens its eyes on "Moon" |
| First hour | A figure under a starry blanket. A horn comes through the wall, and a foot pulls back under the covers |
| Second hour | Five knights rise behind a long table, gold behind them, an hourglass running, an agenda ribbon. They slowly sag |
| The error | They throw their arms up on the wrong word, and drop onto the table when it's scraped off |
| Low point | A tired scribe at a desk, and a heap of scrolls in a crate labeled "inbox". It grows, and when the candle dies a moonbeam shows scrolls still falling |
| The turn | A small burst of light from the book on his desk. Then one scroll flies across to the crate labeled "done", then the next |
| Voiceless | The town from the opening: the sky turns to gold leaf, the bell swings, the sun rises with a face |
| Ending | The moon peeks back over the rooftops on "It shall return" |

The window also works with the user's own clip: play it inside `Miniature` in place of a scene, cropped by `ARCH`, with the gold frame on top. It suits a person reading to camera, and the scenes can come back between their lines.

## 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 gives one small modern ordeal the full gravity of a medieval manuscript. Ours is about surviving a Monday.

| # | Move | What it does | Our line | On the page |
|---|------|--------------|----------|-------------|
| 1 | Incipit | The hook: the small ordeal, named with full manuscript gravity | "Here begins the advice for surviving the day of the Moon." | The vine starts to grow, the moon rises, the initial gilds itself. A note: "that is, Monday" |
| 2 | First hour | The first small misery | "In the first hour, a horn sounds, and you shall not want to rise." | The flame reaches ring i. Figure under a blanket, a horn through the wall |
| 3 | Second hour | The second one, the one everyone knows | "In the second hour, go forth unto the great meeting." | Ring ii. Knights rise behind a long table. "this could have been a letter" |
| 4 | The error | A false hope, killed on the page | "A scribe hath erred. He wrote Friday. It is not Friday." | The heading reads "The third hour of Friday". The knights cheer. A knife scrapes the word off, the knights drop, "Monday" goes in small over the scar |
| 5 | The margin | The side plot arrives, and it's been there all along | "Lo, in the margin, a knight doth battle a snail." | The camera pushes in. A knight draws himself in ink, facing the snail |
| 6 | The fight | The laugh | "The knight is losing." | His sword flies off and he kneels. "the snail has not noticed" |
| 7 | Low point | Everything goes out | "In the fourth hour, the candle fails, and the inbox hath no end." | Ring iv. The candle gutters, the gold goes dead, the page goes dark. Smoke rises, and scrolls keep falling in the moonbeam. Music out; a far-off bell and wind keep running |
| 8 | The turn | The one piece of real advice, played straight | "Yet the old books say: do one small thing, then the next." | A spark on the wick. The candle catches on "small", and music returns. One scroll goes to "done", then another |
| 9 | The joke | The advice again, as a picture | "The knight, being wise, goes around the snail." | He stands, and walks over and past it on a dotted red line. "the snail has still not noticed" |
| 10 | Voiceless | About 3 s of pure payoff | (music at its fullest, the caption reads IT PASSES) | Gold lights up across the page, vines grow around the whole border, a bell swings, the sun rises in the miniature |
| 11 | Explicit | The release, and a wink | "Here ends the Monday. It shall return. Have courage." | The heading changes. The moon peeks back on "return". A wax seal slams down on "courage" |
| 12 | Call to action | One action | "Comment hours and I'll send you the skill that made this." | The knight and the snail hold up a ribbon that reads COMMENT HOURS, and it stays until the end |

**For other topics:** the first week at a new job, moving house, the last week of a quarter, a toddler's bedtime, tax season, a group project. Name the hours, give the false hope a wrong word that gets scraped off, and find the margin's small enemy: the thing that can't be beaten, only walked around.

**The trick that makes it land** is that the advice is real and the delivery never winks. Everything before the turn is a joke, and "do one small thing, then the next" is meant sincerely, so the knight's way around the snail reads as wisdom and not only as a gag. Pick advice you'd actually give.

**Length:** our film runs 40 s on the clock's estimate. For about 36 s, fold moves 5 and 6 into one line ("Lo, in the margin, a knight is losing to a snail."), take the voiceless moment down to 2.5 s, and drop "It shall return." along with the moon's peek. Then re-anchor the events those words carried: `battle`, `bonk` and `returns`.

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

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

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

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

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

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

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

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

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

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

## 7. Build the frame

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

### What to change for your story

Most of this file is the page itself, and that part stays. A new topic mostly changes the four scenes (about 160 of the 716 lines), the story strings at the top and the fight in the margin. `core.tsx` and `clock.mjs` don't change.

| To change | Where |
|---|---|
| The comment keyword | `KEYWORD` at the top of the file, and the last line in `plan.json`. `Banderole` builds its ribbon from `KEYWORD`, and `Captions` lights every use of the word once `cta` starts |
| Title and headings | `TITLE` and `RUBRICS` at the top. A heading written all in capitals is set in uncial, anything else in blackletter |
| The scribe's mistake | `WRONG` and `RIGHT` at the top, and `Mistake` draws them. `Rubric` adds "of WRONG" to the heading whose event is `err`, so change the " of " in `Mistake` too if your sentence needs it |
| Margin notes | `GLOSSES` at the top, each with a place and the two events it lives between |
| The voiceless caption | `QUIET_CAPTION` at the top |
| The agenda ribbon | `AGENDA` at the top, drawn in `Hall` |
| The scenes | `Town`, `BedRoom`, `Hall`, `Scriptorium` draw them, and `SCENES` says which event paints which. Redrawing these is the biggest job. `Hall` reads `TEAM`, the knights and their shields. `Scriptorium` reads `SCROLLS`, `TAKES` and `pileAt`, and its last lines hold the words "inbox" and "done" |
| The hours on the candle | `RINGS` holds each ring's height and numeral, and `flameY` says which event reaches which ring. Change them together |
| The light and the gold | `litAt` (the flame) and `goldAt` (the leaf), keyed to `candle`, `fails`, `small`, `quiet`, `explicit`. `Moonlight` draws over the dark and matches the window in `Scriptorium`, so move them together |
| The margin fight | `Knight`, `SnailG` and `Margin`. In `Margin`, `kx` and `ky` place the knight, `snailX` the snail, and `kneel`, `arm` and `fly` play the fight. `Seal` draws its own small snail, so redraw that too if the enemy changes |
| The camera and the knocks | `KEYS` for the camera, `HITS` for which events shake the page. Leave `FOCUS` alone |
| The border | `vineK` for when it grows, `SPRIGS` for what grows on it |
| The fonts | `FACES`, and the three font names below it |

- **Scene ids in the code:** none. Everything reads events by name, so scenes can be renumbered in `plan.json` and `story.json` alone.
- **Events have to stay in story order.** `flameY`, `snailX`, `vineK`, `pileAt` and `kx` each run a line through several events, and an event out of order bends them. If you drop a move, point its events at a nearby word rather than deleting them, or delete their uses too.
- **Text sized to our words:** the text block holds 3 rows of about 24 characters (`buildChunks(24, 3)`), and a long word that wraps to a fourth row crosses the bottom of the safe area, so check every chunk. A blackletter heading fits about 24 characters before it meets the knight's raised sword, and a capitals one about 20 before it reaches the seal. `AGENDA` fits about 25 in its ribbon. `RIGHT` sits centered on `WRONG`'s scar, so keep them close in length. Margin notes starting at x 130 should end by about x 620, because the knight draws himself to the right of that. The labels "inbox" and "done" are fitted to their crates. The ribbon and `QUIET_CAPTION` shrink themselves to fit.

### The code

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

```json
{
 "lines": [
  {
   "id": "l01",
   "text": "Here begins the advice for surviving the day of the Moon."
  },
  {
   "id": "l02",
   "text": "In the first hour, a horn sounds, and you shall not want to rise."
  },
  {
   "id": "l03",
   "text": "In the second hour, go forth unto the great meeting."
  },
  {
   "id": "l04",
   "text": "A scribe hath erred. He wrote Friday. It is not Friday."
  },
  {
   "id": "l05",
   "text": "Lo, in the margin, a knight doth battle a snail."
  },
  {
   "id": "l06",
   "text": "The knight is losing."
  },
  {
   "id": "l07",
   "text": "In the fourth hour, the candle fails, and the inbox hath no end."
  },
  {
   "id": "l08",
   "text": "Yet the old books say: do one small thing, then the next."
  },
  {
   "id": "l09",
   "text": "The knight, being wise, goes around the snail."
  },
  {
   "id": "l10",
   "text": "Here ends the Monday. It shall return. Have courage."
  },
  {
   "id": "l11",
   "text": "Comment hours and I'll send you the skill that made this.",
   "say": "Comment, hours, and I'll send you the skill that made this."
  }
 ],
 "scenes": [
  {
   "id": "s01",
   "lines": [
    "l01"
   ],
   "lead": 0.25,
   "tail": 0.1
  },
  {
   "id": "s02",
   "lines": [
    "l02"
   ],
   "lead": 0.15,
   "tail": 0.1
  },
  {
   "id": "s03",
   "lines": [
    "l03"
   ],
   "lead": 0.1,
   "tail": 0.1
  },
  {
   "id": "s04",
   "lines": [
    "l04"
   ],
   "lead": 0.1,
   "tail": 0.15
  },
  {
   "id": "s05",
   "lines": [
    "l05"
   ],
   "lead": 0.15,
   "tail": 0.05
  },
  {
   "id": "s06",
   "lines": [
    "l06"
   ],
   "lead": 0.05,
   "tail": 0.15
  },
  {
   "id": "s07",
   "lines": [
    "l07"
   ],
   "lead": 0.5,
   "tail": 0.1
  },
  {
   "id": "s08",
   "lines": [
    "l08"
   ],
   "lead": 0.35,
   "tail": 0.1
  },
  {
   "id": "s09",
   "lines": [
    "l09"
   ],
   "lead": 0.1,
   "tail": 0.1
  },
  {
   "id": "s10",
   "sec": 3.2
  },
  {
   "id": "s11",
   "lines": [
    "l10"
   ],
   "lead": 0.1,
   "tail": 0.1
  },
  {
   "id": "s12",
   "lines": [
    "l11"
   ],
   "lead": 0.15,
   "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 { BookOfHours } from './frames/BookOfHours';
import spec from './spec.json';

export const Root: React.FC = () => (
  <Composition id="Film-9x16" component={BookOfHours} 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": "gild", "scene": "s01", "word": "Here"},
  {"id": "moon", "scene": "s01", "word": "Moon"},
  {"id": "first", "scene": "s02"},
  {"id": "horn", "scene": "s02", "word": "horn"},
  {"id": "rise", "scene": "s02", "word": "rise"},
  {"id": "second", "scene": "s03"},
  {"id": "forth", "scene": "s03", "word": "forth"},
  {"id": "meeting", "scene": "s03", "word": "meeting"},
  {"id": "err", "scene": "s04"},
  {"id": "wrote", "scene": "s04", "word": "wrote"},
  {"id": "friday", "scene": "s04", "word": "Friday"},
  {"id": "scrape", "scene": "s04", "word": "not", "off": -0.05},
  {"id": "margin", "scene": "s05", "word": "margin"},
  {"id": "knight", "scene": "s05", "word": "knight"},
  {"id": "battle", "scene": "s05", "word": "battle"},
  {"id": "bonk", "scene": "s05", "word": "snail"},
  {"id": "losing", "scene": "s06", "word": "losing"},
  {"id": "low", "scene": "s07"},
  {"id": "fourth", "scene": "s07", "word": "fourth"},
  {"id": "candle", "scene": "s07", "word": "candle"},
  {"id": "fails", "scene": "s07", "word": "fails"},
  {"id": "inbox", "scene": "s07", "word": "inbox"},
  {"id": "noend", "scene": "s07", "word": "end"},
  {"id": "yet", "scene": "s08", "word": "Yet"},
  {"id": "books", "scene": "s08", "word": "books"},
  {"id": "small", "scene": "s08", "word": "small"},
  {"id": "next", "scene": "s08", "word": "next"},
  {"id": "wise", "scene": "s09", "word": "wise"},
  {"id": "around", "scene": "s09", "word": "around"},
  {"id": "quiet", "scene": "s10"},
  {"id": "explicit", "scene": "s11"},
  {"id": "returns", "scene": "s11", "word": "return"},
  {"id": "courage", "scene": "s11", "word": "courage"},
  {"id": "cta", "scene": "s12"}
 ]
}
```

`src/frames/BookOfHours.tsx`:

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

/* Book of Hours: one illuminated page lit by one candle. The candle burns past a ring for each hour, the gilded miniature paints
   each hour's scene, and a knight fights a snail in the margin. 1080 x 1920. Time comes from the spoken words through story.json. */
const E = makeE(story as StoryJson);

/* ---------- the words on the page: change these first ---------- */
const KEYWORD = 'HOURS';
const TITLE = 'Liber Horarum';
/* Page headings: [event, text]. Capitals are set in uncial, because blackletter makes Incipit read Jncipit. Only use Latin you are certain of. */
const RUBRICS: [string, string][] = [['on', 'INCIPIT: DIES LUNAE'], ['first', 'The first hour'], ['second', 'The second hour'], ['err', 'The third hour'], ['low', 'The fourth hour'], ['quiet', 'The last hour'], ['explicit', 'EXPLICIT: DIES LUNAE']];
const WRONG = 'Friday', RIGHT = 'Monday';
const QUIET_CAPTION = 'IT PASSES';
const AGENDA = 'Item the first of forty';
/* Notes in the margin: small jokes in a tired scribe's hand. from/until are [event, offset]. */
type Gloss = { from: [string, number]; until: [string, number]; x: number; y: number; text: string; hand?: boolean };
const GLOSSES: Gloss[] = [
  { from: ['moon', -0.55], until: ['first', 0.9], x: 372, y: 944, text: 'that is, Monday' },
  { from: ['horn', 0.45], until: ['wise', 0], x: 130, y: 958, text: 'too early' },
  { from: ['meeting', 0.05], until: ['wise', 0], x: 130, y: 1008, text: 'this could have been a letter', hand: true },
  { from: ['err', 0.7], until: ['wise', 0], x: 130, y: 1058, text: 'oh, my hand' },
  { from: ['losing', 0.25], until: ['around', -0.1], x: 400, y: 958, text: 'the snail has not noticed' },
  { from: ['around', 0.95], until: ['quiet', 0.3], x: 400, y: 958, text: 'the snail has still not noticed' },
];

const VELLUM = '#eadbb8', INK = '#2a1b12', LAPIS = '#1f3e8a', VERM = '#c8341f', GOLD = '#c9a23a', VERD = '#3e7c6a', RUST = '#7a2b18', STEEL = '#a9adb3', WAX = '#efe3bf';
const BLACK = 'UnifrakturMaguntia, serif', UNCIAL = '"Uncial Antiqua", serif', FELL = '"IM Fell English", serif';
const FACES = [
  { family: 'UnifrakturMaguntia', file: 'unifrakturmaguntia-latin-400-normal.woff2' },
  { family: 'Uncial Antiqua', file: 'uncial-antiqua-latin-400-normal.woff2' },
  { family: 'IM Fell English', file: 'im-fell-english-latin-400-normal.woff2' },
  { family: 'IM Fell English', file: 'im-fell-english-latin-400-italic.woff2', style: 'italic' },
];

type P = [number, number];
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
const mix = (a: P, b: P, k: number): P => [lerp(a[0], b[0], k), lerp(a[1], b[1], k)];
/** A piecewise line through (xs, ys). Keep xs in time order. */
const track = (t: number, xs: number[], ys: number[]) => {
  if (t <= xs[0]) return ys[0];
  for (let i = 0; i < xs.length - 1; i++) if (t < xs[i + 1]) return lerp(ys[i], ys[i + 1], (t - xs[i]) / (xs[i + 1] - xs[i]));
  return ys[ys.length - 1];
};
const O = { stroke: INK, strokeWidth: 3, strokeLinejoin: 'round' as const, strokeLinecap: 'round' as const };

/* ---------- the candle is the clock and the light ---------- */

const CX = 862, DISH = 790;
/* One ring for each hour the story names. The flame reaches a ring exactly when the voice names that hour. */
const RINGS: [number, string][] = [[372, 'i'], [472, 'ii'], [572, 'iii'], [672, 'iv']];
const flameY = (t: number) => track(t, [0, E('first'), E('second'), E('err'), E('fourth'), E('fails'), E('small'), DURATION], [292, 372, 472, 572, 672, 694, 694, 742]);
/** The flame: 1 is lit, 0 is out. It gutters on "fails" and catches again on "small". */
const litAt = (t: number) => {
  const a = E('fails'), b = a + 0.95;
  if (t < a) return 1 - 0.25 * ramp(t, E('candle') - 0.1, a);
  if (t < b) return hash(Math.floor((t - a) * 14) + 3) > 0.2 + 0.7 * ramp(t, a, b) ? 0.6 * (1 - ramp(t, a, b)) + 0.12 : 0.06;
  return t < E('small') ? 0 : easeOut(ramp(t, E('small'), E('small') + 0.4));
};
const flicker = (t: number) => 0.5 * Math.sin(t * 11) + 0.3 * Math.sin(t * 23.7 + 1) + 0.2 * Math.sin(t * 41 + 2);
/** Gold leaf: 0 is dead, 0.8 is candlelit, 1 is blazing. `lag` lets the blaze cross the page. */
const goldAt = (t: number, lag = 0) => {
  const dead = ramp(t, E('fails'), E('fails') + 0.8) * (1 - ramp(t, E('small'), E('small') + 0.8));
  const blaze = ramp(t - lag, E('quiet'), E('quiet') + 0.8) * (1 - 0.35 * ramp(t, E('explicit'), E('explicit') + 1));
  return (1 - dead) * 0.8 + blaze * 0.2;
};
const goldC = (t: number, lag = 0, c = GOLD) => interpolateColors(goldAt(t, lag), [0, 0.8, 1], ['#5a4c2a', c, '#f7dd7e']);

/* ---------- the camera: never leaves the page, and everything above the text block stays above it ---------- */

const FOCUS: P = [600, 1222];
const KEYS: { at: number; d: number; s: number }[] = [
  { at: 0, d: 0.01, s: 1.012 },
  { at: E('margin') - 0.3, d: 0.7, s: 1.17 },
  { at: E('low') - 0.05, d: 1.0, s: 1.02 },
  { at: E('wise') - 0.3, d: 0.7, s: 1.17 },
  { at: E('quiet') + 0.1, d: 1.1, s: 1.012 },
];
const HITS: [number, number][] = ([['battle', 3], ['bonk', 3], ['losing', 4], ['courage', 20]] 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) * 11) : acc), 0);
const camAt = (t: number) => {
  let s = KEYS[0].s;
  for (const k of KEYS.slice(1)) {
    if (t < k.at) break;
    s = lerp(s, k.s, easeInOut(ramp(t, k.at, k.at + k.d)));
  }
  s += 0.003 * Math.sin(t * 0.4);
  const j = shake(t);
  return { s, tx: FOCUS[0] * (1 - s) + j, ty: FOCUS[1] * (1 - s) + j * 0.45 };
};

/* ---------- vellum ---------- */

const specks = (n: number, seed: number) => Array.from({ length: n }, (_, i) => `M${(hash(i * 3 + seed) * 1080).toFixed(1)} ${(hash(i * 7 + seed + 1) * 1920).toFixed(1)}h.1`).join('');
const FOLLICLES: [string, string, number, number][] = [[specks(900, 21), '#8a6a3a', 2.6, 0.28], [specks(500, 3021), '#5e4322', 1.8, 0.28], [specks(240, 7021), '#fff3d2', 5, 0.3]];
/* Textura from a distance is a picket fence of short slanted strokes. Below the safe area it stands in for the rest of the page. */
const textura = (y: number, seed: number) => {
  let x = 110, d = '';
  for (let i = 0; x < 960; i++) {
    const r = hash(i * 3 + seed); if (r < 0.14) { x += 15; continue; } const up = r > 0.9 ? 14 : 0; d += `M${x} ${y + 26}l3 -${26 + up}`; x += 8.4;
  }
  return d;
};
const TEXTURA = Array.from({ length: 8 }, (_, i) => textura(1520 + i * 42, i * 31)).join('');

const Vellum: React.FC = () => (
  <>
    <div style={{ position: 'absolute', inset: 0, background: VELLUM, backgroundImage: 'radial-gradient(ellipse 40% 25% at 20% 75%,rgba(150,105,45,.16),transparent),radial-gradient(ellipse 30% 20% at 85% 20%,rgba(150,105,45,.12),transparent),radial-gradient(ellipse 22% 12% at 66% 62%,rgba(120,80,30,.1),transparent),radial-gradient(ellipse 120% 90% at 50% 50%,transparent 60%,rgba(120,80,30,.35))' }} />
    <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0 }}>
      {FOLLICLES.map(([d, c, w, o], i) => <path key={i} d={d} fill="none" stroke={c} strokeWidth={w} strokeLinecap="round" opacity={o} />)}
      <path d={TEXTURA} fill="none" stroke={INK} strokeWidth="4.2" strokeLinecap="square" opacity="0.34" />
      {Array.from({ length: 8 }, (_, i) => <line key={i} x1="100" x2="980" y1={1546 + i * 42} y2={1546 + i * 42} stroke={RUST} strokeWidth="1" opacity="0.18" />)}
      {Array.from({ length: 14 }, (_, i) => <circle key={i} cx="1006" cy={1500 + i * 26} r="2" fill="#5e4322" opacity="0.5" />)}
    </svg>
  </>
);

/* ---------- the border vine: one long stem around the page, and everything on it appears as the growth passes ---------- */

const rim = (u: number) => {
  const W = 1000, H = 1840; let d = u * 2 * (W + H);
  if (d < W) return { x: 40 + d, y: 40, nx: 0, ny: 1 };
  d -= W; if (d < H) return { x: 1040, y: 40 + d, nx: -1, ny: 0 };
  d -= H; if (d < W) return { x: 1040 - d, y: 1880, nx: 0, ny: -1 };
  d -= W; return { x: 40, y: 1880 - d, nx: 1, ny: 0 };
};
const stem = (ph: number) => Array.from({ length: 760 }, (_, i) => {
  const r = rim(i / 759), w = 16 + 13 * Math.sin((i / 759) * 150 + ph);
  return `${i ? 'L' : 'M'}${(r.x + r.nx * w).toFixed(1)} ${(r.y + r.ny * w).toFixed(1)}`;
}).join('');
const STEMS = [stem(0), stem(Math.PI)];
const LEAF = 'M0 0C5 -11 15 -12 22 -19C23 -10 29 -5 36 0C29 5 23 10 22 19C15 12 5 11 0 0Z';
const SPRIGS = Array.from({ length: 150 }, (_, i) => {
  const u = (i + 0.5) / 150, r = rim(u), ph = hash(i * 5 + 1), w = 16 + 13 * Math.sin(u * 150 + (i % 2) * Math.PI);
  return { u, x: r.x + r.nx * w, y: r.y + r.ny * w, rot: (Math.atan2(r.ny, r.nx) * 180) / Math.PI + (ph - 0.5) * 110 + (i % 2 ? 180 : 0), kind: i % 3 === 2 ? 'bezant' : i % 7 === 3 ? 'flower' : 'leaf', c: i % 14 === 3 ? VERM : LAPIS };
});
const vineK = (t: number) => track(t, [0, 0.9, E('low'), E('quiet'), E('quiet') + 2.9], [0.012, 0.05, 0.3, 0.3, 1]);

const Vine: React.FC = () => {
  const t = useT();
  const k = vineK(t), g = goldC(t, 0.2);
  return (
    <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0 }}>
      <rect x="26" y="26" width="1028" height="1868" rx="6" fill="none" stroke={g} strokeWidth="5" /><rect x="16" y="16" width="1048" height="1888" rx="10" fill="none" stroke={VERM} strokeWidth="2.5" />
      {STEMS.map((d, i) => <path key={i} d={d} fill="none" stroke={i ? g : VERD} strokeWidth={i ? 3 : 5.5} pathLength={1} strokeDasharray={1} strokeDashoffset={1 - k} />)}
      {SPRIGS.map((s, i) => {
        const age = (k - s.u) * 90; if (age <= 0) return null; const q = back(clamp(age)); const sway = Math.sin(t * 1.4 + i) * 3;
        return (
          <g key={i} transform={`translate(${s.x.toFixed(1)} ${s.y.toFixed(1)}) rotate(${(s.rot + sway).toFixed(1)}) scale(${q.toFixed(3)})`}>
            {s.kind === 'leaf' ? <path d={LEAF} fill={VERD} stroke="#234f43" strokeWidth="2" /> : null}
            {s.kind === 'bezant' ? <><line x1="0" y1="0" x2="20" y2="0" stroke={INK} strokeWidth="1.6" /><circle cx="27" cy="0" r="8" fill={g} stroke={INK} strokeWidth="1.8" /></> : null}
            {s.kind === 'flower' ? <><line x1="0" y1="0" x2="16" y2="0" stroke={INK} strokeWidth="1.6" />{[0, 72, 144, 216, 288].map((a) => <circle key={a} cx={26 + 8 * Math.cos((a * Math.PI) / 180)} cy={8 * Math.sin((a * Math.PI) / 180)} r="5.5" fill={s.c} />)}<circle cx="26" cy="0" r="4" fill={g} /></> : null}
          </g>
        );
      })}
    </svg>
  );
};

/* ---------- the miniature: a gilded arch, and each hour paints a new scene in it ---------- */

const AW = 624, AH = 655, MX = 96, MY = 195;
const ARCH = `M0 ${AH}L0 270Q0 66 ${AW / 2} 0Q${AW} 66 ${AW} 270L${AW} ${AH}Z`;

const Diaper: React.FC<{ id: string; bg: string; line: string; dot: string; size?: number }> = ({ id, bg, line, dot, size = 52 }) => (
  <>
    <defs><pattern id={id} width={size} height={size} patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width={size} height={size} fill={bg} /><path d={`M0 0H${size}M0 0V${size}`} stroke={line} strokeWidth="2.4" /><circle cx={size / 2} cy={size / 2} r={size * 0.085} fill={dot} /></pattern></defs><rect width={AW} height={AH} fill={`url(#${id})`} />
  </>
);
const Checker: React.FC<{ id: string; y: number; a: string; b: string }> = ({ id, y, a, b }) => (
  <>
    <defs><pattern id={id} width="56" height="56" patternUnits="userSpaceOnUse"><rect width="56" height="56" fill={a} /><rect width="28" height="28" fill={b} /><rect x="28" y="28" width="28" height="28" fill={b} /></pattern></defs>
    <rect x="0" y={y} width={AW} height={AH - y} fill={`url(#${id})`} /><rect x="0" y={y} width={AW} height="5" fill={INK} />
  </>
);

const Face: React.FC<{ open: number; smile: number }> = ({ open, smile }) => (
  <g fill="none" {...O} strokeWidth={5}>
    <path d="M-52 -34Q-32 -44 -14 -30" /><path d="M14 -30Q32 -44 52 -34" />
    {open > 0.05 ? <><ellipse cx="-32" cy="-10" rx="13" ry={13 * open} fill="#fff8e2" /><ellipse cx="32" cy="-10" rx="13" ry={13 * open} fill="#fff8e2" /><circle cx="-30" cy="-9" r={5.5 * open} fill={INK} stroke="none" /><circle cx="34" cy="-9" r={5.5 * open} fill={INK} stroke="none" /></> : <><path d="M-48 -10Q-32 0 -16 -10" /><path d="M16 -10Q32 0 48 -10" /></>}
    <path d="M-2 -14Q14 16 -8 22" /><path d={`M-32 ${48 - smile * 6}Q0 ${48 - 22 + smile * 44} 32 ${48 - smile * 6}`} />
  </g>
);

const Moon: React.FC<{ x: number; y: number; r: number; open: number }> = ({ x, y, r, open }) => (
  <g transform={`translate(${x} ${y}) scale(${r / 100})`}>
    <circle r="100" fill="#ece2b6" stroke={INK} strokeWidth="5" /><path d="M28 -96A100 100 0 0 1 28 96A130 130 0 0 0 28 -96Z" fill="#c9b985" opacity="0.8" />
    <Face open={open} smile={0} />
  </g>
);

const Sun: React.FC<{ y: number; t: number; g: string }> = ({ y, t, g }) => (
  <g transform={`translate(${AW / 2} ${y})`}>
    <g transform={`rotate(${t * 13})`}>
      {Array.from({ length: 18 }, (_, i) => <path key={i} d={i % 2 ? 'M-12 -84L0 -172L12 -84Z' : 'M-11 -84C-22 -112 10 -122 0 -150C16 -122 -4 -108 11 -84Z'} transform={`rotate(${i * 20})`} fill={i % 2 ? g : VERM} stroke={INK} strokeWidth="2" />)}
    </g>
    <circle r="84" fill="#f6dc80" stroke={INK} strokeWidth="5" /><circle cx="-48" cy="20" r="13" fill={VERM} opacity="0.3" /><circle cx="48" cy="20" r="13" fill={VERM} opacity="0.3" />
    <Face open={1} smile={1} />
  </g>
);

const HOUSES: [number, number, number][] = [[40, 66, 62], [196, 72, 92], [268, 60, 60], [330, 74, 128], [408, 60, 70], [470, 66, 104], [530, 60, 62]];
/** Night at the start; at the voiceless moment the same town gets its sunrise. */
const Town: React.FC = () => {
  const t = useT();
  const q = E('quiet'); const day = ramp(t, q, q + 1.5), rise = easeOut(ramp(t, q - 0.1, q + 2.7));
  const sky = interpolateColors(day, [0, 0.45, 1], [LAPIS, '#9a4a3a', '#e6c261']); const g = goldC(t, 0.05);
  const hill = interpolateColors(day, [0, 1], ['#1c463d', '#4f9a7c']), hill2 = interpolateColors(day, [0, 1], ['#163a33', VERD]);
  const wall = interpolateColors(day, [0, 1], ['#59627f', '#efe3c9']); const set = ramp(t, q, q + 1.2);
  const peek = easeOut(ramp(t, E('returns'), E('returns') + 1.1));
  const mx = t < q + 1.2 ? 396 - set * 40 : 112, my = t < q + 1.2 ? 196 + (1 - easeOut(ramp(t, 0, 3))) * 170 + Math.sin(t * 0.8) * 5 + set * 330 : 500 - peek * 136;
  const toll = t >= q && t < E('explicit') ? Math.sin((t - q) * 4.2) * 24 * Math.exp(-(t - q) * 0.25) : 0;
  return (
    <svg viewBox={`0 0 ${AW} ${AH}`} width={AW} height={AH} style={{ position: 'absolute', left: 0, top: 0 }}>
      <rect width={AW} height={AH} fill={sky} />
      <g opacity={1 - day}>{Array.from({ length: 44 }, (_, i) => { const x = hash(i + 3) * AW, y = 30 + hash(i + 40) * 340, s = 5 + hash(i + 80) * 7, a = 0.45 + 0.55 * Math.abs(Math.sin(t * (0.7 + hash(i) * 1.3) + i)); return <path key={i} d={`M${x} ${y - s}L${x + s * 0.28} ${y - s * 0.28}L${x + s} ${y}L${x + s * 0.28} ${y + s * 0.28}L${x} ${y + s}L${x - s * 0.28} ${y + s * 0.28}L${x - s} ${y}L${x - s * 0.28} ${y - s * 0.28}Z`} fill={g} opacity={a} />; })}</g>
      <g opacity={day}><Diaper id="leaf" bg="#e6c261" line="#b48f36" dot="#f7e79f" size={44} />{Array.from({ length: 28 }, (_, i) => <line key={i} x1={AW / 2} y1={520 - rise * 315} x2={AW / 2 + Math.cos((i * Math.PI) / 14 + t * 0.2) * 900} y2={520 - rise * 315 + Math.sin((i * Math.PI) / 14 + t * 0.2) * 900} stroke="#fff3bd" strokeWidth={i % 2 ? 2 : 6} opacity={0.55} />)}</g>
      {t < q + 1.2 || peek > 0 ? <Moon x={mx} y={my} r={t < q + 1.2 ? 92 : 80} open={t < q + 1.2 ? ramp(t, E('moon') - 0.05, E('moon') + 0.1) : 1} /> : null}
      {rise > 0 ? <Sun y={520 - rise * 315} t={t} g={g} /> : null}
      <path d={`M0 470C70 410 170 420 250 468C340 410 470 392 ${AW} 462V${AH}H0Z`} fill={hill2} {...O} />
      <g {...O}>
        {HOUSES.map(([x, w, h], i) => (<g key={i}><rect x={x} y={500 - h} width={w} height={h + 40} fill={wall} /><path d={`M${x - 8} ${500 - h}L${x + w / 2} ${500 - h - 46}L${x + w + 8} ${500 - h}Z`} fill={VERM} /><path d={`M${x + w / 2 - 9} ${536 - h}v-12q9 -12 18 0v12Z`} fill={day > 0.5 ? INK : g} strokeWidth="2" /></g>))}
        <rect x="112" y="310" width="62" height="230" fill={wall} /><path d="M106 310L143 242L180 310Z" fill={LAPIS} /><path d="M124 366V332Q143 312 162 332V366Z" fill={INK} /><g transform={`rotate(${toll} 143 326)`}><path d="M134 350Q135 332 143 330Q151 332 152 350L156 356H130Z" fill={g} strokeWidth="2" /></g>
      </g>
      {t >= q ? [0, 1, 2].map((i) => { const u = ((t - q + i * 0.6) % 1.8) / 1.8; return <circle key={i} cx="143" cy="340" r={30 + u * 300} fill="none" stroke={INK} strokeWidth="2.5" opacity={(1 - u) * 0.4 * (1 - ramp(t, E('explicit'), E('explicit') + 0.5))} />; }) : null}
      <path d={`M0 ${AH}V540C110 500 220 520 330 556C430 520 540 506 ${AW} 548V${AH}Z`} fill={hill} {...O} />
      {[[60, 548], [150, 540], [500, 546], [572, 540]].map(([x, y], i) => <g key={i} {...O}><rect x={x - 4} y={y} width="8" height="34" fill="#5a3a20" /><circle cx={x} cy={y - 14} r="24" fill={hill2} /></g>)}
      <path d="M312 655C300 620 350 600 330 562" fill="none" stroke="#e8d7a8" strokeWidth="16" opacity="0.8" />
    </svg>
  );
};

/** The first hour: a horn comes through the wall, and the blanket wins. */
const BedRoom: React.FC = () => {
  const t = useT();
  const h = E('horn'), r = E('rise'); const inK = easeOut(ramp(t, h - 0.3, h - 0.02));
  const blast = t >= h ? Math.exp(-(t - h) * 0.9) : 0; const under = easeInOut(ramp(t, r - 0.2, r + 0.4));
  const quake = t >= h && t < h + 0.8 ? Math.sin((t - h) * 50) * 4 * (1 - (t - h) / 0.8) : 0;
  const hump = 66 - under * 22 + Math.sin(t * 2.6) * 5 * (1 - under * 0.6); const g = goldC(t);
  return (
    <svg viewBox={`0 0 ${AW} ${AH}`} width={AW} height={AH} style={{ position: 'absolute', left: 0, top: 0 }}>
      <Diaper id="room" bg={VERM} line="#8e2212" dot={g} /><Checker id="tileA" y={560} a="#e6d7b0" b={LAPIS} />
      <g {...O}>
        <path d="M170 94Q312 30 454 94V240H170Z" fill={LAPIS} /><path d="M268 60V240M356 60V240" fill="none" /><circle cx="400" cy={150 + (t - E('first')) * 7} r="34" fill="#ece2b6" /><path d="M170 94Q312 30 454 94V240H170Z" fill="none" strokeWidth="7" stroke={g} />
        {/* the bed */}
        <path d="M34 214C60 280 66 360 58 470H34Z" fill={VERD} /><path d={`M24 150H262V196${[0, 1, 2, 3, 4, 5, 6].map(() => 'q-17 22 -34 0').join('')}Z`} fill={VERD} /><path d="M24 150H262" fill="none" stroke={g} strokeWidth="9" />
        <rect x="34" y="316" width="30" height="244" fill="#5a3a20" /><rect x="548" y="396" width="28" height="164" fill="#5a3a20" /><rect x="44" y="470" width="520" height="52" fill="#7b5230" /><rect x="70" y="520" width="20" height="40" fill="#5a3a20" /><rect x="520" y="520" width="20" height="40" fill="#5a3a20" />
        <g transform={`translate(${quake} 0)`}>
          <path d="M64 470C60 416 104 410 150 420C190 428 190 470 170 474Z" fill="#fbf6e6" />
          <g transform={`translate(${-under * 56} 0)`}><path d="M516 470C526 438 546 432 558 438C566 444 560 470 550 472Z" fill="#e7b48f" />{[0, 1, 2].map((i) => <circle key={i} cx={551 + i * 4} cy={440 + i * 7} r="4" fill="#e7b48f" />)}</g>
          <g transform={`translate(${under * 26} ${under * 18})`}><path d="M96 420C92 396 118 386 138 392C158 398 158 420 150 428Z" fill="#6b4524" />{[0, 1, 2].map((i) => <path key={i} d={`M${108 + i * 12} 396q-4 -12 4 -18`} fill="none" stroke="#6b4524" strokeWidth="5" />)}</g>
          <path d={`M${132 - under * 70} 474C${124 - under * 70} ${414 - hump * 0.4} ${200 - under * 40} ${412 - hump} 300 ${410 - hump * 0.9}C400 ${408 - hump * 0.7} 470 ${424 - hump * 0.3} 524 474Z`} fill={LAPIS} />
          {Array.from({ length: 16 }, (_, i) => <circle key={i} cx={170 + (i % 8) * 44} cy={438 - (i > 7 ? 34 : 0) + (i % 3) * 8 - hump * (i > 7 ? 0.45 : 0.12)} r="4.5" fill={g} stroke="none" />)}
        </g>
        {/* the horn, through the wall */}
        <g transform={`translate(${(1 - inK) * 420} ${quake * 0.3})`}>
          <path d="M640 262L372 236C340 226 312 200 292 190C284 222 286 268 296 292C316 282 344 266 372 254L640 286Z" fill={g} /><ellipse cx="293" cy="241" rx="9" ry="51" fill="#6b5518" />
          <path d="M520 268L500 340L470 318L440 342L430 258Z" fill={VERM} /><circle cx="616" cy="286" r="20" fill="#e7b48f" />
        </g>
        {blast > 0.04 ? Array.from({ length: 7 }, (_, i) => { const a = ((122 + i * 20) * Math.PI) / 180, u = ((t - h) * 2.4 + i * 0.37) % 1, r0 = 30 + u * 60, r1 = r0 + 50, d = `M${284 + Math.cos(a) * r0} ${241 + Math.sin(a) * r0}L${284 + Math.cos(a) * r1} ${241 + Math.sin(a) * r1}`; return <g key={i} opacity={blast}><path d={d} stroke={INK} strokeWidth="13" /><path d={d} stroke="#fff3bd" strokeWidth="8" /></g>; }) : null}
      </g>
    </svg>
  );
};

/** The second hour: the great meeting. Everyone rises for the wrong word and drops when it is scraped off. */
const TEAM: { x: number; c: string; lean: number; mark: string }[] = [
  { x: 72, c: VERM, lean: -1, mark: 'M-22 0L0 24L22 0L22 12L0 36L-22 12Z' },
  { x: 192, c: VERD, lean: 1, mark: 'M-26 -10H26V6H-26Z' },
  { x: 312, c: LAPIS, lean: -1, mark: 'M0 -14L16 8L0 30L-16 8Z' },
  { x: 432, c: '#6b4524', lean: 1, mark: 'M-8 -14H8V34H-8Z' },
  { x: 552, c: '#7a3a6a', lean: -1, mark: 'M-20 -10A9 9 0 1 0 -20 -9ZM20 -10A9 9 0 1 0 20 -9ZM0 16A9 9 0 1 0 0 17Z' },
];
const Hall: React.FC = () => {
  const t = useT();
  const hope = easeOut(ramp(t, E('friday') - 0.05, E('friday') + 0.2)) * (1 - easeIn(ramp(t, E('scrape'), E('scrape') + 0.16)));
  const g = goldC(t); const sand = ramp(t, E('forth'), E('low'));
  return (
    <>
      <svg viewBox={`0 0 ${AW} ${AH}`} width={AW} height={AH} style={{ position: 'absolute', left: 0, top: 0 }}>
        <Diaper id="hallLeaf" bg={g} line="#9a7720" dot="#f4e39a" size={44} /><Checker id="tileB" y={570} a="#5b2e20" b="#e6d7b0" />
        <g {...O}><path d="M112 134L92 120L112 106L132 120V168L112 180Z" fill={VERM} /><path d="M512 134L532 120L512 106L492 120V168L512 180Z" fill={VERM} /><rect x="112" y="104" width="400" height="64" fill="#f6edd2" /></g>
        {TEAM.map((k, i) => {
          const come = back(ramp(t, E('forth') + i * 0.11, E('forth') + 0.32 + i * 0.11));
          const bored = ramp(t, E('meeting') + i * 0.2, E('meeting') + 0.8 + i * 0.2);
          const drop = easeIn(ramp(t, E('scrape') + i * 0.06, E('scrape') + 0.2 + i * 0.06));
          const y = 292 + (1 - come) * 150 - hope * 34 + drop * 70 + bored * 12 * (1 - hope);
          const tilt = k.lean * (bored * 8 * (1 - hope) + drop * 22) + Math.sin(t * 1.6 + i * 2) * 1.6 * (1 - drop);
          const sh: P = [k.x - 44, y + 62], sh2: P = [k.x + 44, y + 62]; const hand = mix([k.x - 58, 424], [k.x - 76, y - 40], hope), hand2 = mix([k.x + 58, 424], [k.x + 76, y - 40], hope);
          return (
            <g key={i} {...O}>
              <path d={`M${k.x - 60} 470C${k.x - 58} ${y + 80} ${k.x - 38} ${y + 40} ${k.x} ${y + 40}C${k.x + 38} ${y + 40} ${k.x + 58} ${y + 80} ${k.x + 60} 470Z`} fill={k.c} />
              <path d={`M${sh[0]} ${sh[1]}L${hand[0]} ${hand[1]}`} stroke={INK} strokeWidth="24" /><path d={`M${sh[0]} ${sh[1]}L${hand[0]} ${hand[1]}`} stroke={k.c} strokeWidth="18" />
              <path d={`M${sh2[0]} ${sh2[1]}L${hand2[0]} ${hand2[1]}`} stroke={INK} strokeWidth="24" /><path d={`M${sh2[0]} ${sh2[1]}L${hand2[0]} ${hand2[1]}`} stroke={k.c} strokeWidth="18" />
              <circle cx={hand[0]} cy={hand[1]} r="14" fill={STEEL} /><circle cx={hand2[0]} cy={hand2[1]} r="14" fill={STEEL} />
              <g transform={`translate(${k.x} ${y + 34}) rotate(${tilt})`}>
                <path d="M4 -86C10 -128 44 -134 56 -112C40 -116 28 -106 22 -84Z" fill={k.c} />
                <path d="M-38 -84Q0 -94 38 -84L42 -6Q0 10 -42 -6Z" fill={STEEL} /><path d="M-38 -84Q0 -94 38 -84L39 -68Q0 -78 -39 -68Z" fill={g} /><path d="M-30 -44H30" stroke={INK} strokeWidth="8" />
                {[-16, 0, 16].map((d) => <circle key={d} cx={d} cy="-18" r="2.6" fill={INK} stroke="none" />)}
              </g>
            </g>
          );
        })}
        <g {...O}>
          <rect x="-10" y="436" width={AW + 20} height="124" fill="#fbf6e6" /><rect x="-10" y="436" width={AW + 20} height="14" fill={VERM} />{[110, 240, 384, 514].map((x) => <path key={x} d={`M${x} 450V560`} fill="none" strokeWidth="1.5" opacity="0.5" />)}
          {TEAM.map((k, i) => <g key={i} transform={`translate(${k.x} 500) scale(.95)`}><path d="M-36 -36H36C36 -4 20 22 0 38C-20 22 -36 -4 -36 -36Z" fill={k.c} /><path d={k.mark} fill={g} strokeWidth="2" /></g>)}
          {/* the hourglass runs the whole meeting */}
          <g transform="translate(250 330)"><path d="M0 0H46L24 52L46 106H0L22 52Z" fill="#f6edd2" fillOpacity="0.85" strokeWidth="3.5" /><path d={`M${6 + sand * 12} ${8 + sand * 28}H${40 - sand * 12}L24 52H22Z`} fill={VERM} stroke="none" /><path d={`M${22 - sand * 18} 102H${24 + sand * 18}L23 ${102 - sand * 36}Z`} fill={VERM} stroke="none" /><rect x="-6" y="-6" width="58" height="8" fill="#5a3a20" /><rect x="-6" y="104" width="58" height="8" fill="#5a3a20" /></g>
        </g>
      </svg>
      <div style={{ position: 'absolute', left: 112, top: 112, width: 400, textAlign: 'center', font: `italic 42px/48px ${FELL}`, color: RUST, whiteSpace: 'nowrap' }}>{AGENDA}</div>
    </>
  );
};

/* The inbox: a heap of scrolls that grows, keeps arriving in the dark, then starts going the other way, one at a time. */
const TAKES = [0, 0.95, 1.8, 2.5, 3.2, 3.9].map((d) => E('small') + 0.1 + d);
const SCROLLS = (() => {
  const out: { x: number; y: number; r: number }[] = [];
  [5, 5, 4, 4, 4, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1].forEach((n, row) => {
    for (let c = 0; c < n; c++) out.push({ x: 448 - row * 6 + (c - (n - 1) / 2) * 76 + (hash(row * 9 + c) - 0.5) * 10, y: 536 - row * 26, r: (hash(row * 5 + c * 3) - 0.5) * 7 });
  });
  return out;
})();
const pileAt = (t: number) => track(t, [E('low'), E('noend'), E('small')], [11, 42, 54]) - 3 * TAKES.filter((x) => t >= x).length;
const ScrollG: React.FC<{ x: number; y: number; r: number }> = ({ x, y, r }) => (
  <g transform={`translate(${x} ${y}) rotate(${r})`} {...O} strokeWidth={2.4}>
    <rect x="-38" y="-14" width="76" height="28" rx="14" fill="#f2e7c8" /><rect x="-5" y="-14" width="10" height="28" fill={VERM} /><circle cx="30" cy="0" r="12" fill="#ddcda2" /><path d="M30 0a4 4 0 1 1 -4 -3" fill="none" strokeWidth="1.8" />
  </g>
);
const Scriptorium: React.FC = () => {
  const t = useT(); const g = goldC(t);
  const n = pileAt(t), whole = Math.floor(n), frac = n - whole;
  const droop = ramp(t, E('inbox'), E('noend') + 0.3) * (1 - easeOut(ramp(t, E('small'), E('small') + 0.4)));
  const write = Math.sin(t * 13) * 3 * (1 - droop);
  const book = ramp(t, E('books') - 0.1, E('books') + 0.3) * (1 - ramp(t, E('small') + 0.2, E('small') + 0.9));
  const done = TAKES.filter((x) => t >= x + 0.45).length; const flying = TAKES.find((x) => t >= x && t < x + 0.45);
  return (
    <>
      <svg viewBox={`0 0 ${AW} ${AH}`} width={AW} height={AH} style={{ position: 'absolute', left: 0, top: 0 }}>
        <Diaper id="green" bg={VERD} line="#2f6456" dot={g} /><Checker id="tileC" y={566} a="#6d4a2b" b="#d8c8a0" />
        <g {...O}>
          {/* a window with the moon in it: the one light left when the candle fails */}<path d="M136 268V176Q186 112 236 176V268Z" fill="#17244f" strokeWidth="6" stroke={g} /><circle cx="190" cy="196" r="30" fill="#f2e9bf" /><circle cx="202" cy="190" r="26" fill="#17244f" />
          {/* the crate */}<rect x="318" y="552" width="262" height="86" fill="#7b5230" /><path d="M318 576H580" fill="none" />
          {SCROLLS.slice(0, whole).map((s, i) => <ScrollG key={i} {...s} />)}
          {whole < SCROLLS.length && frac > 0.02 ? <ScrollG x={SCROLLS[whole].x} y={lerp(-40, SCROLLS[whole].y, easeIn(frac))} r={frac * 90} /> : null}
          {/* the desk and the book */}<path d="M162 442L300 402L306 416L168 458Z" fill="#7b5230" /><rect x="186" y="452" width="16" height="118" fill="#5a3a20" /><rect x="272" y="424" width="16" height="146" fill="#5a3a20" /><path d="M196 424L238 402L280 398L282 410L240 414L200 438Z" fill="#fbf6e6" />
          {book > 0 ? Array.from({ length: 9 }, (_, i) => <line key={i} x1="240" y1="400" x2={240 + Math.cos(-Math.PI * (0.1 + i * 0.1)) * (40 + book * 70)} y2={400 + Math.sin(-Math.PI * (0.1 + i * 0.1)) * (40 + book * 70)} stroke="#f7dd7e" strokeWidth="4" opacity={book} />) : null}
          {/* the scribe, tired and left unmocked */}<rect x="62" y="500" width="84" height="70" fill="#5a3a20" /><path d="M42 520C34 450 44 376 80 344C108 330 140 354 150 412L162 520Z" fill="#5d3a4e" />
          <g transform={`rotate(${droop * 24} 96 350)`}><path d="M52 340C44 278 96 250 128 280C150 304 150 346 128 358C96 370 62 364 52 340Z" fill="#6f4760" /><ellipse cx="124" cy="322" rx="15" ry="21" fill="#e7b48f" /><path d="M120 316h9M136 322l5 8l-5 1" fill="none" strokeWidth="2.4" /></g>
          <path d={`M116 392L${206 + write} ${414 - droop * 10 + write}`} stroke={INK} strokeWidth="24" /><path d={`M116 392L${206 + write} ${414 - droop * 10 + write}`} stroke="#5d3a4e" strokeWidth="18" />
          <circle cx={208 + write} cy={414 - droop * 10 + write} r="9" fill="#e7b48f" /><path d={`M${206 + write} ${416 - droop * 10 + write}L${238 + write} ${368 - droop * 10 + write}`} stroke="#fbf6e6" strokeWidth="5" fill="none" />
          {/* done */}<rect x="176" y="584" width="124" height="56" fill="#7b5230" />
          {Array.from({ length: done }, (_, i) => <ScrollG key={i} x={208 + (i % 3) * 30} y={572 - Math.floor(i / 3) * 22} r={(i % 2) * 5} />)}
          {flying !== undefined ? <ScrollG x={lerp(430, 238, easeInOut(ramp(t, flying, flying + 0.45)))} y={lerp(300, 572, easeInOut(ramp(t, flying, flying + 0.45))) - Math.sin(ramp(t, flying, flying + 0.45) * Math.PI) * 90} r={ramp(t, flying, flying + 0.45) * 180} /> : null}
        </g>
      </svg>
      <div style={{ position: 'absolute', left: 318, top: 576, width: 262, textAlign: 'center', font: `52px/60px ${BLACK}`, color: '#f2e7c8' }}>inbox</div><div style={{ position: 'absolute', left: 176, top: 594, width: 124, textAlign: 'center', font: `36px/42px ${BLACK}`, color: '#f2e7c8' }}>done</div>
    </>
  );
};

const brushEdge = (k: number, seed: number) => {
  if (k >= 1) return undefined;
  const pts = Array.from({ length: 15 }, (_, i) => `${(k * 118 - 9 + (hash(i * 3 + seed) - 0.5) * 16).toFixed(1)}% ${(i / 14) * 100}%`);
  return `polygon(0 0,${pts.join(',')},0 100%)`;
};
const SCENES: [string, React.FC][] = [['on', Town], ['first', BedRoom], ['second', Hall], ['low', Scriptorium], ['quiet', Town]];

const Miniature: React.FC = () => {
  const t = useT(); let i = 0;
  SCENES.forEach(([e], k) => { if (t >= E(e)) i = k; });
  const k = ramp(t, E(SCENES[i][0]), E(SCENES[i][0]) + 0.42);
  const Now = SCENES[i][1], Prev = i > 0 && k < 1 ? SCENES[i - 1][1] : null;
  const g = goldC(t), draw = easeOut(ramp(t, 0, 1.3));
  return (
    <>
      <div style={{ position: 'absolute', left: MX, top: MY, width: AW, height: AH, clipPath: `path('${ARCH}')`, background: LAPIS }}>
        {Prev && i !== 0 ? <Prev /> : null}
        <div style={{ position: 'absolute', inset: 0, clipPath: i === 0 ? undefined : brushEdge(k, i * 7) }}><Now /></div>
      </div>
      <svg viewBox={`-16 -16 ${AW + 32} ${AH + 32}`} width={AW + 32} height={AH + 32} style={{ position: 'absolute', left: MX - 16, top: MY - 16 }}>
        <path d={`M-12 -12H${AW + 12}V${AH + 12}H-12Z${ARCH}`} fillRule="evenodd" fill={LAPIS} />{[[46, 46], [AW - 46, 46], [20, 104], [AW - 20, 104], [104, 22], [AW - 104, 22]].map(([x, y], j) => <g key={j} transform={`translate(${x} ${y})`}><circle r="8" fill={g} /><circle r="15" fill="none" stroke={g} strokeWidth="2" strokeDasharray="2 5" /></g>)}
        <rect x="-12" y="-12" width={AW + 24} height={AH + 24} fill="none" stroke={g} strokeWidth="6" />
        <path d={ARCH} fill="none" stroke={VERM} strokeWidth="17" /><path d={ARCH} fill="none" stroke={g} strokeWidth="9" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - draw} /><path d={ARCH} fill="none" stroke="#fff3bd" strokeWidth="4" strokeLinecap="round" strokeDasharray="0 15" opacity={goldAt(t) * 0.9} />
      </svg>
    </>
  );
};

/* ---------- the candle ---------- */

const DRIPS: [number, number, number][] = [[-20, 410, 34], [18, 505, 26], [-14, 610, 40], [21, 690, 22], [-21, 742, 18]];
const Candle: React.FC = () => {
  const t = useT();
  const y = flameY(t), g = goldC(t, 0.1);
  let now = -1; RINGS.forEach(([ry], i) => { if (y >= ry - 2) now = i; });
  return (
    <>
      <svg viewBox="760 200 240 660" width={240} height={660} style={{ position: 'absolute', left: 760, top: 200 }}>
        <g {...O}>
          <rect x={CX - 23} y={y} width="46" height={DISH - y} fill={WAX} /><rect x={CX + 7} y={y + 2} width="15" height={DISH - y - 4} fill="#d6c594" stroke="none" />
          {RINGS.map(([ry], i) => (ry > y + 3 ? <rect key={i} x={CX - 23} y={ry} width="46" height="7" fill={VERM} strokeWidth="1.5" /> : null))}
          {DRIPS.map(([dx, dy, l], i) => (dy > y + 6 ? <path key={i} d={`M${CX + dx - 5} ${dy}v${l}a5 5 0 0 0 10 0v-${l}Z`} fill={WAX} strokeWidth="2" /> : null))}
          <ellipse cx={CX} cy={y} rx="23" ry="7" fill="#f7efd3" /><path d={`M${CX - 68} ${DISH - 6}Q${CX} ${DISH + 34} ${CX + 68} ${DISH - 6}Z`} fill={g} /><rect x={CX - 6} y={DISH + 14} width="12" height="24" fill={g} /><path d={`M${CX - 40} ${DISH + 40}Q${CX} ${DISH + 26} ${CX + 40} ${DISH + 40}Z`} fill={g} />
        </g>
      </svg>
      {RINGS.map(([ry, label], i) => <div key={i} style={{ position: 'absolute', left: CX + 40, top: ry - 16, width: 70, font: `${i === now ? 'italic 54px' : 'italic 44px'}/40px ${FELL}`, color: i === now ? VERM : RUST, opacity: i === now ? 1 : 0.75 }}>{label}</div>)}
    </>
  );
};
/* The flame draws above the dark, so it still reads when the page has gone. */
const Flame: React.FC = () => {
  const t = useT();
  const y = flameY(t), lit = litAt(t);
  const sway = flicker(t) * 5 * lit + (ramp(t, E('candle') - 0.1, E('fails')) * (1 - ramp(t, E('fails') + 0.95, E('small')))) * 8;
  const h = 72 * lit * (1 + 0.1 * flicker(t * 1.3));
  const smoke = ramp(t, E('fails') + 0.7, E('fails') + 1.1) * (1 - ramp(t, E('small') - 0.3, E('small')));
  const spark = t >= E('yet') && t < E('small') ? 0.3 + 0.7 * ramp(t, E('books'), E('small')) : 0;
  return (
    <svg viewBox="660 0 440 900" width={440} height={900} style={{ position: 'absolute', left: 660, top: 0, overflow: 'visible' }}>
      <defs><radialGradient id="glow"><stop offset="0" stopColor="#ffe9a8" stopOpacity="0.85" /><stop offset="0.3" stopColor="#ffc460" stopOpacity="0.32" /><stop offset="1" stopColor="#ffb040" stopOpacity="0" /></radialGradient><linearGradient id="fire" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#ffd25a" /><stop offset="0.55" stopColor="#ffb22e" /><stop offset="0.85" stopColor="#fff2c4" /><stop offset="1" stopColor="#5a84d8" /></linearGradient></defs>
      {lit > 0 ? <circle cx={CX} cy={y - 34} r={150 * lit + 8 * flicker(t)} fill="url(#glow)" /> : null}
      <path d={`M${CX} ${y}V${y - 13}`} stroke={lit > 0.02 || spark > 0 ? '#ff7a2c' : '#1a120c'} strokeWidth="3.5" strokeLinecap="round" />
      {spark > 0 ? <circle cx={CX} cy={y - 13} r={3 + 7 * spark + flicker(t) * 2} fill="#ffc25a" opacity={0.6 + 0.4 * spark} /> : null}
      {lit > 0.02 ? <path d={`M${CX} ${y - 4}C${CX - 19 * lit} ${y - h * 0.22} ${CX - 16 * lit + sway * 0.3} ${y - h * 0.55} ${CX + sway} ${y - h}C${CX + 16 * lit + sway * 0.3} ${y - h * 0.55} ${CX + 19 * lit} ${y - h * 0.22} ${CX} ${y - 4}Z`} fill="url(#fire)" /> : null}
      {smoke > 0 ? [0, 1].map((i) => <path key={i} d={`M${CX} ${y - 13}C${CX + 22 * Math.sin(t * 2 + i)} ${y - 60} ${CX - 28 * Math.sin(t * 1.5 + i)} ${y - 110} ${CX + 18 * Math.sin(t * 1.1 + i * 2)} ${y - 190}`} fill="none" stroke="#c9c4b8" strokeWidth={4 - i * 1.6} opacity={0.5 * smoke} strokeLinecap="round" />) : null}
    </svg>
  );
};

/** When the candle is out, the miniature's window is the only light on the page. */
const Moonlight: React.FC = () => {
  const t = useT();
  const k = (1 - litAt(t)) * (t >= E('low') && t < E('quiet') ? 1 : 0);
  if (k < 0.02) return null;
  return (
    <div style={{ position: 'absolute', left: MX, top: MY, width: AW, height: AH, clipPath: `path('${ARCH}')` }}>
      <svg viewBox={`0 0 ${AW} ${AH}`} width={AW} height={AH} style={{ position: 'absolute', left: 0, top: 0, opacity: k }}>
        <defs><linearGradient id="beam" x1="0" y1="0" x2="0.5" y2="1"><stop offset="0" stopColor="#c6d6ff" stopOpacity="0.42" /><stop offset="1" stopColor="#c6d6ff" stopOpacity="0.04" /></linearGradient></defs>
        <path d="M140 266H232L590 566H130Z" fill="url(#beam)" /><path d="M136 268V176Q186 112 236 176V268Z" fill="#26386e" /><circle cx="190" cy="196" r="30" fill="#f6efc9" /><circle cx="202" cy="190" r="26" fill="#26386e" />
      </svg>
    </div>
  );
};

/* ---------- the page's headings and notes ---------- */

/** A wipe opens past its box when it ends, or a letter's overhang stays cut off. */
const wipe = (k: number) => (k >= 1 ? undefined : `inset(-30% ${(1 - k) * 100}% -30% -5%)`);

/** The scribe's mistake: the wrong word is written, scraped off with a knife, and the right one goes in small over the scar. */
const Mistake: React.FC = () => {
  const t = useT();
  const at = E('wrote'); if (t < at) return null;
  const write = ramp(t, at, at + 0.42), sc = ramp(t, E('scrape'), E('scrape') + 0.72), fix = ramp(t, E('scrape') + 0.78, E('scrape') + 1.2);
  return (
    <span style={{ display: 'inline-block', clipPath: wipe(write) }}>&nbsp;of&nbsp;<span style={{ position: 'relative', display: 'inline-block' }}>
        <span style={{ display: 'inline-block', clipPath: `inset(-30% -5% -30% ${sc * 105}%)`, textShadow: t < E('scrape') ? `0 0 ${10 * goldAt(t)}px rgba(230,180,60,.8)` : undefined }}>{WRONG}</span>
        {sc > 0 ? <span style={{ position: 'absolute', left: -6, right: -8, top: 10, bottom: 4, background: 'repeating-linear-gradient(97deg,#f6edd2 0 5px,#ead9b2 5px 8px,#f3e6c6 8px 15px)', borderRadius: 8, clipPath: `inset(0 ${(1 - sc) * 100}% 0 0)` }} /> : null}
        {sc > 0 ? Array.from({ length: 16 }, (_, i) => {
          const born = E('scrape') + (i / 16) * 0.72, u = t - born; if (u < 0 || u > 0.9) return null;
          return <span key={i} style={{ position: 'absolute', left: `${(i / 16) * 100}%`, top: 58 + u * u * 240, width: 12, height: 5, borderRadius: 4, background: i % 3 ? VERM : '#f6edd2', transform: `rotate(${u * 500 + i * 60}deg) translateX(${(hash(i) - 0.5) * 50}px)`, opacity: 1 - ramp(u, 0.6, 0.9) }} />;
        }) : null}
        {sc > 0 && sc < 1 ? <svg viewBox="0 0 170 70" width={170} height={70} style={{ position: 'absolute', left: `calc(${sc * 100}% - 12px)`, top: 14 + Math.sin(t * 60) * 7, transform: 'rotate(-14deg)', transformOrigin: '0 50%', overflow: 'visible' }}><path d="M0 34L50 18L66 28L66 42Z" fill={STEEL} {...O} /><rect x="62" y="24" width="104" height="22" rx="6" fill="#6b4524" {...O} /></svg> : null}
        {fix > 0 ? <span style={{ position: 'absolute', left: 0, right: 0, top: 16, textAlign: 'center', font: `italic 60px/64px ${FELL}`, color: INK, clipPath: wipe(fix) }}>{RIGHT}</span> : null}
      </span>
    </span>
  );
};

const Rubric: React.FC = () => {
  const t = useT();
  let i = 0; RUBRICS.forEach(([e], k) => { if (t >= E(e)) i = k; });
  const [e, text] = RUBRICS[i];
  return (
    <>
      <div style={{ position: 'absolute', left: 96, top: 862, height: 92, whiteSpace: 'nowrap', font: text === text.toUpperCase() ? `52px/92px ${UNCIAL}` : `72px/92px ${BLACK}`, color: VERM, clipPath: e === 'on' ? undefined : wipe(ramp(t, E(e), E(e) + 0.36)) }}>
        {text}{e === 'err' ? <Mistake /> : null}
      </div>
    </>
  );
};

const HAND = 'M0 8H16V40H0ZM16 12C26 6 34 4 44 6L84 4C92 4 92 14 84 14L52 15C58 17 58 23 52 24C56 26 56 32 50 33C52 36 50 41 44 41H30C24 41 20 38 16 34Z';
const Glosses: React.FC = () => {
  const t = useT();
  return (
    <>
      {GLOSSES.map((g, i) => {
        const a = E(g.from[0]) + g.from[1], b = E(g.until[0]) + g.until[1]; if (t < a || t > b + 0.3) return null;
        return (
          <div key={i} style={{ position: 'absolute', left: g.x, top: g.y, whiteSpace: 'nowrap', font: `italic 44px/48px ${FELL}`, color: RUST, opacity: 1 - ramp(t, b, b + 0.3), clipPath: wipe(ramp(t, a, a + 0.42)), display: 'flex', alignItems: 'center', gap: 12 }}>
            {g.hand ? <svg viewBox="0 0 92 46" width={72} height={36}><path d={HAND} fill="#f2e3bd" {...O} strokeWidth={2.6} /></svg> : null}
            {g.text}
          </div>
        );
      })}
    </>
  );
};

/* ---------- the margin: a knight, a snail, and what the two of them come to ---------- */

const Ink: React.FC<{ d: string; k: number; fill?: string; w?: number; c?: string }> = ({ d, k, fill = 'none', w = 3.2, c = INK }) =>
  k <= 0 ? null : <path d={d} fill={fill} fillOpacity={ramp(k, 0.55, 1)} stroke={c} strokeWidth={w} strokeLinecap="round" strokeLinejoin="round" pathLength={k >= 1 ? undefined : 1} strokeDasharray={k >= 1 ? undefined : 1} strokeDashoffset={k >= 1 ? undefined : 1 - k} />;

const SWORD = 'M0 -5L120 -3L132 0L120 3L0 5Z';
const Knight: React.FC<{ x: number; y: number; face: number; kneel: number; phase: number; arm: number; sword: boolean; ink: number; g: string }> = ({ x, y, face, kneel, phase, arm, sword, ink, g }) => {
  const S = Math.sin(phase); const hip = mix([0, -104], [-8, -64], kneel);
  const k1 = mix([8 + S * 14, -54], [32, -60], kneel), f1 = mix([10 + S * 30, 0], [36, 0], kneel);
  const k2 = mix([-8 - S * 14, -54], [-36, -8], kneel), f2 = mix([-10 - S * 30, 0], [-82, -4], kneel);
  const leg = (kn: P, ft: P) => `M${hip[0]} ${hip[1]}L${kn[0]} ${kn[1]}L${ft[0]} ${ft[1]}`;
  const sh: P = [hip[0] + 12, hip[1] - 64]; const rad = (arm * Math.PI) / 180; const hand: P = [sh[0] + Math.cos(rad) * 52, sh[1] + Math.sin(rad) * 52];
  return (
    <g transform={`translate(${x} ${y}) scale(${face} 1)`}>
      <Ink d={leg(k2, f2)} k={ink} w={22} c={INK} /><Ink d={leg(k2, f2)} k={ink} w={16} c={STEEL} />
      <Ink d={leg(k1, f1)} k={ink} w={22} c={INK} /><Ink d={leg(k1, f1)} k={ink} w={16} c={STEEL} />
      <g transform={`translate(${hip[0]} ${hip[1]})`}>
        <Ink d="M-28 8L-22 -60Q2 -76 26 -60L32 8L42 34L-40 34Z" k={ink} fill={LAPIS} />
        <Ink d="M-24 2H28" k={ink} w={7} c={g} /><Ink d="M-6 -54L14 -54L14 -14L4 -4L-6 -14Z" k={ink} fill={g} w={2.4} />
        {ink > 0.7 ? <path d="M-38 -44H22V-8C22 18 6 36 -8 48C-24 36 -38 18 -38 -8Z" transform="translate(-14 2) scale(.82)" fill={VERM} stroke={INK} strokeWidth="3.4" opacity={ramp(ink, 0.7, 1)} /> : null}
        <g transform="translate(4 -92)">
          <Ink d="M-4 -40C-8 -92 -66 -92 -74 -46C-54 -66 -28 -60 -16 -38Z" k={ink} fill={VERM} />
          <Ink d="M-26 -30Q0 -44 26 -32L30 20Q0 30 -26 22Z" k={ink} fill={STEEL} />
          <Ink d="M0 -12L30 -12" k={ink} w={7} /><Ink d="M-26 -30Q0 -44 26 -32L27 -22Q0 -34 -26 -20Z" k={ink} fill={g} w={2} />
        </g>
      </g>
      <Ink d={`M${sh[0]} ${sh[1]}L${hand[0]} ${hand[1]}`} k={ink} w={20} /><Ink d={`M${sh[0]} ${sh[1]}L${hand[0]} ${hand[1]}`} k={ink} w={14} c={LAPIS} />
      {sword ? <g transform={`translate(${hand[0]} ${hand[1]}) rotate(${arm - 25})`}><Ink d={SWORD} k={ink} fill="#d9dde2" w={2.4} /><Ink d="M-2 -18V18" k={ink} w={7} c={g} /></g> : null}
      {ink > 0.8 ? <circle cx={hand[0]} cy={hand[1]} r="11" fill={STEEL} stroke={INK} strokeWidth="3" /> : null}
    </g>
  );
};

const SnailG: React.FC<{ x: number; y: number; t: number; g: string; s?: number }> = ({ x, y, t, g, s = 1 }) => {
  const a = Math.sin(t * 2.3) * 5, b = Math.sin(t * 1.9 + 1) * 6, sq = 1 + Math.sin(t * 3) * 0.02;
  return (
    <g transform={`translate(${x} ${y}) scale(${s * sq} ${s / sq})`} {...O}>
      <path d={`M112 -60C118 -80 ${122 + a} -92 ${128 + a} -108`} fill="none" strokeWidth="5" /><circle cx={128 + a} cy="-110" r="7" fill={INK} /><path d={`M94 -58C92 -80 ${90 + b} -92 ${90 + b} -112`} fill="none" strokeWidth="5" /><circle cx={90 + b} cy="-114" r="7" fill={INK} />
      <path d="M-112 0C-60 -6 40 -8 86 -4C110 -2 126 -18 126 -44C126 -68 100 -76 86 -58C74 -40 60 -24 40 -22L-70 -18C-92 -14 -104 -8 -112 0Z" fill="#cfd3a4" />
      <circle cx="-12" cy="-68" r="60" fill={g} /><path d="M-12 -68m0 -12c14 -2 24 10 20 24c-6 20 -36 20 -46 -2c-10 -28 14 -54 44 -46c30 10 36 44 22 68" fill="none" strokeWidth="4.5" />
    </g>
  );
};

const snailX = (t: number) => track(t, [0, E('margin'), E('quiet'), E('cta'), DURATION], [116, 574, 636, 780, 790]);
const chop = (t: number, h: number) => (t < h - 0.14 ? 0 : t < h ? ramp(t, h - 0.14, h) : 1 - ramp(t, h, h + 0.22));
const GROUND = 1206, SPARK = 'M0 -44L8 -8L44 0L8 8L0 44L-8 8L-44 0L-8 -8Z';
const SCROLLWORK = 'M84 1180C130 1090 210 1040 290 1070C350 1094 350 1170 290 1170C250 1170 246 1116 286 1116M290 1070C380 1010 470 1030 530 1090C580 1140 540 1190 490 1170C456 1154 470 1110 506 1116M530 1090C610 1010 700 1020 770 1080C830 1130 790 1190 740 1170C706 1154 720 1110 756 1116M770 1080C830 1040 900 1040 950 1090';

const Margin: React.FC = () => {
  const t = useT(); const g = goldC(t, 0.3);
  const sx = snailX(t); const ink = ramp(t, E('knight') - 0.25, E('knight') + 0.95);
  const u = ramp(t, E('around'), E('around') + 1.05); const walkOn = t >= E('around') && t < E('quiet') + 2.1;
  const kx = track(t, [E('knight'), E('battle') - 0.14, E('battle'), E('losing'), E('losing') + 0.3, E('around'), E('around') + 1.05, E('quiet') + 2.1], [830, 830, 820, 820, 846, 846, 424, 172]);
  const ky = GROUND - Math.sin(u * Math.PI) * 150;
  const kneel = easeOut(ramp(t, E('losing') + 0.05, E('losing') + 0.34)) * (1 - easeInOut(ramp(t, E('wise') - 0.1, E('wise') + 0.3)));
  const swing = Math.max(chop(t, E('battle')), chop(t, E('bonk'))); const lost = t >= E('losing');
  const arm = lost ? (kneel > 0.05 ? -80 : -8 + Math.sin(t * 14) * 18) : -70 + swing * 100;
  const cta = ramp(t, E('cta'), E('cta') + 0.75);
  // the sword flies out of his hand and stays where it lands
  const fly = ramp(t, E('losing'), E('losing') + 0.42);
  const swordAt: P = [lerp(790, 880, fly), lerp(1060, GROUND - 6, easeIn(fly)) - Math.sin(fly * Math.PI) * 100];
  const hit = [E('battle'), E('bonk')].find((h) => t >= h && t < h + 0.2);
  const dots = Array.from({ length: 15 }, (_, i) => i / 14).filter((v) => u > v && u > 0);
  return (
    <svg viewBox="0 780 1080 460" width={1080} height={460} style={{ position: 'absolute', left: 0, top: 780, overflow: 'visible' }}>
      {/* a faint underdrawing, never inked until the gold arrives on the voiceless moment */}
      <path d={SCROLLWORK} fill="none" stroke={RUST} strokeWidth="3" strokeLinecap="round" opacity="0.2" />
      <path d={SCROLLWORK} fill="none" stroke={g} strokeWidth="6" strokeLinecap="round" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - ramp(t, E('quiet') + 0.2, E('quiet') + 2.2)} opacity={t > E('quiet') ? 1 : 0} />
      {/* the ground is a stem */}
      <path d={`M72 ${GROUND + 6}C300 ${GROUND + 2} 600 ${GROUND + 10} 960 ${GROUND + 5}`} fill="none" stroke={VERD} strokeWidth="6" strokeLinecap="round" />
      {[110, 262, 402, 548, 700, 846, 948].map((x, i) => <path key={x} d={LEAF} transform={`translate(${x} ${GROUND + 6}) rotate(${i % 2 ? 25 : -35}) scale(.8)`} fill={VERD} stroke="#234f43" strokeWidth="2" />)}
      {/* a snail leaves a line, so the whole film can see how far it has come */}
      <path d={`M116 ${GROUND - 1}H${sx - 96}`} stroke="#fff6d8" strokeWidth="6" strokeLinecap="round" opacity="0.9" /><path d={`M116 ${GROUND + 2}H${sx - 96}`} stroke="#8d7a50" strokeWidth="1.4" opacity="0.7" />
      <SnailG x={sx} y={GROUND} t={t} g={g} />
      {t >= E('losing') ? <g transform={`translate(${swordAt[0]} ${swordAt[1]}) rotate(${fly < 1 ? fly * 740 - 40 : 0})`}><path d={SWORD} fill="#d9dde2" stroke={INK} strokeWidth="2.4" /><path d="M-2 -18V18" stroke={g} strokeWidth="7" /></g> : null}
      {dots.map((v) => { const a = Math.PI * v; return <circle key={v} cx={846 - 422 * v} cy={GROUND - 4 - Math.sin(a) * 150} r="5" fill={VERM} opacity="0.8" />; })}
      {cta > 0 ? <path d={`M216 ${GROUND - 2}V960`} stroke="#5a3a20" strokeWidth="9" strokeLinecap="round" /> : null}
      {t >= E('knight') - 0.25 ? <Knight x={kx} y={ky} face={t >= E('explicit') ? 1 : -1} kneel={kneel} phase={walkOn ? t * 13 : 0} arm={t >= E('explicit') ? -28 : arm} sword={!lost} ink={ink} g={g} /> : null}
      {hit !== undefined ? <path d={SPARK} transform={`translate(${sx + 40} ${GROUND - 120}) scale(${1 - (t - hit) / 0.25})`} fill={g} stroke={INK} strokeWidth="2.5" /> : null}
      {t >= E('wise') && t < E('wise') + 0.45 ? <path d={SPARK} transform={`translate(${kx} ${ky - 278}) scale(${0.6 + back(ramp(t, E('wise'), E('wise') + 0.2)) * 0.6}) rotate(${t * 90})`} fill={g} stroke={INK} strokeWidth="2.5" /> : null}
    </svg>
  );
};

/** The ending: the knight and the snail hold up the call to action between them. The text shrinks to fit a longer keyword. */
const Banderole: React.FC = () => {
  const t = useT();
  const at = E('cta'); if (t < at) return null;
  const k = easeOut(ramp(t, at, at + 0.75)); const text = `COMMENT ${KEYWORD}`; const y = 1014 + Math.sin(t * 1.3) * 3;
  return (
    <div style={{ position: 'absolute', left: 216, top: y, width: 640, height: 96, clipPath: wipe(k) }}>
      <svg viewBox="0 0 640 96" width={640} height={96} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible' }}>
        <path d="M0 8Q320 22 640 8V88Q320 102 0 88Z" fill="#f6edd2" stroke={INK} strokeWidth="3.5" /><path d="M640 8L672 28L654 46L672 66L640 88Z" fill="#e4d4a8" stroke={INK} strokeWidth="3.5" /><path d="M6 17Q320 31 634 17M6 80Q320 94 634 80" fill="none" stroke={VERM} strokeWidth="3" />
      </svg>
      <div style={{ position: 'absolute', left: 0, right: 0, top: 18, textAlign: 'center', whiteSpace: 'nowrap', font: `${Math.min(60, 780 / text.length)}px/66px ${FELL}`, color: LAPIS, letterSpacing: '.03em' }}>{text.split(' ').map((w, i) => <span key={i} style={{ color: i ? VERM : LAPIS }}>{i ? ' ' : ''}{w}</span>)}</div>
    </div>
  );
};

const BLOB = Array.from({ length: 28 }, (_, i) => { const a = (i / 28) * Math.PI * 2, r = 84 + (hash(i * 3 + 2) - 0.5) * 16; return `${i ? 'L' : 'M'}${(Math.cos(a) * r).toFixed(1)} ${(Math.sin(a) * r).toFixed(1)}`; }).join('') + 'Z';
const Seal: React.FC = () => {
  const t = useT();
  const at = E('courage') - 0.14; if (t < at) return null;
  const k = easeIn(ramp(t, at, at + 0.14)), sq = t > at + 0.14 ? Math.exp(-(t - at - 0.14) * 14) * 0.08 : 0;
  return (
    <svg viewBox="-110 -110 220 220" width={220} height={220} style={{ position: 'absolute', left: 752, top: 812, overflow: 'visible', transform: `scale(${lerp(2.5, 1, k) * (1 + sq)})`, opacity: lerp(0.3, 1, k) }}>
      <path d={BLOB} transform="translate(5 8)" fill="rgba(60,20,0,.35)" /><path d={BLOB} fill="#a5211a" stroke="#6e120d" strokeWidth="3" />
      <circle r="64" fill="#8c1a14" stroke="#c9463a" strokeWidth="3" /><circle r="52" fill="none" stroke="#c9463a" strokeWidth="1.6" strokeDasharray="3 6" />
      <g transform="translate(0 32) scale(.3)">
        <path d="M-112 0C-60 -6 40 -8 86 -4C110 -2 126 -18 126 -44C126 -68 100 -76 86 -58C74 -40 60 -24 40 -22L-70 -18C-92 -14 -104 -8 -112 0Z" fill="#c9463a" /><circle cx="-12" cy="-68" r="60" fill="#c9463a" /><path d="M-12 -68m0 -12c14 -2 24 10 20 24c-6 20 -36 20 -46 -2c-10 -28 14 -54 44 -46c30 10 36 44 22 68" fill="none" stroke="#6e120d" strokeWidth="8" /><path d="M112 -60L128 -108M94 -58L90 -112" stroke="#c9463a" strokeWidth="9" />
      </g>
      <path d="M-46 -40A62 62 0 0 1 -6 -60" fill="none" stroke="#f0a58f" strokeWidth="5" strokeLinecap="round" opacity="0.7" />
    </svg>
  );
};

/* ---------- gold glints ---------- */

const GLINTS: P[] = [[150, 420], [410, 214], [706, 430], [120, 816], [700, 812], [862, 800], [150, 120], [930, 130], [540, 180], [760, 900], [420, 860], [980, 620]];
const STAR = 'M0 -30L6 -6L30 0L6 6L0 30L-6 6L-30 0L-6 -6Z';
const Glints: React.FC = () => {
  const t = useT();
  const v = goldAt(t); const low = ramp(v, 0.15, 0.8), blaze = ramp(v, 0.8, 1);
  return (
    <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0 }}>
      {GLINTS.map(([x, y], i) => { const ph = Math.max(0, Math.sin(t * (2.1 + hash(i) * 1.6) + i * 1.7)); return <path key={i} d={STAR} transform={`translate(${x} ${y}) rotate(${t * 40 + i * 20}) scale(${(0.35 + ph * 0.7) * (1 + blaze * 0.9)})`} fill="#fff8d8" opacity={ph * low * (0.45 + blaze * 0.55)} />; })}
    </svg>
  );
};

/* ---------- the text of the page: the captions ---------- */

const CHUNKS = buildChunks(24, 3);
const TX = 288, TY = 1234, ROW = 78;
const gildBg = (v: number) => {
  const c = (x: string) => interpolateColors(v, [0, 0.8, 1], ['#57492a', x, '#fbe58f']);
  return `linear-gradient(135deg,${c('#b08a28')},${c('#f1d77a')} 45%,${c('#c9a23a')} 60%,${c('#9a7720')})`;
};

const Captions: React.FC = () => {
  const t = useT();
  const idx = CHUNKS.reduce((a, c, i) => (t >= c.t0 - 0.08 ? i : a), 0);
  const nextChunk = CHUNKS.find((c) => c.t0 > E('quiet')); const quiet = t >= E('quiet') && t < (nextChunk ? nextChunk.t0 - 0.08 : DURATION);
  const dim = ramp(t, E('fails') + 0.1, E('fails') + 0.5) * (1 - ramp(t, E('small'), E('small') + 0.35));
  const ink = interpolateColors(dim, [0, 1], [INK, '#efe2c4']); const red = interpolateColors(dim, [0, 1], [VERM, '#ff8a6a']); const v = goldAt(t);
  if (quiet) {
    const k = 50 * (1 - easeOut(ramp(t, E('quiet') + 0.05, E('quiet') + 0.65)));
    return <div style={{ position: 'absolute', left: 60, top: 1252, width: 890, height: 204, background: LAPIS, boxShadow: `inset 0 0 0 7px ${goldC(t)},inset 0 0 0 11px ${VERM},0 6px 12px rgba(40,18,0,.35)`, textAlign: 'center', whiteSpace: 'nowrap', font: `${Math.min(116, 1180 / QUIET_CAPTION.length)}px/204px ${UNCIAL}`, color: '#f7dd7e', clipPath: `inset(-5% ${k}% -5% ${k}%)` }}>{QUIET_CAPTION}</div>;
  }
  const c = CHUNKS[idx];
  const first = c.words[0].w; const gild = idx === 0 ? ramp(t, E('gild'), E('gild') + 1.0) : 1;
  const fresh = 1 - ramp(t, c.t0 - 0.08, c.t0 + 0.28); const state = (w: Word) => (t >= w.t1 ? 'done' : t >= w.t0 - 0.02 ? 'on' : 'next');
  return (
    <>
      <div style={{ position: 'absolute', left: 60, top: 1248, width: 208, height: 208, background: '#e4d4a8', boxShadow: `inset 0 0 0 7px ${LAPIS},inset 0 0 0 11px ${VERM},0 6px 12px rgba(40,18,0,.35)` }}>
        <div style={{ position: 'absolute', inset: 11, background: gildBg(v), clipPath: gild >= 1 ? undefined : `polygon(0 100%,0 ${100 - gild * 220}%,${gild * 220}% 100%)` }} />
        <div style={{ position: 'absolute', inset: 0, textAlign: 'center', font: `168px/204px ${FELL}`, color: dim > 0.5 ? '#2c3a62' : LAPIS, transform: `scale(${1 + fresh * 0.14})` }}>{first[0]}</div>
        {fresh > 0 ? <div style={{ position: 'absolute', inset: 11, background: `linear-gradient(115deg,transparent ${fresh * 130 - 50}%,rgba(255,250,210,.85) ${fresh * 130 - 30}%,transparent ${fresh * 130 - 10}%)`, opacity: v }} /> : null}
      </div>
      <svg viewBox="0 0 960 240" width={960} height={240} style={{ position: 'absolute', left: 0, top: 1240 }}>{[0, 1, 2].map((i) => <line key={i} x1={TX} x2="950" y1={ROW * (i + 1) - 4} y2={ROW * (i + 1) - 4} stroke={dim > 0.5 ? '#c7b48a' : RUST} strokeWidth="1.4" opacity={0.28} />)}</svg>
      <div style={{ position: 'absolute', left: TX, top: TY, width: 950 - TX, font: `66px/${ROW}px ${FELL}`, color: ink }}>
        {c.words.map((w, i) => {
          const s = state(w); const up = w.w.toUpperCase().replace(/[.,;:!?"]/g, '') === KEYWORD && t >= E('cta');
          return <span key={i} style={{ opacity: s === 'next' ? 0.15 : 1, color: s === 'on' || up ? red : ink }}>{i === 0 ? first.slice(1) : w.w}{i < c.words.length - 1 ? ' ' : ''}</span>;
        })}
      </div>
    </>
  );
};

/* ---------- light ---------- */

const Light: React.FC = () => {
  const t = useT();
  const lit = litAt(t), c = camAt(t); const fx = c.s * CX + c.tx, fy = c.s * (flameY(t) - 34) + c.ty;
  const sun = ramp(t, E('quiet'), E('quiet') + 1.5); const f = flicker(t);
  return (
    <>
      <AbsoluteFill style={{ background: `radial-gradient(ellipse 760px 900px at ${fx}px ${fy}px, rgba(255,205,120,${(0.2 + 0.03 * f) * lit}), rgba(255,205,120,0) 72%)` }} />
      <AbsoluteFill style={{ background: `radial-gradient(ellipse 1050px 1350px at ${fx}px ${fy}px, rgba(58,30,10,0) 35%, rgba(58,30,10,${(0.34 + 0.05 * f) * lit * (1 - sun)}) 100%)` }} />
      <AbsoluteFill style={{ background: `rgba(10,11,30,${(1 - lit) * 0.88})` }} />
      {sun > 0 ? <AbsoluteFill style={{ background: `linear-gradient(115deg,rgba(255,226,140,0) ${sun * 130 - 40}%,rgba(255,226,140,${0.3 * (1 - ramp(t, E('quiet') + 1.5, E('quiet') + 2.6))}) ${sun * 130 - 20}%,rgba(255,226,140,0) ${sun * 130}%)` }} /> : null}
    </>
  );
};

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

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

const Stage: React.FC = () => {
  const t = useT();
  const c = camAt(t);
  const cam: React.CSSProperties = { position: 'absolute', left: 0, top: 0, width: 1080, height: 1920, transformOrigin: '0 0', transform: `translate(${c.tx}px, ${c.ty}px) scale(${c.s})` };
  return (
    <AbsoluteFill style={{ background: VELLUM, overflow: 'hidden' }}>
      <div style={cam}>
        <Vellum /><Vine />
        <div style={{ position: 'absolute', left: 0, top: 68, width: 1080, textAlign: 'center', font: `108px/116px ${BLACK}`, color: VERM }}>{TITLE}</div>
        <div style={{ position: 'absolute', right: 86, top: 192, font: `italic 38px/44px ${FELL}`, color: RUST }}>xvii</div>
        <Miniature /><Candle /><Rubric /><Glosses /><Margin /><Banderole /><Seal /><Glints />
      </div>
      <Light />
      <div style={cam}><Moonlight /><Flame /></div>
      <Captions />
      <AbsoluteFill style={{ background: 'radial-gradient(120% 80% at 50% 45%, transparent 66%, rgba(40,20,5,.3) 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:** an old monk reading aloud at a lectern, soft, close to the mic, in no hurry, with a warmth that comes through on "Have courage". He reads this the way he'd read any chronicle, with care and full respect for the page, and he never notices it's funny.

**When to use it:** on every line except the voiceless moment. Read, never chant or intone, because a sung or sermon delivery turns the film into a joke about worship. Slow voices run long, so speed the takes up until a line takes about the time the clock estimates. Give "hath" and "doth" their full sound. Add a small stone room with a short, warm echo, and compress before the room. The line to give room to is "do one small thing, then the next": it's the only thing he says that he means as advice.

- **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 |
|-------|--------|-------------|---------------|
| room tone | 10 s | a quiet stone room at night, low soft air, a distant wind, no voices, no music | the bed, all the way through |
| candle | 5 s | a small candle flame burning close up, a soft irregular flutter, warm and quiet | a low bed while the flame is lit |
| quill | 1 s | a quill pen scratching on parchment, small dry strokes, close | very quietly as each caption chunk starts, and under the knight drawing himself on `knight` |
| gold | 1 s | a soft low shimmer of thin metal leaf, warm and small, not a bright chime | the initial gilding on `gild`; louder as gold crosses the page on `quiet` |
| horn | 1.5 s | an old cow horn blown badly, a short rough low blast in a stone room | `horn` |
| blanket | 0.8 s | a heavy wool blanket pulled up in one tug, soft and muffled | `rise` |
| armor | 0.8 s | a knight in plate armor sitting down, a soft metal clank | once for each knight after `forth` |
| muffled cheer | 1 s | a few men cheering inside metal helmets, hollow and wordless, short | `friday` |
| knife scrape | 1 s | a small knife scraping dry parchment, quick rough strokes, close | `scrape` |
| helmets down | 1.2 s | five metal helmets dropping onto a wooden table one after another, dull thuds | right after `scrape` |
| shell hit | 0.5 s | a small sword tapping a hollow snail shell, a tiny dull tink, comic but not cartoonish | `battle` and `bonk` |
| sword drop | 0.8 s | a small sword landing flat on a wooden floor, one dull clatter | `losing` |
| gutter | 1 s | a candle guttering and going out, a wet sputter, then a tiny hiss | `fails` |
| far bell | 4 s | one large bronze bell heard from very far away across fields at night, low and soft | the low point, while the page is dark |
| wind | 8 s | cold wind around a stone tower at night, low and slow, no howling | rising under the low point, falling on `small` |
| scroll | 0.4 s | a rolled parchment landing on a pile of others, a soft papery thud | each scroll arriving, in a run; a little slower in the dark |
| flame catch | 0.8 s | a candle wick catching, a soft small whoof | `small` |
| bell | 5 s | a large bronze bell tolled once nearby, deep and warm, long decay | `quiet`, the biggest thing in the film's middle |
| wax seal | 1.2 s | a heavy metal seal pressed into hot wax on a wooden desk, a deep solid thud, soft squish | `courage`, the loudest thing in the film |
| ribbon | 0.8 s | a parchment ribbon unrolled quickly, a dry flutter | `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:** "Sparse early-music style instrumental: a slow plucked lute over a low bowed drone, modal and warm, patient for a long stretch, then a soft hand drum and a wooden flute opening it up, ending resolved, no vocals, no chant, no choir." Keep it secular: a sung sacred piece under these jokes would break the frame's first rule.

**When it plays:** under the first lines at a low level. It drains to nothing over 0.7 s on "fails", and stays out while the candle is out, so only the far bell and the wind remain. It returns on "small", low at first, and climbs through the knight's walk. It's at its fullest on the voiceless moment, on a bar where the track opens up, and it ducks right after the wax seal so the last line sits clear.

**Mix, voice first.**

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

## 10. Render and QA

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

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

## 11. Traps

- **Letters that read wrong:** Uncial Antiqua's capital H reads as a b, its capital A as a lambda and its small t as a c. Blackletter's capital I reads as a J and its x as an r, which turns Latin into nonsense, and that's why our drop cap is in Fell, the Latin headings are in uncial capitals with no H, and the crate says "inbox" in small letters. Look at each new word at full size.
- **A joke that drifts to the sacred:** with this look, it's the easy joke, and it's the one thing this frame doesn't do. If a line needs a saint or a prayer to work, write a different line.
- **Latin from memory:** see the rule at the top. A wrong ending is what a careful viewer comments on instead of your topic.
- **A black low point:** the page goes to about 12% with a blue cast, the captions flip to pale ink, and the moonbeam keeps the scrolls moving. It's never black, and there's always something falling.
- **Captions under the page:** only zoom around `FOCUS`. A focus lower than the top of the text block pushes the fight down behind the words.
- **Ghost words look like a bug on a contact sheet:** those are the words not spoken yet. Watch the video to check that each chunk finishes well before the next starts.
- **A snail nobody can find:** keep it large and let it start well to the left, or the line "Lo, in the margin" has nothing to pay off. It has to be visible from the first frame.
- **The walk reads as a hop:** the knight has to clear the shell by a visible gap, with the dotted line left behind him, or "goes around" looks like "climbs on".

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