Adjust the VideoCapture crop rect to a size valid for the encoder

 * Find valid size by referring to the encoder's width/height alignment and supported width/height.
 * After finding a valid size, will enlarge/reduce the original crop rect from its center and keep the original aspect ratio as possible.

Bug: 243808729
Test: ./gradlew camera:camera-video:connectedAndroidTest
Change-Id: I815f2d4b3b8ef0c30cf44ea0247997aa7bff74ff
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/utils/TransformUtils.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/utils/TransformUtils.java
index f649315..75679d4 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/impl/utils/TransformUtils.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/utils/TransformUtils.java
@@ -26,6 +26,8 @@
 import androidx.annotation.RequiresApi;
 import androidx.core.util.Preconditions;
 
+import java.util.Locale;
+
 /**
  * Utility class for transform.
  *
@@ -53,6 +55,11 @@
         return new Size(rect.width(), rect.height());
     }
 
+    /** Returns a formatted string for a Rect. */
+    @NonNull
+    public static String rectToString(@NonNull Rect rect) {
+        return String.format(Locale.US, "%s(%dx%d)", rect, rect.width(), rect.height());
+    }
 
     /**
      * Transforms size to a {@link Rect} with zero left and top.
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/VideoCapture.java b/camera/camera-video/src/main/java/androidx/camera/video/VideoCapture.java
index 9d7dcac..ced0af2 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/VideoCapture.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/VideoCapture.java
@@ -29,12 +29,16 @@
 import static androidx.camera.core.impl.UseCaseConfig.OPTION_SESSION_CONFIG_UNPACKER;
 import static androidx.camera.core.impl.UseCaseConfig.OPTION_SURFACE_OCCUPANCY_PRIORITY;
 import static androidx.camera.core.impl.UseCaseConfig.OPTION_ZSL_DISABLED;
+import static androidx.camera.core.impl.utils.TransformUtils.rectToString;
 import static androidx.camera.core.internal.TargetConfig.OPTION_TARGET_CLASS;
 import static androidx.camera.core.internal.TargetConfig.OPTION_TARGET_NAME;
 import static androidx.camera.core.internal.ThreadConfig.OPTION_BACKGROUND_EXECUTOR;
 import static androidx.camera.core.internal.UseCaseEventConfig.OPTION_USE_CASE_EVENT_CALLBACK;
 import static androidx.camera.video.StreamInfo.STREAM_ID_ERROR;
+import static androidx.camera.video.impl.VideoCaptureConfig.OPTION_VIDEO_ENCODER_INFO_FINDER;
 import static androidx.camera.video.impl.VideoCaptureConfig.OPTION_VIDEO_OUTPUT;
+import static androidx.camera.video.internal.config.VideoConfigUtil.resolveVideoEncoderConfig;
+import static androidx.camera.video.internal.config.VideoConfigUtil.resolveVideoMimeInfo;
 
 import static java.util.Collections.singletonList;
 import static java.util.Objects.requireNonNull;
@@ -55,6 +59,7 @@
 import androidx.annotation.RestrictTo;
 import androidx.annotation.RestrictTo.Scope;
 import androidx.annotation.VisibleForTesting;
+import androidx.arch.core.util.Function;
 import androidx.camera.core.AspectRatio;
 import androidx.camera.core.CameraSelector;
 import androidx.camera.core.ImageCapture;
@@ -63,6 +68,7 @@
 import androidx.camera.core.SurfaceRequest;
 import androidx.camera.core.UseCase;
 import androidx.camera.core.ViewPort;
+import androidx.camera.core.impl.CamcorderProfileProxy;
 import androidx.camera.core.impl.CameraCaptureCallback;
 import androidx.camera.core.impl.CameraCaptureResult;
 import androidx.camera.core.impl.CameraInfoInternal;
@@ -92,15 +98,24 @@
 import androidx.camera.core.processing.SurfaceEffectNode;
 import androidx.camera.video.StreamInfo.StreamState;
 import androidx.camera.video.impl.VideoCaptureConfig;
+import androidx.camera.video.internal.config.MimeInfo;
+import androidx.camera.video.internal.encoder.InvalidConfigException;
+import androidx.camera.video.internal.encoder.VideoEncoderConfig;
+import androidx.camera.video.internal.encoder.VideoEncoderInfo;
+import androidx.camera.video.internal.encoder.VideoEncoderInfoImpl;
 import androidx.concurrent.futures.CallbackToFutureAdapter;
 import androidx.core.util.Preconditions;
+import androidx.core.util.Supplier;
 
 import com.google.common.util.concurrent.ListenableFuture;
 
 import java.lang.reflect.Type;
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Objects;
+import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.CancellationException;
 import java.util.concurrent.ExecutionException;
@@ -146,6 +161,9 @@
     private SurfaceEffectInternal mSurfaceEffect;
     @Nullable
     private SurfaceEffectNode mNode;
+    @Nullable
+
+    private VideoEncoderInfo mVideoEncoderInfo;
 
     /**
      * Create a VideoCapture associated with the given {@link VideoOutput}.
@@ -316,6 +334,8 @@
     @Override
     public void onDetached() {
         clearPipeline();
+
+        mVideoEncoderInfo = null;
     }
 
     /**
@@ -412,8 +432,9 @@
         }
     }
 
+    @VisibleForTesting
     @NonNull
-    private SettableSurface getCameraSettableSurface() {
+    SettableSurface getCameraSettableSurface() {
         Preconditions.checkNotNull(mNode);
         return (SettableSurface) requireNonNull(mDeferrableSurface);
     }
@@ -446,17 +467,22 @@
         // TODO(b/229410005): The expected FPS range will need to come from the camera rather
         //  than what is requested in the config. For now we use the default range of (30, 30)
         //  for behavioral consistency.
-        Range<Integer> targetFpsRange = config.getTargetFramerate(Defaults.DEFAULT_FPS_RANGE);
+        Range<Integer> targetFpsRange = requireNonNull(
+                config.getTargetFramerate(Defaults.DEFAULT_FPS_RANGE));
         if (mSurfaceEffect != null) {
-            mNode = new SurfaceEffectNode(camera, APPLY_CROP_ROTATE_AND_MIRRORING,
-                    mSurfaceEffect);
+            MediaSpec mediaSpec = requireNonNull(getMediaSpec());
+            Rect cropRect = requireNonNull(getCropRect(resolution));
+            cropRect = adjustCropRectIfNeeded(cropRect, resolution,
+                    () -> getVideoEncoderInfo(config.getVideoEncoderInfoFinder(), camera, mediaSpec,
+                            resolution, targetFpsRange));
+            mNode = new SurfaceEffectNode(camera, APPLY_CROP_ROTATE_AND_MIRRORING, mSurfaceEffect);
             SettableSurface cameraSurface = new SettableSurface(
                     SurfaceEffect.VIDEO_CAPTURE,
                     resolution,
                     ImageFormat.PRIVATE,
                     getSensorToBufferTransformMatrix(),
                     /*hasEmbeddedTransform=*/true,
