react-native-image-stitcher 0.18.0 → 0.20.0

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/android/src/main/java/io/imagestitcher/rn/ARFrameContext.kt +89 -0
  3. package/android/src/main/java/io/imagestitcher/rn/ARFramePlugin.kt +57 -0
  4. package/android/src/main/java/io/imagestitcher/rn/AROverlayRenderer.kt +406 -0
  5. package/android/src/main/java/io/imagestitcher/rn/AROverlayStore.kt +441 -0
  6. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +472 -13
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +30 -5
  8. package/android/src/main/java/io/imagestitcher/rn/RNSARPluginRegistry.kt +177 -0
  9. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +127 -0
  10. package/dist/camera/ARCameraView.d.ts +55 -2
  11. package/dist/camera/ARCameraView.js +68 -2
  12. package/dist/camera/Camera.d.ts +65 -2
  13. package/dist/camera/Camera.js +24 -6
  14. package/dist/camera/arOverlayController.d.ts +52 -0
  15. package/dist/camera/arOverlayController.js +132 -0
  16. package/dist/index.d.ts +5 -1
  17. package/dist/index.js +5 -2
  18. package/dist/stitching/ARFrameMeta.d.ts +49 -0
  19. package/dist/stitching/AROverlay.d.ts +97 -0
  20. package/dist/stitching/AROverlay.js +4 -0
  21. package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +15 -8
  22. package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +22 -0
  23. package/ios/Sources/RNImageStitcher/ARSessionBridge.m +14 -0
  24. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +117 -1
  25. package/ios/Sources/RNImageStitcher/CameraFrameHostObject.h +25 -0
  26. package/ios/Sources/RNImageStitcher/CameraFrameHostObject.mm +66 -54
  27. package/ios/Sources/RNImageStitcher/RNISARFramePlugin.swift +284 -0
  28. package/ios/Sources/RNImageStitcher/RNISAROverlay.swift +409 -0
  29. package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +281 -3
  30. package/ios/Sources/RNImageStitcher/RNSARSession.swift +127 -1
  31. package/package.json +1 -1
  32. package/src/camera/ARCameraView.tsx +139 -3
  33. package/src/camera/Camera.tsx +94 -3
  34. package/src/camera/arOverlayController.ts +184 -0
  35. package/src/index.ts +21 -1
  36. package/src/stitching/ARFrameMeta.ts +50 -0
  37. package/src/stitching/AROverlay.ts +105 -0
@@ -31,17 +31,37 @@
31
31
 
32
32
  import Foundation
33
33
  import ARKit
34
+ import SceneKit
34
35
  import UIKit
36
+ import simd
35
37
 
36
38
 
37
39
  @objc(RNSARCameraView)
