@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/reader.ts
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
// BarcodeReader: camera preview + frame pipeline + engine, wired together.
|
|
2
|
+
import { capabilities, listCameras, openCamera, rememberCamera, setTorch, setZoom } from './camera.js';
|
|
3
|
+
import { pickEngineOptions } from './core.js';
|
|
4
|
+
import { createEngine, type Engine } from './engine.js';
|
|
5
|
+
import { Feedback } from './feedback.js';
|
|
6
|
+
import {
|
|
7
|
+
clampRegion,
|
|
8
|
+
coverLayout,
|
|
9
|
+
decoderToVideo,
|
|
10
|
+
outputSize,
|
|
11
|
+
regionToVideoRect,
|
|
12
|
+
videoToElement,
|
|
13
|
+
} from './geometry.js';
|
|
14
|
+
import { CanvasGrabber, createGrabber, type FrameGrabber } from './grabber.js';
|
|
15
|
+
import { Preview } from './overlay.js';
|
|
16
|
+
import type { Detection, EngineOptions, ReaderEvents, ReaderOptions, ReaderStats, Region } from './types.js';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_REGION: Region = { x: 0.1, y: 0.2, width: 0.8, height: 0.6 };
|
|
19
|
+
|
|
20
|
+
/** A frame read from the video, waiting for (or being decoded by) the engine. */
|
|
21
|
+
interface GrabbedFrame {
|
|
22
|
+
buffer: ArrayBuffer;
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
/** Region of the video the frame was cropped from (to map points back). */
|
|
26
|
+
rect: ReturnType<typeof regionToVideoRect>;
|
|
27
|
+
grabMs: number;
|
|
28
|
+
/** Grab order: decodes finish out of order when several workers run. */
|
|
29
|
+
seq: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Listener<T> = (payload: T) => void;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Real-time barcode reader bound to a host element.
|
|
36
|
+
*
|
|
37
|
+
* ```ts
|
|
38
|
+
* const reader = await BarcodeReader.create({ host: '#scanner', formats: ['qr_code', 'ean_13'] });
|
|
39
|
+
* reader.on('detected', (codes) => console.log(codes[0].text));
|
|
40
|
+
* await reader.start();
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export class BarcodeReader {
|
|
44
|
+
private preview: Preview;
|
|
45
|
+
private grabber: FrameGrabber;
|
|
46
|
+
private feedback: Feedback;
|
|
47
|
+
private stream: MediaStream | null = null;
|
|
48
|
+
private listeners: { [K in keyof ReaderEvents]: Set<Listener<ReaderEvents[K]>> } = {
|
|
49
|
+
detected: new Set(),
|
|
50
|
+
error: new Set(),
|
|
51
|
+
started: new Set(),
|
|
52
|
+
stopped: new Set(),
|
|
53
|
+
};
|
|
54
|
+
private region: Region;
|
|
55
|
+
private engineOptions: EngineOptions;
|
|
56
|
+
private running = false;
|
|
57
|
+
private paused = false;
|
|
58
|
+
/** A grab (GPU readback) is in progress. */
|
|
59
|
+
private grabbing = false;
|
|
60
|
+
/** Frames being decoded (at most `engine.concurrency`). */
|
|
61
|
+
private decoding = 0;
|
|
62
|
+
/** Sequence number of the last grabbed frame, and of the last one drawn. */
|
|
63
|
+
private grabbed = 0;
|
|
64
|
+
private drawn = 0;
|
|
65
|
+
/** Grabbed frame waiting for the engine (at most one). */
|
|
66
|
+
private pending: GrabbedFrame | null = null;
|
|
67
|
+
private destroyed = false;
|
|
68
|
+
/** Start in progress, shared by concurrent `start()` calls. */
|
|
69
|
+
private starting: Promise<void> | null = null;
|
|
70
|
+
/** Bumped by `stop()`: a start that sees another value was cancelled. */
|
|
71
|
+
private lifecycle = 0;
|
|
72
|
+
/** Consecutive failed frames: only the first error of a streak is reported. */
|
|
73
|
+
private failures = 0;
|
|
74
|
+
private torchOn = false;
|
|
75
|
+
private buffers: ArrayBuffer[] = [];
|
|
76
|
+
private frameHandle = 0;
|
|
77
|
+
private perf = { frames: 0, since: 0, fps: 0, decodeMs: 0, grabMs: 0, w: 0, h: 0 };
|
|
78
|
+
|
|
79
|
+
private constructor(
|
|
80
|
+
private engine: Engine,
|
|
81
|
+
private options: ReaderOptions,
|
|
82
|
+
host: HTMLElement,
|
|
83
|
+
) {
|
|
84
|
+
this.region = clampRegion(options.regionOfInterest ?? DEFAULT_REGION);
|
|
85
|
+
this.engineOptions = pickEngineOptions(options);
|
|
86
|
+
this.preview = new Preview(host, options.overlay);
|
|
87
|
+
this.preview.setRegion(this.region);
|
|
88
|
+
this.grabber = createGrabber(options.processing?.backend);
|
|
89
|
+
this.feedback = new Feedback(options.feedback ?? {});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Load the engine (WebAssembly, in a Web Worker) and build the preview inside `options.host`. */
|
|
93
|
+
static async create(options: ReaderOptions): Promise<BarcodeReader> {
|
|
94
|
+
const host =
|
|
95
|
+
typeof options.host === 'string' ? document.querySelector<HTMLElement>(options.host) : options.host;
|
|
96
|
+
if (!host) throw new Error(`BarcodeReader: host element not found (${String(options.host)})`);
|
|
97
|
+
const engine = await createEngine(options);
|
|
98
|
+
await engine.configure(pickEngineOptions(options));
|
|
99
|
+
return new BarcodeReader(engine, options, host);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Video input devices (labels are empty until permission is granted). */
|
|
103
|
+
static listCameras(): Promise<MediaDeviceInfo[]> {
|
|
104
|
+
return listCameras();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
on<K extends keyof ReaderEvents>(event: K, listener: Listener<ReaderEvents[K]>): () => void {
|
|
108
|
+
this.listeners[event].add(listener);
|
|
109
|
+
return () => this.listeners[event].delete(listener);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The `<video>` element showing the camera feed. */
|
|
113
|
+
get video(): HTMLVideoElement {
|
|
114
|
+
return this.preview.video;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
get isRunning(): boolean {
|
|
118
|
+
return this.running;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
get stats(): ReaderStats {
|
|
122
|
+
return {
|
|
123
|
+
fps: this.perf.fps,
|
|
124
|
+
decodeMs: this.perf.decodeMs,
|
|
125
|
+
grabMs: this.perf.grabMs,
|
|
126
|
+
frameWidth: this.perf.w,
|
|
127
|
+
frameHeight: this.perf.h,
|
|
128
|
+
backend: this.grabber.kind,
|
|
129
|
+
worker: this.engine.inWorker,
|
|
130
|
+
workers: this.engine.concurrency,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get engineVersion(): string {
|
|
135
|
+
return this.engine.version;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Open the camera and start scanning. Call it from a user gesture to allow the beep on iOS. */
|
|
139
|
+
start(): Promise<void> {
|
|
140
|
+
this.assertAlive();
|
|
141
|
+
if (this.running) return Promise.resolve();
|
|
142
|
+
// Concurrent calls (double tap, React strict mode…) share one start: a
|
|
143
|
+
// second getUserMedia would open a second stream that is never released.
|
|
144
|
+
this.starting ??= this.open().finally(() => (this.starting = null));
|
|
145
|
+
return this.starting;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private async open(): Promise<void> {
|
|
149
|
+
const lifecycle = this.lifecycle;
|
|
150
|
+
const cancelled = () => lifecycle !== this.lifecycle || this.destroyed;
|
|
151
|
+
// Synchronous, before the first await: must run inside the user gesture.
|
|
152
|
+
this.feedback.unlock();
|
|
153
|
+
const stream = await openCamera(this.options.camera);
|
|
154
|
+
if (cancelled()) {
|
|
155
|
+
// stop() or destroy() was called while the camera was opening.
|
|
156
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
this.stream = stream;
|
|
160
|
+
const video = this.preview.video;
|
|
161
|
+
video.srcObject = stream;
|
|
162
|
+
await video.play().catch(() => undefined);
|
|
163
|
+
await waitForVideo(video);
|
|
164
|
+
if (cancelled()) return; // stop() already released `this.stream`.
|
|
165
|
+
|
|
166
|
+
const track = this.track();
|
|
167
|
+
if (this.options.camera?.rememberDevice !== false) rememberCamera(track?.getSettings().deviceId);
|
|
168
|
+
this.torchOn = false;
|
|
169
|
+
await this.refreshButtons();
|
|
170
|
+
if (cancelled()) return;
|
|
171
|
+
|
|
172
|
+
this.running = true;
|
|
173
|
+
this.paused = false;
|
|
174
|
+
this.failures = 0;
|
|
175
|
+
this.perf.since = performance.now();
|
|
176
|
+
this.perf.frames = 0;
|
|
177
|
+
this.emit('started', undefined);
|
|
178
|
+
this.schedule();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Stop scanning and release the camera. Also cancels a `start()` in progress. */
|
|
182
|
+
async stop(): Promise<void> {
|
|
183
|
+
this.lifecycle++;
|
|
184
|
+
if (!this.running && !this.stream) return;
|
|
185
|
+
const wasRunning = this.running;
|
|
186
|
+
this.running = false;
|
|
187
|
+
this.cancelSchedule();
|
|
188
|
+
this.stream?.getTracks().forEach((t) => t.stop());
|
|
189
|
+
this.stream = null;
|
|
190
|
+
this.preview.video.srcObject = null;
|
|
191
|
+
this.preview.setDetections([]);
|
|
192
|
+
if (wasRunning) this.emit('stopped', undefined);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Keep the camera running but stop decoding. */
|
|
196
|
+
pause(): void {
|
|
197
|
+
this.paused = true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
resume(): void {
|
|
201
|
+
this.paused = false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Stop everything and remove the preview from the page. The reader cannot be reused. */
|
|
205
|
+
async destroy(): Promise<void> {
|
|
206
|
+
if (this.destroyed) return;
|
|
207
|
+
await this.stop();
|
|
208
|
+
this.destroyed = true;
|
|
209
|
+
this.grabber.destroy();
|
|
210
|
+
this.preview.destroy();
|
|
211
|
+
this.feedback.destroy();
|
|
212
|
+
this.engine.destroy();
|
|
213
|
+
for (const set of Object.values(this.listeners)) set.clear();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
setRegionOfInterest(region: Region): void {
|
|
217
|
+
this.region = clampRegion(region);
|
|
218
|
+
this.preview.setRegion(this.region);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Change decoding options (formats, duplicate interval…) on the fly. Resets de-duplication. */
|
|
222
|
+
async configure(options: EngineOptions): Promise<void> {
|
|
223
|
+
this.assertAlive();
|
|
224
|
+
this.engineOptions = { ...this.engineOptions, ...options };
|
|
225
|
+
await this.engine.configure(this.engineOptions);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Allow `text` to be reported again right away. */
|
|
229
|
+
clearCooldown(text: string): Promise<void> {
|
|
230
|
+
return this.engine.clearCooldown(text);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
get torchAvailable(): boolean {
|
|
234
|
+
return !!capabilities(this.track()).torch;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async setTorch(on: boolean): Promise<void> {
|
|
238
|
+
const track = this.track();
|
|
239
|
+
if (!track) throw new Error('Camera is not started');
|
|
240
|
+
await setTorch(track, on);
|
|
241
|
+
this.torchOn = on;
|
|
242
|
+
await this.refreshButtons();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async setZoom(zoom: number): Promise<void> {
|
|
246
|
+
const track = this.track();
|
|
247
|
+
if (!track) throw new Error('Camera is not started');
|
|
248
|
+
await setZoom(track, zoom);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Switch to another camera (next one in the list when `deviceId` is omitted). */
|
|
252
|
+
async switchCamera(deviceId?: string): Promise<void> {
|
|
253
|
+
this.assertAlive();
|
|
254
|
+
if (!deviceId) {
|
|
255
|
+
const cams = await listCameras();
|
|
256
|
+
const current = this.track()?.getSettings().deviceId;
|
|
257
|
+
const idx = cams.findIndex((c) => c.deviceId === current);
|
|
258
|
+
deviceId = cams[(idx + 1) % cams.length]?.deviceId;
|
|
259
|
+
}
|
|
260
|
+
const wasRunning = this.running;
|
|
261
|
+
await this.stop();
|
|
262
|
+
this.options = { ...this.options, camera: { ...this.options.camera, deviceId } };
|
|
263
|
+
if (wasRunning) await this.start();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Frame loop ───────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
private schedule(): void {
|
|
269
|
+
if (!this.running) return;
|
|
270
|
+
const video = this.preview.video;
|
|
271
|
+
if ('requestVideoFrameCallback' in video) {
|
|
272
|
+
this.frameHandle = video.requestVideoFrameCallback(() => this.tick());
|
|
273
|
+
} else {
|
|
274
|
+
this.frameHandle = requestAnimationFrame(() => this.tick());
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private cancelSchedule(): void {
|
|
279
|
+
const video = this.preview.video;
|
|
280
|
+
if ('cancelVideoFrameCallback' in video) video.cancelVideoFrameCallback(this.frameHandle);
|
|
281
|
+
cancelAnimationFrame(this.frameHandle);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Frame pipeline, two stages that run **concurrently**: the grab (GPU crop +
|
|
286
|
+
* readback, main thread) of the next frame overlaps the decoding of the
|
|
287
|
+
* previous ones, which itself runs on `engine.concurrency` workers in
|
|
288
|
+
* parallel. Running grab and decode one after the other made the frame
|
|
289
|
+
* time `grab + decode`; overlapped it is `max(grab, decode / workers)`
|
|
290
|
+
* (Galaxy A14: grab ~55 ms, decode ~125 ms → ~180 ms per frame before).
|
|
291
|
+
*
|
|
292
|
+
* Back-pressure: at most one grab in flight and one frame waiting for a
|
|
293
|
+
* free worker. While a frame waits, no new grab starts, so frames are
|
|
294
|
+
* skipped, never queued, and the waiting frame is at most one decode old.
|
|
295
|
+
*/
|
|
296
|
+
private tick(): void {
|
|
297
|
+
if (!this.running) return;
|
|
298
|
+
if (!this.grabbing && !this.pending && !this.paused && this.preview.video.videoWidth > 0) {
|
|
299
|
+
this.grabbing = true;
|
|
300
|
+
this.grabFrame()
|
|
301
|
+
.then((frame) => this.enqueue(frame))
|
|
302
|
+
.catch((err) => this.handleFrameError(err))
|
|
303
|
+
.finally(() => (this.grabbing = false));
|
|
304
|
+
}
|
|
305
|
+
this.preview.render();
|
|
306
|
+
this.schedule();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private async grabFrame(): Promise<GrabbedFrame> {
|
|
310
|
+
const video = this.preview.video;
|
|
311
|
+
const ew = this.preview.width || video.videoWidth;
|
|
312
|
+
const eh = this.preview.height || video.videoHeight;
|
|
313
|
+
const rect = regionToVideoRect(this.region, video.videoWidth, video.videoHeight, ew, eh);
|
|
314
|
+
const { width, height } = outputSize(rect, this.options.processing?.maxDimension ?? 1024);
|
|
315
|
+
|
|
316
|
+
const t0 = performance.now();
|
|
317
|
+
const buffer = this.takeBuffer(width * height);
|
|
318
|
+
try {
|
|
319
|
+
await this.grabber.grab(video, rect, width, height, new Uint8Array(buffer));
|
|
320
|
+
} catch (err) {
|
|
321
|
+
this.buffers.push(buffer);
|
|
322
|
+
throw new GrabError(err);
|
|
323
|
+
}
|
|
324
|
+
return { buffer, width, height, rect, grabMs: performance.now() - t0, seq: ++this.grabbed };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private enqueue(frame: GrabbedFrame): void {
|
|
328
|
+
if (!this.running || this.paused) {
|
|
329
|
+
this.buffers.push(frame.buffer);
|
|
330
|
+
} else if (this.decoding >= this.engine.concurrency) {
|
|
331
|
+
this.pending = frame;
|
|
332
|
+
} else {
|
|
333
|
+
this.decode(frame);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private decode(frame: GrabbedFrame): void {
|
|
338
|
+
this.decoding++;
|
|
339
|
+
this.decodeFrame(frame)
|
|
340
|
+
.catch((err) => this.handleFrameError(err))
|
|
341
|
+
.finally(() => {
|
|
342
|
+
this.decoding--;
|
|
343
|
+
const next = this.pending;
|
|
344
|
+
this.pending = null;
|
|
345
|
+
if (next) this.enqueue(next);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private async decodeFrame(frame: GrabbedFrame): Promise<void> {
|
|
350
|
+
const { width, height, rect } = frame;
|
|
351
|
+
const reply = await this.engine.processFrame(frame.buffer, width, height, 'luma');
|
|
352
|
+
this.buffers.push(reply.buffer);
|
|
353
|
+
if (!this.running) return;
|
|
354
|
+
this.failures = 0;
|
|
355
|
+
|
|
356
|
+
this.updatePerf(frame.grabMs, reply.decodeMs, width, height);
|
|
357
|
+
|
|
358
|
+
const video = this.preview.video;
|
|
359
|
+
const vw = video.videoWidth;
|
|
360
|
+
const vh = video.videoHeight;
|
|
361
|
+
const toVideo = (d: Detection): Detection => ({
|
|
362
|
+
...d,
|
|
363
|
+
points: decoderToVideo(d.points, rect, width, height),
|
|
364
|
+
});
|
|
365
|
+
// An older frame finishing after a newer one must not move the highlights back.
|
|
366
|
+
if (frame.seq > this.drawn) {
|
|
367
|
+
this.drawn = frame.seq;
|
|
368
|
+
const live = reply.live.map(toVideo);
|
|
369
|
+
const layout = coverLayout(vw, vh, this.preview.width || vw, this.preview.height || vh);
|
|
370
|
+
this.preview.setDetections(live.map((d) => d.points.map((p) => videoToElement(p, layout))));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (reply.results && reply.results.length > 0) {
|
|
374
|
+
this.feedback.play();
|
|
375
|
+
this.emit('detected', reply.results.map(toVideo));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private handleFrameError(err: unknown): void {
|
|
380
|
+
// Frames still in flight when the reader was stopped or destroyed.
|
|
381
|
+
if (!this.running || this.destroyed) return;
|
|
382
|
+
if (err instanceof GrabError && this.grabber.kind === 'webgl') {
|
|
383
|
+
// GPU pipeline failed (context lost, driver issue…): continue on Canvas 2D.
|
|
384
|
+
console.warn('[scanner] WebGL pipeline failed, falling back to Canvas 2D:', err.cause);
|
|
385
|
+
this.grabber.destroy();
|
|
386
|
+
this.grabber = new CanvasGrabber();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
// A persistent failure (engine unavailable…) would otherwise emit one error
|
|
390
|
+
// per frame, ~30 per second: report the first one of each streak.
|
|
391
|
+
if (this.failures++ > 0) return;
|
|
392
|
+
const error = err instanceof GrabError ? err.cause : err;
|
|
393
|
+
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private takeBuffer(size: number): ArrayBuffer {
|
|
397
|
+
// Buffers of another size (ROI or resolution changed) are dropped.
|
|
398
|
+
for (let b = this.buffers.pop(); b; b = this.buffers.pop()) {
|
|
399
|
+
if (b.byteLength === size) return b;
|
|
400
|
+
}
|
|
401
|
+
return new ArrayBuffer(size);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private updatePerf(grabMs: number, decodeMs: number, w: number, h: number): void {
|
|
405
|
+
const p = this.perf;
|
|
406
|
+
p.frames++;
|
|
407
|
+
p.grabMs = p.grabMs ? p.grabMs * 0.9 + grabMs * 0.1 : grabMs;
|
|
408
|
+
p.decodeMs = p.decodeMs ? p.decodeMs * 0.9 + decodeMs * 0.1 : decodeMs;
|
|
409
|
+
p.w = w;
|
|
410
|
+
p.h = h;
|
|
411
|
+
const now = performance.now();
|
|
412
|
+
if (now - p.since >= 1000) {
|
|
413
|
+
p.fps = (p.frames * 1000) / (now - p.since);
|
|
414
|
+
p.frames = 0;
|
|
415
|
+
p.since = now;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
private track(): MediaStreamTrack | undefined {
|
|
422
|
+
return this.stream?.getVideoTracks()[0];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private async refreshButtons(): Promise<void> {
|
|
426
|
+
this.preview.setTorchButton(this.torchAvailable, this.torchOn, () => {
|
|
427
|
+
this.setTorch(!this.torchOn).catch((e) => this.emit('error', e));
|
|
428
|
+
});
|
|
429
|
+
const cams = await listCameras().catch(() => []);
|
|
430
|
+
this.preview.setSwitchButton(cams.length > 1, () => {
|
|
431
|
+
this.switchCamera().catch((e) => this.emit('error', e));
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private emit<K extends keyof ReaderEvents>(event: K, payload: ReaderEvents[K]): void {
|
|
436
|
+
for (const l of this.listeners[event]) {
|
|
437
|
+
try {
|
|
438
|
+
l(payload);
|
|
439
|
+
} catch (err) {
|
|
440
|
+
console.error('[scanner] listener error:', err);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private assertAlive(): void {
|
|
446
|
+
if (this.destroyed) throw new Error('BarcodeReader has been destroyed');
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Failure of the frame pipeline (GPU / canvas), as opposed to the engine.
|
|
452
|
+
* Only these trigger the WebGL → Canvas 2D fallback: an engine error must not
|
|
453
|
+
* be mistaken for a WebGL problem.
|
|
454
|
+
*/
|
|
455
|
+
class GrabError extends Error {
|
|
456
|
+
constructor(override readonly cause: unknown) {
|
|
457
|
+
super(cause instanceof Error ? cause.message : String(cause));
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function waitForVideo(video: HTMLVideoElement): Promise<void> {
|
|
462
|
+
if (video.readyState >= 2 && video.videoWidth > 0) return Promise.resolve();
|
|
463
|
+
return new Promise((resolve, reject) => {
|
|
464
|
+
const timer = setTimeout(() => reject(new Error('Camera did not deliver any frame')), 10_000);
|
|
465
|
+
video.addEventListener(
|
|
466
|
+
'loadeddata',
|
|
467
|
+
() => {
|
|
468
|
+
clearTimeout(timer);
|
|
469
|
+
resolve();
|
|
470
|
+
},
|
|
471
|
+
{ once: true },
|
|
472
|
+
);
|
|
473
|
+
});
|
|
474
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// One-shot scanning of still images (file uploads, photos, canvases…).
|
|
2
|
+
import { pickEngineOptions } from './core.js';
|
|
3
|
+
import { sharedEngine } from './engine.js';
|
|
4
|
+
import type { AssetOptions, Detection, EngineOptions } from './types.js';
|
|
5
|
+
|
|
6
|
+
export type ImageInput =
|
|
7
|
+
Blob | ImageData | ImageBitmap | HTMLImageElement | HTMLCanvasElement | HTMLVideoElement | OffscreenCanvas;
|
|
8
|
+
|
|
9
|
+
export interface ScanImageOptions extends EngineOptions, AssetOptions {
|
|
10
|
+
/** Images larger than this (longest side, px) are downscaled first. Default: `2048`. */
|
|
11
|
+
maxDimension?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Scan a still image. Runs every decoding strategy (rotations, inversion,
|
|
16
|
+
* contrast enhancement…) and returns all codes found, with points in the
|
|
17
|
+
* pixel coordinates of the source image.
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* const [code] = await scanImage(fileInput.files[0], { formats: ['qr_code'] });
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export async function scanImage(input: ImageInput, options: ScanImageOptions = {}): Promise<Detection[]> {
|
|
24
|
+
const engine = await sharedEngine(options);
|
|
25
|
+
const { data, width, height, scale } = await rasterize(input, options.maxDimension ?? 2048);
|
|
26
|
+
const engineOptions: EngineOptions = { ...pickEngineOptions(options), maxResults: options.maxResults ?? 8 };
|
|
27
|
+
const results = await engine.scanImage(data.buffer as ArrayBuffer, width, height, 'rgba', engineOptions);
|
|
28
|
+
return scale === 1
|
|
29
|
+
? results
|
|
30
|
+
: results.map((r) => ({ ...r, points: r.points.map((p) => ({ x: p.x / scale, y: p.y / scale })) }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function rasterize(input: ImageInput, maxDim: number) {
|
|
34
|
+
if (
|
|
35
|
+
typeof ImageData !== 'undefined' &&
|
|
36
|
+
input instanceof ImageData &&
|
|
37
|
+
Math.max(input.width, input.height) <= maxDim
|
|
38
|
+
) {
|
|
39
|
+
return {
|
|
40
|
+
data: new Uint8Array(input.data.slice().buffer),
|
|
41
|
+
width: input.width,
|
|
42
|
+
height: input.height,
|
|
43
|
+
scale: 1,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let source: CanvasImageSource | ImageBitmap;
|
|
48
|
+
let owned: ImageBitmap | null = null;
|
|
49
|
+
if (input instanceof Blob) {
|
|
50
|
+
owned = await createImageBitmap(input);
|
|
51
|
+
source = owned;
|
|
52
|
+
} else if (typeof ImageData !== 'undefined' && input instanceof ImageData) {
|
|
53
|
+
owned = await createImageBitmap(input);
|
|
54
|
+
source = owned;
|
|
55
|
+
} else {
|
|
56
|
+
if (typeof HTMLImageElement !== 'undefined' && input instanceof HTMLImageElement && !input.complete) {
|
|
57
|
+
await input.decode();
|
|
58
|
+
}
|
|
59
|
+
source = input as CanvasImageSource;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const [sw, sh] = size(source);
|
|
63
|
+
if (!sw || !sh) throw new Error('scanImage: image has no pixels');
|
|
64
|
+
const scale = Math.min(1, maxDim / Math.max(sw, sh));
|
|
65
|
+
const width = Math.max(1, Math.round(sw * scale));
|
|
66
|
+
const height = Math.max(1, Math.round(sh * scale));
|
|
67
|
+
|
|
68
|
+
const canvas =
|
|
69
|
+
typeof OffscreenCanvas !== 'undefined'
|
|
70
|
+
? new OffscreenCanvas(width, height)
|
|
71
|
+
: Object.assign(document.createElement('canvas'), { width, height });
|
|
72
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true }) as
|
|
73
|
+
CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
|
|
74
|
+
if (!ctx) throw new Error('scanImage: 2D canvas is not available');
|
|
75
|
+
ctx.drawImage(source, 0, 0, width, height);
|
|
76
|
+
owned?.close();
|
|
77
|
+
const pixels = ctx.getImageData(0, 0, width, height).data;
|
|
78
|
+
return { data: new Uint8Array(pixels.buffer), width, height, scale };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function size(s: CanvasImageSource | ImageBitmap): [number, number] {
|
|
82
|
+
if (typeof HTMLImageElement !== 'undefined' && s instanceof HTMLImageElement)
|
|
83
|
+
return [s.naturalWidth, s.naturalHeight];
|
|
84
|
+
if (typeof HTMLVideoElement !== 'undefined' && s instanceof HTMLVideoElement)
|
|
85
|
+
return [s.videoWidth, s.videoHeight];
|
|
86
|
+
const o = s as { width: number; height: number };
|
|
87
|
+
return [o.width, o.height];
|
|
88
|
+
}
|