@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.mjs CHANGED
@@ -9,6 +9,7 @@ var API_PATHS = {
9
9
  health: "/health",
10
10
  fastCheck: "/api/v1/fast-check",
11
11
  fastCheckCrops: "/api/v1/fast-check-crops",
12
+ fastCheckStream: "/api/v1/fast-check-stream",
12
13
  verify: "/api/v1/verify",
13
14
  hybridCheck: "/api/v1/hybrid-check",
14
15
  hybrid50: "/api/v1/hybrid-50",
@@ -129,6 +130,22 @@ function toLivenessResult(response) {
129
130
  framesProcessed: "frames_processed" in response ? response.frames_processed ?? 0 : 0
130
131
  };
131
132
  }
133
+ function toLivenessResultFromStream(response) {
134
+ if (response.status !== "complete") {
135
+ throw new LivenessApiError("Stream not complete", "stream_incomplete", 400);
136
+ }
137
+ if (!response.verdict) {
138
+ throw new LivenessApiError(response.error ?? "No verdict received", "no_verdict", 500);
139
+ }
140
+ return {
141
+ verdict: response.verdict,
142
+ confidence: response.confidence ?? 0,
143
+ score: response.score ?? 0,
144
+ sessionId: response.session_id,
145
+ processingMs: response.processing_ms ?? 0,
146
+ framesProcessed: response.frames_processed ?? 0
147
+ };
148
+ }
132
149
  function generateSessionId() {
133
150
  if (typeof crypto !== "undefined" && crypto.randomUUID) {
134
151
  return crypto.randomUUID();
@@ -263,6 +280,145 @@ var LivenessClient = class {
263
280
  });
264
281
  return toLivenessResult(response);
265
282
  }
