react-native-image-stitcher 0.17.0 → 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.
- package/CHANGELOG.md +121 -0
- package/RNImageStitcher.podspec +1 -1
- package/android/src/main/cpp/CMakeLists.txt +4 -4
- package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +216 -7
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +656 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +156 -0
- package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +1 -1
- package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +84 -2
- package/cpp/{stitcher_frame_data.hpp → camera_frame_data.hpp} +96 -13
- package/cpp/{stitcher_frame_jsi.cpp → camera_frame_jsi.cpp} +154 -11
- package/cpp/{stitcher_frame_jsi.hpp → camera_frame_jsi.hpp} +12 -12
- package/cpp/stitcher_proxy_jsi.cpp +31 -0
- package/cpp/stitcher_proxy_jsi.hpp +16 -0
- package/cpp/stitcher_worklet_dispatch.cpp +5 -5
- package/cpp/stitcher_worklet_dispatch.hpp +5 -5
- package/dist/camera/ARCameraView.d.ts +60 -3
- package/dist/camera/ARCameraView.js +68 -1
- package/dist/camera/Camera.d.ts +54 -7
- package/dist/camera/Camera.js +2 -2
- package/dist/index.d.ts +2 -1
- package/dist/stitching/ARFrameMeta.d.ts +100 -0
- package/dist/stitching/{StitcherFrame.js → ARFrameMeta.js} +1 -1
- package/dist/stitching/{StitcherFrame.d.ts → CameraFrame.d.ts} +70 -11
- package/dist/stitching/CameraFrame.js +4 -0
- package/dist/stitching/useStitcherWorklet.d.ts +4 -4
- package/dist/stitching/useStitcherWorklet.js +4 -4
- package/ios/Sources/RNImageStitcher/ARSessionBridge.m +23 -1
- package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +137 -2
- package/ios/Sources/RNImageStitcher/{StitcherFrameHostObject.h → CameraFrameHostObject.h} +26 -3
- package/ios/Sources/RNImageStitcher/CameraFrameHostObject.mm +760 -0
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +292 -34
- package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +2 -2
- package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +4 -4
- package/package.json +1 -1
- package/src/camera/ARCameraView.tsx +165 -5
- package/src/camera/Camera.tsx +69 -7
- package/src/index.ts +7 -3
- package/src/stitching/ARFrameMeta.ts +107 -0
- package/src/stitching/{StitcherFrame.ts → CameraFrame.ts} +79 -11
- package/src/stitching/useStitcherWorklet.ts +9 -9
- 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,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
|
-
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
|
|
462
|
-
|
|
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
|
|
@@ -555,6 +596,174 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
|
|
|
555
596
|
planeLatchLock.unlock()
|
|
556
597
|
}
|
|
557
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
|
+
|
|
558
767
|
/// Empty the pose log — call between captures so the next
|
|
559
768
|
/// panorama starts fresh.
|
|
560
769
|
@objc public func clearPoseLog() {
|
|
@@ -637,6 +846,12 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
|
|
|
637
846
|
// double-consuming would ingest each frame twice.
|
|
638
847
|
RNSARWorkletRuntime.shared().dispatchFrame(frame, pose: pose)
|
|
639
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)
|
|
854
|
+
|
|
640
855
|
// If recording is in flight, append this frame to the
|
|
641
856
|
// asset writer DIRECTLY — no queue hop.
|
|
642
857
|
//
|
|
@@ -1182,6 +1397,49 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
|
|
|
1182
1397
|
return path
|
|
1183
1398
|
}
|
|
1184
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
|
+
|
|
1185
1443
|
private func makePose(from frame: ARFrame) -> RNSARFramePose {
|
|
1186
1444
|
// ARKit's transform is a 4x4 matrix; extract translation
|
|
1187
1445
|
// (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}` + `
|
|
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 `
|
|
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 "
|
|
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
|
-
//
|
|
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
|
-
|
|
214
|
-
[
|
|
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.
|
|
3
|
+
"version": "0.18.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",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
import React, { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
|
34
34
|
import {
|
|
35
|
+
NativeEventEmitter,
|
|
35
36
|
NativeModules,
|
|
36
37
|
Platform,
|
|
37
38
|
StyleSheet,
|
|
@@ -42,7 +43,8 @@ import {
|
|
|
42
43
|
} from 'react-native';
|
|
43
44
|
|
|
44
45
|
import { ensureStitcherProxyInstalled } from '../stitching/ensureStitcherProxyInstalled';
|
|
45
|
-
import type {
|
|
46
|
+
import type { CameraFrameProcessor } from '../stitching/CameraFrame';
|
|
47
|
+
import type { ARFrameMeta } from '../stitching/ARFrameMeta';
|
|
46
48
|
|
|
47
49
|
|
|
48
50
|
// React Native looks up the component by its NATIVE name.
|
|
@@ -69,7 +71,7 @@ export interface ARCameraViewProps {
|
|
|
69
71
|
/**
|
|
70
72
|
* Optional host worklet invoked once per AR frame, ALONGSIDE the
|
|
71
73
|
* lib's first-party stitching (composition, not replacement). The
|
|
72
|
-
* worklet receives a `
|
|
74
|
+
* worklet receives a `CameraFrame` enriched with AR metadata —
|
|
73
75
|
* `source: 'ar'`, world-space `pose` (rotation + translation),
|
|
74
76
|
* `arTrackingState`, and (when supported) `arDepth` / `arAnchors`.
|
|
75
77
|
*
|
|
@@ -84,7 +86,65 @@ export interface ARCameraViewProps {
|
|
|
84
86
|
* different runtimes with different frame shapes, hence the separate
|
|
85
87
|
* prop.
|
|
86
88
|
*/
|
|
87
|
-
arFrameProcessor?:
|
|
89
|
+
arFrameProcessor?: CameraFrameProcessor;
|
|
90
|
+
/**
|
|
91
|
+
* Opt in to per-frame AR depth extraction (`CameraFrame.arDepth`).
|
|
92
|
+
* Default `false` — depth is the costliest field (a per-frame buffer
|
|
93
|
+
* copy), so it stays off until a worklet needs it.
|
|
94
|
+
*/
|
|
95
|
+
enableDepth?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Opt in to per-frame AR anchor extraction (`CameraFrame.arAnchors` —
|
|
98
|
+
* detected planes / augmented images). Default `false`.
|
|
99
|
+
*/
|
|
100
|
+
enableAnchors?: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Opt in to scene-reconstruction mesh anchors (`type: 'mesh'` entries
|
|
103
|
+
* in `arAnchors`, carrying `meshGeometry`). Default `false`. iOS
|
|
104
|
+
* enables ARKit `sceneReconstruction` (LiDAR devices); Android
|
|
105
|
+
* reconstructs a rough mesh from the depth map. Expensive — only on
|
|
106
|
+
* when needed. Implies depth on Android.
|
|
107
|
+
*/
|
|
108
|
+
enableMesh?: boolean;
|
|
109
|
+
/**
|
|
110
|
+
* Which plane orientations to surface in `arAnchors` (requires
|
|
111
|
+
* `enableAnchors`). Default `'vertical'` — the orientation the
|
|
112
|
+
* plane-projected stitch path has always used, so existing callers
|
|
113
|
+
* see no change.
|
|
114
|
+
*
|
|
115
|
+
* - `'vertical'` — walls / doors / fixtures (the default)
|
|
116
|
+
* - `'horizontal'` — floors / tables / seats
|
|
117
|
+
* - `'both'` — surface every detected plane
|
|
118
|
+
*
|
|
119
|
+
* Platform notes: iOS changes ARKit `planeDetection` to match (a
|
|
120
|
+
* live session reconfigure). Android always detects both planes
|
|
121
|
+
* (ARCore needs horizontal planes to bootstrap tracking) and simply
|
|
122
|
+
* FILTERS which orientations reach `arAnchors`, so the JS-observable
|
|
123
|
+
* set is identical on both platforms.
|
|
124
|
+
*/
|
|
125
|
+
planeDetection?: 'vertical' | 'horizontal' | 'both';
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* v0.18.0 — LIGHT per-frame AR metadata callback, invoked on the JS
|
|
129
|
+
* MAIN thread (NOT a worklet). When provided, the native AR session
|
|
130
|
+
* builds an {@link ARFrameMeta} per frame and emits it as a device
|
|
131
|
+
* event; this component subscribes and calls the handler. Worklet-free
|
|
132
|
+
* — this is the recommended way to read AR pose / tracking / anchor /
|
|
133
|
+
* intrinsics / depth-dims / mesh-counts data (the `arFrameProcessor`
|
|
134
|
+
* worklet can only safely surface a shared value; see `ARFrameMeta`).
|
|
135
|
+
*
|
|
136
|
+
* Costly fields are gated: `depth` only when `enableDepth`, `mesh` only
|
|
137
|
+
* when `enableMesh`, `anchors` only when `enableAnchors`;
|
|
138
|
+
* `intrinsics` / `pose` / `trackingState` are always present. Emission
|
|
139
|
+
* is throttled to {@link arFrameMetaInterval} ms.
|
|
140
|
+
*/
|
|
141
|
+
onArFrame?: (meta: ARFrameMeta) => void;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* v0.18.0 — throttle interval (ms) for {@link onArFrame}. Default `100`
|
|
145
|
+
* (≈ 10 Hz). No effect unless `onArFrame` is provided.
|
|
146
|
+
*/
|
|
147
|
+
arFrameMetaInterval?: number;
|
|
88
148
|
}
|
|
89
149
|
|
|
90
150
|
|
|
@@ -167,7 +227,17 @@ type RecordingCallbacks = {
|
|
|
167
227
|
|
|
168
228
|
export const ARCameraView = forwardRef<ARCameraViewHandle, ARCameraViewProps>(
|
|
169
229
|
function ARCameraView(
|
|
170
|
-
{
|
|
230
|
+
{
|
|
231
|
+
style,
|
|
232
|
+
guidance,
|
|
233
|
+
arFrameProcessor,
|
|
234
|
+
enableDepth,
|
|
235
|
+
enableAnchors,
|
|
236
|
+
enableMesh,
|
|
237
|
+
planeDetection,
|
|
238
|
+
onArFrame,
|
|
239
|
+
arFrameMetaInterval,
|
|
240
|
+
},
|
|
171
241
|
ref,
|
|
172
242
|
): React.JSX.Element {
|
|
173
243
|
// Held across the start→stop lifecycle so stopRecording's
|
|
@@ -189,7 +259,7 @@ export const ARCameraView = forwardRef<ARCameraViewHandle, ARCameraViewProps>(
|
|
|
189
259
|
}
|
|
190
260
|
const proxy = (globalThis as {
|
|
191
261
|
__stitcherProxy?: {
|
|
192
|
-
install(fn:
|
|
262
|
+
install(fn: CameraFrameProcessor): string;
|
|
193
263
|
uninstall(id: string): void;
|
|
194
264
|
};
|
|
195
265
|
}).__stitcherProxy;
|
|
@@ -202,6 +272,96 @@ export const ARCameraView = forwardRef<ARCameraViewHandle, ARCameraViewProps>(
|
|
|
202
272
|
};
|
|
203
273
|
}, [arFrameProcessor]);
|
|
204
274
|
|
|
275
|
+
// Push the AR-metadata extraction config to native — gates the
|
|
276
|
+
// costly per-frame depth / anchor / mesh work (all off by default).
|
|
277
|
+
// Routed through `__stitcherProxy.setExtractionConfig`, read by the
|
|
278
|
+
// platform AR extraction. iOS ADDITIONALLY toggles ARKit
|
|
279
|
+
// `sceneReconstruction` for mesh (a session-config change, not a
|
|
280
|
+
// per-frame gate); Android reconstructs mesh from the depth map and
|
|
281
|
+
// needs no session change.
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
const depth = enableDepth === true;
|
|
284
|
+
const anchors = enableAnchors === true;
|
|
285
|
+
const mesh = enableMesh === true;
|
|
286
|
+
if (ensureStitcherProxyInstalled()) {
|
|
287
|
+
(globalThis as {
|
|
288
|
+
__stitcherProxy?: {
|
|
289
|
+
setExtractionConfig?(d: boolean, a: boolean, m: boolean): void;
|
|
290
|
+
};
|
|
291
|
+
}).__stitcherProxy?.setExtractionConfig?.(depth, anchors, mesh);
|
|
292
|
+
}
|
|
293
|
+
if (Platform.OS === 'ios') {
|
|
294
|
+
const session = (NativeModules as Record<string, unknown>)
|
|
295
|
+
.RNSARSession as
|
|
296
|
+
| { setSceneReconstructionEnabled?(on: boolean): void }
|
|
297
|
+
| undefined;
|
|
298
|
+
session?.setSceneReconstructionEnabled?.(mesh);
|
|
299
|
+
}
|
|
300
|
+
}, [enableDepth, enableAnchors, enableMesh]);
|
|
301
|
+
|
|
302
|
+
// Push the plane-detection mode to native. Unlike the extraction
|
|
303
|
+
// config above this is a SESSION setting, so it routes through the
|
|
304
|
+
// RNSARSession native module on BOTH platforms (iOS reconfigures
|
|
305
|
+
// ARKit `planeDetection`; Android stores an emission filter — see
|
|
306
|
+
// the prop docs). Defaults to `'vertical'` to preserve the
|
|
307
|
+
// plane-projected stitch path's long-standing behaviour.
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
const mode = planeDetection ?? 'vertical';
|
|
310
|
+
const session = (NativeModules as Record<string, unknown>)
|
|
311
|
+
.RNSARSession as
|
|
312
|
+
| { setPlaneDetection?(mode: string): void }
|
|
313
|
+
| undefined;
|
|
314
|
+
session?.setPlaneDetection?.(mode);
|
|
315
|
+
}, [planeDetection]);
|
|
316
|
+
|
|
317
|
+
// v0.18.0 — onArFrame device-event wiring (worklet-free, main thread).
|
|
318
|
+
//
|
|
319
|
+
// The latest `onArFrame` is held in a ref so the subscription effect
|
|
320
|
+
// depends only on whether a handler is present + the interval — NOT on
|
|
321
|
+
// the handler's identity (which typically changes every render). This
|
|
322
|
+
// avoids tearing down + re-establishing the native event subscription
|
|
323
|
+
// (and the costly `setArFrameMetaEnabled(true)` extraction toggle) on
|
|
324
|
+
// every parent re-render.
|
|
325
|
+
const onArFrameRef = useRef<((meta: ARFrameMeta) => void) | undefined>(
|
|
326
|
+
onArFrame,
|
|
327
|
+
);
|
|
328
|
+
useEffect(() => {
|
|
329
|
+
onArFrameRef.current = onArFrame;
|
|
330
|
+
}, [onArFrame]);
|
|
331
|
+
|
|
332
|
+
const arFrameEnabled = onArFrame != null;
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
if (!arFrameEnabled) {
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
const session = (NativeModules as Record<string, unknown>)
|
|
338
|
+
.RNSARSession as
|
|
339
|
+
| {
|
|
340
|
+
setArFrameMetaEnabled?(enabled: boolean, intervalMs: number): void;
|
|
341
|
+
}
|
|
342
|
+
| undefined;
|
|
343
|
+
if (session?.setArFrameMetaEnabled == null) {
|
|
344
|
+
// Native module / method unavailable (e.g. web, or a native build
|
|
345
|
+
// predating the event channel): no-op, no crash.
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
const intervalMs = arFrameMetaInterval ?? 100;
|
|
349
|
+
session.setArFrameMetaEnabled(true, intervalMs);
|
|
350
|
+
const emitter = new NativeEventEmitter(
|
|
351
|
+
NativeModules.RNSARSession as never,
|
|
352
|
+
);
|
|
353
|
+
const sub = emitter.addListener(
|
|
354
|
+
'RNImageStitcherARFrame',
|
|
355
|
+
(meta: ARFrameMeta) => {
|
|
356
|
+
onArFrameRef.current?.(meta);
|
|
357
|
+
},
|
|
358
|
+
);
|
|
359
|
+
return () => {
|
|
360
|
+
sub.remove();
|
|
361
|
+
session.setArFrameMetaEnabled?.(false, intervalMs);
|
|
362
|
+
};
|
|
363
|
+
}, [arFrameEnabled, arFrameMetaInterval]);
|
|
364
|
+
|
|
205
365
|
useImperativeHandle(ref, () => ({
|
|
206
366
|
takePhoto: async (options = {}) => {
|
|
207
367
|
const native: any =
|