@agicash/qr-scanner 0.1.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 +136 -0
- package/dist/index.cjs +1065 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +106 -0
- package/dist/index.d.ts +106 -0
- package/dist/index.js +1036 -0
- package/dist/index.js.map +1 -0
- package/dist/worker.js +1785 -0
- package/dist/worker.js.map +1 -0
- package/package.json +58 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
CameraNotFoundError: () => CameraNotFoundError,
|
|
24
|
+
CameraPermissionError: () => CameraPermissionError,
|
|
25
|
+
default: () => index_default
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(index_exports);
|
|
28
|
+
|
|
29
|
+
// src/camera.ts
|
|
30
|
+
var CameraPermissionError = class extends Error {
|
|
31
|
+
constructor(message = "Camera access denied. Please grant camera permission and try again.") {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "CameraPermissionError";
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
var CameraNotFoundError = class extends Error {
|
|
37
|
+
constructor(message = "No camera found. Please connect a camera and try again.") {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "CameraNotFoundError";
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
var CACHE_KEY_PREFIX = "@agicash/qr-scanner:camera:";
|
|
43
|
+
function getCachedDeviceId(facingMode) {
|
|
44
|
+
try {
|
|
45
|
+
return localStorage.getItem(`${CACHE_KEY_PREFIX}${facingMode}`);
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function setCachedDeviceId(facingMode, deviceId) {
|
|
51
|
+
try {
|
|
52
|
+
localStorage.setItem(`${CACHE_KEY_PREFIX}${facingMode}`, deviceId);
|
|
53
|
+
} catch {
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
var CameraManager = class {
|
|
57
|
+
constructor(config = {}) {
|
|
58
|
+
this.stream = null;
|
|
59
|
+
this.facingMode = config.preferredCamera ?? "environment";
|
|
60
|
+
this.resolution = config.cameraResolution;
|
|
61
|
+
}
|
|
62
|
+
async start(video) {
|
|
63
|
+
if (this.stream) {
|
|
64
|
+
return this.stream;
|
|
65
|
+
}
|
|
66
|
+
const t0 = performance.now();
|
|
67
|
+
this.stream = await this.acquireStream();
|
|
68
|
+
const t1 = performance.now();
|
|
69
|
+
console.debug(`[QrScanner] acquireStream: ${(t1 - t0).toFixed(0)}ms`);
|
|
70
|
+
await this.ensureBestCamera();
|
|
71
|
+
const t2 = performance.now();
|
|
72
|
+
console.debug(`[QrScanner] ensureBestCamera: ${(t2 - t1).toFixed(0)}ms`);
|
|
73
|
+
video.srcObject = this.stream;
|
|
74
|
+
video.setAttribute("playsinline", "true");
|
|
75
|
+
await video.play();
|
|
76
|
+
const t3 = performance.now();
|
|
77
|
+
console.debug(`[QrScanner] video.play: ${(t3 - t2).toFixed(0)}ms`);
|
|
78
|
+
console.debug(`[QrScanner] camera.start total: ${(t3 - t0).toFixed(0)}ms`);
|
|
79
|
+
if (this.facingMode === "environment" || this.facingMode === "user") {
|
|
80
|
+
const finalDeviceId = this.getVideoTrack()?.getSettings().deviceId;
|
|
81
|
+
if (finalDeviceId) {
|
|
82
|
+
setCachedDeviceId(this.facingMode, finalDeviceId);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return this.stream;
|
|
86
|
+
}
|
|
87
|
+
stop() {
|
|
88
|
+
if (this.stream) {
|
|
89
|
+
for (const track of this.stream.getTracks()) {
|
|
90
|
+
track.stop();
|
|
91
|
+
}
|
|
92
|
+
this.stream = null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async setCamera(facingModeOrDeviceId, video) {
|
|
96
|
+
this.stop();
|
|
97
|
+
this.facingMode = facingModeOrDeviceId;
|
|
98
|
+
await this.start(video);
|
|
99
|
+
}
|
|
100
|
+
getStream() {
|
|
101
|
+
return this.stream;
|
|
102
|
+
}
|
|
103
|
+
async hasFlash() {
|
|
104
|
+
const track = this.getVideoTrack();
|
|
105
|
+
if (!track) return false;
|
|
106
|
+
try {
|
|
107
|
+
const capabilities = track.getCapabilities();
|
|
108
|
+
return capabilities.torch === true;
|
|
109
|
+
} catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
isFlashOn() {
|
|
114
|
+
const track = this.getVideoTrack();
|
|
115
|
+
if (!track) return false;
|
|
116
|
+
const settings = track.getSettings();
|
|
117
|
+
return settings.torch === true;
|
|
118
|
+
}
|
|
119
|
+
async toggleFlash() {
|
|
120
|
+
if (this.isFlashOn()) {
|
|
121
|
+
await this.turnFlashOff();
|
|
122
|
+
} else {
|
|
123
|
+
await this.turnFlashOn();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async turnFlashOn() {
|
|
127
|
+
await this.setTorch(true);
|
|
128
|
+
}
|
|
129
|
+
async turnFlashOff() {
|
|
130
|
+
await this.setTorch(false);
|
|
131
|
+
}
|
|
132
|
+
async setTorch(on) {
|
|
133
|
+
const track = this.getVideoTrack();
|
|
134
|
+
if (!track) {
|
|
135
|
+
throw new Error("No active camera stream");
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
await track.applyConstraints({
|
|
139
|
+
advanced: [{ torch: on }]
|
|
140
|
+
});
|
|
141
|
+
} catch {
|
|
142
|
+
throw new Error("Flash/torch is not supported on this device");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* If the current camera lacks continuous autofocus (e.g. an ultrawide sensor
|
|
147
|
+
* picked by facingMode: 'environment'), find a better camera with the same
|
|
148
|
+
* facing mode and replace this.stream. Called before assigning to the video
|
|
149
|
+
* element so the user never sees the wrong camera.
|
|
150
|
+
*/
|
|
151
|
+
async ensureBestCamera() {
|
|
152
|
+
if (this.facingMode !== "environment" && this.facingMode !== "user") {
|
|
153
|
+
console.debug(
|
|
154
|
+
"[QrScanner] ensureBestCamera: skipped (specific deviceId)"
|
|
155
|
+
);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const track = this.getVideoTrack();
|
|
159
|
+
if (!track) return;
|
|
160
|
+
try {
|
|
161
|
+
const capabilities = track.getCapabilities();
|
|
162
|
+
if (!capabilities.focusMode || capabilities.focusMode.includes("continuous")) {
|
|
163
|
+
console.debug(
|
|
164
|
+
`[QrScanner] ensureBestCamera: skipped (focusMode: ${JSON.stringify(capabilities.focusMode)})`
|
|
165
|
+
);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
console.debug(
|
|
169
|
+
`[QrScanner] ensureBestCamera: current camera lacks autofocus (focusMode: ${JSON.stringify(capabilities.focusMode)})`
|
|
170
|
+
);
|
|
171
|
+
} catch {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const currentDeviceId = track.getSettings().deviceId;
|
|
175
|
+
let devices;
|
|
176
|
+
try {
|
|
177
|
+
devices = await navigator.mediaDevices.enumerateDevices();
|
|
178
|
+
} catch {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (!Array.isArray(devices)) return;
|
|
182
|
+
const candidates = devices.filter(
|
|
183
|
+
(d) => d.kind === "videoinput" && d.deviceId !== currentDeviceId
|
|
184
|
+
);
|
|
185
|
+
console.debug(
|
|
186
|
+
`[QrScanner] ensureBestCamera: testing ${candidates.length} candidate camera(s)`
|
|
187
|
+
);
|
|
188
|
+
if (candidates.length === 0) return;
|
|
189
|
+
this.stop();
|
|
190
|
+
for (const candidate of candidates) {
|
|
191
|
+
const t = performance.now();
|
|
192
|
+
let candidateStream;
|
|
193
|
+
try {
|
|
194
|
+
candidateStream = await navigator.mediaDevices.getUserMedia({
|
|
195
|
+
video: {
|
|
196
|
+
deviceId: { exact: candidate.deviceId },
|
|
197
|
+
width: this.resolution?.width ?? { ideal: 1920 },
|
|
198
|
+
height: this.resolution?.height ?? { ideal: 1080 }
|
|
199
|
+
},
|
|
200
|
+
audio: false
|
|
201
|
+
});
|
|
202
|
+
console.debug(
|
|
203
|
+
`[QrScanner] ensureBestCamera: candidate ${candidate.label || candidate.deviceId.slice(0, 8)}: getUserMedia ${(performance.now() - t).toFixed(0)}ms`
|
|
204
|
+
);
|
|
205
|
+
} catch {
|
|
206
|
+
console.debug(
|
|
207
|
+
`[QrScanner] ensureBestCamera: candidate ${candidate.label || candidate.deviceId.slice(0, 8)}: getUserMedia failed ${(performance.now() - t).toFixed(0)}ms`
|
|
208
|
+
);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const candidateTrack = candidateStream.getVideoTracks()[0];
|
|
212
|
+
if (!candidateTrack) {
|
|
213
|
+
for (const t2 of candidateStream.getTracks()) t2.stop();
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const candidateSettings = candidateTrack.getSettings();
|
|
217
|
+
if (candidateSettings.facingMode && candidateSettings.facingMode !== this.facingMode) {
|
|
218
|
+
for (const t2 of candidateStream.getTracks()) t2.stop();
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const candidateCaps = candidateTrack.getCapabilities();
|
|
223
|
+
if (candidateCaps.focusMode?.includes("continuous")) {
|
|
224
|
+
this.stream = candidateStream;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
} catch {
|
|
228
|
+
}
|
|
229
|
+
for (const t2 of candidateStream.getTracks()) t2.stop();
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
this.stream = await navigator.mediaDevices.getUserMedia({
|
|
233
|
+
video: {
|
|
234
|
+
deviceId: currentDeviceId ? { exact: currentDeviceId } : void 0,
|
|
235
|
+
width: this.resolution?.width ?? { ideal: 1920 },
|
|
236
|
+
height: this.resolution?.height ?? { ideal: 1080 }
|
|
237
|
+
},
|
|
238
|
+
audio: false
|
|
239
|
+
});
|
|
240
|
+
} catch {
|
|
241
|
+
try {
|
|
242
|
+
this.stream = await navigator.mediaDevices.getUserMedia(
|
|
243
|
+
this.buildConstraints()
|
|
244
|
+
);
|
|
245
|
+
} catch {
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
getVideoTrack() {
|
|
250
|
+
if (!this.stream) return null;
|
|
251
|
+
const tracks = this.stream.getVideoTracks();
|
|
252
|
+
return tracks[0] ?? null;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Try getUserMedia with progressively simpler constraints.
|
|
256
|
+
*
|
|
257
|
+
* Some browsers (e.g. Brave on Samsung Galaxy S24) throw NotReadableError
|
|
258
|
+
* when facingMode and resolution constraints are combined. Falling back to
|
|
259
|
+
* fewer constraints lets us still open the camera on those browsers.
|
|
260
|
+
*/
|
|
261
|
+
async acquireStream() {
|
|
262
|
+
const labels = [];
|
|
263
|
+
const attempts = [];
|
|
264
|
+
if (this.facingMode === "environment" || this.facingMode === "user") {
|
|
265
|
+
const cachedId = getCachedDeviceId(this.facingMode);
|
|
266
|
+
if (cachedId) {
|
|
267
|
+
labels.push("cached deviceId");
|
|
268
|
+
const video = {
|
|
269
|
+
deviceId: { exact: cachedId }
|
|
270
|
+
};
|
|
271
|
+
if (this.resolution?.width) video.width = this.resolution.width;
|
|
272
|
+
else video.width = { ideal: 1920 };
|
|
273
|
+
if (this.resolution?.height) video.height = this.resolution.height;
|
|
274
|
+
else video.height = { ideal: 1080 };
|
|
275
|
+
attempts.push({ video, audio: false });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
labels.push("full constraints", "no resolution", "bare minimum");
|
|
279
|
+
attempts.push(
|
|
280
|
+
// facingMode/deviceId + resolution
|
|
281
|
+
this.buildConstraints(),
|
|
282
|
+
// facingMode/deviceId only, no resolution
|
|
283
|
+
this.buildConstraints(false),
|
|
284
|
+
// Bare minimum
|
|
285
|
+
{ video: true, audio: false }
|
|
286
|
+
);
|
|
287
|
+
let lastError;
|
|
288
|
+
for (let i = 0; i < attempts.length; i++) {
|
|
289
|
+
const t = performance.now();
|
|
290
|
+
try {
|
|
291
|
+
const stream = await navigator.mediaDevices.getUserMedia(attempts[i]);
|
|
292
|
+
console.debug(
|
|
293
|
+
`[QrScanner] getUserMedia(${labels[i]}): ${(performance.now() - t).toFixed(0)}ms \u2713`
|
|
294
|
+
);
|
|
295
|
+
return stream;
|
|
296
|
+
} catch (err) {
|
|
297
|
+
console.debug(
|
|
298
|
+
`[QrScanner] getUserMedia(${labels[i]}): ${(performance.now() - t).toFixed(0)}ms \u2717 ${err instanceof DOMException ? err.name : err}`
|
|
299
|
+
);
|
|
300
|
+
if (err instanceof DOMException) {
|
|
301
|
+
if (err.name === "NotAllowedError") {
|
|
302
|
+
throw new CameraPermissionError();
|
|
303
|
+
}
|
|
304
|
+
if (err.name === "NotFoundError") {
|
|
305
|
+
throw new CameraNotFoundError();
|
|
306
|
+
}
|
|
307
|
+
lastError = err;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
throw err;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
throw lastError;
|
|
314
|
+
}
|
|
315
|
+
buildConstraints(includeResolution = true) {
|
|
316
|
+
const video = {};
|
|
317
|
+
if (includeResolution) {
|
|
318
|
+
video.width = this.resolution?.width ?? { ideal: 1920 };
|
|
319
|
+
video.height = this.resolution?.height ?? { ideal: 1080 };
|
|
320
|
+
}
|
|
321
|
+
if (this.facingMode === "environment" || this.facingMode === "user") {
|
|
322
|
+
video.facingMode = this.facingMode;
|
|
323
|
+
} else {
|
|
324
|
+
video.deviceId = { exact: this.facingMode };
|
|
325
|
+
}
|
|
326
|
+
return { video, audio: false };
|
|
327
|
+
}
|
|
328
|
+
static async hasCamera() {
|
|
329
|
+
try {
|
|
330
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
331
|
+
return devices.some((d) => d.kind === "videoinput");
|
|
332
|
+
} catch {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
static async listCameras(requestLabels = false) {
|
|
337
|
+
if (requestLabels) {
|
|
338
|
+
try {
|
|
339
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
340
|
+
video: true
|
|
341
|
+
});
|
|
342
|
+
for (const track of stream.getTracks()) {
|
|
343
|
+
track.stop();
|
|
344
|
+
}
|
|
345
|
+
} catch {
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
349
|
+
return devices.filter((d) => d.kind === "videoinput").map((d) => ({
|
|
350
|
+
id: d.deviceId,
|
|
351
|
+
label: d.label || `Camera ${d.deviceId.slice(0, 8)}`
|
|
352
|
+
}));
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
// src/frame-extractor.ts
|
|
357
|
+
var FrameExtractor = class {
|
|
358
|
+
constructor(video, config) {
|
|
359
|
+
this.rafId = null;
|
|
360
|
+
this.running = false;
|
|
361
|
+
this.workerBusy = false;
|
|
362
|
+
this.lastScanTime = -Infinity;
|
|
363
|
+
this.onFrame = null;
|
|
364
|
+
this.tick = () => {
|
|
365
|
+
if (!this.running) return;
|
|
366
|
+
this.rafId = requestAnimationFrame(this.tick);
|
|
367
|
+
if (this.workerBusy) return;
|
|
368
|
+
const now = performance.now();
|
|
369
|
+
if (now - this.lastScanTime < this.minInterval) return;
|
|
370
|
+
if (this.video.readyState < 2) return;
|
|
371
|
+
this.lastScanTime = now;
|
|
372
|
+
const region = this.getScanRegion();
|
|
373
|
+
const sx = region.x ?? 0;
|
|
374
|
+
const sy = region.y ?? 0;
|
|
375
|
+
const sw = region.width ?? this.video.videoWidth;
|
|
376
|
+
const sh = region.height ?? this.video.videoHeight;
|
|
377
|
+
if (sw <= 0 || sh <= 0) return;
|
|
378
|
+
this.canvas.width = sw;
|
|
379
|
+
this.canvas.height = sh;
|
|
380
|
+
this.ctx.drawImage(this.video, sx, sy, sw, sh, 0, 0, sw, sh);
|
|
381
|
+
const imageData = this.ctx.getImageData(0, 0, sw, sh);
|
|
382
|
+
this.workerBusy = true;
|
|
383
|
+
this.onFrame?.(imageData);
|
|
384
|
+
};
|
|
385
|
+
this.video = video;
|
|
386
|
+
this.minInterval = 1e3 / config.maxScansPerSecond;
|
|
387
|
+
this.getScanRegion = config.getScanRegion;
|
|
388
|
+
if (typeof OffscreenCanvas !== "undefined") {
|
|
389
|
+
this.canvas = new OffscreenCanvas(1, 1);
|
|
390
|
+
this.ctx = this.canvas.getContext("2d");
|
|
391
|
+
} else {
|
|
392
|
+
this.canvas = document.createElement("canvas");
|
|
393
|
+
this.canvas.style.display = "none";
|
|
394
|
+
this.ctx = this.canvas.getContext("2d");
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
start(onFrame) {
|
|
398
|
+
if (this.running) return;
|
|
399
|
+
this.running = true;
|
|
400
|
+
this.onFrame = onFrame;
|
|
401
|
+
this.rafId = requestAnimationFrame(this.tick);
|
|
402
|
+
}
|
|
403
|
+
stop() {
|
|
404
|
+
this.running = false;
|
|
405
|
+
this.onFrame = null;
|
|
406
|
+
if (this.rafId !== null) {
|
|
407
|
+
cancelAnimationFrame(this.rafId);
|
|
408
|
+
this.rafId = null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
destroy() {
|
|
412
|
+
this.stop();
|
|
413
|
+
if (this.canvas instanceof HTMLCanvasElement && this.canvas.parentNode) {
|
|
414
|
+
this.canvas.parentNode.removeChild(this.canvas);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
markWorkerIdle() {
|
|
418
|
+
this.workerBusy = false;
|
|
419
|
+
}
|
|
420
|
+
markWorkerBusy() {
|
|
421
|
+
this.workerBusy = true;
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// src/overlay.ts
|
|
426
|
+
function getRenderedVideoRect(video) {
|
|
427
|
+
const elementWidth = video.clientWidth;
|
|
428
|
+
const elementHeight = video.clientHeight;
|
|
429
|
+
const videoWidth = video.videoWidth || 1;
|
|
430
|
+
const videoHeight = video.videoHeight || 1;
|
|
431
|
+
const objectFit = getComputedStyle(video).objectFit;
|
|
432
|
+
if (objectFit === "cover") {
|
|
433
|
+
const scale = Math.max(
|
|
434
|
+
elementWidth / videoWidth,
|
|
435
|
+
elementHeight / videoHeight
|
|
436
|
+
);
|
|
437
|
+
const renderedWidth = videoWidth * scale;
|
|
438
|
+
const renderedHeight = videoHeight * scale;
|
|
439
|
+
return {
|
|
440
|
+
offsetX: (elementWidth - renderedWidth) / 2,
|
|
441
|
+
offsetY: (elementHeight - renderedHeight) / 2,
|
|
442
|
+
width: renderedWidth,
|
|
443
|
+
height: renderedHeight
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
if (objectFit === "contain") {
|
|
447
|
+
const scale = Math.min(
|
|
448
|
+
elementWidth / videoWidth,
|
|
449
|
+
elementHeight / videoHeight
|
|
450
|
+
);
|
|
451
|
+
const renderedWidth = videoWidth * scale;
|
|
452
|
+
const renderedHeight = videoHeight * scale;
|
|
453
|
+
return {
|
|
454
|
+
offsetX: (elementWidth - renderedWidth) / 2,
|
|
455
|
+
offsetY: (elementHeight - renderedHeight) / 2,
|
|
456
|
+
width: renderedWidth,
|
|
457
|
+
height: renderedHeight
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
return { offsetX: 0, offsetY: 0, width: elementWidth, height: elementHeight };
|
|
461
|
+
}
|
|
462
|
+
var ScanOverlay = class {
|
|
463
|
+
constructor(video, config) {
|
|
464
|
+
this.overlayEl = null;
|
|
465
|
+
this.codeOutlineEl = null;
|
|
466
|
+
this.video = video;
|
|
467
|
+
this.config = config;
|
|
468
|
+
const parent = video.parentElement;
|
|
469
|
+
if (!parent) {
|
|
470
|
+
throw new Error(
|
|
471
|
+
"QrScanner: video element must have a parent element. The parent should have position: relative."
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
this.container = parent;
|
|
475
|
+
}
|
|
476
|
+
setup() {
|
|
477
|
+
if (this.config.customOverlay) {
|
|
478
|
+
this.overlayEl = this.config.customOverlay;
|
|
479
|
+
this.positionOverlay();
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (this.config.highlightScanRegion) {
|
|
483
|
+
this.createScanRegionOverlay();
|
|
484
|
+
}
|
|
485
|
+
if (this.config.highlightCodeOutline) {
|
|
486
|
+
this.createCodeOutline();
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
updateScanRegion(region) {
|
|
490
|
+
if (!this.overlayEl || this.config.customOverlay) return;
|
|
491
|
+
this.positionOverlayToRegion(region);
|
|
492
|
+
}
|
|
493
|
+
updateCodeOutline(cornerPoints, scanRegion) {
|
|
494
|
+
if (!this.codeOutlineEl) return;
|
|
495
|
+
if (!cornerPoints || cornerPoints.length < 4) {
|
|
496
|
+
this.codeOutlineEl.style.display = "none";
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
this.codeOutlineEl.style.display = "block";
|
|
500
|
+
const polygon = this.codeOutlineEl.querySelector("polygon");
|
|
501
|
+
if (!polygon) return;
|
|
502
|
+
const regionX = scanRegion?.x ?? 0;
|
|
503
|
+
const regionY = scanRegion?.y ?? 0;
|
|
504
|
+
const rendered = getRenderedVideoRect(this.video);
|
|
505
|
+
const scaleX = rendered.width / this.video.videoWidth;
|
|
506
|
+
const scaleY = rendered.height / this.video.videoHeight;
|
|
507
|
+
const points = cornerPoints.map(
|
|
508
|
+
(p) => `${(p.x + regionX) * scaleX + rendered.offsetX},${(p.y + regionY) * scaleY + rendered.offsetY}`
|
|
509
|
+
).join(" ");
|
|
510
|
+
polygon.setAttribute("points", points);
|
|
511
|
+
}
|
|
512
|
+
destroy() {
|
|
513
|
+
if (this.overlayEl && !this.config.customOverlay) {
|
|
514
|
+
this.overlayEl.remove();
|
|
515
|
+
}
|
|
516
|
+
if (this.codeOutlineEl) {
|
|
517
|
+
this.codeOutlineEl.remove();
|
|
518
|
+
}
|
|
519
|
+
this.overlayEl = null;
|
|
520
|
+
this.codeOutlineEl = null;
|
|
521
|
+
}
|
|
522
|
+
createScanRegionOverlay() {
|
|
523
|
+
this.overlayEl = document.createElement("div");
|
|
524
|
+
this.overlayEl.className = "qr-scanner-region";
|
|
525
|
+
const cw = this.container.clientWidth;
|
|
526
|
+
const ch = this.container.clientHeight;
|
|
527
|
+
const size = Math.round(Math.min(cw, ch) * 3 / 4);
|
|
528
|
+
Object.assign(this.overlayEl.style, {
|
|
529
|
+
position: "absolute",
|
|
530
|
+
top: "50%",
|
|
531
|
+
left: "50%",
|
|
532
|
+
transform: "translate(-50%, -50%)",
|
|
533
|
+
width: `${size}px`,
|
|
534
|
+
height: `${size}px`,
|
|
535
|
+
border: "2px solid rgba(255, 255, 255, 0.5)",
|
|
536
|
+
borderRadius: "8px",
|
|
537
|
+
boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.5)",
|
|
538
|
+
pointerEvents: "none",
|
|
539
|
+
zIndex: "10"
|
|
540
|
+
});
|
|
541
|
+
const corners = ["top-left", "top-right", "bottom-left", "bottom-right"];
|
|
542
|
+
for (const corner of corners) {
|
|
543
|
+
const marker = document.createElement("div");
|
|
544
|
+
marker.className = `qr-scanner-corner qr-scanner-corner-${corner}`;
|
|
545
|
+
const [vertical, horizontal] = corner.split("-");
|
|
546
|
+
Object.assign(marker.style, {
|
|
547
|
+
position: "absolute",
|
|
548
|
+
width: "24px",
|
|
549
|
+
height: "24px",
|
|
550
|
+
[vertical]: "-2px",
|
|
551
|
+
[horizontal]: "-2px",
|
|
552
|
+
[`border-${vertical}`]: "3px solid white",
|
|
553
|
+
[`border-${horizontal}`]: "3px solid white",
|
|
554
|
+
[`border-${vertical}-${horizontal}-radius`]: "8px"
|
|
555
|
+
});
|
|
556
|
+
this.overlayEl.appendChild(marker);
|
|
557
|
+
}
|
|
558
|
+
this.container.appendChild(this.overlayEl);
|
|
559
|
+
}
|
|
560
|
+
createCodeOutline() {
|
|
561
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
562
|
+
svg.setAttribute("class", "qr-scanner-code-outline");
|
|
563
|
+
Object.assign(svg.style, {
|
|
564
|
+
position: "absolute",
|
|
565
|
+
top: "0",
|
|
566
|
+
left: "0",
|
|
567
|
+
width: "100%",
|
|
568
|
+
height: "100%",
|
|
569
|
+
pointerEvents: "none",
|
|
570
|
+
zIndex: "11",
|
|
571
|
+
display: "none"
|
|
572
|
+
});
|
|
573
|
+
const polygon = document.createElementNS(
|
|
574
|
+
"http://www.w3.org/2000/svg",
|
|
575
|
+
"polygon"
|
|
576
|
+
);
|
|
577
|
+
polygon.setAttribute("fill", "none");
|
|
578
|
+
polygon.setAttribute("stroke", "#00ff00");
|
|
579
|
+
polygon.setAttribute("stroke-width", "3");
|
|
580
|
+
polygon.setAttribute("stroke-linejoin", "round");
|
|
581
|
+
const animate = document.createElementNS(
|
|
582
|
+
"http://www.w3.org/2000/svg",
|
|
583
|
+
"animate"
|
|
584
|
+
);
|
|
585
|
+
animate.setAttribute("attributeName", "stroke-opacity");
|
|
586
|
+
animate.setAttribute("values", "1;0.5;1");
|
|
587
|
+
animate.setAttribute("dur", "1.5s");
|
|
588
|
+
animate.setAttribute("repeatCount", "indefinite");
|
|
589
|
+
polygon.appendChild(animate);
|
|
590
|
+
svg.appendChild(polygon);
|
|
591
|
+
this.container.appendChild(svg);
|
|
592
|
+
this.codeOutlineEl = svg;
|
|
593
|
+
}
|
|
594
|
+
positionOverlay() {
|
|
595
|
+
if (!this.overlayEl) return;
|
|
596
|
+
Object.assign(this.overlayEl.style, {
|
|
597
|
+
position: "absolute",
|
|
598
|
+
top: "0",
|
|
599
|
+
left: "0",
|
|
600
|
+
width: "100%",
|
|
601
|
+
height: "100%",
|
|
602
|
+
pointerEvents: "none",
|
|
603
|
+
zIndex: "10"
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
positionOverlayToRegion(_region) {
|
|
607
|
+
if (!this.overlayEl) return;
|
|
608
|
+
const cw = this.container.clientWidth;
|
|
609
|
+
const ch = this.container.clientHeight;
|
|
610
|
+
const size = Math.round(Math.min(cw, ch) * 3 / 4);
|
|
611
|
+
Object.assign(this.overlayEl.style, {
|
|
612
|
+
width: `${size}px`,
|
|
613
|
+
height: `${size}px`
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
// src/scan-region.ts
|
|
619
|
+
function calculateDefaultScanRegion(video) {
|
|
620
|
+
const videoWidth = video.videoWidth || video.width;
|
|
621
|
+
const videoHeight = video.videoHeight || video.height;
|
|
622
|
+
const smallerDimension = Math.min(videoWidth, videoHeight);
|
|
623
|
+
const size = Math.round(smallerDimension * 2 / 3);
|
|
624
|
+
return {
|
|
625
|
+
x: Math.round((videoWidth - size) / 2),
|
|
626
|
+
y: Math.round((videoHeight - size) / 2),
|
|
627
|
+
width: size,
|
|
628
|
+
height: size
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// src/scanner.ts
|
|
633
|
+
var import_meta = {};
|
|
634
|
+
var customWorkerUrl = null;
|
|
635
|
+
function setWorkerUrl(url) {
|
|
636
|
+
customWorkerUrl = url;
|
|
637
|
+
}
|
|
638
|
+
function resolveWorkerUrl() {
|
|
639
|
+
if (customWorkerUrl) {
|
|
640
|
+
return customWorkerUrl;
|
|
641
|
+
}
|
|
642
|
+
try {
|
|
643
|
+
return new URL("./worker.js", import_meta.url);
|
|
644
|
+
} catch {
|
|
645
|
+
throw new Error(
|
|
646
|
+
'@agicash/qr-scanner: Could not resolve worker URL. Call QrScanner.setWorkerUrl() with the path to the worker script before creating a scanner. Example: QrScanner.setWorkerUrl("/path/to/@agicash/qr-scanner/dist/worker.js")'
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
var Scanner = class {
|
|
651
|
+
constructor(video, onDecode, options = {}) {
|
|
652
|
+
this.frameExtractor = null;
|
|
653
|
+
this.worker = null;
|
|
654
|
+
this.overlay = null;
|
|
655
|
+
this.active = false;
|
|
656
|
+
this.paused = false;
|
|
657
|
+
this.destroyed = false;
|
|
658
|
+
this.video = video;
|
|
659
|
+
this.onDecode = onDecode;
|
|
660
|
+
this.options = options;
|
|
661
|
+
this.camera = new CameraManager({
|
|
662
|
+
preferredCamera: options.preferredCamera,
|
|
663
|
+
cameraResolution: options.cameraResolution
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
async start() {
|
|
667
|
+
if (this.destroyed) {
|
|
668
|
+
throw new Error("Scanner has been destroyed");
|
|
669
|
+
}
|
|
670
|
+
if (this.active && !this.paused) {
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
const t0 = performance.now();
|
|
674
|
+
if (!this.overlay && (this.options.highlightScanRegion || this.options.highlightCodeOutline || this.options.overlay)) {
|
|
675
|
+
try {
|
|
676
|
+
this.overlay = new ScanOverlay(this.video, {
|
|
677
|
+
highlightScanRegion: this.options.highlightScanRegion ?? false,
|
|
678
|
+
highlightCodeOutline: this.options.highlightCodeOutline ?? false,
|
|
679
|
+
customOverlay: this.options.overlay
|
|
680
|
+
});
|
|
681
|
+
this.overlay.setup();
|
|
682
|
+
} catch {
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
await this.camera.start(this.video);
|
|
686
|
+
console.debug(
|
|
687
|
+
`[QrScanner] start: camera ready ${(performance.now() - t0).toFixed(0)}ms`
|
|
688
|
+
);
|
|
689
|
+
if (this.overlay) {
|
|
690
|
+
this.overlay.updateScanRegion(this.getCurrentScanRegion());
|
|
691
|
+
}
|
|
692
|
+
if (!this.worker) {
|
|
693
|
+
const tw = performance.now();
|
|
694
|
+
this.worker = this.createWorker();
|
|
695
|
+
console.debug(
|
|
696
|
+
`[QrScanner] start: worker created ${(performance.now() - tw).toFixed(0)}ms`
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
if (!this.frameExtractor) {
|
|
700
|
+
this.frameExtractor = new FrameExtractor(this.video, {
|
|
701
|
+
maxScansPerSecond: this.options.maxScansPerSecond ?? 15,
|
|
702
|
+
getScanRegion: () => this.getCurrentScanRegion()
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
this.frameExtractor.start((imageData) => {
|
|
706
|
+
this.sendToWorker(imageData);
|
|
707
|
+
});
|
|
708
|
+
this.active = true;
|
|
709
|
+
this.paused = false;
|
|
710
|
+
console.debug(
|
|
711
|
+
`[QrScanner] start: total ${(performance.now() - t0).toFixed(0)}ms`
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
stop() {
|
|
715
|
+
this.frameExtractor?.stop();
|
|
716
|
+
this.camera.stop();
|
|
717
|
+
this.video.srcObject = null;
|
|
718
|
+
this.active = false;
|
|
719
|
+
this.paused = false;
|
|
720
|
+
}
|
|
721
|
+
destroy() {
|
|
722
|
+
if (this.destroyed) return;
|
|
723
|
+
this.stop();
|
|
724
|
+
this.frameExtractor?.destroy();
|
|
725
|
+
this.frameExtractor = null;
|
|
726
|
+
this.overlay?.destroy();
|
|
727
|
+
this.overlay = null;
|
|
728
|
+
this.worker?.terminate();
|
|
729
|
+
this.worker = null;
|
|
730
|
+
this.destroyed = true;
|
|
731
|
+
}
|
|
732
|
+
async pause(stopStreamImmediately = true) {
|
|
733
|
+
if (!this.active) return false;
|
|
734
|
+
this.frameExtractor?.stop();
|
|
735
|
+
this.paused = true;
|
|
736
|
+
if (stopStreamImmediately) {
|
|
737
|
+
this.camera.stop();
|
|
738
|
+
this.video.srcObject = null;
|
|
739
|
+
}
|
|
740
|
+
return true;
|
|
741
|
+
}
|
|
742
|
+
async setCamera(facingModeOrDeviceId) {
|
|
743
|
+
const wasActive = this.active && !this.paused;
|
|
744
|
+
if (wasActive) {
|
|
745
|
+
this.frameExtractor?.stop();
|
|
746
|
+
}
|
|
747
|
+
await this.camera.setCamera(facingModeOrDeviceId, this.video);
|
|
748
|
+
if (wasActive) {
|
|
749
|
+
this.frameExtractor?.start((imageData) => {
|
|
750
|
+
this.sendToWorker(imageData);
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
async hasFlash() {
|
|
755
|
+
return this.camera.hasFlash();
|
|
756
|
+
}
|
|
757
|
+
isFlashOn() {
|
|
758
|
+
return this.camera.isFlashOn();
|
|
759
|
+
}
|
|
760
|
+
async toggleFlash() {
|
|
761
|
+
return this.camera.toggleFlash();
|
|
762
|
+
}
|
|
763
|
+
async turnFlashOn() {
|
|
764
|
+
return this.camera.turnFlashOn();
|
|
765
|
+
}
|
|
766
|
+
async turnFlashOff() {
|
|
767
|
+
return this.camera.turnFlashOff();
|
|
768
|
+
}
|
|
769
|
+
setInversionMode(mode) {
|
|
770
|
+
if (!this.worker) return;
|
|
771
|
+
const options = {};
|
|
772
|
+
switch (mode) {
|
|
773
|
+
case "original":
|
|
774
|
+
options.tryInvert = false;
|
|
775
|
+
break;
|
|
776
|
+
case "invert":
|
|
777
|
+
options.tryInvert = true;
|
|
778
|
+
break;
|
|
779
|
+
case "both":
|
|
780
|
+
options.tryInvert = true;
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
const msg = { type: "configure", options };
|
|
784
|
+
this.worker.postMessage(msg);
|
|
785
|
+
}
|
|
786
|
+
isActive() {
|
|
787
|
+
return this.active;
|
|
788
|
+
}
|
|
789
|
+
isPaused() {
|
|
790
|
+
return this.paused;
|
|
791
|
+
}
|
|
792
|
+
isDestroyed() {
|
|
793
|
+
return this.destroyed;
|
|
794
|
+
}
|
|
795
|
+
getCurrentScanRegion() {
|
|
796
|
+
if (this.options.calculateScanRegion) {
|
|
797
|
+
return this.options.calculateScanRegion(this.video);
|
|
798
|
+
}
|
|
799
|
+
return calculateDefaultScanRegion(this.video);
|
|
800
|
+
}
|
|
801
|
+
createWorker() {
|
|
802
|
+
const workerUrl = resolveWorkerUrl();
|
|
803
|
+
const worker = new Worker(workerUrl, { type: "module" });
|
|
804
|
+
if (this.options.decoderOptions) {
|
|
805
|
+
const msg = {
|
|
806
|
+
type: "configure",
|
|
807
|
+
options: this.options.decoderOptions
|
|
808
|
+
};
|
|
809
|
+
worker.postMessage(msg);
|
|
810
|
+
}
|
|
811
|
+
worker.onmessage = (e) => {
|
|
812
|
+
this.handleWorkerMessage(e.data);
|
|
813
|
+
};
|
|
814
|
+
worker.onerror = (err) => {
|
|
815
|
+
console.error("QR Scanner worker error:", err);
|
|
816
|
+
this.frameExtractor?.markWorkerIdle();
|
|
817
|
+
};
|
|
818
|
+
return worker;
|
|
819
|
+
}
|
|
820
|
+
handleWorkerMessage(response) {
|
|
821
|
+
this.frameExtractor?.markWorkerIdle();
|
|
822
|
+
if (response.type === "ready") {
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (response.type === "error") {
|
|
826
|
+
this.options.onDecodeError?.(response.message);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (response.type === "result") {
|
|
830
|
+
if (response.results.length > 0) {
|
|
831
|
+
const result = response.results[0];
|
|
832
|
+
this.onDecode(result);
|
|
833
|
+
if (this.overlay) {
|
|
834
|
+
const region = this.getCurrentScanRegion();
|
|
835
|
+
this.overlay.updateScanRegion(region);
|
|
836
|
+
this.overlay.updateCodeOutline(result.cornerPoints, region);
|
|
837
|
+
}
|
|
838
|
+
} else {
|
|
839
|
+
this.options.onDecodeError?.("No QR code found");
|
|
840
|
+
if (this.overlay) {
|
|
841
|
+
this.overlay.updateCodeOutline(null);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
sendToWorker(imageData) {
|
|
847
|
+
if (!this.worker) return;
|
|
848
|
+
const msg = { type: "decode", imageData };
|
|
849
|
+
this.worker.postMessage(msg, [imageData.data.buffer]);
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
// src/scan-image.ts
|
|
854
|
+
var import_reader = require("zxing-wasm/reader");
|
|
855
|
+
|
|
856
|
+
// src/utils.ts
|
|
857
|
+
async function loadImageData(source, scanRegion, canvas) {
|
|
858
|
+
const img = await resolveImageSource(source);
|
|
859
|
+
const sx = scanRegion?.x ?? 0;
|
|
860
|
+
const sy = scanRegion?.y ?? 0;
|
|
861
|
+
const sw = scanRegion?.width ?? img.width - sx;
|
|
862
|
+
const sh = scanRegion?.height ?? img.height - sy;
|
|
863
|
+
let drawCanvas;
|
|
864
|
+
let ctx;
|
|
865
|
+
if (canvas) {
|
|
866
|
+
drawCanvas = canvas;
|
|
867
|
+
canvas.width = sw;
|
|
868
|
+
canvas.height = sh;
|
|
869
|
+
ctx = canvas.getContext("2d");
|
|
870
|
+
} else if (typeof OffscreenCanvas !== "undefined") {
|
|
871
|
+
drawCanvas = new OffscreenCanvas(sw, sh);
|
|
872
|
+
ctx = drawCanvas.getContext("2d");
|
|
873
|
+
} else {
|
|
874
|
+
drawCanvas = document.createElement("canvas");
|
|
875
|
+
drawCanvas.width = sw;
|
|
876
|
+
drawCanvas.height = sh;
|
|
877
|
+
ctx = drawCanvas.getContext("2d");
|
|
878
|
+
}
|
|
879
|
+
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
|
|
880
|
+
return ctx.getImageData(0, 0, sw, sh);
|
|
881
|
+
}
|
|
882
|
+
async function resolveImageSource(source) {
|
|
883
|
+
if (source instanceof HTMLImageElement || source instanceof HTMLCanvasElement || source instanceof ImageBitmap) {
|
|
884
|
+
return source;
|
|
885
|
+
}
|
|
886
|
+
if (typeof OffscreenCanvas !== "undefined" && source instanceof OffscreenCanvas) {
|
|
887
|
+
return source;
|
|
888
|
+
}
|
|
889
|
+
if (source instanceof File || source instanceof Blob) {
|
|
890
|
+
return createImageBitmapFromBlob(source);
|
|
891
|
+
}
|
|
892
|
+
const url = source instanceof URL ? source.href : source;
|
|
893
|
+
const response = await fetch(url);
|
|
894
|
+
const blob = await response.blob();
|
|
895
|
+
return createImageBitmapFromBlob(blob);
|
|
896
|
+
}
|
|
897
|
+
async function createImageBitmapFromBlob(blob) {
|
|
898
|
+
if (typeof createImageBitmap !== "undefined") {
|
|
899
|
+
return createImageBitmap(blob);
|
|
900
|
+
}
|
|
901
|
+
const url = URL.createObjectURL(blob);
|
|
902
|
+
try {
|
|
903
|
+
const img = new Image();
|
|
904
|
+
img.src = url;
|
|
905
|
+
await new Promise((resolve, reject) => {
|
|
906
|
+
img.onload = () => resolve();
|
|
907
|
+
img.onerror = () => reject(new Error("Failed to load image"));
|
|
908
|
+
});
|
|
909
|
+
return img;
|
|
910
|
+
} finally {
|
|
911
|
+
URL.revokeObjectURL(url);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// src/scan-image.ts
|
|
916
|
+
var defaultReaderOptions = {
|
|
917
|
+
formats: ["QRCode"],
|
|
918
|
+
tryHarder: true,
|
|
919
|
+
tryInvert: true,
|
|
920
|
+
tryRotate: true,
|
|
921
|
+
tryDenoise: false,
|
|
922
|
+
tryDownscale: true,
|
|
923
|
+
maxNumberOfSymbols: 1
|
|
924
|
+
};
|
|
925
|
+
function mapPosition(position) {
|
|
926
|
+
return [
|
|
927
|
+
position.topLeft,
|
|
928
|
+
position.topRight,
|
|
929
|
+
position.bottomRight,
|
|
930
|
+
position.bottomLeft
|
|
931
|
+
];
|
|
932
|
+
}
|
|
933
|
+
function isDirectInput(source) {
|
|
934
|
+
return source instanceof Blob || source instanceof ArrayBuffer || source instanceof Uint8Array || typeof ImageData !== "undefined" && source instanceof ImageData;
|
|
935
|
+
}
|
|
936
|
+
async function scanImage(source, options) {
|
|
937
|
+
const readerOptions = {
|
|
938
|
+
...defaultReaderOptions,
|
|
939
|
+
...options?.decoderOptions,
|
|
940
|
+
formats: ["QRCode"]
|
|
941
|
+
};
|
|
942
|
+
let input;
|
|
943
|
+
if (isDirectInput(source)) {
|
|
944
|
+
input = source;
|
|
945
|
+
} else if (typeof source === "string" || source instanceof URL) {
|
|
946
|
+
const url = source instanceof URL ? source.href : source;
|
|
947
|
+
const response = await fetch(url);
|
|
948
|
+
input = await response.arrayBuffer();
|
|
949
|
+
} else {
|
|
950
|
+
input = await loadImageData(source, options?.scanRegion, options?.canvas);
|
|
951
|
+
}
|
|
952
|
+
const results = await (0, import_reader.readBarcodes)(input, readerOptions);
|
|
953
|
+
const valid = results.filter((r) => r.isValid);
|
|
954
|
+
if (valid.length === 0) {
|
|
955
|
+
throw new Error("No QR code found in the image");
|
|
956
|
+
}
|
|
957
|
+
const first = valid[0];
|
|
958
|
+
return {
|
|
959
|
+
data: first.text,
|
|
960
|
+
cornerPoints: mapPosition(first.position)
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// src/index.ts
|
|
965
|
+
var import_reader2 = require("zxing-wasm/reader");
|
|
966
|
+
var QrScanner = class {
|
|
967
|
+
constructor(videoElement, onDecode, options = {}) {
|
|
968
|
+
this.scanner = new Scanner(videoElement, onDecode, options);
|
|
969
|
+
}
|
|
970
|
+
/** Start camera and begin scanning. Resolves when camera is ready. */
|
|
971
|
+
async start() {
|
|
972
|
+
return this.scanner.start();
|
|
973
|
+
}
|
|
974
|
+
/** Stop scanning and release the camera stream. */
|
|
975
|
+
stop() {
|
|
976
|
+
this.scanner.stop();
|
|
977
|
+
}
|
|
978
|
+
/** Stop scanning, release camera, terminate worker, clean up DOM. */
|
|
979
|
+
destroy() {
|
|
980
|
+
this.scanner.destroy();
|
|
981
|
+
}
|
|
982
|
+
/** Pause scanning. If stopStreamImmediately is false, camera stays on. */
|
|
983
|
+
async pause(stopStreamImmediately) {
|
|
984
|
+
return this.scanner.pause(stopStreamImmediately);
|
|
985
|
+
}
|
|
986
|
+
/** Switch to a different camera by facing mode or device ID. */
|
|
987
|
+
async setCamera(facingModeOrDeviceId) {
|
|
988
|
+
return this.scanner.setCamera(facingModeOrDeviceId);
|
|
989
|
+
}
|
|
990
|
+
/** Check if the current camera supports flash/torch. */
|
|
991
|
+
async hasFlash() {
|
|
992
|
+
return this.scanner.hasFlash();
|
|
993
|
+
}
|
|
994
|
+
/** Whether flash is currently on. */
|
|
995
|
+
isFlashOn() {
|
|
996
|
+
return this.scanner.isFlashOn();
|
|
997
|
+
}
|
|
998
|
+
/** Toggle flash on/off. */
|
|
999
|
+
async toggleFlash() {
|
|
1000
|
+
return this.scanner.toggleFlash();
|
|
1001
|
+
}
|
|
1002
|
+
/** Turn flash on. */
|
|
1003
|
+
async turnFlashOn() {
|
|
1004
|
+
return this.scanner.turnFlashOn();
|
|
1005
|
+
}
|
|
1006
|
+
/** Turn flash off. */
|
|
1007
|
+
async turnFlashOff() {
|
|
1008
|
+
return this.scanner.turnFlashOff();
|
|
1009
|
+
}
|
|
1010
|
+
/** Set the inversion mode for detecting inverted QR codes. */
|
|
1011
|
+
setInversionMode(mode) {
|
|
1012
|
+
this.scanner.setInversionMode(mode);
|
|
1013
|
+
}
|
|
1014
|
+
// --- Static methods ---
|
|
1015
|
+
/** Check if the device has at least one camera. */
|
|
1016
|
+
static hasCamera() {
|
|
1017
|
+
return CameraManager.hasCamera();
|
|
1018
|
+
}
|
|
1019
|
+
/** List available cameras. Pass true to request labels (triggers permission prompt). */
|
|
1020
|
+
static listCameras(requestLabels) {
|
|
1021
|
+
return CameraManager.listCameras(requestLabels);
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Pre-load the WASM binary so it's ready when the scanner starts.
|
|
1025
|
+
* Call this early (e.g., on app init) to avoid delay on first scan.
|
|
1026
|
+
*/
|
|
1027
|
+
static async preload() {
|
|
1028
|
+
const pixel = new Uint8ClampedArray([255, 255, 255, 255]);
|
|
1029
|
+
const img = new ImageData(pixel, 1, 1);
|
|
1030
|
+
try {
|
|
1031
|
+
await scanImage(img);
|
|
1032
|
+
} catch {
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Configure WASM loading. Call before creating any scanner instance.
|
|
1037
|
+
* @example
|
|
1038
|
+
* QrScanner.configureWasm({ locateFile: (filename) => `/wasm/${filename}` });
|
|
1039
|
+
*/
|
|
1040
|
+
static configureWasm(overrides) {
|
|
1041
|
+
(0, import_reader2.setZXingModuleOverrides)(overrides);
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Set a custom URL for the worker script. Call before creating any scanner.
|
|
1045
|
+
* Needed for CJS consumers or non-standard bundler setups.
|
|
1046
|
+
* By default, the worker URL is resolved via `new URL('./worker.js', import.meta.url)`,
|
|
1047
|
+
* which works with Vite, webpack 5, Parcel, and other modern bundlers.
|
|
1048
|
+
* @example
|
|
1049
|
+
* QrScanner.setWorkerUrl('/assets/qr-scanner-worker.js');
|
|
1050
|
+
*/
|
|
1051
|
+
static setWorkerUrl(url) {
|
|
1052
|
+
setWorkerUrl(url);
|
|
1053
|
+
}
|
|
1054
|
+
/** Scan a single image (not a video stream). */
|
|
1055
|
+
static scanImage(source, options) {
|
|
1056
|
+
return scanImage(source, options);
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
var index_default = QrScanner;
|
|
1060
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1061
|
+
0 && (module.exports = {
|
|
1062
|
+
CameraNotFoundError,
|
|
1063
|
+
CameraPermissionError
|
|
1064
|
+
});
|
|
1065
|
+
//# sourceMappingURL=index.cjs.map
|