Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions docs/concepts/data-attributes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions docs/reference/html-schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
12 changes: 9 additions & 3 deletions packages/cli/src/commands/coreSkillContent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/docs/data-attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
4 changes: 2 additions & 2 deletions packages/cli/src/templates/_shared/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {};
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/templates/_shared/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {};
Expand Down
30 changes: 29 additions & 1 deletion packages/core/src/compiler/mediaRenderIds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
25 changes: 23 additions & 2 deletions packages/core/src/compiler/mediaRenderIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand 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);
Expand Down
20 changes: 12 additions & 8 deletions packages/lint/src/rules/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
}
Expand Down Expand Up @@ -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.
Expand All @@ -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),
},
];
Expand Down
27 changes: 18 additions & 9 deletions packages/lint/src/rules/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand All @@ -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">
Expand All @@ -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 () => {
Expand Down
Loading
Loading