diff --git a/README.md b/README.md index 2b67be7112..3a89657d07 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 df5b0b5a74..c7f5bf6732 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" diff --git a/example/ios/CameraKitExample.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/CameraKitExample.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..0c67376eba --- /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 bd5438a9ed..80d37c0ed4 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 d200946a50..3d6c23a72c 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 614ac6de82..72ac82f3e8 100644 --- a/ios/ReactNativeCameraKit/CKCameraManager.m +++ b/ios/ReactNativeCameraKit/CKCameraManager.m @@ -17,6 +17,7 @@ @interface RCT_EXTERN_MODULE(CKCameraManager, RCTViewManager) 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 61bb285743..02b2603545 100644 --- a/ios/ReactNativeCameraKit/CKTypes+RCTConvert.m +++ b/ios/ReactNativeCameraKit/CKTypes+RCTConvert.m @@ -26,6 +26,12 @@ @implementation RCTConvert (CKTypes) @"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 bad0c4ea67..2fa7ece189 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 7de5f924e7..cb3a6f8591 100644 --- a/ios/ReactNativeCameraKit/CameraView.swift +++ b/ios/ReactNativeCameraKit/CameraView.swift @@ -37,6 +37,7 @@ public class CameraView: UIView { @objc public var resizeMode: ResizeMode = .contain @objc public var flashMode: FlashMode = .auto @objc public var torchMode: TorchMode = .off + @objc public var maxPhotoQualityPrioritization: MaxPhotoQualityPrioritization = .balanced // ratio overlay @objc public var ratioOverlay: String? @objc public var ratioOverlayColor: UIColor? @@ -184,6 +185,9 @@ public 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 3ee3d725ae..db32602a4d 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 bb57c60336..b2986ce3f6 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 6aaf995d0e..d5de515c9d 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/package.json b/package.json index 5ba3d6ca6a..a022ee52a8 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "publishConfig": { "registry": "https://registry.npmjs.org/" }, - "version": "14.0.0-beta15", + "version": "14.0.0", "description": "A high performance, fully featured, rock solid camera library for React Native applications", "nativePackage": true, "scripts": { diff --git a/src/CameraProps.ts b/src/CameraProps.ts index 531ad59612..7138ac8beb 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;