Skip to main content

hyperframes.compose

hyperframes.compose turns a plain-language description into a HyperFrames HTML/CSS/JS composition that hyperframes.render can turn into an MP4. It's the "describe it, the LLM writes it" half of video generation — the same split slides.outline provides for decks: the model does the creative work, the pack does the production-contract work.

Why this pack exists

hyperframes.render only renders an author-supplied composition. Writing that composition by hand means reproducing the HyperFrames contract exactly — a sized canvas, a root <div> with data-composition-id / data-width / data-height, and a paused GSAP timeline registered on window.__timelines["main"]. A dropped attribute makes the render fail. So callers ended up pasting raw HTML.

This pack closes that gap without trusting the model to get the boilerplate right. The model returns only three creative pieces — extra CSS, the visible class="clip" elements, and the GSAP timeline body — and the pack assembles the final document around the guaranteed scaffolding (canvas sized to the chosen aspect ratio, root data-*, the window.__timelines registration, an optional narration <audio>). The structural contract holds regardless of model quality.

Timeline coverage

Every second of the composition's [0, duration_seconds) range must be covered by at least one class="clip" element. The body's reset CSS sets background: #000; so any uncovered range renders as visible black in the final MP4. The pack rejects compositions whose foreground elements leave a gap longer than min(2.0s, duration_seconds * 0.05) — added in PR #502 after the 2026-06-13 concept-animator session surfaced a 2+ second black run in an 8-second rendered video.

The canonical pattern is a permanent background element plus foreground content on separate tracks:

<div id="bg" class="clip" data-start="0" data-duration="60" data-track-index="0"
style="background: #1a1a2e; position: absolute; inset: 0"></div>
<div id="title" class="clip" data-start="0" data-duration="6" data-track-index="1">...</div>
<div id="diagram" class="clip" data-start="6" data-duration="54" data-track-index="1">...</div>

Track-index collision check

Per the upstream HyperFrames hard rule documented in the composition guide: clips sharing the same integer data-track-index MUST NOT temporally overlap. Track-index is a non-linear-editor row index (temporal layout), NOT a CSS z-index (spatial stacking). The pack rejects compositions where two clips on the same track overlap — added in PR #504 after sourcing the rule directly from the upstream AGENTS.md.

Upstream convention for track allocation:

  • data-track-index="0" — backgrounds, atmospheric overlays
  • data-track-index="1""5" — primary scenes, typographical elements
  • data-track-index="9"+ — audio elements

To stack visuals at the same moment in time, put them on DIFFERENT tracks and use CSS z-index for spatial layering.

For the full upstream-sourced rule set — seven-step pipeline, layout-first pattern, attribute vocabulary, reference template catalog, audio constraints, documented failure modes, React migration constraints — see HyperFrames composition guide.

Tier-aware system prompt

The system prompt the pack sends to the gateway LLM adapts to the caller-supplied model's tier (per the existing models/*.yaml profile registry — also added in PR #502):

  • Tier C (free / weak open models) — verbose, constraint-heavy prompt with the hard rules and TIMELINE COVERAGE explanation inlined verbatim. Tier C models reliably follow explicit rules but unreliably honor external references.
  • Tier A/B (frontier / mid-tier hosted models) — leaner prompt that trusts the model on creative latitude and references the best-practices guide for deeper guidance.

The contract rules (canvas size, deterministic-only, timeline coverage) are identical across tiers; only the depth of guidance differs.

Inputs

