diff --git a/src/lib/guardrails/videoBridgeContactSheet.ts b/src/lib/guardrails/videoBridgeContactSheet.ts index 7ef2c58e69..4fdf18e258 100644 --- a/src/lib/guardrails/videoBridgeContactSheet.ts +++ b/src/lib/guardrails/videoBridgeContactSheet.ts @@ -1,4 +1,5 @@ -import { decodeJpegFrameDataUri } from "./videoBridgeFrameContract"; +import { decodeJpegFrameDataUri, estimateJpegFrameBytes } from "./videoBridgeFrameContract"; +import { VIDEO_FRAME_MAX_BYTES } from "./videoBridgeRuntime"; export interface ContactSheetFrame { dataUri: string; @@ -85,13 +86,18 @@ export async function buildVideoContactSheet( const { default: sharp } = await import("sharp"); if (signal.aborted) throw new Error("Video contact sheet was aborted"); const tiles = await Promise.all( - frames.map(async (frame) => - sharp(decodeJpegFrameDataUri(frame.dataUri)) + frames.map(async (frame) => { + // Reject before decoding: an oversized frame must never reach sharp() just to be + // discovered later — estimateJpegFrameBytes reads the encoded length only. + if (estimateJpegFrameBytes(frame.dataUri) > VIDEO_FRAME_MAX_BYTES) { + throw new Error("Contact sheet frame exceeds the maximum per-frame size"); + } + return sharp(decodeJpegFrameDataUri(frame.dataUri)) .resize(TILE_SIZE, TILE_SIZE, { fit: "contain", background: "#000000" }) .composite([{ input: buildTimestampLabel(frame.timestampSeconds), left: 0, top: 0 }]) .jpeg({ quality: 80 }) - .toBuffer() - ) + .toBuffer(); + }) ); if (signal.aborted) throw new Error("Video contact sheet was aborted"); const output = await sharp({ diff --git a/tests/unit/guardrails/videoBridgeContactSheet.test.ts b/tests/unit/guardrails/videoBridgeContactSheet.test.ts index b8c41c83a0..d466da2836 100644 --- a/tests/unit/guardrails/videoBridgeContactSheet.test.ts +++ b/tests/unit/guardrails/videoBridgeContactSheet.test.ts @@ -97,6 +97,29 @@ test("contact sheet falls back to individual frames when decoding fails", async assert.deepEqual(result.frames, frames); }); +test("contact sheet falls back to individual frames when a frame exceeds the per-frame byte cap", async () => { + const validJpegBytes = await sharp({ + create: { background: "red", channels: 3, height: 24, width: 32 }, + }) + .jpeg() + .toBuffer(); + // A technically-decodable JPEG prefix followed by zero-filled padding past + // VIDEO_FRAME_MAX_BYTES (4 MiB): without a pre-decode size guard, sharp decodes the + // leading valid JPEG and ignores the trailing bytes after EOI, so an oversized frame + // would otherwise sail through the contact-sheet path undetected (used: true). + const oversizedBytes = Buffer.concat([validJpegBytes, Buffer.alloc(5 * 1024 * 1024, 0)]); + const frames = [ + { + dataUri: `data:image/jpeg;base64,${oversizedBytes.toString("base64")}`, + timestampSeconds: 2, + }, + ]; + const result = await buildVideoContactSheet(frames); + assert.equal(result.used, false); + assert.equal(result.fallbackReason, "CONTACT_SHEET_UNAVAILABLE"); + assert.deepEqual(result.frames, frames); +}); + test("contact sheet respects the parent abort signal", async () => { const controller = new AbortController(); controller.abort();