Compare commits
	
		
			12 Commits
		
	
	
		
			volodymyr/
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 4798aad464 | |||
|  | 2c8d503e66 | ||
| 5b52acda26 | |||
| 17f675657e | |||
| c64516693c | |||
| e9f08ef488 | |||
| bf122db919 | |||
| 3319e48f7d | |||
| 58714f9dac | |||
| 8991779851 | |||
| f8efa172ba | |||
| 66f840eecb | 
| @@ -13,69 +13,36 @@ import com.facebook.react.bridge.ReadableMap | ||||
| import com.facebook.react.bridge.WritableMap | ||||
| import com.mrousavy.camera.core.CameraSession | ||||
| import com.mrousavy.camera.core.InsufficientStorageError | ||||
| import com.mrousavy.camera.utils.FileUtils | ||||
| import com.mrousavy.camera.types.Flash | ||||
| import com.mrousavy.camera.types.QualityPrioritization | ||||
| import com.mrousavy.camera.utils.* | ||||
| import java.io.File | ||||
| import java.io.FileOutputStream | ||||
| import java.io.IOException | ||||
| import kotlinx.coroutines.* | ||||
|  | ||||
| private const val TAG = "CameraView.takePhoto" | ||||
| private const val TAG = "CameraView.takeSnapshot" | ||||
|  | ||||
| @SuppressLint("UnsafeOptInUsageError") | ||||
| suspend fun CameraView.takePhoto(optionsMap: ReadableMap): WritableMap { | ||||
|   val options = optionsMap.toHashMap() | ||||
|   Log.i(TAG, "Taking photo... Options: $options") | ||||
|   Log.i(TAG, "Taking snapshot... Options: $options") | ||||
|   val bitmap = previewView.getBitmap() ?: throw Error() | ||||
|  | ||||
|   val qualityPrioritization = options["qualityPrioritization"] as? String ?: "balanced" | ||||
|   val flash = options["flash"] as? String ?: "off" | ||||
|   val enableAutoStabilization = options["enableAutoStabilization"] == true | ||||
|   val enableShutterSound = options["enableShutterSound"] as? Boolean ?: true | ||||
|   val enablePrecapture = options["enablePrecapture"] as? Boolean ?: false | ||||
|   val file = FileUtils.createTempFile(context, "png"); | ||||
|  | ||||
|   // TODO: Implement Red Eye Reduction | ||||
|   options["enableAutoRedEyeReduction"] | ||||
|   // Write snapshot to .jpg file | ||||
|   FileUtils.writeBitmapTofile(bitmap, file, 100) | ||||
|  | ||||
|   val flashMode = Flash.fromUnionValue(flash) | ||||
|   val qualityPrioritizationMode = QualityPrioritization.fromUnionValue(qualityPrioritization) | ||||
|   Log.i(TAG, "Successfully saved snapshot to file!") | ||||
|  | ||||
|   val photo = cameraSession.takePhoto( | ||||
|     qualityPrioritizationMode, | ||||
|     flashMode, | ||||
|     enableShutterSound, | ||||
|     enableAutoStabilization, | ||||
|     enablePrecapture, | ||||
|     orientation | ||||
|   ) | ||||
|  | ||||
|   photo.use { | ||||
|     Log.i(TAG, "Successfully captured ${photo.image.width} x ${photo.image.height} photo!") | ||||
|  | ||||
|     val cameraCharacteristics = cameraManager.getCameraCharacteristics(cameraId!!) | ||||
|  | ||||
|     val path = try { | ||||
|       savePhotoToFile(context, cameraCharacteristics, photo) | ||||
|     } catch (e: IOException) { | ||||
|       if (e.message?.contains("no space left", true) == true) { | ||||
|         throw InsufficientStorageError() | ||||
|       } else { | ||||
|         throw e | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Log.i(TAG, "Successfully saved photo to file! $path") | ||||
|  | ||||
|     val map = Arguments.createMap() | ||||
|     map.putString("path", path) | ||||
|     map.putInt("width", photo.image.width) | ||||
|     map.putInt("height", photo.image.height) | ||||
|     map.putString("orientation", photo.orientation.unionValue) | ||||
|     map.putBoolean("isRawPhoto", photo.format == ImageFormat.RAW_SENSOR) | ||||
|     map.putBoolean("isMirrored", photo.isMirrored) | ||||
|  | ||||
|     return map | ||||
|   } | ||||
|   // Parse output data | ||||
|   val map = Arguments.createMap() | ||||
|   map.putString("path", file.absolutePath) | ||||
|   map.putInt("width", bitmap.width) | ||||
|   map.putInt("height", bitmap.height) | ||||
|   map.putBoolean("isMirrored", false) | ||||
|   return map | ||||
| } | ||||
|  | ||||
| private fun writePhotoToFile(photo: CameraSession.CapturedPhoto, file: File) { | ||||
|   | ||||
| @@ -102,7 +102,7 @@ class CameraView(context: Context) : | ||||
|  | ||||
|   // session | ||||
|   internal val cameraSession: CameraSession | ||||
|   private val previewView: PreviewView | ||||
|   val previewView: PreviewView | ||||
|   private var currentConfigureCall: Long = System.currentTimeMillis() | ||||
|   internal var frameProcessor: FrameProcessor? = null | ||||
|  | ||||
|   | ||||
| @@ -2,9 +2,13 @@ package com.mrousavy.camera.core | ||||
|  | ||||
| import android.annotation.SuppressLint | ||||
| import android.content.Context | ||||
| import android.content.res.Configuration | ||||
| import android.graphics.Point | ||||
| import android.os.Handler | ||||
| import android.os.Looper | ||||
| import android.util.Log | ||||
| import android.util.Size | ||||
| import android.view.PixelCopy | ||||
| import android.view.SurfaceHolder | ||||
| import android.view.SurfaceView | ||||
| import com.facebook.react.bridge.UiThreadUtil | ||||
| @@ -12,9 +16,69 @@ import com.mrousavy.camera.extensions.resize | ||||
| import com.mrousavy.camera.extensions.rotatedBy | ||||
| import com.mrousavy.camera.types.Orientation | ||||
| import com.mrousavy.camera.types.ResizeMode | ||||
| import kotlin.coroutines.resume | ||||
| import kotlin.coroutines.resumeWithException | ||||
| import kotlin.math.roundToInt | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.suspendCancellableCoroutine | ||||
| import kotlinx.coroutines.withContext | ||||
| import android.graphics.Bitmap | ||||
| import android.graphics.Matrix | ||||
|  | ||||
| fun rotateBitmap90CounterClockwise(source: Bitmap): Bitmap { | ||||
|     val width = source.width | ||||
|     val height = source.height | ||||
|  | ||||
|     // Create a new Bitmap with swapped width and height | ||||
|     val rotatedBitmap = Bitmap.createBitmap(height, width, source.config ?: Bitmap.Config.ARGB_8888) | ||||
|  | ||||
|     for (y in 0 until height) { | ||||
|         for (x in 0 until width) { | ||||
|             // Set the pixel in the new position | ||||
|             rotatedBitmap.setPixel(y, width - 1 - x, source.getPixel(x, y)) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     return rotatedBitmap | ||||
| } | ||||
|  | ||||
|  | ||||
| fun Bitmap.transformBitmap(orientation: Orientation): Bitmap { | ||||
|     return when (orientation) { | ||||
|         Orientation.PORTRAIT -> this // No transformation needed | ||||
|         Orientation.LANDSCAPE_LEFT -> { | ||||
|             // Transpose (swap width and height) | ||||
|             val transposedBitmap = Bitmap.createBitmap(height, width, config ?: Bitmap.Config.ARGB_8888) | ||||
|             for (y in 0 until height) { | ||||
|                 for (x in 0 until width) { | ||||
|                     transposedBitmap.setPixel(y, width - 1 - x, getPixel(x, y)) | ||||
|                 } | ||||
|             } | ||||
|             transposedBitmap | ||||
|         } | ||||
|         Orientation.PORTRAIT_UPSIDE_DOWN -> { | ||||
|             // Invert vertically and horizontally (180-degree rotation) | ||||
|             val invertedBitmap = Bitmap.createBitmap(width, height, config ?: Bitmap.Config.ARGB_8888) | ||||
|             for (y in 0 until height) { | ||||
|                 for (x in 0 until width) { | ||||
|                     invertedBitmap.setPixel(width - 1 - x, height - 1 - y, getPixel(x, y)) | ||||
|                 } | ||||
|             } | ||||
|             invertedBitmap | ||||
|         } | ||||
|         Orientation.LANDSCAPE_RIGHT -> { | ||||
|             // Transpose (swap width and height) and invert vertically | ||||
|             val transposedBitmap = Bitmap.createBitmap(height, width, config ?: Bitmap.Config.ARGB_8888) | ||||
|             for (y in 0 until height) { | ||||
|                 for (x in 0 until width) { | ||||
|                     transposedBitmap.setPixel(height - 1 - y, x, getPixel(x, y)) | ||||
|                 } | ||||
|             } | ||||
|             transposedBitmap | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
| @SuppressLint("ViewConstructor") | ||||
| class PreviewView(context: Context, callback: SurfaceHolder.Callback) : | ||||
| @@ -80,6 +144,34 @@ class PreviewView(context: Context, callback: SurfaceHolder.Callback) : | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   suspend fun getBitmap(): Bitmap? = withContext(Dispatchers.Main) { | ||||
|     val frame = holder.getSurfaceFrame() | ||||
|  | ||||
|     val width = frame.width() | ||||
|     val height = frame.height() | ||||
|  | ||||
|     val bitmap = Bitmap.createBitmap(height, width, Bitmap.Config.ARGB_8888) | ||||
|  | ||||
|     // Use a coroutine to suspend until the PixelCopy request is complete | ||||
|     suspendCancellableCoroutine<Bitmap?> { continuation -> | ||||
|     PixelCopy.request( | ||||
|         holder.surface, | ||||
|         bitmap, | ||||
|         { copyResult -> | ||||
|           if (copyResult == PixelCopy.SUCCESS) { | ||||
|             continuation.resume(rotateBitmap90CounterClockwise(bitmap)) | ||||
|           } else { | ||||
|             continuation.resumeWithException( | ||||
|                 RuntimeException("PixelCopy failed with error code $copyResult") | ||||
|             ) | ||||
|           } | ||||
|         }, | ||||
|         Handler(Looper.getMainLooper()) | ||||
|     ) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|  | ||||
|   fun convertLayerPointToCameraCoordinates(point: Point, cameraDeviceDetails: CameraDeviceDetails): Point { | ||||
|     val sensorOrientation = cameraDeviceDetails.sensorOrientation | ||||
|     val cameraSize = Size(cameraDeviceDetails.activeSize.width(), cameraDeviceDetails.activeSize.height()) | ||||
| @@ -97,22 +189,14 @@ class PreviewView(context: Context, callback: SurfaceHolder.Callback) : | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   override fun requestLayout() { | ||||
|     super.requestLayout() | ||||
|     // Manually trigger measure & layout, as RN on Android skips those. | ||||
|     // See this issue: https://github.com/facebook/react-native/issues/17968#issuecomment-721958427 | ||||
|     post { | ||||
|       measure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)) | ||||
|       layout(left, top, right, bottom) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private fun getSize(contentSize: Size, containerSize: Size, resizeMode: ResizeMode): Size { | ||||
|     var contentSize = contentSize | ||||
|     // Swap dimensions if orientation is landscape | ||||
|     if (orientation.isLandscape()) { | ||||
|     var androidOrientation = context.getResources().getConfiguration().orientation; | ||||
|  | ||||
|     if (androidOrientation == Configuration.ORIENTATION_LANDSCAPE) { | ||||
|       contentSize = Size(contentSize.height, contentSize.width) | ||||
|     } | ||||
|  | ||||
|     val contentAspectRatio = contentSize.width.toDouble() / contentSize.height | ||||
|     val containerAspectRatio = containerSize.width.toDouble() / containerSize.height | ||||
|     if (!(contentAspectRatio > 0 && containerAspectRatio > 0)) { | ||||
|   | ||||
| @@ -1,13 +1,33 @@ | ||||
| package com.mrousavy.camera.utils | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.Bitmap | ||||
| import android.graphics.BitmapFactory | ||||
| import android.util.Size | ||||
| 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 writeBitmapTofile(bitmap: Bitmap, file: File, quality: Int) { | ||||
|       FileOutputStream(file).use { stream -> | ||||
|         bitmap.compress(Bitmap.CompressFormat.JPEG, 50, stream) | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     fun getImageSize(imagePath: String): Size { | ||||
|       val bitmapOptions = BitmapFactory.Options().also { | ||||
|         it.inJustDecodeBounds = true | ||||
|       } | ||||
|       BitmapFactory.decodeFile(imagePath, bitmapOptions) | ||||
|       val width = bitmapOptions.outWidth | ||||
|       val height = bitmapOptions.outHeight | ||||
|       return Size(width, height) | ||||
|     } | ||||
|  | ||||
|     fun createTempFile(context: Context, extension: String): File = | ||||
|         File.createTempFile("mrousavy", extension, context.cacheDir).also { | ||||
|       it.deleteOnExit() | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -50,4 +50,12 @@ extension CameraView: AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureAud | ||||
|   func resumeRecording(promise: Promise) { | ||||
|     cameraSession.resumeRecording(promise: promise) | ||||
|   } | ||||
|    | ||||
|   func lockExposure(promise: Promise) { | ||||
|     cameraSession.lockCurrentExposure(promise: promise) | ||||
|   } | ||||
|    | ||||
|   func unlockExposure(promise: Promise) { | ||||
|     cameraSession.unlockCurrentExposure(promise: promise) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -86,5 +86,13 @@ RCT_EXTERN_METHOD(focus | ||||
|                   : (NSDictionary*)point resolve | ||||
|                   : (RCTPromiseResolveBlock)resolve reject | ||||
|                   : (RCTPromiseRejectBlock)reject); | ||||
| RCT_EXTERN_METHOD(lockCurrentExposure | ||||
|                   : (nonnull NSNumber*)node resolve | ||||
|                   : (RCTPromiseResolveBlock)resolve reject | ||||
|                   : (RCTPromiseRejectBlock)reject); | ||||
| RCT_EXTERN_METHOD(unlockCurrentExposure | ||||
|                   : (nonnull NSNumber*)node resolve | ||||
|                   : (RCTPromiseResolveBlock)resolve reject | ||||
|                   : (RCTPromiseRejectBlock)reject); | ||||
|  | ||||
| @end | ||||
|   | ||||
| @@ -110,6 +110,18 @@ final class CameraViewManager: RCTViewManager { | ||||
|       resolve(result.descriptor) | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   @objc | ||||
|   final func lockCurrentExposure(_ node: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { | ||||
|     let component = getCameraView(withTag: node) | ||||
|     component.lockExposure(promise: Promise(resolver: resolve, rejecter: reject)) | ||||
|   } | ||||
|    | ||||
|   @objc | ||||
|   final func unlockCurrentExposure(_ node: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { | ||||
|     let component = getCameraView(withTag: node) | ||||
|     component.unlockExposure(promise: Promise(resolver: resolve, rejecter: reject)) | ||||
|   } | ||||
|  | ||||
|   // MARK: Private | ||||
|  | ||||
|   | ||||
| @@ -34,7 +34,7 @@ extension CameraSession { | ||||
|       } | ||||
|  | ||||
|       let enableAudio = self.configuration?.audio != .disabled | ||||
|        | ||||
|  | ||||
|       // Callback for when new chunks are ready | ||||
|       let onChunkReady: (ChunkedRecorder.Chunk) -> Void = { chunk in | ||||
|         guard let delegate = self.delegate else { | ||||
| @@ -191,4 +191,68 @@ extension CameraSession { | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   func lockCurrentExposure(promise: Promise) { | ||||
|     CameraQueues.cameraQueue.async { | ||||
|       withPromise(promise) { | ||||
|         guard let captureDevice = AVCaptureDevice.default(for: .video) else { | ||||
|           print("No capture device available") | ||||
|           return | ||||
|         } | ||||
|  | ||||
|         guard captureDevice.isExposureModeSupported(.custom) else { | ||||
|           ReactLogger.log(level: .info, message: "Custom exposure mode not supported") | ||||
|           return | ||||
|         } | ||||
|         do { | ||||
|           // Lock the device for configuration | ||||
|           try captureDevice.lockForConfiguration() | ||||
|  | ||||
|           // Get the current exposure duration and ISO | ||||
|           let currentExposureDuration = captureDevice.exposureDuration | ||||
|           let currentISO = captureDevice.iso | ||||
|  | ||||
|           // Check if the device supports custom exposure settings | ||||
|           if captureDevice.isExposureModeSupported(.custom) { | ||||
|             // Lock the current exposure and ISO by setting custom exposure mode | ||||
|             captureDevice.setExposureModeCustom(duration: currentExposureDuration, iso: currentISO, completionHandler: nil) | ||||
|             ReactLogger.log(level: .info, message: "Exposure and ISO locked at current values") | ||||
|           } else { | ||||
|             ReactLogger.log(level: .info, message:"Custom exposure mode not supported") | ||||
|           } | ||||
|  | ||||
|           // Unlock the device after configuration | ||||
|           captureDevice.unlockForConfiguration() | ||||
|  | ||||
|         } catch { | ||||
|           ReactLogger.log(level: .warning, message:"Error locking exposure: \(error)") | ||||
|         } | ||||
|  | ||||
|         return nil | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   func unlockCurrentExposure(promise: Promise) { | ||||
|     CameraQueues.cameraQueue.async { | ||||
|       withPromise(promise) { | ||||
|         guard let captureDevice = AVCaptureDevice.default(for: .video) else { | ||||
|           print("No capture device available") | ||||
|           return | ||||
|         } | ||||
|  | ||||
|         do { | ||||
|           if captureDevice.isExposureModeSupported(.autoExpose) { | ||||
|             try captureDevice.lockForConfiguration() | ||||
|             captureDevice.exposureMode = .continuousAutoExposure | ||||
|             captureDevice.unlockForConfiguration() | ||||
|           } | ||||
|         } catch { | ||||
|           ReactLogger.log(level: .warning, message:"Error unlocking exposure: \(error)") | ||||
|         } | ||||
|  | ||||
|         return nil | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -319,6 +319,22 @@ export class Camera extends React.PureComponent<CameraProps, CameraState> { | ||||
|       throw tryParseNativeCameraError(e) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public async lockCurrentExposure(): Promise<void> { | ||||
|     try { | ||||
|       return await CameraModule.lockCurrentExposure(this.handle) | ||||
|     } catch (e) { | ||||
|       throw tryParseNativeCameraError(e) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public async unlockCurrentExposure(): Promise<void> { | ||||
|     try { | ||||
|       return await CameraModule.unlockCurrentExposure(this.handle) | ||||
|     } catch (e) { | ||||
|       throw tryParseNativeCameraError(e) | ||||
|     } | ||||
|   } | ||||
|   //#endregion | ||||
|  | ||||
|   //#region Static Functions (NativeModule) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user