mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(render): normalize local AAC duration before mux (#2472)
* fix(render): normalize local AAC duration before mux * style(render): apply repository formatter * fix(render): count AAC packets for duration normalization Older FFmpeg versions estimate raw ADTS duration from bitrate and can undercount variable-bitrate audio, causing the normalizer to append a false silence tail. Derive the mixed AAC duration from packet count and sample rate instead. * fix(render): isolate normalized audio temp path * fix(engine): centralize AAC packet duration * test(producer): refresh AAC duration golden
This commit is contained in:
@@ -203,6 +203,58 @@ describe("ffprobe missing-binary fallback", () => {
|
||||
expect(calls[0]?.command).toBe(resolve("/tools/ffprobe.exe"));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "non-AAC metadata", codec: "mp3", packets: undefined, expected: 1.25, calls: 1 },
|
||||
{ name: "valid AAC packet count", codec: "aac", packets: "783", expected: 16.704, calls: 2 },
|
||||
{
|
||||
name: "missing AAC packet count",
|
||||
codec: "aac",
|
||||
packets: undefined,
|
||||
expected: 1.25,
|
||||
calls: 2,
|
||||
},
|
||||
{ name: "zero AAC packet count", codec: "aac", packets: "0", expected: 1.25, calls: 2 },
|
||||
{
|
||||
name: "invalid AAC packet count",
|
||||
codec: "aac",
|
||||
packets: "invalid",
|
||||
expected: 1.25,
|
||||
calls: 2,
|
||||
},
|
||||
])(
|
||||
"derives audio duration for $name",
|
||||
async ({ codec, packets, expected, calls: expectedCalls }) => {
|
||||
const outcomes: SpawnOutcome[] = [
|
||||
{
|
||||
kind: "exit",
|
||||
code: 0,
|
||||
stdout: JSON.stringify({
|
||||
streams: [
|
||||
{ codec_type: "audio", codec_name: codec, sample_rate: "48000", channels: 2 },
|
||||
],
|
||||
format: { duration: "1.25", bit_rate: "128000" },
|
||||
}),
|
||||
},
|
||||
];
|
||||
if (codec === "aac") {
|
||||
outcomes.push({
|
||||
kind: "exit",
|
||||
code: 0,
|
||||
stdout: JSON.stringify({ streams: [{ nb_read_packets: packets }], format: {} }),
|
||||
});
|
||||
}
|
||||
const { spawn, calls } = createSpawnSpy(outcomes);
|
||||
vi.resetModules();
|
||||
vi.doMock("child_process", () => ({ spawn }));
|
||||
|
||||
const { extractAudioMetadata } = await import("./ffprobe.js");
|
||||
const meta = await extractAudioMetadata(`/tmp/${codec}-${packets ?? "none"}.audio`);
|
||||
|
||||
expect(meta.durationSeconds).toBeCloseTo(expected, 6);
|
||||
expect(calls).toHaveLength(expectedCalls);
|
||||
},
|
||||
);
|
||||
|
||||
it("extractMediaMetadata falls back to PNG cICP metadata when ffprobe is missing", async () => {
|
||||
const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]);
|
||||
hidePathBinaries();
|
||||
|
||||
@@ -53,6 +53,8 @@ function parseProbeJson(stdout: string): FFProbeOutput {
|
||||
|
||||
const videoMetadataCache = new Map<string, Promise<VideoMetadata>>();
|
||||
const audioMetadataCache = new Map<string, Promise<AudioMetadata>>();
|
||||
// FFmpeg's built-in AAC encoder emits AAC-LC, which has 1024 samples per packet.
|
||||
const AAC_LC_SAMPLES_PER_PACKET = 1024;
|
||||
|
||||
export interface VideoColorSpace {
|
||||
/** Color transfer characteristics, e.g. "bt709", "smpte2084", "arib-std-b67" */
|
||||
@@ -98,6 +100,7 @@ interface FFProbeStream {
|
||||
height?: number;
|
||||
duration?: string;
|
||||
nb_frames?: string;
|
||||
nb_read_packets?: string;
|
||||
pix_fmt?: string;
|
||||
r_frame_rate?: string;
|
||||
avg_frame_rate?: string;
|
||||
@@ -366,15 +369,36 @@ export async function extractAudioMetadata(filePath: string): Promise<AudioMetad
|
||||
const audioStream = output.streams.find((s) => s.codec_type === "audio");
|
||||
if (!audioStream) throw new Error("[FFmpeg] No audio stream found");
|
||||
|
||||
const durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
|
||||
let durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
|
||||
const streamDuration = audioStream.duration ? parseFloat(audioStream.duration) : undefined;
|
||||
const sampleRate = audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100;
|
||||
const audioCodec = audioStream.codec_name || "unknown";
|
||||
if (audioCodec === "aac" && sampleRate > 0) {
|
||||
const packetStdout = await runFfprobe([
|
||||
"-v",
|
||||
"quiet",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-count_packets",
|
||||
"-show_entries",
|
||||
"stream=nb_read_packets",
|
||||
"-print_format",
|
||||
"json",
|
||||
filePath,
|
||||
]);
|
||||
const packetOutput = parseProbeJson(packetStdout);
|
||||
const packetCount = Number(packetOutput.streams[0]?.nb_read_packets);
|
||||
if (Number.isFinite(packetCount) && packetCount > 0) {
|
||||
durationSeconds = (packetCount * AAC_LC_SAMPLES_PER_PACKET) / sampleRate;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
durationSeconds,
|
||||
streamDurationSeconds: streamDuration && streamDuration > 0 ? streamDuration : undefined,
|
||||
sampleRate: audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100,
|
||||
sampleRate,
|
||||
channels: audioStream.channels || 2,
|
||||
audioCodec: audioStream.codec_name || "unknown",
|
||||
audioCodec,
|
||||
bitrate: output.format.bit_rate ? parseInt(output.format.bit_rate) : undefined,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -416,7 +416,8 @@ function parseFrameRate(rate: string): { fpsNum: number; fpsDen: number } {
|
||||
}
|
||||
|
||||
async function defaultProbeAudioInfo(audioPath: string): Promise<AudioProbeInfo> {
|
||||
// extractAudioMetadata is the shared ffprobe wrapper (caches results).
|
||||
// The shared ffprobe wrapper derives AAC-LC duration from packet count so
|
||||
// every consumer sees the same VBR-safe metadata.
|
||||
const metadata: AudioMetadata = await extractAudioMetadata(audioPath);
|
||||
return {
|
||||
durationSeconds: metadata.durationSeconds,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AssembleStageInput } from "./assembleStage.js";
|
||||
|
||||
const { muxVideoWithAudioMock, padOrTrimAudioMock } = vi.hoisted(() => ({
|
||||
muxVideoWithAudioMock: vi.fn(),
|
||||
padOrTrimAudioMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@hyperframes/engine", () => ({
|
||||
applyFaststart: vi.fn(),
|
||||
muxVideoWithAudio: muxVideoWithAudioMock,
|
||||
}));
|
||||
|
||||
vi.mock("../audioPadTrim.js", () => ({
|
||||
padOrTrimAudioToVideoFrameCount: padOrTrimAudioMock,
|
||||
}));
|
||||
|
||||
vi.mock("../shared.js", () => ({
|
||||
updateJobStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
import { runAssembleStage } from "./assembleStage.js";
|
||||
|
||||
function makeInput(overrides: Partial<AssembleStageInput> = {}): AssembleStageInput {
|
||||
return {
|
||||
job: {
|
||||
id: "aac-duration-parity",
|
||||
config: { fps: { num: 30, den: 1 }, quality: "draft" },
|
||||
status: "queued",
|
||||
progress: 0,
|
||||
currentStage: "queued",
|
||||
createdAt: new Date(0),
|
||||
duration: 1,
|
||||
},
|
||||
videoOnlyPath: "/tmp/video-only.mp4",
|
||||
audioOutputPath: "/tmp/audio.aac",
|
||||
outputPath: "/tmp/output.mp4",
|
||||
hasAudio: true,
|
||||
abortSignal: undefined,
|
||||
assertNotAborted: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("runAssembleStage audio duration parity", () => {
|
||||
beforeEach(() => {
|
||||
muxVideoWithAudioMock.mockReset();
|
||||
padOrTrimAudioMock.mockReset();
|
||||
muxVideoWithAudioMock.mockResolvedValue({ success: true });
|
||||
padOrTrimAudioMock.mockResolvedValue({
|
||||
success: true,
|
||||
outputPath: "/tmp/audio.duration-normalized.aac",
|
||||
targetDurationSeconds: 1,
|
||||
sourceDurationSeconds: 1.024,
|
||||
operation: "trim",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes mixed AAC to the encoded video frame duration before muxing", async () => {
|
||||
await runAssembleStage(makeInput());
|
||||
|
||||
expect(padOrTrimAudioMock).toHaveBeenCalledWith({
|
||||
videoPath: "/tmp/video-only.mp4",
|
||||
audioPath: "/tmp/audio.aac",
|
||||
outputPath: "/tmp/audio.duration-normalized.aac",
|
||||
});
|
||||
expect(muxVideoWithAudioMock).toHaveBeenCalledWith(
|
||||
"/tmp/video-only.mp4",
|
||||
"/tmp/audio.duration-normalized.aac",
|
||||
"/tmp/output.mp4",
|
||||
undefined,
|
||||
{ audioCodec: "aac" },
|
||||
{ num: 30, den: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a distinct AAC normalization path when the mixed-audio extension differs", async () => {
|
||||
await runAssembleStage(makeInput({ audioOutputPath: "/tmp/audio.m4a" }));
|
||||
|
||||
expect(padOrTrimAudioMock).toHaveBeenCalledWith({
|
||||
videoPath: "/tmp/video-only.mp4",
|
||||
audioPath: "/tmp/audio.m4a",
|
||||
outputPath: "/tmp/audio.duration-normalized.aac",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails instead of muxing an unnormalized AAC tail", async () => {
|
||||
padOrTrimAudioMock.mockResolvedValue({
|
||||
success: false,
|
||||
outputPath: "/tmp/audio.duration-normalized.aac",
|
||||
targetDurationSeconds: 1,
|
||||
sourceDurationSeconds: 1.024,
|
||||
operation: "trim",
|
||||
error: "ffmpeg trim failed",
|
||||
});
|
||||
|
||||
await expect(runAssembleStage(makeInput())).rejects.toThrow(
|
||||
"Audio duration normalization failed: ffmpeg trim failed",
|
||||
);
|
||||
expect(muxVideoWithAudioMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,9 @@
|
||||
*/
|
||||
|
||||
import { applyFaststart, muxVideoWithAudio } from "@hyperframes/engine";
|
||||
import { extname } from "node:path";
|
||||
import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js";
|
||||
import { padOrTrimAudioToVideoFrameCount } from "../audioPadTrim.js";
|
||||
import { updateJobStatus } from "../shared.js";
|
||||
|
||||
export interface AssembleStageInput {
|
||||
@@ -55,9 +57,23 @@ export async function runAssembleStage(input: AssembleStageInput): Promise<Assem
|
||||
updateJobStatus(job, "assembling", "Assembling final video", 90, onProgress);
|
||||
|
||||
if (hasAudio) {
|
||||
const audioExtension = extname(audioOutputPath);
|
||||
const audioStem = audioExtension
|
||||
? audioOutputPath.slice(0, -audioExtension.length)
|
||||
: audioOutputPath;
|
||||
const normalizedAudioPath = `${audioStem}.duration-normalized.aac`;
|
||||
const normalizeResult = await padOrTrimAudioToVideoFrameCount({
|
||||
videoPath: videoOnlyPath,
|
||||
audioPath: audioOutputPath,
|
||||
outputPath: normalizedAudioPath,
|
||||
});
|
||||
assertNotAborted();
|
||||
if (!normalizeResult.success) {
|
||||
throw new Error(`Audio duration normalization failed: ${normalizeResult.error}`);
|
||||
}
|
||||
const muxResult = await muxVideoWithAudio(
|
||||
videoOnlyPath,
|
||||
audioOutputPath,
|
||||
normalizeResult.outputPath,
|
||||
outputPath,
|
||||
abortSignal,
|
||||
{ audioCodec: "aac" },
|
||||
|
||||
@@ -1048,7 +1048,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Background Audio (A-roll Audio) -->
|
||||
<audio id="aroll-audio" src="_remote_media/download_054a544691a0.mp4" data-start="0" data-duration="16.043" data-track-index="1" data-end="16.043"></audio>
|
||||
<audio id="aroll-audio" src="_remote_media/download_054a544691a0.mp4" data-start="0" data-duration="16.064" data-track-index="1" data-end="16.064"></audio>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user