react-native-image-stitcher 0.15.1 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +147 -1
- package/README.md +116 -5
- package/android/src/main/cpp/image_stitcher_jni.cpp +107 -11
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +87 -30
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
- 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/stitcher.cpp +651 -55
- package/cpp/stitcher.hpp +10 -0
- package/cpp/warp_guard.hpp +212 -0
- package/dist/camera/Camera.d.ts +196 -12
- package/dist/camera/Camera.js +629 -35
- package/dist/camera/CameraView.js +62 -5
- 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 +142 -0
- package/dist/camera/CaptureMemoryPill.d.ts +9 -1
- package/dist/camera/CaptureMemoryPill.js +3 -3
- 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/PanoramaSettings.d.ts +8 -6
- package/dist/camera/PanoramaSettings.js +26 -5
- package/dist/camera/PanoramaSettingsModal.js +4 -4
- package/dist/camera/RectCropPreview.d.ts +161 -0
- package/dist/camera/RectCropPreview.js +480 -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 +45 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +56 -8
- 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 +191 -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 +994 -47
- package/src/camera/CameraView.tsx +75 -5
- package/src/camera/CaptureCountdownOverlay.tsx +272 -0
- package/src/camera/CaptureFrameCounterOverlay.tsx +183 -0
- package/src/camera/CaptureMemoryPill.tsx +17 -3
- 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/PanoramaSettings.ts +34 -11
- package/src/camera/PanoramaSettingsModal.tsx +4 -4
- package/src/camera/RectCropPreview.tsx +820 -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 +45 -0
- 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,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
|
|
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
|
|
545
|
-
// recording
|
|
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
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
911
|
-
//
|
|
912
|
-
//
|
|
913
|
-
//
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
onError?.(
|
|
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
|
|
997
|
-
|
|
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(
|
|
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
|