Files
video/tools/graphics/image_selector.py
T
calesthio 2cd36fa8e0 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

198 lines
7.9 KiB
Python

"""Capability-level image selector that routes between generation and stock providers.
Provider discovery is automatic — any BaseTool with capability="image_generation"
is picked up from the registry. Adding a new image provider requires only creating
the tool file in tools/graphics/; no changes to this selector are needed.
"""
from __future__ import annotations
from typing import Any
from tools.base_tool import BaseTool, ToolResult, ToolRuntime, ToolStability, ToolStatus, ToolTier
class ImageSelector(BaseTool):
name = "image_selector"
version = "0.2.0"
tier = ToolTier.GENERATE
capability = "image_generation"
provider = "selector"
stability = ToolStability.BETA
runtime = ToolRuntime.HYBRID
agent_skills = ["flux-best-practices", "bfl-api"]
capabilities = [
"generate_image", "search_image", "download_image",
"provider_selection", "text_to_image", "stock_image",
]
supports = {
"user_preference_routing": True,
"offline_fallback": True,
"stock_fallback": True,
}
best_for = [
"preflight routing — pick the best image provider for the task",
"switching between generated and stock images",
"automatic fallback when preferred provider is unavailable",
]
input_schema = {
"type": "object",
"required": ["prompt"],
"properties": {
"prompt": {
"type": "string",
"description": "Image description (used as prompt for generation or query for stock)",
},
"negative_prompt": {
"type": "string",
"description": "What to avoid in the generated image. Passed to providers that support it.",
},
"width": {"type": "integer", "description": "Image width in pixels"},
"height": {"type": "integer", "description": "Image height in pixels"},
"seed": {"type": "integer", "description": "Random seed for reproducibility (generation providers only)"},
"preferred_provider": {
"type": "string",
"description": "Provider name or 'auto'. Valid values are discovered at runtime from the registry.",
"default": "auto",
},
"allowed_providers": {
"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"},
},
}
def _providers(self) -> list[BaseTool]:
"""Auto-discover image generation providers from the registry."""
from tools.tool_registry import registry
registry.ensure_discovered()
return [t for t in registry.get_by_capability("image_generation")
if t.name != self.name]
@property
def fallback_tools(self) -> list[str]:
"""Dynamically built from discovered providers."""
return [t.name for t in self._providers()]
@property
def provider_matrix(self) -> dict[str, dict[str, str]]:
"""Built at runtime from each provider's best_for field."""
matrix = {}
for tool in self._providers():
strength = ", ".join(tool.best_for) if tool.best_for else tool.name
matrix[tool.provider] = {"tool": tool.name, "strength": strength}
return matrix
def get_status(self) -> ToolStatus:
if any(tool.get_status() == ToolStatus.AVAILABLE for tool in self._providers()):
return ToolStatus.AVAILABLE
return ToolStatus.UNAVAILABLE
def estimate_cost(self, inputs: dict[str, Any]) -> float:
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:
import logging
from lib.scoring import rank_providers
logger = logging.getLogger(__name__)
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 image provider available.")
# Adapt input keys: stock tools use 'query' while generators use 'prompt'
adapted = dict(inputs)
if hasattr(tool, 'input_schema'):
props = tool.input_schema.get("properties", {})
if "query" in props and "query" not in adapted:
adapted["query"] = adapted.get("prompt", "")
# Strip selector-only keys that downstream tools don't understand
adapted.pop("preferred_provider", None)
adapted.pop("allowed_providers", None)
# Pass through generation params only to tools that accept them.
if hasattr(tool, 'input_schema'):
props = tool.input_schema.get("properties", {})
stripped = []
for passthrough_key in ("negative_prompt", "width", "height", "seed"):
if passthrough_key in adapted and passthrough_key not in props:
stripped.append(f"{passthrough_key}={adapted.pop(passthrough_key)}")
if stripped:
logger.warning(
"image_selector: stripped unsupported params for %s: %s",
tool.name, ", ".join(stripped),
)
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_best_tool(
self,
inputs: dict[str, Any],
candidates: list[BaseTool],
task_context: dict[str, Any],
) -> tuple[BaseTool | None, object]:
"""Select the best provider using scored ranking."""
from lib.scoring import rank_providers
preferred = inputs.get("preferred_provider", "auto")
allowed = set(inputs.get("allowed_providers") or [])
if allowed:
candidates = [tool for tool in candidates if tool.provider in allowed]
rankings = rank_providers(candidates, task_context)
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