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
@@ -54,7 +54,10 @@ public final class RNSARCameraView: UIView {
54
54
 
55
55
  private func setupView() {
56
56
  arSCNView = ARSCNView(frame: bounds)
57
- arSCNView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
57
+ // Do NOT set autoresizingMask we manage the ARSCNView frame
58
+ // manually in layoutSubviews() to achieve letterboxing.
59
+ // autoresizingMask would fight that and re-expand the view to
60
+ // fill our bounds on every Auto Layout pass.
58
61
 
59
62
  // Bind to the singleton's session. This is the critical
60
63
  // line — without it, ARSCNView would try to create its own
@@ -71,18 +74,84 @@ public final class RNSARCameraView: UIView {
71
74
  arSCNView.showsStatistics = false
72
75
  arSCNView.automaticallyUpdatesLighting = false
73
76
 
74
- // Black background while ARKit is initialising so the user
75
- // sees a clean frame instead of whatever was there before.
77
+ // Black background: fills the letterbox bars (the areas of
78
+ // this view outside ARSCNView's letterboxed sub-rect).
76
79
  backgroundColor = .black
77
80
  addSubview(arSCNView)
78
81
  }
79
82
 
80
83
  public override func layoutSubviews() {
81
84
  super.layoutSubviews()
82
- // RN's flexbox can re-bound this view at any time; keep the
83
- // ARSCNView locked to our bounds. autoresizingMask handles
84
- // most cases but isn't always enough on rotation transitions.
85
- arSCNView.frame = bounds
85
+ // Letterbox the ARSCNView to show the full camera FOV.
86
+ //
87
+ // ARSCNView's internal renderer always uses resizeAspectFill
88
+ // (fills its view, crops if aspect ratios differ). If we give
89
+ // it our full bounds (portrait 9:21) and the camera image is
90
+ // effectively portrait 3:4 (4:3 sensor rotated for device
91
+ // orientation), it crops ~19% off each horizontal edge —
92
+ // exactly the "viewport ≠ captured frame" bug.
93
+ //
94
+ // Fix: set ARSCNView's frame to the largest rect inside our
95
+ // bounds that has the camera's content aspect ratio. When
96
+ // ARSCNView fills a same-AR sub-rect, there is nothing to crop
97
+ // and the user sees the full captured scene. The parent view's
98
+ // black background fills the remainder.
99
+ arSCNView.frame = letterboxedFrame()
100
+ }
101
+
102
+ /// Returns the largest `CGRect` inside `bounds` that matches the
103
+ /// camera's content aspect ratio (accounting for device orientation),
104
+ /// centred within `bounds`.
105
+ private func letterboxedFrame() -> CGRect {
106
+ let aspect = cameraContentAspect()
107
+ let bw = bounds.width
108
+ let bh = bounds.height
109
+ guard bw > 0, bh > 0, aspect > 0 else { return bounds }
110
+
111
+ // Try fitting by width first; if height overflows, fit by height.
112
+ let hByWidth = bw / aspect
113
+ if hByWidth <= bh {
114
+ // Content fits within height — horizontal bars top+bottom.
115
+ let y = (bh - hByWidth) / 2
116
+ return CGRect(x: 0, y: y, width: bw, height: hByWidth)
117
+ } else {
118
+ // Vertical bars left+right.
119
+ let wByHeight = bh * aspect
120
+ let x = (bw - wByHeight) / 2
121
+ return CGRect(x: x, y: 0, width: wByHeight, height: bh)
122
+ }
123
+ }
124
+
125
+ /// Camera content aspect ratio (W÷H) in the current device orientation.
126
+ ///
127
+ /// The ARKit sensor is physically landscape (e.g. 1920 × 1440, aspect 4/3).
128
+ /// When the device is portrait the ARSCNView displays the scene rotated,
129
+ /// so the effective content aspect is 3/4. We invert accordingly so the
130
+ /// letterboxed frame always reflects what the user is actually looking at.
131
+ ///
132
+ /// Source priority:
133
+ /// 1. `currentFrame.camera.imageResolution` — live, most accurate.
134
+ /// 2. Active session config's `videoFormat.imageResolution` — stable
135
+ /// after `arSession.run(…)` and before the first frame.
136
+ /// 3. 4:3 hardcoded fallback — correct for every iPhone ARKit camera.
137
+ private func cameraContentAspect() -> CGFloat {
138
+ let rawResolution: CGSize? = {
139
+ if let res = RNSARSession.shared.arSession.currentFrame?.camera.imageResolution {
140
+ return CGSize(width: res.width, height: res.height)
141
+ }
142
+ if let fmt = (RNSARSession.shared.arSession.configuration as? ARWorldTrackingConfiguration)?.videoFormat {
143
+ return CGSize(width: fmt.imageResolution.width, height: fmt.imageResolution.height)
144
+ }
145
+ return nil
146
+ }()
147
+
148
+ // Raw sensor aspect (always landscape > 1 for iPhone cameras).
149
+ let sensorAspect: CGFloat = rawResolution.map { $0.width / $0.height } ?? (4.0 / 3.0)
150
+
151
+ // In portrait mode (view taller than wide) the displayed scene
152
+ // is effectively portrait → invert the sensor aspect.
153
+ let deviceIsPortrait = bounds.height > bounds.width
154
+ return deviceIsPortrait ? (1.0 / sensorAspect) : sensorAspect
86
155
  }
87
156
 
88
157
  public override func didMoveToWindow() {
@@ -99,6 +168,12 @@ public final class RNSARCameraView: UIView {
99
168
  if !RNSARSession.shared.isRunning {
100
169
  RNSARSession.shared.start()
101
170
  }
171
+ // Re-layout after session start: the configuration's
172
+ // videoFormat (and shortly after, currentFrame) are now
173
+ // available for a more accurate aspect ratio. On iOS all
174
+ // ARKit cameras are 4:3 so this is a no-op in practice,
175
+ // but it keeps the code correct for future configs.
176
+ setNeedsLayout()
102
177
  } else {
103
178
  // Removed from window — stop the session. Don't clear
104
179
  // the pose log here; the host explicitly clears between
@@ -485,40 +485,14 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
485
485
  isRunning = true
486
486
  currentTrackingState = .initialising
487
487
 
488
- // v0.8.0 Phase 3c — install the worklet runtime + register
489
- // the first-party stitching callback. The delegate's
490
- // per-frame ingest now routes through
491
- // `RNSARWorkletRuntime.dispatchFrame` (see
492
- // `session(_:didUpdate:)` below) which invokes this
493
- // callback synchronously. Net behavior is byte-identical
494
- // to the pre-Phase-3c direct `consumer.consumeFrame(...)`
495
- // call. The indirection sets up the seam where Phase 4
496
- // will fan out to host worklets without touching this
497
- // first-party path.
498
- RNSARWorkletRuntime.shared().installIfNeeded()
499
- RNSARWorkletRuntime.shared().setFirstPartyCallback {
500
- [weak self] arFrame, pose in
501
- // ARKit pool reuse contract: must consume the pixel
502
- // buffer before returning. The consumer's
503
- // `consumeFrame` does that synchronously inside the
504
- // call (NV12 → cv::Mat sync, then heavy work on its
505
- // own queue). We're on the same thread as the
506
- // delegate (ARSession.delegateQueue), so the contract
507
- // holds end-to-end.
508
- guard let self = self else { return }
509
- guard let consumer = self.incrementalConsumer else { return }
510
- consumer.consumeFrame(pixelBuffer: arFrame.capturedImage,
511
- pose: pose)
512
- }
488
+ // Per-frame ingest calls `incrementalConsumer.consumeFrame`
489
+ // directly from `session(_:didUpdate:)` below. (The v0.8
490
+ // worklet-runtime indirection that used to live here was archived
491
+ // in the batch-keyframe cleanup — see archive/.)
513
492
  }
514
493
 
515
494
  @objc public func stop() {
516
495
  guard isRunning else { return }
517
- // v0.8.0 Phase 3c — drop the first-party callback so the
518
- // closure's `[weak self]` reference can be released
519
- // immediately + no in-flight delegate frame re-enters the
520
- // engine after stop. Idempotent.
521
- RNSARWorkletRuntime.shared().setFirstPartyCallback(nil)
522
496
  arSession.pause()
523
497
  isRunning = false
524
498
  currentTrackingState = .notAvailable
@@ -602,11 +576,12 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
602
576
  // `useFrameProcessor` TS hook + a JSI plugin entry point)
603
577
  // without changing this first-party path.
604
578
  //
605
- // ARKit pool reuse contract: still satisfied the runtime
606
- // invokes the callback synchronously on the delegate
607
- // thread, and the callback's `consumer.consumeFrame(...)`
608
- // does the same NV12 cv::Mat sync conversion as before.
609
- RNSARWorkletRuntime.shared().dispatchFrame(frame, pose: pose)
579
+ // ARKit pool reuse contract: consumeFrame does the NV12 →
580
+ // cv::Mat sync conversion synchronously on the delegate thread
581
+ // before returning, so the captured pixel buffer is safe for
582
+ // ARKit to recycle after this call.
583
+ incrementalConsumer?.consumeFrame(pixelBuffer: frame.capturedImage,
584
+ pose: pose)
610
585
 
611
586
  // If recording is in flight, append this frame to the
612
587
  // asset writer DIRECTLY — no queue hop.
@@ -151,11 +151,11 @@ public enum Stitcher {
151
151
  // IncrementalStitcher) hit the method directly with
152
152
  // the right value.
153
153
  captureOrientation: nil,
154
- // V16 Phase 1b.fix5c — generic Stitcher API defaults the
155
- // crop strategy to bbox-only (matches the operator-default
156
- // in the panorama settings modal). Callers that want
157
- // inscribed-rect can use IncrementalStitcher with
158
- // the toggle on.
154
+ // V16 Phase 1b.fix5c — generic Stitcher API keeps the crop
155
+ // strategy at bbox-only (conservative; the panorama-capture
156
+ // path now defaults to inscribed-rect via the settings).
157
+ // Callers that want inscribed-rect can use IncrementalStitcher
158
+ // with the toggle on.
159
159
  useInscribedRectCrop: false,
160
160
  // 2026-05-22 (audit F2) — legacy video-stitch API doesn't
161
161
  // expose stitchMode in its options dict yet. nil falls
@@ -193,6 +193,72 @@ public enum Stitcher {
193
193
  }
194
194
  }
195
195
 
196
+ /// v0.15 debug — compute the max-inscribed rectangle of the image
197
+ /// (no file change), for the example app's crop-visualisation harness.
198
+ public static func computeInscribedRect(
199
+ imagePath: String
200
+ ) throws -> (x: Int, y: Int, width: Int, height: Int, imageWidth: Int, imageHeight: Int) {
201
+ do {
202
+ let d = try OpenCVStitcher.computeInscribedRect(atPath: imagePath)
203
+ return (
204
+ x: d["x"]?.intValue ?? 0,
205
+ y: d["y"]?.intValue ?? 0,
206
+ width: d["width"]?.intValue ?? 0,
207
+ height: d["height"]?.intValue ?? 0,
208
+ imageWidth: d["imageWidth"]?.intValue ?? 0,
209
+ imageHeight: d["imageHeight"]?.intValue ?? 0
210
+ )
211
+ } catch let nsError as NSError {
212
+ throw StitcherError.fromNSError(nsError)
213
+ }
214
+ }
215
+
216
+ /// v0.15 debug — crop the image to an explicit rectangle (overwrite
217
+ /// in place) and re-encode at `quality`. Pairs with the
218
+ /// computeInscribedRect debug harness.
219
+ public static func cropToRect(
220
+ imagePath: String,
221
+ x: Int,
222
+ y: Int,
223
+ width: Int,
224
+ height: Int,
225
+ quality: Int
226
+ ) throws -> (width: Int, height: Int) {
227
+ do {
228
+ let d = try OpenCVStitcher.cropToRect(
229
+ atPath: imagePath,
230
+ x: x,
231
+ y: y,
232
+ width: width,
233
+ height: height,
234
+ quality: quality
235
+ )
236
+ return (width: d["width"]?.intValue ?? 0, height: d["height"]?.intValue ?? 0)
237
+ } catch let nsError as NSError {
238
+ throw StitcherError.fromNSError(nsError)
239
+ }
240
+ }
241
+
242
+ /// v0.15 debug — write a red-tinted mask overlay (excluded pixels =
243
+ /// red) next to the image and report what fraction the brightness mask
244
+ /// drops. Pairs with the inscribed-rect debug harness.
245
+ public static func debugMaskOverlay(
246
+ imagePath: String,
247
+ threshold: Int
248
+ ) throws -> (maskPath: String, width: Int, height: Int, excludedPercent: Int) {
249
+ do {
250
+ let d = try OpenCVStitcher.debugMaskOverlay(atPath: imagePath, threshold: threshold)
251
+ return (
252
+ maskPath: d["maskPath"] as? String ?? "",
253
+ width: (d["width"] as? NSNumber)?.intValue ?? 0,
254
+ height: (d["height"] as? NSNumber)?.intValue ?? 0,
255
+ excludedPercent: (d["excludedPercent"] as? NSNumber)?.intValue ?? 0
256
+ )
257
+ } catch let nsError as NSError {
258
+ throw StitcherError.fromNSError(nsError)
259
+ }
260
+ }
261
+
196
262
  /// Combined pipeline: extract frames from a recorded video,
197
263
  /// stitch them into a panorama, write the result to
198
264
  /// `options.outputPath`. Used by the host app's tap-and-hold
@@ -202,38 +268,21 @@ public enum Stitcher {
202
268
  /// All temp frame extraction lives in /tmp and is torn down by
203
269
  /// the ObjC layer regardless of success or failure.
204
270
  public static func stitchVideo(
205
- _ options: StitchVideoOptions,
206
- poses: [[String: Any]]? = nil
271
+ _ options: StitchVideoOptions
207
272
  ) throws -> StitchResult {
208
273
  do {
209
- let result: RNStitchResult
210
- if let poses = poses, !poses.isEmpty {
211
- // Phase 5: pose-driven path. Skips features matching →
212
- // BundleAdjuster on the native side; cv::detail::CameraParams
213
- // come straight from the ARKit poses with the appropriate
214
- // coordinate-system flip (Y-up → Y-down, -Z → +Z).
215
- result = try OpenCVStitcher.stitchVideo(
216
- atPath: options.videoPath,
217
- outputPath: options.outputPath,
218
- maxFrames: options.maxFrames,
219
- jpegQuality: options.jpegQuality,
220
- warperType: options.warperType,
221
- blenderType: options.blenderType,
222
- seamFinderType: options.seamFinderType,
223
- poses: poses
224
- )
225
- } else {
226
- // Existing feature-matched path.
227
- result = try OpenCVStitcher.stitchVideo(
228
- atPath: options.videoPath,
229
- outputPath: options.outputPath,
230
- maxFrames: options.maxFrames,
231
- jpegQuality: options.jpegQuality,
232
- warperType: options.warperType,
233
- blenderType: options.blenderType,
234
- seamFinderType: options.seamFinderType
235
- )
236
- }
274
+ // The pose-driven video stitch (the old native `withPoses:`
275
+ // path) was archived in the 2026-06 batch-keyframe cleanup;
276
+ // this now always runs the feature-matched compose.
277
+ let result = try OpenCVStitcher.stitchVideo(
278
+ atPath: options.videoPath,
279
+ outputPath: options.outputPath,
280
+ maxFrames: options.maxFrames,
281
+ jpegQuality: options.jpegQuality,
282
+ warperType: options.warperType,
283
+ blenderType: options.blenderType,
284
+ seamFinderType: options.seamFinderType
285
+ )
237
286
  return StitchResult(
238
287
  outputPath: result.outputPath,
239
288
  width: result.width,
@@ -25,4 +25,17 @@ RCT_EXTERN_METHOD(normaliseOrientation:(NSDictionary *)options
25
25
  resolver:(RCTPromiseResolveBlock)resolver
26
26
  rejecter:(RCTPromiseRejectBlock)rejecter)
27
27
 
28
+ // v0.15 debug harness (inscribed-rect visualisation in the example app).
29
+ RCT_EXTERN_METHOD(computeInscribedRect:(NSDictionary *)options
30
+ resolver:(RCTPromiseResolveBlock)resolver
31
+ rejecter:(RCTPromiseRejectBlock)rejecter)
32
+
33
+ RCT_EXTERN_METHOD(cropToRect:(NSDictionary *)options
34
+ resolver:(RCTPromiseResolveBlock)resolver
35
+ rejecter:(RCTPromiseRejectBlock)rejecter)
36
+
37
+ RCT_EXTERN_METHOD(debugMaskOverlay:(NSDictionary *)options
38
+ resolver:(RCTPromiseResolveBlock)resolver
39
+ rejecter:(RCTPromiseRejectBlock)rejecter)
40
+
28
41
  @end
@@ -95,10 +95,6 @@ public class StitcherBridge: NSObject {
95
95
  let warperType = (options["warperType"] as? String) ?? "plane"
96
96
  let blenderType = (options["blenderType"] as? String) ?? "multiband"
97
97
  let seamFinderType = (options["seamFinderType"] as? String) ?? "graphcut"
98
- // Optional pose log from the host's RNSARSession snapshot.
99
- // When present and non-empty, the native stitcher routes to the
100
- // pose-driven path (skips features → matching → BA).
101
- let poses = options["poses"] as? [[String: Any]]
102
98
 
103
99
  let stitchOpts = StitchVideoOptions(
104
100
  videoPath: videoPath,
@@ -112,7 +108,7 @@ public class StitcherBridge: NSObject {
112
108
 
113
109
  DispatchQueue.global(qos: .userInitiated).async {
114
110
  do {
115
- let result = try Stitcher.stitchVideo(stitchOpts, poses: poses)
111
+ let result = try Stitcher.stitchVideo(stitchOpts)
116
112
  resolver([
117
113
  "outputPath": result.outputPath,
118
114
  "width": result.width,
@@ -179,6 +175,137 @@ public class StitcherBridge: NSObject {
179
175
  }
180
176
  }
181
177
 
178
+ /// v0.15 debug — compute the max-inscribed rectangle of `imagePath`
179
+ /// without modifying it. Resolves
180
+ /// `{ x, y, width, height, imageWidth, imageHeight }`.
181
+ @objc(computeInscribedRect:resolver:rejecter:)
182
+ public func computeInscribedRect(
183
+ options: NSDictionary,
184
+ resolver: @escaping RCTPromiseResolveBlock,
185
+ rejecter: @escaping RCTPromiseRejectBlock
186
+ ) {
187
+ guard let imagePath = options["imagePath"] as? String else {
188
+ rejecter("invalid-options", "imagePath must be a string", nil)
189
+ return
190
+ }
191
+ DispatchQueue.global(qos: .userInitiated).async {
192
+ do {
193
+ let r = try Stitcher.computeInscribedRect(imagePath: imagePath)
194
+ resolver([
195
+ "x": r.x,
196
+ "y": r.y,
197
+ "width": r.width,
198
+ "height": r.height,
199
+ "imageWidth": r.imageWidth,
200
+ "imageHeight": r.imageHeight,
201
+ ])
202
+ } catch let err as StitcherError {
203
+ switch err {
204
+ case .insufficientFrames(let count):
205
+ rejecter("insufficient-frames", "(unexpected for computeInscribedRect) frames=\(count)", err)
206
+ case .readFailed(let path):
207
+ rejecter("read-failed", "Could not read image: \(path)", err)
208
+ case .writeFailed(let path):
209
+ rejecter("write-failed", "Could not write image: \(path)", err)
210
+ case .opencvFailed(let code, let message):
211
+ rejecter("opencv-failed-\(code)", message, err)
212
+ }
213
+ } catch {
214
+ rejecter("unknown", "Unexpected computeInscribedRect failure: \(error)", error)
215
+ }
216
+ }
217
+ }
218
+
219
+ /// v0.15 debug — crop `imagePath` to the rectangle in `options`
220
+ /// (`x`, `y`, `width`, `height`) at `quality` (default 90), overwriting
221
+ /// in place. Resolves the final `{ width, height }`.
222
+ @objc(cropToRect:resolver:rejecter:)
223
+ public func cropToRect(
224
+ options: NSDictionary,
225
+ resolver: @escaping RCTPromiseResolveBlock,
226
+ rejecter: @escaping RCTPromiseRejectBlock
227
+ ) {
228
+ guard let imagePath = options["imagePath"] as? String else {
229
+ rejecter("invalid-options", "imagePath must be a string", nil)
230
+ return
231
+ }
232
+ let x = (options["x"] as? NSNumber)?.intValue ?? 0
233
+ let y = (options["y"] as? NSNumber)?.intValue ?? 0
234
+ let width = (options["width"] as? NSNumber)?.intValue ?? 0
235
+ let height = (options["height"] as? NSNumber)?.intValue ?? 0
236
+ let quality = (options["quality"] as? NSNumber)?.intValue ?? 90
237
+ DispatchQueue.global(qos: .userInitiated).async {
238
+ do {
239
+ let dims = try Stitcher.cropToRect(
240
+ imagePath: imagePath,
241
+ x: x,
242
+ y: y,
243
+ width: width,
244
+ height: height,
245
+ quality: quality
246
+ )
247
+ resolver([
248
+ "width": dims.width,
249
+ "height": dims.height,
250
+ ])
251
+ } catch let err as StitcherError {
252
+ switch err {
253
+ case .insufficientFrames(let count):
254
+ rejecter("insufficient-frames", "(unexpected for cropToRect) frames=\(count)", err)
255
+ case .readFailed(let path):
256
+ rejecter("read-failed", "Could not read image: \(path)", err)
257
+ case .writeFailed(let path):
258
+ rejecter("write-failed", "Could not write image: \(path)", err)
259
+ case .opencvFailed(let code, let message):
260
+ rejecter("opencv-failed-\(code)", message, err)
261
+ }
262
+ } catch {
263
+ rejecter("unknown", "Unexpected cropToRect failure: \(error)", error)
264
+ }
265
+ }
266
+ }
267
+
268
+ /// v0.15 debug — write a red-tinted mask overlay for `imagePath`
269
+ /// (excluded pixels red). `threshold` optional (default 1, matching the
270
+ /// inscribed-rect mask). Resolves
271
+ /// `{ maskPath, width, height, excludedPercent }`.
272
+ @objc(debugMaskOverlay:resolver:rejecter:)
273
+ public func debugMaskOverlay(
274
+ options: NSDictionary,
275
+ resolver: @escaping RCTPromiseResolveBlock,
276
+ rejecter: @escaping RCTPromiseRejectBlock
277
+ ) {
278
+ guard let imagePath = options["imagePath"] as? String else {
279
+ rejecter("invalid-options", "imagePath must be a string", nil)
280
+ return
281
+ }
282
+ let threshold = (options["threshold"] as? NSNumber)?.intValue ?? 1
283
+ DispatchQueue.global(qos: .userInitiated).async {
284
+ do {
285
+ let r = try Stitcher.debugMaskOverlay(imagePath: imagePath, threshold: threshold)
286
+ resolver([
287
+ "maskPath": r.maskPath,
288
+ "width": r.width,
289
+ "height": r.height,
290
+ "excludedPercent": r.excludedPercent,
291
+ ])
292
+ } catch let err as StitcherError {
293
+ switch err {
294
+ case .insufficientFrames(let count):
295
+ rejecter("insufficient-frames", "(unexpected for debugMaskOverlay) frames=\(count)", err)
296
+ case .readFailed(let path):
297
+ rejecter("read-failed", "Could not read image: \(path)", err)
298
+ case .writeFailed(let path):
299
+ rejecter("write-failed", "Could not write image: \(path)", err)
300
+ case .opencvFailed(let code, let message):
301
+ rejecter("opencv-failed-\(code)", message, err)
302
+ }
303
+ } catch {
304
+ rejecter("unknown", "Unexpected debugMaskOverlay failure: \(error)", error)
305
+ }
306
+ }
307
+ }
308
+
182
309
  @objc(stitch:resolver:rejecter:)
183
310
  public func stitch(
184
311
  options: NSDictionary,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.14.2",
3
+ "version": "0.15.1",
4
4
  "description": "Pose-aware panorama capture + stitching for React Native. One <Camera> component, both tap-to-photo and hold-to-pan modes, both AR-backed and IMU-fallback capture paths.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -25,10 +25,11 @@
25
25
  "CHANGELOG.md"
26
26
  ],
27
27
  "scripts": {
28
- "build": "tsc -p tsconfig.build.json",
28
+ "build": "npm run clean && tsc -p tsconfig.build.json",
29
29
  "typecheck": "tsc --noEmit",
30
30
  "test": "jest",
31
31
  "clean": "rm -rf dist",
32
+ "prepublishOnly": "npm run build && npm run typecheck && npm test",
32
33
  "postinstall": "node scripts/postinstall-fetch-binaries.js"
33
34
  },
34
35
  "keywords": [
@@ -242,6 +242,11 @@ export interface CameraProps {
242
242
  defaultFlowMaxTranslationCm?: number;
243
243
  defaultKeyframeMaxCount?: number;
244
244
  defaultKeyframeOverlapThreshold?: number;
245
+ /** Time-budget force-accept (ms) for the keyframe gate — accept a
246
+ * keyframe at least this often during a pan even if novelty is low,
247
+ * so slow / static pans don't leave temporal gaps. `0` disables it.
248
+ * Default 2000 (2 s). Applies to both AR and non-AR captures. */
249
+ defaultMaxKeyframeIntervalMs?: number;
245
250
  /** Forward-looking — wires through to cv::Stitcher's compositingResol
246
251
  * once PanoramaSettings exposes the field (currently a no-op). */
247
252
  defaultCompositingResolMP?: number;
@@ -250,6 +255,28 @@ export interface CameraProps {
250
255
  /** Forward-looking — see above. */
251
256
  defaultSeamEstimationResolMP?: number;
252
257
 
258
+ // ── Inscribed-rect crop (v0.15) ───────────────────────────────────
259
+ /**
260
+ * Crop strategy for the stitched panorama. `false` (default) keeps the
261
+ * bounding-rect of non-black pixels, which preserves all stitched
262
+ * content but may leave black corners. `true` crops to the maximum
263
+ * axis-aligned rectangle inscribed in the coverage mask — clean edges,
264
+ * no black corners (slightly more CPU at finalize) — but it can shrink
265
+ * the output substantially on lopsided / ultra-wide masks, which is why
266
+ * it's opt-in.
267
+ *
268
+ * Implemented as a start-time stitcher config (like the other
269
+ * stitcher settings), so this value is read once at mount to seed the
270
+ * initial setting; the in-app settings modal can override it at
271
+ * runtime. It changes image geometry (the crop), not encoding.
272
+ *
273
+ * Since the default is `false`, only pass this prop to opt in:
274
+ * @example
275
+ * // Crop to a clean inscribed rectangle (no black corners):
276
+ * <Camera maxInscribedRectCrop={true} />
277
+ */
278
+ maxInscribedRectCrop?: boolean;
279
+
253
280
  // ── UI knobs ──────────────────────────────────────────────────────
254
281
  enablePhotoMode?: boolean;
255
282
  enablePanoramaMode?: boolean;
@@ -270,27 +297,13 @@ export interface CameraProps {
270
297
  style?: StyleProp<ViewStyle>;
271
298
 
272
299
  /**
273
- * Which incremental stitcher engine to drive. Default
274
- * `'batch-keyframe'` collects accepted JPEGs and runs
275
- * `cv::Stitcher` once at finalize time. This is the v0.4+
276
- * production default and what the v0.5 Frame Processor migration
277
- * exercises.
278
- *
279
- * Switch to a live engine (`'firstwins-rectilinear'` or
280
- * `'hybrid'`) for low-latency in-flight stitching. Live engines
281
- * exercise the F8.6 pixel-buffer ingest path (skipping the JPEG
282
- * encode/decode round-trip; ~30–50 ms saved per accept) when the
283
- * Frame Processor driver is active.
284
- *
285
- * See `docs/f8-frame-processor-plan.md` and the v0.5.0
286
- * CHANGELOG for the trade-offs between batch-keyframe and live
287
- * engines.
300
+ * Which stitcher engine to drive. Only `'batch-keyframe'` is
301
+ * supported (and the default): it collects accepted keyframe JPEGs
302
+ * during the hold-pan-release capture and runs the stitch once at
303
+ * finalize. The live engines (hybrid / slit-scan / firstwins) were
304
+ * archived in the batch-keyframe cleanup — see `archive/`.
288
305
  */
289
- engine?: 'batch-keyframe'
290
- | 'hybrid'
291
- | 'slitscan-rotate' | 'slitscan-both'
292
- | 'firstwins' | 'firstwins-zoomed' | 'firstwins-rectilinear'
293
- | 'slitscan';
306
+ engine?: 'batch-keyframe';
294
307
 
295
308
  /**
296
309
  * Optional destination directory for captures. When set, the lib
@@ -865,6 +878,8 @@ function extractPanoramaOverrides(props: CameraProps): PanoramaPropOverrides {
865
878
  defaultFlowMaxTranslationCm: props.defaultFlowMaxTranslationCm,
866
879
  defaultKeyframeMaxCount: props.defaultKeyframeMaxCount,
867
880
  defaultKeyframeOverlapThreshold: props.defaultKeyframeOverlapThreshold,
881
+ defaultMaxKeyframeIntervalMs: props.defaultMaxKeyframeIntervalMs,
882
+ maxInscribedRectCrop: props.maxInscribedRectCrop,
868
883
  };
869
884
  }
870
885
 
@@ -884,7 +899,7 @@ function extractPanoramaOverrides(props: CameraProps): PanoramaPropOverrides {
884
899
  */
885
900
  export function Camera(props: CameraProps): React.JSX.Element {
886
901
  const {
887
- defaultCaptureSource = 'ar',
902
+ defaultCaptureSource = 'non-ar',
888
903
  defaultLens = '1x',
889
904
  captureSources = 'both',
890
905
  enablePhotoMode = true,
@@ -1433,6 +1448,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
1433
1448
  width = result.width;
1434
1449
  height = result.height;
1435
1450
  }
1451
+
1436
1452
  onCapture?.({ type: 'photo', uri, width, height });
1437
1453
  } catch (err) {
1438
1454
  const e = err instanceof CameraError
@@ -1576,7 +1592,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
1576
1592
  isNonAR ? imuGate.getTotalAbsMetres() : 0;
1577
1593
  const result = await incremental.finalize(
1578
1594
  panoOutputPath,
1579
- 90,
1595
+ 90, // default JPEG quality
1580
1596
  deviceOrientation,
1581
1597
  imuTotalTranslationM,
1582
1598
  );
@@ -1590,6 +1606,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
1590
1606
  included: result.framesIncluded,
1591
1607
  });
1592
1608
  }
1609
+
1593
1610
  onCapture?.({
1594
1611
  type: 'panorama',
1595
1612
  // Native finalize() returns a bare `/data/.../foo.jpg` path;
@@ -1615,7 +1632,11 @@ export function Camera(props: CameraProps): React.JSX.Element {
1615
1632
  } catch (err) {
1616
1633
  const message = err instanceof Error ? err.message : String(err);
1617
1634
  const code: CameraErrorCode =
1618
- /need more images/i.test(message) ? 'STITCH_NEED_MORE_IMGS'
1635
+ // Insufficient overlap surfaces two ways: cv::Stitcher's
1636
+ // ERR_NEED_MORE_IMGS ("need more images") and the manual
1637
+ // pipeline's "0 valid pairwise matches / frames may not overlap
1638
+ // enough" — both are the same recoverable "pan more slowly" case.
1639
+ /need more images|pairwise match|overlap enough/i.test(message) ? 'STITCH_NEED_MORE_IMGS'
1619
1640
  : /homography/i.test(message) ? 'STITCH_HOMOGRAPHY_FAIL'
1620
1641
  : /camera params/i.test(message) ? 'STITCH_CAMERA_PARAMS_FAIL'
1621
1642
  : /out of memory|oom/i.test(message) ? 'STITCH_OOM'