mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 16:12:23 +03:00
Compare commits
5 Commits
fix/better
...
fix/v3850-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db482069e7 | ||
|
|
d4ade9d1d3 | ||
|
|
f54c93c879 | ||
|
|
e2e48fdab8 | ||
|
|
d2cea0811a |
1
changelog.d/fixes/11362-video-bridge-result-cache.md
Normal file
1
changelog.d/fixes/11362-video-bridge-result-cache.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(video):** fingerprint protected Video Bridge bytes, coalesce concurrent work, and fail open when the bounded TTL/LRU result cache is unavailable or corrupt ([#11362](https://github.com/diegosouzapw/OmniRoute/pull/11362))
|
||||
1
changelog.d/fixes/11382-video-bridge-dedup-policy.md
Normal file
1
changelog.d/fixes/11382-video-bridge-dedup-policy.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(video):** apply the caption-frame cap after bounded visual deduplication, preserve first/final candidates plus small high-contrast motion and text changes, and version the dedup policy in result-cache identity ([#11382](https://github.com/diegosouzapw/OmniRoute/pull/11382)).
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: "Guardrails"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-14
|
||||
lastUpdated: 2026-08-24
|
||||
---
|
||||
|
||||
# Guardrails
|
||||
|
||||
> **Source of truth:** `src/lib/guardrails/`
|
||||
> **Last updated:** 2026-08-15 — v3.8.50 (Video Bridge broker confinement)
|
||||
> **Last updated:** 2026-08-24 — v3.8.50 (Video Bridge visual dedup hardening)
|
||||
|
||||
Guardrails enforce safety, policy, and content transformations at the boundary
|
||||
between OmniRoute and upstream providers. Each guardrail can inspect (and
|
||||
@@ -340,11 +340,20 @@ serialized broker response to 32 MiB. A private temporary directory is removed
|
||||
in `finally`. OmniRoute does not bundle FFmpeg and does not accept a custom
|
||||
executable path. Before captioning, the bridge applies a conservative visual
|
||||
deduplication pass: each JPEG is reduced to a 16×16 grayscale buffer and is
|
||||
compared only with the last frame retained, using a fixed similarity threshold
|
||||
of 0.04 — a deliberate constant chosen for predictability, not a runtime
|
||||
setting. The first and final timeline frames
|
||||
are always retained; comparator or decoder errors fail open and keep coverage.
|
||||
The output metadata reports how many frames were dropped.
|
||||
compared only with the last frame retained. For a requested caption budget
|
||||
above one frame, extraction supplies a
|
||||
bounded candidate pool of up to twice that budget and never more than 16 frames.
|
||||
The requested cap is applied only after deduplication, with the first and final
|
||||
selected candidates preserved during final thinning when the budget is at least
|
||||
two. The versioned
|
||||
`grayscale-16x16-mean-cells-v2` policy uses the larger of mean luma delta and
|
||||
the ratio of thumbnail cells whose normalized delta is at least 0.05. The
|
||||
duplicate threshold is the constant 0.04, chosen for predictability rather than
|
||||
exposed as a runtime setting. This secondary
|
||||
high-contrast signal preserves small motion and visible-text changes that a
|
||||
mean-only comparison can hide. Comparator or decoder errors fail open and keep
|
||||
coverage. Output metadata separates extracted candidates, successfully used
|
||||
frames, and visual duplicates dropped.
|
||||
|
||||
An explicitly marked video part may request a timestamped contact sheet. The
|
||||
bridge builds at most a 4-column, 16-frame JPEG grid and labels the resulting
|
||||
@@ -401,7 +410,10 @@ instead of relabeling it as the requested routing plan. The whole-video result
|
||||
cache is keyed on every input that changes the output — prompt, effective
|
||||
model, sampling policy, frame count, focus window, `transcript`,
|
||||
`audioTranscript`, and the contact-sheet flag — so changing any of those
|
||||
dimensions is a cache miss, never a stale reuse.
|
||||
dimensions is a cache miss, never a stale reuse. The visual dedup policy
|
||||
version, threshold, and bounded candidate-frame count are also explicit in the
|
||||
result-cache key and metadata; a policy change therefore cannot reuse a stale
|
||||
whole-video description.
|
||||
|
||||
The guardrail extracts every supported video part but describes no more than
|
||||
`modalityBridgeVideoMaxVideos`. For a target proven to have
|
||||
|
||||
@@ -1,24 +1,78 @@
|
||||
/**
|
||||
* Video Bridge benchmarks (VB-FU-07 sampler overhead + VB-FU-09 contact sheet A/B).
|
||||
* Video Bridge benchmarks (VB-FU-03 dedup comparator, VB-FU-07 sampler overhead,
|
||||
* and VB-FU-09 contact sheet A/B).
|
||||
*
|
||||
* Run: node --import tsx/esm scripts/perf/video-bridge-bench.ts
|
||||
*
|
||||
* 1. Sampler: measures the pure timestamp-selection cost of uniform vs
|
||||
* 1. Dedup: measures bounded CPU and process-memory observations for the
|
||||
* production 16x16 grayscale comparator over the hard 16-frame candidate cap.
|
||||
* 2. Sampler: measures the pure timestamp-selection cost of uniform vs
|
||||
* scene_aware vs segment_aware for growing scene-candidate counts. The
|
||||
* ffmpeg scene-detection pass is shared by both aware policies and is
|
||||
* I/O-bound, so the incremental policy cost is exactly this selection step.
|
||||
* 2. Contact sheet: composes synthetic JPEG frames into the timestamped grid
|
||||
* 3. Contact sheet: composes synthetic JPEG frames into the timestamped grid
|
||||
* and compares payload bytes + model calls against individual frames.
|
||||
*/
|
||||
import { performance } from "node:perf_hooks";
|
||||
|
||||
import { buildVideoContactSheet } from "../../src/lib/guardrails/videoBridgeContactSheet";
|
||||
import {
|
||||
compareVideoFramesByGrayscale,
|
||||
VIDEO_DEDUP_POLICY_VERSION,
|
||||
VIDEO_DEDUP_THRESHOLD,
|
||||
} from "../../src/lib/guardrails/videoBridgeHelpers";
|
||||
import {
|
||||
calculateSamplingDecision,
|
||||
type VideoSamplingPolicy,
|
||||
} from "../../src/lib/guardrails/videoBridgeRuntime";
|
||||
|
||||
const SAMPLER_ITERATIONS = 2_000;
|
||||
const DEDUP_FRAME_CAP = 16;
|
||||
const DEDUP_ITERATIONS = 10;
|
||||
|
||||
function mebibytes(bytes: number): string {
|
||||
return (bytes / (1024 * 1024)).toFixed(2);
|
||||
}
|
||||
|
||||
async function benchDedupComparator(): Promise<void> {
|
||||
const frames = await Promise.all(
|
||||
Array.from({ length: DEDUP_FRAME_CAP }, async (_unused, index) => ({
|
||||
dataUri: await syntheticJpegFrame(index, 1024, 576),
|
||||
timestampSeconds: index,
|
||||
}))
|
||||
);
|
||||
await compareVideoFramesByGrayscale(frames[0], frames[1]);
|
||||
const memoryBefore = process.memoryUsage();
|
||||
const maxRssBefore = process.resourceUsage().maxRSS * 1024;
|
||||
const cpuBefore = process.cpuUsage();
|
||||
const wallBefore = performance.now();
|
||||
let comparisons = 0;
|
||||
for (let iteration = 0; iteration < DEDUP_ITERATIONS; iteration++) {
|
||||
for (let index = 1; index < frames.length; index++) {
|
||||
await compareVideoFramesByGrayscale(frames[index - 1], frames[index]);
|
||||
comparisons += 1;
|
||||
}
|
||||
}
|
||||
const wallMs = performance.now() - wallBefore;
|
||||
const cpu = process.cpuUsage(cpuBefore);
|
||||
const memoryAfter = process.memoryUsage();
|
||||
const maxRssAfter = process.resourceUsage().maxRSS * 1024;
|
||||
const cpuMs = (cpu.user + cpu.system) / 1000;
|
||||
|
||||
console.log("== Visual dedup comparator (synthetic 1024x576 JPEG, bounded) ==");
|
||||
console.log(
|
||||
`policy=${VIDEO_DEDUP_POLICY_VERSION} threshold=${VIDEO_DEDUP_THRESHOLD} frames=${DEDUP_FRAME_CAP} iterations=${DEDUP_ITERATIONS} comparisons=${comparisons}`
|
||||
);
|
||||
console.log(
|
||||
`wall_ms=${wallMs.toFixed(1)} cpu_ms=${cpuMs.toFixed(1)} cpu_ms/comparison=${(cpuMs / comparisons).toFixed(3)}`
|
||||
);
|
||||
console.log(
|
||||
`rss_delta_MiB=${mebibytes(memoryAfter.rss - memoryBefore.rss)} heap_delta_MiB=${mebibytes(memoryAfter.heapUsed - memoryBefore.heapUsed)} max_rss_delta_MiB=${mebibytes(Math.max(0, maxRssAfter - maxRssBefore))}`
|
||||
);
|
||||
console.log(
|
||||
"Scope: comparator decode/resize/delta cost only; this does not measure caption-model quality."
|
||||
);
|
||||
}
|
||||
|
||||
function benchSampler(): void {
|
||||
console.log("== Sampler timestamp-selection cost (pure, per call) ==");
|
||||
@@ -47,12 +101,12 @@ function benchSampler(): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function syntheticJpegFrame(index: number): Promise<string> {
|
||||
async function syntheticJpegFrame(index: number, width = 512, height = 288): Promise<string> {
|
||||
const { default: sharp } = await import("sharp");
|
||||
const buffer = await sharp({
|
||||
create: {
|
||||
width: 512,
|
||||
height: 288,
|
||||
width,
|
||||
height,
|
||||
channels: 3,
|
||||
background: { r: (index * 37) % 255, g: (index * 91) % 255, b: (index * 53) % 255 },
|
||||
},
|
||||
@@ -86,5 +140,7 @@ async function benchContactSheet(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
await benchDedupComparator();
|
||||
console.log("");
|
||||
benchSampler();
|
||||
await benchContactSheet();
|
||||
|
||||
@@ -11,6 +11,9 @@ import type { VisionBridgeRuntimeSettings } from "@/shared/constants/modalityBri
|
||||
|
||||
export interface BridgeCacheKeyOptions {
|
||||
kind?: string;
|
||||
dedupCandidateFrameCount?: number;
|
||||
dedupPolicyVersion?: string;
|
||||
dedupThreshold?: number;
|
||||
extractorVersion?: string;
|
||||
policyVersion?: string;
|
||||
strategy?: string;
|
||||
@@ -38,6 +41,9 @@ export function bridgeCacheKey(
|
||||
kind: options.kind ?? "media-frame",
|
||||
model,
|
||||
prompt,
|
||||
dedupCandidateFrameCount: options.dedupCandidateFrameCount,
|
||||
dedupPolicyVersion: options.dedupPolicyVersion,
|
||||
dedupThreshold: options.dedupThreshold,
|
||||
policyVersion: options.policyVersion,
|
||||
extractorVersion: options.extractorVersion,
|
||||
strategy: options.strategy,
|
||||
@@ -55,6 +61,8 @@ export function bridgeCacheKey(
|
||||
|
||||
export interface BridgeCacheOptions {
|
||||
maxEntries: number;
|
||||
/** Aggregate UTF-8 key/value/metadata budget; unlimited when omitted. */
|
||||
maxBytes?: number;
|
||||
ttlMs: number;
|
||||
/** Injectable clock for tests. */
|
||||
now?: () => number;
|
||||
@@ -67,8 +75,37 @@ export interface BridgeCacheEntry {
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class BridgeCache {
|
||||
private readonly entries = new Map<string, { entry: BridgeCacheEntry; expiresAt: number }>();
|
||||
/** Minimal fail-open store contract accepted by complete-result bridge caches. */
|
||||
export interface BridgeCacheStore {
|
||||
delete(key: string): void;
|
||||
getEntry(key: string): BridgeCacheEntry | undefined;
|
||||
setEntry(key: string, entry: BridgeCacheEntry): void;
|
||||
}
|
||||
|
||||
type StoredBridgeCacheEntry = {
|
||||
bytes: number;
|
||||
entry: BridgeCacheEntry;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
function cacheEntryBytes(entry: BridgeCacheEntry): number {
|
||||
try {
|
||||
const metadata = JSON.stringify({
|
||||
metadata: entry.metadata,
|
||||
producerModel: entry.producerModel,
|
||||
});
|
||||
return Buffer.byteLength(entry.value, "utf8") + Buffer.byteLength(metadata, "utf8");
|
||||
} catch (error) {
|
||||
console.debug("[MODALITY_BRIDGE_CACHE] Entry size calculation failed open", {
|
||||
errorType: error instanceof Error ? error.name : typeof error,
|
||||
});
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
|
||||
export class BridgeCache implements BridgeCacheStore {
|
||||
private readonly entries = new Map<string, StoredBridgeCacheEntry>();
|
||||
private totalBytes = 0;
|
||||
|
||||
constructor(private readonly opts: BridgeCacheOptions) {}
|
||||
|
||||
@@ -81,7 +118,7 @@ export class BridgeCache {
|
||||
if (!hit) return undefined;
|
||||
const now = (this.opts.now ?? Date.now)();
|
||||
if (hit.expiresAt <= now) {
|
||||
this.entries.delete(key);
|
||||
this.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
// Map preserves insertion order — re-insert to mark as most-recently-used.
|
||||
@@ -96,12 +133,17 @@ export class BridgeCache {
|
||||
|
||||
setEntry(key: string, entry: BridgeCacheEntry): void {
|
||||
const now = (this.opts.now ?? Date.now)();
|
||||
this.entries.delete(key);
|
||||
this.entries.set(key, { entry, expiresAt: now + this.opts.ttlMs });
|
||||
while (this.entries.size > this.opts.maxEntries) {
|
||||
const bytes = cacheEntryBytes(entry) + Buffer.byteLength(key, "utf8");
|
||||
const maxBytes = Math.max(0, this.opts.maxBytes ?? Number.POSITIVE_INFINITY);
|
||||
const maxEntries = Math.max(0, Math.floor(this.opts.maxEntries));
|
||||
this.delete(key);
|
||||
if (!Number.isFinite(bytes) || bytes > maxBytes || maxEntries === 0) return;
|
||||
this.entries.set(key, { bytes, entry, expiresAt: now + this.opts.ttlMs });
|
||||
this.totalBytes += bytes;
|
||||
while (this.entries.size > maxEntries || this.totalBytes > maxBytes) {
|
||||
const oldest = this.entries.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
this.entries.delete(oldest);
|
||||
this.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,21 +151,52 @@ export class BridgeCache {
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
/** Current aggregate UTF-8 bytes retained by this cache. */
|
||||
get bytes(): number {
|
||||
return this.totalBytes;
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
const existing = this.entries.get(key);
|
||||
if (existing) this.totalBytes = Math.max(0, this.totalBytes - existing.bytes);
|
||||
this.entries.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear();
|
||||
this.totalBytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Process-wide singleton used by the bridges; recreated when config changes. */
|
||||
let shared: { cache: BridgeCache; ttlMs: number; maxEntries: number } | null = null;
|
||||
let shared: { cache: BridgeCache; ttlMs: number; maxBytes: number; maxEntries: number } | null =
|
||||
null;
|
||||
|
||||
export function getSharedBridgeCache(ttlMs: number, maxEntries: number): BridgeCache {
|
||||
if (!shared || shared.ttlMs !== ttlMs || shared.maxEntries !== maxEntries) {
|
||||
shared = { cache: new BridgeCache({ maxEntries, ttlMs }), ttlMs, maxEntries };
|
||||
/**
|
||||
* Resolve the process-wide bridge cache, recreating it when any bound changes.
|
||||
*
|
||||
* @param ttlMs - Entry lifetime in milliseconds.
|
||||
* @param maxEntries - Maximum retained entry count.
|
||||
* @param maxBytes - Aggregate UTF-8 storage budget.
|
||||
* @returns The process-wide cache for these exact bounds.
|
||||
*/
|
||||
export function getSharedBridgeCache(
|
||||
ttlMs: number,
|
||||
maxEntries: number,
|
||||
maxBytes = Number.POSITIVE_INFINITY
|
||||
): BridgeCache {
|
||||
if (
|
||||
!shared ||
|
||||
shared.ttlMs !== ttlMs ||
|
||||
shared.maxEntries !== maxEntries ||
|
||||
shared.maxBytes !== maxBytes
|
||||
) {
|
||||
shared = {
|
||||
cache: new BridgeCache({ maxBytes, maxEntries, ttlMs }),
|
||||
ttlMs,
|
||||
maxBytes,
|
||||
maxEntries,
|
||||
};
|
||||
}
|
||||
return shared.cache;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface BridgeModalityStats {
|
||||
resultCacheBytes: number;
|
||||
resultCacheHits: number;
|
||||
resultCacheLatencyMs: number;
|
||||
/** Requests that joined an in-flight complete result instead of hitting the persistent cache. */
|
||||
resultSingleflightCoalesced: number;
|
||||
failures: number;
|
||||
/** Audio/video fusion runs (video bridge only; 0 for other modalities). */
|
||||
fusionRuns: number;
|
||||
@@ -47,6 +49,7 @@ function emptyStats(): BridgeModalityStats {
|
||||
resultCacheBytes: 0,
|
||||
resultCacheHits: 0,
|
||||
resultCacheLatencyMs: 0,
|
||||
resultSingleflightCoalesced: 0,
|
||||
failures: 0,
|
||||
fusionRuns: 0,
|
||||
fusionPartials: 0,
|
||||
@@ -69,6 +72,8 @@ export function recordBridgeUse(
|
||||
resultCacheBytes?: number;
|
||||
resultCacheHit?: boolean;
|
||||
resultCacheLatencyMs?: number;
|
||||
/** True only when this request joined existing in-flight result work. */
|
||||
resultSingleflightCoalesced?: boolean;
|
||||
} = {}
|
||||
): void {
|
||||
const s = stats[kind];
|
||||
@@ -104,6 +109,7 @@ export function recordBridgeUse(
|
||||
s.resultCacheLatencyMs += Math.max(0, opts.resultCacheLatencyMs);
|
||||
}
|
||||
}
|
||||
if (opts.resultSingleflightCoalesced) s.resultSingleflightCoalesced += 1;
|
||||
if (typeof opts.latencyMs === "number" && Number.isFinite(opts.latencyMs)) {
|
||||
s.totalLatencyMs += Math.max(0, opts.latencyMs);
|
||||
s.latencySamples += 1;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { fetch as undiciFetch } from "undici";
|
||||
|
||||
import { getSettings as defaultGetSettings } from "@/lib/db/settings";
|
||||
@@ -8,18 +10,38 @@ import {
|
||||
} from "@/shared/constants/modalityBridgeDefaults";
|
||||
|
||||
import { BaseGuardrail, type GuardrailContext, type GuardrailResult } from "./base";
|
||||
import { bridgeCacheKey, getSharedBridgeCacheFor } from "./modalityBridge/bridgeCache";
|
||||
import {
|
||||
bridgeCacheKey,
|
||||
getSharedBridgeCacheFor,
|
||||
type BridgeCacheEntry,
|
||||
type BridgeCacheStore,
|
||||
} from "./modalityBridge/bridgeCache";
|
||||
import { recordBridgeUse } from "./modalityBridge/bridgeStats";
|
||||
import {
|
||||
describeVideoPart as defaultDescribeVideoPart,
|
||||
extractVideoParts,
|
||||
formatVideoTimestamp,
|
||||
loadVideoPartBytes,
|
||||
replaceVideoParts,
|
||||
resolveVideoDedupCandidateFrameCount,
|
||||
VIDEO_BRIDGE_MAX_BYTES,
|
||||
VIDEO_DEDUP_MAX_CANDIDATE_FRAMES,
|
||||
VIDEO_DEDUP_POLICY_VERSION,
|
||||
VIDEO_DEDUP_THRESHOLD,
|
||||
type DescribeVideoDependencies,
|
||||
type DescribedVideo,
|
||||
type VideoFusionTelemetry,
|
||||
type VideoPart,
|
||||
} from "./videoBridgeHelpers";
|
||||
import {
|
||||
getSharedVideoResultCacheFor,
|
||||
runVideoDownloadSingleflight,
|
||||
runVideoResultSingleflight,
|
||||
safeDeleteCacheEntry,
|
||||
safeGetCacheEntry,
|
||||
safeSetCacheEntry,
|
||||
videoBridgeAbortError,
|
||||
} from "./videoBridgeResultCache";
|
||||
import {
|
||||
callVisionModel as defaultCallVisionModel,
|
||||
type VisionModelConfig,
|
||||
@@ -48,9 +70,62 @@ function safeTranscriptFingerprint(value: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
const VIDEO_BRIDGE_RESULT_CACHE_VERSION = "v2";
|
||||
const VIDEO_BRIDGE_RESULT_CACHE_POLICY = "default";
|
||||
const VIDEO_BRIDGE_RESULT_CACHE_KEY_KIND = "video-result-v2";
|
||||
function waitForVideoBridgePromise<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) return Promise.reject(videoBridgeAbortError());
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let completed = false;
|
||||
const finish = (callback: () => void): void => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
callback();
|
||||
};
|
||||
const onAbort = (): void => finish(() => reject(videoBridgeAbortError()));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal.aborted) onAbort();
|
||||
promise.then(
|
||||
(value) => finish(() => resolve(value)),
|
||||
(error: unknown) => finish(() => reject(error))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const VIDEO_BRIDGE_RESULT_CACHE_VERSION = "v4";
|
||||
const VIDEO_BRIDGE_RESULT_CACHE_POLICY = "sampling-then-dedup-v2";
|
||||
const VIDEO_BRIDGE_RESULT_CACHE_KEY_KIND = "video-result-v4";
|
||||
const VIDEO_BRIDGE_DOWNLOAD_FLIGHT_VERSION = "v1";
|
||||
|
||||
function buildVideoDownloadFlightKey(
|
||||
part: VideoPart,
|
||||
context: GuardrailContext,
|
||||
maxBytes: number,
|
||||
timeoutMs: number
|
||||
): string {
|
||||
const rawPrincipalId = context.apiKeyInfo?.id;
|
||||
const principalId =
|
||||
typeof rawPrincipalId === "string" || typeof rawPrincipalId === "number"
|
||||
? String(rawPrincipalId)
|
||||
: "local";
|
||||
const canonicalIdentity = JSON.stringify({
|
||||
container: part.container,
|
||||
endpoint: context.endpoint ?? null,
|
||||
maxBytes,
|
||||
method: context.method ?? null,
|
||||
model: context.model ?? null,
|
||||
provider: context.provider ?? null,
|
||||
ref: part.ref,
|
||||
shape: part.shape,
|
||||
sourceFormat: context.sourceFormat ?? null,
|
||||
targetFormat: context.targetFormat ?? null,
|
||||
timeoutMs,
|
||||
version: VIDEO_BRIDGE_DOWNLOAD_FLIGHT_VERSION,
|
||||
});
|
||||
const requestFingerprint = createHash("sha256").update(canonicalIdentity).digest("hex");
|
||||
// The authenticated database id is an ephemeral in-memory scope, not a
|
||||
// password or persisted credential. Keep it out of cryptographic hashes so
|
||||
// password-hash analysis cannot conflate tenant partitioning with storage.
|
||||
return `video-download:${JSON.stringify([principalId, requestFingerprint])}`;
|
||||
}
|
||||
|
||||
interface VideoResultCacheMetadata {
|
||||
cacheVersion: string;
|
||||
@@ -61,6 +136,9 @@ interface VideoResultCacheMetadata {
|
||||
prompt: string;
|
||||
frameCount: number;
|
||||
maxVideos: number;
|
||||
dedupCandidateFrameCount: number;
|
||||
dedupPolicyVersion: string;
|
||||
dedupThreshold: number;
|
||||
durationSeconds: number;
|
||||
framesRequested: number;
|
||||
framesExtracted: number;
|
||||
@@ -78,6 +156,86 @@ interface VideoResultCacheMetadata {
|
||||
modelUsed: string;
|
||||
}
|
||||
|
||||
type VideoResultCacheIdentity = Pick<
|
||||
VideoResultCacheMetadata,
|
||||
| "cacheVersion"
|
||||
| "dedupCandidateFrameCount"
|
||||
| "dedupPolicyVersion"
|
||||
| "dedupThreshold"
|
||||
| "extractorVersion"
|
||||
| "frameCount"
|
||||
| "maxVideos"
|
||||
| "model"
|
||||
| "policyVersion"
|
||||
| "prompt"
|
||||
| "strategy"
|
||||
>;
|
||||
|
||||
const VIDEO_RESULT_CACHE_IDENTITY_KEYS: readonly (keyof VideoResultCacheIdentity)[] = [
|
||||
"cacheVersion",
|
||||
"dedupCandidateFrameCount",
|
||||
"dedupPolicyVersion",
|
||||
"dedupThreshold",
|
||||
"extractorVersion",
|
||||
"frameCount",
|
||||
"maxVideos",
|
||||
"model",
|
||||
"policyVersion",
|
||||
"prompt",
|
||||
"strategy",
|
||||
];
|
||||
|
||||
function createVideoResultCacheIdentity(
|
||||
runtime: ReturnType<typeof resolveVideoBridgeRuntimeSettings>,
|
||||
visionRuntime: ReturnType<typeof resolveVisionBridgeRuntimeSettings>,
|
||||
model: string
|
||||
): VideoResultCacheIdentity {
|
||||
return {
|
||||
cacheVersion: VIDEO_BRIDGE_RESULT_CACHE_VERSION,
|
||||
dedupCandidateFrameCount: resolveVideoDedupCandidateFrameCount(runtime.frameCount),
|
||||
dedupPolicyVersion: VIDEO_DEDUP_POLICY_VERSION,
|
||||
dedupThreshold: VIDEO_DEDUP_THRESHOLD,
|
||||
extractorVersion: VIDEO_BRIDGE_RESULT_CACHE_VERSION,
|
||||
frameCount: runtime.frameCount,
|
||||
maxVideos: runtime.maxVideos,
|
||||
model,
|
||||
policyVersion: VIDEO_BRIDGE_RESULT_CACHE_POLICY,
|
||||
prompt: visionRuntime.prompt,
|
||||
strategy: runtime.samplingPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
function buildVideoResultCacheKey(
|
||||
contentFingerprint: string,
|
||||
identity: VideoResultCacheIdentity,
|
||||
part: VideoPart
|
||||
): string {
|
||||
return bridgeCacheKey(contentFingerprint, identity.prompt, identity.model, {
|
||||
kind: VIDEO_BRIDGE_RESULT_CACHE_KEY_KIND,
|
||||
dedupCandidateFrameCount: identity.dedupCandidateFrameCount,
|
||||
dedupPolicyVersion: identity.dedupPolicyVersion,
|
||||
dedupThreshold: identity.dedupThreshold,
|
||||
extractorVersion: identity.extractorVersion,
|
||||
policyVersion: identity.policyVersion,
|
||||
strategy: identity.strategy,
|
||||
frameCount: identity.frameCount,
|
||||
maxVideos: identity.maxVideos,
|
||||
focusEndSeconds: part.focusWindow?.endSeconds ?? null,
|
||||
focusStartSeconds: part.focusWindow?.startSeconds ?? null,
|
||||
transcript: safeTranscriptFingerprint(part.transcript),
|
||||
audioTranscript: safeTranscriptFingerprint(part.audioTranscript),
|
||||
contactSheet: part.contactSheet ?? false,
|
||||
version: identity.cacheVersion,
|
||||
});
|
||||
}
|
||||
|
||||
function matchesVideoResultCacheIdentity(
|
||||
metadata: VideoResultCacheMetadata,
|
||||
identity: VideoResultCacheIdentity
|
||||
): boolean {
|
||||
return VIDEO_RESULT_CACHE_IDENTITY_KEYS.every((key) => metadata[key] === identity[key]);
|
||||
}
|
||||
|
||||
function isFusionTelemetry(value: unknown): value is VideoFusionTelemetry {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
@@ -102,6 +260,8 @@ export interface VideoBridgeDependencies {
|
||||
getCapabilities?: (model: string) => { supportsVideo: boolean | null };
|
||||
describePart?: (part: VideoPart) => Promise<DescribedVideo>;
|
||||
extractFrames?: DescribeVideoDependencies["extractFrames"];
|
||||
fetchRemote?: DescribeVideoDependencies["fetchRemote"];
|
||||
resultCache?: BridgeCacheStore;
|
||||
selectVisionModel?: (fixedModel?: string) => Promise<string | null>;
|
||||
callVisionModel?: (
|
||||
imageDataUri: string,
|
||||
@@ -110,28 +270,70 @@ export interface VideoBridgeDependencies {
|
||||
) => Promise<string>;
|
||||
}
|
||||
|
||||
function isVideoResultCacheMetadata(value: unknown): value is VideoResultCacheMetadata {
|
||||
function isFiniteNonNegativeNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function isFiniteNonNegativeInteger(value: unknown): value is number {
|
||||
return isFiniteNonNegativeNumber(value) && Number.isInteger(value);
|
||||
}
|
||||
|
||||
function isVideoResultCacheMetadata(
|
||||
value: unknown,
|
||||
expectedCacheBytes: number
|
||||
): value is VideoResultCacheMetadata {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
!isFiniteNonNegativeInteger(record.framesRequested) ||
|
||||
!isFiniteNonNegativeInteger(record.framesExtracted) ||
|
||||
!isFiniteNonNegativeInteger(record.framesUsed) ||
|
||||
!isFiniteNonNegativeInteger(record.dedupCandidateFrameCount) ||
|
||||
record.dedupCandidateFrameCount < 1 ||
|
||||
record.dedupCandidateFrameCount > VIDEO_DEDUP_MAX_CANDIDATE_FRAMES ||
|
||||
record.framesExtracted > record.dedupCandidateFrameCount ||
|
||||
record.framesUsed > record.framesRequested ||
|
||||
record.framesUsed > record.framesExtracted
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const dedupDropped = record.dedupDropped ?? 0;
|
||||
if (
|
||||
!isFiniteNonNegativeInteger(dedupDropped) ||
|
||||
record.framesUsed + dedupDropped > record.framesExtracted
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(record.focusStartSeconds !== undefined &&
|
||||
!isFiniteNonNegativeNumber(record.focusStartSeconds)) ||
|
||||
(record.focusEndSeconds !== undefined && !isFiniteNonNegativeNumber(record.focusEndSeconds)) ||
|
||||
(typeof record.focusStartSeconds === "number" &&
|
||||
typeof record.focusEndSeconds === "number" &&
|
||||
record.focusStartSeconds > record.focusEndSeconds)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
typeof record.cacheVersion === "string" &&
|
||||
typeof record.dedupPolicyVersion === "string" &&
|
||||
typeof record.dedupThreshold === "number" &&
|
||||
Number.isFinite(record.dedupThreshold) &&
|
||||
record.dedupThreshold >= 0 &&
|
||||
record.dedupThreshold <= 1 &&
|
||||
typeof record.policyVersion === "string" &&
|
||||
typeof record.extractorVersion === "string" &&
|
||||
typeof record.strategy === "string" &&
|
||||
typeof record.model === "string" &&
|
||||
typeof record.prompt === "string" &&
|
||||
typeof record.frameCount === "number" &&
|
||||
typeof record.maxVideos === "number" &&
|
||||
typeof record.durationSeconds === "number" &&
|
||||
typeof record.framesRequested === "number" &&
|
||||
typeof record.framesExtracted === "number" &&
|
||||
typeof record.framesUsed === "number" &&
|
||||
(record.dedupDropped === undefined ||
|
||||
(typeof record.dedupDropped === "number" && record.dedupDropped >= 0)) &&
|
||||
typeof record.cacheBytes === "number" &&
|
||||
isFiniteNonNegativeInteger(record.frameCount) &&
|
||||
isFiniteNonNegativeInteger(record.maxVideos) &&
|
||||
isFiniteNonNegativeNumber(record.durationSeconds) &&
|
||||
isFiniteNonNegativeInteger(record.cacheBytes) &&
|
||||
record.cacheBytes === expectedCacheBytes &&
|
||||
typeof record.modelUsed === "string" &&
|
||||
(record.samplingCandidateCount === undefined ||
|
||||
(typeof record.samplingCandidateCount === "number" && record.samplingCandidateCount >= 0)) &&
|
||||
isFiniteNonNegativeInteger(record.samplingCandidateCount)) &&
|
||||
(record.samplingPolicyEffective === undefined ||
|
||||
record.samplingPolicyEffective === "uniform" ||
|
||||
record.samplingPolicyEffective === "scene_aware" ||
|
||||
@@ -141,12 +343,22 @@ function isVideoResultCacheMetadata(value: unknown): value is VideoResultCacheMe
|
||||
record.samplingPolicyRequested === "scene_aware" ||
|
||||
record.samplingPolicyRequested === "segment_aware") &&
|
||||
(record.transcriptCuesApplied === undefined ||
|
||||
(typeof record.transcriptCuesApplied === "number" && record.transcriptCuesApplied >= 0)) &&
|
||||
isFiniteNonNegativeInteger(record.transcriptCuesApplied)) &&
|
||||
(record.contactSheetUsed === undefined || typeof record.contactSheetUsed === "boolean") &&
|
||||
(record.fusion === undefined || isFusionTelemetry(record.fusion))
|
||||
);
|
||||
}
|
||||
|
||||
function isVideoResultCacheEntry(
|
||||
entry: BridgeCacheEntry
|
||||
): entry is BridgeCacheEntry & { metadata: VideoResultCacheMetadata; value: string } {
|
||||
if (typeof entry.value !== "string") return false;
|
||||
return (
|
||||
(entry.producerModel === undefined || typeof entry.producerModel === "string") &&
|
||||
isVideoResultCacheMetadata(entry.metadata, Buffer.byteLength(entry.value, "utf8"))
|
||||
);
|
||||
}
|
||||
|
||||
export class VideoBridgeGuardrail extends BaseGuardrail {
|
||||
name = "video-bridge";
|
||||
priority = 7;
|
||||
@@ -188,7 +400,9 @@ export class VideoBridgeGuardrail extends BaseGuardrail {
|
||||
const visionRuntime = resolveVisionBridgeRuntimeSettings(persisted);
|
||||
const configuredModel = runtime.model.trim() || visionRuntime.model.trim();
|
||||
const routingPlanModel = configuredModel || "auto";
|
||||
const cache = runtime.cacheEnabled ? getSharedBridgeCacheFor(runtime) : null;
|
||||
const cache = runtime.cacheEnabled
|
||||
? (this.deps.resultCache ?? getSharedVideoResultCacheFor(runtime))
|
||||
: null;
|
||||
const successfulModels = new Set<string>();
|
||||
let selectedModelPromise: Promise<string | null> | null = null;
|
||||
const selectVideoModel = (): Promise<string | null> => {
|
||||
@@ -231,37 +445,62 @@ export class VideoBridgeGuardrail extends BaseGuardrail {
|
||||
if (context.signal?.aborted) throw new Error("Video Bridge processing was aborted");
|
||||
const part = attemptedParts[index];
|
||||
const attemptStartedAt = Date.now();
|
||||
const timeoutController = new AbortController();
|
||||
const attemptTimeout = setTimeout(() => timeoutController.abort(), runtime.timeoutMs);
|
||||
const attemptSignal = context.signal
|
||||
? AbortSignal.any([context.signal, timeoutController.signal])
|
||||
: timeoutController.signal;
|
||||
try {
|
||||
const selectedModel = await selectVideoModel();
|
||||
const resultCacheKey =
|
||||
const selectedModel = await waitForVideoBridgePromise(selectVideoModel(), attemptSignal);
|
||||
if (attemptSignal.aborted) throw videoBridgeAbortError();
|
||||
const shouldLoadVideoBytes =
|
||||
Boolean(selectedModel) &&
|
||||
(Boolean(cache) || (part.ref.startsWith("https://") && !this.deps.describePart));
|
||||
const videoBytes = shouldLoadVideoBytes
|
||||
? part.ref.startsWith("https://")
|
||||
? await runVideoDownloadSingleflight(
|
||||
buildVideoDownloadFlightKey(
|
||||
part,
|
||||
context,
|
||||
VIDEO_BRIDGE_MAX_BYTES,
|
||||
runtime.timeoutMs
|
||||
),
|
||||
attemptSignal,
|
||||
(downloadSignal) =>
|
||||
loadVideoPartBytes(
|
||||
part,
|
||||
VIDEO_BRIDGE_MAX_BYTES,
|
||||
runtime.timeoutMs,
|
||||
downloadSignal,
|
||||
{ fetchRemote: this.deps.fetchRemote }
|
||||
)
|
||||
)
|
||||
: await loadVideoPartBytes(
|
||||
part,
|
||||
VIDEO_BRIDGE_MAX_BYTES,
|
||||
runtime.timeoutMs,
|
||||
attemptSignal,
|
||||
{ fetchRemote: this.deps.fetchRemote }
|
||||
)
|
||||
: null;
|
||||
const contentFingerprint =
|
||||
cache && videoBytes
|
||||
? `sha256:${createHash("sha256").update(videoBytes).digest("hex")}`
|
||||
: part.ref;
|
||||
const resultCacheIdentity =
|
||||
cache && selectedModel
|
||||
? bridgeCacheKey(part.ref, visionRuntime.prompt, selectedModel, {
|
||||
kind: VIDEO_BRIDGE_RESULT_CACHE_KEY_KIND,
|
||||
extractorVersion: VIDEO_BRIDGE_RESULT_CACHE_VERSION,
|
||||
policyVersion: VIDEO_BRIDGE_RESULT_CACHE_POLICY,
|
||||
strategy: runtime.samplingPolicy,
|
||||
frameCount: runtime.frameCount,
|
||||
maxVideos: runtime.maxVideos,
|
||||
focusEndSeconds: part.focusWindow?.endSeconds ?? null,
|
||||
focusStartSeconds: part.focusWindow?.startSeconds ?? null,
|
||||
transcript: safeTranscriptFingerprint(part.transcript),
|
||||
audioTranscript: safeTranscriptFingerprint(part.audioTranscript),
|
||||
contactSheet: part.contactSheet ?? false,
|
||||
version: VIDEO_BRIDGE_RESULT_CACHE_VERSION,
|
||||
})
|
||||
? createVideoResultCacheIdentity(runtime, visionRuntime, selectedModel)
|
||||
: null;
|
||||
const cachedResult = resultCacheKey ? cache.getEntry(resultCacheKey) : null;
|
||||
if (cachedResult && isVideoResultCacheMetadata(cachedResult.metadata)) {
|
||||
const resultCacheKey = resultCacheIdentity
|
||||
? buildVideoResultCacheKey(contentFingerprint, resultCacheIdentity, part)
|
||||
: null;
|
||||
const cachedResult = resultCacheKey
|
||||
? safeGetCacheEntry(cache, resultCacheKey, context.log)
|
||||
: null;
|
||||
if (cachedResult && isVideoResultCacheEntry(cachedResult)) {
|
||||
const meta = cachedResult.metadata;
|
||||
const matchPolicy =
|
||||
meta.cacheVersion === VIDEO_BRIDGE_RESULT_CACHE_VERSION &&
|
||||
meta.policyVersion === VIDEO_BRIDGE_RESULT_CACHE_POLICY &&
|
||||
meta.extractorVersion === VIDEO_BRIDGE_RESULT_CACHE_VERSION &&
|
||||
meta.strategy === runtime.samplingPolicy &&
|
||||
meta.frameCount === runtime.frameCount &&
|
||||
meta.maxVideos === runtime.maxVideos &&
|
||||
meta.model === selectedModel &&
|
||||
meta.prompt === visionRuntime.prompt;
|
||||
resultCacheIdentity && matchesVideoResultCacheIdentity(meta, resultCacheIdentity);
|
||||
if (matchPolicy) {
|
||||
const elapsed = Date.now() - attemptStartedAt;
|
||||
descriptions.push(cachedResult.value);
|
||||
@@ -299,21 +538,60 @@ export class VideoBridgeGuardrail extends BaseGuardrail {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
cache.delete(resultCacheKey);
|
||||
safeDeleteCacheEntry(cache, resultCacheKey, context.log);
|
||||
} else if (cachedResult) {
|
||||
cache.delete(resultCacheKey);
|
||||
safeDeleteCacheEntry(cache, resultCacheKey, context.log);
|
||||
}
|
||||
const cacheStartAt = Date.now();
|
||||
const described = this.deps.describePart
|
||||
? await this.deps.describePart(part)
|
||||
: await this.describeWithVisionModel(
|
||||
part,
|
||||
runtime,
|
||||
visionRuntime,
|
||||
selectedModel,
|
||||
context.signal
|
||||
const describeAndCache = async (processingSignal: AbortSignal) => {
|
||||
const described = this.deps.describePart
|
||||
? await this.deps.describePart(part)
|
||||
: await this.describeWithVisionModel(
|
||||
part,
|
||||
runtime,
|
||||
visionRuntime,
|
||||
selectedModel,
|
||||
processingSignal,
|
||||
videoBytes ?? undefined
|
||||
);
|
||||
if (processingSignal.aborted) throw videoBridgeAbortError();
|
||||
const resultCacheBytes = Buffer.byteLength(described.description, "utf8");
|
||||
if (resultCacheKey && resultCacheIdentity) {
|
||||
safeSetCacheEntry(
|
||||
cache,
|
||||
resultCacheKey,
|
||||
{
|
||||
value: described.description,
|
||||
producerModel: described.modelUsed ?? resultCacheIdentity.model,
|
||||
metadata: {
|
||||
...resultCacheIdentity,
|
||||
durationSeconds: described.durationSeconds,
|
||||
framesRequested: described.framesRequested,
|
||||
framesExtracted: described.framesExtracted ?? described.framesUsed,
|
||||
framesUsed: described.framesUsed,
|
||||
dedupDropped: described.dedupDropped ?? 0,
|
||||
focusEndSeconds: described.focusWindow?.endSeconds,
|
||||
focusStartSeconds: described.focusWindow?.startSeconds,
|
||||
cacheBytes: resultCacheBytes,
|
||||
modelUsed: described.modelUsed ?? resultCacheIdentity.model,
|
||||
samplingCandidateCount: described.sampling?.candidateCount ?? 0,
|
||||
samplingPolicyEffective: described.sampling?.policyEffective ?? "uniform",
|
||||
samplingPolicyRequested:
|
||||
described.sampling?.policyRequested ?? runtime.samplingPolicy,
|
||||
transcriptCuesApplied: described.transcriptCues?.length ?? 0,
|
||||
contactSheetUsed: described.contactSheetUsed ?? false,
|
||||
...(described.fusion ? { fusion: described.fusion } : {}),
|
||||
},
|
||||
},
|
||||
context.log
|
||||
);
|
||||
if (context.signal?.aborted) throw new Error("Video Bridge processing was aborted");
|
||||
}
|
||||
return described;
|
||||
};
|
||||
const resolved =
|
||||
resultCacheKey && selectedModel
|
||||
? await runVideoResultSingleflight(resultCacheKey, attemptSignal, describeAndCache)
|
||||
: { coalesced: false, value: await describeAndCache(attemptSignal) };
|
||||
const described = resolved.value;
|
||||
if (described.modelUsed) successfulModels.add(described.modelUsed);
|
||||
const videoCacheHits = described.cacheHits ?? 0;
|
||||
const processingLatencyMs = Date.now() - attemptStartedAt;
|
||||
@@ -336,46 +614,12 @@ export class VideoBridgeGuardrail extends BaseGuardrail {
|
||||
}
|
||||
totalCacheHits += videoCacheHits;
|
||||
if (resultCacheKey && selectedModel) {
|
||||
const resultCacheBytes = Buffer.byteLength(described.description, "utf8");
|
||||
const cacheLatencyMs = Date.now() - cacheStartAt;
|
||||
cache.setEntry(resultCacheKey, {
|
||||
value: described.description,
|
||||
producerModel: described.modelUsed ?? selectedModel,
|
||||
metadata: {
|
||||
cacheVersion: VIDEO_BRIDGE_RESULT_CACHE_VERSION,
|
||||
policyVersion: VIDEO_BRIDGE_RESULT_CACHE_POLICY,
|
||||
extractorVersion: VIDEO_BRIDGE_RESULT_CACHE_VERSION,
|
||||
strategy: runtime.samplingPolicy,
|
||||
model: selectedModel,
|
||||
prompt: visionRuntime.prompt,
|
||||
frameCount: runtime.frameCount,
|
||||
maxVideos: runtime.maxVideos,
|
||||
durationSeconds: described.durationSeconds,
|
||||
framesRequested: described.framesRequested,
|
||||
framesExtracted: described.framesExtracted ?? described.framesUsed,
|
||||
framesUsed: described.framesUsed,
|
||||
dedupDropped: described.dedupDropped ?? 0,
|
||||
focusEndSeconds: described.focusWindow?.endSeconds,
|
||||
focusStartSeconds: described.focusWindow?.startSeconds,
|
||||
cacheBytes: resultCacheBytes,
|
||||
modelUsed: described.modelUsed ?? selectedModel,
|
||||
samplingCandidateCount: described.sampling?.candidateCount ?? 0,
|
||||
samplingPolicyEffective: described.sampling?.policyEffective ?? "uniform",
|
||||
samplingPolicyRequested:
|
||||
described.sampling?.policyRequested ?? runtime.samplingPolicy,
|
||||
transcriptCuesApplied: described.transcriptCues?.length ?? 0,
|
||||
contactSheetUsed: described.contactSheetUsed ?? false,
|
||||
...(described.fusion ? { fusion: described.fusion } : {}),
|
||||
},
|
||||
});
|
||||
recordBridgeUse("video", {
|
||||
cacheHits: videoCacheHits,
|
||||
fusionRun: Boolean(described.fusion),
|
||||
fusionPartial: described.fusion?.partial ?? false,
|
||||
latencyMs: processingLatencyMs,
|
||||
resultCacheBytes,
|
||||
resultCacheHit: false,
|
||||
resultCacheLatencyMs: cacheLatencyMs,
|
||||
resultSingleflightCoalesced: resolved.coalesced,
|
||||
});
|
||||
} else {
|
||||
recordBridgeUse("video", {
|
||||
@@ -408,6 +652,8 @@ export class VideoBridgeGuardrail extends BaseGuardrail {
|
||||
? `[Video ${index + 1}]: (unavailable — video could not be described)`
|
||||
: null
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(attemptTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,7 +703,8 @@ export class VideoBridgeGuardrail extends BaseGuardrail {
|
||||
runtime: ReturnType<typeof resolveVideoBridgeRuntimeSettings>,
|
||||
visionRuntime: ReturnType<typeof resolveVisionBridgeRuntimeSettings>,
|
||||
selectedModel: string | null,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
preloadedBytes?: Uint8Array
|
||||
): Promise<DescribedVideo> {
|
||||
if (!selectedModel) {
|
||||
throw new Error("No vision-capable provider connected for Video Bridge");
|
||||
@@ -503,7 +750,11 @@ export class VideoBridgeGuardrail extends BaseGuardrail {
|
||||
if (key && cache) cache.setEntry(key, { value: caption, producerModel });
|
||||
return caption;
|
||||
},
|
||||
{ extractFrames: this.deps.extractFrames }
|
||||
{
|
||||
extractFrames: this.deps.extractFrames,
|
||||
fetchRemote: this.deps.fetchRemote,
|
||||
},
|
||||
preloadedBytes
|
||||
);
|
||||
return {
|
||||
...described,
|
||||
|
||||
@@ -274,40 +274,86 @@ export interface VideoFrameDeduplicationResult {
|
||||
|
||||
type VideoFrameComparator = (
|
||||
previous: VideoCaptionFrame,
|
||||
current: VideoCaptionFrame
|
||||
current: VideoCaptionFrame,
|
||||
signal?: AbortSignal
|
||||
) => Promise<number>;
|
||||
|
||||
const VIDEO_DEDUP_THRESHOLD = 0.04;
|
||||
export const VIDEO_DEDUP_POLICY_VERSION = "grayscale-16x16-mean-cells-v2";
|
||||
export const VIDEO_DEDUP_THRESHOLD = 0.04;
|
||||
const VIDEO_DEDUP_CELL_DELTA_THRESHOLD = 0.05;
|
||||
export const VIDEO_DEDUP_MAX_CANDIDATE_FRAMES = 16;
|
||||
|
||||
async function compareVideoFramesByGrayscale(
|
||||
/**
|
||||
* Expand a final caption budget into the bounded pool evaluated by visual deduplication.
|
||||
*
|
||||
* @param frameCount - Requested number of frames that may reach captioning.
|
||||
* @returns One candidate for a one-frame budget, otherwise twice the budget capped at 16.
|
||||
*/
|
||||
export function resolveVideoDedupCandidateFrameCount(frameCount: number): number {
|
||||
const normalizedFrameCount = Number.isFinite(frameCount) ? Math.floor(frameCount) : 1;
|
||||
const finalFrameCount = Math.max(
|
||||
1,
|
||||
Math.min(VIDEO_DEDUP_MAX_CANDIDATE_FRAMES, normalizedFrameCount)
|
||||
);
|
||||
if (finalFrameCount === 1) return 1;
|
||||
return Math.min(VIDEO_DEDUP_MAX_CANDIDATE_FRAMES, finalFrameCount * 2);
|
||||
}
|
||||
|
||||
function throwIfVideoDedupAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) throw new Error("Video Bridge processing timed out or was aborted");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare JPEG frames using the versioned 16x16 grayscale visual policy.
|
||||
*
|
||||
* @param previous - Last frame retained by deduplication.
|
||||
* @param current - Candidate frame being evaluated.
|
||||
* @param signal - Optional request cancellation signal checked around asynchronous image work.
|
||||
* @returns The larger of mean luma delta and the ratio of materially changed cells.
|
||||
* @throws When cancelled or when either frame cannot be decoded as a JPEG data URI.
|
||||
*/
|
||||
export async function compareVideoFramesByGrayscale(
|
||||
previous: VideoCaptionFrame,
|
||||
current: VideoCaptionFrame
|
||||
current: VideoCaptionFrame,
|
||||
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()
|
||||
)
|
||||
);
|
||||
throwIfVideoDedupAborted(signal);
|
||||
if (left.length !== right.length || left.length === 0) {
|
||||
throw new Error("Video frame comparison returned invalid dimensions");
|
||||
}
|
||||
let difference = 0;
|
||||
let changedCells = 0;
|
||||
for (let index = 0; index < left.length; index++) {
|
||||
difference += Math.abs(left[index] - right[index]) / 255;
|
||||
const cellDifference = Math.abs(left[index] - right[index]) / 255;
|
||||
difference += cellDifference;
|
||||
if (cellDifference >= VIDEO_DEDUP_CELL_DELTA_THRESHOLD) changedCells += 1;
|
||||
}
|
||||
return difference / left.length;
|
||||
return Math.max(difference / left.length, changedCells / left.length);
|
||||
}
|
||||
|
||||
export async function deduplicateVideoFrames(
|
||||
frames: readonly VideoCaptionFrame[],
|
||||
options: { compare?: VideoFrameComparator; threshold?: number } = {}
|
||||
options: {
|
||||
compare?: VideoFrameComparator;
|
||||
maxFrames?: number;
|
||||
signal?: AbortSignal;
|
||||
threshold?: number;
|
||||
} = {}
|
||||
): Promise<VideoFrameDeduplicationResult> {
|
||||
throwIfVideoDedupAborted(options.signal);
|
||||
if (frames.length < 2) return { dropped: 0, frames: [...frames] };
|
||||
const compare = options.compare ?? compareVideoFramesByGrayscale;
|
||||
const threshold =
|
||||
@@ -317,23 +363,37 @@ export async function deduplicateVideoFrames(
|
||||
const kept: VideoCaptionFrame[] = [frames[0]];
|
||||
let dropped = 0;
|
||||
for (let index = 1; index < frames.length; index++) {
|
||||
throwIfVideoDedupAborted(options.signal);
|
||||
const current = frames[index];
|
||||
if (index === frames.length - 1) {
|
||||
kept.push(current);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const distance = await compare(kept[kept.length - 1], current);
|
||||
const distance = await compare(kept[kept.length - 1], current, options.signal);
|
||||
throwIfVideoDedupAborted(options.signal);
|
||||
if (Number.isFinite(distance) && distance <= threshold) {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
throwIfVideoDedupAborted(options.signal);
|
||||
// A malformed or unsupported frame must never reduce visual coverage.
|
||||
}
|
||||
kept.push(current);
|
||||
}
|
||||
return { dropped, frames: kept };
|
||||
throwIfVideoDedupAborted(options.signal);
|
||||
const maxFrames =
|
||||
typeof options.maxFrames === "number" && Number.isFinite(options.maxFrames)
|
||||
? Math.max(1, Math.floor(options.maxFrames))
|
||||
: kept.length;
|
||||
if (kept.length <= maxFrames) return { dropped, frames: kept };
|
||||
if (maxFrames === 1) return { dropped, frames: [kept[0]] };
|
||||
const capped = Array.from({ length: maxFrames }, (_unused, index) => {
|
||||
const sourceIndex = Math.round((index * (kept.length - 1)) / (maxFrames - 1));
|
||||
return kept[sourceIndex];
|
||||
});
|
||||
return { dropped, frames: capped };
|
||||
}
|
||||
|
||||
function normalizeBase64(base64: string): string {
|
||||
@@ -372,7 +432,18 @@ export function decodeVideoDataUri(
|
||||
return decode(normalized);
|
||||
}
|
||||
|
||||
async function loadVideoBytes(
|
||||
/**
|
||||
* Load protected video bytes from an inline data URI or SSRF-guarded HTTPS source.
|
||||
*
|
||||
* @param part - Extracted request video part.
|
||||
* @param maxBytes - Maximum accepted decoded/downloaded size.
|
||||
* @param timeoutMs - Download deadline passed to the protected fetch boundary.
|
||||
* @param signal - Caller abort/deadline signal.
|
||||
* @param deps - Injectable external download boundary.
|
||||
* @returns Validated video bytes suitable for hashing and extraction.
|
||||
* @throws When the source, size, deadline, or abort policy rejects the input.
|
||||
*/
|
||||
export async function loadVideoPartBytes(
|
||||
part: VideoPart,
|
||||
maxBytes: number,
|
||||
timeoutMs: number,
|
||||
@@ -427,7 +498,8 @@ export async function describeVideoPart(
|
||||
timestampSeconds: number,
|
||||
signal: AbortSignal
|
||||
) => Promise<string>,
|
||||
deps: DescribeVideoDependencies = {}
|
||||
deps: DescribeVideoDependencies = {},
|
||||
preloadedBytes?: Uint8Array
|
||||
): Promise<DescribedVideo> {
|
||||
const timeoutController = new AbortController();
|
||||
const timeout = setTimeout(() => timeoutController.abort(), options.timeoutMs);
|
||||
@@ -435,23 +507,28 @@ export async function describeVideoPart(
|
||||
? AbortSignal.any([options.signal, timeoutController.signal])
|
||||
: timeoutController.signal;
|
||||
try {
|
||||
const bytes = await loadVideoBytes(
|
||||
part,
|
||||
options.maxBytes ?? VIDEO_BRIDGE_MAX_BYTES,
|
||||
options.timeoutMs,
|
||||
signal,
|
||||
deps
|
||||
);
|
||||
const maxBytes = options.maxBytes ?? VIDEO_BRIDGE_MAX_BYTES;
|
||||
const bytes = preloadedBytes
|
||||
? Buffer.isBuffer(preloadedBytes)
|
||||
? preloadedBytes
|
||||
: Buffer.from(preloadedBytes)
|
||||
: await loadVideoPartBytes(part, maxBytes, options.timeoutMs, signal, deps);
|
||||
if (signal.aborted) throw new Error("Video Bridge processing timed out or was aborted");
|
||||
if (bytes.byteLength > maxBytes) throw new Error("Video exceeds the maximum size");
|
||||
const extractFrames = deps.extractFrames ?? extractVideoFramesViaBroker;
|
||||
const candidateFrameCount = resolveVideoDedupCandidateFrameCount(options.frameCount);
|
||||
const extracted = await extractFrames(bytes, {
|
||||
focusWindow: options.focusWindow,
|
||||
frameCount: options.frameCount,
|
||||
frameCount: candidateFrameCount,
|
||||
samplingPolicy: options.samplingPolicy,
|
||||
signal,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
|
||||
const deduplicated = await deduplicateVideoFrames(extracted.frames);
|
||||
const deduplicated = await deduplicateVideoFrames(extracted.frames, {
|
||||
maxFrames: options.frameCount,
|
||||
signal,
|
||||
});
|
||||
const contactSheet = part.contactSheet
|
||||
? await buildVideoContactSheet(deduplicated.frames, {
|
||||
signal,
|
||||
|
||||
232
src/lib/guardrails/videoBridgeResultCache.ts
Normal file
232
src/lib/guardrails/videoBridgeResultCache.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import type { VideoBridgeRuntimeSettings } from "@/shared/constants/modalityBridgeDefaults";
|
||||
|
||||
import {
|
||||
BridgeCache,
|
||||
type BridgeCacheEntry,
|
||||
type BridgeCacheStore,
|
||||
} from "./modalityBridge/bridgeCache";
|
||||
import type { GuardrailContext } from "./base";
|
||||
|
||||
/** Aggregate in-memory budget for complete Video Bridge results. */
|
||||
export const VIDEO_RESULT_CACHE_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
let sharedResultCache: { cache: BridgeCache; maxEntries: number; ttlMs: number } | null = null;
|
||||
|
||||
/**
|
||||
* Resolve the process-wide complete-result cache for Video Bridge settings.
|
||||
*
|
||||
* @param settings - Runtime TTL and entry-count bounds.
|
||||
* @returns A cache isolated from the frame/caption bridge cache.
|
||||
*/
|
||||
export function getSharedVideoResultCacheFor(
|
||||
settings: Pick<VideoBridgeRuntimeSettings, "cacheTtlMinutes" | "cacheMaxEntries">
|
||||
): BridgeCache {
|
||||
const ttlMs = settings.cacheTtlMinutes * 60_000;
|
||||
if (
|
||||
!sharedResultCache ||
|
||||
sharedResultCache.ttlMs !== ttlMs ||
|
||||
sharedResultCache.maxEntries !== settings.cacheMaxEntries
|
||||
) {
|
||||
sharedResultCache = {
|
||||
cache: new BridgeCache({
|
||||
maxBytes: VIDEO_RESULT_CACHE_MAX_BYTES,
|
||||
maxEntries: settings.cacheMaxEntries,
|
||||
ttlMs,
|
||||
}),
|
||||
maxEntries: settings.cacheMaxEntries,
|
||||
ttlMs,
|
||||
};
|
||||
}
|
||||
return sharedResultCache.cache;
|
||||
}
|
||||
|
||||
interface VideoFlight {
|
||||
controller: AbortController;
|
||||
promise: Promise<unknown>;
|
||||
settled: boolean;
|
||||
waiters: number;
|
||||
}
|
||||
|
||||
const videoDownloadFlights = new Map<string, VideoFlight>();
|
||||
const videoResultFlights = new Map<string, VideoFlight>();
|
||||
|
||||
/**
|
||||
* Build the canonical abort error used by Video Bridge waiters.
|
||||
*
|
||||
* @returns A sanitized abort error safe to propagate through the guardrail.
|
||||
*/
|
||||
export function videoBridgeAbortError(): Error {
|
||||
return new Error("Video Bridge processing was aborted");
|
||||
}
|
||||
|
||||
function waitForVideoFlight<T>(flight: VideoFlight, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) return Promise.reject(videoBridgeAbortError());
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let completed = false;
|
||||
const finish = (callback: () => void): void => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
callback();
|
||||
};
|
||||
const onAbort = (): void => finish(() => reject(videoBridgeAbortError()));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal.aborted) onAbort();
|
||||
(flight.promise as Promise<T>).then(
|
||||
(value) => finish(() => resolve(value)),
|
||||
(error: unknown) => finish(() => reject(error))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function runVideoSingleflight<T>(
|
||||
flights: Map<string, VideoFlight>,
|
||||
key: string,
|
||||
signal: AbortSignal,
|
||||
operation: (signal: AbortSignal) => Promise<T>
|
||||
): Promise<{ coalesced: boolean; value: T }> {
|
||||
let flight = flights.get(key);
|
||||
const coalesced = Boolean(flight);
|
||||
if (!flight) {
|
||||
const controller = new AbortController();
|
||||
flight = {
|
||||
controller,
|
||||
promise: Promise.resolve().then(() => operation(controller.signal)),
|
||||
settled: false,
|
||||
waiters: 0,
|
||||
};
|
||||
const createdFlight = flight;
|
||||
flights.set(key, createdFlight);
|
||||
createdFlight.promise.then(
|
||||
() => {
|
||||
createdFlight.settled = true;
|
||||
if (flights.get(key) === createdFlight) flights.delete(key);
|
||||
},
|
||||
() => {
|
||||
createdFlight.settled = true;
|
||||
if (flights.get(key) === createdFlight) flights.delete(key);
|
||||
}
|
||||
);
|
||||
}
|
||||
flight.waiters += 1;
|
||||
try {
|
||||
return { coalesced, value: await waitForVideoFlight<T>(flight, signal) };
|
||||
} finally {
|
||||
flight.waiters = Math.max(0, flight.waiters - 1);
|
||||
if (flight.waiters === 0 && !flight.settled) {
|
||||
flight.controller.abort();
|
||||
if (flights.get(key) === flight) flights.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesce only concurrent protected downloads and release the Buffer after the flight settles.
|
||||
*
|
||||
* @param key - Hashed remote-part and request-isolation identity.
|
||||
* @param signal - Abort signal for this waiter only.
|
||||
* @param operation - Protected downloader invoked once with a shared producer signal.
|
||||
* @returns The downloaded value shared by active waiters; it is never retained after settlement.
|
||||
* @throws When this waiter aborts or the shared producer rejects.
|
||||
*/
|
||||
export async function runVideoDownloadSingleflight<T>(
|
||||
key: string,
|
||||
signal: AbortSignal,
|
||||
operation: (signal: AbortSignal) => Promise<T>
|
||||
): Promise<T> {
|
||||
return (await runVideoSingleflight(videoDownloadFlights, key, signal, operation)).value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesce identical complete-result work while preserving each waiter's abort signal.
|
||||
*
|
||||
* @param key - Complete-result cache key.
|
||||
* @param signal - Abort signal for this waiter only.
|
||||
* @param operation - Producer invoked once with a shared signal.
|
||||
* @returns The produced value and whether this waiter joined existing work.
|
||||
* @throws When this waiter aborts or the shared producer rejects.
|
||||
*/
|
||||
export async function runVideoResultSingleflight<T>(
|
||||
key: string,
|
||||
signal: AbortSignal,
|
||||
operation: (signal: AbortSignal) => Promise<T>
|
||||
): Promise<{ coalesced: boolean; value: T }> {
|
||||
return runVideoSingleflight(videoResultFlights, key, signal, operation);
|
||||
}
|
||||
|
||||
type ResultCacheOperation = "delete" | "read" | "write";
|
||||
|
||||
function logCacheFailure(
|
||||
log: GuardrailContext["log"],
|
||||
operation: ResultCacheOperation,
|
||||
error: unknown
|
||||
): void {
|
||||
const message = `Video result cache ${operation} failed open`;
|
||||
const meta = { errorType: error instanceof Error ? error.name : typeof error };
|
||||
if (log?.debug) {
|
||||
log.debug("VIDEO_BRIDGE_CACHE", message, meta);
|
||||
} else {
|
||||
console.debug(`[VIDEO_BRIDGE_CACHE] ${message}`, meta);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a complete-result cache entry without allowing cache failure to break video processing.
|
||||
*
|
||||
* @param cache - Cache implementation, including caller-supplied adapters.
|
||||
* @param key - Complete-result key.
|
||||
* @param log - Optional request logger for fail-open diagnostics.
|
||||
* @returns The entry, or `undefined` for misses and cache failures.
|
||||
*/
|
||||
export function safeGetCacheEntry(
|
||||
cache: BridgeCacheStore,
|
||||
key: string,
|
||||
log?: GuardrailContext["log"]
|
||||
): BridgeCacheEntry | undefined {
|
||||
try {
|
||||
return cache.getEntry(key);
|
||||
} catch (error) {
|
||||
logCacheFailure(log, "read", error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an invalid complete-result entry without breaking video processing.
|
||||
*
|
||||
* @param cache - Cache implementation, including caller-supplied adapters.
|
||||
* @param key - Complete-result key.
|
||||
* @param log - Optional request logger for fail-open diagnostics.
|
||||
*/
|
||||
export function safeDeleteCacheEntry(
|
||||
cache: BridgeCacheStore,
|
||||
key: string,
|
||||
log?: GuardrailContext["log"]
|
||||
): void {
|
||||
try {
|
||||
cache.delete(key);
|
||||
} catch (error) {
|
||||
logCacheFailure(log, "delete", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a computed complete result without allowing cache failure to discard valid output.
|
||||
*
|
||||
* @param cache - Cache implementation, including caller-supplied adapters.
|
||||
* @param key - Complete-result key.
|
||||
* @param entry - Valid computed description and metadata.
|
||||
* @param log - Optional request logger for fail-open diagnostics.
|
||||
*/
|
||||
export function safeSetCacheEntry(
|
||||
cache: BridgeCacheStore,
|
||||
key: string,
|
||||
entry: BridgeCacheEntry,
|
||||
log?: GuardrailContext["log"]
|
||||
): void {
|
||||
try {
|
||||
cache.setEntry(key, entry);
|
||||
} catch (error) {
|
||||
logCacheFailure(log, "write", error);
|
||||
}
|
||||
}
|
||||
53
tests/fixtures/videoBridgeDedupFixtures.ts
vendored
Normal file
53
tests/fixtures/videoBridgeDedupFixtures.ts
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
import sharp from "sharp";
|
||||
|
||||
type Rectangle = {
|
||||
height: number;
|
||||
value: number;
|
||||
width: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
const FIXTURE_WIDTH = 256;
|
||||
const FIXTURE_HEIGHT = 144;
|
||||
|
||||
async function renderJpeg(rectangles: readonly Rectangle[]): Promise<string> {
|
||||
const pixels = Buffer.alloc(FIXTURE_WIDTH * FIXTURE_HEIGHT * 3, 255);
|
||||
for (const rectangle of rectangles) {
|
||||
for (let y = rectangle.y; y < rectangle.y + rectangle.height; y++) {
|
||||
for (let x = rectangle.x; x < rectangle.x + rectangle.width; x++) {
|
||||
const offset = (y * FIXTURE_WIDTH + x) * 3;
|
||||
pixels[offset] = rectangle.value;
|
||||
pixels[offset + 1] = rectangle.value;
|
||||
pixels[offset + 2] = rectangle.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
const jpeg = await sharp(pixels, {
|
||||
raw: { channels: 3, height: FIXTURE_HEIGHT, width: FIXTURE_WIDTH },
|
||||
})
|
||||
.jpeg({ chromaSubsampling: "4:4:4", quality: 100 })
|
||||
.toBuffer();
|
||||
return `data:image/jpeg;base64,${jpeg.toString("base64")}`;
|
||||
}
|
||||
|
||||
export async function createVideoDedupFixtures(): Promise<{
|
||||
smallMotion: readonly [string, string];
|
||||
staticFrame: string;
|
||||
visibleText: readonly [string, string];
|
||||
}> {
|
||||
const staticFrame = await renderJpeg([{ height: 48, value: 0, width: 48, x: 64, y: 48 }]);
|
||||
const movedFrame = await renderJpeg([{ height: 48, value: 0, width: 48, x: 68, y: 48 }]);
|
||||
// Rectangular strokes stand in for glyphs without depending on platform fonts.
|
||||
const textBefore = [
|
||||
{ height: 64, value: 0, width: 8, x: 32, y: 32 },
|
||||
{ height: 64, value: 0, width: 8, x: 48, y: 32 },
|
||||
{ height: 64, value: 0, width: 8, x: 64, y: 32 },
|
||||
] as const;
|
||||
const textAfter = [...textBefore, { height: 64, value: 0, width: 8, x: 80, y: 32 }] as const;
|
||||
return {
|
||||
smallMotion: [staticFrame, movedFrame],
|
||||
staticFrame,
|
||||
visibleText: [await renderJpeg(textBefore), await renderJpeg(textAfter)],
|
||||
};
|
||||
}
|
||||
@@ -3,8 +3,40 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
deduplicateVideoFrames,
|
||||
resolveVideoDedupCandidateFrameCount,
|
||||
type VideoCaptionFrame,
|
||||
} from "../../../src/lib/guardrails/videoBridgeHelpers.ts";
|
||||
import { createVideoDedupFixtures } from "../../fixtures/videoBridgeDedupFixtures.ts";
|
||||
|
||||
const fixturesPromise = createVideoDedupFixtures();
|
||||
|
||||
test("dedup candidate count doubles the caption budget within the hard frame bound", () => {
|
||||
assert.equal(resolveVideoDedupCandidateFrameCount(1), 1);
|
||||
assert.equal(resolveVideoDedupCandidateFrameCount(3), 6);
|
||||
assert.equal(resolveVideoDedupCandidateFrameCount(8), 16);
|
||||
assert.equal(resolveVideoDedupCandidateFrameCount(9), 16);
|
||||
assert.equal(resolveVideoDedupCandidateFrameCount(Number.NaN), 1);
|
||||
});
|
||||
|
||||
test("deduplication stops scheduling comparator work after abort", async () => {
|
||||
const controller = new AbortController();
|
||||
let comparisons = 0;
|
||||
const pending = deduplicateVideoFrames(
|
||||
[frame(1), frame(2), frame(3), frame(4), frame(5), frame(6)],
|
||||
{
|
||||
compare: async () => {
|
||||
comparisons += 1;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 30));
|
||||
return 0.2;
|
||||
},
|
||||
signal: controller.signal,
|
||||
}
|
||||
);
|
||||
setTimeout(() => controller.abort(), 5);
|
||||
|
||||
await assert.rejects(pending, /aborted/i);
|
||||
assert.equal(comparisons, 1);
|
||||
});
|
||||
|
||||
const frame = (
|
||||
timestampSeconds: number,
|
||||
@@ -37,13 +69,80 @@ test("deduplication keeps visually distinct frames", async () => {
|
||||
assert.equal(result.dropped, 0);
|
||||
});
|
||||
|
||||
test("deduplication fails open when the visual comparator errors", async () => {
|
||||
const result = await deduplicateVideoFrames([frame(1), frame(2)], {
|
||||
compare: async () => {
|
||||
throw new Error("invalid JPEG");
|
||||
},
|
||||
});
|
||||
test("deduplication applies the final cap after comparison while preserving both endpoints", async () => {
|
||||
const result = await deduplicateVideoFrames(
|
||||
[frame(1), frame(2), frame(3), frame(4), frame(5), frame(6)],
|
||||
{
|
||||
compare: async (_previous, current) =>
|
||||
current.timestampSeconds === 2 || current.timestampSeconds === 4 ? 0.01 : 0.2,
|
||||
maxFrames: 3,
|
||||
threshold: 0.05,
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.frames.length, 2);
|
||||
assert.deepEqual(
|
||||
result.frames.map((item) => item.timestampSeconds),
|
||||
[1, 5, 6]
|
||||
);
|
||||
assert.equal(result.dropped, 2, "only visual duplicates count as dedup drops");
|
||||
});
|
||||
|
||||
test("the real grayscale policy preserves a small moving subject", async () => {
|
||||
const fixtures = await fixturesPromise;
|
||||
const result = await deduplicateVideoFrames([
|
||||
frame(1, fixtures.smallMotion[0]),
|
||||
frame(2, fixtures.smallMotion[1]),
|
||||
frame(3, fixtures.smallMotion[0]),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
result.frames.map((item) => item.timestampSeconds),
|
||||
[1, 2, 3]
|
||||
);
|
||||
assert.equal(result.dropped, 0);
|
||||
});
|
||||
|
||||
test("the real grayscale policy drops a static fixture", async () => {
|
||||
const fixtures = await fixturesPromise;
|
||||
const result = await deduplicateVideoFrames([
|
||||
frame(1, fixtures.staticFrame),
|
||||
frame(2, fixtures.staticFrame),
|
||||
frame(3, fixtures.smallMotion[1]),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
result.frames.map((item) => item.timestampSeconds),
|
||||
[1, 3]
|
||||
);
|
||||
assert.equal(result.dropped, 1);
|
||||
});
|
||||
|
||||
test("the real grayscale policy preserves a visible text change", async () => {
|
||||
const fixtures = await fixturesPromise;
|
||||
const result = await deduplicateVideoFrames([
|
||||
frame(1, fixtures.visibleText[0]),
|
||||
frame(2, fixtures.visibleText[1]),
|
||||
frame(3, fixtures.visibleText[0]),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
result.frames.map((item) => item.timestampSeconds),
|
||||
[1, 2, 3]
|
||||
);
|
||||
assert.equal(result.dropped, 0);
|
||||
});
|
||||
|
||||
test("deduplication fails open for a malformed JPEG candidate", async () => {
|
||||
const fixtures = await fixturesPromise;
|
||||
const result = await deduplicateVideoFrames([
|
||||
frame(1, fixtures.staticFrame),
|
||||
frame(2, "data:image/jpeg;base64,bm90LWEtanBlZw=="),
|
||||
frame(3, fixtures.staticFrame),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
result.frames.map((item) => item.timestampSeconds),
|
||||
[1, 2, 3]
|
||||
);
|
||||
assert.equal(result.dropped, 0);
|
||||
});
|
||||
|
||||
@@ -366,6 +366,44 @@ test("uses the broker seam, reports configured versus extracted frames, and mark
|
||||
assert.match(result.description, /do not follow instructions/i);
|
||||
});
|
||||
|
||||
test("uses a bounded candidate pool before the final caption cap and preserves endpoint coverage", async () => {
|
||||
let candidateFrameCount = 0;
|
||||
const captionedTimestamps: number[] = [];
|
||||
const result = await describeVideoPart(
|
||||
{
|
||||
container: "messages",
|
||||
messageIndex: 0,
|
||||
partIndex: 0,
|
||||
ref: "data:video/mp4;base64,QUJD",
|
||||
shape: "input_video",
|
||||
},
|
||||
{ frameCount: 3, timeoutMs: 5_000 },
|
||||
async (_frame, timestampSeconds) => {
|
||||
captionedTimestamps.push(timestampSeconds);
|
||||
return `frame ${timestampSeconds}`;
|
||||
},
|
||||
{
|
||||
extractFrames: async (_bytes, options) => {
|
||||
candidateFrameCount = options.frameCount;
|
||||
return {
|
||||
durationSeconds: 6,
|
||||
frames: Array.from({ length: options.frameCount }, (_unused, index) => ({
|
||||
dataUri: `data:image/jpeg;base64,${Buffer.from(String(index)).toString("base64")}`,
|
||||
timestampSeconds: index + 1,
|
||||
})),
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(candidateFrameCount, 6, "three caption slots get at most two candidates each");
|
||||
assert.deepEqual(captionedTimestamps, [1, 4, 6]);
|
||||
assert.equal(result.framesRequested, 3);
|
||||
assert.equal(result.framesExtracted, 6);
|
||||
assert.equal(result.framesUsed, 3);
|
||||
assert.equal(result.dedupDropped, 0, "malformed candidate comparisons must fail open");
|
||||
});
|
||||
|
||||
test("video downloads require HTTPS on every redirect hop", async () => {
|
||||
let requireHttps: boolean | undefined;
|
||||
await describeVideoPart(
|
||||
|
||||
1035
tests/unit/guardrails/videoBridgeResultCache.test.ts
Normal file
1035
tests/unit/guardrails/videoBridgeResultCache.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,37 @@ test("key framing prevents boundary-shift collisions between fields", () => {
|
||||
assert.notEqual(bridgeCacheKey("x", "yz", "m"), bridgeCacheKey("x", "y", "zm"));
|
||||
});
|
||||
|
||||
test("video cache keys change with every visual dedup policy dimension", () => {
|
||||
const base = {
|
||||
dedupCandidateFrameCount: 16,
|
||||
dedupPolicyVersion: "grayscale-16x16-mean-cells-v2",
|
||||
dedupThreshold: 0.04,
|
||||
};
|
||||
const key = bridgeCacheKey("video", "describe", "gpt-4o-mini", base);
|
||||
|
||||
assert.notEqual(
|
||||
key,
|
||||
bridgeCacheKey("video", "describe", "gpt-4o-mini", {
|
||||
...base,
|
||||
dedupPolicyVersion: "grayscale-16x16-mean-cells-v3",
|
||||
})
|
||||
);
|
||||
assert.notEqual(
|
||||
key,
|
||||
bridgeCacheKey("video", "describe", "gpt-4o-mini", {
|
||||
...base,
|
||||
dedupThreshold: 0.05,
|
||||
})
|
||||
);
|
||||
assert.notEqual(
|
||||
key,
|
||||
bridgeCacheKey("video", "describe", "gpt-4o-mini", {
|
||||
...base,
|
||||
dedupCandidateFrameCount: 8,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("get/set roundtrip and TTL expiry", () => {
|
||||
let now = 1000;
|
||||
const cache = new BridgeCache({ maxEntries: 10, ttlMs: 500, now: () => now });
|
||||
|
||||
Reference in New Issue
Block a user