fix(producer): type video extraction failures (#2776)

## Summary
- classify per-source video download/probe/decode/extraction failures with a bounded taxonomy and safe producer-facing summaries
- add candidate-only, at-most-one transient retry with cleanup and retry telemetry
- preserve default engine/producer behavior when the policy is off
- carry allowlisted extraction error codes through blocking JSON and SSE responses

## Stack
Depends on #2774 for atomic remote downloads and its single owned download retry. This PR is intentionally based on `fix/atomic-video-download-retry`; rebase/change the base to `main` after #2774 merges.

## Default compatibility
`HF_VIDEO_EXTRACTION_FAILURE_MODE` defaults to `off` and forces `maxTransientRetries=0`.

With the feature off:
- metadata probe failures keep the legacy Promise rejection
- grouped extraction keeps the existing grouped-to-direct fallback
- no new producer failure gate is enforced
- render-plan schema, Plan v1 artifacts, chunk routing, and distributed execution are unchanged

Typed metadata aggregation is explicit and enabled only by the candidate enforce lane.

## Retry ownership
- remote downloads: exactly one retry owned by #2774
- metadata/FFmpeg extraction: at most one retry only when `HF_VIDEO_EXTRACTION_MAX_RETRIES=1`
- invalid, missing, rejected, out-of-range, zero-output, cancellation, and unknown/internal failures do not retry
- non-finite or invalid runtime retry budgets fail closed to zero
- the superset optimization is never retried; on failure it preserves direct-member fallback, and only the individual ranges can use the bounded retry
- retry counters increment when a retry is scheduled, including exhausted retries

The internal sidecar and Experiment Framework must treat both exhausted stage codes as workflow-terminal after the producer-local budget. Candidate enforcement must not be enabled until those companion mappings are deployed, or Temporal can multiply producer attempts.

## Failure contract
- `VIDEO_SOURCE_UNRENDERABLE`: at least one deterministic/unknown source failure
- `VIDEO_EXTRACTION_FAILED`: all source failures are transient but the producer-local budget is exhausted

Only the allowlisted code and kind/count summaries cross JSON/SSE. Raw diagnostics remain engine-local because they may contain signed URLs or local paths.

## Rollout
1. merge and deploy with stable/candidate both `off`
2. candidate `observe`, retries 0
3. candidate `observe`, retries 1
4. deploy internal + EF terminal transport mappings
5. candidate `enforce`, retries 1
6. keep stable off until success delta, retry counts, extraction latency, CPU/disk, and queue backlog are acceptable

## Validation
- engine focused suites: 105 passed
- producer focused suites: 15 passed
- full engine suite: 1,176 passed, 3 skipped
- full producer unit lane: 32 Vitest files / 393 tests plus all classified Bun unit tests
- engine and producer typechecks passed
- oxlint, oxfmt, Fallow, tracked-artifact, and commit hooks passed
- independent review: approved for merge default-off; candidate enforcement held on companion transport rollout
This commit is contained in:
James Russo
2026-07-26 16:33:00 -04:00
committed by GitHub
13 changed files with 1102 additions and 63 deletions
+6
View File
@@ -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,
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { classifyFfmpegSpawnError } from "./videoFrameExtractor.js";
describe("classifyFfmpegSpawnError", () => {
it.each(["ENOENT", "EACCES", "ENOEXEC", "UNKNOWN"])(
"keeps deterministic launch failure %s terminal",
(code) => {
expect(classifyFfmpegSpawnError(Object.assign(new Error(code), { code }))).toMatchObject({
retryable: false,
});
},
);
it.each(["EAGAIN", "EMFILE", "ENFILE"])("retries known transient launch failure %s", (code) => {
expect(classifyFfmpegSpawnError(Object.assign(new Error(code), { code }))).toMatchObject({
kind: "ffmpeg_transient",
retryable: true,
});
});
});
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
existsSync,
@@ -25,6 +26,9 @@ import {
decoderForCodec,
getFrameAtTime,
analyzeClipMediaFit,
classifyVideoExtractionError,
runVideoExtractionWithRetry,
VideoSourceExtractionError,
type VideoElement,
type ExtractedFrames,
type ExtractionResult,
@@ -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:
@@ -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([
@@ -17,7 +17,7 @@ import {
isHdrColorSpace as isHdrColorSpaceUtil,
type HdrTransfer,
} from "../utils/hdr.js";
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
import { downloadToTemp, isHttpUrl, UrlDownloadError } from "../utils/urlDownloader.js";
import { runFfmpeg } from "../utils/runFfmpeg.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { unwrapTemplate } from "../utils/htmlTemplate.js";
@@ -83,6 +83,17 @@ export interface ExtractionOptions {
quality?: number;
format?: VideoFrameFormat;
sdrToHdrTransfer?: HdrTransfer;
/**
* Bounded per-source FFmpeg retries. Default 0 preserves stable behavior;
* the producer may canary at most one retry after observing typed failures.
*/
maxTransientRetries?: number;
/**
* Collect metadata-probe failures into `ExtractionResult.errors` instead
* of preserving the legacy Promise rejection. Default false; only the
* candidate enforce lane may opt into typed aggregation.
*/
collectProbeFailures?: boolean;
}
const EXTRACT_CACHE_MIN_AGE_MS = 60 * 60 * 1000;
@@ -136,12 +147,267 @@ export interface ExtractionPhaseBreakdown {
extractMs: number;
cacheHits: number;
cacheMisses: number;
/** Number of per-source transient failures retried inside this extraction. */
transientRetries?: number;
}
export type VideoExtractionFailureKind =
| "cancelled"
| "source_missing"
| "source_rejected"
| "download_not_found"
| "download_transient"
| "invalid_media"
| "media_start_out_of_range"
| "ffmpeg_unavailable"
| "ffmpeg_timeout"
| "ffmpeg_transient"
| "ffmpeg_failed"
| "zero_output"
| "internal";
export interface VideoExtractionFailure {
videoId: string;
/** Always populated by this engine version; optional for source compatibility with older consumers. */
kind?: VideoExtractionFailureKind;
/** Always populated by this engine version; absent legacy values fail closed. */
retryable?: boolean;
/**
* Operator diagnostic retained inside the engine result. Producer-facing
* errors must summarize `kind`/counts and must not forward this field: it
* can contain a local path or a signed source URL.
*/
error: string;
}
export class VideoSourceExtractionError extends Error {
readonly hyperframesVideoSourceExtractionError = true as const;
constructor(
readonly kind: VideoExtractionFailureKind,
readonly retryable: boolean,
message: string,
readonly diagnostic: string = message,
) {
super(message);
this.name = "VideoSourceExtractionError";
}
}
export function isVideoSourceExtractionError(error: unknown): error is VideoSourceExtractionError {
return (
typeof error === "object" &&
error !== null &&
"hyperframesVideoSourceExtractionError" in error &&
error.hyperframesVideoSourceExtractionError === true
);
}
function boundedTransientRetryBudget(value: number | undefined): 0 | 1 {
return Number.isFinite(value) && (value ?? 0) >= 1 ? 1 : 0;
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* Convert legacy/raw downloader and filesystem errors into the bounded
* extraction taxonomy. New extraction code should throw
* `VideoSourceExtractionError` directly; this classifier keeps older utility
* boundaries safe while they migrate.
*/
export function classifyVideoExtractionError(error: unknown): VideoSourceExtractionError {
if (isVideoSourceExtractionError(error)) return error;
const diagnostic = errorText(error);
const lowered = diagnostic.toLowerCase();
if (error instanceof UrlDownloadError) {
if (error.kind === "cancelled") {
return new VideoSourceExtractionError(
"cancelled",
false,
"Video extraction cancelled",
diagnostic,
);
}
if (error.kind === "http_not_found") {
return new VideoSourceExtractionError(
"download_not_found",
false,
"Video source was not found",
diagnostic,
);
}
if (error.kind === "http_rejected") {
return new VideoSourceExtractionError(
"source_rejected",
false,
"Video source download was rejected",
diagnostic,
);
}
if (error.retryable) {
return new VideoSourceExtractionError(
"download_transient",
true,
"Video source download failed transiently",
diagnostic,
);
}
return new VideoSourceExtractionError(
"internal",
false,
"Video source download failed internally",
diagnostic,
);
}
if (lowered.includes("cancelled") || lowered.includes("aborted")) {
return new VideoSourceExtractionError(
"cancelled",
false,
"Video extraction cancelled",
diagnostic,
);
}
if (lowered.includes("video file not found")) {
return new VideoSourceExtractionError(
"source_missing",
false,
"Video source is missing",
diagnostic,
);
}
if (
lowered.includes("only https urls are permitted") ||
lowered.includes("private/reserved address") ||
lowered.includes("invalid url")
) {
return new VideoSourceExtractionError(
"source_rejected",
false,
"Video source URL is not permitted",
diagnostic,
);
}
const httpStatus = diagnostic.match(/\bHTTP\s+(\d{3})\b/i)?.[1];
if (httpStatus) {
const status = Number(httpStatus);
if (status === 404 || status === 410) {
return new VideoSourceExtractionError(
"download_not_found",
false,
"Video source was not found",
diagnostic,
);
}
if (status === 408 || status === 429 || status >= 500) {
return new VideoSourceExtractionError(
"download_transient",
true,
"Video source download failed transiently",
diagnostic,
);
}
return new VideoSourceExtractionError(
"source_rejected",
false,
"Video source download was rejected",
diagnostic,
);
}
if (
lowered.includes("[urldownloader] download timeout") ||
lowered.includes("[urldownloader] download failed") ||
lowered.includes("fetch failed") ||
lowered.includes("network")
) {
return new VideoSourceExtractionError(
"download_transient",
true,
"Video source download failed transiently",
diagnostic,
);
}
if (lowered.includes("ffprobe not found")) {
return new VideoSourceExtractionError(
"ffmpeg_unavailable",
false,
"FFprobe is unavailable",
diagnostic,
);
}
if (lowered.includes("ffprobe deadline")) {
return new VideoSourceExtractionError(
"ffmpeg_timeout",
true,
"Video inspection timed out",
diagnostic,
);
}
if (
lowered.includes("ffprobe") ||
lowered.includes("failed to parse ffprobe output") ||
lowered.includes("no video stream found")
) {
return new VideoSourceExtractionError(
"invalid_media",
false,
"Video source could not be inspected",
diagnostic,
);
}
return new VideoSourceExtractionError(
"internal",
false,
"Video extraction failed internally",
diagnostic,
);
}
export async function runVideoExtractionWithRetry<T>(
operation: () => Promise<T>,
options: {
signal?: AbortSignal;
onRetry?: () => Promise<void> | void;
maxTransientRetries?: number;
} = {},
): Promise<{ result: T; retries: number }> {
const maxTransientRetries = boundedTransientRetryBudget(options.maxTransientRetries);
let retries = 0;
for (;;) {
if (options.signal?.aborted) {
throw new VideoSourceExtractionError("cancelled", false, "Video extraction cancelled");
}
try {
return { result: await operation(), retries };
} catch (error) {
const classified = classifyVideoExtractionError(error);
if (options.signal?.aborted) {
throw new VideoSourceExtractionError(
"cancelled",
false,
"Video extraction cancelled",
classified.diagnostic,
);
}
if (
classified.kind === "cancelled" ||
!classified.retryable ||
retries >= maxTransientRetries
) {
throw classified;
}
retries += 1;
await options.onRetry?.();
}
}
}
export interface ExtractionResult {
success: boolean;
extracted: ExtractedFrames[];
errors: Array<{ videoId: string; error: string }>;
errors: VideoExtractionFailure[];
totalFramesExtracted: number;
durationMs: number;
phaseBreakdown: ExtractionPhaseBreakdown;
@@ -270,7 +536,28 @@ export async function extractVideoFramesRange(
const videoOutputDir = outputDirOverride ?? join(outputDir, videoId);
if (!existsSync(videoOutputDir)) mkdirSync(videoOutputDir, { recursive: true });
const metadata = await extractMediaMetadata(videoPath);
let metadata: VideoMetadata;
try {
metadata = await extractMediaMetadata(videoPath);
} catch (error) {
throw classifyVideoExtractionError(error);
}
if (!(metadata.durationSeconds > 0)) {
throw new VideoSourceExtractionError(
"invalid_media",
false,
"Video source has no positive duration",
`Video source duration is ${metadata.durationSeconds}s`,
);
}
if (startTime >= metadata.durationSeconds) {
throw new VideoSourceExtractionError(
"media_start_out_of_range",
false,
"Video media start is outside the source duration",
`Video media start ${startTime}s is outside source duration ${metadata.durationSeconds}s`,
);
}
const format = resolveFrameFormat(metadata, options.format);
const framePattern = `${FRAME_FILENAME_PREFIX}%05d.${format}`;
const outputPattern = join(videoOutputDir, framePattern);
@@ -328,13 +615,10 @@ export async function extractVideoFramesRange(
const processResult = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
if (processResult.terminationReason === "abort") {
throw new Error("Video frame extraction cancelled");
throw new VideoSourceExtractionError("cancelled", false, "Video extraction cancelled");
}
if (processResult.terminationReason === "spawn_error") {
if ((processResult.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
throw new Error("[FFmpeg] ffmpeg not found");
}
throw processResult.error ?? new Error(processResult.stderr);
throw classifyFfmpegSpawnError(processResult.error, processResult.stderr);
}
if (!processResult.success) {
// With the SDR-to-HDR remap folded into this pass, a filter failure
@@ -344,12 +628,30 @@ export async function extractVideoFramesRange(
const hdrPrefix = options.sdrToHdrTransfer
? `SDR→HDR conversion failed (colorspace filter in extract pass, target ${options.sdrToHdrTransfer}): `
: "";
const timeoutSuffix =
processResult.terminationReason === "deadline"
? ` (timed out after ${ffmpegProcessTimeout} ms)`
: "";
throw new Error(
`${hdrPrefix}FFmpeg exited with code ${processResult.exitCode}${timeoutSuffix}: ${processResult.stderr.slice(-500)}`,
const timedOut = processResult.terminationReason === "deadline";
const timeoutSuffix = timedOut ? ` (timed out after ${ffmpegProcessTimeout} ms)` : "";
const diagnostic =
`${hdrPrefix}FFmpeg exited with code ${processResult.exitCode}${timeoutSuffix}: ` +
processResult.stderr.slice(-500);
if (timedOut) {
throw new VideoSourceExtractionError(
"ffmpeg_timeout",
true,
"Video frame extraction timed out",
diagnostic,
);
}
const transientIo =
/resource temporarily unavailable|device or resource busy|input\/output error/i.test(
processResult.stderr,
);
throw new VideoSourceExtractionError(
transientIo ? "ffmpeg_transient" : "ffmpeg_failed",
transientIo,
transientIo
? "Video frame extraction hit a transient I/O failure"
: "Video source could not be decoded",
diagnostic,
);
}
@@ -360,6 +662,14 @@ export async function extractVideoFramesRange(
files.forEach((file, index) => {
framePaths.set(index, join(videoOutputDir, file));
});
if (framePaths.size === 0 && duration > 0) {
throw new VideoSourceExtractionError(
"zero_output",
false,
"Video source produced no decodable frames",
`FFmpeg exited successfully but produced no frames (start=${startTime}, duration=${duration})`,
);
}
return {
videoId,
@@ -373,6 +683,33 @@ export async function extractVideoFramesRange(
};
}
const TRANSIENT_FFMPEG_SPAWN_CODES = new Set(["EAGAIN", "EMFILE", "ENFILE"]);
export function classifyFfmpegSpawnError(error: unknown, stderr = ""): VideoSourceExtractionError {
const code =
typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
? error.code
: "";
if (code === "ENOENT") {
return new VideoSourceExtractionError(
"ffmpeg_unavailable",
false,
"FFmpeg is unavailable",
"[FFmpeg] ffmpeg not found",
);
}
const diagnostic = error instanceof Error ? error.message : stderr;
const retryable = TRANSIENT_FFMPEG_SPAWN_CODES.has(code);
return new VideoSourceExtractionError(
retryable ? "ffmpeg_transient" : "ffmpeg_failed",
retryable,
retryable
? "FFmpeg could not be started due to transient resource pressure"
: "FFmpeg could not be started",
diagnostic,
);
}
/**
* Resolve the used-segment duration for a video, falling back to the source's
* natural duration when the caller hasn't specified bounds (end=Infinity) or
@@ -694,7 +1031,7 @@ export async function extractAllVideoFrames(
): Promise<ExtractionResult> {
const startTime = Date.now();
const extracted: ExtractedFrames[] = [];
const errors: Array<{ videoId: string; error: string }> = [];
const errors: VideoExtractionFailure[] = [];
let totalFramesExtracted = 0;
const breakdown: ExtractionPhaseBreakdown = {
resolveMs: 0,
@@ -711,6 +1048,10 @@ export async function extractAllVideoFrames(
extractMs: 0,
cacheHits: 0,
cacheMisses: 0,
transientRetries: 0,
};
const recordTransientRetries = (count: number): void => {
breakdown.transientRetries = (breakdown.transientRetries ?? 0) + count;
};
// Phase 1: Resolve paths and download remote videos
@@ -730,7 +1071,9 @@ export async function extractAllVideoFrames(
if (isHttpUrl(videoPath)) {
const downloadDir = join(options.outputDir, "_downloads");
mkdirSync(downloadDir, { recursive: true });
videoPath = await downloadToTemp(videoPath, downloadDir, undefined, signal);
videoPath = await downloadToTemp(videoPath, downloadDir, undefined, signal, () =>
recordTransientRetries(1),
);
}
if (!existsSync(videoPath)) {
@@ -747,12 +1090,23 @@ export async function extractAllVideoFrames(
`(e.g. src="assets/foo.mp4") over "../assets/foo.mp4".\n`,
);
}
errors.push({ videoId: video.id, error: `Video file not found: ${videoPath}` });
errors.push({
videoId: video.id,
kind: "source_missing",
retryable: false,
error: `Video file not found: ${videoPath}`,
});
continue;
}
resolvedVideos.push({ video, videoPath });
} catch (err) {
errors.push({ videoId: video.id, error: err instanceof Error ? err.message : String(err) });
const classified = classifyVideoExtractionError(err);
errors.push({
videoId: video.id,
kind: classified.kind,
retryable: classified.retryable,
error: classified.diagnostic,
});
}
}
@@ -782,9 +1136,46 @@ export async function extractAllVideoFrames(
// Phase 2: Probe color spaces and normalize if mixed HDR/SDR
const phase2ProbeStart = Date.now();
const videoMetadata = await Promise.all(
resolvedVideos.map(({ videoPath }) => extractMediaMetadata(videoPath)),
const metadataResults = await Promise.all(
resolvedVideos.map(async ({ video, videoPath }, index) => {
try {
// Keep the default/off path byte-for-byte compatible with the legacy
// Promise.all rejection. Classification is introduced only when a
// bounded retry or explicit typed aggregation is enabled.
const attempted =
!options.collectProbeFailures &&
boundedTransientRetryBudget(options.maxTransientRetries) === 0
? { result: await extractMediaMetadata(videoPath), retries: 0 }
: await runVideoExtractionWithRetry(() => extractMediaMetadata(videoPath), {
signal,
maxTransientRetries: options.maxTransientRetries,
onRetry: () => recordTransientRetries(1),
});
return {
video,
videoPath,
metadata: attempted.result,
cacheKeyInput: cacheKeyInputs[index] ?? null,
};
} catch (error) {
if (!options.collectProbeFailures) throw error;
errors.push(extractionError(video.id, error));
return null;
}
}),
);
const probedVideos = metadataResults.filter((entry) => entry !== null);
resolvedVideos.splice(
0,
resolvedVideos.length,
...probedVideos.map(({ video, videoPath }) => ({ video, videoPath })),
);
cacheKeyInputs.splice(
0,
cacheKeyInputs.length,
...probedVideos.map(({ cacheKeyInput }) => cacheKeyInput),
);
const videoMetadata = probedVideos.map(({ metadata }) => metadata);
const videoColorSpaces = videoMetadata.map((m) => m.colorSpace);
// Canonical per-index record of the SDR-to-HDR transform decision. BOTH the
// cache key (transform discriminator) and the extraction options read from
@@ -829,6 +1220,8 @@ export async function extractAllVideoFrames(
if (entry.video.mediaStart >= metadata.durationSeconds) {
errors.push({
videoId: entry.video.id,
kind: "media_start_out_of_range",
retryable: false,
error: `SDR→HDR conversion skipped: mediaStart (${entry.video.mediaStart}s) ≥ source duration (${metadata.durationSeconds}s)`,
});
hdrSkippedIndices.add(i);
@@ -865,12 +1258,10 @@ export async function extractAllVideoFrames(
const vfrPreflightStart = Date.now();
for (let i = 0; i < resolvedVideos.length; i++) {
if (signal?.aborted) break;
const entry = resolvedVideos[i];
if (!entry) continue;
const vfrProbeStart = Date.now();
const metadata = await extractMediaMetadata(entry.videoPath);
const metadata = videoMetadata[i];
breakdown.vfrProbeMs += Date.now() - vfrProbeStart;
if (metadata.isVFR) breakdown.vfrPreflightCount += 1;
if (metadata?.isVFR) breakdown.vfrPreflightCount += 1;
}
breakdown.vfrPreflightMs = Date.now() - vfrPreflightStart;
@@ -888,17 +1279,19 @@ export async function extractAllVideoFrames(
}
}
function extractionError(videoId: string, err: unknown): { videoId: string; error: string } {
return { videoId, error: err instanceof Error ? err.message : String(err) };
function extractionError(videoId: string, err: unknown): VideoExtractionFailure {
const classified = classifyVideoExtractionError(err);
return {
videoId,
kind: classified.kind,
retryable: classified.retryable,
error: classified.diagnostic,
};
}
type PreparedExtractionResult =
| { work: PreparedExtraction }
| { error: { videoId: string; error: string } };
type PreparedExtractionResult = { work: PreparedExtraction } | { error: VideoExtractionFailure };
type ExtractionOutcome =
| { result: ExtractedFrames }
| { error: { videoId: string; error: string } };
type ExtractionOutcome = { result: ExtractedFrames } | { error: VideoExtractionFailure };
function scopedExtractionOptions(work: PreparedExtraction): ExtractionOptions {
return { ...options, format: work.format, sdrToHdrTransfer: work.sdrToHdrTransfer };
@@ -951,32 +1344,62 @@ export async function extractAllVideoFrames(
};
}
async function extractDirectMiss(miss: UniqueExtractionMiss): Promise<ExtractedFrames> {
async function extractDirectMiss(
miss: UniqueExtractionMiss,
maxTransientRetries = options.maxTransientRetries ?? 0,
): Promise<ExtractedFrames> {
const { work, cacheTarget } = miss;
if (!cacheTarget) {
return extractVideoFramesRange(
work.videoPath,
work.video.id,
work.video.mediaStart,
work.videoDuration,
scopedExtractionOptions(work),
signal,
config,
const outputDir = join(options.outputDir, work.video.id);
const attempted = await runVideoExtractionWithRetry(
() =>
extractVideoFramesRange(
work.videoPath,
work.video.id,
work.video.mediaStart,
work.videoDuration,
scopedExtractionOptions(work),
signal,
config,
),
{
signal,
maxTransientRetries,
onRetry: () => {
recordTransientRetries(1);
rmSync(outputDir, { recursive: true, force: true });
},
},
);
return attempted.result;
}
const partialDir = partialCacheEntryDir(cacheTarget.entry);
rmSync(partialDir, { recursive: true, force: true });
mkdirSync(partialDir, { recursive: true });
const result = await extractVideoFramesRange(
work.videoPath,
work.video.id,
work.video.mediaStart,
work.videoDuration,
scopedExtractionOptions(work),
signal,
config,
partialDir,
const attempted = await runVideoExtractionWithRetry(
() =>
extractVideoFramesRange(
work.videoPath,
work.video.id,
work.video.mediaStart,
work.videoDuration,
scopedExtractionOptions(work),
signal,
config,
partialDir,
),
{
signal,
maxTransientRetries,
onRetry: () => {
recordTransientRetries(1);
rmSync(partialDir, { recursive: true, force: true });
mkdirSync(partialDir, { recursive: true });
},
},
);
const result = attempted.result;
const published = publishCacheEntry(cacheTarget.entry, partialDir);
if (!published.published) {
breakdown.cachePublishFailures += 1;
@@ -985,9 +1408,12 @@ export async function extractAllVideoFrames(
return rehydratePublishedCache(work, cacheTarget);
}
async function executeDirectMiss(miss: UniqueExtractionMiss): Promise<ExtractionOutcome> {
async function executeDirectMiss(
miss: UniqueExtractionMiss,
maxTransientRetries = options.maxTransientRetries ?? 0,
): Promise<ExtractionOutcome> {
try {
return { result: await extractDirectMiss(miss) };
return { result: await extractDirectMiss(miss, maxTransientRetries) };
} catch (err) {
return { error: extractionError(miss.work.video.id, err) };
}
@@ -1036,6 +1462,10 @@ export async function extractAllVideoFrames(
try {
rmSync(tempDir, { recursive: true, force: true });
// A long union can hit the fixed FFmpeg deadline even when each shorter
// member range succeeds. Do not retry the optimization itself; preserve
// the established grouped→direct fallback and apply bounded retries only
// to the individual source ranges below.
const superset = await extractVideoFramesRange(
first.videoPath,
group.groupId,
@@ -1164,7 +1594,14 @@ export async function extractAllVideoFrames(
const message = isFollower
? `[shared extraction, leader ${outcome.error.videoId}] ${outcome.error.error}`
: outcome.error.error;
return { error: { videoId: prepared.work.video.id, error: message } };
return {
error: {
videoId: prepared.work.video.id,
kind: outcome.error.kind,
retryable: outcome.error.retryable,
error: message,
},
};
}
return { result: { ...outcome.result, videoId: prepared.work.video.id } };
});
@@ -193,10 +193,21 @@ describe("downloadToTemp atomic publication and bounded retry", () => {
.mockResolvedValueOnce(new Response("complete"));
vi.stubGlobal("fetch", fetchMock);
const dir = makeTempDir();
const onTransientRetry = vi.fn();
const path = await downloadToTemp("https://cdn.example/retry-503.mp4", dir, 1_000);
const path = await downloadToTemp(
"https://cdn.example/retry-503.mp4",
dir,
1_000,
undefined,
onTransientRetry,
);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(onTransientRetry).toHaveBeenCalledOnce();
expect(onTransientRetry).toHaveBeenCalledWith(
expect.objectContaining({ kind: "http_transient", retryable: true }),
);
expect(readFileSync(path, "utf8")).toBe("complete");
expect(temporaryDownloadEntries(dir)).toEqual([]);
});
+4 -1
View File
@@ -347,6 +347,7 @@ async function downloadWithRetry(
localPath: string,
timeoutMs: number,
signal?: AbortSignal,
onTransientRetry?: (error: UrlDownloadError) => void,
): Promise<string> {
const maxTransientRetries = 1;
for (let attempt = 0; ; attempt += 1) {
@@ -355,6 +356,7 @@ async function downloadWithRetry(
} catch (error) {
const classified = classifyDownloadFailure(error);
if (!classified.retryable || attempt >= maxTransientRetries) throw classified;
onTransientRetry?.(classified);
}
}
}
@@ -364,6 +366,7 @@ export async function downloadToTemp(
destDir: string,
timeoutMs: number = 300000,
signal?: AbortSignal,
onTransientRetry?: (error: UrlDownloadError) => void,
): Promise<string> {
// Reject non-HTTPS URLs and private/reserved address ranges before
// touching the cache or filesystem — customer-supplied compositions must
@@ -389,7 +392,7 @@ export async function downloadToTemp(
if (hasCompleteFile(localPath)) return localPath;
const downloadPromise = downloadWithRetry(url, localPath, timeoutMs, signal);
const downloadPromise = downloadWithRetry(url, localPath, timeoutMs, signal, onTransientRetry);
const trackedDownload = downloadPromise.finally(() => {
inFlightDownloads.delete(inFlightKey);
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { extractSafeRenderErrorCode } from "./server.js";
import { VideoExtractionStageError } from "./services/render/stages/extractVideosStage.js";
describe("extractSafeRenderErrorCode", () => {
it("preserves allowlisted typed extraction codes", () => {
const deterministic = new VideoExtractionStageError("VIDEO_SOURCE_UNRENDERABLE", false, [
{ kind: "invalid_media", count: 1 },
]);
const exhausted = new VideoExtractionStageError("VIDEO_EXTRACTION_FAILED", true, [
{ kind: "ffmpeg_timeout", count: 1 },
]);
expect(extractSafeRenderErrorCode(deterministic)).toBe("VIDEO_SOURCE_UNRENDERABLE");
expect(extractSafeRenderErrorCode(exhausted)).toBe("VIDEO_EXTRACTION_FAILED");
});
it("accepts the same bounded structural code across wrapped module boundaries", () => {
expect(extractSafeRenderErrorCode({ code: "VIDEO_SOURCE_UNRENDERABLE" })).toBe(
"VIDEO_SOURCE_UNRENDERABLE",
);
});
it("does not forward arbitrary codes or parse message text", () => {
expect(extractSafeRenderErrorCode({ code: "INTERNAL_ERROR" })).toBeUndefined();
expect(
extractSafeRenderErrorCode(new Error("failed [VIDEO_SOURCE_UNRENDERABLE; secret=/tmp/x]")),
).toBeUndefined();
});
});
+18
View File
@@ -118,6 +118,20 @@ interface PreparedRenderInput {
}
const DEFAULT_SERVER_FPS = { num: 30, den: 1 } as const;
const SAFE_RENDER_ERROR_CODES = new Set<string>([
"VIDEO_SOURCE_UNRENDERABLE",
"VIDEO_EXTRACTION_FAILED",
]);
/**
* Preserve only bounded producer error codes across JSON/SSE. Never derive a
* code from the message: it may contain local paths or signed source URLs.
*/
export function extractSafeRenderErrorCode(error: unknown): string | undefined {
if (typeof error !== "object" || error === null || !("code" in error)) return undefined;
const code = error.code;
return typeof code === "string" && SAFE_RENDER_ERROR_CODES.has(code) ? code : undefined;
}
function parseServerFps(value: unknown): RenderInput["fps"] {
if (typeof value !== "number" && typeof value !== "string") return DEFAULT_SERVER_FPS;
@@ -524,6 +538,7 @@ async function writeRenderStreamFailure(input: {
return;
}
const errorMsg = error instanceof Error ? error.message : String(error);
const errorCode = extractSafeRenderErrorCode(error);
const elapsedMs = Date.now() - startedAtMs;
log.error("render-stream failed", {
requestId,
@@ -536,6 +551,7 @@ async function writeRenderStreamFailure(input: {
type: "error",
requestId,
error: errorMsg,
errorCode,
stage: job.currentStage,
elapsedMs,
errorDetails: job.errorDetails ?? null,
@@ -684,6 +700,7 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
} catch (error) {
const durationMs = Date.now() - t0;
const errorMsg = error instanceof Error ? error.message : String(error);
const errorCode = extractSafeRenderErrorCode(error);
log.error("render failed", {
requestId,
durationMs,
@@ -695,6 +712,7 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
success: false,
requestId,
error: errorMsg,
errorCode,
stage: job.currentStage,
durationMs,
errorDetails: job.errorDetails ?? null,
@@ -996,6 +996,7 @@ export async function plan(
assertNotAborted,
materializeSymlinks: true,
});
if (extractResult.failureToEnforce) throw extractResult.failureToEnforce;
// Skip `extractResult.frameLookup.cleanup()`: it would rm-rf each
// video's outputDir, but in `plan()` those directories ARE the source
// material the renames below move into `planDir/video-frames/`.
@@ -109,6 +109,8 @@ export interface RenderExtractionObservability {
vfrPreflightCount?: number;
cacheHits?: number;
cacheMisses?: number;
/** Per-source transient download/metadata/FFmpeg retries performed during extraction. */
transientRetries?: number;
/**
* Per-clip captured-vs-expected-frame gauges. Emitted by the parity gate
* at extract finalization (see `videoFrameCoverage.ts`). Undefined when
@@ -1,6 +1,18 @@
import { describe, expect, it } from "vitest";
import { appendAutoDetectedVideoAudio, shouldCopyExtractedFrames } from "./extractVideosStage.js";
import type { ExtractedFrames, VideoElement } from "@hyperframes/engine";
import type {
ExtractedFrames,
ExtractionResult,
VideoElement,
VideoExtractionFailure,
} from "@hyperframes/engine";
import {
appendAutoDetectedVideoAudio,
assertVideoExtractionSucceeded,
buildHdrProbeStageError,
resolveVideoExtractionPolicy,
shouldCopyExtractedFrames,
VideoExtractionStageError,
} from "./extractVideosStage.js";
function makeVideo(overrides: Partial<VideoElement> = {}): VideoElement {
return {
@@ -35,6 +47,33 @@ function makeExtracted(videoId: string, fileHasAudio: boolean): ExtractedFrames
} as ExtractedFrames;
}
function extractionResult(errors: VideoExtractionFailure[]): ExtractionResult {
return {
success: errors.length === 0,
errors,
extracted: [],
totalFramesExtracted: 0,
durationMs: 1,
phaseBreakdown: {
resolveMs: 0,
cachePublishFailures: 0,
cacheGcEvictions: 0,
cacheGcBytesFreed: 0,
cacheAgedPartialsCleared: 0,
hdrProbeMs: 0,
hdrPreflightMs: 0,
hdrPreflightCount: 0,
vfrProbeMs: 0,
vfrPreflightMs: 0,
vfrPreflightCount: 0,
extractMs: 0,
cacheHits: 0,
cacheMisses: 0,
transientRetries: 0,
},
};
}
describe("appendAutoDetectedVideoAudio", () => {
it("adds audio for an audible video whose file has an audio track", () => {
const composition = { videos: [makeVideo()], audios: [] as never[] };
@@ -92,3 +131,139 @@ describe("shouldCopyExtractedFrames", () => {
expect(shouldCopyExtractedFrames("linux")).toBe(false);
});
});
describe("resolveVideoExtractionPolicy", () => {
it("preserves stable behavior by default", () => {
expect(resolveVideoExtractionPolicy({})).toEqual({
failureMode: "off",
maxTransientRetries: 0,
});
});
it("allows only the bounded candidate rollout values", () => {
expect(
resolveVideoExtractionPolicy({
HF_VIDEO_EXTRACTION_FAILURE_MODE: "observe",
HF_VIDEO_EXTRACTION_MAX_RETRIES: "1",
}),
).toEqual({ failureMode: "observe", maxTransientRetries: 1 });
expect(
resolveVideoExtractionPolicy({
HF_VIDEO_EXTRACTION_FAILURE_MODE: "unexpected",
HF_VIDEO_EXTRACTION_MAX_RETRIES: "1",
}),
).toEqual({ failureMode: "off", maxTransientRetries: 0 });
});
});
describe("assertVideoExtractionSucceeded", () => {
it("accepts a complete extraction", () => {
expect(() => assertVideoExtractionSucceeded(extractionResult([]))).not.toThrow();
});
it("fails deterministic media errors without forwarding paths or signed URLs", () => {
const result = extractionResult([
{
videoId: "narrator",
kind: "zero_output",
retryable: false,
error:
"FFmpeg failed for /tmp/render/secret.mp4 from https://cdn.example/x?Signature=secret",
},
{
videoId: "missing",
kind: "source_missing",
retryable: false,
error: "Video file not found: /tmp/private/input.mp4",
},
]);
let caught: unknown;
try {
assertVideoExtractionSucceeded(result);
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(VideoExtractionStageError);
expect(caught).toMatchObject({
code: "VIDEO_SOURCE_UNRENDERABLE",
retryable: false,
failures: [
{ kind: "source_missing", count: 1 },
{ kind: "zero_output", count: 1 },
],
});
if (!(caught instanceof Error)) {
throw new Error("expected VideoExtractionStageError");
}
expect(caught.message).not.toContain("/tmp/");
expect(caught.message).not.toContain("Signature");
});
it("keeps exhausted transient failures retryable and collapses duplicate kinds", () => {
const result = extractionResult([
{
videoId: "a",
kind: "download_transient",
retryable: true,
error: "HTTP 503",
},
{
videoId: "b",
kind: "download_transient",
retryable: true,
error: "HTTP 503",
},
]);
expect(() => assertVideoExtractionSucceeded(result)).toThrow(
expect.objectContaining({
code: "VIDEO_EXTRACTION_FAILED",
retryable: true,
failures: [{ kind: "download_transient", count: 2 }],
}),
);
});
it("fails closed for legacy failures without a kind or retryability", () => {
expect(() =>
assertVideoExtractionSucceeded(
extractionResult([
{
videoId: "legacy",
error: "legacy extraction error",
},
]),
),
).toThrow(
expect.objectContaining({
code: "VIDEO_SOURCE_UNRENDERABLE",
retryable: false,
failures: [{ kind: "internal", count: 1 }],
}),
);
});
});
describe("buildHdrProbeStageError", () => {
it.each([
[
{ kind: "download_transient" as const, retryable: true },
{ kind: "source_missing" as const, retryable: false },
],
[
{ kind: "source_missing" as const, retryable: false },
{ kind: "download_transient" as const, retryable: true },
],
])("fails closed for mixed probe outcomes regardless of completion order", (...failures) => {
expect(buildHdrProbeStageError(failures)).toMatchObject({
code: "VIDEO_SOURCE_UNRENDERABLE",
retryable: false,
failures: [
{ kind: "download_transient", count: 1 },
{ kind: "source_missing", count: 1 },
],
});
});
});
@@ -34,15 +34,19 @@ import {
type CaptureVideoMetadataHint,
type EngineConfig,
type ExtractedFrames,
type ExtractionResult,
type FrameLookupTable,
type HdrTransfer,
type VideoExtractionFailureKind,
type VideoColorSpace,
classifyVideoExtractionError,
createFrameLookupTable,
detectTransfer,
extractAllVideoFrames,
extractMediaMetadata,
isHdrColorSpace,
resolveProjectRelativeSrc,
runVideoExtractionWithRetry,
} from "@hyperframes/engine";
import { fpsToNumber } from "@hyperframes/core";
import {
@@ -94,6 +98,11 @@ export interface ExtractVideosStageResult {
imageColorSpaces: (VideoColorSpace | null)[];
/** Wall-clock ms for the video extraction phase. */
videoExtractMs: number;
/**
* Candidate-only typed failure gate. Callers throw this only after their
* extraction telemetry checkpoint has been emitted.
*/
failureToEnforce: VideoExtractionStageError | null;
}
/**
@@ -108,6 +117,137 @@ export function shouldCopyExtractedFrames(platform: NodeJS.Platform): boolean {
return platform === "win32";
}
export type VideoExtractionStageErrorCode = "VIDEO_SOURCE_UNRENDERABLE" | "VIDEO_EXTRACTION_FAILED";
export interface VideoExtractionStageFailureSummary {
kind: VideoExtractionFailureKind;
count: number;
}
export type VideoExtractionFailureMode = "off" | "observe" | "enforce";
export interface VideoExtractionPolicy {
failureMode: VideoExtractionFailureMode;
maxTransientRetries: 0 | 1;
}
/**
* Candidate-lane rollout controls. Stable behavior remains unchanged unless
* explicitly enabled in the producer environment.
*/
export function resolveVideoExtractionPolicy(
env: Readonly<Record<string, string | undefined>> = process.env,
): VideoExtractionPolicy {
const rawMode = env.HF_VIDEO_EXTRACTION_FAILURE_MODE?.trim().toLowerCase();
const failureMode: VideoExtractionFailureMode =
rawMode === "observe" || rawMode === "enforce" ? rawMode : "off";
const maxTransientRetries =
failureMode !== "off" && env.HF_VIDEO_EXTRACTION_MAX_RETRIES?.trim() === "1" ? 1 : 0;
return { failureMode, maxTransientRetries };
}
/**
* Producer-safe terminal error for per-source extraction failures.
*
* `ExtractionResult.errors[].error` intentionally retains local diagnostics
* and can contain signed URLs or filesystem paths. This error carries only a
* bounded taxonomy/count summary so the HTTP/Temporal boundary can transport
* the cause without leaking those values.
*/
export class VideoExtractionStageError extends Error {
constructor(
readonly code: VideoExtractionStageErrorCode,
readonly retryable: boolean,
readonly failures: readonly VideoExtractionStageFailureSummary[],
) {
const total = failures.reduce((sum, failure) => sum + failure.count, 0);
const breakdown = failures.map((failure) => `${failure.kind}=${failure.count}`).join(",");
super(`Video extraction failed for ${total} source(s) [${code}; ${breakdown}]`);
this.name = "VideoExtractionStageError";
}
}
export function assertVideoExtractionSucceeded(result: ExtractionResult): void {
const error = buildVideoExtractionStageError(result);
if (error) throw error;
}
function buildVideoExtractionStageError(
result: ExtractionResult,
): VideoExtractionStageError | null {
if (result.success && result.errors.length === 0) return null;
const counts = new Map<VideoExtractionFailureKind, number>();
for (const failure of result.errors) {
const kind = failure.kind ?? "internal";
counts.set(kind, (counts.get(kind) ?? 0) + 1);
}
const failures = Array.from(counts, ([kind, count]) => ({ kind, count })).sort((a, b) =>
a.kind.localeCompare(b.kind),
);
const retryable =
result.errors.length > 0 && result.errors.every((failure) => failure.retryable === true);
return new VideoExtractionStageError(
retryable ? "VIDEO_EXTRACTION_FAILED" : "VIDEO_SOURCE_UNRENDERABLE",
retryable,
failures,
);
}
export function buildHdrProbeStageError(
failures: readonly Pick<ReturnType<typeof classifyVideoExtractionError>, "kind" | "retryable">[],
): VideoExtractionStageError {
const counts = new Map<VideoExtractionFailureKind, number>();
for (const failure of failures) {
counts.set(failure.kind, (counts.get(failure.kind) ?? 0) + 1);
}
const summary = Array.from(counts, ([kind, count]) => ({ kind, count })).sort((a, b) =>
a.kind.localeCompare(b.kind),
);
const retryable = failures.length > 0 && failures.every((failure) => failure.retryable);
return new VideoExtractionStageError(
retryable ? "VIDEO_EXTRACTION_FAILED" : "VIDEO_SOURCE_UNRENDERABLE",
retryable,
summary,
);
}
type HdrProbeFailure = {
error: unknown;
classified: ReturnType<typeof classifyVideoExtractionError>;
};
function isHdrProbeFailure(failure: HdrProbeFailure | null): failure is HdrProbeFailure {
return failure !== null;
}
function throwHdrProbeFailures(
failures: readonly HdrProbeFailure[],
mode: VideoExtractionFailureMode,
): void {
if (failures.length === 0) return;
if (mode === "enforce") {
throw buildHdrProbeStageError(failures.map((failure) => failure.classified));
}
const firstFailure = failures[0];
if (firstFailure) throw firstFailure.error;
}
function applyVideoExtractionFailurePolicy(
result: ExtractionResult,
policy: VideoExtractionPolicy,
log?: ProducerLogger,
): VideoExtractionStageError | null {
const error = buildVideoExtractionStageError(result);
if (!error || policy.failureMode === "off") return null;
log?.warn("Video extraction produced typed source failures", {
mode: policy.failureMode,
code: error.code,
retryable: error.retryable,
failures: error.failures,
});
return policy.failureMode === "enforce" ? error : null;
}
export async function runExtractVideosStage(
input: ExtractVideosStageInput,
): Promise<ExtractVideosStageResult> {
@@ -124,9 +264,11 @@ export async function runExtractVideosStage(
} = input;
const stage2Start = Date.now();
const extractionPolicy = resolveVideoExtractionPolicy();
let frameLookup: FrameLookupTable | null = null;
let extractionResult: Awaited<ReturnType<typeof extractAllVideoFrames>> | null = null;
let failureToEnforce: VideoExtractionStageError | null = null;
let videoReadinessSkipIds: string[] = [];
let videoMetadataHints: CaptureVideoMetadataHint[] = [];
@@ -136,9 +278,10 @@ export async function runExtractVideosStage(
// avoid ffprobe overhead when the user has explicitly opted out.
const nativeHdrVideoIds = new Set<string>();
const videoTransfers = new Map<string, HdrTransfer>();
let hdrProbeTransientRetries = 0;
if (job.config.hdrMode !== "force-sdr" && composition.videos.length > 0) {
log?.info("Probing video color spaces...", { videoCount: composition.videos.length });
await Promise.all(
const probeFailures = await Promise.all(
composition.videos.map(async (v) => {
// Use the shared resolver so a `<video src="../assets/foo">` in a
// sub-composition resolves the same way the browser would (see
@@ -148,14 +291,40 @@ export async function runExtractVideosStage(
const videoPath = isAbsolute(v.src)
? v.src
: resolveProjectRelativeSrc(v.src, projectDir, compiledDir);
if (!existsSync(videoPath)) return;
const meta = await extractMediaMetadata(videoPath);
if (isHdrColorSpace(meta.colorSpace)) {
nativeHdrVideoIds.add(v.id);
videoTransfers.set(v.id, detectTransfer(meta.colorSpace));
if (!existsSync(videoPath)) return null;
try {
// Retries are separately opt-in from the failure gate. With the
// default zero budget this remains the exact legacy single probe.
const attempted =
extractionPolicy.maxTransientRetries === 0
? { result: await extractMediaMetadata(videoPath), retries: 0 }
: await runVideoExtractionWithRetry(() => extractMediaMetadata(videoPath), {
signal: abortSignal,
maxTransientRetries: extractionPolicy.maxTransientRetries,
onRetry: () => {
hdrProbeTransientRetries += 1;
},
});
const meta = attempted.result;
if (isHdrColorSpace(meta.colorSpace)) {
nativeHdrVideoIds.add(v.id);
videoTransfers.set(v.id, detectTransfer(meta.colorSpace));
}
return null;
} catch (error) {
if (extractionPolicy.failureMode === "off") throw error;
const classified = classifyVideoExtractionError(error);
log?.warn("Video HDR metadata probe failed", {
mode: extractionPolicy.failureMode,
kind: classified.kind,
retryable: classified.retryable,
transientRetries: hdrProbeTransientRetries,
});
return { error, classified };
}
}),
);
throwHdrProbeFailures(probeFailures.filter(isHdrProbeFailure), extractionPolicy.failureMode);
}
// Probe images for HDR color spaces (16-bit PNGs tagged BT.2020 PQ/HLG).
@@ -207,12 +376,17 @@ export async function runExtractVideosStage(
fps: fpsToNumber(job.config.fps),
outputDir: join(compiledDir, "__hyperframes_video_frames"),
format: job.config.videoFrameFormat ?? "auto",
maxTransientRetries: extractionPolicy.maxTransientRetries,
collectProbeFailures: extractionPolicy.failureMode === "enforce",
},
abortSignal,
{ extractCacheDir: cfg.extractCacheDir, extractCacheMaxBytes: cfg.extractCacheMaxBytes },
compiledDir,
);
extractionResult.phaseBreakdown.transientRetries =
(extractionResult.phaseBreakdown.transientRetries ?? 0) + hdrProbeTransientRetries;
assertNotAborted();
failureToEnforce = applyVideoExtractionFailurePolicy(extractionResult, extractionPolicy, log);
materializeExtractedFramesForCompiledDir(extractionResult.extracted, compiledDir, {
materializeSymlinks,
@@ -243,6 +417,7 @@ export async function runExtractVideosStage(
hdrImageSrcPaths,
imageColorSpaces,
videoExtractMs,
failureToEnforce,
};
}
@@ -231,6 +231,7 @@ function summarizeExtractionObservability(
vfrPreflightCount: phaseBreakdown?.vfrPreflightCount,
cacheHits: phaseBreakdown?.cacheHits,
cacheMisses: phaseBreakdown?.cacheMisses,
transientRetries: phaseBreakdown?.transientRetries,
...coverageGauges,
authoredTimedClipCount,
};
@@ -2110,6 +2111,7 @@ async function executeRenderPipeline(input: {
imageTransfers,
hdrImageSrcPaths,
imageColorSpaces,
failureToEnforce,
} = extractResult;
perfStages.videoExtractMs = extractResult.videoExtractMs;
@@ -2145,9 +2147,11 @@ async function executeRenderPipeline(input: {
vfrPreflightMs: extractionObservability.vfrPreflightMs ?? null,
cacheHits: extractionObservability.cacheHits ?? null,
cacheMisses: extractionObservability.cacheMisses ?? null,
transientRetries: extractionObservability.transientRetries ?? null,
minVideoFrameCoverageRatio: extractionObservability.minVideoFrameCoverageRatio ?? null,
authoredTimedClipCount: extractionObservability.authoredTimedClipCount ?? null,
});
if (failureToEnforce) throw failureToEnforce;
// Gate AFTER the checkpoint so a coverage-failed render still emits
// the observability row (partial telemetry is still worth having).
// `assertVideoFrameCoverage` no-ops on an empty report list AND on a