2cd36fa8e0
Implements the 2026-04-02 transformation spec (Phases 1-8) and fixes all critical bugs found during 5-pipeline E2E testing. Governance & Decision Intelligence: - Pipeline-specific stage order in checkpoint (replaces global STAGES list) - Provider scoring engine (lib/scoring.py) with 7-dimension weighted ranking - Decision log artifact enforced at proposal/idea stage across all 10 pipelines - Delivery promise classifier prevents silent motion-to-still downgrades - Structured shot language in scene_plan schema (camera, lens, lighting, DOF) - Variation checker and slideshow risk scorer block samey output before render - Creative intake, capability extension, and creative-intake meta skills - Final self-review artifact with 5 mandatory checks before presenting output - Source media review contract for user-supplied footage Render & Theme System: - Remotion AnimatedBackground now derives colors from playbook (no more hardcoded dark blue fintech gradient on every video) - video_compose builds custom ThemeConfig from playbook YAML colors/fonts — custom playbooks flow through to Remotion automatically - Explainer component wires theme to all child components (charts, cards, etc.) - resolveAsset() handles absolute paths on Windows/Unix via file:// URIs - RENDERER_FAMILY_MAP synced with actual Remotion compositions Critical Bug Fixes: - Windows npx subprocess: run_command() resolves .cmd wrappers via shutil.which() - Silent renderer downgrade: Remotion failure now returns explicit error with options instead of silently falling back to FFmpeg - .env inline comment parsing strips trailing # comments from API keys - concat_path UnboundLocalError in video_compose finally block - audio_mixer and showcase_card capture=True kwarg bug - Selector estimate_cost() calls fixed (_select_tool -> _select_best_tool) - asset_manifest schema expanded with provider, license, subtype fields - screen-demo subtitle_gen moved from required to optional tools - Duration drift detection in post-render final review (>25% warns)
760 lines
25 KiB
TypeScript
760 lines
25 KiB
TypeScript
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 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 (
|
|
<AbsoluteFill style={{ background: gradient }}>
|
|
{/* 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 (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
position: "absolute",
|
|
left: `${ox}%`,
|
|
top: `${oy}%`,
|
|
width: orb.size,
|
|
height: orb.size,
|
|
borderRadius: "50%",
|
|
background: `rgba(${r}, ${g}, ${b}, ${orbOpacity})`,
|
|
filter: `blur(${orb.size * 0.4}px)`,
|
|
transform: "translate(-50%, -50%)",
|
|
willChange: "transform",
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{/* Subtle grid overlay */}
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
backgroundImage: `
|
|
linear-gradient(${gridColor} 1px, transparent 1px),
|
|
linear-gradient(90deg, ${gridColor} 1px, transparent 1px)
|
|
`,
|
|
backgroundSize: "60px 60px",
|
|
opacity: 0.5 + Math.sin(frame / (fps * 20)) * 0.2,
|
|
}}
|
|
/>
|
|
|
|
{/* Top gradient fade for depth */}
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: "30%",
|
|
background: `linear-gradient(to bottom, ${fadeColor}, transparent)`,
|
|
}}
|
|
/>
|
|
</AbsoluteFill>
|
|
);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
// 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
|
|
backgroundOverlay?: number; // Opacity of dark overlay on backgroundImage (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;
|
|
}
|
|
|
|
interface Overlay {
|
|
type: "section_title" | "stat_reveal" | "hero_title";
|
|
in_seconds: number;
|
|
out_seconds: number;
|
|
text: string;
|
|
subtitle?: string;
|
|
accentColor?: string;
|
|
position?: 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 = () => (
|
|
<AbsoluteFill
|
|
style={{
|
|
background:
|
|
"radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,0.6) 100%)",
|
|
pointerEvents: "none",
|
|
}}
|
|
/>
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 (
|
|
<AbsoluteFill style={{ overflow: "hidden", background: "#0F172A" }}>
|
|
<Img
|
|
src={resolveAsset(src)}
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
objectFit: "cover",
|
|
opacity: fadeIn * fadeOut,
|
|
transform: `scale(${scale}) translate(${translateX}px, ${translateY}px)`,
|
|
willChange: "transform, opacity",
|
|
}}
|
|
/>
|
|
<Vignette />
|
|
</AbsoluteFill>
|
|
);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 (
|
|
<AbsoluteFill style={{ background: "#0F172A" }}>
|
|
<OffthreadVideo
|
|
src={resolveAsset(src)}
|
|
startFrom={Math.round(startFrom * fps)}
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
objectFit: "cover",
|
|
opacity: fadeIn * fadeOut,
|
|
}}
|
|
muted
|
|
/>
|
|
<Vignette />
|
|
</AbsoluteFill>
|
|
);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 (
|
|
<AbsoluteFill style={{ overflow: "hidden" }}>
|
|
{/* Background image with subtle zoom */}
|
|
<Img
|
|
src={resolveAsset(src)}
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
objectFit: "cover",
|
|
transform: `scale(${bgScale})`,
|
|
willChange: "transform",
|
|
}}
|
|
/>
|
|
{/* Dark overlay for readability */}
|
|
<AbsoluteFill
|
|
style={{
|
|
background: `rgba(15, 23, 42, ${overlayOpacity})`,
|
|
}}
|
|
/>
|
|
{/* Component content on top */}
|
|
{children}
|
|
</AbsoluteFill>
|
|
);
|
|
};
|
|
|
|
const SceneRenderer: React.FC<{ cut: Cut; theme: ThemeConfig }> = ({ cut, theme }) => {
|
|
// Wrap component with background image if specified
|
|
const maybeWrapWithBgImage = (element: React.ReactElement) => {
|
|
if (cut.backgroundImage) {
|
|
return (
|
|
<BackgroundImageLayer
|
|
src={cut.backgroundImage}
|
|
overlayOpacity={cut.backgroundOverlay ?? 0.55}
|
|
>
|
|
{element}
|
|
</BackgroundImageLayer>
|
|
);
|
|
}
|
|
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 ? "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 maybeWrapWithBgImage(
|
|
<TextCard text={cut.text} fontSize={cut.fontSize} color={textColor} backgroundColor={bgColor} />
|
|
);
|
|
}
|
|
if (cut.type === "stat_card" && cut.stat) {
|
|
return maybeWrapWithBgImage(
|
|
<StatCard stat={cut.stat} subtitle={cut.subtitle} accentColor={accent} backgroundColor={bgColor} />
|
|
);
|
|
}
|
|
if (cut.type === "callout" && cut.text) {
|
|
return maybeWrapWithBgImage(
|
|
<CalloutBox
|
|
text={cut.text} type={cut.callout_type} title={cut.title}
|
|
borderColor={accent} backgroundColor={cut.backgroundColor || theme.surfaceColor}
|
|
textColor={textColor} containerBackgroundColor={bgColor}
|
|
/>
|
|
);
|
|
}
|
|
if (cut.type === "comparison" && cut.leftLabel && cut.rightLabel && cut.leftValue && cut.rightValue) {
|
|
return maybeWrapWithBgImage(
|
|
<ComparisonCard
|
|
leftLabel={cut.leftLabel} rightLabel={cut.rightLabel}
|
|
leftValue={cut.leftValue} rightValue={cut.rightValue}
|
|
title={cut.title} backgroundColor={bgColor} textColor={textColor}
|
|
/>
|
|
);
|
|
}
|
|
if (cut.type === "hero_title" && cut.text) {
|
|
return maybeWrapWithBgImage(
|
|
<HeroTitle title={cut.text} subtitle={cut.heroSubtitle || cut.subtitle} />
|
|
);
|
|
}
|
|
|
|
// --- Chart types — use theme.chartColors as default palette ---
|
|
if (cut.type === "bar_chart" && cut.chartData) {
|
|
return maybeWrapWithBgImage(
|
|
<BarChart
|
|
data={cut.chartData} title={cut.title} colors={cut.chartColors || theme.chartColors}
|
|
animationStyle={(cut.chartAnimation as any) || "grow-up"}
|
|
showGrid={cut.showGrid} showValues={cut.showValues} backgroundColor={bgColor}
|
|
/>
|
|
);
|
|
}
|
|
if (cut.type === "line_chart" && cut.chartSeries) {
|
|
return maybeWrapWithBgImage(
|
|
<LineChart
|
|
series={cut.chartSeries} title={cut.title} colors={cut.chartColors || theme.chartColors}
|
|
animationStyle={(cut.chartAnimation as any) || "draw"}
|
|
showGrid={cut.showGrid} showMarkers={cut.showMarkers} showLegend={cut.showLegend}
|
|
xLabel={cut.xLabel} yLabel={cut.yLabel} backgroundColor={bgColor}
|
|
/>
|
|
);
|
|
}
|
|
if (cut.type === "pie_chart" && cut.chartData) {
|
|
return maybeWrapWithBgImage(
|
|
<PieChart
|
|
data={cut.chartData} title={cut.title} colors={cut.chartColors || theme.chartColors}
|
|
animationStyle={(cut.chartAnimation as any) || "expand"}
|
|
donut={cut.donut} centerLabel={cut.centerLabel} centerValue={cut.centerValue}
|
|
showLegend={cut.showLegend} backgroundColor={bgColor}
|
|
/>
|
|
);
|
|
}
|
|
if (cut.type === "kpi_grid" && cut.chartData) {
|
|
return maybeWrapWithBgImage(
|
|
<KPIGrid
|
|
metrics={cut.chartData} title={cut.title} columns={cut.columns}
|
|
colors={cut.chartColors || theme.chartColors} animationStyle={(cut.chartAnimation as any) || "count-up"}
|
|
backgroundColor={bgColor}
|
|
/>
|
|
);
|
|
}
|
|
if (cut.type === "progress_bar" && cut.progress !== undefined) {
|
|
return maybeWrapWithBgImage(
|
|
<AbsoluteFill
|
|
style={{
|
|
background: bgColor || theme.surfaceColor,
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
padding: "80px 120px",
|
|
}}
|
|
>
|
|
{cut.title && (
|
|
<div style={{
|
|
position: "absolute", top: 120, fontSize: 48, fontWeight: 700,
|
|
color: textColor, textAlign: "center", width: "100%",
|
|
}}>
|
|
{cut.title}
|
|
</div>
|
|
)}
|
|
<ProgressBar
|
|
progress={cut.progress} label={cut.progressLabel}
|
|
color={cut.progressColor || accent}
|
|
animationStyle={(cut.progressAnimation as any) || "fill"}
|
|
segments={cut.progressSegments} backgroundColor={cut.backgroundColor || theme.surfaceColor}
|
|
/>
|
|
</AbsoluteFill>
|
|
);
|
|
}
|
|
|
|
// --- Anime scene (multi-image crossfade + particles) ---
|
|
if (cut.type === "anime_scene" && cut.images && cut.images.length > 0) {
|
|
return (
|
|
<AnimeScene
|
|
images={cut.images}
|
|
animation={(cut.animation as CameraMotion) || "ken-burns"}
|
|
particles={cut.particles}
|
|
particleColor={cut.particleColor}
|
|
particleCount={cut.particleCount}
|
|
particleIntensity={cut.particleIntensity}
|
|
backgroundColor={cut.backgroundColor}
|
|
vignette={cut.vignette ?? true}
|
|
lightingFrom={cut.lightingFrom}
|
|
lightingTo={cut.lightingTo}
|
|
sceneDurationSeconds={cut.out_seconds - cut.in_seconds}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// --- Media types (image / video fallback) ---
|
|
const animation = cut.animation || cut.transform?.animation;
|
|
|
|
if (cut.source && isImage(cut.source)) {
|
|
return <ImageScene src={cut.source} animation={animation} />;
|
|
}
|
|
|
|
if (cut.source && isVideo(cut.source)) {
|
|
return <VideoScene src={cut.source} startFrom={cut.in_seconds} />;
|
|
}
|
|
|
|
// Final fallback — try as image if source exists, otherwise show text_card
|
|
if (cut.source) {
|
|
return <ImageScene src={cut.source} animation={animation} />;
|
|
}
|
|
|
|
// No source, no type — render as text card with cut id as fallback
|
|
return <TextCard text={cut.text || cut.id} color={textColor} backgroundColor={bgColor} />;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Overlay renderer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const OverlayRenderer: React.FC<{ overlay: Overlay }> = ({ overlay }) => {
|
|
if (overlay.type === "section_title") {
|
|
return (
|
|
<SectionTitle
|
|
title={overlay.text}
|
|
subtitle={overlay.subtitle}
|
|
accentColor={overlay.accentColor}
|
|
position={(overlay.position as any) || "top-left"}
|
|
/>
|
|
);
|
|
}
|
|
if (overlay.type === "stat_reveal") {
|
|
return (
|
|
<StatReveal
|
|
stat={overlay.text}
|
|
label={overlay.subtitle}
|
|
accentColor={overlay.accentColor}
|
|
position={(overlay.position as any) || "bottom-right"}
|
|
/>
|
|
);
|
|
}
|
|
if (overlay.type === "hero_title") {
|
|
return <HeroTitle title={overlay.text} subtitle={overlay.subtitle} />;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main composition
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const Explainer: React.FC<ExplainerProps> = (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<string, unknown>);
|
|
|
|
return (
|
|
<AbsoluteFill style={{ background: theme.backgroundColor, fontFamily: theme.headingFont || fontFamily }}>
|
|
{/* Layer 0: Animated gradient background — driven by theme */}
|
|
<AnimatedBackground theme={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 (
|
|
<Sequence key={cut.id} from={from} durationInFrames={duration}>
|
|
<SceneRenderer cut={cut} theme={theme} />
|
|
</Sequence>
|
|
);
|
|
})}
|
|
|
|
{/* 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 (
|
|
<Sequence key={`overlay-${i}`} from={from} durationInFrames={duration}>
|
|
<OverlayRenderer overlay={overlay} />
|
|
</Sequence>
|
|
);
|
|
})}
|
|
|
|
{/* Layer 3: Captions (word-by-word highlight) */}
|
|
{captions && captions.length > 0 && (
|
|
<CaptionOverlay
|
|
words={captions}
|
|
wordsPerPage={6}
|
|
fontSize={42}
|
|
highlightColor={theme.captionHighlightColor}
|
|
backgroundColor={theme.captionBackgroundColor}
|
|
/>
|
|
)}
|
|
|
|
{/* Layer 4: Audio — narration */}
|
|
{audio?.narration?.src && (
|
|
<Audio src={resolveAsset(audio.narration.src)} volume={audio.narration.volume ?? 1} />
|
|
)}
|
|
|
|
{/* Layer 4: Audio — music with offset, fade in/out, and optional loop */}
|
|
{audio?.music?.src && (
|
|
<Audio
|
|
src={resolveAsset(audio.music.src)}
|
|
startFrom={Math.round((audio.music.offsetSeconds ?? 0) * fps)}
|
|
loop={audio.music.loop ?? false}
|
|
loopVolumeCurveBehavior="repeat"
|
|
volume={(f) => {
|
|
const baseVol = audio.music!.volume ?? 0.1;
|
|
const fadeInDur = (audio.music!.fadeInSeconds ?? 2) * fps;
|
|
const fadeOutDur = (audio.music!.fadeOutSeconds ?? 3) * fps;
|
|
const totalFrames = durationInFrames;
|
|
|
|
// Fade in
|
|
const fadeIn = interpolate(f, [0, fadeInDur], [0, baseVol], {
|
|
extrapolateLeft: "clamp",
|
|
extrapolateRight: "clamp",
|
|
});
|
|
// Fade out
|
|
const fadeOut = interpolate(
|
|
f,
|
|
[totalFrames - fadeOutDur, totalFrames],
|
|
[baseVol, 0],
|
|
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
);
|
|
return Math.min(fadeIn, fadeOut);
|
|
}}
|
|
/>
|
|
)}
|
|
</AbsoluteFill>
|
|
);
|
|
};
|