38
- public final class RNSARCameraView: UIView {
40
+ public final class RNSARCameraView: UIView, ARSCNViewDelegate {
39
41
 
40
42
  /// The ARSCNView that does the actual rendering. Bound to the
41
43
  /// singleton's ARSession so all preview surfaces share the same
42
44
  /// session (and the same pose log that the stitcher consumes).
43
45
  private var arSCNView: ARSCNView!
44
46
 
47
+ /// v0.20.0 — world-anchored overlays backed by REAL `ARAnchor`s. Each
48
+ /// overlay becomes an `ARAnchor` added to the session; ARSCNView creates a
49
+ /// node per anchor (via `renderer(_:nodeFor:)`) and keeps its transform
50
+ /// synced to the anchor every frame — and crucially ARKit *refines* the
51
+ /// anchor as its world understanding improves (drift / re-localization),
52
+ /// so the marker stays glued to the real-world spot across a long session,
53
+ /// not just a short one (which a fixed world coordinate would not survive).
54
+ /// Diffed against the overlay store each render pass (`RNISAROverlay` is
55
+ /// `Equatable`): add new ids, rebuild changed ones, remove gone ones.
56
+ ///
57
+ /// Two maps, guarded by `anchorLock` because `nodeFor` may run on a
58
+ /// different (render) thread than the `updateAtTime` diff:
59
+ /// - `overlayAnchors`: overlay id → (its anchor, the overlay it came from)
60
+ /// - `anchorOverlays`: anchor UUID → overlay (so `nodeFor` can build it)
61
+ private var overlayAnchors: [String: (anchor: ARAnchor, overlay: RNISAROverlay)] = [:]
62
+ private var anchorOverlays: [UUID: RNISAROverlay] = [:]
63
+ private let anchorLock = NSLock()
64
+
45
65
  public override init(frame: CGRect) {
46
66
  super.init(frame: frame)
47
67
  setupView()
@@ -68,6 +88,14 @@ public final class RNSARCameraView: UIView {
68
88
  // a renderer.
69
89
  arSCNView.session = RNSARSession.shared.arSession
70
90
 
91
+ // v0.20.0 — become the ARSCNView's render delegate so
92
+ // `renderer(_:updateAtTime:)` fires once per render pass (display
93
+ // rate) on the MAIN thread. This is our per-frame overlay redraw
94
+ // hook: cheap (a handful of overlays), already on the main thread,
95
+ // and gives us smooth display-rate tracking without touching the
96
+ // ARSession delegate (which `RNSARSession` owns for pose logging).
97
+ arSCNView.delegate = self
98
+
71
99
  // We don't draw any 3D content in Phase 4.4. Disable
72
100
  // SceneKit's automatic statistics overlay and lighting model
73
101
  // — we just want the camera feed.
@@ -78,6 +106,39 @@ public final class RNSARCameraView: UIView {
78
106
  // this view outside ARSCNView's letterboxed sub-rect).
79
107
  backgroundColor = .black
80
108
  addSubview(arSCNView)
109
+
110
+ // v0.20.0 — overlays render as world-anchored SceneKit nodes inside
111
+ // `arSCNView.scene` (see `syncOverlayNodes`), so there is no separate
112
+ // 2D overlay UIView to add here.
113
+ }
114
+
115
+ // MARK: - v0.20.0 — declarative `overlays` prop (KVC from RN)
116
+
117
+ /// Declarative `overlays` prop. RN sets this via KVC
118
+ /// (`RCT_EXPORT_VIEW_PROPERTY(overlays, NSArray)`) whenever the prop
119
+ /// changes; we replace the JS overlay namespace wholesale (declarative
120
+ /// = the full set each render). Forwarded to the global store rather
121
+ /// than held per-view because the overlay world is global to the single
122
+ /// AR session. `@objc` + `NSArray` so KVC can set it.
123
+ @objc public var overlays: NSArray = [] {
124
+ didSet {
125
+ Self.applyJSSetOverlays(overlays)
126
+ }
127
+ }
128
+
129
+ /// Parse a JS overlay-dictionary array and replace the JS overlay
130
+ /// namespace in the shared store. Shared by the declarative prop
131
+ /// setter (above) and the imperative `setOverlays` view command (in
132
+ /// `RNSARCameraViewManager`).
133
+ static func applyJSSetOverlays(_ overlays: NSArray) {
134
+ var parsed: [RNISAROverlay] = []
135
+ parsed.reserveCapacity(overlays.count)
136
+ for item in overlays {
137
+ guard let dict = item as? [String: Any],
138
+ let o = RNISAROverlay.from(dictionary: dict) else { continue }
139
+ parsed.append(o)
140
+ }
141
+ RNISAROverlayStore.shared.setJSOverlays(parsed)
81
142
  }
82
143
 
83
144
  public override func layoutSubviews() {
@@ -96,7 +157,12 @@ public final class RNSARCameraView: UIView {
96
157
  // ARSCNView fills a same-AR sub-rect, there is nothing to crop
97
158
  // and the user sees the full captured scene. The parent view's
98
159
  // black background fills the remainder.
99
- arSCNView.frame = letterboxedFrame()
160
+ let lb = letterboxedFrame()
161
+ arSCNView.frame = lb
162
+ // Overlay nodes live in `arSCNView.scene` and are rendered by the
163
+ // same (letterboxed) ARSCNView against the live AR camera, so they
164
+ // need no separate framing — they align with the camera feed
165
+ // automatically.
100
166
  }
101
167
 
102
168
  /// Returns the largest `CGRect` inside `bounds` that matches the
@@ -184,6 +250,218 @@ public final class RNSARCameraView: UIView {
184
250
  }
185
251
  }
186
252
  }
187
- }
188
253
 
254
+ // MARK: - ARSCNViewDelegate (v0.20.0 overlay redraw hook)
255
+
256
+ /// Called once per ARSCNView render pass. Keeps the set of overlay
257
+ /// ARAnchors in sync with the overlay store (add / rebuild / remove).
258
+ /// ARKit then tracks each anchor and ARSCNView positions its node — we do
259
+ /// NOT project or position anything by hand. Cheap: an `==` diff over a
260
+ /// handful of overlays.
261
+ ///
262
+ /// We deliberately do NOT touch `RNSARSession`'s pose log / plugin /
263
+ /// onArFrame paths — those ride the ARSession delegate which the singleton
264
+ /// owns. This delegate is purely the overlay-sync hook.
265
+ public func renderer(
266
+ _ renderer: SCNSceneRenderer,
267
+ updateAtTime time: TimeInterval
268
+ ) {
269
+ syncOverlayAnchors()
270
+ }
271
+
272
+ /// ARSCNViewDelegate: vend the node for one of our overlay anchors. May
273
+ /// run on the render thread; ARSCNView keeps the returned node's transform
274
+ /// synced to the (ARKit-refined) anchor. We build the visual RELATIVE to
275
+ /// the anchor origin — the anchor carries the world position.
276
+ public func renderer(
277
+ _ renderer: SCNSceneRenderer,
278
+ nodeFor anchor: ARAnchor
279
+ ) -> SCNNode? {
280
+ anchorLock.lock()
281
+ let overlay = anchorOverlays[anchor.identifier]
282
+ anchorLock.unlock()
283
+ guard let overlay = overlay else { return nil }
284
+
285
+ if let quad = overlay.worldQuad, quad.count >= 3 {
286
+ // Anchor sits at the centroid; draw the loop relative to it.
287
+ var c = simd_float3(0, 0, 0)
288
+ for v in quad { c += v }
289
+ c /= Float(quad.count)
290
+ return Self.makeQuadOutlineNode(
291
+ relCorners: quad.map { $0 - c },
292
+ color: overlay.color, label: overlay.label)
293
+ }
294
+ return Self.makeBillboardNode(
295
+ sizeMeters: overlay.sizeMeters, color: overlay.color,
296
+ label: overlay.label)
297
+ }
298
+
299
+ /// Diff the overlay store against the live anchors: add an ARAnchor for
300
+ /// each new/changed overlay, remove anchors whose overlay is gone.
301
+ private func syncOverlayAnchors() {
302
+ let session = arSCNView.session
303
+ let overlays = RNISAROverlayStore.shared.snapshot()
304
+ var liveIDs = Set<String>()
305
+ liveIDs.reserveCapacity(overlays.count)
306
+
307
+ for overlay in overlays {
308
+ liveIDs.insert(overlay.id)
309
+ anchorLock.lock()
310
+ let existing = overlayAnchors[overlay.id]
311
+ anchorLock.unlock()
312
+ if let existing = existing, existing.overlay == overlay { continue }
313
+
314
+ // New or changed → drop the old anchor, add a fresh one.
315
+ if let existing = existing {
316
+ session.remove(anchor: existing.anchor)
317
+ anchorLock.lock()
318
+ anchorOverlays[existing.anchor.identifier] = nil
319
+ overlayAnchors[overlay.id] = nil
320
+ anchorLock.unlock()
321
+ }
322
+ guard let pose = Self.anchorPose(for: overlay) else { continue }
323
+ let anchor = ARAnchor(transform: pose)
324
+ anchorLock.lock()
325
+ anchorOverlays[anchor.identifier] = overlay
326
+ overlayAnchors[overlay.id] = (anchor, overlay)
327
+ anchorLock.unlock()
328
+ session.add(anchor: anchor)
329
+ }
330
+
331
+ // Remove anchors whose overlay id is gone (snapshot under the lock,
332
+ // call session.remove outside it).
333
+ anchorLock.lock()
334
+ let goneIDs = overlayAnchors.keys.filter { !liveIDs.contains($0) }
335
+ let goneAnchors = goneIDs.compactMap { overlayAnchors[$0]?.anchor }
336
+ for id in goneIDs {
337
+ if let a = overlayAnchors[id]?.anchor { anchorOverlays[a.identifier] = nil }
338
+ overlayAnchors[id] = nil
339
+ }
340
+ anchorLock.unlock()
341
+ for a in goneAnchors { session.remove(anchor: a) }
342
+ }
343
+
344
+ // MARK: - v0.20.0 overlay node builders (RELATIVE to the anchor origin)
345
+
346
+ /// World transform for an overlay's anchor: a translation-only pose at the
347
+ /// `worldPosition`, or at the centroid of `worldQuad`. nil if no geometry.
348
+ private static func anchorPose(for overlay: RNISAROverlay) -> simd_float4x4? {
349
+ let p: simd_float3
350
+ if let quad = overlay.worldQuad, quad.count >= 3 {
351
+ var c = simd_float3(0, 0, 0)
352
+ for v in quad { c += v }
353
+ p = c / Float(quad.count)
354
+ } else if let center = overlay.worldPosition {
355
+ p = center
356
+ } else {
357
+ return nil
358
+ }
359
+ var m = matrix_identity_float4x4
360
+ m.columns.3 = simd_float4(p.x, p.y, p.z, 1)
361
+ return m
362
+ }
363
+
364
+ /// A camera-facing billboard plane (at the anchor origin), sized in metres,
365
+ /// textured with a stroked outline + optional centred label. Always drawn
366
+ /// on top (depth read/write off) so an annotation is never hidden.
367
+ private static func makeBillboardNode(
368
+ sizeMeters: CGSize?,
369
+ color: UIColor,
370
+ label: String?
371
+ ) -> SCNNode {
372
+ let w = sizeMeters?.width ?? RNISAROverlay.defaultMarkerExtent
373
+ let h = sizeMeters?.height ?? RNISAROverlay.defaultMarkerExtent
374
+ let plane = SCNPlane(width: w, height: h)
375
+ let mat = SCNMaterial()
376
+ mat.diffuse.contents = overlayImage(color: color, label: label)
377
+ mat.isDoubleSided = true
378
+ mat.lightingModel = .constant // unlit — show the texture as-is
379
+ mat.writesToDepthBuffer = false
380
+ mat.readsFromDepthBuffer = false // never occluded by the scene
381
+ plane.firstMaterial = mat
382
+
383
+ let node = SCNNode(geometry: plane)
384
+ node.renderingOrder = 1000 // draw after the camera background
385
+ let billboard = SCNBillboardConstraint()
386
+ billboard.freeAxes = .all // always face the camera, flat
387
+ node.constraints = [billboard]
388
+ return node
389
+ }
390
+
391
+ /// A 3D line-loop through corners expressed RELATIVE to the anchor origin
392
+ /// (the anchor is at the quad centroid), with an optional camera-facing
393
+ /// label at the centre.
394
+ private static func makeQuadOutlineNode(
395
+ relCorners: [simd_float3],
396
+ color: UIColor,
397
+ label: String?
398
+ ) -> SCNNode {
399
+ let vertices = relCorners.map { SCNVector3($0.x, $0.y, $0.z) }
400
+ var indices: [Int32] = []
401
+ let n = vertices.count
402
+ indices.reserveCapacity(n * 2)
403
+ for i in 0..<n {
404
+ indices.append(Int32(i))
405
+ indices.append(Int32((i + 1) % n)) // close the loop
406
+ }
407
+ let src = SCNGeometrySource(vertices: vertices)
408
+ let elem = SCNGeometryElement(indices: indices, primitiveType: .line)
409
+ let geo = SCNGeometry(sources: [src], elements: [elem])
410
+ let mat = SCNMaterial()
411
+ mat.diffuse.contents = color
412
+ mat.lightingModel = .constant
413
+ mat.writesToDepthBuffer = false
414
+ mat.readsFromDepthBuffer = false
415
+ geo.firstMaterial = mat
416
+
417
+ let node = SCNNode(geometry: geo)
418
+ node.renderingOrder = 1000
419
+
420
+ if let label = label, !label.isEmpty {
421
+ // Label at the centroid (≈ local origin in relative space).
422
+ let labelNode = makeBillboardNode(
423
+ sizeMeters: CGSize(width: 0.12, height: 0.12),
424
+ color: color, label: label)
425
+ node.addChildNode(labelNode)
426
+ }
427
+ return node
428
+ }
429
+
430
+ /// Render a stroked rounded-rect outline + optional centred label chip to
431
+ /// a square image, used as the billboard plane's texture. Transparent
432
+ /// background so only the outline + chip show over the camera feed.
433
+ private static func overlayImage(color: UIColor, label: String?) -> UIImage {
434
+ let px: CGFloat = 512
435
+ let renderer = UIGraphicsImageRenderer(size: CGSize(width: px, height: px))
436
+ return renderer.image { ctx in
437
+ let cg = ctx.cgContext
438
+ let inset = px * 0.07
439
+ let rect = CGRect(x: inset, y: inset,
440
+ width: px - 2 * inset, height: px - 2 * inset)
441
+ let path = UIBezierPath(roundedRect: rect, cornerRadius: px * 0.05)
442
+ cg.setStrokeColor(color.cgColor)
443
+ cg.setLineWidth(px * 0.03)
444
+ path.stroke()
445
+
446
+ guard let label = label, !label.isEmpty else { return }
447
+ let fontSize = px * 0.13
448
+ let font = UIFont.systemFont(ofSize: fontSize, weight: .bold)
449
+ let attrs: [NSAttributedString.Key: Any] = [
450
+ .font: font, .foregroundColor: UIColor.white,
451
+ ]
452
+ let ts = (label as NSString).size(withAttributes: attrs)
453
+ let pad = fontSize * 0.35
454
+ let chip = CGRect(x: (px - ts.width) / 2 - pad,
455
+ y: (px - ts.height) / 2 - pad,
456
+ width: ts.width + 2 * pad,
457
+ height: ts.height + 2 * pad)
458
+ color.withAlphaComponent(0.9).setFill()
459
+ UIBezierPath(roundedRect: chip, cornerRadius: pad).fill()
460
+ (label as NSString).draw(
461
+ at: CGPoint(x: (px - ts.width) / 2, y: (px - ts.height) / 2),
462
+ withAttributes: attrs)
463
+ }
464
+ }
465
+
466
+ }
189
467
 
@@ -492,6 +492,24 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
492
492
  private var lastArFrameMetaEmit: TimeInterval = 0
493
493
  private let arFrameMetaLock = NSLock()
494
494
 
495
+ // ──────────────────────────────────────────────────────────────
496
+ // v0.19.0 — native AR plugin framework (RNISARPluginRegistry)
497
+ // ──────────────────────────────────────────────────────────────
498
+ //
499
+ // While the plugin registry is NON-EMPTY, the per-frame path builds a
500
+ // `RNISARFrameContext` once and calls each registered plugin's
501
+ // `process(_:)` on the AR (delegate) thread (see `invokeArPlugins`).
502
+ // Non-nil SYNC results are cached here so the throttled `onArFrame`
503
+ // meta build can fold them in under `plugins: { [name]: result }`
504
+ // without re-running plugins. Zero-plugin apps skip the whole path
505
+ // (the registry's `isEmpty` gate), so they pay nothing.
506
+ //
507
+ // Written on the AR thread (per-frame) and read on the same thread
508
+ // (the meta build runs inline in `session(_:didUpdate:)`), but guarded
509
+ // anyway for defensiveness against any future off-thread reader.
510
+ private var latestPluginSyncResults: [String: Any] = [:]
511
+ private let pluginSyncResultsLock = NSLock()
512
+
495
513
  private override init() {
496
514
  super.init()
497
515
  arSession.delegate = self
@@ -594,6 +612,12 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
594
612
  detectedPlaneTransformInternal = nil
595
613
  bestRejectedAlignment = -1.0
596
614
  planeLatchLock.unlock()
615
+ // v0.19.0 — drop any cached SYNC plugin results so the next
616
+ // capture's `onArFrame` meta doesn't surface stale plugin output
617
+ // before the first frame of the new session runs the plugins.
618
+ pluginSyncResultsLock.lock()
619
+ latestPluginSyncResults = [:]
620
+ pluginSyncResultsLock.unlock()
597
621
  }
598
622
 
599
623
  /// Build the `ARWorldTrackingConfiguration` shared by `start()` and
@@ -846,6 +870,16 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
846
870
  // double-consuming would ingest each frame twice.
847
871
  RNSARWorkletRuntime.shared().dispatchFrame(frame, pose: pose)
848
872
 
873
+ // v0.19.0 — native AR plugin framework. When the registry is
874
+ // non-empty, build the per-frame `RNISARFrameContext` once and run
875
+ // every registered plugin's `process(_:)` SYNCHRONOUSLY on this AR
876
+ // thread (so the live pixel/depth buffers are valid for the call).
877
+ // Caches non-nil SYNC results for the meta build below. Cheap
878
+ // no-op when no plugins are registered (the common case). Runs
879
+ // BEFORE `maybeEmitArFrameMeta` so the throttled `onArFrame` meta
880
+ // can fold in this frame's freshest plugin results.
881
+ invokeArPlugins(frame, pose: pose)
882
+
849
883
  // v0.18.0 — `onArFrame` LIGHT-metadata channel. Gated +
850
884
  // throttled; builds the ARFrameMeta dictionary and posts it for
851
885
  // the bridge to re-emit. Cheap no-op when disabled (the common
@@ -1433,13 +1467,105 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
1433
1467
  // Obj-C++ extraction helpers + the shared C++ extraction-config
1434
1468
  // gating (depth/anchors/mesh ⇐ enableDepth/enableAnchors/enableMesh).
1435
1469
  let meta = CameraFrameHostObject.lightArFrameMeta(from: frame, pose: pose)
1470
+
1471
+ // v0.19.0 — fold in any SYNC plugin results captured by
1472
+ // `invokeArPlugins` for the freshest frames. Only attach the
1473
+ // `plugins` key when there's at least one result, so the common
1474
+ // (no-plugin) meta shape is unchanged. Snapshot under the lock,
1475
+ // then bridge into a fresh dictionary copy.
1476
+ pluginSyncResultsLock.lock()
1477
+ let pluginResults = latestPluginSyncResults
1478
+ pluginSyncResultsLock.unlock()
1479
+ let userInfo: [AnyHashable: Any]
1480
+ if pluginResults.isEmpty {
1481
+ userInfo = meta
1482
+ } else {
1483
+ var withPlugins = meta
1484
+ withPlugins["plugins"] = pluginResults
1485
+ userInfo = withPlugins
1486
+ }
1487
+
1436
1488
  NotificationCenter.default.post(
1437
1489
  name: .retailensARFrameMeta,
1438
1490
  object: nil,
1439
- userInfo: meta
1491
+ userInfo: userInfo
1440
1492
  )
1441
1493
  }
