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"]}