fix(render): normalize local AAC duration before mux (#2472)

* fix(render): normalize local AAC duration before mux

* style(render): apply repository formatter

* fix(render): count AAC packets for duration normalization

Older FFmpeg versions estimate raw ADTS duration from bitrate and can undercount variable-bitrate audio, causing the normalizer to append a false silence tail. Derive the mixed AAC duration from packet count and sample rate instead.

* fix(render): isolate normalized audio temp path

* fix(engine): centralize AAC packet duration

* test(producer): refresh AAC duration golden
This commit is contained in:
Miguel Ángel
2026-07-15 10:07:07 -04:00
committed by GitHub
parent b9be0b2625
commit 1895286189
6 changed files with 201 additions and 6 deletions
+52
View File
@@ -203,6 +203,58 @@ describe("ffprobe missing-binary fallback", () => {
expect(calls[0]?.command).toBe(resolve("/tools/ffprobe.exe"));
});
it.each([
{ name: "non-AAC metadata", codec: "mp3", packets: undefined, expected: 1.25, calls: 1 },
{ name: "valid AAC packet count", codec: "aac", packets: "783", expected: 16.704, calls: 2 },
{
name: "missing AAC packet count",
codec: "aac",
packets: undefined,
expected: 1.25,
calls: 2,
},
{ name: "zero AAC packet count", codec: "aac", packets: "0", expected: 1.25, calls: 2 },
{
name: "invalid AAC packet count",
codec: "aac",
packets: "invalid",
expected: 1.25,
calls: 2,
},
])(
"derives audio duration for $name",
async ({ codec, packets, expected, calls: expectedCalls }) => {
const outcomes: SpawnOutcome[] = [
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [
{ codec_type: "audio", codec_name: codec, sample_rate: "48000", channels: 2 },
],
format: { duration: "1.25", bit_rate: "128000" },
}),
},
];
if (codec === "aac") {
outcomes.push({
kind: "exit",
code: 0,
stdout: JSON.stringify({ streams: [{ nb_read_packets: packets }], format: {} }),
});
}
const { spawn, calls } = createSpawnSpy(outcomes);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractAudioMetadata } = await import("./ffprobe.js");
const meta = await extractAudioMetadata(`/tmp/${codec}-${packets ?? "none"}.audio`);
expect(meta.durationSeconds).toBeCloseTo(expected, 6);
expect(calls).toHaveLength(expectedCalls);
},
);
it("extractMediaMetadata falls back to PNG cICP metadata when ffprobe is missing", async () => {
const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]);
hidePathBinaries();
+27 -3
View File
@@ -53,6 +53,8 @@ function parseProbeJson(stdout: string): FFProbeOutput {
const videoMetadataCache = new Map<string, Promise<VideoMetadata>>();
const audioMetadataCache = new Map<string, Promise<AudioMetadata>>();
// FFmpeg's built-in AAC encoder emits AAC-LC, which has 1024 samples per packet.
const AAC_LC_SAMPLES_PER_PACKET = 1024;
export interface VideoColorSpace {
/** Color transfer characteristics, e.g. "bt709", "smpte2084", "arib-std-b67" */
@@ -98,6 +100,7 @@ interface FFProbeStream {
height?: number;
duration?: string;
nb_frames?: string;
nb_read_packets?: string;
pix_fmt?: string;
r_frame_rate?: string;
avg_frame_rate?: string;
@@ -366,15 +369,36 @@ export async function extractAudioMetadata(filePath: string): Promise<AudioMetad
const audioStream = output.streams.find((s) => s.codec_type === "audio");
if (!audioStream) throw new Error("[FFmpeg] No audio stream found");
const durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
let durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
const streamDuration = audioStream.duration ? parseFloat(audioStream.duration) : undefined;
const sampleRate = audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100;
const audioCodec = audioStream.codec_name || "unknown";
if (audioCodec === "aac" && sampleRate > 0) {
const packetStdout = await runFfprobe([
"-v",
"quiet",
"-select_streams",
"a:0",
"-count_packets",
"-show_entries",
"stream=nb_read_packets",
"-print_format",
"json",
filePath,
]);
const packetOutput = parseProbeJson(packetStdout);
const packetCount = Number(packetOutput.streams[0]?.nb_read_packets);
if (Number.isFinite(packetCount) && packetCount > 0) {
durationSeconds = (packetCount * AAC_LC_SAMPLES_PER_PACKET) / sampleRate;
}
}
return {
durationSeconds,
streamDurationSeconds: streamDuration && streamDuration > 0 ? streamDuration : undefined,
sampleRate: audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100,
sampleRate,
channels: audioStream.channels || 2,
audioCodec: audioStream.codec_name || "unknown",
audioCodec,
bitrate: output.format.bit_rate ? parseInt(output.format.bit_rate) : undefined,
};
})();