diff --git a/packages/producer/src/services/distributed/assemble.ts b/packages/producer/src/services/distributed/assemble.ts index 3b59b91b3..83f6d8ab3 100644 --- a/packages/producer/src/services/distributed/assemble.ts +++ b/packages/producer/src/services/distributed/assemble.ts @@ -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, diff --git a/packages/producer/src/services/render/audioPadTrim.test.ts b/packages/producer/src/services/render/audioPadTrim.test.ts index 14399f30d..57efe884a 100644 --- a/packages/producer/src/services/render/audioPadTrim.test.ts +++ b/packages/producer/src/services/render/audioPadTrim.test.ts @@ -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 () => { diff --git a/packages/producer/src/services/render/audioPadTrim.ts b/packages/producer/src/services/render/audioPadTrim.ts index a9a05668e..eca322760 100644 --- a/packages/producer/src/services/render/audioPadTrim.ts +++ b/packages/producer/src/services/render/audioPadTrim.ts @@ -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: [], }; diff --git a/packages/producer/src/services/render/stages/assembleStage.test.ts b/packages/producer/src/services/render/stages/assembleStage.test.ts index e18da3be7..d441f52de 100644 --- a/packages/producer/src/services/render/stages/assembleStage.test.ts +++ b/packages/producer/src/services/render/stages/assembleStage.test.ts @@ -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", diff --git a/packages/producer/src/services/render/stages/assembleStage.ts b/packages/producer/src/services/render/stages/assembleStage.ts index 31eea26a5..23fedf34e 100644 --- a/packages/producer/src/services/render/stages/assembleStage.ts +++ b/packages/producer/src/services/render/stages/assembleStage.ts @@ -61,7 +61,7 @@ export async function runAssembleStage(input: AssembleStageInput): Promise