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
@@ -43,6 +43,7 @@
43
43
  import React, {
44
44
  useCallback,
45
45
  useEffect,
46
+ useMemo,
46
47
  useRef,
47
48
  useState,
48
49
  } from 'react';
@@ -75,6 +76,7 @@ import {
75
76
  type CaptureThumbnailItem,
76
77
  } from './CaptureThumbnailStrip';
77
78
  import { CaptureStatusOverlay, type CaptureStatusPhase } from './CaptureStatusOverlay';
79
+ import { classifyStitchError } from './classifyStitchError';
78
80
  import { CaptureDebugOverlay } from './CaptureDebugOverlay';
79
81
  import { CaptureMemoryPill } from './CaptureMemoryPill';
80
82
  import { CaptureKeyframePill } from './CaptureKeyframePill';
@@ -94,6 +96,36 @@ import { useDeviceOrientation, type DeviceOrientation } from './useDeviceOrienta
94
96
  import { useContentRotation } from './useContentRotation';
95
97
  import { useOrientationDrift } from './useOrientationDrift';
96
98
  import { OrientationDriftModal } from './OrientationDriftModal';
99
+ // ── Panorama GUIDANCE building blocks (feature/pano-ux-guidance) ─────
100
+ // Pure decision helpers + sensor hook + presentational surfaces for the
101
+ // first-time-user pan-capture guidance (items 1–7). All read directly
102
+ // from the new <Camera> props below, NOT threaded through PanoramaSettings.
103
+ import {
104
+ shouldGateForPanMode,
105
+ gateTargetOrientation,
106
+ type PanMode,
107
+ } from './panModeGate';
108
+ import { countdownSecondsFrom } from './captureCountdown';
109
+ import { usePanMotion } from './usePanMotion';
110
+ import type { Quad } from './cropGeometry';
111
+ import {
112
+ mergeGuidanceCopy,
113
+ captureWarningCopyFrom,
114
+ type GuidanceCopy,
115
+ } from './cameraGuidanceCopy';
116
+ import { RotateToLandscapePrompt } from './RotateToLandscapePrompt';
117
+ import { PanHowToOverlay } from './PanHowToOverlay';
118
+ import { CaptureCountdownOverlay } from './CaptureCountdownOverlay';
119
+ import { CaptureFrameCounterOverlay } from './CaptureFrameCounterOverlay';
120
+ import { LateralMotionModal } from './LateralMotionModal';
121
+ import { RectCropPreview, type ImageRect } from './RectCropPreview';
122
+ import { buildStitchDebugInfo } from './stitchDebugInfo';
123
+ import { cropQuad } from '../stitching/cropQuad';
124
+ import { computeInscribedRect } from '../stitching/computeInscribedRect';
125
+ import {
126
+ buildCaptureWarnings,
127
+ type CaptureWarning,
128
+ } from './captureWarnings';
97
129
  import {
98
130
  getIncrementalNativeModule,
99
131
  incrementalStitcherIsAvailable,
@@ -134,26 +166,45 @@ export type Warper = 'plane' | 'cylindrical' | 'spherical';
134
166
 
135
167
 
136
168
  /**
137
- * Result emitted via `onCapture`. Discriminated union keyed on
138
- * `type` so consumers handle both photo and panorama outputs through
139
- * one callback path.
169
+ * Result emitted via `onCapture`. Discriminated union keyed FIRST on
170
+ * `ok` (success vs. failure) and then on `type` (photo vs. panorama), so a
171
+ * host handles EVERY capture outcome — success, degraded success, and
172
+ * failure — through this one callback.
173
+ *
174
+ * ## v0.16 — unified success/failure + warnings (BREAKING)
175
+ *
176
+ * Previously `onCapture` fired only on success and carried no `ok` field;
177
+ * failures went *solely* to `onError`. Hosts therefore had no single place
178
+ * to learn whether a capture succeeded, and no programmatic signal that a
179
+ * stitch was *degraded* (e.g. most frames dropped). Now:
180
+ *
181
+ * - `onCapture` ALWAYS fires once per capture attempt, with `ok:true`
182
+ * (output present) or `ok:false` (carrying the `CameraError`).
183
+ * - both success and failure carry `warnings: CaptureWarning[]` — non-fatal
184
+ * quality signals (e.g. `LOW_FRAME_UTILIZATION` when <70 % of captured
185
+ * frames survived, `LATERAL_DRIFT_FINALIZE` when item-6 stopped early).
186
+ * - `onError` STILL fires on failure too (an unchanged mirror), so existing
187
+ * error handling keeps working.
140
188
  *
141
- * Identifier `CameraCaptureResult` (vs. the SDK's existing
142
- * `CaptureResult` from `../types`) is intentional the existing
143
- * CaptureResult shape has SDK-specific fields (deviceMetadata,
144
- * qualityReport, deviceUuid) that don't belong in the public RN
145
- * library's surface. Step 3 (symbol rename) will retire the
146
- * historical SDK-specific names; for now we keep both types
147
- * side-by-side so the existing host code continues to work.
189
+ * Migration: gate on `ok` before reading `uri`/`width`/`height`
190
+ * `if (!result.ok) { handle(result.error); return; }`.
191
+ *
192
+ * Identifier `CameraCaptureResult` (vs. the SDK's existing `CaptureResult`
193
+ * from `../types`) is intentional the existing CaptureResult shape has
194
+ * SDK-specific fields that don't belong in the public RN library's surface.
148
195
  */
149
196
  export type CameraCaptureResult =
150
197
  | {
198
+ ok: true;
151
199
  type: 'photo';
152
200
  uri: string;
153
201
  width: number;
154
202
  height: number;
203
+ /** Non-fatal quality signals (empty when none). */
204
+ warnings: CaptureWarning[];
155
205
  }
156
206
  | {
207
+ ok: true;
157
208
  type: 'panorama';
158
209
  uri: string;
159
210
  width: number;
@@ -172,9 +223,63 @@ export type CameraCaptureResult =
172
223
  * cv::Stitcher at finalize).
173
224
  */
174
225
  stitchModeResolved?: 'panorama' | 'scans';
226
+ /**
227
+ * 2026-06-15 (DEV) — gyro rotation magnitude of the capture, in radians.
228
+ * Shown on the dev preview so the panorama-vs-SCANS rotation threshold can
229
+ * be tuned. `0` = no pose-derived rotation signal (non-AR with no poses).
230
+ */
231
+ rRadians?: number;
232
+ /**
233
+ * 2026-06-16 (DEV) — translation magnitude (m) + auto decision ratio
234
+ * (`>=0.55` → SCANS) that drove panorama-vs-SCANS. Shown on the dev
235
+ * readout alongside `rRadians` to tune the threshold from real captures.
236
+ */
237
+ tMeters?: number;
238
+ decisionRatio?: number;
239
+ /**
240
+ * 2026-06-14 (DEV overlay) — semicolon-separated `key=value` trace of the
241
+ * stitcher's runtime choices (pipe/warp/route/seam/blend) for this
242
+ * output. Shown on the preview in __DEV__. iOS only for now.
243
+ */
244
+ debugSummary?: string;
245
+ /**
246
+ * 2026-06-15 (iOS) — keyframe JPEG paths used for this stitch, so the
247
+ * preview can re-stitch them on demand via `refinePanorama` (the
248
+ * high-level tab). iOS only; undefined elsewhere.
249
+ */
250
+ keyframePaths?: string[];
251
+ /**
252
+ * 2026-06-15 (iOS) — orientation this stitch baked in. The on-demand
253
+ * high-level re-stitch passes it back so it matches the manual output's
254
+ * rotation (not the raw sensor landscape). iOS only.
255
+ */
256
+ captureOrientation?: string;
257
+ /** Non-fatal quality signals (empty when none). */
258
+ warnings: CaptureWarning[];
259
+ }
260
+ | {
261
+ ok: false;
262
+ /** Which capture path failed. */
263
+ type: 'photo' | 'panorama';
264
+ /** The classified failure (same object handed to `onError`). */
265
+ error: CameraError;
266
+ /** Any warnings gathered before the failure (usually empty). */
267
+ warnings: CaptureWarning[];
175
268
  };
176
269
 
177
270
 
