Ship Backlot living storyboard release hardening

This commit is contained in:
calesthio
2026-07-02 12:19:06 -07:00
parent 1d60f0da14
commit 280400d479
11 changed files with 674 additions and 67 deletions

View File

@@ -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")

View File

@@ -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); }
}

View File

@@ -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);

View File

@@ -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()),

View File

@@ -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()

View File

@@ -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):

View File

@@ -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"] == ""

View File

@@ -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)
# ------------------------------------------------------------------

View File

@@ -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

View File

@@ -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),
)

View File

@@ -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/<slug>/ 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] = {