react-native-image-stitcher 0.15.2 → 0.16.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.
Files changed (146) hide show
  1. package/CHANGELOG.md +171 -1
  2. package/README.md +131 -5
  3. package/android/src/main/cpp/image_stitcher_jni.cpp +154 -13
  4. package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
  5. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
  6. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +220 -87
  7. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
  8. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
  9. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
  10. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
  11. package/cpp/crop_quad.cpp +162 -0
  12. package/cpp/crop_quad.hpp +163 -0
  13. package/cpp/keyframe_gate.cpp +54 -15
  14. package/cpp/keyframe_gate.hpp +33 -0
  15. package/cpp/stitcher.cpp +1122 -132
  16. package/cpp/stitcher.hpp +62 -0
  17. package/cpp/warp_guard.hpp +212 -0
  18. package/dist/camera/Camera.d.ts +209 -12
  19. package/dist/camera/Camera.js +575 -36
  20. package/dist/camera/CameraView.js +35 -16
  21. package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
  22. package/dist/camera/CaptureCountdownOverlay.js +239 -0
  23. package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
  24. package/dist/camera/CaptureFrameCounterOverlay.js +153 -0
  25. package/dist/camera/CaptureMemoryPill.d.ts +24 -8
  26. package/dist/camera/CaptureMemoryPill.js +37 -12
  27. package/dist/camera/CapturePreview.js +2 -1
  28. package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
  29. package/dist/camera/CaptureStatusOverlay.js +22 -5
  30. package/dist/camera/CaptureThumbnailStrip.js +2 -1
  31. package/dist/camera/LateralMotionModal.d.ts +85 -0
  32. package/dist/camera/LateralMotionModal.js +134 -0
  33. package/dist/camera/PanHowToOverlay.d.ts +76 -0
  34. package/dist/camera/PanHowToOverlay.js +222 -0
  35. package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
  36. package/dist/camera/PanoramaBandOverlay.js +9 -3
  37. package/dist/camera/PanoramaSettings.d.ts +8 -6
  38. package/dist/camera/PanoramaSettings.js +19 -1
  39. package/dist/camera/PanoramaSettingsModal.js +4 -4
  40. package/dist/camera/RectCropPreview.d.ts +135 -0
  41. package/dist/camera/RectCropPreview.js +370 -0
  42. package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
  43. package/dist/camera/RotateToLandscapePrompt.js +138 -0
  44. package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
  45. package/dist/camera/buildPanoramaInitialSettings.js +9 -0
  46. package/dist/camera/cameraErrorMessages.d.ts +30 -1
  47. package/dist/camera/cameraErrorMessages.js +26 -10
  48. package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
  49. package/dist/camera/cameraGuidanceCopy.js +80 -0
  50. package/dist/camera/captureCountdown.d.ts +52 -0
  51. package/dist/camera/captureCountdown.js +76 -0
  52. package/dist/camera/captureWarnings.d.ts +90 -0
  53. package/dist/camera/captureWarnings.js +108 -0
  54. package/dist/camera/classifyStitchError.d.ts +30 -0
  55. package/dist/camera/classifyStitchError.js +42 -0
  56. package/dist/camera/cropGeometry.d.ts +136 -0
  57. package/dist/camera/cropGeometry.js +223 -0
  58. package/dist/camera/displayDecodeImageProps.d.ts +25 -0
  59. package/dist/camera/displayDecodeImageProps.js +29 -0
  60. package/dist/camera/guidanceGraphics.d.ts +58 -0
  61. package/dist/camera/guidanceGraphics.js +280 -0
  62. package/dist/camera/guidanceTokens.d.ts +54 -0
  63. package/dist/camera/guidanceTokens.js +58 -0
  64. package/dist/camera/panModeGate.d.ts +54 -0
  65. package/dist/camera/panModeGate.js +62 -0
  66. package/dist/camera/pickCaptureFormat.d.ts +71 -0
  67. package/dist/camera/pickCaptureFormat.js +85 -0
  68. package/dist/camera/stitchDebugInfo.d.ts +27 -0
  69. package/dist/camera/stitchDebugInfo.js +55 -0
  70. package/dist/camera/usePanMotion.d.ts +250 -0
  71. package/dist/camera/usePanMotion.js +451 -0
  72. package/dist/index.d.ts +24 -3
  73. package/dist/index.js +33 -2
  74. package/dist/stitching/computeInscribedRect.d.ts +40 -0
  75. package/dist/stitching/computeInscribedRect.js +55 -0
  76. package/dist/stitching/cropQuad.d.ts +78 -0
  77. package/dist/stitching/cropQuad.js +116 -0
  78. package/dist/stitching/incremental.d.ts +74 -0
  79. package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
  80. package/dist/stitching/useIncrementalStitcher.js +7 -1
  81. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +154 -29
  82. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
  83. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
  84. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
  85. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
  86. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
  87. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +211 -7
  88. package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
  89. package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
  90. package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
  91. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
  92. package/package.json +5 -1
  93. package/src/camera/Camera.tsx +945 -47
  94. package/src/camera/CameraView.tsx +48 -16
  95. package/src/camera/CaptureCountdownOverlay.tsx +272 -0
  96. package/src/camera/CaptureFrameCounterOverlay.tsx +197 -0
  97. package/src/camera/CaptureMemoryPill.tsx +50 -12
  98. package/src/camera/CapturePreview.tsx +5 -0
  99. package/src/camera/CaptureStatusOverlay.tsx +35 -7
  100. package/src/camera/CaptureThumbnailStrip.tsx +4 -0
  101. package/src/camera/LateralMotionModal.tsx +199 -0
  102. package/src/camera/PanHowToOverlay.tsx +246 -0
  103. package/src/camera/PanoramaBandOverlay.tsx +9 -1
  104. package/src/camera/PanoramaSettings.ts +27 -7
  105. package/src/camera/PanoramaSettingsModal.tsx +4 -4
  106. package/src/camera/RectCropPreview.tsx +638 -0
  107. package/src/camera/RotateToLandscapePrompt.tsx +188 -0
  108. package/src/camera/buildPanoramaInitialSettings.ts +30 -1
  109. package/src/camera/cameraErrorMessages.ts +39 -2
  110. package/src/camera/cameraGuidanceCopy.ts +145 -0
  111. package/src/camera/captureCountdown.ts +83 -0
  112. package/src/camera/captureWarnings.ts +190 -0
  113. package/src/camera/classifyStitchError.ts +68 -0
  114. package/src/camera/cropGeometry.ts +268 -0
  115. package/src/camera/displayDecodeImageProps.ts +25 -0
  116. package/src/camera/guidanceGraphics.tsx +347 -0
  117. package/src/camera/guidanceTokens.ts +57 -0
  118. package/src/camera/panModeGate.ts +81 -0
  119. package/src/camera/pickCaptureFormat.ts +130 -0
  120. package/src/camera/stitchDebugInfo.ts +71 -0
  121. package/src/camera/usePanMotion.ts +667 -0
  122. package/src/index.ts +66 -3
  123. package/src/stitching/computeInscribedRect.ts +81 -0
  124. package/src/stitching/cropQuad.ts +167 -0
  125. package/src/stitching/incremental.ts +74 -0
  126. package/src/stitching/useIncrementalStitcher.ts +13 -0
  127. package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +0 -100
  128. package/cpp/tests/CMakeLists.txt +0 -104
  129. package/cpp/tests/README.md +0 -86
  130. package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
  131. package/cpp/tests/pose_test.cpp +0 -74
  132. package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
  133. package/cpp/tests/stubs/jsi/jsi.h +0 -33
  134. package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
  135. package/cpp/tests/warp_guard_test.cpp +0 -48
  136. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
  137. package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
  138. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
  139. package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
  140. package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
  141. package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
  142. package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
  143. package/src/camera/__tests__/useContentRotation.test.ts +0 -89
  144. package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
  145. package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
  146. package/src/stitching/__tests__/useStitcherWorklet.test.ts +0 -202
