@hexar/biometric-identity-sdk-react-native 1.23.0 → 1.24.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.
@@ -1 +1 @@
1
- {"version":3,"file":"VideoRecorder.d.ts","sourceRoot":"","sources":["../../src/components/VideoRecorder.tsx"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAmD,MAAM,OAAO,CAAC;AAexE,OAAO,EAAE,WAAW,EAAE,mBAAmB,EAAE,iBAAiB,EAAmC,MAAM,oCAAoC,CAAC;AAE1I,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,sEAAsE;IACtE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,sCAAsC;IACtC,YAAY,CAAC,EAAE,mBAAmB,EAAE,CAAC;IACrC,0CAA0C;IAC1C,UAAU,CAAC,EAAE,eAAe,EAAE,CAAC;IAC/B,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kDAAkD;IAClD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,wCAAwC;IACxC,UAAU,EAAE,CAAC,SAAS,EAAE,oBAAoB,KAAK,IAAI,CAAC;IACtD,iCAAiC;IACjC,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,gDAAgD;IAChD,iBAAiB,CAAC,EAAE,MAAM,OAAO,CAAC,eAAe,EAAE,CAAC,CAAC;CACtD;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,OAAO,CAAC;IAC9B,YAAY,EAAE,MAAM,CAAC;IACrB,mBAAmB,EAAE,MAAM,EAAE,CAAC;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA2CD,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,kBAAkB,CAqiCtD,CAAC;AA4PF,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"VideoRecorder.d.ts","sourceRoot":"","sources":["../../src/components/VideoRecorder.tsx"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAmD,MAAM,OAAO,CAAC;AAexE,OAAO,EAAE,WAAW,EAAE,mBAAmB,EAAE,iBAAiB,EAAmC,MAAM,oCAAoC,CAAC;AAE1I,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,sEAAsE;IACtE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,sCAAsC;IACtC,YAAY,CAAC,EAAE,mBAAmB,EAAE,CAAC;IACrC,0CAA0C;IAC1C,UAAU,CAAC,EAAE,eAAe,EAAE,CAAC;IAC/B,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kDAAkD;IAClD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,wCAAwC;IACxC,UAAU,EAAE,CAAC,SAAS,EAAE,oBAAoB,KAAK,IAAI,CAAC;IACtD,iCAAiC;IACjC,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,gDAAgD;IAChD,iBAAiB,CAAC,EAAE,MAAM,OAAO,CAAC,eAAe,EAAE,CAAC,CAAC;CACtD;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,OAAO,CAAC;IAC9B,YAAY,EAAE,MAAM,CAAC;IACrB,mBAAmB,EAAE,MAAM,EAAE,CAAC;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AA2CD,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,kBAAkB,CA2lCtD,CAAC;AA4PF,eAAe,aAAa,CAAC"}
@@ -129,9 +129,19 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
129
129
  const videoRecordingRef = (0, react_1.useRef)(null);
130
130
  const isRecordingRef = (0, react_1.useRef)(false);
131
131
  const recordingTimeoutRef = (0, react_1.useRef)(null);
132
+ // Tracks whether a takePhoto() call is currently dispatched to the native
133
+ // bridge. We must wait for it to resolve before unmounting the <Camera>
134
+ // view, otherwise CameraViewModule.findCameraView crashes on Android.
135
+ const captureInFlightRef = (0, react_1.useRef)(false);
136
+ const isMountedRef = (0, react_1.useRef)(true);
132
137
  // Trigger autofocus on center — some Android devices (e.g. Ulefone Armor)
133
138
  // don't auto-focus without an explicit programmatic trigger.
134
139
  const triggerFocus = (0, react_1.useCallback)(() => {
140
+ // Guard: focus() dispatches to native findCameraView which crashes
141
+ // if the view is gone. The JS try/catch does NOT help — the exception
142
+ // happens on the Android UI thread Looper, not in the JS promise chain.
143
+ if (!isMountedRef.current)
144
+ return;
135
145
  try {
136
146
  const { width: w, height: h } = react_native_1.Dimensions.get('window');
137
147
  cameraRef.current?.focus({ x: w / 2, y: h / 2 });
@@ -140,6 +150,30 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
140
150
  // focus() not supported or camera not ready — safe to ignore
141
151
  }
142
152
  }, []);
153
+ // ── Unmount cleanup ──────────────────────────────────────────────────
154
+ // Prevents scenario 2: unexpected unmount (back button, navigation,
155
+ // phone call) while timers are still scheduled. Without this, pending
156
+ // setTimeout callbacks fire after unmount, dispatch takePhoto() to the
157
+ // native bridge, and crash with IllegalViewOperationException.
158
+ (0, react_1.useEffect)(() => {
159
+ isMountedRef.current = true;
160
+ return () => {
161
+ isMountedRef.current = false;
162
+ isRecordingRef.current = false;
163
+ if (frameCaptureInterval.current) {
164
+ clearTimeout(frameCaptureInterval.current);
165
+ frameCaptureInterval.current = null;
166
+ }
167
+ if (recordingTimeoutRef.current) {
168
+ clearTimeout(recordingTimeoutRef.current);
169
+ recordingTimeoutRef.current = null;
170
+ }
171
+ if (frameInterval.current) {
172
+ clearTimeout(frameInterval.current);
173
+ frameInterval.current = null;
174
+ }
175
+ };
176
+ }, []);
143
177
  const minDurationMs = 8000;
144
178
  const totalDuration = duration || Math.max(minDurationMs, challenges.reduce((sum, c) => sum + c.duration_ms, 0) + 2000);
145
179
  (0, react_1.useEffect)(() => {
@@ -323,6 +357,30 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
323
357
  setPhase('loading');
324
358
  react_native_1.Alert.alert('Recording Error', 'Failed to record video. Please try again.', [{ text: 'OK', onPress: onCancel }]);
325
359
  }, [onCancel]);
360
+ // Wait for any in-flight takePhoto() to finish, then deactivate the camera
361
+ // and deliver the result. This prevents the IllegalViewOperationException
362
+ // crash: the native view stays alive while the native bridge processes the
363
+ // last queued takePhoto(), and only THEN do we trigger the parent unmount
364
+ // via onComplete.
365
+ const safeComplete = (0, react_1.useCallback)((result) => {
366
+ const drain = () => {
367
+ if (captureInFlightRef.current) {
368
+ // A takePhoto() is still on the native bridge — check again shortly
369
+ setTimeout(drain, 30);
370
+ return;
371
+ }
372
+ // No in-flight capture — safe to tear down
373
+ setIsCameraActive(false);
374
+ // Small delay to let VisionCamera process the isActive=false prop change
375
+ // and release the native camera session before the parent unmounts the view.
376
+ setTimeout(() => {
377
+ if (isMountedRef.current) {
378
+ onComplete(result);
379
+ }
380
+ }, 100);
381
+ };
382
+ drain();
383
+ }, [onComplete]);
326
384
  // Safety timeout: if the camera session never fires onInitialized within
327
385
  // 10 seconds, treat it as a hardware failure and send the user back.
328
386
  (0, react_1.useEffect)(() => {
@@ -389,12 +447,7 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
389
447
  };
390
448
  biometric_identity_sdk_core_1.logger.info('Video recording completed:', result.frames.length, 'frames,', (actualDuration / 1000).toFixed(1) + 's', 'challenges:', currentCompletedChallenges.length);
391
449
  isRecordingRef.current = false;
392
- // Deactivate the camera first so VisionCamera tears down its native
393
- // session. Any in-flight takePhoto() calls will then fail gracefully
394
- // rather than crashing with IllegalViewOperationException when the
395
- // parent unmounts the <Camera> view underneath them.
396
- setIsCameraActive(false);
397
- setTimeout(() => onComplete(result), 250);
450
+ safeComplete(result);
398
451
  }
399
452
  catch (error) {
400
453
  biometric_identity_sdk_core_1.logger.error('Error processing video:', error);
@@ -402,7 +455,7 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
402
455
  setOverallProgress(0);
403
456
  handleRecordingError(error);
404
457
  }
405
- }, [frames, completedChallenges, challenges, sessionId, onComplete, resetAndRetry, handleRecordingError, strings, minDurationMs, phase]);
458
+ }, [frames, completedChallenges, challenges, sessionId, safeComplete, resetAndRetry, handleRecordingError, strings, minDurationMs, phase]);
406
459
  const startFrameCapture = (0, react_1.useCallback)(() => {
407
460
  if (cameraRef.current && device) {
408
461
  biometric_identity_sdk_core_1.logger.info('Starting serial frame capture');
@@ -450,9 +503,13 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
450
503
  // finishes, preventing overlapping takePhoto() calls that cause the
451
504
  // camera to throw "busy" errors and kill the capture loop.
452
505
  const captureNextFrame = async () => {
453
- if (!isRecordingRef.current || framesRef.current.length >= MAX_FRAMES) {
506
+ // Bail if recording stopped, component unmounted, or we have enough frames.
507
+ // Checking isMountedRef prevents dispatching takePhoto() to a native view
508
+ // that is about to be removed from NativeViewHierarchyManager.
509
+ if (!isRecordingRef.current || !isMountedRef.current || framesRef.current.length >= MAX_FRAMES) {
454
510
  return;
455
511
  }
512
+ captureInFlightRef.current = true;
456
513
  try {
457
514
  const photo = await cameraRef.current?.takePhoto({
458
515
  flash: 'off',
@@ -495,12 +552,14 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
495
552
  biometric_identity_sdk_core_1.logger.warn('Frame capture error', consecutiveErrors, '/', maxConsecutiveErrors, error?.message);
496
553
  if (consecutiveErrors >= maxConsecutiveErrors) {
497
554
  biometric_identity_sdk_core_1.logger.error('Too many consecutive capture errors, stopping');
555
+ captureInFlightRef.current = false;
498
556
  return;
499
557
  }
500
558
  }
559
+ captureInFlightRef.current = false;
501
560
  // Schedule next capture only after this one completes (serial chain).
502
561
  // Small delay lets the camera hardware reset between captures.
503
- if (isRecordingRef.current && framesRef.current.length < MAX_FRAMES) {
562
+ if (isRecordingRef.current && isMountedRef.current && framesRef.current.length < MAX_FRAMES) {
504
563
  frameCaptureInterval.current = setTimeout(captureNextFrame, 66);
505
564
  }
506
565
  };
@@ -545,8 +604,7 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
545
604
  sessionId,
546
605
  };
547
606
  setPhase('processing');
548
- setIsCameraActive(false);
549
- setTimeout(() => onComplete(result), 250);
607
+ safeComplete(result);
550
608
  }
551
609
  else {
552
610
  biometric_identity_sdk_core_1.logger.info('Stopping recording with frames (fallback):', currentFrames.length);
@@ -559,8 +617,7 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
559
617
  sessionId,
560
618
  };
561
619
  setPhase('processing');
562
- setIsCameraActive(false);
563
- setTimeout(() => onComplete(result), 250);
620
+ safeComplete(result);
564
621
  }
565
622
  }
566
623
  }
@@ -591,12 +648,9 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
591
648
  challengesCompleted: currentCompletedChallenges,
592
649
  sessionId,
593
650
  };
594
- setIsCameraActive(false);
595
- setTimeout(() => {
596
- onComplete(result);
597
- }, 250);
651
+ safeComplete(result);
598
652
  }
