@gueshbzh-perso/scanner 0.5.1 → 0.7.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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +38 -14
  3. package/dist/camera.d.ts.map +1 -1
  4. package/dist/camera.js +5 -2
  5. package/dist/camera.js.map +1 -1
  6. package/dist/core.d.ts +28 -10
  7. package/dist/core.d.ts.map +1 -1
  8. package/dist/core.js +92 -42
  9. package/dist/core.js.map +1 -1
  10. package/dist/engine.d.ts +26 -3
  11. package/dist/engine.d.ts.map +1 -1
  12. package/dist/engine.js +159 -51
  13. package/dist/engine.js.map +1 -1
  14. package/dist/grabber.d.ts +28 -0
  15. package/dist/grabber.d.ts.map +1 -1
  16. package/dist/grabber.js +81 -3
  17. package/dist/grabber.js.map +1 -1
  18. package/dist/index.d.ts +0 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +0 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/overlay.d.ts.map +1 -1
  23. package/dist/overlay.js +12 -11
  24. package/dist/overlay.js.map +1 -1
  25. package/dist/protocol.d.ts +9 -13
  26. package/dist/protocol.d.ts.map +1 -1
  27. package/dist/reader.d.ts +33 -3
  28. package/dist/reader.d.ts.map +1 -1
  29. package/dist/reader.js +113 -25
  30. package/dist/reader.js.map +1 -1
  31. package/dist/types.d.ts +11 -5
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/wasm/THIRD_PARTY_LICENSES.md +3879 -0
  34. package/dist/wasm/scanner_wasm.d.ts +52 -0
  35. package/dist/wasm/scanner_wasm.js +87 -0
  36. package/dist/wasm/scanner_wasm_bg.wasm +0 -0
  37. package/dist/wasm/scanner_wasm_bg.wasm.d.ts +6 -0
  38. package/dist/worker.js +15 -17
  39. package/dist/worker.js.map +1 -1
  40. package/package.json +6 -3
  41. package/src/camera.ts +107 -0
  42. package/src/core.ts +189 -0
  43. package/src/engine.ts +348 -0
  44. package/src/feedback.ts +50 -0
  45. package/src/geometry.ts +55 -0
  46. package/src/grabber.ts +385 -0
  47. package/src/index.ts +20 -0
  48. package/src/overlay.ts +225 -0
  49. package/src/protocol.ts +38 -0
  50. package/src/reader.ts +474 -0
  51. package/src/scan-image.ts +88 -0
  52. package/src/types.ts +155 -0
  53. package/src/worker.ts +57 -0
  54. package/dist/wasm/scanner_wasm_simd_bg.wasm +0 -0
