@smileid/web-components 11.4.5 → 11.6.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-DjSTdVP-.js +5398 -0
- package/dist/esm/DocumentCaptureScreens-DjSTdVP-.js.map +1 -0
- package/dist/esm/{Navigation-Bb7MPLE8.js → Navigation-6DH3vF4-.js} +28 -22
- package/dist/esm/Navigation-6DH3vF4-.js.map +1 -0
- package/dist/esm/{PoweredBySmileId-CxbaihMu.js → PoweredBySmileId-DoKwoPUd.js} +424 -6
- package/dist/esm/PoweredBySmileId-DoKwoPUd.js.map +1 -0
- package/dist/esm/SelfieCaptureScreens-CtX-4Tco.js +11470 -0
- package/dist/esm/SelfieCaptureScreens-CtX-4Tco.js.map +1 -0
- package/dist/esm/combobox.js +1 -1
- package/dist/esm/document.js +1 -1
- package/dist/esm/end-user-consent.js +713 -2
- package/dist/esm/end-user-consent.js.map +1 -1
- package/dist/esm/index-BqyuTk9f.js +1366 -0
- package/dist/esm/{index-C4RTMbgw.js.map → index-BqyuTk9f.js.map} +1 -1
- package/dist/esm/localisation.js +1 -1
- package/dist/esm/main.js +14 -14
- package/dist/esm/navigation.js +1 -1
- package/dist/esm/package-CjZI-cNQ.js +2540 -0
- package/dist/esm/package-CjZI-cNQ.js.map +1 -0
- package/dist/esm/selfie.js +1 -1
- package/dist/esm/smart-camera-web.js +81 -37
- package/dist/esm/smart-camera-web.js.map +1 -1
- package/dist/esm/totp-consent.js +731 -2
- package/dist/esm/totp-consent.js.map +1 -1
- package/dist/esm/validate.js +31 -0
- package/dist/esm/validate.js.map +1 -0
- package/dist/smart-camera-web.js +1513 -383
- package/dist/smart-camera-web.js.map +1 -1
- package/dist/types/main.d.ts +18 -1
- package/dist/types/validate.d.ts +21 -0
- package/lib/components/document/src/DocumentCaptureScreens.js +97 -18
- package/lib/components/document/src/assets/lottie.d.ts +12 -0
- package/lib/components/document/src/assets/svg-inline.d.ts +8 -0
- package/lib/components/document/src/document-auto-capture/DocumentAutoCapture.stories.js +75 -0
- package/lib/components/document/src/document-auto-capture/DocumentAutoCapture.tsx +1458 -0
- package/lib/components/document/src/document-auto-capture/README.md +73 -0
- package/lib/components/document/src/document-auto-capture/assets/Greenbook_Shimmer.svg +42 -0
- package/lib/components/document/src/document-auto-capture/assets/ID_Back_Shimmer.svg +8 -0
- package/lib/components/document/src/document-auto-capture/assets/ID_Front_Shimmer.svg +20 -0
- package/lib/components/document/src/document-auto-capture/assets/Passport-Shimmer.svg +143 -0
- package/lib/components/document/src/document-auto-capture/assets/shimmers.ts +21 -0
- package/lib/components/document/src/document-auto-capture/assets/svg-raw.d.ts +4 -0
- package/lib/components/document/src/document-auto-capture/components/CaptureButton.tsx +122 -0
- package/lib/components/document/src/document-auto-capture/components/Overlay.tsx +167 -0
- package/lib/components/document/src/document-auto-capture/components/TuningPanel.tsx +856 -0
- package/lib/components/document/src/document-auto-capture/constants/captureLayout.ts +58 -0
- package/lib/components/document/src/document-auto-capture/detection/cvErrorRecovery.ts +40 -0
- package/lib/components/document/src/document-auto-capture/detection/documentAspect.ts +20 -0
- package/lib/components/document/src/document-auto-capture/detection/qualityScoring.ts +35 -0
- package/lib/components/document/src/document-auto-capture/detection/seamRejection.ts +209 -0
- package/lib/components/document/src/document-auto-capture/detection/synthesisTiming.ts +10 -0
- package/lib/components/document/src/document-auto-capture/hooks/useCamera.ts +117 -0
- package/lib/components/document/src/document-auto-capture/hooks/useCardDetection.ts +3059 -0
- package/lib/components/document/src/document-auto-capture/index.ts +4 -0
- package/lib/components/document/src/document-auto-capture/theme.ts +40 -0
- package/lib/components/document/src/document-auto-capture/utils/debug.ts +25 -0
- package/lib/components/document/src/document-auto-capture/utils/opencvLoader.ts +86 -0
- package/lib/components/document/src/document-capture-instructions/DocumentCaptureInstructions.tsx +327 -244
- package/lib/components/document/src/document-capture-review/DocumentCaptureReview.js +153 -189
- package/lib/components/document/src/document-capture-submission/DocumentCaptureSubmission.tsx +432 -0
- package/lib/components/document/src/document-capture-submission/index.js +3 -0
- package/lib/components/navigation/src/Navigation.js +27 -8
- package/lib/components/selfie/README.md +13 -0
- package/lib/components/selfie/src/SelfieCaptureScreens.js +56 -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 +24 -1
- 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/signature-pad/package.json +1 -1
- package/lib/components/smart-camera-web/src/README.md +11 -0
- package/lib/components/smart-camera-web/src/SmartCameraWeb.js +89 -8
- package/lib/components/totp-consent/src/TotpConsent.js +1 -1
- package/lib/domain/localisation/index.js +2 -2
- package/package.json +9 -5
- package/dist/esm/DocumentCaptureScreens-D2G0NOQr.js +0 -4147
- package/dist/esm/DocumentCaptureScreens-D2G0NOQr.js.map +0 -1
- package/dist/esm/EndUserConsent-uHfA3txP.js +0 -717
- package/dist/esm/EndUserConsent-uHfA3txP.js.map +0 -1
- package/dist/esm/Navigation-Bb7MPLE8.js.map +0 -1
- package/dist/esm/PoweredBySmileId-CxbaihMu.js.map +0 -1
- package/dist/esm/SelfieCaptureScreens-Dr7VzON7.js +0 -7651
- package/dist/esm/SelfieCaptureScreens-Dr7VzON7.js.map +0 -1
- package/dist/esm/TotpConsent-Depzg0ti.js +0 -734
- package/dist/esm/TotpConsent-Depzg0ti.js.map +0 -1
- package/dist/esm/index-C4RTMbgw.js +0 -1360
- package/dist/esm/package-D6YrpMcO.js +0 -565
- package/dist/esm/package-D6YrpMcO.js.map +0 -1
- package/dist/esm/styles-BTEClL7R.js +0 -419
- package/dist/esm/styles-BTEClL7R.js.map +0 -1
- /package/lib/components/document/src/assets/lottie/{taking photo of green book passport.lottie → greenbook.lottie} +0 -0
- /package/lib/components/document/src/assets/lottie/{taking photo of ID FLIP 2D.lottie → id-card-flip.lottie} +0 -0
- /package/lib/components/document/src/assets/lottie/{taking photo of ID.lottie → id-card.lottie} +0 -0
- /package/lib/components/document/src/assets/lottie/{taking photo of passport 2.lottie → passport.lottie} +0 -0
|
@@ -0,0 +1,1075 @@
|
|
|
1
|
+
import { useRef } from 'preact/hooks';
|
|
2
|
+
import { useSignal, useComputed } from '@preact/signals';
|
|
3
|
+
import type { RefObject } from 'preact';
|
|
4
|
+
import { FaceLandmarker } from '@mediapipe/tasks-vision';
|
|
5
|
+
import throttle from 'lodash/throttle';
|
|
6
|
+
import {
|
|
7
|
+
calculateFaceSize,
|
|
8
|
+
isFaceInBounds,
|
|
9
|
+
computeFaceClippingOval,
|
|
10
|
+
computeFaceClippingSide,
|
|
11
|
+
calculateMouthOpening,
|
|
12
|
+
calculateHeadPose,
|
|
13
|
+
classifyHeadPose,
|
|
14
|
+
buildRandomPoseSequence,
|
|
15
|
+
type HeadPoseDirection,
|
|
16
|
+
} from '../utils/faceDetection';
|
|
17
|
+
import {
|
|
18
|
+
calculateLuminance,
|
|
19
|
+
calculateBlurScore,
|
|
20
|
+
DEFAULT_LUMINANCE_MIN,
|
|
21
|
+
DEFAULT_BLUR_MIN,
|
|
22
|
+
} from '../utils/imageQuality';
|
|
23
|
+
import {
|
|
24
|
+
createCroppedVideoFrame,
|
|
25
|
+
drawFaceMesh,
|
|
26
|
+
clearCanvas,
|
|
27
|
+
} from '../utils/canvas';
|
|
28
|
+
import { captureImageFromVideo } from '../utils/imageCapture';
|
|
29
|
+
import { ImageType } from '../constants';
|
|
30
|
+
import { MESSAGES, type MessageKey } from '../utils/alertMessages';
|
|
31
|
+
import { getMediapipeInstance } from '../utils/mediapipeManager';
|
|
32
|
+
import { t } from '../../../../../domain/localisation';
|
|
33
|
+
import packageJson from '../../../../../../package.json';
|
|
34
|
+
|
|
35
|
+
const COMPONENTS_VERSION = packageJson.version;
|
|
36
|
+
|
|
37
|
+
interface UseFaceCaptureProps {
|
|
38
|
+
videoRef: RefObject<HTMLVideoElement>;
|
|
39
|
+
canvasRef: RefObject<HTMLCanvasElement>;
|
|
40
|
+
interval: number;
|
|
41
|
+
duration: number;
|
|
42
|
+
smileThreshold: number;
|
|
43
|
+
mouthOpenThreshold: number;
|
|
44
|
+
minFaceSize: number;
|
|
45
|
+
maxFaceSize: number;
|
|
46
|
+
smileCooldown: number;
|
|
47
|
+
getFacingMode: () => CameraFacingMode;
|
|
48
|
+
/**
|
|
49
|
+
* Enhanced SmartSelfie / Active Liveness mode. When enabled, the smile-based
|
|
50
|
+
* capture flow is replaced with a randomised head-pose sequence and stricter
|
|
51
|
+
* frame-quality checks (lighting/blur/centering).
|
|
52
|
+
*/
|
|
53
|
+
useStrictMode?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Optional callback invoked when capture completes. When provided, the hook
|
|
56
|
+
* will NOT broadcast the legacy `selfie-capture.publish` window event — the
|
|
57
|
+
* caller takes full ownership of the payload (e.g. to show its own review
|
|
58
|
+
* screen and re-emit the event only on user confirmation).
|
|
59
|
+
*/
|
|
60
|
+
onCaptureComplete?: (detail: {
|
|
61
|
+
images: { image: string; image_type_id: number }[];
|
|
62
|
+
referenceImage: string;
|
|
63
|
+
previewImage: string;
|
|
64
|
+
facingMode: CameraFacingMode;
|
|
65
|
+
forceFailureReason?: string;
|
|
66
|
+
meta: { libraryVersion: string };
|
|
67
|
+
}) => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const useFaceCapture = ({
|
|
71
|
+
videoRef,
|
|
72
|
+
canvasRef,
|
|
73
|
+
interval,
|
|
74
|
+
duration,
|
|
75
|
+
smileThreshold,
|
|
76
|
+
mouthOpenThreshold,
|
|
77
|
+
minFaceSize,
|
|
78
|
+
maxFaceSize,
|
|
79
|
+
smileCooldown,
|
|
80
|
+
getFacingMode,
|
|
81
|
+
useStrictMode = false,
|
|
82
|
+
onCaptureComplete,
|
|
83
|
+
}: UseFaceCaptureProps) => {
|
|
84
|
+
const faceLandmarkerRef = useRef<FaceLandmarker | null>(null);
|
|
85
|
+
const animationFrameRef = useRef<number | null>(null);
|
|
86
|
+
const captureTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
87
|
+
const resumeCaptureRef = useRef<(() => void) | null>(null);
|
|
88
|
+
const fallbackTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
89
|
+
// Ring buffer of the last few raw pose classifications. The capture gate
|
|
90
|
+
// and the displayed pose flip only when a direction wins ≥2 of the last
|
|
91
|
+
// 3 ticks, which kills single-frame noise from atan2 jitter without
|
|
92
|
+
// adding any perceptible lag.
|
|
93
|
+
const poseHistoryRef = useRef<(HeadPoseDirection | null)[]>([]);
|
|
94
|
+
// Timestamp of the most recent tick where the smoothed pose matched the
|
|
95
|
+
// currently-required pose. Used as a short latch so a capture-interval
|
|
96
|
+
// tick that fires moments after the user nails the pose still counts.
|
|
97
|
+
const lastPoseMatchAtRef = useRef<number>(0);
|
|
98
|
+
// How long after a confirmed pose match the capture tick will still
|
|
99
|
+
// accept the pose as held. Sized to comfortably span one capture
|
|
100
|
+
// interval (~330ms at the default rate) plus a little headroom.
|
|
101
|
+
const POSE_MATCH_LATCH_MS = 500;
|
|
102
|
+
|
|
103
|
+
const faceDetected = useSignal(false);
|
|
104
|
+
const faceInBounds = useSignal(false);
|
|
105
|
+
// True when any landmark falls outside the visible egg-shaped clip mask.
|
|
106
|
+
// Computed from runtime DOM rects so it always matches what the user sees,
|
|
107
|
+
// independent of the camera's intrinsic resolution. Only used as a gating
|
|
108
|
+
// edge case when idle — during the active-liveness pose phase head turns
|
|
109
|
+
// legitimately push landmarks past the oval edge.
|
|
110
|
+
const faceClippingOval = useSignal(false);
|
|
111
|
+
const faceProximity = useSignal<'too-close' | 'too-far' | 'good'>('good');
|
|
112
|
+
const videoAspectRatio = useSignal(16 / 9);
|
|
113
|
+
const faceLandmarks = useSignal<any[]>([]);
|
|
114
|
+
const currentSmileScore = useSignal(0);
|
|
115
|
+
const currentFaceSize = useSignal(0);
|
|
116
|
+
const currentMouthOpen = useSignal(0);
|
|
117
|
+
const lastSmileTime = useSignal(0);
|
|
118
|
+
const alertTitle = useSignal('');
|
|
119
|
+
const isInitializing = useSignal(true);
|
|
120
|
+
const captureButtonFallbackEnabled = useSignal(false);
|
|
121
|
+
|
|
122
|
+
const isCapturing = useSignal(false);
|
|
123
|
+
const isPaused = useSignal(false);
|
|
124
|
+
const countdown = useSignal(0);
|
|
125
|
+
const capturedImages = useSignal<string[]>([]);
|
|
126
|
+
const referencePhoto = useSignal<string | null>(null);
|
|
127
|
+
const totalCaptures = useSignal(1);
|
|
128
|
+
const capturesTaken = useSignal(0);
|
|
129
|
+
const hasFinishedCapture = useSignal(false);
|
|
130
|
+
|
|
131
|
+
// Active-liveness (Enhanced SmartSelfie) signals — only meaningful when
|
|
132
|
+
// useStrictMode is true.
|
|
133
|
+
const poseSequence = useSignal<HeadPoseDirection[]>([]);
|
|
134
|
+
const currentPoseIndex = useSignal(0);
|
|
135
|
+
const currentPose = useSignal<HeadPoseDirection | null>(null);
|
|
136
|
+
const isTooDark = useSignal(false);
|
|
137
|
+
const isTooBlurry = useSignal(false);
|
|
138
|
+
// Direction the face is offset from the oval centre when out of bounds.
|
|
139
|
+
// Used by the UI to colour the offending side of the oval and pick a
|
|
140
|
+
// directional prompt ("Move your device higher/lower/left/right").
|
|
141
|
+
const faceOffsetDirection = useSignal<
|
|
142
|
+
'top' | 'bottom' | 'left' | 'right' | null
|
|
143
|
+
>(null);
|
|
144
|
+
let qualityFrameCounter = 0;
|
|
145
|
+
|
|
146
|
+
const smileCheckpoint = useComputed(() =>
|
|
147
|
+
Math.floor(totalCaptures.value * 0.4),
|
|
148
|
+
);
|
|
149
|
+
const neutralZone = useComputed(() => Math.floor(totalCaptures.value * 0.2));
|
|
150
|
+
|
|
151
|
+
const isReadyToCapture = useComputed(
|
|
152
|
+
() =>
|
|
153
|
+
faceDetected.value &&
|
|
154
|
+
faceInBounds.value &&
|
|
155
|
+
faceProximity.value === 'good',
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const updateAlertImmediate = (messageKey: MessageKey | null) => {
|
|
159
|
+
if (messageKey && MESSAGES[messageKey]) {
|
|
160
|
+
alertTitle.value = MESSAGES[messageKey]?.();
|
|
161
|
+
} else {
|
|
162
|
+
alertTitle.value = '';
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const updateAlert = useRef(
|
|
167
|
+
throttle((messageKey: MessageKey | null) => {
|
|
168
|
+
updateAlertImmediate(messageKey);
|
|
169
|
+
}, 600),
|
|
170
|
+
).current;
|
|
171
|
+
|
|
172
|
+
const CAPTURE_FALLBACK_TIMEOUT_MS = 10000;
|
|
173
|
+
|
|
174
|
+
const startFallbackTimer = () => {
|
|
175
|
+
if (fallbackTimerRef.current) {
|
|
176
|
+
clearTimeout(fallbackTimerRef.current);
|
|
177
|
+
}
|
|
178
|
+
fallbackTimerRef.current = setTimeout(() => {
|
|
179
|
+
if (!isReadyToCapture.value) {
|
|
180
|
+
captureButtonFallbackEnabled.value = true;
|
|
181
|
+
}
|
|
182
|
+
}, CAPTURE_FALLBACK_TIMEOUT_MS);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const initializeFaceLandmarker = async () => {
|
|
186
|
+
try {
|
|
187
|
+
const isAlreadyLoaded =
|
|
188
|
+
window.__smileIdentityMediapipe?.loaded &&
|
|
189
|
+
window.__smileIdentityMediapipe?.instance;
|
|
190
|
+
|
|
191
|
+
if (!isAlreadyLoaded) {
|
|
192
|
+
isInitializing.value = true;
|
|
193
|
+
updateAlertImmediate('initializing');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
faceLandmarkerRef.current = await getMediapipeInstance();
|
|
197
|
+
isInitializing.value = false;
|
|
198
|
+
startFallbackTimer();
|
|
199
|
+
} catch (error) {
|
|
200
|
+
console.error('Failed to initialize MediaPipe:', error);
|
|
201
|
+
isInitializing.value = false;
|
|
202
|
+
// MediaPipe failed — start the fallback timer so the button eventually
|
|
203
|
+
// enables and the user isn't permanently stuck.
|
|
204
|
+
startFallbackTimer();
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const setupCanvas = () => {
|
|
209
|
+
if (videoRef.current && canvasRef.current) {
|
|
210
|
+
const { videoWidth, videoHeight } = videoRef.current;
|
|
211
|
+
|
|
212
|
+
videoAspectRatio.value = videoWidth / videoHeight;
|
|
213
|
+
|
|
214
|
+
canvasRef.current.width = videoWidth;
|
|
215
|
+
canvasRef.current.height = videoHeight;
|
|
216
|
+
|
|
217
|
+
const container = videoRef.current.parentElement;
|
|
218
|
+
if (container) {
|
|
219
|
+
canvasRef.current.style.left = '50%';
|
|
220
|
+
canvasRef.current.style.top = '50%';
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const updateCaptureAlerts = () => {
|
|
226
|
+
if (useStrictMode) {
|
|
227
|
+
// Strict-mode capture alerts are driven by head-pose prompts. The active
|
|
228
|
+
// pose label is derived from the randomised sequence; we don't fall back
|
|
229
|
+
// to the smile-zone messaging at all.
|
|
230
|
+
const pose = poseSequence.value[currentPoseIndex.value];
|
|
231
|
+
if (!pose) {
|
|
232
|
+
alertTitle.value = t('selfie.smart.status.capturing');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const poseToMessage: Record<typeof pose, MessageKey> = {
|
|
236
|
+
left: 'turn-head-left',
|
|
237
|
+
right: 'turn-head-right',
|
|
238
|
+
up: 'tilt-head-up',
|
|
239
|
+
};
|
|
240
|
+
updateAlert(poseToMessage[pose]);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const isInNeutralZone = capturesTaken.value < neutralZone.value;
|
|
245
|
+
const isInSmileZone = capturesTaken.value >= smileCheckpoint.value;
|
|
246
|
+
|
|
247
|
+
if (isInNeutralZone) {
|
|
248
|
+
alertTitle.value = t('selfie.smart.status.capturing');
|
|
249
|
+
} else if (isInSmileZone) {
|
|
250
|
+
const timeSinceSmile = Date.now() - lastSmileTime.value;
|
|
251
|
+
if (timeSinceSmile > smileCooldown) {
|
|
252
|
+
if (
|
|
253
|
+
currentSmileScore.value >= smileThreshold &&
|
|
254
|
+
currentMouthOpen.value < mouthOpenThreshold
|
|
255
|
+
) {
|
|
256
|
+
updateAlert('open-mouth-smile');
|
|
257
|
+
} else {
|
|
258
|
+
updateAlert('smile-required');
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
alertTitle.value = t('selfie.smart.status.keepSmiling');
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
updateAlert(null);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const updateAlerts = () => {
|
|
269
|
+
if (isInitializing.value) {
|
|
270
|
+
updateAlertImmediate('initializing');
|
|
271
|
+
} else if (!faceDetected.value) {
|
|
272
|
+
updateAlert('no-face');
|
|
273
|
+
} else if (useStrictMode && isTooDark.value) {
|
|
274
|
+
updateAlert('too-dark');
|
|
275
|
+
} else if (useStrictMode && isTooBlurry.value) {
|
|
276
|
+
updateAlert('too-blurry');
|
|
277
|
+
} else if (faceProximity.value === 'too-close') {
|
|
278
|
+
updateAlert('too-close');
|
|
279
|
+
} else if (faceProximity.value === 'too-far') {
|
|
280
|
+
updateAlert('too-far');
|
|
281
|
+
} else if (!faceInBounds.value) {
|
|
282
|
+
updateAlert(useStrictMode ? 'face-not-centered' : 'out-of-bounds');
|
|
283
|
+
} else if (isCapturing.value) {
|
|
284
|
+
updateCaptureAlerts();
|
|
285
|
+
} else {
|
|
286
|
+
alertTitle.value = t('selfie.smart.status.readyToCapture');
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const stopDetectionLoop = () => {
|
|
291
|
+
if (animationFrameRef.current) {
|
|
292
|
+
cancelAnimationFrame(animationFrameRef.current);
|
|
293
|
+
animationFrameRef.current = null;
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const detectFace = async () => {
|
|
298
|
+
if (!faceLandmarkerRef.current || !videoRef.current) {
|
|
299
|
+
stopDetectionLoop();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ensure video has valid dimensions before processing
|
|
304
|
+
if (
|
|
305
|
+
videoRef.current.videoWidth <= 0 ||
|
|
306
|
+
videoRef.current.videoHeight <= 0 ||
|
|
307
|
+
videoRef.current.readyState < 2
|
|
308
|
+
) {
|
|
309
|
+
animationFrameRef.current = requestAnimationFrame(detectFace);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
if (isInitializing.value) {
|
|
315
|
+
isInitializing.value = false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const croppedCanvas = createCroppedVideoFrame(videoRef.current);
|
|
319
|
+
const detectionSource = croppedCanvas || videoRef.current;
|
|
320
|
+
|
|
321
|
+
const results = faceLandmarkerRef.current.detectForVideo(
|
|
322
|
+
detectionSource,
|
|
323
|
+
performance.now(),
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
faceLandmarks.value = results.faceLandmarks || [];
|
|
327
|
+
|
|
328
|
+
if (results.faceLandmarks && canvasRef.current && videoRef.current) {
|
|
329
|
+
// we run detection on a cropped video frame
|
|
330
|
+
// adjust landmark coordinates back to full video space
|
|
331
|
+
if (croppedCanvas) {
|
|
332
|
+
const sourceWidth = videoRef.current.videoWidth;
|
|
333
|
+
const sourceHeight = videoRef.current.videoHeight;
|
|
334
|
+
const squareSize = Math.min(sourceWidth, sourceHeight);
|
|
335
|
+
const offsetX = (sourceWidth - squareSize) / (2 * sourceWidth);
|
|
336
|
+
const offsetY = (sourceHeight - squareSize) / (2 * sourceHeight);
|
|
337
|
+
const scaleFactor = squareSize / sourceWidth;
|
|
338
|
+
const scaleFactorY = squareSize / sourceHeight;
|
|
339
|
+
|
|
340
|
+
const adjustedLandmarks = results.faceLandmarks.map((face) =>
|
|
341
|
+
face.map((landmark: any) => ({
|
|
342
|
+
x: landmark.x * scaleFactor + offsetX,
|
|
343
|
+
y: landmark.y * scaleFactorY + offsetY,
|
|
344
|
+
z: landmark.z,
|
|
345
|
+
})),
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
drawFaceMesh(
|
|
349
|
+
canvasRef.current,
|
|
350
|
+
adjustedLandmarks,
|
|
351
|
+
capturesTaken.value,
|
|
352
|
+
smileCheckpoint.value,
|
|
353
|
+
useStrictMode,
|
|
354
|
+
);
|
|
355
|
+
} else {
|
|
356
|
+
drawFaceMesh(
|
|
357
|
+
canvasRef.current,
|
|
358
|
+
results.faceLandmarks,
|
|
359
|
+
capturesTaken.value,
|
|
360
|
+
smileCheckpoint.value,
|
|
361
|
+
useStrictMode,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
} else if (canvasRef.current) {
|
|
365
|
+
clearCanvas(canvasRef.current);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Check number of faces
|
|
369
|
+
const numFaces = results.faceLandmarks ? results.faceLandmarks.length : 0;
|
|
370
|
+
|
|
371
|
+
// Check if face is detected
|
|
372
|
+
const hasFace =
|
|
373
|
+
results.faceBlendshapes &&
|
|
374
|
+
results.faceBlendshapes.length > 0 &&
|
|
375
|
+
numFaces >= 1;
|
|
376
|
+
faceDetected.value = hasFace;
|
|
377
|
+
|
|
378
|
+
if (hasFace && results.faceLandmarks) {
|
|
379
|
+
// Calculate face size and position
|
|
380
|
+
const faceSize = calculateFaceSize(results.faceLandmarks, {
|
|
381
|
+
rotationStable: useStrictMode,
|
|
382
|
+
});
|
|
383
|
+
currentFaceSize.value = faceSize;
|
|
384
|
+
|
|
385
|
+
// Proximity check with hysteresis: once the face is in the "good"
|
|
386
|
+
// band, the reading has to drift past the threshold before we flip
|
|
387
|
+
// back to too-close/too-far. Keep the margin small so legitimate
|
|
388
|
+
// edge cases are caught quickly.
|
|
389
|
+
const proximityMargin = 0.02;
|
|
390
|
+
const min = minFaceSize;
|
|
391
|
+
const max = maxFaceSize;
|
|
392
|
+
const current = faceProximity.value;
|
|
393
|
+
if (current === 'good') {
|
|
394
|
+
if (faceSize > max + proximityMargin) {
|
|
395
|
+
faceProximity.value = 'too-close';
|
|
396
|
+
} else if (faceSize < min - proximityMargin) {
|
|
397
|
+
faceProximity.value = 'too-far';
|
|
398
|
+
}
|
|
399
|
+
} else if (current === 'too-close') {
|
|
400
|
+
if (faceSize <= max) faceProximity.value = 'good';
|
|
401
|
+
} else if (current === 'too-far') {
|
|
402
|
+
if (faceSize >= min) faceProximity.value = 'good';
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Check face position. In strict mode the head will rotate, which
|
|
406
|
+
// legitimately widens the bounding box, so only the face centre is
|
|
407
|
+
// required to stay inside the oval.
|
|
408
|
+
faceInBounds.value = isFaceInBounds(
|
|
409
|
+
results.faceLandmarks,
|
|
410
|
+
videoAspectRatio.value,
|
|
411
|
+
{ centerOnly: useStrictMode },
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
// Independent clipping check: project every landmark into the
|
|
415
|
+
// visible wrapper using runtime element rects, then test against
|
|
416
|
+
// the visible egg (approximated as a centred ellipse). If any
|
|
417
|
+
// landmark falls outside, the oval boundary is clipping the face.
|
|
418
|
+
// Used as an idle-only gating signal so head turns during capture
|
|
419
|
+
// don't trip it.
|
|
420
|
+
faceClippingOval.value = computeFaceClippingOval(
|
|
421
|
+
results.faceLandmarks[0],
|
|
422
|
+
videoRef.current,
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
// Directional nudge: only fire when the face is actually clipping
|
|
426
|
+
// (touching/crossing) the visible oval edge. The clipping side is
|
|
427
|
+
// derived from the single most-clipped landmark and its dominant
|
|
428
|
+
// axis. A face fully inside the oval gets `null` here — no nudge.
|
|
429
|
+
// (Mirror correction is handled inside computeFaceClippingSide.)
|
|
430
|
+
faceOffsetDirection.value = computeFaceClippingSide(
|
|
431
|
+
results.faceLandmarks[0],
|
|
432
|
+
videoRef.current,
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
// Get smile and mouth open data
|
|
436
|
+
const blendshapes = results.faceBlendshapes[0].categories;
|
|
437
|
+
const smileLeft =
|
|
438
|
+
blendshapes.find((b) => b.categoryName === 'mouthSmileLeft')?.score ||
|
|
439
|
+
0;
|
|
440
|
+
const smileRight =
|
|
441
|
+
blendshapes.find((b) => b.categoryName === 'mouthSmileRight')
|
|
442
|
+
?.score || 0;
|
|
443
|
+
const mouthOpen = calculateMouthOpening(results.faceLandmarks);
|
|
444
|
+
const smileScore = (smileLeft + smileRight) / 2;
|
|
445
|
+
|
|
446
|
+
currentSmileScore.value = smileScore;
|
|
447
|
+
currentMouthOpen.value = mouthOpen;
|
|
448
|
+
|
|
449
|
+
if (useStrictMode) {
|
|
450
|
+
// Pose detection requires landmarks, so it stays gated on face
|
|
451
|
+
// detection. Lighting/blur are now sampled outside this block —
|
|
452
|
+
// they need to keep running when the face is undetected (e.g.
|
|
453
|
+
// because the scene is too dark / motion-blurred to find a face).
|
|
454
|
+
const pose = calculateHeadPose(results.faceLandmarks);
|
|
455
|
+
const rawPose = classifyHeadPose(pose);
|
|
456
|
+
|
|
457
|
+
// 2-of-3 majority smoothing. atan2 noise (especially on left
|
|
458
|
+
// turns where x_diff approaches zero) causes single-tick drops
|
|
459
|
+
// below threshold; without smoothing those ticks dissolve a
|
|
460
|
+
// held pose and the capture interval keeps missing the window.
|
|
461
|
+
const history = poseHistoryRef.current;
|
|
462
|
+
history.push(rawPose);
|
|
463
|
+
if (history.length > 3) history.shift();
|
|
464
|
+
let smoothed: HeadPoseDirection | null = rawPose;
|
|
465
|
+
if (history.length === 3) {
|
|
466
|
+
const counts = new Map<HeadPoseDirection | null, number>();
|
|
467
|
+
for (let i = 0; i < history.length; i += 1) {
|
|
468
|
+
counts.set(history[i], (counts.get(history[i]) ?? 0) + 1);
|
|
469
|
+
}
|
|
470
|
+
let winner: HeadPoseDirection | null = null;
|
|
471
|
+
let winnerCount = 0;
|
|
472
|
+
counts.forEach((c, k) => {
|
|
473
|
+
if (c > winnerCount) {
|
|
474
|
+
winner = k;
|
|
475
|
+
winnerCount = c;
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
if (winnerCount >= 2) smoothed = winner;
|
|
479
|
+
}
|
|
480
|
+
currentPose.value = smoothed;
|
|
481
|
+
|
|
482
|
+
// Refresh the latch only when the smoothed pose matches the
|
|
483
|
+
// requested one *and* all the other capture gates are currently
|
|
484
|
+
// passing. Refreshing on pose alone lets the latch carry the
|
|
485
|
+
// user through a failing proximity/bounds/lighting check — they
|
|
486
|
+
// see the failure alert, but a moment later the capture tick
|
|
487
|
+
// fires off the residual latch and the pose silently passes.
|
|
488
|
+
// The latch should only represent "this is a moment we could
|
|
489
|
+
// have captured", not "the head was once turned correctly".
|
|
490
|
+
const requiredPose =
|
|
491
|
+
poseSequence.value[currentPoseIndex.value] ?? null;
|
|
492
|
+
const captureGatePasses =
|
|
493
|
+
isCapturing.value &&
|
|
494
|
+
!isPaused.value &&
|
|
495
|
+
faceInBounds.value &&
|
|
496
|
+
faceProximity.value === 'good' &&
|
|
497
|
+
!isTooDark.value &&
|
|
498
|
+
!isTooBlurry.value &&
|
|
499
|
+
faceOffsetDirection.value === null &&
|
|
500
|
+
!faceClippingOval.value;
|
|
501
|
+
if (
|
|
502
|
+
smoothed &&
|
|
503
|
+
requiredPose &&
|
|
504
|
+
smoothed === requiredPose &&
|
|
505
|
+
captureGatePasses
|
|
506
|
+
) {
|
|
507
|
+
lastPoseMatchAtRef.current = Date.now();
|
|
508
|
+
} else if (!captureGatePasses) {
|
|
509
|
+
// Any failing quality check immediately invalidates the latch
|
|
510
|
+
// so a pass→fail→pass blip between capture ticks can't fire
|
|
511
|
+
// off a residual "recent match" while the user is still being
|
|
512
|
+
// told to move the device.
|
|
513
|
+
lastPoseMatchAtRef.current = 0;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (smileScore >= smileThreshold && mouthOpen >= mouthOpenThreshold) {
|
|
518
|
+
lastSmileTime.value = Date.now();
|
|
519
|
+
|
|
520
|
+
if (isPaused.value && isCapturing.value && resumeCaptureRef.current) {
|
|
521
|
+
// defer execution
|
|
522
|
+
setTimeout(() => {
|
|
523
|
+
const stillSmiling = Date.now() - lastSmileTime.value <= 100;
|
|
524
|
+
if (
|
|
525
|
+
stillSmiling &&
|
|
526
|
+
isPaused.value &&
|
|
527
|
+
isCapturing.value &&
|
|
528
|
+
resumeCaptureRef.current
|
|
529
|
+
) {
|
|
530
|
+
resumeCaptureRef.current();
|
|
531
|
+
}
|
|
532
|
+
}, 0);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
} else {
|
|
536
|
+
// No face detected - reset face-derived values. Lighting/blur are
|
|
537
|
+
// intentionally NOT reset here: those checks are useful precisely
|
|
538
|
+
// when the face has gone undetected (dark room / motion blur) and
|
|
539
|
+
// are sampled separately below from the raw video frame.
|
|
540
|
+
currentSmileScore.value = 0;
|
|
541
|
+
currentFaceSize.value = 0;
|
|
542
|
+
currentMouthOpen.value = 0;
|
|
543
|
+
faceInBounds.value = false;
|
|
544
|
+
faceClippingOval.value = false;
|
|
545
|
+
faceProximity.value = 'good';
|
|
546
|
+
faceOffsetDirection.value = null;
|
|
547
|
+
if (useStrictMode) {
|
|
548
|
+
currentPose.value = null;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Lighting/blur sampling — runs every frame regardless of face
|
|
553
|
+
// detection so the alert can fire even when the scene is too dark or
|
|
554
|
+
// shaky for the landmarker to find a face. Throttled with a frame
|
|
555
|
+
// counter so the canvas readback stays cheap.
|
|
556
|
+
if (useStrictMode && videoRef.current) {
|
|
557
|
+
qualityFrameCounter += 1;
|
|
558
|
+
if (qualityFrameCounter % 6 === 0) {
|
|
559
|
+
const luma = calculateLuminance(videoRef.current);
|
|
560
|
+
isTooDark.value = luma > 0 && luma < DEFAULT_LUMINANCE_MIN;
|
|
561
|
+
const blur = calculateBlurScore(videoRef.current);
|
|
562
|
+
isTooBlurry.value = blur > 0 && blur < DEFAULT_BLUR_MIN;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
updateAlerts();
|
|
567
|
+
} catch {
|
|
568
|
+
faceDetected.value = false;
|
|
569
|
+
faceInBounds.value = false;
|
|
570
|
+
faceClippingOval.value = false;
|
|
571
|
+
faceProximity.value = 'good';
|
|
572
|
+
currentMouthOpen.value = 0;
|
|
573
|
+
|
|
574
|
+
if (isCapturing.value) {
|
|
575
|
+
updateAlert('no-face');
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
animationFrameRef.current = requestAnimationFrame(detectFace);
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
const startDetectionLoop = () => {
|
|
583
|
+
if (animationFrameRef.current) {
|
|
584
|
+
cancelAnimationFrame(animationFrameRef.current);
|
|
585
|
+
}
|
|
586
|
+
animationFrameRef.current = requestAnimationFrame(detectFace);
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
// Notify hosted-web inactivity timeout that the user is making progress.
|
|
590
|
+
// Fired on every successful capture so the 120s timer resets continuously
|
|
591
|
+
// as long as frames are being collected.
|
|
592
|
+
const dispatchProgress = () => {
|
|
593
|
+
document
|
|
594
|
+
.querySelector('smart-camera-web')
|
|
595
|
+
?.dispatchEvent(new CustomEvent('metadata.active-liveness-progress'));
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
const captureImage = () => {
|
|
599
|
+
if (!videoRef.current) return;
|
|
600
|
+
|
|
601
|
+
// In strict (Active Liveness) mode the reference selfie is captured up-front
|
|
602
|
+
// in startCapture() while the user is still neutral. The per-pose captures
|
|
603
|
+
// here only ever populate the liveness array.
|
|
604
|
+
const isReference =
|
|
605
|
+
!useStrictMode && capturesTaken.value === totalCaptures.value - 1;
|
|
606
|
+
const imageData = captureImageFromVideo(videoRef.current, isReference);
|
|
607
|
+
|
|
608
|
+
if (!imageData) return;
|
|
609
|
+
|
|
610
|
+
if (isReference) {
|
|
611
|
+
referencePhoto.value = imageData;
|
|
612
|
+
} else {
|
|
613
|
+
capturedImages.value = [...capturedImages.value, imageData];
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
capturesTaken.value++;
|
|
617
|
+
countdown.value = totalCaptures.value - capturesTaken.value;
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const stopCapture = () => {
|
|
621
|
+
if (captureTimerRef.current) {
|
|
622
|
+
clearInterval(captureTimerRef.current);
|
|
623
|
+
captureTimerRef.current = null;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
isCapturing.value = false;
|
|
627
|
+
isPaused.value = false;
|
|
628
|
+
|
|
629
|
+
if (capturesTaken.value >= totalCaptures.value && referencePhoto.value) {
|
|
630
|
+
const livenessImages = capturedImages.value.map((img) => ({
|
|
631
|
+
image: img.split(',')[1],
|
|
632
|
+
image_type_id: ImageType.LIVENESS_IMAGE_BASE64,
|
|
633
|
+
}));
|
|
634
|
+
|
|
635
|
+
const referenceImage = {
|
|
636
|
+
image: referencePhoto.value.split(',')[1],
|
|
637
|
+
image_type_id: ImageType.SELFIE_IMAGE_BASE64,
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
const eventDetail = {
|
|
641
|
+
images: [...livenessImages, referenceImage],
|
|
642
|
+
referenceImage: referencePhoto.value,
|
|
643
|
+
previewImage: referencePhoto.value,
|
|
644
|
+
facingMode: getFacingMode(),
|
|
645
|
+
meta: { libraryVersion: COMPONENTS_VERSION },
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
if (onCaptureComplete) {
|
|
649
|
+
// Caller owns the payload — defer publish until they decide to emit.
|
|
650
|
+
onCaptureComplete(eventDetail);
|
|
651
|
+
} else {
|
|
652
|
+
window.dispatchEvent(
|
|
653
|
+
new CustomEvent('selfie-capture.publish', { detail: eventDetail }),
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
hasFinishedCapture.value = true;
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const pauseCapture = () => {
|
|
662
|
+
if (captureTimerRef.current) {
|
|
663
|
+
clearInterval(captureTimerRef.current);
|
|
664
|
+
captureTimerRef.current = null;
|
|
665
|
+
}
|
|
666
|
+
isPaused.value = true;
|
|
667
|
+
|
|
668
|
+
if (
|
|
669
|
+
faceDetected.value &&
|
|
670
|
+
faceInBounds.value &&
|
|
671
|
+
faceProximity.value === 'good'
|
|
672
|
+
) {
|
|
673
|
+
updateAlert('smile-required');
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
const startCaptureInterval = () => {
|
|
678
|
+
if (captureTimerRef.current) {
|
|
679
|
+
clearInterval(captureTimerRef.current);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
captureTimerRef.current = setInterval(() => {
|
|
683
|
+
if (capturesTaken.value >= totalCaptures.value) {
|
|
684
|
+
stopCapture();
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (!faceDetected.value) {
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (!faceInBounds.value) {
|
|
693
|
+
// Strict mode has no smile-based resume path — calling pauseCapture
|
|
694
|
+
// here would clear the interval and leave the user stuck even after
|
|
695
|
+
// they recentre. Just skip this tick instead so the next frame
|
|
696
|
+
// re-evaluates once they're back in bounds.
|
|
697
|
+
if (useStrictMode) return;
|
|
698
|
+
pauseCapture();
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (faceProximity.value !== 'good') {
|
|
703
|
+
if (useStrictMode) return;
|
|
704
|
+
pauseCapture();
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (useStrictMode) {
|
|
709
|
+
// Strict mode: gate captures on (a) sufficient lighting/sharpness and
|
|
710
|
+
// (b) the user matching the currently-required head pose. The pose
|
|
711
|
+
// sequence advances once enough frames per pose have been collected.
|
|
712
|
+
if (isTooDark.value || isTooBlurry.value) {
|
|
713
|
+
// Same reason as above — skip rather than pause so the loop keeps
|
|
714
|
+
// ticking and resumes automatically once lighting/blur clears.
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// The UI surfaces faceOffsetDirection as a "Move device left/right"
|
|
719
|
+
// alert. The capture path has to honour the same gate, otherwise
|
|
720
|
+
// the user sees the alert but the pose silently passes anyway.
|
|
721
|
+
// Also gate on faceClippingOval — it flags any landmark past the
|
|
722
|
+
// visible egg edge without depending on axis-dominance, so it
|
|
723
|
+
// catches cases where the face is crossing the border but the
|
|
724
|
+
// bbox centre hasn't moved enough to pin a direction yet.
|
|
725
|
+
if (faceOffsetDirection.value !== null || faceClippingOval.value) {
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Distribute the capture window evenly across the pose sequence:
|
|
730
|
+
// each pose gets `framesPerPose = floor(totalCaptures / poseCount)`
|
|
731
|
+
// frames. Any leftover frames (e.g. 8 captures across 3 poses → 2
|
|
732
|
+
// leftover) are taken silently up-front while the user is still
|
|
733
|
+
// neutral, giving the backend forward-facing liveness samples
|
|
734
|
+
// without burdening the user with an extra "look straight" prompt.
|
|
735
|
+
const poseCount = poseSequence.value.length;
|
|
736
|
+
const framesPerPose = Math.max(
|
|
737
|
+
1,
|
|
738
|
+
Math.floor(totalCaptures.value / poseCount),
|
|
739
|
+
);
|
|
740
|
+
const silentNeutralFrames = Math.max(
|
|
741
|
+
0,
|
|
742
|
+
totalCaptures.value - framesPerPose * poseCount,
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
if (capturesTaken.value < silentNeutralFrames) {
|
|
746
|
+
// Pre-pose neutral phase: snap whatever the user is showing now
|
|
747
|
+
// (they're already centred from the hold-still period). No pose
|
|
748
|
+
// gate, no prompt change — these frames feed liveness silently.
|
|
749
|
+
captureImage();
|
|
750
|
+
dispatchProgress();
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const requiredPose = poseSequence.value[currentPoseIndex.value];
|
|
755
|
+
if (!requiredPose) {
|
|
756
|
+
stopCapture();
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
// Accept either an instantaneous match or a very recent one. The
|
|
760
|
+
// latch covers the case where the user held the pose between
|
|
761
|
+
// capture ticks but the smoothed reading drifted by the time this
|
|
762
|
+
// tick fired (e.g. they started relaxing back to neutral).
|
|
763
|
+
const poseMatched =
|
|
764
|
+
currentPose.value === requiredPose ||
|
|
765
|
+
Date.now() - lastPoseMatchAtRef.current <= POSE_MATCH_LATCH_MS;
|
|
766
|
+
if (!poseMatched) {
|
|
767
|
+
// Don't pause — we want the prompt to stay visible while the user
|
|
768
|
+
// adjusts. Just skip this tick.
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
captureImage();
|
|
773
|
+
// Any successful capture counts as activity for the hosted-web
|
|
774
|
+
// inactivity timeout — even if the user is still on pose 1 and
|
|
775
|
+
// hasn't advanced the index yet.
|
|
776
|
+
dispatchProgress();
|
|
777
|
+
|
|
778
|
+
// Frames captured *within* the pose phase only (the leading neutral
|
|
779
|
+
// frames don't belong to any pose). Advance once we've collected
|
|
780
|
+
// enough for the current pose.
|
|
781
|
+
const poseFramesTaken = capturesTaken.value - silentNeutralFrames;
|
|
782
|
+
if (
|
|
783
|
+
poseFramesTaken > 0 &&
|
|
784
|
+
poseFramesTaken % framesPerPose === 0 &&
|
|
785
|
+
currentPoseIndex.value < poseCount - 1
|
|
786
|
+
) {
|
|
787
|
+
currentPoseIndex.value += 1;
|
|
788
|
+
// Clear smoothing + latch so the previous pose can't satisfy
|
|
789
|
+
// the next one — each pose must be re-confirmed from scratch.
|
|
790
|
+
poseHistoryRef.current = [];
|
|
791
|
+
lastPoseMatchAtRef.current = 0;
|
|
792
|
+
}
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const isInSmileZone = capturesTaken.value >= smileCheckpoint.value;
|
|
797
|
+
|
|
798
|
+
if (isInSmileZone) {
|
|
799
|
+
const timeSinceSmile = Date.now() - lastSmileTime.value;
|
|
800
|
+
if (timeSinceSmile > smileCooldown) {
|
|
801
|
+
pauseCapture();
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
captureImage();
|
|
807
|
+
}, interval);
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
const resumeCapture = () => {
|
|
811
|
+
if (
|
|
812
|
+
faceDetected.value &&
|
|
813
|
+
faceProximity.value === 'good' &&
|
|
814
|
+
faceInBounds.value
|
|
815
|
+
) {
|
|
816
|
+
const isInSmileZone = capturesTaken.value >= smileCheckpoint.value;
|
|
817
|
+
if (isInSmileZone) {
|
|
818
|
+
const timeSinceSmile = Date.now() - lastSmileTime.value;
|
|
819
|
+
if (timeSinceSmile > smileCooldown) {
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
isPaused.value = false;
|
|
825
|
+
updateAlert(null);
|
|
826
|
+
startCaptureInterval();
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
resumeCaptureRef.current = resumeCapture;
|
|
831
|
+
|
|
832
|
+
const startCapture = async () => {
|
|
833
|
+
capturedImages.value = [];
|
|
834
|
+
isCapturing.value = true;
|
|
835
|
+
isPaused.value = false;
|
|
836
|
+
totalCaptures.value = Math.ceil(duration / interval);
|
|
837
|
+
capturesTaken.value = 0;
|
|
838
|
+
countdown.value = totalCaptures.value;
|
|
839
|
+
|
|
840
|
+
if (useStrictMode) {
|
|
841
|
+
poseSequence.value = buildRandomPoseSequence();
|
|
842
|
+
currentPoseIndex.value = 0;
|
|
843
|
+
poseHistoryRef.current = [];
|
|
844
|
+
lastPoseMatchAtRef.current = 0;
|
|
845
|
+
|
|
846
|
+
// Snap the neutral selfie up-front so the result preview shows the
|
|
847
|
+
// user facing the camera, not whichever direction the last pose
|
|
848
|
+
// prompted them to turn.
|
|
849
|
+
if (videoRef.current) {
|
|
850
|
+
const neutralReference = captureImageFromVideo(videoRef.current, true);
|
|
851
|
+
if (neutralReference) {
|
|
852
|
+
referencePhoto.value = neutralReference;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const smartCameraWeb = document.querySelector('smart-camera-web');
|
|
858
|
+
smartCameraWeb?.dispatchEvent(
|
|
859
|
+
new CustomEvent('metadata.selfie-capture-start'),
|
|
860
|
+
);
|
|
861
|
+
smartCameraWeb?.dispatchEvent(
|
|
862
|
+
new CustomEvent('metadata.active-liveness-type', {
|
|
863
|
+
detail: { type: useStrictMode ? 'head_pose' : 'smile_detection' },
|
|
864
|
+
}),
|
|
865
|
+
);
|
|
866
|
+
smartCameraWeb?.dispatchEvent(
|
|
867
|
+
new CustomEvent('metadata.selfie-origin', {
|
|
868
|
+
detail: {
|
|
869
|
+
imageOrigin: { environment: 'back_camera', user: 'front_camera' }[
|
|
870
|
+
getFacingMode()
|
|
871
|
+
],
|
|
872
|
+
},
|
|
873
|
+
}),
|
|
874
|
+
);
|
|
875
|
+
|
|
876
|
+
startCaptureInterval();
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
const handleCancel = () => {
|
|
880
|
+
stopCapture();
|
|
881
|
+
window.dispatchEvent(
|
|
882
|
+
new CustomEvent('selfie-capture.cancelled', {
|
|
883
|
+
detail: { meta: { libraryVersion: COMPONENTS_VERSION } },
|
|
884
|
+
}),
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
// TODO: remove - for backwards compatibility
|
|
888
|
+
window.dispatchEvent(
|
|
889
|
+
new CustomEvent('selfie-capture-screens.cancelled', {
|
|
890
|
+
detail: { meta: { libraryVersion: COMPONENTS_VERSION } },
|
|
891
|
+
}),
|
|
892
|
+
);
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Force-finalise the capture session with a failure reason. Used by the
|
|
897
|
+
* hosted-web active-liveness inactivity timer (and any other host-driven
|
|
898
|
+
* fail-fast path) to submit whatever frames have been captured so far
|
|
899
|
+
* tagged with a reason the backend can use to record the failure.
|
|
900
|
+
*
|
|
901
|
+
* The payload mirrors the normal completion shape so the existing publish
|
|
902
|
+
* path doesn't need to special-case it; the only extra field is
|
|
903
|
+
* `forceFailureReason`, which downstream submission handlers forward as
|
|
904
|
+
* a structured `failure_reason` metadata entry (e.g.
|
|
905
|
+
* `{ mobile_active_liveness_timed_out: true }`).
|
|
906
|
+
*/
|
|
907
|
+
const forceFailCapture = (reason: string) => {
|
|
908
|
+
// Stop the capture interval and detection loop; we're not going to take
|
|
909
|
+
// any more frames after this point.
|
|
910
|
+
if (captureTimerRef.current) {
|
|
911
|
+
clearInterval(captureTimerRef.current);
|
|
912
|
+
captureTimerRef.current = null;
|
|
913
|
+
}
|
|
914
|
+
isCapturing.value = false;
|
|
915
|
+
isPaused.value = false;
|
|
916
|
+
|
|
917
|
+
// Top up the liveness buffer to the full expected count by grabbing
|
|
918
|
+
// whatever the camera currently shows. The backend rejects partial
|
|
919
|
+
// submissions, so on timeout we'd rather submit `totalCaptures` "random"
|
|
920
|
+
// frames tagged with `forceFailureReason` than discard the session.
|
|
921
|
+
// The frames are captured back-to-back from the live <video> element,
|
|
922
|
+
// which gives us slight motion between them in practice.
|
|
923
|
+
if (videoRef.current) {
|
|
924
|
+
const target = totalCaptures.value;
|
|
925
|
+
while (capturedImages.value.length < target) {
|
|
926
|
+
const frame = captureImageFromVideo(videoRef.current, false);
|
|
927
|
+
if (!frame) break;
|
|
928
|
+
capturedImages.value = [...capturedImages.value, frame];
|
|
929
|
+
}
|
|
930
|
+
// Reference selfie may not have been snapped yet (e.g. timeout fired
|
|
931
|
+
// before startCapture's up-front grab). Fall back to a live frame so
|
|
932
|
+
// the payload always carries a reference image.
|
|
933
|
+
if (!referencePhoto.value) {
|
|
934
|
+
const ref = captureImageFromVideo(videoRef.current, true);
|
|
935
|
+
if (ref) referencePhoto.value = ref;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const livenessImages = capturedImages.value.map((img) => ({
|
|
940
|
+
image: img.split(',')[1],
|
|
941
|
+
image_type_id: ImageType.LIVENESS_IMAGE_BASE64,
|
|
942
|
+
}));
|
|
943
|
+
|
|
944
|
+
const reference = referencePhoto.value;
|
|
945
|
+
const referenceImage = reference
|
|
946
|
+
? {
|
|
947
|
+
image: reference.split(',')[1],
|
|
948
|
+
image_type_id: ImageType.SELFIE_IMAGE_BASE64,
|
|
949
|
+
}
|
|
950
|
+
: null;
|
|
951
|
+
|
|
952
|
+
const eventDetail = {
|
|
953
|
+
images: referenceImage
|
|
954
|
+
? [...livenessImages, referenceImage]
|
|
955
|
+
: livenessImages,
|
|
956
|
+
referenceImage: reference ?? '',
|
|
957
|
+
previewImage: reference ?? '',
|
|
958
|
+
facingMode: getFacingMode(),
|
|
959
|
+
forceFailureReason: reason,
|
|
960
|
+
meta: { libraryVersion: COMPONENTS_VERSION },
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
if (onCaptureComplete) {
|
|
964
|
+
onCaptureComplete(eventDetail);
|
|
965
|
+
} else {
|
|
966
|
+
window.dispatchEvent(
|
|
967
|
+
new CustomEvent('selfie-capture.publish', { detail: eventDetail }),
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
hasFinishedCapture.value = true;
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
const handleClose = () => {
|
|
975
|
+
stopCapture();
|
|
976
|
+
|
|
977
|
+
window.dispatchEvent(
|
|
978
|
+
new CustomEvent('selfie-capture.close', {
|
|
979
|
+
detail: { meta: { libraryVersion: COMPONENTS_VERSION } },
|
|
980
|
+
}),
|
|
981
|
+
);
|
|
982
|
+
|
|
983
|
+
// TODO: remove - backwards compatibility
|
|
984
|
+
window.dispatchEvent(
|
|
985
|
+
new CustomEvent('selfie-capture-screens.close', {
|
|
986
|
+
detail: { meta: { libraryVersion: COMPONENTS_VERSION } },
|
|
987
|
+
}),
|
|
988
|
+
);
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
const cleanup = () => {
|
|
992
|
+
if (captureTimerRef.current) {
|
|
993
|
+
clearInterval(captureTimerRef.current);
|
|
994
|
+
}
|
|
995
|
+
if (fallbackTimerRef.current) {
|
|
996
|
+
clearTimeout(fallbackTimerRef.current);
|
|
997
|
+
}
|
|
998
|
+
stopDetectionLoop();
|
|
999
|
+
updateAlert.cancel();
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
const resetFaceDetectionState = () => {
|
|
1003
|
+
faceDetected.value = false;
|
|
1004
|
+
faceInBounds.value = false;
|
|
1005
|
+
faceClippingOval.value = false;
|
|
1006
|
+
faceProximity.value = 'good';
|
|
1007
|
+
faceLandmarks.value = [];
|
|
1008
|
+
currentSmileScore.value = 0;
|
|
1009
|
+
currentFaceSize.value = 0;
|
|
1010
|
+
currentMouthOpen.value = 0;
|
|
1011
|
+
lastSmileTime.value = 0;
|
|
1012
|
+
captureButtonFallbackEnabled.value = false;
|
|
1013
|
+
currentPose.value = null;
|
|
1014
|
+
isTooDark.value = false;
|
|
1015
|
+
isTooBlurry.value = false;
|
|
1016
|
+
qualityFrameCounter = 0;
|
|
1017
|
+
if (fallbackTimerRef.current) {
|
|
1018
|
+
clearTimeout(fallbackTimerRef.current);
|
|
1019
|
+
fallbackTimerRef.current = null;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (canvasRef.current) {
|
|
1023
|
+
clearCanvas(canvasRef.current);
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
return {
|
|
1028
|
+
faceDetected,
|
|
1029
|
+
faceInBounds,
|
|
1030
|
+
faceClippingOval,
|
|
1031
|
+
faceProximity,
|
|
1032
|
+
videoAspectRatio,
|
|
1033
|
+
faceLandmarks,
|
|
1034
|
+
currentSmileScore,
|
|
1035
|
+
currentFaceSize,
|
|
1036
|
+
currentMouthOpen,
|
|
1037
|
+
lastSmileTime,
|
|
1038
|
+
alertTitle,
|
|
1039
|
+
isInitializing,
|
|
1040
|
+
isReadyToCapture,
|
|
1041
|
+
captureButtonFallbackEnabled,
|
|
1042
|
+
|
|
1043
|
+
isCapturing,
|
|
1044
|
+
isPaused,
|
|
1045
|
+
countdown,
|
|
1046
|
+
capturedImages,
|
|
1047
|
+
referencePhoto,
|
|
1048
|
+
totalCaptures,
|
|
1049
|
+
capturesTaken,
|
|
1050
|
+
hasFinishedCapture,
|
|
1051
|
+
smileCheckpoint,
|
|
1052
|
+
neutralZone,
|
|
1053
|
+
poseSequence,
|
|
1054
|
+
currentPoseIndex,
|
|
1055
|
+
currentPose,
|
|
1056
|
+
isTooDark,
|
|
1057
|
+
isTooBlurry,
|
|
1058
|
+
faceOffsetDirection,
|
|
1059
|
+
|
|
1060
|
+
initializeFaceLandmarker,
|
|
1061
|
+
setupCanvas,
|
|
1062
|
+
startDetectionLoop,
|
|
1063
|
+
stopDetectionLoop,
|
|
1064
|
+
updateAlert,
|
|
1065
|
+
startCapture,
|
|
1066
|
+
stopCapture,
|
|
1067
|
+
pauseCapture,
|
|
1068
|
+
resumeCapture,
|
|
1069
|
+
handleCancel,
|
|
1070
|
+
forceFailCapture,
|
|
1071
|
+
handleClose,
|
|
1072
|
+
cleanup,
|
|
1073
|
+
resetFaceDetectionState,
|
|
1074
|
+
};
|
|
1075
|
+
};
|