Files
calesthio 355fd2b7d6 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)
2026-04-03 09:35:09 -07:00

172 lines
5.3 KiB
Python

"""Music generation tool via ElevenLabs Music API.
Generates background music and sound effects for video production.
Reports unavailable when no API key is configured.
"""
from __future__ import annotations
import os
import time
from pathlib import Path
from typing import Any
from tools.base_tool import (
BaseTool,
Determinism,
ExecutionMode,
ResourceProfile,
RetryPolicy,
ToolResult,
ToolRuntime,
ToolStability,
ToolStatus,
ToolTier,
)
class MusicGen(BaseTool):
name = "music_gen"
version = "0.1.0"
tier = ToolTier.GENERATE
capability = "music_generation"
provider = "elevenlabs"
stability = ToolStability.EXPERIMENTAL
execution_mode = ExecutionMode.SYNC
determinism = Determinism.STOCHASTIC
runtime = ToolRuntime.API
dependencies = [] # checked dynamically via API key
install_instructions = (
"Set the ELEVENLABS_API_KEY environment variable:\n"
" export ELEVENLABS_API_KEY=your_key_here\n"
"Get a key at https://elevenlabs.io"
)
agent_skills = ["music", "sound-effects", "elevenlabs"]
capabilities = [
"generate_background_music",
"generate_sfx",
]
input_schema = {
"type": "object",
"required": ["prompt"],
"properties": {
"prompt": {
"type": "string",
"description": "Music description (mood, genre, instruments, tempo)",
},
"duration_seconds": {
"type": "number",
"minimum": 3,
"maximum": 600,
"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"},
},
}
resource_profile = ResourceProfile(
cpu_cores=1, ram_mb=256, vram_mb=0, disk_mb=50, network_required=True
)
retry_policy = RetryPolicy(max_retries=2, retryable_errors=["rate_limit", "timeout"])
idempotency_key_fields = ["prompt", "duration_seconds"]
side_effects = ["writes audio file to output_path", "calls ElevenLabs API"]
user_visible_verification = [
"Listen to generated music for mood and quality",
]
def get_status(self) -> ToolStatus:
if os.environ.get("ELEVENLABS_API_KEY"):
return ToolStatus.AVAILABLE
return ToolStatus.UNAVAILABLE
def estimate_cost(self, inputs: dict[str, Any]) -> float:
# ElevenLabs music generation pricing is per generation
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)
def execute(self, inputs: dict[str, Any]) -> ToolResult:
api_key = os.environ.get("ELEVENLABS_API_KEY")
if not api_key:
return ToolResult(
success=False,
error="No ElevenLabs API key. " + self.install_instructions,
)
start = time.time()
try:
result = self._generate(inputs, api_key)
except Exception as e:
return ToolResult(success=False, error=f"Music generation failed: {e}")
result.duration_seconds = round(time.time() - start, 2)
result.cost_usd = self.estimate_cost(inputs)
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")
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"
headers = {
"xi-api-key": api_key,
"Content-Type": "application/json",
}
payload = {
"prompt": prompt,
"music_length_ms": int(duration * 1000),
}
response = requests.post(
url, headers=headers, json=payload, timeout=180
)
response.raise_for_status()
output_path = Path(inputs.get("output_path", "music_output.mp3"))
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(response.content)
return ToolResult(
success=True,
data={
"provider": "elevenlabs",
"prompt": prompt,
"duration_seconds": duration,
"output": str(output_path),
"format": "mp3",
},
artifacts=[str(output_path)],
)