From 113a4985b51bc1d77babe7113d6c96bbe46d41bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 16 Jul 2026 07:16:10 +0000 Subject: [PATCH 01/11] fix(render): trim AAC packet padding exactly --- .../src/services/distributed/assemble.ts | 2 +- .../src/services/render/audioPadTrim.test.ts | 23 ++++++++------ .../src/services/render/audioPadTrim.ts | 31 ++++++++++++++----- .../render/stages/assembleStage.test.ts | 10 +++--- .../services/render/stages/assembleStage.ts | 2 +- 5 files changed, 44 insertions(+), 24 deletions(-) diff --git a/packages/producer/src/services/distributed/assemble.ts b/packages/producer/src/services/distributed/assemble.ts index 3b59b91b3e..83f6d8ab3e 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 14399f30d1..57efe884a5 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 a9a05668e3..eca322760b 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 e18da3be75..d441f52de3 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 31eea26a5e..23fedf34e1 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 Date: Thu, 16 Jul 2026 08:06:17 +0000 Subject: [PATCH 02/11] fix(render): preserve normalized M4A edit timing --- .../engine/src/services/chunkEncoder.test.ts | 24 +++++++++++++++++++ packages/engine/src/services/chunkEncoder.ts | 7 +++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index 4fee83b5b5..8b29e5e8d6 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -418,6 +418,30 @@ describe("muxVideoWithAudio audio codec handling", () => { }); }); + it("preserves M4A priming edit lists 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" }, + { num: 30, den: 1 }, + ); + + await flushMuxCodecResolution(); + expect(calls).toHaveLength(1); + expect(calls[0]!.args).toContain("copy"); + expect(calls[0]!.args).not.toContain("-avoid_negative_ts"); + + emitClose(calls[0]!.proc, 0); + await expect(muxPromise).resolves.toMatchObject({ success: true }); + }); + it("uses the caller-provided AAC codec contract instead of the sidecar extension", async () => { const { spawn, calls } = createSpawnSpy(); vi.resetModules(); diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index 3350793ac8..58b665a23d 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -690,9 +690,14 @@ export async function muxVideoWithAudio( args.push("-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"); } } + const copiesContainerizedAac = + !isWebm && shouldCopyAudio && extname(audioPath).toLowerCase() === ".m4a"; // PTS bases can diverge during mux and reintroduce negative DTS. See // buildEncoderArgs for the full reasoning on why that breaks playback. - args.push("-avoid_negative_ts", "make_zero"); + // A freshly encoded M4A is the exception: its edit list already hides the + // AAC priming packet. `make_zero` discards that edit and shifts copied video + // forward by one AAC frame (~21ms), creating a visible first-frame offset. + if (!copiesContainerizedAac) args.push("-avoid_negative_ts", "make_zero"); if (fps !== undefined) { // Set the exact output framerate so the muxer doesn't PTS-average a // fractional rational like `360000/12001` instead of `30/1` into the 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 03/11] 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 8b29e5e8d6..80491e966a 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 58b665a23d..e302726441 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 83f6d8ab3e..df1f93c64b 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 d441f52de3..ae06962023 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 23fedf34e1..ab218249b0 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 Date: Sat, 18 Jul 2026 18:43:47 +0000 Subject: [PATCH 04/11] fix(render): cap trimmed audio container duration --- packages/producer/src/services/render/audioPadTrim.test.ts | 1 + packages/producer/src/services/render/audioPadTrim.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/packages/producer/src/services/render/audioPadTrim.test.ts b/packages/producer/src/services/render/audioPadTrim.test.ts index 57efe884a5..ee1931b5b5 100644 --- a/packages/producer/src/services/render/audioPadTrim.test.ts +++ b/packages/producer/src/services/render/audioPadTrim.test.ts @@ -91,6 +91,7 @@ describe("buildPadTrimAudioArgs", () => { expect(operation).toBe("trim"); const filterIdx = args.indexOf("-af"); expect(args[filterIdx + 1]).toBe("atrim=duration=15.000000,asetpts=PTS-STARTPTS"); + expect(args[args.indexOf("-t") + 1]).toBe("15.000000"); const codecIdx = args.indexOf("-c:a"); expect(args[codecIdx + 1]).toBe("aac"); expect(args[args.indexOf("-b:a") + 1]).toBe("192k"); diff --git a/packages/producer/src/services/render/audioPadTrim.ts b/packages/producer/src/services/render/audioPadTrim.ts index eca322760b..f7a40cf235 100644 --- a/packages/producer/src/services/render/audioPadTrim.ts +++ b/packages/producer/src/services/render/audioPadTrim.ts @@ -201,6 +201,12 @@ export function buildPadTrimAudioPlan( audioPath, "-af", `atrim=duration=${targetSec},asetpts=PTS-STARTPTS`, + // `atrim` limits decoded samples but does not cap muxer timestamps + // introduced by encoder delay/flush. The output duration contract + // is enforced at the container boundary as well; without `-t`, a + // long primed source can retain its original tail after re-encode. + "-t", + targetSec, "-c:a", "aac", "-b:a", From 19258ea5ba47067e9d5756b122051c467ece0b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 18 Jul 2026 19:13:00 +0000 Subject: [PATCH 05/11] fix(render): cap final mux to video duration --- packages/engine/src/services/chunkEncoder.ts | 5 +++++ packages/producer/src/services/distributed/assemble.ts | 6 +++++- .../producer/src/services/render/stages/assembleStage.ts | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index e302726441..a2d3017815 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -79,6 +79,8 @@ export interface MuxVideoWithAudioOptions extends Partial< audioCodec?: "aac"; /** Preserve a priming edit list known to have been created by AAC re-encoding. */ preserveAudioPrimingEditList?: boolean; + /** Hard cap copied audio to the already-encoded video's exact duration. */ + durationSeconds?: number; } async function shouldCopyAacSidecar( @@ -706,6 +708,9 @@ export async function muxVideoWithAudio( // output container metadata. `-c:v copy` is retained; no re-encode. args.push("-r", fpsToFfmpegArg(fps)); } + if (config?.durationSeconds !== undefined) { + args.push("-t", String(config.durationSeconds)); + } args.push("-y", outputPath); const processTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout; diff --git a/packages/producer/src/services/distributed/assemble.ts b/packages/producer/src/services/distributed/assemble.ts index df1f93c64b..91f56ed90d 100644 --- a/packages/producer/src/services/distributed/assemble.ts +++ b/packages/producer/src/services/distributed/assemble.ts @@ -323,7 +323,11 @@ export async function assemble( audioForMux, muxOutputPath, abortSignal, - { audioCodec: "aac", preserveAudioPrimingEditList }, + { + audioCodec: "aac", + preserveAudioPrimingEditList, + durationSeconds: padTrimResult.targetDurationSeconds, + }, { num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen }, ); if (!muxResult.success) { diff --git a/packages/producer/src/services/render/stages/assembleStage.ts b/packages/producer/src/services/render/stages/assembleStage.ts index ab218249b0..6a2adf483b 100644 --- a/packages/producer/src/services/render/stages/assembleStage.ts +++ b/packages/producer/src/services/render/stages/assembleStage.ts @@ -79,6 +79,7 @@ export async function runAssembleStage(input: AssembleStageInput): Promise Date: Sat, 18 Jul 2026 19:13:20 +0000 Subject: [PATCH 06/11] test(render): cover duration-capped audio mux --- .../engine/src/services/chunkEncoder.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index 80491e966a..f0ce7adab1 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -373,6 +373,28 @@ describe("encodeFramesChunkedConcat ffmpegEncodeTimeout", () => { }); describe("muxVideoWithAudio audio codec handling", () => { + it("caps copied audio to the encoded video duration", 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", durationSeconds: 16.066667 }, + { num: 30, den: 1 }, + ); + + await flushMuxCodecResolution(); + expect(calls[0]!.args).toContain("-t"); + expect(calls[0]!.args).toContain("16.066667"); + emitClose(calls[0]!.proc, 0); + await expect(muxPromise).resolves.toMatchObject({ success: true }); + }); + it("copies HyperFrames AAC sidecars into MP4 instead of re-encoding", async () => { const { spawn, calls } = createSpawnSpy(); vi.resetModules(); From 532461599b7518f4609002cdb314ff2ce9f3a70f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 18 Jul 2026 19:25:24 +0000 Subject: [PATCH 07/11] fix(producer): preserve normalized audio mux duration --- .../src/services/distributed/assemble.ts | 24 ++++++++++++------- .../render/stages/assembleStage.test.ts | 2 +- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/producer/src/services/distributed/assemble.ts b/packages/producer/src/services/distributed/assemble.ts index 91f56ed90d..2f98ccef99 100644 --- a/packages/producer/src/services/distributed/assemble.ts +++ b/packages/producer/src/services/distributed/assemble.ts @@ -289,8 +289,11 @@ export async function assemble( } // ── 3. Audio: pad-or-trim then mux ──────────────────────────────────── - let audioForMux: string | null = null; - let preserveAudioPrimingEditList = false; + let normalizedAudio: { + path: string; + preserveAudioPrimingEditList: boolean; + durationSeconds: number; + } | null = null; if (audioPath !== null && existsSync(audioPath)) { const paddedAudioPath = join(workDir, "audio-padded.m4a"); const padTrimResult = await padOrTrimAudioToVideoFrameCount({ @@ -302,8 +305,11 @@ export async function assemble( if (!padTrimResult.success) { throw new Error(`[assemble] audio pad/trim failed: ${padTrimResult.error}`); } - audioForMux = paddedAudioPath; - preserveAudioPrimingEditList = padTrimResult.operation === "trim"; + normalizedAudio = { + path: paddedAudioPath, + preserveAudioPrimingEditList: padTrimResult.operation === "trim", + durationSeconds: padTrimResult.targetDurationSeconds, + }; log.info("[assemble] audio normalized for mux", { operation: padTrimResult.operation, targetDurationSeconds: padTrimResult.targetDurationSeconds, @@ -316,17 +322,17 @@ export async function assemble( // because it operates on a `RenderJob` and emits `updateJobStatus` // payloads — the distributed activity has no job to thread through. const muxOutputPath = - audioForMux !== null ? join(workDir, `mux.${plan.dimensions.format}`) : postConcatPath; - if (audioForMux !== null) { + normalizedAudio !== null ? join(workDir, `mux.${plan.dimensions.format}`) : postConcatPath; + if (normalizedAudio !== null) { const muxResult = await muxVideoWithAudio( postConcatPath, - audioForMux, + normalizedAudio.path, muxOutputPath, abortSignal, { audioCodec: "aac", - preserveAudioPrimingEditList, - durationSeconds: padTrimResult.targetDurationSeconds, + preserveAudioPrimingEditList: normalizedAudio.preserveAudioPrimingEditList, + durationSeconds: normalizedAudio.durationSeconds, }, { num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen }, ); diff --git a/packages/producer/src/services/render/stages/assembleStage.test.ts b/packages/producer/src/services/render/stages/assembleStage.test.ts index ae06962023..a6d392bac4 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", preserveAudioPrimingEditList: true }, + { audioCodec: "aac", preserveAudioPrimingEditList: true, durationSeconds: 1 }, { num: 30, den: 1 }, ); }); From 63bc525ca90b08356d50ebb41d4e15581c9d3470 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 18 Jul 2026 19:53:29 +0000 Subject: [PATCH 08/11] fix(engine): stop mux at shortest normalized stream --- packages/engine/src/services/chunkEncoder.test.ts | 1 + packages/engine/src/services/chunkEncoder.ts | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index f0ce7adab1..cc63253336 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -389,6 +389,7 @@ describe("muxVideoWithAudio audio codec handling", () => { ); await flushMuxCodecResolution(); + expect(calls[0]!.args).toContain("-shortest"); expect(calls[0]!.args).toContain("-t"); expect(calls[0]!.args).toContain("16.066667"); emitClose(calls[0]!.proc, 0); diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index a2d3017815..e373520a78 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -709,6 +709,13 @@ export async function muxVideoWithAudio( args.push("-r", fpsToFfmpegArg(fps)); } if (config?.durationSeconds !== undefined) { + // Stream-copying a normalized AAC sidecar can preserve a longer packet + // timeline than its container/edit-list duration. `-t` alone limits the + // output timestamp window but does not prevent the copied audio stream's + // tail from extending the MP4 timeline. When an explicit video-derived + // duration is supplied, also stop at the shortest stream so the final + // mux boundary is deterministic. + args.push("-shortest"); args.push("-t", String(config.durationSeconds)); } args.push("-y", outputPath); From 4b116b98802c05f0eeb541878806692eea975541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 18 Jul 2026 21:06:23 +0000 Subject: [PATCH 09/11] fix(producer): normalize padded audio on sample timeline --- .../engine/src/services/chunkEncoder.test.ts | 23 ----- packages/engine/src/services/chunkEncoder.ts | 11 --- .../src/services/distributed/assemble.ts | 5 +- .../render/audioPadTrim.integration.test.ts | 81 ++++++++++++++++ .../src/services/render/audioPadTrim.test.ts | 96 ++++--------------- .../src/services/render/audioPadTrim.ts | 75 +++------------ .../render/stages/assembleStage.test.ts | 2 +- .../services/render/stages/assembleStage.ts | 3 +- 8 files changed, 115 insertions(+), 181 deletions(-) create mode 100644 packages/producer/src/services/render/audioPadTrim.integration.test.ts diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index cc63253336..80491e966a 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -373,29 +373,6 @@ describe("encodeFramesChunkedConcat ffmpegEncodeTimeout", () => { }); describe("muxVideoWithAudio audio codec handling", () => { - it("caps copied audio to the encoded video duration", 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", durationSeconds: 16.066667 }, - { num: 30, den: 1 }, - ); - - await flushMuxCodecResolution(); - expect(calls[0]!.args).toContain("-shortest"); - expect(calls[0]!.args).toContain("-t"); - expect(calls[0]!.args).toContain("16.066667"); - emitClose(calls[0]!.proc, 0); - await expect(muxPromise).resolves.toMatchObject({ success: true }); - }); - it("copies HyperFrames AAC sidecars into MP4 instead of re-encoding", async () => { const { spawn, calls } = createSpawnSpy(); vi.resetModules(); diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index e373520a78..709d89ac49 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -80,7 +80,6 @@ export interface MuxVideoWithAudioOptions extends Partial< /** Preserve a priming edit list known to have been created by AAC re-encoding. */ preserveAudioPrimingEditList?: boolean; /** Hard cap copied audio to the already-encoded video's exact duration. */ - durationSeconds?: number; } async function shouldCopyAacSidecar( @@ -708,16 +707,6 @@ export async function muxVideoWithAudio( // output container metadata. `-c:v copy` is retained; no re-encode. args.push("-r", fpsToFfmpegArg(fps)); } - if (config?.durationSeconds !== undefined) { - // Stream-copying a normalized AAC sidecar can preserve a longer packet - // timeline than its container/edit-list duration. `-t` alone limits the - // output timestamp window but does not prevent the copied audio stream's - // tail from extending the MP4 timeline. When an explicit video-derived - // duration is supplied, also stop at the shortest stream so the final - // mux boundary is deterministic. - args.push("-shortest"); - args.push("-t", String(config.durationSeconds)); - } args.push("-y", outputPath); const processTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout; diff --git a/packages/producer/src/services/distributed/assemble.ts b/packages/producer/src/services/distributed/assemble.ts index 2f98ccef99..5d19316a89 100644 --- a/packages/producer/src/services/distributed/assemble.ts +++ b/packages/producer/src/services/distributed/assemble.ts @@ -292,7 +292,6 @@ export async function assemble( let normalizedAudio: { path: string; preserveAudioPrimingEditList: boolean; - durationSeconds: number; } | null = null; if (audioPath !== null && existsSync(audioPath)) { const paddedAudioPath = join(workDir, "audio-padded.m4a"); @@ -307,8 +306,7 @@ export async function assemble( } normalizedAudio = { path: paddedAudioPath, - preserveAudioPrimingEditList: padTrimResult.operation === "trim", - durationSeconds: padTrimResult.targetDurationSeconds, + preserveAudioPrimingEditList: padTrimResult.operation !== "copy", }; log.info("[assemble] audio normalized for mux", { operation: padTrimResult.operation, @@ -332,7 +330,6 @@ export async function assemble( { audioCodec: "aac", preserveAudioPrimingEditList: normalizedAudio.preserveAudioPrimingEditList, - durationSeconds: normalizedAudio.durationSeconds, }, { num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen }, ); diff --git a/packages/producer/src/services/render/audioPadTrim.integration.test.ts b/packages/producer/src/services/render/audioPadTrim.integration.test.ts new file mode 100644 index 0000000000..137dbdcd1c --- /dev/null +++ b/packages/producer/src/services/render/audioPadTrim.integration.test.ts @@ -0,0 +1,81 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { padOrTrimAudioToVideoFrameCount } from "./audioPadTrim.js"; + +const dirs: string[] = []; +afterEach(() => dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true }))); + +const hasFfmpeg = (() => { + try { + execFileSync("ffmpeg", ["-version"], { stdio: "ignore" }); + execFileSync("ffprobe", ["-version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +})(); + +describe.skipIf(!hasFfmpeg)("audio pad real-media packet contract", () => { + it("normalizes a tiny raw-ADTS pad without an oversized terminal packet", async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-pad-")); + dirs.push(dir); + const input = join(dir, "input.aac"); + const output = join(dir, "normalized.m4a"); + execFileSync("ffmpeg", [ + "-hide_banner", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=16.04", + "-c:a", + "aac", + "-b:a", + "192k", + "-f", + "adts", + input, + ]); + const result = await padOrTrimAudioToVideoFrameCount({ + videoPath: join(dir, "video.mp4"), + audioPath: input, + outputPath: output, + probeVideoFrameInfo: async () => ({ frameCount: 482, fpsNum: 30, fpsDen: 1 }), + probeAudioInfo: async () => ({ durationSeconds: 16.04 }), + runFfmpeg: async (args) => { + const p = spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", ...args]); + return { success: p.status === 0, error: p.stderr?.toString() }; + }, + }); + expect(result.success).toBe(true); + const probe: { + streams?: Array<{ duration?: string }>; + packets?: Array<{ duration_time?: string }>; + } = JSON.parse( + execFileSync( + "ffprobe", + [ + "-v", + "error", + "-show_entries", + "stream=duration", + "-show_entries", + "packet=duration_time", + "-of", + "json", + output, + ], + { encoding: "utf8" }, + ), + ); + const duration = Number(probe.streams?.[0]?.duration); + const packets = probe.packets ?? []; + expect(duration).toBeGreaterThan(16.0); + expect(duration).toBeLessThan(16.1); + expect(Number(packets.at(-1)?.duration_time)).toBeLessThan(0.1); + }); +}); diff --git a/packages/producer/src/services/render/audioPadTrim.test.ts b/packages/producer/src/services/render/audioPadTrim.test.ts index ee1931b5b5..e6b6d27166 100644 --- a/packages/producer/src/services/render/audioPadTrim.test.ts +++ b/packages/producer/src/services/render/audioPadTrim.test.ts @@ -24,61 +24,27 @@ import { } from "./audioPadTrim.js"; describe("buildPadTrimAudioArgs", () => { - it("emits a concat-copy pad plan when audio is shorter than target", () => { + it("emits a decode/filter/re-encode pad plan when audio is shorter than target", () => { const plan = buildPadTrimAudioPlan("/tmp/in.aac", "/tmp/out.aac", 4.0, 5.0, { sampleRate: 48000, channels: 2, }); expect(plan.operation).toBe("pad"); - expect(plan.steps).toHaveLength(2); - - const silenceArgs = plan.steps[0]!.args; - expect(plan.steps[0]!.kind).toBe("pad-silence"); - expect(silenceArgs).not.toContain("/tmp/in.aac"); - expect(silenceArgs[silenceArgs.indexOf("-i") + 1]).toBe( - "anullsrc=channel_layout=stereo:sample_rate=48000", - ); - expect(silenceArgs[silenceArgs.indexOf("-t") + 1]).toBe("1.000000"); - expect(silenceArgs[silenceArgs.indexOf("-c:a") + 1]).toBe("aac"); - - const concatArgs = plan.steps[1]!.args; - expect(plan.steps[1]!.kind).toBe("pad-concat"); - expect(concatArgs).toContain("concat"); - // The concat script is passed via a real file (NOT `pipe:0`). Feeding - // it through stdin makes FFmpeg's URL joiner prepend `pipe:` to bare - // absolute paths in the script — the demuxer then tries to open e.g. - // `pipe:/tmp/foo.aac` and fails with "Impossible to open pipe:/…". - // Materializing to a file matches `assemble.ts`'s concat convention. - expect(plan.steps[1]!.concatListPath).toBe("/tmp/out.aac.concat-list.txt"); - expect(concatArgs[concatArgs.indexOf("-i") + 1]).toBe("/tmp/out.aac.concat-list.txt"); - expect(concatArgs[concatArgs.indexOf("-c:a") + 1]).toBe("copy"); - expect(concatArgs[concatArgs.length - 1]).toBe("/tmp/out.aac"); - // Concat script MUST use bare paths, NOT `file://` URLs. FFmpeg 8.x - // on Windows can't open `file:///C:/…` URLs from the concat demuxer - // (field-signal ts=1784169914 / 1784177061 / 1784177375). Regression - // pin: the `file://` scheme prefix must never appear in the concat - // list content. - expect(plan.steps[1]!.concatListContent).toContain("file '/tmp/in.aac'"); - expect(plan.steps[1]!.concatListContent).toContain("file '/tmp/out.aac.pad-silence.aac'"); - expect(plan.steps[1]!.concatListContent).not.toContain("file://"); + expect(plan.steps).toHaveLength(1); + const args = plan.steps[0]!.args; + expect(args[args.indexOf("-i") + 1]).toBe("/tmp/in.aac"); + expect(args[args.indexOf("-af") + 1]).toBe("apad=whole_dur=5.000000"); + expect(args[args.indexOf("-t") + 1]).toBe("5.000000"); + expect(args[args.indexOf("-c:a") + 1]).toBe("aac"); // Cleanup includes BOTH the silence tail and the concat list script. - expect(plan.cleanupPaths).toEqual([ - "/tmp/out.aac.pad-silence.aac", - "/tmp/out.aac.concat-list.txt", - ]); - - const reencodedSourceStep = plan.steps.find( - (step) => - step.args.includes("/tmp/in.aac") && step.args[step.args.indexOf("-c:a") + 1] === "aac", - ); - expect(reencodedSourceStep).toBeUndefined(); + expect(plan.cleanupPaths).toEqual([]); }); it("keeps the legacy args helper on the first pad materialization step", () => { const { args, operation } = buildPadTrimAudioArgs("/tmp/in.aac", "/tmp/out.aac", 4.0, 5.0); expect(operation).toBe("pad"); - expect(args).not.toContain("/tmp/in.aac"); - expect(args[args.indexOf("-t") + 1]).toBe("1.000000"); + expect(args).toContain("/tmp/in.aac"); + expect(args[args.indexOf("-t") + 1]).toBe("5.000000"); }); it("filter-trims and re-encodes AAC packet padding beyond the target", () => { @@ -129,7 +95,7 @@ describe("buildPadTrimAudioArgs", () => { expect(trimNeeded.operation).toBe("trim"); }); - it("does not emit `file://` URLs in the pad-concat script (FFmpeg 8.x Windows compat)", () => { + it("uses apad for Windows-safe duration normalization", () => { // Regression pin for field-signal reports ts=1784169914 / 1784177061 / // ts=1784177375 (win32/x64, CLI 0.7.59, ffmpeg 8.1.1-full_build). The // concat demuxer's file open on Windows in FFmpeg 8.x rejects @@ -143,34 +109,9 @@ describe("buildPadTrimAudioArgs", () => { 5.0, ); expect(winPlan.operation).toBe("pad"); - const concatStep = winPlan.steps.find((s) => s.kind === "pad-concat"); - expect(concatStep).toBeDefined(); - expect(concatStep!.concatListContent).toBeDefined(); - expect(concatStep!.concatListContent).not.toContain("file://"); - expect(concatStep!.concatListContent).not.toContain("file:\\\\"); - // Bare Windows paths appear as-is in the concat directives. - expect(concatStep!.concatListContent).toContain( - "file 'C:\\Users\\alice\\AppData\\Local\\Temp\\hf-render-abc\\audio.aac'", - ); - }); - - it("materializes the pad-concat script to a real file (not `pipe:0`)", () => { - // Regression pin for the Linux CI failure that surfaced when the - // fix originally dropped `file://` while still feeding the concat - // script via `pipe:0`. FFmpeg's URL joiner resolves bare absolute - // paths against the base `pipe:` URL, producing `pipe:/tmp/foo.aac` - // which the demuxer then tries to open as a pipe. Materializing to - // a real file makes the demuxer treat absolute paths as absolute. - const plan = buildPadTrimAudioPlan("/tmp/in.aac", "/tmp/out.aac", 4.0, 5.0); - const concatStep = plan.steps.find((s) => s.kind === "pad-concat"); - expect(concatStep).toBeDefined(); - // The concat script must NOT be piped in via stdin. - expect(concatStep!.args).not.toContain("pipe:0"); - // The `-i` arg points at the materialized concat list file. - const iIdx = concatStep!.args.indexOf("-i"); - expect(concatStep!.args[iIdx + 1]).toBe(concatStep!.concatListPath); - // The concat list file is cleaned up alongside the silence tail. - expect(plan.cleanupPaths).toContain(concatStep!.concatListPath!); + const args = winPlan.steps[0]!.args; + expect(args).toContain("-af"); + expect(args[args.indexOf("-af") + 1]).toBe("apad=whole_dur=5.000000"); }); }); @@ -232,11 +173,10 @@ describe("padOrTrimAudioToVideoFrameCount", () => { expect(result.operation).toBe("pad"); expect(result.targetDurationSeconds).toBe(6); expect(result.sourceDurationSeconds).toBe(5.5); - expect(captured.args).toHaveLength(2); + expect(captured.args).toHaveLength(1); const tIdx = captured.args[0]!.indexOf("-t"); - expect(captured.args[0]![tIdx + 1]).toBe("0.500000"); - expect(captured.args[0]).not.toContain("/tmp/a.aac"); - expect(captured.args[1]![captured.args[1]!.indexOf("-c:a") + 1]).toBe("copy"); + expect(captured.args[0]![tIdx + 1]).toBe("6.000000"); + expect(captured.args[0]![captured.args[0]!.indexOf("-c:a") + 1]).toBe("aac"); }); it("trims a video of N=120 frames at 30/1 fps with longer audio", async () => { @@ -277,7 +217,7 @@ describe("padOrTrimAudioToVideoFrameCount", () => { expect(result.operation).toBe("pad"); expect(result.targetDurationSeconds).toBeCloseTo((120 * 1001) / 30000, 9); const tIdx = captured.args[0]!.indexOf("-t"); - expect(captured.args[0]![tIdx + 1]).toMatch(/^0\.004\d+$/); + expect(captured.args[0]![tIdx + 1]).toBe("4.004000"); }); it("propagates video probe failure as success=false", async () => { diff --git a/packages/producer/src/services/render/audioPadTrim.ts b/packages/producer/src/services/render/audioPadTrim.ts index f7a40cf235..79128de68a 100644 --- a/packages/producer/src/services/render/audioPadTrim.ts +++ b/packages/producer/src/services/render/audioPadTrim.ts @@ -14,13 +14,13 @@ * "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. 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. + * assemble time. Both branches decode/filter and re-encode AAC. Concatenating + * ADTS packets with `-c:a copy` is unsafe because concat timestamp estimation + * can stretch the terminal packet in the final MP4. */ import { spawn } from "node:child_process"; -import { rmSync, writeFileSync } from "node:fs"; +import { rmSync } from "node:fs"; import { extractAudioMetadata, formatFfmpegError, @@ -90,21 +90,11 @@ export interface PadTrimAudioResult { error?: string; } -export type PadTrimAudioStepKind = "copy" | "trim" | "pad-silence" | "pad-concat"; +export type PadTrimAudioStepKind = "copy" | "trim" | "normalize"; export interface PadTrimAudioStep { kind: PadTrimAudioStepKind; args: string[]; - /** - * Concat-demuxer script materialization. When both fields are set, the - * runner writes `concatListContent` to `concatListPath` synchronously - * before spawning ffmpeg, and the step's `args` reference that path via - * `-i concatListPath`. Feeding the concat script through a real file - * (instead of `pipe:0`) is what makes bare-path directives work on both - * Linux and Windows — see `concatFileLine` for the platform history. - */ - concatListPath?: string; - concatListContent?: string; } export interface PadTrimAudioPlan { @@ -132,7 +122,6 @@ export function buildPadTrimAudioPlan( outputPath: string, sourceDurationSeconds: number, targetDurationSeconds: number, - audioInfo: Pick = {}, ): PadTrimAudioPlan { const delta = targetDurationSeconds - sourceDurationSeconds; const targetSec = formatSeconds(targetDurationSeconds); @@ -144,48 +133,28 @@ export function buildPadTrimAudioPlan( }; } if (delta > 0) { - const padDur = formatSeconds(delta); - const silencePath = `${outputPath}.pad-silence.aac`; - const concatListPath = `${outputPath}.concat-list.txt`; return { operation: "pad", steps: [ { - kind: "pad-silence", + kind: "normalize", args: [ - "-f", - "lavfi", "-i", - `anullsrc=channel_layout=${channelLayoutForChannels(audioInfo.channels)}:sample_rate=${sampleRateForFilter(audioInfo.sampleRate)}`, + audioPath, + "-af", + `apad=whole_dur=${targetSec}`, "-t", - padDur, + targetSec, "-c:a", "aac", "-b:a", "192k", "-y", - silencePath, - ], - }, - { - kind: "pad-concat", - args: [ - "-f", - "concat", - "-safe", - "0", - "-i", - concatListPath, - "-c:a", - "copy", - "-y", outputPath, ], - concatListPath, - concatListContent: `${concatFileLine(audioPath)}\n${concatFileLine(silencePath)}\n`, }, ], - cleanupPaths: [silencePath, concatListPath], + cleanupPaths: [], }; } // Packet-copy trimming snaps to AAC frame boundaries (typically 1024 @@ -245,20 +214,7 @@ function formatSeconds(sec: number): string { return sec.toFixed(6); } -function sampleRateForFilter(sampleRate: number | undefined): number { - return sampleRate !== undefined && Number.isFinite(sampleRate) && sampleRate > 0 - ? Math.round(sampleRate) - : 48000; -} - -function channelLayoutForChannels(channels: number | undefined): string { - if (channels === 1) return "mono"; - if (channels === 6) return "5.1"; - if (channels === 8) return "7.1"; - return "stereo"; -} - -function concatFileLine(path: string): string { +/* // Bare paths in concat directives — NOT `file://` URLs. Two failure // modes on the round trip to a working shape: // 1. `file:///C:/…` — FFmpeg 8.x on Windows fails to open URL-form @@ -279,8 +235,7 @@ function concatFileLine(path: string): string { // file's directory becomes the base URL, and absolute paths in the // script resolve as-is on both platforms. The single-quote escaping // (`'\''`) is the concat demuxer's own escape rule. - return `file '${path.replace(/'/g, "'\\''")}'`; -} +*/ /** * Pad or trim `audio.aac` so its exact duration matches `frameCount / fps` @@ -344,7 +299,6 @@ export async function padOrTrimAudioToVideoFrameCount( input.outputPath, audioInfo.durationSeconds, targetDurationSeconds, - audioInfo, ); try { @@ -353,9 +307,6 @@ export async function padOrTrimAudioToVideoFrameCount( // needs one. Doing this here (instead of piping via `pipe:0` in the // runner) is what makes the demuxer's URL resolution treat // absolute paths as absolute — see `concatFileLine` for context. - if (step.concatListPath !== undefined && step.concatListContent !== undefined) { - writeFileSync(step.concatListPath, step.concatListContent, "utf-8"); - } const ffmpegResult = await runner(step.args); if (!ffmpegResult.success) { return { diff --git a/packages/producer/src/services/render/stages/assembleStage.test.ts b/packages/producer/src/services/render/stages/assembleStage.test.ts index a6d392bac4..ae06962023 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", preserveAudioPrimingEditList: true, durationSeconds: 1 }, + { 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 6a2adf483b..39dc431ca6 100644 --- a/packages/producer/src/services/render/stages/assembleStage.ts +++ b/packages/producer/src/services/render/stages/assembleStage.ts @@ -78,8 +78,7 @@ export async function runAssembleStage(input: AssembleStageInput): Promise Date: Sat, 18 Jul 2026 22:07:39 +0000 Subject: [PATCH 10/11] chore(producer): remove stale audio concat remnants --- .../src/services/render/audioPadTrim.test.ts | 7 ++---- .../src/services/render/audioPadTrim.ts | 23 ------------------- 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/packages/producer/src/services/render/audioPadTrim.test.ts b/packages/producer/src/services/render/audioPadTrim.test.ts index e6b6d27166..90fe862b46 100644 --- a/packages/producer/src/services/render/audioPadTrim.test.ts +++ b/packages/producer/src/services/render/audioPadTrim.test.ts @@ -25,10 +25,7 @@ import { describe("buildPadTrimAudioArgs", () => { it("emits a decode/filter/re-encode pad plan when audio is shorter than target", () => { - const plan = buildPadTrimAudioPlan("/tmp/in.aac", "/tmp/out.aac", 4.0, 5.0, { - sampleRate: 48000, - channels: 2, - }); + const plan = buildPadTrimAudioPlan("/tmp/in.aac", "/tmp/out.aac", 4.0, 5.0); expect(plan.operation).toBe("pad"); expect(plan.steps).toHaveLength(1); const args = plan.steps[0]!.args; @@ -36,7 +33,7 @@ describe("buildPadTrimAudioArgs", () => { expect(args[args.indexOf("-af") + 1]).toBe("apad=whole_dur=5.000000"); expect(args[args.indexOf("-t") + 1]).toBe("5.000000"); expect(args[args.indexOf("-c:a") + 1]).toBe("aac"); - // Cleanup includes BOTH the silence tail and the concat list script. + // The single-step filter plan has no intermediate artifacts to clean up. expect(plan.cleanupPaths).toEqual([]); }); diff --git a/packages/producer/src/services/render/audioPadTrim.ts b/packages/producer/src/services/render/audioPadTrim.ts index 79128de68a..6d56cec775 100644 --- a/packages/producer/src/services/render/audioPadTrim.ts +++ b/packages/producer/src/services/render/audioPadTrim.ts @@ -214,29 +214,6 @@ function formatSeconds(sec: number): string { return sec.toFixed(6); } -/* - // Bare paths in concat directives — NOT `file://` URLs. Two failure - // modes on the round trip to a working shape: - // 1. `file:///C:/…` — FFmpeg 8.x on Windows fails to open URL-form - // paths from the concat demuxer with "Impossible to open - // file:///C:/…" (its `file:` protocol strips the scheme leaving - // `///C:/…`, which Windows path parsing then rejects). Field- - // signal reports ts=1784169914 / 1784177061 / 1784177375 (all - // win32/x64 CLI 0.7.59; the last isolated the module's arg shape - // vs a working manual pad/trim command). - // 2. Bare `/tmp/…` when the concat script was fed via `pipe:0` — - // FFmpeg's URL joiner resolves absolute POSIX paths against the - // base `pipe:` URL, producing `pipe:/tmp/…` which the demuxer - // then tries to open as a pipe. Broke Linux CI (regression shard - // + producer integration) once the `file://` prefix was dropped. - // Fix: emit bare paths AND materialize the concat script into a real - // file (see `concatListPath`/`concatListContent` on the pad-concat - // step), matching sibling `assemble.ts`'s concat convention. A real - // file's directory becomes the base URL, and absolute paths in the - // script resolve as-is on both platforms. The single-quote escaping - // (`'\''`) is the concat demuxer's own escape rule. -*/ - /** * Pad or trim `audio.aac` so its exact duration matches `frameCount / fps` * for the assembled video. From 3b9552ef9db36b133485e4ce805892346e0b9006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 25 Jul 2026 21:16:28 +0000 Subject: [PATCH 11/11] fix(producer): use portable audio padding filter --- .../src/services/render/audioPadTrim.test.ts | 16 +++++++--------- .../producer/src/services/render/audioPadTrim.ts | 6 +----- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/producer/src/services/render/audioPadTrim.test.ts b/packages/producer/src/services/render/audioPadTrim.test.ts index 90fe862b46..4f79ac5bf9 100644 --- a/packages/producer/src/services/render/audioPadTrim.test.ts +++ b/packages/producer/src/services/render/audioPadTrim.test.ts @@ -30,7 +30,8 @@ describe("buildPadTrimAudioArgs", () => { expect(plan.steps).toHaveLength(1); const args = plan.steps[0]!.args; expect(args[args.indexOf("-i") + 1]).toBe("/tmp/in.aac"); - expect(args[args.indexOf("-af") + 1]).toBe("apad=whole_dur=5.000000"); + expect(args[args.indexOf("-af") + 1]).toBe("apad,atrim=0:5.000000"); + expect(args.join(" ")).not.toContain("whole_dur"); expect(args[args.indexOf("-t") + 1]).toBe("5.000000"); expect(args[args.indexOf("-c:a") + 1]).toBe("aac"); // The single-step filter plan has no intermediate artifacts to clean up. @@ -92,13 +93,9 @@ describe("buildPadTrimAudioArgs", () => { expect(trimNeeded.operation).toBe("trim"); }); - it("uses apad for Windows-safe duration normalization", () => { - // Regression pin for field-signal reports ts=1784169914 / 1784177061 / - // ts=1784177375 (win32/x64, CLI 0.7.59, ffmpeg 8.1.1-full_build). The - // concat demuxer's file open on Windows in FFmpeg 8.x rejects - // `file:///C:/…` URLs with "Impossible to open …". The concat script - // MUST use bare paths. Match sibling `assemble.ts` / - // `chunkEncoder.ts` conventions. + it("uses the portable apad/atrim filter for Windows duration normalization", () => { + // Bundled Windows FFmpeg builds reject `apad=whole_dur`. Match the + // portable finite-padding shape used by the main audio mixer. const winPlan = buildPadTrimAudioPlan( "C:\\Users\\alice\\AppData\\Local\\Temp\\hf-render-abc\\audio.aac", "C:\\Users\\alice\\AppData\\Local\\Temp\\hf-render-abc\\audio-padded.aac", @@ -108,7 +105,8 @@ describe("buildPadTrimAudioArgs", () => { expect(winPlan.operation).toBe("pad"); const args = winPlan.steps[0]!.args; expect(args).toContain("-af"); - expect(args[args.indexOf("-af") + 1]).toBe("apad=whole_dur=5.000000"); + expect(args[args.indexOf("-af") + 1]).toBe("apad,atrim=0:5.000000"); + expect(args.join(" ")).not.toContain("whole_dur"); }); }); diff --git a/packages/producer/src/services/render/audioPadTrim.ts b/packages/producer/src/services/render/audioPadTrim.ts index 6d56cec775..9aa30503f2 100644 --- a/packages/producer/src/services/render/audioPadTrim.ts +++ b/packages/producer/src/services/render/audioPadTrim.ts @@ -142,7 +142,7 @@ export function buildPadTrimAudioPlan( "-i", audioPath, "-af", - `apad=whole_dur=${targetSec}`, + `apad,atrim=0:${targetSec}`, "-t", targetSec, "-c:a", @@ -280,10 +280,6 @@ export async function padOrTrimAudioToVideoFrameCount( try { for (const step of plan.steps) { - // Materialize the concat-demuxer script to a real file when the step - // needs one. Doing this here (instead of piping via `pipe:0` in the - // runner) is what makes the demuxer's URL resolution treat - // absolute paths as absolute — see `concatFileLine` for context. const ffmpegResult = await runner(step.args); if (!ffmpegResult.success) { return {