react-native-image-stitcher 0.17.0 → 0.19.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 (46) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/RNImageStitcher.podspec +1 -1
  3. package/android/src/main/cpp/CMakeLists.txt +4 -4
  4. package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +216 -7
  5. package/android/src/main/java/io/imagestitcher/rn/ARFrameContext.kt +89 -0
  6. package/android/src/main/java/io/imagestitcher/rn/ARFramePlugin.kt +57 -0
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +831 -6
  8. package/android/src/main/java/io/imagestitcher/rn/RNSARPluginRegistry.kt +109 -0
  9. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +184 -0
  10. package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +1 -1
  11. package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +84 -2
  12. package/cpp/{stitcher_frame_data.hpp → camera_frame_data.hpp} +96 -13
  13. package/cpp/{stitcher_frame_jsi.cpp → camera_frame_jsi.cpp} +154 -11
  14. package/cpp/{stitcher_frame_jsi.hpp → camera_frame_jsi.hpp} +12 -12
  15. package/cpp/stitcher_proxy_jsi.cpp +31 -0
  16. package/cpp/stitcher_proxy_jsi.hpp +16 -0
  17. package/cpp/stitcher_worklet_dispatch.cpp +5 -5
  18. package/cpp/stitcher_worklet_dispatch.hpp +5 -5
  19. package/dist/camera/ARCameraView.d.ts +81 -3
  20. package/dist/camera/ARCameraView.js +103 -1
  21. package/dist/camera/Camera.d.ts +73 -7
  22. package/dist/camera/Camera.js +2 -2
  23. package/dist/index.d.ts +3 -1
  24. package/dist/stitching/ARFrameMeta.d.ts +149 -0
  25. package/dist/stitching/{StitcherFrame.js → ARFrameMeta.js} +1 -1
  26. package/dist/stitching/{StitcherFrame.d.ts → CameraFrame.d.ts} +70 -11
  27. package/dist/stitching/CameraFrame.js +4 -0
  28. package/dist/stitching/useStitcherWorklet.d.ts +4 -4
  29. package/dist/stitching/useStitcherWorklet.js +4 -4
  30. package/ios/Sources/RNImageStitcher/ARSessionBridge.m +23 -1
  31. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +172 -2
  32. package/ios/Sources/RNImageStitcher/CameraFrameHostObject.h +108 -0
  33. package/ios/Sources/RNImageStitcher/CameraFrameHostObject.mm +772 -0
  34. package/ios/Sources/RNImageStitcher/RNISARFramePlugin.swift +247 -0
  35. package/ios/Sources/RNImageStitcher/RNSARSession.swift +418 -34
  36. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +2 -2
  37. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +4 -4
  38. package/package.json +1 -1
  39. package/src/camera/ARCameraView.tsx +230 -5
  40. package/src/camera/Camera.tsx +91 -7
  41. package/src/index.ts +12 -3
  42. package/src/stitching/ARFrameMeta.ts +157 -0
  43. package/src/stitching/{StitcherFrame.ts → CameraFrame.ts} +79 -11
  44. package/src/stitching/useStitcherWorklet.ts +9 -9
  45. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.h +0 -60
  46. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.mm +0 -214
@@ -29,6 +29,7 @@ import AVFoundation
29
29
  import simd
30
30
  import UIKit
31
31
  import os.log
32
+ import QuartzCore // CACurrentMediaTime (monotonic clock for onArFrame throttle)
32
33
 
33
34
  // V15.0c.4 — FAULT-level os_log on the same subsystem/category the
34
35
  // slit-scan engine uses, so Console.app's filter for `category =
@@ -41,6 +42,19 @@ fileprivate let arSessionDiagLog = OSLog(
41
42
  )
42
43
 
43
44
 
