From b702b63334db5a1097d50e164dfbe4310c041829 Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Tue, 9 Jun 2026 13:10:23 +0200 Subject: [PATCH 1/2] feat: Add `enableResponsiveCapture` to Photo Output --- .../Outputs/HybridCameraPhotoOutput.swift | 6 +++++ .../android/c++/JPhotoOutputOptions.hpp | 6 ++++- .../nitro/camera/PhotoOutputOptions.kt | 9 ++++++-- .../ios/swift/PhotoOutputOptions.swift | 22 +++++++++++++++++-- .../shared/c++/PhotoOutputOptions.hpp | 6 ++++- .../src/hooks/usePhotoOutput.ts | 3 +++ .../specs/outputs/CameraPhotoOutput.nitro.ts | 10 +++++++++ 7 files changed, 56 insertions(+), 6 deletions(-) diff --git a/packages/react-native-vision-camera/ios/Hybrid Objects/Outputs/HybridCameraPhotoOutput.swift b/packages/react-native-vision-camera/ios/Hybrid Objects/Outputs/HybridCameraPhotoOutput.swift index 1f5a54cf18..2e580a7966 100644 --- a/packages/react-native-vision-camera/ios/Hybrid Objects/Outputs/HybridCameraPhotoOutput.swift +++ b/packages/react-native-vision-camera/ios/Hybrid Objects/Outputs/HybridCameraPhotoOutput.swift @@ -57,6 +57,12 @@ final class HybridCameraPhotoOutput: HybridCameraPhotoOutputSpec, NativeCameraOu output.maxPhotoQualityPrioritization = options.qualityPrioritization.toAVQualityPrioritization() + if #available(iOS 17.0, *), + output.isResponsiveCaptureSupported + { + output.isResponsiveCaptureEnabled = options.enableResponsiveCapture ?? false + } + if options.containerFormat == .dng { // If we capture RAW photos, try using Apple ProRAW. If not, Bayer14 RAW will be used. output.isAppleProRAWEnabled = output.isAppleProRAWSupported diff --git a/packages/react-native-vision-camera/nitrogen/generated/android/c++/JPhotoOutputOptions.hpp b/packages/react-native-vision-camera/nitrogen/generated/android/c++/JPhotoOutputOptions.hpp index b275a4960b..ad41e96106 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/android/c++/JPhotoOutputOptions.hpp +++ b/packages/react-native-vision-camera/nitrogen/generated/android/c++/JPhotoOutputOptions.hpp @@ -45,6 +45,8 @@ namespace margelo::nitro::camera { double quality = this->getFieldValue(fieldQuality); static const auto fieldQualityPrioritization = clazz->getField("qualityPrioritization"); jni::local_ref qualityPrioritization = this->getFieldValue(fieldQualityPrioritization); + static const auto fieldEnableResponsiveCapture = clazz->getField("enableResponsiveCapture"); + jni::local_ref enableResponsiveCapture = this->getFieldValue(fieldEnableResponsiveCapture); static const auto fieldPreviewImageTargetSize = clazz->getField("previewImageTargetSize"); jni::local_ref previewImageTargetSize = this->getFieldValue(fieldPreviewImageTargetSize); return PhotoOutputOptions( @@ -52,6 +54,7 @@ namespace margelo::nitro::camera { containerFormat->toCpp(), quality, qualityPrioritization->toCpp(), + enableResponsiveCapture != nullptr ? std::make_optional(static_cast(enableResponsiveCapture->value())) : std::nullopt, previewImageTargetSize != nullptr ? std::make_optional(previewImageTargetSize->toCpp()) : std::nullopt ); } @@ -62,7 +65,7 @@ namespace margelo::nitro::camera { */ [[maybe_unused]] static jni::local_ref fromCpp(const PhotoOutputOptions& value) { - using JSignature = JPhotoOutputOptions(jni::alias_ref, jni::alias_ref, double, jni::alias_ref, jni::alias_ref); + using JSignature = JPhotoOutputOptions(jni::alias_ref, jni::alias_ref, double, jni::alias_ref, jni::alias_ref, jni::alias_ref); static const auto clazz = javaClassStatic(); static const auto create = clazz->getStaticMethod("fromCpp"); return create( @@ -71,6 +74,7 @@ namespace margelo::nitro::camera { JTargetPhotoContainerFormat::fromCpp(value.containerFormat), value.quality, JQualityPrioritization::fromCpp(value.qualityPrioritization), + value.enableResponsiveCapture.has_value() ? jni::JBoolean::valueOf(value.enableResponsiveCapture.value()) : nullptr, value.previewImageTargetSize.has_value() ? JSize::fromCpp(value.previewImageTargetSize.value()) : nullptr ); } diff --git a/packages/react-native-vision-camera/nitrogen/generated/android/kotlin/com/margelo/nitro/camera/PhotoOutputOptions.kt b/packages/react-native-vision-camera/nitrogen/generated/android/kotlin/com/margelo/nitro/camera/PhotoOutputOptions.kt index 997bf4ed81..39671840c3 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/android/kotlin/com/margelo/nitro/camera/PhotoOutputOptions.kt +++ b/packages/react-native-vision-camera/nitrogen/generated/android/kotlin/com/margelo/nitro/camera/PhotoOutputOptions.kt @@ -32,6 +32,9 @@ data class PhotoOutputOptions( val qualityPrioritization: QualityPrioritization, @DoNotStrip @Keep + val enableResponsiveCapture: Boolean?, + @DoNotStrip + @Keep val previewImageTargetSize: Size? ) { /* primary constructor */ @@ -43,6 +46,7 @@ data class PhotoOutputOptions( && Objects.deepEquals(this.containerFormat, other.containerFormat) && Objects.deepEquals(this.quality, other.quality) && Objects.deepEquals(this.qualityPrioritization, other.qualityPrioritization) + && Objects.deepEquals(this.enableResponsiveCapture, other.enableResponsiveCapture) && Objects.deepEquals(this.previewImageTargetSize, other.previewImageTargetSize) } @@ -52,6 +56,7 @@ data class PhotoOutputOptions( containerFormat, quality, qualityPrioritization, + enableResponsiveCapture, previewImageTargetSize ).contentDeepHashCode() } @@ -64,8 +69,8 @@ data class PhotoOutputOptions( @Keep @Suppress("unused") @JvmStatic - private fun fromCpp(targetResolution: Size, containerFormat: TargetPhotoContainerFormat, quality: Double, qualityPrioritization: QualityPrioritization, previewImageTargetSize: Size?): PhotoOutputOptions { - return PhotoOutputOptions(targetResolution, containerFormat, quality, qualityPrioritization, previewImageTargetSize) + private fun fromCpp(targetResolution: Size, containerFormat: TargetPhotoContainerFormat, quality: Double, qualityPrioritization: QualityPrioritization, enableResponsiveCapture: Boolean?, previewImageTargetSize: Size?): PhotoOutputOptions { + return PhotoOutputOptions(targetResolution, containerFormat, quality, qualityPrioritization, enableResponsiveCapture, previewImageTargetSize) } } } diff --git a/packages/react-native-vision-camera/nitrogen/generated/ios/swift/PhotoOutputOptions.swift b/packages/react-native-vision-camera/nitrogen/generated/ios/swift/PhotoOutputOptions.swift index 3033915fe6..727686a3d5 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/ios/swift/PhotoOutputOptions.swift +++ b/packages/react-native-vision-camera/nitrogen/generated/ios/swift/PhotoOutputOptions.swift @@ -18,8 +18,14 @@ public extension PhotoOutputOptions { /** * Create a new instance of `PhotoOutputOptions`. */ - init(targetResolution: Size, containerFormat: TargetPhotoContainerFormat, quality: Double, qualityPrioritization: QualityPrioritization, previewImageTargetSize: Size?) { - self.init(targetResolution, containerFormat, quality, qualityPrioritization, { () -> bridge.std__optional_Size_ in + init(targetResolution: Size, containerFormat: TargetPhotoContainerFormat, quality: Double, qualityPrioritization: QualityPrioritization, enableResponsiveCapture: Bool?, previewImageTargetSize: Size?) { + self.init(targetResolution, containerFormat, quality, qualityPrioritization, { () -> bridge.std__optional_bool_ in + if let __unwrappedValue = enableResponsiveCapture { + return bridge.create_std__optional_bool_(__unwrappedValue) + } else { + return .init() + } + }(), { () -> bridge.std__optional_Size_ in if let __unwrappedValue = previewImageTargetSize { return bridge.create_std__optional_Size_(__unwrappedValue) } else { @@ -48,6 +54,18 @@ public extension PhotoOutputOptions { return self.__qualityPrioritization } + @inline(__always) + var enableResponsiveCapture: Bool? { + return { () -> Bool? in + if bridge.has_value_std__optional_bool_(self.__enableResponsiveCapture) { + let __unwrapped = bridge.get_std__optional_bool_(self.__enableResponsiveCapture) + return __unwrapped + } else { + return nil + } + }() + } + @inline(__always) var previewImageTargetSize: Size? { return self.__previewImageTargetSize.value diff --git a/packages/react-native-vision-camera/nitrogen/generated/shared/c++/PhotoOutputOptions.hpp b/packages/react-native-vision-camera/nitrogen/generated/shared/c++/PhotoOutputOptions.hpp index 3586426c16..78e1bebce7 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/shared/c++/PhotoOutputOptions.hpp +++ b/packages/react-native-vision-camera/nitrogen/generated/shared/c++/PhotoOutputOptions.hpp @@ -51,11 +51,12 @@ namespace margelo::nitro::camera { TargetPhotoContainerFormat containerFormat SWIFT_PRIVATE; double quality SWIFT_PRIVATE; QualityPrioritization qualityPrioritization SWIFT_PRIVATE; + std::optional enableResponsiveCapture SWIFT_PRIVATE; std::optional previewImageTargetSize SWIFT_PRIVATE; public: PhotoOutputOptions() = default; - explicit PhotoOutputOptions(Size targetResolution, TargetPhotoContainerFormat containerFormat, double quality, QualityPrioritization qualityPrioritization, std::optional previewImageTargetSize): targetResolution(targetResolution), containerFormat(containerFormat), quality(quality), qualityPrioritization(qualityPrioritization), previewImageTargetSize(previewImageTargetSize) {} + explicit PhotoOutputOptions(Size targetResolution, TargetPhotoContainerFormat containerFormat, double quality, QualityPrioritization qualityPrioritization, std::optional enableResponsiveCapture, std::optional previewImageTargetSize): targetResolution(targetResolution), containerFormat(containerFormat), quality(quality), qualityPrioritization(qualityPrioritization), enableResponsiveCapture(enableResponsiveCapture), previewImageTargetSize(previewImageTargetSize) {} public: friend bool operator==(const PhotoOutputOptions& lhs, const PhotoOutputOptions& rhs) = default; @@ -75,6 +76,7 @@ namespace margelo::nitro { JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "containerFormat"))), JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "quality"))), JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "qualityPrioritization"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "enableResponsiveCapture"))), JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "previewImageTargetSize"))) ); } @@ -84,6 +86,7 @@ namespace margelo::nitro { obj.setProperty(runtime, PropNameIDCache::get(runtime, "containerFormat"), JSIConverter::toJSI(runtime, arg.containerFormat)); obj.setProperty(runtime, PropNameIDCache::get(runtime, "quality"), JSIConverter::toJSI(runtime, arg.quality)); obj.setProperty(runtime, PropNameIDCache::get(runtime, "qualityPrioritization"), JSIConverter::toJSI(runtime, arg.qualityPrioritization)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "enableResponsiveCapture"), JSIConverter>::toJSI(runtime, arg.enableResponsiveCapture)); obj.setProperty(runtime, PropNameIDCache::get(runtime, "previewImageTargetSize"), JSIConverter>::toJSI(runtime, arg.previewImageTargetSize)); return obj; } @@ -99,6 +102,7 @@ namespace margelo::nitro { if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "containerFormat")))) return false; if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "quality")))) return false; if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "qualityPrioritization")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "enableResponsiveCapture")))) return false; if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "previewImageTargetSize")))) return false; return true; } diff --git a/packages/react-native-vision-camera/src/hooks/usePhotoOutput.ts b/packages/react-native-vision-camera/src/hooks/usePhotoOutput.ts index f8b5054602..9c1c7b421d 100644 --- a/packages/react-native-vision-camera/src/hooks/usePhotoOutput.ts +++ b/packages/react-native-vision-camera/src/hooks/usePhotoOutput.ts @@ -33,6 +33,7 @@ export function usePhotoOutput({ containerFormat = 'native', quality = 0.9, qualityPrioritization = 'balanced', + enableResponsiveCapture = false, previewImageTargetSize = undefined, }: Partial = {}): CameraPhotoOutput { // 1. Create photo output @@ -43,6 +44,7 @@ export function usePhotoOutput({ containerFormat: containerFormat, quality: quality, qualityPrioritization: qualityPrioritization, + enableResponsiveCapture: enableResponsiveCapture, previewImageTargetSize: previewImageTargetSize, }), [ @@ -50,6 +52,7 @@ export function usePhotoOutput({ containerFormat, quality, qualityPrioritization, + enableResponsiveCapture, previewImageTargetSize, ], ) diff --git a/packages/react-native-vision-camera/src/specs/outputs/CameraPhotoOutput.nitro.ts b/packages/react-native-vision-camera/src/specs/outputs/CameraPhotoOutput.nitro.ts index 07297a68e3..4af7e1c126 100644 --- a/packages/react-native-vision-camera/src/specs/outputs/CameraPhotoOutput.nitro.ts +++ b/packages/react-native-vision-camera/src/specs/outputs/CameraPhotoOutput.nitro.ts @@ -66,6 +66,16 @@ export interface PhotoOutputOptions { * @see {@linkcode CameraDevice.supportsSpeedQualityPrioritization} */ qualityPrioritization: QualityPrioritization + /** + * Enables or disables responsive capture. + * + * Responsive capture can reduce shutter lag and improve capture + * rates, at the cost of additional memory usage. + * + * @platform iOS + * @default false + */ + enableResponsiveCapture?: boolean /** * When this is set to a specific {@linkcode Size}, * a ready to display {@linkcode Image} will be delivered From 53f5cc1e6bcc27e2927137b12683230b41fafbb5 Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Tue, 9 Jun 2026 13:11:54 +0200 Subject: [PATCH 2/2] test: Add parallel Photo Capture test --- .../__tests__/visioncamera.photo.harness.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/apps/simple-camera/__tests__/visioncamera.photo.harness.ts b/apps/simple-camera/__tests__/visioncamera.photo.harness.ts index db9b24b773..b29d281957 100644 --- a/apps/simple-camera/__tests__/visioncamera.photo.harness.ts +++ b/apps/simple-camera/__tests__/visioncamera.photo.harness.ts @@ -67,6 +67,58 @@ describe('VisionCamera - Photo', () => { await session.stop() }) + it('captures three Photos in parallel with responsive capture enabled', async () => { + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + enableResponsiveCapture: true, + }) + await session.configure([ + { + input: backDevice, + outputs: [{ output: photoOutput, mirrorMode: 'auto' }], + constraints: [], + }, + ]) + await session.start() + + try { + const captureResults = await Promise.allSettled( + Array.from({ length: 3 }, () => + photoOutput.capturePhoto( + { flashMode: 'off', enableShutterSound: false }, + {}, + ), + ), + ) + + const capturedPhotos = captureResults.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [], + ) + try { + expect(captureResults).toHaveLength(3) + for (const result of captureResults) { + expect(result.status).toBe('fulfilled') + if (result.status === 'rejected') { + throw result.reason + } + expect(result.value.width).toBeGreaterThan(0) + expect(result.value.height).toBeGreaterThan(0) + expect(result.value.containerFormat).toBe('jpeg') + } + } finally { + for (const photo of capturedPhotos) { + photo.dispose() + } + } + } finally { + await session.stop() + } + }) + it('checks and reads a native Photo pixel buffer in-memory', async (context) => { const session = await VisionCamera.createCameraSession(false) const photoOutput = VisionCamera.createPhotoOutput({