Files
hyperframes/packages/studio/src/player/lib/thumbnailVideoDecoder.test.ts
T
Miguel ÁngelandCodex 96861cbafc perf(studio-server): coordinate cancelable thumbnail generation (#2720)
* perf(studio): schedule adaptive timeline thumbnails

* perf(studio): bound thumbnail decoding resources

* perf(studio): virtualize timeline thumbnail media

* perf(studio): prioritize timeline thumbnail work

* perf(studio-server): coordinate cancelable thumbnail generation

---------

Co-authored-by: Codex <codex@local>
2026-08-05 20:41:35 -07:00

128 lines
4.4 KiB
TypeScript

// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { decodeVideoThumbnail, videoThumbnailTimestamps } from "./thumbnailVideoDecoder";
const dispose = vi.fn();
const canvasesAtTimestamps = vi.fn();
const input = {
getPrimaryVideoTrack: vi.fn(),
dispose,
};
vi.mock("mediabunny", () => ({
ALL_FORMATS: {},
UrlSource: class {
constructor(readonly url: string) {}
},
Input: class {
getPrimaryVideoTrack = input.getPrimaryVideoTrack;
dispose = input.dispose;
},
CanvasSink: class {
canvasesAtTimestamps = canvasesAtTimestamps;
},
}));
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(URL, "createObjectURL").mockReturnValueOnce("blob:one").mockReturnValueOnce("blob:two");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
HTMLCanvasElement.prototype.toBlob = function toBlob(callback) {
callback(new Blob(["frame"], { type: "image/jpeg" }));
};
input.getPrimaryVideoTrack.mockResolvedValue({
getDisplayWidth: vi.fn(async () => 1080),
getDisplayHeight: vi.fn(async () => 1920),
getDurationFromMetadata: vi.fn(async () => 10),
});
});
describe("videoThumbnailTimestamps", () => {
it("uses the midpoint for a poster and sorted sparse points for a strip", () => {
expect(videoThumbnailTimestamps(2, 6, 1)).toEqual([5]);
expect(videoThumbnailTimestamps(2, 6, 4)).toEqual([2, 4, 6, 8]);
});
it("clamps invalid source ranges", () => {
expect(videoThumbnailTimestamps(-2, Number.NaN, 0)).toEqual([0]);
expect(videoThumbnailTimestamps(2, 8, Number.NaN)).toEqual([6]);
});
});
describe("decodeVideoThumbnail", () => {
it("extracts sparse frames, returns object URLs, and disposes once", async () => {
const canvas = document.createElement("canvas");
canvasesAtTimestamps.mockImplementation(async function* (timestamps: number[]) {
expect(timestamps).toEqual([2, 8]);
yield { canvas, timestamp: 2, duration: 1 };
yield { canvas, timestamp: 8, duration: 1 };
});
const result = await decodeVideoThumbnail(
{ source: "/clip.mp4", sourceStart: 2, sourceRangeDuration: 6, frameCount: 2 },
new AbortController().signal,
);
expect(result.value).toEqual({
kind: "filmstrip",
urls: ["blob:one", "blob:two"],
aspect: 9 / 16,
});
result.dispose?.();
result.dispose?.();
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2);
expect(dispose).toHaveBeenCalledTimes(1);
});
it("releases input and degrades when the source has no video track", async () => {
input.getPrimaryVideoTrack.mockResolvedValue(null);
await expect(
decodeVideoThumbnail({ source: "/audio.mp3", frameCount: 1 }, new AbortController().signal),
).rejects.toThrow("no decodable video track");
expect(dispose).toHaveBeenCalledTimes(1);
});
it("revokes partial results when cancellation lands during extraction", async () => {
const controller = new AbortController();
const canvas = document.createElement("canvas");
canvasesAtTimestamps.mockImplementation(async function* () {
yield { canvas, timestamp: 1, duration: 1 };
controller.abort();
yield { canvas, timestamp: 2, duration: 1 };
});
await expect(
decodeVideoThumbnail({ source: "/clip.mp4", frameCount: 2 }, controller.signal),
).rejects.toMatchObject({ name: "AbortError" });
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1);
expect(dispose).toHaveBeenCalledTimes(1);
});
it("stops after metadata cancellation before occupying the decoder", async () => {
const controller = new AbortController();
let resolveWidth!: (width: number) => void;
const width = new Promise<number>((resolve) => {
resolveWidth = resolve;
});
const getDisplayWidth = vi.fn(() => width);
const getDurationFromMetadata = vi.fn(async () => 10);
input.getPrimaryVideoTrack.mockResolvedValue({
getDisplayWidth,
getDisplayHeight: vi.fn(async () => 1920),
getDurationFromMetadata,
});
const decoding = decodeVideoThumbnail(
{ source: "/clip.mp4", frameCount: 1 },
controller.signal,
);
await vi.waitFor(() => expect(getDisplayWidth).toHaveBeenCalledOnce());
controller.abort();
resolveWidth(1080);
await expect(decoding).rejects.toMatchObject({ name: "AbortError" });
expect(getDurationFromMetadata).not.toHaveBeenCalled();
expect(canvasesAtTimestamps).not.toHaveBeenCalled();
expect(dispose).toHaveBeenCalledTimes(1);
});
});