@moveris/shared 0.0.1 → 1.0.1

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/dist/index.js CHANGED
@@ -20,67 +20,158 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ ALIGNMENT_THRESHOLD_CAPTURE: () => ALIGNMENT_THRESHOLD_CAPTURE,
24
+ ALIGNMENT_THRESHOLD_GOOD: () => ALIGNMENT_THRESHOLD_GOOD,
25
+ ALIGNMENT_THRESHOLD_PERFECT: () => ALIGNMENT_THRESHOLD_PERFECT,
26
+ ALIGNMENT_THRESHOLD_POOR: () => ALIGNMENT_THRESHOLD_POOR,
23
27
  API_ENDPOINTS: () => API_ENDPOINTS,
28
+ API_PATHS: () => API_PATHS,
24
29
  AUTH_CONFIG: () => AUTH_CONFIG,
25
- AuthManager: () => AuthManager,
26
- ConnectionManager: () => ConnectionManager,
30
+ BACKLIT_RATIO_THRESHOLD: () => BACKLIT_RATIO_THRESHOLD,
31
+ BLUR_THRESHOLD_MOBILE: () => BLUR_THRESHOLD_MOBILE,
32
+ BaseFrameCollector: () => BaseFrameCollector,
33
+ DEFAULT_BLUR_THRESHOLD: () => DEFAULT_BLUR_THRESHOLD,
27
34
  DEFAULT_ENDPOINT: () => DEFAULT_ENDPOINT,
35
+ DEFAULT_LIVENESS_CONFIG: () => DEFAULT_LIVENESS_CONFIG,
36
+ DEFAULT_LOCALE: () => DEFAULT_LOCALE,
37
+ DEFAULT_OVAL_REGION: () => DEFAULT_OVAL_REGION,
38
+ DEFAULT_STATUS_MESSAGES: () => DEFAULT_STATUS_MESSAGES,
39
+ ES_LOCALE: () => ES_LOCALE,
40
+ FACE_CENTER_VERTICAL_OFFSET: () => FACE_CENTER_VERTICAL_OFFSET,
41
+ FACE_CROP_OUTPUT_SIZE: () => FACE_CROP_OUTPUT_SIZE,
42
+ FEEDBACK_MESSAGES: () => FEEDBACK_MESSAGES,
28
43
  FRAME_BUFFER_CONFIG: () => FRAME_BUFFER_CONFIG,
29
44
  FRAME_CONFIG: () => FRAME_CONFIG,
30
45
  FrameBuffer: () => FrameBuffer,
31
46
  FrameQueue: () => FrameQueue,
32
- RECONNECTION_CONFIG: () => RECONNECTION_CONFIG,
33
- WebSocketClient: () => WebSocketClient,
47
+ GOOD_ALIGNMENT: () => GOOD_ALIGNMENT,
48
+ HIGH_ALIGNMENT: () => HIGH_ALIGNMENT,
49
+ HYBRID_MODEL_CONFIGS: () => HYBRID_MODEL_CONFIGS,
50
+ IDEAL_CROP_MULTIPLIER: () => IDEAL_CROP_MULTIPLIER,
51
+ LOW_LIGHT_THRESHOLD: () => LOW_LIGHT_THRESHOLD,
52
+ LivenessApiError: () => LivenessApiError,
53
+ LivenessClient: () => LivenessClient,
54
+ MAX_CROP_MULTIPLIER: () => MAX_CROP_MULTIPLIER,
55
+ MAX_FACE_PERCENTAGE_IN_CROP: () => MAX_FACE_PERCENTAGE_IN_CROP,
56
+ MAX_FACE_RATIO: () => MAX_FACE_RATIO,
57
+ MIN_CAPTURE_ALIGNMENT: () => MIN_CAPTURE_ALIGNMENT,
58
+ MIN_CROP_MULTIPLIER: () => MIN_CROP_MULTIPLIER,
59
+ MIN_FACE_BOTTOM_MARGIN: () => MIN_FACE_BOTTOM_MARGIN,
60
+ MIN_FACE_RATIO: () => MIN_FACE_RATIO,
61
+ MIN_FACE_SIDE_MARGIN: () => MIN_FACE_SIDE_MARGIN,
62
+ MIN_FACE_TOP_MARGIN: () => MIN_FACE_TOP_MARGIN,
63
+ MODEL_CONFIGS: () => MODEL_CONFIGS,
64
+ OVAL_GUIDE_COLORS: () => OVAL_GUIDE_COLORS,
65
+ OVAL_GUIDE_STYLES: () => OVAL_GUIDE_STYLES,
66
+ RETRY_CONFIG: () => RETRY_CONFIG,
67
+ TARGET_FACE_PERCENTAGE_IN_CROP: () => TARGET_FACE_PERCENTAGE_IN_CROP,
68
+ analyzeBlur: () => analyzeBlur,
69
+ analyzeLighting: () => analyzeLighting,
70
+ calculateAdaptiveCropMultiplier: () => calculateAdaptiveCropMultiplier,
71
+ calculateBrightness: () => calculateBrightness,
72
+ calculateFaceAlignment: () => calculateFaceAlignment,
73
+ calculateFaceCropRegion: () => calculateFaceCropRegion,
74
+ canCaptureFrame: () => canCaptureFrame,
75
+ checkFrameQuality: () => checkFrameQuality,
34
76
  decodeBase64: () => decodeBase64,
35
77
  encodeBase64: () => encodeBase64,
78
+ generateSessionId: () => generateSessionId,
79
+ getCaptureQualityFeedback: () => getCaptureQualityFeedback,
80
+ getFeedbackMessage: () => getFeedbackMessage,
81
+ getMinFramesForModel: () => getMinFramesForModel,
82
+ getOvalGuideState: () => getOvalGuideState,
83
+ getStatusMessage: () => getStatusMessage,
84
+ hasEnoughFrames: () => hasEnoughFrames,
85
+ isFaceCropFullyInFrame: () => isFaceCropFullyInFrame,
86
+ isFaceFullyVisible: () => isFaceFullyVisible,
87
+ isFaceInOval: () => isFaceInOval,
36
88
  retryWithBackoff: () => retryWithBackoff,
89
+ rgbaToGrayscale: () => rgbaToGrayscale,
90
+ sleep: () => sleep,
91
+ toFrameData: () => toFrameData,
92
+ toHybridFrameData: () => toHybridFrameData,
93
+ toLivenessResult: () => toLivenessResult,
37
94
  validateApiKey: () => validateApiKey,
95
+ validateFrameCount: () => validateFrameCount,
38
96
  validateFrameData: () => validateFrameData,
39
- validateFrameNumber: () => validateFrameNumber
97
+ validateFrameIndex: () => validateFrameIndex,
98
+ validateTimestamp: () => validateTimestamp,
99
+ validateUUID: () => validateUUID,
100
+ validateUrl: () => validateUrl
40
101
  });
41
102
  module.exports = __toCommonJS(index_exports);
42
103
 
43
104
  // src/constants/config.ts
44
105
  var API_ENDPOINTS = {
45
- production: "wss://api.moveris.com/v1/liveness",
46
- staging: "wss://staging-api.moveris.com/v1/liveness",
47
- development: "ws://localhost:8080/v1/liveness"
106
+ production: "https://api.moveris.com",
107
+ staging: "https://moveris-api-v2-staging.fly.dev",
108
+ development: "http://localhost:8000"
48
109
  };
49
110
  var DEFAULT_ENDPOINT = API_ENDPOINTS.production;
