Skip to content

Commit 33ca1de

Browse files
committed
fix(render): aggregate extraction launch failures
1 parent c01e1a5 commit 33ca1de

4 files changed

Lines changed: 123 additions & 34 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, expect, it } from "vitest";
2+
import { classifyFfmpegSpawnError } from "./videoFrameExtractor.js";
3+
4+
describe("classifyFfmpegSpawnError", () => {
5+
it.each(["ENOENT", "EACCES", "ENOEXEC", "UNKNOWN"])(
6+
"keeps deterministic launch failure %s terminal",
7+
(code) => {
8+
expect(classifyFfmpegSpawnError(Object.assign(new Error(code), { code }))).toMatchObject({
9+
retryable: false,
10+
});
11+
},
12+
);
13+
14+
it.each(["EAGAIN", "EMFILE", "ENFILE"])("retries known transient launch failure %s", (code) => {
15+
expect(classifyFfmpegSpawnError(Object.assign(new Error(code), { code }))).toMatchObject({
16+
kind: "ffmpeg_transient",
17+
retryable: true,
18+
});
19+
});
20+
});

packages/engine/src/services/videoFrameExtractor.ts

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -618,21 +618,7 @@ export async function extractVideoFramesRange(
618618
throw new VideoSourceExtractionError("cancelled", false, "Video extraction cancelled");
619619
}
620620
if (processResult.terminationReason === "spawn_error") {
621-
if ((processResult.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
622-
throw new VideoSourceExtractionError(
623-
"ffmpeg_unavailable",
624-
false,
625-
"FFmpeg is unavailable",
626-
"[FFmpeg] ffmpeg not found",
627-
);
628-
}
629-
const diagnostic = processResult.error?.message || processResult.stderr;
630-
throw new VideoSourceExtractionError(
631-
"ffmpeg_transient",
632-
true,
633-
"FFmpeg could not be started",
634-
diagnostic,
635-
);
621+
throw classifyFfmpegSpawnError(processResult.error, processResult.stderr);
636622
}
637623
if (!processResult.success) {
638624
// With the SDR-to-HDR remap folded into this pass, a filter failure
@@ -697,6 +683,33 @@ export async function extractVideoFramesRange(
697683
};
698684
}
699685

686+
const TRANSIENT_FFMPEG_SPAWN_CODES = new Set(["EAGAIN", "EMFILE", "ENFILE"]);
687+
688+
export function classifyFfmpegSpawnError(error: unknown, stderr = ""): VideoSourceExtractionError {
689+
const code =
690+
typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
691+
? error.code
692+
: "";
693+
if (code === "ENOENT") {
694+
return new VideoSourceExtractionError(
695+
"ffmpeg_unavailable",
696+
false,
697+
"FFmpeg is unavailable",
698+
"[FFmpeg] ffmpeg not found",
699+
);
700+
}
701+
const diagnostic = error instanceof Error ? error.message : stderr;
702+
const retryable = TRANSIENT_FFMPEG_SPAWN_CODES.has(code);
703+
return new VideoSourceExtractionError(
704+
retryable ? "ffmpeg_transient" : "ffmpeg_failed",
705+
retryable,
706+
retryable
707+
? "FFmpeg could not be started due to transient resource pressure"
708+
: "FFmpeg could not be started",
709+
diagnostic,
710+
);
711+
}
712+
700713
/**
701714
* Resolve the used-segment duration for a video, falling back to the source's
702715
* natural duration when the caller hasn't specified bounds (end=Infinity) or

packages/producer/src/services/render/stages/extractVideosStage.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
import {
99
appendAutoDetectedVideoAudio,
1010
assertVideoExtractionSucceeded,
11+
buildHdrProbeStageError,
1112
resolveVideoExtractionPolicy,
1213
shouldCopyExtractedFrames,
1314
VideoExtractionStageError,
@@ -244,3 +245,25 @@ describe("assertVideoExtractionSucceeded", () => {
244245
);
245246
});
246247
});
248+
249+
describe("buildHdrProbeStageError", () => {
250+
it.each([
251+
[
252+
{ kind: "download_transient" as const, retryable: true },
253+
{ kind: "source_missing" as const, retryable: false },
254+
],
255+
[
256+
{ kind: "source_missing" as const, retryable: false },
257+
{ kind: "download_transient" as const, retryable: true },
258+
],
259+
])("fails closed for mixed probe outcomes regardless of completion order", (...failures) => {
260+
expect(buildHdrProbeStageError(failures)).toMatchObject({
261+
code: "VIDEO_SOURCE_UNRENDERABLE",
262+
retryable: false,
263+
failures: [
264+
{ kind: "download_transient", count: 1 },
265+
{ kind: "source_missing", count: 1 },
266+
],
267+
});
268+
});
269+
});

packages/producer/src/services/render/stages/extractVideosStage.ts

Lines changed: 52 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,45 @@ function buildVideoExtractionStageError(
193193
);
194194
}
195195

196+
export function buildHdrProbeStageError(
197+
failures: readonly Pick<ReturnType<typeof classifyVideoExtractionError>, "kind" | "retryable">[],
198+
): VideoExtractionStageError {
199+
const counts = new Map<VideoExtractionFailureKind, number>();
200+
for (const failure of failures) {
201+
counts.set(failure.kind, (counts.get(failure.kind) ?? 0) + 1);
202+
}
203+
const summary = Array.from(counts, ([kind, count]) => ({ kind, count })).sort((a, b) =>
204+
a.kind.localeCompare(b.kind),
205+
);
206+
const retryable = failures.length > 0 && failures.every((failure) => failure.retryable);
207+
return new VideoExtractionStageError(
208+
retryable ? "VIDEO_EXTRACTION_FAILED" : "VIDEO_SOURCE_UNRENDERABLE",
209+
retryable,
210+
summary,
211+
);
212+
}
213+
214+
type HdrProbeFailure = {
215+
error: unknown;
216+
classified: ReturnType<typeof classifyVideoExtractionError>;
217+
};
218+
219+
function isHdrProbeFailure(failure: HdrProbeFailure | null): failure is HdrProbeFailure {
220+
return failure !== null;
221+
}
222+
223+
function throwHdrProbeFailures(
224+
failures: readonly HdrProbeFailure[],
225+
mode: VideoExtractionFailureMode,
226+
): void {
227+
if (failures.length === 0) return;
228+
if (mode === "enforce") {
229+
throw buildHdrProbeStageError(failures.map((failure) => failure.classified));
230+
}
231+
const firstFailure = failures[0];
232+
if (firstFailure) throw firstFailure.error;
233+
}
234+
196235
function applyVideoExtractionFailurePolicy(
197236
result: ExtractionResult,
198237
policy: VideoExtractionPolicy,
@@ -242,7 +281,7 @@ export async function runExtractVideosStage(
242281
let hdrProbeTransientRetries = 0;
243282
if (job.config.hdrMode !== "force-sdr" && composition.videos.length > 0) {
244283
log?.info("Probing video color spaces...", { videoCount: composition.videos.length });
245-
await Promise.all(
284+
const probeFailures = await Promise.all(
246285
composition.videos.map(async (v) => {
247286
// Use the shared resolver so a `<video src="../assets/foo">` in a
248287
// sub-composition resolves the same way the browser would (see
@@ -252,7 +291,7 @@ export async function runExtractVideosStage(
252291
const videoPath = isAbsolute(v.src)
253292
? v.src
254293
: resolveProjectRelativeSrc(v.src, projectDir, compiledDir);
255-
if (!existsSync(videoPath)) return;
294+
if (!existsSync(videoPath)) return null;
256295
try {
257296
// Retries are separately opt-in from the failure gate. With the
258297
// default zero budget this remains the exact legacy single probe.
@@ -271,27 +310,21 @@ export async function runExtractVideosStage(
271310
nativeHdrVideoIds.add(v.id);
272311
videoTransfers.set(v.id, detectTransfer(meta.colorSpace));
273312
}
313+
return null;
274314
} catch (error) {
275-
if (extractionPolicy.failureMode !== "off") {
276-
const classified = classifyVideoExtractionError(error);
277-
log?.warn("Video HDR metadata probe failed", {
278-
mode: extractionPolicy.failureMode,
279-
kind: classified.kind,
280-
retryable: classified.retryable,
281-
transientRetries: hdrProbeTransientRetries,
282-
});
283-
if (extractionPolicy.failureMode === "enforce") {
284-
throw new VideoExtractionStageError(
285-
classified.retryable ? "VIDEO_EXTRACTION_FAILED" : "VIDEO_SOURCE_UNRENDERABLE",
286-
classified.retryable,
287-
[{ kind: classified.kind, count: 1 }],
288-
);
289-
}
290-
}
291-
throw error;
315+
if (extractionPolicy.failureMode === "off") throw error;
316+
const classified = classifyVideoExtractionError(error);
317+
log?.warn("Video HDR metadata probe failed", {
318+
mode: extractionPolicy.failureMode,
319+
kind: classified.kind,
320+
retryable: classified.retryable,
321+
transientRetries: hdrProbeTransientRetries,
322+
});
323+
return { error, classified };
292324
}
293325
}),
294326
);
327+
throwHdrProbeFailures(probeFailures.filter(isHdrProbeFailure), extractionPolicy.failureMode);
295328
}
296329

297330
// Probe images for HDR color spaces (16-bit PNGs tagged BT.2020 PQ/HLG).

0 commit comments

Comments
 (0)