diff --git a/apps/simple-camera/__tests__/visioncamera.video.harness.ts b/apps/simple-camera/__tests__/visioncamera.video.harness.ts index cb90282cb4..2717c3b631 100644 --- a/apps/simple-camera/__tests__/visioncamera.video.harness.ts +++ b/apps/simple-camera/__tests__/visioncamera.video.harness.ts @@ -577,6 +577,49 @@ describe('VisionCamera - Video', () => { expect(recordingFailure).toBeInstanceOf(Error) }) + it('rejects starting a second Recorder while another Recorder of the same output is recording', async () => { + const session = await VisionCamera.createCameraSession(false) + const videoOutput = VisionCamera.createVideoOutput({ + targetResolution: CommonResolutions.HD_16_9, + enableAudio: false, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: videoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + const firstRecorder = await videoOutput.createRecorder({}) + const secondRecorder = await videoOutput.createRecorder({}) + const finished = deferred() + + try { + await firstRecorder.startRecording( + (_path, reason) => finished.resolve(reason), + finished.reject, + ) + // A video output records one file at a time, so the second start must + // reject instead of crashing the app or interrupting the first recording. + await expect( + secondRecorder.startRecording( + () => {}, + () => {}, + ), + ).rejects.toThrow() + expect(firstRecorder.isRecording).toBe(true) + + await sleep(1_000) + await firstRecorder.stopRecording() + const reason = await withTimeout(finished.promise, 10_000, 'finish') + expect(reason).toBe('stopped') + } finally { + await session.stop() + } + }) + // Verifies that `targetResolution` actually drives the video pipeline. // We can't introspect the captured MP4's dimensions from JS today, so the // best signal is `videoOutput.currentResolution` once the output has been diff --git a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/recording/HybridVideoRecorder.kt b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/recording/HybridVideoRecorder.kt index 78cc48c0f7..90e7b118f0 100644 --- a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/recording/HybridVideoRecorder.kt +++ b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/recording/HybridVideoRecorder.kt @@ -53,56 +53,63 @@ class HybridVideoRecorder( // Start recording this.recording = - pendingRecording.start(executor) { event -> - when (event) { - is VideoRecordEvent.Start -> { - promise.resolve() - didResolve = true - } - is VideoRecordEvent.Pause -> onRecordingPaused?.invoke() - is VideoRecordEvent.Resume -> onRecordingResumed?.invoke() - is VideoRecordEvent.Finalize -> { - // Recording is over - drop the Recording reference so `isRecording` - // flips to false even when the finalize was auto-triggered (e.g. by - // reaching `maxDuration` / `maxFileSize`) instead of via `stopRecording()`. - this.recording = null - if (isCancelled) { - // Recording was cancelled - delete the file and don't notify - isCancelled = false - file.delete() - return@start + try { + pendingRecording.start(executor) { event -> + when (event) { + is VideoRecordEvent.Start -> { + promise.resolve() + didResolve = true } - val finishReason = - when (event.error) { - VideoRecordEvent.Finalize.ERROR_NONE -> RecordingFinishedReason.STOPPED - VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED -> RecordingFinishedReason.MAX_DURATION_REACHED - VideoRecordEvent.Finalize.ERROR_FILE_SIZE_LIMIT_REACHED -> RecordingFinishedReason.MAX_FILE_SIZE_REACHED - // Source went inactive (e.g. Activity stopped mid-recording). Unlike - // ERROR_NO_VALID_DATA, CameraX still finalizes a valid file, so keep it. - VideoRecordEvent.Finalize.ERROR_SOURCE_INACTIVE -> RecordingFinishedReason.STOPPED - else -> null + is VideoRecordEvent.Pause -> onRecordingPaused?.invoke() + is VideoRecordEvent.Resume -> onRecordingResumed?.invoke() + is VideoRecordEvent.Finalize -> { + // Recording is over - drop the Recording reference so `isRecording` + // flips to false even when the finalize was auto-triggered (e.g. by + // reaching `maxDuration` / `maxFileSize`) instead of via `stopRecording()`. + this.recording = null + if (isCancelled) { + // Recording was cancelled - delete the file and don't notify + isCancelled = false + file.delete() + return@start } - if (finishReason != null) { - // Recording finished successfully - either no error, or file/duration limit reached: - onRecordingFinished(file.absolutePath, finishReason) - } else { - // We have an error, either during capture or even while starting: - val error = VideoRecorderError(event.error, event.cause) - if (!didResolve) { - // We didn't even start the Recording yet! Reject promise - promise.reject(error) - didResolve = true + val finishReason = + when (event.error) { + VideoRecordEvent.Finalize.ERROR_NONE -> RecordingFinishedReason.STOPPED + VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED -> RecordingFinishedReason.MAX_DURATION_REACHED + VideoRecordEvent.Finalize.ERROR_FILE_SIZE_LIMIT_REACHED -> RecordingFinishedReason.MAX_FILE_SIZE_REACHED + // Source went inactive (e.g. Activity stopped mid-recording). Unlike + // ERROR_NO_VALID_DATA, CameraX still finalizes a valid file, so keep it. + VideoRecordEvent.Finalize.ERROR_SOURCE_INACTIVE -> RecordingFinishedReason.STOPPED + else -> null + } + if (finishReason != null) { + // Recording finished successfully - either no error, or file/duration limit reached: + onRecordingFinished(file.absolutePath, finishReason) } else { - // Unknown error while recording - onRecordingError(error) + // We have an error, either during capture or even while starting: + val error = VideoRecorderError(event.error, event.cause) + if (!didResolve) { + // We didn't even start the Recording yet! Reject promise + promise.reject(error) + didResolve = true + } else { + // Unknown error while recording + onRecordingError(error) + } } } - } - is VideoRecordEvent.Status -> { - this.recordedDuration = event.recordingStats.recordedDurationNanos / 1_000_000_000.0 - this.recordedFileSize = event.recordingStats.numBytesRecorded.toDouble() + is VideoRecordEvent.Status -> { + this.recordedDuration = event.recordingStats.recordedDurationNanos / 1_000_000_000.0 + this.recordedFileSize = event.recordingStats.numBytesRecorded.toDouble() + } } } + } catch (error: IllegalStateException) { + // CameraX throws synchronously if the shared Recorder already has an active + // recording, e.g. one started by another Recorder of this output. + promise.reject(error) + return@execute } }