fix: Use bitRate
multiplier instead of setting it to an absolute value (#2216)
* fix: Use `bitRate` multiplier instead of setting it to an absolute value * Pass override * Format * Rename * feat: Also implement Android * fix: Log Mbps properly * fix: Up-/Down-scale bit-rate if different options * fix: Parse in Manager * Update RecordingSession+getRecommendedBitRate.kt
This commit is contained in:
parent
d78798ff84
commit
d7f7095d1a
@ -9,13 +9,13 @@ import com.mrousavy.camera.core.MicrophonePermissionError
|
|||||||
import com.mrousavy.camera.core.RecorderError
|
import com.mrousavy.camera.core.RecorderError
|
||||||
import com.mrousavy.camera.core.RecordingSession
|
import com.mrousavy.camera.core.RecordingSession
|
||||||
import com.mrousavy.camera.core.code
|
import com.mrousavy.camera.core.code
|
||||||
|
import com.mrousavy.camera.types.Flash
|
||||||
|
import com.mrousavy.camera.types.RecordVideoOptions
|
||||||
import com.mrousavy.camera.types.Torch
|
import com.mrousavy.camera.types.Torch
|
||||||
import com.mrousavy.camera.types.VideoCodec
|
|
||||||
import com.mrousavy.camera.types.VideoFileType
|
|
||||||
import com.mrousavy.camera.utils.makeErrorMap
|
import com.mrousavy.camera.utils.makeErrorMap
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
suspend fun CameraView.startRecording(options: ReadableMap, onRecordCallback: Callback) {
|
suspend fun CameraView.startRecording(options: RecordVideoOptions, onRecordCallback: Callback) {
|
||||||
// check audio permission
|
// check audio permission
|
||||||
if (audio == true) {
|
if (audio == true) {
|
||||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
|
||||||
@ -23,25 +23,13 @@ suspend fun CameraView.startRecording(options: ReadableMap, onRecordCallback: Ca
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val enableFlash = options.getString("flash") == "on"
|
val enableFlash = options.flash == Flash.ON
|
||||||
if (enableFlash) {
|
if (enableFlash) {
|
||||||
// overrides current torch mode value to enable flash while recording
|
// overrides current torch mode value to enable flash while recording
|
||||||
cameraSession.configure { config ->
|
cameraSession.configure { config ->
|
||||||
config.torch = Torch.ON
|
config.torch = Torch.ON
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var codec = VideoCodec.H264
|
|
||||||
if (options.hasKey("videoCodec")) {
|
|
||||||
codec = VideoCodec.fromUnionValue(options.getString("videoCodec"))
|
|
||||||
}
|
|
||||||
var fileType = VideoFileType.MP4
|
|
||||||
if (options.hasKey("fileType")) {
|
|
||||||
fileType = VideoFileType.fromUnionValue(options.getString("fileType"))
|
|
||||||
}
|
|
||||||
var bitRate: Double? = null
|
|
||||||
if (options.hasKey("videoBitRate")) {
|
|
||||||
bitRate = options.getDouble("videoBitRate")
|
|
||||||
}
|
|
||||||
|
|
||||||
val callback = { video: RecordingSession.Video ->
|
val callback = { video: RecordingSession.Video ->
|
||||||
val map = Arguments.createMap()
|
val map = Arguments.createMap()
|
||||||
@ -53,7 +41,7 @@ suspend fun CameraView.startRecording(options: ReadableMap, onRecordCallback: Ca
|
|||||||
val errorMap = makeErrorMap(error.code, error.message)
|
val errorMap = makeErrorMap(error.code, error.message)
|
||||||
onRecordCallback(null, errorMap)
|
onRecordCallback(null, errorMap)
|
||||||
}
|
}
|
||||||
cameraSession.startRecording(audio == true, codec, fileType, bitRate, callback, onError)
|
cameraSession.startRecording(audio == true, options, callback, onError)
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("RestrictedApi")
|
@SuppressLint("RestrictedApi")
|
||||||
|
@ -83,10 +83,11 @@ 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
|
// TODO: startRecording() cannot be awaited, because I can't have a Promise and a onRecordedCallback in the same function. Hopefully TurboModules allows that
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun startRecording(viewTag: Int, options: ReadableMap, onRecordCallback: Callback) {
|
fun startRecording(viewTag: Int, jsOptions: ReadableMap, onRecordCallback: Callback) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val view = findCameraView(viewTag)
|
val view = findCameraView(viewTag)
|
||||||
try {
|
try {
|
||||||
|
val options = RecordVideoOptions(jsOptions)
|
||||||
view.startRecording(options, onRecordCallback)
|
view.startRecording(options, onRecordCallback)
|
||||||
} catch (error: CameraError) {
|
} catch (error: CameraError) {
|
||||||
val map = makeErrorMap("${error.domain}/${error.id}", error.message, error)
|
val map = makeErrorMap("${error.domain}/${error.id}", error.message, error)
|
||||||
|
@ -41,9 +41,8 @@ import com.mrousavy.camera.frameprocessor.FrameProcessor
|
|||||||
import com.mrousavy.camera.types.Flash
|
import com.mrousavy.camera.types.Flash
|
||||||
import com.mrousavy.camera.types.Orientation
|
import com.mrousavy.camera.types.Orientation
|
||||||
import com.mrousavy.camera.types.QualityPrioritization
|
import com.mrousavy.camera.types.QualityPrioritization
|
||||||
|
import com.mrousavy.camera.types.RecordVideoOptions
|
||||||
import com.mrousavy.camera.types.Torch
|
import com.mrousavy.camera.types.Torch
|
||||||
import com.mrousavy.camera.types.VideoCodec
|
|
||||||
import com.mrousavy.camera.types.VideoFileType
|
|
||||||
import com.mrousavy.camera.types.VideoStabilizationMode
|
import com.mrousavy.camera.types.VideoStabilizationMode
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
import java.util.concurrent.CancellationException
|
import java.util.concurrent.CancellationException
|
||||||
@ -516,20 +515,21 @@ class CameraSession(private val context: Context, private val cameraManager: Cam
|
|||||||
|
|
||||||
suspend fun startRecording(
|
suspend fun startRecording(
|
||||||
enableAudio: Boolean,
|
enableAudio: Boolean,
|
||||||
codec: VideoCodec,
|
options: RecordVideoOptions,
|
||||||
fileType: VideoFileType,
|
|
||||||
bitRate: Double?,
|
|
||||||
callback: (video: RecordingSession.Video) -> Unit,
|
callback: (video: RecordingSession.Video) -> Unit,
|
||||||
onError: (error: RecorderError) -> Unit
|
onError: (error: RecorderError) -> Unit
|
||||||
) {
|
) {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
if (recording != null) throw RecordingInProgressError()
|
if (recording != null) throw RecordingInProgressError()
|
||||||
val videoOutput = videoOutput ?: throw VideoNotEnabledError()
|
val videoOutput = videoOutput ?: throw VideoNotEnabledError()
|
||||||
|
val cameraDevice = cameraDevice ?: throw CameraNotReadyError()
|
||||||
|
|
||||||
|
// TODO: Implement HDR
|
||||||
|
val hdr = configuration?.videoHdr ?: false
|
||||||
val fps = configuration?.fps ?: 30
|
val fps = configuration?.fps ?: 30
|
||||||
|
|
||||||
val recording =
|
val recording =
|
||||||
RecordingSession(context, videoOutput.size, enableAudio, fps, codec, orientation, fileType, bitRate, callback, onError)
|
RecordingSession(context, cameraDevice.id, videoOutput.size, enableAudio, fps, hdr, orientation, options, callback, onError)
|
||||||
recording.start()
|
recording.start()
|
||||||
this.recording = recording
|
this.recording = recording
|
||||||
}
|
}
|
||||||
|
@ -7,20 +7,20 @@ import android.os.Build
|
|||||||
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 com.mrousavy.camera.extensions.getRecommendedBitRate
|
||||||
import com.mrousavy.camera.types.Orientation
|
import com.mrousavy.camera.types.Orientation
|
||||||
import com.mrousavy.camera.types.VideoCodec
|
import com.mrousavy.camera.types.RecordVideoOptions
|
||||||
import com.mrousavy.camera.types.VideoFileType
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class RecordingSession(
|
class RecordingSession(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
val cameraId: String,
|
||||||
val size: Size,
|
val size: Size,
|
||||||
private val enableAudio: Boolean,
|
private val enableAudio: Boolean,
|
||||||
private val fps: Int? = null,
|
private val fps: Int? = null,
|
||||||
private val codec: VideoCodec = VideoCodec.H264,
|
private val hdr: Boolean = false,
|
||||||
private val orientation: Orientation,
|
private val orientation: Orientation,
|
||||||
private val fileType: VideoFileType = VideoFileType.MP4,
|
private val options: RecordVideoOptions,
|
||||||
videoBitRate: Double? = null,
|
|
||||||
private val callback: (video: Video) -> Unit,
|
private val callback: (video: Video) -> Unit,
|
||||||
private val onError: (error: RecorderError) -> Unit
|
private val onError: (error: RecorderError) -> Unit
|
||||||
) {
|
) {
|
||||||
@ -34,14 +34,14 @@ class RecordingSession(
|
|||||||
|
|
||||||
data class Video(val path: String, val durationMs: Long)
|
data class Video(val path: String, val durationMs: Long)
|
||||||
|
|
||||||
private val bitRate = videoBitRate ?: getDefaultBitRate()
|
private val bitRate = getBitRate()
|
||||||
private val recorder: MediaRecorder
|
private val recorder: MediaRecorder
|
||||||
private val outputFile: File
|
private val outputFile: File
|
||||||
private var startTime: Long? = null
|
private var startTime: Long? = null
|
||||||
val surface: Surface = MediaCodec.createPersistentInputSurface()
|
val surface: Surface = MediaCodec.createPersistentInputSurface()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
outputFile = File.createTempFile("mrousavy", fileType.toExtension(), context.cacheDir)
|
outputFile = File.createTempFile("mrousavy", options.fileType.toExtension(), context.cacheDir)
|
||||||
|
|
||||||
Log.i(TAG, "Creating RecordingSession for ${outputFile.absolutePath}")
|
Log.i(TAG, "Creating RecordingSession for ${outputFile.absolutePath}")
|
||||||
|
|
||||||
@ -52,12 +52,12 @@ class RecordingSession(
|
|||||||
|
|
||||||
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||||
recorder.setOutputFile(outputFile.absolutePath)
|
recorder.setOutputFile(outputFile.absolutePath)
|
||||||
recorder.setVideoEncodingBitRate((bitRate * 1_000_000).toInt())
|
recorder.setVideoEncodingBitRate(bitRate)
|
||||||
recorder.setVideoSize(size.height, size.width)
|
recorder.setVideoSize(size.height, size.width)
|
||||||
if (fps != null) recorder.setVideoFrameRate(fps)
|
if (fps != null) recorder.setVideoFrameRate(fps)
|
||||||
|
|
||||||
Log.i(TAG, "Using $codec Video Codec at $bitRate Mbps..")
|
Log.i(TAG, "Using ${options.videoCodec} Video Codec at ${bitRate / 1_000_000.0} Mbps..")
|
||||||
recorder.setVideoEncoder(codec.toVideoCodec())
|
recorder.setVideoEncoder(options.videoCodec.toVideoEncoder())
|
||||||
if (enableAudio) {
|
if (enableAudio) {
|
||||||
Log.i(TAG, "Adding Audio Channel..")
|
Log.i(TAG, "Adding Audio Channel..")
|
||||||
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||||
@ -124,22 +124,26 @@ class RecordingSession(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDefaultBitRate(): Double {
|
/**
|
||||||
var baseBitRate = when (size.width * size.height) {
|
* Get the bit-rate to use, in bits per seconds.
|
||||||
in 0..640 * 480 -> 2.0
|
* This can either be overridden, multiplied, or just left at the recommended value.
|
||||||
in 640 * 480..1280 * 720 -> 5.0
|
*/
|
||||||
in 1280 * 720..1920 * 1080 -> 10.0
|
private fun getBitRate(): Int {
|
||||||
in 1920 * 1080..3840 * 2160 -> 30.0
|
var bitRate = getRecommendedBitRate(fps ?: 30, options.videoCodec, hdr)
|
||||||
in 3840 * 2160..7680 * 4320 -> 100.0
|
options.videoBitRateOverride?.let { override ->
|
||||||
else -> 100.0
|
// Mbps -> bps
|
||||||
|
bitRate = (override * 1_000_000).toInt()
|
||||||
}
|
}
|
||||||
baseBitRate = baseBitRate / 30.0 * (fps ?: 30).toDouble()
|
options.videoBitRateMultiplier?.let { multiplier ->
|
||||||
if (this.codec == VideoCodec.H265) baseBitRate *= 0.8
|
// multiply by 1.2, 0.8, ...
|
||||||
return baseBitRate
|
bitRate = (bitRate * multiplier).toInt()
|
||||||
|
}
|
||||||
|
return bitRate
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun toString(): String {
|
override fun toString(): String {
|
||||||
val audio = if (enableAudio) "with audio" else "without audio"
|
val audio = if (enableAudio) "with audio" else "without audio"
|
||||||
return "${size.width} x ${size.height} @ $fps FPS $codec $fileType $orientation $bitRate Mbps RecordingSession ($audio)"
|
return "${size.width} x ${size.height} @ $fps FPS ${options.videoCodec} ${options.fileType} " +
|
||||||
|
"$orientation ${bitRate / 1_000_000.0} Mbps RecordingSession ($audio)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,100 @@
|
|||||||
|
package com.mrousavy.camera.extensions
|
||||||
|
|
||||||
|
import android.media.CamcorderProfile
|
||||||
|
import android.media.MediaRecorder.VideoEncoder
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import android.util.Size
|
||||||
|
import com.mrousavy.camera.core.RecordingSession
|
||||||
|
import com.mrousavy.camera.types.VideoCodec
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
data class RecommendedProfile(
|
||||||
|
val bitRate: Int,
|
||||||
|
// VideoEncoder.H264 or VideoEncoder.HEVC
|
||||||
|
val codec: Int,
|
||||||
|
// 8-bit or 10-bit
|
||||||
|
val bitDepth: Int,
|
||||||
|
// 30 or 60 FPS
|
||||||
|
val fps: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
fun RecordingSession.getRecommendedBitRate(fps: Int, codec: VideoCodec, hdr: Boolean): Int {
|
||||||
|
val targetResolution = size
|
||||||
|
val encoder = codec.toVideoEncoder()
|
||||||
|
val bitDepth = if (hdr) 10 else 8
|
||||||
|
val quality = findClosestCamcorderProfileQuality(targetResolution)
|
||||||
|
Log.i("CamcorderProfile", "Closest matching CamcorderProfile: $quality")
|
||||||
|
|
||||||
|
var recommendedProfile: RecommendedProfile? = null
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
val profiles = CamcorderProfile.getAll(cameraId, quality)
|
||||||
|
if (profiles != null) {
|
||||||
|
val best = profiles.videoProfiles.minBy { abs(it.width * it.height - targetResolution.width * targetResolution.height) }
|
||||||
|
|
||||||
|
recommendedProfile = RecommendedProfile(
|
||||||
|
best.bitrate,
|
||||||
|
best.codec,
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) best.bitDepth else 8,
|
||||||
|
best.frameRate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recommendedProfile == null) {
|
||||||
|
val cameraIdInt = cameraId.toIntOrNull()
|
||||||
|
val profile = if (cameraIdInt != null) {
|
||||||
|
CamcorderProfile.get(cameraIdInt, quality)
|
||||||
|
} else {
|
||||||
|
CamcorderProfile.get(quality)
|
||||||
|
}
|
||||||
|
recommendedProfile = RecommendedProfile(
|
||||||
|
profile.videoBitRate,
|
||||||
|
profile.videoCodec,
|
||||||
|
8,
|
||||||
|
profile.videoFrameRate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var bitRate = recommendedProfile.bitRate.toDouble()
|
||||||
|
// the target bit-rate is for e.g. 30 FPS, but we use 60 FPS. up-scale it
|
||||||
|
bitRate = bitRate / recommendedProfile.fps * fps
|
||||||
|
// the target bit-rate might be in 8-bit SDR, but we record in 10-bit HDR. up-scale it
|
||||||
|
bitRate = bitRate / recommendedProfile.bitDepth * bitDepth
|
||||||
|
if (recommendedProfile.codec == VideoEncoder.H264 && encoder == VideoEncoder.HEVC) {
|
||||||
|
// the target bit-rate is for H.264, but we use H.265, which is 20% smaller
|
||||||
|
bitRate *= 0.8
|
||||||
|
} else if (recommendedProfile.codec == VideoEncoder.HEVC && encoder == VideoEncoder.H264) {
|
||||||
|
// the target bit-rate is for H.265, but we use H.264, which is 20% larger
|
||||||
|
bitRate *= 1.2
|
||||||
|
}
|
||||||
|
return bitRate.toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getResolutionForCamcorderProfileQuality(camcorderProfile: Int): Int =
|
||||||
|
when (camcorderProfile) {
|
||||||
|
CamcorderProfile.QUALITY_QCIF -> 176 * 144
|
||||||
|
CamcorderProfile.QUALITY_QVGA -> 320 * 240
|
||||||
|
CamcorderProfile.QUALITY_CIF -> 352 * 288
|
||||||
|
CamcorderProfile.QUALITY_VGA -> 640 * 480
|
||||||
|
CamcorderProfile.QUALITY_480P -> 720 * 480
|
||||||
|
CamcorderProfile.QUALITY_720P -> 1280 * 720
|
||||||
|
CamcorderProfile.QUALITY_1080P -> 1920 * 1080
|
||||||
|
CamcorderProfile.QUALITY_2K -> 2048 * 1080
|
||||||
|
CamcorderProfile.QUALITY_QHD -> 2560 * 1440
|
||||||
|
CamcorderProfile.QUALITY_2160P -> 3840 * 2160
|
||||||
|
CamcorderProfile.QUALITY_4KDCI -> 4096 * 2160
|
||||||
|
CamcorderProfile.QUALITY_8KUHD -> 7680 * 4320
|
||||||
|
else -> throw Error("Invalid CamcorderProfile \"$camcorderProfile\"!")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findClosestCamcorderProfileQuality(resolution: Size): Int {
|
||||||
|
// Iterate through all available CamcorderProfiles and find the one that matches the closest
|
||||||
|
val targetResolution = resolution.width * resolution.height
|
||||||
|
val closestProfile = (CamcorderProfile.QUALITY_QCIF..CamcorderProfile.QUALITY_8KUHD).minBy { profile ->
|
||||||
|
val currentResolution = getResolutionForCamcorderProfileQuality(profile)
|
||||||
|
return@minBy abs(currentResolution - targetResolution)
|
||||||
|
}
|
||||||
|
return closestProfile
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
package com.mrousavy.camera.types
|
||||||
|
|
||||||
|
import com.facebook.react.bridge.ReadableMap
|
||||||
|
|
||||||
|
class RecordVideoOptions(map: ReadableMap) {
|
||||||
|
var fileType: VideoFileType = VideoFileType.MOV
|
||||||
|
var flash: Flash = Flash.OFF
|
||||||
|
var videoCodec = VideoCodec.H264
|
||||||
|
var videoBitRateOverride: Double? = null
|
||||||
|
var videoBitRateMultiplier: Double? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (map.hasKey("fileType")) {
|
||||||
|
fileType = VideoFileType.fromUnionValue(map.getString("fileType"))
|
||||||
|
}
|
||||||
|
if (map.hasKey("flash")) {
|
||||||
|
flash = Flash.fromUnionValue(map.getString("flash"))
|
||||||
|
}
|
||||||
|
if (map.hasKey("videoCodec")) {
|
||||||
|
videoCodec = VideoCodec.fromUnionValue(map.getString("fileType"))
|
||||||
|
}
|
||||||
|
if (map.hasKey("videoBitRateOverride")) {
|
||||||
|
videoBitRateOverride = map.getDouble("videoBitRateOverride")
|
||||||
|
}
|
||||||
|
if (map.hasKey("videoBitRateMultiplier")) {
|
||||||
|
videoBitRateMultiplier = map.getDouble("videoBitRateMultiplier")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -6,7 +6,7 @@ enum class VideoCodec(override val unionValue: String) : JSUnionValue {
|
|||||||
H264("h264"),
|
H264("h264"),
|
||||||
H265("h265");
|
H265("h265");
|
||||||
|
|
||||||
fun toVideoCodec(): Int =
|
fun toVideoEncoder(): Int =
|
||||||
when (this) {
|
when (this) {
|
||||||
H264 -> MediaRecorder.VideoEncoder.H264
|
H264 -> MediaRecorder.VideoEncoder.H264
|
||||||
H265 -> MediaRecorder.VideoEncoder.HEVC
|
H265 -> MediaRecorder.VideoEncoder.HEVC
|
||||||
|
@ -24,12 +24,31 @@ extension AVCaptureVideoDataOutput {
|
|||||||
throw CameraError.capture(.createRecorderError(message: "Failed to get video settings!"))
|
throw CameraError.capture(.createRecorderError(message: "Failed to get video settings!"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let bitRate = options.bitRate {
|
if let bitRateOverride = options.bitRateOverride {
|
||||||
// Convert from Mbps -> bps
|
// Convert from Mbps -> bps
|
||||||
let bitsPerSecond = bitRate * 1_000_000
|
let bitsPerSecond = bitRateOverride * 1_000_000
|
||||||
settings[AVVideoCompressionPropertiesKey] = [
|
if settings[AVVideoCompressionPropertiesKey] == nil {
|
||||||
AVVideoAverageBitRateKey: NSNumber(value: bitsPerSecond),
|
settings[AVVideoCompressionPropertiesKey] = [:]
|
||||||
]
|
}
|
||||||
|
var compressionSettings = settings[AVVideoCompressionPropertiesKey] as? [String: Any] ?? [:]
|
||||||
|
let currentBitRate = compressionSettings[AVVideoAverageBitRateKey] as? NSNumber
|
||||||
|
ReactLogger.log(level: .info, message: "Setting Video Bit-Rate from \(currentBitRate?.doubleValue.description ?? "nil") bps to \(bitsPerSecond) bps...")
|
||||||
|
|
||||||
|
compressionSettings[AVVideoAverageBitRateKey] = NSNumber(value: bitsPerSecond)
|
||||||
|
settings[AVVideoCompressionPropertiesKey] = compressionSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
if let bitRateMultiplier = options.bitRateMultiplier {
|
||||||
|
// Check if the bit-rate even exists in the settings
|
||||||
|
if var compressionSettings = settings[AVVideoCompressionPropertiesKey] as? [String: Any],
|
||||||
|
let currentBitRate = compressionSettings[AVVideoAverageBitRateKey] as? NSNumber {
|
||||||
|
// Multiply the current value by the given multiplier
|
||||||
|
let newBitRate = Int(currentBitRate.doubleValue * bitRateMultiplier)
|
||||||
|
ReactLogger.log(level: .info, message: "Setting Video Bit-Rate from \(currentBitRate) bps to \(newBitRate) bps...")
|
||||||
|
|
||||||
|
compressionSettings[AVVideoAverageBitRateKey] = NSNumber(value: newBitRate)
|
||||||
|
settings[AVVideoCompressionPropertiesKey] = compressionSettings
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return settings
|
return settings
|
||||||
|
@ -14,9 +14,14 @@ struct RecordVideoOptions {
|
|||||||
var flash: Torch = .off
|
var flash: Torch = .off
|
||||||
var codec: AVVideoCodecType?
|
var codec: AVVideoCodecType?
|
||||||
/**
|
/**
|
||||||
Bit-Rate of the Video, in Megabits per second (Mbps)
|
* Full Bit-Rate override for the Video Encoder, in Megabits per second (Mbps)
|
||||||
*/
|
*/
|
||||||
var bitRate: Double?
|
var bitRateOverride: Double?
|
||||||
|
/**
|
||||||
|
* A multiplier applied to whatever the currently set bit-rate is, whether it's automatically computed by the OS Encoder,
|
||||||
|
* or set via bitRate, in Megabits per second (Mbps)
|
||||||
|
*/
|
||||||
|
var bitRateMultiplier: Double?
|
||||||
|
|
||||||
init(fromJSValue dictionary: NSDictionary) throws {
|
init(fromJSValue dictionary: NSDictionary) throws {
|
||||||
// File Type (.mov or .mp4)
|
// File Type (.mov or .mp4)
|
||||||
@ -31,9 +36,13 @@ struct RecordVideoOptions {
|
|||||||
if let codecOption = dictionary["videoCodec"] as? String {
|
if let codecOption = dictionary["videoCodec"] as? String {
|
||||||
codec = try AVVideoCodecType(withString: codecOption)
|
codec = try AVVideoCodecType(withString: codecOption)
|
||||||
}
|
}
|
||||||
// BitRate
|
// BitRate Override
|
||||||
if let parsed = dictionary["videoBitRate"] as? Double {
|
if let parsed = dictionary["videoBitRateOverride"] as? Double {
|
||||||
bitRate = parsed
|
bitRateOverride = parsed
|
||||||
|
}
|
||||||
|
// BitRate Multiplier
|
||||||
|
if let parsed = dictionary["videoBitRateMultiplier"] as? Double {
|
||||||
|
bitRateMultiplier = parsed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -35,6 +35,10 @@ type NativeCameraViewProps = Omit<CameraProps, 'device' | 'onInitialized' | 'onE
|
|||||||
onCodeScanned?: (event: NativeSyntheticEvent<OnCodeScannedEvent>) => void
|
onCodeScanned?: (event: NativeSyntheticEvent<OnCodeScannedEvent>) => void
|
||||||
onViewReady: () => void
|
onViewReady: () => void
|
||||||
}
|
}
|
||||||
|
type NativeRecordVideoOptions = Omit<RecordVideoOptions, 'onRecordingError' | 'onRecordingFinished' | 'videoBitRate'> & {
|
||||||
|
videoBitRateOverride?: number
|
||||||
|
videoBitRateMultiplier?: number
|
||||||
|
}
|
||||||
type RefType = React.Component<NativeCameraViewProps> & Readonly<NativeMethods>
|
type RefType = React.Component<NativeCameraViewProps> & Readonly<NativeMethods>
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
@ -122,30 +126,15 @@ export class Camera extends React.PureComponent<CameraProps> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private calculateBitRate(bitRate: 'low' | 'normal' | 'high', codec: 'h264' | 'h265' = 'h264'): number {
|
private getBitRateMultiplier(bitRate: RecordVideoOptions['videoBitRate']): number {
|
||||||
const format = this.props.format
|
switch (bitRate) {
|
||||||
if (format == null) {
|
case 'low':
|
||||||
throw new CameraRuntimeError(
|
return 0.8
|
||||||
'parameter/invalid-combination',
|
case 'high':
|
||||||
`A videoBitRate of '${bitRate}' can only be used in combination with a 'format'!`,
|
return 1.2
|
||||||
)
|
default:
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const factor = {
|
|
||||||
low: 0.8,
|
|
||||||
normal: 1,
|
|
||||||
high: 1.2,
|
|
||||||
}[bitRate]
|
|
||||||
let result = (30 / (3840 * 2160 * 0.75)) * (format.videoWidth * format.videoHeight)
|
|
||||||
// FPS - 30 is default, 60 would be 2x, 120 would be 4x
|
|
||||||
const fps = this.props.fps ?? Math.min(format.maxFps, 30)
|
|
||||||
result = (result / 30) * fps
|
|
||||||
// H.265 (HEVC) codec is 20% more efficient
|
|
||||||
if (codec === 'h265') result = result * 0.8
|
|
||||||
// 10-Bit Video HDR takes up 20% more pixels than standard range (8-bit SDR)
|
|
||||||
if (this.props.videoHdr) result = result * 1.2
|
|
||||||
// Return overall result
|
|
||||||
return result * factor
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -165,12 +154,20 @@ export class Camera extends React.PureComponent<CameraProps> {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
public startRecording(options: RecordVideoOptions): void {
|
public startRecording(options: RecordVideoOptions): void {
|
||||||
const { onRecordingError, onRecordingFinished, ...passThroughOptions } = options
|
const { onRecordingError, onRecordingFinished, videoBitRate, ...passThruOptions } = options
|
||||||
if (typeof onRecordingError !== 'function' || typeof onRecordingFinished !== 'function')
|
if (typeof onRecordingError !== 'function' || typeof onRecordingFinished !== 'function')
|
||||||
throw new CameraRuntimeError('parameter/invalid-parameter', 'The onRecordingError or onRecordingFinished functions were not set!')
|
throw new CameraRuntimeError('parameter/invalid-parameter', 'The onRecordingError or onRecordingFinished functions were not set!')
|
||||||
|
|
||||||
const videoBitRate = passThroughOptions.videoBitRate
|
const nativeOptions: NativeRecordVideoOptions = passThruOptions
|
||||||
if (typeof videoBitRate === 'string') passThroughOptions.videoBitRate = this.calculateBitRate(videoBitRate, options.videoCodec)
|
if (typeof videoBitRate === 'string') {
|
||||||
|
// If the user passed 'low'/'normal'/'high', we need to apply this as a multiplier to the native bitrate instead of absolutely setting it
|
||||||
|
delete nativeOptions.videoBitRateOverride
|
||||||
|
nativeOptions.videoBitRateMultiplier = this.getBitRateMultiplier(videoBitRate)
|
||||||
|
} else {
|
||||||
|
// If the user passed an absolute number as a bit-rate, we just use this as a full override.
|
||||||
|
delete nativeOptions.videoBitRateOverride
|
||||||
|
nativeOptions.videoBitRateOverride = videoBitRate
|
||||||
|
}
|
||||||
|
|
||||||
const onRecordCallback = (video?: VideoFile, error?: CameraCaptureError): void => {
|
const onRecordCallback = (video?: VideoFile, error?: CameraCaptureError): void => {
|
||||||
if (error != null) return onRecordingError(error)
|
if (error != null) return onRecordingError(error)
|
||||||
@ -178,7 +175,7 @@ export class Camera extends React.PureComponent<CameraProps> {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// TODO: Use TurboModules to make this awaitable.
|
// TODO: Use TurboModules to make this awaitable.
|
||||||
CameraModule.startRecording(this.handle, passThroughOptions, onRecordCallback)
|
CameraModule.startRecording(this.handle, nativeOptions, onRecordCallback)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw tryParseNativeCameraError(e)
|
throw tryParseNativeCameraError(e)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user