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.
 
 

253 lines
8.8 KiB

import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
import { checkEncoderSupport, stitchVideosAsync } from "../useStitchVideos";
// ---- AudioBuffer polyfill for jsdom ----
class MockAudioBuffer {
readonly sampleRate: number;
readonly length: number;
readonly duration: number;
readonly numberOfChannels: number;
private channels: Float32Array[];
constructor(options: { length: number; numberOfChannels: number; sampleRate: number }) {
this.length = options.length;
this.numberOfChannels = options.numberOfChannels;
this.sampleRate = options.sampleRate;
this.duration = options.length / options.sampleRate;
this.channels = Array.from({ length: options.numberOfChannels }, () => new Float32Array(options.length));
}
getChannelData(channel: number): Float32Array { return this.channels[channel]; }
copyFromChannel() {}
copyToChannel() {}
}
beforeAll(() => {
if (typeof globalThis.AudioBuffer === "undefined") {
(globalThis as any).AudioBuffer = MockAudioBuffer;
}
});
// 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 mockAddAudioTrack = vi.fn();
const mockAudioSourceAdd = vi.fn().mockResolvedValue(undefined);
const mockAudioSourceClose = vi.fn().mockResolvedValue(undefined);
const mockGetFirstEncodableAudioCodec = 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(1.0);
this.dispose = (...args: any[]) => mockDispose(...args);
}
}
class OutputMock {
addVideoTrack: any;
addAudioTrack: any;
start: any;
finalize: any;
constructor() {
this.addVideoTrack = (...args: any[]) => mockAddVideoTrack(...args);
this.addAudioTrack = (...args: any[]) => mockAddAudioTrack(...args);
this.start = (...args: any[]) => mockOutputStart(...args);
this.finalize = (...args: any[]) => mockOutputFinalize(...args);
}
}
class VideoSampleSinkMock {
samples: any;
constructor() {
this.samples = vi.fn().mockReturnValue({
[Symbol.asyncIterator]: async function* () {
yield { timestamp: 0, setTimestamp: vi.fn(), setDuration: 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 AudioBufferSourceMock {
add: any;
close: any;
constructor() {
this.add = (...args: any[]) => mockAudioSourceAdd(...args);
this.close = (...args: any[]) => mockAudioSourceClose(...args);
}
}
class BufferTargetMock {
get buffer() { return mockBufferTargetBuffer; }
}
class Mp4OutputFormatMock { constructor() {} }
return {
BlobSource: BlobSourceMock,
Input: InputMock,
Output: OutputMock,
VideoSampleSink: VideoSampleSinkMock,
VideoSampleSource: VideoSampleSourceMock,
AudioBufferSource: AudioBufferSourceMock,
BufferTarget: BufferTargetMock,
Mp4OutputFormat: Mp4OutputFormatMock,
ALL_FORMATS: [],
canEncodeVideo: (...args: unknown[]) => mockCanEncodeVideo(...args),
getFirstEncodableAudioCodec: (...args: unknown[]) => mockGetFirstEncodableAudioCodec(...args),
};
});
vi.mock("@/lib/video-encoding", () => ({
createAvcEncodingConfig: vi.fn(() => ({})),
AVC_LEVEL_4_0: "avc1.420028",
AVC_LEVEL_5_1: "avc1.640033",
}));
describe("useStitchVideos", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
});
describe("checkEncoderSupport", () => {
it("returns true when canEncodeVideo resolves true", async () => {
mockCanEncodeVideo.mockResolvedValue(true);
expect(await checkEncoderSupport()).toBe(true);
});
it("returns false when canEncodeVideo resolves false", async () => {
mockCanEncodeVideo.mockResolvedValue(false);
expect(await checkEncoderSupport()).toBe(false);
});
it("returns false when canEncodeVideo throws", async () => {
mockCanEncodeVideo.mockRejectedValue(new Error("Not supported"));
expect(await checkEncoderSupport()).toBe(false);
});
it("checks with correct parameters", async () => {
mockCanEncodeVideo.mockResolvedValue(true);
await checkEncoderSupport();
expect(mockCanEncodeVideo).toHaveBeenCalledWith("avc", {
width: 1920,
height: 1080,
bitrate: 8_000_000,
});
});
});
describe("stitchVideosAsync", () => {
const createMockVideoTrack = (width = 1920, height = 1080, rotation = 0) => ({
displayWidth: width,
displayHeight: height,
codedWidth: width,
codedHeight: height,
rotation,
computePacketStats: vi.fn().mockResolvedValue({ averageBitrate: 8_000_000 }),
});
beforeEach(() => {
mockCanEncodeVideo.mockResolvedValue(true);
mockGetVideoTracks.mockResolvedValue([createMockVideoTrack()]);
});
it("throws when given empty array", async () => {
await expect(stitchVideosAsync([])).rejects.toThrow("No videos to stitch");
});
it("reports progress updates", async () => {
const onProgress = vi.fn();
const blob = new Blob(["video1"], { type: "video/mp4" });
await stitchVideosAsync([blob], null, onProgress);
expect(onProgress).toHaveBeenCalled();
const statuses = onProgress.mock.calls.map((c: any[]) => c[0].status);
expect(statuses).toContain("processing");
expect(statuses).toContain("complete");
});
it("returns a video/mp4 Blob", async () => {
const blob = new Blob(["video1"], { type: "video/mp4" });
const result = await stitchVideosAsync([blob]);
expect(result).toBeInstanceOf(Blob);
expect(result.type).toBe("video/mp4");
});
it("processes multiple video blobs", async () => {
const blob1 = new Blob(["video1"], { type: "video/mp4" });
const blob2 = new Blob(["video2"], { type: "video/mp4" });
const result = await stitchVideosAsync([blob1, blob2]);
expect(result).toBeInstanceOf(Blob);
});
it("reports error status on failure", async () => {
mockCanEncodeVideo.mockResolvedValue(false);
const onProgress = vi.fn();
const blob = new Blob(["video"], { type: "video/mp4" });
await expect(stitchVideosAsync([blob], null, onProgress)).rejects.toThrow();
const lastCall = onProgress.mock.calls[onProgress.mock.calls.length - 1];
if (lastCall) {
expect(lastCall[0].status).toBe("error");
}
});
it("handles audio data when provided", async () => {
mockGetFirstEncodableAudioCodec.mockResolvedValue("aac");
const videoBlob = new Blob(["video"], { type: "video/mp4" });
const audioBuffer = new MockAudioBuffer({ length: 44100, numberOfChannels: 1, sampleRate: 44100 });
const audioData = { buffer: audioBuffer as unknown as AudioBuffer, duration: 1.0 };
await stitchVideosAsync([videoBlob], audioData);
expect(mockGetFirstEncodableAudioCodec).toHaveBeenCalled();
expect(mockAddAudioTrack).toHaveBeenCalled();
});
it("continues without audio when no codec is supported", async () => {
mockGetFirstEncodableAudioCodec.mockResolvedValue(null);
const videoBlob = new Blob(["video"], { type: "video/mp4" });
const audioBuffer = new MockAudioBuffer({ length: 44100, numberOfChannels: 1, sampleRate: 44100 });
const result = await stitchVideosAsync([videoBlob], {
buffer: audioBuffer as unknown as AudioBuffer,
duration: 1.0,
});
expect(result).toBeInstanceOf(Blob);
expect(mockAddAudioTrack).not.toHaveBeenCalled();
});
it("probes video metadata before encoding", async () => {
const blob = new Blob(["video"], { type: "video/mp4" });
await stitchVideosAsync([blob]);
expect(mockGetVideoTracks).toHaveBeenCalled();
});
it("checks encoder support for target resolution", async () => {
const blob = new Blob(["video"], { type: "video/mp4" });
await stitchVideosAsync([blob]);
expect(mockCanEncodeVideo).toHaveBeenCalled();
});
});
});