mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
Reference in New Issue
Block a user