@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/dist/ui.js CHANGED
@@ -1,14 +1,17 @@
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
- // ── Oval dimensions — keep in sync with engine.ts config.ovalCx/Cy/Rx/Ry ──
4
- const OVAL_W = 270;
5
- const OVAL_H = 360;
6
- const OVAL_TOP_PCT = 40;
7
- // Progress stroke sits on the main oval: ellipse inset by half stroke so outer edge aligns with cutout
8
- const STROKE_HALF = 1.75;
9
- const RX = OVAL_W / 2 - STROKE_HALF;
10
- const RY = OVAL_H / 2 - STROKE_HALF;
11
- const ELLIPSE_PERIMETER = Math.PI * (3 * (RX + RY) - Math.sqrt((3 * RX + RY) * (RX + 3 * RY)));
3
+ const DEFAULT_THEME = {
4
+ progressColor: "#12c95c",
5
+ progressErrorColor: "#ff3b3b",
6
+ progressWidth: 3.5,
7
+ progressLineCap: "round",
8
+ overlayColor: "rgba(0,0,0,0.82)",
9
+ overlayErrorColor: "rgba(180,0,0,0.55)",
10
+ };
11
+ const DEFAULT_MIN_SIZE = 220;
12
+ // Max radii (in CSS px) of the visible shape's cutout — see ─KEY DESIGN note below.
13
+ const OVAL_BASE = { w: 270, h: 360 };
14
+ const CIRCLE_BASE = 270;
12
15
  /** Step label (from engine) → hint icon kind. Use this so the correct icon shows when steps are randomized. */
