From 33ca1de0631be66bfa5c591a231256c69667226e Mon Sep 17 00:00:00 2001 From: James Date: Sun, 26 Jul 2026 18:33:50 +0000 Subject: [PATCH] fix(render): aggregate extraction launch failures --- ...FrameExtractor.errorClassification.test.ts | 20 ++++++ .../src/services/videoFrameExtractor.ts | 43 +++++++---- .../render/stages/extractVideosStage.test.ts | 23 ++++++ .../render/stages/extractVideosStage.ts | 71 ++++++++++++++----- 4 files changed, 123 insertions(+), 34 deletions(-) create mode 100644 packages/engine/src/services/videoFrameExtractor.errorClassification.test.ts diff --git a/packages/engine/src/services/videoFrameExtractor.errorClassification.test.ts b/packages/engine/src/services/videoFrameExtractor.errorClassification.test.ts new file mode 100644 index 000000000..6afa2f8fa --- /dev/null +++ b/packages/engine/src/services/videoFrameExtractor.errorClassification.test.ts @@ -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, + }); + }); +}); diff --git a/packages/engine/src/services/videoFrameExtractor.ts b/packages/engine/src/services/videoFrameExtractor.ts index fe96e0d53..a3d7df869 100644 --- a/packages/engine/src/services/videoFrameExtractor.ts +++ b/packages/engine/src/services/videoFrameExtractor.ts @@ -618,21 +618,7 @@ export async function extractVideoFramesRange( throw new VideoSourceExtractionError("cancelled", false, "Video extraction cancelled"); } if (processResult.terminationReason === "spawn_error") { - if ((processResult.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") { - throw new VideoSourceExtractionError( - "ffmpeg_unavailable", - false, - "FFmpeg is unavailable", - "[FFmpeg] ffmpeg not found", - ); - } - const diagnostic = processResult.error?.message || processResult.stderr; - throw new VideoSourceExtractionError( - "ffmpeg_transient", - true, - "FFmpeg could not be started", - diagnostic, - ); + throw classifyFfmpegSpawnError(processResult.error, processResult.stderr); } if (!processResult.success) { // With the SDR-to-HDR remap folded into this pass, a filter failure @@ -697,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 diff --git a/packages/producer/src/services/render/stages/extractVideosStage.test.ts b/packages/producer/src/services/render/stages/extractVideosStage.test.ts index 10c36426d..c55fef286 100644 --- a/packages/producer/src/services/render/stages/extractVideosStage.test.ts +++ b/packages/producer/src/services/render/stages/extractVideosStage.test.ts @@ -8,6 +8,7 @@ import type { import { appendAutoDetectedVideoAudio, assertVideoExtractionSucceeded, + buildHdrProbeStageError, resolveVideoExtractionPolicy, shouldCopyExtractedFrames, VideoExtractionStageError, @@ -244,3 +245,25 @@ describe("assertVideoExtractionSucceeded", () => { ); }); }); + +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 }, + ], + }); + }); +}); diff --git a/packages/producer/src/services/render/stages/extractVideosStage.ts b/packages/producer/src/services/render/stages/extractVideosStage.ts index df1956db6..5b0248be6 100644 --- a/packages/producer/src/services/render/stages/extractVideosStage.ts +++ b/packages/producer/src/services/render/stages/extractVideosStage.ts @@ -193,6 +193,45 @@ function buildVideoExtractionStageError( ); } +export function buildHdrProbeStageError( + failures: readonly Pick, "kind" | "retryable">[], +): VideoExtractionStageError { + const counts = new Map(); + 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; +}; + +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, @@ -242,7 +281,7 @@ export async function runExtractVideosStage( 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 `