react-native-image-stitcher 0.16.2 → 0.18.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 (52) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/RNImageStitcher.podspec +26 -1
  3. package/android/build.gradle +20 -0
  4. package/android/src/main/cpp/CMakeLists.txt +46 -3
  5. package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +436 -0
  6. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +6 -0
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +711 -6
  8. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +156 -0
  9. package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +103 -0
  10. package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +338 -0
  11. package/cpp/{stitcher_frame_data.hpp → camera_frame_data.hpp} +96 -13
  12. package/cpp/camera_frame_jsi.cpp +357 -0
  13. package/cpp/camera_frame_jsi.hpp +108 -0
  14. package/cpp/stitcher_proxy_jsi.cpp +140 -0
  15. package/cpp/stitcher_proxy_jsi.hpp +62 -0
  16. package/cpp/stitcher_worklet_dispatch.cpp +103 -0
  17. package/cpp/stitcher_worklet_dispatch.hpp +71 -0
  18. package/cpp/stitcher_worklet_registry.cpp +91 -0
  19. package/cpp/stitcher_worklet_registry.hpp +146 -0
  20. package/dist/camera/ARCameraView.d.ts +77 -0
  21. package/dist/camera/ARCameraView.js +90 -1
  22. package/dist/camera/Camera.d.ts +63 -4
  23. package/dist/camera/Camera.js +2 -2
  24. package/dist/camera/CaptureMemoryPill.d.ts +4 -3
  25. package/dist/camera/CaptureMemoryPill.js +4 -3
  26. package/dist/index.d.ts +2 -1
  27. package/dist/stitching/ARFrameMeta.d.ts +100 -0
  28. package/dist/stitching/{StitcherFrame.js → ARFrameMeta.js} +1 -1
  29. package/dist/stitching/{StitcherFrame.d.ts → CameraFrame.d.ts} +70 -11
  30. package/dist/stitching/CameraFrame.js +4 -0
  31. package/dist/stitching/ensureStitcherProxyInstalled.d.ts +8 -0
  32. package/dist/stitching/ensureStitcherProxyInstalled.js +81 -0
  33. package/dist/stitching/useStitcherWorklet.d.ts +4 -4
  34. package/dist/stitching/useStitcherWorklet.js +4 -4
  35. package/ios/Sources/RNImageStitcher/ARSessionBridge.m +23 -1
  36. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +137 -2
  37. package/ios/Sources/RNImageStitcher/CameraFrameHostObject.h +83 -0
  38. package/ios/Sources/RNImageStitcher/CameraFrameHostObject.mm +760 -0
  39. package/ios/Sources/RNImageStitcher/RNSARSession.swift +336 -40
  40. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +128 -0
  41. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +313 -0
  42. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.h +42 -0
  43. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +160 -0
  44. package/package.json +1 -1
  45. package/src/camera/ARCameraView.tsx +211 -2
  46. package/src/camera/Camera.tsx +81 -4
  47. package/src/camera/CaptureMemoryPill.tsx +4 -3
  48. package/src/index.ts +7 -3
  49. package/src/stitching/ARFrameMeta.ts +107 -0
  50. package/src/stitching/{StitcherFrame.ts → CameraFrame.ts} +79 -11
  51. package/src/stitching/ensureStitcherProxyInstalled.ts +141 -0
  52. package/src/stitching/useStitcherWorklet.ts +9 -9
@@ -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,28 @@ 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
+
426
495
  private override init() {
427
496
  super.init()
428
497
  arSession.delegate = self
@@ -454,40 +523,12 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
454
523
  "[V15.0f-ar-start] start() called while already running — ignored to preserve plane detection state")
455
524
  return
456
525
  }
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
- }
526
+ // Build the shared base config (depth semantics + plane detection
527
+ // + autofocus + scene reconstruction + 4:3 video format). See
528
+ // `makeBaseConfiguration()` centralised so `start()` and the
529
+ // live-reconfigure paths can't drift. `start()` is the only path
530
+ // that resets tracking + removes existing anchors.
531
+ let config = makeBaseConfiguration()
491
532
  arSession.run(config, options: [.resetTracking, .removeExistingAnchors])
