mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user