@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.
Files changed (84) hide show
  1. package/dist/esm/{DocumentCaptureScreens-bLFW-yEM.js → DocumentCaptureScreens-ucJDu5nH.js} +555 -2470
  2. package/dist/esm/DocumentCaptureScreens-ucJDu5nH.js.map +1 -0
  3. package/dist/esm/{EndUserConsent-D26UoVk5.js → EndUserConsent-CsiwoThZ.js} +3 -3
  4. package/dist/esm/{EndUserConsent-D26UoVk5.js.map → EndUserConsent-CsiwoThZ.js.map} +1 -1
  5. package/dist/esm/{Navigation-nvehze1F.js → Navigation-Xg565kcu.js} +28 -22
  6. package/dist/esm/Navigation-Xg565kcu.js.map +1 -0
  7. package/dist/esm/SelfieCaptureScreens-D3KuMzZA.js +11471 -0
  8. package/dist/esm/SelfieCaptureScreens-D3KuMzZA.js.map +1 -0
  9. package/dist/esm/{TotpConsent-owUOdKzP.js → TotpConsent-CRtmtudl.js} +2 -2
  10. package/dist/esm/{TotpConsent-owUOdKzP.js.map → TotpConsent-CRtmtudl.js.map} +1 -1
  11. package/dist/esm/combobox.js +1 -1
  12. package/dist/esm/document.js +1 -1
  13. package/dist/esm/end-user-consent.js +1 -1
  14. package/dist/esm/index-CUwa6MPI.js +1363 -0
  15. package/dist/esm/{index-5Nn2kzHI.js.map → index-CUwa6MPI.js.map} +1 -1
  16. package/dist/esm/localisation.js +1 -1
  17. package/dist/esm/main.js +6 -6
  18. package/dist/esm/navigation.js +1 -1
  19. package/dist/esm/package-BmVbDNny.js +2535 -0
  20. package/dist/esm/package-BmVbDNny.js.map +1 -0
  21. package/dist/esm/selfie.js +1 -1
  22. package/dist/esm/smart-camera-web.js +67 -40
  23. package/dist/esm/smart-camera-web.js.map +1 -1
  24. package/dist/esm/totp-consent.js +1 -1
  25. package/dist/smart-camera-web.js +877 -122
  26. package/dist/smart-camera-web.js.map +1 -1
  27. package/dist/types/main.d.ts +13 -0
  28. package/lib/components/navigation/src/Navigation.js +27 -8
  29. package/lib/components/selfie/src/SelfieCaptureScreens.js +139 -8
  30. package/lib/components/selfie/src/enhanced-smartselfie-capture/EnhancedSmartSelfieCapture.tsx +684 -0
  31. package/lib/components/selfie/src/enhanced-smartselfie-capture/EnhancedSmartSelfieConsent.tsx +71 -0
  32. package/lib/components/selfie/src/enhanced-smartselfie-capture/EnhancedSmartSelfieSubmission.tsx +181 -0
  33. package/lib/components/selfie/src/enhanced-smartselfie-capture/OvalProgress.tsx +87 -0
  34. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/Icon.svg +8 -0
  35. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/accessories.svg +77 -0
  36. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/active_liveness_animation.lottie +0 -0
  37. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/device.svg +12 -0
  38. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/device_orientation.lottie +0 -0
  39. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/good.svg +52 -0
  40. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/id-card.svg +9 -0
  41. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/illustrations.tsx +852 -0
  42. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/instructions-img.svg +3 -0
  43. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/multiple-faces.svg +69 -0
  44. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/person.svg +6 -0
  45. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/phone.svg +8 -0
  46. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/poor-lighting.svg +53 -0
  47. package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/too_dark_animation.lottie +0 -0
  48. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/ActiveLivenessOverlay.tsx +226 -0
  49. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/AlertDisplay.tsx +38 -0
  50. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/BackNavigation.tsx +45 -0
  51. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/CameraPreview.tsx +96 -0
  52. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/CaptureControls.tsx +97 -0
  53. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/CaptureGuidelines.tsx +374 -0
  54. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/ConsentView.tsx +460 -0
  55. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/SubmissionView.tsx +426 -0
  56. package/lib/components/selfie/src/enhanced-smartselfie-capture/components/index.ts +3 -0
  57. package/lib/components/selfie/src/enhanced-smartselfie-capture/constants.ts +23 -0
  58. package/lib/components/selfie/src/enhanced-smartselfie-capture/hooks/index.ts +2 -0
  59. package/lib/components/selfie/src/enhanced-smartselfie-capture/hooks/useCamera.ts +238 -0
  60. package/lib/components/selfie/src/enhanced-smartselfie-capture/hooks/useFaceCapture.ts +1075 -0
  61. package/lib/components/selfie/src/enhanced-smartselfie-capture/index.ts +1 -0
  62. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/alertMessages.ts +20 -0
  63. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/canvas.ts +108 -0
  64. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/faceDetection.ts +545 -0
  65. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/imageCapture.ts +66 -0
  66. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/imageQuality.ts +151 -0
  67. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/index.ts +5 -0
  68. package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/mediapipeManager.ts +215 -0
  69. package/lib/components/selfie/src/selfie-capture-wrapper/SelfieCaptureWrapper.tsx +163 -17
  70. package/lib/components/selfie/src/smartselfie-capture/SmartSelfieCapture.tsx +2 -2
  71. package/lib/components/selfie/src/smartselfie-capture/hooks/useFaceCapture.ts +15 -7
  72. package/lib/components/selfie/src/smartselfie-capture/utils/canvas.ts +4 -6
  73. package/lib/components/selfie/src/smartselfie-capture/utils/mediapipeManager.ts +145 -9
  74. package/lib/components/signature-pad/package.json +1 -1
  75. package/lib/components/smart-camera-web/src/SmartCameraWeb.js +70 -11
  76. package/lib/domain/localisation/index.js +2 -2
  77. package/package.json +3 -3
  78. package/dist/esm/DocumentCaptureScreens-bLFW-yEM.js.map +0 -1
  79. package/dist/esm/Navigation-nvehze1F.js.map +0 -1
  80. package/dist/esm/SelfieCaptureScreens-BXIs6_tl.js +0 -7522
  81. package/dist/esm/SelfieCaptureScreens-BXIs6_tl.js.map +0 -1
  82. package/dist/esm/index-5Nn2kzHI.js +0 -1360
  83. package/dist/esm/package-DmH-I6GW.js +0 -565
  84. package/dist/esm/package-DmH-I6GW.js.map +0 -1
