@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.
Files changed (20) hide show
  1. package/dist/esm/components/FaceLivenessDetector/FaceLivenessDetectorCore.mjs +18 -1
  2. package/dist/esm/components/FaceLivenessDetector/LivenessCheck/LivenessCameraModule.mjs +96 -6
  3. package/dist/esm/components/FaceLivenessDetector/LivenessCheck/LivenessCheck.mjs +2 -2
  4. package/dist/esm/components/FaceLivenessDetector/service/machine/machine.mjs +45 -22
  5. package/dist/esm/components/FaceLivenessDetector/service/utils/closeLivenessStream.mjs +28 -0
  6. package/dist/esm/components/FaceLivenessDetector/service/utils/liveness.mjs +90 -17
  7. package/dist/esm/components/FaceLivenessDetector/shared/CancelButton.mjs +4 -1
  8. package/dist/esm/components/FaceLivenessDetector/shared/DefaultStartScreenComponents.mjs +2 -2
  9. package/dist/esm/version.mjs +1 -1
  10. package/dist/index.js +278 -47
  11. package/dist/styles.css +22 -0
  12. package/dist/types/components/FaceLivenessDetector/LivenessCheck/LivenessCameraModule.d.ts +1 -0
  13. package/dist/types/components/FaceLivenessDetector/service/types/machine.d.ts +1 -1
  14. package/dist/types/components/FaceLivenessDetector/service/utils/closeLivenessStream.d.ts +9 -0
  15. package/dist/types/components/FaceLivenessDetector/service/utils/index.d.ts +1 -0
  16. package/dist/types/components/FaceLivenessDetector/service/utils/liveness.d.ts +30 -0
  17. package/dist/types/components/FaceLivenessDetector/shared/CancelButton.d.ts +15 -0
  18. package/dist/types/components/FaceLivenessDetector/shared/DefaultStartScreenComponents.d.ts +11 -2
  19. package/dist/types/version.d.ts +1 -1
  20. 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 && photoSensitivityWarning,
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 && !isWaitingForCamera && !isCheckSucceeded && (React__default.createElement(DefaultCancelButton, { cancelLivenessCheckText: cancelLivenessCheckText })),
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?.stopRecording().then(() => {
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, isMobile } = context.videoAssociatedParams;
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
- // Get width/height of video element so we can compute scaleFactor
1002
- // and set canvas width/height.
1003
- const { width: videoScaledWidth, height: videoScaledHeight } = videoEl.getBoundingClientRect();
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 = videoScaledWidth / videoEl.videoWidth;
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, scaleFactor } = ovalAssociatedParams;
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
- scaleFactor: scaleFactor,
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.getBoundingClientRect();
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
- // Get width/height of video element so we can compute scaleFactor
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 = videoScaledWidth / videoEl.videoWidth;
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
- const { x: videoX, y: videoY } = videoEl.getBoundingClientRect();
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 * scaleFactor + videoX;
268
- const updatedCenterY = centerY * scaleFactor + videoY;
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
- // Because the canvas is set to to 100% we need to manually set the height for the canvas to use pixel values
274
- ctx.canvas.width = window.innerWidth;
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 * scaleFactor) / 2, (height * scaleFactor) / 2, 0, 0, 2 * Math.PI);
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 };
@@ -1,3 +1,3 @@
1
- const VERSION = '3.6.9';
1
+ const VERSION = '3.7.0';
2
2
 
3
3
  export { VERSION };