mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(render): aggregate extraction launch failures
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,6 +193,45 @@ function buildVideoExtractionStageError(
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -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 `<video src="../assets/foo">` in a
|
||||
// sub-composition resolves the same way the browser would (see
|
||||
@@ -252,7 +291,7 @@ export async function runExtractVideosStage(
|
||||
const videoPath = isAbsolute(v.src)
|
||||
? v.src
|
||||
: resolveProjectRelativeSrc(v.src, projectDir, compiledDir);
|
||||
if (!existsSync(videoPath)) return;
|
||||
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.
|
||||
@@ -271,27 +310,21 @@ export async function runExtractVideosStage(
|
||||
nativeHdrVideoIds.add(v.id);
|
||||
videoTransfers.set(v.id, detectTransfer(meta.colorSpace));
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (extractionPolicy.failureMode !== "off") {
|
||||
const classified = classifyVideoExtractionError(error);
|
||||
log?.warn("Video HDR metadata probe failed", {
|
||||
mode: extractionPolicy.failureMode,
|
||||
kind: classified.kind,
|
||||
retryable: classified.retryable,
|
||||
transientRetries: hdrProbeTransientRetries,
|
||||
});
|
||||
if (extractionPolicy.failureMode === "enforce") {
|
||||
throw new VideoExtractionStageError(
|
||||
classified.retryable ? "VIDEO_EXTRACTION_FAILED" : "VIDEO_SOURCE_UNRENDERABLE",
|
||||
classified.retryable,
|
||||
[{ kind: classified.kind, count: 1 }],
|
||||
);
|
||||
}
|
||||
}
|
||||
throw 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).
|
||||
|
||||
Reference in New Issue
Block a user