react-native-image-stitcher 0.15.1 → 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 +147 -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 +62 -5
  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 +75 -5
  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
@@ -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,50 @@ export type CameraCaptureResult =
172
223
  * cv::Stitcher at finalize).
173
224
  */
174
225
  stitchModeResolved?: 'panorama' | 'scans';
226
+ /**
227
+ * 2026-06-14 (DEV overlay) — semicolon-separated `key=value` trace of the
228
+ * stitcher's runtime choices (pipe/warp/route/seam/blend) for this
229
+ * output. Shown on the preview in __DEV__. iOS only for now.
230
+ */
231
+ debugSummary?: string;
232
+ /**
233
+ * 2026-06-15 (iOS) — keyframe JPEG paths used for this stitch, so the
234
+ * preview can re-stitch them on demand via `refinePanorama` (the
235
+ * high-level tab). iOS only; undefined elsewhere.
236
+ */
237
+ keyframePaths?: string[];
238
+ /**
239
+ * 2026-06-15 (iOS) — orientation this stitch baked in. The on-demand
240
+ * high-level re-stitch passes it back so it matches the manual output's
241
+ * rotation (not the raw sensor landscape). iOS only.
242
+ */
243
+ captureOrientation?: string;
244
+ /** Non-fatal quality signals (empty when none). */
245
+ warnings: CaptureWarning[];
246
+ }
247
+ | {
248
+ ok: false;
249
+ /** Which capture path failed. */
250
+ type: 'photo' | 'panorama';
251
+ /** The classified failure (same object handed to `onError`). */
252
+ error: CameraError;
253
+ /** Any warnings gathered before the failure (usually empty). */
254
+ warnings: CaptureWarning[];
175
255
  };
176
256
 
177
257
 
258
+ /**
259
+ * The success-panorama variant of {@link CameraCaptureResult} — the exact
260
+ * shape stashed for the crop editor and re-emitted (with adjusted dims) once
261
+ * the user crops. Narrowed so the crop-confirm spread keeps `uri`/`width`/
262
+ * `height`/`ok` without a cast.
263
+ */
264
+ export type PanoramaCaptureResult = Extract<
265
+ CameraCaptureResult,
266
+ { ok: true; type: 'panorama' }
267
+ >;
268
+
269
+
178
270
  /**
179
271
  * Errors surfaced via `onError`. Classified codes so consumers can
180
272
  * branch on the kind of failure (toast vs retry vs report).
@@ -188,6 +280,13 @@ export type CameraErrorCode =
188
280
  | 'STITCH_NEED_MORE_IMGS'
189
281
  | 'STITCH_HOMOGRAPHY_FAIL'
190
282
  | 'STITCH_CAMERA_PARAMS_FAIL'
283
+ /**
284
+ * v0.16 — the native post-stitch validator rejected the output: the
285
+ * panorama came out disjoint / fragmented / wildly mis-proportioned
286
+ * (frames didn't connect into one coherent image). Recoverable by
287
+ * re-capturing, so it carries "try again" copy.
288
+ */
289
+ | 'STITCH_LOW_QUALITY'
191
290
  | 'STITCH_OOM'
192
291
  | 'OUTPUT_WRITE_FAILED'
193
292
  /**
@@ -254,6 +353,19 @@ export interface CameraProps {
254
353
  defaultRegistrationResolMP?: number;
255
354
  /** Forward-looking — see above. */
256
355
  defaultSeamEstimationResolMP?: number;
356
+ /**
357
+ * v0.16 — pass the whole stitcher config as a JSON object instead of the
358
+ * individual `default*` props above (canonical field names: `warperType` /
359
+ * `blenderType` / `seamFinderType` / `stitchMode` /
360
+ * `enableMaxInscribedRectCrop`). Partial; any field set here wins over the
361
+ * matching flat prop. Runtime ⚙️-panel edits still override at capture time. */
362
+ stitcher?: PanoramaPropOverrides['stitcher'];
363
+ /**
364
+ * v0.16 — pass the whole frame-gate config as a JSON object (canonical field
365
+ * names: `mode` / `maxKeyframes` / `overlapThreshold` / `maxKeyframeIntervalMs`
366
+ * / `flow`). Partial; `flow` is deep-merged. Wins over the flat `default*`
367
+ * props. */
368
+ frameSelection?: PanoramaPropOverrides['frameSelection'];
257
369
 
258
370
  // ── Inscribed-rect crop (v0.15) ───────────────────────────────────
259
371
  /**
@@ -355,12 +467,19 @@ export interface CameraProps {
355
467
  * decisively cancels the capture (`incremental.cancel()`) and
356
468
  * surfaces `OrientationDriftModal` to explain what happened.
357
469
  *
470
+ * v0.16 adds `'lateral-drift'`: the user moved the phone perpendicular to
471
+ * the pan arrow before enough frames were captured to stitch. Rather than
472
+ * finalize into a misleading "need more images" error, the SDK abandons the
473
+ * capture and surfaces the `LateralMotionModal` with "follow the arrow"
474
+ * copy. (A lateral drift AFTER enough frames still finalizes what was
475
+ * captured and fires `onCapture` with a `LATERAL_DRIFT_FINALIZE` warning.)
476
+ *
358
477
  * Hosts use this callback to clean up their own state (e.g., reset
359
478
  * a wizard step, log telemetry, surface their own retry UX in
360
479
  * addition to the SDK's built-in modal). No `onCapture` will fire
361
480
  * for an abandoned capture.
362
481
  */
363
- onCaptureAbandoned?: (reason: 'orientation-drift') => void;
482
+ onCaptureAbandoned?: (reason: 'orientation-drift' | 'lateral-drift') => void;
364
483
 
365
484
  /**
366
485
  * v0.13.0 — flash (torch) state. Controlled-or-uncontrolled.
@@ -618,6 +737,92 @@ export interface CameraProps {
618
737
  * CHANGELOG.)
619
738
  */
620
739
  frameProcessor?: ReadonlyFrameProcessor | DrawableFrameProcessor;
