feat: Separate usecases (decouple microphone, video, photo) (#168)
* Add props * add props (iOS) * Add use-cases conditionally * Update CameraView+RecordVideo.swift * Update RecordingSession.swift * reconfigure on change * Throw correct errors * Check for audio permission * Move `#if` outward * Throw appropriate errors * Update CameraView+RecordVideo.swift * fix Splashscreen * Dynamic filePath * Fix video extension * add `avci` and `m4v` file types * Fix RecordVideo errors * Fix audio setup * Enable `photo`, `video` and `audio` * Check for `video={true}` in frameProcessor * format * Remove unused DispatchQueue * Update docs * Add `supportsPhotoAndVideoCapture` * Fix view manager * Fix error not being propagated * Catch normal errors too * Update DEVICES.mdx * Update CAPTURING.mdx * Update classdocs
This commit is contained in:
@@ -15,10 +15,18 @@ data class TemporaryFile(val path: String)
|
||||
@SuppressLint("RestrictedApi", "MissingPermission")
|
||||
suspend fun CameraView.startRecording(options: ReadableMap, onRecordCallback: Callback): TemporaryFile {
|
||||
if (videoCapture == null) {
|
||||
throw CameraNotReadyError()
|
||||
if (video == true) {
|
||||
throw CameraNotReadyError()
|
||||
} else {
|
||||
throw VideoNotEnabledError()
|
||||
}
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
|
||||
throw MicrophonePermissionError()
|
||||
|
||||
// check audio permission
|
||||
if (audio == true) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
|
||||
throw MicrophonePermissionError()
|
||||
}
|
||||
}
|
||||
|
||||
if (options.hasKey("flash")) {
|
||||
|
@@ -19,11 +19,17 @@ import kotlin.system.measureTimeMillis
|
||||
suspend fun CameraView.takePhoto(options: ReadableMap): WritableMap = coroutineScope {
|
||||
val startFunc = System.nanoTime()
|
||||
Log.d(CameraView.TAG, "takePhoto() called")
|
||||
val imageCapture = imageCapture ?: throw CameraNotReadyError()
|
||||
if (imageCapture == null) {
|
||||
if (photo == true) {
|
||||
throw CameraNotReadyError()
|
||||
} else {
|
||||
throw PhotoNotEnabledError()
|
||||
}
|
||||
}
|
||||
|
||||
if (options.hasKey("flash")) {
|
||||
val flashMode = options.getString("flash")
|
||||
imageCapture.flashMode = when (flashMode) {
|
||||
imageCapture!!.flashMode = when (flashMode) {
|
||||
"on" -> ImageCapture.FLASH_MODE_ON
|
||||
"off" -> ImageCapture.FLASH_MODE_OFF
|
||||
"auto" -> ImageCapture.FLASH_MODE_AUTO
|
||||
@@ -61,7 +67,7 @@ suspend fun CameraView.takePhoto(options: ReadableMap): WritableMap = coroutineS
|
||||
async(coroutineContext) {
|
||||
Log.d(CameraView.TAG, "Taking picture...")
|
||||
val startCapture = System.nanoTime()
|
||||
val pic = imageCapture.takePicture(takePhotoExecutor)
|
||||
val pic = imageCapture!!.takePicture(takePhotoExecutor)
|
||||
val endCapture = System.nanoTime()
|
||||
Log.i(CameraView.TAG_PERF, "Finished image capture in ${(endCapture - startCapture) / 1_000_000}ms")
|
||||
pic
|
||||
|
@@ -68,6 +68,10 @@ class CameraView(context: Context) : FrameLayout(context), LifecycleOwner {
|
||||
var enableDepthData = false
|
||||
var enableHighResolutionCapture: Boolean? = null
|
||||
var enablePortraitEffectsMatteDelivery = false
|
||||
// use-cases
|
||||
var photo: Boolean? = null
|
||||
var video: Boolean? = null
|
||||
var audio: Boolean? = null
|
||||
// props that require format reconfiguring
|
||||
var format: ReadableMap? = null
|
||||
var fps: Int? = null
|
||||
@@ -220,9 +224,6 @@ class CameraView(context: Context) : FrameLayout(context), LifecycleOwner {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
|
||||
throw CameraPermissionError()
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
|
||||
throw MicrophonePermissionError()
|
||||
}
|
||||
if (cameraId == null) {
|
||||
throw NoCameraDeviceError()
|
||||
}
|
||||
@@ -249,7 +250,7 @@ class CameraView(context: Context) : FrameLayout(context), LifecycleOwner {
|
||||
if (format == null) {
|
||||
// let CameraX automatically find best resolution for the target aspect ratio
|
||||
Log.i(TAG, "No custom format has been set, CameraX will automatically determine best configuration...")
|
||||
val aspectRatio = aspectRatio(previewView.width, previewView.height)
|
||||
val aspectRatio = aspectRatio(previewView.height, previewView.width) // flipped because it's in sensor orientation.
|
||||
previewBuilder.setTargetAspectRatio(aspectRatio)
|
||||
imageCaptureBuilder.setTargetAspectRatio(aspectRatio)
|
||||
videoCaptureBuilder.setTargetAspectRatio(aspectRatio)
|
||||
@@ -257,7 +258,8 @@ class CameraView(context: Context) : FrameLayout(context), LifecycleOwner {
|
||||
// User has selected a custom format={}. Use that
|
||||
val format = DeviceFormat(format!!)
|
||||
Log.i(TAG, "Using custom format - photo: ${format.photoSize}, video: ${format.videoSize} @ $fps FPS")
|
||||
previewBuilder.setDefaultResolution(format.photoSize)
|
||||
val aspectRatio = aspectRatio(format.photoSize.width, format.photoSize.height)
|
||||
previewBuilder.setTargetAspectRatio(aspectRatio)
|
||||
imageCaptureBuilder.setDefaultResolution(format.photoSize)
|
||||
videoCaptureBuilder.setDefaultResolution(format.photoSize)
|
||||
|
||||
@@ -311,14 +313,23 @@ class CameraView(context: Context) : FrameLayout(context), LifecycleOwner {
|
||||
}
|
||||
|
||||
val preview = previewBuilder.build()
|
||||
imageCapture = imageCaptureBuilder.build()
|
||||
videoCapture = videoCaptureBuilder.build()
|
||||
|
||||
// Unbind use cases before rebinding
|
||||
videoCapture = null
|
||||
imageCapture = null
|
||||
cameraProvider.unbindAll()
|
||||
|
||||
// Bind use cases to camera
|
||||
camera = cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageCapture!!, videoCapture!!)
|
||||
val useCases = ArrayList<UseCase>()
|
||||
if (video == true) {
|
||||
videoCapture = videoCaptureBuilder.build()
|
||||
useCases.add(videoCapture!!)
|
||||
}
|
||||
if (photo == true) {
|
||||
imageCapture = imageCaptureBuilder.build()
|
||||
useCases.add(imageCapture!!)
|
||||
}
|
||||
camera = cameraProvider.bindToLifecycle(this, cameraSelector, preview, *useCases.toTypedArray())
|
||||
preview.setSurfaceProvider(previewView.surfaceProvider)
|
||||
|
||||
minZoom = camera!!.cameraInfo.zoomState.value?.minZoomRatio ?: 1f
|
||||
@@ -371,7 +382,7 @@ class CameraView(context: Context) : FrameLayout(context), LifecycleOwner {
|
||||
const val TAG = "CameraView"
|
||||
const val TAG_PERF = "CameraView.performance"
|
||||
|
||||
private val propsThatRequireSessionReconfiguration = arrayListOf("cameraId", "format", "fps", "hdr", "lowLightBoost")
|
||||
private val propsThatRequireSessionReconfiguration = arrayListOf("cameraId", "format", "fps", "hdr", "lowLightBoost", "photo", "video")
|
||||
|
||||
private val arrayListOfZoom = arrayListOf("zoom")
|
||||
}
|
||||
|
@@ -23,6 +23,27 @@ class CameraViewManager : SimpleViewManager<CameraView>() {
|
||||
view.cameraId = cameraId
|
||||
}
|
||||
|
||||
@ReactProp(name = "photo")
|
||||
fun setPhoto(view: CameraView, photo: Boolean?) {
|
||||
if (view.photo != photo)
|
||||
addChangedPropToTransaction(view, "photo")
|
||||
view.photo = photo
|
||||
}
|
||||
|
||||
@ReactProp(name = "video")
|
||||
fun setVideo(view: CameraView, video: Boolean?) {
|
||||
if (view.video != video)
|
||||
addChangedPropToTransaction(view, "video")
|
||||
view.video = video
|
||||
}
|
||||
|
||||
@ReactProp(name = "audio")
|
||||
fun setAudio(view: CameraView, audio: Boolean?) {
|
||||
if (view.audio != audio)
|
||||
addChangedPropToTransaction(view, "audio")
|
||||
view.audio = audio
|
||||
}
|
||||
|
||||
@ReactProp(name = "enableDepthData")
|
||||
fun setEnableDepthData(view: CameraView, enableDepthData: Boolean) {
|
||||
if (view.enableDepthData != enableDepthData)
|
||||
|
@@ -63,11 +63,19 @@ class CameraViewModule(reactContext: ReactApplicationContext) : ReactContextBase
|
||||
}
|
||||
|
||||
// TODO: startRecording() cannot be awaited, because I can't have a Promise and a onRecordedCallback in the same function. Hopefully TurboModules allows that
|
||||
@ReactMethod(isBlockingSynchronousMethod = true)
|
||||
@ReactMethod
|
||||
fun startRecording(viewTag: Int, options: ReadableMap, onRecordCallback: Callback) {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
val view = findCameraView(viewTag)
|
||||
view.startRecording(options, onRecordCallback)
|
||||
try {
|
||||
view.startRecording(options, onRecordCallback)
|
||||
} catch (error: CameraError) {
|
||||
val map = makeErrorMap("${error.domain}/${error.id}", error.message, error)
|
||||
onRecordCallback(null, map)
|
||||
} catch (error: Throwable) {
|
||||
val map = makeErrorMap("capture/unknown", "An unknown error occured while trying to start a video recording!", error)
|
||||
onRecordCallback(null, map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,16 +123,6 @@ class CameraViewModule(reactContext: ReactApplicationContext) : ReactContextBase
|
||||
val characteristics = manager.getCameraCharacteristics(id)
|
||||
val hardwareLevel = characteristics.get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL)!!
|
||||
|
||||
// Filters out cameras that are LEGACY hardware level. Those don't support Preview + Photo Capture + Video Capture at the same time.
|
||||
if (hardwareLevel == CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY) {
|
||||
Log.i(
|
||||
REACT_CLASS,
|
||||
"Skipping Camera #$id because it does not meet the minimum requirements for react-native-vision-camera. " +
|
||||
"See the tables at https://developer.android.com/reference/android/hardware/camera2/CameraDevice#regular-capture for more information."
|
||||
)
|
||||
return@loop
|
||||
}
|
||||
|
||||
val capabilities = characteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES)!!
|
||||
val isMultiCam = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
|
||||
capabilities.contains(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_LOGICAL_MULTI_CAMERA)
|
||||
@@ -162,6 +160,7 @@ class CameraViewModule(reactContext: ReactApplicationContext) : ReactContextBase
|
||||
map.putBoolean("hasFlash", hasFlash)
|
||||
map.putBoolean("hasTorch", hasFlash)
|
||||
map.putBoolean("isMultiCam", isMultiCam)
|
||||
map.putBoolean("supportsPhotoAndVideoCapture", hardwareLevel != CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY)
|
||||
map.putBoolean("supportsRawCapture", supportsRawCapture)
|
||||
map.putBoolean("supportsDepthCapture", supportsDepthCapture)
|
||||
map.putBoolean("supportsLowLightBoost", supportsLowLightBoost)
|
||||
|
@@ -30,7 +30,7 @@ abstract class CameraError(
|
||||
val CameraError.code: String
|
||||
get() = "$domain/$id"
|
||||
|
||||
class MicrophonePermissionError : CameraError("permission", "microphone-permission-denied", "The Microphone permission was denied!")
|
||||
class MicrophonePermissionError : CameraError("permission", "microphone-permission-denied", "The Microphone permission was denied! If you want to record Video without sound, pass `audio={false}`.")
|
||||
class CameraPermissionError : CameraError("permission", "camera-permission-denied", "The Camera permission was denied!")
|
||||
|
||||
class InvalidTypeScriptUnionError(unionName: String, unionValue: String) : CameraError("parameter", "invalid-parameter", "The given value for $unionName could not be parsed! (Received: $unionValue)")
|
||||
@@ -52,6 +52,9 @@ class LowLightBoostNotContainedInFormatError() : CameraError(
|
||||
|
||||
class CameraNotReadyError : CameraError("session", "camera-not-ready", "The Camera is not ready yet! Wait for the onInitialized() callback!")
|
||||
|
||||
class VideoNotEnabledError : CameraError("capture", "video-not-enabled", "Video capture is disabled! Pass `video={true}` to enable video recordings.")
|
||||
class PhotoNotEnabledError : CameraError("capture", "photo-not-enabled", "Photo capture is disabled! Pass `photo={true}` to enable photo capture.")
|
||||
|
||||
class InvalidFormatError(format: Int) : CameraError("capture", "invalid-photo-format", "The Photo has an invalid format! Expected ${ImageFormat.YUV_420_888}, actual: $format")
|
||||
class VideoEncoderError(message: String, cause: Throwable? = null) : CameraError("capture", "encoder-error", message, cause)
|
||||
class VideoMuxerError(message: String, cause: Throwable? = null) : CameraError("capture", "muxer-error", message, cause)
|
||||
|
Reference in New Issue
Block a user