Add VideoCapture.getSelectedQuality() API
This API allows retrieving the selected Quality for a VideoCapture instance based on the provided QualitySelector.
The getSelectedQuality() method returns a value only when the VideoCapture is bound to a lifecycle.
This API was requested by both JCA and 3rd-party developers.
Relnote: "Add VideoCapture.getSelectedQuality() to know the selected Quality based on the QualitySelector."
Bug: 204288986
Test: VideoCaptureTest
Change-Id: I7050868dea1f1654386c991d441c25af2e3f1fe4
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/StreamSpec.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/StreamSpec.java
index bbd3ff2..2552a60 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/impl/StreamSpec.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/StreamSpec.java
@@ -21,6 +21,8 @@
import android.util.Size;
import androidx.camera.core.DynamicRange;
+import androidx.camera.core.ViewPort;
+import androidx.camera.core.streamsharing.StreamSharing;
import com.google.auto.value.AutoValue;
@@ -46,6 +48,25 @@
public abstract @NonNull Size getResolution();
/**
+ * Returns the original resolution configured by the camera. This value is useful for
+ * debugging and analysis, as it represents the initial resolution intended for the stream,
+ * even if the stream is later modified.
+ *
+ * <p>This value typically matches the resolution returned by {@link #getResolution()},
+ * but may differ if the stream is modified (e.g., cropped, scaled, or rotated)
+ * after being configured by the camera. For example, {@link StreamSharing} first determines
+ * which child use case's requested resolution to be its configured resolution and then
+ * request a larger resolution from the camera. The camera stream is further modified (e.g.,
+ * cropped, scaled, or rotated) to fit the configured resolution and other requirements such
+ * as {@link ViewPort} and rotation. The final resolution after these
+ * modifications would be reflected by {@link #getResolution()}, while this method returns the
+ * original configured resolution.
+ *
+ * @return The originally configured camera resolution.
+ */
+ public abstract @NonNull Size getOriginalConfiguredResolution();
+
+ /**
* Returns the {@link DynamicRange} for the stream associated with this stream specification.
* @return the dynamic range for the stream.
*/
@@ -74,6 +95,7 @@
public static @NonNull Builder builder(@NonNull Size resolution) {
return new AutoValue_StreamSpec.Builder()
.setResolution(resolution)
+ .setOriginalConfiguredResolution(resolution)
.setExpectedFrameRateRange(FRAME_RATE_RANGE_UNSPECIFIED)
.setDynamicRange(DynamicRange.SDR)
.setZslDisabled(false);
@@ -93,6 +115,25 @@
public abstract @NonNull Builder setResolution(@NonNull Size resolution);
/**
+ * Sets the original resolution configured by the camera. This value is useful for
+ * debugging and analysis, as it represents the initial resolution intended by the stream
+ * consumer, even if the stream is later modified.
+ *
+ * <p>This value typically matches the resolution set by {@link #setResolution(Size)},
+ * but may differ if the stream is modified (e.g., cropped, scaled, or rotated)
+ * after being configured by the camera. For example, {@link StreamSharing} first
+ * determines which child use case's requested resolution to be its configured resolution
+ * and then request a larger resolution from the camera. The camera stream is further
+ * modified (e.g., cropped, scaled, or rotated) to fit the configured resolution and other
+ * requirements such as {@link ViewPort} and rotation. The final resolution after these
+ * modifications is set by {@link #setResolution(Size)}, while this method retains the
+ * original configured resolution.
+ *
+ * <p>If not set, this value will default to the resolution set in this builder.
+ */
+ public abstract @NonNull Builder setOriginalConfiguredResolution(@NonNull Size resolution);
+
+ /**
* Sets the dynamic range.
*
* <p>If not set, the default dynamic range is {@link DynamicRange#SDR}.
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/PreferredChildSize.kt b/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/PreferredChildSize.kt
new file mode 100644
index 0000000..1b65043
--- /dev/null
+++ b/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/PreferredChildSize.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2025 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.core.streamsharing
+
+import android.graphics.Rect
+import android.util.Size
+
+/** Data class representing the preferred size information for a child. */
+internal data class PreferredChildSize(
+ /** The cropping rectangle to apply before scaling. */
+ val cropRectBeforeScaling: Rect,
+
+ /** The size of the child after scaling. */
+ val childSizeToScale: Size,
+
+ /**
+ * The original selected size from the child's preferred sizes before any scaling, cropping, or
+ * rotating.
+ */
+ val originalSelectedChildSize: Size
+)
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/ResolutionsMerger.java b/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/ResolutionsMerger.java
index 3ad3c04..deef99f 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/ResolutionsMerger.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/ResolutionsMerger.java
@@ -130,10 +130,11 @@
}
/**
- * Returns a preferred pair composed of a crop rect before scaling and a size after scaling.
+ * Returns a {@link PreferredChildSize} object containing the preferred size information for a
+ * child.
*
* <p>The first size in the child's ordered size list that does not require the parent to
- * upscale and does not cause double-cropping will be used to generate the pair, or {@code
+ * upscale and does not cause double-cropping will be used to generate the result, or {@code
* parentCropRect} will be used if no matching is found.
*
* <p>The returned crop rect and size will have the same aspect-ratio. When {@code
@@ -143,8 +144,10 @@
* <p>Notes that the input {@code childConfig} is expected to be one of the values that use to
* construct the {@link ResolutionsMerger}, if not an IllegalArgumentException will be thrown.
*/
- @NonNull Pair<Rect, Size> getPreferredChildSizePair(@NonNull UseCaseConfig<?> childConfig,
- @NonNull Rect parentCropRect, int sensorToBufferRotationDegrees,
+ @NonNull PreferredChildSize getPreferredChildSize(
+ @NonNull UseCaseConfig<?> childConfig,
+ @NonNull Rect parentCropRect,
+ int sensorToBufferRotationDegrees,
boolean isViewportSet) {
// For easier in following computations, width and height are reverted when the rotation
// degrees of sensor-to-buffer is 90 or 270.
@@ -154,27 +157,27 @@
isWidthHeightRevertedForComputation = true;
}
- // Get preferred child size pair.
- Pair<Rect, Size> pair = getPreferredChildSizePairInternal(parentCropRect, childConfig,
- isViewportSet);
- Rect cropRectBeforeScaling = pair.first;
- Size childSizeToScale = pair.second;
+ // Get preferred child size.
+ PreferredChildSize preferredChildSize = getPreferredChildSizeInternal(
+ parentCropRect, childConfig, isViewportSet);
// Restore the reversion of width and height
if (isWidthHeightRevertedForComputation) {
- childSizeToScale = reverseSize(childSizeToScale);
- cropRectBeforeScaling = reverseRect(cropRectBeforeScaling);
+ preferredChildSize = new PreferredChildSize(
+ reverseRect(preferredChildSize.getCropRectBeforeScaling()),
+ reverseSize(preferredChildSize.getChildSizeToScale()),
+ preferredChildSize.getOriginalSelectedChildSize());
}
- return new Pair<>(cropRectBeforeScaling, childSizeToScale);
-
+ return preferredChildSize;
}
- private @NonNull Pair<Rect, Size> getPreferredChildSizePairInternal(
+ private @NonNull PreferredChildSize getPreferredChildSizeInternal(
@NonNull Rect parentCropRect, @NonNull UseCaseConfig<?> childConfig,
boolean isViewportSet) {
Rect cropRectBeforeScaling;
Size childSizeToScale;
+ Size selectedChildSize;
if (isViewportSet) {
cropRectBeforeScaling = parentCropRect;
@@ -182,14 +185,16 @@
// When viewport is set, child size needs to be cropped to match viewport's
// aspect-ratio.
Size viewPortSize = rectToSize(parentCropRect);
- childSizeToScale = getPreferredChildSizeForViewport(viewPortSize, childConfig);
+ Pair<Size, Size> pair = getPreferredChildSizeForViewport(viewPortSize, childConfig);
+ selectedChildSize = pair.first;
+ childSizeToScale = pair.second;
} else {
Size parentSize = rectToSize(parentCropRect);
- childSizeToScale = getPreferredChildSize(parentSize, childConfig);
+ childSizeToScale = selectedChildSize = getPreferredChildSize(parentSize, childConfig);
cropRectBeforeScaling = getCropRectOfReferenceAspectRatio(parentSize, childSizeToScale);
}
- return new Pair<>(cropRectBeforeScaling, childSizeToScale);
+ return new PreferredChildSize(cropRectBeforeScaling, childSizeToScale, selectedChildSize);
}
/**
@@ -242,7 +247,7 @@
* construct the {@link ResolutionsMerger}, if not an IllegalArgumentException will be thrown.
*/
@VisibleForTesting
- @NonNull Size getPreferredChildSizeForViewport(@NonNull Size parentSize,
+ @NonNull Pair<Size, Size> getPreferredChildSizeForViewport(@NonNull Size parentSize,
@NonNull UseCaseConfig<?> childConfig) {
List<Size> candidateChildSizes = getSortedChildSizes(childConfig);
@@ -251,11 +256,11 @@
getCropRectOfReferenceAspectRatio(childSize, parentSize));
if (!hasUpscaling(childSizeToCrop, parentSize)) {
- return childSizeToCrop;
+ return Pair.create(childSize, childSizeToCrop);
}
}
- return parentSize;
+ return Pair.create(parentSize, parentSize);
}
private @NonNull List<Size> getCameraSupportedResolutions() {
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/StreamSharing.java b/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/StreamSharing.java
index ec4402b..565ee32 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/StreamSharing.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/StreamSharing.java
@@ -287,7 +287,10 @@
outputEdges.put(entry.getKey(), out.get(entry.getValue()));
}
- mVirtualCameraAdapter.setChildrenEdges(outputEdges);
+ Map<UseCase, Size> selectedChildSizeMap = mVirtualCameraAdapter.getSelectedChildSizes(
+ mSharingInputEdge, isViewportSet);
+
+ mVirtualCameraAdapter.setChildrenEdges(outputEdges, selectedChildSizeMap);
return List.of(mSessionConfigBuilder.build());
} else {
@@ -323,7 +326,11 @@
for (Map.Entry<UseCase, DualOutConfig> entry : outConfigMap.entrySet()) {
outputEdges.put(entry.getKey(), out.get(entry.getValue()));
}
- mVirtualCameraAdapter.setChildrenEdges(outputEdges);
+
+ Map<UseCase, Size> primarySelectedChildSizes =
+ mVirtualCameraAdapter.getSelectedChildSizes(mSharingInputEdge, isViewportSet);
+
+ mVirtualCameraAdapter.setChildrenEdges(outputEdges, primarySelectedChildSizes);
return List.of(mSessionConfigBuilder.build(),
mSecondarySessionConfigBuilder.build());
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/VirtualCameraAdapter.java b/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/VirtualCameraAdapter.java
index 3121dbd..d903782 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/VirtualCameraAdapter.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/streamsharing/VirtualCameraAdapter.java
@@ -37,7 +37,6 @@
import android.graphics.ImageFormat;
import android.graphics.Rect;
-import android.util.Pair;
import android.util.Range;
import android.util.Size;
import android.view.Surface;
@@ -222,7 +221,8 @@
/**
* Gets {@link OutConfig} for children {@link UseCase} based on the input edge.
*/
- @NonNull Map<UseCase, OutConfig> getChildrenOutConfigs(@NonNull SurfaceEdge sharingInputEdge,
+ @NonNull Map<UseCase, OutConfig> getChildrenOutConfigs(
+ @NonNull SurfaceEdge sharingInputEdge,
@ImageOutputConfig.RotationValue int parentTargetRotation, boolean isViewportSet) {
Map<UseCase, OutConfig> outConfigs = new HashMap<>();
for (UseCase useCase : mChildren) {
@@ -233,6 +233,24 @@
return outConfigs;
}
+ /**
+ * Gets original selected size for children {@link UseCase} based on the input edge.
+ */
+ @NonNull Map<UseCase, Size> getSelectedChildSizes(@NonNull SurfaceEdge sharingInputEdge,
+ boolean isViewportSet) {
+ Map<UseCase, Size> selectedChildSizes = new HashMap<>();
+ for (UseCase useCase : mChildren) {
+ PreferredChildSize preferredChildSize = mResolutionsMerger
+ .getPreferredChildSize(
+ requireNonNull(mChildrenConfigsMap.get(useCase)),
+ sharingInputEdge.getCropRect(),
+ getRotationDegrees(sharingInputEdge.getSensorToBufferTransform()),
+ isViewportSet);
+ selectedChildSizes.put(useCase, preferredChildSize.getOriginalSelectedChildSize());
+ }
+ return selectedChildSizes;
+ }
+
@NonNull Map<UseCase, DualOutConfig> getChildrenOutConfigs(
@NonNull SurfaceEdge primaryInputEdge,
@NonNull SurfaceEdge secondaryInputEdge,
@@ -247,7 +265,7 @@
parentTargetRotation, isViewportSet);
// secondary
OutConfig secondaryOutConfig = calculateOutConfig(
- useCase, mSecondaryResolutionsMerger,
+ useCase, requireNonNull(mSecondaryResolutionsMerger),
requireNonNull(mSecondaryParentCamera),
secondaryInputEdge,
parentTargetRotation, isViewportSet);
@@ -270,14 +288,14 @@
.getSensorRotationDegrees(parentTargetRotation);
boolean parentIsMirrored = isMirrored(
cameraInputEdge.getSensorToBufferTransform());
- Pair<Rect, Size> preferredSizePair = resolutionsMerger
- .getPreferredChildSizePair(
+ PreferredChildSize preferredChildSize = resolutionsMerger
+ .getPreferredChildSize(
requireNonNull(mChildrenConfigsMap.get(useCase)),
cameraInputEdge.getCropRect(),
getRotationDegrees(cameraInputEdge.getSensorToBufferTransform()),
isViewportSet);
- Rect cropRectBeforeScaling = preferredSizePair.first;
- Size childSizeToScale = preferredSizePair.second;
+ Rect cropRectBeforeScaling = preferredChildSize.getCropRectBeforeScaling();
+ Size childSizeToScale = preferredChildSize.getChildSizeToScale();
// Only use primary camera info for output surface
int childRotationDegrees = getChildRotationDegrees(useCase, mParentCamera);
@@ -299,7 +317,8 @@
/**
* Update children {@link SurfaceEdge} calculated by {@link StreamSharing}.
*/
- void setChildrenEdges(@NonNull Map<UseCase, SurfaceEdge> childrenEdges) {
+ void setChildrenEdges(@NonNull Map<UseCase, SurfaceEdge> childrenEdges,
+ @NonNull Map<UseCase, @NonNull Size> selectedChildSizes) {
mChildrenEdges.clear();
mChildrenEdges.putAll(childrenEdges);
for (Map.Entry<UseCase, SurfaceEdge> entry : mChildrenEdges.entrySet()) {
@@ -307,7 +326,9 @@
SurfaceEdge surfaceEdge = entry.getValue();
useCase.setViewPortCropRect(surfaceEdge.getCropRect());
useCase.setSensorToBufferTransformMatrix(surfaceEdge.getSensorToBufferTransform());
- useCase.updateSuggestedStreamSpec(surfaceEdge.getStreamSpec(), null);
+ StreamSpec streamSpec = getChildStreamSpec(useCase, surfaceEdge.getStreamSpec(),
+ selectedChildSizes);
+ useCase.updateSuggestedStreamSpec(streamSpec, null);
useCase.notifyState();
}
}
@@ -401,6 +422,17 @@
return cameraInternal.getCameraInfo().getSensorRotationDegrees(childTargetRotation);
}
+ @NonNull
+ private static StreamSpec getChildStreamSpec(@NonNull UseCase useCase,
+ @NonNull StreamSpec baseStreamSpec, @NonNull Map<UseCase, Size> selectedChildSizes) {
+ StreamSpec.Builder builder = baseStreamSpec.toBuilder();
+ Size selectedChildSize = selectedChildSizes.get(useCase);
+ if (selectedChildSize != null) {
+ builder.setOriginalConfiguredResolution(selectedChildSize);
+ }
+ return builder.build();
+ }
+
private static int getChildFormat(@NonNull UseCase useCase) {
return useCase instanceof ImageCapture ? ImageFormat.JPEG
: INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE;
@@ -517,8 +549,8 @@
Range<Integer> resolvedTargetFrameRate = StreamSpec.FRAME_RATE_RANGE_UNSPECIFIED;
for (UseCaseConfig<?> useCaseConfig : useCaseConfigs) {
- Range<Integer> targetFrameRate = useCaseConfig.getTargetFrameRate(
- resolvedTargetFrameRate);
+ Range<Integer> targetFrameRate = requireNonNull(useCaseConfig.getTargetFrameRate(
+ resolvedTargetFrameRate));
if (StreamSpec.FRAME_RATE_RANGE_UNSPECIFIED.equals(resolvedTargetFrameRate)) {
resolvedTargetFrameRate = targetFrameRate;
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/streamsharing/ResolutionsMergerTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/streamsharing/ResolutionsMergerTest.kt
index e7bbbd3..0a227b2 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/streamsharing/ResolutionsMergerTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/streamsharing/ResolutionsMergerTest.kt
@@ -18,6 +18,7 @@
import android.graphics.Rect
import android.os.Build
+import android.util.Pair
import android.util.Rational
import android.util.Size
import androidx.camera.core.impl.ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE
@@ -337,7 +338,7 @@
}
@Test(expected = IllegalArgumentException::class)
- fun getPreferredChildSizePair_whenConfigNotPassedToConstructor_throwsException() {
+ fun getPreferredChildSize_whenUseCaseConfigNotPassedToConstructor_throwsException() {
// Arrange.
val config = createUseCaseConfig()
val sorter = FakeSupportedOutputSizesSorter(mapOf(config to SIZES_16_9))
@@ -345,11 +346,11 @@
// Act.
val useCaseConfigNotPassed = createUseCaseConfig()
- merger.getPreferredChildSizePair(useCaseConfigNotPassed, SIZE_1920_1440.toRect(), 0, false)
+ merger.getPreferredChildSize(useCaseConfigNotPassed, SIZE_1920_1440.toRect(), 0, false)
}
@Test
- fun getPreferredChildSizePair_whenViewportIsNotSet_canReturnCorrectly() {
+ fun getPreferredChildSize_whenViewportIsNotSet_canReturnCorrectly() {
// Arrange.
val config = createUseCaseConfig()
val candidateChildSizes =
@@ -367,21 +368,18 @@
// Act & Assert, should returns the first child size that do not need upscale and cause
// double-cropping.
- merger
- .getPreferredChildSizePair(config, SIZE_2560_1440.toRect(), 0, false)
- .containsExactly(SIZE_2560_1440.toRect(), SIZE_1920_1080)
- merger
- .getPreferredChildSizePair(config, SIZE_1280_720.toRect(), 0, false)
- .containsExactly(SIZE_1280_720.toRect(), SIZE_960_540)
+ assertThat(merger.getPreferredChildSize(config, SIZE_2560_1440.toRect(), 0, false))
+ .isEqualTo(PreferredChildSize(SIZE_2560_1440.toRect(), SIZE_1920_1080, SIZE_1920_1080))
+ assertThat(merger.getPreferredChildSize(config, SIZE_1280_720.toRect(), 0, false))
+ .isEqualTo(PreferredChildSize(SIZE_1280_720.toRect(), SIZE_960_540, SIZE_960_540))
// Act & Assert, should returns parent size when no matching.
- merger
- .getPreferredChildSizePair(config, SIZE_192_108.toRect(), 0, false)
- .containsExactly(SIZE_192_108.toRect(), SIZE_192_108)
+ assertThat(merger.getPreferredChildSize(config, SIZE_192_108.toRect(), 0, false))
+ .isEqualTo(PreferredChildSize(SIZE_192_108.toRect(), SIZE_192_108, SIZE_192_108))
}
@Test
- fun getPreferredChildSizePair_whenViewportIsSet_canReturnCorrectly() {
+ fun getPreferredChildSize_whenViewportIsSet_canReturnCorrectly() {
// Arrange.
val config = createUseCaseConfig()
val candidateChildSizes =
@@ -396,25 +394,22 @@
// Act & Assert, should returns 1:1 crop rect and size, that are generated from the first
// child size that do not need upscale.
val rect1440To1440 = SIZE_2560_1920.crop(Size(1440, 1440))
- merger
- .getPreferredChildSizePair(config, rect1440To1440, 0, true)
- .containsExactly(rect1440To1440, Size(1080, 1080))
+ assertThat(merger.getPreferredChildSize(config, rect1440To1440, 0, true))
+ .isEqualTo(PreferredChildSize(rect1440To1440, Size(1080, 1080), SIZE_1920_1080))
val rect720To720 = SIZE_1280_720.crop(Size(720, 720))
- merger
- .getPreferredChildSizePair(config, rect720To720, 0, true)
- .containsExactly(rect720To720, Size(540, 540))
+ assertThat(merger.getPreferredChildSize(config, rect720To720, 0, true))
+ .isEqualTo(PreferredChildSize(rect720To720, Size(540, 540), SIZE_960_540))
// Act & Assert, should returns crop rect and size, that are generated from parent size
// when no matching.
val size108To108 = Size(108, 108)
val rect108To108 = SIZE_192_108.crop(size108To108)
- merger
- .getPreferredChildSizePair(config, rect108To108, 0, true)
- .containsExactly(rect108To108, size108To108)
+ assertThat(merger.getPreferredChildSize(config, rect108To108, 0, true))
+ .isEqualTo(PreferredChildSize(rect108To108, size108To108, size108To108))
}
@Test
- fun getPreferredChildSizePair_whenViewportIsSetAndRotationIs90_canReturnCorrectly() {
+ fun getPreferredChildSize_whenViewportIsSetAndRotationIs90_canReturnCorrectly() {
// Arrange.
val config = createUseCaseConfig()
val candidateChildSizes =
@@ -429,20 +424,18 @@
// Act & Assert, should returns 1:2 crop rect and size, that are generated from the first
// child size that do not need upscale.
val rect1280To2560 = SIZE_2560_1440.crop(Size(2560, 1280)).reverse()
- merger
- .getPreferredChildSizePair(config, rect1280To2560, 90, true)
- .containsExactly(rect1280To2560, Size(960, 1920))
+ assertThat(merger.getPreferredChildSize(config, rect1280To2560, 90, true))
+ .isEqualTo(PreferredChildSize(rect1280To2560, Size(960, 1920), SIZE_1920_1080))
val rect640To1280 = SIZE_1280_720.crop(Size(1280, 640)).reverse()
- merger
- .getPreferredChildSizePair(config, rect640To1280, 90, true)
- .containsExactly(rect640To1280, Size(480, 960))
+ assertThat(merger.getPreferredChildSize(config, rect640To1280, 90, true))
+ .isEqualTo(PreferredChildSize(rect640To1280, Size(480, 960), SIZE_960_540))
// Act & Assert, should returns crop rect and size, that are generated from parent size
// when no matching.
- val rect96To192 = SIZE_192_108.crop(Size(192, 96)).reverse()
- merger
- .getPreferredChildSizePair(config, rect96To192, 90, true)
- .containsExactly(rect96To192, rectToSize(rect96To192))
+ val size192To96 = Size(192, 96)
+ val rect96To192 = SIZE_192_108.crop(size192To96).reverse()
+ assertThat(merger.getPreferredChildSize(config, rect96To192, 90, true))
+ .isEqualTo(PreferredChildSize(rect96To192, rectToSize(rect96To192), size192To96))
}
@Test(expected = IllegalArgumentException::class)
@@ -531,13 +524,13 @@
// Act & Assert, should returns the first child size that can be cropped to parent
// aspect-ratio and do not cause upscaling.
assertThat(merger.getPreferredChildSizeForViewport(SIZE_2560_1920, config))
- .isEqualTo(SIZE_1920_1440)
+ .isEqualTo(Pair.create(SIZE_1920_1440, SIZE_1920_1440))
assertThat(merger.getPreferredChildSizeForViewport(SIZE_1280_960, config))
- .isEqualTo(SIZE_960_720)
+ .isEqualTo(Pair.create(SIZE_960_720, SIZE_960_720))
// Act & Assert, should returns parent size when no matching.
assertThat(merger.getPreferredChildSizeForViewport(SIZE_640_480, config))
- .isEqualTo(SIZE_640_480)
+ .isEqualTo(Pair.create(SIZE_640_480, SIZE_640_480))
}
@Test
@@ -556,13 +549,13 @@
// Act & Assert, should returns the first child size that can be cropped to parent
// aspect-ratio and do not cause upscaling.
assertThat(merger.getPreferredChildSizeForViewport(SIZE_1920_1440, config))
- .isEqualTo(Size(1440, 1080))
+ .isEqualTo(Pair.create(SIZE_1920_1080, Size(1440, 1080)))
assertThat(merger.getPreferredChildSizeForViewport(SIZE_1280_960, config))
- .isEqualTo(SIZE_960_720)
+ .isEqualTo(Pair.create(SIZE_1280_720, SIZE_960_720))
// Act & Assert, should returns parent size when no matching.
assertThat(merger.getPreferredChildSizeForViewport(SIZE_640_480, config))
- .isEqualTo(SIZE_640_480)
+ .isEqualTo(Pair.create(SIZE_640_480, SIZE_640_480))
}
@Test
@@ -776,11 +769,6 @@
return FakeUseCaseConfig.Builder().useCaseConfig
}
- private fun android.util.Pair<Rect, Size>.containsExactly(rect: Rect, size: Size) {
- assertThat(first).isEqualTo(rect)
- assertThat(second).isEqualTo(size)
- }
-
private fun Rect.hasMatchingAspectRatio(resolution: Size): Boolean {
return AspectRatioUtil.hasMatchingAspectRatio(resolution, Rational(width(), height()))
}
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/streamsharing/VirtualCameraAdapterTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/streamsharing/VirtualCameraAdapterTest.kt
index 2d8f0f8..1049990 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/streamsharing/VirtualCameraAdapterTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/streamsharing/VirtualCameraAdapterTest.kt
@@ -97,6 +97,8 @@
Pair(child1 as UseCase, createSurfaceEdge()),
Pair(child2 as UseCase, createSurfaceEdge())
)
+ private val selectedChildSizes =
+ mapOf<UseCase, Size>(child1 to INPUT_SIZE, child2 to INPUT_SIZE)
private val useCaseConfigFactory = FakeUseCaseConfigFactory()
private lateinit var adapter: VirtualCameraAdapter
private var snapshotTriggered = false
@@ -223,7 +225,7 @@
fun setUseCaseActiveAndInactive_surfaceConnectsAndDisconnects() {
// Arrange.
adapter.bindChildren()
- adapter.setChildrenEdges(childrenEdges)
+ adapter.setChildrenEdges(childrenEdges, selectedChildSizes)
child1.updateSessionConfigForTesting(SESSION_CONFIG_WITH_SURFACE)
// Assert: edge open by default.
verifyEdge(child1, OPEN, NO_PROVIDER)
@@ -242,7 +244,7 @@
fun resetWithClosedChildSurface_invokesErrorListener() {
// Arrange.
adapter.bindChildren()
- adapter.setChildrenEdges(childrenEdges)
+ adapter.setChildrenEdges(childrenEdges, selectedChildSizes)
child1.updateSessionConfigForTesting(SESSION_CONFIG_WITH_SURFACE)
child1.notifyActiveForTesting()
@@ -260,7 +262,7 @@
fun resetUseCase_edgeInvalidated() {
// Arrange: setup and get the old DeferrableSurface.
adapter.bindChildren()
- adapter.setChildrenEdges(childrenEdges)
+ adapter.setChildrenEdges(childrenEdges, selectedChildSizes)
child1.updateSessionConfigForTesting(SESSION_CONFIG_WITH_SURFACE)
child1.notifyActiveForTesting()
val oldSurface = childrenEdges[child1]!!.deferrableSurfaceForTesting
@@ -277,7 +279,7 @@
fun updateUseCaseWithAndWithoutSurface_surfaceConnectsAndDisconnects() {
// Arrange
adapter.bindChildren()
- adapter.setChildrenEdges(childrenEdges)
+ adapter.setChildrenEdges(childrenEdges, selectedChildSizes)
child1.notifyActiveForTesting()
verifyEdge(child1, OPEN, NO_PROVIDER)
@@ -364,10 +366,17 @@
@Test
fun updateChildrenSpec_updateAndNotifyChildren() {
// Act: update children with the map.
- adapter.setChildrenEdges(childrenEdges)
- // Assert: surface size, crop rect and transformation propagated to children
+ val selectedChildSizes =
+ mapOf<UseCase, Size>(child1 to Size(400, 300), child2 to Size(720, 480))
+ adapter.setChildrenEdges(childrenEdges, selectedChildSizes)
+ // Assert: surface size, original selected size, crop rect and transformation propagated
+ // to children
assertThat(child1.attachedStreamSpec!!.resolution).isEqualTo(INPUT_SIZE)
+ assertThat(child1.attachedStreamSpec!!.originalConfiguredResolution)
+ .isEqualTo(Size(400, 300))
assertThat(child2.attachedStreamSpec!!.resolution).isEqualTo(INPUT_SIZE)
+ assertThat(child2.attachedStreamSpec!!.originalConfiguredResolution)
+ .isEqualTo(Size(720, 480))
assertThat(child1.viewPortCropRect).isEqualTo(CROP_RECT)
assertThat(child2.viewPortCropRect).isEqualTo(CROP_RECT)
assertThat(child1.sensorToBufferTransformMatrix).isEqualTo(SENSOR_TO_BUFFER)
diff --git a/camera/camera-video/api/current.txt b/camera/camera-video/api/current.txt
index 0e6ea1c..0c6fcf2 100644
--- a/camera/camera-video/api/current.txt
+++ b/camera/camera-video/api/current.txt
@@ -162,6 +162,7 @@
method public int getMirrorMode();
method public T getOutput();
method public androidx.camera.core.ResolutionInfo? getResolutionInfo();
+ method public androidx.camera.video.Quality? getSelectedQuality();
method public android.util.Range<java.lang.Integer!> getTargetFrameRate();
method public int getTargetRotation();
method public boolean isVideoStabilizationEnabled();
diff --git a/camera/camera-video/api/restricted_current.txt b/camera/camera-video/api/restricted_current.txt
index 0e6ea1c..0c6fcf2 100644
--- a/camera/camera-video/api/restricted_current.txt
+++ b/camera/camera-video/api/restricted_current.txt
@@ -162,6 +162,7 @@
method public int getMirrorMode();
method public T getOutput();
method public androidx.camera.core.ResolutionInfo? getResolutionInfo();
+ method public androidx.camera.video.Quality? getSelectedQuality();
method public android.util.Range<java.lang.Integer!> getTargetFrameRate();
method public int getTargetRotation();
method public boolean isVideoStabilizationEnabled();
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 43e805a..b947810 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
@@ -56,6 +56,7 @@
import static androidx.camera.video.internal.utils.DynamicRangeUtil.videoProfileHdrFormatsToDynamicRangeEncoding;
import static androidx.core.util.Preconditions.checkState;
+import static java.util.Collections.emptyMap;
import static java.util.Collections.singletonList;
import static java.util.Objects.requireNonNull;
@@ -145,6 +146,7 @@
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -195,6 +197,7 @@
private boolean mHasCompensatingTransformation = false;
private @Nullable SourceStreamRequirementObserver mSourceStreamRequirementObserver;
private SessionConfig.@Nullable CloseableErrorListener mCloseableErrorListener;
+ private Map<Quality, List<Size>> mQualityToCustomSizesMap = emptyMap();
/**
* Create a VideoCapture associated with the given {@link VideoOutput}.
@@ -338,6 +341,44 @@
return getResolutionInfoInternal();
}
+ /**
+ * Returns the selected Quality.
+ *
+ * <p>The selected Quality represents the final quality level chosen for the stream. The
+ * selected Quality will be one of the specified qualities from the {@link QualitySelector}
+ * provided by the associated {@link VideoOutput}. If {@link Quality#HIGHEST} or
+ * {@link Quality#LOWEST} is specified in the selector, it will be resolved to an actual
+ * Quality value. Even if the stream is later cropped (e.g., by using a {@link ViewPort}), this
+ * value represents the original quality level of the stream.
+ *
+ * <p>This method will return the selected Quality only after the use case is bound using
+ * {@link androidx.camera.lifecycle.ProcessCameraProvider#bindToLifecycle}. Otherwise, it
+ * will return null. The selected Quality may change if the use case is unbound and then
+ * rebound.
+ *
+ * @return The selected Quality if the use case is bound, or null otherwise.
+ */
+ public @Nullable Quality getSelectedQuality() {
+ StreamSpec streamSpec = getAttachedStreamSpec();
+ if (streamSpec == null) {
+ return null;
+ }
+ // In the general case, there should be an exact match from configured resolution to
+ // Quality.
+ Size configuredResolution = streamSpec.getOriginalConfiguredResolution();
+ for (Map.Entry<Quality, List<Size>> entry : mQualityToCustomSizesMap.entrySet()) {
+ if (entry.getValue().contains(configuredResolution)) {
+ return entry.getKey(); // Found exact match, no need to check further
+ }
+ }
+ Logger.w(TAG, "Can't find matched Quality for " + configuredResolution);
+
+ // Fallback to find the nearest available quality. This can occur when StreamSharing
+ // is unable to downscale/crop the camera stream according to the UseCase's preferred
+ // resolution and instead returns the original camera stream resolution.
+ return findNearestSizeFor(mQualityToCustomSizesMap, configuredResolution);
+ }
+
@RestrictTo(Scope.LIBRARY_GROUP)
@Override
protected @Nullable ResolutionInfo getResolutionInfoInternal() {
@@ -1456,64 +1497,83 @@
requestedDynamicRange);
QualityRatioToResolutionsTable qualityRatioTable = new QualityRatioToResolutionsTable(
cameraInfo.getSupportedResolutions(getImageFormat()), supportedQualityToSizeMap);
- List<Size> customOrderedResolutions = new ArrayList<>();
+ // Use LinkedHashMap to maintain the order.
+ LinkedHashMap<Quality, List<Size>> orderedQualityToSizesMap = new LinkedHashMap<>();
for (Quality selectedQuality : selectedQualities) {
- customOrderedResolutions.addAll(
+ orderedQualityToSizesMap.put(selectedQuality,
qualityRatioTable.getResolutions(selectedQuality, aspectRatio));
}
- List<Size> filteredCustomOrderedResolutions = filterOutEncoderUnsupportedResolutions(
- (VideoCaptureConfig<T>) builder.getUseCaseConfig(), mediaSpec,
- requestedDynamicRange, videoCapabilities, customOrderedResolutions,
- supportedQualityToSizeMap);
+ LinkedHashMap<Quality, List<Size>> filteredOrderedQualityToSizesMap =
+ filterOutEncoderUnsupportedResolutions(
+ (VideoCaptureConfig<T>) builder.getUseCaseConfig(), mediaSpec,
+ requestedDynamicRange, videoCapabilities, orderedQualityToSizesMap,
+ supportedQualityToSizeMap);
+ List<Size> filteredCustomOrderedResolutions = new ArrayList<>();
+ for (List<Size> resolutions : filteredOrderedQualityToSizesMap.values()) {
+ filteredCustomOrderedResolutions.addAll(resolutions);
+ }
Logger.d(TAG, "Set custom ordered resolutions = " + filteredCustomOrderedResolutions);
builder.getMutableConfig().insertOption(OPTION_CUSTOM_ORDERED_RESOLUTIONS,
filteredCustomOrderedResolutions);
+ mQualityToCustomSizesMap = filteredOrderedQualityToSizesMap;
}
- private static @NonNull List<Size> filterOutEncoderUnsupportedResolutions(
+ private static @NonNull LinkedHashMap<Quality, List<Size>>
+ filterOutEncoderUnsupportedResolutions(
@NonNull VideoCaptureConfig<?> config,
@NonNull MediaSpec mediaSpec,
@NonNull DynamicRange dynamicRange,
@NonNull VideoCapabilities videoCapabilities,
- @NonNull List<Size> resolutions,
+ @NonNull LinkedHashMap<Quality, List<Size>> qualityToSizesOrderedMap,
@NonNull Map<Quality, Size> supportedQualityToSizeMap
) {
- if (resolutions.isEmpty()) {
- return resolutions;
+ if (qualityToSizesOrderedMap.isEmpty()) {
+ return new LinkedHashMap<>();
}
- Iterator<Size> iterator = resolutions.iterator();
- while (iterator.hasNext()) {
- Size resolution = iterator.next();
- // To improve performance, there is no need to check for supported qualities'
- // resolutions because the encoder should support them.
- if (supportedQualityToSizeMap.containsValue(resolution)) {
- continue;
+ LinkedHashMap<Quality, List<Size>> filteredQualityToSizesOrderedMap = new LinkedHashMap<>();
+ for (Map.Entry<Quality, List<Size>> entry : qualityToSizesOrderedMap.entrySet()) {
+ // Copy the size list first and filter out the unsupported resolutions.
+ List<Size> filteredSizeList = new ArrayList<>(entry.getValue());
+ Iterator<Size> sizeIterator = filteredSizeList.iterator();
+ while (sizeIterator.hasNext()) {
+ Size resolution = sizeIterator.next();
+ // To improve performance, there is no need to check for supported qualities'
+ // resolutions because the encoder should support them.
+ if (supportedQualityToSizeMap.containsValue(resolution)) {
+ continue;
+ }
+ // We must find EncoderProfiles for each resolution because the EncoderProfiles
+ // found by resolution may contain different video mine type which leads to
+ // different codec.
+ VideoValidatedEncoderProfilesProxy encoderProfiles =
+ videoCapabilities.findNearestHigherSupportedEncoderProfilesFor(resolution,
+ dynamicRange);
+ if (encoderProfiles == null) {
+ continue;
+ }
+ // If the user set a non-fully specified target DynamicRange, there could be
+ // multiple videoProfiles that matches to the DynamicRange. Find the one with the
+ // largest supported size as a workaround.
+ // If the suggested StreamSpec(i.e. DynamicRange + resolution) is unfortunately over
+ // codec supported size, then rely on surface processing (OpenGL) to resize the
+ // camera stream.
+ VideoEncoderInfo videoEncoderInfo = findLargestSupportedSizeVideoEncoderInfo(
+ config.getVideoEncoderInfoFinder(), encoderProfiles, dynamicRange,
+ mediaSpec, resolution,
+ requireNonNull(config.getTargetFrameRate(Defaults.DEFAULT_FPS_RANGE)));
+ if (videoEncoderInfo != null && !videoEncoderInfo.isSizeSupportedAllowSwapping(
+ resolution.getWidth(), resolution.getHeight())) {
+ sizeIterator.remove();
+ }
}
- // We must find EncoderProfiles for each resolution because the EncoderProfiles found
- // by resolution may contain different video mine type which leads to different codec.
- VideoValidatedEncoderProfilesProxy encoderProfiles =
- videoCapabilities.findNearestHigherSupportedEncoderProfilesFor(resolution,
- dynamicRange);
- if (encoderProfiles == null) {
- continue;
- }
- // If the user set a non-fully specified target DynamicRange, there could be multiple
- // videoProfiles that matches to the DynamicRange. Find the one with the largest
- // supported size as a workaround.
- // If the suggested StreamSpec(i.e. DynamicRange + resolution) is unfortunately over
- // codec supported size, then rely on surface processing (OpenGL) to resize the
- // camera stream.
- VideoEncoderInfo videoEncoderInfo = findLargestSupportedSizeVideoEncoderInfo(
- config.getVideoEncoderInfoFinder(), encoderProfiles, dynamicRange,
- mediaSpec, resolution,
- requireNonNull(config.getTargetFrameRate(Defaults.DEFAULT_FPS_RANGE)));
- if (videoEncoderInfo != null && !videoEncoderInfo.isSizeSupportedAllowSwapping(
- resolution.getWidth(), resolution.getHeight())) {
- iterator.remove();
+
+ // Put the filtered size list only when it is not empty.
+ if (!filteredSizeList.isEmpty()) {
+ filteredQualityToSizesOrderedMap.put(entry.getKey(), filteredSizeList);
}
}
- return resolutions;
+ return filteredQualityToSizesOrderedMap;
}
private static @Nullable VideoEncoderInfo findLargestSupportedSizeVideoEncoderInfo(
@@ -1556,6 +1616,32 @@
}
/**
+ * Finds the Quality with the size closest to the target size based on area.
+ *
+ * @param sizeMap The map of Quality to a list of Size`s.
+ * @param targetSize The target size to compare against.
+ * @return The Quality with the closest size, or `null` if no match is found.
+ */
+ private static @Nullable Quality findNearestSizeFor(
+ @NonNull Map<Quality, List<Size>> sizeMap, @NonNull Size targetSize) {
+ int targetArea = getArea(targetSize);
+ Quality nearestQuality = null;
+ int minAreaDiff = Integer.MAX_VALUE;
+
+ for (Map.Entry<Quality, List<Size>> entry : sizeMap.entrySet()) {
+ for (Size size : entry.getValue()) {
+ int areaDiff = Math.abs(getArea(size) - targetArea);
+ if (areaDiff < minAreaDiff) {
+ minAreaDiff = areaDiff;
+ nearestQuality = entry.getKey();
+ }
+ }
+ }
+
+ return nearestQuality;
+ }
+
+ /**
* Gets the snapshot value of the given {@link Observable}.
*
* <p>Note: Set {@code valueIfMissing} to a non-{@code null} value doesn't mean the method
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 4641453..f0387ef 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
@@ -1847,6 +1847,74 @@
)
}
+ @Test
+ fun verifySelectedQuality_matchConfiguredResolution() {
+ testSelectedQualityIsExpected(
+ streamSpecConfiguredResolution = RESOLUTION_720P,
+ qualitySelector = QualitySelector.fromOrderedList(listOf(FHD, HD, SD)),
+ expectedQuality = HD,
+ )
+ }
+
+ @Test
+ fun verifySelectedQuality_setHighestQuality_returnSpecificQuality() {
+ testSelectedQualityIsExpected(
+ streamSpecConfiguredResolution = RESOLUTION_1080P,
+ qualitySelector = QualitySelector.from(HIGHEST),
+ expectedQuality = FHD,
+ )
+ }
+
+ @Test
+ fun verifySelectedQuality_setLowestQuality_returnSpecificQuality() {
+ testSelectedQualityIsExpected(
+ streamSpecConfiguredResolution = RESOLUTION_480P,
+ qualitySelector = QualitySelector.from(LOWEST),
+ expectedQuality = SD,
+ )
+ }
+
+ @Test
+ fun verifySelectedQuality_configuredResolutionNotMatch_returnNearestQuality() {
+ testSelectedQualityIsExpected(
+ streamSpecConfiguredResolution = Size(1920, 1000),
+ qualitySelector = QualitySelector.fromOrderedList(listOf(FHD, HD, SD)),
+ expectedQuality = FHD,
+ )
+ }
+
+ private fun testSelectedQualityIsExpected(
+ streamSpecConfiguredResolution: Size,
+ streamSpecResolution: Size = streamSpecConfiguredResolution,
+ qualitySelector: QualitySelector,
+ profiles: Map<Int, EncoderProfilesProxy> = FULL_QUALITY_PROFILES_MAP,
+ videoCapabilities: VideoCapabilities = FULL_QUALITY_VIDEO_CAPABILITIES,
+ expectedQuality: Quality?
+ ) {
+ // Arrange.
+ setupCamera(profiles = profiles)
+ createCameraUseCaseAdapter()
+ setSuggestedStreamSpec(
+ resolution = streamSpecResolution,
+ originalConfiguredResolution = streamSpecConfiguredResolution
+ )
+ val videoOutput =
+ createVideoOutput(
+ videoCapabilities = videoCapabilities,
+ mediaSpec =
+ MediaSpec.builder()
+ .configureVideo { it.setQualitySelector(qualitySelector) }
+ .build()
+ )
+ val videoCapture = createVideoCapture(videoOutput = videoOutput)
+
+ // Act.
+ addAndAttachUseCases(videoCapture)
+
+ // Assert.
+ assertThat(videoCapture.selectedQuality).isEqualTo(expectedQuality)
+ }
+
private fun testResolutionInfoContainsExpected(
resolution: Size,
sensorRotationDegrees: Int,
@@ -2101,6 +2169,7 @@
private fun setSuggestedStreamSpec(
resolution: Size,
+ originalConfiguredResolution: Size? = null,
expectedFrameRate: Range<Int> = StreamSpec.FRAME_RATE_RANGE_UNSPECIFIED,
dynamicRange: DynamicRange? = null
) {
@@ -2109,6 +2178,9 @@
.apply {
setExpectedFrameRateRange(expectedFrameRate)
dynamicRange?.let { setDynamicRange(dynamicRange) }
+ originalConfiguredResolution?.let {
+ setOriginalConfiguredResolution(originalConfiguredResolution)
+ }
}
.build()
)