@daboss2003/liveness-web 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,6 +66,44 @@ startLiveness({
66
66
  });
67
67
  ```
68
68
 
69
+ ## Embedding in your own UI
70
+
71
+ By default, `startLiveness` takes over the viewport and renders its own oval frame, step dots, and instructions. If you want to drop just the camera + face frame into a slot in your own designed page (custom button, branding, copy), pass:
72
+
73
+ ```ts
74
+ startLiveness({
75
+ container: mySlotEl, // your sized div (give it explicit width & height)
76
+ embed: true, // render inside container instead of full-screen
77
+ shape: "circle", // or "oval" (default)
78
+ showInstructions: false, // hide built-in text/dots — your UI shows them
79
+ minSize: 240, // floor on shape diameter (px) so small screens stay usable
80
+ theme: {
81
+ progressColor: "#1a0f4d",
82
+ progressErrorColor: "#ff3b3b",
83
+ progressWidth: 4,
84
+ progressLineCap: "round",
85
+ overlayColor: "#5b34d6", // fill outside the cutout — match your bg
86
+ overlayErrorColor: "rgba(180,0,0,0.6)",
87
+ },
88
+ callbacks: {
89
+ onChallengeChanged: (i, label) => myStatus.textContent = label,
90
+ onFaceInOval: (inside, reason) => { if (!inside) myStatus.textContent = reason ?? ""; },
91
+ onSuccess, onFailure,
92
+ },
93
+ });
94
+ ```
95
+
96
+ Sounds still play in `embed` mode and with `showInstructions: false` — only the visuals are hidden.
97
+
98
+ A complete working example (yellow page, purple circle slot, host-owned Start button) lives at [`demo-apps/web/embed.html`](../demo-apps/web/embed.html) + [`demo-apps/web/src/embed.ts`](../demo-apps/web/src/embed.ts). Run it with:
99
+
100
+ ```bash
101
+ cd demo-apps/web && npm install && npm run dev
102
+ # open http://localhost:5173/embed.html
103
+ ```
104
+
105
+ All new options are optional — omitting them gives you the original full-screen oval behaviour.
106
+
69
107
  ## Notes
70
108
 
71
109
  - Uses MediaPipe Tasks Vision Web from CDN.
package/dist/engine.d.ts CHANGED
@@ -72,14 +72,15 @@ export declare class LivenessEngine {
72
72
  private lastDetectTs;
73
73
  private lastOvalState;
74
74
  private stepSoundPlayedForCurrentStep;
75
- private currentStepAudio;
76
- private currentStepAudioCleanup;
75
+ private currentAudio;
76
+ private currentAudioCleanup;
77
+ private currentAudioToken;
77
78
  private sessionTimeoutId;
78
79
  constructor(opts: LivenessOptions);
79
80
  private playSound;
80
81
  private getSoundUrl;
81
82
  private playStepSound;
82
- private stopStepSound;
83
+ private stopAllSounds;
83
84
  private clearSessionTimeout;
84
85
  private playGoodSound;
85
86
  private playCaptureSound;
package/dist/engine.js CHANGED
@@ -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) {
@@ -292,7 +289,7 @@ export class LivenessEngine {
292
289
  stopDetectionOnly() {
293
290
  this.running = false;
294
291
  this.clearSessionTimeout();
295
- this.stopStepSound();
292
+ this.stopAllSounds();
296
293
  if (this.rafId != null) {
297
294
  cancelAnimationFrame(this.rafId);
298
295
  this.rafId = null;
@@ -532,7 +529,7 @@ export class LivenessEngine {
532
529
  return "none";
533
530
  }
534
531
  advanceStep(now) {
535
- this.stopStepSound();
532
+ this.stopAllSounds();
536
533
  this.stepIndex += 1;
537
534
  if (this.stepIndex >= this.steps.length) {
538
535
  this.playGoodSound();
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { LivenessEngine } from "./engine";
2
- export type { StartLivenessOptions } from "./ui";
2
+ export type { StartLivenessOptions, LivenessTheme } from "./ui";
3
3
  export { DEFAULT_MODEL_URL, DEFAULT_WASM_URL, LIVENESS_ERROR_CDN_NOT_AVAILABLE, LIVENESS_ERROR_OFFLINE, LIVENESS_STEP_COUNT, isCdnNotAvailableError, isOfflineError, LivenessError, } from "./engine";
4
4
  export type { LivenessCallbacks, LivenessOptions, LivenessSoundOptions } from "./engine";
5
5
  export type { LivenessEngine } from "./engine";
package/dist/ui.d.ts CHANGED
@@ -1,6 +1,23 @@
1
1
  import { LivenessEngine, LivenessCallbacks, LivenessSoundOptions } from "./engine";
2
+ export type LivenessTheme = {
3
+ progressColor?: string;
4
+ progressErrorColor?: string;
5
+ progressWidth?: number;
6
+ progressLineCap?: "round" | "square" | "butt";
7
+ overlayColor?: string;
8
+ overlayErrorColor?: string;
9
+ };
2
10
  export type StartLivenessOptions = {
3
11
  container?: HTMLElement;
12
+ /** If true, render inside `container` instead of taking over the viewport. Container should have explicit width and height. */
13
+ embed?: boolean;
14
+ /** Visible face frame shape. Defaults to "oval". */
15
+ shape?: "oval" | "circle";
16
+ /** If false, hides SDK-provided instruction text, step dots, position hint, gesture icon and loading text. Sounds still play. Default true. */
17
+ showInstructions?: boolean;
18
+ theme?: LivenessTheme;
19
+ /** Minimum diameter of the visible shape in CSS pixels (so it stays usable on small screens). Default 220. */
20
+ minSize?: number;
4
21
  modelUrl?: string;
5
22
  wasmUrl?: string;
6
23
  callbacks: LivenessCallbacks;
package/dist/ui.js CHANGED
@@ -1,14 +1,17 @@
1
1
  import { LivenessEngine, LIVENESS_STEP_COUNT, LivenessError } 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,11 +387,6 @@ 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 ──────────────────────────────────────────────────────────────
360
391
  const dotsEl = document.createElement("div");
361
392
  dotsEl.className = "lv-dots";
@@ -370,7 +401,7 @@ export function startLivenessWithUI(options) {
370
401
  instruction.className = "lv-instruction";
371
402
  instruction.textContent = "Position your face in the oval";
372
403
  root.appendChild(instruction);
373
- // ── Position hint (shows when face is out of oval) ─────────────────────────
404
+ // ── Position hint ──────────────────────────────────────────────────────────
374
405
  const posHint = document.createElement("div");
375
406
  posHint.className = "lv-pos-hint";
376
407
  root.appendChild(posHint);
@@ -378,7 +409,8 @@ export function startLivenessWithUI(options) {
378
409
  const canvas = document.createElement("canvas");
379
410
  canvas.className = "lv-canvas";
380
411
  root.appendChild(canvas);
381
- container.appendChild(root);
412
+ mountPoint.appendChild(root);
413
+ const topEl = outerEl ?? root;
382
414
  const ringEl = ringWrap.querySelector(".lv-ring-progress");
383
415
  const dots = Array.from(dotsEl.querySelectorAll(".lv-dot"));
384
416
  const P = ELLIPSE_PERIMETER;
@@ -436,7 +468,7 @@ export function startLivenessWithUI(options) {
436
468
  video.removeEventListener("playing", onVideoPlaying);
437
469
  video.removeEventListener("pause", onVideoPause);
438
470
  video.removeEventListener("waiting", onVideoPause);
439
- root.remove();
471
+ topEl.remove();
440
472
  }
441
473
  // ── Engine ─────────────────────────────────────────────────────────────────
442
474
  // Use options.sounds if provided; otherwise use embedded default sounds (works in any host)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daboss2003/liveness-web",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Web liveness detection using MediaPipe Face Landmarker (CDN)",
5
5
  "keywords": [
6
6
  "liveness",
package/src/engine.ts CHANGED
@@ -260,24 +260,35 @@ export class LivenessEngine {
260
260
  private lastDetectTs = -1;
261
261
  private lastOvalState: boolean | null = null;
262
262
  private stepSoundPlayedForCurrentStep = false;
263
- private currentStepAudio: HTMLAudioElement | null = null;
264
- private currentStepAudioCleanup: (() => void) | null = null;
263
+ private currentAudio: HTMLAudioElement | null = null;
264
+ private currentAudioCleanup: (() => void) | null = null;
265
+ private currentAudioToken = 0;
265
266
  private sessionTimeoutId: number | null = null;
266
267
 
267
268
  constructor(private opts: LivenessOptions) {}
268
269
 
269
270
  private playSound(url: string, onEnded?: () => void): void {
271
+ this.stopAllSounds();
272
+ const token = ++this.currentAudioToken;
270
273
  const a = new Audio(url);
271
- if (onEnded) {
272
- const done = () => {
273
- a.removeEventListener("ended", done);
274
- a.removeEventListener("error", done);
275
- onEnded();
276
- };
277
- a.addEventListener("ended", done);
278
- a.addEventListener("error", done);
279
- }
280
- 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());
281
292
  }
282
293
 
283
294
  private getSoundUrl(key: string): string | undefined {
@@ -296,33 +307,18 @@ export class LivenessEngine {
296
307
  if (!key) return;
297
308
  const url = this.getSoundUrl(key);
298
309
  if (!url) return;
299
- this.stopStepSound();
300
- const a = new Audio(url);
301
- const done = () => {
302
- a.removeEventListener("ended", done);
303
- a.removeEventListener("error", done);
304
- if (this.currentStepAudio === a) this.currentStepAudio = null;
305
- if (this.currentStepAudioCleanup === cleanup) this.currentStepAudioCleanup = null;
306
- };
307
- const cleanup = () => {
308
- a.removeEventListener("ended", done);
309
- a.removeEventListener("error", done);
310
- };
311
- this.currentStepAudio = a;
312
- this.currentStepAudioCleanup = cleanup;
313
- a.addEventListener("ended", done);
314
- a.addEventListener("error", done);
315
- a.play().catch(() => done());
310
+ this.playSound(url);
316
311
  }
317
312
 
318
- private stopStepSound(): void {
319
- if (this.currentStepAudio) {
320
- this.currentStepAudio.pause();
321
- 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;
322
318
  }
323
- this.currentStepAudioCleanup?.();
324
- this.currentStepAudioCleanup = null;
325
- this.currentStepAudio = null;
319
+ this.currentAudioCleanup?.();
320
+ this.currentAudioCleanup = null;
321
+ this.currentAudio = null;
326
322
  }
327
323
 
328
324
  private clearSessionTimeout(): void {
@@ -378,7 +374,7 @@ export class LivenessEngine {
378
374
  private stopDetectionOnly(): void {
379
375
  this.running = false;
380
376
  this.clearSessionTimeout();
381
- this.stopStepSound();
377
+ this.stopAllSounds();
382
378
  if (this.rafId != null) { cancelAnimationFrame(this.rafId); this.rafId = null; }
383
379
  if (this.landmarker) { this.landmarker.close(); this.landmarker = null; }
384
380
  }
@@ -629,7 +625,7 @@ export class LivenessEngine {
629
625
  }
630
626
 
631
627
  private advanceStep(now: number): "passed" | "none" {
632
- this.stopStepSound();
628
+ this.stopAllSounds();
633
629
  this.stepIndex += 1;
634
630
  if (this.stepIndex >= this.steps.length) {
635
631
  this.playGoodSound();
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,
package/src/ui.ts CHANGED
@@ -1,24 +1,45 @@
1
1
  import { LivenessEngine, LIVENESS_STEP_COUNT, LivenessCallbacks, LivenessSoundOptions, LivenessError } 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;
10
28
  };
11
29
 
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;
30
+ const DEFAULT_THEME: Required<LivenessTheme> = {
31
+ progressColor: "#12c95c",
32
+ progressErrorColor: "#ff3b3b",
33
+ progressWidth: 3.5,
34
+ progressLineCap: "round",
35
+ overlayColor: "rgba(0,0,0,0.82)",
36
+ overlayErrorColor: "rgba(180,0,0,0.55)",
37
+ };
38
+ const DEFAULT_MIN_SIZE = 220;
16
39
 
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)));
40
+ // Max radii (in CSS px) of the visible shape's cutout — see ─KEY DESIGN note below.
41
+ const OVAL_BASE = { w: 270, h: 360 };
42
+ const CIRCLE_BASE = 270;
22
43
 
23
44
  type HintKind = "left" | "blink" | "right" | "nod" | "mouth";
24
45
 
@@ -31,35 +52,56 @@ const STEP_LABEL_TO_HINT: Record<string, HintKind> = {
31
52
  "Open your mouth": "mouth",
32
53
  };
33
54
 
55
+ type StyleConfig = {
56
+ embed: boolean;
57
+ showInstructions: boolean;
58
+ theme: Required<LivenessTheme>;
59
+ minRadius: number;
60
+ dims: { wMax: number; hMax: number; aspectH: number };
61
+ ovalTopPct: number;
62
+ };
63
+
34
64
  // ─────────────────────────────────────────────────────────────────────────────
35
65
  // Styles
66
+ //
67
+ // KEY DESIGN: `--oval-w` and `--oval-h` are RADII (not full dimensions) of the
68
+ // visible cutout, because the CSS radial-gradient mask consumes them as
69
+ // `ellipse <hRadius> <vRadius>`. The SVG ring wrapper is therefore sized to
70
+ // `2 * var(--oval-w)` × `2 * var(--oval-h)` so it aligns to the cutout edge.
36
71
  // ─────────────────────────────────────────────────────────────────────────────
37
72
 
38
- function createStyles(): HTMLStyleElement {
73
+ function createStyles(cfg: StyleConfig): HTMLStyleElement {
74
+ const { embed, theme, minRadius, dims, ovalTopPct } = cfg;
75
+ const sizeUnit = embed ? "cqmin" : "vmin";
76
+ const pulseHi = (theme.progressWidth * 10 / 7).toFixed(2); // ~1.43× — preserves 3.5→5 ratio of the original
39
77
  const s = document.createElement("style");
40
78
  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);
79
+ .lv-outer {
80
+ position: absolute;
81
+ inset: 0;
82
+ container-type: size;
83
+ overflow: hidden;
46
84
  }
47
85
 
48
86
  .lv-root {
49
- position: fixed;
87
+ position: ${embed ? "absolute" : "fixed"};
50
88
  inset: 0;
51
- z-index: 999999;
52
- background: #000;
89
+ z-index: ${embed ? "1" : "999999"};
90
+ background: ${embed ? "transparent" : "#000"};
53
91
  font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif;
54
- color: var(--lv-white);
92
+ color: #ffffff;
55
93
  overflow: hidden;
56
94
  display: flex;
57
95
  flex-direction: column;
58
96
  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) */
97
+
98
+ --lv-progress: ${theme.progressColor};
99
+ --lv-progress-error: ${theme.progressErrorColor};
100
+ --lv-overlay: ${theme.overlayColor};
101
+ --lv-overlay-error: ${theme.overlayErrorColor};
102
+
103
+ --oval-w: clamp(${minRadius}px, 45${sizeUnit}, ${dims.wMax}px);
104
+ --oval-h: calc(var(--oval-w) * ${dims.aspectH});
63
105
  --oval-half-h: var(--oval-h);
64
106
  }
65
107
 
@@ -73,15 +115,12 @@ function createStyles(): HTMLStyleElement {
73
115
  overflow: hidden;
74
116
  }
75
117
  .lv-video {
76
- /* Fill the container while keeping aspect ratio */
77
118
  width: 100%;
78
119
  height: 100%;
79
120
  object-fit: cover;
80
- /* Mirror so it feels like a selfie camera */
81
121
  transform: scaleX(-1);
82
122
  opacity: 0;
83
123
  transition: opacity 0.2s ease;
84
- /* Clip the video to the oval using clip-path on the parent */
85
124
  }
86
125
  .lv-video.is-playing { opacity: 1; }
87
126
  .lv-video::-webkit-media-controls,
@@ -103,25 +142,35 @@ function createStyles(): HTMLStyleElement {
103
142
  pointer-events: none;
104
143
  }
105
144
  .lv-root:not(.lv-is-loading) .lv-loading { display: none; }
145
+
146
+ /* When the host owns instructions, hide every SDK label/animation. Sounds + ring stay. */
147
+ .lv-root.lv-no-instructions .lv-loading,
148
+ .lv-root.lv-no-instructions .lv-dots,
149
+ .lv-root.lv-no-instructions .lv-instruction,
150
+ .lv-root.lv-no-instructions .lv-pos-hint,
151
+ .lv-root.lv-no-instructions .lv-hint-icon {
152
+ display: none !important;
153
+ }
154
+
106
155
  .lv-loading {
107
156
  position: absolute;
108
157
  z-index: 2;
109
158
  left: 50%;
110
- top: ${OVAL_TOP_PCT}%;
159
+ top: ${ovalTopPct}%;
111
160
  transform: translate(-50%, -50%);
112
161
  display: flex;
113
162
  flex-direction: column;
114
163
  align-items: center;
115
164
  gap: 8px;
116
165
  text-align: center;
117
- color: var(--lv-white);
166
+ color: #ffffff;
118
167
  text-shadow: 0 1px 6px rgba(0,0,0,0.5);
119
168
  }
120
169
  .lv-spinner {
121
170
  width: 32px;
122
171
  height: 32px;
123
172
  border: 3px solid rgba(255,255,255,0.25);
124
- border-top-color: var(--lv-white);
173
+ border-top-color: #ffffff;
125
174
  border-radius: 50%;
126
175
  animation: lv-spin 0.9s linear infinite;
127
176
  }
@@ -132,35 +181,31 @@ function createStyles(): HTMLStyleElement {
132
181
  }
133
182
  @keyframes lv-spin { to { transform: rotate(360deg); } }
134
183
 
135
- /* ── Dark overlay with oval cutout ──────────────────────────────────── */
184
+ /* ── Dark overlay with shape cutout ────────────────────────────────── */
136
185
  .lv-overlay {
137
186
  position: absolute;
138
187
  inset: 0;
139
- /* Ellipse size (radii): full oval w/h so cutout is large; ring is scaled to match */
140
188
  --ow: var(--oval-w);
141
189
  --oh: var(--oval-h);
142
- background: var(--lv-dark);
190
+ background: var(--lv-overlay);
143
191
  -webkit-mask-image: radial-gradient(
144
- ellipse var(--ow) var(--oh) at 50% ${OVAL_TOP_PCT}%,
192
+ ellipse var(--ow) var(--oh) at 50% ${ovalTopPct}%,
145
193
  transparent 99%, black 100%
146
194
  );
147
195
  mask-image: radial-gradient(
148
- ellipse var(--ow) var(--oh) at 50% ${OVAL_TOP_PCT}%,
196
+ ellipse var(--ow) var(--oh) at 50% ${ovalTopPct}%,
149
197
  transparent 99%, black 100%
150
198
  );
151
199
  pointer-events: none;
152
200
  transition: background 0.25s;
153
201
  }
154
- .lv-overlay.out-of-oval {
155
- /* Tint red when face isn't in position */
156
- background: rgba(180,0,0,0.55);
157
- }
202
+ .lv-overlay.out-of-oval { background: var(--lv-overlay-error); }
158
203
 
159
- /* ── Oval SVG ring (2× so progress sits on the main oval boundary) ───── */
204
+ /* ── Progress ring (2× so its stroke sits on the cutout edge) ──────── */
160
205
  .lv-ring-wrap {
161
206
  position: absolute;
162
207
  left: 50%;
163
- top: ${OVAL_TOP_PCT}%;
208
+ top: ${ovalTopPct}%;
164
209
  transform: translate(-50%, -50%);
165
210
  width: calc(2 * var(--oval-w));
166
211
  height: calc(2 * var(--oval-h));
@@ -169,38 +214,20 @@ function createStyles(): HTMLStyleElement {
169
214
  .lv-ring-wrap svg { width: 100%; height: 100%; overflow: visible; }
170
215
  .lv-ring-progress {
171
216
  fill: none;
172
- stroke: var(--lv-green);
173
- stroke-width: 3.5;
174
- stroke-linecap: round;
217
+ stroke: var(--lv-progress);
218
+ stroke-width: ${theme.progressWidth};
219
+ stroke-linecap: ${theme.progressLineCap};
175
220
  transition: stroke-dashoffset 0.45s cubic-bezier(.4,0,.2,1), stroke 0.25s;
176
221
  transform: rotate(0deg);
177
222
  transform-origin: center;
178
223
  }
179
- .lv-ring-progress.out-of-oval { stroke: var(--lv-red); }
224
+ .lv-ring-progress.out-of-oval { stroke: var(--lv-progress-error); }
180
225
 
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 ───────────────────────────────────────────────────────── */
226
+ /* ── Step dots ─────────────────────────────────────────────────────── */
200
227
  .lv-dots {
201
228
  position: absolute;
202
229
  z-index: 2;
203
- top: calc(${OVAL_TOP_PCT}% + var(--oval-half-h) + 20px);
230
+ top: calc(${ovalTopPct}% + var(--oval-half-h) + 20px);
204
231
  left: 50%;
205
232
  transform: translateX(-50%);
206
233
  display: flex;
@@ -211,14 +238,14 @@ function createStyles(): HTMLStyleElement {
211
238
  background: rgba(255,255,255,0.2);
212
239
  transition: background 0.3s, transform 0.3s;
213
240
  }
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); }
241
+ .lv-dot.active { background: var(--lv-progress); transform: scale(1.3); }
242
+ .lv-dot.done { background: var(--lv-progress); opacity: .5; transform: scale(1); }
216
243
 
217
- /* ── Instruction text ────────────────────────────────────────────────── */
244
+ /* ── Instruction text ──────────────────────────────────────────────── */
218
245
  .lv-instruction {
219
246
  position: absolute;
220
247
  z-index: 2;
221
- top: calc(${OVAL_TOP_PCT}% + var(--oval-half-h) + 52px);
248
+ top: calc(${ovalTopPct}% + var(--oval-half-h) + 52px);
222
249
  left: 50%;
223
250
  transform: translateX(-50%);
224
251
  white-space: nowrap;
@@ -230,16 +257,16 @@ function createStyles(): HTMLStyleElement {
230
257
  transition: opacity 0.2s;
231
258
  }
232
259
 
233
- /* ── "Move closer / centre your face" hint ───────────────────────────── */
260
+ /* ── "Move closer / centre your face" hint ─────────────────────────── */
234
261
  .lv-pos-hint {
235
262
  position: absolute;
236
263
  z-index: 2;
237
- top: calc(${OVAL_TOP_PCT}% + var(--oval-half-h) + 84px);
264
+ top: calc(${ovalTopPct}% + var(--oval-half-h) + 84px);
238
265
  left: 50%;
239
266
  transform: translateX(-50%);
240
267
  font-size: 13px;
241
268
  font-weight: 500;
242
- color: var(--lv-red);
269
+ color: var(--lv-progress-error);
243
270
  opacity: 0;
244
271
  white-space: nowrap;
245
272
  text-shadow: 0 1px 6px rgba(0,0,0,0.5);
@@ -248,12 +275,12 @@ function createStyles(): HTMLStyleElement {
248
275
  }
249
276
  .lv-pos-hint.visible { opacity: 1; }
250
277
 
251
- /* ── Animated gesture icon ───────────────────────────────────────────── */
278
+ /* ── Animated gesture icon ─────────────────────────────────────────── */
252
279
  .lv-hint-icon {
253
280
  position: absolute;
254
281
  z-index: 2;
255
282
  left: 50%;
256
- top: ${OVAL_TOP_PCT}%;
283
+ top: ${ovalTopPct}%;
257
284
  transform: translate(-50%, -50%);
258
285
  width: 52px;
259
286
  height: 52px;
@@ -265,7 +292,6 @@ function createStyles(): HTMLStyleElement {
265
292
  }
266
293
  .lv-hint-icon svg { width: 40px; height: 40px; }
267
294
 
268
- /* Arrow bounce animations */
269
295
  @keyframes lv-left { 0%,100%{transform:translateX(0)} 50%{transform:translateX(-9px)} }
270
296
  @keyframes lv-right { 0%,100%{transform:translateX(0)} 50%{transform:translateX(9px)} }
271
297
  @keyframes lv-down { 0%,100%{transform:translateY(0)} 50%{transform:translateY(8px)} }
@@ -275,7 +301,7 @@ function createStyles(): HTMLStyleElement {
275
301
  }
276
302
  @keyframes lv-mouth {
277
303
  0%,60%,100% { transform: scaleY(0.35); }
278
- 30% { transform: scaleY(1); }
304
+ 30% { transform: scaleY(1); }
279
305
  }
280
306
 
281
307
  .lv-anim-left { animation: lv-left 1s ease-in-out infinite; }
@@ -284,14 +310,13 @@ function createStyles(): HTMLStyleElement {
284
310
  .lv-eye { animation: lv-blink 2s ease-in-out infinite; transform-origin: center; }
285
311
  .lv-jaw { animation: lv-mouth 1.5s ease-in-out infinite; transform-origin: top center; }
286
312
 
287
- /* ── Capture pulse ──────────────────────────────────────────────────── */
313
+ /* ── Capture pulse ────────────────────────────────────────────────── */
288
314
  @keyframes lv-pulse {
289
- 0%,100% { stroke-width: 3.5; opacity: 1; }
290
- 50% { stroke-width: 5; opacity: 0.55; }
315
+ 0%,100% { stroke-width: ${theme.progressWidth}; opacity: 1; }
316
+ 50% { stroke-width: ${pulseHi}; opacity: 0.55; }
291
317
  }
292
- .lv-ring-pulse { animation: lv-pulse 1s ease-in-out infinite; stroke: var(--lv-green) !important; }
318
+ .lv-ring-pulse { animation: lv-pulse 1s ease-in-out infinite; stroke: var(--lv-progress) !important; }
293
319
 
294
- /* ── Hidden canvas for capture ───────────────────────────────────────── */
295
320
  .lv-canvas {
296
321
  position: absolute;
297
322
  width: 0; height: 0;
@@ -323,12 +348,54 @@ function hintSvg(kind: HintKind): string {
323
348
  // ─────────────────────────────────────────────────────────────────────────────
324
349
 
325
350
  export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngine {
351
+ const embed = !!options.embed;
352
+ const shape = options.shape ?? "oval";
353
+ const showInstructions = options.showInstructions ?? true;
354
+ const theme = { ...DEFAULT_THEME, ...(options.theme ?? {}) };
355
+ const minSize = options.minSize ?? DEFAULT_MIN_SIZE;
356
+ const minRadius = Math.max(20, Math.floor(minSize / 2));
357
+
358
+ const dims = shape === "circle"
359
+ ? { wMax: CIRCLE_BASE, hMax: CIRCLE_BASE, aspectH: 1 }
360
+ : { wMax: OVAL_BASE.w, hMax: OVAL_BASE.h, aspectH: OVAL_BASE.h / OVAL_BASE.w };
361
+
362
+ // When the host owns instructions, the area below the cutout isn't needed,
363
+ // so we centre the shape vertically. Otherwise keep the original 40% slot.
364
+ const ovalTopPct = showInstructions ? 40 : 50;
365
+
366
+ // Ring geometry derives from dims + stroke width
367
+ const SVG_W = dims.wMax;
368
+ const SVG_H = dims.hMax;
369
+ const STROKE_HALF = theme.progressWidth / 2;
370
+ const RX = SVG_W / 2 - STROKE_HALF;
371
+ const RY = SVG_H / 2 - STROKE_HALF;
372
+ const ELLIPSE_PERIMETER = Math.PI * (3 * (RX + RY) - Math.sqrt((3 * RX + RY) * (RX + 3 * RY)));
373
+
326
374
  const container = options.container ?? document.body;
327
375
 
376
+ // ── Mount layer (wrapper only needed for embed so cqmin has a sized container) ──
377
+ let outerEl: HTMLElement | null = null;
378
+ let mountPoint: HTMLElement;
379
+ if (embed) {
380
+ const computed = getComputedStyle(container);
381
+ if (computed.position === "static" && container !== document.body) {
382
+ container.style.position = "relative";
383
+ }
384
+ const wrapper = document.createElement("div");
385
+ wrapper.className = "lv-outer";
386
+ container.appendChild(wrapper);
387
+ outerEl = wrapper;
388
+ mountPoint = wrapper;
389
+ } else {
390
+ mountPoint = container;
391
+ }
392
+
328
393
  // ── Root shell ─────────────────────────────────────────────────────────────
329
394
  const root = document.createElement("div");
330
- root.className = "lv-root lv-is-loading";
331
- root.appendChild(createStyles());
395
+ root.className = "lv-root lv-is-loading"
396
+ + (embed ? " lv-embed" : "")
397
+ + (!showInstructions ? " lv-no-instructions" : "");
398
+ root.appendChild(createStyles({ embed, showInstructions, theme, minRadius, dims, ovalTopPct }));
332
399
 
333
400
  // ── Video background ───────────────────────────────────────────────────────
334
401
  const videoBg = document.createElement("div");
@@ -342,17 +409,17 @@ export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngi
342
409
  videoBg.appendChild(video);
343
410
  root.appendChild(videoBg);
344
411
 
345
- // ── Dark overlay with oval cutout ──────────────────────────────────────────
412
+ // ── Dark overlay with shape cutout ─────────────────────────────────────────
346
413
  const overlay = document.createElement("div");
347
414
  overlay.className = "lv-overlay";
348
415
  root.appendChild(overlay);
349
416
 
350
- // ── Progress ring (ellipse pathLength = perimeter so dash offset matches) ───
351
- const rx = OVAL_W / 2, ry = OVAL_H / 2;
417
+ // ── Progress ring ──────────────────────────────────────────────────────────
418
+ const rx = SVG_W / 2, ry = SVG_H / 2;
352
419
  const ringWrap = document.createElement("div");
353
420
  ringWrap.className = "lv-ring-wrap";
354
421
  ringWrap.innerHTML = `
