fix(producer): correct short VFR frame coverage (#2936)

* fix(producer): correct short VFR frame coverage

* fix(engine): keep VFR extraction seek-local

* fix(engine): match ffmpeg decimal frame boundaries

* fix(engine): preserve exact extraction frame rates

* fix(engine): key frame cache by exact rate
This commit is contained in:
James Russo
2026-08-03 18:42:03 -07:00
committed by GitHub
parent 9792c32950
commit 127eb19371
9 changed files with 543 additions and 42 deletions
@@ -48,7 +48,6 @@ import {
resolveProjectRelativeSrc,
runVideoExtractionWithRetry,
} from "@hyperframes/engine";
import { fpsToNumber } from "@hyperframes/core";
import {
collectVideoMetadataHints,
collectVideoReadinessSkipIds,
@@ -368,12 +367,11 @@ export async function runExtractVideosStage(
extractionResult = await extractAllVideoFrames(
composition.videos,
projectDir,
// extractAllVideoFrames takes fps as a number (decimal). Frames sampled
// from a video at 29.97 vs 30 differ by ~1 frame in 1000 — not enough
// to break visual parity, and the encoder-side rational keeps the
// output framerate exact.
// Preserve the configured rational through FFmpeg extraction. NTSC
// rates must remain `30000/1001`, not a rounded JavaScript decimal,
// because short boundary counts can differ by one frame.
{
fps: fpsToNumber(job.config.fps),
fps: job.config.fps,
outputDir: join(compiledDir, "__hyperframes_video_frames"),
format: job.config.videoFrameFormat ?? "auto",
timelineEnd: composition.duration,
@@ -97,6 +97,12 @@ describe("expectedFramesForClip", () => {
expect(expectedFramesForClip(0, 0.633333, 30, "nearest")).toBe(19);
});
it("uses exact NTSC rationals for short CFR and VFR boundaries", () => {
expect(expectedFramesForClip(0, 0.25025, { num: 30000, den: 1001 }, "nearest")).toBe(8);
expect(expectedFramesForClip(0, 0.125125, { num: 24000, den: 1001 })).toBe(3);
expect(expectedFramesForClip(0, 0.5005, { num: 24000, den: 1001 })).toBe(12);
});
it("requires one frame for every positive sub-frame clip", () => {
expect(expectedFramesForClip(0, 0.001, 30)).toBe(1);
expect(expectedFramesForClip(0, 0.001, 30, "nearest")).toBe(1);
@@ -160,7 +166,7 @@ describe("computeVideoFrameCoverage", () => {
expect(() => assertVideoFrameCoverage(reports, 0.95)).not.toThrow();
});
it("keeps ceil coverage for the VFR extraction branch", () => {
it("tolerates a single FFmpeg boundary frame on a short 18/19 VFR extraction", () => {
const videos = [makeVideo({ id: "short-vfr", start: 0, end: 0.616666 })];
const reports = computeVideoFrameCoverage(
videos,
@@ -172,7 +178,19 @@ describe("computeVideoFrameCoverage", () => {
capturedFrames: 18,
ratio: 18 / 19,
});
expect(() => assertVideoFrameCoverage(reports, 0.95)).toThrow(VideoFrameCoverageError);
expect(() => assertVideoFrameCoverage(reports, 0.95)).not.toThrow();
});
it("does not reject complete 24000/1001 VFR extraction at an exact boundary", () => {
const videos = [makeVideo({ id: "ntsc-vfr", start: 0, end: 0.125125 })];
const reports = computeVideoFrameCoverage(
videos,
[makeExtracted("ntsc-vfr", 3, { isVFR: true })],
{ num: 24000, den: 1001 },
);
expect(reports[0]).toMatchObject({ expectedFrames: 3, capturedFrames: 3, ratio: 1 });
expect(() => assertVideoFrameCoverage(reports, 0.95)).not.toThrow();
});
it("still fails closed when a positive sub-frame clip captured zero frames", () => {
@@ -344,6 +362,86 @@ describe("assertVideoFrameCoverage", () => {
expect(() => assertVideoFrameCoverage(reports, 0.95)).toThrow(VideoFrameCoverageError);
});
it.each([
[13, 14],
[18, 19],
])(
"tolerates exactly one nonzero boundary frame for a short clip (%i/%i)",
(capturedFrames, expectedFrames) => {
const reports = [
{
videoId: "short-boundary",
clipStart: 0,
clipEnd: expectedFrames / 30,
expectedFrames,
capturedFrames,
ratio: capturedFrames / expectedFrames,
},
];
expect(() => assertVideoFrameCoverage(reports, 0.95)).not.toThrow();
},
);
it("does not treat a one-frame deficit as tolerance when it represents major loss", () => {
const reports = [
{
videoId: "major-loss",
clipStart: 0,
clipEnd: 2 / 30,
expectedFrames: 2,
capturedFrames: 1,
ratio: 0.5,
},
];
expect(() => assertVideoFrameCoverage(reports, 0.95)).toThrow(VideoFrameCoverageError);
});
it("does not tolerate two missing frames, zero captured frames, or an exact threshold", () => {
const report = {
videoId: "short-incomplete",
clipStart: 0,
clipEnd: 19 / 30,
expectedFrames: 19,
capturedFrames: 17,
ratio: 17 / 19,
};
expect(() => assertVideoFrameCoverage([report], 0.95)).toThrow(VideoFrameCoverageError);
expect(() =>
assertVideoFrameCoverage([{ ...report, capturedFrames: 0, ratio: 0 }], 0.95),
).toThrow(VideoFrameCoverageError);
expect(() =>
assertVideoFrameCoverage([{ ...report, capturedFrames: 18, ratio: 18 / 19 }], 1),
).toThrow(VideoFrameCoverageError);
});
it("does not apply the one-frame tolerance to longer clips", () => {
const reports = [
{
videoId: "long-boundary",
clipStart: 0,
clipEnd: 21 / 30,
expectedFrames: 21,
capturedFrames: 20,
ratio: 20 / 21,
},
];
expect(() => assertVideoFrameCoverage(reports, 0.99)).toThrow(VideoFrameCoverageError);
});
it("still rejects a material long-clip shortfall even when it is five frames", () => {
const reports = [
{
videoId: "long-partial",
clipStart: 0,
clipEnd: 89 / 30,
expectedFrames: 89,
capturedFrames: 84,
ratio: 84 / 89,
},
];
expect(() => assertVideoFrameCoverage(reports, 0.95)).toThrow(VideoFrameCoverageError);
});
it("respects a threshold override — 0.5 passes 60% coverage", () => {
const reports = [
{
@@ -45,12 +45,17 @@
*/
import { parseHTML } from "linkedom";
import { fpsToNumber, toFps, type FpsInput } from "@hyperframes/core";
import {
extractionFrameCountForDuration,
resolvePlayableVideoDuration,
type ExtractedFrames,
type VideoElement,
} from "@hyperframes/engine";
const SHORT_CLIP_ONE_FRAME_TOLERANCE_MIN_EXPECTED_FRAMES = 14;
const SHORT_CLIP_ONE_FRAME_TOLERANCE_MAX_EXPECTED_FRAMES = 20;
export interface VideoFrameCoverageReport {
videoId: string;
clipStart: number;
@@ -131,22 +136,34 @@ export function resolveVideoCoverageThreshold(
export function expectedFramesForClip(
start: number,
end: number,
fps: number,
fps: FpsInput,
rounding: "ceil" | "nearest" = "ceil",
): number {
if (!Number.isFinite(start) || !Number.isFinite(end) || !Number.isFinite(fps)) return 0;
if (fps <= 0) return 0;
const fpsValue = fpsToNumber(toFps(fps));
if (!Number.isFinite(start) || !Number.isFinite(end) || !Number.isFinite(fpsValue)) return 0;
if (fpsValue <= 0) return 0;
const duration = Math.max(0, end - start);
if (duration === 0) return 0;
const frameCount =
rounding === "nearest" ? Math.round(duration * fps) : Math.ceil(duration * fps);
return Math.max(1, frameCount);
return extractionFrameCountForDuration(duration, fps, rounding === "ceil");
}
function isToleratedShortClipBoundaryMiss(
report: VideoFrameCoverageReport,
threshold: number,
): boolean {
return (
threshold < 1 &&
report.capturedFrames > 0 &&
report.expectedFrames >= SHORT_CLIP_ONE_FRAME_TOLERANCE_MIN_EXPECTED_FRAMES &&
report.expectedFrames <= SHORT_CLIP_ONE_FRAME_TOLERANCE_MAX_EXPECTED_FRAMES &&
report.expectedFrames - report.capturedFrames === 1
);
}
function expectedFramesForVideo(
video: VideoElement,
entry: ExtractedFrames | undefined,
fps: number,
fps: FpsInput,
): number {
const rounding = entry && !entry.metadata.isVFR ? "nearest" : "ceil";
const slotFrames = expectedFramesForClip(video.start, video.end, fps, rounding);
@@ -169,7 +186,7 @@ function expectedFramesForVideo(
export function computeVideoFrameCoverage(
videos: readonly VideoElement[],
extracted: readonly ExtractedFrames[],
fps: number,
fps: FpsInput,
): VideoFrameCoverageReport[] {
const byId = new Map<string, ExtractedFrames>();
for (const entry of extracted) byId.set(entry.videoId, entry);
@@ -207,7 +224,12 @@ export function assertVideoFrameCoverage(
threshold: number | null,
): void {
if (threshold === null) return;
const failed = reports.filter((report) => report.expectedFrames > 0 && report.ratio < threshold);
const failed = reports.filter(
(report) =>
report.expectedFrames > 0 &&
report.ratio < threshold &&
!isToleratedShortClipBoundaryMiss(report, threshold),
);
if (failed.length === 0) return;
// Sort ascending by ratio so the "worst" is first — that's what we cite
// in the message and pin on the error details for telemetry.
@@ -2369,11 +2369,7 @@ async function executeRenderPipeline(input: {
// Also count authored `[data-start]` clip windows as a coarse proxy
// for the ts=1784144554 authored-clip-count-scaled failure shape.
const coverageReports: VideoFrameCoverageReport[] = extractionResult
? computeVideoFrameCoverage(
composition.videos,
extractionResult.extracted,
fpsToNumber(job.config.fps),
)
? computeVideoFrameCoverage(composition.videos, extractionResult.extracted, job.config.fps)
: [];
const coverageThreshold = resolveVideoCoverageThreshold();
const authoredTimedClipCount = countAuthoredTimedClips(compiled.html);