react-native-image-stitcher 0.1.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 (151) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +21 -0
  4. package/README.md +189 -0
  5. package/RNImageStitcher.podspec +76 -0
  6. package/android/build.gradle +224 -0
  7. package/android/src/main/AndroidManifest.xml +3 -0
  8. package/android/src/main/cpp/CMakeLists.txt +124 -0
  9. package/android/src/main/cpp/image_stitcher_jni.cpp +145 -0
  10. package/android/src/main/cpp/keyframe_gate_jni.cpp +204 -0
  11. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +426 -0
  12. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +960 -0
  13. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +2371 -0
  14. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +256 -0
  15. package/android/src/main/java/io/imagestitcher/rn/QualityChecker.kt +167 -0
  16. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +39 -0
  17. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +558 -0
  18. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +35 -0
  19. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +784 -0
  20. package/android/src/main/java/io/imagestitcher/rn/ar/BackgroundRenderer.kt +176 -0
  21. package/android/src/main/java/io/imagestitcher/rn/ar/ShaderUtil.kt +67 -0
  22. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +201 -0
  23. package/cpp/ar_frame_pose.h +63 -0
  24. package/cpp/keyframe_gate.cpp +927 -0
  25. package/cpp/keyframe_gate.hpp +240 -0
  26. package/cpp/stitcher.cpp +2207 -0
  27. package/cpp/stitcher.hpp +275 -0
  28. package/dist/ar/useARSession.d.ts +102 -0
  29. package/dist/ar/useARSession.js +133 -0
  30. package/dist/camera/ARCameraView.d.ts +93 -0
  31. package/dist/camera/ARCameraView.js +170 -0
  32. package/dist/camera/Camera.d.ts +134 -0
  33. package/dist/camera/Camera.js +688 -0
  34. package/dist/camera/CameraShutter.d.ts +80 -0
  35. package/dist/camera/CameraShutter.js +237 -0
  36. package/dist/camera/CameraView.d.ts +65 -0
  37. package/dist/camera/CameraView.js +117 -0
  38. package/dist/camera/CaptureControlsBar.d.ts +87 -0
  39. package/dist/camera/CaptureControlsBar.js +82 -0
  40. package/dist/camera/CaptureHeader.d.ts +62 -0
  41. package/dist/camera/CaptureHeader.js +81 -0
  42. package/dist/camera/CapturePreview.d.ts +70 -0
  43. package/dist/camera/CapturePreview.js +188 -0
  44. package/dist/camera/CaptureStatusOverlay.d.ts +75 -0
  45. package/dist/camera/CaptureStatusOverlay.js +326 -0
  46. package/dist/camera/CaptureThumbnailStrip.d.ts +87 -0
  47. package/dist/camera/CaptureThumbnailStrip.js +177 -0
  48. package/dist/camera/IncrementalPanGuide.d.ts +83 -0
  49. package/dist/camera/IncrementalPanGuide.js +267 -0
  50. package/dist/camera/PanoramaBandOverlay.d.ts +107 -0
  51. package/dist/camera/PanoramaBandOverlay.js +399 -0
  52. package/dist/camera/PanoramaConfirmModal.d.ts +57 -0
  53. package/dist/camera/PanoramaConfirmModal.js +128 -0
  54. package/dist/camera/PanoramaGuidance.d.ts +79 -0
  55. package/dist/camera/PanoramaGuidance.js +246 -0
  56. package/dist/camera/PanoramaSettingsModal.d.ts +311 -0
  57. package/dist/camera/PanoramaSettingsModal.js +611 -0
  58. package/dist/camera/ViewportCropOverlay.d.ts +46 -0
  59. package/dist/camera/ViewportCropOverlay.js +67 -0
  60. package/dist/camera/useCapture.d.ts +111 -0
  61. package/dist/camera/useCapture.js +160 -0
  62. package/dist/camera/useDeviceOrientation.d.ts +48 -0
  63. package/dist/camera/useDeviceOrientation.js +131 -0
  64. package/dist/camera/useVideoCapture.d.ts +79 -0
  65. package/dist/camera/useVideoCapture.js +151 -0
  66. package/dist/index.d.ts +26 -0
  67. package/dist/index.js +39 -0
  68. package/dist/quality/normaliseOrientation.d.ts +36 -0
  69. package/dist/quality/normaliseOrientation.js +62 -0
  70. package/dist/quality/runQualityCheck.d.ts +41 -0
  71. package/dist/quality/runQualityCheck.js +98 -0
  72. package/dist/sensors/useIMUTranslationGate.d.ts +70 -0
  73. package/dist/sensors/useIMUTranslationGate.js +235 -0
  74. package/dist/stitching/IncrementalStitcherView.d.ts +41 -0
  75. package/dist/stitching/IncrementalStitcherView.js +157 -0
  76. package/dist/stitching/incremental.d.ts +930 -0
  77. package/dist/stitching/incremental.js +133 -0
  78. package/dist/stitching/stitchFrames.d.ts +55 -0
  79. package/dist/stitching/stitchFrames.js +56 -0
  80. package/dist/stitching/stitchVideo.d.ts +119 -0
  81. package/dist/stitching/stitchVideo.js +57 -0
  82. package/dist/stitching/useIncrementalJSDriver.d.ts +74 -0
  83. package/dist/stitching/useIncrementalJSDriver.js +199 -0
  84. package/dist/stitching/useIncrementalStitcher.d.ts +58 -0
  85. package/dist/stitching/useIncrementalStitcher.js +172 -0
  86. package/dist/types.d.ts +58 -0
  87. package/dist/types.js +15 -0
  88. package/ios/Package.swift +72 -0
  89. package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +33 -0
  90. package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +40 -0
  91. package/ios/Sources/RNImageStitcher/ARSessionBridge.m +55 -0
  92. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +149 -0
  93. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +2727 -0
  94. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +85 -0
  95. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +625 -0
  96. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +328 -0
  97. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +141 -0
  98. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +278 -0
  99. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +473 -0
  100. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +1326 -0
  101. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +97 -0
  102. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +296 -0
  103. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +103 -0
  104. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +3285 -0
  105. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +238 -0
  106. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +1880 -0
  107. package/ios/Sources/RNImageStitcher/QualityChecker.swift +252 -0
  108. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.m +26 -0
  109. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.swift +72 -0
  110. package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +114 -0
  111. package/ios/Sources/RNImageStitcher/RNSARSession.swift +1111 -0
  112. package/ios/Sources/RNImageStitcher/Stitcher.swift +243 -0
  113. package/ios/Sources/RNImageStitcher/StitcherBridge.m +28 -0
  114. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +246 -0
  115. package/package.json +73 -0
  116. package/react-native.config.js +34 -0
  117. package/scripts/opencv-version.txt +1 -0
  118. package/scripts/postinstall-fetch-binaries.js +286 -0
  119. package/src/ar/useARSession.ts +210 -0
  120. package/src/camera/.gitkeep +0 -0
  121. package/src/camera/ARCameraView.tsx +256 -0
  122. package/src/camera/Camera.tsx +1053 -0
  123. package/src/camera/CameraShutter.tsx +292 -0
  124. package/src/camera/CameraView.tsx +157 -0
  125. package/src/camera/CaptureControlsBar.tsx +204 -0
  126. package/src/camera/CaptureHeader.tsx +184 -0
  127. package/src/camera/CapturePreview.tsx +318 -0
  128. package/src/camera/CaptureStatusOverlay.tsx +391 -0
  129. package/src/camera/CaptureThumbnailStrip.tsx +277 -0
  130. package/src/camera/IncrementalPanGuide.tsx +328 -0
  131. package/src/camera/PanoramaBandOverlay.tsx +498 -0
  132. package/src/camera/PanoramaConfirmModal.tsx +206 -0
  133. package/src/camera/PanoramaGuidance.tsx +327 -0
  134. package/src/camera/PanoramaSettingsModal.tsx +1357 -0
  135. package/src/camera/ViewportCropOverlay.tsx +81 -0
  136. package/src/camera/useCapture.ts +279 -0
  137. package/src/camera/useDeviceOrientation.ts +140 -0
  138. package/src/camera/useVideoCapture.ts +236 -0
  139. package/src/index.ts +53 -0
  140. package/src/quality/.gitkeep +0 -0
  141. package/src/quality/normaliseOrientation.ts +79 -0
  142. package/src/quality/runQualityCheck.ts +131 -0
  143. package/src/sensors/useIMUTranslationGate.ts +347 -0
  144. package/src/stitching/.gitkeep +0 -0
  145. package/src/stitching/IncrementalStitcherView.tsx +198 -0
  146. package/src/stitching/incremental.ts +1021 -0
  147. package/src/stitching/stitchFrames.ts +88 -0
  148. package/src/stitching/stitchVideo.ts +153 -0
  149. package/src/stitching/useIncrementalJSDriver.ts +273 -0
  150. package/src/stitching/useIncrementalStitcher.ts +252 -0
  151. package/src/types.ts +78 -0
