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
@@ -416,7 +416,8 @@ function parseFrameRate(rate: string): { fpsNum: number; fpsDen: number } {
}
async function defaultProbeAudioInfo(audioPath: string): Promise<AudioProbeInfo> {
// extractAudioMetadata is the shared ffprobe wrapper (caches results).
// The shared ffprobe wrapper derives AAC-LC duration from packet count so
// every consumer sees the same VBR-safe metadata.
const metadata: AudioMetadata = await extractAudioMetadata(audioPath);
return {
durationSeconds: metadata.durationSeconds,
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AssembleStageInput } from "./assembleStage.js";
const { muxVideoWithAudioMock, padOrTrimAudioMock } = vi.hoisted(() => ({
muxVideoWithAudioMock: vi.fn(),
padOrTrimAudioMock: vi.fn(),
}));
vi.mock("@hyperframes/engine", () => ({
applyFaststart: vi.fn(),
muxVideoWithAudio: muxVideoWithAudioMock,
}));
vi.mock("../audioPadTrim.js", () => ({
padOrTrimAudioToVideoFrameCount: padOrTrimAudioMock,
}));
vi.mock("../shared.js", () => ({
updateJobStatus: vi.fn(),
}));
import { runAssembleStage } from "./assembleStage.js";
function makeInput(overrides: Partial<AssembleStageInput> = {}): AssembleStageInput {
return {
job: {
id: "aac-duration-parity",
config: { fps: { num: 30, den: 1 }, quality: "draft" },
status: "queued",
progress: 0,
currentStage: "queued",
createdAt: new Date(0),
duration: 1,
},
videoOnlyPath: "/tmp/video-only.mp4",
audioOutputPath: "/tmp/audio.aac",
outputPath: "/tmp/output.mp4",
hasAudio: true,
abortSignal: undefined,
assertNotAborted: () => {},
...overrides,
};
}
describe("runAssembleStage audio duration parity", () => {
beforeEach(() => {
muxVideoWithAudioMock.mockReset();
padOrTrimAudioMock.mockReset();
muxVideoWithAudioMock.mockResolvedValue({ success: true });
padOrTrimAudioMock.mockResolvedValue({
success: true,
outputPath: "/tmp/audio.duration-normalized.aac",
targetDurationSeconds: 1,
sourceDurationSeconds: 1.024,
operation: "trim",
});
});
it("normalizes mixed AAC to the encoded video frame duration before muxing", async () => {
await runAssembleStage(makeInput());
expect(padOrTrimAudioMock).toHaveBeenCalledWith({
videoPath: "/tmp/video-only.mp4",
audioPath: "/tmp/audio.aac",
outputPath: "/tmp/audio.duration-normalized.aac",
});
expect(muxVideoWithAudioMock).toHaveBeenCalledWith(
"/tmp/video-only.mp4",
"/tmp/audio.duration-normalized.aac",
"/tmp/output.mp4",
undefined,
{ audioCodec: "aac" },
{ num: 30, den: 1 },
);
});
it("uses a distinct AAC normalization path when the mixed-audio extension differs", async () => {
await runAssembleStage(makeInput({ audioOutputPath: "/tmp/audio.m4a" }));
expect(padOrTrimAudioMock).toHaveBeenCalledWith({
videoPath: "/tmp/video-only.mp4",
audioPath: "/tmp/audio.m4a",
outputPath: "/tmp/audio.duration-normalized.aac",
});
});
it("fails instead of muxing an unnormalized AAC tail", async () => {
padOrTrimAudioMock.mockResolvedValue({
success: false,
outputPath: "/tmp/audio.duration-normalized.aac",
targetDurationSeconds: 1,
sourceDurationSeconds: 1.024,
operation: "trim",
error: "ffmpeg trim failed",
});
await expect(runAssembleStage(makeInput())).rejects.toThrow(
"Audio duration normalization failed: ffmpeg trim failed",
);
expect(muxVideoWithAudioMock).not.toHaveBeenCalled();
});
});
@@ -17,7 +17,9 @@
*/
import { applyFaststart, muxVideoWithAudio } from "@hyperframes/engine";
import { extname } from "node:path";
import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js";
import { padOrTrimAudioToVideoFrameCount } from "../audioPadTrim.js";
import { updateJobStatus } from "../shared.js";
export interface AssembleStageInput {
@@ -55,9 +57,23 @@ export async function runAssembleStage(input: AssembleStageInput): Promise<Assem
updateJobStatus(job, "assembling", "Assembling final video", 90, onProgress);
if (hasAudio) {
const audioExtension = extname(audioOutputPath);
const audioStem = audioExtension
? audioOutputPath.slice(0, -audioExtension.length)
: audioOutputPath;
const normalizedAudioPath = `${audioStem}.duration-normalized.aac`;
const normalizeResult = await padOrTrimAudioToVideoFrameCount({
videoPath: videoOnlyPath,
audioPath: audioOutputPath,
outputPath: normalizedAudioPath,
});
assertNotAborted();
if (!normalizeResult.success) {
throw new Error(`Audio duration normalization failed: ${normalizeResult.error}`);
}
const muxResult = await muxVideoWithAudio(
videoOnlyPath,
audioOutputPath,
normalizeResult.outputPath,
outputPath,
abortSignal,
{ audioCodec: "aac" },