From ace123eefa44d063c9b2c46ead8a0fea939b4141 Mon Sep 17 00:00:00 2001 From: Seph Soliman Date: Thu, 14 Nov 2024 13:32:23 -0800 Subject: [PATCH] Added maxPhotoQualityPrioritization for iOS Allows a massive capture speed-up of 60-80% --- README.md | 1 + ReactNativeCameraKit.podspec | 4 +- .../xcshareddata/WorkspaceSettings.xcsettings | 5 ++ example/ios/CameraKitExample/Info.plist | 3 +- example/src/CameraExample.tsx | 60 ++++++++++++------- ios/ReactNativeCameraKit/CKCameraManager.m | 1 + ios/ReactNativeCameraKit/CKTypes+RCTConvert.m | 6 ++ ios/ReactNativeCameraKit/CameraProtocol.swift | 1 + ios/ReactNativeCameraKit/CameraView.swift | 4 ++ ios/ReactNativeCameraKit/RealCamera.swift | 25 +++++++- .../SimulatorCamera.swift | 3 + ios/ReactNativeCameraKit/Types.swift | 23 +++++++ src/CameraProps.ts | 4 +- 13 files changed, 112 insertions(+), 28 deletions(-) create mode 100644 example/ios/CameraKitExample.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/README.md b/README.md index 2b67be7..3a89657 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,7 @@ Additionally, the Camera can be used for barcode scanning | `resetFocusWhenMotionDetected` | Boolean | Dismiss tap to focus when focus area content changes. Native iOS feature, see documentation: https://developer.apple.com/documentation/avfoundation/avcapturedevice/1624644-subjectareachangemonitoringenabl?language=objc). Default `true`. | | `resizeMode` | `'cover' / 'contain'` | Determines the scaling and cropping behavior of content within the view. `cover` (resizeAspectFill on iOS) scales the content to fill the view completely, potentially cropping content if its aspect ratio differs from the view. `contain` (resizeAspect on iOS) scales the content to fit within the view's bounds without cropping, ensuring all content is visible but may introduce letterboxing. Default behavior depends on the specific use case. | | `scanThrottleDelay` | `number` | Duration between scan detection in milliseconds. Default 2000 (2s) | +| `maxPhotoQualityPrioritization` | `'balanced'` / `'quality'` / `'speed'` | [iOS 13 and newer](https://developer.apple.com/documentation/avfoundation/avcapturephotooutput/3182995-maxphotoqualityprioritization). `'speed'` provides a 60-80% median capture time reduction vs 'quality' setting. Tested on iPhone 6S Max (66% faster) and iPhone 15 Pro Max (76% faster!). Default `balanced` | | `onCaptureButtonPressIn` | Function | Callback when iPhone capture button is pressed in. Ex: `onCaptureButtonPressIn={() => console.log("volume button pressed in")}` | | `onCaptureButtonPressOut` | Function | Callback when iPhone capture button is released. Ex: `onCaptureButtonPressOut={() => console.log("volume button released")}` | | **Barcode only** | diff --git a/ReactNativeCameraKit.podspec b/ReactNativeCameraKit.podspec index d96e18c..5c2565b 100644 --- a/ReactNativeCameraKit.podspec +++ b/ReactNativeCameraKit.podspec @@ -5,7 +5,7 @@ package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) Pod::Spec.new do |s| s.name = "ReactNativeCameraKit" s.version = package["version"] - s.summary = "Advanced native camera and gallery controls and device photos API" + s.summary = "A high performance, easy to use camera API" s.license = "MIT" s.authors = "CameraKit" @@ -13,7 +13,7 @@ Pod::Spec.new do |s| s.platform = :ios, "11.0" s.source = { :git => "https://github.com/teslamotors/react-native-camera-kit.git", :tag => "v#{s.version}" } - s.source_files = "ios/**/*.{h,m,swift}" + s.source_files = "ios/**/*.{h,m,mm,swift}" s.dependency 'React-Core' end diff --git a/example/ios/CameraKitExample.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/CameraKitExample.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/example/ios/CameraKitExample.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/example/ios/CameraKitExample/Info.plist b/example/ios/CameraKitExample/Info.plist index bd5438a..80d37c0 100644 --- a/example/ios/CameraKitExample/Info.plist +++ b/example/ios/CameraKitExample/Info.plist @@ -26,7 +26,6 @@ NSAppTransportSecurity - NSAllowsArbitraryLoads NSAllowsLocalNetworking @@ -50,6 +49,8 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UIFileSharingEnabled + UIViewControllerBasedStatusBarAppearance diff --git a/example/src/CameraExample.tsx b/example/src/CameraExample.tsx index d200946..3d6c23a 100644 --- a/example/src/CameraExample.tsx +++ b/example/src/CameraExample.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef } from 'react'; -import { StyleSheet, Text, View, TouchableOpacity, Image, SafeAreaView, Animated, StatusBar } from 'react-native'; +import { StyleSheet, Text, View, TouchableOpacity, Image, SafeAreaView, Animated, StatusBar, ScrollView } from 'react-native'; import Camera from '../../src/Camera'; import { CameraApi, CameraType, CaptureData } from '../../src/types'; import { Orientation } from '../../src'; @@ -25,6 +25,12 @@ const flashArray = [ }, ] as const; +function median(values: number[]): number { + values = [...values].sort((a, b) => a - b); + const half = Math.floor(values.length / 2); + return values.length % 2 ? values[half] : (values[half - 1] + values[half]) / 2; +} + const CameraExample = ({ onBack }: { onBack: () => void }) => { const cameraRef = useRef(null); const [currentFlashArrayPosition, setCurrentFlashArrayPosition] = useState(0); @@ -79,25 +85,31 @@ const CameraExample = ({ onBack }: { onBack: () => void }) => { }; const onCaptureImagePressed = async () => { - if (showImageUri) { - setShowImageUri(''); - return; - } - if (!cameraRef.current || isCapturing.current) return; - let image: CaptureData | undefined; - try { - isCapturing.current = true; - image = await cameraRef.current.capture(); - } catch (e) { - console.log('error', e); - } finally { - isCapturing.current = false; - } - if (!image) return; + const times: number[] = []; + for (let i = 1; i <= 5; i++) { + const start = Date.now(); + if (showImageUri) { + setShowImageUri(''); + return; + } + if (!cameraRef.current || isCapturing.current) return; + let image: CaptureData | undefined; + try { + isCapturing.current = true; + image = await cameraRef.current.capture(); + } catch (e) { + console.log('error', e); + } finally { + isCapturing.current = false; + } + if (!image) return; - setCaptured(true); - setCaptureImages([...captureImages, image]); - console.log('image', image); + setCaptured(true); + setCaptureImages(prev => [...prev, image]); + console.log('image', image); + times.push(Date.now() - start); + } + console.log(`median capture time: ${median(times)}ms`); }; function CaptureButton({ onPress, children }: { onPress: () => void; children?: React.ReactNode }) { @@ -202,7 +214,12 @@ const CameraExample = ({ onBack }: { onBack: () => void }) => { {showImageUri ? ( - + + + ) : ( void }) => { }} torchMode={torchMode ? 'on' : 'off'} shutterPhotoSound + maxPhotoQualityPrioritization="quality" onCaptureButtonPressIn={() => { console.log('capture button pressed in'); }} @@ -322,8 +340,8 @@ const styles = StyleSheet.create({ flex: 1, }, cameraPreview: { - flex: 1, width: '100%', + height: '100%', }, bottomButtons: { margin: 10, diff --git a/ios/ReactNativeCameraKit/CKCameraManager.m b/ios/ReactNativeCameraKit/CKCameraManager.m index 28584bf..82f39b1 100644 --- a/ios/ReactNativeCameraKit/CKCameraManager.m +++ b/ios/ReactNativeCameraKit/CKCameraManager.m @@ -17,6 +17,7 @@ RCT_EXPORT_VIEW_PROPERTY(cameraType, CKCameraType) RCT_EXPORT_VIEW_PROPERTY(flashMode, CKFlashMode) +RCT_EXPORT_VIEW_PROPERTY(maxPhotoQualityPrioritization, CKMaxPhotoQualityPrioritization) RCT_EXPORT_VIEW_PROPERTY(torchMode, CKTorchMode) RCT_EXPORT_VIEW_PROPERTY(ratioOverlay, NSString) RCT_EXPORT_VIEW_PROPERTY(ratioOverlayColor, UIColor) diff --git a/ios/ReactNativeCameraKit/CKTypes+RCTConvert.m b/ios/ReactNativeCameraKit/CKTypes+RCTConvert.m index bcd89bc..47e4e58 100644 --- a/ios/ReactNativeCameraKit/CKTypes+RCTConvert.m +++ b/ios/ReactNativeCameraKit/CKTypes+RCTConvert.m @@ -26,6 +26,12 @@ RCT_ENUM_CONVERTER(CKFlashMode, (@{ @"auto": @(CKFlashModeAuto) }), CKFlashModeAuto, integerValue) +RCT_ENUM_CONVERTER(CKMaxPhotoQualityPrioritization, (@{ + @"balanced": @(CKMaxPhotoQualityPrioritizationBalanced), + @"quality": @(CKMaxPhotoQualityPrioritizationQuality), + @"speed": @(CKMaxPhotoQualityPrioritizationSpeed) +}), CKMaxPhotoQualityPrioritizationBalanced, integerValue) + RCT_ENUM_CONVERTER(CKTorchMode, (@{ @"on": @(CKTorchModeOn), @"off": @(CKTorchModeOff) diff --git a/ios/ReactNativeCameraKit/CameraProtocol.swift b/ios/ReactNativeCameraKit/CameraProtocol.swift index bad0c4e..2fa7ece 100644 --- a/ios/ReactNativeCameraKit/CameraProtocol.swift +++ b/ios/ReactNativeCameraKit/CameraProtocol.swift @@ -19,6 +19,7 @@ protocol CameraProtocol: AnyObject, FocusInterfaceViewDelegate { func update(zoom: Double?) func update(maxZoom: Double?) func update(resizeMode: ResizeMode) + func update(maxPhotoQualityPrioritization: MaxPhotoQualityPrioritization?) func zoomPinchStart() func zoomPinchChange(pinchScale: CGFloat) diff --git a/ios/ReactNativeCameraKit/CameraView.swift b/ios/ReactNativeCameraKit/CameraView.swift index 7b43bad..cab80ea 100644 --- a/ios/ReactNativeCameraKit/CameraView.swift +++ b/ios/ReactNativeCameraKit/CameraView.swift @@ -37,6 +37,7 @@ class CameraView: UIView { @objc var resizeMode: ResizeMode = .contain @objc var flashMode: FlashMode = .auto @objc var torchMode: TorchMode = .off + @objc var maxPhotoQualityPrioritization: MaxPhotoQualityPrioritization = .balanced // ratio overlay @objc var ratioOverlay: String? @objc var ratioOverlayColor: UIColor? @@ -183,6 +184,9 @@ class CameraView: UIView { if changedProps.contains("cameraType") || changedProps.contains("torchMode") { camera.update(torchMode: torchMode) } + if changedProps.contains("maxPhotoQualityPrioritization") { + camera.update(maxPhotoQualityPrioritization: maxPhotoQualityPrioritization) + } if changedProps.contains("onOrientationChange") { camera.update(onOrientationChange: onOrientationChange) diff --git a/ios/ReactNativeCameraKit/RealCamera.swift b/ios/ReactNativeCameraKit/RealCamera.swift index 3ee3d72..db32602 100644 --- a/ios/ReactNativeCameraKit/RealCamera.swift +++ b/ios/ReactNativeCameraKit/RealCamera.swift @@ -33,6 +33,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega private var resizeMode: ResizeMode = .contain private var flashMode: FlashMode = .auto private var torchMode: TorchMode = .off + private var maxPhotoQualityPrioritization: MaxPhotoQualityPrioritization? private var resetFocus: (() -> Void)? private var focusFinished: (() -> Void)? private var onBarcodeRead: ((_ barcode: String,_ codeFormat : CodeFormat) -> Void)? @@ -258,6 +259,16 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega func update(flashMode: FlashMode) { self.flashMode = flashMode } + + func update(maxPhotoQualityPrioritization: MaxPhotoQualityPrioritization?) { + guard maxPhotoQualityPrioritization != self.maxPhotoQualityPrioritization else { return } + if #available(iOS 13.0, *) { + self.session.beginConfiguration() + self.maxPhotoQualityPrioritization = maxPhotoQualityPrioritization + self.photoOutput.maxPhotoQualityPrioritization = maxPhotoQualityPrioritization?.avQualityPrioritization ?? .balanced + self.session.commitConfiguration() + } + } func update(cameraType: CameraType) { sessionQueue.async { @@ -325,7 +336,9 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega } let settings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg]) - settings.isAutoStillImageStabilizationEnabled = true + if #available(iOS 13.0, *) { + settings.photoQualityPrioritization = self.photoOutput.maxPhotoQualityPrioritization + } if self.videoDeviceInput?.device.isFlashAvailable == true { settings.flashMode = self.flashMode.avFlashMode @@ -477,7 +490,13 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega session.beginConfiguration() session.sessionPreset = .photo - + + if #available(iOS 13.0, *) { + if let maxPhotoQualityPrioritization { + photoOutput.maxPhotoQualityPrioritization = maxPhotoQualityPrioritization.avQualityPrioritization + } + } + if session.canAddInput(videoDeviceInput) { session.addInput(videoDeviceInput) @@ -510,7 +529,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega metadataOutput.metadataObjectTypes = filteredTypes } - + session.commitConfiguration() return .success diff --git a/ios/ReactNativeCameraKit/SimulatorCamera.swift b/ios/ReactNativeCameraKit/SimulatorCamera.swift index bb57c60..b2986ce 100644 --- a/ios/ReactNativeCameraKit/SimulatorCamera.swift +++ b/ios/ReactNativeCameraKit/SimulatorCamera.swift @@ -118,6 +118,9 @@ class SimulatorCamera: CameraProtocol { self.mockPreview.torchModeLabel.text = "Torch mode: \(torchMode)" } } + + func update(maxPhotoQualityPrioritization: MaxPhotoQualityPrioritization?) { + } func update(flashMode: FlashMode) { DispatchQueue.main.async { diff --git a/ios/ReactNativeCameraKit/Types.swift b/ios/ReactNativeCameraKit/Types.swift index 6aaf995..d5de515 100644 --- a/ios/ReactNativeCameraKit/Types.swift +++ b/ios/ReactNativeCameraKit/Types.swift @@ -52,6 +52,29 @@ public enum FlashMode: Int, CustomStringConvertible { } } +@objc(CKMaxPhotoQualityPrioritization) +public enum MaxPhotoQualityPrioritization: Int, CustomStringConvertible { + case speed + case balanced + case quality + + var avQualityPrioritization: AVCapturePhotoOutput.QualityPrioritization { + switch self { + case .speed: return .speed + case .balanced: return .balanced + case .quality: return .quality + } + } + + public var description: String { + switch self { + case .speed: return "speed" + case .balanced: return "balanced" + case .quality: return "quality" + } + } +} + @objc(CKTorchMode) public enum TorchMode: Int, CustomStringConvertible { case on diff --git a/src/CameraProps.ts b/src/CameraProps.ts index 531ad59..7138ac8 100644 --- a/src/CameraProps.ts +++ b/src/CameraProps.ts @@ -19,7 +19,7 @@ export type OnReadCodeData = { export type OnOrientationChangeData = { nativeEvent: { - orientation: typeof Orientation; + orientation: typeof Orientation[keyof typeof Orientation]; }; }; @@ -106,6 +106,8 @@ export interface CameraProps extends ViewProps { resizeMode?: ResizeMode; /** **iOS Only**. Throttle how often the barcode scanner triggers a new scan */ scanThrottleDelay?: number; + /** **iOS Only**. 'speed' provides 60-80% faster image capturing */ + maxPhotoQualityPrioritization?: 'balanced' | 'quality' | 'speed'; /** **Android only**. Play a shutter capture sound when capturing a photo */ shutterPhotoSound?: boolean; onCaptureButtonPressIn?: ({ nativeEvent: {} }) => void;