fix(media): unify nested start coordinate mapping (#3732)

This commit is contained in:
Miguel Ángel
2026-09-08 18:10:51 +00:00
committed by GitHub
parent e5a6023228
commit 01744f2b0c
11 changed files with 187 additions and 17 deletions
+6
View File
@@ -108,6 +108,12 @@ Use a separate file when you want to reuse it.
It defaults to `0`. Trimming or splitting from the left pushes this offset
forward by the time elapsed multiplied by `data-playback-rate`, so the
nested animation keeps going instead of jumping back to its beginning.
Timing inside the child file is local to that composition. If this host
starts at `10` and a child video starts at `2`, the video starts at `12` on
the root timeline. Legacy projects that authored a media start in root time
can preserve it explicitly with `data-hf-media-start-basis="global"`; new
compositions should use local timing.
</Tab>
<Tab title="Inline">
Write the nested composition straight into the parent. Simpler for
+6
View File
@@ -50,6 +50,12 @@
"types": "./dist/compositionContract.d.ts",
"environments": ["browser", "bun", "node"]
},
"./media-timing": {
"source": "./src/mediaTiming.ts",
"runtime": "./dist/mediaTiming.js",
"types": "./dist/mediaTiming.d.ts",
"environments": ["browser", "bun", "node"]
},
"./editing": {
"source": "./src/editing/affordances.ts",
"runtime": "./dist/editing/affordances.js",
+10
View File
@@ -64,6 +64,12 @@
"import": "./src/compositionContract.ts",
"types": "./src/compositionContract.ts"
},
"./media-timing": {
"bun": "./src/mediaTiming.ts",
"node": "./dist/mediaTiming.js",
"import": "./src/mediaTiming.ts",
"types": "./src/mediaTiming.ts"
},
"./editing": {
"bun": "./src/editing/affordances.ts",
"node": "./dist/editing/affordances.js",
@@ -424,6 +430,10 @@
"import": "./dist/compositionContract.js",
"types": "./dist/compositionContract.d.ts"
},
"./media-timing": {
"import": "./dist/mediaTiming.js",
"types": "./dist/mediaTiming.d.ts"
},
"./editing": {
"import": "./dist/editing/affordances.js",
"types": "./dist/editing/affordances.d.ts"
+20
View File
@@ -0,0 +1,20 @@
export const MEDIA_START_BASIS_ATTR = "data-hf-media-start-basis";
export type MediaStartBasis = "local" | "global";
export function readMediaStartBasis(value: string | null | undefined): MediaStartBasis {
return value?.trim().toLowerCase() === "global" ? "global" : "local";
}
/** Resolve authored media time onto the root timeline. Nested media is local
* by default; only an explicit compatibility marker preserves a legacy
* root-global value. */
export function resolveAbsoluteMediaStartSeconds(input: {
authoredStart: number;
hostStart: number;
basis?: string | null;
}): number {
return readMediaStartBasis(input.basis) === "global"
? input.authoredStart
: input.hostStart + input.authoredStart;
}
+35
View File
@@ -1228,6 +1228,40 @@ describe("initSandboxRuntimeModular", () => {
expect(video.style.visibility).toBe("visible");
});
it("keeps an overlapping numeric media start local to its delayed host", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-duration", "10");
document.body.appendChild(root);
const host = document.createElement("div");
host.setAttribute("data-composition-id", "nested");
host.setAttribute("data-composition-file", "nested.html");
host.setAttribute("data-start", "2");
host.setAttribute("data-duration", "6");
root.appendChild(host);
const video = document.createElement("video");
video.setAttribute("data-start", "2");
video.setAttribute("data-duration", "2");
video.load = () => {};
host.appendChild(video);
window.__timelines = {
main: createMockTimeline(10),
nested: createMockTimeline(6),
};
initSandboxRuntimeModular();
expect(window.__hfResolveMediaStartSeconds?.(video)).toBe(4);
window.__player?.renderSeek(2.5);
expect(video.style.visibility).toBe("hidden");
window.__player?.renderSeek(4.5);
expect(video.style.visibility).toBe("visible");
});
it("keeps a long video starting at zero local to its delayed composition host", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
@@ -2015,6 +2049,7 @@ describe("initSandboxRuntimeModular", () => {
// pip-wired video: data-start is authored in global time (same value as host)
const pipVideo = document.createElement("video");
pipVideo.setAttribute("data-start", "45.40");
pipVideo.setAttribute("data-hf-media-start-basis", "global");
pipVideo.setAttribute("data-duration", "7.06");
Object.defineProperty(pipVideo, "paused", { value: true, configurable: true });
Object.defineProperty(pipVideo, "readyState", { value: 0, configurable: true });
+9 -12
View File
@@ -67,6 +67,10 @@ import { shouldAttemptPeriodicTimelineBind } from "./timelineRebindPolicy";
import { installStudioCustomEase } from "./customEase";
import { parseNumeric } from "./startExpression";
import { parseStrictFiniteTimingNumber } from "./playbackRate";
import {
MEDIA_START_BASIS_ATTR,
resolveAbsoluteMediaStartSeconds as resolveAuthoredMediaStartSeconds,
} from "../mediaTiming";
import {
clearRuntimeData,
setRuntimeData,
@@ -691,18 +695,11 @@ export function initSandboxRuntimeModular(): void {
return resolveStartForElement(element, inheritedStart);
}
// Both timing conventions exist in shipped projects:
// - composition-local media, e.g. host@20 + video@0 => root@20
// - legacy root-global PIP media, e.g. host@45.4 + video@45.4 => root@45.4
// Preserve the global value when its authored start already falls inside
// the host's absolute window. A long local clip can overlap that window
// even when its start is local (host@39.233 + video@0/duration=80), so the
// duration cannot disambiguate the timing convention.
const hostDuration = context.inheritedDuration;
const hostEnd = hostDuration != null && hostDuration > 0 ? inheritedStart + hostDuration : null;
const startsInsideHostWindow =
authoredStart >= inheritedStart && (hostEnd == null || authoredStart < hostEnd);
return startsInsideHostWindow ? authoredStart : inheritedStart + authoredStart;
return resolveAuthoredMediaStartSeconds({
authoredStart,
hostStart: inheritedStart,
basis: element.getAttribute(MEDIA_START_BASIS_ATTR),
});
};
window.__hfResolveMediaStartSeconds = resolveAbsoluteMediaStartSeconds;
+33
View File
@@ -466,6 +466,39 @@ describe("media rules", () => {
expect(finding).toBeUndefined();
});
it("warns that an unmarked nonzero nested media start is local", async () => {
const html = `<template>
<div data-composition-id="scene" data-width="1920" data-height="1080">
<video id="clip" src="clip.mp4" data-start="2" data-duration="2" muted></video>
</div>
</template>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find(
(item) => item.code === "nested_media_start_basis_ambiguous",
);
expect(finding?.severity).toBe("warning");
expect(finding?.message).toContain("local to its composition");
expect(finding?.fixHint).toContain('data-hf-media-start-basis="global"');
});
it.each([
['data-start="0"', "zero local start"],
['data-start="2" data-hf-media-start-basis="local"', "explicit local start"],
['data-start="2" data-hf-media-start-basis="global"', "explicit legacy-global start"],
])("does not warn for %s (%s)", async (attrs) => {
const html = `<template>
<div data-composition-id="scene" data-width="1920" data-height="1080">
<audio id="clip" src="clip.wav" ${attrs} data-duration="2"></audio>
</div>
</template>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
expect(result.findings.some((item) => item.code === "nested_media_start_basis_ambiguous")).toBe(
false,
);
});
it("reports error for media with crossorigin (breaks preview when host omits CORS)", async () => {
const html = `
<html><body>
+24
View File
@@ -138,6 +138,29 @@ function findMediaSrcKindMismatchFindings(ctx: LintContext): HyperframeLintFindi
return findings;
}
function findNestedMediaStartBasisFindings(ctx: LintContext): HyperframeLintFinding[] {
if (!ctx.options.isSubComposition) return [];
const findings: HyperframeLintFinding[] = [];
for (const tag of ctx.tags) {
if (tag.name !== "video" && tag.name !== "audio") continue;
const rawStart = readAttr(tag.raw, "data-start");
const start = rawStart == null || rawStart.trim() === "" ? NaN : Number(rawStart);
if (!Number.isFinite(start) || start <= 0) continue;
const basis = readAttr(tag.raw, "data-hf-media-start-basis");
if (basis === "local" || basis === "global") continue;
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "nested_media_start_basis_ambiguous",
severity: "warning",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has data-start="${rawStart}" inside a sub-composition. Nested media timing is local to its composition by default; a nonzero value can be confused with a legacy root-global timestamp.`,
elementId,
fixHint: `Keep data-start="${rawStart}" if it is composition-local. If this is a legacy root-global timestamp, add data-hf-media-start-basis="global"; otherwise convert it to local time by subtracting the host start.`,
snippet: truncateSnippet(tag.raw),
});
}
return findings;
}
/** Parent `src`, else a descendant `<source src>` (matches engine resolveMediaElementSrc). */
function mediaHasResolvableSrc(tag: OpenTag, tags: readonly OpenTag[]): boolean {
if (readAttr(tag.raw, "src")) return true;
@@ -376,6 +399,7 @@ function findRuntimeMediaSrcMutationFindings(ctx: LintContext): HyperframeLintFi
}
export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
findNestedMediaStartBasisFindings,
// duplicate_media_id + duplicate_media_discovery_risk
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
@@ -16,4 +16,18 @@ describe("collectRenderMedia host windows", () => {
expect(videos.find((v) => v.id === "red")).toMatchObject({ start: 0, end: 2 });
expect(videos.find((v) => v.id === "blue")).toMatchObject({ start: 2, end: 4 });
});
it("preserves an explicitly marked legacy-global media window", () => {
const html =
`<div data-composition-file="scene.html" data-composition-id="scene" data-start="2" data-duration="6">` +
`<video ${MEDIA_RENDER_ID_ATTR}="local" id="local" src="local.mp4" data-start="2" data-duration="2" data-has-audio="true"></video>` +
`<video ${MEDIA_RENDER_ID_ATTR}="global" id="global" src="global.mp4" data-start="2" data-duration="2" data-hf-media-start-basis="global" data-has-audio="true"></video>` +
`</div>`;
const { videos, audios } = collectRenderMedia(html);
expect(videos.find((video) => video.id === "local")).toMatchObject({ start: 4, end: 6 });
expect(videos.find((video) => video.id === "global")).toMatchObject({ start: 2, end: 4 });
expect(audios.find((audio) => audio.id === "local-audio")).toMatchObject({ start: 4, end: 0 });
expect(audios.find((audio) => audio.id === "global-audio")).toMatchObject({ start: 2, end: 0 });
});
});
@@ -16,6 +16,12 @@
import { parseHTML } from "linkedom";
import { MEDIA_RENDER_ID_ATTR } from "@hyperframes/core";
import {
MEDIA_START_BASIS_ATTR,
readMediaStartBasis,
resolveAbsoluteMediaStartSeconds,
type MediaStartBasis,
} from "@hyperframes/core/media-timing";
import {
parseVideoElements,
parseImageElements,
@@ -40,9 +46,11 @@ interface HostWindow {
offset: number;
/** Absolute time past which a descendant is outside its host, or Infinity. */
limit: number;
/** Whether authored media time is composition-local or legacy root-global. */
basis: MediaStartBasis;
}
const ROOT_WINDOW: HostWindow = { offset: 0, limit: Infinity };
const ROOT_WINDOW: HostWindow = { offset: 0, limit: Infinity, basis: "local" };
function parseNumeric(value: string | null): number | null {
if (value == null || value === "") return null;
@@ -80,7 +88,16 @@ function resolveHostWindow(
if (hostEnd != null) limit = Math.min(limit, offset + hostEnd);
offset += hostStart;
}
return { offset, limit };
const tag = element.tagName.toLowerCase();
const basis =
tag === "video" || tag === "audio"
? readMediaStartBasis(element.getAttribute(MEDIA_START_BASIS_ATTR))
: "local";
return {
offset,
limit,
basis,
};
}
/**
@@ -114,9 +131,17 @@ function toAbsoluteWindow(
end: number,
window: HostWindow,
): { start: number; end: number } | null {
const absoluteStart = start + window.offset;
const absoluteStart = resolveAbsoluteMediaStartSeconds({
authoredStart: start,
hostStart: window.offset,
basis: window.basis,
});
if (absoluteStart >= window.limit) return null;
const absoluteEnd = end + window.offset;
const absoluteEnd = resolveAbsoluteMediaStartSeconds({
authoredStart: end,
hostStart: window.offset,
basis: window.basis,
});
return { start: absoluteStart, end: Math.min(absoluteEnd, window.limit) };
}
@@ -62,7 +62,7 @@ document.getElementById("title").textContent = title;
## Media
**`<video>`/`<audio>` work at any nesting depth, including inside a sub-composition `<template>` or a wrapper `<div>`.** The runtime discovers media with a flat `document.querySelectorAll("video, audio")`, resolves each element's host composition via `element.closest("[data-composition-id]")`, and rebases its local `data-start` by the accumulated absolute start of every ancestor composition (`packages/core/src/runtime/{media,startResolver}.ts`). So a scene-specific clip can live in its scene's sub-comp with scene-local `data-start`, and it seeks/decodes correctly. If a panel renders blank after a render, that is a real bug: capture a per-frame `snapshot` and treat it as render-blocking.
**`<video>`/`<audio>` work at any nesting depth, including inside a sub-composition `<template>` or a wrapper `<div>`.** The runtime discovers media with a flat `document.querySelectorAll("video, audio")`, resolves each element's host composition via `element.closest("[data-composition-id]")`, and rebases its local `data-start` by the accumulated absolute start of every ancestor composition. A host at `2` with child media at `2` therefore starts that media at root time `4`, consistently in preview, snapshot, extraction, and render. Legacy projects that deliberately authored a media start in root time must mark that element with `data-hf-media-start-basis="global"`; never infer the basis from overlapping numbers. New compositions should always use scene-local `data-start`. If a panel renders blank after a render, capture a per-frame `snapshot` and treat it as render-blocking.
The one real constraint is about **timelines, not media placement**: a sub-composition timeline **cannot reach or animate host elements** — neither `document.querySelector("#host-id")` nor a gsap selector string (`tl.to("#host-id", …)`) resolves across the boundary; a sub-comp timeline only drives its own subtree. So if a media element lives at the host root, **its per-scene motion (scale/opacity/morph/tilt/breathing) must be authored on the MAIN timeline in `index.html`, at GLOBAL time** (scene-local time + the scene slot's `data-start`). Keeping the media inside the scene sub-comp instead lets that sub-comp's own timeline animate it with scene-local time. For 3D tilt without a perspective parent, use gsap `transformPerspective` on the element. See `composition-patterns.md` archetype B.