Jackpot
An old fairground machine fills the screen, and your unfinished thing is the game it's running. The reels land on each beat, the bulbs die on the low point, then CHANCE rolls to CHOICE and the lever becomes a play button. It's a plain metaphor that plays for evenings, never for cash, and the skill refuses gambling promotion. 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
What you never finish The small move What the ticket says Platform Narrator Ending AI toolsUse the jackpot skill to make a vertical video for Instagram Reels. - **The thing people start and never finish:** [TOPIC - e.g. "that online course", "the novel in a drawer", "learning guitar"] - **What the machine counts:** [COUNTER - evenings, coins or your topic's own unit of time, never anything of cash value, or "you choose"] - **The small move:** [MOVE - the smallest honest step, e.g. "twenty minutes, press play", or "you write it"] - **What the ticket says:** [TICKET - the next small step, e.g. "lesson four, tomorrow", or "you write it"] - **In the window:** [WINDOW - "no person: the wind-up student drawn in code", or "my own clip, attached"] - **Length:** [LENGTH - e.g. about 35 seconds] - **Voice:** [VOICE - e.g. "a warm fairground emcee", "my own voice", or "no voice: captions and sound only"] - **Music:** [MUSIC - e.g. "the skill's fairground band 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 people or brands, no claims I can't back up, and always no real casinos, games, odds or winnings] Work in this order: 1. Write the script on the skill's moves, with one line for each move, what lands on the reels and what the case shows. Pick the two words for the plate first, the same length and sharing letters, then build the story toward them. 2. Build the plan, the clock and the frame, render a silent animatic, and show me the script, the scene sheet and the animatic. Wait for my OK. 3. Generate the voice, music and sound effects with the AI tools I named above. If I left that open, tell me which tools you'll use and wait for my OK. 4. Place a sound on every change, fit the music to the turn, mix voice first, render, and run every QA gate in the skill. 5. Give me the MP4, a cover frame and a ready-to-paste caption.
Anything left in [brackets] is your agent's call.
Copy prompt -
Paste it and approve the storyboard
Your agent writes the script, builds the machine 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: jackpot
description: Make a 30-40 second vertical video where an old fairground machine tells the story of something people start and never finish - the lever pulls, the reels land on each beat, the bulbs die on the low point, and one word on the machine changes for good. It plays for evenings, never for cash - a plain metaphor that refuses real-cash or gambling promotion. The skill covers the machine's sound design, music fitted to the story and an optional voice like a fairground emcee. Use for habits, courses, side projects and promises to yourself, 'what are the odds you finish that' stories, or when asked for a slot machine or carnival video about a goal, or when given the jackpot prompt.
---
# Jackpot
An old fairground machine fills the screen, and your unfinished thing is the game it's running. A coin goes in and an evening is gone. The reels land on lesson one, then on a small sad quiz, the bonus wheel sends you to start another course, and the bulbs die one by one. Then the plate on the front rolls two letters, CHANCE becomes CHOICE, and the lever turns into a play button. It's a plain metaphor about follow-through: it plays for evenings, never for cash, and this skill refuses real-cash or gambling promotion.
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/jackpot/SKILL.md`; if a skill with that name already exists, ask the user before replacing it. Keep the user's filled-in prompt next to it as `PROMPT.md`.
## 1. Tools and setup
**You need a code environment** with Node 20+, ffmpeg and Python 3 (numpy, soundfile, pyloudnorm). If the film has a voice, you also need Whisper (any build, such as faster-whisper) for the word check in step 10 and to align a user's own recording. Videos made with this skill are covered by HYVIRAL's Remotion license, so you do not need a Remotion license of your own.
**The project** (Remotion 4.x, React, rendered to MP4): run `npm init -y`, then install `remotion`, `@remotion/cli`, `react` and `react-dom`, plus `@fontsource/bungee`, `@fontsource/bungee-shade` and `@fontsource/vt323`. 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/Jackpot.tsx this frame (step 7)
```
Render without audio, then mux your own mix (step 10): `npx remotion render src/index.ts Film-9x16 out/silent.mp4 --muted`. Set TEMP and TMP to a folder on a roomy drive first; Remotion writes a lot of temporary files.
**Prove the setup before you write anything.** Copy the code and our `plan.json` from step 7, run the clock from step 6, and render the first few seconds (add `--frames=0-90`). Only then start on your own story, at step 5. The font files you need are named in `FACES` at the top of the frame.
**For the voice, images, music and sound effects, use whatever the user prefers.** Every step works the same with any provider. If the user hasn't said, ask which tools they want to use, or use what is already set up.
- **Your own providers.** A text-to-speech that returns word timestamps (for example ElevenLabs with timestamps), a music model or a licensed track, and a sound-effects generator (for example ElevenLabs sound effects) or a licensed sound library. Keep API keys in environment variables, never in the project. No AI media at all is fine too: the picture is code, the narration can be the user's own recording (align its words with Whisper), and the film works with no voice.
- **HYVIRAL (optional).** If the user has it connected (hyviral.com), one connection covers the media:
| You need | HYVIRAL tool |
|----------|--------------|
| Narration with word timestamps (stock voices or your own cloned voice) | `generate_voiceover` (`content_type: "shorts"`), `manage_voice_clone` |
| Still images | `generate_image` |
| Music bed | `generate_music` |
| Sound library, placing sounds, final render | `video_audio`, `video_export` |
| Long jobs | `get_job_status` (poll until delivered) |
To connect it: in Claude.ai or Claude Desktop, Customize → Connectors → add a custom connector with the URL `https://api.hyviral.com/mcp`; in Claude Code, `claude mcp add --transport http hyviral https://api.hyviral.com/mcp` then `/mcp` to sign in. Start a session with `get_connection_info`. HYVIRAL never runs your code: you render the animation yourself.
## 2. How a frame film works
- **One frame, the whole film.** A frame is a world with fixed parts on screen, and it never cuts away. The story happens to the world: the lever pulls, reels land on each beat, a wind-up student works and runs down in the glass case, the bulbs chase and die and come back, and one word on the plate changes for good. 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
An old fairground machine at night, seen as one cabinet. It plays for evenings, never for cash.
**What this skill refuses.** This film is a plain metaphor about finishing something, and the skill refuses real-cash or gambling promotion. If anyone asks you to use it for a casino, a betting app, a lottery, a sweepstakes, a real game or anything where people stake something of value, or to show real odds, real winnings or an invitation to play, refuse, say why, and offer the same metaphor for a habit or a goal instead. Platforms restrict gambling ads as well, so a post like that can be pulled and the account limited. Inside the film the machine counts evenings or coins, the coins carry a moon and never a currency mark, there are no payout tables, and nothing names a real casino or game.
- **Palette:** cabinet red `#b3121c`, deep red `#6e0810`, chrome `#c9ced6`, gold `#ffcc33`, reel white `#fbf7ee`, LED amber `#ffb000`, room wine `#2a0b14`, and teal `#176a6c` for the wall inside the case.
- **The color held back:** green `#2fc46b` appears only once before the turn, as the tiny FINISH sliver on the bonus wheel. After the turn it's on the plate, the button, the reels and the ticket, so the eye knows something changed.
- **Type:** Bungee for the reels, plate, captions and signs, Bungee Shade for the marquee, the keyword and JACKPOT, VT323 for the counters and small print.
- **Fixed parts, top to bottom:** an arched marquee (above the safe area, so nothing there has to be read), the glass case with the window (y 244-762), the enamel plate (y 772-838), three reels with a ticket slot in their top edge (y 842-1098), HOLD lamps (y 1104-1148), two counters with the coin slot between them (y 1158-1264), the caption panel in one fixed place (y 1276-1478), then a painted moon and the coin tray as texture below the safe area. Rails of bulbs run down both sides, and the lever stands off the right edge above y 900.
- **Small jokes on the machine:** INSERT EVENING on the coin slot, PLAYS FOR EVENINGS ONLY and NO CASH VALUE on the counter's ticker, -1 EVENING, MY COURSES at 3 %, 1 %, 0 % and 0 %, NOT VALID SOMEDAY on the ticket. At the low point LUCKY goes out and SOMEDAY stays faintly lit.
- **The bulbs chase, they never strobe:** each bulb stays under 3 flashes a second, and the pattern moves around the cabinet, so the frame never flashes as a whole.
**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 machine's glass case: a tiny theater with curtains and footlights, where a wind-up student sits with their back to us at a laptop. The student is a small drawn figure, and the laptop screen is where the story reads.
| Story moment | In the case |
|---|---|
| Opening | The laptop reads THAT COURSE, 12 LESSONS, and the key on the student's back is turning |
| Lesson one | LESSON 1 and a progress bar. A BEGINNER sign comes down on strings, and one arm goes up |
| Nudges | The bar creeps from 3 % to 5 %, and the student jerks forward on each word |
| The quiz | A sad face on the screen, QUESTION 1 OF 40, and the head droops |
| The bonus wheel | A wheel comes down on strings, slows across the green FINISH sliver and stops just past it. START ANOTHER COURSE drops in front of it |
| The drain | MY COURSES at 3 %, 1 %, 0 %, 0 %. The little window flicks from night to day and back while the tear-off calendar runs from days to months |
| Low point | The case goes dim, the screen reads LESSON 3, LAST OPENED, 14 MONTHS AGO, the key stops, the student slumps asleep, a cobweb grows |
| The move | The student wakes, the screen shows a green play button, and the calendar reads TODAY |
| Jackpot | Both arms up, the bar fills, a check mark lands |
| The ticket | Dawn in the little window, and one arm waving |
| Call to action | A card comes down: COMMENT SPIN. It stays |
The window also works with the user's own clip: play it on the laptop (`Screen`, a 440 x 272 pane) and keep the student in front of it, or fill the case with it and keep the curtains on top.
## 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 runs a small human failure as a showman's game, then shows it was never a game. Ours is an online course.
| # | Move | What it does | Our line | On the machine |
|---|------|--------------|----------|----------------|
| 1 | Step up | The hook: the question, in the emcee's voice | "Step right up! What are the odds you finish that course?" | The lever pulls, the reels land ? ? ?, THAT COURSE on the laptop |
| 2 | Coin in | The first small loss | "Coin in! That's your first evening, gone." | A coin drops into INSERT EVENING. -1 EVENING rises, a moon lamp goes out, the counter ticks from 7 to 6 |
| 3 | Spin | The eager start | "Cherry, cherry, lesson one! We have a beginner!" | Reels land one on each word. BEGINNER comes down |
| 4 | Holds | Tiny progress, with great ceremony | "Hold on lesson one. Nudge. Nudge." | A HOLD lamp lights on lesson 1, the other two reels step a notch on each word |
| 5 | The snag | The first real friction | "Lesson two! Oh, that's a quiz." | Two reels land LESSON 2, the third lands late on a small sad quiz |
| 6 | Bonus | The escape that looks like a prize | "Bonus wheel! It lands on: start another course." | The wheel slows past FINISH. START ANOTHER COURSE. Four courses on the laptop, none past 3 % |
| 7 | Drain | Time goes | "Down to one evening a week. Now one a month." | The camera pushes on the counter, moon lamps go out, the coins drain, the calendar runs to months |
| 8 | Low point | The picture dies, in plain words | "Lesson three. Last opened fourteen months ago." | Bulbs die, LUCKY goes out, the reels stop on blanks, the case dims. Music out; the machine's hum and a slow reel tick keep running |
| 9 | The turn | The secret | "Folks, here's a secret. This one isn't chance." | A pool of light finds the plate. On "chance" two letters roll: CHANCE becomes CHOICE, in green. Bulbs come back. Music returns on "chance" |
| 10 | The move | The smallest honest step | "Twenty minutes. Press play." | Reels land 20, MIN, and an arrow, the counter reads 20:00. The lever's ball flies to the middle of the machine and becomes a green play button |
| 11 | Jackpot | The voiceless moment, about 3 s | (music at its fullest, the caption reads JACKPOT) | Reels land PLAY, PLAY, PLAY. Bulbs chase, coins pour into the tray, the bar fills, a ticket starts to print |
| 12 | The ticket | The payoff, small on purpose | "Your ticket reads: lesson four, tomorrow." | LESSON 4, TOMORROW on the ticket, TOMORROW goes green, the marquee now reads LUCKY TOMORROW |
| 13 | Call to action | One action | "Comment spin and I'll send you the skill that made this." | The case's card reads COMMENT SPIN and stays |
**For other topics:** the novel in a drawer, a language app, a gym habit, a side project, the newsletter you meant to send, learning guitar. Keep it about follow-through, and let the machine count time or effort, never anything of cash value. The trick that makes it land is the plate: no new machine arrives. A word that has been on screen since the first second rolls two letters, CHANCE to CHOICE, so the viewer feels the answer was there all along. Pick a pair of words the same length that share letters. The second thing to protect is how small the payoff is: the jackpot pays out one more evening, and that's the joke and the point. Our film runs 41 s on the clock's estimate. For about 35 s, cut move 4, drop "We have a beginner", and shorten move 7 to "Down to one evening a month". Run the clock after each cut.
## 6. Plan and animatic (before generating anything)
**1. Write `plan.json`.** `text` is what the captions show. Add `say` when the spoken version differs, with the same number of words. Scenes hold the lines. `lead` and `tail` are seconds of room around a scene's words, and a voiceless scene has `sec` instead of `lines`.
```json
{
"lines": [
{ "id": "l01", "text": "First line." },
{ "id": "l02", "text": "Comment TOUR and I'll send it to you.", "say": "Comment, tour, and I'll send it to you." }
],
"scenes": [
{ "id": "s01", "lines": ["l01"], "lead": 0.7 },
{ "id": "s02", "sec": 3.4 },
{ "id": "s03", "lines": ["l02"] }
]
}
```
**2. Run the clock.** Write `scripts/clock.mjs` as below, and run it from the project folder. A line with no take yet gets an estimate, so you can see the whole film before generating anything. A real take goes in `takes/<id>.json` as `{ "dur": seconds, "words": [{ "w": "First", "t0": 0, "t1": 0.3 }] }` and replaces the estimate.
```js
// node scripts/clock.mjs plan.json + takes/<id>.json -> src/spec.json (what the frame renders) and clock.json (where each line plays)
import fs from 'node:fs';
const FPS = 30;
const plan = JSON.parse(fs.readFileSync('plan.json', 'utf8'));
const PAUSE = 0.35, LEAD = 0.05, TAIL = 0.25, TAIL_OUT = 1.2;
// No take yet: an estimate for a quick, clear voice, fitted to 53 real takes. A real take in takes/<id>.json replaces it.
function estimate(text) {
const letters = (s) => (s.match(/[\p{L}\p{N}]/gu) ?? []).length;
const stops = (text.match(/[.?!]+(?=\s)/g) ?? []).length, commas = (text.match(/,/g) ?? []).length;
const dur = 0.29 + 0.059 * letters(text) + 0.53 * stops + 0.07 * commas;
const parts = text.split(/\s+/), total = parts.reduce((n, p) => n + letters(p) + 1, 0);
let at = 0;
const words = parts.map((w) => { const d = (dur * (letters(w) + 1)) / total, x = { w, t0: at, t1: at + d }; at += d; return x; });
return { dur, words };
}
let t = 0.1;
const beats = [], placed = [];
for (const s of plan.scenes) {
const from = Math.round(t * FPS);
let end = t + (s.sec ?? 0);
const words = [];
if (s.lines?.length) {
let c = t + (s.lead ?? LEAD);
s.lines.forEach((id, i) => {
const line = plan.lines.find((l) => l.id === id);
const take = fs.existsSync(`takes/${id}.json`) ? JSON.parse(fs.readFileSync(`takes/${id}.json`, 'utf8')) : estimate(line.say ?? line.text);
// Captions show `text`, even when `say` differs ("Comment, gate"), as long as the word counts match.
const shown = line.text.split(/\s+/);
const same = shown.length === take.words.length;
placed.push({ line: id, t0: c, dur: take.dur, scene: s.id });
take.words.forEach((w, k) => words.push({ w: same ? shown[k] : w.w, f0: Math.round((c + w.t0) * FPS) - from, f1: Math.round((c + w.t1) * FPS) - from }));
c += take.dur + (i < s.lines.length - 1 ? PAUSE : 0);
});
end = c + (s.tail ?? TAIL);
}
const dur = Math.max(1, Math.round(end * FPS) - from);
beats.push({ id: s.id, from, dur, words, p: s.p ?? {} });
t = (from + dur) / FPS;
}
const total = Math.round((t + TAIL_OUT) * FPS);
beats[beats.length - 1].dur = total - beats[beats.length - 1].from;
beats[0].dur += beats[0].from;
beats[0].words.forEach((w) => { w.f0 += beats[0].from; w.f1 += beats[0].from; });
beats[0].from = 0;
for (let i = 1; i < placed.length; i++) {
const gap = placed[i].t0 - (placed[i - 1].t0 + placed[i - 1].dur);
const quiet = beats.some((b) => !b.words.length && b.from / FPS < placed[i].t0 && (b.from + b.dur) / FPS > placed[i - 1].t0);
if (gap > 1.0 && !quiet) console.warn(`voice gap ${gap.toFixed(2)} s before ${placed[i].line}: shorten a lead or tail`);
}
fs.mkdirSync('src', { recursive: true });
fs.writeFileSync('src/spec.json', JSON.stringify({ fps: FPS, durationInFrames: total, beats }, null, 1));
fs.writeFileSync('clock.json', JSON.stringify({ fps: FPS, total: total / FPS, lines: placed }, null, 1));
console.log(`${(total / FPS).toFixed(2)} s, ${beats.length} scenes, ${placed.length} lines`);
```
**3. Write `src/frames/story.json`.** Name every change in the story and pin it to a scene, and to a word in it where you can. The frame reads events by name, and a typo fails the render instead of drifting. Every event is checked when the film loads, even one the frame never reads. Our list keeps some events only so a sound can land on them; you can delete those. A `word` matches the first word in its scene that starts with those letters, so "the" also hits "there", and a repeated word can only be reached with `off` from an earlier one.
**4. Build the frame** with the code in step 7, changing the words and schedules to your story. Keep each schedule in time order.
**5. Render a silent animatic.** Make contact sheets at one frame per second with the safe box drawn, and look at every tile: `ffmpeg -i out/silent.mp4 -vf "select='not(mod(n\,30))',drawbox=x=60:y=250:w=890:h=1230:color=red@0.9:t=6,scale=216:-2,tile=8x3" -fps_mode passthrough sheet_%02d.jpg`. Empty tiles at the end of the last sheet are padding, not black frames. A tile that small hides small errors, so also look at about 6 frames at full size, with the window cropped large (`npx remotion still src/index.ts Film-9x16 out/f.png --frame=N`).
**6. Show the user** the script, the scene sheet (one row per scene: its line, what changes on screen, and its seconds) and the animatic. Wait for their OK.
## 7. Build the frame
These files are the real frame that made our demo, with our demo's story in it. You rendered them in step 1 to prove your setup. Now change the story, using the list below. Leave the machinery alone until the film works, and read the code before you change it. Paper, panels and labels are sized to our demo's words, so check anything longer on the contact sheet.
### What to change for your story
Most of the machine carries over: the cabinet, the bulbs, the lever and the counters barely change. Your work is the words near the top of the file, what lands on the reels and what the laptop shows.
| To change | Where |
|---|---|
| Words on the machine | The block at the top of the file: `KEYWORD`, `QUIET_CAPTION`, `MARQUEE_TOP`, `MARQUEE_LOW`, `MARQUEE_END`, `PLATE_LEAD`, `PLATE_FROM`, `PLATE_TO`, `COURSE`, `BADGE`, `GONE_TAG`, `SCREEN`, `SLOT_LABEL`, `LED_TICKER`, `WHEEL_SLICE`, `WHEEL_SLIVER`, `WHEEL_BANNER`, `TICKET` |
| What lands on the reels | `SPINS`, one row per landing and in time order: `stop` is when each reel lands, `nudge` slides one notch, `lazy` spins slowly. Symbols live in `SYM`, `FILL` is what blurs past, `START` is the first frame |
| What the laptop shows | `Screen`: one view for each stretch of the story, chosen by `lesson1`, `quiz`, `start`, `low` and `twenty`. This is the biggest job |
| The student | `Student` draws it, `POSES` keys the lean, the head and both arms to events, `keyAngle` winds and stops the key |
| The room in the case | `Window`: wall, desk, mug, little window, `STARS`, `WEB`. The hanging signs are the `Flat` calls at the end of it. `dayK` and `dawnK` set the sky |
| The bonus wheel | `Wheel` and `WEDGES`. `R` in `Wheel` is where it stops: 7 sits just past the sliver |
| The calendar | `CAL`: each page and the event that tears it off |
| The counters | `Leds`: `list` is the left counter (label, value, moon lamps lit, and -1 to hide the lamps), `coins` is the right |
| Which events pull the lever | `PULLS`. `Lever` flies the ball to `SLOT` on `press` and presses it on `play` |
| The low point | `lowK`, `drainK` and `spotK` set the light, `PILOT` is the last bulb, `Veil` aims its pool at the plate |
| The camera and the knocks | `KEYS` and `HITS` |
| The coins | `CoinDrop`, `FALL` (the waterfall), `PILE` (the tray) |
| The comment keyword | `plan.json` and `KEYWORD`, which sets the card in `Window` and the check in `Captions` that lights the word once the ending starts |
- **Scene ids in the code:** none. Everything reads events by name, so renumbering scenes only touches `plan.json` and `story.json`.
- **Panels sized to our words.** `fit()` shrinks the keyword, marquee, badge, banner and ticket to fit, so check those on the contact sheet and leave them be. Three things don't shrink: the plate has one 46 px cell per letter of `PLATE_TO`, and `PLATE_FROM` must be the same length; a `Tile` on a reel holds about 10 letters at 36 px and 4 at 78 px; `value` in the left counter holds about 11 characters at 76 px, and the moon lamps only leave room for two.
- **The wheel's labels** fit 10 characters at 25 px between the hub and the rim. A longer slice needs a smaller font.
- **Layers:** `Stage` draws back to front. The marquee sits above the coins, so they pour out from behind it. The ticket sits above the coins, so it stays readable. `Veil` sits under the marquee and the case, which dim themselves.
- **Delete an event with its use.** `story.json` events that no longer match a word stop the render, which is what you want. `on`, `lands` and `keyword` are there only so a sound can land on them.
### The code
`plan.json`, our demo's script:
```json
{
"lines": [
{
"id": "l01",
"text": "Step right up! What are the odds you finish that course?"
},
{
"id": "l02",
"text": "Coin in! That's your first evening, gone."
},
{
"id": "l03",
"text": "Cherry, cherry, lesson one! We have a beginner!"
},
{
"id": "l04",
"text": "Hold on lesson one. Nudge. Nudge."
},
{
"id": "l05",
"text": "Lesson two! Oh, that's a quiz."
},
{
"id": "l06",
"text": "Bonus wheel! It lands on: start another course.",
"say": "Bonus wheel! It lands on, start another course."
},
{
"id": "l07",
"text": "Down to one evening a week. Now one a month."
},
{
"id": "l08",
"text": "Lesson three. Last opened fourteen months ago."
},
{
"id": "l09",
"text": "Folks, here's a secret. This one isn't chance."
},
{
"id": "l10",
"text": "Twenty minutes. Press play."
},
{
"id": "l11",
"text": "Your ticket reads: lesson four, tomorrow.",
"say": "Your ticket reads, lesson four, tomorrow."
},
{
"id": "l12",
"text": "Comment spin and I'll send you the skill that made this.",
"say": "Comment, spin, and I'll send you the skill that made this."
}
],
"scenes": [
{
"id": "s01",
"lines": [
"l01"
],
"lead": 0.2,
"tail": 0.1
},
{
"id": "s02",
"lines": [
"l02"
],
"lead": 0.1,
"tail": 0.1
},
{
"id": "s03",
"lines": [
"l03"
],
"lead": 0.2,
"tail": 0.08
},
{
"id": "s04",
"lines": [
"l04"
],
"lead": 0.08,
"tail": 0.1
},
{
"id": "s05",
"lines": [
"l05"
],
"lead": 0.12,
"tail": 0.08
},
{
"id": "s06",
"lines": [
"l06"
],
"lead": 0.08,
"tail": 0.1
},
{
"id": "s07",
"lines": [
"l07"
],
"lead": 0.08,
"tail": 0.08
},
{
"id": "s08",
"lines": [
"l08"
],
"lead": 0.55,
"tail": 0.12
},
{
"id": "s09",
"lines": [
"l09"
],
"lead": 0.3,
"tail": 0.08
},
{
"id": "s10",
"lines": [
"l10"
],
"lead": 0.08,
"tail": 0.05
},
{
"id": "s11",
"sec": 3.1
},
{
"id": "s12",
"lines": [
"l11"
],
"lead": 0.05,
"tail": 0.1
},
{
"id": "s13",
"lines": [
"l12"
],
"lead": 0.1,
"tail": 0.1
}
]
}
```
`src/index.ts`:
```ts
import { registerRoot } from 'remotion';
import { Root } from './Root';
registerRoot(Root);
```
`src/Root.tsx`:
```tsx
import React from 'react';
import { Composition } from 'remotion';
import { Jackpot } from './frames/Jackpot';
import spec from './spec.json';
export const Root: React.FC = () => (
<Composition id="Film-9x16" component={Jackpot} 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": "pull", "scene": "s01", "word": "Step"},
{"id": "odds", "scene": "s01", "word": "odds"},
{"id": "finish", "scene": "s01", "word": "finish"},
{"id": "course", "scene": "s01", "word": "course"},
{"id": "coin", "scene": "s02", "word": "Coin"},
{"id": "coinin", "scene": "s02", "word": "in"},
{"id": "evening", "scene": "s02", "word": "evening"},
{"id": "gone", "scene": "s02", "word": "gone"},
{"id": "spin", "scene": "s03"},
{"id": "cherry1", "scene": "s03", "word": "Cherry"},
{"id": "cherry2", "scene": "s03", "word": "lesson", "off": -0.4},
{"id": "lesson1", "scene": "s03", "word": "lesson"},
{"id": "beginner", "scene": "s03", "word": "beginner"},
{"id": "hold", "scene": "s04", "word": "Hold"},
{"id": "nudge1", "scene": "s04", "word": "Nudge"},
{"id": "nudge2", "scene": "s04", "word": "Nudge", "off": 0.6},
{"id": "lesson2", "scene": "s05", "word": "Lesson"},
{"id": "oh", "scene": "s05", "word": "Oh"},
{"id": "quiz", "scene": "s05", "word": "quiz"},
{"id": "wheel", "scene": "s06", "word": "Bonus"},
{"id": "lands", "scene": "s06", "word": "lands"},
{"id": "start", "scene": "s06", "word": "start"},
{"id": "another", "scene": "s06", "word": "another"},
{"id": "down", "scene": "s07", "word": "Down"},
{"id": "week", "scene": "s07", "word": "week"},
{"id": "now", "scene": "s07", "word": "Now"},
{"id": "month", "scene": "s07", "word": "month"},
{"id": "low", "scene": "s08"},
{"id": "lesson3", "scene": "s08", "word": "Lesson"},
{"id": "last", "scene": "s08", "word": "Last"},
{"id": "fourteen", "scene": "s08", "word": "fourteen"},
{"id": "ago", "scene": "s08", "word": "ago"},
{"id": "folks", "scene": "s09", "word": "Folks"},
{"id": "secret", "scene": "s09", "word": "secret"},
{"id": "isnt", "scene": "s09", "word": "isn't"},
{"id": "chance", "scene": "s09", "word": "chance"},
{"id": "twenty", "scene": "s10", "word": "Twenty"},
{"id": "press", "scene": "s10", "word": "Press"},
{"id": "play", "scene": "s10", "word": "play"},
{"id": "quiet", "scene": "s11"},
{"id": "win", "scene": "s11", "off": 1.25},
{"id": "printing", "scene": "s11", "off": 2.3},
{"id": "ticket", "scene": "s12", "word": "ticket"},
{"id": "four", "scene": "s12", "word": "lesson"},
{"id": "tomorrow", "scene": "s12", "word": "tomorrow"},
{"id": "cta", "scene": "s13"},
{"id": "keyword", "scene": "s13", "word": "spin"}
] }
```
`src/frames/Jackpot.tsx`:
```tsx
import React from 'react';
import { AbsoluteFill, interpolateColors } 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';
/* Jackpot: an old fairground machine that plays for evenings, never for cash. The reels land on each beat, the lever pulls, the
bulbs chase, and in the glass case on top a wind-up student works through a course. 1080 x 1920. Time comes from the spoken
words through story.json, so a re-voice moves every reel with its word. */
const E = makeE(story as StoryJson);
/* ---------- the words on the machine ---------- */
const KEYWORD = 'SPIN';
const QUIET_CAPTION = 'JACKPOT';
const MARQUEE_TOP = 'LUCKY', MARQUEE_LOW = 'SOMEDAY', MARQUEE_END = 'TOMORROW';
const PLATE_LEAD = 'ALL DOWN TO', PLATE_FROM = 'CHANCE', PLATE_TO = 'CHOICE';
const COURSE = ['THAT', 'COURSE'];
const BADGE = 'BEGINNER', GONE_TAG = '-1 EVENING';
const SCREEN = { count: '12 LESSONS', quiz: 'QUIZ', question: 'QUESTION 1 OF 40', courses: 'MY COURSES', stuck: 'LESSON 3', last: 'LAST OPENED', ago: '14 MONTHS AGO' };
const SLOT_LABEL = ['INSERT', 'EVENING'];
const LED_TICKER = 'PLAYS FOR EVENINGS ONLY * NO CASH VALUE * ';
const WHEEL_SLICE = 'NEW COURSE', WHEEL_SLIVER = 'FINISH', WHEEL_BANNER = 'START ANOTHER COURSE';
const TICKET = { head: 'ADMIT ONE EVENING', a: 'LESSON 4', b: 'TOMORROW', foot: 'NO CASH VALUE * NOT VALID SOMEDAY' };
const RED = '#b3121c', RED_D = '#6e0810', GOLD = '#ffcc33', REEL = '#fbf7ee', LED = '#ffb000', ROOM = '#2a0b14', GREEN = '#2fc46b', INK = '#2b1408';
const BUNGEE = 'Bungee, sans-serif', SHADE = '"Bungee Shade", sans-serif', DOTS = 'VT323, monospace';
const CHROME = 'linear-gradient(180deg,#f4f6f8,#c9ced6 28%,#8a9099 52%,#dfe3e8 74%,#7d838c)';
const FACES = [{ family: 'Bungee', file: 'bungee-latin-400-normal.woff2' }, { family: 'Bungee Shade', file: 'bungee-shade-latin-400-normal.woff2' }, { family: 'VT323', file: 'vt323-latin-400-normal.woff2' }];
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
/** The last value in a time-ordered list whose time has come. */
function at<T>(t: number, list: [number, T][]): T {
let v = list[0][1];
for (const [a, x] of list) if (t >= a) v = x;
return v;
}
/** A font size that keeps a longer word inside its panel. */
const fit = (s: string, w: number, max: number, em = 0.74) => Math.min(max, w / (s.length * em));
/* ---------- the light: one dark low point, one green held back until the turn ---------- */
const lowK = (t: number) => ramp(t, E('low'), E('last') + 0.1) * (1 - ramp(t, E('chance') - 0.02, E('chance') + 0.55));
const drainK = (t: number) => ramp(t, E('down'), E('low') + 0.2) * (1 - ramp(t, E('chance') - 0.02, E('chance') + 0.55));
const spotK = (t: number) => ramp(t, E('secret') - 0.1, E('secret') + 0.3);
const winK = (t: number) => (t >= E('win') && t < E('tomorrow') ? 1 : 0);
const dawnK = (t: number) => ramp(t, E('tomorrow') - 0.1, E('tomorrow') + 0.9);
/* Night in the case's little window. An evening goes on "gone", months blur by on the drain, and dawn comes with the ticket. */
const dayK = (t: number) => {
const g = E('gone');
const gone = ramp(t, g - 0.1, g + 0.25) * (1 - ramp(t, g + 0.3, g + 0.75));
const blur = ramp(t, E('down'), E('down') + 0.3) * (1 - ramp(t, E('low') - 0.15, E('low') + 0.25)) * (0.5 - 0.5 * Math.cos((t - E('down')) * 5.2));
return Math.max(gone, blur);
};
/* The camera stays on the machine. Every push holds the bottom of the counters (y 1268) still, so they never slide under the caption panel. */
type View = { s: number; tx: number; ty: number };
const zoom = (s: number, fx = 504): View => ({ s, tx: fx * (1 - s), ty: 1268 * (1 - s) });
const KEYS: { at: number; d: number; v: View }[] = [
{ at: 0, d: 0.01, v: zoom(1.012) },
{ at: E('spin'), d: 0.5, v: zoom(1.06) },
{ at: E('hold'), d: 0.6, v: zoom(1.12) },
{ at: E('lesson2'), d: 0.5, v: zoom(1.05) },
{ at: E('wheel') - 0.1, d: 0.5, v: zoom(1.03) },
{ at: E('down') - 0.05, d: 0.6, v: zoom(1.22, 150) },
{ at: E('low'), d: 1.4, v: zoom(1.02) },
{ at: E('secret') - 0.2, d: 1.1, v: zoom(1.11) },
{ at: E('twenty') - 0.1, d: 0.5, v: zoom(1.05) },
{ at: E('quiet'), d: 0.8, v: zoom(1.012) },
{ at: E('ticket') - 0.3, d: 0.5, v: zoom(1.12) },
{ at: E('cta') - 0.1, d: 0.6, v: zoom(1.03) },
];
const HITS: [number, number][] = ([['coinin', 7], ['lesson1', 4], ['quiz', 3], ['start', 5], ['chance', 6], ['play', 8], ['win', 12]] as [string, number][]).map(([e, a]) => [E(e), a]);
const shake = (t: number) => HITS.reduce((acc, [e, a]) => (t >= e ? acc + a * Math.sin((t - e) * 58) * Math.exp(-(t - e) * 10) : acc), 0);
function keyed<T extends { [k: string]: number }>(t: number, keys: { at: number; d: number; v: T }[]): T {
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 = Object.fromEntries(Object.keys(v).map((n) => [n, lerp(v[n], k.v[n], q)])) as T;
}
return v;
}
const camAt = (t: number): View => {
const v = keyed(t, KEYS), j = shake(t);
return { s: v.s + 0.004 * Math.sin(t * 0.5), tx: v.tx + 3 * Math.sin(t * 0.37) + j, ty: v.ty + j * 0.5 };
};
/* ---------- the room and the cabinet ---------- */
const BOKEH = Array.from({ length: 26 }, (_, i) => ({ x: 960 + hash(i + 3) * 130, y: hash(i + 31) * 1920, r: 16 + hash(i + 61) * 34, c: ['#ffcc33', '#ff6a4a', '#ffe9b0', '#6fd0c8'][i % 4], p: hash(i + 91) * 6.28 }));
const Room: React.FC = () => {
const t = useT();
return <AbsoluteFill style={{ background: `linear-gradient(90deg,${ROOM} 60%,#3c0f1e)` }}>{BOKEH.map((b, i) => <div key={i} style={{ position: 'absolute', left: b.x - b.r, top: b.y - b.r, width: b.r * 2, height: b.r * 2, borderRadius: '50%', background: b.c, opacity: (1 - 0.8 * lowK(t)) * (0.1 + 0.1 * Math.sin(t * 0.8 + b.p)) }} />)}</AbsoluteFill>;
};
const Cabinet: React.FC = () => (
<>
<div style={{ position: 'absolute', left: 24, top: 70, width: 960, height: 1860, background: `linear-gradient(90deg,${RED_D},${RED} 9%,#d4252f 50%,${RED} 91%,${RED_D})` }} />
<div style={{ position: 'absolute', left: 24, top: 70, width: 8, height: 1860, background: 'linear-gradient(90deg,#8a9099,#eef0f3,#6d737b)' }} />
<div style={{ position: 'absolute', left: 976, top: 70, width: 8, height: 1860, background: 'linear-gradient(90deg,#6d737b,#eef0f3,#8a9099)' }} />
</>
);
/* Below the safe area, only texture: a painted moon, the chute and the tray the coins land in. */
const PILE = Array.from({ length: 64 }, (_, i) => ({ x: 214 + hash(i + 7) * 580, y: 1792 - hash(i + 17) * hash(i + 27) * 96 + (i < 20 ? 30 : 0), f: 0.55 + hash(i + 37) * 0.45 }));
const Belly: React.FC = () => {
const t = useT();
const shown = Math.floor(PILE.length * ramp(t, E('win') + 1.2, E('four') + 0.6));
return (
<>
<div style={{ position: 'absolute', left: 40, top: 1496, width: 928, height: 440, background: `repeating-conic-gradient(from ${t * 1.5}deg at 50% 26%, rgba(255,204,51,.13) 0 6deg, rgba(0,0,0,0) 6deg 18deg)`, boxShadow: `inset 0 0 0 5px ${GOLD}` }} />
<svg viewBox="0 0 60 60" width={150} height={150} style={{ position: 'absolute', left: 429, top: 1520 }}><path d="M36 4 A27 27 0 1 0 36 56 A21 21 0 1 1 36 4 Z" fill={GOLD} /></svg>
<div style={{ position: 'absolute', left: 170, top: 1676, width: 668, height: 26, background: CHROME, borderRadius: 6 }} />
<div style={{ position: 'absolute', left: 190, top: 1702, width: 628, height: 150, background: 'linear-gradient(#1a1214,#2d2226)', borderRadius: '0 0 30px 30px' }} />
{PILE.slice(0, shown).map((c, i) => <Coin key={i} x={c.x} y={c.y} d={62} flip={c.f} />)}
<div style={{ position: 'absolute', left: 170, top: 1812, width: 668, height: 64, background: CHROME, borderRadius: '0 0 34px 34px' }} />
</>
);
};
const Coin: React.FC<{ x: number; y: number; d: number; flip: number; rot?: number }> = ({ x, y, d, flip, rot = 0 }) => (
<div style={{ position: 'absolute', left: x - d / 2, top: y - d / 2, width: d, height: d, borderRadius: '50%', transform: `rotate(${rot}deg) scaleX(${flip})`, background: 'radial-gradient(circle at 35% 30%,#fff6c8,#ffcc33 46%,#c08a10)', boxShadow: `inset 0 0 0 ${d * 0.07}px #d99a10, 0 ${d * 0.05}px 0 #7a5208` }}><div style={{ position: 'absolute', left: '30%', top: '22%', width: '48%', height: '56%', borderRadius: '50%', boxShadow: `inset ${d * 0.09}px 0 0 #a8740c` }} /></div>
);
/* ---------- the bulbs: every one stays under 3 flashes a second, and the pattern moves so the frame never flashes as a whole ---------- */
const ARCH = (k: number): [number, number] => [lerp(72, 936, k), (1 - k) * (1 - k) * 84 + 2 * (1 - k) * k * -6 + k * k * 84];
const BULBS: [number, number][] = [
...Array.from({ length: 33 }, (_, i) => [43, 262 + i * 37.5] as [number, number]),
...Array.from({ length: 33 }, (_, i) => [965, 262 + i * 37.5] as [number, number]),
...Array.from({ length: 29 }, (_, i) => ARCH(i / 28)),
...[110, 150, 190, 228].flatMap((y) => [[44, y], [964, y]] as [number, number][]),
];
const PILOT = 12;
const bulbLevel = (i: number, t: number) => {
const low = lowK(t), dr = drainK(t);
if (i === PILOT && low > 0.6) return hash(Math.floor(t * 1.7)) > 0.25 ? 1 : 0.55;
if (hash(i * 1.37 + 2) <= Math.max(low * 1.02, dr * 0.55)) return 0;
if (winK(t)) return (i + Math.floor(t * 11)) % 4 < 2 ? 1 : 0.1;
return (i + Math.floor(t * 8)) % 3 === 0 ? 1 : 0.14;
};
const Bulbs: React.FC = () => {
const t = useT();
const pilot = BULBS[PILOT];
const low = lowK(t);
return (
<>
{BULBS.map(([x, y], i) => {
const v = bulbLevel(i, t);
return <div key={i} style={{ position: 'absolute', left: x - 11, top: y - 11, width: 22, height: 22, borderRadius: '50%', background: v > 0.5 ? `radial-gradient(circle at 40% 35%,#fffdf0,#ffe58a 55%,#e8a820)` : `radial-gradient(circle at 40% 35%,${v > 0 ? '#a07a2a' : '#54401c'},#2c1c08)`, boxShadow: v > 0.5 ? `0 0 ${10 + 12 * v}px ${2 + 4 * v}px rgba(255,214,110,.72)` : 'inset 0 0 0 2px rgba(0,0,0,.45)' }} />;
})}
{low > 0.5 ? <div style={{ position: 'absolute', left: pilot[0] + Math.cos(t * 3.3) * 34 - 7, top: pilot[1] + Math.sin(t * 4.7) * 26 - 5, width: 14, height: 10, borderRadius: '50% 50% 40% 40%', background: '#d8c9a8', transform: `scaleX(${0.4 + 0.6 * Math.abs(Math.sin(t * 19))})` }} /> : null}
</>
);
};
const Marquee: React.FC = () => {
const t = useT();
const low = lowK(t), dr = drainK(t);
const won = t >= E('win');
const pop = won ? back(ramp(t, E('win'), E('win') + 0.3)) : 1;
const end = t >= E('tomorrow');
const letter = (c: string, i: number, seed: number, dead: boolean) => {
const v = dead ? (hash(i + seed) < low * 1.3 ? 0.14 : 1 - 0.35 * dr) : 1 - 0.5 * low;
return <span key={i} style={{ color: v < 0.2 ? '#6b3a1c' : GOLD, opacity: v < 0.2 ? 0.8 : lerp(0.55, 1, v), filter: `brightness(${lerp(0.5, 1, v)})` }}>{c}</span>;
};
return (
<>
<div style={{ position: 'absolute', left: 10, top: 6, width: 988, height: 236, clipPath: 'path("M0 236 L0 96 Q0 66 40 60 Q494 -46 948 60 Q988 66 988 96 L988 236 Z")', background: CHROME, filter: `brightness(${1 - 0.68 * low})` }} />
<div style={{ position: 'absolute', left: 18, top: 14, width: 972, height: 220, clipPath: 'path("M0 220 L0 90 Q0 62 38 56 Q486 -46 934 56 Q972 62 972 90 L972 220 Z")', background: `repeating-conic-gradient(from ${t * 3 - 90}deg at 50% 120%, #c8161f 0 5deg, #8e0c14 5deg 10deg)`, filter: `brightness(${1 - 0.72 * low})` }} />
<div style={{ position: 'absolute', left: 18, top: 0, width: 972, height: 240, textAlign: 'center', textShadow: '0 5px 0 rgba(50,0,6,.7)', whiteSpace: 'nowrap' }}>
{won && !end ? (
<div style={{ position: 'absolute', left: 0, width: 972, top: 66, font: `${fit(QUIET_CAPTION, 800, 142, 0.86)}px/1 ${SHADE}`, color: GOLD, transform: `scale(${lerp(0.7, 1, pop)})` }}>
{Array.from(QUIET_CAPTION).map((c, i) => <span key={i} style={{ color: (i + Math.floor(t * 5)) % 2 ? REEL : GOLD }}>{c}</span>)}
</div>
) : (
<>
<div style={{ position: 'absolute', left: 0, width: 972, top: 42, font: `${fit(MARQUEE_TOP, 640, 58, 0.86)}px/1 ${SHADE}` }}>{Array.from(MARQUEE_TOP).map((c, i) => letter(c, i, 5, true))}</div>
<div style={{ position: 'absolute', left: 0, width: 972, top: 104, font: `${fit(end ? MARQUEE_END : MARQUEE_LOW, 800, 112, 0.86)}px/1 ${SHADE}` }}>{Array.from(end ? MARQUEE_END : MARQUEE_LOW).map((c, i) => letter(c, i, 15, false))}</div>
</>
)}
</div>
</>
);
};
/* ---------- the glass case: a wind-up student in a small theater ---------- */
const WIN = { w: 860, h: 490 };
type Pose = { lean: number; head: number; armL: number; armR: number };
const WORK: Pose = { lean: 2, head: 4, armL: 10, armR: -58 };
const POSES: { at: number; d: number; v: Pose }[] = [
{ at: 0, d: 0.01, v: WORK },
{ at: E('beginner'), d: 0.2, v: { lean: -2, head: -8, armL: 150, armR: -58 } },
{ at: E('hold'), d: 0.25, v: WORK },
{ at: E('nudge1'), d: 0.12, v: { ...WORK, lean: 6, head: 8 } },
{ at: E('nudge2'), d: 0.12, v: { ...WORK, lean: 9, head: 10 } },
{ at: E('oh'), d: 0.3, v: { lean: 4, head: 20, armL: 8, armR: -40 } },
{ at: E('wheel'), d: 0.3, v: { lean: 1, head: -10, armL: 10, armR: -30 } },
{ at: E('down'), d: 2.4, v: { lean: 12, head: 26, armL: 6, armR: -30 } },
{ at: E('low'), d: 1.4, v: { lean: 26, head: 52, armL: 4, armR: -22 } },
{ at: E('isnt'), d: 0.8, v: { lean: 8, head: 6, armL: 8, armR: -44 } },
{ at: E('twenty'), d: 0.3, v: WORK },
{ at: E('win') - 0.1, d: 0.25, v: { lean: 0, head: -10, armL: 152, armR: -152 } },
{ at: E('ticket'), d: 0.4, v: { lean: 0, head: -4, armL: 150, armR: -60 } },
];
/** The key winds while the student works and runs down at the low point. */
const keyAngle = (t: number) => {
const a = E('low'), b = E('isnt');
if (t < a) return t * 7;
if (t < b) return a * 7 + 3.2 * easeOut(ramp(t, a, E('last') + 0.4));
return a * 7 + 3.2 + (t - b) * 7;
};
const SWEATER = '#e0a93a', SWEATER_D = '#b98222', HAIR = '#3b2417';
const Student: React.FC = () => {
const t = useT();
const p = keyed(t, POSES);
const low = lowK(t);
const alive = 1 - clamp(low * 1.3);
const typing = alive * Math.sin(t * 11) * 4 * (t < E('win') - 0.1 || t > E('ticket') ? 1 : 0);
const bob = alive * Math.sin(t * 5.4) * 1.4;
const kx = Math.cos(keyAngle(t));
const wave = t >= E('ticket') ? Math.sin(t * 7) * 8 : 0;
return (
<svg viewBox={`0 0 ${WIN.w} ${WIN.h}`} width={WIN.w} height={WIN.h} style={{ position: 'absolute', left: 0, top: 0, filter: `brightness(${1 - 0.42 * low})` }}>
<g transform={`translate(190 ${462 + bob}) rotate(${p.lean})`}>
<g transform={`translate(-72 -126) rotate(${p.armL + wave})`}><rect x={-16} y={-8} width={32} height={100} rx={15} fill={SWEATER_D} /><circle cy={100} r={16} fill="#e8b48c" /></g>
<g transform={`translate(72 -126) rotate(${p.armR + typing})`}><rect x={-16} y={-8} width={32} height={100} rx={15} fill={SWEATER_D} /><circle cy={100} r={16} fill="#e8b48c" /></g>
<path d="M-82 10 C-90 -66 -84 -128 -56 -144 Q0 -160 56 -144 C84 -128 90 -66 82 10 Z" fill={SWEATER} stroke="#8d5f12" strokeWidth={3} />
<path d="M0 -152 V10" stroke="#8d5f12" strokeWidth={2.5} strokeDasharray="4 7" />
{[-58, 58].map((x) => [-120, -80, -40].map((y) => <circle key={`${x}${y}`} cx={x} cy={y} r={3.2} fill="#8d5f12" />))}
<g transform="translate(0 -84)">
<circle r={10} fill="#4a5058" />
<g transform={`scale(${kx} 1)`}><path d="M0 0 C-14 -26 -44 -26 -44 0 C-44 26 -14 26 0 0 C14 26 44 26 44 0 C44 -26 14 -26 0 0 Z" fill="#eef0f3" stroke="#4a5058" strokeWidth={4} /></g>
</g>
<g transform={`translate(0 -148) rotate(${p.head})`}>
<rect x={-18} y={-22} width={36} height={32} fill="#d9a07a" />
<circle cx={-58} cy={-58} r={13} fill="#e8b48c" /><circle cx={58} cy={-58} r={13} fill="#e8b48c" />
<circle cx={0} cy={-62} r={60} fill={HAIR} />
<path d="M-36 -90 Q-10 -116 22 -106" fill="none" stroke="#6a452c" strokeWidth={6} strokeLinecap="round" />
</g>
</g>
<rect x={124} y={428} width={132} height={70} rx={16} fill="#3a2414" stroke="#22130a" strokeWidth={3} />
</svg>
);
};
/* A sign that comes down on strings from the top of the case, like a flat in a small theater. */
const Flat: React.FC<{ at: number; out?: number; x: number; y: number; w: number; h: number; children: React.ReactNode }> = ({ at: a, out, x, y, w, h, children }) => {
const t = useT();
if (t < a || (out !== undefined && t > out + 0.4)) return null;
const k = back(ramp(t, a, a + 0.38));
const u = out !== undefined ? easeIn(ramp(t, out, out + 0.4)) : 0;
const top = lerp(lerp(-h - 40, y, k), -h - 40, u);
const swing = Math.sin((t - a) * 4.4) * Math.exp(-(t - a) * 1.5) * 2.6;
return (
<div style={{ position: 'absolute', left: x, top, width: w, height: h, transformOrigin: '50% -220px', transform: `rotate(${swing}deg)` }}>
{[0.18, 0.82].map((q) => <div key={q} style={{ position: 'absolute', left: w * q - 2, top: -600, width: 4, height: 600, background: '#d8d2c4' }} />)}
{children}
</div>
);
};
const WEDGES = [{ a: 0, b: 16, label: WHEEL_SLIVER, fill: GREEN }, ...Array.from({ length: 8 }, (_, i) => ({ a: 16 + i * 43, b: 59 + i * 43, label: WHEEL_SLICE, fill: i % 2 ? GOLD : '#e23a33' }))];
const wedge = (a: number, b: number, r: number) => {
const p = (d: number): [number, number] => [Math.sin((d * Math.PI) / 180) * r, -Math.cos((d * Math.PI) / 180) * r];
const [x0, y0] = p(a), [x1, y1] = p(b);
return `M0 0 L${x0.toFixed(1)} ${y0.toFixed(1)} A${r} ${r} 0 0 1 ${x1.toFixed(1)} ${y1.toFixed(1)} Z`;
};
/* The wheel slows across the green sliver and stops just past it. Every other slice is the same. */
const Wheel: React.FC<{ d: number }> = ({ d }) => {
const t = useT();
const r = d / 2;
const q = easeOut(ramp(t, E('wheel'), E('start')));
const R = 7 - 360 * 3 * (1 - q);
const flap = Math.sin(R * 0.42) * 16 * (1 - q);
return (
<div style={{ position: 'absolute', left: 0, top: 0, width: d, height: d }}>
<div style={{ position: 'absolute', left: -10, top: -10, width: d + 20, height: d + 20, borderRadius: '50%', background: CHROME }} />
<div style={{ position: 'absolute', left: 0, top: 0, width: d, height: d, transform: `rotate(${R}deg)` }}>
<svg viewBox={`${-r} ${-r} ${d} ${d}`} width={d} height={d} style={{ position: 'absolute', left: 0, top: 0 }}>
{WEDGES.map((w, i) => <path key={i} d={wedge(w.a, w.b, r)} fill={w.fill} stroke="#5c0a10" strokeWidth={3} />)}
</svg>
{WEDGES.map((w, i) => {
const sliver = w.label === WHEEL_SLIVER;
return <div key={i} style={{ position: 'absolute', left: r, top: r, width: 0, height: 0, transform: `rotate(${(w.a + w.b) / 2 - 90}deg)` }}><div style={{ position: 'absolute', left: sliver ? 116 : 34, top: sliver ? -10 : -13, whiteSpace: 'nowrap', font: `${sliver ? 20 : 25}px/1 ${BUNGEE}`, color: sliver ? REEL : i % 2 ? '#5c0a10' : REEL }}>{w.label}</div></div>;
})}
<div style={{ position: 'absolute', left: 0, top: 0, width: d, height: d, borderRadius: '50%', background: '#e87a2a', opacity: clamp(1.1 - q * 1.5) }} />
<div style={{ position: 'absolute', left: r - 26, top: r - 26, width: 52, height: 52, borderRadius: '50%', background: CHROME }} />
</div>
<svg viewBox="0 0 60 70" width={60} height={70} style={{ position: 'absolute', left: r - 30, top: -30, transformOrigin: '50% 14%', transform: `rotate(${flap}deg)` }}><path d="M4 4 H56 L30 66 Z" fill={REEL} stroke="#5c0a10" strokeWidth={4} /></svg>
</div>
);
};
/* ---------- what the laptop shows ---------- */
const Bar: React.FC<{ pct: number; color?: string; h?: number }> = ({ pct, color = GOLD, h = 18 }) => <div style={{ width: '100%', height: h, borderRadius: h / 2, background: 'rgba(255,255,255,.16)', overflow: 'hidden' }}><div style={{ width: `${clamp(pct, 0, 100)}%`, height: h, background: color }} /></div>;
const SadFace: React.FC<{ size: number }> = ({ size }) => <svg viewBox="0 0 60 60" width={size} height={size}><circle cx={18} cy={22} r={5} fill={INK} /><circle cx={42} cy={22} r={5} fill={INK} /><path d="M16 46 Q30 32 44 46" fill="none" stroke={INK} strokeWidth={5} strokeLinecap="round" /></svg>;
const pane: React.CSSProperties = { position: 'absolute', left: 0, top: 0, width: 440, height: 272, boxSizing: 'border-box', padding: '20px 24px', overflow: 'hidden' };
const NAVY = 'linear-gradient(160deg,#16244a,#0c1530)';
const Screen: React.FC = () => {
const t = useT();
const low = lowK(t);
let view: React.ReactNode;
const column: React.CSSProperties = { ...pane, background: NAVY, color: REEL, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', paddingBottom: 26 };
if (t < E('lesson1')) {
view = (
<div style={{ ...pane, background: `linear-gradient(160deg,${REEL},#efe4c8)`, color: RED, textAlign: 'center' }}>
{COURSE.map((c) => <div key={c} style={{ font: `${fit(c, 392, 76)}px/1.04 ${BUNGEE}`, whiteSpace: 'nowrap' }}>{c}</div>)}
<div style={{ font: `34px/1.1 ${DOTS}`, color: INK, margin: '6px 0' }}>{SCREEN.count}</div>
<div style={{ background: '#c9bfa6', borderRadius: 9 }}><Bar pct={0} /></div>
</div>
);
} else if (t < E('quiz')) {
const two = t >= E('lesson2');
const pct = two ? 0 : at(t, [[0, 3], [E('nudge1'), 4], [E('nudge2'), 5]]);
view = (
<div style={column}>
<div style={{ font: `38px/1 ${BUNGEE}`, color: GOLD }}>LESSON</div>
<div style={{ font: `126px/.9 ${BUNGEE}`, textAlign: 'center', transform: `scale(${1 + 0.1 * Math.exp(-(t - (two ? E('lesson2') : E('lesson1'))) * 9)})` }}>{two ? 2 : 1}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}><Bar pct={pct * 6} /><div style={{ font: `38px/1 ${DOTS}`, color: GOLD }}>{pct}%</div></div>
</div>
);
} else if (t < E('start')) {
view = <div style={{ ...pane, background: REEL, color: INK, textAlign: 'center' }}><div style={{ font: `82px/1 ${BUNGEE}`, color: RED }}>{SCREEN.quiz}</div><SadFace size={104} /><div style={{ font: `34px/1 ${DOTS}` }}>{SCREEN.question}</div></div>;
} else if (t < E('low')) {
const n = 2 + (t >= E('another') ? 1 : 0) + (t >= E('another') + 0.5 ? 1 : 0);
view = (
<div style={{ ...pane, background: NAVY, color: REEL, padding: '14px 18px' }}>
<div style={{ font: `30px/1 ${BUNGEE}`, color: GOLD, marginBottom: 10 }}>{SCREEN.courses}</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
{[3, 1, 0, 0].slice(0, n).map((p, i) => <div key={i} style={{ height: 92, borderRadius: 8, padding: '8px 10px', boxSizing: 'border-box', background: ['#7a2a3a', '#2a6a5e', '#6a4a18', '#3a3a7a'][i] }}><div style={{ font: `40px/1.05 ${BUNGEE}` }}>{p}%</div><Bar pct={p * 3} h={12} /></div>)}
</div>
</div>
);
} else if (t < E('twenty')) {
view = (
<div style={{ ...pane, background: '#0a0d18', color: '#9aa3b8', textAlign: 'center', paddingTop: 34 }}>
<div style={{ font: `46px/1 ${BUNGEE}`, opacity: t >= E('lesson3') ? 1 : 0 }}>{SCREEN.stuck}</div>
<div style={{ font: `46px/1.2 ${DOTS}`, marginTop: 14, opacity: t >= E('last') ? 1 : 0 }}>{SCREEN.last}</div>
<div style={{ font: `44px/1.1 ${BUNGEE}`, color: LED, opacity: t >= E('fourteen') ? 1 : 0, whiteSpace: 'nowrap' }}>{SCREEN.ago}</div>
</div>
);
} else {
const done = t >= E('printing') + 0.4;
view = (
<div style={column}>
<div style={{ font: `46px/1 ${BUNGEE}`, color: done ? GREEN : GOLD, textAlign: 'center' }}>{SCREEN.stuck}</div>
<div style={{ textAlign: 'center', height: 124 }}><svg viewBox="0 0 100 100" width={124} height={124}>{done ? <path d="M18 54 L42 78 L84 26" fill="none" stroke={GREEN} strokeWidth={15} strokeLinecap="round" strokeLinejoin="round" /> : <><circle cx={50} cy={50} r={48} fill={GREEN} /><path d="M38 28 L74 50 L38 72 Z" fill={REEL} /></>}</svg></div>
<Bar pct={100 * ramp(t, E('quiet') + 0.2, E('printing') + 0.4)} color={GREEN} h={20} />
</div>
);
}
return (
<div style={{ position: 'absolute', left: 292, top: 64, width: 440, height: 272, overflow: 'hidden', filter: low > 0 ? `brightness(${1 - 0.25 * low})` : undefined }}>
{view}
<div style={{ position: 'absolute', left: 0, top: 0, width: 440, height: 272, opacity: low, background: 'linear-gradient(transparent 40%,rgba(120,110,90,.2))' }} />
</div>
);
};
/* The tear-off calendar is the film's second clock. */
const CAL: [number, [string, string]][] = [
[0, ['DAY', '1']], [E('gone'), ['DAY', '2']], [E('spin'), ['DAY', '3']], [E('lesson2'), ['DAY', '5']], [E('wheel'), ['DAY', '9']],
[E('down'), ['WEEK', '3']], [E('week'), ['WEEK', '9']], [E('now'), ['MONTH', '3']], [E('month'), ['MONTH', '7']], [E('lesson3'), ['MONTH', '11']], [E('fourteen'), ['MONTH', '14']],
[E('twenty'), ['', 'TODAY']], [E('tomorrow'), ['NEXT', 'DAY']],
];
const CalPage: React.FC<{ v: [string, string]; style?: React.CSSProperties }> = ({ v, style }) => (
<div style={{ position: 'absolute', left: 0, top: 12, width: 84, height: 96, background: REEL, textAlign: 'center', color: INK, ...style }}>
<div style={{ font: `15px/22px ${BUNGEE}`, height: 22, background: RED, color: REEL }}>{v[0]}</div>
<div style={{ font: `${fit(v[1], 76, 50, 0.76)}px/72px ${BUNGEE}` }}>{v[1]}</div>
</div>
);
const Calendar: React.FC = () => {
const t = useT();
let i = 0;
CAL.forEach(([a], k) => { if (t >= a) i = k; });
const age = t - CAL[i][0];
return (
<div style={{ position: 'absolute', left: 744, top: 40, width: 84, height: 108, filter: `brightness(${1 - 0.6 * lowK(t)})` }}>
<CalPage v={CAL[i][1]} />
{i > 0 && age < 0.3 ? <CalPage v={CAL[i - 1][1]} style={{ transformOrigin: '50% 0', transform: `rotateX(${age * 300}deg) translateY(${age * 240}px)`, opacity: 1 - age * 3 }} /> : null}
{[18, 60].map((x) => <div key={x} style={{ position: 'absolute', left: x, top: 4, width: 6, height: 18, borderRadius: 3, background: '#8a9099' }} />)}
</div>
);
};
const STARS = Array.from({ length: 14 }, (_, i) => ({ x: 48 + hash(i + 2) * 122, y: 44 + hash(i + 12) * 130 }));
const VALANCE = `M0 0 H860 V24 ${Array.from({ length: 11 }, () => 'q-39 40 -78 0').join(' ')} q0 0 -2 0 Z`;
const WEB = ['M860 0 L720 60', 'M860 0 L780 96', 'M860 0 L826 130', 'M742 52 Q786 64 792 92 Q810 106 824 120'];
const Window: React.FC = () => {
const t = useT();
const low = lowK(t), dark = low * 0.62, day = dayK(t), dawn = dawnK(t);
const wall = interpolateColors(low, [0, 1], ['#176a6c', '#0c2a30']);
const sky = interpolateColors(dawn, [0, 1], [interpolateColors(day, [0, 1], ['#0e1a3f', '#9ad2e8']), '#f6b36a']);
const moonY = 76 + day * 130 + dawn * 150;
const footOn = 1 - low;
const cobweb = ramp(t, E('fourteen') - 0.3, E('fourteen') + 0.5) * (1 - ramp(t, E('chance'), E('chance') + 0.4));
return (
<div style={{ position: 'absolute', left: 60, top: 244, width: 888, height: 518, borderRadius: 26, background: interpolateColors(low, [0, 1], ['#c9ced6', '#4a4e56']) }}>
<div style={{ position: 'absolute', left: 14, top: 14, width: WIN.w, height: WIN.h, borderRadius: 16, overflow: 'hidden', background: '#07090f' }}>
<svg viewBox={`0 0 ${WIN.w} ${WIN.h}`} width={WIN.w} height={WIN.h} style={{ position: 'absolute', left: 0, top: 0 }}>
<defs>
<clipPath id="pane"><rect x={46} y={40} width={126} height={146} /></clipPath>
<linearGradient id="desk" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#8a5630" /><stop offset="1" stopColor="#4a2a14" /></linearGradient>
<radialGradient id="glow"><stop offset="0" stopColor="#8fa8e0" stopOpacity={0.34} /><stop offset="1" stopColor="#8fa8e0" stopOpacity={0} /></radialGradient>
<linearGradient id="foot" x1="0" y1="1" x2="0" y2="0"><stop offset="0" stopColor="#ffc866" stopOpacity={0.5 * footOn} /><stop offset="0.5" stopColor="#ffc866" stopOpacity={0} /></linearGradient>
</defs>
<rect width={WIN.w} height={WIN.h} fill={wall} />
{Array.from({ length: 22 }, (_, i) => <rect key={i} x={i * 40} y={0} width={18} height={WIN.h} fill="#000" opacity={0.06} />)}
<rect x={38} y={32} width={142} height={162} fill="#3a2414" />
<g clipPath="url(#pane)">
<rect x={46} y={40} width={126} height={146} fill={sky} />
{STARS.map((s, i) => <circle key={i} cx={s.x} cy={s.y} r={1.8} fill="#fff6d8" opacity={(1 - day) * (1 - dawn) * (0.4 + 0.6 * Math.abs(Math.sin(t * 1.3 + i)))} />)}
<path d={`M122 ${moonY - 36} A36 36 0 1 0 122 ${moonY + 36} A28 28 0 1 1 122 ${moonY - 36} Z`} fill="#fff1b8" />
<circle cx={120} cy={230 - dawn * 110} r={40} fill="#fff0b0" opacity={dawn} />
</g>
<rect x={106} y={40} width={6} height={146} fill="#3a2414" /><rect x={46} y={108} width={126} height={6} fill="#3a2414" />
<rect x={0} y={372} width={WIN.w} height={118} fill="url(#desk)" /><rect x={0} y={372} width={WIN.w} height={5} fill="#b07a48" />
<rect x={280} y={52} width={464} height={294} rx={14} fill="#20242e" /><rect x={286} y={58} width={452} height={282} rx={10} fill="#0a0c12" />
<path d="M262 346 H762 L806 390 H218 Z" fill="#3a404c" /><path d="M292 356 H732 L752 378 H272 Z" fill="#20242e" />
<rect x={754} y={300} width={44} height={50} rx={6} fill={REEL} /><path d="M798 312 q20 2 18 18 q-2 12 -18 10" fill="none" stroke={REEL} strokeWidth={7} />
<g opacity={(1 - low) * 0.7} fill="none" stroke="#fff" strokeWidth={4} strokeLinecap="round">
{[766, 784].map((x, i) => <path key={x} d={`M${x} ${292 - (t * 18 + i * 9) % 12} q${6 * Math.sin(t * 3 + i)} -14 0 -28 q${-6 * Math.sin(t * 3 + i)} -14 0 -28`} />)}
</g>
<rect width={WIN.w} height={WIN.h} fill="#050812" opacity={dark} />
<ellipse cx={460} cy={330} rx={460} ry={260} fill="url(#glow)" opacity={low} />
</svg>
<Calendar />
<Screen />
<Student />
{t >= E('last') && t < E('isnt') + 0.4 ? ['z', 'z', 'Z'].map((z, i) => { const k = ((t - E('last')) * 0.45 + i * 0.33) % 1; return <div key={i} style={{ position: 'absolute', left: 250 + i * 8 + k * 30, top: 190 - k * 90, font: `${22 + i * 8}px/1 ${BUNGEE}`, color: REEL, opacity: Math.sin(k * Math.PI) * (1 - ramp(t, E('folks'), E('isnt'))) }}>{z}</div>; }) : null}
<svg viewBox={`0 0 ${WIN.w} ${WIN.h}`} width={WIN.w} height={WIN.h} style={{ position: 'absolute', left: 0, top: 0, filter: `brightness(${1 - 0.5 * low})` }}>
{cobweb > 0 ? <g fill="none" stroke="#cfd4da" strokeWidth={2} opacity={0.6}>{WEB.map((d, i) => <path key={i} d={d} pathLength={1} strokeDasharray={1} strokeDashoffset={1 - cobweb} />)}</g> : null}
<rect width={WIN.w} height={WIN.h} fill="url(#foot)" />
<path d="M0 0 H38 C30 120 16 240 28 360 C32 410 20 450 24 490 H0 Z" fill={RED} /><path d="M12 0 C8 160 6 320 10 490" stroke={RED_D} strokeWidth={6} fill="none" />
<path d="M860 0 H822 C830 120 844 240 832 360 C828 410 840 450 836 490 H860 Z" fill={RED} /><path d="M848 0 C852 160 854 320 850 490" stroke={RED_D} strokeWidth={6} fill="none" />
<path d={VALANCE} fill={RED} stroke={GOLD} strokeWidth={4} />
{Array.from({ length: 12 }, (_, i) => { const on = footOn * ((i + Math.floor(t * 6)) % 3 === 0 ? 1 : 0.45); return <g key={i}><circle cx={50 + i * 69} cy={480} r={7} fill={on > 0.05 ? '#ffe58a' : '#4a3a1c'} opacity={0.35 + 0.65 * on} /></g>; })}
</svg>
<Flat at={E('beginner') - 0.05} out={E('hold') + 0.15} x={340} y={38} w={344} h={92}>
<div style={{ width: 344, height: 92, borderRadius: 12, background: `linear-gradient(${GOLD},#e8a820)`, color: RED, font: `${fit(BADGE, 320, 40)}px/92px ${BUNGEE}`, textAlign: 'center', whiteSpace: 'nowrap', boxShadow: `0 0 0 4px ${RED_D}` }}>{BADGE}</div>
</Flat>
<Flat at={E('wheel') - 0.12} out={E('down') - 0.1} x={200} y={38} w={440} h={440}><Wheel d={440} /></Flat>
<Flat at={E('start') - 0.04} out={E('down') - 0.1} x={60} y={340} w={740} h={92}>
<div style={{ width: 740, height: 92, borderRadius: 10, background: REEL, color: RED, font: `${fit(WHEEL_BANNER, 700, 44, 0.72)}px/92px ${BUNGEE}`, textAlign: 'center', whiteSpace: 'nowrap', boxShadow: `0 0 0 5px ${RED_D}` }}>{WHEEL_BANNER}</div>
</Flat>
<Flat at={E('cta') + 0.02} x={120} y={44} w={620} h={392}>
<div style={{ position: 'relative', width: 620, height: 392, borderRadius: 18, overflow: 'hidden', background: `repeating-conic-gradient(from ${t * 5}deg at 50% 60%, ${GOLD} 0 9deg, #f5b820 9deg 18deg)`, boxShadow: `0 0 0 6px ${RED_D}`, textAlign: 'center' }}>
<div style={{ font: `76px/1 ${BUNGEE}`, color: RED_D, marginTop: 40 }}>COMMENT</div>
<div style={{ font: `${fit(KEYWORD, 560, 182, 0.86)}px/1.1 ${SHADE}`, color: RED, marginTop: 8, whiteSpace: 'nowrap' }}>{KEYWORD}</div>
</div>
</Flat>
<div style={{ position: 'absolute', left: 0, top: 0, width: WIN.w, height: WIN.h, background: 'linear-gradient(115deg,rgba(255,255,255,.09) 0 16%,transparent 16% 24%,rgba(255,255,255,.05) 24% 30%,transparent 30%)', opacity: 1 - 0.5 * low }} />
</div>
</div>
);
};
/* ---------- the plate: the one word on the machine that changes for good ---------- */
const FILLER = 'XTRQ';
const Plate: React.FC = () => {
const t = useT();
const k = ramp(t, E('chance') + 0.02, E('chance') + 0.6);
return (
<div style={{ position: 'absolute', left: 60, top: 772, width: 888, height: 66, background: `linear-gradient(#efe3bc,#c7a860)`, borderRadius: 10, boxShadow: `0 0 0 4px #6b4a14, inset 0 2px 0 rgba(255,255,255,.7)`, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 22 }}>
<div style={{ font: `40px/1 ${BUNGEE}`, color: RED_D, whiteSpace: 'nowrap' }}>{PLATE_LEAD}</div>
<div style={{ display: 'flex', gap: 4, padding: 4, background: '#180a06', borderRadius: 6 }}>
{Array.from(PLATE_TO).map((c, i) => {
const from = PLATE_FROM[i] ?? ' ';
const moved = from !== c;
const q = moved ? easeInOut(ramp(k, (i - 2) * 0.1 + 0.1, 0.9 + (i - 2) * 0.05)) : 0;
const strip = moved ? [from, ...Array.from(FILLER.slice(0, 3)), c] : [c];
return (
<div key={i} style={{ position: 'relative', width: 46, height: 50, overflow: 'hidden', background: `linear-gradient(#2c1810,#0e0604 48%,#2c1810)`, borderRadius: 3 }}>
<div style={{ position: 'absolute', left: 0, top: moved ? -q * (strip.length - 1) * 50 : 0, width: 46, textAlign: 'center' }}>
{strip.map((s, n) => <div key={n} style={{ height: 50, font: `42px/52px ${BUNGEE}`, color: moved ? interpolateColors(k, [0, 1], [GOLD, GREEN]) : interpolateColors(k, [0, 1], [GOLD, '#ffe58a']) }}>{s}</div>)}
</div>
</div>
);
})}
</div>
</div>
);
};
/* ---------- the reels ---------- */
const CELL = 150, REEL_W = 282, REEL_H = 236, REEL_GAP = 7;
const Tile: React.FC<{ a?: string; b: string; color?: string; size?: number }> = ({ a, b, color = RED, size = 92 }) => <div style={{ textAlign: 'center', color, whiteSpace: 'nowrap' }}>{a ? <div style={{ font: `36px/1 ${BUNGEE}` }}>{a}</div> : null}<div style={{ font: `${size}px/1 ${BUNGEE}` }}>{b}</div></div>;
const SYM: Record<string, React.ReactNode> = {
blank: <div style={{ width: 74, height: 5, borderRadius: 3, background: 'rgba(80,60,40,.22)' }} />,
q: <Tile b="?" size={124} />,
L1: <Tile a="LESSON" b="1" />,
L2: <Tile a="LESSON" b="2" />,
L3: <Tile a="LESSON" b="3" />,
T20: <Tile b="20" size={112} color={GREEN} />,
MIN: <Tile b="MIN" size={84} color={GREEN} />,
arrow: <svg viewBox="0 0 100 100" width={116} height={116}><path d="M22 10 L90 50 L22 90 Z" fill={GREEN} stroke="#14733d" strokeWidth={6} strokeLinejoin="round" /></svg>,
play: <Tile b="PLAY" size={78} color="#159a4e" />,
cherry: <svg viewBox="0 0 120 120" width={128} height={128}><path d="M38 78 C44 50 58 30 78 16 M86 72 C86 50 82 34 78 16" stroke="#3f7a2a" strokeWidth={6} fill="none" /><path d="M78 16 C92 6 110 10 116 20 C102 30 88 28 78 16Z" fill="#4c9a35" /><circle cx={38} cy={86} r={27} fill="#c8102e" /><circle cx={88} cy={84} r={27} fill="#d4141f" /><circle cx={29} cy={77} r={6} fill="#ffb3b3" /><circle cx={79} cy={75} r={6} fill="#ffb3b3" /></svg>,
moon: <svg viewBox="0 0 60 60" width={118} height={118}><path d="M36 4 A27 27 0 1 0 36 56 A21 21 0 1 1 36 4 Z" fill="#d99a10" stroke="#8d5f12" strokeWidth={2} /><path d="M46 20 l2.2 5 5 .6 -3.8 3.4 1.1 5 -4.5 -2.6 -4.5 2.6 1.1 -5 -3.8 -3.4 5 -.6 Z" fill="#d99a10" /></svg>,
bell: <svg viewBox="0 0 100 100" width={118} height={118}><path d="M50 8 C28 8 26 34 24 52 C22 66 12 72 12 78 H88 C88 72 78 66 76 52 C74 34 72 8 50 8 Z" fill="#e8b422" stroke="#8d5f12" strokeWidth={4} /><circle cx={50} cy={86} r={9} fill="#8d5f12" /></svg>,
mug: <svg viewBox="0 0 100 100" width={112} height={112}><rect x={18} y={28} width={54} height={60} rx={8} fill="#2f6f8f" /><path d="M72 42 q22 0 20 18 q-2 16 -20 14" fill="none" stroke="#2f6f8f" strokeWidth={9} /><path d="M34 20 q6 -8 0 -16 M52 20 q6 -8 0 -16" fill="none" stroke="#8a7a60" strokeWidth={4} strokeLinecap="round" /></svg>,
quiz: <div style={{ width: 96, height: 124, background: '#fffdf6', borderRadius: 4, transform: 'rotate(-5deg)', boxShadow: '0 0 0 3px #b8ad92, 3px 4px 0 rgba(60,40,20,.25)', textAlign: 'center', paddingTop: 8, boxSizing: 'border-box' }}><div style={{ font: `24px/1 ${BUNGEE}`, color: RED }}>QUIZ</div><SadFace size={78} /></div>,
};
const FILL = ['cherry', 'moon', 'bell', 'mug', 'q'];
type Spin = { start: number; stop: [number, number, number]; to: [string, string, string]; nudge?: boolean; lazy?: boolean };
const START: [string, string, string] = ['moon', 'bell', 'cherry'];
/* One row per landing, in time order. `stop` is when each reel lands, `nudge` slides one notch instead of spinning, `lazy` spins slowly. */
const SPINS: Spin[] = [
{ start: E('pull'), stop: [E('odds'), E('finish'), E('course') + 0.05], to: ['q', 'q', 'q'] },
{ start: E('spin') - 0.02, stop: [E('cherry1') + 0.1, E('cherry2'), E('lesson1')], to: ['cherry', 'cherry', 'L1'] },
{ start: E('nudge1') - 0.18, stop: [E('nudge1'), E('nudge1'), E('nudge1')], to: ['moon', 'bell', 'L1'], nudge: true },
{ start: E('nudge2') - 0.18, stop: [E('nudge2'), E('nudge2'), E('nudge2')], to: ['bell', 'mug', 'L1'], nudge: true },
{ start: E('lesson2') - 0.2, stop: [E('lesson2') + 0.05, E('lesson2') + 0.32, E('quiz')], to: ['L2', 'L2', 'quiz'] },
{ start: E('down'), stop: [E('week'), E('week') + 0.14, E('week') + 0.28], to: ['moon', 'blank', 'blank'], lazy: true },
{ start: E('now'), stop: [E('month'), E('month') + 0.18, E('month') + 0.32], to: ['blank', 'moon', 'blank'], lazy: true },
{ start: E('low') + 0.05, stop: [E('lesson3') + 0.1, E('lesson3') + 0.42, E('lesson3') + 0.7], to: ['L3', 'blank', 'blank'], lazy: true },
{ start: E('ago') - 0.28, stop: [E('ago'), E('ago'), E('ago')], to: ['blank', 'blank', 'blank'], nudge: true },
{ start: E('twenty') - 0.1, stop: [E('twenty') + 0.15, E('twenty') + 0.42, E('press') + 0.02], to: ['T20', 'MIN', 'arrow'] },
{ start: E('quiet') + 0.05, stop: [E('win') - 0.56, E('win') - 0.28, E('win')], to: ['play', 'play', 'play'] },
];
function reelAt(k: number, t: number): { syms: string[]; y: number; blur: number } {
let j = -1;
SPINS.forEach((s, i) => { if (t >= s.start) j = i; });
const cur = j >= 0 ? SPINS[j] : null;
const prev = j > 0 ? SPINS[j - 1].to[k] : START[k];
const fill = (n: number) => FILL[Math.floor(hash(n * 7.3 + k * 91) * FILL.length)];
if (cur && !cur.nudge && t < cur.stop[k]) {
const pos = (t - cur.start) * (cur.lazy ? 1300 : 2700);
const n = Math.floor(pos / CELL);
return { syms: [fill(n + 3), fill(n + 2), fill(n + 1), fill(n)], y: pos % CELL, blur: cur.lazy ? 1.2 : 2.6 };
}
if (cur?.nudge && t < cur.stop[k] && prev !== cur.to[k]) {
return { syms: [fill(j), cur.to[k], prev, fill(j + 5)], y: easeInOut(ramp(t, cur.start, cur.stop[k])) * CELL, blur: 0 };
}
const sym = cur ? cur.to[k] : prev;
const lone = sym === 'blank';
// the slow tick that keeps the dead machine alive: a seam passes on every reel, one after another
let y = cur && !cur.nudge ? (1 - back(ramp(t, cur.stop[k], cur.stop[k] + 0.24))) * -30 : 0;
if (t >= E('ago') && t < E('twenty') - 0.1 && lone) {
const q = (t - E('ago')) / 0.9 + k * 0.33;
y = (Math.floor(q) + easeInOut(clamp((q % 1) / 0.28))) * CELL;
}
return { syms: [lone ? 'blank' : fill(j + 7), lone ? 'blank' : fill(j + 11), sym, lone ? 'blank' : fill(j + 23)], y: y % CELL, blur: 0 };
}
const Reels: React.FC = () => {
const t = useT();
return (
<div style={{ position: 'absolute', left: 60, top: 842, width: 888, height: 256, borderRadius: 14, background: CHROME }}>
<div style={{ position: 'absolute', left: 14, top: 10, width: 860, height: REEL_H, background: '#08050a', borderRadius: 6, overflow: 'hidden' }}>
{[0, 1, 2].map((k) => {
const s = reelAt(k, t);
const y0 = (REEL_H - CELL) / 2;
return (
<div key={k} style={{ position: 'absolute', left: k * (REEL_W + REEL_GAP), top: 0, width: REEL_W, height: REEL_H, overflow: 'hidden', background: REEL }}>
<div style={{ position: 'absolute', left: 0, top: 0, width: REEL_W, height: REEL_H, filter: s.blur ? `blur(${s.blur}px)` : undefined }}>
{s.syms.map((n, i) => <div key={i} style={{ position: 'absolute', left: 0, top: y0 + (i - 2) * CELL + s.y, width: REEL_W, height: CELL, display: 'flex', alignItems: 'center', justifyContent: 'center', borderBottom: '2px solid rgba(80,60,40,.16)', boxSizing: 'border-box' }}>{SYM[n]}</div>)}
</div>
<div style={{ position: 'absolute', left: 0, top: 0, width: REEL_W, height: REEL_H, background: 'linear-gradient(rgba(50,28,12,.72),rgba(50,28,12,0) 24%,rgba(50,28,12,0) 76%,rgba(50,28,12,.72))' }} />
</div>
);
})}
<div style={{ position: 'absolute', left: 0, top: REEL_H / 2 - 2, width: 860, height: 4, background: 'rgba(200,16,46,.45)' }} />
</div>
<div style={{ position: 'absolute', left: 134, top: -3, width: 620, height: 12, borderRadius: 3, background: '#0a0507', boxShadow: '0 2px 0 rgba(255,255,255,.4)' }} />
</div>
);
};
const Holds: React.FC = () => {
const t = useT();
const nudging = (Math.max(t >= E('nudge1') ? Math.exp(-(t - E('nudge1')) * 5) : 0, t >= E('nudge2') ? Math.exp(-(t - E('nudge2')) * 5) : 0));
const held = t >= E('hold') && t < E('lesson2') - 0.2;
const levels = [nudging, nudging, held ? 1 : 0];
return (
<>
{levels.map((v, i) => {
const won = t >= E('win') ? ((i + Math.floor(t * 5)) % 3 === 0 ? 1 : 0.2) : v;
return <div key={i} style={{ position: 'absolute', left: 74 + i * (REEL_W + REEL_GAP) + 44, top: 1104, width: REEL_W - 88, height: 44, borderRadius: 22, textAlign: 'center', font: `30px/44px ${BUNGEE}`, color: won > 0.4 ? '#3a1402' : '#c98a7a', background: won > 0.4 ? `linear-gradient(#ffe58a,${LED})` : '#4d0a10', boxShadow: won > 0.4 ? '0 0 16px 5px rgba(255,176,0,.6)' : 'inset 0 2px 5px rgba(0,0,0,.6)' }}>HOLD</div>;
})}
</>
);
};
/* ---------- the counters: evenings on the left, coins on the right, the coin slot between them ---------- */
const Moon: React.FC<{ on: boolean }> = ({ on }) => <svg viewBox="0 0 60 60" width={31} height={31}><path d="M36 4 A27 27 0 1 0 36 56 A21 21 0 1 1 36 4 Z" fill={on ? LED : '#3a2606'} /></svg>;
const ledBox: React.CSSProperties = { position: 'absolute', top: 1158, width: 372, height: 106, borderRadius: 10, overflow: 'hidden', background: '#0b0806', boxShadow: `0 0 0 4px #8a9099, inset 0 0 16px rgba(0,0,0,.9)`, color: LED, fontFamily: DOTS, textShadow: '0 0 9px rgba(255,176,0,.55)' };
const Leds: React.FC = () => {
const t = useT();
const secs = Math.round(1200 * (1 - ramp(t, E('quiet') + 0.2, E('printing') + 0.4)));
const clockS = `${String(Math.floor(secs / 60)).padStart(2, '0')}:${String(secs % 60).padStart(2, '0')}`;
const ev = (n: number): [string, string, number] => ['EVENINGS THIS WEEK', String(n), n];
const list: [number, [string, string, number]][] = [
[0, ev(7)], [E('gone'), ev(6)], [E('cherry1'), ev(5)], [E('lesson2'), ev(4)], [E('wheel'), ev(3)], [E('down'), ev(2)], [E('week'), ev(1)],
[E('month'), ['EVENINGS THIS MONTH', '1', 1]], [E('last'), ['LAST OPENED', '14 MONTHS', 0]],
[E('twenty'), ['MINUTES', clockS, -1]], [E('printing') + 0.4, ['LESSON 3', 'DONE', -1]], [E('ticket'), ['NEXT EVENING', 'TOMORROW', -1]],
];
const [label, value, moons] = at(t, list);
const pop = 1 + 0.28 * Math.exp(-(t - list.reduce((m, [a]) => (t >= a ? a : m), 0)) * 9);
const coins = t < E('down')
? at(t, [[0, 12], [E('pull'), 11], [E('coinin'), 10], [E('spin'), 9], [E('lesson2'), 8], [E('wheel'), 7]])
: t < E('win') ? Math.round(lerp(7, 0, ramp(t, E('down'), E('low')))) : Math.round(lerp(0, 999, ramp(t, E('win'), E('four'))));
return (
<>
<div style={{ ...ledBox, left: 60 }}>
<div style={{ position: 'absolute', left: 16, top: 6, font: `30px/1 ${DOTS}`, whiteSpace: 'nowrap' }}>{label}</div>
<div style={{ position: 'absolute', left: 16, top: 30, font: `76px/1 ${DOTS}`, whiteSpace: 'nowrap', transformOrigin: '0 60%', transform: `scale(${pop})` }}>{value}</div>
{moons >= 0 ? <div style={{ position: 'absolute', left: 102, top: 48, display: 'flex', gap: 5 }}>{Array.from({ length: 7 }, (_, i) => <Moon key={i} on={i < moons} />)}</div> : null}
</div>
<div style={{ position: 'absolute', left: 440, top: 1154, width: 128, height: 114, borderRadius: 12, background: CHROME, textAlign: 'center', font: `19px/21px ${BUNGEE}`, color: '#3b2a1c', paddingTop: 6, boxSizing: 'border-box' }}>
{SLOT_LABEL.map((s) => <div key={s}>{s}</div>)}
<div style={{ position: 'absolute', left: 58, top: 54, width: 12, height: 50, borderRadius: 6, background: '#0a0507', boxShadow: `0 0 ${t >= E('coin') && t < E('gone') ? 12 : 0}px 3px rgba(255,204,51,${t >= E('coin') && t < E('gone') ? 0.9 : 0})` }} />
</div>
<div style={{ ...ledBox, left: 576 }}>
<div style={{ position: 'absolute', left: 16, top: 6, font: `30px/1 ${DOTS}` }}>COINS</div>
<div style={{ position: 'absolute', left: 16, top: 30, font: `76px/1 ${DOTS}` }}>{String(coins).padStart(3, '0')}</div>
<div style={{ position: 'absolute', left: 130, top: 56, width: 228, height: 30, overflow: 'hidden', font: `27px/30px ${DOTS}`, whiteSpace: 'pre' }}><div style={{ transform: `translateX(${-((t * 70) % 610)}px)` }}>{LED_TICKER.repeat(3)}</div></div>
</div>
</>
);
};
/* ---------- the veil of the low point: never black, and a pool of light finds the plate ---------- */
const Veil: React.FC = () => {
const t = useT();
const dark = Math.max(lowK(t) * 0.68, drainK(t) * 0.16);
if (dark <= 0) return null;
const s = spotK(t);
return <AbsoluteFill style={{ background: `radial-gradient(ellipse 500px 118px at 504px 805px, rgba(8,3,12,${dark * (1 - 0.92 * s)}) 42%, rgba(8,3,12,${dark}) 100%)` }} />;
};
/* ---------- the lever, which becomes the play button ---------- */
const HUB: [number, number] = [984, 730];
const LEVER = 320;
const PULLS = [0.05, E('spin') - 0.08, E('lesson2') - 0.26, E('down') - 0.05, E('twenty') - 0.16];
const pullK = (t: number) => PULLS.reduce((m, a) => Math.max(m, t < a ? 0 : t < a + 0.15 ? easeIn(ramp(t, a, a + 0.15)) : 1 - easeOut(ramp(t, a + 0.15, a + 0.62))), 0);
const SLOT: [number, number] = [504, 1211];
const Lever: React.FC = () => {
const t = useT();
const fly = easeInOut(ramp(t, E('press') - 0.04, E('press') + 0.4));
const gone = ramp(t, E('press') - 0.06, E('press') + 0.14);
const a = pullK(t) * 2.5;
const len = LEVER * (1 - gone);
const bx0 = HUB[0] + 46, by0 = HUB[1] - Math.cos(a) * LEVER;
const c: [number, number] = [860, 760];
const u = 1 - fly;
const bx = u * u * bx0 + 2 * u * fly * c[0] + fly * fly * SLOT[0];
const by = u * u * by0 + 2 * u * fly * c[1] + fly * fly * SLOT[1];
const press = t >= E('play') ? 1 - 0.14 * Math.sin(clamp((t - E('play')) / 0.26) * Math.PI) : 1;
const r = lerp(44, 62, fly) * (1 + 0.3 * Math.sin(a) * (1 - fly)) * press;
const col = interpolateColors(fly, [0, 1], ['#d4141f', GREEN]);
const ring = t >= E('play') ? clamp((t - E('play')) / 0.6) : 0;
const shaftEnd = HUB[1] - Math.cos(a) * len;
return (
<div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1920, filter: lowK(t) > 0 ? `brightness(${1 - 0.6 * lowK(t)})` : undefined }}>
{fly >= 1 ? <div style={{ position: 'absolute', left: SLOT[0] - 72, top: SLOT[1] - 72, width: 144, height: 144, borderRadius: '50%', background: CHROME }} /> : null}
{ring > 0 && ring < 1 ? <div style={{ position: 'absolute', left: SLOT[0] - 80 - ring * 180, top: SLOT[1] - 80 - ring * 180, width: 160 + ring * 360, height: 160 + ring * 360, borderRadius: '50%', boxShadow: `0 0 0 ${8 * (1 - ring)}px ${GREEN}`, opacity: 1 - ring }} /> : null}
<svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0 }}>
{len > 2 ? <><line x1={HUB[0] + 8} y1={HUB[1]} x2={HUB[0] + 46} y2={shaftEnd} stroke="#6d737b" strokeWidth={24} strokeLinecap="round" /><line x1={HUB[0] + 8} y1={HUB[1]} x2={HUB[0] + 46} y2={shaftEnd} stroke="#eef0f3" strokeWidth={9} strokeLinecap="round" /></> : null}
<circle cx={HUB[0] + 4} cy={HUB[1]} r={48 * (1 - gone * 0.3)} fill="#8a9099" /><circle cx={HUB[0] + 4} cy={HUB[1]} r={30 * (1 - gone * 0.3)} fill="#dfe3e8" />
</svg>
<div style={{ position: 'absolute', left: bx - r, top: by - r, width: r * 2, height: r * 2, borderRadius: '50%', background: `radial-gradient(circle at 35% 30%,#fff,${col} 42%,${interpolateColors(fly, [0, 1], ['#7a0a12', '#0c5a2c'])})`, boxShadow: '0 8px 0 rgba(30,0,6,.35)' }}>
{fly > 0.4 ? <svg viewBox="0 0 100 100" width={r * 2} height={r * 2} style={{ position: 'absolute', left: 0, top: 0, opacity: ramp(fly, 0.4, 1) }}><path d="M36 22 L78 50 L36 78 Z" fill={REEL} stroke="#0c5a2c" strokeWidth={4} strokeLinejoin="round" /></svg> : null}
</div>
</div>
);
};
/* ---------- coins ---------- */
const CoinDrop: React.FC = () => {
const t = useT();
const a = E('coin') - 0.3, b = E('coinin') + 0.02;
if (t < a || t > b + 0.22) return null;
const k = easeIn(ramp(t, a, b));
const y = lerp(100, SLOT[1] - 24, k);
const sink = ramp(t, b, b + 0.2);
return <Coin x={SLOT[0]} y={y + sink * 34} d={lerp(190, 110, k)} flip={sink > 0 ? lerp(0.16, 0.1, sink) : 0.14 + 0.86 * Math.abs(Math.cos(t * 12))} />;
};
const GoneTag: React.FC = () => {
const t = useT();
const k = ramp(t, E('evening') - 0.1, E('gone') + 0.5);
if (k <= 0 || k >= 1) return null;
return <div style={{ position: 'absolute', left: 204, width: 600, top: lerp(1110, 930, easeOut(k)), textAlign: 'center', opacity: ramp(k, 0, 0.1) * (1 - ramp(k, 0.82, 1)) }}><span style={{ font: `58px/1 ${BUNGEE}`, color: LED, background: '#0b0806', borderRadius: 12, padding: '10px 22px 4px', whiteSpace: 'nowrap' }}>{GONE_TAG}</span></div>;
};
const FALL = Array.from({ length: 130 }, (_, i) => {
const u = hash(i + 0.5);
return { x: u < 0.5 ? 34 + u * 2 * 400 : 1046 - (u - 0.5) * 2 * 400, st: hash(i + 40) * 2.5, g: 1900 + hash(i + 80) * 900, d: 56 + hash(i + 120) * 44, ph: hash(i + 160) * 6.28, sp: 5 + hash(i + 200) * 7, end: 1720 + hash(i + 240) * 90 };
});
const Waterfall: React.FC = () => {
const t = useT();
const a = E('win') + 0.1;
if (t < a || t > a + 4.2) return null;
return (
<>
{FALL.map((c, i) => {
const k = t - a - c.st;
if (k < 0) return null;
const y = 120 + 0.5 * c.g * k * k + 420 * k;
if (y > c.end) return null;
return <Coin key={i} x={c.x} y={y} d={c.d} flip={0.16 + 0.84 * Math.abs(Math.cos(t * c.sp + c.ph))} rot={c.ph * 12} />;
})}
</>
);
};
const Ticket: React.FC = () => {
const t = useT();
const a = E('printing'), b = E('four') - 0.1;
if (t < a) return null;
const W = 520, H = 372;
const k = ramp(t, a, b);
const h = H * (Math.floor(k * 16) / 16) * 0.4 + H * k * 0.6;
const hot = t >= E('tomorrow');
return (
<div style={{ position: 'absolute', left: 504 - W / 2, top: 846, width: W, height: h, overflow: 'hidden', transformOrigin: '50% 0', transform: `rotate(${Math.sin(t * 1.5) * 0.5}deg)` }}>
<div style={{ position: 'absolute', left: 0, top: h - H, width: W, height: H, background: `linear-gradient(#f4ecd4,${REEL})`, textAlign: 'center', color: INK, boxShadow: 'inset 0 18px 16px -14px rgba(40,20,0,.55)' }}>
<div style={{ font: `30px/1 ${BUNGEE}`, color: '#0f7a3c', paddingTop: 22 }}>{TICKET.head}</div>
<div style={{ height: 3, margin: '12px 26px 16px', background: 'repeating-linear-gradient(90deg,#8a7a60 0 10px,transparent 10px 18px)' }} />
<div style={{ font: `${fit(TICKET.a, W - 60, 84)}px/1 ${BUNGEE}`, color: RED }}>{TICKET.a}</div>
<div style={{ display: 'inline-block', font: `${fit(TICKET.b, W - 80, 80)}px/1 ${BUNGEE}`, marginTop: 10, padding: '6px 14px 2px', borderRadius: 8, background: hot ? GREEN : 'transparent', color: hot ? '#06331a' : INK }}>{TICKET.b}</div>
<div style={{ font: `27px/1.05 ${DOTS}`, marginTop: 16, padding: '0 24px' }}>{TICKET.foot}</div>
</div>
</div>
);
};
/* ---------- captions: one fixed panel, set into the cabinet ---------- */
const PER_ROW = 22;
const QUIET_T0 = E('win') - 0.05;
const QUIET_WORD: Word = { w: QUIET_CAPTION, t0: QUIET_T0, t1: E('ticket') - 0.2, scene: '' };
const CHUNKS: Chunk[] = [...buildChunks(PER_ROW, 2), { t0: QUIET_T0, rows: [{ s: QUIET_CAPTION, map: Array.from(QUIET_CAPTION, () => 0) }], words: [QUIET_WORD] }].sort((a, b) => a.t0 - b.t0);
const Captions: React.FC = () => {
const t = useT();
const idx = CHUNKS.reduce((a, c, i) => (t >= c.t0 - 0.08 ? i : a), -1);
const c = idx >= 0 ? CHUNKS[idx] : null;
const quiet = c?.words[0] === QUIET_WORD;
const pop = c ? 1 + 0.03 * (1 - ramp(t, c.t0 - 0.08, c.t0)) : 1;
const hot = (w: Word) => (t >= w.t0 - 0.03 && t < w.t1 + 0.08) || (t >= E('cta') && w.w.toUpperCase().startsWith(KEYWORD));
return (
<div style={{ position: 'absolute', left: 60, top: 1276, width: 888, height: 202, borderRadius: 16, background: 'linear-gradient(#17100e,#0a0606)', boxShadow: '0 0 0 7px #c9ced6, 0 0 0 11px #6d737b', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
{c ? c.rows.map((r, ri) => (
<div key={ri} style={{ font: quiet ? `${fit(QUIET_CAPTION, 800, 128, 0.86)}px/1.1 ${SHADE}` : `62px/1.2 ${BUNGEE}`, transform: `scale(${pop})`, whiteSpace: 'pre' }}>
{Array.from(r.s).map((ch, i) => {
const wi = r.map[i];
return <span key={i} style={{ color: quiet || (wi >= 0 && hot(c.words[wi])) ? GOLD : REEL }}>{ch}</span>;
})}
</div>
)) : null}
</div>
);
};
/* ---------- the film ---------- */
export const Jackpot: React.FC = () => (
<Clock>
<Fonts faces={FACES}>
<Stage />
</Fonts>
</Clock>
);
const Stage: React.FC = () => {
const t = useT();
const cam = camAt(t);
return (
<AbsoluteFill style={{ background: ROOM, overflow: 'hidden' }}>
<div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1920, transformOrigin: '0 0', transform: `translate(${cam.tx}px, ${cam.ty}px) scale(${cam.s})` }}>
<Room />
<Cabinet />
<Belly />
<Plate />
<Reels />
<Holds />
<Leds />
<Veil />
<Window />
<Lever />
<CoinDrop />
<GoneTag />
<Waterfall />
<Ticket />
<Marquee />
<Bulbs />
</div>
<Captions />
<AbsoluteFill style={{ background: 'radial-gradient(120% 80% at 50% 45%, transparent 66%, rgba(16,2,6,.42) 100%)', pointerEvents: 'none' }} />
</AbsoluteFill>
);
};
```
## 8. Voice (optional)
A voice is worth adding when the world has a speaker of its own, because the deadpan delivery is half the joke. Skip it for a sound-off post, or when the user would rather not have one: the film still works.
**The persona:** a fairground emcee with a big warm chest voice and a showman's patter, quick and delighted, selling your unfinished course like the greatest attraction on the midway. Never mocking: this person is on the viewer's side, which is why the turn works.
**When to use it:** on every line except the jackpot. Give the first lines full patter, let "Nudge. Nudge." go very dry and "Oh, that's a quiz." deflate. The drain loses energy line by line, and the low point is plain and quiet with no showmanship at all. "Folks, here's a secret" drops close to the mic, almost to a confidence, and "Twenty minutes. Press play." stays that plain. The warmth comes back on the ticket. Add a very small room with a short echo, and leave the top end soft.
- **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 |
|-------|--------|-------------|---------------|
| lever | 0.8 s | a heavy chrome lever on an old arcade machine pulled down and sprung back, a deep ratchet clunk, close and warm | every entry in `PULLS` |
| reel spin | 2 s | mechanical reels whirring inside an old wooden cabinet, soft ratcheting, low and warm, loopable | under every spin, slower under the lazy ones |
| reel stop | 0.4 s | one mechanical reel catching, a short dull thunk with a small click | every landing in `SPINS` |
| coin drop | 1 s | a single heavy brass coin dropped into a slot and rolling down a metal chute, dull and close | `coin` to `coinin` |
| relay tick | 0.3 s | a small relay clicking inside an old cabinet, soft, dry | each change of the left counter: `gone`, `cherry1`, `lesson2`, `wheel`, `down`, `week` |
| hold lamp | 0.4 s | a fat plastic arcade button pressed, a soft clack and a small lamp relay | `hold` |
| nudge | 0.4 s | one notch of a heavy ratchet, small and exact | `nudge1`, `nudge2` |
| small letdown | 0.8 s | two soft wooden knocks falling in pitch, comic and gentle | `quiz` |
| bonus wheel | 2 s | a prize wheel spinning, a leather flapper ticking across pegs and slowing to a stop | `wheel` to `start`, with the last ticks crossing the sliver |
| drain | 3 s | coins trickling away down a long pipe, thinning to nothing, dark | `down` to `low` |
| bulbs dying | 1.5 s | old filament bulbs clicking off one by one, soft glass tinks, a relay sagging | `low` to `last` |
| hum | 10 s | the low electric hum of an old cabinet transformer, warm, steady, no music | the bed, all the way through, and the main sound of the low point |
| slow reel tick | 0.5 s | one slow heavy tick of a mechanical drum, dry and close | every 0.9 s from `ago` to `twenty`, loudest around `folks` |
| lights back | 1 s | a bank of old relays waking up, clicks and a hum rising | `chance` |
| letters roll | 0.8 s | small drums on an old mechanical counter rolling and stopping | `chance`, under the lights |
| ball flight | 0.7 s | a soft air whoosh ending in a round rubber clunk | `press` |
| big button | 0.5 s | a large padded button pressed down, a deep soft click | `play` |
| brass bell | 1.5 s | one warm brass fairground bell rung twice, round, no shrill top | `win` |
| coin waterfall | 4 s | a long pour of heavy coins into a metal tray, warm and dark, thinning at the end | `win` to `four`, the fullest effect in the film |
| ticket printer | 2 s | a small ticket printer chattering, paper feeding out, ending in a soft tear | `printing` to `four` |
| fairground | 10 s | a fairground at night heard from far away, muffled crowd, wind, no voices up close, no music | a low bed, dropping out at `low` and back on `chance` |
Every sound here is a brief for your own generator or library. Nothing in this skill was recorded or measured from our demo, which we show silent.
- **Place a sound on every visible change,** at the same event the picture uses, so a re-voice moves both.
- **Beds run under everything:** a world without room tone feels empty, and room tone is what keeps the low point alive.
- **The same sound twice within 80 ms:** keep the louder one.
- **Warm, never bright:** low-pass effects to about 1.5-3.5 kHz if they bite.
**Music: one instrumental bed.** Describe the sound, never an artist. Generators ignore requested timestamps, so fit the track instead: find its real downbeats from bar-to-bar energy jumps, pick the bar where it lifts, place that bar on the story's turn, and stretch (keeping the pitch, within about 4 %) so a second downbeat lands on the voiceless moment.
**The brief:** "A jaunty old fairground band instrumental: oom-pah tuba, wheezy band organ, tack piano and brushed snare, quick and cheerful, then the same tune stripped back and warm, opening up into a full brass lift, no vocals."
**When it plays:** bright under the first lines. It starts to sag on "Down" and drains to nothing over 0.7 s on "Lesson three", and it stays out through the low point so only the hum, the reel tick and the dying bulbs remain. It returns on "chance", low and warmer, climbs through the move, and is at its fullest on the voiceless jackpot, on a bar where the track opens up. It ducks when the ticket line starts, so the last lines sit clear.
**Mix, voice first.**
- **Voice:** compress lightly so consonants stay off the limiter, about -14 LUFS.
- **Music:** about 16 to 20 LU under the voice while it speaks, lifted in gaps and on the voiceless moment.
- **Effects:** 7 to 10 LU under the voice overall, ducked about 6 dB under speech, so nothing masks a word.
- **Master:** -14 LUFS integrated, sample-peak ceiling about -2.4 dBFS. AAC encoding adds about 0.8 dB, so check the true peak of the final file. Give the master a 10 ms fade-in, or every view opens on a click.
## 10. Render and QA
1. **Render** without audio. Mux your mix onto it, trimmed to the exact length (`-t`, never `-shortest`), as H.264 at high quality with AAC 320k and `+faststart`.
2. **Sync and length:** the frame count matches the clock, and a cross-correlation of the muxed audio against your mix shows an offset of 0 ms.
3. **Contact sheets:** one frame per second with the safe box drawn. Look at every sheet: nothing cut off, nothing important outside the box, no empty or black frames, and the time of day and the story still agree.
4. **Determinism:** render about 10 spread frames twice with `npx remotion still src/index.ts Film-9x16 out/a.png --frame=N`, and compare each pair with `cmp`; they should match.
5. **Loudness:** -14 LUFS integrated, true peak at or below -1 dBTP.
6. **Words:** transcribe the mix line by line with Whisper. A difference on a plain word is a masked or swallowed word.
7. **Gaps and stills:** no voice gap over 1.0 s outside the voiceless moment (`clock.mjs` warns about these), and no still stretch over 1.2 s or black stretch (`ffmpeg -i out/silent.mp4 -vf "freezedetect=n=0.001:d=1.2,blackdetect=d=0.1" -an -f null -` prints any). A small moving corner can hide a freeze from that check, so trust the contact sheets over it.
For a silent film, skip the checks that need audio (the offset in 2, and 5 and 6) and use `out/silent.mp4` as the final file.
## 11. Traps
- **Looking like a real offer:** no currency marks, payout tables, odds figures, real game names or "play now". Keep the moon on the coins and NO CASH VALUE on the machine. If a request heads that way, refuse it, as section 3 says.
- **The word on the counter:** the machine counts evenings or coins, or your topic's own unit of time. A counter that reads like a balance turns the metaphor into an ad.
- **Strobing:** don't flash all the bulbs together, and don't speed up the chase in `bulbLevel` past 3 flashes a bulb. The wheel spins up under a solid disc for the same reason: at speed, its slices would flicker from frame to frame.
- **A black low point:** the veil never closes. The pilot bulb and the laptop's light stay on, a moth circles the bulb, and the reels keep ticking. It should read as dead, never as off.
- **Counters under the caption:** `zoom()` holds y 1268 still for that reason. A push around another point slides the counters under the fixed caption panel.
- **A plate pair that doesn't roll:** two words of different lengths break the cells, and two words sharing no letters look like a swap, not a discovery.
- **The coin stream hiding the payoff:** coins are biased toward the sides so PLAY, PLAY, PLAY stays readable. If you make them denser, keep the middle clear.
| 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 jackpot 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`). **One limit:** the slot machine here is a plain metaphor about follow-through. The skill refuses real-cash or gambling promotion, so don't use it for a casino, betting, a lottery or a real game. --- Use the jackpot skill to make a vertical video for [PLATFORM: Instagram Reels / TikTok / YouTube Shorts]. - **The thing people start and never finish:** [TOPIC - e.g. "that online course", "the novel in a drawer", "learning guitar"] - **What the machine counts:** [COUNTER - evenings, coins or your topic's own unit of time, never anything of cash value, or "you choose"] - **The small move:** [MOVE - the smallest honest step, e.g. "twenty minutes, press play", or "you write it"] - **What the ticket says:** [TICKET - the next small step, e.g. "lesson four, tomorrow", or "you write it"] - **In the window:** [WINDOW - "no person: the wind-up student drawn in code", or "my own clip, attached"] - **Length:** [LENGTH - e.g. about 35 seconds] - **Voice:** [VOICE - e.g. "a warm fairground emcee", "my own voice", or "no voice: captions and sound only"] - **Music:** [MUSIC - e.g. "the skill's fairground band 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 people or brands, no claims I can't back up, and always no real casinos, games, odds or winnings] Work in this order: 1. Write the script on the skill's moves, with one line for each move, what lands on the reels and what the case shows. Pick the two words for the plate first, the same length and sharing letters, then build the story toward them. 2. Build the plan, the clock and the frame, render a silent animatic, and show me the script, the scene sheet and the animatic. Wait for my OK. 3. Generate the voice, music and sound effects with the AI tools I named above. If I left that open, tell me which tools you'll use and wait for my OK. 4. Place a sound on every change, fit the music to the turn, mix voice first, render, and run every QA gate in the skill. 5. Give me the MP4, a cover frame and a ready-to-paste caption.
More skills
All skillsEVA
A space helmet shows you drifting off your plan, one small errand at a time.
Frame · 9:16 · 30-40 s
Old Desktop
An invented old computer reads out everything wrong with your first try.
Frame · 9:16 · 30-40 s
The Reading
A hushed tarot reader takes your small problem as fate.
Frame · 9:16 · 30-40 s