740
+
741
+ // ── Panorama GUIDANCE (feature/pano-ux-guidance) ──────────────────
742
+ /**
743
+ * Which device holds the non-AR panorama capture accepts.
744
+ *
745
+ * - `'vertical'` (DEFAULT) — LANDSCAPE-only, top→bottom pan. Starting a
746
+ * panorama in portrait is BLOCKED behind the rotate-to-landscape
747
+ * prompt (item 2); the capture starts the instant they rotate to
748
+ * landscape (either way up).
749
+ * - `'horizontal'` — PORTRAIT-only, left→right pan. Starting in
750
+ * landscape is BLOCKED behind the rotate-to-portrait prompt; capture
751
+ * starts on rotating to portrait (either way up).
752
+ * - `'both'` — landscape OR portrait; the rotate gate never fires, the
753
+ * user captures in whichever hold they're already in.
754
+ *
755
+ * **BREAKING (since the previous release accepted both holds ungated):**
756
+ * the default is now `'vertical'`. Hosts that want left→right (portrait)
757
+ * panoramas use `panMode='horizontal'` (portrait-only) or `'both'`. See
758
+ * CHANGELOG.
759
+ */
760
+ panMode?: PanMode;
761
+
762
+ /**
763
+ * Master switch for the in-capture pan-guidance surfaces (rotate
764
+ * prompt, pan how-to overlay, too-fast pill, blinking countdown).
765
+ * Default `true`. Set `false` to suppress all of them (the lateral-
766
+ * drift FINALIZE behaviour and the crop preview are governed by their
767
+ * own props, not this flag).
768
+ */
769
+ panGuidance?: boolean;
770
+
771
+ /**
772
+ * Optional hard recording-TIME ceiling for a non-AR panorama, in
773
+ * milliseconds, used as a SAFETY cap alongside the primary keyframe-count
774
+ * auto-stop. The default capture now finalizes when the configured
775
+ * keyframe count is reached (see the frame counter HUD), so this is `0`
776
+ * (disabled) by default. Set it to a positive value to ALSO cap the
777
+ * recording by wall-clock time; when > 0 a blinking countdown (item 5)
778
+ * shows the seconds remaining and the capture auto-finalizes at 0.
779
+ *
780
+ * v0.16 — default changed `9000` → `0` (time cap is now opt-in; the
781
+ * keyframe-count stop is the default UX).
782
+ */
783
+ maxPanDurationMs?: number;
784
+
785
+ /**
786
+ * Gyro rate (rad/s) above which the pan is flagged "moving too fast"
787
+ * (item 4 — the transient amber pill). Optional; forwards to
788
+ * `usePanMotion`'s `warnMaxRadPerSec` (default 1.0 rad/s there).
789
+ */
790
+ panTooFastThreshold?: number;
791
+
792
+ /**
793
+ * Cross-pan (lateral) drift budget in CENTIMETRES (item 6). Once the
794
+ * operator's integrated sideways translation exceeds this for the
795
+ * hook's grace window, the capture FINALIZES what was captured and a
796
+ * one-button popup explains why. Default `5`. `0` disables the
797
+ * lateral-drift stop entirely.
798
+ */
799
+ lateralBudgetCm?: number;
800
+
801
+ /**
802
+ * Show the draggable-quad crop editor after a panorama finalizes, BEFORE
803
+ * emitting it via `onCapture`. Default `false`. When `true`, the user
804
+ * drags 4 corners over the stitched result; confirming crops in place
805
+ * (perspective-rectify when the quad isn't axis-aligned), "Use original"
806
+ * emits the un-cropped panorama, "Retake" discards it. Takes precedence
807
+ * over {@link showPreview}.
808
+ */
809
+ rectCrop?: boolean;
810
+
811
+ /**
812
+ * Show a plain review screen after a panorama finalizes — the stitched
813
+ * image with [Retake] / [Confirm] and NO crop box. Default `false`.
814
+ * Ignored when {@link rectCrop} is on (the crop editor is itself the
815
+ * preview). With both off, `onCapture` fires immediately with no UI.
816
+ */
817
+ showPreview?: boolean;
818
+
819
+ /**
820
+ * Copy overrides for every guidance string (rotate prompt, pan hint,
821
+ * too-fast warning, lateral-stop popup, crop buttons). Partial —
822
+ * unspecified keys fall back to {@link DEFAULT_GUIDANCE_COPY}. Hosts
823
+ * localise or re-word the whole guidance surface in one place here.
824
+ */
825
+ guidanceCopy?: Partial<GuidanceCopy>;
621
826
  }
622
827
 
623
828
 
@@ -879,7 +1084,17 @@ function extractPanoramaOverrides(props: CameraProps): PanoramaPropOverrides {
879
1084
  defaultKeyframeMaxCount: props.defaultKeyframeMaxCount,
880
1085
  defaultKeyframeOverlapThreshold: props.defaultKeyframeOverlapThreshold,
881
1086
  defaultMaxKeyframeIntervalMs: props.defaultMaxKeyframeIntervalMs,
882
- maxInscribedRectCrop: props.maxInscribedRectCrop,
1087
+ // v0.16 — JSON-object form (wins over the flat default* props above).
1088
+ stitcher: props.stitcher,
1089
+ frameSelection: props.frameSelection,
1090
+ // Item 2 — the interactive crop editor OWNS cropping, so when it's on we
1091
+ // force the native auto-crop OFF: the editor needs the full un-cropped
1092
+ // panorama (black borders included) so the user can drag the inscribed-
1093
+ // rect seed outward to keep more content. Letting the native auto-crop
1094
+ // pre-trim would leave nothing to adjust.
1095
+ maxInscribedRectCrop: props.rectCrop
1096
+ ? false
1097
+ : props.maxInscribedRectCrop,
883
1098
  };
884
1099
  }
885
1100
 
@@ -930,8 +1145,28 @@ export function Camera(props: CameraProps): React.JSX.Element {
930
1145
  onCapturePreviewClose,
931
1146
  frameProcessor: hostFrameProcessor,
932
1147
  engine = 'batch-keyframe',
1148
+ // ── Panorama GUIDANCE (feature/pano-ux-guidance) ──────────────
1149
+ panMode = 'vertical',
1150
+ panGuidance = true,
1151
+ maxPanDurationMs = 0,
1152
+ panTooFastThreshold,
1153
+ lateralBudgetCm = 4,
1154
+ rectCrop = false,
1155
+ showPreview = false,
1156
+ guidanceCopy,
933
1157
  } = props;
934
1158
 
1159
+ // Derived guidance state. The landscape-only gate decision itself is
1160
+ // computed inline at the call sites via `shouldGateForPanMode(panMode,
1161
+ // deviceOrientation)` (the rotate gate + resume effect), so there's no
1162
+ // standalone `modeAOnly` flag to keep in sync. `guidanceCopyResolved`
1163
+ // merges the host override onto the defaults once per `guidanceCopy`
1164
+ // identity.
1165
+ const guidanceCopyResolved = useMemo(
1166
+ () => mergeGuidanceCopy(guidanceCopy),
1167
+ [guidanceCopy],
1168
+ );
1169
+
935
1170
  // v0.13.2 — capture-source constraint (default 'both'). Derives which
936
1171
  // sources are permitted; `captureSources` overrides any conflicting
937
1172
  // `defaultCaptureSource`. Used to constrain the initial AR preference
@@ -978,6 +1213,102 @@ export function Camera(props: CameraProps): React.JSX.Element {
978
1213
  null,
979
1214
  );
980
1215
  const [incrementalState, setIncrementalState] = useState<IncrementalState | null>(null);