283
+ /**
284
+ * Send a single captured frame to the streaming endpoint.
285
+ *
286
+ * Use this to implement **real-time** streaming: call once per captured
287
+ * frame instead of batching all frames first.
288
+ *
289
+ * - While the server is still collecting frames the response will have
290
+ * `status: 'buffering'` with `frames_received` / `frames_required`.
291
+ * - When the last required frame arrives the response will have
292
+ * `status: 'complete'` with the full liveness result.
293
+ *
294
+ * Each frame gets its own retry via {@link retryWithBackoff}.
295
+ *
296
+ * @param frame - A single captured frame
297
+ * @param options - Session, model and source
298
+ * @returns Stream response (buffering or complete)
299
+ */
300
+ async streamFrame(frame, options) {
301
+ const frameData = {
302
+ index: frame.index,
303
+ timestamp_ms: frame.timestampMs,
304
+ pixels: frame.pixels
305
+ };
306
+ return this.sendStreamFrameInternal(frameData, {
307
+ sessionId: options.sessionId,
308
+ model: options.model ?? "10",
309
+ source: options.source ?? "live"
310
+ });
311
+ }
312
+ /**
313
+ * Send a single FrameData to the streaming endpoint with retry (internal)
314
+ *
315
+ * @param frame - Single frame to send
316
+ * @param options - Session and model options
317
+ * @returns Stream response with status
318
+ */
319
+ async sendStreamFrameInternal(frameData, options) {
320
+ const request = {
321
+ session_id: options.sessionId,
322
+ model: options.model,
323
+ source: options.source,
324
+ frame: frameData
325
+ };
326
+ return this.requestWithRetry(API_PATHS.fastCheckStream, {
327
+ method: "POST",
328
+ body: JSON.stringify(request)
329
+ });
330
+ }
331
+ /**
332
+ * Perform streaming liveness check by sending frames one at a time
333
+ *
334
+ * Each frame is sent separately to the streaming endpoint with individual retry.
335
+ * The last frame that completes the set will return the full liveness result.
336
+ *
337
+ * @param frames - Captured frames to analyze
338
+ * @param options - Additional options
339
+ * @param callbacks - Optional callbacks for progress tracking
340
+ * @returns Liveness result
341
+ */
342
+ async fastCheckStream(frames, options = {}, callbacks) {
343
+ const sessionId = options.sessionId ?? generateSessionId();
344
+ const model = options.model ?? "10";
345
+ const source = options.source ?? "live";
346
+ const frameDataList = toFrameData(frames);
347
+ const total = frameDataList.length;
348
+ let finalResponse = null;
349
+ const sendPromises = frameDataList.map(async (frameData, index) => {
350
+ const response = await this.sendStreamFrameInternal(frameData, {
351
+ sessionId,
352
+ model,
353
+ source
354
+ });
355
+ callbacks?.onFrameSent?.(index + 1, total);
356
+ if (response.status === "buffering") {
357
+ callbacks?.onFrameBuffered?.(response.frames_received, response.frames_required);
358
+ }
359
+ return response;
360
+ });
361
+ const responses = await Promise.all(sendPromises);
362
+ finalResponse = responses.find((r) => r.status === "complete") ?? null;
363
+ if (!finalResponse) {
364
+ const lastResponse = responses[responses.length - 1];
365
+ if (lastResponse) {
366
+ throw new LivenessApiError(
367
+ `Streaming incomplete: received ${lastResponse.frames_received}/${lastResponse.frames_required} frames`,
368
+ "stream_incomplete",
369
+ 400
370
+ );
371
+ }
372
+ throw new LivenessApiError(
373
+ "Streaming failed: no responses received",
374
+ "stream_incomplete",
375
+ 400
376
+ );
377
+ }
378
+ return toLivenessResultFromStream(finalResponse);
379
+ }
380
+ /**
381
+ * Perform streaming liveness check by sending frames sequentially
382
+ *
383
+ * Alternative to fastCheckStream that sends frames one by one in order.
384
+ * Useful when parallel uploads are not desired or network is constrained.
385
+ *
386
+ * @param frames - Captured frames to analyze
387
+ * @param options - Additional options
388
+ * @param callbacks - Optional callbacks for progress tracking
389
+ * @returns Liveness result
390
+ */
391
+ async fastCheckStreamSequential(frames, options = {}, callbacks) {
392
+ const sessionId = options.sessionId ?? generateSessionId();
393
+ const model = options.model ?? "10";
394
+ const source = options.source ?? "live";
395
+ const frameDataList = toFrameData(frames);
396
+ const total = frameDataList.length;
397
+ let finalResponse = null;
398
+ for (const frameData of frameDataList) {
399
+ const response = await this.sendStreamFrameInternal(frameData, {
400
+ sessionId,
401
+ model,
402
+ source
403
+ });
404
+ const currentIndex = frameData.index;
405
+ callbacks?.onFrameSent?.(currentIndex + 1, total);
406
+ if (response.status === "buffering") {
407
+ callbacks?.onFrameBuffered?.(response.frames_received, response.frames_required);
408
+ } else if (response.status === "complete") {
409
+ finalResponse = response;
410
+ break;
411
+ }
412
+ }
413
+ if (!finalResponse) {
414
+ throw new LivenessApiError(
415
+ "Streaming did not complete after all frames sent",
416
+ "stream_incomplete",
417
+ 400
418
+ );
419
+ }
420
+ return toLivenessResultFromStream(finalResponse);
421
+ }
266
422
  // ===========================================================================
267
423
  // Verify Endpoint (Spatial Features)
268
424
  // ===========================================================================
