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

View File

@ -7,6 +7,7 @@ import com.facebook.react.uimanager.ViewGroupManager
import com.facebook.react.uimanager.annotations.ReactProp import com.facebook.react.uimanager.annotations.ReactProp
import com.mrousavy.camera.parsers.Orientation import com.mrousavy.camera.parsers.Orientation
import com.mrousavy.camera.parsers.PixelFormat import com.mrousavy.camera.parsers.PixelFormat
import com.mrousavy.camera.parsers.ResizeMode
import com.mrousavy.camera.parsers.Torch import com.mrousavy.camera.parsers.Torch
import com.mrousavy.camera.parsers.VideoStabilizationMode import com.mrousavy.camera.parsers.VideoStabilizationMode
@ -128,6 +129,14 @@ class CameraViewManager : ViewGroupManager<CameraView>() {
view.format = format 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. // TODO: Change when TurboModules release.
// We're treating -1 as "null" here, because when I make the fps parameter // We're treating -1 as "null" here, because when I make the fps parameter
// of type "Int?" the react bridge throws an error. // 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.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.hardware.camera2.CameraManager
import android.util.Log import android.util.Log
import android.util.Size import android.util.Size
import android.view.Surface import android.view.Surface
import android.view.SurfaceHolder import android.view.SurfaceHolder
import android.view.SurfaceView 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 import kotlin.math.roundToInt
@SuppressLint("ViewConstructor") @SuppressLint("ViewConstructor")
class PreviewView( class PreviewView(
context: Context, context: Context,
cameraManager: CameraManager, val targetSize: Size,
cameraId: String, private val resizeMode: ResizeMode,
private val onSurfaceChanged: (surface: Surface?) -> Unit private val onSurfaceChanged: (surface: Surface?) -> Unit): SurfaceView(context) {
) : SurfaceView(context) {
private val targetSize: Size
private val aspectRatio: Float
get() = targetSize.width.toFloat() / targetSize.height.toFloat()
init { init {
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
targetSize = characteristics.getPreviewSize()
Log.i(TAG, "Using Preview Size ${targetSize.width} x ${targetSize.height}.") Log.i(TAG, "Using Preview Size ${targetSize.width} x ${targetSize.height}.")
holder.setFixedSize(targetSize.width, targetSize.height) holder.setFixedSize(targetSize.width, targetSize.height)
holder.addCallback(object : SurfaceHolder.Callback { 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) { override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec) super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val width = MeasureSpec.getSize(widthMeasureSpec) val viewWidth = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec) val viewHeight = MeasureSpec.getSize(heightMeasureSpec)
Log.d(TAG, "onMeasure($width, $height)")
// Performs center-crop transformation of the camera frames Log.d(TAG, "onMeasure($viewWidth, $viewHeight)")
val newWidth: Int
val newHeight: Int val fittedSize = when (resizeMode) {
val actualRatio = if (width > height) aspectRatio else 1f / aspectRatio ResizeMode.COVER -> this.coverSize(targetSize, viewWidth, viewHeight)
if (width < height * actualRatio) { ResizeMode.CONTAIN -> this.containSize(targetSize, viewWidth, viewHeight)
newHeight = height
newWidth = (height * actualRatio).roundToInt()
} else {
newWidth = width
newHeight = (width / actualRatio).roundToInt()
} }
Log.d(TAG, "Measured dimensions set: $newWidth x $newHeight") Log.d(TAG, "Fitted dimensions set: $fittedSize")
setMeasuredDimension(newWidth, newHeight) setMeasuredDimension(fittedSize.width, fittedSize.height)
} }
companion object { companion object {

View File

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

View File

@ -1,34 +1,9 @@
package com.mrousavy.camera.extensions package com.mrousavy.camera.extensions
import android.content.res.Resources
import android.hardware.camera2.CameraCharacteristics import android.hardware.camera2.CameraCharacteristics
import android.media.CamcorderProfile import android.media.CamcorderProfile
import android.os.Build import android.os.Build
import android.util.Log
import android.util.Size 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? { private fun getMaximumVideoSize(cameraId: String): Size? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 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. * Selects a given format. By default, the best matching format is chosen.
*/ */
format?: CameraDeviceFormat; 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`. * Specify the frames per second this camera should use. Make sure the given `format` includes a frame rate range with the given `fps`.
* *