import { createHash } from "node:crypto"; import { opendir, readFile, stat } from "node:fs/promises"; import { basename, dirname, extname, join, relative, sep } from "node:path"; import type { BrollAssetCandidate, BrollAssetMatch, BrollContinuityPlanReceipt, BrollMatchReceipt, BrollNeed, BrollPlacementInput, BrollSelectionReceipt, BrollVisualIdentity, BrollWindowSelection, FileRef, } from "./contracts.ts"; import { resolveExistingWorkspaceDirectory, resolveExistingWorkspaceFile, snapshotFile, workspaceRoot } from "./workspace.ts"; const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi"]); export interface RankBrollAssetsInput { need: BrollNeed; assetPaths: string[]; maxCandidates?: number; candidateOffset?: number; completeInventory?: boolean; } export interface MatchWorkspaceBrollAssetsInput { assetDirectory: string; recursive?: boolean; maxFiles?: number; maxEntries?: number; maxDepth?: number; maxCandidates?: number; candidateOffset?: number; need: BrollNeed; signal?: AbortSignal; } export type WorkspaceBrollAssetMatch = BrollAssetMatch & { assetDirectory: string; scannedFileCount: number; scannedEntryCount: number; truncated: boolean; }; export interface SelectBrollWindowInput { need: BrollNeed; matchReceipt: BrollMatchReceipt; assetPath: string; manifestPath: string; selectedStartMs: number; evidenceTimestampsMs: number[]; fit: "cover" | "contain"; visualIdentity: BrollVisualIdentity; signal?: AbortSignal; } interface ContactSheetManifest { schemaVersion: number; source?: { path?: string; bytes?: number; sha256?: string }; range?: { startSeconds?: number; endSeconds?: number }; artifacts?: Array<{ timestampsSeconds?: number[]; sourceSha256?: string }>; } function canonicalJson(value: unknown): string { if (value === null || typeof value !== "object") return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; const object = value as Record; return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(",")}}`; } function sha256(value: unknown): string { return createHash("sha256").update(canonicalJson(value)).digest("hex"); } export function createBrollSelectionReceiptSha256(receipt: Omit): string { return sha256(receipt); } function normalized(value: string): string { return value.normalize("NFKC").toLowerCase().replace(/[\p{P}\p{S}\s]+/gu, ""); } function normalizedTerms(terms: string[]): string[] { const unique = new Set(); for (const term of terms) { const value = normalized(term); if (value) unique.add(value); } if (unique.size === 0) throw new Error("B-roll searchTerms must contain at least one meaningful term"); return [...unique]; } function candidateFor(assetPath: string, terms: string[]): BrollAssetCandidate { const fileName = basename(assetPath); const normalizedName = normalized(fileName.replace(/\.[^.]+$/u, "")); const normalizedDirectory = normalized(dirname(assetPath)); const matchedTerms: string[] = []; let score = 0; for (const term of terms) { if (normalizedName.includes(term)) { matchedTerms.push(term); score += 4; } else if (normalizedDirectory.includes(term)) { matchedTerms.push(term); score += 2; } } const highThreshold = Math.min(2, terms.length); return { assetPath, fileName, matchedTerms, score, confidence: matchedTerms.length >= highThreshold ? "high" : matchedTerms.length > 0 ? "medium" : "low", }; } function validateNeed(need: BrollNeed): void { if (!Number.isFinite(need.outputStartMs) || !Number.isFinite(need.outputEndMs) || need.outputStartMs < 0 || need.outputEndMs <= need.outputStartMs) { throw new Error("B-roll need must contain a valid output range"); } } export function rankBrollAssets(input: RankBrollAssetsInput): BrollAssetMatch { validateNeed(input.need); if (input.assetPaths.length === 0) throw new Error("No candidate B-roll video assets were found"); const terms = normalizedTerms(input.need.searchTerms); const maxCandidates = input.maxCandidates ?? 5; if (!Number.isInteger(maxCandidates) || maxCandidates < 1 || maxCandidates > 20) { throw new Error("maxCandidates must be within 1-20"); } const ranked = [...new Set(input.assetPaths)].map((assetPath) => candidateFor(assetPath, terms)).sort((left, right) => ( right.score - left.score || (left.assetPath < right.assetPath ? -1 : left.assetPath > right.assetPath ? 1 : 0) )); const candidateOffset = input.candidateOffset ?? 0; if (!Number.isInteger(candidateOffset) || candidateOffset < 0 || candidateOffset >= ranked.length) { throw new Error(`candidateOffset must be within 0-${ranked.length - 1}`); } const top = ranked[0]; const second = ranked[1]; const direct = (input.completeInventory ?? true) && candidateOffset === 0 && top?.confidence === "high" && (second === undefined || top.score > second.score); if (direct) { return { requiredDurationMs: input.need.outputEndMs - input.need.outputStartMs, totalCandidates: ranked.length, candidateOffset: 0, nextCandidateOffset: null, selectionMode: "filename-direct", shortlist: [top], nextStep: { action: "inspect-selected-asset", reason: "One filename is the unique high-confidence match; inspect only this asset to choose the source window.", }, }; } const hasLexicalMatch = ranked.some((candidate) => candidate.confidence !== "low"); const shortlist = ranked.slice(candidateOffset, candidateOffset + maxCandidates); const nextCandidateOffset = (input.completeInventory ?? true) && candidateOffset + shortlist.length < ranked.length ? candidateOffset + shortlist.length : null; const shared = { requiredDurationMs: input.need.outputEndMs - input.need.outputStartMs, totalCandidates: ranked.length, candidateOffset, nextCandidateOffset, shortlist, }; if (hasLexicalMatch) return { ...shared, selectionMode: "filename-shortlist", nextStep: { action: "inspect-shortlist", reason: "Filename evidence is ambiguous; visually inspect only the bounded shortlist.", }, }; return { ...shared, selectionMode: "visual-fallback", nextStep: { action: "visual-fallback", reason: "Filenames provide no useful evidence; use low-cost visual screening on the bounded shortlist.", }, }; } interface ScanState { paths: string[]; visitedEntries: number; truncated: boolean; } interface ScanLimits { recursive: boolean; maxFiles: number; maxEntries: number; maxDepth: number; signal?: AbortSignal; } function codePointCompare(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } async function collectVideoPaths(directory: string, depth: number, limits: ScanLimits, state: ScanState): Promise { limits.signal?.throwIfAborted(); const entries = []; const handle = await opendir(directory); for await (const entry of handle) { limits.signal?.throwIfAborted(); if (state.visitedEntries >= limits.maxEntries) { state.truncated = true; break; } state.visitedEntries += 1; entries.push(entry); } entries.sort((left, right) => codePointCompare(left.name, right.name)); for (const entry of entries) { limits.signal?.throwIfAborted(); if (state.paths.length > limits.maxFiles) { state.truncated = true; return; } if (entry.isSymbolicLink()) continue; const absolute = join(directory, entry.name); if (entry.isDirectory()) { if (limits.recursive) { if (depth >= limits.maxDepth) state.truncated = true; else await collectVideoPaths(absolute, depth + 1, limits, state); } continue; } if (entry.isFile() && VIDEO_EXTENSIONS.has(extname(entry.name).toLowerCase())) state.paths.push(absolute); } } export async function matchWorkspaceBrollAssets( cwd: string, input: MatchWorkspaceBrollAssetsInput, ): Promise { const maxFiles = input.maxFiles ?? 1_000; if (!Number.isInteger(maxFiles) || maxFiles < 1 || maxFiles > 5_000) { throw new Error("maxFiles must be within 1-5000"); } const maxEntries = input.maxEntries ?? 20_000; if (!Number.isInteger(maxEntries) || maxEntries < 1 || maxEntries > 100_000) { throw new Error("maxEntries must be within 1-100000"); } const maxDepth = input.maxDepth ?? 12; if (!Number.isInteger(maxDepth) || maxDepth < 0 || maxDepth > 50) { throw new Error("maxDepth must be within 0-50"); } const directory = await resolveExistingWorkspaceDirectory(cwd, input.assetDirectory); const state: ScanState = { paths: [], visitedEntries: 0, truncated: false }; await collectVideoPaths(directory, 0, { recursive: input.recursive ?? true, maxFiles, maxEntries, maxDepth, ...(input.signal === undefined ? {} : { signal: input.signal }), }, state); const truncated = state.truncated || state.paths.length > maxFiles; if (truncated && (input.candidateOffset ?? 0) !== 0) { throw new Error("candidateOffset cannot be reused after a truncated scan; narrow the asset directory and rescan"); } const included = state.paths.slice(0, maxFiles); const root = await workspaceRoot(cwd); const toWorkspacePath = (path: string) => relative(root, path).split(sep).join("/"); const assetPaths = included.map(toWorkspacePath); return { assetDirectory: toWorkspacePath(directory), scannedFileCount: included.length, scannedEntryCount: state.visitedEntries, truncated, ...rankBrollAssets({ need: input.need, assetPaths, ...(input.maxCandidates === undefined ? {} : { maxCandidates: input.maxCandidates }), ...(input.candidateOffset === undefined ? {} : { candidateOffset: input.candidateOffset }), completeInventory: !truncated, }), }; } export function createBrollMatchReceipt(input: { projectId: string; revision: number; continuityPlanSha256: string; need: BrollNeed; match: WorkspaceBrollAssetMatch; }): BrollMatchReceipt { const payload: Omit = { schemaVersion: 1, projectId: input.projectId, revision: input.revision, continuityPlanSha256: input.continuityPlanSha256, needId: input.need.id, needSha256: sha256(input.need), assetDirectory: input.match.assetDirectory, candidateOffset: input.match.candidateOffset, candidateAssetPaths: input.match.shortlist.map((candidate) => candidate.assetPath), }; return { ...payload, matchSha256: sha256(payload) }; } function assertMatchReceiptIntegrity(need: BrollNeed, assetPath: string, receipt: BrollMatchReceipt): void { if (receipt.schemaVersion !== 1 || receipt.needId !== need.id || receipt.needSha256 !== sha256(need)) { throw new Error(`B-roll match receipt does not match need ${need.id}`); } const { matchSha256, ...payload } = receipt; if (matchSha256 !== sha256(payload)) throw new Error("B-roll match receipt hash is invalid"); if (!receipt.candidateAssetPaths.includes(assetPath)) { throw new Error(`B-roll asset ${assetPath} was not returned by the matching step for need ${need.id}`); } } export function assertBrollAssetInMatchReceipt(input: { projectId: string; revision: number; continuityPlanReceipt: BrollContinuityPlanReceipt; need: BrollNeed; assetPath: string; matchReceipt: BrollMatchReceipt; }): void { if (input.matchReceipt.projectId !== input.projectId || input.matchReceipt.revision !== input.revision || input.matchReceipt.continuityPlanSha256 !== input.continuityPlanReceipt.planSha256) { throw new Error("B-roll match receipt does not match the current project, revision, or continuity plan"); } assertMatchReceiptIntegrity(input.need, input.assetPath, input.matchReceipt); } function parseContactSheetManifest(payload: string, manifestPath: string): ContactSheetManifest { try { return JSON.parse(payload) as ContactSheetManifest; } catch (error) { throw new Error(`Invalid contact-sheet manifest ${manifestPath}: ${(error as Error).message}`); } } interface SelectionWindow { assetStartMs: number; outputStartMs: number; outputEndMs: number; } export class BrollSelectionError extends Error { readonly details: { code: string; requestedStartMs: number; requiredDurationMs: number; analyzedRangeMs: [number, number]; availableStartTimestampsMs: number[]; availableEvidenceTimestampsMs: number[]; requiredTailAtOrAfterMs: number; nextStep: string; }; constructor(message: string, details: BrollSelectionError["details"]) { super(message); this.name = "BrollSelectionError"; this.details = details; } } function validateManifestSelection( asset: FileRef, manifest: FileRef, payload: ContactSheetManifest, window: SelectionWindow, receipt: BrollSelectionReceipt, ): void { if (payload.schemaVersion !== 2 || !payload.source || !payload.range || !Array.isArray(payload.artifacts)) { throw new Error("Contact-sheet manifest must use pi-media schemaVersion 2"); } if (receipt.assetSha256 !== asset.sha256 || receipt.assetBytes !== asset.bytes) { throw new Error("B-roll selection receipt does not match the current asset"); } if (receipt.manifestPath !== manifest.path || receipt.manifestSha256 !== manifest.sha256) { throw new Error("B-roll selection receipt does not match the current contact-sheet manifest"); } if (receipt.selectionSha256) { const { selectionSha256, ...payload } = receipt; if (selectionSha256 !== createBrollSelectionReceiptSha256(payload)) { throw new Error("B-roll selection receipt hash is invalid"); } } if (payload.source.path !== asset.path || payload.source.sha256 !== asset.sha256 || payload.source.bytes !== asset.bytes) { throw new Error("Contact-sheet manifest source does not match the selected B-roll asset"); } const requiredDurationMs = window.outputEndMs - window.outputStartMs; const selectedEndMs = window.assetStartMs + requiredDurationMs; if (receipt.selectedEndMs !== selectedEndMs) { throw new Error("B-roll selection receipt duration does not match the output placement"); } const rangeStartMs = Number(payload.range.startSeconds) * 1_000; const rangeEndMs = Number(payload.range.endSeconds) * 1_000; if (!Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs) || rangeEndMs <= rangeStartMs) throw new Error("Invalid contact-sheet range"); const availableTimestamps = new Set(); for (const artifact of payload.artifacts) { if (artifact.sourceSha256 !== asset.sha256 || !Array.isArray(artifact.timestampsSeconds)) { throw new Error("Contact-sheet artifact provenance does not match the selected B-roll asset"); } for (const timestamp of artifact.timestampsSeconds) { if (Number.isFinite(timestamp)) availableTimestamps.add(Math.round(timestamp * 1_000)); } } const timestamps = [...availableTimestamps].sort((a, b) => a - b); const tailThresholdMs = selectedEndMs - Math.max(500, requiredDurationMs * 0.25); const fail = (code: string, message: string): never => { throw new BrollSelectionError(message, { code, requestedStartMs: window.assetStartMs, requiredDurationMs, analyzedRangeMs: [rangeStartMs, rangeEndMs], availableStartTimestampsMs: timestamps.filter(t => t >= rangeStartMs && t + requiredDurationMs <= rangeEndMs) .sort((a, b) => Math.abs(a - window.assetStartMs) - Math.abs(b - window.assetStartMs) || a - b).slice(0, 20), availableEvidenceTimestampsMs: timestamps.filter(t => t >= window.assetStartMs && t <= selectedEndMs) .sort((a, b) => b - a).slice(0, 100).sort((a, b) => a - b), requiredTailAtOrAfterMs: tailThresholdMs, nextStep: "Choose and inspect manifest-backed frames including the exact start and a frame at/after requiredTailAtOrAfterMs within the selected window. If absent, run media_contact_sheet on that source range with higher precision, inspect it, and retry. Suggested starts do not imply visual approval.", }); }; if (window.assetStartMs < rangeStartMs || selectedEndMs > rangeEndMs) fail("BROLL_WINDOW_OUTSIDE_ANALYSIS", "Selected B-roll source window is outside the analyzed contact-sheet range"); if (!availableTimestamps.has(window.assetStartMs)) fail("BROLL_START_NOT_SAMPLED", "Selected B-roll source window must start on a manifest timestamp"); const evidenceTimestampsMs = [...new Set(receipt.evidenceTimestampsMs)]; const minimumEvidence = requiredDurationMs >= 1_000 ? 2 : 1; if (evidenceTimestampsMs.length < minimumEvidence || !evidenceTimestampsMs.includes(window.assetStartMs) || evidenceTimestampsMs.some((timestamp) => ( !Number.isFinite(timestamp) || !availableTimestamps.has(timestamp) || timestamp < window.assetStartMs || timestamp > selectedEndMs ))) { fail("BROLL_EVIDENCE_INCOMPLETE", `B-roll selection requires ${minimumEvidence} or more manifest-backed evidence timestamps, including its source start`); } if (Math.max(...evidenceTimestampsMs) < tailThresholdMs) { fail("BROLL_TAIL_NOT_COVERED", "B-roll selection evidence must cover the end of the source window"); } } async function readVerifiedManifest( cwd: string, manifestPath: string, signal?: AbortSignal, ): Promise<{ manifest: FileRef; payload: ContactSheetManifest }> { signal?.throwIfAborted(); const manifestAbsolute = await resolveExistingWorkspaceFile(cwd, manifestPath); if ((await stat(manifestAbsolute)).size > 5 * 1024 * 1024) throw new Error("Contact-sheet manifest exceeds the 5MB limit"); const manifest = await snapshotFile(cwd, manifestPath, signal); const payload = parseContactSheetManifest( await readFile(manifestAbsolute, signal === undefined ? "utf8" : { encoding: "utf8", signal }), manifest.path, ); return { manifest, payload }; } export async function verifyBrollPlacementSelection( cwd: string, placement: BrollPlacementInput, signal?: AbortSignal, ): Promise { if (!Number.isFinite(placement.assetStartMs) || placement.assetStartMs < 0) { throw new Error(`B-roll assetStartMs is required and must be valid: ${placement.id}`); } if (!placement.selectionReceipt) { throw new Error(`B-roll selectionReceipt is required: ${placement.id}`); } const asset = await snapshotFile(cwd, placement.assetPath, signal); const { manifest, payload } = await readVerifiedManifest(cwd, placement.selectionReceipt.manifestPath, signal); validateManifestSelection(asset, manifest, payload, placement, placement.selectionReceipt); return asset; } export async function selectBrollWindow(cwd: string, input: SelectBrollWindowInput): Promise { validateNeed(input.need); assertMatchReceiptIntegrity(input.need, input.assetPath, input.matchReceipt); input.signal?.throwIfAborted(); if (!Number.isFinite(input.selectedStartMs) || input.selectedStartMs < 0) { throw new Error("selectedStartMs must be a finite non-negative timestamp"); } if (!input.visualIdentity.subject.trim() || !input.visualIdentity.action.trim()) { throw new Error("B-roll visualIdentity must name the visible subject and action"); } const requiredDurationMs = input.need.outputEndMs - input.need.outputStartMs; const selectedEndMs = input.selectedStartMs + requiredDurationMs; const evidenceTimestampsMs = [...new Set(input.evidenceTimestampsMs)]; const asset = await snapshotFile(cwd, input.assetPath, input.signal); const { manifest, payload } = await readVerifiedManifest(cwd, input.manifestPath, input.signal); const selectionPayload: Omit = { assetBytes: asset.bytes, assetSha256: asset.sha256, manifestPath: manifest.path, manifestSha256: manifest.sha256, selectedEndMs, evidenceTimestampsMs, matchReceipt: structuredClone(input.matchReceipt), visualIdentity: { ...structuredClone(input.visualIdentity), subject: input.visualIdentity.subject.trim(), action: input.visualIdentity.action.trim(), }, }; const placement: BrollPlacementInput = { id: input.need.id, assetPath: asset.path, outputStartMs: input.need.outputStartMs, outputEndMs: input.need.outputEndMs, assetStartMs: input.selectedStartMs, fit: input.fit, audio: "keep-primary", query: input.need.searchTerms.join(" "), reason: input.need.reason, selectionReceipt: { ...selectionPayload, selectionSha256: createBrollSelectionReceiptSha256(selectionPayload), }, }; validateManifestSelection(asset, manifest, payload, placement, placement.selectionReceipt); return { placement }; }