@gueshbzh-perso/scanner 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  // - `SessionCore`: the session. Cheap calls (`plan`, `commit`), main thread.
7
7
  // - `DecoderCore`: the expensive decoding, one per Web Worker.
8
8
  // The inline engine (no Worker support) uses both on the main thread.
9
+ // Codes read by the browser's native `BarcodeDetector` enter the session with
10
+ // `SessionCore.commitExternal` (see `native.ts`).
9
11
  import init, { WasmFrameDecoder, WasmScanner, WasmScanSession, version } from './wasm/scanner_wasm.js';
10
12
  import type { FramePlan, FrameReport, PixelFormat, ScanResult, WasmScanConfig } from './wasm/scanner_wasm.js';
11
13
  import type { Detection, EngineOptions } from './types.js';
@@ -72,6 +74,12 @@ export function toDetection(r: ScanResult): Detection {
72
74
  return d;
73
75
  }
74
76
 
77
+ function toScanResult(d: Detection): ScanResult {
78
+ const r: ScanResult = { text: d.text, format: d.format, points: d.points, timestampMs: d.timestamp };
79
+ if (d.rawBytes) r.rawBytes = d.rawBytes;
80
+ return r;
81
+ }
82
+
75
83
  export interface FrameOutcome {
76
84
  /** New codes, or `null`. */
77
85
  results: Detection[] | null;
@@ -89,9 +97,14 @@ export class SessionCore {
89
97
  this.session = new WasmScanSession(streamConfig(options));
90
98
  }
91
99
 
92
- /** Strategy plan of the next frame, for a `DecoderCore` configured the same way. */
93
- plan(): FramePlan {
94
- return this.active().plan();
100
+ /**
101
+ * Strategy plan of the next frame, for a `DecoderCore` configured the same
102
+ * way. `fastPath: false` when the native `BarcodeDetector` already searched
103
+ * the frame: the decoder then runs the fallback strategy only.
104
+ */
105
+ plan(fastPath = true): FramePlan {
106
+ const session = this.active();
107
+ return fastPath ? session.plan() : session.planFallbackOnly();
95
108
  }
96
109
 
97
110
  /** Record a decoded frame; returns the new codes and the live ones. */
@@ -102,16 +115,16 @@ export class SessionCore {
102
115
  return { results, live: results ?? session.lastDetections.map(toDetection), decodeMs: report.elapsedMs };
103
116
  }
104
117
 
105
- /** Decode on this thread (inline engine): plan, decode and commit at once. */
106
- process(pixels: Uint8Array, width: number, height: number, format: PixelFormat): FrameOutcome {
118
+ /**
119
+ * Record codes read by the native `BarcodeDetector`: same format filter,
120
+ * confirmation and de-duplication as decoded frames. Points are kept as
121
+ * given (video pixels).
122
+ */
123
+ commitExternal(codes: Detection[], elapsedMs: number): FrameOutcome {
107
124
  const session = this.active();
108
- // Write straight into WASM memory: avoids one more copy of the frame.
109
- session.frameBuffer(pixels.length).set(pixels);
110
- const t0 = performance.now();
111
- const fresh = session.processFrameBuffer(width, height, format);
112
- const decodeMs = performance.now() - t0;
125
+ const fresh = session.commitExternal(codes.map(toScanResult), elapsedMs);
113
126
  const results = fresh ? fresh.map(toDetection) : null;
114
- return { results, live: results ?? session.lastDetections.map(toDetection), decodeMs };
127
+ return { results, live: results ?? session.lastDetections.map(toDetection), decodeMs: elapsedMs };
115
128
  }
116
129
 
117
130
  reset(): void {
package/src/engine.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // with many slow cores (Galaxy A14: decode ~125 ms) the pool multiplies the
10
10
  // frame rate.
11
11
  import { DecoderCore, SessionCore, compileWasm, defaultWasmUrl, loadWasm } from './core.js';
12
- import type { FramePlan, FrameReport } from './core.js';
12
+ import type { FrameOutcome, FramePlan, FrameReport } from './core.js';
13
13
  import type { WorkerRequest, WorkerResponse } from './protocol.js';
14
14
  import type { PixelFormat } from './wasm/scanner_wasm.js';
15
15
  import type { AssetOptions, Detection, EngineOptions } from './types.js';
@@ -28,8 +28,24 @@ export interface Engine {
28
28
  /** Frames that can be decoded at the same time (number of workers). */
29
29
  readonly concurrency: number;
30
30
  configure(options: EngineOptions): Promise<void>;
31
- /** Decode a camera frame. `buffer` is transferred and returned in the reply. */
32
- processFrame(buffer: ArrayBuffer, width: number, height: number, format: PixelFormat): Promise<FrameReply>;
31
+ /**
32
+ * Decode a camera frame. `buffer` is transferred and returned in the reply.
33
+ * `fastPath: false` runs the fallback strategy only, for a frame the
34
+ * native `BarcodeDetector` already searched without finding anything.
35
+ */
36
+ processFrame(
37
+ buffer: ArrayBuffer,
38
+ width: number,
39
+ height: number,
40
+ format: PixelFormat,
41
+ fastPath?: boolean,
42
+ ): Promise<FrameReply>;
43
+ /**
44
+ * Record codes read by the native `BarcodeDetector` in a frame: same
45
+ * format filter, multi-frame confirmation and de-duplication as decoded
46
+ * frames. Points are returned as given.
47
+ */
48
+ commitExternal(codes: Detection[], elapsedMs: number): Promise<FrameOutcome>;
33
49
  /** One-shot scan of a still image, running every strategy. */
34
50
  scanImage(
35
51
  buffer: ArrayBuffer,
@@ -186,10 +202,11 @@ class WorkerPoolEngine implements Engine {
186
202
  width: number,
187
203
  height: number,
188
204
  format: PixelFormat,
205
+ fastPath = true,
189
206
  ): Promise<FrameReply> {
190
207
  if (!this.configured) throw new Error('Engine not configured');
191
208
  const generation = this.generation;
192
- const plan = this.session.plan();
209
+ const plan = this.session.plan(fastPath);
193
210
  let res: OkResponse;
194
211
  try {
195
212
  res = await this.idleWorker().ok({ type: 'decode', buffer, width, height, format, plan }, [buffer]);
@@ -205,6 +222,11 @@ class WorkerPoolEngine implements Engine {
205
222
  return { ...this.session.commit(res.report), buffer: returned };
206
223
  }
207
224
 
225
+ async commitExternal(codes: Detection[], elapsedMs: number) {
226
+ if (!this.configured) throw new Error('Engine not configured');
227
+ return this.session.commitExternal(codes, elapsedMs);
228
+ }
229
+
208
230
  async scanImage(
209
231
  buffer: ArrayBuffer,
210
232
  width: number,
@@ -265,7 +287,10 @@ class InlineEngine implements Engine {
265
287
  constructor(readonly version: string) {}
266
288
 
267
289
  async configure(options: EngineOptions) {
290
+ // Same configuration for both: a plan is an index in the decoder's list
291
+ // of fallback strategies (as with workers).
268
292
  this.session.configure(options);
293
+ this.decoder.configure(options);
269
294
  }
270
295
 
271
296
  async processFrame(
@@ -273,8 +298,21 @@ class InlineEngine implements Engine {
273
298
  width: number,
274
299
  height: number,
275
300
  format: PixelFormat,
301
+ fastPath = true,
276
302
  ): Promise<FrameReply> {
277
- return { ...this.session.process(new Uint8Array(buffer), width, height, format), buffer };
303
+ const plan = this.session.plan(fastPath);
304
+ let report: FrameReport;
305
+ try {
306
+ report = this.decoder.decode(new Uint8Array(buffer), width, height, format, plan);
307
+ } catch (err) {
308
+ this.session.commit(emptyReport(plan));
309
+ throw err;
310
+ }
311
+ return { ...this.session.commit(report), buffer };
312
+ }
313
+
314
+ async commitExternal(codes: Detection[], elapsedMs: number) {
315
+ return this.session.commitExternal(codes, elapsedMs);
278
316
  }
279
317
 
280
318
  async scanImage(
package/src/native.ts ADDED
@@ -0,0 +1,140 @@
1
+ // The browser's native barcode decoder (`BarcodeDetector`, Shape Detection API).
2
+ //
3
+ // Available in Chrome on Android (Google Play Services), macOS (Apple Vision)
4
+ // and ChromeOS; not in Safari, Firefox, nor Chrome on Windows / Linux. Where it
5
+ // exists it decodes outside the page's threads, so it can replace the engine's
6
+ // **fast path**: the engine then only runs its fallback strategies (Linear,
7
+ // CLAHE, Sauvola, inverted…) on the frames the native decoder found nothing
8
+ // in. The native decoder has no such preprocessing, and those strategies are
9
+ // what reads the hard codes (laser-engraved tags, labels in shadow).
10
+ import type { Rect } from './grabber.js';
11
+ import type { BarcodeFormat, Detection, Point } from './types.js';
12
+
13
+ // The API is not in TypeScript's `lib.dom` yet: minimal local declarations.
14
+
15
+ /** A code found by `BarcodeDetector.detect()`. */
16
+ export interface NativeBarcode {
17
+ rawValue: string;
18
+ format: string;
19
+ /** Corners in pixels of the source (for a `<video>`: `videoWidth` space). */
20
+ cornerPoints: readonly Point[];
21
+ }
22
+
23
+ interface BarcodeDetectorInstance {
24
+ detect(source: CanvasImageSource): Promise<NativeBarcode[]>;
25
+ }
26
+
27
+ /** The `BarcodeDetector` constructor. */
28
+ export interface BarcodeDetectorClass {
29
+ new (options?: { formats?: string[] }): BarcodeDetectorInstance;
30
+ getSupportedFormats(): Promise<string[]>;
31
+ }
32
+
33
+ /**
34
+ * Formats of this SDK, all spelled like the native ones (`qr_code`, `ean_13`…)
35
+ * except `micro_qr`, which the native API does not have: when it is asked
36
+ * for, it is still read by the engine's fallback strategies (`Hard` decodes
37
+ * the raw frame with every format), only less often than by a fast path.
38
+ */
39
+ const NATIVE_FORMATS: readonly BarcodeFormat[] = [
40
+ 'qr_code',
41
+ 'data_matrix',
42
+ 'aztec',
43
+ 'pdf417',
44
+ 'code_128',
45
+ 'code_39',
46
+ 'code_93',
47
+ 'ean_13',
48
+ 'ean_8',
49
+ 'upc_a',
50
+ 'upc_e',
51
+ 'itf',
52
+ 'codabar',
53
+ ];
54
+
55
+ /** Native decoder serving one reader configuration. */
56
+ export class NativeDetector {
57
+ private constructor(
58
+ private detector: BarcodeDetectorInstance,
59
+ /** Formats the detector looks for. */
60
+ readonly formats: readonly BarcodeFormat[],
61
+ ) {}
62
+
63
+ /**
64
+ * A detector for `formats` (default: all), or `undefined` when the native
65
+ * decoder cannot replace the engine's fast path: API absent, or one of the
66
+ * requested formats not supported natively (codes of that format would
67
+ * then only be read by the slower fallbacks).
68
+ */
69
+ static async create(
70
+ formats: readonly BarcodeFormat[] | undefined,
71
+ api: BarcodeDetectorClass | undefined = nativeApi(),
72
+ ): Promise<NativeDetector | undefined> {
73
+ if (!api) return undefined;
74
+ let supported: string[];
75
+ try {
76
+ supported = await api.getSupportedFormats();
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ // On Android the list is empty until Google Play Services has downloaded
81
+ // its barcode module (first use on the device, in the background): the
82
+ // detector is unavailable *for now*, the engine does the whole job.
83
+ const wanted = (formats?.length ? formats : NATIVE_FORMATS).filter((f) => f !== 'micro_qr');
84
+ if (wanted.length === 0 || !wanted.every((f) => supported.includes(f))) return undefined;
85
+ try {
86
+ return new NativeDetector(new api({ formats: [...wanted] }), wanted);
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Codes in `source` whose center lies in `rect` (the region of interest, in
94
+ * source pixels), as detections in source pixels. Throws when the native
95
+ * decoder fails.
96
+ */
97
+ async detect(source: CanvasImageSource, rect: Rect): Promise<Detection[]> {
98
+ const found = await this.detector.detect(source);
99
+ const timestamp = Date.now();
100
+ return found.flatMap((code) => {
101
+ const format = code.format as BarcodeFormat;
102
+ if (!this.formats.includes(format) || code.cornerPoints.length === 0) return [];
103
+ if (!contains(rect, center(code.cornerPoints))) return [];
104
+ const points = code.cornerPoints.map((p) => ({ x: p.x, y: p.y }));
105
+ return [{ text: code.rawValue, format, points, timestamp }];
106
+ });
107
+ }
108
+ }
109
+
110
+ /**
111
+ * The browser's own `BarcodeDetector`, if any. Feature detection only (no
112
+ * list of browsers or devices): any browser that ships the API is used.
113
+ *
114
+ * Polyfills are rejected: packages such as `barcode-detector` install a
115
+ * `BarcodeDetector` that runs ZXing in WebAssembly on the main thread,
116
+ * slower than this SDK's own engine in its workers. A built-in class
117
+ * stringifies as `function BarcodeDetector() { [native code] }`.
118
+ */
119
+ function nativeApi(): BarcodeDetectorClass | undefined {
120
+ const api: unknown = Reflect.get(globalThis, 'BarcodeDetector');
121
+ if (typeof api !== 'function' || !isBuiltIn(api)) return undefined;
122
+ return api as BarcodeDetectorClass;
123
+ }
124
+
125
+ /** `true` for a function implemented by the browser, not in JavaScript. */
126
+ export function isBuiltIn(fn: object): boolean {
127
+ return /\{\s*\[native code\]\s*\}\s*$/.test(Function.prototype.toString.call(fn));
128
+ }
129
+
130
+ function center(points: readonly Point[]): Point {
131
+ const n = points.length;
132
+ return {
133
+ x: points.reduce((s, p) => s + p.x, 0) / n,
134
+ y: points.reduce((s, p) => s + p.y, 0) / n,
135
+ };
136
+ }
137
+
138
+ function contains(r: Rect, p: Point): boolean {
139
+ return p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height;
140
+ }
package/src/overlay.ts CHANGED
@@ -181,7 +181,8 @@ function drawPolygon(ctx: CanvasRenderingContext2D, pts: Point[]): void {
181
181
  if (!first) return;
182
182
  ctx.beginPath();
183
183
  if (pts.length === 2 && second) {
184
- // 1D codes report the two ends of the scan line: draw a thick bar.
184
+ // Fallback for a 1D code whose box could not be measured: the two ends
185
+ // of the scan line, drawn as a thick bar.
185
186
  ctx.save();
186
187
  ctx.lineWidth = 10;
187
188
  ctx.globalAlpha *= 0.6;
package/src/reader.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // BarcodeReader: camera preview + frame pipeline + engine, wired together.
2
2
  import { capabilities, listCameras, openCamera, rememberCamera, setTorch, setZoom } from './camera.js';
3
3
  import { pickEngineOptions } from './core.js';
4
- import { createEngine, type Engine } from './engine.js';
4
+ import { createEngine, type Engine, type FrameReply } from './engine.js';
5
5
  import { Feedback } from './feedback.js';
6
6
  import {
7
7
  clampRegion,
@@ -11,22 +11,36 @@ import {
11
11
  regionToVideoRect,
12
12
  videoToElement,
13
13
  } from './geometry.js';
14
- import { CanvasGrabber, createGrabber, type FrameGrabber } from './grabber.js';
14
+ import { CanvasGrabber, createGrabber, type FrameGrabber, type Rect } from './grabber.js';
15
+ import type { FrameOutcome } from './core.js';
16
+ import { NativeDetector } from './native.js';
15
17
  import { Preview } from './overlay.js';
16
18
  import type { Detection, EngineOptions, ReaderEvents, ReaderOptions, ReaderStats, Region } from './types.js';
17
19
 
18
20
  const DEFAULT_REGION: Region = { x: 0.1, y: 0.2, width: 0.8, height: 0.6 };
19
21
 
22
+ /**
23
+ * With the native detector, delay between two engine frames while the
24
+ * native decoder finds nothing (~3 fallbacks per second). A code that only
25
+ * the fallbacks read is still found within a couple of seconds (the
26
+ * strategies share CPU time, the cheap Linear one runs most often), while
27
+ * the workers stay idle most of the time. After a frame where the engine saw
28
+ * a code, the next one is immediate: a 1D code needs a second read.
29
+ */
30
+ const FALLBACK_INTERVAL_MS = 300;
31
+
20
32
  /** A frame read from the video, waiting for (or being decoded by) the engine. */
21
33
  interface GrabbedFrame {
22
34
  buffer: ArrayBuffer;
23
35
  width: number;
24
36
  height: number;
25
37
  /** Region of the video the frame was cropped from (to map points back). */
26
- rect: ReturnType<typeof regionToVideoRect>;
38
+ rect: Rect;
27
39
  grabMs: number;
28
40
  /** Grab order: decodes finish out of order when several workers run. */
29
41
  seq: number;
42
+ /** Run the engine's fast path (`false`: the native detector already searched this frame). */
43
+ fastPath: boolean;
30
44
  }
31
45
 
32
46
  type Listener<T> = (payload: T) => void;
@@ -55,8 +69,20 @@ export class BarcodeReader {
55
69
  private engineOptions: EngineOptions;
56
70
  private running = false;
57
71
  private paused = false;
58
- /** A grab (GPU readback) is in progress. */
72
+ /** A frame is being acquired: grab (GPU readback) and native detection. */
59
73
  private grabbing = false;
74
+ /** Native `BarcodeDetector` used as the fast path, when enabled and available. */
75
+ private native: NativeDetector | undefined;
76
+ /** Bumped when the native detector is replaced: results of the old one are dropped. */
77
+ private nativeGeneration = 0;
78
+ /** The native detector failed once: not used again by this reader. */
79
+ private nativeFailed = false;
80
+ /** Native detector active: a fallback-only frame is being grabbed or decoded. */
81
+ private fallbackInFlight = false;
82
+ /** Native detector active: earliest time of the next fallback-only frame. */
83
+ private nextFallbackAt = 0;
84
+ /** Which decoder drew the current highlights: each one only clears its own. */
85
+ private highlightSource: 'native' | 'engine' | null = null;
60
86
  /** Frames being decoded (at most `engine.concurrency`). */
61
87
  private decoding = 0;
62
88
  /** Sequence number of the last grabbed frame, and of the last one drawn. */
@@ -77,13 +103,15 @@ export class BarcodeReader {
77
103
  private torchOn = false;
78
104
  private buffers: ArrayBuffer[] = [];
79
105
  private frameHandle = 0;
80
- private perf = { frames: 0, since: 0, fps: 0, decodeMs: 0, grabMs: 0, w: 0, h: 0 };
106
+ private perf = { frames: 0, since: 0, fps: 0, decodeMs: 0, grabMs: 0, nativeMs: 0, w: 0, h: 0 };
81
107
 
82
108
  private constructor(
83
109
  private engine: Engine,
84
110
  private options: ReaderOptions,
85
111
  host: HTMLElement,
112
+ native: NativeDetector | undefined,
86
113
  ) {
114
+ this.native = native;
87
115
  this.region = clampRegion(options.regionOfInterest ?? DEFAULT_REGION);
88
116
  this.engineOptions = pickEngineOptions(options);
89
117
  this.preview = new Preview(host, options.overlay);
@@ -101,8 +129,10 @@ export class BarcodeReader {
101
129
  typeof options.host === 'string' ? document.querySelector<HTMLElement>(options.host) : options.host;
102
130
  if (!host) throw new Error(`BarcodeReader: host element not found (${String(options.host)})`);
103
131
  const engine = await createEngine(options);
104
- await engine.configure(pickEngineOptions(options));
105
- return new BarcodeReader(engine, options, host);
132
+ const engineOptions = pickEngineOptions(options);
133
+ await engine.configure(engineOptions);
134
+ const native = await createNative(options, engineOptions);
135
+ return new BarcodeReader(engine, options, host, native);
106
136
  }
107
137
 
108
138
  /** Video input devices (labels are empty until permission is granted). */
@@ -134,6 +164,8 @@ export class BarcodeReader {
134
164
  backend: this.grabber.kind,
135
165
  worker: this.engine.inWorker,
136
166
  workers: this.engine.concurrency,
167
+ native: this.native !== undefined,
168
+ nativeMs: this.perf.nativeMs,
137
169
  };
138
170
  }
139
171
 
@@ -282,7 +314,11 @@ export class BarcodeReader {
282
314
  async configure(options: EngineOptions): Promise<void> {
283
315
  this.assertAlive();
284
316
  this.engineOptions = { ...this.engineOptions, ...options };
317
+ // Detections in flight were made for the old formats: dropped.
318
+ const generation = ++this.nativeGeneration;
285
319
  await this.engine.configure(this.engineOptions);
320
+ const native = this.nativeFailed ? undefined : await createNative(this.options, this.engineOptions);
321
+ if (generation === this.nativeGeneration) this.native = native;
286
322
  }
287
323
 
288
324
  /** Allow `text` to be reported again right away. */
@@ -352,13 +388,14 @@ export class BarcodeReader {
352
388
  * Back-pressure: at most one grab in flight and one frame waiting for a
353
389
  * free worker. While a frame waits, no new grab starts, so frames are
354
390
  * skipped, never queued, and the waiting frame is at most one decode old.
391
+ * The native detection is part of the grab stage, so the same rule keeps
392
+ * at most one `detect()` in flight.
355
393
  */
356
394
  private tick(): void {
357
395
  if (!this.running) return;
358
396
  if (!this.grabbing && !this.pending && !this.paused && this.preview.video.videoWidth > 0) {
359
397
  this.grabbing = true;
360
- this.grabFrame()
361
- .then((frame) => this.enqueue(frame))
398
+ this.acquire()
362
399
  .catch((err) => this.handleFrameError(err))
363
400
  .finally(() => (this.grabbing = false));
364
401
  }
@@ -366,11 +403,84 @@ export class BarcodeReader {
366
403
  this.schedule();
367
404
  }
368
405
 
369
- private async grabFrame(): Promise<GrabbedFrame> {
406
+ /**
407
+ * Grab stage. With the native `BarcodeDetector`, the frame is searched
408
+ * natively first, straight from the `<video>` (Chrome reads it outside the
409
+ * page's threads): no grab of our own. Codes it finds are committed right
410
+ * away. When it finds nothing, the engine gets a frame **from time to
411
+ * time** (`FALLBACK_INTERVAL_MS`), without its fast path, for one fallback
412
+ * strategy: the safety net for what the native decoder has no
413
+ * preprocessing for (laser engraving, light-on-dark codes, `micro_qr`…).
414
+ * Grabbing and decoding every frame as well would double the reads of the
415
+ * frame and keep the workers busy on frames the native decoder handles,
416
+ * costing battery and CPU it also needs.
417
+ */
418
+ private async acquire(): Promise<void> {
419
+ const native = this.native;
420
+ const rect = this.videoRect();
421
+ if (!native) {
422
+ this.enqueue(await this.grabFrame(rect, true));
423
+ return;
424
+ }
425
+ const generation = this.nativeGeneration;
426
+ const t0 = performance.now();
427
+ let codes: Detection[];
428
+ try {
429
+ codes = await native.detect(this.preview.video, rect);
430
+ } catch (err) {
431
+ this.disableNative(err);
432
+ this.enqueue(await this.grabFrame(rect, true));
433
+ return;
434
+ }
435
+ const nativeMs = performance.now() - t0;
436
+ // Replaced by `configure()` meanwhile: results made for the old formats.
437
+ if (generation !== this.nativeGeneration || !this.running || this.paused) return;
438
+
439
+ if (codes.length === 0 && this.fallbackDue()) {
440
+ this.fallbackInFlight = true;
441
+ this.updatePerf({ nativeMs, counted: false });
442
+ try {
443
+ this.enqueue(await this.grabFrame(rect, false));
444
+ } catch (err) {
445
+ this.fallbackInFlight = false;
446
+ throw err;
447
+ }
448
+ return;
449
+ }
450
+ const outcome = await this.engine.commitExternal(codes, nativeMs);
451
+ if (generation !== this.nativeGeneration || !this.running || this.paused) return;
452
+ this.failures = 0;
453
+ this.updatePerf({ nativeMs });
454
+ // Native points are already in video pixels.
455
+ this.present(outcome, ++this.grabbed, 'native', (d) => d);
456
+ }
457
+
458
+ /** The engine may decode a frame for its fallbacks (native detector active). */
459
+ private fallbackDue(): boolean {
460
+ return !this.fallbackInFlight && performance.now() >= this.nextFallbackAt;
461
+ }
462
+
463
+ /** The native decoder threw: decode with the engine only from now on. */
464
+ private disableNative(err: unknown): void {
465
+ if (this.nativeFailed) return;
466
+ // Like the WebGL → Canvas 2D fallback: the reader keeps working, so this
467
+ // is a warning, not an `error` event.
468
+ console.warn('[scanner] native BarcodeDetector failed, decoding with the engine only:', err);
469
+ this.nativeFailed = true;
470
+ this.native = undefined;
471
+ this.nativeGeneration++;
472
+ }
473
+
474
+ /** The region of interest, in video pixels. */
475
+ private videoRect(): Rect {
370
476
  const video = this.preview.video;
371
477
  const ew = this.preview.width || video.videoWidth;
372
478
  const eh = this.preview.height || video.videoHeight;
373
- const rect = regionToVideoRect(this.region, video.videoWidth, video.videoHeight, ew, eh);
479
+ return regionToVideoRect(this.region, video.videoWidth, video.videoHeight, ew, eh);
480
+ }
481
+
482
+ private async grabFrame(rect: Rect, fastPath: boolean): Promise<GrabbedFrame> {
483
+ const video = this.preview.video;
374
484
  const { width, height } = outputSize(rect, this.options.processing?.maxDimension ?? 1024);
375
485
 
376
486
  const t0 = performance.now();
@@ -381,12 +491,21 @@ export class BarcodeReader {
381
491
  this.buffers.push(buffer);
382
492
  throw new GrabError(err);
383
493
  }
384
- return { buffer, width, height, rect, grabMs: performance.now() - t0, seq: ++this.grabbed };
494
+ return {
495
+ buffer,
496
+ width,
497
+ height,
498
+ rect,
499
+ grabMs: performance.now() - t0,
500
+ seq: ++this.grabbed,
501
+ fastPath,
502
+ };
385
503
  }
386
504
 
387
505
  private enqueue(frame: GrabbedFrame): void {
388
506
  if (!this.running || this.paused) {
389
507
  this.buffers.push(frame.buffer);
508
+ if (!frame.fastPath) this.fallbackInFlight = false;
390
509
  } else if (this.decoding >= this.engine.concurrency) {
391
510
  this.pending = frame;
392
511
  } else {
@@ -408,31 +527,58 @@ export class BarcodeReader {
408
527
 
409
528
  private async decodeFrame(frame: GrabbedFrame): Promise<void> {
410
529
  const { width, height, rect } = frame;
411
- const reply = await this.engine.processFrame(frame.buffer, width, height, 'luma');
530
+ let reply: FrameReply;
531
+ try {
532
+ reply = await this.engine.processFrame(frame.buffer, width, height, 'luma', frame.fastPath);
533
+ } finally {
534
+ if (!frame.fastPath) {
535
+ this.fallbackInFlight = false;
536
+ this.nextFallbackAt = performance.now() + FALLBACK_INTERVAL_MS;
537
+ }
538
+ }
412
539
  this.buffers.push(reply.buffer);
413
540
  if (!this.running) return;
414
541
  this.failures = 0;
415
-
416
- this.updatePerf(frame.grabMs, reply.decodeMs, width, height);
417
-
418
- const video = this.preview.video;
419
- const vw = video.videoWidth;
420
- const vh = video.videoHeight;
421
- const toVideo = (d: Detection): Detection => ({
542
+ if (!frame.fastPath && reply.live.length > 0) this.nextFallbackAt = 0;
543
+
544
+ this.updatePerf({ grab: frame, decodeMs: reply.decodeMs });
545
+ // Fallback-only frames (native detector active) are decoded one at a
546
+ // time, so they cannot finish out of order: numbered when they finish,
547
+ // after the native frames that ran meanwhile.
548
+ const seq = frame.fastPath ? frame.seq : ++this.grabbed;
549
+ this.present(reply, seq, 'engine', (d) => ({
422
550
  ...d,
423
551
  points: decoderToVideo(d.points, rect, width, height),
424
- });
425
- // An older frame finishing after a newer one must not move the highlights back.
426
- if (frame.seq > this.drawn) {
427
- this.drawn = frame.seq;
428
- const live = reply.live.map(toVideo);
552
+ }));
553
+ }
554
+
555
+ /** Draw the live codes of frame `seq` and report the new ones, mapped to video pixels by `toVideo`. */
556
+ private present(
557
+ outcome: Pick<FrameOutcome, 'results' | 'live'>,
558
+ seq: number,
559
+ source: 'native' | 'engine',
560
+ toVideo: (d: Detection) => Detection,
561
+ ): void {
562
+ // An older frame finishing after a newer one must not move the highlights
563
+ // back. With the native detector, a decoder that found nothing does not
564
+ // erase what the other one drew: the native decoder would otherwise
565
+ // erase, ~30 times per second, a code only the fallbacks can read.
566
+ const owns = outcome.live.length > 0 || this.highlightSource === source || !this.highlightSource;
567
+ if (seq > this.drawn && owns) {
568
+ this.drawn = seq;
569
+ this.highlightSource = outcome.live.length > 0 ? source : null;
570
+ const video = this.preview.video;
571
+ const vw = video.videoWidth;
572
+ const vh = video.videoHeight;
429
573
  const layout = coverLayout(vw, vh, this.preview.width || vw, this.preview.height || vh);
430
- this.preview.setDetections(live.map((d) => d.points.map((p) => videoToElement(p, layout))));
574
+ this.preview.setDetections(
575
+ outcome.live.map((d) => toVideo(d).points.map((p) => videoToElement(p, layout))),
576
+ );
431
577
  }
432
578
 
433
- if (reply.results && reply.results.length > 0) {
579
+ if (outcome.results && outcome.results.length > 0) {
434
580
  this.feedback.play();
435
- this.emit('detected', reply.results.map(toVideo));
581
+ this.emit('detected', outcome.results.map(toVideo));
436
582
  }
437
583
  }
438
584
 
@@ -461,13 +607,26 @@ export class BarcodeReader {
461
607
  return new ArrayBuffer(size);
462
608
  }
463
609
 
464
- private updatePerf(grabMs: number, decodeMs: number, w: number, h: number): void {
610
+ /**
611
+ * Account for a processed frame. `counted: false` for a native miss handed
612
+ * to the engine: that frame is counted once, when the engine is done.
613
+ */
614
+ private updatePerf(s: {
615
+ grab?: GrabbedFrame;
616
+ decodeMs?: number;
617
+ nativeMs?: number;
618
+ counted?: boolean;
619
+ }): void {
465
620
  const p = this.perf;
466
- p.frames++;
467
- p.grabMs = p.grabMs ? p.grabMs * 0.9 + grabMs * 0.1 : grabMs;
468
- p.decodeMs = p.decodeMs ? p.decodeMs * 0.9 + decodeMs * 0.1 : decodeMs;
469
- p.w = w;
470
- p.h = h;
621
+ const ema = (avg: number, v: number) => (avg ? avg * 0.9 + v * 0.1 : v);
622
+ if (s.counted !== false) p.frames++;
623
+ if (s.grab) {
624
+ p.grabMs = ema(p.grabMs, s.grab.grabMs);
625
+ p.w = s.grab.width;
626
+ p.h = s.grab.height;
627
+ }
628
+ if (s.decodeMs !== undefined) p.decodeMs = ema(p.decodeMs, s.decodeMs);
629
+ if (s.nativeMs !== undefined) p.nativeMs = ema(p.nativeMs, s.nativeMs);
471
630
  const now = performance.now();
472
631
  if (now - p.since >= 1000) {
473
632
  p.fps = (p.frames * 1000) / (now - p.since);
@@ -518,6 +677,18 @@ class GrabError extends Error {
518
677
  }
519
678
  }
520
679
 
680
+ /** The native detector unless `processing.nativeDetector` is `'off'`, when it can serve `engineOptions`. */
681
+ function createNative(
682
+ options: ReaderOptions,
683
+ engineOptions: EngineOptions,
684
+ ): Promise<NativeDetector | undefined> {
685
+ // On by default: on a Galaxy A14 (Chrome, Play Services) it read codes very
686
+ // well whatever the orientation of the phone, where the engine's fast pass
687
+ // only reads 1D codes roughly aligned with the image rows.
688
+ if (options.processing?.nativeDetector === 'off') return Promise.resolve(undefined);
689
+ return NativeDetector.create(engineOptions.formats);
690
+ }
691
+
521
692
  function waitForVideo(video: HTMLVideoElement): Promise<void> {
522
693
  if (video.readyState >= 2 && video.videoWidth > 0) return Promise.resolve();
523
694
  return new Promise((resolve, reject) => {