diff --git a/README.md b/README.md index 2c1f28b..bad82ac 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ VisionCamera is a powerful, high-performance Camera library for React Native. It features: * 📸 Photo and Video capture +* 👁️ QR/Barcode scanner * 📱 Customizable devices and multi-cameras ("fish-eye" zoom) * 🎞️ Customizable resolutions and aspect-ratios (4k/8k images) * ⏱️ Customizable FPS (30..240 FPS) diff --git a/docs/docs/guides/CODE_SCANNING.mdx b/docs/docs/guides/CODE_SCANNING.mdx new file mode 100644 index 0000000..2602d21 --- /dev/null +++ b/docs/docs/guides/CODE_SCANNING.mdx @@ -0,0 +1,104 @@ +--- +id: code-scanning +title: QR/Barcode Scanning +sidebar_label: QR/Barcode Scanning +--- + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' +import useBaseUrl from '@docusaurus/useBaseUrl' + +
+ + + + +
+ +## What is a Code Scanner? + +A Code Scanner is a separate Camera output (just like photo or video) that can detect a variety of machine-readable codes, such as: + +- **QR**: Square QR codes +- **Aztec**: Square Aztec codes +- **Data Matrix**: Square Data Matrix codes +- **Barcode (EAN)**: EAN-13 or EAN-8 Barcodes +- **Barcode (Code)**: Code-128, Code-39 or Code-93 Barcodes +- **Barcode (other)**: Codabar, ITF-14, UPC-E or PDF-417 Barcodes + +## Setup + +On iOS, the Code Scanner uses the platform-native APIs and can be used out of the box. + +On Android, the [MLKit Vision Barcode Scanning](https://developers.google.com/ml-kit/vision/barcode-scanning) API will be used, and the model (2.2MB) needs to be downloaded first. To download the model when the user installs your app, add this to your `AndroidManifest.xml` file: + +```xml + + ... + + +``` + +## Usage + +To use a codescanner, simply create a [`CodeScanner`](/docs/api/interfaces/CodeScanner) and pass it to the ``: + + + + +```tsx +const codeScanner = useCodeScanner({ + codeTypes: ['qr', 'ean-13'], + onCodeScanned: (codes) => { + console.log(`Scanned ${codes.length} codes!`) + } +}) + +return +``` + +The result of this will automatically be memoized. + + + + +```ts +const codeScanner: CodeScanner = { + codeTypes: ['qr', 'ean-13'], + onCodeScanned: (codes) => { + console.log(`Scanned ${codes.length} codes!`) + } +} +``` + +Make sure to memoize the result of this, as every change in this will trigger a Camera session re-build. + +```tsx +render() { + return +} +``` + + + + +## Separate Output + +Since the Code Scanner is a separate camera output (just like photo or video), it cannot be attached simultaneously with photo and video enabled. +You need to disable either `photo`, `video`, or the `codeScanner`. + +## Code result + +The Code Scanner will call your `onCodeScanned` callback with all detected codes ([`Code`](/docs/api/interfaces/Code)), including their decoded string value, and their coordinates on the screen relative to the Preview. + +
+ +#### 🚀 Next section: [Camera Lifecycle](lifecycle) diff --git a/docs/docs/guides/FRAME_PROCESSORS.mdx b/docs/docs/guides/FRAME_PROCESSORS.mdx index 6776436..3dde5f9 100644 --- a/docs/docs/guides/FRAME_PROCESSORS.mdx +++ b/docs/docs/guides/FRAME_PROCESSORS.mdx @@ -134,17 +134,17 @@ const onDraw = useDrawCallback((canvas) => { And you can also call back to the React-JS thread by using `createRunInJsFn(...)`: ```tsx -const onQRCodeDetected = Worklets.createRunInJsFn((qrCode: string) => { - navigation.push("ProductPage", { productId: qrCode }) +const onFaceDetected = Worklets.createRunInJsFn((face: Face) => { + navigation.push("FiltersPage", { face: face }) }) const frameProcessor = useFrameProcessor((frame) => { 'worklet' - const qrCodes = scanQRCodes(frame) - if (qrCodes.length > 0) { - onQRCodeDetected(qrCodes[0]) + const faces = scanFaces(frame) + if (faces.length > 0) { + onFaceDetected(faces[0]) } -}, [onQRCodeDetected]) +}, [onFaceDetected]) ``` ## Threading diff --git a/docs/sidebars.js b/docs/sidebars.js index c38d351..9f05eb7 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -7,6 +7,7 @@ module.exports = { 'guides/formats', 'guides/taking-photos', 'guides/recording-videos', + 'guides/code-scanning', { type: 'category', label: 'Realtime Frame Processing', diff --git a/package/android/build.gradle b/package/android/build.gradle index cb4b873..447e9b3 100644 --- a/package/android/build.gradle +++ b/package/android/build.gradle @@ -142,8 +142,9 @@ android { dependencies { //noinspection GradleDynamicVersion - implementation 'com.facebook.react:react-android:+' + implementation "com.facebook.react:react-android:+" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2" + implementation "com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.0" if (hasWorklets) { // Frame Processor integration (optional) diff --git a/package/android/src/main/java/com/mrousavy/camera/CameraError.kt b/package/android/src/main/java/com/mrousavy/camera/CameraError.kt index e462991..94fd335 100644 --- a/package/android/src/main/java/com/mrousavy/camera/CameraError.kt +++ b/package/android/src/main/java/com/mrousavy/camera/CameraError.kt @@ -79,6 +79,13 @@ class RecordingInProgressError : "There is already an active video recording in progress! Did you call startRecording() twice?" ) +class CodeTypeNotSupportedError(codeType: String) : + CameraError( + "code-scanner", + "code-type-not-supported", + "The codeType \"$codeType\" is not supported by the Code Scanner!" + ) + class ViewNotFoundError(viewId: Int) : CameraError("system", "view-not-found", "The given view (ID $viewId) was not found in the view manager.") class FrameProcessorsUnavailableError(reason: String) : diff --git a/package/android/src/main/java/com/mrousavy/camera/CameraQueues.kt b/package/android/src/main/java/com/mrousavy/camera/CameraQueues.kt index b7d7e5c..571b8ca 100644 --- a/package/android/src/main/java/com/mrousavy/camera/CameraQueues.kt +++ b/package/android/src/main/java/com/mrousavy/camera/CameraQueues.kt @@ -11,6 +11,7 @@ class CameraQueues { companion object { val cameraQueue = CameraQueue("mrousavy/VisionCamera.main") val videoQueue = CameraQueue("mrousavy/VisionCamera.video") + val codeScannerQueue = CameraQueue("mrousavy/VisionCamera.codeScanner") } class CameraQueue(name: String) { diff --git a/package/android/src/main/java/com/mrousavy/camera/CameraView+Events.kt b/package/android/src/main/java/com/mrousavy/camera/CameraView+Events.kt index 7e53277..d242029 100644 --- a/package/android/src/main/java/com/mrousavy/camera/CameraView+Events.kt +++ b/package/android/src/main/java/com/mrousavy/camera/CameraView+Events.kt @@ -5,6 +5,8 @@ import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.WritableMap import com.facebook.react.uimanager.events.RCTEventEmitter +import com.google.mlkit.vision.barcode.common.Barcode +import com.mrousavy.camera.parsers.CodeType fun CameraView.invokeOnInitialized() { Log.i(CameraView.TAG, "invokeOnInitialized()") @@ -37,6 +39,31 @@ fun CameraView.invokeOnViewReady() { reactContext.getJSModule(RCTEventEmitter::class.java).receiveEvent(id, "cameraViewReady", event) } +fun CameraView.invokeOnCodeScanned(barcodes: List) { + val codes = Arguments.createArray() + barcodes.forEach { barcode -> + val code = Arguments.createMap() + val type = CodeType.fromBarcodeType(barcode.format) + code.putString("type", type.unionValue) + code.putString("value", barcode.rawValue) + + barcode.boundingBox?.let { rect -> + val frame = Arguments.createMap() + frame.putInt("x", rect.left) + frame.putInt("y", rect.top) + frame.putInt("width", rect.right - rect.left) + frame.putInt("height", rect.bottom - rect.top) + code.putMap("frame", frame) + } + codes.pushMap(code) + } + + val event = Arguments.createMap() + event.putArray("codes", codes) + val reactContext = context as ReactContext + reactContext.getJSModule(RCTEventEmitter::class.java).receiveEvent(id, "cameraCodeScanned", event) +} + private fun errorToMap(error: Throwable): WritableMap { val map = Arguments.createMap() map.putString("message", error.message) diff --git a/package/android/src/main/java/com/mrousavy/camera/CameraView.kt b/package/android/src/main/java/com/mrousavy/camera/CameraView.kt index 0fdf5b8..c4699c3 100644 --- a/package/android/src/main/java/com/mrousavy/camera/CameraView.kt +++ b/package/android/src/main/java/com/mrousavy/camera/CameraView.kt @@ -22,6 +22,7 @@ import com.mrousavy.camera.extensions.getPreviewTargetSize import com.mrousavy.camera.extensions.installHierarchyFitter import com.mrousavy.camera.extensions.smaller import com.mrousavy.camera.frameprocessor.FrameProcessor +import com.mrousavy.camera.parsers.CodeScanner import com.mrousavy.camera.parsers.Orientation import com.mrousavy.camera.parsers.PixelFormat import com.mrousavy.camera.parsers.ResizeMode @@ -47,7 +48,7 @@ class CameraView(context: Context) : FrameLayout(context) { private val propsThatRequirePreviewReconfiguration = arrayListOf("cameraId", "format", "resizeMode") private val propsThatRequireSessionReconfiguration = - arrayListOf("cameraId", "format", "photo", "video", "enableFrameProcessor", "pixelFormat") + arrayListOf("cameraId", "format", "photo", "video", "enableFrameProcessor", "codeScannerOptions", "pixelFormat") private val propsThatRequireFormatReconfiguration = arrayListOf("fps", "hdr", "videoStabilizationMode", "lowLightBoost") } @@ -80,6 +81,9 @@ class CameraView(context: Context) : FrameLayout(context) { var orientation: Orientation? = null var enableZoomGesture: Boolean = false + // code scanner + var codeScannerOptions: CodeScanner? = null + // private properties private var isMounted = false internal val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager @@ -202,6 +206,7 @@ class CameraView(context: Context) : FrameLayout(context) { val targetPhotoSize = if (format != null) Size(format.getInt("photoWidth"), format.getInt("photoHeight")) else null // TODO: Allow previewSurface to be null/none val previewSurface = previewSurface ?: return + val codeScannerOptions = codeScannerOptions val previewOutput = CameraOutputs.PreviewOutput(previewSurface, previewView?.targetSize) val photoOutput = if (photo == true) { @@ -214,8 +219,17 @@ class CameraView(context: Context) : FrameLayout(context) { } else { null } + val codeScanner = if (codeScannerOptions != null) { + CameraOutputs.CodeScannerOutput( + codeScannerOptions, + { codes -> invokeOnCodeScanned(codes) }, + { error -> invokeOnError(error) } + ) + } else { + null + } - cameraSession.configureSession(cameraId, previewOutput, photoOutput, videoOutput) + cameraSession.configureSession(cameraId, previewOutput, photoOutput, videoOutput, codeScanner) } catch (e: Throwable) { Log.e(TAG, "Failed to configure session: ${e.message}", e) invokeOnError(e) diff --git a/package/android/src/main/java/com/mrousavy/camera/CameraViewManager.kt b/package/android/src/main/java/com/mrousavy/camera/CameraViewManager.kt index b858ae7..32543db 100644 --- a/package/android/src/main/java/com/mrousavy/camera/CameraViewManager.kt +++ b/package/android/src/main/java/com/mrousavy/camera/CameraViewManager.kt @@ -5,6 +5,7 @@ import com.facebook.react.common.MapBuilder import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.annotations.ReactProp +import com.mrousavy.camera.parsers.CodeScanner import com.mrousavy.camera.parsers.Orientation import com.mrousavy.camera.parsers.PixelFormat import com.mrousavy.camera.parsers.ResizeMode @@ -27,6 +28,7 @@ class CameraViewManager : ViewGroupManager() { .put("cameraViewReady", MapBuilder.of("registrationName", "onViewReady")) .put("cameraInitialized", MapBuilder.of("registrationName", "onInitialized")) .put("cameraError", MapBuilder.of("registrationName", "onError")) + .put("cameraCodeScanned", MapBuilder.of("registrationName", "onCodeScanned")) .build() override fun getName(): String = TAG @@ -200,6 +202,15 @@ class CameraViewManager : ViewGroupManager() { view.orientation = newMode } + @ReactProp(name = "codeScannerOptions") + fun setCodeScanner(view: CameraView, codeScannerOptions: ReadableMap) { + val newCodeScannerOptions = CodeScanner(codeScannerOptions) + if (view.codeScannerOptions != newCodeScannerOptions) { + addChangedPropToTransaction(view, "codeScannerOptions") + } + view.codeScannerOptions = newCodeScannerOptions + } + companion object { const val TAG = "CameraView" diff --git a/package/android/src/main/java/com/mrousavy/camera/core/CameraSession.kt b/package/android/src/main/java/com/mrousavy/camera/core/CameraSession.kt index 7027225..ff306e0 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/CameraSession.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/CameraSession.kt @@ -139,7 +139,8 @@ class CameraSession( cameraId: String, preview: CameraOutputs.PreviewOutput? = null, photo: CameraOutputs.PhotoOutput? = null, - video: CameraOutputs.VideoOutput? = null + video: CameraOutputs.VideoOutput? = null, + codeScanner: CameraOutputs.CodeScannerOutput? = null ) { Log.i(TAG, "Configuring Session for Camera $cameraId...") val outputs = CameraOutputs( @@ -148,6 +149,7 @@ class CameraSession( preview, photo, video, + codeScanner, hdr == true, this ) @@ -190,6 +192,7 @@ class CameraSession( currentOutputs.preview, currentOutputs.photo, currentOutputs.video, + currentOutputs.codeScanner, hdr, this ) @@ -534,11 +537,15 @@ class CameraSession( val template = if (outputs.videoOutput != null) CameraDevice.TEMPLATE_RECORD else CameraDevice.TEMPLATE_PREVIEW val captureRequest = camera.createCaptureRequest(template) outputs.previewOutput?.let { output -> - Log.i(TAG, "Adding output surface ${output.outputType}..") + Log.i(TAG, "Adding preview output surface ${output.outputType}..") captureRequest.addTarget(output.surface) } outputs.videoOutput?.let { output -> - Log.i(TAG, "Adding output surface ${output.outputType}..") + Log.i(TAG, "Adding video output surface ${output.outputType}..") + captureRequest.addTarget(output.surface) + } + outputs.codeScannerOutput?.let { output -> + Log.i(TAG, "Adding code scanner output surface ${output.outputType}") captureRequest.addTarget(output.surface) } diff --git a/package/android/src/main/java/com/mrousavy/camera/core/outputs/BarcodeScannerOutput.kt b/package/android/src/main/java/com/mrousavy/camera/core/outputs/BarcodeScannerOutput.kt new file mode 100644 index 0000000..681d45b --- /dev/null +++ b/package/android/src/main/java/com/mrousavy/camera/core/outputs/BarcodeScannerOutput.kt @@ -0,0 +1,19 @@ +package com.mrousavy.camera.core.outputs + +import android.media.ImageReader +import android.util.Log +import com.google.mlkit.vision.barcode.BarcodeScanner +import java.io.Closeable + +class BarcodeScannerOutput(private val imageReader: ImageReader, private val barcodeScanner: BarcodeScanner) : + ImageReaderOutput(imageReader, OutputType.VIDEO), + Closeable { + override fun close() { + Log.i(TAG, "Closing BarcodeScanner..") + barcodeScanner.close() + super.close() + } + + override fun toString(): String = + "$outputType (${imageReader.width} x ${imageReader.height} ${barcodeScanner.detectorType} BarcodeScanner)" +} diff --git a/package/android/src/main/java/com/mrousavy/camera/core/outputs/CameraOutputs.kt b/package/android/src/main/java/com/mrousavy/camera/core/outputs/CameraOutputs.kt index 8e93ecb..55cdc00 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/outputs/CameraOutputs.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/outputs/CameraOutputs.kt @@ -8,6 +8,10 @@ import android.media.ImageReader import android.util.Log import android.util.Size import android.view.Surface +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage import com.mrousavy.camera.CameraQueues import com.mrousavy.camera.core.VideoPipeline import com.mrousavy.camera.extensions.bigger @@ -16,6 +20,8 @@ import com.mrousavy.camera.extensions.getPhotoSizes import com.mrousavy.camera.extensions.getPreviewTargetSize import com.mrousavy.camera.extensions.getVideoSizes import com.mrousavy.camera.extensions.smaller +import com.mrousavy.camera.parsers.CodeScanner +import com.mrousavy.camera.parsers.Orientation import com.mrousavy.camera.parsers.PixelFormat import java.io.Closeable @@ -25,6 +31,7 @@ class CameraOutputs( val preview: PreviewOutput? = null, val photo: PhotoOutput? = null, val video: VideoOutput? = null, + val codeScanner: CodeScannerOutput? = null, val enableHdr: Boolean? = false, val callback: Callback ) : Closeable { @@ -41,6 +48,11 @@ class CameraOutputs( val enableFrameProcessor: Boolean? = false, val format: PixelFormat = PixelFormat.NATIVE ) + data class CodeScannerOutput( + val codeScanner: CodeScanner, + val onCodeScanned: (codes: List) -> Unit, + val onError: (error: Throwable) -> Unit + ) interface Callback { fun onPhotoCaptured(image: Image) @@ -52,6 +64,8 @@ class CameraOutputs( private set var videoOutput: VideoPipelineOutput? = null private set + var codeScannerOutput: BarcodeScannerOutput? = null + private set val size: Int get() { @@ -59,6 +73,7 @@ class CameraOutputs( if (previewOutput != null) size++ if (photoOutput != null) size++ if (videoOutput != null) size++ + if (codeScannerOutput != null) size++ return size } @@ -72,6 +87,7 @@ class CameraOutputs( this.video?.enableRecording == other.video?.enableRecording && this.video?.targetSize == other.video?.targetSize && this.video?.format == other.video?.format && + this.codeScanner?.codeScanner == other.codeScanner?.codeScanner && this.enableHdr == other.enableHdr } @@ -80,12 +96,14 @@ class CameraOutputs( result += (preview?.hashCode() ?: 0) result += (photo?.hashCode() ?: 0) result += (video?.hashCode() ?: 0) + result += (codeScanner?.hashCode() ?: 0) return result } override fun close() { photoOutput?.close() videoOutput?.close() + codeScannerOutput?.close() } override fun toString(): String { @@ -93,6 +111,7 @@ class CameraOutputs( previewOutput?.let { strings.add(it.toString()) } photoOutput?.let { strings.add(it.toString()) } videoOutput?.let { strings.add(it.toString()) } + codeScannerOutput?.let { strings.add(it.toString()) } return strings.joinToString(", ", "[", "]") } @@ -144,6 +163,47 @@ class CameraOutputs( videoOutput = VideoPipelineOutput(videoPipeline, SurfaceOutput.OutputType.VIDEO) } + // Code Scanner + if (codeScanner != null) { + val format = ImageFormat.YUV_420_888 + val targetSize = Size(1280, 720) + val size = characteristics.getVideoSizes(cameraId, format).closestToOrMax(targetSize) + + val types = codeScanner.codeScanner.codeTypes.map { it.toBarcodeType() } + val barcodeScannerOptions = BarcodeScannerOptions.Builder() + .setBarcodeFormats(types[0], *types.toIntArray()) + .setExecutor(CameraQueues.codeScannerQueue.executor) + .build() + val scanner = BarcodeScanning.getClient(barcodeScannerOptions) + + var isBusy = false + val imageReader = ImageReader.newInstance(size.width, size.height, format, 1) + imageReader.setOnImageAvailableListener({ reader -> + if (isBusy) return@setOnImageAvailableListener + val image = reader.acquireNextImage() ?: return@setOnImageAvailableListener + + isBusy = true + // TODO: Get correct orientation + val inputImage = InputImage.fromMediaImage(image, Orientation.PORTRAIT.toDegrees()) + scanner.process(inputImage) + .addOnSuccessListener { barcodes -> + image.close() + isBusy = false + if (barcodes.isNotEmpty()) { + codeScanner.onCodeScanned(barcodes) + } + } + .addOnFailureListener { error -> + image.close() + isBusy = false + codeScanner.onError(error) + } + }, CameraQueues.videoQueue.handler) + + Log.i(TAG, "Adding ${size.width}x${size.height} code scanner output. (Code Types: $types)") + codeScannerOutput = BarcodeScannerOutput(imageReader, scanner) + } + Log.i(TAG, "Prepared $size Outputs for Camera $cameraId!") } } diff --git a/package/android/src/main/java/com/mrousavy/camera/core/outputs/ImageReaderOutput.kt b/package/android/src/main/java/com/mrousavy/camera/core/outputs/ImageReaderOutput.kt index 69b31fb..f127ea7 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/outputs/ImageReaderOutput.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/outputs/ImageReaderOutput.kt @@ -5,7 +5,7 @@ import android.util.Log import android.util.Size import java.io.Closeable -class ImageReaderOutput(private val imageReader: ImageReader, outputType: OutputType, dynamicRangeProfile: Long? = null) : +open class ImageReaderOutput(private val imageReader: ImageReader, outputType: OutputType, dynamicRangeProfile: Long? = null) : SurfaceOutput( imageReader.surface, Size(imageReader.width, imageReader.height), @@ -16,6 +16,7 @@ class ImageReaderOutput(private val imageReader: ImageReader, outputType: Output override fun close() { Log.i(TAG, "Closing ${imageReader.width}x${imageReader.height} $outputType ImageReader..") imageReader.close() + super.close() } override fun toString(): String = "$outputType (${imageReader.width} x ${imageReader.height} in format #${imageReader.imageFormat})" diff --git a/package/android/src/main/java/com/mrousavy/camera/core/outputs/VideoPipelineOutput.kt b/package/android/src/main/java/com/mrousavy/camera/core/outputs/VideoPipelineOutput.kt index befb5c3..76d4593 100644 --- a/package/android/src/main/java/com/mrousavy/camera/core/outputs/VideoPipelineOutput.kt +++ b/package/android/src/main/java/com/mrousavy/camera/core/outputs/VideoPipelineOutput.kt @@ -16,6 +16,7 @@ class VideoPipelineOutput(val videoPipeline: VideoPipeline, outputType: OutputTy override fun close() { Log.i(TAG, "Closing ${videoPipeline.width}x${videoPipeline.height} Video Pipeline..") videoPipeline.close() + super.close() } override fun toString(): String = "$outputType (${videoPipeline.width} x ${videoPipeline.height} in format #${videoPipeline.format})" diff --git a/package/android/src/main/java/com/mrousavy/camera/extensions/CameraDevice+createCaptureSession.kt b/package/android/src/main/java/com/mrousavy/camera/extensions/CameraDevice+createCaptureSession.kt index cb28cff..6083c28 100644 --- a/package/android/src/main/java/com/mrousavy/camera/extensions/CameraDevice+createCaptureSession.kt +++ b/package/android/src/main/java/com/mrousavy/camera/extensions/CameraDevice+createCaptureSession.kt @@ -62,6 +62,9 @@ suspend fun CameraDevice.createCaptureSession( outputs.videoOutput?.let { output -> outputConfigurations.add(output.toOutputConfiguration(characteristics)) } + outputs.codeScannerOutput?.let { output -> + outputConfigurations.add(output.toOutputConfiguration(characteristics)) + } if (outputs.enableHdr == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val supportedProfiles = characteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_DYNAMIC_RANGE_PROFILES) val hdrProfile = supportedProfiles?.bestProfile ?: supportedProfiles?.supportedProfiles?.firstOrNull() diff --git a/package/android/src/main/java/com/mrousavy/camera/parsers/CodeScanner.kt b/package/android/src/main/java/com/mrousavy/camera/parsers/CodeScanner.kt new file mode 100644 index 0000000..2edb373 --- /dev/null +++ b/package/android/src/main/java/com/mrousavy/camera/parsers/CodeScanner.kt @@ -0,0 +1,22 @@ +package com.mrousavy.camera.parsers + +import com.facebook.react.bridge.ReadableMap +import com.mrousavy.camera.InvalidTypeScriptUnionError + +class CodeScanner(map: ReadableMap) { + val codeTypes: List + + init { + val codeTypes = map.getArray("codeTypes")?.toArrayList() ?: throw InvalidTypeScriptUnionError("codeScanner", map.toString()) + this.codeTypes = codeTypes.map { + return@map CodeType.fromUnionValue(it as String) + } + } + + override fun equals(other: Any?): Boolean { + if (other !is CodeScanner) return false + return codeTypes.size == other.codeTypes.size && codeTypes.containsAll(other.codeTypes) + } + + override fun hashCode(): Int = codeTypes.hashCode() +} diff --git a/package/android/src/main/java/com/mrousavy/camera/parsers/CodeType.kt b/package/android/src/main/java/com/mrousavy/camera/parsers/CodeType.kt new file mode 100644 index 0000000..0ba2964 --- /dev/null +++ b/package/android/src/main/java/com/mrousavy/camera/parsers/CodeType.kt @@ -0,0 +1,74 @@ +package com.mrousavy.camera.parsers + +import com.google.mlkit.vision.barcode.common.Barcode +import com.mrousavy.camera.CodeTypeNotSupportedError +import com.mrousavy.camera.InvalidTypeScriptUnionError + +enum class CodeType(override val unionValue: String) : JSUnionValue { + CODE_128("code-128"), + CODE_39("code-39"), + CODE_93("code-93"), + CODABAR("codabar"), + EAN_13("ean-13"), + EAN_8("ean-8"), + ITF("itf"), + UPC_E("upc-e"), + QR("qr"), + PDF_417("pdf-417"), + AZTEC("aztec"), + DATA_MATRIX("data-matrix"), + UNKNOWN("unknown"); + + fun toBarcodeType(): Int = + when (this) { + CODE_128 -> Barcode.FORMAT_CODE_128 + CODE_39 -> Barcode.FORMAT_CODE_39 + CODE_93 -> Barcode.FORMAT_CODE_93 + CODABAR -> Barcode.FORMAT_CODABAR + EAN_13 -> Barcode.FORMAT_EAN_13 + EAN_8 -> Barcode.FORMAT_EAN_8 + ITF -> Barcode.FORMAT_ITF + UPC_E -> Barcode.FORMAT_UPC_E + QR -> Barcode.FORMAT_QR_CODE + PDF_417 -> Barcode.FORMAT_PDF417 + AZTEC -> Barcode.FORMAT_AZTEC + DATA_MATRIX -> Barcode.FORMAT_DATA_MATRIX + UNKNOWN -> throw CodeTypeNotSupportedError(this.unionValue) + } + + companion object : JSUnionValue.Companion { + fun fromBarcodeType(barcodeType: Int): CodeType = + when (barcodeType) { + Barcode.FORMAT_CODE_128 -> CODE_128 + Barcode.FORMAT_CODE_39 -> CODE_39 + Barcode.FORMAT_CODE_93 -> CODE_93 + Barcode.FORMAT_CODABAR -> CODABAR + Barcode.FORMAT_EAN_13 -> EAN_13 + Barcode.FORMAT_EAN_8 -> EAN_8 + Barcode.FORMAT_ITF -> ITF + Barcode.FORMAT_UPC_E -> UPC_E + Barcode.FORMAT_QR_CODE -> QR + Barcode.FORMAT_PDF417 -> PDF_417 + Barcode.FORMAT_AZTEC -> AZTEC + Barcode.FORMAT_DATA_MATRIX -> DATA_MATRIX + else -> UNKNOWN + } + + override fun fromUnionValue(unionValue: String?): CodeType = + when (unionValue) { + "code-128" -> CODE_128 + "code-39" -> CODE_39 + "code-93" -> CODE_93 + "codabar" -> CODABAR + "ean-13" -> EAN_13 + "ean-8" -> EAN_8 + "itf" -> ITF + "upc-e" -> UPC_E + "qr" -> QR + "pdf-417" -> PDF_417 + "aztec" -> AZTEC + "data-matrix" -> DATA_MATRIX + else -> throw InvalidTypeScriptUnionError("codeType", unionValue ?: "(null)") + } + } +} diff --git a/package/android/src/main/java/com/mrousavy/camera/parsers/PixelFormat.kt b/package/android/src/main/java/com/mrousavy/camera/parsers/PixelFormat.kt index 9a0cd07..8c9c7b1 100644 --- a/package/android/src/main/java/com/mrousavy/camera/parsers/PixelFormat.kt +++ b/package/android/src/main/java/com/mrousavy/camera/parsers/PixelFormat.kt @@ -9,31 +9,28 @@ enum class PixelFormat(override val unionValue: String) : JSUnionValue { NATIVE("native"), UNKNOWN("unknown"); - fun toImageFormat(): Int { - return when (this) { + fun toImageFormat(): Int = + when (this) { YUV -> ImageFormat.YUV_420_888 NATIVE -> ImageFormat.PRIVATE else -> throw PixelFormatNotSupportedError(this.unionValue) } - } companion object : JSUnionValue.Companion { - fun fromImageFormat(imageFormat: Int): PixelFormat { - return when (imageFormat) { + fun fromImageFormat(imageFormat: Int): PixelFormat = + when (imageFormat) { ImageFormat.YUV_420_888 -> YUV ImageFormat.PRIVATE -> NATIVE else -> UNKNOWN } - } - override fun fromUnionValue(unionValue: String?): PixelFormat? { - return when (unionValue) { + override fun fromUnionValue(unionValue: String?): PixelFormat? = + when (unionValue) { "yuv" -> YUV "rgb" -> RGB "native" -> NATIVE "unknown" -> UNKNOWN else -> null } - } } } diff --git a/package/ios/CameraError.swift b/package/ios/CameraError.swift index 1dc6283..105a1e9 100644 --- a/package/ios/CameraError.swift +++ b/package/ios/CameraError.swift @@ -229,6 +229,31 @@ enum CaptureError { } } +// MARK: - CodeScannerError + +enum CodeScannerError { + case notCompatibleWithOutputs + case codeTypeNotSupported(codeType: String) + + var code: String { + switch self { + case .notCompatibleWithOutputs: + return "not-compatible-with-outputs" + case .codeTypeNotSupported: + return "code-type-not-supported" + } + } + + var message: String { + switch self { + case .notCompatibleWithOutputs: + return "The Code Scanner is not supported in combination with the current outputs! Either disable video or photo outputs." + case let .codeTypeNotSupported(codeType: codeType): + return "The codeType \"\(codeType)\" is not supported by the Code Scanner!" + } + } +} + // MARK: - CameraError enum CameraError: Error { @@ -238,6 +263,7 @@ enum CameraError: Error { case format(_ id: FormatError) case session(_ id: SessionError) case capture(_ id: CaptureError) + case codeScanner(_ id: CodeScannerError) case unknown(message: String? = nil) var code: String { @@ -254,6 +280,8 @@ enum CameraError: Error { return "session/\(id.code)" case let .capture(id: id): return "capture/\(id.code)" + case let .codeScanner(id: id): + return "code-scanner/\(id.code)" case .unknown: return "unknown/unknown" } @@ -273,6 +301,8 @@ enum CameraError: Error { return id.message case let .capture(id: id): return id.message + case let .codeScanner(id: id): + return id.message case let .unknown(message: message): return message ?? "An unexpected error occured." } diff --git a/package/ios/CameraQueues.swift b/package/ios/CameraQueues.swift index 01704a9..2f88e7e 100644 --- a/package/ios/CameraQueues.swift +++ b/package/ios/CameraQueues.swift @@ -24,6 +24,13 @@ public class CameraQueues: NSObject { autoreleaseFrequency: .inherit, target: nil) + /// The serial execution queue for output processing of QR/barcodes. + @objc public static let codeScannerQueue = DispatchQueue(label: "mrousavy/VisionCamera.codeScanner", + qos: .userInteractive, + attributes: [], + autoreleaseFrequency: .inherit, + target: nil) + /// The serial execution queue for output processing of audio buffers. @objc public static let audioQueue = DispatchQueue(label: "mrousavy/VisionCamera.audio", qos: .userInteractive, diff --git a/package/ios/CameraView+AVCaptureSession.swift b/package/ios/CameraView+AVCaptureSession.swift index 9cce748..95607ff 100644 --- a/package/ios/CameraView+AVCaptureSession.swift +++ b/package/ios/CameraView+AVCaptureSession.swift @@ -124,6 +124,34 @@ extension CameraView { captureSession.addOutput(videoOutput!) } + // Code Scanner + if let codeScannerOptions = codeScannerOptions { + guard let codeScanner = try? CodeScanner(fromJsValue: codeScannerOptions) else { + invokeOnError(.parameter(.invalid(unionName: "codeScanner", receivedValue: codeScannerOptions.description))) + return + } + let metadataOutput = AVCaptureMetadataOutput() + guard captureSession.canAddOutput(metadataOutput) else { + invokeOnError(.codeScanner(.notCompatibleWithOutputs)) + return + } + captureSession.addOutput(metadataOutput) + + for codeType in codeScanner.codeTypes { + // swiftlint:disable:next for_where + if !metadataOutput.availableMetadataObjectTypes.contains(codeType) { + invokeOnError(.codeScanner(.codeTypeNotSupported(codeType: codeType.descriptor))) + return + } + } + + metadataOutput.setMetadataObjectsDelegate(self, queue: CameraQueues.codeScannerQueue) + metadataOutput.metadataObjectTypes = codeScanner.codeTypes + if let rectOfInterest = codeScanner.regionOfInterest { + metadataOutput.rectOfInterest = rectOfInterest + } + } + if outputOrientation != .portrait { updateOrientation() } diff --git a/package/ios/CameraView+CodeScanner.swift b/package/ios/CameraView+CodeScanner.swift new file mode 100644 index 0000000..9d17b03 --- /dev/null +++ b/package/ios/CameraView+CodeScanner.swift @@ -0,0 +1,45 @@ +// +// CameraView+CodeScanner.swift +// VisionCamera +// +// Created by Marc Rousavy on 03.10.23. +// Copyright © 2023 mrousavy. All rights reserved. +// + +import AVFoundation +import Foundation + +extension CameraView: AVCaptureMetadataOutputObjectsDelegate { + public func metadataOutput(_: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from _: AVCaptureConnection) { + guard let onCodeScanned = onCodeScanned else { + return + } + guard !metadataObjects.isEmpty else { + return + } + + // Map codes to JS values + let codes = metadataObjects.map { object in + var value: String? + if let code = object as? AVMetadataMachineReadableCodeObject { + value = code.stringValue + } + let frame = previewView.layerRectConverted(fromMetadataOutputRect: object.bounds) + + return [ + "type": object.type.descriptor, + "value": value as Any, + "frame": [ + "x": frame.origin.x, + "y": frame.origin.y, + "width": frame.size.width, + "height": frame.size.height, + ], + ] + } + // Call JS event + onCodeScanned([ + "codes": codes, + ]) + } +} diff --git a/package/ios/CameraView.swift b/package/ios/CameraView.swift index 08f3552..4340276 100644 --- a/package/ios/CameraView.swift +++ b/package/ios/CameraView.swift @@ -27,7 +27,8 @@ private let propsThatRequireReconfiguration = ["cameraId", "video", "enableFrameProcessor", "hdr", - "pixelFormat"] + "pixelFormat", + "codeScannerOptions"] private let propsThatRequireDeviceReconfiguration = ["fps", "lowLightBoost"] @@ -46,6 +47,7 @@ public final class CameraView: UIView { @objc var video: NSNumber? // nullable bool @objc var audio: NSNumber? // nullable bool @objc var enableFrameProcessor = false + @objc var codeScannerOptions: NSDictionary? @objc var pixelFormat: NSString? // props that require format reconfiguring @objc var format: NSDictionary? @@ -69,6 +71,7 @@ public final class CameraView: UIView { @objc var onInitialized: RCTDirectEventBlock? @objc var onError: RCTDirectEventBlock? @objc var onViewReady: RCTDirectEventBlock? + @objc var onCodeScanned: RCTDirectEventBlock? // zoom @objc var enableZoomGesture = false { didSet { diff --git a/package/ios/CameraViewManager.m b/package/ios/CameraViewManager.m index db3ef4d..4cee979 100644 --- a/package/ios/CameraViewManager.m +++ b/package/ios/CameraViewManager.m @@ -51,6 +51,9 @@ RCT_EXPORT_VIEW_PROPERTY(resizeMode, NSString); RCT_EXPORT_VIEW_PROPERTY(onError, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onInitialized, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onViewReady, RCTDirectEventBlock); +// Code Scanner +RCT_EXPORT_VIEW_PROPERTY(codeScannerOptions, NSDictionary); +RCT_EXPORT_VIEW_PROPERTY(onCodeScanned, RCTDirectEventBlock); // Camera View Functions RCT_EXTERN_METHOD(startRecording diff --git a/package/ios/Parsers/AVMetadataObject.ObjectType+descriptor.swift b/package/ios/Parsers/AVMetadataObject.ObjectType+descriptor.swift new file mode 100644 index 0000000..e74838b --- /dev/null +++ b/package/ios/Parsers/AVMetadataObject.ObjectType+descriptor.swift @@ -0,0 +1,94 @@ +// +// AVMetadataObject.ObjectType+descriptor.swift +// VisionCamera +// +// Created by Marc Rousavy on 03.10.23. +// Copyright © 2023 mrousavy. All rights reserved. +// + +import AVFoundation +import Foundation + +extension AVMetadataObject.ObjectType { + init(withString string: String) throws { + switch string { + case "code-128": + self = .code128 + return + case "code-39": + self = .code39 + return + case "code-93": + self = .code93 + return + case "codabar": + if #available(iOS 15.4, *) { + self = .codabar + } else { + throw CameraError.codeScanner(.codeTypeNotSupported(codeType: string)) + } + return + case "ean-13": + self = .ean13 + return + case "ean-8": + self = .ean8 + return + case "itf": + self = .itf14 + return + case "upc-e": + self = .upce + return + case "qr": + self = .qr + return + case "pdf-417": + self = .pdf417 + return + case "aztec": + self = .aztec + return + case "data-matrix": + self = .dataMatrix + return + default: + throw EnumParserError.invalidValue + } + } + + var descriptor: String { + if #available(iOS 15.4, *) { + if self == .codabar { + return "codabar" + } + } + + switch self { + case .code128: + return "code-128" + case .code39: + return "code-39" + case .code93: + return "code-93" + case .ean13: + return "ean-13" + case .ean8: + return "ean-8" + case .itf14: + return "itf" + case .upce: + return "upce" + case .qr: + return "qr" + case .pdf417: + return "pdf-417" + case .aztec: + return "aztec" + case .dataMatrix: + return "data-matrix" + default: + return "unknown" + } + } +} diff --git a/package/ios/PreviewView.swift b/package/ios/PreviewView.swift index f44cd66..be1e86a 100644 --- a/package/ios/PreviewView.swift +++ b/package/ios/PreviewView.swift @@ -38,6 +38,10 @@ class PreviewView: UIView { return AVCaptureVideoPreviewLayer.self } + func layerRectConverted(fromMetadataOutputRect rect: CGRect) -> CGRect { + return videoPreviewLayer.layerRectConverted(fromMetadataOutputRect: rect) + } + init(frame: CGRect, session: AVCaptureSession) { super.init(frame: frame) videoPreviewLayer.session = session diff --git a/package/ios/Types/CodeScanner.swift b/package/ios/Types/CodeScanner.swift new file mode 100644 index 0000000..116b06e --- /dev/null +++ b/package/ios/Types/CodeScanner.swift @@ -0,0 +1,45 @@ +// +// CodeScanner.swift +// VisionCamera +// +// Created by Marc Rousavy on 03.10.23. +// Copyright © 2023 mrousavy. All rights reserved. +// + +import AVFoundation +import Foundation + +class CodeScanner { + let codeTypes: [AVMetadataObject.ObjectType] + let interval: Int + let regionOfInterest: CGRect? + + init(fromJsValue dictionary: NSDictionary) throws { + if let codeTypes = dictionary["codeTypes"] as? [String] { + self.codeTypes = try codeTypes.map { value in + return try AVMetadataObject.ObjectType(withString: value) + } + } else { + throw CameraError.parameter(.invalidCombination(provided: "codeScanner", missing: "codeTypes")) + } + + if let interval = dictionary["interval"] as? Double { + self.interval = Int(interval) + } else { + interval = 300 + } + + if let regionOfInterest = dictionary["regionOfInterest"] as? NSDictionary { + guard let x = regionOfInterest["x"] as? Double, + let y = regionOfInterest["y"] as? Double, + let width = regionOfInterest["width"] as? Double, + let height = regionOfInterest["height"] as? Double else { + throw CameraError.parameter(.invalid(unionName: "regionOfInterest", receivedValue: regionOfInterest.description)) + } + + self.regionOfInterest = CGRect(x: x, y: y, width: width, height: height) + } else { + regionOfInterest = nil + } + } +} diff --git a/package/ios/VisionCamera.xcodeproj/project.pbxproj b/package/ios/VisionCamera.xcodeproj/project.pbxproj index b5f85e0..410d4d5 100644 --- a/package/ios/VisionCamera.xcodeproj/project.pbxproj +++ b/package/ios/VisionCamera.xcodeproj/project.pbxproj @@ -65,6 +65,9 @@ B8DB3BCA263DC4D8004C18D7 /* RecordingSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8DB3BC9263DC4D8004C18D7 /* RecordingSession.swift */; }; B8DB3BCC263DC97E004C18D7 /* AVFileType+descriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8DB3BCB263DC97E004C18D7 /* AVFileType+descriptor.swift */; }; B8E957D02A693AD2008F5480 /* CameraView+Torch.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8E957CF2A693AD2008F5480 /* CameraView+Torch.swift */; }; + B8FF60AC2ACC93EF009D612F /* CameraView+CodeScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8FF60AB2ACC93EF009D612F /* CameraView+CodeScanner.swift */; }; + B8FF60AE2ACC9731009D612F /* CodeScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8FF60AD2ACC9731009D612F /* CodeScanner.swift */; }; + B8FF60B12ACC981B009D612F /* AVMetadataObject.ObjectType+descriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8FF60B02ACC981B009D612F /* AVMetadataObject.ObjectType+descriptor.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -152,6 +155,9 @@ B8F0825E2A6046FC00C17EB6 /* FrameProcessor.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FrameProcessor.h; sourceTree = ""; }; B8F0825F2A60491900C17EB6 /* FrameProcessor.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = FrameProcessor.mm; sourceTree = ""; }; B8F7DDD1266F715D00120533 /* Frame.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Frame.m; sourceTree = ""; }; + B8FF60AB2ACC93EF009D612F /* CameraView+CodeScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CameraView+CodeScanner.swift"; sourceTree = ""; }; + B8FF60AD2ACC9731009D612F /* CodeScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeScanner.swift; sourceTree = ""; }; + B8FF60B02ACC981B009D612F /* AVMetadataObject.ObjectType+descriptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVMetadataObject.ObjectType+descriptor.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -188,6 +194,7 @@ B887518025E0102000DB86D6 /* CameraView+Focus.swift */, B887515D25E0102000DB86D6 /* CameraView+RecordVideo.swift */, B887517125E0102000DB86D6 /* CameraView+TakePhoto.swift */, + B8FF60AB2ACC93EF009D612F /* CameraView+CodeScanner.swift */, B887518225E0102000DB86D6 /* CameraView+Zoom.swift */, B86400512784A23400E9D2CA /* CameraView+Orientation.swift */, B887515F25E0102000DB86D6 /* CameraViewManager.m */, @@ -208,6 +215,7 @@ isa = PBXGroup; children = ( B80175EB2ABDEBD000E7DE90 /* ResizeMode.swift */, + B8FF60AD2ACC9731009D612F /* CodeScanner.swift */, ); path = Types; sourceTree = ""; @@ -264,6 +272,7 @@ B8DB3BCB263DC97E004C18D7 /* AVFileType+descriptor.swift */, B864004F27849A2400E9D2CA /* UIInterfaceOrientation+descriptor.swift */, B87B11BE2A8E63B700732EBF /* PixelFormat.swift */, + B8FF60B02ACC981B009D612F /* AVMetadataObject.ObjectType+descriptor.swift */, ); path = Parsers; sourceTree = ""; @@ -413,6 +422,7 @@ B887518525E0102000DB86D6 /* PhotoCaptureDelegate.swift in Sources */, B887518B25E0102000DB86D6 /* AVCaptureDevice.Format+isBetterThan.swift in Sources */, B8BD3BA2266E22D2006C80A2 /* Callback.swift in Sources */, + B8FF60B12ACC981B009D612F /* AVMetadataObject.ObjectType+descriptor.swift in Sources */, B84760A62608EE7C004C3180 /* FrameHostObject.mm in Sources */, B864005027849A2400E9D2CA /* UIInterfaceOrientation+descriptor.swift in Sources */, B887518E25E0102000DB86D6 /* AVFrameRateRange+includes.swift in Sources */, @@ -429,10 +439,12 @@ B881D35E2ABC775E009B21C8 /* AVCaptureDevice+toDictionary.swift in Sources */, B87B11BF2A8E63B700732EBF /* PixelFormat.swift in Sources */, B88751A625E0102000DB86D6 /* CameraViewManager.swift in Sources */, + B8FF60AC2ACC93EF009D612F /* CameraView+CodeScanner.swift in Sources */, B80175EC2ABDEBD000E7DE90 /* ResizeMode.swift in Sources */, B887519F25E0102000DB86D6 /* AVCaptureDevice.DeviceType+physicalDeviceDescriptor.swift in Sources */, B8D22CDC2642DB4D00234472 /* AVAssetWriterInputPixelBufferAdaptor+initWithVideoSettings.swift in Sources */, B84760DF2608F57D004C3180 /* CameraQueues.swift in Sources */, + B8FF60AE2ACC9731009D612F /* CodeScanner.swift in Sources */, B8446E502ABA14C900E56077 /* CameraDevicesManager.m in Sources */, B887519025E0102000DB86D6 /* AVCaptureDevice.Format+matchesFilter.swift in Sources */, B887518F25E0102000DB86D6 /* AVCapturePhotoOutput+mirror.swift in Sources */, diff --git a/package/src/Camera.tsx b/package/src/Camera.tsx index e8d4ef4..253bd50 100644 --- a/package/src/Camera.tsx +++ b/package/src/Camera.tsx @@ -11,21 +11,27 @@ import type { RecordVideoOptions, VideoFile } from './VideoFile' import { VisionCameraProxy } from './FrameProcessorPlugins' import { CameraDevices } from './CameraDevices' import type { EmitterSubscription } from 'react-native' +import { Code, CodeScanner } from './CodeScanner' //#region Types export type CameraPermissionStatus = 'granted' | 'not-determined' | 'denied' | 'restricted' export type CameraPermissionRequestResult = 'granted' | 'denied' +interface OnCodeScannedEvent { + codes: Code[] +} interface OnErrorEvent { code: string message: string cause?: ErrorWithCause } -type NativeCameraViewProps = Omit & { +type NativeCameraViewProps = Omit & { cameraId: string enableFrameProcessor: boolean + codeScannerOptions?: Omit onInitialized?: (event: NativeSyntheticEvent) => void onError?: (event: NativeSyntheticEvent) => void + onCodeScanned?: (event: NativeSyntheticEvent) => void onViewReady: () => void } type RefType = React.Component & Readonly @@ -76,6 +82,7 @@ export class Camera extends React.PureComponent { this.onViewReady = this.onViewReady.bind(this) this.onInitialized = this.onInitialized.bind(this) this.onError = this.onError.bind(this) + this.onCodeScanned = this.onCodeScanned.bind(this) this.ref = React.createRef() this.lastFrameProcessor = undefined } @@ -387,6 +394,13 @@ export class Camera extends React.PureComponent { } //#endregion + private onCodeScanned(event: NativeSyntheticEvent): void { + const codeScanner = this.props.codeScanner + if (codeScanner == null) return + + codeScanner.onCodeScanned(event.nativeEvent.codes) + } + //#region Lifecycle private setFrameProcessor(frameProcessor: FrameProcessor): void { VisionCameraProxy.setFrameProcessor(this.handle, frameProcessor) @@ -422,7 +436,7 @@ export class Camera extends React.PureComponent { /** @internal */ public render(): React.ReactNode { // We remove the big `device` object from the props because we only need to pass `cameraId` to native. - const { device, frameProcessor, ...props } = this.props + const { device, frameProcessor, codeScanner, ...props } = this.props // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (device == null) { @@ -440,7 +454,9 @@ export class Camera extends React.PureComponent { ref={this.ref} onViewReady={this.onViewReady} onInitialized={this.onInitialized} + onCodeScanned={this.onCodeScanned} onError={this.onError} + codeScannerOptions={codeScanner} enableFrameProcessor={frameProcessor != null} enableBufferCompression={props.enableBufferCompression ?? shouldEnableBufferCompression} /> diff --git a/package/src/CameraError.ts b/package/src/CameraError.ts index 20c4a6b..9525f12 100644 --- a/package/src/CameraError.ts +++ b/package/src/CameraError.ts @@ -24,6 +24,10 @@ export type SessionError = | 'session/camera-has-been-disconnected' | 'session/audio-in-use-by-other-app' | 'session/audio-session-failed-to-activate' +export type CodeScannerError = + | 'code-scanner/not-compatible-with-outputs' + | 'code-scanner/code-type-not-supported' + | 'code-scanner/cannot-load-model' export type CaptureError = | 'capture/recording-in-progress' | 'capture/no-recording-in-progress' diff --git a/package/src/CameraProps.ts b/package/src/CameraProps.ts index 33c8ecb..56026a4 100644 --- a/package/src/CameraProps.ts +++ b/package/src/CameraProps.ts @@ -1,6 +1,7 @@ import type { ViewProps } from 'react-native' import type { CameraDevice, CameraDeviceFormat, VideoStabilizationMode } from './CameraDevice' import type { CameraRuntimeError } from './CameraError' +import { CodeScanner } from './CodeScanner' import type { Frame } from './Frame' import type { Orientation } from './Orientation' @@ -223,13 +224,17 @@ export interface CameraProps extends ViewProps { * ```tsx * const frameProcessor = useFrameProcessor((frame) => { * 'worklet' - * const qrCodes = scanQRCodes(frame) - * console.log(`Detected QR Codes: ${qrCodes}`) + * const faces = scanFaces(frame) + * console.log(`Faces: ${faces}`) * }, []) * * return * ``` */ frameProcessor?: FrameProcessor + /** + * TODO: Desc + */ + codeScanner?: CodeScanner //#endregion } diff --git a/package/src/CodeScanner.ts b/package/src/CodeScanner.ts new file mode 100644 index 0000000..2e28373 --- /dev/null +++ b/package/src/CodeScanner.ts @@ -0,0 +1,62 @@ +/** + * The type of the code to scan. + */ +export type CodeType = + | 'code-128' + | 'code-39' + | 'code-93' + | 'codabar' + | 'ean-13' + | 'ean-8' + | 'itf' + | 'upc-e' + | 'qr' + | 'pdf-417' + | 'aztec' + | 'data-matrix' + +/** + * A scanned code. + */ +export interface Code { + /** + * The type of the code that was scanned. + */ + type: CodeType | 'unknown' + /** + * The string value, or null if it cannot be decoded. + */ + value?: string + /** + * The location of the code relative to the Camera Preview (in dp). + */ + frame?: { + x: number + y: number + width: number + height: number + } +} + +/** + * A scanner for detecting codes in a Camera Stream. + */ +export interface CodeScanner { + /** + * The types of codes to configure the code scanner for. + */ + codeTypes: CodeType[] + /** + * A callback to call whenever the scanned codes change. + */ + onCodeScanned: (codes: Code[]) => void + /** + * Crops the scanner's view area to the specific region of interest. + */ + regionOfInterest?: { + x: number + y: number + width: number + height: number + } +} diff --git a/package/src/hooks/useCodeScanner.ts b/package/src/hooks/useCodeScanner.ts new file mode 100644 index 0000000..e397f63 --- /dev/null +++ b/package/src/hooks/useCodeScanner.ts @@ -0,0 +1,23 @@ +import { useCallback, useMemo, useRef } from 'react' +import { Code, CodeScanner } from '../CodeScanner' + +export function useCodeScanner(codeScanner: CodeScanner): CodeScanner { + const { onCodeScanned, ...codeScannerOptions } = codeScanner + + // Memoize the function once and use a ref on any identity changes + const ref = useRef(onCodeScanned) + ref.current = onCodeScanned + const callback = useCallback((codes: Code[]) => { + ref.current(codes) + }, []) + + // CodeScanner needs to be memoized so it doesn't trigger a Camera Session re-build + return useMemo( + () => ({ + ...codeScannerOptions, + onCodeScanned: callback, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [JSON.stringify(codeScannerOptions), callback], + ) +} diff --git a/package/src/hooks/useFrameProcessor.ts b/package/src/hooks/useFrameProcessor.ts index f5b6361..d6884f3 100644 --- a/package/src/hooks/useFrameProcessor.ts +++ b/package/src/hooks/useFrameProcessor.ts @@ -42,8 +42,8 @@ export function createFrameProcessor(frameProcessor: FrameProcessor['frameProces * ```ts * const frameProcessor = useFrameProcessor((frame) => { * 'worklet' - * const qrCodes = scanQRCodes(frame) - * console.log(`QR Codes: ${qrCodes}`) + * const faces = scanFaces(frame) + * console.log(`Faces: ${faces}`) * }, []) * ``` */ diff --git a/package/src/index.ts b/package/src/index.ts index 035a4a5..c497a26 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -9,6 +9,7 @@ export * from './PhotoFile' export * from './PixelFormat' export * from './Point' export * from './VideoFile' +export * from './CodeScanner' export * from './devices/getCameraFormat' export * from './devices/getCameraDevice' @@ -19,3 +20,4 @@ export * from './hooks/useCameraDevices' export * from './hooks/useCameraFormat' export * from './hooks/useCameraPermission' export * from './hooks/useFrameProcessor' +export * from './hooks/useCodeScanner'