-                    requireNonNull(getCropRect(resolution)),
+                    cropRect,
                     getRelativeRotation(camera),
                     /*mirroring=*/false);
             SurfaceEdge inputEdge = SurfaceEdge.create(singletonList(cameraSurface));
@@ -537,11 +563,22 @@
                 SurfaceRequest::willNotProvideSurface;
         private static final VideoCaptureConfig<?> DEFAULT_CONFIG;
 
+        private static final Function<VideoEncoderConfig, VideoEncoderInfo>
+                DEFAULT_VIDEO_ENCODER_INFO_FINDER = encoderInfo -> {
+                    try {
+                        return VideoEncoderInfoImpl.from(encoderInfo);
+                    } catch (InvalidConfigException e) {
+                        Logger.w(TAG, "Unable to find VideoEncoderInfo", e);
+                        return null;
+                    }
+                };
+
         static final Range<Integer> DEFAULT_FPS_RANGE = new Range<>(30, 30);
 
         static {
             Builder<?> builder = new Builder<>(DEFAULT_VIDEO_OUTPUT)
-                    .setSurfaceOccupancyPriority(DEFAULT_SURFACE_OCCUPANCY_PRIORITY);
+                    .setSurfaceOccupancyPriority(DEFAULT_SURFACE_OCCUPANCY_PRIORITY)
+                    .setVideoEncoderInfoFinder(DEFAULT_VIDEO_ENCODER_INFO_FINDER);
 
             DEFAULT_CONFIG = builder.getUseCaseConfig();
         }
@@ -630,6 +667,214 @@
     }
 
     @MainThread
