Implementation spec: governance, decision intelligence, theme system, and E2E bug fixes

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)
This commit is contained in:
calesthio
2026-04-03 09:35:09 -07:00
parent a7e5f7498b
commit 2cd36fa8e0
83 changed files with 6076 additions and 282 deletions

View File

@@ -12,13 +12,18 @@ import {
} from "remotion";
import { loadFont } from "@remotion/google-fonts/SpaceGrotesk";
// Resolve asset path — use staticFile() for local paths, passthrough URLs
// 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";
@@ -37,6 +42,7 @@ 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", {
@@ -48,45 +54,84 @@ const { fontFamily } = loadFont("normal", {
// Animated Background — Gradient Mesh + Floating Orbs
// ---------------------------------------------------------------------------
const AnimatedBackground: React.FC<{ style?: "fintech" | "default" }> = ({
style: bgStyle = "default",
}) => {
// 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, width, height } = useVideoConfig();
const progress = frame / durationInFrames;
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;
const angle2 = 225 + Math.cos(frame / (fps * 6)) * 25;
// Color stops shift over time
const shift = Math.sin(frame / (fps * 12)) * 0.15;
// 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(15, 23, 60, 1) 0%, transparent 60%),
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(30, 10, 60, 0.8) 0%, transparent 55%),
radial-gradient(ellipse at ${50 + Math.sin(frame / (fps * 14)) * 30}% ${20 + Math.cos(frame / (fps * 11)) * 15}%,
rgba(0, 40, 60, 0.6) 0%, transparent 50%),
linear-gradient(${angle1}deg, #060918 0%, #0B1026 40%, #0F0A2E 70%, #080D1F 100%)
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
// 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: "rgba(34, 211, 238, 0.08)", speedX: 7, speedY: 11 },
{ x: 70, y: 60, size: 250, color: "rgba(139, 92, 246, 0.1)", speedX: 9, speedY: 8 },
{ x: 40, y: 80, size: 200, color: "rgba(16, 185, 129, 0.07)", speedX: 13, speedY: 6 },
{ x: 80, y: 20, size: 350, color: "rgba(245, 158, 11, 0.06)", speedX: 11, speedY: 14 },
{ x: 10, y: 70, size: 180, color: "rgba(236, 72, 153, 0.05)", speedX: 8, speedY: 10 },
{ 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}
@@ -97,7 +142,7 @@ const AnimatedBackground: React.FC<{ style?: "fintech" | "default" }> = ({
width: orb.size,
height: orb.size,
borderRadius: "50%",
background: orb.color,
background: `rgba(${r}, ${g}, ${b}, ${orbOpacity})`,
filter: `blur(${orb.size * 0.4}px)`,
transform: "translate(-50%, -50%)",
willChange: "transform",
@@ -112,8 +157,8 @@ const AnimatedBackground: React.FC<{ style?: "fintech" | "default" }> = ({
position: "absolute",
inset: 0,
backgroundImage: `
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px)
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,
@@ -128,7 +173,7 @@ const AnimatedBackground: React.FC<{ style?: "fintech" | "default" }> = ({
left: 0,
right: 0,
height: "30%",
background: "linear-gradient(to bottom, rgba(6,9,24,0.4), transparent)",
background: `linear-gradient(to bottom, ${fadeColor}, transparent)`,
}}
/>
</AbsoluteFill>
@@ -427,7 +472,7 @@ const BackgroundImageLayer: React.FC<{
);
};
const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => {
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) {
@@ -445,26 +490,29 @@ const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => {
// Resolve the scene element based on cut type, then wrap with backgroundImage if set
// Use transparent bg so the animated gradient background shows through
const rawBg = cut.backgroundImage ? "transparent" : cut.backgroundColor;
const bgColor = (rawBg === "#0F172A" || rawBg === "#0f172a") ? "transparent" : rawBg;
// 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
// 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={cut.color} backgroundColor={bgColor} />
<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={cut.accentColor} backgroundColor={bgColor} />
<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={cut.accentColor} backgroundColor={cut.backgroundColor}
textColor={cut.color} containerBackgroundColor={bgColor}
borderColor={accent} backgroundColor={cut.backgroundColor || theme.surfaceColor}
textColor={textColor} containerBackgroundColor={bgColor}
/>
);
}
@@ -473,7 +521,7 @@ const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => {
<ComparisonCard
leftLabel={cut.leftLabel} rightLabel={cut.rightLabel}
leftValue={cut.leftValue} rightValue={cut.rightValue}
title={cut.title} backgroundColor={bgColor} textColor={cut.color}
title={cut.title} backgroundColor={bgColor} textColor={textColor}
/>
);
}
@@ -483,11 +531,11 @@ const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => {
);
}
// --- Chart types ---
// --- 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}
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}
/>
@@ -496,7 +544,7 @@ const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => {
if (cut.type === "line_chart" && cut.chartSeries) {
return maybeWrapWithBgImage(
<LineChart
series={cut.chartSeries} title={cut.title} colors={cut.chartColors}
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}
@@ -506,7 +554,7 @@ const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => {
if (cut.type === "pie_chart" && cut.chartData) {
return maybeWrapWithBgImage(
<PieChart
data={cut.chartData} title={cut.title} colors={cut.chartColors}
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}
@@ -517,7 +565,7 @@ const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => {
return maybeWrapWithBgImage(
<KPIGrid
metrics={cut.chartData} title={cut.title} columns={cut.columns}
colors={cut.chartColors} animationStyle={(cut.chartAnimation as any) || "count-up"}
colors={cut.chartColors || theme.chartColors} animationStyle={(cut.chartAnimation as any) || "count-up"}
backgroundColor={bgColor}
/>
);
@@ -526,7 +574,7 @@ const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => {
return maybeWrapWithBgImage(
<AbsoluteFill
style={{
background: bgColor || "#FFFFFF",
background: bgColor || theme.surfaceColor,
display: "flex", alignItems: "center", justifyContent: "center",
padding: "80px 120px",
}}
@@ -534,16 +582,16 @@ const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => {
{cut.title && (
<div style={{
position: "absolute", top: 120, fontSize: 48, fontWeight: 700,
color: "#1F2937", textAlign: "center", width: "100%",
color: textColor, textAlign: "center", width: "100%",
}}>
{cut.title}
</div>
)}
<ProgressBar
progress={cut.progress} label={cut.progressLabel}
color={cut.progressColor || cut.accentColor}
color={cut.progressColor || accent}
animationStyle={(cut.progressAnimation as any) || "fill"}
segments={cut.progressSegments} backgroundColor={cut.backgroundColor}
segments={cut.progressSegments} backgroundColor={cut.backgroundColor || theme.surfaceColor}
/>
</AbsoluteFill>
);
@@ -585,7 +633,7 @@ const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => {
}
// No source, no type — render as text card with cut id as fallback
return <TextCard text={cut.text || cut.id} />;
return <TextCard text={cut.text || cut.id} color={textColor} backgroundColor={bgColor} />;
};
// ---------------------------------------------------------------------------
@@ -623,18 +671,17 @@ const OverlayRenderer: React.FC<{ overlay: Overlay }> = ({ overlay }) => {
// Main composition
// ---------------------------------------------------------------------------
export const Explainer: React.FC<ExplainerProps> = ({
cuts,
overlays,
captions,
audio,
}) => {
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: "#060918", fontFamily }}>
{/* Layer 0: Animated gradient background */}
<AnimatedBackground style="fintech" />
<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) => {
@@ -643,7 +690,7 @@ export const Explainer: React.FC<ExplainerProps> = ({
return (
<Sequence key={cut.id} from={from} durationInFrames={duration}>
<SceneRenderer cut={cut} />
<SceneRenderer cut={cut} theme={theme} />
</Sequence>
);
})}
@@ -668,8 +715,8 @@ export const Explainer: React.FC<ExplainerProps> = ({
words={captions}
wordsPerPage={6}
fontSize={42}
highlightColor="#22D3EE"
backgroundColor="rgba(15, 23, 42, 0.7)"
highlightColor={theme.captionHighlightColor}
backgroundColor={theme.captionBackgroundColor}
/>
)}

View File

@@ -7,6 +7,109 @@ import {
import { signalFromTomorrowWithMusicFixture } from "./cinematic/fixtures";
import { TalkingHead, TalkingHeadProps } from "./TalkingHead";
// ---------------------------------------------------------------------------
// Theme System — prevents every video from looking like dark fintech
// ---------------------------------------------------------------------------
export interface ThemeConfig {
primaryColor: string;
accentColor: string;
backgroundColor: string;
surfaceColor: string;
textColor: string;
mutedTextColor: string;
headingFont: string;
bodyFont: string;
monoFont: string;
chartColors: string[];
springConfig: { damping: number; stiffness: number; mass: number };
transitionDuration: number;
captionHighlightColor: string;
captionBackgroundColor: string;
}
export const THEMES: Record<string, ThemeConfig> = {
"clean-professional": {
primaryColor: "#2563EB",
accentColor: "#F59E0B",
backgroundColor: "#FFFFFF",
surfaceColor: "#F9FAFB",
textColor: "#1F2937",
mutedTextColor: "#6B7280",
headingFont: "Inter",
bodyFont: "Inter",
monoFont: "JetBrains Mono",
chartColors: ["#2563EB", "#F59E0B", "#10B981", "#8B5CF6", "#EC4899", "#06B6D4"],
springConfig: { damping: 20, stiffness: 120, mass: 1 },
transitionDuration: 0.4,
captionHighlightColor: "#2563EB",
captionBackgroundColor: "rgba(255, 255, 255, 0.85)",
},
"flat-motion-graphics": {
primaryColor: "#7C3AED",
accentColor: "#EC4899",
backgroundColor: "#0F172A",
surfaceColor: "#1E293B",
textColor: "#F8FAFC",
mutedTextColor: "#94A3B8",
headingFont: "Space Grotesk",
bodyFont: "Space Grotesk",
monoFont: "Fira Code",
chartColors: ["#7C3AED", "#EC4899", "#06B6D4", "#F59E0B", "#10B981", "#EF4444"],
springConfig: { damping: 12, stiffness: 80, mass: 1 },
transitionDuration: 0.3,
captionHighlightColor: "#22D3EE",
captionBackgroundColor: "rgba(15, 23, 42, 0.75)",
},
"minimalist-diagram": {
primaryColor: "#1A1A2E",
accentColor: "#E94560",
backgroundColor: "#FAFAFA",
surfaceColor: "#FFFFFF",
textColor: "#1A1A2E",
mutedTextColor: "#6B7280",
headingFont: "IBM Plex Sans",
bodyFont: "IBM Plex Sans",
monoFont: "IBM Plex Mono",
chartColors: ["#E94560", "#1A1A2E", "#0F3460", "#9CA3AF"],
springConfig: { damping: 25, stiffness: 150, mass: 1 },
transitionDuration: 0.5,
captionHighlightColor: "#E94560",
captionBackgroundColor: "rgba(250, 250, 250, 0.9)",
},
"anime-ghibli": {
primaryColor: "#2D5016",
accentColor: "#FFB347",
backgroundColor: "#0A0A1A",
surfaceColor: "#1A2332",
textColor: "#F0E6D3",
mutedTextColor: "#A8957E",
headingFont: "Noto Serif JP",
bodyFont: "Noto Sans",
monoFont: "Fira Code",
chartColors: ["#FFB347", "#2D5016", "#FF6B9D", "#A8E6CF", "#6B4C8A", "#E8927C"],
springConfig: { damping: 18, stiffness: 60, mass: 1 },
transitionDuration: 1.0,
captionHighlightColor: "#FFB347",
captionBackgroundColor: "rgba(10, 10, 26, 0.8)",
},
};
// Default theme when none is specified — uses the existing dark style for backwards compatibility
export const DEFAULT_THEME = THEMES["flat-motion-graphics"];
export function resolveTheme(props: Record<string, unknown>): ThemeConfig {
const themeName = (props.theme as string) || (props.playbook as string);
if (themeName && THEMES[themeName]) {
return THEMES[themeName];
}
// Allow custom theme passed as full object
if (props.themeConfig && typeof props.themeConfig === "object") {
return { ...DEFAULT_THEME, ...(props.themeConfig as Partial<ThemeConfig>) };
}
return DEFAULT_THEME;
}
const calculateMetadata: CalculateMetadataFunction<ExplainerProps> = async ({
props,
}) => {