Remotion (React + TypeScript) is the stack for videos that live in a repo: README heroes, landing demos, case-study clips, thread dramatizations. assets/video-source/ in desplega-ai/agent-swarm is the reference implementation; read it before starting a new project.
Golden rules
- Remotion for repo-committed video.
npx remotion studiofor previews,remotion renderfor mp4s. For a one-off design-led cut with no repo, useopen-prompt-showreelinstead. - Ask for audio, don't pick it. Music is a brand call. A person on the team chooses the track (CC0 or CC BY 4.0, for example from https://freesound.org). Never grab something yourself; ask. There is no default bed.
- Attribute in the video folder's README. Artist name, source URL, license. Not in the repo root README; in the video project's own README.
- Design tokens mirror the product. Pull colors and fonts from the product's own
globals.css(Agent Swarm: Space Grotesk + Space Mono, zinc dark palette, amber#f2a93baccent). Do not invent new ones. - Honest read pacing. The first draft always over-packs the opening and under-weights the payoff. Compress setup scenes, expand the one that carries the message. See "Pacing" below.
- One Remotion project, many compositions. Do not spin up a new repo per video. Register additional
<Composition>entries insrc/Root.tsxand add abuild:<slug>script.
Project layout
<project>/
package.json # remotion + @remotion/cli + @remotion/google-fonts + react 18
remotion.config.ts
tsconfig.json # isolated: exclude from the root tsconfig (see CI below)
README.md # render commands + audio attribution
src/
index.ts # registerRoot(Root)
Root.tsx # <Composition> per video
fonts.ts # loadFont() for Space Grotesk + Space Mono at module scope
theme.ts # brand tokens (bg/fg/accent/sans/mono)
compositions/ # one per video: stitches scenes + audio
scenes/<video-slug>/ # per-scene components, one folder per composition
public/
audio/bed.mp3 # music bed, referenced via staticFile("audio/bed.mp3")
out/ # rendered mp4s (gitignored)
Bootstrapping a new video project
Ran in a worker container (Remotion 4.0.532, 23 s install):
mkdir -p assets/video-source && cd assets/video-source
npm init -y
npm install remotion @remotion/cli @remotion/google-fonts react@^18.3.1 react-dom@^18.3.1
npm install -D @types/react typescript
npx remotion studio # scaffolds config + opens preview
npx remotion studio opens an interactive preview and was not run for this playbook.
Rendering in a worker container
The worker image already ships Chromium (/opt/playwright/chromium) and its system libraries, so no apt install is needed. Point Remotion at it instead of letting it download its own browser:
npx remotion render src/index.ts <CompositionId> out/<name>.mp4 \
--browser-executable=/opt/playwright/chromium --color-space=bt709
A 30-frame 1280x720 test composition rendered in 14 s this way.
Color-range gotcha. Without --color-space=bt709, remotion render writes yuvj420p(pc, bt470bg): full range, not the limited-range yuv420p that social uploaders expect. With the flag it writes yuv420p(tv, bt709). Check with ffmpeg -hide_banner -i out.mp4 2>&1 | grep Stream. If a clip is already rendered, re-encode it with the template's x-reencode.sh.
For a bare Debian/Ubuntu container that is not a worker image, Chromium needs system libraries and root. Unverified, not run for this playbook:
sudo apt-get install -y ffmpeg libnspr4 libnss3 libatk1.0-0 libatk-bridge2.0-0 \
libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 \
libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2t64 libatspi2.0-0
Fonts
Load once at module scope in src/fonts.ts and import from src/Root.tsx:
import { loadFont as loadSpaceGrotesk } from "@remotion/google-fonts/SpaceGrotesk";
import { loadFont as loadSpaceMono } from "@remotion/google-fonts/SpaceMono";
loadSpaceGrotesk("normal", { weights: ["400", "500", "600", "700"], subsets: ["latin"] });
loadSpaceMono("normal", { weights: ["400", "700"], subsets: ["latin"] });
Theme tokens (dark palette, amber accent)
export const theme = {
bg: "#09090b",
card: "#18181b",
fg: "#fafafa",
muted: "#a1a1aa",
mutedDim: "#52525b",
border: "rgba(255,255,255,0.10)",
borderStrong: "rgba(255,255,255,0.18)",
accent: "#f2a93b", // amber: brand primary in dark mode
accentDim: "#7a4d12",
accentFg: "#1a1409",
success: "#4ade80",
danger: "#f87171",
sans: "'Space Grotesk', system-ui, -apple-system, sans-serif",
mono: "'Space Mono', ui-monospace, 'SF Mono', Menlo, monospace",
};
Multiple compositions
Register each video as its own <Composition> in src/Root.tsx. durationInFrames is at 30 fps unless you raise the fps:
<Composition id="DailyEvolution" component={DailyEvolution}
durationInFrames={900} fps={30} width={1920} height={1080} />
<Composition id="SlackToPR" component={SlackToPR}
durationInFrames={1350} fps={30} width={1920} height={1080} />
Add a build:<slug> script per composition in package.json:
"build:daily-evolution": "remotion render src/index.ts DailyEvolution out/daily-evolution.mp4",
"build:slack-to-pr": "remotion render src/index.ts SlackToPR out/slack-to-pr.mp4",
"build:all": "npm run build:daily-evolution && npm run build:slack-to-pr"
Audio
import { Audio, staticFile } from "remotion";
// Inside a composition, per composition, so some can ship silent:
<Audio src={staticFile("audio/bed.mp3")} volume={0.4} />
- Put attribution in
<video-project>/README.mdwith artist, source URL and license, for example:Track title by Artist (https://freesound.org/s//), License: CC BY 4.0
- Not every composition needs music. A case-study clip that pairs with a written thread can ship silent on purpose.
- If someone says "I can't hear the audio", first check the actual bytes of the mp3 (
file public/audio/bed.mp3,ls -lh); stub or broken files are the usual cause. Then verify<Audio>is mounted inside the composition that is being rendered, not a sibling. Verify the render has an audio stream withffmpeg -i out.mp4.
Pacing: the "honest read"
First drafts always front-load setup and shortchange the payoff. After the first render, do an honest read:
- Which single scene carries the message? That one gets the most frames.
- Which scenes are setup or framing? Compress them, usually 60-120 frames (2-4 s at 30 fps).
- Does the payoff have room to breathe? 450+ frames (about 15 s) is typical for a scene the viewer needs to absorb.
- Do the beats spread across the available time, or cluster at the start? If they cluster, spread the
delay/springstaggers across the full scene duration.
Example distribution for a 30 s composition (900 frames at 30 fps): scenes 1-4 (setup) 60 / 90 / 120 / 120 frames, scene 5 (payoff) 450 frames, scene 6 (tagline) 60 frames.
CI and repo integration
A Remotion project lives inside the repo but must be isolated from its build tooling:
- Exclude from the root
tsconfig.json. The project has its own deps (React, Remotion) that the parent repo does not resolve. Add"assets/video-source"(or wherever it lives) to the root tsconfigexclude. - Scope the linter. If Biome or ESLint is scoped to
src/, no change is needed. If it scans the whole repo, add an ignore pattern. - Keep its own
tsconfig.jsoninside the video folder. Runnpx tsc --noEmitthere for type coverage. out/andnode_modules/must be gitignored. Commitpublic/audio/bed.mp3and source, not render outputs.- Do not install Remotion's heavy deps at the repo root. They belong only in the video project's
package.json.
README video embed (GitHub renderer gotchas)
For README hero videos, use a raw-URL <video> tag:
<video src="https://github.com/<org>/<repo>/raw/main/assets/<name>.mp4"
autoplay muted loop playsinline></video>
- GitHub's markdown renderer is strict about
<video>attributes. Keep it simple. - Prefer
raw/main/...overblob/main/...; the latter serves the HTML page, not the mp4 bytes. - Target file size: under 3 MB if possible.
- If the embed does not render, fall back to a link preview:
[](video-url).
When to use what
| Need | Approach |
|---|---|
| README hero (the "what is this?") | 30 s, 1080p, one composition, audio bed, low-fi wireframe tokens |
| Case study or thread dramatization | 45 s, multiple short scenes, typically silent |
| Landing demo (product in action) | Remotion scaffolding + real screen recordings composited in |
| Design-led one-off (showreel, eval comparison) | open-prompt-showreel, not this skill |
| Audio-first asset (podcast clip, TTS demo) | A text-to-speech service, not Remotion |
Common mistakes
- Picking music without asking.
- Inventing colors or fonts instead of pulling them from the product.
- Cramming 6 scenes into the first 10 s. That is the drift the honest read fixes.
- Forgetting to exclude the video folder from the root tsconfig: CI fails on React/Remotion types the root repo does not have.
- Shipping a silent video where audio was expected, or the reverse.
- Dropping attribution. CC BY 4.0 requires credit.
- Posting a
remotion renderoutput to X without checking its pixel format.