@smileid/web-components 2.0.1 → 10.0.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.
- package/dist/DocumentCaptureScreens-Dwl7UqVH.js +1534 -0
- package/dist/DocumentCaptureScreens-Dwl7UqVH.js.map +1 -0
- package/dist/EndUserConsent-C5hZdJzH.js +715 -0
- package/dist/EndUserConsent-C5hZdJzH.js.map +1 -0
- package/dist/Navigation-juBE4qOw.js +136 -0
- package/dist/Navigation-juBE4qOw.js.map +1 -0
- package/dist/PoweredBySmileId-CxbaihMu.js +33 -0
- package/dist/PoweredBySmileId-CxbaihMu.js.map +1 -0
- package/dist/SelfieCaptureScreens-CQc251hz.js +7618 -0
- package/dist/SelfieCaptureScreens-CQc251hz.js.map +1 -0
- package/dist/SignaturePad-C7MtmT8m.js +324 -0
- package/dist/SignaturePad-C7MtmT8m.js.map +1 -0
- package/dist/TotpConsent-CQU5jQi4.js +730 -0
- package/dist/TotpConsent-CQU5jQi4.js.map +1 -0
- package/dist/combobox.js +300 -0
- package/dist/combobox.js.map +1 -0
- package/dist/document.js +5 -0
- package/dist/document.js.map +1 -0
- package/dist/end-user-consent.js +5 -0
- package/dist/end-user-consent.js.map +1 -0
- package/dist/main.js +22 -0
- package/dist/main.js.map +1 -0
- package/dist/navigation.js +5 -0
- package/dist/navigation.js.map +1 -0
- package/dist/package-Oi2Yil3b.js +105 -0
- package/dist/package-Oi2Yil3b.js.map +1 -0
- package/dist/selfie.js +5 -0
- package/dist/selfie.js.map +1 -0
- package/dist/signature-pad.js +5 -0
- package/dist/signature-pad.js.map +1 -0
- package/dist/smart-camera-web.js +303 -0
- package/dist/smart-camera-web.js.map +1 -0
- package/dist/styles-BUWNxWeQ.js +406 -0
- package/dist/styles-BUWNxWeQ.js.map +1 -0
- package/dist/totp-consent.js +5 -0
- package/dist/totp-consent.js.map +1 -0
- package/dist/types/combobox.d.ts +21 -0
- package/dist/types/document.d.ts +21 -0
- package/dist/types/end-user-consent.d.ts +21 -0
- package/dist/types/main.d.ts +331 -0
- package/dist/types/navigation.d.ts +21 -0
- package/dist/types/selfie.d.ts +21 -0
- package/dist/types/signature-pad.d.ts +21 -0
- package/dist/types/smart-camera-web.d.ts +21 -0
- package/dist/types/totp-consent.d.ts +21 -0
- package/{src → lib}/components/README.md +14 -14
- package/{src → lib}/components/attribution/PoweredBySmileId.js +42 -42
- package/{src → lib}/components/camera-permission/CameraPermission.js +140 -140
- package/{src → lib}/components/camera-permission/CameraPermission.stories.js +27 -27
- package/{src → lib}/components/combobox/src/Combobox.js +589 -589
- package/{src → lib}/components/combobox/src/index.js +1 -1
- package/{src → lib}/components/document/src/DocumentCaptureScreens.js +409 -409
- package/{src → lib}/components/document/src/DocumentCaptureScreens.stories.js +57 -57
- package/{src → lib}/components/document/src/README.md +111 -111
- package/{src → lib}/components/document/src/document-capture/DocumentCapture.js +760 -760
- package/{src → lib}/components/document/src/document-capture/DocumentCapture.stories.js +78 -78
- package/{src → lib}/components/document/src/document-capture/README.md +90 -90
- package/{src → lib}/components/document/src/document-capture/index.js +3 -3
- package/{src → lib}/components/document/src/document-capture-instructions/DocumentCaptureInstructions.js +499 -499
- package/{src → lib}/components/document/src/document-capture-instructions/DocumentCaptureInstructions.stories.js +24 -24
- package/{src → lib}/components/document/src/document-capture-instructions/README.md +56 -56
- package/{src → lib}/components/document/src/document-capture-instructions/index.js +3 -3
- package/{src → lib}/components/document/src/document-capture-review/DocumentCaptureReview.js +362 -362
- package/{src → lib}/components/document/src/document-capture-review/DocumentCaptureReview.stories.js +24 -24
- package/{src → lib}/components/document/src/document-capture-review/README.md +79 -79
- package/{src → lib}/components/document/src/document-capture-review/index.js +3 -3
- package/{src → lib}/components/document/src/index.js +3 -3
- package/{src → lib}/components/end-user-consent/src/EndUserConsent.js +795 -795
- package/{src → lib}/components/end-user-consent/src/EndUserConsent.stories.js +29 -29
- package/{src → lib}/components/end-user-consent/src/index.js +4 -4
- package/{src → lib}/components/navigation/src/Navigation.js +171 -171
- package/{src → lib}/components/navigation/src/Navigation.stories.js +24 -24
- package/{src → lib}/components/navigation/src/index.js +3 -3
- package/{src → lib}/components/selfie/README.md +225 -225
- package/{src → lib}/components/selfie/src/SelfieCaptureScreens.js +433 -282
- package/{src → lib}/components/selfie/src/SelfieCaptureScreens.stories.js +29 -29
- package/{src → lib}/components/selfie/src/index.js +3 -5
- package/{src → lib}/components/selfie/src/selfie-capture/SelfieCapture.js +1041 -1010
- package/{src → lib}/components/selfie/src/selfie-capture/SelfieCapture.stories.js +36 -36
- package/{src → lib}/components/selfie/src/selfie-capture/index.js +3 -3
- package/{src → lib}/components/selfie/src/selfie-capture-instructions/SelfieCaptureInstructions.js +657 -648
- package/{src → lib}/components/selfie/src/selfie-capture-instructions/SelfieCaptureInstructions.stories.js +23 -23
- package/{src → lib}/components/selfie/src/selfie-capture-instructions/index.js +3 -3
- package/{src → lib}/components/selfie/src/selfie-capture-review/SelfieCaptureReview.js +340 -347
- package/{src → lib}/components/selfie/src/selfie-capture-review/SelfieCaptureReview.stories.js +24 -24
- package/{src → lib}/components/selfie/src/selfie-capture-review/index.js +3 -3
- package/lib/components/selfie/src/selfie-capture-wrapper/SelfieCaptureWrapper.tsx +227 -0
- package/lib/components/selfie/src/selfie-capture-wrapper/index.ts +1 -0
- package/lib/components/selfie/src/smartselfie-capture/OvalProgress.tsx +81 -0
- package/lib/components/selfie/src/smartselfie-capture/SmartSelfieCapture.tsx +224 -0
- package/lib/components/selfie/src/smartselfie-capture/components/AlertDisplay.tsx +34 -0
- package/lib/components/selfie/src/smartselfie-capture/components/CameraPreview.tsx +97 -0
- package/lib/components/selfie/src/smartselfie-capture/components/CaptureControls.tsx +74 -0
- package/lib/components/selfie/src/smartselfie-capture/components/index.ts +3 -0
- package/lib/components/selfie/src/smartselfie-capture/constants.ts +23 -0
- package/lib/components/selfie/src/smartselfie-capture/hooks/index.ts +2 -0
- package/lib/components/selfie/src/smartselfie-capture/hooks/useCamera.ts +94 -0
- package/lib/components/selfie/src/smartselfie-capture/hooks/useFaceCapture.ts +558 -0
- package/lib/components/selfie/src/smartselfie-capture/index.ts +1 -0
- package/lib/components/selfie/src/smartselfie-capture/utils/alertMessages.ts +12 -0
- package/lib/components/selfie/src/smartselfie-capture/utils/canvas.ts +105 -0
- package/lib/components/selfie/src/smartselfie-capture/utils/faceDetection.ts +129 -0
- package/lib/components/selfie/src/smartselfie-capture/utils/imageCapture.ts +64 -0
- package/lib/components/selfie/src/smartselfie-capture/utils/index.ts +4 -0
- package/lib/components/selfie/src/smartselfie-capture/utils/mediapipeManager.ts +60 -0
- package/{src → lib}/components/signature-pad/package-lock.json +3009 -3009
- package/{src → lib}/components/signature-pad/package.json +30 -30
- package/{src → lib}/components/signature-pad/src/SignaturePad.js +484 -484
- package/{src → lib}/components/signature-pad/src/SignaturePad.stories.js +32 -32
- package/{src → lib}/components/signature-pad/src/index.js +3 -3
- package/{src → lib}/components/smart-camera-web/src/README.md +206 -207
- package/{src → lib}/components/smart-camera-web/src/SmartCameraWeb.js +299 -299
- package/{src → lib}/components/smart-camera-web/src/SmartCameraWeb.stories.js +57 -57
- package/{src → lib}/components/totp-consent/src/TotpConsent.js +949 -949
- package/{src → lib}/components/totp-consent/src/index.js +4 -4
- package/{src → lib}/domain/camera/src/README.md +38 -38
- package/{src → lib}/domain/camera/src/SmartCamera.js +109 -109
- package/{src → lib}/domain/constants/src/Constants.js +27 -27
- package/{src → lib}/domain/file-upload/README.md +35 -35
- package/{src → lib}/domain/file-upload/src/SmartFileUpload.js +65 -65
- package/{src → lib}/styles/README.md +3 -3
- package/{src → lib}/styles/src/styles.js +359 -359
- package/{src → lib}/styles/src/typography.js +52 -52
- package/package.json +109 -58
- package/src/index.js +0 -5
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import { useRef } from 'preact/hooks';
|
|
2
|
+
import { useSignal, useComputed } from '@preact/signals';
|
|
3
|
+
import { FaceLandmarker } from '@mediapipe/tasks-vision';
|
|
4
|
+
import {
|
|
5
|
+
calculateFaceSize,
|
|
6
|
+
isFaceInBounds,
|
|
7
|
+
calculateMouthOpening,
|
|
8
|
+
} from '../utils/faceDetection';
|
|
9
|
+
import {
|
|
10
|
+
createCroppedVideoFrame,
|
|
11
|
+
drawFaceMesh,
|
|
12
|
+
clearCanvas,
|
|
13
|
+
} from '../utils/canvas';
|
|
14
|
+
import { captureImageFromVideo } from '../utils/imageCapture';
|
|
15
|
+
import { ImageType } from '../constants';
|
|
16
|
+
import { MESSAGES, type MessageKey } from '../utils/alertMessages';
|
|
17
|
+
import { getMediapipeInstance } from '../utils/mediapipeManager';
|
|
18
|
+
import packageJson from '../../../../../../package.json';
|
|
19
|
+
|
|
20
|
+
const COMPONENTS_VERSION = packageJson.version;
|
|
21
|
+
|
|
22
|
+
interface UseFaceCaptureProps {
|
|
23
|
+
videoRef: React.RefObject<HTMLVideoElement>;
|
|
24
|
+
canvasRef: React.RefObject<HTMLCanvasElement>;
|
|
25
|
+
interval: number;
|
|
26
|
+
duration: number;
|
|
27
|
+
smileThreshold: number;
|
|
28
|
+
mouthOpenThreshold: number;
|
|
29
|
+
minFaceSize: number;
|
|
30
|
+
maxFaceSize: number;
|
|
31
|
+
smileCooldown: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const useFaceCapture = ({
|
|
35
|
+
videoRef,
|
|
36
|
+
canvasRef,
|
|
37
|
+
interval,
|
|
38
|
+
duration,
|
|
39
|
+
smileThreshold,
|
|
40
|
+
mouthOpenThreshold,
|
|
41
|
+
minFaceSize,
|
|
42
|
+
maxFaceSize,
|
|
43
|
+
smileCooldown,
|
|
44
|
+
}: UseFaceCaptureProps) => {
|
|
45
|
+
const faceLandmarkerRef = useRef<FaceLandmarker | null>(null);
|
|
46
|
+
const animationFrameRef = useRef<number | null>(null);
|
|
47
|
+
const captureTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
48
|
+
const resumeCaptureRef = useRef<(() => void) | null>(null);
|
|
49
|
+
|
|
50
|
+
const faceDetected = useSignal(false);
|
|
51
|
+
const faceInBounds = useSignal(false);
|
|
52
|
+
const faceProximity = useSignal<'too-close' | 'too-far' | 'good'>('good');
|
|
53
|
+
const multipleFaces = useSignal(false);
|
|
54
|
+
const videoAspectRatio = useSignal(16 / 9);
|
|
55
|
+
const faceLandmarks = useSignal<any[]>([]);
|
|
56
|
+
const currentSmileScore = useSignal(0);
|
|
57
|
+
const currentFaceSize = useSignal(0);
|
|
58
|
+
const currentMouthOpen = useSignal(0);
|
|
59
|
+
const lastSmileTime = useSignal(0);
|
|
60
|
+
const alertTitle = useSignal('');
|
|
61
|
+
|
|
62
|
+
const isCapturing = useSignal(false);
|
|
63
|
+
const isPaused = useSignal(false);
|
|
64
|
+
const countdown = useSignal(0);
|
|
65
|
+
const capturedImages = useSignal<string[]>([]);
|
|
66
|
+
const referencePhoto = useSignal<string | null>(null);
|
|
67
|
+
const totalCaptures = useSignal(1);
|
|
68
|
+
const capturesTaken = useSignal(0);
|
|
69
|
+
const hasFinishedCapture = useSignal(false);
|
|
70
|
+
|
|
71
|
+
const smileCheckpoint = useComputed(() =>
|
|
72
|
+
Math.floor(totalCaptures.value * 0.4),
|
|
73
|
+
);
|
|
74
|
+
const neutralZone = useComputed(() => Math.floor(totalCaptures.value * 0.2));
|
|
75
|
+
|
|
76
|
+
const isReadyToCapture = useComputed(
|
|
77
|
+
() =>
|
|
78
|
+
faceDetected.value &&
|
|
79
|
+
faceInBounds.value &&
|
|
80
|
+
faceProximity.value === 'good' &&
|
|
81
|
+
!multipleFaces.value,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const initializeFaceLandmarker = async () => {
|
|
85
|
+
try {
|
|
86
|
+
faceLandmarkerRef.current = await getMediapipeInstance();
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error('Failed to initialize MediaPipe:', error);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const updateAlert = (messageKey: MessageKey | null) => {
|
|
93
|
+
if (messageKey && MESSAGES[messageKey]) {
|
|
94
|
+
alertTitle.value = MESSAGES[messageKey];
|
|
95
|
+
} else {
|
|
96
|
+
alertTitle.value = '';
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const setupCanvas = () => {
|
|
101
|
+
if (videoRef.current && canvasRef.current) {
|
|
102
|
+
const { videoWidth, videoHeight } = videoRef.current;
|
|
103
|
+
|
|
104
|
+
videoAspectRatio.value = videoWidth / videoHeight;
|
|
105
|
+
|
|
106
|
+
canvasRef.current.width = videoWidth;
|
|
107
|
+
canvasRef.current.height = videoHeight;
|
|
108
|
+
|
|
109
|
+
const container = videoRef.current.parentElement;
|
|
110
|
+
if (container) {
|
|
111
|
+
canvasRef.current.style.left = '50%';
|
|
112
|
+
canvasRef.current.style.top = '50%';
|
|
113
|
+
canvasRef.current.style.transform = 'translate(-50%, -50%) scaleX(-1)';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const updateCaptureAlerts = () => {
|
|
119
|
+
const isInNeutralZone = capturesTaken.value < neutralZone.value;
|
|
120
|
+
const isInSmileZone = capturesTaken.value >= smileCheckpoint.value;
|
|
121
|
+
|
|
122
|
+
if (isInNeutralZone && currentSmileScore.value >= smileThreshold) {
|
|
123
|
+
updateAlert('neutral-expression');
|
|
124
|
+
} else if (isInNeutralZone) {
|
|
125
|
+
alertTitle.value = 'Capturing...';
|
|
126
|
+
} else if (isInSmileZone) {
|
|
127
|
+
const timeSinceSmile = Date.now() - lastSmileTime.value;
|
|
128
|
+
if (timeSinceSmile > smileCooldown) {
|
|
129
|
+
if (
|
|
130
|
+
currentSmileScore.value >= smileThreshold &&
|
|
131
|
+
currentMouthOpen.value < mouthOpenThreshold
|
|
132
|
+
) {
|
|
133
|
+
updateAlert('open-mouth-smile');
|
|
134
|
+
} else {
|
|
135
|
+
updateAlert('smile-required');
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
alertTitle.value = 'Keep smiling!';
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
updateAlert(null);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const updateAlerts = () => {
|
|
146
|
+
if (multipleFaces.value) {
|
|
147
|
+
updateAlert('multiple-faces');
|
|
148
|
+
} else if (!faceDetected.value) {
|
|
149
|
+
updateAlert('no-face');
|
|
150
|
+
} else if (faceProximity.value === 'too-close') {
|
|
151
|
+
updateAlert('too-close');
|
|
152
|
+
} else if (faceProximity.value === 'too-far') {
|
|
153
|
+
updateAlert('too-far');
|
|
154
|
+
} else if (!faceInBounds.value) {
|
|
155
|
+
updateAlert('out-of-bounds');
|
|
156
|
+
} else if (isCapturing.value) {
|
|
157
|
+
updateCaptureAlerts();
|
|
158
|
+
} else {
|
|
159
|
+
alertTitle.value = 'Ready to capture';
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const stopDetectionLoop = () => {
|
|
164
|
+
if (animationFrameRef.current) {
|
|
165
|
+
cancelAnimationFrame(animationFrameRef.current);
|
|
166
|
+
animationFrameRef.current = null;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const detectFace = async () => {
|
|
171
|
+
if (!faceLandmarkerRef.current || !videoRef.current) {
|
|
172
|
+
stopDetectionLoop();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const croppedCanvas = createCroppedVideoFrame(videoRef.current);
|
|
178
|
+
const detectionSource = croppedCanvas || videoRef.current;
|
|
179
|
+
|
|
180
|
+
const results = faceLandmarkerRef.current.detectForVideo(
|
|
181
|
+
detectionSource,
|
|
182
|
+
performance.now(),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
faceLandmarks.value = results.faceLandmarks || [];
|
|
186
|
+
|
|
187
|
+
if (results.faceLandmarks && canvasRef.current && videoRef.current) {
|
|
188
|
+
// we run detection on a cropped video frame
|
|
189
|
+
// adjust landmark coordinates back to full video space
|
|
190
|
+
if (croppedCanvas) {
|
|
191
|
+
const sourceWidth = videoRef.current.videoWidth;
|
|
192
|
+
const sourceHeight = videoRef.current.videoHeight;
|
|
193
|
+
const squareSize = Math.min(sourceWidth, sourceHeight);
|
|
194
|
+
const offsetX = (sourceWidth - squareSize) / (2 * sourceWidth);
|
|
195
|
+
const offsetY = (sourceHeight - squareSize) / (2 * sourceHeight);
|
|
196
|
+
const scaleFactor = squareSize / sourceWidth;
|
|
197
|
+
const scaleFactorY = squareSize / sourceHeight;
|
|
198
|
+
|
|
199
|
+
const adjustedLandmarks = results.faceLandmarks.map((face) =>
|
|
200
|
+
face.map((landmark: any) => ({
|
|
201
|
+
x: landmark.x * scaleFactor + offsetX,
|
|
202
|
+
y: landmark.y * scaleFactorY + offsetY,
|
|
203
|
+
z: landmark.z,
|
|
204
|
+
})),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
drawFaceMesh(
|
|
208
|
+
canvasRef.current,
|
|
209
|
+
adjustedLandmarks,
|
|
210
|
+
capturesTaken.value,
|
|
211
|
+
smileCheckpoint.value,
|
|
212
|
+
);
|
|
213
|
+
} else {
|
|
214
|
+
drawFaceMesh(
|
|
215
|
+
canvasRef.current,
|
|
216
|
+
results.faceLandmarks,
|
|
217
|
+
capturesTaken.value,
|
|
218
|
+
smileCheckpoint.value,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
} else if (canvasRef.current) {
|
|
222
|
+
clearCanvas(canvasRef.current);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Check number of faces
|
|
226
|
+
const numFaces = results.faceLandmarks ? results.faceLandmarks.length : 0;
|
|
227
|
+
multipleFaces.value = numFaces > 1;
|
|
228
|
+
|
|
229
|
+
// Check if face is detected
|
|
230
|
+
const hasFace =
|
|
231
|
+
results.faceBlendshapes &&
|
|
232
|
+
results.faceBlendshapes.length > 0 &&
|
|
233
|
+
numFaces === 1;
|
|
234
|
+
faceDetected.value = hasFace;
|
|
235
|
+
|
|
236
|
+
if (hasFace && results.faceLandmarks) {
|
|
237
|
+
// Calculate face size and position
|
|
238
|
+
const faceSize = calculateFaceSize(results.faceLandmarks);
|
|
239
|
+
currentFaceSize.value = faceSize;
|
|
240
|
+
|
|
241
|
+
// Check face proximity
|
|
242
|
+
if (faceSize > maxFaceSize) {
|
|
243
|
+
faceProximity.value = 'too-close';
|
|
244
|
+
} else if (faceSize < minFaceSize) {
|
|
245
|
+
faceProximity.value = 'too-far';
|
|
246
|
+
} else {
|
|
247
|
+
faceProximity.value = 'good';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Check face position
|
|
251
|
+
faceInBounds.value = isFaceInBounds(
|
|
252
|
+
results.faceLandmarks,
|
|
253
|
+
videoAspectRatio.value,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// Get smile and mouth open data
|
|
257
|
+
const blendshapes = results.faceBlendshapes[0].categories;
|
|
258
|
+
const smileLeft =
|
|
259
|
+
blendshapes.find((b) => b.categoryName === 'mouthSmileLeft')?.score ||
|
|
260
|
+
0;
|
|
261
|
+
const smileRight =
|
|
262
|
+
blendshapes.find((b) => b.categoryName === 'mouthSmileRight')
|
|
263
|
+
?.score || 0;
|
|
264
|
+
const mouthOpen = calculateMouthOpening(results.faceLandmarks);
|
|
265
|
+
const smileScore = (smileLeft + smileRight) / 2;
|
|
266
|
+
|
|
267
|
+
currentSmileScore.value = smileScore;
|
|
268
|
+
currentMouthOpen.value = mouthOpen;
|
|
269
|
+
|
|
270
|
+
if (smileScore >= smileThreshold && mouthOpen >= mouthOpenThreshold) {
|
|
271
|
+
lastSmileTime.value = Date.now();
|
|
272
|
+
|
|
273
|
+
if (isPaused.value && isCapturing.value && resumeCaptureRef.current) {
|
|
274
|
+
// defer execution
|
|
275
|
+
setTimeout(() => {
|
|
276
|
+
const stillSmiling = Date.now() - lastSmileTime.value <= 100;
|
|
277
|
+
if (
|
|
278
|
+
stillSmiling &&
|
|
279
|
+
isPaused.value &&
|
|
280
|
+
isCapturing.value &&
|
|
281
|
+
resumeCaptureRef.current
|
|
282
|
+
) {
|
|
283
|
+
resumeCaptureRef.current();
|
|
284
|
+
}
|
|
285
|
+
}, 0);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
// No face detected or multiple faces - reset values
|
|
290
|
+
currentSmileScore.value = 0;
|
|
291
|
+
currentFaceSize.value = 0;
|
|
292
|
+
currentMouthOpen.value = 0;
|
|
293
|
+
faceInBounds.value = false;
|
|
294
|
+
faceProximity.value = 'good';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
updateAlerts();
|
|
298
|
+
} catch (error) {
|
|
299
|
+
faceDetected.value = false;
|
|
300
|
+
faceInBounds.value = false;
|
|
301
|
+
multipleFaces.value = false;
|
|
302
|
+
faceProximity.value = 'good';
|
|
303
|
+
currentMouthOpen.value = 0;
|
|
304
|
+
|
|
305
|
+
if (isCapturing.value) {
|
|
306
|
+
updateAlert('no-face');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
animationFrameRef.current = requestAnimationFrame(detectFace);
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const startDetectionLoop = () => {
|
|
314
|
+
if (animationFrameRef.current) {
|
|
315
|
+
cancelAnimationFrame(animationFrameRef.current);
|
|
316
|
+
}
|
|
317
|
+
animationFrameRef.current = requestAnimationFrame(detectFace);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const captureImage = () => {
|
|
321
|
+
if (!videoRef.current) return;
|
|
322
|
+
|
|
323
|
+
const isReference = capturesTaken.value === totalCaptures.value - 1;
|
|
324
|
+
const imageData = captureImageFromVideo(videoRef.current, isReference);
|
|
325
|
+
|
|
326
|
+
if (!imageData) return;
|
|
327
|
+
|
|
328
|
+
if (isReference) {
|
|
329
|
+
referencePhoto.value = imageData;
|
|
330
|
+
} else {
|
|
331
|
+
capturedImages.value = [...capturedImages.value, imageData];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
capturesTaken.value++;
|
|
335
|
+
countdown.value = totalCaptures.value - capturesTaken.value;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const stopCapture = () => {
|
|
339
|
+
if (captureTimerRef.current) {
|
|
340
|
+
clearInterval(captureTimerRef.current);
|
|
341
|
+
captureTimerRef.current = null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
isCapturing.value = false;
|
|
345
|
+
isPaused.value = false;
|
|
346
|
+
|
|
347
|
+
if (capturesTaken.value >= totalCaptures.value && referencePhoto.value) {
|
|
348
|
+
const livenessImages = capturedImages.value.map((img) => ({
|
|
349
|
+
image: img.split(',')[1],
|
|
350
|
+
image_type_id: ImageType.LIVENESS_IMAGE_BASE64,
|
|
351
|
+
}));
|
|
352
|
+
|
|
353
|
+
const referenceImage = {
|
|
354
|
+
image: referencePhoto.value.split(',')[1],
|
|
355
|
+
image_type_id: ImageType.SELFIE_IMAGE_BASE64,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const eventDetail = {
|
|
359
|
+
images: [...livenessImages, referenceImage],
|
|
360
|
+
referenceImage: referencePhoto.value,
|
|
361
|
+
previewImage: referencePhoto.value,
|
|
362
|
+
meta: { libraryVersion: COMPONENTS_VERSION },
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
window.dispatchEvent(
|
|
366
|
+
new CustomEvent('selfie-capture.publish', {
|
|
367
|
+
detail: eventDetail,
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
const smartCameraWeb = document.querySelector('smart-camera-web');
|
|
372
|
+
smartCameraWeb?.dispatchEvent(
|
|
373
|
+
new CustomEvent('metadata.selfie-capture-end'),
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
hasFinishedCapture.value = true;
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const pauseCapture = () => {
|
|
381
|
+
if (captureTimerRef.current) {
|
|
382
|
+
clearInterval(captureTimerRef.current);
|
|
383
|
+
captureTimerRef.current = null;
|
|
384
|
+
}
|
|
385
|
+
isPaused.value = true;
|
|
386
|
+
|
|
387
|
+
if (
|
|
388
|
+
!multipleFaces.value &&
|
|
389
|
+
faceDetected.value &&
|
|
390
|
+
faceInBounds.value &&
|
|
391
|
+
faceProximity.value === 'good'
|
|
392
|
+
) {
|
|
393
|
+
updateAlert('smile-required');
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const startCaptureInterval = () => {
|
|
398
|
+
if (captureTimerRef.current) {
|
|
399
|
+
clearInterval(captureTimerRef.current);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
captureTimerRef.current = setInterval(() => {
|
|
403
|
+
if (capturesTaken.value >= totalCaptures.value) {
|
|
404
|
+
stopCapture();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (multipleFaces.value) {
|
|
409
|
+
pauseCapture();
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (!faceDetected.value) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (!faceInBounds.value) {
|
|
418
|
+
pauseCapture();
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (faceProximity.value !== 'good') {
|
|
423
|
+
pauseCapture();
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const isInNeutralZone = capturesTaken.value < neutralZone.value;
|
|
428
|
+
const isInSmileZone = capturesTaken.value >= smileCheckpoint.value;
|
|
429
|
+
|
|
430
|
+
if (isInNeutralZone && currentSmileScore.value >= smileThreshold) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (isInSmileZone) {
|
|
435
|
+
const timeSinceSmile = Date.now() - lastSmileTime.value;
|
|
436
|
+
if (timeSinceSmile > smileCooldown) {
|
|
437
|
+
pauseCapture();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
captureImage();
|
|
443
|
+
}, interval);
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const resumeCapture = () => {
|
|
447
|
+
if (
|
|
448
|
+
faceDetected.value &&
|
|
449
|
+
faceProximity.value === 'good' &&
|
|
450
|
+
faceInBounds.value &&
|
|
451
|
+
!multipleFaces.value
|
|
452
|
+
) {
|
|
453
|
+
const isInSmileZone = capturesTaken.value >= smileCheckpoint.value;
|
|
454
|
+
if (isInSmileZone) {
|
|
455
|
+
const timeSinceSmile = Date.now() - lastSmileTime.value;
|
|
456
|
+
if (timeSinceSmile > smileCooldown) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
isPaused.value = false;
|
|
462
|
+
updateAlert(null);
|
|
463
|
+
startCaptureInterval();
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
resumeCaptureRef.current = resumeCapture;
|
|
468
|
+
|
|
469
|
+
const startCapture = async () => {
|
|
470
|
+
capturedImages.value = [];
|
|
471
|
+
isCapturing.value = true;
|
|
472
|
+
isPaused.value = false;
|
|
473
|
+
totalCaptures.value = Math.ceil(duration / interval);
|
|
474
|
+
capturesTaken.value = 0;
|
|
475
|
+
countdown.value = totalCaptures.value;
|
|
476
|
+
|
|
477
|
+
startCaptureInterval();
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const handleCancel = () => {
|
|
481
|
+
stopCapture();
|
|
482
|
+
window.dispatchEvent(
|
|
483
|
+
new CustomEvent('selfie-capture.cancelled', {
|
|
484
|
+
detail: { meta: { libraryVersion: COMPONENTS_VERSION } },
|
|
485
|
+
}),
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
// TODO: remove - for backwards compatibility
|
|
489
|
+
window.dispatchEvent(
|
|
490
|
+
new CustomEvent('selfie-capture-screens.cancelled', {
|
|
491
|
+
detail: { meta: { libraryVersion: COMPONENTS_VERSION } },
|
|
492
|
+
}),
|
|
493
|
+
);
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const handleClose = () => {
|
|
497
|
+
stopCapture();
|
|
498
|
+
|
|
499
|
+
window.dispatchEvent(
|
|
500
|
+
new CustomEvent('selfie-capture.close', {
|
|
501
|
+
detail: { meta: { libraryVersion: COMPONENTS_VERSION } },
|
|
502
|
+
}),
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
// TODO: remove - backwards compatibility
|
|
506
|
+
window.dispatchEvent(
|
|
507
|
+
new CustomEvent('selfie-capture-screens.close', {
|
|
508
|
+
detail: { meta: { libraryVersion: COMPONENTS_VERSION } },
|
|
509
|
+
}),
|
|
510
|
+
);
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
const cleanup = () => {
|
|
514
|
+
if (captureTimerRef.current) {
|
|
515
|
+
clearInterval(captureTimerRef.current);
|
|
516
|
+
}
|
|
517
|
+
stopDetectionLoop();
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
return {
|
|
521
|
+
faceDetected,
|
|
522
|
+
faceInBounds,
|
|
523
|
+
faceProximity,
|
|
524
|
+
multipleFaces,
|
|
525
|
+
videoAspectRatio,
|
|
526
|
+
faceLandmarks,
|
|
527
|
+
currentSmileScore,
|
|
528
|
+
currentFaceSize,
|
|
529
|
+
currentMouthOpen,
|
|
530
|
+
lastSmileTime,
|
|
531
|
+
alertTitle,
|
|
532
|
+
isReadyToCapture,
|
|
533
|
+
|
|
534
|
+
isCapturing,
|
|
535
|
+
isPaused,
|
|
536
|
+
countdown,
|
|
537
|
+
capturedImages,
|
|
538
|
+
referencePhoto,
|
|
539
|
+
totalCaptures,
|
|
540
|
+
capturesTaken,
|
|
541
|
+
hasFinishedCapture,
|
|
542
|
+
smileCheckpoint,
|
|
543
|
+
neutralZone,
|
|
544
|
+
|
|
545
|
+
initializeFaceLandmarker,
|
|
546
|
+
setupCanvas,
|
|
547
|
+
startDetectionLoop,
|
|
548
|
+
stopDetectionLoop,
|
|
549
|
+
updateAlert,
|
|
550
|
+
startCapture,
|
|
551
|
+
stopCapture,
|
|
552
|
+
pauseCapture,
|
|
553
|
+
resumeCapture,
|
|
554
|
+
handleCancel,
|
|
555
|
+
handleClose,
|
|
556
|
+
cleanup,
|
|
557
|
+
};
|
|
558
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as SmartSelfieCapture } from './SmartSelfieCapture';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const MESSAGES = {
|
|
2
|
+
'multiple-faces': 'Ensure only one face is visible',
|
|
3
|
+
'no-face': 'Position your face in the oval',
|
|
4
|
+
'out-of-bounds': 'Position your face in the oval',
|
|
5
|
+
'too-close': 'Move farther away',
|
|
6
|
+
'too-far': 'Move closer',
|
|
7
|
+
'neutral-expression': 'Neutral expression',
|
|
8
|
+
'smile-required': 'Smile!',
|
|
9
|
+
'open-mouth-smile': 'Bigger smile!',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type MessageKey = keyof typeof MESSAGES;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { DrawingUtils, FaceLandmarker } from '@mediapipe/tasks-vision';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create a cropped square canvas from video for face detection
|
|
5
|
+
*/
|
|
6
|
+
export const createCroppedVideoFrame = (
|
|
7
|
+
videoElement: HTMLVideoElement,
|
|
8
|
+
): HTMLCanvasElement | null => {
|
|
9
|
+
const canvas = document.createElement('canvas');
|
|
10
|
+
const ctx = canvas.getContext('2d');
|
|
11
|
+
if (!ctx) return null;
|
|
12
|
+
|
|
13
|
+
const sourceWidth = videoElement.videoWidth;
|
|
14
|
+
const sourceHeight = videoElement.videoHeight;
|
|
15
|
+
|
|
16
|
+
const squareSize = Math.min(sourceWidth, sourceHeight);
|
|
17
|
+
const cropX = (sourceWidth - squareSize) / 2;
|
|
18
|
+
const cropY = (sourceHeight - squareSize) / 2;
|
|
19
|
+
|
|
20
|
+
canvas.width = squareSize;
|
|
21
|
+
canvas.height = squareSize;
|
|
22
|
+
|
|
23
|
+
ctx.drawImage(
|
|
24
|
+
videoElement,
|
|
25
|
+
cropX,
|
|
26
|
+
cropY,
|
|
27
|
+
squareSize,
|
|
28
|
+
squareSize,
|
|
29
|
+
0,
|
|
30
|
+
0,
|
|
31
|
+
squareSize,
|
|
32
|
+
squareSize,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return canvas;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Draw face mesh overlay on canvas
|
|
40
|
+
*/
|
|
41
|
+
export const drawFaceMesh = (
|
|
42
|
+
canvas: HTMLCanvasElement,
|
|
43
|
+
landmarks: any[],
|
|
44
|
+
capturesTaken: number,
|
|
45
|
+
smileCheckpoint: number,
|
|
46
|
+
): void => {
|
|
47
|
+
const ctx = canvas.getContext('2d');
|
|
48
|
+
if (!ctx) return;
|
|
49
|
+
|
|
50
|
+
const canvasWidth = canvas.width;
|
|
51
|
+
const canvasHeight = canvas.height;
|
|
52
|
+
|
|
53
|
+
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
|
54
|
+
const drawingUtils = new DrawingUtils(ctx);
|
|
55
|
+
|
|
56
|
+
// use this if scaling is needed
|
|
57
|
+
// const scaleFactor = Math.sqrt(canvasWidth * canvasHeight) / 500;
|
|
58
|
+
|
|
59
|
+
landmarks.forEach((landmark) => {
|
|
60
|
+
if (!landmark || landmark.length === 0) return;
|
|
61
|
+
|
|
62
|
+
const outlineColor = 'rgba(162, 155, 254,0.4)';
|
|
63
|
+
const lineWidth = 2; // Math.max(1, scaleFactor * 2);
|
|
64
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)';
|
|
65
|
+
ctx.lineWidth = lineWidth;
|
|
66
|
+
ctx.lineCap = 'round';
|
|
67
|
+
ctx.lineJoin = 'round';
|
|
68
|
+
|
|
69
|
+
drawingUtils.drawLandmarks(landmark, {
|
|
70
|
+
color: 'rgba(9, 132, 227,0.7)',
|
|
71
|
+
lineWidth: 0.5,
|
|
72
|
+
radius: 0.5,
|
|
73
|
+
});
|
|
74
|
+
drawingUtils.drawConnectors(
|
|
75
|
+
landmark,
|
|
76
|
+
FaceLandmarker.FACE_LANDMARKS_FACE_OVAL,
|
|
77
|
+
{
|
|
78
|
+
color: outlineColor,
|
|
79
|
+
lineWidth,
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const isInSmileZone = capturesTaken > 0 && capturesTaken >= smileCheckpoint;
|
|
84
|
+
if (isInSmileZone) {
|
|
85
|
+
drawingUtils.drawConnectors(
|
|
86
|
+
landmark,
|
|
87
|
+
FaceLandmarker.FACE_LANDMARKS_LIPS,
|
|
88
|
+
{
|
|
89
|
+
color: outlineColor,
|
|
90
|
+
lineWidth,
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Clear canvas completely
|
|
99
|
+
*/
|
|
100
|
+
export const clearCanvas = (canvas: HTMLCanvasElement): void => {
|
|
101
|
+
const ctx = canvas.getContext('2d');
|
|
102
|
+
if (ctx) {
|
|
103
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
104
|
+
}
|
|
105
|
+
};
|