+    @NonNull
+    private Rect adjustCropRectIfNeeded(@NonNull Rect cropRect, @NonNull Size resolution,
+            @NonNull Supplier<VideoEncoderInfo> videoEncoderInfoFinder) {
+        if (!isCropNeeded(cropRect, resolution)) {
+            return cropRect;
+        }
+        VideoEncoderInfo videoEncoderInfo = videoEncoderInfoFinder.get();
+        if (videoEncoderInfo == null) {
+            Logger.w(TAG, "Crop is needed but can't find the encoder info to adjust the cropRect");
+            return cropRect;
+        }
+        return adjustCropRectToValidSize(cropRect, resolution, videoEncoderInfo);
+    }
+
+    /**
+     * This method resizes the crop rectangle to a valid size.
+     *
+     * <p>The valid size must fulfill
+     * <ul>
+     * <li>The multiple of VideoEncoderInfo.getWidthAlignment()/getHeightAlignment() alignment</li>
+     * <li>In the scope of Surface resolution and VideoEncoderInfo.getSupportedWidths()
+     * /getSupportedHeights().</li>
+     * </ul>
+     *
+     * <p>When the size is not a multiple of the alignment, it seeks to shrink or enlarge the size
+     * with the smallest amount of change and ensures that the size is within the surface
+     * resolution and supported widths and heights. The new cropping rectangle position (left,
+     * right, top, and bottom) is then calculated by extending or indenting from the center of
+     * the original cropping rectangle.
+     */
+    @NonNull
+    private static Rect adjustCropRectToValidSize(@NonNull Rect cropRect, @NonNull Size resolution,
+            @NonNull VideoEncoderInfo videoEncoderInfo) {
+        Logger.d(TAG, String.format("Adjust cropRect %s by width/height alignment %d/%d and "
+                        + "supported widths %s / supported heights %s",
+                rectToString(cropRect),
+                videoEncoderInfo.getWidthAlignment(),
+                videoEncoderInfo.getHeightAlignment(),
+                videoEncoderInfo.getSupportedWidths(),
+                videoEncoderInfo.getSupportedHeights()
+        ));
+
+        // Construct all up/down alignment combinations.
+        int widthAlignment = videoEncoderInfo.getWidthAlignment();
+        int heightAlignment = videoEncoderInfo.getHeightAlignment();
+        Range<Integer> supportedWidths = videoEncoderInfo.getSupportedWidths();
+        Range<Integer> supportedHeights = videoEncoderInfo.getSupportedHeights();
+        int widthAlignedDown = alignDown(cropRect.width(), widthAlignment, supportedWidths);
+        int widthAlignedUp = alignUp(cropRect.width(), widthAlignment, supportedWidths);
+        int heightAlignedDown = alignDown(cropRect.height(), heightAlignment, supportedHeights);
+        int heightAlignedUp = alignUp(cropRect.height(), heightAlignment, supportedHeights);
+
+        // Use Set to filter out duplicates.
+        Set<Size> candidateSet = new HashSet<>();
+        addBySupportedSize(candidateSet, widthAlignedDown, heightAlignedDown, resolution,
+                videoEncoderInfo);
+        addBySupportedSize(candidateSet, widthAlignedDown, heightAlignedUp, resolution,
+                videoEncoderInfo);
+        addBySupportedSize(candidateSet, widthAlignedUp, heightAlignedDown, resolution,
+                videoEncoderInfo);
+        addBySupportedSize(candidateSet, widthAlignedUp, heightAlignedUp, resolution,
+                videoEncoderInfo);
+        if (candidateSet.isEmpty()) {
+            Logger.w(TAG, "Can't find valid cropped size");
+            return cropRect;
+        }
+        List<Size> candidatesList = new ArrayList<>(candidateSet);
+        Logger.d(TAG, "candidatesList = " + candidatesList);
+
+        // Find the smallest change in dimensions.
+        Collections.sort(candidatesList,
+                (s1, s2) -> (Math.abs(s1.getWidth() - cropRect.width()) + Math.abs(
+                        s1.getHeight() - cropRect.height()))
+                        - (Math.abs(s2.getWidth() - cropRect.width()) + Math.abs(
+                        s2.getHeight() - cropRect.height())));
+        Logger.d(TAG, "sorted candidatesList = " + candidatesList);
+        Size newSize = candidatesList.get(0);
+        int newWidth = newSize.getWidth();
+        int newHeight = newSize.getHeight();
+
+        if (newWidth == cropRect.width() && newHeight == cropRect.height()) {
+            Logger.d(TAG, "No need to adjust cropRect because crop size is valid.");
+            return cropRect;
+        }
+
+        // New width/height should be multiple of 2 since VideoCapabilities.get*Alignment()
+        // returns power of 2. This ensures width/2 and height/2 are not rounded off.
+        // New width/height smaller than resolution ensures calculated cropRect never exceeds
+        // the resolution.
+        Preconditions.checkState(newWidth % 2 == 0 && newHeight % 2 == 0
+                && newWidth <= resolution.getWidth() && newHeight <= resolution.getHeight());
+        Rect newCropRect = new Rect(cropRect);
+        if (newWidth != cropRect.width()) {
+            // Note: When the width/height of cropRect is odd number, Rect.centerX/Y() will be
+            // offset to the left/top by 0.5.
+            newCropRect.left = Math.max(0, cropRect.centerX() - newWidth / 2);
+            newCropRect.right = newCropRect.left + newWidth;
+            if (newCropRect.right > resolution.getWidth()) {
+                newCropRect.right = resolution.getWidth();
+                newCropRect.left = newCropRect.right - newWidth;
+            }
+        }
+        if (newHeight != cropRect.height()) {
+            newCropRect.top = Math.max(0, cropRect.centerY() - newHeight / 2);
+            newCropRect.bottom = newCropRect.top + newHeight;
+            if (newCropRect.bottom > resolution.getHeight()) {
+                newCropRect.bottom = resolution.getHeight();
+                newCropRect.top = newCropRect.bottom - newHeight;
+            }
+        }
+        Logger.d(TAG, String.format("Adjust cropRect from %s to %s", rectToString(cropRect),
+                rectToString(newCropRect)));
+        return newCropRect;
+    }
+
+    private static void addBySupportedSize(@NonNull Set<Size> candidates, int width, int height,
+            @NonNull Size resolution, @NonNull VideoEncoderInfo videoEncoderInfo) {
+        if (width > resolution.getWidth() || height > resolution.getHeight()) {
+            return;
+        }
+        try {
+            Range<Integer> supportedHeights = videoEncoderInfo.getSupportedHeightsFor(width);
+            candidates.add(new Size(width, supportedHeights.clamp(height)));
+        } catch (IllegalArgumentException e) {
+            Logger.w(TAG, "No supportedHeights for width: " + width, e);
+        }
+        try {
+            Range<Integer> supportedWidths = videoEncoderInfo.getSupportedWidthsFor(height);
+            candidates.add(new Size(supportedWidths.clamp(width), height));
+        } catch (IllegalArgumentException e) {
+            Logger.w(TAG, "No supportedWidths for height: " + height, e);
+        }
+    }
+
+    private static boolean isCropNeeded(@NonNull Rect cropRect, @NonNull Size resolution) {
+        return resolution.getWidth() != cropRect.width()
+                || resolution.getHeight() != cropRect.height();
+    }
+
+    private static int alignDown(int length, int alignment,
+            @NonNull Range<Integer> supportedLength) {
+        return align(true, length, alignment, supportedLength);
+    }
+
+    private static int alignUp(int length, int alignment,
+            @NonNull Range<Integer> supportedRange) {
+        return align(false, length, alignment, supportedRange);
+    }
+
+    private static int align(boolean alignDown, int length, int alignment,
+            @NonNull Range<Integer> supportedRange) {
+        int remainder = length % alignment;
+        int newLength;
+        if (remainder == 0) {
+            newLength = length;
+        } else if (alignDown) {
+            newLength = length - remainder;
+        } else {
+            newLength = length + (alignment - remainder);
+        }
+        // Clamp new length by supportedRange, which is supposed to be valid length.
+        return supportedRange.clamp(newLength);
+    }
+
+    @MainThread
+    @Nullable
+    private VideoEncoderInfo getVideoEncoderInfo(
+            @NonNull Function<VideoEncoderConfig, VideoEncoderInfo> videoEncoderInfoFinder,
+            @NonNull CameraInternal camera,
+            @NonNull MediaSpec mediaSpec,
+            @NonNull Size resolution,
+            @NonNull Range<Integer> targetFps) {
+        if (mVideoEncoderInfo != null) {
+            return mVideoEncoderInfo;
+        }
+        // Cache the VideoEncoderInfo as it should be the same when recreating the pipeline.
+        // This avoids recreating the MediaCodec instance to get encoder information.
+        // Note: We should clear the cache if the MediaSpec changes at any time, especially when
+        // the Encoder-related content in the VideoSpec changes. i.e. when we need to observe the
+        // MediaSpec Observable.
+        return mVideoEncoderInfo = resolveVideoEncoderInfo(videoEncoderInfoFinder, camera,
+                mediaSpec, resolution, targetFps);
+    }
+
+    @Nullable
+    private static VideoEncoderInfo resolveVideoEncoderInfo(
+            @NonNull Function<VideoEncoderConfig, VideoEncoderInfo> videoEncoderInfoFinder,
+            @NonNull CameraInternal camera,
+            @NonNull MediaSpec mediaSpec,
+            @NonNull Size resolution,
+            @NonNull Range<Integer> targetFps) {
+        // Find the nearest CamcorderProfile
+        VideoCapabilities videoCapabilities = VideoCapabilities.from(camera.getCameraInfo());
+        CamcorderProfileProxy camcorderProfileProxy =
+                videoCapabilities.findHighestSupportedCamcorderProfileFor(resolution);
+
+        // Resolve the VideoEncoderConfig
+        MimeInfo videoMimeInfo = resolveVideoMimeInfo(mediaSpec, camcorderProfileProxy);
+        VideoEncoderConfig videoEncoderConfig = resolveVideoEncoderConfig(
+                videoMimeInfo,
+                mediaSpec.getVideoSpec(),
+                resolution,
+                targetFps);
+
+        return videoEncoderInfoFinder.apply(videoEncoderConfig);
+    }
+
+    @MainThread
     private void setupSurfaceUpdateNotifier(@NonNull SessionConfig.Builder sessionConfigBuilder,
             boolean isStreamActive) {
         if (mSurfaceUpdateFuture != null) {
@@ -914,6 +1159,14 @@
             return new VideoCaptureConfig<>(OptionsBundle.from(mMutableConfig));
         }
 
+        @NonNull
+        Builder<T> setVideoEncoderInfoFinder(
+                @NonNull Function<VideoEncoderConfig, VideoEncoderInfo> videoEncoderInfoFinder) {
+            getMutableConfig().insertOption(OPTION_VIDEO_ENCODER_INFO_FINDER,
+                    videoEncoderInfoFinder);
+            return this;
+        }
+
         /**
          * Builds an immutable {@link VideoCaptureConfig} from the current state.
          *
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/impl/VideoCaptureConfig.java b/camera/camera-video/src/main/java/androidx/camera/video/impl/VideoCaptureConfig.java
index dac4381..c9bd4de 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/impl/VideoCaptureConfig.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/impl/VideoCaptureConfig.java
@@ -18,6 +18,7 @@
 
 import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
+import androidx.arch.core.util.Function;
 import androidx.camera.core.impl.Config;
 import androidx.camera.core.impl.ImageFormatConstants;
 import androidx.camera.core.impl.ImageOutputConfig;
@@ -26,6 +27,10 @@
 import androidx.camera.core.internal.ThreadConfig;
 import androidx.camera.video.VideoCapture;
 import androidx.camera.video.VideoOutput;
+import androidx.camera.video.internal.encoder.VideoEncoderConfig;
+import androidx.camera.video.internal.encoder.VideoEncoderInfo;
+
+import java.util.Objects;
 
 /**
  * Config for a video capture use case.
@@ -46,6 +51,10 @@
     public static final Option<VideoOutput> OPTION_VIDEO_OUTPUT =
             Option.create("camerax.video.VideoCapture.videoOutput", VideoOutput.class);
 
+    public static final Option<Function<VideoEncoderConfig, VideoEncoderInfo>>
+            OPTION_VIDEO_ENCODER_INFO_FINDER =
+            Option.create("camerax.video.VideoCapture.videoEncoderInfoFinder", Function.class);
+
     // *********************************************************************************************
 
     private final OptionsBundle mConfig;
@@ -60,6 +69,11 @@
         return (T) retrieveOption(OPTION_VIDEO_OUTPUT);
     }
 
+    @NonNull
+    public Function<VideoEncoderConfig, VideoEncoderInfo> getVideoEncoderInfoFinder() {
+        return Objects.requireNonNull(retrieveOption(OPTION_VIDEO_ENCODER_INFO_FINDER));
+    }
+
     /**
      * Retrieves the format of the image that is fed as input.
      *
diff --git a/camera/camera-video/src/test/java/androidx/camera/video/VideoCaptureTest.kt b/camera/camera-video/src/test/java/androidx/camera/video/VideoCaptureTest.kt
index dc31faf..5918033 100644
--- a/camera/camera-video/src/test/java/androidx/camera/video/VideoCaptureTest.kt
+++ b/camera/camera-video/src/test/java/androidx/camera/video/VideoCaptureTest.kt
@@ -17,10 +17,13 @@
 package androidx.camera.video
 
 import android.content.Context
+import android.graphics.Rect
 import android.os.Build
 import android.os.Looper
+import android.util.Range
 import android.util.Size
 import android.view.Surface
+import androidx.arch.core.util.Function
 import androidx.camera.core.CameraSelector
 import androidx.camera.core.CameraXConfig
 import androidx.camera.core.SurfaceRequest
@@ -30,6 +33,7 @@
 import androidx.camera.core.impl.ImageOutputConfig
 import androidx.camera.core.impl.MutableStateObservable
 import androidx.camera.core.impl.Observable
+import androidx.camera.core.impl.utils.TransformUtils.rectToSize
 import androidx.camera.core.impl.utils.executor.CameraXExecutors
 import androidx.camera.core.internal.CameraUseCaseAdapter
 import androidx.camera.testing.CamcorderProfileUtil
@@ -52,6 +56,9 @@
 import androidx.camera.testing.fakes.FakeSurfaceEffectInternal
 import androidx.camera.video.StreamInfo.StreamState
 import androidx.camera.video.impl.VideoCaptureConfig
+import androidx.camera.video.internal.encoder.FakeVideoEncoderInfo
+import androidx.camera.video.internal.encoder.VideoEncoderConfig
+import androidx.camera.video.internal.encoder.VideoEncoderInfo
 import androidx.core.util.Consumer
 import androidx.test.core.app.ApplicationProvider
 import com.google.common.truth.Truth.assertThat
@@ -106,12 +113,8 @@
 
     @Test
     fun setTargetResolution_throwsException() {
-        val videoOutput = createVideoOutput()
-
         assertThrows(UnsupportedOperationException::class.java) {
-            VideoCapture.Builder(videoOutput)
-                .setTargetResolution(ANY_SIZE)
-                .build()
+            createVideoCapture(targetResolution = ANY_SIZE)
         }
     }
 
@@ -121,7 +124,7 @@
         val videoOutput = createVideoOutput()
 
         // Act.
-        val videoCapture = VideoCapture.withOutput(videoOutput)
+        val videoCapture = createVideoCapture(videoOutput)
 
         // Assert.
         assertThat(videoCapture.output).isEqualTo(videoOutput)
@@ -135,9 +138,7 @@
 
         var surfaceRequest: SurfaceRequest? = null
         val videoOutput = createVideoOutput(surfaceRequestListener = { surfaceRequest = it })
-        val videoCapture = VideoCapture.Builder(videoOutput)
-            .setSessionOptionUnpacker { _, _ -> }
-            .build()
+        val videoCapture = createVideoCapture(videoOutput)
 
         // Act.
         addAndAttachUseCases(videoCapture)
@@ -153,9 +154,7 @@
         createCameraUseCaseAdapter()
 
         val videoOutput = createVideoOutput(mediaSpec = null)
-        val videoCapture = VideoCapture.Builder(videoOutput)
-            .setSessionOptionUnpacker { _, _ -> }
-            .build()
+        val videoCapture = createVideoCapture(videoOutput)
 
         // Assert.
         assertThrows(CameraUseCaseAdapter.CameraException::class.java) {
@@ -189,9 +188,7 @@
                     it.setQualitySelector(QualitySelector.from(quality))
                 }.build()
             )
-            val videoCapture = VideoCapture.Builder(videoOutput)
-                .setSessionOptionUnpacker { _, _ -> }
-                .build()
+            val videoCapture = createVideoCapture(videoOutput)
 
             // Act.
             addAndAttachUseCases(videoCapture)
@@ -238,9 +235,7 @@
                 )
             }.build()
         )
-        val videoCapture = VideoCapture.Builder(videoOutput)
-            .setSessionOptionUnpacker { _, _ -> }
-            .build()
+        val videoCapture = createVideoCapture(videoOutput)
 
         // Act.
         addAndAttachUseCases(videoCapture)
@@ -265,9 +260,7 @@
                 it.setQualitySelector(QualitySelector.from(Quality.FHD))
             }.build()
         )
-        val videoCapture = VideoCapture.Builder(videoOutput)
-            .setSessionOptionUnpacker { _, _ -> }
-            .build()
+        val videoCapture = createVideoCapture(videoOutput)
 
         // Assert.
         assertThrows(CameraUseCaseAdapter.CameraException::class.java) {
@@ -287,9 +280,7 @@
                 it.setQualitySelector(QualitySelector.from(Quality.UHD))
             }.build()
         )
-        val videoCapture = VideoCapture.Builder(videoOutput)
-            .setSessionOptionUnpacker { _, _ -> }
-            .build()
+        val videoCapture = createVideoCapture(videoOutput)
 
         // Act.
         addAndAttachUseCases(videoCapture)
@@ -315,9 +306,7 @@
                 CameraXExecutors.directExecutor()
             ) { surfaceResult = it }
         }
-        val videoCapture = VideoCapture.Builder(videoOutput)
-            .setSessionOptionUnpacker { _, _ -> }
-            .build()
+        val videoCapture = createVideoCapture(videoOutput)
 
         // Act.
         addAndAttachUseCases(videoCapture)
@@ -338,7 +327,7 @@
     @Test
     fun setTargetRotation_rotationIsChanged() {
         // Arrange.
-        val videoCapture = VideoCapture.withOutput(createVideoOutput())
+        val videoCapture = createVideoCapture()
 
         // Act.
         videoCapture.targetRotation = Surface.ROTATION_180
@@ -362,9 +351,7 @@
             }
         )
 
-        val videoCapture = VideoCapture.Builder(videoOutput)
-            .setSessionOptionUnpacker { _, _ -> }
-            .build()
+        val videoCapture = createVideoCapture(videoOutput)
 
         // Act.
         addAndAttachUseCases(videoCapture)
@@ -388,10 +375,7 @@
                 }
             }
         )
-        val videoCapture = VideoCapture.Builder(videoOutput)
-            .setTargetRotation(Surface.ROTATION_90)
-            .setSessionOptionUnpacker { _, _ -> }
-            .build()
+        val videoCapture = createVideoCapture(videoOutput, targetRotation = Surface.ROTATION_90)
 
         // Act.
         addAndAttachUseCases(videoCapture)
@@ -455,9 +439,7 @@
                 appSurfaceReadyToRelease = true
             }
         })
-        val videoCapture = VideoCapture.Builder(videoOutput)
-            .setSessionOptionUnpacker { _, _ -> }
-            .build()
+        val videoCapture = createVideoCapture(videoOutput)
 
         // Act: bind and provide Surface.
         videoCapture.setEffect(effect)
@@ -490,6 +472,108 @@
         assertThat(appSurfaceReadyToRelease).isTrue()
     }
 
+    @Test
+    fun adjustCropRect_noAdjustment() {
+        testAdjustCropRectToValidSize(
+            videoEncoderInfo = createVideoEncoderInfo(widthAlignment = 8, heightAlignment = 8),
+            cropRect = Rect(8, 8, 808, 608),
+            expectedCropRect = Rect(8, 8, 808, 608),
+        )
+    }
+
+    @Test
+    fun adjustCropRect_toSmallerSize() {
+        testAdjustCropRectToValidSize(
+            videoEncoderInfo = createVideoEncoderInfo(widthAlignment = 8, heightAlignment = 8),
+            cropRect = Rect(8, 8, 811, 608), // 803x600 -> 800x600
+            expectedCropRect = Rect(9, 8, 809, 608),
+        )
+    }
+
+    @Test
+    fun adjustCropRect_toLargerSize() {
+        testAdjustCropRectToValidSize(
+            videoEncoderInfo = createVideoEncoderInfo(widthAlignment = 8, heightAlignment = 8),
+            cropRect = Rect(8, 8, 805, 608), // 797x600 -> 800x600
+            expectedCropRect = Rect(6, 8, 806, 608),
+        )
+    }
+
+    @Test
+    fun adjustCropRect_toLargerSize_fromTopLeft() {
+        testAdjustCropRectToValidSize(
+            videoEncoderInfo = createVideoEncoderInfo(widthAlignment = 8, heightAlignment = 8),
+            cropRect = Rect(0, 0, 797, 600), // 797x600 -> 800x600
+            expectedCropRect = Rect(0, 0, 800, 600),
+        )
+    }
+
+    @Test
+    fun adjustCropRect_toLargerSize_fromBottomRight() {
+        testAdjustCropRectToValidSize(
+            // Quality.HD maps to 1280x720 (4:3)
+            videoEncoderInfo = createVideoEncoderInfo(widthAlignment = 8, heightAlignment = 8),
+            cropRect = Rect(1280 - 797, 720 - 600, 1280, 720), // 797x600 -> 800x600
+            expectedCropRect = Rect(1280 - 800, 720 - 600, 1280, 720),
+        )
+    }
+
+    @Test
+    fun adjustCropRect_clampBySupportedWidthsHeights() {
+        testAdjustCropRectToValidSize(
+            videoEncoderInfo = createVideoEncoderInfo(
+                widthAlignment = 8,
+                heightAlignment = 8,
+                supportedWidths = Range(80, 800),
+                supportedHeights = Range(100, 800),
+            ),
+            cropRect = Rect(8, 8, 48, 48), // 40x40
+            expectedCropRect = Rect(0, 0, 80, 100),
+        )
+    }
+
+    @Test
+    fun adjustCropRect_toSmallestDimensionChange() {
+        testAdjustCropRectToValidSize(
+            videoEncoderInfo = createVideoEncoderInfo(widthAlignment = 8, heightAlignment = 8),
+            cropRect = Rect(8, 8, 811, 607), // 803x599 -> 800x600
+            expectedCropRect = Rect(9, 7, 809, 607),
+        )
+    }
+
+    private fun testAdjustCropRectToValidSize(
+        quality: Quality = Quality.HD, // Quality.HD maps to 1280x720 (4:3)
+        videoEncoderInfo: VideoEncoderInfo = createVideoEncoderInfo(),
+        cropRect: Rect,
+        expectedCropRect: Rect,
+    ) {
+        // Arrange.
+        setupCamera()
+        createCameraUseCaseAdapter()
+        var surfaceRequest: SurfaceRequest? = null
+        val videoOutput = createVideoOutput(
+            mediaSpec = MediaSpec.builder().configureVideo {
+                it.setQualitySelector(QualitySelector.from(quality))
+            }.build(),
+            surfaceRequestListener = { surfaceRequest = it }
+        )
+        val videoCapture = createVideoCapture(
+            videoOutput, videoEncoderInfoFinder = { videoEncoderInfo }
+        )
+        val effect = FakeSurfaceEffectInternal(CameraXExecutors.mainThreadExecutor(), false)
+        videoCapture.setEffect(effect)
+        videoCapture.setViewPortCropRect(cropRect)
+
+        // Act.
+        addAndAttachUseCases(videoCapture)
+        shadowOf(Looper.getMainLooper()).idle()
+
+        // Assert.
+        assertThat(surfaceRequest).isNotNull()
+        assertThat(surfaceRequest!!.resolution).isEqualTo(rectToSize(expectedCropRect))
+        assertThat(videoCapture.cameraSettableSurface.cropRect).isEqualTo(expectedCropRect)
+    }
+
     private fun assertSupportedResolutions(
         videoCapture: VideoCapture<out VideoOutput>,
         vararg expectedResolutions: Size
@@ -502,6 +586,20 @@
         }
     }
 
+    private fun createVideoEncoderInfo(
+        widthAlignment: Int = 2,
+        heightAlignment: Int = 2,
+        supportedWidths: Range<Int> = Range.create(0, Integer.MAX_VALUE),
+        supportedHeights: Range<Int> = Range.create(0, Integer.MAX_VALUE),
+    ): VideoEncoderInfo {
+        return FakeVideoEncoderInfo(
+            _widthAlignment = widthAlignment,
+            _heightAlignment = heightAlignment,
+            _supportedWidths = supportedWidths,
+            _supportedHeights = supportedHeights,
+        )
+    }
+
     private fun createVideoOutput(
         streamState: StreamState = StreamState.ACTIVE,
         mediaSpec: MediaSpec? = MediaSpec.builder().build(),
@@ -552,6 +650,19 @@
             CameraUtil.createCameraUseCaseAdapter(context, CameraSelector.DEFAULT_BACK_CAMERA)
     }
 
+    private fun createVideoCapture(
+        videoOutput: VideoOutput = createVideoOutput(),
+        targetRotation: Int? = null,
+        targetResolution: Size? = null,
+        videoEncoderInfoFinder: Function<VideoEncoderConfig, VideoEncoderInfo>? = null,
+    ): VideoCapture<VideoOutput> = VideoCapture.Builder(videoOutput)
+        .setSessionOptionUnpacker { _, _ -> }
+        .apply {
+            targetRotation?.let { setTargetRotation(it) }
+            targetResolution?.let { setTargetResolution(it) }
+            videoEncoderInfoFinder?.let { setVideoEncoderInfoFinder(it) }
+        }.build()
+
     private fun setupCamera(
         cameraId: String = CAMERA_ID_0,
         vararg profiles: CamcorderProfileProxy = CAMERA_0_PROFILES
diff --git a/camera/camera-video/src/test/java/androidx/camera/video/internal/encoder/FakeEncoderInfo.kt b/camera/camera-video/src/test/java/androidx/camera/video/internal/encoder/FakeEncoderInfo.kt
new file mode 100644
index 0000000..f78553d
--- /dev/null
+++ b/camera/camera-video/src/test/java/androidx/camera/video/internal/encoder/FakeEncoderInfo.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.video.internal.encoder
+
+open class FakeEncoderInfo(var _name: String = "fake.encoder") : EncoderInfo {
+    override fun getName(): String = _name
+}
diff --git a/camera/camera-video/src/test/java/androidx/camera/video/internal/encoder/FakeVideoEncoderInfo.kt b/camera/camera-video/src/test/java/androidx/camera/video/internal/encoder/FakeVideoEncoderInfo.kt
new file mode 100644
index 0000000..1b05685
--- /dev/null
+++ b/camera/camera-video/src/test/java/androidx/camera/video/internal/encoder/FakeVideoEncoderInfo.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.video.internal.encoder
+
+import android.util.Range
+
+class FakeVideoEncoderInfo(
+    var _supportedWidths: Range<Int> = Range.create(0, Integer.MAX_VALUE),
+    var _supportedHeights: Range<Int> = Range.create(0, Integer.MAX_VALUE),
+    var _widthAlignment: Int = 2,
+    var _heightAlignment: Int = 2,
+) : FakeEncoderInfo(), VideoEncoderInfo {
+
+    override fun getSupportedWidths(): Range<Int> {
+        return _supportedWidths
+    }
+
+    override fun getSupportedHeights(): Range<Int> {
+        return _supportedHeights
+    }
+
+    override fun getSupportedWidthsFor(height: Int): Range<Int> {
+        return _supportedWidths
+    }
+
+    override fun getSupportedHeightsFor(width: Int): Range<Int> {
+        return _supportedHeights
+    }
+
+    override fun getWidthAlignment(): Int {
+       return _widthAlignment
+    }
+
+    override fun getHeightAlignment(): Int {
+        return _heightAlignment
+    }
+}
\ No newline at end of file