react-native-image-stitcher 0.14.2 → 0.15.1

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 (120) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +35 -0
  3. package/RNImageStitcher.podspec +8 -7
  4. package/android/build.gradle +0 -16
  5. package/android/src/main/cpp/CMakeLists.txt +2 -63
  6. package/android/src/main/cpp/image_stitcher_jni.cpp +14 -0
  7. package/android/src/main/cpp/keyframe_gate_jni.cpp +13 -0
  8. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +285 -3
  9. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +180 -1162
  10. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +29 -0
  11. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +0 -4
  12. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +129 -71
  13. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +49 -0
  14. package/cpp/keyframe_gate.cpp +82 -23
  15. package/cpp/keyframe_gate.hpp +31 -2
  16. package/cpp/stitcher.cpp +208 -28
  17. package/cpp/tests/CMakeLists.txt +18 -12
  18. package/cpp/tests/keyframe_timebudget_test.cpp +65 -0
  19. package/cpp/tests/warp_guard_test.cpp +48 -0
  20. package/cpp/warp_guard.hpp +41 -0
  21. package/dist/camera/Camera.d.ts +31 -16
  22. package/dist/camera/Camera.js +11 -3
  23. package/dist/camera/CameraView.js +93 -3
  24. package/dist/camera/CaptureStitchStatsToast.d.ts +15 -2
  25. package/dist/camera/CaptureStitchStatsToast.js +27 -7
  26. package/dist/camera/PanoramaSettings.d.ts +10 -223
  27. package/dist/camera/PanoramaSettings.js +6 -28
  28. package/dist/camera/PanoramaSettingsBridge.d.ts +1 -24
  29. package/dist/camera/PanoramaSettingsBridge.js +3 -102
  30. package/dist/camera/PanoramaSettingsModal.js +7 -1
  31. package/dist/camera/buildPanoramaInitialSettings.d.ts +11 -0
  32. package/dist/camera/buildPanoramaInitialSettings.js +4 -0
  33. package/dist/camera/cameraErrorMessages.d.ts +32 -0
  34. package/dist/camera/cameraErrorMessages.js +53 -0
  35. package/dist/camera/selectCaptureDevice.d.ts +5 -1
  36. package/dist/camera/selectCaptureDevice.js +22 -2
  37. package/dist/camera/useCapture.js +38 -0
  38. package/dist/index.d.ts +5 -8
  39. package/dist/index.js +11 -34
  40. package/dist/stitching/incremental.d.ts +1 -117
  41. package/dist/stitching/stitchVideo.d.ts +0 -35
  42. package/dist/types.d.ts +0 -87
  43. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +96 -674
  44. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +9 -12
  45. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +14 -0
  46. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +7 -0
  47. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +6 -0
  48. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +2 -2
  49. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +3 -3
  50. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +28 -60
  51. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +180 -921
  52. package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +82 -7
  53. package/ios/Sources/RNImageStitcher/RNSARSession.swift +10 -35
  54. package/ios/Sources/RNImageStitcher/Stitcher.swift +84 -35
  55. package/ios/Sources/RNImageStitcher/StitcherBridge.m +13 -0
  56. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +132 -5
  57. package/package.json +3 -2
  58. package/src/camera/Camera.tsx +44 -23
  59. package/src/camera/CameraView.tsx +113 -4
  60. package/src/camera/CaptureStitchStatsToast.tsx +58 -14
  61. package/src/camera/PanoramaSettings.ts +16 -289
  62. package/src/camera/PanoramaSettingsBridge.ts +3 -114
  63. package/src/camera/PanoramaSettingsModal.tsx +14 -1
  64. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +3 -188
  65. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +41 -0
  66. package/src/camera/__tests__/cameraErrorMessages.test.ts +76 -0
  67. package/src/camera/__tests__/selectCaptureDevice.test.ts +33 -0
  68. package/src/camera/buildPanoramaInitialSettings.ts +17 -0
  69. package/src/camera/cameraErrorMessages.ts +84 -0
  70. package/src/camera/selectCaptureDevice.ts +28 -3
  71. package/src/camera/useCapture.ts +44 -1
  72. package/src/index.ts +11 -40
  73. package/src/stitching/incremental.ts +3 -140
  74. package/src/stitching/stitchVideo.ts +0 -26
  75. package/src/types.ts +0 -95
  76. package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +0 -227
  77. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +0 -1081
  78. package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +0 -103
  79. package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +0 -256
  80. package/cpp/stitcher_frame_jsi.cpp +0 -214
  81. package/cpp/stitcher_frame_jsi.hpp +0 -108
  82. package/cpp/stitcher_proxy_jsi.cpp +0 -109
  83. package/cpp/stitcher_proxy_jsi.hpp +0 -46
  84. package/cpp/stitcher_worklet_dispatch.cpp +0 -103
  85. package/cpp/stitcher_worklet_dispatch.hpp +0 -71
  86. package/cpp/stitcher_worklet_registry.cpp +0 -91
  87. package/cpp/stitcher_worklet_registry.hpp +0 -146
  88. package/cpp/tests/stitcher_worklet_registry_test.cpp +0 -195
  89. package/dist/stitching/IncrementalStitcherView.d.ts +0 -41
  90. package/dist/stitching/IncrementalStitcherView.js +0 -157
  91. package/dist/stitching/StitcherWorkletRegistry.d.ts +0 -117
  92. package/dist/stitching/StitcherWorkletRegistry.js +0 -78
  93. package/dist/stitching/ensureStitcherProxyInstalled.d.ts +0 -8
  94. package/dist/stitching/ensureStitcherProxyInstalled.js +0 -81
  95. package/dist/stitching/useFrameProcessor.d.ts +0 -119
  96. package/dist/stitching/useFrameProcessor.js +0 -196
  97. package/dist/stitching/useFrameStream.d.ts +0 -34
  98. package/dist/stitching/useFrameStream.js +0 -234
  99. package/dist/stitching/useThrottledFrameProcessor.d.ts +0 -33
  100. package/dist/stitching/useThrottledFrameProcessor.js +0 -132
  101. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +0 -474
  102. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +0 -1328
  103. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +0 -103
  104. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +0 -3285
  105. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +0 -128
  106. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +0 -313
  107. package/ios/Sources/RNImageStitcher/SaveFrameAsJpegPlugin.mm +0 -185
  108. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.h +0 -60
  109. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.mm +0 -214
  110. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.h +0 -42
  111. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +0 -160
  112. package/src/stitching/IncrementalStitcherView.tsx +0 -198
  113. package/src/stitching/StitcherWorkletRegistry.ts +0 -156
  114. package/src/stitching/__tests__/StitcherWorkletRegistry.test.ts +0 -176
  115. package/src/stitching/__tests__/ensureStitcherProxyInstalled.test.ts +0 -94
  116. package/src/stitching/__tests__/useThrottledFrameProcessor.test.ts +0 -178
  117. package/src/stitching/ensureStitcherProxyInstalled.ts +0 -141
  118. package/src/stitching/useFrameProcessor.ts +0 -226
  119. package/src/stitching/useFrameStream.ts +0 -271
  120. package/src/stitching/useThrottledFrameProcessor.ts +0 -145
@@ -5,13 +5,19 @@
5
5
  // docs/site-content/design/2026-04-30-realtime-incremental-stitching.md.
6
6
  //
7
7
  // What this file does:
8
- // - Owns a single `OpenCVIncrementalStitcher` instance
8
+ // - Orchestrates the batch-keyframe capture pipeline: a pose/flow
9
+ // keyframe gate selects frames, an OpenCVKeyframeCollector saves
10
+ // them as JPEGs, and finalize() hands the set to
11
+ // `OpenCVStitcher.stitchFramePaths` for one-shot stitching.
9
12
  // - Subscribes to `RNSARSession`'s per-frame ARFrame delivery
10
13
  // - Converts ARKit pose → yaw/pitch + horizontal FoV