271
+ /**
272
+ * The success-panorama variant of {@link CameraCaptureResult} — the exact
273
+ * shape stashed for the crop editor and re-emitted (with adjusted dims) once
274
+ * the user crops. Narrowed so the crop-confirm spread keeps `uri`/`width`/
275
+ * `height`/`ok` without a cast.
276
+ */
277
+ export type PanoramaCaptureResult = Extract<
278
+ CameraCaptureResult,
279
+ { ok: true; type: 'panorama' }
280
+ >;
281
+
282
+
178
283
  /**
179
284
  * Errors surfaced via `onError`. Classified codes so consumers can
180
285
  * branch on the kind of failure (toast vs retry vs report).
@@ -188,6 +293,13 @@ export type CameraErrorCode =
188
293
  | 'STITCH_NEED_MORE_IMGS'
189
294
  | 'STITCH_HOMOGRAPHY_FAIL'
190
295
  | 'STITCH_CAMERA_PARAMS_FAIL'
296
+ /**
297
+ * v0.16 — the native post-stitch validator rejected the output: the
298
+ * panorama came out disjoint / fragmented / wildly mis-proportioned
299
+ * (frames didn't connect into one coherent image). Recoverable by
300
+ * re-capturing, so it carries "try again" copy.
301
+ */
302
+ | 'STITCH_LOW_QUALITY'
191
303
  | 'STITCH_OOM'
192
304
  | 'OUTPUT_WRITE_FAILED'
193
305
  /**
@@ -254,6 +366,19 @@ export interface CameraProps {
254
366
  defaultRegistrationResolMP?: number;
255
367
  /** Forward-looking — see above. */
256
368
  defaultSeamEstimationResolMP?: number;
369
+ /**
370
+ * v0.16 — pass the whole stitcher config as a JSON object instead of the
371
+ * individual `default*` props above (canonical field names: `warperType` /
372
+ * `blenderType` / `seamFinderType` / `stitchMode` /
373
+ * `enableMaxInscribedRectCrop`). Partial; any field set here wins over the
374
+ * matching flat prop. Runtime ⚙️-panel edits still override at capture time. */
375
+ stitcher?: PanoramaPropOverrides['stitcher'];
376
+ /**
377
+ * v0.16 — pass the whole frame-gate config as a JSON object (canonical field
378
+ * names: `mode` / `maxKeyframes` / `overlapThreshold` / `maxKeyframeIntervalMs`
379
+ * / `flow`). Partial; `flow` is deep-merged. Wins over the flat `default*`
380
+ * props. */
381
+ frameSelection?: PanoramaPropOverrides['frameSelection'];
257
382
 
258
383
  // ── Inscribed-rect crop (v0.15) ───────────────────────────────────
259
384
  /**
@@ -355,12 +480,19 @@ export interface CameraProps {
355
480
  * decisively cancels the capture (`incremental.cancel()`) and
356
481
  * surfaces `OrientationDriftModal` to explain what happened.
357
482
  *
483
+ * v0.16 adds `'lateral-drift'`: the user moved the phone perpendicular to
484
+ * the pan arrow before enough frames were captured to stitch. Rather than
485
+ * finalize into a misleading "need more images" error, the SDK abandons the
486
+ * capture and surfaces the `LateralMotionModal` with "follow the arrow"
487
+ * copy. (A lateral drift AFTER enough frames still finalizes what was
488
+ * captured and fires `onCapture` with a `LATERAL_DRIFT_FINALIZE` warning.)
489
+ *
358
490
  * Hosts use this callback to clean up their own state (e.g., reset
359
491
  * a wizard step, log telemetry, surface their own retry UX in
360
492
  * addition to the SDK's built-in modal). No `onCapture` will fire
361
493
  * for an abandoned capture.
362
494
  */
363
- onCaptureAbandoned?: (reason: 'orientation-drift') => void;
495
+ onCaptureAbandoned?: (reason: 'orientation-drift' | 'lateral-drift') => void;
364
496
 
365
497
  /**
366
498
  * v0.13.0 — flash (torch) state. Controlled-or-uncontrolled.
@@ -618,6 +750,92 @@ export interface CameraProps {
618
750
  * CHANGELOG.)
619
751
  */
620
752
  frameProcessor?: ReadonlyFrameProcessor | DrawableFrameProcessor;
753
+
754
+ // ── Panorama GUIDANCE (feature/pano-ux-guidance) ──────────────────
755
+ /**
756
+ * Which device holds the non-AR panorama capture accepts.
757
+ *
758
+ * - `'vertical'` (DEFAULT) — LANDSCAPE-only, top→bottom pan. Starting a
759
+ * panorama in portrait is BLOCKED behind the rotate-to-landscape
760
+ * prompt (item 2); the capture starts the instant they rotate to
761
+ * landscape (either way up).
762
+ * - `'horizontal'` — PORTRAIT-only, left→right pan. Starting in
763
+ * landscape is BLOCKED behind the rotate-to-portrait prompt; capture
764
+ * starts on rotating to portrait (either way up).
765
+ * - `'both'` — landscape OR portrait; the rotate gate never fires, the
766
+ * user captures in whichever hold they're already in.
767
+ *
768
+ * **BREAKING (since the previous release accepted both holds ungated):**
769
+ * the default is now `'vertical'`. Hosts that want left→right (portrait)
770
+ * panoramas use `panMode='horizontal'` (portrait-only) or `'both'`. See
771
+ * CHANGELOG.
772
+ */
773
+ panMode?: PanMode;
774
+
775
+ /**
776
+ * Master switch for the in-capture pan-guidance surfaces (rotate
777
+ * prompt, pan how-to overlay, too-fast pill, blinking countdown).
778
+ * Default `true`. Set `false` to suppress all of them (the lateral-
779
+ * drift FINALIZE behaviour and the crop preview are governed by their
780
+ * own props, not this flag).
781
+ */
782
+ panGuidance?: boolean;
783
+
784
+ /**
785
+ * Optional hard recording-TIME ceiling for a non-AR panorama, in
786
+ * milliseconds, used as a SAFETY cap alongside the primary keyframe-count
787
+ * auto-stop. The default capture now finalizes when the configured
788
+ * keyframe count is reached (see the frame counter HUD), so this is `0`
789
+ * (disabled) by default. Set it to a positive value to ALSO cap the
790
+ * recording by wall-clock time; when > 0 a blinking countdown (item 5)
791
+ * shows the seconds remaining and the capture auto-finalizes at 0.
792
+ *
793
+ * v0.16 — default changed `9000` → `0` (time cap is now opt-in; the
794
+ * keyframe-count stop is the default UX).
795
+ */
796
+ maxPanDurationMs?: number;
797
+
798
+ /**
799
+ * Gyro rate (rad/s) above which the pan is flagged "moving too fast"
800
+ * (item 4 — the transient amber pill). Optional; forwards to
801
+ * `usePanMotion`'s `warnMaxRadPerSec` (default 1.0 rad/s there).
802
+ */
803
+ panTooFastThreshold?: number;
804
+
805
+ /**
806
+ * Cross-pan (lateral) drift budget in CENTIMETRES (item 6). Once the
807
+ * operator's integrated sideways translation exceeds this for the
808
+ * hook's grace window, the capture FINALIZES what was captured and a
809
+ * one-button popup explains why. Default `5`. `0` disables the
810
+ * lateral-drift stop entirely.
811
+ */
812
+ lateralBudgetCm?: number;
813
+
814
+ /**
815
+ * Show the draggable-quad crop editor after a panorama finalizes, BEFORE
816
+ * emitting it via `onCapture`. Default `false`. When `true`, the user
817
+ * drags 4 corners over the stitched result; confirming crops in place
818
+ * (perspective-rectify when the quad isn't axis-aligned), "Use original"
819
+ * emits the un-cropped panorama, "Retake" discards it. Takes precedence
820
+ * over {@link showPreview}.
821
+ */
822
+ rectCrop?: boolean;
823
+
824
+ /**
825
+ * Show a plain review screen after a panorama finalizes — the stitched
826
+ * image with [Retake] / [Confirm] and NO crop box. Default `false`.
827
+ * Ignored when {@link rectCrop} is on (the crop editor is itself the
828
+ * preview). With both off, `onCapture` fires immediately with no UI.
829
+ */
830
+ showPreview?: boolean;
831
+
832
+ /**
833
+ * Copy overrides for every guidance string (rotate prompt, pan hint,
834
+ * too-fast warning, lateral-stop popup, crop buttons). Partial —
835
+ * unspecified keys fall back to {@link DEFAULT_GUIDANCE_COPY}. Hosts
836
+ * localise or re-word the whole guidance surface in one place here.
837
+ */
838
+ guidanceCopy?: Partial<GuidanceCopy>;
621
839
  }
622
840
 
623
841
 
