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
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);
|
|
});
|
|
});
|
|
|