@smileid/web-components 11.4.5 → 11.5.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 (83) hide show
  1. package/dist/esm/{DocumentCaptureScreens-D2G0NOQr.js → DocumentCaptureScreens-ucJDu5nH.js} +555 -2470
  2. package/dist/esm/DocumentCaptureScreens-ucJDu5nH.js.map +1 -0
  3. package/dist/esm/{EndUserConsent-uHfA3txP.js → EndUserConsent-CsiwoThZ.js} +3 -3
  4. package/dist/esm/{EndUserConsent-uHfA3txP.js.map → EndUserConsent-CsiwoThZ.js.map} +1 -1
  5. package/dist/esm/{Navigation-Bb7MPLE8.js → Navigation-Xg565kcu.js} +28 -22
  6. package/dist/esm/Navigation-Xg565kcu.js.map +1 -0
  7. package/dist/esm/SelfieCaptureScreens-D3KuMzZA.js +11471 -0
  8. package/dist/esm/SelfieCaptureScreens-D3KuMzZA.js.map +1 -0
  9. package/dist/esm/{TotpConsent-Depzg0ti.js → TotpConsent-CRtmtudl.js} +2 -2
  10. package/dist/esm/{TotpConsent-Depzg0ti.js.map → TotpConsent-CRtmtudl.js.map} +1 -1
  11. package/dist/esm/combobox.js +1 -1
  12. package/dist/esm/document.js +1 -1
  13. package/dist/esm/end-user-consent.js +1 -1
  14. package/dist/esm/index-CUwa6MPI.js +1363 -0
  15. package/dist/esm/{index-C4RTMbgw.js.map → index-CUwa6MPI.js.map} +1 -1
  16. package/dist/esm/localisation.js +1 -1
  17. package/dist/esm/main.js +6 -6
  18. package/dist/esm/navigation.js +1 -1
  19. package/dist/esm/package-BmVbDNny.js +2535 -0
  20. package/dist/esm/package-BmVbDNny.js.map +1 -0
  21. package/dist/esm/selfie.js +1 -1
  22. package/dist/esm/smart-camera-web.js +67 -37
  23. package/dist/esm/smart-camera-web.js.map +1 -1
  24. package/dist/esm/totp-consent.js +1 -1
  25. package/dist/smart-camera-web.js +877 -122
  26. package/dist/smart-camera-web.js.map +1 -1
  27. package/dist/types/main.d.ts +11 -0
  28. package/lib/components/navigation/src/Navigation.js +27 -8
  29. package/lib/components/selfie/src/SelfieCaptureScreens.js +56 -8
  30. package/lib/components/selfie/src/enhanced-smartselfie-capture/EnhancedSmartSelfieCapture.tsx +684 -0
  31. package/lib/components/selfie/src/enhanced-smartselfie-capture/EnhancedSmartSelfieConsent.tsx +71 -0
  32. package/lib/components/selfie/src/enhanced-smartselfie-capture/EnhancedSmartSelfieSubmission.tsx +181 -0
  33. package/lib/components/selfie/src/enhanced-smartselfie-capture/OvalProgress.tsx +87 -0
  34. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/Icon.svg +8 -0
  35. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/accessories.svg +77 -0
  36. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/active_liveness_animation.lottie +0 -0
  37. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/device.svg +12 -0
  38. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/device_orientation.lottie +0 -0
  39. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/good.svg +52 -0
  40. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/id-card.svg +9 -0
  41. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/illustrations.tsx +852 -0
  42. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/instructions-img.svg +3 -0
  43. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/multiple-faces.svg +69 -0
  44. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/person.svg +6 -0
  45. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/phone.svg +8 -0
  46. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/poor-lighting.svg +53 -0
  47. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/too_dark_animation.lottie +0 -0
  48. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/ActiveLivenessOverlay.tsx +226 -0
  49. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/AlertDisplay.tsx +38 -0
  50. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/BackNavigation.tsx +45 -0
  51. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/CameraPreview.tsx +96 -0
  52. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/CaptureControls.tsx +97 -0
  53. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/CaptureGuidelines.tsx +374 -0
  54. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/ConsentView.tsx +460 -0
  55. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/SubmissionView.tsx +426 -0
  56. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/index.ts +3 -0
  57. package/lib/components/selfie/src/enhanced-smartselfie-capture/constants.ts +23 -0
  58. package/lib/components/selfie/src/enhanced-smartselfie-capture/hooks/index.ts +2 -0
  59. package/lib/components/selfie/src/enhanced-smartselfie-capture/hooks/useCamera.ts +238 -0
  60. package/lib/components/selfie/src/enhanced-smartselfie-capture/hooks/useFaceCapture.ts +1075 -0
  61. package/lib/components/selfie/src/enhanced-smartselfie-capture/index.ts +1 -0
  62. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/alertMessages.ts +20 -0
  63. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/canvas.ts +108 -0
  64. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/faceDetection.ts +545 -0
  65. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/imageCapture.ts +66 -0
  66. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/imageQuality.ts +151 -0
  67. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/index.ts +5 -0
  68. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/mediapipeManager.ts +215 -0
  69. package/lib/components/selfie/src/selfie-capture-wrapper/SelfieCaptureWrapper.tsx +24 -1
  70. package/lib/components/selfie/src/smartselfie-capture/SmartSelfieCapture.tsx +2 -2
  71. package/lib/components/selfie/src/smartselfie-capture/hooks/useFaceCapture.ts +15 -7
  72. package/lib/components/selfie/src/smartselfie-capture/utils/canvas.ts +4 -6
  73. package/lib/components/signature-pad/package.json +1 -1
  74. package/lib/components/smart-camera-web/src/SmartCameraWeb.js +64 -7
  75. package/lib/domain/localisation/index.js +2 -2
  76. package/package.json +2 -2
  77. package/dist/esm/DocumentCaptureScreens-D2G0NOQr.js.map +0 -1
  78. package/dist/esm/Navigation-Bb7MPLE8.js.map +0 -1
  79. package/dist/esm/SelfieCaptureScreens-Dr7VzON7.js +0 -7651
  80. package/dist/esm/SelfieCaptureScreens-Dr7VzON7.js.map +0 -1
  81. package/dist/esm/index-C4RTMbgw.js +0 -1360
  82. package/dist/esm/package-D6YrpMcO.js +0 -565
  83. package/dist/esm/package-D6YrpMcO.js.map +0 -1
@@ -0,0 +1,1075 @@
1
+ import { useRef } from 'preact/hooks';
2
+ import { useSignal, useComputed } from '@preact/signals';
3
+ import type { RefObject } from 'preact';
4
+ import { FaceLandmarker } from '@mediapipe/tasks-vision';
5
+ import throttle from 'lodash/throttle';
6
+ import {
7
+ calculateFaceSize,
8
+ isFaceInBounds,
9
+ computeFaceClippingOval,
10
+ computeFaceClippingSide,
11
+ calculateMouthOpening,
12
+ calculateHeadPose,
13
+ classifyHeadPose,
14
+ buildRandomPoseSequence,
15
+ type HeadPoseDirection,
16
+ } from '../utils/faceDetection';
17
+ import {
18
+ calculateLuminance,
19
+ calculateBlurScore,
20
+ DEFAULT_LUMINANCE_MIN,
21
+ DEFAULT_BLUR_MIN,
22
+ } from '../utils/imageQuality';
23
+ import {
24
+ createCroppedVideoFrame,
25
+ drawFaceMesh,
26
+ clearCanvas,
27
+ } from '../utils/canvas';
28
+ import { captureImageFromVideo } from '../utils/imageCapture';
29
+ import { ImageType } from '../constants';
30
+ import { MESSAGES, type MessageKey } from '../utils/alertMessages';
31
+ import { getMediapipeInstance } from '../utils/mediapipeManager';
32
+ import { t } from '../../../../../domain/localisation';
33
+ import packageJson from '../../../../../../package.json';
34
+
35
+ const COMPONENTS_VERSION = packageJson.version;
36
+
37
+ interface UseFaceCaptureProps {
38
+ videoRef: RefObject<HTMLVideoElement>;
39
+ canvasRef: RefObject<HTMLCanvasElement>;
40
+ interval: number;
41
+ duration: number;
42
+ smileThreshold: number;
43
+ mouthOpenThreshold: number;
44
+ minFaceSize: number;
45
+ maxFaceSize: number;
46
+ smileCooldown: number;
47
+ getFacingMode: () => CameraFacingMode;
48
+ /**
49
+ * Enhanced SmartSelfie / Active Liveness mode. When enabled, the smile-based
50
+ * capture flow is replaced with a randomised head-pose sequence and stricter
51
+ * frame-quality checks (lighting/blur/centering).
52
+ */
53
+ useStrictMode?: boolean;
54
+ /**
55
+ * Optional callback invoked when capture completes. When provided, the hook
56
+ * will NOT broadcast the legacy `selfie-capture.publish` window event — the
57
+ * caller takes full ownership of the payload (e.g. to show its own review
58
+ * screen and re-emit the event only on user confirmation).
59
+ */
60
+ onCaptureComplete?: (detail: {
61
+ images: { image: string; image_type_id: number }[];
62
+ referenceImage: string;
63
+ previewImage: string;
64
+ facingMode: CameraFacingMode;
65
+ forceFailureReason?: string;
66
+ meta: { libraryVersion: string };
67
+ }) => void;
68
+ }
69
+
70
+ export const useFaceCapture = ({
71
+ videoRef,
72
+ canvasRef,
73
+ interval,
74
+ duration,
75
+ smileThreshold,
76
+ mouthOpenThreshold,
77
+ minFaceSize,
78
+ maxFaceSize,
79
+ smileCooldown,
80
+ getFacingMode,
81
+ useStrictMode = false,
82
+ onCaptureComplete,
83
+ }: UseFaceCaptureProps) => {
84
+ const faceLandmarkerRef = useRef<FaceLandmarker | null>(null);
85
+ const animationFrameRef = useRef<number | null>(null);
86
+ const captureTimerRef = useRef<NodeJS.Timeout | null>(null);
87
+ const resumeCaptureRef = useRef<(() => void) | null>(null);
88
+ const fallbackTimerRef = useRef<NodeJS.Timeout | null>(null);
89
+ // Ring buffer of the last few raw pose classifications. The capture gate
90
+ // and the displayed pose flip only when a direction wins ≥2 of the last
91
+ // 3 ticks, which kills single-frame noise from atan2 jitter without
92
+ // adding any perceptible lag.
93
+ const poseHistoryRef = useRef<(HeadPoseDirection | null)[]>([]);
94
+ // Timestamp of the most recent tick where the smoothed pose matched the
95
+ // currently-required pose. Used as a short latch so a capture-interval
96
+ // tick that fires moments after the user nails the pose still counts.
97
+ const lastPoseMatchAtRef = useRef<number>(0);
98
+ // How long after a confirmed pose match the capture tick will still
99
+ // accept the pose as held. Sized to comfortably span one capture
100
+ // interval (~330ms at the default rate) plus a little headroom.
101
+ const POSE_MATCH_LATCH_MS = 500;
102
+
103
+ const faceDetected = useSignal(false);
104
+ const faceInBounds = useSignal(false);
105
+ // True when any landmark falls outside the visible egg-shaped clip mask.
106
+ // Computed from runtime DOM rects so it always matches what the user sees,
107
+ // independent of the camera's intrinsic resolution. Only used as a gating
108
+ // edge case when idle — during the active-liveness pose phase head turns
109
+ // legitimately push landmarks past the oval edge.
110
+ const faceClippingOval = useSignal(false);
111
+ const faceProximity = useSignal<'too-close' | 'too-far' | 'good'>('good');
112
+ const videoAspectRatio = useSignal(16 / 9);
113
+ const faceLandmarks = useSignal<any[]>([]);
114
+ const currentSmileScore = useSignal(0);
115
+ const currentFaceSize = useSignal(0);
116
+ const currentMouthOpen = useSignal(0);
117
+ const lastSmileTime = useSignal(0);
118
+ const alertTitle = useSignal('');
119
+ const isInitializing = useSignal(true);
120
+ const captureButtonFallbackEnabled = useSignal(false);
121
+
122
+ const isCapturing = useSignal(false);
123
+ const isPaused = useSignal(false);
124
+ const countdown = useSignal(0);
125
+ const capturedImages = useSignal<string[]>([]);
126
+ const referencePhoto = useSignal<string | null>(null);
127
+ const totalCaptures = useSignal(1);
128
+ const capturesTaken = useSignal(0);
129
+ const hasFinishedCapture = useSignal(false);
130
+
131
+ // Active-liveness (Enhanced SmartSelfie) signals — only meaningful when
132
+ // useStrictMode is true.
133
+ const poseSequence = useSignal<HeadPoseDirection[]>([]);
134
+ const currentPoseIndex = useSignal(0);
135
+ const currentPose = useSignal<HeadPoseDirection | null>(null);
136
+ const isTooDark = useSignal(false);
137
+ const isTooBlurry = useSignal(false);
138
+ // Direction the face is offset from the oval centre when out of bounds.
139
+ // Used by the UI to colour the offending side of the oval and pick a
140
+ // directional prompt ("Move your device higher/lower/left/right").
141
+ const faceOffsetDirection = useSignal<
142
+ 'top' | 'bottom' | 'left' | 'right' | null
143
+ >(null);
144
+ let qualityFrameCounter = 0;
145
+
146
+ const smileCheckpoint = useComputed(() =>
147
+ Math.floor(totalCaptures.value * 0.4),
148
+ );
149
+ const neutralZone = useComputed(() => Math.floor(totalCaptures.value * 0.2));
150
+
151
+ const isReadyToCapture = useComputed(
152
+ () =>
153
+ faceDetected.value &&
154
+ faceInBounds.value &&
155
+ faceProximity.value === 'good',
156
+ );
157
+
158
+ const updateAlertImmediate = (messageKey: MessageKey | null) => {
159
+ if (messageKey && MESSAGES[messageKey]) {
160
+ alertTitle.value = MESSAGES[messageKey]?.();
161
+ } else {
162
+ alertTitle.value = '';
163
+ }
164
+ };
165
+
166
+ const updateAlert = useRef(
167
+ throttle((messageKey: MessageKey | null) => {
168
+ updateAlertImmediate(messageKey);
169
+ }, 600),
170
+ ).current;
171
+
172
+ const CAPTURE_FALLBACK_TIMEOUT_MS = 10000;
173
+
174
+ const startFallbackTimer = () => {
175
+ if (fallbackTimerRef.current) {
176
+ clearTimeout(fallbackTimerRef.current);
177
+ }
178
+ fallbackTimerRef.current = setTimeout(() => {
179
+ if (!isReadyToCapture.value) {
180
+ captureButtonFallbackEnabled.value = true;
181
+ }
182
+ }, CAPTURE_FALLBACK_TIMEOUT_MS);
183
+ };
184
+
185
+ const initializeFaceLandmarker = async () => {
186
+ try {
187
+ const isAlreadyLoaded =
188
+ window.__smileIdentityMediapipe?.loaded &&
189
+ window.__smileIdentityMediapipe?.instance;
190
+
191
+ if (!isAlreadyLoaded) {
192
+ isInitializing.value = true;
193
+ updateAlertImmediate('initializing');
194
+ }
195
+
196
+ faceLandmarkerRef.current = await getMediapipeInstance();
197
+ isInitializing.value = false;
198
+ startFallbackTimer();
199
+ } catch (error) {
200
+ console.error('Failed to initialize MediaPipe:', error);
201
+ isInitializing.value = false;
202
+ // MediaPipe failed — start the fallback timer so the button eventually
203
+ // enables and the user isn't permanently stuck.
204
+ startFallbackTimer();
205
+ }
206
+ };
207
+
208
+ const setupCanvas = () => {
209
+ if (videoRef.current && canvasRef.current) {
210
+ const { videoWidth, videoHeight } = videoRef.current;
211
+
212
+ videoAspectRatio.value = videoWidth / videoHeight;
213
+
214
+ canvasRef.current.width = videoWidth;
215
+ canvasRef.current.height = videoHeight;
216
+
217
+ const container = videoRef.current.parentElement;
218
+ if (container) {
219
+ canvasRef.current.style.left = '50%';
220
+ canvasRef.current.style.top = '50%';
221
+ }
222
+ }
223
+ };
224
+
225
+ const updateCaptureAlerts = () => {
226
+ if (useStrictMode) {
227
+ // Strict-mode capture alerts are driven by head-pose prompts. The active
228
+ // pose label is derived from the randomised sequence; we don't fall back
229
+ // to the smile-zone messaging at all.
230
+ const pose = poseSequence.value[currentPoseIndex.value];
231
+ if (!pose) {
232
+ alertTitle.value = t('selfie.smart.status.capturing');
233
+ return;
234
+ }
235
+ const poseToMessage: Record<typeof pose, MessageKey> = {
236
+ left: 'turn-head-left',
237
+ right: 'turn-head-right',
238
+ up: 'tilt-head-up',
239
+ };
240
+ updateAlert(poseToMessage[pose]);
241
+ return;
242
+ }
243
+
244
+ const isInNeutralZone = capturesTaken.value < neutralZone.value;
245
+ const isInSmileZone = capturesTaken.value >= smileCheckpoint.value;
246
+
247
+ if (isInNeutralZone) {
248
+ alertTitle.value = t('selfie.smart.status.capturing');
249
+ } else if (isInSmileZone) {
250
+ const timeSinceSmile = Date.now() - lastSmileTime.value;
251
+ if (timeSinceSmile > smileCooldown) {
252
+ if (
253
+ currentSmileScore.value >= smileThreshold &&
254
+ currentMouthOpen.value < mouthOpenThreshold
255
+ ) {
256
+ updateAlert('open-mouth-smile');
257
+ } else {
258
+ updateAlert('smile-required');
259
+ }
260
+ } else {
261
+ alertTitle.value = t('selfie.smart.status.keepSmiling');
262
+ }
263
+ } else {
264
+ updateAlert(null);
265
+ }
266
+ };
267
+
268
+ const updateAlerts = () => {
269
+ if (isInitializing.value) {
270
+ updateAlertImmediate('initializing');
271
+ } else if (!faceDetected.value) {
272
+ updateAlert('no-face');
273
+ } else if (useStrictMode && isTooDark.value) {
274
+ updateAlert('too-dark');
275
+ } else if (useStrictMode && isTooBlurry.value) {
276
+ updateAlert('too-blurry');
277
+ } else if (faceProximity.value === 'too-close') {
278
+ updateAlert('too-close');
279
+ } else if (faceProximity.value === 'too-far') {
280
+ updateAlert('too-far');
281
+ } else if (!faceInBounds.value) {
282
+ updateAlert(useStrictMode ? 'face-not-centered' : 'out-of-bounds');
283
+ } else if (isCapturing.value) {
284
+ updateCaptureAlerts();
285
+ } else {
286
+ alertTitle.value = t('selfie.smart.status.readyToCapture');
287
+ }
288
+ };
289
+
290
+ const stopDetectionLoop = () => {
291
+ if (animationFrameRef.current) {
292
+ cancelAnimationFrame(animationFrameRef.current);
293
+ animationFrameRef.current = null;
294
+ }
295
+ };
296
+
297
+ const detectFace = async () => {
298
+ if (!faceLandmarkerRef.current || !videoRef.current) {
299
+ stopDetectionLoop();
300
+ return;
301
+ }
302
+
303
+ // ensure video has valid dimensions before processing
304
+ if (
305
+ videoRef.current.videoWidth <= 0 ||
306
+ videoRef.current.videoHeight <= 0 ||
307
+ videoRef.current.readyState < 2
308
+ ) {
309
+ animationFrameRef.current = requestAnimationFrame(detectFace);
310
+ return;
311
+ }
312
+
313
+ try {
314
+ if (isInitializing.value) {
315
+ isInitializing.value = false;
316
+ }
317
+
318
+ const croppedCanvas = createCroppedVideoFrame(videoRef.current);
319
+ const detectionSource = croppedCanvas || videoRef.current;
320
+
321
+ const results = faceLandmarkerRef.current.detectForVideo(
322
+ detectionSource,
323
+ performance.now(),
324
+ );
325
+
326
+ faceLandmarks.value = results.faceLandmarks || [];
327
+
328
+ if (results.faceLandmarks && canvasRef.current && videoRef.current) {
329
+ // we run detection on a cropped video frame
330
+ // adjust landmark coordinates back to full video space
331
+ if (croppedCanvas) {
332
+ const sourceWidth = videoRef.current.videoWidth;
333
+ const sourceHeight = videoRef.current.videoHeight;
334
+ const squareSize = Math.min(sourceWidth, sourceHeight);
335
+ const offsetX = (sourceWidth - squareSize) / (2 * sourceWidth);
336
+ const offsetY = (sourceHeight - squareSize) / (2 * sourceHeight);
337
+ const scaleFactor = squareSize / sourceWidth;
338
+ const scaleFactorY = squareSize / sourceHeight;
339
+
340
+ const adjustedLandmarks = results.faceLandmarks.map((face) =>
341
+ face.map((landmark: any) => ({
342
+ x: landmark.x * scaleFactor + offsetX,
343
+ y: landmark.y * scaleFactorY + offsetY,
344
+ z: landmark.z,
345
+ })),
346
+ );
347
+
348
+ drawFaceMesh(
349
+ canvasRef.current,
350
+ adjustedLandmarks,
351
+ capturesTaken.value,
352
+ smileCheckpoint.value,
353
+ useStrictMode,
354
+ );
355
+ } else {
356
+ drawFaceMesh(
357
+ canvasRef.current,
358
+ results.faceLandmarks,
359
+ capturesTaken.value,
360
+ smileCheckpoint.value,
361
+ useStrictMode,
362
+ );
363
+ }
364
+ } else if (canvasRef.current) {
365
+ clearCanvas(canvasRef.current);
366
+ }
367
+
368
+ // Check number of faces
369
+ const numFaces = results.faceLandmarks ? results.faceLandmarks.length : 0;
370
+
371
+ // Check if face is detected
372
+ const hasFace =
373
+ results.faceBlendshapes &&
374
+ results.faceBlendshapes.length > 0 &&
375
+ numFaces >= 1;
376
+ faceDetected.value = hasFace;
377
+
378
+ if (hasFace && results.faceLandmarks) {
379
+ // Calculate face size and position
380
+ const faceSize = calculateFaceSize(results.faceLandmarks, {
381
+ rotationStable: useStrictMode,
382
+ });
383
+ currentFaceSize.value = faceSize;
384
+
385
+ // Proximity check with hysteresis: once the face is in the "good"
386
+ // band, the reading has to drift past the threshold before we flip
387
+ // back to too-close/too-far. Keep the margin small so legitimate
388
+ // edge cases are caught quickly.
389
+ const proximityMargin = 0.02;
390
+ const min = minFaceSize;
391
+ const max = maxFaceSize;
392
+ const current = faceProximity.value;
393
+ if (current === 'good') {
394
+ if (faceSize > max + proximityMargin) {
395
+ faceProximity.value = 'too-close';
396
+ } else if (faceSize < min - proximityMargin) {
397
+ faceProximity.value = 'too-far';
398
+ }
399
+ } else if (current === 'too-close') {
400
+ if (faceSize <= max) faceProximity.value = 'good';
401
+ } else if (current === 'too-far') {
402
+ if (faceSize >= min) faceProximity.value = 'good';
403
+ }
404
+
405
+ // Check face position. In strict mode the head will rotate, which
406
+ // legitimately widens the bounding box, so only the face centre is
407
+ // required to stay inside the oval.
408
+ faceInBounds.value = isFaceInBounds(
409
+ results.faceLandmarks,
410
+ videoAspectRatio.value,
411
+ { centerOnly: useStrictMode },
412
+ );
413
+
414
+ // Independent clipping check: project every landmark into the
415
+ // visible wrapper using runtime element rects, then test against
416
+ // the visible egg (approximated as a centred ellipse). If any
417
+ // landmark falls outside, the oval boundary is clipping the face.
418
+ // Used as an idle-only gating signal so head turns during capture
419
+ // don't trip it.
420
+ faceClippingOval.value = computeFaceClippingOval(
421
+ results.faceLandmarks[0],
422
+ videoRef.current,
423
+ );
424
+
425
+ // Directional nudge: only fire when the face is actually clipping
426
+ // (touching/crossing) the visible oval edge. The clipping side is
427
+ // derived from the single most-clipped landmark and its dominant
428
+ // axis. A face fully inside the oval gets `null` here — no nudge.
429
+ // (Mirror correction is handled inside computeFaceClippingSide.)
430
+ faceOffsetDirection.value = computeFaceClippingSide(
431
+ results.faceLandmarks[0],
432
+ videoRef.current,
433
+ );
434
+
435
+ // Get smile and mouth open data
436
+ const blendshapes = results.faceBlendshapes[0].categories;
437
+ const smileLeft =
438
+ blendshapes.find((b) => b.categoryName === 'mouthSmileLeft')?.score ||
439
+ 0;
440
+ const smileRight =
441
+ blendshapes.find((b) => b.categoryName === 'mouthSmileRight')
442
+ ?.score || 0;
443
+ const mouthOpen = calculateMouthOpening(results.faceLandmarks);
444
+ const smileScore = (smileLeft + smileRight) / 2;
445
+
446
+ currentSmileScore.value = smileScore;
447
+ currentMouthOpen.value = mouthOpen;
448
+
449
+ if (useStrictMode) {
450
+ // Pose detection requires landmarks, so it stays gated on face
451
+ // detection. Lighting/blur are now sampled outside this block —
452
+ // they need to keep running when the face is undetected (e.g.
453
+ // because the scene is too dark / motion-blurred to find a face).
454
+ const pose = calculateHeadPose(results.faceLandmarks);
455
+ const rawPose = classifyHeadPose(pose);
456
+
457
+ // 2-of-3 majority smoothing. atan2 noise (especially on left
458
+ // turns where x_diff approaches zero) causes single-tick drops
459
+ // below threshold; without smoothing those ticks dissolve a
460
+ // held pose and the capture interval keeps missing the window.
461
+ const history = poseHistoryRef.current;
462
+ history.push(rawPose);
463
+ if (history.length > 3) history.shift();
464
+ let smoothed: HeadPoseDirection | null = rawPose;
465
+ if (history.length === 3) {
466
+ const counts = new Map<HeadPoseDirection | null, number>();
467
+ for (let i = 0; i < history.length; i += 1) {
468
+ counts.set(history[i], (counts.get(history[i]) ?? 0) + 1);
469
+ }
470
+ let winner: HeadPoseDirection | null = null;
471
+ let winnerCount = 0;
472
+ counts.forEach((c, k) => {
473
+ if (c > winnerCount) {
474
+ winner = k;
475
+ winnerCount = c;
476
+ }
477
+ });
478
+ if (winnerCount >= 2) smoothed = winner;
479
+ }
480
+ currentPose.value = smoothed;
481
+
482
+ // Refresh the latch only when the smoothed pose matches the
483
+ // requested one *and* all the other capture gates are currently
484
+ // passing. Refreshing on pose alone lets the latch carry the
485
+ // user through a failing proximity/bounds/lighting check — they
486
+ // see the failure alert, but a moment later the capture tick
487
+ // fires off the residual latch and the pose silently passes.
488
+ // The latch should only represent "this is a moment we could
489
+ // have captured", not "the head was once turned correctly".
490
+ const requiredPose =
491
+ poseSequence.value[currentPoseIndex.value] ?? null;
492
+ const captureGatePasses =
493
+ isCapturing.value &&
494
+ !isPaused.value &&
495
+ faceInBounds.value &&
496
+ faceProximity.value === 'good' &&
497
+ !isTooDark.value &&
498
+ !isTooBlurry.value &&
499
+ faceOffsetDirection.value === null &&
500
+ !faceClippingOval.value;
501
+ if (
502
+ smoothed &&
503
+ requiredPose &&
504
+ smoothed === requiredPose &&
505
+ captureGatePasses
506
+ ) {
507
+ lastPoseMatchAtRef.current = Date.now();
508
+ } else if (!captureGatePasses) {
509
+ // Any failing quality check immediately invalidates the latch
510
+ // so a pass→fail→pass blip between capture ticks can't fire
511
+ // off a residual "recent match" while the user is still being
512
+ // told to move the device.
513
+ lastPoseMatchAtRef.current = 0;
514
+ }
515
+ }
516
+
517
+ if (smileScore >= smileThreshold && mouthOpen >= mouthOpenThreshold) {
518
+ lastSmileTime.value = Date.now();
519
+
520
+ if (isPaused.value && isCapturing.value && resumeCaptureRef.current) {
521
+ // defer execution
522
+ setTimeout(() => {
523
+ const stillSmiling = Date.now() - lastSmileTime.value <= 100;
524
+ if (
525
+ stillSmiling &&
526
+ isPaused.value &&
527
+ isCapturing.value &&
528
+ resumeCaptureRef.current
529
+ ) {
530
+ resumeCaptureRef.current();
531
+ }
532
+ }, 0);
533
+ }
534
+ }
535
+ } else {
536
+ // No face detected - reset face-derived values. Lighting/blur are
537
+ // intentionally NOT reset here: those checks are useful precisely
538
+ // when the face has gone undetected (dark room / motion blur) and
539
+ // are sampled separately below from the raw video frame.
540
+ currentSmileScore.value = 0;
541
+ currentFaceSize.value = 0;
542
+ currentMouthOpen.value = 0;
543
+ faceInBounds.value = false;
544
+ faceClippingOval.value = false;
545
+ faceProximity.value = 'good';
546
+ faceOffsetDirection.value = null;
547
+ if (useStrictMode) {
548
+ currentPose.value = null;
549
+ }
550
+ }
551
+
552
+ // Lighting/blur sampling — runs every frame regardless of face
553
+ // detection so the alert can fire even when the scene is too dark or
554
+ // shaky for the landmarker to find a face. Throttled with a frame
555
+ // counter so the canvas readback stays cheap.
556
+ if (useStrictMode && videoRef.current) {
557
+ qualityFrameCounter += 1;
558
+ if (qualityFrameCounter % 6 === 0) {
559
+ const luma = calculateLuminance(videoRef.current);
560
+ isTooDark.value = luma > 0 && luma < DEFAULT_LUMINANCE_MIN;
561
+ const blur = calculateBlurScore(videoRef.current);
562
+ isTooBlurry.value = blur > 0 && blur < DEFAULT_BLUR_MIN;
563
+ }
564
+ }
565
+
566
+ updateAlerts();
567
+ } catch {
568
+ faceDetected.value = false;
569
+ faceInBounds.value = false;
570
+ faceClippingOval.value = false;
571
+ faceProximity.value = 'good';
572
+ currentMouthOpen.value = 0;
573
+
574
+ if (isCapturing.value) {
575
+ updateAlert('no-face');
576
+ }
577
+ }
578
+
579
+ animationFrameRef.current = requestAnimationFrame(detectFace);
580
+ };
581
+
582
+ const startDetectionLoop = () => {
583
+ if (animationFrameRef.current) {
584
+ cancelAnimationFrame(animationFrameRef.current);
585
+ }
586
+ animationFrameRef.current = requestAnimationFrame(detectFace);
587
+ };
588
+
589
+ // Notify hosted-web inactivity timeout that the user is making progress.
590
+ // Fired on every successful capture so the 120s timer resets continuously
591
+ // as long as frames are being collected.
592
+ const dispatchProgress = () => {
593
+ document
594
+ .querySelector('smart-camera-web')
595
+ ?.dispatchEvent(new CustomEvent('metadata.active-liveness-progress'));
596
+ };
597
+
598
+ const captureImage = () => {
599
+ if (!videoRef.current) return;
600
+
601
+ // In strict (Active Liveness) mode the reference selfie is captured up-front
602
+ // in startCapture() while the user is still neutral. The per-pose captures
603
+ // here only ever populate the liveness array.
604
+ const isReference =
605
+ !useStrictMode && capturesTaken.value === totalCaptures.value - 1;
606
+ const imageData = captureImageFromVideo(videoRef.current, isReference);
607
+
608
+ if (!imageData) return;
609
+
610
+ if (isReference) {
611
+ referencePhoto.value = imageData;
612
+ } else {
613
+ capturedImages.value = [...capturedImages.value, imageData];
614
+ }
615
+
616
+ capturesTaken.value++;
617
+ countdown.value = totalCaptures.value - capturesTaken.value;
618
+ };
619
+
620
+ const stopCapture = () => {
621
+ if (captureTimerRef.current) {
622
+ clearInterval(captureTimerRef.current);
623
+ captureTimerRef.current = null;
624
+ }
625
+
626
+ isCapturing.value = false;
627
+ isPaused.value = false;
628
+
629
+ if (capturesTaken.value >= totalCaptures.value && referencePhoto.value) {
630
+ const livenessImages = capturedImages.value.map((img) => ({
631
+ image: img.split(',')[1],
632
+ image_type_id: ImageType.LIVENESS_IMAGE_BASE64,
633
+ }));
634
+
635
+ const referenceImage = {
636
+ image: referencePhoto.value.split(',')[1],
637
+ image_type_id: ImageType.SELFIE_IMAGE_BASE64,
638
+ };
639
+
640
+ const eventDetail = {
641
+ images: [...livenessImages, referenceImage],
642
+ referenceImage: referencePhoto.value,
643
+ previewImage: referencePhoto.value,
644
+ facingMode: getFacingMode(),
645
+ meta: { libraryVersion: COMPONENTS_VERSION },
646
+ };
647
+
648
+ if (onCaptureComplete) {
649
+ // Caller owns the payload — defer publish until they decide to emit.
650
+ onCaptureComplete(eventDetail);
651
+ } else {
652
+ window.dispatchEvent(
653
+ new CustomEvent('selfie-capture.publish', { detail: eventDetail }),
654
+ );
655
+ }
656
+
657
+ hasFinishedCapture.value = true;
658
+ }
659
+ };
660
+
661
+ const pauseCapture = () => {
662
+ if (captureTimerRef.current) {
663
+ clearInterval(captureTimerRef.current);
664
+ captureTimerRef.current = null;
665
+ }
666
+ isPaused.value = true;
667
+
668
+ if (
669
+ faceDetected.value &&
670
+ faceInBounds.value &&
671
+ faceProximity.value === 'good'
672
+ ) {
673
+ updateAlert('smile-required');
674
+ }
675
+ };
676
+
677
+ const startCaptureInterval = () => {
678
+ if (captureTimerRef.current) {
679
+ clearInterval(captureTimerRef.current);
680
+ }
681
+
682
+ captureTimerRef.current = setInterval(() => {
683
+ if (capturesTaken.value >= totalCaptures.value) {
684
+ stopCapture();
685
+ return;
686
+ }
687
+
688
+ if (!faceDetected.value) {
689
+ return;
690
+ }
691
+
692
+ if (!faceInBounds.value) {
693
+ // Strict mode has no smile-based resume path — calling pauseCapture
694
+ // here would clear the interval and leave the user stuck even after
695
+ // they recentre. Just skip this tick instead so the next frame
696
+ // re-evaluates once they're back in bounds.
697
+ if (useStrictMode) return;
698
+ pauseCapture();
699
+ return;
700
+ }
701
+
702
+ if (faceProximity.value !== 'good') {
703
+ if (useStrictMode) return;
704
+ pauseCapture();
705
+ return;
706
+ }
707
+
708
+ if (useStrictMode) {
709
+ // Strict mode: gate captures on (a) sufficient lighting/sharpness and
710
+ // (b) the user matching the currently-required head pose. The pose
711
+ // sequence advances once enough frames per pose have been collected.
712
+ if (isTooDark.value || isTooBlurry.value) {
713
+ // Same reason as above — skip rather than pause so the loop keeps
714
+ // ticking and resumes automatically once lighting/blur clears.
715
+ return;
716
+ }
717
+
718
+ // The UI surfaces faceOffsetDirection as a "Move device left/right"
719
+ // alert. The capture path has to honour the same gate, otherwise
720
+ // the user sees the alert but the pose silently passes anyway.
721
+ // Also gate on faceClippingOval — it flags any landmark past the
722
+ // visible egg edge without depending on axis-dominance, so it
723
+ // catches cases where the face is crossing the border but the
724
+ // bbox centre hasn't moved enough to pin a direction yet.
725
+ if (faceOffsetDirection.value !== null || faceClippingOval.value) {
726
+ return;
727
+ }
728
+
729
+ // Distribute the capture window evenly across the pose sequence:
730
+ // each pose gets `framesPerPose = floor(totalCaptures / poseCount)`
731
+ // frames. Any leftover frames (e.g. 8 captures across 3 poses → 2
732
+ // leftover) are taken silently up-front while the user is still
733
+ // neutral, giving the backend forward-facing liveness samples
734
+ // without burdening the user with an extra "look straight" prompt.
735
+ const poseCount = poseSequence.value.length;
736
+ const framesPerPose = Math.max(
737
+ 1,
738
+ Math.floor(totalCaptures.value / poseCount),
739
+ );
740
+ const silentNeutralFrames = Math.max(
741
+ 0,
742
+ totalCaptures.value - framesPerPose * poseCount,
743
+ );
744
+
745
+ if (capturesTaken.value < silentNeutralFrames) {
746
+ // Pre-pose neutral phase: snap whatever the user is showing now
747
+ // (they're already centred from the hold-still period). No pose
748
+ // gate, no prompt change — these frames feed liveness silently.
749
+ captureImage();
750
+ dispatchProgress();
751
+ return;
752
+ }
753
+
754
+ const requiredPose = poseSequence.value[currentPoseIndex.value];
755
+ if (!requiredPose) {
756
+ stopCapture();
757
+ return;
758
+ }
759
+ // Accept either an instantaneous match or a very recent one. The
760
+ // latch covers the case where the user held the pose between
761
+ // capture ticks but the smoothed reading drifted by the time this
762
+ // tick fired (e.g. they started relaxing back to neutral).
763
+ const poseMatched =
764
+ currentPose.value === requiredPose ||
765
+ Date.now() - lastPoseMatchAtRef.current <= POSE_MATCH_LATCH_MS;
766
+ if (!poseMatched) {
767
+ // Don't pause — we want the prompt to stay visible while the user
768
+ // adjusts. Just skip this tick.
769
+ return;
770
+ }
771
+
772
+ captureImage();
773
+ // Any successful capture counts as activity for the hosted-web
774
+ // inactivity timeout — even if the user is still on pose 1 and
775
+ // hasn't advanced the index yet.
776
+ dispatchProgress();
777
+
778
+ // Frames captured *within* the pose phase only (the leading neutral
779
+ // frames don't belong to any pose). Advance once we've collected
780
+ // enough for the current pose.
781
+ const poseFramesTaken = capturesTaken.value - silentNeutralFrames;
782
+ if (
783
+ poseFramesTaken > 0 &&
784
+ poseFramesTaken % framesPerPose === 0 &&
785
+ currentPoseIndex.value < poseCount - 1
786
+ ) {
787
+ currentPoseIndex.value += 1;
788
+ // Clear smoothing + latch so the previous pose can't satisfy
789
+ // the next one — each pose must be re-confirmed from scratch.
790
+ poseHistoryRef.current = [];
791
+ lastPoseMatchAtRef.current = 0;
792
+ }
793
+ return;
794
+ }
795
+
796
+ const isInSmileZone = capturesTaken.value >= smileCheckpoint.value;
797
+
798
+ if (isInSmileZone) {
799
+ const timeSinceSmile = Date.now() - lastSmileTime.value;
800
+ if (timeSinceSmile > smileCooldown) {
801
+ pauseCapture();
802
+ return;
803
+ }
804
+ }
805
+
806
+ captureImage();
807
+ }, interval);
808
+ };
809
+
810
+ const resumeCapture = () => {
811
+ if (
812
+ faceDetected.value &&
813
+ faceProximity.value === 'good' &&
814
+ faceInBounds.value
815
+ ) {
816
+ const isInSmileZone = capturesTaken.value >= smileCheckpoint.value;
817
+ if (isInSmileZone) {
818
+ const timeSinceSmile = Date.now() - lastSmileTime.value;
819
+ if (timeSinceSmile > smileCooldown) {
820
+ return;
821
+ }
822
+ }
823
+
824
+ isPaused.value = false;
825
+ updateAlert(null);
826
+ startCaptureInterval();
827
+ }
828
+ };
829
+
830
+ resumeCaptureRef.current = resumeCapture;
831
+
832
+ const startCapture = async () => {
833
+ capturedImages.value = [];
834
+ isCapturing.value = true;
835
+ isPaused.value = false;
836
+ totalCaptures.value = Math.ceil(duration / interval);
837
+ capturesTaken.value = 0;
838
+ countdown.value = totalCaptures.value;
839
+
840
+ if (useStrictMode) {
841
+ poseSequence.value = buildRandomPoseSequence();
842
+ currentPoseIndex.value = 0;
843
+ poseHistoryRef.current = [];
844
+ lastPoseMatchAtRef.current = 0;
845
+
846
+ // Snap the neutral selfie up-front so the result preview shows the
847
+ // user facing the camera, not whichever direction the last pose
848
+ // prompted them to turn.
849
+ if (videoRef.current) {
850
+ const neutralReference = captureImageFromVideo(videoRef.current, true);
851
+ if (neutralReference) {
852
+ referencePhoto.value = neutralReference;
853
+ }
854
+ }
855
+ }
856
+
857
+ const smartCameraWeb = document.querySelector('smart-camera-web');
858
+ smartCameraWeb?.dispatchEvent(
859
+ new CustomEvent('metadata.selfie-capture-start'),
860
+ );
861
+ smartCameraWeb?.dispatchEvent(
862
+ new CustomEvent('metadata.active-liveness-type', {
863
+ detail: { type: useStrictMode ? 'head_pose' : 'smile_detection' },
864
+ }),
865
+ );
866
+ smartCameraWeb?.dispatchEvent(
867
+ new CustomEvent('metadata.selfie-origin', {
868
+ detail: {
869
+ imageOrigin: { environment: 'back_camera', user: 'front_camera' }[
870
+ getFacingMode()
871
+ ],
872
+ },
873
+ }),
874
+ );
875
+
876
+ startCaptureInterval();
877
+ };
878
+
879
+ const handleCancel = () => {
880
+ stopCapture();
881
+ window.dispatchEvent(
882
+ new CustomEvent('selfie-capture.cancelled', {
883
+ detail: { meta: { libraryVersion: COMPONENTS_VERSION } },
884
+ }),
885
+ );
886
+
887
+ // TODO: remove - for backwards compatibility
888
+ window.dispatchEvent(
889
+ new CustomEvent('selfie-capture-screens.cancelled', {
890
+ detail: { meta: { libraryVersion: COMPONENTS_VERSION } },
891
+ }),
892
+ );
893
+ };
894
+
895
+ /**
896
+ * Force-finalise the capture session with a failure reason. Used by the
897
+ * hosted-web active-liveness inactivity timer (and any other host-driven
898
+ * fail-fast path) to submit whatever frames have been captured so far
899
+ * tagged with a reason the backend can use to record the failure.
900
+ *
901
+ * The payload mirrors the normal completion shape so the existing publish
902
+ * path doesn't need to special-case it; the only extra field is
903
+ * `forceFailureReason`, which downstream submission handlers forward as
904
+ * a structured `failure_reason` metadata entry (e.g.
905
+ * `{ mobile_active_liveness_timed_out: true }`).
906
+ */
907
+ const forceFailCapture = (reason: string) => {
908
+ // Stop the capture interval and detection loop; we're not going to take
909
+ // any more frames after this point.
910
+ if (captureTimerRef.current) {
911
+ clearInterval(captureTimerRef.current);
912
+ captureTimerRef.current = null;
913
+ }
914
+ isCapturing.value = false;
915
+ isPaused.value = false;
916
+
917
+ // Top up the liveness buffer to the full expected count by grabbing
918
+ // whatever the camera currently shows. The backend rejects partial
919
+ // submissions, so on timeout we'd rather submit `totalCaptures` "random"
920
+ // frames tagged with `forceFailureReason` than discard the session.
921
+ // The frames are captured back-to-back from the live <video> element,
922
+ // which gives us slight motion between them in practice.
923
+ if (videoRef.current) {
924
+ const target = totalCaptures.value;
925
+ while (capturedImages.value.length < target) {
926
+ const frame = captureImageFromVideo(videoRef.current, false);
927
+ if (!frame) break;
928
+ capturedImages.value = [...capturedImages.value, frame];
929
+ }
930
+ // Reference selfie may not have been snapped yet (e.g. timeout fired
931
+ // before startCapture's up-front grab). Fall back to a live frame so
932
+ // the payload always carries a reference image.
933
+ if (!referencePhoto.value) {
934
+ const ref = captureImageFromVideo(videoRef.current, true);
935
+ if (ref) referencePhoto.value = ref;
936
+ }
937
+ }
938
+
939
+ const livenessImages = capturedImages.value.map((img) => ({
940
+ image: img.split(',')[1],
941
+ image_type_id: ImageType.LIVENESS_IMAGE_BASE64,
942
+ }));
943
+
944
+ const reference = referencePhoto.value;
945
+ const referenceImage = reference
946
+ ? {
947
+ image: reference.split(',')[1],
948
+ image_type_id: ImageType.SELFIE_IMAGE_BASE64,
949
+ }
950
+ : null;
951
+
952
+ const eventDetail = {
953
+ images: referenceImage
954
+ ? [...livenessImages, referenceImage]
955
+ : livenessImages,
956
+ referenceImage: reference ?? '',
957
+ previewImage: reference ?? '',
958
+ facingMode: getFacingMode(),
959
+ forceFailureReason: reason,
960
+ meta: { libraryVersion: COMPONENTS_VERSION },
961
+ };
962
+
963
+ if (onCaptureComplete) {
964
+ onCaptureComplete(eventDetail);
965
+ } else {
966
+ window.dispatchEvent(
967
+ new CustomEvent('selfie-capture.publish', { detail: eventDetail }),
968
+ );
969
+ }
970
+
971
+ hasFinishedCapture.value = true;
972
+ };
973
+
974
+ const handleClose = () => {
975
+ stopCapture();
976
+
977
+ window.dispatchEvent(
978
+ new CustomEvent('selfie-capture.close', {
979
+ detail: { meta: { libraryVersion: COMPONENTS_VERSION } },
980
+ }),
981
+ );
982
+
983
+ // TODO: remove - backwards compatibility
984
+ window.dispatchEvent(
985
+ new CustomEvent('selfie-capture-screens.close', {
986
+ detail: { meta: { libraryVersion: COMPONENTS_VERSION } },
987
+ }),
988
+ );
989
+ };
990
+
991
+ const cleanup = () => {
992
+ if (captureTimerRef.current) {
993
+ clearInterval(captureTimerRef.current);
994
+ }
995
+ if (fallbackTimerRef.current) {
996
+ clearTimeout(fallbackTimerRef.current);
997
+ }
998
+ stopDetectionLoop();
999
+ updateAlert.cancel();
1000
+ };
1001
+
1002
+ const resetFaceDetectionState = () => {
1003
+ faceDetected.value = false;
1004
+ faceInBounds.value = false;
1005
+ faceClippingOval.value = false;
1006
+ faceProximity.value = 'good';
1007
+ faceLandmarks.value = [];
1008
+ currentSmileScore.value = 0;
1009
+ currentFaceSize.value = 0;
1010
+ currentMouthOpen.value = 0;
1011
+ lastSmileTime.value = 0;
1012
+ captureButtonFallbackEnabled.value = false;
1013
+ currentPose.value = null;
1014
+ isTooDark.value = false;
1015
+ isTooBlurry.value = false;
1016
+ qualityFrameCounter = 0;
1017
+ if (fallbackTimerRef.current) {
1018
+ clearTimeout(fallbackTimerRef.current);
1019
+ fallbackTimerRef.current = null;
1020
+ }
1021
+
1022
+ if (canvasRef.current) {
1023
+ clearCanvas(canvasRef.current);
1024
+ }
1025
+ };
1026
+
1027
+ return {
1028
+ faceDetected,
1029
+ faceInBounds,
1030
+ faceClippingOval,
1031
+ faceProximity,
1032
+ videoAspectRatio,
1033
+ faceLandmarks,
1034
+ currentSmileScore,
1035
+ currentFaceSize,
1036
+ currentMouthOpen,
1037
+ lastSmileTime,
1038
+ alertTitle,
1039
+ isInitializing,
1040
+ isReadyToCapture,
1041
+ captureButtonFallbackEnabled,
1042
+
1043
+ isCapturing,
1044
+ isPaused,
1045
+ countdown,
1046
+ capturedImages,
1047
+ referencePhoto,
1048
+ totalCaptures,
1049
+ capturesTaken,
1050
+ hasFinishedCapture,
1051
+ smileCheckpoint,
1052
+ neutralZone,
1053
+ poseSequence,
1054
+ currentPoseIndex,
1055
+ currentPose,
1056
+ isTooDark,
1057
+ isTooBlurry,
1058
+ faceOffsetDirection,
1059
+
1060
+ initializeFaceLandmarker,
1061
+ setupCanvas,
1062
+ startDetectionLoop,
1063
+ stopDetectionLoop,
1064
+ updateAlert,
1065
+ startCapture,
1066
+ stopCapture,
1067
+ pauseCapture,
1068
+ resumeCapture,
1069
+ handleCancel,
1070
+ forceFailCapture,
1071
+ handleClose,
1072
+ cleanup,
1073
+ resetFaceDetectionState,
1074
+ };
1075
+ };