From 1be2a584b46b5d81a967cda2599a7aa15f6c71f8 Mon Sep 17 00:00:00 2001 From: James Xiao Date: Tue, 26 May 2026 15:52:21 +0800 Subject: [PATCH] fix(core): use raw data-start for media elements in preview visibility loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For video and audio elements, 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. Previously, the visibility loop called resolveStartForElement which adds the nearest ancestor composition's global start on top, causing a double-offset that kept pip-wired media permanently hidden when the host composition did not start at t=0. Example: a pip video with data-start="45.40" inside a host composition that also starts at data-start="45.40" resolved to 90.80, so the video was always hidden during its actual [45.40, 52.46] window. Non-media elements (divs, sections, etc.) continue to use the accumulating resolver because their data-start values are local to their composition. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/runtime/init.test.ts | 62 ++++++++++++++++++++++++++ packages/core/src/runtime/init.ts | 10 ++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 3a3ffa780..4122095a9 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -477,6 +477,68 @@ 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("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 2357cb24f..ed7e65570 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1329,7 +1329,15 @@ export function initSandboxRuntimeModular(): void { const tag = rawNode.tagName.toLowerCase(); if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") continue; - const start = resolveStartForElement(rawNode, 0); + // 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 + ? 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) {