react-native-image-stitcher 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +124 -1
  2. package/README.md +116 -5
  3. package/android/src/main/cpp/image_stitcher_jni.cpp +107 -11
  4. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
  5. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +87 -30
  6. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
  8. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
  9. package/cpp/crop_quad.cpp +162 -0
  10. package/cpp/crop_quad.hpp +163 -0
  11. package/cpp/stitcher.cpp +651 -55
  12. package/cpp/stitcher.hpp +10 -0
  13. package/cpp/warp_guard.hpp +212 -0
  14. package/dist/camera/Camera.d.ts +196 -12
  15. package/dist/camera/Camera.js +629 -35
  16. package/dist/camera/CameraView.js +35 -16
  17. package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
  18. package/dist/camera/CaptureCountdownOverlay.js +239 -0
  19. package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
  20. package/dist/camera/CaptureFrameCounterOverlay.js +142 -0
  21. package/dist/camera/CaptureMemoryPill.d.ts +9 -1
  22. package/dist/camera/CaptureMemoryPill.js +3 -3
  23. package/dist/camera/CapturePreview.js +2 -1
  24. package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
  25. package/dist/camera/CaptureStatusOverlay.js +22 -5
  26. package/dist/camera/CaptureThumbnailStrip.js +2 -1
  27. package/dist/camera/LateralMotionModal.d.ts +85 -0
  28. package/dist/camera/LateralMotionModal.js +134 -0
  29. package/dist/camera/PanHowToOverlay.d.ts +76 -0
  30. package/dist/camera/PanHowToOverlay.js +222 -0
  31. package/dist/camera/PanoramaSettings.d.ts +8 -6
  32. package/dist/camera/PanoramaSettings.js +26 -5
  33. package/dist/camera/PanoramaSettingsModal.js +4 -4
  34. package/dist/camera/RectCropPreview.d.ts +161 -0
  35. package/dist/camera/RectCropPreview.js +480 -0
  36. package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
  37. package/dist/camera/RotateToLandscapePrompt.js +138 -0
  38. package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
  39. package/dist/camera/buildPanoramaInitialSettings.js +9 -0
  40. package/dist/camera/cameraErrorMessages.d.ts +30 -1
  41. package/dist/camera/cameraErrorMessages.js +26 -10
  42. package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
  43. package/dist/camera/cameraGuidanceCopy.js +80 -0
  44. package/dist/camera/captureCountdown.d.ts +52 -0
  45. package/dist/camera/captureCountdown.js +76 -0
  46. package/dist/camera/captureWarnings.d.ts +90 -0
  47. package/dist/camera/captureWarnings.js +108 -0
  48. package/dist/camera/classifyStitchError.d.ts +30 -0
  49. package/dist/camera/classifyStitchError.js +42 -0
  50. package/dist/camera/cropGeometry.d.ts +136 -0
  51. package/dist/camera/cropGeometry.js +223 -0
  52. package/dist/camera/displayDecodeImageProps.d.ts +25 -0
  53. package/dist/camera/displayDecodeImageProps.js +29 -0
  54. package/dist/camera/guidanceGraphics.d.ts +58 -0
  55. package/dist/camera/guidanceGraphics.js +280 -0
  56. package/dist/camera/guidanceTokens.d.ts +54 -0
  57. package/dist/camera/guidanceTokens.js +58 -0
  58. package/dist/camera/panModeGate.d.ts +54 -0
  59. package/dist/camera/panModeGate.js +62 -0
  60. package/dist/camera/pickCaptureFormat.d.ts +71 -0
  61. package/dist/camera/pickCaptureFormat.js +85 -0
  62. package/dist/camera/stitchDebugInfo.d.ts +27 -0
  63. package/dist/camera/stitchDebugInfo.js +55 -0
  64. package/dist/camera/usePanMotion.d.ts +250 -0
  65. package/dist/camera/usePanMotion.js +451 -0
  66. package/dist/index.d.ts +24 -3
  67. package/dist/index.js +33 -2
  68. package/dist/stitching/computeInscribedRect.d.ts +40 -0
  69. package/dist/stitching/computeInscribedRect.js +55 -0
  70. package/dist/stitching/cropQuad.d.ts +78 -0
  71. package/dist/stitching/cropQuad.js +116 -0
  72. package/dist/stitching/incremental.d.ts +45 -0
  73. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +56 -8
  74. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
  75. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
  76. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
  77. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +191 -7
  78. package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
  79. package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
  80. package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
  81. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
  82. package/package.json +5 -1
  83. package/src/camera/Camera.tsx +994 -47
  84. package/src/camera/CameraView.tsx +48 -16
  85. package/src/camera/CaptureCountdownOverlay.tsx +272 -0
  86. package/src/camera/CaptureFrameCounterOverlay.tsx +183 -0
  87. package/src/camera/CaptureMemoryPill.tsx +17 -3
  88. package/src/camera/CapturePreview.tsx +5 -0
  89. package/src/camera/CaptureStatusOverlay.tsx +35 -7
  90. package/src/camera/CaptureThumbnailStrip.tsx +4 -0
  91. package/src/camera/LateralMotionModal.tsx +199 -0
  92. package/src/camera/PanHowToOverlay.tsx +246 -0
  93. package/src/camera/PanoramaSettings.ts +34 -11
  94. package/src/camera/PanoramaSettingsModal.tsx +4 -4
  95. package/src/camera/RectCropPreview.tsx +820 -0
  96. package/src/camera/RotateToLandscapePrompt.tsx +188 -0
  97. package/src/camera/buildPanoramaInitialSettings.ts +30 -1
  98. package/src/camera/cameraErrorMessages.ts +39 -2
  99. package/src/camera/cameraGuidanceCopy.ts +145 -0
  100. package/src/camera/captureCountdown.ts +83 -0
  101. package/src/camera/captureWarnings.ts +190 -0
  102. package/src/camera/classifyStitchError.ts +68 -0
  103. package/src/camera/cropGeometry.ts +268 -0
  104. package/src/camera/displayDecodeImageProps.ts +25 -0
  105. package/src/camera/guidanceGraphics.tsx +347 -0
  106. package/src/camera/guidanceTokens.ts +57 -0
  107. package/src/camera/panModeGate.ts +81 -0
  108. package/src/camera/pickCaptureFormat.ts +130 -0
  109. package/src/camera/stitchDebugInfo.ts +71 -0
  110. package/src/camera/usePanMotion.ts +667 -0
  111. package/src/index.ts +66 -3
  112. package/src/stitching/computeInscribedRect.ts +81 -0
  113. package/src/stitching/cropQuad.ts +167 -0
  114. package/src/stitching/incremental.ts +45 -0
  115. package/cpp/tests/CMakeLists.txt +0 -104
  116. package/cpp/tests/README.md +0 -86
  117. package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
  118. package/cpp/tests/pose_test.cpp +0 -74
  119. package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
  120. package/cpp/tests/stubs/jsi/jsi.h +0 -33
  121. package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
  122. package/cpp/tests/warp_guard_test.cpp +0 -48
  123. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
  124. package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
  125. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
  126. package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
  127. package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
  128. package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
  129. package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
  130. package/src/camera/__tests__/useContentRotation.test.ts +0 -89
  131. package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
  132. package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
  133. package/src/stitching/__tests__/useStitcherWorklet.test.ts +0 -202
