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
212 changes: 212 additions & 0 deletions packages/engine/src/services/chunkEncoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ afterEach(() => {
}
vi.resetModules();
vi.doUnmock("child_process");
vi.doUnmock("../utils/ffprobe.js");
vi.useRealTimers();
});

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
67 changes: 63 additions & 4 deletions packages/engine/src/services/chunkEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { spawn } from "child_process";
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "fs";
import { join, dirname } from "path";
import { join, dirname, extname } from "path";
import { trackChildProcess } from "../utils/processTracker.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import {
Expand All @@ -20,6 +20,7 @@ import {
import { type HdrTransfer, getHdrEncoderColorParams } from "../utils/hdr.js";
import { formatFfmpegError, runFfmpeg } from "../utils/runFfmpeg.js";
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
import { extractAudioMetadata } from "../utils/ffprobe.js";
import { type Fps, fpsToFfmpegArg } from "@hyperframes/core";
import type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js";

Expand All @@ -44,6 +45,50 @@ function appendEncodeTimeoutMessage(error: string, timedOut: boolean, timeoutMs:
return `${error}\nFFmpeg killed after exceeding ffmpegEncodeTimeout (${timeoutMs} ms)`;
}

function isAacSidecar(audioPath: string): boolean {
return extname(audioPath).toLowerCase() === ".aac";
}

const KNOWN_NON_AAC_AUDIO_EXTENSIONS = new Set([
".flac",
".mp3",
".oga",
".ogg",
".opus",
".wav",
".webm",
]);

export interface MuxVideoWithAudioOptions extends Partial<
Pick<EngineConfig, "ffmpegProcessTimeout">
> {
/**
* Codec of the sidecar audio when the caller already knows it. HyperFrames
* render paths pass the mixed AAC sidecar by contract, so muxing should not
* depend on the file extension alone.
*/
audioCodec?: "aac";
}

async function shouldCopyAacSidecar(
audioPath: string,
options: MuxVideoWithAudioOptions | undefined,
) {
if (options?.audioCodec === "aac" || isAacSidecar(audioPath)) return true;

const audioExtension = extname(audioPath).toLowerCase();
if (KNOWN_NON_AAC_AUDIO_EXTENSIONS.has(audioExtension)) return false;

try {
const metadata = await extractAudioMetadata(audioPath);
return metadata.audioCodec === "aac";
} catch {
// Preserve the pre-existing fallback for invalid or unprobeable sidecars:
// let the final ffmpeg transcode path surface the actionable mux error.
return false;
}
}

/**
* Get encoder preset for a given quality and output format.
* WebM uses VP9 with alpha-capable pixel format; MP4 uses h264 (or h265 for HDR);
Expand Down Expand Up @@ -690,22 +735,36 @@ export async function muxVideoWithAudio(
audioPath: string,
outputPath: string,
signal?: AbortSignal,
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
config?: MuxVideoWithAudioOptions,
fps?: Fps,
): Promise<MuxResult> {
const outputDir = dirname(outputPath);
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });

const isWebm = outputPath.endsWith(".webm");
const isMov = outputPath.endsWith(".mov");
const shouldCopyAudio = isWebm ? false : await shouldCopyAacSidecar(audioPath, config);
const args = ["-i", videoPath, "-i", audioPath, "-c:v", "copy"];

if (isWebm) {
args.push("-c:a", "libopus", "-b:a", "128k");
} else if (isMov) {
args.push("-c:a", "aac", "-b:a", "192k");
if (shouldCopyAudio) {
args.push("-c:a", "copy");
} else {
args.push("-c:a", "aac", "-b:a", "192k");
}
} else {
args.push("-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart");
// processCompositionAudio (audioMixer.ts) performs the AAC encode and
// owns the single encoder-priming interval. Copying that sidecar into
// MP4 preserves the correct priming metadata; re-encoding it during mux
// creates another priming interval that ffmpeg writes as an empty leading
// video edit list, which QuickTime/Safari render as a black first frame.
if (shouldCopyAudio) {
args.push("-c:a", "copy", "-movflags", "+faststart");
} else {
args.push("-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart");
}
}
// PTS bases can diverge during mux and reintroduce negative DTS. See
// buildEncoderArgs for the full reasoning on why that breaks playback.
Expand Down
Loading
Loading