diff --git a/package/android/src/main/cpp/OpenGLRenderer.cpp b/package/android/src/main/cpp/OpenGLRenderer.cpp index 3768399f64..e9d6bd3e3b 100644 --- a/package/android/src/main/cpp/OpenGLRenderer.cpp +++ b/package/android/src/main/cpp/OpenGLRenderer.cpp @@ -29,10 +29,11 @@ OpenGLRenderer::OpenGLRenderer(std::shared_ptr context, ANativeWi } OpenGLRenderer::~OpenGLRenderer() { + __android_log_print(ANDROID_LOG_INFO, TAG, "Destroying OpenGLRenderer..."); + destroy(); if (_outputSurface != nullptr) { ANativeWindow_release(_outputSurface); } - destroy(); } void OpenGLRenderer::destroy() { diff --git a/package/android/src/main/java/com/mrousavy/camera/CameraView.kt b/package/android/src/main/java/com/mrousavy/camera/CameraView.kt index a3d86ffde0..1f244484e6 100644 --- a/package/android/src/main/java/com/mrousavy/camera/CameraView.kt +++ b/package/android/src/main/java/com/mrousavy/camera/CameraView.kt @@ -113,17 +113,17 @@ class CameraView(context: Context) : } override fun onAttachedToWindow() { + super.onAttachedToWindow() if (!isMounted) { isMounted = true invokeOnViewReady() } update() - super.onAttachedToWindow() } override fun onDetachedFromWindow() { - update() super.onDetachedFromWindow() + update() } fun destroy() { diff --git a/package/android/src/main/java/com/mrousavy/camera/core/CameraConfiguration.kt b/package/android/src/main/java/com/mrousavy/camera/core/CameraConfiguration.kt index a948b41bdf..f1dca59d76 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/CameraConfiguration.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/CameraConfiguration.kt @@ -67,7 +67,7 @@ data class CameraConfiguration( } data class Difference( - // Input Camera (cameraId, isActive) + // Input Camera (cameraId) val deviceChanged: Boolean, // Outputs & Session (Photo, Video, CodeScanner, HDR, Format) val outputsChanged: Boolean, @@ -75,14 +75,17 @@ data class CameraConfiguration( val sidePropsChanged: Boolean, // (isActive) changed val isActiveChanged: Boolean - ) + ) { + val hasChanges: Boolean + get() = deviceChanged || outputsChanged || sidePropsChanged || isActiveChanged + } companion object { fun copyOf(other: CameraConfiguration?): CameraConfiguration = other?.copy() ?: CameraConfiguration() fun difference(left: CameraConfiguration?, right: CameraConfiguration): Difference { // input device - val deviceChanged = left?.cameraId != right.cameraId || left?.isActive != right.isActive + val deviceChanged = left?.cameraId != right.cameraId // outputs val outputsChanged = deviceChanged || @@ -101,7 +104,7 @@ data class CameraConfiguration( left.videoStabilizationMode != right.videoStabilizationMode || left.exposure != right.exposure - val isActiveChanged = left?.isActive != right.isActive + val isActiveChanged = sidePropsChanged || left?.isActive != right.isActive return Difference( deviceChanged, diff --git a/package/android/src/main/java/com/mrousavy/camera/core/CameraDeviceDetails.kt b/package/android/src/main/java/com/mrousavy/camera/core/CameraDeviceDetails.kt index 3b66a1b144..25856f84d8 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/CameraDeviceDetails.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/CameraDeviceDetails.kt @@ -1,7 +1,9 @@ package com.mrousavy.camera.core +import android.annotation.SuppressLint import android.graphics.ImageFormat import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraExtensionCharacteristics import android.hardware.camera2.CameraManager import android.hardware.camera2.CameraMetadata import android.os.Build @@ -23,61 +25,73 @@ import com.mrousavy.camera.types.VideoStabilizationMode import kotlin.math.atan2 import kotlin.math.sqrt -class CameraDeviceDetails(val cameraManager: CameraManager, val cameraId: String) { - val characteristics = cameraManager.getCameraCharacteristics(cameraId) - val hardwareLevel = HardwareLevel.fromCameraCharacteristics(characteristics) - val capabilities = characteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES) ?: IntArray(0) - val extensions = getSupportedExtensions() +@SuppressLint("InlinedApi") +class CameraDeviceDetails(private val cameraManager: CameraManager, val cameraId: String) { + val characteristics by lazy { cameraManager.getCameraCharacteristics(cameraId) } + val hardwareLevel by lazy { HardwareLevel.fromCameraCharacteristics(characteristics) } + val capabilities by lazy { characteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES) ?: IntArray(0) } + val extensions by lazy { getSupportedExtensions() } // device characteristics - val isMultiCam = capabilities.contains(11) // TODO: CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_LOGICAL_MULTI_CAMERA - val supportsDepthCapture = capabilities.contains(8) // TODO: CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_DEPTH_OUTPUT - val supportsRawCapture = capabilities.contains(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_RAW) - val supportsLowLightBoost = extensions.contains(4) // TODO: CameraExtensionCharacteristics.EXTENSION_NIGHT - val lensFacing = LensFacing.fromCameraCharacteristics(characteristics) - val hasFlash = characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) ?: false - val focalLengths = - characteristics.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS) - // 35mm is the film standard sensor size - ?: floatArrayOf(35f) - val sensorSize = characteristics.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE)!! - val sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION)!! - val minFocusDistance = getMinFocusDistanceCm() - val name = ( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - characteristics.get(CameraCharacteristics.INFO_VERSION) - } else { - null - } - ) ?: "$lensFacing ($cameraId)" + val isMultiCam by lazy { capabilities.contains(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_LOGICAL_MULTI_CAMERA) } + val supportsDepthCapture by lazy { capabilities.contains(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_DEPTH_OUTPUT) } + val supportsRawCapture by lazy { capabilities.contains(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_RAW) } + val supportsLowLightBoost by lazy { extensions.contains(CameraExtensionCharacteristics.EXTENSION_NIGHT) } + val lensFacing by lazy { LensFacing.fromCameraCharacteristics(characteristics) } + val hasFlash by lazy { characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) ?: false } + val focalLengths by lazy { + // 35mm is the film standard sensor size + characteristics.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS) ?: floatArrayOf(35f) + } + val sensorSize by lazy { characteristics.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE)!! } + val sensorOrientation by lazy { characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0 } + val minFocusDistance by lazy { getMinFocusDistanceCm() } + val name by lazy { + val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) characteristics.get(CameraCharacteristics.INFO_VERSION) else null + return@lazy info ?: "$lensFacing ($cameraId)" + } // "formats" (all possible configurations for this device) - val zoomRange = ( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val maxDigitalZoom by lazy { characteristics.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM) ?: 1f } + val zoomRange by lazy { + val range = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { characteristics.get(CameraCharacteristics.CONTROL_ZOOM_RATIO_RANGE) } else { null } - ) ?: Range(1f, characteristics.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM) ?: 1f) - val physicalDevices = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && characteristics.physicalCameraIds.isNotEmpty()) { - characteristics.physicalCameraIds - } else { - setOf(cameraId) - } - val minZoom = zoomRange.lower.toDouble() - val maxZoom = zoomRange.upper.toDouble() - - val cameraConfig = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!! - val isoRange = characteristics.get(CameraCharacteristics.SENSOR_INFO_SENSITIVITY_RANGE) ?: Range(0, 0) - val exposureRange = characteristics.get(CameraCharacteristics.CONTROL_AE_COMPENSATION_RANGE) ?: Range(0, 0) - val digitalStabilizationModes = + return@lazy range ?: Range(1f, maxDigitalZoom) + } + val physicalDevices by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && characteristics.physicalCameraIds.isNotEmpty()) { + characteristics.physicalCameraIds + } else { + setOf(cameraId) + } + } + val minZoom by lazy { zoomRange.lower.toDouble() } + val maxZoom by lazy { zoomRange.upper.toDouble() } + + val cameraConfig by lazy { characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!! } + val isoRange by lazy { characteristics.get(CameraCharacteristics.SENSOR_INFO_SENSITIVITY_RANGE) ?: Range(0, 0) } + val exposureRange by lazy { characteristics.get(CameraCharacteristics.CONTROL_AE_COMPENSATION_RANGE) ?: Range(0, 0) } + val digitalStabilizationModes by lazy { characteristics.get(CameraCharacteristics.CONTROL_AVAILABLE_VIDEO_STABILIZATION_MODES) ?: IntArray(0) - val opticalStabilizationModes = + } + val opticalStabilizationModes by lazy { characteristics.get(CameraCharacteristics.LENS_INFO_AVAILABLE_OPTICAL_STABILIZATION) ?: IntArray(0) - val supportsPhotoHdr = extensions.contains(3) // TODO: CameraExtensionCharacteristics.EXTENSION_HDR - val supportsVideoHdr = getHasVideoHdr() - val autoFocusSystem = getAutoFocusSystemMode() + } + val supportsPhotoHdr by lazy { extensions.contains(CameraExtensionCharacteristics.EXTENSION_HDR) } + val supportsVideoHdr by lazy { getHasVideoHdr() } + val autoFocusSystem by lazy { getAutoFocusSystemMode() } + + val supportsYuvProcessing by lazy { capabilities.contains(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_YUV_REPROCESSING) } + val supportsPrivateProcessing by lazy { capabilities.contains(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_PRIVATE_REPROCESSING) } + val supportsZsl by lazy { supportsYuvProcessing || supportsPrivateProcessing } + val isBackwardsCompatible by lazy { capabilities.contains(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_BACKWARD_COMPATIBLE) } + val supportsSnapshotCapture by lazy { supportsSnapshotCapture() } + + // TODO: Also add 10-bit YUV here? val videoFormat = ImageFormat.YUV_420_888 // get extensions (HDR, Night Mode, ..) @@ -107,6 +121,14 @@ class CameraDeviceDetails(val cameraManager: CameraManager, val cameraId: String return 1.0 / distance * 100.0 } + @Suppress("RedundantIf") + private fun supportsSnapshotCapture(): Boolean { + // As per CameraDevice.TEMPLATE_VIDEO_SNAPSHOT in documentation: + if (hardwareLevel == HardwareLevel.LEGACY) return false + if (supportsDepthCapture && !isBackwardsCompatible) return false + return true + } + private fun createStabilizationModes(): ReadableArray { val array = Arguments.createArray() digitalStabilizationModes.forEach { videoStabilizationMode -> @@ -176,8 +198,6 @@ class CameraDeviceDetails(val cameraManager: CameraManager, val cameraId: String } } - // TODO: Add high-speed video ranges (high-fps / slow-motion) - return array } diff --git a/package/android/src/main/java/com/mrousavy/camera/core/CameraError.kt b/package/android/src/main/java/com/mrousavy/camera/core/CameraError.kt index c6bb6c2803..182477b50b 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/CameraError.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/CameraError.kt @@ -71,6 +71,8 @@ class CameraSessionCannotBeConfiguredError(cameraId: String) : CameraError("session", "cannot-create-session", "Failed to create a Camera Session for Camera #$cameraId!") class CameraDisconnectedError(cameraId: String, error: CameraDeviceError) : CameraError("session", "camera-has-been-disconnected", "The given Camera device (id: $cameraId) has been disconnected! Error: $error") +class NoOutputsError : + CameraError("session", "no-outputs", "Cannot create a CameraCaptureSession without any outputs! (PREVIEW, PHOTO, VIDEO, ...)") class PropRequiresFormatToBeNonNullError(propName: String) : CameraError("format", "format-required", "The prop \"$propName\" requires a format to be set, but format was null!") diff --git a/package/android/src/main/java/com/mrousavy/camera/core/CameraSession.kt b/package/android/src/main/java/com/mrousavy/camera/core/CameraSession.kt index bdea193a7a..12f4b778ab 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/CameraSession.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/CameraSession.kt @@ -5,49 +5,36 @@ import android.content.Context import android.content.pm.PackageManager import android.graphics.ImageFormat import android.graphics.Point -import android.hardware.camera2.CameraCaptureSession import android.hardware.camera2.CameraCharacteristics -import android.hardware.camera2.CameraDevice import android.hardware.camera2.CameraManager -import android.hardware.camera2.CameraMetadata -import android.hardware.camera2.CaptureRequest import android.hardware.camera2.CaptureResult import android.hardware.camera2.TotalCaptureResult -import android.hardware.camera2.params.MeteringRectangle import android.media.Image import android.media.ImageReader -import android.os.Build import android.util.Log -import android.util.Range import android.util.Size import android.view.Surface import android.view.SurfaceHolder import androidx.core.content.ContextCompat import com.google.mlkit.vision.barcode.common.Barcode +import com.mrousavy.camera.core.capture.RepeatingCaptureRequest import com.mrousavy.camera.core.outputs.BarcodeScannerOutput import com.mrousavy.camera.core.outputs.PhotoOutput import com.mrousavy.camera.core.outputs.SurfaceOutput import com.mrousavy.camera.core.outputs.VideoPipelineOutput -import com.mrousavy.camera.extensions.capture import com.mrousavy.camera.extensions.closestToOrMax -import com.mrousavy.camera.extensions.createCaptureSession -import com.mrousavy.camera.extensions.createPhotoCaptureRequest import com.mrousavy.camera.extensions.getPhotoSizes import com.mrousavy.camera.extensions.getPreviewTargetSize import com.mrousavy.camera.extensions.getVideoSizes -import com.mrousavy.camera.extensions.openCamera -import com.mrousavy.camera.extensions.setZoom import com.mrousavy.camera.frameprocessor.Frame import com.mrousavy.camera.types.Flash +import com.mrousavy.camera.types.LensFacing import com.mrousavy.camera.types.Orientation import com.mrousavy.camera.types.QualityPrioritization import com.mrousavy.camera.types.RecordVideoOptions -import com.mrousavy.camera.types.Torch -import com.mrousavy.camera.types.VideoStabilizationMode import com.mrousavy.camera.utils.ImageFormatUtils import java.io.Closeable -import java.lang.IllegalStateException -import java.util.concurrent.CancellationException +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -55,8 +42,8 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock class CameraSession(private val context: Context, private val cameraManager: CameraManager, private val callback: Callback) : - CameraManager.AvailabilityCallback(), - Closeable { + Closeable, + PersistentCameraCaptureSession.Callback { companion object { private const val TAG = "CameraSession" } @@ -65,14 +52,7 @@ class CameraSession(private val context: Context, private val cameraManager: Cam private var configuration: CameraConfiguration? = null // Camera State - private var cameraDevice: CameraDevice? = null - set(value) { - field = value - cameraDeviceDetails = if (value != null) CameraDeviceDetails(cameraManager, value.id) else null - } - private var cameraDeviceDetails: CameraDeviceDetails? = null - private var captureSession: CameraCaptureSession? = null - private var previewRequest: CaptureRequest.Builder? = null + private val captureSession = PersistentCameraCaptureSession(cameraManager, this) private var photoOutput: PhotoOutput? = null private var videoOutput: VideoPipelineOutput? = null private var codeScannerOutput: BarcodeScannerOutput? = null @@ -109,14 +89,9 @@ class CameraSession(private val context: Context, private val cameraManager: Cam return Orientation.fromRotationDegrees(sensorRotation) } - init { - cameraManager.registerAvailabilityCallback(this, CameraQueues.cameraQueue.handler) - } - override fun close() { Log.i(TAG, "Closing CameraSession...") isDestroyed = true - cameraManager.unregisterAvailabilityCallback(this) runBlocking { mutex.withLock { destroy() @@ -126,18 +101,6 @@ class CameraSession(private val context: Context, private val cameraManager: Cam Log.i(TAG, "CameraSession closed!") } - override fun onCameraAvailable(cameraId: String) { - super.onCameraAvailable(cameraId) - if (this.configuration?.cameraId == cameraId && cameraDevice == null && configuration?.isActive == true) { - Log.i(TAG, "Camera #$cameraId is now available again, trying to re-open it now...") - coroutineScope.launch { - configure { - // re-open CameraDevice if needed - } - } - } - } - suspend fun configure(lambda: (configuration: CameraConfiguration) -> Unit) { Log.i(TAG, "configure { ... }: Waiting for lock...") @@ -146,6 +109,12 @@ class CameraSession(private val context: Context, private val cameraManager: Cam val config = CameraConfiguration.copyOf(this.configuration) lambda(config) val diff = CameraConfiguration.difference(this.configuration, config) + this.configuration = config + + if (!diff.hasChanges) { + Log.i(TAG, "Nothing changed, aborting configure { ... }") + return@withLock + } if (isDestroyed) { Log.i(TAG, "CameraSession is already destroyed. Skipping configure { ... }") @@ -155,29 +124,11 @@ class CameraSession(private val context: Context, private val cameraManager: Cam Log.i(TAG, "configure { ... }: Updating CameraSession Configuration... $diff") try { - val needsRebuild = cameraDevice == null || captureSession == null - if (needsRebuild) { - Log.i(TAG, "Need to rebuild CameraDevice and CameraCaptureSession...") - } - - // Since cameraDevice and captureSession are OS resources, we have three possible paths here: - if (needsRebuild) { - if (config.isActive) { - // A: The Camera has been torn down by the OS and we want it to be active - rebuild everything - Log.i(TAG, "Need to rebuild CameraDevice and CameraCaptureSession...") - configureCameraDevice(config) - configureOutputs(config) - configureCaptureRequest(config) - } else { - // B: The Camera has been torn down by the OS but it's currently in the background - ignore this - Log.i(TAG, "CameraDevice and CameraCaptureSession is torn down but Camera is not active, skipping update...") - } - } else { - // C: The Camera has not been torn down and we just want to update some props - update incrementally + captureSession.withConfiguration { // Build up session or update any props if (diff.deviceChanged) { // 1. cameraId changed, open device - configureCameraDevice(config) + configureInput(config) } if (diff.outputsChanged) { // 2. outputs changed, build new session @@ -187,10 +138,18 @@ class CameraSession(private val context: Context, private val cameraManager: Cam // 3. zoom etc changed, update repeating request configureCaptureRequest(config) } + if (diff.isActiveChanged) { + // 4. Either start or stop the session + val isActive = config.isActive && config.preview.isEnabled + captureSession.setIsActive(isActive) + } } - Log.i(TAG, "Successfully updated CameraSession Configuration! isActive: ${config.isActive}") - this.configuration = config + Log.i( + TAG, + "configure { ... }: Completed CameraSession Configuration! (isActive: ${config.isActive}, isRunning: ${captureSession.isRunning})" + ) + isRunning = captureSession.isRunning // Notify about Camera initialization if (diff.deviceChanged) { @@ -205,8 +164,7 @@ class CameraSession(private val context: Context, private val cameraManager: Cam private fun destroy() { Log.i(TAG, "Destroying session..") - cameraDevice?.close() - cameraDevice = null + captureSession.close() photoOutput?.close() photoOutput = null @@ -262,66 +220,20 @@ class CameraSession(private val context: Context, private val cameraManager: Cam Log.i(TAG, "Preview Output destroyed!") } - /** - * Set up the `CameraDevice` (`cameraId`) - */ - private suspend fun configureCameraDevice(configuration: CameraConfiguration) { - if (!configuration.isActive) { - // If isActive=false, we don't care if the device is opened or closed. - // Android OS can close the CameraDevice if it needs it, otherwise we keep it warm. - Log.i(TAG, "isActive is false, skipping CameraDevice configuration.") - return - } - - if (cameraDevice != null) { - // Close existing device - Log.i(TAG, "Closing previous Camera #${cameraDevice?.id}...") - cameraDevice?.close() - cameraDevice = null - } - isRunning = false - - // Check Camera Permission - val cameraPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) - if (cameraPermission != PackageManager.PERMISSION_GRANTED) throw CameraPermissionError() - - // Open new device + private fun configureInput(configuration: CameraConfiguration) { + Log.i(TAG, "Configuring inputs for CameraSession...") val cameraId = configuration.cameraId ?: throw NoCameraDeviceError() - Log.i(TAG, "Configuring Camera #$cameraId...") - cameraDevice = cameraManager.openCamera(cameraId, { device, error -> - if (cameraDevice != device) { - // a previous device has been disconnected, but we already have a new one. - // this is just normal behavior - return@openCamera - } - - this.cameraDevice = null - isRunning = false - - if (error != null) { - Log.e(TAG, "Camera #${device.id} has been unexpectedly disconnected!", error) - callback.onError(error) - } else { - Log.i(TAG, "Camera #${device.id} has been gracefully disconnected!") - } - }, CameraQueues.cameraQueue) - - Log.i(TAG, "Successfully configured Camera #$cameraId!") + val status = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) + if (status != PackageManager.PERMISSION_GRANTED) throw CameraPermissionError() + isRunning = false + captureSession.setInput(cameraId) } /** * Set up the `CaptureSession` with all outputs (preview, photo, video, codeScanner) and their HDR/Format settings. */ - private suspend fun configureOutputs(configuration: CameraConfiguration) { - if (!configuration.isActive) { - Log.i(TAG, "isActive is false, skipping CameraCaptureSession configuration.") - return - } - val cameraDevice = cameraDevice - if (cameraDevice == null) { - Log.i(TAG, "CameraSession hasn't configured a CameraDevice, skipping session configuration...") - return - } + private fun configureOutputs(configuration: CameraConfiguration) { + val cameraId = configuration.cameraId ?: throw NoCameraDeviceError() // Destroy previous outputs Log.i(TAG, "Destroying previous outputs...") @@ -333,10 +245,10 @@ class CameraSession(private val context: Context, private val cameraManager: Cam codeScannerOutput = null isRunning = false - val characteristics = cameraManager.getCameraCharacteristics(cameraDevice.id) + val characteristics = cameraManager.getCameraCharacteristics(cameraId) val format = configuration.format - Log.i(TAG, "Creating outputs for Camera #${cameraDevice.id}...") + Log.i(TAG, "Creating outputs for Camera #$cameraId...") val isSelfie = characteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT @@ -366,7 +278,7 @@ class CameraSession(private val context: Context, private val cameraManager: Cam val video = configuration.video as? CameraConfiguration.Output.Enabled if (video != null) { val imageFormat = video.config.pixelFormat.toImageFormat() - val sizes = characteristics.getVideoSizes(cameraDevice.id, imageFormat) + val sizes = characteristics.getVideoSizes(cameraId, imageFormat) val size = sizes.closestToOrMax(format?.videoSize) Log.i(TAG, "Adding ${size.width}x${size.height} Video Output in ${ImageFormatUtils.imageFormatToString(imageFormat)}...") @@ -414,7 +326,7 @@ class CameraSession(private val context: Context, private val cameraManager: Cam } val imageFormat = ImageFormat.YUV_420_888 - val sizes = characteristics.getVideoSizes(cameraDevice.id, imageFormat) + val sizes = characteristics.getVideoSizes(cameraId, imageFormat) val size = sizes.closestToOrMax(Size(1280, 720)) Log.i(TAG, "Adding ${size.width}x${size.height} CodeScanner Output in ${ImageFormatUtils.imageFormatToString(imageFormat)}...") @@ -425,175 +337,63 @@ class CameraSession(private val context: Context, private val cameraManager: Cam } // Create session - captureSession = cameraDevice.createCaptureSession(cameraManager, outputs, { session -> - if (this.captureSession != session) { - // a previous session has been closed, but we already have a new one. - // this is just normal behavior - return@createCaptureSession - } - - // onClosed - this.captureSession = null - isRunning = false - - Log.i(TAG, "Camera Session $session has been closed.") - }, CameraQueues.cameraQueue) + captureSession.setOutputs(outputs) - Log.i(TAG, "Successfully configured Session with ${outputs.size} outputs for Camera #${cameraDevice.id}!") + Log.i(TAG, "Successfully configured Session with ${outputs.size} outputs for Camera #$cameraId!") // Update Frame Processor and RecordingSession for newly changed output updateVideoOutputs() } - private fun createRepeatingRequest(device: CameraDevice, targets: List, config: CameraConfiguration): CaptureRequest { - val deviceDetails = cameraDeviceDetails ?: CameraDeviceDetails(cameraManager, device.id) - - val template = if (config.video.isEnabled) CameraDevice.TEMPLATE_RECORD else CameraDevice.TEMPLATE_PREVIEW - val captureRequest = device.createCaptureRequest(template) - - targets.forEach { t -> captureRequest.addTarget(t) } - - val format = config.format - - // Set FPS - val fps = config.fps - if (fps != null) { - if (format == null) throw PropRequiresFormatToBeNonNullError("fps") - if (format.maxFps < fps) throw InvalidFpsError(fps) - captureRequest.set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, Range(fps, fps)) - } - - // Set Video Stabilization - if (config.videoStabilizationMode != VideoStabilizationMode.OFF) { - if (format == null) throw PropRequiresFormatToBeNonNullError("videoStabilizationMode") - if (!format.videoStabilizationModes.contains( - config.videoStabilizationMode - ) - ) { - throw InvalidVideoStabilizationMode(config.videoStabilizationMode) - } - } - when (config.videoStabilizationMode) { - VideoStabilizationMode.OFF -> { - // do nothing - } - VideoStabilizationMode.STANDARD -> { - val mode = if (Build.VERSION.SDK_INT >= - Build.VERSION_CODES.TIRAMISU - ) { - CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_PREVIEW_STABILIZATION - } else { - CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_ON - } - captureRequest.set(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE, mode) - } - VideoStabilizationMode.CINEMATIC, VideoStabilizationMode.CINEMATIC_EXTENDED -> { - captureRequest.set(CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE, CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE_ON) - } - } - - // Set HDR - val video = config.video as? CameraConfiguration.Output.Enabled - val videoHdr = video?.config?.enableHdr - if (videoHdr == true) { - if (format == null) throw PropRequiresFormatToBeNonNullError("videoHdr") - if (!format.supportsVideoHdr) throw InvalidVideoHdrError() - captureRequest.set(CaptureRequest.CONTROL_SCENE_MODE, CaptureRequest.CONTROL_SCENE_MODE_HDR) - } else if (config.enableLowLightBoost) { - if (!deviceDetails.supportsLowLightBoost) throw LowLightBoostNotSupportedError() - captureRequest.set(CaptureRequest.CONTROL_SCENE_MODE, CaptureRequest.CONTROL_SCENE_MODE_NIGHT) - } - - // Set Exposure Bias - val exposure = config.exposure?.toInt() - if (exposure != null) { - val clamped = deviceDetails.exposureRange.clamp(exposure) - captureRequest.set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, clamped) - } - - // Set Zoom - // TODO: Cache camera characteristics? Check perf. - val cameraCharacteristics = cameraManager.getCameraCharacteristics(device.id) - captureRequest.setZoom(config.zoom, cameraCharacteristics) - - // Set Torch - if (config.torch == Torch.ON) { - if (!deviceDetails.hasFlash) throw FlashUnavailableError() - captureRequest.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_TORCH) - } - - // Start repeating request if the Camera is active - return captureRequest.build() - } - private fun configureCaptureRequest(config: CameraConfiguration) { - val captureSession = captureSession - - if (!config.isActive) { - isRunning = false - try { - captureSession?.stopRepeating() - } catch (e: IllegalStateException) { - // ignore - captureSession is already closed. - } - return - } - if (captureSession == null) { - Log.i(TAG, "CameraSession hasn't configured the capture session, skipping CaptureRequest...") - return - } - - val preview = config.preview as? CameraConfiguration.Output.Enabled - val previewSurface = preview?.config?.surface - val targets = listOfNotNull(previewSurface, videoOutput?.surface, codeScannerOutput?.surface) - if (targets.isEmpty()) { - Log.i(TAG, "CameraSession has no repeating outputs (Preview, Video, CodeScanner), skipping CaptureRequest...") - return - } - - val request = createRepeatingRequest(captureSession.device, targets, config) - captureSession.setRepeatingRequest(request, null, null) - isRunning = true + val video = config.video as? CameraConfiguration.Output.Enabled + val enableVideo = video != null + val enableVideoHdr = video?.config?.enableHdr == true + + captureSession.setRepeatingRequest( + RepeatingCaptureRequest( + enableVideo, + config.torch, + config.fps, + config.videoStabilizationMode, + enableVideoHdr, + config.enableLowLightBoost, + config.exposure, + config.zoom, + config.format + ) + ) } suspend fun takePhoto( qualityPrioritization: QualityPrioritization, - flashMode: Flash, + flash: Flash, enableShutterSound: Boolean, enableRedEyeReduction: Boolean, enableAutoStabilization: Boolean, outputOrientation: Orientation ): CapturedPhoto { - val captureSession = captureSession ?: throw CameraNotReadyError() val photoOutput = photoOutput ?: throw PhotoNotEnabledError() - Log.i(TAG, "Photo capture 0/3 - preparing capture request (${photoOutput.size.width}x${photoOutput.size.height})...") - - val zoom = configuration?.zoom ?: 1f - - val cameraCharacteristics = cameraManager.getCameraCharacteristics(captureSession.device.id) - val orientation = outputOrientation.toSensorRelativeOrientation(cameraCharacteristics) - val captureRequest = captureSession.device.createPhotoCaptureRequest( - cameraManager, - photoOutput.surface, - zoom, + Log.i(TAG, "Photo capture 1/3 - capturing ${photoOutput.size.width}x${photoOutput.size.height} image...") + val result = captureSession.capture( qualityPrioritization, - flashMode, + flash, enableRedEyeReduction, enableAutoStabilization, photoOutput.enableHdr, - orientation + outputOrientation, + enableShutterSound ) - Log.i(TAG, "Photo capture 1/3 - starting capture...") - val result = captureSession.capture(captureRequest, enableShutterSound) - val timestamp = result[CaptureResult.SENSOR_TIMESTAMP]!! - Log.i(TAG, "Photo capture 2/3 complete - received metadata with timestamp $timestamp") + try { + val timestamp = result[CaptureResult.SENSOR_TIMESTAMP]!! + Log.i(TAG, "Photo capture 2/3 - waiting for image with timestamp $timestamp now...") val image = photoOutputSynchronizer.await(timestamp) - val isMirrored = cameraCharacteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT - - Log.i(TAG, "Photo capture 3/3 complete - received ${image.width} x ${image.height} image.") + Log.i(TAG, "Photo capture 3/3 - received ${image.width} x ${image.height} image, preparing result...") + val deviceDetails = captureSession.getActiveDeviceDetails() + val isMirrored = deviceDetails?.lensFacing == LensFacing.FRONT return CapturedPhoto(image, result, orientation, isMirrored, image.format) } catch (e: CancellationException) { throw CaptureAbortedError(false) @@ -620,13 +420,13 @@ class CameraSession(private val context: Context, private val cameraManager: Cam mutex.withLock { if (recording != null) throw RecordingInProgressError() val videoOutput = videoOutput ?: throw VideoNotEnabledError() - val cameraDevice = cameraDevice ?: throw CameraNotReadyError() + val cameraId = configuration?.cameraId ?: throw NoCameraDeviceError() val fps = configuration?.fps ?: 30 val recording = RecordingSession( context, - cameraDevice.id, + cameraId, videoOutput.size, enableAudio, fps, @@ -664,41 +464,13 @@ class CameraSession(private val context: Context, private val cameraManager: Cam } } - suspend fun focus(x: Int, y: Int): Unit = throw NotImplementedError("focus() is not yet implemented!") - - private suspend fun focus(point: Point) { - mutex.withLock { - // TODO: Fix this method - val captureSession = captureSession ?: throw CameraNotReadyError() - val request = previewRequest ?: throw CameraNotReadyError() - - val weight = MeteringRectangle.METERING_WEIGHT_MAX - 1 - val focusAreaTouch = MeteringRectangle(point, Size(150, 150), weight) - - // Quickly pause preview - captureSession.stopRepeating() - - request.set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_CANCEL) - request.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_OFF) - captureSession.capture(request.build(), null, null) - - // Add AF trigger with focus region - val characteristics = cameraManager.getCameraCharacteristics(captureSession.device.id) - val maxSupportedFocusRegions = characteristics.get(CameraCharacteristics.CONTROL_MAX_REGIONS_AE) ?: 0 - if (maxSupportedFocusRegions >= 1) { - request.set(CaptureRequest.CONTROL_AF_REGIONS, arrayOf(focusAreaTouch)) - } - request.set(CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO) - request.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO) - request.set(CaptureRequest.CONTROL_AF_TRIGGER, CameraMetadata.CONTROL_AF_TRIGGER_START) + override fun onError(error: Throwable) { + callback.onError(error) + } - captureSession.capture(request.build(), false) + suspend fun focus(x: Int, y: Int): Unit = throw NotImplementedError("focus() is not yet implemented!") - // Resume preview - request.set(CaptureRequest.CONTROL_AF_TRIGGER, CaptureRequest.CONTROL_AF_TRIGGER_IDLE) - captureSession.setRepeatingRequest(request.build(), null, null) - } - } + private suspend fun focus(point: Point): Unit = throw NotImplementedError() data class CapturedPhoto( val image: Image, diff --git a/package/android/src/main/java/com/mrousavy/camera/core/PersistentCameraCaptureSession.kt b/package/android/src/main/java/com/mrousavy/camera/core/PersistentCameraCaptureSession.kt new file mode 100644 index 0000000000..510392af3f --- /dev/null +++ b/package/android/src/main/java/com/mrousavy/camera/core/PersistentCameraCaptureSession.kt @@ -0,0 +1,266 @@ +package com.mrousavy.camera.core + +import android.hardware.camera2.CameraAccessException +import android.hardware.camera2.CameraCaptureSession +import android.hardware.camera2.CameraDevice +import android.hardware.camera2.CameraManager +import android.hardware.camera2.TotalCaptureResult +import android.util.Log +import com.mrousavy.camera.core.capture.PhotoCaptureRequest +import com.mrousavy.camera.core.capture.RepeatingCaptureRequest +import com.mrousavy.camera.core.outputs.SurfaceOutput +import com.mrousavy.camera.extensions.capture +import com.mrousavy.camera.extensions.createCaptureSession +import com.mrousavy.camera.extensions.isValid +import com.mrousavy.camera.extensions.openCamera +import com.mrousavy.camera.extensions.tryAbortCaptures +import com.mrousavy.camera.types.Flash +import com.mrousavy.camera.types.Orientation +import com.mrousavy.camera.types.QualityPrioritization +import java.io.Closeable +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * A [CameraCaptureSession] wrapper that safely handles interruptions and remains open whenever available. + * + * This class aims to be similar to Apple's `AVCaptureSession`. + */ +class PersistentCameraCaptureSession(private val cameraManager: CameraManager, private val callback: Callback) : Closeable { + companion object { + private const val TAG = "PersistentCameraCaptureSession" + } + + // Inputs/Dependencies + private var cameraId: String? = null + private var outputs: List = emptyList() + private var repeatingRequest: RepeatingCaptureRequest? = null + private var isActive = false + + // State/Dependants + private var device: CameraDevice? = null // depends on [cameraId] + private var session: CameraCaptureSession? = null // depends on [device, surfaceOutputs] + private var cameraDeviceDetails: CameraDeviceDetails? = null // depends on [device] + + private val mutex = Mutex() + private var didDestroyFromOutside = false + + val isRunning: Boolean + get() = isActive && session != null && device != null && !didDestroyFromOutside + + override fun close() { + session?.tryAbortCaptures() + device?.close() + } + + private fun assertLocked(method: String) { + if (!mutex.isLocked) { + throw SessionIsNotLockedError("Failed to call $method, session is not locked! Call beginConfiguration() first.") + } + } + + suspend fun withConfiguration(block: suspend () -> Unit) { + mutex.withLock { + block() + configure() + } + } + + fun setInput(cameraId: String) { + Log.d(TAG, "--> setInput($cameraId)") + assertLocked("setInput") + if (this.cameraId != cameraId || device?.id != cameraId) { + this.cameraId = cameraId + + // Abort any captures in the session so we get the onCaptureFailed handler for any outstanding photos + session?.tryAbortCaptures() + session = null + // Closing the device will also close the session above - even faster than manually closing it. + device?.close() + device = null + } + } + + fun setOutputs(outputs: List) { + Log.d(TAG, "--> setOutputs($outputs)") + assertLocked("setOutputs") + if (this.outputs != outputs) { + this.outputs = outputs + + if (outputs.isNotEmpty()) { + // Outputs have changed to something else, we don't wanna destroy the session directly + // so the outputs can be kept warm. The session that gets created next will take over the outputs. + session?.tryAbortCaptures() + } else { + // Just stop it, we don't have any outputs + session?.close() + } + session = null + } + } + + fun setRepeatingRequest(request: RepeatingCaptureRequest) { + assertLocked("setRepeatingRequest") + Log.d(TAG, "--> setRepeatingRequest(...)") + if (this.repeatingRequest != request) { + this.repeatingRequest = request + } + } + + fun setIsActive(isActive: Boolean) { + assertLocked("setIsActive") + Log.d(TAG, "--> setIsActive($isActive)") + if (this.isActive != isActive) { + this.isActive = isActive + } + if (isActive && didDestroyFromOutside) { + didDestroyFromOutside = false + } + } + + suspend fun capture( + qualityPrioritization: QualityPrioritization, + flash: Flash, + enableRedEyeReduction: Boolean, + enableAutoStabilization: Boolean, + enablePhotoHdr: Boolean, + orientation: Orientation, + enableShutterSound: Boolean + ): TotalCaptureResult { + mutex.withLock { + val session = session ?: throw CameraNotReadyError() + val repeatingRequest = repeatingRequest ?: throw CameraNotReadyError() + val photoRequest = PhotoCaptureRequest( + repeatingRequest, + qualityPrioritization, + flash, + enableRedEyeReduction, + enableAutoStabilization, + enablePhotoHdr, + orientation + ) + val device = session.device + val deviceDetails = getOrCreateCameraDeviceDetails(device) + + // Submit a single high-res capture to photo output as well as all preview outputs + val outputs = outputs + val request = photoRequest.createCaptureRequest(device, deviceDetails, outputs) + return session.capture(request.build(), enableShutterSound) + } + } + + fun getActiveDeviceDetails(): CameraDeviceDetails? { + val device = device ?: return null + return getOrCreateCameraDeviceDetails(device) + } + + private suspend fun configure() { + if (didDestroyFromOutside && !isActive) { + Log.d(TAG, "CameraCaptureSession has been destroyed by Android, skipping configuration until isActive is set to `true` again.") + return + } + Log.d(TAG, "Configure() with isActive: $isActive, ID: $cameraId, device: $device, session: $session") + val cameraId = cameraId ?: throw NoCameraDeviceError() + val repeatingRequest = repeatingRequest ?: throw CameraNotReadyError() + val outputs = outputs + + try { + didDestroyFromOutside = false + + val device = getOrCreateDevice(cameraId) + if (didDestroyFromOutside) return + + if (outputs.isEmpty()) return + val session = getOrCreateSession(device, outputs) + if (didDestroyFromOutside) return + + if (isActive) { + Log.d(TAG, "Updating repeating request...") + val details = getOrCreateCameraDeviceDetails(device) + val repeatingOutputs = outputs.filter { it.isRepeating } + val builder = repeatingRequest.createCaptureRequest(device, details, repeatingOutputs) + session.setRepeatingRequest(builder.build(), null, null) + } else { + session.stopRepeating() + Log.d(TAG, "Stopping repeating request...") + } + Log.d(TAG, "Configure() done! isActive: $isActive, ID: $cameraId, device: $device, session: $session") + } catch (e: CameraAccessException) { + if (didDestroyFromOutside) { + // Camera device has been destroyed in the meantime, that's fine. + Log.d(TAG, "Configure() canceled, session has been destroyed in the meantime!") + } else { + // Camera should still be active, so not sure what went wrong. Rethrow + throw e + } + } + } + + private suspend fun getOrCreateDevice(cameraId: String): CameraDevice { + val currentDevice = device + if (currentDevice?.id == cameraId && currentDevice.isValid) { + return currentDevice + } + + this.session?.tryAbortCaptures() + this.device?.close() + this.device = null + this.session = null + + Log.i(TAG, "Creating new device...") + val newDevice = cameraManager.openCamera(cameraId, { device, error -> + Log.i(TAG, "Camera $device closed!") + if (this.device == device) { + this.didDestroyFromOutside = true + this.session?.tryAbortCaptures() + this.session = null + this.device = null + this.isActive = false + } + if (error != null) { + callback.onError(error) + } + }, CameraQueues.videoQueue) + this.device = newDevice + return newDevice + } + + private suspend fun getOrCreateSession(device: CameraDevice, outputs: List): CameraCaptureSession { + val currentSession = session + if (currentSession?.device == device) { + return currentSession + } + + if (outputs.isEmpty()) throw NoOutputsError() + + Log.i(TAG, "Creating new session...") + val newSession = device.createCaptureSession(cameraManager, outputs, { session -> + Log.i(TAG, "Session $session closed!") + if (this.session == session) { + this.didDestroyFromOutside = true + this.session?.tryAbortCaptures() + this.session = null + this.isActive = false + } + }, CameraQueues.videoQueue) + session = newSession + return newSession + } + + private fun getOrCreateCameraDeviceDetails(device: CameraDevice): CameraDeviceDetails { + val currentDetails = cameraDeviceDetails + if (currentDetails?.cameraId == device.id) { + return currentDetails + } + + val newDetails = CameraDeviceDetails(cameraManager, device.id) + cameraDeviceDetails = newDetails + return newDetails + } + + interface Callback { + fun onError(error: Throwable) + } + + class SessionIsNotLockedError(message: String) : Error(message) +} diff --git a/package/android/src/main/java/com/mrousavy/camera/core/VideoPipeline.kt b/package/android/src/main/java/com/mrousavy/camera/core/VideoPipeline.kt index f6df554f58..f864642424 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/VideoPipeline.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/VideoPipeline.kt @@ -125,8 +125,11 @@ class VideoPipeline( isActive = false imageWriter?.close() imageReader?.close() + removeRecordingSessionOutputSurface() recordingSession = null + surfaceTexture.setOnFrameAvailableListener(null, null) surfaceTexture.release() + surface.release() } } @@ -170,7 +173,7 @@ class VideoPipeline( synchronized(this) { if (recordingSession != null) { // Configure OpenGL pipeline to stream Frames into the Recording Session's surface - Log.i(TAG, "Setting $width x $height RecordingSession Output...") + Log.i(TAG, "Setting ${recordingSession.size} RecordingSession Output...") setRecordingSessionOutputSurface(recordingSession.surface) this.recordingSession = recordingSession } else { diff --git a/package/android/src/main/java/com/mrousavy/camera/core/capture/CameraCaptureRequest.kt b/package/android/src/main/java/com/mrousavy/camera/core/capture/CameraCaptureRequest.kt new file mode 100644 index 0000000000..5a73913894 --- /dev/null +++ b/package/android/src/main/java/com/mrousavy/camera/core/capture/CameraCaptureRequest.kt @@ -0,0 +1,86 @@ +package com.mrousavy.camera.core.capture + +import android.hardware.camera2.CameraDevice +import android.hardware.camera2.CaptureRequest +import com.mrousavy.camera.core.CameraDeviceDetails +import com.mrousavy.camera.core.FlashUnavailableError +import com.mrousavy.camera.core.InvalidVideoHdrError +import com.mrousavy.camera.core.LowLightBoostNotSupportedError +import com.mrousavy.camera.core.PropRequiresFormatToBeNonNullError +import com.mrousavy.camera.core.outputs.SurfaceOutput +import com.mrousavy.camera.extensions.setZoom +import com.mrousavy.camera.types.CameraDeviceFormat +import com.mrousavy.camera.types.Torch + +abstract class CameraCaptureRequest( + private val torch: Torch = Torch.OFF, + private val enableVideoHdr: Boolean = false, + val enableLowLightBoost: Boolean = false, + val exposureBias: Double? = null, + val zoom: Float = 1.0f, + val format: CameraDeviceFormat? = null +) { + enum class Template { + RECORD, + PHOTO, + PHOTO_ZSL, + PHOTO_SNAPSHOT, + PREVIEW; + + fun toRequestTemplate(): Int = + when (this) { + RECORD -> CameraDevice.TEMPLATE_RECORD + PHOTO -> CameraDevice.TEMPLATE_STILL_CAPTURE + PHOTO_ZSL -> CameraDevice.TEMPLATE_ZERO_SHUTTER_LAG + PHOTO_SNAPSHOT -> CameraDevice.TEMPLATE_VIDEO_SNAPSHOT + PREVIEW -> CameraDevice.TEMPLATE_PREVIEW + } + } + + abstract fun createCaptureRequest( + device: CameraDevice, + deviceDetails: CameraDeviceDetails, + outputs: List + ): CaptureRequest.Builder + + protected open fun createCaptureRequest( + template: Template, + device: CameraDevice, + deviceDetails: CameraDeviceDetails, + outputs: List + ): CaptureRequest.Builder { + val builder = device.createCaptureRequest(template.toRequestTemplate()) + + // Add all repeating output surfaces + outputs.forEach { output -> + builder.addTarget(output.surface) + } + + // Set HDR + if (enableVideoHdr) { + if (format == null) throw PropRequiresFormatToBeNonNullError("videoHdr") + if (!format.supportsVideoHdr) throw InvalidVideoHdrError() + builder.set(CaptureRequest.CONTROL_SCENE_MODE, CaptureRequest.CONTROL_SCENE_MODE_HDR) + } else if (enableLowLightBoost) { + if (!deviceDetails.supportsLowLightBoost) throw LowLightBoostNotSupportedError() + builder.set(CaptureRequest.CONTROL_SCENE_MODE, CaptureRequest.CONTROL_SCENE_MODE_NIGHT) + } + + // Set Exposure Bias + if (exposureBias != null) { + val clamped = deviceDetails.exposureRange.clamp(exposureBias.toInt()) + builder.set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, clamped) + } + + // Set Zoom + builder.setZoom(zoom, deviceDetails.characteristics) + + // Set Torch + if (torch == Torch.ON) { + if (!deviceDetails.hasFlash) throw FlashUnavailableError() + builder.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_TORCH) + } + + return builder + } +} diff --git a/package/android/src/main/java/com/mrousavy/camera/core/capture/PhotoCaptureRequest.kt b/package/android/src/main/java/com/mrousavy/camera/core/capture/PhotoCaptureRequest.kt new file mode 100644 index 0000000000..82b8dc006c --- /dev/null +++ b/package/android/src/main/java/com/mrousavy/camera/core/capture/PhotoCaptureRequest.kt @@ -0,0 +1,113 @@ +package com.mrousavy.camera.core.capture + +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraDevice +import android.hardware.camera2.CaptureRequest +import android.util.Log +import com.mrousavy.camera.core.CameraDeviceDetails +import com.mrousavy.camera.core.outputs.SurfaceOutput +import com.mrousavy.camera.types.Flash +import com.mrousavy.camera.types.Orientation +import com.mrousavy.camera.types.QualityPrioritization +import com.mrousavy.camera.types.Torch + +class PhotoCaptureRequest( + repeatingRequest: RepeatingCaptureRequest, + private val qualityPrioritization: QualityPrioritization, + private val flash: Flash, + private val enableRedEyeReduction: Boolean, + private val enableAutoStabilization: Boolean, + enablePhotoHdr: Boolean, + private val outputOrientation: Orientation +) : CameraCaptureRequest( + Torch.OFF, + enablePhotoHdr, + repeatingRequest.enableLowLightBoost, + repeatingRequest.exposureBias, + repeatingRequest.zoom, + repeatingRequest.format +) { + companion object { + private const val TAG = "PhotoCaptureRequest" + } + + override fun createCaptureRequest( + device: CameraDevice, + deviceDetails: CameraDeviceDetails, + outputs: List + ): CaptureRequest.Builder { + val template = when (qualityPrioritization) { + QualityPrioritization.QUALITY -> Template.PHOTO + QualityPrioritization.BALANCED -> { + if (deviceDetails.supportsZsl) { + Template.PHOTO_ZSL + } else { + Template.PHOTO + } + } + QualityPrioritization.SPEED -> { + if (deviceDetails.supportsSnapshotCapture) { + Template.PHOTO_SNAPSHOT + } else if (deviceDetails.supportsZsl) { + Template.PHOTO_ZSL + } else { + Template.PHOTO + } + } + } + Log.i(TAG, "Using CaptureRequest Template $template...") + return this.createCaptureRequest(template, device, deviceDetails, outputs) + } + + override fun createCaptureRequest( + template: Template, + device: CameraDevice, + deviceDetails: CameraDeviceDetails, + outputs: List + ): CaptureRequest.Builder { + val builder = super.createCaptureRequest(template, device, deviceDetails, outputs) + + // Set JPEG quality + val jpegQuality = when (qualityPrioritization) { + QualityPrioritization.SPEED -> 85 + QualityPrioritization.BALANCED -> 92 + QualityPrioritization.QUALITY -> 100 + } + builder.set(CaptureRequest.JPEG_QUALITY, jpegQuality.toByte()) + + // Set JPEG Orientation + val targetOrientation = outputOrientation.toSensorRelativeOrientation(deviceDetails) + builder.set(CaptureRequest.JPEG_ORIENTATION, targetOrientation.toDegrees()) + + // TODO: Fix flash. + when (flash) { + // Set the Flash Mode + Flash.OFF -> { + builder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON) + builder.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF) + } + Flash.ON -> { + builder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON) + builder.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_TORCH) + } + Flash.AUTO -> { + if (enableRedEyeReduction) { + builder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH_REDEYE) + } else { + builder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH) + } + } + } + + // Set stabilization for this Frame + if (enableAutoStabilization) { + if (deviceDetails.opticalStabilizationModes.contains(CameraCharacteristics.LENS_OPTICAL_STABILIZATION_MODE_ON)) { + builder.set(CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE, CameraCharacteristics.LENS_OPTICAL_STABILIZATION_MODE_ON) + } else if (deviceDetails.digitalStabilizationModes.contains(CameraCharacteristics.CONTROL_VIDEO_STABILIZATION_MODE_ON)) { + builder.set(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE, CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_ON) + } + } + + return builder + } +} diff --git a/package/android/src/main/java/com/mrousavy/camera/core/capture/RepeatingCaptureRequest.kt b/package/android/src/main/java/com/mrousavy/camera/core/capture/RepeatingCaptureRequest.kt new file mode 100644 index 0000000000..3e790bb796 --- /dev/null +++ b/package/android/src/main/java/com/mrousavy/camera/core/capture/RepeatingCaptureRequest.kt @@ -0,0 +1,79 @@ +package com.mrousavy.camera.core.capture + +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraDevice +import android.hardware.camera2.CaptureRequest +import android.util.Range +import com.mrousavy.camera.core.CameraDeviceDetails +import com.mrousavy.camera.core.InvalidFpsError +import com.mrousavy.camera.core.InvalidVideoStabilizationMode +import com.mrousavy.camera.core.PropRequiresFormatToBeNonNullError +import com.mrousavy.camera.core.outputs.SurfaceOutput +import com.mrousavy.camera.types.CameraDeviceFormat +import com.mrousavy.camera.types.Torch +import com.mrousavy.camera.types.VideoStabilizationMode + +class RepeatingCaptureRequest( + private val enableVideoPipeline: Boolean, + torch: Torch = Torch.OFF, + private val fps: Int? = null, + private val videoStabilizationMode: VideoStabilizationMode = VideoStabilizationMode.OFF, + enableVideoHdr: Boolean = false, + enableLowLightBoost: Boolean = false, + exposureBias: Double? = null, + zoom: Float = 1.0f, + format: CameraDeviceFormat? = null +) : CameraCaptureRequest(torch, enableVideoHdr, enableLowLightBoost, exposureBias, zoom, format) { + override fun createCaptureRequest( + device: CameraDevice, + deviceDetails: CameraDeviceDetails, + outputs: List + ): CaptureRequest.Builder { + val template = if (enableVideoPipeline) Template.RECORD else Template.PREVIEW + return this.createCaptureRequest(template, device, deviceDetails, outputs) + } + + private fun getBestDigitalStabilizationMode(deviceDetails: CameraDeviceDetails): Int { + if (deviceDetails.digitalStabilizationModes.contains(CameraCharacteristics.CONTROL_VIDEO_STABILIZATION_MODE_PREVIEW_STABILIZATION)) { + return CameraCharacteristics.CONTROL_VIDEO_STABILIZATION_MODE_PREVIEW_STABILIZATION + } + return CameraCharacteristics.CONTROL_VIDEO_STABILIZATION_MODE_ON + } + + override fun createCaptureRequest( + template: Template, + device: CameraDevice, + deviceDetails: CameraDeviceDetails, + outputs: List + ): CaptureRequest.Builder { + val builder = super.createCaptureRequest(template, device, deviceDetails, outputs) + + // Set FPS + if (fps != null) { + if (format == null) throw PropRequiresFormatToBeNonNullError("fps") + if (format.maxFps < fps) throw InvalidFpsError(fps) + builder.set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, Range(fps, fps)) + } + + // Set Video Stabilization + if (videoStabilizationMode != VideoStabilizationMode.OFF) { + if (format == null) throw PropRequiresFormatToBeNonNullError("videoStabilizationMode") + if (!format.videoStabilizationModes.contains(videoStabilizationMode)) { + throw InvalidVideoStabilizationMode(videoStabilizationMode) + } + } + when (videoStabilizationMode) { + VideoStabilizationMode.OFF -> { + // do nothing + } + VideoStabilizationMode.STANDARD -> { + builder.set(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE, getBestDigitalStabilizationMode(deviceDetails)) + } + VideoStabilizationMode.CINEMATIC, VideoStabilizationMode.CINEMATIC_EXTENDED -> { + builder.set(CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE, CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE_ON) + } + } + + return builder + } +} diff --git a/package/android/src/main/java/com/mrousavy/camera/core/outputs/SurfaceOutput.kt b/package/android/src/main/java/com/mrousavy/camera/core/outputs/SurfaceOutput.kt index 5ca1b15f60..6948a60017 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/outputs/SurfaceOutput.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/outputs/SurfaceOutput.kt @@ -10,13 +10,7 @@ import android.view.Surface import androidx.annotation.RequiresApi import java.io.Closeable -open class SurfaceOutput( - val surface: Surface, - val size: Size, - val outputType: OutputType, - val enableHdr: Boolean = false, - private val closeSurfaceOnEnd: Boolean = false -) : Closeable { +open class SurfaceOutput(val surface: Surface, val size: Size, val outputType: OutputType, val enableHdr: Boolean = false) : Closeable { companion object { const val TAG = "SurfaceOutput" @@ -52,12 +46,18 @@ open class SurfaceOutput( return result } + val isRepeating: Boolean + get() { + return when (outputType) { + OutputType.VIDEO, OutputType.PREVIEW, OutputType.VIDEO_AND_PREVIEW -> true + OutputType.PHOTO -> false + } + } + override fun toString(): String = "$outputType (${size.width} x ${size.height})" override fun close() { - if (closeSurfaceOnEnd) { - surface.release() - } + // close() does nothing by default } enum class OutputType { diff --git a/package/android/src/main/java/com/mrousavy/camera/extensions/CameraCaptureSession+tryAbortCaptures.kt b/package/android/src/main/java/com/mrousavy/camera/extensions/CameraCaptureSession+tryAbortCaptures.kt new file mode 100644 index 0000000000..24c64ce9b4 --- /dev/null +++ b/package/android/src/main/java/com/mrousavy/camera/extensions/CameraCaptureSession+tryAbortCaptures.kt @@ -0,0 +1,9 @@ +package com.mrousavy.camera.extensions + +import android.hardware.camera2.CameraCaptureSession + +fun CameraCaptureSession.tryAbortCaptures() { + try { + abortCaptures() + } catch (_: Throwable) {} +} diff --git a/package/android/src/main/java/com/mrousavy/camera/extensions/CameraDevice+createPhotoCaptureRequest.kt b/package/android/src/main/java/com/mrousavy/camera/extensions/CameraDevice+createPhotoCaptureRequest.kt deleted file mode 100644 index 0c425a829f..0000000000 --- a/package/android/src/main/java/com/mrousavy/camera/extensions/CameraDevice+createPhotoCaptureRequest.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.mrousavy.camera.extensions - -import android.hardware.camera2.CameraCharacteristics -import android.hardware.camera2.CameraDevice -import android.hardware.camera2.CameraManager -import android.hardware.camera2.CaptureRequest -import android.view.Surface -import com.mrousavy.camera.types.Flash -import com.mrousavy.camera.types.Orientation -import com.mrousavy.camera.types.QualityPrioritization - -private fun supportsSnapshotCapture(cameraCharacteristics: CameraCharacteristics): Boolean { - // As per CameraDevice.TEMPLATE_VIDEO_SNAPSHOT in documentation: - val hardwareLevel = cameraCharacteristics.get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL)!! - if (hardwareLevel == CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY) return false - - val capabilities = cameraCharacteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES)!! - val hasDepth = capabilities.contains(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_DEPTH_OUTPUT) - val isBackwardsCompatible = !capabilities.contains(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_BACKWARD_COMPATIBLE) - if (hasDepth && !isBackwardsCompatible) return false - - return true -} - -fun CameraDevice.createPhotoCaptureRequest( - cameraManager: CameraManager, - surface: Surface, - zoom: Float, - qualityPrioritization: QualityPrioritization, - flashMode: Flash, - enableRedEyeReduction: Boolean, - enableAutoStabilization: Boolean, - enableHdr: Boolean, - orientation: Orientation -): CaptureRequest { - val cameraCharacteristics = cameraManager.getCameraCharacteristics(this.id) - - val template = if (qualityPrioritization == QualityPrioritization.SPEED && supportsSnapshotCapture(cameraCharacteristics)) { - CameraDevice.TEMPLATE_VIDEO_SNAPSHOT - } else { - CameraDevice.TEMPLATE_STILL_CAPTURE - } - val captureRequest = this.createCaptureRequest(template) - captureRequest.addTarget(surface) - - // TODO: Maybe we can even expose that prop directly? - val jpegQuality = when (qualityPrioritization) { - QualityPrioritization.SPEED -> 85 - QualityPrioritization.BALANCED -> 92 - QualityPrioritization.QUALITY -> 100 - } - captureRequest.set(CaptureRequest.JPEG_QUALITY, jpegQuality.toByte()) - - captureRequest.set(CaptureRequest.JPEG_ORIENTATION, orientation.toDegrees()) - - // TODO: Use the same options as from the preview request. This is duplicate code! - - when (flashMode) { - // Set the Flash Mode - Flash.OFF -> { - captureRequest.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON) - captureRequest.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_OFF) - } - Flash.ON -> { - captureRequest.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON) - captureRequest.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_TORCH) - } - Flash.AUTO -> { - if (enableRedEyeReduction) { - captureRequest.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH_REDEYE) - } else { - captureRequest.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH) - } - } - } - - if (enableAutoStabilization) { - // Enable optical or digital image stabilization - val digitalStabilization = cameraCharacteristics.get(CameraCharacteristics.CONTROL_AVAILABLE_VIDEO_STABILIZATION_MODES) - val hasDigitalStabilization = digitalStabilization?.contains(CameraCharacteristics.CONTROL_VIDEO_STABILIZATION_MODE_ON) ?: false - - val opticalStabilization = cameraCharacteristics.get(CameraCharacteristics.LENS_INFO_AVAILABLE_OPTICAL_STABILIZATION) - val hasOpticalStabilization = opticalStabilization?.contains(CameraCharacteristics.LENS_OPTICAL_STABILIZATION_MODE_ON) ?: false - if (hasOpticalStabilization) { - captureRequest.set(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE, CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_OFF) - captureRequest.set(CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE, CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE_ON) - } else if (hasDigitalStabilization) { - captureRequest.set(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE, CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_ON) - } else { - // no stabilization is supported. ignore it - } - } - - // TODO: Check if that zoom value is even supported. - captureRequest.setZoom(zoom, cameraCharacteristics) - - // Set HDR - // TODO: Check if that value is even supported - if (enableHdr) { - captureRequest.set(CaptureRequest.CONTROL_SCENE_MODE, CaptureRequest.CONTROL_SCENE_MODE_HDR) - } - - return captureRequest.build() -} diff --git a/package/android/src/main/java/com/mrousavy/camera/extensions/CameraDevice+isValid.kt b/package/android/src/main/java/com/mrousavy/camera/extensions/CameraDevice+isValid.kt new file mode 100644 index 0000000000..4a991f45b0 --- /dev/null +++ b/package/android/src/main/java/com/mrousavy/camera/extensions/CameraDevice+isValid.kt @@ -0,0 +1,13 @@ +package com.mrousavy.camera.extensions + +import android.hardware.camera2.CameraDevice + +val CameraDevice.isValid: Boolean + get() { + try { + this.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW) + return true + } catch (e: Throwable) { + return false + } + } diff --git a/package/android/src/main/java/com/mrousavy/camera/types/Orientation.kt b/package/android/src/main/java/com/mrousavy/camera/types/Orientation.kt index 926941bdd3..c228ba0c2b 100644 --- a/package/android/src/main/java/com/mrousavy/camera/types/Orientation.kt +++ b/package/android/src/main/java/com/mrousavy/camera/types/Orientation.kt @@ -1,6 +1,6 @@ package com.mrousavy.camera.types -import android.hardware.camera2.CameraCharacteristics +import com.mrousavy.camera.core.CameraDeviceDetails enum class Orientation(override val unionValue: String) : JSUnionValue { PORTRAIT("portrait"), @@ -16,18 +16,17 @@ enum class Orientation(override val unionValue: String) : JSUnionValue { LANDSCAPE_LEFT -> 270 } - fun toSensorRelativeOrientation(cameraCharacteristics: CameraCharacteristics): Orientation { - val sensorOrientation = cameraCharacteristics.get(CameraCharacteristics.SENSOR_ORIENTATION)!! - + fun toSensorRelativeOrientation(deviceDetails: CameraDeviceDetails): Orientation { // Convert target orientation to rotation degrees (0, 90, 180, 270) var rotationDegrees = this.toDegrees() // Reverse device orientation for front-facing cameras - val facingFront = cameraCharacteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT - if (facingFront) rotationDegrees = -rotationDegrees + if (deviceDetails.lensFacing == LensFacing.FRONT) { + rotationDegrees = -rotationDegrees + } // Rotate sensor rotation by target rotation - val newRotationDegrees = (sensorOrientation + rotationDegrees + 360) % 360 + val newRotationDegrees = (deviceDetails.sensorOrientation + rotationDegrees + 360) % 360 return fromRotationDegrees(newRotationDegrees) } diff --git a/package/android/src/main/java/com/mrousavy/camera/types/VideoStabilizationMode.kt b/package/android/src/main/java/com/mrousavy/camera/types/VideoStabilizationMode.kt index 7d1ff25344..87de458e93 100644 --- a/package/android/src/main/java/com/mrousavy/camera/types/VideoStabilizationMode.kt +++ b/package/android/src/main/java/com/mrousavy/camera/types/VideoStabilizationMode.kt @@ -13,21 +13,6 @@ enum class VideoStabilizationMode(override val unionValue: String) : JSUnionValu CINEMATIC("cinematic"), CINEMATIC_EXTENDED("cinematic-extended"); - fun toDigitalStabilizationMode(): Int = - when (this) { - OFF -> CONTROL_VIDEO_STABILIZATION_MODE_OFF - STANDARD -> CONTROL_VIDEO_STABILIZATION_MODE_ON - CINEMATIC -> 2 // TODO: CONTROL_VIDEO_STABILIZATION_MODE_PREVIEW_STABILIZATION - else -> CONTROL_VIDEO_STABILIZATION_MODE_OFF - } - - fun toOpticalStabilizationMode(): Int = - when (this) { - OFF -> LENS_OPTICAL_STABILIZATION_MODE_OFF - CINEMATIC_EXTENDED -> LENS_OPTICAL_STABILIZATION_MODE_ON - else -> LENS_OPTICAL_STABILIZATION_MODE_OFF - } - companion object : JSUnionValue.Companion { override fun fromUnionValue(unionValue: String?): VideoStabilizationMode = when (unionValue) { diff --git a/package/example/ios/Podfile.lock b/package/example/ios/Podfile.lock index c6e827aa2f..dc284e673f 100644 --- a/package/example/ios/Podfile.lock +++ b/package/example/ios/Podfile.lock @@ -484,7 +484,7 @@ PODS: - libwebp (~> 1.0) - SDWebImage/Core (~> 5.10) - SocketRocket (0.6.1) - - VisionCamera (3.8.2): + - VisionCamera (3.9.0-beta.0): - React - React-callinvoker - React-Core @@ -724,7 +724,7 @@ SPEC CHECKSUMS: SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 - VisionCamera: edbcd00e27a438b2228f67823e2b8d15a189065f + VisionCamera: f2f2fa58be438670ef5d5aa88846ffe59a78f7a8 Yoga: 4c3aa327e4a6a23eeacd71f61c81df1bcdf677d5 PODFILE CHECKSUM: 27f53791141a3303d814e09b55770336416ff4eb diff --git a/package/example/src/CameraPage.tsx b/package/example/src/CameraPage.tsx index c928b2bdee..0cf99a60ee 100644 --- a/package/example/src/CameraPage.tsx +++ b/package/example/src/CameraPage.tsx @@ -172,17 +172,19 @@ export function CameraPage({ navigation }: Props): React.ReactElement { 'Camera started!'} + onStopped={() => 'Camera stopped!'} format={format} fps={fps} photoHdr={enableHdr} videoHdr={enableHdr} lowLightBoost={device.supportsLowLightBoost && enableNightMode} - isActive={isActive} - onInitialized={onInitialized} - onError={onError} enableZoomGesture={false} animatedProps={cameraAnimatedProps} exposure={0} diff --git a/package/src/CameraError.ts b/package/src/CameraError.ts index baa02ce956..e39beb5670 100644 --- a/package/src/CameraError.ts +++ b/package/src/CameraError.ts @@ -25,6 +25,7 @@ export type SessionError = | 'session/camera-cannot-be-opened' | 'session/camera-has-been-disconnected' | 'session/audio-in-use-by-other-app' + | 'session/no-outputs' | 'session/audio-session-failed-to-activate' export type CodeScannerError = | 'code-scanner/not-compatible-with-outputs'