@daboss2003/liveness-web 1.0.7 → 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 +54 -0
- package/dist/engine.d.ts +14 -1
- package/dist/engine.js +51 -18
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/ui.d.ts +5 -1
- package/dist/ui.js +10 -6
- package/package.json +1 -1
- package/src/engine.ts +67 -18
- package/src/index.ts +2 -1
- package/src/ui.ts +14 -6
package/README.md
CHANGED
|
@@ -104,6 +104,60 @@ cd demo-apps/web && npm install && npm run dev
|
|
|
104
104
|
|
|
105
105
|
All new options are optional — omitting them gives you the original full-screen oval behaviour.
|
|
106
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
|
+
|
|
107
161
|
## Notes
|
|
108
162
|
|
|
109
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
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, LivenessError, } from "./engine";
|
|
4
|
-
export type { LivenessCallbacks, LivenessOptions, LivenessSoundOptions } from "./engine";
|
|
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,4 +1,4 @@
|
|
|
1
|
-
import { LivenessEngine, LivenessCallbacks, LivenessSoundOptions } from "./engine";
|
|
1
|
+
import { LivenessEngine, LivenessCallbacks, LivenessSoundOptions, LivenessStepKey } from "./engine";
|
|
2
2
|
export type LivenessTheme = {
|
|
3
3
|
progressColor?: string;
|
|
4
4
|
progressErrorColor?: string;
|
|
@@ -22,5 +22,9 @@ export type StartLivenessOptions = {
|
|
|
22
22
|
wasmUrl?: string;
|
|
23
23
|
callbacks: LivenessCallbacks;
|
|
24
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;
|
|
25
29
|
};
|
|
26
30
|
export declare function startLivenessWithUI(options: StartLivenessOptions): LivenessEngine;
|
package/dist/ui.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LivenessEngine,
|
|
1
|
+
import { LivenessEngine, LivenessError, resolveStepLabels } from "./engine";
|
|
2
2
|
import { DEFAULT_SOUND_DATA_URLS } from "./default-sounds.generated";
|
|
3
3
|
const DEFAULT_THEME = {
|
|
4
4
|
progressColor: "#12c95c",
|
|
@@ -388,9 +388,11 @@ export function startLivenessWithUI(options) {
|
|
|
388
388
|
`;
|
|
389
389
|
root.appendChild(loading);
|
|
390
390
|
// ── Step dots ──────────────────────────────────────────────────────────────
|
|
391
|
+
// Count derives from the active subset (`options.steps`), defaulting to all 5.
|
|
392
|
+
const sessionStepCount = resolveStepLabels(options.steps).length;
|
|
391
393
|
const dotsEl = document.createElement("div");
|
|
392
394
|
dotsEl.className = "lv-dots";
|
|
393
|
-
for (let i = 0; i <
|
|
395
|
+
for (let i = 0; i < sessionStepCount; i++) {
|
|
394
396
|
const dot = document.createElement("div");
|
|
395
397
|
dot.className = "lv-dot" + (i === 0 ? " active" : "");
|
|
396
398
|
dotsEl.appendChild(dot);
|
|
@@ -420,7 +422,7 @@ export function startLivenessWithUI(options) {
|
|
|
420
422
|
function setProgress(completedSteps) {
|
|
421
423
|
if (!ringEl)
|
|
422
424
|
return;
|
|
423
|
-
const filled = Math.min(completedSteps /
|
|
425
|
+
const filled = Math.min(completedSteps / sessionStepCount, 1);
|
|
424
426
|
const offset = P * (1 - filled);
|
|
425
427
|
ringEl.setAttribute("stroke-dashoffset", offset.toFixed(1));
|
|
426
428
|
}
|
|
@@ -450,10 +452,10 @@ export function startLivenessWithUI(options) {
|
|
|
450
452
|
}
|
|
451
453
|
function renderChallenge(stepIndex, stepLabel) {
|
|
452
454
|
if (stepIndex === -1) {
|
|
453
|
-
setProgress(
|
|
455
|
+
setProgress(sessionStepCount);
|
|
454
456
|
setCapturePulse();
|
|
455
457
|
setHint(null);
|
|
456
|
-
setDots(
|
|
458
|
+
setDots(sessionStepCount);
|
|
457
459
|
instruction.textContent = stepLabel;
|
|
458
460
|
return;
|
|
459
461
|
}
|
|
@@ -486,6 +488,8 @@ export function startLivenessWithUI(options) {
|
|
|
486
488
|
modelUrl: options.modelUrl,
|
|
487
489
|
wasmUrl: options.wasmUrl,
|
|
488
490
|
sounds,
|
|
491
|
+
steps: options.steps,
|
|
492
|
+
shuffleSteps: options.shuffleSteps,
|
|
489
493
|
callbacks: {
|
|
490
494
|
onChallengeChanged: (stepIndex, stepLabel) => {
|
|
491
495
|
pendingChallenge = { stepIndex, stepLabel };
|
|
@@ -505,7 +509,7 @@ export function startLivenessWithUI(options) {
|
|
|
505
509
|
options.callbacks.onFailure?.(reason);
|
|
506
510
|
},
|
|
507
511
|
onSuccess: (imageBase64) => {
|
|
508
|
-
setProgress(
|
|
512
|
+
setProgress(sessionStepCount);
|
|
509
513
|
cleanup();
|
|
510
514
|
options.callbacks.onSuccess?.(imageBase64);
|
|
511
515
|
},
|
package/package.json
CHANGED
package/src/engine.ts
CHANGED
|
@@ -24,8 +24,15 @@ export type LivenessOptions = {
|
|
|
24
24
|
wasmUrl?: string;
|
|
25
25
|
callbacks?: LivenessCallbacks;
|
|
26
26
|
sounds?: LivenessSoundOptions;
|
|
27
|
+
/** Subset of challenges to run. Default: all 5. Empty array, duplicates and unknown keys fall back to all 5. */
|
|
28
|
+
steps?: LivenessStepKey[];
|
|
29
|
+
/** Shuffle the selected step order. Default true. */
|
|
30
|
+
shuffleSteps?: boolean;
|
|
27
31
|
};
|
|
28
32
|
|
|
33
|
+
/** Identifier for a liveness challenge step. Lowercase, matches the sound-key form. */
|
|
34
|
+
export type LivenessStepKey = "left" | "blink" | "right" | "nod" | "mouth";
|
|
35
|
+
|
|
29
36
|
export const DEFAULT_MODEL_URL =
|
|
30
37
|
"https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task";
|
|
31
38
|
|
|
@@ -132,22 +139,44 @@ export type Metrics = {
|
|
|
132
139
|
faceSize: number;
|
|
133
140
|
};
|
|
134
141
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
"
|
|
138
|
-
"
|
|
139
|
-
"
|
|
140
|
-
"
|
|
142
|
+
/** Canonical step ordering. Each entry pairs the sound/identifier key with its display label. */
|
|
143
|
+
const STEP_DEFS = [
|
|
144
|
+
{ key: "left", label: "Turn your head LEFT" },
|
|
145
|
+
{ key: "blink", label: "Blink" },
|
|
146
|
+
{ key: "right", label: "Turn your head RIGHT" },
|
|
147
|
+
{ key: "nod", label: "Nod your head" },
|
|
148
|
+
{ key: "mouth", label: "Open your mouth" },
|
|
141
149
|
] as const;
|
|
142
150
|
|
|
151
|
+
const STEP_LABELS: readonly string[] = STEP_DEFS.map(s => s.label);
|
|
152
|
+
|
|
153
|
+
const STEP_KEY_TO_LABEL: Record<LivenessStepKey, string> = STEP_DEFS.reduce(
|
|
154
|
+
(acc, s) => { (acc as Record<string, string>)[s.key] = s.label; return acc; },
|
|
155
|
+
{} as Record<LivenessStepKey, string>,
|
|
156
|
+
);
|
|
157
|
+
|
|
143
158
|
/** Step label → sound key (filename without .mp3). Used so the correct sound plays regardless of randomized step order. */
|
|
144
|
-
const STEP_LABEL_TO_SOUND: Record<string, string> =
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
159
|
+
const STEP_LABEL_TO_SOUND: Record<string, string> = STEP_DEFS.reduce(
|
|
160
|
+
(acc, s) => { acc[s.label] = s.key; return acc; },
|
|
161
|
+
{} as Record<string, string>,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
/** Resolve a caller-supplied step-key array into the corresponding display labels.
|
|
165
|
+
* Empty / undefined / all-invalid → default to all 5 steps. Duplicates are dropped. */
|
|
166
|
+
export function resolveStepLabels(keys?: readonly LivenessStepKey[] | null): string[] {
|
|
167
|
+
if (!keys || keys.length === 0) return [...STEP_LABELS];
|
|
168
|
+
const seen = new Set<string>();
|
|
169
|
+
const out: string[] = [];
|
|
170
|
+
for (const k of keys) {
|
|
171
|
+
const normalised = (typeof k === "string" ? k.toLowerCase() : "") as LivenessStepKey;
|
|
172
|
+
const label = STEP_KEY_TO_LABEL[normalised];
|
|
173
|
+
if (label && !seen.has(label)) {
|
|
174
|
+
seen.add(label);
|
|
175
|
+
out.push(label);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return out.length > 0 ? out : [...STEP_LABELS];
|
|
179
|
+
}
|
|
151
180
|
|
|
152
181
|
function shuffleArray<T>(array: readonly T[]): T[] {
|
|
153
182
|
const out = [...array];
|
|
@@ -158,6 +187,7 @@ function shuffleArray<T>(array: readonly T[]): T[] {
|
|
|
158
187
|
return out;
|
|
159
188
|
}
|
|
160
189
|
|
|
190
|
+
/** Default step count (5). The active session may have fewer if `options.steps` is set. */
|
|
161
191
|
export const LIVENESS_STEP_COUNT = STEP_LABELS.length;
|
|
162
192
|
|
|
163
193
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -197,10 +227,13 @@ const config = {
|
|
|
197
227
|
maxYawDuringNod: 40,
|
|
198
228
|
|
|
199
229
|
// ── Blink ──────────────────────────────────────────────────────────────────
|
|
200
|
-
|
|
201
|
-
|
|
230
|
+
// Real blinks easily hit 0.7+. Sustained squinting / glare / hooded eyelids hover at 0.25–0.40 —
|
|
231
|
+
// keep the "closed" threshold well above that band so passive eye states don't latch CLOSED.
|
|
232
|
+
blinkClosedThreshold: 0.50,
|
|
233
|
+
blinkOpenThreshold: 0.25,
|
|
202
234
|
earClosedThreshold: 0.20,
|
|
203
235
|
earOpenThreshold: 0.25,
|
|
236
|
+
blinkMinClosedMs: 60, // eyes must be CLOSED for at least this long — filters out single-frame noise
|
|
204
237
|
blinkMaxDurationMs: 4000,
|
|
205
238
|
maxYawDuringBlink: 30,
|
|
206
239
|
maxPitchDuringBlink: 30,
|
|
@@ -252,6 +285,7 @@ export class LivenessEngine {
|
|
|
252
285
|
// ── Per-step sub-state ─────────────────────────────────────────────────────
|
|
253
286
|
private blinkState: "waitingClose" | "closed" = "waitingClose";
|
|
254
287
|
private blinkCloseTs = 0;
|
|
288
|
+
private blinkSeenOpen = false; // gate: must observe at least one isEyeOpen frame before allowing a `closed` transition
|
|
255
289
|
private nodState: "neutral" | "down" = "neutral";
|
|
256
290
|
private holdStart: number | null = null;
|
|
257
291
|
|
|
@@ -342,10 +376,17 @@ export class LivenessEngine {
|
|
|
342
376
|
|
|
343
377
|
// ── Public ─────────────────────────────────────────────────────────────────
|
|
344
378
|
|
|
379
|
+
/** Number of challenge steps in the active session (after applying `options.steps`). */
|
|
380
|
+
get stepCount(): number {
|
|
381
|
+
return this.steps.length > 0 ? this.steps.length : resolveStepLabels(this.opts.steps).length;
|
|
382
|
+
}
|
|
383
|
+
|
|
345
384
|
async start(): Promise<void> {
|
|
346
385
|
this.stopDetectionOnly();
|
|
347
386
|
this.running = true;
|
|
348
|
-
|
|
387
|
+
const selected = resolveStepLabels(this.opts.steps);
|
|
388
|
+
const ordered = this.opts.shuffleSteps === false ? selected : shuffleArray(selected);
|
|
389
|
+
this.steps = ordered.map((label, index) => ({ index, label }));
|
|
349
390
|
this.stepIndex = 0;
|
|
350
391
|
this.lastDetectTs = -1;
|
|
351
392
|
this.lastOvalState = null;
|
|
@@ -511,6 +552,7 @@ export class LivenessEngine {
|
|
|
511
552
|
private resetStepState(): void {
|
|
512
553
|
this.blinkState = "waitingClose";
|
|
513
554
|
this.blinkCloseTs = 0;
|
|
555
|
+
this.blinkSeenOpen = false;
|
|
514
556
|
this.nodState = "neutral";
|
|
515
557
|
this.holdStart = null;
|
|
516
558
|
this.baselineYaw = null;
|
|
@@ -566,11 +608,18 @@ export class LivenessEngine {
|
|
|
566
608
|
? metrics.blinkScore < config.blinkOpenThreshold
|
|
567
609
|
: metrics.ear > config.earOpenThreshold;
|
|
568
610
|
|
|
569
|
-
|
|
611
|
+
// Confirm eyes were OPEN before we count any closed → open transition.
|
|
612
|
+
// Kills the "step starts mid-blink / mid-squint" false positive.
|
|
613
|
+
if (isEyeOpen) this.blinkSeenOpen = true;
|
|
614
|
+
|
|
615
|
+
if (this.blinkState === "waitingClose" && isEyeClosed && this.blinkSeenOpen) {
|
|
570
616
|
this.blinkState = "closed";
|
|
571
617
|
this.blinkCloseTs = now;
|
|
572
618
|
} else if (this.blinkState === "closed" && isEyeOpen) {
|
|
573
|
-
|
|
619
|
+
const dt = now - this.blinkCloseTs;
|
|
620
|
+
if (dt >= config.blinkMinClosedMs && dt <= config.blinkMaxDurationMs) return this.advanceStep(now);
|
|
621
|
+
// Closed phase too short (noise) or too long (eyes never reopened in time) — discard, wait for next attempt.
|
|
622
|
+
// blinkSeenOpen stays true — we've already confirmed the user can open their eyes.
|
|
574
623
|
this.blinkState = "waitingClose";
|
|
575
624
|
}
|
|
576
625
|
break;
|
package/src/index.ts
CHANGED
|
@@ -10,9 +10,10 @@ export {
|
|
|
10
10
|
LIVENESS_STEP_COUNT,
|
|
11
11
|
isCdnNotAvailableError,
|
|
12
12
|
isOfflineError,
|
|
13
|
+
resolveStepLabels,
|
|
13
14
|
LivenessError,
|
|
14
15
|
} from "./engine";
|
|
15
|
-
export type { LivenessCallbacks, LivenessOptions, LivenessSoundOptions } from "./engine";
|
|
16
|
+
export type { LivenessCallbacks, LivenessOptions, LivenessSoundOptions, LivenessStepKey } from "./engine";
|
|
16
17
|
export type { LivenessEngine } from "./engine";
|
|
17
18
|
|
|
18
19
|
let currentEngine: LivenessEngine | null = null;
|
package/src/ui.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LivenessEngine,
|
|
1
|
+
import { LivenessEngine, LivenessCallbacks, LivenessSoundOptions, LivenessError, resolveStepLabels, LivenessStepKey } from "./engine";
|
|
2
2
|
import { DEFAULT_SOUND_DATA_URLS } from "./default-sounds.generated";
|
|
3
3
|
|
|
4
4
|
export type LivenessTheme = {
|
|
@@ -25,6 +25,10 @@ export type StartLivenessOptions = {
|
|
|
25
25
|
wasmUrl?: string;
|
|
26
26
|
callbacks: LivenessCallbacks;
|
|
27
27
|
sounds?: LivenessSoundOptions;
|
|
28
|
+
/** Subset of challenges to run, e.g. `["nod", "blink", "mouth"]`. Default: all 5. */
|
|
29
|
+
steps?: LivenessStepKey[];
|
|
30
|
+
/** Shuffle the selected step order. Default true. */
|
|
31
|
+
shuffleSteps?: boolean;
|
|
28
32
|
};
|
|
29
33
|
|
|
30
34
|
const DEFAULT_THEME: Required<LivenessTheme> = {
|
|
@@ -445,9 +449,11 @@ export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngi
|
|
|
445
449
|
root.appendChild(loading);
|
|
446
450
|
|
|
447
451
|
// ── Step dots ──────────────────────────────────────────────────────────────
|
|
452
|
+
// Count derives from the active subset (`options.steps`), defaulting to all 5.
|
|
453
|
+
const sessionStepCount = resolveStepLabels(options.steps).length;
|
|
448
454
|
const dotsEl = document.createElement("div");
|
|
449
455
|
dotsEl.className = "lv-dots";
|
|
450
|
-
for (let i = 0; i <
|
|
456
|
+
for (let i = 0; i < sessionStepCount; i++) {
|
|
451
457
|
const dot = document.createElement("div");
|
|
452
458
|
dot.className = "lv-dot" + (i === 0 ? " active" : "");
|
|
453
459
|
dotsEl.appendChild(dot);
|
|
@@ -483,7 +489,7 @@ export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngi
|
|
|
483
489
|
|
|
484
490
|
function setProgress(completedSteps: number): void {
|
|
485
491
|
if (!ringEl) return;
|
|
486
|
-
const filled = Math.min(completedSteps /
|
|
492
|
+
const filled = Math.min(completedSteps / sessionStepCount, 1);
|
|
487
493
|
const offset = P * (1 - filled);
|
|
488
494
|
ringEl.setAttribute("stroke-dashoffset", offset.toFixed(1));
|
|
489
495
|
}
|
|
@@ -515,10 +521,10 @@ export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngi
|
|
|
515
521
|
|
|
516
522
|
function renderChallenge(stepIndex: number, stepLabel: string): void {
|
|
517
523
|
if (stepIndex === -1) {
|
|
518
|
-
setProgress(
|
|
524
|
+
setProgress(sessionStepCount);
|
|
519
525
|
setCapturePulse();
|
|
520
526
|
setHint(null);
|
|
521
|
-
setDots(
|
|
527
|
+
setDots(sessionStepCount);
|
|
522
528
|
instruction.textContent = stepLabel;
|
|
523
529
|
return;
|
|
524
530
|
}
|
|
@@ -555,6 +561,8 @@ export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngi
|
|
|
555
561
|
modelUrl: options.modelUrl,
|
|
556
562
|
wasmUrl: options.wasmUrl,
|
|
557
563
|
sounds,
|
|
564
|
+
steps: options.steps,
|
|
565
|
+
shuffleSteps: options.shuffleSteps,
|
|
558
566
|
callbacks: {
|
|
559
567
|
onChallengeChanged: (stepIndex, stepLabel) => {
|
|
560
568
|
pendingChallenge = { stepIndex, stepLabel };
|
|
@@ -574,7 +582,7 @@ export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngi
|
|
|
574
582
|
options.callbacks.onFailure?.(reason);
|
|
575
583
|
},
|
|
576
584
|
onSuccess: (imageBase64) => {
|
|
577
|
-
setProgress(
|
|
585
|
+
setProgress(sessionStepCount);
|
|
578
586
|
cleanup();
|
|
579
587
|
options.callbacks.onSuccess?.(imageBase64);
|
|
580
588
|
},
|