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:
parent
9add0eb571
commit
c0b80b342b
@ -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 {
|
||||
|
@ -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.
|
||||
|
@ -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(
|
||||
})
|
||||
}
|
||||
|
||||
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)")
|
||||
private fun coverSize(contentSize: Size, containerWidth: Int, containerHeight: Int): Size {
|
||||
val contentAspectRatio = contentSize.height.toDouble() / contentSize.width
|
||||
val containerAspectRatio = containerWidth.toDouble() / containerHeight
|
||||
|
||||
// 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()
|
||||
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 {
|
||||
newWidth = width
|
||||
newHeight = (width / actualRatio).roundToInt()
|
||||
// Scale by height to cover width
|
||||
val scaledHeight = containerWidth / contentAspectRatio
|
||||
Size(containerWidth, scaledHeight.roundToInt())
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Measured dimensions set: $newWidth x $newHeight")
|
||||
setMeasuredDimension(newWidth, newHeight)
|
||||
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 viewWidth = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val viewHeight = MeasureSpec.getSize(heightMeasureSpec)
|
||||
|
||||
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, "Fitted dimensions set: $fittedSize")
|
||||
setMeasuredDimension(fittedSize.width, fittedSize.height)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -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,
|
||||
@ -63,6 +65,7 @@ class CameraOutputs(
|
||||
if (other !is CameraOutputs) return false
|
||||
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 &&
|
||||
@ -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())
|
||||
|
@ -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) {
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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`.
|
||||
*
|
||||
|
Loading…
Reference in New Issue
Block a user