@@ -519,6 +675,7 @@ var FrameQueue = class {
519
675
 
520
676
  // src/types/models.ts
521
677
  var MODEL_CONFIGS = {
678
+ // Standard fast-check models
522
679
  "10": {
523
680
  type: "10",
524
681
  minFrames: 10,
@@ -536,6 +693,104 @@ var MODEL_CONFIGS = {
536
693
  minFrames: 250,
537
694
  recommendedFrames: 250,
538
695
  description: "High-accuracy model - 250 frames, best accuracy"
696
+ },
697
+ // Hybrid V2 models with physiological features
698
+ "hybrid-v2-10": {
699
+ type: "hybrid-v2-10",
700
+ minFrames: 10,
701
+ recommendedFrames: 10,
702
+ description: "Hybrid V2 10-frame model with physio features"
703
+ },
704
+ "hybrid-v2-30": {
705
+ type: "hybrid-v2-30",
706
+ minFrames: 30,
707
+ recommendedFrames: 30,
708
+ description: "Hybrid V2 30-frame model with physio features"
709
+ },
710
+ "hybrid-v2-50": {
711
+ type: "hybrid-v2-50",
712
+ minFrames: 50,
713
+ recommendedFrames: 50,
714
+ description: "Hybrid V2 50-frame model with physio features"
715
+ },
716
+ "hybrid-v2-60": {
717
+ type: "hybrid-v2-60",
718
+ minFrames: 60,
719
+ recommendedFrames: 60,
720
+ description: "Hybrid V2 60-frame model with physio features"
721
+ },
722
+ "hybrid-v2-90": {
723
+ type: "hybrid-v2-90",
724
+ minFrames: 90,
725
+ recommendedFrames: 90,
726
+ description: "Hybrid V2 90-frame model with physio features"
727
+ },
728
+ "hybrid-v2-100": {
729
+ type: "hybrid-v2-100",
730
+ minFrames: 100,
731
+ recommendedFrames: 100,
732
+ description: "Hybrid V2 100-frame model with physio features"
733
+ },
734
+ "hybrid-v2-125": {
735
+ type: "hybrid-v2-125",
736
+ minFrames: 125,
737
+ recommendedFrames: 125,
738
+ description: "Hybrid V2 125-frame model with physio features"
739
+ },
740
+ "hybrid-v2-150": {
741
+ type: "hybrid-v2-150",
742
+ minFrames: 150,
743
+ recommendedFrames: 150,
744
+ description: "Hybrid V2 150-frame model with physio features"
745
+ },
746
+ "hybrid-v2-250": {
747
+ type: "hybrid-v2-250",
748
+ minFrames: 250,
749
+ recommendedFrames: 250,
750
+ description: "Hybrid V2 250-frame model with physio features"
751
+ },
752
+ // Mixed models (FastLivenessDetector with mixed-trained weights)
753
+ "mixed-10": {
754
+ type: "mixed-10",
755
+ minFrames: 10,
756
+ recommendedFrames: 10,
757
+ description: "Mixed 10-frame model"
758
+ },
759
+ "mixed-30": {
760
+ type: "mixed-30",
761
+ minFrames: 30,
762
+ recommendedFrames: 30,
763
+ description: "Mixed 30-frame model"
764
+ },
765
+ "mixed-60": {
766
+ type: "mixed-60",
767
+ minFrames: 60,
768
+ recommendedFrames: 60,
769
+ description: "Mixed 60-frame model"
770
+ },
771
+ "mixed-90": {
772
+ type: "mixed-90",
773
+ minFrames: 90,
774
+ recommendedFrames: 90,
775
+ description: "Mixed 90-frame model"
776
+ },
777
+ "mixed-120": {
778
+ type: "mixed-120",
779
+ minFrames: 120,
780
+ recommendedFrames: 120,
781
+ description: "Mixed 120-frame model"
782
+ },
783
+ "mixed-150": {
784
+ type: "mixed-150",
785
+ minFrames: 150,
786
+ recommendedFrames: 150,
787
+ description: "Mixed 150-frame model"
788
+ },
789
+ "mixed-250": {
790
+ type: "mixed-250",
791
+ minFrames: 250,
792
+ recommendedFrames: 250,
793
+ description: "Mixed 250-frame model"
539
794
  }
540
795
  };
541
796
  var HYBRID_MODEL_CONFIGS = {
@@ -561,6 +816,29 @@ function hasEnoughFrames(model, frameCount) {
561
816
  return frameCount >= MODEL_CONFIGS[model].minFrames;
562
817
  }
563
818
 
819
+ // src/types/detectors.ts
820
+ var DEFAULT_GAZE_THRESHOLDS = {
821
+ maxYaw: 25,
822
+ maxPitch: 20
823
+ };
824
+ var DEFAULT_HAND_OCCLUSION_CONFIG = {
825
+ faceExpansionFactor: 0.5,
826
+ minLandmarksForOcclusion: 3,
827
+ faceBoxExpiryMs: 1e3,
828
+ centerRegion: 0.6
829
+ };
830
+ var DEFAULT_FACE_DETECTION_TIERS = {
831
+ primaryConfidence: 0.7,
832
+ secondaryConfidence: 0.4
833
+ };
834
+ var DEFAULT_STABILIZER_CONFIG = {
835
+ stabilityThreshold: 1.5,
836
+ requiredStableFrames: 15,
837
+ maxWaitMs: 4e3,
838
+ checkIntervalMs: 100,
839
+ sampleSize: 64
840
+ };
841
+
564
842
  // src/constants/feedback.ts
565
843
  var ALIGNMENT_THRESHOLD_CAPTURE = 0.6;
566
844
  var ALIGNMENT_THRESHOLD_POOR = 0.5;
@@ -812,15 +1090,15 @@ var MIN_FACE_SIDE_MARGIN = 0.05;
812
1090
  var MIN_CAPTURE_ALIGNMENT = 0.6;
813
1091
  var HIGH_ALIGNMENT = 0.85;
814
1092
  var GOOD_ALIGNMENT = 0.5;
815
- var IDEAL_CROP_MULTIPLIER = 3.33;
816
- var MIN_CROP_MULTIPLIER = 1.5;
817
- var MAX_CROP_MULTIPLIER = 4;
818
- var FACE_CENTER_VERTICAL_OFFSET = 0.15;
819
- var MIN_FACE_RATIO = 0.03;
1093
+ var IDEAL_CROP_MULTIPLIER = 2;
1094
+ var MIN_CROP_MULTIPLIER = 1.8;
1095
+ var MAX_CROP_MULTIPLIER = 2.5;
1096
+ var FACE_CENTER_VERTICAL_OFFSET = 0.05;
1097
+ var MIN_FACE_RATIO = 0.036;
820
1098
  var MAX_FACE_RATIO = 0.7;
821
1099
  var FACE_CROP_OUTPUT_SIZE = 224;
822
- var MAX_FACE_PERCENTAGE_IN_CROP = 0.5;
823
- var TARGET_FACE_PERCENTAGE_IN_CROP = 0.3;
1100
+ var MAX_FACE_PERCENTAGE_IN_CROP = 0.6;
1101
+ var TARGET_FACE_PERCENTAGE_IN_CROP = 0.5;
824
1102
  function analyzeBlur(grayscalePixels, width, height, threshold = DEFAULT_BLUR_THRESHOLD) {
825
1103
  const laplacian = [];
826
1104
  for (let y = 1; y < height - 1; y++) {
@@ -909,10 +1187,10 @@ function isFaceFullyVisible(boundingBox, frameWidth, frameHeight) {
909
1187
  var DEFAULT_OVAL_REGION = {
910
1188
  centerX: 0.5,
911
1189
  centerY: 0.5,
912
- width: 0.3,
913
- // 30% of frame width
914
- height: 0.4
915
- // 30% * (4/3) = 40% of frame height (3:4 aspect ratio)
1190
+ width: 0.36,
1191
+ // 36% of frame width (+20%)
1192
+ height: 0.48
1193
+ // 36% * (4/3) = 48% of frame height (+20%)
916
1194
  };
917
1195
  function isFaceInOval(faceBox, frameWidth, frameHeight, oval = DEFAULT_OVAL_REGION, tolerance = 0.3) {
918
1196
  const faceCenterX = (faceBox.originX + faceBox.width / 2) / frameWidth;
@@ -1101,6 +1379,37 @@ var BaseFrameCollector = class {
1101
1379
  return this.frames.length;
1102
1380
  }
1103
1381
  };
1382
+
1383
+ // src/utils/landmarkValidator.ts
1384
+ var LANDMARK_INDEX = {
1385
+ /** Nose tip */
1386
+ NOSE_TIP: 1,
1387
+ /** Upper lip center */
1388
+ UPPER_LIP: 13,
1389
+ /** Lower lip center */
1390
+ LOWER_LIP: 14
1391
+ };
1392
+ var LANDMARK_MIN_BOUND = 0.1;
1393
+ var LANDMARK_MAX_BOUND = 0.9;
1394
+ var MIN_LANDMARK_COUNT = 15;
1395
+ function isInBounds(x, y) {
1396
+ return x >= LANDMARK_MIN_BOUND && x <= LANDMARK_MAX_BOUND && y >= LANDMARK_MIN_BOUND && y <= LANDMARK_MAX_BOUND;
1397
+ }
1398
+ function validateFaceLandmarks(landmarks) {
1399
+ if (!landmarks || landmarks.length < MIN_LANDMARK_COUNT) {
1400
+ return { valid: false, message: "Move closer to the camera" };
1401
+ }
1402
+ const noseTip = landmarks[LANDMARK_INDEX.NOSE_TIP];
1403
+ const upperLip = landmarks[LANDMARK_INDEX.UPPER_LIP];
1404
+ const lowerLip = landmarks[LANDMARK_INDEX.LOWER_LIP];
1405
+ if (!noseTip || !isInBounds(noseTip.x, noseTip.y)) {
1406
+ return { valid: false, message: "Make sure your full face is visible" };
1407
+ }
1408
+ if (!upperLip || !lowerLip || !isInBounds(upperLip.x, upperLip.y) || !isInBounds(lowerLip.x, lowerLip.y)) {
1409
+ return { valid: false, message: "Don't cover your mouth" };
1410
+ }
1411
+ return { valid: true };
1412
+ }
1104
1413
  export {
1105
1414
  ALIGNMENT_THRESHOLD_CAPTURE,
1106
1415
  ALIGNMENT_THRESHOLD_GOOD,
@@ -1114,9 +1423,13 @@ export {
1114
1423
  BaseFrameCollector,
1115
1424
  DEFAULT_BLUR_THRESHOLD,
1116
1425
  DEFAULT_ENDPOINT,
1426
+ DEFAULT_FACE_DETECTION_TIERS,
1427
+ DEFAULT_GAZE_THRESHOLDS,
1428
+ DEFAULT_HAND_OCCLUSION_CONFIG,
1117
1429
  DEFAULT_LIVENESS_CONFIG,
1118
1430
  DEFAULT_LOCALE,
1119
1431
  DEFAULT_OVAL_REGION,
1432
+ DEFAULT_STABILIZER_CONFIG,
1120
1433
  DEFAULT_STATUS_MESSAGES,
1121
1434
  ES_LOCALE,
1122
1435
  FACE_CENTER_VERTICAL_OFFSET,
@@ -1130,6 +1443,9 @@ export {
1130
1443
  HIGH_ALIGNMENT,
1131
1444
  HYBRID_MODEL_CONFIGS,
1132
1445
  IDEAL_CROP_MULTIPLIER,
1446
+ LANDMARK_INDEX,
1447
+ LANDMARK_MAX_BOUND,
1448
+ LANDMARK_MIN_BOUND,
1133
1449
  LOW_LIGHT_THRESHOLD,
1134
1450
  LivenessApiError,
1135
1451
  LivenessClient,
@@ -1142,6 +1458,7 @@ export {
1142
1458
  MIN_FACE_RATIO,
1143
1459
  MIN_FACE_SIDE_MARGIN,
1144
1460
  MIN_FACE_TOP_MARGIN,
1461
+ MIN_LANDMARK_COUNT,
1145
1462
  MODEL_CONFIGS,
1146
1463
  OVAL_GUIDE_COLORS,
1147
1464
  OVAL_GUIDE_STYLES,
@@ -1173,7 +1490,9 @@ export {
1173
1490
  toFrameData,
1174
1491
  toHybridFrameData,
1175
1492
  toLivenessResult,
1493
+ toLivenessResultFromStream,
1176
1494
  validateApiKey,
1495
+ validateFaceLandmarks,
1177
1496
  validateFrameCount,
1178
1497
  validateFrameData,
1179
1498
  validateFrameIndex,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moveris/shared",
3
- "version": "1.0.2",
3
+ "version": "2.1.0",
4
4
  "description": "Core business logic for Moveris Live SDK",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",