mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 12:22:34 +03:00
FU-05 core (closes #11652): caller-supplied Video Bridge transcripts had no bounded, deterministic contract — a client could self-assert source: "embedded"/"audio-bridge" and it was accepted verbatim. normalizeVideoTranscript gained a code-only trustedSource seam unreachable from request-body JSON; without it, any cue declaring embedded/audio-bridge is reclassified to client. Added budgets (256 cues, 4096 code units/cue, 4KiB/cue, 64KiB total), malformed-surrogate rejection, focus-window scoping, deterministic cross-source reconciliation, and bumped the result-cache version v4→v5 so old-contract cache entries can never serve new-contract requests. All 187 videoBridge* tests pass (185 pass, 2 unrelated pre-existing skips).
298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
/**
|
|
* FU-05 transcript contract: deterministic budgets, a structural provenance
|
|
* trust boundary, and cross-source reconciliation for Video Bridge
|
|
* transcripts. Extracted from videoBridgeHelpers.ts so the frozen-adjacent
|
|
* helper file only grows by a thin delegation (see #11652).
|
|
*
|
|
* Trust boundary: `normalizeVideoTranscript`'s only public entry point is
|
|
* `buildNormalizedVideoTranscript`. A caller-supplied cue can only ever
|
|
* declare `source: "client"` — declaring "embedded" or "audio-bridge" is
|
|
* self-asserted provenance and is silently reclassified to "client" because
|
|
* there is no way for the server to verify it came from an embedded-subtitle
|
|
* extractor or the audio-bridge pipeline. The ONLY way to obtain a trusted
|
|
* "embedded"/"audio-bridge" cue is for server-owned code to pass
|
|
* `options.trustedSource` explicitly — that option can never be reached by
|
|
* deserializing request-body JSON (it is a second, code-only argument), so
|
|
* the trust decision is structural rather than a flag the caller can flip.
|
|
*/
|
|
|
|
export type VideoTranscriptSource = "audio-bridge" | "client" | "embedded";
|
|
|
|
export interface VideoTranscriptCue {
|
|
confidence: number;
|
|
endSeconds: number;
|
|
source: VideoTranscriptSource;
|
|
startSeconds: number;
|
|
text: string;
|
|
/**
|
|
* Present only when reconciliation merged cues declaring 2+ distinct
|
|
* sources into one cue (see `reconcileVideoTranscriptCues`). Absent for
|
|
* the common single-source case so existing exact-shape assertions are
|
|
* unaffected.
|
|
*/
|
|
contributingSources?: VideoTranscriptSource[];
|
|
}
|
|
|
|
export interface VideoTranscriptFocusWindow {
|
|
endSeconds: number;
|
|
startSeconds: number;
|
|
}
|
|
|
|
export interface NormalizeVideoTranscriptOptions {
|
|
/** Structural trust seam — server-owned adapters only, never derived from request JSON. */
|
|
trustedSource?: VideoTranscriptSource;
|
|
/** Scope (filter + clip) the resulting cues to this window; `null`/omitted keeps everything. */
|
|
focusWindow?: VideoTranscriptFocusWindow | null;
|
|
}
|
|
|
|
const VIDEO_TRANSCRIPT_SOURCES: ReadonlySet<VideoTranscriptSource> = new Set([
|
|
"audio-bridge",
|
|
"client",
|
|
"embedded",
|
|
]);
|
|
|
|
export const VIDEO_TRANSCRIPT_MAX_CUES = 256;
|
|
export const VIDEO_TRANSCRIPT_MAX_CUE_CODE_UNITS = 4096;
|
|
export const VIDEO_TRANSCRIPT_MAX_CUE_UTF8_BYTES = 4 * 1024;
|
|
export const VIDEO_TRANSCRIPT_MAX_TOTAL_UTF8_BYTES = 64 * 1024;
|
|
|
|
// Bounded (no unbounded quantifiers) — matches an unpaired high surrogate not
|
|
// followed by a low surrogate, or an unpaired low surrogate not preceded by a
|
|
// high surrogate. Safe against catastrophic backtracking per AGENTS.md ReDoS rule.
|
|
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/;
|
|
|
|
const SOURCE_PRIORITY: Record<VideoTranscriptSource, number> = {
|
|
client: 0,
|
|
"audio-bridge": 1,
|
|
embedded: 2,
|
|
};
|
|
|
|
function extractRawCues(value: unknown): unknown[] {
|
|
if (Array.isArray(value)) return value;
|
|
if (
|
|
value &&
|
|
typeof value === "object" &&
|
|
Array.isArray((value as Record<string, unknown>).cues)
|
|
) {
|
|
return (value as Record<string, unknown>).cues as unknown[];
|
|
}
|
|
throw new Error("Invalid video transcript: expected a cues array");
|
|
}
|
|
|
|
function extractCueText(record: Record<string, unknown>): string {
|
|
const raw = record.text;
|
|
if (typeof raw !== "string") throw new Error("Invalid video transcript cue text");
|
|
if (raw.length > VIDEO_TRANSCRIPT_MAX_CUE_CODE_UNITS) {
|
|
throw new Error(
|
|
`Video transcript cue exceeds the maximum of ${VIDEO_TRANSCRIPT_MAX_CUE_CODE_UNITS} input code units`
|
|
);
|
|
}
|
|
if (LONE_SURROGATE.test(raw)) {
|
|
throw new Error("Invalid video transcript cue text encoding");
|
|
}
|
|
const text = raw.normalize("NFC").trim();
|
|
if (!text) throw new Error("Invalid video transcript cue text");
|
|
if (Buffer.byteLength(text, "utf8") > VIDEO_TRANSCRIPT_MAX_CUE_UTF8_BYTES) {
|
|
throw new Error(
|
|
`Video transcript cue exceeds the maximum of ${VIDEO_TRANSCRIPT_MAX_CUE_UTF8_BYTES} UTF-8 bytes`
|
|
);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function resolveCueSource(
|
|
record: Record<string, unknown>,
|
|
trustedSource: VideoTranscriptSource | undefined
|
|
): VideoTranscriptSource {
|
|
if (trustedSource) return trustedSource;
|
|
const declared = record.source;
|
|
if (
|
|
typeof declared !== "string" ||
|
|
!VIDEO_TRANSCRIPT_SOURCES.has(declared as VideoTranscriptSource)
|
|
) {
|
|
throw new Error("Invalid video transcript source or provenance");
|
|
}
|
|
// Only a server-owned adapter may assign "embedded"/"audio-bridge" via the
|
|
// trustedSource seam above; any caller-declared value collapses to "client".
|
|
return "client";
|
|
}
|
|
|
|
function resolveCueTimestamps(record: Record<string, unknown>): {
|
|
startSeconds: number;
|
|
endSeconds: number;
|
|
} {
|
|
const startSeconds =
|
|
typeof record.startSeconds === "number"
|
|
? record.startSeconds
|
|
: typeof record.start === "number"
|
|
? record.start
|
|
: Number.NaN;
|
|
const endSeconds =
|
|
typeof record.endSeconds === "number"
|
|
? record.endSeconds
|
|
: typeof record.end === "number"
|
|
? record.end
|
|
: Number.NaN;
|
|
return { startSeconds, endSeconds };
|
|
}
|
|
|
|
function assertValidCueBounds(
|
|
startSeconds: number,
|
|
endSeconds: number,
|
|
confidence: unknown,
|
|
durationSeconds: number
|
|
): void {
|
|
if (
|
|
!Number.isFinite(startSeconds) ||
|
|
!Number.isFinite(endSeconds) ||
|
|
!Number.isFinite(confidence as number) ||
|
|
(confidence as number) < 0 ||
|
|
(confidence as number) > 1 ||
|
|
startSeconds < 0 ||
|
|
endSeconds > durationSeconds ||
|
|
endSeconds <= startSeconds
|
|
) {
|
|
throw new Error("Invalid video transcript timestamp or confidence range");
|
|
}
|
|
}
|
|
|
|
function parseVideoTranscriptCue(
|
|
cue: unknown,
|
|
durationSeconds: number,
|
|
trustedSource: VideoTranscriptSource | undefined
|
|
): VideoTranscriptCue {
|
|
if (!cue || typeof cue !== "object") throw new Error("Invalid video transcript cue");
|
|
const record = cue as Record<string, unknown>;
|
|
const text = extractCueText(record);
|
|
const source = resolveCueSource(record, trustedSource);
|
|
const { startSeconds, endSeconds } = resolveCueTimestamps(record);
|
|
const confidence = record.confidence === undefined ? 1 : record.confidence;
|
|
assertValidCueBounds(startSeconds, endSeconds, confidence, durationSeconds);
|
|
return { confidence: confidence as number, endSeconds, source, startSeconds, text };
|
|
}
|
|
|
|
interface VideoTranscriptCluster {
|
|
startSeconds: number;
|
|
endSeconds: number;
|
|
text: string;
|
|
members: VideoTranscriptCue[];
|
|
}
|
|
|
|
function findOverlappingCluster(
|
|
clusters: readonly VideoTranscriptCluster[],
|
|
cue: VideoTranscriptCue
|
|
): VideoTranscriptCluster | undefined {
|
|
return clusters.find(
|
|
(cluster) =>
|
|
cluster.text === cue.text &&
|
|
cue.startSeconds < cluster.endSeconds &&
|
|
cue.endSeconds > cluster.startSeconds
|
|
);
|
|
}
|
|
|
|
function mergeVideoTranscriptCluster(cluster: VideoTranscriptCluster): VideoTranscriptCue {
|
|
const { members } = cluster;
|
|
const distinctSources = Array.from(new Set(members.map((member) => member.source))).sort(
|
|
(left, right) => SOURCE_PRIORITY[left] - SOURCE_PRIORITY[right]
|
|
);
|
|
const winningSource = distinctSources[distinctSources.length - 1];
|
|
return {
|
|
confidence: Math.max(...members.map((member) => member.confidence)),
|
|
endSeconds: Math.max(...members.map((member) => member.endSeconds)),
|
|
source: winningSource,
|
|
startSeconds: Math.min(...members.map((member) => member.startSeconds)),
|
|
text: cluster.text,
|
|
...(distinctSources.length > 1 ? { contributingSources: distinctSources } : {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Reconcile duplicate and time-overlapping cues sharing identical text —
|
|
* possibly contributed by different sources/calls — into one deterministic
|
|
* cue per cluster, preserving which sources contributed when more than one
|
|
* did. Replaces naive exact-match deduplication, which silently discarded
|
|
* the losing source's identity.
|
|
*/
|
|
export function reconcileVideoTranscriptCues(
|
|
cues: readonly VideoTranscriptCue[]
|
|
): VideoTranscriptCue[] {
|
|
const sorted = [...cues].sort(
|
|
(left, right) =>
|
|
left.startSeconds - right.startSeconds ||
|
|
left.endSeconds - right.endSeconds ||
|
|
left.text.localeCompare(right.text) ||
|
|
SOURCE_PRIORITY[left.source] - SOURCE_PRIORITY[right.source]
|
|
);
|
|
const clusters: VideoTranscriptCluster[] = [];
|
|
for (const cue of sorted) {
|
|
const cluster = findOverlappingCluster(clusters, cue);
|
|
if (cluster) {
|
|
cluster.members.push(cue);
|
|
cluster.startSeconds = Math.min(cluster.startSeconds, cue.startSeconds);
|
|
cluster.endSeconds = Math.max(cluster.endSeconds, cue.endSeconds);
|
|
} else {
|
|
clusters.push({
|
|
endSeconds: cue.endSeconds,
|
|
members: [cue],
|
|
startSeconds: cue.startSeconds,
|
|
text: cue.text,
|
|
});
|
|
}
|
|
}
|
|
return clusters
|
|
.map(mergeVideoTranscriptCluster)
|
|
.sort((left, right) => left.startSeconds - right.startSeconds || left.endSeconds - right.endSeconds);
|
|
}
|
|
|
|
/** Filter cues to those overlapping `focusWindow`, clipping their bounds to it. */
|
|
export function scopeVideoTranscriptCuesToFocusWindow(
|
|
cues: readonly VideoTranscriptCue[],
|
|
focusWindow: VideoTranscriptFocusWindow | null | undefined
|
|
): VideoTranscriptCue[] {
|
|
if (!focusWindow) return [...cues];
|
|
return cues
|
|
.filter(
|
|
(cue) => cue.startSeconds < focusWindow.endSeconds && cue.endSeconds > focusWindow.startSeconds
|
|
)
|
|
.map((cue) => ({
|
|
...cue,
|
|
endSeconds: Math.min(cue.endSeconds, focusWindow.endSeconds),
|
|
startSeconds: Math.max(cue.startSeconds, focusWindow.startSeconds),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Validate optional transcript metadata without ever invoking a transcription
|
|
* provider. Enforces the FU-05 budgets (256 cues, 4096 input code units/cue,
|
|
* 4 KiB UTF-8/cue, 64 KiB total), the provenance trust boundary described at
|
|
* the top of this file, cross-source reconciliation, and focus-window scoping.
|
|
*/
|
|
export function buildNormalizedVideoTranscript(
|
|
value: unknown,
|
|
durationSeconds: number,
|
|
options: NormalizeVideoTranscriptOptions = {}
|
|
): VideoTranscriptCue[] {
|
|
if (value === undefined || value === null) return [];
|
|
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
|
throw new Error("Invalid video transcript duration");
|
|
}
|
|
const rawCues = extractRawCues(value);
|
|
if (rawCues.length > VIDEO_TRANSCRIPT_MAX_CUES) {
|
|
throw new Error(`Video transcript exceeds the maximum of ${VIDEO_TRANSCRIPT_MAX_CUES} cues`);
|
|
}
|
|
const parsed: VideoTranscriptCue[] = [];
|
|
let totalBytes = 0;
|
|
for (const rawCue of rawCues) {
|
|
const cue = parseVideoTranscriptCue(rawCue, durationSeconds, options.trustedSource);
|
|
totalBytes += Buffer.byteLength(cue.text, "utf8");
|
|
if (totalBytes > VIDEO_TRANSCRIPT_MAX_TOTAL_UTF8_BYTES) {
|
|
throw new Error(
|
|
`Video transcript exceeds the maximum total of ${VIDEO_TRANSCRIPT_MAX_TOTAL_UTF8_BYTES} UTF-8 bytes`
|
|
);
|
|
}
|
|
parsed.push(cue);
|
|
}
|
|
const reconciled = reconcileVideoTranscriptCues(parsed);
|
|
return scopeVideoTranscriptCuesToFocusWindow(reconciled, options.focusWindow ?? null);
|
|
}
|