Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ export {
resolveFinalFrameExtractionWindow,
resolveVideoExtractionDuration,
resolvePlayableVideoDuration,
extractionFrameCountForDuration,
resolveProjectRelativeSrc,
getFrameAtTime,
createFrameLookupTable,
Expand Down
110 changes: 110 additions & 0 deletions packages/engine/src/services/videoFrameExtractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
parseImageElements,
extractAllVideoFrames,
extractVideoFramesRange,
extractionFrameCountForDuration,
createFrameLookupTable,
resolveProjectRelativeSrc,
resolveFrameFormat,
Expand Down Expand Up @@ -307,6 +308,44 @@ describe("resolveVideoExtractionDuration", () => {
});
});

describe("extractionFrameCountForDuration", () => {
it("uses the same VFR ceil and CFR nearest-boundary rules as FFmpeg", () => {
expect(extractionFrameCountForDuration(0.466666, 30, true)).toBe(14);
expect(extractionFrameCountForDuration(0.466666, 30, false)).toBe(14);
expect(extractionFrameCountForDuration(0.616666, 30, true)).toBe(19);
expect(extractionFrameCountForDuration(0.616666, 30, false)).toBe(18);
});

it.each([
[0.33 - 0.03, 30, 9],
[0.29 - 0.04, 24, 6],
[0.35 - 0.05, 60, 18],
[4.03 - 3.53, 30, 15],
[4.03 - 3.78, 24, 6],
])(
"snaps floating-point integral boundaries before VFR ceil (%s seconds at %i fps)",
(duration, fps, expectedFrames) => {
expect(extractionFrameCountForDuration(duration, fps, true)).toBe(expectedFrames);
},
);

it("still ceils a genuine fractional boundary beyond floating-point noise", () => {
expect(extractionFrameCountForDuration(0.300001, 30, true)).toBe(10);
});

it("matches FFmpeg's six-digit duration parsing", () => {
expect(extractionFrameCountForDuration(0.6000009, 30, true)).toBe(18);
expect(extractionFrameCountForDuration(0.600001, 30, true)).toBe(19);
});

it("fails closed for invalid durations and emits one frame for positive sub-frame work", () => {
expect(extractionFrameCountForDuration(Number.NaN, 30, true)).toBe(0);
expect(extractionFrameCountForDuration(1, 0, true)).toBe(0);
expect(extractionFrameCountForDuration(0, 30, true)).toBe(0);
expect(extractionFrameCountForDuration(0.001, 30, false)).toBe(1);
});
});

