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:
@@ -139,7 +139,7 @@ class ShowcaseCard(BaseTool):
|
||||
"-of", "csv=p=0",
|
||||
input_path,
|
||||
]
|
||||
probe_out = self.run_command(probe_cmd, capture=True).strip()
|
||||
probe_out = self.run_command(probe_cmd).stdout.strip()
|
||||
src_w, src_h = [int(x.strip()) for x in probe_out.split(",")[:2]]
|
||||
|
||||
# Calculate letterbox dimensions — fit source into output width,
|
||||
|
||||
+750
-42
@@ -13,6 +13,8 @@ For pure video cuts (talking-head, etc.), FFmpeg handles trimming and concat.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
@@ -279,21 +281,26 @@ class VideoCompose(BaseTool):
|
||||
if not cuts:
|
||||
return ToolResult(success=False, error="No cuts in edit_decisions")
|
||||
|
||||
# Extract subtitle style from edit_decisions if not provided directly
|
||||
if not inputs.get("subtitle_style"):
|
||||
ed_subs = edit_decisions.get("subtitles", {})
|
||||
if ed_subs:
|
||||
inputs = dict(inputs)
|
||||
inputs["subtitle_style"] = {
|
||||
k: v for k, v in ed_subs.items()
|
||||
if k in ("font", "font_size", "color", "outline_color", "background")
|
||||
}
|
||||
if ed_subs.get("source") and not subtitle_path:
|
||||
subtitle_path = ed_subs["source"]
|
||||
# Resolve subtitle style using the layered priority resolver
|
||||
# (explicit > edit_decisions > playbook > defaults)
|
||||
playbook_data = inputs.get("playbook")
|
||||
resolved_sub_style = self._resolve_subtitle_style(
|
||||
inputs.get("subtitle_style"),
|
||||
edit_decisions,
|
||||
playbook_data,
|
||||
)
|
||||
inputs = dict(inputs)
|
||||
inputs["subtitle_style"] = resolved_sub_style
|
||||
|
||||
ed_subs = edit_decisions.get("subtitles", {})
|
||||
if ed_subs.get("source") and not subtitle_path:
|
||||
subtitle_path = ed_subs["source"]
|
||||
|
||||
temp_dir = output_path.parent / ".compose_tmp"
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_segments: list[Path] = []
|
||||
concat_path: Path | None = None
|
||||
concat_out: Path | None = None
|
||||
|
||||
try:
|
||||
for i, cut in enumerate(cuts):
|
||||
@@ -412,7 +419,7 @@ class VideoCompose(BaseTool):
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
for f in [concat_path, concat_out]:
|
||||
if f.exists():
|
||||
if f is not None and f.exists():
|
||||
f.unlink()
|
||||
if temp_dir.exists():
|
||||
try:
|
||||
@@ -424,6 +431,144 @@ class VideoCompose(BaseTool):
|
||||
"text_card", "stat_card", "callout", "comparison", "progress", "chart",
|
||||
}
|
||||
|
||||
# Maps renderer_family (set at proposal stage) to Remotion composition ID.
|
||||
# Each family MUST map to a distinct composition — collapsing defeats visual grammar.
|
||||
# Maps renderer_family → Remotion composition ID.
|
||||
# Only compositions registered in remotion-composer/src/Root.tsx are valid.
|
||||
# Current compositions: Explainer, CinematicRenderer, TalkingHead
|
||||
RENDERER_FAMILY_MAP = {
|
||||
"explainer-data": "Explainer",
|
||||
"explainer-teacher": "Explainer",
|
||||
"cinematic-trailer": "CinematicRenderer",
|
||||
"product-reveal": "Explainer",
|
||||
"screen-demo": "Explainer",
|
||||
"presenter": "TalkingHead",
|
||||
"animation-first": "Explainer",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _get_composition_id(cls, renderer_family: str) -> str:
|
||||
"""Resolve renderer_family to Remotion composition ID.
|
||||
|
||||
Raises ValueError if renderer_family is not recognized — the caller
|
||||
must set it at proposal stage.
|
||||
"""
|
||||
comp = cls.RENDERER_FAMILY_MAP.get(renderer_family)
|
||||
if comp is None:
|
||||
raise ValueError(
|
||||
f"Unknown renderer_family {renderer_family!r}. "
|
||||
f"Valid families: {sorted(cls.RENDERER_FAMILY_MAP)}. "
|
||||
f"Set renderer_family at proposal stage."
|
||||
)
|
||||
return comp
|
||||
|
||||
@staticmethod
|
||||
def _build_theme_from_playbook(
|
||||
playbook_name: str | None,
|
||||
composition_data: dict | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Derive a Remotion ThemeConfig from a playbook's actual color values.
|
||||
|
||||
Instead of passing a playbook name and hoping Remotion has a matching
|
||||
preset, we read the playbook YAML and extract concrete colors/fonts.
|
||||
This means custom playbooks, overridden palettes, and per-project
|
||||
styles all flow through to Remotion automatically.
|
||||
|
||||
Falls back to extracting colors from edit_decisions metadata if
|
||||
no playbook is loadable.
|
||||
"""
|
||||
theme: dict[str, Any] = {}
|
||||
|
||||
# Try to load the playbook YAML
|
||||
playbook: dict[str, Any] = {}
|
||||
if playbook_name:
|
||||
try:
|
||||
from styles.playbook_loader import load_playbook
|
||||
playbook = load_playbook(playbook_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if playbook:
|
||||
vl = playbook.get("visual_language", {})
|
||||
palette = vl.get("color_palette", {})
|
||||
typo = playbook.get("typography", {})
|
||||
|
||||
# Extract primary/accent — may be a list (gradient stops) or string
|
||||
primary_raw = palette.get("primary", ["#2563EB"])
|
||||
accent_raw = palette.get("accent", ["#F59E0B"])
|
||||
primary = primary_raw[0] if isinstance(primary_raw, list) else primary_raw
|
||||
accent = accent_raw[0] if isinstance(accent_raw, list) else accent_raw
|
||||
|
||||
bg = palette.get("background", "#FFFFFF")
|
||||
text = palette.get("text", "#1F2937")
|
||||
surface = palette.get("surface", bg)
|
||||
muted = palette.get("muted_text", "#6B7280")
|
||||
|
||||
# Build chart colors from all palette entries
|
||||
chart_colors = []
|
||||
for key in ["primary", "accent", "secondary", "success", "warning", "info"]:
|
||||
val = palette.get(key)
|
||||
if val:
|
||||
chart_colors.append(val[0] if isinstance(val, list) else val)
|
||||
if len(chart_colors) < 3:
|
||||
chart_colors = [primary, accent, "#10B981", "#8B5CF6", "#EC4899", "#06B6D4"]
|
||||
|
||||
theme = {
|
||||
"primaryColor": primary,
|
||||
"accentColor": accent,
|
||||
"backgroundColor": bg,
|
||||
"surfaceColor": surface,
|
||||
"textColor": text,
|
||||
"mutedTextColor": muted,
|
||||
"headingFont": typo.get("heading", {}).get("font", "Inter"),
|
||||
"bodyFont": typo.get("body", {}).get("font", "Inter"),
|
||||
"monoFont": typo.get("code", {}).get("font", "JetBrains Mono"),
|
||||
"chartColors": chart_colors[:6],
|
||||
"springConfig": {"damping": 20, "stiffness": 120, "mass": 1},
|
||||
"transitionDuration": 0.4,
|
||||
}
|
||||
|
||||
# Derive caption colors from the palette
|
||||
theme["captionHighlightColor"] = primary
|
||||
# Caption background: semi-transparent version of the bg color
|
||||
theme["captionBackgroundColor"] = (
|
||||
f"rgba(255, 255, 255, 0.85)" if bg.upper() in ("#FFFFFF", "#FAFAFA", "#F9FAFB")
|
||||
else f"rgba(15, 23, 42, 0.75)"
|
||||
)
|
||||
|
||||
# Motion style from playbook
|
||||
motion = playbook.get("motion", {})
|
||||
pace = motion.get("pace", "moderate")
|
||||
if pace == "fast":
|
||||
theme["springConfig"] = {"damping": 12, "stiffness": 80, "mass": 1}
|
||||
theme["transitionDuration"] = 0.3
|
||||
elif pace == "slow":
|
||||
theme["springConfig"] = {"damping": 25, "stiffness": 150, "mass": 1}
|
||||
theme["transitionDuration"] = 0.6
|
||||
|
||||
# Fallback: try to extract from edit_decisions metadata
|
||||
if not theme and composition_data:
|
||||
meta = composition_data.get("metadata", {})
|
||||
if meta.get("primary_color"):
|
||||
theme = {
|
||||
"primaryColor": meta["primary_color"],
|
||||
"accentColor": meta.get("accent_color", "#F59E0B"),
|
||||
"backgroundColor": meta.get("background_color", "#FFFFFF"),
|
||||
"surfaceColor": meta.get("surface_color", "#F9FAFB"),
|
||||
"textColor": meta.get("text_color", "#1F2937"),
|
||||
"mutedTextColor": "#6B7280",
|
||||
"headingFont": meta.get("heading_font", "Inter"),
|
||||
"bodyFont": meta.get("body_font", "Inter"),
|
||||
"monoFont": "JetBrains Mono",
|
||||
"chartColors": meta.get("chart_colors", ["#2563EB", "#F59E0B", "#10B981"]),
|
||||
"springConfig": {"damping": 20, "stiffness": 120, "mass": 1},
|
||||
"transitionDuration": 0.4,
|
||||
"captionHighlightColor": meta["primary_color"],
|
||||
"captionBackgroundColor": "rgba(255, 255, 255, 0.85)",
|
||||
}
|
||||
|
||||
return theme if theme else None
|
||||
|
||||
def _needs_remotion(self, cuts: list[dict]) -> bool:
|
||||
"""Determine if the composition requires Remotion.
|
||||
|
||||
@@ -444,6 +589,105 @@ class VideoCompose(BaseTool):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _pre_compose_validation(
|
||||
self,
|
||||
edit_decisions: dict[str, Any],
|
||||
resolved_cuts: list[dict],
|
||||
scene_plan: list[dict] | None = None,
|
||||
) -> ToolResult | None:
|
||||
"""Pre-compose quality gate — blocks render on critical violations.
|
||||
|
||||
Checks:
|
||||
1. Delivery promise violation: motion-required brief with >70% still cuts → BLOCK
|
||||
2. Slideshow risk score "fail" (average ≥ 4.0) → BLOCK
|
||||
3. Missing renderer_family → WARN (log only, don't block)
|
||||
|
||||
Returns a failed ToolResult if render should be blocked, None if OK to proceed.
|
||||
"""
|
||||
log = logging.getLogger("video_compose")
|
||||
warnings: list[str] = []
|
||||
blocks: list[str] = []
|
||||
|
||||
# --- 1. Delivery promise check ---
|
||||
delivery_data = edit_decisions.get("metadata", {}).get("delivery_promise")
|
||||
if not delivery_data:
|
||||
# Also check top-level (proposal_packet nests it at top level)
|
||||
delivery_data = edit_decisions.get("delivery_promise")
|
||||
|
||||
if delivery_data:
|
||||
try:
|
||||
from lib.delivery_promise import DeliveryPromise
|
||||
promise = DeliveryPromise.from_dict(delivery_data)
|
||||
result = promise.validate_cuts(resolved_cuts)
|
||||
if not result["valid"]:
|
||||
for v in result["violations"]:
|
||||
blocks.append(f"Delivery promise violation: {v}")
|
||||
except Exception as e:
|
||||
log.warning("Could not validate delivery promise: %s", e)
|
||||
else:
|
||||
warnings.append("No delivery_promise in edit_decisions — skipping promise validation")
|
||||
|
||||
# --- 2. Slideshow risk check ---
|
||||
renderer_family = edit_decisions.get("renderer_family")
|
||||
scenes = scene_plan or []
|
||||
|
||||
# If no scene_plan passed, try to extract scene info from cuts
|
||||
if not scenes and resolved_cuts:
|
||||
scenes = [
|
||||
{
|
||||
"type": c.get("type", ""),
|
||||
"description": c.get("reason", ""),
|
||||
"shot_language": c.get("shot_language", {}),
|
||||
"shot_intent": c.get("shot_intent"),
|
||||
"narrative_role": c.get("narrative_role"),
|
||||
"information_role": c.get("information_role"),
|
||||
"hero_moment": c.get("hero_moment", False),
|
||||
}
|
||||
for c in resolved_cuts
|
||||
]
|
||||
|
||||
if scenes:
|
||||
try:
|
||||
from lib.slideshow_risk import score_slideshow_risk
|
||||
risk = score_slideshow_risk(scenes, edit_decisions, renderer_family)
|
||||
if risk["verdict"] == "fail":
|
||||
blocks.append(
|
||||
f"Slideshow risk score {risk['average']:.1f}/5.0 (verdict: fail). "
|
||||
f"Video plan looks like a slideshow — revise scene plan before rendering."
|
||||
)
|
||||
elif risk["verdict"] == "revise":
|
||||
warnings.append(
|
||||
f"Slideshow risk score {risk['average']:.1f}/5.0 (verdict: revise). "
|
||||
f"Consider improving scene variety before final render."
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning("Could not compute slideshow risk: %s", e)
|
||||
|
||||
# --- 3. Missing renderer_family (BLOCK — must be set at proposal) ---
|
||||
if not renderer_family:
|
||||
blocks.append(
|
||||
"No renderer_family in edit_decisions. "
|
||||
"renderer_family must be set at proposal stage and locked before compose. "
|
||||
"Re-run the proposal stage with a renderer_family selection."
|
||||
)
|
||||
|
||||
# Log warnings
|
||||
for w in warnings:
|
||||
log.warning("[pre-compose] %s", w)
|
||||
|
||||
# Block on critical violations
|
||||
if blocks:
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error=(
|
||||
"Pre-compose validation failed — render blocked.\n"
|
||||
+ "\n".join(f" • {b}" for b in blocks)
|
||||
+ ("\n\nWarnings:\n" + "\n".join(f" • {w}" for w in warnings) if warnings else "")
|
||||
),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _render(self, inputs: dict[str, Any]) -> ToolResult:
|
||||
"""High-level render: assemble edit decisions + asset manifest into final video.
|
||||
|
||||
@@ -480,6 +724,12 @@ class VideoCompose(BaseTool):
|
||||
resolved_cut["source"] = asset_lookup[source_id]["path"]
|
||||
resolved_cuts.append(resolved_cut)
|
||||
|
||||
# --- Pre-compose validation gate ---
|
||||
scene_plan = inputs.get("scene_plan")
|
||||
validation_block = self._pre_compose_validation(edit_decisions, resolved_cuts, scene_plan)
|
||||
if validation_block is not None:
|
||||
return validation_block
|
||||
|
||||
# Also accept profile as "output_profile" (skill convention) or "profile"
|
||||
profile = inputs.get("profile") or inputs.get("output_profile")
|
||||
|
||||
@@ -491,30 +741,71 @@ class VideoCompose(BaseTool):
|
||||
}
|
||||
if profile:
|
||||
remotion_inputs["profile"] = profile
|
||||
return self._remotion_render(remotion_inputs)
|
||||
render_result = self._remotion_render(remotion_inputs)
|
||||
|
||||
# --- FFmpeg path: pure video cuts (talking-head, etc.) ---
|
||||
# Handle options
|
||||
options = inputs.get("options", {})
|
||||
subtitle_burn = options.get("subtitle_burn", True)
|
||||
# Governance: NEVER silently fall back to FFmpeg when Remotion fails.
|
||||
# The agent must decide the fallback path, not the tool.
|
||||
if not render_result.success:
|
||||
renderer_family = edit_decisions.get("renderer_family", "unknown")
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error=(
|
||||
f"Remotion render failed for renderer_family={renderer_family!r}. "
|
||||
f"Underlying error: {render_result.error}\n\n"
|
||||
f"This composition requires Remotion (images, text cards, animations). "
|
||||
f"Options:\n"
|
||||
f" 1. Fix Remotion setup (cd remotion-composer && npm install)\n"
|
||||
f" 2. Re-run with operation='compose' for FFmpeg-only (video cuts only)\n"
|
||||
f" 3. Approve a degraded FFmpeg render (still images → Ken Burns)\n\n"
|
||||
f"Per governance: renderer downgrade requires user approval."
|
||||
),
|
||||
)
|
||||
else:
|
||||
# --- FFmpeg path: pure video cuts (talking-head, etc.) ---
|
||||
options = inputs.get("options", {})
|
||||
subtitle_burn = options.get("subtitle_burn", True)
|
||||
|
||||
# Resolve subtitle_path from edit_decisions if not provided
|
||||
subtitle_path = inputs.get("subtitle_path")
|
||||
if subtitle_burn and not subtitle_path:
|
||||
ed_subs = edit_decisions.get("subtitles", {})
|
||||
if ed_subs.get("enabled") and ed_subs.get("source"):
|
||||
subtitle_path = ed_subs["source"]
|
||||
# Resolve subtitle_path from edit_decisions if not provided
|
||||
subtitle_path = inputs.get("subtitle_path")
|
||||
if subtitle_burn and not subtitle_path:
|
||||
ed_subs = edit_decisions.get("subtitles", {})
|
||||
if ed_subs.get("enabled") and ed_subs.get("source"):
|
||||
subtitle_path = ed_subs["source"]
|
||||
|
||||
# Build compose inputs
|
||||
compose_inputs = dict(inputs)
|
||||
compose_inputs["edit_decisions"] = dict(edit_decisions, cuts=resolved_cuts)
|
||||
compose_inputs["output_path"] = str(output_path)
|
||||
if subtitle_path:
|
||||
compose_inputs["subtitle_path"] = subtitle_path
|
||||
if profile:
|
||||
compose_inputs["profile"] = profile
|
||||
# Build compose inputs
|
||||
compose_inputs = dict(inputs)
|
||||
compose_inputs["edit_decisions"] = dict(edit_decisions, cuts=resolved_cuts)
|
||||
compose_inputs["output_path"] = str(output_path)
|
||||
if subtitle_path:
|
||||
compose_inputs["subtitle_path"] = subtitle_path
|
||||
if profile:
|
||||
compose_inputs["profile"] = profile
|
||||
|
||||
return self._compose(compose_inputs)
|
||||
render_result = self._compose(compose_inputs)
|
||||
|
||||
# --- Post-render: mandatory final self-review ---
|
||||
if render_result.success and output_path.exists():
|
||||
final_review = self._run_final_review(output_path, edit_decisions)
|
||||
|
||||
# Attach final_review to the ToolResult data so the compose-director
|
||||
# skill can include it in the checkpoint alongside the render_report.
|
||||
if render_result.data is None:
|
||||
render_result.data = {}
|
||||
render_result.data["final_review"] = final_review
|
||||
render_result.data["final_review_status"] = final_review["status"]
|
||||
|
||||
# If the self-review says fail, downgrade the ToolResult
|
||||
if final_review["status"] == "fail":
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error=(
|
||||
"Post-render self-review FAILED. The output is not presentable.\n"
|
||||
+ "\n".join(f" • {i}" for i in final_review.get("issues_found", []))
|
||||
),
|
||||
data=render_result.data,
|
||||
)
|
||||
|
||||
return render_result
|
||||
|
||||
def _remotion_render(self, inputs: dict[str, Any]) -> ToolResult:
|
||||
"""Render via Remotion (requires Node.js + npx).
|
||||
@@ -554,6 +845,19 @@ class VideoCompose(BaseTool):
|
||||
posix = resolved.as_posix()
|
||||
cut["source"] = f"file:///{posix}" if not posix.startswith("/") else f"file://{posix}"
|
||||
|
||||
# Build a custom themeConfig from the playbook's actual colors.
|
||||
# This ensures every video gets a unique visual identity derived
|
||||
# from its production decisions — not picked from a preset menu.
|
||||
if "themeConfig" not in props:
|
||||
playbook_name = (
|
||||
props.get("playbook")
|
||||
or props.get("theme")
|
||||
or props.get("metadata", {}).get("playbook")
|
||||
)
|
||||
theme_config = self._build_theme_from_playbook(playbook_name, composition_data)
|
||||
if theme_config:
|
||||
props["themeConfig"] = theme_config
|
||||
|
||||
# Write props to temp file for Remotion CLI
|
||||
props_path = output_path.parent / ".remotion_props.json"
|
||||
with open(props_path, "w", encoding="utf-8") as f:
|
||||
@@ -567,10 +871,15 @@ class VideoCompose(BaseTool):
|
||||
error=f"Remotion composer project not found at {composer_dir}",
|
||||
)
|
||||
|
||||
# Route to the correct Remotion composition based on renderer_family.
|
||||
# This prevents all pipelines from collapsing into the Explainer visual grammar.
|
||||
renderer_family = (composition_data or {}).get("renderer_family", "explainer-data")
|
||||
composition_id = self._get_composition_id(renderer_family)
|
||||
|
||||
cmd = [
|
||||
"npx", "remotion", "render",
|
||||
str(composer_dir / "src" / "index.tsx"),
|
||||
"Explainer",
|
||||
composition_id,
|
||||
str(output_path),
|
||||
"--props", str(props_path),
|
||||
]
|
||||
@@ -609,6 +918,359 @@ class VideoCompose(BaseTool):
|
||||
artifacts=[str(output_path)],
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Final self-review — mandatory post-render inspection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _run_final_review(
|
||||
self,
|
||||
output_path: Path,
|
||||
edit_decisions: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run post-render self-review and produce a final_review artifact.
|
||||
|
||||
This is the governance contract: the compose runtime MUST inspect
|
||||
the actual rendered output before marking the stage complete.
|
||||
Never claim a video is ready without a real probe + frame sample.
|
||||
|
||||
Returns a dict conforming to final_review.schema.json.
|
||||
"""
|
||||
log = logging.getLogger("video_compose.final_review")
|
||||
issues: list[str] = []
|
||||
|
||||
# --- 1. Technical probe via ffprobe ---
|
||||
technical_probe: dict[str, Any] = {
|
||||
"valid_container": False,
|
||||
"issues": [],
|
||||
}
|
||||
try:
|
||||
cmd = [
|
||||
"ffprobe", "-v", "quiet", "-print_format", "json",
|
||||
"-show_format", "-show_streams", str(output_path),
|
||||
]
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if proc.returncode == 0:
|
||||
probe_data = json.loads(proc.stdout)
|
||||
fmt = probe_data.get("format", {})
|
||||
streams = probe_data.get("streams", [])
|
||||
video_stream = next(
|
||||
(s for s in streams if s.get("codec_type") == "video"), {}
|
||||
)
|
||||
audio_stream = next(
|
||||
(s for s in streams if s.get("codec_type") == "audio"), {}
|
||||
)
|
||||
|
||||
duration = float(fmt.get("duration", 0))
|
||||
width = int(video_stream.get("width", 0))
|
||||
height = int(video_stream.get("height", 0))
|
||||
fps_str = video_stream.get("r_frame_rate", "0/1")
|
||||
fps = self._parse_probe_fps(fps_str)
|
||||
|
||||
technical_probe = {
|
||||
"valid_container": bool(video_stream),
|
||||
"duration_seconds": round(duration, 2),
|
||||
"resolution": f"{width}x{height}",
|
||||
"fps": fps,
|
||||
"has_audio": bool(audio_stream),
|
||||
"codec": video_stream.get("codec_name", "unknown"),
|
||||
"file_size_bytes": int(fmt.get("size", 0)),
|
||||
"issues": [],
|
||||
}
|
||||
|
||||
# Sanity checks
|
||||
if duration < 1.0:
|
||||
technical_probe["issues"].append(
|
||||
f"Output is only {duration:.1f}s — suspiciously short"
|
||||
)
|
||||
|
||||
# Check target duration from edit_decisions
|
||||
target_dur = None
|
||||
if edit_decisions:
|
||||
target_dur = (
|
||||
edit_decisions.get("total_duration_seconds")
|
||||
or edit_decisions.get("metadata", {}).get("target_duration_seconds")
|
||||
)
|
||||
if target_dur and target_dur > 0:
|
||||
drift_pct = abs(duration - target_dur) / target_dur
|
||||
if drift_pct > 0.25:
|
||||
technical_probe["issues"].append(
|
||||
f"Duration drift: rendered {duration:.1f}s vs target {target_dur}s "
|
||||
f"({drift_pct:.0%} off). Review pacing or trim."
|
||||
)
|
||||
technical_probe["target_duration"] = target_dur
|
||||
technical_probe["duration_drift_pct"] = round(drift_pct * 100, 1)
|
||||
if width < 320 or height < 240:
|
||||
technical_probe["issues"].append(
|
||||
f"Resolution {width}x{height} is very low"
|
||||
)
|
||||
if not audio_stream:
|
||||
technical_probe["issues"].append("No audio stream in output")
|
||||
else:
|
||||
technical_probe["issues"].append(
|
||||
f"ffprobe failed with exit code {proc.returncode}"
|
||||
)
|
||||
except FileNotFoundError:
|
||||
technical_probe["issues"].append("ffprobe not found — cannot validate output")
|
||||
except Exception as e:
|
||||
technical_probe["issues"].append(f"ffprobe error: {e}")
|
||||
|
||||
issues.extend(technical_probe.get("issues", []))
|
||||
|
||||
# --- 2. Visual spotcheck: sample 4 frames ---
|
||||
visual_spotcheck: dict[str, Any] = {
|
||||
"frames_sampled": 0,
|
||||
"frame_paths": [],
|
||||
"black_frames_detected": False,
|
||||
"broken_overlays": False,
|
||||
"missing_assets": False,
|
||||
"unreadable_text": False,
|
||||
"issues": [],
|
||||
}
|
||||
duration = technical_probe.get("duration_seconds", 0)
|
||||
if duration > 0 and technical_probe.get("valid_container"):
|
||||
try:
|
||||
frame_dir = output_path.parent / ".final_review_frames"
|
||||
frame_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Sample at 10%, 35%, 65%, 90% of duration
|
||||
sample_points = [0.10, 0.35, 0.65, 0.90]
|
||||
frame_paths = []
|
||||
for i, pct in enumerate(sample_points):
|
||||
ts = round(duration * pct, 2)
|
||||
frame_path = frame_dir / f"review_frame_{i}.png"
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-ss", str(ts),
|
||||
"-i", str(output_path),
|
||||
"-frames:v", "1", "-q:v", "2",
|
||||
str(frame_path),
|
||||
]
|
||||
subprocess.run(cmd, capture_output=True, timeout=15)
|
||||
if frame_path.exists():
|
||||
frame_paths.append(str(frame_path))
|
||||
|
||||
# Check for black frames (file size heuristic:
|
||||
# a 1920x1080 PNG of pure black is ~5KB)
|
||||
if frame_path.stat().st_size < 2000:
|
||||
visual_spotcheck["black_frames_detected"] = True
|
||||
|
||||
visual_spotcheck["frames_sampled"] = len(frame_paths)
|
||||
visual_spotcheck["frame_paths"] = frame_paths
|
||||
|
||||
if len(frame_paths) < 4:
|
||||
visual_spotcheck["issues"].append(
|
||||
f"Only {len(frame_paths)}/4 frames extracted — some timestamps may be out of range"
|
||||
)
|
||||
if visual_spotcheck["black_frames_detected"]:
|
||||
visual_spotcheck["issues"].append(
|
||||
"Black frame detected — possible missing asset or failed render segment"
|
||||
)
|
||||
except Exception as e:
|
||||
visual_spotcheck["issues"].append(f"Frame sampling error: {e}")
|
||||
|
||||
issues.extend(visual_spotcheck.get("issues", []))
|
||||
|
||||
# --- 3. Audio spotcheck ---
|
||||
audio_spotcheck: dict[str, Any] = {
|
||||
"narration_present": False,
|
||||
"music_present": False,
|
||||
"unexpected_silence": False,
|
||||
"clipping_detected": False,
|
||||
"mix_intelligible": True,
|
||||
"issues": [],
|
||||
}
|
||||
if technical_probe.get("has_audio") and duration > 0:
|
||||
try:
|
||||
# Use ffmpeg volumedetect to check audio levels
|
||||
cmd = [
|
||||
"ffmpeg", "-i", str(output_path),
|
||||
"-af", "volumedetect", "-f", "null", "-",
|
||||
]
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=60
|
||||
)
|
||||
stderr = proc.stderr or ""
|
||||
# Parse mean_volume and max_volume
|
||||
mean_vol = None
|
||||
max_vol = None
|
||||
for line in stderr.split("\n"):
|
||||
if "mean_volume:" in line:
|
||||
try:
|
||||
mean_vol = float(line.split("mean_volume:")[1].strip().split()[0])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
if "max_volume:" in line:
|
||||
try:
|
||||
max_vol = float(line.split("max_volume:")[1].strip().split()[0])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
if mean_vol is not None:
|
||||
if mean_vol < -60:
|
||||
audio_spotcheck["unexpected_silence"] = True
|
||||
audio_spotcheck["issues"].append(
|
||||
f"Mean volume {mean_vol:.1f} dB — effectively silent"
|
||||
)
|
||||
# Assume narration present if mean volume is reasonable
|
||||
if mean_vol > -40:
|
||||
audio_spotcheck["narration_present"] = True
|
||||
# Assume music present if audio exists (conservative)
|
||||
if mean_vol > -50:
|
||||
audio_spotcheck["music_present"] = True
|
||||
|
||||
if max_vol is not None and max_vol > -0.5:
|
||||
audio_spotcheck["clipping_detected"] = True
|
||||
audio_spotcheck["issues"].append(
|
||||
f"Max volume {max_vol:.1f} dB — possible clipping"
|
||||
)
|
||||
except Exception as e:
|
||||
audio_spotcheck["issues"].append(f"Audio analysis error: {e}")
|
||||
|
||||
issues.extend(audio_spotcheck.get("issues", []))
|
||||
|
||||
# --- 4. Promise preservation ---
|
||||
promise_preservation: dict[str, Any] = {
|
||||
"delivery_promise_honored": True,
|
||||
"silent_downgrade_detected": False,
|
||||
"issues": [],
|
||||
}
|
||||
if edit_decisions:
|
||||
renderer_family = edit_decisions.get("renderer_family", "")
|
||||
promise_preservation["renderer_family_used"] = renderer_family
|
||||
|
||||
delivery_data = (
|
||||
edit_decisions.get("metadata", {}).get("delivery_promise")
|
||||
or edit_decisions.get("delivery_promise")
|
||||
)
|
||||
if delivery_data:
|
||||
try:
|
||||
from lib.delivery_promise import DeliveryPromise
|
||||
promise = DeliveryPromise.from_dict(delivery_data)
|
||||
cuts = edit_decisions.get("cuts", [])
|
||||
result = promise.validate_cuts(cuts)
|
||||
motion_ratio = result.get("motion_ratio", 0)
|
||||
promise_preservation["motion_ratio_actual"] = round(motion_ratio, 3)
|
||||
|
||||
if not result["valid"]:
|
||||
promise_preservation["delivery_promise_honored"] = False
|
||||
for v in result["violations"]:
|
||||
promise_preservation["issues"].append(v)
|
||||
|
||||
# Detect silent downgrade: motion-led promise but <50% motion
|
||||
if (delivery_data.get("type") == "motion_led"
|
||||
and motion_ratio < 0.5):
|
||||
promise_preservation["silent_downgrade_detected"] = True
|
||||
promise_preservation["issues"].append(
|
||||
f"Motion-led promise but only {motion_ratio:.0%} motion — "
|
||||
f"silent downgrade to still-led"
|
||||
)
|
||||
except Exception as e:
|
||||
promise_preservation["issues"].append(
|
||||
f"Could not validate delivery promise: {e}"
|
||||
)
|
||||
|
||||
issues.extend(promise_preservation.get("issues", []))
|
||||
|
||||
# --- 5. Subtitle check ---
|
||||
subtitle_check: dict[str, Any] = {
|
||||
"subtitles_expected": False,
|
||||
"subtitles_present": False,
|
||||
"issues": [],
|
||||
}
|
||||
if edit_decisions:
|
||||
ed_subs = edit_decisions.get("subtitles", {})
|
||||
subtitle_check["subtitles_expected"] = bool(ed_subs.get("enabled"))
|
||||
|
||||
# Check if output has subtitle stream
|
||||
if technical_probe.get("valid_container"):
|
||||
try:
|
||||
cmd = [
|
||||
"ffprobe", "-v", "quiet", "-print_format", "json",
|
||||
"-show_streams", "-select_streams", "s",
|
||||
str(output_path),
|
||||
]
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=15
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
sub_data = json.loads(proc.stdout)
|
||||
sub_streams = sub_data.get("streams", [])
|
||||
subtitle_check["subtitles_present"] = len(sub_streams) > 0
|
||||
|
||||
# If subtitles were expected but not found as a stream,
|
||||
# they may be burned in (which is fine — not a failure)
|
||||
if (subtitle_check["subtitles_expected"]
|
||||
and not subtitle_check["subtitles_present"]):
|
||||
# Check if subtitle_path was used (burned in)
|
||||
sub_source = ed_subs.get("source")
|
||||
if sub_source and Path(sub_source).exists():
|
||||
# Burned-in subtitles are not detectable as streams
|
||||
subtitle_check["subtitles_present"] = True
|
||||
subtitle_check["coverage_ratio"] = 1.0
|
||||
else:
|
||||
subtitle_check["issues"].append(
|
||||
"Subtitles expected but not found in output and "
|
||||
"no subtitle source file exists for burn-in"
|
||||
)
|
||||
except Exception as e:
|
||||
subtitle_check["issues"].append(f"Subtitle check error: {e}")
|
||||
|
||||
issues.extend(subtitle_check.get("issues", []))
|
||||
|
||||
# --- 6. Determine overall status ---
|
||||
critical_issues = [
|
||||
i for i in issues
|
||||
if any(kw in i.lower() for kw in [
|
||||
"silent downgrade", "delivery promise violation",
|
||||
"effectively silent", "ffprobe failed", "suspiciously short",
|
||||
])
|
||||
]
|
||||
|
||||
if critical_issues:
|
||||
status = "revise"
|
||||
recommended_action = "re_render"
|
||||
elif issues:
|
||||
status = "pass"
|
||||
recommended_action = "present_to_user"
|
||||
else:
|
||||
status = "pass"
|
||||
recommended_action = "present_to_user"
|
||||
|
||||
if not technical_probe.get("valid_container"):
|
||||
status = "fail"
|
||||
recommended_action = "re_render"
|
||||
|
||||
final_review = {
|
||||
"version": "1.0",
|
||||
"output_path": str(output_path),
|
||||
"status": status,
|
||||
"checks": {
|
||||
"technical_probe": technical_probe,
|
||||
"visual_spotcheck": visual_spotcheck,
|
||||
"audio_spotcheck": audio_spotcheck,
|
||||
"promise_preservation": promise_preservation,
|
||||
"subtitle_check": subtitle_check,
|
||||
},
|
||||
"issues_found": issues,
|
||||
"recommended_action": recommended_action,
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Final review: status=%s, issues=%d, action=%s",
|
||||
status, len(issues), recommended_action,
|
||||
)
|
||||
|
||||
return final_review
|
||||
|
||||
@staticmethod
|
||||
def _parse_probe_fps(fps_str: str) -> float:
|
||||
"""Parse ffprobe fps string like '30/1' or '24000/1001'."""
|
||||
try:
|
||||
if "/" in fps_str:
|
||||
num, den = fps_str.split("/")
|
||||
return round(int(num) / max(int(den), 1), 2)
|
||||
return float(fps_str)
|
||||
except (ValueError, ZeroDivisionError):
|
||||
return 0.0
|
||||
|
||||
def _burn_subtitles(self, inputs: dict[str, Any]) -> ToolResult:
|
||||
"""Burn subtitle file into video."""
|
||||
input_path = Path(inputs["input_path"])
|
||||
@@ -761,15 +1423,62 @@ class VideoCompose(BaseTool):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_subtitle_style(style: dict) -> str:
|
||||
"""Build ASS force_style string from style dict.
|
||||
def _resolve_subtitle_style(
|
||||
explicit_style: dict | None,
|
||||
edit_decisions: dict | None,
|
||||
playbook: dict | None,
|
||||
) -> dict:
|
||||
"""Resolve subtitle style with layered priority.
|
||||
|
||||
Produces modern social-media-style captions by default:
|
||||
bold, outlined, positioned in the lower portion of the frame.
|
||||
Priority: explicit_style > edit_decisions.subtitles.style > playbook > defaults.
|
||||
This prevents every video from looking identical (Arial bold white).
|
||||
"""
|
||||
# Start with minimal fallback defaults
|
||||
resolved = {
|
||||
"font": "Inter",
|
||||
"font_size": 28,
|
||||
"bold": True,
|
||||
"outline_width": 2,
|
||||
"shadow": 0,
|
||||
"margin_v": 40,
|
||||
"alignment": 2,
|
||||
}
|
||||
|
||||
# Layer 1: Playbook-derived style
|
||||
if playbook:
|
||||
typo = playbook.get("typography", {})
|
||||
colors = playbook.get("visual_language", {}).get("color_palette", {})
|
||||
if typo.get("body", {}).get("family"):
|
||||
resolved["font"] = typo["body"]["family"]
|
||||
if colors.get("text"):
|
||||
resolved["primary_color"] = colors["text"]
|
||||
if colors.get("background"):
|
||||
resolved["outline_color"] = colors["background"]
|
||||
# Semi-transparent background for readability
|
||||
bg = colors["background"]
|
||||
resolved["back_color"] = bg
|
||||
|
||||
# Layer 2: edit_decisions subtitle style
|
||||
if edit_decisions:
|
||||
ed_style = edit_decisions.get("subtitles", {}).get("style", {})
|
||||
for k, v in ed_style.items():
|
||||
if v is not None:
|
||||
resolved[k] = v
|
||||
|
||||
# Layer 3: Explicit override (highest priority)
|
||||
if explicit_style:
|
||||
for k, v in explicit_style.items():
|
||||
if v is not None:
|
||||
resolved[k] = v
|
||||
|
||||
return resolved
|
||||
|
||||
@staticmethod
|
||||
def _build_subtitle_style(style: dict) -> str:
|
||||
"""Build ASS force_style string from style dict."""
|
||||
parts = []
|
||||
parts.append(f"FontName={style.get('font', 'Arial')}")
|
||||
parts.append(f"FontSize={style.get('font_size', 16)}")
|
||||
parts.append(f"FontName={style.get('font', 'Inter')}")
|
||||
parts.append(f"FontSize={style.get('font_size', 28)}")
|
||||
parts.append(f"Bold={1 if style.get('bold', True) else 0}")
|
||||
if style.get("primary_color"):
|
||||
parts.append(f"PrimaryColour={style['primary_color']}")
|
||||
@@ -777,11 +1486,10 @@ class VideoCompose(BaseTool):
|
||||
parts.append(f"OutlineColour={style['outline_color']}")
|
||||
if style.get("back_color"):
|
||||
parts.append(f"BackColour={style['back_color']}")
|
||||
# BorderStyle: 1=outline+shadow (default), 4=opaque box
|
||||
border_style = style.get("border_style", 1)
|
||||
parts.append(f"BorderStyle={border_style}")
|
||||
parts.append(f"Outline={style.get('outline_width', 3)}")
|
||||
parts.append(f"Shadow={style.get('shadow', 1)}")
|
||||
parts.append(f"Outline={style.get('outline_width', 2)}")
|
||||
parts.append(f"Shadow={style.get('shadow', 0)}")
|
||||
parts.append(f"MarginV={style.get('margin_v', 40)}")
|
||||
parts.append(f"Alignment={style.get('alignment', 2)}")
|
||||
return ",".join(parts)
|
||||
|
||||
@@ -49,7 +49,7 @@ class VideoSelector(BaseTool):
|
||||
"default": "auto",
|
||||
},
|
||||
"allowed_providers": {"type": "array", "items": {"type": "string"}},
|
||||
"operation": {"type": "string", "enum": ["text_to_video", "image_to_video"], "default": "text_to_video"},
|
||||
"operation": {"type": "string", "enum": ["text_to_video", "image_to_video", "rank"], "default": "text_to_video"},
|
||||
"output_path": {"type": "string"},
|
||||
},
|
||||
}
|
||||
@@ -81,15 +81,38 @@ class VideoSelector(BaseTool):
|
||||
return ToolStatus.UNAVAILABLE
|
||||
|
||||
def estimate_cost(self, inputs: dict[str, object]) -> float:
|
||||
tool = self._select_tool(inputs)
|
||||
candidates = self._providers()
|
||||
if not candidates:
|
||||
return 0.0
|
||||
tool, _ = self._select_best_tool(inputs, candidates, inputs.get("task_context", {}))
|
||||
return tool.estimate_cost(inputs) if tool else 0.0
|
||||
|
||||
def estimate_runtime(self, inputs: dict[str, object]) -> float:
|
||||
tool = self._select_tool(inputs)
|
||||
candidates = self._providers()
|
||||
if not candidates:
|
||||
return 0.0
|
||||
tool, _ = self._select_best_tool(inputs, candidates, inputs.get("task_context", {}))
|
||||
return tool.estimate_runtime(inputs) if tool else 0.0
|
||||
|
||||
def execute(self, inputs: dict[str, object]) -> ToolResult:
|
||||
tool = self._select_tool(inputs)
|
||||
from lib.scoring import rank_providers
|
||||
|
||||
task_context = inputs.get("task_context", {})
|
||||
candidates = self._providers()
|
||||
|
||||
# Rank mode — return scored provider rankings without generating
|
||||
if inputs.get("operation") == "rank":
|
||||
rankings = rank_providers(candidates, task_context)
|
||||
return ToolResult(
|
||||
success=True,
|
||||
data={
|
||||
"rankings": [r.to_dict() for r in rankings],
|
||||
"explanation": "\n".join(r.explain() for r in rankings[:5]),
|
||||
},
|
||||
)
|
||||
|
||||
# Normal generation — use scored selection
|
||||
tool, score = self._select_best_tool(inputs, candidates, task_context)
|
||||
if tool is None:
|
||||
return ToolResult(success=False, error="No video generation provider available.")
|
||||
|
||||
@@ -103,12 +126,30 @@ class VideoSelector(BaseTool):
|
||||
result = tool.execute(adapted)
|
||||
if result.success:
|
||||
result.data.setdefault("selected_tool", tool.name)
|
||||
result.data["selection_reason"] = score.explain() if score else f"Selected {tool.provider} ({tool.name})"
|
||||
if score:
|
||||
result.data["provider_score"] = score.to_dict()
|
||||
result.data["alternatives_considered"] = [
|
||||
t.name for t in candidates
|
||||
if t.name != tool.name and t.get_status().value == "available"
|
||||
]
|
||||
return result
|
||||
|
||||
def _select_tool(self, inputs: dict[str, object]) -> BaseTool | None:
|
||||
def _select_best_tool(
|
||||
self,
|
||||
inputs: dict[str, object],
|
||||
candidates: list[BaseTool],
|
||||
task_context: dict[str, object],
|
||||
) -> tuple[BaseTool | None, object]:
|
||||
"""Select the best provider using scored ranking.
|
||||
|
||||
Respects preferred_provider and environment hints as tie-breakers,
|
||||
but the scoring engine drives the primary selection.
|
||||
"""
|
||||
from lib.scoring import rank_providers, ProviderScore
|
||||
|
||||
preferred = inputs.get("preferred_provider", "auto")
|
||||
allowed = set(inputs.get("allowed_providers") or [])
|
||||
candidates = self._providers()
|
||||
if allowed:
|
||||
candidates = [tool for tool in candidates if tool.provider in allowed]
|
||||
|
||||
@@ -124,13 +165,24 @@ class VideoSelector(BaseTool):
|
||||
if preferred == "auto" and env_hint in env_map:
|
||||
preferred = env_map[env_hint]
|
||||
|
||||
if preferred != "auto":
|
||||
ordered = [tool for tool in candidates if tool.provider == preferred]
|
||||
ordered.extend([tool for tool in candidates if tool.provider != preferred])
|
||||
else:
|
||||
ordered = candidates
|
||||
rankings = rank_providers(candidates, task_context)
|
||||
|
||||
for tool in ordered:
|
||||
if tool.get_status() == ToolStatus.AVAILABLE:
|
||||
return tool
|
||||
return None
|
||||
# Build tool lookup: provider → tool (first available per provider)
|
||||
tool_by_provider: dict[str, BaseTool] = {}
|
||||
for tool in candidates:
|
||||
if tool.provider not in tool_by_provider and tool.get_status() == ToolStatus.AVAILABLE:
|
||||
tool_by_provider[tool.provider] = tool
|
||||
|
||||
# If a preferred provider is explicitly requested and available,
|
||||
# boost it to the top unless its score is drastically worse.
|
||||
if preferred != "auto":
|
||||
for score in rankings:
|
||||
if score.provider == preferred and score.provider in tool_by_provider:
|
||||
return tool_by_provider[score.provider], score
|
||||
|
||||
# Return the highest-scored available provider
|
||||
for score in rankings:
|
||||
if score.provider in tool_by_provider:
|
||||
return tool_by_provider[score.provider], score
|
||||
|
||||
return None, None
|
||||
|
||||
Reference in New Issue
Block a user