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,