355
- <svg viewBox="0 0 ${OVAL_W} ${OVAL_H}">
422
+ <svg viewBox="0 0 ${SVG_W} ${SVG_H}">
356
423
  <ellipse class="lv-ring-progress"
357
424
  cx="${rx}" cy="${ry}" rx="${RX}" ry="${RY}"
358
425
  pathLength="${ELLIPSE_PERIMETER.toFixed(1)}"
@@ -362,13 +429,13 @@ export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngi
362
429
  </svg>`;
363
430
  root.appendChild(ringWrap);
364
431
 
365
- // ── Gesture hint icon (inside the oval) ────────────────────────────────────
432
+ // ── Gesture hint icon ──────────────────────────────────────────────────────
366
433
  const hintIcon = document.createElement("div");
367
434
  hintIcon.className = "lv-hint-icon";
368
435
  hintIcon.setAttribute("aria-hidden", "true");
369
436
  root.appendChild(hintIcon);
370
437
 
371
- // ── Loading spinner (shown until model is ready) ───────────────────────────
438
+ // ── Loading spinner ────────────────────────────────────────────────────────
372
439
  const loading = document.createElement("div");
373
440
  loading.className = "lv-loading";
374
441
  loading.innerHTML = `
@@ -377,12 +444,6 @@ export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngi
377
444
  `;
378
445
  root.appendChild(loading);
379
446
 
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
447
  // ── Step dots ──────────────────────────────────────────────────────────────
387
448
  const dotsEl = document.createElement("div");
388
449
  dotsEl.className = "lv-dots";
@@ -399,7 +460,7 @@ export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngi
399
460
  instruction.textContent = "Position your face in the oval";
400
461
  root.appendChild(instruction);
401
462
 
402
- // ── Position hint (shows when face is out of oval) ─────────────────────────
463
+ // ── Position hint ──────────────────────────────────────────────────────────
403
464
  const posHint = document.createElement("div");
404
465
  posHint.className = "lv-pos-hint";
405
466
  root.appendChild(posHint);
@@ -409,8 +470,9 @@ export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngi
409
470
  canvas.className = "lv-canvas";
410
471
  root.appendChild(canvas);
411
472
 
412
- container.appendChild(root);
473
+ mountPoint.appendChild(root);
413
474
 
475
+ const topEl = outerEl ?? root;
414
476
  const ringEl = ringWrap.querySelector(".lv-ring-progress") as SVGEllipseElement | null;
415
477
  const dots = Array.from(dotsEl.querySelectorAll(".lv-dot"));
416
478
  const P = ELLIPSE_PERIMETER;
@@ -472,7 +534,7 @@ export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngi
472
534
  video.removeEventListener("playing", onVideoPlaying);
473
535
  video.removeEventListener("pause", onVideoPause);
474
536
  video.removeEventListener("waiting", onVideoPause);
475
- root.remove();
537
+ topEl.remove();
476
538
  }
477
539
 
478
540
  // ── Engine ─────────────────────────────────────────────────────────────────
@@ -545,4 +607,4 @@ export function startLivenessWithUI(options: StartLivenessOptions): LivenessEngi
545
607
  );
546
608
 
547
609
  return engine;
548
- }
610
+ }