diff --git a/packages/engine/src/utils/ffprobe.test.ts b/packages/engine/src/utils/ffprobe.test.ts index e79363b44..5e2d842a4 100644 --- a/packages/engine/src/utils/ffprobe.test.ts +++ b/packages/engine/src/utils/ffprobe.test.ts @@ -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(); diff --git a/packages/engine/src/utils/ffprobe.ts b/packages/engine/src/utils/ffprobe.ts index 2fbaf4225..7168899a9 100644 --- a/packages/engine/src/utils/ffprobe.ts +++ b/packages/engine/src/utils/ffprobe.ts @@ -53,6 +53,8 @@ function parseProbeJson(stdout: string): FFProbeOutput { const videoMetadataCache = new Map>(); const audioMetadataCache = new Map>(); +// 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 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, }; })(); diff --git a/packages/producer/src/services/render/audioPadTrim.ts b/packages/producer/src/services/render/audioPadTrim.ts index b089b09a8..89aff66b2 100644 --- a/packages/producer/src/services/render/audioPadTrim.ts +++ b/packages/producer/src/services/render/audioPadTrim.ts @@ -416,7 +416,8 @@ function parseFrameRate(rate: string): { fpsNum: number; fpsDen: number } { } async function defaultProbeAudioInfo(audioPath: string): Promise { - // 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, diff --git a/packages/producer/src/services/render/stages/assembleStage.test.ts b/packages/producer/src/services/render/stages/assembleStage.test.ts new file mode 100644 index 000000000..e18da3be7 --- /dev/null +++ b/packages/producer/src/services/render/stages/assembleStage.test.ts @@ -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 { + 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(); + }); +}); diff --git a/packages/producer/src/services/render/stages/assembleStage.ts b/packages/producer/src/services/render/stages/assembleStage.ts index c3e4ebc3e..31eea26a5 100644 --- a/packages/producer/src/services/render/stages/assembleStage.ts +++ b/packages/producer/src/services/render/stages/assembleStage.ts @@ -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 - +