@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 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
- 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
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, LIVENESS_STEP_COUNT, LivenessError } from "./engine";
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 < LIVENESS_STEP_COUNT; 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 / LIVENESS_STEP_COUNT, 1);
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(LIVENESS_STEP_COUNT);
455
+ setProgress(sessionStepCount);
454
456
  setCapturePulse();
455
457
  setHint(null);
456
- setDots(LIVENESS_STEP_COUNT);
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(LIVENESS_STEP_COUNT);
512
+ setProgress(sessionStepCount);
509
513
  cleanup();
510
514
  options.callbacks.onSuccess?.(imageBase64);
511
515
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daboss2003/liveness-web",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "Web liveness detection using MediaPipe Face Landmarker (CDN)",
5
5
  "keywords": [
6
6
  "liveness",
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
- const STEP_LABELS = [
136
- "Turn your head LEFT",
137
- "Blink",
138
- "Turn your head RIGHT",
139
- "Nod your head",
140
- "Open your mouth",
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
- "Turn your head LEFT": "left",
146
- "Blink": "blink",
147
- "Turn your head RIGHT": "right",
148
- "Nod your head": "nod",
149
- "Open your mouth": "mouth",
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
- blinkClosedThreshold: 0.30, // blendshape score = eyes closed
201
- blinkOpenThreshold: 0.25, // blendshape score = eyes open
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
- this.steps = shuffleArray(STEP_LABELS).map((label, index) => ({ index, label }));
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
- if (this.blinkState === "waitingClose" && isEyeClosed) {
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
- if (now - this.blinkCloseTs <= config.blinkMaxDurationMs) return this.advanceStep(now);
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, LIVENESS_STEP_COUNT, LivenessCallbacks, LivenessSoundOptions, LivenessError } from "./engine";
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 < LIVENESS_STEP_COUNT; 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 / LIVENESS_STEP_COUNT, 1);
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(LIVENESS_STEP_COUNT);
524
+ setProgress(sessionStepCount);
519
525
  setCapturePulse();
520
526
  setHint(null);
521
- setDots(LIVENESS_STEP_COUNT);
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(LIVENESS_STEP_COUNT);
585
+ setProgress(sessionStepCount);
578
586
  cleanup();
579
587
  options.callbacks.onSuccess?.(imageBase64);
580
588
  },