@@ -0,0 +1,625 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // IncrementalStitcherBridge — RN bridge for the live panorama engine.
4
+ //
5
+ // Why this is an RCTEventEmitter (not a plain NSObject like the
6
+ // other bridges):
7
+ // The engine emits a state update for every ARFrame the AR session
8
+ // delivers (~60 Hz, mostly skipped before any work runs). JS
9
+ // needs to receive these as device events so the live preview UI
10
+ // can update without polling. RCTEventEmitter is the standard
11
+ // React Native pattern; subclassing it is a one-time investment
12
+ // that buys clean event-driven UX with no polling overhead.
13
+ //
14
+ // JS-visible module name: `IncrementalStitcher`. Mapped via
15
+ // `RCT_EXTERN_REMAP_MODULE` in IncrementalStitcherBridge.m so the
16
+ // JS-facing name stays stable while the bridge class itself can be
17
+ // renamed without touching JS.
18
+
19
+ #if canImport(React)
20
+ import Foundation
21
+ import React
22
+ import os.log
23
+ import ImageIO // CGImageSource + kCGImagePropertyOrientation for EXIF read in processFrameAtPath
24
+
25
+ @objc(IncrementalStitcherBridge)
26
+ public final class IncrementalStitcherBridge: RCTEventEmitter {
27
+
28
+ /// Whether at least one JS listener is attached. RN's
29
+ /// EventEmitter contract: don't emit when no listeners are
30
+ /// registered (the events would be dropped with a console warning).
31
+ private var hasListeners: Bool = false
32
+
33
+ private static let stateUpdateEvent = "IncrementalStateUpdate"
34
+
35
+ public override init() {
36
+ super.init()
37
+ // Subscribe once at construction. The handler self-checks
38
+ // `hasListeners` before forwarding, so we don't have to
39
+ // unsubscribe / resubscribe on every JS listener attach/detach.
40
+ NotificationCenter.default.addObserver(
41
+ self,
42
+ selector: #selector(handleStateUpdate(_:)),
43
+ name: .retailensIncrementalStateUpdate,
44
+ object: nil
45
+ )
46
+ }
47
+
48
+ deinit {
49
+ NotificationCenter.default.removeObserver(self)
50
+ }
51
+
52
+ // MARK: - RCTEventEmitter protocol
53
+
54
+ public override class func requiresMainQueueSetup() -> Bool {
55
+ return false
56
+ }
57
+
58
+ public override func supportedEvents() -> [String]! {
59
+ return [Self.stateUpdateEvent]
60
+ }
61
+
62
+ // (startObserving / stopObserving moved next to handleStateUpdate
63
+ // for the PiP investigation; remove this comment after.)
64
+
65
+ // MARK: - Module methods
66
+
67
+ /// `options` (all optional, sensible defaults documented in
68
+ /// the .h file):
69
+ /// - composeWidth, composeHeight (Int)
70
+ /// - canvasWidth, canvasHeight (Int)
71
+ /// - featherPx (Int)
72
+ /// - snapshotJpegQuality (Int, default 75)
73
+ /// - snapshotEveryNAccepts (Int, default 1)
74
+ ///
75
+ /// Resolves with `{ ok: true }`. Rejects when `frameSourceMode`
76
+ /// (options dict) is 'arSession' (the default) AND the AR session
77
+ /// isn't running — that path needs ARKit to deliver frames.
78
+ /// When `frameSourceMode` is 'jsDriver' the AR-session check is
79
+ /// skipped and the engine expects JS to feed frames via
80
+ /// `processFrameAtPath` (used by iOS non-AR captures since
81
+ /// 2026-05-18 / Issue #2 regression fix).
82
+ @objc(start:resolver:rejecter:)
83
+ public func start(
84
+ options: NSDictionary,
85
+ resolver: @escaping RCTPromiseResolveBlock,
86
+ rejecter: @escaping RCTPromiseRejectBlock
87
+ ) {
88
+ let frameSourceMode =
89
+ (options["frameSourceMode"] as? String) ?? "arSession"
90
+ if frameSourceMode == "arSession" {
91
+ guard RNSARSession.shared.isRunning else {
92
+ rejecter(
93
+ "ar-session-not-running",
94
+ "RNSARSession.start() must be called before "
95
+ + "the incremental stitcher.",
96
+ nil
97
+ )
98
+ return
99
+ }
100
+ }
101
+ let composeW = (options["composeWidth"] as? Int) ?? 0
102
+ let composeH = (options["composeHeight"] as? Int) ?? 0
103
+ let canvasW = (options["canvasWidth"] as? Int) ?? 0
104
+ let canvasH = (options["canvasHeight"] as? Int) ?? 0
105
+ let feather = (options["featherPx"] as? Int) ?? 0
106
+ let snapQ = (options["snapshotJpegQuality"] as? Int) ?? 75
107
+ let snapN = (options["snapshotEveryNAccepts"] as? Int) ?? 1
108
+ let rotation = (options["frameRotationDegrees"] as? Int) ?? 90
109
+ // AR-STITCHING-TWO-MODES — see memory/ar-stitching-two-modes.md
110
+ //
111
+ // Capture orientation classifies the user's phone-hold at
112
+ // start() time, sourced from the JS-side accelerometer hook
113
+ // `useDeviceOrientation`. Drives the OUTPUT bake-rotation in
114
+ // OpenCVStitcher.stitchFramePaths. Distinct from `rotation`
115
+ // (frameRotationDegrees) above: rotation collapses both
116
+ // landscape variants to 0°, losing the left/right
117
+ // distinction we need to mirror-rotate the output correctly.
118
+ //
119
+ // Default 'portrait' matches the historical Mode B start
120
+ // state. Unknown values are passed through verbatim; the
121
+ // .mm side falls back to no rotation on anything outside the
122
+ // four supported labels.
123
+ let captureOrientation =
124
+ (options["captureOrientation"] as? String) ?? "portrait"
125
+ // Diagnostic: trace the value as received from JS, before
126
+ // any downstream layer touches it. os_log %{public}@ to
127
+ // bypass iOS log redaction. Logs BOTH captureOrientation
128
+ // (the new field) and frameRotationDegrees (the legacy one)
129
+ // so we can spot a mismatch — frameRotationDegrees=0 with
130
+ // captureOrientation="portrait" means JS is passing stale
131
+ // accelerometer state.
132
+ os_log(.fault, log: OSLog(subsystem: "com.tiger.retailens",
133
+ category: "stitcher.diag"),
134
+ "[V16-bridge] start: captureOrientation=%{public}@ frameRotationDegrees=%d (raw_options_value=%{public}@)",
135
+ captureOrientation,
136
+ Int32(rotation),
137
+ String(describing: options["captureOrientation"]))
138
+ // V15 — engine selection. Three modes:
139
+ // 'hybrid' — planar projection + feature matching
140
+ // 'slitscan-rotate' — V13.0a + 1D NCC for rotation wobble
141
+ // 'slitscan-both' — DEFAULT — V13.0a + no gate + feather
142
+ // blend; iterate via per-stage toggles
143
+ // in the config dict.
144
+ // Backward compat: 'firstwins-rectilinear' → 'slitscan-rotate'.
145
+ // Legacy 'firstwins' / 'firstwins-zoomed' / 'slitscan' fall
146
+ // back to 'slitscan-both' with a deprecation warning.
147
+ let engineMode = (options["engine"] as? String) ?? "slitscan-both"
148
+
149
+ // V15 — per-stage config overrides. All optional; missing
150
+ // fields use mode defaults from +[RLISStitcherConfig configForMode:].
151
+ let configOverrides = options["config"] as? [String: Any] ?? [:]
152
+
153
+ IncrementalStitcher.shared.start(
154
+ composeWidth: composeW,
155
+ composeHeight: composeH,
156
+ canvasWidth: canvasW,
157
+ canvasHeight: canvasH,
158
+ featherPx: feather,
159
+ snapshotJpegQuality: snapQ,
160
+ snapshotEveryNAccepts: snapN,
161
+ frameRotationDegrees: rotation,
162
+ engineMode: engineMode,
163
+ captureOrientation: captureOrientation,
164
+ configOverrides: configOverrides,
165
+ frameSourceMode: frameSourceMode
166
+ )
167
+ resolver(["ok": true])
168
+ }
169
+
170
+ /// `options` keys: `outputPath` (optional — when empty/missing
171
+ /// the native side generates a path under NSTemporaryDirectory),
172
+ /// `quality` (optional, default 90). Resolves with
173
+ /// `{ panoramaPath, width, height, acceptedCount,
174
+ /// droppedBackpressure }`.
175
+ @objc(finalize:resolver:rejecter:)
176
+ public func finalize(
177
+ options: NSDictionary,
178
+ resolver: @escaping RCTPromiseResolveBlock,
179
+ rejecter: @escaping RCTPromiseRejectBlock
180
+ ) {
181
+ let outputPathRaw = (options["outputPath"] as? String) ?? ""
182
+ let outputPath: String
183
+ if outputPathRaw.isEmpty {
184
+ // Mirror RNSARSession's path-generation behaviour
185
+ // — host code can call finalize() with no path and a
186
+ // tmp file is created in the app's sandbox tmp dir.
187
+ let dir = NSTemporaryDirectory()
188
+ outputPath = (dir as NSString).appendingPathComponent(
189
+ "RNImageStitcherIncremental-\(UUID().uuidString).jpg"
190
+ )
191
+ } else {
192
+ outputPath = outputPathRaw
193
+ }
194
+ let quality = (options["quality"] as? Int) ?? 90
195
+ // 2026-05-18 (iOS cross-orientation fix) — JS may pass a
196
+ // fresh deviceOrientation at finalize time; if so, override
197
+ // the engine's start-time snapshot before the stitch + bake.
198
+ // Empty / missing → keep legacy behaviour (start-time value).
199
+ let freshOrientation = (options["captureOrientation"] as? String) ?? ""
200
+ if !freshOrientation.isEmpty {
201
+ IncrementalStitcher.shared.updateCaptureOrientation(
202
+ freshOrientation
203
+ )
204
+ }
205
+ IncrementalStitcher.shared.finalize(
206
+ toPath: outputPath,
207
+ jpegQuality: quality
208
+ ) { result, error in
209
+ if let error = error {
210
+ rejecter(
211
+ "incremental-finalize-failed",
212
+ error.localizedDescription,
213
+ error
214
+ )
215
+ } else {
216
+ resolver(result ?? [:])
217
+ }
218
+ }
219
+ }
220
+
221
+ @objc(cancel:rejecter:)
222
+ public func cancel(
223
+ resolver: @escaping RCTPromiseResolveBlock,
224
+ rejecter: @escaping RCTPromiseRejectBlock
225
+ ) {
226
+ IncrementalStitcher.shared.cancel()
227
+ resolver(["ok": true])
228
+ }
229
+
230
+ /// V16 — JS-side hook for shutter-release in pose-based frame
231
+ /// selection mode. Arms the keyframe gate so the next ARFrame
232
+ /// delivered is force-accepted regardless of overlap, ensuring
233
+ /// the trailing edge of the scan isn't truncated when the user
234
+ /// releases the shutter mid-pan. No-op when the gate is
235
+ /// disabled (frameSelectionMode = "time-based") or no capture
236
+ /// is in flight. Always resolves with `{ ok: true }`.
237
+ @objc(markNextFrameAsLastKeyframe:rejecter:)
238
+ public func markNextFrameAsLastKeyframe(
239
+ resolver: @escaping RCTPromiseResolveBlock,
240
+ rejecter: @escaping RCTPromiseRejectBlock
241
+ ) {
242
+ IncrementalStitcher.shared.markNextFrameAsLastKeyframe()
243
+ resolver(["ok": true])
244
+ }
245
+
246
+ /// 2026-05-18 (Issue #2 v2) — JS-driven frame ingestion for iOS
247
+ /// non-AR mode. Mirrors Android's `processFrameAtPath` exactly:
248
+ /// the JPEG at `path` is already saved on disk by vision-camera
249
+ /// in its native EXIF-correct orientation. We DO NOT decode the
250
+ /// image here. Instead:
251
+ ///
252
+ /// - Build a synthetic `RNSARFramePose` from the
253
+ /// JS-supplied quaternion + intrinsics (no translation;
254
+ /// non-AR captures don't have it).
255
+ /// - Hand the path + pose to
256
+ /// `IncrementalStitcher.addBatchKeyframePath`, which
257
+ /// evaluates the shared-C++ KeyframeGate and (if accepted)
258
+ /// records the path in the finalize-time keyframe list +
259
+ /// emits the same state event the AR-delegate path emits.
260
+ /// - `cv::imread` at finalize handles EXIF orientation
261
+ /// natively, so the output panorama reads upright with no
262
+ /// iOS-specific orientation handling needed in this bridge.
263
+ ///
264
+ /// History: Issue #2 v1 (commit 0e40f17) tried to decode the
265
+ /// JPEG into a CVPixelBuffer and reuse the existing AR
266
+ /// `consumeFrame(pixelBuffer:pose:)` path. That introduced two
267
+ /// orientation bugs (CGContext Y-flip + UIImage.size vs
268
+ /// cgImage.width dim swap) → upside-down output AND canvas-
269
+ /// dimension overflow → OOM crashes (user-reported 2026-05-18).
270
+ /// Architecturally Android never decoded the image either, so
271
+ /// the right fix was to mirror that.
272
+ ///
273
+ /// `options` keys:
274
+ /// - path (NSString, required) — local file path (no file://)
275
+ /// - qx, qy, qz, qw (Double, required) — quaternion, JS-side
276
+ /// gyro-integrated
277
+ /// - fx, fy, cx, cy (Double, required) — intrinsics in sensor px
278
+ /// - imageWidth, imageHeight (Int, required)
279
+ /// - trackingPoor (Bool, optional, default false)
280
+ /// - timestampMs (Double, optional, default = now)
281
+ ///
282
+ /// Only batch-keyframe captures are supported on this path right
283
+ /// now — other engines (hybrid / firstwins) need real pixel data
284
+ /// during the live phase, which isn't trivially derivable from a
285
+ /// JPEG path. Reject with `E_NOT_BATCH_KEYFRAME` so the JS host
286
+ /// can fall back to the legacy stitchVideo path if needed.
287
+ @objc(processFrameAtPath:resolver:rejecter:)
288
+ public func processFrameAtPath(
289
+ options: NSDictionary,
290
+ resolver: @escaping RCTPromiseResolveBlock,
291
+ rejecter: @escaping RCTPromiseRejectBlock
292
+ ) {
293
+ guard let pathRaw = options["path"] as? String, !pathRaw.isEmpty else {
294
+ rejecter("E_NO_PATH", "processFrameAtPath: missing 'path'", nil)
295
+ return
296
+ }
297
+ // Strip optional file:// prefix — JS callers sometimes send
298
+ // file URIs, native APIs want filesystem paths.
299
+ let cleanPath = pathRaw.hasPrefix("file://")
300
+ ? String(pathRaw.dropFirst("file://".count))
301
+ : pathRaw
302
+
303
+ let engine = IncrementalStitcher.shared
304
+ guard engine.isBatchKeyframeMode else {
305
+ rejecter("E_NOT_BATCH_KEYFRAME",
306
+ "processFrameAtPath only supports batch-keyframe "
307
+ + "engine mode on iOS. Configure "
308
+ + "incrementalEngine='batch-keyframe' in start() "
309
+ + "options, or fall back to the stitchVideo path.",
310
+ nil)
311
+ return
312
+ }
313
+
314
+ let qx = (options["qx"] as? Double) ?? 0
315
+ let qy = (options["qy"] as? Double) ?? 0
316
+ let qz = (options["qz"] as? Double) ?? 0
317
+ let qw = (options["qw"] as? Double) ?? 1 // identity quat default
318
+ let fx = (options["fx"] as? Double) ?? 1000.0
319
+ let fy = (options["fy"] as? Double) ?? 1000.0
320
+ let cx = (options["cx"] as? Double) ?? 540.0
321
+ let cy = (options["cy"] as? Double) ?? 960.0
322
+ let imageWidth = (options["imageWidth"] as? Int) ?? 1080
323
+ let imageHeight = (options["imageHeight"] as? Int) ?? 1920
324
+ let trackingPoor = (options["trackingPoor"] as? Bool) ?? false
325
+ let timestampMs = (options["timestampMs"] as? Double)
326
+ ?? (Date().timeIntervalSince1970 * 1000.0)
327
+ let trackingState: RNSARTrackingState =
328
+ trackingPoor ? .limited : .tracking
329
+
330
+ let pose = RNSARFramePose(
331
+ tx: 0, ty: 0, tz: 0, // no translation in non-AR
332
+ qx: qx, qy: qy, qz: qz, qw: qw,
333
+ fx: fx, fy: fy, cx: cx, cy: cy,
334
+ imageWidth: imageWidth, imageHeight: imageHeight,
335
+ timestampMs: timestampMs,
336
+ trackingState: trackingState
337
+ )
338
+
339
+ // 2026-05-18 (Iss #1 diag) — read EXIF Orientation tag from the
340
+ // keyframe JPEG before handing it to the engine. vision-camera
341
+ // writes a JPEG with an EXIF tag matching the physical capture
342
+ // orientation (1=no rotation, 3=180°, 6=90°CW, 8=90°CCW). The
343
+ // bake-rotation table in cpp/stitcher.cpp assumes the post-imread
344
+ // Mat is in user-view orientation (post-EXIF apply). If the EXIF
345
+ // tag isn't what we expect for a given physical orientation, the
346
+ // input Mat to cv::Stitcher will be a different shape than the AR
347
+ // path produces (AR keyframes hardcode EXIF=6, commit 7b828f1) —
348
+ // which would explain why iOS non-AR landscape captures stitch
349
+ // but bake the wrong way. CGImageSource is cheap (metadata-only;
350
+ // no decode).
351
+ var exifOrientation: Int = -1
352
+ if let src = CGImageSourceCreateWithURL(
353
+ URL(fileURLWithPath: cleanPath) as CFURL, nil
354
+ ),
355
+ let props = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any],
356
+ let o = props[kCGImagePropertyOrientation] as? Int {
357
+ exifOrientation = o
358
+ }
359
+ os_log(.fault, log: OSLog(subsystem: "com.tiger.retailens",
360
+ category: "stitcher.diag"),
361
+ "[V16-batch-keyframe.js] processFrameAtPath EXIF=%d imageW=%d imageH=%d path=%{public}@",
362
+ Int32(exifOrientation), Int32(imageWidth), Int32(imageHeight), cleanPath)
363
+
364
+ let accepted = engine.addBatchKeyframePath(path: cleanPath, pose: pose)
365
+ resolver(["ok": true, "accepted": accepted])
366
+ }
367
+
368
+ /// 2026-05-18 (Iss 3) — bridge for `cleanupKeyframes`. See the
369
+ /// Swift method's docstring for behaviour. Options dict keys:
370
+ /// - olderThanMs (Double / NSNumber, optional, default 24h):
371
+ /// cutoff staleness in ms.
372
+ /// Resolves with { sessionsDeleted, bytesFreed }. Never rejects.
373
+ @objc(cleanupKeyframes:resolver:rejecter:)
374
+ public func cleanupKeyframes(
375
+ options: NSDictionary,
376
+ resolver: @escaping RCTPromiseResolveBlock,
377
+ rejecter: @escaping RCTPromiseRejectBlock
378
+ ) {
379
+ let olderThanMs = (options["olderThanMs"] as? Double)
380
+ ?? Double(24 * 3600 * 1000)
381
+ let result = IncrementalStitcher.shared
382
+ .cleanupKeyframes(olderThanMs: olderThanMs)
383
+ resolver(result)
384
+ }
385
+
386
+ /// 2026-05-18 (Iss 3) — bridge for `getKeyframeDir`. Returns the
387
+ /// session dir of the currently-running batch-keyframe capture,
388
+ /// or empty string if no capture is in flight / engine isn't in
389
+ /// batch-keyframe mode.
390
+ @objc(getKeyframeDir:rejecter:)
391
+ public func getKeyframeDir(
392
+ resolver: @escaping RCTPromiseResolveBlock,
393
+ rejecter: @escaping RCTPromiseRejectBlock
394
+ ) {
395
+ let path = IncrementalStitcher.shared.currentKeyframeDir() ?? ""
396
+ resolver(["path": path])
397
+ }
398
+
399
+ /// V16 Phase 1b.fix2 — JS-callable poll for the process'
400
+ /// phys_footprint in MB. This is the SAME metric iOS jetsam
401
+ /// evaluates against, so it's the right number for an on-screen
402
+ /// debug overlay correlating capture activity with memory pressure.
403
+ /// Returns -1 on task_info failure.
404
+ @objc(getMemoryFootprintMB:rejecter:)
405
+ public func getMemoryFootprintMB(
406
+ resolver: @escaping RCTPromiseResolveBlock,
407
+ rejecter: @escaping RCTPromiseRejectBlock
408
+ ) {
409
+ var info = task_vm_info_data_t()
410
+ var count = mach_msg_type_number_t(
411
+ MemoryLayout<task_vm_info_data_t>.size
412
+ / MemoryLayout<integer_t>.size
413
+ )
414
+ let kr = withUnsafeMutablePointer(to: &info) { ptr in
415
+ ptr.withMemoryRebound(
416
+ to: integer_t.self, capacity: Int(count)
417
+ ) { reboundPtr in
418
+ task_info(
419
+ mach_task_self_,
420
+ task_flavor_t(TASK_VM_INFO),
421
+ reboundPtr,
422
+ &count
423
+ )
424
+ }
425
+ }
426
+ if kr != KERN_SUCCESS {
427
+ resolver(-1.0)
428
+ return
429
+ }
430
+ let mb = Double(info.phys_footprint) / (1024.0 * 1024.0)
431
+ resolver(mb)
432
+ }
433
+
434
+ /// 2026-05-16 — realtime+batch fusion (Option A) bridge. Marshal
435
+ /// the options dictionary into the engine layer, dispatch the
436
+ /// refinement off the bridge thread so the JS Promise doesn't block
437
+ /// the bridge queue for the 2-5 s the stitcher takes, and surface
438
+ /// the result/error back to JS. The actual cv::Stitcher invocation
439
+ /// lives on the engine layer so the auto-trigger path (called from
440
+ /// inside `finalize()`) and the explicit JS path share one
441
+ /// implementation.
442
+ ///
443
+ /// `options` keys:
444
+ /// - framePaths (NSArray<NSString *>, required, >= 2 entries)
445
+ /// - outputPath (NSString, required, non-empty)
446
+ /// - config (NSDictionary, optional) — warperType, blenderType,
447
+ /// seamFinderType, captureOrientation, useInscribedRectCrop,
448
+ /// jpegQuality. Missing fields fall back to spherical /
449
+ /// multiband / graphcut / portrait / false / 90.
450
+ @objc(refinePanorama:resolver:rejecter:)
451
+ public func refinePanorama(
452
+ options: NSDictionary,
453
+ resolver: @escaping RCTPromiseResolveBlock,
454
+ rejecter: @escaping RCTPromiseRejectBlock
455
+ ) {
456
+ let framePathsAny = options["framePaths"]
457
+ guard let framePaths = framePathsAny as? [String], framePaths.count >= 2 else {
458
+ rejecter(
459
+ "incremental-refine-invalid-input",
460
+ "refinePanorama requires at least 2 framePaths (got "
461
+ + "\(((framePathsAny as? [String])?.count) ?? 0)).",
462
+ nil
463
+ )
464
+ return
465
+ }
466
+ let outputPathRaw = (options["outputPath"] as? String) ?? ""
467
+ guard !outputPathRaw.isEmpty else {
468
+ rejecter(
469
+ "incremental-refine-invalid-input",
470
+ "refinePanorama requires a non-empty outputPath.",
471
+ nil
472
+ )
473
+ return
474
+ }
475
+ let outputPath = outputPathRaw.hasPrefix("file://")
476
+ ? String(outputPathRaw.dropFirst(7))
477
+ : outputPathRaw
478
+ let config = options["config"] as? [String: Any] ?? [:]
479
+ IncrementalStitcher.shared.refinePanorama(
480
+ framePaths: framePaths,
481
+ outputPath: outputPath,
482
+ config: config
483
+ ) { result, error in
484
+ if let error = error {
485
+ rejecter(
486
+ "incremental-refine-failed",
487
+ error.localizedDescription,
488
+ error
489
+ )
490
+ } else {
491
+ resolver(result ?? [:])
492
+ }
493
+ }
494
+ }
495
+
496
+ /// PiP investigation: write a JS-supplied message into the same
497
+ /// rlis-debug.log file the Swift side uses, so we get a single
498
+ /// timeline across native and JS. Remove once PiP is fixed.
499
+ @objc(appendDebugLog:resolver:rejecter:)
500
+ public func appendDebugLog(
501
+ message: NSString,
502
+ resolver: @escaping RCTPromiseResolveBlock,
503
+ rejecter: @escaping RCTPromiseRejectBlock
504
+ ) {
505
+ IncrementalStitcher.fileLog("JS: \(message)")
506
+ resolver(["ok": true])
507
+ }
508
+
509
+ @objc(getState:rejecter:)
510
+ public func getState(
511
+ resolver: @escaping RCTPromiseResolveBlock,
512
+ rejecter: @escaping RCTPromiseRejectBlock
513
+ ) {
514
+ let dict = IncrementalStitcher.shared.currentStateDictionary()
515
+ resolver(dict ?? NSNull())
516
+ }
517
+
518
+ /// V15.0e — JS-callable poll for ARKit plane detection state.
519
+ /// Used by the capture screen to render a status pill when
520
+ /// planeSource=ARKitDetected so the operator knows whether
521
+ /// they're waiting for a plane lock, the plane is detected
522
+ /// but off-axis, or the plane is ready.
523
+ ///
524
+ /// Returns a dictionary:
525
+ /// `status` — one of "searching" / "evaluating" / "ready"
526
+ /// `hasPlane` — true if a plane is latched
527
+ /// `bestAlignment` — best rejected-alignment score seen so
528
+ /// far (range [-1, 1]; -1 = no candidate
529
+ /// seen yet); when status="evaluating",
530
+ /// UI shows this so the operator knows
531
+ /// how close they are to clearing the
532
+ /// threshold
533
+ /// `threshold` — current alignment threshold for
534
+ /// comparison/UI display
535
+ /// V15.0g — clear the latched ARKit plane and re-evaluate ALL
536
+ /// currently-tracked vertical planes against the camera's CURRENT
537
+ /// aim. Picks the BEST candidate by area-weighted alignment
538
+ /// score (largest plane that passes the alignment threshold).
539
+ /// Use this on hold-to-scan press so the plane reflects what the
540
+ /// operator is aiming at right now, not whichever plane ARKit
541
+ /// noticed first.
542
+ ///
543
+ /// Returns:
544
+ /// `latched` — true if a plane was latched; false if no
545
+ /// candidate passed the alignment threshold (the
546
+ /// status pill will keep showing 'searching' /
547
+ /// 'evaluating' and the engine will refuse the
548
+ /// first capture frame until a plane locks)
549
+ @objc(relatchARPlane:rejecter:)
550
+ public func relatchARPlane(
551
+ resolver: @escaping RCTPromiseResolveBlock,
552
+ rejecter: @escaping RCTPromiseRejectBlock
553
+ ) {
554
+ DispatchQueue.main.async {
555
+ let latched = RNSARSession.shared.relatchPlaneFromCurrentAnchors()
556
+ resolver(["latched": latched])
557
+ }
558
+ }
559
+
560
+ @objc(getARPlaneStatus:rejecter:)
561
+ public func getARPlaneStatus(
562
+ resolver: @escaping RCTPromiseResolveBlock,
563
+ rejecter: @escaping RCTPromiseRejectBlock
564
+ ) {
565
+ let session = RNSARSession.shared
566
+ let hasPlane = session.hasPlaneDetected
567
+ let best = Double(session.bestRejectedAlignment)
568
+ let threshold = Double(session.planeAlignmentThreshold)
569
+ let status: String
570
+ if hasPlane {
571
+ status = "ready"
572
+ } else if best > 0 {
573
+ // ARKit found a plane but the alignment filter rejected
574
+ // it — operator is in the right ballpark but needs to
575
+ // face the wall more directly.
576
+ status = "evaluating"
577
+ } else {
578
+ status = "searching"
579
+ }
580
+ resolver([
581
+ "status": status,
582
+ "hasPlane": hasPlane,
583
+ "bestAlignment": best,
584
+ "threshold": threshold,
585
+ ])
586
+ }
587
+
588
+ // MARK: - Notification → device event
589
+
590
+ @objc private func handleStateUpdate(_ notification: Notification) {
591
+ let hasPath = (notification.userInfo?["panoramaPath"] != nil)
592
+ if hasPath {
593
+ IncrementalStitcher.fileLog(
594
+ "bridge handleStateUpdate hasListeners=\(hasListeners) hasPath=\(hasPath) thread=\(Thread.isMainThread ? "main" : "bg")"
595
+ )
596
+ }
597
+ guard hasListeners else { return }
598
+ guard let userInfo = notification.userInfo else { return }
599
+ // FIX: RCTEventEmitter.sendEvent is documented to be called
600
+ // from any thread, but in practice events from background
601
+ // threads can be dropped silently if the bridge is in
602
+ // certain states. Dispatch to main queue to guarantee
603
+ // delivery. See e.g. RN issues #19518, #28250.
604
+ DispatchQueue.main.async { [weak self] in
605
+ guard let self = self else { return }
606
+ if hasPath {
607
+ IncrementalStitcher.fileLog(
608
+ "bridge sendEvent (main queue) body.panoramaPath=\(userInfo["panoramaPath"] ?? "MISSING")"
609
+ )
610
+ }
611
+ self.sendEvent(withName: Self.stateUpdateEvent, body: userInfo)
612
+ }
613
+ }
614
+
615
+ public override func startObserving() {
616
+ hasListeners = true
617
+ IncrementalStitcher.fileLog("bridge startObserving (hasListeners=true)")
618
+ }
619
+
620
+ public override func stopObserving() {
621
+ hasListeners = false
622
+ IncrementalStitcher.fileLog("bridge stopObserving (hasListeners=false)")
623
+ }
624
+ }
625
+ #endif