@smileid/web-components 11.4.4 → 11.4.5

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 (34) hide show
  1. package/dist/esm/{DocumentCaptureScreens-bLFW-yEM.js → DocumentCaptureScreens-D2G0NOQr.js} +4 -4
  2. package/dist/esm/{DocumentCaptureScreens-bLFW-yEM.js.map → DocumentCaptureScreens-D2G0NOQr.js.map} +1 -1
  3. package/dist/esm/{EndUserConsent-D26UoVk5.js → EndUserConsent-uHfA3txP.js} +3 -3
  4. package/dist/esm/{EndUserConsent-D26UoVk5.js.map → EndUserConsent-uHfA3txP.js.map} +1 -1
  5. package/dist/esm/{Navigation-nvehze1F.js → Navigation-Bb7MPLE8.js} +2 -2
  6. package/dist/esm/{Navigation-nvehze1F.js.map → Navigation-Bb7MPLE8.js.map} +1 -1
  7. package/dist/esm/{SelfieCaptureScreens-BXIs6_tl.js → SelfieCaptureScreens-Dr7VzON7.js} +2356 -2227
  8. package/dist/esm/SelfieCaptureScreens-Dr7VzON7.js.map +1 -0
  9. package/dist/esm/{TotpConsent-owUOdKzP.js → TotpConsent-Depzg0ti.js} +2 -2
  10. package/dist/esm/{TotpConsent-owUOdKzP.js.map → TotpConsent-Depzg0ti.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-5Nn2kzHI.js → index-C4RTMbgw.js} +74 -74
  15. package/dist/esm/{index-5Nn2kzHI.js.map → index-C4RTMbgw.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-DmH-I6GW.js → package-D6YrpMcO.js} +3 -3
  20. package/dist/esm/{package-DmH-I6GW.js.map → package-D6YrpMcO.js.map} +1 -1
  21. package/dist/esm/selfie.js +1 -1
  22. package/dist/esm/smart-camera-web.js +9 -12
  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 +51 -51
  26. package/dist/smart-camera-web.js.map +1 -1
  27. package/dist/types/main.d.ts +2 -0
  28. package/lib/components/selfie/src/SelfieCaptureScreens.js +83 -0
  29. package/lib/components/selfie/src/selfie-capture-wrapper/SelfieCaptureWrapper.tsx +139 -16
  30. package/lib/components/selfie/src/smartselfie-capture/utils/mediapipeManager.ts +145 -9
  31. package/lib/components/signature-pad/package.json +1 -1
  32. package/lib/components/smart-camera-web/src/SmartCameraWeb.js +6 -4
  33. package/package.json +2 -2
  34. package/dist/esm/SelfieCaptureScreens-BXIs6_tl.js.map +0 -1
@@ -242,8 +242,10 @@ export declare class SelfieCaptureScreens extends HTMLElement {
242
242
  }[] | null | undefined;
243
243
  setUpEventListeners(): void;
244
244
  forceWrapperRemount(): Promise<any>;
245
+ restartSelfieCapture(): void;
245
246
  setActiveScreen(screen: any): void;
246
247
  setupSelfieWrapperEventListeners(): void;
248
+ _selfieWrapperPublishHandler: ((event: any) => Promise<void>) | undefined;
247
249
  _publishSelectedImages(): void;
248
250
  get hideInstructions(): boolean;
249
251
  get hideAttribution(): "" | "hide-attribution=\"\"";
@@ -1,6 +1,7 @@
1
1
  import './selfie-capture-instructions';
2
2
  import './selfie-capture-review';
3
3
  import './selfie-capture-wrapper/index.ts';
4
+ import { getMediapipeInstance } from './smartselfie-capture/utils/mediapipeManager.ts';
4
5
  import SmartCamera from '../../../domain/camera/src/SmartCamera';
5
6
  import styles from '../../../styles/src/styles';
6
7
  import packageJson from '../../../../package.json';
@@ -108,6 +109,37 @@ class SelfieCaptureScreens extends HTMLElement {
108
109
  }
109
110
 
110
111
  this.setUpEventListeners();
