Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions packages/engine/src/services/audioMixer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=");
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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-"));
Expand Down
13 changes: 12 additions & 1 deletion packages/engine/src/services/audioMixer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}]`,
);
});

Expand Down
3 changes: 2 additions & 1 deletion packages/producer/src/services/audioExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}]`,
);
});

Expand Down
Loading