Compare commits
	
		
			16 Commits
		
	
	
		
			ivan/attem
			...
			fix/androi
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 2c8d503e66 | ||
| 5b52acda26 | |||
| 17f675657e | |||
| c64516693c | |||
| e9f08ef488 | |||
| bf122db919 | |||
| 3319e48f7d | |||
| 58714f9dac | |||
| 8991779851 | |||
| f8efa172ba | |||
| 66f840eecb | |||
| fcf5fe70f3 | |||
|  | 3a20c44a31 | ||
| 0329e7976d | |||
| 7c162fecb1 | |||
| b28a152471 | 
| @@ -13,69 +13,36 @@ import com.facebook.react.bridge.ReadableMap | |||||||
| import com.facebook.react.bridge.WritableMap | import com.facebook.react.bridge.WritableMap | ||||||
| import com.mrousavy.camera.core.CameraSession | import com.mrousavy.camera.core.CameraSession | ||||||
| import com.mrousavy.camera.core.InsufficientStorageError | import com.mrousavy.camera.core.InsufficientStorageError | ||||||
|  | import com.mrousavy.camera.utils.FileUtils | ||||||
| import com.mrousavy.camera.types.Flash | import com.mrousavy.camera.types.Flash | ||||||
| import com.mrousavy.camera.types.QualityPrioritization |  | ||||||
| import com.mrousavy.camera.utils.* | import com.mrousavy.camera.utils.* | ||||||
| import java.io.File | import java.io.File | ||||||
| import java.io.FileOutputStream | import java.io.FileOutputStream | ||||||
| import java.io.IOException | import java.io.IOException | ||||||
| import kotlinx.coroutines.* | import kotlinx.coroutines.* | ||||||
|  |  | ||||||
| private const val TAG = "CameraView.takePhoto" | private const val TAG = "CameraView.takeSnapshot" | ||||||
|  |  | ||||||
| @SuppressLint("UnsafeOptInUsageError") | @SuppressLint("UnsafeOptInUsageError") | ||||||
| suspend fun CameraView.takePhoto(optionsMap: ReadableMap): WritableMap { | suspend fun CameraView.takePhoto(optionsMap: ReadableMap): WritableMap { | ||||||
|   val options = optionsMap.toHashMap() |   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 file = FileUtils.createTempFile(context, "png"); | ||||||
|   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 |  | ||||||
|  |  | ||||||
|   // TODO: Implement Red Eye Reduction |   // Write snapshot to .jpg file | ||||||
|   options["enableAutoRedEyeReduction"] |   FileUtils.writeBitmapTofile(bitmap, file, 100) | ||||||
|  |  | ||||||
|   val flashMode = Flash.fromUnionValue(flash) |   Log.i(TAG, "Successfully saved snapshot to file!") | ||||||
|   val qualityPrioritizationMode = QualityPrioritization.fromUnionValue(qualityPrioritization) |  | ||||||
|  |  | ||||||
|   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") |  | ||||||
|  |  | ||||||
|  |   // Parse output data | ||||||
|   val map = Arguments.createMap() |   val map = Arguments.createMap() | ||||||
|     map.putString("path", path) |   map.putString("path", file.absolutePath) | ||||||
|     map.putInt("width", photo.image.width) |   map.putInt("width", bitmap.width) | ||||||
|     map.putInt("height", photo.image.height) |   map.putInt("height", bitmap.height) | ||||||
|     map.putString("orientation", photo.orientation.unionValue) |   map.putBoolean("isMirrored", false) | ||||||
|     map.putBoolean("isRawPhoto", photo.format == ImageFormat.RAW_SENSOR) |  | ||||||
|     map.putBoolean("isMirrored", photo.isMirrored) |  | ||||||
|  |  | ||||||
|   return map |   return map | ||||||
|   } |  | ||||||
| } | } | ||||||
|  |  | ||||||
| private fun writePhotoToFile(photo: CameraSession.CapturedPhoto, file: File) { | private fun writePhotoToFile(photo: CameraSession.CapturedPhoto, file: File) { | ||||||
|   | |||||||
| @@ -72,6 +72,10 @@ class CameraView(context: Context) : | |||||||
|   var zoom: Float = 1f // in "factor" |   var zoom: Float = 1f // in "factor" | ||||||
|   var exposure: Double = 1.0 |   var exposure: Double = 1.0 | ||||||
|   var orientation: Orientation = Orientation.PORTRAIT |   var orientation: Orientation = Orientation.PORTRAIT | ||||||
|  |       set(value) { | ||||||
|  |         field = value | ||||||
|  |         previewView.orientation = value | ||||||
|  |       } | ||||||
|   var enableZoomGesture = false |   var enableZoomGesture = false | ||||||
|     set(value) { |     set(value) { | ||||||
|       field = value |       field = value | ||||||
| @@ -98,7 +102,7 @@ class CameraView(context: Context) : | |||||||
|  |  | ||||||
|   // session |   // session | ||||||
|   internal val cameraSession: CameraSession |   internal val cameraSession: CameraSession | ||||||
|   private val previewView: PreviewView |   val previewView: PreviewView | ||||||
|   private var currentConfigureCall: Long = System.currentTimeMillis() |   private var currentConfigureCall: Long = System.currentTimeMillis() | ||||||
|   internal var frameProcessor: FrameProcessor? = null |   internal var frameProcessor: FrameProcessor? = null | ||||||
|  |  | ||||||
|   | |||||||
| @@ -10,6 +10,7 @@ import com.mrousavy.camera.types.CodeScannerOptions | |||||||
| import com.mrousavy.camera.types.Orientation | import com.mrousavy.camera.types.Orientation | ||||||
| import com.mrousavy.camera.types.PixelFormat | import com.mrousavy.camera.types.PixelFormat | ||||||
| import com.mrousavy.camera.types.ResizeMode | import com.mrousavy.camera.types.ResizeMode | ||||||
|  | import android.util.Log | ||||||
| import com.mrousavy.camera.types.Torch | import com.mrousavy.camera.types.Torch | ||||||
| import com.mrousavy.camera.types.VideoStabilizationMode | import com.mrousavy.camera.types.VideoStabilizationMode | ||||||
|  |  | ||||||
| @@ -182,6 +183,7 @@ class CameraViewManager : ViewGroupManager<CameraView>() { | |||||||
|   fun setOrientation(view: CameraView, orientation: String?) { |   fun setOrientation(view: CameraView, orientation: String?) { | ||||||
|     if (orientation != null) { |     if (orientation != null) { | ||||||
|       val newMode = Orientation.fromUnionValue(orientation) |       val newMode = Orientation.fromUnionValue(orientation) | ||||||
|  |       Log.i(TAG, "Orientation set to: $newMode") | ||||||
|       view.orientation = newMode |       view.orientation = newMode | ||||||
|     } else { |     } else { | ||||||
|       view.orientation = Orientation.PORTRAIT |       view.orientation = Orientation.PORTRAIT | ||||||
|   | |||||||
| @@ -312,7 +312,7 @@ class CameraSession(private val context: Context, private val cameraManager: Cam | |||||||
|         enableHdr |         enableHdr | ||||||
|       ) |       ) | ||||||
|       outputs.add(output) |       outputs.add(output) | ||||||
|       // Size is usually landscape, so we flip it here |  | ||||||
|       previewView?.setSurfaceSize(size.width, size.height, deviceDetails.sensorOrientation) |       previewView?.setSurfaceSize(size.width, size.height, deviceDetails.sensorOrientation) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -83,7 +83,7 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   // Muxer specific |   // Muxer specific | ||||||
|   private class MuxerContext(val muxer: MediaMuxer, val filepath: File, val chunkIndex: Int, startTimeUs: Long, encodedFormat: MediaFormat) { |   private class MuxerContext(val muxer: MediaMuxer, val filepath: File, val chunkIndex: Int, startTimeUs: Long, encodedFormat: MediaFormat, val callbacks: CameraSession.Callback,) { | ||||||
|     val videoTrack: Int = muxer.addTrack(encodedFormat) |     val videoTrack: Int = muxer.addTrack(encodedFormat) | ||||||
|     val startTimeUs: Long = startTimeUs |     val startTimeUs: Long = startTimeUs | ||||||
|  |  | ||||||
| @@ -95,16 +95,14 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu | |||||||
|     fun finish() { |     fun finish() { | ||||||
|       muxer.stop() |       muxer.stop() | ||||||
|       muxer.release() |       muxer.release() | ||||||
|  |       callbacks.onVideoChunkReady(filepath, chunkIndex) | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private var muxerContext: MuxerContext? = null |   private var muxerContext: MuxerContext? = null | ||||||
|  |  | ||||||
|   private fun createNextMuxer(bufferInfo: BufferInfo) { |   private fun createNextMuxer(bufferInfo: BufferInfo) { | ||||||
|     muxerContext?.let { |     muxerContext?.finish() | ||||||
|       it.finish() |  | ||||||
|       this.callbacks.onVideoChunkReady(it.filepath, it.chunkIndex) |  | ||||||
|     } |  | ||||||
|     chunkIndex++ |     chunkIndex++ | ||||||
|  |  | ||||||
|     val newFileName = "$chunkIndex.mp4" |     val newFileName = "$chunkIndex.mp4" | ||||||
| @@ -116,7 +114,7 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu | |||||||
|     ) |     ) | ||||||
|     muxer.setOrientationHint(orientationHint) |     muxer.setOrientationHint(orientationHint) | ||||||
|     muxerContext = MuxerContext( |     muxerContext = MuxerContext( | ||||||
|       muxer, newOutputFile, chunkIndex, bufferInfo.presentationTimeUs, this.encodedFormat!! |         muxer, newOutputFile, chunkIndex, bufferInfo.presentationTimeUs, this.encodedFormat!!, this.callbacks | ||||||
|     ) |     ) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,9 +2,13 @@ package com.mrousavy.camera.core | |||||||
|  |  | ||||||
| import android.annotation.SuppressLint | import android.annotation.SuppressLint | ||||||
| import android.content.Context | import android.content.Context | ||||||
|  | import android.content.res.Configuration | ||||||
| import android.graphics.Point | import android.graphics.Point | ||||||
|  | import android.os.Handler | ||||||
|  | import android.os.Looper | ||||||
| import android.util.Log | import android.util.Log | ||||||
| import android.util.Size | import android.util.Size | ||||||
|  | import android.view.PixelCopy | ||||||
| import android.view.SurfaceHolder | import android.view.SurfaceHolder | ||||||
| import android.view.SurfaceView | import android.view.SurfaceView | ||||||
| import com.facebook.react.bridge.UiThreadUtil | 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.extensions.rotatedBy | ||||||
| import com.mrousavy.camera.types.Orientation | import com.mrousavy.camera.types.Orientation | ||||||
| import com.mrousavy.camera.types.ResizeMode | import com.mrousavy.camera.types.ResizeMode | ||||||
|  | import kotlin.coroutines.resume | ||||||
|  | import kotlin.coroutines.resumeWithException | ||||||
| import kotlin.math.roundToInt | import kotlin.math.roundToInt | ||||||
| import kotlinx.coroutines.Dispatchers | import kotlinx.coroutines.Dispatchers | ||||||
|  | import kotlinx.coroutines.suspendCancellableCoroutine | ||||||
| import kotlinx.coroutines.withContext | 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") | @SuppressLint("ViewConstructor") | ||||||
| class PreviewView(context: Context, callback: SurfaceHolder.Callback) : | class PreviewView(context: Context, callback: SurfaceHolder.Callback) : | ||||||
| @@ -36,6 +100,13 @@ class PreviewView(context: Context, callback: SurfaceHolder.Callback) : | |||||||
|         updateLayout() |         updateLayout() | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |   var orientation: Orientation = Orientation.PORTRAIT | ||||||
|  |       set(value) { | ||||||
|  |         if (field != value) { | ||||||
|  |           Log.i(TAG, "View Orientation changed: $field -> $value") | ||||||
|  |           field = value | ||||||
|  |         } | ||||||
|  |       } | ||||||
|   private var inputOrientation: Orientation = Orientation.LANDSCAPE_LEFT |   private var inputOrientation: Orientation = Orientation.LANDSCAPE_LEFT | ||||||
|     set(value) { |     set(value) { | ||||||
|       if (field != value) { |       if (field != value) { | ||||||
| @@ -73,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 { |   fun convertLayerPointToCameraCoordinates(point: Point, cameraDeviceDetails: CameraDeviceDetails): Point { | ||||||
|     val sensorOrientation = cameraDeviceDetails.sensorOrientation |     val sensorOrientation = cameraDeviceDetails.sensorOrientation | ||||||
|     val cameraSize = Size(cameraDeviceDetails.activeSize.width(), cameraDeviceDetails.activeSize.height()) |     val cameraSize = Size(cameraDeviceDetails.activeSize.width(), cameraDeviceDetails.activeSize.height()) | ||||||
| @@ -90,17 +189,14 @@ class PreviewView(context: Context, callback: SurfaceHolder.Callback) : | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   override fun requestLayout() { |   private fun getSize(contentSize: Size, containerSize: Size, resizeMode: ResizeMode): Size { | ||||||
|     super.requestLayout() |     var contentSize = contentSize | ||||||
|     // Manually trigger measure & layout, as RN on Android skips those. |     var androidOrientation = context.getResources().getConfiguration().orientation; | ||||||
|     // See this issue: https://github.com/facebook/react-native/issues/17968#issuecomment-721958427 |  | ||||||
|     post { |     if (androidOrientation == Configuration.ORIENTATION_LANDSCAPE) { | ||||||
|       measure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)) |       contentSize = Size(contentSize.height, contentSize.width) | ||||||
|       layout(left, top, right, bottom) |  | ||||||
|     } |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   private fun getSize(contentSize: Size, containerSize: Size, resizeMode: ResizeMode): Size { |  | ||||||
|     val contentAspectRatio = contentSize.width.toDouble() / contentSize.height |     val contentAspectRatio = contentSize.width.toDouble() / contentSize.height | ||||||
|     val containerAspectRatio = containerSize.width.toDouble() / containerSize.height |     val containerAspectRatio = containerSize.width.toDouble() / containerSize.height | ||||||
|     if (!(contentAspectRatio > 0 && containerAspectRatio > 0)) { |     if (!(contentAspectRatio > 0 && containerAspectRatio > 0)) { | ||||||
| @@ -128,11 +224,11 @@ class PreviewView(context: Context, callback: SurfaceHolder.Callback) : | |||||||
|   override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { |   override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { | ||||||
|     super.onMeasure(widthMeasureSpec, heightMeasureSpec) |     super.onMeasure(widthMeasureSpec, heightMeasureSpec) | ||||||
|  |  | ||||||
|     val viewSize = Size(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec)) |     val measuredViewSize = Size(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec)) | ||||||
|     val surfaceSize = size.rotatedBy(inputOrientation) |     val surfaceSize = size.rotatedBy(inputOrientation) | ||||||
|     val fittedSize = getSize(surfaceSize, viewSize, resizeMode) |     val fittedSize = getSize(surfaceSize, measuredViewSize, resizeMode) | ||||||
|  |  | ||||||
|     Log.i(TAG, "PreviewView is $viewSize, rendering $surfaceSize content ($inputOrientation). Resizing to: $fittedSize ($resizeMode)") |     Log.i(TAG, "PreviewView is $measuredViewSize rendering $surfaceSize orientation ($orientation). Resizing to: $fittedSize ($resizeMode)") | ||||||
|     setMeasuredDimension(fittedSize.width, fittedSize.height) |     setMeasuredDimension(fittedSize.width, fittedSize.height) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,10 +1,30 @@ | |||||||
| package com.mrousavy.camera.utils | package com.mrousavy.camera.utils | ||||||
|  |  | ||||||
| import android.content.Context | import android.content.Context | ||||||
|  | import android.graphics.Bitmap | ||||||
|  | import android.graphics.BitmapFactory | ||||||
|  | import android.util.Size | ||||||
| import java.io.File | import java.io.File | ||||||
|  | import java.io.FileOutputStream | ||||||
|  |  | ||||||
| class FileUtils { | class FileUtils { | ||||||
|   companion object { |   companion object { | ||||||
|  |     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 = |     fun createTempFile(context: Context, extension: String): File = | ||||||
|         File.createTempFile("mrousavy", extension, context.cacheDir).also { |         File.createTempFile("mrousavy", extension, context.cacheDir).also { | ||||||
|       it.deleteOnExit() |       it.deleteOnExit() | ||||||
|   | |||||||
| @@ -50,4 +50,12 @@ extension CameraView: AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureAud | |||||||
|   func resumeRecording(promise: Promise) { |   func resumeRecording(promise: Promise) { | ||||||
|     cameraSession.resumeRecording(promise: promise) |     cameraSession.resumeRecording(promise: promise) | ||||||
|   } |   } | ||||||
|  |    | ||||||
|  |   func lockExposure(promise: Promise) { | ||||||
|  |     cameraSession.lockCurrentExposure(promise: promise) | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   func unlockExposure(promise: Promise) { | ||||||
|  |     cameraSession.unlockCurrentExposure(promise: promise) | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -304,6 +304,15 @@ public final class CameraView: UIView, CameraSessionDelegate { | |||||||
|     onInitialized([:]) |     onInitialized([:]) | ||||||
|   } |   } | ||||||
|    |    | ||||||
|  |   func onCameraConfigurationChanged(_ configuration: CameraConfiguration?, _ difference: CameraConfiguration.Difference?) { | ||||||
|  |     guard let configuration, let difference else { return } | ||||||
|  |      | ||||||
|  |     if difference.orientationChanged, let connection = previewView.videoPreviewLayer.connection { | ||||||
|  |       let videoPreviewLayer = previewView.videoPreviewLayer | ||||||
|  |       connection.setOrientation(configuration.orientation) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   func onCameraStarted() { |   func onCameraStarted() { | ||||||
|     ReactLogger.log(level: .info, message: "Camera started!") |     ReactLogger.log(level: .info, message: "Camera started!") | ||||||
|     guard let onStarted = onStarted else { |     guard let onStarted = onStarted else { | ||||||
|   | |||||||
| @@ -86,5 +86,13 @@ RCT_EXTERN_METHOD(focus | |||||||
|                   : (NSDictionary*)point resolve |                   : (NSDictionary*)point resolve | ||||||
|                   : (RCTPromiseResolveBlock)resolve reject |                   : (RCTPromiseResolveBlock)resolve reject | ||||||
|                   : (RCTPromiseRejectBlock)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 | @end | ||||||
|   | |||||||
| @@ -111,6 +111,18 @@ final class CameraViewManager: RCTViewManager { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|    |    | ||||||
|  |   @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 |   // MARK: Private | ||||||
|  |  | ||||||
|   private func getCameraView(withTag tag: NSNumber) -> CameraView { |   private func getCameraView(withTag tag: NSNumber) -> CameraView { | ||||||
|   | |||||||
| @@ -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 | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -195,6 +195,7 @@ class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, AVC | |||||||
|           self.delegate?.onSessionInitialized() |           self.delegate?.onSessionInitialized() | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         self.delegate?.onCameraConfigurationChanged(config, difference) | ||||||
|         // After configuring, set this to the new configuration. |         // After configuring, set this to the new configuration. | ||||||
|         self.configuration = config |         self.configuration = config | ||||||
|       } catch { |       } catch { | ||||||
|   | |||||||
| @@ -21,6 +21,8 @@ protocol CameraSessionDelegate: AnyObject { | |||||||
|    Called when the [CameraSession] successfully initializes |    Called when the [CameraSession] successfully initializes | ||||||
|    */ |    */ | ||||||
|   func onSessionInitialized() |   func onSessionInitialized() | ||||||
|  |    | ||||||
|  |   func onCameraConfigurationChanged(_ configuration: CameraConfiguration?, _ difference: CameraConfiguration.Difference?) | ||||||
|   /** |   /** | ||||||
|    Called when the [CameraSession] starts streaming frames. (isActive=true) |    Called when the [CameraSession] starts streaming frames. (isActive=true) | ||||||
|    */ |    */ | ||||||
|   | |||||||
| @@ -32,6 +32,15 @@ extension AVCaptureOutput { | |||||||
|   func setOrientation(_ orientation: Orientation) { |   func setOrientation(_ orientation: Orientation) { | ||||||
|     // Set orientation for each connection |     // Set orientation for each connection | ||||||
|     for connection in connections { |     for connection in connections { | ||||||
|  |       connection.setOrientation(orientation) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | extension AVCaptureConnection { | ||||||
|  |   func setOrientation(_ orientation: Orientation) { | ||||||
|     #if swift(>=5.9) |     #if swift(>=5.9) | ||||||
|       if #available(iOS 17.0, *) { |       if #available(iOS 17.0, *) { | ||||||
|         // Camera Sensors are always in landscape rotation (90deg). |         // Camera Sensors are always in landscape rotation (90deg). | ||||||
| @@ -41,19 +50,18 @@ extension AVCaptureOutput { | |||||||
|  |  | ||||||
|         // TODO: Don't rotate the video output because it adds overhead. Instead just use EXIF flags for the .mp4 file if recording. |         // TODO: Don't rotate the video output because it adds overhead. Instead just use EXIF flags for the .mp4 file if recording. | ||||||
|         //       Does that work when we flip the camera? |         //       Does that work when we flip the camera? | ||||||
|           if connection.isVideoRotationAngleSupported(degrees) { |         if isVideoRotationAngleSupported(degrees) { | ||||||
|             connection.videoRotationAngle = degrees |           videoRotationAngle = degrees | ||||||
|         } |         } | ||||||
|       } else { |       } else { | ||||||
|           if connection.isVideoOrientationSupported { |         if isVideoOrientationSupported { | ||||||
|             connection.videoOrientation = orientation.toAVCaptureVideoOrientation() |           videoOrientation = orientation.toAVCaptureVideoOrientation() | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     #else |     #else | ||||||
|         if connection.isVideoOrientationSupported { |       if isVideoOrientationSupported { | ||||||
|           connection.videoOrientation = orientation.toAVCaptureVideoOrientation() |         videoOrientation = orientation.toAVCaptureVideoOrientation() | ||||||
|       } |       } | ||||||
|     #endif |     #endif | ||||||
|   } |   } | ||||||
|   } |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -113,5 +113,19 @@ class ViewController: UIViewController { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|    |    | ||||||
|  |   override func viewWillTransition(to size: CGSize, with coordinator: any UIViewControllerTransitionCoordinator) { | ||||||
|  |     switch UIDevice.current.orientation { | ||||||
|  |     case .landscapeLeft: | ||||||
|  |       cameraView.orientation = "landscape-right" | ||||||
|  |     case .landscapeRight: | ||||||
|  |       cameraView.orientation = "landscape-left" | ||||||
|  |     default: | ||||||
|  |       cameraView.orientation = "portrait" | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     cameraView.didSetProps([]) | ||||||
|  |     super.viewWillTransition(to: size, with: coordinator) | ||||||
|  |   } | ||||||
|  |    | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -319,6 +319,22 @@ export class Camera extends React.PureComponent<CameraProps, CameraState> { | |||||||
|       throw tryParseNativeCameraError(e) |       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 |   //#endregion | ||||||
|  |  | ||||||
|   //#region Static Functions (NativeModule) |   //#region Static Functions (NativeModule) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user