Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,12 +184,18 @@ export {
createFrameLookupTable,
FrameLookupTable,
analyzeClipMediaFit,
classifyVideoExtractionError,
isVideoSourceExtractionError,
runVideoExtractionWithRetry,
VideoSourceExtractionError,
type VideoElement,
type ImageElement,
type ExtractedFrames,
type ExtractionOptions,
type ExtractionResult,
type ExtractionPhaseBreakdown,
type VideoExtractionFailure,
type VideoExtractionFailureKind,
type VideoFrameFormat,
VIDEO_FRAME_FORMATS,
isVideoFrameFormat,
Expand Down
157 changes: 157 additions & 0 deletions packages/engine/src/services/videoFrameExtractor.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
existsSync,
Expand Down Expand Up @@ -25,6 +26,9 @@ import {
decoderForCodec,
getFrameAtTime,
analyzeClipMediaFit,
classifyVideoExtractionError,
runVideoExtractionWithRetry,
VideoSourceExtractionError,
type VideoElement,
type ExtractedFrames,
type ExtractionResult,
Expand All @@ -41,6 +45,120 @@ import { COMPLETE_SENTINEL, GC_MARKER, SCHEMA_PREFIX } from "./extractionCache.j
// synthesized VFR fixture.
const HAS_FFMPEG = spawnSync("ffmpeg", ["-version"]).status === 0;

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({
kind: "download_not_found",
retryable: false,
});
expect(classifyVideoExtractionError(new Error("HTTP 503: Service Unavailable"))).toMatchObject({
kind: "download_transient",
retryable: true,
});
});

it("retries one transient failure, cleaning partial output before the retry", async () => {
const retryDir = mkdtempSync(join(tmpdir(), "hf-extract-retry-"));
const partialPath = join(retryDir, "frame-00001.jpg");
let attempts = 0;
try {
const outcome = await runVideoExtractionWithRetry(
async () => {
attempts += 1;
if (attempts === 1) {
writeFileSync(partialPath, "partial");
throw new VideoSourceExtractionError(
"ffmpeg_timeout",
true,
"Video frame extraction timed out",
);
}
expect(existsSync(partialPath)).toBe(false);
return "frames";
},
{
maxTransientRetries: 1,
onRetry: () => {
rmSync(retryDir, { recursive: true, force: true });
mkdirSync(retryDir, { recursive: true });
},
},
);

expect(outcome).toEqual({ result: "frames", retries: 1 });
expect(attempts).toBe(2);
} finally {
rmSync(retryDir, { recursive: true, force: true });
}
});

it("does not retry deterministic or caller-aborted failures", async () => {
let deterministicAttempts = 0;
await expect(
runVideoExtractionWithRetry(async () => {
deterministicAttempts += 1;
throw new VideoSourceExtractionError(
"zero_output",
false,
"Video source produced no decodable frames",
);
}),
).rejects.toMatchObject({ kind: "zero_output", retryable: false });
expect(deterministicAttempts).toBe(1);

const controller = new AbortController();
controller.abort();
let abortedAttempts = 0;
await expect(
runVideoExtractionWithRetry(
async () => {
abortedAttempts += 1;
throw new VideoSourceExtractionError(
"download_transient",
true,
"Video source download failed transiently",
);
},
{ signal: controller.signal },
),
).rejects.toMatchObject({ kind: "cancelled", retryable: false });
expect(abortedAttempts).toBe(0);
});

it("does not retry transient extraction failures unless the caller opts in", async () => {
let attempts = 0;
await expect(
runVideoExtractionWithRetry(async () => {
attempts += 1;
throw new VideoSourceExtractionError(
"ffmpeg_timeout",
true,
"Video frame extraction timed out",
);
}),
).rejects.toMatchObject({ kind: "ffmpeg_timeout", retryable: true });
expect(attempts).toBe(1);
});

it("fails closed to zero retries for a non-finite runtime retry budget", async () => {
let attempts = 0;
await expect(
runVideoExtractionWithRetry(
async () => {
attempts += 1;
throw new VideoSourceExtractionError(
"ffmpeg_timeout",
true,
"Video frame extraction timed out",
);
},
{ maxTransientRetries: Number.NaN },
),
).rejects.toMatchObject({ kind: "ffmpeg_timeout", retryable: true });
expect(attempts).toBe(1);
});
});

// Codec-based alpha defaulting replaces tag-based detection (the
// alpha_mode/ALPHA_MODE case bug — see ffprobe.test.ts for the regression
// pin on that). The extractor uses these helpers for two decisions:
Expand Down Expand Up @@ -966,6 +1084,45 @@ describe.skipIf(!HAS_FFMPEG)("extractAllVideoFrames on a VFR source", () => {
return src;
}

it("rejects a media start beyond source duration before invoking FFmpeg", async () => {
const src = await synthCfrClip("zero-output-src.mp4", 1);
const outputDir = join(FIXTURE_DIR, "out-zero-output");
await expect(
extractVideoFramesRange(src, "past-eof", 2, 1, { fps: 30, outputDir }),
).rejects.toMatchObject({
kind: "media_start_out_of_range",
retryable: false,
});
}, 60_000);

it("preserves legacy metadata rejection unless typed aggregation is explicitly enabled", async () => {
const src = join(FIXTURE_DIR, "invalid-probe.mp4");
writeFileSync(src, "not a media container");
const video = cfrClipElement("invalid-probe", src, 1);

await expect(
extractAllVideoFrames([video], FIXTURE_DIR, {
fps: 30,
outputDir: join(FIXTURE_DIR, "out-invalid-probe-legacy"),
}),
).rejects.toThrow();

const collected = await extractAllVideoFrames([video], FIXTURE_DIR, {
fps: 30,
outputDir: join(FIXTURE_DIR, "out-invalid-probe-typed"),
collectProbeFailures: true,
});
expect(collected.success).toBe(false);
expect(collected.extracted).toEqual([]);
expect(collected.errors).toEqual([
expect.objectContaining({
videoId: "invalid-probe",
kind: "invalid_media",
retryable: false,
}),
]);
}, 60_000);

async function synthHdrTaggedClip(name: string, durationSeconds: number): Promise<string> {
const src = join(FIXTURE_DIR, name);
const synth = await runFfmpeg([
Expand Down
Loading
Loading