mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-02 21:02:14 +03:00
Compare commits
5 Commits
fix/v3851-
...
refactor/v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96c2f303fc | ||
|
|
5f43300659 | ||
|
|
bc39595ec6 | ||
|
|
d7fc935514 | ||
|
|
630dc172a2 |
@@ -1,3 +1,6 @@
|
||||
import { decodeJpegFrameDataUri, estimateJpegFrameBytes } from "./videoBridgeFrameContract";
|
||||
import { VIDEO_FRAME_MAX_BYTES } from "./videoBridgeRuntime";
|
||||
|
||||
export interface ContactSheetFrame {
|
||||
dataUri: string;
|
||||
timestampSeconds: number;
|
||||
@@ -35,12 +38,6 @@ function fallback(frames: readonly ContactSheetFrame[]): VideoContactSheetResult
|
||||
};
|
||||
}
|
||||
|
||||
function decodeFrame(dataUri: string): Buffer {
|
||||
const match = /^data:image\/jpeg;base64,([A-Za-z0-9+/=]{4,5592408})$/i.exec(dataUri);
|
||||
if (!match) throw new Error("Contact sheet requires JPEG data URIs");
|
||||
return Buffer.from(match[1], "base64");
|
||||
}
|
||||
|
||||
function formatContactSheetTimestamp(timestampSeconds: number): string {
|
||||
const totalMilliseconds = Math.max(0, Math.round(timestampSeconds * 1000));
|
||||
const minutes = Math.floor(totalMilliseconds / 60_000);
|
||||
@@ -89,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(decodeFrame(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({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
||||
|
||||
import sharp from "sharp";
|
||||
|
||||
import { JPEG_FRAME_DATA_URI_PREFIX } from "./videoBridgeFrameContract";
|
||||
import { resolveVideoFocusWindow, type VideoFocusWindow } from "./videoBridgeRuntime";
|
||||
|
||||
export interface VideoDrilldownFrameInput {
|
||||
@@ -101,9 +102,8 @@ export const VIDEO_DRILLDOWN_MAX_FRAME_BYTES = 4 * 1024 * 1024;
|
||||
export const VIDEO_DRILLDOWN_MAX_ENTRY_BYTES = 32 * 1024 * 1024;
|
||||
const MAX_DURATION_SECONDS = 600;
|
||||
const MAX_FRAME_DIMENSION = 8192;
|
||||
const JPEG_DATA_URI_PREFIX = "data:image/jpeg;base64,";
|
||||
export const VIDEO_DRILLDOWN_MAX_FRAME_DATA_URI_CHARS =
|
||||
JPEG_DATA_URI_PREFIX.length + Math.ceil(VIDEO_DRILLDOWN_MAX_FRAME_BYTES / 3) * 4;
|
||||
JPEG_FRAME_DATA_URI_PREFIX.length + Math.ceil(VIDEO_DRILLDOWN_MAX_FRAME_BYTES / 3) * 4;
|
||||
|
||||
function validationFailure(message: string): never {
|
||||
throw new VideoDrilldownValidationError(message);
|
||||
@@ -270,10 +270,10 @@ async function decodeCanonicalJpeg(
|
||||
resolution: { height: number; width: number };
|
||||
}> {
|
||||
throwIfAborted(signal);
|
||||
if (!dataUri.startsWith(JPEG_DATA_URI_PREFIX)) {
|
||||
if (!dataUri.startsWith(JPEG_FRAME_DATA_URI_PREFIX)) {
|
||||
validationFailure("Invalid drill-down JPEG frame");
|
||||
}
|
||||
const encoded = dataUri.slice(JPEG_DATA_URI_PREFIX.length);
|
||||
const encoded = dataUri.slice(JPEG_FRAME_DATA_URI_PREFIX.length);
|
||||
if (dataUri.length > VIDEO_DRILLDOWN_MAX_FRAME_DATA_URI_CHARS) {
|
||||
validationFailure("Drill-down frame byte limit exceeded");
|
||||
}
|
||||
@@ -622,7 +622,7 @@ export class VideoDrilldownCache {
|
||||
)
|
||||
.slice(0, frameCount)
|
||||
.map((frame) => ({
|
||||
dataUri: `${JPEG_DATA_URI_PREFIX}${frame.data.toString("base64")}`,
|
||||
dataUri: `${JPEG_FRAME_DATA_URI_PREFIX}${frame.data.toString("base64")}`,
|
||||
height: frame.height,
|
||||
timestampSeconds: frame.timestampSeconds,
|
||||
width: frame.width,
|
||||
|
||||
28
src/lib/guardrails/videoBridgeFrameContract.ts
Normal file
28
src/lib/guardrails/videoBridgeFrameContract.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export const JPEG_FRAME_DATA_URI_PREFIX = "data:image/jpeg;base64,";
|
||||
|
||||
// Derived from the exported prefix so the two can never drift apart.
|
||||
const JPEG_FRAME_DATA_URI_PATTERN = new RegExp(
|
||||
`^${JPEG_FRAME_DATA_URI_PREFIX.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}([A-Za-z0-9+/=]+)$`,
|
||||
"i"
|
||||
);
|
||||
|
||||
function matchJpegFrame(dataUri: string): string {
|
||||
const match = JPEG_FRAME_DATA_URI_PATTERN.exec(dataUri);
|
||||
if (!match) throw new Error("Video frame is not a JPEG data URI");
|
||||
return match[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws on any non-JPEG or base64-invalid input; the single frame decode used by every
|
||||
* video module.
|
||||
*/
|
||||
export function decodeJpegFrameDataUri(dataUri: string): Buffer {
|
||||
return Buffer.from(matchJpegFrame(dataUri), "base64");
|
||||
}
|
||||
|
||||
/** Decoded-byte estimate without materializing the buffer (validation/budget paths). */
|
||||
export function estimateJpegFrameBytes(dataUri: string): number {
|
||||
const encoded = matchJpegFrame(dataUri);
|
||||
const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0;
|
||||
return Math.floor((encoded.length * 3) / 4) - padding;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type BrokerExtractionOptions,
|
||||
type BrokerExtractionResult,
|
||||
} from "./videoBridgeBrokerClient";
|
||||
import { decodeJpegFrameDataUri } from "./videoBridgeFrameContract";
|
||||
import {
|
||||
resolveVideoFocusWindow,
|
||||
type VideoFocusWindow,
|
||||
@@ -306,16 +307,15 @@ export async function compareVideoFramesByGrayscale(
|
||||
signal?: AbortSignal
|
||||
): Promise<number> {
|
||||
throwIfVideoDedupAborted(signal);
|
||||
const decode = (dataUri: string): Buffer => {
|
||||
const match = /^data:image\/jpeg;base64,([A-Za-z0-9+/=]+)$/i.exec(dataUri);
|
||||
if (!match) throw new Error("Video frame is not a JPEG data URI");
|
||||
return Buffer.from(match[1], "base64");
|
||||
};
|
||||
const { default: sharp } = await import("sharp");
|
||||
throwIfVideoDedupAborted(signal);
|
||||
const [left, right] = await Promise.all(
|
||||
[previous, current].map((frame) =>
|
||||
sharp(decode(frame.dataUri)).resize(16, 16, { fit: "fill" }).greyscale().raw().toBuffer()
|
||||
sharp(decodeJpegFrameDataUri(frame.dataUri))
|
||||
.resize(16, 16, { fit: "fill" })
|
||||
.greyscale()
|
||||
.raw()
|
||||
.toBuffer()
|
||||
)
|
||||
);
|
||||
throwIfVideoDedupAborted(signal);
|
||||
|
||||
@@ -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();
|
||||
|
||||
35
tests/unit/guardrails/videoBridgeFrameContract.test.ts
Normal file
35
tests/unit/guardrails/videoBridgeFrameContract.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
JPEG_FRAME_DATA_URI_PREFIX,
|
||||
decodeJpegFrameDataUri,
|
||||
estimateJpegFrameBytes,
|
||||
} from "../../../src/lib/guardrails/videoBridgeFrameContract";
|
||||
|
||||
test("decodes a valid JPEG data URI case-insensitively", () => {
|
||||
const bytes = Buffer.from("abc");
|
||||
const uri = `data:image/JPEG;base64,${bytes.toString("base64")}`;
|
||||
assert.deepEqual(decodeJpegFrameDataUri(uri), bytes);
|
||||
assert.equal(JPEG_FRAME_DATA_URI_PREFIX, "data:image/jpeg;base64,");
|
||||
});
|
||||
|
||||
test("rejects non-JPEG and malformed URIs with a stable message", () => {
|
||||
for (const bad of [
|
||||
"data:image/png;base64,QQ==",
|
||||
"data:image/jpeg;base64,@@invalid@@",
|
||||
"data:image/jpeg,plain",
|
||||
"https://example.com/frame.jpg",
|
||||
"",
|
||||
]) {
|
||||
assert.throws(() => decodeJpegFrameDataUri(bad), /not a JPEG data URI/i);
|
||||
assert.throws(() => estimateJpegFrameBytes(bad), /not a JPEG data URI/i);
|
||||
}
|
||||
});
|
||||
|
||||
test("estimates decoded bytes without decoding, accounting for padding", () => {
|
||||
for (const source of ["a", "ab", "abc", "abcd", "x".repeat(3000)]) {
|
||||
const uri = `data:image/jpeg;base64,${Buffer.from(source).toString("base64")}`;
|
||||
assert.equal(estimateJpegFrameBytes(uri), Buffer.byteLength(source));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user