Skip to content

Commit 7e837e6

Browse files
authored
[1/3] feat: play html5 frames as they bake (#271)
* feat: hand html5 bakes turns by the frame playback needs next * feat: bake html5 frames from the playhead into a store indexed by frame * feat: interleave html5 bakes within one shared in-flight budget * feat: play html5 frames while their clip is still baking * fix: publish html5 frames as soon as their draws finish
1 parent a474456 commit 7e837e6

6 files changed

Lines changed: 406 additions & 59 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/** A pass's frames from the anchor on, then those before it. Pages only seek forward, so each segment needs a fresh page. */
2+
export function passSegments(indices: readonly number[], anchor: number): number[][] {
3+
return [indices.filter(i => i >= anchor), indices.filter(i => i < anchor)].filter(segment => segment.length > 0);
4+
}

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

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,22 @@
1-
/** Where a waiting bake's clip sits on the timeline. Read at each pick, so seeks and edits count. */
1+
/** Where a waiting bake stands on the timeline. Read at each pick, so seeks, edits and progress count. */
22
export interface BakeSlot {
3-
start(): number;
4-
end(): number;
3+
/** Timeline time at which the next frame this bake draws leaves the screen. */
4+
nextFrameEnd(): number;
55
playhead(): number;
66
}
77

8-
/** The clip under the playhead goes first, then those ahead in start order, then those already passed. */
8+
/** Frames playback still needs go first, earliest first, then frames it has already passed. */
99
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];
10+
const nextFrameEnd = slot.nextFrameEnd();
11+
return [nextFrameEnd <= slot.playhead() ? 1 : 0, nextFrameEnd];
1412
}
1513

