Files
ConvertX/tests/converters/ffmpeg.test.ts
T
2026-10-13 08:41:14 +02:00

227 lines
7.0 KiB
TypeScript

import { afterAll, afterEach, beforeEach, expect, test } from "bun:test";
import { convert } from "../../src/converters/ffmpeg";
import type { ExecFileOptions } from "node:child_process";
import { captureConsole } from "./helpers/converters";
let calls: string[][] = [];
let commands: string[] = [];
let lastOptions: ExecFileOptions | undefined;
function mockExecFile(
cmd: string,
args: string[],
options: ExecFileOptions,
callback: (err: Error | null, stdout: string, stderr: string) => void,
) {
commands.push(cmd);
calls.push(args);
lastOptions = options;
if (args.includes("fail.mov")) {
callback(new Error("mock failure"), "", "Fake stderr: fail");
} else {
callback(null, "Fake stdout", "");
}
}
// Both variables are read on every call and may be set in the developer's shell
// (mise.toml sets FFMPEG_OUTPUT_ARGS), so isolate them and restore them afterwards.
const originalEnv = {
FFMPEG_ARGS: process.env.FFMPEG_ARGS,
FFMPEG_OUTPUT_ARGS: process.env.FFMPEG_OUTPUT_ARGS,
};
let output: ReturnType<typeof captureConsole>;
beforeEach(() => {
calls = [];
commands = [];
lastOptions = undefined;
delete process.env.FFMPEG_ARGS;
delete process.env.FFMPEG_OUTPUT_ARGS;
output = captureConsole();
});
afterEach(() => {
output.restore();
});
afterAll(() => {
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
const ICO_FILTER = "scale='min(256,iw)':min'(256,ih)':force_original_aspect_ratio=decrease";
test("converts a normal file", async () => {
const result = await convert("in.mp4", "mp4", "avi", "out.avi", undefined, mockExecFile);
expect(result).toBe("Done");
expect(commands).toEqual(["ffmpeg"]);
expect(calls).toEqual([["-i", "in.mp4", "out.avi"]]);
expect(output.logs).toEqual(["stdout: Fake stdout"]);
});
test("adds resize for ico output", async () => {
const result = await convert("in.png", "png", "ico", "out.ico", undefined, mockExecFile);
expect(result).toBe("Done: resized to 256x256");
expect(calls[0]).toEqual(["-i", "in.png", "-filter:v", ICO_FILTER, "out.ico"]);
});
test.each([
["av1.mp4", "libaom-av1"],
["av1.mkv", "libaom-av1"],
["h264.mp4", "libx264"],
["h264.mkv", "libx264"],
["h265.mp4", "libx265"],
["h265.mkv", "libx265"],
["h266.mp4", "libx266"],
["h266.mkv", "libx266"],
])("uses the right video codec for %s", async (convertTo, codec) => {
const result = await convert("in.mkv", "mkv", convertTo, "out.video", undefined, mockExecFile);
expect(result).toBe("Done");
expect(calls[0]).toEqual(["-i", "in.mkv", "-c:v", codec, "out.video"]);
});
test.each(["m4a", "mp3", "flac", "ogg"])("keeps embedded cover art for %s output", async (ext) => {
await convert("in.wav", "wav", ext, `out.${ext}`, undefined, mockExecFile);
expect(calls[0]).toEqual([
"-i",
"in.wav",
"-map",
"0:a",
"-map",
"0:v:disp:attached_pic?",
"-c:v",
"copy",
"-disposition:v",
"attached_pic",
`out.${ext}`,
]);
});
test.each(["wav", "aac"])("drops video streams for %s output", async (ext) => {
await convert("in.mp3", "mp3", ext, `out.${ext}`, undefined, mockExecFile);
expect(calls[0]).toEqual(["-i", "in.mp3", "-vn", `out.${ext}`]);
});
test("places the cover art args after FFMPEG_OUTPUT_ARGS", async () => {
process.env.FFMPEG_OUTPUT_ARGS = "-b:a 192k";
await convert("in.wav", "wav", "mp3", "out.mp3", undefined, mockExecFile);
expect(calls[0]?.slice(0, 5)).toEqual(["-i", "in.wav", "-b:a", "192k", "-map"]);
});
test("does not force a codec for an unknown codec prefix", async () => {
await convert("in.mkv", "mkv", "vp9.webm", "out.webm", undefined, mockExecFile);
expect(calls[0]).toEqual(["-i", "in.mkv", "out.webm"]);
});
test("respects FFMPEG_ARGS", async () => {
process.env.FFMPEG_ARGS = "-hide_banner -y";
await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile);
expect(calls[0]).toEqual(["-hide_banner", "-y", "-i", "input.mov", "output.mp4"]);
});
test("places FFMPEG_OUTPUT_ARGS after the input", async () => {
process.env.FFMPEG_OUTPUT_ARGS = "-preset veryfast -threads 2";
await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile);
expect(calls[0]).toEqual([
"-i",
"input.mov",
"-preset",
"veryfast",
"-threads",
"2",
"output.mp4",
]);
});
test("orders global args, input, output args, codec args and target", async () => {
process.env.FFMPEG_ARGS = "-hwaccel vaapi";
process.env.FFMPEG_OUTPUT_ARGS = "-preset veryfast";
await convert("in.mkv", "mkv", "h265.mp4", "out.mp4", undefined, mockExecFile);
expect(calls[0]).toEqual([
"-hwaccel",
"vaapi",
"-i",
"in.mkv",
"-preset",
"veryfast",
"-c:v",
"libx265",
"out.mp4",
]);
});
test("splits FFMPEG_ARGS on any whitespace", async () => {
process.env.FFMPEG_ARGS = "-hide_banner\t-y -nostdin";
await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile);
expect(calls[0]?.slice(0, 3)).toEqual(["-hide_banner", "-y", "-nostdin"]);
});
// BUG: split(/\s+/) keeps empty strings for leading/trailing whitespace
// (" -y ".split(/\s+/) is ["", "-y", ""]), so ffmpeg receives "" as an argument
// and fails. Remove `.failing` once src/converters/ffmpeg.ts drops empty entries.
test.failing("ignores leading and trailing whitespace in FFMPEG_ARGS", async () => {
process.env.FFMPEG_ARGS = " -y ";
process.env.FFMPEG_OUTPUT_ARGS = " -preset veryfast ";
await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile);
expect(calls[0]).toEqual(["-y", "-i", "input.mov", "-preset", "veryfast", "output.mp4"]);
});
test("fails on exec error", async () => {
await expect(
convert("fail.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile),
).rejects.toBe("error: Error: mock failure");
expect(output.errors).toEqual(["stderr: Fake stderr: fail"]);
});
test("logs stderr when execFile returns only stderr and no error", async () => {
// Mock execFile to call back with no error, no stdout, but with stderr
const mockExecFileStderrOnly = (
_cmd: string,
_args: string[],
_options: ExecFileOptions,
callback: (err: Error | null, stdout: string, stderr: string) => void,
) => {
callback(null, "", "Only stderr output");
};
await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFileStderrOnly);
expect(output.errors).toEqual(["stderr: Only stderr output"]);
expect(output.logs).toEqual([]);
});
test("passes a maxBuffer above the 1 MB default so long conversions don't overflow stderr (#565)", async () => {
await convert("in.mkv", "mkv", "h264.mp4", "out.mp4", undefined, mockExecFile);
// execFile's default maxBuffer is 1 MB; ffmpeg's progress output on a long
// encode exceeds it and the conversion fails with "stderr maxBuffer length
// exceeded". Lock in the raised buffer (must match FFMPEG_MAX_BUFFER in
// ffmpeg.ts) so a regression to a smaller-but-still-over-1-MB value is caught.
expect(lastOptions?.maxBuffer).toBe(1024 * 1024 * 64);
});