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