From 6461ad20d244393767b08b7f054694682c524ae0 Mon Sep 17 00:00:00 2001 From: Felipe Caldas Date: Tue, 18 Aug 2026 23:08:21 +1000 Subject: [PATCH] fix(runtime): decide a nested clip's timing convention by its start, not its end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveAbsoluteMediaStartSeconds` disambiguates the two conventions #2859 identified: composition-local host@20 + video@0 => root@20 legacy root-global host@45.4 + video@45.4 => root@45.4 It decides by asking whether the clip's authored *end* lands inside the host window. What distinguishes the two is where the clip *starts*: a composition-local clip is authored from its host's zero, so its start sits below the host's absolute start. Its duration says nothing about which convention it uses. So a composition-local clip is misread as root-global whenever its duration merely exceeds the mount offset: data-start="0", data-duration="4.375", host mounted at 2.96 authoredEnd = 0 + 4.375 = 4.375 > 2.96 -> treated as root-global -> scheduled 0..4.375; the ancestor visibility gate clips the front -> visible 2.96..4.375, blank for the remaining ~3s of its own slot The two branches of that test already disagreed — the no-host-duration branch tested `authoredStart >= inheritedStart`, the other tested the end. This makes both use the start. This is the same failure #2859 fixed, one case further along: that PR handled the clip whose end falls *before* the mount offset (which falls through to local). A clip whose end falls *after* it flips back to global instead. The condition only produces a visible hole when 0 < mountOffset < duration, so a project can look entirely healthy while carrying it — every other scene in the project where we hit this mounts later than its own duration and resolved correctly by luck. Behaviour changes only for clips with 0 <= authoredStart < hostStart. The existing pip-video-late-host fixture (data-start 3.0 inside a host at 3.0) is unaffected by the `>=`, and its golden render still passes, as does nested-sequential-video-local-start. --- packages/core/src/runtime/init.test.ts | 72 ++++++++++++++++++++++++++ packages/core/src/runtime/init.ts | 28 +++++----- 2 files changed, 88 insertions(+), 12 deletions(-) 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;