@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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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');
|