diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index cc59b76045..d91f785b1f 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -1633,6 +1633,78 @@ describe("initSandboxRuntimeModular", () => { expect(pipVideo.style.visibility).toBe("hidden"); }); + it("keeps a composition-local clip visible for its whole slot when its duration exceeds the mount offset", () => { + // TAB-792, from a user report: a scene went black for the last 3s of its + // own 4.375s slot. The clip is composition-local (`data-start="0"`) inside a + // host mounted at 2.96s, and the convention test used to look at the + // authored END: 0 + 4.375 = 4.375 > 2.96, so it read as root-global and was + // scheduled 0..4.375. The ancestor gate clipped the front, leaving + // 2.96..4.375 visible and 4.375..7.335 black. + // + // The giveaway was that making the clip LONGER made the hole BIGGER, which + // is why this asserts a late instant inside the slot rather than just the + // resolved start. + 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", "720"); + root.setAttribute("data-height", "720"); + document.body.appendChild(root); + + const host = document.createElement("div"); + host.setAttribute("data-composition-id", "scene-1"); + host.setAttribute("data-composition-file", "compositions/scene-1.html"); + host.setAttribute("data-start", "2.96"); + host.setAttribute("data-duration", "4.375"); + root.appendChild(host); + + const innerRoot = document.createElement("div"); + innerRoot.setAttribute("data-composition-id", "scene-1"); + host.appendChild(innerRoot); + + const sceneVideo = document.createElement("video"); + sceneVideo.setAttribute("data-start", "0.000"); + sceneVideo.setAttribute("data-duration", "4.375"); + sceneVideo.setAttribute("data-media-start", "0.000"); + Object.defineProperty(sceneVideo, "paused", { value: true, configurable: true }); + Object.defineProperty(sceneVideo, "readyState", { value: 0, configurable: true }); + Object.defineProperty(sceneVideo, "currentTime", { + value: 0, + writable: true, + configurable: true, + }); + sceneVideo.load = () => {}; + innerRoot.appendChild(sceneVideo); + + (window as Window & { __timelines?: Record }).__timelines = { + main: createMockTimeline(18.88), + "scene-1": createMockTimeline(4.375), + }; + + initSandboxRuntimeModular(); + + // Composition-local: the host offset applies. + expect(window.__hfResolveMediaStartSeconds?.(sceneVideo)).toBeCloseTo(2.96); + + const player = (window as Window & { __player?: { seek: (timeSeconds: number) => void } }) + .__player; + expect(player).toBeDefined(); + + player?.seek(3.5); + expect(sceneVideo.style.visibility).toBe("visible"); + // The instants that were black before the fix. + player?.seek(5.5); + expect(sceneVideo.style.visibility).toBe("visible"); + player?.seek(7.2); + expect(sceneVideo.style.visibility).toBe("visible"); + // Still bounded by its own slot. + player?.seek(7.4); + expect(sceneVideo.style.visibility).toBe("hidden"); + player?.seek(2.5); + expect(sceneVideo.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"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index cd8bd8b2ea..9fd933648e 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -631,22 +631,26 @@ export function initSandboxRuntimeModular(): void { // 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 window already intersects - // the host's absolute window. Otherwise it is unambiguously local and - // must inherit the recursively-resolved host start. - const authoredDuration = parseNumeric(element.getAttribute("data-duration")); + // Which one a clip uses is decided by where it *starts*, never by where it + // ends. A composition-local clip is authored from its host's zero, so its + // start sits below the host's absolute start; a root-global clip is already + // in root time and starts at or after its host. + // + // TAB-792: this used to test the authored *end* instead, so any + // composition-local clip whose duration merely exceeded the mount offset + // was misread as root-global — `data-start="0"` with a 4.375s duration in a + // host mounted at 2.96s scheduled itself 0..4.375, the ancestor gate + // clipped the front, and the scene went black for the remaining 3s of its + // own slot. The tell was that making the clip *longer* made the hole + // bigger. The two branches below disagreed, and the start-based one was + // the correct half. const hostDuration = context.inheritedDuration; const hostEnd = hostDuration != null && hostDuration > 0 ? inheritedStart + hostDuration : null; - const authoredEnd = - authoredDuration != null && authoredDuration > 0 - ? authoredStart + authoredDuration - : authoredStart; - const overlapsHostWindow = + const authoredStartIsRootGlobal = hostEnd == null ? authoredStart >= inheritedStart - : authoredStart < hostEnd && - (authoredEnd > inheritedStart || authoredStart === inheritedStart); - return overlapsHostWindow ? authoredStart : inheritedStart + authoredStart; + : authoredStart >= inheritedStart && authoredStart < hostEnd; + return authoredStartIsRootGlobal ? authoredStart : inheritedStart + authoredStart; }; window.__hfResolveMediaStartSeconds = resolveAbsoluteMediaStartSeconds;