492
533
  // V16-diag — log the chosen video format so we can correlate
493
534
  // batch-keyframe memory with ARFrame resolution. iPhone Pro
@@ -501,10 +542,34 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
501
542
  isRunning = true
502
543
  currentTrackingState = .initialising
503
544
 
504
- // Per-frame ingest calls `incrementalConsumer.consumeFrame`
505
- // directly from `session(_:didUpdate:)` below. (The v0.8
506
- // worklet-runtime indirection that used to live here was archived
507
- // in the batch-keyframe cleanup — see archive/.)
545
+ // v0.8.0 Phase 3c/4b — wire the AR worklet runtime. The
546
+ // per-frame ingest is routed through `RNSARWorkletRuntime`
547
+ // (see `session(_:didUpdate:)` below) instead of calling the
548
+ // incremental consumer directly. Two steps here:
549
+ //
550
+ // 1. `installIfNeeded()` lazily constructs the worklet
551
+ // runtime's `JsiWorkletContext` + its serial dispatch
552
+ // queue (idempotent; safe across redundant start() calls
553
+ // and multiple <Camera> mounts).
554
+ // 2. `setFirstPartyCallback:` installs the EXISTING first-
555
+ // party stitching behaviour as a closure. The runtime
556
+ // invokes this synchronously on the delegate (caller)
557
+ // thread per frame — byte-identical to the old direct
558
+ // `consumeFrame(...)` call — and then fans the frame out
559
+ // to any host-registered worklets asynchronously on its
560
+ // own queue.
561
+ //
562
+ // The callback captures `self` weakly so the runtime singleton
563
+ // (process-lifetime) never keeps this session alive. It reads
564
+ // `incrementalConsumer` (itself weak) at call time, so a torn-
565
+ // down consumer simply no-ops — same semantics as the prior
566
+ // `incrementalConsumer?.consumeFrame(...)` optional-chain.
567
+ let workletRuntime = RNSARWorkletRuntime.shared()
568
+ workletRuntime.installIfNeeded()
569
+ workletRuntime.setFirstPartyCallback { [weak self] arFrame, pose in
570
+ self?.incrementalConsumer?.consumeFrame(
571
+ pixelBuffer: arFrame.capturedImage, pose: pose)
572
+ }
508
573
  }
509
574
 
510
575
  @objc public func stop() {
@@ -513,6 +578,12 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
513
578
  isRunning = false
514
579
  currentTrackingState = .notAvailable
515
580
  clearPoseLog()
581
+ // v0.8.0 Phase 3c — clear the worklet runtime's first-party
582
+ // callback so the (process-lifetime) runtime singleton doesn't
583
+ // hold the closure (and transitively the consumer reference)
584
+ // between captures. `start()` reinstalls it on the next run.
585
+ // Idempotent; safe even if start() never ran the install path.
586
+ RNSARWorkletRuntime.shared().setFirstPartyCallback(nil)
516
587
  // V15.0b — clear latched plane so the next capture detects
517
588
  // afresh. Plane geometry is per-capture: a different
518
589
  // fixture in a different orientation needs a new lock.
@@ -525,6 +596,174 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
525
596
  planeLatchLock.unlock()
526
597
  }
527
598
 
