fix(engine): preserve AAC start time during MP4 mux (#1615)

* fix(engine): copy mixed AAC during MP4 mux

* fix(producer): avoid AAC re-encode in distributed audio pad

* fix(engine): probe AAC sidecars before mux copy decision

* fix(producer): avoid temp concat file for audio padding
This commit is contained in:
Miguel Ángel
2026-06-20 17:22:15 -04:00
committed by GitHub
parent 8408a44745
commit 0473254bdd
7 changed files with 558 additions and 56 deletions
@@ -18,6 +18,7 @@ afterEach(() => {
}
vi.resetModules();
vi.doUnmock("child_process");
vi.doUnmock("../utils/ffprobe.js");
vi.useRealTimers();
});
@@ -88,6 +89,11 @@ function emitClose(proc: FakeProc, code: number): void {
proc.emit("close", code);
}
async function flushMuxCodecResolution(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe("ENCODER_PRESETS", () => {
it("has draft, standard, and high presets", () => {
expect(ENCODER_PRESETS).toHaveProperty("draft");
@@ -355,6 +361,212 @@ describe("encodeFramesChunkedConcat ffmpegEncodeTimeout", () => {
});
});
describe("muxVideoWithAudio audio codec handling", () => {
it("copies HyperFrames AAC sidecars into MP4 instead of re-encoding", 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.aac",
"/tmp/output.mp4",
undefined,
undefined,
{ num: 30, den: 1 },
);
await flushMuxCodecResolution();
expect(calls).toHaveLength(1);
expect(calls[0]!.args).toEqual([
"-i",
"/tmp/video-only.mp4",
"-i",
"/tmp/audio.aac",
"-c:v",
"copy",
"-c:a",
"copy",
"-movflags",
"+faststart",
"-avoid_negative_ts",
"make_zero",
"-r",
"30",
"-shortest",
"-y",
"/tmp/output.mp4",
]);
expect(calls[0]!.args).not.toContain("-use_editlist");
emitClose(calls[0]!.proc, 0);
await expect(muxPromise).resolves.toMatchObject({
success: true,
outputPath: "/tmp/output.mp4",
});
});
it("uses the caller-provided AAC codec contract instead of the sidecar extension", 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-sidecar",
"/tmp/output.mp4",
undefined,
{ audioCodec: "aac" },
{ num: 30, den: 1 },
);
await flushMuxCodecResolution();
expect(calls).toHaveLength(1);
expect(calls[0]!.args).toContain("-c:a");
expect(calls[0]!.args[calls[0]!.args.indexOf("-c:a") + 1]).toBe("copy");
expect(calls[0]!.args).not.toContain("-b:a");
expect(calls[0]!.args).toContain("+faststart");
emitClose(calls[0]!.proc, 0);
await expect(muxPromise).resolves.toMatchObject({
success: true,
outputPath: "/tmp/output.mp4",
});
});
it("probes unknown-extension AAC sidecars before choosing the MP4 copy path", async () => {
const { spawn, calls } = createSpawnSpy();
const extractAudioMetadata = vi.fn(async () => ({
durationSeconds: 1,
sampleRate: 48000,
channels: 2,
audioCodec: "aac",
}));
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
vi.doMock("../utils/ffprobe.js", () => ({ extractAudioMetadata }));
const { muxVideoWithAudio } = await import("./chunkEncoder.js");
const muxPromise = muxVideoWithAudio(
"/tmp/video-only.mp4",
"/tmp/audio-sidecar",
"/tmp/output.mp4",
);
await flushMuxCodecResolution();
expect(extractAudioMetadata).toHaveBeenCalledWith("/tmp/audio-sidecar");
expect(calls).toHaveLength(1);
expect(calls[0]!.args).toContain("-c:a");
expect(calls[0]!.args[calls[0]!.args.indexOf("-c:a") + 1]).toBe("copy");
expect(calls[0]!.args).not.toContain("-b:a");
emitClose(calls[0]!.proc, 0);
await expect(muxPromise).resolves.toMatchObject({
success: true,
outputPath: "/tmp/output.mp4",
});
});
it("keeps probed non-AAC unknown-extension sidecars on the MP4 transcode path", async () => {
const { spawn, calls } = createSpawnSpy();
const extractAudioMetadata = vi.fn(async () => ({
durationSeconds: 1,
sampleRate: 48000,
channels: 2,
audioCodec: "mp3",
}));
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
vi.doMock("../utils/ffprobe.js", () => ({ extractAudioMetadata }));
const { muxVideoWithAudio } = await import("./chunkEncoder.js");
const muxPromise = muxVideoWithAudio(
"/tmp/video-only.mp4",
"/tmp/audio-sidecar",
"/tmp/output.mp4",
);
await flushMuxCodecResolution();
expect(extractAudioMetadata).toHaveBeenCalledWith("/tmp/audio-sidecar");
expect(calls).toHaveLength(1);
expect(calls[0]!.args).toContain("-c:a");
expect(calls[0]!.args[calls[0]!.args.indexOf("-c:a") + 1]).toBe("aac");
expect(calls[0]!.args).toContain("-b:a");
emitClose(calls[0]!.proc, 0);
await expect(muxPromise).resolves.toMatchObject({ success: true });
});
it("still transcodes non-AAC audio when muxing MP4", 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.wav",
"/tmp/output.mp4",
);
await flushMuxCodecResolution();
expect(calls).toHaveLength(1);
expect(calls[0]!.args).toContain("-c:a");
expect(calls[0]!.args[calls[0]!.args.indexOf("-c:a") + 1]).toBe("aac");
expect(calls[0]!.args).toContain("-b:a");
expect(calls[0]!.args).toContain("+faststart");
emitClose(calls[0]!.proc, 0);
await expect(muxPromise).resolves.toMatchObject({ success: true });
});
it("copies HyperFrames AAC sidecars into MOV containers without MP4 faststart flags", async () => {
const { spawn, calls } = createSpawnSpy();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { muxVideoWithAudio } = await import("./chunkEncoder.js");
const muxPromise = muxVideoWithAudio(
"/tmp/video-only.mov",
"/tmp/audio.aac",
"/tmp/output.mov",
);
await flushMuxCodecResolution();
expect(calls).toHaveLength(1);
expect(calls[0]!.args).toContain("-c:a");
expect(calls[0]!.args[calls[0]!.args.indexOf("-c:a") + 1]).toBe("copy");
expect(calls[0]!.args).not.toContain("-b:a");
expect(calls[0]!.args).not.toContain("+faststart");
emitClose(calls[0]!.proc, 0);
await expect(muxPromise).resolves.toMatchObject({ success: true });
});
it("keeps WebM audio on the Opus transcode path", async () => {
const { spawn, calls } = createSpawnSpy();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { muxVideoWithAudio } = await import("./chunkEncoder.js");
const muxPromise = muxVideoWithAudio(
"/tmp/video-only.webm",
"/tmp/audio.aac",
"/tmp/output.webm",
);
expect(calls).toHaveLength(1);
expect(calls[0]!.args).toContain("-c:a");
expect(calls[0]!.args[calls[0]!.args.indexOf("-c:a") + 1]).toBe("libopus");
expect(calls[0]!.args).not.toContain("+faststart");
emitClose(calls[0]!.proc, 0);
await expect(muxPromise).resolves.toMatchObject({ success: true });
});
});
describe("getEncoderPreset", () => {
it("returns h264 with yuv420p for mp4 format", () => {
const preset = getEncoderPreset("standard", "mp4");