112
+
113
+ // Pre-warm MediaPipe as soon as the selfie flow starts so the heavy WASM +
114
+ // model download (and on-device init) happens while the user reads the
115
+ // instructions — not behind a blocking spinner on the capture screen. This
116
+ // is fire-and-forget and idempotent: getMediapipeInstance() caches a single
117
+ // in-flight promise / instance, so the wrapper (and any remount) reuses the
118
+ // same load instead of starting a new one. Errors are handled by the
119
+ // wrapper's own retry/fallback path, so swallow them here.
120
+ //
121
+ // Skipped under Cypress to match `SelfieCaptureWrapper`'s existing test
122
+ // seam (`skipMediapipeForTests`). Specs rely on the wrapper short-circuiting
123
+ // to the legacy `selfie-capture` fallback; pre-warming here would race with
124
+ // that seam and (intermittently) flip the wrapper into the SmartSelfie path
125
+ // by populating `window.__smileIdentityMediapipe` before the wrapper mounts.
126
+ //
127
+ // The parent check covers the embed Cypress context where this element runs
128
+ // inside an iframe and window.Cypress is only set on the parent frame.
129
+ const isCypress =
130
+ !!window.Cypress ||
131
+ (() => {
132
+ try {
133
+ return !!window.parent.Cypress;
134
+ } catch {
135
+ return false;
136
+ }
137
+ })() ||
138
+ (window.navigator.userAgent.includes('Electron') && window.__Cypress);
139
+ const forceMediapipeLoad = !!window.__SMILE_ID_TEST_FORCE_MEDIAPIPE_LOAD__;
140
+ if (!isCypress || forceMediapipeLoad) {
141
+ getMediapipeInstance().catch(() => {});
142
+ }
111
143
  }
112
144
 
113
145
  getAgentMode() {
@@ -230,6 +262,45 @@ class SelfieCaptureScreens extends HTMLElement {
230
262
  });
231
263
  }
232
264
 
265
+ // Return to a clean selfie capture screen. Used when navigating back from the
266
+ // document flow. Previously this was driven by toggling the `initial-screen`
267
+ // attribute on this element, which re-fired a full `connectedCallback()`
268
+ // rebuild every time (even when the value was unchanged). We now swap in a
269
+ // fresh `selfie-capture-wrapper` synchronously and navigate explicitly, so we
270
+ // land on a clean capture screen — not the stale review — without rebuilding
271
+ // the whole screen tree or depending on timers.
272
+ restartSelfieCapture() {
273
+ SmartCamera.stopMedia();
274
+
275
+ const container = this.querySelector('div');
276
+ const oldWrapper = this.selfieCapture;
277
+
278
+ if (oldWrapper && container) {
279
+ this._remountKey++;
280
+
281
+ const newWrapper = document.createElement('selfie-capture-wrapper');
282
+ Array.from(oldWrapper.attributes).forEach((attr) => {
283
+ newWrapper.setAttribute(attr.name, attr.value);
284
+ });
285
+ newWrapper.setAttribute('key', this._remountKey.toString());
286
+ newWrapper.setAttribute('start-countdown', 'false');
287
+ newWrapper.setAttribute('hidden', '');
288
+
289
+ const reviewElement = container.querySelector('selfie-capture-review');
290
+ oldWrapper.remove();
291
+ if (reviewElement) {
292
+ container.insertBefore(newWrapper, reviewElement);
293
+ } else {
294
+ container.appendChild(newWrapper);
295
+ }
296
+
297
+ this.selfieCapture = newWrapper;
298
+ this.setupSelfieWrapperEventListeners();
299
+ }
300
+
301
+ this.setActiveScreen(this.selfieCapture);
302
+ }
303
+
233
304
  // Override setActiveScreen to enable countdown when selfie-capture is active
234
305
  setActiveScreen(screen) {
235
306
  if (this.activeScreen === screen) {
@@ -256,6 +327,17 @@ class SelfieCaptureScreens extends HTMLElement {
256
327
  window.removeEventListener(event, handler);
257
328
  });
258
329
  }
330
+ // Also remove the previously-attached element-level publish handler so we
331
+ // don't accumulate duplicates across remounts (each call to
332
+ // setupSelfieWrapperEventListeners would otherwise add another listener,
333
+ // causing multiple transitions to review / multiple metadata events on
334
+ // navigating back from document capture).
335
+ if (this._selfieWrapperPublishHandler) {
336
+ this.removeEventListener(
337
+ 'selfie-capture.publish',
338
+ this._selfieWrapperPublishHandler,
339
+ );
340
+ }
259
341
 
