Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
162 changes: 162 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,45 @@ 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);
expect(extractionFrameCountForDuration(2.05, 30, false)).toBe(62);
});

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 @@ -2067,6 +2107,57 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
expect(supersetDirNames(outputDir)).toEqual([]);
}, 60_000);

it.each([
{ label: "decimal underflow half-frame", duration: 2.05, offset: 1, expectedFrames: 62 },
{
label: "non-half-integer boundary",
duration: 0.616666,
offset: 0.1,
expectedFrames: 18,
},
])(
"keeps direct and superset CFR extraction equal at a $label",
async ({ label, duration, offset, expectedFrames }) => {
const fixtureKey = label.replaceAll(" ", "-");
const src = await synthCfrClip(`superset-cfr-${fixtureKey}.mp4`, 4);
const groupedOutputDir = join(FIXTURE_DIR, `out-superset-cfr-${fixtureKey}`);
const directOutputDir = join(FIXTURE_DIR, `out-direct-cfr-${fixtureKey}`);
mkdirSync(groupedOutputDir, { recursive: true });
mkdirSync(directOutputDir, { recursive: true });

const grouped = await extractAllVideoFrames(
[
cfrClipElement(`${fixtureKey}-base`, src, duration, 0),
cfrClipElement(`${fixtureKey}-member`, src, duration, offset),
],
FIXTURE_DIR,
{ fps: 30, outputDir: groupedOutputDir },
);
const direct = await extractVideoFramesRange(src, `${fixtureKey}-direct`, offset, duration, {
fps: 30,
outputDir: directOutputDir,
format: "jpg",
});

expect(grouped.errors).toEqual([]);
expect(direct.totalFrames).toBe(expectedFrames);
expect(extractedFor(grouped, `${fixtureKey}-base`).totalFrames).toBe(expectedFrames);
expect(extractedFor(grouped, `${fixtureKey}-member`).totalFrames).toBe(expectedFrames);
expect(statSync(framePath(grouped, `${fixtureKey}-base`, Math.round(offset * 30))).ino).toBe(
statSync(framePath(grouped, `${fixtureKey}-member`, 0)).ino,
);
for (let frame = 0; frame < expectedFrames; frame += 1) {
expect(
readFileSync(framePath(grouped, `${fixtureKey}-member`, frame)).equals(
readFileSync(direct.framePaths.get(frame)!),
),
).toBe(true);
}
expect(supersetDirNames(groupedOutputDir)).toEqual([]);
},
60_000,
);

it("does not superset disjoint trims", async () => {
const SRC = await synthCfrClip("superset-disjoint-src.mp4", 10);
const outputDir = join(FIXTURE_DIR, "out-superset-disjoint");
Expand Down Expand Up @@ -2103,6 +2194,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
68 changes: 67 additions & 1 deletion packages/engine/src/services/videoFrameExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,62 @@ 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 receives `String(durationSeconds)` and parses at microsecond
// precision. Derive the integer microseconds from that same decimal text:
// multiplying the binary float first is not equivalent (`2.05 * 1e6` is
// 2049999.9999999998 in JS and would incorrectly truncate one microsecond).
const serialized = String(durationSeconds).toLowerCase();
const decimal = /^(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/.exec(serialized);
if (!decimal) return 0;
const whole = decimal[1] ?? "0";
const fraction = decimal[2] ?? "";
const exponent = Number.parseInt(decimal[3] ?? "0", 10);
const digits = BigInt(`${whole}${fraction}`);
const microsecondScale = exponent + 6 - fraction.length;
const microseconds =
microsecondScale >= 0
? digits * 10n ** BigInt(microsecondScale)
: digits / 10n ** BigInt(-microsecondScale);

// Keep the frame-boundary calculation rational too. Converting the exact
// microseconds back to a binary float recreates the same problem at .5-frame
// boundaries (`2.05 * 30` is 61.49999999999999 in JS).
const serializedFps = String(fps).toLowerCase();
const fpsDecimal = /^(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/.exec(serializedFps);
if (!fpsDecimal) return 0;
const fpsWhole = fpsDecimal[1] ?? "0";
const fpsFraction = fpsDecimal[2] ?? "";
const fpsExponent = Number.parseInt(fpsDecimal[3] ?? "0", 10);
const fpsDigits = BigInt(`${fpsWhole}${fpsFraction}`);
const fpsScale = fpsExponent - fpsFraction.length;
const fpsNumerator = fpsScale >= 0 ? fpsDigits * 10n ** BigInt(fpsScale) : fpsDigits;
const fpsDenominator = fpsScale >= 0 ? 1n : 10n ** BigInt(-fpsScale);

const frameNumerator = microseconds * fpsNumerator;
const frameDenominator = 1_000_000n * fpsDenominator;
const frameCount = isVFR
? (frameNumerator + frameDenominator - 1n) / frameDenominator
: (2n * frameNumerator + frameDenominator) / (2n * frameDenominator);
const frames = Number(frameCount);
return Math.max(1, Number.isSafeInteger(frames) ? frames : Number.MAX_SAFE_INTEGER);
}

export interface ExtractionOptions {
fps: number;
outputDir: string;
Expand Down Expand Up @@ -1102,6 +1158,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 +1252,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
Loading
Loading