@aws-amplify/ui-react-liveness 3.6.9 → 3.7.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/components/FaceLivenessDetector/FaceLivenessDetectorCore.mjs +18 -1
- package/dist/esm/components/FaceLivenessDetector/LivenessCheck/LivenessCameraModule.mjs +96 -6
- package/dist/esm/components/FaceLivenessDetector/LivenessCheck/LivenessCheck.mjs +2 -2
- package/dist/esm/components/FaceLivenessDetector/service/machine/machine.mjs +45 -22
- package/dist/esm/components/FaceLivenessDetector/service/utils/closeLivenessStream.mjs +28 -0
- package/dist/esm/components/FaceLivenessDetector/service/utils/liveness.mjs +90 -17
- package/dist/esm/components/FaceLivenessDetector/shared/CancelButton.mjs +4 -1
- package/dist/esm/components/FaceLivenessDetector/shared/DefaultStartScreenComponents.mjs +2 -2
- package/dist/esm/version.mjs +1 -1
- package/dist/index.js +278 -47
- package/dist/styles.css +22 -0
- package/dist/types/components/FaceLivenessDetector/LivenessCheck/LivenessCameraModule.d.ts +1 -0
- package/dist/types/components/FaceLivenessDetector/service/types/machine.d.ts +1 -1
- package/dist/types/components/FaceLivenessDetector/service/utils/closeLivenessStream.d.ts +9 -0
- package/dist/types/components/FaceLivenessDetector/service/utils/index.d.ts +1 -0
- package/dist/types/components/FaceLivenessDetector/service/utils/liveness.d.ts +30 -0
- package/dist/types/components/FaceLivenessDetector/shared/CancelButton.d.ts +15 -0
- package/dist/types/components/FaceLivenessDetector/shared/DefaultStartScreenComponents.d.ts +11 -2
- package/dist/types/version.d.ts +1 -1
- package/package.json +3 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import { useInterpret } from '@xstate/react';
|
|
3
3
|
import { livenessMachine } from './service/machine/machine.mjs';
|
|
4
|
-
import './service/utils/constants.mjs';
|
|
4
|
+
import { WS_CLOSURE_CODE } from './service/utils/constants.mjs';
|
|
5
5
|
import './service/types/liveness.mjs';
|
|
6
6
|
import '@tensorflow/tfjs-core';
|
|
7
7
|
import '../../packages/react-liveness/node_modules/@tensorflow-models/face-detection/dist/face-detection.esm.mjs';
|
|
@@ -12,6 +12,7 @@ import './service/utils/ColorSequenceDisplay/ColorSequenceDisplay.mjs';
|
|
|
12
12
|
import '@aws-amplify/ui';
|
|
13
13
|
import './service/utils/createStreamingClient/createStreamingClient.mjs';
|
|
14
14
|
import './service/utils/StreamRecorder/StreamRecorder.mjs';
|
|
15
|
+
import { closeLivenessStream } from './service/utils/closeLivenessStream.mjs';
|
|
15
16
|
import { View, Flex } from '@aws-amplify/ui-react';
|
|
16
17
|
import { FaceLivenessDetectorProvider } from './providers/FaceLivenessDetectorProvider.mjs';
|
|
17
18
|
import { LivenessCheck } from './LivenessCheck/LivenessCheck.mjs';
|
|
@@ -31,6 +32,22 @@ function FaceLivenessDetectorCore(props) {
|
|
|
31
32
|
},
|
|
32
33
|
},
|
|
33
34
|
});
|
|
35
|
+
React.useEffect(() => {
|
|
36
|
+
// Track the stream provider as the state changes: by the time this
|
|
37
|
+
// effect is cleaned up, the service has been stopped and reset.
|
|
38
|
+
let livenessStreamProvider;
|
|
39
|
+
const { unsubscribe } = service.subscribe(({ context }) => {
|
|
40
|
+
({ livenessStreamProvider } = context);
|
|
41
|
+
});
|
|
42
|
+
return () => {
|
|
43
|
+
unsubscribe();
|
|
44
|
+
// Unmounting mid-check (e.g. the host modal is closed) bypasses the
|
|
45
|
+
// CANCEL event, so close the stream the same way the cancel button does
|
|
46
|
+
if (livenessStreamProvider?.isRecording()) {
|
|
47
|
+
closeLivenessStream(livenessStreamProvider, WS_CLOSURE_CODE.USER_CANCEL);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}, [service]);
|
|
34
51
|
return (React.createElement(View, { className: DETECTOR_CLASS_NAME, testId: DETECTOR_CLASS_NAME },
|
|
35
52
|
React.createElement(FaceLivenessDetectorProvider, { componentProps: props, service: service },
|
|
36
53
|
React.createElement(Flex, { direction: "column", ref: currElementRef },
|
|
@@ -10,7 +10,7 @@ import '../../../packages/react-liveness/node_modules/@tensorflow-models/face-de
|
|
|
10
10
|
import '@tensorflow/tfjs-backend-wasm';
|
|
11
11
|
import '@tensorflow/tfjs-backend-cpu';
|
|
12
12
|
import '@aws-amplify/core/internals/utils';
|
|
13
|
-
import { drawStaticOval, clearOvalCanvas } from '../service/utils/liveness.mjs';
|
|
13
|
+
import { drawStaticOval, getVideoFillLayout, clearOvalCanvas } from '../service/utils/liveness.mjs';
|
|
14
14
|
import '../service/utils/ColorSequenceDisplay/ColorSequenceDisplay.mjs';
|
|
15
15
|
import '../service/utils/createStreamingClient/createStreamingClient.mjs';
|
|
16
16
|
import '../service/utils/StreamRecorder/StreamRecorder.mjs';
|
|
@@ -32,7 +32,11 @@ const selectVideoStream = createLivenessSelector((state) => state.context.videoA
|
|
|
32
32
|
const selectFaceMatchPercentage = createLivenessSelector((state) => state.context.faceMatchAssociatedParams?.faceMatchPercentage);
|
|
33
33
|
const selectFaceMatchState = createLivenessSelector((state) => state.context.faceMatchAssociatedParams?.faceMatchState);
|
|
34
34
|
const selectSelectedDeviceId = createLivenessSelector((state) => state.context.videoAssociatedParams?.selectedDeviceId);
|
|
35
|
+
const selectDisableStartScreen = createLivenessSelector((state) => state.context.componentProps?.disableStartScreen);
|
|
35
36
|
const selectSelectableDevices = createLivenessSelector((state) => state.context.videoAssociatedParams?.selectableDevices);
|
|
37
|
+
// how long the video size must be stable during recording before the session
|
|
38
|
+
// oval is redrawn
|
|
39
|
+
const VIDEO_RESIZED_DEBOUNCE_MS = 100;
|
|
36
40
|
const showMatchIndicatorStates = [
|
|
37
41
|
FaceMatchState.TOO_FAR,
|
|
38
42
|
FaceMatchState.CANT_IDENTIFY,
|
|
@@ -48,7 +52,7 @@ const MemoizedMatchIndicator = React__default.memo(MatchIndicator);
|
|
|
48
52
|
const LivenessCameraModule = (props) => {
|
|
49
53
|
const { isMobileScreen, isRecordingStopped, instructionDisplayText, streamDisplayText, hintDisplayText, errorDisplayText, cameraDisplayText, components: customComponents, testId, } = props;
|
|
50
54
|
const { cancelLivenessCheckText, recordingIndicatorText } = streamDisplayText;
|
|
51
|
-
const { ErrorView = FaceLivenessErrorModal, PhotosensitiveWarning = DefaultPhotosensitiveWarning, } = customComponents ?? {};
|
|
55
|
+
const { ErrorView = FaceLivenessErrorModal, PhotosensitiveWarning = DefaultPhotosensitiveWarning, CancelButton, } = customComponents ?? {};
|
|
52
56
|
const [state, send] = useLivenessActor();
|
|
53
57
|
const isFaceMovementChallenge = useLivenessSelector(selectChallengeType) === FACE_MOVEMENT_CHALLENGE.type;
|
|
54
58
|
const videoStream = useLivenessSelector(selectVideoStream);
|
|
@@ -58,9 +62,11 @@ const LivenessCameraModule = (props) => {
|
|
|
58
62
|
const faceMatchPercentage = useLivenessSelector(selectFaceMatchPercentage);
|
|
59
63
|
const faceMatchState = useLivenessSelector(selectFaceMatchState);
|
|
60
64
|
const errorState = useLivenessSelector(selectErrorState);
|
|
65
|
+
const disableStartScreen = useLivenessSelector(selectDisableStartScreen);
|
|
61
66
|
const colorMode = useColorMode();
|
|
62
67
|
const { videoRef, videoWidth, videoHeight } = useMediaStreamInVideo(videoStream);
|
|
63
68
|
const canvasRef = useRef(null);
|
|
69
|
+
const videoAnchorRef = useRef(null);
|
|
64
70
|
const freshnessColorRef = useRef(null);
|
|
65
71
|
const [isCameraReady, setIsCameraReady] = useState(false);
|
|
66
72
|
const [isMetadataLoaded, setIsMetadataLoaded] = useState(false);
|
|
@@ -119,6 +125,84 @@ const LivenessCameraModule = (props) => {
|
|
|
119
125
|
lightModePreference.addEventListener('change', updateColorModeHandler);
|
|
120
126
|
};
|
|
121
127
|
}, [videoRef, videoStream, colorMode, isStartView, isMetadataLoaded]);
|
|
128
|
+
// Size the video to fill the anchor, which grows to fill the host container
|
|
129
|
+
// when the host gives it a height (e.g. a modal body)
|
|
130
|
+
const layoutVideo = React__default.useCallback(() => {
|
|
131
|
+
const anchor = videoAnchorRef.current;
|
|
132
|
+
const video = videoRef.current;
|
|
133
|
+
if (!anchor || !video?.videoWidth || !video.videoHeight) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const { width, height, left, top } = getVideoFillLayout({
|
|
137
|
+
containerWidth: anchor.clientWidth,
|
|
138
|
+
containerHeight: anchor.clientHeight,
|
|
139
|
+
videoWidth: video.videoWidth,
|
|
140
|
+
videoHeight: video.videoHeight,
|
|
141
|
+
});
|
|
142
|
+
video.style.width = `${width}px`;
|
|
143
|
+
video.style.height = `${height}px`;
|
|
144
|
+
video.style.left = `${left}px`;
|
|
145
|
+
video.style.top = `${top}px`;
|
|
146
|
+
}, [videoRef]);
|
|
147
|
+
// Keep the oval aligned with the video when the host container resizes,
|
|
148
|
+
// e.g. a modal open animation or a responsive layout change. Kept in a ref
|
|
149
|
+
// so the observer below always calls the latest version.
|
|
150
|
+
const redrawOnResizeRef = React__default.useRef();
|
|
151
|
+
const recordingResizeTimeoutRef = React__default.useRef(null);
|
|
152
|
+
React__default.useLayoutEffect(() => {
|
|
153
|
+
redrawOnResizeRef.current = () => {
|
|
154
|
+
layoutVideo();
|
|
155
|
+
if (isStartView && isMetadataLoaded && canvasRef.current && videoStream) {
|
|
156
|
+
drawStaticOval(canvasRef.current, videoRef.current, videoStream);
|
|
157
|
+
}
|
|
158
|
+
else if (isRecording) {
|
|
159
|
+
// the video is laid out every frame above; only redraw the session
|
|
160
|
+
// oval once the size settles to avoid a machine transition per frame
|
|
161
|
+
if (recordingResizeTimeoutRef.current) {
|
|
162
|
+
clearTimeout(recordingResizeTimeoutRef.current);
|
|
163
|
+
}
|
|
164
|
+
recordingResizeTimeoutRef.current = setTimeout(() => {
|
|
165
|
+
recordingResizeTimeoutRef.current = null;
|
|
166
|
+
send({ type: 'VIDEO_RESIZED' });
|
|
167
|
+
}, VIDEO_RESIZED_DEBOUNCE_MS);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
React__default.useEffect(() => {
|
|
172
|
+
const anchor = videoAnchorRef.current;
|
|
173
|
+
if (!anchor || typeof ResizeObserver === 'undefined') {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
let lastWidth;
|
|
177
|
+
let lastHeight;
|
|
178
|
+
let frameId;
|
|
179
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
180
|
+
const { clientWidth, clientHeight } = anchor;
|
|
181
|
+
if (clientWidth === lastWidth && clientHeight === lastHeight) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
lastWidth = clientWidth;
|
|
185
|
+
lastHeight = clientHeight;
|
|
186
|
+
// coalesce into a single redraw per frame
|
|
187
|
+
frameId ?? (frameId = requestAnimationFrame(() => {
|
|
188
|
+
frameId = undefined;
|
|
189
|
+
redrawOnResizeRef.current?.();
|
|
190
|
+
}));
|
|
191
|
+
});
|
|
192
|
+
resizeObserver.observe(anchor);
|
|
193
|
+
return () => {
|
|
194
|
+
resizeObserver.disconnect();
|
|
195
|
+
if (frameId !== undefined) {
|
|
196
|
+
cancelAnimationFrame(frameId);
|
|
197
|
+
}
|
|
198
|
+
if (recordingResizeTimeoutRef.current) {
|
|
199
|
+
clearTimeout(recordingResizeTimeoutRef.current);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
// The camera permission check returns early (below) without rendering the
|
|
203
|
+
// video anchor, so the ref is only set once `isCheckingCamera` is false;
|
|
204
|
+
// re-run then to start observing the newly mounted anchor.
|
|
205
|
+
}, [isCheckingCamera]);
|
|
122
206
|
React__default.useLayoutEffect(() => {
|
|
123
207
|
if (isCameraReady) {
|
|
124
208
|
send({
|
|
@@ -150,6 +234,7 @@ const LivenessCameraModule = (props) => {
|
|
|
150
234
|
setIsCameraReady(true);
|
|
151
235
|
};
|
|
152
236
|
const handleLoadedMetadata = () => {
|
|
237
|
+
layoutVideo();
|
|
153
238
|
setIsMetadataLoaded(true);
|
|
154
239
|
};
|
|
155
240
|
const beginLivenessCheck = React__default.useCallback(() => {
|
|
@@ -184,7 +269,9 @@ const LivenessCameraModule = (props) => {
|
|
|
184
269
|
// We don't show full screen camera on the pre check screen (isStartView/isWaitingForCamera)
|
|
185
270
|
const shouldShowFullScreenCamera = isMobileScreen && !isStartView && !shouldShowCenteredLoader;
|
|
186
271
|
return (React__default.createElement(React__default.Fragment, null,
|
|
187
|
-
!isFaceMovementChallenge &&
|
|
272
|
+
!isFaceMovementChallenge &&
|
|
273
|
+
!disableStartScreen &&
|
|
274
|
+
photoSensitivityWarning,
|
|
188
275
|
shouldShowCenteredLoader && (React__default.createElement(Flex, { className: LivenessClassNames.ConnectingLoader },
|
|
189
276
|
React__default.createElement(Loader, { size: "large", className: LivenessClassNames.Loader, "data-testid": "centered-loader" }),
|
|
190
277
|
React__default.createElement(Text, { className: LivenessClassNames.LandscapeErrorModalHeader }, hintDisplayText.hintConnectingText))),
|
|
@@ -192,7 +279,10 @@ const LivenessCameraModule = (props) => {
|
|
|
192
279
|
`${LivenessClassNames.CameraModule}--mobile`), "data-testid": testId, gap: "zero" },
|
|
193
280
|
React__default.createElement(Overlay, { horizontal: "center", vertical: isRecording && !isFlashingFreshness ? 'start' : 'space-between', className: LivenessClassNames.InstructionOverlay },
|
|
194
281
|
isRecording && (React__default.createElement(DefaultRecordingIcon, { recordingIndicatorText: recordingIndicatorText })),
|
|
195
|
-
!isStartView &&
|
|
282
|
+
!isStartView &&
|
|
283
|
+
!isWaitingForCamera &&
|
|
284
|
+
!isCheckSucceeded &&
|
|
285
|
+
CancelButton !== null && (React__default.createElement(DefaultCancelButton, { cancelLivenessCheckText: cancelLivenessCheckText, CancelButton: CancelButton })),
|
|
196
286
|
React__default.createElement(Flex, { className: classNames(LivenessClassNames.Hint, shouldShowFullScreenCamera && `${LivenessClassNames.Hint}--mobile`) },
|
|
197
287
|
React__default.createElement(Hint, { hintDisplayText: hintDisplayText })),
|
|
198
288
|
errorState && (React__default.createElement(ErrorView, { onRetry: () => {
|
|
@@ -205,7 +295,7 @@ const LivenessCameraModule = (props) => {
|
|
|
205
295
|
!isFlashingFreshness &&
|
|
206
296
|
showMatchIndicatorStates.includes(faceMatchState) ? (React__default.createElement(MemoizedMatchIndicator, { percentage: Math.ceil(faceMatchPercentage) })) : null),
|
|
207
297
|
React__default.createElement(View, { as: "canvas", ref: freshnessColorRef, className: LivenessClassNames.FreshnessCanvas, hidden: true }),
|
|
208
|
-
React__default.createElement(View, { className: LivenessClassNames.VideoAnchor, style: {
|
|
298
|
+
React__default.createElement(View, { ref: videoAnchorRef, className: LivenessClassNames.VideoAnchor, style: {
|
|
209
299
|
aspectRatio: `${aspectRatio}`,
|
|
210
300
|
} },
|
|
211
301
|
React__default.createElement("video", { ref: videoRef, muted: true, autoPlay: true, playsInline: true, width: mediaWidth, height: mediaHeight, onCanPlay: handleMediaPlay, onLoadedMetadata: handleLoadedMetadata, "data-testid": "video", className: classNames(LivenessClassNames.Video, isCameraUserFacing && LivenessClassNames.UserFacingVideo, isRecordingStopped && LivenessClassNames.FadeOut), "aria-label": cameraDisplayText.a11yVideoLabelText }),
|
|
@@ -217,4 +307,4 @@ const LivenessCameraModule = (props) => {
|
|
|
217
307
|
React__default.createElement(Button, { variation: "primary", type: "button", onClick: beginLivenessCheck }, instructionDisplayText.startScreenBeginCheckText)))));
|
|
218
308
|
};
|
|
219
309
|
|
|
220
|
-
export { LivenessCameraModule, selectChallengeType, selectFaceMatchPercentage, selectFaceMatchState, selectSelectableDevices, selectSelectedDeviceId, selectVideoConstraints, selectVideoStream };
|
|
310
|
+
export { LivenessCameraModule, selectChallengeType, selectDisableStartScreen, selectFaceMatchPercentage, selectFaceMatchState, selectSelectableDevices, selectSelectedDeviceId, selectVideoConstraints, selectVideoStream };
|
|
@@ -81,8 +81,8 @@ const LivenessCheck = ({ instructionDisplayText, hintDisplayText, cameraDisplayT
|
|
|
81
81
|
? cameraMinSpecificationsMessageText
|
|
82
82
|
: cameraNotFoundMessageText),
|
|
83
83
|
React.createElement(Button, { variation: "primary", type: "button", onClick: recheckCameraPermissions }, retryCameraPermissionsText),
|
|
84
|
-
React.createElement(View, { position: "absolute", top: "medium", right: "medium" },
|
|
85
|
-
React.createElement(CancelButton, { ariaLabel: cancelLivenessCheckText }))));
|
|
84
|
+
components?.CancelButton !== null && (React.createElement(View, { position: "absolute", top: "medium", right: "medium" },
|
|
85
|
+
React.createElement(CancelButton, { ariaLabel: cancelLivenessCheckText, Component: components?.CancelButton })))));
|
|
86
86
|
}
|
|
87
87
|
else {
|
|
88
88
|
return (React.createElement(LivenessCameraModule, { isMobileScreen: isMobile, isRecordingStopped: isRecordingStopped, instructionDisplayText: instructionDisplayText, streamDisplayText: streamDisplayText, hintDisplayText: hintDisplayText, errorDisplayText: errorDisplayText, cameraDisplayText: cameraDisplayText, components: components }));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { v4 } from 'uuid';
|
|
2
2
|
import { createMachine, assign, actions, spawn } from 'xstate';
|
|
3
|
-
import { fillOverlayCanvasFractional, generateBboxFromLandmarks, getOvalBoundingBox, getIntersectionOverUnion, estimateIllumination, getOvalDetailsFromSessionInformation, drawLivenessOvalInCanvas, getStaticLivenessOvalDetails, isFaceDistanceBelowThreshold, getFaceMatchState, isCameraDeviceVirtual, getColorsSequencesFromSessionInformation } from '../utils/liveness.mjs';
|
|
3
|
+
import { fillOverlayCanvasFractional, getVideoScaleFactor, generateBboxFromLandmarks, getOvalBoundingBox, getIntersectionOverUnion, estimateIllumination, resizeCanvasToDisplaySize, getOvalDetailsFromSessionInformation, drawLivenessOvalInCanvas, getStaticLivenessOvalDetails, isFaceDistanceBelowThreshold, getFaceMatchState, isCameraDeviceVirtual, getColorsSequencesFromSessionInformation } from '../utils/liveness.mjs';
|
|
4
4
|
import { FACE_MOVEMENT_AND_LIGHT_CHALLENGE, FACE_MOVEMENT_CHALLENGE, WS_CLOSURE_CODE } from '../utils/constants.mjs';
|
|
5
5
|
import { FaceMatchState } from '../types/liveness.mjs';
|
|
6
6
|
import { LivenessErrorState } from '../types/error.mjs';
|
|
@@ -12,6 +12,7 @@ import { createSessionEndEvent, getTrackDimensions, createColorDisplayEvent, cre
|
|
|
12
12
|
import { createStreamingClient } from '../utils/createStreamingClient/createStreamingClient.mjs';
|
|
13
13
|
import { createSessionInfoFromServerSessionInformation } from '../utils/sessionInformation.mjs';
|
|
14
14
|
import { StreamRecorder } from '../utils/StreamRecorder/StreamRecorder.mjs';
|
|
15
|
+
import { closeLivenessStream } from '../utils/closeLivenessStream.mjs';
|
|
15
16
|
import { isServerSessionInformationEvent, isDisconnectionEvent, isValidationExceptionEvent, isInternalServerExceptionEvent, isThrottlingExceptionEvent, isServiceQuotaExceededExceptionEvent, isInvalidSignatureRegionException, isConnectionTimeoutError } from '../utils/responseStreamEvent.mjs';
|
|
16
17
|
import { STATIC_VIDEO_CONSTRAINTS } from '../../utils/helpers.mjs';
|
|
17
18
|
import { TelemetryReporter } from '../utils/TelemetryReporter/TelemetryReporter.mjs';
|
|
@@ -279,6 +280,12 @@ const livenessMachine = createMachine({
|
|
|
279
280
|
'sendTimeoutAfterOvalDrawingDelay',
|
|
280
281
|
],
|
|
281
282
|
initial: 'ovalDrawing',
|
|
283
|
+
on: {
|
|
284
|
+
VIDEO_RESIZED: {
|
|
285
|
+
internal: true,
|
|
286
|
+
actions: ['updateOvalScaleFactor', 'redrawLivenessOval'],
|
|
287
|
+
},
|
|
288
|
+
},
|
|
282
289
|
states: {
|
|
283
290
|
ovalDrawing: {
|
|
284
291
|
invoke: {
|
|
@@ -551,6 +558,34 @@ const livenessMachine = createMachine({
|
|
|
551
558
|
isFaceFarEnoughBeforeRecording: (_, event) => !!event.data.isFaceFarEnoughBeforeRecording,
|
|
552
559
|
errorState: (_, event) => event.data?.error,
|
|
553
560
|
}),
|
|
561
|
+
// The oval is drawn once per check; keep it aligned when the host
|
|
562
|
+
// container resizes after it has been drawn (e.g. modal open animation).
|
|
563
|
+
updateOvalScaleFactor: assign({
|
|
564
|
+
ovalAssociatedParams: (context) => {
|
|
565
|
+
const { videoEl } = context.videoAssociatedParams ?? {};
|
|
566
|
+
if (!context.ovalAssociatedParams?.ovalDetails || !videoEl) {
|
|
567
|
+
return context.ovalAssociatedParams;
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
...context.ovalAssociatedParams,
|
|
571
|
+
scaleFactor: getVideoScaleFactor(videoEl),
|
|
572
|
+
};
|
|
573
|
+
},
|
|
574
|
+
}),
|
|
575
|
+
redrawLivenessOval: (context) => {
|
|
576
|
+
const { videoEl, canvasEl } = context.videoAssociatedParams ?? {};
|
|
577
|
+
const { ovalDetails, scaleFactor } = context.ovalAssociatedParams ?? {};
|
|
578
|
+
if (!videoEl || !canvasEl || !ovalDetails || !scaleFactor) {
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
resizeCanvasToDisplaySize(canvasEl);
|
|
582
|
+
drawLivenessOvalInCanvas({
|
|
583
|
+
canvas: canvasEl,
|
|
584
|
+
oval: ovalDetails,
|
|
585
|
+
scaleFactor,
|
|
586
|
+
videoEl,
|
|
587
|
+
});
|
|
588
|
+
},
|
|
554
589
|
updateOvalAndFaceDetailsPostDraw: assign({
|
|
555
590
|
ovalAssociatedParams: (context, event) => ({
|
|
556
591
|
...context.ovalAssociatedParams,
|
|
@@ -730,12 +765,7 @@ const livenessMachine = createMachine({
|
|
|
730
765
|
else if (context.errorState === undefined) {
|
|
731
766
|
closeCode = WS_CLOSURE_CODE.USER_CANCEL;
|
|
732
767
|
}
|
|
733
|
-
context.livenessStreamProvider
|
|
734
|
-
context.livenessStreamProvider?.dispatchStreamEvent({
|
|
735
|
-
type: 'closeCode',
|
|
736
|
-
data: { closeCode },
|
|
737
|
-
});
|
|
738
|
-
});
|
|
768
|
+
closeLivenessStream(context.livenessStreamProvider, closeCode);
|
|
739
769
|
},
|
|
740
770
|
freezeStream: (context) => {
|
|
741
771
|
const { videoMediaStream, videoEl } = context.videoAssociatedParams;
|
|
@@ -967,7 +997,7 @@ const livenessMachine = createMachine({
|
|
|
967
997
|
},
|
|
968
998
|
async detectInitialFaceAndDrawOval(context) {
|
|
969
999
|
const { parsedSessionInformation } = context;
|
|
970
|
-
const { videoEl, canvasEl
|
|
1000
|
+
const { videoEl, canvasEl } = context.videoAssociatedParams;
|
|
971
1001
|
const { faceDetector } = context.ovalAssociatedParams;
|
|
972
1002
|
// initialize models
|
|
973
1003
|
await faceDetector.modelLoadingPromise;
|
|
@@ -998,20 +1028,12 @@ const livenessMachine = createMachine({
|
|
|
998
1028
|
if (!initialFace) {
|
|
999
1029
|
return { faceMatchState, illuminationState };
|
|
1000
1030
|
}
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
|
|
1004
|
-
if (isMobile) {
|
|
1005
|
-
canvasEl.width = window.innerWidth;
|
|
1006
|
-
canvasEl.height = window.innerHeight;
|
|
1007
|
-
}
|
|
1008
|
-
else {
|
|
1009
|
-
canvasEl.width = videoScaledWidth;
|
|
1010
|
-
canvasEl.height = videoScaledHeight;
|
|
1011
|
-
}
|
|
1031
|
+
// Size the canvas to its container: the full screen on mobile and the
|
|
1032
|
+
// video element otherwise.
|
|
1033
|
+
resizeCanvasToDisplaySize(canvasEl);
|
|
1012
1034
|
// Compute scaleFactor which is how much our video element is scaled
|
|
1013
1035
|
// vs the intrinsic video resolution
|
|
1014
|
-
const scaleFactor =
|
|
1036
|
+
const scaleFactor = getVideoScaleFactor(videoEl);
|
|
1015
1037
|
// generate oval details from initialFace and video dimensions
|
|
1016
1038
|
const ovalDetails = getOvalDetailsFromSessionInformation({
|
|
1017
1039
|
parsedSessionInformation: parsedSessionInformation,
|
|
@@ -1096,7 +1118,7 @@ const livenessMachine = createMachine({
|
|
|
1096
1118
|
if (freshnessColorsComplete) {
|
|
1097
1119
|
return;
|
|
1098
1120
|
}
|
|
1099
|
-
const { ovalDetails
|
|
1121
|
+
const { ovalDetails } = ovalAssociatedParams;
|
|
1100
1122
|
const { videoEl } = videoAssociatedParams;
|
|
1101
1123
|
const completed = await colorSequenceDisplay.startSequences({
|
|
1102
1124
|
onSequenceColorChange: ({ sequenceColor, prevSequenceColor, heightFraction, }) => {
|
|
@@ -1106,7 +1128,8 @@ const livenessMachine = createMachine({
|
|
|
1106
1128
|
ovalDetails: ovalDetails,
|
|
1107
1129
|
nextColor: sequenceColor,
|
|
1108
1130
|
prevColor: prevSequenceColor,
|
|
1109
|
-
|
|
1131
|
+
// read per frame so a resize during the sequence stays aligned
|
|
1132
|
+
scaleFactor: getVideoScaleFactor(videoEl),
|
|
1110
1133
|
videoEl: videoEl,
|
|
1111
1134
|
});
|
|
1112
1135
|
},
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stops recording and sends `closeCode` to the streaming service so the
|
|
3
|
+
* session is closed with a reason instead of being left to time out.
|
|
4
|
+
*
|
|
5
|
+
* Only call this once recording has started: `stopRecording()` resolves when
|
|
6
|
+
* the recorder stops, so if it never started the close code is not sent.
|
|
7
|
+
*/
|
|
8
|
+
function closeLivenessStream(livenessStreamProvider, closeCode) {
|
|
9
|
+
livenessStreamProvider
|
|
10
|
+
?.stopRecording()
|
|
11
|
+
.catch((error) => {
|
|
12
|
+
// eslint-disable-next-line no-console
|
|
13
|
+
console.error('Error stopping liveness recording:', error);
|
|
14
|
+
})
|
|
15
|
+
// send the close code even if stopping failed so the session still ends
|
|
16
|
+
.then(() => {
|
|
17
|
+
livenessStreamProvider.dispatchStreamEvent({
|
|
18
|
+
type: 'closeCode',
|
|
19
|
+
data: { closeCode },
|
|
20
|
+
});
|
|
21
|
+
})
|
|
22
|
+
.catch((error) => {
|
|
23
|
+
// eslint-disable-next-line no-console
|
|
24
|
+
console.error('Error sending liveness close code:', error);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export { closeLivenessStream };
|
|
@@ -91,12 +91,59 @@ function getStaticLivenessOvalDetails({ width, height, widthSeed = 1.0, centerXS
|
|
|
91
91
|
height: Math.floor(ovalHeight),
|
|
92
92
|
};
|
|
93
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Returns how much the intrinsic video frame is scaled inside the rendered
|
|
96
|
+
* video element. The video element uses the default `object-fit: contain`,
|
|
97
|
+
* so when the host container gives it a box that does not match the camera
|
|
98
|
+
* aspect ratio the frame is letterboxed and the smaller ratio applies.
|
|
99
|
+
*/
|
|
100
|
+
function getVideoScaleFactor(videoEl) {
|
|
101
|
+
// layout size, unaffected by CSS transforms such as a modal zoom animation
|
|
102
|
+
const { clientWidth: width, clientHeight: height } = videoEl;
|
|
103
|
+
const { videoWidth, videoHeight } = videoEl;
|
|
104
|
+
if (!videoWidth || !videoHeight) {
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
return Math.min(width / videoWidth, height / videoHeight);
|
|
108
|
+
}
|
|
109
|
+
// When filling a container taller than the camera frame, the frame is scaled
|
|
110
|
+
// up and cropped on the sides. Keep at least this fraction of the frame width
|
|
111
|
+
// visible so the liveness oval (centered, ~45% of the frame width) is never
|
|
112
|
+
// cropped. The frame is never cropped vertically since the oval spans nearly
|
|
113
|
+
// the full frame height.
|
|
114
|
+
const MIN_VISIBLE_FRAME_WIDTH_FRACTION = 0.7;
|
|
115
|
+
/**
|
|
116
|
+
* Returns the size and position of the video element so the camera frame
|
|
117
|
+
* fills as much of its container as possible without cropping the oval.
|
|
118
|
+
*/
|
|
119
|
+
function getVideoFillLayout({ containerWidth, containerHeight, videoWidth, videoHeight, }) {
|
|
120
|
+
const scale = Math.min(containerHeight / videoHeight, containerWidth / (videoWidth * MIN_VISIBLE_FRAME_WIDTH_FRACTION));
|
|
121
|
+
const width = videoWidth * scale;
|
|
122
|
+
const height = videoHeight * scale;
|
|
123
|
+
return {
|
|
124
|
+
width,
|
|
125
|
+
height,
|
|
126
|
+
left: (containerWidth - width) / 2,
|
|
127
|
+
top: (containerHeight - height) / 2,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Sizes the canvas drawing buffer to match its parent's box: the full screen
|
|
132
|
+
* on mobile and the video anchor box otherwise. The anchor can differ from the
|
|
133
|
+
* video element's box, which is centered in it and may be letterboxed or
|
|
134
|
+
* cropped on the sides.
|
|
135
|
+
*/
|
|
136
|
+
function resizeCanvasToDisplaySize(canvas) {
|
|
137
|
+
const { clientWidth, clientHeight } = canvas.parentElement ?? canvas;
|
|
138
|
+
canvas.width = Math.ceil(clientWidth);
|
|
139
|
+
canvas.height = Math.ceil(clientHeight);
|
|
140
|
+
}
|
|
94
141
|
/**
|
|
95
142
|
* Draws the provided liveness oval on the canvas.
|
|
96
143
|
*/
|
|
97
144
|
function drawLivenessOvalInCanvas({ canvas, oval, scaleFactor, videoEl, isStartScreen, }) {
|
|
98
145
|
const { flippedCenterX, centerY, width, height } = oval;
|
|
99
|
-
const { width: canvasWidth, height: canvasHeight } = canvas
|
|
146
|
+
const { width: canvasWidth, height: canvasHeight } = canvas;
|
|
100
147
|
const ctx = canvas.getContext('2d');
|
|
101
148
|
if (ctx) {
|
|
102
149
|
ctx.restore();
|
|
@@ -137,11 +184,7 @@ function drawLivenessOvalInCanvas({ canvas, oval, scaleFactor, videoEl, isStartS
|
|
|
137
184
|
}
|
|
138
185
|
function drawStaticOval(canvasEl, videoEl, videoMediaStream) {
|
|
139
186
|
const { width, height } = videoMediaStream.getTracks()[0].getSettings();
|
|
140
|
-
|
|
141
|
-
// and set canvas width/height.
|
|
142
|
-
const { width: videoScaledWidth, height: videoScaledHeight } = videoEl.getBoundingClientRect();
|
|
143
|
-
canvasEl.width = Math.ceil(videoScaledWidth);
|
|
144
|
-
canvasEl.height = Math.ceil(videoScaledHeight);
|
|
187
|
+
resizeCanvasToDisplaySize(canvasEl);
|
|
145
188
|
const ovalDetails = getStaticLivenessOvalDetails({
|
|
146
189
|
width: width,
|
|
147
190
|
height: height,
|
|
@@ -150,7 +193,7 @@ function drawStaticOval(canvasEl, videoEl, videoMediaStream) {
|
|
|
150
193
|
ovalDetails.flippedCenterX = width - ovalDetails.centerX;
|
|
151
194
|
// Compute scaleFactor which is how much our video element is scaled
|
|
152
195
|
// vs the intrinsic video resolution
|
|
153
|
-
const scaleFactor =
|
|
196
|
+
const scaleFactor = getVideoScaleFactor(videoEl);
|
|
154
197
|
// Draw oval in canvas using ovalDetails and scaleFactor
|
|
155
198
|
drawLivenessOvalInCanvas({
|
|
156
199
|
canvas: canvasEl,
|
|
@@ -262,17 +305,47 @@ function fillFractionalContext({ ctx, prevColor, nextColor, fraction, }) {
|
|
|
262
305
|
}
|
|
263
306
|
}
|
|
264
307
|
function fillOverlayCanvasFractional({ overlayCanvas, prevColor, nextColor, videoEl, ovalDetails, heightFraction, scaleFactor, }) {
|
|
265
|
-
|
|
308
|
+
// The overlay is `position: fixed`, but a transformed ancestor (common for
|
|
309
|
+
// modal dialogs) makes it relative to that ancestor instead of the viewport,
|
|
310
|
+
// so measure the canvas itself rather than assuming the window.
|
|
311
|
+
// getBoundingClientRect is in transformed (screen) space while clientWidth
|
|
312
|
+
// and scaleFactor are in layout space, so convert everything into the
|
|
313
|
+
// canvas's own layout space to also handle scale transforms.
|
|
314
|
+
const videoRect = videoEl.getBoundingClientRect();
|
|
315
|
+
const canvasRect = overlayCanvas.getBoundingClientRect();
|
|
316
|
+
const videoScreenScale = videoEl.clientWidth
|
|
317
|
+
? videoRect.width / videoEl.clientWidth
|
|
318
|
+
: 1;
|
|
319
|
+
const canvasScreenScale = overlayCanvas.clientWidth && canvasRect.width
|
|
320
|
+
? canvasRect.width / overlayCanvas.clientWidth
|
|
321
|
+
: 1;
|
|
322
|
+
// canvas pixels per camera frame pixel
|
|
323
|
+
const frameScale = (scaleFactor * videoScreenScale) / canvasScreenScale;
|
|
324
|
+
// offset of the (possibly letterboxed or cropped) video frame relative to
|
|
325
|
+
// the canvas, in canvas pixels
|
|
326
|
+
const frameX = (videoRect.x +
|
|
327
|
+
(videoRect.width - videoEl.videoWidth * scaleFactor * videoScreenScale) /
|
|
328
|
+
2 -
|
|
329
|
+
canvasRect.x) /
|
|
330
|
+
canvasScreenScale;
|
|
331
|
+
const frameY = (videoRect.y +
|
|
332
|
+
(videoRect.height -
|
|
333
|
+
videoEl.videoHeight * scaleFactor * videoScreenScale) /
|
|
334
|
+
2 -
|
|
335
|
+
canvasRect.y) /
|
|
336
|
+
canvasScreenScale;
|
|
266
337
|
const { flippedCenterX, centerY, width, height } = ovalDetails;
|
|
267
|
-
const updatedCenterX = flippedCenterX *
|
|
268
|
-
const updatedCenterY = centerY *
|
|
269
|
-
const canvasWidth = overlayCanvas.width;
|
|
270
|
-
const canvasHeight = overlayCanvas.height;
|
|
338
|
+
const updatedCenterX = flippedCenterX * frameScale + frameX;
|
|
339
|
+
const updatedCenterY = centerY * frameScale + frameY;
|
|
271
340
|
const ctx = overlayCanvas.getContext('2d');
|
|
341
|
+
// Because the canvas is set to 100% we need to manually set the height for the canvas to use pixel values
|
|
342
|
+
const canvasWidth = Math.ceil(overlayCanvas.clientWidth || canvasRect.width) ||
|
|
343
|
+
window.innerWidth;
|
|
344
|
+
const canvasHeight = Math.ceil(overlayCanvas.clientHeight || canvasRect.height) ||
|
|
345
|
+
window.innerHeight;
|
|
272
346
|
if (ctx) {
|
|
273
|
-
|
|
274
|
-
ctx.canvas.
|
|
275
|
-
ctx.canvas.height = window.innerHeight;
|
|
347
|
+
ctx.canvas.width = canvasWidth;
|
|
348
|
+
ctx.canvas.height = canvasHeight;
|
|
276
349
|
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
|
277
350
|
// fill the complete canvas
|
|
278
351
|
fillFractionalContext({
|
|
@@ -297,7 +370,7 @@ function fillOverlayCanvasFractional({ overlayCanvas, prevColor, nextColor, vide
|
|
|
297
370
|
});
|
|
298
371
|
// draw the oval path and fill it
|
|
299
372
|
ctx.beginPath();
|
|
300
|
-
ctx.ellipse(updatedCenterX, updatedCenterY, (width *
|
|
373
|
+
ctx.ellipse(updatedCenterX, updatedCenterY, (width * frameScale) / 2, (height * frameScale) / 2, 0, 0, 2 * Math.PI);
|
|
301
374
|
// add stroke to the oval path
|
|
302
375
|
ctx.strokeStyle = 'white';
|
|
303
376
|
ctx.lineWidth = 8;
|
|
@@ -402,4 +475,4 @@ async function isFaceDistanceBelowThreshold({ parsedSessionInformation, faceDete
|
|
|
402
475
|
return { isDistanceBelowThreshold, error };
|
|
403
476
|
}
|
|
404
477
|
|
|
405
|
-
export { clearOvalCanvas, drawLivenessOvalInCanvas, drawStaticOval, estimateIllumination, fillOverlayCanvasFractional, generateBboxFromLandmarks, getColorsSequencesFromSessionInformation, getFaceMatchState, getIntersectionOverUnion, getOvalBoundingBox, getOvalDetailsFromSessionInformation, getStaticLivenessOvalDetails, isCameraDeviceVirtual, isFaceDistanceBelowThreshold };
|
|
478
|
+
export { MIN_VISIBLE_FRAME_WIDTH_FRACTION, clearOvalCanvas, drawLivenessOvalInCanvas, drawStaticOval, estimateIllumination, fillOverlayCanvasFractional, generateBboxFromLandmarks, getColorsSequencesFromSessionInformation, getFaceMatchState, getIntersectionOverUnion, getOvalBoundingBox, getOvalDetailsFromSessionInformation, getStaticLivenessOvalDetails, getVideoFillLayout, getVideoScaleFactor, isCameraDeviceVirtual, isFaceDistanceBelowThreshold, resizeCanvasToDisplaySize };
|
|
@@ -7,7 +7,7 @@ import '../providers/FaceLivenessDetectorProvider.mjs';
|
|
|
7
7
|
import '@aws-amplify/ui';
|
|
8
8
|
import { LivenessClassNames } from '../types/classNames.mjs';
|
|
9
9
|
|
|
10
|
-
const CancelButton = ({ ariaLabel }) => {
|
|
10
|
+
const CancelButton = ({ ariaLabel, Component, }) => {
|
|
11
11
|
const [state, send] = useLivenessActor();
|
|
12
12
|
const isFinalState = state.done;
|
|
13
13
|
const handleClick = () => {
|
|
@@ -17,6 +17,9 @@ const CancelButton = ({ ariaLabel }) => {
|
|
|
17
17
|
};
|
|
18
18
|
if (isFinalState)
|
|
19
19
|
return null;
|
|
20
|
+
if (Component) {
|
|
21
|
+
return (React__default.createElement(Component, { onCancel: handleClick, cancelLivenessCheckText: ariaLabel }));
|
|
22
|
+
}
|
|
20
23
|
return (React__default.createElement(Button, { autoFocus: true, variation: "link", onClick: handleClick, size: "large", className: LivenessClassNames.CancelButton, "aria-label": ariaLabel },
|
|
21
24
|
React__default.createElement(IconClose, { "aria-hidden": "true", "data-testid": "close-icon" })));
|
|
22
25
|
};
|
|
@@ -30,9 +30,9 @@ const DefaultRecordingIcon = ({ recordingIndicatorText, }) => {
|
|
|
30
30
|
return (React__default.createElement(View, { className: LivenessClassNames.RecordingIconContainer },
|
|
31
31
|
React__default.createElement(RecordingIcon, null, recordingIndicatorText)));
|
|
32
32
|
};
|
|
33
|
-
const DefaultCancelButton = ({ cancelLivenessCheckText, }) => {
|
|
33
|
+
const DefaultCancelButton = ({ cancelLivenessCheckText, CancelButton: CancelButton$1, }) => {
|
|
34
34
|
return (React__default.createElement(View, { className: LivenessClassNames.CancelContainer },
|
|
35
|
-
React__default.createElement(CancelButton, { ariaLabel: cancelLivenessCheckText })));
|
|
35
|
+
React__default.createElement(CancelButton, { ariaLabel: cancelLivenessCheckText, Component: CancelButton$1 })));
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
export { DefaultCancelButton, DefaultPhotosensitiveWarning, DefaultRecordingIcon };
|
package/dist/esm/version.mjs
CHANGED