Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Make startRecording() awaitable 🎉 #2194

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions docs/docs/guides/RECORDING_VIDEOS.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ await camera.current.resumeRecording()
The [`startRecording(...)`](/docs/api/classes/Camera#startrecording) function can be configured to enable the [flash](/docs/api/interfaces/RecordVideoOptions#flash) while recording, which natively just enables the [`torch`](/docs/api/interfaces/CameraProps#torch) under the hood:

```ts
camera.current.startRecording({
await camera.current.startRecording({
flash: 'on',
...
})
Expand All @@ -97,7 +97,7 @@ VisionCamera also supports H.265 ([HEVC](https://en.wikipedia.org/wiki/High_Effi
If you can handle H.265 on your backend, configure the video recorder to encode in H.265:

```ts
camera.current.startRecording({
await camera.current.startRecording({
...props,
videoCodec: 'h265'
})
Expand All @@ -110,7 +110,7 @@ Videos are recorded with a target bit-rate, which the encoder aims to match as c
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%:

```ts
camera.current.startRecording({
await camera.current.startRecording({
...props,
videoBitRate: 'high'
})
Expand All @@ -119,7 +119,7 @@ camera.current.startRecording({
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%:

```ts
camera.current.startRecording({
await camera.current.startRecording({
...props,
videoBitRate: 'low'
})
Expand Down Expand Up @@ -153,7 +153,7 @@ 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):

```ts
camera.current.startRecording({
await camera.current.startRecording({
...props,
videoBitRate: bitRate // Mbps
})
Expand All @@ -164,7 +164,7 @@ camera.current.startRecording({
Since the Video is stored as a temporary file, you need save it to the Camera Roll to permanentely store it. You can use [react-native-cameraroll](https://github.com/react-native-cameraroll/react-native-cameraroll) for this:

```ts
camera.current.startRecording({
await camera.current.startRecording({
...props,
onRecordingFinished: (video) => {
const path = video.path
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,13 @@ import com.facebook.react.bridge.*
import com.mrousavy.camera.core.MicrophonePermissionError
import com.mrousavy.camera.core.RecorderError
import com.mrousavy.camera.core.RecordingSession
import com.mrousavy.camera.core.code
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 java.util.*

suspend fun CameraView.startRecording(options: ReadableMap, onRecordCallback: Callback) {
suspend fun CameraView.startRecording(options: ReadableMap, onRecordingEnded: Callback) {
// check audio permission
if (audio == true) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
Expand Down Expand Up @@ -47,11 +46,11 @@ suspend fun CameraView.startRecording(options: ReadableMap, onRecordCallback: Ca
val map = Arguments.createMap()
map.putString("path", video.path)
map.putDouble("duration", video.durationMs.toDouble() / 1000.0)
onRecordCallback(map, null)
onRecordingEnded(map, null)
}
val onError = { error: RecorderError ->
val errorMap = makeErrorMap(error.code, error.message)
onRecordCallback(null, errorMap)
val errorMap = makeErrorMap(error)
onRecordingEnded(null, errorMap)
}
cameraSession.startRecording(audio == true, codec, fileType, bitRate, callback, onError)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import kotlinx.coroutines.launch
// TODOs for the CameraView which are currently too hard to implement either because of CameraX' limitations, or my brain capacity.
//
// TODO: High-speed video recordings (export in CameraViewModule::getAvailableVideoDevices(), and set in CameraView::configurePreview()) (120FPS+)
// TODO: Better startRecording()/stopRecording() (promise + callback, wait for TurboModules/JSI)
// TODO: takePhoto() depth data
// TODO: takePhoto() raw capture
// TODO: takePhoto() return with jsi::Value Image reference for faster capture
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.modules.core.PermissionAwareActivity
import com.facebook.react.modules.core.PermissionListener
import com.facebook.react.uimanager.UIManagerHelper
import com.mrousavy.camera.core.CameraError
import com.mrousavy.camera.core.ViewNotFoundError
import com.mrousavy.camera.frameprocessor.VisionCameraInstaller
import com.mrousavy.camera.frameprocessor.VisionCameraProxy
Expand Down Expand Up @@ -81,20 +80,12 @@ 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
fun startRecording(viewTag: Int, options: ReadableMap, onRecordCallback: Callback) {
fun startRecording(viewTag: Int, options: ReadableMap, onRecordingStarted: Callback, onRecordingEnded: Callback) {
coroutineScope.launch {
val view = findCameraView(viewTag)
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 occurred while trying to start a video recording! ${error.message}", error)
onRecordCallback(null, map)
withCallback(onRecordingStarted) {
view.startRecording(options, onRecordingEnded)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.mrousavy.camera.utils

import com.facebook.react.bridge.*
import com.mrousavy.camera.core.CameraError
import com.mrousavy.camera.core.code

private fun makeErrorCauseMap(throwable: Throwable): ReadableMap {
val map = Arguments.createMap()
Expand All @@ -20,3 +22,5 @@ fun makeErrorMap(code: String? = null, message: String? = null, throwable: Throw
map.putMap("userInfo", userInfo)
return map
}

fun makeErrorMap(error: CameraError): ReadableMap = makeErrorMap(error.code, error.message, error.cause)
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.mrousavy.camera.utils

import com.facebook.react.bridge.Callback
import com.mrousavy.camera.core.CameraError
import com.mrousavy.camera.core.UnknownCameraError

inline fun withCallback(callback: Callback, closure: () -> Any?) {
try {
val result = closure()
val argument = if (result is Unit) null else result
callback.invoke(argument, null)
} catch (e: Throwable) {
e.printStackTrace()
val error = if (e is CameraError) e else UnknownCameraError(e)
val errorMap = makeErrorMap(error)
callback.invoke(null, errorMap)
}
}
6 changes: 3 additions & 3 deletions package/example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@ PODS:
- libwebp (~> 1.0)
- SDWebImage/Core (~> 5.10)
- SocketRocket (0.6.1)
- VisionCamera (3.6.6):
- VisionCamera (3.6.8):
- React
- React-callinvoker
- React-Core
Expand Down Expand Up @@ -747,9 +747,9 @@ SPEC CHECKSUMS:
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d
SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17
VisionCamera: 47d2342b724c78fb9ff3e2607c21ceda4ba21e75
VisionCamera: ce927c396e1057199dd01bf412ba3777d900e166
Yoga: 8796b55dba14d7004f980b54bcc9833ee45b28ce

PODFILE CHECKSUM: 27f53791141a3303d814e09b55770336416ff4eb

COCOAPODS: 1.13.0
COCOAPODS: 1.14.3
7 changes: 3 additions & 4 deletions package/example/src/views/CaptureButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,12 @@ const _CaptureButton: React.FC<Props> = ({
console.error('failed to stop recording!', e)
}
}, [camera])
const startRecording = useCallback(() => {
const startRecording = useCallback(async () => {
try {
if (camera.current == null) throw new Error('Camera ref is null!')

console.log('calling startRecording()...')
camera.current.startRecording({
await camera.current.startRecording({
flash: flash,
onRecordingError: (error) => {
console.error('Recording failed!', error)
Expand All @@ -115,11 +115,10 @@ const _CaptureButton: React.FC<Props> = ({
onStoppedRecording()
},
})
// TODO: wait until startRecording returns to actually find out if the recording has successfully started
console.log('called startRecording()!')
isRecording.current = true
} catch (e) {
console.error('failed to start recording!', e, 'camera')
console.error('failed to start recording!', JSON.stringify(e))
}
}, [camera, flash, onMediaCaptured, onStoppedRecording])
//#endregion
Expand Down
40 changes: 23 additions & 17 deletions package/ios/CameraView+RecordVideo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,37 @@
// MARK: - CameraView + AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureAudioDataOutputSampleBufferDelegate

extension CameraView: AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureAudioDataOutputSampleBufferDelegate {
func startRecording(options: NSDictionary, callback jsCallback: @escaping RCTResponseSenderBlock) {
func startRecording(options jsOptions: NSDictionary,
onRecordingStarted: @escaping RCTResponseSenderBlock,
onRecordingEnded: @escaping RCTResponseSenderBlock) {
// Type-safety
let callback = Callback(jsCallback)

let callback = Callback(onRecordingEnded)
let promise = Promise(wrapCallback: onRecordingStarted)

Check failure on line 20 in package/ios/CameraView+RecordVideo.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
let options: RecordVideoOptions
do {
let options = try RecordVideoOptions(fromJSValue: options)

// Start Recording with success and error callbacks
cameraSession.startRecording(
options: options,
onVideoRecorded: { video in
callback.resolve(video.toJSValue())
},
onError: { error in
callback.reject(error: error)
}
)
options = try RecordVideoOptions(fromJSValue: jsOptions)
} catch {
// Some error occured while initializing VideoSettings
if let error = error as? CameraError {
callback.reject(error: error)
promise.reject(error: error)
} else {
callback.reject(error: .capture(.unknown(message: error.localizedDescription)), cause: error as NSError)
promise.reject(error: .capture(.unknown(message: error.localizedDescription)), cause: error as NSError)
}
return
}

// Start Recording with promise for immediate resolving, and success and error callbacks for later.
cameraSession.startRecording(
options: options,
promise: promise,
onVideoRecorded: { video in
callback.resolve(video.toJSValue())
},
onError: { error in
callback.reject(error: error)
}
)
}

func stopRecording(promise: Promise) {
Expand Down
3 changes: 0 additions & 3 deletions package/ios/CameraView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,6 @@ import UIKit

// TODOs for the CameraView which are currently too hard to implement either because of AVFoundation's limitations, or my brain capacity
//
// CameraView+RecordVideo
// TODO: Better startRecording()/stopRecording() (promise + callback, wait for TurboModules/JSI)
//
// CameraView+TakePhoto
// TODO: Photo HDR

Expand Down
5 changes: 3 additions & 2 deletions package/ios/CameraViewManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ @interface RCT_EXTERN_REMAP_MODULE (CameraView, CameraViewManager, RCTViewManage
// Camera View Functions
RCT_EXTERN_METHOD(startRecording
: (nonnull NSNumber*)node options
: (NSDictionary*)options onRecordCallback
: (RCTResponseSenderBlock)onRecordCallback);
: (NSDictionary*)options onRecordingStarted
: (RCTResponseSenderBlock)onRecordingStarted onRecordingEnded
: (RCTResponseSenderBlock)onRecordingEnded);
RCT_EXTERN_METHOD(pauseRecording
: (nonnull NSNumber*)node resolve
: (RCTPromiseResolveBlock)resolve reject
Expand Down
11 changes: 5 additions & 6 deletions package/ios/CameraViewManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,13 @@ final class CameraViewManager: RCTViewManager {
#endif
}

// TODO: The startRecording() func cannot be async because RN doesn't allow
// both a callback and a Promise in a single function. Wait for TurboModules?
// This means that any errors that occur in this function have to be delegated through
// the callback, but I'd prefer for them to throw for the original function instead.
@objc
final func startRecording(_ node: NSNumber, options: NSDictionary, onRecordCallback: @escaping RCTResponseSenderBlock) {
final func startRecording(_ node: NSNumber,
options: NSDictionary,
onRecordingStarted: @escaping RCTResponseSenderBlock,
onRecordingEnded: @escaping RCTResponseSenderBlock) {
let component = getCameraView(withTag: node)
component.startRecording(options: options, callback: onRecordCallback)
component.startRecording(options: options, onRecordingStarted: onRecordingStarted, onRecordingEnded: onRecordingEnded)
}

@objc
Expand Down
Loading
Loading