From 1b2fb117698043230b23dc20cd96f4ccbaac28d6 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 10:33:45 -0400 Subject: [PATCH 1/2] fix(engine): audio encodes on FFmpeg 4.4, which has no setts duration option The segmented AAC encode ended its stream with setts=duration, added in FFmpeg 5.0, so every render with audio failed on Ubuntu 22.04's FFmpeg 4.4. An FFmpeg without the option now encodes the audio in one pass, which ends the stream at the duration itself. --- .../engine/src/services/aacEncode.test.ts | 35 ++++++++++++++++++- packages/engine/src/services/aacEncode.ts | 34 ++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/packages/engine/src/services/aacEncode.test.ts b/packages/engine/src/services/aacEncode.test.ts index b7dcd25ead8..bd7102f1038 100644 --- a/packages/engine/src/services/aacEncode.test.ts +++ b/packages/engine/src/services/aacEncode.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, describe, expect, it, vi } from "vitest"; -import { getFfmpegBinary, getFfprobeBinary } from "../utils/ffmpegBinaries.js"; +import { FFMPEG_PATH_ENV, getFfmpegBinary, getFfprobeBinary } from "../utils/ffmpegBinaries.js"; import { aacSegmentCount, encodeAac, splitAdtsFrames } from "./aacEncode.js"; import * as ffmpegRunner from "../utils/runFfmpeg.js"; @@ -196,6 +196,39 @@ describe.skipIf(!HAS_FFMPEG)("encodeAac", () => { expect(streamEnd(split)).toBe(streamEnd(single)); }, 120_000); + it.skipIf(process.platform === "win32")( + "ends the stream at the duration on an FFmpeg whose setts has no duration option", + async () => { + const source = join(dir, "old-ffmpeg.wav"); + ffmpeg(["-f", "lavfi", "-i", "sine=f=440:d=2.5:r=48000", "-ac", "2", "-y", source]); + const wrapper = join(dir, "ffmpeg-without-setts-duration"); + writeFileSync( + wrapper, + [ + "#!/bin/sh", + 'case "$*" in *"-h bsf=setts"*) exit 0;; esac', + `case "$*" in *"setts=duration"*) echo "Option 'duration' not found" >&2; exit 1;; esac`, + `exec "${getFfmpegBinary()}" "$@"`, + ].join("\n"), + { mode: 0o755 }, + ); + const reference = join(dir, "old-ffmpeg-reference.m4a"); + expect((await encodeAac(source, reference, 2.5, { timeout: 30_000 })).success).toBe(true); + + const saved = process.env[FFMPEG_PATH_ENV]; + process.env[FFMPEG_PATH_ENV] = wrapper; + const coded = join(dir, "old-ffmpeg.m4a"); + try { + expect((await encodeAac(source, coded, 2.5, { timeout: 30_000 })).success).toBe(true); + } finally { + if (saved === undefined) delete process.env[FFMPEG_PATH_ENV]; + else process.env[FFMPEG_PATH_ENV] = saved; + } + expect(streamEnd(coded)).toBe(streamEnd(reference)); + }, + 60_000, + ); + it("keeps a sharp onset at its level when the audio opens quietly, encode after encode", async () => { const growthDb: number[] = []; for (const f0 of [250, 306, 360]) { diff --git a/packages/engine/src/services/aacEncode.ts b/packages/engine/src/services/aacEncode.ts index e4133148421..7919bbe2e64 100644 --- a/packages/engine/src/services/aacEncode.ts +++ b/packages/engine/src/services/aacEncode.ts @@ -3,8 +3,10 @@ * so long audio is encoded as frame-aligned segments in parallel and their frames joined. */ +import { execFile } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { getFfmpegBinary } from "../utils/ffmpegBinaries.js"; import { runFfmpeg, type RunFfmpegResult } from "../utils/runFfmpeg.js"; const SAMPLE_RATE = 48_000; @@ -55,6 +57,35 @@ function planSegments(totalDuration: number, count: number) { }); } +const settsDurationSupport = new Map>(); + +/** FFmpeg before 5.0 (Ubuntu 22.04 ships 4.4) has no `duration` option on the setts filter. */ +export function supportsSettsDuration(binary = getFfmpegBinary()): Promise { + let supported = settsDurationSupport.get(binary); + if (!supported) { + supported = new Promise((resolve) => { + execFile(binary, ["-hide_banner", "-h", "bsf=setts"], (error, stdout) => { + // A binary that cannot even print help fails the encode with its own error, not this one. + resolve(error !== null || stdout.includes("duration")); + }); + }); + settsDurationSupport.set(binary, supported); + } + return supported; +} + +function encodeInOnePass( + input: string, + outputPath: string, + { signal, timeout, filter }: { signal?: AbortSignal; timeout: number; filter?: string }, +): Promise { + const filters = [filter, `aresample=${SAMPLE_RATE}`].filter(Boolean).join(","); + return runFfmpeg(["-i", input, "-af", filters, ...AAC_ARGS, "-y", outputPath], { + signal, + timeout, + }); +} + /** `input`, after `filter`, is audio of exactly `totalDuration`. */ export async function encodeAac( input: string, @@ -67,6 +98,9 @@ export async function encodeAac( segments: count = aacSegmentCount(totalDuration), }: { signal?: AbortSignal; timeout: number; filter?: string; segments?: number }, ): Promise { + if (!(await supportsSettsDuration())) { + return encodeInOnePass(input, outputPath, { signal, timeout, filter }); + } const dir = mkdtempSync(join(dirname(outputPath), ".aac-segments-")); try { const segments = planSegments(totalDuration, count).map((segment, k) => ({ From 7f15da858bdce0554573a56b0f96bccda62637c8 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 11:44:51 -0400 Subject: [PATCH 2/2] fix(engine): keep the setts capability check private to the AAC encoder --- packages/engine/src/services/aacEncode.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/engine/src/services/aacEncode.ts b/packages/engine/src/services/aacEncode.ts index 7919bbe2e64..f700615fcc2 100644 --- a/packages/engine/src/services/aacEncode.ts +++ b/packages/engine/src/services/aacEncode.ts @@ -60,7 +60,7 @@ function planSegments(totalDuration: number, count: number) { const settsDurationSupport = new Map>(); /** FFmpeg before 5.0 (Ubuntu 22.04 ships 4.4) has no `duration` option on the setts filter. */ -export function supportsSettsDuration(binary = getFfmpegBinary()): Promise { +function supportsSettsDuration(binary = getFfmpegBinary()): Promise { let supported = settsDurationSupport.get(binary); if (!supported) { supported = new Promise((resolve) => {