@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
package/dist/index.js CHANGED
@@ -256,12 +256,59 @@ function getStaticLivenessOvalDetails({ width, height, widthSeed = 1.0, centerXS
256
256
  height: Math.floor(ovalHeight),
257
257
  };
258
258
  }
259
+ /**
260
+ * Returns how much the intrinsic video frame is scaled inside the rendered
261
+ * video element. The video element uses the default `object-fit: contain`,
262
+ * so when the host container gives it a box that does not match the camera
263
+ * aspect ratio the frame is letterboxed and the smaller ratio applies.
264
+ */
265
+ function getVideoScaleFactor(videoEl) {
266
+ // layout size, unaffected by CSS transforms such as a modal zoom animation
267
+ const { clientWidth: width, clientHeight: height } = videoEl;
268
+ const { videoWidth, videoHeight } = videoEl;
269
+ if (!videoWidth || !videoHeight) {
270
+ return 1;
271
+ }
272
+ return Math.min(width / videoWidth, height / videoHeight);
273
+ }
274
+ // When filling a container taller than the camera frame, the frame is scaled
275
+ // up and cropped on the sides. Keep at least this fraction of the frame width
276
+ // visible so the liveness oval (centered, ~45% of the frame width) is never
277
+ // cropped. The frame is never cropped vertically since the oval spans nearly
278
+ // the full frame height.
279
+ const MIN_VISIBLE_FRAME_WIDTH_FRACTION = 0.7;
280
+ /**
281
+ * Returns the size and position of the video element so the camera frame
282
+ * fills as much of its container as possible without cropping the oval.
283
+ */
284
+ function getVideoFillLayout({ containerWidth, containerHeight, videoWidth, videoHeight, }) {
285
+ const scale = Math.min(containerHeight / videoHeight, containerWidth / (videoWidth * MIN_VISIBLE_FRAME_WIDTH_FRACTION));
286
+ const width = videoWidth * scale;
287
+ const height = videoHeight * scale;
288
+ return {
289
+ width,
290
+ height,
291
+ left: (containerWidth - width) / 2,
292
+ top: (containerHeight - height) / 2,
293
+ };
294
+ }
295
+ /**
296
+ * Sizes the canvas drawing buffer to match its parent's box: the full screen
297
+ * on mobile and the video anchor box otherwise. The anchor can differ from the
298
+ * video element's box, which is centered in it and may be letterboxed or
299
+ * cropped on the sides.
300
+ */
301
+ function resizeCanvasToDisplaySize(canvas) {
302
+ const { clientWidth, clientHeight } = canvas.parentElement ?? canvas;
303
+ canvas.width = Math.ceil(clientWidth);
304
+ canvas.height = Math.ceil(clientHeight);
305
+ }
259
306
  /**
260
307
  * Draws the provided liveness oval on the canvas.
261
308
  */
