fix(skills,lint): correct composition-contract claims the code contradicts (#3468)

The runtime absorbed a series of authoring mistakes over time and `runtime/init.ts`
says so in its own comments, but the skills kept teaching the old rules. Four of
them actively cost an agent a failing run: add `crossorigin` (lint rejects it
unconditionally), never build a timeline inside `async` (lint calls that the
documented contract), never `gsap.set` later-scene clips (two fixHints instruct
exactly that), and 12 copyable media snippets with no `id`, which render silent.

Corrected in every place each claim appeared, including `hyperframes-animation`,
three workflow scripts, the scaffolded project instructions, the CLI `docs`
command, and the public docs site: `data-track-index` is a Studio display lane
the render never reads, `class="clip"` is a layout convention rather than a
visibility requirement, timed elements may nest, the visibility window is
half-open, sub-composition host dimensions are backfilled, and the root-fill rule
applies only to the layered-composite path.

Behaviour changes, each backed by a render rather than by reading code:

- `timeline_registry_missing_init` deleted. The runtime creates the registry
  before any inline script; a composition without the guard line renders and
  animates correctly.
- `video_nested_in_timed_element` kept, message corrected. A rendered repro shows
  the nested-with-local-start case really does break, so the rule guards a real
  defect, but nothing is "FROZEN": the extractor ignores the wrapper's offset
  while visibility uses it, so the clip shows wrong frames and then vanishes.
- `mediaRenderIds` now stamps media whose source is a `<source>` child, closing a
  duplicate-id gap the old `[src]`-only selector left open.
- Stale messages fixed on `subcomposition_root_styled_by_class` and
  `deprecated_data_layer`.

`coreSkillContent.test.ts` pinned the literal sentence that made root
`data-start` look required, so it is narrowed to structure plus the regression it
genuinely catches.

Not covered, and flagged in the PR: the media global-vs-local start heuristic in
`runtime/init.ts` is the root cause behind the nested-video defect. Removing it
changes the meaning of existing compositions and needs its own deprecation.
This commit is contained in:
Miguel Ángel
2026-08-24 18:04:39 -04:00
committed by GitHub
parent 3e17ddc69a
commit b2fc18b2df
31 changed files with 288 additions and 150 deletions
+9 -8
View File
@@ -17,19 +17,20 @@ a start, a duration, and a track:
| ------------------ | ------------------------------------------------ |
| `data-start` | When the element enters the composition timeline |
| `data-duration` | How long its timeline slot lasts |
| `data-track-index` | Which timeline lane owns that slot |
| `data-track-index` | Which Studio lane displays it (optional; the render ignores it) |
Add `class="clip"` to timed DOM and image elements so the runtime can control
their visibility. Video visibility is managed by the media runtime; audio has
no visual lifecycle.
Add `class="clip"` to timed DOM and image elements. The runtime keys visibility
off `data-start` rather than the class, but the shared `.clip` rule is what gives
a scene its full-frame box. Video visibility is managed by the media runtime;
audio has no visual lifecycle.
## Tracks are not layers
Tracks prevent time ranges from colliding. They do not decide which element is
in front. Use CSS `z-index` for paint order.
Tracks are the rows Studio draws. They do not decide which element is in front,
and they do not schedule anything. Use CSS `z-index` for paint order.
Two clips on one track cannot overlap. Put an intentional overlap, such as a
crossfade, on separate tracks:
Two clips on one track may overlap; the render composites both. Separate tracks
keep an intentional overlap, such as a crossfade, readable in Studio:
```html
<video
+5 -4
View File
@@ -97,11 +97,12 @@ unbounded animation, and timeline-free compositions need an explicit duration.
| `id` | Yes | Stable identifier for timing, editing, and animation |
| `data-start` | Yes | Start in seconds or a relative timing expression |
| `data-duration` | Yes for DOM, image, and nested-composition clips | Visible slot length in seconds |
| `data-track-index` | Yes | Timeline lane used to prevent temporal overlap |
| `class="clip"` | Yes for authored timed DOM and image elements | Lets the runtime own their visibility window |
| `data-track-index` | No | Studio timeline lane, display only. The render never reads it and it does not prevent overlap |
| `class="clip"` | Recommended for authored timed DOM and image elements | Layout and tooling convention. Visibility is keyed off `data-start`, but the shared `.clip` rule supplies the full-frame box |
`data-track-index` does not control paint order. Use CSS `z-index` for
front-to-back layering. Two clips on the same track must not overlap in time.
front-to-back layering. Two clips on the same track may overlap in time; the
lane is a Studio display row, not a scheduling constraint.
Video visibility is managed as media and does not require `class="clip"`.
Audio has no visual lifecycle.
@@ -124,7 +125,7 @@ Audio has no visual lifecycle.
| --- | --- | --- |
| `data-media-start` / `data-playback-start` | Video, audio, nested composition | Offset into the source file, used by trim and split. Two groups of readers disagree, so the right name depends on the element. **Read only `data-media-start`:** the timing compiler, the HTML parser, `hyperframes validate` (which only inspects `<audio>`), and the engine's audio mixer (which feeds ffmpeg `-ss`). **Read `data-playback-start` first, falling back to `data-media-start`:** the runtime player, Studio (which also writes it), and `hyperframes snapshot`. Because the audio mixer reads only `data-media-start`, a `<video>` authored with just `data-playback-start` renders a trimmed picture over untrimmed audio. Set the name by kind: **`<video>` / `<audio>` → `data-media-start`**; **nested composition → `data-playback-start`** — composition hosts are inspected only by the playback-start-first readers (the media-start-only ones are all `<video>`/`<audio>`-scoped). `data-media-start` still works there as a fallback, but `data-playback-start` is the canonical name Studio writes and normalises to for new composition hosts, so the other name works until an edit rewrites it (it is the [child-timeline offset](/concepts/compositions)). |
| `data-playback-rate` | Video, audio, nested composition | Playback multiplier from `0.1` to `5` |
| `data-volume` | Video and audio | Static volume from `0` to `1` |
| `data-volume` | Video and audio | Static gain. `1` is 0 dB, `0` is silence, and values above `1` boost up to `3.98` (+12 dB) |
| `data-has-audio="true"` | Video | Declares that the video contributes audio |
Video and audio may omit `data-duration` when their intrinsic duration is known
@@ -8,11 +8,17 @@ const REPO_ROOT = join(fileURLToPath(new URL(".", import.meta.url)), "..", "..",
const read = (...parts: string[]): string => readFileSync(join(REPO_ROOT, ...parts), "utf8");
describe("hyperframes-core contract docs", () => {
it("keeps root data-start in the minimal composition skeleton", () => {
it("keeps a runnable root in the minimal composition skeleton", () => {
const minimal = read("skills", "hyperframes-core", "references", "minimal-composition.md");
expect(minimal).toMatch(/data-composition-id="main"[\s\S]{0,300}data-start="0"/);
expect(minimal).toContain('Root `<div>` with `data-composition-id`, `data-start="0"`');
// Structural pin: the skeleton must still declare a root the runtime can find
// and size. The prose around it is deliberately not pinned: asserting exact
// sentences here made every docs correction a CI failure, and the sentence this
// replaces ("Root <div> with data-composition-id, data-start=\"0\"") listed
// data-start as required when the runtime stamps it (runtime/init.ts).
expect(minimal).toMatch(/data-composition-id="main"/);
expect(minimal).toMatch(/data-width="1920"[\s\S]{0,120}data-height="1080"/);
expect(minimal).toMatch(/window\.__timelines\["main"\]/);
});
it("teaches check as the canonical quality gate", () => {
+3 -3
View File
@@ -6,12 +6,12 @@ Core attributes for controlling element timing and behavior.
- `data-start="0"` — Start time in seconds
- `data-duration="5"` — Duration in seconds
- `data-track-index="0"`Timeline track number (controls z-ordering)
- `data-track-index="0"`Studio timeline lane, display only. The render never reads it, and it does not control paint order (use CSS `z-index`) or prevent overlap. Optional.
## Media
- `data-media-start="2"` — Media playback offset / trim point (seconds)
- `data-volume="0.8"` — Audio/video volume, 0 to 1
- `data-volume="0.8"` — Audio/video gain. `1` is 0 dB, `0` is silence, and values above `1` boost up to `3.98` (+12 dB)
- `data-has-audio="true"` — Indicates video has an audio track
## Composition
@@ -23,4 +23,4 @@ Core attributes for controlling element timing and behavior.
## Element Visibility
Add `class="clip"` to timed elements so the runtime can manage their visibility lifecycle.
Add `class="clip"` to timed elements. The runtime keys visibility off `data-start`, not this class, but the shared `.clip` rule is what gives a scene its full-frame box and Studio treats it as an edit hint.
+2 -2
View File
@@ -88,8 +88,8 @@ Fix all errors before presenting the result. Warnings should be reviewed before
## Key Rules
1. Every timed element needs `data-start`, `data-duration`, and `data-track-index`
2. Elements with timing **MUST** have `class="clip"` — the framework uses this for visibility control
1. Every timed element needs `data-start` and a duration. `data-start` is what marks it as timed; `data-track-index` is an optional Studio display lane the render never reads
2. Give timed visual elements `class="clip"`. The framework keys visibility off `data-start`, not the class, but the shared `.clip` CSS is what gives a scene its full-frame box, and `lint` warns without it
3. Timelines must be paused and registered on `window.__timelines`:
```js
window.__timelines = window.__timelines || {};
+2 -2
View File
@@ -88,8 +88,8 @@ Fix all errors before presenting the result. Warnings should be reviewed before
## Key Rules
1. Every timed element needs `data-start`, `data-duration`, and `data-track-index`
2. Elements with timing **MUST** have `class="clip"` — the framework uses this for visibility control
1. Every timed element needs `data-start` and a duration. `data-start` is what marks it as timed; `data-track-index` is an optional Studio display lane the render never reads
2. Give timed visual elements `class="clip"`. The framework keys visibility off `data-start`, not the class, but the shared `.clip` CSS is what gives a scene its full-frame box, and `lint` warns without it
3. Timelines must be paused and registered on `window.__timelines`:
```js
window.__timelines = window.__timelines || {};
@@ -75,11 +75,39 @@ describe("assignMediaRenderIds", () => {
expect(ids[1]).toBe("clip__hf2");
});
it("leaves media without a src alone", () => {
it("leaves media with no source at all alone", () => {
const { document } = parseHTML('<video id="no-src"></video>');
assignMediaRenderIds(document as unknown as Parameters<typeof assignMediaRenderIds>[0]);
expect(document.querySelector("video")?.hasAttribute(MEDIA_RENDER_ID_ATTR)).toBe(false);
});
it("stamps media whose source is a <source> child rather than a src attribute", () => {
// The selector used to be `video[src], audio[src], img[src]`, so this shape
// was never stamped and two inlined scenes kept colliding ids in the render
// document, which is the exact failure this module exists to prevent.
const { document } = parseHTML(
'<video id="clip"><source src="a.mp4" type="video/mp4"></video>' +
'<video id="clip"><source src="a.mp4" type="video/mp4"></video>',
);
assignMediaRenderIds(document as unknown as Parameters<typeof assignMediaRenderIds>[0]);
expect(
Array.from(document.querySelectorAll("video")).map((el) =>
el.getAttribute(MEDIA_RENDER_ID_ATTR),
),
).toEqual(["clip", "clip__hf2"]);
});
it("stamps <audio> with a <source> child too", () => {
const { document } = parseHTML('<audio id="bed"><source src="bed.mp3"></audio>');
assignMediaRenderIds(document as unknown as Parameters<typeof assignMediaRenderIds>[0]);
expect(document.querySelector("audio")?.getAttribute(MEDIA_RENDER_ID_ATTR)).toBe("bed");
});
it("ignores a <source> child that carries no src", () => {
const { document } = parseHTML('<video id="empty"><source type="video/mp4"></video>');
assignMediaRenderIds(document as unknown as Parameters<typeof assignMediaRenderIds>[0]);
expect(document.querySelector("video")?.hasAttribute(MEDIA_RENDER_ID_ATTR)).toBe(false);
});
});
describe("audio group render ids", () => {
+23 -2
View File
@@ -40,15 +40,35 @@ export const MEDIA_RENDER_ID_ATTR = "data-hf-render-id";
*/
export const AUDIO_GROUP_RENDER_ID_ATTR = "data-hf-group-render-id";
/** Elements the render pipeline addresses by id. */
const MEDIA_SELECTOR = "video[src], audio[src], img[src]";
/**
* Elements the render pipeline addresses by id.
*
* `<video>`/`<audio>` are matched whether the source is a `src` attribute or a
* `<source>` child. Matching only `[src]` left the `<source>`-child shape
* unstamped, so two scenes each declaring `<video id="clip"><source …></video>`
* kept colliding ids in the render document, which is exactly the failure this
* module exists to prevent.
*/
const MEDIA_SELECTOR = "video, audio, img[src]";
/** Buses, which are addressed by id in exactly the same way and collide the
* same way. Only an id'd bus can be joined at all. */
const AUDIO_GROUP_SELECTOR = "hf-audio-group[id]";
/** A `<source>`-bearing media element is addressable even without its own `src`. */
function hasPlayableSource(el: MediaElementLike): boolean {
if (el.getAttribute("src")) return true;
const sources = el.querySelectorAll?.("source[src]");
if (!sources) return false;
for (const _ of sources) return true;
return false;
}
interface MediaElementLike {
readonly tagName?: string;
getAttribute(name: string): string | null;
setAttribute(name: string, value: string): void;
querySelectorAll?(selector: string): Iterable<unknown>;
}
/** A bus or member, which additionally needs subtree scoping to be paired up. */
@@ -88,6 +108,7 @@ export function assignMediaRenderIds(document: DocumentLike): void {
const pending: MediaElementLike[] = [];
for (const el of document.querySelectorAll(MEDIA_SELECTOR)) {
if (!hasPlayableSource(el)) continue;
const existing = el.getAttribute(MEDIA_RENDER_ID_ATTR);
if (existing) {
taken.add(existing);
+12 -8
View File
@@ -432,7 +432,8 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
severity: "error",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses data-layer instead of data-track-index.`,
elementId,
fixHint: "Replace data-layer with data-track-index. The runtime reads data-track-index.",
fixHint:
"Replace data-layer with data-track-index, which is the canonical name Studio and the linter read. Neither name is read by the render.",
snippet: truncateSnippet(tag.raw),
});
}
@@ -935,10 +936,13 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
// can't leak styles into each other. A rule whose LEFTMOST selector is the ROOT
// element's own class (e.g. `.frame { ... }` on the same element that carries
// data-composition-id) therefore becomes a DESCENDANT selector that can never
// match the root — the whole scene renders unstyled (tiny text top-left, images
// at natural size). lint/validate/inspect evaluate the file in isolation (no
// scoping) and Studio previews each scene in its own iframe (no scoping), so the
// break is invisible until the composited MP4 render. Style the root via `#root`
// match the SCOPED element itself. NOTE on the symptom: since #1886 the producer
// preserves the authored root as a `data-hf-inner-root` wrapper INSIDE the scoped
// element (regression fixture packages/producer/tests/sub-comp-class-selector),
// so the class still matches as a descendant and the scene no longer renders
// unstyled. This rule is now a consistency constraint, not a render-bug guard:
// `#root` is the shape the registry blocks model and the one the scoper
// special-cases. Style the root via `#root`
// (the scoper special-cases the root id) and descendants via plain selectors,
// like the registry blocks — the runtime already scopes each scene by id, so a
// class namespace on the root is redundant.
@@ -960,10 +964,10 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
severity: "error",
message:
`Root element has class="${rootClasses.join(" ")}" and is styled by ${offenders.length} rule(s) keyed off that class (e.g. ${example}). ` +
`At render, every sub-composition rule is scoped to [data-composition-id="${rootCompositionId}"] <selector>, so a selector whose leftmost part is the ROOT's own class becomes a descendant selector that cannot match the root — the scene renders unstyled (tiny text top-left, full-size images). ` +
`lint/validate/inspect and Studio's per-frame iframe preview do not scope, so this passes every static check and looks correct in preview.`,
`At render, every sub-composition rule is scoped to [data-composition-id="${rootCompositionId}"] <selector>, so a selector whose leftmost part is the ROOT's own class becomes a descendant selector that cannot match the scoped element itself. ` +
`Since #1886 the producer preserves the authored root as an inner wrapper, so this no longer renders the scene unstyled, but #root is the shape the scoper special-cases and the registry blocks model. Use it so preview, render, and Studio agree.`,
selector: example,
fixHint: `Give the root id="root" and style it with \`#root { ... }\` plus plain descendant selectors (\`.kicker\`, \`#hero\`) — the runtime already scopes each sub-composition by data-composition-id, so a class namespace on the root is redundant and breaks under scoping.`,
fixHint: `Give the root id="root" and style it with \`#root { ... }\` plus plain descendant selectors (\`.kicker\`, \`#hero\`) — the runtime already scopes each sub-composition by data-composition-id, so a class namespace on the root is redundant.`,
snippet: truncateSnippet(rootTag.raw),
},
];
+18 -9
View File
@@ -227,7 +227,15 @@ describe("core rules", () => {
expect(finding).toBeDefined();
});
it("reports error when timeline registry is assigned without initializing", async () => {
// The runtime creates `window.__timelines` at script-evaluation time
// (runtime/entry.ts), before any inline composition script runs, so a bare
// assignment needs no `window.__timelines = window.__timelines || {}` guard.
// Verified by rendering a composition whose only registration is the bare
// assignment: it renders and animates correctly. The old
// `timeline_registry_missing_init` error therefore failed a working file, and
// because a lint ERROR also suppresses the layout and contrast audits in
// `check`, it cost far more than the line it asked for.
it("accepts a bracket registry assignment with no init guard", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -240,13 +248,13 @@ describe("core rules", () => {
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("without initializing");
expect(
result.findings.find((f) => f.code === "timeline_registry_missing_init"),
).toBeUndefined();
expect(result.findings.find((f) => f.code === "missing_timeline_registry")).toBeUndefined();
});
it("reports error when dot timeline registry is assigned without initializing", async () => {
it("accepts a dot registry assignment with no init guard", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
@@ -259,9 +267,10 @@ describe("core rules", () => {
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(
result.findings.find((f) => f.code === "timeline_registry_missing_init"),
).toBeUndefined();
expect(result.findings.find((f) => f.code === "missing_timeline_registry")).toBeUndefined();
});
it("does not flag timeline assignment when init guard is present", async () => {
+12 -14
View File
@@ -248,7 +248,7 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
];
},
// missing_timeline_registry + timeline_registry_missing_init
// missing_timeline_registry
// fallow-ignore-next-line complexity
({ source, rawSource, rootTag, options }) => {
// Sub-compositions inherit window.__timelines from the host composition
@@ -269,19 +269,17 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
fixHint: "Register each composition timeline on `window.__timelines[compositionId]`.",
});
}
if (
TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) &&
!TIMELINE_REGISTRY_INIT_PATTERN.test(source)
) {
findings.push({
code: "timeline_registry_missing_init",
severity: "error",
message:
"`window.__timelines[…] = …` is used without initializing `window.__timelines` first.",
fixHint:
"Add `window.__timelines = window.__timelines || {};` before any timeline assignment.",
});
}
// `timeline_registry_missing_init` used to fire here, demanding
// `window.__timelines = window.__timelines || {}` before any assignment.
// The runtime already owns that invariant: runtime/entry.ts creates the
// registry at script-evaluation time, before any inline composition script
// runs, and both injection paths put the runtime bundle in <head> ahead of
// the body scripts that build timelines. Verified by rendering a
// composition whose only registration is a bare
// `window.__timelines["main"] = gsap.timeline(...)`: it renders and
// animates correctly. The rule made a working file fail lint, and a lint
// ERROR also suppresses the layout and contrast audits in `check`, so it
// cost far more than the line it was protecting.
return findings;
},
+2 -2
View File
@@ -411,10 +411,10 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
findings.push({
code: "video_nested_in_timed_element",
severity: "error",
message: `<video> with data-start is nested inside <${parent.name}${parent.id ? ` id="${parent.id}"` : ""}> which also has data-start. The framework cannot manage playback of nested media — video will be FROZEN in renders.`,
message: `<video> with data-start is nested inside <${parent.name}${parent.id ? ` id="${parent.id}"` : ""}> which also has data-start. The frame extractor resolves the video's start from its own data-start without the wrapper's offset, while visibility uses the wrapper's window, so the two disagree: the clip shows the wrong source frames and then disappears partway through its slot.`,
elementId: readAttr(tag.raw, "id") || undefined,
fixHint:
"Move the <video> to be a direct child of the stage, or remove data-start from the wrapper div (use it as a non-timed visual container).",
"Time the wrapper OR the video, never both: remove data-start from the wrapper (use it as a non-timed visual container), or move the <video> up to be a direct child of the stage.",
snippet: truncateSnippet(tag.raw),
});
break;
+5 -5
View File
@@ -6,7 +6,7 @@
"files": 138
},
"faceless-explainer": {
"hash": "5de02925f2431645",
"hash": "a7080101c30ceeb7",
"files": 24
},
"figma": {
@@ -22,7 +22,7 @@
"files": 17
},
"hyperframes-animation": {
"hash": "67d23c9507ebb1e8",
"hash": "a5be218c0b0fe377",
"files": 121
},
"hyperframes-audio": {
@@ -34,7 +34,7 @@
"files": 11
},
"hyperframes-core": {
"hash": "74b9841f63c8f405",
"hash": "587492c7413387f6",
"files": 20
},
"hyperframes-creative": {
@@ -62,11 +62,11 @@
"files": 132
},
"pr-to-video": {
"hash": "337d781bd7517d2c",
"hash": "9ce0b5a7c75e969f",
"files": 30
},
"product-launch-video": {
"hash": "6d691b3db92e2c39",
"hash": "01256d74e511f163",
"files": 29
},
"remotion-to-hyperframes": {
@@ -12,7 +12,10 @@
// document order. Transitions are NOT written here — the transitions injector
// mutates this file afterward (data-start/duration/track-index + GSAP).
//
// Track lanes (same-track time-overlap is illegal — lint timeline_track_too_dense):
// Track lanes. Same-track time-overlap is this workflow's own assembly convention,
// not a framework rule: the render never reads data-track-index, and no lint rule
// checks overlap (timeline_track_too_dense counts elements per lane for readability).
// The convention exists because the frame injector below ping-pongs 0/1 for overlaps:
// 1 frame sub-comp clips (sequential; the injector 0/1-ping-pongs for overlaps)
// 2 captions sub-comp clip (full-duration overlay, on top of frames)
// 10 per-frame voice <audio>
@@ -215,7 +218,7 @@ function guardFrame(html, label) {
for (let i = 1; i < list.length; i++) {
if (list[i].start < list[i - 1].end - EPS) {
errors.push(
`${label}: clips on track ${track} overlap (one ends at ${r3(list[i - 1].end)}s, the next starts at ${r3(list[i].start)}s) — same-track time-overlap causes a render conflict. Put them on distinct data-track-index lanes or fix their windows.`,
`${label}: clips on track ${track} overlap (one ends at ${r3(list[i - 1].end)}s, the next starts at ${r3(list[i].start)}s). This workflow's injector assumes one clip per lane at a time. The render itself tolerates the overlap; put them on distinct data-track-index lanes or fix their windows.`,
);
break; // one report per track is enough
}
@@ -328,7 +328,8 @@ function runVerify(argv) {
a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS;
const fail = [];
// (4) global no same-track overlap (the lint invariant).
// (4) global no same-track overlap. This is this workflow's own lane convention,
// not a lint rule: nothing in the framework rejects same-track overlap.
const all = [...clips.values()];
for (let i = 0; i < all.length; i++)
for (let j = i + 1; j < all.length; j++) {
@@ -27,7 +27,7 @@ HyperFrames controls GSAP through its `gsap` runtime adapter. Create a paused ti
- The registry key must match the composition root's `data-composition-id`.
- Bracket and dot syntax both register: `window.__timelines["main"] = tl` and `window.__timelines.main = tl` are equivalent (the linter recognizes both). Bracket form is required when the id isn't a valid identifier (e.g. contains `-`).
- Do not call `tl.play()` for render-critical motion.
- Do not build timelines inside async code, timers, or event handlers.
- Building inside an async callback such as `document.fonts.ready` is supported and common. What breaks is **registering the key before the build finishes**: an empty timeline registered early is treated as ready and nested empty, so it renders blank (`lint`: `gsap_timeline_registered_before_async_build`, error). Assign `window.__timelines[id] = tl` at the end of the callback. Do not drive render-critical motion from timers or event handlers.
- Keep loops finite. HyperFrames renders finite video durations.
- **Render duration comes from `data-duration` on the composition root, not from GSAP timeline length.** Do not pad the timeline with empty tweens like `tl.set({}, {}, 283)` to "extend" it. (Some external docs show this trick; in HyperFrames it conflicts with the seek-driven duration model — set `data-duration` instead.)
@@ -70,10 +70,10 @@ HyperFrames is stricter than vanilla GSAP. Animate only:
**Forbidden** (breaks the renderer or the clip lifecycle):
- `display`, raw `visibility` never duration-tween these directly. Use `autoAlpha` (opacity plus endpoint visibility) or a zero-duration timeline set at an explicit boundary, and only on a non-clip element or wrapper inside a clip. Never target `.clip` itself.
- `display`, raw `visibility` **on a clip element**: never duration-tween these. HyperFrames owns a clip's visibility and `lint` rejects it. Use `autoAlpha` (opacity plus endpoint visibility) or a zero-duration timeline set at an explicit boundary. Animating a clip element's other visual properties is fine and the shipped catalog does it throughout; what is forbidden is taking over its visibility.
- Anything driven by `Math.random()`, `Date.now()`, `performance.now()`, or event handlers — animation state must be deterministic from time alone.
> **Note**: `docs/guides/gsap-animation.mdx` lists `width`/`height`/`visibility` in its "Supported Properties" — that list is too permissive for HyperFrames composition rules. This allowlist is the canonical one. See `hyperframes-core/references/determinism-rules.md` for the full deterministic-render contract.
> **Note**: the list above is a **denylist**, not an allowlist. Properties outside it, including `width`, `height`, `filter`, `clipPath` and `strokeDashoffset`, are legitimate targets; prefer transforms and opacity where you have the choice, for performance rather than correctness. See `hyperframes-core/references/determinism-rules.md` for the full deterministic-render contract.
## References
@@ -23,7 +23,8 @@ injector:
2. Pulls `#el-<to>` wrapper `data-start` earlier by `duration_s` (creates the
overlap window).
3. Reassigns **all** clip `data-track-index` as a 0/1 ping-pong so the two
overlapping wrappers never share a track (same-track overlap is illegal —
overlapping wrappers never share a track (a readability convention, not a
render constraint,
`core/src/lint/rules/composition.ts`). Higher track composites on top.
4. Stamps the `gsap_template` into `window.__timelines["main"]` at `T = overlap-start`.
+18 -14
View File
@@ -16,11 +16,11 @@ This skill is the **technical contract** — how to build one hyperframes projec
| `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/tracks-and-clips.md` | understand what `data-track-index` does (and does not) control, z-index, time a clip relative to another |
| `references/creator-editing-recipes.md` | copy truthful cut/trim/reorder/retime/freeze/camera/mask/crossfade/audio editing recipes and their limits |
| `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/determinism-rules.md` | build a seekable timeline; determinism bans; 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/review-loop.md` | run the plan → sketch → build review passes on a live board — shared by every storyboard-planning workflow |
@@ -38,11 +38,11 @@ For animation runtime specifics (GSAP API, Lottie, Three.js, etc.) go to `hyperf
### 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>`.
- **Standalone** (top-level `index.html`): root `<div data-composition-id="…">` sits directly in `<body>`, **no `<template>` wrapper**. Wrapping a standalone root hides all content and `lint` rejects it (`standalone_composition_wrapped_in_template`, error).
- **Sub-composition** (loaded via `data-composition-src`): wrap the root in `<template>`. This is the shape to write: the loader also accepts a plain full document and falls back to its `<body>`, but the templated form is what the examples and tooling assume.
> ⚠ 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.
> ⚠ Transport rule: for a **templated** sub-composition the assembler drops the file's own `<head>` `<style>`/`<script>` (`packages/core/src/compiler/compositionAssembly.ts`, the `hasTemplate` gate), so put `<style>`/`<script>` **inside** the template. `<link>` is hoisted either way.
> ⚠ Host-id convention: give the host slot, the inner template, and the `window.__timelines["<id>"]` key the **same** id. A different local id is supported (the assembler falls back to the first root in the file) but the mismatch is silent, so match them unless you have a reason not to.
File shape, host wiring, and the pre-render checklist → `references/sub-compositions.md`.
@@ -52,31 +52,35 @@ The standalone root needs an explicit **sized box** (`width`/`height` in px), an
### 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/`.
Each composition registers **exactly one** `gsap.timeline({ paused: true })` at `window.__timelines["<id>"]` (key = root `data-composition-id`). Building it inside an async callback (`document.fonts.ready`) is supported; what matters is that you **register only after the build completes**. Render length is the root's `data-duration`, **not** the timeline's length: a timeline that runs past it is cut off, and one that ends early holds its last frame. Omit the root `data-duration` and the length is inferred instead (timeline, media window, or adapter). You do not need `window.__timelines = window.__timelines || {}`: the runtime creates the registry before your inline scripts run, and `lint` no longer asks for it. Don't manually nest sub-timelines into the host; the runtime auto-nests registered child timelines. Full contract (incl. non-GSAP runtimes) → `references/determinism-rules.md` + `hyperframes-animation/adapters/`.
### First-pass lint gotchas (a guaranteed first build failure)
Two rules that `lint` **does** catch, but only after the fact — write them right the first time:
Rules that `lint` **does** catch, but only after the fact. Write them right the first time:
- The **root** composition element must carry `data-start="0"` (alongside `data-composition-id`/`data-width`/`data-height`); omitting it fails `lint` with `root_composition_missing_data_start`.
- Never pair a CSS initial `transform` with a GSAP tween on the **same** property — the CSS value and the tween's start fight and `lint` rejects it with `gsap_css_transform_conflict`. Set the initial state inside the tween with `gsap.fromTo(el, { x: -40 }, { x: 0 })` instead of a CSS `transform: translateX(-40px)`.
- Never put `crossorigin` on `<video>`/`<audio>`. `lint` rejects it unconditionally with `media_crossorigin_breaks_preview` (error), including for canvas/WebGL/WebAudio readback. There is no suppression.
- Never give a `<video data-start>` an ancestor that also carries `data-start`. `lint` rejects it with `video_nested_in_timed_element` (error). Time the wrapper **or** the video, not both.
- Every `<audio>` needs an `id`. `lint` rejects it with `media_missing_id`, and an id-less `<audio>` is never picked up by the mixer, so the render is **silent**.
A lint **error** also switches off the layout and contrast audits: `check` then reports `0 sample(s)` and `0/0 text checks`, which reads like a clean file but means nothing ran. Clear lint errors before you trust those numbers.
### Non-negotiable rules (silent bugs automated gates may miss)
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 tween `display` or raw `visibility`. GSAP `autoAlpha` and zero-duration timeline boundary sets are the only visibility exceptions, and only on non-clip elements or wrappers inside a clip. The framework alone controls `.clip` visibility. Do not `gsap.set` later-scene clips at page load.`determinism-rules.md`
- Never tween `display` or raw `visibility` on a clip element. The framework owns clip visibility, and `lint` rejects it. Use GSAP `autoAlpha` or a zero-duration boundary `set`. (Tweening ordinary visual properties on a clip element is fine; what lint forbids is taking over its visibility.)`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>` work at **any nesting depth** (including inside a sub-comp `<template>` or wrapper); the framework owns playback and seeks/decodes media wherever it lives. The one caveat is timelines, not placement: a sub-comp timeline can't animate host-root elements. → `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`
- `<video>`/`<audio>` are found by a flat document query, so the framework seeks and decodes them at **any nesting depth** (including inside a sub-comp `<template>` or wrapper). One hard limit: `lint` errors if a `<video data-start>` sits inside another **plain** element that also has `data-start`, and the failure is real (wrong source frames, then the clip vanishes mid-slot), so put the timing on the wrapper or on the video, never both. Sub-composition hosts are exempt: media inside a sub-composition renders correctly. The other caveat is timelines, not placement: a sub-comp timeline can't animate host-root elements. → `variables-and-media.md`
- Keep every `id` unique across the **assembled** page (prefix sub-comp ids with the composition id, `#<id>-hero`) so your own `#id` CSS and `getElementById` calls resolve. Frame injection no longer depends on it: the compiler stamps a document-unique `data-hf-render-id` on every `video[src]`/`audio[src]`/`img[src]`. Media that uses `<source>` children instead of a `src` attribute is **not** stamped, so unique ids still matter there. → `composition-patterns.md`
- A full-screen fill on the composition **root** is fine on a normal render. It is dropped only on the layered-composite path (HDR content, or a composition using shader transitions), where the engine forces every composition root transparent so the layer beneath shows through. If your composition uses shader transitions or HDR media, put the fill on a full-bleed **child** (`position:absolute; inset:0`). → `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.
- Adding a clip: set its `data-start`/`data-duration` intentionally against the clips around it. `data-track-index` is a Studio display lane, not a timing constraint, so it does not need to be free.
- `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.
@@ -119,7 +119,7 @@ When using sub-compositions, `index.html` should be **thin**. Its job is to decl
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.
- **Visual scenes on the same `data-track-index`** (e.g. `1`), authored sequentially. For a cross-fade, overlap their times by the fade duration; giving the incoming scene its own track keeps Studio's timeline readable, but the render accepts an overlap either way.
- **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`).
@@ -256,7 +256,7 @@ Before adding or modifying scenes, identify which architecture is in use:
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 **monolithic** project, add new scenes as inline `<section class="clip">` elements with a deliberate `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).
@@ -1,6 +1,10 @@
# Creator Editing Recipes
Use these copyable contracts after `tracks-and-clips.md`. Global math: **consumed source = timeline duration × rate**; **natural timeline duration = remaining source / rate**. Audio is always a separate audio track.
Use these copyable contracts after `tracks-and-clips.md`. Global math: **consumed source = timeline duration × rate**; **natural timeline duration = remaining source / rate**.
These recipes keep sound on a separate `<audio>` element with the `<video>` muted, which is the pattern to reach for when picture and sound are cut independently. An unmuted `<video>` that declares `data-has-audio="true"` is also mixed, so a separate track is a choice, not a requirement.
**Every `<video>` and `<audio>` below carries an `id`, and that is not cosmetic**: `lint` errors with `media_missing_id` on timed media without one, and an id-less `<audio>` is never picked up by the mixer, so the render comes out silent. Keep the ids when you copy a recipe.
## Hard cut
@@ -26,6 +30,7 @@ Use these copyable contracts after `tracks-and-clips.md`. Global math: **consume
playsinline
></video>
<audio
id="a-audio"
src="take.mp4"
data-start="0"
data-duration="2"
@@ -33,6 +38,7 @@ Use these copyable contracts after `tracks-and-clips.md`. Global math: **consume
data-track-index="10"
></audio>
<audio
id="b-audio"
src="take.mp4"
data-start="2"
data-duration="3"
@@ -41,12 +47,13 @@ Use these copyable contracts after `tracks-and-clips.md`. Global math: **consume
></audio>
```
Timeline math: B starts at A start + duration. Source math: each range starts at `data-media-start`; consumed source = timeline duration × rate. Audio follows: duplicate matching `<audio>` ranges/timing. Owner: `/hyperframes-core`. Limit: adjacent windows only; same-track overlap is invalid.
Timeline math: B starts at A start + duration. Source math: each range starts at `data-media-start`; consumed source = timeline duration × rate. Audio follows: duplicate matching `<audio>` ranges/timing. Owner: `/hyperframes-core`. Limit: adjacent windows only; author the two windows edge to edge.
## Trim in/out
```html
<video
id="shot-1"
src="take.mp4"
data-start="1"
data-duration="3"
@@ -56,6 +63,7 @@ Timeline math: B starts at A start + duration. Source math: each range starts at
playsinline
></video>
<audio
id="shot-1-audio"
src="take.mp4"
data-start="1"
data-duration="3"
@@ -70,6 +78,7 @@ Timeline math: visible window is `[1,4]`. Source math: in=6, out=6+3 at 1x; neve
```html
<video
id="shot-1"
src="take.mp4"
data-start="0"
data-duration="2"
@@ -79,6 +88,7 @@ Timeline math: visible window is `[1,4]`. Source math: in=6, out=6+3 at 1x; neve
playsinline
></video>
<video
id="shot-2"
src="take.mp4"
data-start="2"
data-duration="2"
@@ -88,6 +98,7 @@ Timeline math: visible window is `[1,4]`. Source math: in=6, out=6+3 at 1x; neve
playsinline
></video>
<audio
id="shot-1-audio"
src="take.mp4"
data-start="0"
data-duration="2"
@@ -95,6 +106,7 @@ Timeline math: visible window is `[1,4]`. Source math: in=6, out=6+3 at 1x; neve
data-track-index="10"
></audio>
<audio
id="shot-2-audio"
src="take.mp4"
data-start="2"
data-duration="2"
@@ -109,6 +121,7 @@ Timeline math: splice at t=2. Source math: independent source offsets select kep
```html
<video
id="shot-1"
src="take.mp4"
data-start="0"
data-duration="1"
@@ -118,6 +131,7 @@ Timeline math: splice at t=2. Source math: independent source offsets select kep
playsinline
></video>
<video
id="shot-2"
src="take.mp4"
data-start="4"
data-duration="1"
@@ -127,6 +141,7 @@ Timeline math: splice at t=2. Source math: independent source offsets select kep
playsinline
></video>
<audio
id="shot-1-audio"
src="take.mp4"
data-start="0"
data-duration="1"
@@ -134,6 +149,7 @@ Timeline math: splice at t=2. Source math: independent source offsets select kep
data-track-index="10"
></audio>
<audio
id="shot-2-audio"
src="take.mp4"
data-start="4"
data-duration="1"
@@ -148,6 +164,7 @@ Timeline math: copies may occupy different starts. Source math: identical offset
```html
<video
id="shot-1"
src="take.mp4"
data-start="0"
data-duration="2"
@@ -157,6 +174,7 @@ Timeline math: copies may occupy different starts. Source math: identical offset
playsinline
></video>
<video
id="shot-2"
src="take.mp4"
data-start="2"
data-duration="2"
@@ -166,6 +184,7 @@ Timeline math: copies may occupy different starts. Source math: identical offset
playsinline
></video>
<audio
id="shot-1-audio"
src="take.mp4"
data-start="0"
data-duration="2"
@@ -173,6 +192,7 @@ Timeline math: copies may occupy different starts. Source math: identical offset
data-track-index="10"
></audio>
<audio
id="shot-2-audio"
src="take.mp4"
data-start="2"
data-duration="2"
@@ -181,7 +201,7 @@ Timeline math: copies may occupy different starts. Source math: identical offset
></audio>
```
Timeline math: `data-start` defines authored order. Source math: source offsets need not be chronological. Audio follows: reorder identical matching audio windows. Owner: `/hyperframes-core`. Limit: same-track overlap is invalid.
Timeline math: `data-start` defines authored order. Source math: source offsets need not be chronological. Audio follows: reorder identical matching audio windows. Owner: `/hyperframes-core`. Limit: reordering changes placement only, not source ranges.
## Freeze / hold
@@ -195,6 +215,7 @@ Timeline math: the still owns its hold duration. Source math: final-source frame
```html
<video
id="shot-1"
src="take.mp4"
data-start="0"
data-duration="2"
@@ -276,6 +297,7 @@ Timeline math: overlap placed clips for the 0.5s handoff. Source math: each clip
></video>
</div>
<audio
id="a-audio"
src="a.mp4"
data-start="0"
data-duration="3"
@@ -283,6 +305,7 @@ Timeline math: overlap placed clips for the 0.5s handoff. Source math: each clip
data-automation='{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":1},{"t":2.5,"v":1},{"t":3,"v":0}]}]}'
></audio>
<audio
id="b-audio"
src="b.mp4"
data-start="2.5"
data-duration="3"
@@ -299,12 +322,13 @@ Timeline math: overlap placed clips for the 0.5s handoff. Source math: each clip
</script>
```
Timeline math: distinct tracks overlap by 0.5s with opposing opacity envelopes. Source math: each source range remains independent. Audio follows: opposing volume envelopes on distinct audio tracks. Owner: `/hyperframes-core` + `/hyperframes-keyframes` + `/hyperframes-audio`. Limit: same-track overlap is invalid.
Timeline math: distinct tracks overlap by 0.5s with opposing opacity envelopes. Source math: each source range remains independent. Audio follows: opposing volume envelopes on distinct audio tracks. Owner: `/hyperframes-core` + `/hyperframes-keyframes` + `/hyperframes-audio`. Limit: the crossfade is the opacity/volume envelopes, not a source-level dissolve.
## Volume fades / ducking
```html
<audio
id="music-bed"
src="music.wav"
data-start="0"
data-duration="5"
@@ -319,6 +343,7 @@ Timeline math: lane `t` is clip-local authored time: fade-in 01, duck down 2
```html
<video
id="shot-1"
src="take.mp4"
data-start="3"
data-duration="2"
@@ -329,6 +354,7 @@ Timeline math: lane `t` is clip-local authored time: fade-in 01, duck down 2
playsinline
></video>
<audio
id="shot-1-audio"
src="take.mp4"
data-start="3"
data-duration="2"
@@ -20,41 +20,46 @@ The root should be `position: relative`, have explicit pixel dimensions, and hid
## 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).
**`data-start` is what makes an element a clip.** The runtime collects `[data-start]` and drives visibility off that attribute, so any element carrying it is timed.
**Visual clips (`class="clip"`) must be DIRECT children of the composition root.** A clip nested inside a wrapper `<div>` is not registered as a clip, so its `data-start`/`data-duration` are ignored and it stays visible the whole composition. To wrap/transform a clip, put the wrapper _inside_ the clip, or animate the clip element itself; do not wrap the clip. (This is a clip-_visibility_ rule. `<video>`/`<audio>` are exempt: the framework drives their playback via a flat DOM query, so they seek/decode at any depth, including inside a sub-comp `<template>` — see `variables-and-media.md`.)
`class="clip"` is a **convention, not a requirement**: the runtime never reads it. Keep writing it, because the scaffold's shared `.clip { position: absolute; inset: 0 }` rule is what gives a scene its full-frame box, Studio uses it as an edit hint, and `lint` warns (`timed_element_missing_clip_class`) when a timed element lacks it. If you drop the class you must supply that layout yourself. Omit it on `<video>` and `<audio>`.
**Nesting is allowed.** A timed element inside a wrapper is still timed, and a timed ancestor clamps its descendants: a child cannot be visible while its timed ancestor is hidden. Direct children of the root get automatic layout (see "Root-level clips get automatic layout" below); nested ones do not, so give them their own positioning.
| 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. |
| `id` | Yes on `<video>`/`<audio>`, else recommended | `lint` errors with `media_missing_id` on media without one, and an id-less `<audio>` is never mixed, so the render is **silent**. Elsewhere it is a warning (`studio_missing_editable_id`): Studio needs a stable edit target, and timeline targets reference it. |
| `data-start` | Yes | Start time in seconds, or a supported clip-time reference. This attribute is what marks the element as timed. |
| `data-duration` | Required for `div`, `img`, and sub-compositions | Duration in seconds. Video/audio can default to media duration when known. Without any resolvable duration the element has no end and stays visible for the rest of the composition. |
| `data-track-index` | No | Studio timeline lane, display only. The render never reads it, and clips on one track may overlap in time. Absent, the parser defaults it and Studio lays out one lane per clip. Two `<audio>` elements on the same index that overlap in time raise a `lint` warning. |
| `data-media-start` | No | Offset into the media source, in seconds. |
| `data-volume` | No | Static audio gain, default `1` (0 dB). `0` is silence and values above `1` boost, up to `3.98` (+12 dB) — Studio's fader writes this. For fades, animate `volume` on the timeline instead (see `variables-and-media.md`); a tween's own values replace this baseline entirely. |
| `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.)
**The visibility window is half-open: `[start, start + duration)`.** A clip shows while `start ≤ t < start + duration` and is hidden at exactly `t = start + duration`. Land an animation's resolved end state slightly **before** `data-duration`, not on it, or its last frame is never rendered. Two clips can therefore be authored back to back (`b.start === a.start + a.duration`) with no overlapping frame.
**Root-level clips get automatic layout.** For direct children of the composition root that carry `data-start`, the runtime forces `position: absolute` and anchors them at `top: 0; left: 0`, sizing them to 100% when they have no computed size, so scenes stack in the same viewport layer. Elements **without** `data-start` are skipped entirely: an untimed full-bleed background needs its own `position: absolute; inset: 0`, or it collapses to zero height.
## 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. |
| Attribute | Required | Meaning |
| ---------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data-composition-id` | Recommended | The composition ID of the loaded file. Matching it is the convention; a host that names a different id, or none at all, is supported but resolves silently. |
| `data-composition-src` | Yes | Path to the sub-composition HTML file. |
| `data-width` / `data-height` | No | Render dimensions for the instance. The compiler backfills them from the loaded file's root when absent. |
| `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.
- `class="clip"`: layout and tooling convention on visible timed elements (`<div>`, `<img>`, …), not a runtime requirement. See Clip Attributes above.
- `data-root="true"`: names the composition root explicitly. Without it the runtime picks the outermost `[data-composition-id]` element, which is right for almost every file; set it when compositions nest and you need to be unambiguous.
- `data-layout-allow-overflow` — tells `hyperframes check` that overflow on this element (or its descendants) is intentional. Notes:
- The `check` layout audit measures `getBoundingClientRect` at sampled timestamps, not rendered pixels. `overflow: hidden` clips the visual but does **not** suppress a layout 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.
@@ -8,14 +8,12 @@ GSAP is the primary runtime. The core requirement is generic: animation state mu
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.
- Register it on `window.__timelines["<composition-id>"]`, keyed by the composition root's `data-composition-id`. You do **not** need to write `window.__timelines = window.__timelines || {}` first: the runtime creates the registry before your inline scripts evaluate.
- **Building inside an async callback is supported.** `document.fonts.ready(...)` and friends are the documented setup path. What you must not do is **register the key before the build finishes**. An empty timeline registered early is treated as ready and nested empty, so the animation renders blank (`lint`: `gsap_timeline_registered_before_async_build`, error). Assign `window.__timelines[id] = tl` at the **end** of the callback, after the tweens are added, and optionally call `window.__hfForceTimelineRebind()` right after.
- If the key does not match the root's `data-composition-id`, the runtime still binds it **when it is the only registered timeline**. With two or more registered, a mismatched key leaves the render frozen at t=0.
- **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.
@@ -42,7 +40,8 @@ Rendered frames must be reproducible from the requested time. Do **not** use any
Also avoid:
- Animating anything outside the visual-property allowlist: `opacity`, `x`, `y`, `scale`, `rotation`, `color`, `backgroundColor`, `borderRadius`, and transforms. Never tween `display` or raw `visibility`. GSAP `autoAlpha` is allowed on a registered seekable timeline because it interpolates opacity and changes visibility only at the hidden endpoint. A zero-duration `tl.set(..., { visibility: "hidden" | "visible" })` is also allowed at an explicit beat boundary for a deterministic hard kill. Both exceptions apply only to non-clip elements or wrappers inside a clip. Never target a `.clip` element: HyperFrames timing owns its lifecycle and visibility.
- Tweening `display` or raw `visibility` **on a clip element**: HyperFrames timing owns a clip's visibility, and `lint` rejects it. Use GSAP `autoAlpha` (it interpolates opacity and flips visibility only at the hidden endpoint) or a zero-duration `tl.set(..., { visibility: "hidden" | "visible" })` at an explicit beat boundary for a deterministic hard kill. Animating a clip element's ordinary visual properties (`opacity`, transforms, `filter`, …) is fine and the shipped catalog does it constantly; what is forbidden is taking over its visibility.
- There is no fixed allowlist of animatable properties. `lint` enforces a **denylist**, so `filter`, `clipPath`, `strokeDashoffset`, `width`, `height` and similar are all legitimate targets. Prefer transforms and opacity where you have the choice, for performance rather than correctness. The per-runtime detail lives in `hyperframes-animation/adapters/`.
- 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
@@ -9,6 +9,19 @@ Stacking opaque scene divs means every scene change has to repaint the entire fr
## Pattern
```html
<style>
/* The runtime auto-positions root children that carry data-start. The shared
background deliberately has none, so it gets NO automatic layout and must
size itself, or #bg is 0px tall and the tween paints nothing. */
#bg.full-bleed {
position: absolute;
inset: 0;
}
.clip.transparent {
background: transparent;
}
</style>
<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>
@@ -51,7 +64,8 @@ Stacking opaque scene divs means every scene change has to repaint the entire fr
## Rules
- **The background is not a clip.** No `data-start` / `data-duration` / `data-track-index`. It exists for the whole composition.
- **The background is not a clip.** No `data-start` / `data-duration`. It exists for the whole composition.
- **Because it is not a clip, it gets no automatic layout.** The runtime only positions and sizes root children that carry `data-start`. An untimed background must set its own `position: absolute; inset: 0`, or it collapses to zero height and nothing you animate on it is visible. This is the most common way this pattern is copied wrong.
- **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.
@@ -44,12 +44,11 @@ The smallest renderable HyperFrames composition — a standalone (top-level) roo
data-height="1080"
data-duration="5"
>
<section id="title-card" class="clip" data-start="0" data-duration="5" data-track-index="1">
<section id="title-card" class="clip" data-start="0" data-duration="5">
<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;
@@ -58,10 +57,13 @@ The smallest renderable HyperFrames composition — a standalone (top-level) roo
</html>
```
Required elements:
What the runtime actually requires:
- 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>"]`
- Root `<div>` with `data-composition-id`, `data-width`, `data-height`. Root `data-start="0"` is written above by convention and every shipped block has it, but the runtime stamps it when absent, so it is not required.
- A duration source: root `data-duration` (as above), or a GSAP timeline, or media, or an adapter that can infer one.
- Timed elements carry `data-start` plus a duration. That attribute alone is what makes an element a clip: `class="clip"` is a layout and tooling convention, and `data-track-index` is a Studio display lane. Neither is required, and a composition with no clips at all renders fine.
- A GSAP timeline created paused and registered on `window.__timelines["<composition-id>"]`.
Everything else in the skeleton is ordinary HTML and CSS: the `#root` box, `.clip` positioning, and fonts are yours to choose.
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`.
@@ -173,9 +173,11 @@ Static file checks cannot prove the **cross-file mount contract**. These failure
</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.
**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 scoped element itself. `#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, since 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: isolated checks or scene previews can look correct because they do not reproduce the final scoped mount; the defect appears in the composited render. Lint rule `subcomposition_root_styled_by_class` flags it; the registry blocks (e.g. `apple-money-count`) model the `#root` pattern.
**What actually happens today:** the render no longer drops these rules. Since #1886 the producer preserves the authored root as a `data-hf-inner-root` wrapper _inside_ the scoped element, so a class on the root still matches as a descendant and preview and render agree. The regression is pinned by `packages/producer/tests/sub-comp-class-selector/`.
**So why still use `#root`?** Because `lint` rejects the class form outright (`subcomposition_root_styled_by_class`, **error**), and it is the pattern the registry blocks model (e.g. `apple-money-count`). Treat this as a lint constraint you must satisfy, not as a render bug you are avoiding. If you inherited a composition that styles its root by class, it renders correctly; you still have to convert it to `#root` to get a green `lint`.
### Verification checklist before render
@@ -1,10 +1,10 @@
# Tracks and Clips
Clips are timed children of the composition root. Tracks are a temporal-overlap concept, not a visual-stacking concept.
Clips are timed elements inside a composition. Tracks are a Studio display concept: the render never reads them.
## What is a Clip
A clip is any DOM element with `data-start`, `data-duration` (where required), and `data-track-index`. Common kinds:
A clip is any DOM element with `data-start` and, where required, `data-duration`. `data-track-index` is optional. Common kinds:
- **Visual `<div>` clips** — scenes, cards, overlays. Always require `data-duration`.
- **Sub-composition hosts**`<div>` with `data-composition-src`. Always require `data-duration`.
@@ -12,30 +12,29 @@ A clip is any DOM element with `data-start`, `data-duration` (where required), a
- **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.
Add `class="clip"` to authored visual clips. The runtime does not read it, but the scaffold's shared `.clip { position: absolute; inset: 0 }` rule is what gives a scene its full-frame box, Studio treats it as an edit hint, and `lint` warns without it.
## Tracks Are Temporal, Not Visual
## Tracks Are a Display Lane
`data-track-index` controls **temporal overlap**, not paint order:
`data-track-index` is the row a clip occupies in Studio's timeline. It is **not** read by the render, and it constrains nothing:
- **Two clips on the same `data-track-index`** must NOT overlap in time. `hyperframes lint` flags this.
- **Two clips on the same track may overlap in time.** Nothing rejects it and the render is well defined: both are visible, painted in CSS order.
- **Visual layering (front/back)** is controlled by CSS `z-index`, not by track index.
- **Omitting it is fine.** The parser defaults it, and Studio then lays out one lane per clip.
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.
A clip on track `5` is not "above" a clip on track `1`. Use CSS for layering, `data-start`/`data-duration` for sequencing.
The one place the value carries meaning: two `<audio>` elements that share a track index **and** overlap in time raise a `lint` warning (`duplicate_audio_track`), which is a useful nudge that you are about to double up a bed.
## Picking a Track Index
There's no fixed convention, but common patterns:
Purely a readability choice for whoever opens the file in Studio. 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.
- **Higher tracks (e.g. 10+)**: audio clips, separated from visual tracks.
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.
When adding a clip to an existing composition, set its `data-start`/`data-duration` against the clips around it. You do not need to hunt for a free lane, and you never need to renumber tracks after a retime.
## Clip Time Inside the Composition
@@ -78,11 +77,15 @@ timing as its matching video clip (`data-media-start`, `data-duration`, and
></video>
```
Rules:
Rules, and three ways this fails **silently**. Nothing in `lint` checks any of them, so read them before you use a reference:
- 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).
- **Spaces around the operator are required.** `data-start="intro - 0.5"` means "0.5s before `intro` ends". `data-start="intro-0.5"` (no spaces) is parsed as a reference to an element whose id is literally `intro-0.5`; that element does not exist, so the clip silently starts at 0.
- **An unresolved reference resolves to 0**, it does not error. A typo'd id, or a target that is not in the document, puts the clip at the start of the composition.
- **If the target has no resolvable duration, the reference lands on the target's START, not its end.** So `data-start="hero"` where `hero` has no `data-duration` and no known media length silently means "same time as `hero`" rather than "after `hero`".
- **A cycle resolves to 0** rather than erroring. `A → B → A` puts one of them at 0.
- Lookup is **document-wide** (`getElementById`, then `[data-composition-id]`). A reference can therefore reach a target in another composition on the assembled page. Keep referenced ids unique and keep the reference and its target in the same file, or the result depends on assembly order.
- A value that parses as a number is always absolute seconds. Otherwise the resolver expects `<id>`, `<id> + <number>`, or `<id> - <number>`.
- 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.
- Negative offsets create overlap, which is allowed. Overlapping clips do **not** need different tracks.
Because every failure mode above is a silent 0, snapshot a reference-timed composition and check the clip actually starts where you meant.
@@ -95,8 +95,10 @@ Video elements must be muted and inline. Audio must be a separate `<audio>` elem
- **Do not** call `video.play()`, `audio.play()`, pause, or seek in composition code. HyperFrames owns playback.
- **Do not** drive host-root media from a sub-comp timeline: a sub-comp timeline cannot reach elements outside its subtree, so it has no effect. Drive host-root media from the main timeline at global time (or keep the media inside the sub-comp whose timeline animates it).
- **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.
- **Do not** nest video inside a timed wrapper. `lint` rejects a `<video data-start>` whose ancestor also carries `data-start` (`video_nested_in_timed_element`, error), and the failure is real: the frame extractor resolves the video's start from its own `data-start` without the wrapper's offset, while visibility uses the wrapper's window. The clip then shows the wrong source frames and disappears partway through its slot. Put the timing on the wrapper **or** on the media element, never both.
- **Sub-compositions are exempt and work.** A `<video>`/`<audio>` inside a sub-composition renders identically to one at the host root, because a composition host propagates its offset. Only plain timed wrappers (a `<section data-start>` around a `<video data-start>`) break.
- **Never** add `crossorigin` to `<video>`/`<audio>`. `lint` rejects it unconditionally (`media_crossorigin_breaks_preview`, error) because a media host without `Access-Control-Allow-Origin` then fails silently in preview while renders still work, hiding the bug. There is no suppression, so this holds even for the canvas/WebGL/WebAudio readback case.
- **Every `<audio>` needs an `id`.** The mixer selects `audio[id][src]`, so an id-less `<audio>` is never mixed and the render is **silent**. `lint` catches it as `media_missing_id`.
- 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. A tween's values REPLACE that baseline rather than scaling it, so on a clip whose gain is not `1` you scale the tween's targets instead (`{ volume: 1.95 }`, not `{ volume: 1 }`) — `lint` warns with `audio_volume_tween_overrides_gain` when the two disagree.
@@ -12,7 +12,10 @@
// document order. Transitions are NOT written here — the transitions injector
// mutates this file afterward (data-start/duration/track-index + GSAP).
//
// Track lanes (same-track time-overlap is illegal — lint timeline_track_too_dense):
// Track lanes. Same-track time-overlap is this workflow's own assembly convention,
// not a framework rule: the render never reads data-track-index, and no lint rule
// checks overlap (timeline_track_too_dense counts elements per lane for readability).
// The convention exists because the frame injector below ping-pongs 0/1 for overlaps:
// 1 frame sub-comp clips (sequential; the injector 0/1-ping-pongs for overlaps)
// 2 captions sub-comp clip (full-duration overlay, on top of frames)
// 10 per-frame voice <audio>
@@ -216,7 +219,7 @@ function guardFrame(html, label) {
for (let i = 1; i < list.length; i++) {
if (list[i].start < list[i - 1].end - EPS) {
errors.push(
`${label}: clips on track ${track} overlap (one ends at ${r3(list[i - 1].end)}s, the next starts at ${r3(list[i].start)}s) — same-track time-overlap causes a render conflict. Put them on distinct data-track-index lanes or fix their windows.`,
`${label}: clips on track ${track} overlap (one ends at ${r3(list[i - 1].end)}s, the next starts at ${r3(list[i].start)}s). This workflow's injector assumes one clip per lane at a time. The render itself tolerates the overlap; put them on distinct data-track-index lanes or fix their windows.`,
);
break; // one report per track is enough
}
+2 -1
View File
@@ -326,7 +326,8 @@ function runVerify(argv) {
a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS;
const fail = [];
// (4) global no same-track overlap (the lint invariant).
// (4) global no same-track overlap. This is this workflow's own lane convention,
// not a lint rule: nothing in the framework rejects same-track overlap.
const all = [...clips.values()];
for (let i = 0; i < all.length; i++)
for (let j = i + 1; j < all.length; j++) {
@@ -12,7 +12,10 @@
// document order. Transitions are NOT written here — the transitions injector
// mutates this file afterward (data-start/duration/track-index + GSAP).
//
// Track lanes (same-track time-overlap is illegal — lint timeline_track_too_dense):
// Track lanes. Same-track time-overlap is this workflow's own assembly convention,
// not a framework rule: the render never reads data-track-index, and no lint rule
// checks overlap (timeline_track_too_dense counts elements per lane for readability).
// The convention exists because the frame injector below ping-pongs 0/1 for overlaps:
// 1 frame sub-comp clips (sequential; the injector 0/1-ping-pongs for overlaps)
// 2 captions sub-comp clip (full-duration overlay, on top of frames)
// 10 per-frame voice <audio>
@@ -297,7 +300,7 @@ function guardFrame(html, label) {
const media = scan.match(/<(video|audio)(?=[\s/>])/i);
if (media) {
errors.push(
`${label}: has a <${media[1].toLowerCase()}> inside the sub-composition. The runtime only drives media that is a DIRECT child of the host root (index.html) — sub-comp media renders blank/black. Move the clip to index.html as a root-level <video>/<audio> and drive any per-scene motion on the main timeline (composition-patterns.md archetype B).`,
`${label}: has a <${media[1].toLowerCase()}> inside the sub-composition. This workflow hoists media to index.html so the frame injector owns it; the framework itself renders media inside a sub-composition identically to media at the host root (verified by render, and pinned by packages/producer/tests/sub-composition-video), so this is an assembly convention, not a runtime limit. Move the clip to index.html as a root-level <video>/<audio> and drive any per-scene motion on the main timeline (composition-patterns.md archetype B).`,
);
}
@@ -337,7 +340,7 @@ function guardFrame(html, label) {
for (let i = 1; i < list.length; i++) {
if (list[i].start < list[i - 1].end - EPS) {
errors.push(
`${label}: clips on track ${track} overlap (one ends at ${r3(list[i - 1].end)}s, the next starts at ${r3(list[i].start)}s) — same-track time-overlap causes a render conflict. Put them on distinct data-track-index lanes or fix their windows.`,
`${label}: clips on track ${track} overlap (one ends at ${r3(list[i - 1].end)}s, the next starts at ${r3(list[i].start)}s). This workflow's injector assumes one clip per lane at a time. The render itself tolerates the overlap; put them on distinct data-track-index lanes or fix their windows.`,
);
break; // one report per track is enough
}
@@ -326,7 +326,8 @@ function runVerify(argv) {
a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS;
const fail = [];
// (4) global no same-track overlap (the lint invariant).
// (4) global no same-track overlap. This is this workflow's own lane convention,
// not a lint rule: nothing in the framework rejects same-track overlap.
const all = [...clips.values()];
for (let i = 0; i < all.length; i++)
for (let j = i + 1; j < all.length; j++) {