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:
@@ -0,0 +1,194 @@
|
||||
"""Bridge OpenMontage playbooks → HyperFrames-friendly style artifacts.
|
||||
|
||||
OpenMontage playbooks are YAML files describing visual_language, typography,
|
||||
motion, and asset generation guidance. When `render_runtime = "remotion"`
|
||||
they're translated into a `themeConfig` prop on the React composition
|
||||
(see `tools/video/video_compose._build_theme_from_playbook`). When
|
||||
`render_runtime = "hyperframes"` the equivalent translation produces:
|
||||
|
||||
- a dict of CSS custom properties to emit on `:root`
|
||||
- a short DESIGN.md the workspace can ship so humans (and agents) can
|
||||
inspect the visual system in plain language
|
||||
|
||||
The playbook schema is NOT forked. We read the same fields; we just render
|
||||
them differently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
_FALLBACK_CSS_VARS = {
|
||||
"--color-bg": "#0B0F1A",
|
||||
"--color-fg": "#F5F5F5",
|
||||
"--color-accent": "#F59E0B",
|
||||
"--color-primary": "#2563EB",
|
||||
"--color-secondary": "#10B981",
|
||||
"--color-surface": "#111827",
|
||||
"--color-muted": "#6B7280",
|
||||
"--font-heading": "Inter",
|
||||
"--font-body": "Inter",
|
||||
"--font-mono": "JetBrains Mono",
|
||||
"--ease-primary": "cubic-bezier(0.65, 0, 0.35, 1)",
|
||||
"--ease-entrance": "cubic-bezier(0.33, 1, 0.68, 1)",
|
||||
"--ease-exit": "cubic-bezier(0.32, 0, 0.67, 0)",
|
||||
"--duration-entrance": "0.6s",
|
||||
"--duration-transition": "0.5s",
|
||||
}
|
||||
|
||||
|
||||
def _first(raw: Any, default: str) -> str:
|
||||
"""Return the first string value from a palette entry (list or scalar)."""
|
||||
if isinstance(raw, list) and raw:
|
||||
return str(raw[0])
|
||||
if isinstance(raw, str) and raw:
|
||||
return raw
|
||||
return default
|
||||
|
||||
|
||||
def _font(typo: dict[str, Any], key: str, default: str) -> str:
|
||||
"""Extract a font family string from a typography block."""
|
||||
node = typo.get(key) or {}
|
||||
if isinstance(node, dict):
|
||||
return str(node.get("font") or node.get("family") or default)
|
||||
if isinstance(node, str):
|
||||
return node
|
||||
return default
|
||||
|
||||
|
||||
def _motion_easing(motion: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Derive (duration, ease) from the playbook motion block."""
|
||||
pace = (motion.get("pace") or "moderate").lower()
|
||||
if pace == "fast":
|
||||
return "0.4s", "cubic-bezier(0.33, 1, 0.68, 1)"
|
||||
if pace == "slow":
|
||||
return "0.9s", "cubic-bezier(0.65, 0, 0.35, 1)"
|
||||
return "0.6s", "cubic-bezier(0.5, 0, 0.5, 1)"
|
||||
|
||||
|
||||
def style_bridge(
|
||||
playbook: dict[str, Any] | None,
|
||||
edit_decisions: dict[str, Any] | None = None,
|
||||
) -> tuple[dict[str, str], str]:
|
||||
"""Translate a playbook into (css_vars, design_md).
|
||||
|
||||
Both outputs are safe to write into a HyperFrames workspace:
|
||||
- `css_vars` → `:root { … }` block in index.html
|
||||
- `design_md` → `DESIGN.md` at the workspace root
|
||||
|
||||
When `playbook` is empty or partial, missing fields fall back to
|
||||
`_FALLBACK_CSS_VARS` so the emitted HTML is always renderable.
|
||||
"""
|
||||
css: dict[str, str] = dict(_FALLBACK_CSS_VARS)
|
||||
source_note = "built-in fallback palette"
|
||||
playbook_name = ""
|
||||
|
||||
if playbook:
|
||||
playbook_name = str(
|
||||
playbook.get("name")
|
||||
or playbook.get("id")
|
||||
or playbook.get("display_name")
|
||||
or ""
|
||||
)
|
||||
vl = playbook.get("visual_language", {}) or {}
|
||||
palette = vl.get("color_palette", {}) or {}
|
||||
typo = playbook.get("typography", {}) or {}
|
||||
motion = playbook.get("motion", {}) or {}
|
||||
|
||||
bg = _first(palette.get("background"), css["--color-bg"])
|
||||
fg = _first(palette.get("text"), css["--color-fg"])
|
||||
accent = _first(palette.get("accent"), css["--color-accent"])
|
||||
primary = _first(palette.get("primary"), css["--color-primary"])
|
||||
secondary = _first(palette.get("secondary"), css["--color-secondary"])
|
||||
surface = _first(palette.get("surface"), css["--color-surface"])
|
||||
muted = _first(palette.get("muted_text"), css["--color-muted"])
|
||||
|
||||
duration, ease = _motion_easing(motion)
|
||||
|
||||
css.update(
|
||||
{
|
||||
"--color-bg": bg,
|
||||
"--color-fg": fg,
|
||||
"--color-accent": accent,
|
||||
"--color-primary": primary,
|
||||
"--color-secondary": secondary,
|
||||
"--color-surface": surface,
|
||||
"--color-muted": muted,
|
||||
"--font-heading": _font(typo, "heading", css["--font-heading"]),
|
||||
"--font-body": _font(typo, "body", css["--font-body"]),
|
||||
"--font-mono": _font(typo, "code", css["--font-mono"]),
|
||||
"--ease-primary": ease,
|
||||
"--duration-entrance": duration,
|
||||
}
|
||||
)
|
||||
|
||||
source_note = f"playbook `{playbook_name}`" if playbook_name else "loaded playbook"
|
||||
|
||||
# Edit-decisions may override colors per-production (rare but legal).
|
||||
if edit_decisions:
|
||||
meta = edit_decisions.get("metadata", {}) or {}
|
||||
if meta.get("primary_color"):
|
||||
css["--color-primary"] = str(meta["primary_color"])
|
||||
if meta.get("accent_color"):
|
||||
css["--color-accent"] = str(meta["accent_color"])
|
||||
if meta.get("background_color"):
|
||||
css["--color-bg"] = str(meta["background_color"])
|
||||
if meta.get("text_color"):
|
||||
css["--color-fg"] = str(meta["text_color"])
|
||||
|
||||
design_md = _render_design_md(css, source_note, playbook_name)
|
||||
return css, design_md
|
||||
|
||||
|
||||
def _render_design_md(
|
||||
css: dict[str, str], source_note: str, playbook_name: str
|
||||
) -> str:
|
||||
title = f"# DESIGN — {playbook_name}" if playbook_name else "# DESIGN"
|
||||
lines = [
|
||||
title,
|
||||
"",
|
||||
f"> Generated by OpenMontage HyperFrames style bridge (source: {source_note}).",
|
||||
"",
|
||||
"## Colors",
|
||||
"",
|
||||
f"- Background: `{css['--color-bg']}`",
|
||||
f"- Foreground: `{css['--color-fg']}`",
|
||||
f"- Primary: `{css['--color-primary']}`",
|
||||
f"- Accent: `{css['--color-accent']}`",
|
||||
f"- Secondary: `{css['--color-secondary']}`",
|
||||
f"- Surface: `{css['--color-surface']}`",
|
||||
f"- Muted text: `{css['--color-muted']}`",
|
||||
"",
|
||||
"## Typography",
|
||||
"",
|
||||
f"- Heading: `{css['--font-heading']}`",
|
||||
f"- Body: `{css['--font-body']}`",
|
||||
f"- Mono: `{css['--font-mono']}`",
|
||||
"",
|
||||
"## Motion",
|
||||
"",
|
||||
f"- Primary ease: `{css['--ease-primary']}`",
|
||||
f"- Entrance duration: `{css['--duration-entrance']}`",
|
||||
"",
|
||||
"## How to use in compositions",
|
||||
"",
|
||||
"Reference these tokens via `var(...)` instead of hard-coding hex",
|
||||
"values or font names:",
|
||||
"",
|
||||
"```css",
|
||||
".scene-content h1 {",
|
||||
" font-family: var(--font-heading);",
|
||||
" color: var(--color-fg);",
|
||||
"}",
|
||||
".scene-content .accent {",
|
||||
" color: var(--color-accent);",
|
||||
"}",
|
||||
"```",
|
||||
"",
|
||||
"When adjusting a composition's look, edit the `:root` block in",
|
||||
"`index.html` (or this bridge for cross-project changes), not the",
|
||||
"individual scene styles.",
|
||||
"",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
@@ -27,6 +27,7 @@ def score_slideshow_risk(
|
||||
scenes: list[dict[str, Any]],
|
||||
edit_decisions: dict[str, Any] | None = None,
|
||||
renderer_family: str | None = None,
|
||||
render_runtime: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Score slideshow risk across 6 dimensions.
|
||||
|
||||
@@ -34,12 +35,19 @@ def score_slideshow_risk(
|
||||
scenes: Scene list from scene_plan artifact.
|
||||
edit_decisions: Optional edit_decisions artifact for transition analysis.
|
||||
renderer_family: Optional renderer family for cinematic claim verification.
|
||||
render_runtime: Optional render runtime (remotion/hyperframes/ffmpeg).
|
||||
Passed through so dimension scoring can reason about runtime-specific
|
||||
indicators when it's useful. Scoring logic is currently
|
||||
runtime-neutral — a text-heavy HyperFrames composition is just as
|
||||
slideshow-y as a text-heavy Remotion one — but the parameter is
|
||||
part of the contract so callers record runtime alongside family.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"average": float,
|
||||
"verdict": str,
|
||||
"dimensions": {dimension_name: {"score": float, "reason": str}},
|
||||
"render_runtime": str | None,
|
||||
}
|
||||
"""
|
||||
if not scenes:
|
||||
@@ -47,6 +55,7 @@ def score_slideshow_risk(
|
||||
"average": 5.0,
|
||||
"verdict": "fail",
|
||||
"dimensions": {},
|
||||
"render_runtime": render_runtime,
|
||||
}
|
||||
|
||||
dimensions = {
|
||||
@@ -74,6 +83,7 @@ def score_slideshow_risk(
|
||||
"average": round(average, 2),
|
||||
"verdict": verdict,
|
||||
"dimensions": dimensions,
|
||||
"render_runtime": render_runtime,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user