From 9289551e98958f006ec14af8ae9058096a4932fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 16 Jul 2026 14:52:50 +0000 Subject: [PATCH] fix(render): scope M4A priming preservation to trims --- .../engine/src/services/chunkEncoder.test.ts | 26 ++++++++++++++++++- packages/engine/src/services/chunkEncoder.ts | 4 ++- .../src/services/distributed/assemble.ts | 4 ++- .../render/stages/assembleStage.test.ts | 2 +- .../services/render/stages/assembleStage.ts | 5 +++- 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index 8b29e5e8d..80491e966 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -418,7 +418,7 @@ describe("muxVideoWithAudio audio codec handling", () => { }); }); - it("preserves M4A priming edit lists instead of shifting copied video", async () => { + it("keeps negative-timestamp repair for an M4A without a known priming edit list", async () => { const { spawn, calls } = createSpawnSpy(); vi.resetModules(); vi.doMock("child_process", () => ({ spawn })); @@ -433,6 +433,30 @@ describe("muxVideoWithAudio audio codec handling", () => { { num: 30, den: 1 }, ); + await flushMuxCodecResolution(); + expect(calls).toHaveLength(1); + expect(calls[0]!.args).toContain("copy"); + expect(calls[0]!.args).toContain("-avoid_negative_ts"); + + emitClose(calls[0]!.proc, 0); + await expect(muxPromise).resolves.toMatchObject({ success: true }); + }); + + it("preserves a known M4A priming edit list instead of shifting copied video", async () => { + const { spawn, calls } = createSpawnSpy(); + vi.resetModules(); + vi.doMock("child_process", () => ({ spawn })); + + const { muxVideoWithAudio } = await import("./chunkEncoder.js"); + const muxPromise = muxVideoWithAudio( + "/tmp/video-only.mp4", + "/tmp/audio.duration-normalized.m4a", + "/tmp/output.mp4", + undefined, + { audioCodec: "aac", preserveAudioPrimingEditList: true }, + { num: 30, den: 1 }, + ); + await flushMuxCodecResolution(); expect(calls).toHaveLength(1); expect(calls[0]!.args).toContain("copy"); diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index 58b665a23..e30272644 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -77,6 +77,8 @@ export interface MuxVideoWithAudioOptions extends Partial< * depend on the file extension alone. */ audioCodec?: "aac"; + /** Preserve a priming edit list known to have been created by AAC re-encoding. */ + preserveAudioPrimingEditList?: boolean; } async function shouldCopyAacSidecar( @@ -691,7 +693,7 @@ export async function muxVideoWithAudio( } } const copiesContainerizedAac = - !isWebm && shouldCopyAudio && extname(audioPath).toLowerCase() === ".m4a"; + !isWebm && shouldCopyAudio && config?.preserveAudioPrimingEditList === true; // PTS bases can diverge during mux and reintroduce negative DTS. See // buildEncoderArgs for the full reasoning on why that breaks playback. // A freshly encoded M4A is the exception: its edit list already hides the diff --git a/packages/producer/src/services/distributed/assemble.ts b/packages/producer/src/services/distributed/assemble.ts index 83f6d8ab3..df1f93c64 100644 --- a/packages/producer/src/services/distributed/assemble.ts +++ b/packages/producer/src/services/distributed/assemble.ts @@ -290,6 +290,7 @@ export async function assemble( // ── 3. Audio: pad-or-trim then mux ──────────────────────────────────── let audioForMux: string | null = null; + let preserveAudioPrimingEditList = false; if (audioPath !== null && existsSync(audioPath)) { const paddedAudioPath = join(workDir, "audio-padded.m4a"); const padTrimResult = await padOrTrimAudioToVideoFrameCount({ @@ -302,6 +303,7 @@ export async function assemble( throw new Error(`[assemble] audio pad/trim failed: ${padTrimResult.error}`); } audioForMux = paddedAudioPath; + preserveAudioPrimingEditList = padTrimResult.operation === "trim"; log.info("[assemble] audio normalized for mux", { operation: padTrimResult.operation, targetDurationSeconds: padTrimResult.targetDurationSeconds, @@ -321,7 +323,7 @@ export async function assemble( audioForMux, muxOutputPath, abortSignal, - { audioCodec: "aac" }, + { audioCodec: "aac", preserveAudioPrimingEditList }, { num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen }, ); if (!muxResult.success) { diff --git a/packages/producer/src/services/render/stages/assembleStage.test.ts b/packages/producer/src/services/render/stages/assembleStage.test.ts index d441f52de..ae0696202 100644 --- a/packages/producer/src/services/render/stages/assembleStage.test.ts +++ b/packages/producer/src/services/render/stages/assembleStage.test.ts @@ -69,7 +69,7 @@ describe("runAssembleStage audio duration parity", () => { "/tmp/audio.duration-normalized.m4a", "/tmp/output.mp4", undefined, - { audioCodec: "aac" }, + { audioCodec: "aac", preserveAudioPrimingEditList: true }, { num: 30, den: 1 }, ); }); diff --git a/packages/producer/src/services/render/stages/assembleStage.ts b/packages/producer/src/services/render/stages/assembleStage.ts index 23fedf34e..ab218249b 100644 --- a/packages/producer/src/services/render/stages/assembleStage.ts +++ b/packages/producer/src/services/render/stages/assembleStage.ts @@ -76,7 +76,10 @@ export async function runAssembleStage(input: AssembleStageInput): Promise