Spaces:
Sleeping
Sleeping
| 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<string, any>; | |
| 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<VideoProps> = | |
| 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<VideoProps> = ({ dataUrl }) => { | |
| const [data, setData] = useState<VideoData | null>(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 ( | |
| <AbsoluteFill | |
| style={{ | |
| backgroundColor: "#FFFFFF", | |
| display: "flex", | |
| alignItems: "center", | |
| justifyContent: "center", | |
| }} | |
| > | |
| <p style={{ color: "#000000", fontSize: 36 }}>Loading...</p> | |
| </AbsoluteFill> | |
| ); | |
| } | |
| const FPS = 30; | |
| let currentFrame = 0; | |
| return ( | |
| <AbsoluteFill style={{ backgroundColor: data.bgColor || "#FFFFFF" }}> | |
| {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 ( | |
| <Sequence | |
| key={scene.id} | |
| from={startFrame} | |
| durationInFrames={durationFrames} | |
| name={scene.title} | |
| > | |
| <LayoutComponent {...layoutProps} /> | |
| {/* Voiceover audio */} | |
| {scene.voiceoverFile && ( | |
| <Audio src={staticFile(scene.voiceoverFile)} /> | |
| )} | |
| {/* Transition overlay */} | |
| {index < data.scenes.length - 1 && ( | |
| <Sequence from={durationFrames - 15} durationInFrames={15}> | |
| <TransitionWipe /> | |
| </Sequence> | |
| )} | |
| </Sequence> | |
| ); | |
| })} | |
| {/* Logo overlay β spans entire video */} | |
| {data.logo && ( | |
| <LogoOverlay | |
| src={staticFile(data.logo)} | |
| position={data.logoPosition || "bottom_right"} | |
| maxOpacity={data.logoOpacity ?? 0.9} | |
| aspectRatio={data.aspectRatio || "landscape"} | |
| /> | |
| )} | |
| </AbsoluteFill> | |
| ); | |
| }; | |