/** * Deep camera verification: capture a fresh photo from the candidate's * webcam, run face detection on it, and pass / fail based on the face * count. * * A face detector is mandatory. The check has exactly one pass * condition — the detector returns a face count inside the allowed * band. There is no brightness fallback: a covered or dark lens simply * yields zero faces and fails as `no-face`, which is both simpler and * stricter than the old "is the frame bright enough?" heuristic. * * Improvements over the teq-ts equivalent: * - Reuses the camera stream across retries instead of reopening it. * teq-ts does up to 3 × `getUserMedia` + 700ms warmup for a covered- * webcam case (~3s of camera light flickering). * - Warmup duration is a parameter, not a hardcoded 700ms. */ import type { CheckRow } from "./checks.js"; import { captureFrame, createHiddenCameraElement, type HiddenCameraOptions, type HiddenCameraResult, waitForRealFrame, } from "./hidden-camera.js"; export interface DeepCameraFaceResult { faceCount: number; /** * Backend-owned camera-check helpers can include the persisted * evidence row id. The SDK does not need it for pass/fail, but * Vue uses it to avoid uploading the same frame a second time. */ photoId?: string; /** * Server-side verdict for the exact stored JPEG. When present it * wins over the local face-count classification for detector * failures, so the UI can fail closed while still preserving the * photo on the dashboard. */ verdict?: "passed" | "no-face" | "multiple-faces" | "detector-error"; attempt?: number; byteSize?: number; inferenceMs?: number; } export interface DeepCameraCheckOptions { /** * Customer-provided face detector. The SDK can't ship a face model — * each customer's compliance / model preferences differ — so the * host must wire one. The detector is mandatory: omitting it fails * the check as `deep-check-failed` (a misconfiguration, not a free * pass). The contract is strict in three ways: * - `faceCount === 1` is the only pass (proctoring needs exactly * the candidate in frame, not zero and not more than one). * - `faceCount >= 2` fails as `multiple-faces`. * - An error thrown from the callback fails as `deep-check-failed`. * * The strict-error stance is deliberate. A host wiring this is opting * into face verification -- "no face = no pass." Silently passing * when the detector errors would turn every operational failure of * the detector (server down, network blip, model crashed) into a * free pass exactly when verification matters most. If you want * softer semantics (e.g. accept the candidate during a known * outage), handle it in your callback by returning a synthetic * `{ faceCount: 1 }` rather than throwing. */ detectFace?: (jpegDataUrl: string) => Promise; /** * Optional pre-acquired stream to reuse instead of opening a fresh * hidden camera. Use this during in-test rechecks where the * proctoring stream is already live — keeps the camera light from * flickering. * * When provided, the check captures from this stream and skips * warmup (the stream is already producing real frames). */ existingStream?: { videoElement: HTMLVideoElement }; /** * Sensor warmup window after the camera opens, in ms. Default: 700. * Skipped when `existingStream` is provided. */ warmupMs?: number; /** Number of capture+detect attempts before giving up. Default: 3. */ maxAttempts?: number; /** * Maximum number of faces allowed in the captured frame for a pass. * - 1 (default) -- exactly one face required (typical proctoring; * zero faces → `no-face`, two-plus → `multiple-faces`). * - 2+ -- allows that many faces, useful for assistive-companion * scenarios. Anything above still emits `multiple-faces` if * exceeded. */ maxFacesAllowed?: number; /** Hidden camera options. Forwarded as-is. */ cameraOptions?: HiddenCameraOptions; /** * Optional logger. Receives one-line strings tagged for the check. * Off by default so production runs stay quiet. */ log?: (message: string) => void; } /** * Per-attempt record returned alongside the final verdict. Lets * consumers (the Vue wizard's camera step, ultimately the * dashboard) persist every frame the check saw, not just the * final one. Useful for forensics: did the detector return a * different face count each attempt? Was the candidate off-camera * on attempt 1 then in frame for 2 and 3? The intermediate frames * are the evidence. */ export interface DeepCameraAttempt { /** 1-based attempt index within the loop. */ attempt: number; /** JPEG data URL of the frame captured for this attempt. */ photo: string; /** Verdict the loop assigned to this specific attempt: * "passed" -- the frame that ended the loop with a pass * "no-face" -- detector returned 0 faces (off-camera or * covered lens) * "multiple-faces" -- detector returned 2+ faces * "detector-error" -- detectFace callback threw */ verdict: | "passed" | "no-face" | "multiple-faces" | "detector-error"; /** Face count returned by detectFace, if a detector was wired * and the call succeeded for this attempt. */ faceCount?: number; /** Persisted evidence id when the detector stores the frame server-side. */ photoId?: string; /** Server attempt index when it differs from the local retry index. */ serverAttempt?: number; /** Stored image size when the backend owns persistence. */ byteSize?: number; /** Detector runtime when returned by the backend. */ inferenceMs?: number; } export interface DeepCameraCheckResult { state: CheckRow["state"]; /** Final attempt's JPEG. Stays at the top level so existing * consumers (engine row's `photo` field, the dashboard's * "captured photo" panel) work unchanged. */ photo?: string; /** All attempts the loop saw, in order. Includes the final * attempt (which is also exposed at top-level `photo`). */ attempts: DeepCameraAttempt[]; } const DEFAULTS = { warmupMs: 700, maxAttempts: 3, maxFacesAllowed: 1, } as const; /** * Run the deep camera verification. * * Stream lifecycle: * - If `existingStream` is provided, the function only takes snapshots * and never opens a stream — the caller owns the lifecycle. * - Otherwise, the function opens a hidden camera once, retries * snapshots from the same stream, and tears it down on the way out * (success or failure). */ export const runDeepCameraCheck = async ( options: DeepCameraCheckOptions = {}, ): Promise => { const warmupMs = options.warmupMs ?? DEFAULTS.warmupMs; const maxFacesAllowed = options.maxFacesAllowed ?? DEFAULTS.maxFacesAllowed; const maxAttempts = options.maxAttempts ?? DEFAULTS.maxAttempts; const log = options.log ?? (() => undefined); // The detector is mandatory. Without it there is nothing to judge the // frame against — and silently passing (the old brightness-fallback // behaviour) would turn a missing detector into a free pass for every // candidate. Fail closed as a misconfiguration so the host notices. const { detectFace } = options; if (!detectFace) { log("no detectFace supplied — failing as misconfiguration"); return { state: { kind: "fail", code: "deep-check-failed", detail: "Face detector not configured", }, attempts: [], }; } let hidden: HiddenCameraResult | null = null; let video: HTMLVideoElement; try { if (options.existingStream) { video = options.existingStream.videoElement; log("reusing existing stream — waiting for real frame"); await waitForRealFrame(video); } else { log("opening hidden camera"); hidden = await createHiddenCameraElement(options.cameraOptions); video = hidden.videoElement; log(`stream acquired, waiting ${warmupMs}ms for sensor warmup`); await delay(warmupMs); } let lastPhoto: string | undefined; // Hoisted to function scope so the post-loop verdict branch // can read the most recent face count from any prior attempt. // (Was scoped to the `if (options.detectFace)` block before, // which made the multi-attempt logic forget previous counts.) let lastFaceCount: number | null = null; const attempts: DeepCameraAttempt[] = []; /** * Append the attempt to the per-attempt log. Called immediately * before any `return` so the caller receives the full audit * trail (including the verdict that ended the loop). */ const recordAttempt = ( n: number, photo: string, verdict: DeepCameraAttempt["verdict"], faceCount?: number, meta?: Omit< DeepCameraAttempt, "attempt" | "photo" | "verdict" | "faceCount" >, ): void => { const entry: DeepCameraAttempt = { attempt: n, photo, verdict, ...(meta ?? {}), }; if (faceCount !== undefined) entry.faceCount = faceCount; attempts.push(entry); }; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { log(`attempt ${attempt}/${maxAttempts}`); let captured; try { captured = captureFrame(video); } catch (err) { log(`capture failed: ${err instanceof Error ? err.message : String(err)}`); return { state: { kind: "fail", code: "deep-check-failed", detail: "Unable to capture frame", }, attempts, }; } lastPhoto = captured.dataUrl; if (captured.isLikelyBlank) { log( `attempt ${attempt} blank frame avg=${captured.averageLuma?.toFixed(1) ?? "n/a"} std=${captured.lumaStdDev?.toFixed(1) ?? "n/a"}`, ); lastFaceCount = 0; recordAttempt(attempt, captured.dataUrl, "no-face", 0); if (attempt < maxAttempts) { await delay(300); continue; } return { state: { kind: "fail", code: "no-face", detail: "No face detected", }, photo: lastPhoto, attempts, }; } let faceResult: DeepCameraFaceResult | null = null; let detectError: Error | null = null; try { faceResult = await detectFace(captured.dataUrl); } catch (err) { // The host opted into face verification -- "no face = no pass." // A detector error is a hard fail. Retry within the same loop // in case the next attempt succeeds (a single 502 from a flaky // inference server shouldn't sink the candidate). After max // attempts, fail as `deep-check-failed` -- the verifier is // broken, and `no-face` would misdirect the candidate's // recovery efforts. detectError = err instanceof Error ? err : new Error(String(err)); log(`attempt ${attempt} face-detect threw: ${detectError.message}`); } if (faceResult) { const faces = faceResult.faceCount ?? 0; lastFaceCount = faces; log(`attempt ${attempt} faces=${faces}`); const attemptMeta = { ...(faceResult.photoId ? { photoId: faceResult.photoId } : {}), ...(typeof faceResult.attempt === "number" ? { serverAttempt: faceResult.attempt } : {}), ...(typeof faceResult.byteSize === "number" ? { byteSize: faceResult.byteSize } : {}), ...(typeof faceResult.inferenceMs === "number" ? { inferenceMs: faceResult.inferenceMs } : {}), }; if (faceResult.verdict === "detector-error") { recordAttempt( attempt, captured.dataUrl, "detector-error", faces, attemptMeta, ); if (attempt < maxAttempts) { await delay(300); continue; } return { state: { kind: "fail", code: "deep-check-failed", detail: "Face check failed", }, photo: lastPhoto, attempts, }; } // Pass when faces falls inside the allowed band: // - At least 1 face (zero = covered / off-camera). // - At most `maxFacesAllowed` (default 1; higher allows // a chaperone or assistive companion alongside the // candidate). const inBand = faces >= 1 && faces <= maxFacesAllowed; if (inBand) { recordAttempt(attempt, captured.dataUrl, "passed", faces, attemptMeta); return { state: { kind: "pass", detail: cameraReadyDetail(video) }, photo: lastPhoto, attempts, }; } // Not in band -- decide per-attempt verdict so the // dashboard can show "attempt 2: no face / attempt 3: // 2 faces" rather than a uniform string. const perAttemptVerdict: DeepCameraAttempt["verdict"] = faces === 0 ? "no-face" : "multiple-faces"; recordAttempt( attempt, captured.dataUrl, perAttemptVerdict, faces, attemptMeta, ); } else { // Detector threw; record as detector-error. recordAttempt(attempt, captured.dataUrl, "detector-error"); } if (attempt < maxAttempts) { await delay(300); continue; } // Exhausted attempts. Distinguish the three failure modes // so the wizard's recovery copy can target the right cause: // - detector errored every time → `deep-check-failed` // - detector returned 0 faces every time → `no-face`. Either // the candidate is off-camera, the lens is covered, or // something is occluding their face. // - detector returned ≥2 faces every time → `multiple-faces`. // Another person is visible, or a poster/screen/photo // behind the candidate is being read as a face. if (detectError) { return { state: { kind: "fail", code: "deep-check-failed", detail: `Face check failed: ${detectError.message}`, }, photo: lastPhoto, attempts, }; } // Fail as `multiple-faces` when the detector saw more faces // than the policy allows. With the default `maxFacesAllowed = 1` // this matches the original "any 2+ is a violation" rule; raising // the policy permits the band shift. if (lastFaceCount !== null && lastFaceCount > maxFacesAllowed) { return { state: { kind: "fail", code: "multiple-faces", detail: `${lastFaceCount} faces detected`, }, photo: lastPhoto, attempts, }; } return { state: { kind: "fail", code: "no-face", detail: "No face detected", }, photo: lastPhoto, attempts, }; } // Defensive — shouldn't reach here. return { state: { kind: "fail", code: "deep-check-failed", detail: "Exhausted attempts", }, attempts, }; } finally { if (hidden) { hidden.cleanup(); log("stream closed"); } } }; const cameraReadyDetail = (video: HTMLVideoElement): string => { const stream = video.srcObject instanceof MediaStream ? video.srcObject : null; const label = stream?.getVideoTracks()[0]?.label; return label && label.length > 0 ? label : "Ready"; }; const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms));