import { useEffect, useState } from "react"; import { AbsoluteFill, Audio, Sequence, staticFile, CalculateMetadataFunction, } from "remotion"; import { LAYOUT_REGISTRY, LayoutType, SceneLayoutProps } from "./components/layouts"; import { TransitionWipe } from "./components/Transitions"; import { LogoOverlay } from "./components/LogoOverlay"; // ─── Types ─────────────────────────────────────────────────── interface SceneData { id: number; order: number; title: string; narration: string; layout: LayoutType; layoutProps: Record; durationSeconds: number; voiceoverFile: string | null; images: string[]; } interface VideoData { projectName: string; heroImage?: string | null; accentColor: string; bgColor: string; textColor: string; logo?: string | null; logoPosition?: string; logoOpacity?: number; aspectRatio?: string; scenes: SceneData[]; } interface VideoProps { dataUrl: string; } // ─── Calculate actual duration from data.json ──────────────── export const calculateVideoMetadata: CalculateMetadataFunction = async ({ props }) => { const FPS = 30; try { const url = staticFile(props.dataUrl.replace(/^\//, "")); const res = await fetch(url); if (!res.ok) throw new Error(`Failed to fetch ${url}`); const data: VideoData = await res.json(); const totalSeconds = data.scenes.reduce( (sum, s) => sum + (s.durationSeconds || 5), 0 ); const totalFrames = Math.ceil((totalSeconds + 2) * FPS); const isPortrait = data.aspectRatio === "portrait"; return { durationInFrames: Math.max(totalFrames, FPS * 5), fps: FPS, width: isPortrait ? 1080 : 1920, height: isPortrait ? 1920 : 1080, }; } catch (e) { console.warn("calculateVideoMetadata fallback:", e); return { durationInFrames: FPS * 300, fps: FPS, width: 1920, height: 1080, }; } }; // ─── Main composition ──────────────────────────────────────── export const ExplainerVideo: React.FC = ({ dataUrl }) => { const [data, setData] = useState(null); useEffect(() => { fetch(staticFile(dataUrl.replace(/^\//, ""))) .then((res) => res.json()) .then(setData) .catch(() => { setData({ projectName: "Blog2Video Preview", accentColor: "#7C3AED", bgColor: "#FFFFFF", textColor: "#000000", scenes: [ { id: 1, order: 1, title: "Welcome", narration: "This is a preview of your Blog2Video project.", layout: "text_narration", layoutProps: {}, durationSeconds: 5, voiceoverFile: null, images: [], }, ], }); }); }, [dataUrl]); if (!data) { return (

Loading...

); } const FPS = 30; let currentFrame = 0; return ( {data.scenes.map((scene, index) => { const durationFrames = Math.round(scene.durationSeconds * FPS); const startFrame = currentFrame; currentFrame += durationFrames; // Pick layout component from registry const LayoutComponent = LAYOUT_REGISTRY[scene.layout] || LAYOUT_REGISTRY.text_narration; // Resolve image URL via staticFile const imageUrl = scene.images.length > 0 ? staticFile(scene.images[0]) : undefined; // Build props for the layout component const layoutProps: SceneLayoutProps = { title: scene.title, narration: scene.narration, imageUrl, accentColor: data.accentColor || "#7C3AED", bgColor: data.bgColor || "#FFFFFF", textColor: data.textColor || "#000000", aspectRatio: data.aspectRatio || "landscape", ...scene.layoutProps, }; return ( {/* Voiceover audio */} {scene.voiceoverFile && ( ); })} {/* Logo overlay — spans entire video */} {data.logo && ( )} ); };