react-native-image-stitcher 0.16.0 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +41 -44
  3. package/android/build.gradle +34 -0
  4. package/android/src/main/cpp/image_stitcher_jni.cpp +52 -7
  5. package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
  6. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +135 -59
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
  8. package/cpp/keyframe_gate.cpp +54 -15
  9. package/cpp/keyframe_gate.hpp +33 -0
  10. package/cpp/stitcher.cpp +481 -87
  11. package/cpp/stitcher.hpp +52 -0
  12. package/dist/camera/Camera.d.ts +13 -0
  13. package/dist/camera/Camera.js +9 -64
  14. package/dist/camera/CaptureFrameCounterOverlay.js +12 -1
  15. package/dist/camera/CaptureMemoryPill.d.ts +15 -7
  16. package/dist/camera/CaptureMemoryPill.js +34 -9
  17. package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
  18. package/dist/camera/PanoramaBandOverlay.js +9 -3
  19. package/dist/camera/PanoramaSettings.js +22 -25
  20. package/dist/camera/RectCropPreview.d.ts +3 -29
  21. package/dist/camera/RectCropPreview.js +20 -130
  22. package/dist/stitching/incremental.d.ts +29 -0
  23. package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
  24. package/dist/stitching/useIncrementalStitcher.js +7 -1
  25. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +103 -26
  26. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
  27. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
  28. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +21 -1
  29. package/package.json +1 -1
  30. package/src/camera/Camera.tsx +21 -70
  31. package/src/camera/CaptureFrameCounterOverlay.tsx +15 -1
  32. package/src/camera/CaptureMemoryPill.tsx +33 -9
  33. package/src/camera/PanoramaBandOverlay.tsx +9 -1
  34. package/src/camera/PanoramaSettings.ts +22 -25
  35. package/src/camera/RectCropPreview.tsx +38 -220
  36. package/src/stitching/incremental.ts +29 -0
  37. package/src/stitching/useIncrementalStitcher.ts +13 -0
  38. package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +0 -100
@@ -109,56 +109,8 @@ function seedImageQuad(imageWidth, imageHeight, initialRect) {
109
109
  ];
110
110
  }
