diff --git a/app/features/scanner/components/LiveView.tsx b/app/features/scanner/components/LiveView.tsx index 1206f2d6e..16bda7d79 100644 --- a/app/features/scanner/components/LiveView.tsx +++ b/app/features/scanner/components/LiveView.tsx @@ -175,6 +175,9 @@ export function LiveView() {
{uploadNote} · {clipsNote} + {live.failedFrames > 0 + ? ` · ${live.failedFrames} frame${live.failedFrames === 1 ? "" : "s"} skipped (analysis failed)` + : null}
{live.error ? (
{live.error}
diff --git a/app/features/scanner/components/live-session.ts b/app/features/scanner/components/live-session.ts index 0ac4d132e..e7359e6f9 100644 --- a/app/features/scanner/components/live-session.ts +++ b/app/features/scanner/components/live-session.ts @@ -108,6 +108,8 @@ export interface LiveSnapshot { /** what the clip encoder is getting from that track; null while clips are off */ audioSignal: AudioSignal | null; clips: ClipsState; + /** frames whose analysis threw this capture; each is skipped and the capture carries on */ + failedFrames: number; } const IDLE: LiveSnapshot = { @@ -119,6 +121,7 @@ const IDLE: LiveSnapshot = { audioError: null, audioSignal: null, clips: "off", + failedFrames: 0, }; let snapshot = IDLE; @@ -239,16 +242,18 @@ export async function startCapture({ await video.play(); // the worker first: a failed init must not leave the camera on - client = new AnalyzerClient(onResult, onWorkerError, undefined, { + const starting = new AnalyzerClient(onResult, onWorkerError, undefined, { frameQueueLimit: FRAME_QUEUE_LIMIT, webgpu: settings.webgpu, attachFrames: saveFrames, + onFrameError, }); + client = starting; try { - await client.whenReady(); + await starting.whenReady(); } catch (error) { - client.dispose(); - client = null; + starting.dispose(); + if (client === starting) client = null; throw error; } @@ -310,10 +315,15 @@ export async function startCapture({ /** Ends the capture: the session's clips roll into history and unsent matches get one last send. */ export function stopCapture(): void { if (snapshot.status === "idle") return; + endCapture({ ...IDLE }); +} + +/** Tears the capture down into `next`, rolling the session's clips and sending unsent matches as Stop does. */ +function endCapture(next: LiveSnapshot): void { const stream = snapshot.stream; release(); if (stream) stopTracks(stream); - set({ ...IDLE }); + set(next); // the scan ending is the last match boundary — flush what's unsent // (partials are safe: the server merges them into fuller resends) if (uploadEnabled()) void sendLive(unsentMatches, newestSessionKey()); @@ -379,11 +389,29 @@ function stopTracks(stream: MediaStream): void { for (const track of stream.getTracks()) track.stop(); } +/** The worker itself is gone (init failed or it crashed), so the capture cannot go on. */ function onWorkerError(message: string): void { + const failed: LiveSnapshot = { + ...IDLE, + status: "error", + error: describeError(new Error(message)), + }; + if (snapshot.status === "running") { + endCapture(failed); + return; + } const stream = snapshot.stream; release(); if (stream) stopTracks(stream); - set({ ...IDLE, status: "error", error: describeError(new Error(message)) }); + set(failed); +} + +function onFrameError(message: string): void { + if (snapshot.failedFrames === 0) { + // biome-ignore lint/suspicious/noConsole: the capture carries on, so the console is where a failing frame shows why + console.warn("scanner: frame analysis failed", message); + } + set({ failedFrames: snapshot.failedFrames + 1 }); } function onResult( diff --git a/app/features/scanner/worker/analyzer.worker.ts b/app/features/scanner/worker/analyzer.worker.ts index ab47cafca..1479b0d0e 100644 --- a/app/features/scanner/worker/analyzer.worker.ts +++ b/app/features/scanner/worker/analyzer.worker.ts @@ -245,7 +245,11 @@ async function analyze({ bitmap, t }: AnalyzeRequest): Promise { try { await analyzeFrame(bitmap, t); } catch (error) { - post({ kind: "error", message: `analyze failed: ${String(error)}` }); + post({ + kind: "frameError", + t, + message: `analyze failed: ${String(error)}`, + }); } post({ kind: "done", t, calm: scheduler!.calm(t), telemetry }); } diff --git a/app/features/scanner/worker/client.ts b/app/features/scanner/worker/client.ts index da74ae508..10837fe2d 100644 --- a/app/features/scanner/worker/client.ts +++ b/app/features/scanner/worker/client.ts @@ -48,6 +48,7 @@ export class AnalyzerClient { #busy = false; readonly #onResult: ResultHandler; readonly #onError: ErrorHandler; + readonly #onFrameError: ErrorHandler; readonly #onDone: DoneHandler | undefined; readonly #readyPromise: Promise; #rejectReady: ((error: Error) => void) | undefined; @@ -70,11 +71,14 @@ export class AnalyzerClient { webgpu?: boolean; /** ship the analyzed frame's PNG on results (default true) */ attachFrames?: boolean; + /** one frame's analysis threw; the worker carries on (default onError) */ + onFrameError?: ErrorHandler; } = {}, ) { this.#frameQueueLimit = options.frameQueueLimit ?? 0; this.#onResult = onResult; this.#onError = onError; + this.#onFrameError = options.onFrameError ?? onError; this.#onDone = onDone; this.#worker = new Worker( new URL("./analyzer.worker.ts", import.meta.url), @@ -97,6 +101,8 @@ export class AnalyzerClient { resolveReady(); } else if (msg.kind === "result") { this.#onResult(msg); + } else if (msg.kind === "frameError") { + this.#onFrameError(msg.message); } else if (msg.kind === "done") { this.#settle(); this.#onDone?.(msg.t, { calm: msg.calm, telemetry: msg.telemetry }); diff --git a/app/features/scanner/worker/protocol.ts b/app/features/scanner/worker/protocol.ts index 344756161..e2ec44dd7 100644 --- a/app/features/scanner/worker/protocol.ts +++ b/app/features/scanner/worker/protocol.ts @@ -62,6 +62,8 @@ export type WorkerResponse = /** lossless PNG of the exact frame that was analyzed; present when events fired */ frame?: Blob; } + /** frame t threw mid-analysis; the worker is still usable and a "done" follows */ + | { kind: "frameError"; t: number; message: string } /** all due detectors have reported for frame t (per-frame path only) */ | { kind: "done";