/** * Worker-friendly multi-head perception pipeline. * * The pipeline is intentionally adapter-shaped: the core has no DOM, * no model-loading code, and no WebGPU dependency. Concrete adapters * (TF.js, ONNX-Web, native bridges) implement `DetectorAdapter` and the * pipeline orchestrates them under a per-frame `budgetMs` cap. * * Re-expressed from Human.js's per-tick budget logic (see * docs/research/perception-bio-synthesis-2026.md ยง1, "Per-frame budget"). */ import type { Detection, HeadName, PerceptionEnvelope } from './envelope.js'; import { summariseDetections } from './envelope.js'; import { computeLiveness, type FrameSignal } from './liveness.js'; export interface Frame { /** Stable hash of the frame contents. */ readonly frameHash: string; /** Frame timestamp (millis since epoch). */ readonly tMs: number; /** Adapter-opaque payload (`ImageData`, tensor handle, etc.). */ readonly payload: unknown; } export interface DetectorAdapter { readonly head: HeadName; /** Approximate cost in millis. The pipeline skips heads whose cost * exceeds the remaining budget. */ readonly costMs: number; detect(frame: Frame): Promise; /** Optional liveness signal contribution. */ livenessSignal?(frame: Frame): FrameSignal | null; } export interface DetectOptions { readonly budgetMs: number; readonly consumerArtifact: string; /** Subset of heads to run (default: every adapter). */ readonly heads?: readonly HeadName[]; /** Recent frame signals used for liveness aggregation. */ readonly recentSignals?: readonly FrameSignal[]; } const HEADS_INIT: Record = { face: [], body: [], hand: [], gesture: [], iris: [], emotion: [], object: [], person: [], }; export async function detect( frame: Frame, adapters: readonly DetectorAdapter[], options: DetectOptions, ): Promise { const startMs = nowMs(); const requested = new Set(options.heads ?? adapters.map((a) => a.head)); const ran: HeadName[] = []; const skipped: HeadName[] = []; const results: Record = { face: [], body: [], hand: [], gesture: [], iris: [], emotion: [], object: [], person: [], }; const signals: FrameSignal[] = [...(options.recentSignals ?? [])]; for (const adapter of adapters) { if (!requested.has(adapter.head)) { skipped.push(adapter.head); continue; } const elapsed = nowMs() - startMs; if (elapsed + adapter.costMs > options.budgetMs) { skipped.push(adapter.head); continue; } const detections = await adapter.detect(frame); results[adapter.head] = [...detections]; ran.push(adapter.head); const sig = adapter.livenessSignal?.(frame); if (sig) signals.push(sig); } const liveness = computeLiveness(signals); const base: Omit = { frameHash: frame.frameHash, ranHeads: ran, skippedHeads: skipped, face: results.face, body: results.body, hand: results.hand, gesture: results.gesture, object: results.object, person: results.person, liveness, budgetMs: nowMs() - startMs, consumerArtifact: options.consumerArtifact, }; return { ...base, detectionsSummary: summariseDetections(base) }; } /** Wall-clock now in millis; thin wrapper for tests. */ export function nowMs(): number { return typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now(); } // Suppress "unused import" if a consumer tree-shakes liveness; the // re-export below keeps the binding live. export const _HEADS_INIT = HEADS_INIT;