diff --git a/src/converters/ffmpeg.ts b/src/converters/ffmpeg.ts index 8f263851..47451d42 100644 --- a/src/converters/ffmpeg.ts +++ b/src/converters/ffmpeg.ts @@ -1,5 +1,22 @@ -import { execFile as execFileOriginal } from "node:child_process"; -import { ExecFileFn } from "./types"; +import { + execFile as execFileOriginal, + type ChildProcess, + type ExecFileOptions, +} from "node:child_process"; + +// ffmpeg streams continuous progress to stderr, so a long conversion overflows +// execFile's 1 MB default maxBuffer and fails with "stderr maxBuffer length +// exceeded" (issue #565). Raise it well above that. The options object must be +// passed before the callback: execFile ignores an options argument placed after +// the callback, which is why the shared ExecFileFn type cannot carry it. +const FFMPEG_MAX_BUFFER = 1024 * 1024 * 64; // 64 MB + +type FfmpegExecFile = ( + cmd: string, + args: string[], + options: ExecFileOptions, + callback: (err: Error | null, stdout: string, stderr: string) => void, +) => ChildProcess | void; // This could be done dynamically by running `ffmpeg -formats` and parsing the output export const properties = { @@ -694,7 +711,7 @@ export async function convert( convertTo: string, targetPath: string, options?: unknown, - execFile: ExecFileFn = execFileOriginal, // to make it mockable + execFile: FfmpegExecFile = execFileOriginal as FfmpegExecFile, // to make it mockable ): Promise { let extraArgs: string[] = []; let message = "Done"; @@ -739,6 +756,7 @@ export async function convert( execFile( "ffmpeg", [...ffmpegArgs, "-i", filePath, ...ffmpegOutputArgs, ...extraArgs, targetPath], + { maxBuffer: FFMPEG_MAX_BUFFER }, (error, stdout, stderr) => { if (error) { reject(`error: ${error}`); diff --git a/tests/converters/ffmpeg.test.ts b/tests/converters/ffmpeg.test.ts index 6f0afc60..bd845c31 100644 --- a/tests/converters/ffmpeg.test.ts +++ b/tests/converters/ffmpeg.test.ts @@ -1,14 +1,18 @@ import { beforeEach, expect, test } from "bun:test"; import { convert } from "../../src/converters/ffmpeg"; +import type { ExecFileOptions } from "node:child_process"; let calls: string[][] = []; +let lastOptions: ExecFileOptions | undefined; function mockExecFile( _cmd: string, args: string[], + options: ExecFileOptions, callback: (err: Error | null, stdout: string, stderr: string) => void, ) { calls.push(args); + lastOptions = options; if (args.includes("fail.mov")) { callback(new Error("mock failure"), "", "Fake stderr: fail"); } else { @@ -18,6 +22,7 @@ function mockExecFile( beforeEach(() => { calls = []; + lastOptions = undefined; delete process.env.FFMPEG_ARGS; }); @@ -168,6 +173,7 @@ test("logs stderr when execFile returns only stderr and no error", async () => { const mockExecFileStderrOnly = ( _cmd: string, _args: string[], + _options: ExecFileOptions, callback: (err: Error | null, stdout: string, stderr: string) => void, ) => { callback(null, "", "Only stderr output"); @@ -179,3 +185,13 @@ test("logs stderr when execFile returns only stderr and no error", async () => { expect(loggedMessage).toBe("stderr: Only stderr output"); }); + +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); +});