@@ -0,0 +1 @@
1
+ export { default as EnhancedSmartSelfieCapture } from './EnhancedSmartSelfieCapture';
@@ -0,0 +1,20 @@
1
+ import { t } from '../../../../../domain/localisation';
2
+
3
+ export const MESSAGES = {
4
+ 'no-face': () => t('selfie.smart.alert.noFace'),
5
+ 'out-of-bounds': () => t('selfie.smart.alert.outOfBounds'),
6
+ 'too-close': () => t('selfie.smart.alert.tooClose'),
7
+ 'too-far': () => t('selfie.smart.alert.tooFar'),
8
+ 'neutral-expression': () => t('selfie.smart.alert.neutralExpression'),
9
+ 'smile-required': () => t('selfie.smart.alert.smileRequired'),
10
+ 'open-mouth-smile': () => t('selfie.smart.alert.openMouthSmile'),
11
+ 'too-dark': () => t('selfie.smart.alert.tooDark'),
12
+ 'too-blurry': () => t('selfie.smart.alert.tooBlurry'),
13
+ 'face-not-centered': () => t('selfie.smart.alert.faceNotCentered'),
14
+ 'turn-head-left': () => t('selfie.smart.alert.turnHeadLeft'),
15
+ 'turn-head-right': () => t('selfie.smart.alert.turnHeadRight'),
16
+ 'tilt-head-up': () => t('selfie.smart.alert.tiltHeadUp'),
17
+ initializing: () => t('selfie.smart.alert.initializing'),
18
+ };
19
+
20
+ export type MessageKey = keyof typeof MESSAGES;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Create a cropped square canvas from video for face detection
3
+ */
4
+ import { DrawingUtils, FaceLandmarker } from '@mediapipe/tasks-vision';
5
+
6
+ export const createCroppedVideoFrame = (
7
+ videoElement: HTMLVideoElement,
8
+ ): HTMLCanvasElement | null => {
9
+ const canvas = document.createElement('canvas');
10
+ const ctx = canvas.getContext('2d');
11
+ if (!ctx) return null;
12
+
13
+ const sourceWidth = videoElement.videoWidth;
14
+ const sourceHeight = videoElement.videoHeight;
15
+
16
+ const squareSize = Math.min(sourceWidth, sourceHeight);
17
+ const cropX = (sourceWidth - squareSize) / 2;
18
+ const cropY = (sourceHeight - squareSize) / 2;
19
+
20
+ canvas.width = squareSize;
21
+ canvas.height = squareSize;
22
+
23
+ ctx.drawImage(
24
+ videoElement,
25
+ cropX,
26
+ cropY,
27
+ squareSize,
28
+ squareSize,
29
+ 0,
30
+ 0,
31
+ squareSize,
32
+ squareSize,
33
+ );
34
+
35
+ return canvas;
36
+ };
37
+
38
+ /**
39
+ * Draw face mesh overlay on canvas
40
+ */
41
+ export const drawFaceMesh = (
42
+ canvas: HTMLCanvasElement,
43
+ landmarks: any[],
44
+ capturesTaken: number,
45
+ smileCheckpoint: number,
46
+ useStrictMode = false,
47
+ ): void => {
48
+ const ctx = canvas.getContext('2d');
49
+ if (!ctx) return;
50
+
51
+ const canvasWidth = canvas.width;
52
+ const canvasHeight = canvas.height;
53
+
54
+ ctx.clearRect(0, 0, canvasWidth, canvasHeight);
55
+
56
+ // In strict (Active Liveness) mode we render a Lottie overlay instead of the
57
+ // landmark/connector mesh, so leave the canvas cleared and bail.
58
+ if (useStrictMode) return;
59
+
60
+ const drawingUtils = new DrawingUtils(ctx);
61
+
62
+ landmarks.forEach((landmark) => {
63
+ if (!landmark || landmark.length === 0) return;
64
+
65
+ const outlineColor = 'rgba(162, 155, 254,0.4)';
66
+ const lineWidth = 2;
67
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)';
68
+ ctx.lineWidth = lineWidth;
69
+ ctx.lineCap = 'round';
70
+ ctx.lineJoin = 'round';
71
+
72
+ drawingUtils.drawLandmarks(landmark, {
73
+ color: 'rgba(9, 132, 227,0.7)',
74
+ lineWidth: 0.5,
75
+ radius: 0.5,
76
+ });
77
+ drawingUtils.drawConnectors(
78
+ landmark,
79
+ FaceLandmarker.FACE_LANDMARKS_FACE_OVAL,
80
+ {
81
+ color: outlineColor,
82
+ lineWidth,
83
+ },
84
+ );
85
+
86
+ const isInSmileZone = capturesTaken > 0 && capturesTaken >= smileCheckpoint;
87
+ if (isInSmileZone) {
88
+ drawingUtils.drawConnectors(
89
+ landmark,
90
+ FaceLandmarker.FACE_LANDMARKS_LIPS,
91
+ {
92
+ color: outlineColor,
93
+ lineWidth,
94
+ },
95
+ );
96
+ }
97
+ });
98
+ };
99
+
100
+ /**
101
+ * Clear canvas completely
102
+ */
103
+ export const clearCanvas = (canvas: HTMLCanvasElement): void => {
104
+ const ctx = canvas.getContext('2d');
105
+ if (ctx) {
106
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
107
+ }
108
+ };
@@ -0,0 +1,545 @@
1
+ /**
2
+ * Calculate the size of a face relative to the video frame.
3
+ *
4
+ * @param landmarks MediaPipe face-landmark result
5
+ * @param options.rotationStable When true, also factors in the inter-eye
6
+ * distance (landmarks 33 and 263) and returns the largest of the three
7
+ * measures. Use this for active-liveness mode where the bounding-box
8
+ * height shrinks as the user pitches their head up and width shrinks as
9
+ * they yaw, both of which would otherwise trigger a false "too far".
10
+ */
11
+ export const calculateFaceSize = (
12
+ landmarks: any,
13
+ options: { rotationStable?: boolean } = {},
14
+ ): number => {
15
+ if (!landmarks || landmarks.length === 0) return 0;
16
+
17
+ const face = landmarks[0];
18
+
19
+ if (!face || face.length === 0) return 0;
20
+
21
+ // Get bounding box of face landmarks
22
+ let minX = 1;
23
+ let maxX = 0;
24
+ let minY = 1;
25
+ let maxY = 0;
26
+
27
+ face.forEach((landmark: any) => {
28
+ if (
29
+ landmark &&
30
+ typeof landmark.x === 'number' &&
31
+ typeof landmark.y === 'number'
32
+ ) {
33
+ minX = Math.min(minX, landmark.x);
34
+ maxX = Math.max(maxX, landmark.x);
35
+ minY = Math.min(minY, landmark.y);
36
+ maxY = Math.max(maxY, landmark.y);
37
+ }
38
+ });
39
+
40
+ // Calculate face size as percentage of video area
41
+ const faceWidth = maxX - minX;
42
+ const faceHeight = maxY - minY;
43
+ let faceSize = Math.max(faceWidth, faceHeight);
44
+
45
+ if (options.rotationStable) {
46
+ const leftEye = face[33];
47
+ const rightEye = face[263];
48
+ if (leftEye && rightEye) {
49
+ const dx = rightEye.x - leftEye.x;
50
+ const dy = rightEye.y - leftEye.y;
51
+ const eyeSpan = Math.sqrt(dx * dx + dy * dy);
52
+ // Eye-corner span is ≈ 0.45 of face width on a frontal face. Scale up
53
+ // so the value is comparable to the bbox-derived size and use the max
54
+ // of the three measures — that way pitch (shrinks height) and yaw
55
+ // (shrinks width) don't push the user into a "too far" state.
56
+ faceSize = Math.max(faceSize, eyeSpan / 0.45);
57
+ }
58
+ }
59
+
60
+ return faceSize;
61
+ };
62
+
63
+ /**
64
+ * Check if a face is positioned within the oval bounds
65
+ */
66
+ /**
67
+ * Check if a face is positioned within the oval bounds.
68
+ *
69
+ * @param landmarks MediaPipe face landmarks
70
+ * @param videoAspectRatio width / height of the source video
71
+ * @param options.centerOnly When true, only the face centre needs to fall
72
+ * inside the oval. Use this for active-liveness mode, where head rotation
73
+ * legitimately widens the bounding box and would otherwise fail the
74
+ * four-corner check.
75
+ */
76
+ export const isFaceInBounds = (
77
+ landmarks: any,
78
+ videoAspectRatio: number,
79
+ options: { centerOnly?: boolean } = {},
80
+ ): boolean => {
81
+ if (!landmarks || landmarks.length === 0) return false;
82
+
83
+ const face = landmarks[0];
84
+
85
+ let minX = 1;
86
+ let maxX = 0;
87
+ let minY = 1;
88
+ let maxY = 0;
89
+ face.forEach((landmark: any) => {
90
+ minX = Math.min(minX, landmark.x);
91
+ maxX = Math.max(maxX, landmark.x);
92
+ minY = Math.min(minY, landmark.y);
93
+ maxY = Math.max(maxY, landmark.y);
94
+ });
95
+
96
+ const ovalCenterX = 0.5;
97
+ const ovalCenterY = 0.6;
98
+
99
+ const isLandscape = videoAspectRatio > 1;
100
+ let ovalWidth;
101
+ let ovalHeight;
102
+ if (isLandscape) {
103
+ ovalWidth = 0.4;
104
+ ovalHeight = 0.3;
105
+ } else {
106
+ ovalWidth = 0.35;
107
+ ovalHeight = 0.5;
108
+ }
109
+
110
+ const faceCenterX = (minX + maxX) / 2;
111
+ const faceCenterY = (minY + maxY) / 2;
112
+
113
+ // In strict (Active Liveness) mode the bounding-box centre drifts as the
114
+ // user yaws/pitches their head, which can falsely fail this check. Prefer
115
+ // the nose tip (landmark 1) — it's anatomically stable. Keep the
116
+ // tolerance tight so that obvious off-centre framing (face partly off the
117
+ // oval edge) is still flagged.
118
+ const noseTip = options.centerOnly ? face[1] : null;
119
+ const centerX = noseTip ? noseTip.x : faceCenterX;
120
+ const centerY = noseTip ? noseTip.y : faceCenterY;
121
+
122
+ const centerTolerance = options.centerOnly ? 0.15 : 0.2;
123
+ const centerOvalWidth = ovalWidth * (1 + centerTolerance);
124
+ const centerOvalHeight = ovalHeight * (1 + centerTolerance);
125
+
126
+ const checkPointInCenterOval = (x: number, y: number) => {
127
+ const dx = (x - ovalCenterX) / centerOvalWidth;
128
+ const dy = (y - ovalCenterY) / centerOvalHeight;
129
+ return dx * dx + dy * dy <= 1;
130
+ };
131
+ const centerInBounds = checkPointInCenterOval(centerX, centerY);
132
+
133
+ if (options.centerOnly) {
134
+ // Strict mode: nose-tip must be centred AND the face bounding box must
135
+ // not clip the oval edges. The four-corner check uses a slightly looser
136
+ // tolerance than the non-strict case because head rotation legitimately
137
+ // widens the bounding box, but we still want to reject framings where
138
+ // a chunk of the face is outside the oval (e.g. ear or chin clipped).
139
+ const strictBoundsToleranceX = 0.25;
140
+ const strictBoundsToleranceY = 0.15;
141
+ const strictOvalWidth = ovalWidth * (1 + strictBoundsToleranceX);
142
+ const strictOvalHeight = ovalHeight * (1 + strictBoundsToleranceY);
143
+ const checkPointInStrictOval = (x: number, y: number) => {
144
+ const dx = (x - ovalCenterX) / strictOvalWidth;
145
+ const dy = (y - ovalCenterY) / strictOvalHeight;
146
+ return dx * dx + dy * dy <= 1;
147
+ };
148
+ const tl = checkPointInStrictOval(minX, minY);
149
+ const tr = checkPointInStrictOval(maxX, minY);
150
+ const bl = checkPointInStrictOval(minX, maxY);
151
+ const br = checkPointInStrictOval(maxX, maxY);
152
+
153
+ // Frame-edge clipping guard: if any side of the bounding box is hard
154
+ // against the camera frame edge, part of the face is almost certainly
155
+ // cut off (e.g. chin or forehead outside the visible video). The oval
156
+ // check alone misses this because the oval extends most of the frame
157
+ // height, so a face that fills the frame also fills the oval.
158
+ const FRAME_EDGE_MARGIN = 0.03;
159
+ const notClipped =
160
+ minX > FRAME_EDGE_MARGIN &&
161
+ minY > FRAME_EDGE_MARGIN &&
162
+ maxX < 1 - FRAME_EDGE_MARGIN &&
163
+ maxY < 1 - FRAME_EDGE_MARGIN;
164
+
165
+ return centerInBounds && tl && tr && bl && br && notClipped;
166
+ }
167
+
168
+ const toleranceX = 0.2;
169
+ const toleranceY = 0.1;
170
+ const adjustedOvalWidth = ovalWidth * (1 + toleranceX);
171
+ const adjustedOvalHeight = ovalHeight * (1 + toleranceY);
172
+
173
+ const checkPointInExpandedOval = (x: number, y: number) => {
174
+ const dx = (x - ovalCenterX) / adjustedOvalWidth;
175
+ const dy = (y - ovalCenterY) / adjustedOvalHeight;
176
+ return dx * dx + dy * dy <= 1;
177
+ };
178
+
179
+ const topLeft = checkPointInExpandedOval(minX, minY);
180
+ const topRight = checkPointInExpandedOval(maxX, minY);
181
+ const bottomLeft = checkPointInExpandedOval(minX, maxY);
182
+ const bottomRight = checkPointInExpandedOval(maxX, maxY);
183
+
184
+ return centerInBounds && topLeft && topRight && bottomLeft && bottomRight;
185
+ };
186
+
187
+ /**
188
+ * Detect whether the visible egg-shaped oval mask is clipping any part of
189
+ * the face. Uses the actual rendered DOM rects of the <video> element and
190
+ * its wrapper, so the result is always aligned with what the user sees,
191
+ * regardless of the camera's intrinsic resolution or how the video is
192
+ * cropped/positioned by CSS.
193
+ *
194
+ * The visible oval is approximated as a centred ellipse matching the SVG
195
+ * ellipse drawn by OvalProgress (cx=155.5/311, cy=209/418, rx=153.5/311,
196
+ * ry=207/418 — i.e. essentially fills the wrapper, inset by the 2px stroke).
197
+ *
198
+ * @returns true if any landmark falls outside the visible oval (i.e. the
199
+ * oval boundary is clipping the face), false otherwise.
200
+ */
201
+ // Visible-oval geometry in wrapper-normalised coordinates. Kept in one place
202
+ // so the boolean check and the directional check below can't drift apart.
203
+ // Matches OvalProgress.tsx (rx≈0.494, ry≈0.495). A small inward inset gives
204
+ // us a hair of tolerance for landmark jitter so a face flush against the
205
+ // border doesn't flicker between clipping/not.
206
+ const OVAL_CX = 0.5;
207
+ const OVAL_CY = 0.5;
208
+ const OVAL_HALF_W = 0.49;
209
+ const OVAL_HALF_H = 0.49;
210
+
211
+ interface ProjectedOval {
212
+ videoRect: DOMRect;
213
+ wrapperRect: DOMRect;
214
+ /** Top-left of the centre-cropped square within wrapper-pixel coords. */
215
+ cropLeftInWrapper: number;
216
+ cropTopInWrapper: number;
217
+ /** Side length of the centre-cropped square in wrapper-pixel coords. */
218
+ cropSize: number;
219
+ }
220
+
221
+ const projectOval = (videoEl: HTMLVideoElement): ProjectedOval | null => {
222
+ const wrapper = videoEl.parentElement?.parentElement;
223
+ if (!wrapper) return null;
224
+ const videoRect = videoEl.getBoundingClientRect();
225
+ const wrapperRect = wrapper.getBoundingClientRect();
226
+ if (
227
+ videoRect.width <= 0 ||
228
+ videoRect.height <= 0 ||
229
+ wrapperRect.width <= 0 ||
230
+ wrapperRect.height <= 0
231
+ ) {
232
+ return null;
233
+ }
234
+ // Detection runs on a centre-cropped SQUARE of the source video (see
235
+ // createCroppedVideoFrame). Landmark coords are normalised to that
236
+ // square's [0,1]² space, NOT the full video — so we need the crop's
237
+ // rendered position and size to project them onto the wrapper.
238
+ const cropSize = Math.min(videoRect.width, videoRect.height);
239
+ const cropLeftInWrapper =
240
+ videoRect.left - wrapperRect.left + (videoRect.width - cropSize) / 2;
241
+ const cropTopInWrapper =
242
+ videoRect.top - wrapperRect.top + (videoRect.height - cropSize) / 2;
243
+ return {
244
+ videoRect,
245
+ wrapperRect,
246
+ cropLeftInWrapper,
247
+ cropTopInWrapper,
248
+ cropSize,
249
+ };
250
+ };
251
+
252
+ export const computeFaceClippingOval = (
253
+ face: any,
254
+ videoEl: HTMLVideoElement | null,
255
+ ): boolean => {
256
+ if (!face || face.length === 0 || !videoEl) return false;
257
+ const proj = projectOval(videoEl);
258
+ if (!proj) return false;
259
+
260
+ for (let i = 0; i < face.length; i += 1) {
261
+ const lm = face[i];
262
+ const renderedX = proj.cropLeftInWrapper + lm.x * proj.cropSize;
263
+ const renderedY = proj.cropTopInWrapper + lm.y * proj.cropSize;
264
+ const nx = renderedX / proj.wrapperRect.width;
265
+ const ny = renderedY / proj.wrapperRect.height;
266
+ const dx = (nx - OVAL_CX) / OVAL_HALF_W;
267
+ const dy = (ny - OVAL_CY) / OVAL_HALF_H;
268
+ if (dx * dx + dy * dy > 1) {
269
+ return true;
270
+ }
271
+ }
272
+ return false;
273
+ };
274
+
275
+ /**
276
+ * Like {@link computeFaceClippingOval} but returns which side of the visible
277
+ * oval is being clipped (or null when the face is fully inside).
278
+ *
279
+ * Side is derived from the FACE BOUNDING-BOX CENTRE relative to the oval
280
+ * centre — using the worst single landmark flips between left/right ear on
281
+ * symmetric clips (the user sees the prompt ping-pong every frame). The
282
+ * bbox centre moves slowly and points consistently in one direction.
283
+ *
284
+ * Mirroring: the preview is CSS-mirrored (scaleX(-1)). A face whose source
285
+ * centre has x<0.5 appears on screen-RIGHT, so we report 'right' to nudge
286
+ * the device in the correct direction in the mirrored preview.
287
+ */
288
+ export const computeFaceClippingSide = (
289
+ face: any,
290
+ videoEl: HTMLVideoElement | null,
291
+ ): 'top' | 'right' | 'bottom' | 'left' | null => {
292
+ if (!face || face.length === 0 || !videoEl) return null;
293
+ const proj = projectOval(videoEl);
294
+ if (!proj) return null;
295
+
296
+ // First pass: are we actually clipping? Same test as
297
+ // computeFaceClippingOval — keep it inline to avoid two DOM reads.
298
+ let clipping = false;
299
+ let minNx = Infinity;
300
+ let maxNx = -Infinity;
301
+ let minNy = Infinity;
302
+ let maxNy = -Infinity;
303
+ for (let i = 0; i < face.length; i += 1) {
304
+ const lm = face[i];
305
+ const renderedX = proj.cropLeftInWrapper + lm.x * proj.cropSize;
306
+ const renderedY = proj.cropTopInWrapper + lm.y * proj.cropSize;
307
+ const nx = renderedX / proj.wrapperRect.width;
308
+ const ny = renderedY / proj.wrapperRect.height;
309
+ if (nx < minNx) minNx = nx;
310
+ if (nx > maxNx) maxNx = nx;
311
+ if (ny < minNy) minNy = ny;
312
+ if (ny > maxNy) maxNy = ny;
313
+ if (!clipping) {
314
+ const dx = (nx - OVAL_CX) / OVAL_HALF_W;
315
+ const dy = (ny - OVAL_CY) / OVAL_HALF_H;
316
+ if (dx * dx + dy * dy > 1) clipping = true;
317
+ }
318
+ }
319
+ if (!clipping) return null;
320
+
321
+ // Direction from bbox centre to oval centre, ellipse-normalised so the
322
+ // dominant axis comparison is fair (the oval isn't quite a circle).
323
+ const centreNx = (minNx + maxNx) / 2;
324
+ const centreNy = (minNy + maxNy) / 2;
325
+ const dx = (centreNx - OVAL_CX) / OVAL_HALF_W;
326
+ const dy = (centreNy - OVAL_CY) / OVAL_HALF_H;
327
+
328
+ // Hysteresis-friendly axis pick: require horizontal lead over vertical
329
+ // by a clear margin before reporting left/right. Prevents axis flicker
330
+ // when |dx| ≈ |dy| (e.g. face nudged into a corner).
331
+ const AXIS_MARGIN = 1.2;
332
+ const horizontalDominant = Math.abs(dx) > Math.abs(dy) * AXIS_MARGIN;
333
+ const verticalDominant = Math.abs(dy) > Math.abs(dx) * AXIS_MARGIN;
334
+
335
+ if (horizontalDominant) {
336
+ // Mirror: source-left (dx<0) appears on screen-right.
337
+ return dx < 0 ? 'right' : 'left';
338
+ }
339
+ if (verticalDominant) {
340
+ return dy < 0 ? 'top' : 'bottom';
341
+ }
342
+ // Mixed corner clip — pick by raw magnitude as a tiebreak.
343
+ if (Math.abs(dx) >= Math.abs(dy)) {
344
+ return dx < 0 ? 'right' : 'left';
345
+ }
346
+ return dy < 0 ? 'top' : 'bottom';
347
+ };
348
+
349
+ /**
350
+ * Calculate mouth opening using face landmarks
351
+ */
352
+ export const calculateMouthOpening = (landmarks: any): number => {
353
+ if (!landmarks || landmarks.length === 0) return 0;
354
+
355
+ const face = landmarks[0];
356
+ if (!face || face.length === 0) return 0;
357
+
358
+ // MediaPipe face landmark indices for mouth
359
+ const upperLipCenter = face[13]; // Upper lip center
360
+ const lowerLipCenter = face[14]; // Lower lip center
361
+
362
+ if (!upperLipCenter || !lowerLipCenter) return 0;
363
+
364
+ const mouthHeight = Math.abs(lowerLipCenter.y - upperLipCenter.y);
365
+
366
+ const faceTop = Math.min(...face.map((p: any) => p.y));
367
+ const faceBottom = Math.max(...face.map((p: any) => p.y));
368
+ const faceHeight = faceBottom - faceTop;
369
+
370
+ return faceHeight > 0 ? mouthHeight / faceHeight : 0;
371
+ };
372
+
373
+ /**
374
+ * Discrete head-pose direction used by the active liveness state machine.
375
+ */
376
+ export type HeadPoseDirection = 'left' | 'right' | 'up';
377
+
378
+ export interface HeadPoseAngles {
379
+ /** Left/right rotation in degrees. Negative = subject's left, positive = subject's right. */
380
+ yaw: number;
381
+ /**
382
+ * Vertical tilt as a signed ratio (×100 so it reads like degrees).
383
+ * Positive = looking up, negative = looking down, ~0 = neutral.
384
+ *
385
+ * Derived from the nose tip's vertical position between the forehead and
386
+ * chin landmarks, which is a stable 2D measure independent of z noise.
387
+ */
388
+ pitch: number;
389
+ /** Side-to-side tilt in degrees. */
390
+ roll: number;
391
+ }
392
+
393
+ /**
394
+ * Estimate head pose (yaw/pitch/roll) from MediaPipe face landmarks.
395
+ *
396
+ * Uses a small set of stable landmarks rather than full PnP solving so it
397
+ * stays cheap enough to run every detection frame on the main thread.
398
+ *
399
+ * Landmark indices (MediaPipe FaceLandmarker, 478-point model):
400
+ * 1 - nose tip
401
+ * 10 - forehead (top of face oval)
402
+ * 33 - left eye outer corner
403
+ * 152 - chin (bottom of face oval)
404
+ * 263 - right eye outer corner
405
+ * 207 - left cheek
406
+ * 426 - right cheek
407
+ */
408
+ export const calculateHeadPose = (landmarks: any): HeadPoseAngles | null => {
409
+ if (!landmarks || landmarks.length === 0) return null;
410
+ const face = landmarks[0];
411
+ if (!face || face.length < 427) return null;
412
+
413
+ const noseTip = face[1];
414
+ const leftEye = face[33];
415
+ const rightEye = face[263];
416
+ const leftCheek = face[207];
417
+ const rightCheek = face[426];
418
+ const forehead = face[10];
419
+ const chin = face[152];
420
+
421
+ if (
422
+ !noseTip ||
423
+ !leftEye ||
424
+ !rightEye ||
425
+ !leftCheek ||
426
+ !rightCheek ||
427
+ !forehead ||
428
+ !chin
429
+ ) {
430
+ return null;
431
+ }
432
+
433
+ const toDeg = (rad: number) => (rad * 180) / Math.PI;
434
+ const clamp = (v: number, lo: number, hi: number) =>
435
+ Math.max(lo, Math.min(hi, v));
436
+
437
+ // Yaw: signed cheek-depth component projected onto the physical 3D
438
+ // distance between the two cheek landmarks. asin is symmetric and has
439
+ // no singularity, unlike atan2(dz, dx) which inflates and jitters when
440
+ // the inter-cheek x-gap collapses on a left turn. The 3D cheek span is
441
+ // approximately constant across head rotation (the cheeks don't move
442
+ // relative to each other on the skull), so dz / cheekSpan3D is a clean
443
+ // sin(yaw) signal. Positive = turning the head's right (subject's
444
+ // right cheek goes back), negative = turning left.
445
+ const dx = rightCheek.x - leftCheek.x;
446
+ const dy = rightCheek.y - leftCheek.y;
447
+ const dz = rightCheek.z - leftCheek.z;
448
+ const cheekSpan3D = Math.sqrt(dx * dx + dy * dy + dz * dz);
449
+ const yaw =
450
+ cheekSpan3D > 0 ? toDeg(Math.asin(clamp(dz / cheekSpan3D, -1, 1))) : 0;
451
+
452
+ // Pitch: where the nose sits vertically between the forehead (top) and chin
453
+ // (bottom). Neutral ≈ 0.5; tilting up pushes the nose toward the forehead
454
+ // (ratio < 0.5) so we negate to make "up" positive. Multiplied by 100 to
455
+ // keep numbers in a similar order of magnitude as yaw/roll degrees.
456
+ const faceHeight = chin.y - forehead.y;
457
+ let pitch = 0;
458
+ if (faceHeight > 0) {
459
+ const ratio = (noseTip.y - forehead.y) / faceHeight;
460
+ pitch = (0.5 - ratio) * 100;
461
+ }
462
+
463
+ // Roll: in-plane tilt between the two eye corners.
464
+ const roll = toDeg(
465
+ Math.atan2(rightEye.y - leftEye.y, rightEye.x - leftEye.x),
466
+ );
467
+
468
+ return { yaw, pitch, roll };
469
+ };
470
+
471
+ /**
472
+ * Classify head-pose angles into a discrete required-pose direction.
473
+ *
474
+ * Strict-mode prompts the user for one of: turn left, turn right, tilt slightly
475
+ * up. Yaw thresholds favour deliberate rotation (~25°); pitch threshold (~6)
476
+ * corresponds to roughly the nose moving 6% of face-height toward the
477
+ * forehead, which is a noticeable but comfortable upward tilt.
478
+ *
479
+ * The "up" classification only fires when yaw is small, so a sideways turn
480
+ * doesn't accidentally satisfy the up prompt.
481
+ */
482
+ export const classifyHeadPose = (
483
+ pose: HeadPoseAngles | null,
484
+ thresholds: { yawSide?: number; yawNeutral?: number; pitchUp?: number } = {},
485
+ ): HeadPoseDirection | null => {
486
+ if (!pose) return null;
487
+
488
+ // Yaw is now an asin-based signed sin(yaw) value (degrees), so it's
489
+ // symmetric around 0 and bounded to ±90°. In theory one threshold would
490
+ // work for both directions, but MediaPipe landmarks 207 / 426 aren't
491
+ // true mirror counterparts — dz/cheekSpan grows faster for one rotation
492
+ // direction than the other, so the same physical turn produces
493
+ // unequal yaw magnitudes. Compensate with a slightly lower threshold
494
+ // on the positive (screen-left) side so both prompts feel like the
495
+ // same amount of head movement.
496
+ const yawSide = thresholds.yawSide ?? 18;
497
+ const yawSidePositive = thresholds.yawSide ?? 13;
498
+ const yawNeutral = thresholds.yawNeutral ?? 12;
499
+ // Pitch threshold corresponds to ~7% nose shift toward the forehead —
500
+ // a deliberate tilt rather than the resting-posture drift produced at
501
+ // smaller values.
502
+ const pitchUp = thresholds.pitchUp ?? 7;
503
+
504
+ if (pose.yaw <= -yawSide) return 'right';
505
+ if (pose.yaw >= yawSidePositive) return 'left';
506
+ if (Math.abs(pose.yaw) <= yawNeutral && pose.pitch >= pitchUp) return 'up';
507
+ return null;
508
+ };
509
+
510
+ /*
511
+ * With only 3 poses there are 6 possible permutations, so adjacent sessions
512
+ * will repeat orders fairly often by chance — that's expected, not a bug.
513
+ * Swap indices are drawn from `crypto.getRandomValues` so the sequence is
514
+ * unpredictable to an attacker who has observed previous sessions.
515
+ */
516
+ const randomInt = (maxExclusive: number): number => {
517
+ if (maxExclusive <= 1) return 0;
518
+ // Rejection-sample to avoid modulo bias: discard draws in the unused
519
+ // tail of the uint32 range so the remaining values divide evenly by
520
+ // `maxExclusive`.
521
+ const limit = Math.floor(0x100000000 / maxExclusive) * maxExclusive;
522
+ const buf = new Uint32Array(1);
523
+ do {
524
+ crypto.getRandomValues(buf);
525
+ } while (buf[0] >= limit);
526
+ return buf[0] % maxExclusive;
527
+ };
528
+
529
+ /**
530
+ * Build the pose sequence for an active-liveness session.
531
+ *
532
+ * Randomised order across {left, right, up} to mirror the mobile SDKs and
533
+ * make the active-liveness challenge harder to pre-record. Any leftover
534
+ * frames in the capture window are taken silently while the user is neutral
535
+ * before the first pose prompt — see `useFaceCapture` for that logic.
536
+ */
537
+ export const buildRandomPoseSequence = (): HeadPoseDirection[] => {
538
+ const poses: HeadPoseDirection[] = ['left', 'right', 'up'];
539
+ // Fisher–Yates shuffle.
540
+ for (let i = poses.length - 1; i > 0; i -= 1) {
541
+ const j = randomInt(i + 1);
542
+ [poses[i], poses[j]] = [poses[j], poses[i]];
543
+ }
544
+ return poses;
545
+ };