fix(engine): support current FFmpeg filter scripts (#2324)

This commit is contained in:
Miguel Ángel
2026-07-13 16:52:15 -04:00
committed by GitHub
parent 81a6edcb67
commit 3df59fc0a4
2 changed files with 81 additions and 9 deletions
@@ -4,7 +4,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
// The mix filter graph is written to a temp file and passed via
// -filter_complex_script (not inlined via -filter_complex) so the command
// a file-valued filter option (not inlined via -filter_complex) so the command
// line doesn't scale with track count — production code deletes that file
// the moment the (real) ffmpeg process exits. The mock captures each call's
// filter content synchronously, while the file still exists, into an
@@ -15,7 +15,9 @@ const { runFfmpegMock, capturedFilterScripts } = vi.hoisted(() => {
return {
capturedFilterScripts,
runFfmpegMock: vi.fn(async (args: string[]) => {
const idx = args.indexOf("-filter_complex_script");
const legacyIdx = args.indexOf("-filter_complex_script");
const currentIdx = args.indexOf("-/filter_complex");
const idx = legacyIdx >= 0 ? legacyIdx : currentIdx;
if (idx >= 0) {
const { readFileSync } = await import("node:fs");
capturedFilterScripts.push(readFileSync(args[idx + 1], "utf8"));
@@ -348,6 +350,57 @@ describe("processCompositionAudio", () => {
expect((filter?.match(/atrim=/g) ?? []).length).toBe(trackCount);
});
it("retries with the current file-valued filter option when a nightly removes the legacy alias", async () => {
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
tempDirs.push(baseDir, workDir);
writeFileSync(join(baseDir, "voice.wav"), "stub");
runFfmpegMock
.mockImplementationOnce(async () => {
capturedFilterScripts.push("");
return { success: true, durationMs: 1, stderr: "", exitCode: 0 };
})
.mockImplementationOnce(async () => {
capturedFilterScripts.push("");
return {
success: false,
durationMs: 1,
stderr: "Unrecognized option 'filter_complex_script'.\nError splitting the argument list",
exitCode: 8,
};
});
const result = await processCompositionAudio(
[
{
id: "voice",
src: "voice.wav",
start: 0,
end: 2,
mediaStart: 0,
layer: 0,
volume: 1,
type: "audio",
},
],
baseDir,
workDir,
join(baseDir, "out.m4a"),
2,
);
expect(result.success).toBe(true);
expect(runFfmpegMock).toHaveBeenCalledTimes(3);
const legacyArgs = runFfmpegMock.mock.calls[1]?.[0] as string[];
const currentArgs = runFfmpegMock.mock.calls[2]?.[0] as string[];
expect(legacyArgs).toContain("-filter_complex_script");
expect(currentArgs).toContain("-/filter_complex");
expect(currentArgs).not.toContain("-filter_complex_script");
expect(capturedFilterScripts[2]).toContain("amix=inputs=1");
});
it("prepares percent-encoded non-Latin audio srcs from decoded filesystem paths", async () => {
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
+26 -7
View File
@@ -32,6 +32,13 @@ function escapeExpressionCommas(expression: string): string {
return expression.replace(/\\/g, "\\\\").replace(/,/g, "\\,");
}
function legacyFilterScriptOptionIsUnsupported(stderr: string): boolean {
return (
/filter_complex_script/i.test(stderr) &&
/(?:unrecognized option|option (?:was )?not found)/i.test(stderr)
);
}
/**
* Upper bound on volume-automation keyframes folded into the FFmpeg `volume`
* expression. The expression nests one `if(lt(...))` per keyframe, and
@@ -406,10 +413,14 @@ async function mixAudioTracks(
// argument scale linearly with track count until it exceeds the OS
// command-line length limit — spawn ENAMETOOLONG, seen in practice at 146
// tracks — even though every individual filter segment is short. FFmpeg's
// own `-filter_complex_script <file>` reads the same graph from disk
// instead, sidestepping the argv limit for the one component of this
// command line that actually grows with the composition.
const runMix = (ignoreAutomation: boolean) => {
// file-valued filter options read the same graph from disk instead,
// sidestepping the argv limit for the one component of this command line
// that actually grows with the composition. FFmpeg deprecated
// `-filter_complex_script` in favour of `-/filter_complex`, then removed the
// alias from nightly builds; older stable builds do not understand the new
// spelling. Prefer the legacy spelling for broad compatibility and retry
// only when FFmpeg explicitly says that option is unavailable.
const runMix = async (ignoreAutomation: boolean) => {
const inputs: string[] = [];
tracks.forEach((track) => inputs.push("-i", track.srcPath));
const scriptDir = mkdtempSync(join(outputDir, ".filter-complex-"));
@@ -435,9 +446,17 @@ async function mixAudioTracks(
"-y",
outputPath,
];
return runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout }).finally(() =>
rmSync(scriptDir, { recursive: true, force: true }),
);
try {
const legacyResult = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
if (legacyResult.success || !legacyFilterScriptOptionIsUnsupported(legacyResult.stderr)) {
return legacyResult;
}
const currentArgs = [...args];
currentArgs[currentArgs.indexOf("-filter_complex_script")] = "-/filter_complex";
return await runFfmpeg(currentArgs, { signal, timeout: ffmpegProcessTimeout });
} finally {
rmSync(scriptDir, { recursive: true, force: true });
}
};
let result = await runMix(false);