mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-24 17:10:27 +08:00
Ship Backlot living storyboard release hardening
This commit is contained in:
@@ -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