@smileid/web-components 11.4.3 → 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-D1oMAv4n.js → DocumentCaptureScreens-D2G0NOQr.js} +4 -4
- package/dist/esm/{DocumentCaptureScreens-D1oMAv4n.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-CC-y0CpT.js → SelfieCaptureScreens-Dr7VzON7.js} +2322 -2187
- 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-BxstV9r_.js → package-D6YrpMcO.js} +3 -3
- package/dist/esm/{package-BxstV9r_.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 +275 -88
- 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-CC-y0CpT.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
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect } from 'preact/hooks';
|
|
1
|
+
import { useState, useEffect, useRef } from 'preact/hooks';
|
|
2
2
|
import { IconLoader2 } from '@tabler/icons-preact';
|
|
3
3
|
import register from 'preact-custom-element';
|
|
4
4
|
import type { FunctionComponent } from 'preact';
|
|
@@ -30,7 +30,7 @@ declare global {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
interface Props {
|
|
33
|
-
timeout?: number;
|
|
33
|
+
timeout?: string | number;
|
|
34
34
|
interval?: number;
|
|
35
35
|
duration?: number;
|
|
36
36
|
'theme-color'?: string;
|
|
@@ -45,20 +45,32 @@ interface Props {
|
|
|
45
45
|
hidden?: string | boolean;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
// Default deadlines for the Mediapipe load attempt. These are used when the
|
|
49
|
+
// host did NOT provide an explicit `timeout` prop; an explicit `timeout`
|
|
50
|
+
// always wins regardless of the legacy-fallback flag.
|
|
51
|
+
// - With legacy fallback allowed: 20s, so users on broken networks get a
|
|
52
|
+
// usable capture quickly.
|
|
53
|
+
// - Without legacy fallback: 90s, so we keep waiting before giving up to
|
|
54
|
+
// the error UI.
|
|
55
|
+
const DEFAULT_MEDIAPIPE_WAIT_MS = 90 * 1000;
|
|
56
|
+
const DEFAULT_WAIT_MS = 20 * 1000;
|
|
50
57
|
// Cap retries on transient init failures so we don't spin forever, while still
|
|
51
58
|
// allowing recovery from short-lived issues (e.g. CDN hiccups while the
|
|
52
59
|
// wrapper is preloading in a hidden state). Retries are spaced with
|
|
53
60
|
// exponential backoff (base * 2^(attempt-1)) so we don't hammer the CDN.
|
|
54
61
|
const MAX_MEDIAPIPE_INIT_ATTEMPTS = 3;
|
|
55
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;
|
|
56
68
|
|
|
57
69
|
// Wrapper component that decides whether to use the modern
|
|
58
70
|
// SmartSelfieCapture (Mediapipe-based) or fallback to the legacy `selfie-capture`
|
|
59
71
|
// web component after a timeout (default 90 seconds).
|
|
60
72
|
const SelfieCaptureWrapper: FunctionComponent<Props> = ({
|
|
61
|
-
timeout
|
|
73
|
+
timeout,
|
|
62
74
|
'start-countdown': startCountdownProp = false,
|
|
63
75
|
'allow-legacy-selfie-fallback': allowLegacySelfieFallbackProp = false,
|
|
64
76
|
hidden: hiddenProp = false,
|
|
@@ -82,29 +94,93 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
|
|
|
82
94
|
(window.navigator.userAgent.includes('Electron') &&
|
|
83
95
|
(window as any).__Cypress);
|
|
84
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
|
+
|
|
85
107
|
const hidden = getBoolProp(hiddenProp);
|
|
86
108
|
const startCountdown = getBoolProp(startCountdownProp);
|
|
87
109
|
const allowLegacySelfieFallback = getBoolProp(allowLegacySelfieFallbackProp);
|
|
88
|
-
|
|
110
|
+
|
|
111
|
+
// Resolve how long we'll wait for Mediapipe before the hard deadline fires.
|
|
112
|
+
// Precedence:
|
|
113
|
+
// 1. Explicit `timeout` prop (parsed defensively because web component
|
|
114
|
+
// attributes arrive as strings).
|
|
115
|
+
// 2. With legacy fallback allowed: DEFAULT_WAIT_MS (20s).
|
|
116
|
+
// 3. Otherwise: DEFAULT_MEDIAPIPE_WAIT_MS (90s).
|
|
117
|
+
const parsedTimeout = typeof timeout === 'string' ? Number(timeout) : timeout;
|
|
118
|
+
const hasExplicitTimeout =
|
|
119
|
+
typeof parsedTimeout === 'number' &&
|
|
120
|
+
Number.isFinite(parsedTimeout) &&
|
|
121
|
+
parsedTimeout > 0;
|
|
122
|
+
const defaultLoadingTime = allowLegacySelfieFallback
|
|
123
|
+
? DEFAULT_WAIT_MS
|
|
124
|
+
: DEFAULT_MEDIAPIPE_WAIT_MS;
|
|
125
|
+
const loadingTime = hasExplicitTimeout
|
|
126
|
+
? (parsedTimeout as number)
|
|
127
|
+
: defaultLoadingTime;
|
|
89
128
|
|
|
90
129
|
// Component state:
|
|
91
130
|
// - mediapipeReady: whether the mediapipe instance has successfully loaded
|
|
92
|
-
// - loadingProgress: percentage used for the visible loading UI
|
|
131
|
+
// - loadingProgress: percentage used for the visible loading UI (cosmetic)
|
|
132
|
+
// - loadDeadlineExceeded: hard cap signal — once true, we stop waiting for
|
|
133
|
+
// Mediapipe and commit to the legacy fallback (or error UI). Kept
|
|
134
|
+
// separate from `loadingProgress` so the decision is driven by a single
|
|
135
|
+
// setTimeout firing once, not by a 200ms ticking interval that can race
|
|
136
|
+
// with the Mediapipe promise resolution.
|
|
93
137
|
// - initialSessionCompleted: set when the legacy component emits publish/cancel/close
|
|
94
138
|
// - mediapipeLoading: true while attempting to load mediapipe
|
|
95
139
|
// - usingSelfieCapture: whether we've mounted the legacy `selfie-capture` element
|
|
96
|
-
|
|
97
|
-
|
|
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
|
+
);
|
|
98
156
|
const [initialSessionCompleted, setInitialSessionCompleted] = useState(false);
|
|
99
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);
|
|
100
171
|
// `unsupportedEnvironment` is a permanent, one-shot signal: we know
|
|
101
172
|
// MediaPipe cannot run here, so stop trying.
|
|
102
173
|
const [unsupportedEnvironment, setUnsupportedEnvironment] = useState(false);
|
|
103
|
-
// Bounded retry counter for transient init failures.
|
|
104
|
-
|
|
174
|
+
// Bounded retry counter for transient init failures. Stored in a ref so
|
|
175
|
+
// incrementing it does not trigger a re-render — it's only read inside the
|
|
176
|
+
// load effect.
|
|
177
|
+
const mediapipeInitAttemptsRef = useRef(0);
|
|
105
178
|
// Dedup flag so we only report a given init failure to Sentry once per
|
|
106
|
-
// wrapper instance, even if we end up retrying.
|
|
107
|
-
const
|
|
179
|
+
// wrapper instance, even if we end up retrying. Ref for the same reason.
|
|
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);
|
|
108
184
|
const [usingSelfieCapture, setUsingSelfieCapture] = useState(false);
|
|
109
185
|
|
|
110
186
|
// Attempt to load Mediapipe (with a small bounded retry budget). If
|
|
@@ -115,27 +191,33 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
|
|
|
115
191
|
useEffect(() => {
|
|
116
192
|
if (
|
|
117
193
|
mediapipeReady ||
|
|
118
|
-
|
|
194
|
+
mediapipeLoadingRef.current ||
|
|
119
195
|
unsupportedEnvironment ||
|
|
120
|
-
|
|
121
|
-
|
|
196
|
+
mediapipeInitAttemptsRef.current >= MAX_MEDIAPIPE_INIT_ATTEMPTS ||
|
|
197
|
+
skipMediapipeForTests
|
|
122
198
|
)
|
|
123
199
|
return undefined;
|
|
124
200
|
|
|
125
201
|
let cancelled = false;
|
|
126
202
|
let retryTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
127
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
|
+
|
|
128
209
|
const loadMediapipe = async () => {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
setMediapipeInitAttempts(attemptNumber);
|
|
210
|
+
const attemptNumber = mediapipeInitAttemptsRef.current + 1;
|
|
211
|
+
mediapipeInitAttemptsRef.current = attemptNumber;
|
|
132
212
|
try {
|
|
133
213
|
await getMediapipeInstance();
|
|
134
214
|
if (cancelled) return;
|
|
215
|
+
mediapipeLoadingRef.current = false;
|
|
135
216
|
setMediapipeReady(true);
|
|
136
217
|
setMediapipeLoading(false);
|
|
137
218
|
} catch (error) {
|
|
138
219
|
if (cancelled) return;
|
|
220
|
+
mediapipeLoadingRef.current = false;
|
|
139
221
|
// Loading failed; we'll fall back to the legacy selfie-capture component
|
|
140
222
|
// after the loadingProgress reaches 100% (or sooner for definitively
|
|
141
223
|
// unsupported environments — see below).
|
|
@@ -145,8 +227,8 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
|
|
|
145
227
|
// Report to Sentry (when the host page has exposed it on window) so we
|
|
146
228
|
// can observe how often users land on the fallback path and which
|
|
147
229
|
// environments are affected. Dedup so retries don't flood Sentry.
|
|
148
|
-
if (!
|
|
149
|
-
|
|
230
|
+
if (!mediapipeInitReportedRef.current) {
|
|
231
|
+
mediapipeInitReportedRef.current = true;
|
|
150
232
|
window.Sentry?.captureException(error, {
|
|
151
233
|
tags: {
|
|
152
234
|
area: 'mediapipe_init',
|
|
@@ -164,6 +246,7 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
|
|
|
164
246
|
if (isUnsupportedEnvironment) {
|
|
165
247
|
setUnsupportedEnvironment(true);
|
|
166
248
|
setLoadingProgress(100);
|
|
249
|
+
setLoadDeadlineExceeded(true);
|
|
167
250
|
setMediapipeLoading(false);
|
|
168
251
|
return;
|
|
169
252
|
}
|
|
@@ -182,6 +265,9 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
|
|
|
182
265
|
retryTimeoutId = null;
|
|
183
266
|
if (cancelled) return;
|
|
184
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);
|
|
185
271
|
}, backoffMs);
|
|
186
272
|
}
|
|
187
273
|
};
|
|
@@ -190,35 +276,88 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
|
|
|
190
276
|
|
|
191
277
|
return () => {
|
|
192
278
|
cancelled = true;
|
|
279
|
+
mediapipeLoadingRef.current = false;
|
|
193
280
|
if (retryTimeoutId !== null) {
|
|
194
281
|
clearTimeout(retryTimeoutId);
|
|
195
282
|
}
|
|
196
283
|
};
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
// When using the loading countdown (startCountdown), increment the
|
|
206
|
-
// visible loading progress. This is only used while mediapipe hasn't
|
|
207
|
-
// reported ready; once mediapipeReady becomes true we stop the timer.
|
|
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]);
|
|
287
|
+
|
|
288
|
+
// Cosmetic loading progress: ticks 0→100 over `loadingTime` so the UI can
|
|
289
|
+
// show "slow connection" copy past the SLOW_CONNECTION_THRESHOLD. This is
|
|
290
|
+
// purely visual — it does NOT decide when we fall back. The decision is
|
|
291
|
+
// driven by `loadDeadlineExceeded` below.
|
|
208
292
|
useEffect(() => {
|
|
209
293
|
if (hidden || !startCountdown || mediapipeReady) return undefined;
|
|
210
294
|
|
|
211
295
|
const timer = setInterval(() => {
|
|
212
|
-
setLoadingProgress((prev: number) =>
|
|
296
|
+
setLoadingProgress((prev: number) => {
|
|
297
|
+
if (prev >= 100) {
|
|
298
|
+
clearInterval(timer);
|
|
299
|
+
return 100;
|
|
300
|
+
}
|
|
301
|
+
return prev + 1;
|
|
302
|
+
});
|
|
213
303
|
}, loadingTime / 100);
|
|
214
304
|
|
|
215
305
|
return () => {
|
|
216
306
|
clearInterval(timer);
|
|
217
307
|
};
|
|
218
|
-
|
|
308
|
+
// `mediapipeRetryTick` restarts the cosmetic progress when the user taps
|
|
309
|
+
// Retry (which resets `loadingProgress` to 0).
|
|
310
|
+
}, [hidden, startCountdown, loadingTime, mediapipeReady, mediapipeRetryTick]);
|
|
311
|
+
|
|
312
|
+
// Hard deadline: a single setTimeout that flips `loadDeadlineExceeded`
|
|
313
|
+
// exactly once. This is the signal the render path uses to commit to the
|
|
314
|
+
// fallback. Skipped when hidden, when Mediapipe is already ready, or under
|
|
315
|
+
// Cypress (where the flag is pre-seeded to true).
|
|
316
|
+
useEffect(() => {
|
|
317
|
+
if (
|
|
318
|
+
hidden ||
|
|
319
|
+
mediapipeReady ||
|
|
320
|
+
loadDeadlineExceeded ||
|
|
321
|
+
skipMediapipeForTests
|
|
322
|
+
)
|
|
323
|
+
return undefined;
|
|
324
|
+
|
|
325
|
+
const id = setTimeout(() => {
|
|
326
|
+
setLoadDeadlineExceeded(true);
|
|
327
|
+
}, loadingTime);
|
|
328
|
+
|
|
329
|
+
return () => clearTimeout(id);
|
|
330
|
+
}, [
|
|
331
|
+
hidden,
|
|
332
|
+
mediapipeReady,
|
|
333
|
+
loadDeadlineExceeded,
|
|
334
|
+
loadingTime,
|
|
335
|
+
skipMediapipeForTests,
|
|
336
|
+
]);
|
|
337
|
+
|
|
338
|
+
// Latch the legacy fallback decision in an effect rather than during
|
|
339
|
+
// render. Effects only run after commit, so by the time this runs, any
|
|
340
|
+
// Mediapipe-ready update scheduled in the same batch as the deadline tick
|
|
341
|
+
// will already be visible — closing the race where `setLoadDeadlineExceeded`
|
|
342
|
+
// and `setMediapipeReady` fire in adjacent microtasks.
|
|
343
|
+
useEffect(() => {
|
|
344
|
+
if (hidden || usingSelfieCapture || mediapipeReady) return;
|
|
345
|
+
if (!loadDeadlineExceeded) return;
|
|
346
|
+
const legacyFallbackAllowed =
|
|
347
|
+
allowLegacySelfieFallback || skipMediapipeForTests;
|
|
348
|
+
if (!legacyFallbackAllowed) return;
|
|
349
|
+
setUsingSelfieCapture(true);
|
|
350
|
+
}, [
|
|
351
|
+
hidden,
|
|
352
|
+
usingSelfieCapture,
|
|
353
|
+
mediapipeReady,
|
|
354
|
+
loadDeadlineExceeded,
|
|
355
|
+
allowLegacySelfieFallback,
|
|
356
|
+
skipMediapipeForTests,
|
|
357
|
+
]);
|
|
219
358
|
|
|
220
359
|
useEffect(() => {
|
|
221
|
-
if (hidden || mediapipeReady ||
|
|
360
|
+
if (hidden || mediapipeReady || !loadDeadlineExceeded) return undefined;
|
|
222
361
|
|
|
223
362
|
const setupEventForwarding = () => {
|
|
224
363
|
const selfieCapture = document.querySelector('selfie-capture');
|
|
@@ -267,7 +406,7 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
|
|
|
267
406
|
return () => {
|
|
268
407
|
clearTimeout(timeoutId);
|
|
269
408
|
};
|
|
270
|
-
}, [hidden, mediapipeReady,
|
|
409
|
+
}, [hidden, mediapipeReady, loadDeadlineExceeded]);
|
|
271
410
|
|
|
272
411
|
// Dispatch allow_legacy_selfie_fallback config for observability
|
|
273
412
|
useEffect(() => {
|
|
@@ -298,6 +437,28 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
|
|
|
298
437
|
);
|
|
299
438
|
}, [usingSelfieCapture, hidden, mediapipeLoading]);
|
|
300
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
|
+
|
|
301
462
|
if (hidden) {
|
|
302
463
|
return null;
|
|
303
464
|
}
|
|
@@ -312,65 +473,91 @@ const SelfieCaptureWrapper: FunctionComponent<Props> = ({
|
|
|
312
473
|
return <SmartSelfieCapture {...props} />;
|
|
313
474
|
}
|
|
314
475
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
// `usingSelfieCapture` so other effects can react (e.g. metadata dispatch).
|
|
325
|
-
if (!usingSelfieCapture) {
|
|
326
|
-
setUsingSelfieCapture(true);
|
|
327
|
-
}
|
|
476
|
+
// Legacy capture is mounted only once the latch effect has set
|
|
477
|
+
// `usingSelfieCapture`. The effect re-checks `mediapipeReady` after commit,
|
|
478
|
+
// so if Mediapipe became ready in the same batch as the deadline tick, the
|
|
479
|
+
// latch is skipped and the SmartSelfie branch above wins. While we're
|
|
480
|
+
// waiting for the effect to fire (a single microtask), keep showing the
|
|
481
|
+
// spinner instead of flashing legacy.
|
|
482
|
+
if (usingSelfieCapture) {
|
|
483
|
+
const propsWithoutHidden = { ...props };
|
|
484
|
+
delete (propsWithoutHidden as any).hidden;
|
|
328
485
|
|
|
329
|
-
|
|
330
|
-
|
|
486
|
+
return (
|
|
487
|
+
// @ts-expect-error --- preact-custom-element doesn't have proper types for refs
|
|
488
|
+
<selfie-capture
|
|
489
|
+
{...propsWithoutHidden}
|
|
490
|
+
ref={(el: HTMLElement) => {
|
|
491
|
+
if (el && !el.hasAttribute('data-events-setup')) {
|
|
492
|
+
el.setAttribute('data-events-setup', 'true');
|
|
493
|
+
|
|
494
|
+
const forwardEvent = (event: Event) => {
|
|
495
|
+
const customEvent = event as CustomEvent;
|
|
496
|
+
|
|
497
|
+
if (
|
|
498
|
+
customEvent.type === 'selfie-capture.publish' ||
|
|
499
|
+
customEvent.type === 'selfie-capture.cancelled' ||
|
|
500
|
+
customEvent.type === 'selfie-capture.close'
|
|
501
|
+
) {
|
|
502
|
+
setInitialSessionCompleted(true);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
window.dispatchEvent(
|
|
506
|
+
new CustomEvent(customEvent.type, {
|
|
507
|
+
detail: customEvent.detail,
|
|
508
|
+
bubbles: true,
|
|
509
|
+
}),
|
|
510
|
+
);
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
el.addEventListener('selfie-capture.publish', forwardEvent);
|
|
514
|
+
el.addEventListener('selfie-capture.cancelled', forwardEvent);
|
|
515
|
+
el.addEventListener('selfie-capture.close', forwardEvent);
|
|
516
|
+
}
|
|
517
|
+
}}
|
|
518
|
+
/>
|
|
519
|
+
);
|
|
520
|
+
}
|
|
331
521
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
customEvent.type === 'selfie-capture.close'
|
|
347
|
-
) {
|
|
348
|
-
setInitialSessionCompleted(true);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
window.dispatchEvent(
|
|
352
|
-
new CustomEvent(customEvent.type, {
|
|
353
|
-
detail: customEvent.detail,
|
|
354
|
-
bubbles: true,
|
|
355
|
-
}),
|
|
356
|
-
);
|
|
357
|
-
};
|
|
358
|
-
|
|
359
|
-
el.addEventListener('selfie-capture.publish', forwardEvent);
|
|
360
|
-
el.addEventListener('selfie-capture.cancelled', forwardEvent);
|
|
361
|
-
el.addEventListener('selfie-capture.close', forwardEvent);
|
|
362
|
-
}
|
|
363
|
-
}}
|
|
364
|
-
/>
|
|
365
|
-
);
|
|
366
|
-
}
|
|
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'];
|
|
367
536
|
|
|
368
|
-
// Legacy fallback is NOT allowed: show error message
|
|
369
537
|
return (
|
|
370
538
|
<div style={{ textAlign: 'center', marginTop: '20%', padding: '0 20px' }}>
|
|
371
539
|
<p style={{ fontSize: '1.2rem', color: '#333' }}>
|
|
372
|
-
{translate(
|
|
540
|
+
{translate(errorKey)}
|
|
373
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>
|
|
374
561
|
</div>
|
|
375
562
|
);
|
|
376
563
|
}
|