mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 20:18:35 +00:00
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:
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user