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