diff --git a/README.md b/README.md index b13307a74..9cae917a7 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,8 @@ This feature offers multiple stabilization modes to enhance video smoothness and following stabilization modes are offered as options by JCA * **Auto:** Automatically enables stabilization based on the device's capabilities and current - recording settings. + recording settings. In image-only capture mode, “Auto” uses optical stabilization if the lens + supports it and does not turn on preview stabilization, which crops the captured image. * **On:** Activates stabilization for both the preview and recorded video streams, providing a smooth viewing experience during capture and playback. * **High Quality:** Applies a high-quality stabilization algorithm to the recorded video stream, diff --git a/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt index 3dc22a66d..9d56d3d18 100644 --- a/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt +++ b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt @@ -351,6 +351,54 @@ class CameraXCameraSystemTest { runSetStabilizationModeTest(StabilizationMode.OFF, CaptureMode.IMAGE_ONLY) } + @Test + fun setStabilizationMode_on_imageOnly_updatesCameraState(): Unit = runBlocking { + runSetStabilizationModeTest(StabilizationMode.ON, CaptureMode.IMAGE_ONLY) + } + + @Test + fun setStabilizationMode_optical_imageOnly_updatesCameraState(): Unit = runBlocking { + runSetStabilizationModeTest(StabilizationMode.OPTICAL, CaptureMode.IMAGE_ONLY) + } + + @Test + fun autoStabilization_imageOnly_doesNotUsePreviewStabilization(): Unit = runBlocking { + // Arrange. + val cameraSystem = createAndInitCameraXCameraSystem( + appSettings = CameraAppSettings(stabilizationMode = StabilizationMode.AUTO) + ) + val supportedStabilizationModes = + cameraSystem.getSystemConstraints().value?.forCurrentLens( + DEFAULT_CAMERA_APP_SETTINGS + )?.supportedStabilizationModes + assume().withMessage("Preview stabilization not supported, skip the test.") + .that(supportedStabilizationModes?.contains(StabilizationMode.ON)).isTrue() + val expectedImageOnlyMode = + if (supportedStabilizationModes?.contains(StabilizationMode.OPTICAL) == true) { + StabilizationMode.OPTICAL + } else { + StabilizationMode.OFF + } + + cameraSystem.startCameraAndWaitUntilRunning() + val stabilizationCheck: ReceiveChannel = + cameraSystem.getCurrentCameraState() + .map { it.stabilizationMode } + .produceIn(this) + + // Ensure AUTO resolves to preview stabilization while video can be recorded + stabilizationCheck.awaitValue(StabilizationMode.ON) + + // Act. + cameraSystem.setCaptureMode(CaptureMode.IMAGE_ONLY) + + // Assert. + stabilizationCheck.awaitValue(expectedImageOnlyMode) + + // Clean-up. + stabilizationCheck.cancel() + } + private suspend fun CoroutineScope.runSetStabilizationModeTest( stabilizationMode: StabilizationMode, captureMode: CaptureMode? = null diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt index cd1fb7405..d2e4a1c61 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt @@ -971,7 +971,13 @@ private fun createPreviewUseCase( } when (effectiveStabilizationMode) { StabilizationMode.ON -> setPreviewStabilizationEnabled(true) - StabilizationMode.OPTICAL -> setOpticalStabilizationModeEnabled(true) + StabilizationMode.OPTICAL -> { + setOpticalStabilizationModeEnabled(true) + // Explicitly disable preview and video stabilization. If left unset, the camera's + // default applies, which can turn on video stabilization when there isn't a video + // capture use case attached, such as in IMAGE_ONLY mode + setPreviewStabilizationEnabled(false) + } StabilizationMode.OFF -> { setOpticalStabilizationModeEnabled(false) // Setting this to false in Preview use case will disable video stabilization, even in diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt index 3ce2d87be..e8bbfc09e 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt @@ -642,6 +642,7 @@ class CameraXCameraSystem( val resolvedStabilizationMode = resolveStabilizationMode( requestedStabilizationMode = currentCameraSettings.stabilizationMode, + captureMode = currentCameraSettings.captureMode, targetFrameRate = currentCameraSettings.targetFrameRate, cameraConstraints = cameraConstraints ) @@ -729,29 +730,6 @@ class CameraXCameraSystem( } } - private fun resolveStabilizationMode( - requestedStabilizationMode: StabilizationMode, - targetFrameRate: Int, - cameraConstraints: CameraConstraints - ): StabilizationMode = with(cameraConstraints) { - // Convert AUTO stabilization mode to the first supported stabilization mode - val stabilizationMode = if (requestedStabilizationMode == StabilizationMode.AUTO) { - // Choose between ON, OPTICAL, or OFF, depending on support, in that order - sequenceOf(StabilizationMode.ON, StabilizationMode.OPTICAL, StabilizationMode.OFF) - .first { - it in supportedStabilizationModes && - targetFrameRate !in it.unsupportedFpsSet - } - } else { - requestedStabilizationMode - } - - // Check that the stabilization mode can be supported, otherwise return OFF - stabilizationMode.takeIf { - it in supportedStabilizationModes && targetFrameRate !in it.unsupportedFpsSet - } ?: StabilizationMode.OFF - } - override suspend fun takePicture(onCaptureStarted: (() -> Unit)) { val imageCaptureUseCase = boundImageCapture?.useCase ?: throw RuntimeException("Attempted take picture with null imageCapture use case") diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/StabilizationModeResolution.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/StabilizationModeResolution.kt new file mode 100644 index 000000000..d593e35b7 --- /dev/null +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/StabilizationModeResolution.kt @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2026 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 com.google.jetpackcamera.core.camera + +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.StabilizationMode +import com.google.jetpackcamera.settings.model.CameraConstraints + +/** + * Resolves [requestedStabilizationMode] to the [StabilizationMode] to apply to a camera session. + * + * [StabilizationMode.AUTO] resolves to the first mode in the following order that + * [cameraConstraints] supports at [targetFrameRate]: + * - In [CaptureMode.IMAGE_ONLY]: [StabilizationMode.OPTICAL], then [StabilizationMode.OFF]. + * Preview stabilization ([StabilizationMode.ON]) is intended for video and crops the field of + * view, so it is not chosen automatically when no video can be recorded. + * - In other capture modes: [StabilizationMode.ON], [StabilizationMode.OPTICAL], then + * [StabilizationMode.OFF]. + * + * Any other requested mode is returned unchanged if it is supported at [targetFrameRate], and + * resolves to [StabilizationMode.OFF] otherwise. This includes [StabilizationMode.ON] in + * [CaptureMode.IMAGE_ONLY], so preview stabilization can still be selected explicitly. + */ +internal fun resolveStabilizationMode( + requestedStabilizationMode: StabilizationMode, + captureMode: CaptureMode, + targetFrameRate: Int, + cameraConstraints: CameraConstraints +): StabilizationMode = with(cameraConstraints) { + fun StabilizationMode.isSupported(): Boolean = + this in supportedStabilizationModes && targetFrameRate !in unsupportedFpsSet + + if (requestedStabilizationMode == StabilizationMode.AUTO) { + val autoCandidates = if (captureMode == CaptureMode.IMAGE_ONLY) { + listOf(StabilizationMode.OPTICAL) + } else { + listOf(StabilizationMode.ON, StabilizationMode.OPTICAL) + } + autoCandidates.firstOrNull { it.isSupported() } ?: StabilizationMode.OFF + } else { + requestedStabilizationMode.takeIf { it.isSupported() } ?: StabilizationMode.OFF + } +} diff --git a/core/camera/src/test/java/com/google/jetpackcamera/core/camera/StabilizationModeResolutionTest.kt b/core/camera/src/test/java/com/google/jetpackcamera/core/camera/StabilizationModeResolutionTest.kt new file mode 100644 index 000000000..c3813c4f6 --- /dev/null +++ b/core/camera/src/test/java/com/google/jetpackcamera/core/camera/StabilizationModeResolutionTest.kt @@ -0,0 +1,216 @@ +/* + * Copyright (C) 2026 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 com.google.jetpackcamera.core.camera + +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.StabilizationMode +import com.google.jetpackcamera.model.TARGET_FPS_60 +import com.google.jetpackcamera.model.TARGET_FPS_AUTO +import com.google.jetpackcamera.settings.model.CameraConstraints +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class StabilizationModeResolutionTest { + + @Test + fun auto_standard_previewAndOpticalSupported_resolvesToOn() { + val constraints = cameraConstraints( + StabilizationMode.ON, + StabilizationMode.OPTICAL, + StabilizationMode.OFF + ) + + assertThat(resolve(StabilizationMode.AUTO, CaptureMode.STANDARD, constraints)) + .isEqualTo(StabilizationMode.ON) + } + + @Test + fun auto_videoOnly_previewAndOpticalSupported_resolvesToOn() { + val constraints = cameraConstraints( + StabilizationMode.ON, + StabilizationMode.OPTICAL, + StabilizationMode.OFF + ) + + assertThat(resolve(StabilizationMode.AUTO, CaptureMode.VIDEO_ONLY, constraints)) + .isEqualTo(StabilizationMode.ON) + } + + @Test + fun auto_standard_onlyOpticalSupported_resolvesToOptical() { + val constraints = cameraConstraints(StabilizationMode.OPTICAL, StabilizationMode.OFF) + + assertThat(resolve(StabilizationMode.AUTO, CaptureMode.STANDARD, constraints)) + .isEqualTo(StabilizationMode.OPTICAL) + } + + @Test + fun auto_standard_noStabilizationSupported_resolvesToOff() { + val constraints = cameraConstraints(StabilizationMode.OFF) + + assertThat(resolve(StabilizationMode.AUTO, CaptureMode.STANDARD, constraints)) + .isEqualTo(StabilizationMode.OFF) + } + + @Test + fun auto_standard_previewUnsupportedAtFrameRate_resolvesToOptical() { + val constraints = cameraConstraints( + StabilizationMode.ON, + StabilizationMode.OPTICAL, + StabilizationMode.OFF, + unsupportedStabilizationFpsMap = mapOf(StabilizationMode.ON to setOf(TARGET_FPS_60)) + ) + + assertThat( + resolve( + StabilizationMode.AUTO, + CaptureMode.STANDARD, + constraints, + targetFrameRate = TARGET_FPS_60 + ) + ).isEqualTo(StabilizationMode.OPTICAL) + } + + @Test + fun auto_imageOnly_previewAndOpticalSupported_resolvesToOptical() { + val constraints = cameraConstraints( + StabilizationMode.ON, + StabilizationMode.OPTICAL, + StabilizationMode.OFF + ) + + assertThat(resolve(StabilizationMode.AUTO, CaptureMode.IMAGE_ONLY, constraints)) + .isEqualTo(StabilizationMode.OPTICAL) + } + + @Test + fun auto_imageOnly_onlyPreviewSupported_resolvesToOff() { + val constraints = cameraConstraints(StabilizationMode.ON, StabilizationMode.OFF) + + assertThat(resolve(StabilizationMode.AUTO, CaptureMode.IMAGE_ONLY, constraints)) + .isEqualTo(StabilizationMode.OFF) + } + + @Test + fun auto_imageOnly_opticalUnsupportedAtFrameRate_resolvesToOff() { + val constraints = cameraConstraints( + StabilizationMode.ON, + StabilizationMode.OPTICAL, + StabilizationMode.OFF, + unsupportedStabilizationFpsMap = mapOf( + StabilizationMode.OPTICAL to setOf(TARGET_FPS_60) + ) + ) + + assertThat( + resolve( + StabilizationMode.AUTO, + CaptureMode.IMAGE_ONLY, + constraints, + targetFrameRate = TARGET_FPS_60 + ) + ).isEqualTo(StabilizationMode.OFF) + } + + @Test + fun on_imageOnly_previewSupported_resolvesToOn() { + val constraints = cameraConstraints( + StabilizationMode.ON, + StabilizationMode.OPTICAL, + StabilizationMode.OFF + ) + + assertThat(resolve(StabilizationMode.ON, CaptureMode.IMAGE_ONLY, constraints)) + .isEqualTo(StabilizationMode.ON) + } + + @Test + fun highQuality_imageOnly_supported_resolvesToHighQuality() { + val constraints = cameraConstraints(StabilizationMode.HIGH_QUALITY, StabilizationMode.OFF) + + assertThat(resolve(StabilizationMode.HIGH_QUALITY, CaptureMode.IMAGE_ONLY, constraints)) + .isEqualTo(StabilizationMode.HIGH_QUALITY) + } + + @Test + fun explicitMode_unsupportedByLens_resolvesToOff() { + val constraints = cameraConstraints(StabilizationMode.OPTICAL, StabilizationMode.OFF) + + assertThat(resolve(StabilizationMode.ON, CaptureMode.STANDARD, constraints)) + .isEqualTo(StabilizationMode.OFF) + } + + @Test + fun explicitMode_unsupportedAtFrameRate_resolvesToOff() { + val constraints = cameraConstraints( + StabilizationMode.ON, + StabilizationMode.OFF, + unsupportedStabilizationFpsMap = mapOf(StabilizationMode.ON to setOf(TARGET_FPS_60)) + ) + + assertThat( + resolve( + StabilizationMode.ON, + CaptureMode.STANDARD, + constraints, + targetFrameRate = TARGET_FPS_60 + ) + ).isEqualTo(StabilizationMode.OFF) + } + + @Test + fun off_resolvesToOff() { + val constraints = cameraConstraints( + StabilizationMode.ON, + StabilizationMode.OPTICAL, + StabilizationMode.OFF + ) + + assertThat(resolve(StabilizationMode.OFF, CaptureMode.STANDARD, constraints)) + .isEqualTo(StabilizationMode.OFF) + } + + private fun resolve( + requestedStabilizationMode: StabilizationMode, + captureMode: CaptureMode, + cameraConstraints: CameraConstraints, + targetFrameRate: Int = TARGET_FPS_AUTO + ): StabilizationMode = resolveStabilizationMode( + requestedStabilizationMode = requestedStabilizationMode, + captureMode = captureMode, + targetFrameRate = targetFrameRate, + cameraConstraints = cameraConstraints + ) + + private fun cameraConstraints( + vararg supportedStabilizationModes: StabilizationMode, + unsupportedStabilizationFpsMap: Map> = emptyMap() + ) = CameraConstraints( + supportedStabilizationModes = supportedStabilizationModes.toSet(), + supportedFixedFrameRates = emptySet(), + supportedDynamicRanges = emptySet(), + supportedVideoQualitiesMap = emptyMap(), + supportedImageFormatsMap = emptyMap(), + supportedIlluminants = emptySet(), + supportedFlashModes = emptySet(), + supportedZoomRange = null, + unsupportedStabilizationFpsMap = unsupportedStabilizationFpsMap, + supportedTestPatterns = emptySet() + ) +}