1216
+ // ── Panorama GUIDANCE state (feature/pano-ux-guidance) ──────────
1217
+ // Item 1/2 — a hold that was BLOCKED on the rotate-to-landscape gate.
1218
+ // Latches when the user holds the shutter in portrait under Mode A;
1219
+ // an effect below resumes the capture the instant they rotate.
1220
+ const [pendingPanStart, setPendingPanStart] = useState(false);
1221
+ // Item 6 — the latched lateral-drift popup (capture already finalized
1222
+ // by the time it shows).
1223
+ const [lateralStopVisible, setLateralStopVisible] = useState(false);
1224
+ // Item 6 — true when the lateral stop happened with too few frames to
1225
+ // stitch (the user veered off almost immediately): the popup then shows
1226
+ // the "follow the arrow" copy and the capture is abandoned, not finalized.
1227
+ const [lateralWrongDirection, setLateralWrongDirection] = useState(false);
1228
+ // Item 3 — the brief pan how-to overlay shown at the start of a
1229
+ // recording, auto-dismissed after a timeout.
1230
+ const [howToVisible, setHowToVisible] = useState(false);
1231
+ // Item 5 — a ~250 ms ticking clock that drives the displayed countdown
1232
+ // seconds while recording (the authoritative auto-stop is a setTimeout,
1233
+ // not this tick).
1234
+ const [nowTick, setNowTick] = useState(() => Date.now());
1235
+ // Item 7 — a finalized panorama awaiting the user's crop decision.
1236
+ // Non-null mounts the RectCropPreview; `captureResultObj` is the exact
1237
+ // CameraCaptureResult we'd otherwise have emitted, stashed so cancel /
1238
+ // crop-confirm can emit it (possibly with cropped dims) afterwards.
1239
+ const [cropPending, setCropPending] = useState<{
1240
+ uri: string;
1241
+ width: number;
1242
+ height: number;
1243
+ captureResultObj: PanoramaCaptureResult;
1244
+ /**
1245
+ * Item 2 — max-inscribed-rect seed for the crop quad (image-pixel
1246
+ * coords). Undefined → RectCropPreview falls back to its 8 %-inset
1247
+ * default seed (native module absent / inscribed-rect call failed).
1248
+ */
1249
+ initialRect?: ImageRect;
1250
+ /** Warnings to surface as a banner on the crop editor. */
1251
+ warnings: CaptureWarning[];
1252
+ } | null>(null);
1253
+
1254
+ // 2026-06-15 — ON-DEMAND high-level preview. Manual is the default/eager
1255
+ // output; when the user switches to the "high-level" tab in the preview we
1256
+ // re-stitch the SAME captured keyframes through stock cv::Stitcher via
1257
+ // `refinePanorama` (useManualPipeline:false). Resolves with the high-level
1258
+ // JPEG's file:// uri AND its OWN DEV-overlay recipe (so the preview pill shows
1259
+ // the high-level recipe — pipe=highlevel;… — while that tab is viewed, not the
1260
+ // manual primary's recipe), or null when unavailable (no keyframe paths —
1261
+ // e.g. Android — or the stitch failed). Computed lazily so it costs nothing
1262
+ // unless the user actually asks for it.
1263
+ const requestHighLevelAlt = useCallback(async (): Promise<{
1264
+ uri: string;
1265
+ debugInfo: string;
1266
+ } | null> => {
1267
+ const pending = cropPending;
1268
+ const kf = pending?.captureResultObj.keyframePaths;
1269
+ if (!pending || !kf || kf.length < 2) return null;
1270
+ const native = getIncrementalNativeModule();
1271
+ if (!native) return null;
1272
+ const outputPath = `${toBareFilePath(pending.uri).replace(/\.jpg$/i, '')}-highlevel.jpg`;
1273
+ try {
1274
+ const r = await native.refinePanorama({
1275
+ framePaths: kf,
1276
+ outputPath,
1277
+ config: {
1278
+ useManualPipeline: false,
1279
+ warperType: 'spherical',
1280
+ stitchMode: 'panorama',
1281
+ // Match the manual output's rotation — without this the high-level
1282
+ // re-stitch bakes "portrait" (no rotation) and comes out sideways.
1283
+ captureOrientation: pending.captureResultObj.captureOrientation as
1284
+ | 'portrait'
1285
+ | 'portrait-upside-down'
1286
+ | 'landscape-left'
1287
+ | 'landscape-right'
1288
+ | undefined,
1289
+ },
1290
+ });
1291
+ // Plain file:// uri — the path is unique per capture and computed once, so
1292
+ // no cache-bust here (the accept handler adds one when emitting). The
1293
+ // DEV pill text is the HIGH-LEVEL stitch's own recipe (only the fields
1294
+ // IncrementalRefineResult carries; buildStitchDebugInfo tolerates the rest
1295
+ // being absent).
1296
+ return {
1297
+ uri: toFileUri(r.panoramaPath),
1298
+ debugInfo: buildStitchDebugInfo({
1299
+ debugSummary: r.debugSummary,
1300
+ finalConfidenceThresh: r.finalConfidenceThresh,
1301
+ framesIncluded: r.framesIncluded,
1302
+ framesRequested: r.framesRequested,
1303
+ width: r.width,
1304
+ height: r.height,
1305
+ }),
1306
+ };
1307
+ } catch {
1308
+ return null;
1309
+ }
1310
+ }, [cropPending]);
1311
+
981
1312
  // 2026-05-22 (audit F9 + F3) — debug stitch-stats toast. Hook
982
1313
  // exposes an imperative API; we fire `showResult(finalizeResult)`
983
1314
  // on every successful finalize when settings.debug is on (gated
