mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
17b0db1d3e
commit
ac73cbbcd0
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user