@marianmeres/scanner 1.0.1
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/AGENTS.md +144 -0
- package/API.md +696 -0
- package/CLAUDE.md +3 -0
- package/LICENSE +21 -0
- package/README.md +272 -0
- package/dist/camera-adapter.d.ts +31 -0
- package/dist/camera-adapter.js +96 -0
- package/dist/detector.d.ts +25 -0
- package/dist/detector.js +36 -0
- package/dist/mod.d.ts +5 -0
- package/dist/mod.js +5 -0
- package/dist/scan-image.d.ts +24 -0
- package/dist/scan-image.js +35 -0
- package/dist/scanner.d.ts +17 -0
- package/dist/scanner.js +594 -0
- package/dist/stage.d.ts +59 -0
- package/dist/stage.js +334 -0
- package/dist/types.d.ts +262 -0
- package/dist/types.js +28 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marian Meres
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# @marianmeres/scanner
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@marianmeres/scanner)
|
|
4
|
+
[](https://jsr.io/@marianmeres/scanner)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Low-level, framework-agnostic browser primitive for scanning QR codes (and other
|
|
8
|
+
barcodes) from a live camera stream. Headless controller with reactive state, plus an
|
|
9
|
+
optional mountable "stage" UI (viewfinder overlay, corner guides, control buttons) at
|
|
10
|
+
the `/stage` subpath.
|
|
11
|
+
|
|
12
|
+
Decoding is done by the [`barcode-detector`](https://github.com/Sec-ant/barcode-detector)
|
|
13
|
+
ponyfill (zxing-cpp compiled to wasm) — used unconditionally, the native
|
|
14
|
+
`BarcodeDetector` is deliberately ignored (platform-inconsistent, historically buggy).
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# Deno / JSR
|
|
20
|
+
deno add jsr:@marianmeres/scanner
|
|
21
|
+
|
|
22
|
+
# npm
|
|
23
|
+
npm install @marianmeres/scanner
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
### Quick start
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
import { createScanner } from "@marianmeres/scanner";
|
|
32
|
+
import { createScannerStage } from "@marianmeres/scanner/stage";
|
|
33
|
+
|
|
34
|
+
const scanner = createScanner({
|
|
35
|
+
onScan: (result) => console.log(result.value),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// optional built-in UI — mounts the camera preview + viewfinder into your element
|
|
39
|
+
const stage = createScannerStage(scanner, {
|
|
40
|
+
container: document.getElementById("scan")!,
|
|
41
|
+
controls: { cancel: true, torch: true, cameraSwitch: true },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// scanning must be started explicitly (ideally from a user gesture — never
|
|
45
|
+
// auto-started on mount; iOS Safari permission-prompt rules)
|
|
46
|
+
const result = await scanner.start(); // single-shot: resolves with the first hit
|
|
47
|
+
console.log(result?.value); // null when cancelled or failed (check state.error)
|
|
48
|
+
|
|
49
|
+
stage.destroy(); // does NOT destroy the scanner
|
|
50
|
+
scanner.destroy();
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Single vs continuous mode
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
// "single" (default): auto-stop after the first detection — onScan fires,
|
|
57
|
+
// the start() promise resolves with the result, camera is released.
|
|
58
|
+
const scanner = createScanner({ mode: "single" });
|
|
59
|
+
const result = await scanner.start();
|
|
60
|
+
|
|
61
|
+
// "continuous": keep scanning until stop()/destroy() — every detection fires
|
|
62
|
+
// onScan (identical values suppressed for dedupeMs, default 1500 ms);
|
|
63
|
+
// start() resolves null when stopped.
|
|
64
|
+
const scanner2 = createScanner({
|
|
65
|
+
mode: "continuous",
|
|
66
|
+
onScan: (r) => console.log(r.format, r.value),
|
|
67
|
+
});
|
|
68
|
+
// do NOT `await` inline here — in continuous mode the promise resolves only
|
|
69
|
+
// once the scanner is stopped
|
|
70
|
+
const done = scanner2.start();
|
|
71
|
+
// ... later, e.g. from a "done" button handler:
|
|
72
|
+
scanner2.stop();
|
|
73
|
+
await done; // resolves null
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Scan a still image (no camera)
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
import { scanImage } from "@marianmeres/scanner";
|
|
80
|
+
|
|
81
|
+
// File | Blob | ImageData | ImageBitmap | <img> | <canvas> | <video> | url
|
|
82
|
+
const results = await scanImage(file);
|
|
83
|
+
if (results.length) console.log(results[0].value);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Useful for file uploads, drag & drop, or as a fallback where `getUserMedia` is
|
|
87
|
+
unavailable. Unlike the scanner instance methods, `scanImage` THROWS on fetch/decode
|
|
88
|
+
failure; an image without any recognizable code resolves with `[]`.
|
|
89
|
+
|
|
90
|
+
### Torch (flashlight) and camera switching
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
const ok = await scanner.setTorch(true); // resolves false where unsupported
|
|
94
|
+
|
|
95
|
+
const cameras = await scanner.listCameras(); // [{ deviceId, label, facing }]
|
|
96
|
+
await scanner.setCamera(cameras[1].deviceId); // live-switch while scanning
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Reactive state
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
// Svelte $store compatible — the callback fires immediately with current state
|
|
103
|
+
const unsub = scanner.subscribe((state) => {
|
|
104
|
+
state.status; // "idle" | "initializing" | "scanning" | "stopped"
|
|
105
|
+
state.error; // { code, message } | null — methods never throw
|
|
106
|
+
state.permission; // "unknown" | "prompt" | "granted" | "denied"
|
|
107
|
+
state.torch; // { supported: boolean, on: boolean }
|
|
108
|
+
state.cameras; // CameraInfo[] (labels need granted permission)
|
|
109
|
+
state.activeCameraId; // string | null
|
|
110
|
+
state.lastResult; // ScanResult | null
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Configuration
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
const scanner = createScanner({
|
|
118
|
+
video: myVideoEl, // created lazily when omitted
|
|
119
|
+
formats: ["qr_code", "ean_13"], // default ["qr_code"]
|
|
120
|
+
mode: "single", // "single" (default) | "continuous"
|
|
121
|
+
onScan: (result) => {},
|
|
122
|
+
onError: (error) => {},
|
|
123
|
+
preferredCamera: "environment", // "environment" (default) | "user" | deviceId
|
|
124
|
+
scanIntervalMs: 100, // decode throttle, default 100 (~10 fps)
|
|
125
|
+
dedupeMs: 1500, // continuous-mode per-value cooldown
|
|
126
|
+
adapter: myCameraAdapter, // camera seam (see below)
|
|
127
|
+
detector: myDetector, // decode-engine seam (tests / engine swap)
|
|
128
|
+
wasmOverrides: { locateFile: (path, prefix) => `/assets/${path}` },
|
|
129
|
+
logger: console, // @marianmeres/clog compatible
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Semantics
|
|
134
|
+
|
|
135
|
+
- **Explicit `start()`, never auto-start.** Call it from a user gesture where possible
|
|
136
|
+
— mobile browsers (esp. iOS Safari) tie the permission prompt and playback to
|
|
137
|
+
gestures.
|
|
138
|
+
- **HTTPS required.** `getUserMedia` only exists in secure contexts — serve over HTTPS
|
|
139
|
+
(or `localhost` during development). On insecure origins browsers typically don't
|
|
140
|
+
expose `navigator.mediaDevices` at all, so the scanner reports `NOT_SUPPORTED`;
|
|
141
|
+
`INSECURE_CONTEXT` appears when `getUserMedia` itself throws `SecurityError`
|
|
142
|
+
(e.g. a blocking Permissions-Policy).
|
|
143
|
+
- **Methods never throw.** `start()` never rejects; errors land in `state.error`
|
|
144
|
+
(`{ code, message }`) and fire `onError`. `setTorch()` resolves `false` instead of
|
|
145
|
+
rejecting. The standalone `scanImage()` is the documented exception — it throws.
|
|
146
|
+
- **Permission denial is state, not UI.** A denied prompt sets
|
|
147
|
+
`state.permission = "denied"` plus a `PERMISSION_DENIED` error; the package ships no
|
|
148
|
+
messaging UI for it. For a full "re-enable camera access" walkthrough compose
|
|
149
|
+
[`@marianmeres/mediaperms/reenable-guide`](https://jsr.io/@marianmeres/mediaperms)
|
|
150
|
+
next to the scanner.
|
|
151
|
+
- **Single-shot picks the first detection** when several codes share a frame;
|
|
152
|
+
`onScan` fires, `start()` resolves, the loop and all tracks stop
|
|
153
|
+
(`status: "stopped"`).
|
|
154
|
+
- **Continuous mode dedupes** identical `format|value` pairs within `dedupeMs`
|
|
155
|
+
(default 1500 ms) and runs until `stop()`/`destroy()`.
|
|
156
|
+
- **Concurrent `start()` calls coalesce** — re-entrant calls return the same in-flight
|
|
157
|
+
promise.
|
|
158
|
+
- **The detection loop is throttled and visibility-aware.** Frames are scheduled via
|
|
159
|
+
`requestVideoFrameCallback` (rAF fallback), decoded at most every `scanIntervalMs`
|
|
160
|
+
(default 100 ms ≈ 10 fps), and detection pauses while
|
|
161
|
+
`document.visibilityState === "hidden"`.
|
|
162
|
+
- **Formats default to `["qr_code"]`.** Fewer enabled formats means faster per-frame
|
|
163
|
+
decode and fewer false positives — enable more (`"ean_13"`, `"code_128"`,
|
|
164
|
+
`"data_matrix"`, … or the `"any"` shortcut) only when needed.
|
|
165
|
+
- **Repeated detector failures stop the scan.** After 5 consecutive `detect()` throws
|
|
166
|
+
the scanner sets `DETECTOR_FAILED` and stops.
|
|
167
|
+
- **A dying camera track stops the scan.** When the OS/browser ends the video track
|
|
168
|
+
outside the scanner's control (permission revoked mid-scan, USB camera unplugged,
|
|
169
|
+
a native app preempting the camera), the scanner sets `REQUEST_FAILED` and stops
|
|
170
|
+
instead of hanging in `"scanning"`.
|
|
171
|
+
|
|
172
|
+
### Error codes
|
|
173
|
+
|
|
174
|
+
Machine-readable codes on `state.error.code` (see
|
|
175
|
+
[`ScannerErrorCode`](API.md#scannererrorcode)):
|
|
176
|
+
|
|
177
|
+
| Code | Cause |
|
|
178
|
+
| ------------------- | ------------------------------------------------------------------------------------ |
|
|
179
|
+
| `NO_DEVICE` | `getUserMedia` threw `NotFoundError` — no camera available |
|
|
180
|
+
| `INSECURE_CONTEXT` | `SecurityError` — origin not HTTPS, or Permissions-Policy blocks it |
|
|
181
|
+
| `DEVICE_BUSY` | `NotReadableError` — hardware held by another consumer |
|
|
182
|
+
| `PERMISSION_DENIED` | camera access denied by the user or platform policy |
|
|
183
|
+
| `REQUEST_FAILED` | acquisition failed with a non-classified error, or the live track ended unexpectedly |
|
|
184
|
+
| `NOT_SUPPORTED` | required platform API missing (no `mediaDevices`, no DOM, …) |
|
|
185
|
+
| `DETECTOR_FAILED` | the barcode detector threw repeatedly (or failed to construct) |
|
|
186
|
+
|
|
187
|
+
## Wasm: CDN default, self-hosting, CSP
|
|
188
|
+
|
|
189
|
+
The zxing-cpp decoder is a ~1 MB `.wasm` binary fetched **lazily** (on the first
|
|
190
|
+
decode) from the **jsDelivr CDN** by default — zero configuration needed.
|
|
191
|
+
|
|
192
|
+
If your CSP does not allow `cdn.jsdelivr.net` (or you need offline support), self-host
|
|
193
|
+
the binary and point the engine at it via `wasmOverrides.locateFile` (passed through to
|
|
194
|
+
zxing-wasm's `prepareZXingModule`):
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
const scanner = createScanner({
|
|
198
|
+
wasmOverrides: {
|
|
199
|
+
locateFile: (path: string, prefix: string) => `/assets/${path}`,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Copy the `zxing_reader.wasm` file from the `zxing-wasm` npm package (a dependency of
|
|
205
|
+
`barcode-detector`) into your static assets. Note that executing wasm also requires
|
|
206
|
+
`'wasm-unsafe-eval'` in `script-src` under a strict CSP.
|
|
207
|
+
|
|
208
|
+
## The `CameraAdapter` seam
|
|
209
|
+
|
|
210
|
+
All camera acquisition goes through an injectable `CameraAdapter` interface
|
|
211
|
+
(`getStream`, `enumerateVideoDevices`, `queryPermission`, `onPermissionChange`,
|
|
212
|
+
optional `destroy`). The default implementation wraps `navigator.mediaDevices` for
|
|
213
|
+
stream acquisition/enumeration and delegates the permission lifecycle to
|
|
214
|
+
[`@marianmeres/mediaperms`](https://jsr.io/@marianmeres/mediaperms) — which brings
|
|
215
|
+
platform detection, Android-WebView sticky-denial coercion, and bfcache/app-resume
|
|
216
|
+
permission rechecks for free.
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
const scanner = createScanner({ adapter: myAdapter }); // fully custom seam
|
|
220
|
+
|
|
221
|
+
// or share your app's existing mediaperms instance with the default adapter:
|
|
222
|
+
import { createCamPerms } from "@marianmeres/mediaperms";
|
|
223
|
+
import { createDefaultCameraAdapter, createScanner } from "@marianmeres/scanner";
|
|
224
|
+
|
|
225
|
+
const perms = createCamPerms(); // app-owned (adapter will not destroy it)
|
|
226
|
+
const scanner = createScanner({ adapter: createDefaultCameraAdapter({ perms }) });
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
The adapter seam is also how tests run without real browser APIs.
|
|
230
|
+
|
|
231
|
+
Unlike `@marianmeres/mediaperms` (which probes permission and stops tracks immediately),
|
|
232
|
+
the scanner RETAINS the acquired `MediaStream` for the live preview — all tracks are
|
|
233
|
+
stopped on `stop()`/`destroy()`.
|
|
234
|
+
|
|
235
|
+
## Stage UI: `@marianmeres/scanner/stage`
|
|
236
|
+
|
|
237
|
+
`createScannerStage(scanner, options)` mounts a self-contained, dependency-free DOM
|
|
238
|
+
stage into a container: cover-fit `<video>` preview, dimmed overlay with a rounded
|
|
239
|
+
viewfinder cutout and corner guides, an optional scan-line animation
|
|
240
|
+
(`prefers-reduced-motion` honored), a brief green flash on detection, and opt-in
|
|
241
|
+
control buttons.
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
import { createScannerStage } from "@marianmeres/scanner/stage";
|
|
245
|
+
|
|
246
|
+
const stage = createScannerStage(scanner, {
|
|
247
|
+
container: document.getElementById("scan")!,
|
|
248
|
+
controls: { cancel: true, torch: true, cameraSwitch: true }, // default { cancel: true }
|
|
249
|
+
scanLine: true, // default true
|
|
250
|
+
theme: "auto", // "auto" (default, follows prefers-color-scheme) | "light" | "dark"
|
|
251
|
+
accent: "#e11d48", // any CSS color, sets --mms-accent
|
|
252
|
+
onCancel: () => console.log("user cancelled"),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// later
|
|
256
|
+
stage.destroy(); // unmounts the stage; the scanner keeps working independently
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Buttons for unsupported features hide automatically (torch without capability, camera
|
|
260
|
+
switch with a single camera). Theming via scoped CSS custom properties (`--mms-accent`,
|
|
261
|
+
`--mms-dim`, `--mms-frame-size`, `--mms-radius`, …) — see
|
|
262
|
+
[API.md](API.md#stage-theming-css-custom-properties). The stage is just one consumer of
|
|
263
|
+
the headless controller: destroying the stage does not stop or destroy the scanner (and
|
|
264
|
+
vice versa).
|
|
265
|
+
|
|
266
|
+
## API
|
|
267
|
+
|
|
268
|
+
See [API.md](API.md) for complete API documentation.
|
|
269
|
+
|
|
270
|
+
## License
|
|
271
|
+
|
|
272
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type MediaPerms, type MediaPermsConfig } from "@marianmeres/mediaperms";
|
|
2
|
+
import type { CameraAdapter, CameraFacing } from "./types.js";
|
|
3
|
+
/** Best-effort facing detection from a device label. */
|
|
4
|
+
export declare function detectFacing(label: string): CameraFacing | null;
|
|
5
|
+
/** Options for {@linkcode createDefaultCameraAdapter}. */
|
|
6
|
+
export interface CreateDefaultCameraAdapterOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Share an existing `@marianmeres/mediaperms` instance (camera kind).
|
|
9
|
+
* When omitted, the adapter lazily creates its own via `createCamPerms()`
|
|
10
|
+
* and destroys it in {@linkcode CameraAdapter.destroy}. Pass a shared
|
|
11
|
+
* instance when the app already manages one (it will NOT be destroyed by
|
|
12
|
+
* the adapter).
|
|
13
|
+
*/
|
|
14
|
+
perms?: MediaPerms;
|
|
15
|
+
/**
|
|
16
|
+
* Config forwarded to `createCamPerms()` when the adapter creates its own
|
|
17
|
+
* instance (platform hints, webview bridges, logger...). Ignored when
|
|
18
|
+
* {@linkcode CreateDefaultCameraAdapterOptions.perms} is provided.
|
|
19
|
+
*/
|
|
20
|
+
permsConfig?: MediaPermsConfig;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Default {@linkcode CameraAdapter}.
|
|
24
|
+
*
|
|
25
|
+
* Stream acquisition and device enumeration wrap `navigator.mediaDevices`
|
|
26
|
+
* directly (the scanner needs to RETAIN the stream — deliberately out of
|
|
27
|
+
* mediaperms' scope). The permission lifecycle (query + change tracking,
|
|
28
|
+
* incl. Android-WebView sticky-denial coercion and bfcache/app-resume
|
|
29
|
+
* rechecks) is delegated to `@marianmeres/mediaperms`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createDefaultCameraAdapter(options?: CreateDefaultCameraAdapterOptions): CameraAdapter;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// deno-lint-ignore-file no-explicit-any
|
|
2
|
+
import { createCamPerms, } from "@marianmeres/mediaperms";
|
|
3
|
+
const _g = globalThis;
|
|
4
|
+
/** Best-effort facing detection from a device label. */
|
|
5
|
+
export function detectFacing(label) {
|
|
6
|
+
if (/back|rear|environment/i.test(label))
|
|
7
|
+
return "environment";
|
|
8
|
+
// NOTE: no bare "face" — it would match "facing" (e.g. "camera 2, facing external")
|
|
9
|
+
if (/front|user|facetime|selfie/i.test(label))
|
|
10
|
+
return "user";
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
function mediaDevices() {
|
|
14
|
+
return _g?.navigator?.mediaDevices ?? null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Default {@linkcode CameraAdapter}.
|
|
18
|
+
*
|
|
19
|
+
* Stream acquisition and device enumeration wrap `navigator.mediaDevices`
|
|
20
|
+
* directly (the scanner needs to RETAIN the stream — deliberately out of
|
|
21
|
+
* mediaperms' scope). The permission lifecycle (query + change tracking,
|
|
22
|
+
* incl. Android-WebView sticky-denial coercion and bfcache/app-resume
|
|
23
|
+
* rechecks) is delegated to `@marianmeres/mediaperms`.
|
|
24
|
+
*/
|
|
25
|
+
export function createDefaultCameraAdapter(options = {}) {
|
|
26
|
+
let perms = options.perms ?? null;
|
|
27
|
+
let ownsPerms = false;
|
|
28
|
+
let destroyed = false;
|
|
29
|
+
function getPerms() {
|
|
30
|
+
if (!perms) {
|
|
31
|
+
perms = createCamPerms(options.permsConfig);
|
|
32
|
+
ownsPerms = true;
|
|
33
|
+
}
|
|
34
|
+
return perms;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
getStream(constraints) {
|
|
38
|
+
const md = mediaDevices();
|
|
39
|
+
if (!md?.getUserMedia) {
|
|
40
|
+
const err = new Error("navigator.mediaDevices.getUserMedia unavailable");
|
|
41
|
+
err.name = "NotSupportedError";
|
|
42
|
+
return Promise.reject(err);
|
|
43
|
+
}
|
|
44
|
+
return md.getUserMedia(constraints);
|
|
45
|
+
},
|
|
46
|
+
async enumerateVideoDevices() {
|
|
47
|
+
const md = mediaDevices();
|
|
48
|
+
if (!md?.enumerateDevices)
|
|
49
|
+
return [];
|
|
50
|
+
const devices = await md.enumerateDevices();
|
|
51
|
+
return devices
|
|
52
|
+
.filter((d) => d.kind === "videoinput")
|
|
53
|
+
.map((d) => ({
|
|
54
|
+
deviceId: d.deviceId,
|
|
55
|
+
label: d.label ?? "",
|
|
56
|
+
facing: detectFacing(d.label ?? ""),
|
|
57
|
+
}));
|
|
58
|
+
},
|
|
59
|
+
async queryPermission() {
|
|
60
|
+
if (destroyed)
|
|
61
|
+
return null;
|
|
62
|
+
try {
|
|
63
|
+
const status = await getPerms().check();
|
|
64
|
+
// "unknown" carries no information for the scanner — treat as
|
|
65
|
+
// "could not determine" so the scanner keeps its own state
|
|
66
|
+
return status === "unknown" ? null : status;
|
|
67
|
+
}
|
|
68
|
+
catch (_e) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
onPermissionChange(cb) {
|
|
73
|
+
if (destroyed)
|
|
74
|
+
return null;
|
|
75
|
+
// mediaperms subscribe fires immediately — skip that first emission
|
|
76
|
+
// (queryPermission covers the initial value) and forward changes only
|
|
77
|
+
let last = null;
|
|
78
|
+
let first = true;
|
|
79
|
+
return getPerms().subscribe((s) => {
|
|
80
|
+
const status = s.status;
|
|
81
|
+
if (!first && status !== last)
|
|
82
|
+
cb(status);
|
|
83
|
+
first = false;
|
|
84
|
+
last = status;
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
destroy() {
|
|
88
|
+
if (destroyed)
|
|
89
|
+
return;
|
|
90
|
+
destroyed = true;
|
|
91
|
+
if (ownsPerms)
|
|
92
|
+
perms?.destroy();
|
|
93
|
+
perms = null;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { BarcodeFormat, DetectedBarcodeLike, Detector, ScanResult } from "./types.js";
|
|
2
|
+
/** Options for {@linkcode createDefaultDetector}. */
|
|
3
|
+
export interface CreateDefaultDetectorOptions {
|
|
4
|
+
/** Formats to detect. Default `["qr_code"]`. */
|
|
5
|
+
formats?: BarcodeFormat[];
|
|
6
|
+
/**
|
|
7
|
+
* Overrides passed to zxing-wasm's `prepareZXingModule` — most notably
|
|
8
|
+
* `locateFile` for self-hosting the `.wasm` binary. See
|
|
9
|
+
* {@linkcode ScannerConfig.wasmOverrides}.
|
|
10
|
+
*/
|
|
11
|
+
wasmOverrides?: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
/** Default formats — QR only (the fastest and the primary use case). */
|
|
14
|
+
export declare const DEFAULT_FORMATS: BarcodeFormat[];
|
|
15
|
+
/**
|
|
16
|
+
* Default {@linkcode Detector} wrapping the `barcode-detector` ponyfill
|
|
17
|
+
* (zxing-cpp wasm engine, all major 1D/2D formats).
|
|
18
|
+
*
|
|
19
|
+
* NOTE: the ~1 MB decoder `.wasm` is fetched lazily (on first `detect()`)
|
|
20
|
+
* from the jsDelivr CDN by default — pass `wasmOverrides.locateFile` to
|
|
21
|
+
* self-host it.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createDefaultDetector(options?: CreateDefaultDetectorOptions): Detector;
|
|
24
|
+
/** Map a raw detection to the public {@linkcode ScanResult} shape. */
|
|
25
|
+
export declare function toScanResult(d: DetectedBarcodeLike, timestamp?: number): ScanResult;
|
package/dist/detector.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// deno-lint-ignore-file no-explicit-any
|
|
2
|
+
import { BarcodeDetector, prepareZXingModule } from "barcode-detector/ponyfill";
|
|
3
|
+
/** Default formats — QR only (the fastest and the primary use case). */
|
|
4
|
+
export const DEFAULT_FORMATS = ["qr_code"];
|
|
5
|
+
/**
|
|
6
|
+
* Default {@linkcode Detector} wrapping the `barcode-detector` ponyfill
|
|
7
|
+
* (zxing-cpp wasm engine, all major 1D/2D formats).
|
|
8
|
+
*
|
|
9
|
+
* NOTE: the ~1 MB decoder `.wasm` is fetched lazily (on first `detect()`)
|
|
10
|
+
* from the jsDelivr CDN by default — pass `wasmOverrides.locateFile` to
|
|
11
|
+
* self-host it.
|
|
12
|
+
*/
|
|
13
|
+
export function createDefaultDetector(options = {}) {
|
|
14
|
+
if (options.wasmOverrides) {
|
|
15
|
+
prepareZXingModule({ overrides: options.wasmOverrides });
|
|
16
|
+
}
|
|
17
|
+
const detector = new BarcodeDetector({
|
|
18
|
+
formats: (options.formats ?? DEFAULT_FORMATS),
|
|
19
|
+
});
|
|
20
|
+
return {
|
|
21
|
+
async detect(source) {
|
|
22
|
+
const detections = await detector.detect(source);
|
|
23
|
+
return detections;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** Map a raw detection to the public {@linkcode ScanResult} shape. */
|
|
28
|
+
export function toScanResult(d, timestamp = Date.now()) {
|
|
29
|
+
return {
|
|
30
|
+
value: d.rawValue,
|
|
31
|
+
format: d.format,
|
|
32
|
+
cornerPoints: (d.cornerPoints ?? []).map((p) => ({ x: p.x, y: p.y })),
|
|
33
|
+
boundingBox: d.boundingBox,
|
|
34
|
+
timestamp,
|
|
35
|
+
};
|
|
36
|
+
}
|
package/dist/mod.d.ts
ADDED
package/dist/mod.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { BarcodeFormat, Detector, DetectorSource, ScanResult } from "./types.js";
|
|
2
|
+
/** Options for {@linkcode scanImage}. */
|
|
3
|
+
export interface ScanImageOptions {
|
|
4
|
+
/** Formats to detect. Default `["qr_code"]`. */
|
|
5
|
+
formats?: BarcodeFormat[];
|
|
6
|
+
/** Decoding engine seam. Default wraps the `barcode-detector` ponyfill. */
|
|
7
|
+
detector?: Detector;
|
|
8
|
+
/** See {@linkcode ScannerConfig.wasmOverrides}. */
|
|
9
|
+
wasmOverrides?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Decode barcodes from a still image — no camera involved. Useful for file
|
|
13
|
+
* uploads, drag & drop, or as a fallback where `getUserMedia` is unavailable.
|
|
14
|
+
*
|
|
15
|
+
* Unlike the scanner instance methods (which never throw), this standalone
|
|
16
|
+
* utility THROWS on fetch/decode failure. An image without any recognizable
|
|
17
|
+
* code resolves with `[]` (not an error).
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* const results = await scanImage(file); // File | Blob | ImageData | <img> | url
|
|
21
|
+
* if (results.length) console.log(results[0].value);
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare function scanImage(source: DetectorSource | string, options?: ScanImageOptions): Promise<ScanResult[]>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createDefaultDetector, toScanResult } from "./detector.js";
|
|
2
|
+
/**
|
|
3
|
+
* Decode barcodes from a still image — no camera involved. Useful for file
|
|
4
|
+
* uploads, drag & drop, or as a fallback where `getUserMedia` is unavailable.
|
|
5
|
+
*
|
|
6
|
+
* Unlike the scanner instance methods (which never throw), this standalone
|
|
7
|
+
* utility THROWS on fetch/decode failure. An image without any recognizable
|
|
8
|
+
* code resolves with `[]` (not an error).
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* const results = await scanImage(file); // File | Blob | ImageData | <img> | url
|
|
12
|
+
* if (results.length) console.log(results[0].value);
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export async function scanImage(source, options = {}) {
|
|
16
|
+
const detector = options.detector ??
|
|
17
|
+
createDefaultDetector({
|
|
18
|
+
formats: options.formats,
|
|
19
|
+
wasmOverrides: options.wasmOverrides,
|
|
20
|
+
});
|
|
21
|
+
let src;
|
|
22
|
+
if (typeof source === "string") {
|
|
23
|
+
const resp = await fetch(source);
|
|
24
|
+
if (!resp.ok) {
|
|
25
|
+
throw new Error(`Failed to fetch image (${resp.status}): ${source}`);
|
|
26
|
+
}
|
|
27
|
+
src = await resp.blob();
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
src = source;
|
|
31
|
+
}
|
|
32
|
+
const detections = await detector.detect(src);
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
return detections.map((d) => toScanResult(d, now));
|
|
35
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Scanner, ScannerConfig, ScannerError } from "./types.js";
|
|
2
|
+
/** Classify a getUserMedia (or adapter) rejection into a {@linkcode ScannerError}. */
|
|
3
|
+
export declare function classifyAcquireError(e: unknown): ScannerError;
|
|
4
|
+
/**
|
|
5
|
+
* Create a headless (no UI) camera barcode scanner.
|
|
6
|
+
*
|
|
7
|
+
* Reactive state is exposed via the svelte-store-compatible
|
|
8
|
+
* `subscribe`/`get`. Methods never throw — errors land in `state.error`
|
|
9
|
+
* (see {@linkcode ScannerErrorCode}).
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* const scanner = createScanner({ onScan: (r) => console.log(r.value) });
|
|
13
|
+
* const result = await scanner.start(); // single-shot: auto-stops on first hit
|
|
14
|
+
* scanner.destroy();
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export declare function createScanner(config?: ScannerConfig): Scanner;
|