react-native-image-stitcher 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +21 -0
  4. package/README.md +189 -0
  5. package/RNImageStitcher.podspec +76 -0
  6. package/android/build.gradle +224 -0
  7. package/android/src/main/AndroidManifest.xml +3 -0
  8. package/android/src/main/cpp/CMakeLists.txt +124 -0
  9. package/android/src/main/cpp/image_stitcher_jni.cpp +145 -0
  10. package/android/src/main/cpp/keyframe_gate_jni.cpp +204 -0
  11. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +426 -0
  12. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +960 -0
  13. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +2371 -0
  14. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +256 -0
  15. package/android/src/main/java/io/imagestitcher/rn/QualityChecker.kt +167 -0
  16. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +39 -0
  17. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +558 -0
  18. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +35 -0
  19. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +784 -0
  20. package/android/src/main/java/io/imagestitcher/rn/ar/BackgroundRenderer.kt +176 -0
  21. package/android/src/main/java/io/imagestitcher/rn/ar/ShaderUtil.kt +67 -0
  22. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +201 -0
  23. package/cpp/ar_frame_pose.h +63 -0
  24. package/cpp/keyframe_gate.cpp +927 -0
  25. package/cpp/keyframe_gate.hpp +240 -0
  26. package/cpp/stitcher.cpp +2207 -0
  27. package/cpp/stitcher.hpp +275 -0
  28. package/dist/ar/useARSession.d.ts +102 -0
  29. package/dist/ar/useARSession.js +133 -0
  30. package/dist/camera/ARCameraView.d.ts +93 -0
  31. package/dist/camera/ARCameraView.js +170 -0
  32. package/dist/camera/Camera.d.ts +134 -0
  33. package/dist/camera/Camera.js +688 -0
  34. package/dist/camera/CameraShutter.d.ts +80 -0
  35. package/dist/camera/CameraShutter.js +237 -0
  36. package/dist/camera/CameraView.d.ts +65 -0
  37. package/dist/camera/CameraView.js +117 -0
  38. package/dist/camera/CaptureControlsBar.d.ts +87 -0
  39. package/dist/camera/CaptureControlsBar.js +82 -0
  40. package/dist/camera/CaptureHeader.d.ts +62 -0
  41. package/dist/camera/CaptureHeader.js +81 -0
  42. package/dist/camera/CapturePreview.d.ts +70 -0
  43. package/dist/camera/CapturePreview.js +188 -0
  44. package/dist/camera/CaptureStatusOverlay.d.ts +75 -0
  45. package/dist/camera/CaptureStatusOverlay.js +326 -0
  46. package/dist/camera/CaptureThumbnailStrip.d.ts +87 -0
  47. package/dist/camera/CaptureThumbnailStrip.js +177 -0
  48. package/dist/camera/IncrementalPanGuide.d.ts +83 -0
  49. package/dist/camera/IncrementalPanGuide.js +267 -0
  50. package/dist/camera/PanoramaBandOverlay.d.ts +107 -0
  51. package/dist/camera/PanoramaBandOverlay.js +399 -0
  52. package/dist/camera/PanoramaConfirmModal.d.ts +57 -0
  53. package/dist/camera/PanoramaConfirmModal.js +128 -0
  54. package/dist/camera/PanoramaGuidance.d.ts +79 -0
  55. package/dist/camera/PanoramaGuidance.js +246 -0
  56. package/dist/camera/PanoramaSettingsModal.d.ts +311 -0
  57. package/dist/camera/PanoramaSettingsModal.js +611 -0
  58. package/dist/camera/ViewportCropOverlay.d.ts +46 -0
  59. package/dist/camera/ViewportCropOverlay.js +67 -0
  60. package/dist/camera/useCapture.d.ts +111 -0
  61. package/dist/camera/useCapture.js +160 -0
  62. package/dist/camera/useDeviceOrientation.d.ts +48 -0
  63. package/dist/camera/useDeviceOrientation.js +131 -0
  64. package/dist/camera/useVideoCapture.d.ts +79 -0
  65. package/dist/camera/useVideoCapture.js +151 -0
  66. package/dist/index.d.ts +26 -0
  67. package/dist/index.js +39 -0
  68. package/dist/quality/normaliseOrientation.d.ts +36 -0
  69. package/dist/quality/normaliseOrientation.js +62 -0
  70. package/dist/quality/runQualityCheck.d.ts +41 -0
  71. package/dist/quality/runQualityCheck.js +98 -0
  72. package/dist/sensors/useIMUTranslationGate.d.ts +70 -0
  73. package/dist/sensors/useIMUTranslationGate.js +235 -0
  74. package/dist/stitching/IncrementalStitcherView.d.ts +41 -0
  75. package/dist/stitching/IncrementalStitcherView.js +157 -0
  76. package/dist/stitching/incremental.d.ts +930 -0
  77. package/dist/stitching/incremental.js +133 -0
  78. package/dist/stitching/stitchFrames.d.ts +55 -0
  79. package/dist/stitching/stitchFrames.js +56 -0
  80. package/dist/stitching/stitchVideo.d.ts +119 -0
  81. package/dist/stitching/stitchVideo.js +57 -0
  82. package/dist/stitching/useIncrementalJSDriver.d.ts +74 -0
  83. package/dist/stitching/useIncrementalJSDriver.js +199 -0
  84. package/dist/stitching/useIncrementalStitcher.d.ts +58 -0
  85. package/dist/stitching/useIncrementalStitcher.js +172 -0
  86. package/dist/types.d.ts +58 -0
  87. package/dist/types.js +15 -0
  88. package/ios/Package.swift +72 -0
  89. package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +33 -0
  90. package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +40 -0
  91. package/ios/Sources/RNImageStitcher/ARSessionBridge.m +55 -0
  92. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +149 -0
  93. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +2727 -0
  94. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +85 -0
  95. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +625 -0
  96. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +328 -0
  97. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +141 -0
  98. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +278 -0
  99. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +473 -0
  100. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +1326 -0
  101. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +97 -0
  102. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +296 -0
  103. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +103 -0
  104. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +3285 -0
  105. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +238 -0
  106. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +1880 -0
  107. package/ios/Sources/RNImageStitcher/QualityChecker.swift +252 -0
  108. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.m +26 -0
  109. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.swift +72 -0
  110. package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +114 -0
  111. package/ios/Sources/RNImageStitcher/RNSARSession.swift +1111 -0
  112. package/ios/Sources/RNImageStitcher/Stitcher.swift +243 -0
  113. package/ios/Sources/RNImageStitcher/StitcherBridge.m +28 -0
  114. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +246 -0
  115. package/package.json +73 -0
  116. package/react-native.config.js +34 -0
  117. package/scripts/opencv-version.txt +1 -0
  118. package/scripts/postinstall-fetch-binaries.js +286 -0
  119. package/src/ar/useARSession.ts +210 -0
  120. package/src/camera/.gitkeep +0 -0
  121. package/src/camera/ARCameraView.tsx +256 -0
  122. package/src/camera/Camera.tsx +1053 -0
  123. package/src/camera/CameraShutter.tsx +292 -0
  124. package/src/camera/CameraView.tsx +157 -0
  125. package/src/camera/CaptureControlsBar.tsx +204 -0
  126. package/src/camera/CaptureHeader.tsx +184 -0
  127. package/src/camera/CapturePreview.tsx +318 -0
  128. package/src/camera/CaptureStatusOverlay.tsx +391 -0
  129. package/src/camera/CaptureThumbnailStrip.tsx +277 -0
  130. package/src/camera/IncrementalPanGuide.tsx +328 -0
  131. package/src/camera/PanoramaBandOverlay.tsx +498 -0
  132. package/src/camera/PanoramaConfirmModal.tsx +206 -0
  133. package/src/camera/PanoramaGuidance.tsx +327 -0
  134. package/src/camera/PanoramaSettingsModal.tsx +1357 -0
  135. package/src/camera/ViewportCropOverlay.tsx +81 -0
  136. package/src/camera/useCapture.ts +279 -0
  137. package/src/camera/useDeviceOrientation.ts +140 -0
  138. package/src/camera/useVideoCapture.ts +236 -0
  139. package/src/index.ts +53 -0
  140. package/src/quality/.gitkeep +0 -0
  141. package/src/quality/normaliseOrientation.ts +79 -0
  142. package/src/quality/runQualityCheck.ts +131 -0
  143. package/src/sensors/useIMUTranslationGate.ts +347 -0
  144. package/src/stitching/.gitkeep +0 -0
  145. package/src/stitching/IncrementalStitcherView.tsx +198 -0
  146. package/src/stitching/incremental.ts +1021 -0
  147. package/src/stitching/stitchFrames.ts +88 -0
  148. package/src/stitching/stitchVideo.ts +153 -0
  149. package/src/stitching/useIncrementalJSDriver.ts +273 -0
  150. package/src/stitching/useIncrementalStitcher.ts +252 -0
  151. package/src/types.ts +78 -0
