Departures
A departure board fills the screen and your topic becomes a flight. It boards, it's delayed, it's canceled, then a new flight appears, and the captions flip letter by letter on the same flaps. 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
Topic The word that flips Platform Narrator Ending AI toolsUse the departures skill to make a vertical video for Instagram Reels. - **What it's about:** [TOPIC - e.g. "finally moving abroad after years of saying someday"] - **The word that flips:** [FLIP WORD - the word on the board that flips to its opposite at takeoff, e.g. "SOMEDAY to TODAY", or "you choose"] - **What I want to say:** [MESSAGE - the turn in the story, in a sentence or two, or "you write it"] - **In the window:** [WINDOW - "no person: the view through the terminal glass", or "my own talking clip, attached"] - **Length:** [LENGTH - e.g. about 35 seconds] - **Voice:** [VOICE - e.g. "a calm gate agent on the PA", "my own voice", or "no voice: captions and sound only"] - **Music:** [MUSIC - e.g. "the skill's airport lounge 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 airlines or airports, no claims I can't back up] Work in this order: 1. Write the script on the skill's moves, with one line for each turn and what flips on the board. 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 board 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: departures
description: Make a 30-40 second vertical video where a split-flap departure board tells the story - every turn in the topic is a status change, with flap-letter captions, a window onto the runway, designed airport sound, music fitted to the story and an optional voice like a calm gate agent. Use for announcements, moves and new chapters, launches, before-and-after stories, or when asked for an airport, travel or departure-board video, or when given the departures prompt.
---
# Departures
A split-flap departure board fills the screen, and your topic becomes a flight. The board flips as the story turns: boarding, delayed, gate change, canceled, then a new flight, and the captions flip in letter by letter on the same flaps. A calm voice on the PA says it all with a straight face. Because every change is a sound and a clatter of letters, it's hard to look away from.
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/departures/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/barlow-condensed`. 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/Departures.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: rows flip, the clock jumps, the window goes from dusk to rain to night to dawn. That's why it holds attention: the viewer learns the world in the first second, then watches it change.
- **One clock: the spoken words.** Each line is voiced as its own take with word timings, scenes are laid end to end on those takes, and every change is keyed to a word in `story.json`. Re-voice a line and everything moves with it.
- **A function of time.** Every part of the frame computes what it shows from the current time, with no stored state, `Math.random` or `Date`. Use `hash()` for anything that looks random, so two renders match.
- **Sound off first.** Most people start muted, so the frame and the captions have to tell the whole story on their own. Sound makes it better, never necessary.
- **Safe area.** 1080 x 1920. Platform buttons and captions cover the bottom 440 px, and the right 130 px from about y = 900 down. Keep captions and anything that must be read inside x 60-950, y 250-1480. Above y = 900 the frame can run to the right edge. Below y = 1480, draw only texture. The top 250 px can hold the world's title and small jokes, as long as nothing there has to be read.
- **Pacing rules.** No voice gap over 1.0 s, except one voiceless moment of 2 to 5 s with the music lifted. No still stretch over 1.2 s. Never cut to black. Something moves on the very first frame.
## 3. The world
A departure hall at night, seen as one board.
- **Palette:** board `#181818`, flap `#2a2a2a`, flap ink `#f2efe6`, signal yellow `#ffc20e`, delayed red `#ff5a3c`, on-time green `#3fc380`.
- **Type:** Barlow Condensed (600 and 700) for everything, set in fixed flap cells.
- **Fixed parts, top to bottom:** the title and a flap clock (y 56-190), column labels (Time, Destination, Gate, Remarks), three board rows (y 248-500), the window (y 524-1064), two rows of 15 big flaps for captions (y 1086-1400), a yellow ticker (y 1418-1484), then blank flaps as texture below the safe area.
- **The flap rule:** a cell scrambles for 0.16 to 0.4 s when its letter changes, staggered 0.016 s along the row, then settles. Only changed cells flip. That stagger is the whole look, so don't simplify it. Give a row its previous text as a schedule entry at -1, so letters that stay the same don't scramble. Captions flip faster than the board, so they stay readable.
- **Row sizes:** Time 5 cells, Destination 11, Gate 2, Remarks 9. Write statuses that fit: NEW GATE, not GATE CHANGE.
- **Nothing real on the board:** never a real airline, airport code or livery.
- **The ticker** is a place for jokes: "Please keep your excuses with you at all times".
**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 no-person scene is the view through the terminal glass, and it tracks the story:
| Story moment | In the window |
|---|---|
| Opening | Dusk: a plane parked at the gate, taxi lights, a far skyline, a beacon blinking |
| A small detail | A baggage cart crosses with two suitcases |
| The delay | Rain runs down the glass and the sky goes gray |
| The low point | Almost dark, the plane barely visible, the board dimmed and flickering |
| The new flight | Rain stops, a clear starry night, the plane's windows light up |
| Takeoff | The plane pushes back, rolls left, lifts and leaves |
| Arrival | Dawn over rooftops with a low sun |
Keep the time of day true to the clock on the board. In our first version it was sunset behind a clock reading 23:59, and it looked wrong straight away.
## 5. The story
This is the part that makes the film, so write it before you touch anything else. Read every line aloud.
- **Use the world's own events.** Every turn in the topic has to become something that really happens in this world. If a line could sit in any video, rewrite it.
- **Use the voice's deadpan.** The funniest and most moving versions take the world completely seriously.
- **Short lines.** Mostly 5 to 11 words, one idea each, with an occasional two-word punch.
- **Real claims only.** Use a number or a fact only if the user gave it to you or you can source it. Invented details are fine only when the film is plainly a joke or a metaphor.
- **Build to a low point, then a turn.** The low point is a picture event, never silence: the world goes dim or dead while room tone and a designed sound keep running.
- **No em dashes on screen.** Commas, full stops and line breaks do that job.
- **Ending, organic or sponsored.** Ask the user which one, and for an organic ending, what they'll send people. Organic: "Comment KEYWORD and I'll send you THE THING", said in the world's voice, and no link. The keyword stays on screen in the world's own way until the end, because the caption moves on. Sponsored: a direct line and a URL.
- **Length.** Run the clock from step 6 as soon as the script exists, before you build anything. Each extra line adds about 3 s, and even a one-word line takes about 0.5 s. Time follows the words, so a cut only helps if words go: folding two moves into one shorter line works, and joining them word for word saves nothing. Then re-anchor every event in `story.json`.
**The moves.** Map any topic onto these. Each line says what happens on the board in the same breath. Ours is about finally moving abroad, with the PA treating "someday" as a real flight.
| # | Move | What it does | Our line | On the board |
|---|------|--------------|----------|--------------|
| 1 | Final call | The hook: name the viewer's stalled plan as a flight | "This is the final boarding call for everyone who said someday." | SOMEDAY, gate C1, BOARDING |
| 2 | Checked in | The small real detail | "You have two suitcases and one very good excuse." | 2 SUITCASES, CHECKED |
| 3 | Delay | The first thing that goes wrong | "We regret to announce a delay." | SOMEDAY turns DELAYED, red |
| 4 | Gate change | The tempting easy option | "Gate change. A safe job has opened at gate four." | NEW GATE, and SAFE JOB, G4, OPEN |
| 5 | Canceled | The low point, and it hits everything | "All remaining courage has been canceled." | Every row red, COURAGE CANCELED, the board dims |
| 6 | Reminder | The quiet truth that turns it | "Passengers are reminded that nobody is coming to carry your bags." | Nothing flips: the board stays dim |
| 7 | New flight | The turn | "A new flight has been added." | ONE WAY, gate H1, GO NOW, clock 23:59 |
| 8 | Boarding | Momentum, with a joke | "Now boarding the terrified, followed by everyone else." | BOARDING, 2 SUITCASES LOADED |
| 9 | Last call | Urgency | "Last call. Doors closing." | LAST CALL flashing |
| 10 | Takeoff | The voiceless moment, 3 to 4 s | (music lifts, captions read WHEELS UP) | SOMEDAY flips to TODAY, DEPARTED, green |
| 11 | Arrival | The payoff | "Good morning and welcome. The local time is finally." | Title flips to Arrivals: YOU, ON TIME, 2 SUITCASES, 1 MISSING |
| 12 | Call to action | One action | "Comment gate and I'll send you the skill that made this." | NEXT, COMMENT, GATE in yellow |
**For other topics:** a product launch is a flight that keeps getting delayed by "one more feature", and it leaves when someone decides it's ready. A career change: SAFE JOB, BOARDING for ten years. A relationship story: a connecting flight you keep missing. The trick that makes it land is one word flipping to its opposite at the takeoff, like SOMEDAY to TODAY, huge in the caption flaps as well as on the board. Find yours first. For about 35 s, fold moves 3 and 4 into one short line, and the same for 8 and 9.
## 6. Plan and animatic (before generating anything)
**1. Write `plan.json`.** `text` is what the captions show. Add `say` when the spoken version differs, with the same number of words. Scenes hold the lines. `lead` and `tail` are seconds of room around a scene's words, and a voiceless scene has `sec` instead of `lines`.
```json
{
"lines": [
{ "id": "l01", "text": "First line." },
{ "id": "l02", "text": "Comment TOUR and I'll send it to you.", "say": "Comment, tour, and I'll send it to you." }
],
"scenes": [
{ "id": "s01", "lines": ["l01"], "lead": 0.7 },
{ "id": "s02", "sec": 3.4 },
{ "id": "s03", "lines": ["l02"] }
]
}
```
**2. Run the clock.** Write `scripts/clock.mjs` as below, and run it from the project folder. A line with no take yet gets an estimate, so you can see the whole film before generating anything. A real take goes in `takes/<id>.json` as `{ "dur": seconds, "words": [{ "w": "First", "t0": 0, "t1": 0.3 }] }` and replaces the estimate.
```js
// node scripts/clock.mjs plan.json + takes/<id>.json -> src/spec.json (what the frame renders) and clock.json (where each line plays)
import fs from 'node:fs';
const FPS = 30;
const plan = JSON.parse(fs.readFileSync('plan.json', 'utf8'));
const PAUSE = 0.35, LEAD = 0.05, TAIL = 0.25, TAIL_OUT = 1.2;
// No take yet: an estimate for a quick, clear voice, fitted to 53 real takes. A real take in takes/<id>.json replaces it.
function estimate(text) {
const letters = (s) => (s.match(/[\p{L}\p{N}]/gu) ?? []).length;
const stops = (text.match(/[.?!]+(?=\s)/g) ?? []).length, commas = (text.match(/,/g) ?? []).length;
const dur = 0.29 + 0.059 * letters(text) + 0.53 * stops + 0.07 * commas;
const parts = text.split(/\s+/), total = parts.reduce((n, p) => n + letters(p) + 1, 0);
let at = 0;
const words = parts.map((w) => { const d = (dur * (letters(w) + 1)) / total, x = { w, t0: at, t1: at + d }; at += d; return x; });
return { dur, words };
}
let t = 0.1;
const beats = [], placed = [];
for (const s of plan.scenes) {
const from = Math.round(t * FPS);
let end = t + (s.sec ?? 0);
const words = [];
if (s.lines?.length) {
let c = t + (s.lead ?? LEAD);
s.lines.forEach((id, i) => {
const line = plan.lines.find((l) => l.id === id);
const take = fs.existsSync(`takes/${id}.json`) ? JSON.parse(fs.readFileSync(`takes/${id}.json`, 'utf8')) : estimate(line.say ?? line.text);
// Captions show `text`, even when `say` differs ("Comment, gate"), as long as the word counts match.
const shown = line.text.split(/\s+/);
const same = shown.length === take.words.length;
placed.push({ line: id, t0: c, dur: take.dur, scene: s.id });
take.words.forEach((w, k) => words.push({ w: same ? shown[k] : w.w, f0: Math.round((c + w.t0) * FPS) - from, f1: Math.round((c + w.t1) * FPS) - from }));
c += take.dur + (i < s.lines.length - 1 ? PAUSE : 0);
});
end = c + (s.tail ?? TAIL);
}
const dur = Math.max(1, Math.round(end * FPS) - from);
beats.push({ id: s.id, from, dur, words, p: s.p ?? {} });
t = (from + dur) / FPS;
}
const total = Math.round((t + TAIL_OUT) * FPS);
beats[beats.length - 1].dur = total - beats[beats.length - 1].from;
beats[0].dur += beats[0].from;
beats[0].words.forEach((w) => { w.f0 += beats[0].from; w.f1 += beats[0].from; });
beats[0].from = 0;
for (let i = 1; i < placed.length; i++) {
const gap = placed[i].t0 - (placed[i - 1].t0 + placed[i - 1].dur);
const quiet = beats.some((b) => !b.words.length && b.from / FPS < placed[i].t0 && (b.from + b.dur) / FPS > placed[i - 1].t0);
if (gap > 1.0 && !quiet) console.warn(`voice gap ${gap.toFixed(2)} s before ${placed[i].line}: shorten a lead or tail`);
}
fs.mkdirSync('src', { recursive: true });
fs.writeFileSync('src/spec.json', JSON.stringify({ fps: FPS, durationInFrames: total, beats }, null, 1));
fs.writeFileSync('clock.json', JSON.stringify({ fps: FPS, total: total / FPS, lines: placed }, null, 1));
console.log(`${(total / FPS).toFixed(2)} s, ${beats.length} scenes, ${placed.length} lines`);
```
**3. Write `src/frames/story.json`.** Name every change in the story and pin it to a scene, and to a word in it where you can. The frame reads events by name, and a typo fails the render instead of drifting. Every event is checked when the film loads, even one the frame never reads. Our list keeps some events only so a sound can land on them; you can delete those. A `word` matches the first word in its scene that starts with those letters, so "the" also hits "there", and a repeated word can only be reached with `off` from an earlier one.
**4. Build the frame** with the code in step 7, changing the words and schedules to your story. Keep each schedule in time order.
**5. Render a silent animatic.** Make contact sheets at one frame per second with the safe box drawn, and look at every tile: `ffmpeg -i out/silent.mp4 -vf "select='not(mod(n\,30))',drawbox=x=60:y=250:w=890:h=1230:color=red@0.9:t=6,scale=216:-2,tile=8x3" -fps_mode passthrough sheet_%02d.jpg`. Empty tiles at the end of the last sheet are padding, not black frames. A tile that small hides small errors, so also look at about 6 frames at full size, with the window cropped large (`npx remotion still src/index.ts Film-9x16 out/f.png --frame=N`).
**6. Show the user** the script, the scene sheet (one row per scene: its line, what changes on screen, and its seconds) and the animatic. Wait for their OK.
## 7. Build the frame
These files are the real frame that made our demo, with our demo's story in it. You rendered them in step 1 to prove your setup. Now change the story, using the list below. Leave the machinery alone until the film works, and read the code before you change it. Paper, panels and labels are sized to our demo's words, so check anything longer on the contact sheet.
### What to change for your story
Most of the story lives in one place, the board's schedules, so this is the quickest frame to re-theme.
| To change | Where |
|---|---|
| Every status change | `Board`: `r1`, `r2` and `r3` hold each row's schedule as `[time, text, color]`, and `clock` holds the big clock |
| The flip at takeoff | `Captions`: SOMEDAY, TODAY and WHEELS UP in the `wheels` branch, plus the first row in `Board` |
| The titles | `Board`: Departures and Arrivals |
| The jokes | `TICKER` |
| The view | `Window` and `Plane`, keyed to the story's events |
| The comment keyword | `plan.json`, the `keyword` check in `Captions`, and the last entry of `r3.rem` |
- **Scene ids in the code:** none. It only reads events, so you can renumber scenes freely.
- **Each schedule stays in time order,** and row sizes are fixed: Time 5 cells, Destination 11, Gate 2, Remarks 9.
### The code
`plan.json`, our demo's script:
```json
{
"lines": [
{
"id": "l01",
"text": "This is the final boarding call for everyone who said someday."
},
{
"id": "l02",
"text": "You have two suitcases and one very good excuse."
},
{
"id": "l03",
"text": "We regret to announce a delay."
},
{
"id": "l04",
"text": "Gate change. A safe job has opened at gate four."
},
{
"id": "l05",
"text": "All remaining courage has been canceled."
},
{
"id": "l06",
"text": "Passengers are reminded that nobody is coming to carry your bags."
},
{
"id": "l07",
"text": "A new flight has been added."
},
{
"id": "l08",
"text": "Now boarding the terrified, followed by everyone else."
},
{
"id": "l09",
"text": "Last call. Doors closing."
},
{
"id": "l10",
"text": "Good morning and welcome. The local time is finally."
},
{
"id": "l11",
"text": "Comment gate and I'll send you the skill that made this.",
"say": "Comment, gate, and I'll send you the skill that made this."
}
],
"scenes": [
{
"id": "s01",
"lines": [
"l01"
],
"lead": 0.7
},
{
"id": "s02",
"lines": [
"l02"
]
},
{
"id": "s03",
"lines": [
"l03"
],
"lead": 0.62
},
{
"id": "s04",
"lines": [
"l04"
]
},
{
"id": "s05",
"lines": [
"l05"
],
"lead": 0.62
},
{
"id": "s06",
"lines": [
"l06"
]
},
{
"id": "s07",
"lines": [
"l07"
],
"lead": 0.62
},
{
"id": "s08",
"lines": [
"l08"
]
},
{
"id": "s09",
"lines": [
"l09"
],
"lead": 0.62
},
{
"id": "s10",
"sec": 3.4
},
{
"id": "s11",
"lines": [
"l10"
]
},
{
"id": "s12",
"lines": [
"l11"
]
}
]
}
```
`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 { Departures } from './frames/Departures';
import spec from './spec.json';
export const Root: React.FC = () => (
<Composition id="Film-9x16" component={Departures} 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": "suitcases",
"scene": "s02",
"word": "suitcases"
},
{
"id": "delay",
"scene": "s03",
"word": "delay"
},
{
"id": "gate",
"scene": "s04",
"word": "gate"
},
{
"id": "job",
"scene": "s04",
"word": "job"
},
{
"id": "cancel",
"scene": "s05",
"word": "canceled"
},
{
"id": "reminded",
"scene": "s06"
},
{
"id": "newflight",
"scene": "s07",
"word": "flight"
},
{
"id": "board",
"scene": "s08",
"word": "boarding"
},
{
"id": "lastcall",
"scene": "s09",
"word": "last"
},
{
"id": "doors",
"scene": "s09",
"word": "doors"
},
{
"id": "takeoff",
"scene": "s10"
},
{
"id": "today",
"scene": "s10",
"off": 0.9
},
{
"id": "arrive",
"scene": "s11"
},
{
"id": "finally",
"scene": "s11",
"word": "finally"
},
{
"id": "cta",
"scene": "s12"
}
]
}
```
`src/frames/Departures.tsx`:
```tsx
import React from 'react';
import { AbsoluteFill, interpolateColors } from 'remotion';
import story from './story.json';
import { DURATION, buildChunks, Clock, clamp, easeIn, easeInOut, easeOut, Fonts, hash, makeE, ramp, useT, type StoryJson } from './core';
/* Departures: a split-flap board where every turn in the story is a status change. 1080 x 1920. Time comes from the spoken
words through story.json, so a re-voice moves every flip with its word. */
const E = makeE(story as StoryJson);
const Y = '#ffc20e', R = '#ff5a3c', G = '#3fc380', INK = '#f2efe6';
const ALPH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const COND = '"Barlow Condensed", sans-serif';
const FACES = [
{ family: 'Barlow Condensed', file: 'barlow-condensed-latin-600-normal.woff2', weight: '600' },
{ family: 'Barlow Condensed', file: 'barlow-condensed-latin-700-normal.woff2', weight: '700' },
];
type Sched = [number, string, string?][];
/** One row of flap cells. Each cell scrambles for a moment when its schedule entry changes, then settles. */
const FlapRow: React.FC<{ x: number; y: number; n: number; w: number; h: number; gap: number; fs: number; sched: Sched; seed: number; colorAt?: (i: number) => string | null; dim?: number; quick?: boolean }> = ({ x, y, n, w, h, gap, fs, sched, seed, colorAt, dim = 1, quick = false }) => {
const t = useT();
let cur: Sched[number] | null = null, prev: Sched[number] | null = null;
for (const s of sched) if (t >= s[0]) { prev = cur; cur = s; }
const cells = [];
for (let i = 0; i < n; i++) {
let ch = ' ', col: string | null = null, flip = false;
if (cur) {
const local = t - (cur[0] + i * (quick ? 0.007 : 0.016));
const dur = quick ? 0.07 + hash(i * 7 + seed) * 0.1 : 0.16 + hash(i * 7 + seed) * 0.24;
const same = (prev?.[1][i] ?? ' ') === (cur[1][i] ?? ' ');
if (local < 0) { ch = prev ? (prev[1][i] ?? ' ') : ' '; col = prev?.[2] ?? null; }
else if (local < dur && !same) { flip = true; ch = ALPH[Math.floor(hash(i * 13 + seed + Math.floor(local / 0.045)) * ALPH.length)]; }
else { ch = cur[1][i] ?? ' '; col = colorAt?.(i) ?? cur[2] ?? null; }
}
cells.push(
<div key={i} style={{ position: 'relative', width: w, height: h, marginRight: gap, borderRadius: Math.max(3, w * 0.11), overflow: 'hidden', background: 'linear-gradient(#333,#272727 49%,#111 50%,#2b2b2b 51%,#242424)', boxShadow: '0 2px 3px rgba(0,0,0,.65), inset 0 1px 0 rgba(255,255,255,.05)', textAlign: 'center', font: `700 ${fs}px/${h}px ${COND}`, color: col ?? INK, filter: dim < 1 ? `brightness(${dim})` : undefined }}>
<span style={{ display: 'block', transform: flip ? 'scaleY(.84)' : undefined, filter: flip ? 'brightness(.62)' : undefined }}>{ch}</span>
{flip ? <div style={{ position: 'absolute', left: 0, right: 0, top: 0, height: '50%', background: 'rgba(0,0,0,.38)' }} /> : null}
</div>,
);
}
return <div style={{ position: 'absolute', left: x, top: y, display: 'flex' }}>{cells}</div>;
};
const CHUNKS = buildChunks(15, 2);
const Captions: React.FC = () => {
const t = useT();
const idx = CHUNKS.reduce((a, c, i) => (t >= c.t0 - 0.1 ? i : a), -1);
const c = idx >= 0 ? CHUNKS[idx] : null;
const next = CHUNKS.find((x) => x.t0 > E('takeoff'));
const wheels = t >= E('takeoff') && t < (next ? next.t0 - 0.1 : E('arrive'));
const live = c ? c.words.findIndex((w) => t >= w.t0 - 0.03 && t < w.t1 + 0.1) : -1;
const keyword = (s: string) => s === 'GATE' && t >= E('cta');
const center = (s: string | undefined) => (s ? ' '.repeat(Math.floor((15 - s.length) / 2)) + s : ' ');
const row = (ri: number): Sched => {
if (wheels) return ri === 0 ? [[E('takeoff') - 1, center(c?.rows[0]?.s)], [E('takeoff') + 0.02, ' SOMEDAY'], [E('today'), ' TODAY', G]] : [[E('takeoff'), center(c?.rows[1]?.s)], [E('takeoff') + 0.02, ' WHEELS UP', G]];
if (!c) return [];
const prevChunk = idx > 0 ? CHUNKS[idx - 1] : null;
return [[-1, center(prevChunk?.rows[ri]?.s)], [c.t0 - 0.1 + ri * 0.06, center(c.rows[ri]?.s)]];
};
const paint = (ri: number) => (i: number) => {
if (!c || wheels) return null;
const r = c.rows[ri];
if (!r) return null;
const pad = Math.floor((15 - r.s.length) / 2);
const wi = r.map[i - pad];
if (wi === undefined || wi < 0) return null;
if (keyword(c.words[wi].w.toUpperCase())) return Y;
return wi === live ? Y : null;
};
return (
<>
<FlapRow x={58} y={1086} n={15} w={56} h={152} gap={4} fs={108} sched={row(0)} seed={100 + idx * 7} colorAt={paint(0)} quick={!wheels} />
<FlapRow x={58} y={1248} n={15} w={56} h={152} gap={4} fs={108} sched={row(1)} seed={131 + idx * 7} colorAt={paint(1)} quick={!wheels} />
</>
);
};
const PLANE = 'M10 60 C20 44 50 40 92 40 L300 40 C320 40 336 30 350 6 L364 6 L352 44 C354 50 348 58 330 60 L60 67 C30 67 14 67 10 60 Z';
const Plane: React.FC<{ x: number; y: number; rot: number; lit: number; t: number; scale?: number }> = ({ x, y, rot, lit, t, scale = 1 }) => {
const beacon = Math.floor(t * 1.4) % 2 === 0 ? 1 : 0.15;
return (
<g transform={`translate(${x} ${y}) rotate(${rot} 180 60) scale(${scale})`}>
<path d="M150 58 L238 58 L192 98 L166 98 Z" fill="#0d1018" />
<rect x="168" y="76" width="44" height="15" rx="7" fill="#0a0c12" />
<path d={PLANE} fill="#141824" stroke="#2a3247" strokeWidth="1.2" />
<rect x="23" y="46" width="15" height="7" rx="2.5" fill={`rgba(170,205,255,${0.2 + 0.55 * lit})`} />
{Array.from({ length: 21 }, (_, i) => (
<rect key={i} x={58 + i * 12} y={46} width={6} height={7} rx={1.6} fill={`rgba(255,${200 - (i % 3) * 8},130,${(0.12 + 0.78 * lit) * (hash(i + 5) > 0.15 ? 1 : 0.3)})`} />
))}
<circle cx="357" cy="8" r="3.4" fill="#ff4b3b" opacity={beacon} />
<circle cx="357" cy="8" r="13" fill="#ff4b3b" opacity={beacon * 0.22} />
</g>
);
};
/** The view through the terminal glass, drawn in code. It follows the story: dusk, rain, a dark low point, a lit plane, dawn. */
const Window: React.FC = () => {
const t = useT();
const stormK = ramp(t, E('delay'), E('delay') + 1.4) * (1 - ramp(t, E('newflight'), E('newflight') + 2.4));
const darkK = ramp(t, E('cancel'), E('cancel') + 0.8) * (1 - ramp(t, E('newflight') - 0.2, E('newflight') + 1.2));
const dawnK = ramp(t, E('arrive') - 0.4, E('arrive') + 1.6);
const nightK = ramp(t, E('newflight') - 0.2, E('newflight') + 1.6);
const dusk = (a: string, b: string, c: string) => interpolateColors(darkK, [0, 1], [interpolateColors(stormK, [0, 1], [a, b]), c]);
const top = interpolateColors(dawnK, [0, 1], [interpolateColors(nightK, [0, 1], [dusk('#20295a', '#141a30', '#070a14'), '#070c24']), '#5f9bd6']);
const bot = interpolateColors(dawnK, [0, 1], [interpolateColors(nightK, [0, 1], [dusk('#e08a54', '#3a4258', '#101522'), '#2b3f78']), '#ffd9a8']);
const pad = interpolateColors(dawnK, [0, 1], [interpolateColors(darkK, [0, 1], ['#1a1d27', '#090b10']), '#8b7f76']);
// the plane
const off = ramp(t, E('lastcall') + 0.2, E('takeoff'));
const roll = ramp(t, E('takeoff'), E('takeoff') + 3.1);
const planeX = 520 - easeInOut(off) * 70 - easeIn(roll) * 1250;
const lift = ramp(roll, 0.5, 1);
const planeY = 262 - easeIn(lift) * 330;
const planeRot = easeOut(lift) * 13;
const lit = Math.max(ramp(t, E('newflight'), E('newflight') + 0.9), 0.25 - 0.2 * darkK);
const showPlane = t < E('arrive');
const shake = roll > 0 && roll < 1 ? Math.sin(t * 61) * 1.6 * (1 - lift) : 0;
// baggage cart
const cart = ramp(t, E('suitcases') - 0.5, E('suitcases') + 2.6);
const rain = stormK;
return (
<svg viewBox="0 0 1000 540" width={1000} height={540} style={{ position: 'absolute', left: 0, top: 0, display: 'block' }}>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor={top} /><stop offset="1" stopColor={bot} /></linearGradient>
<linearGradient id="glass" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="#fff" stopOpacity="0.09" /><stop offset="0.45" stopColor="#fff" stopOpacity="0" /><stop offset="0.62" stopColor="#fff" stopOpacity="0.05" /><stop offset="1" stopColor="#fff" stopOpacity="0" /></linearGradient>
<radialGradient id="sun" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stopColor="#fff4cf" stopOpacity="0.95" /><stop offset="0.35" stopColor="#ffd98f" stopOpacity="0.55" /><stop offset="1" stopColor="#ffd98f" stopOpacity="0" /></radialGradient>
</defs>
<rect x="0" y="0" width="1000" height="380" fill="url(#sky)" />
{/* stars on the clear night, once the new flight is on the board */}
<g opacity={nightK * (1 - dawnK)}>
{Array.from({ length: 70 }, (_, i) => <circle key={i} cx={hash(i + 90) * 1000} cy={hash(i + 190) * 300} r={0.7 + hash(i + 290) * 1.3} fill="#dfe8ff" opacity={0.35 + 0.55 * Math.abs(Math.sin(t * (0.6 + hash(i) * 1.4) + i))} />)}
</g>
{/* far skyline at dusk */}
<g opacity={(1 - dawnK) * 0.75} fill="#0d1120">
{Array.from({ length: 26 }, (_, i) => { const w = 26 + hash(i) * 44, h = 12 + hash(i + 40) * 34; return <rect key={i} x={i * 40 - 10} y={380 - h} width={w} height={h} />; })}
</g>
{/* dawn: rooftops and a low sun */}
<g opacity={dawnK}>
<circle cx={700} cy={300 - dawnK * 70} r={190} fill="url(#sun)" />
{Array.from({ length: 16 }, (_, i) => { const x = i * 70 - 20, h = 60 + hash(i + 9) * 90; const c = interpolateColors(hash(i + 3), [0, 1], ['#a8644a', '#d9925f']); return (
<g key={i}><rect x={x} y={420 - h} width={72} height={h + 120} fill={interpolateColors(hash(i + 21), [0, 1], ['#e9dcc6', '#c9b9a4'])} /><polygon points={`${x - 6},${420 - h} ${x + 33},${420 - h - 28} ${x + 72},${420 - h}`} fill={c} />{[0, 1].map((k) => <rect key={k} x={x + 12 + k * 26} y={430 - h} width={13} height={20} fill="#6f6a66" opacity={0.55} />)}</g>
); })}
</g>
{/* tarmac */}
<rect x="0" y="380" width="1000" height="160" fill={pad} opacity={1 - dawnK} />
<g opacity={1 - dawnK}>
{Array.from({ length: 22 }, (_, i) => {
const on = 0.35 + 0.65 * Math.abs(Math.sin(t * 2.2 + i * 0.7));
return <circle key={i} cx={(i * 62 + 12 - t * 6) % 1000} cy={404} r={2.6} fill="#6da7ff" opacity={on * (1 - darkK * 0.75)} />;
})}
{Array.from({ length: 10 }, (_, i) => <rect key={i} x={i * 120 - 40} y={462} width={72} height={5} fill="#e6e0d0" opacity={0.2} />)}
<circle cx={120} cy={392} r={3} fill="#ffb347" opacity={0.25 + 0.75 * (Math.floor(t * 1.1) % 2)} />
</g>
{showPlane ? <Plane x={planeX} y={planeY + shake} rot={planeRot} lit={lit} t={t} /> : null}
{/* landing gear while on the ground */}
{showPlane && lift === 0 ? <g fill="#0a0c12"><rect x={planeX + 70} y={planeY + 66} width={4} height={50} /><rect x={planeX + 190} y={planeY + 88} width={5} height={28} /><circle cx={planeX + 72} cy={planeY + 116} r={7} /><circle cx={planeX + 192} cy={planeY + 116} r={8} /></g> : null}
{/* baggage cart with the two suitcases */}
{cart > 0 && cart < 1 ? (
<g transform={`translate(${-210 + cart * 1400} 430)`}>
<rect x="0" y="40" width="170" height="10" rx="3" fill="#0a0c12" /><circle cx="26" cy="56" r="9" fill="#0a0c12" /><circle cx="146" cy="56" r="9" fill="#0a0c12" />
<rect x="16" y="-8" width="62" height="48" rx="6" fill="#c0392b" /><rect x="84" y="2" width="62" height="38" rx="6" fill="#2f6f8f" /><rect x="38" y="-16" width="18" height="8" rx="2" fill="#1c1c1c" /><rect x="106" y="-6" width="18" height="8" rx="2" fill="#1c1c1c" />
</g>
) : null}
{/* rain on the glass */}
{rain > 0.01 ? (
<g opacity={rain * 0.65} stroke="#bcd0ee" strokeWidth="1.5" strokeLinecap="round">
{Array.from({ length: 70 }, (_, i) => { const x = hash(i) * 1000, sp = 380 + hash(i + 7) * 420, y = (hash(i + 13) * 620 + t * sp) % 620 - 40, l = 16 + hash(i + 3) * 40; return <line key={i} x1={x} y1={y} x2={x - 2} y2={y + l} opacity={0.3 + hash(i + 2) * 0.6} />; })}
</g>
) : null}
{/* the glass and its frame */}
<rect x="0" y="0" width="1000" height="540" fill="url(#glass)" />
<rect x="326" y="0" width="12" height="540" fill="#16181e" /><rect x="664" y="0" width="12" height="540" fill="#16181e" /><rect x="0" y="0" width="1000" height="10" fill="#16181e" />
<rect x="0" y="0" width="1000" height="540" fill="#000" opacity={darkK * 0.28} />
</svg>
);
};
const Board: React.FC = () => {
const t = useT();
const dim = 1 - 0.42 * ramp(t, E('cancel'), E('cancel') + 0.5) * (1 - ramp(t, E('newflight'), E('newflight') + 0.5));
const flash = t >= E('lastcall') && t < E('takeoff') ? (Math.floor((t - E('lastcall')) * 4) % 2 === 0 ? 1 : 0.55) : 1;
const arr = E('arrive');
const r1: Record<string, Sched> = {
time: [[E('on') + 0.15, '21:07'], [arr, '06:40']],
dest: [[E('on') + 0.25, 'SOMEDAY'], [E('today'), 'TODAY'], [arr, 'YOU']],
gate: [[E('on') + 0.4, 'C1'], [E('gate'), 'G4'], [arr, 'A1']],
rem: [[E('on') + 0.5, 'BOARDING', Y], [E('delay'), 'DELAYED', R], [E('gate'), 'NEW GATE', Y], [E('cancel'), 'CANCELED', R], [E('today'), 'DEPARTED', G], [arr, 'ON TIME', G]],
};
const r2: Record<string, Sched> = {
time: [[E('suitcases'), '21:15'], [arr + 0.12, '06:52']],
dest: [[E('suitcases') + 0.08, '2 SUITCASES'], [arr + 0.12, '2 SUITCASES']],
gate: [[E('suitcases') + 0.16, 'C1'], [E('gate') + 0.1, 'G4'], [E('board'), 'H1'], [arr + 0.12, 'B4']],
rem: [[E('suitcases') + 0.22, 'CHECKED', G], [E('cancel') + 0.14, 'HELD', R], [E('board') + 0.1, 'LOADED', G], [arr + 0.12, '1 MISSING', Y]],
};
const r3: Record<string, Sched> = {
time: [[E('job'), '21:22'], [E('cancel') + 0.26, '21:30'], [E('newflight'), '23:59'], [arr + 0.24, '07:00'], [E('cta'), 'NEXT']],
dest: [[E('job') + 0.08, 'SAFE JOB'], [E('cancel') + 0.3, 'COURAGE'], [E('newflight') + 0.08, 'ONE WAY'], [arr + 0.24, 'LISBON'], [E('cta') + 0.08, 'COMMENT']],
gate: [[E('job') + 0.16, 'G4'], [E('cancel') + 0.34, '--'], [E('newflight') + 0.16, 'H1'], [arr + 0.24, 'H1'], [E('cta') + 0.16, '--']],
rem: [[E('job') + 0.2, 'OPEN', G], [E('cancel') + 0.38, 'CANCELED', R], [E('newflight') + 0.2, 'GO NOW', Y], [E('board'), 'BOARDING', Y], [E('lastcall'), 'LAST CALL', Y], [E('today') + 0.12, 'DEPARTED', G], [arr + 0.24, 'WELCOME', G], [E('cta') + 0.2, 'GATE', Y]],
};
const clock: Sched = [[0, '21:07', Y], [E('job'), '21:22', Y], [E('cancel'), '21:30', Y], [E('newflight'), '23:59', Y], [arr, '06:40', Y]];
return (
<>
<div style={{ position: 'absolute', left: 48, top: 56, display: 'flex', alignItems: 'center', gap: 22, font: `700 104px/1 ${COND}`, color: INK, letterSpacing: '.01em' }}>
<svg viewBox="0 0 36 36" width={92} height={92} fill={Y} style={{ transform: `rotate(${t >= arr ? 58 : 0}deg)` }}><path d="M2 17l20-6 8-9c2-2 5-1 4 2l-6 8 6 18-4 2-8-14-10 4v6l-3 1-2-8-6-2z" /></svg>
<span style={{ filter: `brightness(${dim})` }}>{t >= arr ? 'Arrivals' : 'Departures'}</span>
</div>
<FlapRow x={790} y={64} n={5} w={50} h={90} gap={4} fs={70} sched={clock} seed={1} dim={dim} />
<div style={{ position: 'absolute', left: 48, top: 204, width: 1000, height: 34, font: `600 30px/1 ${COND}`, letterSpacing: '.06em', color: Y, filter: `brightness(${dim})` }}>
{[['Time', 0], ['Destination', 194], ['Gate', 590], ['Remarks', 680]].map(([l, x]) => <div key={l as string} style={{ position: 'absolute', left: x as number, top: 0 }}>{l}</div>)}
</div>
{[r1, r2, r3].map((r, ri) => {
const y = 248 + ri * 88;
const rowDim = ri === 2 ? dim * flash : dim;
return (
<React.Fragment key={ri}>
<FlapRow x={48} y={y} n={5} w={32} h={78} gap={3} fs={54} sched={r.time} seed={ri * 40} dim={rowDim} />
<FlapRow x={242} y={y} n={11} w={32} h={78} gap={3} fs={54} sched={r.dest} seed={ri * 40 + 9} dim={rowDim} />
<FlapRow x={638} y={y} n={2} w={32} h={78} gap={3} fs={54} sched={r.gate} seed={ri * 40 + 18} dim={rowDim} />
<FlapRow x={728} y={y} n={9} w={32} h={78} gap={3} fs={54} sched={r.rem} seed={ri * 40 + 27} dim={rowDim} />
</React.Fragment>
);
})}
</>
);
};
const TICKER = 'Please keep your excuses with you at all times ◆ Unattended dreams will be removed ◆ Next departure: today ◆ ';
export const Departures: React.FC = () => (
<Clock>
<Fonts faces={FACES}>
<Stage />
</Fonts>
</Clock>
);
const Stage: React.FC = () => {
const t = useT();
const push = 1 + 0.018 * (t / DURATION);
const gateLabel = t >= E('arrive') ? 'Arrivals A1' : t >= E('newflight') ? 'Gate H1' : t >= E('gate') ? 'Gate G4' : 'Gate C1';
const flicker = ramp(t, E('cancel'), E('cancel') + 0.2) * (1 - ramp(t, E('newflight'), E('newflight') + 0.2));
const fl = flicker > 0 && hash(Math.floor(t * 9)) > 0.86 ? 0.88 : 1;
return (
<AbsoluteFill style={{ background: '#181818', fontFamily: COND }}>
<AbsoluteFill style={{ transform: `scale(${push})`, transformOrigin: '50% 40%', filter: fl < 1 ? `brightness(${fl})` : undefined }}>
<Board />
<div style={{ position: 'absolute', left: 40, top: 524, width: 1000, height: 540, borderRadius: 12, overflow: 'hidden', background: '#000', boxShadow: '0 0 0 10px #262626' }}>
<Window />
<div style={{ position: 'absolute', left: 0, top: 0, background: Y, color: '#1a1a1a', font: `700 44px/1 ${COND}`, padding: '10px 22px 8px', borderRadius: '0 0 12px 0' }}>{gateLabel}</div>
</div>
<Captions />
<div style={{ position: 'absolute', left: 0, top: 1418, width: 1080, height: 66, background: Y, color: '#1a1a1a', overflow: 'hidden', font: `600 40px/66px ${COND}`, whiteSpace: 'pre' }}>
<div style={{ position: 'absolute', left: 0, top: 0, transform: `translateX(${-t * 105}px)` }}>{TICKER.repeat(5)}</div>
</div>
{/* below the safe area: the rest of the board, blank flaps */}
{[0, 1, 2, 3].map((r) => <BlankRow key={r} y={1500 + r * 104} seed={r} />)}
</AbsoluteFill>
<AbsoluteFill style={{ background: 'radial-gradient(120% 80% at 50% 45%, transparent 60%, rgba(0,0,0,.42) 100%)', pointerEvents: 'none' }} />
</AbsoluteFill>
);
};
const BlankRow: React.FC<{ y: number; seed: number }> = ({ y, seed }) => (
<div style={{ position: 'absolute', left: 40, top: y, display: 'flex', opacity: 0.55 }}>
{Array.from({ length: 16 }, (_, i) => <div key={i} style={{ width: 58, height: 92, marginRight: 4, borderRadius: 6, background: 'linear-gradient(#2a2a2a,#222 49%,#0e0e0e 50%,#242424 51%,#1e1e1e)', opacity: clamp(0.6 + hash(i + seed * 17) * 0.4) }} />)}
</div>
);
```
## 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 calm gate agent on the PA, warm, slightly bored and quietly wry, who never notices that what they're saying is funny.
**When to use it:** on every line except the takeoff. Give it a touch of PA color: high-pass at about 170 Hz, low-pass at about 6.5 kHz and a very short slapback, light enough that every word stays clear. If the voice peaks hit your limiter, compress before the color.
- **Never clone or imitate a real person.** Audition 3 or 4 voices on two lines of the script and let the user pick, or use the user's own voice.
- **One take per line,** with word timestamps. Pass the neighboring lines as context if your provider allows it, so the delivery flows. Trim each take to its speech, and tighten any pause inside it longer than 0.5 s.
- **Listen for your call to action.** Keywords blur easily. Commas around one in the spoken text ("Comment, gate, and...") usually fix it. Check the final mix with Whisper, line by line.
- **With no voice.** Keep the estimated timings as the clock. If there's music, put the captions on its grid. Raise the effects and the music: they carry the film now. Make sure the words stay on screen long enough to read, about 0.3 s a word. The estimate runs close to that, so add `tail` after a line that feels rushed.
## 9. Sound
The picture tells the story without sound, so a film can ship silent. Sound is what makes each change land, though, so add it unless the user says not to. This section says what to use and when it plays. Add it after the user approves the animatic.
**Effects: make a set for this world.** Generate them with the sound-effects tool the user chose, or pick from a licensed library. Prompt the thing itself, and keep it warm, never bright or harsh.
| Sound | Length | Prompt idea | When it plays |
|-------|--------|-------------|---------------|
| flap run | 2 s | a split-flap departure board flipping many letters at once, dense soft plastic clatter settling at the end, dry and warm | every big board change |
| flap burst | 0.8 s | a short burst of split-flap letters flipping, soft plastic clicks | single status changes, and very quietly with captions |
| PA chime | 2 s | a soft two-tone airport chime, warm gentle bell, high then low, slight hall reverb | 0.6 s before an announcement |
| suitcase | 3 s | a hard suitcase with small wheels rolling on a smooth terminal floor | the checked-in detail |
| terminal air | 12 s | quiet airport terminal room tone at night, soft air handling, very faint murmur | the bed, until arrival |
| rain | 10 s | steady rain on a large glass window from inside a quiet building | from the delay until the new flight |
| takeoff | 9 s | a jet accelerating and taking off, heard through thick glass, low rumble rising then fading, muffled | takeoff, 0.5 s early |
| gate chime | 1.5 s | a soft three-note gate-closing chime | last call |
| morning | 8 s | early morning in an old city, distant birds, a faint far-off bell | arrival |
- **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:** "Late-night airport lounge instrumental: warm electric piano, soft upright bass, brushed drums, slow and unhurried, gentle melancholy that opens up, light strings join in the last third with a hopeful lift, ending resolved, no vocals."
**When it plays:** the bed plays from the start, drains to nothing over 0.7 s on the low point, stays out through the reminder so only room tone and rain remain, and comes back on the word "flight" of the new flight, on a bar where the track lifts. It rises again for the voiceless takeoff.
**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
- **Remarks clipped:** "GATE CHANGE" doesn't fit 9 cells. Count the letters before writing a status.
- **Captions flickering:** cut caption chunks at phrase breaks and hold each one until the next starts.
- **A flat board:** the low point only works if the board visibly dims and flickers while the air keeps running.
| 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 departures 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 departures skill to make a vertical video for [PLATFORM: Instagram Reels / TikTok / YouTube Shorts]. - **What it's about:** [TOPIC - e.g. "finally moving abroad after years of saying someday"] - **The word that flips:** [FLIP WORD - the word on the board that flips to its opposite at takeoff, e.g. "SOMEDAY to TODAY", or "you choose"] - **What I want to say:** [MESSAGE - the turn in the story, in a sentence or two, or "you write it"] - **In the window:** [WINDOW - "no person: the view through the terminal glass", or "my own talking clip, attached"] - **Length:** [LENGTH - e.g. about 35 seconds] - **Voice:** [VOICE - e.g. "a calm gate agent on the PA", "my own voice", or "no voice: captions and sound only"] - **Music:** [MUSIC - e.g. "the skill's airport lounge 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 airlines or airports, no claims I can't back up] Work in this order: 1. Write the script on the skill's moves, with one line for each turn and what flips on the board. 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 skillsCover Story
Your subject is the star of a magazine cover, issue after issue.
Mission Control
A launch console treats your small project like a space launch.
The Case File
A corkboard solves your small mystery, one pin at a time.