Skip to content

Remotion app

The compositions, scene timing, subtitles and project config.

package.json JSON · 26 lines
{
"name": "fireworks-explainers",
"version": "1.0.0",
"private": true,
"description": "Narrated explainer videos for LLM inference, training and infrastructure concepts (Remotion + Kokoro TTS).",
"scripts": {
"dev": "remotion studio",
"tts": "python3 scripts/tts.py",
"tts:node": "node scripts/tts.mjs",
"render": "bash scripts/render-all.sh",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@remotion/cli": "4.0.527",
"@remotion/media": "4.0.527",
"react": "19.2.3",
"react-dom": "19.2.3",
"remotion": "4.0.527"
},
"devDependencies": {
"@types/react": "19.2.7",
"@types/web": "0.0.166",
"kokoro-js": "1.2.1",
"typescript": "5.9.3"
}
}
remotion.config.ts TypeScript · 6 lines
import { Config } from '@remotion/cli/config';
Config.setVideoImageFormat('jpeg');
Config.setOverwriteOutput(true);
// Software GL is the safe default on a headless Linux box (incl. DGX Spark arm64).
Config.setChromiumOpenGlRenderer('swangle');
src/Explainer.tsx TSX · 97 lines
import React from 'react';
import { AbsoluteFill, Sequence, Series, staticFile, useCurrentFrame, useVideoConfig } from 'remotion';
// <Audio> comes from @remotion/media (the 'remotion' export is the legacy HTML5 tag).
import { Audio } from '@remotion/media';
import { VISUALS, VisualProps } from './components/Visuals';
import { framesFor, SceneSpec, SceneTiming, VideoSpec, VIDEOS } from './scenes';
import { cuesFor } from './lib/subtitles';
import { accentOf, theme } from './theme';
export type ExplainerProps = {
videoId: string;
timings: SceneTiming[] | null;
};
/** Fixed-height band at the bottom. The visual stage never draws into it. */
const SUBTITLE_BAND = 200;
const Subtitles: React.FC<{ text: string; durationInFrames: number; accent: string }> = ({ text, durationInFrames, accent }) => {
const { fps } = useVideoConfig();
const cues = React.useMemo(() => cuesFor(text, durationInFrames, fps), [text, durationInFrames, fps]);
return (
<div style={{
height: SUBTITLE_BAND, flexShrink: 0, borderTop: `2px solid ${theme.rule}`,
background: theme.bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '0 90px', position: 'relative',
}}>
{cues.map((c, i) => (
<Sequence key={i} from={c.from} durationInFrames={c.dur} layout="none">
<div style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '0 90px', textAlign: 'center',
}}>
<span style={{
fontFamily: theme.body, fontSize: 44, lineHeight: 1.3, color: theme.ink,
textWrap: 'balance', maxWidth: 1600,
}}>{c.text}</span>
</div>
</Sequence>
))}
<div style={{ position: 'absolute', left: 90, bottom: 14, width: 34, height: 4, background: accent, borderRadius: 2, opacity: 0.6 }} />
</div>
);
};
const SceneView: React.FC<{
scene: SceneSpec; index: number; total: number; durationInFrames: number;
video: VideoSpec; audio: string | null;
}> = ({ scene, index, total, durationInFrames, video, audio }) => {
const frame = useCurrentFrame();
const p = Math.min(1, Math.max(0, frame / Math.max(1, durationInFrames - 1)));
const accent = accentOf(video.accent);
const Visual = VISUALS[scene.visual] ?? VISUALS.Bullets;
const props: VisualProps = { p, params: scene.params ?? {}, accent };
return (
<AbsoluteFill style={{ backgroundColor: theme.bg, display: 'flex', flexDirection: 'column' }}>
{audio ? <Audio src={staticFile(audio)} /> : null}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 24, padding: '48px 72px 24px', minHeight: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<div style={{ fontFamily: theme.mono, fontSize: 24, letterSpacing: 2, textTransform: 'uppercase', color: accent }}>{video.title}</div>
<div style={{ fontFamily: theme.mono, fontSize: 24, color: theme.muted }}>
{String(index + 1).padStart(2, '0')} / {String(total).padStart(2, '0')}
</div>
</div>
<Visual {...props} />
</div>
<Subtitles text={scene.text} durationInFrames={durationInFrames} accent={accent} />
<div style={{ height: 6, background: theme.panel2 }}>
<div style={{ height: 6, width: `${((index + p) / total) * 100}%`, background: accent }} />
</div>
</AbsoluteFill>
);
};
export const Explainer: React.FC<ExplainerProps> = ({ videoId, timings }) => {
const video = VIDEOS[videoId];
const frames = framesFor(video, timings);
return (
<AbsoluteFill style={{ backgroundColor: theme.bg }}>
<Series>
{video.scenes.map((scene, i) => (
<Series.Sequence key={i} durationInFrames={frames[i]} name={`${i + 1}. ${scene.visual}`}>
<SceneView
scene={scene} index={i} total={video.scenes.length}
durationInFrames={frames[i]} video={video}
audio={timings?.[i]?.audio ?? null}
/>
</Series.Sequence>
))}
</Series>
</AbsoluteFill>
);
};
src/Root.tsx TSX · 50 lines
import React from 'react';
import { Composition, staticFile } from 'remotion';
import { Explainer, ExplainerProps } from './Explainer';
import { framesFor, SceneTiming, VIDEOS, VIDEO_IDS } from './scenes';
import { FPS, HEIGHT, WIDTH } from './theme';
/**
* Narration timings are written by scripts/tts.py (or scripts/tts.mjs) into
* public/scenes/<videoId>.json. If they are missing, every composition still
* works in the Studio using an estimate from the word count.
*/
const loadTimings = async (videoId: string): Promise<SceneTiming[] | null> => {
try {
const res = await fetch(staticFile(`scenes/${videoId}.json`));
if (!res.ok) return null;
const json = await res.json();
if (!json || !Array.isArray(json.scenes)) return null;
return json.scenes.map((s: any) => ({
durationSeconds: Number(s.durationSeconds),
audio: typeof s.audio === 'string' ? s.audio : null,
}));
} catch {
return null;
}
};
export const RemotionRoot: React.FC = () => (
<>
{VIDEO_IDS.map((id) => (
<Composition
key={id}
id={id}
component={Explainer}
fps={FPS}
width={WIDTH}
height={HEIGHT}
durationInFrames={FPS * 60}
defaultProps={{ videoId: id, timings: null } as ExplainerProps}
calculateMetadata={async ({ props }) => {
const timings = await loadTimings(id);
const frames = framesFor(VIDEOS[id], timings);
return {
durationInFrames: frames.reduce((a, b) => a + b, 0),
props: { ...props, timings },
};
}}
/>
))}
</>
);
src/index.ts TypeScript · 4 lines
import { registerRoot } from 'remotion';
import { RemotionRoot } from './Root';
registerRoot(RemotionRoot);
src/lib/subtitles.ts TypeScript · 54 lines
/** Split narration into subtitle chunks and time them across the scene's audio. */
export type Cue = { text: string; from: number; dur: number };
const MAX_CHARS = 92;
export const chunkText = (text: string): string[] => {
const sentences = text.replace(/\s+/g, ' ').trim().match(/[^.!?]+[.!?]*/g) ?? [text];
const out: string[] = [];
for (const raw of sentences) {
const s = raw.trim();
if (!s) continue;
if (s.length <= MAX_CHARS) { out.push(s); continue; }
const parts = s.split(/(?<=[,;:])\s+/);
let buf = '';
for (const part of parts) {
if ((buf + ' ' + part).trim().length <= MAX_CHARS) {
buf = (buf + ' ' + part).trim();
} else {
if (buf) out.push(buf);
if (part.length <= MAX_CHARS) { buf = part; continue; }
const words = part.split(' ');
buf = '';
for (const w of words) {
if ((buf + ' ' + w).trim().length <= MAX_CHARS) buf = (buf + ' ' + w).trim();
else { out.push(buf); buf = w; }
}
}
}
if (buf) out.push(buf);
}
return out;
};
/** Distribute durationInFrames across cues by character count, with a floor per cue. */
export const cuesFor = (text: string, durationInFrames: number, fps: number): Cue[] => {
const chunks = chunkText(text);
if (!chunks.length) return [];
const chars = chunks.map((c) => c.length);
const total = chars.reduce((a, b) => a + b, 0);
const minFrames = Math.round(fps * 0.9);
let frames = chars.map((c) => Math.max(minFrames, Math.round((c / total) * durationInFrames)));
const sum = frames.reduce((a, b) => a + b, 0);
if (sum > durationInFrames) {
const k = durationInFrames / sum;
frames = frames.map((f) => Math.max(Math.round(fps * 0.6), Math.floor(f * k)));
}
let at = 0;
return chunks.map((text, i) => {
const from = at;
at += frames[i];
const last = i === chunks.length - 1;
return { text, from, dur: last ? Math.max(frames[i], durationInFrames - from) : frames[i] };
});
};
src/scenes.ts TypeScript · 32 lines
import narration from './narration.json';
import { estimateSeconds, FPS } from './theme';
export type SceneSpec = {
visual: string;
params: Record<string, unknown>;
text: string;
};
export type VideoSpec = {
title: string;
subtitle: string;
accent: string;
scenes: SceneSpec[];
};
export const VIDEOS = narration as unknown as Record<string, VideoSpec>;
export const VIDEO_IDS = Object.keys(VIDEOS);
export type SceneTiming = {
/** seconds of narration audio for this scene, including its tail padding */
durationSeconds: number;
/** relative path under public/, e.g. "audio/inference-101/01.wav" */
audio: string | null;
};
/** Frame length per scene, from measured narration when available, else an estimate. */
export const framesFor = (video: VideoSpec, timings: SceneTiming[] | null): number[] =>
video.scenes.map((scene, i) => {
const seconds = timings?.[i]?.durationSeconds ?? estimateSeconds(scene.text);
return Math.max(FPS, Math.round(seconds * FPS));
});
src/theme.ts TypeScript · 34 lines
export const FPS = 24;
export const WIDTH = 1920;
export const HEIGHT = 1080;
export const theme = {
bg: '#0C1014',
panel: '#151A20',
panel2: '#1D242C',
rule: '#2A333D',
ink: '#ECEFEA',
muted: '#9BA5B0',
teal: '#3CC4AE',
tealDim: '#123630',
copper: '#EC935B',
copperDim: '#3A2517',
violet: '#9D90FF',
good: '#59CE86',
bad: '#F0705E',
warn: '#E6AD3E',
display: '"Bricolage Grotesque", "Inter", system-ui, sans-serif',
body: '"IBM Plex Sans", system-ui, sans-serif',
mono: '"IBM Plex Mono", ui-monospace, monospace',
};
export const accentOf = (name: string) => (name === 'copper' ? theme.copper : theme.teal);
/** Words per second used to estimate scene length before narration exists. */
export const WORDS_PER_SECOND = 2.6;
export const TAIL_PAD_SECONDS = 0.45;
export const estimateSeconds = (text: string) => {
const words = text.trim().split(/\s+/).filter(Boolean).length;
return Math.max(2.5, words / WORDS_PER_SECOND + TAIL_PAD_SECONDS);
};
tsconfig.json JSON · 17 lines
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": false,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src", "remotion.config.ts"]
}