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.
- package/CHANGELOG.md +96 -0
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +189 -0
- package/RNImageStitcher.podspec +76 -0
- package/android/build.gradle +224 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/cpp/CMakeLists.txt +124 -0
- package/android/src/main/cpp/image_stitcher_jni.cpp +145 -0
- package/android/src/main/cpp/keyframe_gate_jni.cpp +204 -0
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +426 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +960 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +2371 -0
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +256 -0
- package/android/src/main/java/io/imagestitcher/rn/QualityChecker.kt +167 -0
- package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +39 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +558 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +35 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +784 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/BackgroundRenderer.kt +176 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/ShaderUtil.kt +67 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +201 -0
- package/cpp/ar_frame_pose.h +63 -0
- package/cpp/keyframe_gate.cpp +927 -0
- package/cpp/keyframe_gate.hpp +240 -0
- package/cpp/stitcher.cpp +2207 -0
- package/cpp/stitcher.hpp +275 -0
- package/dist/ar/useARSession.d.ts +102 -0
- package/dist/ar/useARSession.js +133 -0
- package/dist/camera/ARCameraView.d.ts +93 -0
- package/dist/camera/ARCameraView.js +170 -0
- package/dist/camera/Camera.d.ts +134 -0
- package/dist/camera/Camera.js +688 -0
- package/dist/camera/CameraShutter.d.ts +80 -0
- package/dist/camera/CameraShutter.js +237 -0
- package/dist/camera/CameraView.d.ts +65 -0
- package/dist/camera/CameraView.js +117 -0
- package/dist/camera/CaptureControlsBar.d.ts +87 -0
- package/dist/camera/CaptureControlsBar.js +82 -0
- package/dist/camera/CaptureHeader.d.ts +62 -0
- package/dist/camera/CaptureHeader.js +81 -0
- package/dist/camera/CapturePreview.d.ts +70 -0
- package/dist/camera/CapturePreview.js +188 -0
- package/dist/camera/CaptureStatusOverlay.d.ts +75 -0
- package/dist/camera/CaptureStatusOverlay.js +326 -0
- package/dist/camera/CaptureThumbnailStrip.d.ts +87 -0
- package/dist/camera/CaptureThumbnailStrip.js +177 -0
- package/dist/camera/IncrementalPanGuide.d.ts +83 -0
- package/dist/camera/IncrementalPanGuide.js +267 -0
- package/dist/camera/PanoramaBandOverlay.d.ts +107 -0
- package/dist/camera/PanoramaBandOverlay.js +399 -0
- package/dist/camera/PanoramaConfirmModal.d.ts +57 -0
- package/dist/camera/PanoramaConfirmModal.js +128 -0
- package/dist/camera/PanoramaGuidance.d.ts +79 -0
- package/dist/camera/PanoramaGuidance.js +246 -0
- package/dist/camera/PanoramaSettingsModal.d.ts +311 -0
- package/dist/camera/PanoramaSettingsModal.js +611 -0
- package/dist/camera/ViewportCropOverlay.d.ts +46 -0
- package/dist/camera/ViewportCropOverlay.js +67 -0
- package/dist/camera/useCapture.d.ts +111 -0
- package/dist/camera/useCapture.js +160 -0
- package/dist/camera/useDeviceOrientation.d.ts +48 -0
- package/dist/camera/useDeviceOrientation.js +131 -0
- package/dist/camera/useVideoCapture.d.ts +79 -0
- package/dist/camera/useVideoCapture.js +151 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +39 -0
- package/dist/quality/normaliseOrientation.d.ts +36 -0
- package/dist/quality/normaliseOrientation.js +62 -0
- package/dist/quality/runQualityCheck.d.ts +41 -0
- package/dist/quality/runQualityCheck.js +98 -0
- package/dist/sensors/useIMUTranslationGate.d.ts +70 -0
- package/dist/sensors/useIMUTranslationGate.js +235 -0
- package/dist/stitching/IncrementalStitcherView.d.ts +41 -0
- package/dist/stitching/IncrementalStitcherView.js +157 -0
- package/dist/stitching/incremental.d.ts +930 -0
- package/dist/stitching/incremental.js +133 -0
- package/dist/stitching/stitchFrames.d.ts +55 -0
- package/dist/stitching/stitchFrames.js +56 -0
- package/dist/stitching/stitchVideo.d.ts +119 -0
- package/dist/stitching/stitchVideo.js +57 -0
- package/dist/stitching/useIncrementalJSDriver.d.ts +74 -0
- package/dist/stitching/useIncrementalJSDriver.js +199 -0
- package/dist/stitching/useIncrementalStitcher.d.ts +58 -0
- package/dist/stitching/useIncrementalStitcher.js +172 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +15 -0
- package/ios/Package.swift +72 -0
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +33 -0
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +40 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.m +55 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +149 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +2727 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +85 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +625 -0
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +328 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +141 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +278 -0
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +473 -0
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +1326 -0
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +97 -0
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +296 -0
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +103 -0
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +3285 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +238 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +1880 -0
- package/ios/Sources/RNImageStitcher/QualityChecker.swift +252 -0
- package/ios/Sources/RNImageStitcher/QualityCheckerBridge.m +26 -0
- package/ios/Sources/RNImageStitcher/QualityCheckerBridge.swift +72 -0
- package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +114 -0
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +1111 -0
- package/ios/Sources/RNImageStitcher/Stitcher.swift +243 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +28 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +246 -0
- package/package.json +73 -0
- package/react-native.config.js +34 -0
- package/scripts/opencv-version.txt +1 -0
- package/scripts/postinstall-fetch-binaries.js +286 -0
- package/src/ar/useARSession.ts +210 -0
- package/src/camera/.gitkeep +0 -0
- package/src/camera/ARCameraView.tsx +256 -0
- package/src/camera/Camera.tsx +1053 -0
- package/src/camera/CameraShutter.tsx +292 -0
- package/src/camera/CameraView.tsx +157 -0
- package/src/camera/CaptureControlsBar.tsx +204 -0
- package/src/camera/CaptureHeader.tsx +184 -0
- package/src/camera/CapturePreview.tsx +318 -0
- package/src/camera/CaptureStatusOverlay.tsx +391 -0
- package/src/camera/CaptureThumbnailStrip.tsx +277 -0
- package/src/camera/IncrementalPanGuide.tsx +328 -0
- package/src/camera/PanoramaBandOverlay.tsx +498 -0
- package/src/camera/PanoramaConfirmModal.tsx +206 -0
- package/src/camera/PanoramaGuidance.tsx +327 -0
- package/src/camera/PanoramaSettingsModal.tsx +1357 -0
- package/src/camera/ViewportCropOverlay.tsx +81 -0
- package/src/camera/useCapture.ts +279 -0
- package/src/camera/useDeviceOrientation.ts +140 -0
- package/src/camera/useVideoCapture.ts +236 -0
- package/src/index.ts +53 -0
- package/src/quality/.gitkeep +0 -0
- package/src/quality/normaliseOrientation.ts +79 -0
- package/src/quality/runQualityCheck.ts +131 -0
- package/src/sensors/useIMUTranslationGate.ts +347 -0
- package/src/stitching/.gitkeep +0 -0
- package/src/stitching/IncrementalStitcherView.tsx +198 -0
- package/src/stitching/incremental.ts +1021 -0
- package/src/stitching/stitchFrames.ts +88 -0
- package/src/stitching/stitchVideo.ts +153 -0
- package/src/stitching/useIncrementalJSDriver.ts +273 -0
- package/src/stitching/useIncrementalStitcher.ts +252 -0
- 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
|