import {
AbsoluteFill,
Audio,
Img,
OffthreadVideo,
Sequence,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { TextCard } from "./components/TextCard";
import { StatCard } from "./components/StatCard";
import { CalloutBox } from "./components/CalloutBox";
import { ComparisonCard } from "./components/ComparisonCard";
// ---------------------------------------------------------------------------
// 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;
// Comparison props
leftLabel?: string;
rightLabel?: string;
leftValue?: string;
rightValue?: string;
// Animation & transitions
animation?: string;
transition_in?: string;
transition_out?: string;
transform?: {
animation?: string;
scale?: number;
position?: { x: number; y: number };
};
}
interface AudioLayer {
src: string;
volume?: number;
}
interface SfxCue {
src: string;
start_seconds: number;
volume?: number;
}
interface AudioConfig {
narration?: AudioLayer;
music?: AudioLayer;
sfx?: SfxCue[];
}
interface ExplainerProps {
cuts: Cut[];
subtitles?: { enabled: boolean; src?: string };
audio?: AudioConfig;
}
// ---------------------------------------------------------------------------
// Image scene — spring / interpolate animations (replaces FFmpeg Ken Burns)
// ---------------------------------------------------------------------------
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));
}
const ImageScene: React.FC<{ src: string; animation?: string }> = ({
src,
animation,
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
// Smooth fade-in on entrance
const fadeIn = spring({ frame, fps, config: { damping: 20 } });
let scale = 1;
let translateX = 0;
let translateY = 0;
const anim = animation || "zoom-in";
if (anim === "zoom-in") {
scale = interpolate(frame, [0, durationInFrames], [1, 1.15], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
} else if (anim === "zoom-out") {
scale = interpolate(frame, [0, durationInFrames], [1.15, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
} else if (anim === "pan-left") {
translateX = interpolate(frame, [0, durationInFrames], [30, -30], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
scale = 1.15;
} else if (anim === "pan-right") {
translateX = interpolate(frame, [0, durationInFrames], [-30, 30], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
scale = 1.15;
} else if (anim === "ken-burns") {
scale = interpolate(frame, [0, durationInFrames], [1, 1.2], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
translateX = interpolate(frame, [0, durationInFrames], [0, -20], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
translateY = interpolate(frame, [0, durationInFrames], [0, -10], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
}
// "static" or "none" → no motion, just fade in
return (
);
};
// ---------------------------------------------------------------------------
// Video scene — OffthreadVideo for frame-accurate rendering
// ---------------------------------------------------------------------------
const VideoScene: React.FC<{ src: string; startFrom?: number }> = ({
src,
startFrom = 0,
}) => {
const { fps } = useVideoConfig();
return (
);
};
// ---------------------------------------------------------------------------
// Fade transition wrapper
// ---------------------------------------------------------------------------
const FadeIn: React.FC<{
children: React.ReactNode;
durationFrames?: number;
}> = ({ children, durationFrames = 10 }) => {
const frame = useCurrentFrame();
const opacity = interpolate(frame, [0, durationFrames], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return {children};
};
// ---------------------------------------------------------------------------
// Scene renderer — maps cut type / source to the right component
// ---------------------------------------------------------------------------
const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => {
// Explicit component types
if (cut.type === "text_card" && cut.text) {
return ;
}
if (cut.type === "stat_card" && cut.stat) {
return ;
}
if (cut.type === "callout" && cut.text) {
return (
);
}
if (
cut.type === "comparison" &&
cut.leftLabel &&
cut.rightLabel &&
cut.leftValue &&
cut.rightValue
) {
return (
);
}
// Auto-detect from source file extension
const animation = cut.animation || cut.transform?.animation;
if (isImage(cut.source)) {
return ;
}
if (isVideo(cut.source)) {
return ;
}
// Fallback: treat as image
return ;
};
// ---------------------------------------------------------------------------
// Main composition
// ---------------------------------------------------------------------------
export const Explainer: React.FC = ({ cuts, audio }) => {
const { fps } = useVideoConfig();
return (
{/* Visual layers */}
{cuts.map((cut) => {
const from = Math.round(cut.in_seconds * fps);
const duration = Math.round(
(cut.out_seconds - cut.in_seconds) * fps
);
const scene = ;
const wrapped =
cut.transition_in === "fade" ? (
{scene}
) : (
scene
);
return (
{wrapped}
);
})}
{/* Audio layers */}
{audio?.narration?.src && (
)}
{audio?.music?.src && (
)}
{audio?.sfx?.map((cue, i) => (
))}
);
};