Skip to content

Commit 5919d46

Browse files
authored
fix: Make recorder less error-prone (#189)
* Abort recording if failed to start or empty frames * Activate Audio Session on `cameraQueue` * Double-check stop recording in callback * Only call callback once * Format * Add description to `.aborted` error * Update RecordingSession.swift * Update AVAudioSession+updateCategory.swift * Rename serial dispatch queues
1 parent 02168e1 commit 5919d46

7 files changed

+97
-35
lines changed

ios/CameraError.swift

+5
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ enum CaptureError {
188188
case invalidPhotoCodec
189189
case videoNotEnabled
190190
case photoNotEnabled
191+
case aborted
191192
case unknown(message: String? = nil)
192193

193194
var code: String {
@@ -210,6 +211,8 @@ enum CaptureError {
210211
return "video-not-enabled"
211212
case .photoNotEnabled:
212213
return "photo-not-enabled"
214+
case .aborted:
215+
return "aborted"
213216
case .unknown:
214217
return "unknown"
215218
}
@@ -235,6 +238,8 @@ enum CaptureError {
235238
return "Video capture is disabled! Pass `video={true}` to enable video recordings."
236239
case .photoNotEnabled:
237240
return "Photo capture is disabled! Pass `photo={true}` to enable photo capture."
241+
case .aborted:
242+
return "The capture has been stopped before any input data arrived."
238243
case let .unknown(message: message):
239244
return message ?? "An unknown error occured while capturing a video/photo."
240245
}

ios/CameraQueues.swift

+6-3
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,26 @@ import Foundation
1010

1111
@objc
1212
public class CameraQueues: NSObject {
13+
1314
/// The serial execution queue for the camera preview layer (input stream) as well as output processing of photos.
14-
@objc public static let cameraQueue = DispatchQueue(label: "com.mrousavy.vision.camera-queue",
15+
@objc public static let cameraQueue = DispatchQueue(label: "mrousavy/VisionCamera.main",
1516
qos: .userInteractive,
1617
attributes: [],
1718
autoreleaseFrequency: .inherit,
1819
target: nil)
20+
1921
/// The serial execution queue for output processing of videos as well as frame processors.
20-
@objc public static let videoQueue = DispatchQueue(label: "com.mrousavy.vision.video-queue",
22+
@objc public static let videoQueue = DispatchQueue(label: "mrousavy/VisionCamera.video",
2123
qos: .userInteractive,
2224
attributes: [],
2325
autoreleaseFrequency: .inherit,
2426
target: nil)
2527

2628
/// The serial execution queue for output processing of audio buffers.
27-
@objc public static let audioQueue = DispatchQueue(label: "com.mrousavy.vision.audio-queue",
29+
@objc public static let audioQueue = DispatchQueue(label: "mrousavy/VisionCamera.audio",
2830
qos: .userInteractive,
2931
attributes: [],
3032
autoreleaseFrequency: .inherit,
3133
target: nil)
34+
3235
}

ios/CameraView+RecordVideo.swift

+22-20
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,16 @@ extension CameraView: AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureAud
7171
}
7272
}
7373
}
74+
75+
self.isRecording = false
7476
ReactLogger.log(level: .info, message: "RecordingSession finished with status \(status.descriptor).")
77+
7578
if let error = error as NSError? {
76-
callback.reject(error: .capture(.unknown(message: "An unknown recording error occured! \(error.description)")), cause: error)
79+
if error.domain == "capture/aborted" {
80+
callback.reject(error: .capture(.aborted), cause: error)
81+
} else {
82+
callback.reject(error: .capture(.unknown(message: "An unknown recording error occured! \(error.description)")), cause: error)
83+
}
7784
} else {
7885
if status == .completed {
7986
callback.resolve([
@@ -106,28 +113,23 @@ extension CameraView: AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureAud
106113

107114
// Init Audio (optional, async)
108115
if enableAudio {
109-
self.audioQueue.async {
110-
// Activate Audio Session (blocking)
111-
self.activateAudioSession()
112-
113-
guard let recordingSession = self.recordingSession else {
114-
// recording has already been cancelled
115-
return
116-
}
117-
if let audioOutput = self.audioOutput,
118-
let audioSettings = audioOutput.recommendedAudioSettingsForAssetWriter(writingTo: fileType) as? [String: Any] {
119-
recordingSession.initializeAudioWriter(withSettings: audioSettings)
120-
}
116+
// Activate Audio Session (blocking)
117+
self.activateAudioSession()
121118

122-
// Finally start recording, with or without audio.
123-
recordingSession.start()
124-
self.isRecording = true
119+
if let audioOutput = self.audioOutput,
120+
let audioSettings = audioOutput.recommendedAudioSettingsForAssetWriter(writingTo: fileType) as? [String: Any] {
121+
self.recordingSession!.initializeAudioWriter(withSettings: audioSettings)
125122
}
126-
} else {
127-
// start recording session without audio.
128-
self.recordingSession!.start()
129-
self.isRecording = true
130123
}
124+
125+
// start recording session with or without audio.
126+
do {
127+
try self.recordingSession!.start()
128+
} catch {
129+
callback.reject(error: .capture(.createRecorderError(message: "RecordingSession failed to start writing.")))
130+
return
131+
}
132+
self.isRecording = true
131133
}
132134
}
133135

ios/Extensions/AVAudioSession+updateCategory.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import Foundation
1111

1212
extension AVAudioSession {
1313
/**
14-
Calls [setCategory] if the given category or options are not equal to the currently set category and options and reactivates the session.
14+
Calls [setCategory] if the given category or options are not equal to the currently set category and options.
1515
*/
1616
func updateCategory(_ category: AVAudioSession.Category, options: AVAudioSession.CategoryOptions = []) throws {
1717
if self.category != category || categoryOptions.rawValue != options.rawValue {

ios/React Utils/Callback.swift

+17-6
Original file line numberDiff line numberDiff line change
@@ -12,27 +12,38 @@ import Foundation
1212
Represents a callback to JavaScript. Syntax is the same as with Promise.
1313
*/
1414
class Callback {
15+
private var hasCalled = false
16+
private let callback: RCTResponseSenderBlock
17+
1518
init(_ callback: @escaping RCTResponseSenderBlock) {
1619
self.callback = callback
1720
}
1821

1922
func reject(error: CameraError, cause: NSError?) {
23+
guard !hasCalled else { return }
24+
2025
callback([NSNull(), makeReactError(error, cause: cause)])
26+
hasCalled = true
2127
}
2228

2329
func reject(error: CameraError) {
30+
guard !hasCalled else { return }
31+
2432
reject(error: error, cause: nil)
33+
hasCalled = true
2534
}
2635

27-
func resolve(_ value: Any?) {
36+
func resolve(_ value: Any) {
37+
guard !hasCalled else { return }
38+
2839
callback([value, NSNull()])
40+
hasCalled = true
2941
}
3042

3143
func resolve() {
32-
resolve(nil)
33-
}
34-
35-
// MARK: Private
44+
guard !hasCalled else { return }
3645

37-
private let callback: RCTResponseSenderBlock
46+
resolve(NSNull())
47+
hasCalled = true
48+
}
3849
}

ios/RecordingSession.swift

+45-5
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ enum BufferType {
1616
case video
1717
}
1818

19+
// MARK: - RecordingSessionError
20+
21+
enum RecordingSessionError: Error {
22+
case failedToStartSession
23+
}
24+
1925
// MARK: - RecordingSession
2026

2127
class RecordingSession {
@@ -59,6 +65,9 @@ class RecordingSession {
5965
}
6066
}
6167

68+
/**
69+
Initializes an AssetWriter for video frames (CMSampleBuffers).
70+
*/
6271
func initializeVideoWriter(withSettings settings: [String: Any], isVideoMirrored: Bool) {
6372
guard !settings.isEmpty else {
6473
ReactLogger.log(level: .error, message: "Tried to initialize Video Writer with empty settings!", alsoLogToJS: true)
@@ -83,6 +92,9 @@ class RecordingSession {
8392
ReactLogger.log(level: .info, message: "Initialized Video AssetWriter.")
8493
}
8594

95+
/**
96+
Initializes an AssetWriter for audio frames (CMSampleBuffers).
97+
*/
8698
func initializeAudioWriter(withSettings settings: [String: Any]) {
8799
guard !settings.isEmpty else {
88100
ReactLogger.log(level: .error, message: "Tried to initialize Audio Writer with empty settings!", alsoLogToJS: true)
@@ -99,15 +111,34 @@ class RecordingSession {
99111
ReactLogger.log(level: .info, message: "Initialized Audio AssetWriter.")
100112
}
101113

102-
func start() {
103-
assetWriter.startWriting()
114+
/**
115+
Start the Asset Writer(s). If the AssetWriter failed to start, an error will be thrown.
116+
*/
117+
func start() throws {
118+
ReactLogger.log(level: .info, message: "Starting Asset Writer(s)...")
119+
120+
let success = assetWriter.startWriting()
121+
if !success {
122+
ReactLogger.log(level: .error, message: "Failed to start Asset Writer(s)!")
123+
throw RecordingSessionError.failedToStartSession
124+
}
125+
104126
initialTimestamp = CMTime(seconds: CACurrentMediaTime(), preferredTimescale: 1_000_000_000)
105127
assetWriter.startSession(atSourceTime: initialTimestamp!)
106128
ReactLogger.log(level: .info, message: "Started RecordingSession at \(initialTimestamp!.seconds) seconds.")
107129
}
108130

131+
/**
132+
Appends a new CMSampleBuffer to the Asset Writer. Use bufferType to specify if this is a video or audio frame.
133+
The timestamp parameter represents the presentation timestamp of the buffer, which should be synchronized across video and audio frames.
134+
*/
109135
func appendBuffer(_ buffer: CMSampleBuffer, type bufferType: BufferType, timestamp: CMTime) {
136+
guard assetWriter.status == .writing else {
137+
ReactLogger.log(level: .error, message: "Frame arrived, but AssetWriter status is \(assetWriter.status.descriptor)!")
138+
return
139+
}
110140
if !CMSampleBufferDataIsReady(buffer) {
141+
ReactLogger.log(level: .error, message: "Frame arrived, but sample buffer is not ready!")
111142
return
112143
}
113144
guard let initialTimestamp = initialTimestamp else {
@@ -138,7 +169,7 @@ class RecordingSession {
138169
bufferAdaptor.append(imageBuffer, withPresentationTime: timestamp)
139170
if !hasWrittenFirstVideoFrame {
140171
hasWrittenFirstVideoFrame = true
141-
ReactLogger.log(level: .warning, message: "VideoWriter: First frame arrived \((timestamp - initialTimestamp).seconds) seconds late.")
172+
ReactLogger.log(level: .warning, message: "VideoWriter: First frame arrived \((initialTimestamp - timestamp).seconds) seconds late.")
142173
}
143174
case .audio:
144175
guard let audioWriter = audioWriter else {
@@ -156,16 +187,25 @@ class RecordingSession {
156187
}
157188

158189
if assetWriter.status == .failed {
159-
// TODO: Should I call the completion handler or is this instance still valid?
160190
ReactLogger.log(level: .error,
161191
message: "AssetWriter failed to write buffer! Error: \(assetWriter.error?.localizedDescription ?? "none")",
162192
alsoLogToJS: true)
193+
finish()
163194
}
164195
}
165196

197+
/**
198+
Marks the AssetWriters as finished and stops writing frames. The callback will be invoked either with an error or the status "success".
199+
*/
166200
func finish() {
167201
ReactLogger.log(level: .info, message: "Finishing Recording with AssetWriter status \"\(assetWriter.status.descriptor)\"...")
168-
if assetWriter.status == .writing {
202+
203+
if !hasWrittenFirstVideoFrame {
204+
let error = NSError(domain: "capture/aborted",
205+
code: 1,
206+
userInfo: [NSLocalizedDescriptionKey: "Stopped Recording Session too early, no frames have been recorded!"])
207+
completionHandler(.failed, error)
208+
} else if assetWriter.status == .writing {
169209
bufferAdaptor?.assetWriterInput.markAsFinished()
170210
audioWriter?.markAsFinished()
171211
assetWriter.finishWriting {

src/CameraError.ts

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export type CaptureError =
3939
| 'capture/capture-type-not-supported'
4040
| 'capture/video-not-enabled'
4141
| 'capture/photo-not-enabled'
42+
| 'capture/aborted'
4243
| 'capture/unknown';
4344
export type SystemError = 'system/no-camera-manager';
4445
export type UnknownError = 'unknown/unknown';

0 commit comments

Comments
 (0)