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