260
342
  // Create new event handlers
261
343
  const cancelledHandler = async () => {
@@ -313,6 +395,7 @@ class SelfieCaptureScreens extends HTMLElement {
313
395
 
314
396
  // Also listen for the publish event on the parent SelfieCaptureScreens element
315
397
  // in case smartselfie-capture dispatches it there
398
+ this._selfieWrapperPublishHandler = publishHandler;
316
399
  this.addEventListener('selfie-capture.publish', publishHandler);
317
400
  }
318
401
 
@@ -60,6 +60,11 @@ const DEFAULT_WAIT_MS = 20 * 1000;
60
60
  // exponential backoff (base * 2^(attempt-1)) so we don't hammer the CDN.
61
61
  const MAX_MEDIAPIPE_INIT_ATTEMPTS = 3;
62
62
  const MEDIAPIPE_RETRY_BASE_DELAY_MS = 500;
63
+ // Cap user-initiated retries from the failure screen. Each tap resets the
64
+ // per-attempt counter (granting another `MAX_MEDIAPIPE_INIT_ATTEMPTS` round of
65
+ // internal retries), so without this cap a frustrated or malicious user could
66
+ // hammer Retry indefinitely and generate unbounded requests to the model CDN.
67
+ const MAX_USER_RETRIES = 3;
63
68
 
64
69
  // Wrapper component that decides whether to use the modern
65
70
  // SmartSelfieCapture (Mediapipe-based) or fallback to the legacy `selfie-capture`
@@ -89,6 +94,16 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
89
94
  (window.navigator.userAgent.includes('Electron') &&
90
95
  (window as any).__Cypress);
91
96
 
97
+ // Test-only seam: the `isCypress` short-circuit below skips the whole
98
+ // MediaPipe load path (so most specs run fast against the legacy fallback).
99
+ // A spec that wants to exercise the real load/spinner/error/Retry path sets
100
+ // `window.__SMILE_ID_TEST_FORCE_MEDIAPIPE_LOAD__ = true` to opt out of that
101
+ // short-circuit. When the flag is unset, `skipMediapipeForTests === isCypress`,
102
+ // so production and existing-test behaviour are unchanged.
103
+ const forceMediapipeLoad = !!(window as any)
104
+ .__SMILE_ID_TEST_FORCE_MEDIAPIPE_LOAD__;
105
+ const skipMediapipeForTests = isCypress && !forceMediapipeLoad;
106
+
92
107
  const hidden = getBoolProp(hiddenProp);
93
108
  const startCountdown = getBoolProp(startCountdownProp);
94
109
  const allowLegacySelfieFallback = getBoolProp(allowLegacySelfieFallbackProp);
@@ -122,11 +137,37 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
122
137
  // - initialSessionCompleted: set when the legacy component emits publish/cancel/close
123
138
  // - mediapipeLoading: true while attempting to load mediapipe
124
139
  // - 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);
140
+ // If MediaPipe already loaded earlier in this session (e.g. we're remounting
141
+ // after returning from document capture), reuse the cached singleton instance
142
+ // immediately instead of showing the loading spinner again. The model and
143
+ // WASM are already in memory, so no network call is needed.
144
+ const mediapipeAlreadyLoaded = !!(
145
+ window.__smileIdentityMediapipe?.loaded &&
146
+ window.__smileIdentityMediapipe?.instance
147
+ );
148
+
149
+ const [mediapipeReady, setMediapipeReady] = useState(mediapipeAlreadyLoaded);
150
+ const [loadingProgress, setLoadingProgress] = useState(
151
+ skipMediapipeForTests || mediapipeAlreadyLoaded ? 100 : 0,
152
+ );
153
+ const [loadDeadlineExceeded, setLoadDeadlineExceeded] = useState(
154
+ skipMediapipeForTests,
155
+ );
128
156
  const [initialSessionCompleted, setInitialSessionCompleted] = useState(false);
129
157
  const [mediapipeLoading, setMediapipeLoading] = useState(false);
158
+ // Concurrency guard for the load effect. Kept in a ref — NOT in
159
+ // `mediapipeLoading` state — so the effect's guard does not depend on a value
160
+ // the effect itself sets. If it did (as it used to), calling
161
+ // `setMediapipeLoading(true)` would re-run the effect, whose cleanup flips
162
+ // `cancelled` true, and a slow `getMediapipeInstance()` would then resolve
163
+ // into a cancelled closure — never calling `setMediapipeReady(true)`. That
164
+ // left the UI stuck on the loading spinner even though MediaPipe loaded
165
+ // successfully (intermittent: only when the load lost the race to the
166
+ // re-render).
167
+ const mediapipeLoadingRef = useRef(false);
168
+ // Bumped to re-trigger the load effect for a bounded retry after a transient
169
+ // failure (replaces re-using `mediapipeLoading` as the re-trigger signal).
170
+ const [mediapipeRetryTick, setMediapipeRetryTick] = useState(0);
130
171
  // `unsupportedEnvironment` is a permanent, one-shot signal: we know
131
172
  // MediaPipe cannot run here, so stop trying.
132
173
  const [unsupportedEnvironment, setUnsupportedEnvironment] = useState(false);
@@ -137,6 +178,9 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
137
178
  // Dedup flag so we only report a given init failure to Sentry once per
138
179
  // wrapper instance, even if we end up retrying. Ref for the same reason.
139
180
  const mediapipeInitReportedRef = useRef(false);
181
+ // User-initiated Retry presses are also bounded — see `MAX_USER_RETRIES`.
182
+ const userRetryCountRef = useRef(0);
183
+ const [retriesExhausted, setRetriesExhausted] = useState(false);
140
184
  const [usingSelfieCapture, setUsingSelfieCapture] = useState(false);
141
185
 
142
186
  // Attempt to load Mediapipe (with a small bounded retry budget). If
@@ -147,27 +191,33 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
147
191
  useEffect(() => {
148
192
  if (
149
193
  mediapipeReady ||
150
- mediapipeLoading ||
194
+ mediapipeLoadingRef.current ||
151
195
  unsupportedEnvironment ||
152
196
  mediapipeInitAttemptsRef.current >= MAX_MEDIAPIPE_INIT_ATTEMPTS ||
153
- isCypress
197
+ skipMediapipeForTests
154
198
  )
155
199
  return undefined;
156
200
 
157
201
  let cancelled = false;
158
202
  let retryTimeoutId: ReturnType<typeof setTimeout> | null = null;
159
203
 
204
+ // Mark loading via the ref (effect guard) and the state (UI). The state is
205
+ // intentionally NOT in this effect's deps — see `mediapipeLoadingRef`.
206
+ mediapipeLoadingRef.current = true;
207
+ setMediapipeLoading(true);
208
+
160
209
  const loadMediapipe = async () => {
161
- setMediapipeLoading(true);
162
210
  const attemptNumber = mediapipeInitAttemptsRef.current + 1;
163
211
  mediapipeInitAttemptsRef.current = attemptNumber;
164
212
  try {
165
213
  await getMediapipeInstance();
166
214
  if (cancelled) return;
215
+ mediapipeLoadingRef.current = false;
167
216
  setMediapipeReady(true);
168
217
  setMediapipeLoading(false);
169
218
  } catch (error) {
170
219
  if (cancelled) return;
220
+ mediapipeLoadingRef.current = false;
171
221
  // Loading failed; we'll fall back to the legacy selfie-capture component
172
222
  // after the loadingProgress reaches 100% (or sooner for definitively
173
223
  // unsupported environments — see below).
@@ -215,6 +265,9 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
215
265
  retryTimeoutId = null;
216
266
  if (cancelled) return;
217
267
  setMediapipeLoading(false);
268
+ // Re-trigger the effect for the next attempt via a dedicated counter
269
+ // rather than toggling `mediapipeLoading` (which is no longer a dep).
270
+ setMediapipeRetryTick((tick) => tick + 1);
218
271
  }, backoffMs);
219
272
  }
220
273
  };
@@ -223,11 +276,14 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
223
276
 
224
277
  return () => {
225
278
  cancelled = true;
279
+ mediapipeLoadingRef.current = false;
226
280
  if (retryTimeoutId !== null) {
227
281
  clearTimeout(retryTimeoutId);
228
282
  }
229
283
  };
230
- }, [mediapipeReady, mediapipeLoading, unsupportedEnvironment]);
284
+ // `mediapipeLoading` is deliberately excluded: it is set inside this effect,
285
+ // so depending on it would re-run the effect and cancel the in-flight load.
286
+ }, [mediapipeReady, unsupportedEnvironment, mediapipeRetryTick]);
231
287
 
