@daboss2003/liveness-web 1.0.2 → 1.0.4

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.js CHANGED
@@ -110,14 +110,14 @@ const config = {
110
110
  // Number of frames averaged to produce the resting baseline per step
111
111
  baselineFrames: 8,
112
112
  // ── Head turns (relative to baseline) ─────────────────────────────────────
113
- yawTurnDelta: 12, // degrees of YAW change needed from rest
113
+ yawTurnDelta: 9, // degrees of YAW change needed from rest
114
114
  yawWrongDirDelta: 16, // block if turned clearly the WRONG way
115
- headTurnHoldMs: 120, // sustain the turned pose for this long
115
+ headTurnHoldMs: 80, // sustain the turned pose for this long
116
116
  // ── Nod (relative to baseline) ────────────────────────────────────────────
117
- nodDownDelta: 8, // chin must DROP by this many degrees from baseline
118
- nodReturnFraction: 0.40, // return to 40% of peak nod depth to complete
119
- nodReturnMaxDelta: 5, // cap: never require returning past 5° from baseline
120
- maxYawDuringNod: 22,
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,
121
121
  // ── Blink ──────────────────────────────────────────────────────────────────
122
122
  blinkClosedThreshold: 0.35, // blendshape score = eyes closed
123
123
  blinkOpenThreshold: 0.20, // blendshape score = eyes open
@@ -127,11 +127,11 @@ const config = {
127
127
  maxYawDuringBlink: 25,
128
128
  maxPitchDuringBlink: 25,
129
129
  // ── Mouth ──────────────────────────────────────────────────────────────────
130
- mouthOpenThreshold: 0.28, // jawOpen blendshape
131
- mouthOpenMarThreshold: 0.28,
132
- mouthHoldMs: 120,
133
- maxYawDuringMouth: 25,
134
- maxPitchDuringMouth: 25,
130
+ mouthOpenThreshold: 0.20, // jawOpen blendshape
131
+ mouthOpenMarThreshold: 0.20,
132
+ mouthHoldMs: 50,
133
+ maxYawDuringMouth: 35,
134
+ maxPitchDuringMouth: 35,
135
135
  // ── Face-in-oval ───────────────────────────────────────────────────────────
136
136
  ovalCx: 0.50,
137
137
  ovalCy: 0.42,
package/dist/ui.js CHANGED
@@ -64,8 +64,58 @@ function createStyles() {
64
64
  object-fit: cover;
65
65
  /* Mirror so it feels like a selfie camera */
66
66
  transform: scaleX(-1);
67
+ opacity: 0;
68
+ transition: opacity 0.2s ease;
67
69
  /* Clip the video to the oval using clip-path on the parent */
68
70
  }
71
+ .lv-video.is-playing { opacity: 1; }
72
+ .lv-video::-webkit-media-controls,
73
+ .lv-video::-webkit-media-controls-panel,
74
+ .lv-video::-webkit-media-controls-play-button,
75
+ .lv-video::-webkit-media-controls-start-playback-button,
76
+ .lv-video::-webkit-media-controls-overlay-play-button,
77
+ .lv-video::-webkit-media-controls-enclosure {
78
+ display: none !important;
79
+ -webkit-appearance: none;
80
+ }
81
+
82
+ .lv-root.lv-is-loading .lv-ring-wrap,
83
+ .lv-root.lv-is-loading .lv-dots,
84
+ .lv-root.lv-is-loading .lv-instruction,
85
+ .lv-root.lv-is-loading .lv-pos-hint,
86
+ .lv-root.lv-is-loading .lv-hint-icon {
87
+ opacity: 0;
88
+ pointer-events: none;
89
+ }
90
+ .lv-root:not(.lv-is-loading) .lv-loading { display: none; }
91
+ .lv-loading {
92
+ position: absolute;
93
+ z-index: 2;
94
+ left: 50%;
95
+ top: ${OVAL_TOP_PCT}%;
96
+ transform: translate(-50%, -50%);
97
+ display: flex;
98
+ flex-direction: column;
99
+ align-items: center;
100
+ gap: 8px;
101
+ text-align: center;
102
+ color: var(--lv-white);
103
+ text-shadow: 0 1px 6px rgba(0,0,0,0.5);
104
+ }
105
+ .lv-spinner {
106
+ width: 32px;
107
+ height: 32px;
108
+ border: 3px solid rgba(255,255,255,0.25);
109
+ border-top-color: var(--lv-white);
110
+ border-radius: 50%;
111
+ animation: lv-spin 0.9s linear infinite;
112
+ }
113
+ .lv-loading-text {
114
+ font-size: 13px;
115
+ font-weight: 500;
116
+ opacity: 0.9;
117
+ }
118
+ @keyframes lv-spin { to { transform: rotate(360deg); } }
69
119
 
70
120
  /* ── Dark overlay with oval cutout ──────────────────────────────────── */
71
121
  .lv-overlay {
@@ -257,7 +307,7 @@ export function startLivenessWithUI(options) {
257
307
  const container = options.container ?? document.body;
258
308
  // ── Root shell ─────────────────────────────────────────────────────────────
259
309
  const root = document.createElement("div");
260
- root.className = "lv-root";
310
+ root.className = "lv-root lv-is-loading";
261
311
  root.appendChild(createStyles());
262
312
  // ── Video background ───────────────────────────────────────────────────────
263
313
  const videoBg = document.createElement("div");
@@ -266,6 +316,7 @@ export function startLivenessWithUI(options) {
266
316
  video.className = "lv-video";
267
317
  video.setAttribute("autoplay", "");
268
318
  video.setAttribute("playsinline", "");
319
+ video.setAttribute("webkit-playsinline", "");
269
320
  video.setAttribute("muted", "");
270
321
  videoBg.appendChild(video);
271
322
  root.appendChild(videoBg);
@@ -292,6 +343,14 @@ export function startLivenessWithUI(options) {
292
343
  hintIcon.className = "lv-hint-icon";
293
344
  hintIcon.setAttribute("aria-hidden", "true");
294
345
  root.appendChild(hintIcon);
346
+ // ── Loading spinner (shown until model is ready) ───────────────────────────
347
+ const loading = document.createElement("div");
348
+ loading.className = "lv-loading";
349
+ loading.innerHTML = `
350
+ <div class="lv-spinner" aria-hidden="true"></div>
351
+ <div class="lv-loading-text">Preparing camera...</div>
352
+ `;
353
+ root.appendChild(loading);
295
354
  // ── Header ─────────────────────────────────────────────────────────────────
296
355
  // const header = document.createElement("div");
297
356
  // header.className = "lv-header";
@@ -323,6 +382,8 @@ export function startLivenessWithUI(options) {
323
382
  const ringEl = ringWrap.querySelector(".lv-ring-progress");
324
383
  const dots = Array.from(dotsEl.querySelectorAll(".lv-dot"));
325
384
  const P = ELLIPSE_PERIMETER;
385
+ let isLoading = true;
386
+ let pendingChallenge = null;
326
387
  // ── UI helpers ──────────────────────────────────────────────────────────────
327
388
  function setProgress(completedSteps) {
328
389
  if (!ringEl)
@@ -355,8 +416,26 @@ export function startLivenessWithUI(options) {
355
416
  posHint.classList.toggle("visible", !inside);
356
417
  posHint.textContent = inside ? "" : (reason ?? "Move your face into the oval");
357
418
  }
419
+ function renderChallenge(stepIndex, stepLabel) {
420
+ if (stepIndex === -1) {
421
+ setProgress(LIVENESS_STEP_COUNT);
422
+ setCapturePulse();
423
+ setHint(null);
424
+ setDots(LIVENESS_STEP_COUNT);
425
+ instruction.textContent = stepLabel;
426
+ return;
427
+ }
428
+ setProgress(stepIndex);
429
+ setDots(stepIndex);
430
+ setHint(stepLabel);
431
+ instruction.textContent = stepLabel;
432
+ ringEl?.classList.remove("lv-ring-pulse");
433
+ }
358
434
  function cleanup() {
359
435
  engine.stop();
436
+ video.removeEventListener("playing", onVideoPlaying);
437
+ video.removeEventListener("pause", onVideoPause);
438
+ video.removeEventListener("waiting", onVideoPause);
360
439
  root.remove();
361
440
  }
362
441
  // ── Engine ─────────────────────────────────────────────────────────────────
@@ -364,6 +443,11 @@ export function startLivenessWithUI(options) {
364
443
  const sounds = options.sounds ?? {
365
444
  ...(Object.keys(DEFAULT_SOUND_DATA_URLS).length > 0 ? DEFAULT_SOUND_DATA_URLS : { baseUrl: "audios/" }),
366
445
  };
446
+ const onVideoPlaying = () => video.classList.add("is-playing");
447
+ const onVideoPause = () => video.classList.remove("is-playing");
448
+ video.addEventListener("playing", onVideoPlaying);
449
+ video.addEventListener("pause", onVideoPause);
450
+ video.addEventListener("waiting", onVideoPause);
367
451
  const engine = new LivenessEngine({
368
452
  videoElement: video,
369
453
  canvasElement: canvas,
@@ -372,20 +456,13 @@ export function startLivenessWithUI(options) {
372
456
  sounds,
373
457
  callbacks: {
374
458
  onChallengeChanged: (stepIndex, stepLabel) => {
375
- if (stepIndex === -1) {
376
- setProgress(LIVENESS_STEP_COUNT);
377
- setCapturePulse();
378
- setHint(null);
379
- setDots(LIVENESS_STEP_COUNT);
380
- instruction.textContent = stepLabel;
381
- return;
459
+ pendingChallenge = { stepIndex, stepLabel };
460
+ if (!isLoading) {
461
+ renderChallenge(stepIndex, stepLabel);
462
+ }
463
+ if (stepIndex !== -1) {
464
+ options.callbacks.onChallengeChanged?.(stepIndex, stepLabel);
382
465
  }
383
- setProgress(stepIndex);
384
- setDots(stepIndex);
385
- setHint(stepLabel);
386
- instruction.textContent = stepLabel;
387
- ringEl?.classList.remove("lv-ring-pulse");
388
- options.callbacks.onChallengeChanged?.(stepIndex, stepLabel);
389
466
  },
390
467
  onFaceInOval: (inside, reason) => {
391
468
  setFaceInOval(inside, reason);
@@ -408,7 +485,12 @@ export function startLivenessWithUI(options) {
408
485
  setProgress(0);
409
486
  setDots(0);
410
487
  setHint(null);
411
- engine.start().then(() => { }, (err) => {
488
+ engine.start().then(() => {
489
+ isLoading = false;
490
+ root.classList.remove("lv-is-loading");
491
+ if (pendingChallenge)
492
+ renderChallenge(pendingChallenge.stepIndex, pendingChallenge.stepLabel);
493
+ }, (err) => {
412
494
  cleanup();
413
495
  const reason = err instanceof LivenessError
414
496
  ? err.code
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daboss2003/liveness-web",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Web liveness detection using MediaPipe Face Landmarker (CDN)",
5
5
  "keywords": [
6
6
  "liveness",
package/src/engine.ts CHANGED
@@ -188,15 +188,15 @@ const config = {
188
188
  baselineFrames: 8,
189
189
 
190
190
  // ── Head turns (relative to baseline) ─────────────────────────────────────
191
- yawTurnDelta: 12, // degrees of YAW change needed from rest
191
+ yawTurnDelta: 9, // degrees of YAW change needed from rest
192
192
  yawWrongDirDelta: 16, // block if turned clearly the WRONG way
193
- headTurnHoldMs: 120, // sustain the turned pose for this long
193
+ headTurnHoldMs: 80, // sustain the turned pose for this long
194
194
 
195
195
  // ── Nod (relative to baseline) ────────────────────────────────────────────
196
- nodDownDelta: 8, // chin must DROP by this many degrees from baseline
197
- nodReturnFraction: 0.40, // return to 40% of peak nod depth to complete
198
- nodReturnMaxDelta: 5, // cap: never require returning past 5° from baseline
199
- maxYawDuringNod: 22,
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,
200
200
 
201
201
  // ── Blink ──────────────────────────────────────────────────────────────────
202
202
  blinkClosedThreshold: 0.35, // blendshape score = eyes closed
@@ -208,11 +208,11 @@ const config = {
208
208
  maxPitchDuringBlink: 25,
209
209
 
210
210
  // ── Mouth ──────────────────────────────────────────────────────────────────
211
- mouthOpenThreshold: 0.28, // jawOpen blendshape
212
- mouthOpenMarThreshold: 0.28,
213
- mouthHoldMs: 120,
214
- maxYawDuringMouth: 25,
215
- maxPitchDuringMouth: 25,
211
+ mouthOpenThreshold: 0.20, // jawOpen blendshape
212
+ mouthOpenMarThreshold: 0.20,
213
+ mouthHoldMs: 50,
214
+ maxYawDuringMouth: 35,
215
+ maxPitchDuringMouth: 35,
216
216
 
217
217
  // ── Face-in-oval ───────────────────────────────────────────────────────────
218
218
  ovalCx: 0.50,
package/src/ui.ts CHANGED
@@ -79,8 +79,58 @@ function createStyles(): HTMLStyleElement {
79
79
  object-fit: cover;
80
80
  /* Mirror so it feels like a selfie camera */
81
81
  transform: scaleX(-1);
82
+ opacity: 0;
83
+ transition: opacity 0.2s ease;
82
84
  /* Clip the video to the oval using clip-path on the parent */
83
85
  }
86
+ .lv-video.is-playing { opacity: 1; }
87
+ .lv-video::-webkit-media-controls,
88
+ .lv-video::-webkit-media-controls-panel,
89
+ .lv-video::-webkit-media-controls-play-button,
90
+ .lv-video::-webkit-media-controls-start-playback-button,
91
+ .lv-video::-webkit-media-controls-overlay-play-button,
92
+ .lv-video::-webkit-media-controls-enclosure {
93
+ display: none !important;
94
+ -webkit-appearance: none;
95
+ }
96
+
97
+ .lv-root.lv-is-loading .lv-ring-wrap,
98
+ .lv-root.lv-is-loading .lv-dots,
99
+ .lv-root.lv-is-loading .lv-instruction,
100
+ .lv-root.lv-is-loading .lv-pos-hint,
101
+ .lv-root.lv-is-loading .lv-hint-icon {
102
+ opacity: 0;
103
+ pointer-events: none;
104
+ }
105
+ .lv-root:not(.lv-is-loading) .lv-loading { display: none; }
106
+ .lv-loading {
107
+ position: absolute;
108
+ z-index: 2;
109
+ left: 50%;
110
+ top: ${OVAL_TOP_PCT}%;
111
+ transform: translate(-50%, -50%);
112
+ display: flex;
113
+ flex-direction: column;
114
+ align-items: center;
115
+ gap: 8px;
116
+ text-align: center;
117
+ color: var(--lv-white);
118
+ text-shadow: 0 1px 6px rgba(0,0,0,0.5);
119
+ }
120
+ .lv-spinner {
121
+ width: 32px;
122
+ height: 32px;
123
+ border: 3px solid rgba(255,255,255,0.25);
124
+ border-top-color: var(--lv-white);
125
+ border-radius: 50%;
126
+ animation: lv-spin 0.9s linear infinite;
127
+ }
128
+ .lv-loading-text {
129
+ font-size: 13px;
130
+ font-weight: 500;
131
+ opacity: 0.9;
132
+ }
133
+ @keyframes lv-spin { to { transform: rotate(360deg); } }
84
134
 
85
135
  /* ── Dark overlay with oval cutout ──────────────────────────────────── */
86
136
  .lv-overlay {
@@ -277,7 +327,7 @@ export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngi
277
327
 
278
328
  // ── Root shell ─────────────────────────────────────────────────────────────
279
329
  const root = document.createElement("div");
280
- root.className = "lv-root";
330
+ root.className = "lv-root lv-is-loading";
281
331
  root.appendChild(createStyles());
282
332
 
283
333
  // ── Video background ───────────────────────────────────────────────────────
@@ -287,6 +337,7 @@ export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngi
287
337
  video.className = "lv-video";
288
338
  video.setAttribute("autoplay", "");
289
339
  video.setAttribute("playsinline", "");
340
+ video.setAttribute("webkit-playsinline", "");
290
341
  video.setAttribute("muted", "");
291
342
  videoBg.appendChild(video);
292
343
  root.appendChild(videoBg);
@@ -317,6 +368,15 @@ export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngi
317
368
  hintIcon.setAttribute("aria-hidden", "true");
318
369
  root.appendChild(hintIcon);
319
370
 
371
+ // ── Loading spinner (shown until model is ready) ───────────────────────────
372
+ const loading = document.createElement("div");
373
+ loading.className = "lv-loading";
374
+ loading.innerHTML = `
375
+ <div class="lv-spinner" aria-hidden="true"></div>
376
+ <div class="lv-loading-text">Preparing camera...</div>
377
+ `;
378
+ root.appendChild(loading);
379
+
320
380
  // ── Header ─────────────────────────────────────────────────────────────────
321
381
  // const header = document.createElement("div");
322
382
  // header.className = "lv-header";
@@ -354,6 +414,8 @@ export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngi
354
414
  const ringEl = ringWrap.querySelector(".lv-ring-progress") as SVGEllipseElement | null;
355
415
  const dots = Array.from(dotsEl.querySelectorAll(".lv-dot"));
356
416
  const P = ELLIPSE_PERIMETER;
417
+ let isLoading = true;
418
+ let pendingChallenge: { stepIndex: number; stepLabel: string } | null = null;
357
419
 
358
420
  // ── UI helpers ──────────────────────────────────────────────────────────────
359
421
 
@@ -389,8 +451,27 @@ export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngi
389
451
  posHint.textContent = inside ? "" : (reason ?? "Move your face into the oval");
390
452
  }
391
453
 
454
+ function renderChallenge(stepIndex: number, stepLabel: string): void {
455
+ if (stepIndex === -1) {
456
+ setProgress(LIVENESS_STEP_COUNT);
457
+ setCapturePulse();
458
+ setHint(null);
459
+ setDots(LIVENESS_STEP_COUNT);
460
+ instruction.textContent = stepLabel;
461
+ return;
462
+ }
463
+ setProgress(stepIndex);
464
+ setDots(stepIndex);
465
+ setHint(stepLabel);
466
+ instruction.textContent = stepLabel;
467
+ ringEl?.classList.remove("lv-ring-pulse");
468
+ }
469
+
392
470
  function cleanup(): void {
393
471
  engine.stop();
472
+ video.removeEventListener("playing", onVideoPlaying);
473
+ video.removeEventListener("pause", onVideoPause);
474
+ video.removeEventListener("waiting", onVideoPause);
394
475
  root.remove();
395
476
  }
396
477
 
@@ -400,6 +481,12 @@ export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngi
400
481
  ...(Object.keys(DEFAULT_SOUND_DATA_URLS).length > 0 ? DEFAULT_SOUND_DATA_URLS : { baseUrl: "audios/" }),
401
482
  };
402
483
 
484
+ const onVideoPlaying = () => video.classList.add("is-playing");
485
+ const onVideoPause = () => video.classList.remove("is-playing");
486
+ video.addEventListener("playing", onVideoPlaying);
487
+ video.addEventListener("pause", onVideoPause);
488
+ video.addEventListener("waiting", onVideoPause);
489
+
403
490
  const engine = new LivenessEngine({
404
491
  videoElement: video,
405
492
  canvasElement: canvas,
@@ -408,20 +495,13 @@ export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngi
408
495
  sounds,
409
496
  callbacks: {
410
497
  onChallengeChanged: (stepIndex, stepLabel) => {
411
- if (stepIndex === -1) {
412
- setProgress(LIVENESS_STEP_COUNT);
413
- setCapturePulse();
414
- setHint(null);
415
- setDots(LIVENESS_STEP_COUNT);
416
- instruction.textContent = stepLabel;
417
- return;
498
+ pendingChallenge = { stepIndex, stepLabel };
499
+ if (!isLoading) {
500
+ renderChallenge(stepIndex, stepLabel);
501
+ }
502
+ if (stepIndex !== -1) {
503
+ options.callbacks.onChallengeChanged?.(stepIndex, stepLabel);
418
504
  }
419
- setProgress(stepIndex);
420
- setDots(stepIndex);
421
- setHint(stepLabel);
422
- instruction.textContent = stepLabel;
423
- ringEl?.classList.remove("lv-ring-pulse");
424
- options.callbacks.onChallengeChanged?.(stepIndex, stepLabel);
425
505
  },
426
506
  onFaceInOval: (inside, reason) => {
427
507
  setFaceInOval(inside, reason);
@@ -447,7 +527,11 @@ export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngi
447
527
  setHint(null);
448
528
 
449
529
  engine.start().then(
450
- () => {},
530
+ () => {
531
+ isLoading = false;
532
+ root.classList.remove("lv-is-loading");
533
+ if (pendingChallenge) renderChallenge(pendingChallenge.stepIndex, pendingChallenge.stepLabel);
534
+ },
451
535
  (err) => {
452
536
  cleanup();
453
537
  const reason =