@daboss2003/liveness-web 1.0.4 → 1.0.6

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/dist/engine.d.ts CHANGED
@@ -50,13 +50,14 @@ export type Metrics = {
50
50
  faceCy: number;
51
51
  faceSize: number;
52
52
  };
53
- export declare const LIVENESS_STEP_COUNT: number;
53
+ export declare const LIVENESS_STEP_COUNT: 5;
54
54
  export declare class LivenessEngine {
55
55
  private opts;
56
56
  private landmarker;
57
57
  private running;
58
58
  private rafId;
59
59
  private stream;
60
+ private steps;
60
61
  private stepIndex;
61
62
  private stepStart;
62
63
  private baselineYaw;
@@ -71,14 +72,15 @@ export declare class LivenessEngine {
71
72
  private lastDetectTs;
72
73
  private lastOvalState;
73
74
  private stepSoundPlayedForCurrentStep;
74
- private currentStepAudio;
75
- private currentStepAudioCleanup;
75
+ private currentAudio;
76
+ private currentAudioCleanup;
77
+ private currentAudioToken;
76
78
  private sessionTimeoutId;
77
79
  constructor(opts: LivenessOptions);
78
80
  private playSound;
79
81
  private getSoundUrl;
80
82
  private playStepSound;
81
- private stopStepSound;
83
+ private stopAllSounds;
82
84
  private clearSessionTimeout;
83
85
  private playGoodSound;
84
86
  private playCaptureSound;
package/dist/engine.js CHANGED
@@ -84,8 +84,7 @@ function shuffleArray(array) {
84
84
  }
85
85
  return out;
86
86
  }
87
- const steps = shuffleArray(STEP_LABELS).map((label, index) => ({ index, label }));
88
- export const LIVENESS_STEP_COUNT = steps.length;
87
+ export const LIVENESS_STEP_COUNT = STEP_LABELS.length;
89
88
  // ─────────────────────────────────────────────────────────────────────────────
90
89
  // KEY DESIGN: RELATIVE MEASUREMENT
91
90
  //
@@ -114,18 +113,18 @@ const config = {
114
113
  yawWrongDirDelta: 16, // block if turned clearly the WRONG way
115
114
  headTurnHoldMs: 80, // sustain the turned pose for this long
116
115
  // ── Nod (relative to baseline) ────────────────────────────────────────────
117
- nodDownDelta: 4, // chin must DROP by this many degrees from baseline
118
- nodReturnFraction: 0.75, // return to 75% of peak nod depth to complete
119
- nodReturnMaxDelta: 9, // cap: never require returning past 9° from baseline
120
- maxYawDuringNod: 32,
116
+ nodDownDelta: 3, // chin must DROP by this many degrees from baseline
117
+ nodReturnFraction: 0.85, // return to 85% of peak nod depth to complete
118
+ nodReturnMaxDelta: 12, // cap: never require returning past 12° from baseline
119
+ maxYawDuringNod: 40,
121
120
  // ── Blink ──────────────────────────────────────────────────────────────────
122
- blinkClosedThreshold: 0.35, // blendshape score = eyes closed
123
- blinkOpenThreshold: 0.20, // blendshape score = eyes open
121
+ blinkClosedThreshold: 0.30, // blendshape score = eyes closed
122
+ blinkOpenThreshold: 0.25, // blendshape score = eyes open
124
123
  earClosedThreshold: 0.20,
125
124
  earOpenThreshold: 0.25,
126
125
  blinkMaxDurationMs: 4000,
127
- maxYawDuringBlink: 25,
128
- maxPitchDuringBlink: 25,
126
+ maxYawDuringBlink: 30,
127
+ maxPitchDuringBlink: 30,
129
128
  // ── Mouth ──────────────────────────────────────────────────────────────────
130
129
  mouthOpenThreshold: 0.20, // jawOpen blendshape
131
130
  mouthOpenMarThreshold: 0.20,
@@ -158,6 +157,7 @@ export class LivenessEngine {
158
157
  this.running = false;
159
158
  this.rafId = null;
160
159
  this.stream = null;
160
+ this.steps = [];
161
161
  this.stepIndex = 0;
162
162
  this.stepStart = 0;
163
163
  // ── Baseline (sampled during readyMs window) ───────────────────────────────
@@ -174,22 +174,36 @@ export class LivenessEngine {
174
174
  this.lastDetectTs = -1;
175
175
  this.lastOvalState = null;
176
176
  this.stepSoundPlayedForCurrentStep = false;
177
- this.currentStepAudio = null;
178
- this.currentStepAudioCleanup = null;
177
+ this.currentAudio = null;
178
+ this.currentAudioCleanup = null;
179
+ this.currentAudioToken = 0;
179
180
  this.sessionTimeoutId = null;
180
181
  }
181
182
  playSound(url, onEnded) {
183
+ this.stopAllSounds();
184
+ const token = ++this.currentAudioToken;
182
185
  const a = new Audio(url);
183
- if (onEnded) {
184
- const done = () => {
185
- a.removeEventListener("ended", done);
186
- a.removeEventListener("error", done);
187
- onEnded();
188
- };
189
- a.addEventListener("ended", done);
190
- a.addEventListener("error", done);
191
- }
192
- a.play().catch(() => onEnded?.());
186
+ a.preload = "auto";
187
+ const done = () => {
188
+ if (token !== this.currentAudioToken)
189
+ return;
190
+ a.removeEventListener("ended", done);
191
+ a.removeEventListener("error", done);
192
+ if (this.currentAudio === a)
193
+ this.currentAudio = null;
194
+ if (this.currentAudioCleanup === cleanup)
195
+ this.currentAudioCleanup = null;
196
+ onEnded?.();
197
+ };
198
+ const cleanup = () => {
199
+ a.removeEventListener("ended", done);
200
+ a.removeEventListener("error", done);
201
+ };
202
+ this.currentAudio = a;
203
+ this.currentAudioCleanup = cleanup;
204
+ a.addEventListener("ended", done);
205
+ a.addEventListener("error", done);
206
+ a.play().catch(() => done());
193
207
  }
194
208
  getSoundUrl(key) {
195
209
  const s = this.opts.sounds;
@@ -211,34 +225,17 @@ export class LivenessEngine {
211
225
  const url = this.getSoundUrl(key);
212
226
  if (!url)
213
227
  return;
214
- this.stopStepSound();
215
- const a = new Audio(url);
216
- const done = () => {
217
- a.removeEventListener("ended", done);
218
- a.removeEventListener("error", done);
219
- if (this.currentStepAudio === a)
220
- this.currentStepAudio = null;
221
- if (this.currentStepAudioCleanup === cleanup)
222
- this.currentStepAudioCleanup = null;
223
- };
224
- const cleanup = () => {
225
- a.removeEventListener("ended", done);
226
- a.removeEventListener("error", done);
227
- };
228
- this.currentStepAudio = a;
229
- this.currentStepAudioCleanup = cleanup;
230
- a.addEventListener("ended", done);
231
- a.addEventListener("error", done);
232
- a.play().catch(() => done());
228
+ this.playSound(url);
233
229
  }
234
- stopStepSound() {
235
- if (this.currentStepAudio) {
236
- this.currentStepAudio.pause();
237
- this.currentStepAudio.currentTime = 0;
230
+ stopAllSounds() {
231
+ this.currentAudioToken += 1;
232
+ if (this.currentAudio) {
233
+ this.currentAudio.pause();
234
+ this.currentAudio.currentTime = 0;
238
235
  }
239
- this.currentStepAudioCleanup?.();
240
- this.currentStepAudioCleanup = null;
241
- this.currentStepAudio = null;
236
+ this.currentAudioCleanup?.();
237
+ this.currentAudioCleanup = null;
238
+ this.currentAudio = null;
242
239
  }
243
240
  clearSessionTimeout() {
244
241
  if (this.sessionTimeoutId != null) {
@@ -264,6 +261,7 @@ export class LivenessEngine {
264
261
  async start() {
265
262
  this.stopDetectionOnly();
266
263
  this.running = true;
264
+ this.steps = shuffleArray(STEP_LABELS).map((label, index) => ({ index, label }));
267
265
  this.stepIndex = 0;
268
266
  this.lastDetectTs = -1;
269
267
  this.lastOvalState = null;
@@ -276,7 +274,9 @@ export class LivenessEngine {
276
274
  if (this.running)
277
275
  this.fail("Timed out. Please try again.");
278
276
  }, config.sessionTimeoutMs);
279
- this.opts.callbacks?.onChallengeChanged?.(steps[0].index, steps[0].label);
277
+ if (this.steps.length > 0) {
278
+ this.opts.callbacks?.onChallengeChanged?.(this.steps[0].index, this.steps[0].label);
279
+ }
280
280
  await this.ensureVideo();
281
281
  this.landmarker = await createLandmarkerWithRetry(this.opts, MAX_CDN_RETRIES);
282
282
  this.loop();
@@ -289,7 +289,7 @@ export class LivenessEngine {
289
289
  stopDetectionOnly() {
290
290
  this.running = false;
291
291
  this.clearSessionTimeout();
292
- this.stopStepSound();
292
+ this.stopAllSounds();
293
293
  if (this.rafId != null) {
294
294
  cancelAnimationFrame(this.rafId);
295
295
  this.rafId = null;
@@ -360,12 +360,12 @@ export class LivenessEngine {
360
360
  }
361
361
  this.opts.callbacks?.onDebugFrame?.({
362
362
  hasFace: true, metrics,
363
- step: steps[this.stepIndex]?.label ?? "done",
363
+ step: this.steps[this.stepIndex]?.label ?? "done",
364
364
  });
365
365
  if (inside) {
366
- if (!this.stepSoundPlayedForCurrentStep && this.stepIndex < steps.length) {
366
+ if (!this.stepSoundPlayedForCurrentStep && this.stepIndex < this.steps.length) {
367
367
  this.stepSoundPlayedForCurrentStep = true;
368
- this.playStepSound(steps[this.stepIndex].label);
368
+ this.playStepSound(this.steps[this.stepIndex].label);
369
369
  }
370
370
  if (this.updateState(metrics, now) === "passed") {
371
371
  this.scheduleCapture();
@@ -380,14 +380,14 @@ export class LivenessEngine {
380
380
  }
381
381
  this.opts.callbacks?.onDebugFrame?.({
382
382
  hasFace: false, metrics: null,
383
- step: steps[this.stepIndex]?.label ?? "done",
383
+ step: this.steps[this.stepIndex]?.label ?? "done",
384
384
  });
385
385
  }
386
386
  this.rafId = requestAnimationFrame(() => this.loop());
387
387
  }
388
388
  // ── Oval check ─────────────────────────────────────────────────────────────
389
389
  checkFaceInOval(m) {
390
- const isHeadTurn = config.headTurnSteps.has(steps[this.stepIndex]?.label ?? "");
390
+ const isHeadTurn = config.headTurnSteps.has(this.steps[this.stepIndex]?.label ?? "");
391
391
  const mx = 1 - m.faceCx; // mirror x to match CSS scaleX(-1)
392
392
  const dy = (m.faceCy - config.ovalCy) / config.ovalRy;
393
393
  const dx = (mx - config.ovalCx) / config.ovalRx;
@@ -426,7 +426,7 @@ export class LivenessEngine {
426
426
  const bPitch = this.baselinePitch ?? metrics.pitch;
427
427
  const dYaw = metrics.yaw - bYaw;
428
428
  const dPitch = metrics.pitch - bPitch;
429
- switch (steps[this.stepIndex].label) {
429
+ switch (this.steps[this.stepIndex]?.label) {
430
430
  // ── LEFT turn (negative yaw delta = turning left from rest) ─────────────
431
431
  case "Turn your head LEFT": {
432
432
  if (dYaw > config.yawWrongDirDelta) {
@@ -529,18 +529,20 @@ export class LivenessEngine {
529
529
  return "none";
530
530
  }
531
531
  advanceStep(now) {
532
- this.stopStepSound();
532
+ this.stopAllSounds();
533
533
  this.stepIndex += 1;
534
- if (this.stepIndex >= steps.length) {
534
+ if (this.stepIndex >= this.steps.length) {
535
535
  this.playGoodSound();
536
536
  return "passed";
537
537
  }
538
538
  this.stepStart = now + config.readyMs;
539
539
  this.resetStepState();
540
540
  this.stepSoundPlayedForCurrentStep = true;
541
- const step = steps[this.stepIndex];
542
- this.opts.callbacks?.onChallengeChanged?.(step.index, step.label);
543
- this.playGoodSound(() => this.playStepSound(step.label));
541
+ const step = this.steps[this.stepIndex];
542
+ if (step) {
543
+ this.opts.callbacks?.onChallengeChanged?.(step.index, step.label);
544
+ this.playGoodSound(() => this.playStepSound(step.label));
545
+ }
544
546
  return "none";
545
547
  }
546
548
  fail(reason) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daboss2003/liveness-web",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Web liveness detection using MediaPipe Face Landmarker (CDN)",
5
5
  "keywords": [
6
6
  "liveness",
package/src/engine.ts CHANGED
@@ -158,9 +158,7 @@ function shuffleArray<T>(array: readonly T[]): T[] {
158
158
  return out;
159
159
  }
160
160
 
161
- const steps: LivenessStep[] = shuffleArray(STEP_LABELS).map((label, index) => ({ index, label }));
162
-
163
- export const LIVENESS_STEP_COUNT = steps.length;
161
+ export const LIVENESS_STEP_COUNT = STEP_LABELS.length;
164
162
 
165
163
  // ─────────────────────────────────────────────────────────────────────────────
166
164
  // KEY DESIGN: RELATIVE MEASUREMENT
@@ -193,19 +191,19 @@ const config = {
193
191
  headTurnHoldMs: 80, // sustain the turned pose for this long
194
192
 
195
193
  // ── Nod (relative to baseline) ────────────────────────────────────────────
196
- nodDownDelta: 4, // chin must DROP by this many degrees from baseline
197
- nodReturnFraction: 0.75, // return to 75% of peak nod depth to complete
198
- nodReturnMaxDelta: 9, // cap: never require returning past 9° from baseline
199
- maxYawDuringNod: 32,
194
+ nodDownDelta: 3, // chin must DROP by this many degrees from baseline
195
+ nodReturnFraction: 0.85, // return to 85% of peak nod depth to complete
196
+ nodReturnMaxDelta: 12, // cap: never require returning past 12° from baseline
197
+ maxYawDuringNod: 40,
200
198
 
201
199
  // ── Blink ──────────────────────────────────────────────────────────────────
202
- blinkClosedThreshold: 0.35, // blendshape score = eyes closed
203
- blinkOpenThreshold: 0.20, // blendshape score = eyes open
200
+ blinkClosedThreshold: 0.30, // blendshape score = eyes closed
201
+ blinkOpenThreshold: 0.25, // blendshape score = eyes open
204
202
  earClosedThreshold: 0.20,
205
203
  earOpenThreshold: 0.25,
206
204
  blinkMaxDurationMs: 4000,
207
- maxYawDuringBlink: 25,
208
- maxPitchDuringBlink: 25,
205
+ maxYawDuringBlink: 30,
206
+ maxPitchDuringBlink: 30,
209
207
 
210
208
  // ── Mouth ──────────────────────────────────────────────────────────────────
211
209
  mouthOpenThreshold: 0.20, // jawOpen blendshape
@@ -242,6 +240,7 @@ export class LivenessEngine {
242
240
  private rafId: number | null = null;
243
241
  private stream: MediaStream | null = null;
244
242
 
243
+ private steps: LivenessStep[] = [];
245
244
  private stepIndex = 0;
246
245
  private stepStart = 0;
247
246
 
@@ -261,24 +260,35 @@ export class LivenessEngine {
261
260
  private lastDetectTs = -1;
262
261
  private lastOvalState: boolean | null = null;
263
262
  private stepSoundPlayedForCurrentStep = false;
264
- private currentStepAudio: HTMLAudioElement | null = null;
265
- private currentStepAudioCleanup: (() => void) | null = null;
263
+ private currentAudio: HTMLAudioElement | null = null;
264
+ private currentAudioCleanup: (() => void) | null = null;
265
+ private currentAudioToken = 0;
266
266
  private sessionTimeoutId: number | null = null;
267
267
 
268
268
  constructor(private opts: LivenessOptions) {}
269
269
 
270
270
  private playSound(url: string, onEnded?: () => void): void {
271
+ this.stopAllSounds();
272
+ const token = ++this.currentAudioToken;
271
273
  const a = new Audio(url);
272
- if (onEnded) {
273
- const done = () => {
274
- a.removeEventListener("ended", done);
275
- a.removeEventListener("error", done);
276
- onEnded();
277
- };
278
- a.addEventListener("ended", done);
279
- a.addEventListener("error", done);
280
- }
281
- a.play().catch(() => onEnded?.());
274
+ a.preload = "auto";
275
+ const done = () => {
276
+ if (token !== this.currentAudioToken) return;
277
+ a.removeEventListener("ended", done);
278
+ a.removeEventListener("error", done);
279
+ if (this.currentAudio === a) this.currentAudio = null;
280
+ if (this.currentAudioCleanup === cleanup) this.currentAudioCleanup = null;
281
+ onEnded?.();
282
+ };
283
+ const cleanup = () => {
284
+ a.removeEventListener("ended", done);
285
+ a.removeEventListener("error", done);
286
+ };
287
+ this.currentAudio = a;
288
+ this.currentAudioCleanup = cleanup;
289
+ a.addEventListener("ended", done);
290
+ a.addEventListener("error", done);
291
+ a.play().catch(() => done());
282
292
  }
283
293
 
284
294
  private getSoundUrl(key: string): string | undefined {
@@ -297,33 +307,18 @@ export class LivenessEngine {
297
307
  if (!key) return;
298
308
  const url = this.getSoundUrl(key);
299
309
  if (!url) return;
300
- this.stopStepSound();
301
- const a = new Audio(url);
302
- const done = () => {
303
- a.removeEventListener("ended", done);
304
- a.removeEventListener("error", done);
305
- if (this.currentStepAudio === a) this.currentStepAudio = null;
306
- if (this.currentStepAudioCleanup === cleanup) this.currentStepAudioCleanup = null;
307
- };
308
- const cleanup = () => {
309
- a.removeEventListener("ended", done);
310
- a.removeEventListener("error", done);
311
- };
312
- this.currentStepAudio = a;
313
- this.currentStepAudioCleanup = cleanup;
314
- a.addEventListener("ended", done);
315
- a.addEventListener("error", done);
316
- a.play().catch(() => done());
310
+ this.playSound(url);
317
311
  }
318
312
 
319
- private stopStepSound(): void {
320
- if (this.currentStepAudio) {
321
- this.currentStepAudio.pause();
322
- this.currentStepAudio.currentTime = 0;
313
+ private stopAllSounds(): void {
314
+ this.currentAudioToken += 1;
315
+ if (this.currentAudio) {
316
+ this.currentAudio.pause();
317
+ this.currentAudio.currentTime = 0;
323
318
  }
324
- this.currentStepAudioCleanup?.();
325
- this.currentStepAudioCleanup = null;
326
- this.currentStepAudio = null;
319
+ this.currentAudioCleanup?.();
320
+ this.currentAudioCleanup = null;
321
+ this.currentAudio = null;
327
322
  }
328
323
 
329
324
  private clearSessionTimeout(): void {
@@ -350,6 +345,7 @@ export class LivenessEngine {
350
345
  async start(): Promise<void> {
351
346
  this.stopDetectionOnly();
352
347
  this.running = true;
348
+ this.steps = shuffleArray(STEP_LABELS).map((label, index) => ({ index, label }));
353
349
  this.stepIndex = 0;
354
350
  this.lastDetectTs = -1;
355
351
  this.lastOvalState = null;
@@ -361,7 +357,9 @@ export class LivenessEngine {
361
357
  this.sessionTimeoutId = setTimeout(() => {
362
358
  if (this.running) this.fail("Timed out. Please try again.");
363
359
  }, config.sessionTimeoutMs);
364
- this.opts.callbacks?.onChallengeChanged?.(steps[0].index, steps[0].label);
360
+ if (this.steps.length > 0) {
361
+ this.opts.callbacks?.onChallengeChanged?.(this.steps[0].index, this.steps[0].label);
362
+ }
365
363
  await this.ensureVideo();
366
364
  this.landmarker = await createLandmarkerWithRetry(this.opts, MAX_CDN_RETRIES);
367
365
  this.loop();
@@ -376,7 +374,7 @@ export class LivenessEngine {
376
374
  private stopDetectionOnly(): void {
377
375
  this.running = false;
378
376
  this.clearSessionTimeout();
379
- this.stopStepSound();
377
+ this.stopAllSounds();
380
378
  if (this.rafId != null) { cancelAnimationFrame(this.rafId); this.rafId = null; }
381
379
  if (this.landmarker) { this.landmarker.close(); this.landmarker = null; }
382
380
  }
@@ -455,13 +453,13 @@ export class LivenessEngine {
455
453
 
456
454
  this.opts.callbacks?.onDebugFrame?.({
457
455
  hasFace: true, metrics,
458
- step: steps[this.stepIndex]?.label ?? "done",
456
+ step: this.steps[this.stepIndex]?.label ?? "done",
459
457
  });
460
458
 
461
459
  if (inside) {
462
- if (!this.stepSoundPlayedForCurrentStep && this.stepIndex < steps.length) {
460
+ if (!this.stepSoundPlayedForCurrentStep && this.stepIndex < this.steps.length) {
463
461
  this.stepSoundPlayedForCurrentStep = true;
464
- this.playStepSound(steps[this.stepIndex].label);
462
+ this.playStepSound(this.steps[this.stepIndex].label);
465
463
  }
466
464
  if (this.updateState(metrics, now) === "passed") {
467
465
  this.scheduleCapture();
@@ -475,7 +473,7 @@ export class LivenessEngine {
475
473
  }
476
474
  this.opts.callbacks?.onDebugFrame?.({
477
475
  hasFace: false, metrics: null,
478
- step: steps[this.stepIndex]?.label ?? "done",
476
+ step: this.steps[this.stepIndex]?.label ?? "done",
479
477
  });
480
478
  }
481
479
 
@@ -485,7 +483,7 @@ export class LivenessEngine {
485
483
  // ── Oval check ─────────────────────────────────────────────────────────────
486
484
 
487
485
  private checkFaceInOval(m: Metrics): { inside: boolean; reason?: string } {
488
- const isHeadTurn = config.headTurnSteps.has(steps[this.stepIndex]?.label ?? "");
486
+ const isHeadTurn = config.headTurnSteps.has(this.steps[this.stepIndex]?.label ?? "");
489
487
  const mx = 1 - m.faceCx; // mirror x to match CSS scaleX(-1)
490
488
  const dy = (m.faceCy - config.ovalCy) / config.ovalRy;
491
489
  const dx = (mx - config.ovalCx) / config.ovalRx;
@@ -529,7 +527,7 @@ export class LivenessEngine {
529
527
  const dYaw = metrics.yaw - bYaw;
530
528
  const dPitch = metrics.pitch - bPitch;
531
529
 
532
- switch (steps[this.stepIndex].label) {
530
+ switch (this.steps[this.stepIndex]?.label) {
533
531
 
534
532
  // ── LEFT turn (negative yaw delta = turning left from rest) ─────────────
535
533
  case "Turn your head LEFT": {
@@ -627,18 +625,20 @@ export class LivenessEngine {
627
625
  }
628
626
 
629
627
  private advanceStep(now: number): "passed" | "none" {
630
- this.stopStepSound();
628
+ this.stopAllSounds();
631
629
  this.stepIndex += 1;
632
- if (this.stepIndex >= steps.length) {
630
+ if (this.stepIndex >= this.steps.length) {
633
631
  this.playGoodSound();
634
632
  return "passed";
635
633
  }
636
634
  this.stepStart = now + config.readyMs;
637
635
  this.resetStepState();
638
636
  this.stepSoundPlayedForCurrentStep = true;
639
- const step = steps[this.stepIndex];
640
- this.opts.callbacks?.onChallengeChanged?.(step.index, step.label);
641
- this.playGoodSound(() => this.playStepSound(step.label));
637
+ const step = this.steps[this.stepIndex];
638
+ if (step) {
639
+ this.opts.callbacks?.onChallengeChanged?.(step.index, step.label);
640
+ this.playGoodSound(() => this.playStepSound(step.label));
641
+ }
642
642
  return "none";
643
643
  }
644
644