react-native-image-stitcher 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +124 -1
  2. package/README.md +116 -5
  3. package/android/src/main/cpp/image_stitcher_jni.cpp +107 -11
  4. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
  5. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +87 -30
  6. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
  8. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
  9. package/cpp/crop_quad.cpp +162 -0
  10. package/cpp/crop_quad.hpp +163 -0
  11. package/cpp/stitcher.cpp +651 -55
  12. package/cpp/stitcher.hpp +10 -0
  13. package/cpp/warp_guard.hpp +212 -0
  14. package/dist/camera/Camera.d.ts +196 -12
  15. package/dist/camera/Camera.js +629 -35
  16. package/dist/camera/CameraView.js +35 -16
  17. package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
  18. package/dist/camera/CaptureCountdownOverlay.js +239 -0
  19. package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
  20. package/dist/camera/CaptureFrameCounterOverlay.js +142 -0
  21. package/dist/camera/CaptureMemoryPill.d.ts +9 -1
  22. package/dist/camera/CaptureMemoryPill.js +3 -3
  23. package/dist/camera/CapturePreview.js +2 -1
  24. package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
  25. package/dist/camera/CaptureStatusOverlay.js +22 -5
  26. package/dist/camera/CaptureThumbnailStrip.js +2 -1
  27. package/dist/camera/LateralMotionModal.d.ts +85 -0
  28. package/dist/camera/LateralMotionModal.js +134 -0
  29. package/dist/camera/PanHowToOverlay.d.ts +76 -0
  30. package/dist/camera/PanHowToOverlay.js +222 -0
  31. package/dist/camera/PanoramaSettings.d.ts +8 -6
  32. package/dist/camera/PanoramaSettings.js +26 -5
  33. package/dist/camera/PanoramaSettingsModal.js +4 -4
  34. package/dist/camera/RectCropPreview.d.ts +161 -0
  35. package/dist/camera/RectCropPreview.js +480 -0
  36. package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
  37. package/dist/camera/RotateToLandscapePrompt.js +138 -0
  38. package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
  39. package/dist/camera/buildPanoramaInitialSettings.js +9 -0
  40. package/dist/camera/cameraErrorMessages.d.ts +30 -1
  41. package/dist/camera/cameraErrorMessages.js +26 -10
  42. package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
  43. package/dist/camera/cameraGuidanceCopy.js +80 -0
  44. package/dist/camera/captureCountdown.d.ts +52 -0
  45. package/dist/camera/captureCountdown.js +76 -0
  46. package/dist/camera/captureWarnings.d.ts +90 -0
  47. package/dist/camera/captureWarnings.js +108 -0
  48. package/dist/camera/classifyStitchError.d.ts +30 -0
  49. package/dist/camera/classifyStitchError.js +42 -0
  50. package/dist/camera/cropGeometry.d.ts +136 -0
  51. package/dist/camera/cropGeometry.js +223 -0
  52. package/dist/camera/displayDecodeImageProps.d.ts +25 -0
  53. package/dist/camera/displayDecodeImageProps.js +29 -0
  54. package/dist/camera/guidanceGraphics.d.ts +58 -0
  55. package/dist/camera/guidanceGraphics.js +280 -0
  56. package/dist/camera/guidanceTokens.d.ts +54 -0
  57. package/dist/camera/guidanceTokens.js +58 -0
  58. package/dist/camera/panModeGate.d.ts +54 -0
  59. package/dist/camera/panModeGate.js +62 -0
  60. package/dist/camera/pickCaptureFormat.d.ts +71 -0
  61. package/dist/camera/pickCaptureFormat.js +85 -0
  62. package/dist/camera/stitchDebugInfo.d.ts +27 -0
  63. package/dist/camera/stitchDebugInfo.js +55 -0
  64. package/dist/camera/usePanMotion.d.ts +250 -0
  65. package/dist/camera/usePanMotion.js +451 -0
  66. package/dist/index.d.ts +24 -3
  67. package/dist/index.js +33 -2
  68. package/dist/stitching/computeInscribedRect.d.ts +40 -0
  69. package/dist/stitching/computeInscribedRect.js +55 -0
  70. package/dist/stitching/cropQuad.d.ts +78 -0
  71. package/dist/stitching/cropQuad.js +116 -0
  72. package/dist/stitching/incremental.d.ts +45 -0
  73. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +56 -8
  74. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
  75. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
  76. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
  77. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +191 -7
  78. package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
  79. package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
  80. package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
  81. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
  82. package/package.json +5 -1
  83. package/src/camera/Camera.tsx +994 -47
  84. package/src/camera/CameraView.tsx +48 -16
  85. package/src/camera/CaptureCountdownOverlay.tsx +272 -0
  86. package/src/camera/CaptureFrameCounterOverlay.tsx +183 -0
  87. package/src/camera/CaptureMemoryPill.tsx +17 -3
  88. package/src/camera/CapturePreview.tsx +5 -0
  89. package/src/camera/CaptureStatusOverlay.tsx +35 -7
  90. package/src/camera/CaptureThumbnailStrip.tsx +4 -0
  91. package/src/camera/LateralMotionModal.tsx +199 -0
  92. package/src/camera/PanHowToOverlay.tsx +246 -0
  93. package/src/camera/PanoramaSettings.ts +34 -11
  94. package/src/camera/PanoramaSettingsModal.tsx +4 -4
  95. package/src/camera/RectCropPreview.tsx +820 -0
  96. package/src/camera/RotateToLandscapePrompt.tsx +188 -0
  97. package/src/camera/buildPanoramaInitialSettings.ts +30 -1
  98. package/src/camera/cameraErrorMessages.ts +39 -2
  99. package/src/camera/cameraGuidanceCopy.ts +145 -0
  100. package/src/camera/captureCountdown.ts +83 -0
  101. package/src/camera/captureWarnings.ts +190 -0
  102. package/src/camera/classifyStitchError.ts +68 -0
  103. package/src/camera/cropGeometry.ts +268 -0
  104. package/src/camera/displayDecodeImageProps.ts +25 -0
  105. package/src/camera/guidanceGraphics.tsx +347 -0
  106. package/src/camera/guidanceTokens.ts +57 -0
  107. package/src/camera/panModeGate.ts +81 -0
  108. package/src/camera/pickCaptureFormat.ts +130 -0
  109. package/src/camera/stitchDebugInfo.ts +71 -0
  110. package/src/camera/usePanMotion.ts +667 -0
  111. package/src/index.ts +66 -3
  112. package/src/stitching/computeInscribedRect.ts +81 -0
  113. package/src/stitching/cropQuad.ts +167 -0
  114. package/src/stitching/incremental.ts +45 -0
  115. package/cpp/tests/CMakeLists.txt +0 -104
  116. package/cpp/tests/README.md +0 -86
  117. package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
  118. package/cpp/tests/pose_test.cpp +0 -74
  119. package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
  120. package/cpp/tests/stubs/jsi/jsi.h +0 -33
  121. package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
  122. package/cpp/tests/warp_guard_test.cpp +0 -48
  123. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
  124. package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
  125. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
  126. package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
  127. package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
  128. package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
  129. package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
  130. package/src/camera/__tests__/useContentRotation.test.ts +0 -89
  131. package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
  132. package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
  133. package/src/stitching/__tests__/useStitcherWorklet.test.ts +0 -202
