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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user