FieldTypeRequiredDefaultNotes
descriptionstringyesWhat the video should show/say.
modelstringyesGateway model id (provider/model; see helmdeck://models).
aspect_ratiostringno16:916:9, 9:16, or 1:1 — drives the exact canvas size (reuses hyperframes.render's preset matrix).
resolutionstringno1080p1080p or 4k — sets the canvas pixel dimensions.
duration_secondsnumberconditional8 (silent only)Video length (cap 720s). Required when audio_url is provided — set to the audio's length (e.g. podcast.generate's duration_s output, rounded up). Wins over length_intent when both are set. The 8s default applies ONLY to silent compositions with no length_intent set.
audio_urlstringnoA presigned audio URL (e.g. podcast.generate's audio_url). When set, the pack embeds an <audio> element so the rendered MP4 carries narration. Empty → a silent video. When set, duration_seconds is required (issue #498). The pack reports length_intent_applied: "explicit:audio-locked" so callers can see the audio dictated the duration.
stylestringnoFreeform visual style hint (e.g. "dark, minimal, bold type").
max_tokensnumbernoderivedCompletion-token budget (clamped to [2048, 8192]).
metadata_modelstringnoopenrouter/autoGateway model id for the engagement-metadata generation step. Pass "" to opt out (no second LLM call, no engagement output). Pass any model id (e.g. openrouter/openai/gpt-oss-120b:free) to pin the chain to free tier. String-ptr-shaped: omitted → use default.
length_intentstringnoJIT length sizing (issue #529) — one of summary / thorough / exhaustive. Pack picks a duration_seconds from the heuristic table below. Honored only when duration_seconds is unset. Back-compat with no-input callers preserved (legacy 8-sec default).
inspectbooleannofalseWhen true, pack returns the planned duration + description word count and does NOT call the gateway. Works without a dispatcher; useful for agents planning before committing tokens.

Length intent heuristic

Unlike blog.rewrite_for_audience and podcast.generate where source word count drives the chosen target, hyperframes.compose has no inherent length-scaling signal — the description is a planning instruction, not source material. Intent picks a fixed duration from the table:

IntentTarget durationFloorCeiling
summary60s30s120s
thorough (default for intent path)180s120s360s
exhaustive600s360s720s (matches hyperframes.render's cap)

Precedence: inspect:trueaudio_url + duration_seconds ("explicit:audio-locked") → duration_seconds > 0 ("explicit") → length_intent set → legacy default 8 sec ("default:legacy-8sec", preserves back-compat). Description word count is reported for transparency but does NOT scale the chosen duration.

Outputs

FieldTypeNotes
composition_htmlstringThe assembled, render-ready composition. Pass to hyperframes.render's composition_html.
modelstringThe model used.
aspect_ratiostringEcho of the aspect ratio.
width / heightnumberThe canvas pixel dimensions.
duration_secondsnumberThe video length baked into the composition.
has_audiobooleanWhether a narration <audio> element was embedded.
duration_sourcestring"audio" when synced to an embedded track, else "timeline".
engagementobjectDuration-band-aware engagement payload (see Engagement metadata below). Absent when metadata_model: "" or when generation failed (composition is still produced — engagement is best-effort).
engagement_artifact_keystringStable artifact key to the JSON sidecar with the same payload. Useful for chaining downstream packs. Absent when engagement is absent or artifact storage failed.
description_wordsnumberWhitespace-delimited word count of the input description. Reported for transparency; doesn't drive the chosen duration.
target_duration_sec_chosennumberThe duration the pack picked. Same value as duration_seconds on the generate path; reported separately so callers can see the resolved-intent value distinctly from the renderer-baked one.
length_intent_appliedstringWhere the chosen duration came from — intent:summary / intent:thorough / intent:exhaustive / explicit / explicit:audio-locked / default:legacy-8sec.
truncatedbooleantrue when the composition-HTML LLM hit finish_reason=length. The assembled composition_html may be incomplete — re-run with a richer description or smaller length_intent / larger max_tokens.

Inspect-mode response

When inspect:true, the pack returns a planning response — no model call, no engagement metadata, no composition assembly:

FieldTypeNotes
composition_htmlstringAlways empty in inspect mode.
inspectbooleanAlways true.
description_wordsnumberWord count of the input description.
suggested_duration_secnumberWhat the precedence + intent table would pick.
length_intent_appliedstringThe chosen path (intent:* / explicit / explicit:audio-locked / default:legacy-8sec).
reasonstringHuman-readable explanation.

Inspect doesn't enforce the audio-requires-duration rule (no render happens), so an agent can inspect with audio_url set even if it hasn't measured the audio duration yet.

Engagement metadata

When metadata_model is non-empty (default: openrouter/auto), hyperframes.compose makes a second gateway LLM call to produce video-shaped engagement metadata aligned with the rendered MP4's duration band. The shape is selected from duration_seconds:

BandDurationOutput shapeTarget distribution
short_form< 60s{title, hook, hashtags, caption, thumbnail_prompt}TikTok / YouTube Shorts / Reels
mid_form60–179s{title, hook, hashtags, caption, social_blurb, thumbnail_prompt}Shorts (long edge) / Twitter / LinkedIn-native
long_form≥ 180s{title, description, chapters, hashtags, tags, hook_30s, category, language, thumbnail_prompt}YouTube proper

All bands include a format field naming the band (defense against the model returning a different shape than the prompt asked for) and a thumbnail_prompt ready to feed image.generate for hero artwork.

Cost discipline: the default openrouter/auto routes to a paid model. To keep the chain on free tier (e.g. when the agent itself runs on openai/gpt-oss-120b:free), pass metadata_model: "openrouter/openai/gpt-oss-120b:free". This mirrors podcast.generate's metadata_model pattern.

Failure handling: if the engagement LLM call returns an error or unparseable JSON, the pack logs a warning and returns the composition without engagement / engagement_artifact_key. The composition itself is the load-bearing output; engagement is value-add.

Async behavior

Asynchronous (Async: true) — one gateway LLM call; no session needed (it only calls the gateway, then hyperframes.render does the session-bound rendering).

Error codes

CodeTriggers
invalid_inputMissing description/model; audio_url provided without duration_seconds > 0 (issue #498); composition's class="clip" elements don't cover [0, duration_seconds) within tolerance (PR #502); two clips on the same data-track-index temporally overlap (PR #504); unsupported aspect_ratio/resolution; the model returned an unparseable spec or no visible elements.
internalRegistered without a gateway dispatcher.
handler_failedGateway returned no choices.

See also