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";