@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 +38 -0
- package/dist/engine.d.ts +4 -3
- package/dist/engine.js +37 -40
- package/dist/index.d.ts +1 -1
- package/dist/ui.d.ts +17 -0
- package/dist/ui.js +128 -96
- package/package.json +1 -1
- package/src/engine.ts +34 -38
- package/src/index.ts +1 -1
- package/src/ui.ts +160 -98
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
|
|
76
|
-
private
|
|
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
|
|
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.
|
|
178
|
-
this.
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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.
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
this.
|
|
230
|
+
stopAllSounds() {
|
|
231
|
+
this.currentAudioToken += 1;
|
|
232
|
+
if (this.currentAudio) {
|
|
233
|
+
this.currentAudio.pause();
|
|
234
|
+
this.currentAudio.currentTime = 0;
|
|
238
235
|
}
|
|
239
|
-
this.
|
|
240
|
-
this.
|
|
241
|
-
this.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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,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
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
|
264
|
-
private
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
|
|
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.
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
this.
|
|
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.
|
|
324
|
-
this.
|
|
325
|
-
this.
|
|
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.
|
|
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.
|
|
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
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
//
|
|
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)));
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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:
|
|
92
|
+
color: #ffffff;
|
|
55
93
|
overflow: hidden;
|
|
56
94
|
display: flex;
|
|
57
95
|
flex-direction: column;
|
|
58
96
|
align-items: center;
|
|
59
|
-
|
|
60
|
-
--
|
|
61
|
-
--
|
|
62
|
-
|
|
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: ${
|
|
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:
|
|
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:
|
|
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
|
|
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-
|
|
190
|
+
background: var(--lv-overlay);
|
|
143
191
|
-webkit-mask-image: radial-gradient(
|
|
144
|
-
ellipse var(--ow) var(--oh) at 50% ${
|
|
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% ${
|
|
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
|
-
/* ──
|
|
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: ${
|
|
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-
|
|
173
|
-
stroke-width:
|
|
174
|
-
stroke-linecap:
|
|
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-
|
|
224
|
+
.lv-ring-progress.out-of-oval { stroke: var(--lv-progress-error); }
|
|
180
225
|
|
|
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 ───────────────────────────────────────────────────────── */
|
|
226
|
+
/* ── Step dots ─────────────────────────────────────────────────────── */
|
|
200
227
|
.lv-dots {
|
|
201
228
|
position: absolute;
|
|
202
229
|
z-index: 2;
|
|
203
|
-
top: calc(${
|
|
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-
|
|
215
|
-
.lv-dot.done { background: var(--lv-
|
|
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(${
|
|
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(${
|
|
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-
|
|
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: ${
|
|
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%
|
|
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:
|
|
290
|
-
50% { stroke-width:
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
|
351
|
-
const rx =
|
|
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 ${
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|