diff --git a/src/lib/guardrails/videoBridgeRuntime.ts b/src/lib/guardrails/videoBridgeRuntime.ts index 52b481b909..0b3203a6f3 100644 --- a/src/lib/guardrails/videoBridgeRuntime.ts +++ b/src/lib/guardrails/videoBridgeRuntime.ts @@ -185,7 +185,7 @@ export async function probeLocalVideo( "-threads", "1", "-show_entries", - "format=duration,format_name:stream=index,codec_type,width,height", + "format=duration,format_name:stream=index,codec_type,width,height:stream_disposition=default,attached_pic", "-of", "json", inputPath, @@ -198,11 +198,13 @@ export async function probeLocalVideo( let height = Number.NaN; let streamIndex = Number.NaN; let allVideoStreamsSafe = false; + let playableVideoStreamCount = 0; try { const parsed = JSON.parse(result.stdout) as { format?: { duration?: unknown; format_name?: unknown }; streams?: Array<{ codec_type?: unknown; + disposition?: unknown; height?: unknown; index?: unknown; width?: unknown; @@ -211,9 +213,21 @@ export async function probeLocalVideo( durationSeconds = Number(parsed.format?.duration); formatName = typeof parsed.format?.format_name === "string" ? parsed.format.format_name : ""; const videoStreams = parsed.streams?.filter((stream) => stream.codec_type === "video") ?? []; + const dispositionFlag = (stream: (typeof videoStreams)[number], key: string): boolean => { + const disposition = stream.disposition; + if (!disposition || typeof disposition !== "object" || Array.isArray(disposition)) { + return false; + } + const value = (disposition as Record)[key]; + return value === 1 || value === "1"; + }; + const playableVideoStreams = videoStreams.filter( + (stream) => !dispositionFlag(stream, "attached_pic") + ); + playableVideoStreamCount = playableVideoStreams.length; allVideoStreamsSafe = - videoStreams.length > 0 && - !videoStreams.some((stream) => { + playableVideoStreams.length > 0 && + !playableVideoStreams.some((stream) => { const streamWidth = Number(stream.width); const streamHeight = Number(stream.height); const candidateIndex = Number(stream.index); @@ -229,12 +243,16 @@ export async function probeLocalVideo( streamWidth * streamHeight > VIDEO_MAX_PIXELS ); }); - const selectedStream = [...videoStreams].sort( - (left, right) => Number(left.index) - Number(right.index) - )[0]; - streamIndex = Number(selectedStream.index); - width = Number(selectedStream.width); - height = Number(selectedStream.height); + const selectedStream = [...playableVideoStreams].sort((left, right) => { + const defaultPreference = + Number(dispositionFlag(right, "default")) - Number(dispositionFlag(left, "default")); + return defaultPreference || Number(left.index) - Number(right.index); + })[0]; + if (selectedStream) { + streamIndex = Number(selectedStream.index); + width = Number(selectedStream.width); + height = Number(selectedStream.height); + } } catch { // The stable error below deliberately excludes raw ffprobe output. } @@ -252,8 +270,11 @@ export async function probeLocalVideo( if (formats.length === 0 || formats.some((entry) => !SAFE_FORMATS.has(entry))) { throw new Error("Video container format is not allowed"); } + if (playableVideoStreamCount === 0) { + throw new Error("Video container has no playable video stream"); + } if (!allVideoStreamsSafe) { - throw new Error("Video dimensions exceed the safe processing limit"); + throw new Error("Video stream metadata or dimensions exceed the safe processing limit"); } return { durationSeconds, formatName, height, streamIndex, width }; } diff --git a/tests/unit/guardrails/videoBridgeRuntime.test.ts b/tests/unit/guardrails/videoBridgeRuntime.test.ts index 061c65271f..01f56f381d 100644 --- a/tests/unit/guardrails/videoBridgeRuntime.test.ts +++ b/tests/unit/guardrails/videoBridgeRuntime.test.ts @@ -65,6 +65,10 @@ test("probes and extracts a local video using shell-free bounded commands", asyn ["-protocol_whitelist", "file"] ); assert.ok(calls[0].args.includes("-format_whitelist")); + assert.equal( + calls[0].args[calls[0].args.indexOf("-show_entries") + 1], + "format=duration,format_name:stream=index,codec_type,width,height:stream_disposition=default,attached_pic" + ); assert.equal( calls.slice(1).every((call) => call.executable === "ffmpeg"), true @@ -243,6 +247,113 @@ test("selects the lowest validated video stream index and maps it explicitly in assert.deepEqual(ffmpegArgs.slice(mapIndex, mapIndex + 2), ["-map", "0:1"]); }); +test("ignores an attached cover and maps the preferred playable default stream", async () => { + const calls: Array<{ executable: string; args: string[] }> = []; + const runner: VideoCommandRunner = async (executable, args) => { + calls.push({ executable, args: [...args] }); + return executable === "ffprobe" + ? { + stdout: JSON.stringify({ + format: { duration: "4", format_name: "mp4" }, + streams: [ + { + index: 0, + codec_type: "video", + width: 20000, + height: 20000, + disposition: { attached_pic: 1, default: 0 }, + }, + { + index: 1, + codec_type: "video", + width: 640, + height: 360, + disposition: { attached_pic: 0, default: 0 }, + }, + { + index: 2, + codec_type: "video", + width: 1280, + height: 720, + disposition: { attached_pic: 0, default: 1 }, + }, + ], + }), + stderr: "", + } + : { stdout: "", stderr: "" }; + }; + + const metadata = await probeLocalVideo("/tmp/cover-and-video.mp4", { runner }); + await extractFramesFromLocalVideo("/tmp/cover-and-video.mp4", "/tmp/frames", { + durationSeconds: metadata.durationSeconds, + frameCount: 1, + runner, + streamIndex: metadata.streamIndex, + }); + + assert.equal(metadata.streamIndex, 2); + assert.equal(metadata.width, 1280); + assert.equal(metadata.height, 720); + const ffmpegArgs = calls.find((call) => call.executable === "ffmpeg")?.args ?? []; + const mapIndex = ffmpegArgs.indexOf("-map"); + assert.deepEqual(ffmpegArgs.slice(mapIndex, mapIndex + 2), ["-map", "0:2"]); +}); + +test("rejects a container whose only video stream is an attached picture", async () => { + const runner: VideoCommandRunner = async () => ({ + stdout: JSON.stringify({ + format: { duration: "4", format_name: "mp4" }, + streams: [ + { index: 0, codec_type: "audio" }, + { + index: 1, + codec_type: "video", + width: 600, + height: 600, + disposition: { attached_pic: 1, default: 1 }, + }, + ], + }), + stderr: "", + }); + + await assert.rejects( + () => probeLocalVideo("/tmp/audio-with-cover.mp4", { runner }), + /playable video stream/ + ); +}); + +test("malformed playable stream disposition or index fails closed without selecting a cover", async () => { + const runner: VideoCommandRunner = async () => ({ + stdout: JSON.stringify({ + format: { duration: "4", format_name: "mp4" }, + streams: [ + { + index: 0, + codec_type: "video", + width: 300, + height: 300, + disposition: { attached_pic: "1", default: "not-a-flag" }, + }, + { + index: "bad", + codec_type: "video", + width: 1280, + height: 720, + disposition: { attached_pic: 0, default: 1 }, + }, + ], + }), + stderr: "", + }); + + await assert.rejects( + () => probeLocalVideo("/tmp/malformed-stream.mp4", { runner }), + /dimensions|stream metadata/ + ); +}); + test("runtime status exposes sanitized versions and a sanitized unavailable reason", async () => { resetVideoRuntimeProbeCacheForTests(); const ready = await probeVideoRuntime({