import { existsSync } from "node:fs"; import { rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { FfmpegProgress } from "./ffmpeg.js"; import { createFfmpegProgressParser, detectFfmpeg, extractAudio, FfmpegError, ffmpegInputArgs, ffmpegSeconds, packetDurationMs, parseProbeOutput, probeMedia, renderPoster, runFfmpeg, } from "./ffmpeg.js"; import type { TestClips } from "./media-test-support.js"; import { createTestClips } from "./media-test-support.js"; const ffmpeg = await detectFfmpeg(); describe("ffmpeg progress", () => { it("reports each block, tolerates split chunks and N/A times, and marks the end", () => { const updates: FfmpegProgress[] = []; const feed = createFfmpegProgressParser((update) => updates.push(update)); feed("frame=0\nout_time_us=N/A\ntotal_size=48\nprogress=cont"); feed("inue\nframe=75\nout_time_us=2995"); feed("374\nout_time_ms=2995374\ntotal_size=42300\nprogress=end\n"); expect(updates).toEqual([ { totalSize: 48, done: false }, { outTimeUs: 2_995_374, totalSize: 42_300, done: true }, ]); }); }); describe("probe parsing", () => { const stream = (fields: Record) => ({ codec_type: "video", codec_name: "h264", ...fields, }); it("swaps the size of rotated video and reads frame rate and audio", () => { const probe = parseProbeOutput( JSON.stringify({ format: { duration: "12.345", format_name: "mov,mp4,m4a,3gp,3g2,mj2" }, streams: [ stream({ width: 1920, height: 1080, avg_frame_rate: "30000/1001", sample_aspect_ratio: "1:1", side_data_list: [{ side_data_type: "Display Matrix", rotation: -90 }], }), { codec_type: "audio", codec_name: "aac", sample_rate: "48000", channels: 2 }, ], }), ); expect(probe).toEqual({ durationMs: 12_345, video: { codec: "h264", width: 1080, height: 1920, rotation: 270, fps: 30000 / 1001, sampleAspectRatio: 1, }, audio: { codec: "aac", sampleRate: 48_000, channels: 2 }, hasAudio: true, }); }); it("reads legacy rotate tags, skips cover art and falls back to stream durations", () => { const probe = parseProbeOutput( JSON.stringify({ format: { format_name: "mov" }, streams: [ stream({ width: 600, height: 600, disposition: { attached_pic: 1 } }), stream({ width: 640, height: 480, avg_frame_rate: "0/0", r_frame_rate: "90000/1", sample_aspect_ratio: "4:3", tags: { rotate: "90" }, duration: "4.5", }), ], }), ); expect(probe.durationMs).toBe(4500); expect(probe.hasAudio).toBe(false); expect(probe.video).toMatchObject({ width: 480, height: 640, rotation: 90, fps: 0, sampleAspectRatio: 3 / 4, }); }); it("reads the end of the last packet when the container has no duration", () => { expect(packetDurationMs("0.000000,0.040000\n2.960000,0.040000\nN/A,N/A\n")).toBe(3000); expect(packetDurationMs("")).toBe(0); }); }); describe("ffmpeg arguments", () => { it("opens inputs through the file protocol with the demuxer of their MIME type", () => { expect( ffmpegInputArgs("/a/b.webm", { mimeType: "video/webm", startSeconds: 2, durationSeconds: 1.5, }), ).toEqual([ ...["-ss", "2", "-t", "1.5", "-protocol_whitelist", "file", "-f", "matroska"], ...["-i", "file:/a/b.webm"], ]); expect(ffmpegInputArgs("/a/b", { mimeType: "application/octet-stream" })).toEqual([ ...["-protocol_whitelist", "file", "-i", "file:/a/b"], ]); expect(ffmpegSeconds(1e-7)).toBe("0"); expect(ffmpegSeconds(12.34567)).toBe("12.346"); }); }); // CI runners may not have ffmpeg; these run wherever it is installed. describe.skipIf(!ffmpeg)("ffmpeg integration", () => { let clips: TestClips; beforeAll(async () => { clips = await createTestClips(); }); afterAll(async () => { if (clips) await rm(clips.dir, { recursive: true, force: true }); }); it("probes duration, size, frame rate and audio", async () => { const probe = await probeMedia(clips.landscape, { mimeType: "video/mp4" }); expect(probe.durationMs).toBeGreaterThanOrEqual(2900); expect(probe.durationMs).toBeLessThanOrEqual(3100); expect(probe.video).toMatchObject({ codec: "h264", width: 320, height: 180, fps: 25 }); expect(probe.hasAudio).toBe(true); const silent = await probeMedia(clips.silentPortrait, { mimeType: "video/mp4" }); expect(silent.hasAudio).toBe(false); expect(silent.video).toMatchObject({ width: 180, height: 320 }); }); it("refuses a playlist disguised as an MP4 instead of following its URLs", async () => { const playlist = join(clips.dir, "playlist.mp4"); await writeFile( playlist, `#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:1.0,\nfile:${clips.srt}\n#EXT-X-ENDLIST\n`, ); await expect(probeMedia(playlist, { mimeType: "video/mp4" })).rejects.toThrow(FfmpegError); }); it("reports progress up to 1 for a run", async () => { const output = join(clips.dir, "progress.mp4"); const fractions: number[] = []; await runFfmpeg( [ ...ffmpegInputArgs(clips.landscape, { mimeType: "video/mp4" }), ...[ "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", "-f", "mp4", `file:${output}`, ], ], { durationMs: 3000, onProgress: (fraction) => fractions.push(fraction) }, ); expect(fractions.at(-1)).toBe(1); expect(fractions).toEqual([...fractions].sort((a, b) => a - b)); expect(existsSync(output)).toBe(true); }); it("fails with the last stderr line, without directory paths, and removes outputs", async () => { const output = join(clips.dir, "broken.mp4"); await writeFile(output, "partial"); const failure = runFfmpeg( [ ...ffmpegInputArgs(join(clips.dir, "missing.mp4"), { mimeType: "video/mp4" }), `file:${output}`, ], { outputs: [output] }, ); const error = await failure.catch((caught: unknown) => caught); expect(error).toBeInstanceOf(FfmpegError); expect((error as Error).message).toMatch(/^ffmpeg failed: .*missing\.mp4.*No such file/); expect((error as Error).message).not.toContain(clips.dir); expect(existsSync(output)).toBe(false); }); it("kills ffmpeg on abort and removes the partial output", async () => { const output = join(clips.dir, "aborted.mp4"); const controller = new AbortController(); const run = runFfmpeg( [ ...["-re", "-f", "lavfi", "-i", "testsrc=size=320x180:rate=25"], ...["-t", "60", "-c:v", "libx264", "-preset", "ultrafast", "-f", "mp4", `file:${output}`], ], { signal: controller.signal, durationMs: 60_000, onProgress: () => controller.abort(new Error("cancelled")), outputs: [output], }, ); await expect(run).rejects.toThrow("cancelled"); expect(existsSync(output)).toBe(false); }); it("stops when the output outgrows its cap", async () => { const output = join(clips.dir, "too-large.mp4"); const run = runFfmpeg( [ ...["-f", "lavfi", "-i", "testsrc=size=640x360:rate=25:duration=20"], ...["-c:v", "libx264", "-preset", "ultrafast", "-crf", "0", "-f", "mp4", `file:${output}`], ], { maxOutputBytes: 64 * 1024, outputs: [output] }, ); await expect(run).rejects.toThrow("The result would exceed 0 MB."); expect(existsSync(output)).toBe(false); }); it("extracts 16 kHz mono WAV for transcription, optionally a range", async () => { const output = join(clips.dir, "speech.wav"); await extractAudio(clips.landscape, output, { mimeType: "video/mp4", startMs: 1000 }); const probe = await probeMedia(output, { mimeType: "audio/wav" }); expect(probe.audio).toEqual({ codec: "pcm_s16le", sampleRate: 16_000, channels: 1 }); expect(probe.video).toBeUndefined(); expect(Math.abs(probe.durationMs - 2000)).toBeLessThan(100); await expect( extractAudio(clips.silentPortrait, join(clips.dir, "none.wav"), { mimeType: "video/mp4" }), ).rejects.toThrow("The file has no audio track."); }); it("renders a poster frame, falling back to the first frame past the end", async () => { const poster = join(clips.dir, "poster.jpg"); await renderPoster(clips.landscape, poster, { mimeType: "video/mp4", atMs: 60_000 }); const probe = await probeMedia(poster); expect(probe.video).toMatchObject({ codec: "mjpeg", width: 320, height: 180 }); }); });