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:
James Russo
2026-09-01 21:35:40 -04:00
committed by GitHub
parent 305de15432
commit c0887b650d
12 changed files with 641 additions and 13 deletions
+5
View File
@@ -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([]);
+16 -1
View File
@@ -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, {
+45 -4
View File
@@ -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;
});
});
+10 -1
View File
@@ -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(
@@ -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,