50
- var RECONNECTION_CONFIG = {
51
- maxAttempts: 10,
111
+ var API_PATHS = {
112
+ health: "/health",
113
+ fastCheck: "/api/v1/fast-check",
114
+ fastCheckCrops: "/api/v1/fast-check-crops",
115
+ verify: "/api/v1/verify",
116
+ hybridCheck: "/api/v1/hybrid-check",
117
+ hybrid50: "/api/v1/hybrid-50",
118
+ hybrid150: "/api/v1/hybrid-150",
119
+ jobResult: "/api/v1/result",
120
+ queueStats: "/api/v1/queue/stats"
121
+ };
122
+ var RETRY_CONFIG = {
123
+ maxAttempts: 3,
52
124
  initialDelay: 1e3,
53
125
  // 1 second
54
- maxDelay: 3e4,
55
- // 30 seconds
126
+ maxDelay: 1e4,
127
+ // 10 seconds
56
128
  backoffMultiplier: 2
57
129
  };
58
130
  var FRAME_BUFFER_CONFIG = {
59
131
  maxSize: 10,
60
- // Maximum un-acked frames
132
+ // Maximum frames to keep in buffer
61
133
  maxMemory: 4 * 1024 * 1024
62
134
  // 4 MB
63
135
  };
64
136
  var AUTH_CONFIG = {
65
- timeout: 1e4,
66
- // 10 seconds
67
- apiKeyPattern: /^mv_[a-zA-Z0-9]{32}$/
137
+ timeout: 3e4,
138
+ // 30 seconds for API requests
139
+ apiKeyHeader: "X-API-Key"
68
140
  };
69
141
  var FRAME_CONFIG = {
70
142
  targetFPS: 30,
71
143
  frameInterval: 33,
72
144
  // milliseconds (1000 / 30)
73
- jpegQuality: 0.8
145
+ jpegQuality: 0.8,
74
146
  // 80%
147
+ defaultModel: "10",
148
+ defaultSource: "live"
149
+ };
150
+ var DEFAULT_LIVENESS_CONFIG = {
151
+ model: FRAME_CONFIG.defaultModel,
152
+ source: FRAME_CONFIG.defaultSource,
153
+ fps: FRAME_CONFIG.targetFPS,
154
+ timeout: AUTH_CONFIG.timeout
75
155
  };
76
156
 
77
157
  // src/utils/retry.ts
158
+ function defaultIsRetryable(error) {
159
+ if (error instanceof Error) {
160
+ if ("statusCode" in error) {
161
+ const statusCode = error.statusCode;
162
+ return statusCode === 0 || statusCode === 408 || statusCode === 429 || statusCode >= 500;
163
+ }
164
+ return error.name === "AbortError" || error.message.includes("network") || error.message.includes("fetch");
165
+ }
166
+ return false;
167
+ }
78
168
  async function retryWithBackoff(fn, options = {}) {
79
169
  const {
80
- maxAttempts = RECONNECTION_CONFIG.maxAttempts,
81
- initialDelay = RECONNECTION_CONFIG.initialDelay,
82
- maxDelay = RECONNECTION_CONFIG.maxDelay,
83
- backoffMultiplier = RECONNECTION_CONFIG.backoffMultiplier
170
+ maxAttempts = RETRY_CONFIG.maxAttempts,
171
+ initialDelay = RETRY_CONFIG.initialDelay,
172
+ maxDelay = RETRY_CONFIG.maxDelay,
173
+ backoffMultiplier = RETRY_CONFIG.backoffMultiplier,
174
+ isRetryable = defaultIsRetryable
84
175
  } = options;
85
176
  let lastError;
86
177
  let delay = initialDelay;
@@ -89,6 +180,9 @@ async function retryWithBackoff(fn, options = {}) {
89
180
  return await fn();
90
181
  } catch (error) {
91
182
  lastError = error;
183
+ if (!isRetryable(error)) {
184
+ throw error;
185
+ }
92
186
  if (attempt < maxAttempts - 1) {
93
187
  await new Promise((resolve) => setTimeout(resolve, delay));
94
188
  delay = Math.min(delay * backoffMultiplier, maxDelay);
@@ -97,189 +191,294 @@ async function retryWithBackoff(fn, options = {}) {
97
191
  }
98
192
  throw lastError;
99
193
  }
194
+ async function sleep(ms) {
195
+ await new Promise((resolve) => setTimeout(resolve, ms));
196
+ }
100
197
 
101
- // src/client/ConnectionManager.ts
102
- var ConnectionManager = class {
103
- constructor(config) {
104
- this.reconnectAttempts = 0;
105
- this.config = config;
106
- }
107
- async handleDisconnect() {
108
- if (this.reconnectAttempts >= (this.config.maxAttempts ?? 10)) {
109
- throw new Error("Max reconnection attempts reached");
110
- }
111
- try {
112
- await retryWithBackoff(
113
- async () => {
114
- await this.config.onReconnect();
115
- this.reconnectAttempts = 0;
116
- },
117
- {
118
- maxAttempts: this.config.maxAttempts ?? 10
119
- }
120
- );
121
- } catch (error) {
122
- this.reconnectAttempts++;
123
- throw error;
124
- }
125
- }
126
- reset() {
127
- this.reconnectAttempts = 0;
198
+ // src/client/LivenessClient.ts
199
+ var LivenessApiError = class extends Error {
200
+ constructor(message, code, statusCode, required, received) {
201
+ super(message);
202
+ this.name = "LivenessApiError";
203
+ this.code = code;
204
+ this.statusCode = statusCode;
205
+ this.required = required;
206
+ this.received = received;
128
207
  }
129
208
  };
130
-
131
- // src/utils/validators.ts
132
- function validateApiKey(apiKey) {
133
- if (!apiKey || typeof apiKey !== "string") {
134
- return false;
135
- }
136
- return AUTH_CONFIG.apiKeyPattern.test(apiKey);
209
+ function toFrameData(frames) {
210
+ return frames.map((frame) => ({
211
+ index: frame.index,
212
+ timestamp_ms: frame.timestampMs,
213
+ pixels: frame.pixels
214
+ }));
137
215
  }
138
- function validateFrameData(data) {
139
- if (!data || typeof data !== "string") {
140
- return false;
141
- }
142
- const base64Pattern = /^[A-Za-z0-9+/=]+$/;
143
- return base64Pattern.test(data) && data.length > 0;
216
+ function toHybridFrameData(frames) {
217
+ return frames.map((frame) => ({
218
+ timestamp_ms: frame.timestampMs,
219
+ pixels: frame.pixels
220
+ }));
144
221
  }
145
- function validateFrameNumber(frameNumber) {
146
- return Number.isInteger(frameNumber) && frameNumber > 0;
147
- }
148
-
149
- // src/types/models.ts
150
- var MODEL_CONFIGS = {
151
- "50-frame": {
152
- type: "50-frame",
153
- fileName: "model_50.h5",
154
- frameCount: 50
155
- },
156
- "250-frame": {
157
- type: "250-frame",
158
- fileName: "model_250.h5",
159
- frameCount: 250
222
+ function toLivenessResult(response) {
223
+ if (!response.verdict) {
224
+ throw new LivenessApiError(response.error ?? "No verdict received", "no_verdict", 500);
160
225
  }
161
- };
162
-
163
- // src/client/AuthManager.ts
164
- var AuthManager = class {
165
- async authenticate(ws, apiKey, model) {
166
- if (!validateApiKey(apiKey)) {
167
- throw new Error("Invalid API key format");
168
- }
169
- const modelType = model;
170
- if (!MODEL_CONFIGS[modelType]) {
171
- throw new Error(`Invalid model type: ${model}`);
172
- }
173
- return new Promise((resolve, reject) => {
174
- const timeout = setTimeout(() => {
175
- reject(new Error("Authentication timeout"));
176
- }, AUTH_CONFIG.timeout);
177
- const messageHandler = (event) => {
178
- try {
179
- const message = JSON.parse(event.data);
180
- if (message.type === "auth_success") {
181
- clearTimeout(timeout);
182
- ws.removeEventListener("message", messageHandler);
183
- resolve(message.request_id);
184
- } else if (message.type === "auth_error") {
185
- clearTimeout(timeout);
186
- ws.removeEventListener("message", messageHandler);
187
- reject(new Error(message.error));
188
- }
189
- } catch (error) {
190
- }
191
- };
192
- ws.addEventListener("message", messageHandler);
193
- ws.send(
194
- JSON.stringify({
195
- type: "auth",
196
- token: apiKey,
197
- model_file_name: MODEL_CONFIGS[modelType].fileName
198
- })
199
- );
200
- });
226
+ return {
227
+ verdict: response.verdict,
228
+ confidence: response.confidence ?? 0,
229
+ score: response.score ?? 0,
230
+ sessionId: response.session_id,
231
+ processingMs: response.processing_ms,
232
+ framesProcessed: "frames_processed" in response ? response.frames_processed ?? 0 : 0
233
+ };
234
+ }
235
+ function generateSessionId() {
236
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
237
+ return crypto.randomUUID();
201
238
  }
202
- };
203
-
204
- // src/client/WebSocketClient.ts
205
- var WebSocketClient = class {
239
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
240
+ const r = Math.random() * 16 | 0;
241
+ const v = c === "x" ? r : r & 3 | 8;
242
+ return v.toString(16);
243
+ });
244
+ }
245
+ var LivenessClient = class {
206
246
  constructor(config) {
207
- this.ws = null;
208
- this.state = "disconnected";
209
- this.config = config;
210
- this.connectionManager = new ConnectionManager({
211
- onReconnect: () => this.connect()
212
- });
213
- this.authManager = new AuthManager();
247
+ this.baseUrl = (config.baseUrl ?? DEFAULT_ENDPOINT).replace(/\/$/, "");
248
+ this.apiKey = config.apiKey;
249
+ this.timeout = config.timeout ?? AUTH_CONFIG.timeout;
250
+ this.enableRetry = config.enableRetry ?? true;
251
+ this.fetchFn = config.customFetch ?? (typeof window !== "undefined" ? fetch.bind(window) : fetch);
214
252
  }
215
- async connect() {
216
- if (this.state === "connected" || this.state === "connecting") {
217
- return;
218
- }
219
- this.setState("connecting");
253
+ /**
254
+ * Make an authenticated API request
255
+ */
256
+ async request(path, options = {}) {
257
+ const url = `${this.baseUrl}${path}`;
258
+ const controller = new AbortController();
259
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
220
260
  try {
221
- this.ws = new WebSocket(this.config.url);
222
- this.ws.onopen = async () => {
223
- try {
224
- await this.authManager.authenticate(this.ws, this.config.apiKey, this.config.model);
225
- this.setState("connected");
226
- } catch (error) {
227
- this.handleError(error instanceof Error ? error : new Error("Authentication failed"));
228
- }
229
- };
230
- this.ws.onmessage = (event) => {
231
- try {
232
- const message = JSON.parse(event.data);
233
- this.handleMessage(message);
234
- } catch (error) {
235
- this.handleError(error instanceof Error ? error : new Error("Failed to parse message"));
261
+ const response = await this.fetchFn(url, {
262
+ ...options,
263
+ signal: controller.signal,
264
+ headers: {
265
+ "Content-Type": "application/json",
266
+ [AUTH_CONFIG.apiKeyHeader]: this.apiKey,
267
+ ...options.headers
236
268
  }
237
- };
238
- this.ws.onerror = () => {
239
- this.handleError(new Error("WebSocket error"));
240
- };
241
- this.ws.onclose = () => {
242
- this.setState("disconnected");
243
- this.connectionManager.handleDisconnect();
244
- };
269
+ });
270
+ clearTimeout(timeoutId);
271
+ if (!response.ok) {
272
+ const errorData = await response.json();
273
+ throw new LivenessApiError(
274
+ errorData.message,
275
+ errorData.error,
276
+ response.status,
277
+ errorData.required,
278
+ errorData.received
279
+ );
280
+ }
281
+ return await response.json();
245
282
  } catch (error) {
246
- this.handleError(error instanceof Error ? error : new Error("Connection failed"));
283
+ clearTimeout(timeoutId);
284
+ if (error instanceof LivenessApiError) {
285
+ throw error;
286
+ }
287
+ if (error instanceof Error && error.name === "AbortError") {
288
+ throw new LivenessApiError("Request timeout", "timeout", 408);
289
+ }
290
+ throw new LivenessApiError(
291
+ error instanceof Error ? error.message : "Network error",
292
+ "network_error",
293
+ 0
294
+ );
247
295
  }
248
296
  }
249
- disconnect() {
250
- if (this.ws) {
251
- this.ws.close();
252
- this.ws = null;
297
+ /**
298
+ * Make a request with optional retry
299
+ */
300
+ async requestWithRetry(path, options = {}) {
301
+ if (this.enableRetry) {
302
+ return retryWithBackoff(() => this.request(path, options), {
303
+ maxAttempts: RETRY_CONFIG.maxAttempts,
304
+ initialDelay: RETRY_CONFIG.initialDelay,
305
+ maxDelay: RETRY_CONFIG.maxDelay,
306
+ backoffMultiplier: RETRY_CONFIG.backoffMultiplier
307
+ });
253
308
  }
254
- this.setState("disconnected");
309
+ return this.request(path, options);
255
310
  }
256
- sendFrame(frameData, frameNumber) {
257
- if (this.state !== "connected" || !this.ws) {
258
- throw new Error("WebSocket not connected");
259
- }
260
- const message = {
261
- type: "frame",
262
- frame_data: frameData,
263
- frame_number: frameNumber,
264
- timestamp: Date.now()
311
+ // ===========================================================================
312
+ // Health & Status
313
+ // ===========================================================================
314
+ /**
315
+ * Check API health status
316
+ */
317
+ async health() {
318
+ return this.request(API_PATHS.health);
319
+ }
320
+ /**
321
+ * Get queue statistics
322
+ */
323
+ async queueStats() {
324
+ return this.request(API_PATHS.queueStats);
325
+ }
326
+ // ===========================================================================
327
+ // Fast Check Endpoints
328
+ // ===========================================================================
329
+ /**
330
+ * Perform fast liveness check with server-side face detection
331
+ *
332
+ * @param frames - Captured frames to analyze
333
+ * @param options - Additional options
334
+ * @returns Liveness result
335
+ */
336
+ async fastCheck(frames, options = {}) {
337
+ const request = {
338
+ session_id: options.sessionId ?? generateSessionId(),
339
+ model: options.model ?? "10",
340
+ source: options.source ?? "live",
341
+ frames: toFrameData(frames)
342
+ };
343
+ const response = await this.requestWithRetry(API_PATHS.fastCheck, {
344
+ method: "POST",
345
+ body: JSON.stringify(request)
346
+ });
347
+ return toLivenessResult(response);
348
+ }
349
+ /**
350
+ * Perform fast liveness check with pre-cropped face images
351
+ *
352
+ * @param crops - Pre-cropped 224x224 face images
353
+ * @param options - Additional options
354
+ * @returns Liveness result
355
+ */
356
+ async fastCheckCrops(crops, options = {}) {
357
+ const request = {
358
+ session_id: options.sessionId ?? generateSessionId(),
359
+ model: options.model ?? "10",
360
+ source: options.source ?? "live",
361
+ crops
265
362
  };
266
- this.ws.send(JSON.stringify(message));
363
+ const response = await this.requestWithRetry(API_PATHS.fastCheckCrops, {
364
+ method: "POST",
365
+ body: JSON.stringify(request)
366
+ });
367
+ return toLivenessResult(response);
267
368
  }
268
- getState() {
269
- return this.state;
369
+ // ===========================================================================
370
+ // Verify Endpoint (Spatial Features)
371
+ // ===========================================================================
372
+ /**
373
+ * Verify liveness using spatial feature-based detection
374
+ * Requires minimum 50 frames
375
+ *
376
+ * @param frames - Captured frames to analyze
377
+ * @param options - Additional options
378
+ * @returns Liveness result
379
+ */
380
+ async verify(frames, options = {}) {
381
+ const request = {
382
+ session_id: options.sessionId ?? generateSessionId(),
383
+ capture_start_ms: options.captureStartMs ?? 0,
384
+ frame_width: options.frameWidth ?? 640,
385
+ frame_height: options.frameHeight ?? 480,
386
+ fps: options.fps ?? 10,
387
+ frames: toFrameData(frames)
388
+ };
389
+ const response = await this.requestWithRetry(API_PATHS.verify, {
390
+ method: "POST",
391
+ body: JSON.stringify(request)
392
+ });
393
+ return toLivenessResult(response);
270
394
  }
271
- setState(newState) {
272
- if (this.state !== newState) {
273
- this.state = newState;
274
- this.config.onConnectionStateChange?.(newState);
275
- }
395
+ // ===========================================================================
396
+ // Hybrid Check Endpoints
397
+ // ===========================================================================
398
+ /**
399
+ * Perform hybrid liveness check with CNN + physiological features
400
+ * Requires minimum 50 frames
401
+ *
402
+ * @param frames - Captured frames to analyze
403
+ * @param options - Additional options
404
+ * @returns Liveness result
405
+ */
406
+ async hybridCheck(frames, options = {}) {
407
+ const request = {
408
+ session_id: options.sessionId ?? generateSessionId(),
409
+ fps: options.fps ?? 30,
410
+ frames: toHybridFrameData(frames)
411
+ };
412
+ const response = await this.requestWithRetry(API_PATHS.hybridCheck, {
413
+ method: "POST",
414
+ body: JSON.stringify(request)
415
+ });
416
+ return toLivenessResult(response);
417
+ }
418
+ /**
419
+ * Perform 50-frame hybrid liveness check
420
+ * Achieves 93.8% balanced accuracy
421
+ *
422
+ * @param frames - Captured frames to analyze (minimum 50)
423
+ * @param options - Additional options
424
+ * @returns Liveness result
425
+ */
426
+ async hybrid50(frames, options = {}) {
427
+ const request = {
428
+ session_id: options.sessionId ?? generateSessionId(),
429
+ fps: options.fps ?? 30,
430
+ frames: toHybridFrameData(frames)
431
+ };
432
+ const response = await this.requestWithRetry(API_PATHS.hybrid50, {
433
+ method: "POST",
434
+ body: JSON.stringify(request)
435
+ });
436
+ return toLivenessResult(response);
276
437
  }
277
- handleMessage(message) {
278
- this.config.onMessage?.(message);
438
+ /**
439
+ * Perform 150-frame hybrid liveness check
440
+ * Achieves 96.2% balanced accuracy
441
+ *
442
+ * @param frames - Captured frames to analyze (minimum 150)
443
+ * @param options - Additional options
444
+ * @returns Liveness result
445
+ */
446
+ async hybrid150(frames, options = {}) {
447
+ const request = {
448
+ session_id: options.sessionId ?? generateSessionId(),
449
+ fps: options.fps ?? 30,
450
+ frames: toHybridFrameData(frames)
451
+ };
452
+ const response = await this.requestWithRetry(API_PATHS.hybrid150, {
453
+ method: "POST",
454
+ body: JSON.stringify(request)
455
+ });
456
+ return toLivenessResult(response);
279
457
  }
280
- handleError(error) {
281
- this.setState("error");
282
- this.config.onError?.(error);
458
+ // ===========================================================================
459
+ // Job Polling Endpoints
460
+ // ===========================================================================
461
+ /**
462
+ * Get job status and result
463
+ *
464
+ * @param jobId - Job identifier
465
+ * @returns Job status response
466
+ */
467
+ async getJobResult(jobId) {
468
+ return this.request(`${API_PATHS.jobResult}/${jobId}`);
469
+ }
470
+ /**
471
+ * Long-poll for job result
472
+ * Waits up to timeout seconds for the job to complete
473
+ *
474
+ * @param jobId - Job identifier
475
+ * @param timeout - Max seconds to wait (default 30, max 120)
476
+ * @returns Job status response
477
+ */
478
+ async waitForJobResult(jobId, timeout = 30) {
479
+ return this.request(
480
+ `${API_PATHS.jobResult}/${jobId}/wait?timeout=${timeout}`
481
+ );
283
482
  }
284
483
  };
285
484
 
@@ -287,54 +486,103 @@ var WebSocketClient = class {
287
486
  var FrameBuffer = class {
288
487
  constructor(maxSize = FRAME_BUFFER_CONFIG.maxSize, maxMemory = FRAME_BUFFER_CONFIG.maxMemory) {
289
488
  this.buffer = /* @__PURE__ */ new Map();
290
- this.lastAcknowledged = 0;
291
489
  this.currentMemoryUsage = 0;
292
490
  this.maxSize = maxSize;
293
491
  this.maxMemory = maxMemory;
294
492
  }
493
+ /**
494
+ * Add a frame to the buffer
495
+ */
295
496
  add(frame) {
296
497
  if (this.currentMemoryUsage + frame.size > this.maxMemory) {
297
498
  this.flushOldest();
298
499
  }
299
- this.buffer.set(frame.number, frame);
500
+ this.buffer.set(frame.index, frame);
300
501
  this.currentMemoryUsage += frame.size;
301
502
  if (this.buffer.size > this.maxSize) {
302
503
  const oldest = Math.min(...this.buffer.keys());
303
504
  this.remove(oldest);
304
505
  }
305
506
  }
306
- acknowledge(frameNumber) {
307
- this.lastAcknowledged = Math.max(this.lastAcknowledged, frameNumber);
308
- for (const [num] of this.buffer) {
309
- if (num <= frameNumber) {
310
- this.remove(num);
311
- }
312
- }
507
+ /**
508
+ * Get a frame by index
509
+ */
510
+ get(index) {
511
+ return this.buffer.get(index);
313
512
  }
314
- getUnacknowledged() {
315
- return Array.from(this.buffer.values()).filter((f) => f.number > this.lastAcknowledged).sort((a, b) => a.number - b.number);
513
+ /**
514
+ * Get all frames sorted by index
515
+ */
516
+ getAll() {
517
+ return Array.from(this.buffer.values()).sort((a, b) => a.index - b.index);
316
518
  }
317
- getLastAcknowledged() {
318
- return this.lastAcknowledged;
519
+ /**
520
+ * Get frames within a range
521
+ */
522
+ getRange(startIndex, endIndex) {
523
+ return this.getAll().filter((f) => f.index >= startIndex && f.index <= endIndex);
524
+ }
525
+ /**
526
+ * Get the latest N frames
527
+ */
528
+ getLatest(count) {
529
+ const all = this.getAll();
530
+ return all.slice(-count);
531
+ }
532
+ /**
533
+ * Remove frames up to and including the given index
534
+ */
535
+ removeUpTo(index) {
536
+ for (const [frameIndex] of this.buffer) {
537
+ if (frameIndex <= index) {
538
+ this.remove(frameIndex);
539
+ }
540
+ }
319
541
  }
542
+ /**
543
+ * Get current buffer size (number of frames)
544
+ */
320
545
  size() {
321
546
  return this.buffer.size;
322
547
  }
548
+ /**
549
+ * Get current memory usage in bytes
550
+ */
323
551
  getMemoryUsage() {
324
552
  return this.currentMemoryUsage;
325
553
  }
554
+ /**
555
+ * Check if buffer is empty
556
+ */
557
+ isEmpty() {
558
+ return this.buffer.size === 0;
559
+ }
560
+ /**
561
+ * Check if buffer is at capacity
562
+ */
563
+ isFull() {
564
+ return this.buffer.size >= this.maxSize;
565
+ }
566
+ /**
567
+ * Clear all frames from the buffer
568
+ */
326
569
  clear() {
327
570
  this.buffer.clear();
328
571
  this.currentMemoryUsage = 0;
329
- this.lastAcknowledged = 0;
330
572
  }
331
- remove(frameNumber) {
332
- const frame = this.buffer.get(frameNumber);
573
+ /**
574
+ * Remove a specific frame by index
575
+ */
576
+ remove(index) {
577
+ const frame = this.buffer.get(index);
333
578
  if (frame) {
334
579
  this.currentMemoryUsage -= frame.size;
335
- this.buffer.delete(frameNumber);
580
+ this.buffer.delete(index);
336
581
  }
337
582
  }
583
+ /**
584
+ * Remove the oldest frame
585
+ */
338
586
  flushOldest() {
339
587
  if (this.buffer.size === 0) {
340
588
  return;
@@ -372,6 +620,273 @@ var FrameQueue = class {
372
620
  }
373
621
  };
374
622
 
623
+ // src/types/models.ts
624
+ var MODEL_CONFIGS = {
625
+ "10": {
626
+ type: "10",
627
+ minFrames: 10,
628
+ recommendedFrames: 10,
629
+ description: "Fast model - 10 frames, quick verification"
630
+ },
631
+ "50": {
632
+ type: "50",
633
+ minFrames: 50,
634
+ recommendedFrames: 50,
635
+ description: "Balanced model - 50 frames, good accuracy"
636
+ },
637
+ "250": {
638
+ type: "250",
639
+ minFrames: 250,
640
+ recommendedFrames: 250,
641
+ description: "High-accuracy model - 250 frames, best accuracy"
642
+ }
643
+ };
644
+ var HYBRID_MODEL_CONFIGS = {
645
+ "hybrid-50": {
646
+ type: "hybrid-50",
647
+ minFrames: 50,
648
+ recommendedFps: 30,
649
+ captureDurationSeconds: 1.7,
650
+ description: "Hybrid 50-frame model - 93.8% balanced accuracy"
651
+ },
652
+ "hybrid-150": {
653
+ type: "hybrid-150",
654
+ minFrames: 150,
655
+ recommendedFps: 30,
656
+ captureDurationSeconds: 5,
657
+ description: "Hybrid 150-frame model - 96.2% balanced accuracy"
658
+ }
659
+ };
660
+ function getMinFramesForModel(model) {
661
+ return MODEL_CONFIGS[model].minFrames;
662
+ }
663
+ function hasEnoughFrames(model, frameCount) {
664
+ return frameCount >= MODEL_CONFIGS[model].minFrames;
665
+ }
666
+
667
+ // src/constants/feedback.ts
668
+ var ALIGNMENT_THRESHOLD_CAPTURE = 0.6;
669
+ var ALIGNMENT_THRESHOLD_POOR = 0.5;
670
+ var ALIGNMENT_THRESHOLD_GOOD = 0.5;
671
+ var ALIGNMENT_THRESHOLD_PERFECT = 0.85;
672
+ var OVAL_GUIDE_COLORS = {
673
+ no_face: "#ef4444",
674
+ // Red - no face detected
675
+ poor: "#f97316",
676
+ // Orange - face detected but alignment < 50%
677
+ good: "#eab308",
678
+ // Yellow - alignment 50-85%
679
+ perfect: "#22c55e"
680
+ // Green - alignment >= 85%, ready to capture
681
+ };
682
+ var OVAL_GUIDE_STYLES = {
683
+ no_face: { color: "#ef4444", dashed: true, pulse: false },
684
+ poor: { color: "#f97316", dashed: false, pulse: false },
685
+ good: { color: "#eab308", dashed: false, pulse: false },
686
+ perfect: { color: "#22c55e", dashed: false, pulse: true }
687
+ };
688
+ function getOvalGuideState(hasFace, alignment) {
689
+ if (!hasFace) return "no_face";
690
+ if (alignment < ALIGNMENT_THRESHOLD_POOR) return "poor";
691
+ if (alignment < ALIGNMENT_THRESHOLD_PERFECT) return "good";
692
+ return "perfect";
693
+ }
694
+ var DEFAULT_STATUS_MESSAGES = {
695
+ idle: "Ready to start",
696
+ capturing: "Hold still...",
697
+ uploading: "Processing...",
698
+ processing: "Analyzing...",
699
+ complete: "Complete!",
700
+ error: "Error occurred"
701
+ };
702
+ var FEEDBACK_MESSAGES = {
703
+ // Initial / No face (Red state)
704
+ position_face: "Position your face in the oval",
705
+ no_face: "No face detected - move into frame",
706
+ multiple_faces: "Multiple people detected - please verify alone",
707
+ // Poor alignment (Orange state) - alignment < 50%
708
+ move_closer_to_center: "Move closer to center",
709
+ center_face: "Center your face",
710
+ // Good alignment (Yellow state) - alignment 50-85%
711
+ almost_there: "Almost there...",
712
+ keep_steady: "Keep steady...",
713
+ // Perfect alignment (Green state) - alignment >= 85%
714
+ perfect: "Perfect! Hold still",
715
+ scanning: "Scanning...",
716
+ // Distance issues
717
+ move_closer: "Move closer - face too far",
718
+ move_back: "Move back - face too close",
719
+ too_close: "Move back - face too close",
720
+ too_far: "Move closer - face too far",
721
+ // Visibility issues
722
+ face_not_visible: "Center your face - edges cut off",
723
+ partial_face: "Center your face - edges cut off",
724
+ // Quality issues
725
+ hold_still: "Hold still - image blurry",
726
+ blurry: "Hold still - image blurry",
727
+ // Lighting issues
728
+ poor_lighting: "Improve lighting",
729
+ too_dark: "Low lighting - move to a brighter area",
730
+ backlit: "Backlit - try facing the light source",
731
+ // Hand occlusion
732
+ hand_detected: "Remove hand from face",
733
+ // Progress messages
734
+ capturing: "Capturing...",
735
+ almost_done: "Almost done...",
736
+ verification_complete: "Verification complete",
737
+ // Alignment-based messages (used by getAlignmentFeedback)
738
+ align_face: "Align your face"
739
+ };
740
+ var DEFAULT_LOCALE = {
741
+ status: DEFAULT_STATUS_MESSAGES,
742
+ feedback: FEEDBACK_MESSAGES
743
+ };
744
+ var ES_LOCALE = {
745
+ status: {
746
+ idle: "Listo para comenzar",
747
+ capturing: "Mantente quieto...",
748
+ uploading: "Procesando...",
749
+ processing: "Analizando...",
750
+ complete: "\xA1Completado!",
751
+ error: "Ocurri\xF3 un error"
752
+ },
753
+ feedback: {
754
+ // Initial / No face (Red state)
755
+ position_face: "Posiciona tu rostro en el \xF3valo",
756
+ no_face: "No se detecta rostro - mu\xE9vete al marco",
757
+ multiple_faces: "Varias personas detectadas - verifica solo",
758
+ // Poor alignment (Orange state)
759
+ move_closer_to_center: "Mu\xE9vete al centro",
760
+ center_face: "Centra tu rostro",
761
+ // Good alignment (Yellow state)
762
+ almost_there: "Casi listo...",
763
+ keep_steady: "Mantente quieto...",
764
+ // Perfect alignment (Green state)
765
+ perfect: "\xA1Perfecto! No te muevas",
766
+ scanning: "Escaneando...",
767
+ // Distance issues
768
+ move_closer: "Ac\xE9rcate - rostro muy lejos",
769
+ move_back: "Al\xE9jate - rostro muy cerca",
770
+ too_close: "Al\xE9jate - rostro muy cerca",
771
+ too_far: "Ac\xE9rcate - rostro muy lejos",
772
+ // Visibility issues
773
+ face_not_visible: "Centra tu rostro - bordes cortados",
774
+ partial_face: "Centra tu rostro - bordes cortados",
775
+ // Quality issues
776
+ hold_still: "Mantente quieto - imagen borrosa",
777
+ blurry: "Mantente quieto - imagen borrosa",
778
+ // Lighting issues
779
+ poor_lighting: "Mejora la iluminaci\xF3n",
780
+ too_dark: "Poca luz - mu\xE9vete a un \xE1rea m\xE1s iluminada",
781
+ backlit: "Contraluz - intenta mirar hacia la fuente de luz",
782
+ // Hand occlusion
783
+ hand_detected: "Retira la mano del rostro",
784
+ // Progress messages
785
+ capturing: "Capturando...",
786
+ almost_done: "Casi listo...",
787
+ verification_complete: "Verificaci\xF3n completada",
788
+ // Alignment-based
789
+ align_face: "Alinea tu rostro"
790
+ }
791
+ };
792
+ function getFeedbackMessage(key, locale = DEFAULT_LOCALE) {
793
+ return locale.feedback[key] ?? FEEDBACK_MESSAGES[key];
794
+ }
795
+ function getStatusMessage(key, locale = DEFAULT_LOCALE) {
796
+ return locale.status[key] ?? DEFAULT_STATUS_MESSAGES[key];
797
+ }
798
+ function getCaptureQualityFeedback(state) {
799
+ const {
800
+ hasFace,
801
+ alignment,
802
+ isCapturing,
803
+ tooClose,
804
+ tooFar,
805
+ isBlurry,
806
+ isPartialFace,
807
+ framesCaptured,
808
+ targetFrames
809
+ } = state;
810
+ if (!hasFace) {
811
+ return FEEDBACK_MESSAGES.no_face;
812
+ }
813
+ if (tooClose) {
814
+ return FEEDBACK_MESSAGES.too_close;
815
+ }
816
+ if (tooFar) {
817
+ return FEEDBACK_MESSAGES.too_far;
818
+ }
819
+ if (isPartialFace) {
820
+ return FEEDBACK_MESSAGES.partial_face;
821
+ }
822
+ if (isBlurry) {
823
+ return FEEDBACK_MESSAGES.blurry;
824
+ }
825
+ if (alignment < ALIGNMENT_THRESHOLD_POOR) {
826
+ return FEEDBACK_MESSAGES.move_closer_to_center;
827
+ }
828
+ if (alignment < ALIGNMENT_THRESHOLD_PERFECT) {
829
+ return FEEDBACK_MESSAGES.almost_there;
830
+ }
831
+ if (isCapturing && framesCaptured > 0) {
832
+ if (framesCaptured >= targetFrames - 2) {
833
+ return FEEDBACK_MESSAGES.almost_done;
834
+ }
835
+ return FEEDBACK_MESSAGES.scanning;
836
+ }
837
+ return FEEDBACK_MESSAGES.perfect;
838
+ }
839
+ function canCaptureFrame(state) {
840
+ const { hasFace, alignment, tooClose, tooFar, isBlurry, isPartialFace } = state;
841
+ return hasFace && alignment >= ALIGNMENT_THRESHOLD_CAPTURE && !tooClose && !tooFar && !isBlurry && !isPartialFace;
842
+ }
843
+
844
+ // src/utils/validators.ts
845
+ function validateApiKey(apiKey) {
846
+ return typeof apiKey === "string" && apiKey.length > 0;
847
+ }
848
+ function validateFrameData(data) {
849
+ if (!data || typeof data !== "string") {
850
+ return false;
851
+ }
852
+ if (data.startsWith("data:")) {
853
+ const base64Part = data.split(",")[1];
854
+ return base64Part !== void 0 && base64Part.length > 0;
855
+ }
856
+ const base64Pattern = /^[A-Za-z0-9+/=]+$/;
857
+ return base64Pattern.test(data) && data.length > 0;
858
+ }
859
+ function validateFrameIndex(index) {
860
+ return Number.isInteger(index) && index >= 0;
861
+ }
862
+ function validateTimestamp(timestamp) {
863
+ return typeof timestamp === "number" && timestamp >= 0 && isFinite(timestamp);
864
+ }
865
+ function validateFrameCount(model, frameCount) {
866
+ const config = MODEL_CONFIGS[model];
867
+ const required = config.minFrames;
868
+ return {
869
+ valid: frameCount >= required,
870
+ required,
871
+ received: frameCount
872
+ };
873
+ }
874
+ function validateUUID(uuid) {
875
+ if (!uuid || typeof uuid !== "string") {
876
+ return false;
877
+ }
878
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
879
+ return uuidPattern.test(uuid);
880
+ }
881
+ function validateUrl(url) {
882
+ try {
883
+ new URL(url);
884
+ return true;
885
+ } catch {
886
+ return false;
887
+ }
888
+ }
889
+
375
890
  // src/utils/encoders.ts
376
891
  function encodeBase64(buffer) {
377
892
  if (buffer instanceof ArrayBuffer) {
@@ -388,23 +903,385 @@ function decodeBase64(base64) {
388
903
  }
389
904
  return bytes;
390
905
  }
906
+
907
+ // src/utils/frameAnalysis.ts
908
+ var DEFAULT_BLUR_THRESHOLD = 100;
909
+ var BLUR_THRESHOLD_MOBILE = 150;
910
+ var BACKLIT_RATIO_THRESHOLD = 0.6;
911
+ var LOW_LIGHT_THRESHOLD = 50;
912
+ var MIN_FACE_TOP_MARGIN = 0.1;
913
+ var MIN_FACE_BOTTOM_MARGIN = 0.08;
914
+ var MIN_FACE_SIDE_MARGIN = 0.05;
915
+ var MIN_CAPTURE_ALIGNMENT = 0.6;
916
+ var HIGH_ALIGNMENT = 0.85;
917
+ var GOOD_ALIGNMENT = 0.5;
918
+ var IDEAL_CROP_MULTIPLIER = 3.33;
919
+ var MIN_CROP_MULTIPLIER = 1.5;
920
+ var MAX_CROP_MULTIPLIER = 4;
921
+ var FACE_CENTER_VERTICAL_OFFSET = 0.15;
922
+ var MIN_FACE_RATIO = 0.03;
923
+ var MAX_FACE_RATIO = 0.7;
924
+ var FACE_CROP_OUTPUT_SIZE = 224;
925
+ var MAX_FACE_PERCENTAGE_IN_CROP = 0.5;
926
+ var TARGET_FACE_PERCENTAGE_IN_CROP = 0.3;
927
+ function analyzeBlur(grayscalePixels, width, height, threshold = DEFAULT_BLUR_THRESHOLD) {
928
+ const laplacian = [];
929
+ for (let y = 1; y < height - 1; y++) {
930
+ for (let x = 1; x < width - 1; x++) {
931
+ const idx = y * width + x;
932
+ const top = grayscalePixels[idx - width] ?? 0;
933
+ const left = grayscalePixels[idx - 1] ?? 0;
934
+ const center = grayscalePixels[idx] ?? 0;
935
+ const right = grayscalePixels[idx + 1] ?? 0;
936
+ const bottom = grayscalePixels[idx + width] ?? 0;
937
+ const value = top + left + center * -4 + right + bottom;
938
+ laplacian.push(value);
939
+ }
940
+ }
941
+ const n = laplacian.length;
942
+ if (n === 0) {
943
+ return { variance: 0, isBlurry: true, threshold };
944
+ }
945
+ const mean = laplacian.reduce((a, b) => a + b, 0) / n;
946
+ const variance = laplacian.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / n;
947
+ return {
948
+ variance,
949
+ isBlurry: variance < threshold,
950
+ threshold
951
+ };
952
+ }
953
+ function rgbaToGrayscale(rgbaPixels) {
954
+ const grayscale = [];
955
+ for (let i = 0; i < rgbaPixels.length; i += 4) {
956
+ const r = rgbaPixels[i] ?? 0;
957
+ const g = rgbaPixels[i + 1] ?? 0;
958
+ const b = rgbaPixels[i + 2] ?? 0;
959
+ grayscale.push(0.299 * r + 0.587 * g + 0.114 * b);
960
+ }
961
+ return grayscale;
962
+ }
963
+ function calculateBrightness(rgbaPixels) {
964
+ let totalLuminance = 0;
965
+ const pixelCount = rgbaPixels.length / 4;
966
+ for (let i = 0; i < rgbaPixels.length; i += 4) {
967
+ const r = rgbaPixels[i] ?? 0;
968
+ const g = rgbaPixels[i + 1] ?? 0;
969
+ const b = rgbaPixels[i + 2] ?? 0;
970
+ totalLuminance += 0.2126 * r + 0.7152 * g + 0.0722 * b;
971
+ }
972
+ return pixelCount > 0 ? totalLuminance / pixelCount : 0;
973
+ }
974
+ function analyzeLighting(faceBrightness, backgroundBrightness) {
975
+ const ratio = backgroundBrightness > 0 ? faceBrightness / backgroundBrightness : 1;
976
+ let status = "good";
977
+ let warning;
978
+ if (faceBrightness < LOW_LIGHT_THRESHOLD && backgroundBrightness < LOW_LIGHT_THRESHOLD) {
979
+ status = "low_light";
980
+ warning = "Low lighting - move to a brighter area";
981
+ } else if (ratio < BACKLIT_RATIO_THRESHOLD) {
982
+ status = "backlit";
983
+ warning = "Backlit - try facing the light source";
984
+ }
985
+ return {
986
+ faceBrightness,
987
+ backgroundBrightness,
988
+ ratio,
989
+ status,
990
+ warning
991
+ };
992
+ }
993
+ function isFaceFullyVisible(boundingBox, frameWidth, frameHeight) {
994
+ const faceTop = boundingBox.originY / frameHeight;
995
+ const faceBottom = (boundingBox.originY + boundingBox.height) / frameHeight;
996
+ const faceLeft = boundingBox.originX / frameWidth;
997
+ const faceRight = (boundingBox.originX + boundingBox.width) / frameWidth;
998
+ if (faceTop < MIN_FACE_TOP_MARGIN) {
999
+ return { visible: false, reason: "Face too close to top edge" };
1000
+ }
1001
+ if (faceBottom > 1 - MIN_FACE_BOTTOM_MARGIN) {
1002
+ return { visible: false, reason: "Face too close to bottom edge" };
1003
+ }
1004
+ if (faceLeft < MIN_FACE_SIDE_MARGIN) {
1005
+ return { visible: false, reason: "Face too close to left edge" };
1006
+ }
1007
+ if (faceRight > 1 - MIN_FACE_SIDE_MARGIN) {
1008
+ return { visible: false, reason: "Face too close to right edge" };
1009
+ }
1010
+ return { visible: true };
1011
+ }
1012
+ var DEFAULT_OVAL_REGION = {
1013
+ centerX: 0.5,
1014
+ centerY: 0.5,
1015
+ width: 0.3,
1016
+ // 30% of frame width
1017
+ height: 0.4
1018
+ // 30% * (4/3) = 40% of frame height (3:4 aspect ratio)
1019
+ };
1020
+ function isFaceInOval(faceBox, frameWidth, frameHeight, oval = DEFAULT_OVAL_REGION, tolerance = 0.3) {
1021
+ const faceCenterX = (faceBox.originX + faceBox.width / 2) / frameWidth;
1022
+ const faceCenterY = (faceBox.originY + faceBox.height / 2) / frameHeight;
1023
+ const faceWidth = faceBox.width / frameWidth;
1024
+ const faceHeight = faceBox.height / frameHeight;
1025
+ const ovalRadiusX = oval.width / 2;
1026
+ const ovalRadiusY = oval.height / 2;
1027
+ const dx = faceCenterX - oval.centerX;
1028
+ const dy = faceCenterY - oval.centerY;
1029
+ const normalizedDist = dx * dx / (ovalRadiusX * ovalRadiusX) + dy * dy / (ovalRadiusY * ovalRadiusY);
1030
+ const centerInOval = normalizedDist <= 1;
1031
+ const expectedFaceWidth = oval.width * 0.7;
1032
+ const expectedFaceHeight = oval.height * 0.7;
1033
+ const widthRatio = faceWidth / expectedFaceWidth;
1034
+ const heightRatio = faceHeight / expectedFaceHeight;
1035
+ const tooSmall = widthRatio < 1 - tolerance || heightRatio < 1 - tolerance;
1036
+ const tooBig = widthRatio > 1 + tolerance || heightRatio > 1 + tolerance;
1037
+ const sizeMatch = !tooSmall && !tooBig;
1038
+ let feedback;
1039
+ if (!centerInOval) {
1040
+ if (dy < -0.1) {
1041
+ feedback = "Move face down";
1042
+ } else if (dy > 0.1) {
1043
+ feedback = "Move face up";
1044
+ } else if (dx < -0.1) {
1045
+ feedback = "Move face right";
1046
+ } else if (dx > 0.1) {
1047
+ feedback = "Move face left";
1048
+ } else {
1049
+ feedback = "Center your face in the oval";
1050
+ }
1051
+ } else if (tooSmall) {
1052
+ feedback = "Move closer";
1053
+ } else if (tooBig) {
1054
+ feedback = "Move back";
1055
+ }
1056
+ return {
1057
+ isInside: centerInOval && sizeMatch,
1058
+ centerInOval,
1059
+ sizeMatch,
1060
+ feedback
1061
+ };
1062
+ }
1063
+ function calculateFaceAlignment(boundingBox, frameWidth, frameHeight) {
1064
+ const faceCenterX = boundingBox.originX + boundingBox.width / 2;
1065
+ const faceCenterY = boundingBox.originY + boundingBox.height / 2;
1066
+ const targetX = frameWidth / 2;
1067
+ const targetY = frameHeight / 2;
1068
+ const distX = Math.abs(faceCenterX - targetX) / (frameWidth / 2);
1069
+ const distY = Math.abs(faceCenterY - targetY) / (frameHeight / 2);
1070
+ const faceRatio = Math.max(boundingBox.width, boundingBox.height) / Math.min(frameWidth, frameHeight);
1071
+ const score = Math.max(0, 1 - Math.max(distX, distY));
1072
+ return {
1073
+ score,
1074
+ tooClose: faceRatio > MAX_FACE_RATIO,
1075
+ tooFar: faceRatio < MIN_FACE_RATIO
1076
+ };
1077
+ }
1078
+ function calculateAdaptiveCropMultiplier(faceBox, frameWidth, frameHeight) {
1079
+ const faceSize = Math.max(faceBox.width, faceBox.height);
1080
+ const idealCropSize = faceSize * IDEAL_CROP_MULTIPLIER;
1081
+ const maxCropSize = Math.min(frameWidth, frameHeight);
1082
+ if (idealCropSize <= maxCropSize) {
1083
+ return IDEAL_CROP_MULTIPLIER;
1084
+ }
1085
+ const adaptiveMultiplier = maxCropSize / faceSize;
1086
+ return Math.max(MIN_CROP_MULTIPLIER, Math.min(MAX_CROP_MULTIPLIER, adaptiveMultiplier));
1087
+ }
1088
+ function isFaceCropFullyInFrame(faceBox, frameWidth, frameHeight) {
1089
+ const marginMultiplier = calculateAdaptiveCropMultiplier(faceBox, frameWidth, frameHeight);
1090
+ const marginX = faceBox.width * (marginMultiplier - 1) / 2;
1091
+ const marginY = faceBox.height * (marginMultiplier - 1) / 2;
1092
+ const faceLeft = faceBox.originX - marginX;
1093
+ const faceTop = faceBox.originY - marginY;
1094
+ const faceRight = faceBox.originX + faceBox.width + marginX;
1095
+ const faceBottom = faceBox.originY + faceBox.height + marginY;
1096
+ return faceLeft >= 0 && faceTop >= 0 && faceRight <= frameWidth && faceBottom <= frameHeight;
1097
+ }
1098
+ function calculateFaceCropRegion(faceBox, frameWidth, frameHeight) {
1099
+ const cropMultiplier = calculateAdaptiveCropMultiplier(faceBox, frameWidth, frameHeight);
1100
+ const expandedWidth = faceBox.width * cropMultiplier;
1101
+ const expandedHeight = faceBox.height * cropMultiplier;
1102
+ const cropSize = Math.max(expandedWidth, expandedHeight);
1103
+ const centerX = faceBox.originX + faceBox.width / 2;
1104
+ const verticalShift = faceBox.height * FACE_CENTER_VERTICAL_OFFSET;
1105
+ const centerY = faceBox.originY + faceBox.height / 2 + verticalShift;
1106
+ let cropX = centerX - cropSize / 2;
1107
+ let cropY = centerY - cropSize / 2;
1108
+ cropX = Math.max(0, Math.min(frameWidth - cropSize, cropX));
1109
+ cropY = Math.max(0, Math.min(frameHeight - cropSize, cropY));
1110
+ const finalSize = Math.min(cropSize, frameWidth - cropX, frameHeight - cropY);
1111
+ return { x: cropX, y: cropY, size: finalSize };
1112
+ }
1113
+ function checkFrameQuality(options) {
1114
+ const {
1115
+ faceBox,
1116
+ frameWidth,
1117
+ frameHeight,
1118
+ blurAnalysis,
1119
+ lightingAnalysis,
1120
+ minAlignment = MIN_CAPTURE_ALIGNMENT
1121
+ } = options;
1122
+ const result = { passed: true };
1123
+ if (!faceBox) {
1124
+ result.passed = false;
1125
+ result.rejectionReason = "no_face";
1126
+ return result;
1127
+ }
1128
+ result.visibility = isFaceFullyVisible(faceBox, frameWidth, frameHeight);
1129
+ if (!result.visibility.visible) {
1130
+ result.passed = false;
1131
+ result.rejectionReason = "partial_face";
1132
+ return result;
1133
+ }
1134
+ result.alignment = calculateFaceAlignment(faceBox, frameWidth, frameHeight);
1135
+ if (result.alignment.tooClose) {
1136
+ result.passed = false;
1137
+ result.rejectionReason = "too_close";
1138
+ return result;
1139
+ }
1140
+ if (result.alignment.tooFar) {
1141
+ result.passed = false;
1142
+ result.rejectionReason = "too_far";
1143
+ return result;
1144
+ }
1145
+ if (result.alignment.score < minAlignment) {
1146
+ result.passed = false;
1147
+ result.rejectionReason = "misaligned";
1148
+ return result;
1149
+ }
1150
+ if (blurAnalysis) {
1151
+ result.blur = blurAnalysis;
1152
+ if (blurAnalysis.isBlurry) {
1153
+ result.passed = false;
1154
+ result.rejectionReason = "blur";
1155
+ return result;
1156
+ }
1157
+ }
1158
+ if (lightingAnalysis) {
1159
+ result.lighting = lightingAnalysis;
1160
+ if (lightingAnalysis.status === "backlit") {
1161
+ result.passed = false;
1162
+ result.rejectionReason = "backlit";
1163
+ return result;
1164
+ }
1165
+ if (lightingAnalysis.status === "low_light") {
1166
+ result.passed = false;
1167
+ result.rejectionReason = "low_light";
1168
+ return result;
1169
+ }
1170
+ }
1171
+ return result;
1172
+ }
1173
+ var BaseFrameCollector = class {
1174
+ constructor(maxFrames = 10) {
1175
+ this.frames = [];
1176
+ this.maxFrames = maxFrames;
1177
+ this.startTime = Date.now();
1178
+ }
1179
+ setMaxFrames(max) {
1180
+ this.maxFrames = max;
1181
+ }
1182
+ addFrame(frame) {
1183
+ if (!this.isComplete()) {
1184
+ this.frames.push(frame);
1185
+ }
1186
+ }
1187
+ getFrames() {
1188
+ return [...this.frames];
1189
+ }
1190
+ getCount() {
1191
+ return this.frames.length;
1192
+ }
1193
+ isComplete() {
1194
+ return this.frames.length >= this.maxFrames;
1195
+ }
1196
+ reset() {
1197
+ this.frames = [];
1198
+ this.startTime = Date.now();
1199
+ }
1200
+ getStartTime() {
1201
+ return this.startTime;
1202
+ }
1203
+ getNextIndex() {
1204
+ return this.frames.length;
1205
+ }
1206
+ };
391
1207
  // Annotate the CommonJS export names for ESM import in node:
392
1208
  0 && (module.exports = {
1209
+ ALIGNMENT_THRESHOLD_CAPTURE,
1210
+ ALIGNMENT_THRESHOLD_GOOD,
1211
+ ALIGNMENT_THRESHOLD_PERFECT,
1212
+ ALIGNMENT_THRESHOLD_POOR,
393
1213
  API_ENDPOINTS,
1214
+ API_PATHS,
394
1215
  AUTH_CONFIG,
395
- AuthManager,
396
- ConnectionManager,
1216
+ BACKLIT_RATIO_THRESHOLD,
1217
+ BLUR_THRESHOLD_MOBILE,
1218
+ BaseFrameCollector,
1219
+ DEFAULT_BLUR_THRESHOLD,
397
1220
  DEFAULT_ENDPOINT,
1221
+ DEFAULT_LIVENESS_CONFIG,
1222
+ DEFAULT_LOCALE,
1223
+ DEFAULT_OVAL_REGION,
1224
+ DEFAULT_STATUS_MESSAGES,
1225
+ ES_LOCALE,
1226
+ FACE_CENTER_VERTICAL_OFFSET,
1227
+ FACE_CROP_OUTPUT_SIZE,
1228
+ FEEDBACK_MESSAGES,
398
1229
  FRAME_BUFFER_CONFIG,
399
1230
  FRAME_CONFIG,
400
1231
  FrameBuffer,
401
1232
  FrameQueue,
402
- RECONNECTION_CONFIG,
403
- WebSocketClient,
1233
+ GOOD_ALIGNMENT,
1234
+ HIGH_ALIGNMENT,
1235
+ HYBRID_MODEL_CONFIGS,
1236
+ IDEAL_CROP_MULTIPLIER,
1237
+ LOW_LIGHT_THRESHOLD,
1238
+ LivenessApiError,
1239
+ LivenessClient,
1240
+ MAX_CROP_MULTIPLIER,
1241
+ MAX_FACE_PERCENTAGE_IN_CROP,
1242
+ MAX_FACE_RATIO,
1243
+ MIN_CAPTURE_ALIGNMENT,
1244
+ MIN_CROP_MULTIPLIER,
1245
+ MIN_FACE_BOTTOM_MARGIN,
1246
+ MIN_FACE_RATIO,
1247
+ MIN_FACE_SIDE_MARGIN,
1248
+ MIN_FACE_TOP_MARGIN,
1249
+ MODEL_CONFIGS,
1250
+ OVAL_GUIDE_COLORS,
1251
+ OVAL_GUIDE_STYLES,
1252
+ RETRY_CONFIG,
1253
+ TARGET_FACE_PERCENTAGE_IN_CROP,
1254
+ analyzeBlur,
1255
+ analyzeLighting,
1256
+ calculateAdaptiveCropMultiplier,
1257
+ calculateBrightness,
1258
+ calculateFaceAlignment,
1259
+ calculateFaceCropRegion,
1260
+ canCaptureFrame,
1261
+ checkFrameQuality,
404
1262
  decodeBase64,
405
1263
  encodeBase64,
1264
+ generateSessionId,
1265
+ getCaptureQualityFeedback,
1266
+ getFeedbackMessage,
1267
+ getMinFramesForModel,
1268
+ getOvalGuideState,
1269
+ getStatusMessage,
1270
+ hasEnoughFrames,
1271
+ isFaceCropFullyInFrame,
1272
+ isFaceFullyVisible,
1273
+ isFaceInOval,
406
1274
  retryWithBackoff,
1275
+ rgbaToGrayscale,
1276
+ sleep,
1277
+ toFrameData,
1278
+ toHybridFrameData,
1279
+ toLivenessResult,
407
1280
  validateApiKey,
1281
+ validateFrameCount,
408
1282
  validateFrameData,
409
- validateFrameNumber
1283
+ validateFrameIndex,
1284
+ validateTimestamp,
1285
+ validateUUID,
1286
+ validateUrl
410
1287
  });