@moveris/shared 1.0.2 → 2.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.
package/dist/index.js CHANGED
@@ -32,9 +32,13 @@ __export(index_exports, {
32
32
  BaseFrameCollector: () => BaseFrameCollector,
33
33
  DEFAULT_BLUR_THRESHOLD: () => DEFAULT_BLUR_THRESHOLD,
34
34
  DEFAULT_ENDPOINT: () => DEFAULT_ENDPOINT,
35
+ DEFAULT_FACE_DETECTION_TIERS: () => DEFAULT_FACE_DETECTION_TIERS,
36
+ DEFAULT_GAZE_THRESHOLDS: () => DEFAULT_GAZE_THRESHOLDS,
37
+ DEFAULT_HAND_OCCLUSION_CONFIG: () => DEFAULT_HAND_OCCLUSION_CONFIG,
35
38
  DEFAULT_LIVENESS_CONFIG: () => DEFAULT_LIVENESS_CONFIG,
36
39
  DEFAULT_LOCALE: () => DEFAULT_LOCALE,
37
40
  DEFAULT_OVAL_REGION: () => DEFAULT_OVAL_REGION,
41
+ DEFAULT_STABILIZER_CONFIG: () => DEFAULT_STABILIZER_CONFIG,
38
42
  DEFAULT_STATUS_MESSAGES: () => DEFAULT_STATUS_MESSAGES,
39
43
  ES_LOCALE: () => ES_LOCALE,
40
44
  FACE_CENTER_VERTICAL_OFFSET: () => FACE_CENTER_VERTICAL_OFFSET,
@@ -48,6 +52,9 @@ __export(index_exports, {
48
52
  HIGH_ALIGNMENT: () => HIGH_ALIGNMENT,
49
53
  HYBRID_MODEL_CONFIGS: () => HYBRID_MODEL_CONFIGS,
50
54
  IDEAL_CROP_MULTIPLIER: () => IDEAL_CROP_MULTIPLIER,
55
+ LANDMARK_INDEX: () => LANDMARK_INDEX,
56
+ LANDMARK_MAX_BOUND: () => LANDMARK_MAX_BOUND,
57
+ LANDMARK_MIN_BOUND: () => LANDMARK_MIN_BOUND,
51
58
  LOW_LIGHT_THRESHOLD: () => LOW_LIGHT_THRESHOLD,
52
59
  LivenessApiError: () => LivenessApiError,
53
60
  LivenessClient: () => LivenessClient,
@@ -60,6 +67,7 @@ __export(index_exports, {
60
67
  MIN_FACE_RATIO: () => MIN_FACE_RATIO,
61
68
  MIN_FACE_SIDE_MARGIN: () => MIN_FACE_SIDE_MARGIN,
62
69
  MIN_FACE_TOP_MARGIN: () => MIN_FACE_TOP_MARGIN,
70
+ MIN_LANDMARK_COUNT: () => MIN_LANDMARK_COUNT,
63
71
  MODEL_CONFIGS: () => MODEL_CONFIGS,
64
72
  OVAL_GUIDE_COLORS: () => OVAL_GUIDE_COLORS,
65
73
  OVAL_GUIDE_STYLES: () => OVAL_GUIDE_STYLES,
@@ -91,7 +99,9 @@ __export(index_exports, {
91
99
  toFrameData: () => toFrameData,
92
100
  toHybridFrameData: () => toHybridFrameData,
93
101
  toLivenessResult: () => toLivenessResult,
102
+ toLivenessResultFromStream: () => toLivenessResultFromStream,
94
103
  validateApiKey: () => validateApiKey,
104
+ validateFaceLandmarks: () => validateFaceLandmarks,
95
105
  validateFrameCount: () => validateFrameCount,
96
106
  validateFrameData: () => validateFrameData,
97
107
  validateFrameIndex: () => validateFrameIndex,
@@ -112,6 +122,7 @@ var API_PATHS = {
112
122
  health: "/health",
113
123
  fastCheck: "/api/v1/fast-check",
114
124
  fastCheckCrops: "/api/v1/fast-check-crops",
125
+ fastCheckStream: "/api/v1/fast-check-stream",
115
126
  verify: "/api/v1/verify",
116
127
  hybridCheck: "/api/v1/hybrid-check",
117
128
  hybrid50: "/api/v1/hybrid-50",
@@ -232,6 +243,22 @@ function toLivenessResult(response) {
232
243
  framesProcessed: "frames_processed" in response ? response.frames_processed ?? 0 : 0
233
244
  };
234
245
  }
246
+ function toLivenessResultFromStream(response) {
247
+ if (response.status !== "complete") {
248
+ throw new LivenessApiError("Stream not complete", "stream_incomplete", 400);
249
+ }
250
+ if (!response.verdict) {
251
+ throw new LivenessApiError(response.error ?? "No verdict received", "no_verdict", 500);
252
+ }
253
+ return {
254
+ verdict: response.verdict,
255
+ confidence: response.confidence ?? 0,
256
+ score: response.score ?? 0,
257
+ sessionId: response.session_id,
258
+ processingMs: response.processing_ms ?? 0,
259
+ framesProcessed: response.frames_processed ?? 0
260
+ };
261
+ }
235
262
  function generateSessionId() {
236
263
  if (typeof crypto !== "undefined" && crypto.randomUUID) {
237
264
  return crypto.randomUUID();
@@ -366,6 +393,145 @@ var LivenessClient = class {
366
393
  });
367
394
  return toLivenessResult(response);
368
395
  }
396
+ /**
397
+ * Send a single captured frame to the streaming endpoint.
398
+ *
399
+ * Use this to implement **real-time** streaming: call once per captured
400
+ * frame instead of batching all frames first.
401
+ *
402
+ * - While the server is still collecting frames the response will have
403
+ * `status: 'buffering'` with `frames_received` / `frames_required`.
404
+ * - When the last required frame arrives the response will have
405
+ * `status: 'complete'` with the full liveness result.
406
+ *
407
+ * Each frame gets its own retry via {@link retryWithBackoff}.
408
+ *
409
+ * @param frame - A single captured frame
410
+ * @param options - Session, model and source
411
+ * @returns Stream response (buffering or complete)
412
+ */
413
+ async streamFrame(frame, options) {
414
+ const frameData = {
415
+ index: frame.index,
416
+ timestamp_ms: frame.timestampMs,
417
+ pixels: frame.pixels
418
+ };
419
+ return this.sendStreamFrameInternal(frameData, {
420
+ sessionId: options.sessionId,
421
+ model: options.model ?? "10",
422
+ source: options.source ?? "live"
423
+ });
424
+ }
425
+ /**
426
+ * Send a single FrameData to the streaming endpoint with retry (internal)
427
+ *
428
+ * @param frame - Single frame to send
429
+ * @param options - Session and model options
430
+ * @returns Stream response with status
431
+ */
432
+ async sendStreamFrameInternal(frameData, options) {
433
+ const request = {
434
+ session_id: options.sessionId,
435
+ model: options.model,
436
+ source: options.source,
437
+ frame: frameData
438
+ };
439
+ return this.requestWithRetry(API_PATHS.fastCheckStream, {
440
+ method: "POST",
441
+ body: JSON.stringify(request)
442
+ });
443
+ }
444
+ /**
445
+ * Perform streaming liveness check by sending frames one at a time
446
+ *
447
+ * Each frame is sent separately to the streaming endpoint with individual retry.
448
+ * The last frame that completes the set will return the full liveness result.
449
+ *
450
+ * @param frames - Captured frames to analyze
451
+ * @param options - Additional options
452
+ * @param callbacks - Optional callbacks for progress tracking
453
+ * @returns Liveness result
454
+ */
455
+ async fastCheckStream(frames, options = {}, callbacks) {
456
+ const sessionId = options.sessionId ?? generateSessionId();
457
+ const model = options.model ?? "10";
458
+ const source = options.source ?? "live";
459
+ const frameDataList = toFrameData(frames);
460
+ const total = frameDataList.length;
461
+ let finalResponse = null;
462
+ const sendPromises = frameDataList.map(async (frameData, index) => {
463
+ const response = await this.sendStreamFrameInternal(frameData, {
464
+ sessionId,
465
+ model,
466
+ source
467
+ });
468
+ callbacks?.onFrameSent?.(index + 1, total);
469
+ if (response.status === "buffering") {
470
+ callbacks?.onFrameBuffered?.(response.frames_received, response.frames_required);
471
+ }
472
+ return response;
473
+ });
474
+ const responses = await Promise.all(sendPromises);
475
+ finalResponse = responses.find((r) => r.status === "complete") ?? null;
476
+ if (!finalResponse) {
477
+ const lastResponse = responses[responses.length - 1];
478
+ if (lastResponse) {
479
+ throw new LivenessApiError(
480
+ `Streaming incomplete: received ${lastResponse.frames_received}/${lastResponse.frames_required} frames`,
481
+ "stream_incomplete",
482
+ 400
483
+ );
484
+ }
485
+ throw new LivenessApiError(
486
+ "Streaming failed: no responses received",
487
+ "stream_incomplete",
488
+ 400
489
+ );
490
+ }
491
+ return toLivenessResultFromStream(finalResponse);
492
+ }
493
+ /**
494
+ * Perform streaming liveness check by sending frames sequentially
495
+ *
496
+ * Alternative to fastCheckStream that sends frames one by one in order.
497
+ * Useful when parallel uploads are not desired or network is constrained.
498
+ *
499
+ * @param frames - Captured frames to analyze
500
+ * @param options - Additional options
501
+ * @param callbacks - Optional callbacks for progress tracking
502
+ * @returns Liveness result
503
+ */
504
+ async fastCheckStreamSequential(frames, options = {}, callbacks) {
505
+ const sessionId = options.sessionId ?? generateSessionId();
506
+ const model = options.model ?? "10";
507
+ const source = options.source ?? "live";
508
+ const frameDataList = toFrameData(frames);
509
+ const total = frameDataList.length;
510
+ let finalResponse = null;
511
+ for (const frameData of frameDataList) {
512
+ const response = await this.sendStreamFrameInternal(frameData, {
513
+ sessionId,
514
+ model,
515
+ source
516
+ });
517
+ const currentIndex = frameData.index;
518
+ callbacks?.onFrameSent?.(currentIndex + 1, total);
519
+ if (response.status === "buffering") {
520
+ callbacks?.onFrameBuffered?.(response.frames_received, response.frames_required);
521
+ } else if (response.status === "complete") {
522
+ finalResponse = response;
523
+ break;
524
+ }
525
+ }
526
+ if (!finalResponse) {
527
+ throw new LivenessApiError(
528
+ "Streaming did not complete after all frames sent",
529
+ "stream_incomplete",
530
+ 400
531
+ );
532
+ }
533
+ return toLivenessResultFromStream(finalResponse);
534
+ }
369
535
  // ===========================================================================
370
536
  // Verify Endpoint (Spatial Features)
371
537
  // ===========================================================================
@@ -622,6 +788,7 @@ var FrameQueue = class {
622
788
 
623
789
  // src/types/models.ts
624
790
  var MODEL_CONFIGS = {
791
+ // Standard fast-check models
625
792
  "10": {
626
793
  type: "10",
627
794
  minFrames: 10,
@@ -639,6 +806,104 @@ var MODEL_CONFIGS = {
639
806
  minFrames: 250,
640
807
  recommendedFrames: 250,
641
808
  description: "High-accuracy model - 250 frames, best accuracy"
809
+ },
810
+ // Hybrid V2 models with physiological features
811
+ "hybrid-v2-10": {
812
+ type: "hybrid-v2-10",
813
+ minFrames: 10,
814
+ recommendedFrames: 10,
815
+ description: "Hybrid V2 10-frame model with physio features"
816
+ },
817
+ "hybrid-v2-30": {
818
+ type: "hybrid-v2-30",
819
+ minFrames: 30,
820
+ recommendedFrames: 30,
821
+ description: "Hybrid V2 30-frame model with physio features"
822
+ },
823
+ "hybrid-v2-50": {
824
+ type: "hybrid-v2-50",
825
+ minFrames: 50,
826
+ recommendedFrames: 50,
827
+ description: "Hybrid V2 50-frame model with physio features"
828
+ },
829
+ "hybrid-v2-60": {
830
+ type: "hybrid-v2-60",
831
+ minFrames: 60,
832
+ recommendedFrames: 60,
833
+ description: "Hybrid V2 60-frame model with physio features"
834
+ },
835
+ "hybrid-v2-90": {
836
+ type: "hybrid-v2-90",
837
+ minFrames: 90,
838
+ recommendedFrames: 90,
839
+ description: "Hybrid V2 90-frame model with physio features"
840
+ },
841
+ "hybrid-v2-100": {
842
+ type: "hybrid-v2-100",
843
+ minFrames: 100,
844
+ recommendedFrames: 100,
845
+ description: "Hybrid V2 100-frame model with physio features"
846
+ },
847
+ "hybrid-v2-125": {
848
+ type: "hybrid-v2-125",
849
+ minFrames: 125,
850
+ recommendedFrames: 125,
851
+ description: "Hybrid V2 125-frame model with physio features"
852
+ },
853
+ "hybrid-v2-150": {
854
+ type: "hybrid-v2-150",
855
+ minFrames: 150,
856
+ recommendedFrames: 150,
857
+ description: "Hybrid V2 150-frame model with physio features"
858
+ },
859
+ "hybrid-v2-250": {
860
+ type: "hybrid-v2-250",
861
+ minFrames: 250,
862
+ recommendedFrames: 250,
863
+ description: "Hybrid V2 250-frame model with physio features"
864
+ },
865
+ // Mixed models (FastLivenessDetector with mixed-trained weights)
866
+ "mixed-10": {
867
+ type: "mixed-10",
868
+ minFrames: 10,
869
+ recommendedFrames: 10,
870
+ description: "Mixed 10-frame model"
871
+ },
872
+ "mixed-30": {
873
+ type: "mixed-30",
874
+ minFrames: 30,
875
+ recommendedFrames: 30,
876
+ description: "Mixed 30-frame model"
877
+ },
878
+ "mixed-60": {
879
+ type: "mixed-60",
880
+ minFrames: 60,
881
+ recommendedFrames: 60,
882
+ description: "Mixed 60-frame model"
883
+ },
884
+ "mixed-90": {
885
+ type: "mixed-90",
886
+ minFrames: 90,
887
+ recommendedFrames: 90,
888
+ description: "Mixed 90-frame model"
889
+ },
890
+ "mixed-120": {
891
+ type: "mixed-120",
892
+ minFrames: 120,
893
+ recommendedFrames: 120,
894
+ description: "Mixed 120-frame model"
895
+ },
896
+ "mixed-150": {
897
+ type: "mixed-150",
898
+ minFrames: 150,
899
+ recommendedFrames: 150,
900
+ description: "Mixed 150-frame model"
901
+ },
902
+ "mixed-250": {
903
+ type: "mixed-250",
904
+ minFrames: 250,
905
+ recommendedFrames: 250,
906
+ description: "Mixed 250-frame model"
642
907
  }
643
908
  };
644
909
  var HYBRID_MODEL_CONFIGS = {
@@ -664,6 +929,29 @@ function hasEnoughFrames(model, frameCount) {
664
929
  return frameCount >= MODEL_CONFIGS[model].minFrames;
665
930
  }
666
931
 
932
+ // src/types/detectors.ts
933
+ var DEFAULT_GAZE_THRESHOLDS = {
934
+ maxYaw: 25,
935
+ maxPitch: 20
936
+ };
937
+ var DEFAULT_HAND_OCCLUSION_CONFIG = {
938
+ faceExpansionFactor: 0.5,
939
+ minLandmarksForOcclusion: 3,
940
+ faceBoxExpiryMs: 1e3,
941
+ centerRegion: 0.6
942
+ };
943
+ var DEFAULT_FACE_DETECTION_TIERS = {
944
+ primaryConfidence: 0.7,
945
+ secondaryConfidence: 0.4
946
+ };
947
+ var DEFAULT_STABILIZER_CONFIG = {
948
+ stabilityThreshold: 1.5,
949
+ requiredStableFrames: 15,
950
+ maxWaitMs: 4e3,
951
+ checkIntervalMs: 100,
952
+ sampleSize: 64
953
+ };
954
+
667
955
  // src/constants/feedback.ts
668
956
  var ALIGNMENT_THRESHOLD_CAPTURE = 0.6;
669
957
  var ALIGNMENT_THRESHOLD_POOR = 0.5;
@@ -915,15 +1203,15 @@ var MIN_FACE_SIDE_MARGIN = 0.05;
915
1203
  var MIN_CAPTURE_ALIGNMENT = 0.6;
916
1204
  var HIGH_ALIGNMENT = 0.85;
917
1205
  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;
1206
+ var IDEAL_CROP_MULTIPLIER = 2;
1207
+ var MIN_CROP_MULTIPLIER = 1.8;
1208
+ var MAX_CROP_MULTIPLIER = 2.5;
1209
+ var FACE_CENTER_VERTICAL_OFFSET = 0.05;
1210
+ var MIN_FACE_RATIO = 0.036;
923
1211
  var MAX_FACE_RATIO = 0.7;
924
1212
  var FACE_CROP_OUTPUT_SIZE = 224;
925
- var MAX_FACE_PERCENTAGE_IN_CROP = 0.5;
926
- var TARGET_FACE_PERCENTAGE_IN_CROP = 0.3;
1213
+ var MAX_FACE_PERCENTAGE_IN_CROP = 0.6;
1214
+ var TARGET_FACE_PERCENTAGE_IN_CROP = 0.5;
927
1215
  function analyzeBlur(grayscalePixels, width, height, threshold = DEFAULT_BLUR_THRESHOLD) {
928
1216
  const laplacian = [];
929
1217
  for (let y = 1; y < height - 1; y++) {
@@ -1012,10 +1300,10 @@ function isFaceFullyVisible(boundingBox, frameWidth, frameHeight) {
1012
1300
  var DEFAULT_OVAL_REGION = {
1013
1301
  centerX: 0.5,
1014
1302
  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)
1303
+ width: 0.36,
1304
+ // 36% of frame width (+20%)
1305
+ height: 0.48
1306
+ // 36% * (4/3) = 48% of frame height (+20%)
1019
1307
  };
1020
1308
  function isFaceInOval(faceBox, frameWidth, frameHeight, oval = DEFAULT_OVAL_REGION, tolerance = 0.3) {
1021
1309
  const faceCenterX = (faceBox.originX + faceBox.width / 2) / frameWidth;
@@ -1204,6 +1492,37 @@ var BaseFrameCollector = class {
1204
1492
  return this.frames.length;
1205
1493
  }
1206
1494
  };
1495
+
1496
+ // src/utils/landmarkValidator.ts
1497
+ var LANDMARK_INDEX = {
1498
+ /** Nose tip */
1499
+ NOSE_TIP: 1,
1500
+ /** Upper lip center */
1501
+ UPPER_LIP: 13,
1502
+ /** Lower lip center */
1503
+ LOWER_LIP: 14
1504
+ };
1505
+ var LANDMARK_MIN_BOUND = 0.1;
1506
+ var LANDMARK_MAX_BOUND = 0.9;
1507
+ var MIN_LANDMARK_COUNT = 15;
1508
+ function isInBounds(x, y) {
1509
+ return x >= LANDMARK_MIN_BOUND && x <= LANDMARK_MAX_BOUND && y >= LANDMARK_MIN_BOUND && y <= LANDMARK_MAX_BOUND;
1510
+ }
1511
+ function validateFaceLandmarks(landmarks) {
1512
+ if (!landmarks || landmarks.length < MIN_LANDMARK_COUNT) {
1513
+ return { valid: false, message: "Move closer to the camera" };
1514
+ }
1515
+ const noseTip = landmarks[LANDMARK_INDEX.NOSE_TIP];
1516
+ const upperLip = landmarks[LANDMARK_INDEX.UPPER_LIP];
1517
+ const lowerLip = landmarks[LANDMARK_INDEX.LOWER_LIP];
1518
+ if (!noseTip || !isInBounds(noseTip.x, noseTip.y)) {
1519
+ return { valid: false, message: "Make sure your full face is visible" };
1520
+ }
1521
+ if (!upperLip || !lowerLip || !isInBounds(upperLip.x, upperLip.y) || !isInBounds(lowerLip.x, lowerLip.y)) {
1522
+ return { valid: false, message: "Don't cover your mouth" };
1523
+ }
1524
+ return { valid: true };
1525
+ }
1207
1526
  // Annotate the CommonJS export names for ESM import in node:
1208
1527
  0 && (module.exports = {
1209
1528
  ALIGNMENT_THRESHOLD_CAPTURE,
@@ -1218,9 +1537,13 @@ var BaseFrameCollector = class {
1218
1537
  BaseFrameCollector,
1219
1538
  DEFAULT_BLUR_THRESHOLD,
1220
1539
  DEFAULT_ENDPOINT,
1540
+ DEFAULT_FACE_DETECTION_TIERS,
1541
+ DEFAULT_GAZE_THRESHOLDS,
1542
+ DEFAULT_HAND_OCCLUSION_CONFIG,
1221
1543
  DEFAULT_LIVENESS_CONFIG,
1222
1544
  DEFAULT_LOCALE,
1223
1545
  DEFAULT_OVAL_REGION,
1546
+ DEFAULT_STABILIZER_CONFIG,
1224
1547
  DEFAULT_STATUS_MESSAGES,
1225
1548
  ES_LOCALE,
1226
1549
  FACE_CENTER_VERTICAL_OFFSET,
@@ -1234,6 +1557,9 @@ var BaseFrameCollector = class {
1234
1557
  HIGH_ALIGNMENT,
1235
1558
  HYBRID_MODEL_CONFIGS,
1236
1559
  IDEAL_CROP_MULTIPLIER,
1560
+ LANDMARK_INDEX,
1561
+ LANDMARK_MAX_BOUND,
1562
+ LANDMARK_MIN_BOUND,
1237
1563
  LOW_LIGHT_THRESHOLD,
1238
1564
  LivenessApiError,
1239
1565
  LivenessClient,
@@ -1246,6 +1572,7 @@ var BaseFrameCollector = class {
1246
1572
  MIN_FACE_RATIO,
1247
1573
  MIN_FACE_SIDE_MARGIN,
1248
1574
  MIN_FACE_TOP_MARGIN,
1575
+ MIN_LANDMARK_COUNT,
1249
1576
  MODEL_CONFIGS,
1250
1577
  OVAL_GUIDE_COLORS,
1251
1578
  OVAL_GUIDE_STYLES,
@@ -1277,7 +1604,9 @@ var BaseFrameCollector = class {
1277
1604
  toFrameData,
1278
1605
  toHybridFrameData,
1279
1606
  toLivenessResult,
1607
+ toLivenessResultFromStream,
1280
1608
  validateApiKey,
1609
+ validateFaceLandmarks,
1281
1610
  validateFrameCount,
1282
1611
  validateFrameData,
1283
1612
  validateFrameIndex,