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
4 changes: 1 addition & 3 deletions packages/cli/src/registry/registryComponents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,7 @@ async function invalidInstallableMedia(entryName: string): Promise<string[]> {
isSubComposition: true,
});
for (const finding of result.findings) {
if (finding.code !== "media_in_subcomposition" && finding.code !== "media_missing_src") {
continue;
}
if (finding.code !== "media_missing_src") continue;
invalidMedia.push(`${entryName}/${file.path}: ${finding.code}`);
}
}
Expand Down
13 changes: 8 additions & 5 deletions packages/lint/src/rules/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,13 @@ describe("media rules", () => {
expect(finding).toBeUndefined();
});

it("flags <video> inside a sub-composition (media must be a host-root child)", async () => {
it("does not flag <video> inside a sub-composition (runtime drives nested media)", async () => {
// The runtime's global media sweep (querySelectorAll("video, audio")) drives
// media at any nesting depth, and startResolver re-bases each nested clip's
// local data-start by its host composition's absolute start. Sub-composition
// media is therefore seeked + decoded correctly in preview and render — see
// packages/core/src/runtime/{media,startResolver,init}.ts. A prior
// `media_in_subcomposition` rule wrongly hard-errored this and was removed.
const html = `<template id="scene-template">
<div id="root" data-composition-id="scene" data-width="1920" data-height="1080">
<video id="v1" src="clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
Expand All @@ -357,10 +363,7 @@ describe("media rules", () => {
</template>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find((f) => f.code === "media_in_subcomposition");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("v1");
expect(finding?.message).toContain("sub-composition");
expect(finding).toBeUndefined();
});

it("does not flag media in a host-root (non-sub) composition", async () => {
Expand Down
23 changes: 0 additions & 23 deletions packages/lint/src/rules/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,29 +396,6 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
return findings;
},

// media_in_subcomposition — <video>/<audio> only render as a DIRECT child of the host
// root (index.html). Inside a sub-composition <template> the runtime never seeks/decodes
// them, so they render BLANK/black in preview and renders — and the other lint/validate
// passes otherwise miss it (only a per-frame snapshot reveals the blank panel).
({ tags, options }) => {
const findings: HyperframeLintFinding[] = [];
if (!options.isSubComposition) return findings;
for (const tag of tags) {
if (tag.name !== "video" && tag.name !== "audio") continue;
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "media_in_subcomposition",
severity: "error",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> is inside a sub-composition. The runtime only drives media that is a DIRECT child of the host root (index.html); media inside a sub-comp <template> is never seeked/decoded and renders BLANK/black in preview and renders.`,
elementId,
fixHint:
"Move the media OUT of the sub-composition: place the <video>/<audio> as a direct child of #root in index.html, positioned over the scene, and drive any per-scene motion on the MAIN timeline at global time (a sub-comp timeline cannot reach host elements). See composition-patterns.md archetype B.",
snippet: truncateSnippet(tag.raw),
});
}
return findings;
},

