mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-12 03:33:44 +08:00
Ship Backlot living storyboard release hardening
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()),
|
||||
|
||||
110
tests/backlot/test_ui_bug_bash.py
Normal file
110
tests/backlot/test_ui_bug_bash.py
Normal 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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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"] == ""
|
||||
|
||||
@@ -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)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
Reference in New Issue
Block a user