599
- }, [frames, completedChallenges, challenges, sessionId, onComplete, minDurationMs, resetAndRetry, strings]);
653
+ }, [frames, completedChallenges, challenges, sessionId, onComplete, safeComplete, minDurationMs, resetAndRetry, strings]);
600
654
  const runChallenge = (0, react_1.useCallback)((index) => {
601
655
  const currentCompletedChallenges = completedChallengesRef.current.length > 0 ? completedChallengesRef.current : completedChallenges;
602
656
  biometric_identity_sdk_core_1.logger.info('VideoRecorder: runChallenge called', {
@@ -638,8 +692,7 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
638
692
  sessionId,
639
693
  };
640
694
  setPhase('processing');
641
- setIsCameraActive(false);
642
- setTimeout(() => onComplete(result), 250);
695
+ safeComplete(result);
643
696
  }
644
697
  }
645
698
  return;
@@ -691,7 +744,7 @@ const VideoRecorder = ({ theme, language, duration, instructions, challenges: pr
691
744
  runChallenge(index + 1);
692
745
  });
693
746
  }, challenge.duration_ms);
694
- }, [challenges, fadeAnim, progressAnim, animateArrow, totalDuration, minDurationMs, stopRecording]);
747
+ }, [challenges, fadeAnim, progressAnim, animateArrow, totalDuration, minDurationMs, stopRecording, safeComplete]);
695
748
  const startRecording = (0, react_1.useCallback)(async () => {
696
749
  setPhase('recording');
697
750
  recordingStartTime.current = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hexar/biometric-identity-sdk-react-native",
3
- "version": "1.23.0",
3
+ "version": "1.24.0",
4
4
  "description": "React Native wrapper for Biometric Identity SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -160,9 +160,18 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
160
160
  const videoRecordingRef = useRef<any>(null);
161
161
  const isRecordingRef = useRef<boolean>(false);
162
162
  const recordingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
163
+ // Tracks whether a takePhoto() call is currently dispatched to the native
164
+ // bridge. We must wait for it to resolve before unmounting the <Camera>
165
+ // view, otherwise CameraViewModule.findCameraView crashes on Android.
166
+ const captureInFlightRef = useRef<boolean>(false);
167
+ const isMountedRef = useRef<boolean>(true);
163
168
  // Trigger autofocus on center — some Android devices (e.g. Ulefone Armor)
164
169
  // don't auto-focus without an explicit programmatic trigger.
165
170
  const triggerFocus = useCallback(() => {
171
+ // Guard: focus() dispatches to native findCameraView which crashes
172
+ // if the view is gone. The JS try/catch does NOT help — the exception
173
+ // happens on the Android UI thread Looper, not in the JS promise chain.
174
+ if (!isMountedRef.current) return;
166
175
  try {
167
176
  const { width: w, height: h } = Dimensions.get('window');
168
177
  cameraRef.current?.focus({ x: w / 2, y: h / 2 });
@@ -171,6 +180,31 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
171
180
  }
172
181
  }, []);
173
182
 
183
+ // ── Unmount cleanup ──────────────────────────────────────────────────
184
+ // Prevents scenario 2: unexpected unmount (back button, navigation,
185
+ // phone call) while timers are still scheduled. Without this, pending
186
+ // setTimeout callbacks fire after unmount, dispatch takePhoto() to the
187
+ // native bridge, and crash with IllegalViewOperationException.
188
+ useEffect(() => {
189
+ isMountedRef.current = true;
190
+ return () => {
191
+ isMountedRef.current = false;
192
+ isRecordingRef.current = false;
193
+ if (frameCaptureInterval.current) {
194
+ clearTimeout(frameCaptureInterval.current);
195
+ frameCaptureInterval.current = null;
196
+ }
197
+ if (recordingTimeoutRef.current) {
198
+ clearTimeout(recordingTimeoutRef.current);
199
+ recordingTimeoutRef.current = null;
200
+ }
201
+ if (frameInterval.current) {
202
+ clearTimeout(frameInterval.current);
203
+ frameInterval.current = null;
204
+ }
205
+ };
206
+ }, []);
207
+
174
208
  const minDurationMs = 8000;
175
209
  const totalDuration = duration || Math.max(
176
210
  minDurationMs,
@@ -378,6 +412,31 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
378
412
  );
379
413
  }, [onCancel]);
