mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 01:56:04 +00:00
* 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>
128 lines
4.4 KiB
TypeScript
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);
|
|
});
|
|
});
|