@kreltix/liveness-sdk 0.1.0

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.
@@ -0,0 +1,1230 @@
1
+ class KreltixError extends Error {
2
+ constructor(message, code, isRetryable = false) {
3
+ super(message);
4
+ this.name = "KreltixError";
5
+ this.code = code;
6
+ this.isRetryable = isRetryable;
7
+ }
8
+ }
9
+ class KreltixNetworkError extends KreltixError {
10
+ constructor(message = "Network request failed") {
11
+ super(message, "NETWORK_ERROR", true);
12
+ this.name = "KreltixNetworkError";
13
+ }
14
+ }
15
+ class KreltixAuthError extends KreltixError {
16
+ constructor(message = "Invalid or expired API key") {
17
+ super(message, "AUTH_ERROR", false);
18
+ this.name = "KreltixAuthError";
19
+ }
20
+ }
21
+ class KreltixSessionExpiredError extends KreltixError {
22
+ constructor(message = "Liveness session expired") {
23
+ super(message, "SESSION_EXPIRED", false);
24
+ this.name = "KreltixSessionExpiredError";
25
+ }
26
+ }
27
+ class KreltixCameraError extends KreltixError {
28
+ constructor(message = "Camera not available or permission denied") {
29
+ super(message, "CAMERA_ERROR", false);
30
+ this.name = "KreltixCameraError";
31
+ }
32
+ }
33
+ class KreltixServerError extends KreltixError {
34
+ constructor(message = "Server error") {
35
+ super(message, "SERVER_ERROR", true);
36
+ this.name = "KreltixServerError";
37
+ }
38
+ }
39
+ class KreltixValidationError extends KreltixError {
40
+ constructor(message = "Validation error") {
41
+ super(message, "VALIDATION_ERROR", false);
42
+ this.name = "KreltixValidationError";
43
+ }
44
+ }
45
+ class KreltixInsufficientFundsError extends KreltixError {
46
+ constructor(message = "Insufficient wallet balance") {
47
+ super(message, "INSUFFICIENT_FUNDS", false);
48
+ this.name = "KreltixInsufficientFundsError";
49
+ }
50
+ }
51
+
52
+ async function withRetry(fn, maxRetries, baseDelayMs = 1000) {
53
+ let lastError;
54
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
55
+ try {
56
+ return await fn();
57
+ }
58
+ catch (err) {
59
+ lastError = err;
60
+ if (err instanceof KreltixError && !err.isRetryable) {
61
+ throw err;
62
+ }
63
+ if (attempt < maxRetries) {
64
+ const delay = baseDelayMs * Math.pow(2, attempt);
65
+ await new Promise((resolve) => setTimeout(resolve, delay));
66
+ }
67
+ }
68
+ }
69
+ throw lastError;
70
+ }
71
+
72
+ class ApiClient {
73
+ constructor(config) {
74
+ this.config = config;
75
+ }
76
+ async request(method, path, body, timeoutOverride) {
77
+ const url = `${this.config.baseUrl}${path}`;
78
+ const controller = new AbortController();
79
+ const timeout = setTimeout(() => controller.abort(), timeoutOverride ?? this.config.timeout);
80
+ try {
81
+ const response = await fetch(url, {
82
+ method,
83
+ headers: {
84
+ "Content-Type": "application/json",
85
+ "X-API-Key": this.config.publicKey,
86
+ },
87
+ body: body ? JSON.stringify(body) : undefined,
88
+ signal: controller.signal,
89
+ });
90
+ if (response.ok) {
91
+ return (await response.json());
92
+ }
93
+ const errorBody = await response.json().catch(() => ({ detail: response.statusText }));
94
+ const detail = errorBody.detail ?? response.statusText;
95
+ switch (response.status) {
96
+ case 401:
97
+ case 403:
98
+ throw new KreltixAuthError(detail);
99
+ case 402:
100
+ throw new KreltixInsufficientFundsError(detail);
101
+ case 409:
102
+ throw new KreltixValidationError(detail);
103
+ case 410:
104
+ throw new KreltixSessionExpiredError(detail);
105
+ case 429:
106
+ throw new KreltixServerError("Rate limit exceeded. Try again shortly.");
107
+ default:
108
+ if (response.status >= 500) {
109
+ throw new KreltixServerError(detail);
110
+ }
111
+ throw new KreltixValidationError(detail);
112
+ }
113
+ }
114
+ catch (err) {
115
+ if (err instanceof TypeError || err.name === "AbortError") {
116
+ throw new KreltixNetworkError(err.name === "AbortError"
117
+ ? "Request timed out"
118
+ : "Network request failed");
119
+ }
120
+ throw err;
121
+ }
122
+ finally {
123
+ clearTimeout(timeout);
124
+ }
125
+ }
126
+ async createSession(metadata) {
127
+ return withRetry(() => this.request("POST", "/api/v1/sessions", {
128
+ metadata: metadata ?? null,
129
+ }), this.config.maxRetries);
130
+ }
131
+ async submitVerification(sessionId, sessionToken, videos) {
132
+ return withRetry(() => this.request("POST", `/api/v1/sessions/${sessionId}/verify`, {
133
+ session_token: sessionToken,
134
+ videos,
135
+ }, 60000 // 60s timeout for multi-video upload
136
+ ), this.config.maxRetries);
137
+ }
138
+ }
139
+
140
+ class SessionManager {
141
+ constructor(client) {
142
+ this.currentSession = null;
143
+ this.client = client;
144
+ }
145
+ async createSession(metadata) {
146
+ this.currentSession = await this.client.createSession(metadata);
147
+ return this.currentSession;
148
+ }
149
+ async submitVerification(videos) {
150
+ if (!this.currentSession) {
151
+ throw new Error("No active session. Call createSession() first.");
152
+ }
153
+ const result = await this.client.submitVerification(this.currentSession.session_id, this.currentSession.session_token, videos);
154
+ this.currentSession = null;
155
+ return result;
156
+ }
157
+ getCurrentSession() {
158
+ return this.currentSession;
159
+ }
160
+ clearSession() {
161
+ this.currentSession = null;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Default web camera adapter using getUserMedia.
167
+ */
168
+ class WebCameraAdapter {
169
+ constructor() {
170
+ this.stream = null;
171
+ this.videoElement = document.createElement("video");
172
+ this.videoElement.setAttribute("playsinline", "true");
173
+ this.videoElement.setAttribute("autoplay", "true");
174
+ this.videoElement.muted = true;
175
+ this.videoElement.style.width = "100%";
176
+ this.videoElement.style.height = "100%";
177
+ this.videoElement.style.objectFit = "cover";
178
+ this.videoElement.style.transform = "scaleX(-1)"; // Mirror for selfie
179
+ }
180
+ async start(facingMode = "user") {
181
+ try {
182
+ this.stream = await navigator.mediaDevices.getUserMedia({
183
+ video: {
184
+ facingMode,
185
+ width: { ideal: 640 },
186
+ height: { ideal: 480 },
187
+ },
188
+ audio: false,
189
+ });
190
+ this.videoElement.srcObject = this.stream;
191
+ await this.videoElement.play();
192
+ return this.stream;
193
+ }
194
+ catch (err) {
195
+ const message = err.name === "NotAllowedError"
196
+ ? "Camera permission denied. Please allow camera access."
197
+ : err.name === "NotFoundError"
198
+ ? "No camera found on this device."
199
+ : `Camera error: ${err.message}`;
200
+ throw new KreltixCameraError(message);
201
+ }
202
+ }
203
+ stop() {
204
+ if (this.stream) {
205
+ this.stream.getTracks().forEach((track) => track.stop());
206
+ this.stream = null;
207
+ }
208
+ this.videoElement.srcObject = null;
209
+ }
210
+ getVideoElement() {
211
+ return this.videoElement;
212
+ }
213
+ }
214
+ class CameraManager {
215
+ constructor(adapter) {
216
+ this.adapter = adapter ?? new WebCameraAdapter();
217
+ }
218
+ async start() {
219
+ return this.adapter.start("user");
220
+ }
221
+ stop() {
222
+ this.adapter.stop();
223
+ }
224
+ getVideoElement() {
225
+ return this.adapter.getVideoElement();
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Convert a Blob to a base64 string (without data URI prefix).
231
+ */
232
+ function blobToBase64(blob) {
233
+ return new Promise((resolve, reject) => {
234
+ const reader = new FileReader();
235
+ reader.onloadend = () => {
236
+ const result = reader.result;
237
+ // Strip the data URI prefix (e.g., "data:video/webm;base64,")
238
+ const base64 = result.split("base64,")[1];
239
+ if (base64) {
240
+ resolve(base64);
241
+ }
242
+ else {
243
+ reject(new Error("Failed to convert blob to base64"));
244
+ }
245
+ };
246
+ reader.onerror = reject;
247
+ reader.readAsDataURL(blob);
248
+ });
249
+ }
250
+
251
+ class VideoRecorder {
252
+ constructor() {
253
+ this.mediaRecorder = null;
254
+ this.chunks = [];
255
+ this.resolveRecording = null;
256
+ this._pendingStopEarly = null;
257
+ }
258
+ /**
259
+ * Get the best supported MIME type for video recording.
260
+ */
261
+ getMimeType() {
262
+ const types = [
263
+ "video/webm;codecs=vp8",
264
+ "video/webm",
265
+ "video/mp4",
266
+ ];
267
+ for (const type of types) {
268
+ if (MediaRecorder.isTypeSupported(type)) {
269
+ return type;
270
+ }
271
+ }
272
+ return "";
273
+ }
274
+ /**
275
+ * Start recording from a media stream.
276
+ */
277
+ start(stream, options = {}) {
278
+ const { videoBitsPerSecond = 1000000 } = options;
279
+ const mimeType = this.getMimeType();
280
+ this.chunks = [];
281
+ this.mediaRecorder = new MediaRecorder(stream, {
282
+ mimeType: mimeType || undefined,
283
+ videoBitsPerSecond,
284
+ });
285
+ this.mediaRecorder.ondataavailable = (event) => {
286
+ if (event.data.size > 0) {
287
+ this.chunks.push(event.data);
288
+ }
289
+ };
290
+ this.mediaRecorder.onstop = () => {
291
+ const blob = new Blob(this.chunks, { type: mimeType || "video/webm" });
292
+ if (this.resolveRecording) {
293
+ this.resolveRecording(blob);
294
+ this.resolveRecording = null;
295
+ }
296
+ };
297
+ this.mediaRecorder.start(500); // Collect data every 500ms
298
+ }
299
+ /**
300
+ * Stop recording and return the video as a base64 string.
301
+ */
302
+ async stop() {
303
+ return new Promise((resolve, reject) => {
304
+ if (!this.mediaRecorder || this.mediaRecorder.state === "inactive") {
305
+ reject(new Error("No active recording"));
306
+ return;
307
+ }
308
+ this.resolveRecording = async (blob) => {
309
+ try {
310
+ const base64 = await blobToBase64(blob);
311
+ resolve(base64);
312
+ }
313
+ catch (err) {
314
+ reject(err);
315
+ }
316
+ };
317
+ this.mediaRecorder.stop();
318
+ });
319
+ }
320
+ /**
321
+ * Record for a duration, then stop and return base64.
322
+ * Can be stopped early via the returned controller.
323
+ */
324
+ record(stream, options = {}) {
325
+ const { minDurationMs = 3000, maxDurationMs = 7000 } = options;
326
+ this.start(stream, options);
327
+ const promise = new Promise((resolve, reject) => {
328
+ // Force stop at max duration
329
+ const maxTimer = setTimeout(() => {
330
+ this.stop().then(resolve).catch(reject);
331
+ }, maxDurationMs);
332
+ // Allow early stop after min duration
333
+ const stopEarlyFn = () => {
334
+ const elapsed = Date.now() - startTime;
335
+ const remaining = Math.max(0, minDurationMs - elapsed);
336
+ setTimeout(() => {
337
+ clearTimeout(maxTimer);
338
+ this.stop().then(resolve).catch(reject);
339
+ }, remaining);
340
+ };
341
+ // Store for external access
342
+ this._pendingStopEarly = stopEarlyFn;
343
+ });
344
+ const startTime = Date.now();
345
+ return {
346
+ promise,
347
+ stopEarly: () => {
348
+ if (this._pendingStopEarly) {
349
+ this._pendingStopEarly();
350
+ }
351
+ },
352
+ };
353
+ }
354
+ }
355
+
356
+ function distance(a, b) {
357
+ return Math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2);
358
+ }
359
+ function eyeAspectRatio(eye) {
360
+ if (eye.length < 6)
361
+ return 0.3; // Default open-eye value
362
+ const vertical1 = distance(eye[1], eye[5]);
363
+ const vertical2 = distance(eye[2], eye[4]);
364
+ const horizontal = distance(eye[0], eye[3]);
365
+ if (horizontal === 0)
366
+ return 0;
367
+ return (vertical1 + vertical2) / (2 * horizontal);
368
+ }
369
+ class BlinkDetector {
370
+ constructor() {
371
+ this.earHistory = [];
372
+ this.blinkDetected = false;
373
+ }
374
+ detect(landmarks) {
375
+ const leftEar = eyeAspectRatio(landmarks.leftEye);
376
+ const rightEar = eyeAspectRatio(landmarks.rightEye);
377
+ const avgEar = (leftEar + rightEar) / 2;
378
+ this.earHistory.push(avgEar);
379
+ // Look for a dip (closed eyes) followed by a rise (open eyes)
380
+ if (this.earHistory.length >= 3) {
381
+ const min = Math.min(...this.earHistory);
382
+ const max = Math.max(...this.earHistory);
383
+ if (min < 0.2 && max > 0.25) {
384
+ this.blinkDetected = true;
385
+ }
386
+ }
387
+ if (this.blinkDetected) {
388
+ return { completed: true, progress: 1, feedback: "Blink detected!" };
389
+ }
390
+ if (avgEar < 0.22) {
391
+ return { completed: false, progress: 0.6, feedback: "Good, now open your eyes..." };
392
+ }
393
+ return { completed: false, progress: 0.2, feedback: "Please blink your eyes clearly." };
394
+ }
395
+ reset() {
396
+ this.earHistory = [];
397
+ this.blinkDetected = false;
398
+ }
399
+ }
400
+
401
+ class HeadTurnDetector {
402
+ constructor() {
403
+ this.noseOffsets = [];
404
+ this.turnDetected = false;
405
+ }
406
+ detect(landmarks, direction) {
407
+ const { noseTip, faceBounds } = landmarks;
408
+ const faceCenterX = faceBounds.x + faceBounds.width / 2;
409
+ const normalizedOffset = (noseTip[0] - faceCenterX) / faceBounds.width;
410
+ this.noseOffsets.push(normalizedOffset);
411
+ const threshold = 0.15;
412
+ const minRange = 0.2;
413
+ if (this.noseOffsets.length >= 3) {
414
+ const min = Math.min(...this.noseOffsets);
415
+ const max = Math.max(...this.noseOffsets);
416
+ const range = max - min;
417
+ if (direction === "left" && min < -threshold && range > minRange) {
418
+ this.turnDetected = true;
419
+ }
420
+ else if (direction === "right" && max > threshold && range > minRange) {
421
+ this.turnDetected = true;
422
+ }
423
+ }
424
+ if (this.turnDetected) {
425
+ return { completed: true, progress: 1, feedback: "Head turn detected!" };
426
+ }
427
+ // Progress based on how far they've turned
428
+ const currentOffset = direction === "left" ? -normalizedOffset : normalizedOffset;
429
+ const progress = Math.min(0.9, Math.max(0, currentOffset / threshold));
430
+ if (progress > 0.5) {
431
+ return {
432
+ completed: false,
433
+ progress,
434
+ feedback: "Almost there, keep turning...",
435
+ };
436
+ }
437
+ const dirLabel = direction === "left" ? "left" : "right";
438
+ return {
439
+ completed: false,
440
+ progress: Math.max(0.1, progress),
441
+ feedback: `Please turn your head to the ${dirLabel}.`,
442
+ };
443
+ }
444
+ reset() {
445
+ this.noseOffsets = [];
446
+ this.turnDetected = false;
447
+ }
448
+ }
449
+
450
+ class SmileDetector {
451
+ constructor() {
452
+ this.aspectHistory = [];
453
+ this.smileDetected = false;
454
+ }
455
+ detect(landmarks) {
456
+ const { upperLip, lowerLip } = landmarks;
457
+ const allPoints = [...upperLip, ...lowerLip];
458
+ if (allPoints.length < 4) {
459
+ return { completed: false, progress: 0, feedback: "Please face the camera." };
460
+ }
461
+ // Mouth dimensions
462
+ const xs = allPoints.map((p) => p[0]);
463
+ const ys = allPoints.map((p) => p[1]);
464
+ const mouthWidth = Math.max(...xs) - Math.min(...xs);
465
+ const mouthHeight = Math.max(...ys) - Math.min(...ys);
466
+ const aspect = mouthWidth > 0 ? mouthHeight / mouthWidth : 0;
467
+ this.aspectHistory.push(aspect);
468
+ if (this.aspectHistory.length >= 3) {
469
+ const min = Math.min(...this.aspectHistory);
470
+ const max = Math.max(...this.aspectHistory);
471
+ if (max - min > 0.05 && max > 0.15) {
472
+ this.smileDetected = true;
473
+ }
474
+ }
475
+ if (this.smileDetected) {
476
+ return { completed: true, progress: 1, feedback: "Smile detected!" };
477
+ }
478
+ if (aspect > 0.12) {
479
+ return { completed: false, progress: 0.6, feedback: "Keep smiling..." };
480
+ }
481
+ return { completed: false, progress: 0.2, feedback: "Please smile naturally." };
482
+ }
483
+ reset() {
484
+ this.aspectHistory = [];
485
+ this.smileDetected = false;
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Lightweight on-device challenge detection for UX guidance.
491
+ * This is NOT a security layer -- the server is the sole authority on liveness.
492
+ */
493
+ class ChallengeDetector {
494
+ constructor() {
495
+ this.blinkDetector = new BlinkDetector();
496
+ this.headTurnDetector = new HeadTurnDetector();
497
+ this.smileDetector = new SmileDetector();
498
+ }
499
+ /**
500
+ * Process a frame's face landmarks and return detection result for the given challenge.
501
+ */
502
+ detect(challenge, landmarks) {
503
+ switch (challenge) {
504
+ case "blink":
505
+ return this.blinkDetector.detect(landmarks);
506
+ case "turn_left":
507
+ return this.headTurnDetector.detect(landmarks, "left");
508
+ case "turn_right":
509
+ return this.headTurnDetector.detect(landmarks, "right");
510
+ case "smile":
511
+ return this.smileDetector.detect(landmarks);
512
+ default:
513
+ return { completed: false, progress: 0, feedback: "Unknown challenge" };
514
+ }
515
+ }
516
+ /**
517
+ * Reset all detectors for a new session.
518
+ */
519
+ reset() {
520
+ this.blinkDetector.reset();
521
+ this.headTurnDetector.reset();
522
+ this.smileDetector.reset();
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Animated SVG overlays that render on top of the camera feed.
528
+ * Semi-transparent so the user can see themselves through them.
529
+ */
530
+ const CHALLENGE_ILLUSTRATIONS = {
531
+ turn_right: `
532
+ <svg viewBox="0 0 320 400" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
533
+ <style>
534
+ .kr-guide { opacity: 0.85; }
535
+ .kr-arrow { animation: kr-slideLeft 1.2s ease-in-out infinite; }
536
+ @keyframes kr-slideLeft {
537
+ 0%,100% { transform: translateX(0); opacity: 0.5; }
538
+ 50% { transform: translateX(-12px); opacity: 1; }
539
+ }
540
+ .kr-face-outline { animation: kr-turnRight 2s ease-in-out infinite; transform-origin: 160px 170px; }
541
+ @keyframes kr-turnRight {
542
+ 0%,100% { transform: rotate(0deg); }
543
+ 50% { transform: rotate(-12deg); }
544
+ }
545
+ </style>
546
+ <g class="kr-guide">
547
+ <!-- Face oval guide -->
548
+ <ellipse class="kr-face-outline" cx="160" cy="170" rx="75" ry="95" fill="none" stroke="rgba(59,130,246,0.5)" stroke-width="2.5" stroke-dasharray="8 6"/>
549
+ <!-- Arrow points left on screen = user's right in mirror -->
550
+ <g class="kr-arrow">
551
+ <path d="M65 170 L45 155 L45 163 L25 163 L25 177 L45 177 L45 185 Z" fill="rgba(59,130,246,0.8)"/>
552
+ </g>
553
+ <!-- Label -->
554
+ <text x="160" y="300" text-anchor="middle" fill="rgba(255,255,255,0.9)" font-size="16" font-family="-apple-system,sans-serif" font-weight="600">Turn right</text>
555
+ </g>
556
+ </svg>
557
+ `,
558
+ turn_left: `
559
+ <svg viewBox="0 0 320 400" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
560
+ <style>
561
+ .kr-guide { opacity: 0.85; }
562
+ .kr-arrow { animation: kr-slideRight 1.2s ease-in-out infinite; }
563
+ @keyframes kr-slideRight {
564
+ 0%,100% { transform: translateX(0); opacity: 0.5; }
565
+ 50% { transform: translateX(12px); opacity: 1; }
566
+ }
567
+ .kr-face-outline { animation: kr-turnLeft 2s ease-in-out infinite; transform-origin: 160px 170px; }
568
+ @keyframes kr-turnLeft {
569
+ 0%,100% { transform: rotate(0deg); }
570
+ 50% { transform: rotate(12deg); }
571
+ }
572
+ </style>
573
+ <g class="kr-guide">
574
+ <ellipse class="kr-face-outline" cx="160" cy="170" rx="75" ry="95" fill="none" stroke="rgba(59,130,246,0.5)" stroke-width="2.5" stroke-dasharray="8 6"/>
575
+ <!-- Arrow points right on screen = user's left in mirror -->
576
+ <g class="kr-arrow">
577
+ <path d="M255 170 L275 155 L275 163 L295 163 L295 177 L275 177 L275 185 Z" fill="rgba(59,130,246,0.8)"/>
578
+ </g>
579
+ <text x="160" y="300" text-anchor="middle" fill="rgba(255,255,255,0.9)" font-size="16" font-family="-apple-system,sans-serif" font-weight="600">Turn left</text>
580
+ </g>
581
+ </svg>
582
+ `,
583
+ blink: `
584
+ <svg viewBox="0 0 320 400" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
585
+ <style>
586
+ .kr-guide { opacity: 0.85; }
587
+ .kr-eye { animation: kr-blink 2.2s ease-in-out infinite; }
588
+ @keyframes kr-blink {
589
+ 0%,30%,50%,100% { ry: 10; }
590
+ 38%,42% { ry: 1; }
591
+ }
592
+ .kr-face-outline { opacity: 1; }
593
+ </style>
594
+ <g class="kr-guide">
595
+ <ellipse class="kr-face-outline" cx="160" cy="170" rx="75" ry="95" fill="none" stroke="rgba(59,130,246,0.5)" stroke-width="2.5" stroke-dasharray="8 6"/>
596
+ <!-- Eye guides -->
597
+ <ellipse class="kr-eye" cx="130" cy="155" rx="15" ry="10" fill="none" stroke="rgba(59,130,246,0.7)" stroke-width="2"/>
598
+ <ellipse class="kr-eye" cx="190" cy="155" rx="15" ry="10" fill="none" stroke="rgba(59,130,246,0.7)" stroke-width="2"/>
599
+ <text x="160" y="300" text-anchor="middle" fill="rgba(255,255,255,0.9)" font-size="16" font-family="-apple-system,sans-serif" font-weight="600">Blink your eyes</text>
600
+ </g>
601
+ </svg>
602
+ `,
603
+ smile: `
604
+ <svg viewBox="0 0 320 400" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
605
+ <style>
606
+ .kr-guide { opacity: 0.85; }
607
+ .kr-mouth { animation: kr-smile 2s ease-in-out infinite; }
608
+ @keyframes kr-smile {
609
+ 0%,100% { d: path("M130 200 Q160 215 190 200"); }
610
+ 50% { d: path("M120 195 Q160 235 200 195"); }
611
+ }
612
+ .kr-face-outline { opacity: 1; }
613
+ </style>
614
+ <g class="kr-guide">
615
+ <ellipse class="kr-face-outline" cx="160" cy="170" rx="75" ry="95" fill="none" stroke="rgba(59,130,246,0.5)" stroke-width="2.5" stroke-dasharray="8 6"/>
616
+ <!-- Smile guide -->
617
+ <path class="kr-mouth" d="M130 200 Q160 215 190 200" fill="none" stroke="rgba(59,130,246,0.7)" stroke-width="2.5" stroke-linecap="round"/>
618
+ <text x="160" y="300" text-anchor="middle" fill="rgba(255,255,255,0.9)" font-size="16" font-family="-apple-system,sans-serif" font-weight="600">Smile naturally</text>
619
+ </g>
620
+ </svg>
621
+ `,
622
+ };
623
+ /**
624
+ * Processing/analyzing animation shown while waiting for server response.
625
+ */
626
+ const PROCESSING_ILLUSTRATION = `
627
+ <svg viewBox="0 0 200 200" width="200" height="200" xmlns="http://www.w3.org/2000/svg">
628
+ <style>
629
+ .kr-scan-ring { animation: kr-rotate 2s linear infinite; transform-origin: 100px 100px; }
630
+ @keyframes kr-rotate { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
631
+ .kr-pulse { animation: kr-pulse 1.5s ease-in-out infinite; transform-origin: 100px 100px; }
632
+ @keyframes kr-pulse { 0%,100% { transform: scale(1); opacity: 0.3; } 50% { transform: scale(1.08); opacity: 0.6; } }
633
+ .kr-dot { animation: kr-dotPulse 1.2s ease-in-out infinite; }
634
+ .kr-dot:nth-child(2) { animation-delay: 0.2s; }
635
+ .kr-dot:nth-child(3) { animation-delay: 0.4s; }
636
+ @keyframes kr-dotPulse { 0%,100% { opacity: 0.3; } 50% { opacity: 1; } }
637
+ .kr-check-path { stroke-dasharray: 60; stroke-dashoffset: 60; }
638
+ .kr-check-path.visible { animation: kr-drawCheck 0.5s ease forwards; }
639
+ @keyframes kr-drawCheck { to { stroke-dashoffset: 0; } }
640
+ </style>
641
+ <!-- Background pulse -->
642
+ <circle class="kr-pulse" cx="100" cy="100" r="70" fill="none" stroke="rgba(59,130,246,0.3)" stroke-width="1.5"/>
643
+ <!-- Spinning ring -->
644
+ <g class="kr-scan-ring">
645
+ <circle cx="100" cy="100" r="55" fill="none" stroke="rgba(59,130,246,0.15)" stroke-width="3"/>
646
+ <path d="M100 45 A55 55 0 0 1 155 100" fill="none" stroke="rgba(59,130,246,0.8)" stroke-width="3" stroke-linecap="round"/>
647
+ </g>
648
+ <!-- Face icon -->
649
+ <circle cx="100" cy="90" r="25" fill="none" stroke="rgba(255,255,255,0.5)" stroke-width="1.5"/>
650
+ <circle cx="91" cy="85" r="2.5" fill="rgba(255,255,255,0.5)"/>
651
+ <circle cx="109" cy="85" r="2.5" fill="rgba(255,255,255,0.5)"/>
652
+ <path d="M92 97 Q100 103 108 97" fill="none" stroke="rgba(255,255,255,0.5)" stroke-width="1.5" stroke-linecap="round"/>
653
+ <!-- Loading dots -->
654
+ <circle class="kr-dot" cx="85" cy="140" r="3" fill="rgba(59,130,246,0.8)"/>
655
+ <circle class="kr-dot" cx="100" cy="140" r="3" fill="rgba(59,130,246,0.8)"/>
656
+ <circle class="kr-dot" cx="115" cy="140" r="3" fill="rgba(59,130,246,0.8)"/>
657
+ </svg>
658
+ `;
659
+ /**
660
+ * Success checkmark animation.
661
+ */
662
+ const SUCCESS_ILLUSTRATION = `
663
+ <svg viewBox="0 0 200 200" width="200" height="200" xmlns="http://www.w3.org/2000/svg">
664
+ <style>
665
+ .kr-success-circle { animation: kr-scaleIn 0.4s ease forwards; transform-origin: 100px 100px; transform: scale(0); }
666
+ @keyframes kr-scaleIn { to { transform: scale(1); } }
667
+ .kr-success-check { stroke-dasharray: 60; stroke-dashoffset: 60; animation: kr-drawCheck 0.5s 0.3s ease forwards; }
668
+ @keyframes kr-drawCheck { to { stroke-dashoffset: 0; } }
669
+ </style>
670
+ <circle class="kr-success-circle" cx="100" cy="100" r="60" fill="rgba(34,197,94,0.15)" stroke="rgba(34,197,94,0.8)" stroke-width="3"/>
671
+ <path class="kr-success-check" d="M72 100 L92 120 L128 80" fill="none" stroke="rgba(34,197,94,0.9)" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
672
+ </svg>
673
+ `;
674
+ /**
675
+ * Failure X animation.
676
+ */
677
+ const FAILURE_ILLUSTRATION = `
678
+ <svg viewBox="0 0 200 200" width="200" height="200" xmlns="http://www.w3.org/2000/svg">
679
+ <style>
680
+ .kr-fail-circle { animation: kr-scaleIn 0.4s ease forwards; transform-origin: 100px 100px; transform: scale(0); }
681
+ @keyframes kr-scaleIn { to { transform: scale(1); } }
682
+ .kr-fail-x1 { stroke-dasharray: 50; stroke-dashoffset: 50; animation: kr-drawX 0.3s 0.3s ease forwards; }
683
+ .kr-fail-x2 { stroke-dasharray: 50; stroke-dashoffset: 50; animation: kr-drawX 0.3s 0.45s ease forwards; }
684
+ @keyframes kr-drawX { to { stroke-dashoffset: 0; } }
685
+ </style>
686
+ <circle class="kr-fail-circle" cx="100" cy="100" r="60" fill="rgba(239,68,68,0.15)" stroke="rgba(239,68,68,0.8)" stroke-width="3"/>
687
+ <line class="kr-fail-x1" x1="78" y1="78" x2="122" y2="122" stroke="rgba(239,68,68,0.9)" stroke-width="4" stroke-linecap="round"/>
688
+ <line class="kr-fail-x2" x1="122" y1="78" x2="78" y2="122" stroke="rgba(239,68,68,0.9)" stroke-width="4" stroke-linecap="round"/>
689
+ </svg>
690
+ `;
691
+ function getIllustrationHTML(challenge) {
692
+ return CHALLENGE_ILLUSTRATIONS[challenge] ?? "";
693
+ }
694
+
695
+ const OVERLAY_STYLES = `
696
+ .kreltix-overlay {
697
+ position: fixed;
698
+ top: 0; left: 0; right: 0; bottom: 0;
699
+ background: rgba(0, 0, 0, 0.95);
700
+ z-index: 99999;
701
+ display: flex;
702
+ flex-direction: column;
703
+ align-items: center;
704
+ justify-content: center;
705
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
706
+ color: #fff;
707
+ gap: 0;
708
+ }
709
+
710
+ /* Step indicators */
711
+ .kreltix-steps {
712
+ display: flex;
713
+ gap: 8px;
714
+ margin-bottom: 14px;
715
+ }
716
+ .kreltix-step {
717
+ width: 40px;
718
+ height: 4px;
719
+ border-radius: 2px;
720
+ background: rgba(255,255,255,0.12);
721
+ transition: background 0.4s;
722
+ }
723
+ .kreltix-step.active { background: #3b82f6; }
724
+ .kreltix-step.done { background: #22c55e; }
725
+
726
+ .kreltix-step-label {
727
+ font-size: 12px;
728
+ color: rgba(255,255,255,0.4);
729
+ margin-bottom: 14px;
730
+ letter-spacing: 0.5px;
731
+ }
732
+
733
+ /* Camera area with overlay illustration */
734
+ .kreltix-camera-wrapper {
735
+ position: relative;
736
+ width: 300px;
737
+ height: 380px;
738
+ border-radius: 20px;
739
+ overflow: hidden;
740
+ border: 3px solid rgba(255,255,255,0.1);
741
+ transition: border-color 0.4s;
742
+ background: #000;
743
+ }
744
+ .kreltix-camera-wrapper.recording { border-color: rgba(59,130,246,0.6); }
745
+ .kreltix-camera-wrapper.completed { border-color: rgba(34,197,94,0.6); }
746
+
747
+ .kreltix-camera-wrapper video {
748
+ width: 100%;
749
+ height: 100%;
750
+ object-fit: cover;
751
+ transform: scaleX(-1);
752
+ }
753
+
754
+ /* SVG guide overlay on top of camera */
755
+ .kreltix-guide-overlay {
756
+ position: absolute;
757
+ top: 0; left: 0; right: 0; bottom: 0;
758
+ pointer-events: none;
759
+ transition: opacity 0.4s;
760
+ }
761
+ .kreltix-guide-overlay.hidden { opacity: 0; }
762
+
763
+ /* Recording indicator */
764
+ .kreltix-rec-dot {
765
+ position: absolute;
766
+ top: 14px;
767
+ right: 14px;
768
+ display: flex;
769
+ align-items: center;
770
+ gap: 6px;
771
+ opacity: 0;
772
+ transition: opacity 0.3s;
773
+ }
774
+ .kreltix-rec-dot.visible { opacity: 1; }
775
+ .kreltix-rec-dot::before {
776
+ content: '';
777
+ width: 8px;
778
+ height: 8px;
779
+ border-radius: 50%;
780
+ background: #ef4444;
781
+ animation: kr-recBlink 1s ease-in-out infinite;
782
+ }
783
+ @keyframes kr-recBlink { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
784
+ .kreltix-rec-dot span {
785
+ font-size: 11px;
786
+ font-weight: 600;
787
+ color: rgba(255,255,255,0.8);
788
+ letter-spacing: 0.5px;
789
+ }
790
+
791
+ /* Timer bar at bottom of camera */
792
+ .kreltix-timer-bar {
793
+ position: absolute;
794
+ bottom: 0; left: 0; right: 0;
795
+ height: 3px;
796
+ background: rgba(255,255,255,0.1);
797
+ }
798
+ .kreltix-timer-fill {
799
+ height: 100%;
800
+ background: #3b82f6;
801
+ width: 0%;
802
+ transition: width 0.1s linear;
803
+ }
804
+
805
+ /* Text below camera */
806
+ .kreltix-instructions {
807
+ margin-top: 18px;
808
+ font-size: 17px;
809
+ font-weight: 600;
810
+ text-align: center;
811
+ max-width: 280px;
812
+ line-height: 1.4;
813
+ }
814
+ .kreltix-feedback {
815
+ margin-top: 6px;
816
+ font-size: 13px;
817
+ color: rgba(255,255,255,0.45);
818
+ text-align: center;
819
+ min-height: 20px;
820
+ }
821
+
822
+ /* Processing state (replaces camera) */
823
+ .kreltix-processing {
824
+ display: none;
825
+ flex-direction: column;
826
+ align-items: center;
827
+ gap: 20px;
828
+ }
829
+ .kreltix-processing.visible {
830
+ display: flex;
831
+ }
832
+ .kreltix-processing-anim {
833
+ display: flex;
834
+ align-items: center;
835
+ justify-content: center;
836
+ }
837
+ .kreltix-processing-text {
838
+ font-size: 16px;
839
+ font-weight: 500;
840
+ color: rgba(255,255,255,0.8);
841
+ text-align: center;
842
+ }
843
+ .kreltix-processing-sub {
844
+ font-size: 13px;
845
+ color: rgba(255,255,255,0.35);
846
+ text-align: center;
847
+ margin-top: -8px;
848
+ }
849
+
850
+ /* Result state */
851
+ .kreltix-result {
852
+ display: none;
853
+ flex-direction: column;
854
+ align-items: center;
855
+ gap: 16px;
856
+ }
857
+ .kreltix-result.visible { display: flex; }
858
+ .kreltix-result-text {
859
+ font-size: 20px;
860
+ font-weight: 700;
861
+ text-align: center;
862
+ }
863
+ .kreltix-result-text.pass { color: #22c55e; }
864
+ .kreltix-result-text.fail { color: #ef4444; }
865
+ .kreltix-result-sub {
866
+ font-size: 13px;
867
+ color: rgba(255,255,255,0.4);
868
+ text-align: center;
869
+ max-width: 260px;
870
+ }
871
+ `;
872
+ class ChallengeOverlay {
873
+ constructor() {
874
+ this.overlay = null;
875
+ this.cameraWrapper = null;
876
+ this.guideOverlay = null;
877
+ this.recDot = null;
878
+ this.timerFill = null;
879
+ this.instructionsEl = null;
880
+ this.feedbackEl = null;
881
+ this.styleEl = null;
882
+ this.stepsContainer = null;
883
+ this.stepLabelEl = null;
884
+ this.stepEls = [];
885
+ this.processingEl = null;
886
+ this.processingAnimEl = null;
887
+ this.processingTextEl = null;
888
+ this.processingSubEl = null;
889
+ this.resultEl = null;
890
+ this.resultTextEl = null;
891
+ this.resultSubEl = null;
892
+ this.resultAnimEl = null;
893
+ this.timerInterval = null;
894
+ }
895
+ mount(container, totalSteps = 1) {
896
+ this.styleEl = document.createElement("style");
897
+ this.styleEl.textContent = OVERLAY_STYLES;
898
+ document.head.appendChild(this.styleEl);
899
+ this.overlay = document.createElement("div");
900
+ this.overlay.className = "kreltix-overlay";
901
+ // Steps
902
+ this.stepsContainer = document.createElement("div");
903
+ this.stepsContainer.className = "kreltix-steps";
904
+ this.stepEls = [];
905
+ for (let i = 0; i < totalSteps; i++) {
906
+ const step = document.createElement("div");
907
+ step.className = "kreltix-step";
908
+ this.stepsContainer.appendChild(step);
909
+ this.stepEls.push(step);
910
+ }
911
+ this.stepLabelEl = document.createElement("div");
912
+ this.stepLabelEl.className = "kreltix-step-label";
913
+ // Camera wrapper with guide overlay
914
+ this.cameraWrapper = document.createElement("div");
915
+ this.cameraWrapper.className = "kreltix-camera-wrapper";
916
+ this.guideOverlay = document.createElement("div");
917
+ this.guideOverlay.className = "kreltix-guide-overlay";
918
+ this.cameraWrapper.appendChild(this.guideOverlay);
919
+ this.recDot = document.createElement("div");
920
+ this.recDot.className = "kreltix-rec-dot";
921
+ this.recDot.innerHTML = "<span>REC</span>";
922
+ this.cameraWrapper.appendChild(this.recDot);
923
+ const timerBar = document.createElement("div");
924
+ timerBar.className = "kreltix-timer-bar";
925
+ this.timerFill = document.createElement("div");
926
+ this.timerFill.className = "kreltix-timer-fill";
927
+ timerBar.appendChild(this.timerFill);
928
+ this.cameraWrapper.appendChild(timerBar);
929
+ // Text
930
+ this.instructionsEl = document.createElement("div");
931
+ this.instructionsEl.className = "kreltix-instructions";
932
+ this.feedbackEl = document.createElement("div");
933
+ this.feedbackEl.className = "kreltix-feedback";
934
+ // Processing state
935
+ this.processingEl = document.createElement("div");
936
+ this.processingEl.className = "kreltix-processing";
937
+ this.processingAnimEl = document.createElement("div");
938
+ this.processingAnimEl.className = "kreltix-processing-anim";
939
+ this.processingTextEl = document.createElement("div");
940
+ this.processingTextEl.className = "kreltix-processing-text";
941
+ this.processingSubEl = document.createElement("div");
942
+ this.processingSubEl.className = "kreltix-processing-sub";
943
+ this.processingEl.appendChild(this.processingAnimEl);
944
+ this.processingEl.appendChild(this.processingTextEl);
945
+ this.processingEl.appendChild(this.processingSubEl);
946
+ // Result state
947
+ this.resultEl = document.createElement("div");
948
+ this.resultEl.className = "kreltix-result";
949
+ this.resultAnimEl = document.createElement("div");
950
+ this.resultAnimEl.className = "kreltix-processing-anim";
951
+ this.resultTextEl = document.createElement("div");
952
+ this.resultTextEl.className = "kreltix-result-text";
953
+ this.resultSubEl = document.createElement("div");
954
+ this.resultSubEl.className = "kreltix-result-sub";
955
+ this.resultEl.appendChild(this.resultAnimEl);
956
+ this.resultEl.appendChild(this.resultTextEl);
957
+ this.resultEl.appendChild(this.resultSubEl);
958
+ // Assemble
959
+ this.overlay.appendChild(this.stepsContainer);
960
+ this.overlay.appendChild(this.stepLabelEl);
961
+ this.overlay.appendChild(this.cameraWrapper);
962
+ this.overlay.appendChild(this.instructionsEl);
963
+ this.overlay.appendChild(this.feedbackEl);
964
+ this.overlay.appendChild(this.processingEl);
965
+ this.overlay.appendChild(this.resultEl);
966
+ const target = container ?? document.body;
967
+ target.appendChild(this.overlay);
968
+ return this.cameraWrapper;
969
+ }
970
+ setStep(index, total, challenge) {
971
+ this.stepEls.forEach((el, i) => {
972
+ el.className = "kreltix-step";
973
+ if (i < index)
974
+ el.classList.add("done");
975
+ else if (i === index)
976
+ el.classList.add("active");
977
+ });
978
+ if (this.stepLabelEl) {
979
+ this.stepLabelEl.textContent = `Step ${index + 1} of ${total}`;
980
+ }
981
+ // Show illustration on camera
982
+ if (this.guideOverlay) {
983
+ this.guideOverlay.innerHTML = getIllustrationHTML(challenge);
984
+ this.guideOverlay.className = "kreltix-guide-overlay";
985
+ }
986
+ }
987
+ startRecording(durationMs = 7000) {
988
+ if (this.cameraWrapper)
989
+ this.cameraWrapper.className = "kreltix-camera-wrapper recording";
990
+ if (this.recDot)
991
+ this.recDot.className = "kreltix-rec-dot visible";
992
+ // Timer bar animation
993
+ const startTime = Date.now();
994
+ if (this.timerInterval)
995
+ clearInterval(this.timerInterval);
996
+ this.timerInterval = setInterval(() => {
997
+ const elapsed = Date.now() - startTime;
998
+ const pct = Math.min(100, (elapsed / durationMs) * 100);
999
+ if (this.timerFill)
1000
+ this.timerFill.style.width = `${pct}%`;
1001
+ if (elapsed >= durationMs && this.timerInterval) {
1002
+ clearInterval(this.timerInterval);
1003
+ }
1004
+ }, 50);
1005
+ }
1006
+ stopRecording() {
1007
+ if (this.timerInterval) {
1008
+ clearInterval(this.timerInterval);
1009
+ this.timerInterval = null;
1010
+ }
1011
+ if (this.recDot)
1012
+ this.recDot.className = "kreltix-rec-dot";
1013
+ if (this.timerFill)
1014
+ this.timerFill.style.width = "100%";
1015
+ if (this.cameraWrapper)
1016
+ this.cameraWrapper.className = "kreltix-camera-wrapper completed";
1017
+ }
1018
+ hideGuide() {
1019
+ if (this.guideOverlay)
1020
+ this.guideOverlay.className = "kreltix-guide-overlay hidden";
1021
+ }
1022
+ showProcessing() {
1023
+ // Hide camera and text, show processing
1024
+ if (this.cameraWrapper)
1025
+ this.cameraWrapper.style.display = "none";
1026
+ if (this.stepsContainer)
1027
+ this.stepsContainer.style.display = "none";
1028
+ if (this.stepLabelEl)
1029
+ this.stepLabelEl.style.display = "none";
1030
+ if (this.instructionsEl)
1031
+ this.instructionsEl.style.display = "none";
1032
+ if (this.feedbackEl)
1033
+ this.feedbackEl.style.display = "none";
1034
+ if (this.processingAnimEl)
1035
+ this.processingAnimEl.innerHTML = PROCESSING_ILLUSTRATION;
1036
+ if (this.processingTextEl)
1037
+ this.processingTextEl.textContent = "Analyzing your face...";
1038
+ if (this.processingSubEl)
1039
+ this.processingSubEl.textContent = "This usually takes a few seconds";
1040
+ if (this.processingEl)
1041
+ this.processingEl.className = "kreltix-processing visible";
1042
+ }
1043
+ updateProcessingText(text, sub) {
1044
+ if (this.processingTextEl)
1045
+ this.processingTextEl.textContent = text;
1046
+ if (sub && this.processingSubEl)
1047
+ this.processingSubEl.textContent = sub;
1048
+ }
1049
+ showResult(passed, confidence) {
1050
+ if (this.processingEl)
1051
+ this.processingEl.className = "kreltix-processing";
1052
+ if (this.resultAnimEl) {
1053
+ this.resultAnimEl.innerHTML = passed ? SUCCESS_ILLUSTRATION : FAILURE_ILLUSTRATION;
1054
+ }
1055
+ if (this.resultTextEl) {
1056
+ this.resultTextEl.textContent = passed ? "Verification Passed" : "Verification Failed";
1057
+ this.resultTextEl.className = `kreltix-result-text ${passed ? "pass" : "fail"}`;
1058
+ }
1059
+ if (this.resultSubEl) {
1060
+ this.resultSubEl.textContent = passed
1061
+ ? `Confidence: ${confidence}%`
1062
+ : "Please try again with better lighting and follow the instructions carefully.";
1063
+ }
1064
+ if (this.resultEl)
1065
+ this.resultEl.className = "kreltix-result visible";
1066
+ }
1067
+ setInstructions(text) {
1068
+ if (this.instructionsEl)
1069
+ this.instructionsEl.textContent = text;
1070
+ }
1071
+ setFeedback(text) {
1072
+ if (this.feedbackEl)
1073
+ this.feedbackEl.textContent = text;
1074
+ }
1075
+ unmount() {
1076
+ if (this.timerInterval)
1077
+ clearInterval(this.timerInterval);
1078
+ if (this.overlay) {
1079
+ this.overlay.remove();
1080
+ this.overlay = null;
1081
+ }
1082
+ if (this.styleEl) {
1083
+ this.styleEl.remove();
1084
+ this.styleEl = null;
1085
+ }
1086
+ }
1087
+ }
1088
+
1089
+ const DEFAULT_BASE_URL = "https://api.kreltix.com";
1090
+ const DEFAULT_TIMEOUT = 10000;
1091
+ const DEFAULT_MAX_RETRIES = 2;
1092
+ const RECORDING_DURATION_MS = 7000;
1093
+ class KreltixSDK {
1094
+ constructor(config) {
1095
+ this.config = config;
1096
+ this.apiClient = new ApiClient(config);
1097
+ this.sessionManager = new SessionManager(this.apiClient);
1098
+ }
1099
+ static initialize(publicKey, options) {
1100
+ if (!publicKey.startsWith("pk_live_")) {
1101
+ throw new KreltixError("Invalid key. Use a public key (pk_live_...) for client-side SDK.", "INVALID_KEY");
1102
+ }
1103
+ return new KreltixSDK({
1104
+ publicKey,
1105
+ baseUrl: options?.baseUrl ?? DEFAULT_BASE_URL,
1106
+ timeout: options?.timeout ?? DEFAULT_TIMEOUT,
1107
+ maxRetries: options?.maxRetries ?? DEFAULT_MAX_RETRIES,
1108
+ });
1109
+ }
1110
+ async startLivenessCheck(options = {}) {
1111
+ const { container, showOverlay = true, metadata, cameraAdapter, onSessionCreated, onChallengeStarted, onProgress, onError, } = options;
1112
+ const notify = (event) => onProgress?.(event);
1113
+ const camera = new CameraManager(cameraAdapter);
1114
+ const detector = new ChallengeDetector();
1115
+ let overlay = null;
1116
+ try {
1117
+ // 1. Create session
1118
+ notify({ stage: "session_created", message: "Creating verification session..." });
1119
+ const session = await this.sessionManager.createSession(metadata);
1120
+ onSessionCreated?.(session);
1121
+ const { challenges, instructions } = session;
1122
+ notify({ stage: "session_created", message: `Challenges: ${challenges.join(" → ")}` });
1123
+ // 2. Mount overlay & start camera
1124
+ if (showOverlay)
1125
+ overlay = new ChallengeOverlay();
1126
+ const cameraContainer = overlay?.mount(container, challenges.length);
1127
+ notify({ stage: "camera_ready", message: "Opening camera..." });
1128
+ const stream = await camera.start();
1129
+ // Insert video element BEFORE the guide overlay so guide renders on top
1130
+ const videoEl = camera.getVideoElement();
1131
+ if (cameraContainer) {
1132
+ cameraContainer.insertBefore(videoEl, cameraContainer.firstChild);
1133
+ }
1134
+ notify({ stage: "camera_ready", message: "Camera ready" });
1135
+ // 3. Record each challenge step
1136
+ const recordedVideos = [];
1137
+ for (let i = 0; i < challenges.length; i++) {
1138
+ const challenge = challenges[i];
1139
+ const instruction = instructions[i];
1140
+ // Show step + guide overlay on camera
1141
+ if (overlay) {
1142
+ overlay.setStep(i, challenges.length, challenge);
1143
+ overlay.setInstructions(instruction);
1144
+ overlay.setFeedback("Position your face in the oval");
1145
+ }
1146
+ onChallengeStarted?.(challenge);
1147
+ notify({ stage: "recording", message: `Step ${i + 1}/${challenges.length}: ${challenge}` });
1148
+ // Pause so user can see the animated guide on camera
1149
+ await sleep(2000);
1150
+ // Start recording
1151
+ if (overlay) {
1152
+ overlay.setFeedback("Recording...");
1153
+ overlay.startRecording(RECORDING_DURATION_MS);
1154
+ }
1155
+ detector.reset();
1156
+ const recorder = new VideoRecorder();
1157
+ const { promise: videoPromise } = recorder.record(stream, {
1158
+ minDurationMs: 3000,
1159
+ maxDurationMs: RECORDING_DURATION_MS,
1160
+ });
1161
+ const videoBase64 = await videoPromise;
1162
+ recordedVideos.push(videoBase64);
1163
+ // Step done
1164
+ if (overlay) {
1165
+ overlay.stopRecording();
1166
+ overlay.setFeedback("Done!");
1167
+ }
1168
+ // Brief pause between steps
1169
+ if (i < challenges.length - 1) {
1170
+ await sleep(1000);
1171
+ }
1172
+ }
1173
+ // 4. Processing phase
1174
+ if (overlay) {
1175
+ overlay.showProcessing();
1176
+ }
1177
+ notify({ stage: "uploading", message: "Uploading videos..." });
1178
+ // Simulate progress messages during upload
1179
+ const progressMessages = [
1180
+ { delay: 2000, text: "Uploading videos...", sub: "This usually takes a few seconds" },
1181
+ { delay: 4000, text: "Analyzing facial features...", sub: "Checking liveness indicators" },
1182
+ { delay: 7000, text: "Verifying challenge responses...", sub: "Almost done" },
1183
+ ];
1184
+ const messageTimers = progressMessages.map(({ delay, text, sub }) => setTimeout(() => overlay?.updateProcessingText(text, sub), delay));
1185
+ try {
1186
+ const result = await this.sessionManager.submitVerification(recordedVideos);
1187
+ // Clear progress timers
1188
+ messageTimers.forEach(clearTimeout);
1189
+ notify({ stage: "complete", message: "Verification complete" });
1190
+ // Show result for 2 seconds
1191
+ if (overlay) {
1192
+ overlay.showResult(result.is_live, result.confidence);
1193
+ await sleep(2500);
1194
+ }
1195
+ return {
1196
+ sessionId: result.session_id,
1197
+ isLive: result.is_live,
1198
+ confidence: result.confidence,
1199
+ challengeResults: result.challenges_completed,
1200
+ allChallengesPassed: result.all_challenges_passed,
1201
+ recommendation: result.recommendation,
1202
+ error: result.error,
1203
+ indicators: result.indicators,
1204
+ };
1205
+ }
1206
+ catch (err) {
1207
+ messageTimers.forEach(clearTimeout);
1208
+ if (err instanceof KreltixSessionExpiredError) {
1209
+ notify({ stage: "session_created", message: "Session expired, please try again." });
1210
+ }
1211
+ throw err;
1212
+ }
1213
+ }
1214
+ catch (err) {
1215
+ onError?.(err);
1216
+ throw err;
1217
+ }
1218
+ finally {
1219
+ camera.stop();
1220
+ overlay?.unmount();
1221
+ detector.reset();
1222
+ }
1223
+ }
1224
+ }
1225
+ function sleep(ms) {
1226
+ return new Promise((r) => setTimeout(r, ms));
1227
+ }
1228
+
1229
+ export { BlinkDetector, CHALLENGE_ILLUSTRATIONS, CameraManager, ChallengeDetector, ChallengeOverlay, HeadTurnDetector, KreltixAuthError, KreltixCameraError, KreltixError, KreltixInsufficientFundsError, KreltixNetworkError, KreltixSDK, KreltixServerError, KreltixSessionExpiredError, KreltixValidationError, SmileDetector, VideoRecorder, WebCameraAdapter, getIllustrationHTML };
1230
+ //# sourceMappingURL=index.esm.js.map