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 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 forward by the time elapsed multiplied by `data-playback-rate`, so the
nested animation keeps going instead of jumping back to its beginning. 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>
<Tab title="Inline"> <Tab title="Inline">
Write the nested composition straight into the parent. Simpler for Write the nested composition straight into the parent. Simpler for
+6
View File
@@ -50,6 +50,12 @@
"types": "./dist/compositionContract.d.ts", "types": "./dist/compositionContract.d.ts",
"environments": ["browser", "bun", "node"] "environments": ["browser", "bun", "node"]
}, },
"./media-timing": {
"source": "./src/mediaTiming.ts",
"runtime": "./dist/mediaTiming.js",
"types": "./dist/mediaTiming.d.ts",
"environments": ["browser", "bun", "node"]
},
"./editing": { "./editing": {
"source": "./src/editing/affordances.ts", "source": "./src/editing/affordances.ts",
"runtime": "./dist/editing/affordances.js", "runtime": "./dist/editing/affordances.js",
+10
View File
@@ -64,6 +64,12 @@
"import": "./src/compositionContract.ts", "import": "./src/compositionContract.ts",
"types": "./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": { "./editing": {
"bun": "./src/editing/affordances.ts", "bun": "./src/editing/affordances.ts",
"node": "./dist/editing/affordances.js", "node": "./dist/editing/affordances.js",
@@ -424,6 +430,10 @@
"import": "./dist/compositionContract.js", "import": "./dist/compositionContract.js",
"types": "./dist/compositionContract.d.ts" "types": "./dist/compositionContract.d.ts"
}, },
"./media-timing": {
"import": "./dist/mediaTiming.js",
"types": "./dist/mediaTiming.d.ts"
},
"./editing": { "./editing": {
"import": "./dist/editing/affordances.js", "import": "./dist/editing/affordances.js",
"types": "./dist/editing/affordances.d.ts" "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"); 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", () => { it("keeps a long video starting at zero local to its delayed composition host", () => {
const root = document.createElement("div"); const root = document.createElement("div");
root.setAttribute("data-composition-id", "main"); 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) // pip-wired video: data-start is authored in global time (same value as host)
const pipVideo = document.createElement("video"); const pipVideo = document.createElement("video");
pipVideo.setAttribute("data-start", "45.40"); pipVideo.setAttribute("data-start", "45.40");
pipVideo.setAttribute("data-hf-media-start-basis", "global");
pipVideo.setAttribute("data-duration", "7.06"); pipVideo.setAttribute("data-duration", "7.06");
Object.defineProperty(pipVideo, "paused", { value: true, configurable: true }); Object.defineProperty(pipVideo, "paused", { value: true, configurable: true });
Object.defineProperty(pipVideo, "readyState", { value: 0, 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 { installStudioCustomEase } from "./customEase";
import { parseNumeric } from "./startExpression"; import { parseNumeric } from "./startExpression";
import { parseStrictFiniteTimingNumber } from "./playbackRate"; import { parseStrictFiniteTimingNumber } from "./playbackRate";
import {
MEDIA_START_BASIS_ATTR,
resolveAbsoluteMediaStartSeconds as resolveAuthoredMediaStartSeconds,
} from "../mediaTiming";
import { import {
clearRuntimeData, clearRuntimeData,
setRuntimeData, setRuntimeData,
@@ -691,18 +695,11 @@ export function initSandboxRuntimeModular(): void {
return resolveStartForElement(element, inheritedStart); return resolveStartForElement(element, inheritedStart);
} }
// Both timing conventions exist in shipped projects: return resolveAuthoredMediaStartSeconds({
// - composition-local media, e.g. host@20 + video@0 => root@20 authoredStart,
// - legacy root-global PIP media, e.g. host@45.4 + video@45.4 => root@45.4 hostStart: inheritedStart,
// Preserve the global value when its authored start already falls inside basis: element.getAttribute(MEDIA_START_BASIS_ATTR),
// 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;
}; };
window.__hfResolveMediaStartSeconds = resolveAbsoluteMediaStartSeconds; window.__hfResolveMediaStartSeconds = resolveAbsoluteMediaStartSeconds;
+33
View File
@@ -466,6 +466,39 @@ describe("media rules", () => {
expect(finding).toBeUndefined(); 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 () => { it("reports error for media with crossorigin (breaks preview when host omits CORS)", async () => {
const html = ` const html = `
<html><body> <html><body>
+24
View File
@@ -138,6 +138,29 @@ function findMediaSrcKindMismatchFindings(ctx: LintContext): HyperframeLintFindi
return findings; 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). */ /** Parent `src`, else a descendant `<source src>` (matches engine resolveMediaElementSrc). */
function mediaHasResolvableSrc(tag: OpenTag, tags: readonly OpenTag[]): boolean { function mediaHasResolvableSrc(tag: OpenTag, tags: readonly OpenTag[]): boolean {
if (readAttr(tag.raw, "src")) return true; if (readAttr(tag.raw, "src")) return true;
@@ -376,6 +399,7 @@ function findRuntimeMediaSrcMutationFindings(ctx: LintContext): HyperframeLintFi
} }
export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
findNestedMediaStartBasisFindings,
// duplicate_media_id + duplicate_media_discovery_risk // duplicate_media_id + duplicate_media_discovery_risk
({ tags }) => { ({ tags }) => {
const findings: HyperframeLintFinding[] = []; 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 === "red")).toMatchObject({ start: 0, end: 2 });
expect(videos.find((v) => v.id === "blue")).toMatchObject({ start: 2, end: 4 }); 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 { parseHTML } from "linkedom";
import { MEDIA_RENDER_ID_ATTR } from "@hyperframes/core"; import { MEDIA_RENDER_ID_ATTR } from "@hyperframes/core";
import {
MEDIA_START_BASIS_ATTR,
readMediaStartBasis,
resolveAbsoluteMediaStartSeconds,
type MediaStartBasis,
} from "@hyperframes/core/media-timing";
import { import {
parseVideoElements, parseVideoElements,
parseImageElements, parseImageElements,
@@ -40,9 +46,11 @@ interface HostWindow {
offset: number; offset: number;
/** Absolute time past which a descendant is outside its host, or Infinity. */ /** Absolute time past which a descendant is outside its host, or Infinity. */
limit: number; 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 { function parseNumeric(value: string | null): number | null {
if (value == null || value === "") return null; if (value == null || value === "") return null;
@@ -80,7 +88,16 @@ function resolveHostWindow(
if (hostEnd != null) limit = Math.min(limit, offset + hostEnd); if (hostEnd != null) limit = Math.min(limit, offset + hostEnd);
offset += hostStart; 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, end: number,
window: HostWindow, window: HostWindow,
): { start: number; end: number } | null { ): { 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; 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) }; return { start: absoluteStart, end: Math.min(absoluteEnd, window.limit) };
} }
@@ -62,7 +62,7 @@ document.getElementById("title").textContent = title;
## Media ## 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. 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.