@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.
- package/dist/esm/{DocumentCaptureScreens-bLFW-yEM.js → DocumentCaptureScreens-ucJDu5nH.js} +555 -2470
- package/dist/esm/DocumentCaptureScreens-ucJDu5nH.js.map +1 -0
- package/dist/esm/{EndUserConsent-D26UoVk5.js → EndUserConsent-CsiwoThZ.js} +3 -3
- package/dist/esm/{EndUserConsent-D26UoVk5.js.map → EndUserConsent-CsiwoThZ.js.map} +1 -1
- package/dist/esm/{Navigation-nvehze1F.js → Navigation-Xg565kcu.js} +28 -22
- package/dist/esm/Navigation-Xg565kcu.js.map +1 -0
- package/dist/esm/SelfieCaptureScreens-D3KuMzZA.js +11471 -0
- package/dist/esm/SelfieCaptureScreens-D3KuMzZA.js.map +1 -0
- package/dist/esm/{TotpConsent-owUOdKzP.js → TotpConsent-CRtmtudl.js} +2 -2
- package/dist/esm/{TotpConsent-owUOdKzP.js.map → TotpConsent-CRtmtudl.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-CUwa6MPI.js +1363 -0
- package/dist/esm/{index-5Nn2kzHI.js.map → index-CUwa6MPI.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-BmVbDNny.js +2535 -0
- package/dist/esm/package-BmVbDNny.js.map +1 -0
- package/dist/esm/selfie.js +1 -1
- package/dist/esm/smart-camera-web.js +67 -40
- package/dist/esm/smart-camera-web.js.map +1 -1
- package/dist/esm/totp-consent.js +1 -1
- package/dist/smart-camera-web.js +877 -122
- package/dist/smart-camera-web.js.map +1 -1
- package/dist/types/main.d.ts +13 -0
- package/lib/components/navigation/src/Navigation.js +27 -8
- package/lib/components/selfie/src/SelfieCaptureScreens.js +139 -8
- package/lib/components/selfie/src/enhanced-smartselfie-capture/EnhancedSmartSelfieCapture.tsx +684 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/EnhancedSmartSelfieConsent.tsx +71 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/EnhancedSmartSelfieSubmission.tsx +181 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/OvalProgress.tsx +87 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/Icon.svg +8 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/accessories.svg +77 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/active_liveness_animation.lottie +0 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/device.svg +12 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/device_orientation.lottie +0 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/good.svg +52 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/id-card.svg +9 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/illustrations.tsx +852 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/instructions-img.svg +3 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/multiple-faces.svg +69 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/person.svg +6 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/phone.svg +8 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/poor-lighting.svg +53 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/too_dark_animation.lottie +0 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/components/ActiveLivenessOverlay.tsx +226 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/components/AlertDisplay.tsx +38 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/components/BackNavigation.tsx +45 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/components/CameraPreview.tsx +96 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/components/CaptureControls.tsx +97 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/components/CaptureGuidelines.tsx +374 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/components/ConsentView.tsx +460 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/components/SubmissionView.tsx +426 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/components/index.ts +3 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/constants.ts +23 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/hooks/index.ts +2 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/hooks/useCamera.ts +238 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/hooks/useFaceCapture.ts +1075 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/index.ts +1 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/alertMessages.ts +20 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/canvas.ts +108 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/faceDetection.ts +545 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/imageCapture.ts +66 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/imageQuality.ts +151 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/index.ts +5 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/mediapipeManager.ts +215 -0
- package/lib/components/selfie/src/selfie-capture-wrapper/SelfieCaptureWrapper.tsx +163 -17
- package/lib/components/selfie/src/smartselfie-capture/SmartSelfieCapture.tsx +2 -2
- package/lib/components/selfie/src/smartselfie-capture/hooks/useFaceCapture.ts +15 -7
- package/lib/components/selfie/src/smartselfie-capture/utils/canvas.ts +4 -6
- 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 +70 -11
- package/lib/domain/localisation/index.js +2 -2
- package/package.json +3 -3
- package/dist/esm/DocumentCaptureScreens-bLFW-yEM.js.map +0 -1
- package/dist/esm/Navigation-nvehze1F.js.map +0 -1
- package/dist/esm/SelfieCaptureScreens-BXIs6_tl.js +0 -7522
- package/dist/esm/SelfieCaptureScreens-BXIs6_tl.js.map +0 -1
- package/dist/esm/index-5Nn2kzHI.js +0 -1360
- package/dist/esm/package-DmH-I6GW.js +0 -565
- 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
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
202
|
+
mediapipeLoadingRef.current ||
|
|
151
203
|
unsupportedEnvironment ||
|
|
152
204
|
mediapipeInitAttemptsRef.current >= MAX_MEDIAPIPE_INIT_ATTEMPTS ||
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
}, [
|
|
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 =
|
|
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
|
-
|
|
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
|
|
431
|
-
//
|
|
432
|
-
|
|
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(
|
|
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
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
};
|