fix(core): extend media start fix to all consumers, guard auto-start

Narrow the raw data-start read to media elements without
data-hf-auto-start (explicitly authored global coordinates). Elements
with auto-injected data-start="0" remain composition-local via the
resolver. Apply consistently across all three consumers:
- visibility loop (init.ts)
- refreshRuntimeMediaCache start/duration (init.ts)
- resolveMediaWindowEndSeconds (timeline.ts)

Add regression test for auto-injected data-start="0" inside a
late-starting host to prove it doesn't regress.
This commit is contained in:
Miguel Ángel
2026-05-26 14:25:39 -04:00
parent 1be2a584b4
commit 1d0b18587d
3 changed files with 75 additions and 9 deletions
+57
View File
@@ -539,6 +539,63 @@ describe("initSandboxRuntimeModular", () => {
expect(pipVideo.style.visibility).toBe("hidden");
});
it("shows auto-injected video at host time, not at t=0", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const host = document.createElement("div");
host.setAttribute("data-composition-id", "intro");
host.setAttribute("data-start", "10");
host.setAttribute("data-duration", "5");
root.appendChild(host);
const innerRoot = document.createElement("div");
innerRoot.setAttribute("data-composition-id", "intro");
host.appendChild(innerRoot);
const video = document.createElement("video");
video.setAttribute("data-start", "0");
video.setAttribute("data-hf-auto-start", "");
video.setAttribute("data-duration", "5");
Object.defineProperty(video, "paused", { value: true, configurable: true });
Object.defineProperty(video, "readyState", { value: 0, configurable: true });
Object.defineProperty(video, "currentTime", {
value: 0,
writable: true,
configurable: true,
});
video.load = () => {};
innerRoot.appendChild(video);
(window as Window & { __timelines?: Record<string, RuntimeTimelineLike> }).__timelines = {
main: createMockTimeline(30),
intro: createMockTimeline(5),
};
initSandboxRuntimeModular();
const player = (
window as Window & {
__player?: { seek: (timeSeconds: number) => void };
}
).__player;
expect(player).toBeDefined();
player?.seek(12);
expect(video.style.visibility).toBe("visible");
player?.seek(5);
expect(video.style.visibility).toBe("hidden");
player?.seek(16);
expect(video.style.visibility).toBe("hidden");
});
it("plays scheduled child timelines without a captured root timeline when audio has failed", () => {
const raf = createManualRaf();
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
+15 -8
View File
@@ -1278,6 +1278,9 @@ export function initSandboxRuntimeModular(): void {
element.hasAttribute("data-start") ||
Boolean(resolveMediaCompositionContext(element).compositionRoot),
resolveStartSeconds: (element) => {
if (!element.hasAttribute("data-hf-auto-start") && element.hasAttribute("data-start")) {
return Math.max(0, Number(element.getAttribute("data-start") ?? 0) || 0);
}
const context = resolveMediaCompositionContext(
element as HTMLVideoElement | HTMLAudioElement,
);
@@ -1285,7 +1288,10 @@ export function initSandboxRuntimeModular(): void {
},
resolveDurationSeconds: (element) => {
const context = resolveMediaCompositionContext(element);
const start = resolveStartForElement(element, context.inheritedStart ?? 0);
const start =
!element.hasAttribute("data-hf-auto-start") && element.hasAttribute("data-start")
? Math.max(0, Number(element.getAttribute("data-start") ?? 0) || 0)
: resolveStartForElement(element, context.inheritedStart ?? 0);
const mediaStart =
Number.parseFloat(element.dataset.playbackStart ?? element.dataset.mediaStart ?? "0") ||
0;
@@ -1329,13 +1335,14 @@ export function initSandboxRuntimeModular(): void {
const tag = rawNode.tagName.toLowerCase();
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") continue;
// For media elements (video/audio) data-start is authored in global (composition-root)
// time — the same contract used by the render pipeline's discoverMediaFromBrowser which
// reads the raw attribute directly. Calling resolveStartForElement would add the nearest
// ancestor composition's start a second time, creating a double-offset that keeps the
// element permanently hidden when its host composition does not start at t=0.
const isMediaElement = tag === "video" || tag === "audio";
const start = isMediaElement
// Media elements with explicitly authored data-start (no data-hf-auto-start
// marker) use global coordinates — matching the render pipeline's
// discoverMediaFromBrowser. resolveStartForElement would add the host
// composition's offset a second time. Auto-injected data-start="0"
// (data-hf-auto-start present) is composition-local and needs the resolver.
const isGlobalMediaStart =
(tag === "video" || tag === "audio") && !rawNode.hasAttribute("data-hf-auto-start");
const start = isGlobalMediaStart
? Math.max(0, Number(rawNode.getAttribute("data-start") ?? 0) || 0)
: resolveStartForElement(rawNode, 0);
let duration = resolveDurationForElement(rawNode);
+3 -1
View File
@@ -220,7 +220,9 @@ export function collectRuntimeTimelinePayload(params: {
if (mediaNodes.length === 0) return null;
let maxWindowEndSeconds = 0;
for (const mediaNode of mediaNodes) {
const start = startResolver.resolveStartForElement(mediaNode, 0);
const start = !mediaNode.hasAttribute("data-hf-auto-start")
? Math.max(0, Number(mediaNode.getAttribute("data-start") ?? 0) || 0)
: startResolver.resolveStartForElement(mediaNode, 0);
if (!Number.isFinite(start)) continue;
const duration = resolveMediaElementDurationSeconds(mediaNode);
if (duration == null || duration <= 0) continue;