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, measureLoudness, packetDurationMs, parseLoudnormOutput, parseProbeOutput, probeMedia, renderPoster, runFfmpeg, } from "./ffmpeg.js"; import type { TestClips } from "./media-test-support.js"; import { createTestClips } from "./media-test-support.js"; import { planLoudnessFix } from "./video-edit-plan.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 the stream's average bitrate when the container records it", () => { const probe = parseProbeOutput( JSON.stringify({ format: { duration: "3" }, streams: [ stream({ width: 1080, height: 1920, avg_frame_rate: "30/1", bit_rate: "522994" }), ], }), ); expect(probe.video?.bitRateKbps).toBe(523); }); 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("loudness parsing", () => { it("reads loudnorm's JSON block from the end of the log", () => { const log = [ "Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'file:out.mp4':", "[Parsed_loudnorm_0 @ 0x5555] ", "{", '\t"input_i" : "-17.42",', '\t"input_tp" : "-3.10",', '\t"input_lra" : "5.20",', '\t"input_thresh" : "-27.71",', '\t"output_i" : "-14.02",', '\t"normalization_type" : "dynamic",', '\t"target_offset" : "0.02"', "}", ].join("\n"); expect(parseLoudnormOutput(log)).toEqual({ integratedLufs: -17.42, truePeakDb: -3.1, rangeLu: 5.2, thresholdLufs: -27.71, offsetLu: 0.02, }); }); it("reads digital silence as minus infinity and refuses a log without numbers", () => { const silent = parseLoudnormOutput( '{"input_i":"-inf","input_tp":"-inf","input_lra":"0.00","input_thresh":"-70.00","target_offset":"inf"}', ); expect(silent?.integratedLufs).toBe(Number.NEGATIVE_INFINITY); expect(parseLoudnormOutput("Conversion failed!")).toBeNull(); expect(parseLoudnormOutput('{"input_i":"-14.0"}')).toBeNull(); }); }); 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("measures loudness and brings it to the target with a linear second pass", async () => { const target = { integratedLufs: -14, truePeakDb: -1 }; const before = await measureLoudness(clips.landscape, target, { mimeType: "video/mp4" }); // The generated tone sits well below the streaming target. expect(before.integratedLufs).toBeLessThan(-18); const fixed = join(clips.dir, "loudness-fixed.mp4"); await runFfmpeg( planLoudnessFix({ inputPath: clips.landscape, outputPath: fixed, durationMs: 3000, target, measured: before, }), ); const after = await measureLoudness(fixed, target, { mimeType: "video/mp4" }); expect(Math.abs(after.integratedLufs - target.integratedLufs)).toBeLessThan(1); const probe = await probeMedia(fixed, { mimeType: "video/mp4" }); expect(probe.video).toMatchObject({ codec: "h264", width: 320, height: 180 }); expect(Math.abs(probe.durationMs - 3000)).toBeLessThan(50); }); 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 }); }); });