13
16
  const STEP_LABEL_TO_HINT = {
14
17
  "Turn your head LEFT": "left",
@@ -19,32 +22,44 @@ const STEP_LABEL_TO_HINT = {
19
22
  };
20
23
  // ─────────────────────────────────────────────────────────────────────────────
21
24
  // Styles
25
+ //
26
+ // KEY DESIGN: `--oval-w` and `--oval-h` are RADII (not full dimensions) of the
27
+ // visible cutout, because the CSS radial-gradient mask consumes them as
28
+ // `ellipse <hRadius> <vRadius>`. The SVG ring wrapper is therefore sized to
29
+ // `2 * var(--oval-w)` × `2 * var(--oval-h)` so it aligns to the cutout edge.
22
30
  // ─────────────────────────────────────────────────────────────────────────────
23
- function createStyles() {
31
+ function createStyles(cfg) {
32
+ const { embed, theme, minRadius, dims, ovalTopPct } = cfg;
33
+ const sizeUnit = embed ? "cqmin" : "vmin";
34
+ const pulseHi = (theme.progressWidth * 10 / 7).toFixed(2); // ~1.43× — preserves 3.5→5 ratio of the original
24
35
  const s = document.createElement("style");
25
36
  s.textContent = `
26
- :root {
27
- --lv-green: #12c95c;
28
- --lv-red: #ff3b3b;
29
- --lv-white: #ffffff;
30
- --lv-dark: rgba(0,0,0,0.82);
37
+ .lv-outer {
38
+ position: absolute;
39
+ inset: 0;
40
+ container-type: size;
41
+ overflow: hidden;
31
42
  }
32
43
 
33
44
  .lv-root {
34
- position: fixed;
45
+ position: ${embed ? "absolute" : "fixed"};
35
46
  inset: 0;
36
- z-index: 999999;
37
- background: #000;
47
+ z-index: ${embed ? "1" : "999999"};
48
+ background: ${embed ? "transparent" : "#000"};
38
49
  font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif;
39
- color: var(--lv-white);
50
+ color: #ffffff;
40
51
  overflow: hidden;
41
52
  display: flex;
42
53
  flex-direction: column;
43
54
  align-items: center;
44
- /* Radii: keep oval inside viewport on mobile (diameter = 2× this), cap at desktop */
45
- --oval-w: min(45vmin, ${OVAL_W}px);
46
- --oval-h: min(60vmin, ${OVAL_H}px);
47
- /* Half-height of the visible oval for positioning (oval uses full w/h as radii) */
55
+
56
+ --lv-progress: ${theme.progressColor};
57
+ --lv-progress-error: ${theme.progressErrorColor};
58
+ --lv-overlay: ${theme.overlayColor};
59
+ --lv-overlay-error: ${theme.overlayErrorColor};
60
+
61
+ --oval-w: clamp(${minRadius}px, 45${sizeUnit}, ${dims.wMax}px);
62
+ --oval-h: calc(var(--oval-w) * ${dims.aspectH});
48
63
  --oval-half-h: var(--oval-h);
49
64
  }
50
65
 
@@ -58,15 +73,12 @@ function createStyles() {
58
73
  overflow: hidden;
59
74
  }
60
75
  .lv-video {
61
- /* Fill the container while keeping aspect ratio */
62
76
  width: 100%;
63
77
  height: 100%;
64
78
  object-fit: cover;
65
- /* Mirror so it feels like a selfie camera */
66
79
  transform: scaleX(-1);
67
80
  opacity: 0;
68
81
  transition: opacity 0.2s ease;
69
- /* Clip the video to the oval using clip-path on the parent */
70
82
  }
71
83
  .lv-video.is-playing { opacity: 1; }
72
84
  .lv-video::-webkit-media-controls,
@@ -88,25 +100,35 @@ function createStyles() {
88
100
  pointer-events: none;
89
101
  }
90
102
  .lv-root:not(.lv-is-loading) .lv-loading { display: none; }
103
+
104
+ /* When the host owns instructions, hide every SDK label/animation. Sounds + ring stay. */
105
+ .lv-root.lv-no-instructions .lv-loading,
106
+ .lv-root.lv-no-instructions .lv-dots,
107
+ .lv-root.lv-no-instructions .lv-instruction,
108
+ .lv-root.lv-no-instructions .lv-pos-hint,
109
+ .lv-root.lv-no-instructions .lv-hint-icon {
110
+ display: none !important;
111
+ }
112
+
91
113
  .lv-loading {
92
114
  position: absolute;
93
115
  z-index: 2;
94
116
  left: 50%;
95
- top: ${OVAL_TOP_PCT}%;
117
+ top: ${ovalTopPct}%;
96
118
  transform: translate(-50%, -50%);
97
119
  display: flex;
98
120
  flex-direction: column;
99
121
  align-items: center;
100
122
  gap: 8px;
101
123
  text-align: center;
102
- color: var(--lv-white);
124
+ color: #ffffff;
103
125
  text-shadow: 0 1px 6px rgba(0,0,0,0.5);
104
126
  }
105
127
  .lv-spinner {
106
128
  width: 32px;
107
129
  height: 32px;
108
130
  border: 3px solid rgba(255,255,255,0.25);
109
- border-top-color: var(--lv-white);
131
+ border-top-color: #ffffff;
110
132
  border-radius: 50%;
111
133
  animation: lv-spin 0.9s linear infinite;
112
134
  }
@@ -117,35 +139,31 @@ function createStyles() {
117
139
  }
118
140
  @keyframes lv-spin { to { transform: rotate(360deg); } }
119
141
 
120
- /* ── Dark overlay with oval cutout ──────────────────────────────────── */
142
+ /* ── Dark overlay with shape cutout ────────────────────────────────── */
121
143
  .lv-overlay {
122
144
  position: absolute;
123
145
  inset: 0;
124
- /* Ellipse size (radii): full oval w/h so cutout is large; ring is scaled to match */
125
146
  --ow: var(--oval-w);
126
147
  --oh: var(--oval-h);
127
- background: var(--lv-dark);
148
+ background: var(--lv-overlay);
128
149
  -webkit-mask-image: radial-gradient(
129
- ellipse var(--ow) var(--oh) at 50% ${OVAL_TOP_PCT}%,
150
+ ellipse var(--ow) var(--oh) at 50% ${ovalTopPct}%,
130
151
  transparent 99%, black 100%
131
152
  );
132
153
  mask-image: radial-gradient(
133
- ellipse var(--ow) var(--oh) at 50% ${OVAL_TOP_PCT}%,
154
+ ellipse var(--ow) var(--oh) at 50% ${ovalTopPct}%,
134
155
  transparent 99%, black 100%
135
156
  );
136
157
  pointer-events: none;
137
158
  transition: background 0.25s;
138
159
  }
139
- .lv-overlay.out-of-oval {
140
- /* Tint red when face isn't in position */
141
- background: rgba(180,0,0,0.55);
142
- }
160
+ .lv-overlay.out-of-oval { background: var(--lv-overlay-error); }
143
161
 
144
- /* ── Oval SVG ring (2× so progress sits on the main oval boundary) ───── */
162
+ /* ── Progress ring (2× so its stroke sits on the cutout edge) ──────── */
145
163
  .lv-ring-wrap {
146
164
  position: absolute;
147
165
  left: 50%;
148
- top: ${OVAL_TOP_PCT}%;
166
+ top: ${ovalTopPct}%;
149
167
  transform: translate(-50%, -50%);
150
168
  width: calc(2 * var(--oval-w));
151
169
  height: calc(2 * var(--oval-h));
@@ -154,38 +172,20 @@ function createStyles() {
154
172
  .lv-ring-wrap svg { width: 100%; height: 100%; overflow: visible; }
155
173
  .lv-ring-progress {
156
174
  fill: none;
157
- stroke: var(--lv-green);
158
- stroke-width: 3.5;
159
- stroke-linecap: round;
175
+ stroke: var(--lv-progress);
176
+ stroke-width: ${theme.progressWidth};
177
+ stroke-linecap: ${theme.progressLineCap};
160
178
  transition: stroke-dashoffset 0.45s cubic-bezier(.4,0,.2,1), stroke 0.25s;
161
179
  transform: rotate(0deg);
162
180
  transform-origin: center;
163
181
  }
164
- .lv-ring-progress.out-of-oval { stroke: var(--lv-red); }
165
-
166
- /* ── Top header bar ──────────────────────────────────────────────────── */
167
- .lv-header {
168
- position: relative;
169
- z-index: 2;
170
- width: 100%;
171
- display: flex;
172
- align-items: center;
173
- justify-content: center;
174
- padding: 18px 20px 0;
175
- gap: 10px;
176
- }
177
- .lv-header-title {
178
- font-size: 15px;
179
- font-weight: 600;
180
- letter-spacing: 0.02em;
181
- opacity: 0.9;
182
- }
182
+ .lv-ring-progress.out-of-oval { stroke: var(--lv-progress-error); }
183
183
 
184
- /* ── Step dots ───────────────────────────────────────────────────────── */
184
+ /* ── Step dots ─────────────────────────────────────────────────────── */
185
185
  .lv-dots {
186
186
  position: absolute;
187
187
  z-index: 2;
188
- top: calc(${OVAL_TOP_PCT}% + var(--oval-half-h) + 20px);
188
+ top: calc(${ovalTopPct}% + var(--oval-half-h) + 20px);
189
189
  left: 50%;
190
190
  transform: translateX(-50%);
191
191
  display: flex;
@@ -196,14 +196,14 @@ function createStyles() {
196
196
  background: rgba(255,255,255,0.2);
197
197
  transition: background 0.3s, transform 0.3s;
198
198
  }
199
- .lv-dot.active { background: var(--lv-green); transform: scale(1.3); }
200
- .lv-dot.done { background: var(--lv-green); opacity: .5; transform: scale(1); }
199
+ .lv-dot.active { background: var(--lv-progress); transform: scale(1.3); }
200
+ .lv-dot.done { background: var(--lv-progress); opacity: .5; transform: scale(1); }
201
201
 
202
- /* ── Instruction text ────────────────────────────────────────────────── */
202
+ /* ── Instruction text ──────────────────────────────────────────────── */
203
203
  .lv-instruction {
204
204
  position: absolute;
205
205
  z-index: 2;
206
- top: calc(${OVAL_TOP_PCT}% + var(--oval-half-h) + 52px);
206
+ top: calc(${ovalTopPct}% + var(--oval-half-h) + 52px);
207
207
  left: 50%;
208
208
  transform: translateX(-50%);
209
209
  white-space: nowrap;
@@ -215,16 +215,16 @@ function createStyles() {
215
215
  transition: opacity 0.2s;
216
216
  }
217
217
 
218
- /* ── "Move closer / centre your face" hint ───────────────────────────── */
218
+ /* ── "Move closer / centre your face" hint ─────────────────────────── */
219
219
  .lv-pos-hint {
220
220
  position: absolute;
221
221
  z-index: 2;
222
- top: calc(${OVAL_TOP_PCT}% + var(--oval-half-h) + 84px);
222
+ top: calc(${ovalTopPct}% + var(--oval-half-h) + 84px);
223
223
  left: 50%;
224
224
  transform: translateX(-50%);
225
225
  font-size: 13px;
226
226
  font-weight: 500;
227
- color: var(--lv-red);
227
+ color: var(--lv-progress-error);
228
228
  opacity: 0;
229
229
  white-space: nowrap;
230
230
  text-shadow: 0 1px 6px rgba(0,0,0,0.5);
@@ -233,12 +233,12 @@ function createStyles() {
233
233
  }
234
234
  .lv-pos-hint.visible { opacity: 1; }
235
235
 
236
- /* ── Animated gesture icon ───────────────────────────────────────────── */
236
+ /* ── Animated gesture icon ─────────────────────────────────────────── */
237
237
  .lv-hint-icon {
238
238
  position: absolute;
239
239
  z-index: 2;
240
240
  left: 50%;
241
- top: ${OVAL_TOP_PCT}%;
241
+ top: ${ovalTopPct}%;
242
242
  transform: translate(-50%, -50%);
243
243
  width: 52px;
244
244
  height: 52px;
@@ -250,7 +250,6 @@ function createStyles() {
250
250
  }
251
251
  .lv-hint-icon svg { width: 40px; height: 40px; }
252
252
 
253
- /* Arrow bounce animations */
254
253
  @keyframes lv-left { 0%,100%{transform:translateX(0)} 50%{transform:translateX(-9px)} }
255
254
  @keyframes lv-right { 0%,100%{transform:translateX(0)} 50%{transform:translateX(9px)} }
256
255
  @keyframes lv-down { 0%,100%{transform:translateY(0)} 50%{transform:translateY(8px)} }
@@ -260,7 +259,7 @@ function createStyles() {
260
259
  }
261
260
  @keyframes lv-mouth {
262
261
  0%,60%,100% { transform: scaleY(0.35); }
263
- 30% { transform: scaleY(1); }
262
+ 30% { transform: scaleY(1); }
264
263
  }
265
264
 
266
265
  .lv-anim-left { animation: lv-left 1s ease-in-out infinite; }
@@ -269,14 +268,13 @@ function createStyles() {
269
268
  .lv-eye { animation: lv-blink 2s ease-in-out infinite; transform-origin: center; }
270
269
  .lv-jaw { animation: lv-mouth 1.5s ease-in-out infinite; transform-origin: top center; }
271
270
 
272
- /* ── Capture pulse ──────────────────────────────────────────────────── */
271
+ /* ── Capture pulse ────────────────────────────────────────────────── */
273
272
  @keyframes lv-pulse {
274
- 0%,100% { stroke-width: 3.5; opacity: 1; }
275
- 50% { stroke-width: 5; opacity: 0.55; }
273
+ 0%,100% { stroke-width: ${theme.progressWidth}; opacity: 1; }
274
+ 50% { stroke-width: ${pulseHi}; opacity: 0.55; }
276
275
  }
277
- .lv-ring-pulse { animation: lv-pulse 1s ease-in-out infinite; stroke: var(--lv-green) !important; }
276
+ .lv-ring-pulse { animation: lv-pulse 1s ease-in-out infinite; stroke: var(--lv-progress) !important; }
278
277
 
279
- /* ── Hidden canvas for capture ───────────────────────────────────────── */
280
278
  .lv-canvas {
281
279
  position: absolute;
282
280
  width: 0; height: 0;
@@ -304,11 +302,49 @@ function hintSvg(kind) {
304
302
  // Main factory
305
303
  // ─────────────────────────────────────────────────────────────────────────────
306
304
  export function startLivenessWithUI(options) {
305
+ const embed = !!options.embed;
306
+ const shape = options.shape ?? "oval";
307
+ const showInstructions = options.showInstructions ?? true;
308
+ const theme = { ...DEFAULT_THEME, ...(options.theme ?? {}) };
309
+ const minSize = options.minSize ?? DEFAULT_MIN_SIZE;
310
+ const minRadius = Math.max(20, Math.floor(minSize / 2));
311
+ const dims = shape === "circle"
312
+ ? { wMax: CIRCLE_BASE, hMax: CIRCLE_BASE, aspectH: 1 }
313
+ : { wMax: OVAL_BASE.w, hMax: OVAL_BASE.h, aspectH: OVAL_BASE.h / OVAL_BASE.w };
314
+ // When the host owns instructions, the area below the cutout isn't needed,
315
+ // so we centre the shape vertically. Otherwise keep the original 40% slot.
316
+ const ovalTopPct = showInstructions ? 40 : 50;
317
+ // Ring geometry derives from dims + stroke width
318
+ const SVG_W = dims.wMax;
319
+ const SVG_H = dims.hMax;
320
+ const STROKE_HALF = theme.progressWidth / 2;
321
+ const RX = SVG_W / 2 - STROKE_HALF;
322
+ const RY = SVG_H / 2 - STROKE_HALF;
323
+ const ELLIPSE_PERIMETER = Math.PI * (3 * (RX + RY) - Math.sqrt((3 * RX + RY) * (RX + 3 * RY)));
307
324
  const container = options.container ?? document.body;
325
+ // ── Mount layer (wrapper only needed for embed so cqmin has a sized container) ──
326
+ let outerEl = null;
327
+ let mountPoint;
328
+ if (embed) {
329
+ const computed = getComputedStyle(container);
330
+ if (computed.position === "static" && container !== document.body) {
331
+ container.style.position = "relative";
332
+ }
333
+ const wrapper = document.createElement("div");
334
+ wrapper.className = "lv-outer";
335
+ container.appendChild(wrapper);
336
+ outerEl = wrapper;
337
+ mountPoint = wrapper;
338
+ }
339
+ else {
340
+ mountPoint = container;
341
+ }
308
342
  // ── Root shell ─────────────────────────────────────────────────────────────
309
343
  const root = document.createElement("div");
310
- root.className = "lv-root lv-is-loading";
311
- root.appendChild(createStyles());
344
+ root.className = "lv-root lv-is-loading"
345
+ + (embed ? " lv-embed" : "")
346
+ + (!showInstructions ? " lv-no-instructions" : "");
347
+ root.appendChild(createStyles({ embed, showInstructions, theme, minRadius, dims, ovalTopPct }));
312
348
  // ── Video background ───────────────────────────────────────────────────────
313
349
  const videoBg = document.createElement("div");
314
350
  videoBg.className = "lv-video-bg";
@@ -320,16 +356,16 @@ export function startLivenessWithUI(options) {
320
356
  video.setAttribute("muted", "");
321
357
  videoBg.appendChild(video);
322
358
  root.appendChild(videoBg);
323
- // ── Dark overlay with oval cutout ──────────────────────────────────────────
359
+ // ── Dark overlay with shape cutout ─────────────────────────────────────────
324
360
  const overlay = document.createElement("div");
325
361
  overlay.className = "lv-overlay";
326
362
  root.appendChild(overlay);
327
- // ── Progress ring (ellipse pathLength = perimeter so dash offset matches) ───
328
- const rx = OVAL_W / 2, ry = OVAL_H / 2;
363
+ // ── Progress ring ──────────────────────────────────────────────────────────
364
+ const rx = SVG_W / 2, ry = SVG_H / 2;
329
365
  const ringWrap = document.createElement("div");
330
366
  ringWrap.className = "lv-ring-wrap";
331
367
  ringWrap.innerHTML = `
332
- <svg viewBox="0 0 ${OVAL_W} ${OVAL_H}">
368
+ <svg viewBox="0 0 ${SVG_W} ${SVG_H}">
333
369
  <ellipse class="lv-ring-progress"
334
370
  cx="${rx}" cy="${ry}" rx="${RX}" ry="${RY}"
335
371
  pathLength="${ELLIPSE_PERIMETER.toFixed(1)}"
@@ -338,12 +374,12 @@ export function startLivenessWithUI(options) {
338
374
  transform="rotate(-90 ${rx} ${ry})"/>
339
375
  </svg>`;
340
376
  root.appendChild(ringWrap);
341
- // ── Gesture hint icon (inside the oval) ────────────────────────────────────
377
+ // ── Gesture hint icon ──────────────────────────────────────────────────────
342
378
  const hintIcon = document.createElement("div");
343
379
  hintIcon.className = "lv-hint-icon";
344
380
  hintIcon.setAttribute("aria-hidden", "true");
345
381
  root.appendChild(hintIcon);
346
- // ── Loading spinner (shown until model is ready) ───────────────────────────
382
+ // ── Loading spinner ────────────────────────────────────────────────────────
347
383
  const loading = document.createElement("div");
348
384
  loading.className = "lv-loading";
349
385
  loading.innerHTML = `
@@ -351,15 +387,12 @@ export function startLivenessWithUI(options) {
351
387
  <div class="lv-loading-text">Preparing camera...</div>
352
388
  `;
353
389
  root.appendChild(loading);
354
- // ── Header ─────────────────────────────────────────────────────────────────
355
- // const header = document.createElement("div");
356
- // header.className = "lv-header";
357
- // header.innerHTML = `<span class="lv-header-title">Face Verification</span>`;
358
- // root.appendChild(header);
359
390
  // ── Step dots ──────────────────────────────────────────────────────────────
391
+ // Count derives from the active subset (`options.steps`), defaulting to all 5.
392
+ const sessionStepCount = resolveStepLabels(options.steps).length;
360
393
  const dotsEl = document.createElement("div");
361
394
  dotsEl.className = "lv-dots";
362
- for (let i = 0; i < LIVENESS_STEP_COUNT; i++) {
395
+ for (let i = 0; i < sessionStepCount; i++) {
363
396
  const dot = document.createElement("div");
364
397
  dot.className = "lv-dot" + (i === 0 ? " active" : "");
365
398
  dotsEl.appendChild(dot);
@@ -370,7 +403,7 @@ export function startLivenessWithUI(options) {
370
403
  instruction.className = "lv-instruction";
371
404
  instruction.textContent = "Position your face in the oval";
372
405
  root.appendChild(instruction);
373
- // ── Position hint (shows when face is out of oval) ─────────────────────────
406
+ // ── Position hint ──────────────────────────────────────────────────────────
374
407
  const posHint = document.createElement("div");
375
408
  posHint.className = "lv-pos-hint";
376
409
  root.appendChild(posHint);
@@ -378,7 +411,8 @@ export function startLivenessWithUI(options) {
378
411
  const canvas = document.createElement("canvas");
379
412
  canvas.className = "lv-canvas";
380
413
  root.appendChild(canvas);
381
- container.appendChild(root);
414
+ mountPoint.appendChild(root);
415
+ const topEl = outerEl ?? root;
382
416
  const ringEl = ringWrap.querySelector(".lv-ring-progress");
383
417
  const dots = Array.from(dotsEl.querySelectorAll(".lv-dot"));
384
418
  const P = ELLIPSE_PERIMETER;
@@ -388,7 +422,7 @@ export function startLivenessWithUI(options) {
388
422
  function setProgress(completedSteps) {
389
423
  if (!ringEl)
390
424
  return;
391
- const filled = Math.min(completedSteps / LIVENESS_STEP_COUNT, 1);
425
+ const filled = Math.min(completedSteps / sessionStepCount, 1);
392
426
  const offset = P * (1 - filled);
393
427
  ringEl.setAttribute("stroke-dashoffset", offset.toFixed(1));
394
428
  }
@@ -418,10 +452,10 @@ export function startLivenessWithUI(options) {
418
452
  }
419
453
  function renderChallenge(stepIndex, stepLabel) {
420
454
  if (stepIndex === -1) {
421
- setProgress(LIVENESS_STEP_COUNT);
455
+ setProgress(sessionStepCount);
422
456
  setCapturePulse();
423
457
  setHint(null);
424
- setDots(LIVENESS_STEP_COUNT);
458
+ setDots(sessionStepCount);
425
459
  instruction.textContent = stepLabel;
426
460
  return;
427
461
  }
@@ -436,7 +470,7 @@ export function startLivenessWithUI(options) {
436
470
  video.removeEventListener("playing", onVideoPlaying);
437
471
  video.removeEventListener("pause", onVideoPause);
438
472
  video.removeEventListener("waiting", onVideoPause);
439
- root.remove();
473
+ topEl.remove();
440
474
  }
441
475
  // ── Engine ─────────────────────────────────────────────────────────────────
442
476
  // Use options.sounds if provided; otherwise use embedded default sounds (works in any host)
@@ -454,6 +488,8 @@ export function startLivenessWithUI(options) {
454
488
  modelUrl: options.modelUrl,
455
489
  wasmUrl: options.wasmUrl,
456
490
  sounds,
491
+ steps: options.steps,
492
+ shuffleSteps: options.shuffleSteps,
457
493
  callbacks: {
458
494
  onChallengeChanged: (stepIndex, stepLabel) => {
459
495
  pendingChallenge = { stepIndex, stepLabel };
@@ -473,7 +509,7 @@ export function startLivenessWithUI(options) {
473
509
  options.callbacks.onFailure?.(reason);
474
510
  },
475
511
  onSuccess: (imageBase64) => {
476
- setProgress(LIVENESS_STEP_COUNT);
512
+ setProgress(sessionStepCount);
477
513
  cleanup();
478
514
  options.callbacks.onSuccess?.(imageBase64);
479
515
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daboss2003/liveness-web",
3
- "version": "1.0.6",
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;