package/CHANGELOG.md CHANGED
@@ -14,7 +14,177 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
14
14
  > during 0.x are bumped to a new MINOR (e.g., 0.1 → 0.2), and the
15
15
  > upgrade path is documented in this CHANGELOG.
16
16
 
17
- ## [Unreleased]
17
+ ## [0.16.1] — 2026-06-16
18
+
19
+ ### Changed — high-level `cv::Stitcher` is now the default pipeline
20
+
21
+ The batch finalize now drives OpenCV's high-level `cv::Stitcher`
22
+ (PANORAMA) on both platforms instead of the hand-rolled `cv::detail`
23
+ ("manual") path. In testing it produced consistently better seams and
24
+ lower, more stable peak memory. This is a **behaviour change, not an
25
+ API change** — the public surface (`<Camera>`, the hooks, the finalize
26
+ options) is unchanged; only the stitched output and memory profile
27
+ differ.
28
+
29
+ The warper is chosen per-capture (pure function of the selected lens +
30
+ pan direction), always `PANORAMA`:
31
+
32
+ | Lens | Mode A (vertical pan) | Mode B (horizontal pan) |
33
+ | ----- | --------------------- | ----------------------- |
34
+ | 1× | plane | cylindrical |
35
+ | 0.5× | spherical | spherical |
36
+
37
+ The lens comes from the explicit `1x` / `0.5x` the user selected
38
+ (plumbed through the finalize options); the previous FOV-from-intrinsics
39
+ heuristic was unreliable on multi-camera devices and is gone, along with
40
+ the now-redundant rotation-vs-translation (ex-SCANS) branch.
41
+
42
+ ### Added — production memory hardening on the high-level path
43
+
44
+ The OOM guards that previously only covered the manual path were ported
45
+ across, so the new default is memory-safe under pressure:
46
+
47
+ - pre-stitch RSS headroom abort (also works on iOS now via the
48
+ `phys_footprint` probe, which revives the runtime-pressure router);
49
+ - RAM-aware compositing resolution;
50
+ - two-phase `estimateTransform` → project the warp canvas → abort if
51
+ degenerate, downscale or route to the bounded spherical warper if
52
+ over budget;
53
+ - a full C++ catch ladder + a JNI backstop so an allocation failure can
54
+ no longer cross the C-ABI and abort the process;
55
+ - a warper→spherical rescue (high-level) with the manual `PANORAMA` ↔
56
+ `SCANS` mode-fallback preserved for the iOS manual callers.
57
+
58
+ ### Fixed
59
+
60
+ - The native allocator is purged after each stitch, and on Android the
61
+ OpenCV worker pool is pinned to one thread, eliminating the per-stitch
62
+ RSS creep observed on the manual path.
63
+
64
+ ## [0.16.0] — 2026-06-15
65
+
66
+ ### Added — first-time-user panorama capture GUIDANCE
67
+
68
+ A set of opt-in-by-default guidance surfaces that coach the operator
69
+ through a non-AR hold-and-pan panorama. All seven are wired into
70
+ `<Camera>` automatically and read directly from new props (none are
71
+ threaded through `PanoramaSettings`):
72
+
73
+ 1. **Mode gate + 2. rotate-to-landscape prompt.** Starting a panorama
74
+ while the phone is held portrait under Mode A is blocked behind a
75
+ "Rotate to landscape" caption; the capture starts the instant the
76
+ user rotates to landscape (either way up). Releasing the shutter
77
+ before rotating cancels the pending start.
78
+ 3. **Pan how-to overlay.** A brief code-drawn looping graphic (phone +
79
+ sweeping band) + bouncing direction arrow (down for landscape Mode A,
80
+ right for portrait Mode B) shown for ~2.5 s at the start of each
81
+ recording.
82
+ 4. **"Moving too fast" pill.** A transient amber pill while the gyro
83
+ pan rate exceeds the warn threshold.
84
+ 5. **Blinking countdown + auto-finalize.** A blinking whole-seconds
85
+ countdown; at 0 the capture auto-finalizes (stitches what was
86
+ captured — same path as releasing the shutter).
87
+ 6. **Lateral-drift stop.** If the operator drifts sideways out of the
88
+ pan plane beyond the budget, the capture FINALIZES what was captured
89
+ and a one-button popup explains why.
90
+ 7. **Post-stitch review surface.** Optional. `rectCrop` shows a
91
+ draggable-quad crop editor (drag four corners; confirm perspective-
92
+ rectifies in place via `cv::warpPerspective` when the quad isn't
93
+ axis-aligned, "Use original" emits un-cropped, "Retake" discards).
94
+ `showPreview` shows the same screen with NO crop box — just the
95
+ stitched image with [Retake]/[Confirm]. With both off, `onCapture`
96
+ fires immediately.
97
+
98
+ New `<Camera>` props (all optional): `panMode`, `panGuidance`
99
+ (default `true`), `maxPanDurationMs` (default `9000`; `0` disables the
100
+ countdown + auto-finalize), `panTooFastThreshold`, `lateralBudgetCm`
101
+ (default `5`; `0` disables the lateral stop), `rectCrop`
102
+ (default `false`), `showPreview` (default `false`), and
103
+ `guidanceCopy` (partial override of every guidance string). A skewed
104
+ crop quad is always perspective-rectified (there is no opt-out flag).
105
+
106
+ New public exports: the `PanMode` type, `GuidanceCopy` +
107
+ `DEFAULT_GUIDANCE_COPY`, the `usePanMotion` hook, the five guidance
108
+ components (`RotateToLandscapePrompt`, `PanHowToOverlay`,
109
+ `CaptureCountdownOverlay`, `LateralMotionModal`, `RectCropPreview`) with
110
+ their prop types, and the `cropQuad` perspective-rectify helper.
111
+
112
+ ### Added — capture hardening
113
+
114
+ Follow-up hardening on top of the guidance set, driven by on-device
115
+ testing:
116
+
117
+ - **Guidance graphics are now code-drawn, not GIFs.** The rotate-to-
118
+ landscape and pan-capture animations are rendered with pure RN
119
+ `View` + `Animated` (`guidanceGraphics.tsx`) — resolution-independent
120
+ (no pixelation on high-density screens) and themeable via
121
+ `GUIDANCE_TOKENS`. Removes the bundled GIF assets AND the Android
122
+ host's previous need to add Fresco's `animated-gif` module.
123
+ - **Crop editor seeds from the max-inscribed rectangle.** With
124
+ `rectCrop`, the draggable quad now opens on the tightest clean
125
+ rectangle (native `computeInscribedRect`) instead of a blind 8 %
126
+ inset, and the editor gains an explicit **"Use original"** button
127
+ (emit the stitch un-cropped) plus a warning banner. When the editor is
128
+ on, the native auto-crop is forced off so the full bordered panorama
129
+ is available to drag.
130
+ - **`onCapture` carries `warnings`.** Both success and failure results
131
+ include `warnings: CaptureWarning[]` — `LOW_FRAME_UTILIZATION` (<70 %
132
+ of captured frames used) and `LATERAL_DRIFT_FINALIZE`. New exports:
133
+ `CaptureWarning`, `CaptureWarningCode`, `PanoramaCaptureResult`.
134
+ - **Post-stitch validation.** A disjoint / fragmented stitch (frames
135
+ that survived confidence but didn't fuse into one panorama) is now
136
+ rejected with the new `STITCH_LOW_QUALITY` error code + "try again"
137
+ copy, instead of emitting a broken image.
138
+ - **Quality-driven warper.** Wide pans switch from plane to the bounded
139
+ cylindrical projection based on the estimated sweep angle (not only on
140
+ an OOM-divergence fallback), reducing end-of-pan perspective stretch.
141
+ - **Headroom-based memory gating.** The flat process-RSS pre-stitch
142
+ abort is replaced by a per-process headroom model: under memory
143
+ pressure the pipeline routes to the lighter STREAM+feather path rather
144
+ than hard-aborting, and the pre-stitch abort fires only when there's
145
+ no room for even a minimal stitch on top of the current footprint —
146
+ so a memory-heavy host app no longer trips it spuriously.
147
+
148
+ ### Added — `stitcher` / `frameSelection` config as JSON-object props
149
+
150
+ `<Camera>` now accepts the full stitcher and frame-gate config as JSON
151
+ objects — `stitcher={{ warperType, blenderType, seamFinderType,
152
+ stitchMode, enableMaxInscribedRectCrop }}` and
153
+ `frameSelection={{ mode, maxKeyframes, overlapThreshold,
154
+ maxKeyframeIntervalMs, flow }}` (both partial; `flow` is deep-merged).
155
+ Object fields win over the matching flat `default*` props, which remain
156
+ supported. This is the recommended way to configure the pipeline.
157
+
158
+ ### Changed (BREAKING)
159
+
160
+ - **`onCapture` is now a discriminated union keyed on `ok`.** It fires
161
+ once per capture attempt — on success (`ok:true`, discriminated
162
+ further by `type`) AND on failure (`ok:false`, carrying `error:
163
+ CameraError`); previously it fired only on success and failures went
164
+ solely to `onError`. `onError` STILL fires on failure as an unchanged
165
+ mirror. **Migration:** gate on `result.ok` before reading
166
+ `uri`/`width`/`height` — `if (!result.ok) { handle(result.error);
167
+ return; }`. Both branches also carry the new `warnings` array.
168
+ - **`<Camera>` now defaults to `panMode='vertical'` (landscape-only,
169
+ top→bottom panorama).** Previously the component accepted both
170
+ landscape and portrait holds with no gate. `panMode` options are now
171
+ `'vertical'` (landscape-only; portrait holds gated behind the
172
+ rotate-to-landscape prompt), `'horizontal'` (portrait-only, left→right;
173
+ landscape holds gated behind the rotate-to-portrait prompt), and
174
+ `'both'` (either, ungated). **Hosts that want portrait/left→right
175
+ panoramas pass `panMode='horizontal'` or `'both'`.**
176
+ - **Stitch defaults moved to more robust values.** `stitchMode` now
177
+ defaults to `'panorama'` (was `'auto'` — the auto-resolver's SCANS
178
+ branch keys off double-integrated IMU translation, which is unreliable
179
+ during rotation); `warperType` defaults to `'spherical'` (was
180
+ `'plane'` — bounds both axes, fixing fragmented wide/vertical pans);
181
+ and the keyframe gate is denser (`maxKeyframes` → 8, a 1.5 s
182
+ `maxKeyframeIntervalMs` time gate re-enabled — bounding a static/slow
183
+ capture to ~12 s before the 8-keyframe auto-finalize, `overlapThreshold`
184
+ → 0.15). **Migration:** hosts relying on the previous behaviour set the
185
+ values explicitly via the new `stitcher` / `frameSelection` props (or
186
+ the matching flat `default*` props) — e.g. `stitcher={{ stitchMode:
187
+ 'auto', warperType: 'plane' }}`.
18
188
 
19
189
  ## [0.15.2] — 2026-06-11
20
190
 
package/README.md CHANGED
@@ -83,6 +83,15 @@ import {
83
83
 
84
84
  export function CaptureScreen() {
85
85
  const handleCapture = (result: CameraCaptureResult) => {
86
+ // `onCapture` fires on success AND failure — gate on `ok` first.
87
+ if (!result.ok) {
88
+ console.warn('capture failed:', result.error.code, result.error.message);
89
+ return;
90
+ }
91
+ // Non-fatal quality signals (e.g. <70% of frames used). Always present.
92
+ if (result.warnings.length > 0) {
93
+ console.warn('warnings:', result.warnings.map((w) => w.code));
94
+ }
86
95
  if (result.type === 'photo') {
87
96
  console.log('Photo:', result.uri, result.width, result.height);
88
97
  } else {
@@ -98,6 +107,8 @@ export function CaptureScreen() {
98
107
  return (
99
108
  <Camera
100
109
  onCapture={handleCapture}
110
+ // onError still fires on failure too (an unchanged mirror of the
111
+ // ok:false result above).
101
112
  onError={(err: CameraError) => console.warn(err.code, err.message)}
102
113
  />
103
114
  );
@@ -135,10 +146,13 @@ export function CaptureScreen() {
135
146
  // 2. Capture history (drives the built-in thumbnail strip).
136
147
  const [thumbnails, setThumbnails] = useState<CaptureThumbnailItem[]>([]);
137
148
 
138
- // 3. Post-stitch preview modal — set on capture, cleared on close.
139
- const [preview, setPreview] = useState<CameraCaptureResult | null>(null);
149
+ // 3. Post-stitch preview modal — set on success, cleared on close.
150
+ const [preview, setPreview] = useState<
151
+ Extract<CameraCaptureResult, { ok: true }> | null
152
+ >(null);
140
153
 
141
154
  const onCapture = useCallback((result: CameraCaptureResult) => {
155
+ if (!result.ok) return; // failures go to onError; nothing to preview
142
156
  setPreview(result);
143
157
  setThumbnails((prev) => [
144
158
  ...prev,
@@ -260,12 +274,12 @@ Setting `headerTitle` renders a built-in top header; the settings gear is absorb
260
274
 
261
275
  | Prop | Type | Fires / purpose |
262
276
  |---|---|---|
263
- | `onCapture` | `(result: CameraCaptureResult) => void` | Photo OR panorama completes. `result.type` discriminates (`'photo'` / `'panorama'`). |
277
+ | `onCapture` | `(result: CameraCaptureResult) => void` | Fires once per capture attempt. **Gate on `result.ok` first** (`true` = output present, discriminated further by `result.type`; `false` carries `result.error`). Both carry `result.warnings: CaptureWarning[]` (e.g. `LOW_FRAME_UTILIZATION`). |
264
278
  | `onCaptureSourceChange` | `(source: CaptureSource) => void` | Effective source changes (AR toggle, or 0.5× forcing non-AR). |
265
279
  | `onLensChange` | `(lens: CameraLens) => void` | User taps the 1×/0.5× chip. |
266
280
  | `onFramesDropped` | `(info: FramesDroppedInfo) => void` | cv::Stitcher's confidence retry dropped input frame(s). |
267
281
  | `onCaptureAbandoned` | `(reason: 'orientation-drift') => void` | SDK auto-cancelled an in-flight capture (currently only mid-capture rotation). |
268
- | `onError` | `(err: CameraError) => void` | Classified error — see codes below. |
282
+ | `onError` | `(err: CameraError) => void` | Classified error — fires on failure as an unchanged mirror of the `ok:false` `onCapture` result. See codes below. |
269
283
  | `outputDir` | `string` | Directory for saved JPEGs. The lib creates it if missing. |
270
284
  | `engine` | `'batch-keyframe' \| …` | Stitching engine. Default `'batch-keyframe'`; most apps leave it. |
271
285
  | `frameProcessor` | vision-camera frame processor | Host worklet composed with first-party stitching (see [`useStitcherWorklet`](docs/camera-component.md)). Advanced. |
@@ -320,7 +334,119 @@ import { Alert } from 'react-native';
320
334
  ```
321
335
 
322
336
  It lives in the SDK (not per-host) so every consumer shows the same guidance for
323
- the same failure. The `example/` app uses it end-to-end.
337
+ the same failure. The `example/` app uses it end-to-end. To localise this copy,
338
+ pass an `overrides` map as the second argument — see
339
+ [Internationalization](#internationalization-i18n) below.
340
+
341
+ ## Internationalization (i18n)
342
+
343
+ Every user-facing string the SDK can show is **overridable** — there is no
344
+ bundled `locale` prop. By design you supply the translated strings from your
345
+ own i18n catalogue (i18next, FormatJS, etc.); the SDK never ships translations
346
+ it can't keep in sync with your wording. There are exactly **two** surfaces, and
347
+ together they cover **100 %** of what a user reads:
348
+
349
+ ### 1. `guidanceCopy` — everything the SDK renders on screen
350
+
351
+ A single `Partial<GuidanceCopy>` prop. Pass the keys you want to translate;
352
+ omitted keys fall back to the English default. This covers the rotate prompt,
353
+ the pan hint, the live "too fast" cue, the lateral-drift popups, the crop-editor
354
+ buttons, the **capture-status banner**, and the **crop-editor warning banners**:
355
+
356
+ Each default is the **exact, complete** source string — translate it verbatim
357
+ (keep the `{included}` / `{requested}` / `{percent}` placeholders in
358
+ `warnLowFrameUtilization`), or `import { DEFAULT_GUIDANCE_COPY }` to seed your
359
+ catalogue programmatically.
360
+
361
+ | Key | Where it appears | English default (translate verbatim) |
362
+ | --- | --- | --- |
363
+ | `rotateToLandscape` | rotate prompt | `Rotate to landscape` |
364
+ | `rotateToPortrait` | rotate prompt | `Rotate to portrait` |
365
+ | `panHint` | pan how-to overlay | `Pan slowly top to bottom` |
366
+ | `tooFast` | speed-cue pill | `Moving too fast — slow down` |
367
+ | `lateralStopTitle` | lateral-drift popup (stitched) | `Keep the pan straight` |
368
+ | `lateralStopBody` | lateral-drift popup (stitched) | `You moved sideways. Pan in one direction only — we stitched what you captured.` |
369
+ | `lateralStopDismiss` | lateral-drift popup button | `Got it` |
370
+ | `lateralWrongDirectionTitle` | lateral-drift popup (too few frames) | `Follow the arrow` |
371
+ | `lateralWrongDirectionBody` | lateral-drift popup (too few frames) | `You moved the phone the wrong way. Pan slowly in the direction the arrow shows, in one straight line.` |
372
+ | `cropConfirm` | crop-editor button | `Crop` |
373
+ | `cropReset` | crop-editor button | `Reset` |
374
+ | `cropUseOriginal` | crop-editor button | `Use original` |
375
+ | `cropRetake` | crop-editor button | `Retake` |
376
+ | `previewConfirm` | preview accept button (`showPreview`) | `Confirm` |
377
+ | `statusRecording` | status banner | `Hold steady — pan slowly` |
378
+ | `statusStitching` | status banner | `Stitching panorama…` |
379
+ | `warnLowFrameUtilization` | crop warning **(template)** | `Only {included} of {requested} captured frames ({percent}%) could be used — the panorama may be incomplete. Pan more slowly and steadily next time.` |
380
+ | `warnLateralDriftFinalize` | crop warning | `Capture stopped early because the phone drifted sideways — only the part captured before the drift was stitched.` |
381
+ | `warnHighPanSpeed` | crop warning | `The capture was taken faster than the recommended pace — the result may not be the best. Pan more slowly next time.` |
382
+
383
+ > **Templates:** `warnLowFrameUtilization` is interpolated at runtime — your
384
+ > translation must keep the `{included}`, `{requested}` and `{percent}`
385
+ > placeholders (an unknown placeholder is left verbatim rather than throwing).
386
+ > Overriding a `warn*` key re-words **both** the on-screen banner **and** the
387
+ > `message` carried on `onCapture(...).warnings[]`. The matching machine-readable
388
+ > `code` (e.g. `HIGH_PAN_SPEED`) is always present regardless of wording, so you
389
+ > can also branch on the code instead of the string.
390
+
391
+ ### 2. `userFacingStitchError(code, overrides?)` — the host-rendered error alert
392
+
393
+ The recoverable-stitch-error copy is rendered by **you** (in `onError`), so it's
394
+ localised at the call site: pass an `overrides` map (keyed by the codes in the
395
+ exported `RECOVERABLE_STITCH_CODES`) and any match wins over the bundled English;
396
+ omitted codes keep the default.
397
+
398
+ ```tsx
399
+ import {
400
+ Camera,
401
+ userFacingStitchError,
402
+ RECOVERABLE_STITCH_CODES,
403
+ DEFAULT_GUIDANCE_COPY,
404
+ type GuidanceCopy,
405
+ type UserFacingStitchErrorOverrides,
406
+ } from 'react-native-image-stitcher';
407
+ import { Alert } from 'react-native';
408
+ import { useTranslation } from 'react-i18next';
409
+
410
+ function CaptureScreen() {
411
+ const { t } = useTranslation();
412
+
413
+ // (1) SDK-rendered copy — translate the keys you care about.
414
+ const guidanceCopy: Partial<GuidanceCopy> = {
415
+ rotateToLandscape: t('pano.rotateToLandscape'),
416
+ statusRecording: t('pano.statusRecording'),
417
+ // keep the placeholders in the template translation:
418
+ warnLowFrameUtilization: t('pano.warnLowFrames'), // "{included}/{requested} ({percent}%) …"
419
+ // …any subset; the rest stay English via DEFAULT_GUIDANCE_COPY
420
+ };
421
+
422
+ // (2) Host-rendered error alerts — translate by code.
423
+ const errorCopy: UserFacingStitchErrorOverrides = Object.fromEntries(
424
+ RECOVERABLE_STITCH_CODES.map((code) => [
425
+ code,
426
+ { title: t(`pano.err.${code}.title`), message: t(`pano.err.${code}.msg`) },
427
+ ]),
428
+ );
429
+
430
+ return (
431
+ <Camera
432
+ guidanceCopy={guidanceCopy}
433
+ onError={(err) => {
434
+ const friendly = userFacingStitchError(err.code, errorCopy);
435
+ if (friendly) Alert.alert(friendly.title, friendly.message);
436
+ else reportGenericError(err);
437
+ }}
438
+ />
439
+ );
440
+ }
441
+ ```
442
+
443
+ `DEFAULT_GUIDANCE_COPY` and `DEFAULT_CAPTURE_WARNING_COPY` are exported so you can
444
+ seed your translation catalogue from the source strings, and
445
+ `RECOVERABLE_STITCH_GUIDANCE` exposes the built-in error copy for the same reason.
446
+
447
+ > **Full worked example** — a Spanish `es.json` catalogue (both surfaces) plus a
448
+ > host language-setting that switches the copy at runtime: see the
449
+ > [Internationalization guide](https://bhargavkanda.github.io/react-native-image-stitcher/docs/i18n#worked-example-spanish-with-a-dynamic-language-setting).
324
450
 
325
451
  ### Migration from 0.13.x
326
452
 
@@ -28,6 +28,15 @@
28
28
 
29
29
  #include <string>
30
30
  #include <vector>
31
+ #include <cstdio> // /proc/self/statm read for the purge diagnostic
32
+ #include <unistd.h> // sysconf — device RAM for the manual-pipeline budget
33
+ #include <dlfcn.h> // dlsym — resolve mallopt() at runtime (API-gated; see below)
34
+
35
+ // M_PURGE (release free pages back to the OS) was added to bionic at API 28;
36
+ // define it for our minSdk-24 build (a harmless no-op on the older allocator).
37
+ #ifndef M_PURGE
38
+ #define M_PURGE (-101)
39
+ #endif
31
40
 
32
41
 
33
42
  #define LOG_TAG "BatchStitcher.JNI"
@@ -63,6 +72,59 @@ void androidLogBridge(int level, const char* tag, const char* msg) {
63
72
  __android_log_print(prio, LOG_TAG, "%s %s", tag ? tag : "", msg ? msg : "");
64
73
  }
65
74
 
75
+ // 2026-06-15 — last successful stitch's debugSummary (pipe/warp/route/seam/blend).
76
+ // The nativeStitchFramePaths return is a jintArray which can't carry a string,
77
+ // so we stash it here and expose it via the lightweight nativeLastDebugSummary()
78
+ // getter that Kotlin calls right after a successful stitch (same thread → no
79
+ // concurrency). Mirrors the iOS RNStitchResult.debugSummary surface so the DEV
80
+ // overlay shows warp/route/seam/blend on Android too, not just mode/score.
81
+ std::string g_lastDebugSummary;
82
+
83
+ // Return the just-finished stitch's freed native memory to the OS. cv::Mat /
84
+ // the OpenCV allocator keep freed blocks in a process-wide pool, so without this
85
+ // the native-heap RSS baseline ratchets up ~10-15 MB per capture (dumpsys showed
86
+ // the creep in Native Heap, not Graphics). mallopt() was exported by bionic at
87
+ // API 26 but our minSdk is 24, so resolve it at runtime via dlsym and call only
88
+ // when present (it is on every API-26+ device, including the test A35).
89
+ double procRssMB() {
90
+ FILE* f = fopen("/proc/self/statm", "r");
91
+ if (f == nullptr) return -1.0;
92
+ long sizePages = 0, residentPages = 0;
93
+ const int n = fscanf(f, "%ld %ld", &sizePages, &residentPages);
94
+ fclose(f);
95
+ if (n != 2) return -1.0;
96
+ return static_cast<double>(residentPages)
97
+ * static_cast<double>(sysconf(_SC_PAGE_SIZE)) / (1024.0 * 1024.0);
98
+ }
99
+
100
+ // Returns the POST-purge RSS in MB (the leak-plateau "floor"), or -1 when the
101
+ // diagnostic reads are gated off. The mallopt(M_PURGE) CALL is UNCONDITIONAL —
102
+ // it's the leak fix; only its before/after READS + the log are gated (3A), so a
103
+ // release build pays nothing while debug builds surface memFloor.
104
+ double purgeNativeAllocator(bool profiling) {
105
+ using MalloptFn = int (*)(int, int);
106
+ // Resolve mallopt at runtime (API-26 symbol; minSdk 24). Prefer an explicit
107
+ // libc.so handle — RTLD_DEFAULT from a dlopen'd .so doesn't always reach
108
+ // libc on Android — then fall back to RTLD_DEFAULT.
109
+ static MalloptFn fn = []() -> MalloptFn {
110
+ void* h = dlopen("libc.so", RTLD_NOLOAD | RTLD_NOW);
111
+ void* s = (h != nullptr) ? dlsym(h, "mallopt") : nullptr;
112
+ if (s == nullptr) s = dlsym(RTLD_DEFAULT, "mallopt");
113
+ return reinterpret_cast<MalloptFn>(s);
114
+ }();
115
+ const double before = profiling ? procRssMB() : -1.0;
116
+ if (fn != nullptr) fn(M_PURGE, 0); // the fix — always runs
117
+ if (!profiling) return -1.0;
118
+ const double after = procRssMB();
119
+ // Diagnostic: shows whether mallopt resolved and how much RSS the purge
120
+ // actually returned to the OS. If mallopt=MISSING → dlsym failed; if
121
+ // resolved but before≈after → the residual isn't allocator-retained (real
122
+ // leak) and M_PURGE can't help.
123
+ LOGI("[memstat] purge: mallopt=%s rss %.1f -> %.1f MB",
124
+ (fn != nullptr) ? "ok" : "MISSING", before, after);
125
+ return after; // memFloor — the post-purge plateau metric
126
+ }
127
+
66
128
  } // namespace
67
129
 
68
130
 
@@ -81,7 +143,8 @@ Java_io_imagestitcher_rn_BatchStitcher_nativeStitchFramePaths(
81
143
  jdouble registrationResolMP,
82
144
  jdouble seamEstimationResolMP,
83
145
  jdouble compositingResolMP,
84
- jstring stitchModeStr) {
146
+ jstring stitchModeStr,
147
+ jboolean useManualPipeline) {
85
148
 
86
149
  if (framePaths == nullptr) {
87
150
  throw_runtime(env, "framePaths is null");
@@ -116,24 +179,89 @@ Java_io_imagestitcher_rn_BatchStitcher_nativeStitchFramePaths(
116
179
  ? retailens::StitchMode::Panorama
117
180
  : retailens::StitchMode::Scans;
118
181
 
119
- // 2026-06-07unify on the manual cv::detail pipeline. It won the
120
- // on-device A/B: equals the high-level cv::Stitcher on quality after
121
- // parity AND is strictly more robust the cylindrical fallback, warp
122
- // guard, and exposure comp all live only in the manual path, so the
123
- // high-level path garbages wide/0.5x captures. Mirrors iOS'
124
- // OpenCVStitcher.mm. See docs/stitch-pipeline-architecture.md §7.
125
- cfg.useManualPipeline = true;
126
- // Match iOS' parity resolution: the manual entry's default registration
127
- // is 0.3 MP (vs the high-level's 0.6); bump to 0.6 unless the caller set
128
- // an explicit value. (compositingResolMP already arrives as 1.0.)
182
+ // 2026-06-15pipeline is caller-selectable (mirrors iOS). The batch
183
+ // finalize passes useManualPipeline=true: ALL the memory/OOM hardening
184
+ // lives on the manual path (PreStitchMemoryAbort, RAM-aware canvas-budget
185
+ // downscale, STREAM/BATCH held-set routing, the black-canvas utilization
186
+ // guard); the high-level cv::Stitcher path calls NONE of it — so manual is
187
+ // both the preferred output AND the memory-safe one. The on-demand
188
+ // HIGH-LEVEL preview tab calls refinePanorama with useManualPipeline=false
189
+ // to re-stitch the captured keyframes via stock cv::Stitcher.
190
+ //
191
+ // WARPER: NOT hardcoded — cfg.warperType carries the caller's choice (set
192
+ // above from the JS `warperType`, which defaults to "spherical" and is
193
+ // settable via the ⚙️ panel / the host's `defaultWarper` prop). The JS
194
+ // default is the single source of truth now (mirrors iOS). Choosing "plane"
195
+ // re-arms the manual pipeline's dynamic plane→spherical fallback/divergence
196
+ // switch (they only fire when warperType != "spherical").
197
+ cfg.useManualPipeline = (useManualPipeline == JNI_TRUE);
198
+ // 2026-06-16 — memory profiling (DEV). Gated by the compile flag (debug-on,
199
+ // release-off); Android leaves memProbeFn null so rss_mb() uses /proc.
200
+ cfg.enableMemoryProfiling = (RNIS_MEMORY_PROFILING != 0);
201
+ if (cfg.warperType.empty()) cfg.warperType = "spherical";
129
202
  if (cfg.registrationResolMP <= 0.0) {
130
203
  cfg.registrationResolMP = 0.6;
131
204
  }
205
+ // Plumb the device's physical RAM so the manual pipeline's memory budget
206
+ // (perProcessMemoryBudgetMB = RAM × 0.42, floored at 900 MB) scales to the
207
+ // ACTUAL device instead of the assumed-4GB fallback (which over-throttles a
208
+ // 6–8 GB phone into STREAM+feather → blurrier). iOS passes physicalMemory;
209
+ // on Android we read it from sysconf here (no JNI signature change needed).
210
+ if (cfg.availableRamMB <= 0.0) {
211
+ const long pages = sysconf(_SC_PHYS_PAGES);
212
+ const long pageSize = sysconf(_SC_PAGE_SIZE);
213
+ if (pages > 0 && pageSize > 0) {
214
+ cfg.availableRamMB =
215
+ static_cast<double>(pages) * static_cast<double>(pageSize)
216
+ / (1024.0 * 1024.0);
217
+ }
218
+ }
132
219
 
133
220
  const std::string outPath = jstring_to_string(env, outputPath);
134
221
 
135
- retailens::StitchResult result = retailens::stitchFramePaths(
136
- paths, outPath, cfg, &androidLogBridge);
222
+ // 2026-06-16 (review #1) — backstop try/catch at the JNI C-ABI boundary.
223
+ // stitchFramePaths now has its own catch ladders (high-level + manual), so
224
+ // this should never fire — but a C++ exception crossing into JNI is UB
225
+ // (std::terminate/SIGABRT), so we NEVER let one through: convert any escape
226
+ // into a Java exception the Kotlin layer can catch.
227
+ retailens::StitchResult result;
228
+ try {
229
+ result = retailens::stitchFramePaths(
230
+ paths, outPath, cfg, &androidLogBridge);
231
+ } catch (const std::exception& e) {
232
+ throw_runtime(env, std::string("native stitch crashed: ") + e.what());
233
+ return nullptr;
234
+ } catch (...) {
235
+ throw_runtime(env, "native stitch crashed (unknown exception)");
236
+ return nullptr;
237
+ }
238
+
239
+ // Return the stitch's freed native memory to the OS so the native-heap RSS
240
+ // baseline doesn't ratchet up ~10-15 MB per capture (see purgeNativeAllocator).
241
+ // Applies to BOTH pipelines (they share the OpenCV/bionic allocator). The
242
+ // post-purge RSS is the leak-plateau "floor" — append it to debugSummary so
243
+ // it rides the existing nativeLastDebugSummary() path to JS (no new bridge).
244
+ const double memFloor = purgeNativeAllocator(RNIS_MEMORY_PROFILING != 0);
245
+ if ((RNIS_MEMORY_PROFILING != 0) && memFloor >= 0.0) {
246
+ char fbuf[40];
247
+ std::snprintf(fbuf, sizeof(fbuf), ";memFloor=%.1f", memFloor);
248
+ if (!result.debugSummary.empty()) result.debugSummary += fbuf;
249
+ // 2026-06-16 — one authoritative per-stitch memory line to logcat (the
250
+ // sampler peak otherwise only rides debugSummary to the on-screen
251
+ // overlay). pipe/warp/mode lets each line be attributed to a preview
252
+ // tab: pipe=manual warp=plane mode=panorama = "As captured" primary;
253
+ // pipe=highlevel warp=plane = HL·Plane; warp=spherical = HL·Sph;
254
+ // mode=scans = SCANS. Grep `[memstat] record:` to harvest all of them.
255
+ LOGI("[memstat] record: pipe=%s warp=%s mode=%s before=%.1f peak=%.1f "
256
+ "after=%.1f floor=%.1f src=%s frames=%d/%d",
257
+ cfg.useManualPipeline ? "manual" : "highlevel",
258
+ cfg.warperType.c_str(),
259
+ (result.stitchModeUsed == retailens::StitchMode::Scans)
260
+ ? "scans" : "panorama",
261
+ result.memBeforeMB, result.memPeakMB, result.memAfterMB, memFloor,
262
+ result.memSource.c_str(),
263
+ result.framesIncluded, result.framesRequested);
264
+ }
137
265
 
138
266
  if (!result.success) {
139
267
  const std::string msg = "Stitch failed: " + result.errorMessage +
@@ -142,6 +270,10 @@ Java_io_imagestitcher_rn_BatchStitcher_nativeStitchFramePaths(
142
270
  return nullptr;
143
271
  }
144
272
 
273
+ // Stash the run's debugSummary for nativeLastDebugSummary() (jintArray
274
+ // can't carry a string). Read by Kotlin right after this returns.
275
+ g_lastDebugSummary = result.debugSummary;
276
+
145
277
  // Return [width, height, framesRequested, framesIncluded, finalThresholdMilli]
146
278
  // — same JNI return layout as the previous file (Kotlin already
147
279
  // parses indices 0-4). The threshold is multiplied by 1000 +
@@ -157,3 +289,12 @@ Java_io_imagestitcher_rn_BatchStitcher_nativeStitchFramePaths(
157
289
  env->SetIntArrayRegion(dims, 0, 5, values);
158
290
  return dims;
159
291
  }
292
+
293
+ // Returns the debugSummary of the most recent successful stitch (pipe/warp/
294
+ // route/seam/blend). Kotlin calls this right after nativeStitchFramePaths so
295
+ // the value is fresh (stitches are serialized on one background thread).
296
+ extern "C" JNIEXPORT jstring JNICALL
297
+ Java_io_imagestitcher_rn_BatchStitcher_nativeLastDebugSummary(
298
+ JNIEnv* env, jobject /*thiz*/) {
299
+ return env->NewStringUTF(g_lastDebugSummary.c_str());
300
+ }
@@ -311,23 +311,30 @@ Java_io_imagestitcher_rn_KeyframeGate_nativeEvaluateWithFrame(
311
311
  }
312
312
  }
313
313
 
314
- // Pin the byte[] for the duration of the gate evaluate. Use
315
- // GetPrimitiveArrayCritical (zero-copy, JVM pins the GC) over
316
- // GetByteArrayElements (may copy on some VMs) because at 30-60
317
- // Hz of 2 MB Y-planes, the copy cost adds up. Evaluate is
318
- // ~1-5 ms so the pin window is short. Always paired with
319
- // ReleasePrimitiveArrayCritical even on the error paths below.
314
+ // 2026-06-16 (audit #4) — pin the byte[] ONLY to INGEST it.
315
+ // GetPrimitiveArrayCritical is zero-copy (the JVM pins the GC) — preferred
316
+ // over GetByteArrayElements (which may copy a 2 MB Y-plane at 30-60 Hz) —
317
+ // but it blocks the GC for the pin's whole life. So we keep that life to a
318
+ // single downscale: ingestWorkingFrame() reads the pinned bytes into an
319
+ // OWNED working frame, we Release IMMEDIATELY, then evaluateWithWorkingMat()
320
+ // runs the heavy OpenCV (goodFeaturesToTrack / optical flow) with the pin
321
+ // already gone — no longer stalling the GC or the frame-rate producer
322
+ // thread. Always paired with ReleasePrimitiveArrayCritical, even on errors.
320
323
  retailens::KeyframeGateDecision d;
321
324
  if (grayBytes && grayWidth > 0 && grayHeight > 0 && grayStride >= grayWidth) {
322
325
  void* raw = env->GetPrimitiveArrayCritical(grayBytes, nullptr);
323
326
  if (raw) {
324
- d = gate(handle)->evaluateWithFrame(
325
- pose, planePtr,
327
+ gate(handle)->ingestWorkingFrame(
326
328
  static_cast<const uint8_t*>(raw),
327
329
  static_cast<int32_t>(grayWidth),
328
330
  static_cast<int32_t>(grayHeight),
329
331
  static_cast<int32_t>(grayStride));
330
332
  env->ReleasePrimitiveArrayCritical(grayBytes, raw, JNI_ABORT);
333
+ // Pin released — heavy OpenCV now runs outside the critical section.
334
+ d = gate(handle)->evaluateWithWorkingMat(
335
+ pose, planePtr,
336
+ static_cast<int32_t>(grayWidth),
337
+ static_cast<int32_t>(grayHeight));
331
338
  } else {
332
339
  // GetPrimitiveArrayCritical failed (rare, but defensive).
333
340
  // Fall back to pose-only path so we degrade gracefully