@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.
Files changed (125) hide show
  1. package/dist/DocumentCaptureScreens-Dwl7UqVH.js +1534 -0
  2. package/dist/DocumentCaptureScreens-Dwl7UqVH.js.map +1 -0
  3. package/dist/EndUserConsent-C5hZdJzH.js +715 -0
  4. package/dist/EndUserConsent-C5hZdJzH.js.map +1 -0
  5. package/dist/Navigation-juBE4qOw.js +136 -0
  6. package/dist/Navigation-juBE4qOw.js.map +1 -0
  7. package/dist/PoweredBySmileId-CxbaihMu.js +33 -0
  8. package/dist/PoweredBySmileId-CxbaihMu.js.map +1 -0
  9. package/dist/SelfieCaptureScreens-CQc251hz.js +7618 -0
  10. package/dist/SelfieCaptureScreens-CQc251hz.js.map +1 -0
  11. package/dist/SignaturePad-C7MtmT8m.js +324 -0
  12. package/dist/SignaturePad-C7MtmT8m.js.map +1 -0
  13. package/dist/TotpConsent-CQU5jQi4.js +730 -0
  14. package/dist/TotpConsent-CQU5jQi4.js.map +1 -0
  15. package/dist/combobox.js +300 -0
  16. package/dist/combobox.js.map +1 -0
  17. package/dist/document.js +5 -0
  18. package/dist/document.js.map +1 -0
  19. package/dist/end-user-consent.js +5 -0
  20. package/dist/end-user-consent.js.map +1 -0
  21. package/dist/main.js +22 -0
  22. package/dist/main.js.map +1 -0
  23. package/dist/navigation.js +5 -0
  24. package/dist/navigation.js.map +1 -0
  25. package/dist/package-Oi2Yil3b.js +105 -0
  26. package/dist/package-Oi2Yil3b.js.map +1 -0
  27. package/dist/selfie.js +5 -0
  28. package/dist/selfie.js.map +1 -0
  29. package/dist/signature-pad.js +5 -0
  30. package/dist/signature-pad.js.map +1 -0
  31. package/dist/smart-camera-web.js +303 -0
  32. package/dist/smart-camera-web.js.map +1 -0
  33. package/dist/styles-BUWNxWeQ.js +406 -0
  34. package/dist/styles-BUWNxWeQ.js.map +1 -0
  35. package/dist/totp-consent.js +5 -0
  36. package/dist/totp-consent.js.map +1 -0
  37. package/dist/types/combobox.d.ts +21 -0
  38. package/dist/types/document.d.ts +21 -0
  39. package/dist/types/end-user-consent.d.ts +21 -0
  40. package/dist/types/main.d.ts +331 -0
  41. package/dist/types/navigation.d.ts +21 -0
  42. package/dist/types/selfie.d.ts +21 -0
  43. package/dist/types/signature-pad.d.ts +21 -0
  44. package/dist/types/smart-camera-web.d.ts +21 -0
  45. package/dist/types/totp-consent.d.ts +21 -0
  46. package/{src → lib}/components/README.md +14 -14
  47. package/{src → lib}/components/attribution/PoweredBySmileId.js +42 -42
  48. package/{src → lib}/components/camera-permission/CameraPermission.js +140 -140
  49. package/{src → lib}/components/camera-permission/CameraPermission.stories.js +27 -27
  50. package/{src → lib}/components/combobox/src/Combobox.js +589 -589
  51. package/{src → lib}/components/combobox/src/index.js +1 -1
  52. package/{src → lib}/components/document/src/DocumentCaptureScreens.js +409 -409
  53. package/{src → lib}/components/document/src/DocumentCaptureScreens.stories.js +57 -57
  54. package/{src → lib}/components/document/src/README.md +111 -111
  55. package/{src → lib}/components/document/src/document-capture/DocumentCapture.js +760 -760
  56. package/{src → lib}/components/document/src/document-capture/DocumentCapture.stories.js +78 -78
  57. package/{src → lib}/components/document/src/document-capture/README.md +90 -90
  58. package/{src → lib}/components/document/src/document-capture/index.js +3 -3
  59. package/{src → lib}/components/document/src/document-capture-instructions/DocumentCaptureInstructions.js +499 -499
  60. package/{src → lib}/components/document/src/document-capture-instructions/DocumentCaptureInstructions.stories.js +24 -24
  61. package/{src → lib}/components/document/src/document-capture-instructions/README.md +56 -56
  62. package/{src → lib}/components/document/src/document-capture-instructions/index.js +3 -3
  63. package/{src → lib}/components/document/src/document-capture-review/DocumentCaptureReview.js +362 -362
  64. package/{src → lib}/components/document/src/document-capture-review/DocumentCaptureReview.stories.js +24 -24
  65. package/{src → lib}/components/document/src/document-capture-review/README.md +79 -79
  66. package/{src → lib}/components/document/src/document-capture-review/index.js +3 -3
  67. package/{src → lib}/components/document/src/index.js +3 -3
  68. package/{src → lib}/components/end-user-consent/src/EndUserConsent.js +795 -795
  69. package/{src → lib}/components/end-user-consent/src/EndUserConsent.stories.js +29 -29
  70. package/{src → lib}/components/end-user-consent/src/index.js +4 -4
  71. package/{src → lib}/components/navigation/src/Navigation.js +171 -171
  72. package/{src → lib}/components/navigation/src/Navigation.stories.js +24 -24
  73. package/{src → lib}/components/navigation/src/index.js +3 -3
  74. package/{src → lib}/components/selfie/README.md +225 -225
  75. package/{src → lib}/components/selfie/src/SelfieCaptureScreens.js +433 -282
  76. package/{src → lib}/components/selfie/src/SelfieCaptureScreens.stories.js +29 -29
  77. package/{src → lib}/components/selfie/src/index.js +3 -5
  78. package/{src → lib}/components/selfie/src/selfie-capture/SelfieCapture.js +1041 -1010
  79. package/{src → lib}/components/selfie/src/selfie-capture/SelfieCapture.stories.js +36 -36
  80. package/{src → lib}/components/selfie/src/selfie-capture/index.js +3 -3
  81. package/{src → lib}/components/selfie/src/selfie-capture-instructions/SelfieCaptureInstructions.js +657 -648
  82. package/{src → lib}/components/selfie/src/selfie-capture-instructions/SelfieCaptureInstructions.stories.js +23 -23
  83. package/{src → lib}/components/selfie/src/selfie-capture-instructions/index.js +3 -3
  84. package/{src → lib}/components/selfie/src/selfie-capture-review/SelfieCaptureReview.js +340 -347
  85. package/{src → lib}/components/selfie/src/selfie-capture-review/SelfieCaptureReview.stories.js +24 -24
  86. package/{src → lib}/components/selfie/src/selfie-capture-review/index.js +3 -3
  87. package/lib/components/selfie/src/selfie-capture-wrapper/SelfieCaptureWrapper.tsx +227 -0
  88. package/lib/components/selfie/src/selfie-capture-wrapper/index.ts +1 -0
  89. package/lib/components/selfie/src/smartselfie-capture/OvalProgress.tsx +81 -0
  90. package/lib/components/selfie/src/smartselfie-capture/SmartSelfieCapture.tsx +224 -0
  91. package/lib/components/selfie/src/smartselfie-capture/components/AlertDisplay.tsx +34 -0
  92. package/lib/components/selfie/src/smartselfie-capture/components/CameraPreview.tsx +97 -0
  93. package/lib/components/selfie/src/smartselfie-capture/components/CaptureControls.tsx +74 -0
  94. package/lib/components/selfie/src/smartselfie-capture/components/index.ts +3 -0
  95. package/lib/components/selfie/src/smartselfie-capture/constants.ts +23 -0
  96. package/lib/components/selfie/src/smartselfie-capture/hooks/index.ts +2 -0
  97. package/lib/components/selfie/src/smartselfie-capture/hooks/useCamera.ts +94 -0
  98. package/lib/components/selfie/src/smartselfie-capture/hooks/useFaceCapture.ts +558 -0
  99. package/lib/components/selfie/src/smartselfie-capture/index.ts +1 -0
  100. package/lib/components/selfie/src/smartselfie-capture/utils/alertMessages.ts +12 -0
  101. package/lib/components/selfie/src/smartselfie-capture/utils/canvas.ts +105 -0
  102. package/lib/components/selfie/src/smartselfie-capture/utils/faceDetection.ts +129 -0
  103. package/lib/components/selfie/src/smartselfie-capture/utils/imageCapture.ts +64 -0
  104. package/lib/components/selfie/src/smartselfie-capture/utils/index.ts +4 -0
  105. package/lib/components/selfie/src/smartselfie-capture/utils/mediapipeManager.ts +60 -0
  106. package/{src → lib}/components/signature-pad/package-lock.json +3009 -3009
  107. package/{src → lib}/components/signature-pad/package.json +30 -30
  108. package/{src → lib}/components/signature-pad/src/SignaturePad.js +484 -484
  109. package/{src → lib}/components/signature-pad/src/SignaturePad.stories.js +32 -32
  110. package/{src → lib}/components/signature-pad/src/index.js +3 -3
  111. package/{src → lib}/components/smart-camera-web/src/README.md +206 -207
  112. package/{src → lib}/components/smart-camera-web/src/SmartCameraWeb.js +299 -299
  113. package/{src → lib}/components/smart-camera-web/src/SmartCameraWeb.stories.js +57 -57
  114. package/{src → lib}/components/totp-consent/src/TotpConsent.js +949 -949
  115. package/{src → lib}/components/totp-consent/src/index.js +4 -4
  116. package/{src → lib}/domain/camera/src/README.md +38 -38
  117. package/{src → lib}/domain/camera/src/SmartCamera.js +109 -109
  118. package/{src → lib}/domain/constants/src/Constants.js +27 -27
  119. package/{src → lib}/domain/file-upload/README.md +35 -35
  120. package/{src → lib}/domain/file-upload/src/SmartFileUpload.js +65 -65
  121. package/{src → lib}/styles/README.md +3 -3
  122. package/{src → lib}/styles/src/styles.js +359 -359
  123. package/{src → lib}/styles/src/typography.js +52 -52
  124. package/package.json +109 -58
  125. 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
+ };