11
- // - Dispatches addPixelBuffer onto a serial queue
12
14
  // - Posts state updates as Notifications so the RN bridge can fan
13
15
  // them out to JS as device events
14
16
  //
17
+ // History: the live incremental engines (`OpenCVIncrementalStitcher`
18
+ // hybrid + `OpenCVFirstWinsCylindricalStitcher` slit-scan) were
19
+ // archived; only the batch-keyframe path remains.
20
+ //
15
21
  // What this file deliberately does NOT do:
16
22
  // - Touch OpenCV / cv::* — that's confined to the .mm impl behind
17
23
  // the ObjC interface.
@@ -43,12 +49,10 @@ import simd
43
49
  import UIKit
44
50
  import os.log
45
51
 
46
- /// Public outcome enum mirroring the ObjC `RLISFrameOutcome` so JS
47
- /// callers can inspect what happened to each frame without crossing
48
- /// the ObjC++ boundary themselves.
49
- ///
50
- /// Values 7+ are emitted from the Swift gate layer (KeyframeGate),
51
- /// not from the native engine. Keep numeric values in lockstep with
52
+ /// Public outcome enum so JS callers can inspect what happened to
53
+ /// each frame. Values 0-6 historically mirrored the (now-archived)
54
+ /// live-engine outcome codes; values 7+ are emitted from the Swift
55
+ /// gate layer (KeyframeGate). Keep numeric values in lockstep with
52
56
  /// `IncrementalOutcome` in incremental.ts.
