@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
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
import { useRef, useEffect, useState } from 'preact/hooks';
|
|
2
|
+
import { signal } from '@preact/signals';
|
|
3
|
+
import register from 'preact-custom-element';
|
|
4
|
+
import type { FunctionComponent } from 'preact';
|
|
5
|
+
|
|
6
|
+
import { getBoolProp } from '../../../../utils/props';
|
|
7
|
+
import { t } from '../../../../domain/localisation';
|
|
8
|
+
import { useFaceCapture, useCamera } from './hooks';
|
|
9
|
+
import { getMediapipeInstance } from './utils/mediapipeManager';
|
|
10
|
+
import { CameraPreview } from './components/CameraPreview';
|
|
11
|
+
import { AlertDisplay } from './components/AlertDisplay';
|
|
12
|
+
import { CaptureControls } from './components/CaptureControls';
|
|
13
|
+
import { ActiveLivenessOverlay } from './components/ActiveLivenessOverlay';
|
|
14
|
+
import { CaptureGuidelines } from './components/CaptureGuidelines';
|
|
15
|
+
import { SubmissionView } from './components/SubmissionView';
|
|
16
|
+
import { BackNavigation } from './components/BackNavigation';
|
|
17
|
+
|
|
18
|
+
import '../../../navigation/src';
|
|
19
|
+
import '../../../attribution/PoweredBySmileId';
|
|
20
|
+
// Side-effect imports: register the standalone consent and submission
|
|
21
|
+
// custom elements so partners can mount them independently of ESS
|
|
22
|
+
// (`<enhanced-smart-selfie-consent>` / `<enhanced-smart-selfie-submission>`).
|
|
23
|
+
import './EnhancedSmartSelfieConsent';
|
|
24
|
+
import './EnhancedSmartSelfieSubmission';
|
|
25
|
+
|
|
26
|
+
// ESS owns the in-flow capture experience: a pre-capture guidelines screen
|
|
27
|
+
// (the "Capture Guidelines" tiles + active-liveness hero animation), the
|
|
28
|
+
// active-liveness capture itself, and the post-capture review screen. Consent
|
|
29
|
+
// and post-confirm submission UI are deliberately not part of this element —
|
|
30
|
+
// they're shipped as separate custom elements (`<enhanced-smart-selfie-
|
|
31
|
+
// consent>`, `<enhanced-smart-selfie-submission>`) that callers mount
|
|
32
|
+
// around ESS as their product flow requires.
|
|
33
|
+
type View = 'guidelines' | 'capture' | 'review';
|
|
34
|
+
|
|
35
|
+
interface Props {
|
|
36
|
+
interval?: number;
|
|
37
|
+
duration?: number;
|
|
38
|
+
'theme-color'?: string;
|
|
39
|
+
'show-navigation'?: string | boolean;
|
|
40
|
+
'allow-agent-mode'?: string | boolean;
|
|
41
|
+
'show-agent-mode-for-tests'?: string | boolean;
|
|
42
|
+
'hide-attribution'?: string | boolean;
|
|
43
|
+
'disable-image-tests'?: string | boolean;
|
|
44
|
+
/** When true, skip the guidelines screen and go straight to capture. */
|
|
45
|
+
'hide-instructions'?: string | boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Render the back button on the guidelines screen. Off by default because
|
|
48
|
+
* guidelines is the first ESS view, so there is nothing within ESS to
|
|
49
|
+
* navigate back to. Hosts that mount ESS after their own prior screen
|
|
50
|
+
* (e.g. a consent step in a KYC / DocV flow) opt in by setting this so
|
|
51
|
+
* the back button surfaces and `selfie-capture.cancelled` fires for the
|
|
52
|
+
* host to handle navigation back to the previous view.
|
|
53
|
+
*/
|
|
54
|
+
'show-back-on-guidelines'?: string | boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const EnhancedSmartSelfieCapture: FunctionComponent<Props> = ({
|
|
58
|
+
interval = 350,
|
|
59
|
+
duration = 2800,
|
|
60
|
+
'theme-color': themeColor = '#001096',
|
|
61
|
+
'show-navigation': showNavigationProp = false,
|
|
62
|
+
'allow-agent-mode': allowAgentModeProp = false,
|
|
63
|
+
'show-agent-mode-for-tests': showAgentModeForTestsProp = false,
|
|
64
|
+
'hide-attribution': hideAttributionProp = false,
|
|
65
|
+
'hide-instructions': hideInstructionsProp = false,
|
|
66
|
+
'show-back-on-guidelines': showBackOnGuidelinesProp = false,
|
|
67
|
+
}) => {
|
|
68
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
69
|
+
|
|
70
|
+
const showNavigation = getBoolProp(showNavigationProp);
|
|
71
|
+
const allowAgentMode = getBoolProp(allowAgentModeProp);
|
|
72
|
+
const showAgentModeForTests = getBoolProp(showAgentModeForTestsProp);
|
|
73
|
+
const hideAttribution = getBoolProp(hideAttributionProp);
|
|
74
|
+
const hideInstructions = getBoolProp(hideInstructionsProp);
|
|
75
|
+
const showBackOnGuidelines = getBoolProp(showBackOnGuidelinesProp);
|
|
76
|
+
|
|
77
|
+
// This component is always strict-mode (Active Liveness).
|
|
78
|
+
const useStrictMode = true;
|
|
79
|
+
|
|
80
|
+
const smileCooldown = 300;
|
|
81
|
+
const smileThreshold = 0.25;
|
|
82
|
+
const mouthOpenThreshold = 0.05;
|
|
83
|
+
// Slightly tighter range than the legacy SmartSelfie defaults so the
|
|
84
|
+
// "too far / too close" edge cases actually trigger when the face fills
|
|
85
|
+
// significantly less / more of the oval than expected. The band is wider
|
|
86
|
+
// than 0.34–0.55 because hysteresis combined with the 0.34 floor caused
|
|
87
|
+
// typical webcam framing to lock into a "too far" state, masking every
|
|
88
|
+
// other check downstream of it.
|
|
89
|
+
const minFaceSize = 0.35;
|
|
90
|
+
const maxFaceSize = 0.62;
|
|
91
|
+
|
|
92
|
+
// Agent mode is opt-in: the partner enables the toggle via `allow-agent-mode`,
|
|
93
|
+
// but the user-facing camera is always the default. The toggle button lets
|
|
94
|
+
// an operator switch to the rear (environment) camera mid-session.
|
|
95
|
+
const initialFacingMode = 'user';
|
|
96
|
+
const camera = useCamera(initialFacingMode);
|
|
97
|
+
|
|
98
|
+
// Three-view flow: guidelines → capture → review. `hide-instructions`
|
|
99
|
+
// skips the guidelines screen and jumps straight to capture. On confirm,
|
|
100
|
+
// ESS emits `selfie-capture.publish` and hands off to the caller — the
|
|
101
|
+
// screens router (`<selfie-capture-screens>`) or, in the SmartSelfie Auth
|
|
102
|
+
// case, the host product script — which decides what to render next.
|
|
103
|
+
const initialView: View = hideInstructions ? 'capture' : 'guidelines';
|
|
104
|
+
const viewSignal = useRef(signal<View>(initialView)).current;
|
|
105
|
+
const pendingPayload = useRef<any>(null);
|
|
106
|
+
|
|
107
|
+
const faceCapture = useFaceCapture({
|
|
108
|
+
videoRef: camera.videoRef,
|
|
109
|
+
canvasRef,
|
|
110
|
+
interval,
|
|
111
|
+
duration,
|
|
112
|
+
smileThreshold,
|
|
113
|
+
mouthOpenThreshold,
|
|
114
|
+
minFaceSize,
|
|
115
|
+
maxFaceSize,
|
|
116
|
+
smileCooldown,
|
|
117
|
+
getFacingMode: () => camera.facingMode,
|
|
118
|
+
useStrictMode,
|
|
119
|
+
onCaptureComplete: (detail) => {
|
|
120
|
+
// Stash the payload and show the review screen. The user can retake
|
|
121
|
+
// or confirm; only on confirm do we emit `selfie-capture.publish`.
|
|
122
|
+
//
|
|
123
|
+
// Forced-failure completions (e.g. the 120s active-liveness
|
|
124
|
+
// inactivity timeout from the hosted-web page) publish immediately
|
|
125
|
+
// with `detail.forceFailureReason` set so the caller can route to
|
|
126
|
+
// its own failure UI without going through review.
|
|
127
|
+
pendingPayload.current = detail;
|
|
128
|
+
if (detail.forceFailureReason) {
|
|
129
|
+
// Tear down the camera + detection loop explicitly. We're skipping
|
|
130
|
+
// review, so the view stays on 'capture' and the mount effect's
|
|
131
|
+
// cleanup never runs on its own — without this, MediaPipe and the
|
|
132
|
+
// camera stream would keep running silently while the host shows
|
|
133
|
+
// its post-publish UI.
|
|
134
|
+
faceCapture.stopDetectionLoop();
|
|
135
|
+
camera.stopCamera();
|
|
136
|
+
faceCapture.cleanup();
|
|
137
|
+
// Dedicated forced-failure signal for the host. The intermediate
|
|
138
|
+
// `<selfie-capture-screens>` / `<smart-camera-web>` chain only
|
|
139
|
+
// forwards `images` in their re-dispatched `*.publish` events, so
|
|
140
|
+
// `forceFailureReason` would otherwise be invisible at the host
|
|
141
|
+
// layer. We fire this synchronously *before* `selfie-capture.publish`
|
|
142
|
+
// so any host listener can flip its "this is a forced failure" flag
|
|
143
|
+
// in time for the publish handler that follows.
|
|
144
|
+
window.dispatchEvent(
|
|
145
|
+
new CustomEvent('enhanced-smartselfie.force-fail-published', {
|
|
146
|
+
detail: { reason: detail.forceFailureReason },
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
window.dispatchEvent(
|
|
150
|
+
new CustomEvent('selfie-capture.publish', { detail }),
|
|
151
|
+
);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
viewSignal.value = 'review';
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (viewSignal.value !== 'capture') return undefined;
|
|
160
|
+
|
|
161
|
+
const initializeCamera = async () => {
|
|
162
|
+
await camera.startCamera(initialFacingMode, (cameraName) => {
|
|
163
|
+
const smartCameraWeb = document.querySelector('smart-camera-web');
|
|
164
|
+
smartCameraWeb?.dispatchEvent(
|
|
165
|
+
new CustomEvent('metadata.camera-name', {
|
|
166
|
+
detail: { cameraName },
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
await camera.checkAgentSupport();
|
|
171
|
+
await faceCapture.initializeFaceLandmarker();
|
|
172
|
+
|
|
173
|
+
setTimeout(() => {
|
|
174
|
+
faceCapture.setupCanvas();
|
|
175
|
+
faceCapture.startDetectionLoop();
|
|
176
|
+
}, 500);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
camera.registerCameraSwitchCallback(() => {
|
|
180
|
+
try {
|
|
181
|
+
faceCapture.resetFaceDetectionState();
|
|
182
|
+
faceCapture.setupCanvas();
|
|
183
|
+
faceCapture.stopDetectionLoop();
|
|
184
|
+
faceCapture.startDetectionLoop();
|
|
185
|
+
} catch (error) {
|
|
186
|
+
console.error('Error during camera switch callback:', error);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
initializeCamera();
|
|
191
|
+
|
|
192
|
+
return () => {
|
|
193
|
+
faceCapture.stopDetectionLoop();
|
|
194
|
+
camera.stopCamera();
|
|
195
|
+
faceCapture.cleanup();
|
|
196
|
+
};
|
|
197
|
+
}, [viewSignal.value]);
|
|
198
|
+
|
|
199
|
+
// Reset scroll-to-top whenever the active view changes. Without this the
|
|
200
|
+
// host page (which now drives scrolling, since each ESS view uses natural
|
|
201
|
+
// document flow) would land on whatever scroll offset the previous view
|
|
202
|
+
// left behind \u2014 e.g. switching from a long Consent page to Instructions
|
|
203
|
+
// would render Instructions already scrolled to the bottom.
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
try {
|
|
206
|
+
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
|
207
|
+
} catch {
|
|
208
|
+
window.scrollTo(0, 0);
|
|
209
|
+
}
|
|
210
|
+
}, [viewSignal.value]);
|
|
211
|
+
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (faceCapture.hasFinishedCapture.value) {
|
|
214
|
+
const smartCameraWeb = document.querySelector('smart-camera-web');
|
|
215
|
+
smartCameraWeb?.dispatchEvent(
|
|
216
|
+
new CustomEvent('metadata.selfie-capture-end'),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}, [faceCapture.hasFinishedCapture.value]);
|
|
220
|
+
|
|
221
|
+
// Brief "hold still" period at the start of capture: the reference selfie
|
|
222
|
+
// is taken in this window and the active-liveness animation/prompts only
|
|
223
|
+
// appear after it elapses.
|
|
224
|
+
const HOLD_STILL_MS = 1800;
|
|
225
|
+
const [holdStillElapsed, setHoldStillElapsed] = useState(false);
|
|
226
|
+
// Tracks whether the Mediapipe model itself has finished downloading. We
|
|
227
|
+
// pre-warm it as soon as ESS mounts so by the time the user finishes
|
|
228
|
+
// reading the guidelines the heavy network work is already done and
|
|
229
|
+
// Continue can be enabled immediately.
|
|
230
|
+
const [isMediapipeReady, setIsMediapipeReady] = useState(false);
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
let cancelled = false;
|
|
233
|
+
getMediapipeInstance()
|
|
234
|
+
.then(() => {
|
|
235
|
+
if (!cancelled) setIsMediapipeReady(true);
|
|
236
|
+
})
|
|
237
|
+
.catch(() => {
|
|
238
|
+
// Swallow — the capture screen will retry and surface any error there.
|
|
239
|
+
});
|
|
240
|
+
return () => {
|
|
241
|
+
cancelled = true;
|
|
242
|
+
};
|
|
243
|
+
}, []);
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (!faceCapture.isCapturing.value) {
|
|
246
|
+
setHoldStillElapsed(false);
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
setHoldStillElapsed(false);
|
|
250
|
+
const id = window.setTimeout(
|
|
251
|
+
() => setHoldStillElapsed(true),
|
|
252
|
+
HOLD_STILL_MS,
|
|
253
|
+
);
|
|
254
|
+
return () => window.clearTimeout(id);
|
|
255
|
+
}, [faceCapture.isCapturing.value]);
|
|
256
|
+
|
|
257
|
+
// Pre-capture fallback: enable Start Capture after 5s even if the user
|
|
258
|
+
// hasn't centred their head yet. Quality checks (lighting / blur /
|
|
259
|
+
// distance) only run during active capture, so we don't want partners to
|
|
260
|
+
// get stuck on a permanently-disabled button.
|
|
261
|
+
const PRE_CAPTURE_FALLBACK_MS = 5000;
|
|
262
|
+
const [preCaptureFallbackElapsed, setPreCaptureFallbackElapsed] =
|
|
263
|
+
useState(false);
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
if (viewSignal.value !== 'capture') return undefined;
|
|
266
|
+
if (faceCapture.isCapturing.value) return undefined;
|
|
267
|
+
if (faceCapture.hasFinishedCapture.value) return undefined;
|
|
268
|
+
setPreCaptureFallbackElapsed(false);
|
|
269
|
+
const id = window.setTimeout(
|
|
270
|
+
() => setPreCaptureFallbackElapsed(true),
|
|
271
|
+
PRE_CAPTURE_FALLBACK_MS,
|
|
272
|
+
);
|
|
273
|
+
return () => window.clearTimeout(id);
|
|
274
|
+
}, [
|
|
275
|
+
viewSignal.value,
|
|
276
|
+
faceCapture.isCapturing.value,
|
|
277
|
+
faceCapture.hasFinishedCapture.value,
|
|
278
|
+
]);
|
|
279
|
+
|
|
280
|
+
// Track device orientation. Active-liveness expects the user to hold the
|
|
281
|
+
// device upright — landscape framing puts the face sideways inside the
|
|
282
|
+
// oval, breaks the bounds/pose checks, and produces unusable selfies.
|
|
283
|
+
// We detect both rotated screens (matchMedia) and rotated mobile devices
|
|
284
|
+
// that report orientation through the Screen Orientation API.
|
|
285
|
+
const [isLandscape, setIsLandscape] = useState(false);
|
|
286
|
+
useEffect(() => {
|
|
287
|
+
if (typeof window === 'undefined' || !window.matchMedia) return undefined;
|
|
288
|
+
|
|
289
|
+
// Only enforce the orientation edge case on touch / mobile-class devices.
|
|
290
|
+
// Desktop/laptop browsers can be resized into a "landscape <" wrapper
|
|
291
|
+
// ratio without the camera or user actually being sideways, and there's
|
|
292
|
+
// no rotate gesture available to recover from the prompt.
|
|
293
|
+
const isMobileDevice =
|
|
294
|
+
// matchMedia hover/pointer hints — most reliable cross-browser way to
|
|
295
|
+
// tell apart a touch-only device from a mouse-driven one.
|
|
296
|
+
window.matchMedia('(hover: none) and (pointer: coarse)').matches ||
|
|
297
|
+
// Touch capability fallback for browsers that don't expose the hover
|
|
298
|
+
// media query (older Android WebView, embedded browsers).
|
|
299
|
+
(typeof navigator !== 'undefined' &&
|
|
300
|
+
((navigator as any).maxTouchPoints > 0 ||
|
|
301
|
+
/Android|iPhone|iPad|iPod|Mobile|webOS|BlackBerry|IEMobile|Opera Mini/i.test(
|
|
302
|
+
navigator.userAgent || '',
|
|
303
|
+
)));
|
|
304
|
+
if (!isMobileDevice) return undefined;
|
|
305
|
+
|
|
306
|
+
const mql = window.matchMedia('(orientation: landscape)');
|
|
307
|
+
const update = () => setIsLandscape(mql.matches);
|
|
308
|
+
update();
|
|
309
|
+
// Older Safari only supports addListener/removeListener.
|
|
310
|
+
if (mql.addEventListener) mql.addEventListener('change', update);
|
|
311
|
+
else mql.addListener(update);
|
|
312
|
+
return () => {
|
|
313
|
+
if (mql.removeEventListener) mql.removeEventListener('change', update);
|
|
314
|
+
else mql.removeListener(update);
|
|
315
|
+
};
|
|
316
|
+
}, []);
|
|
317
|
+
|
|
318
|
+
// If the device flips to landscape mid-capture, pause the capture interval
|
|
319
|
+
// immediately so we don't accumulate liveness frames while the user's face
|
|
320
|
+
// is sideways inside the oval. Resume once the device returns to portrait —
|
|
321
|
+
// the capture sequence picks up from where it left off and continues
|
|
322
|
+
// forward without forcing the user to restart.
|
|
323
|
+
useEffect(() => {
|
|
324
|
+
if (!faceCapture.isCapturing.value) return;
|
|
325
|
+
if (isLandscape) {
|
|
326
|
+
faceCapture.pauseCapture();
|
|
327
|
+
} else if (faceCapture.isPaused.value) {
|
|
328
|
+
faceCapture.resumeCapture();
|
|
329
|
+
}
|
|
330
|
+
}, [isLandscape, faceCapture.isCapturing.value]);
|
|
331
|
+
|
|
332
|
+
// Host-driven forced-failure path (e.g. the hosted-web 120s active-liveness
|
|
333
|
+
// inactivity timer). The host dispatches `enhanced-smartselfie.force-fail`
|
|
334
|
+
// with a `{ reason }` detail; the hook packages whatever frames have been
|
|
335
|
+
// captured so far and fires `onCaptureComplete` with `forceFailureReason`
|
|
336
|
+
// set, which publishes immediately so the caller can surface its own
|
|
337
|
+
// failure UI.
|
|
338
|
+
useEffect(() => {
|
|
339
|
+
const handler = (e: Event) => {
|
|
340
|
+
const ce = e as CustomEvent<{ reason?: string }>;
|
|
341
|
+
const reason = ce.detail?.reason ?? 'active_liveness_timed_out';
|
|
342
|
+
// Only react while the user is actively in the capture flow; ignore
|
|
343
|
+
// the event if we've already moved on to review/submitting/etc.
|
|
344
|
+
if (viewSignal.value !== 'capture') return;
|
|
345
|
+
faceCapture.forceFailCapture(reason);
|
|
346
|
+
};
|
|
347
|
+
window.addEventListener('enhanced-smartselfie.force-fail', handler);
|
|
348
|
+
return () => {
|
|
349
|
+
window.removeEventListener('enhanced-smartselfie.force-fail', handler);
|
|
350
|
+
};
|
|
351
|
+
}, []);
|
|
352
|
+
|
|
353
|
+
const handleConfirm = () => {
|
|
354
|
+
const payload = pendingPayload.current;
|
|
355
|
+
if (payload) {
|
|
356
|
+
// Publish and stay on the review screen. The caller — the screens
|
|
357
|
+
// router for KYC/DV/EDV flows, or the SmartSelfie Auth host page —
|
|
358
|
+
// decides what to render next (next form, submitting card, etc.).
|
|
359
|
+
window.dispatchEvent(
|
|
360
|
+
new CustomEvent('selfie-capture.publish', { detail: payload }),
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const handleRetake = () => {
|
|
366
|
+
pendingPayload.current = null;
|
|
367
|
+
faceCapture.hasFinishedCapture.value = false;
|
|
368
|
+
faceCapture.capturesTaken.value = 0;
|
|
369
|
+
viewSignal.value = 'capture';
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// Back-button navigation: review → retake; capture → guidelines (or
|
|
373
|
+
// cancel when guidelines are hidden); guidelines → cancel. "Cancel"
|
|
374
|
+
// surfaces `selfie-capture.cancelled` so the caller can decide what to do.
|
|
375
|
+
const handleBack = () => {
|
|
376
|
+
const current = viewSignal.value;
|
|
377
|
+
if (current === 'review') {
|
|
378
|
+
handleRetake();
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (current === 'capture') {
|
|
382
|
+
// Stop the camera/detection loop before leaving the capture view so
|
|
383
|
+
// we don't leak resources while sitting on the guidelines screen.
|
|
384
|
+
faceCapture.stopDetectionLoop();
|
|
385
|
+
camera.stopCamera();
|
|
386
|
+
faceCapture.resetFaceDetectionState();
|
|
387
|
+
// Wipe the in-flight capture session so re-entering capture starts
|
|
388
|
+
// fresh (new pose sequence, prompt 1, empty image buffer).
|
|
389
|
+
faceCapture.stopCapture();
|
|
390
|
+
faceCapture.capturedImages.value = [];
|
|
391
|
+
faceCapture.referencePhoto.value = '';
|
|
392
|
+
faceCapture.poseSequence.value = [];
|
|
393
|
+
faceCapture.currentPoseIndex.value = 0;
|
|
394
|
+
faceCapture.hasFinishedCapture.value = false;
|
|
395
|
+
faceCapture.capturesTaken.value = 0;
|
|
396
|
+
if (!hideInstructions) {
|
|
397
|
+
viewSignal.value = 'guidelines';
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
faceCapture.handleCancel();
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
// Guidelines or anything else: cancel.
|
|
404
|
+
faceCapture.handleCancel();
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
if (viewSignal.value === 'guidelines') {
|
|
408
|
+
return (
|
|
409
|
+
<CaptureGuidelines
|
|
410
|
+
themeColor={themeColor}
|
|
411
|
+
hideAttribution={hideAttribution}
|
|
412
|
+
isReady={isMediapipeReady}
|
|
413
|
+
onContinue={() => {
|
|
414
|
+
viewSignal.value = 'capture';
|
|
415
|
+
}}
|
|
416
|
+
onBack={showNavigation && showBackOnGuidelines ? handleBack : undefined}
|
|
417
|
+
/>
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (viewSignal.value === 'review') {
|
|
422
|
+
const detail = pendingPayload.current;
|
|
423
|
+
const src = detail?.referenceImage || '';
|
|
424
|
+
const mirror = detail?.facingMode === 'user';
|
|
425
|
+
return (
|
|
426
|
+
<SubmissionView
|
|
427
|
+
imageSrc={src}
|
|
428
|
+
mirror={mirror}
|
|
429
|
+
themeColor={themeColor}
|
|
430
|
+
hideAttribution={hideAttribution}
|
|
431
|
+
mode="review"
|
|
432
|
+
onConfirm={handleConfirm}
|
|
433
|
+
onRetake={handleRetake}
|
|
434
|
+
onBack={showNavigation ? handleBack : undefined}
|
|
435
|
+
/>
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Centralised quality-check evaluation: pick the dominant problem and
|
|
440
|
+
// derive both the prompt copy and which side of the oval to colour red.
|
|
441
|
+
// Pre-capture (idle) only nudges the user to centre their head — every
|
|
442
|
+
// other quality check (lighting / blur / proximity) is deferred until
|
|
443
|
+
// active capture begins. During capture, framing/centering is no longer
|
|
444
|
+
// re-evaluated since the head is already locked in.
|
|
445
|
+
const isIdle =
|
|
446
|
+
!faceCapture.isCapturing.value && !faceCapture.hasFinishedCapture.value;
|
|
447
|
+
const isHoldingStill = faceCapture.isCapturing.value && !holdStillElapsed;
|
|
448
|
+
|
|
449
|
+
const isFaceCentered =
|
|
450
|
+
faceCapture.faceDetected.value &&
|
|
451
|
+
faceCapture.faceInBounds.value &&
|
|
452
|
+
!faceCapture.faceClippingOval.value;
|
|
453
|
+
|
|
454
|
+
// Default to the pre-capture instruction. We deliberately ignore the
|
|
455
|
+
// hook's transient alertTitle here so the prompt never blanks out as
|
|
456
|
+
// detection signals fluctuate — it stays put until capture begins.
|
|
457
|
+
let alertTitle = isIdle
|
|
458
|
+
? t('selfie.ess.alert.centerFace')
|
|
459
|
+
: faceCapture.alertTitle.value;
|
|
460
|
+
let errorSide: 'top' | 'right' | 'bottom' | 'left' | 'all' | null = null;
|
|
461
|
+
// Whether any active-capture quality check is currently failing. Used to
|
|
462
|
+
// suppress the active-liveness pose animation so the user isn't shown a
|
|
463
|
+
// "turn your head" cue while we still need them to fix lighting/blur/etc.
|
|
464
|
+
let captureCheckFailing = false;
|
|
465
|
+
|
|
466
|
+
if (isLandscape) {
|
|
467
|
+
alertTitle = '';
|
|
468
|
+
errorSide = 'all';
|
|
469
|
+
captureCheckFailing = true;
|
|
470
|
+
} else if (isIdle) {
|
|
471
|
+
// Pre-capture: keep the static "Centre your face within the oval frame"
|
|
472
|
+
// instruction. No directional / lighting / blur / distance prompts run
|
|
473
|
+
// until the user taps Start Capture — quality checks are deferred to
|
|
474
|
+
// the active-capture phase.
|
|
475
|
+
} else if (isHoldingStill) {
|
|
476
|
+
alertTitle = t('selfie.ess.alert.holdStill');
|
|
477
|
+
} else if (faceCapture.isCapturing.value) {
|
|
478
|
+
// Active capture: run quality checks. Centering is no longer evaluated
|
|
479
|
+
// here — once capture has started, head turns are expected.
|
|
480
|
+
if (faceCapture.isTooDark.value) {
|
|
481
|
+
alertTitle = t('selfie.ess.alert.tooDark');
|
|
482
|
+
errorSide = 'all';
|
|
483
|
+
captureCheckFailing = true;
|
|
484
|
+
} else if (faceCapture.isTooBlurry.value) {
|
|
485
|
+
errorSide = 'all';
|
|
486
|
+
captureCheckFailing = true;
|
|
487
|
+
} else if (faceCapture.faceProximity.value === 'too-close') {
|
|
488
|
+
alertTitle = t('selfie.ess.alert.tooClose');
|
|
489
|
+
errorSide = 'all';
|
|
490
|
+
captureCheckFailing = true;
|
|
491
|
+
} else if (faceCapture.faceProximity.value === 'too-far') {
|
|
492
|
+
alertTitle = t('selfie.ess.alert.tooFar');
|
|
493
|
+
errorSide = 'all';
|
|
494
|
+
captureCheckFailing = true;
|
|
495
|
+
} else if (faceCapture.faceOffsetDirection.value) {
|
|
496
|
+
// Directional nudges: face is detected but offset from the oval
|
|
497
|
+
// centre by more than the hook's small threshold. Surfaced during
|
|
498
|
+
// capture so the user re-frames before we evaluate pose prompts.
|
|
499
|
+
const dir = faceCapture.faceOffsetDirection.value;
|
|
500
|
+
if (dir === 'top') alertTitle = t('selfie.ess.alert.moveDeviceUp');
|
|
501
|
+
else if (dir === 'bottom')
|
|
502
|
+
alertTitle = t('selfie.ess.alert.moveDeviceDown');
|
|
503
|
+
else if (dir === 'left')
|
|
504
|
+
alertTitle = t('selfie.ess.alert.moveDeviceLeft');
|
|
505
|
+
else if (dir === 'right')
|
|
506
|
+
alertTitle = t('selfie.ess.alert.moveDeviceRight');
|
|
507
|
+
errorSide = dir;
|
|
508
|
+
captureCheckFailing = true;
|
|
509
|
+
} else {
|
|
510
|
+
// Active-liveness pose prompts: override the localised hook copy with
|
|
511
|
+
// the design-spec wording.
|
|
512
|
+
const pose =
|
|
513
|
+
faceCapture.poseSequence.value[faceCapture.currentPoseIndex.value];
|
|
514
|
+
if (pose === 'left') alertTitle = t('selfie.ess.alert.turnHeadLeft');
|
|
515
|
+
else if (pose === 'right')
|
|
516
|
+
alertTitle = t('selfie.ess.alert.turnHeadRight');
|
|
517
|
+
else if (pose === 'up') alertTitle = t('selfie.ess.alert.tiltHeadUp');
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return (
|
|
522
|
+
<div className="smartselfie-capture">
|
|
523
|
+
{showNavigation && (
|
|
524
|
+
<BackNavigation onBack={handleBack} themeColor={themeColor} />
|
|
525
|
+
)}
|
|
526
|
+
|
|
527
|
+
<CameraPreview
|
|
528
|
+
videoRef={camera.videoRef}
|
|
529
|
+
canvasRef={canvasRef}
|
|
530
|
+
facingMode={camera.facingMode}
|
|
531
|
+
themeColor={themeColor}
|
|
532
|
+
errorSide={errorSide}
|
|
533
|
+
overlay={(() => {
|
|
534
|
+
if (isLandscape) {
|
|
535
|
+
return (
|
|
536
|
+
<ActiveLivenessOverlay
|
|
537
|
+
pose={null}
|
|
538
|
+
currentPose={null}
|
|
539
|
+
isTooDark={false}
|
|
540
|
+
isLandscape
|
|
541
|
+
/>
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
if (faceCapture.isTooDark.value) {
|
|
545
|
+
// Show the too-dark animation as soon as the scene is too dark,
|
|
546
|
+
// even before active capture starts, so the user gets visual
|
|
547
|
+
// guidance matching the alert text.
|
|
548
|
+
return (
|
|
549
|
+
<ActiveLivenessOverlay pose={null} currentPose={null} isTooDark />
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
if (
|
|
553
|
+
faceCapture.isCapturing.value &&
|
|
554
|
+
holdStillElapsed &&
|
|
555
|
+
!captureCheckFailing
|
|
556
|
+
) {
|
|
557
|
+
return (
|
|
558
|
+
<ActiveLivenessOverlay
|
|
559
|
+
pose={
|
|
560
|
+
faceCapture.poseSequence.value[
|
|
561
|
+
faceCapture.currentPoseIndex.value
|
|
562
|
+
] ?? null
|
|
563
|
+
}
|
|
564
|
+
currentPose={faceCapture.currentPose.value}
|
|
565
|
+
isTooDark={faceCapture.isTooDark.value}
|
|
566
|
+
/>
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
return null;
|
|
570
|
+
})()}
|
|
571
|
+
/>
|
|
572
|
+
|
|
573
|
+
{/* Alert text + controls + attribution sit naturally below the
|
|
574
|
+
camera preview, matching the SmartSelfieCapture layout. */}
|
|
575
|
+
<AlertDisplay alertTitle={alertTitle} themeColor={themeColor} />
|
|
576
|
+
|
|
577
|
+
{!faceCapture.isCapturing.value &&
|
|
578
|
+
!faceCapture.hasFinishedCapture.value && (
|
|
579
|
+
<CaptureControls
|
|
580
|
+
isCapturing={faceCapture.isCapturing.value}
|
|
581
|
+
hasFinishedCapture={faceCapture.hasFinishedCapture.value}
|
|
582
|
+
// Pre-capture readiness: face must be centered, OR the 5-second
|
|
583
|
+
// fallback has elapsed. Quality checks no longer gate the Start
|
|
584
|
+
// Capture button — they only run after capture begins.
|
|
585
|
+
isReadyToCapture={
|
|
586
|
+
(isFaceCentered || preCaptureFallbackElapsed) && !isLandscape
|
|
587
|
+
}
|
|
588
|
+
// ESS strict-mode: never bypass the readiness check. The legacy
|
|
589
|
+
// fallback timer enables the button after 10s even when the
|
|
590
|
+
// face isn't centered, but we want capture to only start once
|
|
591
|
+
// the user is properly framed inside the oval.
|
|
592
|
+
captureButtonFallbackEnabled={false}
|
|
593
|
+
allowAgentMode={allowAgentMode}
|
|
594
|
+
agentSupported={camera.agentSupported}
|
|
595
|
+
showAgentModeForTests={showAgentModeForTests}
|
|
596
|
+
facingMode={camera.facingMode}
|
|
597
|
+
themeColor={themeColor}
|
|
598
|
+
onStartCapture={faceCapture.startCapture}
|
|
599
|
+
onSwitchCamera={camera.switchCamera}
|
|
600
|
+
/>
|
|
601
|
+
)}
|
|
602
|
+
|
|
603
|
+
{!hideAttribution && (
|
|
604
|
+
// @ts-expect-error preact-custom-element types
|
|
605
|
+
<powered-by-smile-id />
|
|
606
|
+
)}
|
|
607
|
+
|
|
608
|
+
<style>{`
|
|
609
|
+
* { box-sizing: border-box; }
|
|
610
|
+
:host { display: block; height: 100%; }
|
|
611
|
+
.smartselfie-capture {
|
|
612
|
+
position: relative;
|
|
613
|
+
display: flex;
|
|
614
|
+
flex-direction: column;
|
|
615
|
+
height: 100%;
|
|
616
|
+
padding: 6.5rem 1rem 0.5rem;
|
|
617
|
+
font-family: "DM Sans", system-ui, sans-serif;
|
|
618
|
+
background: #F5F7FA;
|
|
619
|
+
overflow: hidden;
|
|
620
|
+
}
|
|
621
|
+
.smartselfie-capture > .ess-back-navigation {
|
|
622
|
+
position: absolute;
|
|
623
|
+
top: 1.5rem;
|
|
624
|
+
left: 1rem;
|
|
625
|
+
z-index: 2;
|
|
626
|
+
}
|
|
627
|
+
.smartselfie-capture > powered-by-smile-id {
|
|
628
|
+
display: block;
|
|
629
|
+
width: 100%;
|
|
630
|
+
margin-top: clamp(0.75rem, 2dvh, 1.25rem);
|
|
631
|
+
padding-top: clamp(0.4rem, 1dvh, 0.6rem);
|
|
632
|
+
background: #F5F7FA;
|
|
633
|
+
flex-shrink: 0;
|
|
634
|
+
}
|
|
635
|
+
button {
|
|
636
|
+
padding: 10px 20px;
|
|
637
|
+
background: ${themeColor || '#001096'};
|
|
638
|
+
color: white;
|
|
639
|
+
border: none;
|
|
640
|
+
border-radius: 4px;
|
|
641
|
+
cursor: pointer;
|
|
642
|
+
font-size: 16px;
|
|
643
|
+
}
|
|
644
|
+
button:disabled { background: #ccc; cursor: not-allowed; }
|
|
645
|
+
button.btn-primary {
|
|
646
|
+
background-color: ${themeColor || '#001096'};
|
|
647
|
+
border-radius: 2.5rem;
|
|
648
|
+
color: white;
|
|
649
|
+
border: none;
|
|
650
|
+
height: 3.125rem;
|
|
651
|
+
padding: 0.75rem 1.5rem;
|
|
652
|
+
font-size: 1.125rem;
|
|
653
|
+
font-weight: 600;
|
|
654
|
+
font-family: inherit;
|
|
655
|
+
cursor: pointer;
|
|
656
|
+
}
|
|
657
|
+
button.btn-primary:hover { background-color: #2d2b2a; }
|
|
658
|
+
button.btn-primary:disabled { background-color: #666; cursor: not-allowed; }
|
|
659
|
+
`}</style>
|
|
660
|
+
</div>
|
|
661
|
+
);
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
if (!customElements.get('enhanced-smartselfie-capture')) {
|
|
665
|
+
register(
|
|
666
|
+
EnhancedSmartSelfieCapture,
|
|
667
|
+
'enhanced-smartselfie-capture',
|
|
668
|
+
[
|
|
669
|
+
'interval',
|
|
670
|
+
'duration',
|
|
671
|
+
'theme-color',
|
|
672
|
+
'show-navigation',
|
|
673
|
+
'allow-agent-mode',
|
|
674
|
+
'show-agent-mode-for-tests',
|
|
675
|
+
'hide-attribution',
|
|
676
|
+
'disable-image-tests',
|
|
677
|
+
'hide-instructions',
|
|
678
|
+
'show-back-on-guidelines',
|
|
679
|
+
],
|
|
680
|
+
{ shadow: true },
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export default EnhancedSmartSelfieCapture;
|