@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 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
- export declare const LIVENESS_STEP_COUNT: 5;
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
- const STEP_LABELS = [
65
- "Turn your head LEFT",
66
- "Blink",
67
- "Turn your head RIGHT",
68
- "Nod your head",
69
- "Open your mouth",
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
- "Turn your head LEFT": "left",
74
- "Blink": "blink",
75
- "Turn your head RIGHT": "right",
76
- "Nod your head": "nod",
77
- "Open your mouth": "mouth",
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
- blinkClosedThreshold: 0.30, // blendshape score = eyes closed
122
- blinkOpenThreshold: 0.25, // blendshape score = eyes open
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
- this.steps = shuffleArray(STEP_LABELS).map((label, index) => ({ index, label }));
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
- if (this.blinkState === "waitingClose" && isEyeClosed) {
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
- if (now - this.blinkCloseTs <= config.blinkMaxDurationMs)
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;