From 97f888bebd8e1ba7347be227afb5f91607ab92ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 24 Apr 2026 15:37:15 -0400 Subject: [PATCH 1/2] fix: keep nested media paused on seek --- packages/core/src/runtime/init.test.ts | 46 ++++++++++++++++++++++++++ packages/core/src/runtime/init.ts | 39 +++++++++++++++++++++- packages/core/src/runtime/media.ts | 18 ++++++---- 3 files changed, 96 insertions(+), 7 deletions(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 8ce97e748..50aee027a 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -123,4 +123,50 @@ describe("initSandboxRuntimeModular", () => { expect(child.style.visibility).toBe("hidden"); }); + + it("pauses nested media that is outside the timed-media cache after a seek", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + const child = document.createElement("div"); + child.setAttribute("data-composition-id", "slide-translation"); + child.setAttribute("data-start", "20"); + child.setAttribute("data-duration", "16"); + root.appendChild(child); + + const video = document.createElement("video"); + child.appendChild(video); + Object.defineProperty(video, "duration", { value: 20, writable: true, configurable: true }); + Object.defineProperty(video, "paused", { value: false, writable: true, configurable: true }); + Object.defineProperty(video, "readyState", { value: 4, writable: true, configurable: true }); + Object.defineProperty(video, "currentTime", { value: 0, writable: true, configurable: true }); + const pause = () => { + Object.defineProperty(video, "paused", { value: true, writable: true, configurable: true }); + }; + video.load = () => {}; + video.pause = pause; + + (window as Window & { __timelines?: Record }).__timelines = { + main: createMockTimeline(40), + "slide-translation": createMockTimeline(16), + }; + + initSandboxRuntimeModular(); + + const player = ( + window as Window & { + __player?: { seek: (timeSeconds: number) => void }; + } + ).__player; + expect(player).toBeDefined(); + + player?.seek(29); + + expect(video.paused).toBe(true); + expect(video.currentTime).toBe(9); + }); }); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index e87292c87..702c50bd3 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1199,8 +1199,45 @@ export function initSandboxRuntimeModular(): void { }; const syncMediaForCurrentState = () => { + const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => { + const compositionRoot = element.closest("[data-composition-id]"); + const inheritedStart = compositionRoot ? resolveStartForElement(compositionRoot, 0) : null; + const inheritedDuration = compositionRoot + ? resolveDurationForElement(compositionRoot, { includeAuthoredTimingAttrs: true }) + : null; + return { compositionRoot, inheritedStart, inheritedDuration }; + }; const cache = refreshRuntimeMediaCache({ - resolveStartSeconds: (element) => resolveStartForElement(element, 0), + shouldIncludeElement: (element) => + element.hasAttribute("data-start") || + Boolean(resolveMediaCompositionContext(element).compositionRoot), + resolveStartSeconds: (element) => { + const context = resolveMediaCompositionContext( + element as HTMLVideoElement | HTMLAudioElement, + ); + return resolveStartForElement(element, context.inheritedStart ?? 0); + }, + resolveDurationSeconds: (element) => { + const context = resolveMediaCompositionContext(element); + const start = resolveStartForElement(element, context.inheritedStart ?? 0); + const mediaStart = + Number.parseFloat(element.dataset.playbackStart ?? element.dataset.mediaStart ?? "0") || + 0; + const hostRemaining = + context.inheritedStart != null && + context.inheritedDuration != null && + context.inheritedDuration > 0 + ? Math.max(0, context.inheritedStart + context.inheritedDuration - start) + : null; + const sourceDuration = + Number.isFinite(element.duration) && element.duration > mediaStart + ? Math.max(0, element.duration - mediaStart) + : null; + if (sourceDuration != null && hostRemaining != null) { + return Math.min(sourceDuration, hostRemaining); + } + return sourceDuration ?? hostRemaining; + }, }); syncRuntimeMedia({ clips: cache.mediaClips, diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index f0f65d8d7..d7544a5f2 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -13,19 +13,24 @@ export type RuntimeMediaClip = { export function refreshRuntimeMediaCache(params?: { resolveStartSeconds?: (element: Element) => number; + resolveDurationSeconds?: (element: HTMLVideoElement | HTMLAudioElement) => number | null; + shouldIncludeElement?: (element: HTMLVideoElement | HTMLAudioElement) => boolean; }): { timedMediaEls: Array; mediaClips: RuntimeMediaClip[]; videoClips: RuntimeMediaClip[]; maxMediaEnd: number; } { - const mediaEls = Array.from( - document.querySelectorAll("video[data-start], audio[data-start]"), - ) as Array; + const mediaEls = Array.from(document.querySelectorAll("video, audio")) as Array< + HTMLVideoElement | HTMLAudioElement + >; + const timedMediaEls = params?.shouldIncludeElement + ? mediaEls.filter((el) => params.shouldIncludeElement?.(el)) + : mediaEls.filter((el) => el.hasAttribute("data-start")); const mediaClips: RuntimeMediaClip[] = []; const videoClips: RuntimeMediaClip[] = []; let maxMediaEnd = 0; - for (const el of mediaEls) { + for (const el of timedMediaEls) { const start = params?.resolveStartSeconds ? params.resolveStartSeconds(el) : Number.parseFloat(el.dataset.start ?? "0"); @@ -39,7 +44,8 @@ export function refreshRuntimeMediaCache(params?: { Number.isFinite(rawRate) && rawRate > 0 ? Math.max(0.1, Math.min(5, rawRate)) : 1; const loop = el.loop; const sourceDuration = Number.isFinite(el.duration) && el.duration > 0 ? el.duration : null; - let duration = Number.parseFloat(el.dataset.duration ?? ""); + let duration = + params?.resolveDurationSeconds?.(el) ?? Number.parseFloat(el.dataset.duration ?? ""); if ((!Number.isFinite(duration) || duration <= 0) && sourceDuration != null) { // Effective duration accounts for playback rate: // at 0.5x, a 10s source plays for 20s on the timeline @@ -63,7 +69,7 @@ export function refreshRuntimeMediaCache(params?: { if (el.tagName === "VIDEO") videoClips.push(clip); if (Number.isFinite(end)) maxMediaEnd = Math.max(maxMediaEnd, end); } - return { timedMediaEls: mediaEls, mediaClips, videoClips, maxMediaEnd }; + return { timedMediaEls, mediaClips, videoClips, maxMediaEnd }; } // Per-element timeline→media offset from the previous tick. Used to tell a From 732df74bbf1b1121cb6957cc80f7be3eff79a407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 24 Apr 2026 15:50:22 -0400 Subject: [PATCH 2/2] test: extend nested media seek coverage --- packages/core/src/runtime/init.test.ts | 46 ++++++++++++++++++++++++++ packages/core/src/runtime/init.ts | 4 +++ 2 files changed, 50 insertions(+) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 50aee027a..dc5c82884 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -169,4 +169,50 @@ describe("initSandboxRuntimeModular", () => { expect(video.paused).toBe(true); expect(video.currentTime).toBe(9); }); + + it("clamps nested media to the authored host window on seek", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + const child = document.createElement("div"); + child.setAttribute("data-composition-id", "slide-translation"); + child.setAttribute("data-start", "20"); + child.setAttribute("data-duration", "16"); + root.appendChild(child); + + const video = document.createElement("video"); + child.appendChild(video); + Object.defineProperty(video, "duration", { value: 20, writable: true, configurable: true }); + Object.defineProperty(video, "paused", { value: false, writable: true, configurable: true }); + Object.defineProperty(video, "readyState", { value: 4, writable: true, configurable: true }); + Object.defineProperty(video, "currentTime", { value: 0, writable: true, configurable: true }); + const pause = () => { + Object.defineProperty(video, "paused", { value: true, writable: true, configurable: true }); + }; + video.load = () => {}; + video.pause = pause; + + (window as Window & { __timelines?: Record }).__timelines = { + main: createMockTimeline(40), + "slide-translation": createMockTimeline(16), + }; + + initSandboxRuntimeModular(); + + const player = ( + window as Window & { + __player?: { seek: (timeSeconds: number) => void }; + } + ).__player; + expect(player).toBeDefined(); + + player?.seek(37); + + expect(video.paused).toBe(true); + expect(video.currentTime).toBe(0); + }); }); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 702c50bd3..50be98dd7 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1202,6 +1202,10 @@ export function initSandboxRuntimeModular(): void { const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => { const compositionRoot = element.closest("[data-composition-id]"); const inheritedStart = compositionRoot ? resolveStartForElement(compositionRoot, 0) : null; + // Media sync intentionally uses the authored host window here instead of + // the live child timeline duration. Visibility prefers live truth so a + // shrinking child composition hides early, but nested media needs a + // stable authored window so seeks clamp against the host clip timing. const inheritedDuration = compositionRoot ? resolveDurationForElement(compositionRoot, { includeAuthoredTimingAttrs: true }) : null;