Pulp
A worn paperback fills the screen, and your small problem is the crime on its cover. Chapters slam, painted plates print in through growing dots, stickers pile up, and the title's ink runs in the rain. Then the book gets turned over, and the answer was on the back all along. Every scene is code, and the skill tells your agent which sounds to use and when.
- Frame
- 9:16
- 30-40 s
You need an agent that can run code, such as Claude Code, Claude with code execution, ChatGPT Codex or Cursor, plus Node 20+, ffmpeg and Python 3.
-
Give your agent the skill
Paste it into a new chat. Your agent saves it as a skill and waits for your prompt.
Copy for my agent Copy link -
Fill in the prompt
The small crime The answer in plain sight The brush-off that was the answer Platform Narrator Ending AI toolsUse the pulp skill to make a vertical video for Instagram Reels. - **The small crime:** [TOPIC - the tiny problem told as a crime novel, e.g. "the missing Wi-Fi password", "who ate my lunch"] - **The answer in plain sight:** [ANSWER - where it was all along, and what gets turned over to find it, or "you write it"] - **The brush-off that was really the answer:** [BRUSHOFF - e.g. "ask your mother", or "you find it"] - **The paperback's title:** [TITLE - invented and lurid, or "you write it"] - **In the window:** [WINDOW - "no person: painted plates drawn in code", or "my own clip, printed as the cover art, attached"] - **Length:** [LENGTH - e.g. about 35 seconds] - **Voice:** [VOICE - e.g. "a hard-boiled narrator", "my own voice", or "no voice: captions and sound only"] - **Music:** [MUSIC - e.g. "the skill's crime jazz 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 books, authors, publishers or cover art, no real people named as culprits, no claims I can't back up] Work in this order: 1. Write the script on the skill's moves, with one line for each turn and which plate, chapter or sticker it puts on the cover. 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, paints the plates 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: pulp
description: Make a 30-40 second vertical video that is one battered 1950s paperback - a painted, lurid, halftone cover that changes plate by plate, stickers and blurbs that pile up, a title that bleeds in the rain, and a book that gets physically turned over for the answer. The skill covers the rain-soaked sound design, music fitted to the story and an optional hard-boiled narrator. Use for storytime and confessions, small mysteries told as crime novels, dramatic reveals, book and fiction content, retro looks, or when asked for a pulp, paperback or noir cover video, or when given the pulp prompt.
---
# Pulp
A worn 1950s paperback fills the screen, and your small problem is the crime on its cover. Each chapter slams across the art, a new painted plate prints in through growing dots, and lurid stickers pile up until the rain knocks them off and the title's ink runs. Then the book itself gets turned over, and the answer was on the back the whole time. The shadows are a real halftone that moves with the light, so the page looks printed and still feels alive.
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/pulp/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/alfa-slab-one` and `@fontsource/cormorant-garamond`. Copy the font files you need from `node_modules/@fontsource/<name>/files/` into `public/fonts/`, so a render never fetches a font over the network. Then lay it out like this:
```
plan.json the lines and the scenes (step 6)
takes/<id>.json one per line: the voice take's length and word timings (step 8)
scripts/clock.mjs turns plan.json and the voice takes into src/spec.json (step 6)
src/index.ts registers the composition (step 7)
src/Root.tsx the 1080 x 1920, 30 fps composition (step 7)
src/spec.json the clock, written by clock.mjs
src/frames/core.tsx the shared runtime (step 7)
src/frames/story.json the events: which word each change lands on (step 6)
src/frames/Pulp.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: chapters slam across the cover, painted plates print in through growing dots, stickers pile up, the ink runs in the rain, and the whole book gets turned over. That's why it holds attention: the viewer learns the world in the first second, then watches it change.
- **One clock: the spoken words.** Each line is voiced as its own take with word timings, scenes are laid end to end on those takes, and every change is keyed to a word in `story.json`. Re-voice a line and everything moves with it.
- **A function of time.** Every part of the frame computes what it shows from the current time, with no stored state, `Math.random` or `Date`. Use `hash()` for anything that looks random, so two renders match.
- **Sound off first.** Most people start muted, so the frame and the captions have to tell the whole story on their own. Sound makes it better, never necessary.
- **Safe area.** 1080 x 1920. Platform buttons and captions cover the bottom 440 px, and the right 130 px from about y = 900 down. Keep captions and anything that must be read inside x 60-950, y 250-1480. Above y = 900 the frame can run to the right edge. Below y = 1480, draw only texture. The top 250 px can hold the world's title and small jokes, as long as nothing there has to be read.
- **Pacing rules.** No voice gap over 1.0 s, except one voiceless moment of 2 to 5 s with the music lifted. No still stretch over 1.2 s. Never cut to black. Something moves on the very first frame.
## 3. The world
One worn 1950s paperback, seen as a whole object, on a dark wet table. The cover is the frame.
- **Never a real culprit.** The crime is a small problem and the culprit is a thing or a habit. Never put a real person or a named company on the cover as the criminal.
- **Never a real book.** No real titles, authors, publishers, imprint marks, series names or famous cover paintings, and no copying a real cover's layout. This rule is the frame's own, and it covers the user's film too: invent the imprint, the byline and the sequel, and search any title you invent to check it isn't a real book. Ours are WET TUESDAY BOOKS, "by the tenant in 4B" and a sequel that doesn't exist. The round disc holds a series number, No. 417, and nothing else: never print what the book sold for, or any sum of any kind, on the cover. Keep brand names out too, in the title, the sequel and on every prop.
- **Palette:** aged stock `#e8d5a8`, pulp yellow `#f2c230`, blood red `#b81d24`, deep teal `#12524f`, ink `#1b1410`, with orange `#e2701e` and mint `#74ab9c` for the paint. Pick a loud color for each plate: teal, then hot red, yellow, mint, orange, then the drained alley. On a contact sheet each chapter should be a different color.
- **Type:** Alfa Slab One for the title, chapters, stickers and captions. Cormorant Garamond italic for the byline, the tagline and every "said nobody".
- **Fixed parts:** a teal masthead on a slant with the imprint, a round series disc, the title and the byline; a red ribbon along its edge with the series line or the current chapter; the painted art under that; a yellow tagline banner holding the captions in one fixed place (y 1292-1462, outside the book, so it stays put when the book flips); below that only a tagline and paint, as texture. The imprint's small diamond holds four signal bars, and they follow the story.
- **Printed, not rendered.** Every plate is flat painted shapes with their edges wobbled by the `paint` filter, then a real black plate on top: `Halftone` redraws about 17,000 dots each frame, and each dot's size comes from that plate's `shade(x, y)`. The bulb swings and the shadows swing in dots. That one idea is what stops it looking like flat vector art.
- **It's a battered object:** cracked spine, a dog-eared corner, foxing, rubbed edges, nibbled trim. All of it sits above the art in `Wear`, on both covers, so nothing looks new.
- **Everything hits the book:** a slam or a sticker knocks the whole book a little (`HITS`). The book also sways all the time, so no shot is ever still.
- **Small jokes live on the print:** "by the tenant in 4B", "One man. One router. Zero bars.", "NOT ONE WORD CHANGED, UNLIKE THE PASSWORD.", "my roommate, unconfirmed".
**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 cover painting, and each chapter gets a new one. Ours are drawn in code, one component each:
| Story moment | On the cover |
|---|---|
| The crime | `Street`: a trench coat from behind, an apartment block, a big Wi-Fi symbol on the roof. On "dark" the arcs and the windows go out |
| The first witness | `Router`: a giant router on a sunburst, with two eyes that blink on "blinked" and narrow on "knew" |
| The culprit, unseen | `Shadow`: a huge shadow on a yellow wall reaches for the router. Its head is in curlers. The lights turn red |
| The clue | `Fridge`: a flashlight beam crosses a fridge door to a clean square where a note used to be. A red question mark prints in |
| The witness | `Room`: a swinging bulb, the detective's bulk, a roommate holding cereal |
| The low point | `Alley`: rain, a lamp, a dead neon sign that says BARS, with four tubes that are the signal meter |
| The answer | `RouterBack`, on the back cover: the rear of the router, with a printed label |
The window also works with the user's own clip: play it inside a plate component, full bleed, and keep `Brush`, the `Halftone` and `Wear` above it, so their footage prints like cover art. Set that plate's `shade` low, about 0.3, or a face disappears into the dots.
## 5. The story
This is the part that makes the film, so write it before you touch anything else. Read every line aloud.
- **Use the world's own events.** Every turn in the topic has to become something that really happens in this world. If a line could sit in any video, rewrite it.
- **Use the voice's deadpan.** The funniest and most moving versions take the world completely seriously.
- **Short lines.** Mostly 5 to 11 words, one idea each, with an occasional two-word punch.
- **Real claims only.** Use a number or a fact only if the user gave it to you or you can source it. Invented details are fine only when the film is plainly a joke or a metaphor.
- **Build to a low point, then a turn.** The low point is a picture event, never silence: the world goes dim or dead while room tone and a designed sound keep running.
- **No em dashes on screen.** Commas, full stops and line breaks do that job.
- **Ending, organic or sponsored.** Ask the user which one, and for an organic ending, what they'll send people. Organic: "Comment KEYWORD and I'll send you THE THING", said in the world's voice, and no link. The keyword stays on screen in the world's own way until the end, because the caption moves on. Sponsored: a direct line and a URL.
- **Length.** Run the clock from step 6 as soon as the script exists, before you build anything. Each extra line adds about 3 s, and even a one-word line takes about 0.5 s. Time follows the words, so a cut only helps if words go: folding two moves into one shorter line works, and joining them word for word saves nothing. Then re-anchor every event in `story.json`.
**The moves.** Map any topic onto these. The film treats a tiny domestic problem as a crime novel, and never winks. Ours is a missing Wi-Fi password.
| # | Move | What it does | Our line | On the cover |
|---|------|--------------|----------|--------------|
| 1 | The cover | The hook: a small problem in the voice of a crime novel | "It was a wet Tuesday when the Wi-Fi went dark." | The book drops, the title lands, and the Wi-Fi symbol and the windows go out |
| 2 | Chapter one | The thing itself looks guilty | "Chapter one. The router blinked at me like it knew something." | CHAPTER ONE slams, a new plate prints in, the router blinks. IT KNEW! |
| 3 | Chapter two | The crime | "Chapter two. Somebody had changed the password." | A shadow in curlers reaches in, the lights go red, WRONG PASSWORD lands |
| 4 | The clue | A dull detail, treated with far too much gravity | "The sticky note was gone. Nobody talks about the sticky note." | A flashlight finds the clean square. THE NOTE THEY DARE NOT MENTION! |
| 5 | The witness | A brush-off that is really the answer | "I leaned on my roommate. He said, ask your mother." | Bulb swinging, roommate with cereal, ASK YOUR MOTHER! |
| 6 | Dead end | The low point | "Chapter three. Zero bars. The rain came down." | The slam knocks every sticker off. Rain, the alley, the last tube dies, the title's ink runs and the color drains. Music out; rain and a far-off siren keep running |
| 7 | The turn | The object itself gets turned over | "Then I turned over the router." | The whole book flips. The back cover is the back of the router, and the color is back. Music returns on "router" |
| 8 | The answer | It was never hidden | "It was printed on the back. It had been there all along." | A push into the label: PASSWORD, ASKYOURMOTHER, circled in red. ALL ALONG! |
| 9 | The release | The voiceless moment, about 3.5 s | (music at its fullest, the caption reads CONNECTED) | Pages riffle, then the neon tubes climb one by one, BARS buzzes on, and red floods the wet alley |
| 10 | The blurbs | The last laugh | "Gripping, said nobody. I found it, said me." | Back on the back cover, blurbs pile up, and the last one is "TOLD YOU. your mother" |
| 11 | The sequel | The second laugh | "Coming soon. The Remote Affair." | The next paperback slides in over the pile |
| 12 | Call to action | One action | "Comment pulp and I'll send you the skill that made this." | The book flips to the front, and a starburst reads COMMENT PULP and stays |
**For other topics:** who ate my lunch from the office fridge, where the other charger went, why the plant keeps dying, the group chat that went quiet, how I lost a whole Saturday. Pick something too small to deserve a crime novel, and let the narrator give it one.
**The trick that makes it land:** the answer is a line the viewer has already heard. The roommate's brush-off, "ask your mother", sounds like a dead end, and then the password on the label reads ASKYOURMOTHER. No line of the script says so: the picture does it, for the viewers who are reading, and they feel clever. The shadow's curlers in chapter two and the "call mother" note on the fridge are there for the second watch. So write your answer first, then plant it as a throwaway line in move 5, then put one quiet visual clue in two earlier plates. Your film also needs a physical thing that can be turned over, opened or looked behind, because the flip of the book has to be the flip of that thing. Use real facts if the story is real, and keep it plainly a joke if it isn't.
Our film runs 41 s on the clock's estimate. For about 35 s, drop move 11, and drop the second sentence of move 4 or of move 8. Joining two lines word for word saves nothing. Dropping move 4 also removes a whole plate to draw.
## 6. Plan and animatic (before generating anything)
**1. Write `plan.json`.** `text` is what the captions show. Add `say` when the spoken version differs, with the same number of words. Scenes hold the lines. `lead` and `tail` are seconds of room around a scene's words, and a voiceless scene has `sec` instead of `lines`.
```json
{
"lines": [
{ "id": "l01", "text": "First line." },
{ "id": "l02", "text": "Comment TOUR and I'll send it to you.", "say": "Comment, tour, and I'll send it to you." }
],
"scenes": [
{ "id": "s01", "lines": ["l01"], "lead": 0.7 },
{ "id": "s02", "sec": 3.4 },
{ "id": "s03", "lines": ["l02"] }
]
}
```
**2. Run the clock.** Write `scripts/clock.mjs` as below, and run it from the project folder. A line with no take yet gets an estimate, so you can see the whole film before generating anything. A real take goes in `takes/<id>.json` as `{ "dur": seconds, "words": [{ "w": "First", "t0": 0, "t1": 0.3 }] }` and replaces the estimate.
```js
// node scripts/clock.mjs plan.json + takes/<id>.json -> src/spec.json (what the frame renders) and clock.json (where each line plays)
import fs from 'node:fs';
const FPS = 30;
const plan = JSON.parse(fs.readFileSync('plan.json', 'utf8'));
const PAUSE = 0.35, LEAD = 0.05, TAIL = 0.25, TAIL_OUT = 1.2;
// No take yet: an estimate for a quick, clear voice, fitted to 53 real takes. A real take in takes/<id>.json replaces it.
function estimate(text) {
const letters = (s) => (s.match(/[\p{L}\p{N}]/gu) ?? []).length;
const stops = (text.match(/[.?!]+(?=\s)/g) ?? []).length, commas = (text.match(/,/g) ?? []).length;
const dur = 0.29 + 0.059 * letters(text) + 0.53 * stops + 0.07 * commas;
const parts = text.split(/\s+/), total = parts.reduce((n, p) => n + letters(p) + 1, 0);
let at = 0;
const words = parts.map((w) => { const d = (dur * (letters(w) + 1)) / total, x = { w, t0: at, t1: at + d }; at += d; return x; });
return { dur, words };
}
let t = 0.1;
const beats = [], placed = [];
for (const s of plan.scenes) {
const from = Math.round(t * FPS);
let end = t + (s.sec ?? 0);
const words = [];
if (s.lines?.length) {
let c = t + (s.lead ?? LEAD);
s.lines.forEach((id, i) => {
const line = plan.lines.find((l) => l.id === id);
const take = fs.existsSync(`takes/${id}.json`) ? JSON.parse(fs.readFileSync(`takes/${id}.json`, 'utf8')) : estimate(line.say ?? line.text);
// Captions show `text`, even when `say` differs ("Comment, gate"), as long as the word counts match.
const shown = line.text.split(/\s+/);
const same = shown.length === take.words.length;
placed.push({ line: id, t0: c, dur: take.dur, scene: s.id });
take.words.forEach((w, k) => words.push({ w: same ? shown[k] : w.w, f0: Math.round((c + w.t0) * FPS) - from, f1: Math.round((c + w.t1) * FPS) - from }));
c += take.dur + (i < s.lines.length - 1 ? PAUSE : 0);
});
end = c + (s.tail ?? TAIL);
}
const dur = Math.max(1, Math.round(end * FPS) - from);
beats.push({ id: s.id, from, dur, words, p: s.p ?? {} });
t = (from + dur) / FPS;
}
const total = Math.round((t + TAIL_OUT) * FPS);
beats[beats.length - 1].dur = total - beats[beats.length - 1].from;
beats[0].dur += beats[0].from;
beats[0].words.forEach((w) => { w.f0 += beats[0].from; w.f1 += beats[0].from; });
beats[0].from = 0;
for (let i = 1; i < placed.length; i++) {
const gap = placed[i].t0 - (placed[i - 1].t0 + placed[i - 1].dur);
const quiet = beats.some((b) => !b.words.length && b.from / FPS < placed[i].t0 && (b.from + b.dur) / FPS > placed[i - 1].t0);
if (gap > 1.0 && !quiet) console.warn(`voice gap ${gap.toFixed(2)} s before ${placed[i].line}: shorten a lead or tail`);
}
fs.mkdirSync('src', { recursive: true });
fs.writeFileSync('src/spec.json', JSON.stringify({ fps: FPS, durationInFrames: total, beats }, null, 1));
fs.writeFileSync('clock.json', JSON.stringify({ fps: FPS, total: total / FPS, lines: placed }, null, 1));
console.log(`${(total / FPS).toFixed(2)} s, ${beats.length} scenes, ${placed.length} lines`);
```
**3. Write `src/frames/story.json`.** Name every change in the story and pin it to a scene, and to a word in it where you can. The frame reads events by name, and a typo fails the render instead of drifting. Every event is checked when the film loads, even one the frame never reads. Our list keeps some events only so a sound can land on them; you can delete those. A `word` matches the first word in its scene that starts with those letters, so "the" also hits "there", and a repeated word can only be reached with `off` from an earlier one.
**4. Build the frame** with the code in step 7, changing the words and schedules to your story. Keep each schedule in time order.
**5. Render a silent animatic.** Make contact sheets at one frame per second with the safe box drawn, and look at every tile: `ffmpeg -i out/silent.mp4 -vf "select='not(mod(n\,30))',drawbox=x=60:y=250:w=890:h=1230:color=red@0.9:t=6,scale=216:-2,tile=8x3" -fps_mode passthrough sheet_%02d.jpg`. Empty tiles at the end of the last sheet are padding, not black frames. A tile that small hides small errors, so also look at about 6 frames at full size, with the window cropped large (`npx remotion still src/index.ts Film-9x16 out/f.png --frame=N`).
**6. Show the user** the script, the scene sheet (one row per scene: its line, what changes on screen, and its seconds) and the animatic. Wait for their OK.
## 7. Build the frame
These files are the real frame that made our demo, with our demo's story in it. You rendered them in step 1 to prove your setup. Now change the story, using the list below. Leave the machinery alone until the film works, and read the code before you change it. Paper, panels and labels are sized to our demo's words, so check anything longer on the contact sheet.
### What to change for your story
Most of this file is the six painted plates, and almost all of them are about a router. Each plate is one component with one shade function, so replace them one at a time. Expect to redraw about 300 of the 735 lines. `core.tsx`, `clock.mjs`, `Halftone`, `Book`, `Face`, `Wear` and `Banner` don't need to change.
| To change | Where |
|---|---|
| The cover's words | The block at the very top: `TITLE`, `BYLINE`, `IMPRINT`, `SERIES`, `TAGLINE`, `DISC`, `LOW_RIBBON`, `END_RIBBON`, `SOON` |
| The comment keyword | `KEYWORD` at the top, and the last line in `plan.json`. `CommentBurst` and `Banner` read it, and the word stays red in the caption once the ending starts |
| The chapters | `CHAPTERS`: the event that lands each card, the event that clears it, and its word. `ChapterSlam` draws the card. `Ribbon` and the riffle's `Leaf` also print the word CHAPTER |
| The paintings | `Street`, `Router`, `Shadow`, `Fridge`, `Room`, `Alley`. `PLATES` lists them in order, with the time each prints in, the point its slow zoom holds (`fx`, `fy`) and how much `rain` falls on it. Redrawing these is the biggest job |
| The light | Each plate's `streetShade`, `routerShade` and so on: how dark a point is, 0 to 1. `beamTo` aims the flashlight, `bulbAt` swings the bulb, and `alleyShade` opens up around the neon |
| The figures | `GUMSHOE` and `Gumshoe` for the detective, `SHADOW` for the culprit's shadow, the roommate inside `Room`, `NoteHint` and `NOTES` for the fridge notes |
| The stickers | Words in `STICKER` at the top. `Stickers` sets each one's event, where it lands large (`hero`), where it rests (`rest`) and how long it holds. Plan the rest positions around your plates |
| The answer | `SECRET`, `LABEL`, `HOOK`, `FINE_PRINT` at the top. `RouterBack` draws the back of the thing, and its red circle is a path you must move if the label changes. `pushAt` is the zoom, with its point in `BackCover` |
| The blurb pile | Words in `QUOTES`. `BackBlurbs` sets each one's event, place and color |
| The sequel | `SEQUEL` at the top. `Sequel` draws the small cover and its two remotes |
| The neon sign | `SIGN` at the top, `Tube` and the sign panel inside `Alley`. `NEON`, `LIT`, `LAST_BAR`, `tubeAt`, `signAt`, `glowAt` and `barsAt` time it from the `zero` and `riffle` events. `SIGN` is a pun that only works for bars, so give your low point its own dead thing that comes back on |
| The book's moves | `FLIPS` for the turns, `HITS` for the knocks, `LEAVES` for the riffle, `drainAt` for the color, `InkRuns` and `DRIPS` for the bleeding title |
| The voiceless caption | `VOICELESS` at the top. `CHUNKS` adds it, and it lights at `LIT` |
- **Scene ids in the code: none.** Everything reads events from `story.json` by name, and the voiceless caption is placed from the `riffle` event. If you rename an event, search the file for it.
- **What fits itself and what doesn't.** `fit()` sizes `TITLE`, the chapter word, `SECRET` and `KEYWORD`, so a longer word still fits. These are sized to our own words, so check each one on the sheet: the two bursts in `Stickers` (340 and 430 px), the note sticker, `Quote` (it grows with its words and can leave the safe area), the sequel's 390 px cover, `SIGN` at 84 px in its 370 px panel, the ribbon at 40 px (about 28 letters), the byline and the imprint.
- **`buildChunks(19, 2)` in `CHUNKS`** fits the caption banner at 62 px. A word over 19 letters needs a smaller size.
- **Keep the order in `FrontCover` and `BackCover`:** later items draw on top. The title stays above the stickers and the drips, and `Wear` stays above everything.
- **Delete a plate's events along with the plate.** `story.json` events that no longer match a word stop the render, which is what you want.
### The code
`plan.json`, our demo's script:
```json
{
"lines": [
{
"id": "l01",
"text": "It was a wet Tuesday when the Wi-Fi went dark."
},
{
"id": "l02",
"text": "Chapter one. The router blinked at me like it knew something."
},
{
"id": "l03",
"text": "Chapter two. Somebody had changed the password."
},
{
"id": "l04",
"text": "The sticky note was gone. Nobody talks about the sticky note."
},
{
"id": "l05",
"text": "I leaned on my roommate. He said, ask your mother."
},
{
"id": "l06",
"text": "Chapter three. Zero bars. The rain came down."
},
{
"id": "l07",
"text": "Then I turned over the router."
},
{
"id": "l08",
"text": "It was printed on the back. It had been there all along."
},
{
"id": "l09",
"text": "Gripping, said nobody. I found it, said me."
},
{
"id": "l10",
"text": "Coming soon. The Remote Affair."
},
{
"id": "l11",
"text": "Comment pulp and I'll send you the skill that made this.",
"say": "Comment, pulp, and I'll send you the skill that made this."
}
],
"scenes": [
{
"id": "s01",
"lines": [
"l01"
],
"lead": 0.3,
"tail": 0.15
},
{
"id": "s02",
"lines": [
"l02"
],
"lead": 0.1,
"tail": 0.1
},
{
"id": "s03",
"lines": [
"l03"
],
"lead": 0.1,
"tail": 0.1
},
{
"id": "s04",
"lines": [
"l04"
],
"lead": 0.15,
"tail": 0.1
},
{
"id": "s05",
"lines": [
"l05"
],
"lead": 0.15,
"tail": 0.15
},
{
"id": "s06",
"lines": [
"l06"
],
"lead": 0.5,
"tail": 0.15
},
{
"id": "s07",
"lines": [
"l07"
],
"lead": 0.3,
"tail": 0.1
},
{
"id": "s08",
"lines": [
"l08"
],
"lead": 0.15,
"tail": 0.1
},
{
"id": "s09",
"sec": 3.5
},
{
"id": "s10",
"lines": [
"l09"
],
"lead": 0.25,
"tail": 0.15
},
{
"id": "s11",
"lines": [
"l10"
],
"lead": 0.1,
"tail": 0.1
},
{
"id": "s12",
"lines": [
"l11"
],
"lead": 0.2,
"tail": 0.1
}
]
}
```
`src/index.ts`:
```ts
import { registerRoot } from 'remotion';
import { Root } from './Root';
registerRoot(Root);
```
`src/Root.tsx`:
```tsx
import React from 'react';
import { Composition } from 'remotion';
import { Pulp } from './frames/Pulp';
import spec from './spec.json';
export const Root: React.FC = () => (
<Composition id="Film-9x16" component={Pulp} 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": "wet",
"scene": "s01",
"word": "wet"
},
{
"id": "dark",
"scene": "s01",
"word": "dark"
},
{
"id": "ch1",
"scene": "s02",
"word": "Chapter"
},
{
"id": "router",
"scene": "s02",
"word": "router"
},
{
"id": "blink",
"scene": "s02",
"word": "blinked"
},
{
"id": "knew",
"scene": "s02",
"word": "knew"
},
{
"id": "ch2",
"scene": "s03",
"word": "Chapter"
},
{
"id": "somebody",
"scene": "s03",
"word": "Somebody"
},
{
"id": "changed",
"scene": "s03",
"word": "changed"
},
{
"id": "password",
"scene": "s03",
"word": "password"
},
{
"id": "note",
"scene": "s04"
},
{
"id": "gone",
"scene": "s04",
"word": "gone"
},
{
"id": "nobody",
"scene": "s04",
"word": "Nobody"
},
{
"id": "witness",
"scene": "s05"
},
{
"id": "leaned",
"scene": "s05",
"word": "leaned"
},
{
"id": "said",
"scene": "s05",
"word": "said"
},
{
"id": "mother",
"scene": "s05",
"word": "mother"
},
{
"id": "low",
"scene": "s06"
},
{
"id": "ch3",
"scene": "s06",
"word": "Chapter"
},
{
"id": "zero",
"scene": "s06",
"word": "Zero"
},
{
"id": "rain",
"scene": "s06",
"word": "rain"
},
{
"id": "turned",
"scene": "s07",
"word": "turned"
},
{
"id": "routerback",
"scene": "s07",
"word": "router"
},
{
"id": "printed",
"scene": "s08",
"word": "printed"
},
{
"id": "along",
"scene": "s08",
"word": "along"
},
{
"id": "riffle",
"scene": "s09"
},
{
"id": "gripping",
"scene": "s10",
"word": "Gripping"
},
{
"id": "nobody2",
"scene": "s10",
"word": "nobody"
},
{
"id": "found",
"scene": "s10",
"word": "found"
},
{
"id": "me",
"scene": "s10",
"word": "me"
},
{
"id": "soon",
"scene": "s11",
"word": "Coming"
},
{
"id": "affair",
"scene": "s11",
"word": "Affair"
},
{
"id": "cta",
"scene": "s12"
},
{
"id": "pulp",
"scene": "s12",
"word": "pulp"
}
]
}
```
`src/frames/Pulp.tsx`:
```tsx
import React, { useLayoutEffect, useRef } 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 StoryJson, type Word } from './core';
/* Pulp: a worn 1950s paperback. The whole film is one battered book: its painted cover changes plate by plate, stickers pile up, the rain
washes it all away, then the book itself gets turned over. The shadows are a real halftone, redrawn every frame, so the light moves in dots.
1080 x 1920. Time comes from the spoken words through story.json, so a re-voice moves every slam with its word. */
const E = makeE(story as StoryJson);
/* ---------- the story's words: change these first ---------- */
const KEYWORD = 'PULP';
const TITLE = ['THE MISSING', 'PASSWORD'];
const BYLINE = 'by the tenant in 4B';
const IMPRINT = 'WET TUESDAY BOOKS';
const SERIES = 'A WET TUESDAY MYSTERY';
const TAGLINE = 'One man. One router. Zero bars.';
const DISC = ['No.', '417'];
const LOW_RIBBON = ': ZERO BARS', END_RIBBON = 'THE END', SOON = 'COMING SOON';
const CHAPTERS: [string, string, string][] = [['ch1', 'router', 'ONE'], ['ch2', 'somebody', 'TWO'], ['ch3', 'zero', 'THREE']];
const SECRET = 'ASKYOURMOTHER';
const HOOK = ['BEHIND EVERY', 'ROUTER, A SECRET!'];
const VOICELESS = 'CONNECTED';
const SEQUEL = ['THE', 'REMOTE', 'AFFAIR'];
const SIGN = 'BARS';
const LABEL = ['NETWORK: APT 4B', 'READ THIS FIRST', 'PASSWORD'];
const FINE_PRINT = ['COMPLETE AND UNABRIDGED.', 'NOT ONE WORD CHANGED, UNLIKE THE PASSWORD.'];
const NOTES = ['eggs, milk', 'call mother'];
const STICKER = { knew: ['IT', 'KNEW!'], wrong: ['WRONG', 'PASSWORD'], note: ['THE NOTE THEY', 'DARE NOT MENTION!'], ask: ['ASK', 'YOUR', 'MOTHER!'], along: ['ALL', 'ALONG!'] };
const QUOTES: [string, string][] = [['GRIPPING!', 'said nobody'], ['I FOUND IT.', 'said me'], ['A TRIUMPH.', 'my roommate, unconfirmed'], ['TOLD YOU.', 'your mother']];
const STOCK = '#e8d5a8', YEL = '#f2c230', RED = '#b81d24', TEAL = '#12524f', INK = '#1b1410', ORANGE = '#e2701e', MINT = '#74ab9c', SKIN = '#dc9f68', PAGE = '#efe2bd';
const SLAB = '"Alfa Slab One", serif', SERIF = '"Cormorant Garamond", serif';
const FACES = [
{ family: 'Alfa Slab One', file: 'alfa-slab-one-latin-400-normal.woff2' },
{ family: 'Cormorant Garamond', file: 'cormorant-garamond-latin-600-italic.woff2', weight: '600', style: 'italic' },
{ family: 'Cormorant Garamond', file: 'cormorant-garamond-latin-700-normal.woff2', weight: '700' },
];
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
/** The slab runs about 0.76 em a letter: a font size that fits a word in a width, so a longer word still fits its panel. */
const fit = (word: string, width: number, max: number) => Math.min(max, width / (word.length * 0.76));
const pulse = (t: number, at: number, d: number) => (t >= at && t < at + d ? Math.sin(((t - at) / d) * Math.PI) : 0);
const Fill: React.FC<{ children: React.ReactNode; style?: React.CSSProperties }> = ({ children, style }) => <svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, ...style }}>{children}</svg>;
const Paint: React.FC<{ children: React.ReactNode }> = ({ children }) => <g filter="url(#paint)">{children}</g>;
/* ---------- the clock of the book itself ---------- */
/* A flip is [start, seconds, angle]. Odd half-turns show the back. The riffle swaps the faces while the pages hide it. */
const FLIPS: [number, number, number][] = [[E('turned') - 0.05, 0.55, 180], [E('riffle'), 0.001, 360], [E('gripping') - 0.45, 0.42, 540], [E('cta') - 0.05, 0.42, 720]];
const angleAt = (t: number) => FLIPS.reduce((a, [at, d, to]) => (t < at ? a : lerp(a, to, easeInOut(ramp(t, at, at + d)))), 0);
/** 1 while the rain has drained the ink. It comes back as the neon lights. */
const NEON = E('riffle') + 0.82, LIT = NEON + 1.44;
const LAST_BAR = E('zero') + 0.78;
const drainAt = (t: number) => ramp(t, E('ch3') + 0.2, E('rain') + 0.3) * (1 - ramp(t, NEON + 0.3, NEON + 1.6));
const HITS: [number, number][] = ([['ch1', 14], ['ch2', 14], ['knew', 6], ['password', 16], ['nobody', 8], ['mother', 16], ['ch3', 14], ['zero', 8], ['printed', 6], ['nobody2', 10], ['found', 8], ['soon', 6], ['pulp', 16]] as [string, number][]).map(([e, a]) => [E(e), a] as [number, number]).concat([[0.42, 12], [LIT, 7]]);
const shake = (t: number) => HITS.reduce((acc, [e, a]) => (t >= e ? acc + a * Math.sin((t - e) * 52) * Math.exp(-(t - e) * 10) : acc), 0);
/** How far each neon tube has lit: one at a time, each with a stutter. */
const tubeAt = (t: number, i: number) => {
const at = NEON + i * 0.36;
if (t < at) return i === 0 && t < LAST_BAR ? (hash(Math.floor(t * 13)) > 0.34 ? 0.8 : 0.12) : 0;
return t < at + 0.2 ? (hash(Math.floor(t * 30) + i) > 0.42 ? 1 : 0.1) : 1;
};
const signAt = (t: number) => (t < LIT ? 0 : t < LIT + 0.3 ? (hash(Math.floor(t * 30) + 9) > 0.45 ? 1 : 0.1) : 1);
const glowAt = (t: number) => (tubeAt(t, 0) * 0.15 + tubeAt(t, 1) * 0.15 + tubeAt(t, 2) * 0.2 + tubeAt(t, 3) * 0.2 + signAt(t) * 0.3) * ramp(t, NEON, NEON + 0.01);
/** The bars on the publisher's mark: full, one left after the blackout, none, then climbing back with the neon. */
const barsAt = (t: number) => (t < E('dark') ? 4 : t < LAST_BAR ? 1 : t < NEON ? 0 : [0, 1, 2, 3].reduce((n, i) => n + (tubeAt(t, i) > 0.5 ? 1 : 0), 0));
/* ---------- the black plate: real halftone, dots grow where the painting is dark ---------- */
const Halftone: React.FC<{ shade: (x: number, y: number) => number; color?: string; cell?: number }> = ({ shade, color = INK, cell = 11 }) => {
const ref = useRef<HTMLCanvasElement>(null);
const t = useT();
useLayoutEffect(() => {
const c = ref.current?.getContext('2d');
if (!c) return;
c.clearRect(0, 0, 1080, 1920);
c.fillStyle = color;
c.beginPath();
const dy = cell * Math.SQRT1_2, dx = cell * Math.SQRT2;
for (let j = 0, y = 0; y < 1930; j += 1, y += dy) {
for (let x = j % 2 ? dx / 2 : 0; x < 1090; x += dx) {
const d = shade(x, y);
if (d <= 0.015) continue;
const r = cell * 0.74 * Math.sqrt(Math.min(1, d));
c.moveTo(x + r, y);
c.arc(x, y, r, 0, 6.2832);
}
}
c.fill();
}, [t, shade, color, cell]);
return <canvas ref={ref} width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0 }} />;
};
/* ---------- figures ---------- */
const GUMSHOE = 'M102 98 C100 62 118 34 150 36 C186 34 204 60 204 98 Z M60 112 C84 92 222 90 246 108 C256 118 232 126 206 120 C170 112 130 114 96 122 C74 128 52 122 60 112 Z M120 118 L186 118 L182 170 L124 170 Z M182 150 C214 164 246 178 260 214 C276 276 282 370 276 452 L250 456 L268 650 C226 676 80 676 36 650 L52 456 L28 452 C22 372 26 280 42 214 C58 178 92 164 124 150 Z M92 660 L146 664 L134 818 L88 818 Z M164 664 L220 660 L220 818 L174 818 Z M84 812 L138 812 L140 840 L70 840 C70 826 76 818 84 812 Z M170 812 L224 812 C236 820 240 830 240 840 L170 840 Z';
/** The detective, in silhouette, with one rim of painted light. (x, y) is where his feet are. */
const Gumshoe: React.FC<{ x: number; y: number; s: number; rim: string; rot?: number; rimAt?: [number, number] }> = ({ x, y, s, rim, rot = 0, rimAt = [-7, -3] }) => (
<g transform={`translate(${x} ${y}) rotate(${rot}) scale(${s}) translate(-150 -840)`}>
<path d={GUMSHOE} fill={rim} transform={`translate(${rimAt[0]} ${rimAt[1]})`} />
<path d={GUMSHOE} fill={INK} />
<path d="M102 98 C130 90 176 90 204 98 L204 84 C176 76 130 76 102 84 Z" fill={RED} />
<path d="M46 392 C110 410 200 410 262 392 L262 414 C200 432 110 432 46 414 Z M152 180 L150 660 M96 196 C110 250 112 330 104 392 M214 196 C200 250 198 330 206 392 M84 430 C76 500 70 570 66 640 M226 430 C232 500 238 570 244 640" fill="none" stroke="#3d2c20" strokeWidth="6" />
</g>
);
const Burst: React.FC<{ size: number; fill: string; line?: string; n?: number; children: React.ReactNode }> = ({ size, fill, line = INK, n = 18, children }) => {
const pts = Array.from({ length: n * 2 }, (_, i) => { const r = (i % 2 ? 0.74 : 1) * size * 0.48, a = (i / (n * 2)) * Math.PI * 2; return `${size / 2 + r * Math.cos(a)},${size / 2 + r * Math.sin(a)}`; }).join(' ');
return (
<div style={{ position: 'relative', width: size, height: size }}>
<svg viewBox={`0 0 ${size} ${size}`} width={size} height={size} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible' }}>
<polygon points={pts} fill={INK} transform="translate(7 8)" />
<polygon points={pts} fill={fill} stroke={line} strokeWidth={6} strokeLinejoin="round" />
</svg>
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', fontFamily: SLAB }}>{children}</div>
</div>
);
};
/* ---------- plate 1: the wet Tuesday ---------- */
const WINDOWS = Array.from({ length: 25 }, (_, i) => ({ c: i % 5, r: Math.floor(i / 5), lit: hash(i + 9) > 0.2, off: hash(i + 51) * 0.42 }));
const Street: React.FC = () => {
const t = useT();
const dark = E('dark');
const arc = (i: number) => 1 - ramp(t, dark + (2 - i) * 0.09, dark + (2 - i) * 0.09 + 0.05);
return (
<Fill>
<defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0.28" stopColor={TEAL} /><stop offset="0.5" stopColor="#2b8778" /><stop offset="0.68" stopColor="#7fc2a6" /></linearGradient></defs>
<Paint>
<rect x="-40" y="-40" width="1160" height="2000" fill="url(#sky)" />
<path d="M-40 1120 L-40 900 L70 900 L70 820 L190 820 L190 960 L300 960 L300 760 L410 760 L410 1000 L560 1000 L560 1300 L-40 1300 Z" fill="#0f3d40" />
{[[100, 860], [130, 900], [335, 800], [370, 860], [335, 920], [230, 1000]].map(([x, y], i) => <rect key={i} x={x} y={y} width="20" height="28" fill={YEL} opacity={0.75 * (1 - ramp(t, dark + i * 0.03, dark + i * 0.03 + 0.05))} />)}
<path d="M480 900 L1120 840 L1120 1400 L480 1400 Z" fill="#0a1d21" />
<path d="M480 900 L512 897 L512 1400 L480 1400 Z" fill={RED} />
{WINDOWS.map((w, i) => {
const on = w.lit && t < dark + w.off;
const x = 552 + w.c * 102, y = 948 + w.r * 86 - w.c * 7;
return <g key={i}><rect x={x} y={y} width="62" height="66" fill={on ? YEL : '#16383c'} />{on ? <rect x={x} y={y} width="62" height="16" fill={ORANGE} /> : null}<rect x={x + 29} y={y} width="4" height="66" fill={INK} /></g>;
})}
<path d="M756 880 L772 756 L788 880 Z" fill={INK} /><circle cx="772" cy="752" r="17" fill={arc(0) > 0.5 ? YEL : '#225a5a'} />
{[0, 1, 2].map((i) => { const r = 58 + i * 50; return <path key={i} d={`M${772 - r * 0.72} ${748 - r * 0.69} A${r} ${r} 0 0 1 ${772 + r * 0.72} ${748 - r * 0.69}`} fill="none" stroke={arc(i) > 0.5 ? YEL : '#225a5a'} strokeWidth="22" strokeLinecap="round" />; })}
<rect x="-40" y="1340" width="1160" height="640" fill="#1b5a58" />
{Array.from({ length: 12 }, (_, i) => <rect key={i} x={540 + i * 48} y={1350 + hash(i) * 40} width={14 + hash(i + 3) * 18} height={120 + hash(i + 5) * 360} fill={YEL} opacity={0.12 + 0.6 * (1 - ramp(t, dark, dark + 0.4))} />)}
</Paint>
<Gumshoe x={250} y={1660} s={1.0} rim={t < dark ? YEL : MINT} rimAt={[7, -3]} />
</Fill>
);
};
const streetShade = (t: number) => {
const k = ramp(t, E('dark'), E('dark') + 0.45);
return (x: number, y: number) => clamp(0.05 + 0.26 * k + 0.22 * ramp(y, 1300, 1900) + 0.2 * (1 - ramp(y, 560, 880)));
};
/* ---------- plate 2: the router knows ---------- */
const lidAt = (t: number) => Math.max(pulse(t, E('blink'), 0.24), pulse(t, E('blink') + 0.44, 0.24), 0.5 * ramp(t, E('knew') - 0.05, E('knew') + 0.2));
const RouterEye: React.FC<{ x: number; y: number; id: string; lid: number; look: number }> = ({ x, y, id, lid, look }) => (
<g>
<clipPath id={id}><circle cx={x} cy={y} r="78" /></clipPath>
<circle cx={x} cy={y} r="88" fill={INK} /><circle cx={x} cy={y} r="78" fill={YEL} /><circle cx={x - 18} cy={y - 26} r="30" fill="#fff0b0" opacity="0.75" />
<circle cx={x + look} cy={y + 8} r="31" fill={RED} /><circle cx={x + look} cy={y + 8} r="15" fill={INK} />
<g clipPath={`url(#${id})`}><rect x={x - 90} y={y - 90} width="180" height={2 + lid * 178} fill="#0b3438" /><rect x={x - 90} y={y - 96 + lid * 178} width="180" height="9" fill={INK} /></g>
</g>
);
const Router: React.FC = () => {
const t = useT();
const lid = lidAt(t);
const look = lerp(0, 26, ramp(t, E('knew') - 0.05, E('knew') + 0.2));
return (
<Fill>
<Paint>
<rect x="-40" y="-40" width="1160" height="2000" fill={RED} />
{Array.from({ length: 16 }, (_, i) => { const a = (i / 16) * Math.PI * 2 + t * 0.06, b = a + 0.2; return <path key={i} d={`M540 930 L${540 + 1700 * Math.cos(a)} ${930 + 1700 * Math.sin(a)} L${540 + 1700 * Math.cos(b)} ${930 + 1700 * Math.sin(b)} Z`} fill={ORANGE} />; })}
<rect x="-40" y="1190" width="1160" height="800" fill={RED} /><rect x="-40" y="1180" width="1160" height="14" fill={INK} />
{[[330, 840, 240, 628], [540, 830, 540, 610], [750, 840, 840, 628]].map(([a, b, c, d], i) => <g key={i}><path d={`M${a} ${b} L${c} ${d}`} stroke={INK} strokeWidth="38" strokeLinecap="round" /><path d={`M${a - 10} ${b} L${c - 10} ${d}`} stroke="#2b7d73" strokeWidth="9" strokeLinecap="round" /></g>)}
<path d="M216 800 L864 800 L960 900 L120 900 Z" fill="#2b7d73" /><path d="M120 900 L960 900 L938 1200 L142 1200 Z" fill="#14565a" />
<path d="M120 900 L960 900 L956 930 L124 930 Z" fill={MINT} /><path d="M142 1200 L938 1200 L942 1156 L138 1156 Z" fill={ORANGE} />
{Array.from({ length: 11 }, (_, i) => <rect key={i} x={262 + i * 53} y="1092" width="28" height="56" rx="5" fill={INK} />)}
<RouterEye x={356} y={1012} id="eyeL" lid={lid} look={look} /><RouterEye x={724} y={1012} id="eyeR" lid={lid} look={look} />
</Paint>
{[0, 1, 2, 3, 4].map((i) => <circle key={i} cx={470 + i * 35} cy={968} r="9" fill={hash(Math.floor(t * 7) + i * 5) > 0.45 ? '#ffe88a' : '#123c40'} />)}
<Gumshoe x={880} y={1296} s={0.42} rim={YEL} />
</Fill>
);
};
const routerShade = () => (x: number, y: number) => clamp(ramp(Math.hypot(x - 540, y - 1020), 420, 1200) * 0.58);
/* ---------- plate 3: somebody, in curlers ---------- */
const SHADOW = 'M250 660 m-96 0 a96 104 0 1 0 192 0 a96 104 0 1 0 -192 0 M116 590 a34 34 0 1 0 68 0 a34 34 0 1 0 -68 0 M176 548 a34 34 0 1 0 68 0 a34 34 0 1 0 -68 0 M244 538 a34 34 0 1 0 68 0 a34 34 0 1 0 -68 0 M306 566 a34 34 0 1 0 68 0 a34 34 0 1 0 -68 0 M60 650 a30 30 0 1 0 60 0 a30 30 0 1 0 -60 0 M200 750 L200 830 L300 830 L300 750 Z M120 810 C70 830 -10 870 -60 980 L-90 1500 L580 1500 L520 1040 C500 980 470 950 440 930 L690 836 L748 822 L752 806 L690 806 L748 786 L744 770 L680 786 L730 752 L720 738 L660 774 L690 728 L672 722 L620 780 L380 806 C340 800 300 806 260 806 Z';
const Shadow: React.FC = () => {
const t = useT();
const k = easeOut(ramp(t, E('ch2') + 0.25, E('changed') + 0.1));
const bad = t >= E('changed') + 0.1;
return (
<Fill>
<defs><linearGradient id="wall" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="#ffd84f" /><stop offset="0.55" stopColor={YEL} /><stop offset="1" stopColor={ORANGE} /></linearGradient></defs>
<Paint>
<rect x="-40" y="-40" width="1160" height="2000" fill="url(#wall)" />
{Array.from({ length: 14 }, (_, i) => <rect key={i} x={i * 84 - 20} y="-40" width="10" height="1400" fill={ORANGE} opacity="0.32" />)}
<rect x="-40" y="1260" width="1160" height="26" fill={RED} /><rect x="-40" y="1286" width="1160" height="800" fill="#b5451a" />
<g transform={`translate(${lerp(-300, 0, k)} ${lerp(60, 0, k) + 120})`}>
<path d={SHADOW} fill={RED} opacity="0.75" transform="translate(26 18)" /><path d={SHADOW} fill={INK} />
</g>
<rect x="640" y="1030" width="440" height="26" fill={INK} />
<path d="M730 910 L700 780 M880 910 L910 780" stroke={INK} strokeWidth="16" strokeLinecap="round" />
<rect x="690" y="910" width="240" height="120" rx="12" fill={TEAL} /><rect x="690" y="910" width="240" height="20" rx="8" fill={MINT} />
</Paint>
{[0, 1, 2, 3].map((i) => <circle key={i} cx={730 + i * 50} cy={978} r="12" fill={bad ? (hash(Math.floor(t * 9) + i) > 0.4 ? '#ff4136' : '#5a1512') : YEL} />)}
</Fill>
);
};
const shadowShade = () => (x: number, y: number) => clamp(ramp(Math.hypot(x - 40, y - 1000), 620, 1500) * 0.6);
/* ---------- plate 4: the note nobody talks about ---------- */
const BEAM: [number, number] = [176, 1240];
const beamTo = (t: number): [number, number] => { const k = easeInOut(ramp(t, E('note') + 0.15, E('gone') + 0.05)); return [lerp(860, 590, k) + Math.sin(t * 5.3) * 7, lerp(740, 900, k) + Math.cos(t * 4.1) * 7]; };
const NoteHint: React.FC<{ x: number; y: number; r: number; text: string; pin: string }> = ({ x, y, r, text, pin }) => (
<div style={{ position: 'absolute', left: x, top: y, width: 246, height: 150, background: PAGE, transform: `rotate(${r}deg)`, boxShadow: `5px 7px 0 rgba(10,20,20,.45)`, font: `italic 600 42px/48px ${SERIF}`, color: INK, padding: '38px 16px 0', boxSizing: 'border-box', textAlign: 'center' }}>
{text}
<div style={{ position: 'absolute', left: 104, top: -16, width: 38, height: 38, borderRadius: '50%', background: pin, boxShadow: `3px 4px 0 ${INK}` }} />
</div>
);
const Fridge: React.FC = () => {
const t = useT();
const [tx, ty] = beamTo(t);
const a0 = Math.atan2(ty - BEAM[1], tx - BEAM[0]), far = 1500;
const outline = ramp(t, E('gone'), E('gone') + 0.35);
return (
<>
<Fill>
<defs><linearGradient id="enamel" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stopColor="#5a9f90" /><stop offset="0.6" stopColor="#8cc4b0" /><stop offset="1" stopColor="#3f8378" /></linearGradient></defs>
<Paint>
<rect x="-40" y="-40" width="1160" height="2000" fill="#7c2a14" />
<rect x="96" y="440" width="930" height="1600" rx="60" fill="url(#enamel)" stroke={INK} strokeWidth="10" />
<rect x="862" y="820" width="48" height="430" rx="22" fill={STOCK} /><rect x="872" y="830" width="12" height="410" rx="6" fill="#fff4cf" /><rect x="896" y="830" width="10" height="410" rx="5" fill="#7a6a42" />
<rect x="96" y="640" width="930" height="12" fill={INK} /><rect x="96" y="652" width="930" height="10" fill="#3f8378" />
<rect x="404" y="708" width="366" height="366" fill="#3f8378" /><rect x="412" y="716" width="350" height="350" fill="#e6f7e8" />
<path d="M412 716 L470 716 L456 738 L440 730 L412 762 Z" fill={YEL} />
</Paint>
<circle cx="426" cy="732" r="20" fill={RED} /><circle cx="420" cy="726" r="6" fill="#ff8a7a" />
<path d="M412 716 L762 716 L762 1066 L412 1066 Z" fill="none" stroke={RED} strokeWidth="10" strokeDasharray="30 18" pathLength={1400} opacity={outline} />
<polygon points={`${BEAM[0]},${BEAM[1]} ${BEAM[0] + far * Math.cos(a0 - 0.3)},${BEAM[1] + far * Math.sin(a0 - 0.3)} ${BEAM[0] + far * Math.cos(a0 + 0.3)},${BEAM[1] + far * Math.sin(a0 + 0.3)}`} fill="#fff0a8" opacity="0.22" />
</Fill>
<NoteHint x={136} y={760} r={-4} text={NOTES[0]} pin={TEAL} />
<NoteHint x={136} y={960} r={3} text={NOTES[1]} pin={ORANGE} />
<div style={{ position: 'absolute', left: 412, top: 730, width: 350, textAlign: 'center', font: `280px/320px ${SLAB}`, color: RED, opacity: outline }}>?</div>
<Fill>
<Paint><path d="M-40 1320 L-40 1190 L90 1200 C120 1186 160 1190 196 1210 L210 1250 C180 1290 130 1300 90 1296 Z" fill={INK} /></Paint>
<circle cx={BEAM[0]} cy={BEAM[1]} r="26" fill="#fff0a8" />
</Fill>
</>
);
};
const fridgeShade = (t: number) => {
const [tx, ty] = beamTo(t);
const a0 = Math.atan2(ty - BEAM[1], tx - BEAM[0]);
return (x: number, y: number) => clamp(0.3 - 0.3 * (1 - ramp(Math.abs(Math.atan2(y - BEAM[1], x - BEAM[0]) - a0), 0.27, 0.33))) * (x < 400 && y > 740 && y < 1130 ? 0.12 : 1);
};
/* ---------- plate 5: leaning on the roommate ---------- */
const bulbAt = (t: number): [number, number] => { const a = Math.sin(t * 2.5) * 0.26; return [500 + Math.sin(a) * 330, 500 + Math.cos(a) * 330]; };
const Room: React.FC = () => {
const t = useT();
const [bx, by] = bulbAt(t);
const inn = easeOut(ramp(t, E('leaned') - 0.1, E('leaned') + 0.35));
const talk = t >= E('said') && t < E('mother') + 0.7 && Math.floor(t * 9) % 2 === 0;
const sh = t >= E('mother') ? pulse(t, E('mother'), 0.3) * 8 : 0;
return (
<Fill>
<defs><radialGradient id="roomwall" cx="0.5" cy="0.4" r="0.7"><stop offset="0" stopColor="#f09a3c" /><stop offset="1" stopColor={RED} /></radialGradient></defs>
<Paint>
<rect x="-40" y="-40" width="1160" height="2000" fill="url(#roomwall)" />
<rect x="-40" y="1270" width="1160" height="700" fill="#8a3414" />
<path d="M460 1010 C470 960 1020 950 1120 980 L1120 1300 L460 1300 Z" fill={RED} />
<path d="M480 1040 C560 1020 620 1020 640 1040 M1090 1020 C1000 1000 930 1010 880 1030" fill="none" stroke="#6a1014" strokeWidth="10" />
<g transform={`translate(${sh} 0)`}>
<path d="M590 1250 L606 1030 C640 990 830 990 868 1030 L900 1250 Z" fill={STOCK} />
{[1064, 1110, 1156, 1202].map((y) => <rect key={y} x="596" y={y} width="296" height="16" fill={TEAL} opacity="0.85" />)}
<rect x="694" y="950" width="84" height="84" fill="#b9743f" /><ellipse cx="736" cy="890" rx="88" ry="100" fill={SKIN} />
<path d="M736 790 C800 790 824 840 824 890 C824 950 790 990 736 990 C790 950 790 830 736 790 Z" fill="#b9743f" />
<path d="M644 882 C620 770 830 750 830 880 C810 846 780 838 766 852 C742 824 700 834 682 856 C664 852 652 864 644 882 Z" fill="#2c1a10" />
<ellipse cx="700" cy="886" rx="24" ry="20" fill="#fff4cf" /><ellipse cx="774" cy="886" rx="24" ry="20" fill="#fff4cf" /><circle cx="692" cy="890" r="10" fill={INK} /><circle cx="766" cy="890" r="10" fill={INK} />
<path d="M672 858 L724 852 M750 852 L800 860" stroke="#2c1a10" strokeWidth="9" strokeLinecap="round" /><path d="M728 900 L716 932 L738 934" fill="none" stroke="#8a5428" strokeWidth="6" strokeLinecap="round" />
{talk ? <ellipse cx="734" cy="956" rx="24" ry="17" fill="#3c130d" /> : <path d="M708 956 L760 952" stroke="#3c130d" strokeWidth="8" strokeLinecap="round" />}
<path d="M620 1040 C570 1090 560 1150 600 1180 L680 1160 L660 1130 L642 1090 Z" fill={SKIN} />
<path d="M556 1124 C576 1198 700 1198 716 1124 Z" fill={TEAL} /><path d="M556 1124 L716 1124 L708 1140 L564 1140 Z" fill={MINT} />
<path d="M640 1124 L610 1000" stroke={STOCK} strokeWidth="9" strokeLinecap="round" /><ellipse cx="606" cy="990" rx="16" ry="22" fill={STOCK} />
</g>
<rect x="440" y="1196" width="700" height="140" rx="26" fill="#d42a30" /><rect x="440" y="1196" width="700" height="18" rx="9" fill="#f0603a" />
<g transform={`translate(${lerp(-160, 0, inn)} 0)`}>
<path d="M320 800 C400 830 500 950 516 1040 L470 1078 C420 1010 340 950 270 920 Z" fill={INK} />
<Gumshoe x={150} y={1660} s={1.28} rim={YEL} rot={9} rimAt={[8, -4]} />
</g>
</Paint>
<path d={`M500 500 L${bx} ${by}`} stroke={INK} strokeWidth="7" />
<circle cx={bx} cy={by + 28} r="62" fill="#ffe88a" opacity="0.32" /><circle cx={bx} cy={by + 28} r="30" fill="#fff4cf" />
</Fill>
);
};
const roomShade = (t: number) => {
const [bx, by] = bulbAt(t);
return (x: number, y: number) => clamp(ramp(Math.hypot(x - bx * 0.6 - 300, (y - by - 120) * 0.8), 330, 1000) * 0.72);
};
/* ---------- plate 6: the alley, and the sign that is the signal ---------- */
const VP: [number, number] = [540, 1090];
const wallLines = (x0: number, x1: number) => Array.from({ length: 13 }, (_, i) => { const y0 = 520 + i * 104; const k = Math.abs(x1 - x0) / 540; return `M${x0} ${y0} L${x1} ${y0 + (VP[1] - y0) * k}`; }).join(' ');
const WALL_L = wallLines(-40, 410), WALL_R = wallLines(1120, 670);
const REFLECT = Array.from({ length: 11 }, (_, i) => ({ x: 610 + i * 36 + hash(i) * 14, w: 12 + hash(i + 4) * 22, y: 1310 + hash(i + 2) * 90, h: 180 + hash(i + 8) * 400, c: i % 3 ? '#ff3b30' : YEL }));
const Tube: React.FC<{ i: number; on: number }> = ({ i, on }) => {
const h = 42 + i * 40, x = 628 + i * 56, y = 792 - h;
return on > 0.3 ? (
<g><rect x={x - 13} y={y - 13} width="60" height={h + 26} rx="24" fill="#ff3b30" opacity={0.26 * on} /><rect x={x} y={y} width="34" height={h} rx="12" fill="#ff5a48" opacity={on} /><rect x={x + 10} y={y + 8} width="14" height={h - 16} rx="7" fill="#ffe8cf" opacity={on} /></g>
) : <rect x={x} y={y} width="34" height={h} rx="12" fill="#587674" stroke="#0d1c1f" strokeWidth="4" />;
};
const Alley: React.FC = () => {
const t = useT();
const glow = glowAt(t);
const siren = Math.pow(Math.sin(t * 2.6), 2) * (1 - glow);
const sign = signAt(t);
return (
<>
<Fill>
<defs><linearGradient id="gap" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#2f6e72" /><stop offset="1" stopColor="#9cc6b6" /></linearGradient></defs>
<Paint>
<rect x="-40" y="-40" width="1160" height="2000" fill="#123c40" />
<rect x="400" y="480" width="280" height="760" fill="url(#gap)" />
<rect x="400" y="1040" width="280" height="200" fill="#d9361f" opacity={0.2 + 0.5 * siren} />
<path d="M-40 440 L410 860 L410 1250 L-40 1720 Z" fill="#2a6f6e" /><path d="M1120 440 L670 860 L670 1250 L1120 1720 Z" fill="#7a3b2a" />
<path d={WALL_L} stroke="#134446" strokeWidth="9" fill="none" /><path d={WALL_R} stroke="#4a2118" strokeWidth="9" fill="none" />
<path d="M60 760 L300 880 L60 1000 L300 1120 L60 1240 M60 730 L60 1270 M300 860 L300 1140" fill="none" stroke={INK} strokeWidth="12" />
<path d="M-40 1720 L410 1250 L670 1250 L1120 1720 L1120 1980 L-40 1980 Z" fill="#3a6c6a" />
{Array.from({ length: 9 }, (_, i) => <path key={i} d={`M${540 + (i - 4) * 28} 1250 L${540 + (i - 4) * 210} 1980`} stroke="#0f2a2c" strokeWidth="7" />)}
<path d="M300 1330 L300 790 C300 740 360 730 392 748" fill="none" stroke={INK} strokeWidth="18" />
<rect x="590" y="602" width="370" height="300" rx="14" fill="#1d3336" stroke={INK} strokeWidth="10" />
<path d="M960 660 L1120 620 M960 840 L1120 860" stroke={INK} strokeWidth="14" />
</Paint>
<polygon points="396,756 240,1300 610,1300" fill="#ffe88a" opacity={0.15 + 0.1 * (hash(Math.floor(t * 8)) > 0.2 ? 1 : 0)} /><ellipse cx="396" cy="756" rx="26" ry="16" fill="#fff0b0" />
{[0, 1, 2, 3].map((i) => <Tube key={i} i={i} on={tubeAt(t, i)} />)}
{REFLECT.map((r, i) => <rect key={i} x={r.x + Math.sin(t * 2 + i) * 3} y={r.y} width={r.w} height={r.h} fill={r.c} opacity={glow * 0.55} />)}
<path d="M1120 440 L670 860 L670 1250 L1120 1720 Z" fill="#ff3b30" opacity={glow * 0.34} /><path d="M-40 1720 L410 1250 L670 1250 L1120 1720 L1120 1980 L-40 1980 Z" fill="#ff3b30" opacity={glow * 0.2} />
<Gumshoe x={540} y={1290} s={0.46} rim={glow > 0.5 ? '#ff5a48' : '#7fb3a6'} rimAt={[8, -3]} />
</Fill>
<div style={{ position: 'absolute', left: 590, top: 800, width: 370, textAlign: 'center', font: `84px/92px ${SLAB}`, letterSpacing: '.03em', color: sign > 0.3 ? '#ffe8cf' : '#587674', textShadow: sign > 0.3 ? '0 0 22px #ff3b30, 0 0 6px #ff3b30' : 'none' }}>{SIGN}</div>
</>
);
};
const alleyShade = (t: number) => {
const glow = glowAt(t);
const lamp = t >= E('zero') && t < NEON ? (hash(Math.floor(t * 8)) > 0.2 ? 0.75 : 0.35) : 1;
return (x: number, y: number) => {
const pool = 1 - ramp(Math.hypot(x - 470, (y - 1090) * 0.62), 90, 430);
const base = clamp(0.36 + 0.08 * ramp(y, 1300, 1900) - 0.34 * pool * lamp);
return base * (1 - glow * (1 - ramp(Math.hypot(x - 770, (y - 900) * 0.72), 130, 880)) * 0.94);
};
};
/* ---------- the plates in order: a new one prints in through growing dots ---------- */
const DISSOLVE = 0.3;
const PLATES: { at: number; C: React.FC; shade: (t: number) => (x: number, y: number) => number; fx: number; fy: number; rain: number }[] = [
{ at: -1, C: Street, shade: streetShade, fx: 700, fy: 1000, rain: 0.8 },
{ at: E('ch1') + 0.04, C: Router, shade: routerShade, fx: 540, fy: 980, rain: 0 },
{ at: E('ch2') + 0.04, C: Shadow, shade: shadowShade, fx: 700, fy: 850, rain: 0 },
{ at: E('note'), C: Fridge, shade: fridgeShade, fx: 590, fy: 890, rain: 0 },
{ at: E('witness'), C: Room, shade: roomShade, fx: 620, fy: 900, rain: 0 },
{ at: E('ch3') + 0.04, C: Alley, shade: alleyShade, fx: 770, fy: 760, rain: 1 },
];
const plateIndex = (t: number) => PLATES.reduce((a, p, i) => (t >= p.at ? i : a), 0);
const Plates: React.FC = () => {
const t = useT();
return (
<>
{PLATES.map((p, i) => {
const next = PLATES[i + 1];
if (t < p.at || (next && t >= next.at + DISSOLVE)) return null;
const k = ramp(t, p.at, p.at + DISSOLVE);
const mask = k < 1 ? `radial-gradient(circle, #000 ${k * 100}%, transparent ${k * 100 + 3}%)` : undefined;
const push = ramp(t, p.at, next ? next.at : p.at + 12);
return <div key={i} style={{ position: 'absolute', inset: 0, overflow: 'hidden', maskImage: mask, maskSize: '30px 30px', transform: `scale(${1 + 0.05 * push})`, transformOrigin: `${p.fx}px ${p.fy}px` }}><p.C /></div>;
})}
</>
);
};
/** Long dry brush streaks over the art, so flat shapes read as gouache. */
const Brush: React.FC = () => (
<Fill style={{ mixBlendMode: 'soft-light', opacity: 0.26 }}>
<filter id="brush" x="0" y="0" width="1" height="1" colorInterpolationFilters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.0032 0.075" numOctaves="4" seed="11" />
<feColorMatrix type="matrix" values="2.2 0 0 0 -0.6 2.2 0 0 0 -0.6 2.2 0 0 0 -0.6 0 0 0 0 1" />
</filter>
<rect x="-500" y="-500" width="2100" height="2900" filter="url(#brush)" transform="rotate(-16 540 960)" />
</Fill>
);
const ArtShade: React.FC = () => {
const t = useT();
const i = plateIndex(t);
const k = ramp(t, PLATES[i].at, PLATES[i].at + DISSOLVE);
const cur = PLATES[i].shade(t);
if (k >= 1 || i === 0) return <Halftone shade={cur} />;
const prev = PLATES[i - 1].shade(t);
return <Halftone shade={(x, y) => prev(x, y) + (cur(x, y) - prev(x, y)) * k} />;
};
const RAIN = Array.from({ length: 110 }, (_, i) => ({ x: hash(i + 5) * 1300 - 100, sp: 1100 + hash(i + 77) * 700, ph: hash(i + 150) * 2300, l: 50 + hash(i + 220) * 110, o: 0.3 + hash(i + 300) * 0.6 }));
const Rain: React.FC = () => {
const t = useT();
const amount = PLATES[plateIndex(t)].rain * (0.55 + 0.45 * ramp(t, E('rain') - 0.1, E('rain') + 0.3) * (1 - ramp(t, NEON + 0.5, NEON + 2)));
if (amount <= 0) return null;
return <Fill style={{ opacity: amount }}>{RAIN.map((r, i) => { const y = ((r.ph + t * r.sp) % 2300) - 180; return <line key={i} x1={r.x} y1={y} x2={r.x - r.l * 0.16} y2={y + r.l} stroke="#d7efe6" strokeWidth={3} opacity={r.o * 0.7} strokeLinecap="round" />; })}</Fill>;
};
/* ---------- the masthead: imprint, title, byline ---------- */
const SKEW = -3.3;
const ImprintMark: React.FC = () => {
const t = useT();
const bars = barsAt(t);
return (
<div style={{ position: 'absolute', left: 74, top: 58, display: 'flex', alignItems: 'center', gap: 22 }}>
<svg viewBox="-50 -50 100 100" width={96} height={96} style={{ overflow: 'visible' }}>
<rect x="-35" y="-35" width="70" height="70" fill={RED} stroke={YEL} strokeWidth="4" transform="rotate(45)" />
{[0, 1, 2, 3].map((i) => <rect key={i} x={-23 + i * 12} y={18 - (10 + i * 9)} width="9" height={10 + i * 9} fill={i < bars ? YEL : '#6a1014'} />)}
</svg>
<div style={{ font: `32px/36px ${SLAB}`, color: STOCK, letterSpacing: '.05em' }}>{IMPRINT}</div>
</div>
);
};
const Masthead: React.FC = () => {
const t = useT();
const k = back(ramp(t, 0, 0.42));
const drain = drainAt(t);
return (
<>
<Fill>
<defs><linearGradient id="mast" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#08262a" /><stop offset="1" stopColor={TEAL} /></linearGradient></defs>
<path d="M-10 -10 L1090 -10 L1090 520 L-10 583 Z" fill="url(#mast)" />
</Fill>
<div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 590, opacity: 0.5, clipPath: 'polygon(0 0,100% 0,100% 88%,0 99%)', background: `radial-gradient(circle, ${INK} 34%, transparent 38%) 0 0/11px 11px` }} />
<ImprintMark />
<div style={{ position: 'absolute', left: 852, top: 44, width: 138, height: 138, borderRadius: '50%', background: RED, border: `5px solid ${YEL}`, boxSizing: 'border-box', color: YEL, textAlign: 'center', transform: 'rotate(-10deg)', paddingTop: 14 }}><div style={{ font: `30px/32px ${SLAB}` }}>{DISC[0]}</div><div style={{ font: `50px/54px ${SLAB}` }}>{DISC[1]}</div></div>
<div style={{ position: 'absolute', left: 64, top: 176, width: 1000, whiteSpace: 'nowrap', transform: `rotate(${SKEW}deg) scale(${lerp(1.1, 1, k)})`, transformOrigin: '20% 60%', color: interpolateColors(drain, [0, 1], [YEL, '#cdbf8d']), textShadow: `5px 5px 0 ${RED}, 9px 9px 0 ${INK}` }}>
<div style={{ font: `${fit(TITLE[0], 960, 116)}px/124px ${SLAB}` }}>{TITLE[0]}</div>
<div style={{ font: `${fit(TITLE[1], 960, 146)}px/150px ${SLAB}` }}>{TITLE[1]}</div>
</div>
<div style={{ position: 'absolute', right: 112, top: 436, transform: `rotate(${SKEW}deg)`, font: `italic 600 52px/56px ${SERIF}`, color: STOCK, whiteSpace: 'nowrap' }}>{BYLINE}</div>
</>
);
};
/* The red ribbon on the masthead's edge carries the series line, then each chapter as it lands. */
const Ribbon: React.FC = () => {
const t = useT();
const done = CHAPTERS.filter(([, out]) => t >= E(out) + 0.12).pop();
const text = t >= E('riffle') ? END_RIBBON : done ? `CHAPTER ${done[2]}${t >= E('zero') ? LOW_RIBBON : ''}` : SERIES;
return <div style={{ position: 'absolute', left: -40, top: 526, width: 1160, height: 68, background: RED, boxShadow: `0 7px 0 ${INK}`, transform: `rotate(${SKEW}deg)`, color: YEL, font: `40px/68px ${SLAB}`, letterSpacing: '.06em', textAlign: 'center' }}>{text}</div>;
};
/** Each chapter slams across the art, big, then shrinks into the ribbon. The plate changes behind it. */
const ChapterSlam: React.FC = () => {
const t = useT();
return (
<>
{CHAPTERS.map(([inn, out, name]) => {
const a = E(inn), b = E(out) - 0.1;
if (t < a - 0.07 || t >= b + 0.2) return null;
const land = easeIn(ramp(t, a - 0.07, a + 0.05));
const leave = easeIn(ramp(t, b, b + 0.2));
return (
<div key={inn} style={{ position: 'absolute', left: 505 - 400, top: 890 - 230, width: 800, height: 460, background: RED, boxShadow: `inset 0 0 0 9px ${INK}, inset 0 0 0 19px ${YEL}, 12px 14px 0 ${INK}`, textAlign: 'center', transform: `translate(${leave * 35}px, ${leave * -360}px) rotate(${-5 + leave * 1.7}deg) scale(${lerp(2.5, 1, land) * lerp(1, 0.1, leave)})`, color: YEL }}>
<div style={{ font: `82px/90px ${SLAB}`, marginTop: 44 }}>CHAPTER</div>
<div style={{ font: `${fit(name, 720, 190)}px/210px ${SLAB}`, textShadow: `7px 7px 0 ${INK}`, whiteSpace: 'nowrap' }}>{name}</div>
</div>
);
})}
</>
);
};
/* ---------- stickers: each lands big, shrinks to the margin, and the rain takes them all ---------- */
type Pose = [number, number, number, number];
const Sticker: React.FC<{ at: number; hero: Pose; rest: Pose; hold: number; seed: number; children: React.ReactNode }> = ({ at, hero, rest, hold, seed, children }) => {
const t = useT();
if (t < at - 0.08) return null;
const land = easeIn(ramp(t, at - 0.08, at + 0.04));
const to = easeInOut(ramp(t, at + hold, at + hold + 0.3));
const off = E('ch3') + 0.02 + hash(seed) * 0.22;
const wash = easeIn(ramp(t, off, off + 0.8));
if (wash >= 1) return null;
const p = hero.map((v, i) => lerp(v, rest[i], to));
return <div style={{ position: 'absolute', left: p[0], top: p[1] + wash * 1600, transform: `translate(-50%,-50%) rotate(${p[3] + wash * (hash(seed + 3) * 60 - 30)}deg) scale(${p[2] * lerp(2.3, 1, land)})`}}>{children}</div>;
};
const Stickers: React.FC = () => (
<>
<Sticker at={E('knew')} hero={[770, 700, 1, 10]} rest={[880, 700, 0.6, 10]} hold={1.6} seed={1}><Burst size={340} fill={YEL}><div style={{ font: `62px/66px ${SLAB}`, color: RED }}>{STICKER.knew[0]}<br />{STICKER.knew[1]}</div></Burst></Sticker>
<Sticker at={E('password')} hero={[560, 1130, 1, -8]} rest={[215, 690, 0.42, -8]} hold={0.85} seed={2}><div style={{ background: STOCK, border: `12px solid ${RED}`, boxShadow: `8px 9px 0 ${INK}`, color: RED, font: `84px/90px ${SLAB}`, padding: '12px 30px', textAlign: 'center', whiteSpace: 'nowrap' }}>{STICKER.wrong[0]}<br />{STICKER.wrong[1]}</div></Sticker>
<Sticker at={E('nobody')} hero={[560, 1140, 1, -4]} rest={[320, 1225, 0.62, -4]} hold={1.3} seed={3}><div style={{ background: RED, color: YEL, font: `56px/62px ${SLAB}`, padding: '20px 38px', textAlign: 'center', whiteSpace: 'nowrap', boxShadow: `inset 0 0 0 6px ${INK}, 9px 10px 0 ${INK}` }}>{STICKER.note[0]}<br />{STICKER.note[1]}</div></Sticker>
<Sticker at={E('mother')} hero={[470, 770, 1, -5]} rest={[470, 770, 1, -5]} hold={9} seed={4}><Burst size={430} fill={RED} line={YEL} n={22}><div style={{ font: `66px/70px ${SLAB}`, color: YEL, textShadow: `4px 4px 0 ${INK}` }}>{STICKER.ask[0]}<br />{STICKER.ask[1]}<br />{STICKER.ask[2]}</div></Burst></Sticker>
</>
);
/* ---------- ink runs: the title bleeds down the cover in the rain ---------- */
const DRIPS = Array.from({ length: 24 }, (_, i) => ({ x: 70 + hash(i + 17) * 900, y: 280 + hash(i + 23) * 230, w: 7 + hash(i + 31) * 17, l: 240 + hash(i + 41) * 640, d: hash(i + 47) * 0.7, c: i % 3 === 0 ? RED : i % 3 === 1 ? YEL : TEAL }));
const InkRuns: React.FC = () => {
const t = useT();
if (t < E('zero') || t >= E('riffle') + 0.3) return null;
return (
<Fill>
{DRIPS.map((d, i) => {
const k = easeOut(ramp(t, E('zero') + d.d, E('zero') + d.d + 2.6));
return k > 0 ? <g key={i} opacity="0.86"><rect x={d.x} y={d.y} width={d.w} height={k * d.l} rx={d.w / 2} fill={d.c} /><circle cx={d.x + d.w / 2} cy={d.y + k * d.l} r={d.w * 0.72} fill={d.c} /></g> : null;
})}
</Fill>
);
};
/* ---------- wear: what forty years in a coat pocket does ---------- */
const flecks = (n: number, seed: number) => Array.from({ length: n }, (_, i) => `M${(hash(i * 3 + seed) * 1080).toFixed(0)} ${(hash(i * 7 + seed + 1) * 1920).toFixed(0)}l${(hash(i + seed + 2) * 4).toFixed(1)} ${((hash(i + seed + 3) - 0.5) * 3).toFixed(1)}`).join('');
const FLECKS = flecks(420, 3), FOX = flecks(150, 811);
/** A crack: broken, wandering, with a thin shadow. Printed ink splits like this along a spine or a fold. */
const crack = (x0: number, y0: number, x1: number, y1: number, seed: number) => Array.from({ length: 34 }, (_, i) => { const k = i / 34; const j = (hash(i + seed) - 0.5) * 7; return `${hash(i * 5 + seed) > 0.72 ? 'M' : i ? 'L' : 'M'}${(lerp(x0, x1, k) + j).toFixed(1)} ${(lerp(y0, y1, k) + j).toFixed(1)}`; }).join(' ');
const FOLDS = [crack(880, 30, 800, 620, 71), crack(40, 1130, 430, 1190, 91), crack(1030, 1080, 700, 1500, 113)].join(' ');
const Wear: React.FC<{ spine: number }> = ({ spine }) => (
<>
<div style={{ position: 'absolute', inset: 0, background: STOCK, mixBlendMode: 'multiply', opacity: 0.42 }} />
<div style={{ position: 'absolute', inset: 0, mixBlendMode: 'multiply', background: 'radial-gradient(ellipse at 100% 0,rgba(90,55,20,.5),transparent 26%),radial-gradient(ellipse at 0 100%,rgba(90,55,20,.5),transparent 28%),radial-gradient(ellipse at 100% 100%,rgba(90,55,20,.42),transparent 26%),radial-gradient(ellipse at 0 0,rgba(90,55,20,.32),transparent 22%)' }} />
<Fill>
<path d={FLECKS} stroke={STOCK} strokeWidth="2.2" fill="none" opacity="0.5" strokeLinecap="round" />
<path d={FOX} stroke="#7a4e1e" strokeWidth="5" fill="none" opacity="0.28" strokeLinecap="round" />
{[0, 34].map((dx, i) => <g key={i}><path d={crack(spine + dx + 3, 30, spine + dx + 2, 1890, 7 + i)} stroke={INK} strokeWidth="3" fill="none" opacity="0.3" /><path d={crack(spine + dx, 30, spine + dx - 1, 1890, 7 + i)} stroke={STOCK} strokeWidth="2.4" fill="none" opacity="0.7" /></g>)}
<path d={FOLDS} fill="none" stroke={STOCK} strokeWidth="2.4" opacity="0.62" />
<path d="M1042 1730 L888 1890 L1042 1890 Z" fill="#17120d" /><path d="M1042 1730 L888 1890 L1000 1860 Z" fill={STOCK} /><path d="M1042 1730 L888 1890" stroke="#7a5a2c" strokeWidth="3" />
<rect x="40" y="30" width="1000" height="1860" fill="none" stroke={STOCK} strokeWidth="8" strokeDasharray="70 14 26 44 110 30 14 60" opacity="0.8" />
</Fill>
</>
);
/* ---------- the back cover: the back of the router ---------- */
const pushAt = (t: number) => easeInOut(ramp(t, E('printed') - 0.15, E('printed') + 0.5)) * (1 - easeInOut(ramp(t, E('riffle') - 0.45, E('riffle') + 0.05)));
const backShade = (t: number) => {
const open = ramp(t, E('printed') - 0.2, E('printed') + 0.2);
return (x: number, y: number) => clamp(ramp(Math.hypot(x - 510, (y - 880) * 0.72), 420 + 140 * open, 1150) * 0.5);
};
const RouterBack: React.FC = () => {
const t = useT();
const circle = ramp(t, E('printed') + 0.1, E('printed') + 0.55);
return (
<>
<Fill>
<Paint>
{[[250, 540, 200, 410], [510, 540, 510, 400], [770, 540, 820, 410]].map(([a, b, c, d], i) => <path key={i} d={`M${a} ${b} L${c} ${d}`} stroke={INK} strokeWidth="30" strokeLinecap="round" />)}
<rect x="108" y="536" width="828" height="640" rx="34" fill={INK} /><rect x="96" y="522" width="828" height="640" rx="34" fill="#0d4145" /><rect x="96" y="522" width="828" height="34" rx="16" fill={MINT} />
{[0, 1, 2, 3].map((i) => <g key={i}><rect x={150 + i * 112} y="584" width="92" height="74" rx="6" fill={INK} /><rect x={166 + i * 112} y="600" width="60" height="34" fill={i === 0 ? YEL : '#2b7d73'} /></g>)}
<circle cx="664" cy="620" r="36" fill={INK} /><circle cx="664" cy="620" r="14" fill={MINT} /><circle cx="780" cy="620" r="17" fill={INK} /><circle cx="860" cy="620" r="17" fill={RED} />
{Array.from({ length: 13 }, (_, i) => <rect key={i} x={166 + i * 54} y="1098" width="30" height="40" rx="6" fill={INK} />)}
</Paint>
</Fill>
<div style={{ position: 'absolute', left: 170, top: 688, width: 680, height: 384, background: STOCK, boxShadow: `inset 0 0 0 5px ${INK}, 7px 8px 0 ${INK}`, transform: 'rotate(-1.2deg)', padding: '24px 34px', boxSizing: 'border-box', color: INK, textAlign: 'center' }}>
<div style={{ font: `30px/36px ${SLAB}`, letterSpacing: '.08em' }}>{LABEL[0]}</div>
<div style={{ font: `700 34px/40px ${SERIF}`, marginTop: 14 }}>{LABEL[1]}</div>
<div style={{ font: `34px/40px ${SLAB}`, marginTop: 22, letterSpacing: '.08em', color: RED }}>{LABEL[2]}</div>
<div style={{ font: `${fit(SECRET, 610, 60)}px/78px ${SLAB}`, whiteSpace: 'nowrap', letterSpacing: '-.01em' }}>{SECRET}</div>
<svg viewBox="0 0 612 60" width={612} height={56} style={{ display: 'block', marginTop: 12 }}>{Array.from({ length: 60 }, (_, i) => <rect key={i} x={i * 10.2} y="0" width={2 + hash(i) * 6} height="60" fill={INK} />)}</svg>
</div>
<Fill><path d="M150 908 C150 860 880 850 878 908 C876 966 160 976 152 912" fill="none" stroke={RED} strokeWidth="11" strokeLinecap="round" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - circle} /></Fill>
</>
);
};
const Quote: React.FC<{ big: string; by: string; bg: string; fg: string; size?: number }> = ({ big, by, bg, fg, size = 66 }) => (
<div style={{ background: bg, boxShadow: `inset 0 0 0 5px ${INK}, 8px 9px 0 ${INK}`, padding: '18px 34px 12px', textAlign: 'center', whiteSpace: 'nowrap', color: fg }}>
<div style={{ font: `${size}px/${size + 6}px ${SLAB}` }}>{big}</div>
<div style={{ font: `italic 600 44px/48px ${SERIF}` }}>{by}</div>
</div>
);
const Pop: React.FC<{ at: number; x: number; y: number; r: number; children: React.ReactNode }> = ({ at, x, y, r, children }) => {
const t = useT();
if (t < at - 0.08) return null;
return <div style={{ position: 'absolute', left: x, top: y, transform: `translate(-50%,-50%) rotate(${r}deg) scale(${lerp(2.2, 1, easeIn(ramp(t, at - 0.08, at + 0.04)))})` }}>{children}</div>;
};
const BackBlurbs: React.FC = () => (
<>
<Pop at={E('gripping')} x={325} y={650} r={-9}><Burst size={530} fill={YEL} n={24}><div style={{ font: `62px/68px ${SLAB}`, color: RED }}>{QUOTES[0][0]}</div><div style={{ font: `italic 600 50px/56px ${SERIF}`, color: INK }}>{QUOTES[0][1]}</div></Burst></Pop>
<Pop at={E('found')} x={752} y={578} r={4}><Quote big={QUOTES[1][0]} by={QUOTES[1][1]} bg={RED} fg={YEL} /></Pop>
<Pop at={E('me') + 0.4} x={300} y={985} r={-4}><Quote big={QUOTES[2][0]} by={QUOTES[2][1]} bg={STOCK} fg={INK} size={56} /></Pop>
<Pop at={E('soon') - 0.2} x={270} y={1185} r={3}><Quote big={QUOTES[3][0]} by={QUOTES[3][1]} bg={TEAL} fg={YEL} size={62} /></Pop>
</>
);
/** The next book in the series slides in over the pile. Two remotes, one spotlight. */
const Sequel: React.FC = () => {
const t = useT();
const at = E('soon');
if (t < at - 0.05) return null;
const k = back(ramp(t, at - 0.05, at + 0.32));
return (
<div style={{ position: 'absolute', left: 528, top: 640, width: 390, height: 630, transform: `translateX(${lerp(760, 0, k)}px) rotate(4deg)`, background: RED, boxShadow: `inset 0 0 0 6px ${INK}, 14px 16px 0 ${INK}`, overflow: 'hidden' }}>
<svg viewBox="0 0 420 650" width={390} height={630} style={{ position: 'absolute', left: 0, top: 0 }}>
<polygon points="210,190 40,650 380,650" fill={YEL} opacity="0.85" />
{[[-1, 150], [1, 270]].map(([d, x]) => <g key={x} transform={`rotate(${d * -13} ${x} 590)`}><rect x={x - 37} y="372" width="74" height="218" rx="18" fill={STOCK} stroke={INK} strokeWidth="6" /><circle cx={x} cy="406" r="13" fill={RED} stroke={INK} strokeWidth="4" />{[0, 1, 2, 3, 4, 5].map((i) => <rect key={i} x={x - 22 + (i % 2) * 28} y={442 + Math.floor(i / 2) * 38} width="16" height="22" rx="4" fill={INK} />)}</g>)}
</svg>
<div style={{ position: 'absolute', left: 0, right: 0, top: 22, textAlign: 'center', color: YEL, textShadow: `4px 4px 0 ${INK}`, font: `50px/54px ${SLAB}` }}>{SEQUEL[0]}<br /><span style={{ fontSize: 66 }}>{SEQUEL[1]}</span><br />{SEQUEL[2]}</div>
<div style={{ position: 'absolute', left: -60, right: -60, bottom: 26, background: YEL, color: INK, textAlign: 'center', transform: 'rotate(-4deg)', font: `46px/62px ${SLAB}`, boxShadow: `0 0 0 5px ${INK}` }}>{SOON}</div>
</div>
);
};
const BackCover: React.FC = () => {
const t = useT();
const push = pushAt(t);
return (
<>
<div style={{ position: 'absolute', inset: 0, background: `linear-gradient(160deg,#ffd84f,${YEL} 55%,${ORANGE})` }} />
<div style={{ position: 'absolute', inset: 0, transform: `scale(${1 + (0.27 + 0.06 * ramp(t, E('printed'), E('riffle'))) * push})`, transformOrigin: '510px 880px' }}><RouterBack /></div>
<Halftone shade={backShade(t)} />
<div style={{ position: 'absolute', left: 70, top: 96, width: 880, color: INK, textShadow: `4px 4px 0 ${RED}`, opacity: 1 - push, transform: 'rotate(-2deg)' }}>
<div style={{ font: `92px/100px ${SLAB}` }}>{HOOK[0]}</div>
<div style={{ font: `72px/86px ${SLAB}`, whiteSpace: 'nowrap' }}>{HOOK[1]}</div>
</div>
{t < E('riffle') + 0.3 ? <Pop at={E('along') - 0.05} x={780} y={1140} r={8}><Burst size={300} fill={YEL}><div style={{ font: `50px/54px ${SLAB}`, color: RED }}>{STICKER.along[0]}<br />{STICKER.along[1]}</div></Burst></Pop> : null}
<BackBlurbs />
<Sequel />
<div style={{ position: 'absolute', left: 40, top: 1570, width: 900, font: `700 36px/44px ${SERIF}`, color: INK, textAlign: 'center' }}>{FINE_PRINT[0]}<br />{FINE_PRINT[1]}</div>
<Wear spine={960} />
</>
);
};
/* ---------- the book in the hand: flips, riffle, spine ---------- */
const nib = (i: number) => (hash(i * 3.1) > 0.8 ? 2 + hash(i) * 6 : hash(i) * 1.5);
const CLIP = `polygon(${[
...Array.from({ length: 41 }, (_, i) => `${40 + i * 25}px ${30 + nib(i)}px`),
...Array.from({ length: 75 }, (_, i) => `${1040 - nib(i + 50)}px ${30 + i * 25}px`),
...Array.from({ length: 41 }, (_, i) => `${1040 - i * 25}px ${1890 - nib(i + 130)}px`),
...Array.from({ length: 75 }, (_, i) => `${40 + nib(i + 180)}px ${1890 - i * 25}px`),
].join(',')})`;
const Face: React.FC<{ a: number; origin?: string; pages?: 'L' | 'R'; children: React.ReactNode }> = ({ a, origin = '540px 960px', pages = 'R', children }) => (
<div style={{ position: 'absolute', inset: 0, transform: `perspective(2800px) rotateY(${a}deg)`, transformOrigin: origin }}>
<div style={{ position: 'absolute', left: pages === 'R' ? 40 : 28, top: 34, width: 1012, height: 1868, background: `repeating-linear-gradient(90deg,${PAGE} 0 3px,#c9b583 3px 4px)`, boxShadow: `${pages === 'R' ? 8 : -8}px 12px 0 #07181a` }} />
<div style={{ position: 'absolute', inset: 0, clipPath: CLIP, background: STOCK }}>{children}</div>
</div>
);
const LEAVES = 9, LEAF_GAP = 0.06, LEAF_D = 0.18;
const Leaf: React.FC<{ i: number }> = ({ i }) => (
<>
<div style={{ position: 'absolute', inset: 0, background: PAGE }} />
<div style={{ position: 'absolute', left: 130, top: 170, width: 780, textAlign: 'center', font: `70px/80px ${SLAB}`, color: RED }}>{`CHAPTER ${i + 3}`}</div>
{Array.from({ length: 34 }, (_, r) => <div key={r} style={{ position: 'absolute', left: 130, top: 300 + r * 42, height: 15, width: 780 - hash(r * 7 + i) * (r % 9 === 8 ? 420 : 40), background: INK, opacity: 0.42 }} />)}
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 70, textAlign: 'center', font: `700 40px/44px ${SERIF}`, color: INK }}>{190 - i * 21}</div>
<Wear spine={960} />
</>
);
const Book: React.FC = () => {
const t = useT();
const a = angleAt(t);
const sway = -1.5 + Math.sin(t * 0.6) * 1.3;
const drop = back(ramp(t, 0, 0.42));
const j = shake(t);
const rf = E('riffle');
const inRiffle = t >= rf && t < rf + (LEAVES + 1) * LEAF_GAP + LEAF_D;
const leafK = (i: number) => ramp(t, rf + i * LEAF_GAP, rf + i * LEAF_GAP + LEAF_D);
const c = Math.cos((a * Math.PI) / 180), s = Math.abs(Math.sin((a * Math.PI) / 180));
const back0 = inRiffle ? leafK(0) : 2;
return (
<div style={{ position: 'absolute', inset: 0, transform: `translate(${j}px, ${lerp(-30, 0, drop) + j * 0.5}px) rotate(${lerp(-2.5, 0, drop)}deg) scale(${lerp(1.07, 1, drop)})`, transformOrigin: '50% 30%' }}>
{c > 0 ? <Face a={a + sway}><FrontCover /></Face> : null}
{inRiffle ? Array.from({ length: LEAVES }, (_, i) => LEAVES - i).map((i) => (leafK(i) < 0.5 ? <Face key={i} a={leafK(i) * 180} origin="1040px 960px" pages="L"><Leaf i={i} /></Face> : null)) : null}
{c <= 0 ? <Face a={a - 180 + sway} pages="L"><BackCover /></Face> : null}
{inRiffle && back0 < 0.5 ? <Face a={back0 * 180} origin="1040px 960px" pages="L"><BackCover /></Face> : null}
{s > 0.92 ? <div style={{ position: 'absolute', left: 540 - 44 * s, top: 30, width: 88 * s, height: 1860, background: `linear-gradient(90deg,#5a0f12,${RED},#5a0f12)`, boxShadow: `inset 0 0 0 2px ${INK}` }} /> : null}
</div>
);
};
const FrontCover: React.FC = () => {
const t = useT();
const drain = drainAt(t);
return (
<>
<div style={{ position: 'absolute', inset: 0, filter: `saturate(${1 - 0.74 * drain + 0.22 * glowAt(t)}) brightness(${1 - 0.1 * drain})` }}>
<Plates />
<Brush />
<ArtShade />
<Rain />
</div>
<div style={{ position: 'absolute', left: 90, top: 1560, width: 800, textAlign: 'center', font: `italic 600 66px/76px ${SERIF}`, color: STOCK, textShadow: `3px 3px 0 ${INK}` }}>{TAGLINE}</div>
<Stickers />
<InkRuns />
<Masthead />
<Ribbon />
<ChapterSlam />
<CommentBurst />
<Wear spine={82} />
</>
);
};
const CommentBurst: React.FC = () => {
const t = useT();
const at = E('pulp') - 0.2;
if (t < at - 0.08) return null;
const land = easeIn(ramp(t, at - 0.08, at + 0.04));
return (
<div style={{ position: 'absolute', left: 312, top: 1000, transform: `translate(-50%,-50%) rotate(${-9 + Math.sin(t * 2.2) * 1.2}deg) scale(${lerp(2.4, 1, land) * (1 + 0.012 * Math.sin(t * 4.4))})` }}>
<Burst size={500} fill={YEL} n={24}><div style={{ font: `54px/60px ${SLAB}`, color: INK }}>COMMENT</div><div style={{ font: `${fit(KEYWORD, 400, 116)}px/124px ${SLAB}`, color: RED, textShadow: `4px 4px 0 ${INK}` }}>{KEYWORD}</div></Burst>
</div>
);
};
/* ---------- captions: the tagline banner, one fixed place ---------- */
const CHUNKS = [...buildChunks(19, 2), { t0: E('riffle') + 0.3, rows: [{ s: VOICELESS, map: Array.from(VOICELESS, () => 0) }], words: [{ w: VOICELESS, t0: LIT, t1: LIT + 1.1, scene: '' } as Word] }].sort((a, b) => a.t0 - b.t0);
const Banner: React.FC = () => {
const t = useT();
const idx = Math.max(0, CHUNKS.reduce((a, c, i) => (t >= c.t0 - 0.06 ? i : a), -1));
const c = CHUNKS[idx];
const drain = drainAt(t) * (1 - ramp(t, E('turned'), E('turned') + 0.3));
let wi = 0;
return (
<div style={{ position: 'absolute', left: 60, top: 1292, width: 890, height: 170, background: `radial-gradient(circle, rgba(27,20,16,.15) 30%, transparent 34%) 0 0/9px 9px, ${YEL}`, boxShadow: `inset 0 0 0 7px ${INK}, 7px 7px 0 ${INK}`, transform: 'rotate(-1.1deg)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', filter: drain > 0.01 ? `saturate(${1 - 0.5 * drain}) brightness(${1 - 0.12 * drain})` : undefined }}>
{c.rows.map((row, ri) => (
<div key={`${idx}-${ri}`} style={{ font: `62px/70px ${SLAB}`, whiteSpace: 'nowrap' }}>
{row.s.split(' ').map((w, k) => {
const word = c.words[wi++];
const on = word && t >= word.t0 - 0.03 && t < word.t1 + 0.1;
const key = t >= E('cta') && w === KEYWORD;
const seen = word && t >= word.t0 - 0.03;
return <span key={k} style={{ color: on || key ? RED : INK, opacity: seen || key ? 1 : 0.24 }}>{k ? ' ' : ''}{w}</span>;
})}
</div>
))}
</div>
);
};
/* ---------- the film ---------- */
export const Pulp: React.FC = () => <Clock><Fonts faces={FACES}><Stage /></Fonts></Clock>;
const Stage: React.FC = () => (
<AbsoluteFill style={{ background: '#0a2125', overflow: 'hidden' }}>
<svg width="0" height="0" style={{ position: 'absolute' }}>
<filter id="paint" x="-5%" y="-5%" width="110%" height="110%" colorInterpolationFilters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.016 0.024" numOctaves="3" seed="4" />
<feDisplacementMap in="SourceGraphic" scale="20" xChannelSelector="R" yChannelSelector="G" />
</filter>
</svg>
<Book />
<Banner />
</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 hard-boiled narrator, low, dry and close to the mic, reading his own terrible paperback aloud. He believes every word, and he has never once noticed that this is about Wi-Fi. Never imitate a real actor, or a known narrator of real books.
**When to use it:** on every line except the voiceless moment. Hard-boiled voices read slowly, so speed the takes up until a short line lands in 2 to 3 s. "Chapter one." is read flat, like a title page. Give "He said, ask your mother" a small beat at the comma and no extra weight on the last word: it must sound like a brush-off, or the label won't surprise anyone. Add a little warmth around 160 Hz and a very short room.
- **Never clone or imitate a real person.** Audition 3 or 4 voices on two lines of the script and let the user pick, or use the user's own voice.
- **One take per line,** with word timestamps. Pass the neighboring lines as context if your provider allows it, so the delivery flows. Trim each take to its speech, and tighten any pause inside it longer than 0.5 s.
- **Listen for your call to action.** Keywords blur easily. Commas around one in the spoken text ("Comment, gate, and...") usually fix it. Check the final mix with Whisper, line by line.
- **With no voice.** Keep the estimated timings as the clock. If there's music, put the captions on its grid. Raise the effects and the music: they carry the film now. Make sure the words stay on screen long enough to read, about 0.3 s a word. The estimate runs close to that, so add `tail` after a line that feels rushed.
## 9. Sound
The picture tells the story without sound, so a film can ship silent. Sound is what makes each change land, though, so add it unless the user says not to. This section says what to use and when it plays. Add it after the user approves the animatic.
**Effects: make a set for this world.** Generate them with the sound-effects tool the user chose, or pick from a licensed library. Prompt the thing itself, and keep it warm, never bright or harsh.
| Sound | Length | Prompt idea | When it plays |
|-------|--------|-------------|---------------|
| book drop | 0.8 s | a thick old paperback dropped flat on a wooden table, a dull papery slap, close and dry | frame 0, peaking as it lands |
| chapter slam | 1.2 s | a heavy wooden printing block slammed down on a stack of paper, a deep soft thud with a short paper rustle, warm and dark | `ch1`, `ch2`, `ch3`: the loudest things in the first half |
| ink dots | 0.5 s | a quick soft dry rustle, like a sheet of paper pulled off a printing press | each new plate |
| router tick | 0.4 s | a small old electrical relay clicking twice, close and warm | `blink`, and once more on `knew` |
| sticker slap | 0.5 s | a paper sticker slapped hard onto thick card, short and papery | `knew`, `nobody`, `mother`, and each blurb in the pile |
| stamp | 1 s | a rubber stamp slammed on a wooden desk, a solid thud, warm and dark | `password` |
| flashlight | 0.4 s | the metal thumb switch of an old flashlight, one click | `note` |
| bulb creak | 3 s | a bare light bulb swinging on its cord, a slow faint creak, very quiet | under the roommate, on the swing |
| neon death | 1.2 s | an old neon tube ticking and buzzing, then cutting out with a small pop | the last tube, about 0.8 s after `zero` |
| stickers falling | 0.8 s | several paper labels sliding off wet card, soft | just after `ch3` |
| book flip | 0.7 s | a paperback flipped over in one hand, a fast flutter and a soft slap on the palm | `turned`, then just before `gripping`, then `cta` |
| pen circle | 0.8 s | a ballpoint scrawling a fast circle on a paper label | `printed` |
| riffle | 1 s | a thumb riffling fast through the pages of a paperback, a dry purr | `riffle` |
| neon on | 0.9 s | one old neon tube stuttering on with a low warm buzz and a soft clink | each tube as it lights, then the sign, rising in pitch a little each time |
| neon hum | 8 s | a steady low hum from a neon sign in the rain, warm, no hiss | from the first tube to the end, quietly |
| rain | 10 s | steady rain on a city street at night, soft, low and warm, no thunder | on the street, loudest from `zero` to the flip, then low until the end |
| siren | 8 s | a police siren very far away across a city at night, thin and low, never passing | from `low` to the flip, under the rain |
| room tone | 10 s | a quiet apartment at night, a faint fridge hum, a distant city, no voices, no music | the bed, all the way through |
- **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 smoky 1950s crime paperback jazz instrumental: a slinky low tenor sax over brushed drums and a walking upright bass, with a vibraphone in the background and an occasional low brass stab. Sparse at first, then a long lift where the band opens up and the sax climbs, warm and resolved at the end, no vocals."
**When it plays:** low under the first lines, and a brass stab can land on a chapter slam if the track has one. It drains to nothing over about 0.7 s just after "mother", so by "Chapter three" only the rain and the siren remain, and it stays out through the low point. The sax comes back alone on "router". The band builds under the label, is at its fullest on the voiceless moment with the neon, on a bar where the track opens up, ducks for the blurbs and the sequel so those jokes sit clear, and resolves under the last line.
**Mix, voice first.**
- **Voice:** compress lightly so consonants stay off the limiter, about -14 LUFS.
- **Music:** about 16 to 20 LU under the voice while it speaks, lifted in gaps and on the voiceless moment.
- **Effects:** 7 to 10 LU under the voice overall, ducked about 6 dB under speech, so nothing masks a word.
- **Master:** -14 LUFS integrated, sample-peak ceiling about -2.4 dBFS. AAC encoding adds about 0.8 dB, so check the true peak of the final file. Give the master a 10 ms fade-in, or every view opens on a click.
## 10. Render and QA
1. **Render** without audio. Mux your mix onto it, trimmed to the exact length (`-t`, never `-shortest`), as H.264 at high quality with AAC 320k and `+faststart`.
2. **Sync and length:** the frame count matches the clock, and a cross-correlation of the muxed audio against your mix shows an offset of 0 ms.
3. **Contact sheets:** one frame per second with the safe box drawn. Look at every sheet: nothing cut off, nothing important outside the box, no empty or black frames, and the time of day and the story still agree.
4. **Determinism:** render about 10 spread frames twice with `npx remotion still src/index.ts Film-9x16 out/a.png --frame=N`, and compare each pair with `cmp`; they should match.
5. **Loudness:** -14 LUFS integrated, true peak at or below -1 dBTP.
6. **Words:** transcribe the mix line by line with Whisper. A difference on a plain word is a masked or swallowed word.
7. **Gaps and stills:** no voice gap over 1.0 s outside the voiceless moment (`clock.mjs` warns about these), and no still stretch over 1.2 s or black stretch (`ffmpeg -i out/silent.mp4 -vf "freezedetect=n=0.001:d=1.2,blackdetect=d=0.1" -an -f null -` prints any). A small moving corner can hide a freeze from that check, so trust the contact sheets over it.
For a silent film, skip the checks that need audio (the offset in 2, and 5 and 6) and use `out/silent.mp4` as the final file.
## 11. Traps
- **A real book sneaks in:** pulp imprints and titles all sound alike, so check every invented name, and never trace a real cover. A sequel title is the likeliest slip.
- **A brand in a title:** a joke title reaches for a product name first. Avoid brand names in titles, and use the plain thing instead: ours is The Remote Affair.
- **Mud:** the dots get heavy fast. Above about 0.6 across a whole plate, the paint turns brown and nothing reads. Our first draft did this. Keep each plate's `shade` low, and darken a plate with its paint instead.
- **A low point that goes black:** the alley drains and dims, but its shade stays under 0.5, the lamp keeps a pool open and the rain stays bright, so it's never black.
- **The sticker pile hides the next plate:** plan the rest positions plate by plate. Ours are knocked off by the third chapter's slam, so the viewer can see the last tube die.
- **Holes in a figure:** overlapping subpaths in one SVG path that run in opposite directions cancel out and leave a hole. Keep every subpath in `GUMSHOE` and `SHADOW` running the same way round.
- **Words inside `Paint`:** the paint filter wobbles everything under it, and words melt. Keep moving text as HTML, above the SVG.
- **The caption banner seems to float during a flip:** that's on purpose, because it stays in its one fixed place. Don't put it inside `Face`.
- **Explaining the joke:** if the script says "it was his mother all along", the label stops being funny. Let the picture say it.
| 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 pulp skill (paste the skill text and ask it to save it as a skill). Then replace everything in [BRACKETS] below and paste it in. **Tip:** use any AI tools you like for the voice, music and sound effects; the skill works the same with all of them, or with none (every scene is code, and the film works with no voice). If you'd rather not set up separate AI accounts and API keys, HYVIRAL (optional) is one way to get them behind one connection (hyviral.com; in Claude.ai → Customize → Connectors → add `https://api.hyviral.com/mcp`). --- Use the pulp skill to make a vertical video for [PLATFORM: Instagram Reels / TikTok / YouTube Shorts]. - **The small crime:** [TOPIC - the tiny problem told as a crime novel, e.g. "the missing Wi-Fi password", "who ate my lunch"] - **The answer in plain sight:** [ANSWER - where it was all along, and what gets turned over to find it, or "you write it"] - **The brush-off that was really the answer:** [BRUSHOFF - e.g. "ask your mother", or "you find it"] - **The paperback's title:** [TITLE - invented and lurid, or "you write it"] - **In the window:** [WINDOW - "no person: painted plates drawn in code", or "my own clip, printed as the cover art, attached"] - **Length:** [LENGTH - e.g. about 35 seconds] - **Voice:** [VOICE - e.g. "a hard-boiled narrator", "my own voice", or "no voice: captions and sound only"] - **Music:** [MUSIC - e.g. "the skill's crime jazz 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 books, authors, publishers or cover art, no real people named as culprits, no claims I can't back up] Work in this order: 1. Write the script on the skill's moves, with one line for each turn and which plate, chapter or sticker it puts on the cover. 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 skillsOn Air
A 3 a.m. call-in show that starts funny and turns tender.
Frame · 9:16 · 30-40 s
Mission Control
A launch console treats your small project like a space launch.
Frame · 9:16 · 30-40 s
Treasure Map
An old sea chart tells your long hunt as the voyage of a lifetime.
Frame · 9:16 · 30-40 s