scanner.ts
1/**2 * scanner.ts — camera-based Aztec scanning for Codex restoration.3 *4 * Wraps `@zxing/library`'s `BrowserAztecCodeReader` with a tiny lifecycle:5 * one `startScanner` returns a `{ stop }` handle that releases the camera6 * via `reset()`. The decoder fires on every frame; `NotFoundException` is7 * the library's "no code in this frame" signal and must be swallowed8 * silently — otherwise the console fills up at ~30 errors/second.9 *10 * Deduplication and envelope parsing are NOT this module's job. Callers11 * feed every decoded string through `parseRawScan` + `ChunkBuffer` in12 * `./restore.ts`.13 *14 * Browser-only. Never imported by tests directly — vitest runs in `node`15 * environment per `vitest.config.ts` and `navigator.mediaDevices` is16 * undefined there.17 */1819import { BrowserAztecCodeReader, NotFoundException } from "@zxing/library";2021export interface ScannerOptions {22 videoEl: HTMLVideoElement;23 /** Called for every successful Aztec decode. The caller dedups via ChunkBuffer. */24 onScan: (text: string) => void;25 /** Reported on hard errors only — NotFoundException is filtered out. */26 onError?: (err: Error) => void;27}2829export interface ScannerHandle {30 stop: () => void;31}3233export async function startScanner(34 opts: ScannerOptions,35): Promise<ScannerHandle> {36 const reader = new BrowserAztecCodeReader();3738 // Passing `null` for deviceId lets zxing pick the default camera (typically39 // the back camera on mobile, the only camera on a laptop). The library40 // attaches the MediaStream to the provided <video> element internally.41 await reader.decodeFromVideoDevice(null, opts.videoEl, (result, err) => {42 if (result) {43 opts.onScan(result.getText());44 return;45 }46 if (err && !(err instanceof NotFoundException)) {47 opts.onError?.(err);48 }49 });5051 return {52 stop: () => {53 reader.reset();54 },55 };56}