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:
@@ -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
|
||||
Reference in New Issue
Block a user