mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user