The Reading
A candle catches on velvet, and your everyday problem is the reading. Each card lands with an engraved drawing of it, the third lands reversed and turns on the viewer, and the candle dies. Then the moon catches light and stars draw a door from card to card. Every scene is code, and the skill tells your agent which sounds to use and when.
- Frame
- 9:16
- 30-40 s
You need an agent that can run code, such as Claude Code, Claude with code execution, ChatGPT Codex or Cursor, plus Node 20+, ffmpeg and Python 3.
-
Give your agent the skill
Paste it into a new chat. Your agent saves it as a skill and waits for your prompt.
Copy for my agent Copy link -
Fill in the prompt
The reading The card that turns on the viewer The future on the gilded card Platform Narrator Ending AI toolsUse The Reading skill (the-reading) to make a vertical video for Instagram Reels. - **The reading:** [TOPIC - the small, everyday thing the cards are about, e.g. "three signs it's time to leave that group chat", "why every planner I buy dies in February"] - **The first two signs:** [SIGNS - two things the viewer will laugh at and recognize, or "you write them"] - **The sign that turns on the viewer:** [REVERSED - the third card, the uncomfortable one, e.g. "you are the one who keeps replying", or "you write it"] - **The future on the gilded card:** [FUTURE - the calm way out, e.g. "a phone that stays face down", or "you write it"] - **In the window:** [WINDOW - "no person: engraved drawings in code", or "my own clip, attached"] - **Length:** [LENGTH - e.g. about 35 seconds] - **Voice:** [VOICE - e.g. "a hushed tarot reader", "my own voice", or "no voice: captions and sound only"] - **Music:** [MUSIC - e.g. "the skill's hushed parlor brief", or your own idea] - **Ending:** [CTA - organic: "Comment KEYWORD and I'll send you THE THING", with the keyword and what you'll send; or sponsored: the line and the URL] - **AI tools:** [AI TOOLS - e.g. "whatever you already have set up", "HYVIRAL", or "none: animation only, I'll record the voice myself"] - **Avoid:** [AVOID - e.g. no real predictions, no real people, no cards copied from a published deck] Work in this order: 1. Write the script on the skill's moves, with one line for each turn and what each card shows. 2. Build the plan, the clock and the frame, render a silent animatic, and show me the script, the scene sheet and the animatic. Wait for my OK. 3. Generate the voice, music and sound effects with the AI tools I named above. If I left that open, tell me which tools you'll use and wait for my OK. 4. Place a sound on every change, fit the music to the turn, mix voice first, render, and run every QA gate in the skill. 5. Give me the MP4, a cover frame and a ready-to-paste caption.
Anything left in [brackets] is your agent's call.
Copy prompt -
Paste it and approve the storyboard
Your agent writes the script, draws the cards and shows you a silent animatic for your OK before it generates anything, then adds the sound and checks the film. You get the MP4, a cover frame and a caption.
Optional: HYVIRAL. Use any AI tools you like, or none. If you'd rather not juggle separate accounts and API keys, HYVIRAL gives your agent AI video, images, music, voice, captions and the final render in one place.
How to connectThe skill, as plain text
---
name: the-reading
description: Make a 30-40 second vertical video where a hushed tarot reader takes a small, everyday problem with total gravity - cards get dealt on velvet with engraved drawings of your topic, the third one lands reversed, the candle dies, and a constellation draws the way out. The skill covers the card-table sound design, music fitted to the story and an optional voice like a hushed reader. Use for 'three signs it's time to...' lists, relationship and life jokes, habits people won't admit to, or when asked for a tarot, fortune teller or mystic reading video, or when given The Reading prompt.
---
# The Reading
A tarot reader's table fills the screen, and your topic is the reading. A candle catches, the deck riffles, and each card lands huge with an engraved drawing of your problem, then steps down into the spread. The third card lands upside down and turns on the viewer, the candle dies, and then the moon on the table's dial catches light and a constellation draws itself from the very cards that hurt. It takes something small and treats it as fate, so you stay to see what the last card says.
Work through the steps in order. Show the user the script, the scene sheet and a silent animatic (step 6) and wait for their OK before you generate anything.
**What to expect:** the code in step 7 is the real frame that made our demo, and it runs as written. Your work is the story, what goes in the window and the sound. A new subject means redrawing the window, and that is most of the code you'll change; step 7 lists exactly what to change. Plan on one short script, a handful of sound effects, one music bed and two or three render passes.
## 0. If you received this as text
Save it as a skill first, wherever your app keeps skills. In Claude Code that's `~/.claude/skills/the-reading/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/cinzel` and `@fontsource/cormorant-garamond`. 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/Reading.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: cards get dealt and flipped, the moon dial turns one step at a time, a card lands reversed, the candle gutters and relights by itself, and stars draw a shape from card to card. That's why it holds attention: the viewer learns the world in the first second, then watches it change.
- **One clock: the spoken words.** Each line is voiced as its own take with word timings, scenes are laid end to end on those takes, and every change is keyed to a word in `story.json`. Re-voice a line and everything moves with it.
- **A function of time.** Every part of the frame computes what it shows from the current time, with no stored state, `Math.random` or `Date`. Use `hash()` for anything that looks random, so two renders match.
- **Sound off first.** Most people start muted, so the frame and the captions have to tell the whole story on their own. Sound makes it better, never necessary.
- **Safe area.** 1080 x 1920. Platform buttons and captions cover the bottom 440 px, and the right 130 px from about y = 900 down. Keep captions and anything that must be read inside x 60-950, y 250-1480. Above y = 900 the frame can run to the right edge. Below y = 1480, draw only texture. The top 250 px can hold the world's title and small jokes, as long as nothing there has to be read.
- **Pacing rules.** No voice gap over 1.0 s, except one voiceless moment of 2 to 5 s with the music lifted. No still stretch over 1.2 s. Never cut to black. Something moves on the very first frame.
## 3. The world
A tarot reader's table at night, seen as one piece of velvet. Nothing leaves the table.
- **Palette:** midnight `#17113a` and violet velvet, gold leaf `#d9b45a`, pale gold `#f5dfa0`, wine `#7a1f3d`, card stock `#efe3c8`, engraving ink `#2b1622`.
- **Type:** Cinzel 700 and 900 for card names, numerals and labels. Cormorant Garamond italic 600 and 700 for the small jokes and the captions.
- **Fixed parts, top to bottom:** a wine valance with tassels, the title with a small joke under it (y 96-190, nothing there has to be read), the pointer and tonight's moon (y 223-357), the moon dial filling the middle (center 505, 715), a candle on the left, the deck and a small brass bell on the right, three card places stitched into the velvet (y 933-1300), a wine ribbon with the captions in one fixed place (y 1344-1472), then the table's gold edge, tassels and embroidered moons as texture below the safe area.
- **The spot rule:** a dealt card lands huge in the one spot (center 505, 800), says its piece, then steps down into its place in the spread. Everything can be read once, at full size, and the row stays as a record of what's been said.
- **The moon dial never drifts:** it holds eight moon phases and turns in steps, one for each thing that goes wrong, from waning to new, then back round to full. A smaller numeral wheel inside it turns all the time, so the table is never still.
- **Two kinds of light:** everything sits under one room light tied to the flame. What gives off its own light (the flame, the bright moon, the stars of the constellation) draws above it, in a second camera layer, so the room can go dark while they stay bright.
- **Every drawing is engraved:** ink outlines, hatching from one set of patterns, and gold leaf only on rays and halos. Invent every card and every drawing. Never copy a published deck, its names or its compositions.
- **Small jokes live on the cards:** the numeral XCIX on the card with 99 unread, T.B.D. in place of a number, "mentions of you: 0", a bell with a MUTED tag on its cloth, "the cards have left the chat".
**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 face card: the arch in the middle of the card shows an engraved drawing of the topic. Each drawing also moves. Ours, drawn in code:
| Story moment | In the arch |
|---|---|
| Opening | THE GROUP CHAT: a storm of speech bubbles with typing dots, bubbles falling like rain on a small hooded figure under an umbrella |
| First card | THE NOTIFICATION: a phone throwing rays, its badge counting to 99, messages scrolling past |
| Second card | THE ITINERARY: a suitcase in a closet, cobwebs spreading on "never", a spider coming down on its thread, a luggage tag reading TBD |
| Third card | THE LAST WORD: the figure on top of a heap of speech bubbles, phone held up, replies popping out one at a time. The whole card is upside down |
| The low point | No face card. The three cards dim in the row, and the middle of the table is empty |
| The future | THE QUIET, on gold: a full moon, a door standing open on a hill, and a phone that comes down on its face. A snail starts across it |
The window also works with the user's own clip: put it inside the arch, as the children of `CardFront`, cropped to 490 x 572 with their face in the middle third. The name, numeral and gold frame stay on top. For the reversed card, turn the clip with the card: being upside down is the joke.
## 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. A reader treats something small as if it were fate. Ours is three signs it's time to leave that group chat.
| # | Move | What it does | Our line | On the table |
|---|------|--------------|----------|--------------|
| 1 | Sit down | The hook: the viewer is told to stop scrolling, in the world's voice | "Sit down. The cards already know about the group chat." | The candle catches, the deck waits. On "group" a card flips up: THE GROUP CHAT |
| 2 | The shuffle | Ritual, and it makes the viewer name their own version | "Shuffle. Cut. Think of the chat you keep on mute." | The deck riffles on the word, cuts, fans out. One card rises. A cloth drops over the bell on "mute" |
| 3 | First card | The first sign, the plain truth | "The first card. Ninety-nine unread, and none are for you." | THE NOTIFICATION lands, the badge races to 99, "mentions of you: 0" |
| 4 | Second card | The second sign, a laugh | "The second card. A trip that will never happen." | THE ITINERARY lands, cobwebs spread. The moon dial turns one step |
| 5 | The twist | The room changes | "The third card lands reversed." | It spins in and slams down upside down. The table jumps, the candle, bell and row all lean toward it |
| 6 | What it means | The sign turns on the viewer | "You are the one who keeps replying." | THE LAST WORD, REVERSED on a banner, a tag marks the figure YOU, replies keep popping out |
| 7 | Low point | The quiet fact, the worst line in the film | "You left for a week once. Nobody noticed." | Seven chalk marks. The moon goes new. The candle gutters and dies, the cards dim one by one, smoke curls toward the moon. Music out; room tone and a far chime keep running |
| 8 | The turn | Something has been on the table all along | "But look at the moon." | The camera rises, a thin bright crescent catches, a beam falls onto the empty table |
| 9 | The door | The voiceless moment, about 3 s | (music at its fullest, the ribbon reads THE DOOR) | The candle relights by itself. Stars draw from card I, up and over, to card III, to card II and back: a door |
| 10 | The future | The answer | "Your future. A phone that stays face down." | A fourth card arrives in the door and flips, gilded: THE QUIET. The dial turns round to full. The phone comes down on "down" |
| 11 | The button | The last laugh, and a little mercy | "The chat will be fine. It won't notice." | The flame burns steady. The badge on the first card goes from 99 to 100 on "notice", and nobody looks |
| 12 | Call to action | One action | "Comment cards and I'll send you the skill that made this." | A card is laid across the spread: COMMENT CARDS. It stays to the end |
**For other topics:** three signs a side project is over, why every planner dies in February, what your calendar says about you, the friend who only texts when they need a ride. Any "three signs" list works, as long as the signs can be drawn.
The trick that makes it land is that the third sign turns on the viewer. The first two are about the world, and they get a laugh, so the viewer relaxes. Then the card lands upside down and it's about them. Find that line first: if your third card could still be about someone else, it isn't done. The second trick is that nothing new arrives at the turn. The moon has been on the table since the first frame, and the door is drawn from the same three cards that hurt, so the way out was already there.
Keep it plainly a metaphor. This is no real prediction, so don't make a real claim about anyone's future or health.
Our film runs 38.5 s on the clock's estimate. For about 36 s, start the first two cards straight on their signs ("Ninety-nine unread, and none are for you.") and re-anchor `first` and `second` to those lines' first words. Joining moves 5 and 6 word for word saves nothing.
## 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 drawings, and each one is its own component, so replace them one at a time. `core.tsx` and `clock.mjs` don't change.
| To change | Where |
|---|---|
| Every word on the cards and labels | The block at the top: `TEXT` holds each card's `numeral`, `the`, `name` and `flavor`. Also `TITLE`, `SUBTITLE`, `SUBTITLE_END`, `REVERSED_LABEL`, `YOU_TAG`, `MUTED_TAG`, `UNREAD`, `LUGGAGE_TAG`, `REPLIES` and `VOICELESS_CAPTION` |
| The comment keyword | `KEYWORD` and `KEYWORD_ABOVE` at the top, and the last line of `plan.json`. `CtaCard` and `Ribbon` read the constants |
| The drawings in the arches | `ArtChat`, `ArtNotification`, `ArtItinerary`, `ArtLastWord`, `ArtQuiet`: each is one 490 x 572 picture. Redrawing these is the biggest job. Their data sits next to them: `CLOUD`, `RAINDROPS`, `RAYS_LONG`, `WEB_A`, `WEB_B`, `WEB_C`, `MOTES`, `PILE`, `REPLY_AT`, and `unreadAt` for the badge |
| Drawing tools to reuse | `Bubble`, `Figure`, `Hill`, `Sky`, `raysD`, `webD`, and the hatch patterns in `HATCH` (`url(#h2)` and so on) |
| When each card moves | `CHAT_KEYS`, `NOTE_KEYS`, `ITIN_KEYS`, `LAST_KEYS`, `QUIET_KEYS`, `CTA_KEYS`: position, size, spin and flip, each pinned to events. `FlippedCards` shows the first four and sets when each `flavor` appears (`flavorAt`), `QuietCard` and `CtaCard` the last two |
| The opening with the deck | `deckPose` (riffle, cut, fan, rest), `fanPose`, `RANK_CHOSEN`, and the spots `HOME` and `REST` |
| Props and one-off moments | `Bell` and `Muted`, `Tally` (seven marks for "a week"), `YouTag`, `ReversedBanner` |
| The moon dial | `STEPS`: which event turns it, and to which step. `PHASE` sets the order of the moons |
| The candle | `flameAt` and `steadyAt`, keyed to `sit`, `week`, `nobody`, `door`, `future` and `fine`. `Smoke` runs from `nobody`. `WICK` places it |
| The constellation | `DOOR` lists the stars in drawing order, and `KNOB` is the last one. The panel lines are in `Door`. Stars 1, 9 and 10 sit on the three cards in the row, so move them with `SLOT` |
| Where things sit | `SPOT`, `SLOT`, `HOME`, `REST`, `DIAL`, `MOON_R`, `WICK` at the top |
| The camera and the knocks | `KEYS` for the camera, `HITS` for which events shake the table, `leanAt` and `LeanIn` for the room leaning in |
| The light | `Light` (room), `moonAt` and `MoonGlow` (moon), `dimAt` (the cards going dim one by one) |
- **Scene ids in the code:** none. It only reads events, so you can renumber scenes freely.
- **What fits itself:** card names, numerals, `REVERSED_LABEL`, the keyword and the captions shrink to their boxes through `fit()` and `Ribbon`, so longer words stay inside.
- **What's sized to our words:** `flavor` lines don't shrink: keep them to about 26 characters. `REPLIES` use the boxes in `REPLY_AT`, the tag in `ArtItinerary` is 96 px wide, and `Muted` is 124 px, so check each one against its words.
- **Hand-aimed:** `YouTag`'s point is aimed at the figure on the upside-down card, in table coordinates. If you move `Figure`, move the tag.
- **Delete a moment's events along with its component.** `story.json` events that no longer match a word stop the render, which is what you want.
### The code
`plan.json`, our demo's script:
```json
{
"lines": [
{
"id": "l01",
"text": "Sit down. The cards already know about the group chat."
},
{
"id": "l02",
"text": "Shuffle. Cut. Think of the chat you keep on mute."
},
{
"id": "l03",
"text": "The first card. Ninety-nine unread, and none are for you."
},
{
"id": "l04",
"text": "The second card. A trip that will never happen."
},
{
"id": "l05",
"text": "The third card lands reversed."
},
{
"id": "l06",
"text": "You are the one who keeps replying."
},
{
"id": "l07",
"text": "You left for a week once. Nobody noticed."
},
{
"id": "l08",
"text": "But look at the moon."
},
{
"id": "l09",
"text": "Your future. A phone that stays face down."
},
{
"id": "l10",
"text": "The chat will be fine. It won't notice."
},
{
"id": "l11",
"text": "Comment cards and I'll send you the skill that made this.",
"say": "Comment, cards, and I'll send you the skill that made this."
}
],
"scenes": [
{
"id": "s01",
"lines": [
"l01"
],
"lead": 0.3,
"tail": 0.1
},
{
"id": "s02",
"lines": [
"l02"
],
"lead": 0.35,
"tail": 0.1
},
{
"id": "s03",
"lines": [
"l03"
],
"lead": 0.2,
"tail": 0.1
},
{
"id": "s04",
"lines": [
"l04"
],
"lead": 0.2,
"tail": 0.1
},
{
"id": "s05",
"lines": [
"l05"
],
"lead": 0.45,
"tail": 0.1
},
{
"id": "s06",
"lines": [
"l06"
],
"lead": 0.1,
"tail": 0.1
},
{
"id": "s07",
"lines": [
"l07"
],
"lead": 0.55,
"tail": 0.2
},
{
"id": "s08",
"lines": [
"l08"
],
"lead": 0.5,
"tail": 0.15
},
{
"id": "s09",
"sec": 3.4
},
{
"id": "s10",
"lines": [
"l09"
],
"lead": 0.1,
"tail": 0.1
},
{
"id": "s11",
"lines": [
"l10"
],
"lead": 0.2,
"tail": 0.1
},
{
"id": "s12",
"lines": [
"l11"
],
"lead": 0.2,
"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 { Reading } from './frames/Reading';
import spec from './spec.json';
export const Root: React.FC = () => (
<Composition id="Film-9x16" component={Reading} 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": "sit", "scene": "s01", "word": "Sit" },
{ "id": "cards", "scene": "s01", "word": "cards" },
{ "id": "chat", "scene": "s01", "word": "group" },
{ "id": "shuffle", "scene": "s02", "word": "Shuffle" },
{ "id": "cut", "scene": "s02", "word": "Cut" },
{ "id": "think", "scene": "s02", "word": "Think" },
{ "id": "mute", "scene": "s02", "word": "mute" },
{ "id": "first", "scene": "s03", "word": "first" },
{ "id": "ninety", "scene": "s03", "word": "Ninety" },
{ "id": "none", "scene": "s03", "word": "none" },
{ "id": "second", "scene": "s04", "word": "second" },
{ "id": "trip", "scene": "s04", "word": "trip" },
{ "id": "never", "scene": "s04", "word": "never" },
{ "id": "third", "scene": "s05", "word": "third" },
{ "id": "lands", "scene": "s05", "word": "lands" },
{ "id": "reversed", "scene": "s05", "word": "reversed" },
{ "id": "you", "scene": "s06", "word": "You" },
{ "id": "replying", "scene": "s06", "word": "replying" },
{ "id": "low", "scene": "s07" },
{ "id": "left", "scene": "s07", "word": "left" },
{ "id": "week", "scene": "s07", "word": "week" },
{ "id": "nobody", "scene": "s07", "word": "Nobody" },
{ "id": "turn", "scene": "s08" },
{ "id": "look", "scene": "s08", "word": "look" },
{ "id": "moon", "scene": "s08", "word": "moon" },
{ "id": "door", "scene": "s09" },
{ "id": "future", "scene": "s10", "word": "future" },
{ "id": "phone", "scene": "s10", "word": "phone" },
{ "id": "down", "scene": "s10", "word": "down" },
{ "id": "fine", "scene": "s11", "word": "fine" },
{ "id": "notice", "scene": "s11", "word": "notice" },
{ "id": "cta", "scene": "s12" },
{ "id": "keyword", "scene": "s12", "word": "cards" }
]
}
```
`src/frames/Reading.tsx`:
```tsx
import React, { useLayoutEffect, useRef } from 'react';
import { AbsoluteFill } from 'remotion';
import story from './story.json';
import { back, buildChunks, clamp, Clock, easeIn, easeInOut, easeOut, Fonts, hash, makeE, ramp, useT, type StoryJson, type Word } from './core';
/* The Reading: a tarot reader's table at night, seen as one piece of velvet. A candle, a deck, a moon dial that turns in steps. Each dealt card
lands huge, says its piece, then steps down into the spread. 1080 x 1920. Time comes from the spoken words through story.json. */
const E = makeE(story as StoryJson);
/* ---------- the words on screen: change these first ---------- */
const KEYWORD = 'CARDS';
const KEYWORD_ABOVE = 'COMMENT';
const VOICELESS_CAPTION = 'THE DOOR';
const TITLE = 'THE READING';
const SUBTITLE = 'by candle, no screenshots';
const SUBTITLE_END = 'the cards have left the chat';
const REVERSED_LABEL = 'THE LAST WORD, REVERSED';
const YOU_TAG = 'YOU';
const MUTED_TAG = 'MUTED';
/** `numeral` sits above the arch, `the` and `name` below it, `flavor` is the small joke at the bottom. */
const TEXT = {
chat: { numeral: 'XLVII', the: 'THE', name: 'GROUP CHAT', flavor: '47 members, 3 talking' },
notification: { numeral: 'XCIX', the: 'THE', name: 'NOTIFICATION', flavor: 'mentions of you: 0' },
itinerary: { numeral: 'T.B.D.', the: 'THE', name: 'ITINERARY', flavor: 'departs: to be confirmed' },
lastWord: { numeral: 'II:XIV A.M.', the: 'THE', name: 'LAST WORD', flavor: 'seen. no reply.' },
quiet: { numeral: '0', the: 'THE', name: 'QUIET', flavor: 'no reply expected' },
};
const UNREAD = 99;
const LUGGAGE_TAG = 'TBD';
const REPLIES = ['haha, true', 'same', 'lol ok'];
/* ---------- look ---------- */
const NIGHT = '#17113a', GOLD = '#d9b45a', PALE = '#f5dfa0', WINE = '#7a1f3d', WINE_DK = '#5d1530', STOCK = '#efe3c8', INK = '#2b1622', CREAM = '#f6ecd5';
const CAPS = 'Cinzel, serif', ITALIC = '"Cormorant Garamond", serif';
const FACES = [
{ family: 'Cinzel', file: 'cinzel-latin-700-normal.woff2', weight: '700' },
{ family: 'Cinzel', file: 'cinzel-latin-900-normal.woff2', weight: '900' },
{ family: 'Cormorant Garamond', file: 'cormorant-garamond-latin-600-italic.woff2', weight: '600', style: 'italic' },
{ family: 'Cormorant Garamond', file: 'cormorant-garamond-latin-700-italic.woff2', weight: '700', style: 'italic' },
];
type P = [number, number];
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
const noise1 = (x: number) => { const i = Math.floor(x), f = x - i, u = f * f * (3 - 2 * f); return lerp(hash(i), hash(i + 1), u); };
/** Type that stays inside its box for any word length: the font shrinks, the box doesn't. Cinzel capitals run about 0.74 em wide. */
const fit = (text: string, box: number, max: number, em = 0.74) => Math.min(max, box / (Array.from(text).length * em));
/* ---------- where everything sits on the table ---------- */
const CW = 600, CH = 880; // a card at full size
const SPOT = { x: 505, y: 800, k: 1 }; // the face card: the window
const ROW_Y = 1117, ROW_K = 250 / CW;
const SLOT: { x: number; y: number; k: number }[] = [{ x: 230, y: ROW_Y, k: ROW_K }, { x: 505, y: ROW_Y, k: ROW_K }, { x: 780, y: ROW_Y, k: ROW_K }];
const HOME = { x: 505, y: 1060, k: 0.42 }; // the deck, waiting
const REST = { x: 905, y: 640, k: 0.25 }; // the deck, once the reading starts
const DIAL: P = [505, 715];
const MOON_R = 410; // the moons' distance from the dial's center: the top one sits just above the face card
const WICK: P = [118, 560];
const MOON_AT: P = [DIAL[0], DIAL[1] - MOON_R];
/** The constellation, star by star: card I, up and over, card III, card II, and back to card I. */
const DOOR: P[] = [[325, 1010], [325, 700], [345, 590], [415, 480], [505, 438], [595, 480], [665, 590], [685, 700], [685, 1010], [505, 1010], [325, 1010]];
const KNOB: P = [618, 715];
const FAN_C: P = [505, 1560], FAN_R = 660, FAN_K = 0.28;
/* ---------- time-driven state ---------- */
/** The dial turns in steps, one for each thing that goes wrong, then back round to full. */
const STEPS: [number, number, number][] = [[E('trip'), 1, 0.6], [E('reversed'), 2, 0.6], [E('week'), 3, 0.7], [E('moon') - 0.05, 4, 0.7], [E('door') + 2.3, 5, 0.6], [E('future'), 7, 0.9]];
const stepAt = (t: number) => { let s = 0; for (const [at, to, d] of STEPS) { if (t < at) break; s = lerp(s, to, back(ramp(t, at, at + d))); } return s; };
const steadyAt = (t: number) => ramp(t, E('fine') - 0.4, E('fine'));
/** The flame: 1 is a full flame, 0 is out. It catches on frame 0, gutters on the low point, relights by itself, and burns steady for the button. */
const flameAt = (t: number) => {
const gutter = E('week') - 0.25, die = E('nobody') + 0.12, re = E('door') + 0.12, st = steadyAt(t);
const wob = (noise1(t * 7) - 0.5) * 0.22 * (1 - st * 0.85) + (noise1(t * 17 + 9) - 0.5) * 0.1 * (1 - st * 0.9);
if (t < E('sit')) return lerp(0.16, 0.8, ramp(t, 0, E('sit')));
if (t < gutter) return lerp(0.8, 1, ramp(t, E('sit'), E('cards'))) + wob;
if (t < die) { const q = ramp(t, gutter, die); return (1 - q * q) * (hash(Math.floor(t * 16) + 5) > 0.25 + 0.45 * q ? 1.05 : 0.42); }
if (t < re) return 0;
if (t < E('future')) return lerp(0.12, 0.7, easeOut(ramp(t, re, re + 0.7))) + wob * 0.6;
return lerp(0.7, 1, easeOut(ramp(t, E('future'), E('future') + 0.5))) + wob;
};
/** The moment after the third card lands, when the whole room leans toward it. */
const leanAt = (t: number) => easeOut(ramp(t, E('lands'), E('reversed') + 0.15)) * (1 - easeInOut(ramp(t, E('left') - 0.3, E('left'))));
/** Moonlight: nothing until the turn, strongest over the constellation, then it settles. */
const moonAt = (t: number) => easeOut(ramp(t, E('moon') - 0.1, E('moon') + 0.45)) * lerp(1, 0.4, easeInOut(ramp(t, E('future') - 0.2, E('future') + 0.6)));
const HITS: [number, number][] = ([['first', 4], ['second', 4], ['reversed', 22], ['future', 7], ['down', 3], ['keyword', 5]] 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) * 52) * Math.exp(-(t - e) * 9) : acc), 0);
/* The camera drifts and pushes, always around a point inside the frame, so the table's edges never show. */
type View = { s: number; tx: number; ty: number };
const zoom = (s: number, fx: number, fy: number): View => ({ s, tx: fx * (1 - s), ty: fy * (1 - s) });
const KEYS: { at: number; d: number; v: View }[] = [
{ at: 0, d: 0.01, v: zoom(1.12, 300, 720) },
{ at: E('cards'), d: 1.3, v: zoom(1.03, 505, 800) },
{ at: E('shuffle') - 0.3, d: 0.4, v: zoom(1.12, 505, 1050) },
{ at: E('think'), d: 0.7, v: zoom(1.03, 505, 1000) },
{ at: E('first') - 0.2, d: 0.5, v: zoom(1.03, 505, 800) },
{ at: E('ninety'), d: 1.2, v: zoom(1.07, 505, 800) },
{ at: E('second') - 0.1, d: 0.5, v: zoom(1.03, 505, 800) },
{ at: E('never'), d: 1.0, v: zoom(1.07, 505, 800) },
{ at: E('third') - 0.2, d: 0.5, v: zoom(1.02, 505, 800) },
{ at: E('reversed') - 0.05, d: 0.45, v: zoom(1.12, 505, 800) },
{ at: E('left') - 0.3, d: 1.0, v: zoom(1.015, 505, 900) },
{ at: E('nobody'), d: 1.6, v: zoom(1.1, 240, 760) },
{ at: E('look') - 0.1, d: 0.9, v: zoom(1.2, 505, 180) },
{ at: E('door') + 0.05, d: 1.0, v: zoom(1.03, 505, 760) },
{ at: E('future') - 0.2, d: 0.6, v: zoom(1.05, 505, 760) },
{ at: E('fine') - 0.2, d: 0.5, v: zoom(1.03, 505, 800) },
{ at: E('notice') - 0.3, d: 0.4, v: zoom(1.14, 230, 1100) },
{ at: E('cta') - 0.05, d: 0.7, v: zoom(1.02, 505, 900) },
];
const camAt = (t: number): View => {
let v = KEYS[0].v;
for (const k of KEYS.slice(1)) {
if (t < k.at) break;
const q = easeInOut(ramp(t, k.at, k.at + k.d));
v = { s: lerp(v.s, k.v.s, q), tx: lerp(v.tx, k.v.tx, q), ty: lerp(v.ty, k.v.ty, q) };
}
const s = Math.max(1.012, v.s + 0.005 * Math.sin(t * 0.37));
const tx = clamp(v.tx + 6 * Math.sin(t * 0.43 + 1), 1080 - 1080 * s, 0);
const ty = clamp(v.ty + 5 * Math.sin(t * 0.31 + 2), 1920 - 1920 * s, 0);
const j = shake(t);
return { s, tx: clamp(tx + j, 1080 - 1080 * s, 0), ty: clamp(ty + j * 0.6, 1920 - 1920 * s, 0) };
};
const Cam: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const c = camAt(useT());
return <div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1920, transformOrigin: '0 0', transform: `translate(${c.tx}px, ${c.ty}px) scale(${c.s})` }}>{children}</div>;
};
/* ---------- velvet: painted once into a canvas, from hash(), so it never shimmers under the camera ---------- */
const vnoise = (x: number, y: number) => {
const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
const h = (a: number, b: number) => hash(a * 57 + b * 131);
const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
return lerp(lerp(h(xi, yi), h(xi + 1, yi), u), lerp(h(xi, yi + 1), h(xi + 1, yi + 1), u), v);
};
const fbm = (x: number, y: number) => vnoise(x, y) * 0.55 + vnoise(x * 2.1 + 7, y * 2.1 + 3) * 0.3 + vnoise(x * 4.3 + 1, y * 4.3 + 9) * 0.15;
const Velvet: React.FC = () => {
const ref = useRef<HTMLCanvasElement>(null);
useLayoutEffect(() => {
const c = ref.current; if (!c) return;
const g = c.getContext('2d'); if (!g) return;
const w = c.width, h = c.height, im = g.createImageData(w, h);
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const nx = (x / w) * 5.4, ny = (y / h) * 9.6;
const warp = fbm(nx * 0.8 + 3, ny * 0.8);
const v = fbm(nx * 1.2 + warp * 1.8, ny * 0.75 + warp * 1.2);
const fold = clamp((v - 0.28) / 0.44);
const ridge = Math.pow(1 - Math.abs(2 * fold - 1), 3);
const grain = (hash(x * 13 + y * 7919) - 0.5) * 0.09;
const dx = (x / w - 0.47) / 0.75, dy = (y / h - 0.42) / 0.62;
const lit = clamp(1.12 - (dx * dx + dy * dy) * 0.8, 0.3, 1.12);
const k = (0.45 + fold * 0.62 + ridge * 0.28 + grain) * lit;
const i = (y * w + x) * 4;
im.data[i] = clamp(k * 58, 0, 255); im.data[i + 1] = clamp(k * 36, 0, 255); im.data[i + 2] = clamp(k * 118, 0, 255); im.data[i + 3] = 255;
}
g.putImageData(im, 0, 0);
}, []);
return <canvas ref={ref} width={360} height={640} style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1920, display: 'block' }} />;
};
const STARS = Array.from({ length: 96 }, (_, i) => ({ x: hash(i) * 1080, y: hash(i + 99) * 1460, s: 0.45 + hash(i + 7) * 1.0, ph: hash(i + 31) * 6.28, sp: 0.6 + hash(i + 3) * 1.8, big: i % 11 === 0 }));
const star4 = (R: number, r: number) => `M0 ${-R}L${r} ${-r}L${R} 0L${r} ${r}L0 ${R}L${-r} ${r}L${-R} 0L${-r} ${-r}Z`;
const Stars: React.FC = () => {
const t = useT();
const m = moonAt(t);
return (
<svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0 }}>
{STARS.map((s, i) => {
const o = (0.25 + 0.75 * (0.5 + 0.5 * Math.sin(t * s.sp + s.ph))) * (0.75 + m * 0.25);
return s.big
? <path key={i} d={star4(9 * s.s, 1.8 * s.s)} transform={`translate(${s.x} ${s.y})`} fill={PALE} opacity={o} />
: <circle key={i} cx={s.x} cy={s.y} r={2.4 * s.s} fill={PALE} opacity={o} />;
})}
</svg>
);
};
/* ---------- the moon dial ---------- */
/** The lit part of a moon. p is the phase: 0 new, 0.25 first quarter, 0.5 full, 0.75 last quarter. */
const moonLit = (p: number, r: number) => {
const q = p <= 0.5 ? p * 2 : (1 - p) * 2;
const rx = r * Math.abs(1 - 2 * q);
return { d: `M0 ${-r}A${r} ${r} 0 0 1 0 ${r}A${rx.toFixed(2)} ${r} 0 0 ${q < 0.5 ? 0 : 1} 0 ${-r}Z`, mirror: p > 0.5 };
};
const PHASE = (k: number) => (0.625 + k * 0.125) % 1; // k = 0 is waning gibbous; one step forward, one phase on
const Moon: React.FC<{ p: number; r: number; glow?: number }> = ({ p, r, glow = 0 }) => {
const lit = moonLit(p, r);
return (
<g>
{glow > 0 ? <circle r={r + 14} fill="none" stroke={PALE} strokeWidth={10} opacity={0.35 * glow} /> : null}
<circle r={r} fill="#100a2c" stroke={GOLD} strokeWidth={3} />
<path d={lit.d} transform={lit.mirror ? 'scale(-1 1)' : undefined} fill={PALE} />
<path d={lit.d} transform={lit.mirror ? 'scale(-1 1)' : undefined} fill="url(#moonHatch)" opacity={0.55} />
</g>
);
};
const numeralD = (s: string, x: number, h: number) => {
let d = '', at = x;
for (const c of s) {
if (c === 'I') { d += `M${at} 0V${h}`; at += 10; }
if (c === 'V') { d += `M${at} 0L${at + 8} ${h}L${at + 16} 0`; at += 24; }
if (c === 'X') { d += `M${at} 0L${at + 16} ${h}M${at + 16} 0L${at} ${h}`; at += 24; }
}
return { d, w: at - x - 8 };
};
const ROMAN = ['XII', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI'];
const Wheel: React.FC = React.memo(() => (
<g>
<circle r={342} fill="none" stroke={GOLD} strokeWidth={3} />
<circle r={272} fill="none" stroke={GOLD} strokeWidth={2} strokeDasharray="3 12" />
{ROMAN.map((n, i) => {
const m = numeralD(n, 0, 36);
return <g key={n} transform={`rotate(${i * 30}) translate(${-m.w / 2} -326)`}><path d={m.d} fill="none" stroke={GOLD} strokeWidth={4} /></g>;
})}
{Array.from({ length: 60 }, (_, i) => <line key={i} transform={`rotate(${i * 6})`} x1={0} y1={-342} x2={0} y2={i % 5 ? -332 : -326} stroke={GOLD} strokeWidth={2} opacity={i % 5 ? 0.8 : 0} />)}
</g>
));
const Dial: React.FC = () => {
const t = useT();
const s = stepAt(t);
const turn = -45 * s;
return (
<svg viewBox="-520 -520 1040 1040" width={1040} height={1040} style={{ position: 'absolute', left: DIAL[0] - 520, top: DIAL[1] - 520, overflow: 'visible' }}>
<g opacity={0.72}>
<circle r={470} fill="none" stroke={GOLD} strokeWidth={4} />
<circle r={462} fill="none" stroke={GOLD} strokeWidth={1.5} />
<circle r={350} fill="none" stroke={GOLD} strokeWidth={2} />
<g transform={`rotate(${t * 5})`}><Wheel /></g>
</g>
<g transform={`rotate(${turn})`}>
{Array.from({ length: 8 }, (_, k) => {
const a = k * 45, here = Math.cos(((a + turn) * Math.PI) / 180);
const top = Math.pow(clamp(here), 24);
return (
<g key={k} transform={`rotate(${a}) translate(0 ${-MOON_R}) rotate(${-a - turn}) scale(${1 + 0.34 * moonBoost(t) * top})`}>
<Moon p={PHASE(k)} r={lerp(42, 50, top)} />
</g>
);
})}
{Array.from({ length: 8 }, (_, k) => <path key={k} d={star4(14, 3)} transform={`rotate(${k * 45 + 22.5}) translate(0 ${-MOON_R})`} fill={GOLD} opacity={0.8} />)}
</g>
</svg>
);
};
/** The moon swells a little while the voice points at it. */
const moonBoost = (t: number) => easeOut(ramp(t, E('moon') - 0.1, E('moon') + 0.3)) * (1 - easeInOut(ramp(t, E('door') + 0.2, E('door') + 1.0)));
/** The pointer above the dial, fixed: whatever moon sits under it is tonight's. It kicks on every step. */
const Pointer: React.FC = () => {
const t = useT();
const kick = STEPS.reduce((acc, [at]) => (t >= at ? acc + 0.35 * Math.exp(-(t - at) * 7) : acc), 0);
return (
<svg viewBox="0 0 80 50" width={64} height={40} style={{ position: 'absolute', left: DIAL[0] - 32, top: DIAL[1] - 492, overflow: 'visible', transformOrigin: '32px 0', transform: `scale(${1 + kick})` }}>
<path d="M6 4H74L40 46Z" fill={GOLD} stroke={INK} strokeWidth={2.5} />
<path d="M20 10H60L40 34Z" fill={PALE} />
</svg>
);
};
/* ---------- the room: valance, title, table edge, charms ---------- */
const Swags: React.FC = () => {
const t = useT();
const SW = 216;
const scal = Array.from({ length: 6 }, (_, i) => `Q${i * SW + SW / 2} ${i % 2 ? 118 : 96} ${(i + 1) * SW} 12`).join('');
return (
<>
<svg viewBox="0 0 1296 170" width={1296} height={170} style={{ position: 'absolute', left: -108, top: 0, overflow: 'visible' }}>
<defs><linearGradient id="swagFill" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#3a0d22" /><stop offset="1" stopColor={WINE} /></linearGradient></defs>
<path d={`M0 -10V12${scal}V-10Z`} fill="url(#swagFill)" />
{Array.from({ length: 6 }, (_, i) => [24, 48, 72].map((dy) => <path key={`${i}-${dy}`} d={`M${i * SW + 26} 12Q${i * SW + SW / 2} ${dy + (i % 2 ? 14 : 2)} ${(i + 1) * SW - 26} 12`} fill="none" stroke="#2b0a1a" strokeWidth={2} opacity={0.55} />))}
<path d={`M0 12${scal}`} fill="none" stroke={GOLD} strokeWidth={6} />
</svg>
{[108, 324, 540, 756, 972].map((x, i) => (
<div key={x} style={{ position: 'absolute', left: x - 14, top: 6, width: 28, height: 66, transformOrigin: '14px 0', transform: `rotate(${Math.sin(t * 1.1 + i) * 3}deg)` }}>
<div style={{ width: 14, height: 14, margin: '0 auto', borderRadius: '50%', background: GOLD }} />
<div style={{ width: 22, height: 44, margin: '0 auto', borderRadius: '4px 4px 8px 8px', background: `repeating-linear-gradient(90deg,${GOLD} 0 3px,#a8843c 3px 5px)` }} />
</div>
))}
</>
);
};
const TitleBlock: React.FC = () => {
const t = useT();
const end = t >= E('cta');
return (
<div style={{ position: 'absolute', left: DIAL[0] - 300, width: 600, top: 96, textAlign: 'center', color: PALE }}>
<div style={{ font: `700 46px/52px ${CAPS}`, letterSpacing: '.16em', textShadow: '0 3px 10px rgba(0,0,0,.7)' }}>{TITLE}</div>
<div style={{ font: `italic 600 36px/40px ${ITALIC}`, color: CREAM, opacity: 0.85 }}>{end ? SUBTITLE_END : SUBTITLE}</div>
</div>
);
};
/** Below the safe area the platform's buttons cover everything, so only the tablecloth lives there. */
const Drape: React.FC = () => {
const t = useT();
return (
<>
<div style={{ position: 'absolute', left: 0, right: 0, top: 1500, height: 420, background: `linear-gradient(90deg,#3a0d22,${WINE_DK} 12%,#3f0e24 22%,${WINE} 36%,#47102a 50%,${WINE} 64%,#3f0e24 78%,${WINE_DK} 88%,#3a0d22)` }} />
<div style={{ position: 'absolute', left: 0, right: 0, top: 1490, height: 24, background: `repeating-linear-gradient(90deg,${GOLD} 0 10px,#a8843c 10px 14px)`, boxShadow: '0 8px 16px rgba(0,0,0,.55)' }} />
<svg viewBox="0 0 1080 420" width={1080} height={420} style={{ position: 'absolute', left: 0, top: 1500 }}>
{[[200, 170, 1], [540, 230, 1.3], [880, 170, 1]].map(([x, y, s], i) => (
<g key={i} transform={`translate(${x} ${y}) scale(${s})`} opacity={0.55}>
<circle r={56} fill="none" stroke={GOLD} strokeWidth={3} />
<path d="M18 -42A46 46 0 1 0 18 42A36 36 0 1 1 18 -42Z" fill={GOLD} />
{[[64, -50], [78, 40], [-78, -30], [-60, 62]].map(([sx, sy], k) => <path key={k} d={star4(13, 3)} transform={`translate(${sx} ${sy})`} fill={GOLD} />)}
</g>
))}
</svg>
{Array.from({ length: 9 }, (_, i) => (
<div key={i} style={{ position: 'absolute', left: 60 + i * 120 - 12, top: 1514, width: 24, height: 76, transformOrigin: '12px 0', transform: `rotate(${Math.sin(t * 1.3 + i * 0.8) * 2.4}deg)` }}>
<div style={{ width: 14, height: 14, margin: '0 auto', borderRadius: '50%', background: GOLD }} />
<div style={{ width: 22, height: 56, margin: '0 auto', borderRadius: '3px 3px 9px 9px', background: `repeating-linear-gradient(90deg,${GOLD} 0 3px,#a8843c 3px 5px)` }} />
</div>
))}
</>
);
};
/** Three places stitched into the velvet, waiting for the spread. */
const Slots: React.FC = () => (
<svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0 }}>
{SLOT.map((s, i) => {
const w = CW * s.k, h = CH * s.k;
const m = numeralD(['I', 'II', 'III'][i], 0, 40);
return (
<g key={i} opacity={0.75}>
<rect x={s.x - w / 2} y={s.y - h / 2} width={w} height={h} rx={14} fill="rgba(10,6,30,.28)" stroke={GOLD} strokeWidth={3} strokeDasharray="4 10" />
<g transform={`translate(${s.x - m.w / 2} ${s.y - 20})`}><path d={m.d} fill="none" stroke={GOLD} strokeWidth={4} /></g>
</g>
);
})}
</svg>
);
/* ---------- the candle ---------- */
const Candle: React.FC = () => {
const [x, y] = WICK;
return (
<svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0 }}>
<defs>
<linearGradient id="wax" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stopColor="#c9b078" /><stop offset="0.35" stopColor="#f6ecd5" /><stop offset="1" stopColor="#a48b5c" /></linearGradient>
<linearGradient id="brass" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stopColor="#7d5f26" /><stop offset="0.4" stopColor={PALE} /><stop offset="1" stopColor="#6a4f1e" /></linearGradient>
</defs>
{/* saucer and stem */}
<ellipse cx={x + 4} cy={y + 362} rx={92} ry={20} fill="rgba(5,3,20,.6)" />
<ellipse cx={x} cy={y + 350} rx={88} ry={20} fill="url(#brass)" stroke={INK} strokeWidth={2.5} />
<ellipse cx={x} cy={y + 344} rx={64} ry={12} fill="#a8843c" />
<path d={`M${x - 48} ${y + 340}C${x - 36} ${y + 310} ${x - 30} ${y + 300} ${x - 44} ${y + 280}H${x + 44}C${x + 30} ${y + 300} ${x + 36} ${y + 310} ${x + 48} ${y + 340}Z`} fill="url(#brass)" stroke={INK} strokeWidth={2.5} />
<path d={`M${x + 86} ${y + 346}a24 24 0 1 1 -4 -36`} fill="none" stroke="url(#brass)" strokeWidth={9} />
<rect x={x - 50} y={y + 268} width={100} height={16} rx={6} fill="url(#brass)" stroke={INK} strokeWidth={2.5} />
{/* wax */}
<path d={`M${x - 34} ${y + 268}V${y + 10}C${x - 26} ${y - 2} ${x + 26} ${y - 2} ${x + 34} ${y + 10}V${y + 268}Z`} fill="url(#wax)" stroke={INK} strokeWidth={2.5} />
<path d={`M${x + 14} ${y + 8}C${x + 22} ${y + 40} ${x + 12} ${y + 60} ${x + 18} ${y + 92}C${x + 22} ${y + 112} ${x + 32} ${y + 104} ${x + 30} ${y + 84}C${x + 28} ${y + 50} ${x + 34} ${y + 30} ${x + 33} ${y + 10}Z`} fill="#fbf3df" stroke={INK} strokeWidth={1.8} opacity={0.95} />
<path d={`M${x - 30} ${y + 8}C${x - 34} ${y + 40} ${x - 24} ${y + 44} ${x - 22} ${y + 26}C${x - 20} ${y + 16} ${x - 18} ${y + 10} ${x - 14} ${y + 4}Z`} fill="#fbf3df" stroke={INK} strokeWidth={1.6} />
<ellipse cx={x} cy={y + 8} rx={25} ry={7} fill="#fff8e6" stroke={INK} strokeWidth={1.5} />
{/* wick */}
<path d={`M${x} ${y + 8}C${x + 1} ${y} ${x - 2} ${y - 8} ${x + 1} ${y - 18}`} fill="none" stroke="#1b1014" strokeWidth={4.5} strokeLinecap="round" />
</svg>
);
};
const Flame: React.FC = () => {
const t = useT();
const h = flameAt(t);
const [x, y0] = WICK;
const y = y0 - 14;
const st = steadyAt(t);
const gust = t > E('week') - 0.3 && t < E('nobody') + 0.2 ? Math.sin(t * 19) * 0.8 : 0;
const lean = 0.65 * leanAt(t) + (noise1(t * 3.1 + 4) - 0.5) * 0.4 * (1 - st * 0.92) + gust;
const ember = t >= E('nobody') && t < E('door') + 0.2 ? 1 - ramp(t, E('nobody') + 0.1, E('nobody') + 1.6) : 0;
const spark = t < 0.3 || (t >= E('door') - 0.05 && t < E('door') + 0.3) ? 1 : 0;
const size = 132 * Math.max(0, h), w = (26 + 14 * clamp(h)) * Math.min(1, 0.25 + clamp(h) * 1.6);
const tip = lean * 46;
return (
<>
{h > 0.02 ? <div style={{ position: 'absolute', left: x - 300, top: y - size * 0.6 - 300, width: 600, height: 600, background: `radial-gradient(circle, rgba(255,186,90,${0.42 * clamp(h)}) 0, rgba(255,150,60,${0.14 * clamp(h)}) 34%, rgba(255,150,60,0) 68%)` }} /> : null}
<svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible' }}>
<defs>
<linearGradient id="flameO" x1="0" y1="1" x2="0" y2="0"><stop offset="0" stopColor="#ff7a1c" /><stop offset="0.5" stopColor="#ffb040" /><stop offset="1" stopColor="#ffe08a" /></linearGradient>
<linearGradient id="flameI" x1="0" y1="1" x2="0" y2="0"><stop offset="0" stopColor="#4f7dff" /><stop offset="0.22" stopColor="#fff2c0" /><stop offset="1" stopColor="#fffbe8" /></linearGradient>
</defs>
{h > 0.02 ? (
<g transform={`translate(${x} ${y})`}>
<path d={`M0 6C${-w} ${-size * 0.18} ${-w * 0.72 + tip * 0.3} ${-size * 0.62} ${tip} ${-size}C${w * 0.72 + tip * 0.3} ${-size * 0.62} ${w} ${-size * 0.18} 0 6Z`} fill="url(#flameO)" />
<path d={`M0 4C${-w * 0.56} ${-size * 0.14} ${-w * 0.38 + tip * 0.3} ${-size * 0.42} ${tip * 0.55} ${-size * 0.66}C${w * 0.38 + tip * 0.3} ${-size * 0.42} ${w * 0.56} ${-size * 0.14} 0 4Z`} fill="url(#flameI)" />
</g>
) : null}
{ember > 0 ? <circle cx={x + 1} cy={y0 - 18} r={5} fill="#ff6a1c" opacity={ember} /> : null}
{spark > 0 ? Array.from({ length: 5 }, (_, i) => <circle key={i} cx={x + (hash(i + Math.floor(t * 30)) - 0.5) * 34} cy={y0 - 20 - hash(i * 3 + Math.floor(t * 30)) * 34} r={2.5} fill="#ffd27a" />) : null}
</svg>
</>
);
};
/** Smoke from the dead wick. It leans toward the moon, so the eye is already there when the voice says to look. */
const Smoke: React.FC = () => {
const t = useT();
const t0 = E('nobody') + 0.1;
const end = E('door') + 0.5;
if (t < t0 || t > end) return null;
const age = t - t0;
const fade = 1 - ramp(t, end - 1.0, end);
const strand = (ph: number, amp: number, reach: number) => {
const n = 40, grow = clamp(age / 1.7) * n, tail = clamp((age - 2.4) / 2) * n;
let d = '';
for (let i = Math.floor(tail); i < grow; i++) {
const q = i / n;
const x = WICK[0] + Math.pow(q, 1.7) * reach + Math.sin(i * 0.42 + ph - t * 2.2) * amp * (0.12 + q);
const y = WICK[1] - 20 - Math.pow(q, 0.8) * 300;
d += `${d ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`;
}
return d;
};
return (
<svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible', opacity: 0.85 * fade, filter: 'blur(2.5px)' }}>
<path d={strand(0, 34, 330)} fill="none" stroke="#d9d0ee" strokeWidth={10} strokeLinecap="round" strokeLinejoin="round" opacity={0.75} />
<path d={strand(2, 48, 380)} fill="none" stroke="#bfb5de" strokeWidth={7} strokeLinecap="round" strokeLinejoin="round" opacity={0.65} />
<path d={strand(4, 24, 280)} fill="none" stroke="#e7e0f6" strokeWidth={5} strokeLinecap="round" strokeLinejoin="round" opacity={0.7} />
</svg>
);
};
/* ---------- engraving: ink hatching, shared by every drawing on the cards ---------- */
const HATCH: [string, number, number, number][] = [['h1', 45, 9, 1.3], ['h2', 45, 6, 1.3], ['h3', 45, 4, 1.3], ['b2', -45, 6, 1.3], ['v2', 90, 7, 1.2], ['f2', 0, 6, 1.2]];
const EngravingDefs: React.FC = () => (
<svg width={0} height={0} style={{ position: 'absolute' }}>
<defs>
{HATCH.map(([id, a, g, w]) => <pattern key={id} id={id} width={g} height={g} patternUnits="userSpaceOnUse" patternTransform={`rotate(${a})`}><line x1={0} y1={0} x2={0} y2={g} stroke={INK} strokeWidth={w} /></pattern>)}
<pattern id="moonHatch" width={7} height={7} patternUnits="userSpaceOnUse" patternTransform="rotate(30)"><line x1={0} y1={0} x2={0} y2={7} stroke="#a8843c" strokeWidth={1.6} /></pattern>
</defs>
</svg>
);
const SKY_D = Array.from({ length: 34 }, (_, i) => `M0 ${(i * i * 0.46 + 8).toFixed(1)}H490`).join('');
const Sky: React.FC<{ opacity?: number }> = ({ opacity = 0.5 }) => <path d={SKY_D} stroke={INK} strokeWidth={1} opacity={opacity} />;
/** A hill with contour lines that follow its shape. `top(dy)` draws the skyline moved down by dy, without closing it. */
const Hill: React.FC<{ id: string; top: (dy: number) => string; fill?: string; n?: number }> = ({ id, top, fill = STOCK, n = 13 }) => {
const d0 = top(0);
return (
<>
<clipPath id={id}><path d={`${d0}V600H0Z`} /></clipPath>
<path d={`${d0}V600H0Z`} fill={fill} />
<g clipPath={`url(#${id})`}>{Array.from({ length: n }, (_, i) => <path key={i} d={top(7 + i * 5 + i * i * 0.8)} fill="none" stroke={INK} strokeWidth={1.2} />)}</g>
<path d={d0} fill="none" stroke={INK} strokeWidth={3} />
</>
);
};
const bubbleD = (w: number, h: number, right = false) => {
const r = Math.min(w, h) * 0.42, a = right ? w * 0.62 : w * 0.2, tw = Math.min(30, h * 0.34), td = Math.min(26, h * 0.3);
return `M${r} 0H${w - r}Q${w} 0 ${w} ${r}V${h - r}Q${w} ${h} ${w - r} ${h}H${a + tw}L${a + (right ? tw * 1.2 : -tw * 0.27)} ${h + td}L${a} ${h}H${r}Q0 ${h} 0 ${h - r}V${r}Q0 0 ${r} 0Z`;
};
/** A speech bubble: ink outline, hatch along the bottom edge. `dots` is the typing indicator; `lines` are lines of tiny text. */
const Bubble: React.FC<{ id: string; x: number; y: number; w: number; h: number; right?: boolean; dots?: number; lines?: number; fill?: string; rot?: number; s?: number }> = ({ id, x, y, w, h, right, dots, lines, fill = STOCK, rot = 0, s = 1 }) => {
const t = useT();
const d = bubbleD(w, h, right);
return (
<g transform={`translate(${x} ${y}) rotate(${rot} ${w / 2} ${h / 2}) scale(${s})`}>
<clipPath id={id}><path d={d} /></clipPath>
<path d={d} fill={fill} />
<g clipPath={`url(#${id})`}><rect x={-4} y={h * 0.62} width={w + 8} height={h} fill="url(#h2)" /><rect x={w * 0.76} y={-4} width={w} height={h + 8} fill="url(#b2)" /></g>
<path d={d} fill="none" stroke={INK} strokeWidth={3.2} strokeLinejoin="round" />
{dots !== undefined ? [0, 1, 2].map((i) => <circle key={i} cx={w / 2 + (i - 1) * Math.min(26, w * 0.17)} cy={h * 0.45 - Math.max(0, Math.sin(t * 7 - i * 0.9 + dots)) * 7} r={Math.min(9, h * 0.09)} fill={INK} />) : null}
{lines ? Array.from({ length: lines }, (_, i) => <line key={i} x1={w * 0.18} x2={w * (i === lines - 1 ? 0.55 : 0.78)} y1={h * 0.28 + i * (h * 0.22)} y2={h * 0.28 + i * (h * 0.22)} stroke={INK} strokeWidth={3} strokeLinecap="round" />) : null}
</g>
);
};
/** A small hooded figure, no face: this deck draws everyone the same. Holds a phone up in one hand. */
const Figure: React.FC<{ x: number; y: number; s?: number; id: string; umbrella?: boolean }> = ({ x, y, s = 1, id, umbrella }) => {
const robe = 'M-30 0C-28 -34 -24 -60 -13 -74C-20 -92 -12 -108 2 -108C16 -108 23 -92 15 -74C26 -60 30 -34 32 0Z';
return (
<g transform={`translate(${x} ${y}) scale(${s})`}>
<ellipse cx={2} cy={2} rx={44} ry={6} fill="url(#h3)" />
<clipPath id={id}><path d={robe} /></clipPath>
<path d={robe} fill={STOCK} />
<g clipPath={`url(#${id})`}><rect x={2} y={-110} width={40} height={112} fill="url(#h3)" /><rect x={-34} y={-70} width={40} height={72} fill="url(#h1)" /></g>
<path d={robe} fill="none" stroke={INK} strokeWidth={3} strokeLinejoin="round" />
<path d="M-8 -84C-8 -100 12 -100 12 -84C12 -74 -8 -74 -8 -84Z" fill={INK} />
{umbrella ? (
<g>
<path d="M18 -60L18 -150" stroke={INK} strokeWidth={4} />
<path d="M-58 -142C-48 -196 84 -196 94 -142C78 -158 62 -158 50 -142C38 -158 -2 -158 -14 -142C-26 -158 -44 -158 -58 -142Z" fill={STOCK} stroke={INK} strokeWidth={3} strokeLinejoin="round" />
<path d="M-14 -142C-8 -172 8 -184 18 -184C28 -184 44 -172 50 -142" fill="none" stroke={INK} strokeWidth={2} />
<path d="M-58 -142C-48 -196 18 -184 18 -184C-2 -180 -8 -166 -14 -142C-26 -158 -44 -158 -58 -142Z" fill="url(#h2)" />
</g>
) : (
<g>
<path d="M14 -62L40 -92" stroke={INK} strokeWidth={8} strokeLinecap="round" />
<rect x={30} y={-122} width={20} height={34} rx={4} fill={STOCK} stroke={INK} strokeWidth={3} />
</g>
)}
</g>
);
};
/* ---------- the card drawings. Each is 490 x 572, the arch window of a card ---------- */
const CLOUD: [number, number, number, number, boolean][] = [[28, 110, 170, 96, false], [284, 96, 176, 104, true], [150, 26, 200, 110, false], [60, 196, 164, 88, true], [268, 200, 170, 86, false], [168, 132, 170, 92, false]];
const RAINDROPS = Array.from({ length: 12 }, (_, i) => ({ x: 44 + hash(i + 11) * 400, ph: hash(i + 40) * 160, sp: 60 + hash(i + 70) * 50 }));
const ArtChat: React.FC = () => {
const t = useT();
const bob = (i: number) => Math.sin(t * 1.6 + i * 1.3) * 3;
return (
<svg viewBox="0 0 490 572" width={490} height={572} style={{ position: 'absolute', left: 0, top: 0 }}>
<Sky />
{RAINDROPS.map((r, i) => {
const y = 320 + ((r.ph + t * r.sp) % 150);
return <g key={i} opacity={clamp((470 - y) / 40)}><Bubble id={`rd${i}`} x={r.x} y={y} w={30} h={20} /></g>;
})}
<Hill id="chH" top={(d) => `M0 ${476 + d}C90 ${446 + d} 170 ${470 + d} 245 ${466 + d}C330 ${462 + d} 400 ${438 + d} 490 ${470 + d}`} />
{CLOUD.map(([x, y, w, h, right], i) => <Bubble key={i} id={`cl${i}`} x={x} y={y + bob(i)} w={w} h={h} right={right} dots={i * 0.8} />)}
<Figure id="chF" x={232} y={506} s={1.05} umbrella />
</svg>
);
};
const raysD = (n: number, r0: number, r1: number, half: number) => Array.from({ length: n }, (_, i) => {
const a = (i / n) * Math.PI * 2;
const p = (r: number, b: number): string => `${(Math.cos(b) * r).toFixed(1)} ${(Math.sin(b) * r).toFixed(1)}`;
return `M${p(r0, a - half)}L${p(r1, a)}L${p(r0, a + half)}Z`;
}).join('');
const RAYS_LONG = raysD(14, 150, 430, 0.1), RAYS_SHORT = raysD(14, 150, 300, 0.05);
const unreadAt = (t: number) => (t >= E('notice') ? UNREAD + 1 : Math.floor(lerp(UNREAD * 0.12, UNREAD, easeOut(ramp(t, E('ninety') - 0.1, E('ninety') + 0.5)))));
const ArtNotification: React.FC = () => {
const t = useT();
const hit = E('ninety');
const jig = t > hit - 0.1 ? Math.sin(t * 90) * 1.8 * Math.exp(-(t - hit) * 2.2) : Math.sin(t * 60) * 0.3;
const scroll = Math.max(0, t - E('first')) * 34 + easeOut(ramp(t, hit - 0.1, hit + 0.7)) * 280;
const pop = t > hit - 0.1 ? 1 + 0.25 * Math.exp(-(t - hit) * 5) : 1;
const tick = t >= E('notice') ? 1 + 0.9 * Math.exp(-(t - E('notice')) * 3.5) : 1;
const count = unreadAt(t);
return (
<>
<svg viewBox="0 0 490 572" width={490} height={572} style={{ position: 'absolute', left: 0, top: 0 }}>
<Sky opacity={0.4} />
<g transform={`translate(245 290) rotate(${t * 7})`}>
<path d={RAYS_LONG} fill={PALE} stroke={INK} strokeWidth={2} />
<path d={RAYS_SHORT} fill={INK} transform="rotate(12.857)" />
</g>
<Hill id="noH" top={(d) => `M0 ${506 + d}C110 ${484 + d} 190 ${512 + d} 280 ${504 + d}C360 ${498 + d} 420 ${482 + d} 490 ${508 + d}`} n={9} />
<g transform={`translate(245 295) rotate(${jig}) translate(-245 -295)`}>
{[0, 1, 2].map((i) => {
const o = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(t * 12 - i));
return <g key={i} opacity={o}><path d={`M${126 - i * 20} ${245 - i * 12}Q${108 - i * 26} 295 ${126 - i * 20} ${345 + i * 12}`} fill="none" stroke={INK} strokeWidth={5} strokeLinecap="round" /><path d={`M${364 + i * 20} ${245 - i * 12}Q${382 + i * 26} 295 ${364 + i * 20} ${345 + i * 12}`} fill="none" stroke={INK} strokeWidth={5} strokeLinecap="round" /></g>;
})}
<rect x={146} y={116} width={198} height={362} rx={36} fill={STOCK} stroke={INK} strokeWidth={5} />
<rect x={146} y={116} width={198} height={362} rx={36} fill="url(#h3)" opacity={0.65} />
<rect x={160} y={150} width={170} height={296} rx={10} fill="#fbf3df" stroke={INK} strokeWidth={3} />
<line x1={222} x2={268} y1={134} y2={134} stroke={INK} strokeWidth={5} strokeLinecap="round" />
<circle cx={245} cy={462} r={9} fill={STOCK} stroke={INK} strokeWidth={3} />
<clipPath id="noS"><rect x={161} y={151} width={168} height={294} rx={9} /></clipPath>
<g clipPath="url(#noS)">
{Array.from({ length: 7 }, (_, i) => {
const y = 440 - ((i * 62 + scroll) % 434);
const right = i % 3 === 1;
return <Bubble key={i} id={`noB${i}`} x={right ? 212 : 168} y={y} w={108} h={42} right={right} lines={2} />;
})}
</g>
</g>
<g transform={`translate(346 122) scale(${pop * tick})`}>
<circle r={62} fill={GOLD} stroke={INK} strokeWidth={3} />
<circle r={52} fill={WINE} stroke={INK} strokeWidth={2} />
</g>
</svg>
<div style={{ position: 'absolute', left: 346 - 60, top: 122 - 36, width: 120, height: 72, textAlign: 'center', font: `900 ${count > 99 ? 48 : 62}px/72px ${CAPS}`, color: CREAM, transform: `scale(${pop * tick})` }}>{count}</div>
</>
);
};
const webD = (cx: number, cy: number, r: number, a0: number, a1: number, spokes: number, rings: number) => {
let d = '';
const at = (a: number, rr: number): P => [cx + Math.cos(a) * rr, cy + Math.sin(a) * rr];
for (let i = 0; i < spokes; i++) { const [x, y] = at(lerp(a0, a1, i / (spokes - 1)), r); d += `M${cx} ${cy}L${x.toFixed(1)} ${y.toFixed(1)}`; }
for (let k = 1; k <= rings; k++) {
const rr = (r * k) / rings;
for (let i = 0; i < spokes - 1; i++) {
const [x0, y0] = at(lerp(a0, a1, i / (spokes - 1)), rr), [x1, y1] = at(lerp(a0, a1, (i + 1) / (spokes - 1)), rr), [mx, my] = at(lerp(a0, a1, (i + 0.5) / (spokes - 1)), rr * 0.84);
d += `M${x0.toFixed(1)} ${y0.toFixed(1)}Q${mx.toFixed(1)} ${my.toFixed(1)} ${x1.toFixed(1)} ${y1.toFixed(1)}`;
}
}
return d;
};
const WEB_A = webD(92, 232, 150, Math.PI, Math.PI * 1.62, 6, 5), WEB_B = webD(398, 232, 130, -Math.PI * 0.62, 0, 6, 4), WEB_C = webD(285, 196, 120, -Math.PI * 0.55, -Math.PI * 0.04, 5, 4);
const MOTES = Array.from({ length: 12 }, (_, i) => ({ x: 40 + hash(i + 3) * 410, y: 40 + hash(i + 19) * 330, ph: hash(i + 8) * 6.28 }));
const ArtItinerary: React.FC = () => {
const t = useT();
const web = easeOut(ramp(t, E('second') + 0.1, E('second') + 0.9));
const web2 = easeOut(ramp(t, E('never') - 0.1, E('never') + 0.6));
const sp = lerp(70, 158, easeInOut(ramp(t, E('never') - 0.25, E('never') + 0.5))) + Math.sin(t * 1.9) * 5;
return (
<>
<svg viewBox="0 0 490 572" width={490} height={572} style={{ position: 'absolute', left: 0, top: 0 }}>
{/* the back of a closet: wallpaper, then a floor */}
<rect x={0} y={0} width={490} height={470} fill="url(#v2)" opacity={0.42} />
{Array.from({ length: 5 }, (_, i) => <g key={i}>{Array.from({ length: 6 }, (_, k) => <path key={k} d={star4(7, 1.6)} transform={`translate(${28 + k * 88 + (i % 2) * 44} ${50 + i * 86})`} fill={INK} opacity={0.6} />)}</g>)}
<rect x={0} y={466} width={490} height={106} fill={STOCK} />
{Array.from({ length: 9 }, (_, i) => <line key={i} x1={245 + (i - 4) * 56} y1={466} x2={245 + (i - 4) * 120} y2={572} stroke={INK} strokeWidth={1.5} />)}
{[482, 502, 530].map((y) => <line key={y} x1={0} x2={490} y1={y} y2={y} stroke={INK} strokeWidth={1.2} />)}
<line x1={0} x2={490} y1={466} y2={466} stroke={INK} strokeWidth={3.5} />
<ellipse cx={250} cy={472} rx={190} ry={13} fill="url(#h3)" />
{/* the suitcase */}
<path d="M205 228V198Q205 180 223 180H267Q285 180 285 198V228" fill="none" stroke={INK} strokeWidth={16} />
<path d="M205 228V198Q205 180 223 180H267Q285 180 285 198V228" fill="none" stroke={STOCK} strokeWidth={8} />
<rect x={84} y={226} width={322} height={242} rx={24} fill={STOCK} stroke={INK} strokeWidth={5} />
<rect x={330} y={230} width={74} height={236} fill="url(#h2)" />
<rect x={88} y={416} width={316} height={50} rx={18} fill="url(#b2)" />
<rect x={84} y={226} width={322} height={242} rx={24} fill="none" stroke={INK} strokeWidth={5} />
<line x1={84} x2={406} y1={286} y2={286} stroke={INK} strokeWidth={3} /><line x1={84} x2={406} y1={294} y2={294} stroke={INK} strokeWidth={1.5} />
{[128, 334].map((x) => <g key={x}><rect x={x} y={226} width={30} height={242} fill={STOCK} stroke={INK} strokeWidth={3} /><rect x={x} y={226} width={30} height={242} fill="url(#f2)" opacity={0.7} /><rect x={x - 6} y={270} width={42} height={34} rx={4} fill={GOLD} stroke={INK} strokeWidth={3} /><rect x={x + 8} y={278} width={14} height={18} fill={INK} /></g>)}
{[[84, 226, 0], [406, 226, 90], [406, 468, 180], [84, 468, 270]].map(([x, y, r], i) => <g key={i} transform={`translate(${x} ${y}) rotate(${r})`}><path d="M0 44V18Q0 0 18 0H44L44 16H22Q16 16 16 22V44Z" fill={GOLD} stroke={INK} strokeWidth={2.5} /><circle cx={9} cy={34} r={2.5} fill={INK} /><circle cx={34} cy={9} r={2.5} fill={INK} /></g>)}
{/* a sticker from a trip that was only ever discussed */}
<g transform="rotate(-9 245 360)">
<circle cx={245} cy={366} r={58} fill="#fbf3df" stroke={INK} strokeWidth={3} strokeDasharray="7 5" />
<circle cx={245} cy={348} r={20} fill={GOLD} stroke={INK} strokeWidth={2.5} />
{Array.from({ length: 10 }, (_, i) => <line key={i} transform={`rotate(${i * 36} 245 348)`} x1={245} x2={245} y1={320} y2={310} stroke={INK} strokeWidth={2.5} />)}
<path d="M197 376Q221 362 245 376T293 376" fill="none" stroke={INK} strokeWidth={3} /><path d="M201 392Q223 380 245 392T289 392" fill="none" stroke={INK} strokeWidth={2.2} />
</g>
{/* dust along the top */}
{Array.from({ length: 40 }, (_, i) => <circle key={i} cx={96 + hash(i + 200) * 298} cy={232 + hash(i + 300) * 9} r={1.1 + hash(i + 400) * 1.5} fill={INK} opacity={0.7} />)}
{/* the tag */}
<path d="M285 196C318 184 330 178 356 168" fill="none" stroke={INK} strokeWidth={3} />
<g transform="rotate(10 400 160)"><path d="M352 136H440Q446 136 446 142V184Q446 190 440 190H352L336 163Z" fill="#fbf3df" stroke={INK} strokeWidth={3} /><circle cx={356} cy={163} r={6} fill="none" stroke={INK} strokeWidth={2.5} /></g>
{/* cobwebs */}
<path d={WEB_A} fill="none" stroke={INK} strokeWidth={1.7} pathLength={1} strokeDasharray={1} strokeDashoffset={1 - web} />
<path d={WEB_B} fill="none" stroke={INK} strokeWidth={1.7} pathLength={1} strokeDasharray={1} strokeDashoffset={1 - lerp(web * 0.35, 1, web2)} />
<path d={WEB_C} fill="none" stroke={INK} strokeWidth={1.6} pathLength={1} strokeDasharray={1} strokeDashoffset={1 - web2} />
{/* the spider, who has moved in */}
<line x1={176} x2={176} y1={0} y2={sp} stroke={INK} strokeWidth={1.8} />
<g transform={`translate(176 ${sp})`}>
{[-1, 1].map((sd) => [0, 1, 2, 3].map((k) => <path key={`${sd}${k}`} d={`M0 0Q${sd * 14} ${-10 + k * 6} ${sd * 22} ${-2 + k * 8}`} fill="none" stroke={INK} strokeWidth={2.2} />))}
<ellipse cx={0} cy={2} rx={8} ry={10} fill={INK} />
</g>
{MOTES.map((m, i) => <circle key={i} cx={m.x + Math.sin(t * 0.6 + m.ph) * 8} cy={m.y + Math.cos(t * 0.5 + m.ph) * 6} r={2} fill="#fbf3df" stroke={INK} strokeWidth={1} />)}
</svg>
<div style={{ position: 'absolute', left: 350, top: 136, width: 96, height: 54, textAlign: 'center', font: `900 ${fit(LUGGAGE_TAG, 84, 34)}px/54px ${CAPS}`, color: WINE, transform: 'rotate(10deg)' }}>{LUGGAGE_TAG}</div>
</>
);
};
const PILE: [number, number, number][] = [[-16, 492, 118], [92, 496, 112], [196, 492, 118], [306, 496, 112], [404, 492, 110], [30, 424, 112], [136, 420, 118], [246, 424, 112], [352, 420, 108], [84, 352, 110], [192, 348, 112], [298, 352, 104], [140, 282, 104], [240, 280, 106]];
/** The replies pop out one by one, on the side away from the YOU tag. x, y, width, height. */
const REPLY_AT: [number, number, number, number][] = [[62, 92, 190, 72], [28, 198, 118, 60], [168, 24, 150, 58]];
const ArtLastWord: React.FC = () => {
const t = useT();
const at = [E('you') + 0.1, E('replying') - 0.55, E('replying') - 0.05];
return (
<>
<svg viewBox="0 0 490 572" width={490} height={572} style={{ position: 'absolute', left: 0, top: 0 }}>
<Sky />
<g transform={`translate(240 200) rotate(${t * -6})`}><path d={raysD(20, 70, 200, 0.05)} fill={INK} opacity={0.5} /></g>
{PILE.map(([x, y, w], i) => <Bubble key={i} id={`pl${i}`} x={x} y={y} w={w} h={68} right={i % 2 === 0} lines={2} rot={(hash(i + 60) - 0.5) * 9} />)}
<Figure id="lwF" x={236} y={302} s={1.3} />
{at.map((a, i) => {
if (t < a) return null;
const k = back(ramp(t, a, a + 0.22));
const [x, y, w, h] = REPLY_AT[i];
return <g key={i} transform={`translate(${x + w / 2} ${y + h / 2}) scale(${k}) translate(${-x - w / 2} ${-y - h / 2})`}><Bubble id={`rp${i}`} x={x} y={y} w={w} h={h} right={i !== 1} fill="#fbf3df" /></g>;
})}
</svg>
{at.map((a, i) => {
if (t < a + 0.08) return null;
const [x, y, w, h] = REPLY_AT[i];
return <div key={i} style={{ position: 'absolute', left: x, top: y, width: w, height: h * 0.92, textAlign: 'center', font: `italic 700 ${fit(REPLIES[i], w - 20, 42, 0.42)}px/${h * 0.92}px ${ITALIC}`, color: INK, whiteSpace: 'nowrap' }}>{REPLIES[i]}</div>;
})}
</>
);
};
const ArtQuiet: React.FC = () => {
const t = useT();
const land = easeIn(ramp(t, E('down') - 0.24, E('down'))); // the phone comes down on its face, on the word
const puff = ramp(t, E('down'), E('down') + 0.5);
const snail = 322 - Math.max(0, t - E('down')) * 9;
const sway = Math.sin(t * 1.5) * 1.6;
return (
<svg viewBox="0 0 490 572" width={490} height={572} style={{ position: 'absolute', left: 0, top: 0 }}>
{/* moonlit rings on gold */}
{Array.from({ length: 16 }, (_, i) => <circle key={i} cx={245} cy={150} r={72 + i * 26 + (i * i) * 0.6} fill="none" stroke={INK} strokeWidth={1.2} opacity={0.75 - i * 0.03} />)}
<circle cx={245} cy={150} r={62} fill="#fff4cf" stroke={INK} strokeWidth={3} />
<path d="M245 88A62 62 0 0 0 245 212A84 84 0 0 1 245 88Z" fill="url(#h2)" />
{[[70, 90], [420, 110], [110, 250], [392, 262], [46, 190], [448, 200]].map(([x, y], i) => <path key={i} d={star4(12 + (i % 2) * 5, 2.6)} transform={`translate(${x} ${y}) scale(${1 + Math.sin(t * 2 + i) * 0.15})`} fill={INK} />)}
{/* the far hill, and on it the door the constellation drew, standing open */}
<Hill id="quH" fill="#f3d994" n={8} top={(d) => `M0 ${372 + d}C90 ${338 + d} 170 ${330 + d} 245 ${332 + d}C330 ${332 + d} 410 ${342 + d} 490 ${378 + d}`} />
<g>
<path d="M212 336V272Q212 240 245 240Q278 240 278 272V336Z" fill="#fff4cf" stroke={INK} strokeWidth={3.5} />
<path d="M212 336V272Q212 240 245 240L232 252Q224 262 224 278V330Z" fill={STOCK} stroke={INK} strokeWidth={2.5} />
<path d="M212 336V272Q212 240 245 240L232 252Q224 262 224 278V330Z" fill="url(#h2)" />
<path d="M212 336L180 372H310L278 336Z" fill="#fff4cf" opacity={0.9} />
<path d="M212 336L180 372M278 336L310 372" fill="none" stroke={INK} strokeWidth={1.5} strokeDasharray="4 5" />
</g>
{/* the near ground */}
<Hill id="quG" fill="#ecd08a" n={11} top={(d) => `M0 ${450 + d}C120 ${420 + d} 200 ${434 + d} 270 ${436 + d}C350 ${438 + d} 420 ${424 + d} 490 ${452 + d}`} />
{/* the phone: its back up, camera and all. It lies flat, so the whole drawing is squashed and turned */}
<ellipse cx={252} cy={512} rx={lerp(120, 178, land)} ry={lerp(10, 20, land)} fill="url(#h3)" />
<g transform={`translate(245 ${lerp(432, 490, land)})`}>
<g transform={`translate(0 16) scale(1 ${lerp(0.72, 0.5, land)}) rotate(-84)`}><rect x={-72} y={-155} width={144} height={310} rx={30} fill="#5a3f14" stroke={INK} strokeWidth={4} /></g>
<g transform={`scale(1 ${lerp(0.72, 0.5, land)}) rotate(-84)`}>
<rect x={-72} y={-155} width={144} height={310} rx={30} fill="#f8ecc4" stroke={INK} strokeWidth={5} />
<rect x={-72} y={-155} width={72} height={310} rx={30} fill="url(#h2)" opacity={0.75} />
<rect x={-54} y={-136} width={78} height={78} rx={20} fill={GOLD} stroke={INK} strokeWidth={4} />
<circle cx={-30} cy={-112} r={13} fill={INK} /><circle cx={-30} cy={-82} r={13} fill={INK} /><circle cx={2} cy={-97} r={9} fill={INK} />
</g>
</g>
{puff > 0 && puff < 1 ? [-1, 1].map((sd) => <g key={sd} opacity={1 - puff} transform={`translate(${245 + sd * (150 + puff * 34)} ${498 - puff * 12})`}><path d={`M0 0C${sd * 10} -8 ${sd * 22} -8 ${sd * 28} 2M${sd * 4} -12C${sd * 14} -20 ${sd * 24} -18 ${sd * 30} -10`} fill="none" stroke={INK} strokeWidth={3} strokeLinecap="round" /></g>) : null}
{/* moss has started, and a snail is taking its time */}
{[[62, 514], [92, 532], [414, 516], [392, 536], [440, 498]].map(([x, y], i) => <g key={i} transform={`translate(${x} ${y}) rotate(${sway * (i % 2 ? 1 : -1)})`}><path d="M0 0C-4 -14 -2 -24 -8 -36M0 0C4 -12 8 -18 14 -28" fill="none" stroke={INK} strokeWidth={2.4} /><circle cx={-8} cy={-38} r={4} fill={INK} /></g>)}
{t >= E('down') ? <g transform={`translate(${snail} 478)`}>
<path d="M-22 6C-10 8 16 8 26 6C30 5 34 -2 34 -8" fill="none" stroke={INK} strokeWidth={4} strokeLinecap="round" />
<circle cx={2} cy={-8} r={13} fill={STOCK} stroke={INK} strokeWidth={3} />
<path d="M2 -8a5 5 0 1 1 4 5" fill="none" stroke={INK} strokeWidth={2} />
<path d="M34 -8L38 -18M34 -8L30 -18" stroke={INK} strokeWidth={2} />
</g> : null}
</svg>
);
};
/* ---------- a card ---------- */
type Pose = { x: number; y: number; k: number; r: number; f: number };
type Key = { at: number; d: number; to: Partial<Pose>; ease?: (x: number) => number };
const poseAt = (t: number, from: Pose, keys: Key[]): Pose => {
const p: Pose = { ...from };
for (const k of keys) {
if (t < k.at) break;
const q = (k.ease ?? easeInOut)(ramp(t, k.at, k.at + k.d));
(Object.keys(k.to) as (keyof Pose)[]).forEach((f) => { p[f] = lerp(p[f], k.to[f] as number, q); });
}
return p;
};
const backCss: React.CSSProperties = { background: `repeating-conic-gradient(from 45deg at 50% 50%,${WINE} 0 25%,${WINE_DK} 0 50%) 0 0/72px 72px`, boxShadow: `inset 0 0 0 14px ${GOLD},inset 0 0 0 22px ${WINE},inset 0 0 0 26px ${GOLD},0 22px 46px rgba(0,0,0,.6)` };
const CardBack: React.FC = () => (
<div style={{ position: 'absolute', inset: 0, borderRadius: 30, ...backCss }}>
<div style={{ position: 'absolute', left: '50%', top: '50%', width: 260, height: 260, margin: -130, background: GOLD, clipPath: 'polygon(50% 0,61% 39%,100% 50%,61% 61%,50% 100%,39% 61%,0 50%,39% 39%)' }} />
<div style={{ position: 'absolute', left: '50%', top: '50%', width: 340, height: 340, margin: -170, borderRadius: '50%', border: `8px solid ${GOLD}` }} />
</div>
);
const Corner: React.FC<{ x: 'left' | 'right'; y: 'top' | 'bottom'; r: number }> = ({ x, y, r }) => (
<svg viewBox="0 0 70 70" width={62} height={62} style={{ position: 'absolute', [x]: 30, [y]: 30, transform: `rotate(${r}deg)` }}><path d="M6 64V30C6 16 16 6 30 6h34" fill="none" stroke={WINE} strokeWidth={4} /><circle cx={20} cy={20} r={6} fill={GOLD} /></svg>
);
const CardFront: React.FC<{ text: { numeral: string; the: string; name: string; flavor: string }; gilded?: boolean; flavorAt?: number; children: React.ReactNode }> = ({ text, gilded, flavorAt, children }) => {
const t = useT();
const sheen = ((t * 0.42) % 1.6) - 0.3;
const stock = gilded ? 'linear-gradient(155deg,#f7e6a8,#d9b45a 42%,#f5dfa0 62%,#c79c43)' : 'linear-gradient(160deg,#f6ecd5,#e9dbb9)';
const arch = gilded ? 'linear-gradient(180deg,#f5dfa0,#e4c26a)' : '#f4e9d0';
return (
<div style={{ position: 'absolute', inset: 0, borderRadius: 30, overflow: 'hidden', background: stock, boxShadow: `inset 0 0 0 12px ${gilded ? '#a8843c' : GOLD},inset 0 0 0 19px ${gilded ? '#fff4cf' : STOCK},inset 0 0 0 23px ${WINE},0 22px 46px rgba(0,0,0,.6)` }}>
<Corner x="left" y="top" r={0} /><Corner x="right" y="top" r={90} /><Corner x="right" y="bottom" r={180} /><Corner x="left" y="bottom" r={270} />
<div style={{ position: 'absolute', left: 0, right: 0, top: 36, textAlign: 'center', font: `900 ${fit(text.numeral, 330, 50, 0.78)}px/56px ${CAPS}`, letterSpacing: '.08em', color: WINE }}>{text.numeral}</div>
<div style={{ position: 'absolute', left: 55, top: 104, width: 490, height: 572, borderRadius: '245px 245px 14px 14px', overflow: 'hidden', background: arch, boxShadow: `0 0 0 7px ${GOLD},0 0 0 12px ${WINE}` }}>{children}</div>
<div style={{ position: 'absolute', left: 0, right: 0, top: 696, textAlign: 'center', color: WINE }}>
<div style={{ font: `700 26px/30px ${CAPS}`, letterSpacing: '.32em' }}>{text.the}</div>
<div style={{ font: `900 ${fit(text.name, 520, 72)}px/78px ${CAPS}`, letterSpacing: '.01em', whiteSpace: 'nowrap' }}>{text.name}</div>
</div>
{flavorAt === undefined || t >= flavorAt ? <div style={{ position: 'absolute', left: 0, right: 0, top: 800, textAlign: 'center', font: `italic 700 40px/44px ${ITALIC}`, color: gilded ? INK : '#3d2331', opacity: flavorAt === undefined ? 1 : ramp(t, flavorAt, flavorAt + 0.2) }}>{text.flavor}</div> : null}
{gilded ? <div style={{ position: 'absolute', left: '-40%', top: 0, width: '60%', height: '100%', transform: `translateX(${sheen * 200}%) skewX(-18deg)`, background: 'linear-gradient(90deg,rgba(255,250,220,0),rgba(255,250,220,.55),rgba(255,250,220,0))' }} /> : null}
</div>
);
};
/** A flip is one rotation, with the side that faces you drawn. r is the spin on the table, k the size. */
const Card: React.FC<{ pose: Pose; dim?: number; front: React.ReactNode; w?: number; h?: number }> = ({ pose, dim = 0, front, w = CW, h = CH }) => {
const a = (1 - pose.f) * 180;
const showFront = a < 90;
return (
<div style={{ position: 'absolute', left: pose.x - w / 2, top: pose.y - h / 2, width: w, height: h, transform: `rotate(${pose.r}deg) scale(${pose.k}) perspective(2400px) rotateY(${showFront ? a : a - 180}deg)` }}>
{showFront ? front : <CardBack />}
{dim > 0 ? <div style={{ position: 'absolute', inset: 0, borderRadius: 30, background: `rgba(10,6,30,${dim})` }} /> : null}
</div>
);
};
const hero = { x: SPOT.x, y: SPOT.y, k: SPOT.k, r: 0 };
const dimAt = (t: number, i: number) => {
const a = E('nobody') + i * 0.24;
return 0.56 * easeOut(ramp(t, a, a + 0.35)) * (1 - 0.65 * easeOut(ramp(t, E('moon'), E('door') + 0.8))) * (1 - easeInOut(ramp(t, E('future'), E('future') + 0.5)));
};
const CHAT_KEYS: Key[] = [
{ at: E('chat') - 0.5, d: 0.62, to: { ...hero, f: 1 } },
{ at: E('shuffle') - 0.3, d: 0.3, to: { ...HOME, f: 0 } },
];
const NOTE_KEYS: Key[] = [
{ at: E('first') - 0.28, d: 0.55, to: { ...hero, f: 1 } },
{ at: E('second') - 0.3, d: 0.5, to: { ...SLOT[0], r: -2 } },
];
const ITIN_KEYS: Key[] = [
{ at: E('second') - 0.3, d: 0.55, to: { ...hero, f: 1 } },
{ at: E('third') - 0.4, d: 0.5, to: { ...SLOT[1], r: 1.2 } },
];
const LAST_KEYS: Key[] = [
{ at: E('third') - 0.3, d: 0.65, to: { x: 505, y: 700, k: 0.72, r: 70 } },
{ at: E('lands') - 0.08, d: E('reversed') - E('lands') + 0.06, to: { ...hero, r: 180, f: 1 }, ease: easeIn },
{ at: E('left') - 0.3, d: 0.6, to: { ...SLOT[2], r: 181.5 } },
];
const QUIET_KEYS: Key[] = [
{ at: E('future') - 0.5, d: 0.45, to: { x: 505, y: 750, k: 0.84, r: 0 } },
{ at: E('future') - 0.05, d: 0.5, to: { f: 1 } },
{ at: E('fine') - 0.3, d: 0.6, to: { y: 662, k: 0.66 } }, // uncovers the first card's badge for the button
{ at: E('cta') - 0.05, d: 0.7, to: { x: 505, y: 640, k: 0.58 } },
];
/** The last card is laid across the spread, the way a reader crosses a card: that makes it wide enough for a big word. */
const CTA_W = 880, CTA_H = 600;
const CTA_KEYS: Key[] = [
{ at: E('cta') + 0.1, d: 0.5, to: { x: 505, y: 1112, k: 0.56, r: -1.5 } },
{ at: E('keyword') - 0.14, d: 0.32, to: { f: 1 } },
];
const FlippedCards: React.FC = () => {
const t = useT();
const lean = leanAt(t);
const fanTop = fanPose(RANK_CHOSEN, t);
const note = poseAt(t, { x: fanTop.x, y: fanTop.y, k: fanTop.k, r: fanTop.r, f: 0 }, NOTE_KEYS);
const itin = poseAt(t, { ...REST, r: 4, f: 0 }, ITIN_KEYS);
return (
<>
{t >= E('chat') - 0.5 && t < E('shuffle') ? <Card pose={poseAt(t, { ...HOME, r: 0, f: 0 }, CHAT_KEYS)} front={<CardFront text={TEXT.chat}><ArtChat /></CardFront>} /> : null}
{t >= E('first') - 0.28 ? <Card dim={dimAt(t, 0)} pose={{ ...note, x: note.x + lean * 14, r: note.r + lean * 5 }} front={<CardFront text={TEXT.notification} flavorAt={E('none')}><ArtNotification /></CardFront>} /> : null}
{t >= E('second') - 0.3 ? <Card dim={dimAt(t, 1)} pose={{ ...itin, y: itin.y - lean * 18 }} front={<CardFront text={TEXT.itinerary} flavorAt={E('never')}><ArtItinerary /></CardFront>} /> : null}
{t >= E('third') - 0.3 ? <Card dim={dimAt(t, 2)} pose={poseAt(t, { ...REST, r: 4, f: 0 }, LAST_KEYS)} front={<CardFront text={TEXT.lastWord}><ArtLastWord /></CardFront>} /> : null}
</>
);
};
/* ---------- the deck: riffle, cut, fan, then it rests at the side of the table ---------- */
const DECK_N = 22;
/** The order in which the riffle lets each card fall, so the two halves interleave. */
const orderOf = (i: number) => (i < DECK_N / 2 ? i * 2 : (i - DECK_N / 2) * 2 + 1);
/** After the cut, the top half is underneath. */
const rankOf = (i: number) => (orderOf(i) + DECK_N / 2) % DECK_N;
const RANK_CHOSEN = Math.floor(DECK_N / 2);
const fanPose = (rank: number, t: number) => {
const a = lerp(-40, 40, rank / (DECK_N - 1));
const lift = 14 * Math.sin(t * 3.1 - rank * 0.55) + (rank === RANK_CHOSEN ? 110 * easeOut(ramp(t, E('mute') - 0.35, E('mute'))) : 0);
const R = FAN_R + lift, rad = (a * Math.PI) / 180;
return { x: FAN_C[0] + Math.sin(rad) * R, y: FAN_C[1] - Math.cos(rad) * R, k: FAN_K, r: a };
};
const deckPose = (i: number, t: number) => {
const sh = E('shuffle') - 0.3, cut = E('cut'), fan = E('think') + 0.02, first = E('first');
const left = i < DECK_N / 2, dir = left ? -1 : 1, order = orderOf(i), rank = rankOf(i);
const stack = (c: { x: number; y: number; k: number }, n: number) => ({ x: c.x + n * 0.55 * (c.k / 0.42), y: c.y - n * 1.2 * (c.k / 0.42), k: c.k, r: (hash(i + 3) - 0.5) * 1.8 });
const before = t < sh;
let p = stack(HOME, before ? i : order);
// the riffle: split in two, then the halves fall back in turn
const split = easeOut(ramp(t, sh, sh + 0.2)), fall = easeInOut(ramp(t, sh + 0.22 + order * 0.015, sh + 0.34 + order * 0.015));
const o = split * (1 - fall);
p = { ...p, x: p.x + dir * 160 * o, y: p.y - 34 * o, r: p.r + dir * 11 * o };
// the cut: the top half slides away and back underneath
const c = order >= DECK_N / 2 ? easeInOut(ramp(t, cut - 0.1, cut + 0.08)) * (1 - easeInOut(ramp(t, cut + 0.08, cut + 0.26))) : 0;
p = { ...p, x: p.x + 210 * c, y: p.y - 30 * c, r: p.r + 7 * c };
// the fan
const q = easeInOut(ramp(t, fan + rank * 0.012, fan + 0.42 + rank * 0.012));
const f = fanPose(rank, t);
p = { x: lerp(p.x, f.x, q), y: lerp(p.y, f.y, q), k: lerp(p.k, f.k, q), r: lerp(p.r, f.r, q) };
// back into one stack at the side of the table
const q2 = easeInOut(ramp(t, first - 0.32 + rank * 0.006, first + rank * 0.006));
const s = stack(REST, rank);
p = { x: lerp(p.x, s.x, q2), y: lerp(p.y, s.y, q2), k: lerp(p.k, s.k, q2), r: lerp(p.r, s.r + 4, q2) };
const z = before ? i : t > cut ? rank : order;
return { ...p, z };
};
const Deck: React.FC = () => {
const t = useT();
const lean = leanAt(t);
const cards = Array.from({ length: DECK_N }, (_, i) => ({ i, ...deckPose(i, t) })).filter((c) => !(rankOf(c.i) === RANK_CHOSEN && t >= E('first') - 0.28)).sort((a, b) => a.z - b.z);
return (
<>
{cards.map((c) => (
<div key={c.i} style={{ position: 'absolute', left: c.x - CW / 2, top: c.y - CH / 2, width: CW, height: CH, transform: `rotate(${c.r - lean * 3}deg) scale(${c.k})` }}>
<div style={{ position: 'absolute', inset: 0, borderRadius: 30, ...backCss }} />
<div style={{ position: 'absolute', left: '50%', top: '50%', width: 260, height: 260, margin: -130, background: GOLD, clipPath: 'polygon(50% 0,61% 39%,100% 50%,61% 61%,50% 100%,39% 61%,0 50%,39% 39%)' }} />
</div>
))}
</>
);
};
/** The reader's desk bell. On "mute" a cloth drops over it. */
const Bell: React.FC = () => {
const t = useT();
const m = E('mute');
const k = easeIn(ramp(t, m - 0.14, m + 0.02));
const ring = t < m - 0.14 ? Math.sin(t * 22) * 1.5 * Math.max(0, Math.sin(t * 0.9)) : 0;
return (
<svg viewBox="-90 -110 180 170" width={180} height={170} style={{ position: 'absolute', left: 905 - 90, top: 850 - 110, overflow: 'visible' }}>
<ellipse cx={3} cy={40} rx={72} ry={13} fill="rgba(5,3,20,.6)" />
<g transform={`rotate(${ring} 0 -40)`}>
<path d="M0 -62C-30 -62 -36 -30 -40 -8C-44 12 -56 20 -60 30H60C56 20 44 12 40 -8C36 -30 30 -62 0 -62Z" fill={GOLD} stroke={INK} strokeWidth={3} />
<path d="M0 -62C30 -62 36 -30 40 -8C44 12 56 20 60 30H12C14 0 16 -40 0 -62Z" fill="url(#h3)" />
<circle cx={0} cy={-68} r={9} fill={PALE} stroke={INK} strokeWidth={3} />
<rect x={-66} y={28} width={132} height={10} rx={5} fill={PALE} stroke={INK} strokeWidth={3} />
</g>
{t >= m - 0.14 ? (
<g transform={`translate(0 ${lerp(-120, 0, k)})`}>
<path d="M-4 -82C-50 -78 -66 -20 -80 40C-54 30 -40 48 -20 38C0 28 18 48 40 38C58 30 70 44 80 36C66 -20 44 -78 -4 -82Z" fill={WINE} stroke={INK} strokeWidth={3} />
<path d="M-4 -82C-24 -60 -36 -10 -40 34M12 -76C20 -40 26 0 28 36" fill="none" stroke={WINE_DK} strokeWidth={6} />
<path d="M-80 40C-54 30 -40 48 -20 38C0 28 18 48 40 38C58 30 70 44 80 36" fill="none" stroke={GOLD} strokeWidth={4} />
<circle cx={-4} cy={-82} r={7} fill={GOLD} />
</g>
) : null}
</svg>
);
};
const Muted: React.FC = () => {
const t = useT();
const k = back(ramp(t, E('mute') + 0.02, E('mute') + 0.22));
if (t < E('mute')) return null;
return <div style={{ position: 'absolute', left: 905 - 62, top: 846, width: 124, height: 40, background: STOCK, borderRadius: 4, textAlign: 'center', font: `700 25px/40px ${CAPS}`, color: WINE, transform: `rotate(-3deg) scale(${k})`, boxShadow: `inset 0 0 0 2px ${GOLD},0 6px 10px rgba(0,0,0,.5)` }}>{MUTED_TAG}</div>;
};
/* ---------- moments on the table ---------- */
/** Seven marks in chalk, one for each day away. */
const Tally: React.FC = () => {
const t = useT();
const a = E('week') + 0.02;
if (t < a || t > E('door') + 0.4) return null;
const fade = 1 - ramp(t, E('look'), E('look') + 0.6);
const X = 402, Y = 660;
const marks = [0, 1, 2, 3].map((i) => [X + i * 30, Y, X + i * 30 + (hash(i) - 0.5) * 8, Y + 110]).concat([[X - 16, Y + 88, X + 106, Y + 22], [X + 146, Y, X + 144, Y + 110], [X + 176, Y + 2, X + 178, Y + 108]]);
return (
<svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, opacity: fade }}>
{marks.map(([x0, y0, x1, y1], i) => {
const k = easeOut(ramp(t, a + i * 0.075, a + i * 0.075 + 0.12));
return k > 0 ? <line key={i} x1={x0} y1={y0} x2={lerp(x0, x1, k)} y2={lerp(y0, y1, k)} stroke="#e9e2f4" strokeWidth={8} strokeLinecap="round" opacity={0.85} /> : null;
})}
</svg>
);
};
const ReversedBanner: React.FC = () => {
const t = useT();
const at = E('reversed') + 0.04, out = E('left') - 0.32;
if (t < at || t > out + 0.2) return null;
const k = back(ramp(t, at, at + 0.18)) * (1 - ramp(t, out, out + 0.2));
return (
<div style={{ position: 'absolute', left: 505 - 400, top: 1142, width: 800, height: 96, transform: `scale(${lerp(0.6, 1, k)})`, opacity: clamp(k), filter: 'drop-shadow(0 8px 14px rgba(0,0,0,.6))' }}>
<svg viewBox="0 0 800 96" width={800} height={96} style={{ position: 'absolute', left: 0, top: 0 }}>
<polygon points="0,20 64,20 64,96 30,96 44,58" fill={WINE_DK} /><polygon points="800,20 736,20 736,96 770,96 756,58" fill={WINE_DK} />
<rect x={40} y={0} width={720} height={82} fill={WINE} /><rect x={40} y={6} width={720} height={70} fill="none" stroke={GOLD} strokeWidth={3} />
</svg>
<div style={{ position: 'absolute', left: 50, width: 700, top: 0, height: 82, textAlign: 'center', font: `700 ${fit(REVERSED_LABEL, 660, 42, 0.72)}px/82px ${CAPS}`, color: PALE, whiteSpace: 'nowrap' }}>{REVERSED_LABEL}</div>
</div>
);
};
/** A plain label, right way up, pointing at the little figure on the upside-down card. Its point is the card's own figure. */
const YouTag: React.FC = () => {
const t = useT();
const at = E('you') - 0.02, out = E('left') - 0.32;
if (t < at || t > out + 0.15) return null;
const k = back(ramp(t, at, at + 0.2)) * (1 - ramp(t, out, out + 0.15));
return (
<div style={{ position: 'absolute', left: 245, top: 930, width: 205, height: 80, transformOrigin: '100% 50%', transform: `rotate(-4deg) scale(${clamp(k, 0, 1.2)})`, filter: 'drop-shadow(0 8px 10px rgba(0,0,0,.4))' }}>
<div style={{ position: 'absolute', left: 0, top: 0, width: 150, height: 80, background: GOLD, borderRadius: 8, textAlign: 'center', font: `900 ${fit(YOU_TAG, 130, 50)}px/80px ${CAPS}`, color: INK, boxShadow: `inset 0 0 0 3px ${INK}` }}>{YOU_TAG}</div>
<div style={{ position: 'absolute', left: 147, top: 14, width: 58, height: 52, background: GOLD, clipPath: 'polygon(0 0,100% 50%,0 100%)' }} />
</div>
);
};
const doorLens = DOOR.slice(1).map((p, i) => Math.hypot(p[0] - DOOR[i][0], p[1] - DOOR[i][1]));
const doorTotal = doorLens.reduce((a, b) => a + b, 0);
const DOOR_AT = E('door') + 0.15, DOOR_DRAW = 1.9;
const doorProgress = (t: number) => clamp((t - DOOR_AT) / DOOR_DRAW) * doorTotal;
const Door: React.FC = () => {
const t = useT();
if (t < DOOR_AT) return null;
const done = doorProgress(t);
const lit = easeOut(ramp(t, DOOR_AT + DOOR_DRAW - 0.1, DOOR_AT + DOOR_DRAW + 0.45));
const fade = 1 - 0.65 * easeInOut(ramp(t, E('future') - 0.1, E('future') + 0.5));
let d = `M${DOOR[0][0]} ${DOOR[0][1]}`, run = 0;
for (let i = 1; i < DOOR.length; i++) {
const L = doorLens[i - 1];
if (done >= run + L) d += `L${DOOR[i][0]} ${DOOR[i][1]}`;
else if (done > run) { const q = (done - run) / L; d += `L${lerp(DOOR[i - 1][0], DOOR[i][0], q).toFixed(1)} ${lerp(DOOR[i - 1][1], DOOR[i][1], q).toFixed(1)}`; }
run += L;
}
const fill = `M${DOOR.map((p) => p.join(' ')).join('L')}Z`;
let reach = 0;
const reached = DOOR.map((_, i) => { const at = reach; if (i < doorLens.length) reach += doorLens[i]; return at; });
const knob = back(ramp(t, DOOR_AT + DOOR_DRAW + 0.05, DOOR_AT + DOOR_DRAW + 0.3));
return (
<svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible', opacity: fade }}>
<defs><linearGradient id="doorFill" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#fff4cf" stopOpacity="0.5" /><stop offset="1" stopColor="#f5dfa0" stopOpacity="0.14" /></linearGradient></defs>
<path d={fill} fill="url(#doorFill)" opacity={lit} />
<g opacity={lit * 0.8} fill="none" stroke="#fff8e0" strokeWidth={3} strokeDasharray="3 10" strokeLinecap="round">
<path d="M368 690V640Q368 590 416 548L470 512H540L594 548Q642 590 642 640V690Z" />
<rect x={368} y={740} width={274} height={176} rx={8} />
</g>
<path d={d} fill="none" stroke={PALE} strokeWidth={16} strokeLinejoin="round" strokeLinecap="round" opacity={0.22} />
<path d={d} fill="none" stroke="#fff8e0" strokeWidth={4.5} strokeLinejoin="round" strokeLinecap="round" />
{DOOR.slice(0, -1).map(([x, y], i) => {
const k = back(ramp(done, reached[i] - 1, reached[i] + 70));
if (done < reached[i] - 1) return null;
const big = i === 0 || i === 8 || i === 9;
const s = (big ? 1.5 : 1) * k * (1 + 0.14 * Math.sin(t * 5 + i));
return <g key={i} transform={`translate(${x} ${y}) scale(${s})`}><circle r={22} fill={PALE} opacity={0.25} /><path d={star4(24, 5)} fill="#fffbe8" /></g>;
})}
{knob > 0 ? <g transform={`translate(${KNOB[0]} ${KNOB[1]}) scale(${knob})`}><circle r={18} fill={PALE} opacity={0.3} /><path d={star4(19, 4.5)} fill="#fffbe8" /></g> : null}
</svg>
);
};
const Rays: React.FC = () => {
const t = useT();
const a = easeOut(ramp(t, E('future') - 0.05, E('future') + 0.6));
if (a <= 0) return null;
const y = poseAt(t, { x: 505, y: 750, k: 0.84, r: 0, f: 0 }, QUIET_KEYS).y;
return <div style={{ position: 'absolute', left: 505 - 900, top: y - 900, width: 1800, height: 1800, opacity: a * 0.55, transform: `rotate(${t * 8}deg)`, background: 'repeating-conic-gradient(from 0deg,rgba(245,223,160,.3) 0 4deg,rgba(245,223,160,0) 4deg 15deg)', WebkitMaskImage: 'radial-gradient(circle,#000 22%,transparent 58%)' }} />;
};
const MoonGlow: React.FC = () => {
const t = useT();
const m = moonAt(t);
const p = easeOut(ramp(t, E('moon') - 0.1, E('moon') + 0.3));
if (m <= 0) return null;
const [x, y] = MOON_AT;
return (
<>
{/* the beam: it falls from the moon onto the table, where the door is about to be */}
<div style={{ position: 'absolute', left: x - 340, top: y + 58, width: 680, height: 950, opacity: 0.42 * m, background: 'linear-gradient(180deg,rgba(200,214,255,.55),rgba(200,214,255,0) 90%)', clipPath: 'polygon(42% 0,58% 0,100% 100%,0 100%)' }} />
<div style={{ position: 'absolute', left: x - 240, top: y - 240, width: 480, height: 480, opacity: m * (0.75 + 0.1 * Math.sin(t * 3)), background: 'radial-gradient(circle,rgba(255,244,207,.75) 0,rgba(226,226,255,.26) 28%,rgba(200,214,255,0) 64%)' }} />
<svg viewBox="-200 -200 400 400" width={400} height={400} style={{ position: 'absolute', left: x - 200, top: y - 200, opacity: p * m }}>
<g transform={`rotate(${t * 9})`}>{Array.from({ length: 16 }, (_, i) => <line key={i} transform={`rotate(${i * 22.5})`} x1={0} x2={0} y1={-88} y2={i % 2 ? -104 : -124} stroke="#fff4cf" strokeWidth={i % 2 ? 2 : 3.5} strokeLinecap="round" />)}</g>
{/* the moon itself gives off light now, so the room light can't dim it. It rides on the dial, on top of the dial's own moon */}
{(() => {
const s = stepAt(t), near = Math.round(s), off = ((near - s) * 45 * Math.PI) / 180;
const lit = moonLit(PHASE(((near % 8) + 8) % 8), lerp(42, 50, Math.pow(Math.cos(off), 24)) * (1 + 0.34 * moonBoost(t) * Math.pow(Math.cos(off), 24)));
const dx = Math.sin(off) * MOON_R, dy = (1 - Math.cos(off)) * MOON_R;
return <path d={lit.d} transform={`translate(${dx} ${dy})${lit.mirror ? ' scale(-1 1)' : ''}`} fill="#fff8e0" opacity={clamp((0.5 - Math.abs(s - near)) * 6)} />;
})()}
</svg>
</>
);
};
/** The card that stays: the comment keyword, dealt onto the middle of the spread. */
const CtaCard: React.FC = () => {
const t = useT();
if (t < E('cta') + 0.1) return null;
return (
<Card w={CTA_W} h={CTA_H} pose={poseAt(t, { ...REST, r: 94, f: 0 }, CTA_KEYS)} front={
<div style={{ position: 'absolute', inset: 0, borderRadius: 30, background: `linear-gradient(160deg,${WINE},${WINE_DK})`, boxShadow: `inset 0 0 0 14px ${GOLD},inset 0 0 0 22px ${WINE},inset 0 0 0 26px ${GOLD},0 22px 46px rgba(0,0,0,.6)`, textAlign: 'center', color: PALE }}>
<div style={{ position: 'absolute', left: 0, right: 0, top: 62, font: `700 ${fit(KEYWORD_ABOVE, 560, 80)}px/96px ${CAPS}`, letterSpacing: '.06em' }}>{KEYWORD_ABOVE}</div>
<div style={{ position: 'absolute', left: 0, right: 0, top: 154, font: `900 ${fit(KEYWORD, 730, 200, 0.82)}px/240px ${CAPS}`, color: '#fff4cf', textShadow: '0 0 30px rgba(245,223,160,.65)', whiteSpace: 'nowrap' }}>{KEYWORD}</div>
<svg viewBox="-340 -40 680 80" width={680} height={80} style={{ position: 'absolute', left: 100, top: 454 }}>
{[0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875].map((p, i) => { const m = moonLit(p, 26); return <g key={i} transform={`translate(${(i - 3) * 92} 0)`}><circle r={26} fill={WINE_DK} stroke={GOLD} strokeWidth={3} /><path d={m.d} transform={m.mirror ? 'scale(-1 1)' : undefined} fill={PALE} /></g>; })}
</svg>
</div>
} />
);
};
const QuietCard: React.FC = () => {
const t = useT();
if (t < E('future') - 0.5) return null;
return <Card pose={poseAt(t, { ...REST, r: 4, f: 0 }, QUIET_KEYS)} front={<CardFront text={TEXT.quiet} gilded><ArtQuiet /></CardFront>} />;
};
/* ---------- light ---------- */
const Light: React.FC = () => {
const t = useT();
const cam = camAt(t);
const L = clamp(flameAt(t));
const M = moonAt(t);
const fx = cam.s * WICK[0] + cam.tx, fy = cam.s * (WICK[1] - 70) + cam.ty;
const a = clamp(lerp(0.6, 0.05, L) - 0.14 * M, 0.04, 0.7);
return (
<>
<AbsoluteFill style={{ background: `radial-gradient(circle 1500px at ${fx}px ${fy}px, rgba(7,4,24,${a * 0.5}) 0, rgba(7,4,24,${a}) 55%, rgba(7,4,24,${clamp(a + 0.1)}) 100%)` }} />
<AbsoluteFill style={{ mixBlendMode: 'screen', background: `radial-gradient(circle 1250px at ${fx}px ${fy}px, rgba(255,160,70,${0.2 * L}) 0, rgba(255,140,60,${0.06 * L}) 45%, rgba(0,0,0,0) 75%)` }} />
</>
);
};
/* ---------- captions: a ribbon that never moves ---------- */
const PER_ROW = 28;
const VOICELESS_AT = E('door') + 0.3;
const VOICELESS: Word = { w: VOICELESS_CAPTION, t0: VOICELESS_AT, t1: VOICELESS_AT + 2.6, scene: 'voiceless' };
const CHUNKS = [...buildChunks(PER_ROW, 1), { t0: VOICELESS.t0, rows: [], words: [VOICELESS] }].sort((a, b) => a.t0 - b.t0);
const RIBBON = { x: 60, y: 1344, w: 890, h: 128 };
const TEXT_BOX = 700;
const Ribbon: React.FC = () => {
const t = useT();
const idx = CHUNKS.reduce((a, c, i) => (t >= c.t0 - 0.06 ? i : a), 0);
const c = CHUNKS[idx];
const voiceless = c.words[0] === VOICELESS;
const chars = c.words.map((w) => w.w).join(' ').length;
const size = voiceless ? 66 : Math.min(68, TEXT_BOX / (chars * 0.405));
const age = idx === 0 ? 1 : t - (c.t0 - 0.06);
const low = (1 - clamp(flameAt(t))) * 0.1;
return (
<div style={{ position: 'absolute', left: RIBBON.x, top: RIBBON.y, width: RIBBON.w, height: RIBBON.h, transform: `translateY(${Math.sin(t * 1.1) * 2.5}px)` }}>
<svg viewBox="0 0 890 128" width={890} height={128} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible', filter: 'drop-shadow(0 10px 14px rgba(0,0,0,.6))' }}>
<polygon points={`0,26 96,26 96,126 ${44 + Math.sin(t * 1.7) * 3},126 ${64 + Math.sin(t * 1.3) * 3},76`} fill={WINE_DK} />
<polygon points={`890,26 794,26 794,126 ${846 + Math.sin(t * 1.5) * 3},126 ${826 + Math.sin(t * 1.2) * 3},76`} fill={WINE_DK} />
<polygon points="66,108 96,108 96,126" fill="#3a0d22" /><polygon points="824,108 794,108 794,126" fill="#3a0d22" />
<rect x={66} y={0} width={758} height={108} fill={WINE} />
<rect x={66} y={7} width={758} height={94} fill="none" stroke={GOLD} strokeWidth={3} />
</svg>
<div style={{ position: 'absolute', left: 66, width: 758, top: 0, height: 108, textAlign: 'center', whiteSpace: 'nowrap', font: voiceless ? `900 ${size}px/108px ${CAPS}` : `italic 700 ${size}px/104px ${ITALIC}`, letterSpacing: voiceless ? '.14em' : undefined, color: CREAM, opacity: clamp(age / 0.08) * (1 - low) }}>
{c.words.map((w, i) => {
const on = (t >= w.t0 - 0.03 && t < w.t1 + 0.08) || (t >= E('cta') && w.w.toUpperCase() === KEYWORD);
return <span key={i} style={{ color: on ? '#ffe08a' : undefined, textShadow: on ? '0 0 16px rgba(245,223,160,.75)' : undefined }}>{i ? ' ' : ''}{w.w}</span>;
})}
</div>
</div>
);
};
/* ---------- the film ---------- */
export const Reading: React.FC = () => (
<Clock>
<Fonts faces={FACES}>
<Stage />
</Fonts>
</Clock>
);
/** "The room leans in": the props on the table physically tilt toward the third card, from their own bases. */
const LeanIn: React.FC<{ at: P; deg: number; children: React.ReactNode }> = ({ at, deg, children }) => {
const l = leanAt(useT());
return <div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1920, transformOrigin: `${at[0]}px ${at[1]}px`, transform: `skewX(${deg * l}deg)` }}>{children}</div>;
};
const Stage: React.FC = () => (
<AbsoluteFill style={{ background: NIGHT, overflow: 'hidden' }}>
<EngravingDefs />
<Cam>
<Velvet />
<Stars />
<Drape />
<Dial />
<Slots />
<Swags />
<TitleBlock />
<Pointer />
<LeanIn at={[118, 920]} deg={-4.5}><Candle /></LeanIn>
<LeanIn at={[905, 900]} deg={5}><Bell /><Muted /></LeanIn>
<Deck />
<Tally />
<FlippedCards />
<ReversedBanner />
<YouTag />
</Cam>
<Light />
{/* above the room light: what gives off its own light, and the cards that arrive after the turn */}
<Cam>
<MoonGlow />
<LeanIn at={[118, 920]} deg={-4.5}><Flame /></LeanIn>
<Smoke />
<Door />
<Rays />
<QuietCard />
<CtaCard />
</Cam>
<Ribbon />
<AbsoluteFill style={{ background: 'radial-gradient(120% 80% at 50% 45%, transparent 62%, rgba(5,2,18,.5) 100%)', pointerEvents: 'none' }} />
<div style={{ position: 'absolute', inset: 20, border: `3px solid ${GOLD}`, borderRadius: 26, opacity: 0.55, pointerEvents: 'none' }} />
<div style={{ position: 'absolute', inset: 28, border: `1.5px solid ${GOLD}`, borderRadius: 20, opacity: 0.4, pointerEvents: 'none' }} />
</AbsoluteFill>
);
```
## 8. Voice (optional)
A voice is worth adding when the world has a speaker of its own, because the deadpan delivery is half the joke. Skip it for a sound-off post, or when the user would rather not have one: the film still works.
**The persona:** a hushed tarot reader, low and very close to the mic, unhurried and completely certain. Takes a group chat as seriously as a death in the family. Never winks and never raises their voice, not even for the card that lands reversed.
**When to use it:** on every line except the door. Soft voices read slowly, so speed the takes up until a short line lands in 2 to 3 s, or the film runs long. Give the low point the most room: a pause of up to 0.5 s between "once." and "Nobody noticed." is the only one worth keeping in the whole film. Put a comma around the keyword ("Comment, cards, and..."). Add a small draped room with a very short echo and a little warmth around 160 Hz, and compress before the room.
- **Never clone or imitate a real person.** Audition 3 or 4 voices on two lines of the script and let the user pick, or use the user's own voice.
- **One take per line,** with word timestamps. Pass the neighboring lines as context if your provider allows it, so the delivery flows. Trim each take to its speech, and tighten any pause inside it longer than 0.5 s.
- **Listen for your call to action.** Keywords blur easily. Commas around one in the spoken text ("Comment, gate, and...") usually fix it. Check the final mix with Whisper, line by line.
- **With no voice.** Keep the estimated timings as the clock. If there's music, put the captions on its grid. Raise the effects and the music: they carry the film now. Make sure the words stay on screen long enough to read, about 0.3 s a word. The estimate runs close to that, so add `tail` after a line that feels rushed.
## 9. Sound
The picture tells the story without sound, so a film can ship silent. Sound is what makes each change land, though, so add it unless the user says not to. This section says what to use and when it plays. Add it after the user approves the animatic.
**Effects: make a set for this world.** Generate them with the sound-effects tool the user chose, or pick from a licensed library. Prompt the thing itself, and keep it warm, never bright or harsh.
| Sound | Length | Prompt idea | When it plays |
|-------|--------|-------------|---------------|
| room tone | 10 s | a quiet draped room at night, faint air, a slow clock very far away, no voices, no music | the bed, all the way through, and it never stops |
| candle catch | 1 s | a wick catching, a small soft flame puff, close and warm | `sit`, and again by itself just after `door` |
| riffle | 1.5 s | a deck of stiff cards riffle shuffled on cloth, quick papery purr ending in a soft bridge | `shuffle`, from 0.3 s early |
| cut | 0.5 s | a stack of cards slid across velvet and set down, soft thud | `cut` |
| fan | 1 s | a deck of cards fanned across a velvet table in one sweep, soft papery run | `think` |
| muffled bell | 0.8 s | a small brass bell struck once as heavy cloth drops over it, the note choked off at once | `mute` |
| card deal | 0.5 s | one stiff card flicked through the air and landing flat on velvet, soft and close | `chat`, `first`, `second`, `keyword`, and very quietly each time a card steps down to the row |
| buzz | 1.2 s | a phone buzzing on a wooden table, in short bursts, deep and muffled, not bright | `ninety`, for as long as the badge is counting |
| suitcase creak | 1 s | an old leather suitcase creaking in a closet, with a little dust, dry and small | `never` |
| dial step | 0.7 s | one step of a heavy brass clockwork ratchet, a low solid clunk, a little ring | every step in `STEPS`: `trip`, `reversed`, `week`, `moon`, as the door finishes, and on `future` |
| slam | 1.5 s | a heavy card slapped down on a thick wooden table, a deep thud, small brass objects rattle, warm and dark | `reversed`, the loudest thing in the film |
| gutter | 1.8 s | a candle flame sputtering in a draft, small wet crackles, then one soft puff as it goes out | from just before `week`, ending on `nobody` |
| far chime | 6 s | a single wind chime far away across a valley at night, one slow note, a lot of air | starts on `nobody` and keeps running under the moon |
| moon | 2.5 s | a soft rising shimmer of bowed glass, warm and slow, not bright | `moon` |
| star | 0.6 s | one small soft glass note with a short tail | each star of the door as the line reaches it, climbing in pitch to the top of the arch and falling again |
| gild | 2 s | one deep soft bell with a warm swell of air behind it | the fourth card's flip on `future` |
| phone down | 0.4 s | a phone set face down on a wooden table, one soft tap | `down` |
| one buzz | 0.2 s | a single very short phone buzz, muffled, far away | `notice`, when the badge reads 100. Nobody reacts |
- **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:** "Hushed parlor instrumental at night: slow felted piano, bowed glass, a low cello drone, a very soft frame drum like a heartbeat, mysterious but gentle, a long sparse stretch, then warm strings and a celesta figure open up, ending resolved, no vocals."
**When it plays:** low under the opening, and it thins out under the third card. It drains to nothing over 0.7 s between "week" and "Nobody", and it stays out through "noticed" and "But look at the", so only room tone, the smoke and the far chime are left. It comes back very low on "moon", on a bar where the track lifts. It climbs through the voiceless door to its fullest there, then settles under "Your future", and holds warm and quiet to the end.
**Mix, voice first.**
- **Voice:** compress lightly so consonants stay off the limiter, about -14 LUFS.
- **Music:** about 16 to 20 LU under the voice while it speaks, lifted in gaps and on the voiceless moment.
- **Effects:** 7 to 10 LU under the voice overall, ducked about 6 dB under speech, so nothing masks a word.
- **Master:** -14 LUFS integrated, sample-peak ceiling about -2.4 dBFS. AAC encoding adds about 0.8 dB, so check the true peak of the final file. Give the master a 10 ms fade-in, or every view opens on a click.
## 10. Render and QA
1. **Render** without audio. Mux your mix onto it, trimmed to the exact length (`-t`, never `-shortest`), as H.264 at high quality with AAC 320k and `+faststart`.
2. **Sync and length:** the frame count matches the clock, and a cross-correlation of the muxed audio against your mix shows an offset of 0 ms.
3. **Contact sheets:** one frame per second with the safe box drawn. Look at every sheet: nothing cut off, nothing important outside the box, no empty or black frames, and the time of day and the story still agree.
4. **Determinism:** render about 10 spread frames twice with `npx remotion still src/index.ts Film-9x16 out/a.png --frame=N`, and compare each pair with `cmp`; they should match.
5. **Loudness:** -14 LUFS integrated, true peak at or below -1 dBTP.
6. **Words:** transcribe the mix line by line with Whisper. A difference on a plain word is a masked or swallowed word.
7. **Gaps and stills:** no voice gap over 1.0 s outside the voiceless moment (`clock.mjs` warns about these), and no still stretch over 1.2 s or black stretch (`ffmpeg -i out/silent.mp4 -vf "freezedetect=n=0.001:d=1.2,blackdetect=d=0.1" -an -f null -` prints any). A small moving corner can hide a freeze from that check, so trust the contact sheets over it.
For a silent film, skip the checks that need audio (the offset in 2, and 5 and 6) and use `out/silent.mp4` as the final file.
## 11. Traps
- **A reversed card no one can read:** the name prints upside down on purpose, so say it the right way up on `ReversedBanner`, and point at the figure with `YouTag`. Without those two, the turn is lost with the sound off.
- **A card that isn't clearly upside down:** use a drawing with an obvious right way up, with a figure, ground and sky, and let the name and numeral flip with it.
- **A door that reads as an arch:** keep it tall and narrow, about 1 wide by 1.7 tall, with the knob star and the panel lines. Then hold it finished for at least 1 s before the fourth card covers it. It's the picture the whole film is for.
- **A black low point:** the candle goes out, but `Light` stops well short of black, so the gold, the stars and the ribbon stay. The smoke is the thing that moves, so give it room to be seen.
- **A joke hidden behind a card:** the fourth card shrinks before the button, or it covers the badge on the first card that the button needs. Check what's under each big card.
- **Lit things go dim:** the flame, the bright moon and the door draw in the second `Cam`, above `Light`. Put them in the first and they sink with the room.
- **A candle that leaves its flame behind:** `Candle` and `Flame` sit in two layers, each in its own `LeanIn`. Use the same `at` and `deg` for both.
- **Velvet that shimmers:** it's painted once into a canvas in `Velvet`. A live SVG noise filter changes as the camera zooms, so don't swap one in.
- **Missing hatching:** `EngravingDefs` holds the patterns for every drawing and must stay mounted, at size 0 and never `display: none`. A `clipPath` id must also be unique: `Bubble` and `Hill` take one as `id`.
| 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.
The prompt
# The prompt First give your agent The Reading skill (paste the skill text and ask it to save it as a skill). Then replace everything in [BRACKETS] below and paste it in. **Tip:** use any AI tools you like for the voice, music and sound effects; the skill works the same with all of them, or with none (every scene is code, and the film works with no voice). If you'd rather not set up separate AI accounts and API keys, HYVIRAL (optional) is one way to get them behind one connection (hyviral.com; in Claude.ai → Customize → Connectors → add `https://api.hyviral.com/mcp`). --- Use The Reading skill (the-reading) to make a vertical video for [PLATFORM: Instagram Reels / TikTok / YouTube Shorts]. - **The reading:** [TOPIC - the small, everyday thing the cards are about, e.g. "three signs it's time to leave that group chat", "why every planner I buy dies in February"] - **The first two signs:** [SIGNS - two things the viewer will laugh at and recognize, or "you write them"] - **The sign that turns on the viewer:** [REVERSED - the third card, the uncomfortable one, e.g. "you are the one who keeps replying", or "you write it"] - **The future on the gilded card:** [FUTURE - the calm way out, e.g. "a phone that stays face down", or "you write it"] - **In the window:** [WINDOW - "no person: engraved drawings in code", or "my own clip, attached"] - **Length:** [LENGTH - e.g. about 35 seconds] - **Voice:** [VOICE - e.g. "a hushed tarot reader", "my own voice", or "no voice: captions and sound only"] - **Music:** [MUSIC - e.g. "the skill's hushed parlor brief", or your own idea] - **Ending:** [CTA - organic: "Comment KEYWORD and I'll send you THE THING", with the keyword and what you'll send; or sponsored: the line and the URL] - **AI tools:** [AI TOOLS - e.g. "whatever you already have set up", "HYVIRAL", or "none: animation only, I'll record the voice myself"] - **Avoid:** [AVOID - e.g. no real predictions, no real people, no cards copied from a published deck] Work in this order: 1. Write the script on the skill's moves, with one line for each turn and what each card shows. 2. Build the plan, the clock and the frame, render a silent animatic, and show me the script, the scene sheet and the animatic. Wait for my OK. 3. Generate the voice, music and sound effects with the AI tools I named above. If I left that open, tell me which tools you'll use and wait for my OK. 4. Place a sound on every change, fit the music to the turn, mix voice first, render, and run every QA gate in the skill. 5. Give me the MP4, a cover frame and a ready-to-paste caption.
More skills
All skillsCall Now
A shopping channel sells your thing live, until the demo goes wrong.
On Air
A 3 a.m. call-in show that starts funny and turns tender.
Treasure Map
An old sea chart tells your long hunt as the voyage of a lifetime.