@rick427/react-native-liveness 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +20 -0
  2. package/LivenessCamera.podspec +26 -0
  3. package/README.md +167 -0
  4. package/android/build.gradle +80 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/com/livenesscamera/LivenessCameraPackage.kt +28 -0
  7. package/android/src/main/java/com/livenesscamera/LivenessCameraPlugin.kt +63 -0
  8. package/ios/LivenessCameraPlugin-Bridging-Header.h +3 -0
  9. package/ios/LivenessCameraPlugin.m +8 -0
  10. package/ios/LivenessCameraPlugin.swift +69 -0
  11. package/lib/module/LivenessCamera.js +283 -0
  12. package/lib/module/LivenessCamera.js.map +1 -0
  13. package/lib/module/LivenessDetector.js +23 -0
  14. package/lib/module/LivenessDetector.js.map +1 -0
  15. package/lib/module/index.js +5 -0
  16. package/lib/module/index.js.map +1 -0
  17. package/lib/module/livenessScoring.js +58 -0
  18. package/lib/module/livenessScoring.js.map +1 -0
  19. package/lib/module/package.json +1 -0
  20. package/lib/module/types.js +4 -0
  21. package/lib/module/types.js.map +1 -0
  22. package/lib/module/useLivenessCamera.js +167 -0
  23. package/lib/module/useLivenessCamera.js.map +1 -0
  24. package/lib/typescript/package.json +1 -0
  25. package/lib/typescript/src/LivenessCamera.d.ts +3 -0
  26. package/lib/typescript/src/LivenessCamera.d.ts.map +1 -0
  27. package/lib/typescript/src/LivenessDetector.d.ts +8 -0
  28. package/lib/typescript/src/LivenessDetector.d.ts.map +1 -0
  29. package/lib/typescript/src/index.d.ts +4 -0
  30. package/lib/typescript/src/index.d.ts.map +1 -0
  31. package/lib/typescript/src/livenessScoring.d.ts +11 -0
  32. package/lib/typescript/src/livenessScoring.d.ts.map +1 -0
  33. package/lib/typescript/src/types.d.ts +61 -0
  34. package/lib/typescript/src/types.d.ts.map +1 -0
  35. package/lib/typescript/src/useLivenessCamera.d.ts +21 -0
  36. package/lib/typescript/src/useLivenessCamera.d.ts.map +1 -0
  37. package/package.json +120 -0
  38. package/src/LivenessCamera.tsx +284 -0
  39. package/src/LivenessDetector.ts +34 -0
  40. package/src/index.ts +9 -0
  41. package/src/livenessScoring.ts +81 -0
  42. package/src/types.ts +88 -0
  43. package/src/useLivenessCamera.ts +206 -0
