mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(media): unify nested start coordinate mapping (#3732)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user