package/src/engine.ts ADDED
@@ -0,0 +1,348 @@
1
+ // Decoding engine facade: same API whether decoding runs in Web Workers or inline.
2
+ //
3
+ // With workers, frames are decoded **in parallel** by a pool of stateless
4
+ // decoders (one WebAssembly instance per worker), while the scan session
5
+ // (strategy scheduling, multi-frame confirmation, de-duplication) runs on the
6
+ // main thread: its calls take microseconds, and a single instance of the
7
+ // state is what keeps confirmations and de-duplication correct across
8
+ // workers. Decoding is CPU-bound and single-threaded per frame, so on phones
9
+ // with many slow cores (Galaxy A14: decode ~125 ms) the pool multiplies the
10
+ // frame rate.
11
+ import { DecoderCore, SessionCore, compileWasm, defaultWasmUrl, loadWasm } from './core.js';
12
+ import type { FramePlan, FrameReport } from './core.js';
13
+ import type { WorkerRequest, WorkerResponse } from './protocol.js';
14
+ import type { PixelFormat } from './wasm/scanner_wasm.js';
15
+ import type { AssetOptions, Detection, EngineOptions } from './types.js';
16
+
17
+ export interface FrameReply {
18
+ results: Detection[] | null;
19
+ live: Detection[];
20
+ decodeMs: number;
21
+ /** The pixel buffer, handed back for reuse. */
22
+ buffer: ArrayBuffer;
23
+ }
24
+
25
+ export interface Engine {
26
+ readonly inWorker: boolean;
27
+ readonly version: string;
28
+ /** Frames that can be decoded at the same time (number of workers). */
29
+ readonly concurrency: number;
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>;
33
+ /** One-shot scan of a still image, running every strategy. */
34
+ scanImage(
35
+ buffer: ArrayBuffer,
36
+ width: number,
37
+ height: number,
38
+ format: PixelFormat,
39
+ options: EngineOptions,
40
+ ): Promise<Detection[]>;
41
+ reset(): Promise<void>;
42
+ clearCooldown(text: string): Promise<void>;
43
+ destroy(): void;
44
+ }
45
+
46
+ /**
47
+ * Default pool size: one worker per core, minus one for the main thread
48
+ * (camera, GPU, UI), capped at 3. Beyond 3 the frame rate is limited by the
49
+ * frame grab (~55 ms on a Galaxy A14, so ~18 fps) and more workers only cost
50
+ * memory (one WebAssembly instance each).
51
+ */
52
+ export function defaultWorkerCount(): number {
53
+ const cores = typeof navigator !== 'undefined' ? navigator.hardwareConcurrency || 2 : 2;
54
+ return Math.min(3, Math.max(1, cores - 1));
55
+ }
56
+
57
+ type Distribute<T> = T extends unknown ? Omit<T, 'id'> : never;
58
+ type Request = Distribute<WorkerRequest>;
59
+ type OkResponse = Extract<WorkerResponse, { ok: true }>;
60
+
61
+ /** One decoding worker, restarted if the engine crashes. */
62
+ class DecoderWorker {
63
+ version = '';
64
+ /** Requests sent and not answered yet. */
65
+ busy = 0;
66
+
67
+ /** `false` once the worker failed for good (script error, crash outside a request). */
68
+ get alive(): boolean {
69
+ return this.dead === null;
70
+ }
71
+ private worker!: Worker;
72
+ private nextId = 1;
73
+ private pending = new Map<number, { resolve: (r: WorkerResponse) => void; reject: (e: Error) => void }>();
74
+ private dead: Error | null = null;
75
+ private options: EngineOptions | null = null;
76
+ private restarting: Promise<void> | null = null;
77
+
78
+ constructor(
79
+ private spawn: () => Worker,
80
+ private module: WebAssembly.Module,
81
+ ) {
82
+ this.attach(spawn());
83
+ }
84
+
85
+ private attach(worker: Worker) {
86
+ this.worker = worker;
87
+ worker.onmessage = (ev: MessageEvent<WorkerResponse>) => {
88
+ const p = this.pending.get(ev.data.id);
89
+ if (!p) return;
90
+ this.pending.delete(ev.data.id);
91
+ p.resolve(ev.data);
92
+ };
93
+ worker.onerror = (ev) => {
94
+ ev.preventDefault();
95
+ this.fail(new Error(ev.message || 'Scanner worker failed to load'));
96
+ };
97
+ }
98
+
99
+ private fail(err: Error) {
100
+ this.dead = err;
101
+ for (const p of this.pending.values()) p.reject(err);
102
+ this.pending.clear();
103
+ }
104
+
105
+ private call(req: Request, transfer: Transferable[] = []): Promise<WorkerResponse> {
106
+ if (this.dead) return Promise.reject(this.dead);
107
+ const id = this.nextId++;
108
+ return new Promise((resolve, reject) => {
109
+ this.pending.set(id, { resolve, reject });
110
+ this.worker.postMessage({ ...req, id }, transfer);
111
+ });
112
+ }
113
+
114
+ /** Send a request; on a fatal engine crash, restart the worker before throwing. */
115
+ async ok(req: Request, transfer?: Transferable[]): Promise<OkResponse> {
116
+ this.busy++;
117
+ try {
118
+ if (this.restarting) await this.restarting;
119
+ const res = await this.call(req, transfer);
120
+ if (!res.ok) {
121
+ if (res.fatal) await this.restart();
122
+ throw Object.assign(new Error(res.error), { buffer: res.buffer });
123
+ }
124
+ return res;
125
+ } finally {
126
+ this.busy--;
127
+ }
128
+ }
129
+
130
+ private restart(): Promise<void> {
131
+ this.restarting ??= (async () => {
132
+ console.warn('[scanner] engine crashed, restarting worker');
133
+ this.worker.terminate();
134
+ this.attach(this.spawn());
135
+ await this.init();
136
+ if (this.options) await this.call({ type: 'configure', options: this.options });
137
+ })().finally(() => (this.restarting = null));
138
+ return this.restarting;
139
+ }
140
+
141
+ async init() {
142
+ const res = await this.call({ type: 'init', module: this.module });
143
+ if (!res.ok) throw new Error(res.error);
144
+ this.version = res.version ?? '';
145
+ }
146
+
147
+ async configure(options: EngineOptions) {
148
+ this.options = options;
149
+ await this.ok({ type: 'configure', options });
150
+ }
151
+
152
+ destroy() {
153
+ this.fail(new Error('Engine destroyed'));
154
+ this.worker.terminate();
155
+ }
156
+ }
157
+
158
+ /** A pool of decoding workers serving one scan session on the main thread. */
159
+ class WorkerPoolEngine implements Engine {
160
+ readonly inWorker = true;
161
+ readonly version: string;
162
+ private session = new SessionCore();
163
+ private configured = false;
164
+ /** Bumped by `configure`: reports planned with an older configuration are dropped. */
165
+ private generation = 0;
166
+
167
+ constructor(private workers: DecoderWorker[]) {
168
+ this.version = workers[0]?.version ?? '';
169
+ }
170
+
171
+ get concurrency(): number {
172
+ return this.workers.length;
173
+ }
174
+
175
+ async configure(options: EngineOptions) {
176
+ // Session and decoders must share the configuration: a plan is an index
177
+ // in the decoder's list of fallback strategies.
178
+ this.generation++;
179
+ this.session.configure(options);
180
+ this.configured = true;
181
+ await Promise.all(this.workers.map((w) => w.configure(options)));
182
+ }
183
+
184
+ async processFrame(
185
+ buffer: ArrayBuffer,
186
+ width: number,
187
+ height: number,
188
+ format: PixelFormat,
189
+ ): Promise<FrameReply> {
190
+ if (!this.configured) throw new Error('Engine not configured');
191
+ const generation = this.generation;
192
+ const plan = this.session.plan();
193
+ let res: OkResponse;
194
+ try {
195
+ res = await this.idleWorker().ok({ type: 'decode', buffer, width, height, format, plan }, [buffer]);
196
+ } catch (err) {
197
+ // Every plan must be committed, or the scheduler keeps counting it as in flight.
198
+ this.session.commit(emptyReport(plan));
199
+ throw err;
200
+ }
201
+ const returned = res.buffer ?? new ArrayBuffer(width * height);
202
+ if (generation !== this.generation || !res.report) {
203
+ return { results: null, live: [], decodeMs: 0, buffer: returned };
204
+ }
205
+ return { ...this.session.commit(res.report), buffer: returned };
206
+ }
207
+
208
+ async scanImage(
209
+ buffer: ArrayBuffer,
210
+ width: number,
211
+ height: number,
212
+ format: PixelFormat,
213
+ options: EngineOptions,
214
+ ) {
215
+ const res = await this.idleWorker().ok({ type: 'scan', buffer, width, height, format, options }, [
216
+ buffer,
217
+ ]);
218
+ return res.results ?? [];
219
+ }
220
+
221
+ async reset() {
222
+ this.session.reset();
223
+ }
224
+
225
+ async clearCooldown(text: string) {
226
+ this.session.clearCooldown(text);
227
+ }
228
+
229
+ destroy() {
230
+ for (const w of this.workers) w.destroy();
231
+ this.session.destroy();
232
+ }
233
+
234
+ private idleWorker(): DecoderWorker {
235
+ const worker = pickWorker(this.workers);
236
+ if (!worker) throw new Error('Scanner engine unavailable: every decoding worker failed');
237
+ return worker;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * The live worker with the fewest requests in progress. Dead workers are
243
+ * skipped: they reject instantly, so they always look the least busy and
244
+ * would otherwise receive most frames.
245
+ */
246
+ export function pickWorker<W extends { alive: boolean; busy: number }>(workers: readonly W[]): W | undefined {
247
+ let best: W | undefined;
248
+ for (const w of workers) {
249
+ if (w.alive && (!best || w.busy < best.busy)) best = w;
250
+ }
251
+ return best;
252
+ }
253
+
254
+ function emptyReport(plan: FramePlan): FrameReport {
255
+ return { plan, results: [], fallbackRan: false, fallbackRead: false, elapsedMs: 0 };
256
+ }
257
+
258
+ /** Everything on the main thread (no Worker support). */
259
+ class InlineEngine implements Engine {
260
+ readonly inWorker = false;
261
+ readonly concurrency = 1;
262
+ private session = new SessionCore();
263
+ private decoder = new DecoderCore();
264
+
265
+ constructor(readonly version: string) {}
266
+
267
+ async configure(options: EngineOptions) {
268
+ this.session.configure(options);
269
+ }
270
+
271
+ async processFrame(
272
+ buffer: ArrayBuffer,
273
+ width: number,
274
+ height: number,
275
+ format: PixelFormat,
276
+ ): Promise<FrameReply> {
277
+ return { ...this.session.process(new Uint8Array(buffer), width, height, format), buffer };
278
+ }
279
+
280
+ async scanImage(
281
+ buffer: ArrayBuffer,
282
+ width: number,
283
+ height: number,
284
+ format: PixelFormat,
285
+ options: EngineOptions,
286
+ ) {
287
+ return this.decoder.scan(new Uint8Array(buffer), width, height, format, options);
288
+ }
289
+
290
+ async reset() {
291
+ this.session.reset();
292
+ }
293
+
294
+ async clearCooldown(text: string) {
295
+ this.session.clearCooldown(text);
296
+ }
297
+
298
+ destroy() {
299
+ this.session.destroy();
300
+ this.decoder.destroy();
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Load the engine: a pool of Web Workers when possible, else the main thread.
306
+ *
307
+ * Low-level API, for custom pipelines (frames you already have as pixels).
308
+ * Most applications should use `BarcodeReader` or `scanImage`. The `Engine`
309
+ * interface may gain members in minor versions.
310
+ */
311
+ export async function createEngine(options: AssetOptions = {}): Promise<Engine> {
312
+ const wasmUrl = new URL(String(options.wasmUrl ?? defaultWasmUrl()), location.href).href;
313
+ // Compiled once here: the main thread needs the engine for the session,
314
+ // and the workers instantiate the same module without compiling it again.
315
+ const module = await compileWasm(wasmUrl);
316
+ const version = await loadWasm(module);
317
+
318
+ if (options.worker !== false && typeof Worker !== 'undefined') {
319
+ const workerUrl = options.workerUrl;
320
+ const spawn = () =>
321
+ workerUrl
322
+ ? new Worker(workerUrl, { type: 'module' })
323
+ : new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
324
+ const count = Math.max(1, Math.floor(options.workers ?? defaultWorkerCount()));
325
+ const workers: DecoderWorker[] = [];
326
+ try {
327
+ for (let i = 0; i < count; i++) workers.push(new DecoderWorker(spawn, module));
328
+ await Promise.all(workers.map((w) => w.init()));
329
+ return new WorkerPoolEngine(workers);
330
+ } catch (err) {
331
+ for (const w of workers) w.destroy();
332
+ console.warn('[scanner] Web Worker unavailable, decoding on the main thread:', err);
333
+ }
334
+ }
335
+
336
+ return new InlineEngine(version);
337
+ }
338
+
339
+ let shared: Promise<Engine> | null = null;
340
+
341
+ /** Engine shared by `scanImage` calls (one worker is enough for still images). */
342
+ export function sharedEngine(options?: AssetOptions): Promise<Engine> {
343
+ shared ??= createEngine({ ...options, workers: 1 }).catch((err) => {
344
+ shared = null;
345
+ throw err;
346
+ });
347
+ return shared;
348
+ }
@@ -0,0 +1,50 @@
1
+ // Audio / haptic feedback on detection.
2
+ import type { FeedbackOptions } from './types.js';
3
+
4
+ export class Feedback {
5
+ private audio: AudioContext | null = null;
6
+
7
+ constructor(private options: FeedbackOptions | false) {}
8
+
9
+ /** Must be called from a user gesture on iOS for audio to be allowed. */
10
+ unlock(): void {
11
+ if (!this.options || this.options.beep === false || this.audio) return;
12
+ const Ctx =
13
+ window.AudioContext ??
14
+ (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
15
+ if (!Ctx) return;
16
+ try {
17
+ this.audio = new Ctx();
18
+ void this.audio.resume();
19
+ } catch {
20
+ this.audio = null;
21
+ }
22
+ }
23
+
24
+ play(): void {
25
+ if (!this.options) return;
26
+ if (this.options.vibrate !== false) navigator.vibrate?.(60);
27
+ if (this.options.beep !== false) this.beep();
28
+ }
29
+
30
+ private beep(): void {
31
+ const ctx = this.audio;
32
+ if (!ctx || ctx.state !== 'running') return;
33
+ const t = ctx.currentTime;
34
+ const osc = ctx.createOscillator();
35
+ const gain = ctx.createGain();
36
+ osc.type = 'sine';
37
+ osc.frequency.value = 2400;
38
+ gain.gain.setValueAtTime(0.0001, t);
39
+ gain.gain.exponentialRampToValueAtTime(0.25, t + 0.005);
40
+ gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.09);
41
+ osc.connect(gain).connect(ctx.destination);
42
+ osc.start(t);
43
+ osc.stop(t + 0.1);
44
+ }
45
+
46
+ destroy(): void {
47
+ void this.audio?.close();
48
+ this.audio = null;
49
+ }
50
+ }
@@ -0,0 +1,55 @@
1
+ // Coordinate mapping between the host element (what the user sees), the video
2
+ // frame, and the downscaled image handed to the decoder.
3
+ import type { Rect } from './grabber.js';
4
+ import type { Point, Region } from './types.js';
5
+
6
+ /** How a video of `vw×vh` is laid out in an element of `ew×eh` with `object-fit: cover`. */
7
+ export interface CoverLayout {
8
+ scale: number;
9
+ offsetX: number;
10
+ offsetY: number;
11
+ }
12
+
13
+ export function coverLayout(vw: number, vh: number, ew: number, eh: number): CoverLayout {
14
+ const scale = Math.max(ew / vw, eh / vh);
15
+ return { scale, offsetX: (ew - vw * scale) / 2, offsetY: (eh - vh * scale) / 2 };
16
+ }
17
+
18
+ export function videoToElement(p: Point, l: CoverLayout): Point {
19
+ return { x: p.x * l.scale + l.offsetX, y: p.y * l.scale + l.offsetY };
20
+ }
21
+
22
+ /** Region of interest (fractions of the element) → rectangle in video pixels, clamped to the frame. */
23
+ export function regionToVideoRect(r: Region, vw: number, vh: number, ew: number, eh: number): Rect {
24
+ const l = coverLayout(vw, vh, ew, eh);
25
+ const x0 = clamp((r.x * ew - l.offsetX) / l.scale, 0, vw);
26
+ const y0 = clamp((r.y * eh - l.offsetY) / l.scale, 0, vh);
27
+ const x1 = clamp(((r.x + r.width) * ew - l.offsetX) / l.scale, 0, vw);
28
+ const y1 = clamp(((r.y + r.height) * eh - l.offsetY) / l.scale, 0, vh);
29
+ return { x: x0, y: y0, width: Math.max(1, x1 - x0), height: Math.max(1, y1 - y0) };
30
+ }
31
+
32
+ /** Size of the decoder image for a crop: downscaled to `maxDim`, width rounded to a multiple of 4. */
33
+ export function outputSize(rect: Rect, maxDim: number): { width: number; height: number } {
34
+ const scale = Math.min(1, maxDim / Math.max(rect.width, rect.height));
35
+ const width = Math.max(4, Math.floor((rect.width * scale) / 4) * 4);
36
+ const height = Math.max(1, Math.round(rect.height * scale));
37
+ return { width, height };
38
+ }
39
+
40
+ /** Map points from decoder-image space back to video pixels. */
41
+ export function decoderToVideo(points: Point[], rect: Rect, width: number, height: number): Point[] {
42
+ const sx = rect.width / width;
43
+ const sy = rect.height / height;
44
+ return points.map((p) => ({ x: rect.x + (p.x + 0.5) * sx, y: rect.y + (p.y + 0.5) * sy }));
45
+ }
46
+
47
+ export function clampRegion(r: Region): Region {
48
+ const x = clamp(r.x, 0, 1);
49
+ const y = clamp(r.y, 0, 1);
50
+ return { x, y, width: clamp(r.width, 0.01, 1 - x), height: clamp(r.height, 0.01, 1 - y) };
51
+ }
52
+
53
+ function clamp(v: number, lo: number, hi: number): number {
54
+ return Math.min(hi, Math.max(lo, v));
55
+ }