diff --git a/package/android/src/main/java/com/mrousavy/camera/core/CameraError.kt b/package/android/src/main/java/com/mrousavy/camera/core/CameraError.kt index c15880cc64..3656930007 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/CameraError.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/CameraError.kt @@ -58,7 +58,7 @@ class PixelFormatNotSupportedError(format: String) : class FlashUnavailableError : CameraError( "device", - "flash-unavailable", + "flash-not-available", "The Camera Device does not have a flash unit! Make sure you select a device where `device.hasFlash`/`device.hasTorch` is true." ) class FocusNotSupportedError : @@ -191,6 +191,7 @@ class NoRecordingInProgressError : class RecordingCanceledError : CameraError("capture", "recording-canceled", "The active recording was canceled.") class FileIOError(throwable: Throwable) : CameraError("capture", "file-io-error", "An unexpected File IO error occurred! Error: ${throwable.message}.", throwable) +class InvalidPathError(path: String) : CameraError("capture", "invalid-path", "The given path ($path) is invalid, or not writable!") class RecordingInProgressError : CameraError( "capture", diff --git a/package/android/src/main/java/com/mrousavy/camera/core/CameraSession+Photo.kt b/package/android/src/main/java/com/mrousavy/camera/core/CameraSession+Photo.kt index 8f4a98965b..6ef88b2d11 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/CameraSession+Photo.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/CameraSession+Photo.kt @@ -1,35 +1,39 @@ package com.mrousavy.camera.core import android.media.AudioManager -import android.util.Log import com.mrousavy.camera.core.extensions.takePicture import com.mrousavy.camera.core.types.Flash import com.mrousavy.camera.core.types.Orientation +import com.mrousavy.camera.core.types.TakePhotoOptions import com.mrousavy.camera.core.utils.FileUtils -suspend fun CameraSession.takePhoto(flash: Flash, enableShutterSound: Boolean): Photo { +suspend fun CameraSession.takePhoto(options: TakePhotoOptions): Photo { val camera = camera ?: throw CameraNotReadyError() val configuration = configuration ?: throw CameraNotReadyError() val photoConfig = configuration.photo as? CameraConfiguration.Output.Enabled ?: throw PhotoNotEnabledError() val photoOutput = photoOutput ?: throw PhotoNotEnabledError() - if (flash != Flash.OFF && !camera.cameraInfo.hasFlashUnit()) { + // Flash + if (options.flash != Flash.OFF && !camera.cameraInfo.hasFlashUnit()) { throw FlashUnavailableError() } - - photoOutput.flashMode = flash.toFlashMode() - val enableShutterSoundActual = getEnableShutterSoundActual(enableShutterSound) - + photoOutput.flashMode = options.flash.toFlashMode() + // Shutter sound + val enableShutterSound = options.enableShutterSound && !audioManager.isSilent + // isMirrored (EXIF) val isMirrored = photoConfig.config.isMirrored + + // Shoot photo! val photoFile = photoOutput.takePicture( - context, + options.file.file, isMirrored, - enableShutterSoundActual, + enableShutterSound, metadataProvider, callback, CameraQueues.cameraExecutor ) + // Parse resulting photo (EXIF data) val size = FileUtils.getImageSize(photoFile.uri.path) val rotation = photoOutput.targetRotation val orientation = Orientation.fromSurfaceRotation(rotation) @@ -37,11 +41,5 @@ suspend fun CameraSession.takePhoto(flash: Flash, enableShutterSound: Boolean): return Photo(photoFile.uri.path, size.width, size.height, orientation, isMirrored) } -private fun CameraSession.getEnableShutterSoundActual(enable: Boolean): Boolean { - if (enable && audioManager.ringerMode != AudioManager.RINGER_MODE_NORMAL) { - Log.i(CameraSession.TAG, "Ringer mode is silent (${audioManager.ringerMode}), disabling shutter sound...") - return false - } - - return enable -} +private val AudioManager.isSilent: Boolean + get() = ringerMode != AudioManager.RINGER_MODE_NORMAL diff --git a/package/android/src/main/java/com/mrousavy/camera/core/CameraSession+Video.kt b/package/android/src/main/java/com/mrousavy/camera/core/CameraSession+Video.kt index ae6c9d306f..fcc54b8e95 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/CameraSession+Video.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/CameraSession+Video.kt @@ -10,7 +10,6 @@ import androidx.camera.video.VideoRecordEvent import com.mrousavy.camera.core.extensions.getCameraError import com.mrousavy.camera.core.types.RecordVideoOptions import com.mrousavy.camera.core.types.Video -import com.mrousavy.camera.core.utils.FileUtils @OptIn(ExperimentalPersistentRecording::class) @SuppressLint("MissingPermission", "RestrictedApi") @@ -24,13 +23,16 @@ fun CameraSession.startRecording( if (recording != null) throw RecordingInProgressError() val videoOutput = videoOutput ?: throw VideoNotEnabledError() - val file = FileUtils.createTempFile(context, options.fileType.toExtension()) - val outputOptions = FileOutputOptions.Builder(file).also { outputOptions -> + // Create output video file + val outputOptions = FileOutputOptions.Builder(options.file.file).also { outputOptions -> metadataProvider.location?.let { location -> Log.i(CameraSession.TAG, "Setting Video Location to ${location.latitude}, ${location.longitude}...") outputOptions.setLocation(location) } }.build() + + // TODO: Move this to JS so users can prepare recordings earlier + // Prepare recording var pendingRecording = videoOutput.output.prepareRecording(context, outputOptions) if (enableAudio) { checkMicrophonePermission() @@ -38,7 +40,6 @@ fun CameraSession.startRecording( } pendingRecording = pendingRecording.asPersistentRecording() - val size = videoOutput.attachedSurfaceResolution ?: Size(0, 0) isRecordingCanceled = false recording = pendingRecording.start(CameraQueues.cameraExecutor) { event -> when (event) { @@ -55,7 +56,7 @@ fun CameraSession.startRecording( Log.i(CameraSession.TAG, "Recording was canceled, deleting file..") onError(RecordingCanceledError()) try { - file.delete() + options.file.file.delete() } catch (e: Throwable) { this.callback.onError(FileIOError(e)) } @@ -73,9 +74,12 @@ fun CameraSession.startRecording( return@start } } + + // Prepare output result val durationMs = event.recordingStats.recordedDurationNanos / 1_000_000 Log.i(CameraSession.TAG, "Successfully completed video recording! Captured ${durationMs.toDouble() / 1_000.0} seconds.") val path = event.outputResults.outputUri.path ?: throw UnknownRecorderError(false, null) + val size = videoOutput.attachedSurfaceResolution ?: Size(0, 0) val video = Video(path, durationMs, size) callback(video) } diff --git a/package/android/src/main/java/com/mrousavy/camera/core/extensions/ImageCapture+takePicture.kt b/package/android/src/main/java/com/mrousavy/camera/core/extensions/ImageCapture+takePicture.kt index b087a1dafd..a5f8957ee4 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/extensions/ImageCapture+takePicture.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/extensions/ImageCapture+takePicture.kt @@ -1,7 +1,6 @@ package com.mrousavy.camera.core.extensions import android.annotation.SuppressLint -import android.content.Context import android.media.MediaActionSound import android.util.Log import androidx.camera.core.ImageCapture @@ -10,7 +9,7 @@ import androidx.camera.core.ImageCaptureException import com.mrousavy.camera.core.CameraSession import com.mrousavy.camera.core.MetadataProvider import com.mrousavy.camera.core.types.ShutterType -import com.mrousavy.camera.core.utils.FileUtils +import java.io.File import java.net.URI import java.util.concurrent.Executor import kotlin.coroutines.resume @@ -21,7 +20,7 @@ data class PhotoFileInfo(val uri: URI, val metadata: ImageCapture.Metadata) @SuppressLint("RestrictedApi") suspend inline fun ImageCapture.takePicture( - context: Context, + file: File, isMirrored: Boolean, enableShutterSound: Boolean, metadataProvider: MetadataProvider, @@ -33,7 +32,7 @@ suspend inline fun ImageCapture.takePicture( val shutterSound = if (enableShutterSound) MediaActionSound() else null shutterSound?.load(MediaActionSound.SHUTTER_CLICK) - val file = FileUtils.createTempFile(context, ".jpg") + // Create output file val outputFileOptionsBuilder = OutputFileOptions.Builder(file).also { options -> val metadata = ImageCapture.Metadata() metadataProvider.location?.let { location -> @@ -45,6 +44,7 @@ suspend inline fun ImageCapture.takePicture( } val outputFileOptions = outputFileOptionsBuilder.build() + // Take a photo with callbacks takePicture( outputFileOptions, executor, diff --git a/package/android/src/main/java/com/mrousavy/camera/core/types/RecordVideoOptions.kt b/package/android/src/main/java/com/mrousavy/camera/core/types/RecordVideoOptions.kt index 3f2fa7ed0a..5cf4db27a6 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/types/RecordVideoOptions.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/types/RecordVideoOptions.kt @@ -1,25 +1,27 @@ package com.mrousavy.camera.core.types +import android.content.Context import com.facebook.react.bridge.ReadableMap +import com.mrousavy.camera.core.utils.FileUtils +import com.mrousavy.camera.core.utils.OutputFile -class RecordVideoOptions(map: ReadableMap) { - var fileType: VideoFileType = VideoFileType.MOV - var videoCodec = VideoCodec.H264 - var videoBitRateOverride: Double? = null - var videoBitRateMultiplier: Double? = null +class RecordVideoOptions( + val file: OutputFile, + val videoCodec: VideoCodec, + val videoBitRateOverride: Double?, + val videoBitRateMultiplier: Double? +) { - init { - if (map.hasKey("fileType")) { - fileType = VideoFileType.fromUnionValue(map.getString("fileType")) - } - if (map.hasKey("videoCodec")) { - videoCodec = VideoCodec.fromUnionValue(map.getString("videoCodec")) - } - if (map.hasKey("videoBitRateOverride")) { - videoBitRateOverride = map.getDouble("videoBitRateOverride") - } - if (map.hasKey("videoBitRateMultiplier")) { - videoBitRateMultiplier = map.getDouble("videoBitRateMultiplier") + companion object { + fun fromJSValue(context: Context, map: ReadableMap): RecordVideoOptions { + val directory = if (map.hasKey("path")) FileUtils.getDirectory(map.getString("path")) else context.cacheDir + val fileType = if (map.hasKey("fileType")) VideoFileType.fromUnionValue(map.getString("fileType")) else VideoFileType.MOV + val videoCodec = if (map.hasKey("videoCodec")) VideoCodec.fromUnionValue(map.getString("videoCodec")) else VideoCodec.H264 + val videoBitRateOverride = if (map.hasKey("videoBitRateOverride")) map.getDouble("videoBitRateOverride") else null + val videoBitRateMultiplier = if (map.hasKey("videoBitRateMultiplier")) map.getDouble("videoBitRateMultiplier") else null + + val outputFile = OutputFile(context, directory, fileType.toExtension()) + return RecordVideoOptions(outputFile, videoCodec, videoBitRateOverride, videoBitRateMultiplier) } } } diff --git a/package/android/src/main/java/com/mrousavy/camera/core/types/SnapshotOptions.kt b/package/android/src/main/java/com/mrousavy/camera/core/types/SnapshotOptions.kt deleted file mode 100644 index a50bcc7a47..0000000000 --- a/package/android/src/main/java/com/mrousavy/camera/core/types/SnapshotOptions.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.mrousavy.camera.core.types - -import com.facebook.react.bridge.ReadableMap - -data class SnapshotOptions(val quality: Int) { - companion object { - fun fromJSValue(options: ReadableMap): SnapshotOptions { - val quality = if (options.hasKey("quality")) options.getInt("quality") else 100 - return SnapshotOptions(quality) - } - } -} diff --git a/package/android/src/main/java/com/mrousavy/camera/core/types/TakePhotoOptions.kt b/package/android/src/main/java/com/mrousavy/camera/core/types/TakePhotoOptions.kt new file mode 100644 index 0000000000..4c99c4b0e5 --- /dev/null +++ b/package/android/src/main/java/com/mrousavy/camera/core/types/TakePhotoOptions.kt @@ -0,0 +1,20 @@ +package com.mrousavy.camera.core.types + +import android.content.Context +import com.facebook.react.bridge.ReadableMap +import com.mrousavy.camera.core.utils.FileUtils +import com.mrousavy.camera.core.utils.OutputFile + +data class TakePhotoOptions(val file: OutputFile, val flash: Flash, val enableShutterSound: Boolean) { + + companion object { + fun fromJS(context: Context, map: ReadableMap): TakePhotoOptions { + val flash = if (map.hasKey("flash")) Flash.fromUnionValue(map.getString("flash")) else Flash.OFF + val enableShutterSound = if (map.hasKey("enableShutterSound")) map.getBoolean("enableShutterSound") else false + val directory = if (map.hasKey("path")) FileUtils.getDirectory(map.getString("path")) else context.cacheDir + + val outputFile = OutputFile(context, directory, ".jpg") + return TakePhotoOptions(outputFile, flash, enableShutterSound) + } + } +} diff --git a/package/android/src/main/java/com/mrousavy/camera/core/types/TakeSnapshotOptions.kt b/package/android/src/main/java/com/mrousavy/camera/core/types/TakeSnapshotOptions.kt new file mode 100644 index 0000000000..b467632f89 --- /dev/null +++ b/package/android/src/main/java/com/mrousavy/camera/core/types/TakeSnapshotOptions.kt @@ -0,0 +1,19 @@ +package com.mrousavy.camera.core.types + +import android.content.Context +import com.facebook.react.bridge.ReadableMap +import com.mrousavy.camera.core.utils.FileUtils +import com.mrousavy.camera.core.utils.OutputFile + +data class TakeSnapshotOptions(val file: OutputFile, val quality: Int) { + + companion object { + fun fromJSValue(context: Context, map: ReadableMap): TakeSnapshotOptions { + val quality = if (map.hasKey("quality")) map.getInt("quality") else 100 + val directory = if (map.hasKey("path")) FileUtils.getDirectory(map.getString("path")) else context.cacheDir + + val outputFile = OutputFile(context, directory, ".jpg") + return TakeSnapshotOptions(outputFile, quality) + } + } +} diff --git a/package/android/src/main/java/com/mrousavy/camera/core/utils/FileUtils.kt b/package/android/src/main/java/com/mrousavy/camera/core/utils/FileUtils.kt index 61a73b2d40..4c594a5e46 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/utils/FileUtils.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/utils/FileUtils.kt @@ -1,18 +1,24 @@ package com.mrousavy.camera.core.utils -import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.util.Size +import com.mrousavy.camera.core.InvalidPathError import java.io.File import java.io.FileOutputStream class FileUtils { companion object { - fun createTempFile(context: Context, extension: String): File = - File.createTempFile("mrousavy-", extension, context.cacheDir).also { - it.deleteOnExit() + fun getDirectory(path: String?): File { + if (path == null) { + throw InvalidPathError("null") } + val file = File(path) + if (!file.isDirectory) { + throw InvalidPathError(path) + } + return file + } fun writeBitmapTofile(bitmap: Bitmap, file: File, quality: Int) { FileOutputStream(file).use { stream -> diff --git a/package/android/src/main/java/com/mrousavy/camera/core/utils/OutputFile.kt b/package/android/src/main/java/com/mrousavy/camera/core/utils/OutputFile.kt new file mode 100644 index 0000000000..1ed437ac09 --- /dev/null +++ b/package/android/src/main/java/com/mrousavy/camera/core/utils/OutputFile.kt @@ -0,0 +1,15 @@ +package com.mrousavy.camera.core.utils + +import android.content.Context +import java.io.File + +data class OutputFile(val context: Context, val directory: File, val extension: String) { + val file = File.createTempFile("mrousavy", extension, directory) + + init { + if (directory.absolutePath.contains(context.cacheDir.absolutePath)) { + // If this is a temp file (inside temp directory), the file will be deleted once the app closes + file.deleteOnExit() + } + } +} diff --git a/package/android/src/main/java/com/mrousavy/camera/react/CameraView+TakePhoto.kt b/package/android/src/main/java/com/mrousavy/camera/react/CameraView+TakePhoto.kt index 134f460e96..b1445bb01a 100644 --- a/package/android/src/main/java/com/mrousavy/camera/react/CameraView+TakePhoto.kt +++ b/package/android/src/main/java/com/mrousavy/camera/react/CameraView+TakePhoto.kt @@ -6,22 +6,17 @@ import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.WritableMap import com.mrousavy.camera.core.takePhoto -import com.mrousavy.camera.core.types.Flash +import com.mrousavy.camera.core.types.TakePhotoOptions private const val TAG = "CameraView.takePhoto" @SuppressLint("UnsafeOptInUsageError") suspend fun CameraView.takePhoto(optionsMap: ReadableMap): WritableMap { - val options = optionsMap.toHashMap() - Log.i(TAG, "Taking photo... Options: $options") + Log.i(TAG, "Taking photo... Options: ${optionsMap.toHashMap()}") - val flash = options["flash"] as? String ?: "off" - val enableShutterSound = options["enableShutterSound"] as? Boolean ?: true - - val photo = cameraSession.takePhoto( - Flash.fromUnionValue(flash), - enableShutterSound - ) + // Parse options and shoot photo + val options = TakePhotoOptions.fromJS(context, optionsMap) + val photo = cameraSession.takePhoto(options) Log.i(TAG, "Successfully captured ${photo.width} x ${photo.height} photo!") diff --git a/package/android/src/main/java/com/mrousavy/camera/react/CameraView+TakeSnapshot.kt b/package/android/src/main/java/com/mrousavy/camera/react/CameraView+TakeSnapshot.kt index b7963790c0..212d296c8c 100644 --- a/package/android/src/main/java/com/mrousavy/camera/react/CameraView+TakeSnapshot.kt +++ b/package/android/src/main/java/com/mrousavy/camera/react/CameraView+TakeSnapshot.kt @@ -6,27 +6,29 @@ import com.facebook.react.bridge.WritableMap import com.mrousavy.camera.core.SnapshotFailedError import com.mrousavy.camera.core.SnapshotFailedPreviewNotEnabledError import com.mrousavy.camera.core.types.ShutterType -import com.mrousavy.camera.core.types.SnapshotOptions +import com.mrousavy.camera.core.types.TakeSnapshotOptions import com.mrousavy.camera.core.utils.FileUtils private const val TAG = "CameraView.takeSnapshot" -fun CameraView.takeSnapshot(options: SnapshotOptions): WritableMap { +fun CameraView.takeSnapshot(options: TakeSnapshotOptions): WritableMap { Log.i(TAG, "Capturing snapshot of Camera View...") val previewView = previewView ?: throw SnapshotFailedPreviewNotEnabledError() val bitmap = previewView.bitmap ?: throw SnapshotFailedError() + // Shutter Event (JS) onShutter(ShutterType.SNAPSHOT) - val file = FileUtils.createTempFile(context, ".jpg") - FileUtils.writeBitmapTofile(bitmap, file, options.quality) + // Write snapshot to .jpg file + FileUtils.writeBitmapTofile(bitmap, options.file.file, options.quality) Log.i(TAG, "Successfully saved snapshot to file!") val orientation = cameraSession.outputOrientation + // Parse output data val map = Arguments.createMap() - map.putString("path", file.absolutePath) + map.putString("path", options.file.file.absolutePath) map.putInt("width", bitmap.width) map.putInt("height", bitmap.height) map.putString("orientation", orientation.unionValue) diff --git a/package/android/src/main/java/com/mrousavy/camera/react/CameraViewModule.kt b/package/android/src/main/java/com/mrousavy/camera/react/CameraViewModule.kt index 6e0206555c..7ae420949d 100644 --- a/package/android/src/main/java/com/mrousavy/camera/react/CameraViewModule.kt +++ b/package/android/src/main/java/com/mrousavy/camera/react/CameraViewModule.kt @@ -21,7 +21,7 @@ import com.mrousavy.camera.core.CameraQueues import com.mrousavy.camera.core.ViewNotFoundError import com.mrousavy.camera.core.types.PermissionStatus import com.mrousavy.camera.core.types.RecordVideoOptions -import com.mrousavy.camera.core.types.SnapshotOptions +import com.mrousavy.camera.core.types.TakeSnapshotOptions import com.mrousavy.camera.core.utils.runOnUiThread import com.mrousavy.camera.core.utils.runOnUiThreadAndWait import com.mrousavy.camera.frameprocessors.VisionCameraInstaller @@ -104,7 +104,7 @@ class CameraViewModule(reactContext: ReactApplicationContext) : ReactContextBase val view = findCameraView(viewTag) runOnUiThread { try { - val options = SnapshotOptions.fromJSValue(jsOptions) + val options = TakeSnapshotOptions.fromJSValue(reactApplicationContext, jsOptions) val result = view.takeSnapshot(options) promise.resolve(result) } catch (e: Throwable) { @@ -120,7 +120,7 @@ class CameraViewModule(reactContext: ReactApplicationContext) : ReactContextBase backgroundCoroutineScope.launch { val view = findCameraView(viewTag) try { - val options = RecordVideoOptions(jsOptions) + val options = RecordVideoOptions.fromJSValue(reactApplicationContext, jsOptions) view.startRecording(options, onRecordCallback) } catch (error: CameraError) { val map = makeErrorMap("${error.domain}/${error.id}", error.message, error) diff --git a/package/example/ios/Podfile.lock b/package/example/ios/Podfile.lock index dc65072d6d..6bceb811fd 100644 --- a/package/example/ios/Podfile.lock +++ b/package/example/ios/Podfile.lock @@ -1391,16 +1391,16 @@ PODS: - ReactCommon/turbomodule/core - Yoga - SocketRocket (0.7.0) - - VisionCamera (4.4.3): - - VisionCamera/Core (= 4.4.3) - - VisionCamera/FrameProcessors (= 4.4.3) - - VisionCamera/React (= 4.4.3) - - VisionCamera/Core (4.4.3) - - VisionCamera/FrameProcessors (4.4.3): + - VisionCamera (4.5.0): + - VisionCamera/Core (= 4.5.0) + - VisionCamera/FrameProcessors (= 4.5.0) + - VisionCamera/React (= 4.5.0) + - VisionCamera/Core (4.5.0) + - VisionCamera/FrameProcessors (4.5.0): - React - React-callinvoker - react-native-worklets-core - - VisionCamera/React (4.4.3): + - VisionCamera/React (4.5.0): - React-Core - VisionCamera/FrameProcessors - Yoga (0.0.0) @@ -1688,7 +1688,7 @@ SPEC CHECKSUMS: RNStaticSafeAreaInsets: 055ddbf5e476321720457cdaeec0ff2ba40ec1b8 RNVectorIcons: 2a2f79274248390b80684ea3c4400bd374a15c90 SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d - VisionCamera: 7435f20f7ee7756a1e307e986195a97764da5142 + VisionCamera: c75705e486d3ba2e0e850e98aa314010f04e922d Yoga: 2f71ecf38d934aecb366e686278102a51679c308 PODFILE CHECKSUM: 49584be049764895189f1f88ebc9769116621103 diff --git a/package/example/src/MediaPage.tsx b/package/example/src/MediaPage.tsx index 77a2de9384..8eff60dd05 100644 --- a/package/example/src/MediaPage.tsx +++ b/package/example/src/MediaPage.tsx @@ -55,7 +55,7 @@ export function MediaPage({ navigation, route }: Props): React.ReactElement { setHasMediaLoaded(true) }, []) const onMediaLoadError = useCallback((error: OnVideoErrorData) => { - console.log(`failed to load media: ${JSON.stringify(error)}`) + console.error(`failed to load media: ${JSON.stringify(error)}`) }, []) const onSavePressed = useCallback(async () => { diff --git a/package/ios/Core/CameraError.swift b/package/ios/Core/CameraError.swift index fdd356238c..983568929c 100644 --- a/package/ios/Core/CameraError.swift +++ b/package/ios/Core/CameraError.swift @@ -208,7 +208,9 @@ enum CaptureError { case recordingInProgress case recordingCanceled case noRecordingInProgress + case invalidPath(path: String) case fileError(cause: Error) + case flashNotAvailable case imageDataAccessError case createTempFileError(message: String? = nil) case createRecorderError(message: String? = nil) @@ -233,6 +235,10 @@ enum CaptureError { return "file-io-error" case .createTempFileError: return "create-temp-file-error" + case .invalidPath: + return "invalid-path" + case .flashNotAvailable: + return "flash-not-available" case .createRecorderError: return "create-recorder-error" case .videoNotEnabled: @@ -272,12 +278,16 @@ enum CaptureError { return "Failed to create the AVAssetWriter (Recorder)! \(message ?? "(no additional message)")" case .videoNotEnabled: return "Video capture is disabled! Pass `video={true}` to enable video recordings." + case let .invalidPath(path: path): + return "The given path (\(path)) is invalid, or not writable!" case .snapshotFailed: return "Failed to take a Snapshot of the Preview View! Try using takePhoto() instead." case .photoNotEnabled: return "Photo capture is disabled! Pass `photo={true}` to enable photo capture." case .imageDataAccessError: return "An unexpected error occurred while trying to access the image data!" + case .flashNotAvailable: + return "The Camera Device does not have a flash unit! Make sure you select a device where `device.hasFlash`/`device.hasTorch` is true." case .timedOut: return "The capture timed out." case .focusRequiresPreview: diff --git a/package/ios/Core/CameraSession+Photo.swift b/package/ios/Core/CameraSession+Photo.swift index dfb7dc8224..12f5c07ce3 100644 --- a/package/ios/Core/CameraSession+Photo.swift +++ b/package/ios/Core/CameraSession+Photo.swift @@ -14,7 +14,7 @@ extension CameraSession { Takes a photo. `takePhoto` is only available if `photo={true}`. */ - func takePhoto(options: NSDictionary, promise: Promise) { + func takePhoto(options: TakePhotoOptions, promise: Promise) { // Run on Camera Queue CameraQueues.cameraQueue.async { // Get Photo Output configuration @@ -60,31 +60,30 @@ extension CameraSession { } // red-eye reduction - if #available(iOS 12.0, *), let autoRedEyeReduction = options["enableAutoRedEyeReduction"] as? Bool { - photoSettings.isAutoRedEyeReductionEnabled = autoRedEyeReduction - } + photoSettings.isAutoRedEyeReductionEnabled = options.enableAutoRedEyeReduction // distortion correction - if #available(iOS 14.1, *), let enableAutoDistortionCorrection = options["enableAutoDistortionCorrection"] as? Bool { - photoSettings.isAutoContentAwareDistortionCorrectionEnabled = enableAutoDistortionCorrection + if #available(iOS 14.1, *) { + photoSettings.isAutoContentAwareDistortionCorrectionEnabled = options.enableAutoDistortionCorrection } // flash - if videoDeviceInput.device.isFlashAvailable, let flash = options["flash"] as? String { - guard let flashMode = AVCaptureDevice.FlashMode(withString: flash) else { - promise.reject(error: .parameter(.invalid(unionName: "FlashMode", receivedValue: flash))) + if options.flash != .off { + guard videoDeviceInput.device.hasFlash else { + // If user enabled flash, but the device doesn't have a flash, throw an error. + promise.reject(error: .capture(.flashNotAvailable)) return } - photoSettings.flashMode = flashMode } - - // shutter sound - let enableShutterSound = options["enableShutterSound"] as? Bool ?? true + if videoDeviceInput.device.isFlashAvailable { + photoSettings.flashMode = options.flash.toFlashMode() + } // Actually do the capture! let photoCaptureDelegate = PhotoCaptureDelegate(promise: promise, - enableShutterSound: enableShutterSound, + enableShutterSound: options.enableShutterSound, metadataProvider: self.metadataProvider, + path: options.path, cameraSessionDelegate: self.delegate) photoOutput.capturePhoto(with: photoSettings, delegate: photoCaptureDelegate) diff --git a/package/ios/Core/CameraSession+Video.swift b/package/ios/Core/CameraSession+Video.swift index 49f9769c5a..8e57710f24 100644 --- a/package/ios/Core/CameraSession+Video.swift +++ b/package/ios/Core/CameraSession+Video.swift @@ -85,17 +85,14 @@ extension CameraSession { } } - // Create temporary file - let fileExtension = options.fileType.descriptor ?? "mov" - let tempURL = FileUtils.createTempFile(fileExtension: fileExtension) - VisionLogger.log(level: .info, message: "Will record to temporary file: \(tempURL)") + VisionLogger.log(level: .info, message: "Starting recording into file: \(options.path)") do { // Orientation is relative to our current output orientation let orientation = self.outputOrientation.relativeTo(orientation: videoOutput.orientation) // Create RecordingSession for the temp file - let recordingSession = try RecordingSession(url: tempURL, + let recordingSession = try RecordingSession(url: options.path, fileType: options.fileType, metadataProvider: self.metadataProvider, clock: self.captureSession.clock, diff --git a/package/ios/Core/PhotoCaptureDelegate.swift b/package/ios/Core/PhotoCaptureDelegate.swift index 3487d22c51..e845b38dea 100644 --- a/package/ios/Core/PhotoCaptureDelegate.swift +++ b/package/ios/Core/PhotoCaptureDelegate.swift @@ -15,14 +15,17 @@ class PhotoCaptureDelegate: GlobalReferenceHolder, AVCapturePhotoCaptureDelegate private let enableShutterSound: Bool private let cameraSessionDelegate: CameraSessionDelegate? private let metadataProvider: MetadataProvider + private let path: URL required init(promise: Promise, enableShutterSound: Bool, metadataProvider: MetadataProvider, + path: URL, cameraSessionDelegate: CameraSessionDelegate?) { self.promise = promise self.enableShutterSound = enableShutterSound self.metadataProvider = metadataProvider + self.path = path self.cameraSessionDelegate = cameraSessionDelegate super.init() makeGlobal() @@ -48,7 +51,9 @@ class PhotoCaptureDelegate: GlobalReferenceHolder, AVCapturePhotoCaptureDelegate } do { - let path = try FileUtils.writePhotoToTempFile(photo: photo, metadataProvider: metadataProvider) + try FileUtils.writePhotoToFile(photo: photo, + metadataProvider: metadataProvider, + file: path) let exif = photo.metadata["{Exif}"] as? [String: Any] let width = exif?["PixelXDimension"] diff --git a/package/ios/Core/Types/Flash.swift b/package/ios/Core/Types/Flash.swift new file mode 100644 index 0000000000..f0d97c271b --- /dev/null +++ b/package/ios/Core/Types/Flash.swift @@ -0,0 +1,51 @@ +// +// Flash.swift +// VisionCamera +// +// Created by Marc Rousavy on 25.07.24. +// + +import AVFoundation +import Foundation + +/** + A Flash for Photo capture. + */ +@frozen +enum Flash: String, JSUnionValue { + /** + Flash never fires. + */ + case off + /** + Flash always fires, no matter the lighting conditions + */ + case on + /** + Flash fires if lighting conditions are too dark. + */ + case auto + + init(jsValue: String) throws { + if let parsed = Flash(rawValue: jsValue) { + self = parsed + } else { + throw CameraError.parameter(.invalid(unionName: "flash", receivedValue: jsValue)) + } + } + + var jsValue: String { + return rawValue + } + + func toFlashMode() -> AVCaptureDevice.FlashMode { + switch self { + case .on: + return .on + case .off: + return .off + case .auto: + return .auto + } + } +} diff --git a/package/ios/Core/Types/RecordVideoOptions.swift b/package/ios/Core/Types/RecordVideoOptions.swift index 09072e0275..65248c45d9 100644 --- a/package/ios/Core/Types/RecordVideoOptions.swift +++ b/package/ios/Core/Types/RecordVideoOptions.swift @@ -13,6 +13,7 @@ struct RecordVideoOptions { var fileType: AVFileType = .mov var flash: Torch = .off var codec: AVVideoCodecType? + var path: URL /** * Full Bit-Rate override for the Video Encoder, in Megabits per second (Mbps) */ @@ -44,5 +45,12 @@ struct RecordVideoOptions { if let parsed = dictionary["videoBitRateMultiplier"] as? Double { bitRateMultiplier = parsed } + // Custom Path + let fileExtension = fileType.descriptor ?? "mov" + if let customPath = dictionary["path"] as? String { + path = try FileUtils.getFilePath(customDirectory: customPath, fileExtension: fileExtension) + } else { + path = try FileUtils.getFilePath(fileExtension: fileExtension) + } } } diff --git a/package/ios/Core/Types/TakePhotoOptions.swift b/package/ios/Core/Types/TakePhotoOptions.swift new file mode 100644 index 0000000000..c569e885ad --- /dev/null +++ b/package/ios/Core/Types/TakePhotoOptions.swift @@ -0,0 +1,42 @@ +// +// TakePhotoOptions.swift +// VisionCamera +// +// Created by Marc Rousavy on 25.07.24. +// + +import AVFoundation +import Foundation + +struct TakePhotoOptions { + var flash: Flash = .off + var path: URL + var enableAutoRedEyeReduction = false + var enableAutoDistortionCorrection = false + var enableShutterSound = true + + init(fromJSValue dictionary: NSDictionary) throws { + // Flash + if let flashOption = dictionary["flash"] as? String { + flash = try Flash(jsValue: flashOption) + } + // Red-Eye reduction + if let enable = dictionary["enableAutoRedEyeReduction"] as? Bool { + enableAutoRedEyeReduction = enable + } + // Distortion correction + if let enable = dictionary["enableAutoDistortionCorrection"] as? Bool { + enableAutoDistortionCorrection = enable + } + // Shutter sound + if let enable = dictionary["enableShutterSound"] as? Bool { + enableShutterSound = enable + } + // Custom Path + if let customPath = dictionary["path"] as? String { + path = try FileUtils.getFilePath(customDirectory: customPath, fileExtension: "jpg") + } else { + path = try FileUtils.getFilePath(fileExtension: "jpg") + } + } +} diff --git a/package/ios/Core/Types/TakeSnapshotOptions.swift b/package/ios/Core/Types/TakeSnapshotOptions.swift new file mode 100644 index 0000000000..d1b9987896 --- /dev/null +++ b/package/ios/Core/Types/TakeSnapshotOptions.swift @@ -0,0 +1,27 @@ +// +// TakeSnapshotOptions.swift +// VisionCamera +// +// Created by Marc Rousavy on 25.07.24. +// + +import AVFoundation +import Foundation + +struct TakeSnapshotOptions { + var path: URL + var quality: Double = 1.0 + + init(fromJSValue dictionary: NSDictionary) throws { + // Quality + if let customQuality = dictionary["quality"] as? Double { + quality = customQuality / 100.0 + } + // Custom Path + if let customPath = dictionary["path"] as? String { + path = try FileUtils.getFilePath(customDirectory: customPath, fileExtension: "jpg") + } else { + path = try FileUtils.getFilePath(fileExtension: "jpg") + } + } +} diff --git a/package/ios/Core/Utils/FileUtils.swift b/package/ios/Core/Utils/FileUtils.swift index 5f13580424..489d905d20 100644 --- a/package/ios/Core/Utils/FileUtils.swift +++ b/package/ios/Core/Utils/FileUtils.swift @@ -13,38 +13,62 @@ import UIKit enum FileUtils { /** - Writes Data to a temporary file and returns the file path. + Writes Data to a temporary file. */ - private static func writeDataToTempFile(data: Data, fileExtension: String = "jpeg") throws -> URL { + private static func writeDataToFile(data: Data, file: URL) throws { do { - let tempFilePath = createTempFile(fileExtension: fileExtension) - try data.write(to: tempFilePath) - return tempFilePath + if file.isFileURL { + try data.write(to: file) + } else { + guard let url = URL(string: "file://\(file.absoluteString)") else { + throw CameraError.capture(.createTempFileError(message: "Cannot create URL with file:// prefix!")) + } + try data.write(to: url) + } } catch { throw CameraError.capture(.fileError(cause: error)) } } - static func writePhotoToTempFile(photo: AVCapturePhoto, metadataProvider: MetadataProvider) throws -> URL { + static func writePhotoToFile(photo: AVCapturePhoto, metadataProvider: MetadataProvider, file: URL) throws { guard let data = photo.fileDataRepresentation(with: metadataProvider) else { throw CameraError.capture(.imageDataAccessError) } - let path = try writeDataToTempFile(data: data) - return path + try writeDataToFile(data: data, file: file) } - static func writeUIImageToTempFile(image: UIImage, compressionQuality: CGFloat = 1.0) throws -> URL { + static func writeUIImageToFile(image: UIImage, file: URL, compressionQuality: CGFloat = 1.0) throws { guard let data = image.jpegData(compressionQuality: compressionQuality) else { throw CameraError.capture(.imageDataAccessError) } - let path = try writeDataToTempFile(data: data) - return path + try writeDataToFile(data: data, file: file) } - static func createTempFile(fileExtension: String) -> URL { - let filename = UUID().uuidString + "." + fileExtension - let tempFilePath = FileManager.default.temporaryDirectory - .appendingPathComponent(filename) - return tempFilePath + static var tempDirectory: URL { + return FileManager.default.temporaryDirectory + } + + static func createRandomFileName(withExtension fileExtension: String) -> String { + return UUID().uuidString + "." + fileExtension + } + + static func getFilePath(directory: URL, fileExtension: String) throws -> URL { + // Random UUID filename + let filename = createRandomFileName(withExtension: fileExtension) + return directory.appendingPathComponent(filename) + } + + static func getFilePath(customDirectory: String, fileExtension: String) throws -> URL { + // Prefix with file:// + let prefixedDirectory = customDirectory.starts(with: "file:") ? customDirectory : "file://\(customDirectory)" + // Create URL + guard let url = URL(string: prefixedDirectory) else { + throw CameraError.capture(.invalidPath(path: customDirectory)) + } + return try getFilePath(directory: url, fileExtension: fileExtension) + } + + static func getFilePath(fileExtension: String) throws -> URL { + return try getFilePath(directory: tempDirectory, fileExtension: fileExtension) } } diff --git a/package/ios/React/CameraView+TakePhoto.swift b/package/ios/React/CameraView+TakePhoto.swift index 63d7a7174d..244909aab3 100644 --- a/package/ios/React/CameraView+TakePhoto.swift +++ b/package/ios/React/CameraView+TakePhoto.swift @@ -10,6 +10,14 @@ import AVFoundation extension CameraView { func takePhoto(options: NSDictionary, promise: Promise) { - cameraSession.takePhoto(options: options, promise: promise) + do { + // Parse options & take a photo + let photoOptions = try TakePhotoOptions(fromJSValue: options) + cameraSession.takePhoto(options: photoOptions, promise: promise) + } catch let error as CameraError { + promise.reject(error: error) + } catch { + promise.reject(error: .capture(.unknown(message: error.localizedDescription)), cause: error as NSError) + } } } diff --git a/package/ios/React/CameraView+TakeSnapshot.swift b/package/ios/React/CameraView+TakeSnapshot.swift index c32a49f787..eb798037ff 100644 --- a/package/ios/React/CameraView+TakeSnapshot.swift +++ b/package/ios/React/CameraView+TakeSnapshot.swift @@ -10,7 +10,7 @@ import AVFoundation import UIKit extension CameraView { - func takeSnapshot(options _: NSDictionary, promise: Promise) { + func takeSnapshot(options optionsDictionary: NSDictionary, promise: Promise) { withPromise(promise) { guard let snapshot = latestVideoFrame else { throw CameraError.capture(.snapshotFailed) @@ -19,14 +19,21 @@ extension CameraView { throw CameraError.capture(.imageDataAccessError) } + // Parse options + let options = try TakeSnapshotOptions(fromJSValue: optionsDictionary) + + // Play shutter effect (JS event) self.onCaptureShutter(shutterType: .snapshot) - let ciImage = CIImage(cvPixelBuffer: imageBuffer) + // Grab latest frame, convert to UIImage, and save it let orientation = Orientation.portrait.relativeTo(orientation: snapshot.orientation) + let ciImage = CIImage(cvPixelBuffer: imageBuffer) let image = UIImage(ciImage: ciImage, scale: 1.0, orientation: orientation.imageOrientation) - let path = try FileUtils.writeUIImageToTempFile(image: image) + try FileUtils.writeUIImageToFile(image: image, + file: options.path, + compressionQuality: options.quality) return [ - "path": path.absoluteString, + "path": options.path.absoluteString, "width": image.size.width, "height": image.size.height, "orientation": orientation.jsValue, diff --git a/package/src/CameraError.ts b/package/src/CameraError.ts index 5bca8986e6..01ad3f17bb 100644 --- a/package/src/CameraError.ts +++ b/package/src/CameraError.ts @@ -39,6 +39,7 @@ export type CaptureError = | 'capture/recording-canceled' | 'capture/no-recording-in-progress' | 'capture/file-io-error' + | 'capture/invalid-path' | 'capture/create-temp-file-error' | 'capture/create-recorder-error' | 'capture/insufficient-storage' diff --git a/package/src/types/PhotoFile.ts b/package/src/types/PhotoFile.ts index 8bfb06cb04..85098d6898 100644 --- a/package/src/types/PhotoFile.ts +++ b/package/src/types/PhotoFile.ts @@ -8,6 +8,15 @@ export interface TakePhotoOptions { * @default "off" */ flash?: 'on' | 'off' | 'auto' + /** + * A custom `path` where the photo will be saved to. + * + * This must be a directory, as VisionCamera will generate a unique filename itself. + * If the given directory does not exist, this method will throw an error. + * + * By default, VisionCamera will use the device's temporary directory. + */ + path?: string /** * Specifies whether red-eye reduction should be applied automatically on flash captures. * diff --git a/package/src/types/Snapshot.ts b/package/src/types/Snapshot.ts index 25093751e2..e0f49a3c56 100644 --- a/package/src/types/Snapshot.ts +++ b/package/src/types/Snapshot.ts @@ -6,6 +6,15 @@ export interface TakeSnapshotOptions { * @default 100 */ quality?: number + /** + * A custom `path` where the snapshot will be saved to. + * + * This must be a directory, as VisionCamera will generate a unique filename itself. + * If the given directory does not exist, this method will throw an error. + * + * By default, VisionCamera will use the device's temporary directory. + */ + path?: string } export type SnapshotFile = Pick diff --git a/package/src/types/VideoFile.ts b/package/src/types/VideoFile.ts index 28007f2d53..197e4d1122 100644 --- a/package/src/types/VideoFile.ts +++ b/package/src/types/VideoFile.ts @@ -10,6 +10,15 @@ export interface RecordVideoOptions { * Specifies the output file type to record videos into. */ fileType?: 'mov' | 'mp4' + /** + * A custom `path` where the video will be saved to. + * + * This must be a directory, as VisionCamera will generate a unique filename itself. + * If the given directory does not exist, this method will throw an error. + * + * By default, VisionCamera will use the device's temporary directory. + */ + path?: string /** * Called when there was an unexpected runtime error while recording the video. */