@@ -0,0 +1,61 @@
1
+ import type { ViewStyle } from 'react-native';
2
+ import type { PhotoFile } from 'react-native-vision-camera';
3
+ export type FaceData = {
4
+ detected: boolean;
5
+ bounds: {
6
+ x: number;
7
+ y: number;
8
+ width: number;
9
+ height: number;
10
+ };
11
+ yawAngle: number;
12
+ pitchAngle: number;
13
+ rollAngle: number;
14
+ leftEyeOpenProbability: number;
15
+ rightEyeOpenProbability: number;
16
+ smilingProbability: number;
17
+ };
18
+ export type LivenessState = 'idle' | 'scanning' | 'confirmed' | 'countdown' | 'capturing' | 'done' | 'error';
19
+ export type FeedbackMessage = 'Position your face in the oval' | 'Move closer' | 'Move farther away' | 'Look straight ahead' | 'Hold still...' | 'Stay still' | 'Open your eyes' | 'Liveness confirmed' | '';
20
+ export type CaptureResult = {
21
+ photo: PhotoFile;
22
+ livenessScore: number;
23
+ timestamp: number;
24
+ };
25
+ export type LivenessCameraProps = {
26
+ /**
27
+ * Called when liveness is confirmed and photo is captured.
28
+ */
29
+ onCapture: (result: CaptureResult) => void;
30
+ /**
31
+ * Called the moment liveness is confirmed, before the countdown starts.
32
+ */
33
+ onLivenessConfirmed?: () => void;
34
+ /**
35
+ * Called on any unrecoverable error.
36
+ */
37
+ onError?: (error: Error) => void;
38
+ /**
39
+ * Countdown start value. Defaults to 3.
40
+ */
41
+ countdownFrom?: number;
42
+ /**
43
+ * Score (0–1) required to confirm liveness. Defaults to 0.75.
44
+ */
45
+ livenessThreshold?: number;
46
+ /**
47
+ * Number of consecutive high-score frames required before liveness is
48
+ * confirmed. Defaults to 15 (~500ms at 30fps).
49
+ */
50
+ confirmFrames?: number;
51
+ /**
52
+ * Style applied to the root container.
53
+ */
54
+ style?: ViewStyle;
55
+ /**
56
+ * Whether to play a shutter sound on capture. Requires react-native-sound
57
+ * to be installed. Defaults to true.
58
+ */
59
+ soundEnabled?: boolean;
60
+ };
61
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AAE5D,MAAM,MAAM,QAAQ,GAAG;IACrB,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE;QACN,CAAC,EAAE,MAAM,CAAC;QACV,CAAC,EAAE,MAAM,CAAC;QACV,KAAK,EAAE,MAAM,CAAC;QACd,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;IACF,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,sBAAsB,EAAE,MAAM,CAAC;IAC/B,uBAAuB,EAAE,MAAM,CAAC;IAChC,kBAAkB,EAAE,MAAM,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,aAAa,GACrB,MAAM,GACN,UAAU,GACV,WAAW,GACX,WAAW,GACX,WAAW,GACX,MAAM,GACN,OAAO,CAAC;AAEZ,MAAM,MAAM,eAAe,GACvB,gCAAgC,GAChC,aAAa,GACb,mBAAmB,GACnB,qBAAqB,GACrB,eAAe,GACf,YAAY,GACZ,gBAAgB,GAChB,oBAAoB,GACpB,EAAE,CAAC;AAEP,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,SAAS,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC;;OAEG;IACH,SAAS,EAAE,CAAC,MAAM,EAAE,aAAa,KAAK,IAAI,CAAC;IAE3C;;OAEG;IACH,mBAAmB,CAAC,EAAE,MAAM,IAAI,CAAC;IAEjC;;OAEG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IAEjC;;OAEG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB;;OAEG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAE3B;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB;;OAEG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;IAElB;;;OAGG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB,CAAC"}
@@ -0,0 +1,21 @@
1
+ import type { Camera } from 'react-native-vision-camera';
2
+ import type { CaptureResult, FeedbackMessage, LivenessState } from './types';
3
+ type Options = {
4
+ livenessThreshold: number;
5
+ confirmFrames: number;
6
+ countdownFrom: number;
7
+ soundEnabled: boolean;
8
+ cameraRef: React.RefObject<Camera | null>;
9
+ onCapture: (result: CaptureResult) => void;
10
+ onLivenessConfirmed?: () => void;
11
+ onError?: (error: Error) => void;
12
+ };
13
+ export declare function useLivenessCamera(options: Options): {
14
+ frameProcessor: import("react-native-vision-camera").ReadonlyFrameProcessor;
15
+ livenessState: LivenessState;
16
+ livenessScore: number;
17
+ countdown: number | null;
18
+ feedback: FeedbackMessage;
19
+ };
20
+ export {};
21
+ //# sourceMappingURL=useLivenessCamera.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useLivenessCamera.d.ts","sourceRoot":"","sources":["../../../src/useLivenessCamera.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAS,MAAM,4BAA4B,CAAC;AAKhE,OAAO,KAAK,EACV,aAAa,EAEb,eAAe,EACf,aAAa,EACd,MAAM,SAAS,CAAC;AAIjB,KAAK,OAAO,GAAG;IACb,iBAAiB,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,OAAO,CAAC;IACtB,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC1C,SAAS,EAAE,CAAC,MAAM,EAAE,aAAa,KAAK,IAAI,CAAC;IAC3C,mBAAmB,CAAC,EAAE,MAAM,IAAI,CAAC;IACjC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;CAClC,CAAC;AASF,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO;;;;;;EA4KjD"}
package/package.json ADDED
@@ -0,0 +1,120 @@
1
+ {
2
+ "name": "@rick427/react-native-liveness",
3
+ "version": "0.1.0",
4
+ "description": "Liveness detection library for React Native using Vision Camera v4 and ML Kit",
5
+ "main": "./lib/module/index.js",
6
+ "types": "./lib/typescript/src/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "source": "./src/index.ts",
10
+ "types": "./lib/typescript/src/index.d.ts",
11
+ "default": "./lib/module/index.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "lib",
18
+ "android",
19
+ "ios",
20
+ "*.podspec",
21
+ "react-native.config.js",
22
+ "!ios/build",
23
+ "!android/build",
24
+ "!android/gradle",
25
+ "!android/gradlew",
26
+ "!android/gradlew.bat",
27
+ "!android/local.properties",
28
+ "!**/__tests__",
29
+ "!**/__fixtures__",
30
+ "!**/__mocks__",
31
+ "!**/.*"
32
+ ],
33
+ "scripts": {
34
+ "example": "yarn workspace react-native-liveness-example",
35
+ "clean": "del-cli lib",
36
+ "prepare": "bob build",
37
+ "typecheck": "tsc",
38
+ "lint": "eslint \"**/*.{js,ts,tsx}\""
39
+ },
40
+ "keywords": [
41
+ "react-native",
42
+ "ios",
43
+ "android",
44
+ "liveness",
45
+ "face-detection",
46
+ "vision-camera",
47
+ "mlkit",
48
+ "biometrics"
49
+ ],
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/rick427/react-native-liveness.git"
53
+ },
54
+ "author": "Richard <rchardnjoku@yahoo.com>",
55
+ "license": "MIT",
56
+ "bugs": {
57
+ "url": "https://github.com/rick427/react-native-liveness/issues"
58
+ },
59
+ "homepage": "https://github.com/rick427/react-native-liveness#readme",
60
+ "publishConfig": {
61
+ "registry": "https://registry.npmjs.org/"
62
+ },
63
+ "devDependencies": {
64
+ "@eslint/compat": "^2.0.3",
65
+ "@eslint/eslintrc": "^3.3.5",
66
+ "@eslint/js": "^10.0.1",
67
+ "@react-native/babel-preset": "0.85.0",
68
+ "@react-native/eslint-config": "0.85.0",
69
+ "@types/react": "^19.2.0",
70
+ "del-cli": "^7.0.0",
71
+ "eslint": "^9.39.4",
72
+ "eslint-config-prettier": "^10.1.8",
73
+ "eslint-plugin-prettier": "^5.5.5",
74
+ "prettier": "^3.8.1",
75
+ "react": "19.2.0",
76
+ "react-native": "0.83.6",
77
+ "react-native-builder-bob": "^0.41.0",
78
+ "react-native-svg": "^15.0.0",
79
+ "react-native-vision-camera": "^4.0.0",
80
+ "react-native-worklets-core": "^1.3.3",
81
+ "turbo": "^2.8.21",
82
+ "typescript": "^6.0.2"
83
+ },
84
+ "peerDependencies": {
85
+ "react": "*",
86
+ "react-native": "*",
87
+ "react-native-svg": ">=13.0.0",
88
+ "react-native-vision-camera": ">=4.0.0",
89
+ "react-native-worklets-core": ">=1.0.0"
90
+ },
91
+ "workspaces": [
92
+ "example"
93
+ ],
94
+ "packageManager": "yarn@4.11.0",
95
+ "react-native-builder-bob": {
96
+ "source": "src",
97
+ "output": "lib",
98
+ "targets": [
99
+ [
100
+ "module",
101
+ {
102
+ "esm": true
103
+ }
104
+ ],
105
+ [
106
+ "typescript",
107
+ {
108
+ "project": "tsconfig.build.json"
109
+ }
110
+ ]
111
+ ]
112
+ },
113
+ "prettier": {
114
+ "quoteProps": "consistent",
115
+ "singleQuote": true,
116
+ "tabWidth": 2,
117
+ "trailingComma": "es5",
118
+ "useTabs": false
119
+ }
120
+ }
@@ -0,0 +1,284 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { Animated, StyleSheet, Text, View } from 'react-native';
3
+ import {
4
+ Camera,
5
+ useCameraDevice,
6
+ useCameraPermission,
7
+ } from 'react-native-vision-camera';
8
+ import { Circle, Ellipse, Path, Svg } from 'react-native-svg';
9
+ import { useLivenessCamera } from './useLivenessCamera';
10
+ import type { LivenessCameraProps, LivenessState } from './types';
11
+
12
+ const OVAL_H_RATIO = 0.55;
13
+ const OVAL_V_RATIO = 0.72;
14
+ const STROKE_WIDTH = 3;
15
+ // Cubic bezier approximation constant for a smooth ellipse
16
+ const K = 0.5523;
17
+
18
+ function getOvalColor(state: LivenessState): string {
19
+ switch (state) {
20
+ case 'confirmed':
21
+ case 'countdown':
22
+ case 'capturing':
23
+ case 'done':
24
+ return '#4CAF50';
25
+ default:
26
+ return '#FFFFFF';
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Returns an SVG path string tracing an ellipse (cx, cy, rx, ry) using
32
+ * cubic bezier curves. Used inside a compound path with fillRule="evenodd"
33
+ * to punch a transparent hole through the dark scrim.
34
+ */
35
+ function ellipsePath(cx: number, cy: number, rx: number, ry: number): string {
36
+ return [
37
+ `M ${cx + rx} ${cy}`,
38
+ `C ${cx + rx} ${cy - ry * K} ${cx + rx * K} ${cy - ry} ${cx} ${cy - ry}`,
39
+ `C ${cx - rx * K} ${cy - ry} ${cx - rx} ${cy - ry * K} ${cx - rx} ${cy}`,
40
+ `C ${cx - rx} ${cy + ry * K} ${cx - rx * K} ${cy + ry} ${cx} ${cy + ry}`,
41
+ `C ${cx + rx * K} ${cy + ry} ${cx + rx} ${cy + ry * K} ${cx + rx} ${cy}`,
42
+ 'Z',
43
+ ].join(' ');
44
+ }
45
+
46
+ function OvalOverlay({
47
+ width,
48
+ height,
49
+ state,
50
+ }: {
51
+ width: number;
52
+ height: number;
53
+ state: LivenessState;
54
+ }) {
55
+ if (width === 0 || height === 0) return null;
56
+
57
+ const cx = width / 2;
58
+ const cy = height / 2;
59
+ const rx = (width * OVAL_H_RATIO) / 2;
60
+ const ry = (height * OVAL_V_RATIO) / 2;
61
+ const color = getOvalColor(state);
62
+
63
+ // Compound path: outer rect + oval. evenodd fill rule makes the oval transparent.
64
+ const scrimD = `M0 0H${width}V${height}H0Z ${ellipsePath(cx, cy, rx, ry)}`;
65
+
66
+ const showDot =
67
+ state === 'confirmed' || state === 'countdown' || state === 'capturing';
68
+
69
+ return (
70
+ <Svg style={StyleSheet.absoluteFill} width={width} height={height}>
71
+ <Path d={scrimD} fill="rgba(0,0,0,0.55)" fillRule="evenodd" />
72
+ <Ellipse
73
+ cx={cx}
74
+ cy={cy}
75
+ rx={rx}
76
+ ry={ry}
77
+ fill="none"
78
+ stroke={color}
79
+ strokeWidth={STROKE_WIDTH}
80
+ />
81
+ {showDot && <Circle cx={cx} cy={cy - ry - 8} r={5} fill={color} />}
82
+ </Svg>
83
+ );
84
+ }
85
+
86
+ function CountdownBubble({ value }: { value: number }) {
87
+ // key={countdown} in the parent remounts this component on every tick,
88
+ // so [] deps are correct — each mount runs a fresh animation.
89
+ const scale = useRef(new Animated.Value(0)).current;
90
+ const opacity = useRef(new Animated.Value(0)).current;
91
+
92
+ useEffect(() => {
93
+ Animated.parallel([
94
+ Animated.sequence([
95
+ Animated.spring(scale, {
96
+ toValue: 1.2,
97
+ stiffness: 200,
98
+ damping: 6,
99
+ useNativeDriver: true,
100
+ }),
101
+ Animated.spring(scale, {
102
+ toValue: 1.0,
103
+ stiffness: 150,
104
+ damping: 8,
105
+ useNativeDriver: true,
106
+ }),
107
+ ]),
108
+ Animated.timing(opacity, {
109
+ toValue: 1,
110
+ duration: 150,
111
+ useNativeDriver: true,
112
+ }),
113
+ ]).start();
114
+
115
+ return () => {
116
+ Animated.timing(opacity, {
117
+ toValue: 0,
118
+ duration: 200,
119
+ useNativeDriver: true,
120
+ }).start();
121
+ };
122
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
123
+
124
+ return (
125
+ <Animated.View
126
+ style={[styles.countdownBubble, { opacity, transform: [{ scale }] }]}
127
+ >
128
+ <Text style={styles.countdownText}>{value}</Text>
129
+ </Animated.View>
130
+ );
131
+ }
132
+
133
+ export function LivenessCamera({
134
+ onCapture,
135
+ onLivenessConfirmed,
136
+ onError,
137
+ countdownFrom = 3,
138
+ livenessThreshold = 0.75,
139
+ confirmFrames = 15,
140
+ soundEnabled = true,
141
+ style,
142
+ }: LivenessCameraProps) {
143
+ const { hasPermission, requestPermission } = useCameraPermission();
144
+ const device = useCameraDevice('front');
145
+ const cameraRef = useRef<Camera>(null);
146
+ const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
147
+
148
+ const { frameProcessor, livenessState, countdown, feedback } =
149
+ useLivenessCamera({
150
+ livenessThreshold,
151
+ confirmFrames,
152
+ countdownFrom,
153
+ soundEnabled,
154
+ cameraRef,
155
+ onCapture,
156
+ onLivenessConfirmed,
157
+ onError,
158
+ });
159
+
160
+ const handleLayout = useCallback(
161
+ (e: { nativeEvent: { layout: { width: number; height: number } } }) => {
162
+ const { width, height } = e.nativeEvent.layout;
163
+ setContainerSize({ width, height });
164
+ },
165
+ []
166
+ );
167
+
168
+ useEffect(() => {
169
+ if (!hasPermission) {
170
+ requestPermission().catch(() => {
171
+ onError?.(new Error('Camera permission denied'));
172
+ });
173
+ }
174
+ }, [hasPermission, requestPermission, onError]);
175
+
176
+ if (!hasPermission) {
177
+ return (
178
+ <View style={[styles.root, style, styles.centered]}>
179
+ <Text style={styles.permissionText}>Camera permission required</Text>
180
+ </View>
181
+ );
182
+ }
183
+
184
+ if (!device) {
185
+ return (
186
+ <View style={[styles.root, style, styles.centered]}>
187
+ <Text style={styles.permissionText}>No front camera found</Text>
188
+ </View>
189
+ );
190
+ }
191
+
192
+ return (
193
+ <View style={[styles.root, style]} onLayout={handleLayout}>
194
+ <Camera
195
+ ref={cameraRef}
196
+ style={StyleSheet.absoluteFill}
197
+ device={device}
198
+ isActive={livenessState !== 'done' && livenessState !== 'error'}
199
+ frameProcessor={frameProcessor}
200
+ photo
201
+ pixelFormat="yuv"
202
+ />
203
+ <OvalOverlay
204
+ width={containerSize.width}
205
+ height={containerSize.height}
206
+ state={livenessState}
207
+ />
208
+ {livenessState !== 'done' && (
209
+ <View style={styles.feedbackContainer}>
210
+ <Text style={styles.feedbackText}>{feedback}</Text>
211
+ </View>
212
+ )}
213
+ {livenessState === 'countdown' && countdown !== null && (
214
+ <View style={styles.countdownContainer}>
215
+ <CountdownBubble key={countdown} value={countdown} />
216
+ </View>
217
+ )}
218
+ {livenessState === 'capturing' && (
219
+ <View style={styles.captureFlash} pointerEvents="none" />
220
+ )}
221
+ </View>
222
+ );
223
+ }
224
+
225
+ const styles = StyleSheet.create({
226
+ root: {
227
+ flex: 1,
228
+ backgroundColor: '#000',
229
+ overflow: 'hidden',
230
+ },
231
+ centered: {
232
+ justifyContent: 'center',
233
+ alignItems: 'center',
234
+ },
235
+ permissionText: {
236
+ color: '#fff',
237
+ fontSize: 16,
238
+ textAlign: 'center',
239
+ paddingHorizontal: 24,
240
+ },
241
+ feedbackContainer: {
242
+ position: 'absolute',
243
+ bottom: '14%',
244
+ left: 0,
245
+ right: 0,
246
+ alignItems: 'center',
247
+ paddingHorizontal: 16,
248
+ },
249
+ feedbackText: {
250
+ color: '#fff',
251
+ fontSize: 16,
252
+ fontWeight: '600',
253
+ textAlign: 'center',
254
+ textShadowColor: 'rgba(0,0,0,0.8)',
255
+ textShadowOffset: { width: 0, height: 1 },
256
+ textShadowRadius: 4,
257
+ },
258
+ countdownContainer: {
259
+ ...StyleSheet.absoluteFillObject,
260
+ justifyContent: 'center',
261
+ alignItems: 'center',
262
+ },
263
+ countdownBubble: {
264
+ width: 96,
265
+ height: 96,
266
+ borderRadius: 48,
267
+ backgroundColor: 'rgba(255,255,255,0.15)',
268
+ borderWidth: 2,
269
+ borderColor: '#fff',
270
+ justifyContent: 'center',
271
+ alignItems: 'center',
272
+ },
273
+ countdownText: {
274
+ color: '#fff',
275
+ fontSize: 52,
276
+ fontWeight: '700',
277
+ lineHeight: 60,
278
+ },
279
+ captureFlash: {
280
+ ...StyleSheet.absoluteFillObject,
281
+ backgroundColor: '#fff',
282
+ opacity: 0.4,
283
+ },
284
+ });
@@ -0,0 +1,34 @@
1
+ import { useMemo } from 'react';
2
+ import { VisionCameraProxy, type Frame } from 'react-native-vision-camera';
3
+ import type { FaceData } from './types';
4
+
5
+ type LivenessPlugin = {
6
+ detectLiveness: (frame: Frame) => FaceData | null;
7
+ };
8
+
9
+ function createPlugin(): LivenessPlugin {
10
+ const plugin = VisionCameraProxy.initFrameProcessorPlugin(
11
+ 'detectLiveness',
12
+ {}
13
+ );
14
+
15
+ if (!plugin) {
16
+ throw new Error(
17
+ '[LivenessCamera] Frame Processor Plugin "detectLiveness" not found. ' +
18
+ 'Make sure the native module is linked correctly.'
19
+ );
20
+ }
21
+
22
+ return {
23
+ detectLiveness: (frame: Frame): FaceData | null => {
24
+ 'worklet';
25
+ const result = plugin.call(frame);
26
+ // plugin.call returns a loosely-typed BasicParameterType — cast via unknown
27
+ return result as unknown as FaceData | null;
28
+ },
29
+ };
30
+ }
31
+
32
+ export function useLivenessPlugin(): LivenessPlugin {
33
+ return useMemo(() => createPlugin(), []);
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export { LivenessCamera } from './LivenessCamera';
2
+ export { useLivenessCamera } from './useLivenessCamera';
3
+ export type {
4
+ CaptureResult,
5
+ FaceData,
6
+ FeedbackMessage,
7
+ LivenessCameraProps,
8
+ LivenessState,
9
+ } from './types';
@@ -0,0 +1,81 @@
1
+ import type { FaceData, FeedbackMessage } from './types';
2
+
3
+ const WEIGHTS = {
4
+ faceDetected: 0.2,
5
+ faceSize: 0.2,
6
+ headPose: 0.3,
7
+ eyesOpen: 0.3,
8
+ } as const;
9
+
10
+ const FACE_SIZE_MIN = 0.2;
11
+ const FACE_SIZE_MAX = 0.65;
12
+ const MAX_YAW_DEG = 20;
13
+ const MAX_PITCH_DEG = 20;
14
+
15
+ export type FrameScore = {
16
+ total: number;
17
+ faceSize: number;
18
+ headPose: number;
19
+ eyesOpen: number;
20
+ };
21
+
22
+ export function scoreFrame(face: FaceData, frameWidth: number): FrameScore {
23
+ if (!face.detected || frameWidth === 0) {
24
+ return { total: 0, faceSize: 0, headPose: 0, eyesOpen: 0 };
25
+ }
26
+
27
+ const faceWidthRatio = face.bounds.width / frameWidth;
28
+ const faceSize =
29
+ faceWidthRatio >= FACE_SIZE_MIN && faceWidthRatio <= FACE_SIZE_MAX
30
+ ? 1.0
31
+ : 0.0;
32
+
33
+ const yawOK = Math.abs(face.yawAngle) < MAX_YAW_DEG;
34
+ const pitchOK = Math.abs(face.pitchAngle) < MAX_PITCH_DEG;
35
+ const headPose = (yawOK ? 0.5 : 0) + (pitchOK ? 0.5 : 0);
36
+
37
+ // ML Kit returns -1 when classification is disabled or unavailable
38
+ const leftEye =
39
+ face.leftEyeOpenProbability >= 0 ? face.leftEyeOpenProbability : 0.5;
40
+ const rightEye =
41
+ face.rightEyeOpenProbability >= 0 ? face.rightEyeOpenProbability : 0.5;
42
+ const eyesOpen = (leftEye + rightEye) / 2;
43
+
44
+ const total =
45
+ WEIGHTS.faceDetected * 1.0 +
46
+ WEIGHTS.faceSize * faceSize +
47
+ WEIGHTS.headPose * headPose +
48
+ WEIGHTS.eyesOpen * eyesOpen;
49
+
50
+ return { total, faceSize, headPose, eyesOpen };
51
+ }
52
+
53
+ export function rollingAverage(scores: number[]): number {
54
+ if (scores.length === 0) return 0;
55
+ return scores.reduce((a, b) => a + b, 0) / scores.length;
56
+ }
57
+
58
+ export function getFeedback(
59
+ face: FaceData,
60
+ frameWidth: number,
61
+ livenessConfirmed: boolean
62
+ ): FeedbackMessage {
63
+ if (livenessConfirmed) return 'Liveness confirmed';
64
+ if (!face.detected) return 'Position your face in the oval';
65
+
66
+ const faceWidthRatio = face.bounds.width / frameWidth;
67
+ if (faceWidthRatio < FACE_SIZE_MIN) return 'Move closer';
68
+ if (faceWidthRatio > FACE_SIZE_MAX) return 'Move farther away';
69
+
70
+ const yawBad = Math.abs(face.yawAngle) >= MAX_YAW_DEG;
71
+ const pitchBad = Math.abs(face.pitchAngle) >= MAX_PITCH_DEG;
72
+ if (yawBad || pitchBad) return 'Look straight ahead';
73
+
74
+ const leftEye =
75
+ face.leftEyeOpenProbability >= 0 ? face.leftEyeOpenProbability : 1;
76
+ const rightEye =
77
+ face.rightEyeOpenProbability >= 0 ? face.rightEyeOpenProbability : 1;
78
+ if (leftEye < 0.4 || rightEye < 0.4) return 'Open your eyes';
79
+
80
+ return 'Hold still...';
81
+ }