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
+1 -1
View File
@@ -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
View File
@@ -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)
+67 -15
View File
@@ -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