fix(engine): treat a single keyframe as the worst sparse-keyframe case

analyzeKeyframeIntervalsUncached returned isProblematic: false whenever
a video had fewer than two keyframes. A video with exactly one keyframe
is the worst case for the failure the check exists to catch: every seek
past 0 lands inside a single GOP spanning the whole file, twice as bad
as the 5s-interval case the compiler already warns about.

The single-keyframe interval is now the video stream's own duration,
not the container's, since the two can disagree, and is flagged past
the same 2s threshold as the multi-keyframe path. Zero keyframes (still
images, failed probes) keeps the prior not-problematic result.
This commit is contained in:
rajanpanth
2026-08-28 18:13:56 +05:45
parent af1cb1c10d
commit 990819f6e9
2 changed files with 108 additions and 2 deletions
+90
View File
@@ -832,6 +832,96 @@ describe("ffprobe missing-binary fallback", () => {
expect(basename(calls[0]?.command ?? "")).toMatch(/^ffprobe(?:\.exe)?$/);
});
it("analyzeKeyframeIntervals treats a single keyframe as the whole stream duration", async () => {
// A 10s single-GOP video: exactly one keyframe at t=0. Every seek past
// it lands inside that one GOP, so the effective interval is the whole
// stream, not zero.
const { spawn, calls } = createSpawnSpy([
{ kind: "exit", code: 0, stdout: "0.000000\n" },
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 640,
height: 360,
r_frame_rate: "30/1",
avg_frame_rate: "30/1",
duration: "10.0",
},
],
format: { duration: "10.0" },
}),
},
]);
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,
maxIntervalSeconds: 10,
keyframeCount: 1,
isProblematic: true,
});
expect(calls.length).toBe(2);
});
it("analyzeKeyframeIntervals does not flag a single keyframe under the threshold", async () => {
const { spawn } = createSpawnSpy([
{ kind: "exit", code: 0, stdout: "0.000000\n" },
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 640,
height: 360,
r_frame_rate: "30/1",
avg_frame_rate: "30/1",
duration: "1.0",
},
],
format: { duration: "1.0" },
}),
},
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { analyzeKeyframeIntervals } = await import("./ffprobe.js");
const result = await analyzeKeyframeIntervals("/tmp/short-single-gop.mp4");
expect(result.keyframeCount).toBe(1);
expect(result.isProblematic).toBe(false);
});
it("analyzeKeyframeIntervals reports no keyframes as not problematic", async () => {
const { spawn, calls } = createSpawnSpy([{ kind: "exit", code: 0, stdout: "" }]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { analyzeKeyframeIntervals } = await import("./ffprobe.js");
const result = await analyzeKeyframeIntervals("/tmp/no-keyframes.mp4");
expect(result).toEqual({
avgIntervalSeconds: 0,
maxIntervalSeconds: 0,
keyframeCount: 0,
isProblematic: false,
});
// Only the keyframe probe should run — no metadata lookup for zero timestamps.
expect(calls.length).toBe(1);
});
it("ffprobe-missing error message includes install hint", async () => {
const { spawn } = createSpawnSpy([{ kind: "missing" }]);
hidePathBinaries();
+18 -2
View File
@@ -1050,15 +1050,31 @@ async function analyzeKeyframeIntervalsUncached(filePath: string): Promise<Keyfr
.map((line) => 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) {
// A single keyframe means every seek past it lands inside one GOP that
// spans the whole stream, which is the worst case the multi-keyframe
// branch below reports on. The interval is the stream duration, not
// zero — the video-stream duration, not the container's, since they can
// disagree (e.g. an audio-only tail past the last video frame).
const { videoStreamDurationSeconds } = await extractMediaMetadata(filePath);
const duration = Math.round(videoStreamDurationSeconds * 100) / 100;
return {
avgIntervalSeconds: duration,
maxIntervalSeconds: duration,
keyframeCount: 1,
isProblematic: duration > 2,
};
}
let maxInterval = 0;
let totalInterval = 0;
for (let i = 1; i < timestamps.length; i++) {