1614
export function nextBake(waiting: readonly BakeSlot[]): number {
1715
let best = 0;
1816
let bestRank = rank(waiting[0]);
1917
for (let i = 1; i < waiting.length; i += 1) {
2018
const candidate = rank(waiting[i]);
21-
// Strictly better only, so clips that tie keep their arrival order.
19+
// Strictly better only, so bakes that tie keep their arrival order.
2220
if (candidate[0] < bestRank[0] || (candidate[0] === bestRank[0] && candidate[1] < bestRank[1])) {
2321
best = i;
2422
bestRank = candidate;
@@ -28,18 +26,25 @@ export function nextBake(waiting: readonly BakeSlot[]): number {
2826
}
2927

3028
/**
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.
29+
* Runs bakes one at a time. A bake hands its turn back by awaiting `turn` and rejoins the end of the
30+
* waiting list, so bakes whose frames overlap alternate. Each pick waits a task, so clips that join
31+
* together are all waiting when it is made, and it reads the playhead then.
3332
*/
3433
export class BakeQueue {
3534
private readonly waiting: { slot: BakeSlot; run: () => void }[] = [];
3635
private running = false;
3736
private scheduled = false;
3837

39-
public add<T>(slot: BakeSlot, bake: () => Promise<T>): Promise<T> {
38+
public add<T>(slot: BakeSlot, bake: (turn: () => Promise<void>) => Promise<T>): Promise<T> {
4039
return new Promise<T>((resolve, reject) => {
40+
const turn = (): Promise<void> =>
41+
new Promise<void>(resume => {
42+
this.waiting.push({ slot, run: resume });
43+
this.running = false;
44+
this.schedule();
45+
});
4146
const run = (): void => {
42-
bake()
47+
bake(turn)
4348
.then(resolve, reject)
4449
.finally(() => {
4550
this.running = false;

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

Lines changed: 113 additions & 27 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 { passSegments } from "./bake-order";
2021
import { BakeQueue } from "./bake-queue";
2122
import { computeHtml5CacheKey, html5CacheGet, html5CachePut } from "./html5-cache";
2223
import { createCaptureLoadingGraphic, createPlaceholderGraphic } from "./placeholder-graphic";
@@ -117,12 +118,17 @@ export class Html5Player extends Player {
117118
private static bakes = new BakeQueue();
118119
// Pages still mounting. A bake joins the queue once they have, so the queue orders every clip that loaded together.
119120
private static readonly mounts = new Set<Promise<void>>();
121+
// Frames every bake has in flight. Bakes take turns, so they share one window and one memory budget.
122+
private static readonly inFlight = { frames: 0, bytes: 0 };
120123

121124
private iframe: HTMLIFrameElement | null = null;
122125
private renderedWidth: number = 0;
123126
private renderedHeight: number = 0;
124127
private capturedFrames: Blob[] | null = null;
125128
private capturedScale: number = 1;
129+
// A first bake's frames by index as they land, for the stills they replace. Export never reads them.
130+
private bakingFrames: Array<Blob | undefined> | null = null;
131+
private bakingScale: number = 1;
126132
private readonly globalTransform = new pixi.Matrix();
127133
// Stays set after a failed re-bake, so a failure isn't retried every frame.
128134
private rebakeStarted: boolean = false;
@@ -156,7 +162,9 @@ export class Html5Player extends Player {
156162
private liveStillFrame: number = -1;
157163
private liveStillInFlight: boolean = false;
158164
private stillRequest: number | null = null;
159-
private readonly bakeSlot = { start: () => this.getStart(), end: () => this.getEnd(), playhead: () => this.edit.playbackTime };
165+
// The frame the running bake draws next. Null between bakes.
166+
private bakeNextIndex: number | null = null;
167+
private readonly bakeSlot = { nextFrameEnd: () => this.nextFrameEnd(), playhead: () => this.edit.playbackTime };
160168

161169
constructor(edit: Edit, clipConfiguration: ResolvedClip) {
162170
super(edit, clipConfiguration, PlayerType.Html5);
@@ -376,6 +384,7 @@ export class Html5Player extends Player {
376384
private transitionToFailed(): void {
377385
if (this.disposed) return;
378386
this.mode = "failed";
387+
this.bakingFrames = null;
379388
this.removeLoadingGraphic();
380389
this.removeLiveStill();
381390
this.createFallbackGraphic();
@@ -402,13 +411,30 @@ export class Html5Player extends Player {
402411

403412
private async queueBake(queuedFor: string | null, minScale: number): Promise<Blob[] | null> {
404413
while (Html5Player.mounts.size > 0) await Promise.allSettled([...Html5Player.mounts]);
405-
return Html5Player.bakes.add(this.bakeSlot, async () => {
414+
return Html5Player.bakes.add(this.bakeSlot, async turn => {
406415
// A content change while this waited in the queue would bake the new content at this capture's floor.
407416
if (this.disposed || this.contentHash !== queuedFor) return null;
408-
return this.runCapture(minScale);
417+
try {
418+
return await this.runCapture(minScale, turn);
419+
} finally {
420+
this.bakeNextIndex = null;
421+
}
409422
});
410423
}
411424

425+
// A bake that hasn't started begins at the playhead's frame, or at the clip's first once the playhead has passed it.
426+
private nextFrameEnd(): number {
427+
const start = this.getStart();
428+
const { playbackTime } = this.edit;
429+
const idx = this.bakeNextIndex ?? (playbackTime >= this.getEnd() ? 0 : Math.max(0, Math.floor((playbackTime - start) * this.captureFps)));
430+
return start + (idx + 1) / this.captureFps;
431+
}
432+
433+
// The frame on screen at the playhead, or past the last frame once the playhead has left the clip.
434+
private bakeAnchor(frameCount: number): number {
435+
return Math.min(Math.max(0, Math.floor((this.edit.playbackTime - this.getStart()) * this.captureFps)), frameCount);
436+
}
437+
412438
// After a tick, an active clip's global transform is the size the canvas shows it at. An inactive clip's own
413439
// transform isn't applied, so its bake reads only the viewport's zoom and may re-bake once the clip is active.
414440
private async bakeScale(minScale: number): Promise<number> {
@@ -432,7 +458,7 @@ export class Html5Player extends Player {
432458
}
433459

434460
// A re-bake passes the scale it was started for, so a later, smaller sample can't bake softer frames.
435-
private async runCapture(minScale: number = 0): Promise<Blob[] | null> {
461+
private async runCapture(minScale: number = 0, turn: () => Promise<void> = async () => {}): Promise<Blob[] | null> {
436462
if (!this.iframe || !this.iframe.contentDocument || !this.iframe.contentWindow) return null;
437463

438464
const { contentHash } = this;
@@ -465,24 +491,65 @@ export class Html5Player extends Player {
465491
this.captureFramesTotal = frameCount;
466492
this.captureFramesDone = 0;
467493

468-
const framesInFlight = captureFramesInFlight(navigator.hardwareConcurrency, Math.trunc(W * scale) * Math.trunc(H * scale) * 4);
469-
const blobs: Blob[] = [];
470-
const pending: Promise<Blob>[] = [];
494+
const frameBytes = Math.trunc(W * scale) * Math.trunc(H * scale) * 4;
495+
const framesInFlight = captureFramesInFlight(navigator.hardwareConcurrency, frameBytes);
496+
const { inFlight } = Html5Player;
497+
// The playhead's frame goes first, so the clip under it plays soonest.
498+
const segments = passSegments(
499+
Array.from({ length: frameCount }, (_, i) => i),
500+
this.bakeAnchor(frameCount)
501+
);
502+
const order = segments.flat();
503+
// Where each segment after the first starts in the issue order.
504+
const freshPageAt = new Set(segments.slice(0, -1).map((_, i) => segments.slice(0, i + 1).flat().length));
505+
const frames: Array<Blob | undefined> = new Array(frameCount);
506+
// A re-bake leaves complete frames on screen until it finishes, so only a first bake shows frames as they land.
507+
if (this.mode === "capturing") {
508+
this.bakingFrames = frames;
509+
this.bakingScale = scale;
510+
}
511+
const pending: { idx: number; drawn: Promise<Blob>; settled?: Blob }[] = [];
512+
const hasRoom = (): boolean =>
513+
pending.length < framesInFlight &&
514+
(inFlight.frames === 0 || (inFlight.frames < framesInFlight && inFlight.bytes + frameBytes <= CAPTURE_MEMORY_BUDGET_BYTES));
471515
// The SVG is a frame's whole input, so a frame that repeats a recent one draws identical bytes and shares its Blob.
472516
const recent = new Map<string, Promise<Blob>>();
473517
let recentStyles = "";
474518
let issued = 0;
519+
let published = 0;
475520
let progressStep = 0;
476521
let sliceStart = -Infinity;
477-
// Frames publish in order, so the oldest in flight gates the window.
478-
while (blobs.length < frameCount) {
479-
if (issued < frameCount && pending.length < framesInFlight) {
522+
const publish = (idx: number, frame: Blob): void => {
523+
frames[idx] = frame;
524+
published += 1;
525+
this.captureFramesDone += 1;
526+
// Steps, not frames: enough for a ring to fill smoothly, and bounded whatever the frame count.
527+
const step = Math.floor((this.captureFramesDone / frameCount) * CAPTURE_PROGRESS_STEPS);
528+
if (step > progressStep) {
529+
progressStep = step;
530+
this.emitCaptureProgress();
531+
}
532+
};
533+
// Frames publish in the order they were issued, so the oldest in flight gates the window.
534+
while (published < frameCount) {
535+
// Draws finish while the bake yields, so finished frames publish before the next one is drawn.
536+
for (let head = pending[0]?.settled; head; head = pending[0]?.settled) publish(pending.shift()!.idx, head);
537+
if (issued < frameCount && hasRoom()) {
538+
const idx = order[issued];
539+
this.bakeNextIndex = idx;
480540
if (performance.now() - sliceStart >= CAPTURE_SLICE_MS) {
481541
await nextFrame();
482542
if (stale()) return null;
543+
await turn();
544+
if (stale()) return null;
483545
sliceStart = performance.now();
484546
}
485-
this.seekHarness(issued / fps);
547+
}
548+
// Other bakes draw during their turns, so the window is read again once this one has its turn back.
549+
if (issued < frameCount && hasRoom()) {
550+
const idx = order[issued];
551+
if (freshPageAt.has(issued) && !(await this.loadFreshPage(this.iframe, stale, scale))) return null;
552+
this.seekHarness(idx / fps);
486553
forceLayout(this.iframe.contentDocument.body);
487554
const { styles, body } = this.serialiseIframe(W, H);
488555
if (styles !== recentStyles) {
@@ -491,31 +558,43 @@ export class Html5Player extends Player {
491558
}
492559
let drawn = recent.get(body);
493560
if (!drawn) {
494-
drawn = foreignObjectSvgToWebp(foreignObjectSvg(W, H, styles, body), W, H, scale).then(
495-
bytes => new Blob([bytes as BlobPart], { type: "image/webp" })
496-
);
561+
inFlight.frames += 1;
562+
inFlight.bytes += frameBytes;
563+
drawn = foreignObjectSvgToWebp(foreignObjectSvg(W, H, styles, body), W, H, scale)
564+
.then(bytes => new Blob([bytes as BlobPart], { type: "image/webp" }))
565+
.finally(() => {
566+
inFlight.frames -= 1;
567+
inFlight.bytes -= frameBytes;
568+
});
497569
// Marks a queued frame's failure handled; awaiting it rethrows.
498570
drawn.catch(() => undefined);
499571
// A Map iterates in insertion order, so its first key is the oldest frame.
500572
if (recent.size >= REPEAT_WINDOW_FRAMES) recent.delete(recent.keys().next().value!);
501573
recent.set(body, drawn);
502574
}
503-
pending.push(drawn);
575+
const entry: (typeof pending)[number] = { idx, drawn };
576+
drawn.then(
577+
blob => {
578+
entry.settled = blob;
579+
},
580+
() => undefined
581+
);
582+
pending.push(entry);
504583
issued += 1;
505-
} else {
506-
const frame = await pending.shift()!;
584+
} else if (pending.length > 0) {
585+
const { idx, drawn } = pending.shift()!;
586+
const frame = await drawn;
587+
if (stale()) return null;
588+
publish(idx, frame);
589+
} else if (issued < frameCount) {
590+
// Other bakes' frames fill the window, and they finish without their bakes' turns.
591+
await nextFrame();
507592
if (stale()) return null;
508-
blobs.push(frame);
509-
this.captureFramesDone += 1;
510-
// Steps, not frames: enough for a ring to fill smoothly, and bounded whatever the frame count.
511-
const step = Math.floor((this.captureFramesDone / frameCount) * CAPTURE_PROGRESS_STEPS);
512-
if (step > progressStep) {
513-
progressStep = step;
514-
this.emitCaptureProgress();
515-
}
516593
}
517594
}
518595

596+
// Every index has published by now.
597+
const blobs = frames as Blob[];
519598
if (!(await this.adoptBake(blobs, cacheKey, scale, stale))) return null;
520599
this.emitCaptureCompleted(frameCount);
521600

@@ -846,6 +925,7 @@ export class Html5Player extends Player {
846925
this.decodedFrames.clear();
847926
this.capturedFrames = null;
848927
this.capturedHash = null;
928+
this.bakingFrames = null;
849929
this.lastFrameIdx = -1;
850930
this.decodeInFlight = null;
851931
this.rebakeStarted = false;
@@ -1031,9 +1111,10 @@ export class Html5Player extends Player {
10311111
if (!this.disposed) console.warn("[Html5Player] still failed:", err);
10321112
return null;
10331113
});
1034-
let texture = await attempt();
1114+
const baked = this.bakingFrames?.[frame];
1115+
let texture = baked ? await this.bakingTexture(baked) : await attempt();
10351116
// A page can load too slowly once, so a failed still gets one more try while nothing newer is waiting.
1036-
if (!texture && this.liveStillFrame === frame && this.stillRequest === null) texture = await attempt();
1117+
if (!texture && !baked && this.liveStillFrame === frame && this.stillRequest === null) texture = await attempt();
10371118
const unbaked = this.mode === "editing" || this.mode === "stale" || this.mode === "capturing";
10381119
if (!texture || this.disposed || !unbaked || this.contentHash !== hash) {
10391120
texture?.destroy(true);
@@ -1054,6 +1135,11 @@ export class Html5Player extends Player {
10541135
}
10551136
}
10561137

1138+
// The resolution makes a scaled frame's texture report the clip's full size.
1139+
private async bakingTexture(blob: Blob): Promise<pixi.Texture> {
1140+
return pixi.Texture.from({ resource: await createImageBitmap(blob), resolution: this.bakingScale });
1141+
}
1142+
10571143
private removeLiveStill(): void {
10581144
this.stillRequest = null;
10591145
this.liveStillFrame = -1;

‎tests/bake-order.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { passSegments } from "@canvas/players/bake-order";
2+
3+
it("splits a pass at the playhead, ahead first", () => {
4+
const all = Array.from({ length: 10 }, (_, i) => i);
5+
6+
expect(passSegments(all, 4)).toEqual([
7+
[4, 5, 6, 7, 8, 9],
8+
[0, 1, 2, 3]
9+
]);
10+
expect(passSegments(all, 0)).toEqual([all]);
11+
expect(passSegments(all, 10)).toEqual([all]);
12+
});

0 commit comments

Comments
 (0)