diff --git a/packages/engine/src/services/audioMixer.test.ts b/packages/engine/src/services/audioMixer.test.ts index 341c9130da..f1aed1cc6f 100644 --- a/packages/engine/src/services/audioMixer.test.ts +++ b/packages/engine/src/services/audioMixer.test.ts @@ -296,7 +296,7 @@ describe("processCompositionAudio", () => { expect(filter).toContain("volume=0"); expect(filter).toContain("[mixed]volume=1[out]"); - expect(filter).toContain("apad,atrim=0:2"); + expect(filter).toContain("apad,asetpts=N/SR/TB,atrim=0:2"); expect(filter).not.toContain("whole_dur"); expect(filter).not.toContain("normalize="); expect(filter).not.toContain("weights="); @@ -464,7 +464,7 @@ describe("processCompositionAudio", () => { // 2 s clip + the 1.9 s tail 0.6 + size * 2.6 generates. expect(filter).toContain("atrim=0:3.9,"); // And still cut at the composition's end, so a tail cannot extend the video. - expect(filter).toContain("apad,atrim=0:8"); + expect(filter).toContain("apad,asetpts=N/SR/TB,atrim=0:8"); }); it("hands the volume envelope to the FX pass instead of ducking the file after it", async () => { @@ -1078,6 +1078,60 @@ describe("processCompositionAudio", () => { expect((filter?.match(/apad,/g) ?? []).length).toBe(trackCount); }); + it("renumbers timestamps between apad and atrim on every mixed branch", async () => { + // Regression: `apad` then `atrim` is the portable pad-to-length shape -- + // #2769 moved off `apad=whole_dur=` because some builds reject that option. + // But on FFmpeg 5.x-8.0.x the padded samples carry timestamps `atrim` + // misreads, so a delayed branch sounds at t=0 and, past three branches, the + // last one vanishes from the mix. `asetpts=N/SR/TB` between the two rebuilds + // the timestamps from the sample count and costs no portability, since all + // three filters exist in every build we support. + const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); + const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); + tempDirs.push(baseDir, workDir); + writeFileSync(join(baseDir, "a.wav"), "stub"); + writeFileSync(join(baseDir, "b.wav"), "stub"); + + // Two branches, the second delayed: the shape that misplaced audio. + await processCompositionAudio( + [ + { + id: "a", + src: "a.wav", + start: 0, + end: 1, + mediaStart: 0, + layer: 0, + volume: 1, + type: "audio", + }, + { + id: "b", + src: "b.wav", + start: 4, + end: 5, + mediaStart: 0, + layer: 1, + volume: 1, + type: "audio", + }, + ], + baseDir, + workDir, + join(baseDir, "out.m4a"), + 8, + ); + + const filter = capturedFilterScripts.at(-1) ?? ""; + const branches = filter.match(/apad[^;]*/g) ?? []; + expect(branches).toHaveLength(2); + for (const branch of branches) { + expect(branch).toMatch(/^apad,asetpts=N\/SR\/TB,atrim=0:/); + } + // The portability constraint #2769 established still holds. + expect(filter).not.toContain("whole_dur"); + }); + it("retries with the current file-valued filter option when a nightly removes the legacy alias", async () => { const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts index 2d856d1d7e..6265ba0b99 100644 --- a/packages/engine/src/services/audioMixer.ts +++ b/packages/engine/src/services/audioMixer.ts @@ -709,8 +709,19 @@ async function mixAudioTracks( // can run over what follows but never past the end of the video. const trimDuration = track.end - track.start + (track.tailSeconds ?? 0); const volumeFilter = buildVolumeExpression(track, ignoreAutomation); + // `apad` then `atrim` is the portable pad-to-length shape: PR #2769 moved + // off `apad=whole_dur=` because some FFmpeg builds reject that option + // outright ("Error applying option 'whole_dur': Option not found"). + // But on FFmpeg 5.x through 8.0.x the samples `apad` appends carry + // timestamps the following `atrim` misreads, so a delayed branch lands at + // t=0 and, once four or more branches are mixed, the last one disappears + // entirely. `asetpts=N/SR/TB` renumbers the padded stream from the sample + // count before the trim reads it, which fixes the misplacement while + // keeping the filter set every build supports. Verified correct on 4.2.7, + // 7.0.2, an 8.x nightly and 8.1.1; the un-reset form is wrong on the + // middle two. filterParts.push( - `[${i}:a]atrim=0:${formatFilterNumber(trimDuration)},${volumeFilter},adelay=${delayMs}|${delayMs},apad,atrim=0:${formatFilterNumber(totalDuration)}[a${i}]`, + `[${i}:a]atrim=0:${formatFilterNumber(trimDuration)},${volumeFilter},adelay=${delayMs}|${delayMs},apad,asetpts=N/SR/TB,atrim=0:${formatFilterNumber(totalDuration)}[a${i}]`, ); }); diff --git a/packages/producer/src/services/audioExtractor.ts b/packages/producer/src/services/audioExtractor.ts index ccddf32055..6e6f0fa65d 100644 --- a/packages/producer/src/services/audioExtractor.ts +++ b/packages/producer/src/services/audioExtractor.ts @@ -211,8 +211,9 @@ async function mixTracks( const delayMs = Math.round(track.start * 1000); const trimDuration = track.duration > 0 ? track.duration : totalDuration; + // See audioMixer.ts for why asetpts sits between apad and atrim. filterParts.push( - `[${i}:a]atrim=0:${trimDuration},volume=${track.volume},adelay=${delayMs}|${delayMs},apad,atrim=0:${totalDuration}[a${i}]`, + `[${i}:a]atrim=0:${trimDuration},volume=${track.volume},adelay=${delayMs}|${delayMs},apad,asetpts=N/SR/TB,atrim=0:${totalDuration}[a${i}]`, ); });