@@ -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,82 @@ 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);
382
+ // 2026-06-15 — ON-DEMAND high-level preview. Manual is the default/eager
383
+ // output; when the user switches to the "high-level" tab in the preview we
384
+ // re-stitch the SAME captured keyframes through stock cv::Stitcher via
385
+ // `refinePanorama` (useManualPipeline:false). Resolves with the high-level
386
+ // JPEG's file:// uri AND its OWN DEV-overlay recipe (so the preview pill shows
387
+ // the high-level recipe — pipe=highlevel;… — while that tab is viewed, not the
388
+ // manual primary's recipe), or null when unavailable (no keyframe paths —
389
+ // e.g. Android — or the stitch failed). Computed lazily so it costs nothing
390
+ // unless the user actually asks for it.
391
+ const requestHighLevelAlt = (0, react_1.useCallback)(async () => {
392
+ const pending = cropPending;
393
+ const kf = pending?.captureResultObj.keyframePaths;
394
+ if (!pending || !kf || kf.length < 2)
395
+ return null;
396
+ const native = (0, incremental_1.getIncrementalNativeModule)();
397
+ if (!native)
398
+ return null;
399
+ const outputPath = `${(0, paths_1.toBareFilePath)(pending.uri).replace(/\.jpg$/i, '')}-highlevel.jpg`;
400
+ try {
401
+ const r = await native.refinePanorama({
402
+ framePaths: kf,
403
+ outputPath,
404
+ config: {
405
+ useManualPipeline: false,
406
+ warperType: 'spherical',
407
+ stitchMode: 'panorama',
408
+ // Match the manual output's rotation — without this the high-level
409
+ // re-stitch bakes "portrait" (no rotation) and comes out sideways.
410
+ captureOrientation: pending.captureResultObj.captureOrientation,
411
+ },
412
+ });
413
+ // Plain file:// uri — the path is unique per capture and computed once, so
414
+ // no cache-bust here (the accept handler adds one when emitting). The
415
+ // DEV pill text is the HIGH-LEVEL stitch's own recipe (only the fields
416
+ // IncrementalRefineResult carries; buildStitchDebugInfo tolerates the rest
417
+ // being absent).
418
+ return {
419
+ uri: (0, paths_1.toFileUri)(r.panoramaPath),
420
+ debugInfo: (0, stitchDebugInfo_1.buildStitchDebugInfo)({
421
+ debugSummary: r.debugSummary,
422
+ finalConfidenceThresh: r.finalConfidenceThresh,
423
+ framesIncluded: r.framesIncluded,
424
+ framesRequested: r.framesRequested,
425
+ width: r.width,
426
+ height: r.height,
427
+ }),
428
+ };
429
+ }
430
+ catch {
431
+ return null;
432
+ }
433
+ }, [cropPending]);
320
434
  // 2026-05-22 (audit F9 + F3) — debug stitch-stats toast. Hook
321
435
  // exposes an imperative API; we fire `showResult(finalizeResult)`
322
436
  // on every successful finalize when settings.debug is on (gated