@@ -0,0 +1,243 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Stitcher.swift
3
+ //
4
+ // Pure-Swift wrapper around the Objective-C(++) `OpenCVStitcher`.
5
+ // Mirrors the layering of `QualityChecker.swift` ↔ `QualityCheckerBridge.swift`
6
+ // from Phase 1: this file does the type translation between Swift
7
+ // idioms (Result, throwing functions, Error) and the ObjC interface.
8
+ // It does NOT import OpenCV or React — keeping it framework-light is
9
+ // what lets `swift test` build clean from the command line.
10
+ //
11
+ // XCTest coverage:
12
+ // The pure algorithmic part of stitching lives in OpenCV itself
13
+ // (cv::Stitcher::SCANS — well-tested upstream). Our test surface
14
+ // is therefore "did we wire it correctly + handle errors right?",
15
+ // covered by `StitcherTests.swift` against synthetic fixture
16
+ // images. Those tests need an iOS simulator (OpenCV ships only
17
+ // iOS XCFramework binaries from the opencv-mobile fork), so they
18
+ // live behind an availability check that lets `swift test` skip
19
+ // them gracefully on macOS-only CI.
20
+
21
+ #if canImport(UIKit)
22
+ import Foundation
23
+ import UIKit
24
+
25
+ // `OpenCVStitcher` is an ObjC class; the SwiftPM target excludes the
26
+ // .mm files (they need OpenCV to compile) but the Pods build does
27
+ // include them. When the Pods build is the one running, this Swift
28
+ // file links against the ObjC class via the umbrella header.
29
+ //
30
+ // In the SwiftPM macOS test build, OpenCVStitcher isn't available;
31
+ // `canImport(UIKit)` plus the .mm file's exclusion in Package.swift
32
+ // keeps everything compilable.
33
+
34
+ public struct StitchOptions {
35
+ public let framePaths: [String]
36
+ public let outputPath: String
37
+ public let jpegQuality: Int
38
+ public init(framePaths: [String], outputPath: String, jpegQuality: Int = 85) {
39
+ self.framePaths = framePaths
40
+ self.outputPath = outputPath
41
+ self.jpegQuality = jpegQuality
42
+ }
43
+ }
44
+
45
+ public struct StitchVideoOptions {
46
+ /// Path to the recorded mp4 (with or without `file://` prefix).
47
+ public let videoPath: String
48
+ /// Where the resulting panoramic JPEG should be written.
49
+ public let outputPath: String
50
+ /// How many frames to sample from the video for stitching.
51
+ /// 10 is the empirical sweet spot — enough overlap to keep
52
+ /// homography solid, few enough that stitching stays under 4
53
+ /// seconds on iPhone 14+ for a typical ~3s pan.
54
+ public let maxFrames: Int
55
+ /// JPEG quality [0..100] applied to BOTH the intermediate
56
+ /// frames AND the final panorama.
57
+ public let jpegQuality: Int
58
+ /// "plane" / "cylindrical" / "spherical". See OpenCVStitcher.h
59
+ /// for guidance. Default "plane".
60
+ public let warperType: String
61
+ /// "multiband" / "feather". Default "multiband".
62
+ public let blenderType: String
63
+ /// "graphcut" / "skip". Default "graphcut".
64
+ /// "graphcut" runs cv::detail::GraphCutSeamFinder for clean
65
+ /// seams (more memory). "skip" streams warp+feed for low peak
66
+ /// memory at the cost of less optimal seams.
67
+ public let seamFinderType: String
68
+ public init(
69
+ videoPath: String,
70
+ outputPath: String,
71
+ maxFrames: Int = 10,
72
+ jpegQuality: Int = 85,
73
+ warperType: String = "plane",
74
+ blenderType: String = "multiband",
75
+ seamFinderType: String = "graphcut"
76
+ ) {
77
+ self.videoPath = videoPath
78
+ self.outputPath = outputPath
79
+ self.maxFrames = maxFrames
80
+ self.jpegQuality = jpegQuality
81
+ self.warperType = warperType
82
+ self.blenderType = blenderType
83
+ self.seamFinderType = seamFinderType
84
+ }
85
+ }
86
+
87
+ public struct StitchResult: Equatable {
88
+ public let outputPath: String
89
+ public let width: Int
90
+ public let height: Int
91
+ public let durationMs: Double
92
+ }
93
+
94
+ public enum StitcherError: Error {
95
+ case insufficientFrames(count: Int)
96
+ case readFailed(path: String)
97
+ case writeFailed(path: String)
98
+ case opencvFailed(code: Int, message: String)
99
+
100
+ /// Build a Swift error from the NSError the ObjC layer hands back.
101
+ /// Codes are aligned with `OpenCVStitcher.mm`'s `cv::Stitcher::Status`
102
+ /// mapping so the JS layer can branch on classes of failure.
103
+ static func fromNSError(_ err: NSError) -> StitcherError {
104
+ switch err.code {
105
+ case 1000:
106
+ // Pulled from the description text — we don't have the count
107
+ // structurally because the ObjC error doesn't carry it.
108
+ return .insufficientFrames(count: -1)
109
+ case 1001:
110
+ let path = (err.userInfo[NSLocalizedDescriptionKey] as? String) ?? "<unknown>"
111
+ return .readFailed(path: path)
112
+ case 1002:
113
+ let path = (err.userInfo[NSLocalizedDescriptionKey] as? String) ?? "<unknown>"
114
+ return .writeFailed(path: path)
115
+ default:
116
+ let msg = (err.userInfo[NSLocalizedDescriptionKey] as? String) ?? "OpenCV failure"
117
+ return .opencvFailed(code: err.code, message: msg)
118
+ }
119
+ }
120
+ }
121
+
122
+ public enum Stitcher {
123
+
124
+ /// Stitch the configured frames into a panorama at `options.outputPath`.
125
+ /// Throws `StitcherError` on failure; returns the result on success.
126
+ ///
127
+ /// This is synchronous on the calling thread. The RN bridge in
128
+ /// `StitcherBridge.swift` dispatches it to a background queue so
129
+ /// the JS thread isn't blocked during what's typically a 1–4s
130
+ /// operation on iPhone hardware.
131
+ public static func stitch(_ options: StitchOptions) throws -> StitchResult {
132
+ if options.framePaths.count < 2 {
133
+ throw StitcherError.insufficientFrames(count: options.framePaths.count)
134
+ }
135
+
136
+ // The ObjC method's `(NSError **)error` last parameter is
137
+ // imported by Swift as a throwing method — calling with `try`
138
+ // catches the NSError; there's no `error:` argument to pass.
139
+ do {
140
+ let result = try OpenCVStitcher.stitchFramePaths(
141
+ options.framePaths,
142
+ outputPath: options.outputPath,
143
+ jpegQuality: options.jpegQuality,
144
+ warperType: "plane",
145
+ blenderType: "multiband",
146
+ seamFinderType: "graphcut",
147
+ // AR-STITCHING-TWO-MODES — see memory/ar-stitching-two-modes.md
148
+ // Generic Stitcher API doesn't carry capture orientation;
149
+ // pass nil → .mm treats as "portrait" → no bake-rotation.
150
+ // Callers that care about output orientation (e.g.
151
+ // IncrementalStitcher) hit the method directly with
152
+ // the right value.
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.
159
+ useInscribedRectCrop: false
160
+ )
161
+ return StitchResult(
162
+ outputPath: result.outputPath,
163
+ width: result.width,
164
+ height: result.height,
165
+ durationMs: result.durationMs
166
+ )
167
+ } catch let nsError as NSError {
168
+ throw StitcherError.fromNSError(nsError)
169
+ }
170
+ }
171
+
172
+ /// Bake EXIF rotation into pixels for the image at `imagePath`.
173
+ /// Returns the post-rotation dimensions so the host can keep its
174
+ /// width/height fields aligned with what's now on disk.
175
+ ///
176
+ /// Idempotent on already-normalised files. Errors mirror the
177
+ /// existing StitcherError shape so JS can switch on `.code`.
178
+ public static func normaliseOrientation(
179
+ imagePath: String
180
+ ) throws -> (width: Int, height: Int) {
181
+ do {
182
+ let dict = try OpenCVStitcher.normaliseImage(atPath: imagePath)
183
+ let width = dict["width"]?.intValue ?? 0
184
+ let height = dict["height"]?.intValue ?? 0
185
+ return (width: width, height: height)
186
+ } catch let nsError as NSError {
187
+ throw StitcherError.fromNSError(nsError)
188
+ }
189
+ }
190
+
191
+ /// Combined pipeline: extract frames from a recorded video,
192
+ /// stitch them into a panorama, write the result to
193
+ /// `options.outputPath`. Used by the host app's tap-and-hold
194
+ /// shutter — the JS side records video while the user holds the
195
+ /// button and calls this on release.
196
+ ///
197
+ /// All temp frame extraction lives in /tmp and is torn down by
198
+ /// the ObjC layer regardless of success or failure.
199
+ public static func stitchVideo(
200
+ _ options: StitchVideoOptions,
201
+ poses: [[String: Any]]? = nil
202
+ ) throws -> StitchResult {
203
+ do {
204
+ let result: RNStitchResult
205
+ if let poses = poses, !poses.isEmpty {
206
+ // Phase 5: pose-driven path. Skips features → matching →
207
+ // BundleAdjuster on the native side; cv::detail::CameraParams
208
+ // come straight from the ARKit poses with the appropriate
209
+ // coordinate-system flip (Y-up → Y-down, -Z → +Z).
210
+ result = try OpenCVStitcher.stitchVideo(
211
+ atPath: options.videoPath,
212
+ outputPath: options.outputPath,
213
+ maxFrames: options.maxFrames,
214
+ jpegQuality: options.jpegQuality,
215
+ warperType: options.warperType,
216
+ blenderType: options.blenderType,
217
+ seamFinderType: options.seamFinderType,
218
+ poses: poses
219
+ )
220
+ } else {
221
+ // Existing feature-matched path.
222
+ result = try OpenCVStitcher.stitchVideo(
223
+ atPath: options.videoPath,
224
+ outputPath: options.outputPath,
225
+ maxFrames: options.maxFrames,
226
+ jpegQuality: options.jpegQuality,
227
+ warperType: options.warperType,
228
+ blenderType: options.blenderType,
229
+ seamFinderType: options.seamFinderType
230
+ )
231
+ }
232
+ return StitchResult(
233
+ outputPath: result.outputPath,
234
+ width: result.width,
235
+ height: result.height,
236
+ durationMs: result.durationMs
237
+ )
238
+ } catch let nsError as NSError {
239
+ throw StitcherError.fromNSError(nsError)
240
+ }
241
+ }
242
+ }
243
+ #endif
@@ -0,0 +1,28 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // StitcherBridge.m
4
+ //
5
+ // RN bridge declaration for the Swift `StitcherBridge`.
6
+ // Same pattern as `QualityCheckerBridge.m`. Without this file the
7
+ // JS side's `NativeModules.BatchStitcher` would resolve to
8
+ // `undefined` because RN's module map is populated by RCT_EXTERN_*
9
+ // macros, not Swift @objc decorators alone.
10
+ //
11
+
12
+ #import <React/RCTBridgeModule.h>
13
+
14
+ @interface RCT_EXTERN_MODULE(BatchStitcher, NSObject)
15
+
16
+ RCT_EXTERN_METHOD(stitch:(NSDictionary *)options
17
+ resolver:(RCTPromiseResolveBlock)resolver
18
+ rejecter:(RCTPromiseRejectBlock)rejecter)
19
+
20
+ RCT_EXTERN_METHOD(stitchVideo:(NSDictionary *)options
21
+ resolver:(RCTPromiseResolveBlock)resolver
22
+ rejecter:(RCTPromiseRejectBlock)rejecter)
23
+
24
+ RCT_EXTERN_METHOD(normaliseOrientation:(NSDictionary *)options
25
+ resolver:(RCTPromiseResolveBlock)resolver
26
+ rejecter:(RCTPromiseRejectBlock)rejecter)
27
+
28
+ @end
@@ -0,0 +1,246 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // StitcherBridge.swift
3
+ //
4
+ // React Native bridge for the SDK's image stitcher. Mirrors the
5
+ // QualityCheckerBridge pattern: the algorithm lives in
6
+ // `Stitcher.swift` (which depends only on UIKit + the ObjC
7
+ // `OpenCVStitcher`); this file is the translation between RN's
8
+ // promise-based bridge and the throwing-Swift API.
9
+ //
10
+ // Threading:
11
+ // `Stitcher.stitch(...)` is synchronous and CPU-heavy (1–4 seconds
12
+ // for typical 4-frame panoramas on an A17 phone). Running it on
13
+ // the JS-thread-owned bridge queue would freeze the UI. We
14
+ // dispatch onto a global utility queue so the user sees their
15
+ // spinner animate while the panorama assembles.
16
+
17
+ #if canImport(React)
18
+ import Foundation
19
+ import React
20
+ import UIKit
21
+
22
+ @objc(BatchStitcher)
23
+ public class StitcherBridge: NSObject {
24
+
25
+ // Stitching is a CPU-bound background operation; let RN drop the
26
+ // module setup to a background queue too so the main thread isn't
27
+ // blocked during initialisation.
28
+ @objc public static func requiresMainQueueSetup() -> Bool { return false }
29
+
30
+ /// Constants exposed to JS at module load time. Read via
31
+ /// `NativeModules.BatchStitcher.physicalMemoryBytes`.
32
+ ///
33
+ /// Used by the SDK's `DEFAULT_PANORAMA_SETTINGS` to pick
34
+ /// memory-appropriate defaults: high-quality MultiBand+GraphCut
35
+ /// on devices with ≥2 GB physical RAM, low-memory Feather+skip
36
+ /// on devices with <2 GB. The user can still override either
37
+ /// way via the panorama settings modal.
38
+ ///
39
+ /// CRITICAL: this MUST be a class method (`static`), not an
40
+ /// instance method. React Native's bridge gathers constants by
41
+ /// trying `[ModuleClass respondsToSelector:@selector(constants
42
+ /// ToExport)]` first; only the class-method form satisfies that
43
+ /// without forcing module instantiation. When this was an
44
+ /// instance method, the module was lazily created on the first
45
+ /// `stitchVideo` call — so by then JS had already read empty
46
+ /// constants and our `_isLowMem` check fell back incorrectly.
47
+ ///
48
+ /// Reading `ProcessInfo.processInfo.physicalMemory` is thread-
49
+ /// safe (no UIKit dependency), so the constants gathering can
50
+ /// happen off main thread; `requiresMainQueueSetup = false`
51
+ /// stays correct.
52
+ ///
53
+ /// Returning `[String: Any]` (not `[AnyHashable: Any]`) so the
54
+ /// Swift→ObjC bridge produces an `NSDictionary<NSString *, id> *`
55
+ /// which is exactly what RN's `constantsToExport` declares.
56
+ @objc public static func constantsToExport() -> [String: Any] {
57
+ let bytes = ProcessInfo.processInfo.physicalMemory
58
+ NSLog("[BatchStitcher] constantsToExport: physicalMemoryBytes=%llu",
59
+ bytes)
60
+ return [
61
+ "physicalMemoryBytes": NSNumber(value: bytes),
62
+ ]
63
+ }
64
+
65
+ /// Bridged entry: stitch the frames at `options.framePaths` into a
66
+ /// panorama at `options.outputPath`. Resolves with
67
+ /// `{ outputPath, width, height, durationMs }` to match the JS
68
+ /// `StitchFramesResult` type. Reject codes correspond to the
69
+ /// `StitcherError` cases so the JS host can branch on
70
+ /// "need more frames" vs. "decode failed" etc.
71
+ /// Bridged entry: end-to-end video → panorama pipeline. Resolves
72
+ /// with the same `{ outputPath, width, height, durationMs }` shape
73
+ /// as `stitch` so the JS layer can swap between "stitch from
74
+ /// pre-captured photos" and "stitch from a tap-hold video" without
75
+ /// branching on result type.
76
+ ///
77
+ /// Expected `options` keys: `videoPath`, `outputPath`, `maxFrames`
78
+ /// (default 10), `quality` (default 85).
79
+ @objc(stitchVideo:resolver:rejecter:)
80
+ public func stitchVideo(
81
+ options: NSDictionary,
82
+ resolver: @escaping RCTPromiseResolveBlock,
83
+ rejecter: @escaping RCTPromiseRejectBlock
84
+ ) {
85
+ guard let videoPath = options["videoPath"] as? String else {
86
+ rejecter("invalid-options", "videoPath must be a string", nil)
87
+ return
88
+ }
89
+ guard let outputPath = options["outputPath"] as? String else {
90
+ rejecter("invalid-options", "outputPath must be a string", nil)
91
+ return
92
+ }
93
+ let maxFrames = (options["maxFrames"] as? Int) ?? 10
94
+ let jpegQuality = (options["quality"] as? Int) ?? 85
95
+ let warperType = (options["warperType"] as? String) ?? "plane"
96
+ let blenderType = (options["blenderType"] as? String) ?? "multiband"
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
+
103
+ let stitchOpts = StitchVideoOptions(
104
+ videoPath: videoPath,
105
+ outputPath: outputPath,
106
+ maxFrames: maxFrames,
107
+ jpegQuality: jpegQuality,
108
+ warperType: warperType,
109
+ blenderType: blenderType,
110
+ seamFinderType: seamFinderType
111
+ )
112
+
113
+ DispatchQueue.global(qos: .userInitiated).async {
114
+ do {
115
+ let result = try Stitcher.stitchVideo(stitchOpts, poses: poses)
116
+ resolver([
117
+ "outputPath": result.outputPath,
118
+ "width": result.width,
119
+ "height": result.height,
120
+ "durationMs": result.durationMs,
121
+ ])
122
+ } catch let err as StitcherError {
123
+ switch err {
124
+ case .insufficientFrames(let count):
125
+ rejecter(
126
+ "insufficient-frames",
127
+ "Need at least 2 frames to stitch (got \(count))",
128
+ err
129
+ )
130
+ case .readFailed(let path):
131
+ rejecter("read-failed", "Could not read input: \(path)", err)
132
+ case .writeFailed(let path):
133
+ rejecter("write-failed", "Could not write panorama: \(path)", err)
134
+ case .opencvFailed(let code, let message):
135
+ rejecter("opencv-failed-\(code)", message, err)
136
+ }
137
+ } catch {
138
+ rejecter("unknown", "Unexpected stitcher failure: \(error)", error)
139
+ }
140
+ }
141
+ }
142
+
143
+ /// Bake EXIF orientation into the pixels of the image at the
144
+ /// given path. Resolves with the post-rotation `{ width, height }`
145
+ /// so the JS layer can update its own metadata.
146
+ ///
147
+ /// Expected `options` keys: `imagePath`.
148
+ @objc(normaliseOrientation:resolver:rejecter:)
149
+ public func normaliseOrientation(
150
+ options: NSDictionary,
151
+ resolver: @escaping RCTPromiseResolveBlock,
152
+ rejecter: @escaping RCTPromiseRejectBlock
153
+ ) {
154
+ guard let imagePath = options["imagePath"] as? String else {
155
+ rejecter("invalid-options", "imagePath must be a string", nil)
156
+ return
157
+ }
158
+ DispatchQueue.global(qos: .userInitiated).async {
159
+ do {
160
+ let dims = try Stitcher.normaliseOrientation(imagePath: imagePath)
161
+ resolver([
162
+ "width": dims.width,
163
+ "height": dims.height,
164
+ ])
165
+ } catch let err as StitcherError {
166
+ switch err {
167
+ case .insufficientFrames(let count):
168
+ rejecter("insufficient-frames", "(unexpected for normalise) frames=\(count)", err)
169
+ case .readFailed(let path):
170
+ rejecter("read-failed", "Could not read image: \(path)", err)
171
+ case .writeFailed(let path):
172
+ rejecter("write-failed", "Could not write image: \(path)", err)
173
+ case .opencvFailed(let code, let message):
174
+ rejecter("opencv-failed-\(code)", message, err)
175
+ }
176
+ } catch {
177
+ rejecter("unknown", "Unexpected normaliseOrientation failure: \(error)", error)
178
+ }
179
+ }
180
+ }
181
+
182
+ @objc(stitch:resolver:rejecter:)
183
+ public func stitch(
184
+ options: NSDictionary,
185
+ resolver: @escaping RCTPromiseResolveBlock,
186
+ rejecter: @escaping RCTPromiseRejectBlock
187
+ ) {
188
+ // Translate the dictionary the JS layer hands us into the typed
189
+ // StitchOptions struct. Defensive: missing / wrong-typed fields
190
+ // raise a recognisable bridge-side error instead of throwing
191
+ // deep inside ObjC.
192
+ guard let framePaths = options["framePaths"] as? [String] else {
193
+ rejecter("invalid-options", "framePaths must be an array of strings", nil)
194
+ return
195
+ }
196
+ guard let outputPath = options["outputPath"] as? String else {
197
+ rejecter("invalid-options", "outputPath must be a string", nil)
198
+ return
199
+ }
200
+ let jpegQuality = (options["quality"] as? Int) ?? 85
201
+
202
+ let stitchOpts = StitchOptions(
203
+ framePaths: framePaths,
204
+ outputPath: outputPath,
205
+ jpegQuality: jpegQuality
206
+ )
207
+
208
+ DispatchQueue.global(qos: .userInitiated).async {
209
+ do {
210
+ let result = try Stitcher.stitch(stitchOpts)
211
+ resolver([
212
+ "outputPath": result.outputPath,
213
+ "width": result.width,
214
+ "height": result.height,
215
+ "durationMs": result.durationMs,
216
+ ])
217
+ } catch let err as StitcherError {
218
+ switch err {
219
+ case .insufficientFrames(let count):
220
+ rejecter(
221
+ "insufficient-frames",
222
+ "Need at least 2 frames to stitch (got \(count))",
223
+ err
224
+ )
225
+ case .readFailed(let path):
226
+ rejecter(
227
+ "read-failed",
228
+ "Could not read input image: \(path)",
229
+ err
230
+ )
231
+ case .writeFailed(let path):
232
+ rejecter(
233
+ "write-failed",
234
+ "Could not write stitched panorama: \(path)",
235
+ err
236
+ )
237
+ case .opencvFailed(let code, let message):
238
+ rejecter("opencv-failed-\(code)", message, err)
239
+ }
240
+ } catch {
241
+ rejecter("unknown", "Unexpected stitcher failure: \(error)", error)
242
+ }
243
+ }
244
+ }
245
+ }
246
+ #endif
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "react-native-image-stitcher",
3
+ "version": "0.1.0",
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
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist/**/*.js",
9
+ "dist/**/*.d.ts",
10
+ "src",
11
+ "ios/Sources",
12
+ "ios/Package.swift",
13
+ "android/build.gradle",
14
+ "android/src/main/java",
15
+ "android/src/main/cpp",
16
+ "android/src/main/AndroidManifest.xml",
17
+ "cpp",
18
+ "scripts/postinstall-fetch-binaries.js",
19
+ "scripts/opencv-version.txt",
20
+ "RNImageStitcher.podspec",
21
+ "react-native.config.js",
22
+ "LICENSE",
23
+ "NOTICE",
24
+ "README.md",
25
+ "CHANGELOG.md"
26
+ ],
27
+ "scripts": {
28
+ "build": "tsc",
29
+ "typecheck": "tsc --noEmit",
30
+ "clean": "rm -rf dist",
31
+ "postinstall": "node scripts/postinstall-fetch-binaries.js"
32
+ },
33
+ "keywords": [
34
+ "react-native",
35
+ "panorama",
36
+ "stitching",
37
+ "image-stitcher",
38
+ "camera",
39
+ "opencv",
40
+ "arkit",
41
+ "arcore",
42
+ "vision-camera",
43
+ "shelf-scan"
44
+ ],
45
+ "license": "Apache-2.0",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "https://github.com/bhargavkanda/react-native-image-stitcher.git"
49
+ },
50
+ "bugs": {
51
+ "url": "https://github.com/bhargavkanda/react-native-image-stitcher/issues"
52
+ },
53
+ "homepage": "https://github.com/bhargavkanda/react-native-image-stitcher#readme",
54
+ "devDependencies": {
55
+ "@types/react": "^19.0.0",
56
+ "expo-sensors": "^14.0.0",
57
+ "react": "^19.0.0",
58
+ "react-native": "^0.84.0",
59
+ "react-native-safe-area-context": "^4.0.0",
60
+ "react-native-sensors": "^7.0.0",
61
+ "react-native-vision-camera": "^4.0.0",
62
+ "rxjs": "^7.0.0",
63
+ "typescript": "^5.5.0"
64
+ },
65
+ "peerDependencies": {
66
+ "react": ">=18.0.0",
67
+ "react-native": ">=0.72.0",
68
+ "react-native-vision-camera": ">=4.7.0",
69
+ "react-native-sensors": ">=7.0.0",
70
+ "expo-sensors": ">=14.0.0",
71
+ "react-native-safe-area-context": ">=4.0.0"
72
+ }
73
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * react-native.config.js — describe this SDK's native surface to RN
3
+ * autolinking. Without this file autolinking would look for a
4
+ * podspec named `CaptureSdk.podspec` (the slug of the package name
5
+ * `react-native-image-stitcher`) and miss our actual file
6
+ * `RNImageStitcher.podspec` at the package root.
7
+ *
8
+ * Declaring the explicit paths here also lets autolinking find the
9
+ * Android source directory once Phase 3 lands (currently absent —
10
+ * the `android` block is included now so the iOS-Android shape is
11
+ * symmetric and operators don't have to re-edit this file mid-work).
12
+ */
13
+ const path = require('path');
14
+
15
+ module.exports = {
16
+ dependency: {
17
+ platforms: {
18
+ ios: {
19
+ podspecPath: path.join(__dirname, 'RNImageStitcher.podspec'),
20
+ },
21
+ android: {
22
+ // RELATIVE path on purpose. RN's autolinking computes
23
+ // `path.join(root, sourceDir)` where `root` is the package
24
+ // root in node_modules (a symlink in our case). An ABSOLUTE
25
+ // sourceDir gets concatenated by path.join rather than
26
+ // treated as already-resolved, producing a broken double
27
+ // path and silently failing detection. Relative `'android'`
28
+ // joins cleanly and resolves through the symlink to the
29
+ // real Android module folder.
30
+ sourceDir: 'android',
31
+ },
32
+ },
33
+ },
34
+ };
@@ -0,0 +1 @@
1
+ 4.10.0