fix(runtime): apply parent composition offset to WebAudio scheduling for sub-comp audio (#1175)

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 <noreply@anthropic.com>
This commit is contained in:
Miguel Ángel
2026-06-02 22:58:43 -04:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 17b0db1d3e
commit ac73cbbcd0
2 changed files with 93 additions and 5 deletions
+80
View File
@@ -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 = `
<div data-composition-id="root" data-root="true" data-start="0"
data-width="1920" data-height="1080">
<div data-composition-id="slide-2" data-start="10" data-duration="10">
<audio data-start="0" data-duration="10" src="tone.wav"></audio>
</div>
</div>
`;
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 = `
<div data-composition-id="root" data-root="true" data-start="0"
data-width="1920" data-height="1080">
<div data-composition-id="slide-2" data-start="10" data-duration="10">
<audio data-start="0" data-duration="10" src="tone.wav"></audio>
</div>
</div>
`;
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 = `
<div data-composition-id="root" data-root="true" data-start="0"
data-width="1920" data-height="1080">
<audio data-start="0" data-duration="20" src="bg.wav"></audio>
</div>
`;
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);
});
});
});
+13 -5
View File
@@ -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;