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.
@@ -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: caller MUST `image.close()` after this returns.
26
- * We don't close inside because the caller may want to inspect more
27
- * fields (timestamp, format) before releasing.
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
- /// Convert + write JPEG. Returns the path (no file:// prefix)
32
- /// or null on any encode/write error (caller-decides whether to
33
- /// log + drop the frame).
34
- ///
35
- /// 2026-05-15 (B3) — `displayRotation` parameter writes the
36
- /// appropriate EXIF orientation tag so RN's Image loader (and
37
- /// any other consumer that respects EXIF) displays the JPEG
38
- /// upright regardless of how the device was held at capture.
39
- ///
40
- /// Without this, Android's image loaders display the raw sensor
41
- /// pixels (typically landscape) as-is — so a portrait-held
42
- /// capture's thumbnail appears sideways. iOS's image loader
43
- /// auto-respects EXIF orientation; Android's doesn't always
44
- /// (depends on the loader path). Setting the tag covers both.
45
- ///
46
- /// `displayRotation` should be the value returned from
47
- /// `WindowManager.defaultDisplay.rotation` at capture time
48
- /// (Surface.ROTATION_0/_90/_180/_270). We assume a typical
49
- /// back-camera sensor orientation of 90° — true for all
50
- /// devices we ship to (Galaxy A35 verified). Wire through
51
- /// CameraCharacteristics.SENSOR_ORIENTATION in a follow-up
52
- /// if we ever encounter a device that differs.
53
- ///
54
- /// Default `displayRotation = Surface.ROTATION_0` (portrait)
55
- /// preserves the previous behaviour for legacy callsites that
56
- /// haven't been updated yet.
57
- fun encodeToJpeg(
58
- image: Image,
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
- image.width,
69
- image.height,
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, image.width, image.height),
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
- // Sensor orientation 90° assumed (back camera). The math:
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
- // consumers (cv::Stitcher does NOT auto-honour EXIF, see
98
- // BatchStitcher.applyExifOrientation; this metadata
99
- // exists primarily for the live thumbnail strip + future
100
- // RN Image renderers).
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. Caller doesn't need to
118
- // know non-fatal.
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
- * Pack a YUV_420_888 `Image` into a contiguous NV21 byte array.
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
- * The Image API exposes Y, U, V as three planes, each with its
127
- * own row stride and pixel stride. NV21 expects a single
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
- private fun yuv420toNV21(image: Image): ByteArray? {
133
- val w = image.width
134
- val h = image.height
135
- val ySize = w * h
136
- val uvSize = w * h / 2
137
-
138
- val nv21 = ByteArray(ySize + uvSize)
139
- val planes = image.planes
140
- if (planes.size < 3) return null
141
-
142
- // Y plane.
143
- val yPlane = planes[0]
144
- val yBuf = yPlane.buffer
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
  }
@@ -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
- * Apply per-prop defaults to build the initial settings snapshot.
232
- * The settings live in component state from there; the prop values
233
- * never re-flow.
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
- * Note: the `default*ResolMP` props don't have a home on PanoramaSettings
236
- * yet they're accepted on the prop interface for forward compatibility
237
- * but ignored here. Wiring is a follow-up once PanoramaSettings is
238
- * extended.
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 buildInitialSettings(props) {
247
+ function extractPanoramaOverrides(props) {
241
248
  return {
242
- ...PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS,
243
- stitchMode: props.defaultStitchMode ?? PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.stitchMode,
244
- blenderType: props.defaultBlender ?? PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.blenderType,
245
- seamFinderType: props.defaultSeamFinder ?? PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.seamFinderType,
246
- warperType: props.defaultWarper ?? PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.warperType,
247
- flowNoveltyPercentile: props.defaultFlowNoveltyPercentile ??
248
- PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.flowNoveltyPercentile,
249
- flowEvalEveryNFrames: props.defaultFlowEvalEveryNFrames ??
250
- PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.flowEvalEveryNFrames,
251
- flowMaxTranslationCm: props.defaultFlowMaxTranslationCm ??
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)(() => buildInitialSettings(props));
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
- && settings.flowMaxTranslationCm > 0,
406
- budgetMeters: Math.max(0.001, settings.flowMaxTranslationCm / 100.0),
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
- // ── cv::Stitcher (batch finalize) ─────────────────────────
594
- stitchMode: settings.stitchMode,
595
- warperType: settings.warperType,
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.frameSelectionMode, stitchMode: settings.stitchMode }))),
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 }] },