diff --git a/packages/studio/src/player/lib/thumbnailSourcePool.ts b/packages/studio/src/player/lib/thumbnailSourcePool.ts index 2769289719..650f6ff40f 100644 --- a/packages/studio/src/player/lib/thumbnailSourcePool.ts +++ b/packages/studio/src/player/lib/thumbnailSourcePool.ts @@ -19,6 +19,7 @@ export interface ThumbnailSource extends OpenedSource { release(failed: boolean): void; /** The job waited for the reader to open: the open finishing was progress the job saw. */ waitedForOpen: boolean; + progress: { framesFromAnyJob: number }; } interface PooledSource { @@ -28,6 +29,7 @@ interface PooledSource { ready: boolean; opened: Promise; users: number; + progress: { framesFromAnyJob: number }; idleTimer: ReturnType | null; disposed: boolean; } @@ -100,6 +102,7 @@ function pooledSource(source: string, version: string | undefined): PooledSource ready: false, opened: open(source, () => entry), users: 0, + progress: { framesFromAnyJob: 0 }, idleTimer: null, disposed: false, }; @@ -150,7 +153,8 @@ export async function acquireThumbnailSource( } }; try { - return { ...(await untilAborted(entry.opened, signal)), release, waitedForOpen }; + const { progress } = entry; + return { ...(await untilAborted(entry.opened, signal)), release, waitedForOpen, progress }; } catch (error) { // Timed out still waiting: the open made no progress in the job's whole run. release(!isAbortError(error) || timedOut(signal)); diff --git a/packages/studio/src/player/lib/thumbnailVideoDecoder.test.ts b/packages/studio/src/player/lib/thumbnailVideoDecoder.test.ts index f3cc28845b..8b8d94ee5d 100644 --- a/packages/studio/src/player/lib/thumbnailVideoDecoder.test.ts +++ b/packages/studio/src/player/lib/thumbnailVideoDecoder.test.ts @@ -766,6 +766,32 @@ describe("decodeVideoThumbnail's shared source", () => { expect(input.getPrimaryVideoTrack).toHaveBeenCalledTimes(1); }); + it("keeps the reader when a clip times out while another clip decodes frames from it", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + recordDecodes([]); + await decodeVideoThumbnail(clip(0), signal); + canvasesAtTimestamps.mockImplementationOnce(async function* () { + await new Promise(() => {}); + yield null; + }); + canvasesAtTimestamps.mockImplementationOnce(async function* ( + timestamps: AsyncIterable, + ) { + await timestamps[Symbol.asyncIterator]().next(); + yield { canvas: document.createElement("canvas") }; + await new Promise(() => {}); + }); + const controller = new AbortController(); + void decodeVideoThumbnail(clip(6), controller.signal).catch(() => {}); + void decodeVideoThumbnail(clip(12), new AbortController().signal).catch(() => {}); + await vi.waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(4)); + controller.abort(timedOut()); + + expect(warn).not.toHaveBeenCalled(); + await decodeVideoThumbnail(clip(18), signal); + expect(input.getPrimaryVideoTrack).toHaveBeenCalledTimes(1); + }); + it("fails a job that times out waiting for its source to open, and the next clip opens afresh", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const stalled = untilDisposed(); diff --git a/packages/studio/src/player/lib/thumbnailVideoDecoder.ts b/packages/studio/src/player/lib/thumbnailVideoDecoder.ts index ce580d3c6c..eef88cc523 100644 --- a/packages/studio/src/player/lib/thumbnailVideoDecoder.ts +++ b/packages/studio/src/player/lib/thumbnailVideoDecoder.ts @@ -79,7 +79,6 @@ interface DecodedResources { held: HeldFrame[]; canvases: Set; releasedFrameBytes: number; - decodedByJob: number; } interface ThumbnailCanvasSink { @@ -175,6 +174,7 @@ async function decodeFrames( slots: { slot: number; key: string; decodedAt?: number }[], signal: AbortSignal, resources: DecodedResources, + progress: ThumbnailSource["progress"], ): Promise { let next = 0; for await (const wrapped of sink.canvasesAtTimestamps(times)) { @@ -185,7 +185,7 @@ async function decodeFrames( const blob = await canvasToBlob(wrapped.canvas); throwIfAborted(signal); const frame = shareFrame(target.key, target.decodedAt, URL.createObjectURL(blob), blob.size); - resources.decodedByJob += 1; + progress.framesFromAnyJob += 1; resources.urls[target.slot] = frame.url; resources.held.push({ key: target.key, frame }); } @@ -232,7 +232,6 @@ export async function decodeVideoThumbnail( held: [], canvases: new Set(), releasedFrameBytes: budgets.sharedFrameCacheBytes, - decodedByJob: 0, }; const entry = sourceInfos.get(request.source); const known = entry?.version === request.contentVersion ? entry?.info : undefined; @@ -271,8 +270,9 @@ async function decodeMissingFrames( if (timedOut(signal)) reportMediaFetchFailure(timeoutWithoutProgress()); throw error; } + const framesBefore = source.progress.framesFromAnyJob; const releaseOnAbort = () => { - const madeProgress = source.waitedForOpen || resources.decodedByJob > 0; + const madeProgress = source.waitedForOpen || source.progress.framesFromAnyJob > framesBefore; const failed = timedOut(signal) && !madeProgress; if (failed) reportMediaFetchFailure(timeoutWithoutProgress()); source.release(failed); @@ -307,7 +307,8 @@ async function decodeMissingFrames( fit: request.fit ?? "cover", poolSize: 1, }); - await decodeFrames(sink, decodeTimesAtNearbyKeyframes(), slots, signal, resources); + const times = decodeTimesAtNearbyKeyframes(); + await decodeFrames(sink, times, slots, signal, resources, source.progress); } const result = loadedResult(resources, info.aspect, budgets, timestamps.length); source.release(false);