chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:35 +08:00
commit 85453da49f
4031 changed files with 710987 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
---
name: hyperframes-core
description: The HyperFrames composition contract — build one renderable project. Use for composition structure, the `data-*` timing attributes, `class="clip"`, tracks, sub-compositions, variables, framework-owned media playback, deterministic-render rules, and validation. Also covers Tailwind projects and the STORYBOARD.md / SCRIPT.md plan formats. Read before writing composition HTML.
---
# HyperFrames Core
HyperFrames renders video from HTML. A composition is an HTML file whose DOM declares timing with `data-*` attributes, whose animation runtime is seekable, and whose media playback is owned by the framework.
This skill is the **technical contract** — how to build one hyperframes project. The body below is the build guide; per-topic detail lives in `references/` (index next), read on demand. Other concerns live in the sibling domain skills — `hyperframes-animation`, `hyperframes-creative`, `media-use`, `hyperframes-cli`, `hyperframes-registry`. The capability map in `/hyperframes` says what each one covers.
## References
| File | Read it to… |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| `references/minimal-composition.md` | start from the smallest renderable composition skeleton |
| `references/composition-patterns.md` | choose monolithic vs modular; structure a modular `index.html`; pick a sub-comp archetype |
| `references/data-attributes.md` | look up any `data-*` (root / clip / sub-comp host / legacy aliases); use `class="clip"` |
| `references/tracks-and-clips.md` | pick `data-track-index`, handle same-track overlap / z-index, time a clip relative to another |
| `references/sub-compositions.md` | wire a sub-composition (host attrs, `<template>`, per-instance vars) and animate inside it |
| `references/variables-and-media.md` | declare variables; place `<video>`/`<audio>`, set volume, trim |
| `references/determinism-rules.md` | build a seekable timeline; determinism bans; the animatable-property allowlist; layout / text fit |
| `references/full-screen-motion.md` | author full-frame motion with shared backgrounds |
| `references/storyboard-format.md` | author a `STORYBOARD.md` plan (+ the parsed manifest) |
| `references/brief-contract.md` | conduct a creation workflow's intake — interaction mode (collaborative / autonomous), shared brief fields, asking rules |
| `references/script-format.md` | author the optional `SCRIPT.md` locked narration |
| `references/subagent-dispatch.md` | map subagent dispatch verbs (parallel fan-out / background / wait) to your harness |
| `references/tailwind.md` | work in a Tailwind v4 project (`init --tailwind`; runtime contract differs from Studio's v3) |
For animation runtime specifics (GSAP API, Lottie, Three.js, etc.) go to `hyperframes-animation``adapters/<runtime>.md`.
## Building a composition
### Two root forms (not interchangeable)
- **Standalone** (top-level `index.html`) — root `<div data-composition-id="…">` sits directly in `<body>`, **no `<template>` wrapper** (wrapping it hides all content and breaks rendering).
- **Sub-composition** (loaded via `data-composition-src`) — root **must** be wrapped in `<template>`.
> ⚠ Transport rule: the runtime **only clones `<template>` contents**; everything outside (incl. `<head>` styles/scripts) is discarded — put `<style>`/`<script>` **inside** the template.
> ⚠ Host-id rule: the host slot's `data-composition-id` must **exactly equal** the inner template's `data-composition-id` **and** the `window.__timelines["<id>"]` key — no `-mount`/`-slot`/`-host` suffix.
File shape, host wiring, and the pre-render checklist → `references/sub-compositions.md`.
### Root must be sized (silent layout bug)
The standalone root needs an explicit **sized box** (`width`/`height` in px), and every ancestor down to a `height:100%` element must have a resolved height — otherwise a flex/`100%` child collapses to ~0 and content piles into the top-left corner. `lint`/`validate`/`inspect` do **not** catch this. Skeleton → `references/minimal-composition.md`.
### One paused timeline
Each composition registers **exactly one** `gsap.timeline({ paused: true })` at `window.__timelines["<id>"]` (key = root `data-composition-id`), built **synchronously** at page load. Render duration = root `data-duration`, not timeline length. Don't manually nest sub-timelines into the host. Full contract (incl. non-GSAP runtimes) → `references/determinism-rules.md` + `hyperframes-animation/adapters/`.
### Non-negotiable rules (silent bugs `lint`/`validate`/`inspect` won't catch)
Surfaced here; full rationale in the linked reference. Do not violate:
- No render-time clocks / unseeded `Math.random` / network / input-state; no `repeat: -1` (use a finite count). → `determinism-rules.md`
- Animate only the visual-property allowlist; never `display`/`visibility`; no `gsap.set` on later-scene clips. → `determinism-rules.md`
- No `<br>` in body text; transformed elements must be block-level + sized; pulsing absolute decoratives need peak clearance. → `determinism-rules.md`
- `<video>`/`<audio>` must be a **direct child of the host root** (never inside a sub-comp `<template>`/wrapper); the framework owns playback. → `variables-and-media.md`
- Every `id` must be unique across the **assembled** page; inside a sub-comp, prefix ids with the composition id (`#<id>-hero`). Duplicate `<video>`/`<img>` ids render **blank** — the producer injects frames by `getElementById`, and cross-file dupes slip past `lint`. → `composition-patterns.md`
- A full-screen scene fill goes on a full-bleed **child** (`position:absolute; inset:0`), never on the composition root itself — the producer's frame compositing can drop the root element's own `background` (the frame renders **black**) even though preview/`snapshot` show it correctly. → `composition-patterns.md`
## Editing existing compositions
- Read the files first. Preserve unrelated timing, tracks, IDs, variables, media paths.
- Match existing composition IDs and timeline keys.
- Adding a clip: pick a non-overlapping `data-track-index` or adjust surrounding timing intentionally.
- `data-hidden` on any composition element hides it in BOTH preview and render, overriding its time window; it is non-destructive/reversible and toggled by Studio's timeline eye icon.
- Adding a sub-composition: verify its internal `data-composition-id` before wiring the host.
## Validation
Use `hyperframes-cli` for command details
- [ ] `npx hyperframes check` passes (0 findings across lint, runtime, layout, motion, and contrast)
- [ ] Projects with sub-compositions: `npx hyperframes snapshot --at <midpoints>` and eyeball each frame
- [ ] `npx hyperframes preview` for review (the user can edit anything in Studio's timeline)
- [ ] `npx hyperframes render` only after the user approves
@@ -0,0 +1,48 @@
# Brief contract — interaction mode, shared brief fields, and question rules
Every creation workflow runs its intake step (Step 0 / brief) against this contract. It defines three things: the **interaction mode** (which controls all later gates, not just the brief), the **shared brief fields**, and the **question rules**. Each workflow maps these fields to its own values in its SKILL.md — including its enums, recommendation logic, and extra inputs. This file never includes workflow-specific content. Workflows without a real brief, such as `/motion-graphics`, use only § 1.
## 1. Interaction mode
There are two modes. Default: **collaborative**.
**Signals.**
- **Ongoing autonomous signals** — "autonomous", "surprise me", "decide for me", "just build it", "don't ask, just go", "LFG": the whole flow switches to autonomous from this point on.
- **One-time acceptance** — a bare "go" / "looks good" at a gate accepts only that gate's defaults; the mode does not change.
- The mode is set **once** — by a signal in the request, or by the brief's first question (§ 3) — and **carries forward. No later step asks again.** Once a storyboard exists, record it in `STORYBOARD.md` frontmatter (`mode: autonomous`). When resuming an existing project, read `mode` from that frontmatter first — a recorded mode counts as already set, so don't ask again.
- **Mid-run switch**: "stop asking / just finish it" → autonomous for the rest of the run. Clear feedback on a heads-up → collaborative resumes at the next gate.
**Gate types.** Autonomous mode changes only the first two types:
1. **Preference gates** (which preset, voice, caption identity, want a preview?) — autonomous: decide yourself and state the decision with a one-line reason. Never stay silent.
2. **Checkpoint gates** (storyboard approval, pre-render review) — autonomous: post the same summary you would have asked about as an inline heads-up, then continue. One exception: before rendering, ask once — preview first, or render (§ 3).
3. **Quality gates** (`hyperframes check`, capture completeness, fetch failures, workflow-specific verification checklists) — never skip these in any mode. Errors still stop the run. Reasoning like "autonomous means bias toward action, so I'll skip verification" misuses the mode — bias toward action applies to deciding _what to build_, not _whether to verify_.
4. **Routing and sign-in decisions** — wrong routing is a quality problem: an ambiguous-intent confirmation, such as `/slideshow`'s "is this a deck?", still happens in autonomous mode. Auth sign-in follows `/media-use` → Preflight: show the status as-is; collaborative waits for the user's choice, while autonomous notes it and continues offline.
**Autonomous is not silent.** Every question absorbed by the mode becomes a decision with a receipt — state the choice and its one-line reason inline as you go. Final delivery always includes the contact sheet, so review happens after the fact instead of not happening at all.
## 2. Field registry
The shared brief fields. Each workflow's SKILL.md declares which fields it uses, its own value set, how it derives recommendations, and — decisively — marks each field **ask** (always gets its own question) or **state** (stated in the intro text, never asked). The binding table's ask/state marking is authoritative; the default policies below apply only when a binding doesn't say otherwise. If a workflow does not use a field, such as `/music-to-video` having no narration, that field is simply absent from its binding — don't ask about it.
| Field | Meaning | Default policy |
| ------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode` | collaborative / autonomous (§ 1) | detect signals; never ask again |
| `destination` | where the video will play (X / LinkedIn feed, YouTube, TikTok, embed) | infer from the request; if unknown **and** it would change aspect or type scale, include ONE question in the brief |
| `aspect` | canvas | derive from destination — social **feed** (X / LinkedIn / Instagram) → square `1080x1080`; TikTok / Reels / Shorts → `1080x1920`; YouTube / website embed / unknown-desktop → `1920x1080`. State the derivation; never ask twice |
| `length` | target duration | the workflow derives its own recommendation and states the reason |
| `language` | narration + captions | use the user's language — state it, don't ask |
| `audience` | who will watch | infer from the input; confirm only when it would change the beats |
| `message` | the ONE thing the video must communicate | derive it and echo it in the brief — if the message cannot be stated in one sentence, the video is not ready for storyboarding |
| `angle` | what kind of story (workflow enum) | workflow-specific values; recommend one with a receipt |
| `narration` | yes / minimal / no (+ workflow slots such as `VO_MODE`) | workflow-specific |
## 3. Question rules
The executable question script lives in each workflow's Step 0 as a literal two-round template. This section defines only the invariants that script satisfies:
- **Round 1 asks the mode** — one question, skipped when the request already carried a signal. Autonomous → no further questions: state the locked brief (all fields + receipts) as a heads-up and build straight through; the one remaining question, before render, is "preview first, or render?". Collaborative → Round 2.
- **Round 2 asks the workflow's ask-marked fields** — one question per field, recommended option first, each with its receipt. Skip a question only when the user already answered that field in their request; inference is not an answer.
- **Receipts.** Every recommended option states its basis — "~40s — small change, +44/13 across 12 files"; "square 1:1 — you named the X/LinkedIn feed as the destination".
- **Channel.** Native question UI when the environment has one; otherwise plain text as one numbered list. "go" accepts all defaults.
@@ -0,0 +1,263 @@
# Composition Patterns
How to architect a project — when to inline everything in one HTML, when to split into sub-compositions, what the `index.html` orchestrator looks like at scale, and the common sub-composition archetypes seen in real projects. Pair with `minimal-composition.md` (single-file shape) and `sub-compositions.md` (mechanics of a sub-comp file).
## Two Architectures
| | Monolithic (single file) | Modular (sub-compositions) |
| --------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| Project layout | `index.html` only | `index.html` + `compositions/<scene>.html` per scene |
| Where scenes live | Inline `<section class="clip">` siblings under the root | Each scene is a separate file wrapped in `<template>` |
| Timeline registration | One timeline keyed at the root's `data-composition-id` | Root timeline (often near-empty) + one timeline per sub-comp, each keyed by its `id` |
| Routing entry | `references/minimal-composition.md` | `references/sub-compositions.md` |
Both architectures use the same runtime contract — `data-*` attributes + `window.__timelines[id]`. The choice is structural, not behavioral.
### Pick monolithic when
- The whole video is one continuous scene with no hard cuts.
- Scenes share heavy state (one canvas/WebGL context spanning the whole video, a single SVG that morphs across all beats).
- Total scope is small (~200400 lines of markup + script).
- No scene is reused across projects.
### Pick modular when
- The video has clear scene cuts — each scene is its own segment of the timeline.
- Some scenes are large (>100 lines of markup or significant scripted animation).
- A scene is reusable (kinetic intro, end-card logo lockup, a transition).
- The video has a continuous audio track over multiple visual segments. Keep audio at the root, visual segments as sub-comps.
- You want to author/iterate on scenes in isolation (preview a single sub-comp file directly).
### Refactor between them
Conversion is mechanical and reversible. To lift a monolithic scene into a sub-comp: wrap the scene's markup + scoped CSS + its slice of the parent timeline into a `<template>`, save as `compositions/<scene>.html`, replace the inline content in `index.html` with a slot `<div data-composition-src="compositions/<scene>.html">`, and have the sub-comp register its own timeline at `window.__timelines["<scene>"]`. The parent timeline shrinks accordingly.
If a monolithic project is approaching three or more scene cuts, prefer modularizing _before_ adding the next scene. Mixed projects where some scenes are inline and siblings are in `compositions/` are the hardest to maintain.
## Modular Orchestrator Pattern
When using sub-compositions, `index.html` should be **thin**. Its job is to declare slots, lay them out in time, mount the audio track, and register a (usually empty) root timeline. All scene animation lives inside the sub-comps.
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
body {
margin: 0;
background: #000;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
}
/* Sub-comp slots stretch to fill the root. */
[data-composition-id="root"] > div[data-composition-src] {
position: absolute;
inset: 0;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="root"
data-width="1920"
data-height="1080"
data-duration="30"
>
<!-- Sequential scenes — each one a sub-composition slot. -->
<div
id="el-intro"
data-composition-id="intro"
data-composition-src="compositions/intro.html"
data-start="0"
data-duration="6"
data-track-index="1"
></div>
<div
id="el-body"
data-composition-id="body"
data-composition-src="compositions/body.html"
data-start="6"
data-duration="18"
data-track-index="1"
></div>
<div
id="el-outro"
data-composition-id="outro"
data-composition-src="compositions/outro.html"
data-start="24"
data-duration="6"
data-track-index="1"
></div>
<!-- Continuous audio at the root — survives scene cuts. -->
<audio
id="el-bgm"
src="assets/bgm.mp3"
data-start="0"
data-duration="30"
data-track-index="10"
data-volume="0.6"
></audio>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["root"] = gsap.timeline({ paused: true });
</script>
</body>
</html>
```
Key properties of this layout:
- **Visual scenes on the same `data-track-index`** (e.g. `1`). Sequential — they cannot overlap on the same track. For a cross-fade between two scenes, put one on a higher track and overlap their times by the fade duration.
- **Audio on a separate, higher track index** (e.g. `10`). Keeps the linter's overlap rules clear of any visual collisions.
- **Root timeline is near-empty.** All animation lives in the sub-comps. A root-level fade-to-black at the very end is fine; do not stage a parallel animation track from the root.
- **Host slot ids** use `el-<name>` or `<scene-id>`. The slot's `data-composition-id` must still equal the sub-comp's internal id (see `sub-compositions.md`).
## Sub-Composition Archetypes
### A. Content scene (default)
The sub-comp contains the scene's full DOM, scoped CSS, and timeline. This is the standard pattern in `sub-compositions.md` — most scenes are this.
### B. Host media + main-timeline driver (REQUIRED for any `<video>`/`<audio>`)
Media playback only works when the `<video>`/`<audio>` is a **direct child of the host root** — never inside a sub-comp `<template>` (it would render blank/black). This is not optional or "for media that spans scenes"; it applies to every clip, including a scene-specific one. The scene's sub-comp keeps the frame/shell; the media is a host sibling positioned over it.
A sub-comp timeline **cannot** drive host elements (a global selector or `document.querySelector` does not resolve across the boundary). So author the media's per-scene motion (scale/opacity/morph/tilt/breathing) on the **main timeline** in `index.html`, at **global time** = scene-local time + the scene slot's `data-start`.
```html
<!-- index.html (host) -->
<div
id="el-final"
data-composition-id="final-anim"
data-composition-src="compositions/final-anim.html"
data-start="20"
data-duration="6"
data-track-index="1"
></div>
<!-- media is a DIRECT root child; sits over the sub-comp's frame -->
<video
id="final-video"
class="clip"
src="assets/final.mp4"
data-start="20"
data-duration="6"
data-track-index="2"
muted
playsinline
style="position:absolute; left:360px; top:100px; width:1200px; height:680px; object-fit:cover; border-radius:24px;"
></video>
<script>
// MAIN timeline drives the host video. Global time: scene starts at 20.
window.__timelines = window.__timelines || {};
const main = window.__timelines["main"];
main.fromTo(
"#final-video",
{ scale: 1.4, filter: "blur(14px)" },
{ scale: 1.0, filter: "blur(0px)", duration: 0.9, ease: "power3.out" },
20,
); // = slot data-start (+ any scene-local offset)
</script>
<!-- compositions/final-anim.html — frame/shell only, no <video>, no host-element animation -->
<template>
<div
data-composition-id="final-anim"
data-width="1920"
data-height="1080"
data-duration="6"
style="position:absolute; inset:0; pointer-events:none;"
>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// animate ONLY this sub-comp's own elements here (labels, frame, overlays)
window.__timelines["final-anim"] = tl;
</script>
</div>
</template>
```
Caveats:
- The host media must be a direct root child and exist in the DOM (static in `index.html`) — it always is.
- Clip lifecycle owns the media element's visibility across its `[data-start, data-start+data-duration]` window. The main-timeline opacity/scale tweens compose with it fine; for an opacity reveal/crossfade prefer a host **wrapper** so you are not fighting the lifecycle on the media element itself.
- Two media elements sharing the same `src` + `data-start` trigger `duplicate_media_discovery_risk` (benign — both still render).
### C. Multi-scene merge
When several beat-level scenes share continuous state — a chat thread that grows, a persistent headline word that carries across the cut, a single canvas with internal phase changes — collapse them into one sub-comp and use **internal phase divs** rather than multiple sub-comp slots.
```html
<!-- compositions/act2-merged.html -->
<template>
<div data-composition-id="act2-merged" data-width="1920" data-height="1080" data-duration="9">
<style>
[data-composition-id="act2-merged"] .phase {
position: absolute;
inset: 0;
opacity: 0;
}
</style>
<div class="phase" id="phase-a"></div>
<div class="phase" id="phase-b"></div>
<div class="phase" id="phase-c"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.set("#phase-a", { opacity: 1 }, 0);
tl.to("#phase-a", { opacity: 0, duration: 0.4 }, 3.0);
tl.set("#phase-b", { opacity: 1 }, 3.0);
// …
window.__timelines["act2-merged"] = tl;
</script>
</div>
</template>
```
Reach for this over multiple sequential slots when scenes share DOM, share a canvas, or need to cross-fade with persistent elements (a headline that survives the cut between phases). Each phase is just a div inside the same sub-comp — the parent timeline never has to know about the internal phase boundaries.
### D. Audio at root, reactive visual inside
Audio always lives at the host (`index.html`) as a root-level `<audio>` so playback survives scene cuts. A sub-comp that visualizes audio should read a **pre-baked** frequency curve at init, then sample the baked curve from its timeline — the visual must still be a deterministic function of `tl.time()`, not of `audio.currentTime`. See `determinism-rules.md` and `hyperframes-creative` for the authoring pattern.
## Naming Conventions
| Thing | Convention | Example |
| ----------------------------------- | ------------------------------------------- | ------------------------------------------ |
| Sub-comp file | `compositions/<scene-id>.html` | `compositions/act0-intro-bell.html` |
| Sub-comp `<template>` id (optional) | `<scene-id>-template` | `<template id="act0-intro-bell-template">` |
| Sub-comp root `data-composition-id` | `<scene-id>` (must match host slot) | `data-composition-id="act0-intro-bell"` |
| Timeline registry key | matches `data-composition-id` | `window.__timelines["act0-intro-bell"]` |
| Host slot `id` | `el-<short>` or `<scene-id>` | `id="el-intro"`, `id="act0"` |
| Element ids inside a sub-comp | prefix with the scene id | `#act0-bell`, `#b1-tape` |
| Audio at root | `data-track-index` well above visual tracks | `10` while visuals use `1` |
The `-template` suffix on `<template>` is conventional but not required — the runtime extracts contents from whichever `<template>` is in `<body>`, regardless of id. The prefix on inner element ids is the only safeguard against id collisions when multiple sub-comps are mounted into the same host page at once.
## Editing Existing Projects
Before adding or modifying scenes, identify which architecture is in use:
```bash
ls compositions/ 2>/dev/null && echo "modular" || echo "monolithic"
```
- In a **monolithic** project, add new scenes as inline `<section class="clip">` elements with a non-overlapping `data-start` and a sensible `data-track-index`, and extend the existing single timeline.
- In a **modular** project, match the pattern: add a new file under `compositions/`, add a slot in `index.html`, keep the root timeline thin. Do **not** start inlining new scenes into `index.html` when sibling scenes are sub-comps — the inconsistency is the worst of both worlds.
- If a monolithic project needs a third or fourth scene cut, lift each scene into a sub-comp before adding more. The conversion is mechanical (see "Refactor between them" above).
When picking the slot's `data-start`/`data-duration`, prefer continuing the existing sequencing convention (adjoining starts, deliberate overlaps for cross-fades). Don't introduce a new track index unless you actually need parallel visual layers — most sequential-scene projects use exactly one visual track.
@@ -0,0 +1,72 @@
# Data Attributes Reference
Every HyperFrames composition uses `data-*` attributes to declare timing and structure to the framework. This is the full attribute table — pair with `tracks-and-clips.md` for the rules behind `data-track-index`.
## Composition Root
Every renderable composition needs one root element:
| Attribute | Required | Meaning |
| ---------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data-composition-id` | Yes | Unique ID. Must match the animation registry key on `window.__timelines`. |
| `data-width` / `data-height` | Yes | Pixel frame size. Common values: `1920x1080`, `1080x1920`, `1080x1080`. |
| `data-duration` | Conditional\* | Render duration in seconds (total length / frame count), not the GSAP timeline length. **Read once at compile time, like `data-width` / `data-height`**: a static root `data-duration` is locked before scripts run, so a script (`root.setAttribute("data-duration", ...)`) or a `--variables`-driven value cannot change the render length. To vary length per render, author the root `data-duration` directly. (A clip's `data-duration` is different: re-read from the live DOM, so scripts/variables can drive it.) Only when the root omits `data-duration` does the renderer derive total length from the live DOM / timeline after scripts run. |
| `data-fps` | No | Optional frame rate hint. CLI render flags can override output fps. |
| `data-composition-variables` | No | JSON array of variable declarations (on `<html>`). See `variables-and-media.md`. |
\*`data-duration` is optional whenever the runtime can auto-infer duration: a registered GSAP timeline, a finite CSS animation, a finite WAAPI `element.animate()`, or a registered Lottie animation. It is **required** for Three.js (no auto-inference), for infinite/unbounded CSS or WAAPI animations, and for any composition with no GSAP timeline and no animation signal at all. `npx hyperframes lint` enforces this (`root_composition_missing_duration_source`). See `determinism-rules.md` → "Duration Contract For Non-GSAP Runtimes" for the per-runtime breakdown.
The root should be `position: relative`, have explicit pixel dimensions, and hide overflow unless intentionally composing outside the frame.
## Clip Attributes
Timed child elements are clips. **`class="clip"` is required on visible timed elements** (`<div>`, `<img>`, etc.) — without it the runtime keeps the element visible for the whole composition, ignoring `data-start` / `data-duration`. Omit on `<video>` (framework manages visibility directly) and `<audio>` (no visual).
**Clips must be DIRECT children of the composition root.** A clip nested inside a wrapper `<div>` is not registered — most visibly, a `<video>` in a wrapper is never seeked/decoded and renders black. To wrap/transform a clip, put the wrapper _inside_ the clip, or animate the clip element itself; do not wrap the clip. (`<video>`/`<audio>` additionally must be at the **host** root, never in a sub-comp `<template>` — see `variables-and-media.md`.)
| Attribute | Required | Meaning |
| ------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Yes | Stable DOM ID for linting, timeline targets, and debugging. |
| `data-start` | Yes | Start time in seconds, or a supported clip-time reference. |
| `data-duration` | Required for `div`, `img`, and sub-compositions | Duration in seconds. Video/audio can default to media duration when known. |
| `data-track-index` | Yes | Timeline track. Clips on the same track must not overlap. |
| `data-media-start` | No | Offset into the media source, in seconds. |
| `data-volume` | No | Static audio volume, `0` to `1`, default `1`. For fades, animate `volume` on the timeline instead (see `variables-and-media.md`). |
| `data-has-audio` | No (`<video>` only) | `"true"` to declare the video carries an audio track when auto-detection would miss it. |
**Visibility window is inclusive of both ends.** A clip shows while `start ≤ t ≤ start + duration` — it still renders at exactly `t = start + duration`, so the final frame holds the animation's resolved end state (the runtime does not hide it one frame early). A reveal/entrance that lands on `data-duration` is therefore visible on the last frame; you do not need to finish it _before_ `data-duration` just to guarantee the end state renders. (Climax-dwell guidance in `/hyperframes-animation` is about pacing, not this boundary.)
## Sub-Composition Host Attributes
When a clip is a sub-composition host (loads another composition file):
| Attribute | Required | Meaning |
| ---------------------------- | -------- | -------------------------------------------------------------------------------------------------------- |
| `data-composition-id` | Yes | The internal composition ID of the loaded file. |
| `data-composition-src` | Yes | Path to the sub-composition HTML file. |
| `data-width` / `data-height` | Yes | Render dimensions for the sub-composition instance. |
| `data-variable-values` | No | Per-instance variable overrides as JSON. See `variables-and-media.md`. |
| `data-var-src` | No | Binds the element's `src` to a declared variable id (media/image substitution, authored src = fallback). |
| `data-var-text` | No | Binds the element's own text to a scalar variable id; children are preserved. |
See `sub-compositions.md` for the full wiring pattern.
## Authoring Hints
- `id="root"` — template convention used by scaffolds and the transition catalog so CSS can target the composition root with `#root` instead of `[data-composition-id="main"]`. Not required by the runtime, but consistent with the rest of the ecosystem.
- `class="clip"` — required runtime visibility marker on visible timed elements (`<div>`, `<img>`, …). See Clip Attributes above.
- `data-layout-allow-overflow` — tells `hyperframes check` that overflow on this element (or its descendants) is intentional. Notes:
- `inspect` measures `getBoundingClientRect` at sampled timestamps, not rendered pixels — `overflow: hidden` clips the visual but does **not** suppress an `inspect` overflow finding. This attribute is the escape hatch; CSS overflow is not.
- Can be set on the composition **root** as well as on any child. When the cited offender is `div.<comp>-root inside div.<comp>-root` (the root reports its own children's union as overflowing), the fix goes on the root, not on individual text descendants — shrinking font sizes will not converge.
- In a multi-scene `group_wN.html` (continue runs), every scene-local element stays in the DOM during the other scenes' time windows; the layout-box union almost always overflows the canvas during morph seams. Mark the root and every scene-local primary/supporting element with this attribute **at construction**, not after `inspect` flags it.
- **Blast radius — it silences more than `inspect`.** The attribute is inherited down the subtree (the perception probe walks ancestors), so it also suppresses the rendered-perception checks `text-clipping`, `content-cramped-container`, and `foreground-over-panel` for every descendant. Putting it on a persistent panel that also hosts real foreground content disables collision checks on that content for the panel's whole lifetime. Prefer the narrowest opt-out: scope it to the smallest decorative wrapper, or use per-element `data-layout-bleed="true"` for one intentional primary-text crop. The two canvas/edge checks `primary-offscreen` and `foreground-over-panel` deliberately run **even under** allow-overflow, so it cannot hide a wordmark sliced by the frame or text bleeding onto a panel edge.
- `data-layout-ignore` — exclude this element from layout audits entirely.
## Legacy / Removed Attributes
These names appear in older projects and examples. Use the current names when authoring or editing:
| Legacy name | Use instead |
| ------------ | ------------------ |
| `data-layer` | `data-track-index` |
| `data-end` | `data-duration` |
@@ -0,0 +1,68 @@
# Determinism, Animation Runtime, and Layout
HyperFrames seeks compositions frame-by-frame. Every frame must be reproducible from its time value alone — same input time → same pixels. Three contracts enforce this: the **animation runtime contract**, the **determinism rules**, and the **layout contract**.
## Animation Runtime Contract
GSAP is the primary runtime. The core requirement is generic: animation state must be seekable from HyperFrames time.
For GSAP:
- Create the timeline **synchronously** during page initialization.
- Use `gsap.timeline({ paused: true })`.
- Register it on `window.__timelines["<composition-id>"]`.
- The key must match `data-composition-id` on the composition root.
- **Do not** call `tl.play()` for render-critical motion.
- **Do not** build timelines inside `async`, `Promise`, `setTimeout`, or event handlers — the renderer can sample before they finish.
- **Do not** create empty tweens only to set duration; use `data-duration` on the clip instead.
- **Do not** `gsap.set()` clip elements from later scenes — they are not in the DOM at page load. Use `tl.set(selector, vars, time)` inside the timeline at or after the clip's `data-start`.
Use the `hyperframes-animation` skill for tween syntax, position parameters, eases, and performance rules.
### Duration Contract For Non-GSAP Runtimes
The render engine needs a positive total duration before it will capture a single frame — without one, capture fails outright with "Composition has zero duration." A GSAP timeline supplies this automatically. CSS, WAAPI, and Lottie compositions have no timeline object, so the runtime infers duration itself:
- **CSS**: longest `animation-delay` + `animation-duration` × finite `animation-iteration-count` across animated elements (offset by each element's `data-start`). `animation-iteration-count: infinite` cannot be inferred.
- **WAAPI**: longest `element.animate()` effect's `getComputedTiming().endTime`. Infinite `iterations` cannot be inferred.
- **Lottie**: the registered animation's native length (`totalFrames / frameRate`, or the dotLottie player's own `duration`) — always finite regardless of `loop`.
- **Three.js**: **not inferable**. The `three` adapter only forwards time via `hf-seek` — it has no `AnimationClip`/`AnimationMixer` inspection.
`data-duration` on the root `[data-composition-id]` element is therefore optional whenever every non-GSAP animation on the page is finite (CSS/WAAPI with finite iteration counts, or Lottie). It is **required** when: the composition has an infinite/unbounded CSS or WAAPI animation, the composition uses Three.js, or there is no GSAP timeline and no animation signal at all for any adapter to discover. `npx hyperframes lint` enforces exactly this (`root_composition_missing_duration_source`) — see the runtime/adapter-specific docs under `hyperframes-animation/adapters/` for the full contract per runtime.
## Determinism Rules
Rendered frames must be reproducible from the requested time. Do **not** use any of the following for visual state:
- `Date.now()`, `performance.now()`, or any render-time clock.
- Unseeded `Math.random()`. Use a seeded PRNG if random-looking placement is needed.
- Render-time network fetches for required assets. Inline or pre-bundle them.
- Hover, scroll, pointer, or focus state. The renderer has no input events.
- Infinite loops such as `repeat: -1`. Compute a finite count: `repeat: Math.max(0, Math.floor(duration / cycleDuration) - 1)`**`floor`, not `ceil`** (`ceil` overshoots `data-duration` and trips the `gsap_repeat_ceil_overshoot` lint; `max(0, …)` avoids a negative repeat = infinite).
Also avoid:
- Animating anything outside the visual-property allowlist: `opacity`, `x`, `y`, `scale`, `rotation`, `color`, `backgroundColor`, `borderRadius`, transforms. Never animate `display` or `visibility` — use opacity/transforms and timed clip visibility instead.
- Animating the same property on the same element from multiple timelines at the same time — GSAP's overwrite behavior is order-dependent and can flip between renders.
## Layout Contract
Build the visible end-state in static HTML and CSS first, then animate from/to that state.
- The composition root has fixed pixel frame dimensions.
- **The root composition's total duration (render length / frame count) is fixed at compile time**, read once from the static root `data-duration` before scripts run, like `data-width` / `data-height`. A script or `--variables` value that rewrites the root `data-duration` afterward is ignored. To vary render length per output, author the root `data-duration` directly. (A _clip's_ own `data-duration` is re-read from the live DOM, so scripts/variables can still drive clip lengths. Only when the root omits `data-duration` does the renderer probe the live DOM / timeline for total length.)
- Scene containers should fill the scene with `width: 100%; height: 100%; box-sizing: border-box`.
- Use padding, flex, grid, and `max-width` for layout. Avoid positioning main content with hardcoded `top`/`left` offsets when a layout container can do it.
- Use `position: absolute` for layers and decorative elements, not as the default content-layout strategy.
- Prefer transforms and opacity for animation.
- Keep text inside its intended container. For dynamic text, use `max-width`, wrapping, or `window.__hyperframes.fitTextFontSize(text, { maxWidth, fontFamily, fontWeight })`.
- For text measurement without DOM reflow, use `window.__hyperframes.pretext`: `pretext.prepare(text, font)` then `pretext.layout(prepared, maxWidth, lineHeight)`. Pure arithmetic, ~0.0002 ms per call — safe for per-frame text reflow, shrinkwrap containers, and computing layout before render. `fitTextFontSize` is built on it.
- **Do not** use `<br>` in body text. Forced breaks ignore the actual rendered font width and produce an extra break when the line already wraps naturally, causing overlap. Let text wrap via `max-width`. Exception: short display titles where each word is deliberately on its own line.
- **Transformed elements must be block-level + sized.** `transform`/`scaleX`/`scaleY` is a no-op on an inline `<span>`, and scaling an auto-width (0px) element shows nothing → invisible bars/fills. Give them `display: block`/`inline-block`/flex-item **and** a real `width`/`height` (e.g. `width: 100%` inside a sized parent). _(silent — lint/inspect miss it.)_
- **Absolutely-positioned decoratives that pulse or overshoot** (`yoyo` scale, `back.out`) need clearance at their **peak** size and must not straddle an `overflow: hidden` edge — else they overlap a neighbor or get clipped. Position for the largest frame, not the resting one. _(silent.)_
## Why This Matters
The renderer takes a time value and produces a pixel buffer. There is no notion of "playback" — every frame is a fresh seek. Any state that depends on having reached this frame _through_ a prior frame (timers, accumulated state, event-driven animations) will desync when the renderer samples out of order or in parallel.
If you find yourself reaching for `setTimeout`, `requestAnimationFrame`, or `addEventListener` to drive a visual, rebuild it as a tween on the timeline instead.
@@ -0,0 +1,62 @@
# Full-Screen Motion Pattern
For full-frame motion (continuous backgrounds, color washes, full-bleed visual states that span multiple clips), prefer a **shared background layer + transparent timed content layers** over stacked opaque scene backgrounds.
## Why
Stacking opaque scene divs means every scene change has to repaint the entire frame, every cross-scene visual continuity has to be faked, and every "global" state (a hue shift, a vignette, a film grain) has to be duplicated on every scene. A shared background layer driven by the seekable timeline gives you one continuous visual surface and makes scenes themselves cheap and transparent.
## Pattern
```html
<div id="root" data-composition-id="main" data-width="1920" data-height="1080" data-duration="20">
<!-- Shared background — NOT a clip. Always visible. Driven by the timeline. -->
<div id="bg" class="full-bleed"></div>
<!-- Timed content layers — transparent backgrounds. -->
<section
id="scene1"
class="clip transparent"
data-start="0"
data-duration="6"
data-track-index="1"
>
<!-- content -->
</section>
<section
id="scene2"
class="clip transparent"
data-start="6"
data-duration="14"
data-track-index="1"
>
<!-- content -->
</section>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Drive the shared background from the seekable timeline.
tl.to("#bg", { backgroundColor: "#0a1530", duration: 6, ease: "sine.inOut" }, 0);
tl.to("#bg", { backgroundColor: "#1a0a30", duration: 14, ease: "sine.inOut" }, 6);
// Scene-local animations stay transparent on top.
tl.from("#scene1 h1", { y: 48, opacity: 0, duration: 0.6 }, 0.2);
window.__timelines["main"] = tl;
</script>
```
## Rules
- **The background is not a clip.** No `data-start` / `data-duration` / `data-track-index`. It exists for the whole composition.
- **Content scenes have transparent backgrounds.** Whatever you put in the shared `#bg` shows through.
- **Drive global state from the shared layer.** Hue shifts, vignettes, grain, film-look filters — animate them once on the shared layer, not per-scene.
- **Do not animate visibility on `.clip` elements.** HyperFrames already shows/hides clips based on `data-start` and `data-duration`. Animating `display` / `visibility` on the clip itself races with the framework's own show/hide. Animate a _child wrapper_ inside the clip instead.
- **Verify intentional overflow with snapshots.** Before adding `data-layout-allow-overflow` to silence an inspect warning, run `npx hyperframes snapshot` and confirm the overflow is what you want.
## When Not to Use This Pattern
If scenes really are visually disjoint — hard cuts between distinct color worlds with no continuity — the stacked-opaque pattern is fine. The shared-background pattern is for compositions where the background **is part of the motion language**, not just backdrop.
@@ -0,0 +1,67 @@
# Minimal Composition
The smallest renderable HyperFrames composition — a standalone (top-level) root with one clip and one tween:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Minimal HyperFrames Composition</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
body {
margin: 0;
background: #0b0f14;
color: white;
font-family: Inter, system-ui, sans-serif;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
}
.clip {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
h1 {
margin: 0;
font-size: 96px;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-width="1920"
data-height="1080"
data-duration="5"
>
<section id="title-card" class="clip" data-start="0" data-duration="5" data-track-index="1">
<h1 id="title">Hello HyperFrames</h1>
</section>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.from("#title", { y: 48, opacity: 0, duration: 0.6, ease: "power3.out" }, 0.2);
window.__timelines["main"] = tl;
</script>
</body>
</html>
```
Required elements:
- Root `<div>` with `data-composition-id`, `data-start="0"`, `data-width`, `data-height`, `data-duration`
- At least one clip (any element with `data-start`, `data-duration`, `data-track-index`)
- GSAP timeline created paused, registered on `window.__timelines["<composition-id>"]`
This pattern is **standalone** (top-level `index.html`) — no `<template>` wrapper around the root. For sub-compositions (files loaded by `data-composition-src`), see `sub-compositions.md`.
@@ -0,0 +1,49 @@
# `SCRIPT.md` — locked narration (optional)
The **locked narration** for a project: the final spoken lines + voice + delivery. It is an _optional_ plan-layer file — a video with no narration (bgm-only, silent overlay) has none. The storyboard's per-frame `voiceover` is the lighter, editable _guide_; `SCRIPT.md` is the _commit_. (Storyboard format → `references/storyboard-format.md`.)
This file defines the SCRIPT.md **shape** only. Synthesizing the spoken lines into audio is a capability owned by `media-use``references/tts.md`.
Free-form markdown — there is no strict parser; the Studio renders it read-only beside the Storyboard board, and the TTS step extracts the indented spoken lines.
## Shape
A header block, then one section per spoken line.
| Part | Holds |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| Header | `**Voice:**` (provider + voice), `**Voice settings:**` (e.g. stability / similarity / style), `**Voice direction:**` (overall delivery) |
| `## Line N — <label> (Frame N)` | one spoken line, tied to its storyboard frame |
| `**Time:**` | the board's rough window — a _guide_, not authoritative (real timing comes from TTS word timestamps) |
| `**Delivery:**` | per-line delivery note |
| indented block | the **spoken text** — the only part fed to TTS |
## Example
```markdown
# SCRIPT — acme-launch
**Voice:** Rachel (ElevenLabs)
**Voice settings:** stability 0.35 · similarity 0.75 · style 0.20
**Voice direction:** Confident, warm, a little playful.
---
## Line 1 — Hook (Frame 1)
**Time:** 0.0 3.0s
**Delivery:** Land the promise on the beat.
Ship a launch video in an afternoon.
## Line 2 — The problem (Frame 2)
**Time:** 3.0 7.0s
**Delivery:** Wry, a touch tired.
The old way? Prompt, wait twenty minutes, get something that misses.
```
## To TTS
Feed each line's spoken text to `npx hyperframes tts` (pin `--voice` / `--provider` from the header; capture word timestamps for captions). Real per-word timing replaces the `**Time:**` guides. CLI contract → `media-use/audio/references/tts.md`.
@@ -0,0 +1,96 @@
# Storyboard format — `STORYBOARD.md` + parsed manifest
Defines the storyboard's **base data format** only: the `STORYBOARD.md` file shape and the `StoryboardManifest` it parses into. How a workflow _generates_ a storyboard lives in that workflow; the optional narration/TTS file (`SCRIPT.md`) is a separate concern owned by the TTS step, not here.
A storyboard is the **plan layer** for a video — an ordered set of **frames** (key moments) in one markdown file. HyperFrames Studio renders it as a contact sheet (the Storyboard view, available by default in every Studio session). Parser: `@hyperframes/core/storyboard``StoryboardManifest`; read API: `GET /api/projects/<id>/storyboard`.
## Frontmatter (global direction)
YAML block at the top. Unknown keys are kept under `globals.extra`.
| Key | Meaning | Example |
| ---------- | ----------------------------------------------------------------- | ----------------------------------------- |
| `format` | Canvas size | `1920x1080` |
| `message` | One-line thesis | `Ship a launch video in an afternoon` |
| `arc` | Narrative arc | `Hook → Problem → Solution → Proof → CTA` |
| `audience` | Who it's for | `indie devs on X` |
| `mode` | Interaction mode (see `brief-contract.md`; default collaborative) | `autonomous` |
## Per-frame sections
One `## Frame N — Title` heading per frame (`Frame` / `Beat` / `Scene` accepted at H2/H3). Metadata as `- key: value` bullets; everything below them until the next heading is the free-form **narrative**.
| Key | Meaning |
| --------------- | ------------------------------------------------------------------------------------------------------------- |
| `status` | `outline``built``animated` (defaults `outline`) |
| `src` | project-relative path to the frame's HTML sub-composition (the tile poster renders from it) |
| `duration` | e.g. `4s` |
| `transition_in` | `crossfade` / `cut` / `wipe` … (alias `transition`) |
| `scene` | one-line contact-sheet caption (aliases `description` / `summary` / `caption`) |
| `voiceover` | the frame's narration _guide_ (aliases `vo` / `voice_over` / `narration`) |
| `poster` | seconds to seek for the tile poster (past the intro animation) |
| _any other key_ | kept verbatim under the frame's `extra` — a workflow carries its own per-frame data (effects, assets, …) here |
## Parsed manifest
The parser is **lenient**: it never throws and records anything surprising as a `warning`.
```
StoryboardManifest {
globals: { format?, message?, arc?, audience?, extra: {…} }
frames: Array<{
index, number?, title?,
status, // "outline" | "built" | "animated"
src?, duration? / durationSeconds?, transitionIn?,
scene?, voiceover?, poster?,
narrative, // markdown below the metadata
extra: {…} // unknown keys, preserved
}>
warnings: Array<{ message, line?, frameIndex? }>
}
```
The read API also adds `srcExists` per frame and attaches the optional `SCRIPT.md` payload when present.
## `SCRIPT.md` (out of scope here)
Optional, free-form, **not parsed into the manifest** — the locked-narration file that drives TTS. Its format is defined in `references/script-format.md`, and it is absent for videos with no narration/TTS. The per-frame `voiceover` above is the storyboard's own narration guide.
## Example
```markdown
---
format: 1920x1080
message: "Ship a launch video in an afternoon"
arc: Hook → Problem → Solution → Proof → CTA
audience: indie devs on X
---
## Frame 1 — Hook
- scene: Big type punches in on the beat
- duration: 3s
- poster: 2s
- transition_in: cut
- status: animated
- voiceover: "Ship a launch video in an afternoon."
- src: compositions/frames/01-hook.html
Open cold on the promise. This is the thesis — everything after pays it off.
## Frame 2 — The problem
- scene: A 20-minute timer spins on a stack of rejected takes
- duration: 4s
- transition_in: crossfade
- status: built
- voiceover: "The old way? Prompt, wait twenty minutes, get something that misses."
- src: compositions/frames/02-problem.html
The old way: prompt, wait, get something that misses. Establish the pain we remove.
```
## Notes
- A frame with `status: outline` and no built `src` renders as an outline placeholder.
- Multi-line `voiceover` values collapse to one line on save.
@@ -0,0 +1,237 @@
# Sub-Compositions
A sub-composition is a separate HTML file embedded in a host composition. HyperFrames loads it, seeks it independently, and composites the result into the host at `data-start`.
## Host Wiring
In the host composition, the sub-composition appears as a clip with `data-composition-src`:
```html
<div
id="chart"
data-composition-id="data-chart"
data-composition-src="compositions/data-chart.html"
data-start="2"
data-duration="8"
data-track-index="2"
data-width="1920"
data-height="1080"
></div>
```
- `data-composition-id` on the host must match the internal `data-composition-id` of the file at `data-composition-src`.
- The host clip needs its own `data-start`, `data-duration`, `data-track-index`, `data-width`, `data-height`.
## Sub-Composition File Structure
### Mental model — what the runtime actually does
When a host loads a sub-composition via `data-composition-src`, the runtime:
1. `fetch`es the HTML file.
2. Parses it with `DOMParser`.
3. **Finds the `<template>` element and clones ONLY its contents into the host slot.**
4. Everything **outside** the `<template>` (including the entire `<head>`) is **discarded**.
So `<template>` is not just a wrapper — it is the **transport container**. If a node needs to exist in the live render, it must be inside `<template>`. Full stop.
### File shape
```html
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<!-- head is metadata for the source file only; the runtime ignores it -->
</head>
<body>
<template>
<!-- EVERYTHING the runtime needs goes here: styles, markup, scripts -->
<style>
/* Root: style by #root, never a class. (At render the CSS is scoped to
data-composition-id, so a class on the root stops matching — see Pitfall 3.) */
#root {
position: absolute;
inset: 0;
color: #fff;
}
/* .label, #bar, … — descendants, plain selectors */
</style>
<div id="root" data-composition-id="data-chart" data-width="1920" data-height="1080">
<!-- sub-composition markup -->
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// ... build timeline ...
window.__timelines["data-chart"] = tl;
</script>
</template>
</body>
</html>
```
Contrast with **standalone** compositions, which put the root directly in `<body>` with no `<template>` wrapper.
## Common pitfalls that pass static checks but break at render
`lint`, `validate`, and `inspect` evaluate each file in isolation. These two failures live in the **cross-file mount contract** and cannot be caught until the runtime actually tries to mount the sub-composition. Watch for them at author time and verify with the pre-render checklist below.
### Pitfall 1 — `<style>` in `<head>` instead of inside `<template>`
```html
<!-- ❌ WRONG — looks normal, ships catastrophically broken -->
<head>
<style>
#root { font-size: 88px; ... }
</style>
</head>
<body>
<template>
<div id="root" data-composition-id="data-chart" ...>...</div>
</template>
</body>
<!-- ✅ RIGHT — styles are inside the template, root styled by #root (see Pitfall 3) -->
<head></head>
<body>
<template>
<style>
#root { font-size: 88px; ... }
</style>
<div id="root" data-composition-id="data-chart" ...>...</div>
</template>
</body>
```
**Why this happens:** standard HTML conventions tell you to put `<style>` in `<head>`. In a standalone HTML file that's correct. In a HyperFrames sub-composition it is **not** — the runtime only clones `<template>` contents, so `<head><style>` is dropped on the floor.
**Symptom:** the composition lints / validates / inspects clean, the render completes, but every text element shows up as tiny unstyled default text in the top-left and SVGs blow up to canvas-size because none of the CSS reached the live DOM. The same trap applies to `<script>` blocks, `<link rel="stylesheet">`, custom-element registrations — anything that needs to execute or apply in the render must be inside `<template>`.
### Pitfall 2 — Host `data-composition-id` ≠ inner template `data-composition-id`
```html
<!-- ❌ WRONG — host renames the slot; runtime can't find the timeline -->
<!-- host file (e.g. index.html) -->
<div data-composition-id="chart-mount" data-composition-src="compositions/chart.html" ...></div>
<!-- chart.html -->
<template>
<div data-composition-id="data-chart" ...>...</div>
<script>
window.__timelines["data-chart"] = tl;
</script>
</template>
<!-- ✅ RIGHT — both ids match, and the timeline key matches them too -->
<div data-composition-id="data-chart" data-composition-src="compositions/chart.html" ...></div>
<!-- chart.html template root: data-composition-id="data-chart" -->
<!-- timeline: window.__timelines["data-chart"] = tl; -->
```
**Why this happens:** it feels natural to give the host slot a different name like `chart-mount` ("the mount point") vs `data-chart` ("the actual chart"). HyperFrames does not work that way — **the host's `data-composition-id` is the lookup key the framework uses to find the registered timeline**. Lint passes because each file's ids are individually valid; the cross-file mismatch only blows up at render.
**Symptom:** the render logs `Sub-composition timelines not registered after 45000ms: <host-id>` for every mismatched slot, waits 45s per scene, then captures static initial-state frames (so the video is full-length but no animation plays).
### Pitfall 3 — Styling the root by a class instead of `#root`
```html
<!-- ❌ WRONG — class on the root, stylesheet keyed off it -->
<template>
<style>
.frame {
position: absolute;
inset: 0;
background: #faf9f5;
}
.frame .title {
font-size: 120px;
}
</style>
<div id="root" class="frame" data-composition-id="03-scene" ...>
<div class="title"></div>
</div>
</template>
<!-- ✅ RIGHT — root styled by #root, descendants by plain selectors -->
<template>
<style>
#root {
position: absolute;
inset: 0;
background: #faf9f5;
}
.title {
font-size: 120px;
}
</style>
<div id="root" data-composition-id="03-scene" ...>
<div class="title"></div>
</div>
</template>
```
**Why this happens:** when sub-compositions are inlined into one composited render, the compiler **scopes each file's CSS to its own `data-composition-id`** so scenes can't leak styles into each other — every rule `S` becomes `[data-composition-id="<id>"] S` (a _descendant_ selector). A rule whose leftmost selector is the **root's own class** (`.frame`) therefore becomes `[data-composition-id="<id>"] .frame`, which cannot match the root (the root _is_ the scoped element, not a descendant of it), so **every `.frame…` rule silently drops**. `#root` is special-cased by the scoper and keeps matching the root; plain descendant selectors (`.title`) match normally. The per-scene class namespace is also just redundant — the `data-composition-id` scope already isolates each scene's styles.
**Symptom:** _identical_ to Pitfall 1 — tiny unstyled text in the top-left, images at natural size, inline styles (e.g. a card background) the only thing surviving. The trap: this passes `lint`/`validate`/`inspect` (they evaluate the file in isolation, unscoped) **and looks perfect in `preview`** (Studio mounts each scene in its own iframe, also unscoped) — it only breaks in the composited MP4 render. Lint rule `subcomposition_root_styled_by_class` flags it; the registry blocks (e.g. `apple-money-count`) model the `#root` pattern.
### Verification checklist before render
```bash
# For every sub-composition file in compositions/:
# 1) <style> + <script> + main markup all live INSIDE <template>
grep -n "<style\|<script\|<template" compositions/<scene>.html
# → first <style>/<script>/<div data-composition-id> should be AFTER <template>, before </template>
# 2) host data-composition-id == internal data-composition-id == window.__timelines key
grep "data-composition-id" index.html
grep "data-composition-id\|__timelines\[" compositions/<scene>.html
# → all three strings must match exactly per scene
# 3) the root is styled by #root, not by a class on the data-composition-id element
grep -n 'data-composition-id=' compositions/<scene>.html
# → that element should NOT also carry a class="…" that the <style> keys off
# (e.g. `.frame { … }`); scoping drops it. Style the root via #root. See Pitfall 3.
```
For the runtime end-to-end check (a fast `snapshot` pass + per-scene frame eyeball), see the **Visual smoke test** step in `hyperframes-cli`'s Minimum Completion Gate — that is the only gate that catches these three pitfalls.
## What HyperFrames Does With the Sub-Composition
- Loads the file and registers its timeline under its internal `data-composition-id`.
- Seeks the sub-composition's timeline independently from the host's playhead.
- Plays the sub-composition's content from `data-start` of the host clip, for `data-duration` seconds.
**Do not** manually `master.add(child)` a sub-composition timeline into the host timeline. HyperFrames already drives them independently — nesting them in GSAP causes double-seeks.
### The host clip's `data-duration` is the slot's visible window
`data-duration` on the host clip defines **how long the slot is visible**, and it takes precedence over the sub-composition's internal GSAP timeline length. Two consequences follow:
- **Internal timeline shorter than the slot → the slot holds.** If the sub-composition's GSAP timeline finishes before `data-duration` elapses, the slot keeps showing its final frame for the rest of the window. You do **not** need to pad the timeline with empty tweens.
- **`data-duration` shorter than the host composition → the slot ends (and goes blank) when its own `data-duration` elapses.** This is intended: the clip is a fixed-length window on the timeline, not "fill until the composition ends." To keep a sub-composition visible for the whole composition, set its `data-duration` to span the host window (or add another clip to cover the remaining time). Leaving a single full-bleed sub-composition shorter than the composition is almost always a mistake — the linter flags it as `subcomposition_blanks_before_host`.
## Animations Inside Sub-Compositions
Prefer `gsap.fromTo()` over `gsap.from()` for entrance tweens. The host re-seeks the sub-composition every time its clip becomes visible; `gsap.from()` records the starting state at registration and can desync on seek-back, while `gsap.fromTo()` declares both endpoints explicitly and replays cleanly.
## Per-Instance Variables
If the sub-composition declares variables on its `<html>` element (`data-composition-variables`), the host can override values per instance:
```html
<div
data-composition-id="data-chart"
data-composition-src="compositions/data-chart.html"
data-variable-values='{"title":"Q4 Revenue","accent":"#66d9ef"}'
data-start="2"
data-duration="8"
data-track-index="2"
data-width="1920"
data-height="1080"
></div>
```
The host can render the same sub-composition multiple times with different `data-variable-values` to produce per-instance variations. See `variables-and-media.md` for variable declaration syntax.
@@ -0,0 +1,41 @@
# Subagent dispatch — harness adapter
The video workflows (`product-launch-video` / `faceless-explainer` / `pr-to-video` / `motion-graphics` / `general-video`) describe subagent dispatch in harness-neutral verbs. This file maps those verbs to the primitives of whatever agent harness you are running on. Read it once per run, before the first dispatch; everything else in the workflows (dispatch packets, file artifacts, exit-code gates, Resume tables) is harness-independent and needs no translation.
## The contract (identical on every harness)
- **DISPATCH(role_file, dispatch_context)** — start one child agent whose prompt is the **full contents of the named `agents/<role>.md` file** followed by the `## Dispatch context` block from the workflow, copied **verbatim** (never digested or paraphrased). Every harness below accepts arbitrary task text, so this works everywhere; never rely on the child seeing your conversation, memory, or skills — the prompt and the files on disk are its entire world.
- **Parallel fan-out** — when a step says "start N workers in parallel", the workers are mutually independent (no ordering, no shared state beyond the filesystem). Run as many concurrently as your harness allows.
- **WAIT** — a step's completion criterion is always **the expected artifact existing on disk** (e.g. `compositions/<scene-id>.html`), never the harness's completion notification (some harnesses deliver results best-effort). After waiting, verify the artifacts; a missing artifact means that child failed — re-dispatch it once with the same prompt before surfacing an error.
## Concurrency cap → batching rule (cap never changes scope)
A harness concurrency limit **reduces parallelism, not work**: every scene still gets built, one scene per dispatch, with the available slots chewing through the full list.
- Harness queues excess children internally (Claude Code; Hermes `delegate_task` beyond `max_concurrent_children`) → submit **all N at once** and let the queue drain.
- Harness hard-caps active children (e.g. OpenClaw `maxChildrenPerAgent`) → dispatch in **waves of the cap size**: start `cap` workers, wait for their artifacts, start the next wave, until all N scenes exist. Example: 9 scenes on a cap-3 harness = 3 waves of 3 — never drop scenes, never merge scenes into one worker to fit the cap.
## Primitive map
| Verb in the workflows | Claude Code | Codex CLI | OpenClaw | Hermes Agent |
| -------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| DISPATCH | `Agent` tool, `subagent_type: "general-purpose"` | `spawn_agent` (built-in `worker` role; prompt carries the role file) | `sessions_spawn` (role file rides in `task`; child only auto-loads AGENTS.md/TOOLS.md) | `delegate_task` — put role file + context into each task's `context` (child system prompt is not customizable) |
| parallel fan-out | multiple Agent calls in one message, each `run_in_background: true` | multiple `spawn_agent` calls (async by design) or `spawn_agents_on_csv` for uniform batches | repeated `sessions_spawn` (always non-blocking) | one `delegate_task(tasks=[...])` batch call |
| WAIT | background-task completion notifications arrive automatically | `wait_agent` with all child IDs | `sessions_yield` (never poll session lists) | `delegate_task` returns when all tasks finish (blocking the turn is normal) |
| default concurrency | min(16, cores 2), excess queues | 6 (`agents.max_threads`) | 5 active per session, 8 global | 3 (`delegation.max_concurrent_children`) |
| re-dispatch after a gate failure | new Agent call, same prompt + the error/`## Repair context` | new `spawn_agent`, same prompt + the error | new `sessions_spawn`, same `task` + the error | new `delegate_task`, same `context` + the error |
Harness-specific notes:
- **Codex**: children inherit the parent cwd and cannot change it — exactly what these pipelines need (shared `PROJECT_DIR`). Prefer prompt-carried roles over `.codex/agents/*.toml` registration; the workflows already deliver roles in the prompt. **Codex policy-gates spawning on the user explicitly requesting delegation — a skill instruction alone does not satisfy it, and you will be tempted to skip the tool without even trying.** Do not silently degrade: on Codex, if the user has not granted delegation this session, **ask one line** — "OK to run this workflow's workers as parallel subagents?" — **folded into the workflow's first existing user pause** (e.g. the API-key question) so the run pauses only once; at the latest, before the first dispatch step. An affirmative — or a standing grant in the workspace `AGENTS.md` or the kickoff prompt — unlocks native dispatch for the whole session. Only without a grant: prefer ladder rung 1 below (headless `codex exec` children) for the scene fan-out; inline serial is the last resort.
- **OpenClaw**: result announcement is best-effort (lost on gateway restart) — the artifact-on-disk WAIT rule above is the source of truth, not the announce message.
- **Hermes**: children get a fresh conversation and only see what you put in `context`, and only the final summary returns — both already match the workflows' design (dispatch packets carry everything; results land on disk). Requires a **local execution backend** (the pipelines exchange data through `PROJECT_DIR` and packet files on the shared filesystem; Docker/remote backends break that).
- **Any other harness**, or subagents unavailable/disabled — degrade down this ladder:
1. Headless CLI children: write each child prompt to a file, start the CLI per worker as a background shell process (redirect stdin from /dev/null, output to a log), then collect artifacts from disk.
2. Inline serial execution: do each role yourself, one at a time — read the role file, follow it with the dispatch context as if you were the child, finish, then return to the runbook. Load only one role's resources at a time (a scene's role file + its packet), and still run every per-role self-check before moving on.
## Vocabulary mapping (older phrasing you may meet in agent prompts)
- "in the same message" / `run_in_background: true` — Claude Code idioms for "concurrently / as a background child"; apply your harness's equivalent from the table.
- "Skill `X`" / "loaded with the Skill tool" — load skill X; on harnesses without a skill-loading tool, read `<skills-root>/X/SKILL.md` directly (the skills root is the directory containing this skill family; derive it from `SKILL_DIR` in your dispatch context).
- `Read` / `Write` / `Edit` / `Bash` — capability names (read file / write file / edit in place / run shell); map to your harness's tools.
@@ -0,0 +1,123 @@
# HyperFrames Tailwind
HyperFrames `init --tailwind` uses the Tailwind browser runtime pinned by the scaffold. Treat it as Tailwind v4, not Studio's Tailwind v3 setup.
## When To Use
- The project was scaffolded with `npx hyperframes init --tailwind`.
- `index.html` contains `window.__tailwindReady`.
- The task asks for Tailwind utility classes, `@theme`, custom utilities, or v3-to-v4 fixes in a composition.
- Rendered frames have missing Tailwind styles or frame-0 flashes.
## Version Contract
- **Pinned: `@tailwindcss/browser@4.2.4`** (source of truth: `packages/cli/src/commands/init.ts` `TAILWIND_BROWSER_VERSION`).
- Do not replace the scaffolded runtime with `cdn.tailwindcss.com` (unpinned, defeats reproducibility).
- Keep the readiness shim deterministic; HyperFrames waits for `window.__tailwindReady` before frame 0 capture.
- For offline / locked-down / production-stable renders, compile Tailwind to CSS and ship the stylesheet instead of the browser runtime.
## v4 Browser Runtime Rules
Tailwind v4 is CSS-first:
```html
<style type="text/tailwindcss">
@theme {
--color-brand: oklch(0.68 0.2 252);
--font-display: "Inter", sans-serif;
}
@utility headline-balance {
text-wrap: balance;
letter-spacing: 0;
}
</style>
```
Avoid v3-only patterns in browser-runtime compositions:
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```
Do not add `tailwind.config.js` only for composition colors, fonts, spacing, or utilities. Use `@theme` and `@utility`.
**Migrating from v3?** Load an existing JS config explicitly: put `@config "./tailwind.config.js";` inside a `text/tailwindcss` block. v4 does **not** auto-detect v3 config files.
## Composition Pattern
Use Tailwind for static layout and style. Keep render-critical timing in GSAP or another seekable HyperFrames adapter.
```html
<section
id="hero"
class="clip absolute inset-0 grid place-items-center bg-zinc-950 text-white"
data-start="0"
data-duration="5"
data-track-index="1"
>
<div class="w-[1280px] max-w-[82vw] text-center">
<h1 class="text-7xl font-black leading-none text-balance">Render-ready Tailwind</h1>
</div>
</section>
```
For repeated items, **parameterize via CSS variables** — keep the class list static so the runtime sees every utility:
```html
<span class="translate-y-[calc(var(--i)*6px)] opacity-80" style="--i: 0"></span>
<span class="translate-y-[calc(var(--i)*6px)] opacity-80" style="--i: 1"></span>
<span class="translate-y-[calc(var(--i)*6px)] opacity-80" style="--i: 2"></span>
```
## Dynamic Class Safety
The browser runtime scans classes it can see. Do not build render-critical class names only at seek time:
```js
// Risky: the runtime may never see every generated class.
element.className = `bg-${color}-500`;
```
Prefer complete class tokens in HTML, data variants, or explicit CSS:
```html
<div data-tone="blue" class="bg-blue-500 data-[tone=rose]:bg-rose-500"></div>
```
If a generated class is unavoidable, make sure the full class token appears in a `text/tailwindcss` block before validation.
## Video-Specific Guardrails
v4 + render-mode footguns. Every bullet is a hard rule:
- **Stable dimensions only** — use `w-[…]` / `h-[…]` / `aspect-video` / grid / flex. **No `md:` / `lg:` breakpoints** (renderer is fixed-viewport).
- **Animate via transforms / opacity** — `translate-*`, `scale-*`, `opacity-*` are seek-safe; animating Tailwind sizing utilities is not.
- **No `transition-*` for render-critical motion** — a seekable runtime (GSAP) must own the state.
- **No interaction variants** — `hover:` / `focus:` / `active:` / `group-*:` / `peer-*:` / scroll / pointer variants never fire during render.
- **Bare `border` is broken in v4** — v4 default is `currentColor` (v3 was `gray-200`). Always write the color: `border border-white/20`.
- **v4 utility renames** — `shadow-sm``shadow-xs`, `rounded-sm``rounded-xs`, `outline-none``outline-hidden`, `flex-shrink-*``shrink-*`, `flex-grow-*``grow-*`.
- **Modern CSS is fine** — `color-mix()`, container queries, logical properties work; the renderer is current Chrome.
## Validation
```bash
npx hyperframes check
# Render proof — frame 0 must NOT flash unstyled content. Preview alone can hide this.
npx hyperframes render . --workers 1 --quality draft --output tailwind-proof.mp4
```
## Quick Debug Checklist
When Tailwind styles don't apply in a render, check in order:
1. Project scaffolded with `npx hyperframes init --tailwind`?
2. `index.html` `<head>` has `<script src="…@tailwindcss/browser@4.2.4…">` (not `cdn.tailwindcss.com`)?
3. `window.__tailwindReady` Promise present in `<head>`?
4. No v3 directives (`@tailwind base/components/utilities`) in the file?
5. Tokens moved from `tailwind.config.js` to `@theme` (or `@config` reference for v3 migration)?
6. Every render-critical class appears as a complete static token (no `bg-${color}-500` style assembly)?
7. Re-run `npx hyperframes check`, then the render proof above.
@@ -0,0 +1,76 @@
# Tracks and Clips
Clips are timed children of the composition root. Tracks are a temporal-overlap concept, not a visual-stacking concept.
## What is a Clip
A clip is any DOM element with `data-start`, `data-duration` (where required), and `data-track-index`. Common kinds:
- **Visual `<div>` clips** — scenes, cards, overlays. Always require `data-duration`.
- **Sub-composition hosts** — `<div>` with `data-composition-src`. Always require `data-duration`.
- **Video clips** — `<video>` with `muted` and `playsinline`. Duration can default to media length.
- **Audio clips** — `<audio>`. Duration can default to media length.
- **Image clips** — `<img>`. Always require `data-duration`.
Add `class="clip"` to authored visual clips so tooling and examples can find them.
## Tracks Are Temporal, Not Visual
`data-track-index` controls **temporal overlap**, not paint order:
- **Two clips on the same `data-track-index`** must NOT overlap in time. `hyperframes lint` flags this.
- **Visual layering (front/back)** is controlled by CSS `z-index`, not by track index.
A clip on track `5` is not "above" a clip on track `1` — it's just on a different audio/visual lane in time. Use CSS for layering, tracks for sequencing.
## Picking a Track Index
There's no fixed convention, but common patterns:
- **Track 0** — base video (e.g. an A-roll).
- **Track 1+** — visual scenes, overlays, captions.
- **Higher tracks (e.g. 10+)** — audio clips, separated from visual tracks to keep linting clear.
When adding a new clip to an existing composition:
1. Find an existing track that has no overlap with your new clip's `[data-start, data-start + data-duration)` range.
2. Or pick a fresh track index.
3. Never overlap two clips on the same track — the linter will fail and the render is undefined.
## Clip Time Inside the Composition
`data-start` is in seconds, measured from the start of the _composition_. For sub-compositions, the sub-composition's internal timeline (its own `data-duration` and child clips) runs from `data-start` to `data-start + data-duration` of the host.
`data-media-start` (on `<video>`/`<audio>`) is an offset _into the source media_. Use it to skip the first few seconds of a media file without trimming the file itself.
## Relative Timing
`data-start` accepts a clip ID instead of a number, meaning "start when that clip ends". Add `+ N` / `- N` to offset; negative produces overlap (useful for crossfades).
```html
<video id="intro" data-start="0" data-duration="10" data-track-index="0" src="..."></video>
<video id="main" data-start="intro" data-duration="20" data-track-index="0" src="..."></video>
<video
id="scene-a"
data-start="intro + 2"
data-duration="20"
data-track-index="0"
src="..."
></video>
<video
id="scene-b"
data-start="intro - 0.5"
data-duration="20"
data-track-index="1"
src="..."
></video>
```
Rules:
- References resolve **inside the same composition only** — cannot reach into a parent or sibling sub-composition.
- The referenced clip must have a **known duration** (explicit `data-duration` or inferred from media). Otherwise the reference cannot resolve.
- **No circular references** — `A → B → A` is rejected. Cycles are detected and error out.
- A value that parses as a number is always treated as absolute seconds. Otherwise the resolver expects `<id>`, `<id> + <number>`, or `<id> - <number>` (whitespace optional).
- References can chain (`A → B → C`). Keep chains under 3-4 levels for readability.
- Negative offsets create overlap; overlapping clips must be on **different tracks**, same-track overlap is rejected.
@@ -0,0 +1,107 @@
# Variables and Media
Two separate concerns, grouped because both control "what flows in from outside the HTML": runtime parameters (variables) and external media files (video/audio).
## Variables
Declare variables on the `<html>` element with `data-composition-variables`. Each declaration needs `id`, `type`, `label`, and `default`:
```html
<html
data-composition-variables='[
{"id":"title","type":"string","label":"Title","default":"Hello"},
{"id":"accent","type":"color","label":"Accent","default":"#66d9ef"}
]'
></html>
```
**Prefer declarative bindings — no script needed** for direct substitution:
```html
<img class="clip" data-start="0" data-duration="5" data-var-src="heroImage" src="fallback.jpg" />
<h1 class="clip" data-start="0" data-duration="5" data-var-text="title">Fallback</h1>
<style>
.card {
color: var(--accent);
}
</style>
```
- `data-var-src="id"` substitutes the element's `src` (URL string or image `{url}`); the authored `src` is the fallback.
- `data-var-text="id"` substitutes the element's own text; element children (nested clips, animated spans) are preserved.
- Every scalar variable is applied automatically as a `--{id}` CSS custom property on the composition root, so `var(--id)` CSS responds to overrides — no `setProperty` boilerplate.
- Bindings resolve identically in preview and render, and per-instance for sub-compositions.
- Caveat: media with audio should keep a real fallback `src` — render audio extraction reads the authored attribute (lint: `media_variable_src_no_fallback`).
For logic beyond direct substitution (loops, conditionals, derived values), read values once during initialization:
```js
const { title, accent } = window.__hyperframes.getVariables();
document.getElementById("title").textContent = title;
```
### Variable Rules
- Supported types and their extra options (consumed by Studio's editing UI):
- `string` — optional `placeholder`, `maxLength`
- `number` — optional `min`, `max`, `step`, `unit`
- `color` — none
- `boolean` — none
- `enum`**required** `options: [{ "value": "...", "label": "..." }, ...]`
- Always provide useful `default` values so preview works without CLI overrides.
- Use `data-variable-values='{"title":"Pro"}'` on sub-composition hosts for per-instance overrides.
- Use `npx hyperframes render --variables '{"title":"Q4 Report"}'` or `--variables-file` for render-time overrides.
- Add `--strict-variables` in CI: turns undeclared keys, type mismatches, and enum values not in `options` into errors instead of warnings.
- Read values once during init, not on every animation tick — variables don't change mid-render.
- Media color grading can use exact variable references inside `data-color-grading` JSON. Use `$gradingPreset` or `${gradingIntensity}` as the whole field value; the runtime resolves it from the current composition's variables before applying shader adjustments, finishing details, blur/pixelate effects, and custom LUTs.
### Two JSON Shapes (Easy to Confuse)
- `data-composition-variables` is an **array of declarations** (the schema): `[{id, type, label, default}, ...]`
- `--variables` and `data-variable-values` are **objects keyed by id** (the values): `{ title: "Q4", accent: "#fff" }`
## Media
**NON-NEGOTIABLE: `<video>`/`<audio>` must be a DIRECT child of the host composition root (`index.html`).** The runtime only registers + drives media that is a direct root child. Media placed inside a sub-composition `<template>`, or wrapped in any intermediate `<div>`, is never seeked/decoded → renders blank (paper/white) or black. `lint`/`validate`/`inspect` do not catch this; per-frame `snapshot` shows the blank panel.
Consequences:
- A scene-specific clip still lives at the host root, not in the scene's sub-comp. The sub-comp keeps only the frame/shell; the media is a sibling host element positioned over it.
- A sub-composition **cannot reach or animate host elements** — neither `document.querySelector("#host-id")` nor a gsap selector string (`tl.to("#host-id", …)`) resolves across the boundary; a sub-comp timeline only drives its own subtree. So **all per-scene motion on host media (scale/opacity/morph/tilt/breathing) must be authored on the MAIN timeline in `index.html`, at GLOBAL time** (scene-local time + the scene slot's `data-start`). For 3D tilt without a perspective parent, use gsap `transformPerspective` on the element. See `composition-patterns.md` archetype B.
Video elements must be muted and inline. Audio must be a separate `<audio>` element, even when it uses the same source file.
```html
<video
id="a-roll"
class="clip"
src="assets/demo.mp4"
data-start="0"
data-duration="12"
data-track-index="0"
muted
playsinline
></video>
<audio
id="a-roll-audio"
src="assets/demo.mp4"
data-start="0"
data-duration="12"
data-track-index="10"
data-volume="1"
></audio>
```
### Media Rules
- **Do not** call `video.play()`, `audio.play()`, pause, or seek in composition code. HyperFrames owns playback.
- **Do not** place media inside a sub-comp `<template>` or any wrapper `<div>` — direct host-root child only (see above), else it never decodes.
- **Do not** drive host media from a sub-comp timeline — it has no effect. Drive it from the main timeline at global time.
- **Do not** animate timed media element dimensions; animate a non-timed wrapper instead.
- **Do not** nest video inside a timed wrapper. Put timing on the media element or keep the wrapper untimed.
- Add `crossorigin="anonymous"` for external media that needs canvas capture or pixel inspection.
- Audio always lives on a separate `<audio>` element — even if its source file is the same as a `<video>`. The `<video>` is muted; the `<audio>` carries sound.
- For volume fades/ducking, animate `volume` on the timeline (`tl.to("#bgm", { volume: 0, duration: 1 }, "outro")`) rather than swapping `data-volume`. The runtime probes the timeline's volume keyframes and applies them identically in preview and render; `data-volume` is the static baseline for elements no tween touches.
For media duration: `<video>` and `<audio>` can omit `data-duration` if the media's intrinsic length is known and you want the full clip. Otherwise provide `data-duration` explicitly.