mirror of
https://github.com/hanzoai/video.git
synced 2026-10-30 18:18:27 +00:00
Separates creative grammar (renderer_family) from technical engine (render_runtime) so HyperFrames can stand alongside Remotion as a first-class runtime instead of masquerading as a Remotion sub-case. Locks runtime choice at proposal stage and enforces it end-to-end: the schemas require it, video_compose routes by it, the reviewer fails closed on silent swaps, and a parametrized contract test walks every pipeline manifest to ensure each planning-stage skill explains the conversation to the user. Adds hyperframes_compose (scaffold/lint/ validate/render/doctor/add_block), a playbook -> CSS style bridge, and vendored HyperFrames Layer 3 skills from commit d291358, pinned via PROVENANCE.md for future re-sync. Final_review now records render_runtime_used and runtime_swap_detected so compose lies are catchable after the fact.
199 lines
6.4 KiB
Python
199 lines
6.4 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from tools.base_tool import ToolStatus
|
|
from tools.tool_registry import ToolRegistry
|
|
from tools.video.corpus_builder import CorpusBuilder
|
|
from tools.video.video_compose import VideoCompose
|
|
|
|
|
|
class _DummySource:
|
|
def __init__(self, name: str, available: bool) -> None:
|
|
self.name = name
|
|
self._available = available
|
|
|
|
def is_available(self) -> bool:
|
|
return self._available
|
|
|
|
def search(self, query: str, filters): # pragma: no cover - protocol stub
|
|
return []
|
|
|
|
def download(self, candidate, out_path: Path): # pragma: no cover - protocol stub
|
|
return out_path
|
|
|
|
|
|
def test_corpus_builder_reports_source_level_discoverability(monkeypatch):
|
|
import tools.video.stock_sources as stock_sources
|
|
|
|
monkeypatch.setattr(
|
|
stock_sources,
|
|
"all_sources",
|
|
lambda: [_DummySource("pexels", False), _DummySource("archive_org", True)],
|
|
)
|
|
monkeypatch.setattr(
|
|
stock_sources,
|
|
"available_sources",
|
|
lambda: [_DummySource("archive_org", True)],
|
|
)
|
|
monkeypatch.setattr(
|
|
stock_sources,
|
|
"source_catalog",
|
|
lambda: [
|
|
{"name": "pexels", "status": "unavailable"},
|
|
{"name": "archive_org", "status": "available"},
|
|
],
|
|
)
|
|
monkeypatch.setattr(
|
|
stock_sources,
|
|
"source_summary",
|
|
lambda: {
|
|
"configured": 1,
|
|
"total": 2,
|
|
"available_source_names": ["archive_org"],
|
|
"unavailable_source_names": ["pexels"],
|
|
},
|
|
)
|
|
|
|
tool = CorpusBuilder()
|
|
assert tool.get_status() == ToolStatus.DEGRADED
|
|
|
|
info = tool.get_info()
|
|
assert info["source_provider_summary"]["configured"] == 1
|
|
assert info["source_provider_summary"]["total"] == 2
|
|
assert {entry["name"] for entry in info["source_provider_menu"]} == {
|
|
"pexels",
|
|
"archive_org",
|
|
}
|
|
|
|
|
|
def test_corpus_builder_rejects_unavailable_pinned_sources(monkeypatch, tmp_path):
|
|
import tools.video.stock_sources as stock_sources
|
|
|
|
sources = {
|
|
"pexels": _DummySource("pexels", False),
|
|
"archive_org": _DummySource("archive_org", True),
|
|
}
|
|
|
|
monkeypatch.setattr(stock_sources, "all_sources", lambda: list(sources.values()))
|
|
monkeypatch.setattr(
|
|
stock_sources,
|
|
"available_sources",
|
|
lambda: [sources["archive_org"]],
|
|
)
|
|
monkeypatch.setattr(stock_sources, "get_source", lambda name: sources[name])
|
|
monkeypatch.setattr(
|
|
stock_sources,
|
|
"source_summary",
|
|
lambda: {
|
|
"configured": 1,
|
|
"total": 2,
|
|
"available_source_names": ["archive_org"],
|
|
"unavailable_source_names": ["pexels"],
|
|
},
|
|
)
|
|
|
|
result = CorpusBuilder().execute({
|
|
"corpus_dir": str(tmp_path / "corpus"),
|
|
"queries": [{"query": "rain at night"}],
|
|
"sources": ["pexels"],
|
|
})
|
|
|
|
assert not result.success
|
|
assert "Requested stock sources are unavailable" in result.error
|
|
assert "archive_org" in result.error
|
|
|
|
|
|
def test_documentary_renderer_family_maps_to_remotion():
|
|
assert VideoCompose._get_composition_id("documentary-montage") == "CinematicRenderer"
|
|
|
|
|
|
def test_video_compose_surfaces_all_three_runtimes():
|
|
"""Preflight must see remotion, hyperframes, and ffmpeg as separate engines."""
|
|
info = VideoCompose().get_info()
|
|
engines = info["render_engines"]
|
|
assert set(engines.keys()) == {"remotion", "hyperframes", "ffmpeg"}
|
|
assert engines["ffmpeg"] is True # always true on this machine
|
|
assert "hyperframes_note" in info
|
|
assert "runtime_governance" in info
|
|
|
|
|
|
def test_video_compose_blocks_silent_hyperframes_swap(tmp_path, monkeypatch):
|
|
"""Governance: if render_runtime='hyperframes' is locked but runtime
|
|
is missing, the tool MUST return a structured blocker and NOT route to
|
|
Remotion or FFmpeg."""
|
|
monkeypatch.setattr(
|
|
VideoCompose, "_hyperframes_available", lambda self: False, raising=True
|
|
)
|
|
result = VideoCompose().execute(
|
|
{
|
|
"operation": "render",
|
|
"edit_decisions": {
|
|
"version": "1.0",
|
|
"renderer_family": "animation-first",
|
|
"render_runtime": "hyperframes",
|
|
"cuts": [
|
|
{"id": "c1", "source": "x", "in_seconds": 0, "out_seconds": 2}
|
|
],
|
|
},
|
|
"asset_manifest": {"assets": [{"id": "x", "path": "missing.png"}]},
|
|
"output_path": str(tmp_path / "out.mp4"),
|
|
}
|
|
)
|
|
assert not result.success
|
|
err = (result.error or "").lower()
|
|
assert "hyperframes" in err
|
|
# Error MUST mention it's a blocker, not silently pick a different engine.
|
|
assert ("blocker" in err) or ("not available" in err)
|
|
|
|
|
|
def test_video_compose_rejects_unknown_render_runtime(tmp_path):
|
|
result = VideoCompose().execute(
|
|
{
|
|
"operation": "render",
|
|
"edit_decisions": {
|
|
"version": "1.0",
|
|
"renderer_family": "explainer-data",
|
|
"render_runtime": "bogus-runtime",
|
|
"cuts": [
|
|
{"id": "c1", "source": "x", "in_seconds": 0, "out_seconds": 2}
|
|
],
|
|
},
|
|
"asset_manifest": {"assets": []},
|
|
"output_path": str(tmp_path / "out.mp4"),
|
|
}
|
|
)
|
|
assert not result.success
|
|
assert "unknown render_runtime" in (result.error or "").lower()
|
|
|
|
|
|
def test_provider_menu_preserves_tool_discovery_metadata(monkeypatch):
|
|
import tools.video.stock_sources as stock_sources
|
|
|
|
monkeypatch.setattr(stock_sources, "all_sources", lambda: [_DummySource("archive_org", True)])
|
|
monkeypatch.setattr(stock_sources, "available_sources", lambda: [_DummySource("archive_org", True)])
|
|
monkeypatch.setattr(
|
|
stock_sources,
|
|
"source_catalog",
|
|
lambda: [{"name": "archive_org", "status": "available"}],
|
|
)
|
|
monkeypatch.setattr(
|
|
stock_sources,
|
|
"source_summary",
|
|
lambda: {
|
|
"configured": 1,
|
|
"total": 1,
|
|
"available_source_names": ["archive_org"],
|
|
"unavailable_source_names": [],
|
|
},
|
|
)
|
|
|
|
registry = ToolRegistry()
|
|
registry.register(CorpusBuilder())
|
|
menu = registry.provider_menu()
|
|
entry = menu["corpus_population"]["available"][0]
|
|
|
|
assert entry["name"] == "corpus_builder"
|
|
assert entry["source_provider_summary"]["configured"] == 1
|
|
assert entry["source_provider_menu"][0]["name"] == "archive_org"
|