111
111
  function RectCropPreview(props) {
112
- const { imageUri, imageWidth, imageHeight, altImageUri, visible, onConfirm, onUseOriginal, onRetake, warnings, showCropControls = true, initialRect, copy, topInset = 0, bottomInset = 0, debugInfo, onRequestAlt, showMemoryPill, } = props;
112
+ const { imageUri, imageWidth, imageHeight, visible, onConfirm, onUseOriginal, onRetake, warnings, showCropControls = true, initialRect, copy, topInset = 0, bottomInset = 0, debugInfo, showMemoryPill, } = props;
113
113
  const resolvedCopy = (0, react_1.useMemo)(() => (0, cameraGuidanceCopy_1.mergeGuidanceCopy)(copy), [copy]);
114
- // ── A/B comparison — the PRIMARY (imageUri) is the MANUAL pipeline (the
115
- // default output). The alt is HIGH-LEVEL cv::Stitcher, produced either
116
- // EAGERLY (`altImageUri`, legacy) or ON DEMAND (`onRequestAlt`, re-stitched
117
- // the first time the user opens the high-level tab). `altSize` is fetched
118
- // once the alt uri exists; when the alt is showing we use its dims for the
119
- // contain-fit and hide the crop quad.
120
- const [showingAlt, setShowingAlt] = (0, react_1.useState)(false);
121
- const [lazyAltUri, setLazyAltUri] = (0, react_1.useState)(null);
122
- // The high-level (alt) stitch's OWN DEV-overlay recipe, resolved alongside
123
- // its uri from `onRequestAlt`. Shown in the params pill in place of the
124
- // manual primary's `debugInfo` while the high-level tab is being viewed.
125
- const [lazyAltDebugInfo, setLazyAltDebugInfo] = (0, react_1.useState)(null);
126
- const [altLoading, setAltLoading] = (0, react_1.useState)(false);
127
- const [altFailed, setAltFailed] = (0, react_1.useState)(false);
128
- const altUri = altImageUri ?? lazyAltUri ?? null;
129
- const altOffered = !!altImageUri || !!onRequestAlt;
130
- const [altSize, setAltSize] = (0, react_1.useState)(null);
131
- react_1.default.useEffect(() => {
132
- if (!altUri) {
133
- setAltSize(null);
134
- return;
135
- }
136
- react_native_1.Image.getSize(altUri, (w, h) => setAltSize({ w, h }), () => setAltSize(null));
137
- }, [altUri]);
138
- // Switch to the high-level (alt) view; compute it lazily on first request.
139
- const showHighLevel = react_1.default.useCallback(() => {
140
- setShowingAlt(true);
141
- if (altUri || altLoading || !onRequestAlt)
142
- return;
143
- setAltFailed(false);
144
- setAltLoading(true);
145
- onRequestAlt()
146
- .then((result) => {
147
- if (result) {
148
- setLazyAltUri(result.uri);
149
- setLazyAltDebugInfo(result.debugInfo);
150
- }
151
- else {
152
- setAltFailed(true);
153
- }
154
- })
155
- .catch(() => setAltFailed(true))
156
- .finally(() => setAltLoading(false));
157
- }, [altUri, altLoading, onRequestAlt]);
158
- const showAlt = showingAlt && !!altUri && !!altSize;
159
- const activeUri = showAlt ? altUri : imageUri;
160
- const activeW = showAlt ? altSize.w : imageWidth;
161
- const activeH = showAlt ? altSize.h : imageHeight;
162
114
  // The 4 corners live in IMAGE-PIXEL space (the source of truth) so they
163
115
  // survive layout-box changes (rotation, keyboard) without drift. We map
164
116
  // to screen for rendering and back on every drag via cropGeometry.
@@ -244,19 +196,18 @@ function RectCropPreview(props) {
244
196
  let imageBox = null;
245
197
  let screenCorners = null;
246
198
  if (box) {
247
- const fit = (0, cropGeometry_1.containFit)(box, activeW, activeH);
199
+ const fit = (0, cropGeometry_1.containFit)(box, imageWidth, imageHeight);
248
200
  if (fit) {
249
201
  imageBox = {
250
202
  left: fit.offX,
251
203
  top: fit.offY,
252
- width: activeW * fit.scale,
253
- height: activeH * fit.scale,
204
+ width: imageWidth * fit.scale,
205
+ height: imageHeight * fit.scale,
254
206
  };
255
- // Quad corners only apply to the primary (croppable) image — hidden
256
- // while the alt (manual) output is shown for comparison.
257
- screenCorners = showAlt
258
- ? null
259
- : imageQuad.map((p) => (0, cropGeometry_1.imageToScreen)(p, box, imageWidth, imageHeight));
207
+ // Quad corners only apply in crop mode.
208
+ screenCorners = showCropControls
209
+ ? imageQuad.map((p) => (0, cropGeometry_1.imageToScreen)(p, box, imageWidth, imageHeight))
210
+ : null;
260
211
  }
261
212
  }
262
213
  // Outline path (a <View> per edge — RN core has no <Polygon>; this
@@ -282,32 +233,16 @@ function RectCropPreview(props) {
282
233
  react_1.default.createElement(react_native_1.View, { style: [styles.root, { paddingTop: topInset }] },
283
234
  showMemoryPill ? (react_1.default.createElement(CaptureMemoryPill_1.CaptureMemoryPill, { style: {
284
235
  position: 'absolute',
285
- top: topInset + (altOffered ? 76 : 8),
236
+ top: topInset + 8,
286
237
  left: 12,
287
238
  zIndex: 21,
288
239
  } })) : null,
289
- (() => {
290
- const pillText = showAlt && lazyAltDebugInfo ? lazyAltDebugInfo : debugInfo;
291
- return pillText ? (react_1.default.createElement(react_native_1.View, { style: [
292
- styles.debugPill,
293
- { top: topInset + (altImageUri && altSize ? 76 : 8) },
294
- ], pointerEvents: "none", accessibilityRole: "text" },
295
- react_1.default.createElement(react_native_1.Text, { style: styles.debugPillText }, pillText))) : null;
296
- })(),
240
+ debugInfo ? (react_1.default.createElement(react_native_1.View, { style: [styles.debugPill, { top: topInset + 8 }], pointerEvents: "none", accessibilityRole: "text" },
241
+ react_1.default.createElement(react_native_1.Text, { style: styles.debugPillText }, debugInfo))) : null,
297
242
  warnings && warnings.length > 0 && (react_1.default.createElement(react_native_1.View, { style: styles.warningBanner, accessibilityRole: "alert" }, warnings.map((w, i) => (react_1.default.createElement(react_native_1.Text, { key: `warn-${i}`, style: styles.warningText }, w))))),
298
- altOffered && (react_1.default.createElement(react_native_1.View, { style: styles.abBar },
299
- react_1.default.createElement(react_native_1.Text, { style: styles.abBarLabel }, altLoading
300
- ? 'Stitching high-level… (manual shown meanwhile)'
301
- : altFailed
302
- ? 'High-level stitch failed — showing manual'
303
- : 'Viewing the highlighted pipeline — tap to switch:'),
304
- react_1.default.createElement(react_native_1.View, { style: styles.abSegments },
305
- react_1.default.createElement(react_native_1.Pressable, { style: [styles.abSeg, !showAlt && styles.abSegActive], onPress: () => setShowingAlt(false), accessibilityRole: "button", accessibilityState: { selected: !showAlt }, accessibilityLabel: "View manual pipeline (default)" },
306
- react_1.default.createElement(react_native_1.Text, { style: [styles.abSegText, !showAlt && styles.abSegTextActive] }, "Manual")),
307
- react_1.default.createElement(react_native_1.Pressable, { style: [styles.abSeg, showAlt && styles.abSegActive], onPress: showHighLevel, accessibilityRole: "button", accessibilityState: { selected: showAlt, busy: altLoading }, accessibilityLabel: "View high-level pipeline (computed on demand)" }, altLoading ? (react_1.default.createElement(react_native_1.ActivityIndicator, { size: "small", color: "#fff" })) : (react_1.default.createElement(react_native_1.Text, { style: [styles.abSegText, showAlt && styles.abSegTextActive] }, "High-level")))))),
308
243
  react_1.default.createElement(react_native_1.View, { style: styles.canvas, onLayout: onLayout },
309
- imageBox && (react_1.default.createElement(react_native_1.Image, { source: { uri: activeUri }, style: [styles.image, imageBox], resizeMode: "stretch" })),
310
- showCropControls && !showAlt && (react_1.default.createElement(react_1.default.Fragment, null,
244
+ imageBox && (react_1.default.createElement(react_native_1.Image, { source: { uri: imageUri }, style: [styles.image, imageBox], resizeMode: "stretch" })),
245
+ showCropControls && (react_1.default.createElement(react_1.default.Fragment, null,
311
246
  edges.map((e, i) => (react_1.default.createElement(react_native_1.View, { key: `edge-${i}`, style: [styles.edge, e], pointerEvents: "none" }))),
312
247
  screenCorners
313
248
  && screenCorners.map((c, i) => (react_1.default.createElement(react_native_1.View, { key: `handle-${i}`, ...responders[i].panHandlers, hitSlop: {
@@ -327,26 +262,18 @@ function RectCropPreview(props) {
327
262
  react_1.default.createElement(react_native_1.View, { style: styles.buttons },
328
263
  react_1.default.createElement(react_native_1.Pressable, { style: ({ pressed }) => [styles.btn, pressed && styles.btnPressed], onPress: onRetake, accessibilityRole: "button", accessibilityLabel: resolvedCopy.cropRetake },
329
264
  react_1.default.createElement(react_native_1.Text, { style: styles.btnText }, resolvedCopy.cropRetake)),
330
- showCropControls && !showAlt && (react_1.default.createElement(react_native_1.Pressable, { style: ({ pressed }) => [styles.btn, pressed && styles.btnPressed], onPress: () => onUseOriginal(), accessibilityRole: "button", accessibilityLabel: resolvedCopy.cropUseOriginal },
265
+ showCropControls && (react_1.default.createElement(react_native_1.Pressable, { style: ({ pressed }) => [styles.btn, pressed && styles.btnPressed], onPress: () => onUseOriginal(), accessibilityRole: "button", accessibilityLabel: resolvedCopy.cropUseOriginal },
331
266
  react_1.default.createElement(react_native_1.Text, { style: styles.btnText }, resolvedCopy.cropUseOriginal))),
332
267
  react_1.default.createElement(react_native_1.Pressable, { style: ({ pressed }) => [
333
268
  styles.btn,
334
269
  styles.primary,
335
270
  pressed && styles.btnPressed,
336
- ], onPress: showAlt
337
- ? () => onUseOriginal(activeUri)
338
- : showCropControls
339
- ? handleConfirm
340
- : () => onUseOriginal(), accessibilityRole: "button", accessibilityLabel: showAlt
341
- ? 'Use this output'
342
- : showCropControls
343
- ? resolvedCopy.cropConfirm
344
- : resolvedCopy.previewConfirm },
345
- react_1.default.createElement(react_native_1.Text, { style: styles.btnText }, showAlt
346
- ? 'Use this'
347
- : showCropControls
348
- ? resolvedCopy.cropConfirm
349
- : resolvedCopy.previewConfirm)))))));
271
+ ], onPress: showCropControls ? handleConfirm : () => onUseOriginal(), accessibilityRole: "button", accessibilityLabel: showCropControls
272
+ ? resolvedCopy.cropConfirm
273
+ : resolvedCopy.previewConfirm },
274
+ react_1.default.createElement(react_native_1.Text, { style: styles.btnText }, showCropControls
275
+ ? resolvedCopy.cropConfirm
276
+ : resolvedCopy.previewConfirm)))))));
350
277
  }
351
278
  /**
352
279
  * Absolute-positioned style for a 1-px-thick edge line between two
@@ -404,43 +331,6 @@ const styles = react_native_1.StyleSheet.create({
404
331
  fontSize: 13,
405
332
  fontWeight: '600',
406
333
  },
407
- abBar: {
408
- backgroundColor: '#1a1a1a',
409
- paddingVertical: 10,
410
- paddingHorizontal: 12,
411
- borderBottomWidth: react_native_1.StyleSheet.hairlineWidth,
412
- borderBottomColor: '#333',
413
- },
414
- abBarLabel: {
415
- color: '#aaa',
416
- fontSize: 11,
417
- fontWeight: '600',
418
- textAlign: 'center',
419
- marginBottom: 8,
420
- },
421
- abSegments: {
422
- flexDirection: 'row',
423
- alignSelf: 'center',
424
- backgroundColor: '#000',
425
- borderRadius: 9,
426
- padding: 3,
427
- },
428
- abSeg: {
429
- paddingVertical: 7,
430
- paddingHorizontal: 22,
431
- borderRadius: 7,
432
- },
433
- abSegActive: {
434
- backgroundColor: '#0A84FF',
435
- },
436
- abSegText: {
437
- color: '#9aa',
438
- fontSize: 14,
439
- fontWeight: '700',
440
- },
441
- abSegTextActive: {
442
- color: '#fff',
443
- },
444
334
  canvas: { flex: 1 },
445
335
  image: { position: 'absolute' },
446
336
  edge: {
@@ -639,6 +639,23 @@ export interface IncrementalFinalizeResult {
639
639
  * on the just-completed capture.
640
640
  */
641
641
  stitchModeResolved?: 'panorama' | 'scans';
642
+ /**
643
+ * 2026-06-15 (DEV) — gyro rotation magnitude of the capture, in RADIANS
644
+ * (angle between the first and last accepted keyframe camera-forward vectors).
645
+ * Surfaced so a dev tool can display it and tune the panorama-vs-SCANS
646
+ * rotation threshold from real captures. `0` when there is no pose-derived
647
+ * rotation signal (non-AR with no poses) — not necessarily "no rotation".
648
+ */
649
+ rRadians?: number;
650
+ /**
651
+ * 2026-06-16 (DEV) — translation magnitude (metres) and the auto decision
652
+ * ratio (`tScore/(tScore+rScore)`, `>=0.55` → SCANS) that drove the
653
+ * panorama-vs-SCANS choice. Surfaced alongside `rRadians` so a dev tool can
654
+ * display the full decision inputs and tune the threshold from real captures.
655
+ * `0` when there is no motion signal (non-AR with no poses / no movement).
656
+ */
657
+ tMeters?: number;
658
+ decisionRatio?: number;
642
659
  /**
643
660
  * 2026-06-14 (DEV overlay) — a semicolon-separated `key=value` trace of the
644
661
  * stitcher's RUNTIME choices for this output, e.g.
@@ -844,6 +861,14 @@ interface NativeIncrementalModule {
844
861
  * are zero, matching legacy behaviour.
845
862
  */
846
863
  imuTranslationMetres?: number;
864
+ /**
865
+ * 2026-06-16 — the explicit lens the user selected (`'1x'` | `'0.5x'`).
866
+ * The reliable zoom signal for the high-level warper tree: `'0.5x'`
867
+ * (ultra-wide) → spherical warper. Replaces deriving zoom from the
868
+ * intrinsics FOV (unreliable on multi-cam 0.5x / non-AR fx=0). Omitted →
869
+ * treated as `'1x'`.
870
+ */
871
+ lens?: string;
847
872
  }): Promise<IncrementalFinalizeResult>;
848
873
  cancel(): Promise<{
849
874
  ok: true;
@@ -875,6 +900,10 @@ interface NativeIncrementalModule {
875
900
  * one-true-number for "how close are we to OOM?". Returns -1
876
901
  * on task_info failure (very rare). Resolves immediately. */
877
902
  getMemoryFootprintMB(): Promise<number>;
903
+ /** 2026-06-16 — total physical RAM in MB. Lets the DEV memory pill derive
904
+ * RAM-aware pressure bands instead of iPhone-fixed thresholds. -1 on
905
+ * failure. Resolves immediately. */
906
+ getDeviceTotalRamMB?(): Promise<number>;
878
907
  /**
879
908
  * 2026-05-16 — realtime+batch fusion API foundation. Run the
880
909
  * shared C++ `cv::Stitcher` pipeline over a caller-supplied list
@@ -61,7 +61,13 @@ export interface UseIncrementalStitcherReturn {
61
61
  * (e.g. in AR mode the native side has its own pose-driven
62
62
  * translation magnitude and prefers that).
63
63
  */
64
- imuTranslationMetres?: number) => Promise<IncrementalFinalizeResult>;
64
+ imuTranslationMetres?: number,
65
+ /**
66
+ * 2026-06-16 — the EXPLICIT lens the user selected (`'1x'` | `'0.5x'`).
67
+ * The reliable zoom signal for the high-level warper tree (`'0.5x'`
68
+ * ultra-wide → spherical). Omit ⇒ treated as `'1x'`.
69
+ */
70
+ lens?: string) => Promise<IncrementalFinalizeResult>;
65
71
  /** Abort the capture without producing output. */
66
72
  cancel: () => Promise<void>;
67
73
  }
@@ -110,7 +110,7 @@ function useIncrementalStitcher() {
110
110
  setState(null);
111
111
  lastHintRef.current = null;
112
112
  }, [native]);
