For AI agents: HYVIRAL has a remote MCP server at https://api.hyviral.com/mcp (Streamable HTTP, OAuth 2.1). In a chat app such as Claude or ChatGPT, the person you are helping adds it as a connector; an assistant cannot add one from inside the conversation. In Claude: Customize → Connectors → add a custom connector, then paste the endpoint. A coding agent such as Claude Code can add it itself: claude mcp add --transport http hyviral https://api.hyviral.com/mcp. Every client, step by step: https://hyviral.com/docs/mcp/connect. The whole reference as plain text: https://hyviral.com/llms.txt and https://hyviral.com/llms-full.txt. Pricing as JSON: https://hyviral.com/pricing.json.

Cover Story

The whole screen is a glossy magazine, and whatever you're talking about is the cover star, even if it's a coat. The flash pops, the date rolls back, coverlines argue, and a page turns to a pull quote. You bring the photos or your agent makes them, and the rest is code.

  • 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.

  1. 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
  2. Fill in the prompt

    The cover star Pictures Its history Platform Narrator Ending AI tools
    Use the cover-story skill to make a vertical video for Instagram Reels.
    
    - **The cover star:** [SUBJECT - e.g. "my 20-year-old coat", "our shop's oven", "me"]
    - **The history:** [HISTORY - the eras, milestones or low point to cover, or "you write it"]
    - **The message:** [MESSAGE - what it would say in an interview, or "you write it"]
    - **Pictures:** [PICTURES - "my photos, attached", or "generate them with the AI tools below"]
    - **Magazine name:** [MASTHEAD - one short word, not a real magazine, or "you choose"]
    - **Length:** [LENGTH - e.g. about 35 seconds]
    - **Voice:** [VOICE - e.g. "a dry fashion editor", "my own voice", or "no voice: captions and sound only"]
    - **Music:** [MUSIC - e.g. "the skill's runway house 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: I have photos and I'll record the voice myself"]
    - **Avoid:** [AVOID - e.g. no real magazine names, 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 happens on the cover.
    2. Build the plan, the clock and the frame, render a silent animatic, and show me the script, the scene sheet and the animatic. Wait for my OK.
    3. Generate the pictures, 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
  3. Paste it and approve the storyboard

    Your agent writes the script, builds the cover 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 connect

The skill, as plain text

SKILL.md · 1023 lines · 51 KB
Copy Raw file
---
name: cover-story
description: Make a 30-40 second vertical video where the whole screen is a glossy magazine cover and the story runs issue after issue - the masthead drops in, the flash goes off, the date rolls back through the years, coverlines argue, and a page turns to a pull quote. It comes with designed studio sound, music fitted to the story and an optional voice like a dry fashion editor. Use for a product or object with a history, personal milestones, fashion, beauty and lifestyle, 'the truth about' stories, or when asked for a magazine cover video, or when given the cover-story prompt.
---

# Cover Story

The whole screen is a magazine cover, and your subject is the cover star, even if your subject is a coat. The subject barely moves. Everything around it does: the flash pops, the date rolls back through the years, the masthead changes color, coverlines pile up and argue, and a page turns to a pull quote. A dry editor's voice treats all of it as the most important story of the season, and that gap is the joke and the hook.

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/cover-story/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/bodoni-moda` and `@fontsource/anton`. 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/CoverStory.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 flash goes off, the date rolls, a new cover replaces the last one, a page turns. 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 fashion magazine called whatever you like, as long as it isn't a real one. Ours is STILL, because the coat is.

- **Palette:** masthead red `#e3001b`, highlighter `#ffe600`, ink `#141414`, white. The masthead changes color with each cover.
- **Type:** Bodoni Moda 900 for the masthead and big numbers, Bodoni Moda 600 italic for teasers, Anton for coverlines and captions.
- **Fixed parts, top to bottom:** the masthead (y 128-390), the issue line with a rule and a rolling date (y 402), coverlines down the left (y 482-1100), captions (y 1236-1476, two lines of Anton at 104 px), then the issue number and a barcode as texture.
- **A dark fade from y = 1100 down** keeps the captions readable on any cover.
- **Coverline idioms, one for each turn:** a huge headline, an italic teaser with one highlighted phrase, a list on solid strips, a giant number, a red EXCLUSIVE tab, a round sticker.
- **Legibility is the trap here.** Choose dark or light type per cover, and put a solid strip behind anything that crosses the subject.
- **Never a real magazine's name, logo or masthead shape.**

**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.

In this frame the window is the whole cover, so this is the one place the film needs real pictures. Without a person, make the subject an object, a product or a place.

- **Use a real photo if you have one.** The user's product shots or portraits work best.
- **Before you have pictures,** the frame still needs its six files to render, for the setup test in step 1 and for the animatic. Fill `public/img/` with placeholders: `for n in hero e2009 e2014 e2019 bag back; do ffmpeg -y -f lavfi -i color=c=slategray:s=1080x1935 -frames:v 1 -update 1 public/img/$n.jpg; done`. A missing image stops the render.
- **Or generate 5 or 6 stills** (9:16, 2K) of the same subject. Make one hero image with lots of empty space above the subject, then make each of the others by editing it: "the exact same coat on the same hanger, same framing, only the backdrop and light change". References copy content as well as style, so finish every prompt with "The frame contains only..." and "No text, no logos".
- **What the set needs:** a neutral first cover, one cover per era with its own backdrop color and light, one sad cover for the low point, and one triumphant cover for the turn.
- **Hide the subject at first.** Blurred and dim behind flash pops until the line that names it, then sharp on a full white flash.
- **Keep it moving.** Every cover pushes in slowly (3 % a second), and a white flash that falls away in 0.2 s covers each change of picture.

## 5. The story

This is the part that makes the film, so write it before you touch anything else. Read every line aloud.

- **Use the world's own events.** Every turn in the topic has to become something that really happens in this world. If a line could sit in any video, rewrite it.
- **Use the voice's deadpan.** The funniest and most moving versions take the world completely seriously.
- **Short lines.** Mostly 5 to 11 words, one idea each, with an occasional two-word punch.
- **Real claims only.** Use a number or a fact only if the user gave it to you or you can source it. Invented details are fine only when the film is plainly a joke or a metaphor.
- **Build to a low point, then a turn.** The low point is a picture event, never silence: the world goes dim or dead while room tone and a designed sound keep running.
- **No em dashes on screen.** Commas, full stops and line breaks do that job.
- **Ending, organic or sponsored.** Ask the user which one, and for an organic ending, what they'll send people. Organic: "Comment KEYWORD and I'll send you THE THING", said in the world's voice, and no link. The keyword stays on screen in the world's own way until the end, because the caption moves on. Sponsored: a direct line and a URL.
- **Length.** Run the clock from step 6 as soon as the script exists, before you build anything. Each extra line adds about 3 s, and even a one-word line takes about 0.5 s. Time follows the words, so a cut only helps if words go: folding two moves into one shorter line works, and joining them word for word saves nothing. Then re-anchor every event in `story.json`.

**The moves.** Map any topic onto these. The film is a profile written as if the subject were a star. Ours is about a 20-year-old coat.

| # | Move | What it does | Our line | On the cover |
|---|------|--------------|----------|--------------|
| 1 | The tease | The hook: describe a star, without saying what it is | "Our cover star this month is twenty years old and has never been ironed." | Masthead drops letter by letter; blurred subject; flash pops on three words |
| 2 | The reveal | The joke lands | "It's a coat." | Full flash, subject sharp, IT'S A COAT. |
| 3 | The profile | One funny, true detail | "Bought on a whim. Worn to every funeral." | Italic teaser on a strip, "every funeral" highlighted |
| 4-6 | The eras | Three quick covers, each a new date | "It survived skinny jeans." "It survived the poncho." "It survived the year everyone wore a blanket." | Flash, new backdrop, date rolls back: 2009, 2014, 2019. A SURVIVED list grows |
| 7 | The fall | The low point | "Last spring, it went in a donation bag." | Gray, sad cover, the masthead almost gone. Music out; a far-off press and studio air keep running |
| 8 | The number | How low | "It was in there for eleven days." | Giant red number counts up to 11 |
| 9 | The comeback | The turn | "Exclusive. It came back." | Full flash, triumphant red cover, EXCLUSIVE tab, music returns on the word "came" |
| 10 | The interview | The real message | "Its advice? You don't need more clothes. You need fewer opinions." | The cover turns away to an inside page; the quote sets itself word by word |
| 11 | Next issue | The voiceless moment, 2 to 3 s | (music lifts) | A new cover slides in, a later date, STILL HERE. |
| 12 | The button | The last laugh | "Next month's cover star: the same coat." | "Same coat. Again." |
| 13 | Call to action | One action | "Comment cover and I'll send you the skill that made this." | A round yellow sticker slaps on: comment COVER |

**For other topics:** a person's milestone, with the real years on the covers and what they survived. A small business: "Our cover star has never taken a day off", and it's the oven. A product: the eras are the trends it outlasted. The trick that makes it land is the first line: describe the subject like a celebrity, then reveal something small and ordinary. Find that mismatch first. For about 35 s, cut one era and fold moves 7 and 8 into one short line.

## 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

Your pictures do most of the work, so the code changes are mostly words.

| To change | Where |
|---|---|
| The covers | `SHOTS`: one row per cover, with its time, image file, masthead color and date. Images go in `public/img/` |
| The flash pops | `pops` in `flashAt` |
| The magazine | `Masthead`: STILL and THE LONGEVITY ISSUE; `Foot`: ISSUE 47 |
| The headlines | `Coverlines`: IT'S A COAT., the `items` list under SURVIVED, DAYS IN A BAG, EXCLUSIVE, IT CAME BACK, STILL HERE., Same coat. Again. |
| The interview | `QUOTE` and `Spread` |
| The comment keyword | `plan.json`, `Sticker`, and the `COVER` check in `Captions` |

- **Scene ids in the code:** none. It only reads events, so you can renumber scenes freely.
- **Choose each cover's ink when you change its picture.** The `SHOTS` row sets the masthead color; check each coverline against its cover on the contact sheet.

### The code

`plan.json`, our demo's script:

```json
{
 "lines": [
  {
   "id": "l01",
   "text": "Our cover star this month is twenty years old and has never been ironed."
  },
  {
   "id": "l02",
   "text": "It's a coat."
  },
  {
   "id": "l03",
   "text": "Bought on a whim. Worn to every funeral."
  },
  {
   "id": "l04",
   "text": "It survived skinny jeans."
  },
  {
   "id": "l05",
   "text": "It survived the poncho."
  },
  {
   "id": "l06",
   "text": "It survived the year everyone wore a blanket."
  },
  {
   "id": "l07",
   "text": "Last spring, it went in a donation bag."
  },
  {
   "id": "l08",
   "text": "It was in there for eleven days."
  },
  {
   "id": "l09",
   "text": "Exclusive. It came back."
  },
  {
   "id": "l10",
   "text": "Its advice? You don't need more clothes. You need fewer opinions."
  },
  {
   "id": "l11",
   "text": "Next month's cover star: the same coat."
  },
  {
   "id": "l12",
   "text": "Comment cover and I'll send you the skill that made this.",
   "say": "Comment, cover, and I'll send you the skill that made this."
  }
 ],
 "scenes": [
  {
   "id": "s01",
   "lines": [
    "l01"
   ],
   "lead": 0.4
  },
  {
   "id": "s02",
   "lines": [
    "l02"
   ]
  },
  {
   "id": "s03",
   "lines": [
    "l03"
   ]
  },
  {
   "id": "s04",
   "lines": [
    "l04"
   ],
   "lead": 0.2
  },
  {
   "id": "s05",
   "lines": [
    "l05"
   ],
   "lead": 0.2
  },
  {
   "id": "s06",
   "lines": [
    "l06"
   ],
   "lead": 0.2
  },
  {
   "id": "s07",
   "lines": [
    "l07"
   ],
   "lead": 0.3
  },
  {
   "id": "s08",
   "lines": [
    "l08"
   ]
  },
  {
   "id": "s09",
   "lines": [
    "l09"
   ],
   "lead": 0.3
  },
  {
   "id": "s10",
   "lines": [
    "l10"
   ],
   "lead": 0.35
  },
  {
   "id": "s11",
   "sec": 2.6
  },
  {
   "id": "s12",
   "lines": [
    "l11"
   ]
  },
  {
   "id": "s13",
   "lines": [
    "l12"
   ]
  }
 ]
}
```

`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 { CoverStory } from './frames/CoverStory';
import spec from './spec.json';

export const Root: React.FC = () => (
  <Composition id="Film-9x16" component={CoverStory} 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": "pop1",
   "scene": "s01",
   "word": "cover"
  },
  {
   "id": "pop2",
   "scene": "s01",
   "word": "twenty"
  },
  {
   "id": "pop3",
   "scene": "s01",
   "word": "ironed"
  },
  {
   "id": "coat",
   "scene": "s02",
   "word": "coat"
  },
  {
   "id": "whim",
   "scene": "s03",
   "word": "bought"
  },
  {
   "id": "funeral",
   "scene": "s03",
   "word": "funeral"
  },
  {
   "id": "y2009",
   "scene": "s04",
   "word": "skinny"
  },
  {
   "id": "y2014",
   "scene": "s05",
   "word": "poncho"
  },
  {
   "id": "y2019",
   "scene": "s06",
   "word": "year"
  },
  {
   "id": "blanket",
   "scene": "s06",
   "word": "blanket"
  },
  {
   "id": "bag",
   "scene": "s07",
   "word": "donation"
  },
  {
   "id": "eleven",
   "scene": "s08",
   "word": "eleven"
  },
  {
   "id": "exclusive",
   "scene": "s09",
   "word": "exclusive"
  },
  {
   "id": "back",
   "scene": "s09",
   "word": "came"
  },
  {
   "id": "spread",
   "scene": "s10"
  },
  {
   "id": "quoteEnd",
   "scene": "s10",
   "word": "opinions"
  },
  {
   "id": "next",
   "scene": "s11"
  },
  {
   "id": "samecoat",
   "scene": "s12",
   "word": "same"
  },
  {
   "id": "cta",
   "scene": "s13"
  }
 ]
}
```

`src/frames/CoverStory.tsx`:

```tsx
import React from 'react';
import { AbsoluteFill, Img, staticFile } from 'remotion';
import story from './story.json';
import { back as backEase, buildChunks, Clock, easeInOut, easeOut, Fonts, hash, makeE, ramp, useT, type StoryJson } from './core';

/* Cover Story: the whole screen is a magazine cover, and the story is issue after issue. 1080 x 1920. The subject never moves: the
   date rolls, the masthead changes color, the coverlines argue, and the flash goes off. */
const E = makeE(story as StoryJson);
const RED = '#e3001b', YEL = '#ffe600';
const DISPLAY = '"Bodoni Moda", serif', LOUD = 'Anton, sans-serif';
const FACES = [
  { family: 'Bodoni Moda', file: 'bodoni-moda-latin-900-normal.woff2', weight: '900' },
  { family: 'Bodoni Moda', file: 'bodoni-moda-latin-600-italic.woff2', weight: '600', style: 'italic' },
  { family: 'Anton', file: 'anton-latin-400-normal.woff2' },
];

type Shot = { at: () => number; img: string; mast: string; date: string };
const SHOTS: Shot[] = [
  { at: () => 0, img: 'hero', mast: RED, date: 'NOVEMBER 2026' },
  { at: () => E('y2009'), img: 'e2009', mast: '#ffffff', date: 'MARCH 2009' },
  { at: () => E('y2014'), img: 'e2014', mast: YEL, date: 'APRIL 2014' },
  { at: () => E('y2019'), img: 'e2019', mast: '#141414', date: 'JANUARY 2019' },
  { at: () => E('bag'), img: 'bag', mast: '#7d7d7d', date: 'LAST SPRING' },
  { at: () => E('back'), img: 'back', mast: '#ffffff', date: 'THIS SPRING' },
  { at: () => E('next'), img: 'hero', mast: RED, date: 'DECEMBER 2026' },
];
const shotAt = (t: number) => SHOTS.reduce((a, s, i) => (t >= s.at() ? i : a), 0);
/** Pale covers take dark type; a coverline that vanishes into its own backdrop is the first thing to check on a contact sheet. */
const darkInk = (s: Shot) => s.img === 'hero' || s.img === 'e2019';

/** A photographer's flash: white that falls away in a fifth of a second. */
const flashAt = (t: number) => {
  const pops = [0, E('pop1'), E('pop2'), E('pop3'), E('coat'), E('y2009'), E('y2014'), E('y2019'), E('back'), E('next')];
  let v = 0;
  for (const p of pops) if (t >= p) v = Math.max(v, (1 - ramp(t, p, p + 0.2)) * (p === E('coat') || p === E('back') ? 1 : 0.85));
  return v;
};

const Photo: React.FC = () => {
  const t = useT();
  const i = shotAt(t);
  const s = SHOTS[i];
  const local = t - s.at();
  const scale = 1.02 + 0.03 * local;
  const hidden = t < E('coat');
  const sad = s.img === 'bag';
  const filter = hidden ? `blur(${26 - 8 * ramp(t, 0, E('coat'))}px) brightness(.62)` : sad ? 'saturate(.55) brightness(.92)' : undefined;
  return (
    <AbsoluteFill>
      <Img src={staticFile(`img/${s.img}.jpg`)} style={{ position: 'absolute', left: 0, top: 0, width: 1080, height: 1935, objectFit: 'cover', transform: `scale(${scale})`, transformOrigin: '50% 62%', filter }} />
      <AbsoluteFill style={{ background: 'linear-gradient(180deg, rgba(20,12,8,.18), rgba(20,12,8,0) 24%, rgba(20,12,8,0) 56%, rgba(20,12,8,.72) 72%, rgba(20,12,8,.9) 100%)' }} />
    </AbsoluteFill>
  );
};

const Masthead: React.FC = () => {
  const t = useT();
  const s = SHOTS[shotAt(t)];
  return (
    <>
      <div style={{ position: 'absolute', left: 0, right: 0, top: 128, textAlign: 'center', font: `900 262px/1 ${DISPLAY}`, color: s.mast, letterSpacing: '-.015em', whiteSpace: 'nowrap' }}>
        {'STILL'.split('').map((c, i) => {
          const k = backEase(ramp(t, 0.04 + i * 0.09, 0.5 + i * 0.09));
          return <span key={i} style={{ display: 'inline-block', opacity: t < 0.04 + i * 0.09 ? 0 : 1, transform: `translateY(${(1 - k) * -110}px)` }}>{c}</span>;
        })}
      </div>
      <div style={{ position: 'absolute', left: 60, right: 60, top: 402, display: 'flex', justifyContent: 'space-between', font: `400 34px/1 ${LOUD}`, letterSpacing: '.12em', color: darkInk(s) ? '#141414' : '#fff', borderTop: `3px solid ${darkInk(s) ? '#141414' : '#fff'}`, paddingTop: 12 }}>
        <span>THE LONGEVITY ISSUE</span>
        <span>{s.date}</span>
      </div>
    </>
  );
};

const inFrom = (t: number, a: number) => {
  const k = easeOut(ramp(t, a, a + 0.4));
  return { opacity: t < a ? 0 : 1, transform: `translateX(${(1 - k) * -80}px)` } as React.CSSProperties;
};
const shadow = '0 3px 18px rgba(0,0,0,.55)';

const Coverlines: React.FC = () => {
  const t = useT();
  const era = t >= E('y2009') && t < E('bag');
  const first = t >= E('coat') && t < E('y2009');
  const low = t >= E('bag') && t < E('back');
  const ret = t >= E('back') && t < E('spread');
  const items: [string, number][] = [['SKINNY JEANS', E('y2009')], ['THE PONCHO', E('y2014')], ['THE BLANKET YEAR', E('blanket')]];
  const days = Math.max(1, Math.min(11, 1 + Math.floor(ramp(t, E('eleven') - 0.9, E('eleven') + 0.25) * 10)));
  const daysK = backEase(ramp(t, E('eleven') - 0.9, E('eleven') - 0.5));
  return (
    <>
      {first ? (
        <div style={{ position: 'absolute', left: 60, top: 482, width: 720, ...inFrom(t, E('coat')) }}>
          <div style={{ font: `400 118px/.98 ${LOUD}`, color: '#141414' }}>IT'S A COAT.</div>
          <div style={{ font: `italic 600 50px/1.1 ${DISPLAY}`, color: RED, marginTop: 8, opacity: t >= E('coat') + 0.3 ? 1 : 0 }}>and it has notes</div>
        </div>
      ) : null}
      {first && t >= E('whim') ? (
        <div style={{ position: 'absolute', left: 60, top: 712, width: 470, padding: '10px 16px 12px', background: '#141414', font: `italic 600 46px/1.14 ${DISPLAY}`, color: '#fff', ...inFrom(t, E('whim')) }}>
          Bought on a whim. Worn to <span style={{ background: t >= E('funeral') ? YEL : 'transparent', color: t >= E('funeral') ? '#000' : '#fff', padding: '0 6px' }}>every funeral</span>
        </div>
      ) : null}
      {era ? (
        <div style={{ position: 'absolute', left: 60, top: 482 }}>
          <div style={{ display: 'inline-block', background: RED, color: '#fff', font: `400 44px/1 ${LOUD}`, letterSpacing: '.12em', padding: '10px 18px 6px' }}>SURVIVED</div>
          {items.map(([label, at]) => (
            <div key={label} style={{ width: 'fit-content', marginTop: 10, background: '#141414', color: '#fff', padding: '8px 16px 4px', font: `400 66px/1 ${LOUD}`, whiteSpace: 'nowrap', ...inFrom(t, at) }}>{label}</div>
          ))}
        </div>
      ) : null}
      {low && t >= E('eleven') - 0.9 ? (
        <div style={{ position: 'absolute', left: 60, top: 470, transform: `scale(${1.25 - 0.25 * daysK})`, transformOrigin: '0 40%' }}>
          <div style={{ font: `900 330px/.86 ${DISPLAY}`, color: RED, fontVariantNumeric: 'tabular-nums' }}>{days}</div>
          <div style={{ font: `400 60px/1 ${LOUD}`, color: '#fff', marginTop: 10, textShadow: shadow }}>DAYS IN A BAG</div>
        </div>
      ) : null}
      {ret ? (
        <div style={{ position: 'absolute', left: 60, top: 482, ...inFrom(t, E('exclusive')) }}>
          <div style={{ display: 'inline-block', background: '#fff', color: RED, font: `400 46px/1 ${LOUD}`, letterSpacing: '.12em', padding: '10px 18px 6px' }}>EXCLUSIVE</div>
          {t >= E('back') ? <div style={{ font: `400 124px/.98 ${LOUD}`, color: '#fff', marginTop: 12, textShadow: shadow }}>IT CAME<br />BACK</div> : null}
        </div>
      ) : null}
      {t >= E('next') ? (
        <div style={{ position: 'absolute', left: 60, top: 482, ...inFrom(t, E('next') + 0.5) }}>
          <div style={{ font: `400 138px/.98 ${LOUD}`, color: '#141414', transform: `scale(${1 + 0.05 * ramp(t, E('next') + 0.5, E('samecoat'))})`, transformOrigin: '0 0' }}>STILL<br />HERE.</div>
        </div>
      ) : null}
      {t >= E('samecoat') ? <div style={{ position: 'absolute', left: 60, top: 770, width: 'fit-content', padding: '8px 16px 10px', background: '#141414', font: `italic 600 50px/1.1 ${DISPLAY}`, color: YEL, ...inFrom(t, E('samecoat')) }}>Same coat. Again.</div> : null}
    </>
  );
};

const Sticker: React.FC = () => {
  const t = useT();
  if (t < E('cta')) return null;
  const k = backEase(ramp(t, E('cta'), E('cta') + 0.45));
  return (
    <div style={{ position: 'absolute', left: 646, top: 760, width: 296, height: 296, borderRadius: '50%', background: YEL, color: '#000', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', transform: `rotate(${10 + Math.sin(t * 3.1) * 5}deg) scale(${k * (1 + Math.sin(t * 4.2) * 0.025)})`, boxShadow: '0 12px 34px rgba(0,0,0,.35)' }}>
      <span style={{ font: `italic 600 42px/1 ${DISPLAY}` }}>comment</span>
      <b style={{ font: `400 86px/1.02 ${LOUD}` }}>COVER</b>
    </div>
  );
};

const QUOTE = 'You don’t need more clothes. You need fewer opinions.'.split(' ');
/** The inside page: the cover turns away and the pull quote is set word by word as it is spoken. */
const Spread: React.FC = () => {
  const t = useT();
  if (t < E('spread') || t >= E('next') + 0.5) return null;
  const turn = easeInOut(ramp(t, E('spread'), E('spread') + 0.55));
  const leave = easeInOut(ramp(t, E('next'), E('next') + 0.5));
  const reached = (i: number) => {
    const lead = ramp(t, E('spread') + 0.35, E('quoteEnd'));
    return lead >= i / QUOTE.length;
  };
  return (
    <AbsoluteFill style={{ background: '#f6f1e6', transform: `translateX(${(1 - turn) * 1080 - leave * 1080}px) scale(${1 + 0.018 * ramp(t, E('spread'), E('next'))})`, boxShadow: '-30px 0 60px rgba(0,0,0,.4)' }}>
      <div style={{ position: 'absolute', left: 60, top: 268, right: 60, display: 'flex', justifyContent: 'space-between', font: `400 34px/1 ${LOUD}`, letterSpacing: '.12em', color: RED, borderBottom: `3px solid ${RED}`, paddingBottom: 12 }}>
        <span>THE INTERVIEW</span><span>PAGE 12</span>
      </div>
      <div style={{ position: 'absolute', left: 60, top: 350, width: 890, font: `italic 600 112px/1.08 ${DISPLAY}`, color: '#16120c' }}>
        <span style={{ color: RED }}>{'“'}</span>
        {QUOTE.map((w, i) => <span key={i} style={{ opacity: reached(i) ? 1 : 0.1 }}>{w}{i < QUOTE.length - 1 ? ' ' : ''}</span>)}
        <span style={{ color: RED, opacity: reached(QUOTE.length - 1) ? 1 : 0 }}>{'”'}</span>
      </div>
      <Img src={staticFile('img/hero.jpg')} style={{ position: 'absolute', left: 640, top: 1040, width: 310, height: 440, objectFit: 'cover', objectPosition: '50% 70%', boxShadow: '0 10px 28px rgba(0,0,0,.28)', transform: `rotate(${2 + 2.5 * ramp(t, E('spread'), E('next'))}deg) scale(${1 + 0.06 * ramp(t, E('spread'), E('next'))})` }} />
      <div style={{ position: 'absolute', left: 60, top: 1260, width: 540, font: `italic 600 42px/1.2 ${DISPLAY}`, color: '#5b5245' }}>The coat, in its own words, after twenty years of being right.</div>
    </AbsoluteFill>
  );
};

const CHUNKS = buildChunks(18, 2);
const Captions: React.FC = () => {
  const t = useT();
  if (t >= E('spread') - 0.05 && t < E('next') + 0.5) return null;
  const idx = CHUNKS.reduce((a, c, i) => (t >= c.t0 - 0.06 ? i : a), -1);
  if (idx < 0) return null;
  const c = CHUNKS[idx];
  if (c.t0 >= E('spread') && c.t0 < E('next')) return null;
  const live = c.words.findIndex((w) => t >= w.t0 - 0.03 && t < w.t1 + 0.08);
  const k = easeOut(ramp(t, c.t0 - 0.06, c.t0 + 0.12));
  let w = 0;
  return (
    <div style={{ position: 'absolute', left: 60, top: 1236, width: 890, height: 240, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', font: `400 104px/1.08 ${LOUD}`, color: '#fff', textTransform: 'uppercase', textShadow: '0 4px 22px rgba(0,0,0,.55)', transform: `translateY(${(1 - k) * 14}px)`, opacity: k }}>
      {c.rows.map((r, ri) => {
        const ws = r.s.split(' ');
        return (
          <div key={ri}>
            {ws.map((word, wi) => {
              const on = w === live;
              const kw = /^COVER/.test(word) && t >= E('cta');
              w += 1;
              return <span key={wi} style={{ color: on || kw ? YEL : '#fff' }}>{word}{wi < ws.length - 1 ? ' ' : ''}</span>;
            })}
          </div>
        );
      })}
    </div>
  );
};

const BARS = (() => { const out: [number, number][] = []; let x = 0; for (let i = 0; x < 160; i++) { const w = 2 + Math.floor(hash(i + 3) * 4) * 2; if (i % 2 === 0) out.push([x, w]); x += w; } return out; })();
const Foot: React.FC = () => (
  <>
    <div style={{ position: 'absolute', left: 60, top: 1800, font: `400 36px/1 ${LOUD}`, letterSpacing: '.12em', color: '#fff', opacity: 0.85 }}>ISSUE 47</div>
    <div style={{ position: 'absolute', left: 830, top: 1740, width: 190, height: 130, background: '#fff', padding: '12px 14px 0' }}>
      <svg viewBox="0 0 162 96" preserveAspectRatio="none" style={{ width: '100%', height: 92, display: 'block' }}>{BARS.map(([x, w], i) => <rect key={i} x={x} y={0} width={w} height={96} fill="#000" />)}</svg>
    </div>
  </>
);

const Stage: React.FC = () => {
  const t = useT();
  const f = flashAt(t);
  return (
    <AbsoluteFill style={{ background: '#000' }}>
      <Photo />
      <Masthead />
      <Coverlines />
      <Sticker />
      <Foot />
      <Captions />
      <Spread />
      <AbsoluteFill style={{ background: '#fff', opacity: f, pointerEvents: 'none' }} />
    </AbsoluteFill>
  );
};

export const CoverStory: React.FC = () => (
  <Clock>
    <Fonts faces={FACES}>
      <Stage />
    </Fonts>
  </Clock>
);
```

## 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 fashion editor, cool, dry and unimpressed, who has seen everything and speaks in short verdicts. Restrained: never perky, never an announcer.

**When to use it:** on every line except the next-issue moment. No effects on the voice beyond a light compressor. British or transatlantic suits it well.

- **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 |
|-------|--------|-------------|---------------|
| shutter | 0.7 s | a professional camera shutter with a studio flash firing, one crisp but soft click and pop | every flash |
| flash recharge | 1.2 s | a studio flash recharging, a faint rising electronic whine, soft and low | after the opening pops |
| page flip | 1 s | a single glossy magazine page turned quickly, soft and close | the inside page, the next issue |
| thump | 1 s | a heavy glossy magazine dropped flat on a wooden table, a deep soft thump | the reveal, the comeback, the opening |
| bag | 2 s | a clear plastic bag pushed down and settling on a concrete floor, soft and dull | the fall |
| press | 10 s | a large printing press running far away in another room, a low hum and soft rhythmic thud, dark and warm | under the low point |
| studio air | 12 s | a quiet photo studio room tone, soft air, very faint distant activity | the bed, all the way through |
| tick | 0.4 s | a soft dull tick of a date stamp, very short | each step of the number counting up |
| sticker | 0.6 s | a thick sticker slapped onto a magazine cover, a soft round pop | the call to action |

Effects carry this one, so check them against the voice: ours measured too quiet at first, at about 22 LU under. Aim for 8 to 10.

- **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:** "Minimal runway fashion show house instrumental: a deep soft kick, a dry clipped bass line, sparse dark chords, very restrained and cool for a long stretch, then a clear lift where a warm synth pad and hi-hats open up, confident, sleek, no vocals."

**When it plays:** the bed plays cool under the eras, drains to nothing over 0.7 s on the fall, stays out through the number, and comes back on the word "came" on a bar where the track opens up. It rises again for the voiceless next issue, then settles under the last lines.

**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

- **Type lost in the picture:** white on a pale backdrop, or dark on the subject. Look at every cover on the contact sheet, and use a solid strip when in doubt.
- **The stills drift:** always edit from the hero image, and hide the small differences under the flash.
- **Too clean:** long holds on a still image read as a slideshow. Keep the push running, and something changing every 2 or 3 seconds.

| 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.
Show less

The prompt

PROMPT.md · 33 lines · 2 KB
Copy Raw file
# The prompt

First give your agent the cover-story 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 images, voice, music and sound effects; the skill works the same with all of them. Your own photos work best for the covers, 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 cover-story skill to make a vertical video for [PLATFORM: Instagram Reels / TikTok / YouTube Shorts].

- **The cover star:** [SUBJECT - e.g. "my 20-year-old coat", "our shop's oven", "me"]
- **The history:** [HISTORY - the eras, milestones or low point to cover, or "you write it"]
- **The message:** [MESSAGE - what it would say in an interview, or "you write it"]
- **Pictures:** [PICTURES - "my photos, attached", or "generate them with the AI tools below"]
- **Magazine name:** [MASTHEAD - one short word, not a real magazine, or "you choose"]
- **Length:** [LENGTH - e.g. about 35 seconds]
- **Voice:** [VOICE - e.g. "a dry fashion editor", "my own voice", or "no voice: captions and sound only"]
- **Music:** [MUSIC - e.g. "the skill's runway house 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: I have photos and I'll record the voice myself"]
- **Avoid:** [AVOID - e.g. no real magazine names, 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 happens on the cover.
2. Build the plan, the clock and the frame, render a silent animatic, and show me the script, the scene sheet and the animatic. Wait for my OK.
3. Generate the pictures, 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.
Show less

Cookies for ads and referrals

We use the Meta Pixel to measure our ads and Tolt to credit partners who refer you. Accept to allow them; decline and the site works the same. Privacy Policy

Decline Accept