@@ -350,6 +464,17 @@ function Camera(props) {
350
464
  // cases that resolve to AR once support is confirmed.
351
465
  const arSupportPending = arPreference && lens !== '0.5x' && !isARSupportProbed;
352
466
  const deviceOrientation = (0, useDeviceOrientation_1.useDeviceOrientation)();
467
+ // ── Panorama GUIDANCE — shared motion signals (item 3/4/6) ──────
468
+ // One gyro + one accelerometer subscription, live only while a non-AR
469
+ // capture is recording. Feeds the too-fast pill (`panSpeedBucket`)
470
+ // and the lateral-drift FINALIZE (`lateralExceeded`). `panTooFast-
471
+ // Threshold` (if set) tunes the 'warn'→'bad' boundary; `lateralBudget-
472
+ // Cm` tunes the drift latch (0 disables the latch in the hook).
473
+ const panMotion = (0, usePanMotion_1.usePanMotion)({
474
+ active: statusPhase === 'recording' && isNonAR,
475
+ warnMaxRadPerSec: panTooFastThreshold,
476
+ lateralBudgetCm,
477
+ });
353
478
  // v0.13.1 — counter-rotation for control CONTENT (AR toggle, lens
354
479
  // pill, flash icon, thumbnails) so their labels read upright relative
355
480
  // to gravity when the device is held landscape under a PORTRAIT-LOCKED
@@ -522,9 +647,43 @@ function Camera(props) {
522
647
  // The imperative pattern (start on hold-start, stop on hold-end)
523
648
  // avoids the re-render churn entirely.
524
649
  const fpDriver = (0, useFrameProcessorDriver_1.useFrameProcessorDriver)();
525
- // Safety: stop the driver if the component unmounts mid-recording.
650
+ // Safety: stop the driver AND clear the pan-duration auto-finalize
651
+ // timer if the component unmounts mid-recording (item 5 exit path #4).
526
652
  // eslint-disable-next-line react-hooks/exhaustive-deps
527
- (0, react_1.useEffect)(() => () => { fpDriver.stop(); }, []);
653
+ (0, react_1.useEffect)(() => () => { fpDriver.stop(); clearPanTimer(); }, []);
654
+ // ── Panorama GUIDANCE — auto-finalize timer + ref bridges ───────
655
+ // The 9 s pan-duration ceiling (item 5) is an authoritative
656
+ // `setTimeout` (not derived from the cosmetic countdown tick). Stored
657
+ // in a ref so the start logic can schedule it and ALL four capture-exit
658
+ // paths (manual release, drift cancel, lateral stop, unmount) clear it.
659
+ const panDurationTimerRef = (0, react_1.useRef)(null);
660
+ const clearPanTimer = (0, react_1.useCallback)(() => {
661
+ if (panDurationTimerRef.current) {
662
+ clearTimeout(panDurationTimerRef.current);
663
+ panDurationTimerRef.current = null;
664
+ }
665
+ }, []);
666
+ // `handleHoldEnd` / `startCapture` are defined further down but are
667
+ // referenced from effects + timers declared above them. Refs break
668
+ // the declaration-order + circular-useCallback-dep cycle: each is
669
+ // kept current by a commit-phase effect, and callers invoke via the
670
+ // ref (`handleHoldEndRef.current?.()`) — mirroring how the drift
671
+ // effect avoids putting these in its dep array.
672
+ const handleHoldEndRef = (0, react_1.useRef)(null);
673
+ const startCaptureRef = (0, react_1.useRef)(null);
674
+ // Synchronous re-entrancy latch for the finalize path: the auto-finalize
675
+ // timer and a manual release can both pass the async statusPhase guard in
676
+ // the same tick before React commits 'stitching'.
677
+ const finalizingRef = (0, react_1.useRef)(false);
678
+ // Item 6 — set by the lateral-drift effect just before it calls
679
+ // handleHoldEnd, so the finalize knows this stop was a sideways-drift
680
+ // auto-stop and can attach the LATERAL_DRIFT_FINALIZE warning. Consumed
681
+ // (reset) at the start of handleHoldEnd so it never leaks to the next pan.
682
+ const lateralFinalizeRef = (0, react_1.useRef)(false);
683
+ // Item 4 — latched true if the pan ever exceeded the recommended pace (the
684
+ // live "too fast" cue fired) during the capture, so the finalize attaches a
685
+ // HIGH_PAN_SPEED warning. Reset at capture start; consumed at finalize.
686
+ const fastPanRef = (0, react_1.useRef)(false);
528
687
  // ── v0.12.0 — Orientation drift detection + auto-abandon ────────
529
688
  //
530
689
  // The incremental engine supports both portrait (Mode B, horizontal
@@ -541,11 +700,20 @@ function Camera(props) {
541
700
  // the engine spec.
542
701
  const drift = (0, useOrientationDrift_1.useOrientationDrift)(statusPhase === 'recording');
543
702
  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.
703
+ // Reset the modal flags when a new capture STARTS (statusPhase
704
+ // 'recording'), NOT when one stops. v0.16 fix: the old "any non-recording
705
+ // state" condition cleared `lateralStopVisible` the instant a lateral stop
706
+ // moved statusPhase out of 'recording' — so the popup was hidden before it
707
+ // could ever show (the user only saw the downstream error). Clearing on
708
+ // capture START instead lets the lateral / drift popups persist after the
709
+ // stop until the user dismisses them, while still giving the next capture
710
+ // a clean slate.
546
711
  (0, react_1.useEffect)(() => {
547
- if (statusPhase !== 'recording')
712
+ if (statusPhase === 'recording') {
548
713
  setDriftModalDismissed(false);
714
+ setLateralStopVisible(false);
715
+ setLateralWrongDirection(false);
716
+ }
549
717
  }, [statusPhase]);
550
718
  (0, react_1.useEffect)(() => {
551
719
  if (!drift.drifted || statusPhase !== 'recording')
@@ -563,6 +731,9 @@ function Camera(props) {
563
731
  // through `onError` — abandonment must succeed even if the engine
564
732
  // is in a weird state.
565
733
  void (async () => {
734
+ // item 5 exit path #2 — kill the pan-duration auto-finalize timer
735
+ // so it can't fire into an already-cancelled capture.
736
+ clearPanTimer();
566
737
  fpDriver.stop();
567
738
  try {
568
739
  await incremental.cancel();
@@ -747,25 +918,29 @@ function Camera(props) {
747
918
  width = result.width;
748
919
  height = result.height;
749
920
  }
750
- onCapture?.({ type: 'photo', uri, width, height });
921
+ onCapture?.({ ok: true, type: 'photo', uri, width, height, warnings: [] });
751
922
  }
752
923
  catch (err) {
753
924
  const e = err instanceof CameraError
754
925
  ? err
755
926
  : new CameraError('PHOTO_CAPTURE_FAILED', err instanceof Error ? err.message : String(err), err);
927
+ // v0.16 — failures now reach `onCapture` too (ok:false), with
928
+ // `onError` kept as a mirror so existing handlers keep working.
756
929
  onError?.(e);
930
+ onCapture?.({ ok: false, type: 'photo', error: e, warnings: [] });
757
931
  }
758
932
  }, [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
- }
933
+ // ── startCapture the "actually start recording" logic ─────────
934
+ // Extracted from `handleHoldStart` so the rotate-to-landscape gate
935
+ // (item 1/2) can DEFER it: a portrait Mode-A hold latches
936
+ // `pendingPanStart` and an effect calls this once the user rotates.
937
+ // Identical behaviour to the inline body it replaced — the only new
938
+ // line is the item-5 auto-finalize timer scheduled right after
939
+ // `setRecordingStartedAt`.
940
+ const startCapture = (0, react_1.useCallback)(async () => {
766
941
  try {
767
942
  // 2026-05-23 (race fix) — synchronously clear thumbnails +
768
- // engine state at the top of handleHoldStart, BEFORE awaiting
943
+ // engine state at the top of startCapture, BEFORE awaiting
769
944
  // incremental.start(). In the previous effect-based design
770
945
  // the GL thread could ingest an AR frame during the await
771
946
  // window and add to thumbnails BEFORE React's
@@ -775,8 +950,21 @@ function Camera(props) {
775
950
  // an empty array and accumulates from there.
776
951
  setBatchKeyframeThumbnails([]);
777
952
  setIncrementalState(null);
953
+ // Item 4 — fresh capture: clear the latched too-fast flag.
954
+ fastPanRef.current = false;
778
955
  setStatusPhase('recording');
779
956
  setRecordingStartedAt(Date.now());
957
+ // Item 5 — schedule the hard-ceiling auto-finalize. Fires
958
+ // `handleHoldEnd` (via ref to dodge the circular useCallback dep),
959
+ // which finalizes what's captured — the FINALIZE-on-zero product
960
+ // decision. Cleared on every other capture-exit path. Skipped
961
+ // when the feature is disabled (`maxPanDurationMs <= 0`).
962
+ clearPanTimer();
963
+ if (maxPanDurationMs > 0) {
964
+ panDurationTimerRef.current = setTimeout(() => {
965
+ handleHoldEndRef.current?.();
966
+ }, maxPanDurationMs);
967
+ }
780
968
  const orientationRotation = deviceOrientation === 'portrait' ? 90
781
969
  : deviceOrientation === 'portrait-upside-down' ? 270
782
970
  : 0;
@@ -835,10 +1023,10 @@ function Camera(props) {
835
1023
  }
836
1024
  catch (err) {
837
1025
  setStatusPhase('idle');
1026
+ clearPanTimer();
838
1027
  onError?.(new CameraError('PANORAMA_START_FAILED', err instanceof Error ? err.message : String(err), err));
839
1028
  }
840
1029
  }, [
841
- enablePanoramaMode,
842
1030
  incremental,
843
1031
  isNonAR,
844
1032
  deviceOrientation,
@@ -848,15 +1036,93 @@ function Camera(props) {
848
1036
  fpDriver,
849
1037
  engine,
850
1038
  onError,
1039
+ maxPanDurationMs,
1040
+ clearPanTimer,
851
1041
  ]);
1042
+ // Keep the ref current so the auto-finalize timer + the rotate-resume
1043
+ // effect can invoke the latest `startCapture` without taking it as a
1044
+ // dep (which would re-run them on every recording-driven re-render).
1045
+ (0, react_1.useEffect)(() => {
1046
+ startCaptureRef.current = () => { void startCapture(); };
1047
+ });
1048
+ // ── handleHoldStart — early guards + the rotate-to-landscape gate ─
1049
+ // The "actually start" body lives in `startCapture`; this wrapper only
1050
+ // decides WHETHER to start now. Under Mode A in portrait it latches
1051
+ // `pendingPanStart` instead (item 1/2) and the resume effect below
1052
+ // starts the capture once the user rotates to landscape.
1053
+ const handleHoldStart = (0, react_1.useCallback)(() => {
1054
+ if (!enablePanoramaMode)
1055
+ return;
1056
+ if (!(0, incremental_1.incrementalStitcherIsAvailable)()) {
1057
+ onError?.(new CameraError('PANORAMA_START_FAILED', 'Native incremental stitcher module not available'));
1058
+ return;
1059
+ }
1060
+ if ((0, panModeGate_1.shouldGateForPanMode)(panMode, deviceOrientation)) {
1061
+ // Mode-A + portrait — block the start and show the rotate prompt.
1062
+ // The resume effect picks this up the instant the device rotates.
1063
+ setPendingPanStart(true);
1064
+ return;
1065
+ }
1066
+ void startCapture();
1067
+ }, [
1068
+ enablePanoramaMode,
1069
+ onError,
1070
+ panMode,
1071
+ deviceOrientation,
1072
+ startCapture,
1073
+ ]);
1074
+ // ── Rotate-to-landscape resume (item 1/2) ───────────────────────
1075
+ // When a hold was gated (`pendingPanStart`) and the user has since
1076
+ // rotated so the gate no longer fires, start the deferred capture.
1077
+ // Invoked through `startCaptureRef` (kept current above) so this
1078
+ // effect's deps don't churn on every recording re-render.
1079
+ (0, react_1.useEffect)(() => {
1080
+ if (pendingPanStart && !(0, panModeGate_1.shouldGateForPanMode)(panMode, deviceOrientation)) {
1081
+ setPendingPanStart(false);
1082
+ startCaptureRef.current?.();
1083
+ }
1084
+ }, [pendingPanStart, deviceOrientation, panMode]);
852
1085
  const handleHoldEnd = (0, react_1.useCallback)(async () => {
1086
+ // Item 5 exit path #1 — always kill the auto-finalize timer on
1087
+ // release, even on the early-return below (it's idempotent).
1088
+ clearPanTimer();
1089
+ // Item 1/2 — if the shutter is released while a rotate-gated hold is
1090
+ // pending (user let go before rotating to landscape), abandon the
1091
+ // deferred start rather than starting on the next rotation.
1092
+ if (pendingPanStart)
1093
+ setPendingPanStart(false);
853
1094
  if (statusPhase !== 'recording')
854
1095
  return;
1096
+ // Re-entrancy latch — close the timer-vs-release double-finalize window
1097
+ // synchronously so incremental.finalize()/onCapture fire exactly once.
1098
+ if (finalizingRef.current)
1099
+ return;
1100
+ finalizingRef.current = true;
1101
+ // Consume the lateral-drift flag once, here, so it's cleared on BOTH the
1102
+ // success and failure paths and never leaks into the next capture.
1103
+ const wasLateralFinalize = lateralFinalizeRef.current;
1104
+ lateralFinalizeRef.current = false;
1105
+ const wasFastPan = fastPanRef.current;
1106
+ fastPanRef.current = false;
1107
+ if (__DEV__) {
1108
+ // eslint-disable-next-line no-console
1109
+ console.log(`[capture] finalize: wasFastPan=${wasFastPan} `
1110
+ + `wasLateralFinalize=${wasLateralFinalize}`);
1111
+ }
855
1112
  setStatusPhase('stitching');
856
1113
  // Stop pumping new frames before finalizing so the engine isn't
857
1114
  // racing the final cv::Stitcher pass against late-arriving
858
1115
  // keyframes. No-op in AR mode (the driver was never started).
859
1116
  fpDriver.stop();
1117
+ // V12.14.8 restore (regressed in the SDK camera extraction): the
1118
+ // render below unmounts <CameraView>/<ARCameraView> while
1119
+ // statusPhase==='stitching'. Yield a macrotask so React commits that
1120
+ // unmount and vision-camera tears down the AVCaptureSession + preview
1121
+ // buffers (~150-250 MB) BEFORE the memory-heavy stitch runs. Without
1122
+ // it the live-camera footprint and the stitch peak coexist and
1123
+ // jetsam (iOS) / lmkd (Android) OOM-kill the app — the exact
1124
+ // WatchdogTermination crash V12.14.8 originally fixed.
1125
+ await new Promise((resolve) => setTimeout(resolve, 50));
860
1126
  try {
861
1127
  // Compose the panorama output path: host-controlled if
862
1128
  // `outputDir` is set, else the lib's canonical capture dir
@@ -883,7 +1149,19 @@ function Camera(props) {
883
1149
  included: result.framesIncluded,
884
1150
  });
885
1151
  }
886
- onCapture?.({
1152
+ // v0.16 — non-fatal quality signals attached to the result + (when
1153
+ // the crop editor shows) the crop banner. LOW_FRAME_UTILIZATION when
1154
+ // <70 % of captured frames survived; LATERAL_DRIFT_FINALIZE when item-6
1155
+ // stopped this capture early.
1156
+ const warnings = (0, captureWarnings_1.buildCaptureWarnings)({
1157
+ framesRequested: result.framesRequested,
1158
+ framesIncluded: result.framesIncluded,
1159
+ lateralFinalize: wasLateralFinalize,
1160
+ highPanSpeed: wasFastPan,
1161
+ copy: (0, cameraGuidanceCopy_1.captureWarningCopyFrom)(guidanceCopyResolved),
1162
+ });
1163
+ const captureResultObj = {
1164
+ ok: true,
887
1165
  type: 'panorama',
888
1166
  // Native finalize() returns a bare `/data/.../foo.jpg` path;
889
1167
  // normalise to `file://` for Android <Image>.
@@ -896,7 +1174,53 @@ function Camera(props) {
896
1174
  finalConfidenceThresh: result.finalConfidenceThresh ?? -1,
897
1175
  durationMs: Date.now() - (recordingStartedAt ?? Date.now()),
898
1176
  stitchModeResolved: result.stitchModeResolved,
899
- });
1177
+ debugSummary: result.debugSummary,
1178
+ keyframePaths: result.batchKeyframePaths,
1179
+ captureOrientation: result.captureOrientation,
1180
+ warnings,
1181
+ };
1182
+ // When the crop editor OR a plain preview is enabled AND the panorama
1183
+ // has valid intrinsic dims, defer `onCapture`: stash the result and
1184
+ // mount RectCropPreview (crop mode when `rectCrop`, preview-only when
1185
+ // just `showPreview`). The modal's confirm / use-original / retake
1186
+ // decision emits the final result. Otherwise emit immediately.
1187
+ if ((rectCrop || showPreview)
1188
+ && result.width > 0
1189
+ && result.height > 0) {
1190
+ // Crop mode only — seed the quad from the max-inscribed rectangle of
1191
+ // the (un-cropped) panorama so the editor opens on the tightest clean
1192
+ // rectangle, not a blind 8 % inset. Best-effort: an absent native
1193
+ // module / decode failure falls back to the default seed. Skipped in
1194
+ // preview-only mode (no quad to seed).
1195
+ let initialRect;
1196
+ if (rectCrop) {
1197
+ try {
1198
+ const inscribed = await (0, computeInscribedRect_1.computeInscribedRect)(captureResultObj.uri);
1199
+ if (inscribed && inscribed.width > 0 && inscribed.height > 0) {
1200
+ initialRect = {
1201
+ x: inscribed.x,
1202
+ y: inscribed.y,
1203
+ width: inscribed.width,
1204
+ height: inscribed.height,
1205
+ };
1206
+ }
1207
+ }
1208
+ catch {
1209
+ // No seed — RectCropPreview uses its default inset.
1210
+ }
1211
+ }
1212
+ setCropPending({
1213
+ uri: captureResultObj.uri,
1214
+ width: result.width,
1215
+ height: result.height,
1216
+ captureResultObj,
1217
+ initialRect,
1218
+ warnings,
1219
+ });
1220
+ }
1221
+ else {
1222
+ onCapture?.(captureResultObj);
1223
+ }
900
1224
  // 2026-05-22 (audit F9) — fire the debug stitch-stats toast on
901
1225
  // every successful finalize when settings.debug is on. Shows
902
1226
  // the leaveBiggestComponent retry telemetry + resolved mode so
@@ -907,19 +1231,30 @@ function Camera(props) {
907
1231
  }
908
1232
  catch (err) {
909
1233
  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));
1234
+ // Classify the raw native failure string → typed code. The chain
1235
+ // lives in classifyStitchError() (the load-bearing C++↔JS contract,
1236
+ // unit-tested against the actual native strings) so a future reword
1237
+ // of a cpp throw can't silently drop the "pan more slowly" path.
1238
+ const code = (0, classifyStitchError_1.classifyStitchError)(message);
1239
+ const error = new CameraError(code, message, err);
1240
+ // v0.16 surface the failure on BOTH callbacks: `onError` (unchanged
1241
+ // mirror) and `onCapture` (ok:false) so a host has one place to learn
1242
+ // the outcome. A lateral-drift stop that then failed to stitch still
1243
+ // reports that cause via the warning.
1244
+ onError?.(error);
1245
+ onCapture?.({
1246
+ ok: false,
1247
+ type: 'panorama',
1248
+ error,
1249
+ warnings: (0, captureWarnings_1.buildCaptureWarnings)({
1250
+ lateralFinalize: wasLateralFinalize,
1251
+ highPanSpeed: wasFastPan,
1252
+ copy: (0, cameraGuidanceCopy_1.captureWarningCopyFrom)(guidanceCopyResolved),
1253
+ }),
1254
+ });
921
1255
  }
922
1256
  finally {
1257
+ finalizingRef.current = false;
923
1258
  setStatusPhase('idle');
924
1259
  setRecordingStartedAt(null);
925
1260
  }
@@ -944,7 +1279,143 @@ function Camera(props) {
944
1279
  isNonAR,
945
1280
  imuGate,
946
1281
  stitchToast,
1282
+ // feature/pano-ux-guidance — the release also tears down the
1283
+ // pan-duration timer + a pending rotate-gate, and decides whether to
1284
+ // route the result through the crop editor.
1285
+ clearPanTimer,
1286
+ pendingPanStart,
1287
+ rectCrop,
1288
+ showPreview,
947
1289
  ]);
1290
+ // Keep `handleHoldEndRef` current so the auto-finalize timer + the
1291
+ // lateral-drift effect invoke the latest `handleHoldEnd` without
1292
+ // adding it as a dep (it changes identity on every recording tick).
1293
+ (0, react_1.useEffect)(() => {
1294
+ handleHoldEndRef.current = () => { void handleHoldEnd(); };
1295
+ });
1296
+ // ── Item 6 — lateral drift → FINALIZE + popup ───────────────────
1297
+ // Mirrors the orientation-drift effect, but FINALIZES the capture
1298
+ // (keeps what was stitched) rather than cancelling it: clear the
1299
+ // pan-duration timer, latch the popup, then call handleHoldEnd via
1300
+ // its ref. Gated off when the budget is disabled (`<= 0`).
1301
+ (0, react_1.useEffect)(() => {
1302
+ if (!panMotion.lateralExceeded
1303
+ || statusPhase !== 'recording'
1304
+ || lateralBudgetCm <= 0) {
1305
+ return;
1306
+ }
1307
+ clearPanTimer();
1308
+ // #3 — if the user veered off before enough frames were captured to
1309
+ // stitch, finalizing would fail with a misleading "need more images"
1310
+ // error. Instead ABANDON the capture (no stitch → no error) and show
1311
+ // the "follow the arrow" popup. Otherwise FINALIZE what was captured
1312
+ // (a usable partial pano) and show the "keep it straight" popup.
1313
+ const MIN_STITCHABLE_KEYFRAMES = 2;
1314
+ if (acceptedKeyframeCount < MIN_STITCHABLE_KEYFRAMES) {
1315
+ setLateralWrongDirection(true);
1316
+ setLateralStopVisible(true);
1317
+ void (async () => {
1318
+ fpDriver.stop();
1319
+ try {
1320
+ await incremental.cancel();
1321
+ }
1322
+ catch {
1323
+ // best-effort — abandonment must succeed even in a weird state.
1324
+ }
1325
+ finally {
1326
+ setStatusPhase('idle');
1327
+ setRecordingStartedAt(null);
1328
+ onCaptureAbandoned?.('lateral-drift');
1329
+ }
1330
+ })();
1331
+ return;
1332
+ }
1333
+ setLateralWrongDirection(false);
1334
+ setLateralStopVisible(true);
1335
+ // Mark this finalize as lateral-drift-triggered so handleHoldEnd attaches
1336
+ // the LATERAL_DRIFT_FINALIZE warning to the result.
1337
+ lateralFinalizeRef.current = true;
1338
+ handleHoldEndRef.current?.();
1339
+ // Deps mirror the drift effect: re-run when the latch trips or the
1340
+ // recording state changes. Other reads are stable setters / refs.
1341
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1342
+ }, [panMotion.lateralExceeded, statusPhase, lateralBudgetCm]);
1343
+ // ── Item 7 — auto-finalize when the configured keyframe count is hit ─
1344
+ // The engine caps accepted keyframes at `keyframeMaxCount`; once it
1345
+ // reports that many, no more frames will be accepted, so stop + stitch
1346
+ // (same finalize path as releasing the shutter). `handleHoldEnd`'s
1347
+ // re-entrancy latch makes this idempotent vs. a manual release in the
1348
+ // same tick. This is the PRIMARY auto-stop (the time cap is opt-in).
1349
+ const keyframeMaxCount = settings.frameSelection.maxKeyframes;
1350
+ const acceptedKeyframeCount = incrementalState?.acceptedCount ?? 0;
1351
+ // Item 4 — speed cue routed into the REC banner/border colour (green→red).
1352
+ // Gated on panGuidance so opting out keeps the banner calm/green.
1353
+ const recordingTooFast = panGuidance && panMotion.panSpeedBucket !== 'good';
1354
+ // Latch the too-fast flag for the HIGH_PAN_SPEED warning (shown on the crop
1355
+ // editor + returned in onCapture.warnings). Latches when the live cue is
1356
+ // active OR — per the user's request — when a KEYFRAME the stitch will use
1357
+ // is accepted while the pan is too fast (so a captured frame was actually
1358
+ // taken at speed). Depending on panSpeedBucket + acceptedKeyframeCount
1359
+ // directly (not just the derived `recordingTooFast`) makes the effect run
1360
+ // on every bucket / keyframe change, so a brief red window can't be missed.
1361
+ // Reset at capture start.
1362
+ const prevAcceptedForSpeedRef = (0, react_1.useRef)(0);
1363
+ (0, react_1.useEffect)(() => {
1364
+ if (statusPhase !== 'recording') {
1365
+ prevAcceptedForSpeedRef.current = acceptedKeyframeCount;
1366
+ return;
1367
+ }
1368
+ const newKeyframe = acceptedKeyframeCount > prevAcceptedForSpeedRef.current;
1369
+ prevAcceptedForSpeedRef.current = acceptedKeyframeCount;
1370
+ if (recordingTooFast) {
1371
+ if (__DEV__ && !fastPanRef.current) {
1372
+ // eslint-disable-next-line no-console
1373
+ console.log(`[panMotion] HIGH_PAN_SPEED latched (bucket=`
1374
+ + `${panMotion.panSpeedBucket} acceptedCount=${acceptedKeyframeCount}`
1375
+ + `${newKeyframe ? ' on a keyframe' : ''})`);
1376
+ }
1377
+ fastPanRef.current = true;
1378
+ }
1379
+ }, [
1380
+ statusPhase,
1381
+ recordingTooFast,
1382
+ acceptedKeyframeCount,
1383
+ panMotion.panSpeedBucket,
1384
+ ]);
1385
+ (0, react_1.useEffect)(() => {
1386
+ if (statusPhase === 'recording'
1387
+ && keyframeMaxCount > 0
1388
+ && acceptedKeyframeCount >= keyframeMaxCount) {
1389
+ handleHoldEndRef.current?.();
1390
+ }
1391
+ }, [statusPhase, keyframeMaxCount, acceptedKeyframeCount]);
1392
+ // ── Item 3 — brief pan how-to overlay at recording start ────────
1393
+ // Show the how-to GIF + direction arrow for a short window when a
1394
+ // recording begins, then auto-fade. The component never self-times;
1395
+ // this effect owns the lifecycle.
1396
+ (0, react_1.useEffect)(() => {
1397
+ if (statusPhase !== 'recording') {
1398
+ setHowToVisible(false);
1399
+ return;
1400
+ }
1401
+ setHowToVisible(true);
1402
+ const t = setTimeout(() => setHowToVisible(false), 2500);
1403
+ return () => clearTimeout(t);
1404
+ }, [statusPhase]);
1405
+ // ── Item 5 — cosmetic countdown tick ────────────────────────────
1406
+ // While recording, bump `nowTick` ~4×/s so `countdownSecondsFrom`
1407
+ // recomputes the displayed whole-seconds. The authoritative auto-stop
1408
+ // is the `panDurationTimerRef` setTimeout, NOT this interval. Skipped
1409
+ // when the countdown feature is disabled (`maxPanDurationMs <= 0`).
1410
+ (0, react_1.useEffect)(() => {
1411
+ if (statusPhase !== 'recording' || maxPanDurationMs <= 0)
1412
+ return;
1413
+ const id = setInterval(() => setNowTick(Date.now()), 250);
1414
+ return () => clearInterval(id);
1415
+ }, [statusPhase, maxPanDurationMs]);
1416
+ // Whole seconds remaining for the countdown overlay (item 5). Pure
1417
+ // helper; clamps to [0, round(maxPanDurationMs/1000)].
1418
+ const countdownSeconds = (0, captureCountdown_1.countdownSecondsFrom)(recordingStartedAt, nowTick, maxPanDurationMs);
948
1419
  // ── Lens / AR-toggle handlers ───────────────────────────────────
949
1420
  const handleLensChange = (0, react_1.useCallback)((next) => {
950
1421
  setLens(next);
@@ -993,8 +1464,13 @@ function Camera(props) {
993
1464
  : insets.top + 8;
994
1465
  // ── JSX ─────────────────────────────────────────────────────────
995
1466
  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,
1467
+ cameraShouldUnmount(inFlightTransition, arSupportPending, statusPhase) ? (
1468
+ // statusPhase==='stitching' UNMOUNTS the camera so vision-camera
1469
+ // frees the AVCaptureSession + preview buffers during the stitch
1470
+ // (V12.14.8 OOM fix). The CaptureStatusOverlay renders the
1471
+ // "Stitching…" state on top, so no placeholder label is needed
1472
+ // in that case — only for the camera-switch transition.
1473
+ 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
1474
  // `video={true}` is REQUIRED for takeSnapshot to work on iOS.
999
1475
  // vision-camera v4's iOS implementation of takeSnapshot waits
1000
1476
  // for a frame on the video pipeline; with video disabled, the
@@ -1027,7 +1503,12 @@ function Camera(props) {
1027
1503
  const msg = e?.message ?? String(err);
1028
1504
  onError?.(new CameraError('VISION_CAMERA_RUNTIME', `${codeStr}: ${msg}`, err));
1029
1505
  } })),
1030
- react_1.default.createElement(CaptureStatusOverlay_1.CaptureStatusOverlay, { phase: statusPhase, topInset: insets.top, recordingStartedAt: recordingStartedAt ?? undefined }),
1506
+ react_1.default.createElement(CaptureStatusOverlay_1.CaptureStatusOverlay, { phase: statusPhase, topInset: insets.top, recordingStartedAt: recordingStartedAt ?? undefined, tooFast: recordingTooFast, recordingMessage: recordingTooFast
1507
+ ? guidanceCopyResolved.tooFast
1508
+ : guidanceCopyResolved.statusRecording, stitchingMessage: guidanceCopyResolved.statusStitching }),
1509
+ react_1.default.createElement(CaptureFrameCounterOverlay_1.CaptureFrameCounterOverlay, { visible: statusPhase === 'recording' && panGuidance, framesCaptured: acceptedKeyframeCount, framesMax: keyframeMaxCount, orientation: deviceOrientation }),
1510
+ react_1.default.createElement(CaptureCountdownOverlay_1.CaptureCountdownOverlay, { visible: statusPhase === 'recording' && panGuidance && maxPanDurationMs > 0, secondsRemaining: countdownSeconds, orientation: deviceOrientation }),
1511
+ react_1.default.createElement(PanHowToOverlay_1.PanHowToOverlay, { visible: statusPhase === 'recording' && panGuidance && howToVisible, orientation: deviceOrientation }),
1031
1512
  settings.debug && (react_1.default.createElement(react_1.default.Fragment, null,
1032
1513
  react_1.default.createElement(CaptureOrientationPill_1.CaptureOrientationPill, { orientation: deviceOrientation, topInset: insets.top }),
1033
1514
  react_1.default.createElement(CaptureKeyframePill_1.CaptureKeyframePill, { state: incrementalState, topInset: insets.top }),
@@ -1069,8 +1550,101 @@ function Camera(props) {
1069
1550
  contentRotation,
1070
1551
  ] }, "\u26A1")))),
1071
1552
  react_1.default.createElement(PanoramaSettingsModal_1.PanoramaSettingsModal, { visible: settingsModalVisible, settings: settings, onChange: setSettings, onClose: () => setSettingsModalVisible(false) }),
1553
+ react_1.default.createElement(RotateToLandscapePrompt_1.RotateToLandscapePrompt, { visible: pendingPanStart, target: (0, panModeGate_1.gateTargetOrientation)(panMode) ?? 'landscape', copy: (0, panModeGate_1.gateTargetOrientation)(panMode) === 'portrait'
1554
+ ? guidanceCopyResolved.rotateToPortrait
1555
+ : guidanceCopyResolved.rotateToLandscape }),
1072
1556
  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 })));
1557
+ react_1.default.createElement(LateralMotionModal_1.LateralMotionModal, { visible: lateralStopVisible, title: lateralWrongDirection
1558
+ ? guidanceCopyResolved.lateralWrongDirectionTitle
1559
+ : guidanceCopyResolved.lateralStopTitle, body: lateralWrongDirection
1560
+ ? guidanceCopyResolved.lateralWrongDirectionBody
1561
+ : guidanceCopyResolved.lateralStopBody, dismissLabel: guidanceCopyResolved.lateralStopDismiss, onDismiss: () => {
1562
+ setLateralStopVisible(false);
1563
+ setLateralWrongDirection(false);
1564
+ } }),
1565
+ 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 }),
1566
+ react_1.default.createElement(RectCropPreview_1.RectCropPreview
1567
+ // Remount per capture so the dragged-quad + layout state re-seed to
1568
+ // the new image (RectCropPreview seeds its quad once via useState).
1569
+ , {
1570
+ // Remount per capture so the dragged-quad + layout state re-seed to
1571
+ // the new image (RectCropPreview seeds its quad once via useState).
1572
+ key: cropPending?.uri ?? 'crop', visible: cropPending != null, imageUri: cropPending?.uri ?? '', imageWidth: cropPending?.width ?? 0, imageHeight: cropPending?.height ?? 0,
1573
+ // 2026-06-15 — manual is the default/eager output. The high-level tab
1574
+ // is ON DEMAND: RectCropPreview calls onRequestAlt() (which re-stitches
1575
+ // the captured keyframes via cv::Stitcher) only when the user switches
1576
+ // to it. DEBUG-ONLY: it's a pipeline-comparison tool (dev-jargon
1577
+ // "Manual"/"High-level" labels), gated behind `settings.debug` like the
1578
+ // rest of the diagnostic UI. Also requires keyframePaths, so it only
1579
+ // appears where it can run (iOS); Android returns no paths → no tab.
1580
+ onRequestAlt: settings.debug && cropPending?.captureResultObj.keyframePaths?.length
1581
+ ? requestHighLevelAlt
1582
+ : undefined, initialRect: cropPending?.initialRect, warnings: cropPending?.warnings.map((w) => w.message) ?? [], showCropControls: rectCrop, topInset: insets.top, bottomInset: insets.bottom, copy: guidanceCopyResolved,
1583
+ // Carry the live memory pill onto the preview too (same settings.debug
1584
+ // gate as the camera), so the operator can watch the RSS spike when the
1585
+ // on-demand high-level re-stitch fires.
1586
+ showMemoryPill: settings.debug,
1587
+ // DEV overlay — show the stitcher's runtime choices (pipeline / warper /
1588
+ // route / seam / blend) + score / frames / size for this output, so the
1589
+ // operator can see HOW it was built. __DEV__ only.
1590
+ debugInfo: __DEV__ && cropPending
1591
+ ? (0, stitchDebugInfo_1.buildStitchDebugInfo)(cropPending.captureResultObj)
1592
+ : undefined, onUseOriginal: (altUri) => {
1593
+ if (cropPending) {
1594
+ // altUri set → the user picked the alt (manual) pipeline's output
1595
+ // in the A/B toggle; emit THAT image (cache-bust for <Image>).
1596
+ onCapture?.(altUri
1597
+ ? {
1598
+ ...cropPending.captureResultObj,
1599
+ uri: `${altUri}?t=${Date.now()}`,
1600
+ }
1601
+ : cropPending.captureResultObj);
1602
+ }
1603
+ setCropPending(null);
1604
+ }, onRetake: () => {
1605
+ // Discard this capture entirely — no onCapture — and return to
1606
+ // the live camera (statusPhase is already 'idle' post-finalize).
1607
+ setCropPending(null);
1608
+ }, onConfirm: async ({ quad, perspective }) => {
1609
+ if (!cropPending)
1610
+ return;
1611
+ const pending = cropPending;
1612
+ // perspective=true → rectify the dragged quad to an upright
1613
+ // rectangle (cropToQuad). perspective=false (the user dragged a
1614
+ // ~rectangular quad) → crop to the quad's axis-aligned bounding box
1615
+ // — a plain crop, no warp.
1616
+ const xs = quad.map((p) => p.x);
1617
+ const ys = quad.map((p) => p.y);
1618
+ const cropPoints = perspective
1619
+ ? quad
1620
+ : [
1621
+ { x: Math.min(...xs), y: Math.min(...ys) },
1622
+ { x: Math.max(...xs), y: Math.min(...ys) },
1623
+ { x: Math.max(...xs), y: Math.max(...ys) },
1624
+ { x: Math.min(...xs), y: Math.max(...ys) },
1625
+ ];
1626
+ try {
1627
+ // cropQuad takes a BARE path; the stashed uri is a file://
1628
+ // URI. Overwrites in place (pass the same path).
1629
+ const cropped = await (0, cropQuad_1.cropQuad)((0, paths_1.toBareFilePath)(pending.uri), cropPoints, undefined, { quality: 90 });
1630
+ onCapture?.({
1631
+ ...pending.captureResultObj,
1632
+ // Cache-bust so <Image> reloads the overwritten file.
1633
+ uri: `${(0, paths_1.toFileUri)(cropped.outputPath)}?t=${Date.now()}`,
1634
+ width: cropped.width,
1635
+ height: cropped.height,
1636
+ });
1637
+ }
1638
+ catch (err) {
1639
+ onError?.(new CameraError('OUTPUT_WRITE_FAILED', err instanceof Error ? err.message : String(err), err));
1640
+ // Fall back to the un-cropped panorama so the capture isn't
1641
+ // lost on a crop failure.
1642
+ onCapture?.(pending.captureResultObj);
1643
+ }
1644
+ finally {
1645
+ setCropPending(null);
1646
+ }
1647
+ } })));
1074
1648
  }
