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
@@ -74,7 +74,7 @@ var __importStar = (this && this.__importStar) || (function () {
74
74
  };
75
75
  })();
76
76
  Object.defineProperty(exports, "__esModule", { value: true });
77
- exports._isSideEdgeForTests = exports._homeIndicatorEdgeForTests = exports.CameraError = void 0;
77
+ exports._cameraShouldUnmountForTests = exports._isSideEdgeForTests = exports._homeIndicatorEdgeForTests = exports.CameraError = void 0;
78
78
  exports.Camera = Camera;
79
79
  const react_1 = __importStar(require("react"));
80
80
  const react_native_1 = require("react-native");
@@ -87,6 +87,7 @@ const CaptureHeader_1 = require("./CaptureHeader");
87
87
  const CapturePreview_1 = require("./CapturePreview");
88
88
  const CaptureThumbnailStrip_1 = require("./CaptureThumbnailStrip");
89
89
  const CaptureStatusOverlay_1 = require("./CaptureStatusOverlay");
90
+ const classifyStitchError_1 = require("./classifyStitchError");
90
91
  const CaptureDebugOverlay_1 = require("./CaptureDebugOverlay");
91
92
  const CaptureMemoryPill_1 = require("./CaptureMemoryPill");
92
93
  const CaptureKeyframePill_1 = require("./CaptureKeyframePill");
@@ -102,6 +103,24 @@ const useDeviceOrientation_1 = require("./useDeviceOrientation");
102
103
  const useContentRotation_1 = require("./useContentRotation");
103
104
  const useOrientationDrift_1 = require("./useOrientationDrift");
104
105
  const OrientationDriftModal_1 = require("./OrientationDriftModal");
106
+ // ── Panorama GUIDANCE building blocks (feature/pano-ux-guidance) ─────
107
+ // Pure decision helpers + sensor hook + presentational surfaces for the
108
+ // first-time-user pan-capture guidance (items 1–7). All read directly
109
+ // from the new <Camera> props below, NOT threaded through PanoramaSettings.
110
+ const panModeGate_1 = require("./panModeGate");
111
+ const captureCountdown_1 = require("./captureCountdown");
112
+ const usePanMotion_1 = require("./usePanMotion");
113
+ const cameraGuidanceCopy_1 = require("./cameraGuidanceCopy");
114
+ const RotateToLandscapePrompt_1 = require("./RotateToLandscapePrompt");
115
+ const PanHowToOverlay_1 = require("./PanHowToOverlay");
116
+ const CaptureCountdownOverlay_1 = require("./CaptureCountdownOverlay");
117
+ const CaptureFrameCounterOverlay_1 = require("./CaptureFrameCounterOverlay");
118
+ const LateralMotionModal_1 = require("./LateralMotionModal");
119
+ const RectCropPreview_1 = require("./RectCropPreview");
120
+ const stitchDebugInfo_1 = require("./stitchDebugInfo");
121
+ const cropQuad_1 = require("../stitching/cropQuad");
122
+ const computeInscribedRect_1 = require("../stitching/computeInscribedRect");
123
+ const captureWarnings_1 = require("./captureWarnings");
105
124
  const incremental_1 = require("../stitching/incremental");
106
125
  const useFrameProcessorDriver_1 = require("../stitching/useFrameProcessorDriver");
107
126
  const useIncrementalStitcher_1 = require("../stitching/useIncrementalStitcher");
@@ -266,7 +285,17 @@ function extractPanoramaOverrides(props) {
266
285
  defaultKeyframeMaxCount: props.defaultKeyframeMaxCount,
267
286
  defaultKeyframeOverlapThreshold: props.defaultKeyframeOverlapThreshold,
268
287
  defaultMaxKeyframeIntervalMs: props.defaultMaxKeyframeIntervalMs,
269
- maxInscribedRectCrop: props.maxInscribedRectCrop,
288
+ // v0.16 — JSON-object form (wins over the flat default* props above).
289
+ stitcher: props.stitcher,
290
+ frameSelection: props.frameSelection,
291
+ // Item 2 — the interactive crop editor OWNS cropping, so when it's on we
292
+ // force the native auto-crop OFF: the editor needs the full un-cropped
293
+ // panorama (black borders included) so the user can drag the inscribed-
294
+ // rect seed outward to keep more content. Letting the native auto-crop
295
+ // pre-trim would leave nothing to adjust.
296
+ maxInscribedRectCrop: props.rectCrop
297
+ ? false
298
+ : props.maxInscribedRectCrop,
270
299
  };
271
300
  }
272
301
  // `toFileUri` (used to be an inline `toFileUri` here) lives in
