From d05a04fd5c6b115483b520442dd5f8dea06cf4cf Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 30 Oct 2026 20:46:44 +1100 Subject: [PATCH] fix: keep baking html5 clips while their tab is in the background --- src/components/canvas/players/bake-queue.ts | 6 ++- src/components/canvas/players/html5-player.ts | 10 ++++- src/core/shared/utils.ts | 22 +++++++++- tests/bake-queue.test.ts | 12 ++++++ tests/html5-player.test.ts | 26 ++++++++---- tests/next-task.test.ts | 41 +++++++++++++++++++ 6 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 tests/next-task.test.ts diff --git a/src/components/canvas/players/bake-queue.ts b/src/components/canvas/players/bake-queue.ts index 624a283c..04eda118 100644 --- a/src/components/canvas/players/bake-queue.ts +++ b/src/components/canvas/players/bake-queue.ts @@ -1,3 +1,5 @@ +import { nextTask } from "@shared/utils"; + /** Where a waiting bake stands on the timeline. Read at each pick, so seeks, edits and progress count. */ export interface BakeSlot { /** Timeline time at which the next frame this bake draws leaves the screen. */ @@ -59,12 +61,12 @@ export class BakeQueue { private schedule(): void { if (this.running || this.scheduled || this.waiting.length === 0) return; this.scheduled = true; - setTimeout(() => { + nextTask().then(() => { this.scheduled = false; if (this.running || this.waiting.length === 0) return; const [next] = this.waiting.splice(nextBake(this.waiting.map(({ slot }) => slot)), 1); this.running = true; next.run(); - }, 0); + }); } } diff --git a/src/components/canvas/players/html5-player.ts b/src/components/canvas/players/html5-player.ts index eee960b3..f2c14108 100644 --- a/src/components/canvas/players/html5-player.ts +++ b/src/components/canvas/players/html5-player.ts @@ -128,6 +128,8 @@ export class Html5Player extends Player { private static readonly mounts = new Set>(); // Frames every bake has in flight. Bakes take turns, so they share one window and one memory budget. private static readonly inFlight = { frames: 0, bytes: 0 }; + // Bakes waiting for room in the window, woken whenever any bake's frame lands. + private static readonly roomWaiters: Array<() => void> = []; private iframe: HTMLIFrameElement | null = null; private renderedWidth: number = 0; @@ -577,6 +579,7 @@ export class Html5Player extends Player { .finally(() => { inFlight.frames -= 1; inFlight.bytes -= frameBytes; + Html5Player.roomWaiters.splice(0).forEach(wake => wake()); }); // Marks a queued frame's failure handled; awaiting it rethrows. drawn.catch(() => undefined); @@ -599,8 +602,11 @@ export class Html5Player extends Player { if (stale()) return null; publish(idx, frame); } else if (issued < frameCount) { - // Other bakes' frames fill the window, and they finish without their bakes' turns. - await nextFrame(); + // Other bakes' frames fill the window and land without their bakes' turns. Polling for that would spin on a hidden + // page, where a task comes at once. + await new Promise(resolve => { + Html5Player.roomWaiters.push(resolve); + }); if (stale()) return null; } } diff --git a/src/core/shared/utils.ts b/src/core/shared/utils.ts index 539bc8ed..e92ced2b 100644 --- a/src/core/shared/utils.ts +++ b/src/core/shared/utils.ts @@ -146,8 +146,28 @@ export function createThrottle( return { call, flush, cancel }; } -/** Resolve on the next animation frame, or a timer when rAF is paused (hidden document). @internal */ +/** + * Resolve on the next task. A hidden page holds timers back for a second or more, but not messages. + * @internal + */ +export function nextTask(): Promise { + return new Promise(resolve => { + if (typeof document === "undefined" || !document.hidden) { + setTimeout(resolve, 0); + return; + } + const { port1, port2 } = new MessageChannel(); + port1.onmessage = () => { + port1.close(); + resolve(); + }; + port2.postMessage(null); + }); +} + +/** Resolve on the next animation frame, or a timer when rAF stalls. A hidden page paints nothing, so it waits a task. @internal */ export function nextFrame(): Promise { + if (typeof document !== "undefined" && document.hidden) return nextTask(); return new Promise(resolve => { let settled = false; const finish = (): void => { diff --git a/tests/bake-queue.test.ts b/tests/bake-queue.test.ts index d4672c75..51bb1259 100644 --- a/tests/bake-queue.test.ts +++ b/tests/bake-queue.test.ts @@ -7,6 +7,11 @@ const settle = () => setTimeout(resolve, 0); }); +afterEach(() => { + Reflect.deleteProperty(globalThis, "document"); + jest.useRealTimers(); +}); + describe("nextBake", () => { it("takes the earliest next frame still ahead of the playhead, then frames it has passed", () => { const playhead = () => 30; @@ -108,6 +113,13 @@ describe("BakeQueue", () => { expect(ran).toEqual(["A", "C", "B"]); }); + it("starts a bake while the page is hidden and holds its timers back", async () => { + jest.useFakeTimers(); + Object.assign(globalThis, { document: { hidden: true } }); + + await expect(new BakeQueue().add(at(0.1), async () => "baked")).resolves.toBe("baked"); + }); + it("moves on after a bake fails, and hands the failure to its caller", async () => { const queue = new BakeQueue(); const failed = queue.add(at(0.1), async () => { diff --git a/tests/html5-player.test.ts b/tests/html5-player.test.ts index 90399796..cfacb813 100644 --- a/tests/html5-player.test.ts +++ b/tests/html5-player.test.ts @@ -868,6 +868,7 @@ const sliceEveryFrame = () => { }; const sharedInFlight = () => (Html5Player as unknown as { inFlight: { frames: number; bytes: number } }).inFlight; +const roomWaiters = () => (Html5Player as unknown as { roomWaiters: Array<() => void> }).roomWaiters; it("publishes frames and progress while later frames still draw", async () => { const { harness, events } = createStillPlayer(); // 2 s at 30 fps: 60 frames @@ -892,16 +893,23 @@ it("waits for another bake's frames to leave the window before drawing its own", Object.assign(harness, { renderedWidth: 3840, renderedHeight: 2160 }); jest.spyOn(Navigator.prototype, "hardwareConcurrency", "get").mockReturnValue(16); Object.assign(sharedInFlight(), { frames: 8, bytes: 8 * 3840 * 2160 * 4 }); - let waits = 0; - jest.spyOn(window, "requestAnimationFrame").mockImplementation(callback => { - waits += 1; - if (waits === 5) Object.assign(sharedInFlight(), { frames: 0, bytes: 0 }); - setTimeout(() => callback(0), 0); - return 0; - }); + const toBlob = jest.mocked(HTMLCanvasElement.prototype.toBlob); + toBlob.mockClear(); + const rafs = jest.spyOn(window, "requestAnimationFrame"); - expect(await harness.runCapture()).toHaveLength(60); - expect(waits).toBeGreaterThanOrEqual(5); + const capture = harness.runCapture(); + await until(() => roomWaiters().length > 0); + const rafsWhileWaiting = rafs.mock.calls.length; + await flush(20); + expect(toBlob).not.toHaveBeenCalled(); + expect(rafs.mock.calls.length).toBe(rafsWhileWaiting); + // The other bake's frames land. + Object.assign(sharedInFlight(), { frames: 0, bytes: 0 }); + roomWaiters() + .splice(0) + .forEach(wake => wake()); + + expect(await capture).toHaveLength(60); expect(sharedInFlight()).toEqual({ frames: 0, bytes: 0 }); }); diff --git a/tests/next-task.test.ts b/tests/next-task.test.ts new file mode 100644 index 00000000..79189949 --- /dev/null +++ b/tests/next-task.test.ts @@ -0,0 +1,41 @@ +import { nextFrame, nextTask } from "@core/shared/utils"; + +// A hidden page holds timers back for a second or more; fake timers that never advance stand in for that. +const showPage = (hidden: boolean) => Object.assign(globalThis, { document: { hidden } }); + +afterEach(() => { + Reflect.deleteProperty(globalThis, "document"); + jest.useRealTimers(); +}); + +describe("nextTask", () => { + it("resolves while the page is hidden and holds its timers back", async () => { + jest.useFakeTimers(); + showPage(true); + + await expect(nextTask()).resolves.toBeUndefined(); + }); + + it("waits for a timer while the page is visible", async () => { + jest.useFakeTimers(); + showPage(false); + let done = false; + nextTask().then(() => { + done = true; + }); + + await Promise.resolve(); + expect(done).toBe(false); + await jest.runOnlyPendingTimersAsync(); + expect(done).toBe(true); + }); +}); + +describe("nextFrame", () => { + it("resolves while the page is hidden and paints nothing", async () => { + jest.useFakeTimers(); + showPage(true); + + await expect(nextFrame()).resolves.toBeUndefined(); + }); +});