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.
195 lines
6.9 KiB
Python
195 lines
6.9 KiB
Python
"""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)
|