fix(engine): report single-keyframe videos as sparse GOP

A video with exactly one keyframe has a GOP spanning the entire file —
the worst case for the seek-accuracy warning. The < 2 early return
swallowed it silently.

Split the check: zero keyframes (still images) stay non-problematic;
one keyframe probes the stream duration and treats it as the effective
interval, flagging isProblematic when duration > 2s.

Fixes #3460
This commit is contained in:
miga-heygen
2026-08-31 22:19:06 +00:00
parent 9097d539b1
commit cf5e504286
2 changed files with 128 additions and 2 deletions
+87
View File
@@ -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
+41 -2
View File
@@ -1050,15 +1050,25 @@ 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) {
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<Keyfr
isProblematic: maxInterval > 2,
};
}
async function probeStreamDurationSeconds(filePath: string): Promise<number> {
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;
}
}