Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 134 additions & 1 deletion packages/engine/src/utils/ffprobe.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
});
});
Loading