From 280400d479cd652e2dc9c09d45b71a60b1d13383 Mon Sep 17 00:00:00 2001 From: calesthio Date: Thu, 2 Jul 2026 12:19:06 -0700 Subject: [PATCH] Ship Backlot living storyboard release hardening --- backlot/server.py | 4 + backlot/ui/board.css | 73 ++++++++ backlot/ui/board.js | 10 +- backlot/ui/library.js | 3 +- tests/backlot/test_ui_bug_bash.py | 110 +++++++++++ tests/contracts/test_phase3_contracts.py | 20 +- tests/tools/test_documentary_governance.py | 195 +++++++++++++++++++ tests/tools/test_hyperframes_compose.py | 39 ++++ tools/audio/piper_tts.py | 6 +- tools/video/direct_clip_search.py | 206 ++++++++++++++++++--- tools/video/video_compose.py | 75 ++++---- 11 files changed, 674 insertions(+), 67 deletions(-) create mode 100644 tests/backlot/test_ui_bug_bash.py diff --git a/backlot/server.py b/backlot/server.py index 854cc938..f2220d14 100644 --- a/backlot/server.py +++ b/backlot/server.py @@ -266,6 +266,10 @@ def create_app() -> FastAPI: async def board_page(project_id: str) -> FileResponse: return FileResponse(UI_DIR / "board.html") + @app.get("/p/{project_path:path}") + async def board_page_path(project_path: str) -> FileResponse: + return FileResponse(UI_DIR / "board.html") + @app.get("/") async def library_page() -> FileResponse: return FileResponse(UI_DIR / "index.html") diff --git a/backlot/ui/board.css b/backlot/ui/board.css index 9ae3c2f8..45655970 100644 --- a/backlot/ui/board.css +++ b/backlot/ui/board.css @@ -532,3 +532,76 @@ body:not(.first) .lib-card, body:not(.first) .drawer { animation: none; } .stage.stalled .node { border-color: var(--red); color: var(--red); background: var(--red-dim); animation: none; } .stage.stalled .name { color: var(--red); } .stage.stalled .sub { color: var(--red); } + +/* responsive project board */ +@media (max-width: 900px) { + .wrap { max-width: none; width: 100%; padding: 0 18px 64px; overflow-x: clip; } + .slate { flex-wrap: wrap; align-items: flex-start; gap: 10px 12px; } + .slate > div:nth-child(2) { min-width: 0; flex: 1 1 240px; } + .slate h1 { overflow-wrap: anywhere; } + .slate .spacer { display: none; } + .cost { text-align: left; } + .cost .bar { width: min(150px, 38vw); } + + .rail { + overflow-x: auto; + overscroll-behavior-x: contain; + padding: 18px 0 16px; + scrollbar-width: thin; + } + .stage { flex: 0 0 82px; } + .stage .name { font-size: 10px; max-width: 76px; overflow-wrap: anywhere; text-align: center; } + .stage .sub { max-width: 76px; font-size: 9.5px; } + + .board { display: block; } + .main-col, aside { width: 100%; min-width: 0; } + aside { margin-top: 20px; } + aside .panel + .panel { margin-top: 14px; } + + .script-card { + width: 100%; + max-width: 700px; + padding: 28px 32px 26px; + } + .filmstrip { + max-width: 100%; + overflow-x: auto; + overscroll-behavior-x: contain; + padding-left: 4px; + padding-right: 4px; + } + .section-title { flex-wrap: wrap; } + .section-title .meta { margin-left: 0; } +} + +@media (max-width: 520px) { + .wrap { padding: 0 12px 52px; } + .slate { padding-top: 14px; } + .clapper { width: 30px; height: 23px; } + .slate .wordmark { font-size: 10px; } + .slate h1 { font-size: 15px; letter-spacing: .06em; } + .chip { font-size: 9.5px; padding: 3px 7px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; } + .live { font-size: 10px; letter-spacing: .1em; } + .cost { width: 100%; } + .cost .bar { width: 100%; } + + .rail { margin: 0 -12px; padding-left: 12px; padding-right: 12px; } + .stage { flex-basis: 74px; } + .stage .name, .stage .sub { max-width: 68px; } + + .script-card { + padding: 24px 20px 28px; + border-radius: 5px; + } + .script-approved { top: 14px; right: 16px; font-size: 9px; padding: 2px 6px; } + .script-card .sp-title { font-size: 14px; padding-right: 58px; } + .script-card .sp-meta { margin-bottom: 18px; } + .script-card .sp-slug .tc { float: none; display: block; margin-top: 2px; } + .script-card .sp-expand { right: 12px; bottom: 10px; } + + .panel-head { flex-wrap: wrap; } + .panel-head .meta { margin-left: 0; } + .drawer .drawer-head { flex-wrap: wrap; } + .drawer pre { font-size: 10.5px; } + .scene-card { max-width: calc(100vw - 42px); } +} diff --git a/backlot/ui/board.js b/backlot/ui/board.js index 1f2bcc30..5eb8246e 100644 --- a/backlot/ui/board.js +++ b/backlot/ui/board.js @@ -5,7 +5,9 @@ import { getJSON, mediaURL, subscribe, thumbURL, waveBars, } from "/ui/lib.js"; -const projectId = decodeURIComponent(location.pathname.split("/p/")[1] || ""); +const rawProjectPath = location.pathname.split("/p/")[1] || ""; +const projectId = decodeURIComponent(rawProjectPath); +const encodedProjectId = encodeURIComponent(projectId); const app = document.getElementById("app"); const modal = document.getElementById("modal"); const player = document.getElementById("player"); @@ -411,7 +413,11 @@ function sceneCard(s, card) { if (card.takes.length > 1) { const takes = el("div", { class: "takes" }); card.takes.forEach((t, i) => { - const isActive = t === card.visual; + const isActive = card.visual && ( + t === card.visual + || (t.path && t.path === card.visual.path) + || (t.id && t.id === card.visual.id) + ); const tk = el("span", { class: `tk${isActive ? " active" : ""}`, title: `take ${i + 1}` }); if (t.exists && t.type === "image") tk.append(el("img", { src: thumbURL(s.project_id, t.path, 320), loading: "lazy", alt: "" })); takes.append(tk); diff --git a/backlot/ui/library.js b/backlot/ui/library.js index 07222432..5852f866 100644 --- a/backlot/ui/library.js +++ b/backlot/ui/library.js @@ -35,7 +35,8 @@ function card(p) { el("span", { class: "when" }, fmtAgo(p.last_activity)), ); - return el("a", { class: `lib-card${p.live ? " live-card" : ""}`, href: `/p/${p.project_id}`, style: "text-decoration:none;color:inherit" }, + const staticSuffix = new URLSearchParams(location.search).has("static") ? "?static=1" : ""; + return el("a", { class: `lib-card${p.live ? " live-card" : ""}`, href: `/p/${p.project_id}${staticSuffix}`, style: "text-decoration:none;color:inherit" }, poster, el("div", { class: "lib-body" }, el("h3", {}, (p.title || p.project_id).toUpperCase()), diff --git a/tests/backlot/test_ui_bug_bash.py b/tests/backlot/test_ui_bug_bash.py new file mode 100644 index 00000000..16ead774 --- /dev/null +++ b/tests/backlot/test_ui_bug_bash.py @@ -0,0 +1,110 @@ +"""Browser regressions from the Backlot UI bug bash.""" + +from __future__ import annotations + +import os +import subprocess +import sys +import time +import urllib.request + +import pytest + +from scripts import backlot_screenshot_stage + + +pytest.importorskip("playwright.sync_api") +from playwright.sync_api import sync_playwright # noqa: E402 + + +@pytest.fixture(scope="module") +def staged_backlot_server(): + backlot_screenshot_stage.build_stage() + port = 4897 + env = dict(os.environ) + env["OPENMONTAGE_PROJECTS_DIR"] = str(backlot_screenshot_stage.STAGE_DIR) + server = subprocess.Popen( + [sys.executable, "-m", "backlot", "serve", "--port", str(port)], + cwd=backlot_screenshot_stage.REPO_ROOT, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + deadline = time.time() + 20 + while time.time() < deadline: + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=1): + break + except Exception: + time.sleep(0.2) + else: + server.terminate() + raise RuntimeError("Backlot server did not become healthy") + + try: + yield f"http://127.0.0.1:{port}" + finally: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + + +def test_project_pages_fit_mobile_and_tablet_widths(staged_backlot_server): + project_paths = [ + "/p/signal-in-the-static?static=1", + "/p/the-slow-orchard?static=1", + "/p/the-last-lighthouse?static=1", + "/p/paper-boats?static=1", + ] + viewports = [ + {"width": 390, "height": 844}, + {"width": 768, "height": 1024}, + ] + + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + try: + for viewport in viewports: + page.set_viewport_size(viewport) + for path in project_paths: + page.goto(staged_backlot_server + path, wait_until="networkidle") + page.wait_for_timeout(300) + sizes = page.evaluate( + """() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth + })""" + ) + assert sizes["scrollWidth"] <= sizes["clientWidth"], ( + path, + viewport, + sizes, + ) + finally: + browser.close() + + +def test_static_navigation_invalid_route_and_active_takes(staged_backlot_server): + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1560, "height": 1000}) + try: + page.goto(staged_backlot_server + "/?static=1", wait_until="networkidle") + href = page.locator("a.lib-card").first.get_attribute("href") + assert href and "static=1" in href + + response = page.goto( + staged_backlot_server + "/p/..%2FAGENT_GUIDE.md?static=1", + wait_until="networkidle", + ) + assert response and response.status == 200 + assert "PROJECT NOT FOUND" in page.locator("body").inner_text() + + page.goto(staged_backlot_server + "/p/the-last-lighthouse?static=1", wait_until="networkidle") + page.wait_for_timeout(300) + assert page.locator(".takes .tk.active").count() >= 1 + finally: + browser.close() diff --git a/tests/contracts/test_phase3_contracts.py b/tests/contracts/test_phase3_contracts.py index 17b93adb..1bd8aaa7 100644 --- a/tests/contracts/test_phase3_contracts.py +++ b/tests/contracts/test_phase3_contracts.py @@ -5,6 +5,8 @@ stage director skills, meta skills, and the animated-explainer pipeline. """ import sys +import builtins +import shutil from pathlib import Path import pytest @@ -23,7 +25,7 @@ from lib.pipeline_loader import ( from lib.checkpoint import STAGES from schemas.artifacts import list_schemas from styles.playbook_loader import load_playbook, list_playbooks, validate_playbook -from tools.base_tool import ToolTier +from tools.base_tool import ToolTier, ToolStatus from tools.audio.music_gen import MusicGen from tools.tool_registry import ToolRegistry from tools.audio.elevenlabs_tts import ElevenLabsTTS @@ -73,6 +75,22 @@ class TestPiperTTS: assert "text_to_speech" in tool.capabilities assert "offline_generation" in tool.capabilities + def test_status_requires_piper_executable_even_if_python_package_imports(self, monkeypatch): + """F-12 regression: Piper generation shells out to `piper`, so importing + the Python package is not enough to mark the provider available.""" + original_import = builtins.__import__ + original_which = shutil.which + + def fake_import(name, *args, **kwargs): + if name == "piper": + return object() + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(shutil, "which", lambda cmd: None if cmd == "piper" else original_which(cmd)) + monkeypatch.setattr(builtins, "__import__", fake_import) + + assert PiperTTS().get_status() == ToolStatus.UNAVAILABLE + class TestMusicGen: def test_identity(self): diff --git a/tests/tools/test_documentary_governance.py b/tests/tools/test_documentary_governance.py index bb96b73c..8f498c95 100644 --- a/tests/tools/test_documentary_governance.py +++ b/tests/tools/test_documentary_governance.py @@ -4,7 +4,9 @@ from pathlib import Path from tools.base_tool import ToolStatus from tools.tool_registry import ToolRegistry +from tools.video.stock_sources import Candidate from tools.video.corpus_builder import CorpusBuilder +from tools.video.direct_clip_search import DirectClipSearch from tools.video.video_compose import VideoCompose @@ -196,3 +198,196 @@ def test_provider_menu_preserves_tool_discovery_metadata(monkeypatch): assert entry["name"] == "corpus_builder" assert entry["source_provider_summary"]["configured"] == 1 assert entry["source_provider_menu"][0]["name"] == "archive_org" + + +def test_direct_clip_search_honors_overall_timeout(monkeypatch, tmp_path): + """F-13 regression: direct clip search must stop on its own deadline and + return partial progress instead of relying on an external PTY interrupt.""" + import tools.video.direct_clip_search as direct_clip_search + import tools.video.stock_sources as stock_sources + + class SlowSource(_DummySource): + def search(self, query: str, filters): + return [ + Candidate( + source=self.name, + source_id="slow-1", + source_url="https://example.test/slow-1", + download_url="https://example.test/slow-1.mp4", + kind="video", + ) + ] + + def download(self, candidate, out_path: Path): + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_bytes(b"0" * 2048) + return out_path + + source = SlowSource("slow_source", True) + monkeypatch.setattr(stock_sources, "all_sources", lambda: [source]) + monkeypatch.setattr(stock_sources, "available_sources", lambda: [source]) + monkeypatch.setattr( + stock_sources, + "source_summary", + lambda: { + "configured": 1, + "total": 1, + "available_source_names": ["slow_source"], + "unavailable_source_names": [], + }, + ) + + ticks = iter([0.0, 2.0, 2.0, 2.0]) + monkeypatch.setattr(direct_clip_search.time, "time", lambda: next(ticks, 2.0)) + + result = DirectClipSearch().execute( + { + "output_dir": str(tmp_path / "clips"), + "queries": [{"query": "foggy harbor", "slot_id": "sc5"}], + "timeout_seconds": 1, + "extract_thumbnails": False, + } + ) + + assert not result.success + assert "timed out" in (result.error or "").lower() + assert result.data["timed_out"] is True + assert result.data["phase"] in {"query", "search", "download"} + assert result.data["clips"] == [] + + +def test_direct_clip_search_times_out_streaming_download(monkeypatch, tmp_path): + """F-13 regression: a streaming adapter download must not run past the + tool-level deadline just because bytes keep arriving.""" + import tools.video.direct_clip_search as direct_clip_search + import tools.video.stock_sources as stock_sources + import requests + + clock = {"now": 0.0} + + class StreamingResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def raise_for_status(self): + return None + + def iter_content(self, chunk_size=1024): + clock["now"] = 2.0 + yield b"0" * 2048 + + class StreamingSource(_DummySource): + def search(self, query: str, filters): + return [ + Candidate( + source=self.name, + source_id="stream-1", + source_url="https://example.test/stream-1", + download_url="https://example.test/stream-1.mp4", + kind="video", + ) + ] + + def download(self, candidate, out_path: Path): + out_path.parent.mkdir(parents=True, exist_ok=True) + with requests.get(candidate.download_url, stream=True, timeout=300) as response: + response.raise_for_status() + with out_path.open("wb") as f: + for chunk in response.iter_content(chunk_size=1024): + if chunk: + f.write(chunk) + return out_path + + source = StreamingSource("streaming_source", True) + monkeypatch.setattr(stock_sources, "all_sources", lambda: [source]) + monkeypatch.setattr(stock_sources, "available_sources", lambda: [source]) + monkeypatch.setattr( + stock_sources, + "source_summary", + lambda: { + "configured": 1, + "total": 1, + "available_source_names": ["streaming_source"], + "unavailable_source_names": [], + }, + ) + monkeypatch.setattr(direct_clip_search.time, "time", lambda: clock["now"]) + monkeypatch.setattr(requests, "get", lambda *args, **kwargs: StreamingResponse()) + + result = DirectClipSearch().execute( + { + "output_dir": str(tmp_path / "clips"), + "queries": [{"query": "foggy harbor", "slot_id": "sc5"}], + "timeout_seconds": 1, + "extract_thumbnails": False, + } + ) + + assert not result.success + assert result.data["timed_out"] is True + assert result.data["phase"] == "download" + assert result.data["clips"] == [] + + +def test_direct_clip_search_reports_downloaded_clip_when_thumbnail_times_out( + monkeypatch, tmp_path +): + """F-13 regression: timeout data should include a clip that was already + downloaded and validated before thumbnail extraction hit the deadline.""" + import tools.video.direct_clip_search as direct_clip_search + import tools.video.stock_sources as stock_sources + + clock = {"now": 0.0} + + class SlowThumbnailSource(_DummySource): + def search(self, query: str, filters): + return [ + Candidate( + source=self.name, + source_id="thumb-1", + source_url="https://example.test/thumb-1", + download_url="https://example.test/thumb-1.mp4", + kind="video", + ) + ] + + def download(self, candidate, out_path: Path): + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_bytes(b"0" * 2048) + clock["now"] = 2.0 + return out_path + + source = SlowThumbnailSource("thumb_source", True) + monkeypatch.setattr(stock_sources, "all_sources", lambda: [source]) + monkeypatch.setattr(stock_sources, "available_sources", lambda: [source]) + monkeypatch.setattr( + stock_sources, + "source_summary", + lambda: { + "configured": 1, + "total": 1, + "available_source_names": ["thumb_source"], + "unavailable_source_names": [], + }, + ) + monkeypatch.setattr(direct_clip_search.time, "time", lambda: clock["now"]) + + result = DirectClipSearch().execute( + { + "output_dir": str(tmp_path / "clips"), + "queries": [{"query": "foggy harbor", "slot_id": "sc5"}], + "timeout_seconds": 1, + "extract_thumbnails": True, + } + ) + + assert not result.success + assert result.data["timed_out"] is True + assert result.data["phase"] == "thumbnail" + assert result.data["clips_downloaded"] == 1 + assert result.data["total_clips"] == 1 + assert result.data["clips"][0]["clip_id"] == "thumb_source_thumb-1" + assert result.data["clips"][0]["thumbnail"] == "" diff --git a/tests/tools/test_hyperframes_compose.py b/tests/tools/test_hyperframes_compose.py index dc465c3c..41997990 100644 --- a/tests/tools/test_hyperframes_compose.py +++ b/tests/tools/test_hyperframes_compose.py @@ -870,6 +870,45 @@ def test_video_compose_blocks_hyperframes_when_runtime_unavailable( assert "blocker" in err or "not available" in err +def test_video_compose_honors_hyperframes_runtime_before_atelier_mode( + tmp_path, monkeypatch +): + """Regression for F-14: composition_mode='atelier' must not force the + Remotion atelier branch when render_runtime='hyperframes' is locked.""" + + monkeypatch.setattr( + VideoCompose, "_hyperframes_available", lambda self: False, raising=True + ) + + result = VideoCompose().execute( + { + "operation": "render", + "edit_decisions": { + "version": "1.0", + "cuts": [ + { + "id": "c1", + "source": "a1", + "in_seconds": 0, + "out_seconds": 3, + } + ], + "render_runtime": "hyperframes", + "composition_mode": "atelier", + "renderer_family": "animation-first", + }, + "asset_manifest": {"assets": [{"id": "a1", "path": "does-not-matter.png"}]}, + "output_path": str(tmp_path / "out.mp4"), + } + ) + + assert not result.success + err = (result.error or "").lower() + assert "hyperframes" in err + assert "not available" in err or "blocker" in err + assert "remotion entry" not in err + + # ------------------------------------------------------------------ # Scaffold / workspace generation (no CLI invocation) # ------------------------------------------------------------------ diff --git a/tools/audio/piper_tts.py b/tools/audio/piper_tts.py index 090fbd3a..cd44d255 100644 --- a/tools/audio/piper_tts.py +++ b/tools/audio/piper_tts.py @@ -98,11 +98,7 @@ class PiperTTS(BaseTool): def get_status(self) -> ToolStatus: if shutil.which("piper"): return ToolStatus.AVAILABLE - try: - import piper # noqa: F401 - return ToolStatus.AVAILABLE - except ImportError: - return ToolStatus.UNAVAILABLE + return ToolStatus.UNAVAILABLE def estimate_cost(self, inputs: dict[str, Any]) -> float: return 0.0 diff --git a/tools/video/direct_clip_search.py b/tools/video/direct_clip_search.py index 53a118c5..45b9a782 100644 --- a/tools/video/direct_clip_search.py +++ b/tools/video/direct_clip_search.py @@ -33,6 +33,7 @@ No CLIP model. No embeddings. No corpus index. Just files on disk. """ from __future__ import annotations +from contextlib import contextmanager import subprocess import time import urllib.parse @@ -53,6 +54,10 @@ from tools.base_tool import ( ) +class _DeadlineExceeded(TimeoutError): + """Raised when the direct-clip-search wall-clock deadline is exhausted.""" + + class DirectClipSearch(BaseTool): name = "direct_clip_search" version = "0.1.0" @@ -178,6 +183,16 @@ class DirectClipSearch(BaseTool): "default": True, "description": "Skip download if a file with the same clip_id already exists.", }, + "timeout_seconds": { + "type": "number", + "default": 600, + "minimum": 1, + "description": ( + "Overall wall-clock deadline for search, download, and thumbnail " + "work. Defaults to 10 minutes. On timeout, returns partial progress " + "instead of relying on an external process interrupt." + ), + }, }, } @@ -245,6 +260,8 @@ class DirectClipSearch(BaseTool): clips_per_query = int(inputs.get("clips_per_query", 3)) extract_thumbs = bool(inputs.get("extract_thumbnails", True)) skip_existing = bool(inputs.get("skip_existing", True)) + timeout_seconds = float(inputs.get("timeout_seconds", 600)) + deadline = start + timeout_seconds clips_dir = output_dir / "clips" thumbs_dir = output_dir / "thumbnails" @@ -295,9 +312,53 @@ class DirectClipSearch(BaseTool): errors: list[dict] = [] skipped = 0 per_source_counts: dict[str, int] = {s.name: 0 for s in sources} + queries_started = 0 + + def timeout_result( + *, + phase: str, + query: str = "", + source: str = "", + clip_id: str = "", + ) -> ToolResult: + elapsed = time.time() - start + return ToolResult( + success=False, + error=( + f"Direct clip search timed out after {timeout_seconds:.1f}s " + f"during {phase}." + ), + data={ + "timed_out": True, + "phase": phase, + "query": query, + "source": source, + "clip_id": clip_id, + "output_dir": str(output_dir), + "clips_downloaded": len([d for d in downloaded if not d.get("skipped_existing")]), + "clips_reused": skipped, + "total_clips": len(downloaded), + "per_source_counts": per_source_counts, + "queries_run": queries_started, + "resolved_sources": [s.name for s in sources], + "clips": downloaded, + "errors": errors[:25], + "elapsed_seconds": round(elapsed, 2), + "timeout_seconds": timeout_seconds, + }, + cost_usd=0.0, + duration_seconds=round(elapsed, 2), + ) + + def timed_out() -> bool: + return time.time() >= deadline for q_spec in queries: + if timed_out(): + return timeout_result(phase="query", query=q_spec.get("query", "")) + query = q_spec["query"] + queries_started += 1 slot_id = q_spec.get("slot_id", "") kind = q_spec.get("kind", "video") collected_for_query = 0 @@ -312,11 +373,17 @@ class DirectClipSearch(BaseTool): ) for src in sources: + if timed_out(): + return timeout_result(phase="search", query=query, source=src.name) + if collected_for_query >= clips_per_query: break try: - candidates = src.search(query, filters) + with _requests_deadline(deadline): + candidates = src.search(query, filters) + except _DeadlineExceeded: + return timeout_result(phase="search", query=query, source=src.name) except Exception as e: errors.append({ "phase": "search", @@ -327,6 +394,14 @@ class DirectClipSearch(BaseTool): continue for cand in candidates: + if timed_out(): + return timeout_result( + phase="download", + query=query, + source=src.name, + clip_id=cand.clip_id, + ) + if collected_for_query >= clips_per_query: break @@ -362,7 +437,15 @@ class DirectClipSearch(BaseTool): # Download try: - src.download(cand, clip_path) + with _requests_deadline(deadline): + src.download(cand, clip_path) + except _DeadlineExceeded: + return timeout_result( + phase="download", + query=query, + source=src.name, + clip_id=clip_id, + ) except Exception as e: errors.append({ "phase": "download", @@ -386,21 +469,7 @@ class DirectClipSearch(BaseTool): pass continue - # Extract thumbnail - thumb_path_str = "" - if extract_thumbs and cand.kind == "video": - thumb_path = thumbs_dir / f"{clip_id}.jpg" - try: - _extract_mid_thumbnail(clip_path, thumb_path) - if thumb_path.exists(): - thumb_path_str = str(thumb_path) - except Exception: - pass # thumbnail failure is non-fatal - - per_source_counts[src.name] = per_source_counts.get(src.name, 0) + 1 - collected_for_query += 1 - - downloaded.append({ + downloaded_record = { "clip_id": clip_id, "source": cand.source, "source_id": cand.source_id, @@ -409,7 +478,7 @@ class DirectClipSearch(BaseTool): "slot_id": slot_id, "kind": cand.kind, "path": str(clip_path), - "thumbnail": thumb_path_str, + "thumbnail": "", "duration": cand.duration, "width": cand.width, "height": cand.height, @@ -417,7 +486,38 @@ class DirectClipSearch(BaseTool): "license": cand.license, "source_tags": cand.source_tags, "skipped_existing": False, - }) + } + downloaded.append(downloaded_record) + per_source_counts[src.name] = per_source_counts.get(src.name, 0) + 1 + collected_for_query += 1 + + # Extract thumbnail + if extract_thumbs and cand.kind == "video": + if timed_out(): + return timeout_result( + phase="thumbnail", + query=query, + source=src.name, + clip_id=clip_id, + ) + thumb_path = thumbs_dir / f"{clip_id}.jpg" + try: + _extract_mid_thumbnail( + clip_path, + thumb_path, + timeout_seconds=remaining_seconds(deadline), + ) + if thumb_path.exists(): + downloaded_record["thumbnail"] = str(thumb_path) + except _DeadlineExceeded: + return timeout_result( + phase="thumbnail", + query=query, + source=src.name, + clip_id=clip_id, + ) + except Exception: + pass # thumbnail failure is non-fatal elapsed = time.time() - start @@ -429,7 +529,7 @@ class DirectClipSearch(BaseTool): "clips_reused": skipped, "total_clips": len(downloaded), "per_source_counts": per_source_counts, - "queries_run": len(queries), + "queries_run": queries_started, "resolved_sources": [s.name for s in sources], "clips": downloaded, "errors": errors[:25], @@ -462,7 +562,64 @@ def _guess_ext(cand) -> str: return ".mp4" if cand.kind == "video" else ".jpg" -def _extract_mid_thumbnail(video_path: Path, thumb_path: Path) -> None: +def remaining_seconds(deadline: float) -> float: + remaining = deadline - time.time() + if remaining <= 0: + raise _DeadlineExceeded("direct_clip_search deadline exceeded") + return remaining + + +def _clamp_timeout(timeout: Any, remaining: float) -> Any: + if timeout is None: + return remaining + if isinstance(timeout, tuple): + return tuple(min(float(part), remaining) for part in timeout) + try: + return min(float(timeout), remaining) + except (TypeError, ValueError): + return remaining + + +@contextmanager +def _requests_deadline(deadline: float): + """Clamp adapter requests calls to the direct-search deadline. + + Stock-source adapters are intentionally simple and call `requests.get` + directly. Keeping the deadline wrapper here avoids widening every adapter + method signature while still preventing streaming downloads from running + past the tool-level budget. + """ + import requests + + original_get = requests.get + + def get_with_deadline(*args, **kwargs): + remaining = remaining_seconds(deadline) + kwargs["timeout"] = _clamp_timeout(kwargs.get("timeout"), remaining) + response = original_get(*args, **kwargs) + original_iter_content = getattr(response, "iter_content", None) + if callable(original_iter_content): + def iter_content_with_deadline(*iter_args, **iter_kwargs): + for chunk in original_iter_content(*iter_args, **iter_kwargs): + remaining_seconds(deadline) + yield chunk + + response.iter_content = iter_content_with_deadline + return response + + requests.get = get_with_deadline + try: + yield + finally: + requests.get = original_get + + +def _extract_mid_thumbnail( + video_path: Path, + thumb_path: Path, + *, + timeout_seconds: float = 15, +) -> None: """Extract a single frame from the middle of the video via ffmpeg. This is deliberately simple — one frame, no CLIP, no motion score. @@ -470,6 +627,7 @@ def _extract_mid_thumbnail(video_path: Path, thumb_path: Path) -> None: clip is a good match. """ thumb_path.parent.mkdir(parents=True, exist_ok=True) + deadline = time.time() + timeout_seconds # Probe duration first probe_cmd = [ @@ -479,8 +637,9 @@ def _extract_mid_thumbnail(video_path: Path, thumb_path: Path) -> None: str(video_path), ] try: + probe_timeout = min(10, remaining_seconds(deadline)) result = subprocess.run( - probe_cmd, capture_output=True, text=True, timeout=10 + probe_cmd, capture_output=True, text=True, timeout=probe_timeout ) duration = float(result.stdout.strip() or "0") except (ValueError, subprocess.TimeoutExpired, FileNotFoundError): @@ -497,7 +656,8 @@ def _extract_mid_thumbnail(video_path: Path, thumb_path: Path) -> None: "-q:v", "3", str(thumb_path), ] + extract_timeout = min(15, remaining_seconds(deadline)) subprocess.run( - extract_cmd, capture_output=True, timeout=15, + extract_cmd, capture_output=True, timeout=extract_timeout, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) diff --git a/tools/video/video_compose.py b/tools/video/video_compose.py index c2aa7e38..8012cea4 100644 --- a/tools/video/video_compose.py +++ b/tools/video/video_compose.py @@ -17,9 +17,11 @@ Routing is driven by `edit_decisions.render_runtime` (locked at proposal): Authoring mode is orthogonal to runtime. Setting `edit_decisions.composition_mode = "atelier"` (or `renderer_family="bespoke"`) -routes to a hand-authored, project-local Remotion composition that BYPASSES the -cut-schema and the stock scene-type registry entirely — the "hand-stitched -every time" path for hero/bespoke pieces. See `_render_via_atelier`. +means the composition is hand-authored rather than assembled from stock scene +components. Runtime still wins first: HyperFrames atelier routes through +`hyperframes_compose`, FFmpeg stays FFmpeg-only, and only Remotion atelier uses +`_render_via_atelier` for a project-local Remotion entry that bypasses the +cut-schema and stock scene-type registry. Silent runtime swaps are forbidden by governance. If the chosen runtime is unavailable or fails, this tool surfaces a structured blocker and waits for @@ -1293,6 +1295,36 @@ class VideoCompose(BaseTool): if not edit_decisions: return ToolResult(success=False, error="edit_decisions required for render") + # --- Runtime routing: honor render_runtime locked at proposal --- + # Silent swaps are forbidden by governance. Resolve this before any + # composition-mode branching so `composition_mode="atelier"` cannot + # accidentally force the Remotion atelier path when HyperFrames or + # FFmpeg was approved. + render_runtime = (edit_decisions.get("render_runtime") or "").strip().lower() + + if not render_runtime: + return ToolResult( + success=False, + error=( + "render_runtime is not set in edit_decisions. Per governance, " + "it MUST be locked at proposal stage (proposal_packet." + "production_plan.render_runtime) and carried forward through " + "edit_decisions.render_runtime. Valid values: 'remotion', " + "'hyperframes', 'ffmpeg'. Re-run the proposal stage with an " + "explicit runtime choice — do NOT default this field." + ), + ) + + if render_runtime not in {"remotion", "hyperframes", "ffmpeg"}: + return ToolResult( + success=False, + error=( + f"Unknown render_runtime {render_runtime!r}. " + f"Valid values: remotion, hyperframes, ffmpeg. " + f"render_runtime must be set at proposal stage." + ), + ) + # --- Atelier (bespoke) mode ------------------------------------- # Hand-authored, project-local Remotion composition. Deliberately # bypasses the cut-schema, the stock scene-type registry, and the @@ -1301,8 +1333,11 @@ class VideoCompose(BaseTool): # under remotion-composer/projects// and points this renderer at # it. No reusable creative components; a new visual language per video. # Triggered by composition_mode="atelier" (or renderer_family="bespoke"). - if (edit_decisions.get("composition_mode") == "atelier" - or edit_decisions.get("renderer_family") == "bespoke"): + remotion_atelier_requested = ( + edit_decisions.get("composition_mode") == "atelier" + or edit_decisions.get("renderer_family") == "bespoke" + ) + if render_runtime == "remotion" and remotion_atelier_requested: return self._render_via_atelier(inputs, edit_decisions) if not asset_manifest: @@ -1336,26 +1371,6 @@ class VideoCompose(BaseTool): # Also accept profile as "output_profile" (skill convention) or "profile" profile = inputs.get("profile") or inputs.get("output_profile") - # --- Runtime routing: honor render_runtime locked at proposal --- - # Silent swaps are forbidden by governance. If the chosen runtime - # is unavailable, surface a structured blocker rather than quietly - # picking a different engine. Missing render_runtime is itself a - # governance violation — edit_decisions.schema.json requires it. - render_runtime = (edit_decisions.get("render_runtime") or "").strip().lower() - - if not render_runtime: - return ToolResult( - success=False, - error=( - "render_runtime is not set in edit_decisions. Per governance, " - "it MUST be locked at proposal stage (proposal_packet." - "production_plan.render_runtime) and carried forward through " - "edit_decisions.render_runtime. Valid values: 'remotion', " - "'hyperframes', 'ffmpeg'. Re-run the proposal stage with an " - "explicit runtime choice — do NOT default this field." - ), - ) - if render_runtime == "hyperframes": return self._render_via_hyperframes( inputs=inputs, @@ -1374,16 +1389,6 @@ class VideoCompose(BaseTool): output_path=output_path, profile=profile, ) - if render_runtime != "remotion": - return ToolResult( - success=False, - error=( - f"Unknown render_runtime {render_runtime!r}. " - f"Valid values: remotion, hyperframes, ffmpeg. " - f"render_runtime must be set at proposal stage." - ), - ) - # --- Explicit Remotion path (render_runtime == 'remotion') --- if self._needs_remotion(resolved_cuts): remotion_inputs: dict[str, Any] = {