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,204 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
//
|
|
3
|
+
// keyframe_gate_jni.cpp — JNI bindings exposing the shared C++
|
|
4
|
+
// retailens::KeyframeGate (in ../../../../cpp/) to the Kotlin side
|
|
5
|
+
// (com.retailens.capturesdk.KeyframeGate).
|
|
6
|
+
//
|
|
7
|
+
// Architecture parity with iOS:
|
|
8
|
+
// iOS uses an Obj-C++ bridge (KeyframeGateBridge.mm) to wrap the
|
|
9
|
+
// same C++ class. Android uses these JNI thunks. Both ultimately
|
|
10
|
+
// call into the same code in cpp/keyframe_gate.cpp — that's the
|
|
11
|
+
// point of the port.
|
|
12
|
+
//
|
|
13
|
+
// Handle pattern:
|
|
14
|
+
// nativeCreate() returns a `Long` opaque handle (the C++ KeyframeGate
|
|
15
|
+
// pointer cast to jlong). All subsequent calls pass the handle
|
|
16
|
+
// back. The Kotlin wrapper owns the handle's lifetime and MUST
|
|
17
|
+
// call nativeDestroy() before being garbage-collected (otherwise we
|
|
18
|
+
// leak a small heap allocation per gate-instance).
|
|
19
|
+
//
|
|
20
|
+
// Decision packing:
|
|
21
|
+
// Evaluate returns a DoubleArray of length 5:
|
|
22
|
+
// [0] accept — 1.0 or 0.0
|
|
23
|
+
// [1] reasonCode — int (the C++ enum int value)
|
|
24
|
+
// [2] newContentFraction — -1.0 when not computed
|
|
25
|
+
// [3] acceptedCount — int
|
|
26
|
+
// [4] maxCount — int
|
|
27
|
+
// Kotlin wrapper unpacks into a data class. This avoids JNI
|
|
28
|
+
// per-call object construction (NewObject) which is ~10× more
|
|
29
|
+
// expensive than a primitive-array allocation.
|
|
30
|
+
|
|
31
|
+
#include <jni.h>
|
|
32
|
+
#include <cstring>
|
|
33
|
+
|
|
34
|
+
#include "keyframe_gate.hpp"
|
|
35
|
+
#include "ar_frame_pose.h"
|
|
36
|
+
|
|
37
|
+
namespace {
|
|
38
|
+
inline retailens::KeyframeGate* gate(jlong h) {
|
|
39
|
+
return reinterpret_cast<retailens::KeyframeGate*>(h);
|
|
40
|
+
}
|
|
41
|
+
} // anonymous namespace
|
|
42
|
+
|
|
43
|
+
extern "C" {
|
|
44
|
+
|
|
45
|
+
// ── Lifecycle ────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
JNIEXPORT jlong JNICALL
|
|
48
|
+
Java_io_imagestitcher_rn_KeyframeGate_nativeCreate(JNIEnv*, jclass) {
|
|
49
|
+
return reinterpret_cast<jlong>(new retailens::KeyframeGate());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
JNIEXPORT void JNICALL
|
|
53
|
+
Java_io_imagestitcher_rn_KeyframeGate_nativeDestroy(
|
|
54
|
+
JNIEnv*, jclass, jlong handle)
|
|
55
|
+
{
|
|
56
|
+
delete gate(handle);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Settings ─────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
JNIEXPORT void JNICALL
|
|
62
|
+
Java_io_imagestitcher_rn_KeyframeGate_nativeSetEnabled(
|
|
63
|
+
JNIEnv*, jclass, jlong handle, jboolean enabled)
|
|
64
|
+
{
|
|
65
|
+
gate(handle)->setEnabled(enabled);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
JNIEXPORT void JNICALL
|
|
69
|
+
Java_io_imagestitcher_rn_KeyframeGate_nativeSetOverlapThreshold(
|
|
70
|
+
JNIEnv*, jclass, jlong handle, jdouble t)
|
|
71
|
+
{
|
|
72
|
+
gate(handle)->setOverlapThreshold(t);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
JNIEXPORT void JNICALL
|
|
76
|
+
Java_io_imagestitcher_rn_KeyframeGate_nativeSetMaxCount(
|
|
77
|
+
JNIEnv*, jclass, jlong handle, jint n)
|
|
78
|
+
{
|
|
79
|
+
gate(handle)->setMaxCount(static_cast<int32_t>(n));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
JNIEXPORT void JNICALL
|
|
83
|
+
Java_io_imagestitcher_rn_KeyframeGate_nativeMarkNextFrameAsLast(
|
|
84
|
+
JNIEnv*, jclass, jlong handle)
|
|
85
|
+
{
|
|
86
|
+
gate(handle)->markNextFrameAsLast();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 2026-05-14 — non-AR-mode opt-out for the angular-delta fallback.
|
|
90
|
+
// See `setDisableAngularFallback` doc in keyframe_gate.hpp for the
|
|
91
|
+
// rationale (no usable pose data in non-AR captures).
|
|
92
|
+
JNIEXPORT void JNICALL
|
|
93
|
+
Java_io_imagestitcher_rn_KeyframeGate_nativeSetDisableAngularFallback(
|
|
94
|
+
JNIEnv*, jclass, jlong handle, jboolean disabled)
|
|
95
|
+
{
|
|
96
|
+
gate(handle)->setDisableAngularFallback(static_cast<bool>(disabled));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 2026-05-14 — JS-driven IMU translation budget for non-AR mode.
|
|
100
|
+
// In non-AR captures, the gate has no ARKit/ARCore pose; the JS
|
|
101
|
+
// host computes translation via react-native-sensors accelerometer
|
|
102
|
+
// integration and forwards it via this setter so the gate's
|
|
103
|
+
// translation-budget logic still kicks in. See setFlowMaxTranslationM
|
|
104
|
+
// doc in keyframe_gate.hpp. This is the Android JNI counterpart of
|
|
105
|
+
// the iOS bridge method that already exists in KeyframeGateBridge.
|
|
106
|
+
JNIEXPORT void JNICALL
|
|
107
|
+
Java_io_imagestitcher_rn_KeyframeGate_nativeSetFlowMaxTranslationM(
|
|
108
|
+
JNIEnv*, jclass, jlong handle, jdouble metres)
|
|
109
|
+
{
|
|
110
|
+
gate(handle)->setFlowMaxTranslationM(static_cast<double>(metres));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 2026-05-14 — Android JNI for the percentile setter so JS Settings
|
|
114
|
+
// can tune novelty aggregation on Android (was iOS-only until now).
|
|
115
|
+
// See setFlowNoveltyPercentile doc in keyframe_gate.hpp.
|
|
116
|
+
JNIEXPORT void JNICALL
|
|
117
|
+
Java_io_imagestitcher_rn_KeyframeGate_nativeSetFlowNoveltyPercentile(
|
|
118
|
+
JNIEnv*, jclass, jlong handle, jdouble percentile)
|
|
119
|
+
{
|
|
120
|
+
gate(handle)->setFlowNoveltyPercentile(static_cast<double>(percentile));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
JNIEXPORT void JNICALL
|
|
124
|
+
Java_io_imagestitcher_rn_KeyframeGate_nativeReset(
|
|
125
|
+
JNIEnv*, jclass, jlong handle)
|
|
126
|
+
{
|
|
127
|
+
gate(handle)->reset();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Read-only state ──────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
JNIEXPORT jint JNICALL
|
|
133
|
+
Java_io_imagestitcher_rn_KeyframeGate_nativeGetAcceptedCount(
|
|
134
|
+
JNIEnv*, jclass, jlong handle)
|
|
135
|
+
{
|
|
136
|
+
return static_cast<jint>(gate(handle)->getAcceptedCount());
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
JNIEXPORT jint JNICALL
|
|
140
|
+
Java_io_imagestitcher_rn_KeyframeGate_nativeGetMaxCount(
|
|
141
|
+
JNIEnv*, jclass, jlong handle)
|
|
142
|
+
{
|
|
143
|
+
return static_cast<jint>(gate(handle)->getMaxCount());
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
JNIEXPORT jboolean JNICALL
|
|
147
|
+
Java_io_imagestitcher_rn_KeyframeGate_nativeIsEnabled(
|
|
148
|
+
JNIEnv*, jclass, jlong handle)
|
|
149
|
+
{
|
|
150
|
+
return static_cast<jboolean>(gate(handle)->isEnabled());
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Per-frame evaluate ───────────────────────────────────────────
|
|
154
|
+
//
|
|
155
|
+
// plane16OrNull is FloatArray of exactly 16 elements (column-major),
|
|
156
|
+
// or null for angular-delta fallback. Returns DoubleArray[5] as
|
|
157
|
+
// described in the file header.
|
|
158
|
+
|
|
159
|
+
JNIEXPORT jdoubleArray JNICALL
|
|
160
|
+
Java_io_imagestitcher_rn_KeyframeGate_nativeEvaluate(
|
|
161
|
+
JNIEnv* env, jclass, jlong handle,
|
|
162
|
+
jfloat tx, jfloat ty, jfloat tz,
|
|
163
|
+
jfloat qx, jfloat qy, jfloat qz, jfloat qw,
|
|
164
|
+
jfloat fx, jfloat fy, jfloat cx, jfloat cy,
|
|
165
|
+
jint imageWidth, jint imageHeight,
|
|
166
|
+
jfloatArray plane16OrNull)
|
|
167
|
+
{
|
|
168
|
+
retailens::Pose pose;
|
|
169
|
+
pose.tx = tx; pose.ty = ty; pose.tz = tz;
|
|
170
|
+
pose.qx = qx; pose.qy = qy; pose.qz = qz; pose.qw = qw;
|
|
171
|
+
pose.fx = fx; pose.fy = fy; pose.cx = cx; pose.cy = cy;
|
|
172
|
+
pose.imageWidth = static_cast<int32_t>(imageWidth);
|
|
173
|
+
pose.imageHeight = static_cast<int32_t>(imageHeight);
|
|
174
|
+
|
|
175
|
+
retailens::PlaneTransform planeStorage;
|
|
176
|
+
const retailens::PlaneTransform* planePtr = nullptr;
|
|
177
|
+
if (plane16OrNull) {
|
|
178
|
+
jsize len = env->GetArrayLength(plane16OrNull);
|
|
179
|
+
if (len == 16) {
|
|
180
|
+
jfloat* src = env->GetFloatArrayElements(plane16OrNull, nullptr);
|
|
181
|
+
if (src) {
|
|
182
|
+
std::memcpy(planeStorage.m, src, sizeof(float) * 16);
|
|
183
|
+
env->ReleaseFloatArrayElements(plane16OrNull, src, JNI_ABORT);
|
|
184
|
+
planePtr = &planeStorage;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// len != 16 silently falls through to angular fallback; the
|
|
188
|
+
// Kotlin caller is responsible for passing exactly 16 floats.
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
retailens::KeyframeGateDecision d = gate(handle)->evaluate(pose, planePtr);
|
|
192
|
+
|
|
193
|
+
jdoubleArray out = env->NewDoubleArray(5);
|
|
194
|
+
jdouble values[5];
|
|
195
|
+
values[0] = d.accept ? 1.0 : 0.0;
|
|
196
|
+
values[1] = static_cast<jdouble>(static_cast<int32_t>(d.reason));
|
|
197
|
+
values[2] = d.newContentFraction;
|
|
198
|
+
values[3] = static_cast<jdouble>(d.acceptedCount);
|
|
199
|
+
values[4] = static_cast<jdouble>(d.maxCount);
|
|
200
|
+
env->SetDoubleArrayRegion(out, 0, 5, values);
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
} // extern "C"
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
package io.imagestitcher.rn
|
|
3
|
+
|
|
4
|
+
import com.facebook.react.bridge.Promise
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
7
|
+
import com.facebook.react.bridge.ReactMethod
|
|
8
|
+
import com.facebook.react.bridge.ReadableMap
|
|
9
|
+
import com.facebook.react.bridge.WritableNativeMap
|
|
10
|
+
import kotlinx.coroutines.CoroutineScope
|
|
11
|
+
import kotlinx.coroutines.Dispatchers
|
|
12
|
+
import kotlinx.coroutines.launch
|
|
13
|
+
import org.opencv.core.Mat
|
|
14
|
+
import org.opencv.core.MatOfInt
|
|
15
|
+
import org.opencv.imgcodecs.Imgcodecs
|
|
16
|
+
import java.io.File
|
|
17
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Android twin of the iOS BatchStitcher. Mirrors the JS-facing
|
|
21
|
+
* surface exactly:
|
|
22
|
+
*
|
|
23
|
+
* stitch({ framePaths, outputPath, quality })
|
|
24
|
+
* → { outputPath, width, height, durationMs }
|
|
25
|
+
*
|
|
26
|
+
* stitchVideo({ videoPath, outputPath, maxFrames, quality })
|
|
27
|
+
* → { outputPath, width, height, durationMs }
|
|
28
|
+
*
|
|
29
|
+
* normaliseImage({ imagePath })
|
|
30
|
+
* → { width, height }
|
|
31
|
+
*
|
|
32
|
+
* Algorithm choices match iOS:
|
|
33
|
+
* - cv::Stitcher::SCANS mode (translational, planar subject — the
|
|
34
|
+
* shelf-walking gesture) instead of PANORAMA (which assumes
|
|
35
|
+
* rotational camera).
|
|
36
|
+
* - 10 evenly-spaced frames as the SCANS-mode sweet spot.
|
|
37
|
+
* - JPEG quality 85 default; adjustable per call.
|
|
38
|
+
*
|
|
39
|
+
* Frame extraction uses Android's MediaMetadataRetriever — analogous
|
|
40
|
+
* to iOS' AVAssetImageGenerator. Each call to getFrameAtTime is
|
|
41
|
+
* fast (~30-50 ms) and the API blocks per-frame, so we run the
|
|
42
|
+
* whole pipeline on a background coroutine.
|
|
43
|
+
*/
|
|
44
|
+
class BatchStitcher(reactContext: ReactApplicationContext)
|
|
45
|
+
: ReactContextBaseJavaModule(reactContext) {
|
|
46
|
+
|
|
47
|
+
override fun getName(): String = "BatchStitcher"
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* JNI bridge to our custom-built OpenCV stitcher. Mirrors iOS'
|
|
51
|
+
* OpenCVStitcher.stitchFramePaths so the batch-keyframe flow has
|
|
52
|
+
* parity across platforms. Implementation:
|
|
53
|
+
* retailens-capture-sdk/android/src/main/cpp/image_stitcher_jni.cpp
|
|
54
|
+
*
|
|
55
|
+
* @param framePaths input JPEG paths in capture order (≥2 required)
|
|
56
|
+
* @param outputPath destination JPEG path
|
|
57
|
+
* @param jpegQuality 0..100
|
|
58
|
+
* @param warperType "plane" | "cylindrical" | "spherical"
|
|
59
|
+
* @param blenderType "multiband" | "feather"
|
|
60
|
+
* @param seamFinderType "graphcut" | "skip" | "voronoi"
|
|
61
|
+
* @param captureOrientation "portrait" | "portrait-upside-down"
|
|
62
|
+
* | "landscape-left" | "landscape-right"
|
|
63
|
+
* (drives output bake-rotation table,
|
|
64
|
+
* mirrors iOS)
|
|
65
|
+
* @param useInscribedRectCrop reserved for future parity with
|
|
66
|
+
* iOS' inscribed-rect crop toggle;
|
|
67
|
+
* currently bbox-only on Android
|
|
68
|
+
* @return [width, height] of the written JPEG
|
|
69
|
+
* @throws RuntimeException on stitch failure
|
|
70
|
+
*/
|
|
71
|
+
private external fun nativeStitchFramePaths(
|
|
72
|
+
framePaths: Array<String>,
|
|
73
|
+
outputPath: String,
|
|
74
|
+
jpegQuality: Int,
|
|
75
|
+
warperType: String,
|
|
76
|
+
blenderType: String,
|
|
77
|
+
seamFinderType: String,
|
|
78
|
+
captureOrientation: String,
|
|
79
|
+
useInscribedRectCrop: Boolean,
|
|
80
|
+
// V16-followup (Android OOM fix): cv::Stitcher staged-resolution
|
|
81
|
+
// budgets in megapixels. Pass any negative value to keep
|
|
82
|
+
// cv::Stitcher's library default for that stage. See
|
|
83
|
+
// image_stitcher_jni.cpp arg doc for the full rationale; the
|
|
84
|
+
// tl;dr is that the cv::Stitcher COMPOSITING default is
|
|
85
|
+
// ORIG_RESOL (no downscale) which on Android with 1920×1080
|
|
86
|
+
// sensor frames balloons MultiBand memory and triggers lmkd.
|
|
87
|
+
// Bounding compositing to ~1.0 MP keeps stitch peak < 200 MB
|
|
88
|
+
// on the A35.
|
|
89
|
+
registrationResolMP: Double,
|
|
90
|
+
seamEstimationResolMP: Double,
|
|
91
|
+
compositingResolMP: Double,
|
|
92
|
+
// 2026-05-14 — cv::Stitcher pipeline mode picker.
|
|
93
|
+
// "panorama" → cv::Stitcher::PANORAMA (rotation-only)
|
|
94
|
+
// "scans" → cv::Stitcher::SCANS (translation/affine)
|
|
95
|
+
// Always a concrete mode at this layer; 'auto' is resolved
|
|
96
|
+
// upstream in IncrementalStitcher.finalize() based
|
|
97
|
+
// on accumulated translation/rotation totals. Defaults to
|
|
98
|
+
// "scans" in the JNI on unknown input (safer fallback —
|
|
99
|
+
// SCANS canvas size is bounded by sum-of-frames; PANORAMA
|
|
100
|
+
// can diverge to multi-GB on translation-heavy input).
|
|
101
|
+
stitchMode: String,
|
|
102
|
+
): IntArray
|
|
103
|
+
|
|
104
|
+
// ── Stitch frames → panorama ─────────────────────────────────
|
|
105
|
+
|
|
106
|
+
@ReactMethod
|
|
107
|
+
fun stitch(options: ReadableMap, promise: Promise) {
|
|
108
|
+
// Unmarshal options. We accept iOS-aligned parameter names
|
|
109
|
+
// so the JS-side code stays platform-agnostic.
|
|
110
|
+
val framePathsArr = options.getArray("framePaths")
|
|
111
|
+
if (framePathsArr == null || framePathsArr.size() < 2) {
|
|
112
|
+
promise.reject(
|
|
113
|
+
"invalid-options",
|
|
114
|
+
"framePaths must be an array of at least 2 paths " +
|
|
115
|
+
"(got ${framePathsArr?.size() ?: 0}).",
|
|
116
|
+
)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
val framePaths = Array(framePathsArr.size()) {
|
|
120
|
+
stripFileScheme(framePathsArr.getString(it) ?: "")
|
|
121
|
+
}
|
|
122
|
+
val outputPath = options.getString("outputPath")
|
|
123
|
+
?.let(::stripFileScheme)
|
|
124
|
+
?: return promise.reject("invalid-options", "outputPath required")
|
|
125
|
+
val quality = if (options.hasKey("quality"))
|
|
126
|
+
options.getInt("quality") else 85
|
|
127
|
+
val warperType = options.getString("warperType") ?: "plane"
|
|
128
|
+
val blenderType = options.getString("blenderType") ?: "multiband"
|
|
129
|
+
val seamFinderType = options.getString("seamFinderType") ?: "graphcut"
|
|
130
|
+
val captureOrientation = options.getString("captureOrientation") ?: "portrait"
|
|
131
|
+
val useInscribedRectCrop = options.hasKey("useInscribedRectCrop") &&
|
|
132
|
+
options.getBoolean("useInscribedRectCrop")
|
|
133
|
+
// V16-followup (Android OOM fix): cv::Stitcher staged-resolution
|
|
134
|
+
// budgets in MP. Defaults:
|
|
135
|
+
// registrationResolMP = -1.0 → keep cv::Stitcher default 0.6 MP
|
|
136
|
+
// seamEstimationResolMP = -1.0 → keep cv::Stitcher default 0.1 MP
|
|
137
|
+
// compositingResolMP = 1.0 → OVERRIDE the dangerous
|
|
138
|
+
// ORIG_RESOL (-1.0) default
|
|
139
|
+
// Caller-supplied negative values keep the library default;
|
|
140
|
+
// any positive value scales the stage to that target MP.
|
|
141
|
+
val registrationResolMP = if (options.hasKey("registrationResolMP"))
|
|
142
|
+
options.getDouble("registrationResolMP") else -1.0
|
|
143
|
+
val seamEstimationResolMP = if (options.hasKey("seamEstimationResolMP"))
|
|
144
|
+
options.getDouble("seamEstimationResolMP") else -1.0
|
|
145
|
+
val compositingResolMP = if (options.hasKey("compositingResolMP"))
|
|
146
|
+
options.getDouble("compositingResolMP") else 1.0
|
|
147
|
+
// 2026-05-14 — cv::Stitcher pipeline mode. Caller from
|
|
148
|
+
// IncrementalStitcher.finalize resolves 'auto' to
|
|
149
|
+
// 'panorama' or 'scans' before reaching here. Direct
|
|
150
|
+
// @ReactMethod callers (CLI / tests) can pass 'auto' too;
|
|
151
|
+
// we default to 'scans' if missing/unrecognised since SCANS
|
|
152
|
+
// is the safer mode (bounded canvas; can't lmkd-kill on
|
|
153
|
+
// translation-heavy input).
|
|
154
|
+
val stitchMode = (options.getString("stitchMode") ?: "scans")
|
|
155
|
+
.let { if (it in setOf("panorama", "scans")) it else "scans" }
|
|
156
|
+
|
|
157
|
+
CoroutineScope(Dispatchers.Default).launch {
|
|
158
|
+
val start = System.currentTimeMillis()
|
|
159
|
+
try {
|
|
160
|
+
ensureNativeStitcher()
|
|
161
|
+
val dims = nativeStitchFramePaths(
|
|
162
|
+
framePaths,
|
|
163
|
+
outputPath,
|
|
164
|
+
quality,
|
|
165
|
+
warperType,
|
|
166
|
+
blenderType,
|
|
167
|
+
seamFinderType,
|
|
168
|
+
captureOrientation,
|
|
169
|
+
useInscribedRectCrop,
|
|
170
|
+
registrationResolMP,
|
|
171
|
+
seamEstimationResolMP,
|
|
172
|
+
compositingResolMP,
|
|
173
|
+
stitchMode,
|
|
174
|
+
)
|
|
175
|
+
val duration = System.currentTimeMillis() - start
|
|
176
|
+
// 2026-05-15 (D) — dims layout from native JNI:
|
|
177
|
+
// [0] width, [1] height, [2] framesRequested,
|
|
178
|
+
// [3] framesIncluded, [4] finalThresholdMilli
|
|
179
|
+
// (see image_stitcher_jni.cpp return site).
|
|
180
|
+
// dims.size >= 5 guards against older native libs
|
|
181
|
+
// (defensive — keeps Kotlin/native loosely versioned).
|
|
182
|
+
val framesRequested = if (dims.size > 2) dims[2] else framePaths.size
|
|
183
|
+
val framesIncluded = if (dims.size > 3) dims[3] else framePaths.size
|
|
184
|
+
val finalConfidenceThresh =
|
|
185
|
+
if (dims.size > 4) dims[4].toDouble() / 1000.0 else -1.0
|
|
186
|
+
val result = WritableNativeMap().apply {
|
|
187
|
+
putString("outputPath", outputPath)
|
|
188
|
+
putInt("width", dims[0])
|
|
189
|
+
putInt("height", dims[1])
|
|
190
|
+
putInt("durationMs", duration.toInt())
|
|
191
|
+
putInt("framesRequested", framesRequested)
|
|
192
|
+
putInt("framesIncluded", framesIncluded)
|
|
193
|
+
putInt("framesDropped", framesRequested - framesIncluded)
|
|
194
|
+
putDouble("finalConfidenceThresh", finalConfidenceThresh)
|
|
195
|
+
}
|
|
196
|
+
promise.resolve(result)
|
|
197
|
+
} catch (t: Throwable) {
|
|
198
|
+
promise.reject(
|
|
199
|
+
"stitch-failed",
|
|
200
|
+
"Native stitch threw: ${t.message ?: t.javaClass.simpleName}",
|
|
201
|
+
t,
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Stitch video → panorama (extract + stitch + cleanup) ─────
|
|
208
|
+
|
|
209
|
+
@ReactMethod
|
|
210
|
+
fun stitchVideo(options: ReadableMap, promise: Promise) {
|
|
211
|
+
// Video → frames extraction not yet implemented on Android.
|
|
212
|
+
// The batch-keyframe flow drives stitch() directly with
|
|
213
|
+
// already-captured frame paths. If video-driven panorama
|
|
214
|
+
// ever ships on Android, extract via MediaMetadataRetriever
|
|
215
|
+
// and delegate to nativeStitchFramePaths.
|
|
216
|
+
promise.reject(
|
|
217
|
+
"STITCH_VIDEO_NOT_IMPLEMENTED",
|
|
218
|
+
"stitchVideo() is not implemented on Android. Use " +
|
|
219
|
+
"stitch() with pre-extracted framePaths instead.",
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Normalise photo orientation (bake EXIF into pixels) ──────
|
|
224
|
+
|
|
225
|
+
@ReactMethod
|
|
226
|
+
fun normaliseImage(options: ReadableMap, promise: Promise) {
|
|
227
|
+
val imagePath = options.getString("imagePath")
|
|
228
|
+
?: return promise.reject("invalid-options", "imagePath required")
|
|
229
|
+
|
|
230
|
+
CoroutineScope(Dispatchers.Default).launch {
|
|
231
|
+
try {
|
|
232
|
+
ensureOpenCv()
|
|
233
|
+
val cleaned = stripFileScheme(imagePath)
|
|
234
|
+
if (!File(cleaned).exists()) {
|
|
235
|
+
promise.reject("read-failed", "Image not found: $imagePath")
|
|
236
|
+
return@launch
|
|
237
|
+
}
|
|
238
|
+
// First rotate the source on the Java side using
|
|
239
|
+
// ExifInterface — Android's cv::imread, unlike iOS',
|
|
240
|
+
// doesn't auto-honour EXIF. We re-write a rotated
|
|
241
|
+
// intermediate to disk THEN call the JNI normalise
|
|
242
|
+
// (which re-reads + re-writes via cv::imread/imwrite,
|
|
243
|
+
// stripping EXIF metadata for good measure).
|
|
244
|
+
val img = Imgcodecs.imread(cleaned, Imgcodecs.IMREAD_COLOR)
|
|
245
|
+
if (img.empty()) {
|
|
246
|
+
promise.reject("read-failed", "Could not decode $imagePath")
|
|
247
|
+
return@launch
|
|
248
|
+
}
|
|
249
|
+
val rotated = applyExifOrientation(cleaned, img)
|
|
250
|
+
val params = MatOfInt(Imgcodecs.IMWRITE_JPEG_QUALITY, 92)
|
|
251
|
+
if (!Imgcodecs.imwrite(cleaned, rotated, params)) {
|
|
252
|
+
promise.reject("write-failed", "Could not rewrite $imagePath")
|
|
253
|
+
return@launch
|
|
254
|
+
}
|
|
255
|
+
promise.resolve(WritableNativeMap().apply {
|
|
256
|
+
putInt("width", rotated.cols())
|
|
257
|
+
putInt("height", rotated.rows())
|
|
258
|
+
})
|
|
259
|
+
img.release()
|
|
260
|
+
if (rotated !== img) rotated.release()
|
|
261
|
+
} catch (t: Throwable) {
|
|
262
|
+
promise.reject("normalise-failed", t.message, t)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Internals ────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Read EXIF orientation tag and rotate the Mat accordingly.
|
|
271
|
+
* The original Mat is released and replaced if rotation was
|
|
272
|
+
* needed — caller can compare references to know if a fresh
|
|
273
|
+
* Mat was returned.
|
|
274
|
+
*/
|
|
275
|
+
private fun applyExifOrientation(path: String, src: Mat): Mat {
|
|
276
|
+
val exif = androidx.exifinterface.media.ExifInterface(path)
|
|
277
|
+
val orientation = exif.getAttributeInt(
|
|
278
|
+
androidx.exifinterface.media.ExifInterface.TAG_ORIENTATION,
|
|
279
|
+
androidx.exifinterface.media.ExifInterface.ORIENTATION_NORMAL,
|
|
280
|
+
)
|
|
281
|
+
val rotated = Mat()
|
|
282
|
+
when (orientation) {
|
|
283
|
+
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_90 -> {
|
|
284
|
+
org.opencv.core.Core.rotate(src, rotated, org.opencv.core.Core.ROTATE_90_CLOCKWISE)
|
|
285
|
+
}
|
|
286
|
+
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_180 -> {
|
|
287
|
+
org.opencv.core.Core.rotate(src, rotated, org.opencv.core.Core.ROTATE_180)
|
|
288
|
+
}
|
|
289
|
+
androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_270 -> {
|
|
290
|
+
org.opencv.core.Core.rotate(src, rotated, org.opencv.core.Core.ROTATE_90_COUNTERCLOCKWISE)
|
|
291
|
+
}
|
|
292
|
+
else -> return src
|
|
293
|
+
}
|
|
294
|
+
return rotated
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private fun stripFileScheme(path: String): String =
|
|
298
|
+
if (path.startsWith("file://")) path.removePrefix("file://") else path
|
|
299
|
+
|
|
300
|
+
private fun ensureOpenCv() {
|
|
301
|
+
if (!opencvInitialised.get()) {
|
|
302
|
+
try {
|
|
303
|
+
System.loadLibrary("opencv_java4")
|
|
304
|
+
opencvInitialised.set(true)
|
|
305
|
+
} catch (e: UnsatisfiedLinkError) {
|
|
306
|
+
throw IllegalStateException(
|
|
307
|
+
"OpenCV native library 'opencv_java4' failed to load",
|
|
308
|
+
e,
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Internal-visibility synchronous stitch entry point so the
|
|
316
|
+
* orchestrator (IncrementalStitcher) can drive the V16
|
|
317
|
+
* batch-keyframe finalize without re-marshalling through the
|
|
318
|
+
* @ReactMethod surface. Loads the JNI shim if not yet loaded,
|
|
319
|
+
* then calls straight into native. Throws on error.
|
|
320
|
+
*/
|
|
321
|
+
internal fun stitchSync(
|
|
322
|
+
framePaths: Array<String>,
|
|
323
|
+
outputPath: String,
|
|
324
|
+
jpegQuality: Int,
|
|
325
|
+
warperType: String,
|
|
326
|
+
blenderType: String,
|
|
327
|
+
seamFinderType: String,
|
|
328
|
+
captureOrientation: String,
|
|
329
|
+
useInscribedRectCrop: Boolean,
|
|
330
|
+
// V16-followup (Android OOM fix): MP budgets for the
|
|
331
|
+
// staged-resolution pipeline. Negative = use cv::Stitcher
|
|
332
|
+
// library default. Default param values here apply the OOM
|
|
333
|
+
// fix (compose=1.0 MP) by default for the internal-orchestrator
|
|
334
|
+
// call site — caller can override per capture if needed.
|
|
335
|
+
registrationResolMP: Double = -1.0,
|
|
336
|
+
seamEstimationResolMP: Double = -1.0,
|
|
337
|
+
compositingResolMP: Double = 1.0,
|
|
338
|
+
// 2026-05-14 — cv::Stitcher pipeline mode. Caller resolves
|
|
339
|
+
// 'auto' upstream; "panorama" or "scans" only here. Default
|
|
340
|
+
// 'scans' since SCANS handles both rotation-light and
|
|
341
|
+
// translation captures safely (PANORAMA on translation can
|
|
342
|
+
// diverge → multi-GB canvas → lmkd kill).
|
|
343
|
+
stitchMode: String = "scans",
|
|
344
|
+
): IntArray {
|
|
345
|
+
ensureNativeStitcher()
|
|
346
|
+
return nativeStitchFramePaths(
|
|
347
|
+
framePaths,
|
|
348
|
+
outputPath,
|
|
349
|
+
jpegQuality,
|
|
350
|
+
warperType,
|
|
351
|
+
blenderType,
|
|
352
|
+
seamFinderType,
|
|
353
|
+
captureOrientation,
|
|
354
|
+
useInscribedRectCrop,
|
|
355
|
+
registrationResolMP,
|
|
356
|
+
seamEstimationResolMP,
|
|
357
|
+
compositingResolMP,
|
|
358
|
+
stitchMode,
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Load the JNI shim that exposes cv::Stitcher. libopencv_java4
|
|
364
|
+
* must be loaded FIRST because the shim dynamically links against
|
|
365
|
+
* it (uses cv::Mat, cv::imread/imwrite, cv::imgproc symbols
|
|
366
|
+
* exported by the fat lib).
|
|
367
|
+
*/
|
|
368
|
+
internal fun ensureNativeStitcher() {
|
|
369
|
+
ensureOpenCv()
|
|
370
|
+
if (!stitcherInitialised.get()) {
|
|
371
|
+
try {
|
|
372
|
+
System.loadLibrary("image_stitcher")
|
|
373
|
+
stitcherInitialised.set(true)
|
|
374
|
+
} catch (e: UnsatisfiedLinkError) {
|
|
375
|
+
throw IllegalStateException(
|
|
376
|
+
"JNI shim 'image_stitcher' failed to load. " +
|
|
377
|
+
"Check that the custom OpenCV build artifacts " +
|
|
378
|
+
"(libopencv_java4.so + libopencv_stitching.a) " +
|
|
379
|
+
"are in vendor/OpenCV-android-sdk/sdk/native/.",
|
|
380
|
+
e,
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private class StitcherException(val code: String, message: String) : Exception(message)
|
|
387
|
+
|
|
388
|
+
init {
|
|
389
|
+
// Singleton-style accessor for callers that need the
|
|
390
|
+
// BatchStitcher instance from outside the @ReactMethod
|
|
391
|
+
// path (e.g. IncrementalStitcher.finalize() during
|
|
392
|
+
// batch-keyframe stitching).
|
|
393
|
+
//
|
|
394
|
+
// Why this exists:
|
|
395
|
+
// reactContext.getNativeModule(BatchStitcher::class.java)
|
|
396
|
+
// returns null under bridgeless / new-architecture mode for
|
|
397
|
+
// modules registered the legacy way (ReactPackage +
|
|
398
|
+
// createNativeModules), even when the module is fully
|
|
399
|
+
// registered. Empirically confirmed by Galaxy A35
|
|
400
|
+
// capture session 2026-05-13: stitcher IS registered (see
|
|
401
|
+
// RNImageStitcherPackage.kt) but lookup returned null →
|
|
402
|
+
// "BatchStitcher module not registered" IllegalState
|
|
403
|
+
// at finalize time.
|
|
404
|
+
//
|
|
405
|
+
// Same pattern IncrementalStitcher uses (its
|
|
406
|
+
// `bridgeInstance` companion).
|
|
407
|
+
bridgeInstance = this
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
companion object {
|
|
411
|
+
@JvmStatic
|
|
412
|
+
private val opencvInitialised = AtomicBoolean(false)
|
|
413
|
+
|
|
414
|
+
@JvmStatic
|
|
415
|
+
private val stitcherInitialised = AtomicBoolean(false)
|
|
416
|
+
|
|
417
|
+
/// Direct access to the last-constructed BatchStitcher.
|
|
418
|
+
/// RN may rebuild modules across reloads; the lookup always
|
|
419
|
+
/// returns the latest reference. Read-only from outside;
|
|
420
|
+
/// only the init {} above sets it.
|
|
421
|
+
@JvmStatic
|
|
422
|
+
@Volatile
|
|
423
|
+
var bridgeInstance: BatchStitcher? = null
|
|
424
|
+
private set
|
|
425
|
+
}
|
|
426
|
+
}
|