a36ce99793
Make synthetic screen recording a first-class, discoverable capability alongside real OS capture. For CLI / terminal / install-flow demos where commands and output are predictable, author a `terminal_scene` cut instead of driving a real screen recorder — deterministic, privacy-safe, pixel- perfect, and frame-accurate to narration cues. Components: - TerminalScene.tsx: window chrome, char-by-char typing, blinking cursor, scrolling output, non-blocking floating pills, spring-based reveals - ProviderChip.tsx: rotating badge overlay that cycles through provider names (used in AI-generated-motion scenes) - BackgroundVideoLayer + source_in_seconds + backgroundVideo props on every scene type — supports video-behind-component composition Discovery chain (six layers, so the next agent finds this without reading source code): 1. pipeline_defs/screen-demo.yaml — bump to 2.1, declare production_modes (real_capture, synthetic_terminal) with required_tools, scene_type, and agent_skills pointers 2. skills/pipelines/screen-demo/idea-director.md — mode-selection table at brief time; brief.metadata.production_mode contract 3. skills/pipelines/screen-demo/asset-director.md — reads production_mode and branches asset production (capture+overlays vs steps+narration+ pacing check) 4. .agents/skills/synthetic-screen-recording/SKILL.md — Layer 3 skill with step kinds (cmd/out/pause/pill), pacing rule, and the frozen-terminal failure mode captured from the showcase v3 retune 5. AGENT_GUIDE.md — TerminalScene added to Remotion routing; links SCENE_TYPES.md as the authoritative cut-type registry 6. remotion-composer/SCENE_TYPES.md — new cheat sheet of every cut.type and overlay.type with required fields, plus a "how to add a new scene type" section (candidates: ChatTranscript, EditorScene, PrReview, SlackThread, TicketBoard) Guardrail: - lib/verify_scene_pacing.py — reusable trace() and assert_alignment() helpers that mimic the TerminalScene frame math exactly. Fail loudly before render if steps burn through too fast or leave the scene frozen.
843 lines
28 KiB
TypeScript
843 lines
28 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 { TerminalScene } from "./components/TerminalScene";
|
|
import type { TerminalStep } from "./components/TerminalScene";
|
|
import { ProviderChip } from "./components/ProviderChip";
|
|
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;
|
|
// Video source trim — seek to this point in the source before playback.
|
|
// Defaults to 0 (play from beginning). Use this instead of in_seconds for source trimming.
|
|
source_in_seconds?: number;
|
|
// 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
|
|
backgroundVideo?: string; // Video clip rendered behind the component (takes priority over backgroundImage)
|
|
backgroundVideoStart?: number; // Seek position in seconds for background video (default 0)
|
|
backgroundOverlay?: number; // Opacity of dark overlay on backgroundImage/backgroundVideo (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;
|
|
// Terminal scene props (type: "terminal_scene")
|
|
steps?: TerminalStep[];
|
|
terminalTitle?: string;
|
|
prompt?: string;
|
|
}
|
|
|
|
interface Overlay {
|
|
type: "section_title" | "stat_reveal" | "hero_title" | "provider_chip";
|
|
in_seconds: number;
|
|
out_seconds: number;
|
|
text?: string;
|
|
subtitle?: string;
|
|
accentColor?: string;
|
|
position?: string;
|
|
// provider_chip
|
|
providers?: string[];
|
|
cycleSeconds?: number;
|
|
label?: 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>
|
|
);
|
|
};
|
|
|
|
// Background video layer — plays a looping video behind component content with dark overlay
|
|
const BackgroundVideoLayer: React.FC<{
|
|
src: string;
|
|
startFrom?: number;
|
|
overlayOpacity?: number;
|
|
children: React.ReactNode;
|
|
}> = ({ src, startFrom = 0, overlayOpacity = 0.55, children }) => {
|
|
const { fps } = useVideoConfig();
|
|
|
|
return (
|
|
<AbsoluteFill style={{ overflow: "hidden" }}>
|
|
{/* Background video */}
|
|
<OffthreadVideo
|
|
src={resolveAsset(src)}
|
|
startFrom={Math.round(startFrom * fps)}
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
objectFit: "cover",
|
|
}}
|
|
muted
|
|
/>
|
|
{/* 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 video or image if specified
|
|
const maybeWrapWithBg = (element: React.ReactElement) => {
|
|
if (cut.backgroundVideo) {
|
|
return (
|
|
<BackgroundVideoLayer
|
|
src={cut.backgroundVideo}
|
|
startFrom={cut.backgroundVideoStart ?? 0}
|
|
overlayOpacity={cut.backgroundOverlay ?? 0.55}
|
|
>
|
|
{element}
|
|
</BackgroundVideoLayer>
|
|
);
|
|
}
|
|
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 || cut.backgroundVideo) ? "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 maybeWrapWithBg(
|
|
<TextCard text={cut.text} fontSize={cut.fontSize} color={textColor} backgroundColor={bgColor} />
|
|
);
|
|
}
|
|
if (cut.type === "stat_card" && cut.stat) {
|
|
return maybeWrapWithBg(
|
|
<StatCard stat={cut.stat} subtitle={cut.subtitle} accentColor={accent} backgroundColor={bgColor} />
|
|
);
|
|
}
|
|
if (cut.type === "callout" && cut.text) {
|
|
return maybeWrapWithBg(
|
|
<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 maybeWrapWithBg(
|
|
<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 maybeWrapWithBg(
|
|
<HeroTitle title={cut.text} subtitle={cut.heroSubtitle || cut.subtitle} />
|
|
);
|
|
}
|
|
if (cut.type === "terminal_scene" && cut.steps) {
|
|
return maybeWrapWithBg(
|
|
<TerminalScene
|
|
title={cut.terminalTitle || "Terminal"}
|
|
steps={cut.steps as TerminalStep[]}
|
|
prompt={cut.prompt}
|
|
accentColor={accent}
|
|
backgroundColor={bgColor || theme.backgroundColor}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// --- Chart types — use theme.chartColors as default palette ---
|
|
if (cut.type === "bar_chart" && cut.chartData) {
|
|
return maybeWrapWithBg(
|
|
<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 maybeWrapWithBg(
|
|
<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 maybeWrapWithBg(
|
|
<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 maybeWrapWithBg(
|
|
<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 maybeWrapWithBg(
|
|
<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 maybeWrapWithBg(<ImageScene src={cut.source} animation={animation} />);
|
|
}
|
|
|
|
if (cut.source && isVideo(cut.source)) {
|
|
return maybeWrapWithBg(<VideoScene src={cut.source} startFrom={cut.source_in_seconds ?? 0} />);
|
|
}
|
|
|
|
// Final fallback — try as image if source exists, otherwise show text_card
|
|
if (cut.source) {
|
|
return maybeWrapWithBg(<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} />;
|
|
}
|
|
if (overlay.type === "provider_chip" && overlay.providers) {
|
|
return (
|
|
<ProviderChip
|
|
providers={overlay.providers as string[]}
|
|
cycleSeconds={overlay.cycleSeconds}
|
|
position={(overlay.position as any) || "bottom-right"}
|
|
accentColor={overlay.accentColor}
|
|
label={overlay.label}
|
|
/>
|
|
);
|
|
}
|
|
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>
|
|
);
|
|
};
|