599
+ /// Build the `ARWorldTrackingConfiguration` shared by `start()` and
600
+ /// the live-reconfigure paths (`setSceneReconstructionEnabled`,
601
+ /// `setPlaneDetection`). Single source of truth so the call sites
602
+ /// can't drift — every reconfigure preserves the SAME depth
603
+ /// frameSemantics, current plane-detection mode, autofocus, scene
604
+ /// reconstruction, and 4:3 video-format preference. Only the `run`
605
+ /// OPTIONS differ at the call site (`start()` resets tracking; the
606
+ /// live toggles do not).
607
+ ///
608
+ /// - sceneDepth + smoothedSceneDepth: enable both when supported so
609
+ /// `ARFrame.sceneDepth` populates the StitcherFrame `arDepth` field;
610
+ /// each is gated by `supportsFrameSemantics` so this no-ops on
611
+ /// non-LiDAR devices instead of throwing at `run()`.
612
+ /// - planeDetection: from `planeDetectionOptions` (default `[.vertical]`
613
+ /// for the plane-projected stitch path; set via `setPlaneDetection`).
614
+ /// - autofocus: on, for feature tracking on shelves with small text.
615
+ /// - sceneReconstruction: from the stored `isSceneReconstructionEnabled`
616
+ /// flag (no-ops on non-LiDAR devices).
617
+ /// - videoFormat: prefer 4:3 for full sensor FOV (keyframe is
618
+ /// downscaled to `arKeyframeMaxLongEdge` later for memory parity).
619
+ private func makeBaseConfiguration() -> ARWorldTrackingConfiguration {
620
+ let config = ARWorldTrackingConfiguration()
621
+ var depthSemantics: ARConfiguration.FrameSemantics = []
622
+ if ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) {
623
+ depthSemantics.insert(.sceneDepth)
624
+ }
625
+ if ARWorldTrackingConfiguration.supportsFrameSemantics(.smoothedSceneDepth) {
626
+ depthSemantics.insert(.smoothedSceneDepth)
627
+ }
628
+ config.frameSemantics = depthSemantics
629
+ config.planeDetection = planeDetectionOptions
630
+ config.isAutoFocusEnabled = true
631
+ Self.applySceneReconstruction(to: config,
632
+ enabled: isSceneReconstructionEnabled)
633
+ if let fmt = ARWorldTrackingConfiguration.supportedVideoFormats.min(by: { a, b in
634
+ let da = abs(a.imageResolution.width / a.imageResolution.height - 4.0 / 3.0)
635
+ let db = abs(b.imageResolution.width / b.imageResolution.height - 4.0 / 3.0)
636
+ if abs(da - db) > 0.001 { return da < db }
637
+ return a.imageResolution.width * a.imageResolution.height
638
+ < b.imageResolution.width * b.imageResolution.height
639
+ }) {
640
+ config.videoFormat = fmt
641
+ }
642
+ return config
643
+ }
644
+
645
+ /// Set the ARWorldTrackingConfiguration's `sceneReconstruction`
646
+ /// from a desired-enabled flag, gated on device support. Prefers
647
+ /// `.meshWithClassification` (per-face semantic labels — what the
648
+ /// StitcherFrame `meshGeometry.classifications` consumer wants),
649
+ /// falling back to plain `.mesh`, and `.none` when disabled or
650
+ /// unsupported. Centralised so `start()` and the live-toggle path
651
+ /// stay consistent.
652
+ private static func applySceneReconstruction(
653
+ to config: ARWorldTrackingConfiguration,
654
+ enabled: Bool
655
+ ) {
656
+ guard enabled else {
657
+ // `ARWorldTrackingConfiguration.SceneReconstruction` is an
658
+ // OptionSet — the empty set `[]` means "no meshing"
659
+ // (`.none` is unavailable on OptionSets).
660
+ config.sceneReconstruction = []
661
+ return
662
+ }
663
+ if ARWorldTrackingConfiguration.supportsSceneReconstruction(
664
+ .meshWithClassification) {
665
+ config.sceneReconstruction = .meshWithClassification
666
+ } else if ARWorldTrackingConfiguration.supportsSceneReconstruction(
667
+ .mesh) {
668
+ config.sceneReconstruction = .mesh
669
+ } else {
670
+ // No LiDAR / unsupported — leave meshing off (empty set).
671
+ config.sceneReconstruction = []
672
+ }
673
+ }
674
+
675
+ /// Toggle ARKit scene reconstruction (LiDAR mesh / `ARMeshAnchor`s).
676
+ /// Stores the flag so it's honored by the next `start()`, and — if a
677
+ /// session is ALREADY running — reconfigures + re-runs the live
678
+ /// session in place so the toggle takes effect immediately.
679
+ ///
680
+ /// Reconfiguration rebuilds the SAME config `start()` builds (depth
681
+ /// frameSemantics + vertical planeDetection + autofocus + 4:3 video
682
+ /// format) so we don't accidentally drop those when flipping the
683
+ /// mesh flag. We re-run WITHOUT `[.resetTracking,
684
+ /// .removeExistingAnchors]` so the existing world map, pose log, and
685
+ /// any latched plane survive the toggle (only the mesh option
686
+ /// changes).
687
+ ///
688
+ /// No-op on devices without scene-reconstruction support: the flag
689
+ /// is still stored (cheap, harmless) but `applySceneReconstruction`
690
+ /// leaves the config `.none`.
691
+ @objc public func setSceneReconstructionEnabled(_ enabled: Bool) {
692
+ isSceneReconstructionEnabled = enabled
693
+ os_log(.fault, log: arSessionDiagLog,
694
+ "[scene-mesh] setSceneReconstructionEnabled(%{public}@) running=%{public}@ supported=%{public}@",
695
+ enabled ? "true" : "false",
696
+ isRunning ? "true" : "false",
697
+ ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh)
698
+ ? "true" : "false")
699
+
700
+ guard isRunning else { return }
701
+
702
+ // Rebuild the live config from the shared builder — picks up the
703
+ // new mesh flag (just stored) along with the current depth
704
+ // semantics + plane-detection mode, so a mesh toggle never
705
+ // silently drops those. Re-run in place — NO reset/removeAnchors
706
+ // so existing tracking, pose log, and latched plane survive.
707
+ let config = makeBaseConfiguration()
708
+ arSession.run(config)
709
+ }
710
+
711
+ /// Set which plane orientations ARKit detects — the JS `<Camera>`
712
+ /// `planeDetection` prop, routed via
713
+ /// `NativeModules.RNSARSession.setPlaneDetection(mode)`. Stores the
714
+ /// mode (honored by the next `start()`) and, if a session is live,
715
+ /// reconfigures + re-runs in place (no reset — tracking / pose log /
716
+ /// latched plane survive).
717
+ ///
718
+ /// `mode`: `"vertical"` (default), `"horizontal"`, or `"both"`.
719
+ /// Anything else falls back to `"vertical"`.
720
+ ///
721
+ /// NOTE on the legacy vertical-plane latch: the V15 plane-projected
722
+ /// stitch latches the first camera-facing VERTICAL plane. Choosing
723
+ /// `"horizontal"` drops vertical detection, so that latch won't fire
724
+ /// — an explicit opt-out (the caller asked for horizontal-only).
725
+ /// `"vertical"` and `"both"` keep it working.
726
+ @objc public func setPlaneDetection(_ mode: String) {
727
+ switch mode {
728
+ case "horizontal": planeDetectionOptions = [.horizontal]
729
+ case "both": planeDetectionOptions = [.horizontal, .vertical]
730
+ default: planeDetectionOptions = [.vertical] // "vertical" + fallback
731
+ }
732
+ os_log(.fault, log: arSessionDiagLog,
733
+ "[plane-detect] setPlaneDetection(%{public}@) running=%{public}@",
734
+ mode, isRunning ? "true" : "false")
735
+ guard isRunning else { return }
736
+ // Reconfigure in place — NO reset/removeAnchors so existing
737
+ // tracking + pose log survive the plane-mode change.
738
+ let config = makeBaseConfiguration()
739
+ arSession.run(config)
740
+ }
741
+
742
+ /// v0.18.0 — toggle the `onArFrame` LIGHT-metadata channel. Called
743
+ /// from JS (via the bridge) with `true` + the throttle interval when a
744
+ /// host supplies `<Camera onArFrame={...}>`, and `false` on
745
+ /// unmount / prop-removal. Idempotent.
746
+ ///
747
+ /// `intervalMs` clamps to a 16ms floor (≈60Hz, ARKit's max delivery
748
+ /// rate) — a smaller value can't produce more frames, and 0 would
749
+ /// disable throttling entirely (one emit per ARFrame). Resetting the
750
+ /// `lastArFrameMetaEmit` clock on enable means the first frame after
751
+ /// `onArFrame` mounts emits immediately rather than waiting out a
752
+ /// stale interval from a previous session.
753
+ @objc public func setArFrameMetaEnabled(_ enabled: Bool, intervalMs: Double) {
754
+ arFrameMetaLock.lock()
755
+ arFrameMetaEnabled = enabled
756
+ arFrameMetaIntervalSec = max(0.016, intervalMs / 1000.0)
757
+ if enabled {
758
+ // Emit the next frame immediately (don't carry a stale clock).
759
+ lastArFrameMetaEmit = 0
760
+ }
761
+ arFrameMetaLock.unlock()
762
+ os_log(.fault, log: arSessionDiagLog,
763
+ "[onArFrame] setArFrameMetaEnabled(%{public}@) interval=%.0fms",
764
+ enabled ? "true" : "false", intervalMs)
765
+ }
766
+
528
767
  /// Empty the pose log — call between captures so the next
