From 5def121371790b054c2d100c6794e383b881e015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 02:17:01 +0000 Subject: [PATCH] fix(runtime): apply parent composition offset to WebAudio scheduling for sub-comp audio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audio elements inside sub-compositions on the root timeline were ignoring their host composition's data-start placement offset in the WebAudio scheduling path (introduced in #671 / v0.5.4). All sub-comp audio was scheduled with compositionStart equal to its local data-start (typically 0), causing every slide's audio to fire simultaneously at global t=0 instead of at each slide's placement time. Root cause: two sites in the WebAudio path read rawEl.dataset.start directly instead of accounting for the [data-composition-id] ancestor's data-start: 1. player.play() — WebAudioTransport.schedulePlayback() compositionStart arg 2. transportTick — TransportClock.attachAudioSource() compositionStart arg The syncRuntimeMedia path (HTMLMediaElement fallback) was already correct because syncMediaForCurrentState uses resolveMediaCompositionContext which sums the host offset into the clip's start time. Fix: add resolveGlobalAudioStart() that walks up [data-composition-id] ancestors and sums their resolveStartForElement() offsets. Handles nested sub-compositions. Apply it at both broken call sites. Fixes #1174. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/runtime/init.test.ts | 80 ++++++++++++++++++++++++++ packages/core/src/runtime/init.ts | 18 ++++-- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index c1fa09bad..38a0a901d 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -711,4 +711,84 @@ describe("initSandboxRuntimeModular", () => { expect(seekTimes.length).toBeGreaterThan(0); expect(seekTimes[0]).toBe(0); }); + + describe("sub-composition audio global start offset (regression #1174)", () => { + // Audio inside a sub-composition must account for the host's data-start + // on the root timeline. Before the fix, resolveGlobalAudioStart was not + // called and the local data-start (typically 0) was used instead. + + it("does not seek sub-comp audio before its host composition starts", () => { + // slide-2 host: data-start="10", audio inside: data-start="0" + document.body.innerHTML = ` +
+
+ +
+
+ `; + window.__timelines = { root: createMockTimeline(20) }; + initSandboxRuntimeModular(); + + const audio = document.querySelector("audio") as HTMLAudioElement; + const seeksSeen: number[] = []; + Object.defineProperty(audio, "currentTime", { + get: () => 0, + set: (v: number) => seeksSeen.push(v), + configurable: true, + }); + + // Seek to t=5 — before slide-2 starts (global 10). Audio must not be touched. + window.__player?.renderSeek(5); + expect(seeksSeen).toHaveLength(0); + }); + + it("seeks sub-comp audio to the correct relative position when the host is active", () => { + document.body.innerHTML = ` +
+
+ +
+
+ `; + window.__timelines = { root: createMockTimeline(20) }; + initSandboxRuntimeModular(); + + const audio = document.querySelector("audio") as HTMLAudioElement; + const seeksSeen: number[] = []; + Object.defineProperty(audio, "currentTime", { + get: () => 0, + set: (v: number) => seeksSeen.push(v), + configurable: true, + }); + + // Seek to t=12 — 2s into slide-2. Audio should be at relTime = 12 - 10 = 2. + window.__player?.renderSeek(12); + expect(seeksSeen).toContain(2); + }); + + it("handles audio in root (no composition host) without offset", () => { + document.body.innerHTML = ` +
+ +
+ `; + window.__timelines = { root: createMockTimeline(20) }; + initSandboxRuntimeModular(); + + const audio = document.querySelector("audio") as HTMLAudioElement; + const seeksSeen: number[] = []; + Object.defineProperty(audio, "currentTime", { + get: () => 0, + set: (v: number) => seeksSeen.push(v), + configurable: true, + }); + + // Seek to t=5 — audio at root level, offset = 0, relTime = 5 - 0 = 5. + window.__player?.renderSeek(5); + expect(seeksSeen).toContain(5); + }); + }); }); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 724d3d26b..15cfc57ea 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1319,11 +1319,19 @@ export function initSandboxRuntimeModular(): void { const context = resolveMediaCompositionContext( element as HTMLVideoElement | HTMLAudioElement, ); - return resolveMediaStartSeconds(element, context.inheritedStart ?? 0); + // resolveStartForElement resolves the element's position on the ROOT + // timeline, correctly summing ancestor composition-host offsets via + // resolveHostOffsetForElement. For elements WITH explicit data-start, + // the fallback is ignored and the host offset is always applied — this + // fixes the bug where data-start="0" audio inside a sub-composition at + // a non-zero host start was scheduled at global 0. + // For elements WITHOUT data-start (inherited timing), the fallback is + // set to inheritedStart to preserve the "fill the host window" behavior. + return resolveStartForElement(element, context.inheritedStart ?? 0); }, resolveDurationSeconds: (element) => { const context = resolveMediaCompositionContext(element); - const start = resolveMediaStartSeconds(element, context.inheritedStart ?? 0); + const start = resolveStartForElement(element, context.inheritedStart ?? 0); const mediaStart = Number.parseFloat(element.dataset.playbackStart ?? element.dataset.mediaStart ?? "0") || 0; @@ -1899,7 +1907,7 @@ export function initSandboxRuntimeModular(): void { let foundActive = false; for (const rawEl of audioEls) { if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue; - const start = Number.parseFloat(rawEl.dataset.start ?? ""); + const start = resolveStartForElement(rawEl, 0); const durAttr = Number.parseFloat(rawEl.dataset.duration ?? ""); const end = Number.isFinite(durAttr) && durAttr > 0 ? start + durAttr : Infinity; const mediaStart = @@ -1966,7 +1974,7 @@ export function initSandboxRuntimeModular(): void { for (const el of mediaEls) { if (!(el instanceof HTMLMediaElement)) continue; if (!el.isConnected) continue; - const start = Number.parseFloat(el.dataset.start ?? ""); + const start = resolveStartForElement(el, 0); if (!Number.isFinite(start)) continue; const durAttr = Number.parseFloat(el.dataset.duration ?? ""); const end = Number.isFinite(durAttr) && durAttr > 0 ? start + durAttr : Infinity; @@ -2014,7 +2022,7 @@ export function initSandboxRuntimeModular(): void { const audioEls = document.querySelectorAll("audio[data-start]"); for (const rawEl of audioEls) { if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue; - const compStart = Number.parseFloat(rawEl.dataset.start ?? ""); + const compStart = resolveStartForElement(rawEl, 0); if (!Number.isFinite(compStart)) continue; const mediaStart = Number.parseFloat(rawEl.dataset.playbackStart ?? rawEl.dataset.mediaStart ?? "0") || 0;