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
+63 -4
View File
@@ -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 {
@@ -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";
import { appendVp9CpuUsedArg } from "./vp9Options.js";
@@ -45,6 +46,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);
@@ -683,7 +728,7 @@ 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);
@@ -691,14 +736,28 @@ export async function muxVideoWithAudio(
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.