Skip to content

Commit

Permalink
feat: Add Android support to videoBitRate (move it to props) (#3269)
Browse files Browse the repository at this point in the history
* feat: Add Android support to `videoBitRate` (move it to props)

* chore: Update Podfile.lock

* chore: Format

* chore: Add `?` to SurfaceHolder again

* chore: Make prettier happy `(`

* chore: Fix remove question

* chore: Update Podfile

* feat: Add `bitRateModifier` to Android

* chore: Fix conflicing overrides

* fix: Replace `bitRateModifier` -> `bitRateMultiplier`

* fix: Fix iOS build

* fix: Use `CamcorderProfile` to find recommended video bit-rate

* fix: Its already in bps
  • Loading branch information
mrousavy authored Oct 30, 2024
1 parent 48b4300 commit 8cc9803
Show file tree
Hide file tree
Showing 16 changed files with 161 additions and 116 deletions.
29 changes: 10 additions & 19 deletions docs/docs/guides/RECORDING_VIDEOS.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ camera.current.startRecording({
})
```

You can customize capture options such as [video codec](/docs/api/interfaces/RecordVideoOptions#videocodec), [video bit-rate](/docs/api/interfaces/RecordVideoOptions#videobitrate), [file type](/docs/api/interfaces/RecordVideoOptions#filetype), [enable flash](/docs/api/interfaces/RecordVideoOptions#flash) and more using the [`RecordVideoOptions`](/docs/api/interfaces/RecordVideoOptions) parameter.
You can customize capture options such as [video codec](/docs/api/interfaces/RecordVideoOptions#videocodec), [file type](/docs/api/interfaces/RecordVideoOptions#filetype), [enable flash](/docs/api/interfaces/RecordVideoOptions#flash) and more using the [`RecordVideoOptions`](/docs/api/interfaces/RecordVideoOptions) parameter.

For any error that occured _while recording the video_, the `onRecordingError` callback will be invoked with a [`CaptureError`](/docs/api/classes/CameraCaptureError) and the recording is therefore cancelled.

Expand Down Expand Up @@ -119,22 +119,16 @@ If the device does not support `h265`, VisionCamera will automatically fall-back

Videos are recorded with a target bit-rate, which the encoder aims to match as closely as possible. A lower bit-rate means less quality (and less file size), a higher bit-rate means higher quality (and larger file size) since it can assign more bits to moving pixels.

To simply record videos with higher quality, use a [`videoBitRate`](/docs/api/interfaces/RecordVideoOptions#videobitrate) of `'high'`, which effectively increases the bit-rate by 20%:
To simply record videos with higher quality, use a [`videoBitRate`](/docs/api/interfaces/CameraProps#videobitrate) of `'high'`, which effectively increases the bit-rate by 20%:

```ts
camera.current.startRecording({
...props,
videoBitRate: 'high'
})
```jsx
<Camera {...props} videoBitRate="high" />
```

To use a lower bit-rate for lower quality and lower file-size, use a [`videoBitRate`](/docs/api/interfaces/RecordVideoOptions#videobitrate) of `'low'`, which effectively decreases the bit-rate by 20%:
To use a lower bit-rate for lower quality and lower file-size, use a [`videoBitRate`](/docs/api/interfaces/CameraProps#videobitrate) of `'low'`, which effectively decreases the bit-rate by 20%:

```ts
camera.current.startRecording({
...props,
videoBitRate: 'low'
})
```jsx
<Camera {...props} videoBitRate="low" />
```

#### Custom Bit Rate
Expand Down Expand Up @@ -162,13 +156,10 @@ if (codec === 'h265') bitRate *= 0.8 // H.265
bitRate *= yourCustomFactor // e.g. 0.5x for half the bit-rate
```

And then pass it to the [`startRecording(...)`](/docs/api/classes/Camera#startrecording) function (in Mbps):
And then pass it to the `<Camera>` component (in Mbps):

```ts
camera.current.startRecording({
...props,
videoBitRate: bitRate // Mbps
})
```jsx
<Camera {...props} videoBitRate={bitRate} />
```

### Video Frame Rate (FPS)
Expand Down
16 changes: 8 additions & 8 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ PODS:
- hermes-engine (0.75.4):
- hermes-engine/Pre-built (= 0.75.4)
- hermes-engine/Pre-built (0.75.4)
- MMKV (1.3.9):
- MMKVCore (~> 1.3.9)
- MMKVCore (1.3.9)
- MMKV (2.0.0):
- MMKVCore (~> 2.0.0)
- MMKVCore (2.0.0)
- RCT-Folly (2024.01.01.00):
- boost
- DoubleConversion
Expand Down Expand Up @@ -2027,8 +2027,8 @@ SPEC CHECKSUMS:
fmt: 4c2741a687cc09f0634a2e2c72a838b99f1ff120
glog: 69ef571f3de08433d766d614c73a9838a06bf7eb
hermes-engine: ea92f60f37dba025e293cbe4b4a548fd26b610a0
MMKV: 817ba1eea17421547e01e087285606eb270a8dcb
MMKVCore: af055b00e27d88cd92fad301c5fecd1ff9b26dd9
MMKV: f7d1d5945c8765f97f39c3d121f353d46735d801
MMKVCore: c04b296010fcb1d1638f2c69405096aac12f6390
RCT-Folly: 4464f4d875961fce86008d45f4ecf6cef6de0740
RCTDeprecation: 726d24248aeab6d7180dac71a936bbca6a994ed1
RCTRequired: a94e7febda6db0345d207e854323c37e3a31d93b
Expand Down Expand Up @@ -2098,8 +2098,8 @@ SPEC CHECKSUMS:
RNVectorIcons: 6382277afab3c54658e9d555ee0faa7a37827136
SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d
VisionCamera: 3b7cfecebbcb3871c4dd3fb47791b96a47fe5371
Yoga: aa3df615739504eebb91925fc9c58b4922ea9a08
Yoga: 055f92ad73f8c8600a93f0e25ac0b2344c3b07e6

PODFILE CHECKSUM: a43dbce8eba88fb736654cbed5c32f2a764615ef
PODFILE CHECKSUM: 2ad84241179871ca890f7c65c855d117862f1a68

COCOAPODS: 1.15.2
COCOAPODS: 1.16.1
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ data class CameraConfiguration(
// Output<T> types, those need to be comparable
data class CodeScanner(val codeTypes: List<CodeType>)
data class Photo(val isMirrored: Boolean, val enableHdr: Boolean, val photoQualityBalance: QualityBalance)
data class Video(val isMirrored: Boolean, val enableHdr: Boolean)
data class Video(val isMirrored: Boolean, val enableHdr: Boolean, val bitRateOverride: Double?, val bitRateMultiplier: Double?)
data class FrameProcessor(val isMirrored: Boolean, val pixelFormat: PixelFormat)
data class Audio(val nothing: Unit)
data class Preview(val surfaceProvider: SurfaceProvider)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import com.mrousavy.camera.core.extensions.*
import com.mrousavy.camera.core.types.CameraDeviceFormat
import com.mrousavy.camera.core.types.Torch
import com.mrousavy.camera.core.types.VideoStabilizationMode
import com.mrousavy.camera.core.utils.CamcorderProfileUtils
import kotlin.math.roundToInt

private fun assertFormatRequirement(
Expand All @@ -44,7 +45,8 @@ private fun assertFormatRequirement(
@SuppressLint("RestrictedApi")
@Suppress("LiftReturnOrAssignment")
internal fun CameraSession.configureOutputs(configuration: CameraConfiguration) {
Log.i(CameraSession.TAG, "Creating new Outputs for Camera #${configuration.cameraId}...")
val cameraId = configuration.cameraId!!
Log.i(CameraSession.TAG, "Creating new Outputs for Camera #$cameraId...")
val fpsRange = configuration.targetFpsRange
val format = configuration.format

Expand Down Expand Up @@ -120,11 +122,24 @@ internal fun CameraSession.configureOutputs(configuration: CameraConfiguration)
// We are currently not recording, so we can re-create a recorder instance if needed.
Log.i(CameraSession.TAG, "Creating new Recorder...")
Recorder.Builder().also { recorder ->
configuration.format?.let { format ->
format?.let { format ->
recorder.setQualitySelector(format.videoQualitySelector)
}
// TODO: Make videoBitRate a Camera Prop
// video.setTargetVideoEncodingBitRate()
videoConfig.config.bitRateOverride?.let { bitRateOverride ->
val bps = bitRateOverride * 1_000_000
recorder.setTargetVideoEncodingBitRate(bps.toInt())
}
videoConfig.config.bitRateMultiplier?.let { bitRateMultiplier ->
if (format == null) {
// We need to get the videoSize to estimate the bitRate modifier
throw PropRequiresFormatToBeNonNullError("videoBitRate")
}
val recommendedBitRate = CamcorderProfileUtils.getRecommendedBitRate(cameraId, format.videoSize)
if (recommendedBitRate != null) {
val targetBitRate = recommendedBitRate.toDouble() * bitRateMultiplier
recorder.setTargetVideoEncodingBitRate(targetBitRate.toInt())
}
}
}.build()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,16 @@ import com.facebook.react.bridge.ReadableMap
import com.mrousavy.camera.core.utils.FileUtils
import com.mrousavy.camera.core.utils.OutputFile

class RecordVideoOptions(
val file: OutputFile,
val videoCodec: VideoCodec,
val videoBitRateOverride: Double?,
val videoBitRateMultiplier: Double?
) {
class RecordVideoOptions(val file: OutputFile, val videoCodec: VideoCodec) {

companion object {
fun fromJSValue(context: Context, map: ReadableMap): RecordVideoOptions {
val directory = if (map.hasKey("path")) FileUtils.getDirectory(map.getString("path")) else context.cacheDir
val fileType = if (map.hasKey("fileType")) VideoFileType.fromUnionValue(map.getString("fileType")) else VideoFileType.MOV
val videoCodec = if (map.hasKey("videoCodec")) VideoCodec.fromUnionValue(map.getString("videoCodec")) else VideoCodec.H264
val videoBitRateOverride = if (map.hasKey("videoBitRateOverride")) map.getDouble("videoBitRateOverride") else null
val videoBitRateMultiplier = if (map.hasKey("videoBitRateMultiplier")) map.getDouble("videoBitRateMultiplier") else null
val outputFile = OutputFile(context, directory, fileType.toExtension())
return RecordVideoOptions(outputFile, videoCodec, videoBitRateOverride, videoBitRateMultiplier)
return RecordVideoOptions(outputFile, videoCodec)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package com.mrousavy.camera.core.utils

import android.annotation.SuppressLint
import android.media.CamcorderProfile
import android.os.Build
import android.util.Log
import android.util.Size
import kotlin.math.abs

class CamcorderProfileUtils {
companion object {
private const val TAG = "CamcorderProfileUtils"

private fun getResolutionForCamcorderProfileQuality(camcorderProfile: Int): Int =
when (camcorderProfile) {
CamcorderProfile.QUALITY_QCIF -> 176 * 144
Expand All @@ -29,6 +33,7 @@ class CamcorderProfileUtils {
val targetResolution = resolution.width * resolution.height
val cameraIdInt = cameraId.toIntOrNull()

@SuppressLint("InlinedApi")
var profiles = (CamcorderProfile.QUALITY_QCIF..CamcorderProfile.QUALITY_8KUHD).filter { profile ->
if (cameraIdInt != null) {
return@filter CamcorderProfile.hasProfile(cameraIdInt, profile)
Expand Down Expand Up @@ -70,6 +75,7 @@ class CamcorderProfileUtils {
return null
} catch (e: Throwable) {
// some Samsung phones just crash when trying to get the CamcorderProfile. Only god knows why.
Log.e(TAG, "Failed to get maximum video size for Camera ID $cameraId! ${e.message}", e)
return null
}
}
Expand All @@ -94,6 +100,32 @@ class CamcorderProfileUtils {
return null
} catch (e: Throwable) {
// some Samsung phones just crash when trying to get the CamcorderProfile. Only god knows why.
Log.e(TAG, "Failed to get maximum FPS for Camera ID $cameraId! ${e.message}", e)
return null
}
}

fun getRecommendedBitRate(cameraId: String, videoSize: Size): Int? {
try {
val quality = findClosestCamcorderProfileQuality(cameraId, videoSize, true)

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val profiles = CamcorderProfile.getAll(cameraId, quality)
if (profiles != null) {
return profiles.videoProfiles.maxOf { profile -> profile.bitrate }
}
}

val cameraIdInt = cameraId.toIntOrNull()
if (cameraIdInt != null) {
val profile = CamcorderProfile.get(cameraIdInt, quality)
return profile.videoBitRate
}

return null
} catch (e: Throwable) {
// some Samsung phones just crash when trying to get the CamcorderProfile. Only god knows why.
Log.e(TAG, "Failed to get recommended video bit-rate for Camera ID $cameraId! ${e.message}", e)
return null
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import kotlinx.coroutines.launch
// TODO: takePhoto() depth data
// TODO: takePhoto() raw capture
// TODO: takePhoto() return with jsi::Value Image reference for faster capture
// TODO: Support videoCodec and videoBitRate on Android
// TODO: Support videoCodec on Android

@SuppressLint("ClickableViewAccessibility", "ViewConstructor", "MissingPermission")
class CameraView(context: Context) :
Expand Down Expand Up @@ -75,6 +75,8 @@ class CameraView(context: Context) :
var videoStabilizationMode: VideoStabilizationMode? = null
var videoHdr = false
var photoHdr = false
var videoBitRateOverride: Double? = null
var videoBitRateMultiplier: Double? = null

// TODO: Use .BALANCED once CameraX fixes it https://issuetracker.google.com/issues/337214687
var photoQualityBalance = QualityBalance.SPEED
Expand Down Expand Up @@ -180,7 +182,10 @@ class CameraView(context: Context) :

// Video
if (video || enableFrameProcessor) {
config.video = CameraConfiguration.Output.Enabled.create(CameraConfiguration.Video(isMirrored, videoHdr))
config.video =
CameraConfiguration.Output.Enabled.create(
CameraConfiguration.Video(isMirrored, videoHdr, videoBitRateOverride, videoBitRateMultiplier)
)
} else {
config.video = CameraConfiguration.Output.Disabled.create()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,24 @@ class CameraViewManager : ViewGroupManager<CameraView>() {
view.videoHdr = videoHdr
}

@ReactProp(name = "videoBitRateOverride", defaultDouble = -1.0)
fun setVideoBitRateOverride(view: CameraView, videoBitRateOverride: Double) {
if (videoBitRateOverride != -1.0) {
view.videoBitRateOverride = videoBitRateOverride
} else {
view.videoBitRateOverride = null
}
}

@ReactProp(name = "videoBitRateMultiplier", defaultDouble = -1.0)
fun setVideoBitRateMultiplier(view: CameraView, videoBitRateMultiplier: Double) {
if (videoBitRateMultiplier != -1.0) {
view.videoBitRateMultiplier = videoBitRateMultiplier
} else {
view.videoBitRateMultiplier = null
}
}

@ReactProp(name = "lowLightBoost")
fun setLowLightBoost(view: CameraView, lowLightBoost: Boolean) {
view.lowLightBoost = lowLightBoost
Expand Down
10 changes: 3 additions & 7 deletions package/ios/Core/Types/RecordVideoOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ struct RecordVideoOptions {
*/
var bitRateMultiplier: Double?

init(fromJSValue dictionary: NSDictionary) throws {
init(fromJSValue dictionary: NSDictionary, bitRateOverride: Double? = nil, bitRateMultiplier: Double? = nil) throws {
// File Type (.mov or .mp4)
if let fileTypeOption = dictionary["fileType"] as? String {
fileType = try AVFileType(withString: fileTypeOption)
Expand All @@ -38,13 +38,9 @@ struct RecordVideoOptions {
codec = try AVVideoCodecType(withString: codecOption)
}
// BitRate Override
if let parsed = dictionary["videoBitRateOverride"] as? Double {
bitRateOverride = parsed
}
self.bitRateOverride = bitRateOverride
// BitRate Multiplier
if let parsed = dictionary["videoBitRateMultiplier"] as? Double {
bitRateMultiplier = parsed
}
self.bitRateMultiplier = bitRateMultiplier
// Custom Path
let fileExtension = fileType.descriptor ?? "mov"
if let customPath = dictionary["path"] as? String {
Expand Down
4 changes: 3 additions & 1 deletion package/ios/React/CameraView+RecordVideo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ extension CameraView: AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureAud
let callback = Callback(jsCallback)

do {
let options = try RecordVideoOptions(fromJSValue: options)
let options = try RecordVideoOptions(fromJSValue: options,
bitRateOverride: videoBitRateOverride?.doubleValue,
bitRateMultiplier: videoBitRateMultiplier?.doubleValue)

// Start Recording with success and error callbacks
cameraSession.startRecording(
Expand Down
2 changes: 2 additions & 0 deletions package/ios/React/CameraView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ public final class CameraView: UIView, CameraSessionDelegate, PreviewViewDelegat
@objc var photoQualityBalance: NSString?
@objc var lowLightBoost = false
@objc var outputOrientation: NSString?
@objc var videoBitRateOverride: NSNumber?
@objc var videoBitRateMultiplier: NSNumber?

// other props
@objc var isActive = false
Expand Down
47 changes: 16 additions & 31 deletions package/ios/React/CameraViewManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ @interface RCT_EXTERN_REMAP_MODULE (CameraView, CameraViewManager, RCTViewManage
RCT_EXPORT_VIEW_PROPERTY(lowLightBoost, BOOL);
RCT_EXPORT_VIEW_PROPERTY(videoStabilizationMode, NSString);
RCT_EXPORT_VIEW_PROPERTY(pixelFormat, NSString);
RCT_EXPORT_VIEW_PROPERTY(videoBitRateOverride, NSNumber);
RCT_EXPORT_VIEW_PROPERTY(videoBitRateMultiplier, NSNumber);
// other props
RCT_EXPORT_VIEW_PROPERTY(torch, NSString);
RCT_EXPORT_VIEW_PROPERTY(zoom, NSNumber);
Expand All @@ -71,37 +73,20 @@ @interface RCT_EXTERN_REMAP_MODULE (CameraView, CameraViewManager, RCTViewManage
RCT_REMAP_VIEW_PROPERTY(onCodeScanned, onCodeScannedEvent, RCTDirectEventBlock);

// Camera View Functions
RCT_EXTERN_METHOD(startRecording
: (nonnull NSNumber*)node options
: (NSDictionary*)options onRecordCallback
: (RCTResponseSenderBlock)onRecordCallback);
RCT_EXTERN_METHOD(pauseRecording
: (nonnull NSNumber*)node resolve
: (RCTPromiseResolveBlock)resolve reject
: (RCTPromiseRejectBlock)reject);
RCT_EXTERN_METHOD(cancelRecording
: (nonnull NSNumber*)node resolve
: (RCTPromiseResolveBlock)resolve reject
: (RCTPromiseRejectBlock)reject);
RCT_EXTERN_METHOD(resumeRecording
: (nonnull NSNumber*)node resolve
: (RCTPromiseResolveBlock)resolve reject
: (RCTPromiseRejectBlock)reject);
RCT_EXTERN_METHOD(startRecording : (nonnull NSNumber*)node options : (NSDictionary*)options onRecordCallback : (RCTResponseSenderBlock)
onRecordCallback);
RCT_EXTERN_METHOD(pauseRecording : (nonnull NSNumber*)node resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)
reject);
RCT_EXTERN_METHOD(cancelRecording : (nonnull NSNumber*)node resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)
reject);
RCT_EXTERN_METHOD(resumeRecording : (nonnull NSNumber*)node resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)
reject);
RCT_EXTERN_METHOD(stopRecording : (nonnull NSNumber*)node resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject);
RCT_EXTERN_METHOD(takePhoto
: (nonnull NSNumber*)node options
: (NSDictionary*)options resolve
: (RCTPromiseResolveBlock)resolve reject
: (RCTPromiseRejectBlock)reject);
RCT_EXTERN_METHOD(takeSnapshot
: (nonnull NSNumber*)node options
: (NSDictionary*)options resolve
: (RCTPromiseResolveBlock)resolve reject
: (RCTPromiseRejectBlock)reject);
RCT_EXTERN_METHOD(focus
: (nonnull NSNumber*)node point
: (NSDictionary*)point resolve
: (RCTPromiseResolveBlock)resolve reject
: (RCTPromiseRejectBlock)reject);
RCT_EXTERN_METHOD(takePhoto : (nonnull NSNumber*)node options : (NSDictionary*)options resolve : (RCTPromiseResolveBlock)
resolve reject : (RCTPromiseRejectBlock)reject);
RCT_EXTERN_METHOD(takeSnapshot : (nonnull NSNumber*)node options : (NSDictionary*)options resolve : (RCTPromiseResolveBlock)
resolve reject : (RCTPromiseRejectBlock)reject);
RCT_EXTERN_METHOD(focus : (nonnull NSNumber*)node point : (NSDictionary*)point resolve : (RCTPromiseResolveBlock)
resolve reject : (RCTPromiseRejectBlock)reject);

@end
Loading

0 comments on commit 8cc9803

Please sign in to comment.