diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 3a3ffa7806..12d712a19b 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -477,6 +477,125 @@ describe("initSandboxRuntimeModular", () => { expect(hookHost.style.visibility).toBe("visible"); }); + it("shows pip video at global start time even when host composition starts late", () => { + // Regression: resolveStartForElement used to add the host composition's start on top of + // the video's own data-start, causing double-offset. A pip video with data-start="45.40" + // inside a host at data-start="45.40" would resolve to 90.80 and stay permanently hidden. + 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); + + // 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-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(); + + // Before the fix: resolveStartForElement(pipVideo) = 45.40 + 45.40 = 90.80, so the + // video would be hidden at t=46 (90.80 > 46). After the fix: start = 45.40, visible. + 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-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 }).__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..1e32a71e9d 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,16 @@ 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 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); 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;