Karaoke Night
A karaoke screen fills the frame, and your small daily annoyance is tonight's ballad. Lyrics wipe word by word over a sunset beach, the key change turns everything magenta, and the pile buries the singers. Then the mic dies mid-word, and one boring command ends it in confetti. Your words and music stay original: the skill refuses real lyrics and real songs. 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 ballad is about What piles up The way out Platform Narrator Ending AI toolsUse the karaoke-night skill to make a vertical video for Instagram Reels. - **The ballad is about:** [TOPIC - the small daily annoyance, e.g. "my inbox", "two hundred open browser tabs", "the family group chat"] - **What piles up:** [PILE - what fills the beach, and how many, e.g. "forty thousand unread emails", or "you choose"] - **The way out:** [FIX - the one boring command that ends it, e.g. "select all, delete", or "you write it"] - **Song title:** [TITLE - an invented title for the menu, or "you write it"] - **In the window:** [WINDOW - "no person: the sunset beach drawn in code", or "my own clip, attached"] - **Length:** [LENGTH - e.g. about 35 seconds] - **Voice:** [VOICE - e.g. "an overcommitted karaoke regular", "my own voice", or "no voice: captions and sound only"] - **Music:** [MUSIC - e.g. "the skill's original power ballad brief", or your own idea, always original] - **Ending:** [CTA - organic: "Comment KEYWORD and I'll send you THE THING", with the keyword and what you'll send; or sponsored: the line and the URL] - **AI tools:** [AI TOOLS - e.g. "whatever you already have set up", "HYVIRAL", or "none: animation only, I'll record the voice myself"] - **Avoid:** [AVOID - e.g. no real people or brands, no claims I can't back up, and always no real lyrics, songs, artists or karaoke brands] Work in this order: 1. Write original lyrics on the skill's moves, with one line for each move and what happens on the screen. Find the low point first: the moment the song stops mid-word and a plain voice takes over. 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 original lyrics, builds the screen 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: karaoke-night
description: Make a 30-40 second vertical video where a bar's karaoke screen turns a small daily annoyance into a sincere power ballad - lyrics wipe word by word, a pitch lane scrolls and a score climbs over a cheesy sunset beach, the mic dies mid-word, and a one-line fix ends in confetti. Original words and music only - the skill refuses real lyrics and real songs. It covers the bar's sound design, an original ballad fitted to the story and an optional voice like an overcommitted karaoke regular. Use for comedy about everyday overload, 'a song about my inbox' ideas, anything spoken with rhythm, or when asked for a karaoke video, or when given the karaoke-night prompt.
---
# Karaoke Night
A karaoke screen fills the frame between velvet curtains, and your small daily annoyance is tonight's power ballad. The song menu lands on your title, the lyrics wipe word by word, the beach turns magenta on the key change, and the unread pile buries the singers. Then the mic dies mid-word, the video freezes, and someone asks if this thing is on. A one-line fix brings it all back, in confetti and a score of ninety-nine. Every word and note is your own: this skill refuses real lyrics and real songs.
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/karaoke-night/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/monoton`, `@fontsource/archivo-black`, `@fontsource/bungee` and `@fontsource/vt323`. Copy the font files you need from `node_modules/@fontsource/<name>/files/` into `public/fonts/`, so a render never fetches a font over the network. Then lay it out like this:
```
plan.json the lines and the scenes (step 6)
takes/<id>.json one per line: the voice take's length and word timings (step 8)
scripts/clock.mjs turns plan.json and the voice takes into src/spec.json (step 6)
src/index.ts registers the composition (step 7)
src/Root.tsx the 1080 x 1920, 30 fps composition (step 7)
src/spec.json the clock, written by clock.mjs
src/frames/core.tsx the shared runtime (step 7)
src/frames/story.json the events: which word each change lands on (step 6)
src/frames/KaraokeNight.tsx this frame (step 7)
```
Render without audio, then mux your own mix (step 10): `npx remotion render src/index.ts Film-9x16 out/silent.mp4 --muted`. Set TEMP and TMP to a folder on a roomy drive first; Remotion writes a lot of temporary files.
**Prove the setup before you write anything.** Copy the code and our `plan.json` from step 7, run the clock from step 6, and render the first few seconds (add `--frames=0-90`). Only then start on your own story, at step 5. The font files you need are named in `FACES` at the top of the frame.
**For the voice, images, music and sound effects, use whatever the user prefers.** Every step works the same with any provider. If the user hasn't said, ask which tools they want to use, or use what is already set up.
- **Your own providers.** A text-to-speech that returns word timestamps (for example ElevenLabs with timestamps), a music model or a licensed track, and a sound-effects generator (for example ElevenLabs sound effects) or a licensed sound library. Keep API keys in environment variables, never in the project. No AI media at all is fine too: the picture is code, the narration can be the user's own recording (align its words with Whisper), and the film works with no voice.
- **HYVIRAL (optional).** If the user has it connected (hyviral.com), one connection covers the media:
| You need | HYVIRAL tool |
|----------|--------------|
| Narration with word timestamps (stock voices or your own cloned voice) | `generate_voiceover` (`content_type: "shorts"`), `manage_voice_clone` |
| Still images | `generate_image` |
| Music bed | `generate_music` |
| Sound library, placing sounds, final render | `video_audio`, `video_export` |
| Long jobs | `get_job_status` (poll until delivered) |
To connect it: in Claude.ai or Claude Desktop, Customize → Connectors → add a custom connector with the URL `https://api.hyviral.com/mcp`; in Claude Code, `claude mcp add --transport http hyviral https://api.hyviral.com/mcp` then `/mcp` to sign in. Start a session with `get_connection_info`. HYVIRAL never runs your code: you render the animation yourself.
## 2. How a frame film works
- **One frame, the whole film.** A frame is a world with fixed parts on screen, and it never cuts away. The story happens to the world: the song menu lands on a title, lyrics wipe word by word, a pitch lane scrolls, the backing video turns magenta and fills with envelopes, the mic dies and the video freezes, and the score tallies up. That's why it holds attention: the viewer learns the world in the first second, then watches it change.
- **One clock: the spoken words.** Each line is voiced as its own take with word timings, scenes are laid end to end on those takes, and every change is keyed to a word in `story.json`. Re-voice a line and everything moves with it.
- **A function of time.** Every part of the frame computes what it shows from the current time, with no stored state, `Math.random` or `Date`. Use `hash()` for anything that looks random, so two renders match.
- **Sound off first.** Most people start muted, so the frame and the captions have to tell the whole story on their own. Sound makes it better, never necessary.
- **Safe area.** 1080 x 1920. Platform buttons and captions cover the bottom 440 px, and the right 130 px from about y = 900 down. Keep captions and anything that must be read inside x 60-950, y 250-1480. Above y = 900 the frame can run to the right edge. Below y = 1480, draw only texture. The top 250 px can hold the world's title and small jokes, as long as nothing there has to be read.
- **Pacing rules.** No voice gap over 1.0 s, except one voiceless moment of 2 to 5 s with the music lifted. No still stretch over 1.2 s. Never cut to black. Something moves on the very first frame.
## 3. The world
A bar's karaoke night, seen as one big screen between velvet curtains. The film is a sincere power ballad about something very small.
**What this skill refuses.** Original words and music only. Every lyric, title and note in the film has to be new. If anyone asks you to use real lyrics, a real song, a song's recognizable melody or a parody that follows one, refuse, say why, and offer to write an original verse on the same idea instead. The same goes for a real karaoke brand, a real song catalog or an artist's name on screen, and for imitating a real singer. Song lyrics and melodies are protected, and a platform can mute or pull a post that uses them. The song titles and numbers on our menu are all invented, and yours must be too.
- **Palette:** curtain `#4b1a5e`, stage night `#14061f`, neon pink `#ff4fa3`, lyric cyan `#3ad1ff`, gold `#ffd23f`, lyric white `#fff6ff`, menu blue `#0d1aa0`, and the red `#ff3b4e` of an unread dot.
- **The backing video's sky is the mood:** sunset, magenta at the key change, dusk under the pile, drained at the low point, and a clear turquoise morning once the pile is gone. That clear sky appears nowhere earlier, so the eye knows something changed.
- **Type:** Monoton for the neon sign, Archivo Black for the lyrics, Bungee for the score, tags and big numbers, VT323 for the machine's menu and on-screen display.
- **Fixed parts, top to bottom:** a scalloped valance and the neon sign (above the safe area, so nothing there has to be read), the screen in a neon tube (y 244-1494) holding the display and score (y 268-370), the backing video, the pitch lane (y 1052-1228) and the lyrics in one fixed place (y 1258-1472), then the stage below the safe area as texture: table nine's card, the mic on its stand, and a crowd holding up phones.
- **Lyrics stagger:** the first row sits flush left and the second flush right, the old karaoke way. It also hides that the safe area isn't centered on the screen.
- **Small jokes on the machine:** the lane grades the singing with PASSIVE, AGGRESSIVE, FLAT, BRAVE and WHY. The duet's two sides are YOU and EVERYONE. The menu lists PLEASE SEE ATTACHED and OUT OF OFFICE, FOREVER. At the low point the sign dies down to the two letters OK. The pause reads PAUSE, the way a tape deck says it.
- **The backing video has nothing to do with the bar:** two figures in long coats and enormous hair sing at the sea. It takes itself completely seriously, and so does everyone else.
**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 karaoke machine's backing video, a tall painted beach. It follows the song, which real backing videos never do, and that's the joke.
| Story moment | In the video |
|---|---|
| The menu | No video yet: the machine's blue song menu. A star wipe opens onto the beach |
| Verse | A sunset, one singer, and one big envelope washing up with a red 1 on it. A postmark stamps FALL 2023 |
| Verse two | More envelopes wash in. A gull crosses the sky carrying one, and doesn't drop it |
| Key change | The whole picture jumps up and stays up, and the sky turns magenta |
| Duet | The screen splits pink and blue, YOU and EVERYONE. A second singer slides in, and envelopes arc out of both of them |
| Bridge | Envelopes rain down and pile up over the singers' knees while the sun sets. Three light up, opened |
| Low point | The video stops dead, drained of color, with tracking streaks. Everything in it freezes mid-gesture |
| The turn | A selection box drags across the beach, and every envelope turns blue with a check mark. A dialog asks DELETE 40,000 UNREAD? |
| Voiceless | The sun climbs, the pile trembles and lifts, then bursts into confetti. Clear morning, an empty beach, INBOX ZERO written in the sand |
| The score | One last envelope lands on that sand, right on top of the word ZERO |
The window also works with the user's own clip: play it in place of `<Beach />`, a pane of 956 x 1234, keeping the lane, the lyrics and the display on top. Wrap it in Remotion's `<Freeze>` from `cut` to `del` so it still stops dead at the low point.
## 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 daily annoyance as the most sincere song ever sung. Ours is an inbox. Write your own lyrics, and keep them new.
| # | Move | What it does | Our line | On the screen |
|---|------|--------------|----------|---------------|
| 1 | The menu | The hook: your annoyance has a song number | "Next up, table nine, with a song about their inbox." | The bar steps through invented titles and lands on UNREAD on "inbox" |
| 2 | Verse | The first small wrong, far too seriously | "I left you on unread, in the fall of twenty-three." | The count-in dots burn down and lyrics wipe. The envelope lands and gets its postmark |
| 3 | Verse two | The grievance | "You said, per my last email, but you never wrote to me." | The pitch lane scrolls: PASSIVE on "per", AGGRESSIVE on "email", FLAT on "never" |
| 4 | Key change | The shameless lift | "Key change!" | Everything jumps up, the beach turns magenta, KEY +1 |
| 5 | Duet | It spreads to other people | "Reply all, reply all, why did you reply all?" | The screen splits. Each "reply all" wipes in its singer's color, and the counter runs |
| 6 | Bridge | The true size of it | "Forty thousand subjects, and I've opened only three." | 40,000 fills the sky, envelopes rain down, OPENED: 3 on the word |
| 7 | Low point | The song stops and a person is left | "Is this thing on?" | The next lyric starts and stalls mid-word, the lane goes flat, MIC OFF, PAUSE, the sign reads OK, and the real mic gets tapped twice. Music out; room murmur and the tap keep running |
| 8 | The turn | The fix is one boring command | "Select all. Delete. Oh, select all." | The mic's back, a selection box takes the whole pile, and on "delete" the video plays again. YES gets pressed. Music returns on "delete" |
| 9 | The silence | The voiceless moment, about 3 s | (music at its fullest, the caption wipes INBOX ZERO) | The last note climbs the lane, the dot flies out of it and bursts, the pile goes up as confetti, the score tallies |
| 10 | The score | The undercut | "Score, ninety-nine. One unread." | The tally stops at 99 with stars. A new envelope lands on the clean sand, covering ZERO, and the counter reads 1 |
| 11 | Encore | The last laugh | "Encore? Press any key to reply." | The sign turns to ENCORE, PRESS ANY KEY blinks, and the envelope hops |
| 12 | Call to action | One action | "Comment encore and I'll send you the skill that made this." | The lyrics line reads COMMENT ENCORE and stays |
**For other topics:** two hundred open browser tabs, a phone with no storage left, the family group chat, a to-do list, the laundry chair, every password you've ever reset. Keep it small and universal. The trick that makes it land is the low point: the whole film has been a performance, then the song stops mid-word and a plain human voice asks whether the mic works. Protect that switch from sung to spoken, and don't write a joke there. The second thing to protect is the undercut: it's ninety-nine and never a hundred, because the fix never quite holds. Our film runs 36 s on the clock's estimate. For about 33 s, cut move 3 and delete `per` with its tag. Keep the `email` and `never` events, re-pinned to words in move 2, because the pile, the poses, the counter and the gull read them. Run the clock after each cut.
## 6. Plan and animatic (before generating anything)
**1. Write `plan.json`.** `text` is what the captions show. Add `say` when the spoken version differs, with the same number of words. Scenes hold the lines. `lead` and `tail` are seconds of room around a scene's words, and a voiceless scene has `sec` instead of `lines`.
```json
{
"lines": [
{ "id": "l01", "text": "First line." },
{ "id": "l02", "text": "Comment TOUR and I'll send it to you.", "say": "Comment, tour, and I'll send it to you." }
],
"scenes": [
{ "id": "s01", "lines": ["l01"], "lead": 0.7 },
{ "id": "s02", "sec": 3.4 },
{ "id": "s03", "lines": ["l02"] }
]
}
```
**2. Run the clock.** Write `scripts/clock.mjs` as below, and run it from the project folder. A line with no take yet gets an estimate, so you can see the whole film before generating anything. A real take goes in `takes/<id>.json` as `{ "dur": seconds, "words": [{ "w": "First", "t0": 0, "t1": 0.3 }] }` and replaces the estimate.
```js
// node scripts/clock.mjs plan.json + takes/<id>.json -> src/spec.json (what the frame renders) and clock.json (where each line plays)
import fs from 'node:fs';
const FPS = 30;
const plan = JSON.parse(fs.readFileSync('plan.json', 'utf8'));
const PAUSE = 0.35, LEAD = 0.05, TAIL = 0.25, TAIL_OUT = 1.2;
// No take yet: an estimate for a quick, clear voice, fitted to 53 real takes. A real take in takes/<id>.json replaces it.
function estimate(text) {
const letters = (s) => (s.match(/[\p{L}\p{N}]/gu) ?? []).length;
const stops = (text.match(/[.?!]+(?=\s)/g) ?? []).length, commas = (text.match(/,/g) ?? []).length;
const dur = 0.29 + 0.059 * letters(text) + 0.53 * stops + 0.07 * commas;
const parts = text.split(/\s+/), total = parts.reduce((n, p) => n + letters(p) + 1, 0);
let at = 0;
const words = parts.map((w) => { const d = (dur * (letters(w) + 1)) / total, x = { w, t0: at, t1: at + d }; at += d; return x; });
return { dur, words };
}
let t = 0.1;
const beats = [], placed = [];
for (const s of plan.scenes) {
const from = Math.round(t * FPS);
let end = t + (s.sec ?? 0);
const words = [];
if (s.lines?.length) {
let c = t + (s.lead ?? LEAD);
s.lines.forEach((id, i) => {
const line = plan.lines.find((l) => l.id === id);
const take = fs.existsSync(`takes/${id}.json`) ? JSON.parse(fs.readFileSync(`takes/${id}.json`, 'utf8')) : estimate(line.say ?? line.text);
// Captions show `text`, even when `say` differs ("Comment, gate"), as long as the word counts match.
const shown = line.text.split(/\s+/);
const same = shown.length === take.words.length;
placed.push({ line: id, t0: c, dur: take.dur, scene: s.id });
take.words.forEach((w, k) => words.push({ w: same ? shown[k] : w.w, f0: Math.round((c + w.t0) * FPS) - from, f1: Math.round((c + w.t1) * FPS) - from }));
c += take.dur + (i < s.lines.length - 1 ? PAUSE : 0);
});
end = c + (s.tail ?? TAIL);
}
const dur = Math.max(1, Math.round(end * FPS) - from);
beats.push({ id: s.id, from, dur, words, p: s.p ?? {} });
t = (from + dur) / FPS;
}
const total = Math.round((t + TAIL_OUT) * FPS);
beats[beats.length - 1].dur = total - beats[beats.length - 1].from;
beats[0].dur += beats[0].from;
beats[0].words.forEach((w) => { w.f0 += beats[0].from; w.f1 += beats[0].from; });
beats[0].from = 0;
for (let i = 1; i < placed.length; i++) {
const gap = placed[i].t0 - (placed[i - 1].t0 + placed[i - 1].dur);
const quiet = beats.some((b) => !b.words.length && b.from / FPS < placed[i].t0 && (b.from + b.dur) / FPS > placed[i - 1].t0);
if (gap > 1.0 && !quiet) console.warn(`voice gap ${gap.toFixed(2)} s before ${placed[i].line}: shorten a lead or tail`);
}
fs.mkdirSync('src', { recursive: true });
fs.writeFileSync('src/spec.json', JSON.stringify({ fps: FPS, durationInFrames: total, beats }, null, 1));
fs.writeFileSync('clock.json', JSON.stringify({ fps: FPS, total: total / FPS, lines: placed }, null, 1));
console.log(`${(total / FPS).toFixed(2)} s, ${beats.length} scenes, ${placed.length} lines`);
```
**3. Write `src/frames/story.json`.** Name every change in the story and pin it to a scene, and to a word in it where you can. The frame reads events by name, and a typo fails the render instead of drifting. Every event is checked when the film loads, even one the frame never reads. Our list keeps some events only so a sound can land on them; you can delete those. A `word` matches the first word in its scene that starts with those letters, so "the" also hits "there", and a repeated word can only be reached with `off` from an earlier one.
**4. Build the frame** with the code in step 7, changing the words and schedules to your story. Keep each schedule in time order.
**5. Render a silent animatic.** Make contact sheets at one frame per second with the safe box drawn, and look at every tile: `ffmpeg -i out/silent.mp4 -vf "select='not(mod(n\,30))',drawbox=x=60:y=250:w=890:h=1230:color=red@0.9:t=6,scale=216:-2,tile=8x3" -fps_mode passthrough sheet_%02d.jpg`. Empty tiles at the end of the last sheet are padding, not black frames. A tile that small hides small errors, so also look at about 6 frames at full size, with the window cropped large (`npx remotion still src/index.ts Film-9x16 out/f.png --frame=N`).
**6. Show the user** the script, the scene sheet (one row per scene: its line, what changes on screen, and its seconds) and the animatic. Wait for their OK.
## 7. Build the frame
These files are the real frame that made our demo, with our demo's story in it. You rendered them in step 1 to prove your setup. Now change the story, using the list below. Leave the machinery alone until the film works, and read the code before you change it. Paper, panels and labels are sized to our demo's words, so check anything longer on the contact sheet.
### What to change for your story
The room, the screen, the lane, the lyric wipe and the score barely change. Your work is the words at the top of the file and whatever replaces the envelopes.
| To change | Where |
|---|---|
| Words on screen | The block at the top of the file: `KEYWORD`, `QUIET_CAPTION`, `SIGN_TEXT`, `TABLE`, `NEXT_UP`, `SONG`, `SONG_SUB`, `MENU_TITLE`, `POSTMARK`, `COUNTER_LABEL`, `KEY_POP`, `DUET_A`, `DUET_B`, `OPENED_LABEL`, `SELECTED_LABEL`, `DIALOG`, `YES`, `NO`, `SCORE_LABEL`, `SCORE_RIBBON`, `PROMPT` |
| The song menu | `MENU` holds number, title and style, all invented. `MENU_PICK` is the row it lands on, `MENU_PATH` is the rows the bar visits. `SONG` is that row's title |
| The lane's jokes | `TAGS`: one row per joke, with its event and its color |
| The counter | `COUNTS` is what it reaches on each event, `COUNT_FULL` the top, and `countAt` the fall to zero and the last 1 |
| The score | `scoreAt`, with `SCORE_END` as the final number. `STAR_AT` places the stars |
| The stalled lyric | `STALLED` is the line the mic cuts off, and `STALL_AT` is how many letters get wiped first, so it stops mid-word |
| The sign at the low point | `SIGN_KEEP`: which letters of `SIGN_TEXT` stay lit. Ours spell OK. If your sign changes, find your own word in it or keep none |
| The thing that piles up | `EnvG` draws one, `ENVS` places and times them all (`heapTop` is the pile's outline, `SPRAY` where the duet's arcs start), `Opened` and `OPENED` are the three that light up. `Letter`, `Hero` and `LastOne` are the two big ones. This is the biggest job: tabs, shirts or chat bubbles all fit the same schedule |
| The confetti | `CONF`, and `CONF_C` for its colors |
| The backing video | `Beach`: `sunY` moves the sun, `Palm`, `Gull`, `CLOUDS` and the boat are details |
| Its colors | `SUNSET`, `MAGENTA`, `DUSK`, `WAKE`, `DAWN`, and `STEPS` for when each arrives |
| The singers | `Singer` draws them, the poses are named in `MIC` to `STOOP`, and `POSE_A` and `POSE_B` key them to events. The singers are frozen from `cut` to `del`, so put the next pose on `del` |
| The low point | `beachClock` stops the video, `lowK` drains it, `flatK` flattens the lane, `Vhs` draws the streaks. `TAPS` lists the mic taps |
| The way out | `Marquee`, `BigCount`, `Dialog`, and `sel` in `Envelopes`, which turns them blue left to right |
| The notes | `NOTES` makes one note per sung word from `WORDS`, so a re-voice moves them. `sung` says which words count, `duetOf` splits the duet, and the last rows are the climb |
| The lyric rows | `PER_ROW`, `FONT`, `ROW1`, `ROW2`, `LEFT`, `RIGHT`. A row breaks at every comma, a chunk ends at every full stop, and `inkOf` picks the wipe's color |
| The sand joke | `SandWriting` writes `QUIET_CAPTION`, and the `x` in `LastOne` lands the envelope on its last word |
| Hits and jumps | `HITS` for which events shake the screen, `keyLift` for the jump that stays |
| The comment keyword | `plan.json` and `KEYWORD`, which sets the sign's last word and the first branch of `Lyrics`. That branch takes the first two words after `cta` as COMMENT and the keyword, so keep your line in that order |
- **Scene ids in the code:** none. Everything reads events by name, so renumbering scenes only touches `plan.json` and `story.json`.
- **Repeated words:** story.json can only name a word's first use, so `wordAfter` finds the second "reply" and the second "select". Change `REPLY2` and `SELECT2` with your lyric, or delete them with the duet.
- **What's sized to our words.** `fit()` shrinks the keyword, ribbon, dialog, prompt, duet labels and big count, so check those on the contact sheet and leave them be. Three things don't shrink: a menu title holds about 23 letters at 66 px, a lyric row 17 at 74 px, and a lane tag about 10 at 40 px.
- **Layers:** `Stage` draws back to front. The lyrics sit outside the screen and its clip, so a star wipe or a jump never moves them.
- **Delete an event with its use.** `story.json` events that no longer match a word stop the render, which is what you want. `thing` is there only so a sound can land on it.
### The code
`plan.json`, our demo's script:
```json
{
"lines": [
{
"id": "l01",
"text": "Next up, table nine, with a song about their inbox."
},
{
"id": "l02",
"text": "I left you on unread, in the fall of twenty-three."
},
{
"id": "l03",
"text": "You said, per my last email, but you never wrote to me."
},
{
"id": "l04",
"text": "Key change!"
},
{
"id": "l05",
"text": "Reply all, reply all, why did you reply all?"
},
{
"id": "l06",
"text": "Forty thousand subjects, and I've opened only three."
},
{
"id": "l07",
"text": "Is this thing on?"
},
{
"id": "l08",
"text": "Select all. Delete. Oh, select all."
},
{
"id": "l09",
"text": "Score, ninety-nine. One unread."
},
{
"id": "l10",
"text": "Encore? Press any key to reply."
},
{
"id": "l11",
"text": "Comment encore and I'll send you the skill that made this.",
"say": "Comment, encore, and I'll send you the skill that made this."
}
],
"scenes": [
{
"id": "s01",
"lines": [
"l01"
],
"lead": 0.62,
"tail": 0.12
},
{
"id": "s02",
"lines": [
"l02"
],
"lead": 0.85,
"tail": 0.1
},
{
"id": "s03",
"lines": [
"l03"
],
"lead": 0.15,
"tail": 0.15
},
{
"id": "s04",
"lines": [
"l04"
],
"lead": 0.35,
"tail": 0.15
},
{
"id": "s05",
"lines": [
"l05"
],
"lead": 0.12,
"tail": 0.15
},
{
"id": "s06",
"lines": [
"l06"
],
"lead": 0.12,
"tail": 0.1
},
{
"id": "s07",
"lines": [
"l07"
],
"lead": 0.85,
"tail": 0.25
},
{
"id": "s08",
"lines": [
"l08"
],
"lead": 0.65,
"tail": 0.12
},
{
"id": "s09",
"sec": 3.3
},
{
"id": "s10",
"lines": [
"l09"
],
"lead": 0.12,
"tail": 0.15
},
{
"id": "s11",
"lines": [
"l10"
],
"lead": 0.12,
"tail": 0.15
},
{
"id": "s12",
"lines": [
"l11"
],
"lead": 0.15,
"tail": 0.1
}
]
}
```
`src/index.ts`:
```ts
import { registerRoot } from 'remotion';
import { Root } from './Root';
registerRoot(Root);
```
`src/Root.tsx`:
```tsx
import React from 'react';
import { Composition } from 'remotion';
import { KaraokeNight } from './frames/KaraokeNight';
import spec from './spec.json';
export const Root: React.FC = () => (
<Composition id="Film-9x16" component={KaraokeNight} 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": "pick",
"scene": "s01",
"word": "inbox"
},
{
"id": "song",
"scene": "s02"
},
{
"id": "unread",
"scene": "s02",
"word": "unread"
},
{
"id": "twenty",
"scene": "s02",
"word": "twenty"
},
{
"id": "per",
"scene": "s03",
"word": "per"
},
{
"id": "email",
"scene": "s03",
"word": "email"
},
{
"id": "never",
"scene": "s03",
"word": "never"
},
{
"id": "key",
"scene": "s04",
"word": "Key"
},
{
"id": "reply1",
"scene": "s05",
"word": "Reply"
},
{
"id": "why",
"scene": "s05",
"word": "why"
},
{
"id": "forty",
"scene": "s06",
"word": "Forty"
},
{
"id": "three",
"scene": "s06",
"word": "three"
},
{
"id": "low",
"scene": "s07"
},
{
"id": "cut",
"scene": "s07",
"off": 0.52
},
{
"id": "tap1",
"scene": "s07",
"off": 0.78
},
{
"id": "thing",
"scene": "s07",
"word": "thing"
},
{
"id": "tap2",
"scene": "s07",
"word": "on",
"off": 0.34
},
{
"id": "select",
"scene": "s08",
"word": "Select"
},
{
"id": "del",
"scene": "s08",
"word": "Delete"
},
{
"id": "oh",
"scene": "s08",
"word": "Oh"
},
{
"id": "quiet",
"scene": "s09"
},
{
"id": "burst",
"scene": "s09",
"off": 1.2
},
{
"id": "score",
"scene": "s10",
"word": "Score"
},
{
"id": "ninety",
"scene": "s10",
"word": "ninety"
},
{
"id": "one",
"scene": "s10",
"word": "One"
},
{
"id": "encore",
"scene": "s11",
"word": "Encore"
},
{
"id": "press",
"scene": "s11",
"word": "Press"
},
{
"id": "cta",
"scene": "s12"
},
{
"id": "keyword",
"scene": "s12",
"word": "encore"
}
]
}
```
`src/frames/KaraokeNight.tsx`:
```tsx
import React from 'react';
import { AbsoluteFill, interpolateColors } from 'remotion';
import story from './story.json';
import { back, clamp, Clock, easeIn, easeInOut, easeOut, Fonts, hash, makeE, ramp, SCENES, useT, WORDS, type StoryJson, type Word } from './core';
/* Karaoke Night: a bar's karaoke screen between velvet curtains. A cheesy sunset beach plays behind word-by-word lyric wipes, a pitch
lane and a score, and the backing video follows the song. 1080 x 1920. Time comes from the spoken words through story.json. */
const E = makeE(story as StoryJson);
/* ---------- the words on screen: every title and number here is invented ---------- */
const KEYWORD = 'ENCORE', QUIET_CAPTION = 'INBOX ZERO', SIGN_TEXT = 'KARAOKE';
const SIGN_KEEP = [4, 5]; // the letters that stay lit at the low point: KARA[OK]E
const TABLE = 'TABLE 9', NEXT_UP = 'NEXT UP';
const SONG = 'UNREAD', SONG_SUB = 'A BALLAD, FOR TABLE 9', MENU_TITLE = 'SONG BOOK';
const MENU: [string, string, string][] = [
['0911', 'PLEASE SEE ATTACHED', 'BALLAD'],
['0912', 'CIRCLE BACK TO ME', 'DUET'],
['0913', 'SORRY, JUST SEEING THIS', 'SLOW'],
['0914', SONG, 'BALLAD'],
['0915', 'LOOPING YOU IN', 'DUET'],
['0916', 'OUT OF OFFICE, FOREVER', 'SLOW'],
['0917', 'BOUNCED', 'BALLAD'],
];
const MENU_PICK = 3;
const MENU_PATH = [0, 1, 2, 3, 4, 5, 6, 5, 4, MENU_PICK]; // the rows the bar visits before it lands
const POSTMARK = ['FALL', '2023'], COUNTER_LABEL = 'UNREAD';
const KEY_POP = 'KEY +1', DUET_A = 'YOU', DUET_B = 'EVERYONE';
const STALLED = 'AND THE THIRD WAS'; // the next lyric, which the mic cuts off
const STALL_AT = 8.6; // how many letters of it get wiped before the cut
const OPENED_LABEL = 'OPENED: 3', SELECTED_LABEL = 'ALL SELECTED';
const DIALOG = 'DELETE 40,000 UNREAD?', YES = 'YES', NO = 'NO';
const SCORE_LABEL = 'SCORE', SCORE_END = 99, SCORE_RIBBON = 'ONE UNREAD';
const PROMPT = 'PRESS ANY KEY TO REPLY';
/* What the lane says about the singing, each on its event. */
const TAGS: [string, string, 'c' | 'n' | 'g'][] = [['unread', 'NICE', 'c'], ['per', 'PASSIVE', 'c'], ['email', 'AGGRESSIVE', 'n'], ['never', 'FLAT', 'n'], ['key', 'BRAVE', 'g'], ['why', 'WHY', 'g'], ['three', 'OH NO', 'n'], ['select', 'BACK', 'c'], ['oh', 'HOLD IT', 'g']];
/* The unread counter: the number it reaches on each event. */
const COUNTS: [string, number][] = [['unread', 1], ['email', 2], ['never', 9], ['key', 88], ['reply1', 640], ['why', 4800], ['forty', 12400]];
const COUNT_FULL = 40000;
const NEON = '#ff4fa3', CYAN = '#3ad1ff', GOLD = '#ffd23f', WHITE = '#fff6ff', NIGHT = '#14061f', CURTAIN = '#4b1a5e', VIOLET = '#7b2cff', RED = '#ff3b4e', PAPER = '#fff3dc';
const INKS = { c: CYAN, n: NEON, g: GOLD };
const SIGNF = 'Monoton, cursive', LYRIC = '"Archivo Black", sans-serif', BUNGEE = 'Bungee, sans-serif', OSD = 'VT323, monospace';
const FACES = [
{ family: 'Monoton', file: 'monoton-latin-400-normal.woff2' },
{ family: 'Archivo Black', file: 'archivo-black-latin-400-normal.woff2' },
{ family: 'Bungee', file: 'bungee-latin-400-normal.woff2' },
{ family: 'VT323', file: 'vt323-latin-400-normal.woff2' },
];
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
/** The start of the nth word after an event. story.json can only name a repeated word's first use. */
const wordAfter = (id: string, n: number) => WORDS[WORDS.findIndex((w) => w.t0 >= E(id) - 0.001) + n].t0;
const REPLY2 = wordAfter('reply1', 2), SELECT2 = wordAfter('oh', 1);
/** A value that moves in steps: each key eases to `to` from `at` over `d` seconds. Keys stay in time order. */
const track = (t: number, init: number, keys: [number, number, number][]) => {
let v = init;
for (const [at, d, to] of keys) { if (t < at) break; v = lerp(v, to, easeInOut(ramp(t, at, at + d))); }
return v;
};
const fmt = (n: number) => Math.round(n).toLocaleString('en-US');
/** A font size that fits a word into a width: Bungee and Archivo Black run about .74 em a letter. */
const fit = (s: string, width: number, max: number, em = 0.74) => Math.min(max, width / (s.length * em));
/* The backing video has its own clock: it stops when the mic dies and picks up where it left off on "delete". */
const beachClock = (t: number) => (t < E('cut') ? t : t < E('del') ? E('cut') : t - (E('del') - E('cut')));
const lowK = (t: number) => ramp(t, E('cut'), E('cut') + 0.14) * (1 - ramp(t, E('del'), E('del') + 0.25));
const HITS: [number, number][] = ([['key', 26], ['reply1', 7], ['why', 7], ['forty', 12], ['cut', 10], ['tap1', 5], ['tap2', 5], ['del', 12], ['burst', 26], ['ninety', 10]] as [string, number][]).map(([e, a]) => [E(e), a]);
const shake = (t: number) => HITS.reduce((acc, [e, a]) => (t >= e ? acc + a * Math.sin((t - e) * 44) * Math.exp(-(t - e) * 9) : acc), 0);
const keyLift = (t: number) => 44 * back(ramp(t, E('key'), E('key') + 0.2));
/* ---------- the palette of the backing video: sky x4, sea x2, sand x2, silhouette ---------- */
const SUNSET = ['#2a1766', '#a82f78', '#ff6f4a', '#ffc65c', '#ff9a5c', '#5a1c5e', '#e9a25f', '#8e4f38', '#1a0a2a'];
const MAGENTA = ['#4a0a78', '#e0148c', '#ff3fb4', '#ffb0e0', '#ff5cc0', '#6a0a78', '#f08ac0', '#7e2a78', '#22052e'];
const DUSK = ['#0b0a2e', '#2a1763', '#6b2478', '#c24a78', '#7a2a78', '#140a38', '#6a4a78', '#2c1c4a', '#08041a'];
const WAKE = ['#241a6e', '#7a2c9e', '#ff5f8f', '#ffc08a', '#d8689a', '#2a1a66', '#b0809a', '#4a2f66', '#0c0624'];
const DAWN = ['#1aa7e8', '#6fd3f7', '#c6f1fb', '#fff0b8', '#7fe6e6', '#0e8fc0', '#ffe3a0', '#e8b873', '#123a5e'];
const STEPS: [number, number, string[]][] = [[E('key'), 0.16, MAGENTA], [E('forty') - 0.1, 1.2, DUSK], [E('del'), 0.9, WAKE], [E('burst') - 0.1, 0.5, DAWN]];
const palAt = (t: number) => {
let p = SUNSET;
for (const [at, d, to] of STEPS) { if (t < at) break; const k = ramp(t, at, at + d); p = p.map((c, i) => interpolateColors(k, [0, 1], [c, to[i]])); }
return p;
};
/* ---------- the singers: drawn silhouettes in long coats, posed by event ---------- */
type Pose = [number, number, number, number, number, number]; // mic arm, mic forearm, free arm, free forearm, lean, hop
const MIC: Pose = [120, -25, -150, -170, 0, 0], OUT: Pose = [120, -25, -100, -78, -2, 0], HIGH: Pose = [120, -25, -60, -25, -4, 0], LEAP: Pose = [120, -25, -25, -8, -6, 60];
const SLUMP: Pose = [120, -25, -165, -178, 8, 0], BOTH: Pose = [50, 15, -50, -15, -3, 0], WAVE: Pose = [120, -25, -60, -5, 0, 0], STOOP: Pose = [140, 165, -165, -150, 14, 0];
const POSE_A: [number, number, Pose][] = [[E('unread'), 0.5, OUT], [E('email'), 0.4, HIGH], [E('never'), 0.5, SLUMP], [E('key'), 0.12, LEAP], [E('key') + 0.32, 0.3, HIGH], [E('forty'), 0.8, SLUMP], [E('del'), 0.5, OUT], [E('oh'), 0.5, BOTH], [E('score'), 0.5, WAVE], [E('one') + 0.1, 0.5, STOOP], [E('encore'), 0.4, WAVE]];
const POSE_B: [number, number, Pose][] = [[REPLY2, 0.3, HIGH], [E('forty'), 0.8, SLUMP], [E('del'), 0.5, OUT], [E('oh'), 0.5, BOTH], [E('score'), 0.5, WAVE], [E('one') + 0.25, 0.5, STOOP]];
const poseAt = (t: number, keys: [number, number, Pose][]) => {
const pt = t >= E('cut') && t < E('del') ? E('cut') : t; // the singers freeze with the video
return MIC.map((m, i) => track(pt, m, keys.map(([at, d, p]) => [at, d, p[i]] as [number, number, number])));
};
const Singer: React.FC<{ x: number; y: number; s: number; flip?: boolean; pose: number[]; ink: string; rim: string; bt: number; hop?: number }> = ({ x, y, s, flip, pose, ink, rim, bt, hop = 0 }) => {
const [a, af, b, bf, lean, up] = pose, w = 10 * Math.sin(bt * 3.1 + x), h = 7 * Math.sin(bt * 4.3 + x);
const rad = (d: number) => (d * Math.PI) / 180;
const arm = (sx: number, u: number, f: number, L = 70) => {
const ex = sx + L * Math.sin(rad(u)), ey = -254 - L * Math.cos(rad(u));
const hx = ex + L * Math.sin(rad(f)), hy = ey - L * Math.cos(rad(f));
return { d: `M${sx} -254 L${ex.toFixed(1)} ${ey.toFixed(1)} L${hx.toFixed(1)} ${hy.toFixed(1)}`, hx, hy };
};
const m = arm(30, a, af), o = arm(-30, b, bf), md = rad(af - 20);
const body = (c: string) => (
<>
<path d="M-24 -156 L-52 -6 L-60 2 L-20 2 L2 -140 Z M6 -156 L54 -6 L66 2 L24 2 L-12 -140 Z" fill={c} />
<path d={`M-36 -268 Q-6 -280 34 -268 L38 -150 C34 -110 ${-6 + w} -92 ${-86 + w} ${-58 + w * 0.5} C-66 -106 -46 -140 -40 -180 Z`} fill={c} />
<rect x="-8" y="-292" width="18" height="28" fill={c} />
<path d="M-18 -322 C-6 -340 20 -340 28 -320 C31 -312 30 -306 38 -298 C34 -294 32 -292 31 -288 C30 -278 18 -276 6 -278 C-14 -280 -28 -300 -18 -322 Z" fill={c} />
<path d={`M-14 -332 C6 -348 30 -340 32 -320 C16 -326 0 -320 -8 -302 C-14 -286 -30 -270 -56 -262 C-90 ${-252 + h} -120 ${-268 - h} -152 ${-258 + h} C-122 ${-284 + h} -96 -292 -82 -310 C-66 -330 -36 -338 -14 -332 Z`} fill={c} />
<path d={o.d} fill="none" stroke={c} strokeWidth="20" strokeLinecap="round" strokeLinejoin="round" />
<path d={m.d} fill="none" stroke={c} strokeWidth="20" strokeLinecap="round" strokeLinejoin="round" />
<line x1={m.hx} y1={m.hy} x2={m.hx + 24 * Math.sin(md)} y2={m.hy - 24 * Math.cos(md)} stroke={c} strokeWidth="10" strokeLinecap="round" />
<circle cx={m.hx + 32 * Math.sin(md)} cy={m.hy - 32 * Math.cos(md)} r="12" fill={c} />
</>
);
return (
<g transform={`translate(${x} ${y - up - hop}) scale(${flip ? -s : s} ${s}) rotate(${lean + 1.6 * Math.sin(bt * 1.5 + x)} 0 -150)`}>
<g transform="translate(5 -2)" opacity=".8">{body(rim)}</g>
{body(ink)}
</g>
);
};
/* ---------- the envelopes: they wash in, then pile up, then burst ---------- */
const EnvG: React.FC<{ paper: string; sel: boolean; dot?: boolean }> = ({ paper, sel, dot = true }) => (
<>
<rect x="-36" y="-24" width="72" height="48" rx="3" fill={sel ? '#2d6bff' : paper} stroke="rgba(20,6,31,.55)" strokeWidth="2" />
<path d="M-36 -24 L0 6 L36 -24" fill="none" stroke={sel ? '#bcd4ff' : 'rgba(20,6,31,.5)'} strokeWidth="2.5" />
{sel ? <path d="M-13 3 L-3 14 L16 -9" fill="none" stroke={WHITE} strokeWidth="6" strokeLinecap="round" strokeLinejoin="round" /> : dot ? <circle cx="30" cy="-22" r="10" fill={RED} /> : null}
</>
);
const heapTop = (x: number) => 1010 - 240 * Math.max(0, 1 - Math.pow((x - 560) / 520, 2));
const SPRAY: [number, number] = [790, 760];
const ENVS = Array.from({ length: 170 }, (_, i) => {
const z = i / 170, x = 70 + hash(i + 1) * 940, r = hash(i + 301);
const y = z < 0.13 ? 985 + r * 60 : z < 0.42 ? 950 + r * 110 : lerp(heapTop(x), 1062, Math.pow(r, 0.8));
const at = z < 0.04 ? lerp(E('email'), E('key'), z / 0.04) : z < 0.13 ? lerp(E('key'), E('reply1'), (z - 0.04) / 0.09) : z < 0.42 ? lerp(E('reply1'), E('forty'), (z - 0.13) / 0.29) : lerp(E('forty'), E('three') + 0.2, (z - 0.42) / 0.58);
const from: [number, number] = z < 0.13 ? [x + 30, 950] : z < 0.42 ? (i % 2 ? SPRAY : [340, 760]) : [x + 40, 170];
return { i, x, y, at, from, arc: z >= 0.13 && z < 0.42 ? 190 : 0, s: 0.62 + (y - 760) / 620, rot: -20 + hash(i + 77) * 40, gone: E('burst') + Math.hypot(x - 540, y - 800) / 2200 };
}).sort((a, b) => a.y - b.y);
const Envelopes: React.FC<{ paper: string }> = ({ paper }) => {
const t = useT(), lift = ramp(t, E('del'), E('quiet') + 1.1);
return (
<>
{ENVS.map((e) => {
if (t < e.at || t >= e.gone) return null;
const k = easeOut(ramp(t, e.at, e.at + 0.42));
const jig = lift > 0 ? lift * 7 * Math.sin(t * 34 + e.i) : 0;
const x = lerp(e.from[0], e.x, k) + jig, y = lerp(e.from[1], e.y, k) - e.arc * 4 * k * (1 - k) - lift * (10 + hash(e.i) * 34);
const sel = t >= E('select') + 0.05 + (0.55 * (e.x - 62)) / 956;
return <g key={e.i} transform={`translate(${x.toFixed(1)} ${y.toFixed(1)}) rotate(${(e.rot * k + (1 - k) * 200).toFixed(1)}) scale(${(e.s * lerp(0.5, 1, k)).toFixed(3)})`}><EnvG paper={paper} sel={sel} /></g>;
})}
</>
);
};
/* The three anyone ever opened, lit up on the word. */
const OPENED: [number, number, number][] = [[196, 1000, -9], [520, 1016, 5], [716, 992, -5]];
const Opened: React.FC = () => {
const t = useT(), at = E('three') - 0.05;
if (t < at || t >= E('select')) return null;
return (
<>
{OPENED.map(([x, y, r], i) => (
<g key={i} transform={`translate(${x} ${y}) rotate(${r}) scale(${1.6 * back(ramp(t, at + i * 0.09, at + i * 0.09 + 0.2))})`}>
<circle r="56" fill="rgba(255,210,63,.3)" />
<path d="M-36 -22 L0 -56 L36 -22 Z" fill={WHITE} stroke="rgba(20,6,31,.55)" strokeWidth="2" />
<rect x="-36" y="-22" width="72" height="46" rx="3" fill={PAPER} stroke={GOLD} strokeWidth="3" />
<path d="M-36 24 L0 -4 L36 24" fill="none" stroke="rgba(20,6,31,.4)" strokeWidth="2.5" />
</g>
))}
</>
);
};
const CONF_C = [NEON, CYAN, GOLD, PAPER, VIOLET, WHITE];
const CONF = Array.from({ length: 320 }, (_, i) => {
const dir = hash(i + 11) - 0.5;
return { c: CONF_C[i % CONF_C.length], x: 80 + hash(i + 31) * 920, y: 800 + hash(i + 37) * 260, vx: dir * 1500, vy: -700 - hash(i + 23) * 900, w: 16 + hash(i + 41) * 30, h: 10 + hash(i + 43) * 18, r: hash(i + 47) * 360, rs: 200 + hash(i + 53) * 500, d: hash(i + 59) * 0.2, g: 420 + hash(i + 61) * 420 };
});
const Confetti: React.FC = () => {
const t = useT(), b = E('burst');
if (t < b || t > b + 5) return null;
return (
<>
{CONF.map((c, i) => {
const u = t - b - c.d;
if (u < 0) return null;
const y = c.y + c.vy * u + c.g * u * u;
if (y > 1560) return null;
const flatten = 0.25 + 0.75 * Math.abs(Math.cos(u * 6 + i));
return <rect key={i} x={-c.w / 2} y={-c.h / 2} width={c.w} height={c.h * flatten} rx="2" fill={c.c} transform={`translate(${(c.x + c.vx * u * Math.exp(-u * 0.7)).toFixed(1)} ${y.toFixed(1)}) rotate(${(c.r + u * c.rs).toFixed(0)})`} />;
})}
</>
);
};
/* ---------- the backing video ---------- */
const CLOUDS = Array.from({ length: 7 }, (_, i) => ({ x: hash(i + 3) * 1200, y: 330 + hash(i + 8) * 300, w: 240 + hash(i + 13) * 420, h: 10 + hash(i + 17) * 20, sp: 4 + hash(i + 19) * 9 }));
const surf = (y: number, amp: number, ph: number) => Array.from({ length: 25 }, (_, i) => { const x = 62 + i * 40; return `${i ? 'L' : 'M'}${x} ${(y + amp * Math.sin(i * 0.62 + ph) + amp * 0.5 * Math.sin(i * 1.7 - ph * 0.7)).toFixed(1)}`; }).join(' ');
const FRONDS = [-150, -112, -74, -36, 4, 44, 88, 132];
const Palm: React.FC<{ ink: string; bt: number }> = ({ ink, bt }) => (
<g transform="translate(950 1010)">
<path d="M-16 0 Q-10 -280 -92 -560 L-72 -566 Q12 -280 18 0 Z" fill={ink} />
{FRONDS.map((a, i) => <path key={i} d="M0 0 C60 -54 150 -44 226 28 C150 -14 70 -10 0 12 Z" fill={ink} transform={`translate(-82 -562) rotate(${a + 3 * Math.sin(bt * 1.3 + i)})`} />)}
<circle cx="-70" cy="-540" r="11" fill={ink} /><circle cx="-92" cy="-536" r="11" fill={ink} />
</g>
);
const Gull: React.FC<{ x: number; y: number; s: number; flap: number; ink: string; carry?: string }> = ({ x, y, s, flap, ink, carry }) => (
<g transform={`translate(${x} ${y}) scale(${s})`}>
<path d={`M-46 ${-14 - flap * 14} Q-22 ${-16 - flap * 6} 0 0 Q22 ${-16 - flap * 6} 46 ${-14 - flap * 14} Q22 ${-4 - flap * 3} 0 10 Q-22 ${-4 - flap * 3} -46 ${-14 - flap * 14} Z`} fill={ink} />
{carry ? <g transform="translate(2 34) scale(.62)"><EnvG paper={carry} sel={false} /></g> : null}
</g>
);
const STIPPLE = Array.from({ length: 260 }, (_, i) => `M${(62 + hash(i + 400) * 956).toFixed(0)} ${(1030 + hash(i + 900) * 240).toFixed(0)}h${(2 + hash(i + 1300) * 5).toFixed(1)}`).join('');
const Beach: React.FC = () => {
const t = useT(), bt = beachClock(t), [a, b, c, d, seaA, seaB, sandA, sandB, ink] = palAt(t), up = keyLift(t);
const sunY = track(t, 628, [[E('key'), 0.14, 540], [E('forty'), 1.6, 716], [E('del'), 1, 660], [E('burst'), 1.3, 452]]);
const sunC = interpolateColors(ramp(t, E('burst'), E('burst') + 0.6), [0, 1], ['#ffb347', '#fff2a6']);
const paper = interpolateColors(ramp(t, E('forty'), E('forty') + 1.2), [0, 1], [PAPER, '#c4b8de']);
const crossing = ramp(t, E('email') - 0.1, E('key')), edge = 1000 + 14 * Math.sin(bt * 0.9);
const push = 1 + 0.035 * ramp(bt, 0, 30), splitA = poseAt(t, POSE_A), splitB = poseAt(t, POSE_B);
const hop = (ph: number) => Math.abs(Math.sin(t * 5.2 + ph)) * 34 * ramp(t, E('burst'), E('burst') + 0.1) * (1 - ramp(t, E('score') - 0.2, E('score') + 0.2));
const bx = lerp(1180, 790, easeOut(ramp(t, REPLY2 - 0.35, REPLY2 + 0.05)));
return (
<svg viewBox="62 252 956 1234" width={956} height={1234} style={{ position: 'absolute', left: 0, top: 0, display: 'block' }}>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor={a} /><stop offset=".45" stopColor={b} /><stop offset=".8" stopColor={c} /><stop offset="1" stopColor={d} /></linearGradient>
<linearGradient id="sea" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor={seaA} /><stop offset="1" stopColor={seaB} /></linearGradient>
<linearGradient id="sand" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor={sandA} /><stop offset="1" stopColor={sandB} /></linearGradient>
<radialGradient id="glow"><stop offset="0" stopColor={d} stopOpacity=".95" /><stop offset=".45" stopColor={d} stopOpacity=".35" /><stop offset="1" stopColor={d} stopOpacity="0" /></radialGradient>
<linearGradient id="sun" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#fff4b8" /><stop offset=".6" stopColor={sunC} /><stop offset="1" stopColor={c} /></linearGradient>
</defs>
<g transform={`translate(0 ${-up}) translate(540 800) scale(${push}) translate(-540 -800)`}>
<rect x="0" y="120" width="1080" height="600" fill="url(#sky)" />
{CLOUDS.map((k, i) => <rect key={i} x={((k.x + bt * k.sp) % 1400) - 200} y={k.y} width={k.w} height={k.h} rx={k.h / 2} fill={d} opacity={0.26} />)}
<circle cx="640" cy={sunY} r="360" fill="url(#glow)" />
<circle cx="640" cy={sunY} r="118" fill="url(#sun)" />
<rect x="0" y="710" width="1080" height="300" fill="url(#sea)" />
<g fill={d}>{Array.from({ length: 38 }, (_, i) => { const y = 716 + hash(i + 2) * 250, w = 22 + (y - 716) * 0.32, sp = 40 + (y - 716) * 0.42; return <rect key={i} x={640 - w / 2 + (hash(i + 5) - 0.5) * sp * 2} y={y} width={w} height={3 + (y - 716) * 0.012} rx="2" opacity={0.25 + 0.6 * Math.abs(Math.sin(bt * 2.2 + i * 1.7))} />; })}</g>
<path d="M62 712 L62 700 C110 688 150 696 190 704 C226 694 250 702 280 712 Z" fill={ink} opacity=".8" />
<g transform={`translate(${((220 + bt * 6) % 900) + 62} 704)`} fill={ink}><path d="M0 -40 L0 -4 L26 -4 Z" /><path d="M-3 -32 L-3 -4 L-20 -4 Z" /><path d="M-22 0 L28 0 L20 8 L-14 8 Z" /></g>
<rect x="0" y="984" width="1080" height="800" fill="url(#sand)" />
<path d={STIPPLE} stroke={sandB} strokeWidth="4" strokeLinecap="round" opacity=".55" />
<path d={`${surf(edge, 7, bt * 0.7)} L1018 960 L62 960 Z`} fill={seaB} opacity=".82" />
<path d={surf(edge, 7, bt * 0.7)} fill="none" stroke={PAPER} strokeWidth="7" opacity=".82" strokeLinecap="round" />
<path d={surf(edge - 24, 5, bt * 0.7 + 1)} fill="none" stroke={PAPER} strokeWidth="3" opacity=".42" />
<Palm ink={ink} bt={bt} />
<Singer x={328} y={1022} s={0.98} pose={splitA} ink={ink} rim={d} bt={bt} hop={hop(0)} />
{t >= REPLY2 - 0.35 ? <Singer x={bx} y={1026} s={0.92} flip pose={splitB} ink={ink} rim={d} bt={bt} hop={hop(1.1)} /> : null}
<Envelopes paper={paper} />
<Opened />
</g>
{crossing > 0 && crossing < 1 ? <Gull x={lerp(-40, 1120, crossing)} y={430 + 26 * Math.sin(crossing * 9)} s={1.2} flap={Math.sin(bt * 9)} ink={ink} carry={PAPER} /> : null}
{t > E('press') + 0.4 && t < E('press') + 5 ? <Gull x={lerp(-60, 1140, (t - E('press') - 0.4) / 4.6)} y={330 + 20 * Math.sin(t * 2)} s={1.1} flap={Math.sin(bt * 9)} ink={ink} carry={PAPER} /> : null}
<Gull x={((bt * 26 + 820) % 1200) - 40} y={360 + 18 * Math.sin(bt * 0.8)} s={0.55} flap={Math.sin(bt * 6)} ink={ink} />
<Confetti />
</svg>
);
};
/* Before the song: the machine's blue menu. The bar visits the rows and lands on the song. */
const Menu: React.FC = () => {
const t = useT();
if (t > E('song') + 0.7) return null;
const k = ramp(t, 0.05, E('pick') - 0.05);
const pos = MENU_PATH[Math.min(MENU_PATH.length - 1, Math.floor(k * MENU_PATH.length))], picked = t >= E('pick');
const pulse = picked ? Math.floor((t - E('pick')) * 5) % 2 : 0;
const lift = picked ? back(ramp(t, E('pick'), E('pick') + 0.18)) : 0;
return (
<div style={{ position: 'absolute', left: 0, top: 0, width: 956, height: 1234, background: 'linear-gradient(#0d1aa0,#0a1078 62%,#070a4c)' }}>
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'repeating-linear-gradient(135deg,rgba(90,120,255,.07) 0 26px,transparent 26px 52px)', backgroundPosition: `${-t * 28}px 0` }} />
<div style={{ position: 'absolute', left: 28, top: 26, font: `52px/1 ${OSD}`, color: CYAN, letterSpacing: '.04em' }}>{NEXT_UP}</div>
<div style={{ position: 'absolute', left: 26, top: 72, font: `112px/1 ${BUNGEE}`, color: GOLD, textShadow: `5px 5px 0 ${NIGHT}` }}>{TABLE}</div>
<div style={{ position: 'absolute', right: 86, top: 40, width: 224, textAlign: 'center', font: `44px/1 ${OSD}`, color: WHITE }}>SONG NO.<div style={{ marginTop: 8, background: '#080210', borderRadius: 8, font: `104px/104px ${OSD}`, color: RED, letterSpacing: '.06em' }}>{MENU[pos][0]}</div></div>
<div style={{ position: 'absolute', left: 0, right: 0, top: 204, height: 56, background: 'rgba(58,209,255,.16)', font: `50px/56px ${OSD}`, color: CYAN, paddingLeft: 28, letterSpacing: '.08em' }}>{MENU_TITLE}</div>
{MENU.map((m, i) => {
const on = i === pos;
return (
<div key={m[0]} style={{ position: 'absolute', left: 14, top: 278 + i * 98, width: 856, height: 90, borderRadius: 10, background: on ? (pulse ? WHITE : GOLD) : 'transparent', transform: on ? `scale(${1 + 0.04 * lift})` : undefined, transformOrigin: '10% 50%', color: on ? NIGHT : WHITE, font: `66px/90px ${OSD}`, whiteSpace: 'nowrap' }}>
<span style={{ position: 'absolute', left: 16, color: on ? NIGHT : GOLD }}>{m[0]}</span>
<span style={{ position: 'absolute', left: 134 }}>{m[1]}</span>
<span style={{ position: 'absolute', right: 14, font: `40px/90px ${OSD}`, opacity: 0.8 }}>{m[2]}</span>
</div>
);
})}
</div>
);
};
/** The corniest transition a video editor ever shipped, and exactly right here. */
const star = (cx: number, cy: number, R: number) => Array.from({ length: 10 }, (_, i) => { const a = -Math.PI / 2 + (i * Math.PI) / 5, r = i % 2 ? R * 0.42 : R; return `${(cx + r * Math.cos(a)).toFixed(1)}px ${(cy + r * Math.sin(a)).toFixed(1)}px`; }).join(',');
/* ---------- on-screen display ---------- */
const countAt = (t: number) => {
if (t >= E('one')) return 1;
const fall = ramp(t, E('burst'), E('burst') + 1);
if (fall >= 1) return 0;
if (t < E(COUNTS[0][0])) return 0;
let v = COUNTS[0][1];
COUNTS.forEach(([id, n], i) => { if (t >= E(id)) v = i + 1 < COUNTS.length ? Math.exp(lerp(Math.log(n), Math.log(COUNTS[i + 1][1]), clamp((t - E(id)) / (E(COUNTS[i + 1][0]) - E(id))))) : n; });
v = lerp(v, COUNT_FULL, easeOut(ramp(t, E('forty'), E('forty') + 0.7)));
return v * (1 - easeOut(fall));
};
const Hud: React.FC = () => {
const t = useT(), low = lowK(t), n = countAt(t), bump = t >= E('one') ? back(ramp(t, E('one'), E('one') + 0.2)) : 1;
return (
<div style={{ position: 'absolute', left: 84, top: 268 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, font: `56px/1 ${OSD}`, color: low > 0.5 ? GOLD : WHITE, textShadow: `3px 0 0 rgba(255,79,163,.7), -3px 0 0 rgba(58,209,255,.7)` }}>
{low > 0.5 ? <span style={{ display: 'flex', gap: 7 }}><i style={{ width: 13, height: 40, background: GOLD }} /><i style={{ width: 13, height: 40, background: GOLD }} /></span> : <i style={{ width: 0, height: 0, borderTop: '20px solid transparent', borderBottom: '20px solid transparent', borderLeft: `32px solid ${WHITE}` }} />}
{low > 0.5 ? 'PAUSE' : 'PLAY'}
</div>
<div style={{ marginTop: 10, display: 'inline-flex', alignItems: 'center', gap: 14, padding: '7px 18px 7px 12px', borderRadius: 40, background: 'rgba(20,6,31,.78)', font: `38px/1 ${BUNGEE}`, color: WHITE, transform: `scale(${bump})`, transformOrigin: '0 50%' }}>
<i style={{ width: 28, height: 28, borderRadius: '50%', background: n >= 1 ? RED : '#3fd47a' }} />{COUNTER_LABEL} {fmt(n)}
</div>
</div>
);
};
const STAR_AT: [number, number][] = [[250, 470], [150, 610], [320, 590], [790, 300], [860, 480], [940, 620], [760, 590], [120, 440], [960, 380]];
const Stars: React.FC<{ at: number }> = ({ at }) => {
const t = useT();
if (t < at) return null;
const pts = Array.from({ length: 10 }, (_, j) => { const q = -Math.PI / 2 + (j * Math.PI) / 5, R = j % 2 ? 11 : 26; return `${(R * Math.cos(q)).toFixed(1)},${(R * Math.sin(q)).toFixed(1)}`; }).join(' ');
return (
<svg viewBox="0 0 1080 1920" width={1080} height={1920} style={{ position: 'absolute', left: 0, top: 0, pointerEvents: 'none' }}>
{STAR_AT.map(([x, y], i) => {
const u = t - at - i * 0.03, k = easeOut(ramp(u, 0, 0.4));
return <polygon key={i} points={pts} fill={GOLD} stroke={NIGHT} strokeWidth="4" transform={`translate(${lerp(540, x, k).toFixed(1)} ${lerp(450, y, k).toFixed(1)}) rotate(${(u * 60 + i * 40).toFixed(1)}) scale(${(k * (0.9 + 0.7 * Math.sin(t * 2 + i) ** 2)).toFixed(3)})`} />;
})}
</svg>
);
};
/* The score lives small in the corner, then comes to the middle of the sky for the tally. */
const scoreAt = (t: number) => {
const sung = WORDS.filter((w) => w.t1 <= t && w.t0 >= E('song') && w.t0 < E('low')).length;
const all = WORDS.filter((w) => w.t0 >= E('song') && w.t0 < E('low')).length;
const base = (41 * sung) / all + 9 * ramp(t, E('select'), E('quiet'));
const tally = 48 * easeOut(ramp(t, E('quiet') + 0.3, E('quiet') + 3.2));
return t >= E('ninety') ? SCORE_END : Math.min(SCORE_END - 1, base + tally + ramp(t, E('score'), E('ninety')));
};
const Score: React.FC = () => {
const t = useT(), grow = easeInOut(ramp(t, E('burst') + 0.1, E('burst') + 0.6));
const hit = t >= E('ninety') ? 1 + 0.14 * (1 - easeOut(ramp(t, E('ninety'), E('ninety') + 0.28))) : 1;
const x = lerp(856, 540, grow), y = lerp(312, 436, grow), s = lerp(1, 2.9, grow) * hit;
const rib = t >= E('one') ? back(ramp(t, E('one'), E('one') + 0.22)) : 0;
return (
<>
<div style={{ position: 'absolute', left: x - 140, top: y - 60, width: 280, height: 120, transform: `scale(${s})`, textAlign: 'center' }}>
<div style={{ font: `26px/26px ${BUNGEE}`, color: WHITE, WebkitTextStroke: `5px ${NIGHT}`, paintOrder: 'stroke fill' }}>{SCORE_LABEL}</div>
<div style={{ font: `78px/84px ${BUNGEE}`, color: grow > 0.5 ? WHITE : GOLD, WebkitTextStroke: `${grow > 0.5 ? 5 : 8}px ${NIGHT}`, paintOrder: 'stroke fill', textShadow: grow > 0.5 ? `3px 3px 0 ${NEON}` : undefined }}>{String(Math.round(scoreAt(t))).padStart(2, '0')}</div>
</div>
<Stars at={E('ninety')} />
{rib > 0 ? <div style={{ position: 'absolute', left: 540 - 250, top: 568, width: 500, height: 86, borderRadius: 12, background: NEON, color: WHITE, textAlign: 'center', font: `${fit(SCORE_RIBBON, 460, 58)}px/86px ${BUNGEE}`, transform: `scale(${rib}) rotate(-2deg)`, boxShadow: `0 8px 0 ${NIGHT}`, whiteSpace: 'nowrap' }}>{SCORE_RIBBON}</div> : null}
</>
);
};
/** One text pop in the sky: scale up, hold, fade. */
const Pop: React.FC<{ at: number; to: number; y: number; size: number; color: string; children: React.ReactNode }> = ({ at, to, y, size, color, children }) => {
const t = useT();
if (t < at || t > to + 0.3) return null;
const k = back(ramp(t, at, at + 0.2));
return <div style={{ position: 'absolute', left: 0, width: 1080, top: y, textAlign: 'center', font: `${size}px/1.1 ${BUNGEE}`, color, WebkitTextStroke: `${Math.round(size * 0.1)}px ${NIGHT}`, paintOrder: 'stroke fill', transform: `scale(${lerp(0.5, 1, k)})`, opacity: 1 - ramp(t, to, to + 0.3), whiteSpace: 'nowrap' }}>{children}</div>;
};
/* A big envelope in HTML, so its small print stays crisp: the first one that washes in, and the last one that lands. */
const Letter: React.FC<{ x: number; y: number; w: number; rot: number; o?: number; s?: number; badge?: number; children?: React.ReactNode }> = ({ x, y, w, rot, o = 1, s = 1, badge = 1, children }) => (
<div style={{ position: 'absolute', left: x, top: y, width: w, height: w * 0.65, background: PAPER, borderRadius: 6, boxShadow: '0 6px 0 rgba(20,6,31,.45)', transform: `rotate(${rot}deg) scale(${s})`, opacity: o }}>
<svg viewBox="0 0 280 182" width={w} height={w * 0.65} style={{ position: 'absolute', left: 0, top: 0 }}><path d="M0 0 L140 106 L280 0" fill="none" stroke="rgba(20,6,31,.5)" strokeWidth="6" /></svg>
{children}
<div style={{ position: 'absolute', right: -20, top: -20, width: 58, height: 58, borderRadius: '50%', background: RED, color: WHITE, textAlign: 'center', font: `37px/58px ${BUNGEE}`, transform: `scale(${badge})` }}>1</div>
</div>
);
const Hero: React.FC = () => {
const t = useT(), at = E('unread') - 0.25;
if (t < at || t > E('reply1') + 0.2) return null;
const k = easeOut(ramp(t, at, at + 0.5));
const stamp = t >= E('twenty') ? easeIn(ramp(t, E('twenty'), E('twenty') + 0.12)) : 0;
return (
<Letter x={lerp(600, 500, k)} y={lerp(790, 856, k) + 5 * Math.sin(t * 1.6) - keyLift(t)} w={280} rot={-5 + 3 * Math.sin(t * 1.1)} s={lerp(0.25, 1, k)} o={1 - ramp(t, E('reply1') - 0.08, E('reply1') + 0.14)}>
{stamp > 0 ? <div style={{ position: 'absolute', left: 14, top: 40, width: 124, height: 124, borderRadius: '50%', boxSizing: 'border-box', border: `6px solid ${RED}`, color: RED, textAlign: 'center', font: `43px/44px ${OSD}`, paddingTop: 12, transform: `rotate(-14deg) scale(${lerp(1.8, 1, stamp)})`, opacity: 0.9 * stamp }}>{POSTMARK[0]}<br />{POSTMARK[1]}</div> : null}
</Letter>
);
};
/* ---------- the pitch lane: every sung word is a note, so it works with no audio ---------- */
const LX = 78, LY = 1052, LW = 872, LH = 176, HEAD = 240, PX = 290;
const sung = (w: Word) => (w.t0 >= E('song') && w.t0 < E('low')) || (w.t0 >= E('select') && w.t0 < E('quiet'));
const duetOf = (at: number) => (at >= E('forty') || at < E('reply1') ? null : at < REPLY2 ? 'n' : at < E('why') ? 'c' : 'g');
const NOTES = [
...WORDS.filter(sung).map((w, i) => {
const d = duetOf(w.t0);
const p = d === 'n' ? 0.62 + 0.12 * hash(i) : d === 'c' ? 0.12 + 0.12 * hash(i) : Math.round((0.08 + 0.6 * hash(i * 2.7 + 1)) * 6) / 6;
return { t0: w.t0, t1: w.t1, p, d };
}),
...Array.from({ length: 7 }, (_, i) => ({ t0: E('quiet') + i * 0.17, t1: E('quiet') + (i + 1) * 0.17 - 0.015, p: 0.1 + i * 0.15, d: 'g' as string | null })),
];
const laneLift = (t: number) => 0.14 * back(ramp(t, E('key'), E('key') + 0.2)) * (1 - ramp(t, E('low'), E('low') + 0.1));
const flatK = (t: number) => ramp(t, E('cut'), E('cut') + 0.2) * (1 - ramp(t, E('select'), E('select') + 0.2));
const noteY = (p: number, t: number) => lerp(LY + LH - 24 - (p + laneLift(t)) * (LH - 48), LY + LH / 2, flatK(t));
const pitchAt = (t: number) => {
let i = NOTES.findIndex((n) => n.t0 > t);
if (i < 0) i = NOTES.length;
const a = NOTES[Math.max(0, i - 1)], b = NOTES[Math.min(NOTES.length - 1, i)];
if (t <= a.t1 || a === b) return a.p + 0.012 * Math.sin(t * 28);
return lerp(a.p, b.p, easeInOut(clamp((t - a.t1) / Math.max(0.01, b.t0 - a.t1))));
};
const TAPS = ['tap1', 'tap2'].map((id) => E(id));
const flyK = (t: number) => easeIn(ramp(t, E('burst') - 0.26, E('burst')));
const laneIn = (t: number) => ramp(t, E('song') + 0.42, E('song') + 0.62) * (1 - ramp(t, E('burst'), E('burst') + 0.45));
const Lane: React.FC = () => {
const t = useT(), o = laneIn(t);
if (o <= 0) return null;
const flat = flatK(t), barC = (d: string | null) => (d ? INKS[d as 'n'] : VIOLET);
const tag = TAGS.filter(([id]) => t >= E(id) && t < E(id) + 0.85).pop(), keyOn = t >= E('key');
return (
<div style={{ position: 'absolute', left: LX, top: LY, width: LW, height: LH, borderRadius: 28, background: 'rgba(20,6,31,.76)', boxShadow: `inset 0 0 0 4px ${interpolateColors(lowK(t), [0, 1], ['#7a2c96', '#3c3048'])}`, overflow: 'hidden', opacity: o }}>
<svg viewBox={`${LX} ${LY} ${LW} ${LH}`} width={LW} height={LH} style={{ position: 'absolute', left: 0, top: 0 }}>
{[1, 2, 3, 4].map((i) => <rect key={i} x={LX} y={LY + (i * LH) / 5} width={LW} height="2" fill="rgba(255,246,255,.08)" />)}
{NOTES.map((n, i) => {
const x = HEAD + (n.t0 - t) * PX, w = Math.max(14, (n.t1 - n.t0) * PX - 7);
if (x > LX + LW || x + w < LX) return null;
const y = noteY(n.p, t);
const done = clamp(HEAD - x, 0, w);
const g = flat > 0.5 ? '#5a5068' : barC(n.d);
return (
<g key={i}>
<rect x={x} y={y - 12} width={w} height="24" rx="12" fill={g} opacity={n.d ? 0.5 : 0.85} />
{done > 0 && flat < 0.5 ? <rect x={x} y={y - 12} width={Math.max(done, 14)} height="24" rx="12" fill={n.d ? barC(n.d) : keyOn ? NEON : CYAN} /> : null}
</g>
);
})}
{flat > 0 ? <rect x={LX} y={LY + LH / 2 - 2} width={LW} height="4" fill="#8a8098" opacity={flat} /> : null}
{flat > 0 ? TAPS.map((at, i) => { if (t < at) return null; const x = HEAD - (t - at) * 150; return <path key={i} d={`M${x - 26} ${LY + LH / 2} L${x - 10} ${LY + LH / 2 - 56} L${x + 8} ${LY + LH / 2 + 40} L${x + 24} ${LY + LH / 2}`} fill="none" stroke={GOLD} strokeWidth="6" strokeLinejoin="round" opacity={flat} />; }) : null}
<rect x={HEAD - 2} y={LY} width="4" height={LH} fill="rgba(255,246,255,.35)" />
</svg>
{tag && flat < 0.5 ? <div style={{ position: 'absolute', left: 340, top: 10, font: `${tag[1].length > 7 ? 40 : 46}px/1 ${BUNGEE}`, color: INKS[tag[2]], WebkitTextStroke: `7px ${NIGHT}`, paintOrder: 'stroke fill', transform: `scale(${lerp(1.3, 1, easeOut(ramp(t, E(tag[0]), E(tag[0]) + 0.12)))})`, transformOrigin: '0 50%', whiteSpace: 'nowrap' }}>{tag[1]}</div> : null}
{flat > 0.5 ? <div style={{ position: 'absolute', right: 32, top: 24, font: `58px/1 ${BUNGEE}`, color: RED, WebkitTextStroke: `7px ${NIGHT}`, paintOrder: 'stroke fill', whiteSpace: 'nowrap' }}>MIC OFF</div> : null}
<div style={{ position: 'absolute', left: 20, top: 14, font: `34px/1 ${BUNGEE}`, color: keyOn && t < E('low') ? GOLD : '#b6a3c6' }}>KEY {keyOn && t < E('low') ? '+1' : '+0'}</div>
</div>
);
};
/* The gold dot rides the notes, drops to the flat line when the mic dies, and flies out of the lane on the last note. */
const Dot: React.FC = () => {
const t = useT(), fly = flyK(t);
if (t >= E('burst') || laneIn(t) <= 0) return null;
const y0 = noteY(pitchAt(t), t), x = lerp(HEAD, 540, fly), y = lerp(y0, 560, fly), flat = flatK(t);
const s = lerp(1, 2.2, fly), c = flat > 0.5 ? '#8a8098' : GOLD;
return <div style={{ position: 'absolute', left: x - 22, top: y - 22, width: 44, height: 44, borderRadius: '50%', background: c, opacity: laneIn(t) + fly, transform: `scale(${s})`, boxShadow: flat > 0.5 ? undefined : `0 0 0 8px rgba(255,210,63,.32), 0 0 0 18px rgba(255,210,63,.14)` }} />;
};
const Burst: React.FC = () => {
const t = useT(), u = t - E('burst');
if (u < 0 || u > 0.9) return null;
return (
<>
<div style={{ position: 'absolute', left: 62, top: 252, width: 956, height: 1234, background: '#fff7d0', opacity: 0.7 * (1 - ramp(u, 0, 0.32)) }} />
{[0, 0.12].map((d, i) => { const k = easeOut(ramp(u, d, d + 0.7)); return <div key={i} style={{ position: 'absolute', left: 540 - 760 * k, top: 560 - 760 * k, width: 1520 * k, height: 1520 * k, borderRadius: '50%', boxSizing: 'border-box', border: `${Math.max(0, 22 * (1 - k))}px solid ${i ? NEON : GOLD}` }} />; })}
</>
);
};
/* ---------- the low point and the way out ---------- */
const Vhs: React.FC = () => {
const t = useT(), low = lowK(t), seed = Math.floor(t * 8);
return (
<div style={{ position: 'absolute', left: 0, top: 0, width: 956, height: 1234, pointerEvents: 'none', overflow: 'hidden' }}>
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'repeating-linear-gradient(rgba(10,2,20,.13) 0 2px,transparent 2px 5px)' }} />
{low > 0 ? (
<svg viewBox="0 0 956 1234" width={956} height={1234} style={{ position: 'absolute', left: 0, top: 0, opacity: low * 0.7 }}>
{[0, 1].map((b) => { const y0 = ((t * (130 + b * 70) + b * 520) % 1000) - 40; return Array.from({ length: 16 }, (_, i) => <rect key={`${b}-${i}`} x={hash(seed * 3 + i + b * 40) * 956 - 80} y={y0 + hash(i * 7 + b) * (40 + b * 26)} width={30 + hash(seed + i * 5) * 240} height={2 + hash(i + seed) * 5} fill={WHITE} opacity={0.25 + hash(i * 3 + seed) * 0.6} />); })}
</svg>
) : null}
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(100% 70% at 50% 42%,transparent 62%,rgba(10,2,20,.55) 100%)' }} />
</div>
);
};
const Marquee: React.FC = () => {
const t = useT();
if (t < E('select') || t >= E('del') + 0.15) return null;
const k = easeInOut(ramp(t, E('select'), E('select') + 0.55));
return <div style={{ position: 'absolute', left: 74, top: 660, width: 932 * k, height: 392 * k, background: 'rgba(45,107,255,.2)', boxSizing: 'border-box', border: `5px dashed ${WHITE}` }} />;
};
const BigCount: React.FC = () => {
const t = useT();
if (t < E('forty') || t >= E('del') + 0.1) return null;
const k = back(ramp(t, E('forty'), E('forty') + 0.22)), sel = t >= E('select'), color = sel ? '#7fb0ff' : WHITE;
const dim = lowK(t) * (1 - ramp(t, E('select'), E('select') + 0.1));
return (
<div style={{ position: 'absolute', left: 0, width: 1080, top: 400, textAlign: 'center', transform: `scale(${lerp(0.5, 1, k)})`, filter: dim > 0 ? `brightness(${1 - 0.35 * dim})` : undefined }}>
<div style={{ font: `${fit(fmt(COUNT_FULL), 860, 190)}px/1 ${BUNGEE}`, color, WebkitTextStroke: `16px ${NIGHT}`, paintOrder: 'stroke fill' }}>{fmt(countAt(t))}</div>
<div style={{ font: `58px/1.2 ${BUNGEE}`, color: sel ? WHITE : t >= E('three') ? GOLD : WHITE, WebkitTextStroke: `9px ${NIGHT}`, paintOrder: 'stroke fill' }}>{sel ? SELECTED_LABEL : t >= E('three') - 0.05 ? OPENED_LABEL : COUNTER_LABEL}</div>
</div>
);
};
const Dialog: React.FC = () => {
const t = useT();
if (t < E('del') || t >= E('quiet') + 0.14) return null;
const armed = t >= E('oh'), pressed = t >= SELECT2 + 0.15, cur = easeInOut(ramp(t, E('oh') - 0.1, E('oh') + 0.45));
const btn = (s: string, on: boolean, down: boolean) => <div style={{ width: 190, height: 84, textAlign: 'center', font: `50px/84px ${BUNGEE}`, color: on ? NIGHT : '#4a3a5a', background: on ? GOLD : '#c9c2d8', boxShadow: down ? 'inset 5px 5px 0 #6a6078' : `inset -5px -5px 0 #6a6078, inset 5px 5px 0 ${WHITE}`, transform: down ? 'translate(3px,3px)' : undefined }}>{s}</div>;
return (
<div style={{ position: 'absolute', left: 168, top: 412, width: 744, height: 278, background: '#ddd7e8', boxShadow: `inset -6px -6px 0 #6a6078, inset 6px 6px 0 ${WHITE}, 12px 12px 0 rgba(10,2,20,.5)`, transform: `scale(${back(ramp(t, E('del'), E('del') + 0.16)) * (1 - ramp(t, E('quiet'), E('quiet') + 0.14))})` }}>
<div style={{ position: 'absolute', left: 10, top: 10, right: 10, height: 50, background: 'linear-gradient(90deg,#1d1a8c,#c026a0)', color: WHITE, font: `40px/50px ${OSD}`, paddingLeft: 14 }}>{SONG}</div>
<div style={{ position: 'absolute', left: 0, right: 0, top: 80, textAlign: 'center', font: `${fit(DIALOG, 680, 44, 0.76)}px/1 ${BUNGEE}`, color: NIGHT, whiteSpace: 'nowrap' }}>{DIALOG}</div>
<div style={{ position: 'absolute', left: 150, right: 150, top: 160, display: 'flex', justifyContent: 'space-between' }}>{btn(NO, false, false)}{btn(YES, armed, pressed)}</div>
<svg viewBox="0 0 40 56" width={60} height={84} style={{ position: 'absolute', left: lerp(150, 470, cur), top: lerp(250, 188, cur) }}><path d="M2 2 L2 42 L12 33 L20 52 L28 48 L20 30 L34 30 Z" fill={WHITE} stroke={NIGHT} strokeWidth="3" strokeLinejoin="round" /></svg>
</div>
);
};
/* The quiet caption stays behind, written in the clean sand. */
const SandWriting: React.FC = () => {
const t = useT(), at = E('burst') + 0.5;
if (t < at) return null;
const k = ramp(t, at, at + 1.1);
return <div style={{ position: 'absolute', left: 60, width: 890, top: 1056, textAlign: 'center', font: `${fit(QUIET_CAPTION, 780, 92)}px/1 ${BUNGEE}`, color: 'rgba(120,74,30,.62)', textShadow: '3px 3px 0 rgba(255,246,220,.75)', whiteSpace: 'nowrap', clipPath: `inset(-20% ${k >= 1 ? '-20%' : `${(1 - k) * 100}%`} -20% -5%)` }}>{QUIET_CAPTION}</div>;
};
/* The last envelope lands on the clean sand, right on the last word. Inbox zero lasted about three seconds. */
const LastOne: React.FC = () => {
const t = useT(), at = E('one') - 0.45;
if (t < at) return null;
const k = easeOut(ramp(t, at, at + 0.55));
return <Letter x={596 + (1 - k) * 60 * Math.sin(t * 7)} y={lerp(600, 1022, k) - 46 * (Math.sin(Math.PI * ramp(t, E('press'), E('press') + 0.32)) + Math.sin(Math.PI * ramp(t, E('keyword'), E('keyword') + 0.32)))} w={240} rot={lerp(30, -3, k)} badge={1 + 0.12 * Math.sin((t - at) * 4.4)} />;
};
const PromptLine: React.FC = () => {
const t = useT();
if (t < E('press')) return null;
const on = Math.floor((t - E('press')) * 2.6) % 2 === 0; // about 1.3 blinks a second
return <div style={{ position: 'absolute', left: 60, width: 890, top: 1178, textAlign: 'center', font: `${fit(PROMPT, 860, 70, 0.41)}px/1 ${OSD}`, color: WHITE, textShadow: `3px 3px 0 ${NIGHT}, -3px 3px 0 ${NIGHT}, 3px -3px 0 ${NIGHT}, -3px -3px 0 ${NIGHT}`, opacity: on ? 1 : 0.18, whiteSpace: 'nowrap' }}>{PROMPT}</div>;
};
/* ---------- lyrics: each word wipes across its own duration, in one fixed place ---------- */
const PER_ROW = 17, FONT = 74, ROW1 = 1258, ROW2 = 1372, LEFT = 72, RIGHT = 948;
type LW = { s: string; w: Word };
const CHUNKS: { t0: number; rows: LW[][] }[] = [];
for (const sc of SCENES) {
let rows: LW[][] = [[]], len = 0;
const flush = () => { const r = rows.filter((x) => x.length); if (r.length) CHUNKS.push({ t0: r[0][0].w.t0, rows: r }); rows = [[]]; len = 0; };
const nextRow = () => { if (rows.length === 2) flush(); else { rows.push([]); len = 0; } };
for (const w of sc.words) {
const s = w.w.replace(/[.,;:!?"]/g, '').toUpperCase();
if (len && len + 1 + s.length > PER_ROW) nextRow();
rows[rows.length - 1].push({ s, w });
len += (len ? 1 : 0) + s.length;
if (/[.!?]$/.test(w.w)) flush();
else if (/,$/.test(w.w)) nextRow();
}
flush();
}
/* No word left alone on the second row. */
for (const { rows } of CHUNKS) {
if (rows.length === 2 && rows[1].length === 1 && rows[0].length > 2 && rows[0].slice(-1)[0].s.length + 1 + rows[1][0].s.length <= PER_ROW) rows[1].unshift(rows[0].pop() as LW);
}
/* The color a word wipes to: cyan for the verse, pink after the key change, one color per singer in the duet, gold at the end. */
const inkOf = (at: number) => (at < E('song') || at >= E('quiet') ? GOLD : at < E('key') ? CYAN : at >= E('select') ? CYAN : duetOf(at) ? INKS[duetOf(at) as 'n'] : NEON);
const strokeOf = (size: number): React.CSSProperties => ({ WebkitTextStroke: `${Math.round(size * 0.17)}px ${NIGHT}`, paintOrder: 'stroke fill' });
const Wiped: React.FC<{ s: string; k: number; ink: string; size: number; base?: string; font?: string }> = ({ s, k, ink, size, base = WHITE, font = LYRIC }) => (
<span style={{ position: 'relative', display: 'inline-block', font: `${size}px/1.2 ${font}`, marginRight: '.26em', whiteSpace: 'pre', color: base, ...strokeOf(size) }}>
{s}
{k > 0 ? <span style={{ position: 'absolute', left: 0, top: 0, color: ink, clipPath: `inset(-20% ${k >= 1 ? '-20%' : `${(1 - k) * 100}%`} -20% -20%)` }}>{s}</span> : null}
</span>
);
const Lyrics: React.FC = () => {
const t = useT();
const place = (i: number, n: number): React.CSSProperties => (n === 1
? { left: 60, width: 890, top: (ROW1 + ROW2) / 2, textAlign: 'center' }
: i === 0 ? { left: LEFT, top: ROW1 } : { right: 1080 - RIGHT, top: ROW2 });
const rowDiv = (key: string, st: React.CSSProperties, kids: React.ReactNode) => <div key={key} style={{ position: 'absolute', whiteSpace: 'nowrap', ...st }}>{kids}</div>;
if (t >= E('cta') - 0.05) {
const ws = WORDS.filter((w) => w.t0 >= E('cta'));
const rest = ws.slice(2), half = Math.ceil(rest.length * 0.6);
const row = (list: Word[]) => list.map((w, i) => <Wiped key={i} s={w.w.replace(/[.,;:!?"]/g, '').toUpperCase()} k={ramp(t, w.t0, Math.max(w.t1, w.t0 + 0.12))} ink={CYAN} size={44} />);
const big = fit(`COMMENT ${KEYWORD}`, 860, 88, 0.78);
return (
<>
{rowDiv('k', { left: 60, width: 890, top: 1252, textAlign: 'center' }, ws.slice(0, 2).map((w, i) => <Wiped key={i} s={i ? KEYWORD : 'COMMENT'} k={ramp(t, w.t0, Math.max(w.t1, w.t0 + 0.12))} ink={i ? GOLD : NEON} size={big} />))}
{rowDiv('a', { left: 60, width: 890, top: 1362, textAlign: 'center' }, row(rest.slice(0, half)))}
{rowDiv('b', { left: 60, width: 890, top: 1416, textAlign: 'center' }, row(rest.slice(half)))}
</>
);
}
if (t >= E('quiet') && t < E('score') - 0.1) {
return rowDiv('q', { left: 60, width: 890, top: 1296, textAlign: 'center' }, <Wiped s={QUIET_CAPTION} k={ramp(t, E('quiet'), E('burst'))} ink={GOLD} size={fit(QUIET_CAPTION, 850, 108, 0.76)} />);
}
if (t >= E('low') && t < E('select') - 0.06) {
const st = Math.min(t, E('cut'));
const n = STALL_AT * ramp(st, E('low'), E('cut'));
let at = 0;
return (
<>
{rowDiv('s', { left: LEFT, top: ROW1, opacity: lerp(1, 0.7, lowK(t)) }, STALLED.split(' ').map((s, i) => { const k = clamp((n - at) / s.length); at += s.length + 1; return <Wiped key={i} s={s} k={k} ink={NEON} size={FONT} />; }))}
{t >= E('cut') + 0.3 ? rowDiv('o', { left: LEFT, top: ROW2 + 6 }, <span style={{ font: `96px/1 ${OSD}`, color: WHITE, textShadow: `3px 3px 0 ${NIGHT}` }}>{WORDS.filter((w) => w.t0 >= E('low') && w.t0 < E('select') && t >= w.t0 - 0.02).map((w) => w.w.toUpperCase()).join(' ')}</span>) : null}
</>
);
}
const idx = CHUNKS.reduce((a, c, i) => (t >= c.t0 - 0.22 ? i : a), -1);
if (idx < 0) return null;
const c = CHUNKS[idx];
const end = c.rows[c.rows.length - 1].slice(-1)[0].w.t1, nx = CHUNKS[idx + 1];
if (t > end + 0.25 && (!nx || nx.t0 - end > 0.9)) return null;
return (
<>
{c.rows.map((r, i) => rowDiv(`${idx}-${i}`, place(i, c.rows.length), r.map((x, j) => <Wiped key={j} s={x.s} k={ramp(t, x.w.t0, Math.max(x.w.t1, x.w.t0 + 0.14))} ink={inkOf(x.w.t0)} size={FONT} />)))}
</>
);
};
/* The classic count-in: four dots burn down before the first word. */
const CountIn: React.FC = () => {
const t = useT(), a = E('song') + 0.14, b = WORDS.find((w) => w.t0 >= E('song'))?.t0 ?? a;
if (t < a || t >= b) return null;
const n = 4 - Math.floor(((t - a) / (b - a)) * 4);
return <div style={{ position: 'absolute', left: LEFT, top: ROW1 - 6, display: 'flex', gap: 16 }}>{Array.from({ length: n }, (_, i) => <i key={i} style={{ width: 30, height: 30, borderRadius: '50%', background: GOLD, boxShadow: `0 0 0 5px ${NIGHT}` }} />)}</div>;
};
/* ---------- the room: curtains, the sign, the stage floor and the crowd ---------- */
const SPARKS = Array.from({ length: 46 }, (_, i) => ({ r: 300 + hash(i) * 660, ph: i * 2.4, q: 0.9 + hash(i + 4) * 0.8, s: 5 + hash(i + 9) * 8 }));
const Room: React.FC = () => {
const t = useT(), bt = beachClock(t), low = lowK(t);
return (
<>
<div style={{ position: 'absolute', inset: 0, background: `repeating-linear-gradient(90deg,#3a1049 0 18px,#5c2272 18px 40px,${CURTAIN} 40px 60px)` }} />
<div style={{ position: 'absolute', inset: 0, background: `rgba(10,2,20,${0.22 + 0.45 * low})` }} />
{SPARKS.map((s, i) => { const a = bt * 0.5 + s.ph; return <div key={i} style={{ position: 'absolute', left: 540 + Math.cos(a) * s.r - s.s / 2, top: 900 + Math.sin(a * s.q) * (500 + s.r * 0.5) - s.s / 2, width: s.s, height: s.s, borderRadius: '50%', background: WHITE, opacity: 0.55 * (1 - 0.62 * low) }} />; })}
</>
);
};
const VALANCE = Array.from({ length: 9 }, (_, i) => <div key={i} style={{ position: 'absolute', left: i * 120, top: -66, width: 120, height: 150, borderRadius: '0 0 60px 60px', background: 'repeating-linear-gradient(90deg,#3a1049 0 14px,#5c2272 14px 30px)', boxShadow: `inset 0 -9px 0 ${GOLD}, 0 7px 0 rgba(10,2,20,.45)` }} />);
const Sign: React.FC = () => {
const t = useT(), end = t >= E('encore'), text = end ? KEYWORD : SIGN_TEXT, size = fit(text, 960, 142, 0.98);
return (
<div style={{ position: 'absolute', left: 0, width: 1080, top: 96, height: 150, textAlign: 'center', font: `${size}px/150px ${SIGNF}`, transform: `translateY(${-keyLift(t) * 0.16 * (1 - ramp(t, E('key') + 0.25, E('key') + 0.6)) + shake(t) * 0.1}px)` }}>
{Array.from(text).map((ch, i) => {
const on = t >= i * 0.07 && !(t >= E('cut') + i * 0.05 && t < E('del') + i * 0.04 && !SIGN_KEEP.includes(i));
const pop = end ? ramp(t, E('encore') + i * 0.05, E('encore') + i * 0.05 + 0.05) : 1;
const lit = on && pop > 0;
return <span key={i} style={{ color: lit ? (end ? GOLD : NEON) : '#3a2348', textShadow: lit ? `0 0 10px ${end ? GOLD : NEON}, 0 0 30px ${end ? GOLD : NEON}` : undefined }}>{ch}</span>;
})}
</div>
);
};
const CROWD = Array.from({ length: 13 }, (_, i) => ({ x: 20 + i * 86 + hash(i) * 30, y: 1752 + hash(i + 3) * 80, r: 44 + hash(i + 6) * 16, ph: hash(i + 9) * 6, phone: i % 2 === 0 }));
const Floor: React.FC = () => {
const t = useT(), bt = beachClock(t), low = lowK(t), party = ramp(t, E('burst'), E('burst') + 0.5), off = low > 0.5;
const lights = ramp(t, E('song'), E('song') + 1) * (1 - low * 0.75);
return (
<>
<svg viewBox="0 1490 1080 430" width={1080} height={430} style={{ position: 'absolute', left: 0, top: 1490 }}>
<defs><linearGradient id="fl" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#2a0f3c" /><stop offset="1" stopColor={NIGHT} /></linearGradient><linearGradient id="ch" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stopColor="#6e6880" /><stop offset=".45" stopColor="#f2eef8" /><stop offset="1" stopColor="#544e66" /></linearGradient></defs>
<rect x="0" y="1490" width="1080" height="430" fill="url(#fl)" />
{Array.from({ length: 40 }, (_, i) => <circle key={i} cx={(i % 10) * 118 + (i % 20 > 9 ? 59 : 0)} cy={1540 + Math.floor(i / 10) * 70} r="17" fill="none" stroke="#5c2272" strokeWidth="4" opacity=".5" />)}
<g opacity={lerp(1, 0.55, low)}>
<ellipse cx="190" cy="1668" rx="170" ry="32" fill="#0a0314" />
<rect x="296" y="1556" width="16" height="108" rx="7" fill="#d9b070" /><path d="M278 1536 Q304 1496 330 1536 Z" fill={NEON} />
<path d="M84 1664 L108 1562 L252 1562 L276 1664 Z" fill={PAPER} />
</g>
{/* the mic on its stand: tapped at the low point, its lamp red while it's off */}
{TAPS.map((at, i) => { const u = t - at; return u < 0 || u > 0.7 ? null : <circle key={i} cx="540" cy="1584" r={60 + u * 340} fill="none" stroke={GOLD} strokeWidth={9 * (1 - u / 0.7)} opacity={1 - u / 0.7} />; })}
<rect x="530" y="1650" width="20" height="280" fill="url(#ch)" />
<rect x="514" y="1626" width="52" height="62" rx="8" fill="#1c1426" />
<circle cx="552" cy="1650" r="8" fill={off ? RED : '#3fd47a'} />
<circle cx="540" cy="1584" r="60" fill="url(#ch)" />
{[-36, -18, 0, 18, 36].map((d) => <rect key={d} x="484" y={1584 + d} width="112" height="3.5" fill="#3a3448" opacity=".65" />)}
<rect x="480" y="1581" width="120" height="9" fill="#2a2436" />
{/* the crowd, phones up for the ballad */}
{CROWD.map((c, i) => {
const sway = Math.sin(bt * 1.4 + c.ph) * 11 * (1 + party);
const px = c.x + 34 + sway * 2.4, py = c.y - 96 - 40 * party;
return (
<g key={i} fill="#0a0314">
<ellipse cx={c.x + sway * 0.4} cy={c.y} rx={c.r} ry={c.r * 1.12} />
<rect x={c.x - c.r * 1.5 + sway * 0.4} y={c.y + c.r * 0.9} width={c.r * 3} height="240" rx="30" />
{c.phone ? <><line x1={c.x + 30 + sway} y1={c.y + 48} x2={px} y2={py + 26} stroke="#0a0314" strokeWidth="18" strokeLinecap="round" /><rect x={px - 13} y={py} width="26" height="44" rx="4" fill={off ? '#6f8cff' : GOLD} opacity={lights} /></> : null}
</g>
);
})}
</svg>
<div style={{ position: 'absolute', left: 84, width: 192, top: 1584, textAlign: 'center', font: `32px/34px ${BUNGEE}`, color: NIGHT, opacity: lerp(1, 0.55, low) }}>{TABLE}</div>
</>
);
};
/* ---------- the film ---------- */
export const KaraokeNight: React.FC = () => (
<Clock>
<Fonts faces={FACES}>
<Stage />
</Fonts>
</Clock>
);
const Stage: React.FC = () => {
const t = useT(), low = lowK(t), k = ramp(t, E('song') - 0.08, E('song') + 0.5), j = shake(t);
const jump = -60 * Math.sin(Math.PI * ramp(t, E('key'), E('key') + 0.3));
const wob = low > 0 ? (hash(Math.floor(t * 9)) - 0.5) * 7 * low : 0;
const split = ramp(t, E('reply1') - 0.06, E('reply1') + 0.12) * (1 - ramp(t, E('forty') - 0.1, E('forty') + 0.25));
const neonC = interpolateColors(ramp(t, E('burst'), E('burst') + 0.4), [0, 1], [interpolateColors(low, [0, 1], [NEON, '#4a2a5a']), GOLD]);
const showB = t >= REPLY2 - 0.05;
return (
<AbsoluteFill style={{ background: NIGHT, overflow: 'hidden' }}>
<Room />
<Floor />
<div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1920, transform: `translate(${j * 0.3}px, ${j * 0.55 + jump * 0.6}px)` }}>
{[[26, 0.13], [15, 0.3]].map(([g, o]) => <div key={g} style={{ position: 'absolute', left: 54 - g, top: 244 - g, width: 972 + g * 2, height: 1250 + g * 2, borderRadius: 26 + g, background: neonC, opacity: o }} />)}
<div style={{ position: 'absolute', left: 54, top: 244, width: 972, height: 1250, borderRadius: 26, background: neonC }} />
<div style={{ position: 'absolute', left: 60, top: 250, width: 960, height: 1238, borderRadius: 20, background: '#0a0314' }} />
<div style={{ position: 'absolute', left: 62, top: 252, width: 956, height: 1234, borderRadius: 18, overflow: 'hidden', background: '#070a4c' }}>
<Menu />
<div style={{ position: 'absolute', inset: 0, clipPath: k >= 1 ? undefined : `polygon(${star(478, 460, 40 + easeIn(k) * 1900)})`, opacity: k > 0 ? 1 : 0 }}>
<div style={{ position: 'absolute', inset: 0, transform: `translateX(${wob}px)`, filter: low > 0 ? `saturate(${1 - 0.72 * low}) brightness(${1 - 0.3 * low})` : undefined }}>
<Beach />
</div>
{split > 0 ? (
<>
<div style={{ position: 'absolute', left: 0, top: 0, width: 478, height: 1234, background: NEON, opacity: 0.3 * split }} />
<div style={{ position: 'absolute', left: 478, top: 0, width: 478, height: 1234, background: '#1f7dff', opacity: (showB ? 0.42 : 0) * split }} />
<div style={{ position: 'absolute', left: 475, top: 0, width: 6, height: 1234 * split, background: WHITE, opacity: split }} />
</>
) : null}
<div style={{ position: 'absolute', left: -62, top: -252, width: 1080, height: 1920 }}>
<Marquee />
<Hero />
<Pop at={E('song')} to={E('unread') - 0.4} y={500} size={150} color={GOLD}>{SONG}</Pop>
<Pop at={E('song') + 0.08} to={E('unread') - 0.4} y={680} size={44} color={WHITE}>{SONG_SUB}</Pop>
<Pop at={E('key')} to={E('reply1') - 0.1} y={420} size={140} color={GOLD}>{KEY_POP}</Pop>
{split > 0.5 ? <div style={{ position: 'absolute', left: 62, width: 478, top: 410, textAlign: 'center', font: `${fit(DUET_A, 400, 96)}px/1 ${BUNGEE}`, color: WHITE, WebkitTextStroke: `10px ${NIGHT}`, paintOrder: 'stroke fill' }}>{DUET_A}</div> : null}
{split > 0.5 && showB ? <div style={{ position: 'absolute', left: 540, width: 478, top: 410, textAlign: 'center', font: `${fit(DUET_B, 440, 96)}px/1.3 ${BUNGEE}`, color: WHITE, WebkitTextStroke: `10px ${NIGHT}`, paintOrder: 'stroke fill' }}>{DUET_B}</div> : null}
<BigCount />
<Lane />
<SandWriting />
<LastOne />
<PromptLine />
<Hud />
<Score />
<Dialog />
</div>
</div>
<Vhs />
<div style={{ position: 'absolute', left: 0, top: 976, width: 956, height: 258, background: `linear-gradient(transparent,rgba(20,6,31,.88) 18%,rgba(20,6,31,.94))` }} />
</div>
<div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1920 }}>
<Dot />
<Burst />
</div>
</div>
{VALANCE}
<Sign />
<CountIn />
<Lyrics />
<AbsoluteFill style={{ background: 'radial-gradient(120% 80% at 50% 45%,transparent 62%,rgba(10,2,20,.42) 100%)', pointerEvents: 'none' }} />
</AbsoluteFill>
);
};
```
## 8. Voice (optional)
A voice is worth adding when the world has a speaker of its own, because the deadpan delivery is half the joke. Skip it for a sound-off post, or when the user would rather not have one: the film still works.
**The persona:** a karaoke regular at table nine, painfully committed, who has rehearsed this in the car. Earnest and a little breathless, never winking. The joke only works if this person means every word. Speak the lines in the ballad's rhythm with long held vowels: most voice tools don't sing, and a sincere spoken delivery is funnier anyway. If the user wants it sung, use their own voice, or a singing tool given only your original words and an original tune.
**When to use it:** on every line except the silence. The first line belongs to the machine's host, flat and bored, so give it a different voice if you can. Key change goes loud and proud, far too pleased with itself. For the duet, a second voice or a layered copy can take the second "reply all". The low point is the one dry line: no echo, away from the mic, a person checking whether anyone can hear them. "Select all" comes back on the mic with the echo returning, and "Oh" opens all the way up. "Score, ninety-nine" belongs to the machine again. Put a big bar-room echo on everything sung, and cut it dead on the low point.
- **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 |
|-------|--------|-------------|---------------|
| menu step | 0.2 s | a soft low beep from an old remote control, warm, rounded, no high tone | each move of the bar on the song menu, up to `pick` |
| menu pick | 0.5 s | two soft rising electronic notes from an old machine, warm | `pick` |
| star wipe | 0.6 s | a dated video-effect swish, soft and airy, a little silly | `song` |
| count-in | 0.3 s | one drumstick click, dry, close | each dot burning down, from `song` to the first word |
| envelope | 0.4 s | a paper envelope slapped onto wet sand, soft and flat | `unread`, then a scatter of them, quiet, as the pile grows |
| postmark | 0.4 s | a rubber stamp pressed on thick paper, a short dull thump | `twenty` |
| gull | 1.5 s | one seagull far away over a calm sea, soft, no wind noise | `email` and `press`, under the gull crossing |
| lane tag | 0.3 s | a soft arcade game grade sound, one rounded blip | every entry in `TAGS` |
| key change | 0.8 s | a big dated drum fill landing on a huge gated tom, warm, dark | `key` |
| paper spray | 1.2 s | a burst of paper envelopes flung into the air, flutter and rustle | `reply1` and the second "reply" |
| paper avalanche | 3 s | thousands of paper envelopes sliding and falling in a heap, a long dry rustle, low and warm | `forty` to `three` |
| three notes | 0.8 s | three small soft glass notes, gentle and a little sad | `three` |
| mic cut | 0.6 s | a live microphone switched off, a dull pop and the room's speaker hum dropping away | `cut`, the sound of the low point starting |
| tape pause | 3 s | an old video tape held on pause, a low steady flutter and a faint hum | `cut` to `del` |
| mic tap | 0.4 s | a finger tapping a dead microphone, a muffled close thud, no ring | `tap1`, `tap2` |
| one cough | 0.8 s | one person coughing politely in a quiet bar, some way off | `thing`, on the low point's line |
| mic back | 0.8 s | a speaker system popping on, with a gentle swell of soft feedback, not shrill | `select` |
| selecting | 0.6 s | a soft quick computer drag sound, a short rising shimmer | `select`, under the box |
| big key | 0.5 s | one large mechanical keyboard key pressed hard, a deep clack | `del`, with the dialog |
| click | 0.2 s | a small plastic mouse click, close | the dialog's YES, just after the second "select" |
| climb | 1.2 s | one synth note gliding upward, warm, rising and opening | `quiet` to `burst` |
| burst | 1 s | a soft deep confetti cannon, a low thump and a paper spray, never bright | `burst`, the loudest thing in the film |
| crowd | 4 s | a small bar crowd cheering and whooping, warm and close, no words | `burst`, fading under the score |
| tally | 2.5 s | a small mechanical score counter rolling quickly, soft ticks | `burst` to `ninety` |
| tally stop | 1 s | one warm game-show bell, round, no shrill top | `ninety` |
| last envelope | 0.8 s | a paper envelope fluttering down and landing softly on dry sand | `one`, starting just before it |
| room murmur | 10 s | a small bar at night, low conversation with no clear words, a few glasses, no music | the bed, all the way through, and the main sound of the low point |
Every sound here is a brief for your own generator or library. Nothing in this skill was recorded or measured from our demo, which we show silent.
- **Place a sound on every visible change,** at the same event the picture uses, so a re-voice moves both.
- **Beds run under everything:** a world without room tone feels empty, and room tone is what keeps the low point alive.
- **The same sound twice within 80 ms:** keep the louder one.
- **Warm, never bright:** low-pass effects to about 1.5-3.5 kHz if they bite.
**Music: one instrumental bed.** Describe the sound, never an artist. Generators ignore requested timestamps, so fit the track instead: find its real downbeats from bar-to-bar energy jumps, pick the bar where it lifts, place that bar on the story's turn, and stretch (keeping the pitch, within about 4 %) so a second downbeat lands on the voiceless moment.
**The brief:** "An original, completely sincere power ballad instrumental, no vocals: slow gated drums with a huge echo, a glassy electric piano, warm synth pads and a chorus-soaked clean guitar, a big dated modulation up one key, then a stripped-back middle and a towering final chord. An original melody, not based on any existing song."
**Original music only.** Never use a real song, a cover, or a tune that follows a real one, even as a joke. Listen to every take you generate, and throw away any whose melody you recognize.
**When it plays:** a thin, bored hold loop under the menu. The ballad starts on `song`, under the count-in. On `key` it jumps up a whole step: if your take has no key change, pitch the track up from that bar, which suits the film perfectly. It cuts dead on `cut`, with no fade, because the mic just died. It stays out through the low point so only room murmur, the tape pause and the mic taps remain. It returns on "delete", low and warm, climbs through "Oh, select all", and is at its fullest on the voiceless moment, on the big final chord at `burst`. It ducks when "Score" starts so the last lines sit clear, and stays low and happy to the end.
**Mix, voice first.**
- **Voice:** compress lightly so consonants stay off the limiter, about -14 LUFS.
- **Music:** about 16 to 20 LU under the voice while it speaks, lifted in gaps and on the voiceless moment.
- **Effects:** 7 to 10 LU under the voice overall, ducked about 6 dB under speech, so nothing masks a word.
- **Master:** -14 LUFS integrated, sample-peak ceiling about -2.4 dBFS. AAC encoding adds about 0.8 dB, so check the true peak of the final file. Give the master a 10 ms fade-in, or every view opens on a click.
## 10. Render and QA
1. **Render** without audio. Mux your mix onto it, trimmed to the exact length (`-t`, never `-shortest`), as H.264 at high quality with AAC 320k and `+faststart`.
2. **Sync and length:** the frame count matches the clock, and a cross-correlation of the muxed audio against your mix shows an offset of 0 ms.
3. **Contact sheets:** one frame per second with the safe box drawn. Look at every sheet: nothing cut off, nothing important outside the box, no empty or black frames, and the time of day and the story still agree.
4. **Determinism:** render about 10 spread frames twice with `npx remotion still src/index.ts Film-9x16 out/a.png --frame=N`, and compare each pair with `cmp`; they should match.
5. **Loudness:** -14 LUFS integrated, true peak at or below -1 dBTP.
6. **Words:** transcribe the mix line by line with Whisper. A difference on a plain word is a masked or swallowed word.
7. **Gaps and stills:** no voice gap over 1.0 s outside the voiceless moment (`clock.mjs` warns about these), and no still stretch over 1.2 s or black stretch (`ffmpeg -i out/silent.mp4 -vf "freezedetect=n=0.001:d=1.2,blackdetect=d=0.1" -an -f null -` prints any). A small moving corner can hide a freeze from that check, so trust the contact sheets over it.
For a silent film, skip the checks that need audio (the offset in 2, and 5 and 6) and use `out/silent.mp4` as the final file.
## 11. Traps
- **Real lyrics or a real tune:** refuse them, as section 3 says, even when asked for "just one line" or a parody. Generated music can drift close to a hit, so listen before you use it.
- **Real names on the menu:** every title and number on the song menu is invented. Check that a title isn't a famous song's, and keep artists, karaoke brands and catalogs off the screen.
- **A low point that looks off:** the video is paused, never black. The drained picture, the streaks, the jitter and the mic taps keep it alive, and the sign still says OK.
- **Strobing:** the bar's pulse on the menu runs under 3 flashes a second, the prompt blinks about 1.3 times a second, and `Burst` flashes the screen once. Don't speed these up. The tracking streaks are thin for the same reason.
- **Pose keys during the freeze:** a key between `cut` and `del` jumps when the video restarts. Put the next pose on `del`.
- **A caption that blinks:** a chunk only clears when the next one is more than 0.9 s away, so the lyric never drops out for one frame between lines. Keep that check if you rewrite `Lyrics`.
- **The pile over the lane:** keep the pile above y 1062. The lane is see-through, but the notes must stay readable.
- **A wide second row:** rows are right-aligned to x 948, so a row longer than `PER_ROW` runs left into the first row's space. Break the line at a comma instead.
| 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 karaoke-night skill (paste the skill text and ask it to save it as a skill). Then replace everything in [BRACKETS] below and paste it in. **Tip:** use any AI tools you like for the voice, music and sound effects; the skill works the same with all of them, or with none (every scene is code, and the film works with no voice). If you'd rather not set up separate AI accounts and API keys, HYVIRAL (optional) is one way to get them behind one connection (hyviral.com; in Claude.ai → Customize → Connectors → add `https://api.hyviral.com/mcp`). **One limit:** original words and music only. The skill refuses real lyrics, real songs and a song's recognizable melody, so don't ask it for those, even as a parody. --- Use the karaoke-night skill to make a vertical video for [PLATFORM: Instagram Reels / TikTok / YouTube Shorts]. - **The ballad is about:** [TOPIC - the small daily annoyance, e.g. "my inbox", "two hundred open browser tabs", "the family group chat"] - **What piles up:** [PILE - what fills the beach, and how many, e.g. "forty thousand unread emails", or "you choose"] - **The way out:** [FIX - the one boring command that ends it, e.g. "select all, delete", or "you write it"] - **Song title:** [TITLE - an invented title for the menu, or "you write it"] - **In the window:** [WINDOW - "no person: the sunset beach drawn in code", or "my own clip, attached"] - **Length:** [LENGTH - e.g. about 35 seconds] - **Voice:** [VOICE - e.g. "an overcommitted karaoke regular", "my own voice", or "no voice: captions and sound only"] - **Music:** [MUSIC - e.g. "the skill's original power ballad brief", or your own idea, always original] - **Ending:** [CTA - organic: "Comment KEYWORD and I'll send you THE THING", with the keyword and what you'll send; or sponsored: the line and the URL] - **AI tools:** [AI TOOLS - e.g. "whatever you already have set up", "HYVIRAL", or "none: animation only, I'll record the voice myself"] - **Avoid:** [AVOID - e.g. no real people or brands, no claims I can't back up, and always no real lyrics, songs, artists or karaoke brands] Work in this order: 1. Write original lyrics on the skill's moves, with one line for each move and what happens on the screen. Find the low point first: the moment the song stops mid-word and a plain voice takes over. 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 skillsPhotobooth
A photo booth prints your year, one flash at a time.
Frame · 9:16 · 30-40 s
The Gallery
A grand museum treats your small silly thing as a masterpiece.
Frame · 9:16 · 30-40 s
Pulp
A battered 1950s paperback turns your small problem into a lurid crime.
Frame · 9:16 · 30-40 s