@@ -281,7 +310,16 @@ function extractPanoramaOverrides(props) {
281
310
  * The public `<Camera>` component.
282
311
  */
283
312
  function Camera(props) {
284
- const { defaultCaptureSource = 'non-ar', defaultLens = '1x', captureSources = 'both', enablePhotoMode = true, enablePanoramaMode = true, showSettingsButton = false, style, outputDir, onCapture, onCaptureSourceChange, onLensChange, onFramesDropped, onError, onCaptureAbandoned, flash: controlledFlash, onFlashChange, showFlashButton = true, headerTitle, onHeaderBack, headerBackLabel, headerGuidance, headerColors, thumbnails, thumbnailsMin, thumbnailsMax, onThumbnailPress, capturePreview, capturePreviewActions, onCapturePreviewClose, frameProcessor: hostFrameProcessor, engine = 'batch-keyframe', } = props;
313
+ const { defaultCaptureSource = 'non-ar', defaultLens = '1x', captureSources = 'both', enablePhotoMode = true, enablePanoramaMode = true, showSettingsButton = false, style, outputDir, onCapture, onCaptureSourceChange, onLensChange, onFramesDropped, onError, onCaptureAbandoned, flash: controlledFlash, onFlashChange, showFlashButton = true, headerTitle, onHeaderBack, headerBackLabel, headerGuidance, headerColors, thumbnails, thumbnailsMin, thumbnailsMax, onThumbnailPress, capturePreview, capturePreviewActions, onCapturePreviewClose, frameProcessor: hostFrameProcessor, engine = 'batch-keyframe',
314
+ // ── Panorama GUIDANCE (feature/pano-ux-guidance) ──────────────
315
+ panMode = 'vertical', panGuidance = true, maxPanDurationMs = 0, panTooFastThreshold, lateralBudgetCm = 4, rectCrop = false, showPreview = false, guidanceCopy, } = props;
316
+ // Derived guidance state. The landscape-only gate decision itself is
317
+ // computed inline at the call sites via `shouldGateForPanMode(panMode,
318
+ // deviceOrientation)` (the rotate gate + resume effect), so there's no
319
+ // standalone `modeAOnly` flag to keep in sync. `guidanceCopyResolved`
320
+ // merges the host override onto the defaults once per `guidanceCopy`
321
+ // identity.
322
+ const guidanceCopyResolved = (0, react_1.useMemo)(() => (0, cameraGuidanceCopy_1.mergeGuidanceCopy)(guidanceCopy), [guidanceCopy]);
285
323
  // v0.13.2 — capture-source constraint (default 'both'). Derives which
286
324
  // sources are permitted; `captureSources` overrides any conflicting
287
325
  // `defaultCaptureSource`. Used to constrain the initial AR preference
@@ -317,6 +355,30 @@ function Camera(props) {
317
355
  const [statusPhase, setStatusPhase] = (0, react_1.useState)('idle');
318
356
  const [recordingStartedAt, setRecordingStartedAt] = (0, react_1.useState)(null);
319
357
  const [incrementalState, setIncrementalState] = (0, react_1.useState)(null);
358
+ // ── Panorama GUIDANCE state (feature/pano-ux-guidance) ──────────
359
+ // Item 1/2 — a hold that was BLOCKED on the rotate-to-landscape gate.
360
+ // Latches when the user holds the shutter in portrait under Mode A;
361
+ // an effect below resumes the capture the instant they rotate.
362
+ const [pendingPanStart, setPendingPanStart] = (0, react_1.useState)(false);
363
+ // Item 6 — the latched lateral-drift popup (capture already finalized
364
+ // by the time it shows).
365
+ const [lateralStopVisible, setLateralStopVisible] = (0, react_1.useState)(false);
366
+ // Item 6 — true when the lateral stop happened with too few frames to
367
+ // stitch (the user veered off almost immediately): the popup then shows
368
+ // the "follow the arrow" copy and the capture is abandoned, not finalized.
369
+ const [lateralWrongDirection, setLateralWrongDirection] = (0, react_1.useState)(false);
370
+ // Item 3 — the brief pan how-to overlay shown at the start of a
371
+ // recording, auto-dismissed after a timeout.
372
+ const [howToVisible, setHowToVisible] = (0, react_1.useState)(false);
373
+ // Item 5 — a ~250 ms ticking clock that drives the displayed countdown
374
+ // seconds while recording (the authoritative auto-stop is a setTimeout,
375
+ // not this tick).
376
+ const [nowTick, setNowTick] = (0, react_1.useState)(() => Date.now());
377
+ // Item 7 — a finalized panorama awaiting the user's crop decision.
378
+ // Non-null mounts the RectCropPreview; `captureResultObj` is the exact
379
+ // CameraCaptureResult we'd otherwise have emitted, stashed so cancel /
380
+ // crop-confirm can emit it (possibly with cropped dims) afterwards.
381
+ const [cropPending, setCropPending] = (0, react_1.useState)(null);
320
382
  // 2026-05-22 (audit F9 + F3) — debug stitch-stats toast. Hook
321
383
  // exposes an imperative API; we fire `showResult(finalizeResult)`
322
384
  // on every successful finalize when settings.debug is on (gated
@@ -350,6 +412,17 @@ function Camera(props) {
350
412
  // cases that resolve to AR once support is confirmed.
351
413
  const arSupportPending = arPreference && lens !== '0.5x' && !isARSupportProbed;
352
414
  const deviceOrientation = (0, useDeviceOrientation_1.useDeviceOrientation)();
415
+ // ── Panorama GUIDANCE — shared motion signals (item 3/4/6) ──────
416
+ // One gyro + one accelerometer subscription, live only while a non-AR
417
+ // capture is recording. Feeds the too-fast pill (`panSpeedBucket`)
418
+ // and the lateral-drift FINALIZE (`lateralExceeded`). `panTooFast-
419
+ // Threshold` (if set) tunes the 'warn'→'bad' boundary; `lateralBudget-
420
+ // Cm` tunes the drift latch (0 disables the latch in the hook).
421
+ const panMotion = (0, usePanMotion_1.usePanMotion)({
422
+ active: statusPhase === 'recording' && isNonAR,
423
+ warnMaxRadPerSec: panTooFastThreshold,
424
+ lateralBudgetCm,
425
+ });
353
426
  // v0.13.1 — counter-rotation for control CONTENT (AR toggle, lens
354
427
  // pill, flash icon, thumbnails) so their labels read upright relative
355
428
  // to gravity when the device is held landscape under a PORTRAIT-LOCKED
@@ -522,9 +595,43 @@ function Camera(props) {
522
595
  // The imperative pattern (start on hold-start, stop on hold-end)
523
596
  // avoids the re-render churn entirely.
524
597
  const fpDriver = (0, useFrameProcessorDriver_1.useFrameProcessorDriver)();
525
- // Safety: stop the driver if the component unmounts mid-recording.
598
+ // Safety: stop the driver AND clear the pan-duration auto-finalize
599
+ // timer if the component unmounts mid-recording (item 5 exit path #4).
526
600
  // eslint-disable-next-line react-hooks/exhaustive-deps
527
- (0, react_1.useEffect)(() => () => { fpDriver.stop(); }, []);
601
+ (0, react_1.useEffect)(() => () => { fpDriver.stop(); clearPanTimer(); }, []);
602
+ // ── Panorama GUIDANCE — auto-finalize timer + ref bridges ───────
603
+ // The 9 s pan-duration ceiling (item 5) is an authoritative
604
+ // `setTimeout` (not derived from the cosmetic countdown tick). Stored
605
+ // in a ref so the start logic can schedule it and ALL four capture-exit
606
+ // paths (manual release, drift cancel, lateral stop, unmount) clear it.
607
+ const panDurationTimerRef = (0, react_1.useRef)(null);
608
+ const clearPanTimer = (0, react_1.useCallback)(() => {
609
+ if (panDurationTimerRef.current) {
610
+ clearTimeout(panDurationTimerRef.current);
611
+ panDurationTimerRef.current = null;
612
+ }
613
+ }, []);
614
+ // `handleHoldEnd` / `startCapture` are defined further down but are
615
+ // referenced from effects + timers declared above them. Refs break
616
+ // the declaration-order + circular-useCallback-dep cycle: each is
617
+ // kept current by a commit-phase effect, and callers invoke via the
618
+ // ref (`handleHoldEndRef.current?.()`) — mirroring how the drift
619
+ // effect avoids putting these in its dep array.
620
+ const handleHoldEndRef = (0, react_1.useRef)(null);
621
+ const startCaptureRef = (0, react_1.useRef)(null);
622
+ // Synchronous re-entrancy latch for the finalize path: the auto-finalize
623
+ // timer and a manual release can both pass the async statusPhase guard in
624
+ // the same tick before React commits 'stitching'.
625
+ const finalizingRef = (0, react_1.useRef)(false);
626
+ // Item 6 — set by the lateral-drift effect just before it calls
627
+ // handleHoldEnd, so the finalize knows this stop was a sideways-drift
628
+ // auto-stop and can attach the LATERAL_DRIFT_FINALIZE warning. Consumed
629
+ // (reset) at the start of handleHoldEnd so it never leaks to the next pan.
630
+ const lateralFinalizeRef = (0, react_1.useRef)(false);
631
+ // Item 4 — latched true if the pan ever exceeded the recommended pace (the
632
+ // live "too fast" cue fired) during the capture, so the finalize attaches a
633
+ // HIGH_PAN_SPEED warning. Reset at capture start; consumed at finalize.
634
+ const fastPanRef = (0, react_1.useRef)(false);
528
635
  // ── v0.12.0 — Orientation drift detection + auto-abandon ────────
529
636
  //
530
637
  // The incremental engine supports both portrait (Mode B, horizontal
@@ -541,11 +648,20 @@ function Camera(props) {
541
648
  // the engine spec.
542
649
  const drift = (0, useOrientationDrift_1.useOrientationDrift)(statusPhase === 'recording');
543
650
  const [driftModalDismissed, setDriftModalDismissed] = (0, react_1.useState)(false);
544
- // Reset the dismissed flag when a new capture starts (or any non-
545
- // recording state) so the next drift event surfaces a fresh modal.
651
+ // Reset the modal flags when a new capture STARTS (statusPhase
652
+ // 'recording'), NOT when one stops. v0.16 fix: the old "any non-recording
653
+ // state" condition cleared `lateralStopVisible` the instant a lateral stop
654
+ // moved statusPhase out of 'recording' — so the popup was hidden before it
655
+ // could ever show (the user only saw the downstream error). Clearing on
656
+ // capture START instead lets the lateral / drift popups persist after the
657
+ // stop until the user dismisses them, while still giving the next capture
658
+ // a clean slate.
546
659
  (0, react_1.useEffect)(() => {
547
- if (statusPhase !== 'recording')
660
+ if (statusPhase === 'recording') {
548
661
  setDriftModalDismissed(false);
662
+ setLateralStopVisible(false);
663
+ setLateralWrongDirection(false);
664
+ }
549
665
  }, [statusPhase]);
550
666
  (0, react_1.useEffect)(() => {
551
667
  if (!drift.drifted || statusPhase !== 'recording')
@@ -563,6 +679,9 @@ function Camera(props) {
563
679
  // through `onError` — abandonment must succeed even if the engine
564
680
  // is in a weird state.
565
681
  void (async () => {
682
+ // item 5 exit path #2 — kill the pan-duration auto-finalize timer
683
+ // so it can't fire into an already-cancelled capture.
684
+ clearPanTimer();
566
685
  fpDriver.stop();
567
686
  try {
568
687
  await incremental.cancel();
@@ -747,25 +866,29 @@ function Camera(props) {
747
866
  width = result.width;
748
867
  height = result.height;
749
868
  }
750
- onCapture?.({ type: 'photo', uri, width, height });
869
+ onCapture?.({ ok: true, type: 'photo', uri, width, height, warnings: [] });
751
870
  }
752
871
  catch (err) {
753
872
  const e = err instanceof CameraError
754
873
  ? err
755
874
  : new CameraError('PHOTO_CAPTURE_FAILED', err instanceof Error ? err.message : String(err), err);
875
+ // v0.16 — failures now reach `onCapture` too (ok:false), with
876
+ // `onError` kept as a mirror so existing handlers keep working.
756
877
  onError?.(e);
878
+ onCapture?.({ ok: false, type: 'photo', error: e, warnings: [] });
757
879
  }
758
880
  }, [enablePhotoMode, isAR, capture, outputDir, onCapture, onError]);
759
- const handleHoldStart = (0, react_1.useCallback)(async () => {
760
- if (!enablePanoramaMode)
761
- return;
762
- if (!(0, incremental_1.incrementalStitcherIsAvailable)()) {
763
- onError?.(new CameraError('PANORAMA_START_FAILED', 'Native incremental stitcher module not available'));
764
- return;
765
- }
881
+ // ── startCapture the "actually start recording" logic ─────────
882
+ // Extracted from `handleHoldStart` so the rotate-to-landscape gate
883
+ // (item 1/2) can DEFER it: a portrait Mode-A hold latches
884
+ // `pendingPanStart` and an effect calls this once the user rotates.
885
+ // Identical behaviour to the inline body it replaced — the only new
886
+ // line is the item-5 auto-finalize timer scheduled right after
887
+ // `setRecordingStartedAt`.
888
+ const startCapture = (0, react_1.useCallback)(async () => {
766
889
  try {
767
890
  // 2026-05-23 (race fix) — synchronously clear thumbnails +
768
- // engine state at the top of handleHoldStart, BEFORE awaiting
891
+ // engine state at the top of startCapture, BEFORE awaiting
769
892
  // incremental.start(). In the previous effect-based design
770
893
  // the GL thread could ingest an AR frame during the await
771
894
  // window and add to thumbnails BEFORE React's
@@ -775,8 +898,21 @@ function Camera(props) {
775
898
  // an empty array and accumulates from there.
776
899
  setBatchKeyframeThumbnails([]);
777
900
  setIncrementalState(null);
901
+ // Item 4 — fresh capture: clear the latched too-fast flag.
902
+ fastPanRef.current = false;
778
903
  setStatusPhase('recording');
779
904
  setRecordingStartedAt(Date.now());
905
+ // Item 5 — schedule the hard-ceiling auto-finalize. Fires
906
+ // `handleHoldEnd` (via ref to dodge the circular useCallback dep),
907
+ // which finalizes what's captured — the FINALIZE-on-zero product
908
+ // decision. Cleared on every other capture-exit path. Skipped
909
+ // when the feature is disabled (`maxPanDurationMs <= 0`).
910
+ clearPanTimer();
911
+ if (maxPanDurationMs > 0) {
912
+ panDurationTimerRef.current = setTimeout(() => {
913
+ handleHoldEndRef.current?.();
914
+ }, maxPanDurationMs);
915
+ }
780
916
  const orientationRotation = deviceOrientation === 'portrait' ? 90
781
917
  : deviceOrientation === 'portrait-upside-down' ? 270
782
918
  : 0;
@@ -835,10 +971,10 @@ function Camera(props) {
835
971
  }
836
972
  catch (err) {
837
973
  setStatusPhase('idle');
974
+ clearPanTimer();
838
975
  onError?.(new CameraError('PANORAMA_START_FAILED', err instanceof Error ? err.message : String(err), err));
839
976
  }
840
977
  }, [
841
- enablePanoramaMode,
842
978
  incremental,
843
979
  isNonAR,
844
980
  deviceOrientation,
@@ -848,15 +984,93 @@ function Camera(props) {
848
984
  fpDriver,
849
985
  engine,
850
986
  onError,
987
+ maxPanDurationMs,
988
+ clearPanTimer,
989
+ ]);
990
+ // Keep the ref current so the auto-finalize timer + the rotate-resume
991
+ // effect can invoke the latest `startCapture` without taking it as a
992
+ // dep (which would re-run them on every recording-driven re-render).
993
+ (0, react_1.useEffect)(() => {
994
+ startCaptureRef.current = () => { void startCapture(); };
995
+ });
996
+ // ── handleHoldStart — early guards + the rotate-to-landscape gate ─
997
+ // The "actually start" body lives in `startCapture`; this wrapper only
998
+ // decides WHETHER to start now. Under Mode A in portrait it latches
999
+ // `pendingPanStart` instead (item 1/2) and the resume effect below
1000
+ // starts the capture once the user rotates to landscape.
1001
+ const handleHoldStart = (0, react_1.useCallback)(() => {
1002
+ if (!enablePanoramaMode)
1003
+ return;
1004
+ if (!(0, incremental_1.incrementalStitcherIsAvailable)()) {
1005
+ onError?.(new CameraError('PANORAMA_START_FAILED', 'Native incremental stitcher module not available'));
1006
+ return;
1007
+ }
1008
+ if ((0, panModeGate_1.shouldGateForPanMode)(panMode, deviceOrientation)) {
1009
+ // Mode-A + portrait — block the start and show the rotate prompt.
1010
+ // The resume effect picks this up the instant the device rotates.
1011
+ setPendingPanStart(true);
1012
+ return;
1013
+ }
1014
+ void startCapture();
1015
+ }, [
1016
+ enablePanoramaMode,
1017
+ onError,
1018
+ panMode,
1019
+ deviceOrientation,
1020
+ startCapture,
851
1021
  ]);
1022
+ // ── Rotate-to-landscape resume (item 1/2) ───────────────────────
1023
+ // When a hold was gated (`pendingPanStart`) and the user has since
1024
+ // rotated so the gate no longer fires, start the deferred capture.
1025
+ // Invoked through `startCaptureRef` (kept current above) so this
1026
+ // effect's deps don't churn on every recording re-render.
1027
+ (0, react_1.useEffect)(() => {
1028
+ if (pendingPanStart && !(0, panModeGate_1.shouldGateForPanMode)(panMode, deviceOrientation)) {
1029
+ setPendingPanStart(false);
1030
+ startCaptureRef.current?.();
1031
+ }
1032
+ }, [pendingPanStart, deviceOrientation, panMode]);
852
1033
  const handleHoldEnd = (0, react_1.useCallback)(async () => {
1034
+ // Item 5 exit path #1 — always kill the auto-finalize timer on
1035
+ // release, even on the early-return below (it's idempotent).
1036
+ clearPanTimer();
1037
+ // Item 1/2 — if the shutter is released while a rotate-gated hold is
1038
+ // pending (user let go before rotating to landscape), abandon the
1039
+ // deferred start rather than starting on the next rotation.
1040
+ if (pendingPanStart)
1041
+ setPendingPanStart(false);
853
1042
  if (statusPhase !== 'recording')
854
1043
  return;
1044
+ // Re-entrancy latch — close the timer-vs-release double-finalize window
1045
+ // synchronously so incremental.finalize()/onCapture fire exactly once.
1046
+ if (finalizingRef.current)
1047
+ return;
1048
+ finalizingRef.current = true;
1049
+ // Consume the lateral-drift flag once, here, so it's cleared on BOTH the
1050
+ // success and failure paths and never leaks into the next capture.
1051
+ const wasLateralFinalize = lateralFinalizeRef.current;
1052
+ lateralFinalizeRef.current = false;
1053
+ const wasFastPan = fastPanRef.current;
1054
+ fastPanRef.current = false;
1055
+ if (__DEV__) {
1056
+ // eslint-disable-next-line no-console
1057
+ console.log(`[capture] finalize: wasFastPan=${wasFastPan} `
1058
+ + `wasLateralFinalize=${wasLateralFinalize}`);
1059
+ }
855
1060
  setStatusPhase('stitching');
856
1061
  // Stop pumping new frames before finalizing so the engine isn't
857
1062
  // racing the final cv::Stitcher pass against late-arriving
858
1063
  // keyframes. No-op in AR mode (the driver was never started).
859
1064
  fpDriver.stop();
1065
+ // V12.14.8 restore (regressed in the SDK camera extraction): the
1066
+ // render below unmounts <CameraView>/<ARCameraView> while
1067
+ // statusPhase==='stitching'. Yield a macrotask so React commits that
1068
+ // unmount and vision-camera tears down the AVCaptureSession + preview
1069
+ // buffers (~150-250 MB) BEFORE the memory-heavy stitch runs. Without
1070
+ // it the live-camera footprint and the stitch peak coexist and
1071
+ // jetsam (iOS) / lmkd (Android) OOM-kill the app — the exact
1072
+ // WatchdogTermination crash V12.14.8 originally fixed.
1073
+ await new Promise((resolve) => setTimeout(resolve, 50));
860
1074
  try {
861
1075
  // Compose the panorama output path: host-controlled if
862
1076
  // `outputDir` is set, else the lib's canonical capture dir
@@ -874,7 +1088,7 @@ function Camera(props) {
874
1088
  // native side uses pose-derived translation and ignores this).
875
1089
  const imuTotalTranslationM = isNonAR ? imuGate.getTotalAbsMetres() : 0;
876
1090
  const result = await incremental.finalize(panoOutputPath, 90, // default JPEG quality
877
- deviceOrientation, imuTotalTranslationM);
1091
+ deviceOrientation, imuTotalTranslationM, lens);
878
1092
  if (typeof result.framesRequested === 'number'
879
1093
  && typeof result.framesIncluded === 'number'
880
1094
  && result.framesIncluded < result.framesRequested) {
@@ -883,7 +1097,19 @@ function Camera(props) {
883
1097
  included: result.framesIncluded,
884
1098
  });
885
1099
  }
886
- onCapture?.({
1100
+ // v0.16 — non-fatal quality signals attached to the result + (when
1101
+ // the crop editor shows) the crop banner. LOW_FRAME_UTILIZATION when
1102
+ // <70 % of captured frames survived; LATERAL_DRIFT_FINALIZE when item-6
1103
+ // stopped this capture early.
1104
+ const warnings = (0, captureWarnings_1.buildCaptureWarnings)({
1105
+ framesRequested: result.framesRequested,
1106
+ framesIncluded: result.framesIncluded,
1107
+ lateralFinalize: wasLateralFinalize,
1108
+ highPanSpeed: wasFastPan,
1109
+ copy: (0, cameraGuidanceCopy_1.captureWarningCopyFrom)(guidanceCopyResolved),
1110
+ });
1111
+ const captureResultObj = {
1112
+ ok: true,
887
1113
  type: 'panorama',
888
1114
  // Native finalize() returns a bare `/data/.../foo.jpg` path;
889
1115
  // normalise to `file://` for Android <Image>.
@@ -896,7 +1122,56 @@ function Camera(props) {
896
1122
  finalConfidenceThresh: result.finalConfidenceThresh ?? -1,
897
1123
  durationMs: Date.now() - (recordingStartedAt ?? Date.now()),
898
1124
  stitchModeResolved: result.stitchModeResolved,
899
- });
1125
+ rRadians: result.rRadians,
1126
+ tMeters: result.tMeters,
1127
+ decisionRatio: result.decisionRatio,
1128
+ debugSummary: result.debugSummary,
1129
+ keyframePaths: result.batchKeyframePaths,
1130
+ captureOrientation: result.captureOrientation,
1131
+ warnings,
1132
+ };
1133
+ // When the crop editor OR a plain preview is enabled AND the panorama
1134
+ // has valid intrinsic dims, defer `onCapture`: stash the result and
1135
+ // mount RectCropPreview (crop mode when `rectCrop`, preview-only when
1136
+ // just `showPreview`). The modal's confirm / use-original / retake
1137
+ // decision emits the final result. Otherwise emit immediately.
1138
+ if ((rectCrop || showPreview)
1139
+ && result.width > 0
1140
+ && result.height > 0) {
1141
+ // Crop mode only — seed the quad from the max-inscribed rectangle of
1142
+ // the (un-cropped) panorama so the editor opens on the tightest clean
1143
+ // rectangle, not a blind 8 % inset. Best-effort: an absent native
1144
+ // module / decode failure falls back to the default seed. Skipped in
1145
+ // preview-only mode (no quad to seed).
1146
+ let initialRect;
1147
+ if (rectCrop) {
1148
+ try {
1149
+ const inscribed = await (0, computeInscribedRect_1.computeInscribedRect)(captureResultObj.uri);
1150
+ if (inscribed && inscribed.width > 0 && inscribed.height > 0) {
1151
+ initialRect = {
1152
+ x: inscribed.x,
1153
+ y: inscribed.y,
1154
+ width: inscribed.width,
1155
+ height: inscribed.height,
1156
+ };
1157
+ }
1158
+ }
1159
+ catch {
1160
+ // No seed — RectCropPreview uses its default inset.
1161
+ }
1162
+ }
1163
+ setCropPending({
1164
+ uri: captureResultObj.uri,
1165
+ width: result.width,
1166
+ height: result.height,
1167
+ captureResultObj,
1168
+ initialRect,
1169
+ warnings,
1170
+ });
1171
+ }
1172
+ else {
1173
+ onCapture?.(captureResultObj);
1174
+ }
900
1175
  // 2026-05-22 (audit F9) — fire the debug stitch-stats toast on
901
1176
  // every successful finalize when settings.debug is on. Shows
902
1177
  // the leaveBiggestComponent retry telemetry + resolved mode so
@@ -907,19 +1182,30 @@ function Camera(props) {
907
1182
  }
908
1183
  catch (err) {
909
1184
  const message = err instanceof Error ? err.message : String(err);
910
- const code =
911
- // Insufficient overlap surfaces two ways: cv::Stitcher's
912
- // ERR_NEED_MORE_IMGS ("need more images") and the manual
913
- // pipeline's "0 valid pairwise matches / frames may not overlap
914
- // enough" both are the same recoverable "pan more slowly" case.
915
- /need more images|pairwise match|overlap enough/i.test(message) ? 'STITCH_NEED_MORE_IMGS'
916
- : /homography/i.test(message) ? 'STITCH_HOMOGRAPHY_FAIL'
917
- : /camera params/i.test(message) ? 'STITCH_CAMERA_PARAMS_FAIL'
918
- : /out of memory|oom/i.test(message) ? 'STITCH_OOM'
919
- : 'PANORAMA_FINALIZE_FAILED';
920
- onError?.(new CameraError(code, message, err));
1185
+ // Classify the raw native failure string → typed code. The chain
1186
+ // lives in classifyStitchError() (the load-bearing C++↔JS contract,
1187
+ // unit-tested against the actual native strings) so a future reword
1188
+ // of a cpp throw can't silently drop the "pan more slowly" path.
1189
+ const code = (0, classifyStitchError_1.classifyStitchError)(message);
1190
+ const error = new CameraError(code, message, err);
1191
+ // v0.16 surface the failure on BOTH callbacks: `onError` (unchanged
1192
+ // mirror) and `onCapture` (ok:false) so a host has one place to learn
1193
+ // the outcome. A lateral-drift stop that then failed to stitch still
1194
+ // reports that cause via the warning.
1195
+ onError?.(error);
1196
+ onCapture?.({
1197
+ ok: false,
1198
+ type: 'panorama',
1199
+ error,
1200
+ warnings: (0, captureWarnings_1.buildCaptureWarnings)({
1201
+ lateralFinalize: wasLateralFinalize,
1202
+ highPanSpeed: wasFastPan,
1203
+ copy: (0, cameraGuidanceCopy_1.captureWarningCopyFrom)(guidanceCopyResolved),
1204
+ }),
1205
+ });
921
1206
  }
922
1207
  finally {
1208
+ finalizingRef.current = false;
923
1209
  setStatusPhase('idle');
924
1210
  setRecordingStartedAt(null);
925
1211
  }
@@ -944,7 +1230,147 @@ function Camera(props) {
944
1230
  isNonAR,
945
1231
  imuGate,
946
1232
  stitchToast,
1233
+ // 2026-06-16 — the finalize passes `lens` (the high-level warper tree's zoom
1234
+ // signal); without it here the closure would send a STALE lens if the user
1235
+ // switched 1x↔0.5x after this callback was last memoized.
1236
+ lens,
1237
+ // feature/pano-ux-guidance — the release also tears down the
1238
+ // pan-duration timer + a pending rotate-gate, and decides whether to
1239
+ // route the result through the crop editor.
1240
+ clearPanTimer,
1241
+ pendingPanStart,
1242
+ rectCrop,
1243
+ showPreview,
1244
+ ]);
1245
+ // Keep `handleHoldEndRef` current so the auto-finalize timer + the
1246
+ // lateral-drift effect invoke the latest `handleHoldEnd` without
1247
+ // adding it as a dep (it changes identity on every recording tick).
1248
+ (0, react_1.useEffect)(() => {
1249
+ handleHoldEndRef.current = () => { void handleHoldEnd(); };
1250
+ });
1251
+ // ── Item 6 — lateral drift → FINALIZE + popup ───────────────────
1252
+ // Mirrors the orientation-drift effect, but FINALIZES the capture
1253
+ // (keeps what was stitched) rather than cancelling it: clear the
1254
+ // pan-duration timer, latch the popup, then call handleHoldEnd via
1255
+ // its ref. Gated off when the budget is disabled (`<= 0`).
1256
+ (0, react_1.useEffect)(() => {
1257
+ if (!panMotion.lateralExceeded
1258
+ || statusPhase !== 'recording'
1259
+ || lateralBudgetCm <= 0) {
1260
+ return;
1261
+ }
1262
+ clearPanTimer();
1263
+ // #3 — if the user veered off before enough frames were captured to
1264
+ // stitch, finalizing would fail with a misleading "need more images"
1265
+ // error. Instead ABANDON the capture (no stitch → no error) and show
1266
+ // the "follow the arrow" popup. Otherwise FINALIZE what was captured
1267
+ // (a usable partial pano) and show the "keep it straight" popup.
1268
+ const MIN_STITCHABLE_KEYFRAMES = 2;
1269
+ if (acceptedKeyframeCount < MIN_STITCHABLE_KEYFRAMES) {
1270
+ setLateralWrongDirection(true);
1271
+ setLateralStopVisible(true);
1272
+ void (async () => {
1273
+ fpDriver.stop();
1274
+ try {
1275
+ await incremental.cancel();
1276
+ }
1277
+ catch {
1278
+ // best-effort — abandonment must succeed even in a weird state.
1279
+ }
1280
+ finally {
1281
+ setStatusPhase('idle');
1282
+ setRecordingStartedAt(null);
1283
+ onCaptureAbandoned?.('lateral-drift');
1284
+ }
1285
+ })();
1286
+ return;
1287
+ }
1288
+ setLateralWrongDirection(false);
1289
+ setLateralStopVisible(true);
1290
+ // Mark this finalize as lateral-drift-triggered so handleHoldEnd attaches
1291
+ // the LATERAL_DRIFT_FINALIZE warning to the result.
1292
+ lateralFinalizeRef.current = true;
1293
+ handleHoldEndRef.current?.();
1294
+ // Deps mirror the drift effect: re-run when the latch trips or the
1295
+ // recording state changes. Other reads are stable setters / refs.
1296
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1297
+ }, [panMotion.lateralExceeded, statusPhase, lateralBudgetCm]);
1298
+ // ── Item 7 — auto-finalize when the configured keyframe count is hit ─
1299
+ // The engine caps accepted keyframes at `keyframeMaxCount`; once it
1300
+ // reports that many, no more frames will be accepted, so stop + stitch
1301
+ // (same finalize path as releasing the shutter). `handleHoldEnd`'s
1302
+ // re-entrancy latch makes this idempotent vs. a manual release in the
1303
+ // same tick. This is the PRIMARY auto-stop (the time cap is opt-in).
1304
+ const keyframeMaxCount = settings.frameSelection.maxKeyframes;
1305
+ const acceptedKeyframeCount = incrementalState?.acceptedCount ?? 0;
1306
+ // Item 4 — speed cue routed into the REC banner/border colour (green→red).
1307
+ // Gated on panGuidance so opting out keeps the banner calm/green.
1308
+ const recordingTooFast = panGuidance && panMotion.panSpeedBucket !== 'good';
1309
+ // Latch the too-fast flag for the HIGH_PAN_SPEED warning (shown on the crop
1310
+ // editor + returned in onCapture.warnings). Latches when the live cue is
1311
+ // active OR — per the user's request — when a KEYFRAME the stitch will use
1312
+ // is accepted while the pan is too fast (so a captured frame was actually
1313
+ // taken at speed). Depending on panSpeedBucket + acceptedKeyframeCount
1314
+ // directly (not just the derived `recordingTooFast`) makes the effect run
1315
+ // on every bucket / keyframe change, so a brief red window can't be missed.
1316
+ // Reset at capture start.
1317
+ const prevAcceptedForSpeedRef = (0, react_1.useRef)(0);
1318
+ (0, react_1.useEffect)(() => {
1319
+ if (statusPhase !== 'recording') {
1320
+ prevAcceptedForSpeedRef.current = acceptedKeyframeCount;
1321
+ return;
1322
+ }
1323
+ const newKeyframe = acceptedKeyframeCount > prevAcceptedForSpeedRef.current;
1324
+ prevAcceptedForSpeedRef.current = acceptedKeyframeCount;
1325
+ if (recordingTooFast) {
1326
+ if (__DEV__ && !fastPanRef.current) {
1327
+ // eslint-disable-next-line no-console
1328
+ console.log(`[panMotion] HIGH_PAN_SPEED latched (bucket=`
1329
+ + `${panMotion.panSpeedBucket} acceptedCount=${acceptedKeyframeCount}`
1330
+ + `${newKeyframe ? ' on a keyframe' : ''})`);
1331
+ }
1332
+ fastPanRef.current = true;
1333
+ }
1334
+ }, [
1335
+ statusPhase,
1336
+ recordingTooFast,
1337
+ acceptedKeyframeCount,
1338
+ panMotion.panSpeedBucket,
947
1339
  ]);
1340
+ (0, react_1.useEffect)(() => {
1341
+ if (statusPhase === 'recording'
1342
+ && keyframeMaxCount > 0
1343
+ && acceptedKeyframeCount >= keyframeMaxCount) {
1344
+ handleHoldEndRef.current?.();
1345
+ }
1346
+ }, [statusPhase, keyframeMaxCount, acceptedKeyframeCount]);
1347
+ // ── Item 3 — brief pan how-to overlay at recording start ────────
1348
+ // Show the how-to GIF + direction arrow for a short window when a
1349
+ // recording begins, then auto-fade. The component never self-times;
1350
+ // this effect owns the lifecycle.
1351
+ (0, react_1.useEffect)(() => {
1352
+ if (statusPhase !== 'recording') {
1353
+ setHowToVisible(false);
1354
+ return;
1355
+ }
1356
+ setHowToVisible(true);
1357
+ const t = setTimeout(() => setHowToVisible(false), 2500);
1358
+ return () => clearTimeout(t);
1359
+ }, [statusPhase]);
1360
+ // ── Item 5 — cosmetic countdown tick ────────────────────────────
1361
+ // While recording, bump `nowTick` ~4×/s so `countdownSecondsFrom`
1362
+ // recomputes the displayed whole-seconds. The authoritative auto-stop
1363
+ // is the `panDurationTimerRef` setTimeout, NOT this interval. Skipped
1364
+ // when the countdown feature is disabled (`maxPanDurationMs <= 0`).
1365
+ (0, react_1.useEffect)(() => {
1366
+ if (statusPhase !== 'recording' || maxPanDurationMs <= 0)
1367
+ return;
1368
+ const id = setInterval(() => setNowTick(Date.now()), 250);
1369
+ return () => clearInterval(id);
1370
+ }, [statusPhase, maxPanDurationMs]);
1371
+ // Whole seconds remaining for the countdown overlay (item 5). Pure
1372
+ // helper; clamps to [0, round(maxPanDurationMs/1000)].
1373
+ const countdownSeconds = (0, captureCountdown_1.countdownSecondsFrom)(recordingStartedAt, nowTick, maxPanDurationMs);
948
1374
  // ── Lens / AR-toggle handlers ───────────────────────────────────
949
1375
  const handleLensChange = (0, react_1.useCallback)((next) => {
950
1376
  setLens(next);
@@ -993,8 +1419,13 @@ function Camera(props) {
993
1419
  : insets.top + 8;
994
1420
  // ── JSX ─────────────────────────────────────────────────────────
995
1421
  return (react_1.default.createElement(react_native_1.View, { style: [styles.container, style] },
996
- inFlightTransition || arSupportPending ? (react_1.default.createElement(react_native_1.View, { style: [react_native_1.StyleSheet.absoluteFill, styles.transitionPlaceholder] },
997
- react_1.default.createElement(react_native_1.Text, { style: styles.transitionLabel }, "Switching camera\u2026"))) : isAR ? (react_1.default.createElement(ARCameraView_1.ARCameraView, { ref: arViewRef, style: react_native_1.StyleSheet.absoluteFill })) : (react_1.default.createElement(CameraView_1.CameraView, { ref: visionCameraRef, device: capture.device, isActive: true,
1422
+ cameraShouldUnmount(inFlightTransition, arSupportPending, statusPhase) ? (
1423
+ // statusPhase==='stitching' UNMOUNTS the camera so vision-camera
1424
+ // frees the AVCaptureSession + preview buffers during the stitch
1425
+ // (V12.14.8 OOM fix). The CaptureStatusOverlay renders the
1426
+ // "Stitching…" state on top, so no placeholder label is needed
1427
+ // in that case — only for the camera-switch transition.
1428
+ react_1.default.createElement(react_native_1.View, { style: [react_native_1.StyleSheet.absoluteFill, styles.transitionPlaceholder] }, statusPhase === 'stitching' ? null : (react_1.default.createElement(react_native_1.Text, { style: styles.transitionLabel }, "Switching camera\u2026")))) : isAR ? (react_1.default.createElement(ARCameraView_1.ARCameraView, { ref: arViewRef, style: react_native_1.StyleSheet.absoluteFill })) : (react_1.default.createElement(CameraView_1.CameraView, { ref: visionCameraRef, device: capture.device, isActive: true,
998
1429
  // `video={true}` is REQUIRED for takeSnapshot to work on iOS.
999
1430
  // vision-camera v4's iOS implementation of takeSnapshot waits
1000
1431
  // for a frame on the video pipeline; with video disabled, the
@@ -1027,7 +1458,12 @@ function Camera(props) {
1027
1458
  const msg = e?.message ?? String(err);
1028
1459
  onError?.(new CameraError('VISION_CAMERA_RUNTIME', `${codeStr}: ${msg}`, err));
1029
1460
  } })),
1030
- react_1.default.createElement(CaptureStatusOverlay_1.CaptureStatusOverlay, { phase: statusPhase, topInset: insets.top, recordingStartedAt: recordingStartedAt ?? undefined }),
1461
+ react_1.default.createElement(CaptureStatusOverlay_1.CaptureStatusOverlay, { phase: statusPhase, topInset: insets.top, recordingStartedAt: recordingStartedAt ?? undefined, tooFast: recordingTooFast, recordingMessage: recordingTooFast
1462
+ ? guidanceCopyResolved.tooFast
1463
+ : guidanceCopyResolved.statusRecording, stitchingMessage: guidanceCopyResolved.statusStitching }),
1464
+ react_1.default.createElement(CaptureFrameCounterOverlay_1.CaptureFrameCounterOverlay, { visible: statusPhase === 'recording' && panGuidance, framesCaptured: acceptedKeyframeCount, framesMax: keyframeMaxCount, orientation: deviceOrientation }),
1465
+ react_1.default.createElement(CaptureCountdownOverlay_1.CaptureCountdownOverlay, { visible: statusPhase === 'recording' && panGuidance && maxPanDurationMs > 0, secondsRemaining: countdownSeconds, orientation: deviceOrientation }),
1466
+ react_1.default.createElement(PanHowToOverlay_1.PanHowToOverlay, { visible: statusPhase === 'recording' && panGuidance && howToVisible, orientation: deviceOrientation }),
1031
1467
  settings.debug && (react_1.default.createElement(react_1.default.Fragment, null,
1032
1468
  react_1.default.createElement(CaptureOrientationPill_1.CaptureOrientationPill, { orientation: deviceOrientation, topInset: insets.top }),
1033
1469
  react_1.default.createElement(CaptureKeyframePill_1.CaptureKeyframePill, { state: incrementalState, topInset: insets.top }),
@@ -1069,8 +1505,91 @@ function Camera(props) {
1069
1505
  contentRotation,
1070
1506
  ] }, "\u26A1")))),
1071
1507
  react_1.default.createElement(PanoramaSettingsModal_1.PanoramaSettingsModal, { visible: settingsModalVisible, settings: settings, onChange: setSettings, onClose: () => setSettingsModalVisible(false) }),
1508
+ react_1.default.createElement(RotateToLandscapePrompt_1.RotateToLandscapePrompt, { visible: pendingPanStart, target: (0, panModeGate_1.gateTargetOrientation)(panMode) ?? 'landscape', copy: (0, panModeGate_1.gateTargetOrientation)(panMode) === 'portrait'
1509
+ ? guidanceCopyResolved.rotateToPortrait
1510
+ : guidanceCopyResolved.rotateToLandscape }),
1072
1511
  react_1.default.createElement(OrientationDriftModal_1.OrientationDriftModal, { visible: drift.drifted && !driftModalDismissed, captureOrientation: drift.captureOrientation, currentOrientation: drift.currentOrientation, onAcknowledge: () => setDriftModalDismissed(true) }),
1073
- react_1.default.createElement(CapturePreview_1.CapturePreview, { visible: capturePreview != null, imageUri: capturePreview?.imageUri ?? '', imageWidth: capturePreview?.imageWidth, imageHeight: capturePreview?.imageHeight, title: capturePreview?.title, actions: capturePreviewActions, onClose: onCapturePreviewClose ?? noop })));
1512
+ react_1.default.createElement(LateralMotionModal_1.LateralMotionModal, { visible: lateralStopVisible, title: lateralWrongDirection
1513
+ ? guidanceCopyResolved.lateralWrongDirectionTitle
1514
+ : guidanceCopyResolved.lateralStopTitle, body: lateralWrongDirection
1515
+ ? guidanceCopyResolved.lateralWrongDirectionBody
1516
+ : guidanceCopyResolved.lateralStopBody, dismissLabel: guidanceCopyResolved.lateralStopDismiss, onDismiss: () => {
1517
+ setLateralStopVisible(false);
1518
+ setLateralWrongDirection(false);
1519
+ } }),
1520
+ react_1.default.createElement(CapturePreview_1.CapturePreview, { visible: capturePreview != null, imageUri: capturePreview?.imageUri ?? '', imageWidth: capturePreview?.imageWidth, imageHeight: capturePreview?.imageHeight, title: capturePreview?.title, actions: capturePreviewActions, onClose: onCapturePreviewClose ?? noop }),
1521
+ react_1.default.createElement(RectCropPreview_1.RectCropPreview
1522
+ // Remount per capture so the dragged-quad + layout state re-seed to
1523
+ // the new image (RectCropPreview seeds its quad once via useState).
1524
+ , {
1525
+ // Remount per capture so the dragged-quad + layout state re-seed to
1526
+ // the new image (RectCropPreview seeds its quad once via useState).
1527
+ key: cropPending?.uri ?? 'crop', visible: cropPending != null, imageUri: cropPending?.uri ?? '', imageWidth: cropPending?.width ?? 0, imageHeight: cropPending?.height ?? 0, initialRect: cropPending?.initialRect, warnings: cropPending?.warnings.map((w) => w.message) ?? [], showCropControls: rectCrop, topInset: insets.top, bottomInset: insets.bottom, copy: guidanceCopyResolved,
1528
+ // Carry the live memory pill onto the preview too (same settings.debug
1529
+ // gate as the camera), so the operator can watch the RSS spike when the
1530
+ // on-demand high-level re-stitch fires.
1531
+ showMemoryPill: settings.debug,
1532
+ // DEV overlay — show the stitcher's runtime choices (pipeline / warper /
1533
+ // route / seam / blend) + score / frames / size for this output, so the
1534
+ // operator can see HOW it was built. __DEV__ only.
1535
+ debugInfo: __DEV__ && cropPending
1536
+ ? (0, stitchDebugInfo_1.buildStitchDebugInfo)(cropPending.captureResultObj)
1537
+ : undefined, onUseOriginal: (altUri) => {
1538
+ if (cropPending) {
1539
+ // altUri set → the user picked the alt (manual) pipeline's output
1540
+ // in the A/B toggle; emit THAT image (cache-bust for <Image>).
1541
+ onCapture?.(altUri
1542
+ ? {
1543
+ ...cropPending.captureResultObj,
1544
+ uri: `${altUri}?t=${Date.now()}`,
1545
+ }
1546
+ : cropPending.captureResultObj);
1547
+ }
1548
+ setCropPending(null);
1549
+ }, onRetake: () => {
1550
+ // Discard this capture entirely — no onCapture — and return to
1551
+ // the live camera (statusPhase is already 'idle' post-finalize).
1552
+ setCropPending(null);
1553
+ }, onConfirm: async ({ quad, perspective }) => {
1554
+ if (!cropPending)
1555
+ return;
1556
+ const pending = cropPending;
1557
+ // perspective=true → rectify the dragged quad to an upright
1558
+ // rectangle (cropToQuad). perspective=false (the user dragged a
1559
+ // ~rectangular quad) → crop to the quad's axis-aligned bounding box
1560
+ // — a plain crop, no warp.
1561
+ const xs = quad.map((p) => p.x);
1562
+ const ys = quad.map((p) => p.y);
1563
+ const cropPoints = perspective
1564
+ ? quad
1565
+ : [
1566
+ { x: Math.min(...xs), y: Math.min(...ys) },
1567
+ { x: Math.max(...xs), y: Math.min(...ys) },
1568
+ { x: Math.max(...xs), y: Math.max(...ys) },
1569
+ { x: Math.min(...xs), y: Math.max(...ys) },
1570
+ ];
1571
+ try {
1572
+ // cropQuad takes a BARE path; the stashed uri is a file://
1573
+ // URI. Overwrites in place (pass the same path).
1574
+ const cropped = await (0, cropQuad_1.cropQuad)((0, paths_1.toBareFilePath)(pending.uri), cropPoints, undefined, { quality: 90 });
1575
+ onCapture?.({
1576
+ ...pending.captureResultObj,
1577
+ // Cache-bust so <Image> reloads the overwritten file.
1578
+ uri: `${(0, paths_1.toFileUri)(cropped.outputPath)}?t=${Date.now()}`,
1579
+ width: cropped.width,
1580
+ height: cropped.height,
1581
+ });
1582
+ }
1583
+ catch (err) {
1584
+ onError?.(new CameraError('OUTPUT_WRITE_FAILED', err instanceof Error ? err.message : String(err), err));
1585
+ // Fall back to the un-cropped panorama so the capture isn't
1586
+ // lost on a crop failure.
1587
+ onCapture?.(pending.captureResultObj);
1588
+ }
1589
+ finally {
1590
+ setCropPending(null);
1591
+ }
1592
+ } })));
1074
1593
  }
1075
1594
  function noop() {
1076
1595
  /* no-op handler used when panorama mode is disabled */
@@ -1102,6 +1621,26 @@ function isSideEdge(edge) {
1102
1621
  exports._homeIndicatorEdgeForTests = homeIndicatorEdge;
1103
1622
  /** @internal test-only — see `isSideEdge`. */
1104
1623
  exports._isSideEdgeForTests = isSideEdge;
1624
+ /**
1625
+ * cameraShouldUnmount — whether the live camera (<CameraView> /
1626
+ * <ARCameraView>) should be UNMOUNTED (replaced by the placeholder) this
1627
+ * render rather than mounted.
1628
+ *
1629
+ * True while a camera-switch transition or AR-support probe is in flight,
1630
+ * OR during the stitch (statusPhase==='stitching'). The stitching case is
1631
+ * the V12.14.8 OOM fix: unmounting frees vision-camera's AVCaptureSession +
1632
+ * preview buffers (~150-250 MB) BEFORE the memory-heavy stitch, so the
1633
+ * live-camera footprint and the stitch peak never coexist and jetsam (iOS)
1634
+ * / lmkd (Android) don't OOM-kill the app.
1635
+ *
1636
+ * Pure + exported for test — the lib's jest config can't mount <Camera>,
1637
+ * so this boolean is the unit-testable core of the OOM render gate.
1638
+ */
1639
+ function cameraShouldUnmount(inFlightTransition, arSupportPending, statusPhase) {
1640
+ return inFlightTransition || arSupportPending || statusPhase === 'stitching';
1641
+ }
1642
+ /** @internal test-only — see `cameraShouldUnmount`. */
1643
+ exports._cameraShouldUnmountForTests = cameraShouldUnmount;
1105
1644
  /**
1106
1645
  * v0.12.0 — bottom-controls outer container positioning. Anchors
1107
1646
  * to the home-indicator JS edge with the appropriate flex direction