529
768
  /// panorama starts fresh.
530
769
  @objc public func clearPoseLog() {
@@ -596,8 +835,22 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
596
835
  // cv::Mat sync conversion synchronously on the delegate thread
597
836
  // before returning, so the captured pixel buffer is safe for
598
837
  // ARKit to recycle after this call.
599
- incrementalConsumer?.consumeFrame(pixelBuffer: frame.capturedImage,
600
- pose: pose)
838
+ //
839
+ // `dispatchFrame(_:pose:)` runs the first-party callback
840
+ // (installed in `start()`, which wraps the same
841
+ // `incrementalConsumer.consumeFrame(...)` path) SYNCHRONOUSLY on
842
+ // this delegate thread — preserving the pool-reuse contract —
843
+ // and THEN fans the frame out to any host-registered worklets
844
+ // ASYNCHRONOUSLY on the runtime's own queue. Do NOT also call
845
+ // `consumeFrame` here: dispatchFrame already drives it, and
846
+ // double-consuming would ingest each frame twice.
847
+ RNSARWorkletRuntime.shared().dispatchFrame(frame, pose: pose)
848
+
849
+ // v0.18.0 — `onArFrame` LIGHT-metadata channel. Gated +
850
+ // throttled; builds the ARFrameMeta dictionary and posts it for
851
+ // the bridge to re-emit. Cheap no-op when disabled (the common
852
+ // case — most hosts don't supply `onArFrame`).
853
+ maybeEmitArFrameMeta(frame, pose: pose)
601
854
 
602
855
  // If recording is in flight, append this frame to the
603
856
  // asset writer DIRECTLY — no queue hop.
@@ -1144,6 +1397,49 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
1144
1397
  return path
1145
1398
  }
