mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user