package/CHANGELOG.md CHANGED
@@ -14,7 +14,130 @@ 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.0] — 2026-06-15
18
+
19
+ ### Added — first-time-user panorama capture GUIDANCE
20
+
21
+ A set of opt-in-by-default guidance surfaces that coach the operator
22
+ through a non-AR hold-and-pan panorama. All seven are wired into
23
+ `<Camera>` automatically and read directly from new props (none are
24
+ threaded through `PanoramaSettings`):
25
+
26
+ 1. **Mode gate + 2. rotate-to-landscape prompt.** Starting a panorama
27
+ while the phone is held portrait under Mode A is blocked behind a
28
+ "Rotate to landscape" caption; the capture starts the instant the
29
+ user rotates to landscape (either way up). Releasing the shutter
30
+ before rotating cancels the pending start.
31
+ 3. **Pan how-to overlay.** A brief code-drawn looping graphic (phone +
32
+ sweeping band) + bouncing direction arrow (down for landscape Mode A,
33
+ right for portrait Mode B) shown for ~2.5 s at the start of each
34
+ recording.
35
+ 4. **"Moving too fast" pill.** A transient amber pill while the gyro
36
+ pan rate exceeds the warn threshold.
37
+ 5. **Blinking countdown + auto-finalize.** A blinking whole-seconds
38
+ countdown; at 0 the capture auto-finalizes (stitches what was
39
+ captured — same path as releasing the shutter).
40
+ 6. **Lateral-drift stop.** If the operator drifts sideways out of the
41
+ pan plane beyond the budget, the capture FINALIZES what was captured
42
+ and a one-button popup explains why.
43
+ 7. **Post-stitch review surface.** Optional. `rectCrop` shows a
44
+ draggable-quad crop editor (drag four corners; confirm perspective-
45
+ rectifies in place via `cv::warpPerspective` when the quad isn't
46
+ axis-aligned, "Use original" emits un-cropped, "Retake" discards).
47
+ `showPreview` shows the same screen with NO crop box — just the
48
+ stitched image with [Retake]/[Confirm]. With both off, `onCapture`
49
+ fires immediately.
50
+
51
+ New `<Camera>` props (all optional): `panMode`, `panGuidance`
52
+ (default `true`), `maxPanDurationMs` (default `9000`; `0` disables the
53
+ countdown + auto-finalize), `panTooFastThreshold`, `lateralBudgetCm`
54
+ (default `5`; `0` disables the lateral stop), `rectCrop`
55
+ (default `false`), `showPreview` (default `false`), and
56
+ `guidanceCopy` (partial override of every guidance string). A skewed
57
+ crop quad is always perspective-rectified (there is no opt-out flag).
58
+
59
+ New public exports: the `PanMode` type, `GuidanceCopy` +
60
+ `DEFAULT_GUIDANCE_COPY`, the `usePanMotion` hook, the five guidance
61
+ components (`RotateToLandscapePrompt`, `PanHowToOverlay`,
62
+ `CaptureCountdownOverlay`, `LateralMotionModal`, `RectCropPreview`) with
63
+ their prop types, and the `cropQuad` perspective-rectify helper.
64
+
65
+ ### Added — capture hardening
66
+
67
+ Follow-up hardening on top of the guidance set, driven by on-device
68
+ testing:
69
+
70
+ - **Guidance graphics are now code-drawn, not GIFs.** The rotate-to-
71
+ landscape and pan-capture animations are rendered with pure RN
72
+ `View` + `Animated` (`guidanceGraphics.tsx`) — resolution-independent
73
+ (no pixelation on high-density screens) and themeable via
74
+ `GUIDANCE_TOKENS`. Removes the bundled GIF assets AND the Android
75
+ host's previous need to add Fresco's `animated-gif` module.
76
+ - **Crop editor seeds from the max-inscribed rectangle.** With
77
+ `rectCrop`, the draggable quad now opens on the tightest clean
78
+ rectangle (native `computeInscribedRect`) instead of a blind 8 %
79
+ inset, and the editor gains an explicit **"Use original"** button
80
+ (emit the stitch un-cropped) plus a warning banner. When the editor is
81
+ on, the native auto-crop is forced off so the full bordered panorama
82
+ is available to drag.
83
+ - **`onCapture` carries `warnings`.** Both success and failure results
84
+ include `warnings: CaptureWarning[]` — `LOW_FRAME_UTILIZATION` (<70 %
85
+ of captured frames used) and `LATERAL_DRIFT_FINALIZE`. New exports:
86
+ `CaptureWarning`, `CaptureWarningCode`, `PanoramaCaptureResult`.
87
+ - **Post-stitch validation.** A disjoint / fragmented stitch (frames
88
+ that survived confidence but didn't fuse into one panorama) is now
89
+ rejected with the new `STITCH_LOW_QUALITY` error code + "try again"
90
+ copy, instead of emitting a broken image.
91
+ - **Quality-driven warper.** Wide pans switch from plane to the bounded
92
+ cylindrical projection based on the estimated sweep angle (not only on
93
+ an OOM-divergence fallback), reducing end-of-pan perspective stretch.
94
+ - **Headroom-based memory gating.** The flat process-RSS pre-stitch
95
+ abort is replaced by a per-process headroom model: under memory
96
+ pressure the pipeline routes to the lighter STREAM+feather path rather
97
+ than hard-aborting, and the pre-stitch abort fires only when there's
98
+ no room for even a minimal stitch on top of the current footprint —
99
+ so a memory-heavy host app no longer trips it spuriously.
100
+
101
+ ### Added — `stitcher` / `frameSelection` config as JSON-object props
102
+
103
+ `<Camera>` now accepts the full stitcher and frame-gate config as JSON
104
+ objects — `stitcher={{ warperType, blenderType, seamFinderType,
105
+ stitchMode, enableMaxInscribedRectCrop }}` and
106
+ `frameSelection={{ mode, maxKeyframes, overlapThreshold,
107
+ maxKeyframeIntervalMs, flow }}` (both partial; `flow` is deep-merged).
108
+ Object fields win over the matching flat `default*` props, which remain
109
+ supported. This is the recommended way to configure the pipeline.
110
+
111
+ ### Changed (BREAKING)
112
+
113
+ - **`onCapture` is now a discriminated union keyed on `ok`.** It fires
114
+ once per capture attempt — on success (`ok:true`, discriminated
115
+ further by `type`) AND on failure (`ok:false`, carrying `error:
116
+ CameraError`); previously it fired only on success and failures went
117
+ solely to `onError`. `onError` STILL fires on failure as an unchanged
118
+ mirror. **Migration:** gate on `result.ok` before reading
119
+ `uri`/`width`/`height` — `if (!result.ok) { handle(result.error);
120
+ return; }`. Both branches also carry the new `warnings` array.
121
+ - **`<Camera>` now defaults to `panMode='vertical'` (landscape-only,
122
+ top→bottom panorama).** Previously the component accepted both
123
+ landscape and portrait holds with no gate. `panMode` options are now
124
+ `'vertical'` (landscape-only; portrait holds gated behind the
125
+ rotate-to-landscape prompt), `'horizontal'` (portrait-only, left→right;
126
+ landscape holds gated behind the rotate-to-portrait prompt), and
127
+ `'both'` (either, ungated). **Hosts that want portrait/left→right
128
+ panoramas pass `panMode='horizontal'` or `'both'`.**
129
+ - **Stitch defaults moved to more robust values.** `stitchMode` now
130
+ defaults to `'panorama'` (was `'auto'` — the auto-resolver's SCANS
131
+ branch keys off double-integrated IMU translation, which is unreliable
132
+ during rotation); `warperType` defaults to `'spherical'` (was
133
+ `'plane'` — bounds both axes, fixing fragmented wide/vertical pans);
134
+ and the keyframe gate is denser (`maxKeyframes` → 8, a 1.5 s
135
+ `maxKeyframeIntervalMs` time gate re-enabled — bounding a static/slow
136
+ capture to ~12 s before the 8-keyframe auto-finalize, `overlapThreshold`
137
+ → 0.15). **Migration:** hosts relying on the previous behaviour set the
138
+ values explicitly via the new `stitcher` / `frameSelection` props (or
139
+ the matching flat `default*` props) — e.g. `stitcher={{ stitchMode:
140
+ 'auto', warperType: 'plane' }}`.
18
141
 
19
142
  ## [0.15.2] — 2026-06-11
20
143
 
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,104 @@ 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
+ | Key | Group | English default |
357
+ | --- | --- | --- |
358
+ | `rotateToLandscape` | rotate prompt | `Rotate to landscape` |
359
+ | `rotateToPortrait` | rotate prompt | `Rotate to portrait` |
360
+ | `panHint` | pan how-to | `Pan slowly top to bottom` |
361
+ | `tooFast` | speed cue | `Moving too fast — slow down` |
362
+ | `lateralStopTitle` / `lateralStopBody` / `lateralStopDismiss` | lateral popup (stitched) | `Keep the pan straight` / … / `Got it` |
363
+ | `lateralWrongDirectionTitle` / `lateralWrongDirectionBody` | lateral popup (too few frames) | `Follow the arrow` / … |
364
+ | `cropConfirm` / `cropReset` / `cropUseOriginal` / `cropRetake` | crop buttons | `Crop` / `Reset` / `Use original` / `Retake` |
365
+ | `previewConfirm` | preview-only accept button (`showPreview`) | `Confirm` |
366
+ | `statusRecording` | status banner | `Hold steady — pan slowly` |
367
+ | `statusStitching` | status banner | `Stitching panorama…` |
368
+ | `warnLowFrameUtilization` | crop warning **(template)** | `Only {included} of {requested} captured frames ({percent}%) could be used — …` |
369
+ | `warnLateralDriftFinalize` | crop warning | `Capture stopped early because the phone drifted sideways — …` |
370
+ | `warnHighPanSpeed` | crop warning | `The capture was taken faster than the recommended pace — …` |
371
+
372
+ > **Templates:** `warnLowFrameUtilization` is interpolated at runtime — your
373
+ > translation must keep the `{included}`, `{requested}` and `{percent}`
374
+ > placeholders (an unknown placeholder is left verbatim rather than throwing).
375
+ > Overriding a `warn*` key re-words **both** the on-screen banner **and** the
376
+ > `message` carried on `onCapture(...).warnings[]`. The matching machine-readable
377
+ > `code` (e.g. `HIGH_PAN_SPEED`) is always present regardless of wording, so you
378
+ > can also branch on the code instead of the string.
379
+
380
+ ### 2. `userFacingStitchError(code, overrides?)` — the host-rendered error alert
381
+
382
+ The recoverable-stitch-error copy is rendered by **you** (in `onError`), so it's
383
+ localised at the call site: pass an `overrides` map (keyed by the codes in the
384
+ exported `RECOVERABLE_STITCH_CODES`) and any match wins over the bundled English;
385
+ omitted codes keep the default.
386
+
387
+ ```tsx
388
+ import {
389
+ Camera,
390
+ userFacingStitchError,
391
+ RECOVERABLE_STITCH_CODES,
392
+ DEFAULT_GUIDANCE_COPY,
393
+ type GuidanceCopy,
394
+ type UserFacingStitchErrorOverrides,
395
+ } from 'react-native-image-stitcher';
396
+ import { Alert } from 'react-native';
397
+ import { useTranslation } from 'react-i18next';
398
+
399
+ function CaptureScreen() {
400
+ const { t } = useTranslation();
401
+
402
+ // (1) SDK-rendered copy — translate the keys you care about.
403
+ const guidanceCopy: Partial<GuidanceCopy> = {
404
+ rotateToLandscape: t('pano.rotateToLandscape'),
405
+ statusRecording: t('pano.statusRecording'),
406
+ // keep the placeholders in the template translation:
407
+ warnLowFrameUtilization: t('pano.warnLowFrames'), // "{included}/{requested} ({percent}%) …"
408
+ // …any subset; the rest stay English via DEFAULT_GUIDANCE_COPY
409
+ };
410
+
411
+ // (2) Host-rendered error alerts — translate by code.
412
+ const errorCopy: UserFacingStitchErrorOverrides = Object.fromEntries(
413
+ RECOVERABLE_STITCH_CODES.map((code) => [
414
+ code,
415
+ { title: t(`pano.err.${code}.title`), message: t(`pano.err.${code}.msg`) },
416
+ ]),
417
+ );
418
+
419
+ return (
420
+ <Camera
421
+ guidanceCopy={guidanceCopy}
422
+ onError={(err) => {
423
+ const friendly = userFacingStitchError(err.code, errorCopy);
424
+ if (friendly) Alert.alert(friendly.title, friendly.message);
425
+ else reportGenericError(err);
426
+ }}
427
+ />
428
+ );
429
+ }
430
+ ```
431
+
432
+ `DEFAULT_GUIDANCE_COPY` and `DEFAULT_CAPTURE_WARNING_COPY` are exported so you can
433
+ seed your translation catalogue from the source strings, and
434
+ `RECOVERABLE_STITCH_GUIDANCE` exposes the built-in error copy for the same reason.
324
435
 
325
436
  ### Migration from 0.13.x
326
437
 
@@ -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,53 @@ 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
+ void purgeNativeAllocator() {
101
+ using MalloptFn = int (*)(int, int);
102
+ // Resolve mallopt at runtime (API-26 symbol; minSdk 24). Prefer an explicit
103
+ // libc.so handle — RTLD_DEFAULT from a dlopen'd .so doesn't always reach
104
+ // libc on Android — then fall back to RTLD_DEFAULT.
105
+ static MalloptFn fn = []() -> MalloptFn {
106
+ void* h = dlopen("libc.so", RTLD_NOLOAD | RTLD_NOW);
107
+ void* s = (h != nullptr) ? dlsym(h, "mallopt") : nullptr;
108
+ if (s == nullptr) s = dlsym(RTLD_DEFAULT, "mallopt");
109
+ return reinterpret_cast<MalloptFn>(s);
110
+ }();
111
+ const double before = procRssMB();
112
+ if (fn != nullptr) fn(M_PURGE, 0);
113
+ const double after = procRssMB();
114
+ // Diagnostic: shows whether mallopt resolved and how much RSS the purge
115
+ // actually returned to the OS. If mallopt=MISSING → dlsym failed; if
116
+ // resolved but before≈after → the residual isn't allocator-retained (real
117
+ // leak) and M_PURGE can't help.
118
+ LOGI("[memstat] purge: mallopt=%s rss %.1f -> %.1f MB",
119
+ (fn != nullptr) ? "ok" : "MISSING", before, after);
120
+ }
121
+
66
122
  } // namespace
