mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 19:06:04 +00:00
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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user