Skip to content
Merged
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
6 changes: 4 additions & 2 deletions src/components/canvas/players/bake-queue.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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);
});
}
}
10 changes: 8 additions & 2 deletions src/components/canvas/players/html5-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ export class Html5Player extends Player {
private static readonly mounts = new Set<Promise<void>>();
// 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;
Expand Down Expand Up @@ -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);
Expand All @@ -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<void>(resolve => {
Html5Player.roomWaiters.push(resolve);
});
if (stale()) return null;
}
}
Expand Down
22 changes: 21 additions & 1 deletion src/core/shared/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,28 @@ export function createThrottle<TArgs extends unknown[]>(
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<void> {
return new Promise<void>(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<void> {
if (typeof document !== "undefined" && document.hidden) return nextTask();
return new Promise<void>(resolve => {
let settled = false;
const finish = (): void => {
Expand Down
12 changes: 12 additions & 0 deletions tests/bake-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 () => {
Expand Down
26 changes: 17 additions & 9 deletions tests/html5-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 });
});

Expand Down
41 changes: 41 additions & 0 deletions tests/next-task.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading