diff --git a/packages/engine/src/utils/ffprobe.test.ts b/packages/engine/src/utils/ffprobe.test.ts index 7d3a6678..a9f3959e 100644 --- a/packages/engine/src/utils/ffprobe.test.ts +++ b/packages/engine/src/utils/ffprobe.test.ts @@ -1,6 +1,7 @@ +import { EventEmitter } from "events"; import { readFileSync } from "fs"; import { resolve } from "path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { extractMediaMetadata, extractPngMetadataFromBuffer } from "./ffprobe.js"; function crc32(buf: Buffer): number { @@ -107,3 +108,135 @@ describe("extractPngMetadataFromBuffer", () => { expect(extractPngMetadataFromBuffer(fixture)?.colorSpace?.colorTransfer).toBe("smpte2084"); }); }); + +interface SpawnCall { + command: string; + args: readonly string[]; +} + +interface FakeProc extends EventEmitter { + stdout: EventEmitter; + stderr: EventEmitter; +} + +type SpawnOutcome = + | { kind: "missing" } + | { kind: "error"; message: string; code?: string } + | { kind: "exit"; code: number; stdout?: string; stderr?: string }; + +function createSpawnSpy(outcomes: SpawnOutcome[]): { + spawn: (command: string, args: readonly string[]) => FakeProc; + calls: SpawnCall[]; +} { + const calls: SpawnCall[] = []; + let invocation = 0; + const spawn = (command: string, args: readonly string[]): FakeProc => { + calls.push({ command, args }); + const outcome = outcomes[invocation] ?? outcomes[outcomes.length - 1]; + invocation += 1; + + const proc = new EventEmitter() as FakeProc; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + + process.nextTick(() => { + if (!outcome) return; + if (outcome.kind === "missing") { + const err = new Error("spawn ffprobe ENOENT") as NodeJS.ErrnoException; + err.code = "ENOENT"; + proc.emit("error", err); + return; + } + if (outcome.kind === "error") { + const err = new Error(outcome.message) as NodeJS.ErrnoException; + if (outcome.code) err.code = outcome.code; + proc.emit("error", err); + return; + } + if (outcome.stdout) proc.stdout.emit("data", Buffer.from(outcome.stdout)); + if (outcome.stderr) proc.stderr.emit("data", Buffer.from(outcome.stderr)); + proc.emit("close", outcome.code); + }); + + return proc; + }; + return { spawn, calls }; +} + +describe("ffprobe missing-binary fallback", () => { + afterEach(() => { + vi.resetModules(); + vi.doUnmock("child_process"); + }); + + it("extractMediaMetadata falls back to PNG cICP metadata when ffprobe is missing", async () => { + const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]); + vi.resetModules(); + vi.doMock("child_process", () => ({ spawn })); + + const { extractMediaMetadata: extractMediaMetadataMocked } = await import("./ffprobe.js"); + const fixture = resolve( + __dirname, + "../../../producer/tests/hdr-regression/src/hdr-photo-pq.png", + ); + const meta = await extractMediaMetadataMocked(fixture); + + expect(calls.length).toBe(1); + expect(calls[0]?.command).toBe("ffprobe"); + expect(meta.videoCodec).toBe("png"); + expect(meta.durationSeconds).toBe(0); + expect(meta.fps).toBe(0); + expect(meta.hasAudio).toBe(false); + expect(meta.isVFR).toBe(false); + expect(meta.colorSpace?.colorTransfer).toBe("smpte2084"); + expect(meta.colorSpace?.colorPrimaries).toBe("bt2020"); + }); + + it("extractMediaMetadata rethrows ffprobe-missing error for non-image files without fallback", async () => { + const { spawn } = createSpawnSpy([{ kind: "missing" }]); + vi.resetModules(); + vi.doMock("child_process", () => ({ spawn })); + + const { extractMediaMetadata: extractMediaMetadataMocked } = await import("./ffprobe.js"); + + await expect(extractMediaMetadataMocked("/tmp/no-such-video.mp4")).rejects.toThrow(/ffprobe/); + }); + + it("extractAudioMetadata surfaces a ffprobe-missing error verbatim", async () => { + const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]); + vi.resetModules(); + vi.doMock("child_process", () => ({ spawn })); + + const { extractAudioMetadata } = await import("./ffprobe.js"); + + await expect(extractAudioMetadata("/tmp/no-such-audio.wav")).rejects.toThrow( + /ffprobe not found/, + ); + expect(calls.length).toBe(1); + expect(calls[0]?.command).toBe("ffprobe"); + }); + + it("analyzeKeyframeIntervals surfaces a ffprobe-missing error verbatim", async () => { + const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]); + vi.resetModules(); + vi.doMock("child_process", () => ({ spawn })); + + const { analyzeKeyframeIntervals } = await import("./ffprobe.js"); + + await expect(analyzeKeyframeIntervals("/tmp/no-such-video.mp4")).rejects.toThrow( + /ffprobe not found/, + ); + expect(calls.length).toBe(1); + expect(calls[0]?.command).toBe("ffprobe"); + }); + + it("ffprobe-missing error message includes install hint", async () => { + const { spawn } = createSpawnSpy([{ kind: "missing" }]); + vi.resetModules(); + vi.doMock("child_process", () => ({ spawn })); + + const { extractAudioMetadata } = await import("./ffprobe.js"); + + await expect(extractAudioMetadata("/tmp/example.mp3")).rejects.toThrow(/install FFmpeg/i); + }); +});