From 1110b0bf489f1566424a59bba8e8ebb2e3e5ac94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 26 May 2026 16:49:05 +0000 Subject: [PATCH] fix(core): prevent double-offset on pip video visibility and media sync When a pip-wired media element has data-start authored in global time inside a sub-composition host, resolveStartForElement adds the host offset a second time. Use the raw data-start for media elements without data-hf-auto-start (explicitly authored, global coordinates) across all three consumers: visibility loop, refreshRuntimeMediaCache, and resolveMediaWindowEndSeconds. Auto-injected timing (data-hf-auto-start) remains composition-local via the resolver. Based on JamesXiaoFF's analysis in #1078. --- packages/core/src/runtime/init.test.ts | 113 +++++++++++++++++++++++++ packages/core/src/runtime/init.ts | 21 ++++- packages/core/src/runtime/timeline.ts | 4 +- 3 files changed, 135 insertions(+), 3 deletions(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 3a3ffa7806..bd5c25a28e 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -477,6 +477,119 @@ describe("initSandboxRuntimeModular", () => { expect(hookHost.style.visibility).toBe("visible"); }); + it("shows pip video at correct time when host composition starts late (no double-offset)", () => { + 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", "scene-pip"); + host.setAttribute("data-start", "45.40"); + host.setAttribute("data-duration", "7.06"); + root.appendChild(host); + + const innerRoot = document.createElement("div"); + innerRoot.setAttribute("data-composition-id", "scene-pip"); + host.appendChild(innerRoot); + + const pipVideo = document.createElement("video"); + pipVideo.setAttribute("data-start", "45.40"); + pipVideo.setAttribute("data-duration", "7.06"); + Object.defineProperty(pipVideo, "paused", { value: true, configurable: true }); + Object.defineProperty(pipVideo, "readyState", { value: 0, configurable: true }); + Object.defineProperty(pipVideo, "currentTime", { + value: 0, + writable: true, + configurable: true, + }); + pipVideo.load = () => {}; + innerRoot.appendChild(pipVideo); + + (window as Window & { __timelines?: Record }).__timelines = { + main: createMockTimeline(60), + "scene-pip": createMockTimeline(7.06), + }; + + initSandboxRuntimeModular(); + + const player = ( + window as Window & { + __player?: { seek: (timeSeconds: number) => void }; + } + ).__player; + expect(player).toBeDefined(); + + player?.seek(46); + expect(pipVideo.style.visibility).toBe("visible"); + + player?.seek(53); + expect(pipVideo.style.visibility).toBe("hidden"); + + player?.seek(44); + expect(pipVideo.style.visibility).toBe("hidden"); + }); + + it("shows auto-start 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 }).__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()); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 2357cb24f1..36d89b5de5 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -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,7 +1335,18 @@ export function initSandboxRuntimeModular(): void { const tag = rawNode.tagName.toLowerCase(); if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") continue; - const start = resolveStartForElement(rawNode, 0); + // Media elements (video/audio) without data-hf-auto-start have their + // data-start authored in global time — the same contract used by the + // render pipeline's discoverMediaFromBrowser. Using resolveStartForElement + // would add the host composition's offset a second time, creating a + // double-offset that keeps the element permanently hidden when its host + // doesn't start at t=0. Auto-injected timing (data-hf-auto-start) is + // composition-local and still 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); const compId = rawNode.getAttribute("data-composition-id"); if (compId) { diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index 4d598fb04a..1aa234ee3e 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -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;