fix(render): trim AAC packet padding exactly

This commit is contained in:
Miguel Ángel
2026-07-25 21:14:17 +00:00
parent 8c50770684
commit 113a4985b5
5 changed files with 44 additions and 24 deletions
@@ -291,7 +291,7 @@ export async function assemble(
// ── 3. Audio: pad-or-trim then mux ──────────────────────────────────── // ── 3. Audio: pad-or-trim then mux ────────────────────────────────────
let audioForMux: string | null = null; let audioForMux: string | null = null;
if (audioPath !== null && existsSync(audioPath)) { if (audioPath !== null && existsSync(audioPath)) {
const paddedAudioPath = join(workDir, "audio-padded.aac"); const paddedAudioPath = join(workDir, "audio-padded.m4a");
const padTrimResult = await padOrTrimAudioToVideoFrameCount({ const padTrimResult = await padOrTrimAudioToVideoFrameCount({
videoPath: postConcatPath, videoPath: postConcatPath,
audioPath, audioPath,
@@ -81,15 +81,20 @@ describe("buildPadTrimAudioArgs", () => {
expect(args[args.indexOf("-t") + 1]).toBe("1.000000"); expect(args[args.indexOf("-t") + 1]).toBe("1.000000");
}); });
it("emits -t when audio is longer than target", () => { it("filter-trims and re-encodes AAC packet padding beyond the target", () => {
const { args, operation } = buildPadTrimAudioArgs("/tmp/in.aac", "/tmp/out.aac", 6.123, 5.0); const { args, operation } = buildPadTrimAudioArgs(
"/tmp/in.aac",
"/tmp/out.m4a",
15.018667,
15.0,
);
expect(operation).toBe("trim"); expect(operation).toBe("trim");
const tIdx = args.indexOf("-t"); const filterIdx = args.indexOf("-af");
expect(tIdx).toBeGreaterThan(-1); expect(args[filterIdx + 1]).toBe("atrim=duration=15.000000,asetpts=PTS-STARTPTS");
expect(args[tIdx + 1]).toBe("5.000000");
// Trim preserves AAC stream copy.
const codecIdx = args.indexOf("-c:a"); const codecIdx = args.indexOf("-c:a");
expect(args[codecIdx + 1]).toBe("copy"); expect(args[codecIdx + 1]).toBe("aac");
expect(args[args.indexOf("-b:a") + 1]).toBe("192k");
expect(args.at(-1)).toBe("/tmp/out.m4a");
}); });
it("emits a plain copy when source duration matches target within ~1ms", () => { it("emits a plain copy when source duration matches target within ~1ms", () => {
@@ -243,8 +248,8 @@ describe("padOrTrimAudioToVideoFrameCount", () => {
expect(result.operation).toBe("trim"); expect(result.operation).toBe("trim");
expect(result.targetDurationSeconds).toBe(4); expect(result.targetDurationSeconds).toBe(4);
expect(captured.args).toHaveLength(1); expect(captured.args).toHaveLength(1);
const tIdx = captured.args[0]!.indexOf("-t"); const filterIdx = captured.args[0]!.indexOf("-af");
expect(captured.args[0]![tIdx + 1]).toBe("4.000000"); expect(captured.args[0]![filterIdx + 1]).toBe("atrim=duration=4.000000,asetpts=PTS-STARTPTS");
}); });
it("emits a copy when audio duration already equals frameCount/fps", async () => { it("emits a copy when audio duration already equals frameCount/fps", async () => {
@@ -14,8 +14,9 @@
* "audio cuts off early" or "video shows a frozen final frame" bugs. * "audio cuts off early" or "video shows a frozen final frame" bugs.
* *
* The fix: post-pad/trim audio to *exactly* `frameCount / fps` seconds at * The fix: post-pad/trim audio to *exactly* `frameCount / fps` seconds at
* assemble time. Pad by concat-copying a generated silence tail, trim with * assemble time. Pad by concat-copying a generated silence tail. For trim,
* `-t`, and avoid re-encoding the already mixed source AAC in either case. * decode and filter to the exact target before re-encoding into an M4A
* container; packet-copying AAC can only cut on packet boundaries.
*/ */
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
@@ -121,8 +122,8 @@ export interface PadTrimAudioPlan {
* tail, then concat-copy the source AAC plus that tail. This avoids * tail, then concat-copy the source AAC plus that tail. This avoids
* re-encoding the already mixed `audio.aac`; the pad branch remains the * re-encoding the already mixed `audio.aac`; the pad branch remains the
* inverse of trim instead of becoming a second full-source AAC encode. * inverse of trim instead of becoming a second full-source AAC encode.
* - `sourceDuration > targetDuration` → trim with `-t target`. `-c:a copy` * - `sourceDuration > targetDuration` → filter to the exact target and
* is preserved when the input is already AAC. * re-encode AAC so packet padding cannot outlast the video.
* - `|Δ| < AUDIO_DURATION_TOLERANCE_SECONDS` → no-op `copy`, but we still * - `|Δ| < AUDIO_DURATION_TOLERANCE_SECONDS` → no-op `copy`, but we still
* run ffmpeg with `-c:a copy` to materialize the output path. * run ffmpeg with `-c:a copy` to materialize the output path.
*/ */
@@ -187,13 +188,27 @@ export function buildPadTrimAudioPlan(
cleanupPaths: [silencePath, concatListPath], cleanupPaths: [silencePath, concatListPath],
}; };
} }
// Trim. `-t` truncates AAC without re-encoding because AAC frames are // Packet-copy trimming snaps to AAC frame boundaries (typically 1024
// independently decodable; ffmpeg snaps the cut point to the nearest // samples), which can leave ~20ms beyond the target. Decode/filter/re-encode
// packet boundary, fine for the ±1ms tolerance we care about here. // into M4A so ffmpeg records the exact presentation duration.
return { return {
operation: "trim", operation: "trim",
steps: [ steps: [
{ kind: "trim", args: ["-i", audioPath, "-t", targetSec, "-c:a", "copy", "-y", outputPath] }, {
kind: "trim",
args: [
"-i",
audioPath,
"-af",
`atrim=duration=${targetSec},asetpts=PTS-STARTPTS`,
"-c:a",
"aac",
"-b:a",
"192k",
"-y",
outputPath,
],
},
], ],
cleanupPaths: [], cleanupPaths: [],
}; };
@@ -49,7 +49,7 @@ describe("runAssembleStage audio duration parity", () => {
muxVideoWithAudioMock.mockResolvedValue({ success: true }); muxVideoWithAudioMock.mockResolvedValue({ success: true });
padOrTrimAudioMock.mockResolvedValue({ padOrTrimAudioMock.mockResolvedValue({
success: true, success: true,
outputPath: "/tmp/audio.duration-normalized.aac", outputPath: "/tmp/audio.duration-normalized.m4a",
targetDurationSeconds: 1, targetDurationSeconds: 1,
sourceDurationSeconds: 1.024, sourceDurationSeconds: 1.024,
operation: "trim", operation: "trim",
@@ -62,11 +62,11 @@ describe("runAssembleStage audio duration parity", () => {
expect(padOrTrimAudioMock).toHaveBeenCalledWith({ expect(padOrTrimAudioMock).toHaveBeenCalledWith({
videoPath: "/tmp/video-only.mp4", videoPath: "/tmp/video-only.mp4",
audioPath: "/tmp/audio.aac", audioPath: "/tmp/audio.aac",
outputPath: "/tmp/audio.duration-normalized.aac", outputPath: "/tmp/audio.duration-normalized.m4a",
}); });
expect(muxVideoWithAudioMock).toHaveBeenCalledWith( expect(muxVideoWithAudioMock).toHaveBeenCalledWith(
"/tmp/video-only.mp4", "/tmp/video-only.mp4",
"/tmp/audio.duration-normalized.aac", "/tmp/audio.duration-normalized.m4a",
"/tmp/output.mp4", "/tmp/output.mp4",
undefined, undefined,
{ audioCodec: "aac" }, { audioCodec: "aac" },
@@ -80,14 +80,14 @@ describe("runAssembleStage audio duration parity", () => {
expect(padOrTrimAudioMock).toHaveBeenCalledWith({ expect(padOrTrimAudioMock).toHaveBeenCalledWith({
videoPath: "/tmp/video-only.mp4", videoPath: "/tmp/video-only.mp4",
audioPath: "/tmp/audio.m4a", audioPath: "/tmp/audio.m4a",
outputPath: "/tmp/audio.duration-normalized.aac", outputPath: "/tmp/audio.duration-normalized.m4a",
}); });
}); });
it("fails instead of muxing an unnormalized AAC tail", async () => { it("fails instead of muxing an unnormalized AAC tail", async () => {
padOrTrimAudioMock.mockResolvedValue({ padOrTrimAudioMock.mockResolvedValue({
success: false, success: false,
outputPath: "/tmp/audio.duration-normalized.aac", outputPath: "/tmp/audio.duration-normalized.m4a",
targetDurationSeconds: 1, targetDurationSeconds: 1,
sourceDurationSeconds: 1.024, sourceDurationSeconds: 1.024,
operation: "trim", operation: "trim",
@@ -61,7 +61,7 @@ export async function runAssembleStage(input: AssembleStageInput): Promise<Assem
const audioStem = audioExtension const audioStem = audioExtension
? audioOutputPath.slice(0, -audioExtension.length) ? audioOutputPath.slice(0, -audioExtension.length)
: audioOutputPath; : audioOutputPath;
const normalizedAudioPath = `${audioStem}.duration-normalized.aac`; const normalizedAudioPath = `${audioStem}.duration-normalized.m4a`;
const normalizeResult = await padOrTrimAudioToVideoFrameCount({ const normalizeResult = await padOrTrimAudioToVideoFrameCount({
videoPath: videoOnlyPath, videoPath: videoOnlyPath,
audioPath: audioOutputPath, audioPath: audioOutputPath,