diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index f5ca7c62f..613759155 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -197,6 +197,7 @@ export { classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, + safeVideoExtractionSourceIdentity, VideoSourceExtractionError, type VideoElement, type ImageElement, @@ -207,6 +208,10 @@ export { type TimelineExtractionWindow, type VideoExtractionFailure, type VideoExtractionFailureKind, + type VideoExtractionFailureGroupDetails, + type VideoExtractionFailureRetry, + type VideoExtractionFailureStatusClass, + type SafeVideoExtractionSourceIdentity, type VideoFrameFormat, VIDEO_FRAME_FORMATS, isVideoFrameFormat, diff --git a/packages/engine/src/services/videoFrameExtractor.errorClassification.test.ts b/packages/engine/src/services/videoFrameExtractor.errorClassification.test.ts index 6873c484a..7a70945c9 100644 --- a/packages/engine/src/services/videoFrameExtractor.errorClassification.test.ts +++ b/packages/engine/src/services/videoFrameExtractor.errorClassification.test.ts @@ -1,6 +1,26 @@ -import { describe, expect, it } from "vitest"; -import { UrlDownloadError } from "../utils/urlDownloader.js"; -import { classifyFfmpegSpawnError, classifyVideoExtractionError } from "./videoFrameExtractor.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +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", () => { 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("denied")), + ); + + 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 }, + }, + }); + }); +}); diff --git a/packages/engine/src/services/videoFrameExtractor.ts b/packages/engine/src/services/videoFrameExtractor.ts index 716d7c50d..f6e9da844 100644 --- a/packages/engine/src/services/videoFrameExtractor.ts +++ b/packages/engine/src/services/videoFrameExtractor.ts @@ -36,6 +36,7 @@ import { import { downloadToTemp, isHttpUrl, + safeDownloadUrlIdentity, UrlDownloadError, writeUrlDownloadTelemetry, } from "../utils/urlDownloader.js"; @@ -280,6 +281,8 @@ export interface VideoExtractionFailure { kind?: VideoExtractionFailureKind; /** Always populated by this engine version; absent legacy values fail closed. */ retryable?: boolean; + /** Bounded, path-free grouping data safe for producer-owned error metadata. */ + group?: VideoExtractionFailureGroupDetails; /** * Operator diagnostic retained inside the engine result. Producer-facing * errors must summarize `kind`/counts and must not forward this field: it @@ -288,6 +291,71 @@ export interface VideoExtractionFailure { 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 { readonly hyperframesVideoSourceExtractionError = true as const; @@ -1546,6 +1614,7 @@ export async function extractAllVideoFrames( videoId: video.id, kind: classified.kind, retryable: classified.retryable, + ...(err instanceof UrlDownloadError ? { group: downloadFailureGroup(video.src, err) } : {}), error: classified.diagnostic, }); } diff --git a/packages/engine/src/utils/urlDownloader.test.ts b/packages/engine/src/utils/urlDownloader.test.ts index 908765c18..e08d5ff40 100644 --- a/packages/engine/src/utils/urlDownloader.test.ts +++ b/packages/engine/src/utils/urlDownloader.test.ts @@ -447,6 +447,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { ).rejects.toMatchObject({ kind: "length_mismatch", retryable: true, + telemetry: expect.objectContaining({ attempt: 2 }), } satisfies Partial); expect(fetchMock).toHaveBeenCalledTimes(2); expect(readdirSync(dir).filter((name) => name.startsWith("download_"))).toEqual([]); @@ -802,6 +803,7 @@ describe("downloadToTemp atomic publication and bounded retry", () => { ).rejects.toMatchObject({ kind: "invalid_payload", retryable: false, + telemetry: expect.objectContaining({ attempt: 1 }), } satisfies Partial); expect(fetchMock).toHaveBeenCalledTimes(1); expect(temporaryDownloadEntries(dir)).toEqual([]); diff --git a/packages/engine/src/utils/urlDownloader.ts b/packages/engine/src/utils/urlDownloader.ts index 3ba5fe650..f3d3a64ef 100644 --- a/packages/engine/src/utils/urlDownloader.ts +++ b/packages/engine/src/utils/urlDownloader.ts @@ -1113,7 +1113,22 @@ async function downloadWithRetry( return await runDownloadAttempt(url, localPath, timeoutMs, attempt + 1, options, signal); } catch (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); const identity = safeDownloadUrlIdentity(url); emitDownloadTelemetry(options, { diff --git a/packages/producer/src/server.errorCode.test.ts b/packages/producer/src/server.errorCode.test.ts index 59f635911..ead7bdc80 100644 --- a/packages/producer/src/server.errorCode.test.ts +++ b/packages/producer/src/server.errorCode.test.ts @@ -30,10 +30,6 @@ describe("extractSafeRenderErrorCode", () => { const error = new AssetMediaTypeMismatchError([ { 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({ errorCode: "ASSET_MEDIA_TYPE_MISMATCH", errorOwner: "user", @@ -51,6 +47,51 @@ describe("extractSafeRenderErrorCode", () => { 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", () => { expect(extractSafeRenderErrorCode({ code: "INTERNAL_ERROR" })).toBeUndefined(); expect( diff --git a/packages/producer/src/server.extractionFailureMetadata.test.ts b/packages/producer/src/server.extractionFailureMetadata.test.ts new file mode 100644 index 000000000..3071c1f29 --- /dev/null +++ b/packages/producer/src/server.extractionFailureMetadata.test.ts @@ -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) => ({ + 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 { + return createApp().request(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ 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).callerDefinedField; + }); +}); diff --git a/packages/producer/src/server.ts b/packages/producer/src/server.ts index dacf927dc..ffc5d3c6c 100644 --- a/packages/producer/src/server.ts +++ b/packages/producer/src/server.ts @@ -142,9 +142,14 @@ export interface SafeRenderErrorMetadata { errorCode: string; errorOwner?: "system" | "user"; retryable?: boolean; + /** Public, producer-authored data whose schema and policy belong to callers. */ + errorMetadata?: Readonly>; } -/** 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( error: unknown, ): SafeRenderErrorMetadata | undefined { @@ -152,10 +157,12 @@ export function extractSafeRenderErrorMetadata( if (!errorCode || typeof error !== "object" || error === null) return undefined; const owner = "owner" in error ? error.owner : undefined; const retryable = "retryable" in error ? error.retryable : undefined; + const publicMetadata = "publicMetadata" in error ? error.publicMetadata : undefined; return { errorCode, errorOwner: owner === "user" || owner === "system" ? owner : undefined, retryable: typeof retryable === "boolean" ? retryable : undefined, + ...(isPlainObject(publicMetadata) ? { errorMetadata: publicMetadata } : {}), }; } @@ -625,6 +632,7 @@ async function writeRenderStreamFailure(input: { errorCode: safeError?.errorCode, errorOwner: safeError?.errorOwner, retryable: safeError?.retryable, + errorMetadata: safeError?.errorMetadata, stage: job.currentStage, elapsedMs, errorDetails: job.errorDetails ?? null, @@ -788,6 +796,7 @@ export function createRenderHandlers(options: HandlerOptions = {}): RenderHandle errorCode: safeError?.errorCode, errorOwner: safeError?.errorOwner, retryable: safeError?.retryable, + errorMetadata: safeError?.errorMetadata, stage: job.currentStage, durationMs, errorDetails: job.errorDetails ?? null, diff --git a/packages/producer/src/services/render/extractionFailureMetadata.ts b/packages/producer/src/services/render/extractionFailureMetadata.ts new file mode 100644 index 000000000..8cef769f1 --- /dev/null +++ b/packages/producer/src/services/render/extractionFailureMetadata.ts @@ -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)); +} diff --git a/packages/producer/src/services/render/stages/extractVideosStage.test.ts b/packages/producer/src/services/render/stages/extractVideosStage.test.ts index 5b2033502..3df853e8a 100644 --- a/packages/producer/src/services/render/stages/extractVideosStage.test.ts +++ b/packages/producer/src/services/render/stages/extractVideosStage.test.ts @@ -14,6 +14,7 @@ import { assertVideoExtractionSucceeded, buildHdrProbeStageError, resolveVideoExtractionPolicy, + safeVideoExtractionSourceLogMetadata, shouldCopyExtractedFrames, VideoExtractionStageError, } 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", () => { it("accepts a complete extraction", () => { 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", () => { expect(() => assertVideoExtractionSucceeded( diff --git a/packages/producer/src/services/render/stages/extractVideosStage.timelineBound.test.ts b/packages/producer/src/services/render/stages/extractVideosStage.timelineBound.test.ts index d6a27a957..09a8fdc75 100644 --- a/packages/producer/src/services/render/stages/extractVideosStage.timelineBound.test.ts +++ b/packages/producer/src/services/render/stages/extractVideosStage.timelineBound.test.ts @@ -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 type { ProducerLogger } from "../../../logger.js"; const extractionCalls = vi.hoisted( () => new Array<{ timelineEnd: number | undefined; durationSeconds: number }>(), @@ -61,13 +67,17 @@ vi.mock("@hyperframes/engine", async (importOriginal) => { import { createRenderJob } from "../../renderOrchestrator.js"; import { runExtractVideosStage } from "./extractVideosStage.js"; -async function runStage(compositionDuration: number, materializeSymlinks: boolean): Promise { +async function runStage( + compositionDuration: number, + materializeSymlinks: boolean, + options: { source?: string; log?: ProducerLogger } = {}, +): Promise { const composition = { duration: compositionDuration, videos: [ { id: "root-video", - src: "long.mp4", + src: options.source ?? "long.mp4", start: 0, end: Number.POSITIVE_INFINITY, mediaStart: 0, @@ -89,6 +99,7 @@ async function runStage(compositionDuration: number, materializeSymlinks: boolea hdrMode: "force-sdr", }), cfg: resolveConfig(), + log: options.log, composition, abortSignal: undefined, assertNotAborted: () => {}, @@ -118,3 +129,34 @@ describe.each([ 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"); + }); +}); diff --git a/packages/producer/src/services/render/stages/extractVideosStage.ts b/packages/producer/src/services/render/stages/extractVideosStage.ts index 7b048bda7..dc37c0c2b 100644 --- a/packages/producer/src/services/render/stages/extractVideosStage.ts +++ b/packages/producer/src/services/render/stages/extractVideosStage.ts @@ -38,6 +38,7 @@ import { type FrameLookupTable, type HdrTransfer, type VideoExtractionFailureKind, + type VideoExtractionFailureGroupDetails, type VideoColorSpace, classifyVideoExtractionError, createFrameLookupTable, @@ -47,6 +48,7 @@ import { isHdrColorSpace, resolveProjectRelativeSrc, runVideoExtractionWithRetry, + safeVideoExtractionSourceIdentity, } from "@hyperframes/engine"; import { collectVideoMetadataHints, @@ -56,6 +58,11 @@ import { import { materializeExtractedFramesForCompiledDir, type CompositionMetadata } from "../shared.js"; import type { ProducerLogger } from "../../../logger.js"; import { encoderFailureError } from "../encoderInterruption.js"; +import { + compareExtractionFailureGroups, + extractionFailureGroupIdentityKey, + type ExtractionFailureMetadataV1, +} from "../extractionFailureMetadata.js"; export interface ExtractVideosStageInput { projectDir: string; @@ -124,6 +131,60 @@ export interface VideoExtractionStageFailureSummary { 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(); + const grouped = new Map(); + 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 { + const identity = safeVideoExtractionSourceIdentity(source); + return identity ? { sourceType: "remote", ...identity } : { sourceType: "local" }; +} + export type VideoExtractionFailureMode = "off" | "observe" | "enforce"; export interface VideoExtractionPolicy { @@ -157,15 +218,29 @@ export function resolveVideoExtractionPolicy( * the cause without leaking those values. */ 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>; + constructor( readonly code: VideoExtractionStageErrorCode, readonly retryable: boolean, 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 breakdown = failures.map((failure) => `${failure.kind}=${failure.count}`).join(","); super(`Video extraction failed for ${total} source(s) [${code}; ${breakdown}]`); this.name = "VideoExtractionStageError"; + this.publicMetadata = { extractionFailure }; } } @@ -202,6 +277,7 @@ function buildVideoExtractionStageError( retryable ? "VIDEO_EXTRACTION_FAILED" : "VIDEO_SOURCE_UNRENDERABLE", retryable, failures, + extractionFailureMetadataFromResult(result), ); } @@ -220,6 +296,12 @@ export function buildHdrProbeStageError( retryable ? "VIDEO_EXTRACTION_FAILED" : "VIDEO_SOURCE_UNRENDERABLE", retryable, summary, + buildExtractionFailureMetadata( + failures.map((failure) => ({ + kind: failure.kind, + affectedElementCount: 1, + })), + ), ); } @@ -411,7 +493,10 @@ export async function runExtractVideosStage( const totalVideos = composition.videos.length; for (let i = 0; i < totalVideos; 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( composition.videos,