380
414
 
415
+ // Wait for any in-flight takePhoto() to finish, then deactivate the camera
416
+ // and deliver the result. This prevents the IllegalViewOperationException
417
+ // crash: the native view stays alive while the native bridge processes the
418
+ // last queued takePhoto(), and only THEN do we trigger the parent unmount
419
+ // via onComplete.
420
+ const safeComplete = useCallback((result: VideoRecordingResult) => {
421
+ const drain = () => {
422
+ if (captureInFlightRef.current) {
423
+ // A takePhoto() is still on the native bridge — check again shortly
424
+ setTimeout(drain, 30);
425
+ return;
426
+ }
427
+ // No in-flight capture — safe to tear down
428
+ setIsCameraActive(false);
429
+ // Small delay to let VisionCamera process the isActive=false prop change
430
+ // and release the native camera session before the parent unmounts the view.
431
+ setTimeout(() => {
432
+ if (isMountedRef.current) {
433
+ onComplete(result);
434
+ }
435
+ }, 100);
436
+ };
437
+ drain();
438
+ }, [onComplete]);
439
+
381
440
  // Safety timeout: if the camera session never fires onInitialized within
382
441
  // 10 seconds, treat it as a hardware failure and send the user back.
383
442
  useEffect(() => {
@@ -459,19 +518,14 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
459
518
  logger.info('Video recording completed:', result.frames.length, 'frames,', (actualDuration / 1000).toFixed(1) + 's', 'challenges:', currentCompletedChallenges.length);
460
519
 
461
520
  isRecordingRef.current = false;
462
- // Deactivate the camera first so VisionCamera tears down its native
463
- // session. Any in-flight takePhoto() calls will then fail gracefully
464
- // rather than crashing with IllegalViewOperationException when the
465
- // parent unmounts the <Camera> view underneath them.
466
- setIsCameraActive(false);
467
- setTimeout(() => onComplete(result), 250);
521
+ safeComplete(result);
468
522
  } catch (error) {
469
523
  logger.error('Error processing video:', error);
470
524
  setPhase('recording');
471
525
  setOverallProgress(0);
472
526
  handleRecordingError(error);
473
527
  }
474
- }, [frames, completedChallenges, challenges, sessionId, onComplete, resetAndRetry, handleRecordingError, strings, minDurationMs, phase]);
528
+ }, [frames, completedChallenges, challenges, sessionId, safeComplete, resetAndRetry, handleRecordingError, strings, minDurationMs, phase]);
475
529
 
