Skip to content

Commit e1fa158

Browse files
authored
fix: keep baking html5 clips while their tab is in the background (#281)
1 parent d508db8 commit e1fa158

6 files changed

Lines changed: 103 additions & 14 deletions

File tree

‎src/components/canvas/players/bake-queue.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { nextTask } from "@shared/utils";
2+
13
/** Where a waiting bake stands on the timeline. Read at each pick, so seeks, edits and progress count. */
24
export interface BakeSlot {
35
/** Timeline time at which the next frame this bake draws leaves the screen. */
@@ -59,12 +61,12 @@ export class BakeQueue {
5961
private schedule(): void {
6062
if (this.running || this.scheduled || this.waiting.length === 0) return;
6163
this.scheduled = true;
62-
setTimeout(() => {
64+
nextTask().then(() => {
6365
this.scheduled = false;
6466
if (this.running || this.waiting.length === 0) return;
6567
const [next] = this.waiting.splice(nextBake(this.waiting.map(({ slot }) => slot)), 1);
6668
this.running = true;
6769
next.run();
68-
}, 0);
70+
});
6971
}
7072
}

‎src/components/canvas/players/html5-player.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ export class Html5Player extends Player {
152152
private static readonly mounts = new Set<Promise<void>>();
153153
// Frames every bake has in flight. Bakes take turns, so they share one window and one memory budget.
154154
private static readonly inFlight = { frames: 0, bytes: 0 };
155+
// Bakes waiting for room in the window, woken whenever any bake's frame lands.
156+
private static readonly roomWaiters: Array<() => void> = [];
155157

156158
private iframe: HTMLIFrameElement | null = null;
157159
private renderedWidth: number = 0;
@@ -601,6 +603,7 @@ export class Html5Player extends Player {
601603
.finally(() => {
602604
inFlight.frames -= 1;
603605
inFlight.bytes -= frameBytes;
606+
Html5Player.roomWaiters.splice(0).forEach(wake => wake());
604607
});
605608
// Marks a queued frame's failure handled; awaiting it rethrows.
606609
drawn.catch(() => undefined);
@@ -623,8 +626,11 @@ export class Html5Player extends Player {
623626
if (stale()) return null;
624627
publish(idx, frame);
625628
} else if (issued < frameCount) {
626-
// Other bakes' frames fill the window, and they finish without their bakes' turns.
627-
await nextFrame();
629+
// Other bakes' frames fill the window and land without their bakes' turns. Polling for that would spin on a hidden
630+
// page, where a task comes at once.
631+
await new Promise<void>(resolve => {
632+
Html5Player.roomWaiters.push(resolve);
633+
});
628634
if (stale()) return null;
629635
}
630636
}

‎src/core/shared/utils.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,28 @@ export function createThrottle<TArgs extends unknown[]>(
146146
return { call, flush, cancel };
147147
}
148148

149-
/** Resolve on the next animation frame, or a timer when rAF is paused (hidden document). @internal */
149+
/**
150+
* Resolve on the next task. A hidden page holds timers back for a second or more, but not messages.
151+
* @internal
152+
*/
153+
export function nextTask(): Promise<void> {
154+
return new Promise<void>(resolve => {
155+
if (typeof document === "undefined" || !document.hidden) {
156+
setTimeout(resolve, 0);
157+
return;
158+
}
159+
const { port1, port2 } = new MessageChannel();
160+
port1.onmessage = () => {
161+
port1.close();
162+
resolve();
163+
};
164+
port2.postMessage(null);
165+
});
166+
}
167+
168+
/** Resolve on the next animation frame, or a timer when rAF stalls. A hidden page paints nothing, so it waits a task. @internal */
150169
export function nextFrame(): Promise<void> {
170+
if (typeof document !== "undefined" && document.hidden) return nextTask();
151171
return new Promise<void>(resolve => {
152172
let settled = false;
153173
const finish = (): void => {

‎tests/bake-queue.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ const settle = () =>
77
setTimeout(resolve, 0);
88
});
99

10+
afterEach(() => {
11+
Reflect.deleteProperty(globalThis, "document");
12+
jest.useRealTimers();
13+
});
14+
1015
describe("nextBake", () => {
1116
it("takes the earliest next frame still ahead of the playhead, then frames it has passed", () => {
1217
const playhead = () => 30;
@@ -108,6 +113,13 @@ describe("BakeQueue", () => {
108113
expect(ran).toEqual(["A", "C", "B"]);
109114
});
110115

116+
it("starts a bake while the page is hidden and holds its timers back", async () => {
117+
jest.useFakeTimers();
118+
Object.assign(globalThis, { document: { hidden: true } });
119+
120+
await expect(new BakeQueue().add(at(0.1), async () => "baked")).resolves.toBe("baked");
121+
});
122+
111123
it("moves on after a bake fails, and hands the failure to its caller", async () => {
112124
const queue = new BakeQueue();
113125
const failed = queue.add(at(0.1), async () => {

‎tests/html5-player.test.ts‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,7 @@ const sliceEveryFrame = () => {
902902
};
903903

904904
const sharedInFlight = () => (Html5Player as unknown as { inFlight: { frames: number; bytes: number } }).inFlight;
905+
const roomWaiters = () => (Html5Player as unknown as { roomWaiters: Array<() => void> }).roomWaiters;
905906

906907
it("publishes frames and progress while later frames still draw", async () => {
907908
const { harness, events } = createStillPlayer(); // 2 s at 30 fps: 60 frames
@@ -926,16 +927,23 @@ it("waits for another bake's frames to leave the window before drawing its own",
926927
Object.assign(harness, { renderedWidth: 3840, renderedHeight: 2160 });
927928
jest.spyOn(Navigator.prototype, "hardwareConcurrency", "get").mockReturnValue(16);
928929
Object.assign(sharedInFlight(), { frames: 8, bytes: 8 * 3840 * 2160 * 4 });
929-
let waits = 0;
930-
jest.spyOn(window, "requestAnimationFrame").mockImplementation(callback => {
931-
waits += 1;
932-
if (waits === 5) Object.assign(sharedInFlight(), { frames: 0, bytes: 0 });
933-
setTimeout(() => callback(0), 0);
934-
return 0;
935-
});
930+
const toBlob = jest.mocked(HTMLCanvasElement.prototype.toBlob);
931+
toBlob.mockClear();
932+
const rafs = jest.spyOn(window, "requestAnimationFrame");
936933

937-
expect(await harness.runCapture()).toHaveLength(60);
938-
expect(waits).toBeGreaterThanOrEqual(5);
934+
const capture = harness.runCapture();
935+
await until(() => roomWaiters().length > 0);
936+
const rafsWhileWaiting = rafs.mock.calls.length;
937+
await flush(20);
938+
expect(toBlob).not.toHaveBeenCalled();
939+
expect(rafs.mock.calls.length).toBe(rafsWhileWaiting);
940+
// The other bake's frames land.
941+
Object.assign(sharedInFlight(), { frames: 0, bytes: 0 });
942+
roomWaiters()
943+
.splice(0)
944+
.forEach(wake => wake());
945+
946+
expect(await capture).toHaveLength(60);
939947
expect(sharedInFlight()).toEqual({ frames: 0, bytes: 0 });
940948
});
941949

‎tests/next-task.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { nextFrame, nextTask } from "@core/shared/utils";
2+
3+
// A hidden page holds timers back for a second or more; fake timers that never advance stand in for that.
4+
const showPage = (hidden: boolean) => Object.assign(globalThis, { document: { hidden } });
5+
6+
afterEach(() => {
7+
Reflect.deleteProperty(globalThis, "document");
8+
jest.useRealTimers();
9+
});
10+
11+
describe("nextTask", () => {
12+
it("resolves while the page is hidden and holds its timers back", async () => {
13+
jest.useFakeTimers();
14+
showPage(true);
15+
16+
await expect(nextTask()).resolves.toBeUndefined();
17+
});
18+
19+
it("waits for a timer while the page is visible", async () => {
20+
jest.useFakeTimers();
21+
showPage(false);
22+
let done = false;
23+
nextTask().then(() => {
24+
done = true;
25+
});
26+
27+
await Promise.resolve();
28+
expect(done).toBe(false);
29+
await jest.runOnlyPendingTimersAsync();
30+
expect(done).toBe(true);
31+
});
32+
});
33+
34+
describe("nextFrame", () => {
35+
it("resolves while the page is hidden and paints nothing", async () => {
36+
jest.useFakeTimers();
37+
showPage(true);
38+
39+
await expect(nextFrame()).resolves.toBeUndefined();
40+
});
41+
});

0 commit comments

Comments
 (0)