232
288
  // Cosmetic loading progress: ticks 0→100 over `loadingTime` so the UI can
233
289
  // show "slow connection" copy past the SLOW_CONNECTION_THRESHOLD. This is
@@ -249,14 +305,21 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
249
305
  return () => {
250
306
  clearInterval(timer);
251
307
  };
252
- }, [hidden, startCountdown, loadingTime, mediapipeReady]);
308
+ // `mediapipeRetryTick` restarts the cosmetic progress when the user taps
309
+ // Retry (which resets `loadingProgress` to 0).
310
+ }, [hidden, startCountdown, loadingTime, mediapipeReady, mediapipeRetryTick]);
253
311
 
254
312
  // Hard deadline: a single setTimeout that flips `loadDeadlineExceeded`
255
313
  // exactly once. This is the signal the render path uses to commit to the
256
314
  // fallback. Skipped when hidden, when Mediapipe is already ready, or under
257
315
  // Cypress (where the flag is pre-seeded to true).
258
316
  useEffect(() => {
259
- if (hidden || mediapipeReady || loadDeadlineExceeded || isCypress)
317
+ if (
318
+ hidden ||
319
+ mediapipeReady ||
320
+ loadDeadlineExceeded ||
321
+ skipMediapipeForTests
322
+ )
260
323
  return undefined;
261
324
 
262
325
  const id = setTimeout(() => {
@@ -264,7 +327,13 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
264
327
  }, loadingTime);
265
328
 
266
329
  return () => clearTimeout(id);
267
- }, [hidden, mediapipeReady, loadDeadlineExceeded, loadingTime, isCypress]);
330
+ }, [
331
+ hidden,
332
+ mediapipeReady,
333
+ loadDeadlineExceeded,
334
+ loadingTime,
335
+ skipMediapipeForTests,
336
+ ]);
268
337
 