1075
1649
  function noop() {
1076
1650
  /* no-op handler used when panorama mode is disabled */
@@ -1102,6 +1676,26 @@ function isSideEdge(edge) {
1102
1676
  exports._homeIndicatorEdgeForTests = homeIndicatorEdge;
1103
1677
  /** @internal test-only — see `isSideEdge`. */
1104
1678
  exports._isSideEdgeForTests = isSideEdge;
1679
+ /**
1680
+ * cameraShouldUnmount — whether the live camera (<CameraView> /
1681
+ * <ARCameraView>) should be UNMOUNTED (replaced by the placeholder) this
1682
+ * render rather than mounted.
1683
+ *
1684
+ * True while a camera-switch transition or AR-support probe is in flight,
1685
+ * OR during the stitch (statusPhase==='stitching'). The stitching case is
1686
+ * the V12.14.8 OOM fix: unmounting frees vision-camera's AVCaptureSession +
1687
+ * preview buffers (~150-250 MB) BEFORE the memory-heavy stitch, so the
1688
+ * live-camera footprint and the stitch peak never coexist and jetsam (iOS)
1689
+ * / lmkd (Android) don't OOM-kill the app.
1690
+ *
1691
+ * Pure + exported for test — the lib's jest config can't mount <Camera>,
1692
+ * so this boolean is the unit-testable core of the OOM render gate.
1693
+ */
1694
+ function cameraShouldUnmount(inFlightTransition, arSupportPending, statusPhase) {
1695
+ return inFlightTransition || arSupportPending || statusPhase === 'stitching';
1696
+ }
1697
+ /** @internal test-only — see `cameraShouldUnmount`. */
1698
+ exports._cameraShouldUnmountForTests = cameraShouldUnmount;
1105
1699
  /**
1106
1700
  * v0.12.0 — bottom-controls outer container positioning. Anchors
1107
1701
  * to the home-indicator JS edge with the appropriate flex direction