67
123
 
68
124
 
@@ -81,7 +137,8 @@ Java_io_imagestitcher_rn_BatchStitcher_nativeStitchFramePaths(
81
137
  jdouble registrationResolMP,
82
138
  jdouble seamEstimationResolMP,
83
139
  jdouble compositingResolMP,
84
- jstring stitchModeStr) {
140
+ jstring stitchModeStr,
141
+ jboolean useManualPipeline) {
85
142
 
86
143
  if (framePaths == nullptr) {
87
144
  throw_runtime(env, "framePaths is null");
@@ -116,25 +173,51 @@ Java_io_imagestitcher_rn_BatchStitcher_nativeStitchFramePaths(
116
173
  ? retailens::StitchMode::Panorama
117
174
  : retailens::StitchMode::Scans;
118
175
 
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.)
176
+ // 2026-06-15pipeline is caller-selectable (mirrors iOS). The batch
177
+ // finalize passes useManualPipeline=true: ALL the memory/OOM hardening
178
+ // lives on the manual path (PreStitchMemoryAbort, RAM-aware canvas-budget
179
+ // downscale, STREAM/BATCH held-set routing, the black-canvas utilization
180
+ // guard); the high-level cv::Stitcher path calls NONE of it — so manual is
181
+ // both the preferred output AND the memory-safe one. The on-demand
182
+ // HIGH-LEVEL preview tab calls refinePanorama with useManualPipeline=false
183
+ // to re-stitch the captured keyframes via stock cv::Stitcher.
184
+ //
185
+ // WARPER: NOT hardcoded — cfg.warperType carries the caller's choice (set
186
+ // above from the JS `warperType`, which defaults to "spherical" and is
187
+ // settable via the ⚙️ panel / the host's `defaultWarper` prop). The JS
188
+ // default is the single source of truth now (mirrors iOS). Choosing "plane"
189
+ // re-arms the manual pipeline's dynamic plane→spherical fallback/divergence
190
+ // switch (they only fire when warperType != "spherical").
191
+ cfg.useManualPipeline = (useManualPipeline == JNI_TRUE);
192
+ if (cfg.warperType.empty()) cfg.warperType = "spherical";
129
193
  if (cfg.registrationResolMP <= 0.0) {
130
194
  cfg.registrationResolMP = 0.6;
131
195
  }
196
+ // Plumb the device's physical RAM so the manual pipeline's memory budget
197
+ // (perProcessMemoryBudgetMB = RAM × 0.42, floored at 900 MB) scales to the
198
+ // ACTUAL device instead of the assumed-4GB fallback (which over-throttles a
199
+ // 6–8 GB phone into STREAM+feather → blurrier). iOS passes physicalMemory;
200
+ // on Android we read it from sysconf here (no JNI signature change needed).
201
+ if (cfg.availableRamMB <= 0.0) {
202
+ const long pages = sysconf(_SC_PHYS_PAGES);
203
+ const long pageSize = sysconf(_SC_PAGE_SIZE);
204
+ if (pages > 0 && pageSize > 0) {
205
+ cfg.availableRamMB =
206
+ static_cast<double>(pages) * static_cast<double>(pageSize)
207
+ / (1024.0 * 1024.0);
208
+ }
209
+ }
132
210
 
133
211
  const std::string outPath = jstring_to_string(env, outputPath);
134
212
 
135
213
  retailens::StitchResult result = retailens::stitchFramePaths(
136
214
  paths, outPath, cfg, &androidLogBridge);
137
215
 
216
+ // Return the stitch's freed native memory to the OS so the native-heap RSS
217
+ // baseline doesn't ratchet up ~10-15 MB per capture (see purgeNativeAllocator).
218
+ // Applies to BOTH pipelines (they share the OpenCV/bionic allocator).
219
+ purgeNativeAllocator();
220
+
138
221
  if (!result.success) {
139
222
  const std::string msg = "Stitch failed: " + result.errorMessage +
140
223
  " (code=" + std::to_string(static_cast<int>(result.errorCode)) + ")";
@@ -142,6 +225,10 @@ Java_io_imagestitcher_rn_BatchStitcher_nativeStitchFramePaths(
142
225
  return nullptr;
143
226
  }
144
227
 
228
+ // Stash the run's debugSummary for nativeLastDebugSummary() (jintArray
229
+ // can't carry a string). Read by Kotlin right after this returns.
230
+ g_lastDebugSummary = result.debugSummary;
231
+
145
232
  // Return [width, height, framesRequested, framesIncluded, finalThresholdMilli]
146
233
  // — same JNI return layout as the previous file (Kotlin already
147
234
  // parses indices 0-4). The threshold is multiplied by 1000 +
@@ -157,3 +244,12 @@ Java_io_imagestitcher_rn_BatchStitcher_nativeStitchFramePaths(
157
244
  env->SetIntArrayRegion(dims, 0, 5, values);
158
245
  return dims;
159
246
  }
247
+
248
+ // Returns the debugSummary of the most recent successful stitch (pipe/warp/
249
+ // route/seam/blend). Kotlin calls this right after nativeStitchFramePaths so
250
+ // the value is fresh (stitches are serialized on one background thread).
251
+ extern "C" JNIEXPORT jstring JNICALL
252
+ Java_io_imagestitcher_rn_BatchStitcher_nativeLastDebugSummary(
253
+ JNIEnv* env, jobject /*thiz*/) {
254
+ return env->NewStringUTF(g_lastDebugSummary.c_str());
255
+ }