@moveris/shared 2.7.0 → 3.0.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.
package/dist/index.js CHANGED
@@ -31,6 +31,8 @@ __export(index_exports, {
31
31
  BACKLIT_RATIO_THRESHOLD: () => BACKLIT_RATIO_THRESHOLD,
32
32
  BLUR_THRESHOLD_MOBILE: () => BLUR_THRESHOLD_MOBILE,
33
33
  BaseFrameCollector: () => BaseFrameCollector,
34
+ CAMERA_ANGLE_HIGH_RATIO: () => CAMERA_ANGLE_HIGH_RATIO,
35
+ CAMERA_ANGLE_LOW_RATIO: () => CAMERA_ANGLE_LOW_RATIO,
34
36
  DEFAULT_BLUR_THRESHOLD: () => DEFAULT_BLUR_THRESHOLD,
35
37
  DEFAULT_ENDPOINT: () => DEFAULT_ENDPOINT,
36
38
  DEFAULT_FACE_DETECTION_TIERS: () => DEFAULT_FACE_DETECTION_TIERS,
@@ -83,6 +85,7 @@ __export(index_exports, {
83
85
  OVAL_REGION_MOBILE: () => OVAL_REGION_MOBILE,
84
86
  RETRY_CONFIG: () => RETRY_CONFIG,
85
87
  TARGET_FACE_PERCENTAGE_IN_CROP: () => TARGET_FACE_PERCENTAGE_IN_CROP,
88
+ VALID_FRAME_COUNTS: () => VALID_FRAME_COUNTS,
86
89
  analyzeBlur: () => analyzeBlur,
87
90
  analyzeEyeRegionBrightness: () => analyzeEyeRegionBrightness,
88
91
  analyzeEyeRegionContrast: () => analyzeEyeRegionContrast,
@@ -95,6 +98,7 @@ __export(index_exports, {
95
98
  checkEyeRegionQuality: () => checkEyeRegionQuality,
96
99
  checkFrameQuality: () => checkFrameQuality,
97
100
  decodeBase64: () => decodeBase64,
101
+ detectCameraAngle: () => detectCameraAngle,
98
102
  detectFaceRoll: () => detectFaceRoll,
99
103
  detectSpecularHighlights: () => detectSpecularHighlights,
100
104
  encodeBase64: () => encodeBase64,
@@ -168,7 +172,8 @@ var FRAME_BUFFER_CONFIG = {
168
172
  var AUTH_CONFIG = {
169
173
  timeout: 3e4,
170
174
  // 30 seconds for API requests
171
- apiKeyHeader: "X-API-Key"
175
+ apiKeyHeader: "X-API-Key",
176
+ modelVersionHeader: "X-Model-Version"
172
177
  };
173
178
  var FRAME_CONFIG = {
174
179
  targetFPS: 30,
@@ -297,14 +302,22 @@ var LivenessClient = class _LivenessClient {
297
302
  constructor(config) {
298
303
  this.baseUrl = (config.baseUrl ?? DEFAULT_ENDPOINT).replace(/\/$/, "");
299
304
  this.apiKey = config.apiKey;
305
+ this.modelVersion = config.modelVersion;
300
306
  this.timeout = config.timeout ?? AUTH_CONFIG.timeout;
301
307
  this.enableRetry = config.enableRetry ?? true;
302
308
  this.fetchFn = config.customFetch ?? (typeof window !== "undefined" ? fetch.bind(window) : fetch);
303
309
  }
304
310
  /**
305
- * Make an authenticated API request
311
+ * Make an authenticated API request, returning the parsed body.
306
312
  */
307
313
  async request(path, options = {}) {
314
+ const { data } = await this.requestRaw(path, options);
315
+ return data;
316
+ }
317
+ /**
318
+ * Make an authenticated API request, returning both parsed body and headers.
319
+ */
320
+ async requestRaw(path, options = {}) {
308
321
  const url = `${this.baseUrl}${path}`;
309
322
  const controller = new AbortController();
310
323
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
@@ -322,7 +335,8 @@ var LivenessClient = class _LivenessClient {
322
335
  if (!response.ok) {
323
336
  throw await this.parseErrorResponse(response);
324
337
  }
325
- return await response.json();
338
+ const data = await response.json();
339
+ return { data, headers: response.headers };
326
340
  } catch (error) {
327
341
  clearTimeout(timeoutId);
328
342
  if (error instanceof LivenessApiError) {
@@ -407,6 +421,30 @@ var LivenessClient = class _LivenessClient {
407
421
  const match = /'error'\s*:\s*'([^']+)'/.exec(text) ?? /"error"\s*:\s*"([^"]+)"/.exec(text);
408
422
  return match?.[1] ?? null;
409
423
  }
424
+ /**
425
+ * Parse deprecation-related headers from an API response.
426
+ * Returns undefined when no deprecation info is present.
427
+ */
428
+ static parseDeprecationHeaders(headers) {
429
+ const resolved = headers.get("x-moveris-model-resolved");
430
+ if (!resolved) return void 0;
431
+ const deprecated = headers.get("deprecation") === "true";
432
+ return {
433
+ deprecated,
434
+ resolvedModel: resolved,
435
+ deprecatedModel: headers.get("x-moveris-deprecated-model") ?? void 0,
436
+ sunsetDate: headers.get("sunset") ?? void 0,
437
+ suggestedModel: headers.get("x-moveris-suggested-model") ?? void 0
438
+ };
439
+ }
440
+ /**
441
+ * Build extra request headers for model versioning.
442
+ */
443
+ buildModelVersionHeaders(modelVersion) {
444
+ const version = modelVersion ?? this.modelVersion;
445
+ if (!version) return {};
446
+ return { [AUTH_CONFIG.modelVersionHeader]: version };
447
+ }
410
448
  /**
411
449
  * Make a request with optional retry
412
450
  */
@@ -421,6 +459,20 @@ var LivenessClient = class _LivenessClient {
421
459
  }
422
460
  return this.request(path, options);
423
461
  }
462
+ /**
463
+ * Make a request with optional retry, returning both data and headers.
464
+ */
465
+ async requestWithRetryRaw(path, options = {}) {
466
+ if (this.enableRetry) {
467
+ return retryWithBackoff(() => this.requestRaw(path, options), {
468
+ maxAttempts: RETRY_CONFIG.maxAttempts,
469
+ initialDelay: RETRY_CONFIG.initialDelay,
470
+ maxDelay: RETRY_CONFIG.maxDelay,
471
+ backoffMultiplier: RETRY_CONFIG.backoffMultiplier
472
+ });
473
+ }
474
+ return this.requestRaw(path, options);
475
+ }
424
476
  // ===========================================================================
425
477
  // Health & Status
426
478
  // ===========================================================================
@@ -455,18 +507,31 @@ var LivenessClient = class _LivenessClient {
455
507
  * @returns Liveness result
456
508
  */
457
509
  async fastCheck(frames, options = {}) {
510
+ const effectiveVersion = options.modelVersion ?? this.modelVersion;
458
511
  const request = {
459
512
  session_id: options.sessionId ?? generateSessionId(),
460
513
  model: options.model ?? "10",
461
514
  source: options.source ?? "live",
462
515
  frames: toFrameData(frames),
516
+ ...options.frameCount != null ? { frame_count: options.frameCount } : {},
463
517
  ...options.warnings?.length ? { warnings: options.warnings } : {}
464
518
  };
465
- const response = await this.requestWithRetry(API_PATHS.fastCheck, {
466
- method: "POST",
467
- body: JSON.stringify(request)
468
- });
469
- return toLivenessResult(response);
519
+ const { data: response, headers } = await this.requestWithRetryRaw(
520
+ API_PATHS.fastCheck,
521
+ {
522
+ method: "POST",
523
+ body: JSON.stringify(request),
524
+ headers: this.buildModelVersionHeaders(effectiveVersion)
525
+ }
526
+ );
527
+ const result = toLivenessResult(response);
528
+ result.deprecation = _LivenessClient.parseDeprecationHeaders(headers);
529
+ if (result.deprecation?.deprecated) {
530
+ console.warn(
531
+ `[Moveris] Model "${result.deprecation.resolvedModel}" is deprecated.` + (result.deprecation.suggestedModel ? ` Migrate to "${result.deprecation.suggestedModel}".` : "") + (result.deprecation.sunsetDate ? ` Sunset date: ${result.deprecation.sunsetDate}.` : "")
532
+ );
533
+ }
534
+ return result;
470
535
  }
471
536
  /**
472
537
  * Perform fast liveness check with pre-cropped face images
@@ -476,18 +541,32 @@ var LivenessClient = class _LivenessClient {
476
541
  * @returns Liveness result
477
542
  */
478
543
  async fastCheckCrops(crops, options = {}) {
544
+ const effectiveVersion = options.modelVersion ?? this.modelVersion;
479
545
  const request = {
480
546
  session_id: options.sessionId ?? generateSessionId(),
481
547
  model: options.model ?? "10",
482
548
  source: options.source ?? "live",
483
549
  crops,
484
- ...options.warnings?.length ? { warnings: options.warnings } : {}
550
+ ...options.frameCount != null ? { frame_count: options.frameCount } : {},
551
+ ...options.warnings?.length ? { warnings: options.warnings } : {},
552
+ ...options.bgSegmentation !== void 0 ? { bg_segmentation: options.bgSegmentation } : {}
485
553
  };
486
- const response = await this.requestWithRetry(API_PATHS.fastCheckCrops, {
487
- method: "POST",
488
- body: JSON.stringify(request)
489
- });
490
- return toLivenessResult(response);
554
+ const { data: response, headers } = await this.requestWithRetryRaw(
555
+ API_PATHS.fastCheckCrops,
556
+ {
557
+ method: "POST",
558
+ body: JSON.stringify(request),
559
+ headers: this.buildModelVersionHeaders(effectiveVersion)
560
+ }
561
+ );
562
+ const result = toLivenessResult(response);
563
+ result.deprecation = _LivenessClient.parseDeprecationHeaders(headers);
564
+ if (result.deprecation?.deprecated) {
565
+ console.warn(
566
+ `[Moveris] Model "${result.deprecation.resolvedModel}" is deprecated.` + (result.deprecation.suggestedModel ? ` Migrate to "${result.deprecation.suggestedModel}".` : "") + (result.deprecation.sunsetDate ? ` Sunset date: ${result.deprecation.sunsetDate}.` : "")
567
+ );
568
+ }
569
+ return result;
491
570
  }
492
571
  /**
493
572
  * Send a single captured frame to the streaming endpoint.
@@ -515,28 +594,29 @@ var LivenessClient = class _LivenessClient {
515
594
  return this.sendStreamFrameInternal(frameData, {
516
595
  sessionId: options.sessionId,
517
596
  model: options.model ?? "10",
597
+ modelVersion: options.modelVersion,
598
+ frameCount: options.frameCount,
518
599
  source: options.source ?? "live",
519
600
  warnings: options.warnings
520
601
  });
521
602
  }
522
603
  /**
523
604
  * Send a single FrameData to the streaming endpoint with retry (internal)
524
- *
525
- * @param frame - Single frame to send
526
- * @param options - Session and model options
527
- * @returns Stream response with status
528
605
  */
529
606
  async sendStreamFrameInternal(frameData, options) {
607
+ const effectiveVersion = options.modelVersion ?? this.modelVersion;
530
608
  const request = {
531
609
  session_id: options.sessionId,
532
610
  model: options.model,
533
611
  source: options.source,
534
612
  frame: frameData,
613
+ ...options.frameCount != null ? { frame_count: options.frameCount } : {},
535
614
  ...options.warnings?.length ? { warnings: options.warnings } : {}
536
615
  };
537
616
  return this.requestWithRetry(API_PATHS.fastCheckStream, {
538
617
  method: "POST",
539
- body: JSON.stringify(request)
618
+ body: JSON.stringify(request),
619
+ headers: this.buildModelVersionHeaders(effectiveVersion)
540
620
  });
541
621
  }
542
622
  /**
@@ -561,6 +641,8 @@ var LivenessClient = class _LivenessClient {
561
641
  const response = await this.sendStreamFrameInternal(frameData, {
562
642
  sessionId,
563
643
  model,
644
+ modelVersion: options.modelVersion,
645
+ frameCount: options.frameCount,
564
646
  source,
565
647
  warnings: options.warnings
566
648
  });
@@ -611,6 +693,8 @@ var LivenessClient = class _LivenessClient {
611
693
  const response = await this.sendStreamFrameInternal(frameData, {
612
694
  sessionId,
613
695
  model,
696
+ modelVersion: options.modelVersion,
697
+ frameCount: options.frameCount,
614
698
  source,
615
699
  warnings: options.warnings
616
700
  });
@@ -887,6 +971,7 @@ var FrameQueue = class {
887
971
  };
888
972
 
889
973
  // src/types/models.ts
974
+ var VALID_FRAME_COUNTS = [10, 30, 60, 90, 120];
890
975
  var MODEL_CONFIGS = {
891
976
  // Standard fast-check models
892
977
  "10": {
@@ -894,21 +979,24 @@ var MODEL_CONFIGS = {
894
979
  minFrames: 10,
895
980
  recommendedFrames: 10,
896
981
  description: "Fast model - 10 frames, quick verification",
897
- deprecated: false
982
+ deprecated: false,
983
+ aliases: ["fast"]
898
984
  },
899
985
  "50": {
900
986
  type: "50",
901
987
  minFrames: 50,
902
988
  recommendedFrames: 50,
903
989
  description: "Balanced model - 50 frames, good accuracy",
904
- deprecated: false
990
+ deprecated: false,
991
+ aliases: ["spatial"]
905
992
  },
906
993
  "250": {
907
994
  type: "250",
908
995
  minFrames: 250,
909
996
  recommendedFrames: 250,
910
997
  description: "High-accuracy model - 250 frames, best accuracy",
911
- deprecated: false
998
+ deprecated: false,
999
+ aliases: ["spatial"]
912
1000
  },
913
1001
  // Hybrid V2 models with physiological features
914
1002
  "hybrid-v2-10": {
@@ -916,63 +1004,72 @@ var MODEL_CONFIGS = {
916
1004
  minFrames: 10,
917
1005
  recommendedFrames: 10,
918
1006
  description: "Hybrid V2 10-frame model with physio features",
919
- deprecated: false
1007
+ deprecated: false,
1008
+ aliases: ["hybrid"]
920
1009
  },
921
1010
  "hybrid-v2-30": {
922
1011
  type: "hybrid-v2-30",
923
1012
  minFrames: 30,
924
1013
  recommendedFrames: 30,
925
1014
  description: "Hybrid V2 30-frame model with physio features",
926
- deprecated: false
1015
+ deprecated: false,
1016
+ aliases: ["hybrid"]
927
1017
  },
928
1018
  "hybrid-v2-50": {
929
1019
  type: "hybrid-v2-50",
930
1020
  minFrames: 50,
931
1021
  recommendedFrames: 50,
932
1022
  description: "Hybrid V2 50-frame model with physio features",
933
- deprecated: false
1023
+ deprecated: false,
1024
+ aliases: ["hybrid"]
934
1025
  },
935
1026
  "hybrid-v2-60": {
936
1027
  type: "hybrid-v2-60",
937
1028
  minFrames: 60,
938
1029
  recommendedFrames: 60,
939
1030
  description: "Hybrid V2 60-frame model with physio features",
940
- deprecated: false
1031
+ deprecated: false,
1032
+ aliases: ["hybrid"]
941
1033
  },
942
1034
  "hybrid-v2-90": {
943
1035
  type: "hybrid-v2-90",
944
1036
  minFrames: 90,
945
1037
  recommendedFrames: 90,
946
1038
  description: "Hybrid V2 90-frame model with physio features",
947
- deprecated: false
1039
+ deprecated: false,
1040
+ aliases: ["hybrid"]
948
1041
  },
949
1042
  "hybrid-v2-100": {
950
1043
  type: "hybrid-v2-100",
951
1044
  minFrames: 100,
952
1045
  recommendedFrames: 100,
953
1046
  description: "Hybrid V2 100-frame model with physio features",
954
- deprecated: false
1047
+ deprecated: false,
1048
+ aliases: ["hybrid"]
955
1049
  },
956
1050
  "hybrid-v2-125": {
957
1051
  type: "hybrid-v2-125",
958
1052
  minFrames: 125,
959
1053
  recommendedFrames: 125,
960
1054
  description: "Hybrid V2 125-frame model with physio features",
961
- deprecated: false
1055
+ deprecated: false,
1056
+ aliases: ["hybrid"]
962
1057
  },
963
1058
  "hybrid-v2-150": {
964
1059
  type: "hybrid-v2-150",
965
1060
  minFrames: 150,
966
1061
  recommendedFrames: 150,
967
1062
  description: "Hybrid V2 150-frame model with physio features",
968
- deprecated: false
1063
+ deprecated: false,
1064
+ aliases: ["hybrid"]
969
1065
  },
970
1066
  "hybrid-v2-250": {
971
1067
  type: "hybrid-v2-250",
972
1068
  minFrames: 250,
973
1069
  recommendedFrames: 250,
974
1070
  description: "Hybrid V2 250-frame model with physio features",
975
- deprecated: false
1071
+ deprecated: false,
1072
+ aliases: ["hybrid"]
976
1073
  },
977
1074
  // Mixed V1 models — deprecated, use mixed-*-v2 equivalents instead
978
1075
  "mixed-10": {
@@ -980,49 +1077,68 @@ var MODEL_CONFIGS = {
980
1077
  minFrames: 10,
981
1078
  recommendedFrames: 10,
982
1079
  description: "Mixed 10-frame model",
983
- deprecated: true
1080
+ deprecated: true,
1081
+ aliases: ["v1"],
1082
+ sunsetDate: "2026-09-01",
1083
+ replacement: "mixed-10-v2"
984
1084
  },
985
1085
  "mixed-30": {
986
1086
  type: "mixed-30",
987
1087
  minFrames: 30,
988
1088
  recommendedFrames: 30,
989
1089
  description: "Mixed 30-frame model",
990
- deprecated: true
1090
+ deprecated: true,
1091
+ aliases: ["v1"],
1092
+ sunsetDate: "2026-09-01",
1093
+ replacement: "mixed-30-v2"
991
1094
  },
992
1095
  "mixed-60": {
993
1096
  type: "mixed-60",
994
1097
  minFrames: 60,
995
1098
  recommendedFrames: 60,
996
1099
  description: "Mixed 60-frame model",
997
- deprecated: true
1100
+ deprecated: true,
1101
+ aliases: ["v1"],
1102
+ sunsetDate: "2026-09-01",
1103
+ replacement: "mixed-60-v2"
998
1104
  },
999
1105
  "mixed-90": {
1000
1106
  type: "mixed-90",
1001
1107
  minFrames: 90,
1002
1108
  recommendedFrames: 90,
1003
1109
  description: "Mixed 90-frame model",
1004
- deprecated: true
1110
+ deprecated: true,
1111
+ aliases: ["v1"],
1112
+ sunsetDate: "2026-09-01",
1113
+ replacement: "mixed-90-v2"
1005
1114
  },
1006
1115
  "mixed-120": {
1007
1116
  type: "mixed-120",
1008
1117
  minFrames: 120,
1009
1118
  recommendedFrames: 120,
1010
1119
  description: "Mixed 120-frame model",
1011
- deprecated: true
1120
+ deprecated: true,
1121
+ aliases: ["v1"],
1122
+ sunsetDate: "2026-09-01",
1123
+ replacement: "mixed-120-v2"
1012
1124
  },
1013
1125
  "mixed-150": {
1014
1126
  type: "mixed-150",
1015
1127
  minFrames: 150,
1016
1128
  recommendedFrames: 150,
1017
1129
  description: "Mixed 150-frame model",
1018
- deprecated: true
1130
+ deprecated: true,
1131
+ aliases: ["v1"],
1132
+ sunsetDate: "2026-09-01"
1019
1133
  },
1020
1134
  "mixed-250": {
1021
1135
  type: "mixed-250",
1022
1136
  minFrames: 250,
1023
1137
  recommendedFrames: 250,
1024
1138
  description: "Mixed 250-frame model",
1025
- deprecated: true
1139
+ deprecated: true,
1140
+ aliases: ["v1"],
1141
+ sunsetDate: "2026-09-01"
1026
1142
  },
1027
1143
  // Mixed V2 models (exp_042 checkpoints — EER: 4.0% / AUC: 0.991 at 30f)
1028
1144
  "mixed-10-v2": {
@@ -1030,35 +1146,40 @@ var MODEL_CONFIGS = {
1030
1146
  minFrames: 10,
1031
1147
  recommendedFrames: 10,
1032
1148
  description: "Mixed V2 10-frame model",
1033
- deprecated: false
1149
+ deprecated: false,
1150
+ aliases: ["latest", "v2"]
1034
1151
  },
1035
1152
  "mixed-30-v2": {
1036
1153
  type: "mixed-30-v2",
1037
1154
  minFrames: 30,
1038
1155
  recommendedFrames: 30,
1039
1156
  description: "Mixed V2 30-frame model (recommended \u2014 95.7% balanced accuracy)",
1040
- deprecated: false
1157
+ deprecated: false,
1158
+ aliases: ["latest", "v2"]
1041
1159
  },
1042
1160
  "mixed-60-v2": {
1043
1161
  type: "mixed-60-v2",
1044
1162
  minFrames: 60,
1045
1163
  recommendedFrames: 60,
1046
1164
  description: "Mixed V2 60-frame model",
1047
- deprecated: false
1165
+ deprecated: false,
1166
+ aliases: ["latest", "v2"]
1048
1167
  },
1049
1168
  "mixed-90-v2": {
1050
1169
  type: "mixed-90-v2",
1051
1170
  minFrames: 90,
1052
1171
  recommendedFrames: 90,
1053
1172
  description: "Mixed V2 90-frame model",
1054
- deprecated: false
1173
+ deprecated: false,
1174
+ aliases: ["latest", "v2"]
1055
1175
  },
1056
1176
  "mixed-120-v2": {
1057
1177
  type: "mixed-120-v2",
1058
1178
  minFrames: 120,
1059
1179
  recommendedFrames: 120,
1060
1180
  description: "Mixed V2 120-frame model",
1061
- deprecated: false
1181
+ deprecated: false,
1182
+ aliases: ["latest", "v2"]
1062
1183
  }
1063
1184
  };
1064
1185
  var HYBRID_MODEL_CONFIGS = {
@@ -1254,9 +1375,10 @@ var FEEDBACK_MESSAGES = {
1254
1375
  poor_lighting: "Improve lighting",
1255
1376
  too_dark: "Low lighting - move to a brighter area",
1256
1377
  backlit: "Backlit - try facing the light source",
1257
- // Phone orientation
1258
- phone_angle_low: "Raise your phone to eye level",
1259
- phone_tilted: "Hold your phone level",
1378
+ // Camera angle (platform-agnostic)
1379
+ camera_angle_low: "Raise camera to eye level",
1380
+ camera_angle_high: "Lower camera to eye level",
1381
+ camera_tilted: "Hold camera level",
1260
1382
  // Hand occlusion
1261
1383
  hand_detected: "Remove hand from face",
1262
1384
  // Eye region quality
@@ -1316,9 +1438,10 @@ var ES_LOCALE = {
1316
1438
  poor_lighting: "Mejora la iluminaci\xF3n",
1317
1439
  too_dark: "Poca luz - mu\xE9vete a un \xE1rea m\xE1s iluminada",
1318
1440
  backlit: "Contraluz - intenta mirar hacia la fuente de luz",
1319
- // Phone orientation
1320
- phone_angle_low: "Levanta el tel\xE9fono a la altura de los ojos",
1321
- phone_tilted: "Mant\xE9n el tel\xE9fono nivelado",
1441
+ // Camera angle (platform-agnostic)
1442
+ camera_angle_low: "Levanta la c\xE1mara a la altura de los ojos",
1443
+ camera_angle_high: "Baja la c\xE1mara a la altura de los ojos",
1444
+ camera_tilted: "Mant\xE9n la c\xE1mara nivelada",
1322
1445
  // Hand occlusion
1323
1446
  hand_detected: "Retira la mano del rostro",
1324
1447
  // Eye region quality
@@ -1354,17 +1477,21 @@ function getCaptureQualityFeedback(state) {
1354
1477
  targetFrames,
1355
1478
  tooFarFromIdeal,
1356
1479
  tooCloseToIdeal,
1357
- phoneAngled,
1358
- phoneTilted
1480
+ cameraAngleLow,
1481
+ cameraAngleHigh,
1482
+ cameraTilted
1359
1483
  } = state;
1360
1484
  if (!hasFace) {
1361
1485
  return FEEDBACK_MESSAGES.no_face;
1362
1486
  }
1363
- if (phoneTilted) {
1364
- return FEEDBACK_MESSAGES.phone_tilted;
1487
+ if (cameraTilted) {
1488
+ return FEEDBACK_MESSAGES.camera_tilted;
1365
1489
  }
1366
- if (phoneAngled) {
1367
- return FEEDBACK_MESSAGES.phone_angle_low;
1490
+ if (cameraAngleLow) {
1491
+ return FEEDBACK_MESSAGES.camera_angle_low;
1492
+ }
1493
+ if (cameraAngleHigh) {
1494
+ return FEEDBACK_MESSAGES.camera_angle_high;
1368
1495
  }
1369
1496
  if (tooClose) {
1370
1497
  return FEEDBACK_MESSAGES.too_close;
@@ -1408,10 +1535,11 @@ function canCaptureFrame(state) {
1408
1535
  isPartialFace,
1409
1536
  tooFarFromIdeal,
1410
1537
  tooCloseToIdeal,
1411
- phoneAngled,
1412
- phoneTilted
1538
+ cameraAngleLow,
1539
+ cameraAngleHigh,
1540
+ cameraTilted
1413
1541
  } = state;
1414
- return hasFace && alignment >= ALIGNMENT_THRESHOLD_CAPTURE && !tooClose && !tooFar && !isBlurry && !isPartialFace && !tooFarFromIdeal && !tooCloseToIdeal && !phoneAngled && !phoneTilted;
1542
+ return hasFace && alignment >= ALIGNMENT_THRESHOLD_CAPTURE && !tooClose && !tooFar && !isBlurry && !isPartialFace && !tooFarFromIdeal && !tooCloseToIdeal && !cameraAngleLow && !cameraAngleHigh && !cameraTilted;
1415
1543
  }
1416
1544
 
1417
1545
  // src/utils/validators.ts
@@ -1478,7 +1606,7 @@ function decodeBase64(base64) {
1478
1606
  }
1479
1607
 
1480
1608
  // src/utils/frameAnalysis.ts
1481
- var DEFAULT_BLUR_THRESHOLD = 100;
1609
+ var DEFAULT_BLUR_THRESHOLD = 110;
1482
1610
  var BLUR_THRESHOLD_MOBILE = 60;
1483
1611
  var BACKLIT_RATIO_THRESHOLD = 0.6;
1484
1612
  var LOW_LIGHT_THRESHOLD = 50;
@@ -1779,6 +1907,30 @@ function detectFaceRoll(landmarks) {
1779
1907
  tooTilted: roll > MAX_FACE_ROLL_DEGREES
1780
1908
  };
1781
1909
  }
1910
+ var CAMERA_ANGLE_HIGH_RATIO = 1.35;
1911
+ var CAMERA_ANGLE_LOW_RATIO = 0.75;
1912
+ function detectCameraAngle(landmarks) {
1913
+ if (landmarks.length < 153) {
1914
+ return { ratio: 1, cameraAbove: false, cameraBelow: false };
1915
+ }
1916
+ const forehead = landmarks[10];
1917
+ const noseTip = landmarks[1];
1918
+ const chin = landmarks[152];
1919
+ if (!forehead || !noseTip || !chin) {
1920
+ return { ratio: 1, cameraAbove: false, cameraBelow: false };
1921
+ }
1922
+ const foreheadToNose = Math.abs(forehead.y - noseTip.y);
1923
+ const noseToChin = Math.abs(noseTip.y - chin.y);
1924
+ if (noseToChin < 1e-3) {
1925
+ return { ratio: 1, cameraAbove: false, cameraBelow: false };
1926
+ }
1927
+ const ratio = foreheadToNose / noseToChin;
1928
+ return {
1929
+ ratio,
1930
+ cameraAbove: ratio > CAMERA_ANGLE_HIGH_RATIO,
1931
+ cameraBelow: ratio < CAMERA_ANGLE_LOW_RATIO
1932
+ };
1933
+ }
1782
1934
  var BaseFrameCollector = class {
1783
1935
  constructor(maxFrames = 10) {
1784
1936
  this.frames = [];
@@ -1996,6 +2148,8 @@ function checkEyeRegionQuality(pixels, thresholds = EYE_QUALITY_THRESHOLDS) {
1996
2148
  BACKLIT_RATIO_THRESHOLD,
1997
2149
  BLUR_THRESHOLD_MOBILE,
1998
2150
  BaseFrameCollector,
2151
+ CAMERA_ANGLE_HIGH_RATIO,
2152
+ CAMERA_ANGLE_LOW_RATIO,
1999
2153
  DEFAULT_BLUR_THRESHOLD,
2000
2154
  DEFAULT_ENDPOINT,
2001
2155
  DEFAULT_FACE_DETECTION_TIERS,
@@ -2048,6 +2202,7 @@ function checkEyeRegionQuality(pixels, thresholds = EYE_QUALITY_THRESHOLDS) {
2048
2202
  OVAL_REGION_MOBILE,
2049
2203
  RETRY_CONFIG,
2050
2204
  TARGET_FACE_PERCENTAGE_IN_CROP,
2205
+ VALID_FRAME_COUNTS,
2051
2206
  analyzeBlur,
2052
2207
  analyzeEyeRegionBrightness,
2053
2208
  analyzeEyeRegionContrast,
@@ -2060,6 +2215,7 @@ function checkEyeRegionQuality(pixels, thresholds = EYE_QUALITY_THRESHOLDS) {
2060
2215
  checkEyeRegionQuality,
2061
2216
  checkFrameQuality,
2062
2217
  decodeBase64,
2218
+ detectCameraAngle,
2063
2219
  detectFaceRoll,
2064
2220
  detectSpecularHighlights,
2065
2221
  encodeBase64,