mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 16:42:27 +00:00
fix(producer): transport safe extraction failure metadata (#3592)
* fix(producer): transport safe extraction failure metadata * refactor(producer): generalize public error metadata * test(producer): use vendor-neutral media hosts
This commit is contained in:
@@ -197,6 +197,7 @@ export {
|
|||||||
classifyVideoExtractionError,
|
classifyVideoExtractionError,
|
||||||
isVideoSourceExtractionError,
|
isVideoSourceExtractionError,
|
||||||
runVideoExtractionWithRetry,
|
runVideoExtractionWithRetry,
|
||||||
|
safeVideoExtractionSourceIdentity,
|
||||||
VideoSourceExtractionError,
|
VideoSourceExtractionError,
|
||||||
type VideoElement,
|
type VideoElement,
|
||||||
type ImageElement,
|
type ImageElement,
|
||||||
@@ -207,6 +208,10 @@ export {
|
|||||||
type TimelineExtractionWindow,
|
type TimelineExtractionWindow,
|
||||||
type VideoExtractionFailure,
|
type VideoExtractionFailure,
|
||||||
type VideoExtractionFailureKind,
|
type VideoExtractionFailureKind,
|
||||||
|
type VideoExtractionFailureGroupDetails,
|
||||||
|
type VideoExtractionFailureRetry,
|
||||||
|
type VideoExtractionFailureStatusClass,
|
||||||
|
type SafeVideoExtractionSourceIdentity,
|
||||||
type VideoFrameFormat,
|
type VideoFrameFormat,
|
||||||
VIDEO_FRAME_FORMATS,
|
VIDEO_FRAME_FORMATS,
|
||||||
isVideoFrameFormat,
|
isVideoFrameFormat,
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
import { UrlDownloadError } from "../utils/urlDownloader.js";
|
import { tmpdir } from "node:os";
|
||||||
import { classifyFfmpegSpawnError, classifyVideoExtractionError } from "./videoFrameExtractor.js";
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { safeDownloadUrlIdentity, UrlDownloadError } from "../utils/urlDownloader.js";
|
||||||
|
import {
|
||||||
|
classifyFfmpegSpawnError,
|
||||||
|
classifyVideoExtractionError,
|
||||||
|
extractAllVideoFrames,
|
||||||
|
} from "./videoFrameExtractor.js";
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function extractionOutputDir(): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "hf-extraction-failure-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
describe("classifyFfmpegSpawnError", () => {
|
describe("classifyFfmpegSpawnError", () => {
|
||||||
it.each(["ENOENT", "EACCES", "ENOEXEC", "UNKNOWN"])(
|
it.each(["ENOENT", "EACCES", "ENOEXEC", "UNKNOWN"])(
|
||||||
@@ -37,3 +57,74 @@ describe("classifyVideoExtractionError download integrity", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("extractAllVideoFrames download failure metadata", () => {
|
||||||
|
it("threads a query-free fingerprint, allowlisted host, and zero used retries", async () => {
|
||||||
|
const source =
|
||||||
|
"https://media.customer-cdn.example/private/clip.mp4?X-Amz-Signature=super-secret#fragment";
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue(new Response("<!doctype html><html><body>denied</body></html>")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await extractAllVideoFrames(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "private-video-id",
|
||||||
|
src: source,
|
||||||
|
start: 0,
|
||||||
|
end: 1,
|
||||||
|
mediaStart: 0,
|
||||||
|
playbackRate: 1,
|
||||||
|
loop: false,
|
||||||
|
hasAudio: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
extractionOutputDir(),
|
||||||
|
{ fps: 30, outputDir: extractionOutputDir() },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
expect(result.errors[0]?.group).toEqual({
|
||||||
|
sourceFingerprint: `sha256:${safeDownloadUrlIdentity(source).urlFingerprint}`,
|
||||||
|
host: "media.customer-cdn.example",
|
||||||
|
statusClass: "other",
|
||||||
|
retry: { phase: "download", used: 0, budget: 1 },
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(result.errors[0]?.group)).not.toContain("super-secret");
|
||||||
|
expect(JSON.stringify(result.errors[0]?.group)).not.toContain("private-video-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses an exhausted HTTP retry to status class and normalized host", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 503 }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const result = await extractAllVideoFrames(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "v1",
|
||||||
|
src: "https://customer-cdn.example/clip.mp4?token=secret",
|
||||||
|
start: 0,
|
||||||
|
end: 1,
|
||||||
|
mediaStart: 0,
|
||||||
|
playbackRate: 1,
|
||||||
|
loop: false,
|
||||||
|
hasAudio: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
extractionOutputDir(),
|
||||||
|
{ fps: 30, outputDir: extractionOutputDir() },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result.errors[0]).toMatchObject({
|
||||||
|
kind: "download_transient",
|
||||||
|
retryable: true,
|
||||||
|
group: {
|
||||||
|
host: "customer-cdn.example",
|
||||||
|
statusClass: "http_5xx",
|
||||||
|
retry: { phase: "download", used: 1, budget: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
downloadToTemp,
|
downloadToTemp,
|
||||||
isHttpUrl,
|
isHttpUrl,
|
||||||
|
safeDownloadUrlIdentity,
|
||||||
UrlDownloadError,
|
UrlDownloadError,
|
||||||
writeUrlDownloadTelemetry,
|
writeUrlDownloadTelemetry,
|
||||||
} from "../utils/urlDownloader.js";
|
} from "../utils/urlDownloader.js";
|
||||||
@@ -280,6 +281,8 @@ export interface VideoExtractionFailure {
|
|||||||
kind?: VideoExtractionFailureKind;
|
kind?: VideoExtractionFailureKind;
|
||||||
/** Always populated by this engine version; absent legacy values fail closed. */
|
/** Always populated by this engine version; absent legacy values fail closed. */
|
||||||
retryable?: boolean;
|
retryable?: boolean;
|
||||||
|
/** Bounded, path-free grouping data safe for producer-owned error metadata. */
|
||||||
|
group?: VideoExtractionFailureGroupDetails;
|
||||||
/**
|
/**
|
||||||
* Operator diagnostic retained inside the engine result. Producer-facing
|
* Operator diagnostic retained inside the engine result. Producer-facing
|
||||||
* errors must summarize `kind`/counts and must not forward this field: it
|
* errors must summarize `kind`/counts and must not forward this field: it
|
||||||
@@ -288,6 +291,71 @@ export interface VideoExtractionFailure {
|
|||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type VideoExtractionFailureStatusClass =
|
||||||
|
| "http_4xx"
|
||||||
|
| "http_5xx"
|
||||||
|
| "timeout"
|
||||||
|
| "network"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
export interface VideoExtractionFailureRetry {
|
||||||
|
phase: "download";
|
||||||
|
used: 0 | 1;
|
||||||
|
budget: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoExtractionFailureGroupDetails {
|
||||||
|
sourceFingerprint?: string;
|
||||||
|
host?: string;
|
||||||
|
statusClass?: VideoExtractionFailureStatusClass;
|
||||||
|
retry?: VideoExtractionFailureRetry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SafeVideoExtractionSourceIdentity {
|
||||||
|
sourceFingerprint: string;
|
||||||
|
host: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query/fragment-free remote identity safe for extraction logs and wire metadata. */
|
||||||
|
export function safeVideoExtractionSourceIdentity(
|
||||||
|
source: string,
|
||||||
|
): SafeVideoExtractionSourceIdentity | null {
|
||||||
|
if (!isHttpUrl(source)) return null;
|
||||||
|
const identity = safeDownloadUrlIdentity(source);
|
||||||
|
return {
|
||||||
|
sourceFingerprint: `sha256:${identity.urlFingerprint}`,
|
||||||
|
host: identity.host ?? "other",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadStatusClass(error: UrlDownloadError): VideoExtractionFailureStatusClass {
|
||||||
|
const status = error.status ?? error.telemetry?.status;
|
||||||
|
if (typeof status === "number" && status >= 400 && status < 500) return "http_4xx";
|
||||||
|
if (typeof status === "number" && status >= 500 && status < 600) return "http_5xx";
|
||||||
|
if (error.kind === "timeout") return "timeout";
|
||||||
|
if (error.kind === "network") return "network";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFailureGroup(
|
||||||
|
source: string,
|
||||||
|
error: UrlDownloadError,
|
||||||
|
): VideoExtractionFailureGroupDetails {
|
||||||
|
const sourceIdentity = safeVideoExtractionSourceIdentity(source);
|
||||||
|
const failureHost = error.telemetry?.finalHost ?? error.telemetry?.initialHost;
|
||||||
|
const attempt = error.telemetry?.attempt;
|
||||||
|
return {
|
||||||
|
...(sourceIdentity ? { sourceFingerprint: sourceIdentity.sourceFingerprint } : {}),
|
||||||
|
...((failureHost ?? sourceIdentity?.host) ? { host: failureHost ?? sourceIdentity?.host } : {}),
|
||||||
|
statusClass: downloadStatusClass(error),
|
||||||
|
retry: {
|
||||||
|
phase: "download",
|
||||||
|
used: typeof attempt === "number" && attempt >= 2 ? 1 : 0,
|
||||||
|
budget: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export class VideoSourceExtractionError extends Error {
|
export class VideoSourceExtractionError extends Error {
|
||||||
readonly hyperframesVideoSourceExtractionError = true as const;
|
readonly hyperframesVideoSourceExtractionError = true as const;
|
||||||
|
|
||||||
@@ -1546,6 +1614,7 @@ export async function extractAllVideoFrames(
|
|||||||
videoId: video.id,
|
videoId: video.id,
|
||||||
kind: classified.kind,
|
kind: classified.kind,
|
||||||
retryable: classified.retryable,
|
retryable: classified.retryable,
|
||||||
|
...(err instanceof UrlDownloadError ? { group: downloadFailureGroup(video.src, err) } : {}),
|
||||||
error: classified.diagnostic,
|
error: classified.diagnostic,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -447,6 +447,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => {
|
|||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
kind: "length_mismatch",
|
kind: "length_mismatch",
|
||||||
retryable: true,
|
retryable: true,
|
||||||
|
telemetry: expect.objectContaining({ attempt: 2 }),
|
||||||
} satisfies Partial<UrlDownloadError>);
|
} satisfies Partial<UrlDownloadError>);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
expect(readdirSync(dir).filter((name) => name.startsWith("download_"))).toEqual([]);
|
expect(readdirSync(dir).filter((name) => name.startsWith("download_"))).toEqual([]);
|
||||||
@@ -802,6 +803,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => {
|
|||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
kind: "invalid_payload",
|
kind: "invalid_payload",
|
||||||
retryable: false,
|
retryable: false,
|
||||||
|
telemetry: expect.objectContaining({ attempt: 1 }),
|
||||||
} satisfies Partial<UrlDownloadError>);
|
} satisfies Partial<UrlDownloadError>);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
expect(temporaryDownloadEntries(dir)).toEqual([]);
|
expect(temporaryDownloadEntries(dir)).toEqual([]);
|
||||||
|
|||||||
@@ -1113,7 +1113,22 @@ async function downloadWithRetry(
|
|||||||
return await runDownloadAttempt(url, localPath, timeoutMs, attempt + 1, options, signal);
|
return await runDownloadAttempt(url, localPath, timeoutMs, attempt + 1, options, signal);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const classified = classifyDownloadFailure(error);
|
const classified = classifyDownloadFailure(error);
|
||||||
if (!classified.locallyRetryable || attempt >= maxTransientRetries) throw classified;
|
if (!classified.locallyRetryable || attempt >= maxTransientRetries) {
|
||||||
|
const identity = safeDownloadUrlIdentity(url);
|
||||||
|
throw new UrlDownloadError(
|
||||||
|
classified.kind,
|
||||||
|
classified.retryable,
|
||||||
|
classified.message,
|
||||||
|
classified.status,
|
||||||
|
{
|
||||||
|
...classified.telemetry,
|
||||||
|
urlFingerprint: identity.urlFingerprint,
|
||||||
|
initialHost: identity.host,
|
||||||
|
attempt: attempt + 1,
|
||||||
|
},
|
||||||
|
classified.locallyRetryable,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (classified.retryable) onTransientRetry?.(classified);
|
if (classified.retryable) onTransientRetry?.(classified);
|
||||||
const identity = safeDownloadUrlIdentity(url);
|
const identity = safeDownloadUrlIdentity(url);
|
||||||
emitDownloadTelemetry(options, {
|
emitDownloadTelemetry(options, {
|
||||||
|
|||||||
@@ -30,10 +30,6 @@ describe("extractSafeRenderErrorCode", () => {
|
|||||||
const error = new AssetMediaTypeMismatchError([
|
const error = new AssetMediaTypeMismatchError([
|
||||||
{ expected: "video", detected: "image", elementFingerprint: "0123456789abcdef" },
|
{ expected: "video", detected: "image", elementFingerprint: "0123456789abcdef" },
|
||||||
]);
|
]);
|
||||||
expect(error.code).toBe("ASSET_MEDIA_TYPE_MISMATCH");
|
|
||||||
expect(error.owner).toBe("user");
|
|
||||||
expect(error.retryable).toBe(false);
|
|
||||||
expect(extractSafeRenderErrorCode(error)).toBe("ASSET_MEDIA_TYPE_MISMATCH");
|
|
||||||
expect(extractSafeRenderErrorMetadata(error)).toEqual({
|
expect(extractSafeRenderErrorMetadata(error)).toEqual({
|
||||||
errorCode: "ASSET_MEDIA_TYPE_MISMATCH",
|
errorCode: "ASSET_MEDIA_TYPE_MISMATCH",
|
||||||
errorOwner: "user",
|
errorOwner: "user",
|
||||||
@@ -51,6 +47,51 @@ describe("extractSafeRenderErrorCode", () => {
|
|||||||
expect(error.message).not.toContain("private ffmpeg stderr");
|
expect(error.message).not.toContain("private ffmpeg stderr");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("transports producer-authored public metadata without interpreting its schema", () => {
|
||||||
|
const error = new VideoExtractionStageError(
|
||||||
|
"VIDEO_EXTRACTION_FAILED",
|
||||||
|
true,
|
||||||
|
[{ kind: "download_transient", count: 1 }],
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
kindCounts: [{ kind: "download_transient", affectedElementCount: 1 }],
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
kind: "download_transient",
|
||||||
|
affectedElementCount: 1,
|
||||||
|
sourceFingerprint: `sha256:${"0".repeat(64)}`,
|
||||||
|
host: "media.customer-cdn.example",
|
||||||
|
statusClass: "http_5xx",
|
||||||
|
retry: { phase: "download", used: 1, budget: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
omittedGroupCount: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(extractSafeRenderErrorMetadata(error)).toEqual({
|
||||||
|
errorCode: "VIDEO_EXTRACTION_FAILED",
|
||||||
|
errorOwner: undefined,
|
||||||
|
retryable: true,
|
||||||
|
errorMetadata: error.publicMetadata,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not transport arbitrary private fields or non-object public metadata", () => {
|
||||||
|
expect(
|
||||||
|
extractSafeRenderErrorMetadata({
|
||||||
|
code: "VIDEO_EXTRACTION_FAILED",
|
||||||
|
retryable: true,
|
||||||
|
publicMetadata: "https://media.example/private.mp4?signature=secret",
|
||||||
|
localPath: "/tmp/private.mp4",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
errorCode: "VIDEO_EXTRACTION_FAILED",
|
||||||
|
errorOwner: undefined,
|
||||||
|
retryable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("does not forward arbitrary codes or parse message text", () => {
|
it("does not forward arbitrary codes or parse message text", () => {
|
||||||
expect(extractSafeRenderErrorCode({ code: "INTERNAL_ERROR" })).toBeUndefined();
|
expect(extractSafeRenderErrorCode({ code: "INTERNAL_ERROR" })).toBeUndefined();
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const safeExtractionFailure = vi.hoisted(() => ({
|
||||||
|
schemaVersion: 1,
|
||||||
|
kindCounts: [{ kind: "download_transient", affectedElementCount: 1 }],
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
kind: "download_transient",
|
||||||
|
affectedElementCount: 1,
|
||||||
|
sourceFingerprint: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||||
|
host: "media.customer-cdn.example",
|
||||||
|
statusClass: "http_5xx",
|
||||||
|
retry: { phase: "download", used: 1, budget: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
omittedGroupCount: 0,
|
||||||
|
}));
|
||||||
|
vi.mock("./services/renderOrchestrator.js", () => {
|
||||||
|
class RenderCancelledError extends Error {}
|
||||||
|
class MockVideoExtractionStageError extends Error {
|
||||||
|
readonly code = "VIDEO_EXTRACTION_FAILED";
|
||||||
|
readonly retryable = true;
|
||||||
|
readonly publicMetadata = { extractionFailure: safeExtractionFailure };
|
||||||
|
readonly source =
|
||||||
|
"https://media.customer-cdn.example/private/clip.mp4?X-Amz-Signature=must-not-reach-wire";
|
||||||
|
readonly videoId = "private-video-id";
|
||||||
|
readonly statusText = "upstream private status text";
|
||||||
|
readonly localPath = "/tmp/private-render/clip.mp4";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
RenderCancelledError,
|
||||||
|
createRenderJob: (config: Record<string, unknown>) => ({
|
||||||
|
config,
|
||||||
|
progress: 0,
|
||||||
|
currentStage: "video_extract",
|
||||||
|
framesRendered: 0,
|
||||||
|
totalFrames: 0,
|
||||||
|
warnings: [],
|
||||||
|
}),
|
||||||
|
executeRenderJob: async () => {
|
||||||
|
throw new MockVideoExtractionStageError("Video extraction failed");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { createRenderHandlers } from "./server.js";
|
||||||
|
|
||||||
|
function createApp(): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
const handlers = createRenderHandlers({
|
||||||
|
getRequestId: () => "extraction-failure-test",
|
||||||
|
maxConcurrentRenders: 1,
|
||||||
|
});
|
||||||
|
app.post("/v1/render", handlers.render);
|
||||||
|
app.post("/v1/render-stream", handlers.renderStream);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(path: string): Promise<Response> {
|
||||||
|
return createApp().request(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ html: "<html><body></body></html>" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectPrivateDiagnosticsAbsent(body: string): void {
|
||||||
|
expect(body).not.toContain("must-not-reach-wire");
|
||||||
|
expect(body).not.toContain("/private/");
|
||||||
|
expect(body).not.toContain("private-video-id");
|
||||||
|
expect(body).not.toContain("private status text");
|
||||||
|
expect(body).not.toContain("/tmp/private-render");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("server extraction failure metadata", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
safeExtractionFailure.groups[0]!.host = "media.customer-cdn.example";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits the bounded top-level contract in blocking JSON", async () => {
|
||||||
|
const response = await request("/v1/render");
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(JSON.parse(body)).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
errorCode: "VIDEO_EXTRACTION_FAILED",
|
||||||
|
retryable: true,
|
||||||
|
errorMetadata: { extractionFailure: safeExtractionFailure },
|
||||||
|
});
|
||||||
|
expectPrivateDiagnosticsAbsent(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits the same bounded top-level contract in SSE", async () => {
|
||||||
|
const response = await request("/v1/render-stream");
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body).toContain('"errorCode":"VIDEO_EXTRACTION_FAILED"');
|
||||||
|
expect(body).toContain('"retryable":true');
|
||||||
|
expect(body).toContain(
|
||||||
|
`"errorMetadata":{"extractionFailure":${JSON.stringify(safeExtractionFailure)}}`,
|
||||||
|
);
|
||||||
|
expectPrivateDiagnosticsAbsent(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not impose a caller-specific schema on public metadata", async () => {
|
||||||
|
Object.assign(safeExtractionFailure, { callerDefinedField: "v2" });
|
||||||
|
const body = await (await request("/v1/render")).text();
|
||||||
|
expect(body).toContain('"callerDefinedField":"v2"');
|
||||||
|
delete (safeExtractionFailure as Record<string, unknown>).callerDefinedField;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -142,9 +142,14 @@ export interface SafeRenderErrorMetadata {
|
|||||||
errorCode: string;
|
errorCode: string;
|
||||||
errorOwner?: "system" | "user";
|
errorOwner?: "system" | "user";
|
||||||
retryable?: boolean;
|
retryable?: boolean;
|
||||||
|
/** Public, producer-authored data whose schema and policy belong to callers. */
|
||||||
|
errorMetadata?: Readonly<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Additive bounded metadata for typed producer failures. */
|
/**
|
||||||
|
* Additive public metadata for typed producer failures. The producer server
|
||||||
|
* only transports it; callers own schema validation and policy decisions.
|
||||||
|
*/
|
||||||
export function extractSafeRenderErrorMetadata(
|
export function extractSafeRenderErrorMetadata(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
): SafeRenderErrorMetadata | undefined {
|
): SafeRenderErrorMetadata | undefined {
|
||||||
@@ -152,10 +157,12 @@ export function extractSafeRenderErrorMetadata(
|
|||||||
if (!errorCode || typeof error !== "object" || error === null) return undefined;
|
if (!errorCode || typeof error !== "object" || error === null) return undefined;
|
||||||
const owner = "owner" in error ? error.owner : undefined;
|
const owner = "owner" in error ? error.owner : undefined;
|
||||||
const retryable = "retryable" in error ? error.retryable : undefined;
|
const retryable = "retryable" in error ? error.retryable : undefined;
|
||||||
|
const publicMetadata = "publicMetadata" in error ? error.publicMetadata : undefined;
|
||||||
return {
|
return {
|
||||||
errorCode,
|
errorCode,
|
||||||
errorOwner: owner === "user" || owner === "system" ? owner : undefined,
|
errorOwner: owner === "user" || owner === "system" ? owner : undefined,
|
||||||
retryable: typeof retryable === "boolean" ? retryable : undefined,
|
retryable: typeof retryable === "boolean" ? retryable : undefined,
|
||||||
|
...(isPlainObject(publicMetadata) ? { errorMetadata: publicMetadata } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,6 +632,7 @@ async function writeRenderStreamFailure(input: {
|
|||||||
errorCode: safeError?.errorCode,
|
errorCode: safeError?.errorCode,
|
||||||
errorOwner: safeError?.errorOwner,
|
errorOwner: safeError?.errorOwner,
|
||||||
retryable: safeError?.retryable,
|
retryable: safeError?.retryable,
|
||||||
|
errorMetadata: safeError?.errorMetadata,
|
||||||
stage: job.currentStage,
|
stage: job.currentStage,
|
||||||
elapsedMs,
|
elapsedMs,
|
||||||
errorDetails: job.errorDetails ?? null,
|
errorDetails: job.errorDetails ?? null,
|
||||||
@@ -788,6 +796,7 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle
|
|||||||
errorCode: safeError?.errorCode,
|
errorCode: safeError?.errorCode,
|
||||||
errorOwner: safeError?.errorOwner,
|
errorOwner: safeError?.errorOwner,
|
||||||
retryable: safeError?.retryable,
|
retryable: safeError?.retryable,
|
||||||
|
errorMetadata: safeError?.errorMetadata,
|
||||||
stage: job.currentStage,
|
stage: job.currentStage,
|
||||||
durationMs,
|
durationMs,
|
||||||
errorDetails: job.errorDetails ?? null,
|
errorDetails: job.errorDetails ?? null,
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type {
|
||||||
|
VideoExtractionFailureKind,
|
||||||
|
VideoExtractionFailureRetry,
|
||||||
|
VideoExtractionFailureStatusClass,
|
||||||
|
} from "@hyperframes/engine";
|
||||||
|
|
||||||
|
export interface ExtractionFailureKindCount {
|
||||||
|
kind: VideoExtractionFailureKind;
|
||||||
|
affectedElementCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractionFailureGroup {
|
||||||
|
kind: VideoExtractionFailureKind;
|
||||||
|
affectedElementCount: number;
|
||||||
|
sourceFingerprint?: string;
|
||||||
|
host?: string;
|
||||||
|
statusClass?: VideoExtractionFailureStatusClass;
|
||||||
|
retry?: VideoExtractionFailureRetry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractionFailureMetadataV1 {
|
||||||
|
schemaVersion: 1;
|
||||||
|
kindCounts: ExtractionFailureKindCount[];
|
||||||
|
groups: ExtractionFailureGroup[];
|
||||||
|
omittedGroupCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtractionFailureGroupSortTuple = readonly [
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
];
|
||||||
|
|
||||||
|
function stringOrEmpty(value: string | undefined): string {
|
||||||
|
return value === undefined ? "" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retrySortTuple(
|
||||||
|
retry: VideoExtractionFailureRetry | undefined,
|
||||||
|
): readonly [string, number, number] {
|
||||||
|
return retry === undefined ? ["", -1, -1] : [retry.phase, retry.used, retry.budget];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractionFailureGroupSortTuple(
|
||||||
|
group: ExtractionFailureGroup,
|
||||||
|
): ExtractionFailureGroupSortTuple {
|
||||||
|
const retry = retrySortTuple(group.retry);
|
||||||
|
return [
|
||||||
|
group.kind,
|
||||||
|
stringOrEmpty(group.sourceFingerprint),
|
||||||
|
stringOrEmpty(group.host),
|
||||||
|
stringOrEmpty(group.statusClass),
|
||||||
|
retry[0],
|
||||||
|
retry[1],
|
||||||
|
retry[2],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareExtractionFailureGroups(
|
||||||
|
left: ExtractionFailureGroup,
|
||||||
|
right: ExtractionFailureGroup,
|
||||||
|
): number {
|
||||||
|
const leftTuple = extractionFailureGroupSortTuple(left);
|
||||||
|
const rightTuple = extractionFailureGroupSortTuple(right);
|
||||||
|
for (let index = 0; index < leftTuple.length; index += 1) {
|
||||||
|
const leftValue = leftTuple[index]!;
|
||||||
|
const rightValue = rightTuple[index]!;
|
||||||
|
if (leftValue < rightValue) return -1;
|
||||||
|
if (leftValue > rightValue) return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractionFailureGroupIdentityKey(group: ExtractionFailureGroup): string {
|
||||||
|
return JSON.stringify(extractionFailureGroupSortTuple(group));
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
assertVideoExtractionSucceeded,
|
assertVideoExtractionSucceeded,
|
||||||
buildHdrProbeStageError,
|
buildHdrProbeStageError,
|
||||||
resolveVideoExtractionPolicy,
|
resolveVideoExtractionPolicy,
|
||||||
|
safeVideoExtractionSourceLogMetadata,
|
||||||
shouldCopyExtractedFrames,
|
shouldCopyExtractedFrames,
|
||||||
VideoExtractionStageError,
|
VideoExtractionStageError,
|
||||||
} from "./extractVideosStage.js";
|
} from "./extractVideosStage.js";
|
||||||
@@ -249,6 +250,28 @@ describe("resolveVideoExtractionPolicy", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("safeVideoExtractionSourceLogMetadata", () => {
|
||||||
|
it("logs only a query-free fingerprint and normalized host for a signed remote source", () => {
|
||||||
|
const source =
|
||||||
|
"https://MEDIA.CUSTOMER-CDN.EXAMPLE/private/clip.mp4?X-Amz-Signature=super-secret#fragment";
|
||||||
|
const metadata = safeVideoExtractionSourceLogMetadata(source);
|
||||||
|
|
||||||
|
expect(metadata).toMatchObject({
|
||||||
|
sourceType: "remote",
|
||||||
|
sourceFingerprint: expect.stringMatching(/^sha256:[0-9a-f]{64}$/),
|
||||||
|
host: "media.customer-cdn.example",
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(metadata)).not.toContain("super-secret");
|
||||||
|
expect(JSON.stringify(metadata)).not.toContain("/private/clip.mp4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a non-sensitive marker for local sources", () => {
|
||||||
|
expect(safeVideoExtractionSourceLogMetadata("/private/render/clip.mp4")).toEqual({
|
||||||
|
sourceType: "local",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("assertVideoExtractionSucceeded", () => {
|
describe("assertVideoExtractionSucceeded", () => {
|
||||||
it("accepts a complete extraction", () => {
|
it("accepts a complete extraction", () => {
|
||||||
expect(() => assertVideoExtractionSucceeded(extractionResult([]))).not.toThrow();
|
expect(() => assertVideoExtractionSucceeded(extractionResult([]))).not.toThrow();
|
||||||
@@ -319,6 +342,57 @@ describe("assertVideoExtractionSucceeded", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("attaches bounded, deterministic v1 groups while keeping kind counts exhaustive", () => {
|
||||||
|
const errors: VideoExtractionFailure[] = Array.from({ length: 9 }, (_, index) => ({
|
||||||
|
videoId: `private-${index}`,
|
||||||
|
kind: "download_transient",
|
||||||
|
retryable: true,
|
||||||
|
group: {
|
||||||
|
sourceFingerprint: `sha256:${index.toString(16).padStart(64, "0")}`,
|
||||||
|
host: index % 2 === 0 ? "media.customer-cdn.example" : "other",
|
||||||
|
statusClass: "http_5xx",
|
||||||
|
retry: { phase: "download", used: 1, budget: 1 },
|
||||||
|
},
|
||||||
|
error: `https://media.customer-cdn.example/private/${index}.mp4?signature=secret`,
|
||||||
|
}));
|
||||||
|
errors.push(
|
||||||
|
{
|
||||||
|
videoId: "bad-a",
|
||||||
|
kind: "invalid_media",
|
||||||
|
retryable: false,
|
||||||
|
error: "/tmp/private-a.mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
videoId: "bad-b",
|
||||||
|
kind: "invalid_media",
|
||||||
|
retryable: false,
|
||||||
|
error: "/tmp/private-b.mp4",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
assertVideoExtractionSucceeded(extractionResult(errors));
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
expect(caught).toBeInstanceOf(VideoExtractionStageError);
|
||||||
|
if (!(caught instanceof VideoExtractionStageError)) return;
|
||||||
|
|
||||||
|
expect(caught.extractionFailure.schemaVersion).toBe(1);
|
||||||
|
expect(caught.extractionFailure.kindCounts).toEqual([
|
||||||
|
{ kind: "download_transient", affectedElementCount: 9 },
|
||||||
|
{ kind: "invalid_media", affectedElementCount: 2 },
|
||||||
|
]);
|
||||||
|
expect(caught.extractionFailure.groups).toHaveLength(8);
|
||||||
|
expect(caught.extractionFailure.omittedGroupCount).toBe(2);
|
||||||
|
expect(caught.extractionFailure.groups.map((group) => group.sourceFingerprint)).toEqual(
|
||||||
|
Array.from({ length: 8 }, (_, index) => `sha256:${index.toString(16).padStart(64, "0")}`),
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(caught.extractionFailure)).not.toContain("signature=secret");
|
||||||
|
expect(JSON.stringify(caught.extractionFailure)).not.toContain("private-");
|
||||||
|
});
|
||||||
|
|
||||||
it("fails closed for legacy failures without a kind or retryability", () => {
|
it("fails closed for legacy failures without a kind or retryability", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
assertVideoExtractionSucceeded(
|
assertVideoExtractionSucceeded(
|
||||||
|
|||||||
+45
-3
@@ -1,5 +1,11 @@
|
|||||||
import { resolveConfig, type ExtractionResult, type VideoElement } from "@hyperframes/engine";
|
import {
|
||||||
|
resolveConfig,
|
||||||
|
safeDownloadUrlIdentity,
|
||||||
|
type ExtractionResult,
|
||||||
|
type VideoElement,
|
||||||
|
} from "@hyperframes/engine";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ProducerLogger } from "../../../logger.js";
|
||||||
|
|
||||||
const extractionCalls = vi.hoisted(
|
const extractionCalls = vi.hoisted(
|
||||||
() => new Array<{ timelineEnd: number | undefined; durationSeconds: number }>(),
|
() => new Array<{ timelineEnd: number | undefined; durationSeconds: number }>(),
|
||||||
@@ -61,13 +67,17 @@ vi.mock("@hyperframes/engine", async (importOriginal) => {
|
|||||||
import { createRenderJob } from "../../renderOrchestrator.js";
|
import { createRenderJob } from "../../renderOrchestrator.js";
|
||||||
import { runExtractVideosStage } from "./extractVideosStage.js";
|
import { runExtractVideosStage } from "./extractVideosStage.js";
|
||||||
|
|
||||||
async function runStage(compositionDuration: number, materializeSymlinks: boolean): Promise<void> {
|
async function runStage(
|
||||||
|
compositionDuration: number,
|
||||||
|
materializeSymlinks: boolean,
|
||||||
|
options: { source?: string; log?: ProducerLogger } = {},
|
||||||
|
): Promise<void> {
|
||||||
const composition = {
|
const composition = {
|
||||||
duration: compositionDuration,
|
duration: compositionDuration,
|
||||||
videos: [
|
videos: [
|
||||||
{
|
{
|
||||||
id: "root-video",
|
id: "root-video",
|
||||||
src: "long.mp4",
|
src: options.source ?? "long.mp4",
|
||||||
start: 0,
|
start: 0,
|
||||||
end: Number.POSITIVE_INFINITY,
|
end: Number.POSITIVE_INFINITY,
|
||||||
mediaStart: 0,
|
mediaStart: 0,
|
||||||
@@ -89,6 +99,7 @@ async function runStage(compositionDuration: number, materializeSymlinks: boolea
|
|||||||
hdrMode: "force-sdr",
|
hdrMode: "force-sdr",
|
||||||
}),
|
}),
|
||||||
cfg: resolveConfig(),
|
cfg: resolveConfig(),
|
||||||
|
log: options.log,
|
||||||
composition,
|
composition,
|
||||||
abortSignal: undefined,
|
abortSignal: undefined,
|
||||||
assertNotAborted: () => {},
|
assertNotAborted: () => {},
|
||||||
@@ -118,3 +129,34 @@ describe.each([
|
|||||||
expect(extractionCalls).toEqual([{ timelineEnd: 10, durationSeconds: 2 }]);
|
expect(extractionCalls).toEqual([{ timelineEnd: 10, durationSeconds: 2 }]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("video extraction source logging", () => {
|
||||||
|
it("keeps the actual logger message source-free and emits only safe remote metadata", async () => {
|
||||||
|
const source =
|
||||||
|
"https://media.customer-cdn.example/private/clip.mp4?X-Amz-Signature=must-not-log#fragment";
|
||||||
|
const log = {
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
} satisfies ProducerLogger;
|
||||||
|
|
||||||
|
await runStage(2, false, { source, log });
|
||||||
|
|
||||||
|
const extractionCall = log.info.mock.calls.find(([message]) =>
|
||||||
|
message.startsWith("Extracting frames from video"),
|
||||||
|
);
|
||||||
|
expect(extractionCall).toEqual([
|
||||||
|
"Extracting frames from video 1/1",
|
||||||
|
{
|
||||||
|
sourceType: "remote",
|
||||||
|
sourceFingerprint: `sha256:${safeDownloadUrlIdentity(source).urlFingerprint}`,
|
||||||
|
host: "media.customer-cdn.example",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const serializedCall = JSON.stringify(extractionCall);
|
||||||
|
expect(serializedCall).not.toContain(source);
|
||||||
|
expect(serializedCall).not.toContain("must-not-log");
|
||||||
|
expect(serializedCall).not.toContain("/private/clip.mp4");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
type FrameLookupTable,
|
type FrameLookupTable,
|
||||||
type HdrTransfer,
|
type HdrTransfer,
|
||||||
type VideoExtractionFailureKind,
|
type VideoExtractionFailureKind,
|
||||||
|
type VideoExtractionFailureGroupDetails,
|
||||||
type VideoColorSpace,
|
type VideoColorSpace,
|
||||||
classifyVideoExtractionError,
|
classifyVideoExtractionError,
|
||||||
createFrameLookupTable,
|
createFrameLookupTable,
|
||||||
@@ -47,6 +48,7 @@ import {
|
|||||||
isHdrColorSpace,
|
isHdrColorSpace,
|
||||||
resolveProjectRelativeSrc,
|
resolveProjectRelativeSrc,
|
||||||
runVideoExtractionWithRetry,
|
runVideoExtractionWithRetry,
|
||||||
|
safeVideoExtractionSourceIdentity,
|
||||||
} from "@hyperframes/engine";
|
} from "@hyperframes/engine";
|
||||||
import {
|
import {
|
||||||
collectVideoMetadataHints,
|
collectVideoMetadataHints,
|
||||||
@@ -56,6 +58,11 @@ import {
|
|||||||
import { materializeExtractedFramesForCompiledDir, type CompositionMetadata } from "../shared.js";
|
import { materializeExtractedFramesForCompiledDir, type CompositionMetadata } from "../shared.js";
|
||||||
import type { ProducerLogger } from "../../../logger.js";
|
import type { ProducerLogger } from "../../../logger.js";
|
||||||
import { encoderFailureError } from "../encoderInterruption.js";
|
import { encoderFailureError } from "../encoderInterruption.js";
|
||||||
|
import {
|
||||||
|
compareExtractionFailureGroups,
|
||||||
|
extractionFailureGroupIdentityKey,
|
||||||
|
type ExtractionFailureMetadataV1,
|
||||||
|
} from "../extractionFailureMetadata.js";
|
||||||
|
|
||||||
export interface ExtractVideosStageInput {
|
export interface ExtractVideosStageInput {
|
||||||
projectDir: string;
|
projectDir: string;
|
||||||
@@ -124,6 +131,60 @@ export interface VideoExtractionStageFailureSummary {
|
|||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_EXTRACTION_FAILURE_GROUPS = 8;
|
||||||
|
|
||||||
|
interface ExtractionFailureAggregateInput extends VideoExtractionFailureGroupDetails {
|
||||||
|
kind: VideoExtractionFailureKind;
|
||||||
|
affectedElementCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExtractionFailureMetadata(
|
||||||
|
inputs: readonly ExtractionFailureAggregateInput[],
|
||||||
|
): ExtractionFailureMetadataV1 {
|
||||||
|
const kindCounts = new Map<VideoExtractionFailureKind, number>();
|
||||||
|
const grouped = new Map<string, ExtractionFailureAggregateInput>();
|
||||||
|
for (const input of inputs) {
|
||||||
|
kindCounts.set(input.kind, (kindCounts.get(input.kind) ?? 0) + input.affectedElementCount);
|
||||||
|
const key = extractionFailureGroupIdentityKey(input);
|
||||||
|
const existing = grouped.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.affectedElementCount += input.affectedElementCount;
|
||||||
|
} else {
|
||||||
|
grouped.set(key, { ...input });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allGroups = [...grouped.values()].sort(compareExtractionFailureGroups);
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
kindCounts: [...kindCounts]
|
||||||
|
.map(([kind, affectedElementCount]) => ({
|
||||||
|
kind,
|
||||||
|
affectedElementCount,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0)),
|
||||||
|
groups: allGroups.slice(0, MAX_EXTRACTION_FAILURE_GROUPS),
|
||||||
|
omittedGroupCount: Math.max(0, allGroups.length - MAX_EXTRACTION_FAILURE_GROUPS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractionFailureMetadataFromResult(
|
||||||
|
result: ExtractionResult,
|
||||||
|
): ExtractionFailureMetadataV1 {
|
||||||
|
return buildExtractionFailureMetadata(
|
||||||
|
result.errors.map((failure) => ({
|
||||||
|
kind: failure.kind ?? "internal",
|
||||||
|
affectedElementCount: 1,
|
||||||
|
...failure.group,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeVideoExtractionSourceLogMetadata(source: string): Record<string, unknown> {
|
||||||
|
const identity = safeVideoExtractionSourceIdentity(source);
|
||||||
|
return identity ? { sourceType: "remote", ...identity } : { sourceType: "local" };
|
||||||
|
}
|
||||||
|
|
||||||
export type VideoExtractionFailureMode = "off" | "observe" | "enforce";
|
export type VideoExtractionFailureMode = "off" | "observe" | "enforce";
|
||||||
|
|
||||||
export interface VideoExtractionPolicy {
|
export interface VideoExtractionPolicy {
|
||||||
@@ -157,15 +218,29 @@ export function resolveVideoExtractionPolicy(
|
|||||||
* the cause without leaking those values.
|
* the cause without leaking those values.
|
||||||
*/
|
*/
|
||||||
export class VideoExtractionStageError extends Error {
|
export class VideoExtractionStageError extends Error {
|
||||||
|
/**
|
||||||
|
* Producer servers may expose this explicitly public, JSON-compatible data
|
||||||
|
* without knowing its schema. Boundary adapters remain responsible for
|
||||||
|
* validating and applying policy to it.
|
||||||
|
*/
|
||||||
|
readonly publicMetadata: Readonly<Record<string, unknown>>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly code: VideoExtractionStageErrorCode,
|
readonly code: VideoExtractionStageErrorCode,
|
||||||
readonly retryable: boolean,
|
readonly retryable: boolean,
|
||||||
readonly failures: readonly VideoExtractionStageFailureSummary[],
|
readonly failures: readonly VideoExtractionStageFailureSummary[],
|
||||||
|
readonly extractionFailure: ExtractionFailureMetadataV1 = buildExtractionFailureMetadata(
|
||||||
|
failures.map((failure) => ({
|
||||||
|
kind: failure.kind,
|
||||||
|
affectedElementCount: failure.count,
|
||||||
|
})),
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
const total = failures.reduce((sum, failure) => sum + failure.count, 0);
|
const total = failures.reduce((sum, failure) => sum + failure.count, 0);
|
||||||
const breakdown = failures.map((failure) => `${failure.kind}=${failure.count}`).join(",");
|
const breakdown = failures.map((failure) => `${failure.kind}=${failure.count}`).join(",");
|
||||||
super(`Video extraction failed for ${total} source(s) [${code}; ${breakdown}]`);
|
super(`Video extraction failed for ${total} source(s) [${code}; ${breakdown}]`);
|
||||||
this.name = "VideoExtractionStageError";
|
this.name = "VideoExtractionStageError";
|
||||||
|
this.publicMetadata = { extractionFailure };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +277,7 @@ function buildVideoExtractionStageError(
|
|||||||
retryable ? "VIDEO_EXTRACTION_FAILED" : "VIDEO_SOURCE_UNRENDERABLE",
|
retryable ? "VIDEO_EXTRACTION_FAILED" : "VIDEO_SOURCE_UNRENDERABLE",
|
||||||
retryable,
|
retryable,
|
||||||
failures,
|
failures,
|
||||||
|
extractionFailureMetadataFromResult(result),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +296,12 @@ export function buildHdrProbeStageError(
|
|||||||
retryable ? "VIDEO_EXTRACTION_FAILED" : "VIDEO_SOURCE_UNRENDERABLE",
|
retryable ? "VIDEO_EXTRACTION_FAILED" : "VIDEO_SOURCE_UNRENDERABLE",
|
||||||
retryable,
|
retryable,
|
||||||
summary,
|
summary,
|
||||||
|
buildExtractionFailureMetadata(
|
||||||
|
failures.map((failure) => ({
|
||||||
|
kind: failure.kind,
|
||||||
|
affectedElementCount: 1,
|
||||||
|
})),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,7 +493,10 @@ export async function runExtractVideosStage(
|
|||||||
const totalVideos = composition.videos.length;
|
const totalVideos = composition.videos.length;
|
||||||
for (let i = 0; i < totalVideos; i++) {
|
for (let i = 0; i < totalVideos; i++) {
|
||||||
const v = composition.videos[i]!;
|
const v = composition.videos[i]!;
|
||||||
log?.info(`Extracting frames from video ${i + 1}/${totalVideos}: ${v.src}`);
|
log?.info(
|
||||||
|
`Extracting frames from video ${i + 1}/${totalVideos}`,
|
||||||
|
safeVideoExtractionSourceLogMetadata(v.src),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
extractionResult = await extractAllVideoFrames(
|
extractionResult = await extractAllVideoFrames(
|
||||||
composition.videos,
|
composition.videos,
|
||||||
|
|||||||
Reference in New Issue
Block a user