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
@@ -646,7 +646,7 @@ class AudioMixer(BaseTool):
"-of", "csv=p=0",
video_path,
]
total_dur = float(self.run_command(dur_cmd, capture=True).strip().split("\n")[0])
total_dur = float(self.run_command(dur_cmd).stdout.strip().split("\n")[0])
# Build volume expression for each segment with smooth fades
parts = []
+26 -4
View File
@@ -60,10 +60,13 @@ class MusicGen(BaseTool):
},
"duration_seconds": {
"type": "number",
"default": 60,
"minimum": 3,
"maximum": 600,
"description": "Target duration in seconds (API supports 3-600s)",
"description": (
"Target duration in seconds (API supports 3-600s). "
"Should match the target video duration from the script/proposal. "
"Omitting this defaults to 60s which may not match your video."
),
},
"output_path": {"type": "string"},
},
@@ -86,7 +89,13 @@ class MusicGen(BaseTool):
def estimate_cost(self, inputs: dict[str, Any]) -> float:
# ElevenLabs music generation pricing is per generation
duration = inputs.get("duration_seconds", 60)
duration = inputs.get("duration_seconds")
if duration is None:
raise ValueError(
"music_gen.estimate_cost: duration_seconds is required. "
"Derive it from the approved target runtime in the script/proposal. "
"Silent defaults are not permitted."
)
# Approximate: ~$0.05 per 30 seconds
return round(duration / 30 * 0.05, 4)
@@ -110,10 +119,23 @@ class MusicGen(BaseTool):
return result
def _generate(self, inputs: dict[str, Any], api_key: str) -> ToolResult:
import logging
import requests
logger = logging.getLogger(__name__)
prompt = inputs["prompt"]
duration = inputs.get("duration_seconds", 60)
duration = inputs.get("duration_seconds")
if duration is None:
return ToolResult(
success=False,
error=(
"music_gen: duration_seconds is required. "
"Derive it from the approved target runtime in the script/proposal. "
"Silent defaults to 60s are not permitted — the generated music "
"must match the actual video duration."
),
)
url = "https://api.elevenlabs.io/v1/music"
+61 -13
View File
@@ -74,6 +74,12 @@ class TTSSelector(BaseTool):
"type": "array",
"items": {"type": "string"},
},
"operation": {
"type": "string",
"enum": ["generate", "rank"],
"default": "generate",
"description": "Operation mode. 'rank' returns scored provider rankings without generating.",
},
"output_path": {"type": "string"},
},
}
@@ -105,32 +111,74 @@ class TTSSelector(BaseTool):
return ToolStatus.UNAVAILABLE
def estimate_cost(self, inputs: dict[str, Any]) -> 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 execute(self, inputs: dict[str, Any]) -> 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 TTS provider available.")
result = tool.execute(inputs)
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, Any]) -> BaseTool | None:
def _select_best_tool(
self,
inputs: dict[str, Any],
candidates: list[BaseTool],
task_context: dict[str, Any],
) -> tuple[BaseTool | None, object]:
"""Select the best TTS provider using scored ranking."""
from lib.scoring import rank_providers
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]
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
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 preferred != "auto":
for score_item in rankings:
if score_item.provider == preferred and score_item.provider in tool_by_provider:
return tool_by_provider[score_item.provider], score_item
for score_item in rankings:
if score_item.provider in tool_by_provider:
return tool_by_provider[score_item.provider], score_item
return None, None