Going Up
You're inside an elevator, facing the doors, and your topic is the building. Every floor opens on its own room while a polite announcer reads out one small disaster after another. Then the car stalls, the lights go red, and the camera turns around to a door nobody mentioned. 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 climb The floors The second door Platform Narrator Ending AI toolsUse the going-up skill to make a vertical video for Instagram Reels. - **The climb:** [TOPIC - the ladder this building stands for, e.g. "the career floors nobody warns you about", "a startup's funding rounds", "chasing follower counts"] - **The floors:** [FLOORS - three or four prizes, each with one specific, funny catch, or "you write them"] - **The second door:** [EXIT - what's better than the top, e.g. "a small job with good light", "going solo", or "you write it"] - **In the window:** [WINDOW - "no person: rooms drawn in code", or "my own clips or photos, attached"] - **Length:** [LENGTH - e.g. about 35 seconds] - **Voice:** [VOICE - e.g. "a polite automated elevator announcer", "my own voice", or "no voice: captions and sound only"] - **Music:** [MUSIC - e.g. "the skill's elevator bossa brief", or your own idea] - **Ending:** [CTA - organic: "Comment KEYWORD and I'll send you THE THING", with the keyword and what you'll send; or sponsored: the line and the URL] - **AI tools:** [AI TOOLS - e.g. "whatever you already have set up", "HYVIRAL", or "none: animation only, I'll record the voice myself"] - **Avoid:** [AVOID - e.g. no real employers, no telling anyone to quit, no claims I can't back up] Work in this order: 1. Write the script on the skill's moves, with one line for each floor and what's drawn behind the doors. Start from the second door, then pick the floors that lead to it. 2. Build the plan, the clock and the frame, render a silent animatic, and show me the script, the scene sheet and the animatic. Wait for my OK. 3. Generate the voice, music and sound effects with the AI tools I named above. If I left that open, tell me which tools you'll use and wait for my OK. 4. Place a sound on every change, fit the music to the turn, mix voice first, render, and run every QA gate in the skill. 5. Give me the MP4, a cover frame and a ready-to-paste caption.
Anything left in [brackets] is your agent's call.
Copy prompt -
Paste it and approve the storyboard
Your agent writes the script, builds the car and its floors 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: going-up
description: Make a 30-40 second vertical video set inside an elevator - the doors open on a new drawn room at every floor, a polite announcer never notices what it's saying, the car stalls between floors, and a second door opens behind the viewer. The skill covers the elevator's sound design, music fitted to the story and an optional voice like an automated announcer. Use for career ladders, promotions and burnout, any climb that turns out to be optional, growth stories with a twist, or when asked for an elevator, floors or 'going up' video, or when given the going-up prompt.
---
# Going Up
You're inside an elevator, facing the doors, and your topic is the building. Each floor opens on its own drawn room while a polite announcer reads out one small disaster after another and never notices. Then the car stalls between floors, the lights go red and the emergency phone rings, and the camera does the one thing it was told not to: it turns around. There's a second door behind you, and it opens on a floor nobody listed.
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/going-up/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/dotgothic16` and `@fontsource/overpass`. 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/GoingUp.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 doors open on a new room at every floor, the display and the buttons climb, the car stalls and goes red, the camera turns around, and a second door opens on sunlight. That's why it holds attention: the viewer learns the world in the first second, then watches it change.
- **One clock: the spoken words.** Each line is voiced as its own take with word timings, scenes are laid end to end on those takes, and every change is keyed to a word in `story.json`. Re-voice a line and everything moves with it.
- **A function of time.** Every part of the frame computes what it shows from the current time, with no stored state, `Math.random` or `Date`. Use `hash()` for anything that looks random, so two renders match.
- **Sound off first.** Most people start muted, so the frame and the captions have to tell the whole story on their own. Sound makes it better, never necessary.
- **Safe area.** 1080 x 1920. Platform buttons and captions cover the bottom 440 px, and the right 130 px from about y = 900 down. Keep captions and anything that must be read inside x 60-950, y 250-1480. Above y = 900 the frame can run to the right edge. Below y = 1480, draw only texture. The top 250 px can hold the world's title and small jokes, as long as nothing there has to be read.
- **Pacing rules.** No voice gap over 1.0 s, except one voiceless moment of 2 to 5 s with the music lifted. No still stretch over 1.2 s. Never cut to black. Something moves on the very first frame.
## 3. The world
An elevator seen from inside, built as a real 3D box the camera can turn around in.
- **Palette:** brushed steel `#b8bcc2`, steel shadow `#7d828a`, display glass `#140d08`, amber `#ffae2b`, button glow `#ffe08a`, floor rubber `#2b2b2e`, mug red `#c8392b`, emergency tint `#9c3226`, sun tint `#fff0da`.
- **Type:** DotGothic16 for the floor display and the announcement board, Overpass (700 to 900) for the plates, the buttons and anything printed in the rooms.
- **Fixed parts, top to bottom:** the ceiling light (y 0-150), two plates and the speaker (y 170-240, above the safe area, so nothing there has to be read), the floor display (y 262-420), the doors (x 100-790, y 440-1180) beside the button panel and the emergency phone (x 812-992), the announcement board (y 1240-1476), then a rail and a floor mat as texture below the safe area.
- **The box rule:** six planes sit around the camera in `Car`, and a wall is exactly 1:1 on screen when it's faced, so a wall's x and y are the screen's minus (70, 150). The camera turns with `YAW` and pushes with `PUSH`. The board sits outside the box, so the captions never move, even when the car spins.
- **The door rule:** a room only changes while the doors are shut. `DOOR` is the one list of door moves, and `FrontRoom` picks the room from the same events.
- **One light for the whole car:** `Shade` multiplies whatever was drawn before it on each plane, and `shadeAt` sets its color. The display, the lit buttons, the phone and the sign are drawn after it, so they stay lit when the car goes dark.
- **The board reads ahead:** each caption's words start dim and light up as they're spoken, so a muted viewer can read on. The speaker's meter follows `WORDS` from the clock, so it works with no voice at all.
- **The display never notices:** NOT PRESSED. DOOR HELD. PLEASE HOLD. RECONSIDER. It reports each disaster as routine, and that's the running joke.
- **One red mug runs through it:** on the desk on floors one and two, left on a shelf on floor three, then waiting at the door in the last scene. Show it early or the last joke has nothing behind it.
- **Small jokes live on the hardware:** CAPACITY: ONE CAREER. INSPECTED: BY NOBODY. The floor mat in front says PLEASE FACE THE DOORS, and the one behind you says NOBODY STANDS HERE.
**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 door, and each floor is its own drawn room with its own color. Ours are drawn in code and kept simple:
| Story moment | Through the door |
|---|---|
| Lobby | Marble, a checkered floor, a revolving door that never stops, a brass sign: CAREERS |
| Floor one | Rows of desks and one huge spreadsheet on the wall. On "excited" a wave goes through the staff, confetti falls, and a banner lands: ROW 12 BALANCES! |
| Floor two | The same desk and the same mug. Bunting, CONGRATULATIONS, and eleven chairs arrive around the desk: NOW ALSO A MEETING ROOM |
| Floor three | A wall calendar fills itself square by square, except one. Platform-edge stripes appear around the gap |
| The held door | A hand wedges the doors, which bump it and back off twice. One finger goes up, and a red QUICK Q? drops into the gap |
| Floor seven | Bare carpet, empty desks, one box, a dust ball rolling by, and a taped-up sign: RELOCATED |
| Between floors | The stall shakes the doors open on bare shaft: concrete, cables, a stencil reading NOT A FLOOR |
| The rear door | A sunlit room: a window, a plant, one desk and a blank brass nameplate with a cursor on it. Out the window, far off, a tower stands under its own small rain cloud, labeled THE TOP |
The window also works with the user's own clips. Play one in a room's place, cropped to the door, and the doors still open and close on it. The strongest single swap is the last room: their real desk, their real window.
## 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 climb onto these. The film reads out one small career disaster after another in a pleasant voice, then shows that the ride was optional. Ours is about the career floors nobody warns you about.
| # | Move | What it does | Our line | In the car |
|---|------|--------------|----------|------------|
| 1 | Doors close | The hook, and the rule: face front | "Going up. Please state your career goals." | A button lights, the doors shut on "goals", the display reads 0 |
| 2 | First floor | The cheerful start | "Floor one. Everyone is excited about spreadsheets." | Ding, the doors open on rows of desks and one huge spreadsheet |
| 3 | The promotion | Getting what you asked for | "Floor two, a promotion. Same desk, more meetings." | The same desk, with eleven chairs arriving around it |
| 4 | Management | The climb starts to take something | "Floor three, management. Mind the gap in your calendar." | A wall calendar fills up, with one small striped gap |
| 5 | Hold the door | The last free moment goes | "Someone is holding the door. One quick question." | A hand wedges the doors, one finger goes up, and the gap gets booked |
| 6 | Wrong floor | The ladder moves without you | "Floor seven. You did not press seven. Your department has moved." | The 7 lights by itself. Empty desks, one box, RELOCATED |
| 7 | Low point | Stuck, and the voice is still polite | "We are now between floors. Please reconsider." | The car drops and jolts, the lights flicker out and go red, the display goes to dashes, the doors shake open on NOT A FLOOR, the phone starts to ring. Music warps and drains; a cable creak and the room keep running |
| 8 | The turn | The rule breaks | "This car has a second door. Nobody reads the sign." | The camera turns for the first time, to a small dusty sign: REAR DOOR, NEVER LOCKED. Light comes on behind it. Music returns on "door" |
| 9 | Voiceless | The release, about 2.7 s | (music at its fullest, the caption reads FLOOR: YOURS) | The camera finishes the turn, the rear doors open on sun, the display spins and stops on YOURS |
| 10 | The floor | The quiet payoff | "Your floor. Not the top. Better light." | A blank nameplate on the desk. A rain cloud finds the tower labeled THE TOP. The sun grows |
| 11 | The button | The last laugh, from early on | "Please take your belongings, including those on floor three." | One sad mug slides up to the door, tagged FLOOR 3, and sheds a tear |
| 12 | Call to action | One action | "Comment floor and I'll send you the skill that made this." | The display reads COMMENT FLOOR and stays |
**For other topics:** a startup's funding rounds, a creator chasing follower counts, a school ladder from honors to a doctorate, each size of house, or the levels of a very serious hobby. Make each floor a real prize with one specific, funny catch, and let the voice report it as good news. Then decide what the second door opens on before you write anything else, and keep it smaller than the top and clearly better.
The trick that makes it land is that the camera has faced one way for 25 s. A viewer never thinks about what's behind them until the film turns around, so the turn feels like breaking a rule, and no new fact has to arrive: the door was there the whole ride. Our film runs 41 s on the clock's estimate. For about 35 s, drop floor two and the held door, which saves about 6.5 s. Keep the low point and the voiceless moment at full length.
This film is a joke about ladders, not advice. Don't tell anyone to quit, and don't name a real employer.
## 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 words are in one block at the top of the file, and most of the drawing is one component per room. Replace them in that order. `core.tsx` and `clock.mjs` don't change.
| To change | Where |
|---|---|
| The words on the floor display | `STOPS`: when, the number and the legend. `LIT_AT` says when each button lights, and `LABELS` is what's on the panel |
| The words in the rooms | The constants at the top: `CHEER`, `BUNTING`, `DESK_SIGN`, `CAL_TITLE`, `QUICK`, `MOVED`, `MOVED_SMALL`, `SHAFT`, `SIGN`, `TOP_TAG`, `MUG_TAG` |
| What the rear display stops on, and the voiceless caption | `REAR_STOP` and `QUIET_CAPTION`. Their moments are the `quiet` and `stop` events |
| The rooms | `Lobby`, `FloorOne` (`ROWS` lists the desks), `FloorTwo` (`CHAIRS`, `Chair`), `FloorThree` with `Calendar` (`CELLS`, `CAL`, where `gapCol` and `gapRow` place the gap), `FloorSeven`, `Shaft`, `SunRoom`. Each draws in units of 690 x 740, and `SunRoom` in 890 x 740 |
| The same desk and the mug | `Desk` and `RedMug`. `SadMug` is the big mug at the end |
| Adding or removing a floor | Four places: a branch in `FrontRoom`, an opening and a closing in `DOOR`, a row in `STOPS`, and a row in `LIT_AT`, plus its events in `story.json` |
| The hand | `Hand` draws it and folds it on the `one` event. `DOOR` stops the held doors at 0.36 because the hand is that wide, so change them together |
| The camera | `YAW` is the turn, `PUSH` is the dolly and `LURCH` is every jolt |
| The light | `lampAt` (the tube), `emAt` (the red), `seamAt` (the light behind the rear door), `sunAt` (the flood), and `shadeAt`, which mixes them |
| The sign | `RightWall`, with the cobweb right after it. The yaw of 108 degrees in `YAW` and the push on `reads` frame it, so keep it readable at that angle |
| The small jokes | Plain text where they're used: the `Plaque` rows in `FrontWall` and `RearWall`, the two floor mats in `FloorPlane`, CAREERS in `Lobby`, and ANNOUNCEMENT and NO FLOOR in `Board` |
| The comment keyword | `plan.json` and `KEYWORD` at the top. `RearDisplay` shows it at the end, and `Board` lights every use of the word once the ending starts |
- **Scene ids in the code:** none. The frame only reads events, and `QUIET` times the voiceless caption from `quiet` and `stop`, so you can renumber scenes freely.
- **Words in `story.json` belong to our script.** `shut`, `o1`, `excited`, `o2`, `same`, `more`, `o3`, `gap`, `holding`, `heldDoor`, `one`, `quick`, `o7`, `moved`, `between`, `floors`, `reconsider`, `second`, `door`, `reads`, `your`, `top`, `better`, `belongings` and `three` all point at a word, so re-point each one. A word that isn't there stops the render, which is what you want. `word` takes the first match in its scene, and `o2` points at "a". `on` and `same` are there only for sounds, so delete them if you don't use them.
- **Panels are sized to our words.** `MOVED` fits 68 px type on a 426 px sign, `CHEER` 50 px on a 500 px banner, `BUNTING` 54 px across the room, `SIGN[1]` 66 px on a 400 px sign, and `QUICK` 25 px in one calendar square. A legend in `STOPS` holds 17 characters, and `REAR_STOP` holds 12. Only the ending sizes itself: `RearDisplay` shrinks `COMMENT KEYWORD` until it fits. Check anything longer on a full-size still.
- **Each room needs its last change to land early.** The doors close at `c1`, `c2`, `c3` and `c7`, so a change pinned to a line's last word is on screen for almost no time. Our RELOCATED sign is pinned to "department" for that reason, and the chairs start before "more".
- **Captions** break into rows at `PER_ROW` letters. The dot font is fixed pitch, and `pull` closes the hole around an I, so keep it on anything set in that font.
- **Planes clip what they hold.** Anything drawn past a wall's edge is lost, which is why the big mug's base stops on the wall's bottom line (y 1030). Layers on a wall run back to front, with the unlit parts before `Shade`.
### The code
`plan.json`, our demo's script:
```json
{
"lines": [
{
"id": "l01",
"text": "Going up. Please state your career goals."
},
{
"id": "l02",
"text": "Floor one. Everyone is excited about spreadsheets."
},
{
"id": "l03",
"text": "Floor two, a promotion. Same desk, more meetings."
},
{
"id": "l04",
"text": "Floor three, management. Mind the gap in your calendar."
},
{
"id": "l05",
"text": "Someone is holding the door. One quick question."
},
{
"id": "l06",
"text": "Floor seven. You did not press seven. Your department has moved."
},
{
"id": "l07",
"text": "We are now between floors. Please reconsider."
},
{
"id": "l08",
"text": "This car has a second door. Nobody reads the sign."
},
{
"id": "l09",
"text": "Your floor. Not the top. Better light."
},
{
"id": "l10",
"text": "Please take your belongings, including those on floor three."
},
{
"id": "l11",
"text": "Comment floor and I'll send you the skill that made this.",
"say": "Comment, floor, and I'll send you the skill that made this."
}
],
"scenes": [
{
"id": "s01",
"lines": [
"l01"
],
"lead": 0.1,
"tail": 0.05
},
{
"id": "s02",
"lines": [
"l02"
],
"lead": 0.05,
"tail": 0.05
},
{
"id": "s03",
"lines": [
"l03"
],
"lead": 0.03,
"tail": 0.05
},
{
"id": "s04",
"lines": [
"l04"
],
"lead": 0.03,
"tail": 0.05
},
{
"id": "s05",
"lines": [
"l05"
],
"lead": 0.03,
"tail": 0.05
},
{
"id": "s06",
"lines": [
"l06"
],
"lead": 0.05,
"tail": 0.05
},
{
"id": "s07",
"lines": [
"l07"
],
"lead": 0.25,
"tail": 0.05
},
{
"id": "s08",
"lines": [
"l08"
],
"lead": 0.05,
"tail": 0.05
},
{
"id": "s09",
"sec": 2.65
},
{
"id": "s10",
"lines": [
"l09"
],
"lead": 0.03,
"tail": 0.05
},
{
"id": "s11",
"lines": [
"l10"
],
"lead": 0.03,
"tail": 0.05
},
{
"id": "s12",
"lines": [
"l11"
],
"lead": 0.03,
"tail": 0.0
}
]
}
```
`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 { GoingUp } from './frames/GoingUp';
import spec from './spec.json';
export const Root: React.FC = () => (
<Composition id="Film-9x16" component={GoingUp} durationInFrames={Math.max(1, (spec as { durationInFrames: number }).durationInFrames)} fps={30} width={1080} height={1920} />
);
```
`src/frames/core.tsx`, the shared runtime:
```tsx
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { continueRender, delayRender, staticFile, useCurrentFrame } from 'remotion';
import spec from '../spec.json';
/* The shared frame runtime. One frame runs through the whole film; the clock comes from the spoken words. */
export const clamp = (x: number, a = 0, b = 1) => Math.min(b, Math.max(a, x));
export const ramp = (t: number, a: number, b: number) => clamp((t - a) / (b - a));
export const easeOut = (x: number) => 1 - Math.pow(1 - x, 3);
export const easeInOut = (x: number) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2);
export const easeIn = (x: number) => x * x * x;
export const back = (x: number) => { const c = 1.70158; return 1 + (c + 1) * Math.pow(x - 1, 3) + c * Math.pow(x - 1, 2); };
export const hash = (n: number) => { const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };
export type Word = { w: string; t0: number; t1: number; scene: string };
export type SceneT = { id: string; t0: number; t1: number; words: Word[]; p: Record<string, unknown> };
export type StoryEvent = { id: string; scene: string; word?: string; off?: number };
export type StoryJson = { events: StoryEvent[] };
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9']/g, '');
type SpecBeat = { id: string; from: number; dur: number; words: { w: string; f0: number; f1: number }[]; p: Record<string, unknown> };
const FPS = (spec as { fps: number }).fps;
export const SCENES: SceneT[] = (spec as unknown as { beats: SpecBeat[] }).beats.map((b) => ({
id: b.id,
t0: b.from / FPS,
t1: (b.from + b.dur) / FPS,
p: b.p ?? {},
words: b.words.map((w) => ({ w: w.w, t0: (b.from + w.f0) / FPS, t1: (b.from + w.f1) / FPS, scene: b.id })),
}));
export const WORDS: Word[] = SCENES.flatMap((s) => s.words);
export const DURATION = (spec as { durationInFrames: number }).durationInFrames / FPS;
/** The film second of an event: a scene's start, or a word in it, plus an offset. Throws on a typo, so a wrong anchor fails the render. */
export function eventTime(e: StoryEvent): number {
const s = SCENES.find((x) => x.id === e.scene);
if (!s) throw new Error(`event ${e.id}: no scene ${e.scene}`);
let t = s.t0;
if (e.word) {
const w = s.words.find((x) => norm(x.w).startsWith(norm(e.word as string)));
if (!w) throw new Error(`event ${e.id}: no word "${e.word}" in ${e.scene} (${s.words.map((x) => x.w).join(' ')})`);
t = w.t0;
}
return t + (e.off ?? 0);
}
export const makeE = (story: StoryJson) => {
const map = new Map(story.events.map((e) => [e.id, eventTime(e)]));
return (id: string) => {
const v = map.get(id);
if (v === undefined) throw new Error(`unknown event ${id}`);
return v;
};
};
const TimeCtx = createContext(0);
export const useT = () => useContext(TimeCtx);
export const Clock: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const f = useCurrentFrame();
return <TimeCtx.Provider value={f / FPS}>{children}</TimeCtx.Provider>;
};
/** One caption chunk at a time, cut to fit rows of `perRow` cells; `wi` is the word being spoken, or -1. */
export type Chunk = { t0: number; rows: { s: string; map: number[] }[]; words: Word[] };
const clean = (w: string) => w.replace(/[.,;:!?"]/g, '');
export function buildChunks(perRow: number, maxRows: number): Chunk[] {
const chunks: Chunk[] = [];
for (const scene of SCENES) {
let cur: Word[] = [];
const flush = () => {
if (!cur.length) return;
const rows: { s: string; map: number[] }[] = [{ s: '', map: [] }];
cur.forEach((w, i) => {
const up = clean(w.w).toUpperCase();
let r = rows[rows.length - 1];
if (r.s.length && r.s.length + 1 + up.length > perRow) { rows.push({ s: '', map: [] }); r = rows[rows.length - 1]; }
if (r.s.length) { r.s += ' '; r.map.push(-1); }
for (let k = 0; k < up.length; k++) r.map.push(i);
r.s += up;
});
chunks.push({ t0: cur[0].t0, rows, words: cur });
cur = [];
};
for (const w of scene.words) {
const trial = [...cur, w];
let rowsUsed = 1, len = 0;
for (const x of trial) {
const L = clean(x.w).length;
if (len && len + 1 + L > perRow) { rowsUsed += 1; len = L; } else len += (len ? 1 : 0) + L;
}
if (rowsUsed > maxRows) flush();
cur.push(w);
if (/[.!?]$/.test(w.w)) flush();
}
flush();
}
return chunks;
}
/** Loads the frame's fonts before the first frame. The handle is taken inside React: a module-level one can outlive its render. */
export const Fonts: React.FC<{ faces: { family: string; file: string; weight?: string; style?: string }[]; children: React.ReactNode }> = ({ faces, children }) => {
const [handle] = useState(() => delayRender('fonts', { timeoutInMilliseconds: 120000 }));
const [ready, setReady] = useState(false);
const list = useMemo(() => faces, [faces]);
useEffect(() => {
Promise.all(list.map((x) => {
const ff = new FontFace(x.family, `url(${staticFile(`fonts/${x.file}`)})`, { weight: x.weight ?? '400', style: x.style ?? 'normal' });
return ff.load().then((l) => { (document.fonts as unknown as { add: (f: FontFace) => void }).add(l); });
}))
.then(() => document.fonts.ready)
.then(() => { setReady(true); continueRender(handle); })
.catch((e) => { console.error('font load failed', e); setReady(true); continueRender(handle); });
}, [handle, list]);
return ready ? <>{children}</> : null;
};
```
`src/frames/story.json`, our demo's events:
```json
{
"events": [
{
"id": "on",
"scene": "s01"
},
{
"id": "press",
"scene": "s01",
"off": 0.2
},
{
"id": "shut",
"scene": "s01",
"word": "goals",
"off": -0.1
},
{
"id": "f1",
"scene": "s02"
},
{
"id": "o1",
"scene": "s02",
"word": "Everyone"
},
{
"id": "excited",
"scene": "s02",
"word": "excited"
},
{
"id": "c1",
"scene": "s03",
"off": -0.25
},
{
"id": "f2",
"scene": "s03"
},
{
"id": "o2",
"scene": "s03",
"word": "a"
},
{
"id": "same",
"scene": "s03",
"word": "Same"
},
{
"id": "more",
"scene": "s03",
"word": "more"
},
{
"id": "c2",
"scene": "s04",
"off": -0.1
},
{
"id": "f3",
"scene": "s04"
},
{
"id": "o3",
"scene": "s04",
"word": "management"
},
{
"id": "gap",
"scene": "s04",
"word": "gap"
},
{
"id": "hold",
"scene": "s05"
},
{
"id": "holding",
"scene": "s05",
"word": "holding"
},
{
"id": "heldDoor",
"scene": "s05",
"word": "door"
},
{
"id": "one",
"scene": "s05",
"word": "One"
},
{
"id": "quick",
"scene": "s05",
"word": "quick"
},
{
"id": "c3",
"scene": "s06",
"off": -0.2
},
{
"id": "climb",
"scene": "s06",
"off": -0.1
},
{
"id": "o7",
"scene": "s06",
"word": "You"
},
{
"id": "moved",
"scene": "s06",
"word": "department",
"off": 0.2
},
{
"id": "c7",
"scene": "s07",
"off": -0.15
},
{
"id": "low",
"scene": "s07"
},
{
"id": "between",
"scene": "s07",
"word": "between"
},
{
"id": "floors",
"scene": "s07",
"word": "floors"
},
{
"id": "reconsider",
"scene": "s07",
"word": "reconsider"
},
{
"id": "second",
"scene": "s08",
"word": "second"
},
{
"id": "door",
"scene": "s08",
"word": "door"
},
{
"id": "reads",
"scene": "s08",
"word": "reads"
},
{
"id": "quiet",
"scene": "s09"
},
{
"id": "open",
"scene": "s09",
"off": 0.55
},
{
"id": "stop",
"scene": "s09",
"off": 2.0
},
{
"id": "your",
"scene": "s10",
"word": "Your"
},
{
"id": "top",
"scene": "s10",
"word": "top"
},
{
"id": "better",
"scene": "s10",
"word": "Better"
},
{
"id": "please",
"scene": "s11",
"word": "Please"
},
{
"id": "belongings",
"scene": "s11",
"word": "belongings"
},
{
"id": "three",
"scene": "s11",
"word": "three"
},
{
"id": "cta",
"scene": "s12"
}
]
}
```
`src/frames/GoingUp.tsx`:
```tsx
import React from 'react';
import { AbsoluteFill, interpolateColors } from 'remotion';
import story from './story.json';
import { back, buildChunks, clamp, Clock, easeIn, easeInOut, easeOut, Fonts, hash, makeE, ramp, useT, WORDS, type StoryJson, type Word } from './core';
/* Going Up: an elevator seen from inside, as a real 3D box the camera can turn around in. The front doors open on one drawn room per
floor, the polite display and the announcement board never notice what they say, and the rear doors open on the floor nobody listed.
1080 x 1920. Time comes from the spoken words through story.json, so a re-voice moves every door with its word. */
const KEYWORD = 'FLOOR';
const E = makeE(story as StoryJson);
/* ---------- the story's strings ---------- */
/** The floor display: when, the number, the legend (17 characters fit). */
const STOPS: [number, string, string][] = [
[0, '0', 'LOBBY'], [E('f1'), '1', 'ENTRY LEVEL'], [E('f2'), '2', 'PROMOTED'], [E('f3'), '3', 'MANAGEMENT'], [E('hold'), '3', 'DOOR HELD'],
[E('climb'), '4', ''], [E('climb') + 0.14, '5', ''], [E('climb') + 0.28, '6', ''], [E('climb') + 0.42, '7', 'NOT PRESSED'],
[E('between'), '--', 'PLEASE HOLD'], [E('reconsider'), '--', 'RECONSIDER'],
];
const LIT_AT: Record<string, number> = { 0: E('press'), 1: E('f1'), 2: E('f2'), 3: E('f3'), 7: E('climb') + 0.42 };
const REAR_STOP = 'YOURS', QUIET_CAPTION = ['FLOOR:', 'YOURS'];
const CHEER = 'ROW 12 BALANCES!', BUNTING = 'CONGRATULATIONS', DESK_SIGN = 'NOW ALSO A MEETING ROOM', CAL_TITLE = 'THIS MONTH, AND ALSO NEXT MONTH', QUICK = 'QUICK Q?';
const MOVED = 'RELOCATED', MOVED_SMALL = 'no forwarding floor', SHAFT = ['NOT A', 'FLOOR'], SIGN = ['NOTICE', 'REAR DOOR', 'NEVER LOCKED', 'last read: never'], MUG_TAG = 'FLOOR 3', TOP_TAG = 'THE TOP';
const STEEL = '#b8bcc2', SHADOW = '#7d828a', DISP = '#140d08', AMBER = '#ffae2b', GLOW = '#ffe08a', RUBBER = '#2b2b2e', MUG = '#c8392b', RED = '#ff4a36';
const DOT = 'DotGothic16, monospace', SANS = 'Overpass, sans-serif';
const FACES = [
{ family: 'DotGothic16', file: 'dotgothic16-latin-400-normal.woff2' },
{ family: 'Overpass', file: 'overpass-latin-700-normal.woff2', weight: '700' },
{ family: 'Overpass', file: 'overpass-latin-800-normal.woff2', weight: '800' },
{ family: 'Overpass', file: 'overpass-latin-900-normal.woff2', weight: '900' },
];
const BRUSH = 'repeating-linear-gradient(90deg,rgba(255,255,255,.17) 0 2px,rgba(0,0,0,.05) 2px 5px,rgba(255,255,255,.07) 5px 9px)';
/* The car: a box around the camera. Every wall is 1:1 on screen when it's faced, so a wall's x and y are the screen's minus (70, 150). */
const OY = 620, HW = 470, HD = 540, WALL_H = 1030, WALL_CY = 45, FLOOR_Y = 560, CEIL_Y = -470;
const FRONT = { x: 30, y: 290, w: 690, h: 740 }, REAR = { x: 30, y: 290, w: 890, h: 740 };
type Key = { at: number; to: number; d: number };
const track = (keys: Key[], t: number, from: number) => {
let v = from;
for (const k of keys) { if (t < k.at) break; v += (k.to - v) * easeInOut(ramp(t, k.at, k.at + k.d)); }
return v;
};
/* ---------- timing: doors, camera, light ---------- */
const DOOR: Key[] = [
{ at: E('shut'), to: 0, d: 0.5 },
{ at: E('o1'), to: 1, d: 0.5 }, { at: E('c1'), to: 0, d: 0.35 },
{ at: E('o2'), to: 1, d: 0.5 }, { at: E('c2'), to: 0, d: 0.35 },
{ at: E('o3'), to: 1, d: 0.5 },
// the door tries to close on the hand, bumps it and backs off, twice
{ at: E('hold'), to: 0.36, d: E('holding') + 0.1 - E('hold') }, { at: E('holding') + 0.1, to: 0.6, d: 0.22 },
{ at: E('heldDoor'), to: 0.36, d: 0.17 }, { at: E('heldDoor') + 0.17, to: 0.64, d: 0.24 },
{ at: E('c3'), to: 0, d: 0.35 },
{ at: E('o7'), to: 1, d: 0.5 }, { at: E('c7'), to: 0, d: 0.35 },
{ at: E('between'), to: 0.32, d: 0.3 }, // the stall shakes them open on the shaft
];
const doorAt = (t: number) => track(DOOR, t, 1);
const rearAt = (t: number) => easeInOut(ramp(t, E('open'), E('open') + 1.05));
const YAW: Key[] = [{ at: E('second') - 0.1, to: 108, d: 0.8 }, { at: E('quiet'), to: 180, d: 0.7 }];
const PUSH: Key[] = [
{ at: E('o1'), to: 18, d: 2 }, { at: E('c1'), to: 0, d: 0.3 }, { at: E('o2'), to: 22, d: 2 }, { at: E('c2'), to: 0, d: 0.3 },
{ at: E('gap'), to: 44, d: 0.8 }, { at: E('c3'), to: 0, d: 0.3 }, { at: E('o7'), to: 26, d: 3 }, { at: E('c7'), to: 0, d: 0.3 },
{ at: E('reconsider'), to: 34, d: 0.9 }, { at: E('second'), to: 0, d: 0.6 }, { at: E('reads'), to: 118, d: 1.0 }, { at: E('quiet'), to: 0, d: 0.7 },
{ at: E('your'), to: 138, d: 1.2 }, { at: E('please'), to: 0, d: 0.8 },
];
const LURCH: [number, number][] = [[E('o1') - 0.12, 9], [E('o2') - 0.12, 9], [E('o3') - 0.12, 9], [E('hold') + 0.5, 3], [E('o7') - 0.12, 11], [E('low'), 30], [E('between'), 20], [E('open'), 5]];
const camAt = (t: number) => {
const yaw = track(YAW, t, 0) + Math.sin(t * 0.37) * 0.22;
const push = track(PUSH, t, 0) + 5 * Math.sin(t * 0.43);
const hit = LURCH.reduce((acc, [e, a]) => (t >= e ? acc + a * Math.sin((t - e) * 30) * Math.exp(-(t - e) * 6.5) : acc), 0);
const hum = t < E('between') && doorAt(t) < 0.03 ? Math.sin(t * 43) * 1.3 : 0;
return { yaw, push, dy: hit + hum };
};
const lampAt = (t: number) => {
const a = E('between'), b = E('floors') + 0.3;
if (t < a) return 1;
if (t < b) return hash(Math.floor((t - a) * 15) + 9) > 0.2 + 0.62 * ramp(t, a, b) ? 0.85 : 0.08;
return 0.04;
};
const emAt = (t: number) => ramp(t, E('floors'), E('floors') + 0.4) * (1 - 0.55 * ramp(t, E('door'), E('door') + 0.9)) * (1 - ramp(t, E('quiet') + 0.3, E('quiet') + 1.1));
const seamAt = (t: number) => ramp(t, E('door') - 0.1, E('door') + 0.35);
const sunAt = (t: number) => ramp(t, E('open') - 0.1, E('open') + 0.85);
const shadeAt = (t: number) => {
const base = interpolateColors(lampAt(t), [0, 1], ['#3c3840', '#ffffff']);
const red = interpolateColors(emAt(t), [0, 1], [base, '#9c3226']);
return interpolateColors(sunAt(t), [0, 1], [red, '#fff0da']);
};
const Shade: React.FC = () => <div style={{ position: 'absolute', inset: 0, background: shadeAt(useT()), mixBlendMode: 'multiply', pointerEvents: 'none' }} />;
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
/* ---------- small parts ---------- */
const Label: React.FC<{ x: number; y: number; size: number; color?: string; font?: string; w?: number; rot?: number; style?: React.CSSProperties; children: React.ReactNode }> = ({ x, y, size, color = '#222', font = SANS, w, rot = 0, style, children }) => (
<div style={{ position: 'absolute', left: x, top: y, width: w, font: `800 ${size}px/1.1 ${font}`, color, whiteSpace: 'nowrap', textAlign: w ? 'center' : undefined, transform: rot ? `rotate(${rot}deg)` : undefined, ...style }}>{children}</div>
);
const Room: React.FC<{ w?: number; children: React.ReactNode }> = ({ w = 690, children }) => <svg viewBox={`0 0 ${w} 740`} width={w} height={740} style={{ position: 'absolute', left: 0, top: 0 }}>{children}</svg>;
const RedMug: React.FC<{ x: number; y: number; s?: number }> = ({ x, y, s = 1 }) => (
<g transform={`translate(${x} ${y}) scale(${s})`}>
<path d="M26 6 q 16 2 14 13 q -2 9 -14 8" fill="none" stroke={MUG} strokeWidth="6" />
<rect x="0" y="0" width="30" height="34" rx="4" fill={MUG} /><ellipse cx="15" cy="2" rx="15" ry="4" fill="#7c1e14" />
</g>
);
/** One desk, the same desk everywhere it appears: that's a joke, so keep the mug and the monitor on it. */
const Desk: React.FC<{ x: number; y: number; s?: number; top?: string; face?: string; gear?: boolean; mug?: boolean }> = ({ x, y, s = 1, top = '#c99c68', face = '#a57a4a', gear = true, mug = false }) => (
<g transform={`translate(${x} ${y}) scale(${s})`}>
<rect x="-140" y="40" width="15" height="80" fill="#4e3a27" /><rect x="125" y="40" width="15" height="80" fill="#4e3a27" />
<polygon points="-120,0 120,0 150,30 -150,30" fill={top} /><rect x="-150" y="30" width="300" height="44" fill={face} />
{gear ? (
<g>
<rect x="-10" y="-28" width="20" height="30" fill="#3a3d44" /><rect x="-42" y="-6" width="84" height="8" rx="3" fill="#3a3d44" />
<rect x="-68" y="-112" width="136" height="88" rx="6" fill="#23262b" /><rect x="-60" y="-104" width="120" height="72" fill="#d8f0dd" />
{[0, 1, 2, 3].map((i) => <rect key={i} x="-60" y={-104 + i * 18} width="120" height="1.6" fill="#3f9a68" />)}{[0, 1, 2, 3, 4].map((i) => <rect key={i} x={-60 + i * 24} y="-104" width="1.6" height="72" fill="#3f9a68" />)}
<polygon points="-52,8 52,8 62,22 -62,22" fill="#50545c" />
</g>
) : null}
{mug ? <RedMug x={84} y={-16} /> : null}
</g>
);
/* ---------- floor 0: the lobby ---------- */
const Lobby: React.FC = () => {
const t = useT();
return (
<>
<Room>
<rect width="690" height="740" fill="#dccfb4" />
<rect y="540" width="690" height="200" fill="#efe6d2" />
{Array.from({ length: 28 }, (_, i) => { const r = i % 4, c = (i - r) / 4; return (c + r) % 2 ? <polygon key={i} points={`${345 + (c * 100 - 350) * (1 + r * 0.45)},${540 + r * 50 * (1 + r * 0.14)} ${345 + ((c + 1) * 100 - 350) * (1 + r * 0.45)},${540 + r * 50 * (1 + r * 0.14)} ${345 + ((c + 1) * 100 - 350) * (1.45 + r * 0.45)},${590 + r * 50 * (1 + r * 0.2)} ${345 + (c * 100 - 350) * (1.45 + r * 0.45)},${590 + r * 50 * (1 + r * 0.2)}`} fill="#3f3a33" /> : null; })}
{/* the revolving door: it never stops, so the first frame moves */}
<rect x="205" y="180" width="280" height="360" fill="#a9c4cf" /><rect x="205" y="180" width="280" height="36" fill="#6d5a3a" />
{[0, 1, 2].map((i) => { const a = t * 1.5 + (i * Math.PI) / 1.5; return <rect key={i} x={345 + Math.sin(a) * 130 - 5} y="216" width="10" height="324" fill="#6d5a3a" opacity={0.45 + 0.55 * Math.cos(a) ** 2} />; })}
<rect x="40" y="130" width="60" height="410" fill="#efe6d2" /><rect x="590" y="130" width="60" height="410" fill="#efe6d2" />
<rect x="548" y="520" width="84" height="84" fill="#7a4a2c" />
{[-50, -20, 10, 40].map((r, i) => <ellipse key={i} cx="590" cy="440" rx="24" ry="96" fill={i % 2 ? '#3f7a4d' : '#2d6340'} transform={`rotate(${r + Math.sin(t * 0.9 + i) * 2} 590 530)`} />)}
</Room>
<div style={{ position: 'absolute', left: 205, top: 84, width: 280, padding: '14px 0 8px', background: 'linear-gradient(#d8b25a,#a57f2c)', borderRadius: 6, textAlign: 'center', font: `800 44px/1 ${SANS}`, color: '#3a2a0c', letterSpacing: '.08em' }}>CAREERS ↑</div>
</>
);
};
/* ---------- floor 1: rows of desks and one huge spreadsheet ---------- */
const ROWS: [number, number, number, number][] = [[350, 0.34, 6, 112], [404, 0.46, 5, 140], [470, 0.64, 4, 200]];
const FloorOne: React.FC = () => {
const t = useT();
const ex = E('excited');
const cheer = ramp(t, ex - 0.1, ex + 0.2);
const k = back(ramp(t, ex, ex + 0.26));
return (
<>
<Room>
<rect width="690" height="740" fill="#dcebe2" /><rect y="330" width="690" height="410" fill="#7f9690" />
{Array.from({ length: 9 }, (_, i) => <line key={i} x1={345 + (i - 4) * 50} y1="330" x2={345 + (i - 4) * 220} y2="740" stroke="#6b827c" strokeWidth="3" />)}
{/* the spreadsheet fills the wall */}
<rect x="26" y="20" width="638" height="300" fill="#fbfefb" stroke="#1f7a4d" strokeWidth="5" /><rect x="26" y="20" width="638" height="36" fill="#1f7a4d" />
{Array.from({ length: 49 }, (_, i) => { const c = i % 7, r = (i - c) / 7; const v = hash(i * 3 + Math.floor(t * 2.2 + hash(i) * 3)); return <rect key={i} x={70 + c * 84} y={70 + r * 34} width={22 + v * 50} height={12} fill={v > 0.82 ? '#ffb347' : '#8fc7a4'} />; })}
{Array.from({ length: 8 }, (_, i) => <line key={i} x1={62 + i * 84} y1="56" x2={62 + i * 84} y2="320" stroke="#bcdcc7" strokeWidth="2" />)}{Array.from({ length: 8 }, (_, i) => <line key={i} x1="26" y1={56 + i * 34} x2="664" y2={56 + i * 34} stroke="#bcdcc7" strokeWidth="2" />)}
<rect x={62 + (Math.floor(t * 1.7) % 7) * 84} y={56 + (Math.floor(t * 2.3) % 7) * 34} width="84" height="34" fill="none" stroke="#0d8a4f" strokeWidth="5" />
{/* everyone else, in rows. A wave goes through them on the word */}
{ROWS.map(([y, s, n, gap], ri) => Array.from({ length: n }, (_, i) => {
const x = 345 + (i - (n - 1) / 2) * gap;
const w = cheer * Math.abs(Math.sin((t - ex) * 5 - i * 0.7 - ri * 1.1));
const hy = y - 142 * s - 46 * s * w;
return (
<g key={`${ri}-${i}`}>
<rect x={x - 40 * s} y={hy + 22 * s} width={80 * s} height={120 * s} rx={16 * s} fill={['#51698c', '#7a5a8c', '#3f7f7a', '#a8664c'][(i + ri) % 4]} />
<circle cx={x} cy={hy} r={25 * s} fill={['#e8b48f', '#a9744f', '#f1c9a5', '#7c5235'][(i * 3 + ri) % 4]} />
{w > 0.1 ? <path d={`M${x - 38 * s} ${hy + 34 * s} L${x - 58 * s} ${hy - 30 * s} M${x + 38 * s} ${hy + 34 * s} L${x + 58 * s} ${hy - 30 * s}`} stroke="#51698c" strokeWidth={13 * s} strokeLinecap="round" /> : null}
<Desk x={x} y={y} s={s} />
</g>
);
}))}
<Desk x={345} y={590} mug />
{cheer > 0 ? Array.from({ length: 46 }, (_, i) => { const u = t - ex - hash(i + 8) * 0.5; if (u < 0) return null; const y = u * (240 + hash(i) * 170) - 20; return <rect key={i} x={hash(i + 3) * 690 + Math.sin(u * 5 + i) * 18} y={y} width="13" height="8" fill={['#ffae2b', '#e0464a', '#3fa66c', '#3f7fd0'][i % 4]} transform={`rotate(${u * 220 + i * 40} ${hash(i + 3) * 690} ${y})`} />; }) : null}
</Room>
{t >= ex ? <div style={{ position: 'absolute', left: 95, top: 118, width: 500, textAlign: 'center', padding: '16px 0 8px', background: '#ffcf33', font: `900 50px/1 ${SANS}`, color: '#22301f', transform: `rotate(-3deg) scale(${lerp(1.5, 1, k)})`, boxShadow: '0 8px 14px rgba(0,0,0,.3)' }}>{CHEER}</div> : null}
</>
);
};
/* ---------- floor 2: the same desk, eleven chairs ---------- */
const CHAIRS: [number, number, number][] = [[345, 408, 0.7], [118, 436, 0.78], [226, 420, 0.74], [464, 420, 0.74], [572, 436, 0.78], [58, 556, 0.92], [632, 556, 0.92], [100, 650, 1.06], [214, 684, 1.1], [476, 684, 1.1], [590, 650, 1.06]];
const Chair: React.FC<{ x: number; y: number; s: number; k: number }> = ({ x, y, s, k }) => (
<g transform={`translate(${x} ${y}) scale(${s * k})`}>
<path d="M0 26 L-30 58 M0 26 L30 58 M0 26 L0 62" stroke="#23262b" strokeWidth="7" strokeLinecap="round" />
<rect x="-5" y="4" width="10" height="24" fill="#23262b" />
<rect x="-30" y="-74" width="60" height="62" rx="12" fill="#39404d" /><ellipse cx="0" cy="2" rx="38" ry="15" fill="#4b5365" />
</g>
);
const FloorTwo: React.FC = () => {
const t = useT();
const pop = (i: number) => back(ramp(t, E('more') - 0.2 + i * 0.056, E('more') - 0.2 + i * 0.056 + 0.14));
const seat = (c: [number, number, number], i: number) => (pop(i) > 0 ? <Chair key={i} x={c[0]} y={c[1]} s={c[2]} k={pop(i)} /> : null);
return (
<>
<Room>
<rect width="690" height="740" fill="#e0b98f" /><rect y="360" width="690" height="380" fill="#8c4f3b" />
{Array.from({ length: 7 }, (_, i) => <line key={i} x1={345 + (i - 3) * 70} y1="360" x2={345 + (i - 3) * 280} y2="740" stroke="#7a4331" strokeWidth="3" />)}
<path d="M0 26 Q 345 118 690 26" fill="none" stroke="#6c4a2c" strokeWidth="4" />
{Array.from({ length: 13 }, (_, i) => { const x = 28 + i * 53, y = 26 + 92 * (1 - ((x - 345) / 345) ** 2) * 0.5; return <polygon key={i} points={`${x - 19},${y} ${x + 19},${y} ${x},${y + 42}`} fill={['#d0433f', '#ffcf33', '#3a7fbf'][i % 3]} transform={`rotate(${Math.sin(t * 1.6 + i) * 4} ${x} ${y})`} />; })}
{[[70, 250, 0, '#d0433f'], [130, 276, 0, '#ffcf33'], [622, 300, 16, '#3a7fbf']].map(([x, y, r, c], i) => <g key={i} transform={`rotate(${(r as number) + Math.sin(t * 1.3 + i) * 2} ${x} 380)`}><line x1={x as number} y1={(y as number) + 40} x2={x as number} y2={380} stroke="#6c4a2c" strokeWidth="2" /><ellipse cx={x as number} cy={y as number} rx="30" ry="38" fill={c as string} /></g>)}
{CHAIRS.slice(0, 5).map((c, i) => seat(c, i))}
<Desk x={345} y={590} mug />
{CHAIRS.slice(5).map((c, i) => seat(c, i + 5))}
</Room>
<Label x={0} y={118} w={690} size={54} color="#5a2c1e" style={{ fontWeight: 900, letterSpacing: '.03em' }}>{BUNTING}</Label>
{t >= E('more') ? <Label x={0} y={186} w={690} size={30} color="#5a2c1e" style={{ opacity: ramp(t, E('more'), E('more') + 0.2) }}>{DESK_SIGN}</Label> : null}
</>
);
};
/* ---------- floor 3: the calendar, and the hand ---------- */
const CELLS = ['SYNC', '1:1', 'PREP', 'SYNC', 'ALL HANDS', 'RE: SYNC', '1:1'];
const CAL = { x: 28, y: 100, cw: 91, ch: 88, cols: 7, rows: 5, gapCol: 3, gapRow: 1 };
const Calendar: React.FC = () => {
const t = useT();
const fill = E('o3') + 0.1;
const gapK = ramp(t, E('gap') - 0.1, E('gap') + 0.15);
const pulse = 0.65 + 0.35 * Math.sin((t - E('gap')) * 7);
const q = t >= E('quick') - 0.1 ? easeOut(ramp(t, E('quick') - 0.1, E('quick') + 0.1)) : 0;
const gx = CAL.x + CAL.gapCol * CAL.cw, gy = CAL.y + CAL.gapRow * CAL.ch;
return (
<>
<div style={{ position: 'absolute', left: CAL.x - 12, top: 44, width: CAL.cols * CAL.cw + 24, height: CAL.rows * CAL.ch + 70, background: '#f6f1e2', borderRadius: 6, boxShadow: '0 12px 22px rgba(0,0,0,.45)' }} />
<div style={{ position: 'absolute', left: CAL.x - 12, top: 44, width: CAL.cols * CAL.cw + 24, height: 52, background: '#b23a48', borderRadius: '6px 6px 0 0', textAlign: 'center', font: `800 31px/56px ${SANS}`, color: '#fff3e0', letterSpacing: '.03em' }}>{CAL_TITLE}</div>
{Array.from({ length: CAL.cols * CAL.rows }, (_, i) => {
const c = i % CAL.cols, r = (i - c) / CAL.cols;
if (c === CAL.gapCol && r === CAL.gapRow) return null;
const k = back(ramp(t, fill + hash(i + 2) * 0.75, fill + hash(i + 2) * 0.75 + 0.14));
if (k <= 0) return null;
return <div key={i} style={{ position: 'absolute', left: CAL.x + c * CAL.cw + 3, top: CAL.y + r * CAL.ch + 3, width: CAL.cw - 6, height: CAL.ch - 6, boxSizing: 'border-box', padding: '10px 2px 0', background: ['#4f7cac', '#c98a3d', '#5c9a7c', '#8a5a9c', '#c8606a'][(c * 2 + r * 3 + (i % 2)) % 5], borderRadius: 5, transform: `scale(${k})`, font: `800 16px/1.05 ${SANS}`, color: '#fff', textAlign: 'center' }}>{CELLS[Math.floor(hash(i + 40) * CELLS.length)]}</div>;
})}
{/* the gap: platform-edge stripes around one empty square */}
{gapK > 0 && q <= 0 ? <div style={{ position: 'absolute', left: gx - 5, top: gy - 5, width: CAL.cw + 10, height: CAL.ch + 10, boxSizing: 'border-box', border: '11px solid transparent', borderImage: 'repeating-linear-gradient(45deg,#ffcf33 0 12px,#1b1b1b 12px 24px) 11', opacity: gapK * pulse, transform: `scale(${lerp(1.5, 1, gapK)})` }} /> : null}
{q > 0 ? <div style={{ position: 'absolute', left: gx + 1, top: gy + 1 - (1 - q) * 240, width: CAL.cw - 2, height: CAL.ch - 2, boxSizing: 'border-box', paddingTop: 11, background: '#e0292a', borderRadius: 5, boxShadow: '0 0 0 4px #ffcf33', font: `900 25px/1.05 ${SANS}`, color: '#fff', textAlign: 'center', whiteSpace: 'normal' }}>{QUICK}</div> : null}
</>
);
};
/** One finger up for "one quick question": the other three fold on the word. */
const Hand: React.FC = () => {
const t = useT();
const a = E('hold') + 0.1, b = E('c3') - 0.15;
if (t < a || t > b + 0.35) return null;
const rise = easeOut(ramp(t, a, a + 0.3)) * (1 - easeIn(ramp(t, b, b + 0.3)));
const fold = easeInOut(ramp(t, E('one') - 0.1, E('one') + 0.1));
const jolt = [E('holding') + 0.1, E('heldDoor') + 0.17].reduce((acc, e) => (t >= e ? acc + 7 * Math.sin((t - e) * 40) * Math.exp(-(t - e) * 9) : acc), 0);
// the finger beside the thumb stays up; the other three fold, and the thumb tucks over them
const fingers = [[290, 392 - fold * 92], [332, 362 + fold * 130], [374, 372 + fold * 120], [414, 414 + fold * 78]];
const tx = lerp(212, 372, fold), ty = lerp(520, 548, fold);
const skin = '#e6b28c', line = '#a8734f';
return (
<svg viewBox="0 0 690 740" width={690} height={740} style={{ position: 'absolute', left: 0, top: 0, transform: `translate(${28 + jolt}px, ${(1 - rise) * 420}px)` }}>
<rect x="258" y="664" width="176" height="90" fill="#2b3550" /><rect x="252" y="640" width="188" height="34" rx="4" fill="#eef0f2" />
{fingers.map(([x, y], i) => <path key={i} d={`M${x} 510 L${x} ${y}`} stroke={line} strokeWidth="44" strokeLinecap="round" />)}
<rect x="258" y="478" width="176" height="172" rx="30" fill={skin} stroke={line} strokeWidth="3" />
{fingers.map(([x, y], i) => <path key={i} d={`M${x} 510 L${x} ${y}`} stroke={skin} strokeWidth="37" strokeLinecap="round" />)}
{[1, 2, 3].map((i) => <path key={i} d={`M${fingers[i][0] - 19} 502 L${fingers[i][0] + 19} 502`} stroke={line} strokeWidth="2.5" opacity={fold} />)}
<path d={`M270 612 L${tx} ${ty}`} stroke={line} strokeWidth="46" strokeLinecap="round" /><path d={`M270 612 L${tx} ${ty}`} stroke={skin} strokeWidth="39" strokeLinecap="round" />
</svg>
);
};
const FloorThree: React.FC = () => {
const t = useT();
return (
<>
<Room>
<rect width="690" height="740" fill="#2f2d52" /><rect y="640" width="690" height="100" fill="#1f1d38" />
{Array.from({ length: 12 }, (_, i) => <rect key={i} x={i * 60} y="0" width="2" height="640" fill="#3a3866" />)}
</Room>
<Calendar />
<Room>
<rect x="430" y="604" width="240" height="14" fill="#6e5234" /><RedMug x={560} y={569} s={1.04} />
</Room>
{t >= E('hold') ? <Hand /> : null}
</>
);
};
/* ---------- floor 7: nobody home ---------- */
const FloorSeven: React.FC = () => {
const t = useT();
const m = E('moved') - 0.1;
const k = easeIn(ramp(t, m, m + 0.12));
const weed = ramp(t, E('o7'), E('o7') + 4.2);
const tube = hash(Math.floor(t * 11)) > 0.82 ? 0.72 : 1;
return (
<>
<Room>
<rect width="690" height="740" fill="#79838e" /><rect y="360" width="690" height="380" fill="#555b63" />
{[[80, 420, 120, 50], [486, 420, 120, 50], [40, 510, 170, 70], [480, 510, 170, 70]].map(([x, y, w, h], i) => <rect key={i} x={x} y={y} width={w} height={h} fill="#646b74" />)}
<rect x="60" y="0" width="570" height="20" fill="#e6f0ee" opacity={tube} />
<Desk x={150} y={392} s={0.42} gear={false} /><Desk x={540} y={392} s={0.42} gear={false} />
<Desk x={120} y={470} s={0.66} gear={false} /><Desk x={570} y={470} s={0.66} gear={false} />
<path d="M112 470 q 8 44 -10 60 q -14 12 -3 34" fill="none" stroke="#1f2125" strokeWidth="4" />
{/* the one box */}
<g transform="translate(345 610)">
<ellipse cx="0" cy="66" rx="118" ry="16" fill="rgba(0,0,0,.3)" />
<polygon points="-100,-40 100,-40 112,-8 -112,-8" fill="#c79a5f" /><rect x="-108" y="-8" width="216" height="74" fill="#ab7d44" /><rect x="-18" y="-8" width="36" height="74" fill="#d8c79f" opacity="0.6" />
</g>
{/* a dust ball crosses the floor, because nobody will stop it */}
<g transform={`translate(${720 - weed * 860} ${698 - Math.abs(Math.sin(t * 5)) * 12}) rotate(${-t * 300})`} stroke="#3d3f42" strokeWidth="2.6" fill="none">
{[0, 1, 2, 3, 4, 5, 6].map((i) => <ellipse key={i} rx="31" ry={12 + i * 3} transform={`rotate(${i * 26})`} />)}
</g>
</Room>
{t >= m ? (
<div style={{ position: 'absolute', left: 132, top: 92, width: 426, boxSizing: 'border-box', padding: '26px 0 16px', background: '#f7d84a', textAlign: 'center', transform: `rotate(-3deg) scale(${lerp(1.9, 1, k)})`, boxShadow: '0 12px 18px rgba(0,0,0,.35)' }}>
<div style={{ font: `900 68px/1 ${SANS}`, color: '#151515' }}>{MOVED}</div>
<div style={{ font: `700 32px/1 ${SANS}`, color: '#151515', marginTop: 10 }}>{MOVED_SMALL}</div>
{[[-22, -12, -24], [392, -14, 26]].map(([x, y, r], i) => <div key={i} style={{ position: 'absolute', left: x, top: y, width: 58, height: 26, background: 'rgba(240,235,200,.75)', transform: `rotate(${r}deg)` }} />)}
</div>
) : <div style={{ position: 'absolute', left: 148, top: 104, width: 390, height: 148, background: 'rgba(255,255,255,.1)' }} />}
</>
);
};
/** Between floors: the doors shake open on bare shaft. */
const Shaft: React.FC = () => {
const t = useT();
return (
<>
<Room>
<rect width="690" height="740" fill="#5e5b56" /><rect y="330" width="690" height="46" fill="#3f3d3a" /><rect y="376" width="690" height="364" fill="#17161a" />
{Array.from({ length: 60 }, (_, i) => <circle key={i} cx={hash(i) * 690} cy={hash(i + 70) * 330} r={1 + hash(i + 9) * 2} fill="#44423e" />)}
<path d={`M300 376 Q ${300 + Math.sin(t * 2.3) * 14} 560 ${300 + Math.sin(t * 2.3 + 0.5) * 6} 740`} stroke="#6a6a6e" strokeWidth="8" fill="none" /><path d={`M392 376 Q ${392 + Math.sin(t * 2.1 + 1) * 12} 560 ${392 + Math.sin(t * 2.1 + 1.4) * 6} 740`} stroke="#6a6a6e" strokeWidth="8" fill="none" />
</Room>
{SHAFT.map((s, i) => <Label key={i} x={0} y={146 + i * 72} w={690} size={74} color="#f6efe0" font={DOT}><Dots s={s} /></Label>)}
</>
);
};
const FrontRoom: React.FC = () => {
const t = useT();
if (t < E('o1') - 0.02) return <Lobby />;
if (t < E('o2') - 0.02) return <FloorOne />;
if (t < E('o3') - 0.02) return <FloorTwo />;
if (t < E('o7') - 0.02) return <FloorThree />;
if (t < E('between')) return <FloorSeven />;
return <Shaft />;
};
/* ---------- the floor nobody listed ---------- */
const SunRoom: React.FC = () => {
const t = useT();
const glow = ramp(t, E('better'), E('better') + 1.1);
const plate = back(ramp(t, E('your') - 0.1, E('your') + 0.25));
const cloud = easeOut(ramp(t, E('top') - 0.1, E('top') + 0.7));
const gl = ((t - E('your') - 0.5) % 2.4) / 0.7;
return (
<>
<Room w={890}>
<defs>
<linearGradient id="sk" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#7cc4f4" /><stop offset="1" stopColor="#fff2c8" /></linearGradient>
<radialGradient id="sn" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stopColor="#fffbe6" /><stop offset="0.3" stopColor="#ffeeaa" stopOpacity="0.9" /><stop offset="1" stopColor="#ffe9a0" stopOpacity="0" /></radialGradient>
<linearGradient id="wl" x1="0" y1="0" x2="1" y2="0.6"><stop offset="0" stopColor="#f4d9a2" /><stop offset="1" stopColor="#e6b56e" /></linearGradient>
<linearGradient id="br" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stopColor="#b98f3a" /><stop offset="0.5" stopColor="#f0d58a" /><stop offset="1" stopColor="#a67e2c" /></linearGradient>
<linearGradient id="shf" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#fff2c4" stopOpacity="0.5" /><stop offset="1" stopColor="#fff2c4" stopOpacity="0.05" /></linearGradient>
</defs>
<rect width="890" height="740" fill="url(#wl)" />
{/* the view: rolling green, and far off, the tower with its own small rain cloud */}
<rect x="238" y="44" width="492" height="384" fill="url(#sk)" />
<circle cx="630" cy="130" r={170 + 26 * glow} fill="url(#sn)" />
<path d="M238 366 Q 330 300 450 344 T 730 320 V428 H238 Z" fill="#7fb85a" /><path d="M238 396 Q 400 346 560 392 T 730 376 V428 H238 Z" fill="#4f9448" />
{[300, 410, 610, 680].map((x, i) => <ellipse key={i} cx={x} cy={374 + (i % 2) * 10} rx="34" ry="30" fill="#2f7040" />)}
<rect x="336" y="178" width="54" height="172" fill="#6f7b92" />
{Array.from({ length: 21 }, (_, i) => <rect key={i} x={343 + (i % 3) * 15} y={188 + Math.floor(i / 3) * 22} width="9" height="12" fill="#465270" />)}
<g transform={`translate(378 150) scale(${0.25 + 0.75 * cloud})`} opacity={cloud}>
{Array.from({ length: 6 }, (_, i) => <line key={i} x1={-66 + i * 18} y1={26 + ((t * 110 + i * 23) % 44)} x2={-72 + i * 18} y2={46 + ((t * 110 + i * 23) % 44)} stroke="#4a6a9a" strokeWidth="4" strokeLinecap="round" />)}
<rect x="-104" y="-6" width="208" height="32" rx="16" fill="#8a93a3" />
{[[-70, -6, 30], [-30, -16, 34], [18, -12, 38], [66, -4, 28]].map(([x, y, r], i) => <circle key={i} cx={x} cy={y} r={r} fill={i % 2 ? '#98a1b0' : '#8a93a3'} />)}
</g>
<rect x="238" y="44" width="492" height="384" fill="none" stroke="#fff8e6" strokeWidth="18" /><rect x="572" y="44" width="12" height="384" fill="#fff8e6" /><rect x="238" y="226" width="492" height="10" fill="#fff8e6" /><rect x="222" y="428" width="524" height="20" fill="#fff8e6" />
{/* floor, and the window lying across it */}
<rect y="552" width="890" height="188" fill="#a86e38" /><rect y="552" width="890" height="10" fill="#fff2d6" />
{Array.from({ length: 11 }, (_, i) => <line key={i} x1={445 + (i - 5) * 80} y1="562" x2={445 + (i - 5) * 200} y2="740" stroke="#8a5a2a" strokeWidth="3" />)}
<polygon points="250,566 722,566 860,740 90,740" fill="#ffdf96" opacity={0.3 + 0.25 * glow} />
<polygon points="572,566 584,566 650,740 612,740" fill="#a86e38" opacity="0.6" /><polygon points="176,650 800,650 812,664 164,664" fill="#a86e38" opacity="0.5" />
{/* the plant */}
<g transform="translate(118 0)">
{[[-62, 96, 44], [-40, 150, 40], [-22, 74, 50], [-6, 186, 44], [14, 112, 52], [30, 196, 40], [50, 130, 46], [70, 84, 40]].map(([r, l, w], i) => {
const sway = Math.sin(t * 1.1 + i * 0.8) * 2.2, tip = 548 - l;
return <g key={i} transform={`rotate(${r + sway} 0 548)`}><path d={`M0 548 L0 ${tip}`} stroke="#2a5f38" strokeWidth="7" /><ellipse cx="0" cy={tip - w * 1.3} rx={w} ry={w * 1.75} fill={['#2c7a44', '#39904f', '#25693b'][i % 3]} /><path d={`M0 ${tip} L0 ${tip - w * 2.8}`} stroke="#7cc98a" strokeWidth="3" opacity="0.7" /></g>;
})}
<path d="M-58 548 L58 548 L44 672 L-44 672 Z" fill="#b5613a" /><rect x="-64" y="536" width="128" height="22" rx="4" fill="#cf7a4c" />
</g>
{/* the chair and the one desk, with nothing on it yet */}
<g transform="translate(490 430)" fill="#6b4a2c"><rect x="-58" y="-76" width="13" height="130" rx="5" /><rect x="45" y="-76" width="13" height="130" rx="5" /><rect x="-64" y="-84" width="128" height="24" rx="10" /><rect x="-50" y="-42" width="100" height="10" /><rect x="-50" y="-16" width="100" height="10" /></g>
<Desk x={490} y={494} s={1.28} top="#c9925a" face="#9c6c3a" gear={false} />
{/* the nameplate: brass, blank, and a cursor, because nobody has told it who you are */}
<g transform={`translate(490 528) scale(${clamp(plate, 0, 1.2)})`}>
<polygon points="-100,0 100,0 92,10 -92,10" fill="#2b2118" />
<polygon points="-92,0 92,0 84,-54 -84,-54" fill="url(#br)" stroke="#6e4f16" strokeWidth="3" />
<polygon points="-82,-7 82,-7 76,-47 -76,-47" fill="none" stroke="#6e4f16" strokeWidth="2" opacity="0.7" />
{Math.floor(t * 2) % 2 === 0 ? <rect x="-4" y="-42" width="8" height="30" fill="#4c3610" /> : null}
{gl > 0 && gl < 1 ? <polygon points={`${-92 + gl * 170},-54 ${-62 + gl * 170},-54 ${-70 + gl * 170},0 ${-100 + gl * 170},0`} fill="#fff8d8" opacity="0.75" /> : null}
</g>
<polygon points="238,44 730,44 862,740 92,740" fill="url(#shf)" opacity={0.16 + 0.34 * glow} />
{Array.from({ length: 36 }, (_, i) => { const y = (hash(i + 2) * 700 + t * (10 + hash(i) * 14)) % 700, x = 200 + hash(i + 50) * 560 + Math.sin(t * 0.5 + i) * 18; return <circle key={i} cx={x} cy={y} r={1.6 + hash(i + 9) * 2.4} fill="#fffbe0" opacity={0.3 + 0.5 * Math.abs(Math.sin(t * 0.8 + i))} />; })}
</Room>
{cloud > 0 ? <Label x={412} y={190} size={27} color="#27304a" style={{ opacity: cloud }}>← {TOP_TAG}</Label> : null}
</>
);
};
/** One sad mug, left behind on floor three, waits on the sill. */
const SadMug: React.FC = () => {
const t = useT();
const at = E('belongings') - 0.1;
if (t < at) return null;
const k = back(ramp(t, at, at + 0.34));
const droop = easeInOut(ramp(t, E('three') - 0.1, E('three') + 0.4));
const tear = ramp(t, E('three') + 0.1, E('three') + 0.9);
const blink = (t - at) % 2.3 > 2.16;
return (
<div style={{ position: 'absolute', left: 640 + lerp(330, 0, k), top: 838, width: 230, height: 192, transform: `rotate(${(1 - clamp(k)) * 6 + Math.sin(t * 1.4) * 0.5}deg)`, transformOrigin: '90px 190px' }}>
<svg viewBox="0 0 230 192" width={230} height={192} style={{ overflow: 'visible' }}>
{[72, 112].map((x) => <path key={x} d={`M${x} ${48 + droop * 8} C ${x - 12} ${26 + droop * 16} ${x + 20} ${10 + droop * 28} ${x + 2} ${-10 + droop * 46}`} fill="none" stroke="#fff" strokeOpacity="0.8" strokeWidth="7" strokeLinecap="round" />)}
<ellipse cx="92" cy="186" rx="88" ry="8" fill="rgba(60,30,0,.35)" />
<path d="M150 88 q 56 2 52 42 q -4 34 -52 28" fill="none" stroke={MUG} strokeWidth="19" />
<rect x="16" y="52" width="150" height="134" rx="16" fill={MUG} /><ellipse cx="91" cy="56" rx="75" ry="14" fill="#6e1a11" /><ellipse cx="91" cy="53" rx="75" ry="12" fill="#3a1d10" />
{blink ? <path d="M52 120 h 24 M106 120 h 24" stroke="#260b06" strokeWidth="7" strokeLinecap="round" /> : <g><ellipse cx="64" cy="118" rx="14" ry="17" fill="#260b06" /><ellipse cx="118" cy="118" rx="14" ry="17" fill="#260b06" /><circle cx="60" cy="111" r="5" fill="#fff" /><circle cx="114" cy="111" r="5" fill="#fff" /></g>}
<path d={`M44 ${102 + droop * 2} L74 ${88 - droop * 3} M138 ${102 + droop * 2} L108 ${88 - droop * 3}`} stroke="#260b06" strokeWidth="6" strokeLinecap="round" />
<path d={`M72 ${166 - droop * 2} Q 91 ${150 - droop * 6} 110 ${166 - droop * 2}`} fill="none" stroke="#260b06" strokeWidth="7" strokeLinecap="round" />
{tear > 0 && tear < 1 ? <path d={`M52 ${134 + tear * 36} q -7 11 0 15 q 7 -4 0 -15 Z`} fill="#bfe4ff" stroke="#6fa8d0" strokeWidth="1.5" /> : null}
</svg>
<div style={{ position: 'absolute', left: -74, top: 118 + droop * 4, padding: '6px 10px 2px', background: '#f7f1dc', font: `800 25px/1.1 ${SANS}`, color: '#222', transform: `rotate(${-8 + Math.sin(t * 2.4) * 3}deg)`, transformOrigin: '100% 0', boxShadow: '0 2px 4px rgba(0,0,0,.3)', whiteSpace: 'nowrap' }}>{MUG_TAG}</div>
</div>
);
};
/* ---------- doors and the walls they sit in ---------- */
const Leaf: React.FC<{ side: -1 | 1; k: number; w: number; h: number; color?: string }> = ({ side, k, w, h, color = '#c5c9cf' }) => {
const t = useT();
const cx = side < 0 ? w : 0;
const moving = 1 - clamp(k * 30);
const band = ((t * 1900) % 1500) - 300;
return (
<div style={{ position: 'absolute', top: 0, left: side < 0 ? 0 : w, width: w, height: h, overflow: 'hidden', transform: `translateX(${side * k * (w + 8)}px)`, backgroundColor: color, backgroundImage: `radial-gradient(74px 96px at ${cx}px 250px,rgba(35,37,45,.34),rgba(35,37,45,0)),radial-gradient(180px 300px at ${cx}px 570px,rgba(35,37,45,.34),rgba(35,37,45,0)),linear-gradient(rgba(255,255,255,.3),rgba(255,255,255,0) 26%,rgba(0,0,0,0) 72%,rgba(0,0,0,.22)),${BRUSH}`, boxShadow: side < 0 ? 'inset -5px 0 0 #3a3d44' : 'inset 5px 0 0 #3a3d44' }}>
{moving > 0 ? <div style={{ position: 'absolute', left: 0, right: 0, top: band, height: 150, background: 'linear-gradient(rgba(255,244,210,0),rgba(255,244,210,.5),rgba(255,244,210,0))', opacity: moving }} /> : null}
</div>
);
};
const Aperture: React.FC<{ a: { x: number; y: number; w: number; h: number }; children: React.ReactNode }> = ({ a, children }) => (
<>
<div style={{ position: 'absolute', left: a.x - 18, top: a.y - 18, width: a.w + 36, height: a.h + 18, background: `linear-gradient(#92979e,${SHADOW})`, borderRadius: '6px 6px 0 0', boxShadow: '0 5px 14px rgba(0,0,0,.35)' }} />
<div style={{ position: 'absolute', left: a.x, top: a.y, width: a.w, height: a.h, overflow: 'hidden', background: '#0b0b0d' }}>{children}</div>
<div style={{ position: 'absolute', left: a.x - 18, top: a.y + a.h - 12, width: a.w + 36, height: 12, background: 'repeating-linear-gradient(90deg,#9ca1a8 0 6px,#5f646b 6px 9px)' }} />
</>
);
const Glass: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div style={{ position: 'absolute', left: 40, top: 112, width: 860, height: 158, overflow: 'hidden', borderRadius: 12, background: DISP, backgroundImage: 'radial-gradient(circle,rgba(255,174,43,.07) 1.5px,transparent 2px)', backgroundSize: '7px 7px', boxShadow: '0 4px 10px rgba(0,0,0,.35)' }}>
<div style={{ position: 'absolute', inset: 0, borderRadius: 12, boxShadow: `inset 0 0 0 6px ${SHADOW},inset 0 0 0 9px #2c2d31` }} />
<Shade />
{children}
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(115deg,rgba(255,255,255,.1),rgba(255,255,255,0) 28%)' }} />
</div>
);
/** The dot font is fixed pitch, so an I or an apostrophe leaves a hole. Pull those letters in. */
const pull = (c: string): React.CSSProperties | undefined => (c === 'I' || c === "'" || c === '1' ? { margin: '0 -.13em' } : undefined);
const Dots: React.FC<{ s: string }> = ({ s }) => <>{Array.from(s).map((c, i) => <span key={i} style={pull(c)}>{c}</span>)}</>;
const led = (o = 1): React.CSSProperties => ({ color: AMBER, opacity: o, textShadow: '0 0 18px rgba(255,174,43,.75),0 0 3px rgba(255,224,138,.8)', whiteSpace: 'nowrap' });
const FrontDisplay: React.FC = () => {
const t = useT();
let s = STOPS[0];
for (const x of STOPS) if (t >= x[0]) s = x;
const dead = t >= E('between');
const moving = !dead && doorAt(t) < 0.05;
const stutter = dead && t < E('floors') + 0.4 ? (hash(Math.floor(t * 17) + 4) > 0.4 ? 1 : 0.2) : 1;
const arrowY = moving ? -((t * 3) % 1) * 26 + 13 : 0;
return (
<Glass>
<div style={{ position: 'absolute', left: 34, top: 44 + arrowY, width: 0, height: 0, borderLeft: '34px solid transparent', borderRight: '34px solid transparent', borderBottom: `60px solid ${AMBER}`, opacity: dead ? 0.1 : moving ? 1 : 0.42, filter: 'drop-shadow(0 0 8px rgba(255,174,43,.7))' }} />
<div style={{ position: 'absolute', left: 118, top: 14, font: `136px/1 ${DOT}`, color: '#2a1a0a' }}>88</div>
{dead ? [0, 1].map((i) => <div key={i} style={{ position: 'absolute', left: 126 + i * 68, top: 74, width: 54, height: 13, background: AMBER, boxShadow: '0 0 16px rgba(255,174,43,.8)', opacity: stutter }} />) : <div style={{ position: 'absolute', left: 118, top: 14, width: 136, textAlign: 'center', font: `136px/1 ${DOT}`, ...led(stutter) }}>{s[1]}</div>}
<div style={{ position: 'absolute', left: 276, top: 44, font: `68px/1 ${DOT}`, ...led(stutter * 0.95) }}><Dots s={s[2]} /></div>
</Glass>
);
};
const RearDisplay: React.FC = () => {
const t = useT();
const a = E('open') - 0.1, b = E('stop'), c = E('cta');
let text = '--', size = 136, o = 0.28;
if (t >= a && t < b) { const step = Math.floor(t * (22 - 14 * ramp(t, a, b))); text = `${Math.floor(hash(step) * 90) + 10}`; o = 1; }
else if (t >= b && t < c) { text = REAR_STOP; o = 1; }
else if (t >= c) { text = `COMMENT ${KEYWORD}`; size = Math.min(128, 1560 / text.length); o = t < c + 0.42 ? (Math.floor(t * 16) % 2 ? 1 : 0.35) : 1; }
const k = t >= b && t < c ? lerp(1.25, 1, easeOut(ramp(t, b, b + 0.2))) : 1;
return (
<Glass>
<div style={{ position: 'absolute', left: 0, top: (158 - size) / 2, width: 860, textAlign: 'center', font: `${size}px/1 ${DOT}`, transform: `scale(${k})`, ...led(o) }}><Dots s={text} /></div>
</Glass>
);
};
/* ---------- the button panel and the phone ---------- */
const LABELS = ['9', '8', '7', '6', '5', '4', '3', '2', '1', '0'];
const btn = (i: number) => ({ x: 790 + (i % 2) * 84, y: 350 + Math.floor(i / 2) * 80 });
const PanelBase: React.FC = () => (
<>
<div style={{ position: 'absolute', left: 742, top: 300, width: 180, height: 730, borderRadius: 14, backgroundColor: '#a7acb3', backgroundImage: BRUSH, boxShadow: 'inset 0 0 0 4px #80858c,0 3px 8px rgba(0,0,0,.3)' }} />
{LABELS.map((l, i) => { const p = btn(i); return <div key={l} style={{ position: 'absolute', left: p.x - 33, top: p.y - 33, width: 66, height: 66, borderRadius: '50%', background: 'radial-gradient(circle at 40% 35%,#eef0f2,#979da4)', boxShadow: '0 3px 0 #6d737b', textAlign: 'center', font: `800 36px/72px ${SANS}`, color: '#2b2b2e' }}>{l}</div>; })}
{/* door open, door close */}
{[0, 1].map((i) => { const x = 790 + i * 84; return <div key={i} style={{ position: 'absolute', left: x - 33, top: 738, width: 66, height: 50, borderRadius: 10, background: 'linear-gradient(#e2e5e8,#a2a7ae)', boxShadow: '0 3px 0 #6d737b', textAlign: 'center', font: `800 26px/56px ${SANS}`, color: '#2b2b2e' }}>{i ? '>|<' : '<|>'}</div>; })}
</>
);
const PanelLights: React.FC = () => {
const t = useT();
const hold = t >= E('holding') && t < E('c3');
return (
<>
{LABELS.map((l, i) => {
const a = LIT_AT[l];
if (a === undefined || t < a || t >= E('between') + i * 0.06) return null;
const o = l === '7' && t < a + 0.6 ? (Math.floor((t - a) * 10) % 2 ? 1 : 0.3) : 1;
const p = btn(i), ring = 1 - ramp(t, a, a + 0.45);
return (
<React.Fragment key={l}>
<div style={{ position: 'absolute', left: p.x - 33, top: p.y - 33, width: 66, height: 66, borderRadius: '50%', background: `radial-gradient(circle at 40% 35%,${GLOW},#e89a1c)`, boxShadow: `0 0 0 5px ${AMBER},0 0 24px ${GLOW}`, textAlign: 'center', font: `800 36px/72px ${SANS}`, color: '#6a3d00', opacity: o }}>{l}</div>
{ring > 0 ? <div style={{ position: 'absolute', left: p.x - 33, top: p.y - 33, width: 66, height: 66, borderRadius: '50%', boxShadow: `0 0 0 ${2 + (1 - ring) * 8}px ${AMBER}`, transform: `scale(${1 + (1 - ring) * 0.9})`, opacity: ring }} /> : null}
</React.Fragment>
);
})}
{hold ? <div style={{ position: 'absolute', left: 757, top: 738, width: 66, height: 50, borderRadius: 10, background: GLOW, boxShadow: `0 0 0 4px ${AMBER},0 0 18px ${GLOW}`, textAlign: 'center', font: `800 26px/56px ${SANS}`, color: '#6a3d00' }}>{'<|>'}</div> : null}
</>
);
};
const Phone: React.FC = () => {
const t = useT();
const r = E('reconsider') - 0.1;
const ringing = t >= r && t < E('open') + 0.2;
const on = ringing && Math.floor((t - r) * 2.4) % 2 === 0;
const rot = on ? Math.sin(t * 62) * 9 : 0;
return (
<div style={{ position: 'absolute', left: 752, top: 812, width: 136, height: 200, borderRadius: 10, background: '#232427', boxShadow: on ? `inset 0 0 0 3px #4c4f55,0 0 70px 22px rgba(255,74,54,.55)` : 'inset 0 0 0 3px #4c4f55' }}>
<div style={{ position: 'absolute', left: 0, right: 0, top: 13, textAlign: 'center', font: `800 19px/1 ${SANS}`, color: '#ff8a78' }}>EMERGENCY</div>
<svg viewBox="8 0 136 150" width={136} height={150} style={{ position: 'absolute', left: 0, top: 44, overflow: 'visible' }}>
{ringing && on ? [0, 1, 2].map((i) => { const k = ((t - r) * 2.4 + i * 0.22) % 1; return <circle key={i} cx="76" cy="66" r={34 + k * 66} fill="none" stroke={RED} strokeWidth="5" opacity={(1 - k) * 0.9} />; }) : null}
<g transform={`rotate(${rot} 76 72)`}>
<path d="M26 44 C 44 22 108 22 126 44 L 126 66 L 98 62 L 98 46 C 88 42 64 42 54 46 L 54 62 L 26 66 Z" fill={ringing ? RED : '#b0352a'} />
<rect x="44" y="72" width="64" height="44" rx="8" fill={ringing ? '#d83a2a' : '#8f2a22'} />
</g>
<circle cx="76" cy="136" r="9" fill={on ? '#ffd9d0' : '#5c1a14'} />
{on ? <circle cx="76" cy="136" r="24" fill={RED} opacity="0.4" /> : null}
</svg>
</div>
);
};
/* ---------- speaker: the meter reads the words from the clock, so it works with no audio ---------- */
const Speaker: React.FC = () => {
const t = useT();
const live = WORDS.findIndex((w) => t >= w.t0 && t < w.t1);
return (
<div style={{ position: 'absolute', left: 330, top: 26, width: 280, height: 66, borderRadius: 33, background: '#54575d', boxShadow: 'inset 0 3px 7px rgba(0,0,0,.6),0 1px 0 rgba(255,255,255,.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
{Array.from({ length: 19 }, (_, i) => <div key={i} style={{ width: 6, borderRadius: 3, height: 10 + (live >= 0 ? 34 * hash(i * 5 + Math.floor(t * 14) + live) * (1 - Math.abs(i - 9) / 11) : 0), background: live >= 0 ? GLOW : '#26272a' }} />)}
</div>
);
};
const Plaque: React.FC<{ x: number; w: number; lines: string[] }> = ({ x, w, lines }) => (
<div style={{ position: 'absolute', left: x, top: 22, width: w, padding: '10px 0 6px', textAlign: 'center', background: 'linear-gradient(#d9dce0,#a2a7ae)', borderRadius: 4, boxShadow: '0 2px 3px rgba(0,0,0,.45)', font: `800 21px/1.14 ${SANS}`, color: '#2b2b2e', letterSpacing: '.03em' }}>{lines.map((l) => <div key={l}>{l}</div>)}</div>
);
/* ---------- the box ---------- */
const wallBase = (c: string): React.CSSProperties => ({ position: 'absolute', inset: 0, backgroundColor: c, backgroundImage: `linear-gradient(rgba(255,255,255,.14),rgba(0,0,0,0) 22%,rgba(0,0,0,0) 76%,rgba(0,0,0,.25)),${BRUSH}`, boxShadow: 'inset 0 0 90px rgba(0,0,0,.4)' });
const Plane: React.FC<{ w: number; h: number; tf: string; children: React.ReactNode }> = ({ w, h, tf, children }) => <div style={{ position: 'absolute', left: -w / 2, top: -h / 2, width: w + 1, height: h + 1, transform: tf, overflow: 'hidden' }}>{children}</div>;
const FrontWall: React.FC = () => {
const k = doorAt(useT());
return (
<>
<div style={wallBase(STEEL)} />
<Plaque x={40} w={250} lines={['CAPACITY:', 'ONE CAREER']} /><Speaker /><Plaque x={650} w={250} lines={['INSPECTED:', 'BY NOBODY']} />
<Aperture a={FRONT}>
<FrontRoom />
<div style={{ position: 'absolute', inset: 0, boxShadow: 'inset 0 26px 34px rgba(0,0,0,.42),inset 18px 0 24px rgba(0,0,0,.25),inset -18px 0 24px rgba(0,0,0,.25)' }} />
<Leaf side={-1} k={k} w={FRONT.w / 2} h={FRONT.h} /><Leaf side={1} k={k} w={FRONT.w / 2} h={FRONT.h} />
</Aperture>
<PanelBase />
<Shade />
<FrontDisplay /><PanelLights /><Phone />
</>
);
};
const RearWall: React.FC = () => {
const t = useT();
const k = rearAt(t), seam = seamAt(t) * (1 - sunAt(t));
return (
<>
<div style={wallBase(STEEL)} />
<Plaque x={40} w={250} lines={['CAPACITY:', 'YOU']} /><Plaque x={650} w={250} lines={['NOT ON', 'THE DIRECTORY']} />
<Aperture a={REAR}>
<SunRoom />
{k < 1 ? <><Leaf side={-1} k={k} w={REAR.w / 2} h={REAR.h} /><Leaf side={1} k={k} w={REAR.w / 2} h={REAR.h} /></> : null}
</Aperture>
<Shade />
{seam > 0 ? <div style={{ position: 'absolute', left: REAR.x + REAR.w / 2 - 5 - k * REAR.w * 0.5, top: REAR.y, width: 10 + k * REAR.w, height: REAR.h, background: '#fff2c4', boxShadow: `0 0 70px 26px rgba(255,214,130,${seam})`, opacity: seam }} /> : null}
{seam > 0 ? <div style={{ position: 'absolute', left: REAR.x - 6, top: REAR.y - 6, width: REAR.w + 12, height: REAR.h + 6, boxShadow: `0 0 34px 8px rgba(255,214,130,${seam * 0.9}),inset 0 0 0 6px rgba(255,236,180,${seam})` }} /> : null}
<RearDisplay />
<SadMug />
</>
);
};
const RightWall: React.FC = () => {
const t = useT();
const k = seamAt(t);
return (
<>
<div style={wallBase('#9a9fa6')} />
{[230, 850].map((x) => <div key={x} style={{ position: 'absolute', left: x, top: 0, width: 4, height: WALL_H, background: '#54575d' }} />)}
<div style={{ position: 'absolute', left: 0, top: 712, width: 1080, height: 30, background: 'linear-gradient(#eef0f2,#878c93 60%,#55585f)', boxShadow: '0 12px 12px rgba(0,0,0,.3)' }} />
<Shade />
<div style={{ position: 'absolute', inset: 0, background: `linear-gradient(to left,rgba(255,206,120,${0.95 * k}),rgba(255,190,100,${0.4 * k}) 34%,rgba(255,190,100,0) 72%)`, mixBlendMode: 'screen' }} />
{/* the small sign nobody reads. It's lit from behind us, so it sits above the shade and dims itself until the light arrives */}
<div style={{ position: 'absolute', left: 386, top: 306, width: 400, height: 262, boxSizing: 'border-box', paddingTop: 26, background: 'linear-gradient(#f6f1df,#d9d2bc)', borderRadius: 8, boxShadow: '0 0 0 7px #5c5f65,0 10px 16px rgba(0,0,0,.45)', textAlign: 'center', color: '#23211c', whiteSpace: 'nowrap' }}>
<div style={{ font: `800 26px/1 ${SANS}`, letterSpacing: '.2em' }}>{SIGN[0]}</div>
<div style={{ font: `900 66px/1 ${SANS}`, marginTop: 14 }}>{SIGN[1]}</div>
<div style={{ font: `900 44px/1 ${SANS}`, marginTop: 8, color: '#b3261c' }}>{SIGN[2]}</div>
<div style={{ font: `700 26px/1 ${SANS}`, marginTop: 16, opacity: 0.75 }}>{SIGN[3]}</div>
<div style={{ position: 'absolute', inset: -7, borderRadius: 8, background: '#5a1c14', mixBlendMode: 'multiply', opacity: 1 - 0.95 * k }} />
</div>
<svg viewBox="0 0 140 140" width={140} height={140} style={{ position: 'absolute', left: 686, top: 250 }} stroke="#fff" strokeWidth="1.8" fill="none" opacity={0.25 + 0.65 * k}>
{[0, 20, 42, 66, 90].map((a) => <line key={a} x1="118" y1="10" x2={118 - Math.cos((a * Math.PI) / 180) * 120} y2={10 + Math.sin((a * Math.PI) / 180) * 120} />)}
{[40, 72, 102].map((r) => <path key={r} d={`M${118 - r} 10 Q ${118 - r * 0.7} ${10 + r * 0.42} ${118 - r * 0.62} ${10 + r * 0.72} Q ${118 - r * 0.32} ${10 + r * 0.72} 118 ${10 + r}`} />)}
</svg>
</>
);
};
const LeftWall: React.FC = () => {
const k = sunAt(useT());
return (
<>
<div style={wallBase('#9a9fa6')} />
<div style={{ position: 'absolute', left: 0, top: 712, width: 1080, height: 30, background: 'linear-gradient(#eef0f2,#878c93 60%,#55585f)' }} />
<Shade />
<div style={{ position: 'absolute', inset: 0, background: `linear-gradient(to right,rgba(255,206,120,${0.8 * k}),rgba(255,190,100,0) 60%)`, mixBlendMode: 'screen' }} />
</>
);
};
const FloorPlane: React.FC = () => {
const t = useT();
const sun = sunAt(t), sm = seamAt(t);
return (
<>
<div style={{ position: 'absolute', inset: 0, backgroundColor: RUBBER, backgroundImage: 'repeating-linear-gradient(45deg,rgba(255,255,255,.05) 0 5px,transparent 5px 15px),repeating-linear-gradient(-45deg,rgba(255,255,255,.05) 0 5px,transparent 5px 15px)' }} />
{[['PLEASE FACE THE DOORS', 214, 0], ['NOBODY STANDS HERE', 776, 180]].map(([s, y, r]) => <div key={s as string} style={{ position: 'absolute', left: 150, top: y as number, width: 640, height: 90, background: '#222225', borderRadius: 6, boxShadow: 'inset 0 0 0 3px #47474b', textAlign: 'center', font: `800 28px/92px ${SANS}`, letterSpacing: '.12em', color: '#6b6b70', transform: `rotate(${r}deg)` }}>{s}</div>)}
<Shade />
<div style={{ position: 'absolute', inset: 0, background: `linear-gradient(to top,rgba(255,214,140,${0.9 * sun + 0.3 * sm * (1 - sun)}),rgba(255,214,140,${0.3 * sun}) 45%,rgba(255,214,140,0) 80%)`, mixBlendMode: 'screen' }} />
</>
);
};
const CeilingPlane: React.FC = () => {
const t = useT();
const on = Math.max(lampAt(t), sunAt(t) * 0.85);
return (
<>
<div style={{ position: 'absolute', inset: 0, background: '#6a6d73' }} />
<div style={{ position: 'absolute', left: 190, top: 0, width: 560, height: 1081, background: interpolateColors(on, [0, 1], ['#3a3532', '#fbf0d4']), boxShadow: on > 0.5 ? '0 0 60px 20px rgba(255,244,214,.45)' : undefined }} />
{[0, 1, 2, 3, 4, 5, 6, 7].map((i) => <div key={i} style={{ position: 'absolute', left: 190, top: i * 150, width: 560, height: 6, background: 'rgba(90,80,60,.55)' }} />)}
{[0, 1, 2].map((i) => <div key={i} style={{ position: 'absolute', left: 290 + i * 180, top: 0, width: 4, height: 1081, background: 'rgba(90,80,60,.32)' }} />)}
<Shade />
</>
);
};
const Car: React.FC = () => {
const { yaw, push, dy } = camAt(useT());
return (
<div style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1920, overflow: 'hidden', perspective: `${HD}px`, perspectiveOrigin: `540px ${OY}px` }}>
<div style={{ position: 'absolute', left: 540, top: OY, width: 0, height: 0, transformStyle: 'preserve-3d', transform: `translateY(${dy}px) translateZ(${HD + push}px) rotateY(${yaw}deg)` }}>
<Plane w={HW * 2} h={WALL_H} tf={`translate3d(0,${WALL_CY}px,${-HD}px)`}><FrontWall /></Plane>
<Plane w={HW * 2} h={WALL_H} tf={`translate3d(0,${WALL_CY}px,${HD}px) rotateY(180deg)`}><RearWall /></Plane>
<Plane w={HD * 2} h={WALL_H} tf={`translate3d(${HW}px,${WALL_CY}px,0) rotateY(-90deg)`}><RightWall /></Plane>
<Plane w={HD * 2} h={WALL_H} tf={`translate3d(${-HW}px,${WALL_CY}px,0) rotateY(90deg)`}><LeftWall /></Plane>
<Plane w={HW * 2} h={HD * 2} tf={`translate3d(0,${FLOOR_Y}px,0) rotateX(90deg)`}><FloorPlane /></Plane>
<Plane w={HW * 2} h={HD * 2} tf={`translate3d(0,${CEIL_Y}px,0) rotateX(-90deg)`}><CeilingPlane /></Plane>
</div>
</div>
);
};
/* ---------- captions: the announcement board ---------- */
const PER_ROW = 24;
const QUIET: Word[] = [{ w: QUIET_CAPTION[0], t0: E('quiet') + 0.7, t1: E('stop'), scene: 'quiet' }, { w: QUIET_CAPTION[1], t0: E('stop'), t1: E('stop') + 0.7, scene: 'quiet' }];
const quietRow = () => { const a = QUIET_CAPTION[0], b = QUIET_CAPTION[1]; return { s: `${a} ${b}`, map: [...Array.from(a, () => 0), -1, ...Array.from(b, () => 1)] }; };
const CHUNKS = [...buildChunks(PER_ROW, 2), { t0: QUIET[0].t0, rows: [quietRow()], words: QUIET }].sort((a, b) => a.t0 - b.t0);
const Board: React.FC = () => {
const t = useT();
const idx = CHUNKS.reduce((a, c, i) => (t >= c.t0 - 0.08 ? i : a), 0);
const c = CHUNKS[idx];
let s = STOPS[0];
for (const x of STOPS) if (t >= x[0]) s = x;
const where = t >= E('stop') ? `FLOOR ${REAR_STOP}` : s[1] === '--' ? 'NO FLOOR' : `FLOOR ${s[1]}`;
const state = (w: Word) => (t >= E('cta') && w.w.replace(/[^a-z]/gi, '').toUpperCase() === KEYWORD ? 2 : t >= w.t0 - 0.03 && t < w.t1 + 0.08 ? 2 : t >= w.t1 ? 1 : 0);
return (
<>
<div style={{ position: 'absolute', left: 0, top: 1462, width: 1080, height: 56, overflow: 'hidden' }}>
<div style={{ position: 'absolute', left: 0, top: 16, width: 1080, height: 34, background: 'linear-gradient(#f2f4f6,#8d9299 55%,#50535a)' }} />
{[150, 860].map((x) => <div key={x} style={{ position: 'absolute', left: x, top: 0, width: 70, height: 40, background: 'linear-gradient(#a7acb3,#60646b)', borderRadius: 4 }} />)}
<Shade />
</div>
<div style={{ position: 'absolute', left: 44, top: 1240, width: 922, height: 236, overflow: 'hidden', boxSizing: 'border-box', padding: '16px 30px 0', borderRadius: 14, background: DISP, backgroundImage: 'radial-gradient(circle,rgba(255,174,43,.06) 1.5px,transparent 2px)', backgroundSize: '7px 7px', boxShadow: '0 14px 26px rgba(0,0,0,.5)' }}>
<div style={{ position: 'absolute', inset: 0, borderRadius: 14, boxShadow: `inset 0 0 0 6px ${SHADOW}` }} />
<Shade />
<div style={{ position: 'relative' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', font: `800 27px/1 ${SANS}`, letterSpacing: '.13em', color: '#c9a46a' }}><span>ANNOUNCEMENT</span><span>{where}</span></div>
<div style={{ height: 168, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
{c.rows.map((row, ri) => (
<div key={`${idx}-${ri}`} style={{ font: `68px/80px ${DOT}`, whiteSpace: 'pre', height: 80 }}>
{Array.from(row.s).map((ch, i) => {
const wi = row.map[i];
const st = wi >= 0 ? state(c.words[wi]) : 1;
return <span key={i} style={{ ...pull(ch), color: st === 2 ? AMBER : st === 1 ? '#f6ead2' : '#6e5b3e', textShadow: st === 2 ? '0 0 16px rgba(255,174,43,.8)' : undefined, WebkitTextStroke: '1px currentColor' }}>{ch}</span>;
})}
</div>
))}
</div>
</div>
</div>
</>
);
};
/* ---------- the film ---------- */
export const GoingUp: React.FC = () => (
<Clock>
<Fonts faces={FACES}>
<Stage />
</Fonts>
</Clock>
);
const Stage: React.FC = () => {
const t = useT();
const sun = sunAt(t) * (0.75 + 0.25 * ramp(t, E('better'), E('better') + 1.1));
return (
<AbsoluteFill style={{ background: '#101012', overflow: 'hidden' }}>
<Car />
<AbsoluteFill style={{ background: `radial-gradient(ellipse 640px 620px at 540px 760px,rgba(255,226,160,${0.2 * sun}),rgba(255,226,160,0))`, mixBlendMode: 'screen', pointerEvents: 'none' }} />
<Board />
<AbsoluteFill style={{ background: 'radial-gradient(125% 85% at 50% 42%,transparent 62%,rgba(0,0,0,.4) 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 polite automated elevator announcer, bright, even and a little too pleasant, who reads every line in the same friendly melody and never notices what it's saying. Use a stock voice or the user's own, and never imitate a real product's announcer.
**When to use it:** on every line except the voiceless moment. Keep the tone exactly the same through the low point, because "Please reconsider" is only funny if it's read like "Please mind the doors". Leave a small gap after each floor number, the way these systems stitch their phrases. Treat it as a voice from a small speaker in a metal box: compress it, cut below about 250 Hz and above about 5 kHz, and add a very short metallic room. Then, on "Your floor", take the speaker filter off: same voice, warm and close, as if it finally stepped out of the box. That one change does more than any effect. Say the keyword with commas around it: "Comment, floor, and I'll send you..."
- **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.
Times are the events in `story.json`, so a re-voice moves them with the picture.
| Sound | Length | Prompt idea | When it plays |
|-------|--------|-------------|---------------|
| room tone | 10 s | the inside of a small closed elevator car, a faint ventilation fan, no voices, no music | the bed, all the way through. It never stops, and it's what keeps the low point alive |
| motor hum | 8 s | a smooth low elevator motor running steadily, warm, no beeps | whenever the doors are shut and the car is moving, from the first close until `between` |
| button | 0.3 s | a large plastic elevator button pressed once, a soft click with a small electric tick | `press`, and with no hand near it when the 7 lights |
| ding | 1.2 s | a single soft elevator arrival chime, one warm bell note, short decay, not bright | `f1`, `f2`, `f3`, and when the display lands on 7 |
| doors | 1.2 s | brushed steel elevator doors sliding on a track, a smooth low rumble ending in a soft stop | every door move: `shut`, `o1`, `c1`, `o2`, `c2`, `o3`, `c3`, `o7`, `c7` |
| door bump | 0.5 s | a rubber door edge bumping a forearm, a soft dull thud with a small rattle | just after `holding`, and just after `heldDoor` |
| paper party | 1 s | a soft party popper and paper confetti falling, small, indoors | `excited` |
| chairs | 1 s | a rush of office chairs rolling into place on carpet, soft casters, small bumps | from `more`, once, under all eleven chairs |
| blocks | 1 s | a quick run of small wooden blocks tapped onto paper, dry and close | from `o3`, as the calendar fills |
| booked | 0.4 s | one wooden block dropped firmly onto a desk, a small solid knock | `quick` |
| tape slap | 0.5 s | a sheet of paper slapped onto a wall with tape, quick and flat | `moved` |
| jolt | 1.2 s | a heavy elevator car jerking to a stop, a deep metal thud, chains rattling softly, warm and dark | `low`, the loudest thing in the film |
| motor dies | 1.5 s | an elevator motor winding down and stopping, the pitch falling, ending in one heavy clunk | `between` |
| tube flicker | 1.5 s | a fluorescent tube sputtering, a soft electric buzz cutting in and out, not harsh | `between` to `floors` |
| cable creak | 5 s | a steel elevator cable under load creaking slowly in a deep shaft, low, hollow and distant | from `floors` to `second`. This is the designed sound under the low point |
| phone | 2 s | an old wall telephone ringing inside a metal box, muffled, two slow rings | from `reconsider`, in time with the shaking handset, faint once the camera has turned. It stops dead at `open` |
| turn | 0.6 s | a quick low swish of air, like a heavy coat turning, soft | `second`, and again at `quiet` |
| light behind | 2 s | a warm hollow swell of air, like a heavy door coming unsealed, no whoosh | `door` |
| rear doors | 1.5 s | large doors sliding open onto outside air, a low rumble that opens into a soft breeze | `open` |
| floor spin | 1.5 s | a mechanical floor indicator ticking fast and slowing to a stop, small dry clicks | `open` to `stop`, with one soft clunk on `stop` |
| outside | 10 s | a quiet sunny room with an open window, a few distant birds, a light breeze, warm | fades in from `open` and runs to the end |
| mug | 0.5 s | a ceramic mug pushed a short way across a hard floor, a small scrape and a soft clink | `belongings` |
- **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:** "Polite elevator bossa instrumental: a soft electric organ and vibraphone melody over nylon guitar, brushed drums and upright bass, unbothered and a little too cheerful, then in the last third the same tune moves to warm piano and soft strings and opens slowly into a major lift, ending resolved, no vocals."
**When it plays:** under every floor at a low level, like the music the building chose for you. From `between` it warps and drains over about 1 s, with the pitch sagging as the motor dies, so slow and pitch the track down as it fades. It stays out through "Please reconsider", leaving the room, the cable and the phone. It returns low on "door", on the piano and not the organ, climbs through the turn and is at its fullest on the voiceless moment, on a bar where the track opens up. It settles under "Your floor" so the line sits clear, and stays warm and low under the ending. Don't bring the organ back.
**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
- **The wrong finger:** a single raised finger is easy to get badly wrong. Use the one beside the thumb, tuck the thumb over the other three, and look at a full-size still. Our first draft raised the middle one.
- **One black block for one frame:** on one of our full renders, one frame had a black rectangle over the top of the wall. That's the browser's compositor dropping a tile under load, not the code, and it didn't come back on the next render. `freezedetect` and `blackdetect` don't catch it, so look for any frame that's far from both neighbors while the neighbors agree, and render again if you find one.
- **A flat wall in a turn:** a hand-made pan shows nothing behind the wall. All the turning comes from the real planes in `Car`, so draw on a plane and check the frames mid-turn.
- **A black low point:** the tube goes out, but the emergency red, the display, the phone and the shaft keep the car lit. It's never black.
- **A rain cloud that reads as an explosion:** a round cloud centered on a tower looks like the wrong kind of event. Keep it flat, lumpy, pale and off center, with the rain showing.
- **A mug that looks angry:** brows that drop toward the middle read as anger. Sad brows rise toward the middle.
- **A loud floor:** the mats below the rail are texture. Keep them dim, or they compete with the captions and fall under the platform's buttons anyway.
| 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 going-up skill (paste the skill text and ask it to save it as a skill). Then replace everything in [BRACKETS] below and paste it in. **Tip:** use any AI tools you like for the voice, music and sound effects; the skill works the same with all of them, or with none (every scene is code, and the film works with no voice). If you'd rather not set up separate AI accounts and API keys, HYVIRAL (optional) is one way to get them behind one connection (hyviral.com; in Claude.ai → Customize → Connectors → add `https://api.hyviral.com/mcp`). --- Use the going-up skill to make a vertical video for [PLATFORM: Instagram Reels / TikTok / YouTube Shorts]. - **The climb:** [TOPIC - the ladder this building stands for, e.g. "the career floors nobody warns you about", "a startup's funding rounds", "chasing follower counts"] - **The floors:** [FLOORS - three or four prizes, each with one specific, funny catch, or "you write them"] - **The second door:** [EXIT - what's better than the top, e.g. "a small job with good light", "going solo", or "you write it"] - **In the window:** [WINDOW - "no person: rooms drawn in code", or "my own clips or photos, attached"] - **Length:** [LENGTH - e.g. about 35 seconds] - **Voice:** [VOICE - e.g. "a polite automated elevator announcer", "my own voice", or "no voice: captions and sound only"] - **Music:** [MUSIC - e.g. "the skill's elevator bossa brief", or your own idea] - **Ending:** [CTA - organic: "Comment KEYWORD and I'll send you THE THING", with the keyword and what you'll send; or sponsored: the line and the URL] - **AI tools:** [AI TOOLS - e.g. "whatever you already have set up", "HYVIRAL", or "none: animation only, I'll record the voice myself"] - **Avoid:** [AVOID - e.g. no real employers, no telling anyone to quit, no claims I can't back up] Work in this order: 1. Write the script on the skill's moves, with one line for each floor and what's drawn behind the doors. Start from the second door, then pick the floors that lead to it. 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 skillsRecipe Card
A handwritten recipe tells your story, and forgives the mistake.
Checkmate
A hushed chess broadcast plays your hardest call as a real game.
Departures
A split-flap board where every turn in your story is a status change.