// self_closing_media_tag
({ source }) => {
const findings: HyperframeLintFinding[] = [];
Expand Down
8 changes: 4 additions & 4 deletions skills-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"files": 140
},
"faceless-explainer": {
"hash": "d53bcc3cbcfaa7bb",
"hash": "15fc5a20e2a44bbe",
"files": 22
},
"figma": {
Expand All @@ -26,11 +26,11 @@
"files": 121
},
"hyperframes-cli": {
"hash": "dc57ec7198b9eaac",
"hash": "de00375f053c05e8",
"files": 11
},
"hyperframes-core": {
"hash": "85004c9571ed19ce",
"hash": "773fc6d15d9e87b3",
"files": 19
},
"hyperframes-creative": {
Expand Down Expand Up @@ -58,7 +58,7 @@
"files": 132
},
"pr-to-video": {
"hash": "17ed27aa2cc32501",
"hash": "0eb5abe09844cee4",
"files": 29
},
"product-launch-video": {
Expand Down
30 changes: 13 additions & 17 deletions skills/faceless-explainer/scripts/assemble-index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,14 @@
// `lint` failures surface HERE instead of after assembly + a wasted render):
// ① AUTO-REPAIR — a sub-comp root missing data-width/data-height: inject the canvas
// dims (the renderer needs them on the cloned root; else lint root_missing_dimensions).
// ② HARD FAIL — <video>/<audio> inside a sub-comp: the runtime only drives media that
// is a DIRECT child of the host root, so sub-comp media renders blank/black.
// ③ HARD FAIL — a timed element (data-start+duration+track-index) that is not the root
// ② HARD FAIL — a timed element (data-start+duration+track-index) that is not the root
// and lacks class="clip" (shows the whole frame), or two same-track clips that overlap.
// (Media inside a sub-comp is NOT a violation: the runtime seeks + decodes nested
// <video>/<audio> at any depth — see packages/core/src/runtime/{media,startResolver}.ts.)
//
// Exit 0 = index.html written + summary. Exit 1 = fatal contract break (no
// frames, a built/animated frame missing its src/file, a frame with no
// duration, an inner data-composition-id mismatch, or a guard ②/③ violation).
// duration, an inner data-composition-id mismatch, or a guard ② violation).
// No backstop: fix upstream.

import { existsSync, readFileSync, writeFileSync } from "node:fs";
Expand Down Expand Up @@ -121,15 +121,15 @@ const outPath = resolve(flag("out", join(hyperframesDir, "index.html")));

const r3 = (x) => Math.round(x * 1000) / 1000;
const anomalies = [];
const frameErrors = []; // fatal per-frame composition violations (guards ②/③) — reported together
const frameErrors = []; // fatal per-frame composition violations (guard ②) — reported together
const repairs = []; // auto-repairs applied to frame files in place (guard ①)

// ---------- parse storyboard ----------
if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`);
const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8"));
const { width: WIDTH, height: HEIGHT } = parseFormat(manifest.globals.format);

// ---------- per-frame composition guards (see header ①②) ----------
// ---------- per-frame composition guards (see header ①②) ----------
// String-level checks on each frame's HTML — no DOM parse, deterministic, run in
// the same pass that already reads the file. OPEN_TAG matches one opening tag while
// tolerating quoted attribute values that contain ">" (e.g. inline styles).
Expand Down Expand Up @@ -166,21 +166,17 @@ function guardFrame(html, label) {
const errors = [];
// Scan a copy with comments + <script>/<style> bodies blanked, so a tag-like string
// in a comment (e.g. "<!-- match the host <video> coords -->") or in GSAP code can't
// trip ②/③. ① still splices into the ORIGINAL html, so its offsets stay correct.
// trip ②. ① still splices into the ORIGINAL html, so its offsets stay correct.
const scan = html
.replace(/<!--[\s\S]*?-->/g, " ")
.replace(/<script\b[\s\S]*?<\/script[^>]*>/gi, " ")
.replace(/<style\b[\s\S]*?<\/style[^>]*>/gi, " ");

// ② media inside a sub-comp — never driven by the runtime (renders blank/black).
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).`,
);
}

