@daboss2003/liveness-web 1.0.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.
@@ -0,0 +1,12 @@
1
+ import { LivenessEngine } from "./engine";
2
+ export type { StartLivenessOptions } from "./ui";
3
+ export { DEFAULT_MODEL_URL, DEFAULT_WASM_URL, LIVENESS_ERROR_CDN_NOT_AVAILABLE, LIVENESS_ERROR_OFFLINE, LIVENESS_STEP_COUNT, isCdnNotAvailableError, isOfflineError, LivenessError, } from "./engine";
4
+ export type { LivenessCallbacks, LivenessOptions, LivenessSoundOptions } from "./engine";
5
+ export type { LivenessEngine } from "./engine";
6
+ /**
7
+ * Starts liveness verification with built-in UI (oval frame, progress ring, camera).
8
+ * Runs until the user completes all steps or a hard error occurs. Callbacks only.
9
+ */
10
+ export declare function startLiveness(options: import("./ui").StartLivenessOptions): LivenessEngine;
11
+ /** Stops the current liveness session and releases the camera. */
12
+ export declare function stop(): void;
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ import { startLivenessWithUI } from "./ui";
2
+ export { DEFAULT_MODEL_URL, DEFAULT_WASM_URL, LIVENESS_ERROR_CDN_NOT_AVAILABLE, LIVENESS_ERROR_OFFLINE, LIVENESS_STEP_COUNT, isCdnNotAvailableError, isOfflineError, LivenessError, } from "./engine";
3
+ let currentEngine = null;
4
+ /**
5
+ * Starts liveness verification with built-in UI (oval frame, progress ring, camera).
6
+ * Runs until the user completes all steps or a hard error occurs. Callbacks only.
7
+ */
8
+ export function startLiveness(options) {
9
+ if (currentEngine) {
10
+ currentEngine.stop();
11
+ currentEngine = null;
12
+ }
13
+ const engine = startLivenessWithUI(options);
14
+ currentEngine = engine;
15
+ return engine;
16
+ }
17
+ /** Stops the current liveness session and releases the camera. */
18
+ export function stop() {
19
+ currentEngine?.stop();
20
+ currentEngine = null;
21
+ }
package/dist/ui.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { LivenessEngine, LivenessCallbacks, LivenessSoundOptions } from "./engine";
2
+ export type StartLivenessOptions = {
3
+ container?: HTMLElement;
4
+ modelUrl?: string;
5
+ wasmUrl?: string;
6
+ callbacks: LivenessCallbacks;
7
+ sounds?: LivenessSoundOptions;
8
+ };
9
+ export declare function startLivenessWithUI(options: StartLivenessOptions): LivenessEngine;
package/dist/ui.js ADDED
@@ -0,0 +1,423 @@
1
+ import { LivenessEngine, LIVENESS_STEP_COUNT, LivenessError } from "./engine";
2
+ import { DEFAULT_SOUND_DATA_URLS } from "./default-sounds.generated";
3
+ // ── Oval dimensions — keep in sync with engine.ts config.ovalCx/Cy/Rx/Ry ──
4
+ const OVAL_W = 270;
5
+ const OVAL_H = 360;
6
+ const OVAL_TOP_PCT = 40;
7
+ // Approximate ellipse perimeter via Ramanujan's formula so pathLength matches
8
+ const RX = OVAL_W / 2 - 2;
9
+ const RY = OVAL_H / 2 - 2;
10
+ const ELLIPSE_PERIMETER = Math.PI * (3 * (RX + RY) - Math.sqrt((3 * RX + RY) * (RX + 3 * RY)));
11
+ /** Step label (from engine) → hint icon kind. Use this so the correct icon shows when steps are randomized. */
12
+ const STEP_LABEL_TO_HINT = {
13
+ "Turn your head LEFT": "left",
14
+ "Blink": "blink",
15
+ "Turn your head RIGHT": "right",
16
+ "Nod your head": "nod",
17
+ "Open your mouth": "mouth",
18
+ };
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+ // Styles
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+ function createStyles() {
23
+ const s = document.createElement("style");
24
+ s.textContent = `
25
+ :root {
26
+ --lv-green: #12c95c;
27
+ --lv-red: #ff3b3b;
28
+ --lv-white: #ffffff;
29
+ --lv-dark: rgba(0,0,0,0.82);
30
+ }
31
+
32
+ .lv-root {
33
+ position: fixed;
34
+ inset: 0;
35
+ z-index: 999999;
36
+ background: #000;
37
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif;
38
+ color: var(--lv-white);
39
+ overflow: hidden;
40
+ display: flex;
41
+ flex-direction: column;
42
+ align-items: center;
43
+ }
44
+
45
+ /* ── Full-bleed video behind everything ─────────────────────────────── */
46
+ .lv-video-bg {
47
+ position: absolute;
48
+ inset: 0;
49
+ display: flex;
50
+ align-items: center;
51
+ justify-content: center;
52
+ overflow: hidden;
53
+ }
54
+ .lv-video {
55
+ /* Fill the container while keeping aspect ratio */
56
+ width: 100%;
57
+ height: 100%;
58
+ object-fit: cover;
59
+ /* Mirror so it feels like a selfie camera */
60
+ transform: scaleX(-1);
61
+ /* Clip the video to the oval using clip-path on the parent */
62
+ }
63
+
64
+ /* ── Dark overlay with oval cutout ──────────────────────────────────── */
65
+ .lv-overlay {
66
+ position: absolute;
67
+ inset: 0;
68
+ /* The mask punches a transparent oval at 50% x, OVAL_TOP_PCT% y */
69
+ --ow: min(72vw, ${OVAL_W}px);
70
+ --oh: min(90vw, ${OVAL_H}px);
71
+ background: var(--lv-dark);
72
+ -webkit-mask-image: radial-gradient(
73
+ ellipse var(--ow) var(--oh) at 50% ${OVAL_TOP_PCT}%,
74
+ transparent 99%, black 100%
75
+ );
76
+ mask-image: radial-gradient(
77
+ ellipse var(--ow) var(--oh) at 50% ${OVAL_TOP_PCT}%,
78
+ transparent 99%, black 100%
79
+ );
80
+ pointer-events: none;
81
+ transition: background 0.25s;
82
+ }
83
+ .lv-overlay.out-of-oval {
84
+ /* Tint red when face isn't in position */
85
+ background: rgba(180,0,0,0.55);
86
+ }
87
+
88
+ /* ── Oval SVG ring ───────────────────────────────────────────────────── */
89
+ .lv-ring-wrap {
90
+ position: absolute;
91
+ left: 50%;
92
+ top: ${OVAL_TOP_PCT}%;
93
+ transform: translate(-50%, -50%);
94
+ width: min(72vw, ${OVAL_W}px);
95
+ height: min(90vw, ${OVAL_H}px);
96
+ pointer-events: none;
97
+ }
98
+ .lv-ring-wrap svg { width: 100%; height: 100%; overflow: visible; }
99
+ .lv-ring-track {
100
+ fill: none;
101
+ stroke: rgba(255,255,255,0.15);
102
+ stroke-width: 3.5;
103
+ }
104
+ .lv-ring-progress {
105
+ fill: none;
106
+ stroke: var(--lv-green);
107
+ stroke-width: 3.5;
108
+ stroke-linecap: round;
109
+ transition: stroke-dashoffset 0.45s cubic-bezier(.4,0,.2,1), stroke 0.25s;
110
+ transform: rotate(0deg);
111
+ transform-origin: center;
112
+ }
113
+ .lv-ring-progress.out-of-oval { stroke: var(--lv-red); }
114
+
115
+ /* ── Top header bar ──────────────────────────────────────────────────── */
116
+ .lv-header {
117
+ position: relative;
118
+ z-index: 2;
119
+ width: 100%;
120
+ display: flex;
121
+ align-items: center;
122
+ justify-content: center;
123
+ padding: 18px 20px 0;
124
+ gap: 10px;
125
+ }
126
+ .lv-header-title {
127
+ font-size: 15px;
128
+ font-weight: 600;
129
+ letter-spacing: 0.02em;
130
+ opacity: 0.9;
131
+ }
132
+
133
+ /* ── Step dots ───────────────────────────────────────────────────────── */
134
+ .lv-dots {
135
+ position: absolute;
136
+ z-index: 2;
137
+ top: calc(${OVAL_TOP_PCT}% + min(45vw, ${OVAL_H / 2}px) + 20px);
138
+ left: 50%;
139
+ transform: translateX(-50%);
140
+ display: flex;
141
+ gap: 8px;
142
+ }
143
+ .lv-dot {
144
+ width: 7px; height: 7px; border-radius: 50%;
145
+ background: rgba(255,255,255,0.2);
146
+ transition: background 0.3s, transform 0.3s;
147
+ }
148
+ .lv-dot.active { background: var(--lv-green); transform: scale(1.3); }
149
+ .lv-dot.done { background: var(--lv-green); opacity: .5; transform: scale(1); }
150
+
151
+ /* ── Instruction text ────────────────────────────────────────────────── */
152
+ .lv-instruction {
153
+ position: absolute;
154
+ z-index: 2;
155
+ top: calc(${OVAL_TOP_PCT}% + min(45vw, ${OVAL_H / 2}px) + 52px);
156
+ left: 50%;
157
+ transform: translateX(-50%);
158
+ white-space: nowrap;
159
+ font-size: 17px;
160
+ font-weight: 600;
161
+ text-align: center;
162
+ letter-spacing: -0.01em;
163
+ text-shadow: 0 1px 8px rgba(0,0,0,0.6);
164
+ transition: opacity 0.2s;
165
+ }
166
+
167
+ /* ── "Move closer / centre your face" hint ───────────────────────────── */
168
+ .lv-pos-hint {
169
+ position: absolute;
170
+ z-index: 2;
171
+ top: calc(${OVAL_TOP_PCT}% + min(45vw, ${OVAL_H / 2}px) + 84px);
172
+ left: 50%;
173
+ transform: translateX(-50%);
174
+ font-size: 13px;
175
+ font-weight: 500;
176
+ color: var(--lv-red);
177
+ opacity: 0;
178
+ white-space: nowrap;
179
+ text-shadow: 0 1px 6px rgba(0,0,0,0.5);
180
+ transition: opacity 0.3s;
181
+ pointer-events: none;
182
+ }
183
+ .lv-pos-hint.visible { opacity: 1; }
184
+
185
+ /* ── Animated gesture icon ───────────────────────────────────────────── */
186
+ .lv-hint-icon {
187
+ position: absolute;
188
+ z-index: 2;
189
+ left: 50%;
190
+ top: ${OVAL_TOP_PCT}%;
191
+ transform: translate(-50%, -50%);
192
+ width: 52px;
193
+ height: 52px;
194
+ display: flex;
195
+ align-items: center;
196
+ justify-content: center;
197
+ pointer-events: none;
198
+ filter: drop-shadow(0 2px 8px rgba(0,0,0,0.5));
199
+ }
200
+ .lv-hint-icon svg { width: 40px; height: 40px; }
201
+
202
+ /* Arrow bounce animations */
203
+ @keyframes lv-left { 0%,100%{transform:translateX(0)} 50%{transform:translateX(-9px)} }
204
+ @keyframes lv-right { 0%,100%{transform:translateX(0)} 50%{transform:translateX(9px)} }
205
+ @keyframes lv-down { 0%,100%{transform:translateY(0)} 50%{transform:translateY(8px)} }
206
+ @keyframes lv-blink {
207
+ 0%,40%,60%,100% { transform: scaleY(1); opacity: 1; }
208
+ 50% { transform: scaleY(0.08); opacity: 0.4; }
209
+ }
210
+ @keyframes lv-mouth {
211
+ 0%,60%,100% { transform: scaleY(0.35); }
212
+ 30% { transform: scaleY(1); }
213
+ }
214
+
215
+ .lv-anim-left { animation: lv-left 1s ease-in-out infinite; }
216
+ .lv-anim-right { animation: lv-right 1s ease-in-out infinite; }
217
+ .lv-anim-down { animation: lv-down 1s ease-in-out infinite; }
218
+ .lv-eye { animation: lv-blink 2s ease-in-out infinite; transform-origin: center; }
219
+ .lv-jaw { animation: lv-mouth 1.5s ease-in-out infinite; transform-origin: top center; }
220
+
221
+ /* ── Capture pulse ──────────────────────────────────────────────────── */
222
+ @keyframes lv-pulse {
223
+ 0%,100% { stroke-width: 3.5; opacity: 1; }
224
+ 50% { stroke-width: 5; opacity: 0.55; }
225
+ }
226
+ .lv-ring-pulse { animation: lv-pulse 1s ease-in-out infinite; stroke: var(--lv-green) !important; }
227
+
228
+ /* ── Hidden canvas for capture ───────────────────────────────────────── */
229
+ .lv-canvas {
230
+ position: absolute;
231
+ width: 0; height: 0;
232
+ opacity: 0;
233
+ pointer-events: none;
234
+ }
235
+ `;
236
+ return s;
237
+ }
238
+ // ─────────────────────────────────────────────────────────────────────────────
239
+ // SVG hints
240
+ // ─────────────────────────────────────────────────────────────────────────────
241
+ function hintSvg(kind) {
242
+ const sk = `stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"`;
243
+ switch (kind) {
244
+ case "left": return `<svg viewBox="0 0 24 24" fill="none" ${sk} class="lv-anim-left"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>`;
245
+ case "right": return `<svg viewBox="0 0 24 24" fill="none" ${sk} class="lv-anim-right"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>`;
246
+ case "nod": return `<svg viewBox="0 0 24 24" fill="none" ${sk} class="lv-anim-down"><path d="M12 5v14"/><path d="M19 12l-7 7-7-7"/></svg>`;
247
+ case "blink": return `<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><ellipse class="lv-eye" cx="8" cy="12" rx="2" ry="2.5"/><ellipse class="lv-eye" cx="16" cy="12" rx="2" ry="2.5" style="animation-delay:.08s"/></svg>`;
248
+ case "mouth": return `<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round"><path class="lv-jaw" d="M6 10 Q12 17 18 10"/></svg>`;
249
+ default: return "";
250
+ }
251
+ }
252
+ // ─────────────────────────────────────────────────────────────────────────────
253
+ // Main factory
254
+ // ─────────────────────────────────────────────────────────────────────────────
255
+ export function startLivenessWithUI(options) {
256
+ const container = options.container ?? document.body;
257
+ // ── Root shell ─────────────────────────────────────────────────────────────
258
+ const root = document.createElement("div");
259
+ root.className = "lv-root";
260
+ root.appendChild(createStyles());
261
+ // ── Video background ───────────────────────────────────────────────────────
262
+ const videoBg = document.createElement("div");
263
+ videoBg.className = "lv-video-bg";
264
+ const video = document.createElement("video");
265
+ video.className = "lv-video";
266
+ video.setAttribute("autoplay", "");
267
+ video.setAttribute("playsinline", "");
268
+ video.setAttribute("muted", "");
269
+ videoBg.appendChild(video);
270
+ root.appendChild(videoBg);
271
+ // ── Dark overlay with oval cutout ──────────────────────────────────────────
272
+ const overlay = document.createElement("div");
273
+ overlay.className = "lv-overlay";
274
+ root.appendChild(overlay);
275
+ // ── Progress ring (ellipse pathLength = perimeter so dash offset matches) ───
276
+ const rx = OVAL_W / 2, ry = OVAL_H / 2;
277
+ const ringWrap = document.createElement("div");
278
+ ringWrap.className = "lv-ring-wrap";
279
+ ringWrap.innerHTML = `
280
+ <svg viewBox="0 0 ${OVAL_W} ${OVAL_H}">
281
+ <ellipse class="lv-ring-track"
282
+ cx="${rx}" cy="${ry}" rx="${RX}" ry="${RY}"
283
+ pathLength="${ELLIPSE_PERIMETER.toFixed(1)}"/>
284
+ <ellipse class="lv-ring-progress"
285
+ cx="${rx}" cy="${ry}" rx="${RX}" ry="${RY}"
286
+ pathLength="${ELLIPSE_PERIMETER.toFixed(1)}"
287
+ stroke-dasharray="${ELLIPSE_PERIMETER.toFixed(1)}"
288
+ stroke-dashoffset="${ELLIPSE_PERIMETER.toFixed(1)}"
289
+ transform="rotate(-90 ${rx} ${ry})"/>
290
+ </svg>`;
291
+ root.appendChild(ringWrap);
292
+ // ── Gesture hint icon (inside the oval) ────────────────────────────────────
293
+ const hintIcon = document.createElement("div");
294
+ hintIcon.className = "lv-hint-icon";
295
+ hintIcon.setAttribute("aria-hidden", "true");
296
+ root.appendChild(hintIcon);
297
+ // ── Header ─────────────────────────────────────────────────────────────────
298
+ const header = document.createElement("div");
299
+ header.className = "lv-header";
300
+ header.innerHTML = `<span class="lv-header-title">Face Verification</span>`;
301
+ root.appendChild(header);
302
+ // ── Step dots ──────────────────────────────────────────────────────────────
303
+ const dotsEl = document.createElement("div");
304
+ dotsEl.className = "lv-dots";
305
+ for (let i = 0; i < LIVENESS_STEP_COUNT; i++) {
306
+ const dot = document.createElement("div");
307
+ dot.className = "lv-dot" + (i === 0 ? " active" : "");
308
+ dotsEl.appendChild(dot);
309
+ }
310
+ root.appendChild(dotsEl);
311
+ // ── Instruction text ───────────────────────────────────────────────────────
312
+ const instruction = document.createElement("div");
313
+ instruction.className = "lv-instruction";
314
+ instruction.textContent = "Position your face in the oval";
315
+ root.appendChild(instruction);
316
+ // ── Position hint (shows when face is out of oval) ─────────────────────────
317
+ const posHint = document.createElement("div");
318
+ posHint.className = "lv-pos-hint";
319
+ root.appendChild(posHint);
320
+ // ── Hidden canvas ──────────────────────────────────────────────────────────
321
+ const canvas = document.createElement("canvas");
322
+ canvas.className = "lv-canvas";
323
+ root.appendChild(canvas);
324
+ container.appendChild(root);
325
+ const ringEl = ringWrap.querySelector(".lv-ring-progress");
326
+ const dots = Array.from(dotsEl.querySelectorAll(".lv-dot"));
327
+ const P = ELLIPSE_PERIMETER;
328
+ // ── UI helpers ──────────────────────────────────────────────────────────────
329
+ function setProgress(completedSteps) {
330
+ if (!ringEl)
331
+ return;
332
+ const filled = Math.min(completedSteps / LIVENESS_STEP_COUNT, 1);
333
+ const offset = P * (1 - filled);
334
+ ringEl.setAttribute("stroke-dashoffset", offset.toFixed(1));
335
+ }
336
+ function setCapturePulse() {
337
+ ringEl?.classList.add("lv-ring-pulse");
338
+ ringEl?.setAttribute("stroke-dashoffset", "0");
339
+ }
340
+ function setHint(stepLabel) {
341
+ if (!stepLabel) {
342
+ hintIcon.innerHTML = "";
343
+ return;
344
+ }
345
+ const kind = STEP_LABEL_TO_HINT[stepLabel];
346
+ hintIcon.innerHTML = kind ? hintSvg(kind) : "";
347
+ }
348
+ function setDots(activeIndex) {
349
+ dots.forEach((d, i) => {
350
+ d.classList.toggle("done", i < activeIndex);
351
+ d.classList.toggle("active", i === activeIndex);
352
+ });
353
+ }
354
+ function setFaceInOval(inside, reason) {
355
+ overlay.classList.toggle("out-of-oval", !inside);
356
+ ringEl?.classList.toggle("out-of-oval", !inside);
357
+ posHint.classList.toggle("visible", !inside);
358
+ posHint.textContent = inside ? "" : (reason ?? "Move your face into the oval");
359
+ }
360
+ function cleanup() {
361
+ engine.stop();
362
+ root.remove();
363
+ }
364
+ // ── Engine ─────────────────────────────────────────────────────────────────
365
+ // Use options.sounds if provided; otherwise use embedded default sounds (works in any host)
366
+ const sounds = options.sounds ?? {
367
+ ...(Object.keys(DEFAULT_SOUND_DATA_URLS).length > 0 ? DEFAULT_SOUND_DATA_URLS : { baseUrl: "audios/" }),
368
+ };
369
+ const engine = new LivenessEngine({
370
+ videoElement: video,
371
+ canvasElement: canvas,
372
+ modelUrl: options.modelUrl,
373
+ wasmUrl: options.wasmUrl,
374
+ sounds,
375
+ callbacks: {
376
+ onChallengeChanged: (stepIndex, stepLabel) => {
377
+ if (stepIndex === -1) {
378
+ setProgress(LIVENESS_STEP_COUNT);
379
+ setCapturePulse();
380
+ setHint(null);
381
+ setDots(LIVENESS_STEP_COUNT);
382
+ instruction.textContent = stepLabel;
383
+ return;
384
+ }
385
+ setProgress(stepIndex);
386
+ setDots(stepIndex);
387
+ setHint(stepLabel);
388
+ instruction.textContent = stepLabel;
389
+ ringEl?.classList.remove("lv-ring-pulse");
390
+ options.callbacks.onChallengeChanged?.(stepIndex, stepLabel);
391
+ },
392
+ onFaceInOval: (inside, reason) => {
393
+ setFaceInOval(inside, reason);
394
+ options.callbacks.onFaceInOval?.(inside, reason);
395
+ },
396
+ onFailure: (reason) => {
397
+ cleanup();
398
+ options.callbacks.onFailure?.(reason);
399
+ },
400
+ onSuccess: (imageBase64) => {
401
+ setProgress(LIVENESS_STEP_COUNT);
402
+ cleanup();
403
+ options.callbacks.onSuccess?.(imageBase64);
404
+ },
405
+ onDebugFrame: (info) => {
406
+ options.callbacks.onDebugFrame?.(info);
407
+ },
408
+ },
409
+ });
410
+ setProgress(0);
411
+ setDots(0);
412
+ setHint(null);
413
+ engine.start().then(() => { }, (err) => {
414
+ cleanup();
415
+ const reason = err instanceof LivenessError
416
+ ? err.code
417
+ : err instanceof Error
418
+ ? err.message
419
+ : String(err);
420
+ options.callbacks.onFailure?.(reason);
421
+ });
422
+ return engine;
423
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@daboss2003/liveness-web",
3
+ "version": "1.0.0",
4
+ "description": "Web liveness detection using MediaPipe Face Landmarker (CDN)",
5
+ "keywords": [
6
+ "liveness",
7
+ "mediapipe",
8
+ "face",
9
+ "web"
10
+ ],
11
+ "type": "module",
12
+ "main": "dist/index.js",
13
+ "module": "dist/index.js",
14
+ "types": "dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js",
19
+ "default": "./dist/index.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist/",
24
+ "src/"
25
+ ],
26
+ "license": "Apache-2.0",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/daboss2003/face-detection-library.git"
30
+ },
31
+ "homepage": "https://github.com/daboss2003/face-detection-library",
32
+ "bugs": {
33
+ "url": "https://github.com/daboss2003/face-detection-library/issues"
34
+ },
35
+ "scripts": {
36
+ "build": "node scripts/embed-audios.cjs && tsc -p tsconfig.json",
37
+ "prepublishOnly": "npm run build"
38
+ },
39
+ "devDependencies": {
40
+ "typescript": "^5.4.0"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ }
45
+ }
package/src/cdn.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ declare module "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest" {
2
+ const m: unknown;
3
+ export = m;
4
+ }