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:
@@ -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 = []
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user