@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/grabber.ts ADDED
@@ -0,0 +1,385 @@
1
+ // Frame pipeline: turns a video frame into the small grayscale image the decoder needs.
2
+ //
3
+ // The WebGL2 path does all the per-pixel work on the GPU in a single pass:
4
+ // upload video texture → crop to the region of interest → RGB→luma
5
+ // → box-filtered downscale → pack 4 luma pixels per RGBA texel
6
+ // so the CPU readback is exactly `width × height` bytes (4× less than
7
+ // getImageData) and, thanks to a pixel-pack buffer + fence, it does not
8
+ // stall the main thread waiting for the GPU.
9
+
10
+ export interface Rect {
11
+ x: number;
12
+ y: number;
13
+ width: number;
14
+ height: number;
15
+ }
16
+
17
+ export interface FrameGrabber {
18
+ readonly kind: 'webgl' | 'canvas';
19
+ /**
20
+ * Render `src` (cropped to `rect`, in source pixels) into `out`, a luma
21
+ * buffer of `width × height` bytes. `width` must be a multiple of 4.
22
+ */
23
+ grab(source: TexImageSource, rect: Rect, width: number, height: number, out: Uint8Array): Promise<void>;
24
+ destroy(): void;
25
+ }
26
+
27
+ type AnyCanvas = HTMLCanvasElement | OffscreenCanvas;
28
+
29
+ function makeCanvas(): AnyCanvas {
30
+ return typeof OffscreenCanvas !== 'undefined'
31
+ ? new OffscreenCanvas(4, 4)
32
+ : document.createElement('canvas');
33
+ }
34
+
35
+ const VERT = `#version 300 es
36
+ in vec2 a_pos;
37
+ void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }`;
38
+
39
+ const FRAG = `#version 300 es
40
+ precision highp float;
41
+ uniform sampler2D u_src;
42
+ uniform vec4 u_rect; // crop rectangle in texture coordinates (origin top-left)
43
+ uniform vec2 u_out; // size of the luma image
44
+ uniform vec2 u_tap; // sub-sample offset for the 2x2 box filter
45
+ out vec4 o_color;
46
+ const vec3 LUMA = vec3(0.299, 0.587, 0.114);
47
+
48
+ float luma(vec2 px) {
49
+ vec2 uv = u_rect.xy + (px + 0.5) / u_out * u_rect.zw;
50
+ vec3 c = texture(u_src, uv + vec2(-u_tap.x, -u_tap.y)).rgb
51
+ + texture(u_src, uv + vec2( u_tap.x, -u_tap.y)).rgb
52
+ + texture(u_src, uv + vec2(-u_tap.x, u_tap.y)).rgb
53
+ + texture(u_src, uv + vec2( u_tap.x, u_tap.y)).rgb;
54
+ return dot(c * 0.25, LUMA);
55
+ }
56
+
57
+ void main() {
58
+ // Framebuffer row 0 is read back first, so row r samples image row r from the top.
59
+ float y = floor(gl_FragCoord.y);
60
+ float x = floor(gl_FragCoord.x) * 4.0;
61
+ o_color = vec4(luma(vec2(x, y)), luma(vec2(x + 1.0, y)), luma(vec2(x + 2.0, y)), luma(vec2(x + 3.0, y)));
62
+ }`;
63
+
64
+ export class WebGLGrabber implements FrameGrabber {
65
+ readonly kind = 'webgl';
66
+ private gl: WebGL2RenderingContext;
67
+ private program: WebGLProgram;
68
+ private srcTex: WebGLTexture;
69
+ private dstTex: WebGLTexture;
70
+ private fbo: WebGLFramebuffer;
71
+ private pbo: WebGLBuffer;
72
+ private uniforms: Record<'rect' | 'out' | 'tap', WebGLUniformLocation | null>;
73
+ private size = { w: 0, h: 0 };
74
+ private lost = false;
75
+
76
+ /** Returns `null` when WebGL2 is not available. */
77
+ static create(): WebGLGrabber | null {
78
+ try {
79
+ const canvas = makeCanvas();
80
+ const gl = canvas.getContext('webgl2', {
81
+ alpha: true,
82
+ antialias: false,
83
+ depth: false,
84
+ stencil: false,
85
+ premultipliedAlpha: false,
86
+ preserveDrawingBuffer: false,
87
+ powerPreference: 'low-power',
88
+ }) as WebGL2RenderingContext | null;
89
+ return gl ? new WebGLGrabber(canvas, gl) : null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ private constructor(canvas: AnyCanvas, gl: WebGL2RenderingContext) {
96
+ this.gl = gl;
97
+ (canvas as HTMLCanvasElement).addEventListener?.('webglcontextlost', (e) => {
98
+ e.preventDefault();
99
+ this.lost = true;
100
+ });
101
+
102
+ this.program = link(gl, VERT, FRAG);
103
+ gl.useProgram(this.program);
104
+
105
+ // Full-screen triangle.
106
+ const vbo = gl.createBuffer();
107
+ gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
108
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
109
+ const loc = gl.getAttribLocation(this.program, 'a_pos');
110
+ gl.enableVertexAttribArray(loc);
111
+ gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
112
+
113
+ this.uniforms = {
114
+ rect: gl.getUniformLocation(this.program, 'u_rect'),
115
+ out: gl.getUniformLocation(this.program, 'u_out'),
116
+ tap: gl.getUniformLocation(this.program, 'u_tap'),
117
+ };
118
+ gl.uniform1i(gl.getUniformLocation(this.program, 'u_src'), 0);
119
+
120
+ this.srcTex = texture(gl);
121
+ this.dstTex = texture(gl);
122
+ this.fbo = must(gl.createFramebuffer(), 'framebuffer');
123
+ this.pbo = must(gl.createBuffer(), 'pixel buffer');
124
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
125
+ gl.pixelStorei(gl.PACK_ALIGNMENT, 1);
126
+ }
127
+
128
+ async grab(
129
+ source: TexImageSource,
130
+ rect: Rect,
131
+ width: number,
132
+ height: number,
133
+ out: Uint8Array,
134
+ ): Promise<void> {
135
+ const gl = this.gl;
136
+ if (this.lost || gl.isContextLost()) throw new Error('WebGL context lost');
137
+ const [sw, sh] = sourceSize(source);
138
+ const texW = width / 4;
139
+
140
+ gl.activeTexture(gl.TEXTURE0);
141
+ gl.bindTexture(gl.TEXTURE_2D, this.srcTex);
142
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
143
+
144
+ if (this.size.w !== texW || this.size.h !== height) {
145
+ gl.bindTexture(gl.TEXTURE_2D, this.dstTex);
146
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, texW, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
147
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbo);
148
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.dstTex, 0);
149
+ gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this.pbo);
150
+ gl.bufferData(gl.PIXEL_PACK_BUFFER, width * height, gl.STREAM_READ);
151
+ this.size = { w: texW, h: height };
152
+ gl.bindTexture(gl.TEXTURE_2D, this.srcTex);
153
+ }
154
+
155
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbo);
156
+ gl.viewport(0, 0, texW, height);
157
+ gl.uniform4f(this.uniforms.rect, rect.x / sw, rect.y / sh, rect.width / sw, rect.height / sh);
158
+ gl.uniform2f(this.uniforms.out, width, height);
159
+ // Box filter footprint: a quarter of an output pixel, only when downscaling.
160
+ const fx = rect.width / width;
161
+ const fy = rect.height / height;
162
+ gl.uniform2f(this.uniforms.tap, fx > 1 ? (0.25 * fx) / sw : 0, fy > 1 ? (0.25 * fy) / sh : 0);
163
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
164
+
165
+ // Asynchronous readback into the pixel-pack buffer.
166
+ gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this.pbo);
167
+ gl.readPixels(0, 0, texW, height, gl.RGBA, gl.UNSIGNED_BYTE, 0);
168
+ const sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);
169
+ gl.flush();
170
+ if (sync) {
171
+ await waitSync(gl, sync);
172
+ gl.deleteSync(sync);
173
+ }
174
+ gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this.pbo);
175
+ gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, out, 0, width * height);
176
+ gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
177
+ }
178
+
179
+ destroy() {
180
+ const gl = this.gl;
181
+ gl.deleteTexture(this.srcTex);
182
+ gl.deleteTexture(this.dstTex);
183
+ gl.deleteFramebuffer(this.fbo);
184
+ gl.deleteBuffer(this.pbo);
185
+ gl.deleteProgram(this.program);
186
+ gl.getExtension('WEBGL_lose_context')?.loseContext();
187
+ }
188
+ }
189
+
190
+ /** Fallback pipeline using Canvas 2D (`drawImage` does the crop + scale). */
191
+ export class CanvasGrabber implements FrameGrabber {
192
+ readonly kind = 'canvas';
193
+ private canvas = makeCanvas();
194
+ private ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
195
+
196
+ constructor() {
197
+ const ctx = this.canvas.getContext('2d', { willReadFrequently: true, alpha: false });
198
+ this.ctx = must(ctx as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null, '2D context');
199
+ }
200
+
201
+ async grab(
202
+ source: TexImageSource,
203
+ rect: Rect,
204
+ width: number,
205
+ height: number,
206
+ out: Uint8Array,
207
+ ): Promise<void> {
208
+ if (this.canvas.width !== width || this.canvas.height !== height) {
209
+ this.canvas.width = width;
210
+ this.canvas.height = height;
211
+ }
212
+ const ctx = this.ctx;
213
+ ctx.imageSmoothingEnabled = true;
214
+ ctx.imageSmoothingQuality = 'medium';
215
+ ctx.drawImage(source as CanvasImageSource, rect.x, rect.y, rect.width, rect.height, 0, 0, width, height);
216
+ const rgba = ctx.getImageData(0, 0, width, height).data;
217
+ rgbaToLuma(rgba, out);
218
+ }
219
+
220
+ destroy() {
221
+ this.canvas.width = this.canvas.height = 0;
222
+ }
223
+ }
224
+
225
+ export function createGrabber(backend: 'auto' | 'webgl' | 'canvas' = 'auto'): FrameGrabber {
226
+ if (backend !== 'canvas') {
227
+ const gl = WebGLGrabber.create();
228
+ if (gl) return backend === 'auto' ? new AdaptiveGrabber(gl, () => new CanvasGrabber()) : gl;
229
+ if (backend === 'webgl') throw new Error('WebGL2 is not available');
230
+ }
231
+ return new CanvasGrabber();
232
+ }
233
+
234
+ /** Frames timed per backend before deciding. */
235
+ const PROBE_FRAMES = 15;
236
+ /** WebGL is kept without probing when its median grab is below this (ms). */
237
+ const SLOW_GRAB_MS = 20;
238
+ /** Canvas 2D must be this much faster to be kept: it blocks the main thread. */
239
+ const CANVAS_WIN_RATIO = 0.6;
240
+
241
+ /**
242
+ * `'auto'` backend: WebGL2, unless Canvas 2D is measured to be much faster on
243
+ * this device.
244
+ *
245
+ * WebGL does the crop / luma / downscale on the GPU and reads back 4× less
246
+ * data, asynchronously, so it is the right default. But on some GPUs the
247
+ * readback itself is slow (the fence takes tens of ms to signal), and then
248
+ * the grab limits the frame rate now that decoding runs in parallel. So when
249
+ * the median WebGL grab exceeds `SLOW_GRAB_MS`, Canvas 2D is timed on the
250
+ * next `PROBE_FRAMES` frames and kept only if clearly faster (`drawImage` +
251
+ * `getImageData` run on the main thread, while WebGL waits asynchronously).
252
+ * The decision is made once, from measurements: no device list.
253
+ */
254
+ export class AdaptiveGrabber implements FrameGrabber {
255
+ private active: FrameGrabber;
256
+ private other: FrameGrabber | null = null;
257
+ private samples: number[] = [];
258
+ private webglMedian = 0;
259
+ private state: 'webgl' | 'probing' | 'decided' = 'webgl';
260
+
261
+ constructor(
262
+ private webgl: FrameGrabber,
263
+ private makeCanvas: () => FrameGrabber,
264
+ private now: () => number = () => performance.now(),
265
+ ) {
266
+ this.active = webgl;
267
+ }
268
+
269
+ get kind(): 'webgl' | 'canvas' {
270
+ return this.active.kind;
271
+ }
272
+
273
+ async grab(source: TexImageSource, rect: Rect, width: number, height: number, out: Uint8Array) {
274
+ const grabber = this.active;
275
+ const t0 = this.now();
276
+ await grabber.grab(source, rect, width, height, out);
277
+ if (this.state !== 'decided' && grabber === this.active) this.record(this.now() - t0);
278
+ }
279
+
280
+ private record(ms: number): void {
281
+ this.samples.push(ms);
282
+ if (this.samples.length < PROBE_FRAMES) return;
283
+ const median = medianOf(this.samples);
284
+ this.samples = [];
285
+ if (this.state === 'webgl') {
286
+ if (median < SLOW_GRAB_MS) {
287
+ this.state = 'decided';
288
+ return;
289
+ }
290
+ this.webglMedian = median;
291
+ this.other = this.webgl;
292
+ this.active = this.makeCanvas();
293
+ this.state = 'probing';
294
+ return;
295
+ }
296
+ // Probing Canvas 2D: keep it, or go back to WebGL.
297
+ this.state = 'decided';
298
+ if (median < this.webglMedian * CANVAS_WIN_RATIO) {
299
+ this.other?.destroy();
300
+ } else {
301
+ this.active.destroy();
302
+ this.active = this.webgl;
303
+ }
304
+ this.other = null;
305
+ }
306
+
307
+ destroy(): void {
308
+ this.active.destroy();
309
+ this.other?.destroy();
310
+ }
311
+ }
312
+
313
+ function medianOf(values: number[]): number {
314
+ const sorted = [...values].sort((a, b) => a - b);
315
+ return sorted[sorted.length >> 1] ?? 0;
316
+ }
317
+
318
+ /** BT.601 luma with integer arithmetic (same weights as the GPU path and the engine). */
319
+ export function rgbaToLuma(rgba: Uint8ClampedArray | Uint8Array, out: Uint8Array): void {
320
+ const n = Math.min(out.length, rgba.length >> 2);
321
+ for (let i = 0, j = 0; i < n; i++, j += 4) {
322
+ // In bounds by construction (`n`): `?? 0` only satisfies the type checker.
323
+ out[i] = (19595 * (rgba[j] ?? 0) + 38470 * (rgba[j + 1] ?? 0) + 7471 * (rgba[j + 2] ?? 0)) >> 16;
324
+ }
325
+ }
326
+
327
+ export function sourceSize(source: TexImageSource): [number, number] {
328
+ if (typeof HTMLVideoElement !== 'undefined' && source instanceof HTMLVideoElement) {
329
+ return [source.videoWidth, source.videoHeight];
330
+ }
331
+ if (typeof HTMLImageElement !== 'undefined' && source instanceof HTMLImageElement) {
332
+ return [source.naturalWidth, source.naturalHeight];
333
+ }
334
+ if (typeof VideoFrame !== 'undefined' && source instanceof VideoFrame) {
335
+ return [source.displayWidth, source.displayHeight];
336
+ }
337
+ const s = source as { width: number; height: number };
338
+ return [s.width, s.height];
339
+ }
340
+
341
+ // ── WebGL helpers ──────────────────────────────────────────────────────────
342
+
343
+ function must<T>(v: T | null, what: string): T {
344
+ if (!v) throw new Error(`WebGL: failed to create ${what}`);
345
+ return v;
346
+ }
347
+
348
+ function texture(gl: WebGL2RenderingContext): WebGLTexture {
349
+ const t = must(gl.createTexture(), 'texture');
350
+ gl.bindTexture(gl.TEXTURE_2D, t);
351
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
352
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
353
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
354
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
355
+ return t;
356
+ }
357
+
358
+ function link(gl: WebGL2RenderingContext, vs: string, fs: string): WebGLProgram {
359
+ const compile = (type: number, src: string) => {
360
+ const s = must(gl.createShader(type), 'shader');
361
+ gl.shaderSource(s, src);
362
+ gl.compileShader(s);
363
+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(`Shader: ${gl.getShaderInfoLog(s)}`);
364
+ return s;
365
+ };
366
+ const p = must(gl.createProgram(), 'program');
367
+ gl.attachShader(p, compile(gl.VERTEX_SHADER, vs));
368
+ gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fs));
369
+ gl.linkProgram(p);
370
+ if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(`Program: ${gl.getProgramInfoLog(p)}`);
371
+ return p;
372
+ }
373
+
374
+ function waitSync(gl: WebGL2RenderingContext, sync: WebGLSync): Promise<void> {
375
+ return new Promise((resolve) => {
376
+ const started = performance.now();
377
+ const poll = () => {
378
+ const status = gl.clientWaitSync(sync, 0, 0);
379
+ // Give up waiting after 50 ms: getBufferSubData will then block, but never hang.
380
+ if (status !== gl.TIMEOUT_EXPIRED || performance.now() - started > 50) resolve();
381
+ else setTimeout(poll, 1);
382
+ };
383
+ poll();
384
+ });
385
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export { BarcodeReader } from './reader.js';
2
+ export { scanImage, type ImageInput, type ScanImageOptions } from './scan-image.js';
3
+ export { createEngine, type Engine, type FrameReply } from './engine.js';
4
+ export { listCameras } from './camera.js';
5
+ export type {
6
+ AssetOptions,
7
+ BarcodeFormat,
8
+ CameraOptions,
9
+ CameraResolution,
10
+ Detection,
11
+ EngineOptions,
12
+ FeedbackOptions,
13
+ OverlayOptions,
14
+ Point,
15
+ ProcessingOptions,
16
+ ReaderEvents,
17
+ ReaderOptions,
18
+ ReaderStats,
19
+ Region,
20
+ } from './types.js';
package/src/overlay.ts ADDED
@@ -0,0 +1,225 @@
1
+ // Camera preview DOM + canvas overlay (region of interest, detection highlights, buttons).
2
+ import type { OverlayOptions, Point, Region } from './types.js';
3
+
4
+ const TORCH_ICON =
5
+ '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2h12v5l-3 4v11H9V11L6 7z"/><path d="M12 14v2"/></svg>';
6
+ const SWITCH_ICON =
7
+ '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h3l2-3h6l2 3h3v12H4z"/><path d="M9 13a3 3 0 0 1 5-2.2M15 13a3 3 0 0 1-5 2.2"/><path d="M14 9v2h-2M10 17v-2h2"/></svg>';
8
+
9
+ const HIGHLIGHT_MS = 400;
10
+
11
+ export class Preview {
12
+ readonly root: HTMLDivElement;
13
+ readonly video: HTMLVideoElement;
14
+ private canvas: HTMLCanvasElement;
15
+ private ctx: CanvasRenderingContext2D | null;
16
+ private controls: HTMLDivElement;
17
+ private torchBtn: HTMLButtonElement;
18
+ private switchBtn: HTMLButtonElement;
19
+ private resizeObserver: ResizeObserver | null = null;
20
+ private region: Region = { x: 0, y: 0, width: 1, height: 1 };
21
+ private detections: { polygons: Point[][]; at: number } = { polygons: [], at: 0 };
22
+ private dirty = true;
23
+ private opts: Required<OverlayOptions> | null;
24
+ private restoreHostPosition: string | null = null;
25
+
26
+ constructor(
27
+ private host: HTMLElement,
28
+ options: OverlayOptions | false | undefined,
29
+ ) {
30
+ this.opts =
31
+ options === false
32
+ ? null
33
+ : {
34
+ showRegion: true,
35
+ showDetections: true,
36
+ showTorchButton: true,
37
+ showCameraSwitch: true,
38
+ color: '#00d4aa',
39
+ ...options,
40
+ };
41
+
42
+ if (getComputedStyle(host).position === 'static') {
43
+ this.restoreHostPosition = host.style.position;
44
+ host.style.position = 'relative';
45
+ }
46
+
47
+ this.root = el('div', 'position:absolute;inset:0;overflow:hidden;background:#000;');
48
+ this.root.className = 'scanner-preview';
49
+
50
+ this.video = el('video', 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover;');
51
+ this.video.muted = true;
52
+ this.video.playsInline = true;
53
+ this.video.autoplay = true;
54
+ this.video.setAttribute('playsinline', '');
55
+ this.video.setAttribute('muted', '');
56
+
57
+ this.canvas = el('canvas', 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;');
58
+ this.ctx = this.canvas.getContext('2d');
59
+
60
+ this.controls = el('div', 'position:absolute;right:12px;bottom:12px;display:flex;gap:8px;');
61
+ this.torchBtn = button(TORCH_ICON, 'Toggle flashlight');
62
+ this.switchBtn = button(SWITCH_ICON, 'Switch camera');
63
+ this.controls.append(this.switchBtn, this.torchBtn);
64
+
65
+ this.root.append(this.video, this.canvas, this.controls);
66
+ host.append(this.root);
67
+
68
+ if (typeof ResizeObserver !== 'undefined') {
69
+ this.resizeObserver = new ResizeObserver(() => (this.dirty = true));
70
+ this.resizeObserver.observe(this.root);
71
+ }
72
+ }
73
+
74
+ get width(): number {
75
+ return this.root.clientWidth;
76
+ }
77
+
78
+ get height(): number {
79
+ return this.root.clientHeight;
80
+ }
81
+
82
+ setRegion(region: Region): void {
83
+ this.region = region;
84
+ this.dirty = true;
85
+ }
86
+
87
+ /** Polygons in element (CSS pixel) coordinates. */
88
+ setDetections(polygons: Point[][]): void {
89
+ if (!this.opts?.showDetections) return;
90
+ if (polygons.length === 0 && this.detections.polygons.length === 0) return;
91
+ if (polygons.length > 0) this.detections = { polygons, at: performance.now() };
92
+ this.dirty = true;
93
+ }
94
+
95
+ setTorchButton(visible: boolean, on: boolean, onClick: () => void): void {
96
+ const show = visible && !!this.opts?.showTorchButton;
97
+ this.torchBtn.style.display = show ? 'flex' : 'none';
98
+ this.torchBtn.style.background = on ? (this.opts?.color ?? '#00d4aa') : 'rgba(0,0,0,0.55)';
99
+ this.torchBtn.onclick = onClick;
100
+ }
101
+
102
+ setSwitchButton(visible: boolean, onClick: () => void): void {
103
+ this.switchBtn.style.display = visible && this.opts?.showCameraSwitch ? 'flex' : 'none';
104
+ this.switchBtn.onclick = onClick;
105
+ }
106
+
107
+ /** Redraw if something changed. Cheap to call on every frame. */
108
+ render(): void {
109
+ const ctx = this.ctx;
110
+ if (!ctx || !this.opts) return;
111
+ const now = performance.now();
112
+ const age = now - this.detections.at;
113
+ const highlighting = this.detections.polygons.length > 0 && age < HIGHLIGHT_MS;
114
+ if (!this.dirty && !highlighting) return;
115
+ this.dirty = highlighting; // keep animating the fade-out
116
+
117
+ const dpr = window.devicePixelRatio || 1;
118
+ const w = this.width;
119
+ const h = this.height;
120
+ if (this.canvas.width !== Math.round(w * dpr) || this.canvas.height !== Math.round(h * dpr)) {
121
+ this.canvas.width = Math.round(w * dpr);
122
+ this.canvas.height = Math.round(h * dpr);
123
+ }
124
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
125
+ ctx.clearRect(0, 0, w, h);
126
+
127
+ const color = this.opts.color;
128
+ if (this.opts.showRegion) {
129
+ const r = this.region;
130
+ const rx = r.x * w;
131
+ const ry = r.y * h;
132
+ const rw = r.width * w;
133
+ const rh = r.height * h;
134
+ ctx.fillStyle = 'rgba(0,0,0,0.45)';
135
+ ctx.beginPath();
136
+ ctx.rect(0, 0, w, h);
137
+ ctx.rect(rx, ry, rw, rh);
138
+ ctx.fill('evenodd');
139
+
140
+ const c = Math.min(24, rw / 4, rh / 4);
141
+ ctx.strokeStyle = color;
142
+ ctx.lineWidth = 3;
143
+ ctx.lineCap = 'round';
144
+ ctx.beginPath();
145
+ const corners: [number, number, number, number][] = [
146
+ [rx, ry, 1, 1],
147
+ [rx + rw, ry, -1, 1],
148
+ [rx, ry + rh, 1, -1],
149
+ [rx + rw, ry + rh, -1, -1],
150
+ ];
151
+ for (const [x, y, dx, dy] of corners) {
152
+ ctx.moveTo(x + dx * c, y);
153
+ ctx.lineTo(x, y);
154
+ ctx.lineTo(x, y + dy * c);
155
+ }
156
+ ctx.stroke();
157
+ }
158
+
159
+ if (highlighting) {
160
+ ctx.globalAlpha = 1 - age / HIGHLIGHT_MS;
161
+ ctx.strokeStyle = color;
162
+ ctx.fillStyle = withAlpha(color, 0.25);
163
+ ctx.lineWidth = 3;
164
+ for (const poly of this.detections.polygons) {
165
+ drawPolygon(ctx, poly);
166
+ }
167
+ ctx.globalAlpha = 1;
168
+ }
169
+ }
170
+
171
+ destroy(): void {
172
+ this.resizeObserver?.disconnect();
173
+ this.video.srcObject = null;
174
+ this.root.remove();
175
+ if (this.restoreHostPosition !== null) this.host.style.position = this.restoreHostPosition;
176
+ }
177
+ }
178
+
179
+ function drawPolygon(ctx: CanvasRenderingContext2D, pts: Point[]): void {
180
+ const [first, second] = pts;
181
+ if (!first) return;
182
+ ctx.beginPath();
183
+ if (pts.length === 2 && second) {
184
+ // 1D codes report the two ends of the scan line: draw a thick bar.
185
+ ctx.save();
186
+ ctx.lineWidth = 10;
187
+ ctx.globalAlpha *= 0.6;
188
+ ctx.moveTo(first.x, first.y);
189
+ ctx.lineTo(second.x, second.y);
190
+ ctx.stroke();
191
+ ctx.restore();
192
+ return;
193
+ }
194
+ ctx.moveTo(first.x, first.y);
195
+ for (const p of pts.slice(1)) ctx.lineTo(p.x, p.y);
196
+ ctx.closePath();
197
+ ctx.fill();
198
+ ctx.stroke();
199
+ }
200
+
201
+ function withAlpha(color: string, alpha: number): string {
202
+ const hex = /^#([0-9a-f]{6})$/i.exec(color)?.[1];
203
+ if (!hex) return color;
204
+ const n = parseInt(hex, 16);
205
+ return `rgba(${n >> 16},${(n >> 8) & 255},${n & 255},${alpha})`;
206
+ }
207
+
208
+ function el<K extends keyof HTMLElementTagNameMap>(tag: K, css: string): HTMLElementTagNameMap[K] {
209
+ const e = document.createElement(tag);
210
+ e.style.cssText = css;
211
+ return e;
212
+ }
213
+
214
+ function button(icon: string, label: string): HTMLButtonElement {
215
+ const b = el(
216
+ 'button',
217
+ 'display:none;align-items:center;justify-content:center;width:44px;height:44px;border-radius:50%;' +
218
+ 'border:none;color:#fff;background:rgba(0,0,0,0.55);cursor:pointer;padding:0;',
219
+ );
220
+ b.type = 'button';
221
+ b.innerHTML = icon;
222
+ b.title = label;
223
+ b.setAttribute('aria-label', label);
224
+ return b;
225
+ }
@@ -0,0 +1,38 @@
1
+ // Messages exchanged between the SDK and its decoding Web Workers.
2
+ import type { FramePlan, FrameReport } from './core.js';
3
+ import type { PixelFormat } from './wasm/scanner_wasm.js';
4
+ import type { Detection, EngineOptions } from './types.js';
5
+
6
+ export type WorkerRequest =
7
+ /** `module` is compiled once on the main thread and shared by every worker. */
8
+ | { id: number; type: 'init'; module: WebAssembly.Module }
9
+ | { id: number; type: 'configure'; options: EngineOptions }
10
+ | {
11
+ id: number;
12
+ type: 'decode';
13
+ buffer: ArrayBuffer;
14
+ width: number;
15
+ height: number;
16
+ format: PixelFormat;
17
+ plan: FramePlan;
18
+ }
19
+ | {
20
+ id: number;
21
+ type: 'scan';
22
+ buffer: ArrayBuffer;
23
+ width: number;
24
+ height: number;
25
+ format: PixelFormat;
26
+ options: EngineOptions;
27
+ };
28
+
29
+ export type WorkerResponse =
30
+ | {
31
+ id: number;
32
+ ok: true;
33
+ version?: string;
34
+ report?: FrameReport;
35
+ results?: Detection[];
36
+ buffer?: ArrayBuffer;
37
+ }
38
+ | { id: number; ok: false; error: string; fatal?: boolean; buffer?: ArrayBuffer };