45
+ // v0.18.0 — `onArFrame` LIGHT-metadata channel. The AR session posts
46
+ // this notification (carrying the `ARFrameMeta`-shaped dictionary) per
47
+ // throttled frame; `RNSARSessionBridge` (an RCTEventEmitter) observes it
48
+ // and re-emits as the JS `RNImageStitcherARFrame` device event. We go
49
+ // via NotificationCenter — rather than the bridge holding a reference to
50
+ // the session singleton — so the framework-free engine pattern (used by
51
+ // IncrementalStitcher) is preserved.
52
+ public extension Notification.Name {
53
+ static let retailensARFrameMeta =
54
+ Notification.Name("RNImageStitcherARFrame")
55
+ }
56
+
57
+
44
58
  /// Track state mirrors `ARCamera.TrackingState`. We mirror it
45
59
  /// rather than re-export the ARKit enum so the JS bridge sees a
46
60
  /// stable shape that doesn't drift with iOS SDK updates.
@@ -165,6 +179,39 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
165
179
  /// Whether the session is currently running.
166
180
  @objc public private(set) var isRunning: Bool = false
167
181
 
182
+ // ──────────────────────────────────────────────────────────────
183
+ // Scene reconstruction (ARKit mesh) — opt-in
184
+ // ──────────────────────────────────────────────────────────────
185
+ /// Whether ARKit scene reconstruction (the LiDAR mesh,
186
+ /// `ARMeshAnchor`s) should be enabled. Off by default — meshing
187
+ /// is costly (extra LiDAR processing + per-frame ARMeshAnchor
188
+ /// churn) and only the StitcherFrame `meshGeometry` consumer wants
189
+ /// it. Driven from JS via
190
+ /// `NativeModules.RNSARSession.setSceneReconstructionEnabled(bool)`
191
+ /// (the <Camera> `enableMesh` prop). When toggled while a session
192
+ /// is live, the session is reconfigured + re-run in place.
193
+ ///
194
+ /// Honored both at session creation (`start()`) and on live toggle
195
+ /// (`setSceneReconstructionEnabled`). Independent of the depth
196
+ /// `frameSemantics`/planeDetection config — those are left
197
+ /// untouched. No-ops on devices without
198
+ /// `supportsSceneReconstruction` (the flag is stored but produces
199
+ /// no mesh).
200
+ @objc public private(set) var isSceneReconstructionEnabled: Bool = false
201
+
202
+ // ──────────────────────────────────────────────────────────────
203
+ // v0.18.0 — configurable plane detection (planeDetection prop)
204
+ // ──────────────────────────────────────────────────────────────
205
+ /// Which plane orientations ARKit should detect, driven from JS via
206
+ /// `NativeModules.RNSARSession.setPlaneDetection(mode)` (the
207
+ /// `<Camera>` `planeDetection` prop). Default `[.vertical]` preserves
208
+ /// the plane-projected stitch path's long-standing behaviour. Stored
209
+ /// as the raw ARKit OptionSet so `start()` and the live-reconfigure
210
+ /// paths (`setSceneReconstructionEnabled` / `setPlaneDetection`) read
211
+ /// one source of truth.
212
+ private var planeDetectionOptions:
213
+ ARWorldTrackingConfiguration.PlaneDetection = [.vertical]
214
+
168
215
  // ──────────────────────────────────────────────────────────────
169
216
  // V15.0b — vertical plane detection
170
217
  // ──────────────────────────────────────────────────────────────
@@ -423,6 +470,46 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
423
470
  /// been torn down.
424
471
  @objc public weak var incrementalConsumer: ARFrameConsumer?
425
472
 
