@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/dist/ui.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { LivenessEngine,
|
|
1
|
+
import { LivenessEngine, LivenessError, resolveStepLabels } from "./engine";
|
|
2
2
|
import { DEFAULT_SOUND_DATA_URLS } from "./default-sounds.generated";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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:
|
|
50
|
+
color: #ffffff;
|
|
40
51
|
overflow: hidden;
|
|
41
52
|
display: flex;
|
|
42
53
|
flex-direction: column;
|
|
43
54
|
align-items: center;
|
|
44
|
-
|
|
45
|
-
--
|
|
46
|
-
--
|
|
47
|
-
|
|
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: ${
|
|
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:
|
|
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:
|
|
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
|
|
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-
|
|
148
|
+
background: var(--lv-overlay);
|
|
128
149
|
-webkit-mask-image: radial-gradient(
|
|
129
|
-
ellipse var(--ow) var(--oh) at 50% ${
|
|
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% ${
|
|
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
|
-
/* ──
|
|
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: ${
|
|
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-
|
|
158
|
-
stroke-width:
|
|
159
|
-
stroke-linecap:
|
|
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-
|
|
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(${
|
|
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-
|
|
200
|
-
.lv-dot.done { background: var(--lv-
|
|
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(${
|
|
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(${
|
|
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-
|
|
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: ${
|
|
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%
|
|
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:
|
|
275
|
-
50% { stroke-width:
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
|
328
|
-
const rx =
|
|
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 ${
|
|
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
|
|
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
|
|
382
|
+
// ── Loading spinner ────────────────────────────────────────────────────────
|
|
347
383
|
const loading = document.createElement("div");
|
|
348
384
|
loading.className = "lv-loading";
|
|
349
385
|
loading.innerHTML = `
|
|
@@ -351,15 +387,12 @@ export function startLivenessWithUI(options) {
|
|
|
351
387
|
<div class="lv-loading-text">Preparing camera...</div>
|
|
352
388
|
`;
|
|
353
389
|
root.appendChild(loading);
|
|
354
|
-
// ── Header ─────────────────────────────────────────────────────────────────
|
|
355
|
-
// const header = document.createElement("div");
|
|
356
|
-
// header.className = "lv-header";
|
|
357
|
-
// header.innerHTML = `<span class="lv-header-title">Face Verification</span>`;
|
|
358
|
-
// root.appendChild(header);
|
|
359
390
|
// ── Step dots ──────────────────────────────────────────────────────────────
|
|
391
|
+
// Count derives from the active subset (`options.steps`), defaulting to all 5.
|
|
392
|
+
const sessionStepCount = resolveStepLabels(options.steps).length;
|
|
360
393
|
const dotsEl = document.createElement("div");
|
|
361
394
|
dotsEl.className = "lv-dots";
|
|
362
|
-
for (let i = 0; i <
|
|
395
|
+
for (let i = 0; i < sessionStepCount; i++) {
|
|
363
396
|
const dot = document.createElement("div");
|
|
364
397
|
dot.className = "lv-dot" + (i === 0 ? " active" : "");
|
|
365
398
|
dotsEl.appendChild(dot);
|
|
@@ -370,7 +403,7 @@ export function startLivenessWithUI(options) {
|
|
|
370
403
|
instruction.className = "lv-instruction";
|
|
371
404
|
instruction.textContent = "Position your face in the oval";
|
|
372
405
|
root.appendChild(instruction);
|
|
373
|
-
// ── Position hint
|
|
406
|
+
// ── Position hint ──────────────────────────────────────────────────────────
|
|
374
407
|
const posHint = document.createElement("div");
|
|
375
408
|
posHint.className = "lv-pos-hint";
|
|
376
409
|
root.appendChild(posHint);
|
|
@@ -378,7 +411,8 @@ export function startLivenessWithUI(options) {
|
|
|
378
411
|
const canvas = document.createElement("canvas");
|
|
379
412
|
canvas.className = "lv-canvas";
|
|
380
413
|
root.appendChild(canvas);
|
|
381
|
-
|
|
414
|
+
mountPoint.appendChild(root);
|
|
415
|
+
const topEl = outerEl ?? root;
|
|
382
416
|
const ringEl = ringWrap.querySelector(".lv-ring-progress");
|
|
383
417
|
const dots = Array.from(dotsEl.querySelectorAll(".lv-dot"));
|
|
384
418
|
const P = ELLIPSE_PERIMETER;
|
|
@@ -388,7 +422,7 @@ export function startLivenessWithUI(options) {
|
|
|
388
422
|
function setProgress(completedSteps) {
|
|
389
423
|
if (!ringEl)
|
|
390
424
|
return;
|
|
391
|
-
const filled = Math.min(completedSteps /
|
|
425
|
+
const filled = Math.min(completedSteps / sessionStepCount, 1);
|
|
392
426
|
const offset = P * (1 - filled);
|
|
393
427
|
ringEl.setAttribute("stroke-dashoffset", offset.toFixed(1));
|
|
394
428
|
}
|
|
@@ -418,10 +452,10 @@ export function startLivenessWithUI(options) {
|
|
|
418
452
|
}
|
|
419
453
|
function renderChallenge(stepIndex, stepLabel) {
|
|
420
454
|
if (stepIndex === -1) {
|
|
421
|
-
setProgress(
|
|
455
|
+
setProgress(sessionStepCount);
|
|
422
456
|
setCapturePulse();
|
|
423
457
|
setHint(null);
|
|
424
|
-
setDots(
|
|
458
|
+
setDots(sessionStepCount);
|
|
425
459
|
instruction.textContent = stepLabel;
|
|
426
460
|
return;
|
|
427
461
|
}
|
|
@@ -436,7 +470,7 @@ export function startLivenessWithUI(options) {
|
|
|
436
470
|
video.removeEventListener("playing", onVideoPlaying);
|
|
437
471
|
video.removeEventListener("pause", onVideoPause);
|
|
438
472
|
video.removeEventListener("waiting", onVideoPause);
|
|
439
|
-
|
|
473
|
+
topEl.remove();
|
|
440
474
|
}
|
|
441
475
|
// ── Engine ─────────────────────────────────────────────────────────────────
|
|
442
476
|
// Use options.sounds if provided; otherwise use embedded default sounds (works in any host)
|
|
@@ -454,6 +488,8 @@ export function startLivenessWithUI(options) {
|
|
|
454
488
|
modelUrl: options.modelUrl,
|
|
455
489
|
wasmUrl: options.wasmUrl,
|
|
456
490
|
sounds,
|
|
491
|
+
steps: options.steps,
|
|
492
|
+
shuffleSteps: options.shuffleSteps,
|
|
457
493
|
callbacks: {
|
|
458
494
|
onChallengeChanged: (stepIndex, stepLabel) => {
|
|
459
495
|
pendingChallenge = { stepIndex, stepLabel };
|
|
@@ -473,7 +509,7 @@ export function startLivenessWithUI(options) {
|
|
|
473
509
|
options.callbacks.onFailure?.(reason);
|
|
474
510
|
},
|
|
475
511
|
onSuccess: (imageBase64) => {
|
|
476
|
-
setProgress(
|
|
512
|
+
setProgress(sessionStepCount);
|
|
477
513
|
cleanup();
|
|
478
514
|
options.callbacks.onSuccess?.(imageBase64);
|
|
479
515
|
},
|
package/package.json
CHANGED
package/src/engine.ts
CHANGED
|
@@ -24,8 +24,15 @@ export type LivenessOptions = {
|
|
|
24
24
|
wasmUrl?: string;
|
|
25
25
|
callbacks?: LivenessCallbacks;
|
|
26
26
|
sounds?: LivenessSoundOptions;
|
|
27
|
+
/** Subset of challenges to run. Default: all 5. Empty array, duplicates and unknown keys fall back to all 5. */
|
|
28
|
+
steps?: LivenessStepKey[];
|
|
29
|
+
/** Shuffle the selected step order. Default true. */
|
|
30
|
+
shuffleSteps?: boolean;
|
|
27
31
|
};
|
|
28
32
|
|
|
33
|
+
/** Identifier for a liveness challenge step. Lowercase, matches the sound-key form. */
|
|
34
|
+
export type LivenessStepKey = "left" | "blink" | "right" | "nod" | "mouth";
|
|
35
|
+
|
|
29
36
|
export const DEFAULT_MODEL_URL =
|
|
30
37
|
"https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task";
|
|
31
38
|
|
|
@@ -132,22 +139,44 @@ export type Metrics = {
|
|
|
132
139
|
faceSize: number;
|
|
133
140
|
};
|
|
134
141
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
"
|
|
138
|
-
"
|
|
139
|
-
"
|
|
140
|
-
"
|
|
142
|
+
/** Canonical step ordering. Each entry pairs the sound/identifier key with its display label. */
|
|
143
|
+
const STEP_DEFS = [
|
|
144
|
+
{ key: "left", label: "Turn your head LEFT" },
|
|
145
|
+
{ key: "blink", label: "Blink" },
|
|
146
|
+
{ key: "right", label: "Turn your head RIGHT" },
|
|
147
|
+
{ key: "nod", label: "Nod your head" },
|
|
148
|
+
{ key: "mouth", label: "Open your mouth" },
|
|
141
149
|
] as const;
|
|
142
150
|
|
|
151
|
+
const STEP_LABELS: readonly string[] = STEP_DEFS.map(s => s.label);
|
|
152
|
+
|
|
153
|
+
const STEP_KEY_TO_LABEL: Record<LivenessStepKey, string> = STEP_DEFS.reduce(
|
|
154
|
+
(acc, s) => { (acc as Record<string, string>)[s.key] = s.label; return acc; },
|
|
155
|
+
{} as Record<LivenessStepKey, string>,
|
|
156
|
+
);
|
|
157
|
+
|
|
143
158
|
/** Step label → sound key (filename without .mp3). Used so the correct sound plays regardless of randomized step order. */
|
|
144
|
-
const STEP_LABEL_TO_SOUND: Record<string, string> =
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
159
|
+
const STEP_LABEL_TO_SOUND: Record<string, string> = STEP_DEFS.reduce(
|
|
160
|
+
(acc, s) => { acc[s.label] = s.key; return acc; },
|
|
161
|
+
{} as Record<string, string>,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
/** Resolve a caller-supplied step-key array into the corresponding display labels.
|
|
165
|
+
* Empty / undefined / all-invalid → default to all 5 steps. Duplicates are dropped. */
|
|
166
|
+
export function resolveStepLabels(keys?: readonly LivenessStepKey[] | null): string[] {
|
|
167
|
+
if (!keys || keys.length === 0) return [...STEP_LABELS];
|
|
168
|
+
const seen = new Set<string>();
|
|
169
|
+
const out: string[] = [];
|
|
170
|
+
for (const k of keys) {
|
|
171
|
+
const normalised = (typeof k === "string" ? k.toLowerCase() : "") as LivenessStepKey;
|
|
172
|
+
const label = STEP_KEY_TO_LABEL[normalised];
|
|
173
|
+
if (label && !seen.has(label)) {
|
|
174
|
+
seen.add(label);
|
|
175
|
+
out.push(label);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return out.length > 0 ? out : [...STEP_LABELS];
|
|
179
|
+
}
|
|
151
180
|
|
|
152
181
|
function shuffleArray<T>(array: readonly T[]): T[] {
|
|
153
182
|
const out = [...array];
|
|
@@ -158,6 +187,7 @@ function shuffleArray<T>(array: readonly T[]): T[] {
|
|
|
158
187
|
return out;
|
|
159
188
|
}
|
|
160
189
|
|
|
190
|
+
/** Default step count (5). The active session may have fewer if `options.steps` is set. */
|
|
161
191
|
export const LIVENESS_STEP_COUNT = STEP_LABELS.length;
|
|
162
192
|
|
|
163
193
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -197,10 +227,13 @@ const config = {
|
|
|
197
227
|
maxYawDuringNod: 40,
|
|
198
228
|
|
|
199
229
|
// ── Blink ──────────────────────────────────────────────────────────────────
|
|
200
|
-
|
|
201
|
-
|
|
230
|
+
// Real blinks easily hit 0.7+. Sustained squinting / glare / hooded eyelids hover at 0.25–0.40 —
|
|
231
|
+
// keep the "closed" threshold well above that band so passive eye states don't latch CLOSED.
|
|
232
|
+
blinkClosedThreshold: 0.50,
|
|
233
|
+
blinkOpenThreshold: 0.25,
|
|
202
234
|
earClosedThreshold: 0.20,
|
|
203
235
|
earOpenThreshold: 0.25,
|
|
236
|
+
blinkMinClosedMs: 60, // eyes must be CLOSED for at least this long — filters out single-frame noise
|
|
204
237
|
blinkMaxDurationMs: 4000,
|
|
205
238
|
maxYawDuringBlink: 30,
|
|
206
239
|
maxPitchDuringBlink: 30,
|
|
@@ -252,6 +285,7 @@ export class LivenessEngine {
|
|
|
252
285
|
// ── Per-step sub-state ─────────────────────────────────────────────────────
|
|
253
286
|
private blinkState: "waitingClose" | "closed" = "waitingClose";
|
|
254
287
|
private blinkCloseTs = 0;
|
|
288
|
+
private blinkSeenOpen = false; // gate: must observe at least one isEyeOpen frame before allowing a `closed` transition
|
|
255
289
|
private nodState: "neutral" | "down" = "neutral";
|
|
256
290
|
private holdStart: number | null = null;
|
|
257
291
|
|
|
@@ -342,10 +376,17 @@ export class LivenessEngine {
|
|
|
342
376
|
|
|
343
377
|
// ── Public ─────────────────────────────────────────────────────────────────
|
|
344
378
|
|
|
379
|
+
/** Number of challenge steps in the active session (after applying `options.steps`). */
|
|
380
|
+
get stepCount(): number {
|
|
381
|
+
return this.steps.length > 0 ? this.steps.length : resolveStepLabels(this.opts.steps).length;
|
|
382
|
+
}
|
|
383
|
+
|
|
345
384
|
async start(): Promise<void> {
|
|
346
385
|
this.stopDetectionOnly();
|
|
347
386
|
this.running = true;
|
|
348
|
-
|
|
387
|
+
const selected = resolveStepLabels(this.opts.steps);
|
|
388
|
+
const ordered = this.opts.shuffleSteps === false ? selected : shuffleArray(selected);
|
|
389
|
+
this.steps = ordered.map((label, index) => ({ index, label }));
|
|
349
390
|
this.stepIndex = 0;
|
|
350
391
|
this.lastDetectTs = -1;
|
|
351
392
|
this.lastOvalState = null;
|
|
@@ -511,6 +552,7 @@ export class LivenessEngine {
|
|
|
511
552
|
private resetStepState(): void {
|
|
512
553
|
this.blinkState = "waitingClose";
|
|
513
554
|
this.blinkCloseTs = 0;
|
|
555
|
+
this.blinkSeenOpen = false;
|
|
514
556
|
this.nodState = "neutral";
|
|
515
557
|
this.holdStart = null;
|
|
516
558
|
this.baselineYaw = null;
|
|
@@ -566,11 +608,18 @@ export class LivenessEngine {
|
|
|
566
608
|
? metrics.blinkScore < config.blinkOpenThreshold
|
|
567
609
|
: metrics.ear > config.earOpenThreshold;
|
|
568
610
|
|
|
569
|
-
|
|
611
|
+
// Confirm eyes were OPEN before we count any closed → open transition.
|
|
612
|
+
// Kills the "step starts mid-blink / mid-squint" false positive.
|
|
613
|
+
if (isEyeOpen) this.blinkSeenOpen = true;
|
|
614
|
+
|
|
615
|
+
if (this.blinkState === "waitingClose" && isEyeClosed && this.blinkSeenOpen) {
|
|
570
616
|
this.blinkState = "closed";
|
|
571
617
|
this.blinkCloseTs = now;
|
|
572
618
|
} else if (this.blinkState === "closed" && isEyeOpen) {
|
|
573
|
-
|
|
619
|
+
const dt = now - this.blinkCloseTs;
|
|
620
|
+
if (dt >= config.blinkMinClosedMs && dt <= config.blinkMaxDurationMs) return this.advanceStep(now);
|
|
621
|
+
// Closed phase too short (noise) or too long (eyes never reopened in time) — discard, wait for next attempt.
|
|
622
|
+
// blinkSeenOpen stays true — we've already confirmed the user can open their eyes.
|
|
574
623
|
this.blinkState = "waitingClose";
|
|
575
624
|
}
|
|
576
625
|
break;
|