feat: Respect format's aspect ratio in Preview and add resizeMode prop (#1817)

* feat(preview): respect format's aspect ratio

* fix: code guidelines and previewSize in PreviewView

* feat: add resizeMode 'cover' and 'contain' on Android
This commit is contained in:
Maxime 2023-09-22 17:32:34 +02:00 committed by GitHub
parent 9add0eb571
commit c0b80b342b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 166 additions and 67 deletions

View File

@ -7,9 +7,9 @@ import android.content.pm.PackageManager
import android.hardware.camera2.CameraManager
import android.util.Log
import android.util.Size
import android.view.Gravity
import android.view.ScaleGestureDetector
import android.view.Surface
import android.view.View
import android.widget.FrameLayout
import androidx.core.content.ContextCompat
import com.facebook.react.bridge.ReadableMap
@ -23,6 +23,10 @@ import com.mrousavy.camera.parsers.Orientation
import com.mrousavy.camera.parsers.PixelFormat
import com.mrousavy.camera.parsers.Torch
import com.mrousavy.camera.parsers.VideoStabilizationMode
import com.mrousavy.camera.extensions.bigger
import com.mrousavy.camera.extensions.getPreviewTargetSize
import com.mrousavy.camera.extensions.smaller
import com.mrousavy.camera.parsers.ResizeMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -41,7 +45,7 @@ class CameraView(context: Context) : FrameLayout(context) {
companion object {
const val TAG = "CameraView"
private val propsThatRequirePreviewReconfiguration = arrayListOf("cameraId")
private val propsThatRequirePreviewReconfiguration = arrayListOf("cameraId", "format", "resizeMode")
private val propsThatRequireSessionReconfiguration =
arrayListOf("cameraId", "format", "photo", "video", "enableFrameProcessor", "pixelFormat")
private val propsThatRequireFormatReconfiguration = arrayListOf("fps", "hdr", "videoStabilizationMode", "lowLightBoost")
@ -63,6 +67,7 @@ class CameraView(context: Context) : FrameLayout(context) {
// props that require format reconfiguring
var format: ReadableMap? = null
var resizeMode: ResizeMode = ResizeMode.COVER
var fps: Int? = null
var videoStabilizationMode: VideoStabilizationMode? = null
var hdr: Boolean? = null // nullable bool
@ -81,7 +86,7 @@ class CameraView(context: Context) : FrameLayout(context) {
// session
internal val cameraSession: CameraSession
private var previewView: View? = null
private var previewView: PreviewView? = null
private var previewSurface: Surface? = null
internal var frameProcessor: FrameProcessor? = null
@ -115,16 +120,30 @@ class CameraView(context: Context) : FrameLayout(context) {
updateLifecycle()
}
private fun getPreviewTargetSize(): Size {
val cameraId = cameraId ?: throw NoCameraDeviceError()
val format = format
val targetPreviewSize = if (format != null) Size(format.getInt("videoWidth"), format.getInt("videoHeight")) else null
val formatAspectRatio = if (targetPreviewSize != null) targetPreviewSize.bigger.toDouble() / targetPreviewSize.smaller else null
return this.cameraManager.getCameraCharacteristics(cameraId).getPreviewTargetSize(formatAspectRatio)
}
private fun setupPreviewView() {
removeView(previewView)
this.previewSurface = null
val cameraId = cameraId ?: return
val previewView = PreviewView(context, cameraManager, cameraId) { surface ->
if (cameraId == null) return
val previewView = PreviewView(context, this.getPreviewTargetSize(), resizeMode) { surface ->
previewSurface = surface
configureSession()
}
previewView.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
previewView.layoutParams = LayoutParams(
LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT,
Gravity.CENTER)
addView(previewView)
this.previewView = previewView
}
@ -183,7 +202,7 @@ class CameraView(context: Context) : FrameLayout(context) {
// TODO: Allow previewSurface to be null/none
val previewSurface = previewSurface ?: return
val previewOutput = CameraOutputs.PreviewOutput(previewSurface)
val previewOutput = CameraOutputs.PreviewOutput(previewSurface, previewView?.targetSize)
val photoOutput = if (photo == true) {
CameraOutputs.PhotoOutput(targetPhotoSize)
} else {

View File

@ -7,6 +7,7 @@ import com.facebook.react.uimanager.ViewGroupManager
import com.facebook.react.uimanager.annotations.ReactProp
import com.mrousavy.camera.parsers.Orientation
import com.mrousavy.camera.parsers.PixelFormat
import com.mrousavy.camera.parsers.ResizeMode
import com.mrousavy.camera.parsers.Torch
import com.mrousavy.camera.parsers.VideoStabilizationMode
@ -128,6 +129,14 @@ class CameraViewManager : ViewGroupManager<CameraView>() {
view.format = format
}
@ReactProp(name = "resizeMode")
fun setResizeMode(view: CameraView, resizeMode: String) {
val newMode = ResizeMode.fromUnionValue(resizeMode)
if (view.resizeMode != newMode)
addChangedPropToTransaction(view, "resizeMode")
view.resizeMode = newMode
}
// TODO: Change when TurboModules release.
// We're treating -1 as "null" here, because when I make the fps parameter
// of type "Int?" the react bridge throws an error.

View File

@ -2,30 +2,24 @@ package com.mrousavy.camera.core
import android.annotation.SuppressLint
import android.content.Context
import android.hardware.camera2.CameraManager
import android.util.Log
import android.util.Size
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import com.mrousavy.camera.extensions.getPreviewSize
import com.mrousavy.camera.extensions.bigger
import com.mrousavy.camera.extensions.smaller
import com.mrousavy.camera.parsers.ResizeMode
import kotlin.math.roundToInt
@SuppressLint("ViewConstructor")
class PreviewView(
context: Context,
cameraManager: CameraManager,
cameraId: String,
private val onSurfaceChanged: (surface: Surface?) -> Unit
) : SurfaceView(context) {
private val targetSize: Size
private val aspectRatio: Float
get() = targetSize.width.toFloat() / targetSize.height.toFloat()
val targetSize: Size,
private val resizeMode: ResizeMode,
private val onSurfaceChanged: (surface: Surface?) -> Unit): SurfaceView(context) {
init {
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
targetSize = characteristics.getPreviewSize()
Log.i(TAG, "Using Preview Size ${targetSize.width} x ${targetSize.height}.")
holder.setFixedSize(targetSize.width, targetSize.height)
holder.addCallback(object : SurfaceHolder.Callback {
@ -45,26 +39,54 @@ class PreviewView(
})
}
private fun coverSize(contentSize: Size, containerWidth: Int, containerHeight: Int): Size {
val contentAspectRatio = contentSize.height.toDouble() / contentSize.width
val containerAspectRatio = containerWidth.toDouble() / containerHeight
Log.d(TAG, "coverSize :: $contentSize ($contentAspectRatio), ${containerWidth}x${containerHeight} ($containerAspectRatio)")
return if (contentAspectRatio > containerAspectRatio) {
// Scale by width to cover height
val scaledWidth = containerHeight * contentAspectRatio
Size(scaledWidth.roundToInt(), containerHeight)
} else {
// Scale by height to cover width
val scaledHeight = containerWidth / contentAspectRatio
Size(containerWidth, scaledHeight.roundToInt())
}
}
private fun containSize(contentSize: Size, containerWidth: Int, containerHeight: Int): Size {
val contentAspectRatio = contentSize.height.toDouble() / contentSize.width
val containerAspectRatio = containerWidth.toDouble() / containerHeight
Log.d(TAG, "containSize :: $contentSize ($contentAspectRatio), ${containerWidth}x${containerHeight} ($containerAspectRatio)")
return if (contentAspectRatio > containerAspectRatio) {
// Scale by height to fit within width
val scaledHeight = containerWidth / contentAspectRatio
return Size(containerWidth, scaledHeight.roundToInt())
} else {
// Scale by width to fit within height
val scaledWidth = containerHeight * contentAspectRatio
return Size(scaledWidth.roundToInt(), containerHeight)
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
Log.d(TAG, "onMeasure($width, $height)")
val viewWidth = MeasureSpec.getSize(widthMeasureSpec)
val viewHeight = MeasureSpec.getSize(heightMeasureSpec)
// Performs center-crop transformation of the camera frames
val newWidth: Int
val newHeight: Int
val actualRatio = if (width > height) aspectRatio else 1f / aspectRatio
if (width < height * actualRatio) {
newHeight = height
newWidth = (height * actualRatio).roundToInt()
} else {
newWidth = width
newHeight = (width / actualRatio).roundToInt()
Log.d(TAG, "onMeasure($viewWidth, $viewHeight)")
val fittedSize = when (resizeMode) {
ResizeMode.COVER -> this.coverSize(targetSize, viewWidth, viewHeight)
ResizeMode.CONTAIN -> this.containSize(targetSize, viewWidth, viewHeight)
}
Log.d(TAG, "Measured dimensions set: $newWidth x $newHeight")
setMeasuredDimension(newWidth, newHeight)
Log.d(TAG, "Fitted dimensions set: $fittedSize")
setMeasuredDimension(fittedSize.width, fittedSize.height)
}
companion object {

View File

@ -12,8 +12,10 @@ import com.mrousavy.camera.CameraQueues
import com.mrousavy.camera.core.VideoPipeline
import com.mrousavy.camera.extensions.closestToOrMax
import com.mrousavy.camera.extensions.getPhotoSizes
import com.mrousavy.camera.extensions.getPreviewSize
import com.mrousavy.camera.extensions.getPreviewTargetSize
import com.mrousavy.camera.extensions.getVideoSizes
import com.mrousavy.camera.extensions.bigger
import com.mrousavy.camera.extensions.smaller
import java.io.Closeable
class CameraOutputs(
@ -30,7 +32,7 @@ class CameraOutputs(
const val PHOTO_OUTPUT_BUFFER_SIZE = 3
}
data class PreviewOutput(val surface: Surface)
data class PreviewOutput(val surface: Surface, val targetSize: Size? = null)
data class PhotoOutput(val targetSize: Size? = null, val format: Int = ImageFormat.JPEG)
data class VideoOutput(
val targetSize: Size? = null,
@ -61,11 +63,12 @@ class CameraOutputs(
override fun equals(other: Any?): Boolean {
if (other !is CameraOutputs) return false
return this.cameraId == other.cameraId &&
this.preview?.surface == other.preview?.surface &&
this.photo?.targetSize == other.photo?.targetSize &&
this.photo?.format == other.photo?.format &&
this.video?.enableRecording == other.video?.enableRecording &&
return this.cameraId == other.cameraId &&
this.preview?.surface == other.preview?.surface &&
this.preview?.targetSize == other.preview?.targetSize &&
this.photo?.targetSize == other.photo?.targetSize &&
this.photo?.format == other.photo?.format &&
this.video?.enableRecording == other.video?.enableRecording &&
this.video?.targetSize == other.video?.targetSize &&
this.video?.format == other.video?.format &&
this.enableHdr == other.enableHdr
@ -101,7 +104,11 @@ class CameraOutputs(
// Preview output: Low resolution repeating images (SurfaceView)
if (preview != null) {
Log.i(TAG, "Adding native preview view output.")
previewOutput = SurfaceOutput(preview.surface, characteristics.getPreviewSize(), SurfaceOutput.OutputType.PREVIEW)
val previewSizeAspectRatio = if (preview.targetSize != null) preview.targetSize.bigger.toDouble() / preview.targetSize.smaller else null
previewOutput = SurfaceOutput(
preview.surface,
characteristics.getPreviewTargetSize(previewSizeAspectRatio),
SurfaceOutput.OutputType.PREVIEW)
}
// Photo output: High quality still images (takePhoto())

View File

@ -1,34 +1,9 @@
package com.mrousavy.camera.extensions
import android.content.res.Resources
import android.hardware.camera2.CameraCharacteristics
import android.media.CamcorderProfile
import android.os.Build
import android.util.Log
import android.util.Size
import android.view.SurfaceHolder
import android.view.SurfaceView
private fun getMaximumPreviewSize(): Size {
// See https://developer.android.com/reference/android/hardware/camera2/params/StreamConfigurationMap
// According to the Android Developer documentation, PREVIEW streams can have a resolution
// of up to the phone's display's resolution, with a maximum of 1920x1080.
val display1080p = Size(1920, 1080)
val displaySize = Size(Resources.getSystem().displayMetrics.widthPixels, Resources.getSystem().displayMetrics.heightPixels)
val isHighResScreen = displaySize.bigger >= display1080p.bigger || displaySize.smaller >= display1080p.smaller
Log.i("PreviewSize", "Phone has a ${displaySize.width} x ${displaySize.height} screen.")
return if (isHighResScreen) display1080p else displaySize
}
/**
* Gets the maximum Preview Resolution this device is capable of streaming at. (For [SurfaceView])
*/
fun CameraCharacteristics.getPreviewSize(): Size {
val config = this.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!!
val previewSize = getMaximumPreviewSize()
val outputSizes = config.getOutputSizes(SurfaceHolder::class.java).sortedByDescending { it.width * it.height }
return outputSizes.first { it.bigger <= previewSize.bigger && it.smaller <= previewSize.smaller }
}
private fun getMaximumVideoSize(cameraId: String): Size? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {

View File

@ -0,0 +1,47 @@
package com.mrousavy.camera.extensions
import android.content.res.Resources
import android.hardware.camera2.CameraCharacteristics
import android.util.Log
import android.util.Size
import android.view.SurfaceHolder
import kotlin.math.abs
private fun getMaximumPreviewSize(): Size {
// See https://developer.android.com/reference/android/hardware/camera2/params/StreamConfigurationMap
// According to the Android Developer documentation, PREVIEW streams can have a resolution
// of up to the phone's display's resolution, with a maximum of 1920x1080.
val display1080p = Size(1920, 1080)
val displaySize = Size(Resources.getSystem().displayMetrics.widthPixels,
Resources.getSystem().displayMetrics.heightPixels)
val isHighResScreen = displaySize.bigger >= display1080p.bigger || displaySize.smaller >= display1080p.smaller
return if (isHighResScreen) display1080p else displaySize
}
fun CameraCharacteristics.getPreviewSizeFromAspectRatio(aspectRatio: Double): Size {
val config = this.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!!
val maximumPreviewSize = getMaximumPreviewSize()
val outputSizes = config.getOutputSizes(SurfaceHolder::class.java)
.sortedByDescending { it.width * it.height }
.sortedBy { abs(aspectRatio - (it.bigger.toDouble() / it.smaller)) }
return outputSizes.first { it.bigger <= maximumPreviewSize.bigger && it.smaller <= maximumPreviewSize.smaller }
}
fun CameraCharacteristics.getAutomaticPreviewSize(): Size {
val config = this.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!!
val maximumPreviewSize = getMaximumPreviewSize()
val outputSizes = config.getOutputSizes(SurfaceHolder::class.java)
.sortedByDescending { it.width * it.height }
return outputSizes.first { it.bigger <= maximumPreviewSize.bigger && it.smaller <= maximumPreviewSize.smaller }
}
fun CameraCharacteristics.getPreviewTargetSize(aspectRatio: Double?): Size {
return if (aspectRatio != null) {
getPreviewSizeFromAspectRatio(aspectRatio)
} else {
getAutomaticPreviewSize()
}
}

View File

@ -0,0 +1,16 @@
package com.mrousavy.camera.parsers
enum class ResizeMode(override val unionValue: String): JSUnionValue {
COVER("cover"),
CONTAIN("contain");
companion object: JSUnionValue.Companion<ResizeMode> {
override fun fromUnionValue(unionValue: String?): ResizeMode {
return when (unionValue) {
"cover" -> COVER
"contain" -> CONTAIN
else -> COVER
}
}
}
}

View File

@ -111,6 +111,10 @@ export interface CameraProps extends ViewProps {
* Selects a given format. By default, the best matching format is chosen.
*/
format?: CameraDeviceFormat;
/**
* Specify how you want the preview to fit the container it's in
*/
resizeMode?: 'cover' | 'contain';
/**
* Specify the frames per second this camera should use. Make sure the given `format` includes a frame rate range with the given `fps`.
*