describe("video extraction failure taxonomy and bounded retry", () => {
it("classifies missing and transient HTTP sources without exposing retry ambiguity", () => {
expect(classifyVideoExtractionError(new Error("HTTP 404: Not Found"))).toMatchObject({
Expand Down Expand Up @@ -2103,6 +2142,77 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
expect(supersetDirNames(outputDir)).toEqual([]);
}, 60_000);

it("keeps overlapping VFR trims direct because CFR resampling phase resets per seek", async () => {
const outputDir = join(FIXTURE_DIR, "out-vfr-superset-short");
mkdirSync(outputDir, { recursive: true });

const result = await extractAllVideoFrames(
[
cfrClipElement("vfr-short-a", VFR_FIXTURE, 0.616666, 0),
cfrClipElement("vfr-short-b", VFR_FIXTURE, 0.616666, 0.1),
],
FIXTURE_DIR,
{ fps: 30, outputDir },
);

expect(result.errors).toEqual([]);
expect(extractedFor(result, "vfr-short-a").metadata.isVFR).toBe(true);
expect(extractedFor(result, "vfr-short-a").totalFrames).toBe(19);
expect(extractedFor(result, "vfr-short-b").totalFrames).toBe(19);
expect(statSync(framePath(result, "vfr-short-a", 3)).ino).not.toBe(
statSync(framePath(result, "vfr-short-b", 0)).ino,
);
expect(supersetDirNames(outputDir)).toEqual([]);
}, 60_000);

it("keeps batched and direct VFR extraction equal at a floating integral boundary", async () => {
const groupedOutputDir = join(FIXTURE_DIR, "out-vfr-superset-integral-boundary");
const directOutputDir = join(FIXTURE_DIR, "out-vfr-direct-integral-boundary");
mkdirSync(groupedOutputDir, { recursive: true });
mkdirSync(directOutputDir, { recursive: true });

const first: VideoElement = {
id: "vfr-integral-a",
src: VFR_FIXTURE,
start: 0.03,
end: 0.33,
mediaStart: 0.03,
loop: false,
hasAudio: false,
};
const second: VideoElement = {
...first,
id: "vfr-integral-b",
mediaStart: 0.13,
};

const direct = await extractAllVideoFrames([{ ...second }], FIXTURE_DIR, {
fps: 30,
outputDir: directOutputDir,
});
const grouped = await extractAllVideoFrames([{ ...first }, second], FIXTURE_DIR, {
fps: 30,
outputDir: groupedOutputDir,
});

expect(direct.errors).toEqual([]);
expect(grouped.errors).toEqual([]);
expect(extractedFor(direct, second.id).totalFrames).toBe(9);
expect(extractedFor(grouped, first.id).totalFrames).toBe(9);
expect(extractedFor(grouped, second.id).totalFrames).toBe(9);
for (let frame = 0; frame < 9; frame += 1) {
expect(
readFileSync(framePath(grouped, second.id, frame)).equals(
readFileSync(framePath(direct, second.id, frame)),
),
).toBe(true);
}
expect(statSync(framePath(grouped, first.id, 3)).ino).not.toBe(
statSync(framePath(grouped, second.id, 0)).ino,
);
expect(supersetDirNames(groupedOutputDir)).toEqual([]);
}, 60_000);

it("publishes overlapping superset slices to cache entries and hits them on the next render", async () => {
const CACHE_DIR = mkdtempSync(join(tmpdir(), "hf-extract-superset-cache-test-"));
const SRC = await synthCfrClip("superset-cache-src.mp4", 10);
Expand Down
46 changes: 45 additions & 1 deletion packages/engine/src/services/videoFrameExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,40 @@ export function isVideoFrameFormat(value: unknown): value is VideoFrameFormat {
return typeof value === "string" && (VIDEO_FRAME_FORMATS as readonly string[]).includes(value);
}

/**
* Resolve the frame count produced for a requested extraction duration.
*
* CFR extraction uses FFmpeg's fps filter, whose end boundary rounds to the
* nearest frame. The VFR path normalizes with `-fps_mode cfr -r`, whose end
* boundary rounds up. Keep this calculation shared by superset slicing and
* producer coverage accounting so a complete VFR extraction cannot be
* rejected because the two paths disagree by one frame.
*/
export function extractionFrameCountForDuration(
durationSeconds: number,
fps: number,
isVFR: boolean,
): number {
if (!Number.isFinite(durationSeconds) || !Number.isFinite(fps)) return 0;
if (durationSeconds <= 0 || fps <= 0) return 0;
// FFmpeg parses `-t` on its AV_TIME_BASE (microsecond) clock and ignores
// fractional digits beyond that precision. Quantize the same way before
// translating duration to frames. Besides matching the command FFmpeg
// actually executes, this removes subtraction artifacts whose magnitude is
// determined by the absolute authored timestamps rather than the short
// duration itself (for example, 4.03 - 3.53).
const ffmpegDurationSeconds = Math.trunc(durationSeconds * 1_000_000) / 1_000_000;
const rawFrames = ffmpegDurationSeconds * fps;
const nearestInteger = Math.round(rawFrames);
// Multiplication can still introduce a few ULPs after the microsecond
// quantization. Snap only that remaining arithmetic noise to an integer.
const integerTolerance = Number.EPSILON * Math.max(1, Math.abs(rawFrames)) * 4;
const quantizedFrames =
Math.abs(rawFrames - nearestInteger) <= integerTolerance ? nearestInteger : rawFrames;
const frames = isVFR ? Math.ceil(quantizedFrames) : Math.round(quantizedFrames);
return Math.max(1, frames);
}

export interface ExtractionOptions {
fps: number;
outputDir: string;
Expand Down Expand Up @@ -1102,6 +1136,12 @@ function buildSupersetGroup(
): SupersetGroupPlan | null {
if (misses.length < 2) return null;
if (misses.some(({ work }) => work.finalFrameOnly)) return null;
// VFR normalization (`-fps_mode cfr -r`) establishes its duplicate/drop
// phase relative to each seek. A union extraction therefore cannot be
// sliced into the same frames as independently sought member ranges, even
// when their offsets land on an integral output-frame boundary. Keep VFR
// ranges direct until the extractor has a proven absolute timestamp phase.
if (misses.some(({ work }) => work.metadata.isVFR)) return null;
const baseStart = Math.min(...misses.map(({ work }) => work.video.mediaStart));
if (!misses.every(({ work }) => isIntegralFrameOffset(work.video.mediaStart - baseStart, fps))) {
return null;
Expand Down Expand Up @@ -1190,7 +1230,11 @@ function sliceSupersetMember(
// offset_i + k, so its source time is
// baseStart + (offset_i + k) / fps = mediaStart_i + k / fps.
// The frame-alignment precondition is what makes offset_i integral.
const requestedFrames = Math.round(work.videoDuration * fps);
const requestedFrames = extractionFrameCountForDuration(
work.videoDuration,
fps,
work.metadata.isVFR,
);
const availableFrames = Math.max(0, superset.totalFrames - member.offsetFrames);
const frameCount = Math.min(requestedFrames, availableFrames);
for (let i = 0; i < frameCount; i += 1) {
Expand Down
84 changes: 82 additions & 2 deletions packages/producer/src/services/render/videoFrameCoverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,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,
Expand All @@ -172,7 +172,7 @@ describe("computeVideoFrameCoverage", () => {
capturedFrames: 18,
ratio: 18 / 19,
});
expect(() => assertVideoFrameCoverage(reports, 0.95)).toThrow(VideoFrameCoverageError);
expect(() => assertVideoFrameCoverage(reports, 0.95)).not.toThrow();
});

it("still fails closed when a positive sub-frame clip captured zero frames", () => {
Expand Down Expand Up @@ -344,6 +344,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 = [
{
Expand Down
28 changes: 24 additions & 4 deletions packages/producer/src/services/render/videoFrameCoverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,15 @@

import { parseHTML } from "linkedom";
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;
Expand Down Expand Up @@ -138,9 +142,20 @@ export function expectedFramesForClip(
if (fps <= 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(
Expand Down Expand Up @@ -207,7 +222,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.
Expand Down
Loading