53
57
  @objc public enum IncrementalOutcome: Int {
54
58
  case acceptedHigh = 0
@@ -84,10 +88,10 @@ public final class IncrementalStateObject: NSObject {
84
88
  @objc public let confidence: Double
85
89
  @objc public let overlapPercent: Double
86
90
  @objc public let processingMs: Double
87
- /// V12.12 — engine-detected physical orientation, plumbed up
88
- /// from `RLISFrameTelemetry.isLandscape`. See incremental.ts
89
- /// for the full rationale (single source of truth across SDK
90
- /// + host).
91
+ /// V12.12 — detected physical orientation. In the batch-keyframe
92
+ /// path it's derived from the saved keyframe's pixel dimensions
93
+ /// (imageWidth >= imageHeight). See incremental.ts for the full
94
+ /// rationale (single source of truth across SDK + host).
91
95
  @objc public let isLandscape: Bool
92
96
  /// V12.14.9 — running painted extent along the pan axis, in
93
97
  /// canvas pixels. Combined with `panExtent`, lets the JS band
@@ -196,11 +200,9 @@ struct FinalizePayload {
196
200
 
197
201
  // ── Stitcher mode selection ─────────────────────────────────
198
202
  /// True if this finalize is the V16 batch-keyframe pipeline.
203
+ /// Always true now that the live engines are archived; retained
204
+ /// so the finalize closure's branch structure stays explicit.
199
205
  let inBatchKeyframeMode: Bool
200
- /// Hybrid engine ref (V14/V15 path). nil if batch mode.
201
- let hybrid: OpenCVIncrementalStitcher?
202
- /// First-wins cylindrical engine ref (V13 path). nil if batch mode.
203
- let slit: OpenCVFirstWinsCylindricalStitcher?
204
206
  /// V16 keyframe collector — owns the per-session JPEG sidecar
205
207
  /// directory the post-stitch result references.
206
208
  let collector: OpenCVKeyframeCollector?
@@ -250,35 +252,6 @@ public final class IncrementalStitcher: NSObject {
250
252
 
251
253
  @objc public static let shared = IncrementalStitcher()
252
254
 
253
- /// Underlying OpenCV engine. Created on `start`, torn down on
254
- /// `finalize`/`reset`. Holding it across captures would keep the
255
- /// 24 MB canvas allocated in idle.
256
- ///
257
- /// V10: two engine variants exist behind one Swift wrapper.
258
- /// `hybridEngine` (Samsung-style, full-frame cylindrical + OF) is
259
- /// the default. `firstwinsEngine` (Apple-style, per-strip painting)
260
- /// is opt-in via the JS `engine: 'slitscan'` start option. Only
261
- /// one is non-nil at a time.
262
- private var hybridEngine: OpenCVIncrementalStitcher?
263
- private var firstwinsEngine: OpenCVFirstWinsCylindricalStitcher?
264
-
265
- /// V15.0b — true once we've forwarded the latched plane transform
266
- /// from RNSARSession to the slit-scan engine. Reset on
267
- /// every start() so the next capture re-propagates. We only
268
- /// forward once per capture: the plane transform is latched
269
- /// (RNSARSession ignores subsequent ARKit refinements),
270
- /// so re-propagating each frame is wasted work.
271
- private var havePropagatedPlane: Bool = false
272
-
273
- /// Convenience: read the active engine's accepted count. Used by
274
- /// the per-frame state event.
275
- private var engineAcceptedCount: Int {
276
- return hybridEngine?.acceptedCount ?? firstwinsEngine?.acceptedCount ?? 0
277
- }
278
- private var anyEngineActive: Bool {
279
- return hybridEngine != nil || firstwinsEngine != nil
280
- }
281
-
282
255
  /// Serial queue for the heavy per-frame work. ARSession delegate
283
256
  /// only dispatches a pre-allocated cv::Mat onto this queue — the
284
257
  /// pixel buffer itself is consumed before return.
@@ -299,13 +272,13 @@ public final class IncrementalStitcher: NSObject {
299
272
  qos: .userInitiated
300
273
  )
301
274
 
302
- /// 2026-05-16 — realtime+batch fusion (Option A "Replace on
303
- /// completion"). Dedicated queue for the async refinement run
304
- /// that follows a hybrid-engine finalize(). Kept SEPARATE from
305
- /// `workQueue` so the next capture's start/consumeFrame path
306
- /// isn't gated on the prior capture's 2-5 s cv::Stitcher run
307
- /// the design doc explicitly calls out "operator can continue
308
- /// browsing / starting another capture during refinement".
275
+ /// 2026-05-16 — dedicated queue for the async refinement run
276
+ /// driven by the explicit JS `refinePanorama(...)` API. Kept
277
+ /// SEPARATE from `workQueue` so the next capture's start/
278
+ /// consumeFrame path isn't gated on a prior 2-5 s cv::Stitcher
279
+ /// run the design doc explicitly calls out "operator can
280
+ /// continue browsing / starting another capture during
281
+ /// refinement".
309
282
  ///
310
283
  /// Serial: at most one refinement runs at a time (the design's
311
284
  /// "cancellation semantics if a new capture starts mid-refine"
@@ -393,16 +366,14 @@ public final class IncrementalStitcher: NSObject {
393
366
  /// capture. See KeyframeGate.swift for the full rationale.
394
367
  private let keyframeGate = KeyframeGate()
395
368
 
396
- /// V16 Phase 1 — when `engineMode == "batch-keyframe"`, no
397
- /// incremental engine runs; we accumulate the gate-accepted
398
- /// frames as on-disk JPEGs + their poses, then on `finalize` hand
399
- /// them to `OpenCVStitcher.stitchKeyframePaths:withPoses:` (the
400
- /// full BA + ExposureCompensator + GraphCutSeamFinder +
401
- /// MultiBandBlender pipeline) for one-shot stitching. Why this
402
- /// is structurally different from the slit-scan / hybrid engines:
403
- /// they ingest into a streaming canvas, whereas batch-keyframe
404
- /// defers all stitching until shutter release so the global-
405
- /// stage quality wins (BA, multi-band) become available.
369
+ /// V16 Phase 1 — the batch-keyframe pipeline (the only surviving
370
+ /// engine mode): we accumulate the gate-accepted frames as on-disk
371
+ /// JPEGs + their poses, then on `finalize` hand them to
372
+ /// `OpenCVStitcher.stitchFramePaths` (the full feature-matched
373
+ /// BA + ExposureCompensator + GraphCutSeamFinder + MultiBandBlender
374
+ /// pipeline) for one-shot stitching. This defers all stitching
375
+ /// until shutter release so the global-stage quality wins (BA,
376
+ /// multi-band) become available.
406
377
  private var batchKeyframeMode: Bool = false
407
378
  private var keyframeCollector: OpenCVKeyframeCollector?
408
379
  /// Poses recorded 1:1 with `keyframeCollector`'s saved JPEGs.
@@ -626,29 +597,6 @@ public final class IncrementalStitcher: NSObject {
626
597
  ]
627
598
  }
628
599
 
629
- /// 2026-05-16 — realtime+batch fusion (Option A) path derivation.
630
- /// Given the live panorama path (which finalize() wrote inside
631
- /// the app sandbox tmp or a host-supplied location), pick a path
632
- /// for the refined output. Pattern:
633
- ///
634
- /// /…/RNImageStitcherIncremental-<uuid>.jpg
635
- /// → /…/RNImageStitcherIncremental-<uuid>-refined.jpg
636
- ///
637
- /// Same directory keeps cleanup discoverable (delete both when
638
- /// the audit is discarded). Different name avoids racing the
639
- /// host UI that may still be reading the live file as the
640
- /// refinement is writing.
641
- fileprivate static func refinedPathFromLive(livePath: String) -> String {
642
- let ns = livePath as NSString
643
- let dir = ns.deletingLastPathComponent
644
- let base = (ns.lastPathComponent as NSString).deletingPathExtension
645
- let ext = (ns.lastPathComponent as NSString).pathExtension
646
- let refinedName = ext.isEmpty
647
- ? "\(base)-refined"
648
- : "\(base)-refined.\(ext)"
649
- return (dir as NSString).appendingPathComponent(refinedName)
650
- }
651
-
652
600
  // ── Native orientation classifier ────────────────────────────────
653
601
  //
654
602
  // AR-STITCHING-TWO-MODES — see memory/ar-stitching-two-modes.md
@@ -781,44 +729,16 @@ public final class IncrementalStitcher: NSObject {
781
729
  resolvedOrientation,
782
730
  nativeResult.hadFrame ? Int32(1) : Int32(0),
783
731
  engineMode)
784
- // V15 engine modes:
785
- // 'hybrid' hybrid engine, planar projection by default
786
- // 'slitscan-rotate' slit-scan, rectilinear, V13.0a + 1D NCC
787
- // 'slitscan-both' → slit-scan, rectilinear, V13.0a + no gate
788
- // + feather blend (iterate via overrides)
789
- // Backward compat in -[RLISStitcherConfig configForMode:] handles
790
- // 'firstwins-rectilinear' 'slitscan-rotate' and warns on
791
- // legacy 'firstwins' / 'firstwins-zoomed' / 'slitscan' modes.
792
- let normalisedMode: String
793
- switch engineMode {
794
- case "hybrid": normalisedMode = "hybrid"
795
- case "batch-keyframe":
796
- // V16 Phase 1 — new mode. Skips the live incremental
797
- // engines entirely; KeyframeGate accumulates accepted
798
- // frames as JPEGs, on finalize OpenCVStitcher does the
799
- // full-pipeline stitch.
800
- normalisedMode = "batch-keyframe"
801
- case "slitscan-rotate", "firstwins-rectilinear":
802
- normalisedMode = "slitscan-rotate"
803
- case "slitscan-both":
804
- normalisedMode = "slitscan-both"
805
- case "firstwins", "firstwins-zoomed", "slitscan":
806
- NSLog("[V15-bridge] DEPRECATED engine '\(engineMode)' — using slitscan-both")
807
- normalisedMode = "slitscan-both"
808
- default:
809
- NSLog("[V15-bridge] unknown engine '\(engineMode)' — using slitscan-both")
810
- normalisedMode = "slitscan-both"
732
+ // Engine mode: only the batch-keyframe pipeline survives. The
733
+ // live incremental engines ('hybrid', 'slitscan-*', and the
734
+ // legacy 'firstwins*' aliases) were archived any non-batch
735
+ // `engineMode` now falls back to batch-keyframe so existing JS
736
+ // callers keep working.
737
+ if engineMode != "batch-keyframe" {
738
+ NSLog("[bridge] DEPRECATED engine '\(engineMode)' live engines archived, using batch-keyframe")
811
739
  }
812
740
 
813
- let useBatchKeyframe = (normalisedMode == "batch-keyframe")
814
- let useFirstwinsClass = normalisedMode.hasPrefix("slitscan")
815
-
816
- // Build the V15 config: factory default for the mode, then apply
817
- // JS-side overrides.
818
- let config = RLISStitcherConfig(forMode: normalisedMode)
819
- Self.applyConfigOverrides(configOverrides, to: config)
820
-
821
- if useBatchKeyframe {
741
+ do {
822
742
  // V16 Phase 1 — no live engine; spin up a keyframe
823
743
  // collector that saves accepted frames to disk under
824
744
  // Library/AppSupport/Captures/{uuid}/. On finalize
@@ -838,8 +758,6 @@ public final class IncrementalStitcher: NSObject {
838
758
  // sensor orientation for the batch-keyframe path so the
839
759
  // pose's intrinsics (which describe the unrotated
840
760
  // 1920×1440 sensor) match the saved-image dimensions.
841
- // The slit-scan and hybrid engines continue to receive
842
- // `frameRotationDegrees` unchanged.
843
761
  self.keyframeRotationDegrees = 0
844
762
  // 2026-05-18 (Issue #1a fix) — keyframe EXIF Orientation
845
763
  // is hardcoded to 6 ("rotate 90° CW for display") regardless
@@ -912,40 +830,10 @@ public final class IncrementalStitcher: NSObject {
912
830
  // option value.
913
831
  self.batchImuTranslationMetres = 0.0
914
832
  self.batchKeyframeMode = true
915
- self.hybridEngine = nil
916
- self.firstwinsEngine = nil
917
833
  os_log(.fault, log: Self.diagLog,
918
834
  "[V16-batch-keyframe] start mode=batch-keyframe rotation=0 (was %d, forced to 0 to match pose intrinsics) sessionDir=%{public}@",
919
835
  frameRotationDegrees,
920
836
  self.keyframeCollector?.sessionDir ?? "(nil)")
921
- } else if useFirstwinsClass {
922
- // Slit-scan engine always uses rectilinear in V15
923
- // (firstwins-cylindrical and firstwins-zoomed modes were
924
- // removed; their behaviour is unused).
925
- self.firstwinsEngine = OpenCVFirstWinsCylindricalStitcher(
926
- composeWidth: composeWidth,
927
- composeHeight: composeHeight,
928
- canvasWidth: canvasWidth,
929
- canvasHeight: canvasHeight,
930
- featherPx: featherPx,
931
- frameRotationDegrees: frameRotationDegrees,
932
- useRectilinear: true
933
- )
934
- self.firstwinsEngine?.setConfig(config)
935
- self.hybridEngine = nil
936
- self.batchKeyframeMode = false
937
- } else {
938
- self.hybridEngine = OpenCVIncrementalStitcher(
939
- composeWidth: composeWidth,
940
- composeHeight: composeHeight,
941
- canvasWidth: canvasWidth,
942
- canvasHeight: canvasHeight,
943
- featherPx: featherPx,
944
- frameRotationDegrees: frameRotationDegrees
945
- )
946
- self.hybridEngine?.setConfig(config)
947
- self.firstwinsEngine = nil
948
- self.batchKeyframeMode = false
949
837
  }
950
838
  self.isRunning = true
951
839
  // F8.3 — enable the Frame Processor plugin's producer-thread
@@ -958,8 +846,6 @@ public final class IncrementalStitcher: NSObject {
958
846
  self.acceptsSinceSnapshot = 0
959
847
  self.droppedBackpressure = 0
960
848
  self.lastState = nil
961
- // V15.0b — re-arm plane propagation for the new capture.
962
- self.havePropagatedPlane = false
963
849
  // V13.0c.1 — reset translation diagnostic state for the
964
850
  // new capture. First-frame translation will be captured
965
851
  // on the next consumeFrame call.
@@ -1044,6 +930,21 @@ public final class IncrementalStitcher: NSObject {
1044
930
  } else {
1045
931
  self.keyframeGate.flowMaxTranslationCm = 0.0
1046
932
  }
933
+ // Wall-clock keyframe-interval budget, in MILLISECONDS. When
934
+ // > 0, the gate force-accepts a frame once this much time has
935
+ // elapsed since the last accepted keyframe (applies to BOTH
936
+ // Pose and Flow strategies). Passed straight through — the JS
937
+ // value is already in ms (no cm→m style conversion). Clamp to
938
+ // ≥ 0 (the bridge/C++ re-clamps too). Default 2000 ms when the
939
+ // key is absent (NOT 0 — time-budget acceptance is on by
940
+ // default so a stalled scan still advances).
941
+ if let v = configOverrides["maxKeyframeIntervalMs"] as? Double {
942
+ self.keyframeGate.maxKeyframeIntervalMs = max(0.0, v)
943
+ } else if let v = configOverrides["maxKeyframeIntervalMs"] as? Int {
944
+ self.keyframeGate.maxKeyframeIntervalMs = max(0.0, Double(v))
945
+ } else {
946
+ self.keyframeGate.maxKeyframeIntervalMs = 2000.0
947
+ }
1047
948
  // V16 — novelty aggregation percentile. Clamp at start to
1048
949
  // [0.5, 0.99]; the bridge re-clamps but matching it here
1049
950
  // means our state stays in-range for logging. Default 0.85
@@ -1067,11 +968,12 @@ public final class IncrementalStitcher: NSObject {
1067
968
  }
1068
969
  self.keyframeGate.reset()
1069
970
  os_log(.fault, log: Self.diagLog,
1070
- "[V16-keyframe] start gate enabled=%d strategy=%{public}@ thr=%.2f max=%d flow(maxCorners=%d quality=%.3f minDist=%.1f maxTransCm=%.1f pctile=%.2f evalEveryN=%d)",
971
+ "[V16-keyframe] start gate enabled=%d strategy=%{public}@ thr=%.2f max=%d maxKfIntervalMs=%.0f flow(maxCorners=%d quality=%.3f minDist=%.1f maxTransCm=%.1f pctile=%.2f evalEveryN=%d)",
1071
972
  self.keyframeGate.enabled ? 1 : 0,
1072
973
  self.keyframeGate.strategy == .flow ? "flow" : "pose",
1073
974
  self.keyframeGate.overlapThreshold,
1074
975
  self.keyframeGate.maxCount,
976
+ self.keyframeGate.maxKeyframeIntervalMs,
1075
977
  self.keyframeGate.flowMaxCorners,
1076
978
  self.keyframeGate.flowQualityLevel,
1077
979
  self.keyframeGate.flowMinDistance,
@@ -1119,115 +1021,10 @@ public final class IncrementalStitcher: NSObject {
1119
1021
  /// AR delegate could deliver several more frames — each one
1120
1022
  /// passed consumeFrame's `isRunning == true` check and got
1121
1023
  /// ingested into the canvas, producing visible "phantom" frames
1122
- /// V15 apply per-stage JS overrides on top of a mode default.
1123
- /// Keys recognised in `overrides`: any non-readonly RLISStitcherConfig
1124
- /// field. Unrecognised keys are ignored. Values out of range are
1125
- /// clamped silently (e.g. kPanAxisFractionRect outside [0.05, 0.90]).
1126
- private static func applyConfigOverrides(_ overrides: [String: Any],
1127
- to config: RLISStitcherConfig) {
1128
- if let v = overrides["kPanAxisFractionRect"] as? Double {
1129
- config.kPanAxisFractionRect = max(0.05, min(0.90, v))
1130
- }
1131
- if let v = overrides["kMinAcceptDeltaPx"] as? Int {
1132
- config.kMinAcceptDeltaPx = max(0, min(500, v))
1133
- }
1134
- if let v = overrides["enableTriangulation"] as? Bool {
1135
- config.enableTriangulation = v
1136
- }
1137
- if let v = overrides["enableTriAccumulator"] as? Bool {
1138
- config.enableTriAccumulator = v
1139
- }
1140
- if let v = overrides["enable1dNcc"] as? Bool {
1141
- config.enable1dNcc = v
1142
- }
1143
- if let v = overrides["nccSearchRadius1d"] as? Int {
1144
- config.nccSearchRadius1d = max(5, min(60, v))
1145
- }
1146
- if let v = overrides["enable2dNcc"] as? Bool {
1147
- config.enable2dNcc = v
1148
- }
1149
- if let v = overrides["enableRansacHomography"] as? Bool {
1150
- config.enableRansacHomography = v
1151
- }
1152
- if let v = overrides["paintMode"] as? String {
1153
- switch v {
1154
- case "FirstPaintedWins": config.paintMode = .firstPaintedWins
1155
- case "FeatherBlend": config.paintMode = .featherBlend
1156
- default: break
1157
- }
1158
- }
1159
- if let v = overrides["hybridProjection"] as? String {
1160
- switch v {
1161
- case "Cylindrical": config.hybridProjection = .cylindrical
1162
- case "Planar": config.hybridProjection = .planar
1163
- default: break
1164
- }
1165
- }
1166
- if let v = overrides["useDetectedPlane"] as? Bool {
1167
- config.useDetectedPlane = v
1168
- }
1169
- if let v = overrides["sliverPosition"] as? String {
1170
- switch v {
1171
- case "Center": config.sliverPosition = .center
1172
- case "Bottom": config.sliverPosition = .bottom
1173
- case "Top": config.sliverPosition = .top
1174
- default: break
1175
- }
1176
- }
1177
- if let v = overrides["firstFrameFullFrame"] as? Bool {
1178
- config.firstFrameFullFrame = v
1179
- }
1180
- // V15.0d new overrides.
1181
- if let v = overrides["planeSource"] as? String {
1182
- switch v {
1183
- case "Disabled": config.planeSource = .disabled
1184
- case "ARKitDetected": config.planeSource = .arKitDetected
1185
- case "Virtual": config.planeSource = .virtual
1186
- default: break
1187
- }
1188
- }
1189
- if let v = overrides["virtualPlaneDepthMeters"] as? Double {
1190
- config.virtualPlaneDepthMeters = max(0.3, min(5.0, v))
1191
- }
1192
- if let v = overrides["arkitPlaneAlignmentThreshold"] as? Double {
1193
- config.arkitPlaneAlignmentThreshold = max(0.0, min(1.0, v))
1194
- }
1195
- if let v = overrides["planeProjectionStyle"] as? String {
1196
- switch v {
1197
- case "Trapezoidal": config.planeProjectionStyle = .trapezoidal
1198
- case "Rectified": config.planeProjectionStyle = .rectified
1199
- default: break
1200
- }
1201
- }
1202
- if let v = overrides["nccSearchMargin2d"] as? Int {
1203
- config.nccSearchMargin2d = max(4, min(60, v))
1204
- }
1205
- if let v = overrides["nccConfidenceThreshold2d"] as? Double {
1206
- config.nccConfidenceThreshold2d = max(0.30, min(0.99, v))
1207
- }
1208
- if let v = overrides["enableNcc2dEmaSmoothing"] as? Bool {
1209
- config.enableNcc2dEmaSmoothing = v
1210
- }
1211
- if let v = overrides["ncc2dEmaAlpha"] as? Double {
1212
- config.ncc2dEmaAlpha = max(0.05, min(0.95, v))
1213
- }
1214
- if let v = overrides["enableNcc2dPanAxisLock"] as? Bool {
1215
- config.enableNcc2dPanAxisLock = v
1216
- }
1217
- if let v = overrides["ncc2dCrossAxisLockPx"] as? Int {
1218
- config.ncc2dCrossAxisLockPx = max(0, min(30, v))
1219
- }
1220
- // Propagate the alignment threshold to the AR session so its
1221
- // didAdd / didUpdate filter uses the operator-chosen value.
1222
- // (planeAlignmentThreshold is a Float on the AR session.)
1223
- RNSARSession.shared.planeAlignmentThreshold =
1224
- Float(config.arkitPlaneAlignmentThreshold)
1225
- }
1226
-
1227
- /// after the user thought they had released. The engine refs
1228
- /// and isRunning flag are now flipped SYNCHRONOUSLY here so the
1024
+ /// after the user thought they had released. The batch-keyframe
1025
+ /// state and isRunning flag are flipped SYNCHRONOUSLY here so the
1229
1026
  /// AR delegate's very next consumeFrame sees isRunning=false.
1230
- /// The work-queue body just runs the engine's own finalize.
1027
+ /// The work-queue body just runs the one-shot stitch.
1231
1028
  @objc public func finalize(
1232
1029
  toPath outputPath: String,
1233
1030
  jpegQuality: Int,
@@ -1247,8 +1044,6 @@ public final class IncrementalStitcher: NSObject {
1247
1044
  // collapses the race: any consumeFrame entered after this
1248
1045
  // line sees isRunning=false at its very first guard.
1249
1046
  stateLock.lock()
1250
- let hybrid = self.hybridEngine
1251
- let slit = self.firstwinsEngine
1252
1047
  let inBatchKeyframeMode = self.batchKeyframeMode
1253
1048
  let collector = self.keyframeCollector
1254
1049
  let paths = self.keyframePaths
@@ -1306,8 +1101,6 @@ public final class IncrementalStitcher: NSObject {
1306
1101
  // features. Drop the closure-capture to avoid a compile
1307
1102
  // warning; ARKit pose data is preserved on the ivar regardless.
1308
1103
  _ = self.keyframePoses
1309
- self.hybridEngine = nil
1310
- self.firstwinsEngine = nil
1311
1104
  self.batchKeyframeMode = false
1312
1105
  self.keyframeCollector = nil
1313
1106
  self.keyframePaths = []
@@ -1373,8 +1166,6 @@ public final class IncrementalStitcher: NSObject {
1373
1166
  cleaned: cleaned,
1374
1167
  q: q,
1375
1168
  inBatchKeyframeMode: inBatchKeyframeMode,
1376
- hybrid: hybrid,
1377
- slit: slit,
1378
1169
  collector: collector,
1379
1170
  paths: paths,
1380
1171
  batchWarperType: batchWarperType,
@@ -1617,9 +1408,9 @@ public final class IncrementalStitcher: NSObject {
1617
1408
  // ARKit poses are still saved alongside each
1618
1409
  // keyframe (`keyframePoses`) for future
1619
1410
  // pose-driven investigation as a separate
1620
- // workstream that path stays in the codebase
1621
- // (stitchKeyframePaths method) but isn't on
1622
- // the hot path.
1411
+ // workstream, but the pose-driven stitch method
1412
+ // has since been archived; the feature-matched
1413
+ // path is the only one on the hot path.
1623
1414
  // V16 Phase 1b.fix3 — pass the EXIF Orientation
1624
1415
  // tag derived from `frameRotationDegrees`.
1625
1416
  // V16 Phase 1b.fix8 (C2) — read knobs from
@@ -1736,81 +1527,11 @@ public final class IncrementalStitcher: NSObject {
1736
1527
  } catch let stitchErr as NSError {
1737
1528
  completion(nil, stitchErr)
1738
1529
  }
1739
- } else if let hybrid = payload.hybrid {
1740
- let snap = try hybrid.finalize(atPath: payload.cleaned, jpegQuality: payload.q)
1741
- completion([
1742
- "panoramaPath": snap.panoramaPath,
1743
- "width": snap.width,
1744
- "height": snap.height,
1745
- "acceptedCount": snap.acceptedCount,
1746
- "droppedBackpressure": payload.drops,
1747
- ], nil)
1748
- // 2026-05-16 — realtime+batch fusion (Option A
1749
- // "Replace on completion") hook. The live
1750
- // panorama has been written and the JS finalize
1751
- // promise has resolved; now fire-and-forget an
1752
- // async refinement over the hybrid engine's
1753
- // accepted keyframes.
1754
- //
1755
- // Constraints honoured here (per the design doc
1756
- // and the prompt's "Constraints" list):
1757
- // 1. Hybrid realtime engine is NOT modified —
1758
- // `OpenCVIncrementalStitcher.mm` stays
1759
- // untouched; we only consult the existing
1760
- // keyframe-path ivar that finalize() already
1761
- // snapshotted into `payload.paths`.
1762
- // 2. NO-OP when keyframes are not on disk.
1763
- // Today's hybrid engine does NOT save per-
1764
- // frame JPEGs (only batch-keyframe mode does
1765
- // via OpenCVKeyframeCollector), so
1766
- // `payload.paths` is empty for the hybrid
1767
- // branch. `runHybridAutoRefine` detects
1768
- // that and emits `isRefining=false` without
1769
- // running cv::Stitcher. When a future change
1770
- // hooks the hybrid engine up to a keyframe
1771
- // collector, the same code path lights up
1772
- // automatically.
1773
- // 3. Refinement is fire-and-forget — finalize's
1774
- // promise has ALREADY been resolved above.
1775
- //
1776
- // Capture-list discipline (C2 invariant — see the
1777
- // file-top markers). No `self.*` references allowed
1778
- // here; we route the dispatch through the type
1779
- // (IncrementalStitcher.shared) so the
1780
- // closure captures only value-typed locals + the
1781
- // class type itself. shared is a process-wide
1782
- // singleton (initialised once at module load),
1783
- // so this is lifecycle-safe.
1784
- let refinedOut = Self.refinedPathFromLive(
1785
- livePath: snap.panoramaPath
1786
- )
1787
- let pathsForRefine = payload.paths // empty for hybrid today
1788
- let capOri = payload.captureOrientation
1789
- let warper = payload.batchWarperType
1790
- let blender = payload.batchBlenderType
1791
- let seam = payload.batchSeamFinderType
1792
- let inscribed = payload.batchEnableInscribedRectCrop
1793
- IncrementalStitcher.shared.refineQueue.async {
1794
- IncrementalStitcher.shared.runHybridAutoRefine(
1795
- framePaths: pathsForRefine,
1796
- refinedOutputPath: refinedOut,
1797
- captureOrientation: capOri,
1798
- warperType: warper,
1799
- blenderType: blender,
1800
- seamFinderType: seam,
1801
- useInscribedRectCrop: inscribed
1802
- )
1803
- }
1804
- } else if let slit = payload.slit {
1805
- let snap = try slit.finalize(atPath: payload.cleaned, jpegQuality: payload.q)
1806
- completion([
1807
- "panoramaPath": snap.panoramaPath,
1808
- "width": snap.width,
1809
- "height": snap.height,
1810
- "acceptedCount": snap.acceptedCount,
1811
- "droppedBackpressure": payload.drops,
1812
- ], nil)
1813
1530
  } else {
1531
+ // Defensive: batch-keyframe is the only pipeline,
1532
+ // so a non-batch finalize means no capture was
1533
+ // active (start() was never called or already torn
1534
+ // down).
1814
1535
  completion(nil, NSError(
1815
1536
  domain: "RNImageStitcherIncremental",
1816
1537
  code: 9002,
@@ -1825,14 +1546,12 @@ public final class IncrementalStitcher: NSObject {
1825
1546
  // MARK: C2-INVARIANT-END
1826
1547
  }
1827
1548
 
1828
- /// 2026-05-16 — realtime+batch fusion (Option A) entry point.
1829
- /// Runs the shared C++ stitcher over the supplied keyframe JPEGs
1830
- /// and writes a refined panorama to `outputPath`.
1549
+ /// 2026-05-16 — refine entry point. Runs the shared C++ stitcher
1550
+ /// over the supplied keyframe JPEGs and writes a refined panorama
1551
+ /// to `outputPath`.
1831
1552
  ///
1832
- /// Called by:
1833
- /// 1. The bridge layer (explicit JS `refinePanorama(...)` API).
1834
- /// 2. `runHybridAutoRefine(...)` below, the fire-and-forget hook
1835
- /// from `finalize()` for the hybrid-engine path.
1553
+ /// Called by the bridge layer (explicit JS `refinePanorama(...)`
1554
+ /// API) to re-stitch a saved keyframe set at higher quality.
1836
1555
  ///
1837
1556
  /// Threading: the work itself dispatches onto `refineQueue` (NOT
1838
1557
  /// `workQueue`). That keeps the per-capture path completely
@@ -2019,140 +1738,6 @@ public final class IncrementalStitcher: NSObject {
2019
1738
  }
2020
1739
  }
2021
1740
 
2022
- /// 2026-05-16 — realtime+batch fusion (Option A) auto-trigger.
2023
- /// Called from `finalize()` immediately after the hybrid engine
2024
- /// wrote its live panorama; fire-and-forget from finalize()'s
2025
- /// perspective so the JS-side finalize promise resolves with the
2026
- /// live result first. Then this method:
2027
- ///
2028
- /// 1. Emits a state event with `isRefining = true` so the host
2029
- /// can render its "Refining…" pill.
2030
- /// 2. Runs `refinePanorama(framePaths, refinedOutputPath, ...)`.
2031
- /// 3. On success: emits a state event with `isRefining = false`
2032
- /// AND `refinedPanoramaPath = <path>` so the host swaps in
2033
- /// the higher-quality output.
2034
- /// 4. On failure: emits a state event with `isRefining = false`
2035
- /// AND NO refined path. Host keeps showing the live
2036
- /// panorama; the design doc's "Couldn't refine" toast UX is
2037
- /// a follow-up.
2038
- ///
2039
- /// No-op when `framePaths.count < 2` or any framePath is missing
2040
- /// on disk. Hybrid-engine captures DO NOT today save per-frame
2041
- /// JPEGs, so this method's most common call site (from finalize's
2042
- /// hybrid branch) currently produces a no-op + isRefining=false
2043
- /// emit — which is intentional (the design doc says "if
2044
- /// keyframes are NOT on disk, the auto-trigger is a no-op").
2045
- private func runHybridAutoRefine(
2046
- framePaths: [String],
2047
- refinedOutputPath: String,
2048
- captureOrientation: String,
2049
- warperType: String,
2050
- blenderType: String,
2051
- seamFinderType: String,
2052
- useInscribedRectCrop: Bool
2053
- ) {
2054
- if framePaths.count < 2 {
2055
- os_log(.info, log: Self.diagLog,
2056
- "[refine.auto] skipped: framePaths.count=%d (< 2 — hybrid engine retains no per-frame JPEGs)",
2057
- framePaths.count)
2058
- // Emit isRefining=false so any host that pre-seeded a
2059
- // pill on finalize doesn't get stuck.
2060
- self.emitRefinementState(isRefining: false, refinedPanoramaPath: nil)
2061
- return
2062
- }
2063
- // Pre-flight existence check so we degrade gracefully when
2064
- // a JPEG was unlinked between finalize and the dispatch
2065
- // landing on refineQueue.
2066
- let fm = FileManager.default
2067
- for p in framePaths {
2068
- let cleaned = p.hasPrefix("file://") ? String(p.dropFirst(7)) : p
2069
- if !fm.fileExists(atPath: cleaned) {
2070
- os_log(.info, log: Self.diagLog,
2071
- "[refine.auto] skipped: missing keyframe %{public}@",
2072
- cleaned)
2073
- self.emitRefinementState(isRefining: false, refinedPanoramaPath: nil)
2074
- return
2075
- }
2076
- }
2077
- // Signal the pill on before the stitcher work begins. The
2078
- // emit goes through the same notification channel as every
2079
- // other state update; JS sees it asynchronously, which is
2080
- // fine — operator UX wants the pill within a few hundred ms,
2081
- // not synchronously with finalize's promise resolution.
2082
- self.emitRefinementState(isRefining: true, refinedPanoramaPath: nil)
2083
- let config: [String: Any] = [
2084
- "warperType": warperType,
2085
- "blenderType": blenderType,
2086
- "seamFinderType": seamFinderType,
2087
- "captureOrientation": captureOrientation,
2088
- "useInscribedRectCrop": useInscribedRectCrop,
2089
- "jpegQuality": 90,
2090
- ]
2091
- self.refinePanorama(
2092
- framePaths: framePaths,
2093
- outputPath: refinedOutputPath,
2094
- config: config
2095
- ) { [weak self] result, error in
2096
- guard let self = self else { return }
2097
- if let error = error {
2098
- os_log(.fault, log: Self.diagLog,
2099
- "[refine.auto] refinement failed: %{public}@ — leaving live output in place",
2100
- error.localizedDescription)
2101
- self.emitRefinementState(isRefining: false, refinedPanoramaPath: nil)
2102
- return
2103
- }
2104
- let path = (result?["panoramaPath"] as? String) ?? refinedOutputPath
2105
- os_log(.fault, log: Self.diagLog,
2106
- "[refine.auto] success path=%{public}@",
2107
- path)
2108
- self.emitRefinementState(isRefining: false, refinedPanoramaPath: path)
2109
- }
2110
- }
2111
-
2112
- /// 2026-05-16 — emit a minimal state event carrying only the
2113
- /// refinement-related fields. Mirrors the existing
2114
- /// `emitBatchKeyframeAcceptedState` pattern: build a fresh
2115
- /// IncrementalStateObject, then add the new optional fields
2116
- /// directly to the userInfo dict so JS (which reads from the
2117
- /// raw event payload) picks them up without a schema change in
2118
- /// the Obj-C class.
2119
- private func emitRefinementState(
2120
- isRefining: Bool,
2121
- refinedPanoramaPath: String?
2122
- ) {
2123
- // Preserve the most-recent panoramaPath / dims / accepted
2124
- // count so the JS subscriber's sticky-snapshot merge keeps
2125
- // showing the live preview between the finalize and the
2126
- // refined swap. All other fields default to "no-op" values.
2127
- stateLock.lock()
2128
- let prev = self.lastState
2129
- stateLock.unlock()
2130
- let state = IncrementalStateObject(
2131
- panoramaPath: prev?.panoramaPath,
2132
- width: prev?.width ?? 0,
2133
- height: prev?.height ?? 0,
2134
- acceptedCount: prev?.acceptedCount ?? 0,
2135
- outcome: prev?.outcome ?? .acceptedHigh,
2136
- confidence: prev?.confidence ?? 1.0,
2137
- overlapPercent: prev?.overlapPercent ?? -1.0,
2138
- processingMs: 0,
2139
- isLandscape: prev?.isLandscape ?? false,
2140
- paintedExtent: prev?.paintedExtent ?? 0,
2141
- panExtent: prev?.panExtent ?? 0,
2142
- keyframeMax: prev?.keyframeMax ?? 0
2143
- )
2144
- var dict = state.asDictionary()
2145
- dict["isRefining"] = isRefining
2146
- if let p = refinedPanoramaPath {
2147
- dict["refinedPanoramaPath"] = p
2148
- }
2149
- NotificationCenter.default.post(
2150
- name: .retailensIncrementalStateUpdate,
2151
- object: nil,
2152
- userInfo: dict
2153
- )
2154
- }
2155
-
2156
1741
  /// v0.10.0 #15A — emit a refine-pipeline phase update on the same
2157
1742
  /// `IncrementalStateUpdate` channel that carries `isRefining` /
2158
1743
  /// `refinedPanoramaPath`. Five `stage` values fire across the
@@ -2172,10 +1757,9 @@ public final class IncrementalStitcher: NSObject {
2172
1757
  ///
2173
1758
  /// Reuses the existing channel (rather than introducing a new
2174
1759
  /// device-event name) so the JS subscriber doesn't need to wire
2175
- /// a second listener. The payload carries the same skeleton
2176
- /// `emitRefinementState` emits (lastState fields preserved) so
2177
- /// `isRefining` / `refinedPanoramaPath` sticky-merge logic on the
2178
- /// JS side keeps working untouched.
1760
+ /// a second listener. The payload preserves the lastState fields
1761
+ /// so the `isRefining` / `refinedPanoramaPath` sticky-merge logic
1762
+ /// on the JS side keeps working untouched.
2179
1763
  private func emitRefineProgress(
2180
1764
  stage: String,
2181
1765
  fraction: Double,
@@ -2228,11 +1812,7 @@ public final class IncrementalStitcher: NSObject {
2228
1812
  // FIRST so any in-flight consumeFrame bails at its first
2229
1813
  // guard. Then detach the AR consumer.
2230
1814
  stateLock.lock()
2231
- let hybrid = self.hybridEngine
2232
- let slit = self.firstwinsEngine
2233
1815
  let collector = self.keyframeCollector
2234
- self.hybridEngine = nil
2235
- self.firstwinsEngine = nil
2236
1816
  self.keyframeCollector = nil
2237
1817
  self.batchKeyframeMode = false
2238
1818
  self.keyframePaths = []
@@ -2250,14 +1830,12 @@ public final class IncrementalStitcher: NSObject {
2250
1830
  self.keyframeGate.reset()
2251
1831
  stateLock.unlock()
2252
1832
  RNSARSession.shared.incrementalConsumer = nil
2253
- // Reset on the work queue so we don't race with an in-flight
2254
- // ingest that's still touching the engine's canvas. Cancel
2255
- // ALSO removes the collector's session directory — the
2256
- // operator explicitly aborted, so the saved JPEGs aren't
2257
- // worth keeping for re-processing.
1833
+ // Clean up on the work queue so we don't race with an in-flight
1834
+ // ingest that's still saving a keyframe. Cancel removes the
1835
+ // collector's session directory — the operator explicitly
1836
+ // aborted, so the saved JPEGs aren't worth keeping for
1837
+ // re-processing.
2258
1838
  workQueue.async {
2259
- hybrid?.reset()
2260
- slit?.reset()
2261
1839
  collector?.cleanup()
2262
1840
  }
2263
1841
  }
@@ -2467,7 +2045,10 @@ public final class IncrementalStitcher: NSObject {
2467
2045
  let acceptedCount: Int
2468
2046
  stateLock.lock()
2469
2047
  prev = self.lastState
2470
- acceptedCount = self.engineAcceptedCount
2048
+ // Batch-keyframe is the only running mode: the accepted count is
2049
+ // the gate's running keyframe tally (the live engines that used
2050
+ // to back `engineAcceptedCount` have been archived).
2051
+ acceptedCount = self.keyframeGate.acceptedCount
2471
2052
  stateLock.unlock()
2472
2053
  let overlapPercent = (decision.newContentFraction >= 0)
2473
2054
  ? (1.0 - decision.newContentFraction) * 100.0
@@ -2516,8 +2097,6 @@ public final class IncrementalStitcher: NSObject {
2516
2097
  // start/stop in flight — drop this frame.
2517
2098
  return
2518
2099
  }
2519
- let hybrid = self.hybridEngine
2520
- let slit = self.firstwinsEngine
2521
2100
  let isRunning = self.isRunning
2522
2101
  // V16 Phase 1 — capture batch-keyframe state under the lock so
2523
2102
  // the work-queue closure (or the synchronous reject below)
@@ -2605,7 +2184,7 @@ public final class IncrementalStitcher: NSObject {
2605
2184
  let cadenceFires = ((self.consumeFrameCounter - 1) % evalCadence == 0)
2606
2185
  let gateActive =
2607
2186
  isRunning
2608
- && (hybrid != nil || slit != nil || inBatchKeyframeMode)
2187
+ && inBatchKeyframeMode
2609
2188
  && self.keyframeGate.enabled
2610
2189
  let shouldEvaluateGate = gateActive && cadenceFires
2611
2190
  // True iff the gate is active for this capture but we're
@@ -2649,10 +2228,9 @@ public final class IncrementalStitcher: NSObject {
2649
2228
  // text; accept-path decisions emit via the keyframeAccepted
2650
2229
  // path and the JS state subscriber.
2651
2230
 
2652
- // V16 Phase 1 — batch-keyframe is also a valid running mode
2653
- // (no engine pointer, but the collector and gate are active).
2654
- guard isRunning,
2655
- (hybrid != nil || slit != nil || inBatchKeyframeMode)
2231
+ // V16 Phase 1 — batch-keyframe is the only running mode now
2232
+ // (no engine pointer; the collector and gate are active).
2233
+ guard isRunning, inBatchKeyframeMode
2656
2234
  else { return }
2657
2235
 
2658
2236
  // Surface the gate's reject decision (if any) outside the lock.
@@ -2664,34 +2242,11 @@ public final class IncrementalStitcher: NSObject {
2664
2242
  return
2665
2243
  }
2666
2244
 
2667
- // Compute yaw + pitch from the quaternion. Convention:
2668
- // yaw = rotation about world Y (camera turning left/right)
2669
- // pitch = rotation about camera X (camera tilting up/down)
2670
- let q = simd_quatf(
2671
- ix: Float(pose.qx), iy: Float(pose.qy),
2672
- iz: Float(pose.qz), r: Float(pose.qw)
2673
- )
2674
- let (yaw, pitch) = Self.yawPitch(from: q)
2675
-
2676
- // Both FoVs from physical camera intrinsics. Passing the
2677
- // PHYSICAL vertical FoV (vs deriving it from compose aspect
2678
- // inside the engine) is what fixes the v1/v2 "only left-to-
2679
- // right portrait pan responds" bug — the engine's overlap
2680
- // gate compared world-pitch against a compose-aspect-derived
2681
- // vertical FoV that didn't match the actual camera, so most
2682
- // top-to-bottom pans fell outside the 30-70% window.
2683
- let fovHRad = 2.0 * atan(Double(pose.imageWidth) / (2.0 * pose.fx))
2684
- let fovVRad = 2.0 * atan(Double(pose.imageHeight) / (2.0 * pose.fy))
2685
- let fovHDeg = fovHRad * 180.0 / .pi
2686
- let fovVDeg = fovVRad * 180.0 / .pi
2687
-
2688
- let trackingPoor = (pose.trackingState != .tracking)
2689
-
2690
- // V11 Gap #27: dispatch the heavy pipeline (engine.ingest +
2691
- // optional snapshot) to the work queue. Earlier versions
2692
- // ran the full ~70 ms accept inside the AR delegate thread,
2693
- // blocking ARKit's 16 ms inter-frame budget and causing
2694
- // ~4-5 frames to be dropped during each accept.
2245
+ // V11 Gap #27: dispatch the heavy keyframe-save work to the
2246
+ // work queue. Earlier versions ran the full ~70 ms accept
2247
+ // inside the AR delegate thread, blocking ARKit's 16 ms
2248
+ // inter-frame budget and causing ~4-5 frames to be dropped
2249
+ // during each accept.
2695
2250
  //
2696
2251
  // Backpressure: if the work queue is already busy with a
2697
2252
  // previous frame, drop this one (don't queue up — that'd
@@ -2721,8 +2276,8 @@ public final class IncrementalStitcher: NSObject {
2721
2276
  // CVPixelBufferCreate + memcpy gives us a fully-owned copy
2722
2277
  // that ARC alone governs. ~1-2 ms cost on iPhone 16 Pro
2723
2278
  // (10 MB memcpy at memory bandwidth ~10 GB/s). Fixes the
2724
- // crash for ALL engine paths (slit-scan / hybrid / batch-
2725
- // keyframe) since they all dispatch via consumeFrame.
2279
+ // crash for the batch-keyframe save path, which dispatches
2280
+ // via consumeFrame.
2726
2281
  guard let pbCopy = Self.deepCopyPixelBuffer(pixelBuffer) else {
2727
2282
  // Allocation failure — drop the frame. Extremely rare;
2728
2283
  // would only happen under genuine OOM.
@@ -2794,126 +2349,8 @@ public final class IncrementalStitcher: NSObject {
2794
2349
  "[V16-batch-keyframe] saveKeyframe failed: %{public}@",
2795
2350
  err.localizedDescription)
2796
2351
  }
2797
- return
2798
- }
2799
-
2800
- // V15.0b — if a vertical plane has just been detected and
2801
- // we haven't propagated it to the slit-scan engine yet,
2802
- // do so now. Propagated only once per latched plane;
2803
- // RNSARSession resets on stop().
2804
- if !self.havePropagatedPlane,
2805
- let plane = RNSARSession.shared.planeTransformFlat() {
2806
- slit?.setPlaneTransformFlat(plane)
2807
- self.havePropagatedPlane = true
2808
- // V15.0c.4 — fault log so we can see the propagation
2809
- // moment without rate-limit drops.
2810
- os_log(.fault, log: Self.diagLog,
2811
- "[V15.0b-plane] bridge propagated plane to slit-scan engine (one-shot per capture)")
2812
- }
2813
-
2814
- let telemetry: RLISFrameTelemetry
2815
- if let hybrid = hybrid {
2816
- telemetry = hybrid.ingest(
2817
- pixelBuffer: pbCopy, qx: pose.qx, qy: pose.qy, qz: pose.qz, qw: pose.qw,
2818
- tx: pose.tx, ty: pose.ty, tz: pose.tz,
2819
- fx: pose.fx, fy: pose.fy, cx: pose.cx, cy: pose.cy,
2820
- imageWidth: pose.imageWidth, imageHeight: pose.imageHeight,
2821
- yaw: yaw, pitch: pitch,
2822
- fovHorizDegrees: fovHDeg, fovVertDegrees: fovVDeg,
2823
- trackingPoor: trackingPoor
2824
- )
2825
- } else if let slit = slit {
2826
- // V13.0e — slit-scan engine consumes tx/ty/tz for
2827
- // ORB-triangulation-based depth estimation and per-frame
2828
- // translation parallax correction. Hybrid passes them
2829
- // for API symmetry; only the slit engine uses them.
2830
- telemetry = slit.ingest(
2831
- pixelBuffer: pbCopy, qx: pose.qx, qy: pose.qy, qz: pose.qz, qw: pose.qw,
2832
- tx: pose.tx, ty: pose.ty, tz: pose.tz,
2833
- fx: pose.fx, fy: pose.fy, cx: pose.cx, cy: pose.cy,
2834
- imageWidth: pose.imageWidth, imageHeight: pose.imageHeight,
2835
- yaw: yaw, pitch: pitch,
2836
- fovHorizDegrees: fovHDeg, fovVertDegrees: fovVDeg,
2837
- trackingPoor: trackingPoor
2838
- )
2839
- } else {
2840
- return
2841
- }
2842
-
2843
- self.processIngestResult(
2844
- telemetry: telemetry, hybrid: hybrid, slit: slit)
2845
- }
2846
- }
2847
-
2848
- /// Pulled out of consumeFrame so the work-queue closure stays
2849
- /// readable. Same flow: build state, optionally snapshot, post
2850
- /// notification.
2851
- private func processIngestResult(
2852
- telemetry: RLISFrameTelemetry,
2853
- hybrid: OpenCVIncrementalStitcher?,
2854
- slit: OpenCVFirstWinsCylindricalStitcher?
2855
- ) {
2856
- var snapshotPath: String?
2857
- var snapW = 0, snapH = 0
2858
- let outcome = IncrementalOutcome(rawValue: telemetry.outcome.rawValue)
2859
- ?? .skippedTrackingPoor
2860
-
2861
- let isAccept = (telemetry.outcome == .acceptedHigh ||
2862
- telemetry.outcome == .acceptedMedium)
2863
-
2864
- if isAccept {
2865
- self.acceptsSinceSnapshot += 1
2866
- if self.acceptsSinceSnapshot >= self.snapshotEveryNAccepts {
2867
- self.acceptsSinceSnapshot = 0
2868
- do {
2869
- let snap: RLISSnapshot
2870
- if let hybrid = hybrid {
2871
- snap = try hybrid.snapshot(
2872
- withJpegQuality: self.snapshotJpegQuality)
2873
- } else {
2874
- snap = try slit!.snapshot(
2875
- withJpegQuality: self.snapshotJpegQuality)
2876
- }
2877
- snapshotPath = snap.panoramaPath
2878
- snapW = snap.width
2879
- snapH = snap.height
2880
- } catch {
2881
- // Silently dropping a snapshot is fine — next
2882
- // accept will retry.
2883
- }
2884
2352
  }
2885
2353
  }
2886
-
2887
- // V16 — pass the gate's max keyframe count when the gate is
2888
- // active so JS can render "Keyframes: n/max". Zero signals
2889
- // "gate disabled" to the JS pill.
2890
- let kfMax = self.keyframeGate.enabled ? self.keyframeGate.maxCount : 0
2891
- let state = IncrementalStateObject(
2892
- panoramaPath: snapshotPath,
2893
- width: snapW,
2894
- height: snapH,
2895
- acceptedCount: hybrid?.acceptedCount ?? slit?.acceptedCount ?? 0,
2896
- outcome: outcome,
2897
- confidence: telemetry.confidence,
2898
- overlapPercent: telemetry.overlapPercent,
2899
- processingMs: telemetry.processingMs,
2900
- isLandscape: telemetry.isLandscape,
2901
- paintedExtent: telemetry.paintedExtent,
2902
- panExtent: telemetry.panExtent,
2903
- keyframeMax: kfMax
2904
- )
2905
- stateLock.lock()
2906
- self.lastState = state
2907
- stateLock.unlock()
2908
-
2909
- // Emit always — JS may want to drive UX on rejects too.
2910
- // NotificationCenter is thread-agnostic; the bridge converts
2911
- // it to a main-thread RN event.
2912
- NotificationCenter.default.post(
2913
- name: .retailensIncrementalStateUpdate,
2914
- object: nil,
2915
- userInfo: state.asDictionary()
2916
- )
2917
2354
  }
2918
2355
 
2919
2356
  // ── Debug log file ──────────────────────────────────────────────
@@ -2947,21 +2384,6 @@ public final class IncrementalStitcher: NSObject {
2947
2384
 
2948
2385
  // ── Helpers ─────────────────────────────────────────────────────
2949
2386
 
2950
- /// Extract yaw (rotation about world Y) and pitch (rotation about
2951
- /// camera X) from an ARKit camera quaternion. Numerically stable
2952
- /// for camera orientations the user holds in practice — straight
2953
- /// up/down is gimbal-locked but a shelf-audit user is never there.
2954
- private static func yawPitch(from q: simd_quatf) -> (Double, Double) {
2955
- // Apply the quaternion to ARKit's camera-forward vector
2956
- // (-Z in camera frame) to get the camera-forward in world.
2957
- // Yaw is the angle of the projection onto the X-Z plane;
2958
- // pitch is the elevation angle.
2959
- let forward = simd_act(q, simd_float3(0, 0, -1))
2960
- let yaw = Double(atan2(forward.x, -forward.z))
2961
- let pitch = Double(asin(forward.y))
2962
- return (yaw, pitch)
2963
- }
2964
-
2965
2387
  /// 2026-05-22 (audit F2) — stitchMode auto-resolver. Port of
2966
2388
  /// Android's `resolveStitchModeAuto` (IncrementalStitcher.kt:1727).
2967
2389
  /// Picks PANORAMA vs SCANS based on the magnitude ratio of