diff --git a/bun.lockb b/bun.lockb index 72547bfba5..86ec8caa81 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/docs/guides/FOCUSING.mdx b/docs/docs/guides/FOCUSING.mdx index f523956344..04d761021c 100644 --- a/docs/docs/guides/FOCUSING.mdx +++ b/docs/docs/guides/FOCUSING.mdx @@ -27,7 +27,7 @@ This is an Example on how to use [react-native-gesture-handler](https://github.c ```tsx import { Camera, useCameraDevice } from 'react-native-vision-camera' import { Gesture, GestureDetector } from 'react-native-gesture-handler' -import { runOnJS } from 'react-native-reanimated'; +import { runOnJS } from 'react-native-reanimated' export function App() { const camera = useRef(null) @@ -39,25 +39,38 @@ export function App() { c.focus(point) }, []) - const gesture = Gesture.Tap() - .onEnd(({ x, y }) => { - runOnJS(focus)({ x, y }) - }) + const gesture = Gesture.Tap().onEnd(({ x, y }) => { + runOnJS(focus)({ x, y }) + }) if (device == null) return return ( - + ) } ``` +### Focus depth (focus on a fixed manual distance) + +You can programmatically set the distance of the focus, or the depth. + +```ts +await camera.current.focusDepth(0.5) +``` + +The value between Android and iOS is reversed: +| distance | Android | iOS | +|---------------|--------------------------------|-----| +| macro (close) | 10 | 0 | +| infinite | 0.2 | 1 | + +In practice, you will probably use these values to prevent any issues on **Android**: + +- macro: `device.minFocusDistance + 0.1` (0.1 prevents some weird focus changes) +- infinite: `Math.min(0.2, device.minFocusDistance)` (the lowest value for infinite focus, but setting it too low also produces focus artifacts) +
#### 🚀 Next section: [Orientation](orientation) diff --git a/package/android/src/main/java/com/mrousavy/camera/core/CameraSession+FocusDepth.kt b/package/android/src/main/java/com/mrousavy/camera/core/CameraSession+FocusDepth.kt new file mode 100644 index 0000000000..9d8a743987 --- /dev/null +++ b/package/android/src/main/java/com/mrousavy/camera/core/CameraSession+FocusDepth.kt @@ -0,0 +1,29 @@ +package com.mrousavy.camera.core + +import android.annotation.SuppressLint +import android.hardware.camera2.CameraMetadata +import android.hardware.camera2.CaptureRequest +import androidx.camera.camera2.interop.Camera2CameraControl +import androidx.camera.camera2.interop.CaptureRequestOptions +import androidx.camera.camera2.interop.ExperimentalCamera2Interop +import androidx.camera.core.CameraControl + +@ExperimentalCamera2Interop +@SuppressLint("RestrictedApi") +suspend fun CameraSession.focusDepth(depth: Double) { + val camera = camera ?: throw CameraNotReadyError() + + try { + Camera2CameraControl.from(camera.cameraControl).let { + CaptureRequestOptions.Builder().apply { + val distance = depth.toFloat() + setCaptureRequestOption(CaptureRequest.LENS_FOCUS_DISTANCE, distance) + setCaptureRequestOption(CaptureRequest.CONTROL_AF_MODE, CameraMetadata.CONTROL_AF_MODE_OFF) + }.let { builder -> + it.addCaptureRequestOptions(builder.build()) + } + } + } catch (e: CameraControl.OperationCanceledException) { + throw FocusCanceledError() + } +} diff --git a/package/android/src/main/java/com/mrousavy/camera/react/CameraView+FocusDepth.kt b/package/android/src/main/java/com/mrousavy/camera/react/CameraView+FocusDepth.kt new file mode 100644 index 0000000000..054de59eec --- /dev/null +++ b/package/android/src/main/java/com/mrousavy/camera/react/CameraView+FocusDepth.kt @@ -0,0 +1,9 @@ +package com.mrousavy.camera.react + +import androidx.camera.camera2.interop.ExperimentalCamera2Interop +import com.mrousavy.camera.core.focusDepth + +@ExperimentalCamera2Interop +suspend fun CameraView.focusDepth(distance: Double) { + cameraSession.focusDepth(distance) +} diff --git a/package/android/src/main/java/com/mrousavy/camera/react/CameraViewModule.kt b/package/android/src/main/java/com/mrousavy/camera/react/CameraViewModule.kt index 059f677a44..2851ba8a0d 100644 --- a/package/android/src/main/java/com/mrousavy/camera/react/CameraViewModule.kt +++ b/package/android/src/main/java/com/mrousavy/camera/react/CameraViewModule.kt @@ -3,6 +3,7 @@ package com.mrousavy.camera.react import android.Manifest import android.content.pm.PackageManager import android.util.Log +import androidx.camera.camera2.interop.ExperimentalCamera2Interop import androidx.core.content.ContextCompat import com.facebook.react.bridge.Callback import com.facebook.react.bridge.Promise @@ -188,6 +189,18 @@ class CameraViewModule(reactContext: ReactApplicationContext) : ReactContextBase } } + @ExperimentalCamera2Interop + @ReactMethod + fun focusDepth(viewTag: Int, distance: Double, promise: Promise) { + backgroundCoroutineScope.launch { + val view = findCameraView(viewTag) + withPromise(promise) { + view.focusDepth(distance) + return@withPromise null + } + } + } + private fun canRequestPermission(permission: String): Boolean { val activity = reactApplicationContext.currentActivity as? PermissionAwareActivity return activity?.shouldShowRequestPermissionRationale(permission) ?: false diff --git a/package/ios/Core/CameraConfiguration.swift b/package/ios/Core/CameraConfiguration.swift index a4b94dd377..58d6f6080b 100644 --- a/package/ios/Core/CameraConfiguration.swift +++ b/package/ios/Core/CameraConfiguration.swift @@ -153,7 +153,7 @@ final class CameraConfiguration { case disabled case enabled(config: T) - public static func == (lhs: OutputConfiguration, rhs: OutputConfiguration) -> Bool { + static func == (lhs: OutputConfiguration, rhs: OutputConfiguration) -> Bool { switch (lhs, rhs) { case (.disabled, .disabled): return true diff --git a/package/ios/Core/CameraError.swift b/package/ios/Core/CameraError.swift index 983568929c..f5f742c649 100644 --- a/package/ios/Core/CameraError.swift +++ b/package/ios/Core/CameraError.swift @@ -76,6 +76,7 @@ enum DeviceError { case microphoneUnavailable case lowLightBoostNotSupported case focusNotSupported + case focusDepthNotSupported case notAvailableOnSimulator case pixelFormatNotSupported(targetFormats: [FourCharCode], availableFormats: [FourCharCode]) @@ -95,6 +96,8 @@ enum DeviceError { return "low-light-boost-not-supported" case .focusNotSupported: return "focus-not-supported" + case .focusDepthNotSupported: + return "focus-depth-not-supported" case .notAvailableOnSimulator: return "camera-not-available-on-simulator" case .pixelFormatNotSupported: @@ -116,6 +119,8 @@ enum DeviceError { return "The currently selected camera device does not support low-light boost! Select a device where `device.supportsLowLightBoost` is true." case .focusNotSupported: return "The currently selected camera device does not support focusing!" + case .focusDepthNotSupported: + return "The currently selected camera device does not support manual depth-of-field focusing!" case .microphoneUnavailable: return "The microphone was unavailable." case .notAvailableOnSimulator: diff --git a/package/ios/Core/CameraSession+FocusDepth.swift b/package/ios/Core/CameraSession+FocusDepth.swift new file mode 100644 index 0000000000..9f86a0da81 --- /dev/null +++ b/package/ios/Core/CameraSession+FocusDepth.swift @@ -0,0 +1,37 @@ +// +// CameraSession+FocusDepth.swift +// VisionCamera +// +// Created by Hugo Gresse on 12.08.25. +// + +import AVFoundation +import Foundation + +extension CameraSession { + /** + Focuses the Camera to the specified distance. The distance must be within 0.001f and device.minFocusDistance + */ + func focusDepth(distance: Float) throws { + guard let device = videoDeviceInput?.device else { + throw CameraError.session(SessionError.cameraNotReady) + } + if !device.isLockingFocusWithCustomLensPositionSupported { + throw CameraError.device(DeviceError.focusDepthNotSupported) + } + + VisionLogger.log(level: .info, message: "Focusing depth (\(distance))...") + + do { + try device.lockForConfiguration() + defer { + device.unlockForConfiguration() + } + + // Set Focus depth + device.setFocusModeLocked(lensPosition: distance, completionHandler: nil) + } catch { + throw CameraError.device(DeviceError.configureError) + } + } +} diff --git a/package/ios/Core/CameraSession.swift b/package/ios/Core/CameraSession.swift index 10b0f3399c..03bffe87d3 100644 --- a/package/ios/Core/CameraSession.swift +++ b/package/ios/Core/CameraSession.swift @@ -265,7 +265,7 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat } } - public final func captureOutput(_ captureOutput: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { + final func captureOutput(_ captureOutput: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { switch captureOutput { case is AVCaptureVideoDataOutput: onVideoFrame(sampleBuffer: sampleBuffer, orientation: connection.orientation, isMirrored: connection.isVideoMirrored) diff --git a/package/ios/Core/Extensions/AVCaptureDevice+toDictionary.swift b/package/ios/Core/Extensions/AVCaptureDevice+toDictionary.swift index 4d8bc38fad..120f0decc7 100644 --- a/package/ios/Core/Extensions/AVCaptureDevice+toDictionary.swift +++ b/package/ios/Core/Extensions/AVCaptureDevice+toDictionary.swift @@ -29,6 +29,7 @@ extension AVCaptureDevice { "supportsRawCapture": false, // TODO: supportsRawCapture "supportsLowLightBoost": isLowLightBoostSupported, "supportsFocus": isFocusPointOfInterestSupported, + "supportsFocusDepth": isLockingFocusWithCustomLensPositionSupported, "hardwareLevel": HardwareLevel.full.jsValue, "sensorOrientation": sensorOrientation.jsValue, "formats": formats.map { $0.toJSValue() }, diff --git a/package/ios/Core/PreviewView.swift b/package/ios/Core/PreviewView.swift index 9c6ff25cce..81a8d7cba0 100644 --- a/package/ios/Core/PreviewView.swift +++ b/package/ios/Core/PreviewView.swift @@ -48,7 +48,7 @@ final class PreviewView: UIView { } } - override public static var layerClass: AnyClass { + override static var layerClass: AnyClass { return AVCaptureVideoPreviewLayer.self } diff --git a/package/ios/Core/Recording/Track.swift b/package/ios/Core/Recording/Track.swift index 8dec5ce89a..25c51dc346 100644 --- a/package/ios/Core/Recording/Track.swift +++ b/package/ios/Core/Recording/Track.swift @@ -51,7 +51,7 @@ final class Track { /** Returns the last timestamp that was actually written to the track. */ - public private(set) var lastTimestamp: CMTime? + private(set) var lastTimestamp: CMTime? /** Gets the natural size of the asset writer, or zero if it is not a visual track. diff --git a/package/ios/Core/Recording/TrackTimeline.swift b/package/ios/Core/Recording/TrackTimeline.swift index 345322fdd6..5e33e6af9e 100644 --- a/package/ios/Core/Recording/TrackTimeline.swift +++ b/package/ios/Core/Recording/TrackTimeline.swift @@ -25,22 +25,22 @@ final class TrackTimeline { Represents whether the timeline has been marked as finished or not. A timeline will automatically be marked as finished when a timestamp arrives that appears after a stop(). */ - public private(set) var isFinished = false + private(set) var isFinished = false /** Gets the latency of the buffers in this timeline. This is computed by (currentTime - mostRecentBuffer.timestamp) */ - public private(set) var latency: CMTime = .zero + private(set) var latency: CMTime = .zero /** Get the first actually written timestamp of this timeline */ - public private(set) var firstTimestamp: CMTime? + private(set) var firstTimestamp: CMTime? /** Get the last actually written timestamp of this timeline. */ - public private(set) var lastTimestamp: CMTime? + private(set) var lastTimestamp: CMTime? init(ofTrackType type: TrackType, withClock clock: CMClock) { trackType = type diff --git a/package/ios/React/CameraView+FocusDepth.swift b/package/ios/React/CameraView+FocusDepth.swift new file mode 100644 index 0000000000..fef1bfab25 --- /dev/null +++ b/package/ios/React/CameraView+FocusDepth.swift @@ -0,0 +1,18 @@ +// +// CameraView+FocusDepth.swift +// VisionCamera +// +// Created by Hugo Gresse on 12.08.25. +// + +import AVFoundation +import Foundation + +extension CameraView { + func focusDepth(distance: Float, promise: Promise) { + withPromise(promise) { + try cameraSession.focusDepth(distance: distance) + return nil + } + } +} diff --git a/package/ios/React/CameraViewManager.m b/package/ios/React/CameraViewManager.m index 527c9bc0fd..819088b91c 100644 --- a/package/ios/React/CameraViewManager.m +++ b/package/ios/React/CameraViewManager.m @@ -88,5 +88,7 @@ @interface RCT_EXTERN_REMAP_MODULE (CameraView, CameraViewManager, RCTViewManage resolve reject : (RCTPromiseRejectBlock)reject); RCT_EXTERN_METHOD(focus : (nonnull NSNumber*)node point : (NSDictionary*)point resolve : (RCTPromiseResolveBlock) resolve reject : (RCTPromiseRejectBlock)reject); +RCT_EXTERN_METHOD(focusDepth : (nonnull NSNumber*)node distance : (NSNumber*)distance resolve : (RCTPromiseResolveBlock) + resolve reject : (RCTPromiseRejectBlock)reject); @end diff --git a/package/ios/React/CameraViewManager.swift b/package/ios/React/CameraViewManager.swift index f7bd9e8d0e..5b345ba6e0 100644 --- a/package/ios/React/CameraViewManager.swift +++ b/package/ios/React/CameraViewManager.swift @@ -96,6 +96,13 @@ final class CameraViewManager: RCTViewManager { component.focus(point: CGPoint(x: x.doubleValue, y: y.doubleValue), promise: promise) } + @objc + final func focusDepth(_ node: NSNumber, distance: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + let promise = Promise(resolver: resolve, rejecter: reject) + let component = getCameraView(withTag: node) + component.focusDepth(distance: distance.floatValue, promise: promise) + } + @objc final func getCameraPermissionStatus() -> String { let status = AVCaptureDevice.authorizationStatus(for: .video) diff --git a/package/ios/React/Utils/Promise.swift b/package/ios/React/Utils/Promise.swift index d1a7a132a2..946ea5cce1 100644 --- a/package/ios/React/Utils/Promise.swift +++ b/package/ios/React/Utils/Promise.swift @@ -14,7 +14,7 @@ import Foundation * Represents a JavaScript Promise instance. `reject()` and `resolve()` should only be called once. */ class Promise { - public private(set) var didResolve = false + private(set) var didResolve = false init(resolver: @escaping RCTPromiseResolveBlock, rejecter: @escaping RCTPromiseRejectBlock) { self.resolver = resolver diff --git a/package/src/Camera.tsx b/package/src/Camera.tsx index afe056a761..6ae377e242 100644 --- a/package/src/Camera.tsx +++ b/package/src/Camera.tsx @@ -380,6 +380,27 @@ export class Camera extends React.PureComponent { throw tryParseNativeCameraError(e) } } + + /** + * Focus the camera to a specific distance. + * @param {number} distance The distance to focus to. It should be lower than the minFocusDistance. Lower the value (closer to 0.001f), further the distance, higher the value (closer to the minFocusDistance), more macro the focus will be. But reversed on iOS: 0.0 is macro, 1.0 is infinite. + * + * Make sure the value doesn't exceed the device.minFocusDistance. + * + * @throws {@linkcode CameraRuntimeError} When any kind of error occured while focussing. + * Use the {@linkcode CameraRuntimeError.code | code} property to get the actual error + * @example + * ```ts + * await camera.current.focusDepth(5) + * ``` + */ + public async focusDepth(distance: number): Promise { + try { + return await CameraModule.focusDepth(this.handle, distance) + } catch (e) { + throw tryParseNativeCameraError(e) + } + } //#endregion //#region Static Functions (NativeModule) diff --git a/package/src/CameraError.ts b/package/src/CameraError.ts index 01ad3f17bb..4fa825595f 100644 --- a/package/src/CameraError.ts +++ b/package/src/CameraError.ts @@ -12,6 +12,7 @@ export type DeviceError = | 'device/pixel-format-not-supported' | 'device/low-light-boost-not-supported' | 'device/focus-not-supported' + | 'device/focus-depth-not-supported' | 'device/camera-not-available-on-simulator' | 'device/camera-already-in-use' export type FormatError =