From 7ffe7840aa051c7b0b3111f409702920cdb3ba60 Mon Sep 17 00:00:00 2001 From: miga-heygen Date: Fri, 11 Sep 2026 22:34:24 +0000 Subject: [PATCH] fix(engine): recover audio duration via packet scan when the container header has none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractAudioMetadata() previously trusted only ffprobe's container-header duration (format.duration). A container whose encoder never got a chance to seek back and patch that field in — e.g. audio piped to a non-seekable destination, which is exactly what a browser MediaRecorder-style capture does — comes back with no duration at format OR stream level, not merely zero. With no fallback, the render pipeline treated the track as zero-length and silently dropped it: no error, no warning, just missing audio in the output. The function already had one precedent for recovering a bad duration (an AAC-LC-specific packet-count recomputation), but it only applies to that one codec/profile combination. This generalizes the idea to any codec: when duration is still unrecoverable after the existing logic, prefer the already-probed stream-level duration if usable, otherwise scan every packet's timestamp to EOF and take the last one's pts_time + duration_time. Only the true last parsed line is trusted, mirroring the existing final-video-frame-timestamp prober a few hundred lines above in the same file (same bounded-output settings, same reasoning): the output-retention limit that bounds a long probe's cost keeps only the tail of ffprobe's output, which can slice through the middle of an early line, but never through the last one. Taking the last line only (not a max across all lines) avoids a truncation artifact ever masquerading as the answer. Adds regression coverage: successful recovery from a full scan, preferring the cheap stream-level duration when present, graceful no-op when the scan finds nothing or the probe itself fails, tolerating codecs that omit per-packet duration, correct behavior when a truncated leading line contains a bogus large timestamp, and confirming the new fallback stays out of the way when the existing codec-specific refinement already succeeded. Co-Authored-By: Miguel Angel --- packages/engine/src/utils/ffprobe.test.ts | 148 ++++++++++++++++++++++ packages/engine/src/utils/ffprobe.ts | 79 +++++++++++- 2 files changed, 226 insertions(+), 1 deletion(-) diff --git a/packages/engine/src/utils/ffprobe.test.ts b/packages/engine/src/utils/ffprobe.test.ts index 20186127dc..442d844bc9 100644 --- a/packages/engine/src/utils/ffprobe.test.ts +++ b/packages/engine/src/utils/ffprobe.test.ts @@ -861,6 +861,154 @@ describe("ffprobe missing-binary fallback", () => { }); }); +// PRINFRA-692: a browser MediaRecorder-produced WebM (or any encode to a +// non-seekable destination) never gets a Duration element patched back into +// its header, so ffprobe reports no duration at format OR stream level — not +// merely zero. Without a fallback, extractAudioMetadata returns +// durationSeconds: 0, and the render pipeline silently drops the whole audio +// track (htmlCompiler / audioMixer both treat a non-positive duration as "no +// usable audio"). These tests cover the packet-timestamp EOF scan that +// recovers the real duration in that case. +describe("extractAudioMetadata packet-scan duration recovery (no format.duration at all)", () => { + afterEach(() => { + vi.resetModules(); + vi.doUnmock("child_process"); + }); + + const opusStreamNoDuration = (streamDuration?: string) => + JSON.stringify({ + streams: [ + { + codec_type: "audio", + codec_name: "opus", + sample_rate: "48000", + channels: 1, + duration: streamDuration, + }, + ], + format: {}, + }); + + async function probe(outcomes: SpawnOutcome[], file: string) { + const { spawn, calls } = createSpawnSpy(outcomes); + vi.resetModules(); + vi.doMock("child_process", () => ({ spawn })); + const { extractAudioMetadata } = await import("./ffprobe.js"); + return { meta: await extractAudioMetadata(file), calls }; + } + + it("recovers duration from the last packet's pts_time + duration_time", async () => { + const { meta, calls } = await probe( + [ + { kind: "exit", code: 0, stdout: opusStreamNoDuration(undefined) }, + { + kind: "exit", + code: 0, + stdout: "0.000000,0.020000\n0.020000,0.020000\n4.994000,0.020000\n", + }, + ], + "/tmp/streamed-no-duration.webm", + ); + expect(meta.durationSeconds).toBeCloseTo(5.014, 6); + expect(calls).toHaveLength(2); + }); + + it("prefers the cheap stream-level duration over the packet scan when present", async () => { + const { meta, calls } = await probe( + [{ kind: "exit", code: 0, stdout: opusStreamNoDuration("5.008000") }], + "/tmp/has-stream-duration.webm", + ); + expect(meta.durationSeconds).toBe(5.008); + // The packet-scan probe is never attempted — the stream field was enough. + expect(calls).toHaveLength(1); + }); + + it("keeps duration at 0 when the packet scan finds no parseable packets", async () => { + const { meta } = await probe( + [ + { kind: "exit", code: 0, stdout: opusStreamNoDuration(undefined) }, + { kind: "exit", code: 0, stdout: "" }, + ], + "/tmp/streamed-empty-scan.webm", + ); + expect(meta.durationSeconds).toBe(0); + }); + + it("keeps duration at 0 (never throws) when the packet-scan probe itself fails", async () => { + const { meta } = await probe( + [ + { kind: "exit", code: 0, stdout: opusStreamNoDuration(undefined) }, + { kind: "exit", code: 1, stdout: "", stderr: "ffprobe exploded mid-scan" }, + ], + "/tmp/streamed-scan-fails.webm", + ); + expect(meta.durationSeconds).toBe(0); + }); + + it("falls back to a bare pts_time when ffprobe reports duration_time as N/A", async () => { + const { meta } = await probe( + [ + { kind: "exit", code: 0, stdout: opusStreamNoDuration(undefined) }, + { kind: "exit", code: 0, stdout: "0.000000,N/A\n4.994000,N/A\n" }, + ], + "/tmp/streamed-no-packet-duration.webm", + ); + expect(meta.durationSeconds).toBeCloseTo(4.994, 6); + }); + + // Regression: retainTail keeps only the trailing 64 KB of a long probe's + // stdout, which for a large file slices through the MIDDLE of an early + // line rather than on a line boundary — e.g. "58234.123456,0.02" truncated + // to "123456,0.02", a finite but wildly wrong pts. Taking the max across + // every parsed line (instead of trusting only the true last line, which is + // never truncated since it's flush against the real end of the output) + // would let that corrupted fragment silently win over the correct answer — + // a confidently-wrong non-zero duration, worse than the 0 this fallback + // exists to fix. + it("ignores a bogus huge pts from a truncated leading line and trusts only the true last line", async () => { + const { meta } = await probe( + [ + { kind: "exit", code: 0, stdout: opusStreamNoDuration(undefined) }, + { + kind: "exit", + code: 0, + // First line simulates a retainTail truncation artifact: a huge, + // finite-but-bogus pts (would win any max()-based reduction). + stdout: "123456.000000,0.020000\n4.994000,0.020000\n", + }, + ], + "/tmp/streamed-truncated-leading-line.webm", + ); + expect(meta.durationSeconds).toBeCloseTo(5.014, 6); + }); + + it("does not run the generic packet scan when the AAC-LC refinement already recovered a duration", async () => { + const { meta, calls } = await probe( + [ + { + kind: "exit", + code: 0, + stdout: JSON.stringify({ + streams: [ + { codec_type: "audio", codec_name: "aac", sample_rate: "44100", profile: "LC" }, + ], + format: {}, + }), + }, + { + kind: "exit", + code: 0, + stdout: JSON.stringify({ streams: [{ nb_read_packets: "861" }], format: {} }), + }, + ], + "/tmp/aac-lc-no-format-duration.m4a", + ); + expect(meta.durationSeconds).toBeCloseTo((861 * 1024) / 44100, 5); + // Only the AAC-LC packet-count probe ran — the generic scan was unnecessary. + expect(calls).toHaveLength(2); + }); +}); + describe("ffprobe option separator", () => { afterEach(() => { vi.resetModules(); diff --git a/packages/engine/src/utils/ffprobe.ts b/packages/engine/src/utils/ffprobe.ts index c4358494e1..eab9676ea0 100644 --- a/packages/engine/src/utils/ffprobe.ts +++ b/packages/engine/src/utils/ffprobe.ts @@ -910,6 +910,67 @@ export async function extractFinalVideoFrameTimestamp( */ export const extractVideoMetadata = extractMediaMetadata; +/** + * Recover audio duration by reading every packet's timestamp to EOF, for + * containers that never got a header-level duration written at all — e.g. a + * browser `MediaRecorder`-produced WebM (or any encode to a non-seekable + * destination): the encoder can't seek back to patch the Segment `Duration` + * element in after the fact, so ffprobe reports no duration at format OR + * stream level, not merely zero. Unlike the AAC-LC packet-COUNT refinement + * above, this doesn't assume a fixed samples-per-packet framing, so it works + * for any codec: it takes the latest packet's `pts_time + duration_time` (or + * just `pts_time` if a codec/container doesn't report per-packet duration). + * Same constraints as the refinement above — never throws (a failed/timed-out + * scan just leaves durationSeconds at 0, exactly as before this fallback + * existed) and honours the caller's AbortSignal. + */ +async function recoverAudioDurationFromPacketScan( + filePath: string, + signal?: AbortSignal, +): Promise { + try { + const stdout = await runFfprobe( + filePath, + [ + "-select_streams", + "a:0", + "-show_entries", + "packet=pts_time,duration_time", + "-of", + "csv=p=0", + ], + signal, + { retainTail: true, maxChars: 64 * 1024 }, + ); + // Only the LAST parsed line is trusted, mirroring + // extractFinalVideoFrameTimestamp's parseFinalTimestamp above (same + // retainTail/maxChars config, same reason): retainTail keeps only the + // trailing bytes of a long probe, which can slice through the MIDDLE of + // an early line and leave a numerically-bogus fragment (e.g. + // "58234.123456" truncated to "123456" — a finite but wildly wrong + // number) — but never through the true last line, which is flush against + // the real end of ffprobe's output. Taking the max across every parsed + // line instead of the true last one would let a truncation artifact like + // that silently win over the correct answer. + return stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const [ptsText, durationText] = line.split(","); + return { pts: Number(ptsText), duration: Number(durationText) }; + }) + .filter(({ pts }) => Number.isFinite(pts)) + .map(({ pts, duration }) => pts + (Number.isFinite(duration) ? duration : 0)) + .at(-1); + } catch (error) { + // An abort is the caller's intent, not a recovery failure — let it + // through. Anything else leaves durationSeconds at 0, same as before. + if (signal?.aborted) throw error; + return undefined; + } +} + export async function extractAudioMetadata( filePath: string, options?: { signal?: AbortSignal }, @@ -927,6 +988,8 @@ export async function extractAudioMetadata( let durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0; const streamDuration = audioStream.duration ? parseFloat(audioStream.duration) : undefined; + const usableStreamDuration = + streamDuration !== undefined && streamDuration > 0 ? streamDuration : undefined; const sampleRate = audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100; const audioCodec = audioStream.codec_name || "unknown"; // AAC-LC container durations are often slightly wrong, so the packet @@ -985,9 +1048,23 @@ export async function extractAudioMetadata( } } + // Neither format.duration nor the AAC-LC refinement above produced a + // usable duration — a non-seekable-origin container (PRINFRA-692) most + // likely. Prefer the cheap, already-parsed stream-level duration if the + // container happened to carry one; otherwise recover it with a full + // packet-timestamp scan. + if (durationSeconds <= 0) { + if (usableStreamDuration !== undefined) { + durationSeconds = usableStreamDuration; + } else { + const recovered = await recoverAudioDurationFromPacketScan(filePath, options?.signal); + if (recovered !== undefined && recovered > 0) durationSeconds = recovered; + } + } + return { durationSeconds, - streamDurationSeconds: streamDuration && streamDuration > 0 ? streamDuration : undefined, + streamDurationSeconds: usableStreamDuration, sampleRate, channels: audioStream.channels || 2, audioCodec,