@@ -879,7 +1097,17 @@ function extractPanoramaOverrides(props: CameraProps): PanoramaPropOverrides {
879
1097
  defaultKeyframeMaxCount: props.defaultKeyframeMaxCount,
880
1098
  defaultKeyframeOverlapThreshold: props.defaultKeyframeOverlapThreshold,
881
1099
  defaultMaxKeyframeIntervalMs: props.defaultMaxKeyframeIntervalMs,
882
- maxInscribedRectCrop: props.maxInscribedRectCrop,
1100
+ // v0.16 — JSON-object form (wins over the flat default* props above).
1101
+ stitcher: props.stitcher,
1102
+ frameSelection: props.frameSelection,
1103
+ // Item 2 — the interactive crop editor OWNS cropping, so when it's on we
1104
+ // force the native auto-crop OFF: the editor needs the full un-cropped
1105
+ // panorama (black borders included) so the user can drag the inscribed-
1106
+ // rect seed outward to keep more content. Letting the native auto-crop
1107
+ // pre-trim would leave nothing to adjust.
1108
+ maxInscribedRectCrop: props.rectCrop
1109
+ ? false
1110
+ : props.maxInscribedRectCrop,
883
1111
  };
884
1112
  }
885
1113
 
@@ -930,8 +1158,28 @@ export function Camera(props: CameraProps): React.JSX.Element {
930
1158
  onCapturePreviewClose,
931
1159
  frameProcessor: hostFrameProcessor,
932
1160
  engine = 'batch-keyframe',
1161
+ // ── Panorama GUIDANCE (feature/pano-ux-guidance) ──────────────
1162
+ panMode = 'vertical',
1163
+ panGuidance = true,
1164
+ maxPanDurationMs = 0,
1165
+ panTooFastThreshold,
1166
+ lateralBudgetCm = 4,
1167
+ rectCrop = false,
1168
+ showPreview = false,
1169
+ guidanceCopy,
933
1170
  } = props;
934
1171
 
1172
+ // Derived guidance state. The landscape-only gate decision itself is
1173
+ // computed inline at the call sites via `shouldGateForPanMode(panMode,
1174
+ // deviceOrientation)` (the rotate gate + resume effect), so there's no
1175
+ // standalone `modeAOnly` flag to keep in sync. `guidanceCopyResolved`
1176
+ // merges the host override onto the defaults once per `guidanceCopy`
1177
+ // identity.
1178
+ const guidanceCopyResolved = useMemo(
1179
+ () => mergeGuidanceCopy(guidanceCopy),
1180
+ [guidanceCopy],
1181
+ );
1182
+
935
1183
  // v0.13.2 — capture-source constraint (default 'both'). Derives which
936
1184
  // sources are permitted; `captureSources` overrides any conflicting
937
1185
  // `defaultCaptureSource`. Used to constrain the initial AR preference
@@ -978,6 +1226,44 @@ export function Camera(props: CameraProps): React.JSX.Element {
978
1226
  null,
979
1227
  );
980
1228
  const [incrementalState, setIncrementalState] = useState<IncrementalState | null>(null);
1229
+ // ── Panorama GUIDANCE state (feature/pano-ux-guidance) ──────────
1230
+ // Item 1/2 — a hold that was BLOCKED on the rotate-to-landscape gate.
1231
+ // Latches when the user holds the shutter in portrait under Mode A;
1232
+ // an effect below resumes the capture the instant they rotate.
1233
+ const [pendingPanStart, setPendingPanStart] = useState(false);
1234
+ // Item 6 — the latched lateral-drift popup (capture already finalized
1235
+ // by the time it shows).
1236
+ const [lateralStopVisible, setLateralStopVisible] = useState(false);
1237
+ // Item 6 — true when the lateral stop happened with too few frames to
1238
+ // stitch (the user veered off almost immediately): the popup then shows
1239
+ // the "follow the arrow" copy and the capture is abandoned, not finalized.
1240
+ const [lateralWrongDirection, setLateralWrongDirection] = useState(false);
1241
+ // Item 3 — the brief pan how-to overlay shown at the start of a
1242
+ // recording, auto-dismissed after a timeout.
1243
+ const [howToVisible, setHowToVisible] = useState(false);
1244
+ // Item 5 — a ~250 ms ticking clock that drives the displayed countdown
1245
+ // seconds while recording (the authoritative auto-stop is a setTimeout,
1246
+ // not this tick).
1247
+ const [nowTick, setNowTick] = useState(() => Date.now());
1248
+ // Item 7 — a finalized panorama awaiting the user's crop decision.
1249
+ // Non-null mounts the RectCropPreview; `captureResultObj` is the exact
1250
+ // CameraCaptureResult we'd otherwise have emitted, stashed so cancel /
1251
+ // crop-confirm can emit it (possibly with cropped dims) afterwards.
1252
+ const [cropPending, setCropPending] = useState<{
1253
+ uri: string;
1254
+ width: number;
1255
+ height: number;
1256
+ captureResultObj: PanoramaCaptureResult;
1257
+ /**
1258
+ * Item 2 — max-inscribed-rect seed for the crop quad (image-pixel
1259
+ * coords). Undefined → RectCropPreview falls back to its 8 %-inset
1260
+ * default seed (native module absent / inscribed-rect call failed).
1261
+ */
1262
+ initialRect?: ImageRect;
1263
+ /** Warnings to surface as a banner on the crop editor. */
1264
+ warnings: CaptureWarning[];
1265
+ } | null>(null);
1266
+
981
1267
  // 2026-05-22 (audit F9 + F3) — debug stitch-stats toast. Hook
982
1268
  // exposes an imperative API; we fire `showResult(finalizeResult)`
983
1269
  // on every successful finalize when settings.debug is on (gated
