hyperframes: add HTML/CSS/GSAP as a parallel composition runtime
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.
This commit is contained in:
@@ -105,6 +105,7 @@ def sample_artifact(name: str) -> dict:
|
||||
"selected_concept": {"concept_id": "c1", "rationale": "Strongest research backing"},
|
||||
"production_plan": {
|
||||
"pipeline": "animated-explainer",
|
||||
"render_runtime": "remotion",
|
||||
"stages": [
|
||||
{"stage": "script", "tools": [], "approach": "Write from research"},
|
||||
{"stage": "assets", "tools": [{"tool_name": "tts_selector", "role": "narration", "available": True}], "approach": "Generate assets"},
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Enforce the 'present both composition runtimes' governance contract.
|
||||
|
||||
For every pipeline in `pipeline_defs/`, the planning-stage skill (proposal
|
||||
or idea) MUST instruct the agent about runtime selection — either by
|
||||
presenting both runtimes to the user when they are a real choice, or by
|
||||
surfacing the constraint when the pipeline is locked to one runtime.
|
||||
|
||||
This test prevents a new pipeline from being added without the conversation
|
||||
contract. A fresh-session agent that reads a pipeline's planning skill and
|
||||
finds no runtime guidance will silently default to Remotion — which is the
|
||||
exact failure mode this contract prevents.
|
||||
|
||||
See:
|
||||
- AGENT_GUIDE.md → "Present Both Composition Runtimes (HARD RULE)"
|
||||
- skills/core/hyperframes.md → "Hard rule: present both runtimes"
|
||||
- skills/meta/reviewer.md → finding #6
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
PIPELINE_DIR = ROOT / "pipeline_defs"
|
||||
SKILLS_DIR = ROOT / "skills"
|
||||
|
||||
# Tokens we expect in any compliant planning-stage skill. A skill needs AT
|
||||
# LEAST one from each group to pass. The groups are intentionally loose —
|
||||
# this test is a tripwire, not a style enforcer.
|
||||
_REQUIRED_RUNTIME_TOKENS = [
|
||||
"render_runtime", # the field name must appear
|
||||
"hyperframes", # the alternative runtime must be named
|
||||
]
|
||||
# And at least one of these phrases showing the conversation-not-default contract.
|
||||
_CONVERSATION_TOKENS = [
|
||||
"present both",
|
||||
"Present Both",
|
||||
"PRESENT BOTH",
|
||||
"render_runtime_selection", # pointing at the decision_log category
|
||||
]
|
||||
|
||||
|
||||
def _planning_stages(manifest: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return every 'proposal' or 'idea' stage (a pipeline may have one or both)."""
|
||||
out: list[dict[str, Any]] = []
|
||||
for stage in manifest.get("stages", []):
|
||||
if stage.get("name") in {"proposal", "idea"}:
|
||||
out.append(stage)
|
||||
return out
|
||||
|
||||
|
||||
def _load_manifest(path: Path) -> dict[str, Any]:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def _load_skill(skill_ref: str) -> tuple[Path, str]:
|
||||
"""Resolve a manifest `skill:` string to its markdown path + contents."""
|
||||
candidate = SKILLS_DIR / f"{skill_ref}.md"
|
||||
assert candidate.is_file(), (
|
||||
f"Manifest references skill {skill_ref!r} but {candidate} does not exist."
|
||||
)
|
||||
return candidate, candidate.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
ALL_MANIFESTS = sorted(PIPELINE_DIR.glob("*.yaml"))
|
||||
assert ALL_MANIFESTS, "No pipeline manifests found"
|
||||
|
||||
# Test-only pipelines that don't compose final video go on this list with
|
||||
# an explicit reason. Everything else is required to follow the contract.
|
||||
_EXCLUDED_PIPELINES = {
|
||||
"framework-smoke": "minimal 2-stage smoke test, no compose stage",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"manifest_path",
|
||||
[p for p in ALL_MANIFESTS if p.stem not in _EXCLUDED_PIPELINES],
|
||||
ids=lambda p: p.stem,
|
||||
)
|
||||
def test_planning_skill_mentions_runtime_contract(manifest_path: Path):
|
||||
"""Every pipeline that reaches compose must have runtime guidance in its
|
||||
planning-stage skill."""
|
||||
manifest = _load_manifest(manifest_path)
|
||||
planning = _planning_stages(manifest)
|
||||
assert planning, (
|
||||
f"Pipeline {manifest_path.stem} has no 'proposal' or 'idea' stage. "
|
||||
f"Add one, or add this pipeline to _EXCLUDED_PIPELINES with a reason."
|
||||
)
|
||||
|
||||
# At least ONE of the planning skills in this pipeline must cover the
|
||||
# contract (pipelines with both proposal+idea only need one to carry it).
|
||||
matched_skill: str | None = None
|
||||
matched_why: dict[str, bool] = {}
|
||||
for stage in planning:
|
||||
skill_ref = stage.get("skill")
|
||||
if not skill_ref:
|
||||
continue
|
||||
_, body = _load_skill(skill_ref)
|
||||
covers_required = all(token in body for token in _REQUIRED_RUNTIME_TOKENS)
|
||||
covers_conversation = any(token in body for token in _CONVERSATION_TOKENS)
|
||||
if covers_required and covers_conversation:
|
||||
matched_skill = skill_ref
|
||||
matched_why = {
|
||||
"mentions_render_runtime": "render_runtime" in body,
|
||||
"mentions_hyperframes": "hyperframes" in body,
|
||||
"conversation_token_found": covers_conversation,
|
||||
}
|
||||
break
|
||||
|
||||
assert matched_skill, (
|
||||
f"Pipeline {manifest_path.stem}: no planning-stage skill covers the "
|
||||
f"runtime-selection contract. Each pipeline's proposal- or idea-director "
|
||||
f"must discuss render_runtime, name hyperframes, and either 'Present both' "
|
||||
f"or a `render_runtime_selection` decision. A fresh-session agent reading "
|
||||
f"this pipeline's plan would silently default to Remotion. Fix the skill "
|
||||
f"that drives the planning stage."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"manifest_path",
|
||||
[p for p in ALL_MANIFESTS if p.stem not in _EXCLUDED_PIPELINES],
|
||||
ids=lambda p: p.stem,
|
||||
)
|
||||
def test_compose_stage_references_runtime_routing(manifest_path: Path):
|
||||
"""Compose stage's director skill must also cover runtime routing, so
|
||||
that even a pipeline whose planning skill somehow misses the contract
|
||||
cannot silently render under the wrong runtime."""
|
||||
manifest = _load_manifest(manifest_path)
|
||||
compose_stage = next(
|
||||
(s for s in manifest.get("stages", []) if s.get("name") == "compose"),
|
||||
None,
|
||||
)
|
||||
if compose_stage is None:
|
||||
# Some pipelines use alternate terminal stages; skip.
|
||||
pytest.skip(f"{manifest_path.stem} has no 'compose' stage")
|
||||
|
||||
skill_ref = compose_stage.get("skill")
|
||||
assert skill_ref, f"{manifest_path.stem} compose stage has no skill reference"
|
||||
_, body = _load_skill(skill_ref)
|
||||
# Compose-directors must at minimum mention render_runtime AND either
|
||||
# route by runtime or surface a hard constraint (HyperFrames deferred).
|
||||
assert "render_runtime" in body, (
|
||||
f"{skill_ref} does not mention render_runtime. Compose MUST route by "
|
||||
f"render_runtime; without this instruction the agent will fall back to "
|
||||
f"the tool's legacy behavior (silently pick Remotion)."
|
||||
)
|
||||
# Must also mention HyperFrames explicitly so a reviewer can tell the
|
||||
# author considered it and either enabled or rejected it with reason.
|
||||
assert re.search(r"hyperframes|HyperFrames", body), (
|
||||
f"{skill_ref} does not mention HyperFrames at all. Even on deferred "
|
||||
f"pipelines, the compose-director must name HyperFrames so the agent "
|
||||
f"can surface the constraint to the user rather than silently pick "
|
||||
f"Remotion. See documentary-montage or talking-head compose-director "
|
||||
f"for the deferred-pipeline template."
|
||||
)
|
||||
|
||||
|
||||
def test_agent_guide_carries_hard_rule():
|
||||
"""The top-level agent contract must carry the HARD RULE banner so every
|
||||
fresh-session agent reads it before picking a pipeline."""
|
||||
guide = (ROOT / "AGENT_GUIDE.md").read_text(encoding="utf-8")
|
||||
assert "Present Both Composition Runtimes" in guide
|
||||
assert "HARD RULE" in guide
|
||||
# The rule must explicitly forbid the failure mode.
|
||||
assert "silently" in guide.lower()
|
||||
|
||||
|
||||
def test_reviewer_has_critical_finding_for_single_option_runtime():
|
||||
"""The reviewer meta-skill must treat a single-option render_runtime_selection
|
||||
as CRITICAL — otherwise the governance rule has no enforcement at review
|
||||
time and a bypass slips through unnoticed."""
|
||||
body = (SKILLS_DIR / "meta" / "reviewer.md").read_text(encoding="utf-8")
|
||||
# Locate the critical-severity rule explicitly.
|
||||
assert "render_runtime_selection" in body
|
||||
# The section must carry CRITICAL severity language tied to single-option.
|
||||
assert re.search(
|
||||
r"render_runtime_selection.{0,800}(CRITICAL|critical)",
|
||||
body,
|
||||
re.DOTALL,
|
||||
), (
|
||||
"Reviewer skill doesn't flag single-option render_runtime_selection "
|
||||
"as CRITICAL — the conversation contract has no teeth."
|
||||
)
|
||||
@@ -29,6 +29,7 @@ class BenchScenario:
|
||||
scenes: list[dict[str, Any]]
|
||||
edit_decisions: dict[str, Any] | None = None
|
||||
renderer_family: str | None = None
|
||||
render_runtime: str | None = None # remotion | hyperframes | ffmpeg (optional)
|
||||
delivery_promise: dict[str, Any] | None = None
|
||||
cuts: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
@@ -365,7 +366,9 @@ def run_bench(scenarios: list[BenchScenario], verbose: bool = False) -> list[Ben
|
||||
if sc.expected_slideshow_verdict is not None:
|
||||
try:
|
||||
from lib.slideshow_risk import score_slideshow_risk
|
||||
risk = score_slideshow_risk(sc.scenes, sc.edit_decisions, sc.renderer_family)
|
||||
risk = score_slideshow_risk(
|
||||
sc.scenes, sc.edit_decisions, sc.renderer_family, sc.render_runtime
|
||||
)
|
||||
actual = risk["verdict"]
|
||||
ok = _verdict_matches(sc.expected_slideshow_verdict, actual)
|
||||
result.checks["slideshow_risk"] = {
|
||||
|
||||
@@ -203,6 +203,7 @@ proposal_packet = {
|
||||
"production_plan": {
|
||||
"pipeline": "animated-explainer",
|
||||
"playbook": "clean-professional",
|
||||
"render_runtime": "remotion",
|
||||
"stages": [
|
||||
{"stage": "script", "tools": [{"tool_name": "tts_selector", "role": "narration", "available": True}], "approach": "AI-written script with TTS narration"},
|
||||
{"stage": "scene_plan", "tools": [], "approach": "5 scenes with motion graphics"},
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""QA Test 09: HyperFrames end-to-end — scaffold + lint + validate + render.
|
||||
|
||||
This test hits the real HyperFrames CLI via `npx @hyperframes/cli`. On first
|
||||
run, npm fetches the package (slow — ~30-90s) and then Chrome downloads its
|
||||
browser for validation (~30s extra, cached thereafter). Skip unless
|
||||
HYPERFRAMES_QA=1 is set so CI doesn't pay the cost on every run.
|
||||
|
||||
The test is still valuable even without `--render`:
|
||||
- scaffold_workspace proves the workspace generator emits a contract-valid
|
||||
composition.
|
||||
- lint exercises the static contract checker.
|
||||
- validate exercises the browser-based contract + contrast audit.
|
||||
|
||||
Full render (operation='render') is optional and gated on HYPERFRAMES_QA_RENDER=1.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||
|
||||
|
||||
from tools.video.hyperframes_compose import HyperFramesCompose
|
||||
|
||||
|
||||
OUT = Path(__file__).resolve().parent / "output"
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
_SKIP_REASON = (
|
||||
"HyperFrames QA is opt-in. Set HYPERFRAMES_QA=1 to run scaffold+lint+validate, "
|
||||
"and HYPERFRAMES_QA_RENDER=1 to additionally run the real render."
|
||||
)
|
||||
|
||||
|
||||
def _runtime_ready() -> bool:
|
||||
"""Cheap check — don't bother launching the CLI if the floor isn't met."""
|
||||
return HyperFramesCompose()._runtime_check()["runtime_available"]
|
||||
|
||||
|
||||
def _make_fixture_asset(dest_dir: Path, name: str = "hero.png") -> Path:
|
||||
"""Generate a real PNG with ffmpeg so the browser actually has something to load."""
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
out = dest_dir / name
|
||||
if out.exists():
|
||||
return out
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y",
|
||||
"-f", "lavfi", "-i", "color=c=#2563EB:s=1920x1080:d=1",
|
||||
"-frames:v", "1", str(out),
|
||||
],
|
||||
capture_output=True, check=True, timeout=30,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _minimal_scenario(workspace: Path, asset: Path) -> dict:
|
||||
return {
|
||||
"operation": "render",
|
||||
"workspace_path": str(workspace),
|
||||
"output_path": str(workspace / "renders" / "smoke.mp4"),
|
||||
"edit_decisions": {
|
||||
"version": "1.0",
|
||||
"renderer_family": "animation-first",
|
||||
"render_runtime": "hyperframes",
|
||||
"cuts": [
|
||||
{"id": "c1", "source": "hero_asset", "in_seconds": 0, "out_seconds": 2, "type": "image"},
|
||||
{"id": "c2", "source": "", "in_seconds": 2, "out_seconds": 5,
|
||||
"type": "text_card", "text": "HyperFrames smoke test",
|
||||
"subtitle": "scaffold + lint + validate + render"},
|
||||
],
|
||||
},
|
||||
"asset_manifest": {"assets": [{"id": "hero_asset", "path": str(asset)}]},
|
||||
"playbook": {
|
||||
"name": "smoke-test",
|
||||
"visual_language": {
|
||||
"color_palette": {
|
||||
"background": "#0F172A",
|
||||
"text": "#F8FAFC",
|
||||
"accent": "#F59E0B",
|
||||
"primary": "#2563EB",
|
||||
}
|
||||
},
|
||||
"typography": {
|
||||
"heading": {"font": "Inter"},
|
||||
"body": {"font": "Inter"},
|
||||
},
|
||||
"motion": {"pace": "moderate"},
|
||||
},
|
||||
"quality": "draft",
|
||||
"fps": 30,
|
||||
"skip_contrast": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not os.environ.get("HYPERFRAMES_QA"), reason=_SKIP_REASON)
|
||||
def test_hyperframes_scaffold_lint_validate(tmp_path: Path):
|
||||
if not _runtime_ready():
|
||||
pytest.skip("HyperFrames runtime floor not met (node>=22 + ffmpeg + npx).")
|
||||
|
||||
asset = _make_fixture_asset(tmp_path / "assets_src")
|
||||
workspace = tmp_path / "hyperframes"
|
||||
inputs = _minimal_scenario(workspace, asset)
|
||||
|
||||
# 1. Scaffold
|
||||
scaffold = HyperFramesCompose().execute(
|
||||
{**inputs, "operation": "scaffold_workspace"}
|
||||
)
|
||||
assert scaffold.success, scaffold.error
|
||||
assert (workspace / "index.html").is_file()
|
||||
assert (workspace / "assets" / "hero.png").is_file()
|
||||
assert (workspace / "hyperframes.json").is_file()
|
||||
|
||||
# 2. Lint — the CLI fetch happens here on cold cache. Allow plenty of time.
|
||||
lint = HyperFramesCompose().execute(
|
||||
{"operation": "lint", "workspace_path": str(workspace)}
|
||||
)
|
||||
# A fresh scaffold should lint clean; if it doesn't, the generator has a bug.
|
||||
assert lint.success, (
|
||||
f"Lint failed on a freshly scaffolded workspace — "
|
||||
f"generator contract violation.\nError: {lint.error}\n"
|
||||
f"Stdout tail: {lint.data.get('stdout_tail')}\n"
|
||||
f"Stderr tail: {lint.data.get('stderr_tail')}"
|
||||
)
|
||||
|
||||
# 3. Validate — browser-based. Skip contrast since our placeholder colors
|
||||
# aren't tuned for WCAG.
|
||||
validate = HyperFramesCompose().execute(
|
||||
{
|
||||
"operation": "validate",
|
||||
"workspace_path": str(workspace),
|
||||
"skip_contrast": True,
|
||||
}
|
||||
)
|
||||
# Validate may return non-zero if the composition has warnings vs errors;
|
||||
# what we care about is that it at least ran and produced a report.
|
||||
assert "exit_code" in validate.data
|
||||
assert validate.data.get("stderr_tail") is not None or validate.data.get("report")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not (
|
||||
os.environ.get("HYPERFRAMES_QA")
|
||||
and os.environ.get("HYPERFRAMES_QA_RENDER")
|
||||
),
|
||||
reason=_SKIP_REASON,
|
||||
)
|
||||
def test_hyperframes_full_render(tmp_path: Path):
|
||||
"""Full render — slow (~1-3 min). Opt in with both env vars."""
|
||||
if not _runtime_ready():
|
||||
pytest.skip("HyperFrames runtime floor not met.")
|
||||
|
||||
asset = _make_fixture_asset(tmp_path / "assets_src")
|
||||
workspace = tmp_path / "hyperframes"
|
||||
inputs = _minimal_scenario(workspace, asset)
|
||||
inputs["skip_contrast"] = True # placeholder palette isn't WCAG-tuned
|
||||
|
||||
result = HyperFramesCompose().execute(inputs)
|
||||
assert result.success, (
|
||||
f"Full render failed: {result.error}\n"
|
||||
f"Steps: {result.data.get('steps')}"
|
||||
)
|
||||
out_mp4 = Path(result.data["output"])
|
||||
assert out_mp4.is_file(), f"No MP4 at {out_mp4}"
|
||||
assert out_mp4.stat().st_size > 5000, "Output suspiciously small"
|
||||
|
||||
# Probe it — real MP4 must have a valid video stream.
|
||||
probe = subprocess.run(
|
||||
[
|
||||
"ffprobe", "-v", "error",
|
||||
"-select_streams", "v:0",
|
||||
"-show_entries", "stream=codec_name,duration",
|
||||
"-of", "default=nw=1",
|
||||
str(out_mp4),
|
||||
],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert probe.returncode == 0, probe.stderr
|
||||
assert "codec_name" in probe.stdout
|
||||
@@ -108,6 +108,65 @@ 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
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user