diff --git a/src/components/canvas/players/bake-order.ts b/src/components/canvas/players/bake-order.ts new file mode 100644 index 00000000..6b05a7f8 --- /dev/null +++ b/src/components/canvas/players/bake-order.ts @@ -0,0 +1,4 @@ +/** A pass's frames from the anchor on, then those before it. Pages only seek forward, so each segment needs a fresh page. */ +export function passSegments(indices: readonly number[], anchor: number): number[][] { + return [indices.filter(i => i >= anchor), indices.filter(i => i < anchor)].filter(segment => segment.length > 0); +} diff --git a/src/components/canvas/players/bake-queue.ts b/src/components/canvas/players/bake-queue.ts index 255cd1f3..624a283c 100644 --- a/src/components/canvas/players/bake-queue.ts +++ b/src/components/canvas/players/bake-queue.ts @@ -1,16 +1,14 @@ -/** Where a waiting bake's clip sits on the timeline. Read at each pick, so seeks and edits count. */ +/** Where a waiting bake stands on the timeline. Read at each pick, so seeks, edits and progress count. */ export interface BakeSlot { - start(): number; - end(): number; + /** Timeline time at which the next frame this bake draws leaves the screen. */ + nextFrameEnd(): number; playhead(): number; } -/** The clip under the playhead goes first, then those ahead in start order, then those already passed. */ +/** Frames playback still needs go first, earliest first, then frames it has already passed. */ function rank(slot: BakeSlot): [number, number] { - const playhead = slot.playhead(); - const start = slot.start(); - if (start <= playhead && playhead < slot.end()) return [0, start]; - return [start > playhead ? 1 : 2, start]; + const nextFrameEnd = slot.nextFrameEnd(); + return [nextFrameEnd <= slot.playhead() ? 1 : 0, nextFrameEnd]; } export function nextBake(waiting: readonly BakeSlot[]): number { @@ -18,7 +16,7 @@ export function nextBake(waiting: readonly BakeSlot[]): number { let bestRank = rank(waiting[0]); for (let i = 1; i < waiting.length; i += 1) { const candidate = rank(waiting[i]); - // Strictly better only, so clips that tie keep their arrival order. + // Strictly better only, so bakes that tie keep their arrival order. if (candidate[0] < bestRank[0] || (candidate[0] === bestRank[0] && candidate[1] < bestRank[1])) { best = i; bestRank = candidate; @@ -28,18 +26,25 @@ export function nextBake(waiting: readonly BakeSlot[]): number { } /** - * Runs bakes one at a time. Each pick waits a task, so clips that join together are all - * waiting when it is made, and it reads the playhead then. + * Runs bakes one at a time. A bake hands its turn back by awaiting `turn` and rejoins the end of the + * waiting list, so bakes whose frames overlap alternate. Each pick waits a task, so clips that join + * together are all waiting when it is made, and it reads the playhead then. */ export class BakeQueue { private readonly waiting: { slot: BakeSlot; run: () => void }[] = []; private running = false; private scheduled = false; - public add(slot: BakeSlot, bake: () => Promise): Promise { + public add(slot: BakeSlot, bake: (turn: () => Promise) => Promise): Promise { return new Promise((resolve, reject) => { + const turn = (): Promise => + new Promise(resume => { + this.waiting.push({ slot, run: resume }); + this.running = false; + this.schedule(); + }); const run = (): void => { - bake() + bake(turn) .then(resolve, reject) .finally(() => { this.running = false; diff --git a/src/components/canvas/players/html5-player.ts b/src/components/canvas/players/html5-player.ts index 2a1d4906..1cc8ba84 100644 --- a/src/components/canvas/players/html5-player.ts +++ b/src/components/canvas/players/html5-player.ts @@ -17,6 +17,7 @@ import { } from "@shotstack/shotstack-canvas"; import * as pixi from "pixi.js"; +import { passSegments } from "./bake-order"; import { BakeQueue } from "./bake-queue"; import { computeHtml5CacheKey, html5CacheGet, html5CachePut } from "./html5-cache"; import { createCaptureLoadingGraphic, createPlaceholderGraphic } from "./placeholder-graphic"; @@ -117,12 +118,17 @@ export class Html5Player extends Player { private static bakes = new BakeQueue(); // Pages still mounting. A bake joins the queue once they have, so the queue orders every clip that loaded together. 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 }; private iframe: HTMLIFrameElement | null = null; private renderedWidth: number = 0; private renderedHeight: number = 0; private capturedFrames: Blob[] | null = null; private capturedScale: number = 1; + // A first bake's frames by index as they land, for the stills they replace. Export never reads them. + private bakingFrames: Array | null = null; + private bakingScale: number = 1; private readonly globalTransform = new pixi.Matrix(); // Stays set after a failed re-bake, so a failure isn't retried every frame. private rebakeStarted: boolean = false; @@ -156,7 +162,9 @@ export class Html5Player extends Player { private liveStillFrame: number = -1; private liveStillInFlight: boolean = false; private stillRequest: number | null = null; - private readonly bakeSlot = { start: () => this.getStart(), end: () => this.getEnd(), playhead: () => this.edit.playbackTime }; + // The frame the running bake draws next. Null between bakes. + private bakeNextIndex: number | null = null; + private readonly bakeSlot = { nextFrameEnd: () => this.nextFrameEnd(), playhead: () => this.edit.playbackTime }; constructor(edit: Edit, clipConfiguration: ResolvedClip) { super(edit, clipConfiguration, PlayerType.Html5); @@ -376,6 +384,7 @@ export class Html5Player extends Player { private transitionToFailed(): void { if (this.disposed) return; this.mode = "failed"; + this.bakingFrames = null; this.removeLoadingGraphic(); this.removeLiveStill(); this.createFallbackGraphic(); @@ -402,13 +411,30 @@ export class Html5Player extends Player { private async queueBake(queuedFor: string | null, minScale: number): Promise { while (Html5Player.mounts.size > 0) await Promise.allSettled([...Html5Player.mounts]); - return Html5Player.bakes.add(this.bakeSlot, async () => { + return Html5Player.bakes.add(this.bakeSlot, async turn => { // A content change while this waited in the queue would bake the new content at this capture's floor. if (this.disposed || this.contentHash !== queuedFor) return null; - return this.runCapture(minScale); + try { + return await this.runCapture(minScale, turn); + } finally { + this.bakeNextIndex = null; + } }); } + // A bake that hasn't started begins at the playhead's frame, or at the clip's first once the playhead has passed it. + private nextFrameEnd(): number { + const start = this.getStart(); + const { playbackTime } = this.edit; + const idx = this.bakeNextIndex ?? (playbackTime >= this.getEnd() ? 0 : Math.max(0, Math.floor((playbackTime - start) * this.captureFps))); + return start + (idx + 1) / this.captureFps; + } + + // The frame on screen at the playhead, or past the last frame once the playhead has left the clip. + private bakeAnchor(frameCount: number): number { + return Math.min(Math.max(0, Math.floor((this.edit.playbackTime - this.getStart()) * this.captureFps)), frameCount); + } + // After a tick, an active clip's global transform is the size the canvas shows it at. An inactive clip's own // transform isn't applied, so its bake reads only the viewport's zoom and may re-bake once the clip is active. private async bakeScale(minScale: number): Promise { @@ -432,7 +458,7 @@ export class Html5Player extends Player { } // A re-bake passes the scale it was started for, so a later, smaller sample can't bake softer frames. - private async runCapture(minScale: number = 0): Promise { + private async runCapture(minScale: number = 0, turn: () => Promise = async () => {}): Promise { if (!this.iframe || !this.iframe.contentDocument || !this.iframe.contentWindow) return null; const { contentHash } = this; @@ -465,24 +491,65 @@ export class Html5Player extends Player { this.captureFramesTotal = frameCount; this.captureFramesDone = 0; - const framesInFlight = captureFramesInFlight(navigator.hardwareConcurrency, Math.trunc(W * scale) * Math.trunc(H * scale) * 4); - const blobs: Blob[] = []; - const pending: Promise[] = []; + const frameBytes = Math.trunc(W * scale) * Math.trunc(H * scale) * 4; + const framesInFlight = captureFramesInFlight(navigator.hardwareConcurrency, frameBytes); + const { inFlight } = Html5Player; + // The playhead's frame goes first, so the clip under it plays soonest. + const segments = passSegments( + Array.from({ length: frameCount }, (_, i) => i), + this.bakeAnchor(frameCount) + ); + const order = segments.flat(); + // Where each segment after the first starts in the issue order. + const freshPageAt = new Set(segments.slice(0, -1).map((_, i) => segments.slice(0, i + 1).flat().length)); + const frames: Array = new Array(frameCount); + // A re-bake leaves complete frames on screen until it finishes, so only a first bake shows frames as they land. + if (this.mode === "capturing") { + this.bakingFrames = frames; + this.bakingScale = scale; + } + const pending: { idx: number; drawn: Promise; settled?: Blob }[] = []; + const hasRoom = (): boolean => + pending.length < framesInFlight && + (inFlight.frames === 0 || (inFlight.frames < framesInFlight && inFlight.bytes + frameBytes <= CAPTURE_MEMORY_BUDGET_BYTES)); // The SVG is a frame's whole input, so a frame that repeats a recent one draws identical bytes and shares its Blob. const recent = new Map>(); let recentStyles = ""; let issued = 0; + let published = 0; let progressStep = 0; let sliceStart = -Infinity; - // Frames publish in order, so the oldest in flight gates the window. - while (blobs.length < frameCount) { - if (issued < frameCount && pending.length < framesInFlight) { + const publish = (idx: number, frame: Blob): void => { + frames[idx] = frame; + published += 1; + this.captureFramesDone += 1; + // Steps, not frames: enough for a ring to fill smoothly, and bounded whatever the frame count. + const step = Math.floor((this.captureFramesDone / frameCount) * CAPTURE_PROGRESS_STEPS); + if (step > progressStep) { + progressStep = step; + this.emitCaptureProgress(); + } + }; + // Frames publish in the order they were issued, so the oldest in flight gates the window. + while (published < frameCount) { + // Draws finish while the bake yields, so finished frames publish before the next one is drawn. + for (let head = pending[0]?.settled; head; head = pending[0]?.settled) publish(pending.shift()!.idx, head); + if (issued < frameCount && hasRoom()) { + const idx = order[issued]; + this.bakeNextIndex = idx; if (performance.now() - sliceStart >= CAPTURE_SLICE_MS) { await nextFrame(); if (stale()) return null; + await turn(); + if (stale()) return null; sliceStart = performance.now(); } - this.seekHarness(issued / fps); + } + // Other bakes draw during their turns, so the window is read again once this one has its turn back. + if (issued < frameCount && hasRoom()) { + const idx = order[issued]; + if (freshPageAt.has(issued) && !(await this.loadFreshPage(this.iframe, stale, scale))) return null; + this.seekHarness(idx / fps); forceLayout(this.iframe.contentDocument.body); const { styles, body } = this.serialiseIframe(W, H); if (styles !== recentStyles) { @@ -491,31 +558,43 @@ export class Html5Player extends Player { } let drawn = recent.get(body); if (!drawn) { - drawn = foreignObjectSvgToWebp(foreignObjectSvg(W, H, styles, body), W, H, scale).then( - bytes => new Blob([bytes as BlobPart], { type: "image/webp" }) - ); + inFlight.frames += 1; + inFlight.bytes += frameBytes; + drawn = foreignObjectSvgToWebp(foreignObjectSvg(W, H, styles, body), W, H, scale) + .then(bytes => new Blob([bytes as BlobPart], { type: "image/webp" })) + .finally(() => { + inFlight.frames -= 1; + inFlight.bytes -= frameBytes; + }); // Marks a queued frame's failure handled; awaiting it rethrows. drawn.catch(() => undefined); // A Map iterates in insertion order, so its first key is the oldest frame. if (recent.size >= REPEAT_WINDOW_FRAMES) recent.delete(recent.keys().next().value!); recent.set(body, drawn); } - pending.push(drawn); + const entry: (typeof pending)[number] = { idx, drawn }; + drawn.then( + blob => { + entry.settled = blob; + }, + () => undefined + ); + pending.push(entry); issued += 1; - } else { - const frame = await pending.shift()!; + } else if (pending.length > 0) { + const { idx, drawn } = pending.shift()!; + const frame = await drawn; + 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(); if (stale()) return null; - blobs.push(frame); - this.captureFramesDone += 1; - // Steps, not frames: enough for a ring to fill smoothly, and bounded whatever the frame count. - const step = Math.floor((this.captureFramesDone / frameCount) * CAPTURE_PROGRESS_STEPS); - if (step > progressStep) { - progressStep = step; - this.emitCaptureProgress(); - } } } + // Every index has published by now. + const blobs = frames as Blob[]; if (!(await this.adoptBake(blobs, cacheKey, scale, stale))) return null; this.emitCaptureCompleted(frameCount); @@ -846,6 +925,7 @@ export class Html5Player extends Player { this.decodedFrames.clear(); this.capturedFrames = null; this.capturedHash = null; + this.bakingFrames = null; this.lastFrameIdx = -1; this.decodeInFlight = null; this.rebakeStarted = false; @@ -1031,9 +1111,10 @@ export class Html5Player extends Player { if (!this.disposed) console.warn("[Html5Player] still failed:", err); return null; }); - let texture = await attempt(); + const baked = this.bakingFrames?.[frame]; + let texture = baked ? await this.bakingTexture(baked) : await attempt(); // A page can load too slowly once, so a failed still gets one more try while nothing newer is waiting. - if (!texture && this.liveStillFrame === frame && this.stillRequest === null) texture = await attempt(); + if (!texture && !baked && this.liveStillFrame === frame && this.stillRequest === null) texture = await attempt(); const unbaked = this.mode === "editing" || this.mode === "stale" || this.mode === "capturing"; if (!texture || this.disposed || !unbaked || this.contentHash !== hash) { texture?.destroy(true); @@ -1054,6 +1135,11 @@ export class Html5Player extends Player { } } + // The resolution makes a scaled frame's texture report the clip's full size. + private async bakingTexture(blob: Blob): Promise { + return pixi.Texture.from({ resource: await createImageBitmap(blob), resolution: this.bakingScale }); + } + private removeLiveStill(): void { this.stillRequest = null; this.liveStillFrame = -1; diff --git a/tests/bake-order.test.ts b/tests/bake-order.test.ts new file mode 100644 index 00000000..4c36dafa --- /dev/null +++ b/tests/bake-order.test.ts @@ -0,0 +1,12 @@ +import { passSegments } from "@canvas/players/bake-order"; + +it("splits a pass at the playhead, ahead first", () => { + const all = Array.from({ length: 10 }, (_, i) => i); + + expect(passSegments(all, 4)).toEqual([ + [4, 5, 6, 7, 8, 9], + [0, 1, 2, 3] + ]); + expect(passSegments(all, 0)).toEqual([all]); + expect(passSegments(all, 10)).toEqual([all]); +}); diff --git a/tests/bake-queue.test.ts b/tests/bake-queue.test.ts index 2b971c53..d4672c75 100644 --- a/tests/bake-queue.test.ts +++ b/tests/bake-queue.test.ts @@ -1,6 +1,6 @@ import { BakeQueue, nextBake } from "@canvas/players/bake-queue"; -const at = (start: number, end: number, playhead: () => number = () => 0) => ({ start: () => start, end: () => end, playhead }); +const at = (next: number, playhead: () => number = () => 0) => ({ nextFrameEnd: () => next, playhead }); const settle = () => new Promise(resolve => { @@ -8,17 +8,21 @@ const settle = () => }); describe("nextBake", () => { - it("takes the clip under the playhead, then those ahead in start order, then those it has passed", () => { + it("takes the earliest next frame still ahead of the playhead, then frames it has passed", () => { const playhead = () => 30; - const waiting = [at(54.4, 80.37, playhead), at(0, 24.4, playhead), at(24.4, 54.4, playhead), at(40, 45, playhead)]; + const waiting = [at(54.4, playhead), at(24.4, playhead), at(30.1, playhead), at(40, playhead)]; const order: number[] = []; - while (waiting.length > 0) order.push(waiting.splice(nextBake(waiting), 1)[0].start()); + while (waiting.length > 0) order.push(waiting.splice(nextBake(waiting), 1)[0].nextFrameEnd()); - expect(order).toEqual([24.4, 40, 54.4, 0]); + expect(order).toEqual([30.1, 40, 54.4, 24.4]); }); - it("keeps arrival order between clips that start together", () => { - expect(nextBake([at(10, 20), at(10, 15)])).toBe(0); + it("counts a frame that leaves the screen as the playhead reaches it as passed", () => { + expect(nextBake([at(30, () => 30), at(31, () => 30)])).toBe(1); + }); + + it("keeps arrival order between bakes whose next frames coincide", () => { + expect(nextBake([at(10), at(10)])).toBe(0); }); }); @@ -26,33 +30,59 @@ describe("BakeQueue", () => { it("bakes clips that arrive together from the playhead, whatever their arrival order", async () => { const queue = new BakeQueue(); const ran: number[] = []; - await Promise.all([54.4, 24.4, 0].map(start => queue.add(at(start, start + 20), async () => ran.push(start)))); + await Promise.all([54.4, 24.4, 0.1].map(next => queue.add(at(next), async () => ran.push(next)))); - expect(ran).toEqual([0, 24.4, 54.4]); + expect(ran).toEqual([0.1, 24.4, 54.4]); }); - it("runs one bake at a time", async () => { + it("runs one bake at a time, between turns too", async () => { const queue = new BakeQueue(); let running = 0; let most = 0; - const bake = async () => { + const bake = async (turn: () => Promise) => { + running += 1; + most = Math.max(most, running); + await settle(); + running -= 1; + await turn(); running += 1; most = Math.max(most, running); await settle(); running -= 1; }; - await Promise.all([0, 10, 20].map(start => queue.add(at(start, start + 10), bake))); + await Promise.all([1, 11, 21].map(next => queue.add(at(next), bake))); expect(most).toBe(1); }); + it("alternates bakes whose frames overlap, turn by turn", async () => { + const queue = new BakeQueue(); + const ran: string[] = []; + const bake = (name: string) => { + let next = 1; + return queue.add({ nextFrameEnd: () => next, playhead: () => 0 }, async turn => { + const step = async () => { + ran.push(name); + next += 1; + await turn(); + }; + await step(); + await step(); + await step(); + }); + }; + await Promise.all([bake("A"), bake("B")]); + + expect(ran).toEqual(["A", "B", "A", "B", "A", "B"]); + }); + it("re-sorts what's waiting when the playhead moves during a bake", async () => { const queue = new BakeQueue(); let playhead = 0; const ran: string[] = []; let finishFirst = () => {}; const first = queue.add( - at(0, 10, () => playhead), + at(0.1, () => playhead), () => { ran.push("A"); return new Promise(resolve => { @@ -63,11 +93,11 @@ describe("BakeQueue", () => { await settle(); const rest = [ queue.add( - at(10, 20, () => playhead), + at(10.1, () => playhead), async () => ran.push("B") ), queue.add( - at(20, 30, () => playhead), + at(25.1, () => playhead), async () => ran.push("C") ) ]; @@ -80,10 +110,10 @@ describe("BakeQueue", () => { 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, 10), async () => { + const failed = queue.add(at(0.1), async () => { throw new Error("bake failed"); }); - const next = queue.add(at(10, 20), async () => "baked"); + const next = queue.add(at(10.1), async () => "baked"); await expect(failed).rejects.toThrow("bake failed"); await expect(next).resolves.toBe("baked"); diff --git a/tests/html5-player.test.ts b/tests/html5-player.test.ts index 7b34258f..98e815ef 100644 --- a/tests/html5-player.test.ts +++ b/tests/html5-player.test.ts @@ -121,6 +121,8 @@ type PlayerHarness = { renderStill(seconds: number): Promise; transitionToPlayback(): Promise; transitionToFailed(): void; + bakingFrames: Array | null; + reloadAsset(): Promise; }; function createPlayer() { @@ -737,6 +739,22 @@ it("keeps a still taken mid-capture and the capture from changing each other's f expect(seeks.indexOf("still 0.1")).toBeLessThan(seeks.indexOf(`bake ${59 / 30}`)); }); +it("bakes from the playhead to the clip's end, then from a fresh page up to the playhead", async () => { + const { harness, edit, iframe, seeks } = createStillPlayer(); // 2 s at 30 fps: 60 frames + Object.assign(edit, { playbackTime: 1 }); + const loads = jest.spyOn(HTMLIFrameElement.prototype, "srcdoc", "set"); + loads.mockClear(); + + const frames = (await harness.runCapture())!; + + const order = [...Array.from({ length: 30 }, (_, i) => 30 + i), ...Array.from({ length: 30 }, (_, i) => i)]; + expect(seeks.filter(seek => seek.startsWith("bake"))).toEqual(order.map(i => `bake ${i / 30}`)); + expect(loads.mock.contexts.filter(context => context === iframe)).toHaveLength(2); + const markup = await Promise.all(frames.map(frameMarkup)); + expect(markup.map(frame => /data-t="([^"]*)"/.exec(frame)?.[1])).toEqual(Array.from({ length: 60 }, (_, i) => String(i / 30))); + expect(markup.some(frame => frame.includes("data-rewound"))).toBe(false); +}); + it("draws a frame only when the page changed, reusing the last frame's blob", async () => { const { harness } = createStillPlayer(); // 2 s at 30 fps: 60 frames const seek = jest.mocked(harness.seekHarness); @@ -841,6 +859,66 @@ it.each([ expect(markup.map(frame => /data-t="([^"]*)"/.exec(frame)?.[1])).toEqual(Array.from({ length: 60 }, (_, i) => String(i / 30))); }); +const sliceEveryFrame = () => { + let clock = 0; + jest.spyOn(performance, "now").mockImplementation(() => { + clock += 13; + return clock; + }); +}; + +const sharedInFlight = () => (Html5Player as unknown as { inFlight: { frames: number; bytes: number } }).inFlight; + +it("publishes frames and progress while later frames still draw", async () => { + const { harness, events } = createStillPlayer(); // 2 s at 30 fps: 60 frames + Object.assign(harness, { mode: "capturing" }); + sliceEveryFrame(); + const steps = () => events.emit.mock.calls.filter(([name]) => name === InternalEvent.CaptureProgressChanged).length; + let atLastSeek = { landed: 0, steps: 0 }; + const record = jest.mocked(harness.seekHarness).getMockImplementation()!; + jest.mocked(harness.seekHarness).mockImplementation((seconds, win) => { + record(seconds, win); + if (seconds === 59 / 30) atLastSeek = { landed: harness.bakingFrames!.filter(Boolean).length, steps: steps() }; + }); + + await harness.runCapture(); + + expect(atLastSeek.landed).toBeGreaterThan(30); + expect(atLastSeek.steps).toBeGreaterThan(10); +}); + +it("waits for another bake's frames to leave the window before drawing its own", async () => { + const { harness } = createStillPlayer(); + 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; + }); + + expect(await harness.runCapture()).toHaveLength(60); + expect(waits).toBeGreaterThanOrEqual(5); + expect(sharedInFlight()).toEqual({ frames: 0, bytes: 0 }); +}); + +it("releases its share of the in-flight window when disposed mid-bake", async () => { + const { player, harness } = createStillPlayer(); + const { held, releaseAll } = holdToBlob(); + const capture = harness.runCapture(); + await until(() => held.length > 0); + + player.dispose(); + releaseAll(); + await capture; + await flush(); + + expect(sharedInFlight()).toEqual({ frames: 0, bytes: 0 }); +}); + it("returns no frames when the content changes while frames are in flight", async () => { const { harness } = createStillPlayer(); jest.spyOn(Navigator.prototype, "hardwareConcurrency", "get").mockReturnValue(4); @@ -1150,8 +1228,8 @@ function holdCaptureQueue(): { drain: () => void; restore: () => void } { const gate = new Promise(resolve => { drain = resolve; }); - // Under the playhead from the start, so it runs ahead of any clip that joins after it. - held.add({ start: () => 0, end: () => Infinity, playhead: () => 0 }, () => gate); + // Ahead of the playhead and earliest, so it runs ahead of any clip that joins after it. + held.add({ nextFrameEnd: () => 0, playhead: () => -Infinity }, () => gate); Object.assign(Html5Player, { bakes: held }); return { drain, restore: () => Object.assign(Html5Player, { bakes }) }; } @@ -1282,6 +1360,46 @@ it("bakes the clip that plays first ahead of one whose page loaded first", async } }); +it("shares one in-flight window between bakes that take turns", async () => { + const a = createStillPlayer(); + const b = createStillPlayer(); + b.harness.contentHash = "other"; + [a, b].forEach(({ harness }) => Object.assign(harness, { renderedWidth: 3840, renderedHeight: 2160 })); + jest.spyOn(Navigator.prototype, "hardwareConcurrency", "get").mockReturnValue(16); + sliceEveryFrame(); + const { held, releaseAll } = holdToBlob(); + const restore = emptyCaptureQueue(); + try { + const captures = [a.harness.captureFrames(), b.harness.captureFrames()]; + await until(() => held.length >= 8); + await flush(100); + expect(held).toHaveLength(8); + + releaseAll(); + expect((await Promise.all(captures)).map(frames => frames?.length)).toEqual([60, 60]); + } finally { + restore(); + } +}); + +it("alternates overlapping clips' frames rather than baking one clip whole", async () => { + const a = createStillPlayer(); + const b = createStillPlayer(); + b.harness.contentHash = "other"; + const order: string[] = []; + a.onSeek = seek => seek.startsWith("bake") && order.push("a"); + b.onSeek = seek => seek.startsWith("bake") && order.push("b"); + sliceEveryFrame(); + const restore = emptyCaptureQueue(); + try { + await Promise.all([a.harness.captureFrames(), b.harness.captureFrames()]); + + expect(new Set(order.slice(0, 10)).size).toBe(2); + } finally { + restore(); + } +}); + it("drops a cached bake whose content changed during the lookup", async () => { const { harness, events } = createStillPlayer(); let hit: (value: unknown) => void = () => {}; @@ -1390,6 +1508,98 @@ it("keeps showing stills after one fails to mount", async () => { expect(warn).toHaveBeenCalledWith("[Html5Player] still failed:", expect.objectContaining({ message: "fit failed" })); }); +const bakedAt = (...indices: number[]) => Array.from({ length: 60 }, (_, i) => (indices.includes(i) ? new Blob([`baked ${i}`]) : undefined)); + +it("plays a frame from a bake still in progress instead of rendering a still", async () => { + const { player, harness, seeks } = createStillPlayer(); + Object.assign(player, { getPlaybackTime: () => 0.5 }); + baking(harness); + harness.beginCapture(); + Object.assign(harness, { bakingFrames: bakedAt(15), bakingScale: 0.5 }); + player.update(0, 0); + await until(stillShown(harness)); + + expect(await frameMarkup(layer(harness)[0].texture.source.blob)).toBe("baked 15"); + expect((layer(harness)[0].texture as { resolution?: number }).resolution).toBe(0.5); + expect(seeks.some(seek => seek.startsWith("still"))).toBe(false); +}); + +it("renders a still where the bake in progress hasn't reached", async () => { + const { player, harness } = createStillPlayer(); + Object.assign(player, { getPlaybackTime: () => 0.5 }); + baking(harness); + harness.beginCapture(); + Object.assign(harness, { bakingFrames: bakedAt(...Array.from({ length: 10 }, (_, i) => i)), bakingScale: 1 }); + player.update(0, 0); + await until(stillShown(harness)); + + expect(await frameMarkup(layer(harness)[0].texture.source.blob)).toContain(">t=0.5<"); +}); + +it("renders an export's still mid-bake, never a frame from the bake in progress", async () => { + const { player, harness } = createStillPlayer(); + Object.assign(player, { getPlaybackTime: () => 0.5 }); + baking(harness); + harness.beginCapture(); + Object.assign(harness, { bakingFrames: bakedAt(...Array.from({ length: 60 }, (_, i) => i)), bakingScale: 1 }); + const serialise = jest.spyOn(harness, "captureIframeAsForeignObjectSvg"); + + await player.prepareStaticRender(); + + expect(serialise).toHaveBeenCalledTimes(1); + expect(await frameMarkup((harness.contentContainer.children[0] as StaticSprite).texture.source.blob)).toContain(">t=0.5<"); + player.endStaticRender(); +}); + +it("forgets a bake's frames once the clip's content changes", async () => { + const { player, harness } = createStillPlayer(); + baking(harness); + harness.beginCapture(); + Object.assign(harness, { bakingFrames: bakedAt(0, 1, 2) }); + jest.spyOn(harness, "hashAsset").mockResolvedValue("changed"); + + await player.reloadAsset(); + + expect(harness.bakingFrames).toBeNull(); +}); + +it("forgets a failed bake's frames", () => { + const { harness } = createStillPlayer(); + Object.assign(harness, { bakingFrames: bakedAt(0, 1, 2) }); + + harness.transitionToFailed(); + + expect(harness.bakingFrames).toBeNull(); +}); + +it("keeps a sharper re-bake's frames hidden until it finishes", async () => { + const { harness } = createStillPlayer(); + Object.assign(harness, { mode: "playback", capturedFrames: [new Blob(["baked 0"])], capturedHash: "original@0.5", capturedScale: 0.5 }); + const { held, releaseAll } = holdToBlob(); + const capture = harness.runCapture(1); + await until(() => held.length > 0); + + expect(harness.bakingFrames).toBeNull(); + releaseAll(); + await capture; +}); + +it("shows a first bake's frames as they land", async () => { + const { harness } = createStillPlayer(); + baking(harness); + harness.beginCapture(); + const { held, releaseAll } = holdToBlob(); + const capture = harness.runCapture(); + await until(() => held.length > 1); + held.shift()!(); + await until(() => harness.bakingFrames?.[0] !== undefined); + + expect(harness.bakingFrames).toHaveLength(60); + expect(harness.bakingFrames!.filter(Boolean)).toHaveLength(1); + releaseAll(); + await capture; +}); + it("swaps the still for baked frames once the bake finishes", async () => { const { player, harness } = createStillPlayer(); let finish: (frames: Blob[]) => void = () => {};