Tolerate frameError

This commit is contained in:
Kalle
2026-09-29 18:31:27 +03:00
parent b5d3e4a533
commit a5e43de616
5 changed files with 50 additions and 7 deletions

View File

@@ -175,6 +175,9 @@ export function LiveView() {
</div>
<div className={styles.notes}>
{uploadNote} · {clipsNote}
{live.failedFrames > 0
? ` · ${live.failedFrames} frame${live.failedFrames === 1 ? "" : "s"} skipped (analysis failed)`
: null}
</div>
{live.error ? (
<div className={styles.error}>{live.error}</div>

View File

@@ -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(

View File

@@ -245,7 +245,11 @@ async function analyze({ bitmap, t }: AnalyzeRequest): Promise<void> {
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 });
}

View File

@@ -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<void>;
#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 });

View File

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