mirror of
https://github.com/hanzoai/video.git
synced 2026-10-30 18:18:27 +00:00
Adds a transcript_comparison check to VideoCompose._run_final_review that word-diffs the whisper/whisperx transcript against script.txt and fails loudly when the TTS engine literally voiced punctuation tokens (dot, dots, ellipsis, comma, dash, hyphen, period). Chirp3-HD did this to ellipses in a production run and it slipped past review; now it cannot. Also corrects the "tiny background video" gotcha in skills/core/hyperframes.md. After six renders of blaming HyperFrames CSS, the real root cause was 640x360 Pexels sources combined with a fit-and-pad pre-transform — HyperFrames was rendering the letterboxed input faithfully. Gotcha now walks through the ffprobe diagnostic and the scale-to-cover fix, and keeps the wrapper-div pattern for the right reasons (aspect mismatch handling, not framework bug workaround). Four regression tests cover the new check: punctuation-leak detection, clean-audio false-positive guard, graceful skip when inputs missing, and always-present transcript_comparison section.
1180 lines
44 KiB
Python
1180 lines
44 KiB
Python
"""Fast unit tests for the HyperFrames runtime integration.
|
|
|
|
These tests do NOT invoke the HyperFrames CLI — they verify schema
|
|
acceptance, tool contract wiring, governance routing in video_compose, the
|
|
style bridge, and workspace scaffolding. Subprocess-based smoke tests live
|
|
in tests/qa/test_09_hyperframes_compose.py and are opt-in.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from tools.base_tool import ToolStatus
|
|
from tools.video.hyperframes_compose import HyperFramesCompose
|
|
from tools.video.video_compose import VideoCompose
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Tool contract
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
def test_hyperframes_tool_identity():
|
|
t = HyperFramesCompose()
|
|
assert t.name == "hyperframes_compose"
|
|
assert t.capability == "video_post"
|
|
assert t.provider == "hyperframes"
|
|
assert "hyperframes" in t.agent_skills
|
|
assert "hyperframes-cli" in t.agent_skills
|
|
|
|
|
|
def test_hyperframes_get_info_reports_runtime():
|
|
info = HyperFramesCompose().get_info()
|
|
assert "hyperframes_runtime" in info
|
|
rc = info["hyperframes_runtime"]
|
|
assert set(rc.keys()) >= {
|
|
"runtime_available",
|
|
"node_major",
|
|
"ffmpeg_available",
|
|
"npx_available",
|
|
"reasons",
|
|
}
|
|
|
|
|
|
def test_hyperframes_layer2_skill_names_correct_package():
|
|
"""Regression: skills/core/hyperframes.md previously claimed HyperFrames
|
|
was 'consumable via `npx @hyperframes/cli`' which is the 404-ing name.
|
|
A Layer 2 skill reader would get bad advice even after the
|
|
install_instructions fix. Must name the real published package."""
|
|
from pathlib import Path
|
|
body = (
|
|
Path(__file__).resolve().parent.parent.parent
|
|
/ "skills" / "core" / "hyperframes.md"
|
|
).read_text(encoding="utf-8")
|
|
# The dangerous invocation must be called out, not recommended.
|
|
# If `@hyperframes/cli` appears it must be in a warning context.
|
|
if "@hyperframes/cli" in body:
|
|
# Only OK if it's named as a trap, not a recommendation.
|
|
assert (
|
|
"404" in body
|
|
or "NOT" in body
|
|
or "do not" in body.lower()
|
|
or "trap" in body.lower()
|
|
), (
|
|
"skills/core/hyperframes.md still recommends `@hyperframes/cli` "
|
|
"(the 404-ing monorepo name) without a warning. Replace with "
|
|
"`npx hyperframes` or flag it as a trap."
|
|
)
|
|
# Must mention the correct published name.
|
|
assert "npx hyperframes" in body, (
|
|
"skills/core/hyperframes.md must reference `npx hyperframes` (the real "
|
|
"published package). A Layer 2 skill missing this would leave agents "
|
|
"stuck if they bypass the tool's install_instructions."
|
|
)
|
|
|
|
|
|
def test_animation_proposal_director_has_no_hardcoded_costs_or_keys():
|
|
"""Regression: multiple audit rounds found hardcoded per-unit dollar costs
|
|
and specific API key names in the animation proposal-director. Uses a
|
|
regex to catch the ENTIRE class of drift, not just specific strings. Covers
|
|
the whole file, not just Step 3 — dry-run round 3 found hardcoded values
|
|
surviving in the decision matrix and Common Pitfalls after Step 3 was
|
|
cleaned."""
|
|
from pathlib import Path
|
|
import re
|
|
|
|
body = (
|
|
Path(__file__).resolve().parent.parent.parent
|
|
/ "skills" / "pipelines" / "animation" / "proposal-director.md"
|
|
).read_text(encoding="utf-8")
|
|
|
|
# Only flag NON-ZERO dollar figures. `$0` and `$0.00` labeling something
|
|
# as free is fine (local tools don't drift in cost), but any real price
|
|
# ($0.05, $3-15, etc.) is drift-prone and must come from estimate_cost.
|
|
dollar_pattern = re.compile(
|
|
r"\$(?!0(?!\.?\d*[1-9]))" # dollar sign
|
|
r"\d+(?:[.,]\d+)?" # integer or decimal part
|
|
r"(?:\s*-\s*\$?\d+(?:[.,]\d+)?)?" # optional range tail
|
|
)
|
|
env_var_pattern = re.compile(
|
|
r"\b(FAL_KEY|OPENAI_API_KEY|RUNWAY_API_KEY|KLING_API_KEY|"
|
|
r"REPLICATE_API_TOKEN|ANTHROPIC_API_KEY|GEMINI_API_KEY|"
|
|
r"ELEVENLABS_API_KEY|HEYGEN_API_KEY)\b"
|
|
)
|
|
# Lines in anti-pattern bullets or registry-pointer bullets are allowed
|
|
# to mention banned shapes as counter-examples.
|
|
anti_pattern_markers = [
|
|
"do not hardcode",
|
|
"do not fill in",
|
|
"don't type them from memory",
|
|
"drift between releases",
|
|
"governance regression",
|
|
"if you find yourself typing",
|
|
"read each missing tool",
|
|
"do not fill in a dollar figure",
|
|
]
|
|
|
|
violations: list[tuple[int, str]] = []
|
|
for lineno, line in enumerate(body.splitlines(), start=1):
|
|
if not (dollar_pattern.search(line) or env_var_pattern.search(line)):
|
|
continue
|
|
if any(marker in line.lower() for marker in anti_pattern_markers):
|
|
continue
|
|
if line.strip() in ("```", "```python", "```bash"):
|
|
continue
|
|
violations.append((lineno, line.strip()[:140]))
|
|
|
|
if violations:
|
|
formatted = "\n".join(f" line {n}: {text}" for n, text in violations)
|
|
raise AssertionError(
|
|
f"Hardcoded cost figures or env var names in "
|
|
f"animation/proposal-director.md. Director skills must pull these "
|
|
f"from the registry (estimate_cost / install_instructions) because "
|
|
f"provider pricing drifts between releases. Violations:\n{formatted}"
|
|
)
|
|
assert "provider_menu_summary" in body or "estimate_cost" in body, (
|
|
"Animation proposal-director must reference provider_menu_summary() "
|
|
"or estimate_cost so agents know where live pricing comes from."
|
|
)
|
|
|
|
|
|
def test_provider_menu_summary_deduplicates_providers_across_buckets():
|
|
"""Regression: when a provider has multiple tools (e.g. two seedance tools
|
|
both reporting provider='seedance'), the summary previously listed the
|
|
provider as BOTH available and unavailable — reads as a contradiction to
|
|
users. Any-available wins over any-unavailable."""
|
|
from tools.tool_registry import registry
|
|
|
|
registry.discover()
|
|
s = registry.provider_menu_summary()
|
|
for cap_entry in s["capabilities"]:
|
|
both = set(cap_entry["available_providers"]) & set(
|
|
cap_entry["unavailable_providers"]
|
|
)
|
|
assert not both, (
|
|
f"Capability {cap_entry['capability']!r} lists providers in BOTH "
|
|
f"available and unavailable buckets: {sorted(both)}. A provider "
|
|
f"with any available tool must not also show as unavailable."
|
|
)
|
|
|
|
|
|
def test_provider_menu_summary_is_cp1252_safe():
|
|
"""Regression: on Windows cp1252 stdout, printing any string with an
|
|
em-dash crashes with UnicodeEncodeError (or renders as `?` / mojibake).
|
|
provider_menu_summary() post-processes its output via
|
|
_scrub_unicode_dashes so preflight pasting works on every shell.
|
|
This protects preflight even if a future tool author writes em-dashes
|
|
into install_instructions."""
|
|
import json
|
|
from tools.tool_registry import registry, _scrub_unicode_dashes
|
|
|
|
# Direct unit test of the helper.
|
|
dirty = "one \u2014 two \u2013 three \u2018quoted\u2019"
|
|
clean = _scrub_unicode_dashes(dirty)
|
|
assert "\u2014" not in clean
|
|
assert "\u2013" not in clean
|
|
assert "--" in clean
|
|
|
|
# And the summary itself is scrubbed.
|
|
registry.discover()
|
|
summary_json = json.dumps(registry.provider_menu_summary())
|
|
assert "\u2014" not in summary_json, (
|
|
"em-dash leaked into provider_menu_summary — Windows cp1252 users "
|
|
"will see mojibake in preflight."
|
|
)
|
|
assert "\u2013" not in summary_json, "en-dash leaked"
|
|
# Nested structures must also be scrubbed.
|
|
nested = _scrub_unicode_dashes({"a": ["x \u2014 y", {"b": "c \u2014 d"}]})
|
|
assert "\u2014" not in json.dumps(nested)
|
|
|
|
|
|
def test_install_instructions_reference_correct_npm_package_name():
|
|
"""Regression: install_instructions previously pointed at `npx @hyperframes/cli`,
|
|
which returns a 404 on the public npm registry. The real published package
|
|
name is `hyperframes`. A fresh-session agent reading install_instructions
|
|
and trying to verify setup would hit 404 and conclude HyperFrames isn't
|
|
available.
|
|
"""
|
|
hint = HyperFramesCompose.install_instructions
|
|
# Must name the correct published package name.
|
|
assert "`npx hyperframes" in hint or "npm package: `hyperframes`" in hint, (
|
|
"install_instructions must reference `npx hyperframes` / npm package "
|
|
"`hyperframes` — NOT the monorepo-internal `@hyperframes/cli` name."
|
|
)
|
|
# And ideally warns about the 404 trap so agents don't re-introduce it.
|
|
assert "404" in hint or "@hyperframes/cli" in hint, (
|
|
"install_instructions should mention that the monorepo-internal name "
|
|
"`@hyperframes/cli` is NOT the published name — this is the exact trap "
|
|
"that misled a previous audit."
|
|
)
|
|
|
|
|
|
def test_runtime_check_fails_when_npm_package_unresolvable(monkeypatch):
|
|
"""Regression: `_runtime_check()` previously returned runtime_available=True
|
|
based only on local binaries (node/ffmpeg/npx). That meant the tool lied
|
|
when the machine was offline, npm was down, or the package name was wrong.
|
|
The check must now include a real npm resolve."""
|
|
# Clear process cache and force _resolve_npm_package to return a 404.
|
|
monkeypatch.setattr(
|
|
HyperFramesCompose, "_npm_resolve_cache", None, raising=False
|
|
)
|
|
monkeypatch.setattr(
|
|
HyperFramesCompose,
|
|
"_resolve_npm_package",
|
|
classmethod(lambda cls: {"error": "npm package `hyperframes` not found (404)"}),
|
|
)
|
|
rc = HyperFramesCompose()._runtime_check()
|
|
assert rc["runtime_available"] is False, (
|
|
"Runtime must report NOT available when the npm package can't be "
|
|
"resolved — even if node/ffmpeg/npx are all on PATH."
|
|
)
|
|
assert any("404" in r for r in rc["reasons"]), (
|
|
"reasons must include the actual npm-resolve failure, not just a "
|
|
"generic 'runtime unavailable' message."
|
|
)
|
|
assert rc["npm_resolve_error"] is not None
|
|
assert rc["npm_package"] == "hyperframes"
|
|
|
|
|
|
def test_runtime_check_succeeds_when_npm_resolves(monkeypatch):
|
|
monkeypatch.setattr(
|
|
HyperFramesCompose, "_npm_resolve_cache", None, raising=False
|
|
)
|
|
monkeypatch.setattr(
|
|
HyperFramesCompose,
|
|
"_resolve_npm_package",
|
|
classmethod(lambda cls: {"version": "0.4.5"}),
|
|
)
|
|
rc = HyperFramesCompose()._runtime_check()
|
|
# Local binaries must still pass for this to go green.
|
|
if rc["node_major"] is None or not rc["ffmpeg_available"] or not rc["npx_available"]:
|
|
pytest.skip("Local runtime floor not met on this machine")
|
|
assert rc["runtime_available"] is True
|
|
assert rc["npm_package_version"] == "0.4.5"
|
|
assert rc["reasons"] == []
|
|
|
|
|
|
def test_video_compose_render_engines_follow_hyperframes_runtime_check(monkeypatch):
|
|
"""Regression: `video_compose.get_info()['render_engines']['hyperframes']`
|
|
must track the true availability, not just the local-binary floor.
|
|
Without this, the 'Present Both Composition Runtimes' HARD RULE surfaces
|
|
a runtime that cannot actually render."""
|
|
monkeypatch.setattr(
|
|
HyperFramesCompose, "_npm_resolve_cache", None, raising=False
|
|
)
|
|
monkeypatch.setattr(
|
|
HyperFramesCompose,
|
|
"_resolve_npm_package",
|
|
classmethod(lambda cls: {"error": "npm package not found (404)"}),
|
|
)
|
|
info = VideoCompose().get_info()
|
|
assert info["render_engines"]["hyperframes"] is False, (
|
|
"video_compose must mark hyperframes as unavailable when the real "
|
|
"runtime check fails. Otherwise the HARD RULE lies."
|
|
)
|
|
|
|
|
|
def test_provider_menu_summary_returns_expected_shape():
|
|
"""Regression: AGENT_GUIDE.md line 246 points agents at provider_menu_summary
|
|
for the capability menu. The shape must be stable and cover the four fields
|
|
the guide references."""
|
|
from tools.tool_registry import registry
|
|
|
|
registry.discover()
|
|
s = registry.provider_menu_summary()
|
|
|
|
assert set(s.keys()) == {
|
|
"composition_runtimes",
|
|
"capabilities",
|
|
"setup_offers",
|
|
"runtime_warnings",
|
|
}
|
|
# Composition runtimes MUST include all three engines so the HARD RULE
|
|
# presentation has the data it needs.
|
|
for engine in ("ffmpeg", "remotion", "hyperframes"):
|
|
assert engine in s["composition_runtimes"]
|
|
assert isinstance(s["composition_runtimes"][engine], bool)
|
|
|
|
# Capabilities rollup is a list of dicts with configured/total counts.
|
|
assert isinstance(s["capabilities"], list)
|
|
assert len(s["capabilities"]) > 0
|
|
for entry in s["capabilities"]:
|
|
assert set(entry.keys()) >= {
|
|
"capability",
|
|
"configured",
|
|
"total",
|
|
"available_providers",
|
|
"unavailable_providers",
|
|
}
|
|
assert entry["configured"] <= entry["total"]
|
|
|
|
# setup_offers and runtime_warnings must be lists (possibly empty).
|
|
assert isinstance(s["setup_offers"], list)
|
|
assert isinstance(s["runtime_warnings"], list)
|
|
|
|
|
|
def test_agent_guide_references_provider_menu_summary():
|
|
"""Regression: AGENT_GUIDE.md must route agents to provider_menu_summary
|
|
instead of dumping support_envelope raw. If this gets reverted a fresh-
|
|
session agent will paste the firehose into chat."""
|
|
from pathlib import Path
|
|
|
|
root = Path(__file__).resolve().parent.parent.parent
|
|
guide = (root / "AGENT_GUIDE.md").read_text(encoding="utf-8")
|
|
assert "provider_menu_summary" in guide, (
|
|
"AGENT_GUIDE.md must reference provider_menu_summary() as the primary "
|
|
"preflight helper — without this, agents fall back to the firehose."
|
|
)
|
|
|
|
|
|
def test_hyperframes_unknown_operation_returns_error():
|
|
result = HyperFramesCompose().execute({"operation": "bogus"})
|
|
assert not result.success
|
|
assert "Unknown operation" in (result.error or "")
|
|
|
|
|
|
def test_hyperframes_lint_requires_workspace():
|
|
# No workspace_path → ValueError surfaced through execute as a ToolResult.
|
|
result = HyperFramesCompose().execute({"operation": "lint"})
|
|
assert not result.success
|
|
assert "workspace_path" in (result.error or "")
|
|
|
|
|
|
def test_hyperframes_render_requires_workspace():
|
|
result = HyperFramesCompose().execute({"operation": "render"})
|
|
assert not result.success
|
|
# Depending on runtime availability, error mentions either workspace or runtime.
|
|
err = (result.error or "").lower()
|
|
assert ("workspace" in err) or ("runtime" in err) or ("hyperframes" in err)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# video_compose runtime routing
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
def test_video_compose_reports_hyperframes_engine():
|
|
info = VideoCompose().get_info()
|
|
assert "render_engines" in info
|
|
assert "hyperframes" in info["render_engines"]
|
|
assert "hyperframes_note" in info
|
|
# Both legacy key and new alias must be present.
|
|
assert "render_runtimes" in info
|
|
assert info["render_engines"] == info["render_runtimes"]
|
|
|
|
|
|
def test_video_compose_governance_note_present():
|
|
info = VideoCompose().get_info()
|
|
assert "runtime_governance" in info
|
|
assert "silent swap" in info["runtime_governance"].lower()
|
|
|
|
|
|
def test_video_compose_rejects_unknown_render_runtime(tmp_path):
|
|
"""Governance: an unknown render_runtime must fail, not silently fall back."""
|
|
comp_out = tmp_path / "out.mp4"
|
|
result = VideoCompose().execute(
|
|
{
|
|
"operation": "render",
|
|
"edit_decisions": {
|
|
"version": "1.0",
|
|
"cuts": [
|
|
{
|
|
"id": "c1",
|
|
"source": "nonexistent",
|
|
"in_seconds": 0,
|
|
"out_seconds": 3,
|
|
}
|
|
],
|
|
"render_runtime": "totally-made-up",
|
|
"renderer_family": "explainer-data",
|
|
},
|
|
"asset_manifest": {"assets": []},
|
|
"output_path": str(comp_out),
|
|
}
|
|
)
|
|
assert not result.success
|
|
assert "Unknown render_runtime" in (result.error or "")
|
|
|
|
|
|
def test_video_compose_rejects_missing_render_runtime(tmp_path):
|
|
"""Regression: missing render_runtime MUST NOT silently fall back to Remotion.
|
|
|
|
Prior behavior: empty/missing render_runtime fell through to the
|
|
Remotion-default path, which defeated the auditable-runtime-selection
|
|
governance contract.
|
|
"""
|
|
comp_out = tmp_path / "out.mp4"
|
|
result = VideoCompose().execute(
|
|
{
|
|
"operation": "render",
|
|
"edit_decisions": {
|
|
"version": "1.0",
|
|
"cuts": [
|
|
{
|
|
"id": "c1",
|
|
"source": "nonexistent",
|
|
"in_seconds": 0,
|
|
"out_seconds": 3,
|
|
}
|
|
],
|
|
# NOTE: no render_runtime field
|
|
"renderer_family": "explainer-data",
|
|
},
|
|
"asset_manifest": {"assets": []},
|
|
"output_path": str(comp_out),
|
|
}
|
|
)
|
|
assert not result.success
|
|
err = (result.error or "").lower()
|
|
assert "render_runtime" in err
|
|
assert "not set" in err or "must be" in err
|
|
# Explicitly NOT treated as a Remotion request.
|
|
assert "remotion render failed" not in err
|
|
|
|
|
|
def test_schemas_require_render_runtime():
|
|
"""Regression: both proposal_packet and edit_decisions schemas must
|
|
REQUIRE render_runtime, not just declare it as an optional property."""
|
|
import json
|
|
from pathlib import Path
|
|
|
|
root = Path(__file__).resolve().parent.parent.parent
|
|
|
|
ed = json.loads(
|
|
(root / "schemas" / "artifacts" / "edit_decisions.schema.json").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
)
|
|
assert "render_runtime" in ed["required"], (
|
|
"edit_decisions.schema.json must require render_runtime — missing means "
|
|
"governance bypass (silent Remotion fallback)."
|
|
)
|
|
|
|
pp = json.loads(
|
|
(root / "schemas" / "artifacts" / "proposal_packet.schema.json").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
)
|
|
pp_prod = pp["properties"]["production_plan"]
|
|
assert "render_runtime" in pp_prod["required"], (
|
|
"proposal_packet.production_plan must require render_runtime — "
|
|
"the proposal stage MUST pick a runtime explicitly."
|
|
)
|
|
|
|
|
|
def test_runtime_swap_detected_flips_when_proposal_packet_disagrees(tmp_path):
|
|
"""Regression: runtime_swap_detected was previously dead code because the
|
|
check read a metadata field no one writes. The fix accepts
|
|
`proposal_packet` directly so the signal actually fires."""
|
|
# Build a minimal real MP4 so final_review can probe it.
|
|
import subprocess
|
|
|
|
mp4 = tmp_path / "tiny.mp4"
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg", "-y",
|
|
"-f", "lavfi", "-i", "color=c=#000000:s=320x240:d=2",
|
|
"-f", "lavfi", "-i", "sine=frequency=440:duration=2",
|
|
"-c:v", "libx264", "-pix_fmt", "yuv420p",
|
|
"-c:a", "aac",
|
|
"-shortest", str(mp4),
|
|
],
|
|
capture_output=True, check=True, timeout=30,
|
|
)
|
|
|
|
edit_decisions = {
|
|
"version": "1.0",
|
|
"render_runtime": "hyperframes", # what compose actually ran
|
|
"renderer_family": "animation-first",
|
|
"cuts": [{"id": "c1", "source": "x", "in_seconds": 0, "out_seconds": 2}],
|
|
}
|
|
proposal_packet = {
|
|
"production_plan": {
|
|
"render_runtime": "remotion", # what proposal approved
|
|
},
|
|
}
|
|
|
|
review = VideoCompose()._run_final_review(
|
|
mp4, edit_decisions, proposal_packet
|
|
)
|
|
pp = review["checks"]["promise_preservation"]
|
|
assert pp.get("runtime_swap_detected") is True
|
|
assert "runtime_swap_check" in pp
|
|
assert "detected" in pp["runtime_swap_check"]
|
|
# And the human-readable issues list mentions the swap.
|
|
assert any("render_runtime changed" in i for i in pp.get("issues", []))
|
|
|
|
|
|
def test_runtime_swap_detected_stays_false_when_proposal_matches(tmp_path):
|
|
import subprocess
|
|
|
|
mp4 = tmp_path / "tiny.mp4"
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg", "-y",
|
|
"-f", "lavfi", "-i", "color=c=#000000:s=320x240:d=2",
|
|
"-f", "lavfi", "-i", "sine=frequency=440:duration=2",
|
|
"-c:v", "libx264", "-pix_fmt", "yuv420p",
|
|
"-c:a", "aac",
|
|
"-shortest", str(mp4),
|
|
],
|
|
capture_output=True, check=True, timeout=30,
|
|
)
|
|
edit_decisions = {
|
|
"version": "1.0",
|
|
"render_runtime": "remotion",
|
|
"cuts": [{"id": "c1", "source": "x", "in_seconds": 0, "out_seconds": 2}],
|
|
}
|
|
proposal_packet = {"production_plan": {"render_runtime": "remotion"}}
|
|
review = VideoCompose()._run_final_review(
|
|
mp4, edit_decisions, proposal_packet
|
|
)
|
|
pp = review["checks"]["promise_preservation"]
|
|
assert pp.get("runtime_swap_detected", False) is False
|
|
assert "ok" in pp["runtime_swap_check"]
|
|
|
|
|
|
def test_both_runtimes_visible_in_render_engines_when_available():
|
|
"""Regression for the 'silently picks Remotion' failure mode.
|
|
|
|
A fresh-session agent decides which runtime to present based on
|
|
`video_compose.get_info()["render_engines"]`. That dict MUST expose
|
|
BOTH remotion and hyperframes as separate boolean entries — not
|
|
collapse them under one 'composition' key or hide hyperframes behind
|
|
a remotion-specific note. If this test fails on a machine where both
|
|
should be available, the agent's runtime discovery is broken and it
|
|
will likely silently default to Remotion.
|
|
"""
|
|
info = VideoCompose().get_info()
|
|
engines = info["render_engines"]
|
|
# Both entries must exist as keys regardless of availability.
|
|
assert "remotion" in engines, (
|
|
"render_engines dict is missing 'remotion' — agents won't see it as "
|
|
"an option."
|
|
)
|
|
assert "hyperframes" in engines, (
|
|
"render_engines dict is missing 'hyperframes' — agents won't see it "
|
|
"as an option and will silently default to Remotion."
|
|
)
|
|
assert "ffmpeg" in engines
|
|
# Both notes must exist independently so onboarding can surface both.
|
|
assert "remotion_note" in info
|
|
assert "hyperframes_note" in info
|
|
# Governance note must be present — this is what reminds the agent
|
|
# not to silently pick a default.
|
|
assert "runtime_governance" in info
|
|
assert "silent" in info["runtime_governance"].lower()
|
|
|
|
|
|
def _valid_runtime_decision(options: list[dict]) -> dict:
|
|
"""Build a minimal schema-valid decision_log entry with given options."""
|
|
return {
|
|
"decision_id": "d-runtime-1",
|
|
"stage": "proposal",
|
|
"category": "render_runtime_selection",
|
|
"subject": "composition runtime",
|
|
"options_considered": options,
|
|
"selected": options[0]["option_id"],
|
|
"reason": "fit-for-brief",
|
|
}
|
|
|
|
|
|
def test_decision_log_accepts_render_runtime_selection_with_both_options():
|
|
"""Schema-level: a decision_log with BOTH runtimes in options_considered
|
|
must validate. This is the contract the reviewer enforces."""
|
|
import json
|
|
from pathlib import Path
|
|
try:
|
|
import jsonschema
|
|
except ImportError: # pragma: no cover
|
|
pytest.skip("jsonschema not installed")
|
|
|
|
root = Path(__file__).resolve().parent.parent.parent
|
|
schema = json.loads(
|
|
(root / "schemas" / "artifacts" / "decision_log.schema.json").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
)
|
|
|
|
log = {
|
|
"version": "1.0",
|
|
"project_id": "p-test",
|
|
"decisions": [
|
|
_valid_runtime_decision(
|
|
[
|
|
{
|
|
"option_id": "remotion",
|
|
"label": "Remotion",
|
|
"score": 0.6,
|
|
"reason": "existing React scene stack fits",
|
|
},
|
|
{
|
|
"option_id": "hyperframes",
|
|
"label": "HyperFrames",
|
|
"score": 0.4,
|
|
"reason": "GSAP motion is natural but caption parity deferred",
|
|
},
|
|
]
|
|
)
|
|
],
|
|
}
|
|
# No raise = valid.
|
|
jsonschema.validate(log, schema)
|
|
|
|
|
|
def test_transcript_comparison_catches_literal_punctuation_leak(tmp_path):
|
|
"""Regression: Chirp3-HD (and some other TTS engines) read literal `...`
|
|
as the word 'dot' in audio output. This failure is invisible to
|
|
volume-based audio spotchecks but ships audio that literally says
|
|
'dot dot dot' twelve times. The transcript_comparison check catches
|
|
this automatically before the video is marked pass."""
|
|
import json
|
|
|
|
# Real-world example: user's script had `...` everywhere for dramatic
|
|
# pause, Chirp read them all as "dot", transcript contains "dot dot dot"
|
|
# phrases the script never had.
|
|
script_text = (
|
|
"A computer just did in five minutes what would take every machine on Earth, "
|
|
"running since the Big Bang, ten septillion years to finish. "
|
|
"We may have gotten help from parallel universes."
|
|
)
|
|
transcript_data = {
|
|
"word_timestamps": [
|
|
{"word": "A", "start": 0.0, "end": 0.1},
|
|
{"word": "computer", "start": 0.1, "end": 0.5},
|
|
{"word": "just", "start": 0.5, "end": 0.7},
|
|
{"word": "did", "start": 0.7, "end": 0.9},
|
|
{"word": "in", "start": 0.9, "end": 1.0},
|
|
{"word": "five", "start": 1.0, "end": 1.3},
|
|
{"word": "minutes", "start": 1.3, "end": 1.7},
|
|
{"word": "dot", "start": 1.7, "end": 1.9}, # leak!
|
|
{"word": "dot", "start": 1.9, "end": 2.1}, # leak!
|
|
{"word": "dot", "start": 2.1, "end": 2.3}, # leak!
|
|
{"word": "what", "start": 2.5, "end": 2.8},
|
|
{"word": "would", "start": 2.8, "end": 3.0},
|
|
{"word": "take", "start": 3.0, "end": 3.3},
|
|
{"word": "every", "start": 3.3, "end": 3.6},
|
|
{"word": "machine", "start": 3.6, "end": 4.0},
|
|
{"word": "on", "start": 4.0, "end": 4.2},
|
|
{"word": "Earth", "start": 4.2, "end": 4.6},
|
|
{"word": "running", "start": 4.7, "end": 5.1},
|
|
{"word": "since", "start": 5.1, "end": 5.4},
|
|
{"word": "the", "start": 5.4, "end": 5.5},
|
|
{"word": "Big", "start": 5.5, "end": 5.8},
|
|
{"word": "Bang", "start": 5.8, "end": 6.2},
|
|
{"word": "ten", "start": 6.2, "end": 6.5},
|
|
{"word": "septillion", "start": 6.5, "end": 7.3},
|
|
{"word": "years", "start": 7.3, "end": 7.7},
|
|
{"word": "to", "start": 7.7, "end": 7.9},
|
|
{"word": "finish", "start": 7.9, "end": 8.3},
|
|
{"word": "dot", "start": 8.3, "end": 8.5}, # another leak
|
|
{"word": "We", "start": 9.0, "end": 9.2},
|
|
{"word": "may", "start": 9.2, "end": 9.4},
|
|
{"word": "have", "start": 9.4, "end": 9.6},
|
|
{"word": "gotten", "start": 9.6, "end": 9.9},
|
|
{"word": "help", "start": 9.9, "end": 10.3},
|
|
{"word": "from", "start": 10.3, "end": 10.5},
|
|
{"word": "parallel", "start": 10.5, "end": 11.0},
|
|
{"word": "universes", "start": 11.0, "end": 11.7},
|
|
]
|
|
}
|
|
transcript_path = tmp_path / "transcript.json"
|
|
transcript_path.write_text(json.dumps(transcript_data), encoding="utf-8")
|
|
|
|
result = VideoCompose._compare_transcript_to_script(transcript_path, script_text)
|
|
|
|
# Must catch the punctuation leak
|
|
assert result["spurious_punctuation_words"], (
|
|
"transcript_comparison failed to detect the 'dot' leak from literal ... punctuation."
|
|
)
|
|
leak_counts = {
|
|
entry["word"]: entry["count"]
|
|
for entry in result["spurious_punctuation_words"]
|
|
}
|
|
assert leak_counts.get("dot") == 4, f"Expected 4 'dot' leaks, got {leak_counts}"
|
|
|
|
# Must produce a CRITICAL-severity issue message
|
|
issue_text = " ".join(result["issues"]).lower()
|
|
assert "tts punctuation leak" in issue_text
|
|
assert "not in the script" in issue_text
|
|
|
|
# Must NOT mark the transcript as matching
|
|
assert result["transcript_matches_script"] is False
|
|
|
|
|
|
def test_transcript_comparison_passes_clean_audio(tmp_path):
|
|
"""Clean audio with no punctuation leaks must NOT trigger a false
|
|
positive."""
|
|
import json
|
|
|
|
script_text = "The quick brown fox jumps over the lazy dog."
|
|
transcript_data = {
|
|
"word_timestamps": [
|
|
{"word": "The", "start": 0.0, "end": 0.1},
|
|
{"word": "quick", "start": 0.1, "end": 0.4},
|
|
{"word": "brown", "start": 0.4, "end": 0.7},
|
|
{"word": "fox", "start": 0.7, "end": 1.0},
|
|
{"word": "jumps", "start": 1.0, "end": 1.3},
|
|
{"word": "over", "start": 1.3, "end": 1.6},
|
|
{"word": "the", "start": 1.6, "end": 1.7},
|
|
{"word": "lazy", "start": 1.7, "end": 2.0},
|
|
{"word": "dog", "start": 2.0, "end": 2.3},
|
|
]
|
|
}
|
|
transcript_path = tmp_path / "transcript.json"
|
|
transcript_path.write_text(json.dumps(transcript_data), encoding="utf-8")
|
|
|
|
result = VideoCompose._compare_transcript_to_script(transcript_path, script_text)
|
|
assert result["spurious_punctuation_words"] == []
|
|
assert result["transcript_matches_script"] is True
|
|
assert result["word_accuracy"] >= 0.9
|
|
# issues may still have informational content but no CRITICAL TTS leak
|
|
assert not any("tts punctuation leak" in i.lower() for i in result["issues"])
|
|
|
|
|
|
def test_transcript_comparison_graceful_when_inputs_missing(tmp_path):
|
|
"""When transcript or script is unavailable, the check should NOT
|
|
crash — it should record the skip in issues so the silence is visible."""
|
|
# No transcript
|
|
result = VideoCompose._compare_transcript_to_script(None, "some script text")
|
|
assert any("not provided" in i for i in result["issues"])
|
|
|
|
# No script
|
|
dummy = tmp_path / "t.json"
|
|
dummy.write_text('{"word_timestamps": []}', encoding="utf-8")
|
|
result = VideoCompose._compare_transcript_to_script(dummy, "")
|
|
assert any("not provided" in i for i in result["issues"])
|
|
|
|
# Transcript file missing
|
|
result = VideoCompose._compare_transcript_to_script(tmp_path / "nonexistent.json", "script")
|
|
assert any("not provided" in i for i in result["issues"])
|
|
|
|
|
|
def test_run_final_review_includes_transcript_comparison_section(tmp_path):
|
|
"""Regression: the `transcript_comparison` section must ALWAYS appear in
|
|
the final_review output — even when the caller doesn't provide a
|
|
transcript. A missing section = silent governance failure."""
|
|
import subprocess
|
|
|
|
# Build a minimal MP4 so _run_final_review can probe it.
|
|
mp4 = tmp_path / "out.mp4"
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg", "-y",
|
|
"-f", "lavfi", "-i", "color=c=#000000:s=320x240:d=2",
|
|
"-f", "lavfi", "-i", "sine=frequency=440:duration=2",
|
|
"-c:v", "libx264", "-pix_fmt", "yuv420p",
|
|
"-c:a", "aac", "-shortest", str(mp4),
|
|
],
|
|
capture_output=True, check=True, timeout=30,
|
|
)
|
|
|
|
review = VideoCompose()._run_final_review(
|
|
mp4,
|
|
edit_decisions={
|
|
"version": "1.0",
|
|
"renderer_family": "animation-first",
|
|
"render_runtime": "hyperframes",
|
|
"cuts": [{"id": "c1", "source": "x", "in_seconds": 0, "out_seconds": 2}],
|
|
},
|
|
)
|
|
assert "transcript_comparison" in review["checks"], (
|
|
"final_review must always include a transcript_comparison section. "
|
|
"When the caller doesn't provide a transcript, the section should "
|
|
"still appear with a 'skipped' issue entry — not be omitted."
|
|
)
|
|
tc = review["checks"]["transcript_comparison"]
|
|
assert any("not provided" in i for i in tc["issues"])
|
|
|
|
|
|
def test_hyperframes_root_composition_has_data_start_and_duration(tmp_path):
|
|
"""Regression: the generated root composition was missing data-start
|
|
and data-duration, violating the HyperFrames contract (SKILL.md table)."""
|
|
asset = tmp_path / "hero.png"
|
|
asset.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 512)
|
|
workspace = tmp_path / "hyperframes"
|
|
result = HyperFramesCompose().execute(
|
|
{
|
|
"operation": "scaffold_workspace",
|
|
"workspace_path": str(workspace),
|
|
"edit_decisions": {
|
|
"version": "1.0",
|
|
"render_runtime": "hyperframes",
|
|
"renderer_family": "animation-first",
|
|
"cuts": [
|
|
{
|
|
"id": "c1",
|
|
"source": "a1",
|
|
"in_seconds": 0,
|
|
"out_seconds": 5,
|
|
"type": "image",
|
|
}
|
|
],
|
|
},
|
|
"asset_manifest": {"assets": [{"id": "a1", "path": str(asset)}]},
|
|
}
|
|
)
|
|
assert result.success, result.error
|
|
html = (workspace / "index.html").read_text(encoding="utf-8")
|
|
# Must have all four required root attributes per the HyperFrames contract.
|
|
assert 'data-composition-id="root"' in html
|
|
assert 'data-start="0"' in html # per SKILL.md: root composition: use "0"
|
|
# data-duration must match the timeline total; value can be '5' or '5.0' etc.
|
|
import re
|
|
m = re.search(r'data-duration="([^"]+)"', html)
|
|
assert m, "root composition missing data-duration"
|
|
assert float(m.group(1)) == pytest.approx(5.0)
|
|
assert 'data-width="1920"' in html
|
|
assert 'data-height="1080"' in html
|
|
|
|
|
|
def test_video_compose_blocks_hyperframes_when_runtime_unavailable(
|
|
tmp_path, monkeypatch
|
|
):
|
|
"""Governance: if render_runtime='hyperframes' is locked but runtime is
|
|
missing, the tool must NOT silently substitute another engine."""
|
|
|
|
# Force HyperFrames availability to False regardless of the machine state.
|
|
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",
|
|
"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 "blocker" in err or "not available" in err
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Scaffold / workspace generation (no CLI invocation)
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
def test_scaffold_workspace_generates_html_and_assets(tmp_path: Path):
|
|
# Build a minimal asset manifest + edit decisions referencing a real
|
|
# file so the staging copy has something to move.
|
|
asset = tmp_path / "hero.png"
|
|
asset.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 1024)
|
|
|
|
workspace = tmp_path / "hyperframes"
|
|
edit_decisions: dict[str, Any] = {
|
|
"version": "1.0",
|
|
"renderer_family": "animation-first",
|
|
"render_runtime": "hyperframes",
|
|
"cuts": [
|
|
{
|
|
"id": "c1",
|
|
"source": "asset_hero",
|
|
"in_seconds": 0,
|
|
"out_seconds": 3,
|
|
"type": "image",
|
|
},
|
|
{
|
|
"id": "c2",
|
|
"source": "",
|
|
"in_seconds": 3,
|
|
"out_seconds": 6,
|
|
"type": "text_card",
|
|
"text": "Hello HyperFrames",
|
|
},
|
|
],
|
|
}
|
|
asset_manifest = {
|
|
"assets": [{"id": "asset_hero", "path": str(asset)}],
|
|
}
|
|
|
|
result = HyperFramesCompose().execute(
|
|
{
|
|
"operation": "scaffold_workspace",
|
|
"workspace_path": str(workspace),
|
|
"edit_decisions": edit_decisions,
|
|
"asset_manifest": asset_manifest,
|
|
"playbook": {
|
|
"name": "test-playbook",
|
|
"visual_language": {
|
|
"color_palette": {
|
|
"background": "#0B0F1A",
|
|
"text": "#F5F5F5",
|
|
"accent": "#F59E0B",
|
|
}
|
|
},
|
|
"typography": {
|
|
"heading": {"font": "Inter"},
|
|
"body": {"font": "Inter"},
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
assert result.success, result.error
|
|
index = workspace / "index.html"
|
|
assert index.is_file()
|
|
html = index.read_text(encoding="utf-8")
|
|
|
|
# HyperFrames authoring contract requirements we MUST emit:
|
|
assert 'data-composition-id="root"' in html
|
|
assert 'window.__timelines["root"]' in html
|
|
assert 'paused: true' in html
|
|
assert 'class="clip' in html
|
|
assert "gsap" in html.lower()
|
|
|
|
# Text card for c2 must carry data-start and data-duration.
|
|
assert 'data-start="3"' in html
|
|
assert 'Hello HyperFrames' in html
|
|
|
|
# Image asset was staged into the workspace.
|
|
staged = workspace / "assets" / "hero.png"
|
|
assert staged.is_file()
|
|
# And index.html references it via a relative path.
|
|
assert "assets/hero.png" in html
|
|
|
|
# hyperframes.json registry config was written.
|
|
hf_json = workspace / "hyperframes.json"
|
|
assert hf_json.is_file()
|
|
config = json.loads(hf_json.read_text(encoding="utf-8"))
|
|
assert config["paths"]["blocks"] == "compositions"
|
|
|
|
# DESIGN.md was written from the playbook.
|
|
design = workspace / "DESIGN.md"
|
|
assert design.is_file()
|
|
design_text = design.read_text(encoding="utf-8")
|
|
assert "#0B0F1A" in design_text or "test-playbook" in design_text
|
|
|
|
|
|
def test_scaffold_rejects_empty_cuts(tmp_path: Path):
|
|
result = HyperFramesCompose().execute(
|
|
{
|
|
"operation": "scaffold_workspace",
|
|
"workspace_path": str(tmp_path / "hyperframes"),
|
|
"edit_decisions": {"version": "1.0", "cuts": []},
|
|
"asset_manifest": {"assets": []},
|
|
}
|
|
)
|
|
assert not result.success
|
|
assert "cuts" in (result.error or "").lower()
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Style bridge
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
def test_style_bridge_fallback_has_all_required_vars():
|
|
from lib.hyperframes_style_bridge import style_bridge
|
|
|
|
css, design = style_bridge(None, None)
|
|
for key in (
|
|
"--color-bg",
|
|
"--color-fg",
|
|
"--color-accent",
|
|
"--color-primary",
|
|
"--font-heading",
|
|
"--font-body",
|
|
"--ease-primary",
|
|
"--duration-entrance",
|
|
):
|
|
assert key in css, f"missing CSS var: {key}"
|
|
assert "# DESIGN" in design
|
|
|
|
|
|
def test_style_bridge_picks_up_playbook_palette():
|
|
from lib.hyperframes_style_bridge import style_bridge
|
|
|
|
playbook = {
|
|
"name": "neon-test",
|
|
"visual_language": {
|
|
"color_palette": {
|
|
"background": "#000000",
|
|
"text": "#FFFFFF",
|
|
"accent": ["#FF00FF", "#FF66FF"],
|
|
"primary": "#00FFFF",
|
|
}
|
|
},
|
|
"typography": {
|
|
"heading": {"font": "Space Grotesk"},
|
|
"body": {"font": "Inter"},
|
|
},
|
|
"motion": {"pace": "fast"},
|
|
}
|
|
css, design = style_bridge(playbook, None)
|
|
assert css["--color-bg"] == "#000000"
|
|
assert css["--color-accent"] == "#FF00FF" # list → first
|
|
assert css["--color-primary"] == "#00FFFF"
|
|
assert css["--font-heading"] == "Space Grotesk"
|
|
# Fast pace → shorter entrance duration.
|
|
assert css["--duration-entrance"].startswith("0.")
|
|
assert "neon-test" in design
|
|
|
|
|
|
def test_style_bridge_edit_decision_override_wins():
|
|
from lib.hyperframes_style_bridge import style_bridge
|
|
|
|
playbook = {
|
|
"visual_language": {"color_palette": {"background": "#111", "text": "#eee"}},
|
|
}
|
|
edit = {"metadata": {"background_color": "#fff", "accent_color": "#09f"}}
|
|
css, _ = style_bridge(playbook, edit)
|
|
assert css["--color-bg"] == "#fff"
|
|
assert css["--color-accent"] == "#09f"
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Schema acceptance for render_runtime
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
def test_proposal_packet_schema_accepts_render_runtime():
|
|
schema_path = (
|
|
Path(__file__).resolve().parent.parent.parent
|
|
/ "schemas"
|
|
/ "artifacts"
|
|
/ "proposal_packet.schema.json"
|
|
)
|
|
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
|
props = schema["properties"]["production_plan"]["properties"]
|
|
assert "render_runtime" in props
|
|
assert "renderer_family" in props
|
|
assert props["render_runtime"]["enum"] == ["remotion", "hyperframes", "ffmpeg"]
|
|
|
|
|
|
def test_edit_decisions_schema_accepts_render_runtime():
|
|
schema_path = (
|
|
Path(__file__).resolve().parent.parent.parent
|
|
/ "schemas"
|
|
/ "artifacts"
|
|
/ "edit_decisions.schema.json"
|
|
)
|
|
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
|
assert "render_runtime" in schema["properties"]
|
|
assert schema["properties"]["render_runtime"]["enum"] == [
|
|
"remotion",
|
|
"hyperframes",
|
|
"ffmpeg",
|
|
]
|
|
|
|
|
|
def test_final_review_tracks_runtime_and_swap():
|
|
schema_path = (
|
|
Path(__file__).resolve().parent.parent.parent
|
|
/ "schemas"
|
|
/ "artifacts"
|
|
/ "final_review.schema.json"
|
|
)
|
|
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
|
pp = schema["properties"]["checks"]["properties"]["promise_preservation"][
|
|
"properties"
|
|
]
|
|
assert "render_runtime_used" in pp
|
|
assert "runtime_swap_detected" in pp
|
|
|
|
|
|
def test_decision_log_has_render_runtime_category():
|
|
schema_path = (
|
|
Path(__file__).resolve().parent.parent.parent
|
|
/ "schemas"
|
|
/ "artifacts"
|
|
/ "decision_log.schema.json"
|
|
)
|
|
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
|
category_enum = schema["properties"]["decisions"]["items"]["properties"][
|
|
"category"
|
|
]["enum"]
|
|
assert "render_runtime_selection" in category_enum
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Slideshow risk runtime threading
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
def test_slideshow_risk_accepts_render_runtime():
|
|
from lib.slideshow_risk import score_slideshow_risk
|
|
|
|
scenes = [
|
|
{
|
|
"type": "image",
|
|
"description": "Opening shot of city",
|
|
"shot_language": {"shot_size": "wide"},
|
|
"shot_intent": "establish",
|
|
},
|
|
{
|
|
"type": "text_card",
|
|
"description": "Title overlay",
|
|
"shot_language": {"shot_size": "medium"},
|
|
"shot_intent": "announce",
|
|
},
|
|
]
|
|
out = score_slideshow_risk(scenes, render_runtime="hyperframes")
|
|
assert out["render_runtime"] == "hyperframes"
|
|
assert out["verdict"] in {"strong", "acceptable", "revise", "fail"}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Composition validator runtime awareness
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
def test_composition_validator_hyperframes_asset_root(tmp_path: Path):
|
|
"""With render_runtime='hyperframes', the validator should look for
|
|
assets next to index.html, not under remotion-composer/public."""
|
|
from tools.analysis.composition_validator import CompositionValidator
|
|
|
|
# Set up a fake HyperFrames workspace: hyperframes/ with index.html +
|
|
# assets/. Composition JSON lives a sibling directory away.
|
|
workspace = tmp_path / "hyperframes"
|
|
(workspace / "assets").mkdir(parents=True)
|
|
(workspace / "index.html").write_text("<!-- stub -->", encoding="utf-8")
|
|
asset = workspace / "assets" / "hero.png"
|
|
asset.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 10)
|
|
|
|
artifacts_dir = tmp_path / "artifacts"
|
|
artifacts_dir.mkdir()
|
|
comp_json = artifacts_dir / "comp.json"
|
|
comp_json.write_text(
|
|
json.dumps(
|
|
{
|
|
"render_runtime": "hyperframes",
|
|
"cuts": [
|
|
{
|
|
"id": "c1",
|
|
"source": "hero.png",
|
|
"in_seconds": 0,
|
|
"out_seconds": 3,
|
|
}
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = CompositionValidator().execute({"composition_path": str(comp_json)})
|
|
# Asset exists in workspace/assets/ — should resolve without errors.
|
|
assert result.success, result.error
|
|
info_lines = " ".join(result.data.get("info", []))
|
|
assert "hyperframes" in info_lines.lower() or "assets" in info_lines.lower()
|