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:
calesthio
2026-04-18 18:37:00 -07:00
parent 9e17263aa5
commit b4f7ec4eee
118 changed files with 10128 additions and 126 deletions
+1
View File
@@ -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."
)
+4 -1
View File
@@ -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"] = {
+1
View File
@@ -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"},
+189
View File
@@ -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