Backend-B2V / remotion-video /src /ExplainerVideo.tsx
Muhammad-Mehdi's picture
Backend and remotion-video for HF Space
cfe45d5
Raw
History Blame Contribute Delete
5.54 kB
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>
);
};