diff --git a/apps/simple-camera/__tests__/visioncamera.video.harness.ts b/apps/simple-camera/__tests__/visioncamera.video.harness.ts index 91d0d733c0..1fd73772ba 100644 --- a/apps/simple-camera/__tests__/visioncamera.video.harness.ts +++ b/apps/simple-camera/__tests__/visioncamera.video.harness.ts @@ -9,6 +9,7 @@ import { import type { CameraDevice, CameraDeviceFactory, + Recorder, RecordingFinishedReason, } from 'react-native-vision-camera' import { CommonResolutions, VisionCamera } from 'react-native-vision-camera' @@ -408,6 +409,165 @@ describe('VisionCamera - Video', () => { } }) + it('records to a custom file path', 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() + + // Discover the platform-specific app-writable temp directory by + // creating a default recorder and reading its chosen file path. + // We can't hard-code `/tmp/...` because iOS app sandboxes and Android + // app contexts both use platform-specific dynamic paths. + const probe = await videoOutput.createRecorder({}) + const probePath = probe.filePath.replace(/^file:\/\//, '') + const tempDir = probePath.substring(0, probePath.lastIndexOf('/')) + const ext = Platform.OS === 'ios' ? 'mov' : 'mp4' + const customPath = `${tempDir}/visioncamera-custom-${Date.now()}.${ext}` + + const recorder = await videoOutput.createRecorder({ filePath: customPath }) + expect(recorder.filePath).toContain(customPath) + + let finishedPath: string | undefined + let recordingError: Error | undefined + try { + await recorder.startRecording( + (filePath) => { + finishedPath = filePath + }, + (error) => { + recordingError = error + }, + ) + await sleep(800) + await recorder.stopRecording() + await waitUntil(() => finishedPath != null, { timeout: 10_000 }) + + expect(recordingError).toBe(undefined) + expect(finishedPath).toContain(customPath) + } finally { + await session.stop() + } + }) + + it('auto-creates parent directories for a nested custom file path', 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 probe = await videoOutput.createRecorder({}) + const probePath = probe.filePath.replace(/^file:\/\//, '') + const tempDir = probePath.substring(0, probePath.lastIndexOf('/')) + const ext = Platform.OS === 'ios' ? 'mov' : 'mp4' + // Use multiple non-existent nested folders so the test fails if the + // implementation doesn't recursively create parent dirs. + const customPath = `${tempDir}/visioncamera-nested-${Date.now()}/sub/dir/recording.${ext}` + + const recorder = await videoOutput.createRecorder({ filePath: customPath }) + expect(recorder.filePath).toContain(customPath) + + let finishedPath: string | undefined + let recordingError: Error | undefined + try { + await recorder.startRecording( + (filePath) => { + finishedPath = filePath + }, + (error) => { + recordingError = error + }, + ) + await sleep(800) + await recorder.stopRecording() + await waitUntil(() => finishedPath != null, { timeout: 10_000 }) + + // If the recording finished without error, the nested directories + // had to be created on the fly - otherwise the encoder couldn't + // have written any bytes. + expect(recordingError).toBe(undefined) + expect(finishedPath).toContain(customPath) + } finally { + await session.stop() + } + }) + + it('fails to record when given an unwritable file path', 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() + + // The filesystem root is read-only inside both iOS and Android app + // sandboxes, so writing to `/.mp4` must fail somehow. + const invalidPath = `/visioncamera-cannot-write-${Date.now()}.mp4` + let createError: Error | undefined + let startError: Error | undefined + let recordingError: Error | undefined + + try { + let recorder: Recorder | undefined + try { + recorder = await videoOutput.createRecorder({ filePath: invalidPath }) + } catch (e) { + createError = e as Error + } + if (recorder != null) { + try { + await recorder.startRecording( + () => {}, + (error) => { + recordingError = error + }, + ) + } catch (e) { + startError = e as Error + } + // The error may surface synchronously (startRecording rejection) + // or asynchronously (onRecordingError callback). Wait briefly for + // the async case, but don't fail if the sync case already fired. + await waitUntil(() => startError != null || recordingError != null, { + timeout: 5_000, + }).catch(() => { + // Timed out without an error - the assertion below will fail. + }) + } + } finally { + await session.stop() + } + + expect( + createError != null || startError != null || recordingError != null, + ).toBe(true) + }) + it('returns supported video codecs on iOS after the output is attached', async () => { if (Platform.OS !== 'ios') { console.log('[SKIP] getSupportedVideoCodecs: iOS only')