Rare Pull
A kitchen table at dawn, and your topic is the last card in the pack. The commons fly off the table, the foil sweeps, the stats count up, then you trade it away and the foil dies. It comes back, and the score rolls to ten. The card design is our own, 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 pull The commons The three stats Platform Narrator Ending AI toolsUse the rare-pull skill to make a vertical video for Instagram Reels. - **The pull:** [TOPIC - the everyday thing that turns out legendary, e.g. "my morning coffee", "my dog", "our product's best feature"] - **The commons:** [COMMONS - the two ordinary versions that fly off the table, or "you write them"] - **The three stats:** [STATS - three funny true measures, one of them not a number, e.g. "warmth, focus, aftertaste: yes", or "you write them"] - **The special move:** [MOVE - what it really does for you, or "you write it"] - **The mistake and the way back:** [TRADE - what you swapped it for, and why it came back, or "you write it"] - **In the seal:** [WINDOW - "no person: pictures drawn in code", or "my own clip or photos, attached"] - **Length:** [LENGTH - e.g. about 35 seconds] - **Voice:** [VOICE - e.g. "a hushed, breathy pack-opener", "my own voice", or "no voice: captions and sound only"] - **Music:** [MUSIC - e.g. "the skill's hushed electronic 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 games or brands named, no amounts on screen, no claims I can't back up] Work in this order: 1. Write the script on the skill's moves, with one line for each turn and what happens on the table. 2. Build the plan, the clock and the frame, keeping the skill's own card design, 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, builds the table 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: rare-pull
description: Make a 30-40 second vertical video where an everyday thing is pulled from a foil pack as a legendary trading card of our own design - commons fly off the table, the last card's edge glows, the foil sweeps, stats count up, it gets traded away and comes back, then it's sealed in a clear slab while a grade rolls to ten. The skill covers the kitchen-table sound design, music fitted to the story and an optional voice like a hushed, breathy pack-opener. Use for ranking your favorite thing, a product spec breakdown, an about-me video, or when asked for a trading card, card pull, pack opening or unboxing video, or when given the rare-pull prompt.
---
# Rare Pull
A kitchen table at six in the morning, seen from straight above, and your topic is the card at the bottom of the pack. Someone whispers through the commons, flicks them onto the bulk pile, then spots a glow at the edge of the last card. It flips, the foil sweeps, the stats count up, and then it gets traded for two energy drinks and the foil dies on the table. It comes back. It goes into a clear slab, and the grade rolls up to ten. Every change lands with a rip, a flick or a click, so you keep watching to see what the next card does.
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/rare-pull/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/fraunces`, `@fontsource/space-mono`, `@fontsource/big-shoulders-display` and `@fontsource/caveat`. Copy the font files you need from `node_modules/@fontsource/<name>/files/` into `public/fonts/`, so a render never fetches a font over the network. Then lay it out like this:
```
plan.json the lines and the scenes (step 6)
takes/<id>.json one per line: the voice take's length and word timings (step 8)
scripts/clock.mjs turns plan.json and the voice takes into src/spec.json (step 6)
src/index.ts registers the composition (step 7)
src/Root.tsx the 1080 x 1920, 30 fps composition (step 7)
src/spec.json the clock, written by clock.mjs
src/frames/core.tsx the shared runtime (step 7)
src/frames/story.json the events: which word each change lands on (step 6)
src/frames/RarePull.tsx this frame (step 7)
```
Render without audio, then mux your own mix (step 10): `npx remotion render src/index.ts Film-9x16 out/silent.mp4 --muted`. Set TEMP and TMP to a folder on a roomy drive first; Remotion writes a lot of temporary files.
**Prove the setup before you write anything.** Copy the code and our `plan.json` from step 7, run the clock from step 6, and render the first few seconds (add `--frames=0-90`). Only then start on your own story, at step 5. The font files you need are named in `FACES` at the top of the frame.
**For the voice, images, music and sound effects, use whatever the user prefers.** Every step works the same with any provider. If the user hasn't said, ask which tools they want to use, or use what is already set up.
- **Your own providers.** A text-to-speech that returns word timestamps (for example ElevenLabs with timestamps), a music model or a licensed track, and a sound-effects generator (for example ElevenLabs sound effects) or a licensed sound library. Keep API keys in environment variables, never in the project. No AI media at all is fine too: the picture is code, the narration can be the user's own recording (align its words with Whisper), and the film works with no voice.
- **HYVIRAL (optional).** If the user has it connected (hyviral.com), one connection covers the media:
| You need | HYVIRAL tool |
|----------|--------------|
| Narration with word timestamps (stock voices or your own cloned voice) | `generate_voiceover` (`content_type: "shorts"`), `manage_voice_clone` |
| Still images | `generate_image` |
| Music bed | `generate_music` |
| Sound library, placing sounds, final render | `video_audio`, `video_export` |
| Long jobs | `get_job_status` (poll until delivered) |
To connect it: in Claude.ai or Claude Desktop, Customize → Connectors → add a custom connector with the URL `https://api.hyviral.com/mcp`; in Claude Code, `claude mcp add --transport http hyviral https://api.hyviral.com/mcp` then `/mcp` to sign in. Start a session with `get_connection_info`. HYVIRAL never runs your code: you render the animation yourself.
## 2. How a frame film works
- **One frame, the whole film.** A frame is a world with fixed parts on screen, and it never cuts away. The story happens to the world: a foil pack rips, cards flip and fly off the table, stats count up, the foil dies with the lamp and comes back, and a card seals into a clear slab while its grade rolls to ten. 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 kitchen table at six in the morning, seen from straight above, under one pendant lamp. Someone is opening cards in a whisper because the house is asleep.
- **Palette:** laminate teal `#1d4f4f` with `#0b2629` in the corners, espresso `#170e0a`, crema `#f4e6cb`, oxblood `#5e1d16`, dull card paper `#e2d7bf` with ink `#6e6455`, amber `#ffd27a`, and the foil loop `#f2c14e`, `#ef7a8f`, `#a584f0`, `#45d4c4`.
- **Type:** Fraunces 900 for names and big numbers, Fraunces 600 italic for the small joke on each card, Space Mono 700 for everything printed small and for the text around the seal, Big Shoulders Display 800 for captions, Caveat 700 for masking tape and the sticker.
- **Fixed parts, top to bottom:** masking tape, a clock and the bulk pile (y 60-260, nothing there has to be read), the play area (y 270-1285), captions printed on a dark rubber mat (y 1292-1480), then the chrome table edge and a checkered kitchen floor as texture below the safe area. The torn pack lands on that floor and stays there.
- **The foil is one number:** `foilAt(t)` runs from 0 to 1. It drives the card's lines, the colored light on the table, the mat's ruler and the caption's lit word. When it dies, all of them die together. That's most of what the low point looks like.
- **The camera never leaves the table:** it drifts and pushes in with `zoom()`, around a point inside the frame, so the table's edges stay off screen.
- **Small jokes live on the props:** "whisper. house is asleep.", "ALSO SOLD AS GRAVEL", "3 CARDS. NO SUGAR.", "FLAVOR: BLUE", a phone contact called Drink Guy.
**The card rule: this card must never look like a real game's card.** The idea we keep is a foil pack and a card with stats, and everything else is ours. Keep this format: a chamfered ticket with one big cut corner, a ruler spine down the left side, a round seal for the art with text set around it, the name under the seal, three tasting scales along the bottom, and beans along the cut corner for rarity.
- **Never add** hit points, attacks, damage numbers, weakness, resistance, retreat, energy symbols or energy types, evolution stages or the word "evolves", or any language about catching, training or collecting creatures.
- **Never lay it out like a famous game:** no name bar across the top with a number in the far corner, no rectangular art window in a metal frame, no rows of abilities with required symbols on the left, no star levels, no numbers in the card's corners, no attack and defense box, no colored frames by type, no "12/150" set numbering, no circle, diamond or star rarity marks, no rounded yellow or black border.
- **Stats are measures of the thing itself.** Ours are WARMTH, FOCUS and AFTERTASTE. Find the funny true measure for your topic, and let one of them not be a number.
- **The pack is a foil pouch,** crimped and valved like a bag of beans, with no mascot and no character art.
- **The slab is ours too:** a clear shell with a kitchen order ticket at the bottom and a rolling wheel for the grade. No colored band across the top, no company-style logo, no barcode, no certificate number, no grade wording copied from a real grading service. "GEM" appears alone, as a caption.
- **No real names:** no real game, grader, card shop or brand, in the picture, the script or the caption. The drinks stay generic: a plain can that says ENERGY, shaped and colored like no real product.
- **No amounts anywhere:** no market value, no trade value, no currency sign on any card, sticker, ticket or phone. The trade is one thing for two things, and that's the joke.
**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 round seal on the card, and each card shows its own subject there. Ours are drawn in code:
| Story moment | In the seal, or where it was |
|---|---|
| First common | A dull jar, side on, labeled INSTANT, with granules settling at the bottom |
| Second common | A pale cup whose steam is three small z's |
| The pull | A cup from straight above on its saucer. The crema turns slowly and steam rises |
| The ability's last word | The foam gives a tired smile, and it stays for the rest of the film |
| The low point | The card has left, so the window is gone. An empty sleeve, and "it was right here." written on the table |
| The return | The same cup, still smiling, sliding back into the sleeve |
| "Still warm" | Steam rises out of the cup, through the shell and off the top of the frame |
The window also works with the user's own clip: play it inside the seal (`Face` takes it as `art`), cropped to the circle, with the face in the middle third. It's one 408 px circle, so keep it to one face or one object. On a common, use a deliberately dull still. The card should look worth nothing until the pull.
## 5. The story
This is the part that makes the film, so write it before you touch anything else. Read every line aloud.
- **Use the world's own events.** Every turn in the topic has to become something that really happens in this world. If a line could sit in any video, rewrite it.
- **Use the voice's deadpan.** The funniest and most moving versions take the world completely seriously.
- **Short lines.** Mostly 5 to 11 words, one idea each, with an occasional two-word punch.
- **Real claims only.** Use a number or a fact only if the user gave it to you or you can source it. Invented details are fine only when the film is plainly a joke or a metaphor.
- **Build to a low point, then a turn.** The low point is a picture event, never silence: the world goes dim or dead while room tone and a designed sound keep running.
- **No em dashes on screen.** Commas, full stops and line breaks do that job.
- **Ending, organic or sponsored.** Ask the user which one, and for an organic ending, what they'll send people. Organic: "Comment KEYWORD and I'll send you THE THING", said in the world's voice, and no link. The keyword stays on screen in the world's own way until the end, because the caption moves on. Sponsored: a direct line and a URL.
- **Length.** Run the clock from step 6 as soon as the script exists, before you build anything. Each extra line adds about 3 s, and even a one-word line takes about 0.5 s. Time follows the words, so a cut only helps if words go: folding two moves into one shorter line works, and joining them word for word saves nothing. Then re-anchor every event in `story.json`.
**The moves.** Map any topic onto these. The film treats something small and daily as the rarest thing ever printed. Ours is your morning coffee, from a pack-opener whispering at dawn.
| # | Move | What it does | Our line | On the table |
|---|------|--------------|----------|--------------|
| 1 | The pack | The hook: a ritual already under way | "Okay, okay, pack number one of today." | A foil pouch shimmers, the top rips off and the cards rise out of the tear |
| 2 | Common | The ordinary version, dismissed | "Common. Instant coffee. Whatever." | The top card flips: dull paper, one bean. On "Whatever" it flies to the bulk pile |
| 3 | Common | The worse version, faster | "Common. Decaf. Who even prints these." | Same again, a cup steaming z's |
| 4 | The tell | The first sign of something else | "Wait. Wait, what is that foil?" | The lamp leans down, the last card shivers, its edge glows and thin rays leak from behind it |
| 5 | The pull | The reveal | "No way. Morning Coffee, first edition, legendary." | It flips on "Morning". Foil lines sweep from the seal, FIRST EDITION and LEGENDARY write themselves around it, five beans light, a sleeve slides on |
| 6 | Stats | The joke in numbers | "Warmth, ninety. Focus, seventy-four. Aftertaste, yes." | The camera drops to the scales. Each counts up on its own word, and the last scrambles and lands on YES |
| 7 | Ability | What it's really for, deadpan | "Special move: makes your coworkers bearable." | The seal's ring rewrites itself, and on "bearable" the foam smiles |
| 8 | Low point | The self-inflicted loss | "I traded it for two energy drinks. Why." | The card slides out and spins away, the foil dies, the lamp shrinks to a pool. An empty sleeve, two cans roll in, "it was right here." Music out; a fridge hum and a slow tick keep running |
| 9 | The turn | It comes back for a funny reason | "He doesn't drink coffee. He traded it back." | A phone lights the table: "i don't drink coffee", "trading it back". On "back" the card spins home, the foil and the lamp return, the cans roll off |
| 10 | The grade | The voiceless moment, about 3 s | (music at its fullest, captions read GRADING, then GEM) | A clear shell presses down, a line runs around its edge, the wheel rolls up to 10, rays and one big sweep |
| 11 | The button | The last laugh | "Graded ten. Still warm. Not for sale." | Steam rises through the shell on "warm", and a sticker slaps on for "sale" |
| 12 | Call to action | One action | "Comment pull and I'll send you the skill that made this." | The ticket rolls over to COMMENT PULL and stays until the end |
**For other topics:** your dog as a legendary card (LOYALTY, SNACK RANGE, BREATH: YES), your first car, a ten-year-old laptop, a product's spec sheet, an about-me video where you are the pull. The commons are the obvious alternatives. The trick that makes it land is the turn: the person who took it didn't even want it, so the thing comes back through no skill of yours. Find that reason first, then build the rest up to it. The second trick is one stat that refuses to be a number. Our film runs 38.9 s on the clock's estimate. For about 35 s, fold moves 2 and 3 into "Common. Common. Who even prints these." and cut "first edition" from move 5. Joining moves 11 and 12 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
The film is one card moved around one table, and almost every word on it sits in a block at the top of the file, so this is a quick frame to re-theme. The biggest job is redrawing the three pictures in the seal.
| To change | Where |
|---|---|
| The cards: name, small joke, rarity, ring text, stats | `INSTANT`, `DECAF`, `HERO` at the top. A stat with `from` and `land` counts up between those two events; a stat with a word for its `value` scrambles and lands on it |
| The ability | `MOVE`, and the `rings` schedule in `Face`, which says which event writes which ring text |
| The pictures in the seal | `JarArt`, `DecafArt`, `CupArt`, passed in as `art` in `Stage` and `HeroCard`. `CupArt`'s `smile` is keyed to `bearable` in `HeroCard` |
| The pack's print | `PACK`, plus `SET` for the card backs and spines |
| The jokes on the props | `TAPE` (the third line appears on the grade), `CAN`, `STICKER`, `WAS_HERE` |
| The turn | `TEXTS`: the phone's contact and its two messages. `Bubble` rows in `Phone` are keyed to `drink` and `tradedback` |
| The slab's ticket | `NOTES`, `WARM_NOTE` |
| The voiceless captions | `QUIET`: the word during the roll, and the word when the wheel lands |
| The rarity marks and symbols | `Bean`, and the bean on `CardBack` |
| The grade | `Wheel`: rolls from 1 to 10 between `slab` and `gem`. Change the array's length and the 9 in `val` for a different top score |
| The comment keyword | `KEYWORD` at the top, `plan.json`, and the `keyword` event in `story.json`, which names the word |
| The foil, lamp and camera | `foilAt`, `lampAt`, `sheenAt` and `KEYS`, all keyed to events. `HITS` says which events knock the table |
| Where things sit | `HOME` for the card, `CW`, `CH`, `RX`, `RY`, `RM` for the card's own layout, `SW`, `SH` for the slab. `Can`'s `rest` values are in `Stage`, and `Sticker` sets its own position |
- **Scene ids in the code:** none. It only reads events, so you can renumber scenes freely.
- **What sizes itself:** the card's name shrinks to fit, stat words shrink to fit, ring text packs tighter as it gets longer, `KEYWORD` shrinks to fit the ticket, the tape widens with its text, and `CAN[0]` shrinks to fit the can.
- **What is sized to our words:** `NOTES` lines fit about 30 letters, `STICKER` fits two short lines, phone messages fit about 30 letters each before they crowd the screen, and `WAS_HERE` fits about 20. Check each on the contact sheet.
- **A card name is two rows.** With a one-word name the second row sits empty, so give it something: ours says "(sort of)".
- **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": "Okay, okay, pack number one of today."
},
{
"id": "l02",
"text": "Common. Instant coffee. Whatever."
},
{
"id": "l03",
"text": "Common. Decaf. Who even prints these."
},
{
"id": "l04",
"text": "Wait. Wait, what is that foil?"
},
{
"id": "l05",
"text": "No way. Morning Coffee, first edition, legendary."
},
{
"id": "l06",
"text": "Warmth, ninety. Focus, seventy-four. Aftertaste, yes."
},
{
"id": "l07",
"text": "Special move: makes your coworkers bearable."
},
{
"id": "l08",
"text": "I traded it for two energy drinks. Why."
},
{
"id": "l09",
"text": "He doesn't drink coffee. He traded it back."
},
{
"id": "l10",
"text": "Graded ten. Still warm. Not for sale."
},
{
"id": "l11",
"text": "Comment PULL and I'll send you the skill that made this.",
"say": "Comment, pull, and I'll send you the skill that made this."
}
],
"scenes": [
{
"id": "s01",
"lines": [
"l01"
],
"lead": 0.25,
"tail": 0.1
},
{
"id": "s02",
"lines": [
"l02"
],
"lead": 0.05,
"tail": 0.1
},
{
"id": "s03",
"lines": [
"l03"
],
"lead": 0.1,
"tail": 0.1
},
{
"id": "s04",
"lines": [
"l04"
],
"lead": 0.25,
"tail": 0.1
},
{
"id": "s05",
"lines": [
"l05"
],
"lead": 0.05,
"tail": 0.1
},
{
"id": "s06",
"lines": [
"l06"
],
"lead": 0.1,
"tail": 0.1
},
{
"id": "s07",
"lines": [
"l07"
],
"lead": 0.1,
"tail": 0.1
},
{
"id": "s08",
"lines": [
"l08"
],
"lead": 0.2,
"tail": 0.8
},
{
"id": "s09",
"lines": [
"l09"
],
"lead": 0.15,
"tail": 0.1
},
{
"id": "s10",
"sec": 3.0
},
{
"id": "s11",
"lines": [
"l10"
],
"lead": 0.05,
"tail": 0.1
},
{
"id": "s12",
"lines": [
"l11"
],
"lead": 0.1,
"tail": 0.1
}
]
}
```
`src/index.ts`:
```ts
import { registerRoot } from 'remotion';
import { Root } from './Root';
registerRoot(Root);
```
`src/Root.tsx`:
```tsx
import React from 'react';
import { Composition } from 'remotion';
import { RarePull } from './frames/RarePull';
import spec from './spec.json';
export const Root: React.FC = () => (
<Composition id="Film-9x16" component={RarePull} 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": "rip",
"scene": "s01",
"word": "number",
"off": -0.1
},
{
"id": "today",
"scene": "s01",
"word": "today"
},
{
"id": "common1",
"scene": "s02",
"word": "Common"
},
{
"id": "instant",
"scene": "s02",
"word": "Instant"
},
{
"id": "whatever",
"scene": "s02",
"word": "Whatever"
},
{
"id": "common2",
"scene": "s03",
"word": "Common"
},
{
"id": "decaf",
"scene": "s03",
"word": "Decaf"
},
{
"id": "prints",
"scene": "s03",
"word": "these"
},
{
"id": "wait",
"scene": "s04",
"word": "Wait"
},
{
"id": "what",
"scene": "s04",
"word": "what"
},
{
"id": "foil",
"scene": "s04",
"word": "foil"
},
{
"id": "noway",
"scene": "s05",
"word": "No"
},
{
"id": "flip",
"scene": "s05",
"word": "Morning",
"off": -0.12
},
{
"id": "edition",
"scene": "s05",
"word": "first"
},
{
"id": "legendary",
"scene": "s05",
"word": "legendary"
},
{
"id": "warmth",
"scene": "s06",
"word": "Warmth"
},
{
"id": "ninety",
"scene": "s06",
"word": "ninety"
},
{
"id": "focus",
"scene": "s06",
"word": "Focus"
},
{
"id": "seventy",
"scene": "s06",
"word": "seventy"
},
{
"id": "after",
"scene": "s06",
"word": "Aftertaste"
},
{
"id": "yes",
"scene": "s06",
"word": "yes"
},
{
"id": "special",
"scene": "s07",
"word": "Special"
},
{
"id": "makes",
"scene": "s07",
"word": "makes"
},
{
"id": "bearable",
"scene": "s07",
"word": "bearable"
},
{
"id": "traded",
"scene": "s08",
"word": "traded"
},
{
"id": "two",
"scene": "s08",
"word": "two"
},
{
"id": "energy",
"scene": "s08",
"word": "energy"
},
{
"id": "why",
"scene": "s08",
"word": "Why"
},
{
"id": "he",
"scene": "s09",
"word": "He"
},
{
"id": "drink",
"scene": "s09",
"word": "drink"
},
{
"id": "tradedback",
"scene": "s09",
"word": "traded"
},
{
"id": "back",
"scene": "s09",
"word": "back"
},
{
"id": "slab",
"scene": "s10"
},
{
"id": "gem",
"scene": "s10",
"off": 2.15
},
{
"id": "ten",
"scene": "s11",
"word": "ten"
},
{
"id": "warm",
"scene": "s11",
"word": "warm"
},
{
"id": "sale",
"scene": "s11",
"word": "sale"
},
{
"id": "cta",
"scene": "s12"
},
{
"id": "keyword",
"scene": "s12",
"word": "PULL"
}
]
}
```
`src/frames/RarePull.tsx`:
```tsx
import React from 'react';
import { AbsoluteFill } from 'remotion';
import story from './story.json';
import { back, buildChunks, clamp, Clock, easeIn, easeInOut, easeOut, Fonts, hash, makeE, ramp, useT, type Chunk, type StoryJson, type Word } from './core';
/* Rare Pull: a kitchen table at 6 am, seen from straight above. Someone whispers through a foil pack, and the card's round seal is the window.
1080 x 1920. Time comes from the spoken words through story.json, so a re-voice moves every flip with its word.
The card is our own design: a chamfered ticket with a ruler spine, a round seal for art, three tasting scales and beans for rarity. Keep it ours. */
const E = makeE(story as StoryJson);
/* ---------- the story's strings: change these first ---------- */
const KEYWORD = 'PULL';
const TAPE = ['PACK 1 OF TODAY', 'whisper. house is asleep.', 'that was the only pack. i swear.'];
const SET = 'THE MORNING SET';
const PACK = { over: 'THE', name: ['Morning', 'Set'], under: 'FIRST POUR', tag: '3 CARDS. NO SUGAR.' };
const CAN = ['ENERGY', 'FLAVOR: BLUE'];
const TEXTS = { from: 'Drink Guy', one: "i don't drink coffee", two: 'trading it back' };
const QUIET = { before: 'GRADING', after: 'GEM' };
const STICKER = ['NOT FOR', 'SALE'];
const NOTES = ['MORNING COFFEE', 'FIRST EDITION', 'GRADED AT THE TABLE, BY ME'];
const WARM_NOTE = 'TEMP: STILL WARM';
const WAS_HERE = 'it was right here.';
type Stat = { label: string; value: string; fill: number; from?: string; land?: string };
type CardData = { no: string; name: string[]; note: string; rank: number; top: string; bottom: string; stats: Stat[] };
const INSTANT: CardData = { no: 'ROAST 88', name: ['Instant', 'Coffee'], note: 'Dissolves, eventually.', rank: 1, top: 'COMMON', bottom: 'ALSO SOLD AS GRAVEL', stats: [{ label: 'WARMTH', value: '31', fill: 0.31 }, { label: 'FOCUS', value: '12', fill: 0.12 }, { label: 'AFTERTASTE', value: 'LONG', fill: 1 }] };
const DECAF: CardData = { no: 'ROAST 90', name: ['Decaf', '(sort of)'], note: 'Contains the idea of coffee.', rank: 1, top: 'COMMON', bottom: 'BUT WHY', stats: [{ label: 'WARMTH', value: '40', fill: 0.4 }, { label: 'FOCUS', value: '00', fill: 0 }, { label: 'AFTERTASTE', value: 'NO', fill: 0 }] };
const HERO: CardData = { no: 'ROAST 01', name: ['Morning', 'Coffee'], note: 'Best before noon. Best before anyone speaks.', rank: 5, top: 'LEGENDARY', bottom: 'FIRST EDITION', stats: [{ label: 'WARMTH', value: '90', fill: 0.9, from: 'warmth', land: 'ninety' }, { label: 'FOCUS', value: '74', fill: 0.74, from: 'focus', land: 'seventy' }, { label: 'AFTERTASTE', value: 'YES', fill: 1, from: 'after', land: 'yes' }] };
const MOVE = { top: 'SPECIAL MOVE', bottom: 'MAKES YOUR COWORKERS BEARABLE' };
/* ---------- palette and type ---------- */
const TABLE = '#1d4f4f', DEEP = '#0b2629', ESPRESSO = '#170e0a', CREMA = '#f4e6cb', OX = '#5e1d16', PAPER = '#e2d7bf', PAPER_INK = '#6e6455', AMBER = '#ffd27a', DEAD = '#9eb0ae';
const FOIL = ['#f2c14e', '#ef7a8f', '#a584f0', '#45d4c4', '#f2c14e'];
const SERIF = 'Fraunces, serif', MONO = '"Space Mono", monospace', HAND = 'Caveat, cursive', CAP = '"Big Shoulders Display", sans-serif';
const FACES = [
{ family: 'Fraunces', file: 'fraunces-latin-900-normal.woff2', weight: '900' },
{ family: 'Fraunces', file: 'fraunces-latin-600-italic.woff2', weight: '600', style: 'italic' },
{ family: 'Space Mono', file: 'space-mono-latin-700-normal.woff2', weight: '700' },
{ family: 'Big Shoulders Display', file: 'big-shoulders-display-latin-800-normal.woff2', weight: '800' },
{ family: 'Caveat', file: 'caveat-latin-700-normal.woff2', weight: '700' },
];
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
const rgb = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
/** A point on the foil's color loop. */
const sample = (u: number) => {
const n = FOIL.length - 1, x = (((u % 1) + 1) % 1) * n, i = Math.floor(x), a = rgb(FOIL[i]), b = rgb(FOIL[i + 1]);
return `rgb(${a.map((v, j) => Math.round(lerp(v, b[j], x - i))).join(',')})`;
};
/* ---------- the three things the whole film hangs on: the foil, the lamp, the camera ---------- */
/** 1 is alive. Off until the flip, dies on the trade, returns on the way back. */
const foilAt = (t: number) => ramp(t, E('flip'), E('flip') + 0.3) * (1 - ramp(t, E('traded') + 0.1, E('traded') + 0.9)) + ramp(t, E('back') - 0.35, E('back') + 0.25);
/** 1 is the full pool of light. It leans in for the tell and nearly goes on the low point. */
const lampAt = (t: number) => {
const tell = ramp(t, E('wait'), E('foil')) * (1 - ramp(t, E('flip'), E('flip') + 0.35));
const low = ramp(t, E('traded') + 0.2, E('why')) * (1 - ramp(t, E('back') - 0.3, E('back') + 0.3));
return 1 - 0.5 * tell - 0.92 * low;
};
/** Where a slow sheen sits, 0 to 1, restarting on each big moment. Under one pass a second, so nothing strobes. */
const sheenAt = (t: number) => {
const hits = [E('flip'), E('legendary'), E('back'), E('gem'), E('cta')].filter((h) => t >= h && t < h + 0.8);
return hits.length ? ramp(t, hits[hits.length - 1], hits[hits.length - 1] + 0.8) : (t % 3.4) / 3.4;
};
const HITS: [number, number][] = [...([['rip', 7], ['common1', 3], ['common2', 3], ['flip', 9], ['back', 12], ['gem', 8], ['sale', 9]] as [string, number][]).map(([e, a]): [number, number] => [E(e), a]), [E('slab') + 0.42, 6]];
const knock = (t: number) => HITS.reduce((acc, [e, a]) => (t >= e ? acc + a * Math.sin((t - e) * 52) * Math.exp(-(t - e) * 11) : acc), 0);
type View = { s: number; tx: number; ty: number };
/** Zoom by s around a point of the picture that stays put, so the table's edges stay off screen. */
const zoom = (s: number, fx: number, fy: number): View => ({ s, tx: fx * (1 - s), ty: fy * (1 - s) });
const KEYS: { at: number; d: number; v: View }[] = [
{ at: E('on'), d: 0.01, v: zoom(1.05, 505, 760) },
{ at: E('rip') - 0.15, d: 0.3, v: zoom(1.12, 505, 330) },
{ at: E('common1') - 0.3, d: 0.35, v: zoom(1.015, 505, 775) },
{ at: E('wait'), d: 0.9, v: zoom(1.1, 860, 760) },
{ at: E('foil') - 0.1, d: 0.5, v: zoom(1.3, 880, 620) },
{ at: E('flip') - 0.1, d: 0.4, v: zoom(1.02, 505, 775) },
{ at: E('warmth') - 0.15, d: 0.45, v: zoom(1.2, 505, 1270) },
{ at: E('special') - 0.15, d: 0.5, v: zoom(1.08, 505, 250) },
{ at: E('traded') + 0.1, d: 0.8, v: zoom(1.01, 505, 775) },
{ at: E('why'), d: 1.6, v: zoom(1.06, 505, 700) },
{ at: E('back') - 0.4, d: 0.35, v: zoom(1.01, 505, 775) },
{ at: E('slab') + 0.5, d: 1.8, v: zoom(1.05, 505, 760) },
{ at: E('cta') - 0.1, d: 0.5, v: zoom(1.07, 505, 1260) },
];
const camAt = (t: number): View => {
let v = KEYS[0].v;
for (const k of KEYS.slice(1)) {
if (t < k.at) break;
const q = easeInOut(ramp(t, k.at, k.at + k.d));
v = { s: lerp(v.s, k.v.s, q), tx: lerp(v.tx, k.v.tx, q), ty: lerp(v.ty, k.v.ty, q) };
}
const s = Math.max(1.012, v.s + 0.006 * Math.sin(t * 0.37));
const j = knock(t);
return { s, tx: clamp(v.tx + 7 * Math.sin(t * 0.43 + 1), 1080 - 1080 * s, 0) + j, ty: clamp(v.ty + 6 * Math.sin(t * 0.31 + 2), 1920 - 1920 * s, 0) + j * 0.5 };
};
/* ---------- the table ---------- */
const boomerangs = (n: number, seed: number) => Array.from({ length: n }, (_, i) => {
const x = hash(i * 3 + seed) * 1080, y = hash(i * 7 + seed + 1) * 1520, a = hash(i + seed + 2) * 6.28, l = 7 + hash(i + seed + 3) * 9;
return `M${x.toFixed(1)} ${y.toFixed(1)}l${(Math.cos(a) * l).toFixed(1)} ${(Math.sin(a) * l).toFixed(1)}l${(Math.cos(a + 1.1) * l).toFixed(1)} ${(Math.sin(a + 1.1) * l).toFixed(1)}`;
}).join('');
const FLECKS: [string, string, number, number][] = [[boomerangs(620, 11), '#8fd0c4', 2.6, 0.2], [boomerangs(520, 5011), '#0a2d2f', 3.2, 0.55], [boomerangs(170, 9011), '#d9bf86', 2.6, 0.3]];
/** Retro laminate with a chrome edge. Past the edge is the kitchen floor, which only the platform's buttons ever cover. */
const Table: React.FC = () => (
<>
<div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1520, background: `linear-gradient(170deg,#215857,${TABLE} 55%,#183f40)` }} />
<svg viewBox="0 0 1080 1520" width={1080} height={1520} style={{ position: 'absolute', left: 0, top: 0 }}>
{FLECKS.map(([d, c, w, o], i) => <path key={i} d={d} fill="none" stroke={c} strokeWidth={w} strokeLinecap="round" strokeLinejoin="round" opacity={o} />)}
<circle cx="120" cy="1040" r="72" fill="none" stroke="#3a2412" strokeWidth="7" opacity="0.32" /><circle cx="92" cy="1090" r="72" fill="none" stroke="#3a2412" strokeWidth="4" opacity="0.2" />
<circle cx="420" cy="170" r="66" fill="none" stroke="#3a2412" strokeWidth="6" opacity="0.26" />
</svg>
<div style={{ position: 'absolute', left: 0, top: 1520, width: 1080, height: 400, background: 'repeating-conic-gradient(#2a3338 0 25%,#b9b2a0 0 50%) 0 0/130px 130px' }} />
<div style={{ position: 'absolute', left: 0, top: 1490, width: 1080, height: 44, background: 'repeating-linear-gradient(#e8efef 0 3px,#7f8c8e 3px 8px,#c6d0d1 8px 11px)', boxShadow: '0 14px 0 rgba(0,0,0,.35)' }} />
</>
);
/** Colored light the foil throws on the table. It dies with the foil, which is most of what the low point looks like. */
const Caustics: React.FC = () => {
const t = useT(), foil = foilAt(t);
if (foil <= 0.01) return null;
const spots = [0, 1, 2, 3, 4].map((i) => {
const a = t * 0.4 + i * 1.3 + Math.sin(t * 0.9) * 0.5;
return `radial-gradient(circle 300px at ${505 + Math.cos(a) * 470}px ${760 + Math.sin(a * 1.2) * 620}px, ${sample(i * 0.2 + t * 0.05).replace('rgb', 'rgba').replace(')', `,${0.42 * foil})`)}, rgba(0,0,0,0) 100%)`;
});
return <div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1490, background: spots.join(','), mixBlendMode: 'screen' }} />;
};
const Masking: React.FC<{ x: number; y: number; w: number; r: number; size: number; children: React.ReactNode; font?: string }> = ({ x, y, w, r, size, children, font = HAND }) => (
<div style={{ position: 'absolute', left: x, top: y, width: w, background: 'linear-gradient(#ead9a6,#dcc88f)', opacity: 0.96, transform: `rotate(${r}deg)`, font: `700 ${size}px/${size * 1.12}px ${font}`, color: '#33291a', textAlign: 'center', whiteSpace: 'nowrap', clipPath: 'polygon(0 0,1.5% 18%,0 36%,1.5% 54%,0 72%,1.5% 100%,98.5% 100%,100% 78%,98.5% 58%,100% 38%,98.5% 20%,100% 0)' }}>{children}</div>
);
/** Top of the table: nothing here has to be read, so the jokes live here. */
const Props: React.FC = () => {
const t = useT();
const done = t >= E('gem');
const tick = Math.floor(t);
const kick = 1 - ramp(t - tick, 0, 0.09);
return (
<>
<Masking x={44} y={84} w={TAPE[0].length * 30} r={-2} size={62}>{TAPE[0]}</Masking>
<Masking x={70} y={166} w={(done ? TAPE[2] : TAPE[1]).length * 13.6} r={1} size={36}>{done ? TAPE[2] : TAPE[1]}</Masking>
<svg viewBox="-90 -90 180 180" width={170} height={170} style={{ position: 'absolute', left: 520, top: 58 }}>
<circle cx="5" cy="9" r="84" fill="rgba(3,14,16,.5)" /><circle r="84" fill="#aeb9ba" /><circle r="76" fill="#40494b" /><circle r="72" fill={CREMA} />
{Array.from({ length: 12 }, (_, i) => <rect key={i} x="-1.8" y="-66" width="3.6" height={i % 3 ? 7 : 13} fill={ESPRESSO} transform={`rotate(${i * 30})`} />)}
<rect x="-3" y="-36" width="6" height="40" rx="2" fill={ESPRESSO} transform="rotate(186)" />
<rect x="-2.2" y="-56" width="4.4" height="60" rx="2" fill={ESPRESSO} transform={`rotate(${72 + t * 0.1})`} />
<rect x="-1" y="-60" width="2" height="72" fill={OX} transform={`rotate(${(tick + 8) * 6 - kick * 3})`} />
<circle r="5" fill={OX} />
</svg>
{/* the bulk pile: where commons go */}
<div style={{ position: 'absolute', left: 806, top: 60, width: 150, height: 196, transform: 'rotate(7deg)' }}>
{[0, 1, 2, 3, 4, 5].map((i) => <div key={i} style={{ position: 'absolute', left: i * 3 - 8, top: i * -3 + 14, width: 136, height: 190, clipPath: 'polygon(10% 0,74% 0,100% 18%,100% 95%,92% 100%,10% 100%,0 95%,0 7%)', background: i % 2 ? PAPER : '#cfc4ab' }} />)}
<div style={{ position: 'absolute', left: -14, top: 86, width: 170, height: 9, background: '#7a3a2c' }} />
</div>
<div style={{ position: 'absolute', zIndex: 45, left: 0, top: 0 }}><Masking x={760} y={226} w={120} r={-5} size={32} font={MONO}>BULK</Masking></div>
</>
);
};
/* ---------- the pack: a foil pouch, crimped like a bag of beans, never a booster with a mascot ---------- */
const PW = 600, PH = 870, TEAR = 104;
const GRID = Array.from({ length: 11 }, (_, r) => Array.from({ length: 8 }, (_, c) => [(c / 7) * PW + (c % 7 ? (hash(r * 8 + c + 3) - 0.5) * 62 : 0), (r / 10) * PH + (r % 10 ? (hash(r * 8 + c + 90) - 0.5) * 62 : 0)]));
const TRIS = GRID.slice(0, -1).flatMap((row, r) => row.slice(0, -1).flatMap((_, c) => {
const [a, b, d, e] = [GRID[r][c], GRID[r][c + 1], GRID[r + 1][c], GRID[r + 1][c + 1]];
return [[a, b, d], [b, e, d]].map((p, k) => ({ pts: p.map((q) => q.map((n) => n.toFixed(1)).join(',')).join(' '), cx: (p[0][0] + p[1][0] + p[2][0]) / 3, cy: (p[0][1] + p[1][1] + p[2][1]) / 3, v: hash(r * 31 + c * 7 + k) }));
}));
const JAG = Array.from({ length: 25 }, (_, i) => `${(i / 24) * 100}% ${TEAR + (i % 2 ? 7 : -5) + hash(i + 3) * 8}px`);
const TEETH = Array.from({ length: 61 }, (_, i) => `${(i / 60) * 100}% ${i % 2 ? 8 : 0}px`).join(',');
const PackArt: React.FC = () => {
const t = useT();
const S = ((t * 520) % 2000) - 300;
return (
<>
<svg viewBox={`0 0 ${PW} ${PH}`} width={PW} height={PH} style={{ position: 'absolute', left: 0, top: 0 }}>
{TRIS.map((q, i) => {
const hit = Math.exp(-Math.pow((q.cx + q.cy * 0.55 - S) / 90, 2));
return <polygon key={i} points={q.pts} fill={`hsl(${20 + 10 * Math.sin((q.cx + q.cy) / 300 + t * 0.6) - hit * 28},${46 + hit * 40}%,${20 + q.v * 36 + hit * 42}%)`} stroke={`rgba(255,232,205,${0.08 + hit * 0.4})`} strokeWidth="1.5" />;
})}
<rect x="0" y="0" width={PW} height="70" fill="url(#crimp)" />
<defs><pattern id="crimp" width="600" height="9" patternUnits="userSpaceOnUse"><rect width="600" height="4" fill="rgba(255,240,220,.3)" /><rect y="4" width="600" height="5" fill="rgba(30,10,0,.32)" /></pattern></defs>
<path d={`M0 ${TEAR} H${PW}`} stroke="rgba(30,10,0,.5)" strokeWidth="3" strokeDasharray="12 10" />
<circle cx="505" cy="170" r="28" fill="rgba(30,10,0,.28)" /><circle cx="505" cy="170" r="11" fill="rgba(255,240,220,.4)" />
<path d={`M0 ${PH - 150} L120 ${PH} M${PW} ${PH - 150} L${PW - 120} ${PH}`} stroke="rgba(30,10,0,.35)" strokeWidth="5" />
</svg>
<div style={{ position: 'absolute', left: 88, top: 196, width: 424, height: 424, borderRadius: '50%', background: ESPRESSO, boxShadow: `0 0 0 6px ${CREMA},0 0 0 16px ${ESPRESSO}`, textAlign: 'center', color: CREMA }}>
<div style={{ font: `700 24px/1 ${MONO}`, letterSpacing: '.14em', marginTop: 62 }}>{PACK.over}</div>
<div style={{ font: `900 80px/82px ${SERIF}`, marginTop: 10 }}>{PACK.name.map((l) => <div key={l}>{l}</div>)}</div>
<div style={{ font: `700 24px/1 ${MONO}`, letterSpacing: '.14em', marginTop: 24, color: AMBER }}>{PACK.under}</div>
</div>
<div style={{ position: 'absolute', left: 104, right: 104, top: 668, padding: '10px 0 8px', textAlign: 'center', background: CREMA, font: `700 32px/1 ${MONO}`, color: ESPRESSO }}>{PACK.tag}</div>
<div style={{ position: 'absolute', left: 22, top: 114, font: `700 20px/1 ${MONO}`, color: 'rgba(30,10,0,.7)' }}>TEAR HERE</div>
</>
);
};
const Pack: React.FC = () => {
const t = useT();
const rip = E('rip');
const q = ramp(t, rip, rip + 0.26);
const fly = easeIn(ramp(t, rip + 0.24, rip + 0.85));
const f = easeInOut(ramp(t, rip + 0.5, rip + 1.05));
const wob = (1 - q) * Math.sin(t * 2.6) * 1.6;
const x = lerp(505, 200, f), y = lerp(760, 1700, f), s = lerp(1, 0.36, f), r = wob + f * -24;
const box = { position: 'absolute' as const, left: 0, top: 0, width: PW, height: PH };
return (
<div style={{ ...box, zIndex: 60, left: x - PW / 2, top: y - PH / 2, transform: `rotate(${r}deg) scale(${s})` }}>
<div style={{ ...box, left: 12, top: 18, background: 'rgba(0,10,12,.34)', clipPath: `polygon(${TEETH},100% 100%,0 100%)` }} />
<div style={{ ...box, clipPath: `polygon(${q > 0 ? JAG.join(',') : `0 ${TEAR}px,100% ${TEAR}px`},100% 100%,0 100%)` }}><PackArt /></div>
{fly < 1 ? <div style={{ ...box, clipPath: `polygon(${TEETH},${[...JAG].reverse().join(',')})`, transformOrigin: `100% ${TEAR}px`, transform: `translate(${fly * 340}px,${fly * -520}px) rotate(${easeOut(q) * -13 + fly * 80}deg)` }}><PackArt /></div> : null}
</div>
);
};
/* ---------- the card: 700 x 980, our own format ---------- */
const CW = 700, CH = 980, CUT = 40, BIG = 140, RX = 384, RY = 306, DISC = 204, RM = 230, RO = 256;
const SHAPE = `polygon(${CUT}px 0,${CW - BIG}px 0,${CW}px ${BIG}px,${CW}px ${CH - CUT}px,${CW - CUT}px ${CH}px,${CUT}px ${CH}px,0 ${CH - CUT}px,0 ${CUT}px)`;
const OUTLINE = (i: number) => `${CUT + i * 0.4},${i} ${CW - BIG - i * 0.4},${i} ${CW - i},${BIG + i * 0.4} ${CW - i},${CH - CUT - i * 0.4} ${CW - CUT - i * 0.4},${CH - i} ${CUT + i * 0.4},${CH - i} ${i},${CH - CUT - i * 0.4} ${i},${CUT + i * 0.4}`;
const RIPS = Array.from({ length: 44 }, (_, i) => RO + 14 + i * 14);
/** Text set around the seal, one letter at a time. A longer string packs tighter instead of running off the ring. */
const Ring: React.FC<{ sched: [number, string][]; arc: 'top' | 'bottom'; tone: (i: number) => string }> = ({ sched, arc, tone }) => {
const t = useT();
const cur = [...sched].reverse().find(([at]) => t >= at);
if (!cur) return null;
const [at, text] = cur;
const n = text.length, step = Math.min(6.1, (arc === 'top' ? 190 : 176) / Math.max(1, n));
return (
<>
{Array.from(text).map((ch, i) => {
const age = t - at - i * 0.016;
if (age < 0) return null;
const th = (i - (n - 1) / 2) * step, k = back(ramp(age, 0, 0.14));
const tr = arc === 'top' ? `rotate(${th}deg) translateY(${-RM}px)` : `rotate(${-th}deg) translateY(${RM}px)`;
return <span key={`${at}-${i}`} style={{ position: 'absolute', left: RX - 16, top: RY - 24, width: 32, height: 48, textAlign: 'center', font: `700 35px/48px ${MONO}`, color: tone(i), transform: `${tr} scale(${lerp(1.7, 1, k)})` }}>{ch}</span>;
})}
</>
);
};
const Bean: React.FC<{ i: number; lit: number; color: string; ground: string }> = ({ i, lit, color, ground }) => (
<g transform={`translate(${556 + i * 26} ${54 + i * 26}) rotate(45) scale(${1 + 0.5 * (1 - lit) * (lit > 0 ? 1 : 0)})`}>
<ellipse rx="15" ry="10" fill={lit > 0 ? color : 'none'} stroke={color} strokeWidth="2.4" opacity={lit > 0 ? 1 : 0.5} />
{lit > 0 ? <path d="M-14 3 C-5 -6 5 6 14 -3" fill="none" stroke={ground} strokeWidth="2.6" /> : null}
</g>
);
const StatCell: React.FC<{ s: Stat; x: number; hero: boolean; ink: string; foil: number }> = ({ s, x, hero, ink, foil }) => {
const t = useT();
let v = s.value, k = 1, pop = 0;
if (s.from && s.land) {
const a = E(s.from), b = E(s.land) + 0.15, num = Number(s.value);
k = easeOut(ramp(t, a, b));
if (Number.isNaN(num)) v = t < a ? '--' : t < b - 0.15 ? String(Math.floor(hash(Math.floor(t * 11)) * 90) + 10) : s.value;
else v = String(Math.round(num * k)).padStart(2, '0');
pop = t >= b - 0.15 ? 1 - ramp(t, b - 0.15, b + 0.15) : 0;
}
const lit = Math.round(k * s.fill * 15);
return (
<div style={{ position: 'absolute', left: x, top: 0, width: 190, height: 150 }}>
<div style={{ font: `700 23px/28px ${MONO}`, color: ink, letterSpacing: '.02em', whiteSpace: 'nowrap' }}>{s.label}</div>
<div style={{ height: 80, font: `900 ${Math.min(84, 240 / Math.max(1, v.length))}px/80px ${SERIF}`, color: hero && pop ? AMBER : ink, transformOrigin: '0 60%', transform: `scale(${1 + 0.24 * pop})`, whiteSpace: 'nowrap' }}>{v}</div>
<svg viewBox="0 0 180 22" width={180} height={22} style={{ position: 'absolute', left: 0, top: 110 }}>
{Array.from({ length: 15 }, (_, i) => <rect key={i} x={i * 12} y={i < lit ? 0 : 10} width="7" height={i < lit ? 22 : 12} fill={i < lit ? (hero ? sample(i * 0.05 + t * 0.2 * foil) : ink) : ink} opacity={i < lit ? 1 : 0.32} />)}
</svg>
</div>
);
};
const Face: React.FC<{ c: CardData; hero?: boolean; art: React.ReactNode }> = ({ c, hero = false, art }) => {
const t = useT();
const foil = hero ? foilAt(t) : 0;
const ink = hero ? CREMA : PAPER_INK, ground = hero ? ESPRESSO : PAPER;
const g = t * 150 + 460 * Math.sin(t * 0.9);
const sheen = sheenAt(t) * 150 - 25;
const nameFoil: React.CSSProperties = hero ? { backgroundImage: `linear-gradient(105deg,${CREMA} 0 ${sheen - 13}%,#ffd98a ${sheen - 8}%,#ff9ab0 ${sheen - 3}%,#b79cff ${sheen + 2}%,#7fe6d8 ${sheen + 7}%,${CREMA} ${sheen + 13}% 100%)`, WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' } : { color: ink };
const nameSize = Math.min(118, 590 / (Math.max(...c.name.map((l) => l.length)) * 0.63));
const tone = (i: number) => (hero && foil > 0.3 ? sample(i * 0.045 + t * 0.14) : ink);
const rings: { top: [number, string][]; bottom: [number, string][] } = hero
? { top: [[E('legendary'), c.top], [E('special'), MOVE.top]], bottom: [[E('edition'), c.bottom], [E('makes'), MOVE.bottom]] }
: { top: [[-1, c.top]], bottom: [[-1, c.bottom]] };
return (
<div style={{ position: 'absolute', inset: 0, clipPath: SHAPE, background: ground }}>
<svg viewBox={`0 0 ${CW} ${CH}`} width={CW} height={CH} style={{ position: 'absolute', left: 0, top: 0 }}>
<defs>
<linearGradient id="rpFoil" gradientUnits="userSpaceOnUse" spreadMethod="repeat" x1={g} y1={g * 0.4} x2={g + 560} y2={g * 0.4 + 560}>{FOIL.map((f, i) => <stop key={i} offset={i / (FOIL.length - 1)} stopColor={f} />)}</linearGradient>
</defs>
{hero ? RIPS.map((r, i) => <circle key={r} cx={RX} cy={RY} r={r} fill="none" stroke="url(#rpFoil)" strokeWidth={i % 4 ? 2 : 3.6} opacity={(0.1 + 0.8 * foil) * (1 - i / 62)} />) : null}
{hero ? <rect x="0" y="0" width="64" height={CH} fill={ground} opacity="0.8" /> : null}
<polygon points={OUTLINE(12)} fill="none" stroke={hero ? 'url(#rpFoil)' : ink} strokeWidth="3" opacity={hero ? 0.35 + 0.65 * foil : 0.55} />
<line x1="64" y1="30" x2="64" y2={CH - 30} stroke={hero ? 'url(#rpFoil)' : ink} strokeWidth="2.5" opacity={hero ? 0.4 + 0.6 * foil : 0.55} />
{Array.from({ length: 56 }, (_, i) => <rect key={i} x="12" y={36 + i * 16.4} width={i % 5 ? 12 : 24} height="2.5" fill={hero ? 'url(#rpFoil)' : ink} opacity={hero ? 0.4 + 0.6 * foil : 0.6} />)}
<circle cx={RX} cy={RY} r={RO + 6} fill={ground} /><circle cx={RX} cy={RY} r={RO} fill="none" stroke={hero ? 'url(#rpFoil)' : ink} strokeWidth="3.5" /><circle cx={RX} cy={RY} r={DISC + 2} fill="none" stroke={hero ? 'url(#rpFoil)' : ink} strokeWidth="3.5" />
{Array.from({ length: 60 }, (_, i) => <rect key={i} x={RX - 1} y={RY - RO + 3} width="2" height={i % 5 ? 6 : 10} fill={ink} opacity={0.5} transform={`rotate(${i * 6} ${RX} ${RY})`} />)}
{[0, 1, 2, 3, 4].map((i) => { const on = hero ? ramp(t, E('legendary') + i * 0.09, E('legendary') + i * 0.09 + 0.2) : i < c.rank ? 1 : 0; return <Bean key={i} i={i} lit={on} color={hero ? sample(i * 0.2 + t * 0.15) : ink} ground={ground} />; })}
</svg>
<div style={{ position: 'absolute', left: RX - DISC, top: RY - DISC, width: DISC * 2, height: DISC * 2, borderRadius: '50%', overflow: 'hidden', background: '#000' }}>{art}</div>
<Ring sched={rings.top} arc="top" tone={tone} />
<Ring sched={rings.bottom} arc="bottom" tone={tone} />
<div style={{ position: 'absolute', left: 88, top: 22, font: `700 23px/1 ${MONO}`, color: ink, opacity: 0.85 }}>{c.no}</div>
<div style={{ position: 'absolute', left: 16, top: 36, height: CH - 80, writingMode: 'vertical-rl', transform: 'rotate(180deg)', font: `700 24px/44px ${MONO}`, color: ink, letterSpacing: '.3em', textAlign: 'center', paddingRight: 8 }}>{SET}</div>
<div style={{ position: 'absolute', left: 84, top: 566, width: 600, background: hero ? 'linear-gradient(rgba(23,14,10,0),rgba(23,14,10,.82) 16%,rgba(23,14,10,.92))' : undefined, paddingLeft: 4 }}>
<div style={{ font: `900 ${nameSize}px/112px ${SERIF}`, letterSpacing: '-.015em', whiteSpace: 'nowrap', ...nameFoil }}>{c.name.map((l) => <div key={l}>{l}</div>)}</div>
</div>
<div style={{ position: 'absolute', left: 84, top: 790, width: 600, height: 136, background: hero ? 'rgba(23,14,10,.92)' : undefined, paddingLeft: 4 }}>
{c.stats.map((s, i) => <StatCell key={s.label} s={s} x={4 + i * 200} hero={hero} ink={ink} foil={foil} />)}
</div>
<div style={{ position: 'absolute', left: 88, top: 928, width: 580, font: `italic 600 25px/30px ${SERIF}`, color: ink, opacity: 0.85, whiteSpace: 'nowrap' }}>{c.note}</div>
{hero ? <div style={{ position: 'absolute', inset: 0, backgroundImage: `linear-gradient(115deg,rgba(255,250,235,0) ${sheen - 16}%,rgba(255,250,235,${0.6 * foil}) ${sheen}%,rgba(255,250,235,0) ${sheen + 16}%)`, mixBlendMode: 'overlay' }} /> : null}
</div>
);
};
const CardBack: React.FC = () => (
<div style={{ position: 'absolute', inset: 0, clipPath: SHAPE, background: OX }}>
<svg viewBox={`0 0 ${CW} ${CH}`} width={CW} height={CH} style={{ position: 'absolute', left: 0, top: 0 }}>
{Array.from({ length: 22 }, (_, i) => <circle key={i} cx={CW / 2} cy={CH / 2} r={70 + i * 30} fill="none" stroke={CREMA} strokeWidth={i % 3 ? 2 : 5} opacity={0.34} />)}
<circle cx={CW / 2} cy={CH / 2} r="136" fill={OX} stroke={CREMA} strokeWidth="5" />
<g transform={`translate(${CW / 2} ${CH / 2}) rotate(-35) scale(5.4)`}><ellipse rx="15" ry="10" fill={CREMA} /><path d="M-14 3 C-5 -6 5 6 14 -3" fill="none" stroke={OX} strokeWidth="2.6" /></g>
<polygon points={OUTLINE(14)} fill="none" stroke={CREMA} strokeWidth="4" opacity="0.8" />
</svg>
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 56, textAlign: 'center', font: `700 28px/1 ${MONO}`, letterSpacing: '.3em', color: CREMA }}>{SET}</div>
</div>
);
/* ---------- the window: what the round seal shows, drawn in code ---------- */
const JarArt: React.FC = () => {
const t = useT();
return (
<>
<svg viewBox="-204 -204 408 408" width={408} height={408} style={{ display: 'block' }}>
<rect x="-204" y="-204" width="408" height="408" fill="#c6bba3" />
<rect x="-92" y="-128" width="184" height="46" rx="6" fill="#7d7363" /><rect x="-104" y="-82" width="208" height="222" rx="20" fill="#5b4638" stroke="#6e6455" strokeWidth="5" />
<rect x="-104" y="-30" width="208" height="110" fill={PAPER} />
{[0, 1, 2, 3, 4].map((i) => <circle key={i} cx={-70 + i * 35} cy={110 + Math.sin(t * 2 + i) * 3} r="9" fill="#3c2c22" />)}
</svg>
<div style={{ position: 'absolute', left: 0, right: 0, top: 195, textAlign: 'center', font: `700 38px/1 ${MONO}`, color: PAPER_INK }}>INSTANT</div>
</>
);
};
const DecafArt: React.FC = () => {
const t = useT();
return (
<>
<svg viewBox="-204 -204 408 408" width={408} height={408} style={{ display: 'block' }}>
<rect x="-204" y="-204" width="408" height="408" fill="#b4b9ae" />
<path d="M-96 -30 H96 L78 130 H-78 Z" fill={PAPER} stroke={PAPER_INK} strokeWidth="5" /><path d="M96 -6 C150 -8 146 74 86 74" fill="none" stroke={PAPER_INK} strokeWidth="12" />
<ellipse cx="0" cy="-30" rx="96" ry="18" fill="#a68e72" stroke={PAPER_INK} strokeWidth="5" />
</svg>
{[0, 1, 2].map((i) => { const k = (t * 0.5 + i / 3) % 1; return <div key={i} style={{ position: 'absolute', left: 190 + i * 26, top: 110 - k * 90, font: `700 ${26 + i * 12}px/1 ${MONO}`, color: PAPER_INK, opacity: Math.sin(k * 3.14) }}>z</div>; })}
</>
);
};
/** The hero, from straight above. The crema swirls, the steam rises, and on the ability's last word the foam gives a tired smile. */
const CupArt: React.FC<{ smile: number }> = ({ smile }) => {
const t = useT();
const wisps = [-40, 8, 50].map((x, i) => `M${x} 40 ` + Array.from({ length: 18 }, (_, k) => `L${(x + Math.sin(t * 1.3 + k * 0.42 + i * 2) * (5 + k * 1.4)).toFixed(1)} ${40 - (k + 1) * 13}`).join(' '));
return (
<svg viewBox="-204 -204 408 408" width={408} height={408} style={{ display: 'block' }}>
<defs><radialGradient id="rpCof" cx="0.42" cy="0.38" r="0.7"><stop offset="0" stopColor="#6d3c1d" /><stop offset="0.72" stopColor="#3a1d0d" /><stop offset="0.92" stopColor="#b57a3e" /><stop offset="1" stopColor="#e2b77a" /></radialGradient></defs>
<rect x="-204" y="-204" width="408" height="408" fill={OX} />
<circle cx="8" cy="14" r="186" fill="rgba(0,0,0,.28)" /><circle r="186" fill="#efe1c6" /><circle r="166" fill="none" stroke="#c9b48f" strokeWidth="3" /><circle r="146" fill="none" stroke={OX} strokeWidth="5" />
<rect x="112" y="-26" width="74" height="52" rx="22" fill="#fff6e4" stroke="#c9b48f" strokeWidth="3" />
<circle r="130" fill="#fff6e4" stroke="#c9b48f" strokeWidth="3" /><circle r="112" fill="url(#rpCof)" />
<g transform={`rotate(${t * 9})`}>{[0, 1, 2].map((i) => <path key={i} d="M12 -6 C60 -20 86 26 54 62 C36 82 4 84 -14 72" transform={`rotate(${i * 120})`} fill="none" stroke="#c99a5e" strokeWidth={i === 1 ? 5 : 3.5} opacity="0.5" strokeLinecap="round" />)}</g>
{smile > 0 ? <g opacity={smile}><circle cx="-32" cy="-20" r="10" fill={CREMA} /><circle cx="32" cy="-20" r="10" fill={CREMA} /><path d="M-40 26 Q0 52 40 26" fill="none" stroke={CREMA} strokeWidth="9" strokeLinecap="round" /></g> : null}
{wisps.map((d, i) => <path key={i} d={d} fill="none" stroke="#fff8ea" strokeWidth={13 - i * 3} strokeLinecap="round" strokeLinejoin="round" opacity="0.15" />)}
</svg>
);
};
/* ---------- moving the cards ---------- */
type Pose = { x: number; y: number; s: number; rx: number; ry: number; rz: number };
const HOME = { x: 505, y: 775 };
/* The stack starts inside the pack, small enough to hide, rises out of the tear, then settles home as the pack falls away. */
const riseY = (t: number) => lerp(lerp(800, 520, easeOut(ramp(t, E('rip') + 0.08, E('rip') + 0.5))), HOME.y, easeInOut(ramp(t, E('rip') + 0.55, E('common1') - 0.08)));
const stackS = (t: number) => lerp(0.8, 0.94, ramp(t, E('rip') + 0.4, E('common1') - 0.08));
const Slip: React.FC<{ pose: Pose; z: number; front: React.ReactNode; backFace: React.ReactNode; under?: React.ReactNode; filter?: string }> = ({ pose, z, front, backFace, under, filter }) => {
const up = Math.cos((pose.ry * Math.PI) / 180) > 0;
return (
<div style={{ position: 'absolute', left: pose.x - CW / 2, top: pose.y - CH / 2, width: CW, height: CH, zIndex: z, filter, transform: `perspective(2400px) rotateX(${pose.rx}deg) rotateY(${up ? pose.ry : pose.ry - 180}deg) rotateZ(${pose.rz}deg) scale(${pose.s})` }}>
{under}
<div style={{ position: 'absolute', left: 9, top: 16, width: CW, height: CH, clipPath: SHAPE, background: 'rgba(0,12,14,.42)' }} />
{up ? front : backFace}
</div>
);
};
const Common: React.FC<{ c: CardData; i: number; flip: number; fly: number; art: React.ReactNode; rest: [number, number, number] }> = ({ c, i, flip, fly, art, rest }) => {
const t = useT();
const f = easeInOut(ramp(t, flip, flip + 0.3)), q = ramp(t, fly, fly + 0.42), e = easeInOut(q);
const pose: Pose = {
x: lerp(HOME.x + i * 6, rest[0], e), y: lerp(riseY(t) + i * 6, rest[1], e), s: lerp(lerp(stackS(t), 1, f), 0.19, e), rx: 0,
ry: 180 * (1 - f) + (f === 1 && q === 0 ? 3 * Math.sin(t * 1.8) : 0), rz: lerp(lerp(i * 0.7, -1.4, f), rest[2], e),
};
return <Slip pose={pose} z={q >= 1 ? 40 - i : 50 - i} front={<Face c={c} art={art} />} backFace={<CardBack />} />;
};
const heroPose = (t: number): Pose => {
const f = E('flip'), a = E('traded'), b = E('back'), sl = E('slab');
const k = easeInOut(ramp(t, f, f + 0.5)), tell = ramp(t, E('wait'), E('noway')), slab = easeInOut(ramp(t, sl, sl + 0.45));
const idle = ramp(t, f + 0.4, f + 1) * lerp(1, 0.4, slab);
const shiver = tell * (1 - k) * Math.sin(t * 41) * 1.8;
const p: Pose = {
x: lerp(HOME.x + 12, HOME.x, Math.max(k, tell)) + shiver, y: lerp(riseY(t) + 12, HOME.y, Math.max(k, tell)) - shiver * 0.5,
s: lerp(lerp(stackS(t), 1, tell), 1, k) + 0.09 * Math.sin(k * Math.PI),
rx: idle * 3 * Math.cos(t * 0.7), ry: 180 + 180 * k + idle * 5 * Math.sin(t * 0.9), rz: lerp(1.4 * (1 - tell), 0, k),
};
const u = easeIn(ramp(t, a, a + 0.22)), v = easeIn(ramp(t, a + 0.16, a + 0.95));
const r = t < b - 0.55 ? 0 : easeOut(ramp(t, b - 0.55, b));
const w = t > a && r > 0 ? 1 - r : v;
p.y -= (t < b - 0.55 ? 240 * u : 0) + 1120 * w;
p.x += 780 * w;
p.rz += (r > 0 ? -260 : 320) * w;
p.ry += (r > 0 ? -540 : 540) * w;
p.s = lerp(p.s, 0.55, w);
p.y = lerp(p.y, 706, slab);
p.s = lerp(p.s, 0.82, slab);
return p;
};
const Aura: React.FC = () => {
const t = useT();
const a = ramp(t, E('wait'), E('foil') + 0.1) * (1 - ramp(t, E('flip') + 0.1, E('flip') + 0.4));
if (a <= 0) return null;
const grow = 1 + 0.014 + 0.03 * ramp(t, E('what'), E('flip'));
return <div style={{ position: 'absolute', inset: 0, clipPath: SHAPE, opacity: a, transform: `scale(${grow})`, background: `conic-gradient(from ${t * 110}deg,${[...FOIL, FOIL[0]].join(',')})` }} />;
};
const HeroCard: React.FC = () => {
const t = useT();
const pose = heroPose(t);
const a = E('traded'), b = E('back');
if (t > a + 0.95 && t < b - 0.55) return null;
const foil = foilAt(t);
const smile = ramp(t, E('bearable'), E('bearable') + 0.3);
return <Slip pose={pose} z={20} under={<Aura />} filter={t > a && t < b + 0.3 ? `saturate(${0.15 + 0.85 * foil}) brightness(${0.7 + 0.3 * foil})` : undefined} front={<Face c={HERO} hero art={<CupArt smile={smile} />} />} backFace={<CardBack />} />;
};
/** Long thin rays from behind the card while the tell builds, and for a moment on the flip and on the grade. */
const Rays: React.FC = () => {
const t = useT();
const a = Math.max(ramp(t, E('what'), E('flip')) * (1 - ramp(t, E('flip') + 0.5, E('flip') + 1.4)), ramp(t, E('gem') - 0.1, E('gem')) * (1 - ramp(t, E('gem') + 0.2, E('gem') + 1.0)));
if (a <= 0) return null;
return (
<svg viewBox="0 0 1080 1520" width={1080} height={1520} style={{ position: 'absolute', left: 0, top: 0, mixBlendMode: 'screen' }}>
{Array.from({ length: 18 }, (_, i) => {
const th = (i / 18) * 6.283 + t * 0.14 + hash(i + 5) * 0.2, w = 0.035 + hash(i + 9) * 0.05, R = 1500;
return <polygon key={i} points={`${HOME.x},${HOME.y} ${HOME.x + Math.cos(th - w) * R},${HOME.y + Math.sin(th - w) * R} ${HOME.x + Math.cos(th + w) * R},${HOME.y + Math.sin(th + w) * R}`} fill={sample(i / 7 + t * 0.1)} opacity={a * (0.2 + hash(i + 2) * 0.22)} />;
})}
</svg>
);
};
/* ---------- the low point: a sleeve, two cans, a phone ---------- */
const Sleeve: React.FC = () => {
const t = useT();
const at = E('legendary') + 0.32, sl = E('slab');
if (t < at || t > sl + 0.3) return null;
const k = easeOut(ramp(t, at, at + 0.3)), out = ramp(t, sl, sl + 0.3);
const gone = ramp(t, E('traded'), E('traded') + 0.3) * (1 - ramp(t, E('back') - 0.1, E('back') + 0.2));
const idle = lerp(1, 0, gone);
const hum = gone * Math.sin(t * 47) * 0.5;
const lean = (t * 0.22) % 1;
return (
<div style={{ position: 'absolute', zIndex: 21, left: HOME.x - CW / 2 - 16 + hum, top: HOME.y - CH / 2 - 26 + (1 - k) * 1100, width: CW + 32, height: CH + 42, opacity: 1 - out, transform: `perspective(2400px) rotateX(${idle * 3 * Math.cos(t * 0.7)}deg) rotateY(${idle * 5 * Math.sin(t * 0.9)}deg)`, background: `linear-gradient(${118}deg,rgba(225,245,245,.05) ${lean * 60}%,rgba(235,250,250,${0.11 + 0.1 * gone}) ${lean * 60 + 9}%,rgba(225,245,245,.05) ${lean * 60 + 20}%,rgba(225,245,245,.14) ${lean * 60 + 26}%,rgba(225,245,245,.05) ${lean * 60 + 34}%)`, border: `3px solid rgba(225,245,245,${0.42 + 0.4 * gone})`, borderTop: '3px dashed rgba(225,245,245,.28)', borderRadius: 8 }}>
<div style={{ position: 'absolute', left: 0, right: 0, top: 0, height: 26, background: 'rgba(225,245,245,.09)' }} />
<div style={{ position: 'absolute', left: (CW + 32) / 2 - 70, top: -3, width: 140, height: 40, borderRadius: '0 0 70px 70px', background: 'rgba(3,14,18,.3)' }} />
</div>
);
};
/** Written on the table where the card sat. It's the first thing the low point says with the sound off. */
const WasHere: React.FC = () => {
const t = useT();
const at = E('why') - 0.15;
if (t < at || t > E('he') + 0.05) return null;
const k = ramp(t, at, at + 0.5);
return <div style={{ position: 'absolute', left: 150, width: 710, top: 610, textAlign: 'center', font: `700 96px/1 ${HAND}`, color: CREMA, whiteSpace: 'nowrap', clipPath: `inset(-20% ${k >= 1 ? '-20%' : `${(1 - k) * 100}%`} -20% -5%)`, transform: 'rotate(-2deg)' }}>{WAS_HERE}</div>;
};
const Can: React.FC<{ rest: number; at: number; creep: number; tilt: number }> = ({ rest, at, creep, tilt }) => {
const t = useT();
const b = E('back');
if (t < at || t > b + 0.6) return null;
const x = lerp(1260, rest, easeOut(ramp(t, at, at + 0.62))) - creep * ramp(t, at + 0.62, b - 0.3) + 760 * easeIn(ramp(t, b - 0.18, b + 0.55));
const W = 150, H = 340, roll = (x - rest) * 1.1;
const shade = 'linear-gradient(90deg,rgba(0,0,0,.66),rgba(0,0,0,.08) 28%,rgba(255,255,255,.34) 40%,rgba(0,0,0,.04) 56%,rgba(0,0,0,.7))';
return (
<div style={{ position: 'absolute', zIndex: 30, left: x - W / 2, top: 1090 - H / 2, width: W, height: H, transform: `rotate(${tilt}deg)`, borderRadius: 24, overflow: 'hidden', background: '#99a2a3' }}>
<div style={{ position: 'absolute', left: 0, right: 0, top: 26, bottom: 26, background: '#a4bb31', overflow: 'hidden' }}>
{[0, 1].map((i) => <div key={i} style={{ position: 'absolute', left: 14 + ((roll % 420) + 420) % 420 - i * 420, top: 0, width: 120, height: 288, writingMode: 'vertical-rl', textAlign: 'center', font: `900 ${Math.min(66, 280 / (CAN[0].length * 0.72))}px/72px ${SERIF}`, color: '#14181a' }}>{CAN[0]}<div style={{ font: `700 21px/30px ${MONO}`, display: 'inline-block' }}>{CAN[1]}</div></div>)}
</div>
<div style={{ position: 'absolute', inset: 0, background: shade }} />
{[10, H - 22].map((y) => <div key={y} style={{ position: 'absolute', left: 0, right: 0, top: y, height: 5, background: 'rgba(0,0,0,.4)' }} />)}
</div>
);
};
const Bubble: React.FC<{ at: number; text: string }> = ({ at, text }) => {
const t = useT();
if (t < at) return null;
const k = back(ramp(t, at, at + 0.16));
return <div style={{ background: CREMA, color: ESPRESSO, borderRadius: 18, padding: '16px 20px', font: `700 44px/52px ${MONO}`, marginTop: 22, transformOrigin: '0 100%', transform: `scale(${lerp(0.7, 1, k)})` }}>{text}</div>;
};
/** A plain phone with no real system's look. It lights the dark table, so it draws above the lamp. */
const Phone: React.FC = () => {
const t = useT();
const a = E('he'), b = E('back');
if (t < a - 0.12 || t > b) return null;
const inK = easeOut(ramp(t, a - 0.12, a + 0.22)), outK = easeIn(ramp(t, b - 0.5, b - 0.12));
const buzz = Math.sin(t * 73) * 4 * (1 - ramp(t, a + 0.1, a + 0.6));
return (
<div style={{ position: 'absolute', left: 196 + buzz, top: lerp(1560, 318, inK) + outK * 1200, width: 410, height: 690, borderRadius: 46, background: '#0d1213', boxShadow: '0 0 0 4px #4a5557', padding: 22, transform: 'rotate(-2deg)' }}>
<div style={{ height: '100%', borderRadius: 28, background: 'linear-gradient(#26323a,#141b1f)', padding: '30px 22px', boxSizing: 'border-box' }}>
<div style={{ font: `900 50px/1 ${SERIF}`, color: AMBER }}>{TEXTS.from}</div>
<Bubble at={E('drink') - 0.25} text={TEXTS.one} />
<Bubble at={E('tradedback')} text={TEXTS.two} />
</div>
</div>
);
};
/* Dust in the lamp, only worth seeing once the room goes quiet. */
const MOTES = Array.from({ length: 30 }, (_, i) => ({ x: hash(i + 2) * 760 + 130, y: hash(i + 40) * 1000 + 240, sp: 6 + hash(i + 80) * 12, r: 2 + hash(i + 120) * 3, ph: hash(i + 160) * 6.28 }));
const Motes: React.FC = () => {
const t = useT();
const a = 1 - lampAt(t);
if (a < 0.2) return null;
return <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0 }}>{MOTES.map((m, i) => <circle key={i} cx={m.x + Math.sin(t * 0.4 + m.ph) * 26} cy={m.y - ((t * m.sp) % 80)} r={m.r} fill="#efe6d0" opacity={a * (0.18 + 0.3 * Math.abs(Math.sin(t * 0.5 + m.ph)))} />)}</svg>;
};
const Light: React.FC = () => {
const t = useT();
const L = lampAt(t);
const cx = 505 + 16 * Math.sin(t * 0.55), cy = 730 + 9 * Math.cos(t * 0.41);
const rx = lerp(470, 980, L), ry = lerp(700, 1320, L), dark = lerp(0.9, 0.42, L);
return <AbsoluteFill style={{ background: `radial-gradient(ellipse ${rx}px ${ry}px at ${cx}px ${cy}px, rgba(255,226,170,${0.1 * L}) 0%, rgba(3,14,18,${dark * 0.1}) 60%, rgba(3,14,18,${dark}) 100%)` }} />;
};
/** One soft bloom on the flip and one on the grade. A single pulse each, nowhere near a strobe. */
const Bloom: React.FC = () => {
const t = useT();
const pulse = (e: number, m: number) => (t < e - 0.06 ? 0 : m * ramp(t, e - 0.06, e + 0.02) * (1 - ramp(t, e + 0.05, e + 0.6)));
const v = pulse(E('flip') + 0.22, 0.75) + pulse(E('back'), 0.4) + pulse(E('gem'), 0.6);
return v > 0.003 ? <AbsoluteFill style={{ background: `radial-gradient(circle 900px at ${HOME.x}px ${HOME.y}px, rgba(255,246,225,${v}), rgba(255,246,225,0) 100%)` }} /> : null;
};
/* ---------- the slab: clear shell, kitchen order ticket at the BOTTOM, a rolling wheel for the grade. Never a top label in the style of a real grader. ---------- */
const SW = 684, SH = 1006, SC = 34;
const SLAB_SHAPE = `polygon(${SC}px 0,${SW - SC}px 0,${SW}px ${SC}px,${SW}px ${SH - SC}px,${SW - SC}px ${SH}px,${SC}px ${SH}px,0 ${SH - SC}px,0 ${SC}px)`;
const SLAB_LINE = (i: number) => `${SC + i * 0.4},${i} ${SW - SC - i * 0.4},${i} ${SW - i},${SC + i * 0.4} ${SW - i},${SH - SC - i * 0.4} ${SW - SC - i * 0.4},${SH - i} ${SC + i * 0.4},${SH - i} ${i},${SH - SC - i * 0.4} ${i},${SC + i * 0.4}`;
const Wheel: React.FC = () => {
const t = useT();
const a = E('slab') + 0.6, g = E('gem');
const val = 1 + 9 * easeOut(ramp(t, a, g));
const landed = t >= g;
const pop = landed ? 1 - ramp(t, g, g + 0.3) : 0;
return (
<div style={{ position: 'absolute', left: 18, top: 12, width: 142, height: 120, overflow: 'hidden', background: landed ? ESPRESSO : '#2a211b', boxShadow: `inset 0 0 0 4px ${landed ? sample(t * 0.3) : '#6e6455'}` }}>
{Array.from({ length: 10 }, (_, i) => <div key={i} style={{ position: 'absolute', left: 0, width: 142, height: 120, top: (i + 1 - val) * 120, textAlign: 'center', font: `900 104px/118px ${SERIF}`, color: landed ? CREMA : AMBER, transform: i === 9 ? `scale(${1 + 0.16 * pop})` : undefined }}>{i + 1}</div>)}
</div>
);
};
const Ticket: React.FC = () => {
const t = useT();
const c = easeInOut(ramp(t, E('cta'), E('cta') + 0.32));
const kw = Math.min(128, 430 / (KEYWORD.length * 0.72));
const warm = t >= E('warm');
return (
<div style={{ position: 'absolute', left: 22, right: 22, bottom: 20, height: 144, background: 'linear-gradient(#f4ebd6,#e7dcc2)', color: ESPRESSO, overflow: 'hidden', backgroundImage: 'radial-gradient(circle at 5px 8px,rgba(0,0,0,.28) 3px,transparent 3.5px),linear-gradient(#f4ebd6,#e7dcc2)', backgroundSize: '100% 18px,100% 100%' }}>
<Wheel />
<div style={{ position: 'absolute', left: 176, right: 8, top: 0, height: 144, overflow: 'hidden' }}>
<div style={{ position: 'absolute', left: 0, right: 0, top: -144 * c + 13, font: `700 24px/29px ${MONO}`, whiteSpace: 'nowrap' }}>
{NOTES.map((n) => <div key={n}>{n}</div>)}
<div style={{ color: OX, visibility: warm ? 'visible' : 'hidden' }}>{WARM_NOTE}</div>
</div>
<div style={{ position: 'absolute', left: 0, right: 0, top: 144 * (1 - c) + 4, whiteSpace: 'nowrap', lineHeight: 1 }}>
<div style={{ font: `700 32px/34px ${MONO}`, letterSpacing: '.12em' }}>COMMENT</div>
<div style={{ font: `900 ${kw}px/${kw * 0.82}px ${SERIF}`, color: OX, letterSpacing: '-.01em' }}>{KEYWORD}</div>
</div>
</div>
</div>
);
};
const Slab: React.FC = () => {
const t = useT();
const a = E('slab');
if (t < a) return null;
const drop = easeIn(ramp(t, a + 0.05, a + 0.42)), weld = ramp(t, a + 0.4, a + 1.1);
const idle = ramp(t, a + 0.5, a + 1.2) * 0.4;
const sheen = sheenAt(t) * 150 - 25;
const stroke = 'rgba(232,246,246,.72)';
return (
<div style={{ position: 'absolute', zIndex: 22, left: HOME.x - SW / 2, top: 780 - SH / 2, width: SW, height: SH, opacity: ramp(t, a + 0.03, a + 0.2), transform: `perspective(2400px) rotateX(${idle * 3 * Math.cos(t * 0.7)}deg) rotateY(${idle * 5 * Math.sin(t * 0.9)}deg) scale(${lerp(1.4, 1, drop)})` }}>
<div style={{ position: 'absolute', inset: 0, clipPath: SLAB_SHAPE, background: `linear-gradient(118deg,rgba(225,245,245,.1) 0 22%,rgba(235,250,250,.2) 26%,rgba(225,245,245,.07) 31% 64%,rgba(235,250,250,.17) 68%,rgba(225,245,245,.08) 72%)` }} />
<div style={{ position: 'absolute', inset: 0, clipPath: SLAB_SHAPE, backgroundImage: `linear-gradient(115deg,rgba(255,250,235,0) ${sheen - 13}%,rgba(255,250,235,.3) ${sheen}%,rgba(255,250,235,0) ${sheen + 13}%)` }} />
<svg viewBox={`0 0 ${SW} ${SH}`} width={SW} height={SH} style={{ position: 'absolute', left: 0, top: 0 }}>
<polygon points={SLAB_LINE(3)} fill="none" stroke={stroke} strokeWidth="5" />
<polygon points={SLAB_LINE(16)} fill="none" stroke="rgba(232,246,246,.26)" strokeWidth="3" />
<polygon points={SLAB_LINE(9)} fill="none" stroke={sample(t * 0.3)} strokeWidth="5" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - weld} opacity={weld >= 1 ? 1 - ramp(t, a + 1.1, a + 1.6) : 1} />
</svg>
<Ticket />
</div>
);
};
/** Steam on "still warm". It rises through the shell and off the top of the frame. */
const Wisps: React.FC = () => {
const t = useT();
const a = ramp(t, E('warm') - 0.1, E('warm') + 0.4);
if (a <= 0) return null;
return (
<svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', zIndex: 23, left: 0, top: 0 }}>
{[0, 1, 2, 3].map((i) => {
const x0 = 440 + i * 56;
return <path key={i} d={`M${x0} 560 ` + Array.from({ length: 11 }, (_, k) => `L${(x0 + Math.sin(t * 1.15 + k * 0.7 + i * 1.9) * (10 + k * 5.5)).toFixed(1)} ${560 - (k + 1) * 58}`).join(' ')} fill="none" stroke="#fff8ea" strokeWidth={26 - i * 3} strokeLinecap="round" strokeLinejoin="round" opacity={a * 0.16} />;
})}
</svg>
);
};
const Sticker: React.FC = () => {
const t = useT();
const at = E('sale') - 0.1;
if (t < at) return null;
const k = easeIn(ramp(t, at, at + 0.1));
return (
<div style={{ position: 'absolute', zIndex: 24, left: 690, top: 298, width: 230, height: 230, borderRadius: '50%', background: '#e04a2f', boxShadow: '0 0 0 7px #f4e6cb,6px 12px 0 7px rgba(0,8,10,.35)', transform: `rotate(9deg) scale(${lerp(2.1, 1, k)})`, textAlign: 'center', color: CREMA, font: `700 68px/64px ${HAND}`, paddingTop: 50, boxSizing: 'border-box' }}>{STICKER.map((s) => <div key={s}>{s}</div>)}</div>
);
};
/* ---------- captions: printed on a rubber mat, in one fixed place ---------- */
const PER_ROW = 19;
const say = (w: string, t0: number, d: number): Chunk => { const x: Word = { w, t0, t1: t0 + d, scene: '' }; return { t0, rows: [{ s: w, map: Array.from(w, () => 0) }], words: [x] }; };
const CHUNKS = [...buildChunks(PER_ROW, 2), say(QUIET.before, E('slab') + 0.3, 1.5), say(QUIET.after, E('gem'), 0.9)].sort((a, b) => a.t0 - b.t0);
const Captions: React.FC = () => {
const t = useT();
const foil = foilAt(t);
const idx = CHUNKS.reduce((a, c, i) => (t >= c.t0 - 0.05 ? i : a), -1);
const c = idx >= 0 ? CHUNKS[idx] : null;
const live = c && (idx === CHUNKS.length - 1 || t <= c.words[c.words.length - 1].t1 + 0.95);
const big = c?.words[0].w === QUIET.after;
let wi = -1;
const hotTone = t < E('flip') ? AMBER : foil < 0.35 ? DEAD : null;
return (
<div style={{ position: 'absolute', left: 44, top: 1292, width: 924, height: 188, clipPath: 'polygon(24px 0,900px 0,924px 24px,924px 164px,900px 188px,24px 188px,0 164px,0 24px)', background: 'linear-gradient(#0f1a1b,#091213)' }}>
<svg viewBox="0 0 924 188" width={924} height={188} style={{ position: 'absolute', left: 0, top: 0 }}>
{Array.from({ length: 76 }, (_, i) => <rect key={i} x={26 + i * 11.6} y="0" width="2" height={i % 5 ? 8 : 15} fill={foil > 0.05 ? sample(i * 0.013 + t * 0.12) : '#55686a'} opacity={0.5 + 0.5 * foil} />)}
</svg>
{live && c ? (
<div style={{ position: 'absolute', left: 16, right: 16, top: big ? 8 : c.rows.length > 1 ? 18 : 40, textAlign: 'center', font: big ? `800 170px/170px ${CAP}` : c.rows.length > 1 ? `800 86px/78px ${CAP}` : `800 106px/106px ${CAP}`, color: CREMA, letterSpacing: '.005em', whiteSpace: 'nowrap' }}>
{c.rows.map((row, ri) => (
<div key={ri}>
{row.s.split(' ').map((word, k) => {
wi += 1;
const w = c.words[wi];
const on = (t >= w.t0 - 0.03 && t < w.t1 + 0.08) || (t >= E('keyword') && word === KEYWORD) || big;
const fill: React.CSSProperties = !on ? {} : hotTone ? { color: hotTone } : { backgroundImage: `linear-gradient(90deg,${sample(t * 0.35)},${sample(t * 0.35 + 0.28)})`, WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' };
return <span key={k} style={{ display: 'inline-block', marginRight: k < row.s.split(' ').length - 1 ? '.27em' : 0, transform: on && t < w.t0 + 0.09 ? 'scale(1.06)' : undefined, ...fill }}>{word}</span>;
})}
</div>
))}
</div>
) : null}
</div>
);
};
/* ---------- the film ---------- */
export const RarePull: React.FC = () => (
<Clock>
<Fonts faces={FACES}>
<Stage />
</Fonts>
</Clock>
);
const Stage: React.FC = () => {
const t = useT();
const cam = camAt(t);
const camera: React.CSSProperties = { position: 'absolute', left: 0, top: 0, width: 1080, height: 1920, transformOrigin: '0 0', transform: `translate(${cam.tx}px, ${cam.ty}px) scale(${cam.s})` };
return (
<AbsoluteFill style={{ background: DEEP, overflow: 'hidden' }}>
<div style={camera}>
<Table />
<Caustics />
<Props />
<Rays />
<WasHere />
<HeroCard />
<Sleeve />
<Common c={DECAF} i={1} flip={E('common2')} fly={E('prints')} art={<DecafArt />} rest={[890, 130, 15]} />
<Common c={INSTANT} i={0} flip={E('common1')} fly={E('whatever')} art={<JarArt />} rest={[872, 150, -8]} />
<Pack />
<Can rest={700} at={E('two') - 0.1} creep={10} tilt={-5} />
<Can rest={860} at={E('energy') + 0.1} creep={46} tilt={4} />
<Slab />
<Wisps />
<Sticker />
</div>
<Light />
<Motes />
<Bloom />
<div style={camera}><Phone /></div>
<Captions />
</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 pack-opener at dawn, whispering so the house stays asleep, close to the mic and breathy, and barely holding it together. Fast and bored on the commons, cracking on "No way", reverent on the stats, then small and honest on "Why." Think of someone who has done this every morning for years and still means it.
**When to use it:** on every line except the grade. Keep it dry and very close, with no room on it and a little warmth around 200 Hz. Whispered takes run quiet and hissy, so compress them gently and tame the top before you raise them. Pick a voice that keeps its consonants in a whisper. If "pull" blurs in the call to action, commas around it help ("Comment, pull, and...").
- **Never clone or imitate a real person.** Audition 3 or 4 voices on two lines of the script and let the user pick, or use the user's own voice.
- **One take per line,** with word timestamps. Pass the neighboring lines as context if your provider allows it, so the delivery flows. Trim each take to its speech, and tighten any pause inside it longer than 0.5 s.
- **Listen for your call to action.** Keywords blur easily. Commas around one in the spoken text ("Comment, gate, and...") usually fix it. Check the final mix with Whisper, line by line.
- **With no voice.** Keep the estimated timings as the clock. If there's music, put the captions on its grid. Raise the effects and the music: they carry the film now. Make sure the words stay on screen long enough to read, about 0.3 s a word. The estimate runs close to that, so add `tail` after a line that feels rushed.
## 9. Sound
The picture tells the story without sound, so a film can ship silent. Sound is what makes each change land, though, so add it unless the user says not to. This section says what to use and when it plays. Add it after the user approves the animatic.
**Effects: make a set for this world.** Generate them with the sound-effects tool the user chose, or pick from a licensed library. Prompt the thing itself, and keep it warm, never bright or harsh.
| Sound | Length | Prompt idea | When it plays |
|-------|--------|-------------|---------------|
| foil crackle | 2 s | a sealed foil pouch handled gently, soft crinkles, close and dry | frame 0, as the lead into the first line |
| pack rip | 0.8 s | the top of a foil pouch torn off in one quick pull, a bright crisp rip kept warm, close | `rip` |
| card flip | 0.4 s | a stiff playing card flipped over onto a table, one soft snap | `common1`, `common2` |
| card flick | 0.5 s | a card flicked away with a finger, a short papery whoosh, then a soft landing on a stack | `whatever`, `prints` |
| kitchen room | 12 s | a quiet kitchen before dawn, a faint fridge in the corner, one far bird, no voices, no music | the bed, all the way through |
| fridge hum | 10 s | a refrigerator compressor humming low and steady in a silent kitchen, slightly uneven, no rattle | rises from `traded`, loudest at `why`, gone by `back` |
| clock tick | 0.5 s | one tick of a small mechanical table clock, dry wooden click | once a second through the low point, on the clock's own seconds |
| tell shimmer | 2.5 s | a low soft shimmer rising slowly, glassy and warm, like breath on a wine glass, no bell | from `what`, peaking at `flip` |
| foil sweep | 1.2 s | a smooth airy shimmer passing left to right, soft and warm, a gentle swell with no sharp top | `flip`, `back`, `gem` |
| sleeve | 0.6 s | a card sliding into a thin plastic sleeve, one soft slick rustle | just after `legendary` |
| stat tick | 0.6 s | a small mechanical counter clicking quickly upward, dry and tiny | under each count, from `warmth` and from `focus` |
| stat land | 0.4 s | one soft wooden knock on a tabletop, warm and short | `ninety`, `seventy`, and louder on `yes` |
| seal write | 1 s | a soft stylus scratching quickly on thin card, dry | as the ring rewrites, at `special` and `makes` |
| cans | 1.5 s | an empty aluminum can rolling slowly across a laminate table, then stopping | `two`, `energy` |
| phone buzz | 0.8 s | a phone vibrating twice on a wooden table, low and muted | `he`, and once more at `tradedback` |
| card return | 0.8 s | a card spinning through the air, a quick soft flutter, landing with a slick slide into plastic | ends on `back` |
| shell press | 0.7 s | a thick clear plastic case clicked shut under firm pressure, a deep solid click | 0.4 s after `slab` |
| wheel | 1.6 s | a mechanical number wheel ratcheting upward and slowing down, dry clicks getting further apart | from `slab` to `gem` |
| wheel land | 0.8 s | one heavy mechanical clunk as a counter locks into place, warm and dark | `gem`, the loudest thing in the film |
| steam | 2 s | a soft quiet breath of steam from a hot cup, very gentle, no hiss | `warm` |
| sticker | 0.4 s | a thick paper sticker slapped flat onto hard plastic, one dull smack | `sale` |
| ticket | 0.5 s | a paper ticket pulled through a small printer, a short soft zip | `cta` |
- **Place a sound on every visible change,** at the same event the picture uses, so a re-voice moves both.
- **Beds run under everything:** a world without room tone feels empty, and room tone is what keeps the low point alive.
- **The same sound twice within 80 ms:** keep the louder one.
- **Warm, never bright:** low-pass effects to about 1.5-3.5 kHz if they bite.
**Music: one instrumental bed.** Describe the sound, never an artist. Generators ignore requested timestamps, so fit the track instead: find its real downbeats from bar-to-bar energy jumps, pick the bar where it lifts, place that bar on the story's turn, and stretch (keeping the pitch, within about 4 %) so a second downbeat lands on the voiceless moment.
**The brief:** "Hushed bedroom electronic instrumental: soft muted synth plucks, a warm round sub pulse, a tape-worn electric piano, very quiet and wide awake for a long stretch, then a bright glassy lift with soft drums that opens up fully, ending warm and resolved, no vocals."
**When it plays:** low under the first lines, and thinning to almost nothing on the tell so the shimmer can rise. It opens on the word "Morning" of the pull, on a bar where the track lifts, and stays bright through the stats and the ability. It drains to nothing over 0.7 s from "traded", stays out so only the fridge hum and the clock remain, and comes back on the word "back". It's at its fullest on the voiceless grade, with a downbeat on the wheel's landing, then ducks for the button and sits low under the call to action.
**Mix, voice first.**
- **Voice:** compress lightly so consonants stay off the limiter, about -14 LUFS.
- **Music:** about 16 to 20 LU under the voice while it speaks, lifted in gaps and on the voiceless moment.
- **Effects:** 7 to 10 LU under the voice overall, ducked about 6 dB under speech, so nothing masks a word.
- **Master:** -14 LUFS integrated, sample-peak ceiling about -2.4 dBFS. AAC encoding adds about 0.8 dB, so check the true peak of the final file. Give the master a 10 ms fade-in, or every view opens on a click.
## 10. Render and QA
1. **Render** without audio. Mux your mix onto it, trimmed to the exact length (`-t`, never `-shortest`), as H.264 at high quality with AAC 320k and `+faststart`.
2. **Sync and length:** the frame count matches the clock, and a cross-correlation of the muxed audio against your mix shows an offset of 0 ms.
3. **Contact sheets:** one frame per second with the safe box drawn. Look at every sheet: nothing cut off, nothing important outside the box, no empty or black frames, and the time of day and the story still agree.
4. **Determinism:** render about 10 spread frames twice with `npx remotion still src/index.ts Film-9x16 out/a.png --frame=N`, and compare each pair with `cmp`; they should match.
5. **Loudness:** -14 LUFS integrated, true peak at or below -1 dBTP.
6. **Words:** transcribe the mix line by line with Whisper. A difference on a plain word is a masked or swallowed word.
7. **Gaps and stills:** no voice gap over 1.0 s outside the voiceless moment (`clock.mjs` warns about these), and no still stretch over 1.2 s or black stretch (`ffmpeg -i out/silent.mp4 -vf "freezedetect=n=0.001:d=1.2,blackdetect=d=0.1" -an -f null -` prints any). A small moving corner can hide a freeze from that check, so trust the contact sheets over it.
For a silent film, skip the checks that need audio (the offset in 2, and 5 and 6) and use `out/silent.mp4` as the final file.
## 11. Traps
- **It starts looking like a famous game:** every "small" addition pulls that way: a number in the corner, a row with symbols, a star. Check the finished card against the card rule above before you show the user anything.
- **Rainbow everywhere:** foil reads as foil only against a dark card and a dull one before it. Keep the color in thin lines and small type, and keep the commons flat.
- **Flashing:** the blooms on the flip, the return and the grade are one pulse each, and the sheen makes at most one pass a second. Don't loop them. Stay under 3 flashes a second, and check with your own eyes as well as any tool.
- **The flip goes flat or wrong:** `Slip` swaps faces at 90 degrees on purpose. A `clip-path` or `overflow` on a parent breaks `preserve-3d`, so don't rebuild it that way.
- **The stack pokes out of the pack:** the cards are taller than the pouch, so the stack starts at 0.8 scale inside it. Keep that if you resize either one.
- **A black low point:** the lamp shrinks to a pool and never goes out. The sleeve, the dust and the clock's hand keep moving.
- **A camera push hides the stats:** the mat covers anything below y = 1292. Check `KEYS` against the contact sheet whenever you move a stat.
- **Grainy lines on a small tile:** the foil's lines look noisy at contact-sheet size. Judge the foil from a full-size still.
- **Captions and the phone say the same thing:** that's fine here, because the phone is the proof. Keep its messages shorter than the line.
| 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 rare-pull 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 rare-pull skill to make a vertical video for [PLATFORM: Instagram Reels / TikTok / YouTube Shorts]. - **The pull:** [TOPIC - the everyday thing that turns out legendary, e.g. "my morning coffee", "my dog", "our product's best feature"] - **The commons:** [COMMONS - the two ordinary versions that fly off the table, or "you write them"] - **The three stats:** [STATS - three funny true measures, one of them not a number, e.g. "warmth, focus, aftertaste: yes", or "you write them"] - **The special move:** [MOVE - what it really does for you, or "you write it"] - **The mistake and the way back:** [TRADE - what you swapped it for, and why it came back, or "you write it"] - **In the seal:** [WINDOW - "no person: pictures drawn in code", or "my own clip or photos, attached"] - **Length:** [LENGTH - e.g. about 35 seconds] - **Voice:** [VOICE - e.g. "a hushed, breathy pack-opener", "my own voice", or "no voice: captions and sound only"] - **Music:** [MUSIC - e.g. "the skill's hushed electronic 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 games or brands named, no amounts on screen, no claims I can't back up] Work in this order: 1. Write the script on the skill's moves, with one line for each turn and what happens on the table. 2. Build the plan, the clock and the frame, keeping the skill's own card design, 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 skillsBook of Hours
An illuminated page gets you through your worst day, hour by hour.
Frame · 9:16 · 30-40 s
Departures
A split-flap board where every turn in your story is a status change.
Frame · 9:16 · 30-40 s
Call Now
A shopping channel sells your thing live, until the demo goes wrong.
Frame · 9:16 · 30-40 s