@momo-kits/camerakit 0.160.1-beta.8 → 0.161.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -84,10 +84,10 @@ static id CKConvertFollyDynamicToId(const folly::dynamic &dyn)
84
84
  - (void)prepareView
85
85
  {
86
86
  _view = [[CKCameraView alloc] init];
87
-
87
+
88
88
  // just need to pass something, it won't really be used on fabric, but it's used to create events (it won't impact sending them)
89
89
  _view.reactTag = @-1;
90
-
90
+
91
91
  __weak __typeof__(self) weakSelf = self;
92
92
 
93
93
  [_view setOnReadCode:^(NSDictionary* event) {
@@ -137,7 +137,7 @@ static id CKConvertFollyDynamicToId(const folly::dynamic &dyn)
137
137
  std::dynamic_pointer_cast<const facebook::react::CKCameraEventEmitter>(strongSelf->_eventEmitter)->onMRZ({.docMRZ = docMRZ});
138
138
  }
139
139
  }];
140
-
140
+
141
141
  self.contentView = _view;
142
142
  }
143
143
 
@@ -251,8 +251,8 @@ static id CKConvertFollyDynamicToId(const folly::dynamic &dyn)
251
251
  _view.barcodeFrameSize = @{@"width": @(barcodeWidth), @"height": @(barcodeHeight)};
252
252
  [changedProps addObject:@"barcodeFrameSize"];
253
253
  }
254
-
255
-
254
+
255
+
256
256
  [super updateProps:props oldProps:oldProps];
257
257
  [_view didSetProps:changedProps];
258
258
  }
@@ -260,6 +260,12 @@ static id CKConvertFollyDynamicToId(const folly::dynamic &dyn)
260
260
  - (void)prepareForRecycle
261
261
  {
262
262
  [super prepareForRecycle];
263
+
264
+ if (_view != nil) {
265
+ [_view removeFromSuperview];
266
+ _view = nil;
267
+ }
268
+
263
269
  [self prepareView];
264
270
  }
265
271
 
@@ -71,22 +71,16 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
71
71
  // MARK: - Lifecycle
72
72
 
