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.
- package/CHANGELOG.md +164 -0
- package/README.md +35 -0
- package/RNImageStitcher.podspec +8 -7
- package/android/build.gradle +0 -16
- package/android/src/main/cpp/CMakeLists.txt +2 -63
- package/android/src/main/cpp/image_stitcher_jni.cpp +14 -0
- package/android/src/main/cpp/keyframe_gate_jni.cpp +13 -0
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +285 -3
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +180 -1162
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +29 -0
- package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +0 -4
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +129 -71
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +49 -0
- package/cpp/keyframe_gate.cpp +82 -23
- package/cpp/keyframe_gate.hpp +31 -2
- package/cpp/stitcher.cpp +208 -28
- package/cpp/tests/CMakeLists.txt +18 -12
- package/cpp/tests/keyframe_timebudget_test.cpp +65 -0
- package/cpp/tests/warp_guard_test.cpp +48 -0
- package/cpp/warp_guard.hpp +41 -0
- package/dist/camera/Camera.d.ts +31 -16
- package/dist/camera/Camera.js +11 -3
- package/dist/camera/CameraView.js +93 -3
- package/dist/camera/CaptureStitchStatsToast.d.ts +15 -2
- package/dist/camera/CaptureStitchStatsToast.js +27 -7
- package/dist/camera/PanoramaSettings.d.ts +10 -223
- package/dist/camera/PanoramaSettings.js +6 -28
- package/dist/camera/PanoramaSettingsBridge.d.ts +1 -24
- package/dist/camera/PanoramaSettingsBridge.js +3 -102
- package/dist/camera/PanoramaSettingsModal.js +7 -1
- package/dist/camera/buildPanoramaInitialSettings.d.ts +11 -0
- package/dist/camera/buildPanoramaInitialSettings.js +4 -0
- package/dist/camera/cameraErrorMessages.d.ts +32 -0
- package/dist/camera/cameraErrorMessages.js +53 -0
- package/dist/camera/selectCaptureDevice.d.ts +5 -1
- package/dist/camera/selectCaptureDevice.js +22 -2
- package/dist/camera/useCapture.js +38 -0
- package/dist/index.d.ts +5 -8
- package/dist/index.js +11 -34
- package/dist/stitching/incremental.d.ts +1 -117
- package/dist/stitching/stitchVideo.d.ts +0 -35
- package/dist/types.d.ts +0 -87
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +96 -674
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +9 -12
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +14 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +7 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +6 -0
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +2 -2
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +3 -3
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +28 -60
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +180 -921
- package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +82 -7
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +10 -35
- package/ios/Sources/RNImageStitcher/Stitcher.swift +84 -35
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +13 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +132 -5
- package/package.json +3 -2
- package/src/camera/Camera.tsx +44 -23
- package/src/camera/CameraView.tsx +113 -4
- package/src/camera/CaptureStitchStatsToast.tsx +58 -14
- package/src/camera/PanoramaSettings.ts +16 -289
- package/src/camera/PanoramaSettingsBridge.ts +3 -114
- package/src/camera/PanoramaSettingsModal.tsx +14 -1
- package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +3 -188
- package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +41 -0
- package/src/camera/__tests__/cameraErrorMessages.test.ts +76 -0
- package/src/camera/__tests__/selectCaptureDevice.test.ts +33 -0
- package/src/camera/buildPanoramaInitialSettings.ts +17 -0
- package/src/camera/cameraErrorMessages.ts +84 -0
- package/src/camera/selectCaptureDevice.ts +28 -3
- package/src/camera/useCapture.ts +44 -1
- package/src/index.ts +11 -40
- package/src/stitching/incremental.ts +3 -140
- package/src/stitching/stitchVideo.ts +0 -26
- package/src/types.ts +0 -95
- package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +0 -227
- package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +0 -1081
- package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +0 -103
- package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +0 -256
- package/cpp/stitcher_frame_jsi.cpp +0 -214
- package/cpp/stitcher_frame_jsi.hpp +0 -108
- package/cpp/stitcher_proxy_jsi.cpp +0 -109
- package/cpp/stitcher_proxy_jsi.hpp +0 -46
- package/cpp/stitcher_worklet_dispatch.cpp +0 -103
- package/cpp/stitcher_worklet_dispatch.hpp +0 -71
- package/cpp/stitcher_worklet_registry.cpp +0 -91
- package/cpp/stitcher_worklet_registry.hpp +0 -146
- package/cpp/tests/stitcher_worklet_registry_test.cpp +0 -195
- package/dist/stitching/IncrementalStitcherView.d.ts +0 -41
- package/dist/stitching/IncrementalStitcherView.js +0 -157
- package/dist/stitching/StitcherWorkletRegistry.d.ts +0 -117
- package/dist/stitching/StitcherWorkletRegistry.js +0 -78
- package/dist/stitching/ensureStitcherProxyInstalled.d.ts +0 -8
- package/dist/stitching/ensureStitcherProxyInstalled.js +0 -81
- package/dist/stitching/useFrameProcessor.d.ts +0 -119
- package/dist/stitching/useFrameProcessor.js +0 -196
- package/dist/stitching/useFrameStream.d.ts +0 -34
- package/dist/stitching/useFrameStream.js +0 -234
- package/dist/stitching/useThrottledFrameProcessor.d.ts +0 -33
- package/dist/stitching/useThrottledFrameProcessor.js +0 -132
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +0 -474
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +0 -1328
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +0 -103
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +0 -3285
- package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +0 -128
- package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +0 -313
- package/ios/Sources/RNImageStitcher/SaveFrameAsJpegPlugin.mm +0 -185
- package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.h +0 -60
- package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.mm +0 -214
- package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.h +0 -42
- package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +0 -160
- package/src/stitching/IncrementalStitcherView.tsx +0 -198
- package/src/stitching/StitcherWorkletRegistry.ts +0 -156
- package/src/stitching/__tests__/StitcherWorkletRegistry.test.ts +0 -176
- package/src/stitching/__tests__/ensureStitcherProxyInstalled.test.ts +0 -94
- package/src/stitching/__tests__/useThrottledFrameProcessor.test.ts +0 -178
- package/src/stitching/ensureStitcherProxyInstalled.ts +0 -141
- package/src/stitching/useFrameProcessor.ts +0 -226
- package/src/stitching/useFrameStream.ts +0 -271
- 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
|
-
|
|
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
|
|
75
|
-
//
|
|
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
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
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
|
-
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
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:
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
|
|
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
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
// inscribed-rect can use IncrementalStitcher
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
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.
|
|
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": [
|
package/src/camera/Camera.tsx
CHANGED
|
@@ -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
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
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
|
-
|
|
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'
|