262
309
  function drawLivenessOvalInCanvas({ canvas, oval, scaleFactor, videoEl, isStartScreen, }) {
263
310
  const { flippedCenterX, centerY, width, height } = oval;
264
- const { width: canvasWidth, height: canvasHeight } = canvas.getBoundingClientRect();
311
+ const { width: canvasWidth, height: canvasHeight } = canvas;
265
312
  const ctx = canvas.getContext('2d');
266
313
  if (ctx) {
267
314
  ctx.restore();
@@ -302,11 +349,7 @@ function drawLivenessOvalInCanvas({ canvas, oval, scaleFactor, videoEl, isStartS
302
349
  }
303
350
  function drawStaticOval(canvasEl, videoEl, videoMediaStream) {
304
351
  const { width, height } = videoMediaStream.getTracks()[0].getSettings();
305
- // Get width/height of video element so we can compute scaleFactor
306
- // and set canvas width/height.
307
- const { width: videoScaledWidth, height: videoScaledHeight } = videoEl.getBoundingClientRect();
308
- canvasEl.width = Math.ceil(videoScaledWidth);
309
- canvasEl.height = Math.ceil(videoScaledHeight);
352
+ resizeCanvasToDisplaySize(canvasEl);
310
353
  const ovalDetails = getStaticLivenessOvalDetails({
311
354
  width: width,
312
355
  height: height,
@@ -315,7 +358,7 @@ function drawStaticOval(canvasEl, videoEl, videoMediaStream) {
315
358
  ovalDetails.flippedCenterX = width - ovalDetails.centerX;
316
359
  // Compute scaleFactor which is how much our video element is scaled
317
360
  // vs the intrinsic video resolution
318
- const scaleFactor = videoScaledWidth / videoEl.videoWidth;
361
+ const scaleFactor = getVideoScaleFactor(videoEl);
319
362
  // Draw oval in canvas using ovalDetails and scaleFactor
320
363
  drawLivenessOvalInCanvas({
321
364
  canvas: canvasEl,
@@ -427,17 +470,47 @@ function fillFractionalContext({ ctx, prevColor, nextColor, fraction, }) {
427
470
  }
428
471
  }
429
472
  function fillOverlayCanvasFractional({ overlayCanvas, prevColor, nextColor, videoEl, ovalDetails, heightFraction, scaleFactor, }) {
430
- const { x: videoX, y: videoY } = videoEl.getBoundingClientRect();
473
+ // The overlay is `position: fixed`, but a transformed ancestor (common for
474
+ // modal dialogs) makes it relative to that ancestor instead of the viewport,
475
+ // so measure the canvas itself rather than assuming the window.
476
+ // getBoundingClientRect is in transformed (screen) space while clientWidth
477
+ // and scaleFactor are in layout space, so convert everything into the
478
+ // canvas's own layout space to also handle scale transforms.
479
+ const videoRect = videoEl.getBoundingClientRect();
480
+ const canvasRect = overlayCanvas.getBoundingClientRect();
481
+ const videoScreenScale = videoEl.clientWidth
482
+ ? videoRect.width / videoEl.clientWidth
483
+ : 1;
484
+ const canvasScreenScale = overlayCanvas.clientWidth && canvasRect.width
485
+ ? canvasRect.width / overlayCanvas.clientWidth
486
+ : 1;
487
+ // canvas pixels per camera frame pixel
488
+ const frameScale = (scaleFactor * videoScreenScale) / canvasScreenScale;
489
+ // offset of the (possibly letterboxed or cropped) video frame relative to
490
+ // the canvas, in canvas pixels
491
+ const frameX = (videoRect.x +
492
+ (videoRect.width - videoEl.videoWidth * scaleFactor * videoScreenScale) /
493
+ 2 -
494
+ canvasRect.x) /
495
+ canvasScreenScale;
496
+ const frameY = (videoRect.y +
497
+ (videoRect.height -
498
+ videoEl.videoHeight * scaleFactor * videoScreenScale) /
499
+ 2 -
500
+ canvasRect.y) /
501
+ canvasScreenScale;
431
502
  const { flippedCenterX, centerY, width, height } = ovalDetails;
432
- const updatedCenterX = flippedCenterX * scaleFactor + videoX;
433
- const updatedCenterY = centerY * scaleFactor + videoY;
434
- const canvasWidth = overlayCanvas.width;
435
- const canvasHeight = overlayCanvas.height;
503
+ const updatedCenterX = flippedCenterX * frameScale + frameX;
504
+ const updatedCenterY = centerY * frameScale + frameY;
436
505
  const ctx = overlayCanvas.getContext('2d');
506
+ // Because the canvas is set to 100% we need to manually set the height for the canvas to use pixel values
507
+ const canvasWidth = Math.ceil(overlayCanvas.clientWidth || canvasRect.width) ||
508
+ window.innerWidth;
509
+ const canvasHeight = Math.ceil(overlayCanvas.clientHeight || canvasRect.height) ||
510
+ window.innerHeight;
437
511
  if (ctx) {
438
- // Because the canvas is set to to 100% we need to manually set the height for the canvas to use pixel values
439
- ctx.canvas.width = window.innerWidth;
440
- ctx.canvas.height = window.innerHeight;
512
+ ctx.canvas.width = canvasWidth;
513
+ ctx.canvas.height = canvasHeight;
441
514
  ctx.clearRect(0, 0, canvasWidth, canvasHeight);
442
515
  // fill the complete canvas
443
516
  fillFractionalContext({
@@ -462,7 +535,7 @@ function fillOverlayCanvasFractional({ overlayCanvas, prevColor, nextColor, vide
462
535
  });
463
536
  // draw the oval path and fill it
464
537
  ctx.beginPath();
465
- ctx.ellipse(updatedCenterX, updatedCenterY, (width * scaleFactor) / 2, (height * scaleFactor) / 2, 0, 0, 2 * Math.PI);
538
+ ctx.ellipse(updatedCenterX, updatedCenterY, (width * frameScale) / 2, (height * frameScale) / 2, 0, 0, 2 * Math.PI);
466
539
  // add stroke to the oval path
467
540
  ctx.strokeStyle = 'white';
468
541
  ctx.lineWidth = 8;
@@ -1251,7 +1324,7 @@ function createRequestStreamGenerator(stream) {
1251
1324
  };
1252
1325
  }
1253
1326
 
1254
- const VERSION = '3.6.9';
1327
+ const VERSION = '3.7.0';
1255
1328
 
1256
1329
  const BASE_USER_AGENT = `ui-react-liveness/${VERSION}`;
1257
1330
  const getLivenessUserAgent = () => {
@@ -1812,6 +1885,33 @@ _StreamRecorder_chunks = new WeakMap(), _StreamRecorder_recorder = new WeakMap()
1812
1885
  tslib.__classPrivateFieldSet(this, _StreamRecorder_eventListeners, {}, "f");
1813
1886
  };
1814
1887
 
1888
+ /**
1889
+ * Stops recording and sends `closeCode` to the streaming service so the
1890
+ * session is closed with a reason instead of being left to time out.
1891
+ *
1892
+ * Only call this once recording has started: `stopRecording()` resolves when
1893
+ * the recorder stops, so if it never started the close code is not sent.
1894
+ */
1895
+ function closeLivenessStream(livenessStreamProvider, closeCode) {
1896
+ livenessStreamProvider
1897
+ ?.stopRecording()
1898
+ .catch((error) => {
1899
+ // eslint-disable-next-line no-console
1900
+ console.error('Error stopping liveness recording:', error);
1901
+ })
1902
+ // send the close code even if stopping failed so the session still ends
1903
+ .then(() => {
1904
+ livenessStreamProvider.dispatchStreamEvent({
1905
+ type: 'closeCode',
1906
+ data: { closeCode },
1907
+ });
1908
+ })
1909
+ .catch((error) => {
1910
+ // eslint-disable-next-line no-console
1911
+ console.error('Error sending liveness close code:', error);
1912
+ });
1913
+ }
1914
+
1815
1915
  const isServerSessionInformationEvent = (value) => {
1816
1916
  return !!value
1817
1917
  ?.ServerSessionInformationEvent;
@@ -2121,6 +2221,12 @@ const livenessMachine = xstate.createMachine({
2121
2221
  'sendTimeoutAfterOvalDrawingDelay',
2122
2222
  ],
2123
2223
  initial: 'ovalDrawing',
2224
+ on: {
2225
+ VIDEO_RESIZED: {
2226
+ internal: true,
2227
+ actions: ['updateOvalScaleFactor', 'redrawLivenessOval'],
2228
+ },
2229
+ },
2124
2230
  states: {
2125
2231
  ovalDrawing: {
2126
2232
  invoke: {
@@ -2393,6 +2499,34 @@ const livenessMachine = xstate.createMachine({
2393
2499
  isFaceFarEnoughBeforeRecording: (_, event) => !!event.data.isFaceFarEnoughBeforeRecording,
2394
2500
  errorState: (_, event) => event.data?.error,
2395
2501
  }),
2502
+ // The oval is drawn once per check; keep it aligned when the host
2503
+ // container resizes after it has been drawn (e.g. modal open animation).
2504
+ updateOvalScaleFactor: xstate.assign({
2505
+ ovalAssociatedParams: (context) => {
2506
+ const { videoEl } = context.videoAssociatedParams ?? {};
2507
+ if (!context.ovalAssociatedParams?.ovalDetails || !videoEl) {
2508
+ return context.ovalAssociatedParams;
2509
+ }
2510
+ return {
2511
+ ...context.ovalAssociatedParams,
2512
+ scaleFactor: getVideoScaleFactor(videoEl),
2513
+ };
2514
+ },
2515
+ }),
2516
+ redrawLivenessOval: (context) => {
2517
+ const { videoEl, canvasEl } = context.videoAssociatedParams ?? {};
2518
+ const { ovalDetails, scaleFactor } = context.ovalAssociatedParams ?? {};
2519
+ if (!videoEl || !canvasEl || !ovalDetails || !scaleFactor) {
2520
+ return;
2521
+ }
2522
+ resizeCanvasToDisplaySize(canvasEl);
2523
+ drawLivenessOvalInCanvas({
2524
+ canvas: canvasEl,
2525
+ oval: ovalDetails,
2526
+ scaleFactor,
2527
+ videoEl,
2528
+ });
2529
+ },
2396
2530
  updateOvalAndFaceDetailsPostDraw: xstate.assign({
2397
2531
  ovalAssociatedParams: (context, event) => ({
2398
2532
  ...context.ovalAssociatedParams,
@@ -2572,12 +2706,7 @@ const livenessMachine = xstate.createMachine({
2572
2706
  else if (context.errorState === undefined) {
2573
2707
  closeCode = WS_CLOSURE_CODE.USER_CANCEL;
2574
2708
  }
2575
- context.livenessStreamProvider?.stopRecording().then(() => {
2576
- context.livenessStreamProvider?.dispatchStreamEvent({
2577
- type: 'closeCode',
2578
- data: { closeCode },
2579
- });
2580
- });
2709
+ closeLivenessStream(context.livenessStreamProvider, closeCode);
2581
2710
  },
2582
2711
  freezeStream: (context) => {
2583
2712
  const { videoMediaStream, videoEl } = context.videoAssociatedParams;
@@ -2809,7 +2938,7 @@ const livenessMachine = xstate.createMachine({
2809
2938
  },
2810
2939
  async detectInitialFaceAndDrawOval(context) {
2811
2940
  const { parsedSessionInformation } = context;
2812
- const { videoEl, canvasEl, isMobile } = context.videoAssociatedParams;
2941
+ const { videoEl, canvasEl } = context.videoAssociatedParams;
2813
2942
  const { faceDetector } = context.ovalAssociatedParams;
2814
2943
  // initialize models
2815
2944
  await faceDetector.modelLoadingPromise;
@@ -2840,20 +2969,12 @@ const livenessMachine = xstate.createMachine({
2840
2969
  if (!initialFace) {
2841
2970
  return { faceMatchState, illuminationState };
2842
2971
  }
2843
- // Get width/height of video element so we can compute scaleFactor
2844
- // and set canvas width/height.
2845
- const { width: videoScaledWidth, height: videoScaledHeight } = videoEl.getBoundingClientRect();
2846
- if (isMobile) {
2847
- canvasEl.width = window.innerWidth;
2848
- canvasEl.height = window.innerHeight;
2849
- }
2850
- else {
2851
- canvasEl.width = videoScaledWidth;
2852
- canvasEl.height = videoScaledHeight;
2853
- }
2972
+ // Size the canvas to its container: the full screen on mobile and the
2973
+ // video element otherwise.
2974
+ resizeCanvasToDisplaySize(canvasEl);
2854
2975
  // Compute scaleFactor which is how much our video element is scaled
2855
2976
  // vs the intrinsic video resolution
2856
- const scaleFactor = videoScaledWidth / videoEl.videoWidth;
2977
+ const scaleFactor = getVideoScaleFactor(videoEl);
2857
2978
  // generate oval details from initialFace and video dimensions
2858
2979
  const ovalDetails = getOvalDetailsFromSessionInformation({
2859
2980
  parsedSessionInformation: parsedSessionInformation,
@@ -2938,7 +3059,7 @@ const livenessMachine = xstate.createMachine({
2938
3059
  if (freshnessColorsComplete) {
2939
3060
  return;
2940
3061
  }
2941
- const { ovalDetails, scaleFactor } = ovalAssociatedParams;
3062
+ const { ovalDetails } = ovalAssociatedParams;
2942
3063
  const { videoEl } = videoAssociatedParams;
2943
3064
  const completed = await colorSequenceDisplay.startSequences({
2944
3065
  onSequenceColorChange: ({ sequenceColor, prevSequenceColor, heightFraction, }) => {
@@ -2948,7 +3069,8 @@ const livenessMachine = xstate.createMachine({
2948
3069
  ovalDetails: ovalDetails,
2949
3070
  nextColor: sequenceColor,
2950
3071
  prevColor: prevSequenceColor,
2951
- scaleFactor: scaleFactor,
3072
+ // read per frame so a resize during the sequence stays aligned
3073
+ scaleFactor: getVideoScaleFactor(videoEl),
2952
3074
  videoEl: videoEl,
2953
3075
  });
2954
3076
  },
@@ -3123,7 +3245,7 @@ var LivenessClassNames;
3123
3245
  LivenessClassNames["VideoAnchor"] = "amplify-liveness-video-anchor";
3124
3246
  })(LivenessClassNames || (LivenessClassNames = {}));
3125
3247
 
3126
- const CancelButton = ({ ariaLabel }) => {
3248
+ const CancelButton = ({ ariaLabel, Component, }) => {
3127
3249
  const [state, send] = useLivenessActor();
3128
3250
  const isFinalState = state.done;
3129
3251
  const handleClick = () => {
@@ -3133,6 +3255,9 @@ const CancelButton = ({ ariaLabel }) => {
3133
3255
  };
3134
3256
  if (isFinalState)
3135
3257
  return null;
3258
+ if (Component) {
3259
+ return (React__namespace["default"].createElement(Component, { onCancel: handleClick, cancelLivenessCheckText: ariaLabel }));
3260
+ }
3136
3261
  return (React__namespace["default"].createElement(uiReact.Button, { autoFocus: true, variation: "link", onClick: handleClick, size: "large", className: LivenessClassNames.CancelButton, "aria-label": ariaLabel },
3137
3262
  React__namespace["default"].createElement(internal.IconClose, { "aria-hidden": "true", "data-testid": "close-icon" })));
3138
3263
  };
@@ -3492,9 +3617,9 @@ const DefaultRecordingIcon = ({ recordingIndicatorText, }) => {
3492
3617
  return (React__namespace["default"].createElement(uiReact.View, { className: LivenessClassNames.RecordingIconContainer },
3493
3618
  React__namespace["default"].createElement(RecordingIcon, null, recordingIndicatorText)));
3494
3619
  };
3495
- const DefaultCancelButton = ({ cancelLivenessCheckText, }) => {
3620
+ const DefaultCancelButton = ({ cancelLivenessCheckText, CancelButton: CancelButton$1, }) => {
3496
3621
  return (React__namespace["default"].createElement(uiReact.View, { className: LivenessClassNames.CancelContainer },
3497
- React__namespace["default"].createElement(CancelButton, { ariaLabel: cancelLivenessCheckText })));
3622
+ React__namespace["default"].createElement(CancelButton, { ariaLabel: cancelLivenessCheckText, Component: CancelButton$1 })));
3498
3623
  };
3499
3624
 
3500
3625
  const CameraSelector = (props) => {
@@ -3511,7 +3636,11 @@ const selectVideoStream = createLivenessSelector((state) => state.context.videoA
3511
3636
  const selectFaceMatchPercentage = createLivenessSelector((state) => state.context.faceMatchAssociatedParams?.faceMatchPercentage);
3512
3637
  const selectFaceMatchState = createLivenessSelector((state) => state.context.faceMatchAssociatedParams?.faceMatchState);
3513
3638
  const selectSelectedDeviceId = createLivenessSelector((state) => state.context.videoAssociatedParams?.selectedDeviceId);
3639
+ const selectDisableStartScreen = createLivenessSelector((state) => state.context.componentProps?.disableStartScreen);
3514
3640
  const selectSelectableDevices = createLivenessSelector((state) => state.context.videoAssociatedParams?.selectableDevices);
3641
+ // how long the video size must be stable during recording before the session
3642
+ // oval is redrawn
3643
+ const VIDEO_RESIZED_DEBOUNCE_MS = 100;
3515
3644
  const showMatchIndicatorStates = [
3516
3645
  FaceMatchState.TOO_FAR,
3517
3646
  FaceMatchState.CANT_IDENTIFY,
@@ -3527,7 +3656,7 @@ const MemoizedMatchIndicator = React__namespace["default"].memo(MatchIndicator);
3527
3656
  const LivenessCameraModule = (props) => {
3528
3657
  const { isMobileScreen, isRecordingStopped, instructionDisplayText, streamDisplayText, hintDisplayText, errorDisplayText, cameraDisplayText, components: customComponents, testId, } = props;
3529
3658
  const { cancelLivenessCheckText, recordingIndicatorText } = streamDisplayText;
3530
- const { ErrorView = FaceLivenessErrorModal, PhotosensitiveWarning = DefaultPhotosensitiveWarning, } = customComponents ?? {};
3659
+ const { ErrorView = FaceLivenessErrorModal, PhotosensitiveWarning = DefaultPhotosensitiveWarning, CancelButton, } = customComponents ?? {};
3531
3660
  const [state, send] = useLivenessActor();
3532
3661
  const isFaceMovementChallenge = useLivenessSelector(selectChallengeType) === FACE_MOVEMENT_CHALLENGE.type;
3533
3662
  const videoStream = useLivenessSelector(selectVideoStream);
@@ -3537,9 +3666,11 @@ const LivenessCameraModule = (props) => {
3537
3666
  const faceMatchPercentage = useLivenessSelector(selectFaceMatchPercentage);
3538
3667
  const faceMatchState = useLivenessSelector(selectFaceMatchState);
3539
3668
  const errorState = useLivenessSelector(selectErrorState);
3669
+ const disableStartScreen = useLivenessSelector(selectDisableStartScreen);
3540
3670
  const colorMode = internal.useColorMode();
3541
3671
  const { videoRef, videoWidth, videoHeight } = useMediaStreamInVideo(videoStream);
3542
3672
  const canvasRef = React.useRef(null);
3673
+ const videoAnchorRef = React.useRef(null);
3543
3674
  const freshnessColorRef = React.useRef(null);
3544
3675
  const [isCameraReady, setIsCameraReady] = React.useState(false);
3545
3676
  const [isMetadataLoaded, setIsMetadataLoaded] = React.useState(false);
@@ -3598,6 +3729,84 @@ const LivenessCameraModule = (props) => {
3598
3729
  lightModePreference.addEventListener('change', updateColorModeHandler);
3599
3730
  };
3600
3731
  }, [videoRef, videoStream, colorMode, isStartView, isMetadataLoaded]);
3732
+ // Size the video to fill the anchor, which grows to fill the host container
3733
+ // when the host gives it a height (e.g. a modal body)
3734
+ const layoutVideo = React__namespace["default"].useCallback(() => {
3735
+ const anchor = videoAnchorRef.current;
3736
+ const video = videoRef.current;
3737
+ if (!anchor || !video?.videoWidth || !video.videoHeight) {
3738
+ return;
3739
+ }
3740
+ const { width, height, left, top } = getVideoFillLayout({
3741
+ containerWidth: anchor.clientWidth,
3742
+ containerHeight: anchor.clientHeight,
3743
+ videoWidth: video.videoWidth,
3744
+ videoHeight: video.videoHeight,
3745
+ });
3746
+ video.style.width = `${width}px`;
3747
+ video.style.height = `${height}px`;
3748
+ video.style.left = `${left}px`;
3749
+ video.style.top = `${top}px`;
3750
+ }, [videoRef]);
3751
+ // Keep the oval aligned with the video when the host container resizes,
3752
+ // e.g. a modal open animation or a responsive layout change. Kept in a ref
3753
+ // so the observer below always calls the latest version.
3754
+ const redrawOnResizeRef = React__namespace["default"].useRef();
3755
+ const recordingResizeTimeoutRef = React__namespace["default"].useRef(null);
3756
+ React__namespace["default"].useLayoutEffect(() => {
3757
+ redrawOnResizeRef.current = () => {
3758
+ layoutVideo();
3759
+ if (isStartView && isMetadataLoaded && canvasRef.current && videoStream) {
3760
+ drawStaticOval(canvasRef.current, videoRef.current, videoStream);
3761
+ }
3762
+ else if (isRecording) {
3763
+ // the video is laid out every frame above; only redraw the session
3764
+ // oval once the size settles to avoid a machine transition per frame
3765
+ if (recordingResizeTimeoutRef.current) {
3766
+ clearTimeout(recordingResizeTimeoutRef.current);
3767
+ }
3768
+ recordingResizeTimeoutRef.current = setTimeout(() => {
3769
+ recordingResizeTimeoutRef.current = null;
3770
+ send({ type: 'VIDEO_RESIZED' });
3771
+ }, VIDEO_RESIZED_DEBOUNCE_MS);
3772
+ }
3773
+ };
3774
+ });
3775
+ React__namespace["default"].useEffect(() => {
3776
+ const anchor = videoAnchorRef.current;
3777
+ if (!anchor || typeof ResizeObserver === 'undefined') {
3778
+ return;
3779
+ }
3780
+ let lastWidth;
3781
+ let lastHeight;
3782
+ let frameId;
3783
+ const resizeObserver = new ResizeObserver(() => {
3784
+ const { clientWidth, clientHeight } = anchor;
3785
+ if (clientWidth === lastWidth && clientHeight === lastHeight) {
3786
+ return;
3787
+ }
3788
+ lastWidth = clientWidth;
3789
+ lastHeight = clientHeight;
3790
+ // coalesce into a single redraw per frame
3791
+ frameId ?? (frameId = requestAnimationFrame(() => {
3792
+ frameId = undefined;
3793
+ redrawOnResizeRef.current?.();
3794
+ }));
3795
+ });
3796
+ resizeObserver.observe(anchor);
3797
+ return () => {
3798
+ resizeObserver.disconnect();
3799
+ if (frameId !== undefined) {
3800
+ cancelAnimationFrame(frameId);
3801
+ }
3802
+ if (recordingResizeTimeoutRef.current) {
3803
+ clearTimeout(recordingResizeTimeoutRef.current);
3804
+ }
3805
+ };
3806
+ // The camera permission check returns early (below) without rendering the
3807
+ // video anchor, so the ref is only set once `isCheckingCamera` is false;
3808
+ // re-run then to start observing the newly mounted anchor.
3809
+ }, [isCheckingCamera]);
3601
3810
  React__namespace["default"].useLayoutEffect(() => {
3602
3811
  if (isCameraReady) {
3603
3812
  send({
@@ -3629,6 +3838,7 @@ const LivenessCameraModule = (props) => {
3629
3838
  setIsCameraReady(true);
3630
3839
  };
3631
3840
  const handleLoadedMetadata = () => {
3841
+ layoutVideo();
3632
3842
  setIsMetadataLoaded(true);
3633
3843
  };
3634
3844
  const beginLivenessCheck = React__namespace["default"].useCallback(() => {
@@ -3663,7 +3873,9 @@ const LivenessCameraModule = (props) => {
3663
3873
  // We don't show full screen camera on the pre check screen (isStartView/isWaitingForCamera)
3664
3874
  const shouldShowFullScreenCamera = isMobileScreen && !isStartView && !shouldShowCenteredLoader;
3665
3875
  return (React__namespace["default"].createElement(React__namespace["default"].Fragment, null,
3666
- !isFaceMovementChallenge && photoSensitivityWarning,
3876
+ !isFaceMovementChallenge &&
3877
+ !disableStartScreen &&
3878
+ photoSensitivityWarning,
3667
3879
  shouldShowCenteredLoader && (React__namespace["default"].createElement(uiReact.Flex, { className: LivenessClassNames.ConnectingLoader },
3668
3880
  React__namespace["default"].createElement(uiReact.Loader, { size: "large", className: LivenessClassNames.Loader, "data-testid": "centered-loader" }),
3669
3881
  React__namespace["default"].createElement(uiReact.Text, { className: LivenessClassNames.LandscapeErrorModalHeader }, hintDisplayText.hintConnectingText))),
@@ -3671,7 +3883,10 @@ const LivenessCameraModule = (props) => {
3671
3883
  `${LivenessClassNames.CameraModule}--mobile`), "data-testid": testId, gap: "zero" },
3672
3884
  React__namespace["default"].createElement(Overlay, { horizontal: "center", vertical: isRecording && !isFlashingFreshness ? 'start' : 'space-between', className: LivenessClassNames.InstructionOverlay },
3673
3885
  isRecording && (React__namespace["default"].createElement(DefaultRecordingIcon, { recordingIndicatorText: recordingIndicatorText })),
3674
- !isStartView && !isWaitingForCamera && !isCheckSucceeded && (React__namespace["default"].createElement(DefaultCancelButton, { cancelLivenessCheckText: cancelLivenessCheckText })),
3886
+ !isStartView &&
3887
+ !isWaitingForCamera &&
3888
+ !isCheckSucceeded &&
3889
+ CancelButton !== null && (React__namespace["default"].createElement(DefaultCancelButton, { cancelLivenessCheckText: cancelLivenessCheckText, CancelButton: CancelButton })),
3675
3890
  React__namespace["default"].createElement(uiReact.Flex, { className: ui.classNames(LivenessClassNames.Hint, shouldShowFullScreenCamera && `${LivenessClassNames.Hint}--mobile`) },
3676
3891
  React__namespace["default"].createElement(Hint, { hintDisplayText: hintDisplayText })),
3677
3892
  errorState && (React__namespace["default"].createElement(ErrorView, { onRetry: () => {
@@ -3684,7 +3899,7 @@ const LivenessCameraModule = (props) => {
3684
3899
  !isFlashingFreshness &&
3685
3900
  showMatchIndicatorStates.includes(faceMatchState) ? (React__namespace["default"].createElement(MemoizedMatchIndicator, { percentage: Math.ceil(faceMatchPercentage) })) : null),
3686
3901
  React__namespace["default"].createElement(uiReact.View, { as: "canvas", ref: freshnessColorRef, className: LivenessClassNames.FreshnessCanvas, hidden: true }),
3687
- React__namespace["default"].createElement(uiReact.View, { className: LivenessClassNames.VideoAnchor, style: {
3902
+ React__namespace["default"].createElement(uiReact.View, { ref: videoAnchorRef, className: LivenessClassNames.VideoAnchor, style: {
3688
3903
  aspectRatio: `${aspectRatio}`,
3689
3904
  } },
3690
3905
  React__namespace["default"].createElement("video", { ref: videoRef, muted: true, autoPlay: true, playsInline: true, width: mediaWidth, height: mediaHeight, onCanPlay: handleMediaPlay, onLoadedMetadata: handleLoadedMetadata, "data-testid": "video", className: ui.classNames(LivenessClassNames.Video, isCameraUserFacing && LivenessClassNames.UserFacingVideo, isRecordingStopped && LivenessClassNames.FadeOut), "aria-label": cameraDisplayText.a11yVideoLabelText }),
@@ -3778,8 +3993,8 @@ const LivenessCheck = ({ instructionDisplayText, hintDisplayText, cameraDisplayT
3778
3993
  ? cameraMinSpecificationsMessageText
3779
3994
  : cameraNotFoundMessageText),
3780
3995
  React__namespace.createElement(uiReact.Button, { variation: "primary", type: "button", onClick: recheckCameraPermissions }, retryCameraPermissionsText),
3781
- React__namespace.createElement(uiReact.View, { position: "absolute", top: "medium", right: "medium" },
3782
- React__namespace.createElement(CancelButton, { ariaLabel: cancelLivenessCheckText }))));
3996
+ components?.CancelButton !== null && (React__namespace.createElement(uiReact.View, { position: "absolute", top: "medium", right: "medium" },
3997
+ React__namespace.createElement(CancelButton, { ariaLabel: cancelLivenessCheckText, Component: components?.CancelButton })))));
3783
3998
  }
3784
3999
  else {
3785
4000
  return (React__namespace.createElement(LivenessCameraModule, { isMobileScreen: isMobile, isRecordingStopped: isRecordingStopped, instructionDisplayText: instructionDisplayText, streamDisplayText: streamDisplayText, hintDisplayText: hintDisplayText, errorDisplayText: errorDisplayText, cameraDisplayText: cameraDisplayText, components: components }));
@@ -3914,6 +4129,22 @@ function FaceLivenessDetectorCore(props) {
3914
4129
  },
3915
4130
  },
3916
4131
  });
4132
+ React__namespace.useEffect(() => {
4133
+ // Track the stream provider as the state changes: by the time this
4134
+ // effect is cleaned up, the service has been stopped and reset.
4135
+ let livenessStreamProvider;
4136
+ const { unsubscribe } = service.subscribe(({ context }) => {
4137
+ ({ livenessStreamProvider } = context);
4138
+ });
4139
+ return () => {
4140
+ unsubscribe();
4141
+ // Unmounting mid-check (e.g. the host modal is closed) bypasses the
4142
+ // CANCEL event, so close the stream the same way the cancel button does
4143
+ if (livenessStreamProvider?.isRecording()) {
4144
+ closeLivenessStream(livenessStreamProvider, WS_CLOSURE_CODE.USER_CANCEL);
4145
+ }
4146
+ };
4147
+ }, [service]);
3917
4148
  return (React__namespace.createElement(uiReact.View, { className: DETECTOR_CLASS_NAME, testId: DETECTOR_CLASS_NAME },
3918
4149
  React__namespace.createElement(FaceLivenessDetectorProvider, { componentProps: props, service: service },
3919
4150
  React__namespace.createElement(uiReact.Flex, { direction: "column", ref: currElementRef },
package/dist/styles.css CHANGED
@@ -4246,7 +4246,21 @@ html[dir=rtl] .amplify-field-group__inner-start {
4246
4246
  opacity: 0;
4247
4247
  }
4248
4248
  }
4249
+ .liveness-detector {
4250
+ display: flex;
4251
+ flex-direction: column;
4252
+ height: 100%;
4253
+ min-height: 0;
4254
+ }
4255
+ .liveness-detector > .amplify-flex,
4256
+ .liveness-detector .liveness-detector-check {
4257
+ flex: 1 1 auto;
4258
+ min-height: 0;
4259
+ }
4260
+
4249
4261
  .amplify-liveness-camera-module {
4262
+ flex: 1 1 auto;
4263
+ min-height: 0;
4250
4264
  flex-direction: column;
4251
4265
  align-items: center;
4252
4266
  justify-content: center;
@@ -4323,6 +4337,13 @@ html[dir=rtl] .amplify-field-group__inner-start {
4323
4337
  .amplify-liveness-video-anchor {
4324
4338
  position: relative;
4325
4339
  width: 100%;
4340
+ flex: 1 1 auto;
4341
+ min-height: 200px;
4342
+ overflow: hidden;
4343
+ }
4344
+
4345
+ .amplify-liveness-camera-module--mobile .amplify-liveness-video-anchor {
4346
+ flex: none;
4326
4347
  }
4327
4348
 
4328
4349
  .amplify-liveness-recording-icon-container {
@@ -4663,6 +4684,7 @@ html[dir=rtl] .amplify-field-group__inner-start {
4663
4684
  justify-content: center;
4664
4685
  text-align: center;
4665
4686
  height: 480px;
4687
+ max-height: 100%;
4666
4688
  }
4667
4689
 
4668
4690
  .amplify-menu__wrapper {
@@ -8,6 +8,7 @@ export declare const selectVideoStream: import("../hooks").LivenessSelectorFn<Me
8
8
  export declare const selectFaceMatchPercentage: import("../hooks").LivenessSelectorFn<number | undefined>;
9
9
  export declare const selectFaceMatchState: import("../hooks").LivenessSelectorFn<FaceMatchState | undefined>;
10
10
  export declare const selectSelectedDeviceId: import("../hooks").LivenessSelectorFn<string | undefined>;
11
+ export declare const selectDisableStartScreen: import("../hooks").LivenessSelectorFn<boolean | undefined>;
11
12
  export declare const selectSelectableDevices: import("../hooks").LivenessSelectorFn<MediaDeviceInfo[] | undefined>;
12
13
  export interface LivenessCameraModuleProps {
13
14
  isMobileScreen: boolean;
@@ -66,7 +66,7 @@ export interface LivenessContext {
66
66
  shouldDisconnect: boolean | undefined;
67
67
  videoAssociatedParams: VideoAssociatedParams | undefined;
68
68
  }
69
- export type LivenessEventTypes = 'BEGIN' | 'CONNECTION_TIMEOUT' | 'START_RECORDING' | 'TIMEOUT' | 'ERROR' | 'CANCEL' | 'SET_SESSION_INFO' | 'DISCONNECT_EVENT' | 'SET_DOM_AND_CAMERA_DETAILS' | 'UPDATE_DEVICE_AND_STREAM' | 'CAMERA_NOT_FOUND' | 'SERVER_ERROR' | 'RUNTIME_ERROR' | 'RETRY_CAMERA_CHECK' | 'MOBILE_LANDSCAPE_WARNING';
69
+ export type LivenessEventTypes = 'BEGIN' | 'CONNECTION_TIMEOUT' | 'START_RECORDING' | 'TIMEOUT' | 'ERROR' | 'CANCEL' | 'SET_SESSION_INFO' | 'DISCONNECT_EVENT' | 'SET_DOM_AND_CAMERA_DETAILS' | 'UPDATE_DEVICE_AND_STREAM' | 'CAMERA_NOT_FOUND' | 'SERVER_ERROR' | 'RUNTIME_ERROR' | 'RETRY_CAMERA_CHECK' | 'MOBILE_LANDSCAPE_WARNING' | 'VIDEO_RESIZED';
70
70
  export type LivenessEventData = Record<PropertyKey, any>;
71
71
  export interface LivenessEvent {
72
72
  type: LivenessEventTypes;
@@ -0,0 +1,9 @@
1
+ import type { StreamRecorder } from './StreamRecorder';
2
+ /**
3
+ * Stops recording and sends `closeCode` to the streaming service so the
4
+ * session is closed with a reason instead of being left to time out.
5
+ *
6
+ * Only call this once recording has started: `stopRecording()` resolves when
7
+ * the recorder stops, so if it never started the close code is not sent.
8
+ */
9
+ export declare function closeLivenessStream(livenessStreamProvider: StreamRecorder | undefined, closeCode: number): void;
@@ -8,3 +8,4 @@ export { createRequestStreamGenerator, createSessionStartEvent, createSessionEnd
8
8
  export { createStreamingClient } from './createStreamingClient';
9
9
  export { isFaceMovementAndLightChallenge, isFaceMovementChallenge, createSessionInfoFromServerSessionInformation, } from './sessionInformation';
10
10
  export { StreamRecorder } from './StreamRecorder';
11
+ export { closeLivenessStream } from './closeLivenessStream';
@@ -36,6 +36,36 @@ export declare function getStaticLivenessOvalDetails({ width, height, widthSeed,
36
36
  ratioMultiplier?: number;
37
37
  ovalHeightWidthRatio?: number;
38
38
  }): LivenessOvalDetails;
39
+ /**
40
+ * Returns how much the intrinsic video frame is scaled inside the rendered
41
+ * video element. The video element uses the default `object-fit: contain`,
42
+ * so when the host container gives it a box that does not match the camera
43
+ * aspect ratio the frame is letterboxed and the smaller ratio applies.
44
+ */
45
+ export declare function getVideoScaleFactor(videoEl: HTMLVideoElement): number;
46
+ export declare const MIN_VISIBLE_FRAME_WIDTH_FRACTION = 0.7;
47
+ /**
48
+ * Returns the size and position of the video element so the camera frame
49
+ * fills as much of its container as possible without cropping the oval.
50
+ */
51
+ export declare function getVideoFillLayout({ containerWidth, containerHeight, videoWidth, videoHeight, }: {
52
+ containerWidth: number;
53
+ containerHeight: number;
54
+ videoWidth: number;
55
+ videoHeight: number;
56
+ }): {
57
+ width: number;
58
+ height: number;
59
+ left: number;
60
+ top: number;
61
+ };
62
+ /**
63
+ * Sizes the canvas drawing buffer to match its parent's box: the full screen
64
+ * on mobile and the video anchor box otherwise. The anchor can differ from the
65
+ * video element's box, which is centered in it and may be letterboxed or
66
+ * cropped on the sides.
67
+ */
68
+ export declare function resizeCanvasToDisplaySize(canvas: HTMLCanvasElement): void;
39
69
  /**
40
70
  * Draws the provided liveness oval on the canvas.
41
71
  */
@@ -1,5 +1,20 @@
1
1
  import React from 'react';
2
+ export interface FaceLivenessCancelButtonProps {
3
+ /**
4
+ * Cancels the liveness check, same as the default cancel button
5
+ */
6
+ onCancel: () => void;
7
+ /**
8
+ * Resolved `cancelLivenessCheckText` display text, used as the default
9
+ * button's accessible name
10
+ */
11
+ cancelLivenessCheckText: string;
12
+ }
2
13
  export interface CancelButtonProps {
3
14
  ariaLabel: string;
15
+ /**
16
+ * Custom cancel button provided through `components.CancelButton`
17
+ */
18
+ Component?: React.ComponentType<FaceLivenessCancelButtonProps>;
4
19
  }
5
20
  export declare const CancelButton: React.FC<CancelButtonProps>;