269
338
  // Latch the legacy fallback decision in an effect rather than during
270
339
  // render. Effects only run after commit, so by the time this runs, any
@@ -274,7 +343,8 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
274
343
  useEffect(() => {
275
344
  if (hidden || usingSelfieCapture || mediapipeReady) return;
276
345
  if (!loadDeadlineExceeded) return;
277
- const legacyFallbackAllowed = allowLegacySelfieFallback || isCypress;
346
+ const legacyFallbackAllowed =
347
+ allowLegacySelfieFallback || skipMediapipeForTests;
278
348
  if (!legacyFallbackAllowed) return;
279
349
  setUsingSelfieCapture(true);
280
350
  }, [
@@ -283,7 +353,7 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
283
353
  mediapipeReady,
284
354
  loadDeadlineExceeded,
285
355
  allowLegacySelfieFallback,
286
- isCypress,
356
+ skipMediapipeForTests,
287
357
  ]);
288
358
 
289
359
  useEffect(() => {
@@ -367,6 +437,28 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
367
437
  );
368
438
  }, [usingSelfieCapture, hidden, mediapipeLoading]);
369
439
 
440
+ // Retry from the failure screen: clear the give-up state and re-arm the load
441
+ // effect, deadline, and cosmetic progress so we make a fresh attempt. We reset
442
+ // `unsupportedEnvironment` so a genuinely unsupported device fails fast again
443
+ // (rather than spinning for the full deadline) instead of being permanently
444
+ // latched off. User taps are capped by `MAX_USER_RETRIES` so a frustrated or
445
+ // malicious user can't hammer Retry indefinitely and generate unbounded
446
+ // requests to the model CDN.
447
+ const handleRetry = () => {
448
+ if (userRetryCountRef.current >= MAX_USER_RETRIES) {
449
+ setRetriesExhausted(true);
450
+ return;
451
+ }
452
+ userRetryCountRef.current += 1;
453
+ mediapipeInitAttemptsRef.current = 0;
454
+ mediapipeInitReportedRef.current = false;
455
+ mediapipeLoadingRef.current = false;
456
+ setUnsupportedEnvironment(false);
457
+ setLoadDeadlineExceeded(false);
458
+ setLoadingProgress(0);
459
+ setMediapipeRetryTick((tick) => tick + 1);
460
+ };
461
+
370
462
  if (hidden) {
371
463
  return null;
372
464
  }