473
+ // ──────────────────────────────────────────────────────────────
474
+ // v0.18.0 — `onArFrame` LIGHT-metadata channel
475
+ // ──────────────────────────────────────────────────────────────
476
+ //
477
+ // When a host supplies the `<Camera onArFrame={...}>` prop, the TS
478
+ // layer calls `setArFrameMetaEnabled(true, intervalMs)`; on
479
+ // unmount / prop-removal it calls `(false, _)`. While enabled, each
480
+ // ARFrame's per-frame path builds the LIGHT `ARFrameMeta` dictionary
481
+ // (no pixel / vertex / face bytes — see
482
+ // `CameraFrameHostObject.lightArFrameMetaFromARFrame:pose:`) and
483
+ // posts it on `.retailensARFrameMeta` for the bridge to re-emit.
484
+ //
485
+ // Throttle: emit at most one meta per `arFrameMetaIntervalSec`
486
+ // (default 0.1s ≈ 10Hz) using `CACurrentMediaTime()` (monotonic,
487
+ // unaffected by wall-clock changes). Both flags are touched only on
488
+ // the ARSession delegate thread (the per-frame path) + the bridge
489
+ // thread (the setter); guarded by `arFrameMetaLock`.
490
+ private var arFrameMetaEnabled: Bool = false
491
+ private var arFrameMetaIntervalSec: TimeInterval = 0.1
492
+ private var lastArFrameMetaEmit: TimeInterval = 0
493
+ private let arFrameMetaLock = NSLock()
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
+
426
513
  private override init() {
427
514
  super.init()
428
515
  arSession.delegate = self
@@ -454,40 +541,12 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
454
541
  "[V15.0f-ar-start] start() called while already running — ignored to preserve plane detection state")
455
542
  return
456
543
  }
457
- let config = ARWorldTrackingConfiguration()
458
- // sceneDepth gives us per-pixel depth on LiDAR-equipped
459
- // devices; gracefully no-ops on non-LiDAR devices. Used by
460
- // Phase 6 measurement.
461
- if ARWorldTrackingConfiguration.supportsFrameSemantics(.smoothedSceneDepth) {
462
- config.frameSemantics = .smoothedSceneDepth
463
- }
464
- // V15.0b — enable VERTICAL plane detection for the
465
- // plane-projected stitch mode. ARKit incrementally builds a
466
- // model of any vertical surface in view (typical retail
467
- // fixture wall). The first-detected vertical plane's
468
- // transform is latched at capture-start and used as the
469
- // canvas reference frame: each accepted camera frame is
470
- // warped onto the plane via a 3×3 homography rather than
471
- // onto a virtual cylinder/plane at first-frame anchor.
472
- // CPU cost is negligible (<2 ms/frame). Detection time:
473
- // 2–5 s on non-LiDAR devices, sub-second on LiDAR.
474
- config.planeDetection = [.vertical]
475
- // Auto-focus on for better feature tracking on shelves with
476
- // small text and packaging detail.
477
- config.isAutoFocusEnabled = true
478
-
479
- // Option B — prefer the 4:3 videoFormat for full sensor FOV; the
480
- // keyframe is downscaled to arKeyframeMaxLongEdge below so memory
481
- // stays consistent across devices regardless of the format res.
482
- if let fmt = ARWorldTrackingConfiguration.supportedVideoFormats.min(by: { a, b in
483
- let da = abs(a.imageResolution.width / a.imageResolution.height - 4.0 / 3.0)
484
- let db = abs(b.imageResolution.width / b.imageResolution.height - 4.0 / 3.0)
485
- if abs(da - db) > 0.001 { return da < db }
486
- return a.imageResolution.width * a.imageResolution.height
487
- < b.imageResolution.width * b.imageResolution.height
488
- }) {
489
- config.videoFormat = fmt
490
- }
544
+ // Build the shared base config (depth semantics + plane detection
545
+ // + autofocus + scene reconstruction + 4:3 video format). See
546
+ // `makeBaseConfiguration()` centralised so `start()` and the
547
+ // live-reconfigure paths can't drift. `start()` is the only path
548
+ // that resets tracking + removes existing anchors.
549
+ let config = makeBaseConfiguration()
491
550
  arSession.run(config, options: [.resetTracking, .removeExistingAnchors])
492
551
  // V16-diag — log the chosen video format so we can correlate
493
552
  // batch-keyframe memory with ARFrame resolution. iPhone Pro
@@ -553,6 +612,180 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
553
612
  detectedPlaneTransformInternal = nil
554
613
  bestRejectedAlignment = -1.0
