Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions packages/engine/src/utils/ffprobe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
79 changes: 78 additions & 1 deletion packages/engine/src/utils/ffprobe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | undefined> {
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 },
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading