@smileid/web-components 11.4.4 → 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 (84) hide show
  1. package/dist/esm/{DocumentCaptureScreens-bLFW-yEM.js → DocumentCaptureScreens-ucJDu5nH.js} +555 -2470
  2. package/dist/esm/DocumentCaptureScreens-ucJDu5nH.js.map +1 -0
  3. package/dist/esm/{EndUserConsent-D26UoVk5.js → EndUserConsent-CsiwoThZ.js} +3 -3
  4. package/dist/esm/{EndUserConsent-D26UoVk5.js.map → EndUserConsent-CsiwoThZ.js.map} +1 -1
  5. package/dist/esm/{Navigation-nvehze1F.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-owUOdKzP.js → TotpConsent-CRtmtudl.js} +2 -2
  10. package/dist/esm/{TotpConsent-owUOdKzP.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-5Nn2kzHI.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 -40
  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 +13 -0
  28. package/lib/components/navigation/src/Navigation.js +27 -8
  29. package/lib/components/selfie/src/SelfieCaptureScreens.js +139 -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 +163 -17
  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/selfie/src/smartselfie-capture/utils/mediapipeManager.ts +145 -9
  74. package/lib/components/signature-pad/package.json +1 -1
  75. package/lib/components/smart-camera-web/src/SmartCameraWeb.js +70 -11
  76. package/lib/domain/localisation/index.js +2 -2
  77. package/package.json +3 -3
  78. package/dist/esm/DocumentCaptureScreens-bLFW-yEM.js.map +0 -1
  79. package/dist/esm/Navigation-nvehze1F.js.map +0 -1
  80. package/dist/esm/SelfieCaptureScreens-BXIs6_tl.js +0 -7522
  81. package/dist/esm/SelfieCaptureScreens-BXIs6_tl.js.map +0 -1
  82. package/dist/esm/index-5Nn2kzHI.js +0 -1360
  83. package/dist/esm/package-DmH-I6GW.js +0 -565
  84. package/dist/esm/package-DmH-I6GW.js.map +0 -1
@@ -6,6 +6,7 @@ import type { FunctionComponent } from 'preact';
6
6
  import { getBoolProp } from '../../../../utils/props';
7
7
  import { translate, translateHtml } from '../../../../domain/localisation';
8
8
  import SmartSelfieCapture from '../smartselfie-capture/SmartSelfieCapture';
9
+ import EnhancedSmartSelfieCapture from '../enhanced-smartselfie-capture/EnhancedSmartSelfieCapture';
9
10
  // Legacy web component fallback (used when Mediapipe isn't available)
10
11
  import '../selfie-capture/SelfieCapture';
11
12
  // Mediapipe loader/manager used by SmartSelfieCapture
@@ -40,6 +41,8 @@ interface Props {
40
41
  'show-agent-mode-for-tests'?: string | boolean;
41
42
  'hide-attribution'?: string | boolean;
42
43
  'disable-image-tests'?: string | boolean;
44
+ 'use-strict-mode'?: string | boolean;
45
+ 'show-back-on-guidelines'?: string | boolean;
43
46
  key?: string;
44
47
  'start-countdown'?: string | boolean;
45
48
  hidden?: string | boolean;
@@ -60,6 +63,11 @@ const DEFAULT_WAIT_MS = 20 * 1000;
60
63
  // exponential backoff (base * 2^(attempt-1)) so we don't hammer the CDN.
61
64
  const MAX_MEDIAPIPE_INIT_ATTEMPTS = 3;
62
65
  const MEDIAPIPE_RETRY_BASE_DELAY_MS = 500;
66
+ // Cap user-initiated retries from the failure screen. Each tap resets the
67
+ // per-attempt counter (granting another `MAX_MEDIAPIPE_INIT_ATTEMPTS` round of
68
+ // internal retries), so without this cap a frustrated or malicious user could
69
+ // hammer Retry indefinitely and generate unbounded requests to the model CDN.
70
+ const MAX_USER_RETRIES = 3;
63
71
 
64
72
  // Wrapper component that decides whether to use the modern
65
73
  // SmartSelfieCapture (Mediapipe-based) or fallback to the legacy `selfie-capture`
@@ -68,6 +76,7 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
68
76
  timeout,
69
77
  'start-countdown': startCountdownProp = false,
70
78
  'allow-legacy-selfie-fallback': allowLegacySelfieFallbackProp = false,
79
+ 'use-strict-mode': useStrictModeProp = false,
71
80
  hidden: hiddenProp = false,
72
81
  ...props
73
82
  }) => {
@@ -89,9 +98,23 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
89
98
  (window.navigator.userAgent.includes('Electron') &&
90
99
  (window as any).__Cypress);
91
100
 
101
+ // Test-only seam: the `isCypress` short-circuit below skips the whole
102
+ // MediaPipe load path (so most specs run fast against the legacy fallback).
103
+ // A spec that wants to exercise the real load/spinner/error/Retry path sets
104
+ // `window.__SMILE_ID_TEST_FORCE_MEDIAPIPE_LOAD__ = true` to opt out of that
105
+ // short-circuit. When the flag is unset, `skipMediapipeForTests === isCypress`,
106
+ // so production and existing-test behaviour are unchanged.
107
+ const forceMediapipeLoad = !!(window as any)
108
+ .__SMILE_ID_TEST_FORCE_MEDIAPIPE_LOAD__;
109
+ const skipMediapipeForTests = isCypress && !forceMediapipeLoad;
110
+
92
111
  const hidden = getBoolProp(hiddenProp);
93
112
  const startCountdown = getBoolProp(startCountdownProp);
94
- const allowLegacySelfieFallback = getBoolProp(allowLegacySelfieFallbackProp);
113
+ const useStrictMode = getBoolProp(useStrictModeProp);
114
+ // Strict mode (Enhanced SmartSelfie) requires Mediapipe head-pose detection,
115
+ // so the legacy fallback is force-disabled regardless of the partner setting.
116
+ const allowLegacySelfieFallback =
117
+ !useStrictMode && getBoolProp(allowLegacySelfieFallbackProp);
95
118
 
96
119
  // Resolve how long we'll wait for Mediapipe before the hard deadline fires.
97
120
  // Precedence:
@@ -122,11 +145,37 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
122
145
  // - initialSessionCompleted: set when the legacy component emits publish/cancel/close
123
146
  // - mediapipeLoading: true while attempting to load mediapipe
124
147
  // - usingSelfieCapture: whether we've mounted the legacy `selfie-capture` element
125
- const [mediapipeReady, setMediapipeReady] = useState(false);
126
- const [loadingProgress, setLoadingProgress] = useState(isCypress ? 100 : 0);
127
- const [loadDeadlineExceeded, setLoadDeadlineExceeded] = useState(isCypress);
148
+ // If MediaPipe already loaded earlier in this session (e.g. we're remounting
149
+ // after returning from document capture), reuse the cached singleton instance
150
+ // immediately instead of showing the loading spinner again. The model and
151
+ // WASM are already in memory, so no network call is needed.
152
+ const mediapipeAlreadyLoaded = !!(
153
+ window.__smileIdentityMediapipe?.loaded &&
154
+ window.__smileIdentityMediapipe?.instance
155
+ );
156
+
157
+ const [mediapipeReady, setMediapipeReady] = useState(mediapipeAlreadyLoaded);
158
+ const [loadingProgress, setLoadingProgress] = useState(
159
+ skipMediapipeForTests || mediapipeAlreadyLoaded ? 100 : 0,
160
+ );
161
+ const [loadDeadlineExceeded, setLoadDeadlineExceeded] = useState(
162
+ skipMediapipeForTests,
163
+ );
128
164
  const [initialSessionCompleted, setInitialSessionCompleted] = useState(false);
129
165
  const [mediapipeLoading, setMediapipeLoading] = useState(false);
166
+ // Concurrency guard for the load effect. Kept in a ref — NOT in
167
+ // `mediapipeLoading` state — so the effect's guard does not depend on a value
168
+ // the effect itself sets. If it did (as it used to), calling
169
+ // `setMediapipeLoading(true)` would re-run the effect, whose cleanup flips
170
+ // `cancelled` true, and a slow `getMediapipeInstance()` would then resolve
171
+ // into a cancelled closure — never calling `setMediapipeReady(true)`. That
172
+ // left the UI stuck on the loading spinner even though MediaPipe loaded
173
+ // successfully (intermittent: only when the load lost the race to the
174
+ // re-render).
175
+ const mediapipeLoadingRef = useRef(false);
176
+ // Bumped to re-trigger the load effect for a bounded retry after a transient
177
+ // failure (replaces re-using `mediapipeLoading` as the re-trigger signal).
178
+ const [mediapipeRetryTick, setMediapipeRetryTick] = useState(0);
130
179
  // `unsupportedEnvironment` is a permanent, one-shot signal: we know
131
180
  // MediaPipe cannot run here, so stop trying.
132
181
  const [unsupportedEnvironment, setUnsupportedEnvironment] = useState(false);
@@ -137,6 +186,9 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
137
186
  // Dedup flag so we only report a given init failure to Sentry once per
138
187
  // wrapper instance, even if we end up retrying. Ref for the same reason.
139
188
  const mediapipeInitReportedRef = useRef(false);
189
+ // User-initiated Retry presses are also bounded — see `MAX_USER_RETRIES`.
190
+ const userRetryCountRef = useRef(0);
191
+ const [retriesExhausted, setRetriesExhausted] = useState(false);
140
192
  const [usingSelfieCapture, setUsingSelfieCapture] = useState(false);
141
193
 
142
194
  // Attempt to load Mediapipe (with a small bounded retry budget). If
@@ -147,27 +199,33 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
147
199
  useEffect(() => {
148
200
  if (
149
201
  mediapipeReady ||
150
- mediapipeLoading ||
202
+ mediapipeLoadingRef.current ||
151
203
  unsupportedEnvironment ||
152
204
  mediapipeInitAttemptsRef.current >= MAX_MEDIAPIPE_INIT_ATTEMPTS ||
153
- isCypress
205
+ skipMediapipeForTests
154
206
  )
155
207
  return undefined;
156
208
 
157
209
  let cancelled = false;
158
210
  let retryTimeoutId: ReturnType<typeof setTimeout> | null = null;
159
211
 
212
+ // Mark loading via the ref (effect guard) and the state (UI). The state is
213
+ // intentionally NOT in this effect's deps — see `mediapipeLoadingRef`.
214
+ mediapipeLoadingRef.current = true;
215
+ setMediapipeLoading(true);
216
+
160
217
  const loadMediapipe = async () => {
161
- setMediapipeLoading(true);
162
218
  const attemptNumber = mediapipeInitAttemptsRef.current + 1;
163
219
  mediapipeInitAttemptsRef.current = attemptNumber;
164
220
  try {
165
221
  await getMediapipeInstance();
166
222
  if (cancelled) return;
223
+ mediapipeLoadingRef.current = false;
167
224
  setMediapipeReady(true);
168
225
  setMediapipeLoading(false);
169
226
  } catch (error) {
170
227
  if (cancelled) return;
228
+ mediapipeLoadingRef.current = false;
171
229
  // Loading failed; we'll fall back to the legacy selfie-capture component
172
230
  // after the loadingProgress reaches 100% (or sooner for definitively
173
231
  // unsupported environments — see below).
@@ -215,6 +273,9 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
215
273
  retryTimeoutId = null;
216
274
  if (cancelled) return;
217
275
  setMediapipeLoading(false);
276
+ // Re-trigger the effect for the next attempt via a dedicated counter
277
+ // rather than toggling `mediapipeLoading` (which is no longer a dep).
278
+ setMediapipeRetryTick((tick) => tick + 1);
218
279
  }, backoffMs);
219
280
  }
220
281
  };
@@ -223,11 +284,14 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
223
284
 
224
285
  return () => {
225
286
  cancelled = true;
287
+ mediapipeLoadingRef.current = false;
226
288
  if (retryTimeoutId !== null) {
227
289
  clearTimeout(retryTimeoutId);
228
290
  }
229
291
  };
230
- }, [mediapipeReady, mediapipeLoading, unsupportedEnvironment]);
292
+ // `mediapipeLoading` is deliberately excluded: it is set inside this effect,
293
+ // so depending on it would re-run the effect and cancel the in-flight load.
294
+ }, [mediapipeReady, unsupportedEnvironment, mediapipeRetryTick]);
231
295
 
232
296
  // Cosmetic loading progress: ticks 0→100 over `loadingTime` so the UI can
233
297
  // show "slow connection" copy past the SLOW_CONNECTION_THRESHOLD. This is
@@ -249,14 +313,21 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
249
313
  return () => {
250
314
  clearInterval(timer);
251
315
  };
252
- }, [hidden, startCountdown, loadingTime, mediapipeReady]);
316
+ // `mediapipeRetryTick` restarts the cosmetic progress when the user taps
317
+ // Retry (which resets `loadingProgress` to 0).
318
+ }, [hidden, startCountdown, loadingTime, mediapipeReady, mediapipeRetryTick]);
253
319
 
254
320
  // Hard deadline: a single setTimeout that flips `loadDeadlineExceeded`
255
321
  // exactly once. This is the signal the render path uses to commit to the
256
322
  // fallback. Skipped when hidden, when Mediapipe is already ready, or under
257
323
  // Cypress (where the flag is pre-seeded to true).
258
324
  useEffect(() => {
259
- if (hidden || mediapipeReady || loadDeadlineExceeded || isCypress)
325
+ if (
326
+ hidden ||
327
+ mediapipeReady ||
328
+ loadDeadlineExceeded ||
329
+ skipMediapipeForTests
330
+ )
260
331
  return undefined;
261
332
 
262
333
  const id = setTimeout(() => {
@@ -264,7 +335,13 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
264
335
  }, loadingTime);
265
336
 
266
337
  return () => clearTimeout(id);
267
- }, [hidden, mediapipeReady, loadDeadlineExceeded, loadingTime, isCypress]);
338
+ }, [
339
+ hidden,
340
+ mediapipeReady,
341
+ loadDeadlineExceeded,
342
+ loadingTime,
343
+ skipMediapipeForTests,
344
+ ]);
268
345
 
269
346
  // Latch the legacy fallback decision in an effect rather than during
270
347
  // render. Effects only run after commit, so by the time this runs, any
@@ -274,7 +351,8 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
274
351
  useEffect(() => {
275
352
  if (hidden || usingSelfieCapture || mediapipeReady) return;
276
353
  if (!loadDeadlineExceeded) return;
277
- const legacyFallbackAllowed = allowLegacySelfieFallback || isCypress;
354
+ const legacyFallbackAllowed =
355
+ allowLegacySelfieFallback || skipMediapipeForTests;
278
356
  if (!legacyFallbackAllowed) return;
279
357
  setUsingSelfieCapture(true);
280
358
  }, [
@@ -283,7 +361,7 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
283
361
  mediapipeReady,
284
362
  loadDeadlineExceeded,
285
363
  allowLegacySelfieFallback,
286
- isCypress,
364
+ skipMediapipeForTests,
287
365
  ]);
288
366
 
289
367
  useEffect(() => {
@@ -367,10 +445,45 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
367
445
  );
368
446
  }, [usingSelfieCapture, hidden, mediapipeLoading]);
369
447
 
448
+ // Retry from the failure screen: clear the give-up state and re-arm the load
449
+ // effect, deadline, and cosmetic progress so we make a fresh attempt. We reset
450
+ // `unsupportedEnvironment` so a genuinely unsupported device fails fast again
451
+ // (rather than spinning for the full deadline) instead of being permanently
452
+ // latched off. User taps are capped by `MAX_USER_RETRIES` so a frustrated or
453
+ // malicious user can't hammer Retry indefinitely and generate unbounded
454
+ // requests to the model CDN.
455
+ const handleRetry = () => {
456
+ if (userRetryCountRef.current >= MAX_USER_RETRIES) {
457
+ setRetriesExhausted(true);
458
+ return;
459
+ }
460
+ userRetryCountRef.current += 1;
461
+ mediapipeInitAttemptsRef.current = 0;
462
+ mediapipeInitReportedRef.current = false;
463
+ mediapipeLoadingRef.current = false;
464
+ setUnsupportedEnvironment(false);
465
+ setLoadDeadlineExceeded(false);
466
+ setLoadingProgress(0);
467
+ setMediapipeRetryTick((tick) => tick + 1);
468
+ };
469
+
370
470
  if (hidden) {
371
471
  return null;
372
472
  }
373
473
 
474
+ // Strict mode (Enhanced SmartSelfie) owns its own loading UX. Mount it
475
+ // immediately — the guidelines screen doesn't need Mediapipe, and by the
476
+ // time the user reaches the capture screen the background load started by
477
+ // `useFaceCapture.initializeFaceLandmarker()` will normally have resolved.
478
+ if (useStrictMode) {
479
+ return (
480
+ <>
481
+ <style>{`:host { display: block; height: 100%; }`}</style>
482
+ <EnhancedSmartSelfieCapture {...(props as any)} />
483
+ </>
484
+ );
485
+ }
486
+
374
487
  // on retakes, prefer SmartSelfieCapture if Mediapipe is ready
375
488
  if (initialSessionCompleted && mediapipeReady && !usingSelfieCapture) {
376
489
  return <SmartSelfieCapture {...props} />;
@@ -427,14 +540,45 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
427
540
  );
428
541
  }
429
542
 
430
- // Hard deadline elapsed and legacy fallback isn't allowed: show the
431
- // connection error.
432
- if (loadDeadlineExceeded && !(allowLegacySelfieFallback || isCypress)) {
543
+ // Hard deadline elapsed and legacy fallback isn't allowed: show an actionable
544
+ // error. The network is usually fine here (the hold-up is on-device setup), so
545
+ // only blame the connection when the browser actually reports being offline —
546
+ // otherwise frame it as a setup problem. Always offer a Retry control.
547
+ if (
548
+ loadDeadlineExceeded &&
549
+ !(allowLegacySelfieFallback || skipMediapipeForTests)
550
+ ) {
551
+ const isOffline =
552
+ typeof navigator !== 'undefined' && navigator.onLine === false;
553
+ const errorKey = isOffline
554
+ ? 'selfie.capture.loading.offlineError'
555
+ : 'selfie.capture.loading.setupError';
556
+ const themeColor = (props as Record<string, string>)['theme-color'];
557
+
433
558
  return (
434
559
  <div style={{ textAlign: 'center', marginTop: '20%', padding: '0 20px' }}>
435
560
  <p style={{ fontSize: '1.2rem', color: '#333' }}>
436
- {translate('selfie.capture.loading.connectionError')}
561
+ {translate(errorKey)}
437
562
  </p>
563
+ <button
564
+ type="button"
565
+ onClick={handleRetry}
566
+ disabled={retriesExhausted}
567
+ style={{
568
+ marginTop: '16px',
569
+ padding: '0.75rem 1.5rem',
570
+ borderRadius: '2.5rem',
571
+ border: 'none',
572
+ backgroundColor: themeColor || '#001096',
573
+ color: '#fff',
574
+ fontSize: '1rem',
575
+ fontWeight: 600,
576
+ cursor: retriesExhausted ? 'not-allowed' : 'pointer',
577
+ opacity: retriesExhausted ? 0.6 : 1,
578
+ }}
579
+ >
580
+ {translate('selfie.capture.loading.retry')}
581
+ </button>
438
582
  </div>
439
583
  );
440
584
  }
@@ -490,6 +634,8 @@ if (!customElements.get('selfie-capture-wrapper')) {
490
634
  'show-agent-mode-for-tests',
491
635
  'hide-attribution',
492
636
  'disable-image-tests',
637
+ 'use-strict-mode',
638
+ 'show-back-on-guidelines',
493
639
  'key',
494
640
  'start-countdown',
495
641
  'hidden',
@@ -237,11 +237,11 @@ const SmartSelfieCapture: FunctionComponent<Props> = ({
237
237
  font-family: "DM Sans", sans-serif;
238
238
  cursor: pointer;
239
239
  }
240
-
240
+
241
241
  button.btn-primary:hover {
242
242
  background-color: #2d2b2a;
243
243
  }
244
-
244
+
245
245
  button.btn-primary:disabled {
246
246
  background-color: #666;
247
247
  cursor: not-allowed;
@@ -336,6 +336,17 @@ export const useFaceCapture = ({
336
336
  }, 0);
337
337
  }
338
338
  }
339
+
340
+ // Before the smile zone, resume automatically when face returns to a
341
+ // valid position — no smile is needed yet.
342
+ if (
343
+ isPaused.value &&
344
+ isCapturing.value &&
345
+ capturesTaken.value < smileCheckpoint.value &&
346
+ resumeCaptureRef.current
347
+ ) {
348
+ resumeCaptureRef.current();
349
+ }
339
350
  } else {
340
351
  // No face detected - reset values
341
352
  currentSmileScore.value = 0;
@@ -414,9 +425,7 @@ export const useFaceCapture = ({
414
425
  };
415
426
 
416
427
  window.dispatchEvent(
417
- new CustomEvent('selfie-capture.publish', {
418
- detail: eventDetail,
419
- }),
428
+ new CustomEvent('selfie-capture.publish', { detail: eventDetail }),
420
429
  );
421
430
 
422
431
  hasFinishedCapture.value = true;
@@ -515,10 +524,9 @@ export const useFaceCapture = ({
515
524
  smartCameraWeb?.dispatchEvent(
516
525
  new CustomEvent('metadata.selfie-origin', {
517
526
  detail: {
518
- imageOrigin: {
519
- environment: 'back_camera',
520
- user: 'front_camera',
521
- }[getFacingMode()],
527
+ imageOrigin: { environment: 'back_camera', user: 'front_camera' }[
528
+ getFacingMode()
529
+ ],
522
530
  },
523
531
  }),
524
532
  );
@@ -1,8 +1,8 @@
1
- import { DrawingUtils, FaceLandmarker } from '@mediapipe/tasks-vision';
2
-
3
1
  /**
4
2
  * Create a cropped square canvas from video for face detection
5
3
  */
4
+ import { DrawingUtils, FaceLandmarker } from '@mediapipe/tasks-vision';
5
+
6
6
  export const createCroppedVideoFrame = (
7
7
  videoElement: HTMLVideoElement,
8
8
  ): HTMLCanvasElement | null => {
@@ -51,16 +51,14 @@ export const drawFaceMesh = (
51
51
  const canvasHeight = canvas.height;
52
52
 
53
53
  ctx.clearRect(0, 0, canvasWidth, canvasHeight);
54
- const drawingUtils = new DrawingUtils(ctx);
55
54
 
56
- // use this if scaling is needed
57
- // const scaleFactor = Math.sqrt(canvasWidth * canvasHeight) / 500;
55
+ const drawingUtils = new DrawingUtils(ctx);
58
56
 
59
57
  landmarks.forEach((landmark) => {
60
58
  if (!landmark || landmark.length === 0) return;
61
59
 
62
60
  const outlineColor = 'rgba(162, 155, 254,0.4)';
63
- const lineWidth = 2; // Math.max(1, scaleFactor * 2);
61
+ const lineWidth = 2;
64
62
  ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)';
65
63
  ctx.lineWidth = lineWidth;
66
64
  ctx.lineCap = 'round';
@@ -122,6 +122,62 @@ export class UnsupportedMediapipeEnvironmentError extends Error {
122
122
  }
123
123
  }
124
124
 
125
+ /**
126
+ * @description Thrown when `FaceLandmarker.createFromOptions` does not settle
127
+ * within {@link MEDIAPIPE_INIT_TIMEOUT_MS}. The WASM and model assets download
128
+ * over the network, but the subsequent WASM compile + GPU/CPU graph
129
+ * initialization runs on-device and can stall indefinitely on some drivers.
130
+ * Without a timeout the cached `loading` promise never resolves nor rejects,
131
+ * which (a) keeps the loading UI spinning until the wrapper's hard deadline and
132
+ * (b) poisons the singleton so retries/remounts await the same stuck promise.
133
+ * Treated as a transient failure by callers so the bounded retry can re-run.
134
+ */
135
+ export class MediapipeInitTimeoutError extends Error {
136
+ constructor(message: string) {
137
+ super(message);
138
+ this.name = 'MediapipeInitTimeoutError';
139
+ }
140
+ }
141
+
142
+ // Last-resort hang guard for `createFromOptions`. This call also downloads the
143
+ // ~3MB model, so the budget must cover a slow/uncached fetch as well as the
144
+ // on-device init — hence it is deliberately generous. It exists only so a truly
145
+ // wedged init eventually rejects (letting the wrapper's bounded retry / hard
146
+ // deadline take over) rather than hanging forever; it is NOT used to decide the
147
+ // GPU→CPU fallback (that keys off real init errors — see getMediapipeInstance).
148
+ const MEDIAPIPE_INIT_TIMEOUT_MS = 45000;
149
+
150
+ /**
151
+ * @description Races a promise against a timeout. On timeout, rejects with a
152
+ * {@link MediapipeInitTimeoutError}. The underlying promise is not (and cannot
153
+ * be) aborted — we just stop awaiting it so callers can recover.
154
+ * @param {Promise<T>} promise The work to bound.
155
+ * @param {number} ms Timeout in milliseconds.
156
+ * @param {string} message Message for the timeout error.
157
+ * @returns {Promise<T>} Resolves/rejects with the promise, or rejects on timeout.
158
+ */
159
+ const withTimeout = <T>(
160
+ promise: Promise<T>,
161
+ ms: number,
162
+ message: string,
163
+ ): Promise<T> =>
164
+ new Promise<T>((resolve, reject) => {
165
+ const timeoutId = setTimeout(() => {
166
+ reject(new MediapipeInitTimeoutError(message));
167
+ }, ms);
168
+
169
+ promise.then(
170
+ (value) => {
171
+ clearTimeout(timeoutId);
172
+ resolve(value);
173
+ },
174
+ (error) => {
175
+ clearTimeout(timeoutId);
176
+ reject(error);
177
+ },
178
+ );
179
+ });
180
+
125
181
  /**
126
182
  * @description Reads system architecture hints from User-Agent Client Hints.
127
183
  * @returns {Promise<string | null>} Lower-cased hint string or null when hints are unavailable.
@@ -207,6 +263,28 @@ const hasFP16Support = () => {
207
263
  return !!(hasHalfFloatExt && hasColorBufferHalfFloat && hasHalfFloatLinear);
208
264
  };
209
265
 
266
+ /**
267
+ * @description Creates a FaceLandmarker with the given compute delegate.
268
+ * Extracted so the init can be retried with a different delegate without
269
+ * duplicating the options.
270
+ * @param {Awaited<ReturnType<typeof FilesetResolver.forVisionTasks>>} vision Resolved WASM fileset.
271
+ * @param {'CPU' | 'GPU'} delegate Compute delegate to use.
272
+ * @returns {Promise<FaceLandmarker>} The created FaceLandmarker.
273
+ */
274
+ const createLandmarker = (
275
+ vision: Awaited<ReturnType<typeof FilesetResolver.forVisionTasks>>,
276
+ delegate: 'CPU' | 'GPU',
277
+ ): Promise<FaceLandmarker> =>
278
+ FaceLandmarker.createFromOptions(vision, {
279
+ baseOptions: {
280
+ modelAssetPath: `https://web-models.smileidentity.com/face_landmarker/face_landmarker.task`,
281
+ delegate,
282
+ },
283
+ outputFaceBlendshapes: true,
284
+ runningMode: 'VIDEO',
285
+ numFaces: 2,
286
+ });
287
+
210
288
  export const getMediapipeInstance = async (): Promise<FaceLandmarker> => {
211
289
  if (!window.__smileIdentityMediapipe) {
212
290
  window.__smileIdentityMediapipe = {
@@ -249,15 +327,69 @@ export const getMediapipeInstance = async (): Promise<FaceLandmarker> => {
249
327
  const delegate =
250
328
  gpuDelegate === 'CPU' || !hasFP16Support() ? 'CPU' : 'GPU';
251
329
 
252
- const faceLandmarker = await FaceLandmarker.createFromOptions(vision, {
253
- baseOptions: {
254
- modelAssetPath: `https://web-models.smileidentity.com/face_landmarker/face_landmarker.task`,
255
- delegate,
256
- },
257
- outputFaceBlendshapes: true,
258
- runningMode: 'VIDEO',
259
- numFaces: 2,
260
- });
330
+ // A GPU-delegate init can throw on some drivers (WebGL context/shader
331
+ // failures) even though the model already downloaded; on a genuine GPU
332
+ // *error* we retry once on CPU before giving up. We deliberately do NOT
333
+ // fall back on a timeout: the timeout also covers the model download, so
334
+ // a slow fetch could trip it while a healthy GPU init is still in
335
+ // progress — abandoning it to start a redundant CPU init only makes
336
+ // things worse. A timeout therefore propagates as a transient failure for
337
+ // the wrapper's bounded retry / hard deadline to handle.
338
+ //
339
+ // Orphan handling: when `withTimeout` fires, the underlying
340
+ // `createLandmarker` promise is still in flight (it cannot be aborted).
341
+ // If it eventually resolves, the resulting `FaceLandmarker` would leak a
342
+ // GPU/WebGL context, so we attach a best-effort `.close()` cleanup to the
343
+ // original promise reference for both the GPU and CPU init paths.
344
+ const closeOrphan = (orphan: FaceLandmarker) => {
345
+ try {
346
+ orphan.close();
347
+ } catch {
348
+ /* best effort */
349
+ }
350
+ };
351
+
352
+ let faceLandmarker: FaceLandmarker;
353
+ const initPromise = createLandmarker(vision, delegate);
354
+ try {
355
+ faceLandmarker = await withTimeout(
356
+ initPromise,
357
+ MEDIAPIPE_INIT_TIMEOUT_MS,
358
+ `MediaPipe initialization timed out after ${MEDIAPIPE_INIT_TIMEOUT_MS}ms (delegate: ${delegate}).`,
359
+ );
360
+ } catch (error) {
361
+ const isTimeout = error instanceof MediapipeInitTimeoutError;
362
+ if (isTimeout) {
363
+ // Stop awaiting the in-flight init, but if it eventually resolves,
364
+ // close the orphaned instance to avoid leaking a GPU/WebGL context.
365
+ initPromise.then(closeOrphan, () => {
366
+ /* already failed; nothing to clean up */
367
+ });
368
+ }
369
+ if (delegate === 'GPU' && !isTimeout) {
370
+ console.warn(
371
+ '[SmileID] GPU MediaPipe init failed; retrying with CPU delegate.',
372
+ error,
373
+ );
374
+ const cpuInitPromise = createLandmarker(vision, 'CPU');
375
+ try {
376
+ faceLandmarker = await withTimeout(
377
+ cpuInitPromise,
378
+ MEDIAPIPE_INIT_TIMEOUT_MS,
379
+ `MediaPipe CPU initialization timed out after ${MEDIAPIPE_INIT_TIMEOUT_MS}ms.`,
380
+ );
381
+ } catch (cpuError) {
382
+ if (cpuError instanceof MediapipeInitTimeoutError) {
383
+ cpuInitPromise.then(closeOrphan, () => {
384
+ /* already failed; nothing to clean up */
385
+ });
386
+ }
387
+ throw cpuError;
388
+ }
389
+ } else {
390
+ throw error;
391
+ }
392
+ }
261
393
 
262
394
  mediapipeGlobal.instance = faceLandmarker;
263
395
  mediapipeGlobal.loaded = true;
@@ -265,6 +397,8 @@ export const getMediapipeInstance = async (): Promise<FaceLandmarker> => {
265
397
 
266
398
  return faceLandmarker;
267
399
  } catch (error) {
400
+ // Always clear the poisoned promise so the wrapper's bounded retry — and
401
+ // any later remount — can re-attempt instead of awaiting a dead promise.
268
402
  mediapipeGlobal.loading = null;
269
403
  throw error;
270
404
  }
@@ -277,4 +411,6 @@ export const __testUtils = {
277
411
  matchesExcludedGpu,
278
412
  getDelegateFromGpuDetection,
279
413
  supportsWasmReftypes,
414
+ withTimeout,
415
+ MEDIAPIPE_INIT_TIMEOUT_MS,
280
416
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smileid/signature-pad",
3
- "version": "11.4.4",
3
+ "version": "11.5.0",
4
4
  "private": true,
5
5
  "exports": {
6
6
  ".": "./index.js"