555
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()
621
+ }
622
+
623
+ /// Build the `ARWorldTrackingConfiguration` shared by `start()` and
624
+ /// the live-reconfigure paths (`setSceneReconstructionEnabled`,
625
+ /// `setPlaneDetection`). Single source of truth so the call sites
626
+ /// can't drift — every reconfigure preserves the SAME depth
627
+ /// frameSemantics, current plane-detection mode, autofocus, scene
628
+ /// reconstruction, and 4:3 video-format preference. Only the `run`
629
+ /// OPTIONS differ at the call site (`start()` resets tracking; the
630
+ /// live toggles do not).
631
+ ///
632
+ /// - sceneDepth + smoothedSceneDepth: enable both when supported so
633
+ /// `ARFrame.sceneDepth` populates the StitcherFrame `arDepth` field;
634
+ /// each is gated by `supportsFrameSemantics` so this no-ops on
635
+ /// non-LiDAR devices instead of throwing at `run()`.
636
+ /// - planeDetection: from `planeDetectionOptions` (default `[.vertical]`
637
+ /// for the plane-projected stitch path; set via `setPlaneDetection`).
638
+ /// - autofocus: on, for feature tracking on shelves with small text.
639
+ /// - sceneReconstruction: from the stored `isSceneReconstructionEnabled`
640
+ /// flag (no-ops on non-LiDAR devices).
641
+ /// - videoFormat: prefer 4:3 for full sensor FOV (keyframe is
642
+ /// downscaled to `arKeyframeMaxLongEdge` later for memory parity).
643
+ private func makeBaseConfiguration() -> ARWorldTrackingConfiguration {
644
+ let config = ARWorldTrackingConfiguration()
645
+ var depthSemantics: ARConfiguration.FrameSemantics = []
646
+ if ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) {
647
+ depthSemantics.insert(.sceneDepth)
648
+ }
649
+ if ARWorldTrackingConfiguration.supportsFrameSemantics(.smoothedSceneDepth) {
650
+ depthSemantics.insert(.smoothedSceneDepth)
651
+ }
652
+ config.frameSemantics = depthSemantics
653
+ config.planeDetection = planeDetectionOptions
654
+ config.isAutoFocusEnabled = true
655
+ Self.applySceneReconstruction(to: config,
656
+ enabled: isSceneReconstructionEnabled)
657
+ if let fmt = ARWorldTrackingConfiguration.supportedVideoFormats.min(by: { a, b in
658
+ let da = abs(a.imageResolution.width / a.imageResolution.height - 4.0 / 3.0)
659
+ let db = abs(b.imageResolution.width / b.imageResolution.height - 4.0 / 3.0)
660
+ if abs(da - db) > 0.001 { return da < db }
661
+ return a.imageResolution.width * a.imageResolution.height
662
+ < b.imageResolution.width * b.imageResolution.height
663
+ }) {
664
+ config.videoFormat = fmt
665
+ }
666
+ return config
667
+ }
668
+
669
+ /// Set the ARWorldTrackingConfiguration's `sceneReconstruction`
670
+ /// from a desired-enabled flag, gated on device support. Prefers
671
+ /// `.meshWithClassification` (per-face semantic labels — what the
672
+ /// StitcherFrame `meshGeometry.classifications` consumer wants),
673
+ /// falling back to plain `.mesh`, and `.none` when disabled or
674
+ /// unsupported. Centralised so `start()` and the live-toggle path
675
+ /// stay consistent.
676
+ private static func applySceneReconstruction(
677
+ to config: ARWorldTrackingConfiguration,
678
+ enabled: Bool
679
+ ) {
680
+ guard enabled else {
681
+ // `ARWorldTrackingConfiguration.SceneReconstruction` is an
682
+ // OptionSet — the empty set `[]` means "no meshing"
683
+ // (`.none` is unavailable on OptionSets).
684
+ config.sceneReconstruction = []
685
+ return
686
+ }
687
+ if ARWorldTrackingConfiguration.supportsSceneReconstruction(
688
+ .meshWithClassification) {
689
+ config.sceneReconstruction = .meshWithClassification
690
+ } else if ARWorldTrackingConfiguration.supportsSceneReconstruction(
691
+ .mesh) {
692
+ config.sceneReconstruction = .mesh
693
+ } else {
694
+ // No LiDAR / unsupported — leave meshing off (empty set).
695
+ config.sceneReconstruction = []
696
+ }
697
+ }
698
+
699
+ /// Toggle ARKit scene reconstruction (LiDAR mesh / `ARMeshAnchor`s).
700
+ /// Stores the flag so it's honored by the next `start()`, and — if a
701
+ /// session is ALREADY running — reconfigures + re-runs the live
702
+ /// session in place so the toggle takes effect immediately.
703
+ ///
704
+ /// Reconfiguration rebuilds the SAME config `start()` builds (depth
705
+ /// frameSemantics + vertical planeDetection + autofocus + 4:3 video
706
+ /// format) so we don't accidentally drop those when flipping the
707
+ /// mesh flag. We re-run WITHOUT `[.resetTracking,
708
+ /// .removeExistingAnchors]` so the existing world map, pose log, and
709
+ /// any latched plane survive the toggle (only the mesh option
710
+ /// changes).
711
+ ///
712
+ /// No-op on devices without scene-reconstruction support: the flag
713
+ /// is still stored (cheap, harmless) but `applySceneReconstruction`
714
+ /// leaves the config `.none`.
715
+ @objc public func setSceneReconstructionEnabled(_ enabled: Bool) {
716
+ isSceneReconstructionEnabled = enabled
717
+ os_log(.fault, log: arSessionDiagLog,
718
+ "[scene-mesh] setSceneReconstructionEnabled(%{public}@) running=%{public}@ supported=%{public}@",
719
+ enabled ? "true" : "false",
720
+ isRunning ? "true" : "false",
721
+ ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh)
722
+ ? "true" : "false")
723
+
724
+ guard isRunning else { return }
725
+
726
+ // Rebuild the live config from the shared builder — picks up the
727
+ // new mesh flag (just stored) along with the current depth
728
+ // semantics + plane-detection mode, so a mesh toggle never
729
+ // silently drops those. Re-run in place — NO reset/removeAnchors
730
+ // so existing tracking, pose log, and latched plane survive.
731
+ let config = makeBaseConfiguration()
732
+ arSession.run(config)
733
+ }
734
+
735
+ /// Set which plane orientations ARKit detects — the JS `<Camera>`
736
+ /// `planeDetection` prop, routed via
737
+ /// `NativeModules.RNSARSession.setPlaneDetection(mode)`. Stores the
738
+ /// mode (honored by the next `start()`) and, if a session is live,
739
+ /// reconfigures + re-runs in place (no reset — tracking / pose log /
740
+ /// latched plane survive).
741
+ ///
742
+ /// `mode`: `"vertical"` (default), `"horizontal"`, or `"both"`.
743
+ /// Anything else falls back to `"vertical"`.
744
+ ///
745
+ /// NOTE on the legacy vertical-plane latch: the V15 plane-projected
746
+ /// stitch latches the first camera-facing VERTICAL plane. Choosing
747
+ /// `"horizontal"` drops vertical detection, so that latch won't fire
748
+ /// — an explicit opt-out (the caller asked for horizontal-only).
749
+ /// `"vertical"` and `"both"` keep it working.
750
+ @objc public func setPlaneDetection(_ mode: String) {
751
+ switch mode {
752
+ case "horizontal": planeDetectionOptions = [.horizontal]
753
+ case "both": planeDetectionOptions = [.horizontal, .vertical]
754
+ default: planeDetectionOptions = [.vertical] // "vertical" + fallback
755
+ }
756
+ os_log(.fault, log: arSessionDiagLog,
757
+ "[plane-detect] setPlaneDetection(%{public}@) running=%{public}@",
758
+ mode, isRunning ? "true" : "false")
759
+ guard isRunning else { return }
760
+ // Reconfigure in place — NO reset/removeAnchors so existing
761
+ // tracking + pose log survive the plane-mode change.
762
+ let config = makeBaseConfiguration()
763
+ arSession.run(config)
764
+ }
765
+
766
+ /// v0.18.0 — toggle the `onArFrame` LIGHT-metadata channel. Called
767
+ /// from JS (via the bridge) with `true` + the throttle interval when a
768
+ /// host supplies `<Camera onArFrame={...}>`, and `false` on
769
+ /// unmount / prop-removal. Idempotent.
770
+ ///
771
+ /// `intervalMs` clamps to a 16ms floor (≈60Hz, ARKit's max delivery
772
+ /// rate) — a smaller value can't produce more frames, and 0 would
773
+ /// disable throttling entirely (one emit per ARFrame). Resetting the
774
+ /// `lastArFrameMetaEmit` clock on enable means the first frame after
775
+ /// `onArFrame` mounts emits immediately rather than waiting out a
776
+ /// stale interval from a previous session.
777
+ @objc public func setArFrameMetaEnabled(_ enabled: Bool, intervalMs: Double) {
778
+ arFrameMetaLock.lock()
779
+ arFrameMetaEnabled = enabled
780
+ arFrameMetaIntervalSec = max(0.016, intervalMs / 1000.0)
781
+ if enabled {
782
+ // Emit the next frame immediately (don't carry a stale clock).
783
+ lastArFrameMetaEmit = 0
784
+ }
785
+ arFrameMetaLock.unlock()
786
+ os_log(.fault, log: arSessionDiagLog,
787
+ "[onArFrame] setArFrameMetaEnabled(%{public}@) interval=%.0fms",
788
+ enabled ? "true" : "false", intervalMs)
556
789
  }