476
530
  const startFrameCapture = useCallback(() => {
477
531
  if (cameraRef.current && device) {
@@ -531,10 +585,14 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
531
585
  // finishes, preventing overlapping takePhoto() calls that cause the
532
586
  // camera to throw "busy" errors and kill the capture loop.
533
587
  const captureNextFrame = async () => {
534
- if (!isRecordingRef.current || framesRef.current.length >= MAX_FRAMES) {
588
+ // Bail if recording stopped, component unmounted, or we have enough frames.
589
+ // Checking isMountedRef prevents dispatching takePhoto() to a native view
590
+ // that is about to be removed from NativeViewHierarchyManager.
591
+ if (!isRecordingRef.current || !isMountedRef.current || framesRef.current.length >= MAX_FRAMES) {
535
592
  return;
536
593
  }
537
594
 
595
+ captureInFlightRef.current = true;
538
596
  try {
539
597
  const photo = await cameraRef.current?.takePhoto({
540
598
  flash: 'off',
@@ -574,13 +632,15 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
574
632
  logger.warn('Frame capture error', consecutiveErrors, '/', maxConsecutiveErrors, error?.message);
575
633
  if (consecutiveErrors >= maxConsecutiveErrors) {
576
634
  logger.error('Too many consecutive capture errors, stopping');
635
+ captureInFlightRef.current = false;
577
636
  return;
578
637
  }
579
638
  }
639
+ captureInFlightRef.current = false;
580
640
 
581
641
  // Schedule next capture only after this one completes (serial chain).
582
642
  // Small delay lets the camera hardware reset between captures.
583
- if (isRecordingRef.current && framesRef.current.length < MAX_FRAMES) {
643
+ if (isRecordingRef.current && isMountedRef.current && framesRef.current.length < MAX_FRAMES) {
584
644
  frameCaptureInterval.current = setTimeout(captureNextFrame, 66);
585
645
  }
586
646
  };
@@ -631,8 +691,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
631
691
  sessionId,
632
692
  };
633
693
  setPhase('processing');
634
- setIsCameraActive(false);
635
- setTimeout(() => onComplete(result), 250);
694
+ safeComplete(result);
636
695
  } else {
637
696
  logger.info('Stopping recording with frames (fallback):', currentFrames.length);
638
697
  const result: VideoRecordingResult = {
@@ -644,8 +703,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
644
703
  sessionId,
645
704
  };
646
705
  setPhase('processing');
647
- setIsCameraActive(false);
648
- setTimeout(() => onComplete(result), 250);
706
+ safeComplete(result);
649
707
  }
650
708
  }
651
709
  } else {
@@ -683,12 +741,9 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
683
741
  sessionId,
684
742
  };
685
743
 
686
- setIsCameraActive(false);
687
- setTimeout(() => {
688
- onComplete(result);
689
- }, 250);
744
+ safeComplete(result);
690
745
  }
691
- }, [frames, completedChallenges, challenges, sessionId, onComplete, minDurationMs, resetAndRetry, strings]);
746
+ }, [frames, completedChallenges, challenges, sessionId, onComplete, safeComplete, minDurationMs, resetAndRetry, strings]);
692
747
 
693
748
  const runChallenge = useCallback((index: number) => {
694
749
  const currentCompletedChallenges = completedChallengesRef.current.length > 0 ? completedChallengesRef.current : completedChallenges;
@@ -735,8 +790,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
735
790
  sessionId,
736
791
  };
737
792
  setPhase('processing');
738
- setIsCameraActive(false);
739
- setTimeout(() => onComplete(result), 250);
793
+ safeComplete(result);
740
794
  }
741
795
  }
742
796
  return;
@@ -797,7 +851,7 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
797
851
  runChallenge(index + 1);
798
852
  });
799
853
  }, challenge.duration_ms);
800
- }, [challenges, fadeAnim, progressAnim, animateArrow, totalDuration, minDurationMs, stopRecording]);
854
+ }, [challenges, fadeAnim, progressAnim, animateArrow, totalDuration, minDurationMs, stopRecording, safeComplete]);
801
855
 
802
856
  const startRecording = useCallback(async () => {
803
857
  setPhase('recording');