@@ -1023,6 +1354,18 @@ export function Camera(props: CameraProps): React.JSX.Element {
1023
1354
  arPreference && lens !== '0.5x' && !isARSupportProbed;
1024
1355
  const deviceOrientation = useDeviceOrientation();
1025
1356
 
1357
+ // ── Panorama GUIDANCE — shared motion signals (item 3/4/6) ──────
1358
+ // One gyro + one accelerometer subscription, live only while a non-AR
1359
+ // capture is recording. Feeds the too-fast pill (`panSpeedBucket`)
1360
+ // and the lateral-drift FINALIZE (`lateralExceeded`). `panTooFast-
1361
+ // Threshold` (if set) tunes the 'warn'→'bad' boundary; `lateralBudget-
1362
+ // Cm` tunes the drift latch (0 disables the latch in the hook).
1363
+ const panMotion = usePanMotion({
1364
+ active: statusPhase === 'recording' && isNonAR,
1365
+ warnMaxRadPerSec: panTooFastThreshold,
1366
+ lateralBudgetCm,
1367
+ });
1368
+
1026
1369
  // v0.13.1 — counter-rotation for control CONTENT (AR toggle, lens
1027
1370
  // pill, flash icon, thumbnails) so their labels read upright relative
1028
1371
  // to gravity when the device is held landscape under a PORTRAIT-LOCKED
@@ -1210,9 +1553,46 @@ export function Camera(props: CameraProps): React.JSX.Element {
1210
1553
  // The imperative pattern (start on hold-start, stop on hold-end)
1211
1554
  // avoids the re-render churn entirely.
1212
1555
  const fpDriver = useFrameProcessorDriver();
1213
- // Safety: stop the driver if the component unmounts mid-recording.
1556
+ // Safety: stop the driver AND clear the pan-duration auto-finalize
1557
+ // timer if the component unmounts mid-recording (item 5 exit path #4).
1214
1558
  // eslint-disable-next-line react-hooks/exhaustive-deps
1215
- useEffect(() => () => { fpDriver.stop(); }, []);
1559
+ useEffect(() => () => { fpDriver.stop(); clearPanTimer(); }, []);
1560
+
1561
+ // ── Panorama GUIDANCE — auto-finalize timer + ref bridges ───────
1562
+ // The 9 s pan-duration ceiling (item 5) is an authoritative
1563
+ // `setTimeout` (not derived from the cosmetic countdown tick). Stored
1564
+ // in a ref so the start logic can schedule it and ALL four capture-exit
1565
+ // paths (manual release, drift cancel, lateral stop, unmount) clear it.
1566
+ const panDurationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
1567
+ null,
1568
+ );
1569
+ const clearPanTimer = useCallback(() => {
1570
+ if (panDurationTimerRef.current) {
1571
+ clearTimeout(panDurationTimerRef.current);
1572
+ panDurationTimerRef.current = null;
1573
+ }
1574
+ }, []);
1575
+ // `handleHoldEnd` / `startCapture` are defined further down but are
1576
+ // referenced from effects + timers declared above them. Refs break
1577
+ // the declaration-order + circular-useCallback-dep cycle: each is
1578
+ // kept current by a commit-phase effect, and callers invoke via the
1579
+ // ref (`handleHoldEndRef.current?.()`) — mirroring how the drift
1580
+ // effect avoids putting these in its dep array.
1581
+ const handleHoldEndRef = useRef<(() => void) | null>(null);
1582
+ const startCaptureRef = useRef<(() => void) | null>(null);
1583
+ // Synchronous re-entrancy latch for the finalize path: the auto-finalize
1584
+ // timer and a manual release can both pass the async statusPhase guard in
1585
+ // the same tick before React commits 'stitching'.
1586
+ const finalizingRef = useRef(false);
1587
+ // Item 6 — set by the lateral-drift effect just before it calls
1588
+ // handleHoldEnd, so the finalize knows this stop was a sideways-drift
1589
+ // auto-stop and can attach the LATERAL_DRIFT_FINALIZE warning. Consumed
1590
+ // (reset) at the start of handleHoldEnd so it never leaks to the next pan.
1591
+ const lateralFinalizeRef = useRef(false);
1592
+ // Item 4 — latched true if the pan ever exceeded the recommended pace (the
1593
+ // live "too fast" cue fired) during the capture, so the finalize attaches a
1594
+ // HIGH_PAN_SPEED warning. Reset at capture start; consumed at finalize.
1595
+ const fastPanRef = useRef(false);
1216
1596
 
1217
1597
  // ── v0.12.0 — Orientation drift detection + auto-abandon ────────
1218
1598
  //
@@ -1230,10 +1610,20 @@ export function Camera(props: CameraProps): React.JSX.Element {
1230
1610
  // the engine spec.
1231
1611
  const drift = useOrientationDrift(statusPhase === 'recording');
1232
1612
  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.
1613
+ // Reset the modal flags when a new capture STARTS (statusPhase
1614
+ // 'recording'), NOT when one stops. v0.16 fix: the old "any non-recording
1615
+ // state" condition cleared `lateralStopVisible` the instant a lateral stop
1616
+ // moved statusPhase out of 'recording' — so the popup was hidden before it
1617
+ // could ever show (the user only saw the downstream error). Clearing on
1618
+ // capture START instead lets the lateral / drift popups persist after the
1619
+ // stop until the user dismisses them, while still giving the next capture
1620
+ // a clean slate.
1235
1621
  useEffect(() => {
1236
- if (statusPhase !== 'recording') setDriftModalDismissed(false);
1622
+ if (statusPhase === 'recording') {
1623
+ setDriftModalDismissed(false);
1624
+ setLateralStopVisible(false);
1625
+ setLateralWrongDirection(false);
1626
+ }
1237
1627
  }, [statusPhase]);
1238
1628
 
1239
1629
  useEffect(() => {
@@ -1251,6 +1641,9 @@ export function Camera(props: CameraProps): React.JSX.Element {
1251
1641
  // through `onError` — abandonment must succeed even if the engine
1252
1642
  // is in a weird state.
1253
1643
  void (async () => {
1644
+ // item 5 exit path #2 — kill the pan-duration auto-finalize timer
1645
+ // so it can't fire into an already-cancelled capture.
1646
+ clearPanTimer();
1254
1647
  fpDriver.stop();
1255
1648
  try {
1256
1649
  await incremental.cancel();
@@ -1449,7 +1842,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
1449
1842
  height = result.height;
1450
1843
  }
1451
1844
 
1452
- onCapture?.({ type: 'photo', uri, width, height });
1845
+ onCapture?.({ ok: true, type: 'photo', uri, width, height, warnings: [] });
1453
1846
  } catch (err) {
1454
1847
  const e = err instanceof CameraError
1455
1848
  ? err
@@ -1458,24 +1851,24 @@ export function Camera(props: CameraProps): React.JSX.Element {
1458
1851
  err instanceof Error ? err.message : String(err),
1459
1852
  err,
1460
1853
  );
1854
+ // v0.16 — failures now reach `onCapture` too (ok:false), with
1855
+ // `onError` kept as a mirror so existing handlers keep working.
1461
1856
  onError?.(e);
1857
+ onCapture?.({ ok: false, type: 'photo', error: e, warnings: [] });
1462
1858
  }
1463
1859
  }, [enablePhotoMode, isAR, capture, outputDir, onCapture, onError]);
1464
1860
 
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
- }
1861
+ // ── startCapture the "actually start recording" logic ─────────
1862
+ // Extracted from `handleHoldStart` so the rotate-to-landscape gate
1863
+ // (item 1/2) can DEFER it: a portrait Mode-A hold latches
1864
+ // `pendingPanStart` and an effect calls this once the user rotates.
1865
+ // Identical behaviour to the inline body it replaced — the only new
1866
+ // line is the item-5 auto-finalize timer scheduled right after
1867
+ // `setRecordingStartedAt`.
1868
+ const startCapture = useCallback(async () => {
1476
1869
  try {
1477
1870
  // 2026-05-23 (race fix) — synchronously clear thumbnails +
1478
- // engine state at the top of handleHoldStart, BEFORE awaiting
1871
+ // engine state at the top of startCapture, BEFORE awaiting
1479
1872
  // incremental.start(). In the previous effect-based design
1480
1873
  // the GL thread could ingest an AR frame during the await
1481
1874
  // window and add to thumbnails BEFORE React's
@@ -1485,8 +1878,21 @@ export function Camera(props: CameraProps): React.JSX.Element {
1485
1878
  // an empty array and accumulates from there.
1486
1879
  setBatchKeyframeThumbnails([]);
1487
1880
  setIncrementalState(null);
1881
+ // Item 4 — fresh capture: clear the latched too-fast flag.
1882
+ fastPanRef.current = false;
1488
1883
  setStatusPhase('recording');
1489
1884
  setRecordingStartedAt(Date.now());
1885
+ // Item 5 — schedule the hard-ceiling auto-finalize. Fires
1886
+ // `handleHoldEnd` (via ref to dodge the circular useCallback dep),
1887
+ // which finalizes what's captured — the FINALIZE-on-zero product
1888
+ // decision. Cleared on every other capture-exit path. Skipped
1889
+ // when the feature is disabled (`maxPanDurationMs <= 0`).
1890
+ clearPanTimer();
1891
+ if (maxPanDurationMs > 0) {
1892
+ panDurationTimerRef.current = setTimeout(() => {
1893
+ handleHoldEndRef.current?.();
1894
+ }, maxPanDurationMs);
1895
+ }
1490
1896
  const orientationRotation: 0 | 90 | 180 | 270 =
1491
1897
  deviceOrientation === 'portrait' ? 90
1492
1898
  : deviceOrientation === 'portrait-upside-down' ? 270
@@ -1545,6 +1951,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
1545
1951
  }
1546
1952
  } catch (err) {
1547
1953
  setStatusPhase('idle');
1954
+ clearPanTimer();
1548
1955
  onError?.(
1549
1956
  new CameraError(
1550
1957
  'PANORAMA_START_FAILED',
@@ -1554,7 +1961,6 @@ export function Camera(props: CameraProps): React.JSX.Element {
1554
1961
  );
1555
1962
  }
1556
1963
  }, [
1557
- enablePanoramaMode,
1558
1964
  incremental,
1559
1965
  isNonAR,
1560
1966
  deviceOrientation,
@@ -1564,15 +1970,100 @@ export function Camera(props: CameraProps): React.JSX.Element {
1564
1970
  fpDriver,
1565
1971
  engine,
1566
1972
  onError,
1973
+ maxPanDurationMs,
1974
+ clearPanTimer,
1975
+ ]);
1976
+
1977
+ // Keep the ref current so the auto-finalize timer + the rotate-resume
1978
+ // effect can invoke the latest `startCapture` without taking it as a
1979
+ // dep (which would re-run them on every recording-driven re-render).
1980
+ useEffect(() => {
1981
+ startCaptureRef.current = () => { void startCapture(); };
1982
+ });
1983
+
1984
+ // ── handleHoldStart — early guards + the rotate-to-landscape gate ─
1985
+ // The "actually start" body lives in `startCapture`; this wrapper only
1986
+ // decides WHETHER to start now. Under Mode A in portrait it latches
1987
+ // `pendingPanStart` instead (item 1/2) and the resume effect below
1988
+ // starts the capture once the user rotates to landscape.
1989
+ const handleHoldStart = useCallback(() => {
1990
+ if (!enablePanoramaMode) return;
1991
+ if (!incrementalStitcherIsAvailable()) {
1992
+ onError?.(
1993
+ new CameraError(
1994
+ 'PANORAMA_START_FAILED',
1995
+ 'Native incremental stitcher module not available',
1996
+ ),
1997
+ );
1998
+ return;
1999
+ }
2000
+ if (shouldGateForPanMode(panMode, deviceOrientation)) {
2001
+ // Mode-A + portrait — block the start and show the rotate prompt.
2002
+ // The resume effect picks this up the instant the device rotates.
2003
+ setPendingPanStart(true);
2004
+ return;
2005
+ }
2006
+ void startCapture();
2007
+ }, [
2008
+ enablePanoramaMode,
2009
+ onError,
2010
+ panMode,
2011
+ deviceOrientation,
2012
+ startCapture,
1567
2013
  ]);
1568
2014
 
2015
+ // ── Rotate-to-landscape resume (item 1/2) ───────────────────────
2016
+ // When a hold was gated (`pendingPanStart`) and the user has since
2017
+ // rotated so the gate no longer fires, start the deferred capture.
2018
+ // Invoked through `startCaptureRef` (kept current above) so this
2019
+ // effect's deps don't churn on every recording re-render.
2020
+ useEffect(() => {
2021
+ if (pendingPanStart && !shouldGateForPanMode(panMode, deviceOrientation)) {
2022
+ setPendingPanStart(false);
2023
+ startCaptureRef.current?.();
2024
+ }
2025
+ }, [pendingPanStart, deviceOrientation, panMode]);
2026
+
1569
2027
  const handleHoldEnd = useCallback(async () => {
2028
+ // Item 5 exit path #1 — always kill the auto-finalize timer on
2029
+ // release, even on the early-return below (it's idempotent).
2030
+ clearPanTimer();
2031
+ // Item 1/2 — if the shutter is released while a rotate-gated hold is
2032
+ // pending (user let go before rotating to landscape), abandon the
2033
+ // deferred start rather than starting on the next rotation.
2034
+ if (pendingPanStart) setPendingPanStart(false);
1570
2035
  if (statusPhase !== 'recording') return;
2036
+ // Re-entrancy latch — close the timer-vs-release double-finalize window
2037
+ // synchronously so incremental.finalize()/onCapture fire exactly once.
2038
+ if (finalizingRef.current) return;
2039
+ finalizingRef.current = true;
2040
+ // Consume the lateral-drift flag once, here, so it's cleared on BOTH the
2041
+ // success and failure paths and never leaks into the next capture.
2042
+ const wasLateralFinalize = lateralFinalizeRef.current;
2043
+ lateralFinalizeRef.current = false;
2044
+ const wasFastPan = fastPanRef.current;
2045
+ fastPanRef.current = false;
2046
+ if (__DEV__) {
2047
+ // eslint-disable-next-line no-console
2048
+ console.log(
2049
+ `[capture] finalize: wasFastPan=${wasFastPan} `
2050
+ + `wasLateralFinalize=${wasLateralFinalize}`,
2051
+ );
2052
+ }
1571
2053
  setStatusPhase('stitching');
1572
2054
  // Stop pumping new frames before finalizing so the engine isn't
1573
2055
  // racing the final cv::Stitcher pass against late-arriving
1574
2056
  // keyframes. No-op in AR mode (the driver was never started).
1575
2057
  fpDriver.stop();
2058
+ // V12.14.8 restore (regressed in the SDK camera extraction): the
2059
+ // render below unmounts <CameraView>/<ARCameraView> while
2060
+ // statusPhase==='stitching'. Yield a macrotask so React commits that
2061
+ // unmount and vision-camera tears down the AVCaptureSession + preview
2062
+ // buffers (~150-250 MB) BEFORE the memory-heavy stitch runs. Without
2063
+ // it the live-camera footprint and the stitch peak coexist and
2064
+ // jetsam (iOS) / lmkd (Android) OOM-kill the app — the exact
2065
+ // WatchdogTermination crash V12.14.8 originally fixed.
2066
+ await new Promise((resolve) => setTimeout(resolve, 50));
1576
2067
  try {
1577
2068
  // Compose the panorama output path: host-controlled if
1578
2069
  // `outputDir` is set, else the lib's canonical capture dir
@@ -1607,7 +2098,20 @@ export function Camera(props: CameraProps): React.JSX.Element {
1607
2098
  });
1608
2099
  }
1609
2100
 
1610
- onCapture?.({
2101
+ // v0.16 — non-fatal quality signals attached to the result + (when
2102
+ // the crop editor shows) the crop banner. LOW_FRAME_UTILIZATION when
2103
+ // <70 % of captured frames survived; LATERAL_DRIFT_FINALIZE when item-6
2104
+ // stopped this capture early.
2105
+ const warnings = buildCaptureWarnings({
2106
+ framesRequested: result.framesRequested,
2107
+ framesIncluded: result.framesIncluded,
2108
+ lateralFinalize: wasLateralFinalize,
2109
+ highPanSpeed: wasFastPan,
2110
+ copy: captureWarningCopyFrom(guidanceCopyResolved),
2111
+ });
2112
+
2113
+ const captureResultObj: PanoramaCaptureResult = {
2114
+ ok: true,
1611
2115
  type: 'panorama',
1612
2116
  // Native finalize() returns a bare `/data/.../foo.jpg` path;
1613
2117
  // normalise to `file://` for Android <Image>.
@@ -1621,7 +2125,53 @@ export function Camera(props: CameraProps): React.JSX.Element {
1621
2125
  finalConfidenceThresh: result.finalConfidenceThresh ?? -1,
1622
2126
  durationMs: Date.now() - (recordingStartedAt ?? Date.now()),
1623
2127
  stitchModeResolved: result.stitchModeResolved,
1624
- });
2128
+ debugSummary: result.debugSummary,
2129
+ keyframePaths: result.batchKeyframePaths,
2130
+ captureOrientation: result.captureOrientation,
2131
+ warnings,
2132
+ };
2133
+ // When the crop editor OR a plain preview is enabled AND the panorama
2134
+ // has valid intrinsic dims, defer `onCapture`: stash the result and
2135
+ // mount RectCropPreview (crop mode when `rectCrop`, preview-only when
2136
+ // just `showPreview`). The modal's confirm / use-original / retake
2137
+ // decision emits the final result. Otherwise emit immediately.
2138
+ if (
2139
+ (rectCrop || showPreview)
2140
+ && result.width > 0
2141
+ && result.height > 0
2142
+ ) {
2143
+ // Crop mode only — seed the quad from the max-inscribed rectangle of
2144
+ // the (un-cropped) panorama so the editor opens on the tightest clean
2145
+ // rectangle, not a blind 8 % inset. Best-effort: an absent native
2146
+ // module / decode failure falls back to the default seed. Skipped in
2147
+ // preview-only mode (no quad to seed).
2148
+ let initialRect: ImageRect | undefined;
2149
+ if (rectCrop) {
2150
+ try {
2151
+ const inscribed = await computeInscribedRect(captureResultObj.uri);
2152
+ if (inscribed && inscribed.width > 0 && inscribed.height > 0) {
2153
+ initialRect = {
2154
+ x: inscribed.x,
2155
+ y: inscribed.y,
2156
+ width: inscribed.width,
2157
+ height: inscribed.height,
2158
+ };
2159
+ }
2160
+ } catch {
2161
+ // No seed — RectCropPreview uses its default inset.
2162
+ }
2163
+ }
2164
+ setCropPending({
2165
+ uri: captureResultObj.uri,
2166
+ width: result.width,
2167
+ height: result.height,
2168
+ captureResultObj,
2169
+ initialRect,
2170
+ warnings,
2171
+ });
2172
+ } else {
2173
+ onCapture?.(captureResultObj);
2174
+ }
1625
2175
  // 2026-05-22 (audit F9) — fire the debug stitch-stats toast on
1626
2176
  // every successful finalize when settings.debug is on. Shows
1627
2177
  // the leaveBiggestComponent retry telemetry + resolved mode so
@@ -1631,18 +2181,29 @@ export function Camera(props: CameraProps): React.JSX.Element {
1631
2181
  }
1632
2182
  } catch (err) {
1633
2183
  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));
2184
+ // Classify the raw native failure string → typed code. The chain
2185
+ // lives in classifyStitchError() (the load-bearing C++↔JS contract,
2186
+ // unit-tested against the actual native strings) so a future reword
2187
+ // of a cpp throw can't silently drop the "pan more slowly" path.
2188
+ const code = classifyStitchError(message);
2189
+ const error = new CameraError(code, message, err);
2190
+ // v0.16 surface the failure on BOTH callbacks: `onError` (unchanged
2191
+ // mirror) and `onCapture` (ok:false) so a host has one place to learn
2192
+ // the outcome. A lateral-drift stop that then failed to stitch still
2193
+ // reports that cause via the warning.
2194
+ onError?.(error);
2195
+ onCapture?.({
2196
+ ok: false,
2197
+ type: 'panorama',
2198
+ error,
2199
+ warnings: buildCaptureWarnings({
2200
+ lateralFinalize: wasLateralFinalize,
2201
+ highPanSpeed: wasFastPan,
2202
+ copy: captureWarningCopyFrom(guidanceCopyResolved),
2203
+ }),
2204
+ });
1645
2205
  } finally {
2206
+ finalizingRef.current = false;
1646
2207
  setStatusPhase('idle');
1647
2208
  setRecordingStartedAt(null);
1648
2209
  }
@@ -1667,7 +2228,159 @@ export function Camera(props: CameraProps): React.JSX.Element {
1667
2228
  isNonAR,
1668
2229
  imuGate,
1669
2230
  stitchToast,
2231
+ // feature/pano-ux-guidance — the release also tears down the
2232
+ // pan-duration timer + a pending rotate-gate, and decides whether to
2233
+ // route the result through the crop editor.
2234
+ clearPanTimer,
2235
+ pendingPanStart,
2236
+ rectCrop,
2237
+ showPreview,
2238
+ ]);
2239
+
2240
+ // Keep `handleHoldEndRef` current so the auto-finalize timer + the
2241
+ // lateral-drift effect invoke the latest `handleHoldEnd` without
2242
+ // adding it as a dep (it changes identity on every recording tick).
2243
+ useEffect(() => {
2244
+ handleHoldEndRef.current = () => { void handleHoldEnd(); };
2245
+ });
2246
+
2247
+ // ── Item 6 — lateral drift → FINALIZE + popup ───────────────────
2248
+ // Mirrors the orientation-drift effect, but FINALIZES the capture
2249
+ // (keeps what was stitched) rather than cancelling it: clear the
2250
+ // pan-duration timer, latch the popup, then call handleHoldEnd via
2251
+ // its ref. Gated off when the budget is disabled (`<= 0`).
2252
+ useEffect(() => {
2253
+ if (
2254
+ !panMotion.lateralExceeded
2255
+ || statusPhase !== 'recording'
2256
+ || lateralBudgetCm <= 0
2257
+ ) {
2258
+ return;
2259
+ }
2260
+ clearPanTimer();
2261
+
2262
+ // #3 — if the user veered off before enough frames were captured to
2263
+ // stitch, finalizing would fail with a misleading "need more images"
2264
+ // error. Instead ABANDON the capture (no stitch → no error) and show
2265
+ // the "follow the arrow" popup. Otherwise FINALIZE what was captured
2266
+ // (a usable partial pano) and show the "keep it straight" popup.
2267
+ const MIN_STITCHABLE_KEYFRAMES = 2;
2268
+ if (acceptedKeyframeCount < MIN_STITCHABLE_KEYFRAMES) {
2269
+ setLateralWrongDirection(true);
2270
+ setLateralStopVisible(true);
2271
+ void (async () => {
2272
+ fpDriver.stop();
2273
+ try {
2274
+ await incremental.cancel();
2275
+ } catch {
2276
+ // best-effort — abandonment must succeed even in a weird state.
2277
+ } finally {
2278
+ setStatusPhase('idle');
2279
+ setRecordingStartedAt(null);
2280
+ onCaptureAbandoned?.('lateral-drift');
2281
+ }
2282
+ })();
2283
+ return;
2284
+ }
2285
+
2286
+ setLateralWrongDirection(false);
2287
+ setLateralStopVisible(true);
2288
+ // Mark this finalize as lateral-drift-triggered so handleHoldEnd attaches
2289
+ // the LATERAL_DRIFT_FINALIZE warning to the result.
2290
+ lateralFinalizeRef.current = true;
2291
+ handleHoldEndRef.current?.();
2292
+ // Deps mirror the drift effect: re-run when the latch trips or the
2293
+ // recording state changes. Other reads are stable setters / refs.
2294
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2295
+ }, [panMotion.lateralExceeded, statusPhase, lateralBudgetCm]);
2296
+
2297
+ // ── Item 7 — auto-finalize when the configured keyframe count is hit ─
2298
+ // The engine caps accepted keyframes at `keyframeMaxCount`; once it
2299
+ // reports that many, no more frames will be accepted, so stop + stitch
2300
+ // (same finalize path as releasing the shutter). `handleHoldEnd`'s
2301
+ // re-entrancy latch makes this idempotent vs. a manual release in the
2302
+ // same tick. This is the PRIMARY auto-stop (the time cap is opt-in).
2303
+ const keyframeMaxCount = settings.frameSelection.maxKeyframes;
2304
+ const acceptedKeyframeCount = incrementalState?.acceptedCount ?? 0;
2305
+ // Item 4 — speed cue routed into the REC banner/border colour (green→red).
2306
+ // Gated on panGuidance so opting out keeps the banner calm/green.
2307
+ const recordingTooFast =
2308
+ panGuidance && panMotion.panSpeedBucket !== 'good';
2309
+ // Latch the too-fast flag for the HIGH_PAN_SPEED warning (shown on the crop
2310
+ // editor + returned in onCapture.warnings). Latches when the live cue is
2311
+ // active OR — per the user's request — when a KEYFRAME the stitch will use
2312
+ // is accepted while the pan is too fast (so a captured frame was actually
2313
+ // taken at speed). Depending on panSpeedBucket + acceptedKeyframeCount
2314
+ // directly (not just the derived `recordingTooFast`) makes the effect run
2315
+ // on every bucket / keyframe change, so a brief red window can't be missed.
2316
+ // Reset at capture start.
2317
+ const prevAcceptedForSpeedRef = useRef(0);
2318
+ useEffect(() => {
2319
+ if (statusPhase !== 'recording') {
2320
+ prevAcceptedForSpeedRef.current = acceptedKeyframeCount;
2321
+ return;
2322
+ }
2323
+ const newKeyframe = acceptedKeyframeCount > prevAcceptedForSpeedRef.current;
2324
+ prevAcceptedForSpeedRef.current = acceptedKeyframeCount;
2325
+ if (recordingTooFast) {
2326
+ if (__DEV__ && !fastPanRef.current) {
2327
+ // eslint-disable-next-line no-console
2328
+ console.log(
2329
+ `[panMotion] HIGH_PAN_SPEED latched (bucket=`
2330
+ + `${panMotion.panSpeedBucket} acceptedCount=${acceptedKeyframeCount}`
2331
+ + `${newKeyframe ? ' on a keyframe' : ''})`,
2332
+ );
2333
+ }
2334
+ fastPanRef.current = true;
2335
+ }
2336
+ }, [
2337
+ statusPhase,
2338
+ recordingTooFast,
2339
+ acceptedKeyframeCount,
2340
+ panMotion.panSpeedBucket,
1670
2341
  ]);
2342
+ useEffect(() => {
2343
+ if (
2344
+ statusPhase === 'recording'
2345
+ && keyframeMaxCount > 0
2346
+ && acceptedKeyframeCount >= keyframeMaxCount
2347
+ ) {
2348
+ handleHoldEndRef.current?.();
2349
+ }
2350
+ }, [statusPhase, keyframeMaxCount, acceptedKeyframeCount]);
2351
+
2352
+ // ── Item 3 — brief pan how-to overlay at recording start ────────
2353
+ // Show the how-to GIF + direction arrow for a short window when a
2354
+ // recording begins, then auto-fade. The component never self-times;
2355
+ // this effect owns the lifecycle.
2356
+ useEffect(() => {
2357
+ if (statusPhase !== 'recording') {
2358
+ setHowToVisible(false);
2359
+ return;
2360
+ }
2361
+ setHowToVisible(true);
2362
+ const t = setTimeout(() => setHowToVisible(false), 2500);
2363
+ return () => clearTimeout(t);
2364
+ }, [statusPhase]);
2365
+
2366
+ // ── Item 5 — cosmetic countdown tick ────────────────────────────
2367
+ // While recording, bump `nowTick` ~4×/s so `countdownSecondsFrom`
2368
+ // recomputes the displayed whole-seconds. The authoritative auto-stop
2369
+ // is the `panDurationTimerRef` setTimeout, NOT this interval. Skipped
2370
+ // when the countdown feature is disabled (`maxPanDurationMs <= 0`).
2371
+ useEffect(() => {
2372
+ if (statusPhase !== 'recording' || maxPanDurationMs <= 0) return;
2373
+ const id = setInterval(() => setNowTick(Date.now()), 250);
2374
+ return () => clearInterval(id);
2375
+ }, [statusPhase, maxPanDurationMs]);
2376
+
2377
+ // Whole seconds remaining for the countdown overlay (item 5). Pure
2378
+ // helper; clamps to [0, round(maxPanDurationMs/1000)].
2379
+ const countdownSeconds = countdownSecondsFrom(
2380
+ recordingStartedAt,
2381
+ nowTick,
2382
+ maxPanDurationMs,
2383
+ );
1671
2384
 
1672
2385
  // ── Lens / AR-toggle handlers ───────────────────────────────────
1673
2386
  const handleLensChange = useCallback((next: CameraLens) => {
@@ -1729,9 +2442,16 @@ export function Camera(props: CameraProps): React.JSX.Element {
1729
2442
  only ONE camera component is alive at a time; matches the
1730
2443
  monorepo's working pattern and avoids the Camera2-in-use
1731
2444
  conflict that "always mount both" caused on Android. */}
1732
- {inFlightTransition || arSupportPending ? (
2445
+ {cameraShouldUnmount(inFlightTransition, arSupportPending, statusPhase) ? (
2446
+ // statusPhase==='stitching' UNMOUNTS the camera so vision-camera
2447
+ // frees the AVCaptureSession + preview buffers during the stitch
2448
+ // (V12.14.8 OOM fix). The CaptureStatusOverlay renders the
2449
+ // "Stitching…" state on top, so no placeholder label is needed
2450
+ // in that case — only for the camera-switch transition.
1733
2451
  <View style={[StyleSheet.absoluteFill, styles.transitionPlaceholder]}>
1734
- <Text style={styles.transitionLabel}>Switching camera…</Text>
2452
+ {statusPhase === 'stitching' ? null : (
2453
+ <Text style={styles.transitionLabel}>Switching camera…</Text>
2454
+ )}
1735
2455
  </View>
1736
2456
  ) : isAR ? (
1737
2457
  <ARCameraView
@@ -1785,11 +2505,21 @@ export function Camera(props: CameraProps): React.JSX.Element {
1785
2505
  />
1786
2506
  )}
1787
2507
 
1788
- {/* REC banner + record border (during recording / stitching). */}
2508
+ {/* REC banner + record border (during recording / stitching). v0.16
2509
+ — the banner + border are GREEN normally and turn RED (with the
2510
+ too-fast copy) when the pan is too fast: the single speed cue that
2511
+ replaced the always-red border + separate amber pill. */}
1789
2512
  <CaptureStatusOverlay
1790
2513
  phase={statusPhase}
1791
2514
  topInset={insets.top}
1792
2515
  recordingStartedAt={recordingStartedAt ?? undefined}
2516
+ tooFast={recordingTooFast}
2517
+ recordingMessage={
2518
+ recordingTooFast
2519
+ ? guidanceCopyResolved.tooFast
2520
+ : guidanceCopyResolved.statusRecording
2521
+ }
2522
+ stitchingMessage={guidanceCopyResolved.statusStitching}
1793
2523
  />
1794
2524
 
1795
2525
  {/* v0.13.1 — the built-in pan-guidance overlays
@@ -1799,6 +2529,35 @@ export function Camera(props: CameraProps): React.JSX.Element {
1799
2529
  renders them and the `panGuide` / `panoramaGuidance` props
1800
2530
  are gone. Re-wire here if a host need resurfaces. */}
1801
2531
 
2532
+ {/* feature/pano-ux-guidance — in-capture guidance overlays.
2533
+ All gated on `panGuidance`; each renders null when not
2534
+ visible so they can mount unconditionally. */}
2535
+ {/* Item 6 — live keyframe counter "k / n" (top-centre). The primary
2536
+ capture HUD; the capture auto-finalizes when k reaches n. */}
2537
+ <CaptureFrameCounterOverlay
2538
+ visible={statusPhase === 'recording' && panGuidance}
2539
+ framesCaptured={acceptedKeyframeCount}
2540
+ framesMax={keyframeMaxCount}
2541
+ orientation={deviceOrientation}
2542
+ />
2543
+ {/* Item 5 — optional blinking time countdown (top corner), shown only
2544
+ when the host opts into a wall-clock cap via maxPanDurationMs. */}
2545
+ <CaptureCountdownOverlay
2546
+ visible={statusPhase === 'recording' && panGuidance && maxPanDurationMs > 0}
2547
+ secondsRemaining={countdownSeconds}
2548
+ orientation={deviceOrientation}
2549
+ />
2550
+ {/* Item 3 — brief pan how-to graphic + direction arrow. */}
2551
+ <PanHowToOverlay
2552
+ visible={statusPhase === 'recording' && panGuidance && howToVisible}
2553
+ orientation={deviceOrientation}
2554
+ />
2555
+ {/* Item 4 — "moving too fast" feedback is no longer a separate pill.
2556
+ v0.16 — it's consolidated into the CaptureStatusOverlay banner +
2557
+ border above, which turn from GREEN to RED (with the too-fast copy)
2558
+ when `recordingTooFast` — one calm cue instead of an always-red
2559
+ border plus a second amber pill. */}
2560
+
1802
2561
  {/*
1803
2562
  2026-05-22 (audit F9 + F3) — debug UI suite, all gated on
1804
2563
  settings.debug. Mounts in <Camera> automatically; Layer-2
@@ -2016,6 +2775,24 @@ export function Camera(props: CameraProps): React.JSX.Element {
2016
2775
  onClose={() => setSettingsModalVisible(false)}
2017
2776
  />
2018
2777
 
2778
+ {/* Item 1/2 — rotate prompt. Shown while a gated hold is blocked on
2779
+ the user rotating to the target orientation (landscape for
2780
+ panMode='vertical', portrait for 'horizontal'). The resume effect
2781
+ starts the deferred capture the instant they do. */}
2782
+ {/* The rotate prompt is the ONLY feedback for the mode gate, so it is
2783
+ NOT gated on `panGuidance` — otherwise panGuidance={false} +
2784
+ a gated panMode would block the hold with a dead, silent shutter.
2785
+ `panGuidance` governs only the cosmetic in-capture overlays. */}
2786
+ <RotateToLandscapePrompt
2787
+ visible={pendingPanStart}
2788
+ target={gateTargetOrientation(panMode) ?? 'landscape'}
2789
+ copy={
2790
+ gateTargetOrientation(panMode) === 'portrait'
2791
+ ? guidanceCopyResolved.rotateToPortrait
2792
+ : guidanceCopyResolved.rotateToLandscape
2793
+ }
2794
+ />
2795
+
2019
2796
  {/* v0.12.0 — Orientation drift modal. Shows AFTER the SDK has
2020
2797
  auto-abandoned the capture (the useEffect above stops the
2021
2798
  engine + transitions to idle + fires onCaptureAbandoned).
@@ -2029,6 +2806,29 @@ export function Camera(props: CameraProps): React.JSX.Element {
2029
2806
  onAcknowledge={() => setDriftModalDismissed(true)}
2030
2807
  />
2031
2808
 
2809
+ {/* Item 6 — lateral-drift popup. Latched true by the lateral
2810
+ effect AFTER it finalizes the capture; informational only,
2811
+ dismiss just clears the latch so the next capture starts
2812
+ fresh. */}
2813
+ <LateralMotionModal
2814
+ visible={lateralStopVisible}
2815
+ title={
2816
+ lateralWrongDirection
2817
+ ? guidanceCopyResolved.lateralWrongDirectionTitle
2818
+ : guidanceCopyResolved.lateralStopTitle
2819
+ }
2820
+ body={
2821
+ lateralWrongDirection
2822
+ ? guidanceCopyResolved.lateralWrongDirectionBody
2823
+ : guidanceCopyResolved.lateralStopBody
2824
+ }
2825
+ dismissLabel={guidanceCopyResolved.lateralStopDismiss}
2826
+ onDismiss={() => {
2827
+ setLateralStopVisible(false);
2828
+ setLateralWrongDirection(false);
2829
+ }}
2830
+ />
2831
+
2032
2832
  {/* v0.13.0 — built-in post-stitch / tap-to-preview modal.
2033
2833
  Visible when the host supplies `capturePreview`. When
2034
2834
  undefined the modal stays hidden (visible=false) so it
@@ -2043,6 +2843,126 @@ export function Camera(props: CameraProps): React.JSX.Element {
2043
2843
  actions={capturePreviewActions}
2044
2844
  onClose={onCapturePreviewClose ?? noop}
2045
2845
  />
2846
+
2847
+ {/* Post-capture review surface, shown after a panorama finalizes when
2848
+ `rectCrop` OR `showPreview` is on (handleHoldEnd stashed the pending
2849
+ result instead of emitting it). `showCropControls={rectCrop}`:
2850
+ - crop mode (rectCrop) → draggable quad seeded on the max-inscribed
2851
+ rectangle; any capture warnings banner on top.
2852
+ - Use original → emit the original, un-cropped panorama.
2853
+ - Crop → cropQuad (perspective-rectify when the quad
2854
+ isn't axis-aligned) overwrites the file in place; emit with
2855
+ the rectified dims + a cache-busting query so <Image> reloads
2856
+ it. On any crop failure, fall back to the original.
2857
+ - preview-only mode (showPreview, no rectCrop) → bare image with
2858
+ [Retake]/[Confirm]; Confirm routes through onUseOriginal. */}
2859
+ <RectCropPreview
2860
+ // Remount per capture so the dragged-quad + layout state re-seed to
2861
+ // the new image (RectCropPreview seeds its quad once via useState).
2862
+ key={cropPending?.uri ?? 'crop'}
2863
+ visible={cropPending != null}
2864
+ imageUri={cropPending?.uri ?? ''}
2865
+ imageWidth={cropPending?.width ?? 0}
2866
+ imageHeight={cropPending?.height ?? 0}
2867
+ // 2026-06-15 — manual is the default/eager output. The high-level tab
2868
+ // is ON DEMAND: RectCropPreview calls onRequestAlt() (which re-stitches
2869
+ // the captured keyframes via cv::Stitcher) only when the user switches
2870
+ // to it. DEBUG-ONLY: it's a pipeline-comparison tool (dev-jargon
2871
+ // "Manual"/"High-level" labels), gated behind `settings.debug` like the
2872
+ // rest of the diagnostic UI. Also requires keyframePaths, so it only
2873
+ // appears where it can run (iOS); Android returns no paths → no tab.
2874
+ onRequestAlt={
2875
+ settings.debug && cropPending?.captureResultObj.keyframePaths?.length
2876
+ ? requestHighLevelAlt
2877
+ : undefined
2878
+ }
2879
+ initialRect={cropPending?.initialRect}
2880
+ warnings={cropPending?.warnings.map((w) => w.message) ?? []}
2881
+ showCropControls={rectCrop}
2882
+ topInset={insets.top}
2883
+ bottomInset={insets.bottom}
2884
+ copy={guidanceCopyResolved}
2885
+ // Carry the live memory pill onto the preview too (same settings.debug
2886
+ // gate as the camera), so the operator can watch the RSS spike when the
2887
+ // on-demand high-level re-stitch fires.
2888
+ showMemoryPill={settings.debug}
2889
+ // DEV overlay — show the stitcher's runtime choices (pipeline / warper /
2890
+ // route / seam / blend) + score / frames / size for this output, so the
2891
+ // operator can see HOW it was built. __DEV__ only.
2892
+ debugInfo={
2893
+ __DEV__ && cropPending
2894
+ ? buildStitchDebugInfo(cropPending.captureResultObj)
2895
+ : undefined
2896
+ }
2897
+ onUseOriginal={(altUri) => {
2898
+ if (cropPending) {
2899
+ // altUri set → the user picked the alt (manual) pipeline's output
2900
+ // in the A/B toggle; emit THAT image (cache-bust for <Image>).
2901
+ onCapture?.(
2902
+ altUri
2903
+ ? {
2904
+ ...cropPending.captureResultObj,
2905
+ uri: `${altUri}?t=${Date.now()}`,
2906
+ }
2907
+ : cropPending.captureResultObj,
2908
+ );
2909
+ }
2910
+ setCropPending(null);
2911
+ }}
2912
+ onRetake={() => {
2913
+ // Discard this capture entirely — no onCapture — and return to
2914
+ // the live camera (statusPhase is already 'idle' post-finalize).
2915
+ setCropPending(null);
2916
+ }}
2917
+ onConfirm={async ({ quad, perspective }) => {
2918
+ if (!cropPending) return;
2919
+ const pending = cropPending;
2920
+ // perspective=true → rectify the dragged quad to an upright
2921
+ // rectangle (cropToQuad). perspective=false (the user dragged a
2922
+ // ~rectangular quad) → crop to the quad's axis-aligned bounding box
2923
+ // — a plain crop, no warp.
2924
+ const xs = quad.map((p) => p.x);
2925
+ const ys = quad.map((p) => p.y);
2926
+ const cropPoints: Quad = perspective
2927
+ ? quad
2928
+ : [
2929
+ { x: Math.min(...xs), y: Math.min(...ys) },
2930
+ { x: Math.max(...xs), y: Math.min(...ys) },
2931
+ { x: Math.max(...xs), y: Math.max(...ys) },
2932
+ { x: Math.min(...xs), y: Math.max(...ys) },
2933
+ ];
2934
+ try {
2935
+ // cropQuad takes a BARE path; the stashed uri is a file://
2936
+ // URI. Overwrites in place (pass the same path).
2937
+ const cropped = await cropQuad(
2938
+ toBareFilePath(pending.uri),
2939
+ cropPoints,
2940
+ undefined,
2941
+ { quality: 90 },
2942
+ );
2943
+ onCapture?.({
2944
+ ...pending.captureResultObj,
2945
+ // Cache-bust so <Image> reloads the overwritten file.
2946
+ uri: `${toFileUri(cropped.outputPath)}?t=${Date.now()}`,
2947
+ width: cropped.width,
2948
+ height: cropped.height,
2949
+ });
2950
+ } catch (err) {
2951
+ onError?.(
2952
+ new CameraError(
2953
+ 'OUTPUT_WRITE_FAILED',
2954
+ err instanceof Error ? err.message : String(err),
2955
+ err,
2956
+ ),
2957
+ );
2958
+ // Fall back to the un-cropped panorama so the capture isn't
2959
+ // lost on a crop failure.
2960
+ onCapture?.(pending.captureResultObj);
2961
+ } finally {
2962
+ setCropPending(null);
2963
+ }
2964
+ }}
2965
+ />
2046
2966
  </View>
2047
2967
  );
2048
2968
  }
@@ -2120,6 +3040,33 @@ export const _homeIndicatorEdgeForTests = homeIndicatorEdge;
2120
3040
  export const _isSideEdgeForTests = isSideEdge;
2121
3041
 
2122
3042
 
3043
+ /**
3044
+ * cameraShouldUnmount — whether the live camera (<CameraView> /
3045
+ * <ARCameraView>) should be UNMOUNTED (replaced by the placeholder) this
3046
+ * render rather than mounted.
3047
+ *
3048
+ * True while a camera-switch transition or AR-support probe is in flight,
3049
+ * OR during the stitch (statusPhase==='stitching'). The stitching case is
3050
+ * the V12.14.8 OOM fix: unmounting frees vision-camera's AVCaptureSession +
3051
+ * preview buffers (~150-250 MB) BEFORE the memory-heavy stitch, so the
3052
+ * live-camera footprint and the stitch peak never coexist and jetsam (iOS)
3053
+ * / lmkd (Android) don't OOM-kill the app.
3054
+ *
3055
+ * Pure + exported for test — the lib's jest config can't mount <Camera>,
3056
+ * so this boolean is the unit-testable core of the OOM render gate.
3057
+ */
3058
+ function cameraShouldUnmount(
3059
+ inFlightTransition: boolean,
3060
+ arSupportPending: boolean,
3061
+ statusPhase: CaptureStatusPhase,
3062
+ ): boolean {
3063
+ return inFlightTransition || arSupportPending || statusPhase === 'stitching';
3064
+ }
3065
+
3066
+ /** @internal test-only — see `cameraShouldUnmount`. */
3067
+ export const _cameraShouldUnmountForTests = cameraShouldUnmount;
3068
+
3069
+
2123
3070
  /**
2124
3071
  * v0.12.0 — bottom-controls outer container positioning. Anchors
2125
3072
  * to the home-indicator JS edge with the appropriate flex direction