import { AbsoluteFill, Audio, Img, OffthreadVideo, Sequence, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig, } from "remotion"; import { loadFont } from "@remotion/google-fonts/SpaceGrotesk"; // Resolve asset path — handle URLs, absolute paths (Windows/Unix), and public/ relative paths function resolveAsset(src: string): string { if (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:")) { return src; } // Strip any file:// prefix const clean = src.replace(/^file:\/\/\/?/, ""); // Absolute paths (Unix: /foo, Windows: C:\foo or C:/foo) — convert to file:// URI // staticFile() only accepts relative paths within public/, so absolute paths must bypass it if (clean.startsWith("/") || /^[A-Za-z]:[\\/]/.test(clean)) { return `file:///${clean.replace(/\\/g, "/")}`; } return staticFile(clean); } import { TextCard } from "./components/TextCard"; import { StatCard } from "./components/StatCard"; import { CalloutBox } from "./components/CalloutBox"; import { ComparisonCard } from "./components/ComparisonCard"; import { BarChart } from "./components/charts/BarChart"; import { LineChart } from "./components/charts/LineChart"; import { PieChart } from "./components/charts/PieChart"; import { KPIGrid } from "./components/charts/KPIGrid"; import { ProgressBar } from "./components/ProgressBar"; import { CaptionOverlay, WordCaption } from "./components/CaptionOverlay"; import { SectionTitle } from "./components/SectionTitle"; import { StatReveal } from "./components/StatReveal"; import { HeroTitle } from "./components/HeroTitle"; import { AnimeScene } from "./components/AnimeScene"; import type { CameraMotion } from "./components/AnimeScene"; import { TerminalScene } from "./components/TerminalScene"; import type { TerminalStep } from "./components/TerminalScene"; import { ProviderChip } from "./components/ProviderChip"; import type { ParticleType } from "./components/ParticleOverlay"; import { resolveTheme, type ThemeConfig, DEFAULT_THEME } from "./Root"; // Load Space Grotesk font for cinematic typography const { fontFamily } = loadFont("normal", { weights: ["400", "700"], subsets: ["latin"], }); // --------------------------------------------------------------------------- // Animated Background — Gradient Mesh + Floating Orbs // --------------------------------------------------------------------------- // Parse hex color to RGB components function hexToRgb(hex: string): { r: number; g: number; b: number } { const clean = hex.replace("#", ""); const bigint = parseInt(clean.length === 3 ? clean.split("").map(c => c + c).join("") : clean, 16); return { r: (bigint >> 16) & 255, g: (bigint >> 8) & 255, b: bigint & 255 }; } // Detect if a color is "light" (for choosing grid/overlay treatment) function isLightColor(hex: string): boolean { const { r, g, b } = hexToRgb(hex); return (r * 299 + g * 587 + b * 114) / 1000 > 128; } // Darken/lighten a color by mixing toward black or white function shiftColor(hex: string, amount: number): string { const { r, g, b } = hexToRgb(hex); const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v))); if (amount < 0) { // Darken const f = 1 + amount; return `rgb(${clamp(r * f)}, ${clamp(g * f)}, ${clamp(b * f)})`; } // Lighten return `rgb(${clamp(r + (255 - r) * amount)}, ${clamp(g + (255 - g) * amount)}, ${clamp(b + (255 - b) * amount)})`; } const AnimatedBackground: React.FC<{ theme: ThemeConfig }> = ({ theme }) => { const frame = useCurrentFrame(); const { fps, durationInFrames } = useVideoConfig(); const bg = theme.backgroundColor; const primary = theme.primaryColor; const accent = theme.accentColor; const surface = theme.surfaceColor; const light = isLightColor(bg); // Slow-moving gradient angles const angle1 = 135 + Math.sin(frame / (fps * 8)) * 30; // Build gradient from theme colors instead of hardcoded dark blue const { r: bgR, g: bgG, b: bgB } = hexToRgb(bg); const { r: priR, g: priG, b: priB } = hexToRgb(primary); const { r: accR, g: accG, b: accB } = hexToRgb(accent); const gradient = ` radial-gradient(ellipse at ${30 + Math.sin(frame / (fps * 10)) * 20}% ${40 + Math.cos(frame / (fps * 8)) * 20}%, rgba(${priR}, ${priG}, ${priB}, 0.15) 0%, transparent 60%), radial-gradient(ellipse at ${70 + Math.cos(frame / (fps * 7)) * 20}% ${60 + Math.sin(frame / (fps * 9)) * 25}%, rgba(${accR}, ${accG}, ${accB}, 0.1) 0%, transparent 55%), linear-gradient(${angle1}deg, ${bg} 0%, ${shiftColor(bg, light ? -0.05 : 0.05)} 40%, ${surface} 70%, ${bg} 100%) `; // Floating orbs — derived from theme chart colors with low opacity const orbColors = theme.chartColors.slice(0, 5); const orbOpacity = light ? 0.06 : 0.08; const orbs = [ { x: 20, y: 30, size: 300, color: orbColors[0] || primary, speedX: 7, speedY: 11 }, { x: 70, y: 60, size: 250, color: orbColors[1] || accent, speedX: 9, speedY: 8 }, { x: 40, y: 80, size: 200, color: orbColors[2] || primary, speedX: 13, speedY: 6 }, { x: 80, y: 20, size: 350, color: orbColors[3] || accent, speedX: 11, speedY: 14 }, { x: 10, y: 70, size: 180, color: orbColors[4] || primary, speedX: 8, speedY: 10 }, ]; // Grid and overlay colors adapt to light vs dark backgrounds const gridColor = light ? "rgba(0,0,0,0.03)" : "rgba(255,255,255,0.02)"; const fadeColor = light ? `rgba(${bgR},${bgG},${bgB},0.2)` : `rgba(${bgR},${bgG},${bgB},0.4)`; return ( {/* Floating glow orbs */} {orbs.map((orb, i) => { const ox = orb.x + Math.sin(frame / (fps * orb.speedX)) * 15; const oy = orb.y + Math.cos(frame / (fps * orb.speedY)) * 12; const { r, g, b } = hexToRgb(orb.color); return (
); })} {/* Subtle grid overlay */}
{/* Top gradient fade for depth */}
); }; // --------------------------------------------------------------------------- // Types — aligned with edit_decisions artifact schema // --------------------------------------------------------------------------- interface Cut { id: string; source: string; in_seconds: number; out_seconds: number; layer?: string; type?: string; // Component-specific props text?: string; stat?: string; subtitle?: string; callout_type?: "info" | "warning" | "tip" | "quote"; title?: string; // Video source trim — seek to this point in the source before playback. // Defaults to 0 (play from beginning). Use this instead of in_seconds for source trimming. source_in_seconds?: number; // Comparison props leftLabel?: string; rightLabel?: string; leftValue?: string; rightValue?: string; // Chart props chartData?: any[]; chartSeries?: any[]; chartColors?: string[]; chartAnimation?: string; donut?: boolean; centerLabel?: string; centerValue?: string; showGrid?: boolean; showValues?: boolean; showLegend?: boolean; showMarkers?: boolean; xLabel?: string; yLabel?: string; columns?: 2 | 3 | 4; // Progress bar props progress?: number; progressLabel?: string; progressColor?: string; progressAnimation?: string; progressSegments?: any[]; // Hero title props (when used as scene, not overlay) heroSubtitle?: string; // Styling overrides backgroundColor?: string; backgroundImage?: string; // AI-generated or stock image rendered behind the component backgroundVideo?: string; // Video clip rendered behind the component (takes priority over backgroundImage) backgroundVideoStart?: number; // Seek position in seconds for background video (default 0) backgroundOverlay?: number; // Opacity of dark overlay on backgroundImage/backgroundVideo (0-1, default 0.55) color?: string; accentColor?: string; fontSize?: number; // Animation & transitions animation?: string; transition_in?: string; transition_out?: string; transform?: { animation?: string; scale?: number; position?: string | { x: number; y: number }; }; // Anime scene props (type: "anime_scene") images?: string[]; particles?: ParticleType; particleColor?: string; particleCount?: number; particleIntensity?: number; vignette?: boolean; lightingFrom?: string; lightingTo?: string; // Terminal scene props (type: "terminal_scene") steps?: TerminalStep[]; terminalTitle?: string; prompt?: string; } interface Overlay { type: "section_title" | "stat_reveal" | "hero_title" | "provider_chip"; in_seconds: number; out_seconds: number; text?: string; subtitle?: string; accentColor?: string; position?: string; // provider_chip providers?: string[]; cycleSeconds?: number; label?: string; } interface AudioLayer { src: string; volume?: number; } interface AudioConfig { narration?: AudioLayer; music?: AudioLayer & { fadeInSeconds?: number; fadeOutSeconds?: number; /** Start playback from this offset in seconds (skip quiet intros). * Use the audio_energy tool to find the optimal offset. */ offsetSeconds?: number; /** Loop the music if it's shorter than the video duration. */ loop?: boolean; }; } export interface ExplainerProps { [key: string]: unknown; cuts: Cut[]; overlays?: Overlay[]; captions?: WordCaption[]; audio?: AudioConfig; } // --------------------------------------------------------------------------- // Image Extensions // --------------------------------------------------------------------------- const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif", ".webp"]; const VIDEO_EXTENSIONS = [".mp4", ".mov", ".webm", ".avi", ".mkv"]; function isImage(source: string): boolean { const lower = source.toLowerCase(); return IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext)); } function isVideo(source: string): boolean { const lower = source.toLowerCase(); return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext)); } // --------------------------------------------------------------------------- // Cinematic vignette overlay // --------------------------------------------------------------------------- const Vignette: React.FC = () => ( ); // --------------------------------------------------------------------------- // Enhanced Image Scene — spring physics, parallax, variety // --------------------------------------------------------------------------- const ImageScene: React.FC<{ src: string; animation?: string }> = ({ src, animation, }) => { const frame = useCurrentFrame(); const { fps, durationInFrames } = useVideoConfig(); // Smooth spring fade-in const fadeIn = spring({ frame, fps, config: { damping: 18, stiffness: 80 } }); // Fade-out for crossfade effect const fadeOutStart = durationInFrames - 8; const fadeOut = interpolate(frame, [fadeOutStart, durationInFrames], [1, 0.3], { extrapolateLeft: "clamp", extrapolateRight: "clamp", }); let scale = 1; let translateX = 0; let translateY = 0; const anim = animation || "zoom-in"; // Progress with easing — smoother than linear const progress = interpolate(frame, [0, durationInFrames], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", }); if (anim === "zoom-in") { scale = 1 + progress * 0.18; } else if (anim === "zoom-out") { scale = 1.18 - progress * 0.18; } else if (anim === "pan-left") { translateX = interpolate(progress, [0, 1], [40, -40]); scale = 1.15; } else if (anim === "pan-right") { translateX = interpolate(progress, [0, 1], [-40, 40]); scale = 1.15; } else if (anim === "ken-burns" || anim === "ken-burns-slow-zoom") { // Cinematic Ken Burns: gentle zoom + diagonal drift scale = 1 + progress * 0.22; translateX = interpolate(progress, [0, 1], [0, -25]); translateY = interpolate(progress, [0, 1], [0, -15]); } else if (anim === "parallax") { // Subtle parallax — foreground moves faster translateY = interpolate(progress, [0, 1], [15, -15]); scale = 1.1; } // "static" or "none" → just display return ( ); }; // --------------------------------------------------------------------------- // Enhanced Video Scene // --------------------------------------------------------------------------- const VideoScene: React.FC<{ src: string; startFrom?: number }> = ({ src, startFrom = 0, }) => { const frame = useCurrentFrame(); const { fps, durationInFrames } = useVideoConfig(); const fadeIn = spring({ frame, fps, config: { damping: 20 } }); const fadeOutStart = durationInFrames - 8; const fadeOut = interpolate(frame, [fadeOutStart, durationInFrames], [1, 0.3], { extrapolateLeft: "clamp", extrapolateRight: "clamp", }); return ( ); }; // --------------------------------------------------------------------------- // Scene renderer — maps cut type / source to the right component // --------------------------------------------------------------------------- // Background image layer — renders an AI-generated/stock image behind data components const BackgroundImageLayer: React.FC<{ src: string; overlayOpacity?: number; children: React.ReactNode; }> = ({ src, overlayOpacity = 0.55, children }) => { const frame = useCurrentFrame(); const { fps, durationInFrames } = useVideoConfig(); // Subtle ken-burns on the background const progress = interpolate(frame, [0, durationInFrames], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", }); const bgScale = 1 + progress * 0.08; return ( {/* Background image with subtle zoom */} {/* Dark overlay for readability */} {/* Component content on top */} {children} ); }; // Background video layer — plays a looping video behind component content with dark overlay const BackgroundVideoLayer: React.FC<{ src: string; startFrom?: number; overlayOpacity?: number; children: React.ReactNode; }> = ({ src, startFrom = 0, overlayOpacity = 0.55, children }) => { const { fps } = useVideoConfig(); return ( {/* Background video */} {/* Dark overlay for readability */} {/* Component content on top */} {children} ); }; const SceneRenderer: React.FC<{ cut: Cut; theme: ThemeConfig }> = ({ cut, theme }) => { // Wrap component with background video or image if specified const maybeWrapWithBg = (element: React.ReactElement) => { if (cut.backgroundVideo) { return ( {element} ); } if (cut.backgroundImage) { return ( {element} ); } return element; }; // Resolve the scene element based on cut type, then wrap with backgroundImage if set // Use transparent bg so the animated gradient background shows through // When no explicit backgroundColor on the cut, inherit from theme const rawBg = (cut.backgroundImage || cut.backgroundVideo) ? "transparent" : (cut.backgroundColor || theme.surfaceColor); const bgColor = (rawBg === theme.backgroundColor || rawBg === "#0F172A" || rawBg === "#0f172a") ? "transparent" : rawBg; const textColor = cut.color || theme.textColor; const accent = cut.accentColor || theme.accentColor; // Explicit component types — use theme-derived defaults for colors if (cut.type === "text_card" && cut.text) { return maybeWrapWithBg( ); } if (cut.type === "stat_card" && cut.stat) { return maybeWrapWithBg( ); } if (cut.type === "callout" && cut.text) { return maybeWrapWithBg( ); } if (cut.type === "comparison" && cut.leftLabel && cut.rightLabel && cut.leftValue && cut.rightValue) { return maybeWrapWithBg( ); } if (cut.type === "hero_title" && cut.text) { return maybeWrapWithBg( ); } if (cut.type === "terminal_scene" && cut.steps) { return maybeWrapWithBg( ); } // --- Chart types — use theme.chartColors as default palette --- if (cut.type === "bar_chart" && cut.chartData) { return maybeWrapWithBg( ); } if (cut.type === "line_chart" && cut.chartSeries) { return maybeWrapWithBg( ); } if (cut.type === "pie_chart" && cut.chartData) { return maybeWrapWithBg( ); } if (cut.type === "kpi_grid" && cut.chartData) { return maybeWrapWithBg( ); } if (cut.type === "progress_bar" && cut.progress !== undefined) { return maybeWrapWithBg( {cut.title && (
{cut.title}
)}
); } // --- Anime scene (multi-image crossfade + particles) --- if (cut.type === "anime_scene" && cut.images && cut.images.length > 0) { return ( ); } // --- Media types (image / video fallback) --- const animation = cut.animation || cut.transform?.animation; if (cut.source && isImage(cut.source)) { return maybeWrapWithBg(); } if (cut.source && isVideo(cut.source)) { return maybeWrapWithBg(); } // Final fallback — try as image if source exists, otherwise show text_card if (cut.source) { return maybeWrapWithBg(); } // No source, no type — render as text card with cut id as fallback return ; }; // --------------------------------------------------------------------------- // Overlay renderer // --------------------------------------------------------------------------- const OverlayRenderer: React.FC<{ overlay: Overlay }> = ({ overlay }) => { if (overlay.type === "section_title") { return ( ); } if (overlay.type === "stat_reveal") { return ( ); } if (overlay.type === "hero_title") { return ; } if (overlay.type === "provider_chip" && overlay.providers) { return ( ); } return null; }; // --------------------------------------------------------------------------- // Main composition // --------------------------------------------------------------------------- export const Explainer: React.FC = (props) => { const { cuts, overlays, captions, audio } = props; const { fps, durationInFrames } = useVideoConfig(); // Resolve theme from props — playbook name, theme name, or custom themeConfig const theme = resolveTheme(props as Record); return ( {/* Layer 0: Animated gradient background — driven by theme */} {/* Layer 1: Visual scenes */} {cuts.map((cut) => { const from = Math.round(cut.in_seconds * fps); const duration = Math.round((cut.out_seconds - cut.in_seconds) * fps); return ( ); })} {/* Layer 2: Overlays (section titles, stat reveals, hero titles) */} {overlays?.map((overlay, i) => { const from = Math.round(overlay.in_seconds * fps); const duration = Math.round( (overlay.out_seconds - overlay.in_seconds) * fps ); return ( ); })} {/* Layer 3: Captions (word-by-word highlight) */} {captions && captions.length > 0 && ( )} {/* Layer 4: Audio — narration */} {audio?.narration?.src && ( ); };