You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

249 lines
8.2 KiB

import { describe, it, expect, vi, beforeEach } from "vitest";
import { applySpeedCurveAsync } from "../useApplySpeedCurve";
// Mock mediabunny with proper class constructors
const mockCanEncodeVideo = vi.fn();
const mockGetVideoTracks = vi.fn();
const mockDispose = vi.fn();
const mockOutputStart = vi.fn().mockResolvedValue(undefined);
const mockOutputFinalize = vi.fn().mockResolvedValue(undefined);
const mockVideoSourceAdd = vi.fn().mockResolvedValue(undefined);
const mockVideoSourceClose = vi.fn().mockResolvedValue(undefined);
const mockAddVideoTrack = vi.fn();
const mockBufferTargetBuffer = new ArrayBuffer(100);
vi.mock("mediabunny", () => {
class BlobSourceMock { constructor() {} }
class InputMock {
getVideoTracks: any;
computeDuration: any;
dispose: any;
constructor() {
this.getVideoTracks = (...args: any[]) => mockGetVideoTracks(...args);
this.computeDuration = vi.fn().mockResolvedValue(5.0);
this.dispose = (...args: any[]) => mockDispose(...args);
}
}
class OutputMock {
addVideoTrack: any;
start: any;
finalize: any;
constructor() {
this.addVideoTrack = (...args: any[]) => mockAddVideoTrack(...args);
this.start = (...args: any[]) => mockOutputStart(...args);
this.finalize = (...args: any[]) => mockOutputFinalize(...args);
}
}
class VideoSampleSinkMock {
samplesAtTimestamps: any;
constructor() {
this.samplesAtTimestamps = vi.fn().mockReturnValue({
[Symbol.asyncIterator]: async function* () {
for (let i = 0; i < 5; i++) {
yield {
timestamp: i * 0.033,
clone: vi.fn().mockReturnValue({
setTimestamp: vi.fn(),
setDuration: vi.fn(),
close: vi.fn(),
}),
close: vi.fn(),
};
}
},
});
}
}
class VideoSampleSourceMock {
add: any;
close: any;
constructor() {
this.add = (...args: any[]) => mockVideoSourceAdd(...args);
this.close = (...args: any[]) => mockVideoSourceClose(...args);
}
}
class BufferTargetMock {
get buffer() { return mockBufferTargetBuffer; }
}
class Mp4OutputFormatMock { constructor() {} }
return {
BlobSource: BlobSourceMock,
Input: InputMock,
Output: OutputMock,
VideoSampleSink: VideoSampleSinkMock,
VideoSampleSource: VideoSampleSourceMock,
BufferTarget: BufferTargetMock,
Mp4OutputFormat: Mp4OutputFormatMock,
ALL_FORMATS: [],
canEncodeVideo: (...args: unknown[]) => mockCanEncodeVideo(...args),
};
});
vi.mock("@/lib/video-encoding", () => ({
createAvcEncodingConfig: vi.fn(() => ({})),
AVC_LEVEL_4_0: "avc1.640028",
AVC_LEVEL_5_1: "avc1.640033",
}));
// Mock document.createElement('video') for getVideoDimensions
const mockVideoElement = {
preload: "",
src: "",
videoWidth: 1920,
videoHeight: 1080,
onloadedmetadata: null as (() => void) | null,
onerror: null as (() => void) | null,
};
const origCreateElement = document.createElement.bind(document);
describe("useApplySpeedCurve", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
mockCanEncodeVideo.mockResolvedValue(true);
const mockVideoTrack = {
displayWidth: 1920,
displayHeight: 1080,
codedWidth: 1920,
codedHeight: 1080,
rotation: 0,
computeDuration: vi.fn().mockResolvedValue(5.0),
computePacketStats: vi.fn().mockResolvedValue({
averageBitrate: 8_000_000,
averagePacketRate: 30,
}),
};
mockGetVideoTracks.mockResolvedValue([mockVideoTrack]);
vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
if (tag === "video") {
const video = { ...mockVideoElement };
setTimeout(() => video.onloadedmetadata?.(), 0);
return video as unknown as HTMLVideoElement;
}
return origCreateElement(tag);
});
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock-url");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
});
it("returns null when no video tracks found", async () => {
mockGetVideoTracks.mockResolvedValue([]);
const result = await applySpeedCurveAsync(new Blob(["video"], { type: "video/mp4" }));
expect(result).toBeNull();
});
it("reports progress milestones", async () => {
const onProgress = vi.fn();
await applySpeedCurveAsync(new Blob(["video"], { type: "video/mp4" }), 5, 1.5, onProgress);
expect(onProgress).toHaveBeenCalled();
const messages = onProgress.mock.calls.map((c: any[]) => c[0].message);
expect(messages.some((m: string) => m.includes("Creating input"))).toBe(true);
});
it("accepts string easing function name", async () => {
const result = await applySpeedCurveAsync(
new Blob(["video"], { type: "video/mp4" }), 5, 1.5, undefined, "easeInOutCubic"
);
expect(result).toBeInstanceOf(Blob);
});
it("accepts function easing", async () => {
const linear = (t: number) => t;
const result = await applySpeedCurveAsync(
new Blob(["video"], { type: "video/mp4" }), 5, 1.5, undefined, linear
);
expect(result).toBeInstanceOf(Blob);
});
it("uses default parameters", async () => {
const result = await applySpeedCurveAsync(new Blob(["video"], { type: "video/mp4" }));
expect(result).toBeInstanceOf(Blob);
});
it("returns video/mp4 Blob on success", async () => {
const result = await applySpeedCurveAsync(new Blob(["video"], { type: "video/mp4" }));
expect(result).toBeInstanceOf(Blob);
expect(result!.type).toBe("video/mp4");
});
it("uses dimension fallback when getVideoDimensions fails", async () => {
vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
if (tag === "video") {
const video = { ...mockVideoElement };
setTimeout(() => video.onerror?.(), 0);
return video as unknown as HTMLVideoElement;
}
return origCreateElement(tag);
});
// Should still succeed with fallback dimensions (logged warning)
const result = await applySpeedCurveAsync(new Blob(["video"], { type: "video/mp4" }));
expect(result).toBeInstanceOf(Blob);
});
it("cascades through encoder tiers", async () => {
mockCanEncodeVideo
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
const result = await applySpeedCurveAsync(new Blob(["video"], { type: "video/mp4" }));
expect(result).toBeInstanceOf(Blob);
expect(mockCanEncodeVideo.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it("returns null and reports error when all tiers fail", async () => {
mockCanEncodeVideo.mockResolvedValue(false);
const onProgress = vi.fn();
const result = await applySpeedCurveAsync(
new Blob(["video"], { type: "video/mp4" }), 5, 1.5, onProgress
);
expect(result).toBeNull();
const lastCall = onProgress.mock.calls[onProgress.mock.calls.length - 1];
expect(lastCall[0].status).toBe("error");
});
it("handles errors gracefully and returns null", async () => {
mockGetVideoTracks.mockRejectedValue(new Error("Track error"));
const result = await applySpeedCurveAsync(
new Blob(["video"], { type: "video/mp4" }), 5, 1.5
);
expect(result).toBeNull();
});
it("disposes input even on error", async () => {
// Make getVideoTracks succeed but then fail later
const mockTrack = {
displayWidth: 1920,
displayHeight: 1080,
codedWidth: 1920,
codedHeight: 1080,
rotation: 0,
computeDuration: vi.fn().mockRejectedValue(new Error("fail")),
computePacketStats: vi.fn().mockRejectedValue(new Error("fail")),
};
mockGetVideoTracks.mockResolvedValue([mockTrack]);
mockCanEncodeVideo.mockResolvedValue(false);
await applySpeedCurveAsync(new Blob(["video"], { type: "video/mp4" }));
expect(mockDispose).toHaveBeenCalled();
});
it("accepts custom bitrate parameter", async () => {
const result = await applySpeedCurveAsync(
new Blob(["video"], { type: "video/mp4" }), 5, 1.5, undefined, "easeInOutSine", 16_000_000
);
expect(result).toBeInstanceOf(Blob);
});
});