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.
- package/CHANGELOG.md +171 -1
- package/README.md +131 -5
- package/android/src/main/cpp/image_stitcher_jni.cpp +154 -13
- package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +220 -87
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
- package/cpp/crop_quad.cpp +162 -0
- package/cpp/crop_quad.hpp +163 -0
- package/cpp/keyframe_gate.cpp +54 -15
- package/cpp/keyframe_gate.hpp +33 -0
- package/cpp/stitcher.cpp +1122 -132
- package/cpp/stitcher.hpp +62 -0
- package/cpp/warp_guard.hpp +212 -0
- package/dist/camera/Camera.d.ts +209 -12
- package/dist/camera/Camera.js +575 -36
- package/dist/camera/CameraView.js +35 -16
- package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
- package/dist/camera/CaptureCountdownOverlay.js +239 -0
- package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
- package/dist/camera/CaptureFrameCounterOverlay.js +153 -0
- package/dist/camera/CaptureMemoryPill.d.ts +24 -8
- package/dist/camera/CaptureMemoryPill.js +37 -12
- package/dist/camera/CapturePreview.js +2 -1
- package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
- package/dist/camera/CaptureStatusOverlay.js +22 -5
- package/dist/camera/CaptureThumbnailStrip.js +2 -1
- package/dist/camera/LateralMotionModal.d.ts +85 -0
- package/dist/camera/LateralMotionModal.js +134 -0
- package/dist/camera/PanHowToOverlay.d.ts +76 -0
- package/dist/camera/PanHowToOverlay.js +222 -0
- package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
- package/dist/camera/PanoramaBandOverlay.js +9 -3
- package/dist/camera/PanoramaSettings.d.ts +8 -6
- package/dist/camera/PanoramaSettings.js +19 -1
- package/dist/camera/PanoramaSettingsModal.js +4 -4
- package/dist/camera/RectCropPreview.d.ts +135 -0
- package/dist/camera/RectCropPreview.js +370 -0
- package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
- package/dist/camera/RotateToLandscapePrompt.js +138 -0
- package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
- package/dist/camera/buildPanoramaInitialSettings.js +9 -0
- package/dist/camera/cameraErrorMessages.d.ts +30 -1
- package/dist/camera/cameraErrorMessages.js +26 -10
- package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
- package/dist/camera/cameraGuidanceCopy.js +80 -0
- package/dist/camera/captureCountdown.d.ts +52 -0
- package/dist/camera/captureCountdown.js +76 -0
- package/dist/camera/captureWarnings.d.ts +90 -0
- package/dist/camera/captureWarnings.js +108 -0
- package/dist/camera/classifyStitchError.d.ts +30 -0
- package/dist/camera/classifyStitchError.js +42 -0
- package/dist/camera/cropGeometry.d.ts +136 -0
- package/dist/camera/cropGeometry.js +223 -0
- package/dist/camera/displayDecodeImageProps.d.ts +25 -0
- package/dist/camera/displayDecodeImageProps.js +29 -0
- package/dist/camera/guidanceGraphics.d.ts +58 -0
- package/dist/camera/guidanceGraphics.js +280 -0
- package/dist/camera/guidanceTokens.d.ts +54 -0
- package/dist/camera/guidanceTokens.js +58 -0
- package/dist/camera/panModeGate.d.ts +54 -0
- package/dist/camera/panModeGate.js +62 -0
- package/dist/camera/pickCaptureFormat.d.ts +71 -0
- package/dist/camera/pickCaptureFormat.js +85 -0
- package/dist/camera/stitchDebugInfo.d.ts +27 -0
- package/dist/camera/stitchDebugInfo.js +55 -0
- package/dist/camera/usePanMotion.d.ts +250 -0
- package/dist/camera/usePanMotion.js +451 -0
- package/dist/index.d.ts +24 -3
- package/dist/index.js +33 -2
- package/dist/stitching/computeInscribedRect.d.ts +40 -0
- package/dist/stitching/computeInscribedRect.js +55 -0
- package/dist/stitching/cropQuad.d.ts +78 -0
- package/dist/stitching/cropQuad.js +116 -0
- package/dist/stitching/incremental.d.ts +74 -0
- package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
- package/dist/stitching/useIncrementalStitcher.js +7 -1
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +154 -29
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +211 -7
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
- package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
- package/package.json +5 -1
- package/src/camera/Camera.tsx +945 -47
- package/src/camera/CameraView.tsx +48 -16
- package/src/camera/CaptureCountdownOverlay.tsx +272 -0
- package/src/camera/CaptureFrameCounterOverlay.tsx +197 -0
- package/src/camera/CaptureMemoryPill.tsx +50 -12
- package/src/camera/CapturePreview.tsx +5 -0
- package/src/camera/CaptureStatusOverlay.tsx +35 -7
- package/src/camera/CaptureThumbnailStrip.tsx +4 -0
- package/src/camera/LateralMotionModal.tsx +199 -0
- package/src/camera/PanHowToOverlay.tsx +246 -0
- package/src/camera/PanoramaBandOverlay.tsx +9 -1
- package/src/camera/PanoramaSettings.ts +27 -7
- package/src/camera/PanoramaSettingsModal.tsx +4 -4
- package/src/camera/RectCropPreview.tsx +638 -0
- package/src/camera/RotateToLandscapePrompt.tsx +188 -0
- package/src/camera/buildPanoramaInitialSettings.ts +30 -1
- package/src/camera/cameraErrorMessages.ts +39 -2
- package/src/camera/cameraGuidanceCopy.ts +145 -0
- package/src/camera/captureCountdown.ts +83 -0
- package/src/camera/captureWarnings.ts +190 -0
- package/src/camera/classifyStitchError.ts +68 -0
- package/src/camera/cropGeometry.ts +268 -0
- package/src/camera/displayDecodeImageProps.ts +25 -0
- package/src/camera/guidanceGraphics.tsx +347 -0
- package/src/camera/guidanceTokens.ts +57 -0
- package/src/camera/panModeGate.ts +81 -0
- package/src/camera/pickCaptureFormat.ts +130 -0
- package/src/camera/stitchDebugInfo.ts +71 -0
- package/src/camera/usePanMotion.ts +667 -0
- package/src/index.ts +66 -3
- package/src/stitching/computeInscribedRect.ts +81 -0
- package/src/stitching/cropQuad.ts +167 -0
- package/src/stitching/incremental.ts +74 -0
- package/src/stitching/useIncrementalStitcher.ts +13 -0
- package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +0 -100
- package/cpp/tests/CMakeLists.txt +0 -104
- package/cpp/tests/README.md +0 -86
- package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
- package/cpp/tests/pose_test.cpp +0 -74
- package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
- package/cpp/tests/stubs/jsi/jsi.h +0 -33
- package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
- package/cpp/tests/warp_guard_test.cpp +0 -48
- package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
- package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
- package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
- package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
- package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
- package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
- package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
- package/src/camera/__tests__/useContentRotation.test.ts +0 -89
- package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
- package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
- package/src/stitching/__tests__/useStitcherWorklet.test.ts +0 -202
package/dist/camera/Camera.js
CHANGED
|
@@ -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
|
-
|
|
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',
|
|
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
|
|
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
|
|
545
|
-
// recording
|
|
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
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
911
|
-
//
|
|
912
|
-
//
|
|
913
|
-
//
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
onError?.(
|
|
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
|
|
997
|
-
|
|
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(
|
|
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
|