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