react-native-image-stitcher 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +220 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +137 -124
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +212 -119
- package/dist/camera/Camera.js +70 -58
- package/dist/camera/PanoramaSettings.d.ts +478 -0
- package/dist/camera/PanoramaSettings.js +120 -0
- package/dist/camera/PanoramaSettingsBridge.d.ts +84 -0
- package/dist/camera/PanoramaSettingsBridge.js +208 -0
- package/dist/camera/PanoramaSettingsModal.d.ts +50 -299
- package/dist/camera/PanoramaSettingsModal.js +189 -354
- package/dist/camera/buildPanoramaInitialSettings.d.ts +70 -0
- package/dist/camera/buildPanoramaInitialSettings.js +97 -0
- package/dist/camera/lowMemDevice.d.ts +24 -0
- package/dist/camera/lowMemDevice.js +69 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +23 -2
- package/package.json +6 -2
- package/src/camera/Camera.tsx +79 -71
- package/src/camera/PanoramaSettings.ts +605 -0
- package/src/camera/PanoramaSettingsBridge.ts +238 -0
- package/src/camera/PanoramaSettingsModal.tsx +296 -989
- package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +375 -0
- package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +119 -0
- package/src/camera/__tests__/lowMemDevice.test.ts +52 -0
- package/src/camera/buildPanoramaInitialSettings.ts +139 -0
- package/src/camera/lowMemDevice.ts +71 -0
- package/src/index.ts +42 -3
|
@@ -14,6 +14,25 @@ import java.io.FileOutputStream
|
|
|
14
14
|
/**
|
|
15
15
|
* Convert an ARCore `Image` (YUV_420_888) to a JPEG file on disk.
|
|
16
16
|
*
|
|
17
|
+
* 2026-05-22 (audit follow-up #19) — split into two phases so callers
|
|
18
|
+
* can release the underlying ARCore `Image` ASAP:
|
|
19
|
+
*
|
|
20
|
+
* 1. `packNV21(image)` — reads the Y/U/V planes into a contiguous
|
|
21
|
+
* JVM-side `ByteArray` (NV21 layout). Fast (~3 ms for 1920×1080).
|
|
22
|
+
* The caller can close the `Image` IMMEDIATELY after this returns,
|
|
23
|
+
* freeing the ARCore Camera2 ImageReader buffer.
|
|
24
|
+
*
|
|
25
|
+
* 2. `encodeJpegFromNV21(packed, …)` — does the slow YUV→JPEG
|
|
26
|
+
* conversion (~10-25 ms) on the already-extracted bytes, NOT on
|
|
27
|
+
* the Image. Safe to run after the Image has been closed.
|
|
28
|
+
*
|
|
29
|
+
* The pre-#19 single-call `encodeToJpeg(image, …)` API is preserved as
|
|
30
|
+
* a thin wrapper for callers that don't care about Image hold time
|
|
31
|
+
* (e.g., one-shot photo capture). Performance-critical paths
|
|
32
|
+
* (`RNSARCameraView.forwardToIncremental`, called at ~60 Hz on the
|
|
33
|
+
* GL render thread) should use the two-step API to keep Image hold
|
|
34
|
+
* times bounded by the ~3 ms pack step instead of the ~25 ms encode.
|
|
35
|
+
*
|
|
17
36
|
* Why JPEG → file → re-decode by OpenCV (slightly wasteful)?
|
|
18
37
|
* The incremental engine's existing API (matching iOS') consumes
|
|
19
38
|
* image PATHS, not raw planes. Threading raw YUV through the
|
|
@@ -22,56 +41,182 @@ import java.io.FileOutputStream
|
|
|
22
41
|
* next to the ~40 ms per-frame engine work — keeping the surface
|
|
23
42
|
* uniform across iOS / Android paths is worth the few-ms cost.
|
|
24
43
|
*
|
|
25
|
-
* `Image` ownership:
|
|
26
|
-
*
|
|
27
|
-
*
|
|
44
|
+
* `Image` ownership: the two-step API (`packNV21` + `encodeJpegFromNV21`)
|
|
45
|
+
* returns control to the caller after the pack step so the caller can
|
|
46
|
+
* close the Image at the right moment. The legacy single-call
|
|
47
|
+
* `encodeToJpeg(image, …)` does NOT close the Image — caller is
|
|
48
|
+
* responsible for that.
|
|
28
49
|
*/
|
|
29
50
|
internal object YuvImageConverter {
|
|
30
51
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Packed NV21 pixel data extracted from an ARCore `Image`.
|
|
54
|
+
* Once you hold one of these, the source `Image` can be closed —
|
|
55
|
+
* all subsequent operations work on the JVM-side byte array.
|
|
56
|
+
*
|
|
57
|
+
* NV21 layout (single contiguous byte array):
|
|
58
|
+
* bytes [0 .. width*height) = Y plane (luminance),
|
|
59
|
+
* densely packed,
|
|
60
|
+
* row stride = width
|
|
61
|
+
* bytes [width*height .. width*height*3/2) = interleaved V-U pairs
|
|
62
|
+
* at half resolution
|
|
63
|
+
*
|
|
64
|
+
* The Y plane portion can be passed directly to the C++
|
|
65
|
+
* `keyframe_gate` as grayscale pixels with `stride = width`.
|
|
66
|
+
*/
|
|
67
|
+
data class PackedYuv(
|
|
68
|
+
val nv21: ByteArray,
|
|
69
|
+
val width: Int,
|
|
70
|
+
val height: Int,
|
|
71
|
+
) {
|
|
72
|
+
/** Length of the Y plane portion (bytes [0 .. ySize)). */
|
|
73
|
+
val ySize: Int get() = width * height
|
|
74
|
+
|
|
75
|
+
// equals + hashCode override required because `nv21` is a
|
|
76
|
+
// mutable array; default `data class` equality uses reference
|
|
77
|
+
// identity for arrays, which is rarely what callers want.
|
|
78
|
+
override fun equals(other: Any?): Boolean {
|
|
79
|
+
if (this === other) return true
|
|
80
|
+
if (other !is PackedYuv) return false
|
|
81
|
+
return width == other.width
|
|
82
|
+
&& height == other.height
|
|
83
|
+
&& nv21.contentEquals(other.nv21)
|
|
84
|
+
}
|
|
85
|
+
override fun hashCode(): Int {
|
|
86
|
+
var result = nv21.contentHashCode()
|
|
87
|
+
result = 31 * result + width
|
|
88
|
+
result = 31 * result + height
|
|
89
|
+
return result
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Pack the Y, U, V planes of a YUV_420_888 `Image` into a
|
|
96
|
+
* contiguous JVM-side NV21 byte array. Returns null if the
|
|
97
|
+
* `Image`'s format isn't YUV_420_888 or doesn't expose 3 planes.
|
|
98
|
+
*
|
|
99
|
+
* Performance: ~3 ms for 1920×1080 on a Galaxy A35. Dominated
|
|
100
|
+
* by the row-by-row copy through the direct ByteBuffers backing
|
|
101
|
+
* the camera planes.
|
|
102
|
+
*
|
|
103
|
+
* The Y plane is densely repacked (the source rowStride may be
|
|
104
|
+
* padded, but we discard padding on the way in so the result has
|
|
105
|
+
* `rowStride = width`). This is what callers want — `cv::Mat`
|
|
106
|
+
* wrap on the C++ side prefers tight strides, and downstream
|
|
107
|
+
* `YuvImage.compressToJpeg` requires densely-packed input.
|
|
108
|
+
*/
|
|
109
|
+
fun packNV21(image: Image): PackedYuv? {
|
|
110
|
+
if (image.format != ImageFormat.YUV_420_888) return null
|
|
111
|
+
val planes = image.planes
|
|
112
|
+
if (planes.size < 3) return null
|
|
113
|
+
|
|
114
|
+
val w = image.width
|
|
115
|
+
val h = image.height
|
|
116
|
+
val ySize = w * h
|
|
117
|
+
val uvSize = w * h / 2
|
|
118
|
+
val nv21 = ByteArray(ySize + uvSize)
|
|
119
|
+
|
|
120
|
+
// ── Y plane (luminance) ─────────────────────────────────
|
|
121
|
+
val yPlane = planes[0]
|
|
122
|
+
val yBuf = yPlane.buffer
|
|
123
|
+
val yRowStride = yPlane.rowStride
|
|
124
|
+
if (yRowStride == w) {
|
|
125
|
+
// Source already densely packed — single block copy.
|
|
126
|
+
// Use duplicate() so we don't mutate the original buffer's
|
|
127
|
+
// position state (defensive — ARCore may have other readers
|
|
128
|
+
// of the same underlying buffer, though in practice it
|
|
129
|
+
// shouldn't).
|
|
130
|
+
yBuf.duplicate().apply { rewind() }.get(nv21, 0, ySize)
|
|
131
|
+
} else {
|
|
132
|
+
// Row-by-row copy when stride > width (padded rows).
|
|
133
|
+
val dup = yBuf.duplicate()
|
|
134
|
+
var dstOffset = 0
|
|
135
|
+
var srcOffset = 0
|
|
136
|
+
for (row in 0 until h) {
|
|
137
|
+
dup.position(srcOffset)
|
|
138
|
+
dup.get(nv21, dstOffset, w)
|
|
139
|
+
dstOffset += w
|
|
140
|
+
srcOffset += yRowStride
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── U + V planes (chroma) ───────────────────────────────
|
|
145
|
+
// YUV_420_888 has them subsampled 2:1 so each plane physically
|
|
146
|
+
// covers (w/2) × (h/2). Pixel stride is 1 (planar) or 2
|
|
147
|
+
// (semi-planar interleaved). NV21 expects interleaved V-U.
|
|
148
|
+
val uPlane = planes[1]
|
|
149
|
+
val vPlane = planes[2]
|
|
150
|
+
val uBuf = uPlane.buffer
|
|
151
|
+
val vBuf = vPlane.buffer
|
|
152
|
+
val uRowStride = uPlane.rowStride
|
|
153
|
+
val uPixelStride = uPlane.pixelStride
|
|
154
|
+
val vRowStride = vPlane.rowStride
|
|
155
|
+
val vPixelStride = vPlane.pixelStride
|
|
156
|
+
|
|
157
|
+
// Fast path — most Android camera2 / ARCore producers emit
|
|
158
|
+
// semi-planar interleaved data with pixelStride=2. In that
|
|
159
|
+
// case the V plane's underlying bytes physically interleave
|
|
160
|
+
// V-U-V-U... and copying the V plane's full byte range
|
|
161
|
+
// produces NV21 layout directly.
|
|
162
|
+
if (uPixelStride == 2 && vPixelStride == 2 &&
|
|
163
|
+
uRowStride == vRowStride && uRowStride == w) {
|
|
164
|
+
val vBytes = vBuf.remaining().coerceAtMost(uvSize)
|
|
165
|
+
// Defensive duplicate() again — same reasoning as Y plane.
|
|
166
|
+
vBuf.duplicate().apply { rewind() }.get(nv21, ySize, vBytes)
|
|
167
|
+
return PackedYuv(nv21, w, h)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Slow path — manual interleave for planar (pixelStride=1) or
|
|
171
|
+
// non-tight semi-planar layouts.
|
|
172
|
+
var pos = ySize
|
|
173
|
+
val rowsUv = h / 2
|
|
174
|
+
val colsUv = w / 2
|
|
175
|
+
for (row in 0 until rowsUv) {
|
|
176
|
+
for (col in 0 until colsUv) {
|
|
177
|
+
val vIdx = row * vRowStride + col * vPixelStride
|
|
178
|
+
val uIdx = row * uRowStride + col * uPixelStride
|
|
179
|
+
nv21[pos++] = vBuf.get(vIdx)
|
|
180
|
+
nv21[pos++] = uBuf.get(uIdx)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return PackedYuv(nv21, w, h)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Encode an already-packed NV21 buffer to a JPEG file on disk.
|
|
189
|
+
*
|
|
190
|
+
* Returns the output path on success, or null on any encode/write
|
|
191
|
+
* error (caller decides whether to log + drop the frame).
|
|
192
|
+
*
|
|
193
|
+
* `displayRotation` writes the appropriate EXIF orientation tag
|
|
194
|
+
* so consumers that respect EXIF (RN's Image loader, etc.)
|
|
195
|
+
* display the JPEG upright regardless of how the device was held
|
|
196
|
+
* at capture. Should be the value from
|
|
197
|
+
* `WindowManager.defaultDisplay.rotation` at capture time
|
|
198
|
+
* (Surface.ROTATION_0 / _90 / _180 / _270).
|
|
199
|
+
*
|
|
200
|
+
* Sensor orientation 90° assumed (back camera) — verified on
|
|
201
|
+
* Galaxy A35. Wire `CameraCharacteristics.SENSOR_ORIENTATION`
|
|
202
|
+
* through in a follow-up if we hit a device that differs.
|
|
203
|
+
*/
|
|
204
|
+
fun encodeJpegFromNV21(
|
|
205
|
+
packed: PackedYuv,
|
|
59
206
|
outputPath: String,
|
|
60
207
|
jpegQuality: Int = 70,
|
|
61
208
|
displayRotation: Int = Surface.ROTATION_0,
|
|
62
209
|
): String? {
|
|
63
|
-
if (image.format != ImageFormat.YUV_420_888) return null
|
|
64
|
-
val nv21 = yuv420toNV21(image) ?: return null
|
|
65
210
|
val yuvImage = YuvImage(
|
|
66
|
-
nv21,
|
|
211
|
+
packed.nv21,
|
|
67
212
|
ImageFormat.NV21,
|
|
68
|
-
|
|
69
|
-
|
|
213
|
+
packed.width,
|
|
214
|
+
packed.height,
|
|
70
215
|
null,
|
|
71
216
|
)
|
|
72
217
|
val baos = ByteArrayOutputStream()
|
|
73
218
|
val ok = yuvImage.compressToJpeg(
|
|
74
|
-
Rect(0, 0,
|
|
219
|
+
Rect(0, 0, packed.width, packed.height),
|
|
75
220
|
jpegQuality.coerceIn(1, 100),
|
|
76
221
|
baos,
|
|
77
222
|
)
|
|
@@ -81,8 +226,9 @@ internal object YuvImageConverter {
|
|
|
81
226
|
} catch (e: Throwable) {
|
|
82
227
|
return null
|
|
83
228
|
}
|
|
229
|
+
|
|
84
230
|
// Write EXIF orientation tag based on display rotation.
|
|
85
|
-
//
|
|
231
|
+
// The math:
|
|
86
232
|
// ROTATION_0 (portrait, sensor 90° CW from screen-up)
|
|
87
233
|
// → JPEG needs 90° CW to display upright → ROTATE_90 (6)
|
|
88
234
|
// ROTATION_90 (landscape-left, sensor aligned with screen)
|
|
@@ -93,11 +239,11 @@ internal object YuvImageConverter {
|
|
|
93
239
|
// → 180° → ROTATE_180 (3)
|
|
94
240
|
//
|
|
95
241
|
// EXIF tag set EVEN when the orientation is normal — keeps
|
|
96
|
-
// every output JPEG self-describing for downstream
|
|
97
|
-
//
|
|
98
|
-
// BatchStitcher.applyExifOrientation; this metadata
|
|
99
|
-
//
|
|
100
|
-
//
|
|
242
|
+
// every output JPEG self-describing for downstream consumers.
|
|
243
|
+
// cv::Stitcher does NOT auto-honour EXIF (see
|
|
244
|
+
// BatchStitcher.applyExifOrientation); this metadata exists
|
|
245
|
+
// primarily for the live thumbnail strip + future RN Image
|
|
246
|
+
// renderers.
|
|
101
247
|
val exifOrientation = when (displayRotation) {
|
|
102
248
|
Surface.ROTATION_0 -> ExifInterface.ORIENTATION_ROTATE_90
|
|
103
249
|
Surface.ROTATION_90 -> ExifInterface.ORIENTATION_NORMAL
|
|
@@ -114,88 +260,35 @@ internal object YuvImageConverter {
|
|
|
114
260
|
exif.saveAttributes()
|
|
115
261
|
} catch (e: Throwable) {
|
|
116
262
|
// EXIF write failed — JPEG itself is still valid; just
|
|
117
|
-
// missing the orientation hint.
|
|
118
|
-
//
|
|
263
|
+
// missing the orientation hint. Non-fatal; caller doesn't
|
|
264
|
+
// need to know.
|
|
119
265
|
}
|
|
120
266
|
return outputPath
|
|
121
267
|
}
|
|
122
268
|
|
|
269
|
+
|
|
123
270
|
/**
|
|
124
|
-
*
|
|
271
|
+
* Single-call convenience wrapper: pack the `Image` and encode
|
|
272
|
+
* to JPEG in one step. Keeps the `Image` open through the entire
|
|
273
|
+
* ~25 ms encode — fine for one-shot photo capture, NOT
|
|
274
|
+
* recommended for the ~60 Hz `forwardToIncremental` path. See the
|
|
275
|
+
* file-level docs for the two-step alternative.
|
|
125
276
|
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* contiguous buffer with Y plane first, then interleaved VU bytes
|
|
129
|
-
* after. The repacking handles row + pixel strides that don't
|
|
130
|
-
* match the dense layout.
|
|
277
|
+
* Caller still owns the `Image` and MUST close it afterwards;
|
|
278
|
+
* this function does not.
|
|
131
279
|
*/
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
val
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
val yRowStride = yPlane.rowStride
|
|
146
|
-
if (yRowStride == w) {
|
|
147
|
-
yBuf.get(nv21, 0, ySize)
|
|
148
|
-
} else {
|
|
149
|
-
// Row-by-row copy when stride != width.
|
|
150
|
-
var dstOffset = 0
|
|
151
|
-
var srcOffset = 0
|
|
152
|
-
for (row in 0 until h) {
|
|
153
|
-
yBuf.position(srcOffset)
|
|
154
|
-
yBuf.get(nv21, dstOffset, w)
|
|
155
|
-
dstOffset += w
|
|
156
|
-
srcOffset += yRowStride
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// U + V planes. YUV_420_888 has them subsampled 2:1 so each
|
|
161
|
-
// covers (w/2) × (h/2). Pixel stride is 1 (planar) or 2
|
|
162
|
-
// (semi-planar interleaved). NV21 requires interleaved VU.
|
|
163
|
-
val uPlane = planes[1]
|
|
164
|
-
val vPlane = planes[2]
|
|
165
|
-
val uBuf = uPlane.buffer
|
|
166
|
-
val vBuf = vPlane.buffer
|
|
167
|
-
val uRowStride = uPlane.rowStride
|
|
168
|
-
val uPixelStride = uPlane.pixelStride
|
|
169
|
-
val vRowStride = vPlane.rowStride
|
|
170
|
-
val vPixelStride = vPlane.pixelStride
|
|
171
|
-
|
|
172
|
-
// Most camera2 / ARCore implementations on Android already
|
|
173
|
-
// produce semi-planar interleaved data with pixelStride=2.
|
|
174
|
-
// In that case Y plane + V plane (offset by 1) form NV21
|
|
175
|
-
// directly with a single block copy. Detect + fast-path it.
|
|
176
|
-
if (uPixelStride == 2 && vPixelStride == 2 &&
|
|
177
|
-
uRowStride == vRowStride && uRowStride == w) {
|
|
178
|
-
// The V plane in NV21 layout starts at vBuf's first byte.
|
|
179
|
-
// Copy the entire V plane (which physically interleaves
|
|
180
|
-
// with U bytes since pixelStride=2 means consecutive
|
|
181
|
-
// bytes are V-U-V-U...).
|
|
182
|
-
val vBytes = vBuf.remaining().coerceAtMost(uvSize)
|
|
183
|
-
vBuf.get(nv21, ySize, vBytes)
|
|
184
|
-
return nv21
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Slow path — manual interleave.
|
|
188
|
-
var pos = ySize
|
|
189
|
-
val rowsUv = h / 2
|
|
190
|
-
val colsUv = w / 2
|
|
191
|
-
for (row in 0 until rowsUv) {
|
|
192
|
-
for (col in 0 until colsUv) {
|
|
193
|
-
val vIdx = row * vRowStride + col * vPixelStride
|
|
194
|
-
val uIdx = row * uRowStride + col * uPixelStride
|
|
195
|
-
nv21[pos++] = vBuf.get(vIdx)
|
|
196
|
-
nv21[pos++] = uBuf.get(uIdx)
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
return nv21
|
|
280
|
+
fun encodeToJpeg(
|
|
281
|
+
image: Image,
|
|
282
|
+
outputPath: String,
|
|
283
|
+
jpegQuality: Int = 70,
|
|
284
|
+
displayRotation: Int = Surface.ROTATION_0,
|
|
285
|
+
): String? {
|
|
286
|
+
val packed = packNV21(image) ?: return null
|
|
287
|
+
return encodeJpegFromNV21(
|
|
288
|
+
packed,
|
|
289
|
+
outputPath,
|
|
290
|
+
jpegQuality = jpegQuality,
|
|
291
|
+
displayRotation = displayRotation,
|
|
292
|
+
)
|
|
200
293
|
}
|
|
201
294
|
}
|
package/dist/camera/Camera.js
CHANGED
|
@@ -90,7 +90,10 @@ const CaptureKeyframePill_1 = require("./CaptureKeyframePill");
|
|
|
90
90
|
const CaptureOrientationPill_1 = require("./CaptureOrientationPill");
|
|
91
91
|
const CaptureStitchStatsToast_1 = require("./CaptureStitchStatsToast");
|
|
92
92
|
const PanoramaBandOverlay_1 = require("./PanoramaBandOverlay");
|
|
93
|
+
const PanoramaSettingsBridge_1 = require("./PanoramaSettingsBridge");
|
|
93
94
|
const PanoramaSettingsModal_1 = require("./PanoramaSettingsModal");
|
|
95
|
+
const buildPanoramaInitialSettings_1 = require("./buildPanoramaInitialSettings");
|
|
96
|
+
const lowMemDevice_1 = require("./lowMemDevice");
|
|
94
97
|
const useCapture_1 = require("./useCapture");
|
|
95
98
|
const useDeviceOrientation_1 = require("./useDeviceOrientation");
|
|
96
99
|
const incremental_1 = require("../stitching/incremental");
|
|
@@ -228,32 +231,31 @@ function deriveEffectiveCaptureSource(arPreference, lens, isARSupportedOnDevice)
|
|
|
228
231
|
return arPreference ? 'ar' : 'non-ar';
|
|
229
232
|
}
|
|
230
233
|
/**
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
+
* Pluck the props that influence the initial PanoramaSettings tree.
|
|
235
|
+
* Kept inline (vs. a wide structural type) so future Camera prop
|
|
236
|
+
* additions don't accidentally widen the settings-translation
|
|
237
|
+
* surface — the pure builder in `./buildPanoramaInitialSettings.ts`
|
|
238
|
+
* has the canonical interface; this just forwards the relevant
|
|
239
|
+
* fields.
|
|
234
240
|
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
241
|
+
* The `default*ResolMP` props on `CameraProps` are documented as
|
|
242
|
+
* forward-looking no-ops; the new PanoramaSettings tree has no home
|
|
243
|
+
* for them yet (the v0.3 audit found cv::Stitcher's resol knobs
|
|
244
|
+
* aren't reached by either platform's bridge). They're accepted on
|
|
245
|
+
* the prop interface for API stability and ignored here.
|
|
239
246
|
*/
|
|
240
|
-
function
|
|
247
|
+
function extractPanoramaOverrides(props) {
|
|
241
248
|
return {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.flowMaxTranslationCm,
|
|
253
|
-
keyframeMaxCount: props.defaultKeyframeMaxCount ??
|
|
254
|
-
PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.keyframeMaxCount,
|
|
255
|
-
keyframeOverlapThreshold: props.defaultKeyframeOverlapThreshold ??
|
|
256
|
-
PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.keyframeOverlapThreshold,
|
|
249
|
+
defaultCaptureSource: props.defaultCaptureSource,
|
|
250
|
+
defaultStitchMode: props.defaultStitchMode,
|
|
251
|
+
defaultBlender: props.defaultBlender,
|
|
252
|
+
defaultSeamFinder: props.defaultSeamFinder,
|
|
253
|
+
defaultWarper: props.defaultWarper,
|
|
254
|
+
defaultFlowNoveltyPercentile: props.defaultFlowNoveltyPercentile,
|
|
255
|
+
defaultFlowEvalEveryNFrames: props.defaultFlowEvalEveryNFrames,
|
|
256
|
+
defaultFlowMaxTranslationCm: props.defaultFlowMaxTranslationCm,
|
|
257
|
+
defaultKeyframeMaxCount: props.defaultKeyframeMaxCount,
|
|
258
|
+
defaultKeyframeOverlapThreshold: props.defaultKeyframeOverlapThreshold,
|
|
257
259
|
};
|
|
258
260
|
}
|
|
259
261
|
// `toFileUri` (used to be an inline `toFileUri` here) lives in
|
|
@@ -273,7 +275,7 @@ function Camera(props) {
|
|
|
273
275
|
// ── State ───────────────────────────────────────────────────────
|
|
274
276
|
const [arPreference, setArPreference] = (0, react_1.useState)(defaultCaptureSource === 'ar');
|
|
275
277
|
const [lens, setLens] = (0, react_1.useState)(defaultLens);
|
|
276
|
-
const [settings, setSettings] = (0, react_1.useState)(() =>
|
|
278
|
+
const [settings, setSettings] = (0, react_1.useState)(() => (0, buildPanoramaInitialSettings_1.buildPanoramaInitialSettings)(extractPanoramaOverrides(props), (0, lowMemDevice_1.isLowMemDevice)()));
|
|
277
279
|
const [settingsModalVisible, setSettingsModalVisible] = (0, react_1.useState)(false);
|
|
278
280
|
const [statusPhase, setStatusPhase] = (0, react_1.useState)('idle');
|
|
279
281
|
const [recordingStartedAt, setRecordingStartedAt] = (0, react_1.useState)(null);
|
|
@@ -399,11 +401,16 @@ function Camera(props) {
|
|
|
399
401
|
// |residual|` — which undercounted any time a non-IMU accept
|
|
400
402
|
// (flow novelty, force-last) reset the integrator before the
|
|
401
403
|
// budget threshold was reached.
|
|
404
|
+
// The translation budget lives at `frameSelection.flow.maxTranslationCm`
|
|
405
|
+
// in the new hierarchical settings shape. When `flow` is undefined
|
|
406
|
+
// (the consumer opted out of the flow strategy entirely), the gate
|
|
407
|
+
// stays disabled — same observable behaviour as v0.3's `0` default.
|
|
408
|
+
const flowMaxTranslationCm = settings.frameSelection.flow?.maxTranslationCm ?? 0;
|
|
402
409
|
const imuGate = (0, useIMUTranslationGate_1.useIMUTranslationGate)({
|
|
403
410
|
enabled: isNonAR
|
|
404
411
|
&& statusPhase === 'recording'
|
|
405
|
-
&&
|
|
406
|
-
budgetMeters: Math.max(0.001,
|
|
412
|
+
&& flowMaxTranslationCm > 0,
|
|
413
|
+
budgetMeters: Math.max(0.001, flowMaxTranslationCm / 100.0),
|
|
407
414
|
onBudgetExceeded: () => {
|
|
408
415
|
const mod = (0, incremental_1.getIncrementalNativeModule)();
|
|
409
416
|
mod?.markNextFrameAsLastKeyframe?.().catch(() => undefined);
|
|
@@ -578,6 +585,25 @@ function Camera(props) {
|
|
|
578
585
|
const orientationRotation = deviceOrientation === 'portrait' ? 90
|
|
579
586
|
: deviceOrientation === 'portrait-upside-down' ? 270
|
|
580
587
|
: 0;
|
|
588
|
+
// v0.4 — the inline-flat config dict that v0.3 maintained here
|
|
589
|
+
// moved into `panoramaSettingsToNativeConfig` (see
|
|
590
|
+
// PanoramaSettingsBridge.ts). That adapter is the single source
|
|
591
|
+
// of truth for the JS→native wire format; both this call site
|
|
592
|
+
// AND the modal's reset-to-defaults preview agree on the same
|
|
593
|
+
// mapping. Audit fixes F1 / F4 / F6 from v0.3 are now properties
|
|
594
|
+
// of the bridge (verified by the unit tests in
|
|
595
|
+
// src/camera/__tests__/PanoramaSettingsBridge.test.ts).
|
|
596
|
+
//
|
|
597
|
+
// 2026-05-23 — override `captureSource` with the runtime-derived
|
|
598
|
+
// `effectiveCaptureSource` (from `arPreference + lens +
|
|
599
|
+
// AR-device-support`). Pre-this change the camera-screen AR
|
|
600
|
+
// toggle wrote ONLY to local `arPreference` state while the
|
|
601
|
+
// bridge read `settings.captureSource` — so native could think
|
|
602
|
+
// the capture was AR while the operator had toggled it off (or
|
|
603
|
+
// vice-versa). Single source of truth now: whatever camera the
|
|
604
|
+
// operator can see is what native is told it is. The settings
|
|
605
|
+
// modal's `captureSource` control has been removed for the same
|
|
606
|
+
// reason — see PanoramaSettingsModal.tsx for the rationale.
|
|
581
607
|
await incremental.start({
|
|
582
608
|
snapshotJpegQuality: 75,
|
|
583
609
|
snapshotEveryNAccepts: 1,
|
|
@@ -589,37 +615,10 @@ function Camera(props) {
|
|
|
589
615
|
canvasWidth: 5000,
|
|
590
616
|
canvasHeight: 5000,
|
|
591
617
|
engine: 'batch-keyframe',
|
|
592
|
-
config: {
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
blenderType: settings.blenderType,
|
|
597
|
-
seamFinderType: settings.seamFinderType,
|
|
598
|
-
enableMaxInscribedRectCrop: settings.enableMaxInscribedRectCrop,
|
|
599
|
-
// ── KeyframeGate (per-frame selection) ────────────────────
|
|
600
|
-
// F6 audit fix: pass settings.frameSelectionMode through
|
|
601
|
-
// instead of hardcoding 'flow-based' (which silently made the
|
|
602
|
-
// time-based / pose-based modal options no-ops).
|
|
603
|
-
frameSelectionMode: settings.frameSelectionMode,
|
|
604
|
-
keyframeMaxCount: settings.keyframeMaxCount,
|
|
605
|
-
keyframeOverlapThreshold: settings.keyframeOverlapThreshold,
|
|
606
|
-
// ── Flow-strategy tunables ────────────────────────────────
|
|
607
|
-
// F4 audit fix: previously omitted, which made the modal
|
|
608
|
-
// sliders for these three a complete no-op (only iOS native
|
|
609
|
-
// even read them, and only when JS sent them).
|
|
610
|
-
flowNoveltyPercentile: settings.flowNoveltyPercentile,
|
|
611
|
-
flowEvalEveryNFrames: settings.flowEvalEveryNFrames,
|
|
612
|
-
flowMaxTranslationCm: settings.flowMaxTranslationCm,
|
|
613
|
-
flowMaxCorners: settings.flowMaxCorners,
|
|
614
|
-
flowQualityLevel: settings.flowQualityLevel,
|
|
615
|
-
flowMinDistance: settings.flowMinDistance,
|
|
616
|
-
// ── Engine-routing flags consumed by native ───────────────
|
|
617
|
-
// F1 audit fix: Android keyframe gate's disableAngularFallback
|
|
618
|
-
// opt-out reads this to decide whether to skip the angular
|
|
619
|
-
// fallback (gyro pose is too noisy for the FoV-overlap calc
|
|
620
|
-
// in non-AR mode, causing degenerate cv::Stitcher params).
|
|
621
|
-
captureSource: settings.captureSource,
|
|
622
|
-
},
|
|
618
|
+
config: (0, PanoramaSettingsBridge_1.panoramaSettingsToNativeConfig)({
|
|
619
|
+
...settings,
|
|
620
|
+
captureSource: effectiveCaptureSource,
|
|
621
|
+
}),
|
|
623
622
|
});
|
|
624
623
|
imuGate.resetAnchor();
|
|
625
624
|
// Start pumping vision-camera snapshots into the engine for
|
|
@@ -642,6 +641,7 @@ function Camera(props) {
|
|
|
642
641
|
isNonAR,
|
|
643
642
|
deviceOrientation,
|
|
644
643
|
settings,
|
|
644
|
+
effectiveCaptureSource,
|
|
645
645
|
imuGate,
|
|
646
646
|
jsDriver,
|
|
647
647
|
onError,
|
|
@@ -723,6 +723,18 @@ function Camera(props) {
|
|
|
723
723
|
onError,
|
|
724
724
|
recordingStartedAt,
|
|
725
725
|
jsDriver,
|
|
726
|
+
// F10 Phase 2 review N1 — these four were missing pre-fix. The
|
|
727
|
+
// callback reads `settings.debug` (to gate the stitchToast),
|
|
728
|
+
// `isNonAR` (to decide whether to read IMU totalAbs translation),
|
|
729
|
+
// `imuGate` (the read itself), and `stitchToast` (the toast hook
|
|
730
|
+
// object). If any of those identities change between the user
|
|
731
|
+
// pressing-and-holding the shutter and the release, the stale-
|
|
732
|
+
// closure read could disagree with the actual current state.
|
|
733
|
+
// Pre-existing v0.3 bug; v0.4 was the natural time to address it.
|
|
734
|
+
settings,
|
|
735
|
+
isNonAR,
|
|
736
|
+
imuGate,
|
|
737
|
+
stitchToast,
|
|
726
738
|
]);
|
|
727
739
|
// ── Lens / AR-toggle handlers ───────────────────────────────────
|
|
728
740
|
const handleLensChange = (0, react_1.useCallback)((next) => {
|
|
@@ -749,7 +761,7 @@ function Camera(props) {
|
|
|
749
761
|
react_1.default.createElement(CaptureOrientationPill_1.CaptureOrientationPill, { orientation: deviceOrientation, topInset: insets.top }),
|
|
750
762
|
react_1.default.createElement(CaptureKeyframePill_1.CaptureKeyframePill, { state: incrementalState, topInset: insets.top }),
|
|
751
763
|
react_1.default.createElement(CaptureMemoryPill_1.CaptureMemoryPill, { topInset: insets.top }),
|
|
752
|
-
react_1.default.createElement(CaptureDebugOverlay_1.CaptureDebugOverlay, { incrementalState: incrementalState, imuTranslationMetres: isNonAR ? imuGate.getTranslationMetres() : null, captureSource: effectiveCaptureSource, frameSelectionMode: settings.
|
|
764
|
+
react_1.default.createElement(CaptureDebugOverlay_1.CaptureDebugOverlay, { incrementalState: incrementalState, imuTranslationMetres: isNonAR ? imuGate.getTranslationMetres() : null, captureSource: effectiveCaptureSource, frameSelectionMode: settings.frameSelection.mode, stitchMode: settings.stitcher.stitchMode }))),
|
|
753
765
|
react_1.default.createElement(CaptureStitchStatsToast_1.CaptureStitchStatsToast, { message: stitchToast.message, topInset: insets.top }),
|
|
754
766
|
showSettingsButton && (react_1.default.createElement(SettingsButton, { topInset: insets.top, onPress: () => setSettingsModalVisible(true) })),
|
|
755
767
|
react_1.default.createElement(react_native_1.View, { pointerEvents: "box-none", style: [styles.bottomArea, { paddingBottom: insets.bottom + 12 }] },
|