feat: Add customizable Video Bit Rate (videoBitRate
) (#1882)
* feat: Add `videoBitRate` option to `RecordVideoOptions` * feat: Implement `videoBitRate` on iOS * feat: Implement `videoBitRate` on Android * chore: Format * docs: Separate recording and photo docs * docs: Fix links * docs: Add docs about bitrate and quality * docs: Add blob * fix: Don't use inline style for CI * fix: Correctly log default bitRate * fix: Fix typo * fix: Calculate default bit-rate on Android depending on resolution * Update RecordingSession.kt
This commit is contained in:
@@ -33,6 +33,10 @@ suspend fun CameraView.startRecording(options: ReadableMap, onRecordCallback: Ca
|
||||
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 map = Arguments.createMap()
|
||||
@@ -44,7 +48,7 @@ suspend fun CameraView.startRecording(options: ReadableMap, onRecordCallback: Ca
|
||||
val errorMap = makeErrorMap(error.code, error.message)
|
||||
onRecordCallback(null, errorMap)
|
||||
}
|
||||
cameraSession.startRecording(audio == true, codec, fileType, callback, onError)
|
||||
cameraSession.startRecording(audio == true, codec, fileType, bitRate, callback, onError)
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
|
@@ -279,6 +279,7 @@ class CameraSession(
|
||||
enableAudio: Boolean,
|
||||
codec: VideoCodec,
|
||||
fileType: VideoFileType,
|
||||
bitRate: Double?,
|
||||
callback: (video: RecordingSession.Video) -> Unit,
|
||||
onError: (error: RecorderError) -> Unit
|
||||
) {
|
||||
@@ -287,7 +288,8 @@ class CameraSession(
|
||||
val outputs = outputs ?: throw CameraNotReadyError()
|
||||
val videoOutput = outputs.videoOutput ?: throw VideoNotEnabledError()
|
||||
|
||||
val recording = RecordingSession(context, videoOutput.size, enableAudio, fps, codec, orientation, fileType, callback, onError)
|
||||
val recording =
|
||||
RecordingSession(context, videoOutput.size, enableAudio, fps, codec, orientation, fileType, bitRate, callback, onError)
|
||||
recording.start()
|
||||
this.recording = recording
|
||||
}
|
||||
|
@@ -22,14 +22,13 @@ class RecordingSession(
|
||||
private val codec: VideoCodec = VideoCodec.H264,
|
||||
private val orientation: Orientation,
|
||||
private val fileType: VideoFileType = VideoFileType.MP4,
|
||||
videoBitRate: Double? = null,
|
||||
private val callback: (video: Video) -> Unit,
|
||||
private val onError: (error: RecorderError) -> Unit
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "RecordingSession"
|
||||
|
||||
// bits per second
|
||||
private const val VIDEO_BIT_RATE = 10_000_000
|
||||
private const val AUDIO_SAMPLING_RATE = 44_100
|
||||
private const val AUDIO_BIT_RATE = 16 * AUDIO_SAMPLING_RATE
|
||||
private const val AUDIO_CHANNELS = 1
|
||||
@@ -37,6 +36,7 @@ class RecordingSession(
|
||||
|
||||
data class Video(val path: String, val durationMs: Long)
|
||||
|
||||
private val bitRate = videoBitRate ?: getDefaultBitRate()
|
||||
private val recorder: MediaRecorder
|
||||
private val outputFile: File
|
||||
private var startTime: Long? = null
|
||||
@@ -44,7 +44,6 @@ class RecordingSession(
|
||||
val surface: Surface = MediaCodec.createPersistentInputSurface()
|
||||
|
||||
init {
|
||||
|
||||
outputFile = File.createTempFile("mrousavy", fileType.toExtension(), context.cacheDir)
|
||||
|
||||
Log.i(TAG, "Creating RecordingSession for ${outputFile.absolutePath}")
|
||||
@@ -56,11 +55,11 @@ class RecordingSession(
|
||||
|
||||
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
recorder.setOutputFile(outputFile.absolutePath)
|
||||
recorder.setVideoEncodingBitRate(VIDEO_BIT_RATE)
|
||||
recorder.setVideoEncodingBitRate((bitRate * 1_000_000).toInt())
|
||||
recorder.setVideoSize(size.height, size.width)
|
||||
if (fps != null) recorder.setVideoFrameRate(fps)
|
||||
|
||||
Log.i(TAG, "Using $codec Video Codec..")
|
||||
Log.i(TAG, "Using $codec Video Codec at $bitRate Mbps..")
|
||||
recorder.setVideoEncoder(codec.toVideoCodec())
|
||||
if (enableAudio) {
|
||||
Log.i(TAG, "Adding Audio Channel..")
|
||||
@@ -131,8 +130,22 @@ class RecordingSession(
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDefaultBitRate(): Double {
|
||||
var baseBitRate = when (size.width * size.height) {
|
||||
in 0..640 * 480 -> 2.0
|
||||
in 640 * 480..1280 * 720 -> 5.0
|
||||
in 1280 * 720..1920 * 1080 -> 10.0
|
||||
in 1920 * 1080..3840 * 2160 -> 30.0
|
||||
in 3840 * 2160..7680 * 4320 -> 100.0
|
||||
else -> 100.0
|
||||
}
|
||||
baseBitRate = baseBitRate / 30.0 * (fps ?: 30).toDouble()
|
||||
if (this.codec == VideoCodec.H265) baseBitRate *= 0.8
|
||||
return baseBitRate
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
val audio = if (enableAudio) "with audio" else "without audio"
|
||||
return "${size.width} x ${size.height} @ $fps FPS $codec $fileType $orientation RecordingSession ($audio)"
|
||||
return "${size.width} x ${size.height} @ $fps FPS $codec $fileType $orientation $bitRate Mbps RecordingSession ($audio)"
|
||||
}
|
||||
}
|
||||
|
@@ -15,17 +15,17 @@ PODS:
|
||||
- hermes-engine/Pre-built (= 0.72.3)
|
||||
- hermes-engine/Pre-built (0.72.3)
|
||||
- libevent (2.1.12)
|
||||
- libwebp (1.3.1):
|
||||
- libwebp/demux (= 1.3.1)
|
||||
- libwebp/mux (= 1.3.1)
|
||||
- libwebp/sharpyuv (= 1.3.1)
|
||||
- libwebp/webp (= 1.3.1)
|
||||
- libwebp/demux (1.3.1):
|
||||
- libwebp (1.3.2):
|
||||
- libwebp/demux (= 1.3.2)
|
||||
- libwebp/mux (= 1.3.2)
|
||||
- libwebp/sharpyuv (= 1.3.2)
|
||||
- libwebp/webp (= 1.3.2)
|
||||
- libwebp/demux (1.3.2):
|
||||
- libwebp/webp
|
||||
- libwebp/mux (1.3.1):
|
||||
- libwebp/mux (1.3.2):
|
||||
- libwebp/demux
|
||||
- libwebp/sharpyuv (1.3.1)
|
||||
- libwebp/webp (1.3.1):
|
||||
- libwebp/sharpyuv (1.3.2)
|
||||
- libwebp/webp (1.3.2):
|
||||
- libwebp/sharpyuv
|
||||
- RCT-Folly (2021.07.22.00):
|
||||
- boost
|
||||
@@ -501,7 +501,7 @@ PODS:
|
||||
- libwebp (~> 1.0)
|
||||
- SDWebImage/Core (~> 5.10)
|
||||
- SocketRocket (0.6.1)
|
||||
- VisionCamera (3.0.0):
|
||||
- VisionCamera (3.1.0):
|
||||
- React
|
||||
- React-callinvoker
|
||||
- React-Core
|
||||
@@ -686,7 +686,7 @@ SPEC CHECKSUMS:
|
||||
glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b
|
||||
hermes-engine: 10fbd3f62405c41ea07e71973ea61e1878d07322
|
||||
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
|
||||
libwebp: 33dc822fbbf4503668d09f7885bbfedc76c45e96
|
||||
libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009
|
||||
RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1
|
||||
RCTRequired: a2faf4bad4e438ca37b2040cb8f7799baa065c18
|
||||
RCTTypeSafety: cb09f3e4747b6d18331a15eb05271de7441ca0b3
|
||||
@@ -733,7 +733,7 @@ SPEC CHECKSUMS:
|
||||
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
|
||||
SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d
|
||||
SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17
|
||||
VisionCamera: 4780262974d65e89883a9b374d15459359e56ab3
|
||||
VisionCamera: 5716d5c3f2e5cbcf0e58e30e91b09b2fbb1f12ef
|
||||
Yoga: 8796b55dba14d7004f980b54bcc9833ee45b28ce
|
||||
|
||||
PODFILE CHECKSUM: ab9c06b18c63e741c04349c0fd630c6d3145081c
|
||||
|
@@ -7,6 +7,7 @@ import { CameraPage } from './CameraPage'
|
||||
import type { Routes } from './Routes'
|
||||
import { Camera, CameraPermissionStatus } from 'react-native-vision-camera'
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||
import { StyleSheet } from 'react-native'
|
||||
|
||||
const Stack = createNativeStackNavigator<Routes>()
|
||||
|
||||
@@ -29,7 +30,7 @@ export function App(): React.ReactElement | null {
|
||||
const showPermissionsPage = cameraPermission !== 'granted' || microphonePermission === 'not-determined'
|
||||
return (
|
||||
<NavigationContainer>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
@@ -52,3 +53,9 @@ export function App(): React.ReactElement | null {
|
||||
</NavigationContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
})
|
||||
|
@@ -116,12 +116,20 @@ extension CameraView: AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureAud
|
||||
}
|
||||
|
||||
// Init Video
|
||||
guard let videoSettings = self.recommendedVideoSettings(videoOutput: videoOutput, fileType: fileType, videoCodec: videoCodec),
|
||||
guard var videoSettings = self.recommendedVideoSettings(videoOutput: videoOutput, fileType: fileType, videoCodec: videoCodec),
|
||||
!videoSettings.isEmpty else {
|
||||
callback.reject(error: .capture(.createRecorderError(message: "Failed to get video settings!")))
|
||||
return
|
||||
}
|
||||
|
||||
// Custom Video Bit Rate (Mbps -> bps)
|
||||
if let videoBitRate = options["videoBitRate"] as? NSNumber {
|
||||
let bitsPerSecond = videoBitRate.doubleValue * 1_000_000
|
||||
videoSettings[AVVideoCompressionPropertiesKey] = [
|
||||
AVVideoAverageBitRateKey: NSNumber(value: bitsPerSecond),
|
||||
]
|
||||
}
|
||||
|
||||
// get pixel format (420f, 420v, x420)
|
||||
let pixelFormat = CMFormatDescriptionGetMediaSubType(videoInput.device.activeFormat.formatDescription)
|
||||
recordingSession.initializeVideoWriter(withSettings: videoSettings,
|
||||
|
@@ -114,6 +114,32 @@ export class Camera extends React.PureComponent<CameraProps> {
|
||||
}
|
||||
}
|
||||
|
||||
private calculateBitRate(bitRate: 'low' | 'normal' | 'high', codec: 'h264' | 'h265' = 'h264'): number {
|
||||
const format = this.props.format
|
||||
if (format == null) {
|
||||
throw new CameraRuntimeError(
|
||||
'parameter/invalid-combination',
|
||||
`A videoBitRate of '${bitRate}' can only be used in combination with a 'format'!`,
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
// HDR (10-bit) instead of SDR (8-bit) takes up 20% more pixels
|
||||
if (this.props.hdr) result = result * 1.2
|
||||
// Return overall result
|
||||
return result * factor
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new video recording.
|
||||
*
|
||||
@@ -135,6 +161,9 @@ export class Camera extends React.PureComponent<CameraProps> {
|
||||
if (typeof onRecordingError !== 'function' || typeof onRecordingFinished !== 'function')
|
||||
throw new CameraRuntimeError('parameter/invalid-parameter', 'The onRecordingError or onRecordingFinished functions were not set!')
|
||||
|
||||
const videoBitRate = passThroughOptions.videoBitRate
|
||||
if (typeof videoBitRate === 'string') passThroughOptions.videoBitRate = this.calculateBitRate(videoBitRate, options.videoCodec)
|
||||
|
||||
const onRecordCallback = (video?: VideoFile, error?: CameraCaptureError): void => {
|
||||
if (error != null) return onRecordingError(error)
|
||||
if (video != null) return onRecordingFinished(video)
|
||||
|
@@ -46,17 +46,17 @@ export interface CameraProps extends ViewProps {
|
||||
|
||||
//#region Use-cases
|
||||
/**
|
||||
* Enables **photo capture** with the `takePhoto` function (see ["Taking Photos"](https://react-native-vision-camera.com/docs/guides/capturing#taking-photos))
|
||||
* Enables **photo capture** with the `takePhoto` function (see ["Taking Photos"](https://react-native-vision-camera.com/docs/guides/taking-photos))
|
||||
*/
|
||||
photo?: boolean
|
||||
/**
|
||||
* Enables **video capture** with the `startRecording` function (see ["Recording Videos"](https://react-native-vision-camera.com/docs/guides/capturing/#recording-videos))
|
||||
* Enables **video capture** with the `startRecording` function (see ["Recording Videos"](https://react-native-vision-camera.com/docs/guides/recording-videos))
|
||||
*
|
||||
* Note: If both the `photo` and `video` properties are enabled at the same time and the device is running at a `hardwareLevel` of `'legacy'` or `'limited'`, VisionCamera _might_ use a lower resolution for video capture due to hardware constraints.
|
||||
*/
|
||||
video?: boolean
|
||||
/**
|
||||
* Enables **audio capture** for video recordings (see ["Recording Videos"](https://react-native-vision-camera.com/docs/guides/capturing/#recording-videos))
|
||||
* Enables **audio capture** for video recordings (see ["Recording Videos"](https://react-native-vision-camera.com/docs/guides/recording-videos))
|
||||
*/
|
||||
audio?: boolean
|
||||
/**
|
||||
|
@@ -24,6 +24,17 @@ export interface RecordVideoOptions {
|
||||
* - `h265`: The HEVC (High-Efficient-Video-Codec) for higher efficient video recordings.
|
||||
*/
|
||||
videoCodec?: 'h264' | 'h265'
|
||||
/**
|
||||
* The bit-rate for encoding the video into a file, in Mbps (Megabits per second).
|
||||
*
|
||||
* Bit-rate is dependant on various factors such as resolution, FPS, pixel format (whether it's 10 bit HDR or not), and codec.
|
||||
*
|
||||
* By default, it will be calculated using those factors.
|
||||
* For example, at 1080p, 30 FPS, H.264 codec, without HDR it will result to 10 Mbps.
|
||||
*
|
||||
* @default 'normal'
|
||||
*/
|
||||
videoBitRate?: 'low' | 'normal' | 'high' | number
|
||||
}
|
||||
|
||||
/**
|
||||
|
Reference in New Issue
Block a user