mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +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.length).toBeGreaterThan(0);
|
||||||
expect(seekTimes[0]).toBe(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(
|
const context = resolveMediaCompositionContext(
|
||||||
element as HTMLVideoElement | HTMLAudioElement,
|
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) => {
|
resolveDurationSeconds: (element) => {
|
||||||
const context = resolveMediaCompositionContext(element);
|
const context = resolveMediaCompositionContext(element);
|
||||||
const start = resolveMediaStartSeconds(element, context.inheritedStart ?? 0);
|
const start = resolveStartForElement(element, context.inheritedStart ?? 0);
|
||||||
const mediaStart =
|
const mediaStart =
|
||||||
Number.parseFloat(element.dataset.playbackStart ?? element.dataset.mediaStart ?? "0") ||
|
Number.parseFloat(element.dataset.playbackStart ?? element.dataset.mediaStart ?? "0") ||
|
||||||
0;
|
0;
|
||||||
@@ -1899,7 +1907,7 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
let foundActive = false;
|
let foundActive = false;
|
||||||
for (const rawEl of audioEls) {
|
for (const rawEl of audioEls) {
|
||||||
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
|
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 durAttr = Number.parseFloat(rawEl.dataset.duration ?? "");
|
||||||
const end = Number.isFinite(durAttr) && durAttr > 0 ? start + durAttr : Infinity;
|
const end = Number.isFinite(durAttr) && durAttr > 0 ? start + durAttr : Infinity;
|
||||||
const mediaStart =
|
const mediaStart =
|
||||||
@@ -1966,7 +1974,7 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
for (const el of mediaEls) {
|
for (const el of mediaEls) {
|
||||||
if (!(el instanceof HTMLMediaElement)) continue;
|
if (!(el instanceof HTMLMediaElement)) continue;
|
||||||
if (!el.isConnected) continue;
|
if (!el.isConnected) continue;
|
||||||
const start = Number.parseFloat(el.dataset.start ?? "");
|
const start = resolveStartForElement(el, 0);
|
||||||
if (!Number.isFinite(start)) continue;
|
if (!Number.isFinite(start)) continue;
|
||||||
const durAttr = Number.parseFloat(el.dataset.duration ?? "");
|
const durAttr = Number.parseFloat(el.dataset.duration ?? "");
|
||||||
const end = Number.isFinite(durAttr) && durAttr > 0 ? start + durAttr : Infinity;
|
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]");
|
const audioEls = document.querySelectorAll("audio[data-start]");
|
||||||
for (const rawEl of audioEls) {
|
for (const rawEl of audioEls) {
|
||||||
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
|
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
|
||||||
const compStart = Number.parseFloat(rawEl.dataset.start ?? "");
|
const compStart = resolveStartForElement(rawEl, 0);
|
||||||
if (!Number.isFinite(compStart)) continue;
|
if (!Number.isFinite(compStart)) continue;
|
||||||
const mediaStart =
|
const mediaStart =
|
||||||
Number.parseFloat(rawEl.dataset.playbackStart ?? rawEl.dataset.mediaStart ?? "0") || 0;
|
Number.parseFloat(rawEl.dataset.playbackStart ?? rawEl.dataset.mediaStart ?? "0") || 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user