1146
1399
 
1400
+ /// v0.18.0 — build + post the LIGHT `onArFrame` metadata for this
1401
+ /// frame, gated on `arFrameMetaEnabled` and throttled to
1402
+ /// `arFrameMetaIntervalSec`. Runs on the ARSession delegate thread
1403
+ /// (the per-frame `didUpdate` path).
1404
+ ///
1405
+ /// The gate + throttle check is taken under `arFrameMetaLock` (a
1406
+ /// microsecond hold); the actual meta build (the slightly more
1407
+ /// expensive part — anchor transpose, depth/mesh probes) happens
1408
+ /// OUTSIDE the lock so the bridge's `setArFrameMetaEnabled` never
1409
+ /// blocks behind a frame build. Posting on NotificationCenter is
1410
+ /// synchronous but the observer (`RNSARSessionBridge.handle…`) just
1411
+ /// hops to the main queue for the actual JS emit, so this returns
1412
+ /// quickly and never blocks ARKit's delegate.
1413
+ ///
1414
+ /// `CACurrentMediaTime()` is the monotonic media clock (same unit as
1415
+ /// the throttle interval); immune to wall-clock adjustments.
1416
+ private func maybeEmitArFrameMeta(_ frame: ARFrame, pose: RNSARFramePose) {
1417
+ arFrameMetaLock.lock()
1418
+ let enabled = arFrameMetaEnabled
1419
+ let interval = arFrameMetaIntervalSec
1420
+ let last = lastArFrameMetaEmit
1421
+ let now = CACurrentMediaTime()
1422
+ let due = enabled && (last == 0 || (now - last) >= interval)
1423
+ if due {
1424
+ // Reserve this slot before releasing the lock so a burst of
1425
+ // delegate callbacks can't all pass the throttle gate.
1426
+ lastArFrameMetaEmit = now
1427
+ }
1428
+ arFrameMetaLock.unlock()
1429
+
1430
+ guard due else { return }
1431
+
1432
+ // Build the LIGHT meta (no pixel/vertex/face bytes). Reuses the
1433
+ // Obj-C++ extraction helpers + the shared C++ extraction-config
1434
+ // gating (depth/anchors/mesh ⇐ enableDepth/enableAnchors/enableMesh).
1435
+ let meta = CameraFrameHostObject.lightArFrameMeta(from: frame, pose: pose)
1436
+ NotificationCenter.default.post(
1437
+ name: .retailensARFrameMeta,
1438
+ object: nil,
1439
+ userInfo: meta
1440
+ )
1441
+ }
1442
+
1147
1443
  private func makePose(from frame: ARFrame) -> RNSARFramePose {
1148
1444
  // ARKit's transform is a 4x4 matrix; extract translation
1149
1445
  // (last column) and rotation (top-left 3x3 → quaternion).
@@ -0,0 +1,128 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // RNSARWorkletRuntime.h — Obj-C facade for the v0.8.0 AR-mode
4
+ // worklet runtime. Wraps `react-native-worklets-core`'s
5
+ // `RNWorklet::JsiWorkletContext` (the same primitive vision-camera
6
+ // uses for its Frame Processor runtime) so the lib can dispatch
7
+ // per-ARFrame worklets on a thread we own — rather than ARKit's
8
+ // delegate queue, where doing significant work would block the
9
+ // AR session's update loop.
10
+ //
11
+ // ## Phase 3b scope (this commit)
12
+ //
13
+ // Owns:
14
+ // - The dispatch queue the worklet runtime pins to.
15
+ // - The underlying `JsiWorkletContext` (constructed lazily on
16
+ // `installIfNeeded`, lives for the singleton's lifetime).
17
+ //
18
+ // Exposes:
19
+ // - `+ shared` singleton accessor.
20
+ // - `- installIfNeeded` (idempotent runtime construction).
21
+ // - `- isInstalled` for diagnostics + tests.
22
+ // - `- dispatchFrame:pose:` — currently a no-op stub; Phase 3c
23
+ // fills in the actual host-object construction + worklet
24
+ // invocation + first-party stitching dispatch.
25
+ //
26
+ // Host-worklet registry is intentionally NOT in Phase 3b — Phase 4
27
+ // lands the JSI plugin + TS-side hook that defines the storage
28
+ // shape (NSMutableArray of boxed shared_ptrs vs a C++ vector ivar
29
+ // vs something else). Pre-committing the storage type here would
30
+ // risk rework. See
31
+ // `docs/plans/handoff/2026-05-26-v0.8.0-phases-2-5-implementation-guide.md`
32
+ // Phase 4 section for the planned API.
33
+ //
34
+ // ## Why Obj-C facade with `.mm` implementation
35
+ //
36
+ // The implementation needs to hold `std::shared_ptr<JsiWorkletContext>`
37
+ // + run JSI value construction, which can't live in pure Swift. Same
38
+ // pattern as `KeyframeGateBridge.{h,mm}` + `CameraFrameHostObject.{h,mm}`:
39
+ // keep the header umbrella-safe (no JSI imports), put the C++ glue in
40
+ // the .mm.
41
+ //
42
+ // ## Header umbrella safety
43
+ //
44
+ // This .h imports only Foundation + ARKit (both system frameworks).
45
+ // Worklets-core types are confined to the .mm.
46
+
47
+ #pragma once
48
+
49
+ #import <Foundation/Foundation.h>
50
+ #import <ARKit/ARKit.h>
51
+
52
+ @class RNSARFramePose;
53
+
54
+ NS_ASSUME_NONNULL_BEGIN
55
+
56
+ NS_SWIFT_NAME(RNSARWorkletRuntime)
57
+ @interface RNSARWorkletRuntime : NSObject
58
+
59
+ /// Singleton accessor. One AR worklet runtime per process; multiple
60
+ /// `<Camera>` mounts share it. Construction is cheap (just an Obj-C
61
+ /// alloc + an `NSMutableArray`); the heavy JSI work happens in
62
+ /// `-installIfNeeded`.
63
+ + (instancetype)shared;
64
+
65
+ /// Construct the underlying `JsiWorkletContext` if not yet
66
+ /// installed. Idempotent — repeated calls are no-ops. Called from
67
+ /// `RNSARSession` at AR-mode start time (Phase 3c will wire this
68
+ /// up; Phase 3b ships the method but no one calls it yet).
69
+ ///
70
+ /// Threading: safe to call from any thread; internally serialised.
71
+ /// The runtime's own dispatch queue starts running once installed.
72
+ - (void)installIfNeeded;
73
+
74
+ /// Diagnostics + tests. Returns `YES` after a successful
75
+ /// `-installIfNeeded`.
76
+ - (BOOL)isInstalled;
77
+
78
+ /// Phase 3c — type of the first-party stitching callback. Invoked
79
+ /// synchronously on the caller thread (`ARSession.delegateQueue` —
80
+ /// typically main queue today) per AR frame. Block must consume
81
+ /// the pixel buffer before returning (ARKit pool reuse contract).
82
+ typedef void (^RNSARFirstPartyCallback)(ARFrame *arFrame,
83
+ RNSARFramePose *pose);
84
+
85
+ /// Phase 3c — install the closure that takes ownership of the
86
+ /// per-frame first-party stitching dispatch. Called from
87
+ /// `RNSARSession.start()` after the incremental consumer is set;
88
+ /// the block then routes `dispatchFrame:pose:` calls through to
89
+ /// the existing `incrementalConsumer.consumeFrame(...)` path.
90
+ ///
91
+ /// Pre-Phase-3c the delegate called the consumer directly. After
92
+ /// Phase 3c the delegate calls `dispatchFrame:pose:` (this class)
93
+ /// which invokes the callback. Net behavior is byte-identical;
94
+ /// the indirection sets up the seam where Phase 4 will fan out to
95
+ /// host worklets without changing the first-party path.
96
+ ///
97
+ /// Pass `nil` to clear (e.g. on `RNSARSession.stop()`). Idempotent.
98
+ - (void)setFirstPartyCallback:(nullable RNSARFirstPartyCallback)callback;
99
+
100
+ /// Dispatch one AR frame through the registered worklets. Called
101
+ /// per `ARFrame` by `RNSARSession.delegate` once Phase 3c lands the
102
+ /// migration (Phase 3b ships this method as a no-op stub so the
103
+ /// runtime can be built + linked + the API surface fixed).
104
+ ///
105
+ /// The Phase 3c implementation will:
106
+ /// 1. Build a `CameraFrameHostObject` from `arFrame` + `pose`.
107
+ /// 2. Run the first-party stitching synchronously on the caller
108
+ /// thread (preserves today's `ingestFromARCameraView` cost
109
+ /// envelope at the producer site).
110
+ /// 3. If any host worklets are registered, dispatch the host
111
+ /// object onto the worklet runtime's thread + invoke each
112
+ /// worklet via `RNWorklet::WorkletInvoker::call`.
113
+ /// 4. Invalidate the host object after all worklets return.
114
+ ///
115
+ /// Phase 3c gate: install/idempotence tests + this method's
116
+ /// integration test required before merge. See
117
+ /// `docs/plans/handoff/2026-05-26-v0.8.0-phases-2-5-implementation-guide.md`
118
+ /// Phase 3c gate criteria.
119
+ ///
120
+ /// Threading: typically called from `ARSession.delegateQueue` (main
121
+ /// queue by default; Phase 3c will pin it explicitly to a
122
+ /// dedicated queue).
123
+ - (void)dispatchFrame:(ARFrame *)arFrame pose:(RNSARFramePose *)pose
124
+ NS_SWIFT_NAME(dispatchFrame(_:pose:));
125
+
126
+ @end
127
+
128
+ NS_ASSUME_NONNULL_END