73
73
  func cameraRemovedFromSuperview() {
74
- sessionQueue.async {
75
- if self.setupResult == .success {
76
- // Only stop if session is running
77
- guard self.session.isRunning else {
78
- self.removeObservers()
79
- return
80
- }
74
+ sessionQueue.async { [weak self] in
75
+ guard let self, self.setupResult == .success else { return }
81
76
 
82
- // If we're currently configuring the session, mark pending stop
83
- // and let the configuration completion handle it
84
- if self.configurationDepth > 0 {
85
- self.pendingStop = true
86
- return
87
- }
77
+ // Stop now if idle/safe, or defer the stop until the open configuration
78
+ // transaction commits (stopSessionIfNeeded sets pendingStop in that case).
79
+ self.stopSessionIfNeeded()
88
80
 
89
- self.session.stopRunning()
81
+ // If the stop was deferred, handlePendingStopIfNeeded() will remove the
82
+ // observers once the transaction commits; otherwise remove them now.
83
+ if !self.pendingStop {
90
84
  self.removeObservers()
91
85
  }
92
86
  }
@@ -102,96 +96,117 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
102
96
 
103
97
  deinit {
104
98
  removeObservers()
99
+
100
+ // Ensure the capture hardware is released even if cameraRemovedFromSuperview()
101
+ // was never delivered (e.g. a forced React surface cleanup during rapid
102
+ // mount/unmount). Capture only the session object so the block does not
103
+ // resurrect `self`.
104
+ let session = self.session
105
+ sessionQueue.async {
106
+ if session.isRunning {
107
+ session.stopRunning()
108
+ }
109
+ }
105
110
  }
106
111
 
107
112
  // MARK: - Public
108
113
 
109
114
  func setup(cameraType: CameraType, supportedBarcodeType: [CodeFormat]) {
110
-
111
- // Setup the capture session with priority on basic video preview first
112
- sessionQueue.async {
113
- self.setupResult = self.setupBasicVideoInput(cameraType: cameraType)
114
-
115
- if self.setupResult == .success {
116
- self.session.startRunning()
117
-
118
- self.setupAdditionalOutputs(supportedBarcodeType: supportedBarcodeType)
119
-
120
- self.addObservers()
121
-
122
- self.update(torchMode: self.torchMode)
123
- }
115
+ sessionQueue.async { [weak self] in
116
+ guard let self else { return }
117
+
118
+ // Idempotency guard: a RealCamera configures its session exactly once.
119
+ // A fresh instance is created on every mount / Fabric recycle, so this
120
+ // never blocks a legitimate restart — resuming after stopCamera() is
121
+ // handled by startCamera() -> startSessionIfNeeded().
122
+ guard self.setupResult == .notStarted else { return }
123
+
124
+ // Configure the WHOLE session (inputs, preset, outputs) inside a single
125
+ // begin/commit transaction on the session queue. The preset is set here
126
+ // and never on the main queue, so the startRunning() below can no longer
127
+ // observe the session mid-configuration from another thread.
128
+ //
129
+ // This is the root-cause fix for:
130
+ // "[AVCaptureSession startRunning] startRunning may not be called between
131
+ // calls to beginConfiguration and commitConfiguration".
132
+ self.setupResult = self.configureSession(cameraType: cameraType,
133
+ supportedBarcodeType: supportedBarcodeType)
134
+
135
+ guard self.setupResult == .success else { return }
136
+
137
+ // Order matches the previous behaviour: start, then observe, then torch.
138
+ self.startSessionIfNeeded()
139
+ self.addObservers()
140
+ self.update(torchMode: self.torchMode)
124
141
 
125
- DispatchQueue.main.async {
142
+ // Only the preview LAYER is touched on the main queue. Connecting an
143
+ // AVCaptureVideoPreviewLayer to a session from the main thread is
144
+ // sanctioned by AVFoundation (see Apple's AVCam) and does NOT open a
145
+ // session configuration transaction, so it cannot race startRunning().
146
+ DispatchQueue.main.async { [weak self] in
147
+ guard let self else { return }
126
148
  self.cameraPreview.session = self.session
127
149
  self.cameraPreview.previewLayer.videoGravity = .resizeAspectFill
128
- self.session.sessionPreset = .photo
129
150
  self.setVideoOrientationToInterfaceOrientation()
130
151
  }
131
152
  }
132
-
133
- DispatchQueue.global(qos: .utility).async {
134
- self.initializeMotionManager()
135
- }
136
153
 
154
+ DispatchQueue.global(qos: .utility).async { [weak self] in
155
+ self?.initializeMotionManager()
156
+ }
137
157
  }
138
158
 
139
159
  // MARK: - Private optimization methods
140
-
141
- private func setupBasicVideoInput(cameraType: CameraType) -> SetupResult {
160
+
161
+ /// Configures the video input, session preset and every output in ONE atomic
162
+ /// transaction. Must be called on `sessionQueue`. Order is significant:
163
+ /// beginConfiguration -> addInput -> set preset -> addOutputs -> commitConfiguration.
164
+ private func configureSession(cameraType: CameraType, supportedBarcodeType: [CodeFormat]) -> SetupResult {
165
+ assertOnSessionQueue()
142
166
  guard let videoDevice = self.getBestDevice(for: cameraType),
143
167
  let videoDeviceInput = try? AVCaptureDeviceInput(device: videoDevice) else {
144
168
  return .sessionConfigurationFailed
145
169
  }
146
-
147
- configurationDepth += 1
148
- session.beginConfiguration()
149
- defer {
150
- session.commitConfiguration()
151
- configurationDepth -= 1
152
- handlePendingStopIfNeeded()
153
- }
154
-
155
- if session.canAddInput(videoDeviceInput) {
156
- session.addInput(videoDeviceInput)
157
- self.videoDeviceInput = videoDeviceInput
158
- self.resetZoom(forDevice: videoDevice)
159
- return .success
170
+
171
+ beginConfiguration()
172
+ defer { commitConfiguration() }
173
+
174
+ // 1. Video input
175
+ guard session.canAddInput(videoDeviceInput) else {
176
+ return .sessionConfigurationFailed
160
177
  }
161
- return .sessionConfigurationFailed
162
- }
163
-
164
- private func setupAdditionalOutputs(supportedBarcodeType: [CodeFormat]) {
165
- configurationDepth += 1
166
- session.beginConfiguration()
167
- defer {
168
- session.commitConfiguration()
169
- configurationDepth -= 1
170
- handlePendingStopIfNeeded()
178
+ session.addInput(videoDeviceInput)
179
+ self.videoDeviceInput = videoDeviceInput
180
+ self.resetZoom(forDevice: videoDevice)
181
+
182
+ // 2. Preset — set inside the transaction with the device input already
183
+ // present, so `canSetSessionPreset` reflects the real device. `.photo` is
184
+ // supported by every camera device; if it somehow is not, we keep whatever
185
+ // preset the session defaults to rather than crashing.
186
+ if session.canSetSessionPreset(.photo) {
187
+ session.sessionPreset = .photo
171
188
  }
172
-
173
- // Add photo output
189
+
190
+ // 3. Photo output
174
191
  if #available(iOS 13.0, *) {
175
192
  if let maxPhotoQualityPrioritization = maxPhotoQualityPrioritization {
176
193
  photoOutput.maxPhotoQualityPrioritization = maxPhotoQualityPrioritization.avQualityPrioritization
177
194
  }
178
195
  }
179
-
180
196
  if session.canAddOutput(photoOutput) {
181
197
  session.addOutput(photoOutput)
182
-
183
- if let photoOutputConnection = self.photoOutput.connection(with: .video) {
184
- if photoOutputConnection.isVideoStabilizationSupported {
185
- photoOutputConnection.preferredVideoStabilizationMode = .auto
186
- }
198
+ // Connection only exists after the output is added to the session.
199
+ if let photoOutputConnection = self.photoOutput.connection(with: .video),
200
+ photoOutputConnection.isVideoStabilizationSupported {
201
+ photoOutputConnection.preferredVideoStabilizationMode = .auto
187
202
  }
188
203
  }
189
-
190
- // Add metadata output for barcode scanning
204
+
205
+ // 4. Metadata output for barcode scanning
191
206
  if self.session.canAddOutput(metadataOutput) {
192
207
  self.session.addOutput(metadataOutput)
193
208
  metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
194
-
209
+
195
210
  let availableTypes = self.metadataOutput.availableMetadataObjectTypes
196
211
  let filteredTypes = supportedBarcodeType
197
212
  .map { $0.toAVMetadataObjectType() }
@@ -199,29 +214,49 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
199
214
 
200
215
  metadataOutput.metadataObjectTypes = filteredTypes
201
216
  }
202
-
203
- // add for text detections
204
- if (textRequest != nil && self.session.canAddOutput(self.videoDataOutput)) {
217
+
218
+ // 5. Video data output for text / MRZ detection
219
+ if textRequest != nil && self.session.canAddOutput(self.videoDataOutput) {
205
220
  self.session.addOutput(self.videoDataOutput)
206
221
  }
222
+
223
+ return .success
207
224
  }
208
225
 
209
226
  // MARK: - Pause / Resume non-essential outputs
227
+
228
+ /// Disables OCR + barcode work while a photo is captured.
229
+ /// MUST be called on `sessionQueue` (these are capture-output mutations).
210
230
  private func pauseNonEssentialOutputs() {
231
+ assertOnSessionQueue()
211
232
  videoDataOutput.setSampleBufferDelegate(nil, queue: nil)
212
233
  metadataOutput.rectOfInterest = CGRect(x: 0, y: 0, width: 0, height: 0)
213
234
  }
214
-
235
+
236
+ /// Re-enables OCR + barcode work after a capture. May be called from any
237
+ /// thread (photo-capture completion handlers run on an arbitrary queue), so it
238
+ /// hops to the main queue only to read preview-layer geometry and applies all
239
+ /// output mutations back on `sessionQueue`.
215
240
  private func resumeNonEssentialOutputs() {
216
- if textDetectionEnabled {
217
- videoDataOutput.setSampleBufferDelegate(self, queue: globalOCRQueue)
218
- }
219
- // Restore real rect of interest
220
- if let scanner = scannerFrameSize, scanner != .zero {
221
- metadataOutput.rectOfInterest =
222
- cameraPreview.previewLayer.metadataOutputRectConverted(fromLayerRect: scanner)
223
- } else {
224
- metadataOutput.rectOfInterest = CGRect(x: 0, y: 0, width: 1, height: 1)
241
+ sessionQueue.async { [weak self] in
242
+ guard let self else { return }
243
+ if self.textDetectionEnabled {
244
+ self.videoDataOutput.setSampleBufferDelegate(self, queue: globalOCRQueue)
245
+ }
246
+
247
+ guard let scanner = self.scannerFrameSize, scanner != .zero else {
248
+ self.metadataOutput.rectOfInterest = CGRect(x: 0, y: 0, width: 1, height: 1)
249
+ return
250
+ }
251
+
252
+ // metadataOutputRectConverted must be read on the main thread.
253
+ DispatchQueue.main.async { [weak self] in
254
+ guard let self else { return }
255
+ let rect = self.cameraPreview.previewLayer.metadataOutputRectConverted(fromLayerRect: scanner)
256
+ self.sessionQueue.async { [weak self] in
257
+ self?.metadataOutput.rectOfInterest = rect
258
+ }
259
+ }
225
260
  }
226
261
  }
227
262
 
@@ -361,21 +396,18 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
361
396
  func update(maxPhotoQualityPrioritization: MaxPhotoQualityPrioritization?) {
362
397
  guard #available(iOS 13.0, *) else { return }
363
398
  guard maxPhotoQualityPrioritization != self.maxPhotoQualityPrioritization else { return }
364
- sessionQueue.async {
365
- self.configurationDepth += 1
366
- self.session.beginConfiguration()
367
- defer {
368
- self.session.commitConfiguration()
369
- self.configurationDepth -= 1
370
- self.handlePendingStopIfNeeded()
371
- }
399
+ sessionQueue.async { [weak self] in
400
+ guard let self else { return }
401
+ self.beginConfiguration()
402
+ defer { self.commitConfiguration() }
372
403
  self.maxPhotoQualityPrioritization = maxPhotoQualityPrioritization
373
404
  self.photoOutput.maxPhotoQualityPrioritization = maxPhotoQualityPrioritization?.avQualityPrioritization ?? .balanced
374
405
  }
375
406
  }
376
407
 
377
408
  func update(cameraType: CameraType) {
378
- sessionQueue.async {
409
+ sessionQueue.async { [weak self] in
410
+ guard let self else { return }
379
411
  if self.videoDeviceInput?.device.position == cameraType.avPosition {
380
412
  return
381
413
  }
@@ -387,15 +419,10 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
387
419
  let videoDeviceInput = try? AVCaptureDeviceInput(device: videoDevice) else {
388
420
  return
389
421
  }
390
-
422
+
391
423
  self.removeObservers()
392
- self.configurationDepth += 1
393
- self.session.beginConfiguration()
394
- defer {
395
- self.session.commitConfiguration()
396
- self.configurationDepth -= 1
397
- self.handlePendingStopIfNeeded()
398
- }
424
+ self.beginConfiguration()
425
+ defer { self.commitConfiguration() }
399
426
 
400
427
  // Remove the existing device input first, since using the front and back camera simultaneously is not supported.
401
428
  self.session.removeInput(currentViewDeviceInput)
@@ -436,9 +463,6 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
436
463
  the main thread and session configuration is done on the session queue.
437
464
  */
438
465
 
439
- // Pause OCR + barcode before capturing
440
- self.pauseNonEssentialOutputs()
441
-
442
466
  DispatchQueue.main.async { [weak self] in
443
467
  guard let self = self else {
444
468
  onError("Camera was deallocated")
@@ -480,6 +504,9 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
480
504
  photoOutputConnection.videoOrientation = videoPreviewLayerOrientation
481
505
  }
482
506
 
507
+ // Pause OCR + barcode work on the session queue, right before capture.
508
+ self.pauseNonEssentialOutputs()
509
+
483
510
  let settings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
484
511
  if #available(iOS 13.0, *) {
485
512
  settings.photoQualityPrioritization = self.photoOutput.maxPhotoQualityPrioritization
@@ -829,39 +856,99 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega
829
856
 
830
857
  print("Capture session runtime error: \(error)")
831
858
 
832
- // Automatically try to restart the session running if media services were reset
859
+ // Automatically try to restart the session if media services were reset.
860
+ // (After a reset the session is NOT running, so the restart is routed
861
+ // through the guarded helper, which only starts when it is safe to.)
833
862
  if error.code == .mediaServicesWereReset {
834
- sessionQueue.async {
835
- if self.session.isRunning {
836
- self.session.startRunning()
837
- }
863
+ sessionQueue.async { [weak self] in
864
+ self?.startSessionIfNeeded()
838
865
  }
839
866
  }
840
867
  }
841
868
 
842
869
  func startCamera() {
843
- self.sessionQueue.async {
844
- if !self.session.isRunning {
845
- self.session.startRunning()
846
- }
870
+ sessionQueue.async { [weak self] in
871
+ self?.startSessionIfNeeded()
847
872
  }
848
873
  }
849
874
 
850
875
  func stopCamera() {
851
- self.sessionQueue.async {
852
- if self.session.isRunning {
853
- self.session.stopRunning()
854
- }
876
+ sessionQueue.async { [weak self] in
877
+ self?.stopSessionIfNeeded()
878
+ }
879
+ }
880
+
881
+ // MARK: - Session configuration transaction helpers
882
+
883
+ /// Debug-only tripwire enforcing the core invariant of this class: EVERY
884
+ /// AVCaptureSession mutation and start/stop happens on `sessionQueue`. The whole
885
+ /// "startRunning may not be called between beginConfiguration and
886
+ /// commitConfiguration" crash class is prevented precisely because nothing
887
+ /// touches the session off this serial queue. If a future change — or an
888
+ /// external caller such as a mini-app native bridge — ever reaches one of these
889
+ /// methods on the wrong queue, this fails loudly in DEBUG/QA instead of
890
+ /// crashing randomly in production. Compiled out of release builds, so it can
891
+ /// never add a production crash.
892
+ private func assertOnSessionQueue() {
893
+ #if DEBUG
894
+ dispatchPrecondition(condition: .onQueue(sessionQueue))
895
+ #endif
896
+ }
897
+
898
+ /// Opens a session configuration transaction and tracks nesting depth.
899
+ /// MUST be balanced by `commitConfiguration()` (use `defer`) and MUST run on
900
+ /// `sessionQueue`.
901
+ private func beginConfiguration() {
902
+ assertOnSessionQueue()
903
+ configurationDepth += 1
904
+ session.beginConfiguration()
905
+ }
906
+
907
+ /// Closes a session configuration transaction and flushes a stop that was
908
+ /// requested while the transaction was open. MUST run on `sessionQueue`.
909
+ private func commitConfiguration() {
910
+ assertOnSessionQueue()
911
+ session.commitConfiguration()
912
+ configurationDepth -= 1
913
+ handlePendingStopIfNeeded()
914
+ }
915
+
916
+ /// Starts the session only when it is safe to do so. MUST run on `sessionQueue`.
917
+ /// Because every begin/commit pair is synchronous on the serial session queue,
918
+ /// `configurationDepth` is always 0 at the start of a fresh queue block — the
919
+ /// depth check is therefore defence-in-depth against ever calling this from
920
+ /// inside an open transaction.
921
+ private func startSessionIfNeeded() {
922
+ assertOnSessionQueue()
923
+ guard setupResult == .success,
924
+ configurationDepth == 0,
925
+ !pendingStop,
926
+ !session.isRunning else {
927
+ return
928
+ }
929
+ session.startRunning()
930
+ }
931
+
932
+ /// Stops the session, deferring until the current transaction commits if one
933
+ /// is open. MUST run on `sessionQueue`.
934
+ private func stopSessionIfNeeded() {
935
+ assertOnSessionQueue()
936
+ guard session.isRunning else { return }
937
+ if configurationDepth > 0 {
938
+ pendingStop = true
939
+ return
855
940
  }
941
+ session.stopRunning()
856
942
  }
857
943
 
858
944
  // MARK: - Private helper for safe session stop
859
-
945
+
860
946
  private func handlePendingStopIfNeeded() {
861
947
  // Must be called on sessionQueue after configuration completes
948
+ assertOnSessionQueue()
862
949
  // Only execute if all configurations are done (depth == 0)
863
950
  guard configurationDepth == 0 && pendingStop else { return }
864
-
951
+
865
952
  pendingStop = false
866
953
  if session.isRunning {
867
954
  session.stopRunning()
package/package.json CHANGED
@@ -1,41 +1,41 @@
1
1
  {
2
- "name": "@momo-kits/camerakit",
3
- "version": "0.160.1-beta.8",
4
- "repository": {
5
- "type": "git",
6
- "url": "https://github.com/teslamotors/react-native-camera-kit.git"
7
- },
8
- "publishConfig": {
9
- "registry": "https://registry.npmjs.org/"
10
- },
11
- "description": "A high performance, fully featured, rock solid camera library for React Native applications",
12
- "nativePackage": true,
13
- "scripts": {
14
- "build": "echo",
15
- "test": "jest",
16
- "lint": "yarn eslint -c .eslintrc.js"
17
- },
18
- "main": "./src/index.ts",
19
- "dependencies": {},
20
- "license": "MIT",
21
- "devDependencies": {
22
- "react": "19.0.0",
23
- "react-native": "0.80.1"
24
- },
25
- "peerDependencies": {
26
- "react": "*",
27
- "react-native": "*"
28
- },
29
- "engines": {
30
- "node": ">=18"
31
- },
32
- "codegenConfig": {
33
- "name": "rncamerakit_specs",
34
- "type": "all",
35
- "jsSrcsDir": "src/specs",
36
- "android": {
37
- "javaPackageName": "com.rncamerakit"
38
- }
39
- },
40
- "packageManager": "yarn@1.22.22"
41
- }
2
+ "name": "@momo-kits/camerakit",
3
+ "version": "0.161.1-beta.1",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/teslamotors/react-native-camera-kit.git"
7
+ },
8
+ "publishConfig": {
9
+ "registry": "https://registry.npmjs.org/"
10
+ },
11
+ "description": "A high performance, fully featured, rock solid camera library for React Native applications",
12
+ "nativePackage": true,
13
+ "scripts": {
14
+ "build": "echo",
15
+ "test": "jest",
16
+ "lint": "yarn eslint -c .eslintrc.js"
17
+ },
18
+ "main": "./src/index.ts",
19
+ "dependencies": {},
20
+ "license": "MIT",
21
+ "devDependencies": {
22
+ "react": "19.0.0",
23
+ "react-native": "0.80.1"
24
+ },
25
+ "peerDependencies": {
26
+ "react": "*",
27
+ "react-native": "*"
28
+ },
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "codegenConfig": {
33
+ "name": "rncamerakit_specs",
34
+ "type": "all",
35
+ "jsSrcsDir": "src/specs",
36
+ "android": {
37
+ "javaPackageName": "com.rncamerakit"
38
+ }
39
+ },
40
+ "packageManager": "yarn@1.22.22"
41
+ }