fix(engine): cross-check nb_frames when video stream duration is absent

When ffprobe omits the video stream's own `duration` field,
`extractMediaMetadata` falls back to the container duration
(`format.duration`), which includes the longest stream in the
container. Stock and looping clips commonly have an audio track
2x the video length, inflating `videoStreamDurationSeconds` and
causing the downstream coverage gate to expect twice the frames
FFmpeg can actually produce — the "50% cluster" failure pattern.

Cross-check with `nb_frames / fps` when the stream duration is
absent. If the frame-derived duration is >10% shorter than the
container, use it instead. This corrects the inflation at probe
time without requiring a second ffprobe pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
xiaye
2026-09-23 15:37:35 -07:00
co-authored by Claude Opus 4.6
parent 00ea884d8f
commit ee4c00db6a
2 changed files with 148 additions and 1 deletions
+129
View File
@@ -77,6 +77,135 @@ describe("extractMediaMetadata", () => {
});
});
describe("extractMediaMetadata nb_frames duration cross-check", () => {
afterEach(() => {
vi.resetModules();
});
it("uses nb_frames to bound video duration when stream duration is absent and audio is longer", async () => {
const { spawn } = createSpawnSpy([
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 1920,
height: 1080,
r_frame_rate: "30/1",
avg_frame_rate: "30/1",
nb_frames: "150",
},
{ codec_type: "audio", codec_name: "aac" },
],
format: { duration: "10" },
}),
},
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractMediaMetadata: mocked } = await import("./ffprobe.js");
const meta = await mocked("/tmp/stock-clip-long-audio.mp4");
expect(meta.durationSeconds).toBe(10);
expect(meta.videoStreamDurationSeconds).toBe(5);
expect(meta.frames).toBe(150);
});
it("falls back to container duration when nb_frames is absent", async () => {
const { spawn } = createSpawnSpy([
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 1280,
height: 720,
r_frame_rate: "30/1",
avg_frame_rate: "30/1",
},
{ codec_type: "audio", codec_name: "aac" },
],
format: { duration: "10" },
}),
},
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractMediaMetadata: mocked } = await import("./ffprobe.js");
const meta = await mocked("/tmp/no-nb-frames.mp4");
expect(meta.videoStreamDurationSeconds).toBe(10);
expect(meta.frames).toBeUndefined();
});
it("keeps container duration when nb_frames-derived duration is within 10%", async () => {
const { spawn } = createSpawnSpy([
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 1280,
height: 720,
r_frame_rate: "30/1",
avg_frame_rate: "30/1",
nb_frames: "285",
},
],
format: { duration: "10" },
}),
},
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractMediaMetadata: mocked } = await import("./ffprobe.js");
const meta = await mocked("/tmp/close-duration.mp4");
// 285 / 30 = 9.5s, within 10% of container (10s), so keep container
expect(meta.videoStreamDurationSeconds).toBe(10);
});
it("prefers stream duration over nb_frames when both are present", async () => {
const { spawn } = createSpawnSpy([
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 1280,
height: 720,
duration: "5",
r_frame_rate: "30/1",
avg_frame_rate: "30/1",
nb_frames: "300",
},
],
format: { duration: "10" },
}),
},
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractMediaMetadata: mocked } = await import("./ffprobe.js");
const meta = await mocked("/tmp/stream-duration-present.mp4");
// stream duration (5s) takes precedence, nb_frames (300 / 30 = 10s) ignored
expect(meta.videoStreamDurationSeconds).toBe(5);
});
});
describe("extractPngMetadataFromBuffer", () => {
it("accepts a valid cICP chunk before IDAT", () => {
const metadata = extractPngMetadataFromBuffer(buildMinimalPng());
+19 -1
View File
@@ -758,9 +758,27 @@ export async function extractMediaMetadata(filePath: string): Promise<VideoMetad
const frames =
Number.isFinite(parsedNbFrames) && parsedNbFrames > 0 ? parsedNbFrames : undefined;
// When the video stream omits its own duration, the container duration
// (format.duration) includes the longest stream — often a longer audio
// track in stock/looping clips. Cross-check with nb_frames to bound the
// video-specific extent and avoid inflating the expected frame count.
let effectiveStreamDuration: number;
if (streamDuration > 0) {
effectiveStreamDuration = streamDuration;
} else if (frames !== undefined && fps > 0 && containerDuration > 0) {
const frameDerivedDuration = frames / fps;
// Only override when the frame-derived duration is meaningfully shorter
// (>10% gap). Within 10% the container value is close enough and may
// account for a trailing hold frame that nb_frames does not include.
effectiveStreamDuration =
frameDerivedDuration < containerDuration * 0.9 ? frameDerivedDuration : containerDuration;
} else {
effectiveStreamDuration = containerDuration;
}
return {
durationSeconds: containerDuration,
videoStreamDurationSeconds: streamDuration > 0 ? streamDuration : containerDuration,
videoStreamDurationSeconds: effectiveStreamDuration,
videoStreamStartSeconds: streamStart,
width: videoStream.width || stillImage()?.width || 0,
height: videoStream.height || stillImage()?.height || 0,