@gueshbzh-perso/scanner 0.6.0 → 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.
- package/LICENSE +21 -0
- package/README.md +23 -4
- package/dist/camera.d.ts.map +1 -1
- package/dist/camera.js +5 -2
- package/dist/camera.js.map +1 -1
- package/dist/core.d.ts +1 -3
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +2 -16
- package/dist/core.js.map +1 -1
- package/dist/engine.d.ts +16 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +34 -18
- package/dist/engine.js.map +1 -1
- package/dist/grabber.d.ts +28 -0
- package/dist/grabber.d.ts.map +1 -1
- package/dist/grabber.js +81 -3
- package/dist/grabber.js.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/overlay.d.ts.map +1 -1
- package/dist/overlay.js +12 -11
- package/dist/overlay.js.map +1 -1
- package/dist/reader.d.ts +8 -1
- package/dist/reader.d.ts.map +1 -1
- package/dist/reader.js +43 -7
- package/dist/reader.js.map +1 -1
- package/dist/types.d.ts +1 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/wasm/THIRD_PARTY_LICENSES.md +3879 -0
- package/dist/wasm/scanner_wasm_bg.wasm +0 -0
- package/package.json +6 -3
- package/src/camera.ts +107 -0
- package/src/core.ts +189 -0
- package/src/engine.ts +348 -0
- package/src/feedback.ts +50 -0
- package/src/geometry.ts +55 -0
- package/src/grabber.ts +385 -0
- package/src/index.ts +20 -0
- package/src/overlay.ts +225 -0
- package/src/protocol.ts +38 -0
- package/src/reader.ts +474 -0
- package/src/scan-image.ts +88 -0
- package/src/types.ts +155 -0
- package/src/worker.ts +57 -0
- package/dist/wasm/scanner_wasm_simd_bg.wasm +0 -0
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
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -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 };
|