Skip to content

Commit 2cb8ede

Browse files
authored
fix: bake html5 clips in playback order from the playhead (#264)
1 parent 744c915 commit 2cb8ede

4 files changed

Lines changed: 229 additions & 49 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/** Where a waiting bake's clip sits on the timeline. Read at each pick, so seeks and edits count. */
2+
export interface BakeSlot {
3+
start(): number;
4+
end(): number;
5+
playhead(): number;
6+
}
7+
8+
/** The clip under the playhead goes first, then those ahead in start order, then those already passed. */
9+
function rank(slot: BakeSlot): [number, number] {
10+
const playhead = slot.playhead();
11+
const start = slot.start();
12+
if (start <= playhead && playhead < slot.end()) return [0, start];
13+
return [start > playhead ? 1 : 2, start];
14+
}
15+
16+
export function nextBake(waiting: readonly BakeSlot[]): number {
17+
let best = 0;
18+
let bestRank = rank(waiting[0]);
19+
for (let i = 1; i < waiting.length; i += 1) {
20+
const candidate = rank(waiting[i]);
21+
// Strictly better only, so clips that tie keep their arrival order.
22+
if (candidate[0] < bestRank[0] || (candidate[0] === bestRank[0] && candidate[1] < bestRank[1])) {
23+
best = i;
24+
bestRank = candidate;
25+
}
26+
}
27+
return best;
28+
}
29+
30+
/**
31+
* Runs bakes one at a time. Each pick waits a task, so clips that join together are all
32+
* waiting when it is made, and it reads the playhead then.
33+
*/
34+
export class BakeQueue {
35+
private readonly waiting: { slot: BakeSlot; run: () => void }[] = [];
36+
private running = false;
37+
private scheduled = false;
38+
39+
public add<T>(slot: BakeSlot, bake: () => Promise<T>): Promise<T> {
40+
return new Promise<T>((resolve, reject) => {
41+
const run = (): void => {
42+
bake()
43+
.then(resolve, reject)
44+
.finally(() => {
45+
this.running = false;
46+
this.schedule();
47+
});
48+
};
49+
this.waiting.push({ slot, run });
50+
this.schedule();
51+
});
52+
}
53+
54+
private schedule(): void {
55+
if (this.running || this.scheduled || this.waiting.length === 0) return;
56+
this.scheduled = true;
57+
setTimeout(() => {
58+
this.scheduled = false;
59+
if (this.running || this.waiting.length === 0) return;
60+
const [next] = this.waiting.splice(nextBake(this.waiting.map(({ slot }) => slot)), 1);
61+
this.running = true;
62+
next.run();
63+
}, 0);
64+
}
65+
}

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

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from "@shotstack/shotstack-canvas";
1818
import * as pixi from "pixi.js";
1919

20+
import { BakeQueue } from "./bake-queue";
2021
import { computeHtml5CacheKey, html5CacheGet, html5CachePut } from "./html5-cache";
2122
import { createCaptureLoadingGraphic, createPlaceholderGraphic } from "./placeholder-graphic";
2223

@@ -106,7 +107,9 @@ export function bakeScaleFor(onScreenScale: number): number {
106107
type Html5Mode = "editing" | "capturing" | "playback" | "stale" | "failed";
107108

108109
export class Html5Player extends Player {
109-
private static captureChain: Promise<unknown> = Promise.resolve();
110+
private static bakes = new BakeQueue();
111+
// Pages still mounting. A bake joins the queue once they have, so the queue orders every clip that loaded together.
112+
private static readonly mounts = new Set<Promise<void>>();
110113

111114
private iframe: HTMLIFrameElement | null = null;
112115
private renderedWidth: number = 0;
@@ -146,6 +149,7 @@ export class Html5Player extends Player {
146149
private liveStillFrame: number = -1;
147150
private liveStillInFlight: boolean = false;
148151
private stillRequest: number | null = null;
152+
private readonly bakeSlot = { start: () => this.getStart(), end: () => this.getEnd(), playhead: () => this.edit.playbackTime };
149153

150154
constructor(edit: Edit, clipConfiguration: ResolvedClip) {
151155
super(edit, clipConfiguration, PlayerType.Html5);
@@ -256,10 +260,13 @@ export class Html5Player extends Player {
256260

257261
// A reload waits on this, so a mount slowed by ingest fetches never runs alongside a second one.
258262
private trackMount(mount: Promise<void>): Promise<void> {
259-
this.mounting = mount.finally(() => {
263+
const tracked = mount.finally(() => {
260264
this.mounting = null;
265+
Html5Player.mounts.delete(tracked);
261266
});
262-
return this.mounting;
267+
this.mounting = tracked;
268+
Html5Player.mounts.add(tracked);
269+
return tracked;
263270
}
264271

265272
private async mountAsset(): Promise<void> {
@@ -373,29 +380,27 @@ export class Html5Player extends Player {
373380
return this.captureInFlight;
374381
}
375382
const queuedFor = this.contentHash;
376-
const previous = Html5Player.captureChain;
377383
// Cached frames cost no bake, so they never wait in the queue behind one.
378384
const capture: Promise<Blob[] | null> = this.bakeScale(minScale)
379385
.then(scale => this.adoptCachedBake(scale))
380-
.then(
381-
cached =>
382-
cached ??
383-
previous.then(() => {
384-
// A content change while this waited in the queue would bake the new content at this capture's floor.
385-
if (this.disposed || this.contentHash !== queuedFor) return null;
386-
return this.runCapture(minScale);
387-
})
388-
)
386+
.then(cached => cached ?? this.queueBake(queuedFor, minScale))
389387
.finally(() => {
390388
// A content change frees the slot, and a newer capture may hold it by now.
391389
if (this.captureInFlight === capture) this.captureInFlight = null;
392390
});
393391
this.captureInFlight = capture;
394-
// A cache hit settles early, so the next bake waits on the whole queue, not just this capture.
395-
Html5Player.captureChain = Promise.allSettled([previous, capture]).then(() => undefined);
396392
return capture;
397393
}
398394

395+
private async queueBake(queuedFor: string | null, minScale: number): Promise<Blob[] | null> {
396+
while (Html5Player.mounts.size > 0) await Promise.allSettled([...Html5Player.mounts]);
397+
return Html5Player.bakes.add(this.bakeSlot, async () => {
398+
// A content change while this waited in the queue would bake the new content at this capture's floor.
399+
if (this.disposed || this.contentHash !== queuedFor) return null;
400+
return this.runCapture(minScale);
401+
});
402+
}
403+
399404
// After a tick, an active clip's global transform is the size the canvas shows it at. An inactive clip's own
400405
// transform isn't applied, so its bake reads only the viewport's zoom and may re-bake once the clip is active.
401406
private async bakeScale(minScale: number): Promise<number> {

‎tests/bake-queue.test.ts‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { BakeQueue, nextBake } from "@canvas/players/bake-queue";
2+
3+
const at = (start: number, end: number, playhead: () => number = () => 0) => ({ start: () => start, end: () => end, playhead });
4+
5+
const settle = () =>
6+
new Promise(resolve => {
7+
setTimeout(resolve, 0);
8+
});
9+
10+
describe("nextBake", () => {
11+
it("takes the clip under the playhead, then those ahead in start order, then those it has passed", () => {
12+
const playhead = () => 30;
13+
const waiting = [at(54.4, 80.37, playhead), at(0, 24.4, playhead), at(24.4, 54.4, playhead), at(40, 45, playhead)];
14+
const order: number[] = [];
15+
while (waiting.length > 0) order.push(waiting.splice(nextBake(waiting), 1)[0].start());
16+
17+
expect(order).toEqual([24.4, 40, 54.4, 0]);
18+
});
19+
20+
it("keeps arrival order between clips that start together", () => {
21+
expect(nextBake([at(10, 20), at(10, 15)])).toBe(0);
22+
});
23+
});
24+
25+
describe("BakeQueue", () => {
26+
it("bakes clips that arrive together from the playhead, whatever their arrival order", async () => {
27+
const queue = new BakeQueue();
28+
const ran: number[] = [];
29+
await Promise.all([54.4, 24.4, 0].map(start => queue.add(at(start, start + 20), async () => ran.push(start))));
30+
31+
expect(ran).toEqual([0, 24.4, 54.4]);
32+
});
33+
34+
it("runs one bake at a time", async () => {
35+
const queue = new BakeQueue();
36+
let running = 0;
37+
let most = 0;
38+
const bake = async () => {
39+
running += 1;
40+
most = Math.max(most, running);
41+
await settle();
42+
running -= 1;
43+
};
44+
await Promise.all([0, 10, 20].map(start => queue.add(at(start, start + 10), bake)));
45+
46+
expect(most).toBe(1);
47+
});
48+
49+
it("re-sorts what's waiting when the playhead moves during a bake", async () => {
50+
const queue = new BakeQueue();
51+
let playhead = 0;
52+
const ran: string[] = [];
53+
let finishFirst = () => {};
54+
const first = queue.add(
55+
at(0, 10, () => playhead),
56+
() => {
57+
ran.push("A");
58+
return new Promise<void>(resolve => {
59+
finishFirst = resolve;
60+
});
61+
}
62+
);
63+
await settle();
64+
const rest = [
65+
queue.add(
66+
at(10, 20, () => playhead),
67+
async () => ran.push("B")
68+
),
69+
queue.add(
70+
at(20, 30, () => playhead),
71+
async () => ran.push("C")
72+
)
73+
];
74+
playhead = 25;
75+
finishFirst();
76+
await Promise.all([first, ...rest]);
77+
78+
expect(ran).toEqual(["A", "C", "B"]);
79+
});
80+
81+
it("moves on after a bake fails, and hands the failure to its caller", async () => {
82+
const queue = new BakeQueue();
83+
const failed = queue.add(at(0, 10), async () => {
84+
throw new Error("bake failed");
85+
});
86+
const next = queue.add(at(10, 20), async () => "baked");
87+
88+
await expect(failed).rejects.toThrow("bake failed");
89+
await expect(next).resolves.toBe("baked");
90+
});
91+
});

‎tests/html5-player.test.ts‎

Lines changed: 53 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** @jest-environment jsdom */
22

3+
import { BakeQueue } from "@canvas/players/bake-queue";
34
import { Html5Player, bakeScaleFor, captureFramesInFlight } from "@canvas/players/html5-player";
45
import type { Edit } from "@core/edit-session";
56
import { EditEvent, InternalEvent } from "@core/events/edit-events";
@@ -33,6 +34,14 @@ jest.mock("@canvas/players/player", () => ({
3334
return this.resolvedTiming.length;
3435
}
3536

37+
getStart() {
38+
return this.resolvedTiming.start;
39+
}
40+
41+
getEnd() {
42+
return this.resolvedTiming.start + this.resolvedTiming.length;
43+
}
44+
3645
setResolvedTiming(timing: { start: number; length: number }) {
3746
this.resolvedTiming = { ...timing };
3847
this.clipConfiguration.start = timing.start;
@@ -119,6 +128,7 @@ function createPlayer() {
119128
const edit = {
120129
size: { width: 1080, height: 1920 },
121130
isPlaying: false,
131+
playbackTime: 0,
122132
getHtml5Prerender: () => "on-load",
123133
getInternalEvents: () => events,
124134
getResolvedEdit: () => ({ output: { resolution: "hd" } }),
@@ -1083,17 +1093,34 @@ it("reports a failed re-bake and keeps the frames it has", async () => {
10831093
expect(harness.capturedFrames).toBe(frames);
10841094
});
10851095

1096+
type SharedQueue = { bakes: BakeQueue };
1097+
1098+
// Stands in for another clip's bake at the head of the shared queue.
1099+
function holdCaptureQueue(): { drain: () => void; restore: () => void } {
1100+
const { bakes } = Html5Player as unknown as SharedQueue;
1101+
const held = new BakeQueue();
1102+
let drain = () => {};
1103+
const gate = new Promise<void>(resolve => {
1104+
drain = resolve;
1105+
});
1106+
// Under the playhead from the start, so it runs ahead of any clip that joins after it.
1107+
held.add({ start: () => 0, end: () => Infinity, playhead: () => 0 }, () => gate);
1108+
Object.assign(Html5Player, { bakes: held });
1109+
return { drain, restore: () => Object.assign(Html5Player, { bakes }) };
1110+
}
1111+
1112+
// Earlier tests can leave a bake that never settles on the shared queue.
1113+
function emptyCaptureQueue(): () => void {
1114+
const { bakes } = Html5Player as unknown as SharedQueue;
1115+
Object.assign(Html5Player, { bakes: new BakeQueue() });
1116+
return () => Object.assign(Html5Player, { bakes });
1117+
}
1118+
10861119
it("drops a re-bake queued for content that has since changed, leaving the newer capture in flight", async () => {
10871120
const { player, harness } = createStillPlayer();
10881121
Object.assign(harness, { capturedFrames: [new Blob(["baked 0"])], capturedHash: "original@0.5", capturedScale: 0.5, mode: "playback" });
10891122
shownAt(harness, 0.8);
1090-
const { captureChain } = Html5Player as unknown as { captureChain: Promise<unknown> };
1091-
let drain = () => {};
1092-
Object.assign(Html5Player, {
1093-
captureChain: new Promise<void>(resolve => {
1094-
drain = resolve;
1095-
})
1096-
});
1123+
const queue = holdCaptureQueue();
10971124
try {
10981125
player.update(0, 0);
10991126
const rebake = harness.captureInFlight!;
@@ -1103,7 +1130,7 @@ it("drops a re-bake queued for content that has since changed, leaving the newer
11031130
await player.reloadAsset();
11041131
const newer = harness.captureInFlight;
11051132
expect(newer).not.toBeNull();
1106-
drain();
1133+
queue.drain();
11071134
await rebake;
11081135
expect(harness.captureInFlight).toBe(newer);
11091136

@@ -1112,29 +1139,10 @@ it("drops a re-bake queued for content that has since changed, leaving the newer
11121139
expect(html5CachePut).toHaveBeenCalledWith("changed@0.5", expect.anything());
11131140
expect(html5CachePut).not.toHaveBeenCalledWith("changed@1", expect.anything());
11141141
} finally {
1115-
Object.assign(Html5Player, { captureChain });
1142+
queue.restore();
11161143
}
11171144
});
11181145

1119-
// Stands in for another clip's bake at the head of the shared queue.
1120-
function holdCaptureQueue(): { drain: () => void; restore: () => void } {
1121-
const { captureChain } = Html5Player as unknown as { captureChain: Promise<unknown> };
1122-
let drain = () => {};
1123-
Object.assign(Html5Player, {
1124-
captureChain: new Promise<void>(resolve => {
1125-
drain = resolve;
1126-
})
1127-
});
1128-
return { drain, restore: () => Object.assign(Html5Player, { captureChain }) };
1129-
}
1130-
1131-
// Earlier tests can leave a capture that never settles on the shared queue.
1132-
function emptyCaptureQueue(): () => void {
1133-
const { captureChain } = Html5Player as unknown as { captureChain: Promise<unknown> };
1134-
Object.assign(Html5Player, { captureChain: Promise.resolve() });
1135-
return () => Object.assign(Html5Player, { captureChain });
1136-
}
1137-
11381146
it("adopts cached frames while another clip's bake holds the queue", async () => {
11391147
const { harness, events } = createStillPlayer();
11401148
const queue = holdCaptureQueue();
@@ -1202,15 +1210,26 @@ it("keeps the queue's bakes in order when a cached clip sits between them", asyn
12021210
}
12031211
});
12041212

1205-
it("leaves no capture's frames on the shared queue once it settles", async () => {
1206-
const { harness } = createStillPlayer();
1207-
cacheLib.html5CacheGet.mockResolvedValueOnce({ pngs: [new Blob(["cached 0"])], fps: 1, frameCount: 1 });
1213+
it("bakes the clip that plays first ahead of one whose page loaded first", async () => {
1214+
const later = createStillPlayer();
1215+
const opening = createStillPlayer();
1216+
Object.assign(later.player, { getStart: () => 54.4, getEnd: () => 80.37 });
1217+
Object.assign(opening.player, { getStart: () => 0, getEnd: () => 24.4 });
1218+
const baked: string[] = [];
1219+
const bake = (name: string) => async () => {
1220+
baked.push(name);
1221+
return null;
1222+
};
1223+
jest.spyOn(later.harness, "runCapture").mockImplementation(bake("later"));
1224+
jest.spyOn(opening.harness, "runCapture").mockImplementation(bake("opening"));
1225+
jest.spyOn(console, "warn").mockImplementation(() => {});
12081226
const restore = emptyCaptureQueue();
12091227
try {
1210-
harness.beginCapture();
1211-
await until(() => harness.capturedFrames !== null);
1228+
later.harness.beginCapture();
1229+
opening.harness.beginCapture();
1230+
await until(() => baked.length === 2);
12121231

1213-
await expect((Html5Player as unknown as { captureChain: Promise<unknown> }).captureChain).resolves.toBeUndefined();
1232+
expect(baked).toEqual(["opening", "later"]);
12141233
} finally {
12151234
restore();
12161235
}

0 commit comments

Comments
 (0)