@@ -427,14 +519,45 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
427
519
  );
428
520
  }
429
521
 
430
- // Hard deadline elapsed and legacy fallback isn't allowed: show the
431
- // connection error.
432
- if (loadDeadlineExceeded && !(allowLegacySelfieFallback || isCypress)) {
522
+ // Hard deadline elapsed and legacy fallback isn't allowed: show an actionable
523
+ // error. The network is usually fine here (the hold-up is on-device setup), so
524
+ // only blame the connection when the browser actually reports being offline —
525
+ // otherwise frame it as a setup problem. Always offer a Retry control.
526
+ if (
527
+ loadDeadlineExceeded &&
528
+ !(allowLegacySelfieFallback || skipMediapipeForTests)
529
+ ) {
530
+ const isOffline =
531
+ typeof navigator !== 'undefined' && navigator.onLine === false;
532
+ const errorKey = isOffline
533
+ ? 'selfie.capture.loading.offlineError'
534
+ : 'selfie.capture.loading.setupError';
535
+ const themeColor = (props as Record<string, string>)['theme-color'];
536
+
433
537
  return (
434
538
  <div style={{ textAlign: 'center', marginTop: '20%', padding: '0 20px' }}>
435
539
  <p style={{ fontSize: '1.2rem', color: '#333' }}>
436
- {translate('selfie.capture.loading.connectionError')}
540
+ {translate(errorKey)}
437
541
  </p>
542
+ <button
543
+ type="button"
544
+ onClick={handleRetry}
545
+ disabled={retriesExhausted}
546
+ style={{
547
+ marginTop: '16px',
548
+ padding: '0.75rem 1.5rem',
549
+ borderRadius: '2.5rem',
550
+ border: 'none',
551
+ backgroundColor: themeColor || '#001096',
552
+ color: '#fff',
553
+ fontSize: '1rem',
554
+ fontWeight: 600,
555
+ cursor: retriesExhausted ? 'not-allowed' : 'pointer',
556
+ opacity: retriesExhausted ? 0.6 : 1,
557
+ }}
558
+ >
559
+ {translate('selfie.capture.loading.retry')}
560
+ </button>
438
561
  </div>
439
562
  );
440
563
  }
@@ -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.4.5",
4
4
  "private": true,
5
5
  "exports": {
6
6
  ".": "./index.js"
@@ -166,11 +166,13 @@ class SmartCameraWeb extends HTMLElement {
166
166
  this.documentCapture.addEventListener(
167
167
  'document-capture-screens.cancelled',
168
168
  () => {
169
- this.SelfieCaptureScreens.setAttribute(
170
- 'initial-screen',
171
- 'selfie-capture',
172
- );
173
169
  this.setActiveScreen(this.SelfieCaptureScreens);
170
+ // Land on a clean selfie capture screen by driving the navigation
171
+ // explicitly. Previously this set `initial-screen="selfie-capture"`,
172
+ // whose side effect was a full SelfieCaptureScreens rebuild — re-fired
173
+ // on every back-navigation (setAttribute invokes attributeChangedCallback
174
+ // even when the value is unchanged).
175
+ this.SelfieCaptureScreens.restartSelfieCapture();
174
176
  this.SelfieCaptureScreens.removeAttribute('data-camera-error');
175
177
  this.SelfieCaptureScreens.setAttribute('data-camera-ready', true);
176
178
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smileid/web-components",
3
- "version": "11.4.4",
3
+ "version": "11.4.5",
4
4
  "private": false,
5
5
  "main": "dist/esm/main.js",
6
6
  "module": "dist/esm/main.js",
@@ -105,7 +105,7 @@
105
105
  "@typescript-eslint/eslint-plugin": "^8.49.0",
106
106
  "@typescript-eslint/parser": "^8.49.0",
107
107
  "cross-env": "^7.0.3",
108
- "cypress": "^13.15.0",
108
+ "cypress": "^13.17.0",
109
109
  "eslint": "^8.57.0",
110
110
  "eslint-config-airbnb-base": "^15.0.0",
111
111
  "eslint-config-prettier": "^9.1.0",