113
- const finalize = (0, react_1.useCallback)(async (outputPath, quality = 90, captureOrientation, imuTranslationMetres) => {
113
+ const finalize = (0, react_1.useCallback)(async (outputPath, quality = 90, captureOrientation, imuTranslationMetres, lens) => {
114
114
  if (!native) {
115
115
  throw new Error('useIncrementalStitcher: native module unavailable');
116
116
  }
@@ -128,6 +128,12 @@ function useIncrementalStitcher() {
128
128
  // doesn't carry tx/ty/tz, so pose-derived translation is 0).
129
129
  // Native side treats it as a magnitude (always ≥ 0).
130
130
  imuTranslationMetres: Math.max(0, imuTranslationMetres ?? 0),
131
+ // 2026-06-16 — the EXPLICIT lens the user selected ('1x' | '0.5x').
132
+ // This is the reliable zoom signal for the high-level warper tree
133
+ // (0.5x ultra-wide → spherical); deriving zoom from intrinsics FOV was
134
+ // unreliable (multi-cam 0.5x reaches the ultra-wide by zoom without
135
+ // changing the reported fx, and the non-AR path may supply fx=0).
136
+ lens,
131
137
  });
132
138
  setIsRunning(false);
133
139
  // Clear React state on finalize so the next start doesn't
@@ -223,6 +223,15 @@ struct FinalizePayload {
223
223
  /// resolved upstream by `resolveStitchModeAuto` before this snapshot
224
224
  /// is captured; this field never carries 'auto'.
225
225
  let batchStitchModeResolved: String
226
+ /// Gyro rotation magnitude (radians) of the capture — surfaced to JS for the
227
+ /// dev 3-tab preview's rRadians readout (threshold tuning). 0.0 when there
228
+ /// is no pose-derived rotation signal (non-AR with no poses).
229
+ let rRadians: Double
230
+ /// Translation magnitude (metres) + the auto decision ratio
231
+ /// (tScore/(tScore+rScore), >=0.55 → SCANS) that drove the panorama-vs-SCANS
232
+ /// choice — surfaced to JS for the dev tuning readout alongside rRadians.
233
+ let tMeters: Double
234
+ let decisionRatio: Double
226
235
  let keyframeExifOrientation: Int
227
236
  /// AR-STITCHING-TWO-MODES (memory/ar-stitching-two-modes.md):
228
237
  /// capture-time hold orientation for the bake-rotation pass.
@@ -430,6 +439,10 @@ public final class IncrementalStitcher: NSObject {
430
439
  /// AR mode (where pose-derived tx/ty/tz is always 0). Set to 0
431
440
  /// at start() and overwritten at finalize() entry.
432
441
  private var batchImuTranslationMetres: Double = 0.0
442
+ /// 2026-06-16 — the explicit lens the user selected ('1x' | '0.5x'), set at
443
+ /// finalize() entry from JS. The zoom signal for the high-level warper tree
444
+ /// (0.5x ultra-wide → spherical). Defaults to '1x'.
445
+ private var batchLens: String = "1x"
433
446
  /// AR-STITCHING-TWO-MODES — see memory/ar-stitching-two-modes.md
434
447
  ///
435
448
  /// Physical phone orientation at start() time, sourced from the
@@ -492,6 +505,14 @@ public final class IncrementalStitcher: NSObject {
492
505
  stateLock.unlock()
493
506
  }
494
507
 
508
+ /// 2026-06-16 — store the explicit lens ('1x' | '0.5x') JS supplies at
509
+ /// finalize() entry; the high-level warper tree reads it (0.5x → spherical).
510
+ @objc public func updateLens(_ lens: String) {
511
+ stateLock.lock()
512
+ self.batchLens = lens
513
+ stateLock.unlock()
514
+ }
515
+
495
516
  /// 2026-05-18 (Iss 3) — return the current capture's keyframe
496
517
  /// session directory, or nil if no capture is in flight / engine
497
518
  /// isn't using a per-session keyframe collector.
@@ -829,6 +850,7 @@ public final class IncrementalStitcher: NSObject {
829
850
  // too. Updated at finalize() entry from JS-supplied
830
851
  // option value.
831
852
  self.batchImuTranslationMetres = 0.0
853
+ self.batchLens = "1x" // overwritten at finalize() from JS (updateLens)
832
854
  self.batchKeyframeMode = true
833
855
  os_log(.fault, log: Self.diagLog,
834
856
  "[V16-batch-keyframe] start mode=batch-keyframe rotation=0 (was %d, forced to 0 to match pose intrinsics) sessionDir=%{public}@",
@@ -1147,20 +1169,33 @@ public final class IncrementalStitcher: NSObject {
1147
1169
  // translation/rotation magnitude ratio between first + last
1148
1170
  // accepted keyframe poses → SCANS (translation-heavy) or
1149
1171
  // PANORAMA (rotation-heavy). Non-auto values pass through.
1172
+ // Resolve once so the dev readout gets the SAME tMeters / ratio / rRadians
1173
+ // that drove the decision — and gets them even when the mode is forced
1174
+ // (informative: shows what auto WOULD have picked). Captured into the
1175
+ // payload here so the C2-invariant finalize closure can read them via
1176
+ // payload (no self/ivar access inside the closure).
1177
+ let autoResolution = resolveStitchModeAuto(
1178
+ first: batchFirstAcceptedPose,
1179
+ last: batchLastAcceptedPose,
1180
+ imuTranslationMetres: batchImuTranslationMetres)
1150
1181
  let stitchModeResolved: String
1151
1182
  switch batchStitchMode {
1152
1183
  case "panorama": stitchModeResolved = "panorama"
1153
1184
  case "scans": stitchModeResolved = "scans"
1154
- default: stitchModeResolved = resolveStitchModeAuto(
1155
- first: batchFirstAcceptedPose,
1156
- last: batchLastAcceptedPose,
1157
- imuTranslationMetres: batchImuTranslationMetres
1158
- )
1185
+ default: stitchModeResolved = autoResolution.mode
1159
1186
  }
1187
+ let rRadiansResolved = autoResolution.rRadians
1188
+ // 2026-06-16 — HIGH-LEVEL ACROSS THE BOARD (mirrors Android). Pick the
1189
+ // warper from the (motion, Mode A/B, lens) tree; the dispatch below now
1190
+ // forces useManualPipeline=false + stitchMode="panorama". batchWarperType
1191
+ // (settings) is superseded by the tree.
1192
+ let highLevelWarper = pickHighLevelWarper(
1193
+ orientation: captureOrientation,
1194
+ lens: batchLens)
1160
1195
  os_log(.fault, log: Self.diagLog,
1161
- "[V16-batch-keyframe.stitchMode] configured=%{public}@ resolved=%{public}@ paths=%d imuT=%.3fm",
1162
- batchStitchMode, stitchModeResolved, Int32(paths.count),
1163
- batchImuTranslationMetres)
1196
+ "[V16-batch-keyframe.stitchMode] configured=%{public}@ resolved=%{public}@ warper=%{public}@ lens=%{public}@ paths=%d imuT=%.3fm",
1197
+ batchStitchMode, stitchModeResolved, highLevelWarper, batchLens,
1198
+ Int32(paths.count), batchImuTranslationMetres)
1164
1199
 
1165
1200
  let payload = FinalizePayload(
1166
1201
  cleaned: cleaned,
@@ -1168,11 +1203,14 @@ public final class IncrementalStitcher: NSObject {
1168
1203
  inBatchKeyframeMode: inBatchKeyframeMode,
1169
1204
  collector: collector,
1170
1205
  paths: paths,
1171
- batchWarperType: batchWarperType,
1206
+ batchWarperType: highLevelWarper,
1172
1207
  batchBlenderType: batchBlenderType,
1173
1208
  batchSeamFinderType: batchSeamFinderType,
1174
1209
  batchEnableInscribedRectCrop: batchEnableInscribedRectCrop,
1175
1210
  batchStitchModeResolved: stitchModeResolved,
1211
+ rRadians: rRadiansResolved,
1212
+ tMeters: autoResolution.tMeters,
1213
+ decisionRatio: autoResolution.ratio,
1176
1214
  keyframeExifOrientation: keyframeExifOrientation,
1177
1215
  captureOrientation: captureOrientation,
1178
1216
  drops: drops,
@@ -1438,10 +1476,15 @@ public final class IncrementalStitcher: NSObject {
1438
1476
  seamFinderType: payload.batchSeamFinderType,
1439
1477
  captureOrientation: payload.captureOrientation,
1440
1478
  useInscribedRectCrop: payload.batchEnableInscribedRectCrop,
1441
- stitchMode: payload.batchStitchModeResolved,
1442
- // Batch capture = the default output = MANUAL pipeline
1443
- // (graphcut + multiband + the full memory-guard set).
1444
- useManualPipeline: true
1479
+ // 2026-06-16 — HIGH-LEVEL ACROSS THE BOARD (mirrors
1480
+ // Android): always cv::Stitcher PANORAMA with the
1481
+ // tree-chosen warper (payload.batchWarperType is now
1482
+ // highLevelWarper). The manual path's OOM hardening
1483
+ // was ported to high-level (catch ladder + two-phase
1484
+ // canvas guard + RAM-aware compositingResol + spherical
1485
+ // rescue), so this is now memory-safe.
1486
+ stitchMode: "panorama",
1487
+ useManualPipeline: false
1445
1488
  )
1446
1489
  // V16 fix-attempt 9 (verified on device,
1447
1490
  // 2026-05-13) — sentinel-result detection.
@@ -1537,6 +1580,11 @@ public final class IncrementalStitcher: NSObject {
1537
1580
  // helps the operator understand why the
1538
1581
  // panorama looks the way it does.
1539
1582
  batchDict["stitchModeResolved"] = payload.batchStitchModeResolved
1583
+ batchDict["rRadians"] = payload.rRadians
1584
+ // Dev tuning readout — translation magnitude + the auto
1585
+ // decision ratio that drove panorama-vs-SCANS.
1586
+ batchDict["tMeters"] = payload.tMeters
1587
+ batchDict["decisionRatio"] = payload.decisionRatio
1540
1588
  // 2026-06-14 (DEV overlay) — the stitcher's runtime
1541
1589
  // choices (pipeline/warper/route/seam/blend) for this
1542
1590
  // output, shown on the preview in __DEV__.
@@ -2449,13 +2497,13 @@ public final class IncrementalStitcher: NSObject {
2449
2497
  first: [Double]?,
2450
2498
  last: [Double]?,
2451
2499
  imuTranslationMetres: Double
2452
- ) -> String {
2500
+ ) -> (mode: String, rRadians: Double, tMeters: Double, ratio: Double) {
2453
2501
  guard let firstPose = first, firstPose.count == 7,
2454
2502
  let lastPose = last, lastPose.count == 7 else {
2455
2503
  // No pose data at all — fall back on whichever signal we
2456
2504
  // do have. imuTranslationMetres > 0 hints "scans"; 0
2457
- // hints "panorama".
2458
- return imuTranslationMetres > 0.05 ? "scans" : "panorama"
2505
+ // hints "panorama". rRadians 0.0 — no gyro signal.
2506
+ return (imuTranslationMetres > 0.05 ? "scans" : "panorama", 0.0, 0.0, 0.0)
2459
2507
  }
2460
2508
  // Translation magnitude (Euclidean, in metres).
2461
2509
  let dtx = lastPose[0] - firstPose[0]
@@ -2467,14 +2515,7 @@ public final class IncrementalStitcher: NSObject {
2467
2515
  // the only signal we have.
2468
2516
  let tMeters = max(tPose, imuTranslationMetres)
2469
2517
  // Rotation magnitude — angle between camera-forward vectors.
2470
- // Camera-forward in body frame is (0, 0, -1) for ARKit/ARCore.
2471
- let fwdFirst = qrotForwardZneg(
2472
- firstPose[3], firstPose[4], firstPose[5], firstPose[6])
2473
- let fwdLast = qrotForwardZneg(
2474
- lastPose[3], lastPose[4], lastPose[5], lastPose[6])
2475
- let dot = max(-1.0, min(1.0,
2476
- fwdFirst.0 * fwdLast.0 + fwdFirst.1 * fwdLast.1 + fwdFirst.2 * fwdLast.2))
2477
- let rRadians = acos(dot)
2518
+ let rRadians = rotationRadians(first: firstPose, last: lastPose)
2478
2519
  // Normalisation: 10 cm of translation ≈ 1 rad of rotation as
2479
2520
  // "equivalent magnitude" for the ratio. Shelf scans cover
2480
2521
  // ~30 cm translation with ~10° (0.17 rad) rotation:
@@ -2484,7 +2525,7 @@ public final class IncrementalStitcher: NSObject {
2484
2525
  let tScore = tMeters / 0.10
2485
2526
  let rScore = rRadians / 1.00
2486
2527
  let denom = tScore + rScore
2487
- if denom <= 1e-9 { return "panorama" } // no motion either way
2528
+ if denom <= 1e-9 { return ("panorama", rRadians, tMeters, 0.0) } // no motion either way
2488
2529
  let ratio = tScore / denom
2489
2530
 
2490
2531
  // 2026-06-15 — LOW-ROTATION GUARD. The gyro rotation (rRadians) is
@@ -2502,7 +2543,43 @@ public final class IncrementalStitcher: NSObject {
2502
2543
  "[stitchMode.auto] tPose=%.3fm tImu=%.3fm r=%.3frad ratio=%.3f rotGuard=%d → %{public}@",
2503
2544
  tPose, imuTranslationMetres, rRadians, ratio,
2504
2545
  lowRotationGuard ? 1 : 0, mode)
2505
- return mode
2546
+ return (mode, rRadians, tMeters, ratio)
2547
+ }
2548
+
2549
+ /// 2026-06-16 — high-level warper decision tree (mirrors Android's
2550
+ /// pickHighLevelWarper). The pipeline is now ALWAYS high-level cv::Stitcher
2551
+ /// PANORAMA. Warper is a pure function of (lens, pan direction); the
2552
+ /// rotation-vs-translation (ex-SCANS) distinction was DROPPED as redundant —
2553
+ /// at 1x the same direction-based warpers serve both, and 0.5x is always
2554
+ /// spherical. orientation = capture hold ("landscape*" = Mode A vertical
2555
+ /// pan; else Mode B horizontal); lens = the EXPLICIT lens ("0.5x" | "1x").
2556
+ ///
2557
+ /// 0.5x ultra-wide → spherical (bounded both axes; any pan)
2558
+ /// 1x + Mode A (vertical) → plane
2559
+ /// 1x + Mode B (horizontal) → cylindrical
2560
+ ///
2561
+ /// Quality-preferred warper; the C++ memory ladder force-falls to spherical
2562
+ /// (and downscales compositingResol) under pressure.
2563
+ private func pickHighLevelWarper(
2564
+ orientation: String,
2565
+ lens: String
2566
+ ) -> String {
2567
+ if lens == "0.5x" { return "spherical" } // ultra-wide → always spherical
2568
+ let verticalPanModeA = orientation.hasPrefix("landscape")
2569
+ return verticalPanModeA ? "plane" : "cylindrical" // 1x: A→plane, B→cylindrical
2570
+ }
2571
+
2572
+ /// Gyro rotation magnitude (radians) between two 7-element poses
2573
+ /// `[tx,ty,tz,qx,qy,qz,qw]` — angle between camera-forward vectors.
2574
+ /// Returns 0.0 if either pose is missing/malformed (non-AR, no pose).
2575
+ /// Shared by `resolveStitchModeAuto` + the finalize `rRadians` readout (DRY).
2576
+ private func rotationRadians(first: [Double]?, last: [Double]?) -> Double {
2577
+ guard let f = first, f.count == 7, let l = last, l.count == 7 else { return 0.0 }
2578
+ let fwdFirst = qrotForwardZneg(f[3], f[4], f[5], f[6])
2579
+ let fwdLast = qrotForwardZneg(l[3], l[4], l[5], l[6])
2580
+ let dot = max(-1.0, min(1.0,
2581
+ fwdFirst.0 * fwdLast.0 + fwdFirst.1 * fwdLast.1 + fwdFirst.2 * fwdLast.2))
2582
+ return acos(dot)
2506
2583
  }
2507
2584
 
2508
2585
  /// Closed-form q · (0,0,-1) · q⁻¹ — rotates the camera-forward
@@ -56,6 +56,10 @@ RCT_EXTERN_METHOD(markNextFrameAsLastKeyframe:(RCTPromiseResolveBlock)resolver
56
56
  RCT_EXTERN_METHOD(getMemoryFootprintMB:(RCTPromiseResolveBlock)resolver
57
57
  rejecter:(RCTPromiseRejectBlock)rejecter)
58
58
 
59
+ // 2026-06-16 — total physical RAM (MB) for the pill's RAM-aware pressure bands.
60
+ RCT_EXTERN_METHOD(getDeviceTotalRamMB:(RCTPromiseResolveBlock)resolver
61
+ rejecter:(RCTPromiseRejectBlock)rejecter)
62
+
59
63
  // 2026-05-16 — realtime+batch fusion (Option A "Replace on completion").
60
64
  // Run the shared C++ stitcher over a caller-supplied list of keyframe
61
65
  // JPEG paths and write a refined panorama to `outputPath`. See JS
@@ -217,6 +217,10 @@ public final class IncrementalStitcherBridge: RCTEventEmitter {
217
217
  // and to PANORAMA when both are 0).
218
218
  let imuT = (options["imuTranslationMetres"] as? Double) ?? 0.0
219
219
  IncrementalStitcher.shared.updateImuTranslationMetres(imuT)
220
+ // 2026-06-16 — the EXPLICIT lens the user selected ('1x'|'0.5x'): the
221
+ // reliable zoom signal for the high-level warper tree (0.5x → spherical).
222
+ let lens = (options["lens"] as? String) ?? "1x"
223
+ IncrementalStitcher.shared.updateLens(lens)
220
224
  IncrementalStitcher.shared.finalize(
221
225
  toPath: outputPath,
222
226
  jpegQuality: quality
@@ -325,6 +329,17 @@ public final class IncrementalStitcherBridge: RCTEventEmitter {
325
329
  resolver(mb)
326
330
  }
327
331
 
332
+ /// Total physical RAM in MB. Lets the DEV memory pill derive RAM-aware
333
+ /// pressure bands (iOS jetsam scales with device RAM) instead of fixed
334
+ /// thresholds. NSProcessInfo.physicalMemory is exact + cheap.
335
+ @objc(getDeviceTotalRamMB:rejecter:)
336
+ public func getDeviceTotalRamMB(
337
+ resolver: @escaping RCTPromiseResolveBlock,
338
+ rejecter: @escaping RCTPromiseRejectBlock
339
+ ) {
340
+ resolver(Double(ProcessInfo.processInfo.physicalMemory) / (1024.0 * 1024.0))
341
+ }
342
+
328
343
  /// 2026-05-16 — realtime+batch fusion (Option A) bridge. Marshal
329
344
  /// the options dictionary into the engine layer, dispatch the
330
345
  /// refinement off the bridge thread so the JS Promise doesn't block
@@ -509,6 +509,17 @@ cv::detail::CameraParams cameraParamsFromPose(NSDictionary *pose) {
509
509
  // output); the on-demand high-level tab re-stitches with NO.
510
510
  cfg.useManualPipeline = useManualPipeline;
511
511
 
512
+ // 2026-06-16 — iOS resident-memory probe. iOS has no /proc/self/statm, so the
513
+ // shared rss_mb() returned -1 — which (a) blinded the per-stitch profiling and
514
+ // (b) silently DISABLED the runtime-pressure half of the manual pipeline's OOM
515
+ // router (the lowBatchHeadroom STREAM trigger), on the very platform (jetsam)
516
+ // it protects. Plug task_info(TASK_VM_INFO).phys_footprint (the metric jetsam
517
+ // evaluates) as the probe. Set UNCONDITIONALLY — the OOM guards must work in
518
+ // release too; only the sampler + per-stitch record are gated by the compile
519
+ // flag (debug-on, release-off).
520
+ cfg.memProbeFn = []() -> double { return StitcherResidentMB(); };
521
+ cfg.enableMemoryProfiling = (RNIS_MEMORY_PROFILING != 0);
522
+
512
523
  // Marshal NSArray<NSString*> → std::vector<std::string>. Strip the
513
524
  // `file://` scheme that some callers attach so the shared C++ can
514
525
  // cv::imread the raw filesystem path.
@@ -590,8 +601,17 @@ cv::detail::CameraParams cameraParamsFromPose(NSDictionary *pose) {
590
601
  framesIncluded:(NSInteger)r.framesIncluded
591
602
  finalConfidenceThresh:r.finalConfidenceThresh];
592
603
  if (!r.debugSummary.empty()) {
604
+ std::string dbg = r.debugSummary;
605
+ // iOS has no mallopt purge; the post-stitch settle read IS the leak
606
+ // floor (memFloor). Append it so it rides debugSummary to JS like
607
+ // Android's post-purge value (gated; debug-only).
608
+ if (RNIS_MEMORY_PROFILING != 0) {
609
+ char fbuf[40];
610
+ snprintf(fbuf, sizeof(fbuf), ";memFloor=%.1f", StitcherResidentMB());
611
+ dbg += fbuf;
612
+ }
593
613
  result.debugSummary =
594
- [NSString stringWithUTF8String:r.debugSummary.c_str()];
614
+ [NSString stringWithUTF8String:dbg.c_str()];
595
615
  }
596
616
  // 2026-06-15 — the eager A/B harness that ALSO stitched the high-level
597
617
  // alt on EVERY capture has been REMOVED. Manual is now the default (this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
4
4
  "description": "Pose-aware panorama capture + stitching for React Native. One <Camera> component, both tap-to-photo and hold-to-pan modes, both AR-backed and IMU-fallback capture paths.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",