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
+59 -15
View File
@@ -59,11 +59,24 @@ class CompositionValidator(BaseTool):
"properties": {
"composition_path": {
"type": "string",
"description": "Path to the ExplainerProps JSON file",
"description": "Path to the composition JSON file",
},
"assets_root": {
"type": "string",
"description": "Root directory for resolving relative asset paths (defaults to composition's parent dir)",
"description": (
"Root directory for resolving relative asset paths. "
"If omitted, resolved from render_runtime (see below)."
),
},
"render_runtime": {
"type": "string",
"enum": ["remotion", "hyperframes", "ffmpeg"],
"description": (
"Which runtime will consume this composition. Drives the "
"default asset root: remotion→remotion-composer/public, "
"hyperframes→<workspace>/assets or composition's parent, "
"ffmpeg→composition's parent. Explicit assets_root wins."
),
},
},
}
@@ -91,20 +104,50 @@ class CompositionValidator(BaseTool):
except (json.JSONDecodeError, UnicodeDecodeError) as e:
return ToolResult(success=False, error=f"Invalid JSON: {e}")
# Determine assets root (Remotion public dir)
assets_root = Path(inputs.get("assets_root", ""))
if not assets_root.is_dir():
# Default: look for remotion-composer/public relative to composition
candidate = comp_path
for _ in range(5):
candidate = candidate.parent
public = candidate / "remotion-composer" / "public"
if public.is_dir():
assets_root = public
break
else:
# Fall back to composition's parent
# Determine assets root. Explicit wins; otherwise dispatch by runtime.
# `render_runtime` may be passed in inputs, or extracted from the
# composition JSON itself (edit_decisions.render_runtime).
explicit_root = inputs.get("assets_root") or ""
assets_root = Path(explicit_root) if explicit_root else None
runtime = (
inputs.get("render_runtime")
or comp.get("render_runtime")
or ""
).strip().lower()
if assets_root is None or not assets_root.is_dir():
if runtime == "hyperframes":
# HyperFrames workspaces keep assets/ alongside index.html.
# Composition JSON typically lives in projects/<p>/artifacts/,
# so the workspace is at projects/<p>/hyperframes/.
candidate = comp_path
resolved = None
for _ in range(5):
candidate = candidate.parent
hf_assets = candidate / "hyperframes" / "assets"
if hf_assets.is_dir():
resolved = hf_assets
break
local_assets = candidate / "assets"
if local_assets.is_dir() and (candidate / "index.html").is_file():
resolved = local_assets
break
assets_root = resolved or comp_path.parent
elif runtime == "ffmpeg":
# FFmpeg jobs reference files by absolute path; fall back to
# the composition's parent for any bare-name references.
assets_root = comp_path.parent
else:
# Remotion (default): remotion-composer/public
candidate = comp_path
resolved = None
for _ in range(5):
candidate = candidate.parent
public = candidate / "remotion-composer" / "public"
if public.is_dir():
resolved = public
break
assets_root = resolved or comp_path.parent
errors: list[str] = []
warnings: list[str] = []
@@ -125,6 +168,7 @@ class CompositionValidator(BaseTool):
if out_s > video_duration:
video_duration = out_s
info.append(f"Video duration: {video_duration}s ({len(cuts)} cuts)")
info.append(f"Render runtime: {runtime or 'default (remotion)'}; assets root: {assets_root}")
# --- Check 3: Cut ordering and gaps ---
sorted_cuts = sorted(cuts, key=lambda c: c.get("in_seconds", 0))
+10
View File
@@ -60,6 +60,16 @@ class ImageGen(BaseTool):
"generate_diagram_overlay",
"generate_illustration",
]
best_for = [
"DEPRECATED — prefer image_selector which routes to per-provider tools "
"(flux_image, openai_image, recraft_image, grok_image, local_diffusion, "
"pexels_image, pixabay_image).",
"Kept only for backwards compatibility. New code should not call this.",
]
not_good_for = [
"New production code — use image_selector instead.",
"Picking a specific provider (use the per-provider tool directly).",
]
input_schema = {
"type": "object",
+156
View File
@@ -15,6 +15,43 @@ from typing import Any, Optional
from tools.base_tool import BaseTool, ToolStatus, ToolTier, ToolStability
# Unicode punctuation that breaks on Windows cp1252 stdout. Map each to an
# ASCII equivalent. This only touches strings rendered by registry helpers
# that an agent is likely to print to the user at preflight — not docstrings,
# comments, or markdown.
_UNICODE_DASH_REPLACEMENTS = {
"\u2014": "--", # em dash
"\u2013": "-", # en dash
"\u2212": "-", # minus sign
"\u2018": "'", # left single quote
"\u2019": "'", # right single quote
"\u201c": '"', # left double quote
"\u201d": '"', # right double quote
"\u2026": "...", # ellipsis
}
def _scrub_unicode_dashes(value: Any) -> Any:
"""Recursively normalize unicode punctuation in str leaves to ASCII.
Used to keep `provider_menu_summary()` output readable on Windows cp1252
stdout. Does NOT modify dict/list structure or non-string values.
"""
if isinstance(value, str):
out = value
for needle, repl in _UNICODE_DASH_REPLACEMENTS.items():
if needle in out:
out = out.replace(needle, repl)
return out
if isinstance(value, list):
return [_scrub_unicode_dashes(item) for item in value]
if isinstance(value, tuple):
return tuple(_scrub_unicode_dashes(item) for item in value)
if isinstance(value, dict):
return {k: _scrub_unicode_dashes(v) for k, v in value.items()}
return value
class ToolRegistry:
"""Central registry of all OpenMontage tools."""
@@ -258,6 +295,125 @@ class ToolRegistry:
return dict(sorted(menu.items()))
def provider_menu_summary(self) -> dict[str, Any]:
"""Compact, human-ready rollup of provider_menu() for onboarding/preflight.
Returns a dict shaped for the "N of M configured" capability menu the
agent is supposed to present to the user per AGENT_GUIDE.md → "Provider
Menu (Mandatory at Preflight)". Collapses the firehose of
support_envelope() into something the agent can paraphrase in plain
language in a few lines.
Example output (abbreviated):
{
"composition_runtimes": {
"ffmpeg": True,
"remotion": True,
"hyperframes": True,
},
"capabilities": [
{"capability": "video_generation", "configured": 10, "total": 16,
"available_providers": ["fal", "heygen", ...],
"unavailable_providers": ["openai", ...]},
...
],
"setup_offers": [
{"capability": "music_generation", "tool": "suno_music",
"install_instructions": "Add SUNO_API_KEY to .env"},
...
],
"runtime_warnings": [
"hyperframes: npm package `hyperframes` not resolvable: ...",
...
],
}
Agents should use this as the source for the preflight capability
menu rather than rendering `support_envelope()` or `provider_menu()`
raw. See AGENT_GUIDE.md > "Provider Menu (Mandatory at Preflight)".
"""
self.ensure_discovered()
menu = self.provider_menu()
# Composition runtimes — lift from video_compose.get_info() since
# they're the signal the runtime-selection contract depends on.
comp_runtimes: dict[str, bool] = {}
runtime_warnings: list[str] = []
vc = self._tools.get("video_compose")
if vc is not None:
info = vc.get_info()
engines = info.get("render_engines") or {}
comp_runtimes = {k: bool(v) for k, v in engines.items()}
# If hyperframes_compose is registered, surface its npm-resolve reasons
# explicitly — those are the "looks available but isn't" failures.
hf = self._tools.get("hyperframes_compose")
if hf is not None:
hf_info = hf.get_info()
rc = hf_info.get("hyperframes_runtime") or {}
for reason in rc.get("reasons") or []:
runtime_warnings.append(f"hyperframes: {reason}")
# Capabilities rollup (configured/total + provider lists).
# When a provider has multiple tools (e.g. seedance-fal and
# seedance-replicate both reporting provider="seedance"), a
# naive set-split shows the provider in BOTH available and
# unavailable — confusing for users. Dedupe: if the provider has
# any available tool, do NOT list it as unavailable.
capabilities: list[dict[str, Any]] = []
for cap, bucket in menu.items():
available_providers = {
e.get("provider") for e in bucket.get("available", [])
} - {None}
unavailable_providers = (
{e.get("provider") for e in bucket.get("unavailable", [])}
- {None}
- available_providers # provider with any available tool wins
)
capabilities.append(
{
"capability": cap,
"configured": bucket.get("configured", 0),
"total": bucket.get("total", 0),
"available_providers": sorted(available_providers),
"unavailable_providers": sorted(unavailable_providers),
}
)
# Setup offers — unavailable tools that would be 1-minute env-var fixes.
# Filter for short install instructions referencing an env var so the
# agent can lead with the easy wins.
setup_offers: list[dict[str, Any]] = []
for cap, bucket in menu.items():
for entry in bucket.get("unavailable", []):
hint = entry.get("install_instructions") or ""
# Heuristic: 1-minute fixes mention an env var or API key.
if any(k in hint.lower() for k in ["api key", "env", "_key=", "_api"]):
setup_offers.append(
{
"capability": cap,
"tool": entry.get("name"),
"provider": entry.get("provider"),
"install_instructions": hint,
}
)
result = {
"composition_runtimes": comp_runtimes,
"capabilities": capabilities,
"setup_offers": setup_offers,
"runtime_warnings": runtime_warnings,
}
# Normalize em-dashes and en-dashes to ASCII so preflight output prints
# cleanly on Windows cp1252 stdout (the default on Git Bash / PowerShell
# without PYTHONIOENCODING=utf-8). Agents paste this dict into chat; a
# mojibake `` in an install_instructions string looks like a bug.
# Markdown docs keep their typographic dashes; this only touches the
# runtime-reported strings.
return _scrub_unicode_dashes(result)
# Post-hoc fix: narrow helper that keeps the registry output stdout-safe on
# Windows cp1252 without imposing a new style rule on every tool author.
def gpu_required_tools(self) -> list[str]:
"""List tools that require GPU (VRAM > 0)."""
return [
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -1,7 +1,7 @@
"""Remotion caption burn tool.
"""Remotion caption burn tool — **runtime-specific (Remotion-only)**.
Renders animated word-by-word captions onto a talking-head video using
the Remotion CaptionOverlay component. Falls back to FFmpeg subtitle
the Remotion CaptionOverlay component. Falls back to FFmpeg subtitle
burning if Remotion is not available.
The tool:
@@ -12,6 +12,20 @@ The tool:
Fallback: if Remotion is unavailable, burns subtitles at the bottom of
the frame using FFmpeg's ``subtitles`` filter with bold styling.
## Runtime scope
This tool is **Remotion-specific** and deliberately has no HyperFrames
counterpart in Phase 1. Word-level caption burn parity on the HyperFrames
runtime is explicitly deferred work (see ``skills/core/hyperframes.md`` →
"What stays Remotion-only in Phase 1").
If a brief requires word-level/karaoke captions, lock
``render_runtime="remotion"`` at proposal even if the rest of the
composition would otherwise be a good fit for HyperFrames. Do NOT attempt
to bolt this tool onto a HyperFrames workspace — the TalkingHead
composition ID and ``WordCaption`` prop shape it emits are tied to the
React scene stack in ``remotion-composer/``.
"""
from __future__ import annotations
+366 -23
View File
@@ -1,13 +1,23 @@
"""Video composition tool — FFmpeg + Remotion.
"""Video composition tool — FFmpeg + Remotion + HyperFrames (runtime-aware).
Final composition path that takes edit decisions, assets, and audio
and renders the complete output video. Supports subtitle burn-in,
overlay compositing, and platform-specific encoding profiles.
Pipeline-facing orchestration surface for composition. Takes `edit_decisions`,
`asset_manifest`, and audio, and delegates to the technical runtime chosen
at proposal stage.
For compositions with still images, animated scenes, or component types
(text cards, stat cards, etc.), the render operation auto-routes to
Remotion for frame-accurate spring animations and React-based rendering.
For pure video cuts (talking-head, etc.), FFmpeg handles trimming and concat.
Routing is driven by `edit_decisions.render_runtime` (locked at proposal):
- `remotion` → React-based frame-accurate render via `npx remotion render`.
Handles the existing scene-component stack, word-level captions,
TalkingHead/CinematicRenderer. Current default.
- `hyperframes` → HTML/CSS/GSAP render via `hyperframes_compose`.
Handles kinetic typography, product promos, website-to-video,
registry blocks. Added in the parallel-runtime initiative.
- `ffmpeg` → FFmpeg concat/trim. Used only for simple video cuts without
composition, or when the approved path explicitly names FFmpeg.
Silent runtime swaps are forbidden by governance. If the chosen runtime is
unavailable or fails, this tool surfaces a structured blocker and waits for
the agent to re-ask the user rather than substituting a different engine.
"""
from __future__ import annotations
@@ -84,6 +94,17 @@ class VideoCompose(BaseTool):
"Used to resolve asset IDs in cuts[].source to file paths."
),
},
"proposal_packet": {
"type": "object",
"description": (
"Full proposal_packet artifact. Optional but STRONGLY "
"recommended — when present, final_review compares "
"proposal_packet.production_plan.render_runtime against "
"edit_decisions.render_runtime and flags runtime_swap_detected. "
"Without it, runtime-swap detection falls back to checking "
"edit_decisions.metadata.proposal_render_runtime."
),
},
"subtitle_path": {"type": "string"},
"subtitle_style": {
"type": "object",
@@ -177,42 +198,85 @@ class VideoCompose(BaseTool):
return False
return True
def get_info(self) -> dict[str, Any]:
"""Extend base get_info to surface Remotion sub-capability.
def _hyperframes_available(self) -> bool:
"""Check if HyperFrames rendering is available.
This lets preflight report 'video_compose: AVAILABLE (FFmpeg + Remotion)'
so the agent knows Remotion is usable for motion graphics, animated text
cards, stat cards, charts, and image-to-video rendering — rather than
falling back to Ken Burns pan-and-zoom over still images.
Delegates to the dedicated tool so the availability check stays in
one place (node 22 floor, ffmpeg + npx on PATH).
"""
try:
from tools.video.hyperframes_compose import HyperFramesCompose
return bool(HyperFramesCompose()._runtime_check()["runtime_available"])
except Exception:
return False
def get_info(self) -> dict[str, Any]:
"""Extend base get_info to surface all available render runtimes.
Preflight reports each runtime's availability separately so the agent
can choose an appropriate `render_runtime` at proposal stage. Silent
fallback between runtimes is forbidden.
"""
info = super().get_info()
remotion_ok = self._remotion_available()
hyperframes_ok = self._hyperframes_available()
info["render_engines"] = {
"ffmpeg": True,
"remotion": remotion_ok,
"hyperframes": hyperframes_ok,
}
# Backwards-compat alias — some proposal skills inspect this name.
info["render_runtimes"] = info["render_engines"]
if remotion_ok:
info["remotion_components"] = self._REMOTION_COMPONENTS
info["remotion_note"] = (
"Remotion is available for React-based rendering. Use it for "
"image-to-video with spring animations, animated text/stat cards, "
"charts, callouts, comparisons, and complex transitions. "
"charts, callouts, comparisons, and word-level caption burn. "
"Prefer Remotion over Ken Burns pan-and-zoom for explainer "
"and motion-graphics pipelines."
"and motion-graphics pipelines that already use the scene-component stack."
)
else:
composer_dir = Path(__file__).resolve().parent.parent.parent / "remotion-composer"
if composer_dir.exists() and (composer_dir / "package.json").exists() and not (composer_dir / "node_modules").exists():
info["remotion_note"] = (
"Remotion project exists but node_modules are NOT installed. "
"Run 'cd remotion-composer && npm install' to enable Remotion rendering. "
"Falling back to FFmpeg Ken Burns for image-based compositions."
"Run 'cd remotion-composer && npm install' to enable Remotion rendering."
)
else:
info["remotion_note"] = (
"Remotion is NOT available (needs Node.js/npx + remotion-composer). "
"Falling back to FFmpeg Ken Burns for image-based compositions."
"Remotion is NOT available (needs Node.js/npx + remotion-composer + node_modules)."
)
if hyperframes_ok:
info["hyperframes_note"] = (
"HyperFrames is available for HTML/CSS/GSAP composition. Use it "
"for kinetic typography, product promos, launch reels, "
"website-to-video, and registry-block-driven scenes. Consumed via "
"'npx hyperframes' (npm package: 'hyperframes'). "
"Before locking render_runtime='hyperframes' at the proposal stage, "
"verify the runtime with `hyperframes_compose` operation='doctor' "
"or `make hyperframes-doctor`. An 'available' flag from the runtime "
"check means node + ffmpeg + the npm package all resolve; it does "
"not guarantee a render will succeed on the first specific "
"composition."
)
else:
info["hyperframes_note"] = (
"HyperFrames is NOT available. Requires Node.js >= 22, FFmpeg, "
"npx on PATH, and the 'hyperframes' npm package to be resolvable. "
"Run `make hyperframes-doctor` to see the specific missing piece, "
"or call `hyperframes_compose` operation='doctor' directly."
)
# Governance note — agents and reviewers consume this.
info["runtime_governance"] = (
"render_runtime is locked at proposal stage and carried unchanged "
"through edit_decisions. Silent swaps are forbidden. If the "
"chosen runtime fails, surface a structured blocker and wait for "
"user approval before switching."
)
return info
def execute(self, inputs: dict[str, Any]) -> ToolResult:
@@ -784,7 +848,10 @@ class VideoCompose(BaseTool):
if scenes:
try:
from lib.slideshow_risk import score_slideshow_risk
risk = score_slideshow_risk(scenes, edit_decisions, renderer_family)
render_runtime = edit_decisions.get("render_runtime")
risk = score_slideshow_risk(
scenes, edit_decisions, renderer_family, render_runtime
)
if risk["verdict"] == "fail":
blocks.append(
f"Slideshow risk score {risk['average']:.1f}/5.0 (verdict: fail). "
@@ -875,7 +942,55 @@ class VideoCompose(BaseTool):
# Also accept profile as "output_profile" (skill convention) or "profile"
profile = inputs.get("profile") or inputs.get("output_profile")
# --- Route: Remotion by default, FFmpeg only when Remotion unavailable ---
# --- Runtime routing: honor render_runtime locked at proposal ---
# Silent swaps are forbidden by governance. If the chosen runtime
# is unavailable, surface a structured blocker rather than quietly
# picking a different engine. Missing render_runtime is itself a
# governance violation — edit_decisions.schema.json requires it.
render_runtime = (edit_decisions.get("render_runtime") or "").strip().lower()
if not render_runtime:
return ToolResult(
success=False,
error=(
"render_runtime is not set in edit_decisions. Per governance, "
"it MUST be locked at proposal stage (proposal_packet."
"production_plan.render_runtime) and carried forward through "
"edit_decisions.render_runtime. Valid values: 'remotion', "
"'hyperframes', 'ffmpeg'. Re-run the proposal stage with an "
"explicit runtime choice — do NOT default this field."
),
)
if render_runtime == "hyperframes":
return self._render_via_hyperframes(
inputs=inputs,
edit_decisions=edit_decisions,
asset_manifest=asset_manifest,
resolved_cuts=resolved_cuts,
output_path=output_path,
profile=profile,
)
if render_runtime == "ffmpeg":
# Caller explicitly asked for FFmpeg — don't auto-upgrade to Remotion.
return self._render_via_ffmpeg(
inputs=inputs,
edit_decisions=edit_decisions,
resolved_cuts=resolved_cuts,
output_path=output_path,
profile=profile,
)
if render_runtime != "remotion":
return ToolResult(
success=False,
error=(
f"Unknown render_runtime {render_runtime!r}. "
f"Valid values: remotion, hyperframes, ffmpeg. "
f"render_runtime must be set at proposal stage."
),
)
# --- Explicit Remotion path (render_runtime == 'remotion') ---
if self._needs_remotion(resolved_cuts):
remotion_inputs: dict[str, Any] = {
"edit_decisions": dict(edit_decisions, cuts=resolved_cuts),
@@ -927,7 +1042,9 @@ class VideoCompose(BaseTool):
# --- Post-render: mandatory final self-review ---
if render_result.success and output_path.exists():
final_review = self._run_final_review(output_path, edit_decisions)
final_review = self._run_final_review(
output_path, edit_decisions, inputs.get("proposal_packet")
)
# Attach final_review to the ToolResult data so the compose-director
# skill can include it in the checkpoint alongside the render_report.
@@ -949,6 +1066,172 @@ class VideoCompose(BaseTool):
return render_result
def _render_via_hyperframes(
self,
*,
inputs: dict[str, Any],
edit_decisions: dict[str, Any],
asset_manifest: dict[str, Any],
resolved_cuts: list[dict],
output_path: Path,
profile: Optional[str],
) -> ToolResult:
"""Delegate to hyperframes_compose and run the mandatory final self-review.
Governance: if HyperFrames is unavailable or fails, return a structured
blocker — do NOT silently route to Remotion or FFmpeg. The agent must
surface the blocker and get user approval before any runtime swap.
"""
if not self._hyperframes_available():
return ToolResult(
success=False,
error=(
"render_runtime='hyperframes' was locked at proposal, but "
"the HyperFrames runtime is not available on this machine. "
"Per governance this is a BLOCKER — surface it to the user "
"per AGENT_GUIDE.md > 'Escalate Blockers Explicitly' and wait "
"for approval before switching runtime. Requirements: "
"Node.js >= 22, FFmpeg, and npx on PATH. See "
"tools/video/hyperframes_compose.py for the specific missing piece."
),
)
try:
from tools.video.hyperframes_compose import HyperFramesCompose
except Exception as e:
return ToolResult(
success=False,
error=f"Could not import hyperframes_compose: {e}",
)
workspace_path = (
inputs.get("workspace_path")
or str(output_path.parent.parent / "hyperframes")
)
# Pass the playbook through so the style bridge can emit CSS vars.
playbook_data = inputs.get("playbook")
if not playbook_data:
playbook_name = (
inputs.get("playbook_name")
or (edit_decisions.get("metadata") or {}).get("playbook")
)
if playbook_name:
try:
from styles.playbook_loader import load_playbook # type: ignore
playbook_data = load_playbook(playbook_name)
except Exception:
playbook_data = None
hf_inputs: dict[str, Any] = {
"operation": "render",
"workspace_path": workspace_path,
"output_path": str(output_path),
"edit_decisions": dict(edit_decisions, cuts=resolved_cuts),
"asset_manifest": asset_manifest,
}
if playbook_data:
hf_inputs["playbook"] = playbook_data
if profile:
hf_inputs["profile"] = profile
if "quality" in inputs:
hf_inputs["quality"] = inputs["quality"]
if "fps" in inputs:
hf_inputs["fps"] = inputs["fps"]
if "strict" in inputs:
hf_inputs["strict"] = inputs["strict"]
if "skip_contrast" in inputs:
hf_inputs["skip_contrast"] = inputs["skip_contrast"]
render_result = HyperFramesCompose().execute(hf_inputs)
if not render_result.success:
return ToolResult(
success=False,
error=(
f"HyperFrames render failed: {render_result.error}. "
"Per governance: do NOT silently fall back to Remotion or "
"FFmpeg. Surface the failure to the user along with the "
"hyperframes_compose step log before proposing a swap."
),
data=render_result.data,
)
# Post-render: mandatory final self-review (identical contract to the Remotion path).
if output_path.exists():
final_review = self._run_final_review(
output_path, edit_decisions, inputs.get("proposal_packet")
)
if render_result.data is None:
render_result.data = {}
render_result.data["final_review"] = final_review
render_result.data["final_review_status"] = final_review["status"]
if final_review["status"] == "fail":
return ToolResult(
success=False,
error=(
"Post-render self-review FAILED (HyperFrames). The output is not presentable.\n"
+ "\n".join(f"{i}" for i in final_review.get("issues_found", []))
),
data=render_result.data,
)
return render_result
def _render_via_ffmpeg(
self,
*,
inputs: dict[str, Any],
edit_decisions: dict[str, Any],
resolved_cuts: list[dict],
output_path: Path,
profile: Optional[str],
) -> ToolResult:
"""Explicit FFmpeg-only render path.
Use when the proposal locked `render_runtime="ffmpeg"` — e.g. simple
source-footage concat/trim jobs that don't benefit from composition.
Still runs the mandatory final self-review.
"""
options = inputs.get("options", {})
subtitle_burn = options.get("subtitle_burn", True)
subtitle_path = inputs.get("subtitle_path")
if subtitle_burn and not subtitle_path:
ed_subs = edit_decisions.get("subtitles", {})
if ed_subs.get("enabled") and ed_subs.get("source"):
subtitle_path = ed_subs["source"]
compose_inputs = dict(inputs)
compose_inputs["edit_decisions"] = dict(edit_decisions, cuts=resolved_cuts)
compose_inputs["output_path"] = str(output_path)
if subtitle_path:
compose_inputs["subtitle_path"] = subtitle_path
if profile:
compose_inputs["profile"] = profile
render_result = self._compose(compose_inputs)
if render_result.success and output_path.exists():
final_review = self._run_final_review(
output_path, edit_decisions, inputs.get("proposal_packet")
)
if render_result.data is None:
render_result.data = {}
render_result.data["final_review"] = final_review
render_result.data["final_review_status"] = final_review["status"]
if final_review["status"] == "fail":
return ToolResult(
success=False,
error=(
"Post-render self-review FAILED (FFmpeg). The output is not presentable.\n"
+ "\n".join(f"{i}" for i in final_review.get("issues_found", []))
),
data=render_result.data,
)
return render_result
def _remotion_render(self, inputs: dict[str, Any]) -> ToolResult:
"""Render via Remotion (requires Node.js + npx).
@@ -1074,6 +1357,7 @@ class VideoCompose(BaseTool):
self,
output_path: Path,
edit_decisions: dict[str, Any] | None = None,
proposal_packet: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Run post-render self-review and produce a final_review artifact.
@@ -1081,6 +1365,13 @@ class VideoCompose(BaseTool):
the actual rendered output before marking the stage complete.
Never claim a video is ready without a real probe + frame sample.
When `proposal_packet` is provided, its
`production_plan.render_runtime` is compared against
`edit_decisions.render_runtime` so `runtime_swap_detected` can
actually flip. Without it, we fall back to
`edit_decisions.metadata.proposal_render_runtime` (which the edit
director can set explicitly to opt into swap detection).
Returns a dict conforming to final_review.schema.json.
"""
log = logging.getLogger("video_compose.final_review")
@@ -1278,12 +1569,64 @@ class VideoCompose(BaseTool):
promise_preservation: dict[str, Any] = {
"delivery_promise_honored": True,
"silent_downgrade_detected": False,
"runtime_swap_detected": False,
"issues": [],
}
if edit_decisions:
renderer_family = edit_decisions.get("renderer_family", "")
promise_preservation["renderer_family_used"] = renderer_family
# Runtime governance — record what actually ran and flag a swap.
# Three sources of truth, in priority order:
# 1. proposal_packet.production_plan.render_runtime (authoritative)
# 2. edit_decisions.metadata.proposal_render_runtime (if edit stage
# explicitly copied it to opt into in-tool swap detection)
# 3. edit_decisions.render_runtime itself (cannot detect a swap in
# this case — reviewer does cross-artifact comparison instead)
render_runtime_edit = (edit_decisions.get("render_runtime") or "").strip().lower()
if render_runtime_edit:
promise_preservation["render_runtime_used"] = render_runtime_edit
proposal_runtime: str | None = None
runtime_source: str | None = None
if proposal_packet:
pp_runtime = (
(proposal_packet.get("production_plan") or {}).get("render_runtime")
or ""
).strip().lower()
if pp_runtime:
proposal_runtime = pp_runtime
runtime_source = "proposal_packet.production_plan.render_runtime"
if proposal_runtime is None:
md_runtime = (
(edit_decisions.get("metadata") or {}).get("proposal_render_runtime")
or ""
).strip().lower()
if md_runtime:
proposal_runtime = md_runtime
runtime_source = "edit_decisions.metadata.proposal_render_runtime"
if proposal_runtime is None:
promise_preservation["runtime_swap_check"] = (
"skipped — no proposal_packet or proposal_render_runtime "
"metadata provided. Reviewer skill does cross-artifact "
"comparison separately."
)
elif proposal_runtime != render_runtime_edit:
promise_preservation["runtime_swap_detected"] = True
promise_preservation["runtime_swap_check"] = (
f"detected — source: {runtime_source}"
)
promise_preservation["issues"].append(
f"render_runtime changed between proposal ({proposal_runtime}) "
f"and compose ({render_runtime_edit}) — this is a contract "
f"violation unless a render_runtime_selection decision was logged."
)
else:
promise_preservation["runtime_swap_check"] = (
f"ok — proposal and edit agree ({runtime_source})"
)
delivery_data = (
edit_decisions.get("metadata", {}).get("delivery_promise")
or edit_decisions.get("delivery_promise")