@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.
- package/dist/esm/{DocumentCaptureScreens-bLFW-yEM.js → DocumentCaptureScreens-D2G0NOQr.js} +4 -4
- package/dist/esm/{DocumentCaptureScreens-bLFW-yEM.js.map → DocumentCaptureScreens-D2G0NOQr.js.map} +1 -1
- package/dist/esm/{EndUserConsent-D26UoVk5.js → EndUserConsent-uHfA3txP.js} +3 -3
- package/dist/esm/{EndUserConsent-D26UoVk5.js.map → EndUserConsent-uHfA3txP.js.map} +1 -1
- package/dist/esm/{Navigation-nvehze1F.js → Navigation-Bb7MPLE8.js} +2 -2
- package/dist/esm/{Navigation-nvehze1F.js.map → Navigation-Bb7MPLE8.js.map} +1 -1
- package/dist/esm/{SelfieCaptureScreens-BXIs6_tl.js → SelfieCaptureScreens-Dr7VzON7.js} +2356 -2227
- package/dist/esm/SelfieCaptureScreens-Dr7VzON7.js.map +1 -0
- package/dist/esm/{TotpConsent-owUOdKzP.js → TotpConsent-Depzg0ti.js} +2 -2
- package/dist/esm/{TotpConsent-owUOdKzP.js.map → TotpConsent-Depzg0ti.js.map} +1 -1
- package/dist/esm/combobox.js +1 -1
- package/dist/esm/document.js +1 -1
- package/dist/esm/end-user-consent.js +1 -1
- package/dist/esm/{index-5Nn2kzHI.js → index-C4RTMbgw.js} +74 -74
- package/dist/esm/{index-5Nn2kzHI.js.map → index-C4RTMbgw.js.map} +1 -1
- package/dist/esm/localisation.js +1 -1
- package/dist/esm/main.js +6 -6
- package/dist/esm/navigation.js +1 -1
- package/dist/esm/{package-DmH-I6GW.js → package-D6YrpMcO.js} +3 -3
- package/dist/esm/{package-DmH-I6GW.js.map → package-D6YrpMcO.js.map} +1 -1
- package/dist/esm/selfie.js +1 -1
- package/dist/esm/smart-camera-web.js +9 -12
- package/dist/esm/smart-camera-web.js.map +1 -1
- package/dist/esm/totp-consent.js +1 -1
- package/dist/smart-camera-web.js +51 -51
- package/dist/smart-camera-web.js.map +1 -1
- package/dist/types/main.d.ts +2 -0
- package/lib/components/selfie/src/SelfieCaptureScreens.js +83 -0
- package/lib/components/selfie/src/selfie-capture-wrapper/SelfieCaptureWrapper.tsx +139 -16
- package/lib/components/selfie/src/smartselfie-capture/utils/mediapipeManager.ts +145 -9
- package/lib/components/signature-pad/package.json +1 -1
- package/lib/components/smart-camera-web/src/SmartCameraWeb.js +6 -4
- package/package.json +2 -2
- package/dist/esm/SelfieCaptureScreens-BXIs6_tl.js.map +0 -1
package/dist/types/main.d.ts
CHANGED
|
@@ -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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
194
|
+
mediapipeLoadingRef.current ||
|
|
151
195
|
unsupportedEnvironment ||
|
|
152
196
|
mediapipeInitAttemptsRef.current >= MAX_MEDIAPIPE_INIT_ATTEMPTS ||
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
}, [
|
|
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 =
|
|
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
|
-
|
|
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
|
|
431
|
-
//
|
|
432
|
-
|
|
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(
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
};
|
|
@@ -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.
|
|
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.
|
|
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",
|