Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,28 @@ describe("initSandboxRuntimeModular", () => {
expect(warned).not.toContain("Root timeline not bound");
});

it("caps the playable duration at <html data-composition-duration>", () => {
// The document-level duration is machine-calculated from the content
// schedule, so it is a CEILING: one over-long ambient tween (a scene
// component's 20s background drift) must not stretch playback into a
// blank tail past the calculated end. (A composition HOST's declared
// data-duration stays floor-only — see "keeps the timeline duration when
// it exceeds the root's declared data-duration".)
document.documentElement.setAttribute("data-composition-duration", "11.5");
const root = document.createElement("div");
root.className = "clip";
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
document.body.appendChild(root);

window.__timelines = { main: createMockTimeline(20) };

initSandboxRuntimeModular();

expect(window.__player?.getDuration()).toBe(11.5);
document.documentElement.removeAttribute("data-composition-duration");
});

it("uses the shorter authored host window when the child timeline is longer", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
Expand Down Expand Up @@ -652,6 +674,52 @@ describe("initSandboxRuntimeModular", () => {
expect(injectedLink?.isConnected).toBe(false);
});

describe("pagehide teardown", () => {
async function initMinimalRuntime(): Promise<void> {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
window.__timelines = { main: createMockTimeline(3) };
initSandboxRuntimeModular();
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
}

function firePageHide(persisted: boolean): void {
const event = new Event("pagehide");
Object.defineProperty(event, "persisted", { value: persisted });
window.dispatchEvent(event);
}

it("tears down on a real page hide", async () => {
await initMinimalRuntime();
expect(window.__hfRuntimeTeardown).toBeTypeOf("function");

firePageHide(false);

expect(window.__hfRuntimeTeardown).toBeNull();
});

it("survives a bfcache page hide so a pageshow restore stays playable", async () => {
await initMinimalRuntime();

// persisted === true means the document is frozen into the back-forward
// cache, not destroyed — `pageshow` restores it WITHOUT re-running init,
// so a teardown here would leave the player permanently inert.
firePageHide(true);

expect(window.__hfRuntimeTeardown).toBeTypeOf("function");

// ...and the listener is still armed, so the eventual real hide still
// reclaims the AudioContext and decoded buffers.
firePageHide(false);
expect(window.__hfRuntimeTeardown).toBeNull();
});
});

it("keeps compiled external composition hosts visible through their authored duration", async () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
Expand Down Expand Up @@ -2683,4 +2751,128 @@ describe("initSandboxRuntimeModular", () => {
}).not.toThrow();
});
});

describe("audio buffer pre-decode at mount", () => {
it("warm-decodes every timed audio source before any play()", async () => {
const decodeAudioData = vi.fn(async () => ({}) as AudioBuffer);
class MockAudioContext {
currentTime = 0;
state = "running";
destination = {};
decodeAudioData = decodeAudioData;
createGain() {
return { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
}
resume = vi.fn();
close = vi.fn();
}
vi.stubGlobal("AudioContext", MockAudioContext);
const fetchMock = vi.fn(async () => ({
ok: true,
arrayBuffer: async () => new ArrayBuffer(8),
}));
vi.stubGlobal("fetch", fetchMock);

try {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "10");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
const audioA = document.createElement("audio");
audioA.setAttribute("data-start", "0");
audioA.setAttribute("data-duration", "5");
audioA.setAttribute("src", "https://media.example/a.mp3");
const audioB = document.createElement("audio");
audioB.setAttribute("data-start", "5");
audioB.setAttribute("data-duration", "5");
audioB.setAttribute("src", "https://media.example/b.mp3");
root.append(audioA, audioB);
document.body.appendChild(root);
window.__timelines = { main: createMockTimeline(10) };

initSandboxRuntimeModular();

// No play() has happened — the decode must be driven by mount alone.
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(decodeAudioData).toHaveBeenCalledTimes(2);
});
const urls = fetchMock.mock.calls.map((call) => String(call[0]));
expect(urls.some((u) => u.endsWith("a.mp3"))).toBe(true);
expect(urls.some((u) => u.endsWith("b.mp3"))).toBe(true);
} finally {
vi.unstubAllGlobals();
}
});

it("only warms clips near the playhead, and honors the embedder opt-out", async () => {
const decodeAudioData = vi.fn(async () => ({}) as AudioBuffer);
class MockAudioContext {
currentTime = 0;
state = "running";
destination = {};
decodeAudioData = decodeAudioData;
createGain() {
return { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
}
resume = vi.fn();
close = vi.fn();
}
vi.stubGlobal("AudioContext", MockAudioContext);
const fetchMock = vi.fn(async () => ({
ok: true,
arrayBuffer: async () => new ArrayBuffer(8),
}));
vi.stubGlobal("fetch", fetchMock);

try {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "600");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
const near = document.createElement("audio");
near.setAttribute("data-start", "0");
near.setAttribute("data-duration", "5");
near.setAttribute("src", "https://media.example/near.mp3");
const far = document.createElement("audio");
far.setAttribute("data-start", "300");
far.setAttribute("data-duration", "5");
far.setAttribute("src", "https://media.example/far.mp3");
root.append(near, far);
document.body.appendChild(root);
window.__timelines = { main: createMockTimeline(600) };

initSandboxRuntimeModular();

// Warming a whole long composition costs a full-file fetch + ~23MB of
// PCM per stereo minute PER SOURCE — only playhead-proximate clips may
// pre-decode; the rest stay lazy until the playhead approaches.
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("near.mp3");

window.__hfRuntimeTeardown?.();
fetchMock.mockClear();
(
window as Window & { __HF_AUDIO_PREDECODE_DISABLED?: boolean }
).__HF_AUDIO_PREDECODE_DISABLED = true;
initSandboxRuntimeModular();
// A document that exists but is never played (e.g. a double-buffered
// preview's standby iframe) must not warm anything.
await new Promise((r) => setTimeout(r, 20));
expect(fetchMock).not.toHaveBeenCalled();
} finally {
delete (window as Window & { __HF_AUDIO_PREDECODE_DISABLED?: boolean })
.__HF_AUDIO_PREDECODE_DISABLED;
vi.unstubAllGlobals();
}
});
});
});
Loading