@@ -1023,6 +1309,18 @@ export function Camera(props: CameraProps): React.JSX.Element {
1023
1309
  arPreference && lens !== '0.5x' && !isARSupportProbed;
1024
1310
  const deviceOrientation = useDeviceOrientation();
1025
1311
 
1312
+ // ── Panorama GUIDANCE — shared motion signals (item 3/4/6) ──────
1313
+ // One gyro + one accelerometer subscription, live only while a non-AR
1314
+ // capture is recording. Feeds the too-fast pill (`panSpeedBucket`)
1315
+ // and the lateral-drift FINALIZE (`lateralExceeded`). `panTooFast-
1316
+ // Threshold` (if set) tunes the 'warn'→'bad' boundary; `lateralBudget-
1317
+ // Cm` tunes the drift latch (0 disables the latch in the hook).
1318
+ const panMotion = usePanMotion({
1319
+ active: statusPhase === 'recording' && isNonAR,
1320
+ warnMaxRadPerSec: panTooFastThreshold,
1321
+ lateralBudgetCm,
1322
+ });
1323
+
1026
1324
  // v0.13.1 — counter-rotation for control CONTENT (AR toggle, lens
1027
1325
  // pill, flash icon, thumbnails) so their labels read upright relative
1028
1326
  // to gravity when the device is held landscape under a PORTRAIT-LOCKED
@@ -1210,9 +1508,46 @@ export function Camera(props: CameraProps): React.JSX.Element {
1210
1508
  // The imperative pattern (start on hold-start, stop on hold-end)
1211
1509
  // avoids the re-render churn entirely.
1212
1510
  const fpDriver = useFrameProcessorDriver();
1213
- // Safety: stop the driver if the component unmounts mid-recording.
1511
+ // Safety: stop the driver AND clear the pan-duration auto-finalize
1512
+ // timer if the component unmounts mid-recording (item 5 exit path #4).
1214
1513
  // eslint-disable-next-line react-hooks/exhaustive-deps
1215
- useEffect(() => () => { fpDriver.stop(); }, []);
1514
+ useEffect(() => () => { fpDriver.stop(); clearPanTimer(); }, []);
1515
+
1516
+ // ── Panorama GUIDANCE — auto-finalize timer + ref bridges ───────
1517
+ // The 9 s pan-duration ceiling (item 5) is an authoritative
1518
+ // `setTimeout` (not derived from the cosmetic countdown tick). Stored
1519
+ // in a ref so the start logic can schedule it and ALL four capture-exit
1520
+ // paths (manual release, drift cancel, lateral stop, unmount) clear it.
1521
+ const panDurationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
1522
+ null,
1523
+ );
1524
+ const clearPanTimer = useCallback(() => {
1525
+ if (panDurationTimerRef.current) {
1526
+ clearTimeout(panDurationTimerRef.current);
1527
+ panDurationTimerRef.current = null;
1528
+ }
1529
+ }, []);
1530
+ // `handleHoldEnd` / `startCapture` are defined further down but are
1531
+ // referenced from effects + timers declared above them. Refs break
1532
+ // the declaration-order + circular-useCallback-dep cycle: each is
1533
+ // kept current by a commit-phase effect, and callers invoke via the
1534
+ // ref (`handleHoldEndRef.current?.()`) — mirroring how the drift
1535
+ // effect avoids putting these in its dep array.
1536
+ const handleHoldEndRef = useRef<(() => void) | null>(null);
1537
+ const startCaptureRef = useRef<(() => void) | null>(null);
1538
+ // Synchronous re-entrancy latch for the finalize path: the auto-finalize
1539
+ // timer and a manual release can both pass the async statusPhase guard in
1540
+ // the same tick before React commits 'stitching'.
1541
+ const finalizingRef = useRef(false);
1542
+ // Item 6 — set by the lateral-drift effect just before it calls
1543
+ // handleHoldEnd, so the finalize knows this stop was a sideways-drift
1544
+ // auto-stop and can attach the LATERAL_DRIFT_FINALIZE warning. Consumed
1545
+ // (reset) at the start of handleHoldEnd so it never leaks to the next pan.
1546
+ const lateralFinalizeRef = useRef(false);
1547
+ // Item 4 — latched true if the pan ever exceeded the recommended pace (the
1548
+ // live "too fast" cue fired) during the capture, so the finalize attaches a
1549
+ // HIGH_PAN_SPEED warning. Reset at capture start; consumed at finalize.
1550
+ const fastPanRef = useRef(false);
1216
1551
 
1217
1552
  // ── v0.12.0 — Orientation drift detection + auto-abandon ────────
1218
1553
  //
@@ -1230,10 +1565,20 @@ export function Camera(props: CameraProps): React.JSX.Element {
1230
1565
  // the engine spec.
1231
1566
  const drift = useOrientationDrift(statusPhase === 'recording');
1232
1567
  const [driftModalDismissed, setDriftModalDismissed] = useState(false);
1233
- // Reset the dismissed flag when a new capture starts (or any non-
1234
- // recording state) so the next drift event surfaces a fresh modal.
1568
+ // Reset the modal flags when a new capture STARTS (statusPhase
1569
+ // 'recording'), NOT when one stops. v0.16 fix: the old "any non-recording
1570
+ // state" condition cleared `lateralStopVisible` the instant a lateral stop
1571
+ // moved statusPhase out of 'recording' — so the popup was hidden before it
1572
+ // could ever show (the user only saw the downstream error). Clearing on
1573
+ // capture START instead lets the lateral / drift popups persist after the
1574
+ // stop until the user dismisses them, while still giving the next capture
1575
+ // a clean slate.
1235
1576
  useEffect(() => {
1236
- if (statusPhase !== 'recording') setDriftModalDismissed(false);
1577
+ if (statusPhase === 'recording') {
1578
+ setDriftModalDismissed(false);
1579
+ setLateralStopVisible(false);
1580
+ setLateralWrongDirection(false);
1581
+ }
1237
1582
  }, [statusPhase]);
1238
1583
 
1239
1584
  useEffect(() => {
@@ -1251,6 +1596,9 @@ export function Camera(props: CameraProps): React.JSX.Element {
1251
1596
  // through `onError` — abandonment must succeed even if the engine
1252
1597
  // is in a weird state.
1253
1598
  void (async () => {
1599
+ // item 5 exit path #2 — kill the pan-duration auto-finalize timer
1600
+ // so it can't fire into an already-cancelled capture.
1601
+ clearPanTimer();
1254
1602
  fpDriver.stop();
1255
1603
  try {
1256
1604
  await incremental.cancel();
@@ -1449,7 +1797,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
1449
1797
  height = result.height;
1450
1798
  }
1451
1799
 
1452
- onCapture?.({ type: 'photo', uri, width, height });
1800
+ onCapture?.({ ok: true, type: 'photo', uri, width, height, warnings: [] });
1453
1801
  } catch (err) {
1454
1802
  const e = err instanceof CameraError
1455
1803
  ? err
@@ -1458,24 +1806,24 @@ export function Camera(props: CameraProps): React.JSX.Element {
1458
1806
  err instanceof Error ? err.message : String(err),
1459
1807
  err,
1460
1808
  );
1809
+ // v0.16 — failures now reach `onCapture` too (ok:false), with
1810
+ // `onError` kept as a mirror so existing handlers keep working.
1461
1811
  onError?.(e);
1812
+ onCapture?.({ ok: false, type: 'photo', error: e, warnings: [] });
1462
1813
  }
1463
1814
  }, [enablePhotoMode, isAR, capture, outputDir, onCapture, onError]);
1464
1815
 
1465
- const handleHoldStart = useCallback(async () => {
1466
- if (!enablePanoramaMode) return;
1467
- if (!incrementalStitcherIsAvailable()) {
1468
- onError?.(
1469
- new CameraError(
1470
- 'PANORAMA_START_FAILED',
1471
- 'Native incremental stitcher module not available',
1472
- ),
1473
- );
1474
- return;
1475
- }
1816
+ // ── startCapture the "actually start recording" logic ─────────
1817
+ // Extracted from `handleHoldStart` so the rotate-to-landscape gate
1818
+ // (item 1/2) can DEFER it: a portrait Mode-A hold latches
1819
+ // `pendingPanStart` and an effect calls this once the user rotates.
1820
+ // Identical behaviour to the inline body it replaced — the only new
1821
+ // line is the item-5 auto-finalize timer scheduled right after
1822
+ // `setRecordingStartedAt`.
1823
+ const startCapture = useCallback(async () => {
1476
1824
  try {
1477
1825
  // 2026-05-23 (race fix) — synchronously clear thumbnails +
1478
- // engine state at the top of handleHoldStart, BEFORE awaiting
1826
+ // engine state at the top of startCapture, BEFORE awaiting
1479
1827
  // incremental.start(). In the previous effect-based design
1480
1828
  // the GL thread could ingest an AR frame during the await
1481
1829
  // window and add to thumbnails BEFORE React's
@@ -1485,8 +1833,21 @@ export function Camera(props: CameraProps): React.JSX.Element {
1485
1833
  // an empty array and accumulates from there.
1486
1834
  setBatchKeyframeThumbnails([]);
1487
1835
  setIncrementalState(null);
1836
+ // Item 4 — fresh capture: clear the latched too-fast flag.
1837
+ fastPanRef.current = false;
1488
1838
  setStatusPhase('recording');
1489
1839
  setRecordingStartedAt(Date.now());
1840
+ // Item 5 — schedule the hard-ceiling auto-finalize. Fires
1841
+ // `handleHoldEnd` (via ref to dodge the circular useCallback dep),
1842
+ // which finalizes what's captured — the FINALIZE-on-zero product
1843
+ // decision. Cleared on every other capture-exit path. Skipped
1844
+ // when the feature is disabled (`maxPanDurationMs <= 0`).
1845
+ clearPanTimer();
1846
+ if (maxPanDurationMs > 0) {
1847
+ panDurationTimerRef.current = setTimeout(() => {
1848
+ handleHoldEndRef.current?.();
1849
+ }, maxPanDurationMs);
1850
+ }
1490
1851
  const orientationRotation: 0 | 90 | 180 | 270 =
1491
1852
  deviceOrientation === 'portrait' ? 90
1492
1853
  : deviceOrientation === 'portrait-upside-down' ? 270
@@ -1545,6 +1906,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
1545
1906
  }
1546
1907
  } catch (err) {
1547
1908
  setStatusPhase('idle');
1909
+ clearPanTimer();
1548
1910
  onError?.(
1549
1911
  new CameraError(
1550
1912
  'PANORAMA_START_FAILED',
@@ -1554,7 +1916,6 @@ export function Camera(props: CameraProps): React.JSX.Element {
1554
1916
  );
1555
1917
  }
1556
1918
  }, [
1557
- enablePanoramaMode,
1558
1919
  incremental,
1559
1920
  isNonAR,
1560
1921
  deviceOrientation,
@@ -1564,15 +1925,100 @@ export function Camera(props: CameraProps): React.JSX.Element {
1564
1925
  fpDriver,
1565
1926
  engine,
1566
1927
  onError,
1928
+ maxPanDurationMs,
1929
+ clearPanTimer,
1930
+ ]);
1931
+
1932
+ // Keep the ref current so the auto-finalize timer + the rotate-resume
1933
+ // effect can invoke the latest `startCapture` without taking it as a
1934
+ // dep (which would re-run them on every recording-driven re-render).
1935
+ useEffect(() => {
1936
+ startCaptureRef.current = () => { void startCapture(); };
1937
+ });
1938
+
1939
+ // ── handleHoldStart — early guards + the rotate-to-landscape gate ─
1940
+ // The "actually start" body lives in `startCapture`; this wrapper only
1941
+ // decides WHETHER to start now. Under Mode A in portrait it latches
1942
+ // `pendingPanStart` instead (item 1/2) and the resume effect below
1943
+ // starts the capture once the user rotates to landscape.
1944
+ const handleHoldStart = useCallback(() => {
1945
+ if (!enablePanoramaMode) return;
1946
+ if (!incrementalStitcherIsAvailable()) {
1947
+ onError?.(
1948
+ new CameraError(
1949
+ 'PANORAMA_START_FAILED',
1950
+ 'Native incremental stitcher module not available',
1951
+ ),
1952
+ );
1953
+ return;
1954
+ }
1955
+ if (shouldGateForPanMode(panMode, deviceOrientation)) {
1956
+ // Mode-A + portrait — block the start and show the rotate prompt.
1957
+ // The resume effect picks this up the instant the device rotates.
1958
+ setPendingPanStart(true);
1959
+ return;
1960
+ }
1961
+ void startCapture();
1962
+ }, [
1963
+ enablePanoramaMode,
1964
+ onError,
1965
+ panMode,
1966
+ deviceOrientation,
1967
+ startCapture,
1567
1968
  ]);
1568
1969
 
1970
+ // ── Rotate-to-landscape resume (item 1/2) ───────────────────────
1971
+ // When a hold was gated (`pendingPanStart`) and the user has since
1972
+ // rotated so the gate no longer fires, start the deferred capture.
1973
+ // Invoked through `startCaptureRef` (kept current above) so this
1974
+ // effect's deps don't churn on every recording re-render.
1975
+ useEffect(() => {
1976
+ if (pendingPanStart && !shouldGateForPanMode(panMode, deviceOrientation)) {
1977
+ setPendingPanStart(false);
1978
+ startCaptureRef.current?.();
1979
+ }
1980
+ }, [pendingPanStart, deviceOrientation, panMode]);
1981
+
1569
1982
  const handleHoldEnd = useCallback(async () => {
1983
+ // Item 5 exit path #1 — always kill the auto-finalize timer on
1984
+ // release, even on the early-return below (it's idempotent).
1985
+ clearPanTimer();
1986
+ // Item 1/2 — if the shutter is released while a rotate-gated hold is
1987
+ // pending (user let go before rotating to landscape), abandon the
1988
+ // deferred start rather than starting on the next rotation.
1989
+ if (pendingPanStart) setPendingPanStart(false);
1570
1990
  if (statusPhase !== 'recording') return;
1991
+ // Re-entrancy latch — close the timer-vs-release double-finalize window
1992
+ // synchronously so incremental.finalize()/onCapture fire exactly once.
1993
+ if (finalizingRef.current) return;
1994
+ finalizingRef.current = true;
1995
+ // Consume the lateral-drift flag once, here, so it's cleared on BOTH the
1996
+ // success and failure paths and never leaks into the next capture.
1997
+ const wasLateralFinalize = lateralFinalizeRef.current;
1998
+ lateralFinalizeRef.current = false;
1999
+ const wasFastPan = fastPanRef.current;
2000
+ fastPanRef.current = false;
2001
+ if (__DEV__) {
2002
+ // eslint-disable-next-line no-console
2003
+ console.log(
2004
+ `[capture] finalize: wasFastPan=${wasFastPan} `
2005
+ + `wasLateralFinalize=${wasLateralFinalize}`,
2006
+ );
2007
+ }
1571
2008
  setStatusPhase('stitching');
1572
2009
  // Stop pumping new frames before finalizing so the engine isn't
1573
2010
  // racing the final cv::Stitcher pass against late-arriving
1574
2011
  // keyframes. No-op in AR mode (the driver was never started).
1575
2012
  fpDriver.stop();
2013
+ // V12.14.8 restore (regressed in the SDK camera extraction): the
2014
+ // render below unmounts <CameraView>/<ARCameraView> while
2015
+ // statusPhase==='stitching'. Yield a macrotask so React commits that
2016
+ // unmount and vision-camera tears down the AVCaptureSession + preview
2017
+ // buffers (~150-250 MB) BEFORE the memory-heavy stitch runs. Without
2018
+ // it the live-camera footprint and the stitch peak coexist and
2019
+ // jetsam (iOS) / lmkd (Android) OOM-kill the app — the exact
2020
+ // WatchdogTermination crash V12.14.8 originally fixed.
2021
+ await new Promise((resolve) => setTimeout(resolve, 50));
1576
2022
  try {
1577
2023
  // Compose the panorama output path: host-controlled if
1578
2024
  // `outputDir` is set, else the lib's canonical capture dir
@@ -1595,6 +2041,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
1595
2041
  90, // default JPEG quality
1596
2042
  deviceOrientation,
1597
2043
  imuTotalTranslationM,
2044
+ lens, // 2026-06-16 — explicit '1x'|'0.5x' for the high-level warper tree
1598
2045
  );
1599
2046
  if (
1600
2047
  typeof result.framesRequested === 'number'
@@ -1607,7 +2054,20 @@ export function Camera(props: CameraProps): React.JSX.Element {
1607
2054
  });
1608
2055
  }
1609
2056
 
1610
- onCapture?.({
2057
+ // v0.16 — non-fatal quality signals attached to the result + (when
2058
+ // the crop editor shows) the crop banner. LOW_FRAME_UTILIZATION when
2059
+ // <70 % of captured frames survived; LATERAL_DRIFT_FINALIZE when item-6
2060
+ // stopped this capture early.
2061
+ const warnings = buildCaptureWarnings({
2062
+ framesRequested: result.framesRequested,
2063
+ framesIncluded: result.framesIncluded,
2064
+ lateralFinalize: wasLateralFinalize,
2065
+ highPanSpeed: wasFastPan,
2066
+ copy: captureWarningCopyFrom(guidanceCopyResolved),
2067
+ });
2068
+
2069
+ const captureResultObj: PanoramaCaptureResult = {
2070
+ ok: true,
1611
2071
  type: 'panorama',
1612
2072
  // Native finalize() returns a bare `/data/.../foo.jpg` path;
1613
2073
  // normalise to `file://` for Android <Image>.
@@ -1621,7 +2081,56 @@ export function Camera(props: CameraProps): React.JSX.Element {
1621
2081
  finalConfidenceThresh: result.finalConfidenceThresh ?? -1,
1622
2082
  durationMs: Date.now() - (recordingStartedAt ?? Date.now()),
1623
2083
  stitchModeResolved: result.stitchModeResolved,
1624
- });
2084
+ rRadians: result.rRadians,
2085
+ tMeters: result.tMeters,
2086
+ decisionRatio: result.decisionRatio,
2087
+ debugSummary: result.debugSummary,
2088
+ keyframePaths: result.batchKeyframePaths,
2089
+ captureOrientation: result.captureOrientation,
2090
+ warnings,
2091
+ };
2092
+ // When the crop editor OR a plain preview is enabled AND the panorama
2093
+ // has valid intrinsic dims, defer `onCapture`: stash the result and
2094
+ // mount RectCropPreview (crop mode when `rectCrop`, preview-only when
2095
+ // just `showPreview`). The modal's confirm / use-original / retake
2096
+ // decision emits the final result. Otherwise emit immediately.
2097
+ if (
2098
+ (rectCrop || showPreview)
2099
+ && result.width > 0
2100
+ && result.height > 0
2101
+ ) {
2102
+ // Crop mode only — seed the quad from the max-inscribed rectangle of
2103
+ // the (un-cropped) panorama so the editor opens on the tightest clean
2104
+ // rectangle, not a blind 8 % inset. Best-effort: an absent native
2105
+ // module / decode failure falls back to the default seed. Skipped in
2106
+ // preview-only mode (no quad to seed).
2107
+ let initialRect: ImageRect | undefined;
2108
+ if (rectCrop) {
2109
+ try {
2110
+ const inscribed = await computeInscribedRect(captureResultObj.uri);
2111
+ if (inscribed && inscribed.width > 0 && inscribed.height > 0) {
2112
+ initialRect = {
2113
+ x: inscribed.x,
2114
+ y: inscribed.y,
2115
+ width: inscribed.width,
2116
+ height: inscribed.height,
2117
+ };
2118
+ }
2119
+ } catch {
2120
+ // No seed — RectCropPreview uses its default inset.
2121
+ }
2122
+ }
2123
+ setCropPending({
2124
+ uri: captureResultObj.uri,
2125
+ width: result.width,
2126
+ height: result.height,
2127
+ captureResultObj,
2128
+ initialRect,
2129
+ warnings,
2130
+ });
2131
+ } else {
2132
+ onCapture?.(captureResultObj);
2133
+ }
1625
2134
  // 2026-05-22 (audit F9) — fire the debug stitch-stats toast on
1626
2135
  // every successful finalize when settings.debug is on. Shows
1627
2136
  // the leaveBiggestComponent retry telemetry + resolved mode so
@@ -1631,18 +2140,29 @@ export function Camera(props: CameraProps): React.JSX.Element {
1631
2140
  }
1632
2141
  } catch (err) {
1633
2142
  const message = err instanceof Error ? err.message : String(err);
1634
- const code: CameraErrorCode =
1635
- // Insufficient overlap surfaces two ways: cv::Stitcher's
1636
- // ERR_NEED_MORE_IMGS ("need more images") and the manual
1637
- // pipeline's "0 valid pairwise matches / frames may not overlap
1638
- // enough" both are the same recoverable "pan more slowly" case.
1639
- /need more images|pairwise match|overlap enough/i.test(message) ? 'STITCH_NEED_MORE_IMGS'
1640
- : /homography/i.test(message) ? 'STITCH_HOMOGRAPHY_FAIL'
1641
- : /camera params/i.test(message) ? 'STITCH_CAMERA_PARAMS_FAIL'
1642
- : /out of memory|oom/i.test(message) ? 'STITCH_OOM'
1643
- : 'PANORAMA_FINALIZE_FAILED';
1644
- onError?.(new CameraError(code, message, err));
2143
+ // Classify the raw native failure string → typed code. The chain
2144
+ // lives in classifyStitchError() (the load-bearing C++↔JS contract,
2145
+ // unit-tested against the actual native strings) so a future reword
2146
+ // of a cpp throw can't silently drop the "pan more slowly" path.
2147
+ const code = classifyStitchError(message);
2148
+ const error = new CameraError(code, message, err);
2149
+ // v0.16 surface the failure on BOTH callbacks: `onError` (unchanged
2150
+ // mirror) and `onCapture` (ok:false) so a host has one place to learn
2151
+ // the outcome. A lateral-drift stop that then failed to stitch still
2152
+ // reports that cause via the warning.
2153
+ onError?.(error);
2154
+ onCapture?.({
2155
+ ok: false,
2156
+ type: 'panorama',
2157
+ error,
2158
+ warnings: buildCaptureWarnings({
2159
+ lateralFinalize: wasLateralFinalize,
2160
+ highPanSpeed: wasFastPan,
2161
+ copy: captureWarningCopyFrom(guidanceCopyResolved),
2162
+ }),
2163
+ });
1645
2164
  } finally {
2165
+ finalizingRef.current = false;
1646
2166
  setStatusPhase('idle');
1647
2167
  setRecordingStartedAt(null);
1648
2168
  }
@@ -1667,8 +2187,164 @@ export function Camera(props: CameraProps): React.JSX.Element {
1667
2187
  isNonAR,
1668
2188
  imuGate,
1669
2189
  stitchToast,
2190
+ // 2026-06-16 — the finalize passes `lens` (the high-level warper tree's zoom
2191
+ // signal); without it here the closure would send a STALE lens if the user
2192
+ // switched 1x↔0.5x after this callback was last memoized.
2193
+ lens,
2194
+ // feature/pano-ux-guidance — the release also tears down the
2195
+ // pan-duration timer + a pending rotate-gate, and decides whether to
2196
+ // route the result through the crop editor.
2197
+ clearPanTimer,
2198
+ pendingPanStart,
2199
+ rectCrop,
2200
+ showPreview,
1670
2201
  ]);
1671
2202
 
2203
+ // Keep `handleHoldEndRef` current so the auto-finalize timer + the
2204
+ // lateral-drift effect invoke the latest `handleHoldEnd` without
2205
+ // adding it as a dep (it changes identity on every recording tick).
2206
+ useEffect(() => {
2207
+ handleHoldEndRef.current = () => { void handleHoldEnd(); };
2208
+ });
2209
+
2210
+ // ── Item 6 — lateral drift → FINALIZE + popup ───────────────────
2211
+ // Mirrors the orientation-drift effect, but FINALIZES the capture
2212
+ // (keeps what was stitched) rather than cancelling it: clear the
2213
+ // pan-duration timer, latch the popup, then call handleHoldEnd via
2214
+ // its ref. Gated off when the budget is disabled (`<= 0`).
2215
+ useEffect(() => {
2216
+ if (
2217
+ !panMotion.lateralExceeded
2218
+ || statusPhase !== 'recording'
2219
+ || lateralBudgetCm <= 0
2220
+ ) {
2221
+ return;
2222
+ }
2223
+ clearPanTimer();
2224
+
2225
+ // #3 — if the user veered off before enough frames were captured to
2226
+ // stitch, finalizing would fail with a misleading "need more images"
2227
+ // error. Instead ABANDON the capture (no stitch → no error) and show
2228
+ // the "follow the arrow" popup. Otherwise FINALIZE what was captured
2229
+ // (a usable partial pano) and show the "keep it straight" popup.
2230
+ const MIN_STITCHABLE_KEYFRAMES = 2;
2231
+ if (acceptedKeyframeCount < MIN_STITCHABLE_KEYFRAMES) {
2232
+ setLateralWrongDirection(true);
2233
+ setLateralStopVisible(true);
2234
+ void (async () => {
2235
+ fpDriver.stop();
2236
+ try {
2237
+ await incremental.cancel();
2238
+ } catch {
2239
+ // best-effort — abandonment must succeed even in a weird state.
2240
+ } finally {
2241
+ setStatusPhase('idle');
2242
+ setRecordingStartedAt(null);
2243
+ onCaptureAbandoned?.('lateral-drift');
2244
+ }
2245
+ })();
2246
+ return;
2247
+ }
2248
+
2249
+ setLateralWrongDirection(false);
2250
+ setLateralStopVisible(true);
2251
+ // Mark this finalize as lateral-drift-triggered so handleHoldEnd attaches
2252
+ // the LATERAL_DRIFT_FINALIZE warning to the result.
2253
+ lateralFinalizeRef.current = true;
2254
+ handleHoldEndRef.current?.();
2255
+ // Deps mirror the drift effect: re-run when the latch trips or the
2256
+ // recording state changes. Other reads are stable setters / refs.
2257
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2258
+ }, [panMotion.lateralExceeded, statusPhase, lateralBudgetCm]);
2259
+
2260
+ // ── Item 7 — auto-finalize when the configured keyframe count is hit ─
2261
+ // The engine caps accepted keyframes at `keyframeMaxCount`; once it
2262
+ // reports that many, no more frames will be accepted, so stop + stitch
2263
+ // (same finalize path as releasing the shutter). `handleHoldEnd`'s
2264
+ // re-entrancy latch makes this idempotent vs. a manual release in the
2265
+ // same tick. This is the PRIMARY auto-stop (the time cap is opt-in).
2266
+ const keyframeMaxCount = settings.frameSelection.maxKeyframes;
2267
+ const acceptedKeyframeCount = incrementalState?.acceptedCount ?? 0;
2268
+ // Item 4 — speed cue routed into the REC banner/border colour (green→red).
2269
+ // Gated on panGuidance so opting out keeps the banner calm/green.
2270
+ const recordingTooFast =
2271
+ panGuidance && panMotion.panSpeedBucket !== 'good';
2272
+ // Latch the too-fast flag for the HIGH_PAN_SPEED warning (shown on the crop
2273
+ // editor + returned in onCapture.warnings). Latches when the live cue is
2274
+ // active OR — per the user's request — when a KEYFRAME the stitch will use
2275
+ // is accepted while the pan is too fast (so a captured frame was actually
2276
+ // taken at speed). Depending on panSpeedBucket + acceptedKeyframeCount
2277
+ // directly (not just the derived `recordingTooFast`) makes the effect run
2278
+ // on every bucket / keyframe change, so a brief red window can't be missed.
2279
+ // Reset at capture start.
2280
+ const prevAcceptedForSpeedRef = useRef(0);
2281
+ useEffect(() => {
2282
+ if (statusPhase !== 'recording') {
2283
+ prevAcceptedForSpeedRef.current = acceptedKeyframeCount;
2284
+ return;
2285
+ }
2286
+ const newKeyframe = acceptedKeyframeCount > prevAcceptedForSpeedRef.current;
2287
+ prevAcceptedForSpeedRef.current = acceptedKeyframeCount;
2288
+ if (recordingTooFast) {
2289
+ if (__DEV__ && !fastPanRef.current) {
2290
+ // eslint-disable-next-line no-console
2291
+ console.log(
2292
+ `[panMotion] HIGH_PAN_SPEED latched (bucket=`
2293
+ + `${panMotion.panSpeedBucket} acceptedCount=${acceptedKeyframeCount}`
2294
+ + `${newKeyframe ? ' on a keyframe' : ''})`,
2295
+ );
2296
+ }
2297
+ fastPanRef.current = true;
2298
+ }
2299
+ }, [
2300
+ statusPhase,
2301
+ recordingTooFast,
2302
+ acceptedKeyframeCount,
2303
+ panMotion.panSpeedBucket,
2304
+ ]);
2305
+ useEffect(() => {
2306
+ if (
2307
+ statusPhase === 'recording'
2308
+ && keyframeMaxCount > 0
2309
+ && acceptedKeyframeCount >= keyframeMaxCount
2310
+ ) {
2311
+ handleHoldEndRef.current?.();
2312
+ }
2313
+ }, [statusPhase, keyframeMaxCount, acceptedKeyframeCount]);
2314
+
2315
+ // ── Item 3 — brief pan how-to overlay at recording start ────────
2316
+ // Show the how-to GIF + direction arrow for a short window when a
2317
+ // recording begins, then auto-fade. The component never self-times;
2318
+ // this effect owns the lifecycle.
2319
+ useEffect(() => {
2320
+ if (statusPhase !== 'recording') {
2321
+ setHowToVisible(false);
2322
+ return;
2323
+ }
2324
+ setHowToVisible(true);
2325
+ const t = setTimeout(() => setHowToVisible(false), 2500);
2326
+ return () => clearTimeout(t);
2327
+ }, [statusPhase]);
2328
+
2329
+ // ── Item 5 — cosmetic countdown tick ────────────────────────────
2330
+ // While recording, bump `nowTick` ~4×/s so `countdownSecondsFrom`
2331
+ // recomputes the displayed whole-seconds. The authoritative auto-stop
2332
+ // is the `panDurationTimerRef` setTimeout, NOT this interval. Skipped
2333
+ // when the countdown feature is disabled (`maxPanDurationMs <= 0`).
2334
+ useEffect(() => {
2335
+ if (statusPhase !== 'recording' || maxPanDurationMs <= 0) return;
2336
+ const id = setInterval(() => setNowTick(Date.now()), 250);
2337
+ return () => clearInterval(id);
2338
+ }, [statusPhase, maxPanDurationMs]);
2339
+
2340
+ // Whole seconds remaining for the countdown overlay (item 5). Pure
2341
+ // helper; clamps to [0, round(maxPanDurationMs/1000)].
2342
+ const countdownSeconds = countdownSecondsFrom(
2343
+ recordingStartedAt,
2344
+ nowTick,
2345
+ maxPanDurationMs,
2346
+ );
2347
+
1672
2348
  // ── Lens / AR-toggle handlers ───────────────────────────────────
1673
2349
  const handleLensChange = useCallback((next: CameraLens) => {
1674
2350
  setLens(next);
@@ -1729,9 +2405,16 @@ export function Camera(props: CameraProps): React.JSX.Element {
1729
2405
  only ONE camera component is alive at a time; matches the
1730
2406
  monorepo's working pattern and avoids the Camera2-in-use
1731
2407
  conflict that "always mount both" caused on Android. */}
1732
- {inFlightTransition || arSupportPending ? (
2408
+ {cameraShouldUnmount(inFlightTransition, arSupportPending, statusPhase) ? (
2409
+ // statusPhase==='stitching' UNMOUNTS the camera so vision-camera
2410
+ // frees the AVCaptureSession + preview buffers during the stitch
2411
+ // (V12.14.8 OOM fix). The CaptureStatusOverlay renders the
2412
+ // "Stitching…" state on top, so no placeholder label is needed
2413
+ // in that case — only for the camera-switch transition.
1733
2414
  <View style={[StyleSheet.absoluteFill, styles.transitionPlaceholder]}>
1734
- <Text style={styles.transitionLabel}>Switching camera…</Text>
2415
+ {statusPhase === 'stitching' ? null : (
2416
+ <Text style={styles.transitionLabel}>Switching camera…</Text>
2417
+ )}
1735
2418
  </View>
1736
2419
  ) : isAR ? (
1737
2420
  <ARCameraView
@@ -1785,11 +2468,21 @@ export function Camera(props: CameraProps): React.JSX.Element {
1785
2468
  />
1786
2469
  )}
1787
2470
 
1788
- {/* REC banner + record border (during recording / stitching). */}
2471
+ {/* REC banner + record border (during recording / stitching). v0.16
2472
+ — the banner + border are GREEN normally and turn RED (with the
2473
+ too-fast copy) when the pan is too fast: the single speed cue that
2474
+ replaced the always-red border + separate amber pill. */}
1789
2475
  <CaptureStatusOverlay
1790
2476
  phase={statusPhase}
1791
2477
  topInset={insets.top}
1792
2478
  recordingStartedAt={recordingStartedAt ?? undefined}
2479
+ tooFast={recordingTooFast}
2480
+ recordingMessage={
2481
+ recordingTooFast
2482
+ ? guidanceCopyResolved.tooFast
2483
+ : guidanceCopyResolved.statusRecording
2484
+ }
2485
+ stitchingMessage={guidanceCopyResolved.statusStitching}
1793
2486
  />
1794
2487
 
1795
2488
  {/* v0.13.1 — the built-in pan-guidance overlays
@@ -1799,6 +2492,35 @@ export function Camera(props: CameraProps): React.JSX.Element {
1799
2492
  renders them and the `panGuide` / `panoramaGuidance` props
1800
2493
  are gone. Re-wire here if a host need resurfaces. */}
1801
2494
 
2495
+ {/* feature/pano-ux-guidance — in-capture guidance overlays.
2496
+ All gated on `panGuidance`; each renders null when not
2497
+ visible so they can mount unconditionally. */}
2498
+ {/* Item 6 — live keyframe counter "k / n" (top-centre). The primary
2499
+ capture HUD; the capture auto-finalizes when k reaches n. */}
2500
+ <CaptureFrameCounterOverlay
2501
+ visible={statusPhase === 'recording' && panGuidance}
2502
+ framesCaptured={acceptedKeyframeCount}
2503
+ framesMax={keyframeMaxCount}
2504
+ orientation={deviceOrientation}
2505
+ />
2506
+ {/* Item 5 — optional blinking time countdown (top corner), shown only
2507
+ when the host opts into a wall-clock cap via maxPanDurationMs. */}
2508
+ <CaptureCountdownOverlay
2509
+ visible={statusPhase === 'recording' && panGuidance && maxPanDurationMs > 0}
2510
+ secondsRemaining={countdownSeconds}
2511
+ orientation={deviceOrientation}
2512
+ />
2513
+ {/* Item 3 — brief pan how-to graphic + direction arrow. */}
2514
+ <PanHowToOverlay
2515
+ visible={statusPhase === 'recording' && panGuidance && howToVisible}
2516
+ orientation={deviceOrientation}
2517
+ />
2518
+ {/* Item 4 — "moving too fast" feedback is no longer a separate pill.
2519
+ v0.16 — it's consolidated into the CaptureStatusOverlay banner +
2520
+ border above, which turn from GREEN to RED (with the too-fast copy)
2521
+ when `recordingTooFast` — one calm cue instead of an always-red
2522
+ border plus a second amber pill. */}
2523
+
1802
2524
  {/*
1803
2525
  2026-05-22 (audit F9 + F3) — debug UI suite, all gated on
1804
2526
  settings.debug. Mounts in <Camera> automatically; Layer-2
@@ -2016,6 +2738,24 @@ export function Camera(props: CameraProps): React.JSX.Element {
2016
2738
  onClose={() => setSettingsModalVisible(false)}
2017
2739
  />
2018
2740
 
2741
+ {/* Item 1/2 — rotate prompt. Shown while a gated hold is blocked on
2742
+ the user rotating to the target orientation (landscape for
2743
+ panMode='vertical', portrait for 'horizontal'). The resume effect
2744
+ starts the deferred capture the instant they do. */}
2745
+ {/* The rotate prompt is the ONLY feedback for the mode gate, so it is
2746
+ NOT gated on `panGuidance` — otherwise panGuidance={false} +
2747
+ a gated panMode would block the hold with a dead, silent shutter.
2748
+ `panGuidance` governs only the cosmetic in-capture overlays. */}
2749
+ <RotateToLandscapePrompt
2750
+ visible={pendingPanStart}
2751
+ target={gateTargetOrientation(panMode) ?? 'landscape'}
2752
+ copy={
2753
+ gateTargetOrientation(panMode) === 'portrait'
2754
+ ? guidanceCopyResolved.rotateToPortrait
2755
+ : guidanceCopyResolved.rotateToLandscape
2756
+ }
2757
+ />
2758
+
2019
2759
  {/* v0.12.0 — Orientation drift modal. Shows AFTER the SDK has
2020
2760
  auto-abandoned the capture (the useEffect above stops the
2021
2761
  engine + transitions to idle + fires onCaptureAbandoned).
@@ -2029,6 +2769,29 @@ export function Camera(props: CameraProps): React.JSX.Element {
2029
2769
  onAcknowledge={() => setDriftModalDismissed(true)}
2030
2770
  />
2031
2771
 
2772
+ {/* Item 6 — lateral-drift popup. Latched true by the lateral
2773
+ effect AFTER it finalizes the capture; informational only,
2774
+ dismiss just clears the latch so the next capture starts
2775
+ fresh. */}
2776
+ <LateralMotionModal
2777
+ visible={lateralStopVisible}
2778
+ title={
2779
+ lateralWrongDirection
2780
+ ? guidanceCopyResolved.lateralWrongDirectionTitle
2781
+ : guidanceCopyResolved.lateralStopTitle
2782
+ }
2783
+ body={
2784
+ lateralWrongDirection
2785
+ ? guidanceCopyResolved.lateralWrongDirectionBody
2786
+ : guidanceCopyResolved.lateralStopBody
2787
+ }
2788
+ dismissLabel={guidanceCopyResolved.lateralStopDismiss}
2789
+ onDismiss={() => {
2790
+ setLateralStopVisible(false);
2791
+ setLateralWrongDirection(false);
2792
+ }}
2793
+ />
2794
+
2032
2795
  {/* v0.13.0 — built-in post-stitch / tap-to-preview modal.
2033
2796
  Visible when the host supplies `capturePreview`. When
2034
2797
  undefined the modal stays hidden (visible=false) so it
@@ -2043,6 +2806,114 @@ export function Camera(props: CameraProps): React.JSX.Element {
2043
2806
  actions={capturePreviewActions}
2044
2807
  onClose={onCapturePreviewClose ?? noop}
2045
2808
  />
2809
+
2810
+ {/* Post-capture review surface, shown after a panorama finalizes when
2811
+ `rectCrop` OR `showPreview` is on (handleHoldEnd stashed the pending
2812
+ result instead of emitting it). `showCropControls={rectCrop}`:
2813
+ - crop mode (rectCrop) → draggable quad seeded on the max-inscribed
2814
+ rectangle; any capture warnings banner on top.
2815
+ - Use original → emit the original, un-cropped panorama.
2816
+ - Crop → cropQuad (perspective-rectify when the quad
2817
+ isn't axis-aligned) overwrites the file in place; emit with
2818
+ the rectified dims + a cache-busting query so <Image> reloads
2819
+ it. On any crop failure, fall back to the original.
2820
+ - preview-only mode (showPreview, no rectCrop) → bare image with
2821
+ [Retake]/[Confirm]; Confirm routes through onUseOriginal. */}
2822
+ <RectCropPreview
2823
+ // Remount per capture so the dragged-quad + layout state re-seed to
2824
+ // the new image (RectCropPreview seeds its quad once via useState).
2825
+ key={cropPending?.uri ?? 'crop'}
2826
+ visible={cropPending != null}
2827
+ imageUri={cropPending?.uri ?? ''}
2828
+ imageWidth={cropPending?.width ?? 0}
2829
+ imageHeight={cropPending?.height ?? 0}
2830
+ initialRect={cropPending?.initialRect}
2831
+ warnings={cropPending?.warnings.map((w) => w.message) ?? []}
2832
+ showCropControls={rectCrop}
2833
+ topInset={insets.top}
2834
+ bottomInset={insets.bottom}
2835
+ copy={guidanceCopyResolved}
2836
+ // Carry the live memory pill onto the preview too (same settings.debug
2837
+ // gate as the camera), so the operator can watch the RSS spike when the
2838
+ // on-demand high-level re-stitch fires.
2839
+ showMemoryPill={settings.debug}
2840
+ // DEV overlay — show the stitcher's runtime choices (pipeline / warper /
2841
+ // route / seam / blend) + score / frames / size for this output, so the
2842
+ // operator can see HOW it was built. __DEV__ only.
2843
+ debugInfo={
2844
+ __DEV__ && cropPending
2845
+ ? buildStitchDebugInfo(cropPending.captureResultObj)
2846
+ : undefined
2847
+ }
2848
+ onUseOriginal={(altUri) => {
2849
+ if (cropPending) {
2850
+ // altUri set → the user picked the alt (manual) pipeline's output
2851
+ // in the A/B toggle; emit THAT image (cache-bust for <Image>).
2852
+ onCapture?.(
2853
+ altUri
2854
+ ? {
2855
+ ...cropPending.captureResultObj,
2856
+ uri: `${altUri}?t=${Date.now()}`,
2857
+ }
2858
+ : cropPending.captureResultObj,
2859
+ );
2860
+ }
2861
+ setCropPending(null);
2862
+ }}
2863
+ onRetake={() => {
2864
+ // Discard this capture entirely — no onCapture — and return to
2865
+ // the live camera (statusPhase is already 'idle' post-finalize).
2866
+ setCropPending(null);
2867
+ }}
2868
+ onConfirm={async ({ quad, perspective }) => {
2869
+ if (!cropPending) return;
2870
+ const pending = cropPending;
2871
+ // perspective=true → rectify the dragged quad to an upright
2872
+ // rectangle (cropToQuad). perspective=false (the user dragged a
2873
+ // ~rectangular quad) → crop to the quad's axis-aligned bounding box
2874
+ // — a plain crop, no warp.
2875
+ const xs = quad.map((p) => p.x);
2876
+ const ys = quad.map((p) => p.y);
2877
+ const cropPoints: Quad = perspective
2878
+ ? quad
2879
+ : [
2880
+ { x: Math.min(...xs), y: Math.min(...ys) },
2881
+ { x: Math.max(...xs), y: Math.min(...ys) },
2882
+ { x: Math.max(...xs), y: Math.max(...ys) },
2883
+ { x: Math.min(...xs), y: Math.max(...ys) },
2884
+ ];
2885
+ try {
2886
+ // cropQuad takes a BARE path; the stashed uri is a file://
2887
+ // URI. Overwrites in place (pass the same path).
2888
+ const cropped = await cropQuad(
2889
+ toBareFilePath(pending.uri),
2890
+ cropPoints,
2891
+ undefined,
2892
+ { quality: 90 },
2893
+ );
2894
+ onCapture?.({
2895
+ ...pending.captureResultObj,
2896
+ // Cache-bust so <Image> reloads the overwritten file.
2897
+ uri: `${toFileUri(cropped.outputPath)}?t=${Date.now()}`,
2898
+ width: cropped.width,
2899
+ height: cropped.height,
2900
+ });
2901
+ } catch (err) {
2902
+ onError?.(
2903
+ new CameraError(
2904
+ 'OUTPUT_WRITE_FAILED',
2905
+ err instanceof Error ? err.message : String(err),
2906
+ err,
2907
+ ),
2908
+ );
2909
+ // Fall back to the un-cropped panorama so the capture isn't
2910
+ // lost on a crop failure.
2911
+ onCapture?.(pending.captureResultObj);
2912
+ } finally {
2913
+ setCropPending(null);
2914
+ }
2915
+ }}
2916
+ />
2046
2917
  </View>
2047
2918
  );
2048
2919
  }
@@ -2120,6 +2991,33 @@ export const _homeIndicatorEdgeForTests = homeIndicatorEdge;
2120
2991
  export const _isSideEdgeForTests = isSideEdge;
2121
2992
 
2122
2993
 
2994
+ /**
2995
+ * cameraShouldUnmount — whether the live camera (<CameraView> /
2996
+ * <ARCameraView>) should be UNMOUNTED (replaced by the placeholder) this
2997
+ * render rather than mounted.
2998
+ *
2999
+ * True while a camera-switch transition or AR-support probe is in flight,
3000
+ * OR during the stitch (statusPhase==='stitching'). The stitching case is
3001
+ * the V12.14.8 OOM fix: unmounting frees vision-camera's AVCaptureSession +
3002
+ * preview buffers (~150-250 MB) BEFORE the memory-heavy stitch, so the
3003
+ * live-camera footprint and the stitch peak never coexist and jetsam (iOS)
3004
+ * / lmkd (Android) don't OOM-kill the app.
3005
+ *
3006
+ * Pure + exported for test — the lib's jest config can't mount <Camera>,
3007
+ * so this boolean is the unit-testable core of the OOM render gate.
3008
+ */
3009
+ function cameraShouldUnmount(
3010
+ inFlightTransition: boolean,
3011
+ arSupportPending: boolean,
3012
+ statusPhase: CaptureStatusPhase,
3013
+ ): boolean {
3014
+ return inFlightTransition || arSupportPending || statusPhase === 'stitching';
3015
+ }
3016
+
3017
+ /** @internal test-only — see `cameraShouldUnmount`. */
3018
+ export const _cameraShouldUnmountForTests = cameraShouldUnmount;
3019
+
3020
+
2123
3021
  /**
2124
3022
  * v0.12.0 — bottom-controls outer container positioning. Anchors
2125
3023
  * to the home-indicator JS edge with the appropriate flex direction