@daboss2003/liveness-web 1.0.6 → 1.0.8
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/README.md +92 -0
- package/dist/engine.d.ts +14 -1
- package/dist/engine.js +51 -18
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/ui.d.ts +22 -1
- package/dist/ui.js +138 -102
- package/package.json +1 -1
- package/src/engine.ts +67 -18
- package/src/index.ts +3 -2
- package/src/ui.ts +174 -104
package/README.md
CHANGED
|
@@ -66,6 +66,98 @@ startLiveness({
|
|
|
66
66
|
});
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
+
## Embedding in your own UI
|
|
70
|
+
|
|
71
|
+
By default, `startLiveness` takes over the viewport and renders its own oval frame, step dots, and instructions. If you want to drop just the camera + face frame into a slot in your own designed page (custom button, branding, copy), pass:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
startLiveness({
|
|
75
|
+
container: mySlotEl, // your sized div (give it explicit width & height)
|
|
76
|
+
embed: true, // render inside container instead of full-screen
|
|
77
|
+
shape: "circle", // or "oval" (default)
|
|
78
|
+
showInstructions: false, // hide built-in text/dots — your UI shows them
|
|
79
|
+
minSize: 240, // floor on shape diameter (px) so small screens stay usable
|
|
80
|
+
theme: {
|
|
81
|
+
progressColor: "#1a0f4d",
|
|
82
|
+
progressErrorColor: "#ff3b3b",
|
|
83
|
+
progressWidth: 4,
|
|
84
|
+
progressLineCap: "round",
|
|
85
|
+
overlayColor: "#5b34d6", // fill outside the cutout — match your bg
|
|
86
|
+
overlayErrorColor: "rgba(180,0,0,0.6)",
|
|
87
|
+
},
|
|
88
|
+
callbacks: {
|
|
89
|
+
onChallengeChanged: (i, label) => myStatus.textContent = label,
|
|
90
|
+
onFaceInOval: (inside, reason) => { if (!inside) myStatus.textContent = reason ?? ""; },
|
|
91
|
+
onSuccess, onFailure,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Sounds still play in `embed` mode and with `showInstructions: false` — only the visuals are hidden.
|
|
97
|
+
|
|
98
|
+
A complete working example (yellow page, purple circle slot, host-owned Start button) lives at [`demo-apps/web/embed.html`](../demo-apps/web/embed.html) + [`demo-apps/web/src/embed.ts`](../demo-apps/web/src/embed.ts). Run it with:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
cd demo-apps/web && npm install && npm run dev
|
|
102
|
+
# open http://localhost:5173/embed.html
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
All new options are optional — omitting them gives you the original full-screen oval behaviour.
|
|
106
|
+
|
|
107
|
+
## Picking which challenges run
|
|
108
|
+
|
|
109
|
+
By default, all 5 challenges run in a random order. Pass `steps` to restrict to a subset (e.g. for shorter flows or A/B testing). The dot progress indicator and the progress ring both adjust to the active subset count.
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { startLiveness, type LivenessStepKey } from "@daboss2003/liveness-web";
|
|
113
|
+
|
|
114
|
+
startLiveness({
|
|
115
|
+
steps: ["nod", "blink", "mouth"], // run only these, in this set
|
|
116
|
+
shuffleSteps: true, // default true — randomise within the subset
|
|
117
|
+
callbacks: { /* … */ },
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Valid keys are `"left"`, `"blink"`, `"right"`, `"nod"`, `"mouth"`. Unknown keys and duplicates are dropped; an empty array falls back to all 5.
|
|
122
|
+
|
|
123
|
+
`shuffleSteps: false` runs the steps in the order you pass.
|
|
124
|
+
|
|
125
|
+
## Tuning individual steps
|
|
126
|
+
|
|
127
|
+
Each challenge has its own thresholds — bump them up to be stricter (harder to pass, fewer false positives), or down to be more lenient.
|
|
128
|
+
|
|
129
|
+
**Blink**
|
|
130
|
+
|
|
131
|
+
| Field | Default | Effect |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| `blinkClosedThreshold` | `0.50` | Min blendshape score that counts as "eyes closed". Raise to reject squinting / glare / hooded eyelids; lower if real blinks don't register. Below ~0.35 you start catching passive eye states. |
|
|
134
|
+
| `blinkOpenThreshold` | `0.25` | Max score that counts as "eyes open". Keep a clear gap from `blinkClosedThreshold`. |
|
|
135
|
+
| `blinkMinClosedMs` | `60` | Eyes must stay closed at least this long. Raise to require deliberate (slower) blinks. |
|
|
136
|
+
| `blinkMaxDurationMs` | `4000` | Abandon and reset if eyes stay closed longer than this. |
|
|
137
|
+
|
|
138
|
+
**Head turn** — `yawTurnDelta` (degrees needed), `yawWrongDirDelta` (wrong-direction abort), `headTurnHoldMs` (sustain time).
|
|
139
|
+
|
|
140
|
+
**Nod** — `nodDownDelta` (degrees chin must drop), `nodReturnFraction`, `nodReturnMaxDelta`.
|
|
141
|
+
|
|
142
|
+
**Mouth** — `mouthOpenThreshold`, `mouthOpenMarThreshold`, `mouthHoldMs`.
|
|
143
|
+
|
|
144
|
+
**Capture (post-success)** — `captureMaxYaw`, `captureMaxPitch`, `captureMaxMouthScore`, `captureMaxBlinkScore` (all bound how "neutral" the final frame must be).
|
|
145
|
+
|
|
146
|
+
Pass any subset as part of `config`:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
startLiveness({
|
|
150
|
+
config: {
|
|
151
|
+
blinkClosedThreshold: 0.55, // tighter blink
|
|
152
|
+
blinkMinClosedMs: 100, // require ~6 frames of closure
|
|
153
|
+
yawTurnDelta: 12, // looser head turn
|
|
154
|
+
},
|
|
155
|
+
callbacks: { /* … */ },
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
See [`engine.ts`](src/engine.ts) for the full config object and every default value.
|
|
160
|
+
|
|
69
161
|
## Notes
|
|
70
162
|
|
|
71
163
|
- Uses MediaPipe Tasks Vision Web from CDN.
|
package/dist/engine.d.ts
CHANGED
|
@@ -26,7 +26,13 @@ export type LivenessOptions = {
|
|
|
26
26
|
wasmUrl?: string;
|
|
27
27
|
callbacks?: LivenessCallbacks;
|
|
28
28
|
sounds?: LivenessSoundOptions;
|
|
29
|
+
/** Subset of challenges to run. Default: all 5. Empty array, duplicates and unknown keys fall back to all 5. */
|
|
30
|
+
steps?: LivenessStepKey[];
|
|
31
|
+
/** Shuffle the selected step order. Default true. */
|
|
32
|
+
shuffleSteps?: boolean;
|
|
29
33
|
};
|
|
34
|
+
/** Identifier for a liveness challenge step. Lowercase, matches the sound-key form. */
|
|
35
|
+
export type LivenessStepKey = "left" | "blink" | "right" | "nod" | "mouth";
|
|
30
36
|
export declare const DEFAULT_MODEL_URL = "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task";
|
|
31
37
|
export declare const DEFAULT_WASM_URL = "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm";
|
|
32
38
|
/** Error code when CDN/assets are unavailable after retries (internet confirmed). */
|
|
@@ -50,7 +56,11 @@ export type Metrics = {
|
|
|
50
56
|
faceCy: number;
|
|
51
57
|
faceSize: number;
|
|
52
58
|
};
|
|
53
|
-
|
|
59
|
+
/** Resolve a caller-supplied step-key array into the corresponding display labels.
|
|
60
|
+
* Empty / undefined / all-invalid → default to all 5 steps. Duplicates are dropped. */
|
|
61
|
+
export declare function resolveStepLabels(keys?: readonly LivenessStepKey[] | null): string[];
|
|
62
|
+
/** Default step count (5). The active session may have fewer if `options.steps` is set. */
|
|
63
|
+
export declare const LIVENESS_STEP_COUNT: number;
|
|
54
64
|
export declare class LivenessEngine {
|
|
55
65
|
private opts;
|
|
56
66
|
private landmarker;
|
|
@@ -65,6 +75,7 @@ export declare class LivenessEngine {
|
|
|
65
75
|
private baselineSamples;
|
|
66
76
|
private blinkState;
|
|
67
77
|
private blinkCloseTs;
|
|
78
|
+
private blinkSeenOpen;
|
|
68
79
|
private nodState;
|
|
69
80
|
private holdStart;
|
|
70
81
|
private latestMetrics;
|
|
@@ -84,6 +95,8 @@ export declare class LivenessEngine {
|
|
|
84
95
|
private clearSessionTimeout;
|
|
85
96
|
private playGoodSound;
|
|
86
97
|
private playCaptureSound;
|
|
98
|
+
/** Number of challenge steps in the active session (after applying `options.steps`). */
|
|
99
|
+
get stepCount(): number;
|
|
87
100
|
start(): Promise<void>;
|
|
88
101
|
stop(): void;
|
|
89
102
|
private stopDetectionOnly;
|
package/dist/engine.js
CHANGED
|
@@ -61,21 +61,35 @@ function isRetriableCdnError(error) {
|
|
|
61
61
|
return true;
|
|
62
62
|
return false;
|
|
63
63
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
64
|
+
/** Canonical step ordering. Each entry pairs the sound/identifier key with its display label. */
|
|
65
|
+
const STEP_DEFS = [
|
|
66
|
+
{ key: "left", label: "Turn your head LEFT" },
|
|
67
|
+
{ key: "blink", label: "Blink" },
|
|
68
|
+
{ key: "right", label: "Turn your head RIGHT" },
|
|
69
|
+
{ key: "nod", label: "Nod your head" },
|
|
70
|
+
{ key: "mouth", label: "Open your mouth" },
|
|
70
71
|
];
|
|
72
|
+
const STEP_LABELS = STEP_DEFS.map(s => s.label);
|
|
73
|
+
const STEP_KEY_TO_LABEL = STEP_DEFS.reduce((acc, s) => { acc[s.key] = s.label; return acc; }, {});
|
|
71
74
|
/** Step label → sound key (filename without .mp3). Used so the correct sound plays regardless of randomized step order. */
|
|
72
|
-
const STEP_LABEL_TO_SOUND = {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
const STEP_LABEL_TO_SOUND = STEP_DEFS.reduce((acc, s) => { acc[s.label] = s.key; return acc; }, {});
|
|
76
|
+
/** Resolve a caller-supplied step-key array into the corresponding display labels.
|
|
77
|
+
* Empty / undefined / all-invalid → default to all 5 steps. Duplicates are dropped. */
|
|
78
|
+
export function resolveStepLabels(keys) {
|
|
79
|
+
if (!keys || keys.length === 0)
|
|
80
|
+
return [...STEP_LABELS];
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const k of keys) {
|
|
84
|
+
const normalised = (typeof k === "string" ? k.toLowerCase() : "");
|
|
85
|
+
const label = STEP_KEY_TO_LABEL[normalised];
|
|
86
|
+
if (label && !seen.has(label)) {
|
|
87
|
+
seen.add(label);
|
|
88
|
+
out.push(label);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out.length > 0 ? out : [...STEP_LABELS];
|
|
92
|
+
}
|
|
79
93
|
function shuffleArray(array) {
|
|
80
94
|
const out = [...array];
|
|
81
95
|
for (let i = out.length - 1; i > 0; i--) {
|
|
@@ -84,6 +98,7 @@ function shuffleArray(array) {
|
|
|
84
98
|
}
|
|
85
99
|
return out;
|
|
86
100
|
}
|
|
101
|
+
/** Default step count (5). The active session may have fewer if `options.steps` is set. */
|
|
87
102
|
export const LIVENESS_STEP_COUNT = STEP_LABELS.length;
|
|
88
103
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
89
104
|
// KEY DESIGN: RELATIVE MEASUREMENT
|
|
@@ -118,10 +133,13 @@ const config = {
|
|
|
118
133
|
nodReturnMaxDelta: 12, // cap: never require returning past 12° from baseline
|
|
119
134
|
maxYawDuringNod: 40,
|
|
120
135
|
// ── Blink ──────────────────────────────────────────────────────────────────
|
|
121
|
-
|
|
122
|
-
|
|
136
|
+
// Real blinks easily hit 0.7+. Sustained squinting / glare / hooded eyelids hover at 0.25–0.40 —
|
|
137
|
+
// keep the "closed" threshold well above that band so passive eye states don't latch CLOSED.
|
|
138
|
+
blinkClosedThreshold: 0.50,
|
|
139
|
+
blinkOpenThreshold: 0.25,
|
|
123
140
|
earClosedThreshold: 0.20,
|
|
124
141
|
earOpenThreshold: 0.25,
|
|
142
|
+
blinkMinClosedMs: 60, // eyes must be CLOSED for at least this long — filters out single-frame noise
|
|
125
143
|
blinkMaxDurationMs: 4000,
|
|
126
144
|
maxYawDuringBlink: 30,
|
|
127
145
|
maxPitchDuringBlink: 30,
|
|
@@ -167,6 +185,7 @@ export class LivenessEngine {
|
|
|
167
185
|
// ── Per-step sub-state ─────────────────────────────────────────────────────
|
|
168
186
|
this.blinkState = "waitingClose";
|
|
169
187
|
this.blinkCloseTs = 0;
|
|
188
|
+
this.blinkSeenOpen = false; // gate: must observe at least one isEyeOpen frame before allowing a `closed` transition
|
|
170
189
|
this.nodState = "neutral";
|
|
171
190
|
this.holdStart = null;
|
|
172
191
|
this.latestMetrics = null;
|
|
@@ -258,10 +277,16 @@ export class LivenessEngine {
|
|
|
258
277
|
onEnded?.();
|
|
259
278
|
}
|
|
260
279
|
// ── Public ─────────────────────────────────────────────────────────────────
|
|
280
|
+
/** Number of challenge steps in the active session (after applying `options.steps`). */
|
|
281
|
+
get stepCount() {
|
|
282
|
+
return this.steps.length > 0 ? this.steps.length : resolveStepLabels(this.opts.steps).length;
|
|
283
|
+
}
|
|
261
284
|
async start() {
|
|
262
285
|
this.stopDetectionOnly();
|
|
263
286
|
this.running = true;
|
|
264
|
-
|
|
287
|
+
const selected = resolveStepLabels(this.opts.steps);
|
|
288
|
+
const ordered = this.opts.shuffleSteps === false ? selected : shuffleArray(selected);
|
|
289
|
+
this.steps = ordered.map((label, index) => ({ index, label }));
|
|
265
290
|
this.stepIndex = 0;
|
|
266
291
|
this.lastDetectTs = -1;
|
|
267
292
|
this.lastOvalState = null;
|
|
@@ -411,6 +436,7 @@ export class LivenessEngine {
|
|
|
411
436
|
resetStepState() {
|
|
412
437
|
this.blinkState = "waitingClose";
|
|
413
438
|
this.blinkCloseTs = 0;
|
|
439
|
+
this.blinkSeenOpen = false;
|
|
414
440
|
this.nodState = "neutral";
|
|
415
441
|
this.holdStart = null;
|
|
416
442
|
this.baselineYaw = null;
|
|
@@ -472,13 +498,20 @@ export class LivenessEngine {
|
|
|
472
498
|
const isEyeOpen = metrics.blinkScore > 0
|
|
473
499
|
? metrics.blinkScore < config.blinkOpenThreshold
|
|
474
500
|
: metrics.ear > config.earOpenThreshold;
|
|
475
|
-
|
|
501
|
+
// Confirm eyes were OPEN before we count any closed → open transition.
|
|
502
|
+
// Kills the "step starts mid-blink / mid-squint" false positive.
|
|
503
|
+
if (isEyeOpen)
|
|
504
|
+
this.blinkSeenOpen = true;
|
|
505
|
+
if (this.blinkState === "waitingClose" && isEyeClosed && this.blinkSeenOpen) {
|
|
476
506
|
this.blinkState = "closed";
|
|
477
507
|
this.blinkCloseTs = now;
|
|
478
508
|
}
|
|
479
509
|
else if (this.blinkState === "closed" && isEyeOpen) {
|
|
480
|
-
|
|
510
|
+
const dt = now - this.blinkCloseTs;
|
|
511
|
+
if (dt >= config.blinkMinClosedMs && dt <= config.blinkMaxDurationMs)
|
|
481
512
|
return this.advanceStep(now);
|
|
513
|
+
// Closed phase too short (noise) or too long (eyes never reopened in time) — discard, wait for next attempt.
|
|
514
|
+
// blinkSeenOpen stays true — we've already confirmed the user can open their eyes.
|
|
482
515
|
this.blinkState = "waitingClose";
|
|
483
516
|
}
|
|
484
517
|
break;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
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";
|
|
2
|
+
export type { StartLivenessOptions, LivenessTheme } from "./ui";
|
|
3
|
+
export { DEFAULT_MODEL_URL, DEFAULT_WASM_URL, LIVENESS_ERROR_CDN_NOT_AVAILABLE, LIVENESS_ERROR_OFFLINE, LIVENESS_STEP_COUNT, isCdnNotAvailableError, isOfflineError, resolveStepLabels, LivenessError, } from "./engine";
|
|
4
|
+
export type { LivenessCallbacks, LivenessOptions, LivenessSoundOptions, LivenessStepKey } from "./engine";
|
|
5
5
|
export type { LivenessEngine } from "./engine";
|
|
6
6
|
/**
|
|
7
7
|
* Starts liveness verification with built-in UI (oval frame, progress ring, camera).
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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";
|
|
2
|
+
export { DEFAULT_MODEL_URL, DEFAULT_WASM_URL, LIVENESS_ERROR_CDN_NOT_AVAILABLE, LIVENESS_ERROR_OFFLINE, LIVENESS_STEP_COUNT, isCdnNotAvailableError, isOfflineError, resolveStepLabels, LivenessError, } from "./engine";
|
|
3
3
|
let currentEngine = null;
|
|
4
4
|
/**
|
|
5
5
|
* Starts liveness verification with built-in UI (oval frame, progress ring, camera).
|
package/dist/ui.d.ts
CHANGED
|
@@ -1,9 +1,30 @@
|
|
|
1
|
-
import { LivenessEngine, LivenessCallbacks, LivenessSoundOptions } from "./engine";
|
|
1
|
+
import { LivenessEngine, LivenessCallbacks, LivenessSoundOptions, LivenessStepKey } from "./engine";
|
|
2
|
+
export type LivenessTheme = {
|
|
3
|
+
progressColor?: string;
|
|
4
|
+
progressErrorColor?: string;
|
|
5
|
+
progressWidth?: number;
|
|
6
|
+
progressLineCap?: "round" | "square" | "butt";
|
|
7
|
+
overlayColor?: string;
|
|
8
|
+
overlayErrorColor?: string;
|
|
9
|
+
};
|
|
2
10
|
export type StartLivenessOptions = {
|
|
3
11
|
container?: HTMLElement;
|
|
12
|
+
/** If true, render inside `container` instead of taking over the viewport. Container should have explicit width and height. */
|
|
13
|
+
embed?: boolean;
|
|
14
|
+
/** Visible face frame shape. Defaults to "oval". */
|
|
15
|
+
shape?: "oval" | "circle";
|
|
16
|
+
/** If false, hides SDK-provided instruction text, step dots, position hint, gesture icon and loading text. Sounds still play. Default true. */
|
|
17
|
+
showInstructions?: boolean;
|
|
18
|
+
theme?: LivenessTheme;
|
|
19
|
+
/** Minimum diameter of the visible shape in CSS pixels (so it stays usable on small screens). Default 220. */
|
|
20
|
+
minSize?: number;
|
|
4
21
|
modelUrl?: string;
|
|
5
22
|
wasmUrl?: string;
|
|
6
23
|
callbacks: LivenessCallbacks;
|
|
7
24
|
sounds?: LivenessSoundOptions;
|
|
25
|
+
/** Subset of challenges to run, e.g. `["nod", "blink", "mouth"]`. Default: all 5. */
|
|
26
|
+
steps?: LivenessStepKey[];
|
|
27
|
+
/** Shuffle the selected step order. Default true. */
|
|
28
|
+
shuffleSteps?: boolean;
|
|
8
29
|
};
|
|
9
30
|
export declare function startLivenessWithUI(options: StartLivenessOptions): LivenessEngine;
|