557
790
 
558
791
  /// Empty the pose log — call between captures so the next
@@ -637,6 +870,22 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
637
870
  // double-consuming would ingest each frame twice.
638
871
  RNSARWorkletRuntime.shared().dispatchFrame(frame, pose: pose)
639
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
+
883
+ // v0.18.0 — `onArFrame` LIGHT-metadata channel. Gated +
884
+ // throttled; builds the ARFrameMeta dictionary and posts it for
885
+ // the bridge to re-emit. Cheap no-op when disabled (the common
886
+ // case — most hosts don't supply `onArFrame`).
887
+ maybeEmitArFrameMeta(frame, pose: pose)
888
+
640
889
  // If recording is in flight, append this frame to the
641
890
  // asset writer DIRECTLY — no queue hop.
642
891
  //
@@ -1182,6 +1431,141 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
1182
1431
  return path
1183
1432
  }
1184
1433
 
1434
+ /// v0.18.0 — build + post the LIGHT `onArFrame` metadata for this
1435
+ /// frame, gated on `arFrameMetaEnabled` and throttled to
1436
+ /// `arFrameMetaIntervalSec`. Runs on the ARSession delegate thread
1437
+ /// (the per-frame `didUpdate` path).
1438
+ ///
1439
+ /// The gate + throttle check is taken under `arFrameMetaLock` (a
1440
+ /// microsecond hold); the actual meta build (the slightly more
1441
+ /// expensive part — anchor transpose, depth/mesh probes) happens
1442
+ /// OUTSIDE the lock so the bridge's `setArFrameMetaEnabled` never
1443
+ /// blocks behind a frame build. Posting on NotificationCenter is
1444
+ /// synchronous but the observer (`RNSARSessionBridge.handle…`) just
1445
+ /// hops to the main queue for the actual JS emit, so this returns
1446
+ /// quickly and never blocks ARKit's delegate.
1447
+ ///
1448
+ /// `CACurrentMediaTime()` is the monotonic media clock (same unit as
1449
+ /// the throttle interval); immune to wall-clock adjustments.
1450
+ private func maybeEmitArFrameMeta(_ frame: ARFrame, pose: RNSARFramePose) {
1451
+ arFrameMetaLock.lock()
1452
+ let enabled = arFrameMetaEnabled
1453
+ let interval = arFrameMetaIntervalSec
1454
+ let last = lastArFrameMetaEmit
1455
+ let now = CACurrentMediaTime()
1456
+ let due = enabled && (last == 0 || (now - last) >= interval)
1457
+ if due {
1458
+ // Reserve this slot before releasing the lock so a burst of
1459
+ // delegate callbacks can't all pass the throttle gate.
1460
+ lastArFrameMetaEmit = now
1461
+ }
1462
+ arFrameMetaLock.unlock()
1463
+
1464
+ guard due else { return }
1465
+
1466
+ // Build the LIGHT meta (no pixel/vertex/face bytes). Reuses the
1467
+ // Obj-C++ extraction helpers + the shared C++ extraction-config
1468
+ // gating (depth/anchors/mesh ⇐ enableDepth/enableAnchors/enableMesh).
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
+
1488
+ NotificationCenter.default.post(
1489
+ name: .retailensARFrameMeta,
1490
+ object: nil,
1491
+ userInfo: userInfo
1492
+ )
1493
+ }
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
+
1185
1569
  private func makePose(from frame: ARFrame) -> RNSARFramePose {
1186
1570
  // ARKit's transform is a 4x4 matrix; extract translation
1187
1571
  // (last column) and rotation (top-left 3x3 → quaternion).
@@ -35,7 +35,7 @@
35
35
  //
36
36
  // The implementation needs to hold `std::shared_ptr<JsiWorkletContext>`
37
37
  // + run JSI value construction, which can't live in pure Swift. Same
38
- // pattern as `KeyframeGateBridge.{h,mm}` + `StitcherFrameHostObject.{h,mm}`:
38
+ // pattern as `KeyframeGateBridge.{h,mm}` + `CameraFrameHostObject.{h,mm}`:
39
39
  // keep the header umbrella-safe (no JSI imports), put the C++ glue in
40
40
  // the .mm.
41
41
  //
@@ -103,7 +103,7 @@ typedef void (^RNSARFirstPartyCallback)(ARFrame *arFrame,
103
103
  /// runtime can be built + linked + the API surface fixed).
104
104
  ///
105
105
  /// The Phase 3c implementation will:
106
- /// 1. Build a `StitcherFrameHostObject` from `arFrame` + `pose`.
106
+ /// 1. Build a `CameraFrameHostObject` from `arFrame` + `pose`.
107
107
  /// 2. Run the first-party stitching synchronously on the caller
108
108
  /// thread (preserves today's `ingestFromARCameraView` cost
109
109
  /// envelope at the producer site).
@@ -26,7 +26,7 @@
26
26
  // (process termination reclaims it). Phase 3c will keep this shape.
27
27
 
28
28
  #import "RNSARWorkletRuntime.h"
29
- #import "StitcherFrameHostObject.h"
29
+ #import "CameraFrameHostObject.h"
30
30
 
31
31
  #import <Foundation/Foundation.h>
32
32
  #import <os/log.h>
@@ -48,7 +48,7 @@
48
48
  #include <vector>
49
49
 
50
50
  // Forward-declare `RNSARFramePose` — same pattern as
51
- // StitcherFrameHostObject.mm. We don't read its fields here in
51
+ // CameraFrameHostObject.mm. We don't read its fields here in
52
52
  // Phase 3b (the stub doesn't unpack the pose), but Phase 3c will.
53
53
  @class RNSARFramePose;
54
54
 
@@ -210,8 +210,8 @@
210
210
  // (acceptable for Phase 4b minimum-viable; a per-frame buffer
211
211
  // copy is a known optimization for later if throughput
212
212
  // suffers).
213
- StitcherFrameHostObject *hostObj =
214
- [StitcherFrameHostObject fromARFrame:arFrame pose:pose];
213
+ CameraFrameHostObject *hostObj =
214
+ [CameraFrameHostObject fromARFrame:arFrame pose:pose];
215
215
 
216
216
  // Hand the host object's jsi::HostObject shared_ptr (boxed as
217
217
  // void*) into the lambda. The lambda will:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.17.0",
3
+ "version": "0.19.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",