// ③ timed-element checks: missing class="clip", and same-track window overlap.
// ② timed-element checks: missing class="clip", and same-track window overlap.
// (Media inside a sub-comp is fine: the runtime's global media sweep seeks + decodes
// <video>/<audio> at any nesting depth, re-basing each clip's local data-start by its
// host composition's absolute start — no root-child requirement. See
// packages/core/src/runtime/{media,startResolver}.ts.)
const re = new RegExp(OPEN_TAG, "g");
const clips = [];
let m;
Expand Down Expand Up @@ -285,7 +281,7 @@ for (const f of manifest.frames) {
`${label}: ${f.src} is empty or has no HTML — the worker wrote a blank/partial file. Re-dispatch that worker before assembling.`,
);
}
// pre-assembly guards: ① repair missing root dims in place, ②/③ collect fatal violations.
// pre-assembly guards: ① repair missing root dims in place, ② collects fatal violations.
const guard = guardFrame(html, label);
if (guard.repairedHtml) {
writeFileSync(compAbs, guard.repairedHtml);
Expand Down
8 changes: 1 addition & 7 deletions skills/hyperframes-cli/references/lint-validate-inspect.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,7 @@ npx hyperframes lint --json # machine-readable

Lints `index.html` and all files in `compositions/`. Reports errors (must fix), warnings (should fix), and info (with `--verbose`). Catches missing `data-composition-id`, overlapping tracks on the same `data-track-index`, unregistered timelines, and GSAP/CSS transform conflicts.

**Blind spot — media inside a sub-composition (not yet a lint rule).** A `<video>`/`<audio>` inside a `compositions/*.html` `<template>` (or nested in a wrapper `<div>` anywhere) is never seeked/decoded and renders blank/black; the automated checks all pass. Media must be a direct child of the host root (`index.html`) — see `hyperframes-core` → `variables-and-media.md`. Until a rule exists, check manually before render:

```bash
grep -nE '<(video|audio)\b' compositions/*.html # expect NO matches; media belongs in index.html
```

A non-empty result is a defect. Then `snapshot` each scene that has a video and confirm the panel actually shows footage (a blank/black panel where a clip should play is a bug, not a placeholder — treat it as render-blocking).
`<video>`/`<audio>` work at any nesting depth, including inside a `compositions/*.html` sub-composition or a wrapper `<div>`: the runtime discovers media with a flat DOM query and seeks/decodes it wherever it lives (`packages/core/src/runtime/{media,startResolver}.ts`). After a render, `snapshot` each scene that has a video and confirm the panel actually shows footage (a blank/black panel where a clip should play is a real bug, not a placeholder).

## check

Expand Down
2 changes: 1 addition & 1 deletion skills/hyperframes-core/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ 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`
- No `<br>` in body text; transformed elements must be block-level + sized; pulsing absolute decoratives need peak clearance. → `determinism-rules.md`
- `<video>`/`<audio>` must be a **direct child of the host root** (never inside a sub-comp `<template>`/wrapper); the framework owns playback. → `variables-and-media.md`
- `<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`

Expand Down
8 changes: 4 additions & 4 deletions skills/hyperframes-core/references/composition-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,11 @@ Key properties of this layout:

The sub-comp contains the scene's full DOM, scoped CSS, and timeline. This is the standard pattern in `sub-compositions.md` — most scenes are this.

### B. Host media + main-timeline driver (REQUIRED for any `<video>`/`<audio>`)
### B. Host media + main-timeline driver (one pattern for `<video>`/`<audio>`)

Media playback only works when the `<video>`/`<audio>` is a **direct child of the host root** — never inside a sub-comp `<template>` (it would render blank/black). This is not optional or "for media that spans scenes"; it applies to every clip, including a scene-specific one. The scene's sub-comp keeps the frame/shell; the media is a host sibling positioned over it.
`<video>`/`<audio>` seek and decode at any nesting depth, so a scene-specific clip can live inside its scene's sub-comp with scene-local `data-start` and be driven by that sub-comp's own timeline. Use this host-media pattern instead when you want the media's motion authored on the **main** timeline: put the `<video>`/`<audio>` as a host-root sibling positioned over the scene's frame.

A sub-comp timeline **cannot** drive host elements (a global selector or `document.querySelector` does not resolve across the boundary). So author the media's per-scene motion (scale/opacity/morph/tilt/breathing) on the **main timeline** in `index.html`, at **global time** = scene-local time + the scene slot's `data-start`.
The reason to reach for it: a sub-comp timeline **cannot** drive host elements (a global selector or `document.querySelector` does not resolve across the boundary). So if the media lives at the host root, author its per-scene motion (scale/opacity/morph/tilt/breathing) on the **main timeline** in `index.html`, at **global time** = scene-local time + the scene slot's `data-start`.

```html
<!-- index.html (host) -->
Expand Down Expand Up @@ -193,7 +193,7 @@ A sub-comp timeline **cannot** drive host elements (a global selector or `docume

Caveats:

- The host media must be a direct root child and exist in the DOM (static in `index.html`) — it always is.
- In this pattern the media is a host-root child, static in `index.html`, so the main timeline's selector resolves it. (Media nested in a sub-comp is also driven fine; it just can't be reached by the main timeline's selectors — drive it from the sub-comp's own timeline.)
- Clip lifecycle owns the media element's visibility across its `[data-start, data-start+data-duration]` window. The main-timeline opacity/scale tweens compose with it fine; for an opacity reveal/crossfade prefer a host **wrapper** so you are not fighting the lifecycle on the media element itself.
- Two media elements sharing the same `src` + `data-start` trigger `duplicate_media_discovery_risk` (benign — both still render).

Expand Down
2 changes: 1 addition & 1 deletion skills/hyperframes-core/references/data-attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ The root should be `position: relative`, have explicit pixel dimensions, and hid

Timed child elements are clips. **`class="clip"` is required on visible timed elements** (`<div>`, `<img>`, etc.) — without it the runtime keeps the element visible for the whole composition, ignoring `data-start` / `data-duration`. Omit on `<video>` (framework manages visibility directly) and `<audio>` (no visual).

**Clips must be DIRECT children of the composition root.** A clip nested inside a wrapper `<div>` is not registered — most visibly, a `<video>` in a wrapper is never seeked/decoded and renders black. To wrap/transform a clip, put the wrapper _inside_ the clip, or animate the clip element itself; do not wrap the clip. (`<video>`/`<audio>` additionally must be at the **host** root, never in a sub-comp `<template>` — see `variables-and-media.md`.)
**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`.)

| Attribute | Required | Meaning |
| ------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
Expand Down
Loading
Loading