@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
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gueshbzh-perso/scanner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Fast QR code & barcode scanning for the web: camera, WebGL frame pipeline, Web Worker and Rust/WebAssembly decoder.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/
|
|
9
|
+
"url": "https://github.com/RomainRodrigues/scanner"
|
|
10
10
|
},
|
|
11
11
|
"keywords": [
|
|
12
12
|
"barcode",
|
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"dist",
|
|
36
|
+
"src",
|
|
37
|
+
"!src/wasm",
|
|
36
38
|
"README.md"
|
|
37
39
|
],
|
|
38
40
|
"sideEffects": false,
|
|
@@ -42,7 +44,8 @@
|
|
|
42
44
|
"build": "npm run build:wasm && npm run build:ts",
|
|
43
45
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
44
46
|
"test:e2e": "node scripts/e2e.mjs",
|
|
45
|
-
"serve": "node scripts/serve.mjs"
|
|
47
|
+
"serve": "node scripts/serve.mjs",
|
|
48
|
+
"test": "node --test test/unit/*.test.mjs"
|
|
46
49
|
},
|
|
47
50
|
"devDependencies": {
|
|
48
51
|
"binaryen": "^132.0.0",
|
package/src/camera.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Camera access helpers (getUserMedia, device listing, torch, zoom, focus).
|
|
2
|
+
import type { CameraOptions, CameraResolution } from './types.js';
|
|
3
|
+
|
|
4
|
+
const RESOLUTIONS: Record<CameraResolution, [number, number]> = {
|
|
5
|
+
sd: [640, 480],
|
|
6
|
+
hd: [1280, 720],
|
|
7
|
+
fhd: [1920, 1080],
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// Namespaced: the host page shares localStorage with the SDK.
|
|
11
|
+
const STORAGE_KEY = '@gueshbzh-perso/scanner:cameraId';
|
|
12
|
+
/** Key used up to 0.6: still read, so the remembered camera survives the upgrade. */
|
|
13
|
+
const LEGACY_STORAGE_KEY = 'scanner.cameraId';
|
|
14
|
+
|
|
15
|
+
export function rememberedCamera(): string | undefined {
|
|
16
|
+
try {
|
|
17
|
+
return localStorage.getItem(STORAGE_KEY) ?? localStorage.getItem(LEGACY_STORAGE_KEY) ?? undefined;
|
|
18
|
+
} catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function rememberCamera(id: string | undefined): void {
|
|
24
|
+
try {
|
|
25
|
+
if (id) localStorage.setItem(STORAGE_KEY, id);
|
|
26
|
+
} catch {
|
|
27
|
+
/* storage unavailable: ignore */
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function openCamera(options: CameraOptions = {}): Promise<MediaStream> {
|
|
32
|
+
if (!navigator.mediaDevices?.getUserMedia) {
|
|
33
|
+
throw new Error('Camera access is not available (a secure https:// context is required)');
|
|
34
|
+
}
|
|
35
|
+
const [width, height] = RESOLUTIONS[options.resolution ?? 'hd'];
|
|
36
|
+
const deviceId = options.deviceId ?? (options.rememberDevice !== false ? rememberedCamera() : undefined);
|
|
37
|
+
|
|
38
|
+
const video: MediaTrackConstraints = {
|
|
39
|
+
width: { ideal: width },
|
|
40
|
+
height: { ideal: height },
|
|
41
|
+
frameRate: { ideal: 30 },
|
|
42
|
+
};
|
|
43
|
+
if (deviceId) video.deviceId = { exact: deviceId };
|
|
44
|
+
else video.facingMode = { ideal: options.facingMode ?? 'environment' };
|
|
45
|
+
|
|
46
|
+
let stream: MediaStream;
|
|
47
|
+
try {
|
|
48
|
+
stream = await navigator.mediaDevices.getUserMedia({ video, audio: false });
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// A remembered camera may have been unplugged: retry with the facing mode.
|
|
51
|
+
if (!deviceId || options.deviceId) throw err;
|
|
52
|
+
delete video.deviceId;
|
|
53
|
+
video.facingMode = { ideal: options.facingMode ?? 'environment' };
|
|
54
|
+
stream = await navigator.mediaDevices.getUserMedia({ video, audio: false });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const track = stream.getVideoTracks()[0];
|
|
58
|
+
if (track) await applyAdvanced(track, { focusMode: 'continuous' });
|
|
59
|
+
return stream;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Video input devices. Labels are only populated once camera permission was granted. */
|
|
63
|
+
export async function listCameras(): Promise<MediaDeviceInfo[]> {
|
|
64
|
+
if (!navigator.mediaDevices?.enumerateDevices) return [];
|
|
65
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
66
|
+
return devices.filter((d) => d.kind === 'videoinput');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ExtendedCapabilities extends MediaTrackCapabilities {
|
|
70
|
+
torch?: boolean;
|
|
71
|
+
zoom?: { min: number; max: number; step?: number };
|
|
72
|
+
focusMode?: string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function capabilities(track: MediaStreamTrack | undefined): ExtendedCapabilities {
|
|
76
|
+
try {
|
|
77
|
+
return (track?.getCapabilities?.() ?? {}) as ExtendedCapabilities;
|
|
78
|
+
} catch {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function setTorch(track: MediaStreamTrack, on: boolean): Promise<void> {
|
|
84
|
+
if (!capabilities(track).torch) throw new Error('This camera has no controllable flashlight');
|
|
85
|
+
await track.applyConstraints({ advanced: [{ torch: on } as MediaTrackConstraintSet] });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function setZoom(track: MediaStreamTrack, zoom: number): Promise<void> {
|
|
89
|
+
const range = capabilities(track).zoom;
|
|
90
|
+
if (!range) throw new Error('This camera does not support zoom');
|
|
91
|
+
const value = Math.min(range.max, Math.max(range.min, zoom));
|
|
92
|
+
await track.applyConstraints({ advanced: [{ zoom: value } as MediaTrackConstraintSet] });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function applyAdvanced(track: MediaStreamTrack, set: Record<string, unknown>): Promise<void> {
|
|
96
|
+
const caps = capabilities(track) as Record<string, unknown>;
|
|
97
|
+
const supported = Object.keys(set).filter((k) => {
|
|
98
|
+
const c = caps[k];
|
|
99
|
+
return Array.isArray(c) ? c.includes(set[k]) : c !== undefined;
|
|
100
|
+
});
|
|
101
|
+
if (supported.length === 0) return;
|
|
102
|
+
try {
|
|
103
|
+
await track.applyConstraints({ advanced: [Object.fromEntries(supported.map((k) => [k, set[k]]))] });
|
|
104
|
+
} catch {
|
|
105
|
+
/* best effort */
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Engine core: owns the WebAssembly module and its objects.
|
|
2
|
+
//
|
|
3
|
+
// Two roles, so that frames can be decoded on several threads while a single
|
|
4
|
+
// session keeps the state (strategy scheduling, multi-frame confirmation,
|
|
5
|
+
// de-duplication):
|
|
6
|
+
// - `SessionCore`: the session. Cheap calls (`plan`, `commit`), main thread.
|
|
7
|
+
// - `DecoderCore`: the expensive decoding, one per Web Worker.
|
|
8
|
+
// The inline engine (no Worker support) uses both on the main thread.
|
|
9
|
+
import init, { WasmFrameDecoder, WasmScanner, WasmScanSession, version } from './wasm/scanner_wasm.js';
|
|
10
|
+
import type { FramePlan, FrameReport, PixelFormat, ScanResult, WasmScanConfig } from './wasm/scanner_wasm.js';
|
|
11
|
+
import type { Detection, EngineOptions } from './types.js';
|
|
12
|
+
|
|
13
|
+
export type { FramePlan, FrameReport };
|
|
14
|
+
|
|
15
|
+
/** Default location of the engine binary, next to this module. */
|
|
16
|
+
export function defaultWasmUrl(): URL {
|
|
17
|
+
return new URL('./wasm/scanner_wasm_bg.wasm', import.meta.url);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compile the engine once. The compiled module can be posted to any number
|
|
22
|
+
* of workers, which then only instantiate it (no second download or compile).
|
|
23
|
+
*/
|
|
24
|
+
export async function compileWasm(url: string | URL): Promise<WebAssembly.Module> {
|
|
25
|
+
const response = fetch(url);
|
|
26
|
+
try {
|
|
27
|
+
return await WebAssembly.compileStreaming(response);
|
|
28
|
+
} catch {
|
|
29
|
+
// Servers that do not send `application/wasm` reject streaming compilation.
|
|
30
|
+
return WebAssembly.compile(await (await response).arrayBuffer());
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let initPromise: Promise<string> | null = null;
|
|
35
|
+
|
|
36
|
+
/** Instantiate the engine in this realm (once). Returns the engine version. */
|
|
37
|
+
export function loadWasm(source: string | URL | WebAssembly.Module): Promise<string> {
|
|
38
|
+
initPromise ??= init({ module_or_path: source }).then(() => version());
|
|
39
|
+
return initPromise;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Keep only the engine options (drops URLs and other non-cloneable values). */
|
|
43
|
+
export function pickEngineOptions(o: EngineOptions): EngineOptions {
|
|
44
|
+
const { formats, preprocessing, tryInvert, maxResults, duplicateIntervalMs, linearConfirmations } = o;
|
|
45
|
+
return { formats, preprocessing, tryInvert, maxResults, duplicateIntervalMs, linearConfirmations };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function toWasmConfig(o: EngineOptions, extra: Partial<WasmScanConfig> = {}): WasmScanConfig {
|
|
49
|
+
const cfg: WasmScanConfig = { ...extra };
|
|
50
|
+
if (o.formats) cfg.formats = o.formats;
|
|
51
|
+
if (o.preprocessing !== undefined) cfg.preprocessing = o.preprocessing;
|
|
52
|
+
if (o.tryInvert !== undefined) cfg.tryInvert = o.tryInvert;
|
|
53
|
+
if (o.maxResults !== undefined) cfg.maxResults = o.maxResults;
|
|
54
|
+
cfg.dedupCooldownMs = o.duplicateIntervalMs ?? 1500;
|
|
55
|
+
if (o.linearConfirmations !== undefined) cfg.linearConfirmations = o.linearConfirmations;
|
|
56
|
+
return cfg;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Configuration of camera sessions and their decoders: they must be equal,
|
|
61
|
+
* since a plan refers to the decoder's list of fallback strategies.
|
|
62
|
+
*/
|
|
63
|
+
function streamConfig(o: EngineOptions): WasmScanConfig {
|
|
64
|
+
// Back-pressure is handled by the SDK (frames are skipped, never queued),
|
|
65
|
+
// so the engine's own frame-skipping throttle would only drop useful frames.
|
|
66
|
+
return toWasmConfig(o, { adaptiveThrottle: false });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function toDetection(r: ScanResult): Detection {
|
|
70
|
+
const d: Detection = { text: r.text, format: r.format, points: r.points, timestamp: r.timestampMs };
|
|
71
|
+
if (r.rawBytes) d.rawBytes = r.rawBytes;
|
|
72
|
+
return d;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface FrameOutcome {
|
|
76
|
+
/** New codes, or `null`. */
|
|
77
|
+
results: Detection[] | null;
|
|
78
|
+
/** Every code seen in this frame (including duplicates in cooldown). */
|
|
79
|
+
live: Detection[];
|
|
80
|
+
decodeMs: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The scan session of a camera stream (state only, cheap calls). */
|
|
84
|
+
export class SessionCore {
|
|
85
|
+
private session: WasmScanSession | null = null;
|
|
86
|
+
|
|
87
|
+
configure(options: EngineOptions): void {
|
|
88
|
+
this.session?.free();
|
|
89
|
+
this.session = new WasmScanSession(streamConfig(options));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Strategy plan of the next frame, for a `DecoderCore` configured the same way. */
|
|
93
|
+
plan(): FramePlan {
|
|
94
|
+
return this.active().plan();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Record a decoded frame; returns the new codes and the live ones. */
|
|
98
|
+
commit(report: FrameReport): FrameOutcome {
|
|
99
|
+
const session = this.active();
|
|
100
|
+
const fresh = session.commit(report);
|
|
101
|
+
const results = fresh ? fresh.map(toDetection) : null;
|
|
102
|
+
return { results, live: results ?? session.lastDetections.map(toDetection), decodeMs: report.elapsedMs };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Decode on this thread (inline engine): plan, decode and commit at once. */
|
|
106
|
+
process(pixels: Uint8Array, width: number, height: number, format: PixelFormat): FrameOutcome {
|
|
107
|
+
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;
|
|
113
|
+
const results = fresh ? fresh.map(toDetection) : null;
|
|
114
|
+
return { results, live: results ?? session.lastDetections.map(toDetection), decodeMs };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
reset(): void {
|
|
118
|
+
this.session?.reset();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
clearCooldown(text: string): void {
|
|
122
|
+
this.session?.clearCooldown(text);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
destroy(): void {
|
|
126
|
+
this.session?.free();
|
|
127
|
+
this.session = null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private active(): WasmScanSession {
|
|
131
|
+
if (!this.session) throw new Error('Engine not configured');
|
|
132
|
+
return this.session;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Frame decoding (stateless) and still-image scanning: the expensive part. */
|
|
137
|
+
export class DecoderCore {
|
|
138
|
+
private decoder: WasmFrameDecoder | null = null;
|
|
139
|
+
private scanners = new Map<string, WasmScanner>();
|
|
140
|
+
|
|
141
|
+
configure(options: EngineOptions): void {
|
|
142
|
+
this.decoder?.free();
|
|
143
|
+
this.decoder = new WasmFrameDecoder(streamConfig(options));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
decode(
|
|
147
|
+
pixels: Uint8Array,
|
|
148
|
+
width: number,
|
|
149
|
+
height: number,
|
|
150
|
+
format: PixelFormat,
|
|
151
|
+
plan: FramePlan,
|
|
152
|
+
): FrameReport {
|
|
153
|
+
const decoder = this.decoder;
|
|
154
|
+
if (!decoder) throw new Error('Engine not configured');
|
|
155
|
+
decoder.frameBuffer(pixels.length).set(pixels);
|
|
156
|
+
return decoder.decodeFrameBuffer(width, height, format, plan);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** One-shot scan of a still image, running every strategy. */
|
|
160
|
+
scan(
|
|
161
|
+
pixels: Uint8Array,
|
|
162
|
+
width: number,
|
|
163
|
+
height: number,
|
|
164
|
+
format: PixelFormat,
|
|
165
|
+
options: EngineOptions,
|
|
166
|
+
): Detection[] {
|
|
167
|
+
const config = toWasmConfig(options);
|
|
168
|
+
const key = JSON.stringify(config);
|
|
169
|
+
let scanner = this.scanners.get(key);
|
|
170
|
+
if (!scanner) {
|
|
171
|
+
if (this.scanners.size >= 4) {
|
|
172
|
+
const [oldKey, old] = this.scanners.entries().next().value as [string, WasmScanner];
|
|
173
|
+
old.free();
|
|
174
|
+
this.scanners.delete(oldKey);
|
|
175
|
+
}
|
|
176
|
+
scanner = new WasmScanner(config);
|
|
177
|
+
this.scanners.set(key, scanner);
|
|
178
|
+
}
|
|
179
|
+
scanner.frameBuffer(pixels.length).set(pixels);
|
|
180
|
+
return scanner.scanFrameBuffer(width, height, format).map(toDetection);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
destroy(): void {
|
|
184
|
+
this.decoder?.free();
|
|
185
|
+
this.decoder = null;
|
|
186
|
+
for (const s of this.scanners.values()) s.free();
|
|
187
|
+
this.scanners.clear();
|
|
188
|
+
}
|
|
189
|
+
}
|
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
|
+
}
|