diff --git a/packages/engine/src/utils/ffprobe.test.ts b/packages/engine/src/utils/ffprobe.test.ts index 2030dc105..207c8821e 100644 --- a/packages/engine/src/utils/ffprobe.test.ts +++ b/packages/engine/src/utils/ffprobe.test.ts @@ -931,6 +931,93 @@ describe("ffprobe option separator", () => { }); }); +describe("analyzeKeyframeIntervals — single-keyframe videos", () => { + afterEach(() => { + vi.resetModules(); + vi.doUnmock("child_process"); + }); + + it("reports single-keyframe video as problematic when duration exceeds threshold", async () => { + const { spawn } = createSpawnSpy([ + // First call: keyframe probe returns a single timestamp + { kind: "exit", code: 0, stdout: "0.000000\n" }, + // Second call: stream duration probe + { kind: "exit", code: 0, stdout: "10.5\n" }, + ]); + vi.resetModules(); + vi.doMock("child_process", () => ({ spawn })); + + const { analyzeKeyframeIntervals } = await import("./ffprobe.js"); + const result = await analyzeKeyframeIntervals("/tmp/single-gop.mp4"); + + expect(result).toEqual({ + avgIntervalSeconds: 10.5, + maxIntervalSeconds: 10.5, + keyframeCount: 1, + isProblematic: true, + }); + }); + + it("reports single-keyframe short video as non-problematic", async () => { + const { spawn } = createSpawnSpy([ + { kind: "exit", code: 0, stdout: "0.000000\n" }, + { kind: "exit", code: 0, stdout: "1.5\n" }, + ]); + vi.resetModules(); + vi.doMock("child_process", () => ({ spawn })); + + const { analyzeKeyframeIntervals } = await import("./ffprobe.js"); + const result = await analyzeKeyframeIntervals("/tmp/short-single-gop.mp4"); + + expect(result).toEqual({ + avgIntervalSeconds: 1.5, + maxIntervalSeconds: 1.5, + keyframeCount: 1, + isProblematic: false, + }); + }); + + it("falls back to format duration when stream duration is unavailable", async () => { + const { spawn } = createSpawnSpy([ + { kind: "exit", code: 0, stdout: "0.000000\n" }, + // Stream duration probe fails + { kind: "exit", code: 1, stdout: "" }, + // Format duration probe succeeds + { kind: "exit", code: 0, stdout: "8.0\n" }, + ]); + vi.resetModules(); + vi.doMock("child_process", () => ({ spawn })); + + const { analyzeKeyframeIntervals } = await import("./ffprobe.js"); + const result = await analyzeKeyframeIntervals("/tmp/no-stream-duration.mp4"); + + expect(result).toEqual({ + avgIntervalSeconds: 8, + maxIntervalSeconds: 8, + keyframeCount: 1, + isProblematic: true, + }); + }); + + it("returns non-problematic for zero keyframes (still image)", async () => { + const { spawn } = createSpawnSpy([ + { kind: "exit", code: 0, stdout: "\n" }, + ]); + vi.resetModules(); + vi.doMock("child_process", () => ({ spawn })); + + const { analyzeKeyframeIntervals } = await import("./ffprobe.js"); + const result = await analyzeKeyframeIntervals("/tmp/still-image.png"); + + expect(result).toEqual({ + avgIntervalSeconds: 0, + maxIntervalSeconds: 0, + keyframeCount: 0, + isProblematic: false, + }); + }); +}); + describe("parseFrameRate", () => { // Direct against the exported function. The previous table drove this // through extractMediaMetadata behind a spawn mock, which cost a diff --git a/packages/engine/src/utils/ffprobe.ts b/packages/engine/src/utils/ffprobe.ts index c4358494e..8b51fc997 100644 --- a/packages/engine/src/utils/ffprobe.ts +++ b/packages/engine/src/utils/ffprobe.ts @@ -1050,15 +1050,25 @@ async function analyzeKeyframeIntervalsUncached(filePath: string): Promise parseFloat(line.trim())) .filter((t) => Number.isFinite(t)); - if (timestamps.length < 2) { + if (timestamps.length === 0) { return { avgIntervalSeconds: 0, maxIntervalSeconds: 0, - keyframeCount: timestamps.length, + keyframeCount: 0, isProblematic: false, }; } + if (timestamps.length === 1) { + const duration = await probeStreamDurationSeconds(filePath); + return { + avgIntervalSeconds: Math.round(duration * 100) / 100, + maxIntervalSeconds: Math.round(duration * 100) / 100, + keyframeCount: 1, + isProblematic: duration > 2, + }; + } + let maxInterval = 0; let totalInterval = 0; for (let i = 1; i < timestamps.length; i++) { @@ -1075,3 +1085,32 @@ async function analyzeKeyframeIntervalsUncached(filePath: string): Promise 2, }; } + +async function probeStreamDurationSeconds(filePath: string): Promise { + try { + const out = await runFfprobe(filePath, [ + "-select_streams", + "v:0", + "-show_entries", + "stream=duration", + "-of", + "csv=p=0", + ]); + const d = parseFloat(out.trim()); + if (Number.isFinite(d) && d > 0) return d; + } catch { + // Fall through to format-level probe. + } + try { + const out = await runFfprobe(filePath, [ + "-show_entries", + "format=duration", + "-of", + "csv=p=0", + ]); + const d = parseFloat(out.trim()); + return Number.isFinite(d) && d > 0 ? d : 0; + } catch { + return 0; + } +}