@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/README.md +92 -0
- package/dist/engine.d.ts +14 -1
- package/dist/engine.js +51 -18
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/ui.d.ts +22 -1
- package/dist/ui.js +138 -102
- package/package.json +1 -1
- package/src/engine.ts +67 -18
- package/src/index.ts +3 -2
- package/src/ui.ts +174 -104
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,
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
//
|
|
18
|
-
const
|
|
19
|
-
const
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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:
|
|
96
|
+
color: #ffffff;
|
|
55
97
|
overflow: hidden;
|
|
56
98
|
display: flex;
|
|
57
99
|
flex-direction: column;
|
|
58
100
|
align-items: center;
|
|
59
|
-
|
|
60
|
-
--
|
|
61
|
-
--
|
|
62
|
-
|
|
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: ${
|
|
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:
|
|
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:
|
|
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
|
|
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-
|
|
194
|
+
background: var(--lv-overlay);
|
|
143
195
|
-webkit-mask-image: radial-gradient(
|
|
144
|
-
ellipse var(--ow) var(--oh) at 50% ${
|
|
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% ${
|
|
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
|
-
/* ──
|
|
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: ${
|
|
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-
|
|
173
|
-
stroke-width:
|
|
174
|
-
stroke-linecap:
|
|
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-
|
|
228
|
+
.lv-ring-progress.out-of-oval { stroke: var(--lv-progress-error); }
|
|
180
229
|
|
|
181
|
-
/* ──
|
|
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(${
|
|
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-
|
|
215
|
-
.lv-dot.done { background: var(--lv-
|
|
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(${
|
|
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(${
|
|
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-
|
|
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: ${
|
|
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%
|
|
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:
|
|
290
|
-
50% { stroke-width:
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
|
351
|
-
const rx =
|
|
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 ${
|
|
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
|
|
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
|
|
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 <
|
|
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
|
|
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
|
-
|
|
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 /
|
|
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(
|
|
524
|
+
setProgress(sessionStepCount);
|
|
457
525
|
setCapturePulse();
|
|
458
526
|
setHint(null);
|
|
459
|
-
setDots(
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|