@hexar/biometric-identity-sdk-react-native 1.0.5 → 1.0.7

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":"BiometricIdentityFlow.d.ts","sourceRoot":"","sources":["../../src/components/BiometricIdentityFlow.tsx"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAA2C,MAAM,OAAO,CAAC;AAChE,OAAO,EAOL,SAAS,EACV,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,gBAAgB,EAChB,WAAW,EACX,cAAc,EAId,iBAAiB,EAClB,MAAM,oCAAoC,CAAC;AAU5C,MAAM,WAAW,0BAA0B;IACzC,oBAAoB,EAAE,CAAC,MAAM,EAAE,gBAAgB,KAAK,IAAI,CAAC;IACzD,OAAO,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;IACzC,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5C,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,MAAM,CAAC,EAAE;QACP,SAAS,CAAC,EAAE,SAAS,CAAC;QACtB,OAAO,CAAC,EAAE,SAAS,CAAC;KACrB,CAAC;CACH;AAED,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC,EAAE,CAAC,0BAA0B,CAwStE,CAAC;AAiOF,eAAe,qBAAqB,CAAC"}
1
+ {"version":3,"file":"BiometricIdentityFlow.d.ts","sourceRoot":"","sources":["../../src/components/BiometricIdentityFlow.tsx"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAA2C,MAAM,OAAO,CAAC;AAChE,OAAO,EAOL,SAAS,EACV,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,gBAAgB,EAChB,WAAW,EACX,cAAc,EAId,iBAAiB,EAClB,MAAM,oCAAoC,CAAC;AAU5C,MAAM,WAAW,0BAA0B;IACzC,oBAAoB,EAAE,CAAC,MAAM,EAAE,gBAAgB,KAAK,IAAI,CAAC;IACzD,OAAO,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;IACzC,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5C,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,MAAM,CAAC,EAAE;QACP,SAAS,CAAC,EAAE,SAAS,CAAC;QACtB,OAAO,CAAC,EAAE,SAAS,CAAC;KACrB,CAAC;CACH;AAED,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC,EAAE,CAAC,0BAA0B,CA6StE,CAAC;AAiOF,eAAe,qBAAqB,CAAC"}
@@ -56,11 +56,16 @@ const BiometricIdentityFlow = ({ onValidationComplete, onError, theme, language,
56
56
  const [currentChallenges, setCurrentChallenges] = (0, react_1.useState)([]);
57
57
  const [isLoadingChallenges, setIsLoadingChallenges] = (0, react_1.useState)(false);
58
58
  // Set language early, before any components render
59
+ // Priority: language prop > SDK config language > default 'en'
59
60
  // Run on mount and whenever language prop changes
60
61
  (0, react_1.useEffect)(() => {
61
62
  if (language) {
63
+ // If language prop is provided, override the config
62
64
  (0, biometric_identity_sdk_core_1.setLanguage)(language);
63
65
  }
66
+ // If no language prop, the language should already be set by BiometricIdentitySDK.configure()
67
+ // The global language state is set when configure() is called, so getStrings() will
68
+ // automatically return strings for the configured language (or 'en' as default)
64
69
  }, [language]);
65
70
  const strings = (0, biometric_identity_sdk_core_1.getStrings)();
66
71
  const styles = createStyles(theme);
@@ -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;AAaxE,OAAO,EAAE,WAAW,EAAE,mBAAmB,EAAE,MAAM,oCAAoC,CAAC;AAGtF,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,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;AAgDD,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,kBAAkB,CA2kBtD,CAAC;AA4OF,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;AAaxE,OAAO,EAAE,WAAW,EAAE,mBAAmB,EAAE,MAAM,oCAAoC,CAAC;AAGtF,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,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;AAgDD,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,kBAAkB,CAmpBtD,CAAC;AA4OF,eAAe,aAAa,CAAC"}
@@ -107,12 +107,13 @@ const VideoRecorder = ({ theme, duration, instructions, challenges: propChalleng
107
107
  const pulseAnim = (0, react_1.useRef)(new react_native_1.Animated.Value(1)).current;
108
108
  const arrowAnim = (0, react_1.useRef)(new react_native_1.Animated.Value(0)).current;
109
109
  const progressAnim = (0, react_1.useRef)(new react_native_1.Animated.Value(0)).current;
110
- // Refs
111
110
  const recordingStartTime = (0, react_1.useRef)(0);
112
111
  const frameInterval = (0, react_1.useRef)(null);
113
112
  const frameCaptureInterval = (0, react_1.useRef)(null);
114
- // Calculate total duration from challenges
115
- const totalDuration = duration || challenges.reduce((sum, c) => sum + c.duration_ms, 0) + 2000;
113
+ const videoRecordingRef = (0, react_1.useRef)(null);
114
+ const isRecordingRef = (0, react_1.useRef)(false);
115
+ const minDurationMs = 8000;
116
+ const totalDuration = duration || Math.max(minDurationMs, challenges.reduce((sum, c) => sum + c.duration_ms, 0) + 2000);
116
117
  // Check camera permissions
117
118
  (0, react_1.useEffect)(() => {
118
119
  const checkPermissions = async () => {
@@ -254,124 +255,180 @@ const VideoRecorder = ({ theme, duration, instructions, challenges: propChalleng
254
255
  }),
255
256
  ])).start();
256
257
  }, [arrowAnim]);
257
- // Start recording
258
- const startRecording = (0, react_1.useCallback)(async () => {
259
- setPhase('recording');
260
- recordingStartTime.current = Date.now();
261
- // Start capturing frames from camera
258
+ const resetAndRetry = (0, react_1.useCallback)(() => {
259
+ setFrames([]);
260
+ setCompletedChallenges([]);
261
+ setCurrentChallengeIndex(0);
262
+ setChallengeProgress(0);
263
+ setOverallProgress(0);
264
+ recordingStartTime.current = 0;
265
+ setPhase('countdown');
266
+ setCountdown(3);
267
+ }, []);
268
+ const handleRecordingError = (0, react_1.useCallback)((error) => {
269
+ setPhase('loading');
270
+ react_native_1.Alert.alert('Recording Error', 'Failed to record video. Please try again.', [{ text: 'OK', onPress: onCancel }]);
271
+ }, [onCancel]);
272
+ const handleVideoComplete = (0, react_1.useCallback)(async (video) => {
273
+ const actualDuration = Date.now() - recordingStartTime.current;
274
+ if (actualDuration < minDurationMs) {
275
+ react_native_1.Alert.alert('Recording Too Short', `Video must be at least ${minDurationMs / 1000} seconds. Please try again.`, [{ text: 'OK', onPress: resetAndRetry }]);
276
+ return;
277
+ }
278
+ try {
279
+ const RNFS = require('react-native-fs');
280
+ const videoBase64 = await RNFS.readFile(video.path, 'base64');
281
+ const result = {
282
+ frames: frames.length > 0 ? frames : [videoBase64],
283
+ duration: actualDuration,
284
+ instructionsFollowed: completedChallenges.length === challenges.length,
285
+ qualityScore: frames.length > 0 ? Math.min(100, (frames.length / 30) * 100) : 85,
286
+ challengesCompleted: completedChallenges,
287
+ sessionId,
288
+ };
289
+ onComplete(result);
290
+ }
291
+ catch (error) {
292
+ console.error('Error processing video:', error);
293
+ handleRecordingError(error);
294
+ }
295
+ }, [frames, completedChallenges, challenges, sessionId, onComplete, resetAndRetry, handleRecordingError]);
296
+ const startFrameCapture = (0, react_1.useCallback)(() => {
262
297
  if (cameraRef.current && device) {
263
- // Capture frames periodically (every 100ms = ~10 FPS)
264
298
  frameCaptureInterval.current = setInterval(async () => {
265
299
  try {
266
- // Take a photo frame (react-native-vision-camera doesn't have direct frame access in v4)
267
- // We'll use takeSnapshot or capture frames via photo
268
300
  const photo = await cameraRef.current?.takePhoto({
269
301
  flash: 'off',
270
302
  });
271
303
  if (photo) {
272
- // Convert to base64 if possible, otherwise use file path
273
304
  try {
274
305
  const RNFS = require('react-native-fs');
275
306
  const base64 = await RNFS.readFile(photo.path, 'base64');
276
- setFrames(prev => [...prev, base64]);
307
+ setFrames(prev => {
308
+ if (prev.length < 100) {
309
+ return [...prev, base64];
310
+ }
311
+ return prev;
312
+ });
277
313
  }
278
314
  catch (fsError) {
279
- // Fallback: use file path
280
- setFrames(prev => [...prev, photo.path]);
315
+ setFrames(prev => {
316
+ if (prev.length < 100) {
317
+ return [...prev, photo.path];
318
+ }
319
+ return prev;
320
+ });
281
321
  }
282
322
  }
283
323
  }
284
324
  catch (error) {
285
325
  console.warn('Frame capture error:', error);
286
- // Continue even if one frame fails
287
326
  }
288
327
  }, 100);
289
328
  }
290
- else {
291
- // Fallback: simulate frames if camera not available
292
- frameInterval.current = setInterval(() => {
293
- setFrames(prev => [...prev, `frame_${Date.now()}`]);
294
- }, 100);
295
- }
296
- // Start first challenge
297
- runChallenge(0);
298
329
  }, [device]);
299
- // Run a specific challenge
330
+ const stopRecording = (0, react_1.useCallback)(async () => {
331
+ if (videoRecordingRef.current) {
332
+ try {
333
+ await videoRecordingRef.current.stop();
334
+ }
335
+ catch (error) {
336
+ console.error('Error stopping video recording:', error);
337
+ }
338
+ videoRecordingRef.current = null;
339
+ }
340
+ if (frameCaptureInterval.current) {
341
+ clearInterval(frameCaptureInterval.current);
342
+ frameCaptureInterval.current = null;
343
+ }
344
+ isRecordingRef.current = false;
345
+ }, []);
300
346
  const runChallenge = (0, react_1.useCallback)((index) => {
301
347
  if (index >= challenges.length) {
302
- completeRecording();
348
+ if (isRecordingRef.current) {
349
+ const elapsed = Date.now() - recordingStartTime.current;
350
+ if (elapsed < minDurationMs) {
351
+ setTimeout(() => {
352
+ if (isRecordingRef.current) {
353
+ stopRecording();
354
+ }
355
+ }, minDurationMs - elapsed);
356
+ return;
357
+ }
358
+ stopRecording();
359
+ }
303
360
  return;
304
361
  }
305
362
  const challenge = challenges[index];
306
363
  setCurrentChallengeIndex(index);
307
364
  setChallengeProgress(0);
308
- // Fade in instruction
309
365
  react_native_1.Animated.timing(fadeAnim, {
310
366
  toValue: 1,
311
367
  duration: 300,
312
368
  useNativeDriver: true,
313
369
  }).start();
314
- // Animate arrow for directional challenges
315
370
  if (challenge.action.includes('left') || challenge.action.includes('right')) {
316
371
  animateArrow(challenge.action);
317
372
  }
318
- // Progress animation for this challenge
319
373
  react_native_1.Animated.timing(progressAnim, {
320
374
  toValue: 100,
321
375
  duration: challenge.duration_ms,
322
376
  useNativeDriver: false,
323
377
  }).start();
324
- // Update progress in real-time
325
378
  const progressInterval = setInterval(() => {
326
379
  setChallengeProgress(prev => {
327
380
  const newProgress = prev + (100 / (challenge.duration_ms / 100));
328
381
  return Math.min(100, newProgress);
329
382
  });
330
- // Update overall progress
331
383
  const elapsed = Date.now() - recordingStartTime.current;
332
- const totalTime = challenges.reduce((sum, c) => sum + c.duration_ms, 0);
384
+ const totalTime = Math.max(totalDuration, challenges.reduce((sum, c) => sum + c.duration_ms, 0) + 2000);
333
385
  setOverallProgress(Math.min(100, (elapsed / totalTime) * 100));
334
386
  }, 100);
335
- // Move to next challenge after duration
336
387
  setTimeout(() => {
337
388
  clearInterval(progressInterval);
338
- // Mark challenge as completed
339
389
  setCompletedChallenges(prev => [...prev, challenge.action]);
340
- // Fade out current instruction
341
390
  react_native_1.Animated.timing(fadeAnim, {
342
391
  toValue: 0,
343
392
  duration: 200,
344
393
  useNativeDriver: true,
345
394
  }).start(() => {
346
- // Reset and start next challenge
347
395
  progressAnim.setValue(0);
348
396
  runChallenge(index + 1);
349
397
  });
350
398
  }, challenge.duration_ms);
351
- }, [challenges, fadeAnim, progressAnim, animateArrow]);
352
- // Complete recording
353
- const completeRecording = (0, react_1.useCallback)(() => {
354
- if (frameInterval.current) {
355
- clearInterval(frameInterval.current);
399
+ }, [challenges, fadeAnim, progressAnim, animateArrow, totalDuration, minDurationMs, stopRecording]);
400
+ const startRecording = (0, react_1.useCallback)(async () => {
401
+ setPhase('recording');
402
+ recordingStartTime.current = Date.now();
403
+ isRecordingRef.current = true;
404
+ if (cameraRef.current && device) {
405
+ try {
406
+ videoRecordingRef.current = await cameraRef.current.startRecording({
407
+ flash: 'off',
408
+ onRecordingFinished: (video) => {
409
+ handleVideoComplete(video);
410
+ },
411
+ onRecordingError: (error) => {
412
+ console.error('Recording error:', error);
413
+ handleRecordingError(error);
414
+ },
415
+ });
416
+ }
417
+ catch (error) {
418
+ console.warn('Video recording not available, falling back to frame capture:', error);
419
+ startFrameCapture();
420
+ }
356
421
  }
357
- if (frameCaptureInterval.current) {
358
- clearInterval(frameCaptureInterval.current);
422
+ else {
423
+ startFrameCapture();
359
424
  }
360
- setPhase('processing');
361
- setOverallProgress(100);
362
- // Process and return result
425
+ runChallenge(0);
363
426
  setTimeout(() => {
364
- const result = {
365
- frames,
366
- duration: Date.now() - recordingStartTime.current,
367
- instructionsFollowed: completedChallenges.length === challenges.length,
368
- qualityScore: frames.length > 0 ? 85 + Math.random() * 10 : 0, // Quality based on frames captured
369
- challengesCompleted: completedChallenges,
370
- sessionId,
371
- };
372
- onComplete(result);
373
- }, 500);
374
- }, [frames, completedChallenges, challenges, sessionId, onComplete]);
427
+ if (isRecordingRef.current) {
428
+ stopRecording();
429
+ }
430
+ }, totalDuration);
431
+ }, [device, totalDuration, handleVideoComplete, handleRecordingError, runChallenge, stopRecording, startFrameCapture]);
375
432
  // Current challenge
376
433
  const currentChallenge = challenges[currentChallengeIndex];
377
434
  // Get direction arrow for the current challenge
@@ -421,7 +478,7 @@ const VideoRecorder = ({ theme, duration, instructions, challenges: propChalleng
421
478
  }
422
479
  return (react_1.default.createElement(react_native_1.View, { style: styles.container },
423
480
  react_1.default.createElement(react_native_1.View, { style: styles.cameraContainer },
424
- react_1.default.createElement(react_native_vision_camera_1.Camera, { ref: cameraRef, style: react_native_1.StyleSheet.absoluteFill, device: device, isActive: phase === 'recording' || phase === 'countdown', video: false, photo: true }),
481
+ react_1.default.createElement(react_native_vision_camera_1.Camera, { ref: cameraRef, style: react_native_1.StyleSheet.absoluteFill, device: device, isActive: phase === 'recording' || phase === 'countdown', video: true, audio: false }),
425
482
  react_1.default.createElement(react_native_1.View, { style: styles.overlay },
426
483
  react_1.default.createElement(react_native_1.View, { style: [
427
484
  styles.faceOval,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hexar/biometric-identity-sdk-react-native",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "React Native wrapper for Biometric Identity SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -11,14 +11,13 @@
11
11
  "clean": "rm -rf dist"
12
12
  },
13
13
  "peerDependencies": {
14
+ "@hexar/biometric-identity-sdk-core": ">=1.0.0",
14
15
  "react": ">=18.0.0",
15
16
  "react-native": ">=0.70.0",
16
17
  "react-native-permissions": ">=4.0.0",
17
18
  "react-native-vision-camera": ">=4.0.0"
18
19
  },
19
- "dependencies": {
20
- "@hexar/biometric-identity-sdk-core": "file:../core"
21
- },
20
+ "dependencies": {},
22
21
  "devDependencies": {
23
22
  "@types/react": "^19.0.0",
24
23
  "@types/react-native": "^0.73.0",
@@ -74,11 +74,16 @@ export const BiometricIdentityFlow: React.FC<BiometricIdentityFlowProps> = ({
74
74
  const [isLoadingChallenges, setIsLoadingChallenges] = useState(false);
75
75
 
76
76
  // Set language early, before any components render
77
+ // Priority: language prop > SDK config language > default 'en'
77
78
  // Run on mount and whenever language prop changes
78
79
  useEffect(() => {
79
80
  if (language) {
81
+ // If language prop is provided, override the config
80
82
  setLanguage(language);
81
83
  }
84
+ // If no language prop, the language should already be set by BiometricIdentitySDK.configure()
85
+ // The global language state is set when configure() is called, so getStrings() will
86
+ // automatically return strings for the configured language (or 'en' as default)
82
87
  }, [language]);
83
88
 
84
89
  const strings = getStrings();
@@ -14,7 +14,7 @@ import {
14
14
  Platform,
15
15
  Alert,
16
16
  } from 'react-native';
17
- import { Camera, useCameraDevice, useCameraPermission, useFrameProcessor } from 'react-native-vision-camera';
17
+ import { Camera, useCameraDevice, useCameraPermission } from 'react-native-vision-camera';
18
18
  import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
19
19
  import { ThemeConfig, LivenessInstruction } from '@hexar/biometric-identity-sdk-core';
20
20
 
@@ -136,13 +136,16 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
136
136
  const arrowAnim = useRef(new Animated.Value(0)).current;
137
137
  const progressAnim = useRef(new Animated.Value(0)).current;
138
138
 
139
- // Refs
140
139
  const recordingStartTime = useRef<number>(0);
141
140
  const frameInterval = useRef<NodeJS.Timeout | null>(null);
142
141
  const frameCaptureInterval = useRef<NodeJS.Timeout | null>(null);
143
-
144
- // Calculate total duration from challenges
145
- const totalDuration = duration || challenges.reduce((sum, c) => sum + c.duration_ms, 0) + 2000;
142
+ const videoRecordingRef = useRef<any>(null);
143
+ const isRecordingRef = useRef<boolean>(false);
144
+ const minDurationMs = 8000;
145
+ const totalDuration = duration || Math.max(
146
+ minDurationMs,
147
+ challenges.reduce((sum, c) => sum + c.duration_ms, 0) + 2000
148
+ );
146
149
 
147
150
  // Check camera permissions
148
151
  useEffect(() => {
@@ -298,53 +301,124 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
298
301
  ).start();
299
302
  }, [arrowAnim]);
300
303
 
301
- // Start recording
302
- const startRecording = useCallback(async () => {
303
- setPhase('recording');
304
- recordingStartTime.current = Date.now();
304
+ const resetAndRetry = useCallback(() => {
305
+ setFrames([]);
306
+ setCompletedChallenges([]);
307
+ setCurrentChallengeIndex(0);
308
+ setChallengeProgress(0);
309
+ setOverallProgress(0);
310
+ recordingStartTime.current = 0;
311
+ setPhase('countdown');
312
+ setCountdown(3);
313
+ }, []);
314
+
315
+ const handleRecordingError = useCallback((error: any) => {
316
+ setPhase('loading');
317
+ Alert.alert(
318
+ 'Recording Error',
319
+ 'Failed to record video. Please try again.',
320
+ [{ text: 'OK', onPress: onCancel }]
321
+ );
322
+ }, [onCancel]);
323
+
324
+ const handleVideoComplete = useCallback(async (video: any) => {
325
+ const actualDuration = Date.now() - recordingStartTime.current;
305
326
 
306
- // Start capturing frames from camera
327
+ if (actualDuration < minDurationMs) {
328
+ Alert.alert(
329
+ 'Recording Too Short',
330
+ `Video must be at least ${minDurationMs / 1000} seconds. Please try again.`,
331
+ [{ text: 'OK', onPress: resetAndRetry }]
332
+ );
333
+ return;
334
+ }
335
+
336
+ try {
337
+ const RNFS = require('react-native-fs');
338
+ const videoBase64 = await RNFS.readFile(video.path, 'base64');
339
+
340
+ const result: VideoRecordingResult = {
341
+ frames: frames.length > 0 ? frames : [videoBase64],
342
+ duration: actualDuration,
343
+ instructionsFollowed: completedChallenges.length === challenges.length,
344
+ qualityScore: frames.length > 0 ? Math.min(100, (frames.length / 30) * 100) : 85,
345
+ challengesCompleted: completedChallenges,
346
+ sessionId,
347
+ };
348
+
349
+ onComplete(result);
350
+ } catch (error) {
351
+ console.error('Error processing video:', error);
352
+ handleRecordingError(error);
353
+ }
354
+ }, [frames, completedChallenges, challenges, sessionId, onComplete, resetAndRetry, handleRecordingError]);
355
+
356
+ const startFrameCapture = useCallback(() => {
307
357
  if (cameraRef.current && device) {
308
- // Capture frames periodically (every 100ms = ~10 FPS)
309
358
  frameCaptureInterval.current = setInterval(async () => {
310
359
  try {
311
- // Take a photo frame (react-native-vision-camera doesn't have direct frame access in v4)
312
- // We'll use takeSnapshot or capture frames via photo
313
360
  const photo = await cameraRef.current?.takePhoto({
314
361
  flash: 'off',
315
362
  });
316
363
 
317
364
  if (photo) {
318
- // Convert to base64 if possible, otherwise use file path
319
365
  try {
320
366
  const RNFS = require('react-native-fs');
321
367
  const base64 = await RNFS.readFile(photo.path, 'base64');
322
- setFrames(prev => [...prev, base64]);
368
+ setFrames(prev => {
369
+ if (prev.length < 100) {
370
+ return [...prev, base64];
371
+ }
372
+ return prev;
373
+ });
323
374
  } catch (fsError) {
324
- // Fallback: use file path
325
- setFrames(prev => [...prev, photo.path]);
375
+ setFrames(prev => {
376
+ if (prev.length < 100) {
377
+ return [...prev, photo.path];
378
+ }
379
+ return prev;
380
+ });
326
381
  }
327
382
  }
328
383
  } catch (error) {
329
384
  console.warn('Frame capture error:', error);
330
- // Continue even if one frame fails
331
385
  }
332
386
  }, 100);
333
- } else {
334
- // Fallback: simulate frames if camera not available
335
- frameInterval.current = setInterval(() => {
336
- setFrames(prev => [...prev, `frame_${Date.now()}`]);
337
- }, 100);
338
387
  }
339
-
340
- // Start first challenge
341
- runChallenge(0);
342
388
  }, [device]);
343
389
 
344
- // Run a specific challenge
390
+ const stopRecording = useCallback(async () => {
391
+ if (videoRecordingRef.current) {
392
+ try {
393
+ await videoRecordingRef.current.stop();
394
+ } catch (error) {
395
+ console.error('Error stopping video recording:', error);
396
+ }
397
+ videoRecordingRef.current = null;
398
+ }
399
+
400
+ if (frameCaptureInterval.current) {
401
+ clearInterval(frameCaptureInterval.current);
402
+ frameCaptureInterval.current = null;
403
+ }
404
+
405
+ isRecordingRef.current = false;
406
+ }, []);
407
+
345
408
  const runChallenge = useCallback((index: number) => {
346
409
  if (index >= challenges.length) {
347
- completeRecording();
410
+ if (isRecordingRef.current) {
411
+ const elapsed = Date.now() - recordingStartTime.current;
412
+ if (elapsed < minDurationMs) {
413
+ setTimeout(() => {
414
+ if (isRecordingRef.current) {
415
+ stopRecording();
416
+ }
417
+ }, minDurationMs - elapsed);
418
+ return;
419
+ }
420
+ stopRecording();
421
+ }
348
422
  return;
349
423
  }
350
424
 
@@ -352,84 +426,82 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
352
426
  setCurrentChallengeIndex(index);
353
427
  setChallengeProgress(0);
354
428
 
355
- // Fade in instruction
356
429
  Animated.timing(fadeAnim, {
357
430
  toValue: 1,
358
431
  duration: 300,
359
432
  useNativeDriver: true,
360
433
  }).start();
361
434
 
362
- // Animate arrow for directional challenges
363
435
  if (challenge.action.includes('left') || challenge.action.includes('right')) {
364
436
  animateArrow(challenge.action);
365
437
  }
366
438
 
367
- // Progress animation for this challenge
368
439
  Animated.timing(progressAnim, {
369
440
  toValue: 100,
370
441
  duration: challenge.duration_ms,
371
442
  useNativeDriver: false,
372
443
  }).start();
373
444
 
374
- // Update progress in real-time
375
445
  const progressInterval = setInterval(() => {
376
446
  setChallengeProgress(prev => {
377
447
  const newProgress = prev + (100 / (challenge.duration_ms / 100));
378
448
  return Math.min(100, newProgress);
379
449
  });
380
450
 
381
- // Update overall progress
382
451
  const elapsed = Date.now() - recordingStartTime.current;
383
- const totalTime = challenges.reduce((sum, c) => sum + c.duration_ms, 0);
452
+ const totalTime = Math.max(totalDuration, challenges.reduce((sum, c) => sum + c.duration_ms, 0) + 2000);
384
453
  setOverallProgress(Math.min(100, (elapsed / totalTime) * 100));
385
454
  }, 100);
386
455
 
387
- // Move to next challenge after duration
388
456
  setTimeout(() => {
389
457
  clearInterval(progressInterval);
390
458
 
391
- // Mark challenge as completed
392
459
  setCompletedChallenges(prev => [...prev, challenge.action]);
393
460
 
394
- // Fade out current instruction
395
461
  Animated.timing(fadeAnim, {
396
462
  toValue: 0,
397
463
  duration: 200,
398
464
  useNativeDriver: true,
399
465
  }).start(() => {
400
- // Reset and start next challenge
401
466
  progressAnim.setValue(0);
402
467
  runChallenge(index + 1);
403
468
  });
404
469
  }, challenge.duration_ms);
405
- }, [challenges, fadeAnim, progressAnim, animateArrow]);
470
+ }, [challenges, fadeAnim, progressAnim, animateArrow, totalDuration, minDurationMs, stopRecording]);
406
471
 
407
- // Complete recording
408
- const completeRecording = useCallback(() => {
409
- if (frameInterval.current) {
410
- clearInterval(frameInterval.current);
411
- }
412
- if (frameCaptureInterval.current) {
413
- clearInterval(frameCaptureInterval.current);
472
+ const startRecording = useCallback(async () => {
473
+ setPhase('recording');
474
+ recordingStartTime.current = Date.now();
475
+ isRecordingRef.current = true;
476
+
477
+ if (cameraRef.current && device) {
478
+ try {
479
+ videoRecordingRef.current = await cameraRef.current.startRecording({
480
+ flash: 'off',
481
+ onRecordingFinished: (video: any) => {
482
+ handleVideoComplete(video);
483
+ },
484
+ onRecordingError: (error: any) => {
485
+ console.error('Recording error:', error);
486
+ handleRecordingError(error);
487
+ },
488
+ });
489
+ } catch (error) {
490
+ console.warn('Video recording not available, falling back to frame capture:', error);
491
+ startFrameCapture();
492
+ }
493
+ } else {
494
+ startFrameCapture();
414
495
  }
415
496
 
416
- setPhase('processing');
417
- setOverallProgress(100);
418
-
419
- // Process and return result
497
+ runChallenge(0);
498
+
420
499
  setTimeout(() => {
421
- const result: VideoRecordingResult = {
422
- frames,
423
- duration: Date.now() - recordingStartTime.current,
424
- instructionsFollowed: completedChallenges.length === challenges.length,
425
- qualityScore: frames.length > 0 ? 85 + Math.random() * 10 : 0, // Quality based on frames captured
426
- challengesCompleted: completedChallenges,
427
- sessionId,
428
- };
429
-
430
- onComplete(result);
431
- }, 500);
432
- }, [frames, completedChallenges, challenges, sessionId, onComplete]);
500
+ if (isRecordingRef.current) {
501
+ stopRecording();
502
+ }
503
+ }, totalDuration);
504
+ }, [device, totalDuration, handleVideoComplete, handleRecordingError, runChallenge, stopRecording, startFrameCapture]);
433
505
 
434
506
  // Current challenge
435
507
  const currentChallenge = challenges[currentChallengeIndex];
@@ -532,8 +604,8 @@ export const VideoRecorder: React.FC<VideoRecorderProps> = ({
532
604
  style={StyleSheet.absoluteFill}
533
605
  device={device}
534
606
  isActive={phase === 'recording' || phase === 'countdown'}
535
- video={false}
536
- photo={true}
607
+ video={true}
608
+ audio={false}
537
609
  />
538
610
 
539
611
  {/* Face Oval Overlay */}