react-native-biometric-verifier 0.0.17 → 0.0.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-biometric-verifier",
3
- "version": "0.0.17",
3
+ "version": "0.0.18",
4
4
  "description": "A React Native module for biometric verification with face recognition and QR code scanning",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -1,4 +1,4 @@
1
- import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
1
+ import React, { useRef, useEffect, useState, useCallback } from 'react';
2
2
  import {
3
3
  View,
4
4
  TouchableOpacity,
@@ -8,6 +8,7 @@ import {
8
8
  Platform,
9
9
  AppState,
10
10
  BackHandler,
11
+ Animated,
11
12
  } from 'react-native';
12
13
  import Icon from 'react-native-vector-icons/MaterialIcons';
13
14
  import {
@@ -39,6 +40,7 @@ const CaptureImageWithoutEdit = React.memo(
39
40
  const [faces, setFaces] = useState([]);
40
41
  const [singleFaceDetected, setSingleFaceDetected] = useState(false);
41
42
  const [cameraError, setCameraError] = useState(null);
43
+ const [livenessStep, setLivenessStep] = useState(0);
42
44
 
43
45
  const captured = useRef(false);
44
46
  const appState = useRef(AppState.currentState);
@@ -46,11 +48,15 @@ const CaptureImageWithoutEdit = React.memo(
46
48
  const initializationAttempts = useRef(0);
47
49
  const maxInitializationAttempts = 3;
48
50
 
51
+ // Animation values
52
+ const instructionAnim = useRef(new Animated.Value(1)).current;
53
+
49
54
  // Reset capture state
50
55
  const resetCaptureState = useCallback(() => {
51
56
  captured.current = false;
52
57
  setSingleFaceDetected(false);
53
58
  setFaces([]);
59
+ setLivenessStep(0);
54
60
  }, []);
55
61
 
56
62
  // Code scanner
@@ -132,6 +138,18 @@ const CaptureImageWithoutEdit = React.memo(
132
138
  }
133
139
  }, []);
134
140
 
141
+ const onLivenessUpdate = useCallback((step) => {
142
+ setLivenessStep(step);
143
+
144
+ // Animate instruction change
145
+ instructionAnim.setValue(0);
146
+ Animated.timing(instructionAnim, {
147
+ toValue: 1,
148
+ duration: 300,
149
+ useNativeDriver: true,
150
+ }).start();
151
+ }, [instructionAnim]);
152
+
135
153
  // Use the face detection frame processor hook
136
154
  const {
137
155
  frameProcessor,
@@ -143,6 +161,7 @@ const CaptureImageWithoutEdit = React.memo(
143
161
  } = useFaceDetectionFrameProcessor({
144
162
  onStableFaceDetected,
145
163
  onFacesUpdate,
164
+ onLivenessUpdate,
146
165
  showCodeScanner,
147
166
  isLoading,
148
167
  isActive: showCamera && cameraInitialized, // Pass active state
@@ -343,6 +362,28 @@ const CaptureImageWithoutEdit = React.memo(
343
362
  }, 500);
344
363
  }, [initializeCamera, resetCaptureState, forceResetCaptureState]);
345
364
 
365
+ // Render liveness instructions
366
+ const renderLivenessInstruction = () => {
367
+ if (faces.length === 0) return 'Position your face in the frame';
368
+
369
+ switch (livenessStep) {
370
+ case 0:
371
+ return 'Look straight into the camera';
372
+ case 1:
373
+ return 'Turn your face to the right';
374
+ case 2:
375
+ return 'Now turn your face to the left';
376
+ case 3:
377
+ if (captured.current) {
378
+ return 'Capturing...';
379
+ } else {
380
+ return 'Perfect! Hold still for capture...';
381
+ }
382
+ default:
383
+ return 'Position your face in the frame';
384
+ }
385
+ };
386
+
346
387
  // camera placeholder / UI
347
388
  const renderCameraPlaceholder = () => (
348
389
  <View style={styles.cameraContainer}>
@@ -430,32 +471,65 @@ const CaptureImageWithoutEdit = React.memo(
430
471
  renderCameraPlaceholder()
431
472
  )}
432
473
 
433
- {/* Face detection status text */}
474
+ {/* Liveness Detection UI */}
434
475
  {!showCodeScanner && shouldRenderCamera && (
476
+ <View style={styles.livenessContainer}>
477
+ {/* Instructions with animation */}
478
+ <Animated.View
479
+ style={[
480
+ styles.instructionContainer,
481
+ {
482
+ opacity: instructionAnim,
483
+ transform: [
484
+ {
485
+ translateY: instructionAnim.interpolate({
486
+ inputRange: [0, 1],
487
+ outputRange: [10, 0],
488
+ }),
489
+ },
490
+ ],
491
+ },
492
+ ]}
493
+ >
494
+ <Text style={styles.instructionText}>{renderLivenessInstruction()}</Text>
495
+ </Animated.View>
496
+
497
+ {/* Step Indicators */}
498
+ <View style={styles.stepsContainer}>
499
+ {[0, 1, 2].map((step) => (
500
+ <React.Fragment key={step}>
501
+ <View style={[
502
+ styles.stepIndicator,
503
+ livenessStep > step ? styles.stepCompleted :
504
+ livenessStep === step ? styles.stepCurrent : styles.stepPending
505
+ ]}>
506
+ <Text style={styles.stepText}>{step + 1}</Text>
507
+ </View>
508
+ {step < 2 && (
509
+ <View style={[
510
+ styles.stepConnector,
511
+ livenessStep > step ? styles.connectorCompleted : {}
512
+ ]} />
513
+ )}
514
+ </React.Fragment>
515
+ ))}
516
+ </View>
517
+
518
+ {/* Step Labels */}
519
+ <View style={styles.stepLabelsContainer}>
520
+ <Text style={styles.stepLabel}>Center</Text>
521
+ <Text style={styles.stepLabel}>Right</Text>
522
+ <Text style={styles.stepLabel}>Left</Text>
523
+ </View>
524
+ </View>
525
+ )}
526
+
527
+ {/* Face detection status */}
528
+ {!showCodeScanner && shouldRenderCamera && faces.length > 1 && (
435
529
  <View style={styles.faceDetectionStatus}>
436
- {faces.length === 0 && (
437
- <Text style={styles.faceDetectionText}>
438
- Position your face in the frame
439
- </Text>
440
- )}
441
- {faces.length > 1 && (
442
- <Text style={[styles.faceDetectionText, styles.multipleFacesText]}>
443
- Multiple faces detected. Please ensure only one person is in the frame.
444
- </Text>
445
- )}
446
- {singleFaceDetected && !captured.current && (
447
- <Text style={[styles.faceDetectionText, styles.singleFaceText]}>
448
- Face detected. Hold still...
449
- </Text>
450
- )}
451
- {captured.current && (
452
- <View style={styles.capturingContainer}>
453
- <ActivityIndicator size="small" color={COLORS.light} />
454
- <Text style={[styles.faceDetectionText, styles.capturingText]}>
455
- Capturing...
456
- </Text>
457
- </View>
458
- )}
530
+ <Text style={[styles.faceDetectionText, styles.multipleFacesText]}>
531
+ Multiple faces detected. Please ensure only one person is in the frame.
532
+ </Text>
459
533
  </View>
460
534
  )}
461
535
 
@@ -465,10 +539,10 @@ const CaptureImageWithoutEdit = React.memo(
465
539
  <TouchableOpacity
466
540
  style={[
467
541
  styles.flipButton,
468
- (isLoading || captured.current) && styles.flipButtonDisabled,
542
+ (isLoading || captured.current || livenessStep > 0) && styles.flipButtonDisabled,
469
543
  ]}
470
544
  onPress={onToggleCamera}
471
- disabled={isLoading || captured.current}
545
+ disabled={isLoading || captured.current || livenessStep > 0}
472
546
  accessibilityLabel="Flip camera"
473
547
  >
474
548
  <Icon name="flip-camera-ios" size={28} color={COLORS.light} />
@@ -579,19 +653,80 @@ const styles = StyleSheet.create({
579
653
  multipleFacesText: {
580
654
  backgroundColor: 'rgba(255,0,0,0.7)',
581
655
  },
582
- singleFaceText: {
583
- backgroundColor: 'rgba(0,255,0,0.7)',
656
+ // Liveness detection styles
657
+ livenessContainer: {
658
+ position: 'absolute',
659
+ top: 40,
660
+ left: 0,
661
+ right: 0,
662
+ alignItems: 'center',
663
+ paddingHorizontal: 20,
584
664
  },
585
- capturingText: {
586
- backgroundColor: 'rgba(0,100,255,0.7)',
665
+ instructionContainer: {
666
+ backgroundColor: 'rgba(0,0,0,0.8)',
667
+ paddingHorizontal: 20,
668
+ paddingVertical: 12,
669
+ borderRadius: 8,
670
+ marginBottom: 20,
587
671
  },
588
- capturingContainer: {
672
+ instructionText: {
673
+ color: 'white',
674
+ fontSize: 16,
675
+ fontWeight: 'bold',
676
+ textAlign: 'center',
677
+ },
678
+ stepsContainer: {
589
679
  flexDirection: 'row',
590
680
  alignItems: 'center',
591
- backgroundColor: 'rgba(0,100,255,0.7)',
592
- paddingHorizontal: 16,
593
- paddingVertical: 8,
594
- borderRadius: 8,
681
+ marginBottom: 8,
682
+ },
683
+ stepIndicator: {
684
+ width: 30,
685
+ height: 30,
686
+ borderRadius: 15,
687
+ justifyContent: 'center',
688
+ alignItems: 'center',
689
+ borderWidth: 2,
690
+ },
691
+ stepCompleted: {
692
+ backgroundColor: COLORS.primary,
693
+ borderColor: COLORS.primary,
694
+ },
695
+ stepCurrent: {
696
+ backgroundColor: COLORS.primary,
697
+ borderColor: COLORS.primary,
698
+ opacity: 0.7,
699
+ },
700
+ stepPending: {
701
+ backgroundColor: 'transparent',
702
+ borderColor: 'rgba(255,255,255,0.5)',
703
+ },
704
+ stepText: {
705
+ color: 'white',
706
+ fontSize: 12,
707
+ fontWeight: 'bold',
708
+ },
709
+ stepConnector: {
710
+ flex: 1,
711
+ height: 2,
712
+ backgroundColor: 'rgba(255,255,255,0.3)',
713
+ marginHorizontal: 4,
714
+ },
715
+ connectorCompleted: {
716
+ backgroundColor: COLORS.primary,
717
+ },
718
+ stepLabelsContainer: {
719
+ flexDirection: 'row',
720
+ justifyContent: 'space-between',
721
+ width: '100%',
722
+ paddingHorizontal: 10,
723
+ },
724
+ stepLabel: {
725
+ color: 'white',
726
+ fontSize: 12,
727
+ opacity: 0.8,
728
+ textAlign: 'center',
729
+ flex: 1,
595
730
  },
596
731
  });
597
732
 
@@ -16,7 +16,7 @@ export default function Loader({
16
16
  overlayColor = 'rgba(0,0,0,0.4)',
17
17
  loaderColor = 'lightblue',
18
18
  size = 50,
19
- gifSource = {uri:`http://emr.amalaims.org:9393/file/getCommonFile/image/heartpulse.gif`},
19
+ gifSource = {uri: `http://emr.amalaims.org:9393/file/getCommonFile/image/heartpulse.gif`},
20
20
  message = '',
21
21
  messageStyle = {},
22
22
  animationType = 'fade',
@@ -27,6 +27,19 @@ export default function Loader({
27
27
  const [rotation] = useState(new Animated.Value(0));
28
28
  const [pulse] = useState(new Animated.Value(1));
29
29
  const [fade] = useState(new Animated.Value(0));
30
+ const [imageSource, setImageSource] = useState(gifSource);
31
+
32
+ // Reset imageSource whenever gifSource prop changes
33
+ useEffect(() => {
34
+ setImageSource(gifSource);
35
+ }, [gifSource]);
36
+
37
+ const handleImageError = () => {
38
+ // Fallback to default heartpulse GIF when image fails to load
39
+ setImageSource({
40
+ uri: "http://emr.amalaims.org:9393/file/getCommonFile/image/heartpulse.gif"
41
+ });
42
+ };
30
43
 
31
44
  // Rotation animation
32
45
  useEffect(() => {
@@ -73,7 +86,9 @@ export default function Loader({
73
86
  const loaderContent = gifSource ? (
74
87
  <FastImage
75
88
  style={[styles.icon_style, { width: normalize(size), height: normalize(size) }]}
76
- source={gifSource}
89
+ source={imageSource}
90
+ resizeMode={FastImage.resizeMode.contain}
91
+ onError={handleImageError}
77
92
  />
78
93
  ) : (
79
94
  <Animated.View style={[
@@ -120,7 +135,6 @@ export default function Loader({
120
135
  ...(shadow && styles.shadowStyle)
121
136
  }
122
137
  ]}>
123
- {console.log('loadersource--------------------',JSON.stringify(gifSource))}
124
138
  {loaderContent}
125
139
  {message ? (
126
140
  <Text style={[styles.messageText, messageStyle]}>
@@ -3,20 +3,25 @@ import { Worklets } from 'react-native-worklets-core';
3
3
  import { useFrameProcessor } from 'react-native-vision-camera';
4
4
  import { useFaceDetector } from 'react-native-vision-camera-face-detector';
5
5
 
6
- // Tuned constants
6
+ // Tuned constants for liveness detection
7
7
  const FACE_STABILITY_THRESHOLD = 3;
8
8
  const FACE_MOVEMENT_THRESHOLD = 15;
9
- const FRAME_PROCESSOR_MIN_INTERVAL_MS = 1000;
9
+ const FRAME_PROCESSOR_MIN_INTERVAL_MS = 800;
10
10
  const MIN_FACE_SIZE = 0.2;
11
11
 
12
+ // Liveness detection constants
13
+ const YAW_LEFT_THRESHOLD = -15; // face turned left (negative yaw)
14
+ const YAW_RIGHT_THRESHOLD = 15; // face turned right (positive yaw)
15
+ const YAW_CENTER_THRESHOLD = 5; // face centered
16
+
12
17
  export const useFaceDetectionFrameProcessor = ({
13
18
  onStableFaceDetected = () => {},
14
19
  onFacesUpdate = () => {},
20
+ onLivenessUpdate = () => {},
15
21
  showCodeScanner = false,
16
22
  isLoading = false,
17
- isActive = true, // Add this prop to control activation
23
+ isActive = true,
18
24
  }) => {
19
- // Face detector (fast mode only)
20
25
  const { detectFaces } = useFaceDetector({
21
26
  performanceMode: 'fast',
22
27
  landmarkMode: 'none',
@@ -25,23 +30,26 @@ export const useFaceDetectionFrameProcessor = ({
25
30
  minFaceSize: MIN_FACE_SIZE,
26
31
  });
27
32
 
28
- // Use ref to track component mount state
29
33
  const isMounted = useRef(true);
30
34
 
31
- // Consolidated shared state to reduce memory usage
32
- const sharedState = useMemo(() => Worklets.createSharedValue({
33
- lastProcessedTime: 0,
34
- lastX: 0,
35
- lastY: 0,
36
- lastW: 0,
37
- lastH: 0,
38
- stableCount: 0,
39
- captured: false,
40
- showCodeScanner: showCodeScanner,
41
- isActive: isActive, // Track active state
42
- }), []);
35
+ const sharedState = useMemo(() =>
36
+ Worklets.createSharedValue({
37
+ lastProcessedTime: 0,
38
+ lastX: 0,
39
+ lastY: 0,
40
+ lastW: 0,
41
+ lastH: 0,
42
+ stableCount: 0,
43
+ captured: false,
44
+ showCodeScanner: showCodeScanner,
45
+ isActive: isActive,
46
+ livenessStep: 0, // 0=look straight, 1=left done, 2=right done, 3=ready for capture
47
+ leftTurnVerified: false,
48
+ rightTurnVerified: false,
49
+ currentYaw: 0,
50
+ yawStableCount: 0,
51
+ }), []);
43
52
 
44
- // Update states when props change
45
53
  useEffect(() => {
46
54
  if (!isMounted.current) return;
47
55
 
@@ -51,58 +59,55 @@ export const useFaceDetectionFrameProcessor = ({
51
59
  isActive: isActive
52
60
  };
53
61
 
54
- // Reset capture state when component becomes active again
62
+ // Reset when becoming active again
55
63
  if (isActive && sharedState.value.captured) {
56
64
  sharedState.value = {
57
65
  ...sharedState.value,
58
66
  captured: false,
59
67
  stableCount: 0,
60
- lastX: 0,
61
- lastY: 0,
62
- lastW: 0,
63
- lastH: 0
68
+ livenessStep: 0,
69
+ leftTurnVerified: false,
70
+ rightTurnVerified: false,
71
+ yawStableCount: 0,
64
72
  };
65
73
  }
66
- }, [showCodeScanner, isActive]);
74
+ }, [showCodeScanner, isActive, sharedState]);
67
75
 
68
- // Safe JS callbacks
69
- const runOnStable = useMemo(
70
- () =>
71
- Worklets.createRunOnJS((x, y, width, height) => {
72
- try {
73
- onStableFaceDetected?.({
74
- x: Math.max(0, Math.round(x)),
75
- y: Math.max(0, Math.round(y)),
76
- width: Math.max(0, Math.round(width)),
77
- height: Math.max(0, Math.round(height)),
78
- });
79
- } catch (error) {
80
- if (__DEV__) console.log('runOnStable error:', error);
81
- }
82
- }),
83
- [onStableFaceDetected]
76
+ const runOnStable = useMemo(() =>
77
+ Worklets.createRunOnJS((faceRect) => {
78
+ try {
79
+ onStableFaceDetected?.(faceRect);
80
+ } catch (error) {
81
+ console.error('Error in runOnStable:', error);
82
+ }
83
+ }), [onStableFaceDetected]
84
84
  );
85
85
 
86
- const runOnFaces = useMemo(
87
- () =>
88
- Worklets.createRunOnJS((count, progress) => {
89
- try {
90
- onFacesUpdate?.({ count, progress });
91
- } catch (error) {
92
- if (__DEV__) console.log('runOnFaces error:', error);
93
- }
94
- }),
95
- [onFacesUpdate]
86
+ const runOnFaces = useMemo(() =>
87
+ Worklets.createRunOnJS((count, progress, step) => {
88
+ try {
89
+ onFacesUpdate?.({ count, progress, step });
90
+ } catch (error) {
91
+ console.error('Error in runOnFaces:', error);
92
+ }
93
+ }), [onFacesUpdate]
94
+ );
95
+
96
+ const runOnLiveness = useMemo(() =>
97
+ Worklets.createRunOnJS((step) => {
98
+ try {
99
+ onLivenessUpdate?.(step);
100
+ } catch (error) {
101
+ console.error('Error in runOnLiveness:', error);
102
+ }
103
+ }), [onLivenessUpdate]
96
104
  );
97
105
 
98
- // Frame processor with memory optimization
99
106
  const frameProcessor = useFrameProcessor((frame) => {
100
107
  'worklet';
101
-
102
- // Get current state
103
108
  const state = sharedState.value;
104
-
105
- // Kill switches - release frame immediately if not needed
109
+
110
+ // Early return conditions
106
111
  if (state.showCodeScanner || state.captured || isLoading || !state.isActive) {
107
112
  frame.release?.();
108
113
  return;
@@ -117,7 +122,6 @@ export const useFaceDetectionFrameProcessor = ({
117
122
  let detected;
118
123
  try {
119
124
  detected = detectFaces?.(frame);
120
- // Release frame immediately after face detection to prevent memory leaks
121
125
  frame.release?.();
122
126
  } catch (error) {
123
127
  frame.release?.();
@@ -125,76 +129,146 @@ export const useFaceDetectionFrameProcessor = ({
125
129
  }
126
130
 
127
131
  if (!detected || detected.length === 0) {
128
- // No faces → reset stability but don't reset captured state completely
129
- sharedState.value = {
130
- ...state,
131
- stableCount: 0,
132
- lastProcessedTime: now
132
+ sharedState.value = {
133
+ ...state,
134
+ stableCount: 0,
135
+ lastProcessedTime: now,
136
+ yawStableCount: 0
133
137
  };
134
- runOnFaces(0, 0);
138
+ runOnFaces(0, 0, state.livenessStep);
135
139
  return;
136
140
  }
137
141
 
138
142
  if (detected.length === 1 && !state.captured) {
139
- const f = detected[0];
140
- if (!f?.bounds) {
141
- runOnFaces(0, 0);
143
+ const face = detected[0];
144
+ if (!face?.bounds || face.yawAngle === undefined) {
145
+ runOnFaces(0, 0, state.livenessStep);
142
146
  return;
143
147
  }
144
148
 
145
- // Clamp invalid values
146
- const x = Math.max(0, f.bounds.x ?? 0);
147
- const y = Math.max(0, f.bounds.y ?? 0);
148
- const width = Math.max(0, f.bounds.width ?? 0);
149
- const height = Math.max(0, f.bounds.height ?? 0);
149
+ const yaw = face.yawAngle;
150
+ const x = Math.max(0, face.bounds.x);
151
+ const y = Math.max(0, face.bounds.y);
152
+ const width = Math.max(0, face.bounds.width);
153
+ const height = Math.max(0, face.bounds.height);
154
+
155
+ let newLivenessStep = state.livenessStep;
156
+ let newLeftTurnVerified = state.leftTurnVerified;
157
+ let newRightTurnVerified = state.rightTurnVerified;
158
+ let newYawStableCount = state.yawStableCount;
150
159
 
151
- let newStableCount;
160
+ // Liveness detection logic
161
+ if (newLivenessStep === 0) {
162
+ // Step 0: Wait for face to be centered and stable
163
+ if (Math.abs(yaw) < YAW_CENTER_THRESHOLD) {
164
+ newYawStableCount++;
165
+ if (newYawStableCount >= 2) {
166
+ newLivenessStep = 1; // Ready for left turn
167
+ runOnLiveness(newLivenessStep);
168
+ newYawStableCount = 0;
169
+ }
170
+ } else {
171
+ newYawStableCount = 0;
172
+ }
173
+ }
174
+ else if (newLivenessStep === 1 && !newLeftTurnVerified) {
175
+ // Step 1: Detect left turn
176
+ if (yaw < YAW_LEFT_THRESHOLD) {
177
+ newYawStableCount++;
178
+ if (newYawStableCount >= 2) {
179
+ newLeftTurnVerified = true;
180
+ newLivenessStep = 2; // Ready for right turn
181
+ runOnLiveness(newLivenessStep);
182
+ newYawStableCount = 0;
183
+ }
184
+ } else {
185
+ newYawStableCount = Math.max(0, newYawStableCount - 0.5);
186
+ }
187
+ }
188
+ else if (newLivenessStep === 2 && newLeftTurnVerified && !newRightTurnVerified) {
189
+ // Step 2: Detect right turn
190
+ if (yaw > YAW_RIGHT_THRESHOLD) {
191
+ newYawStableCount++;
192
+ if (newYawStableCount >= 2) {
193
+ newRightTurnVerified = true;
194
+ newLivenessStep = 3; // Ready for capture
195
+ runOnLiveness(newLivenessStep);
196
+ newYawStableCount = 0;
197
+ }
198
+ } else {
199
+ newYawStableCount = Math.max(0, newYawStableCount - 0.5);
200
+ }
201
+ }
202
+ else if (newLivenessStep === 3 && newLeftTurnVerified && newRightTurnVerified) {
203
+ // Step 3: Wait for face to return to center for capture
204
+ if (Math.abs(yaw) < YAW_CENTER_THRESHOLD) {
205
+ newYawStableCount++;
206
+ } else {
207
+ newYawStableCount = 0;
208
+ }
209
+ }
210
+
211
+ // Calculate face stability for capture
212
+ let newStableCount = state.stableCount;
152
213
  if (state.lastX === 0 && state.lastY === 0) {
153
- // First detection
154
214
  newStableCount = 1;
155
215
  } else {
156
216
  const dx = Math.abs(x - state.lastX);
157
217
  const dy = Math.abs(y - state.lastY);
158
-
159
218
  if (dx < FACE_MOVEMENT_THRESHOLD && dy < FACE_MOVEMENT_THRESHOLD) {
160
219
  newStableCount = state.stableCount + 1;
161
220
  } else {
162
- newStableCount = 1; // restart stability
221
+ newStableCount = 1;
163
222
  }
164
223
  }
165
224
 
166
225
  // Update shared state
167
226
  sharedState.value = {
168
227
  ...state,
228
+ lastProcessedTime: now,
169
229
  lastX: x,
170
230
  lastY: y,
171
231
  lastW: width,
172
232
  lastH: height,
173
233
  stableCount: newStableCount,
174
- lastProcessedTime: now
234
+ livenessStep: newLivenessStep,
235
+ leftTurnVerified: newLeftTurnVerified,
236
+ rightTurnVerified: newRightTurnVerified,
237
+ currentYaw: yaw,
238
+ yawStableCount: newYawStableCount,
175
239
  };
176
240
 
177
- if (newStableCount >= FACE_STABILITY_THRESHOLD) {
241
+ // Calculate progress for UI
242
+ const progress = Math.min(100, (newStableCount / FACE_STABILITY_THRESHOLD) * 100);
243
+ runOnFaces(1, progress, newLivenessStep);
244
+
245
+ // Capture conditions: liveness complete + face stable + face centered
246
+ if (newLivenessStep === 3 &&
247
+ newLeftTurnVerified &&
248
+ newRightTurnVerified &&
249
+ newStableCount >= FACE_STABILITY_THRESHOLD &&
250
+ Math.abs(yaw) < YAW_CENTER_THRESHOLD &&
251
+ newYawStableCount >= 2) {
252
+
178
253
  sharedState.value = {
179
254
  ...sharedState.value,
180
255
  captured: true
181
256
  };
182
- runOnStable(x, y, width, height);
183
- } else {
184
- runOnFaces(1, newStableCount);
257
+ runOnStable({ x, y, width, height });
185
258
  }
259
+
186
260
  } else {
187
- // Multiple faces not allowed
188
- sharedState.value = {
189
- ...state,
190
- stableCount: 0,
191
- lastProcessedTime: now
261
+ // Multiple faces or other conditions
262
+ sharedState.value = {
263
+ ...state,
264
+ stableCount: 0,
265
+ lastProcessedTime: now,
266
+ yawStableCount: 0
192
267
  };
193
- runOnFaces(detected.length, 0);
268
+ runOnFaces(detected.length, 0, state.livenessStep);
194
269
  }
195
270
  }, [detectFaces, isLoading]);
196
271
 
197
- // Reset everything safely
198
272
  const resetCaptureState = useCallback(() => {
199
273
  sharedState.value = {
200
274
  ...sharedState.value,
@@ -204,11 +278,15 @@ export const useFaceDetectionFrameProcessor = ({
204
278
  lastW: 0,
205
279
  lastH: 0,
206
280
  stableCount: 0,
207
- captured: false
281
+ captured: false,
282
+ livenessStep: 0,
283
+ leftTurnVerified: false,
284
+ rightTurnVerified: false,
285
+ currentYaw: 0,
286
+ yawStableCount: 0,
208
287
  };
209
288
  }, [sharedState]);
210
289
 
211
- // Force reset capture state (for external calls)
212
290
  const forceResetCaptureState = useCallback(() => {
213
291
  sharedState.value = {
214
292
  lastProcessedTime: 0,
@@ -219,31 +297,34 @@ export const useFaceDetectionFrameProcessor = ({
219
297
  stableCount: 0,
220
298
  captured: false,
221
299
  showCodeScanner: sharedState.value.showCodeScanner,
222
- isActive: sharedState.value.isActive
300
+ isActive: sharedState.value.isActive,
301
+ livenessStep: 0,
302
+ leftTurnVerified: false,
303
+ rightTurnVerified: false,
304
+ currentYaw: 0,
305
+ yawStableCount: 0,
223
306
  };
224
307
  }, [sharedState]);
225
308
 
226
- // Update QR/Scanner toggle
227
309
  const updateShowCodeScanner = useCallback(
228
310
  (value) => {
229
- sharedState.value = {
230
- ...sharedState.value,
231
- showCodeScanner: !!value
311
+ sharedState.value = {
312
+ ...sharedState.value,
313
+ showCodeScanner: !!value
232
314
  };
233
- },
315
+ },
234
316
  [sharedState]
235
317
  );
236
318
 
237
- // Update active state
238
319
  const updateIsActive = useCallback(
239
320
  (active) => {
240
- sharedState.value = {
241
- ...sharedState.value,
321
+ sharedState.value = {
322
+ ...sharedState.value,
242
323
  isActive: active,
243
- // Reset capture state when reactivating
244
- captured: active ? false : sharedState.value.captured
324
+ // Reset capture state when deactivating
325
+ captured: active ? sharedState.value.captured : false
245
326
  };
246
- },
327
+ },
247
328
  [sharedState]
248
329
  );
249
330
 
@@ -253,7 +334,6 @@ export const useFaceDetectionFrameProcessor = ({
253
334
 
254
335
  return () => {
255
336
  isMounted.current = false;
256
- // Reset state when component unmounts
257
337
  forceResetCaptureState();
258
338
  };
259
339
  }, [forceResetCaptureState]);
@@ -261,7 +341,7 @@ export const useFaceDetectionFrameProcessor = ({
261
341
  return {
262
342
  frameProcessor,
263
343
  resetCaptureState,
264
- forceResetCaptureState, // Use this when reopening
344
+ forceResetCaptureState,
265
345
  updateShowCodeScanner,
266
346
  updateIsActive,
267
347
  capturedSV: { value: sharedState.value.captured },