1442
1494
 
1495
+ /// v0.19.0 — run all registered native AR plugins for this frame.
1496
+ /// Gated on the registry being NON-EMPTY (the cheap `isEmpty` check) so
1497
+ /// zero-plugin apps skip the context build entirely. When plugins are
1498
+ /// present, builds ONE `RNISARFrameContext` (zero-copy view of the
1499
+ /// frame's live buffers + the already-built anchor dicts) and calls
1500
+ /// each plugin's `process(_:)` SYNCHRONOUSLY on this AR (delegate)
1501
+ /// thread — so the live `pixelBuffer` / `depthBuffer` are valid for the
1502
+ /// call (the plugin must copy before offloading; see the protocol
1503
+ /// docstring). Non-nil SYNC results are cached in
1504
+ /// `latestPluginSyncResults` for the throttled `onArFrame` meta to fold
1505
+ /// in; ASYNC results arrive later via `RNISARPluginRegistry.emit`.
1506
+ private func invokeArPlugins(_ frame: ARFrame, pose: RNSARFramePose) {
1507
+ let registry = RNISARPluginRegistry.shared
1508
+ guard !registry.isEmpty else { return }
1509
+ let plugins = registry.plugins()
1510
+ guard !plugins.isEmpty else { return }
1511
+
1512
+ // depthBuffer: expose the live sceneDepth map ONLY when the
1513
+ // `<Camera enableDepth>` prop is on (gating read in Obj-C++ so the
1514
+ // C++ extraction-config header stays out of Swift). Prefer
1515
+ // `sceneDepth`, fall back to `smoothedSceneDepth` — same precedence
1516
+ // as the full extraction path.
1517
+ var depthBuffer: CVPixelBuffer? = nil
1518
+ if CameraFrameHostObject.arExtractionDepthEnabled() {
1519
+ if let dd = frame.sceneDepth ?? frame.smoothedSceneDepth {
1520
+ depthBuffer = dd.depthMap
1521
+ }
1522
+ }
1523
+
1524
+ // anchors: reuse the EXACT light dicts the `onArFrame` meta builds
1525
+ // (gated on `enableAnchors`; empty otherwise) — DRY single source.
1526
+ let anchorDicts = CameraFrameHostObject.arAnchorDicts(from: frame)
1527
+ let anchors = anchorDicts as? [[String: Any]] ?? []
1528
+
1529
+ let context = RNISARFrameContext(
1530
+ pixelBuffer: frame.capturedImage,
1531
+ timestampNs: frame.timestamp * 1e9,
1532
+ fx: pose.fx, fy: pose.fy, cx: pose.cx, cy: pose.cy,
1533
+ imageWidth: pose.imageWidth, imageHeight: pose.imageHeight,
1534
+ poseRotation: [pose.qx, pose.qy, pose.qz, pose.qw],
1535
+ poseTranslation: [pose.tx, pose.ty, pose.tz],
1536
+ trackingState: Self.trackingStateString(pose.trackingState),
1537
+ depthBuffer: depthBuffer,
1538
+ anchors: anchors
1539
+ )
1540
+
1541
+ var syncResults: [String: Any] = [:]
1542
+ for plugin in plugins {
1543
+ // Defensive: a plugin throwing/crashing in `process` would take
1544
+ // down the AR thread, but Swift has no try/catch for non-Error
1545
+ // crashes — the contract is that plugins are well-behaved. We
1546
+ // simply collect non-nil results keyed by the plugin's name.
1547
+ if let result = plugin.process(context) {
1548
+ syncResults[plugin.name()] = result
1549
+ }
1550
+ }
1551
+
1552
+ pluginSyncResultsLock.lock()
1553
+ latestPluginSyncResults = syncResults
1554
+ pluginSyncResultsLock.unlock()
1555
+ }
1556
+
1557
+ /// Map the SDK's `RNSARTrackingState` to the same string the
1558
+ /// `onArFrame` meta + `CameraFrame.trackingState` use, so plugins see a
1559
+ /// consistent vocabulary.
1560
+ private static func trackingStateString(_ s: RNSARTrackingState) -> String {
1561
+ switch s {
1562
+ case .tracking: return "normal"
1563
+ case .limited: return "limited"
1564
+ case .initialising: return "limited"
1565
+ case .notAvailable: return "notAvailable"
1566
+ }
1567
+ }
1568
+
1443
1569
  private func makePose(from frame: ARFrame) -> RNSARFramePose {
1444
1570
  // ARKit's transform is a 4x4 matrix; extract translation
1445
1571
  // (last column) and rotation (top-left 3x3 → quaternion).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Pose-aware panorama capture + stitching for React Native. One <Camera> component, both tap-to-photo and hold-to-pan modes, both AR-backed and IMU-fallback capture paths.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",