react-native-image-stitcher 0.15.2 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +171 -1
- package/README.md +131 -5
- package/android/src/main/cpp/image_stitcher_jni.cpp +154 -13
- package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +220 -87
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
- package/cpp/crop_quad.cpp +162 -0
- package/cpp/crop_quad.hpp +163 -0
- package/cpp/keyframe_gate.cpp +54 -15
- package/cpp/keyframe_gate.hpp +33 -0
- package/cpp/stitcher.cpp +1122 -132
- package/cpp/stitcher.hpp +62 -0
- package/cpp/warp_guard.hpp +212 -0
- package/dist/camera/Camera.d.ts +209 -12
- package/dist/camera/Camera.js +575 -36
- package/dist/camera/CameraView.js +35 -16
- package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
- package/dist/camera/CaptureCountdownOverlay.js +239 -0
- package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
- package/dist/camera/CaptureFrameCounterOverlay.js +153 -0
- package/dist/camera/CaptureMemoryPill.d.ts +24 -8
- package/dist/camera/CaptureMemoryPill.js +37 -12
- package/dist/camera/CapturePreview.js +2 -1
- package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
- package/dist/camera/CaptureStatusOverlay.js +22 -5
- package/dist/camera/CaptureThumbnailStrip.js +2 -1
- package/dist/camera/LateralMotionModal.d.ts +85 -0
- package/dist/camera/LateralMotionModal.js +134 -0
- package/dist/camera/PanHowToOverlay.d.ts +76 -0
- package/dist/camera/PanHowToOverlay.js +222 -0
- package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
- package/dist/camera/PanoramaBandOverlay.js +9 -3
- package/dist/camera/PanoramaSettings.d.ts +8 -6
- package/dist/camera/PanoramaSettings.js +19 -1
- package/dist/camera/PanoramaSettingsModal.js +4 -4
- package/dist/camera/RectCropPreview.d.ts +135 -0
- package/dist/camera/RectCropPreview.js +370 -0
- package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
- package/dist/camera/RotateToLandscapePrompt.js +138 -0
- package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
- package/dist/camera/buildPanoramaInitialSettings.js +9 -0
- package/dist/camera/cameraErrorMessages.d.ts +30 -1
- package/dist/camera/cameraErrorMessages.js +26 -10
- package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
- package/dist/camera/cameraGuidanceCopy.js +80 -0
- package/dist/camera/captureCountdown.d.ts +52 -0
- package/dist/camera/captureCountdown.js +76 -0
- package/dist/camera/captureWarnings.d.ts +90 -0
- package/dist/camera/captureWarnings.js +108 -0
- package/dist/camera/classifyStitchError.d.ts +30 -0
- package/dist/camera/classifyStitchError.js +42 -0
- package/dist/camera/cropGeometry.d.ts +136 -0
- package/dist/camera/cropGeometry.js +223 -0
- package/dist/camera/displayDecodeImageProps.d.ts +25 -0
- package/dist/camera/displayDecodeImageProps.js +29 -0
- package/dist/camera/guidanceGraphics.d.ts +58 -0
- package/dist/camera/guidanceGraphics.js +280 -0
- package/dist/camera/guidanceTokens.d.ts +54 -0
- package/dist/camera/guidanceTokens.js +58 -0
- package/dist/camera/panModeGate.d.ts +54 -0
- package/dist/camera/panModeGate.js +62 -0
- package/dist/camera/pickCaptureFormat.d.ts +71 -0
- package/dist/camera/pickCaptureFormat.js +85 -0
- package/dist/camera/stitchDebugInfo.d.ts +27 -0
- package/dist/camera/stitchDebugInfo.js +55 -0
- package/dist/camera/usePanMotion.d.ts +250 -0
- package/dist/camera/usePanMotion.js +451 -0
- package/dist/index.d.ts +24 -3
- package/dist/index.js +33 -2
- package/dist/stitching/computeInscribedRect.d.ts +40 -0
- package/dist/stitching/computeInscribedRect.js +55 -0
- package/dist/stitching/cropQuad.d.ts +78 -0
- package/dist/stitching/cropQuad.js +116 -0
- package/dist/stitching/incremental.d.ts +74 -0
- package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
- package/dist/stitching/useIncrementalStitcher.js +7 -1
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +154 -29
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +211 -7
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
- package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
- package/package.json +5 -1
- package/src/camera/Camera.tsx +945 -47
- package/src/camera/CameraView.tsx +48 -16
- package/src/camera/CaptureCountdownOverlay.tsx +272 -0
- package/src/camera/CaptureFrameCounterOverlay.tsx +197 -0
- package/src/camera/CaptureMemoryPill.tsx +50 -12
- package/src/camera/CapturePreview.tsx +5 -0
- package/src/camera/CaptureStatusOverlay.tsx +35 -7
- package/src/camera/CaptureThumbnailStrip.tsx +4 -0
- package/src/camera/LateralMotionModal.tsx +199 -0
- package/src/camera/PanHowToOverlay.tsx +246 -0
- package/src/camera/PanoramaBandOverlay.tsx +9 -1
- package/src/camera/PanoramaSettings.ts +27 -7
- package/src/camera/PanoramaSettingsModal.tsx +4 -4
- package/src/camera/RectCropPreview.tsx +638 -0
- package/src/camera/RotateToLandscapePrompt.tsx +188 -0
- package/src/camera/buildPanoramaInitialSettings.ts +30 -1
- package/src/camera/cameraErrorMessages.ts +39 -2
- package/src/camera/cameraGuidanceCopy.ts +145 -0
- package/src/camera/captureCountdown.ts +83 -0
- package/src/camera/captureWarnings.ts +190 -0
- package/src/camera/classifyStitchError.ts +68 -0
- package/src/camera/cropGeometry.ts +268 -0
- package/src/camera/displayDecodeImageProps.ts +25 -0
- package/src/camera/guidanceGraphics.tsx +347 -0
- package/src/camera/guidanceTokens.ts +57 -0
- package/src/camera/panModeGate.ts +81 -0
- package/src/camera/pickCaptureFormat.ts +130 -0
- package/src/camera/stitchDebugInfo.ts +71 -0
- package/src/camera/usePanMotion.ts +667 -0
- package/src/index.ts +66 -3
- package/src/stitching/computeInscribedRect.ts +81 -0
- package/src/stitching/cropQuad.ts +167 -0
- package/src/stitching/incremental.ts +74 -0
- package/src/stitching/useIncrementalStitcher.ts +13 -0
- package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +0 -100
- package/cpp/tests/CMakeLists.txt +0 -104
- package/cpp/tests/README.md +0 -86
- package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
- package/cpp/tests/pose_test.cpp +0 -74
- package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
- package/cpp/tests/stubs/jsi/jsi.h +0 -33
- package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
- package/cpp/tests/warp_guard_test.cpp +0 -48
- package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
- package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
- package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
- package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
- package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
- package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
- package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
- package/src/camera/__tests__/useContentRotation.test.ts +0 -89
- package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
- package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
- package/src/stitching/__tests__/useStitcherWorklet.test.ts +0 -202
package/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,63 @@ export type CameraCaptureResult =
|
|
|
172
223
|
* cv::Stitcher at finalize).
|
|
173
224
|
*/
|
|
174
225
|
stitchModeResolved?: 'panorama' | 'scans';
|
|
226
|
+
/**
|
|
227
|
+
* 2026-06-15 (DEV) — gyro rotation magnitude of the capture, in radians.
|
|
228
|
+
* Shown on the dev preview so the panorama-vs-SCANS rotation threshold can
|
|
229
|
+
* be tuned. `0` = no pose-derived rotation signal (non-AR with no poses).
|
|
230
|
+
*/
|
|
231
|
+
rRadians?: number;
|
|
232
|
+
/**
|
|
233
|
+
* 2026-06-16 (DEV) — translation magnitude (m) + auto decision ratio
|
|
234
|
+
* (`>=0.55` → SCANS) that drove panorama-vs-SCANS. Shown on the dev
|
|
235
|
+
* readout alongside `rRadians` to tune the threshold from real captures.
|
|
236
|
+
*/
|
|
237
|
+
tMeters?: number;
|
|
238
|
+
decisionRatio?: number;
|
|
239
|
+
/**
|
|
240
|
+
* 2026-06-14 (DEV overlay) — semicolon-separated `key=value` trace of the
|
|
241
|
+
* stitcher's runtime choices (pipe/warp/route/seam/blend) for this
|
|
242
|
+
* output. Shown on the preview in __DEV__. iOS only for now.
|
|
243
|
+
*/
|
|
244
|
+
debugSummary?: string;
|
|
245
|
+
/**
|
|
246
|
+
* 2026-06-15 (iOS) — keyframe JPEG paths used for this stitch, so the
|
|
247
|
+
* preview can re-stitch them on demand via `refinePanorama` (the
|
|
248
|
+
* high-level tab). iOS only; undefined elsewhere.
|
|
249
|
+
*/
|
|
250
|
+
keyframePaths?: string[];
|
|
251
|
+
/**
|
|
252
|
+
* 2026-06-15 (iOS) — orientation this stitch baked in. The on-demand
|
|
253
|
+
* high-level re-stitch passes it back so it matches the manual output's
|
|
254
|
+
* rotation (not the raw sensor landscape). iOS only.
|
|
255
|
+
*/
|
|
256
|
+
captureOrientation?: string;
|
|
257
|
+
/** Non-fatal quality signals (empty when none). */
|
|
258
|
+
warnings: CaptureWarning[];
|
|
259
|
+
}
|
|
260
|
+
| {
|
|
261
|
+
ok: false;
|
|
262
|
+
/** Which capture path failed. */
|
|
263
|
+
type: 'photo' | 'panorama';
|
|
264
|
+
/** The classified failure (same object handed to `onError`). */
|
|
265
|
+
error: CameraError;
|
|
266
|
+
/** Any warnings gathered before the failure (usually empty). */
|
|
267
|
+
warnings: CaptureWarning[];
|
|
175
268
|
};
|
|
176
269
|
|
|
177
270
|
|
|
271
|
+
/**
|
|
272
|
+
* The success-panorama variant of {@link CameraCaptureResult} — the exact
|
|
273
|
+
* shape stashed for the crop editor and re-emitted (with adjusted dims) once
|
|
274
|
+
* the user crops. Narrowed so the crop-confirm spread keeps `uri`/`width`/
|
|
275
|
+
* `height`/`ok` without a cast.
|
|
276
|
+
*/
|
|
277
|
+
export type PanoramaCaptureResult = Extract<
|
|
278
|
+
CameraCaptureResult,
|
|
279
|
+
{ ok: true; type: 'panorama' }
|
|
280
|
+
>;
|
|
281
|
+
|
|
282
|
+
|
|
178
283
|
/**
|
|
179
284
|
* Errors surfaced via `onError`. Classified codes so consumers can
|
|
180
285
|
* branch on the kind of failure (toast vs retry vs report).
|
|
@@ -188,6 +293,13 @@ export type CameraErrorCode =
|
|
|
188
293
|
| 'STITCH_NEED_MORE_IMGS'
|
|
189
294
|
| 'STITCH_HOMOGRAPHY_FAIL'
|
|
190
295
|
| 'STITCH_CAMERA_PARAMS_FAIL'
|
|
296
|
+
/**
|
|
297
|
+
* v0.16 — the native post-stitch validator rejected the output: the
|
|
298
|
+
* panorama came out disjoint / fragmented / wildly mis-proportioned
|
|
299
|
+
* (frames didn't connect into one coherent image). Recoverable by
|
|
300
|
+
* re-capturing, so it carries "try again" copy.
|
|
301
|
+
*/
|
|
302
|
+
| 'STITCH_LOW_QUALITY'
|
|
191
303
|
| 'STITCH_OOM'
|
|
192
304
|
| 'OUTPUT_WRITE_FAILED'
|
|
193
305
|
/**
|
|
@@ -254,6 +366,19 @@ export interface CameraProps {
|
|
|
254
366
|
defaultRegistrationResolMP?: number;
|
|
255
367
|
/** Forward-looking — see above. */
|
|
256
368
|
defaultSeamEstimationResolMP?: number;
|
|
369
|
+
/**
|
|
370
|
+
* v0.16 — pass the whole stitcher config as a JSON object instead of the
|
|
371
|
+
* individual `default*` props above (canonical field names: `warperType` /
|
|
372
|
+
* `blenderType` / `seamFinderType` / `stitchMode` /
|
|
373
|
+
* `enableMaxInscribedRectCrop`). Partial; any field set here wins over the
|
|
374
|
+
* matching flat prop. Runtime ⚙️-panel edits still override at capture time. */
|
|
375
|
+
stitcher?: PanoramaPropOverrides['stitcher'];
|
|
376
|
+
/**
|
|
377
|
+
* v0.16 — pass the whole frame-gate config as a JSON object (canonical field
|
|
378
|
+
* names: `mode` / `maxKeyframes` / `overlapThreshold` / `maxKeyframeIntervalMs`
|
|
379
|
+
* / `flow`). Partial; `flow` is deep-merged. Wins over the flat `default*`
|
|
380
|
+
* props. */
|
|
381
|
+
frameSelection?: PanoramaPropOverrides['frameSelection'];
|
|
257
382
|
|
|
258
383
|
// ── Inscribed-rect crop (v0.15) ───────────────────────────────────
|
|
259
384
|
/**
|
|
@@ -355,12 +480,19 @@ export interface CameraProps {
|
|
|
355
480
|
* decisively cancels the capture (`incremental.cancel()`) and
|
|
356
481
|
* surfaces `OrientationDriftModal` to explain what happened.
|
|
357
482
|
*
|
|
483
|
+
* v0.16 adds `'lateral-drift'`: the user moved the phone perpendicular to
|
|
484
|
+
* the pan arrow before enough frames were captured to stitch. Rather than
|
|
485
|
+
* finalize into a misleading "need more images" error, the SDK abandons the
|
|
486
|
+
* capture and surfaces the `LateralMotionModal` with "follow the arrow"
|
|
487
|
+
* copy. (A lateral drift AFTER enough frames still finalizes what was
|
|
488
|
+
* captured and fires `onCapture` with a `LATERAL_DRIFT_FINALIZE` warning.)
|
|
489
|
+
*
|
|
358
490
|
* Hosts use this callback to clean up their own state (e.g., reset
|
|
359
491
|
* a wizard step, log telemetry, surface their own retry UX in
|
|
360
492
|
* addition to the SDK's built-in modal). No `onCapture` will fire
|
|
361
493
|
* for an abandoned capture.
|
|
362
494
|
*/
|
|
363
|
-
onCaptureAbandoned?: (reason: 'orientation-drift') => void;
|
|
495
|
+
onCaptureAbandoned?: (reason: 'orientation-drift' | 'lateral-drift') => void;
|
|
364
496
|
|
|
365
497
|
/**
|
|
366
498
|
* v0.13.0 — flash (torch) state. Controlled-or-uncontrolled.
|
|
@@ -618,6 +750,92 @@ export interface CameraProps {
|
|
|
618
750
|
* CHANGELOG.)
|
|
619
751
|
*/
|
|
620
752
|
frameProcessor?: ReadonlyFrameProcessor | DrawableFrameProcessor;
|
|
753
|
+
|
|
754
|
+
// ── Panorama GUIDANCE (feature/pano-ux-guidance) ──────────────────
|
|
755
|
+
/**
|
|
756
|
+
* Which device holds the non-AR panorama capture accepts.
|
|
757
|
+
*
|
|
758
|
+
* - `'vertical'` (DEFAULT) — LANDSCAPE-only, top→bottom pan. Starting a
|
|
759
|
+
* panorama in portrait is BLOCKED behind the rotate-to-landscape
|
|
760
|
+
* prompt (item 2); the capture starts the instant they rotate to
|
|
761
|
+
* landscape (either way up).
|
|
762
|
+
* - `'horizontal'` — PORTRAIT-only, left→right pan. Starting in
|
|
763
|
+
* landscape is BLOCKED behind the rotate-to-portrait prompt; capture
|
|
764
|
+
* starts on rotating to portrait (either way up).
|
|
765
|
+
* - `'both'` — landscape OR portrait; the rotate gate never fires, the
|
|
766
|
+
* user captures in whichever hold they're already in.
|
|
767
|
+
*
|
|
768
|
+
* **BREAKING (since the previous release accepted both holds ungated):**
|
|
769
|
+
* the default is now `'vertical'`. Hosts that want left→right (portrait)
|
|
770
|
+
* panoramas use `panMode='horizontal'` (portrait-only) or `'both'`. See
|
|
771
|
+
* CHANGELOG.
|
|
772
|
+
*/
|
|
773
|
+
panMode?: PanMode;
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Master switch for the in-capture pan-guidance surfaces (rotate
|
|
777
|
+
* prompt, pan how-to overlay, too-fast pill, blinking countdown).
|
|
778
|
+
* Default `true`. Set `false` to suppress all of them (the lateral-
|
|
779
|
+
* drift FINALIZE behaviour and the crop preview are governed by their
|
|
780
|
+
* own props, not this flag).
|
|
781
|
+
*/
|
|
782
|
+
panGuidance?: boolean;
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Optional hard recording-TIME ceiling for a non-AR panorama, in
|
|
786
|
+
* milliseconds, used as a SAFETY cap alongside the primary keyframe-count
|
|
787
|
+
* auto-stop. The default capture now finalizes when the configured
|
|
788
|
+
* keyframe count is reached (see the frame counter HUD), so this is `0`
|
|
789
|
+
* (disabled) by default. Set it to a positive value to ALSO cap the
|
|
790
|
+
* recording by wall-clock time; when > 0 a blinking countdown (item 5)
|
|
791
|
+
* shows the seconds remaining and the capture auto-finalizes at 0.
|
|
792
|
+
*
|
|
793
|
+
* v0.16 — default changed `9000` → `0` (time cap is now opt-in; the
|
|
794
|
+
* keyframe-count stop is the default UX).
|
|
795
|
+
*/
|
|
796
|
+
maxPanDurationMs?: number;
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Gyro rate (rad/s) above which the pan is flagged "moving too fast"
|
|
800
|
+
* (item 4 — the transient amber pill). Optional; forwards to
|
|
801
|
+
* `usePanMotion`'s `warnMaxRadPerSec` (default 1.0 rad/s there).
|
|
802
|
+
*/
|
|
803
|
+
panTooFastThreshold?: number;
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Cross-pan (lateral) drift budget in CENTIMETRES (item 6). Once the
|
|
807
|
+
* operator's integrated sideways translation exceeds this for the
|
|
808
|
+
* hook's grace window, the capture FINALIZES what was captured and a
|
|
809
|
+
* one-button popup explains why. Default `5`. `0` disables the
|
|
810
|
+
* lateral-drift stop entirely.
|
|
811
|
+
*/
|
|
812
|
+
lateralBudgetCm?: number;
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Show the draggable-quad crop editor after a panorama finalizes, BEFORE
|
|
816
|
+
* emitting it via `onCapture`. Default `false`. When `true`, the user
|
|
817
|
+
* drags 4 corners over the stitched result; confirming crops in place
|
|
818
|
+
* (perspective-rectify when the quad isn't axis-aligned), "Use original"
|
|
819
|
+
* emits the un-cropped panorama, "Retake" discards it. Takes precedence
|
|
820
|
+
* over {@link showPreview}.
|
|
821
|
+
*/
|
|
822
|
+
rectCrop?: boolean;
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Show a plain review screen after a panorama finalizes — the stitched
|
|
826
|
+
* image with [Retake] / [Confirm] and NO crop box. Default `false`.
|
|
827
|
+
* Ignored when {@link rectCrop} is on (the crop editor is itself the
|
|
828
|
+
* preview). With both off, `onCapture` fires immediately with no UI.
|
|
829
|
+
*/
|
|
830
|
+
showPreview?: boolean;
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Copy overrides for every guidance string (rotate prompt, pan hint,
|
|
834
|
+
* too-fast warning, lateral-stop popup, crop buttons). Partial —
|
|
835
|
+
* unspecified keys fall back to {@link DEFAULT_GUIDANCE_COPY}. Hosts
|
|
836
|
+
* localise or re-word the whole guidance surface in one place here.
|
|
837
|
+
*/
|
|
838
|
+
guidanceCopy?: Partial<GuidanceCopy>;
|
|
621
839
|
}
|
|
622
840
|
|
|
623
841
|
|
|
@@ -879,7 +1097,17 @@ function extractPanoramaOverrides(props: CameraProps): PanoramaPropOverrides {
|
|
|
879
1097
|
defaultKeyframeMaxCount: props.defaultKeyframeMaxCount,
|
|
880
1098
|
defaultKeyframeOverlapThreshold: props.defaultKeyframeOverlapThreshold,
|
|
881
1099
|
defaultMaxKeyframeIntervalMs: props.defaultMaxKeyframeIntervalMs,
|
|
882
|
-
|
|
1100
|
+
// v0.16 — JSON-object form (wins over the flat default* props above).
|
|
1101
|
+
stitcher: props.stitcher,
|
|
1102
|
+
frameSelection: props.frameSelection,
|
|
1103
|
+
// Item 2 — the interactive crop editor OWNS cropping, so when it's on we
|
|
1104
|
+
// force the native auto-crop OFF: the editor needs the full un-cropped
|
|
1105
|
+
// panorama (black borders included) so the user can drag the inscribed-
|
|
1106
|
+
// rect seed outward to keep more content. Letting the native auto-crop
|
|
1107
|
+
// pre-trim would leave nothing to adjust.
|
|
1108
|
+
maxInscribedRectCrop: props.rectCrop
|
|
1109
|
+
? false
|
|
1110
|
+
: props.maxInscribedRectCrop,
|
|
883
1111
|
};
|
|
884
1112
|
}
|
|
885
1113
|
|
|
@@ -930,8 +1158,28 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
930
1158
|
onCapturePreviewClose,
|
|
931
1159
|
frameProcessor: hostFrameProcessor,
|
|
932
1160
|
engine = 'batch-keyframe',
|
|
1161
|
+
// ── Panorama GUIDANCE (feature/pano-ux-guidance) ──────────────
|
|
1162
|
+
panMode = 'vertical',
|
|
1163
|
+
panGuidance = true,
|
|
1164
|
+
maxPanDurationMs = 0,
|
|
1165
|
+
panTooFastThreshold,
|
|
1166
|
+
lateralBudgetCm = 4,
|
|
1167
|
+
rectCrop = false,
|
|
1168
|
+
showPreview = false,
|
|
1169
|
+
guidanceCopy,
|
|
933
1170
|
} = props;
|
|
934
1171
|
|
|
1172
|
+
// Derived guidance state. The landscape-only gate decision itself is
|
|
1173
|
+
// computed inline at the call sites via `shouldGateForPanMode(panMode,
|
|
1174
|
+
// deviceOrientation)` (the rotate gate + resume effect), so there's no
|
|
1175
|
+
// standalone `modeAOnly` flag to keep in sync. `guidanceCopyResolved`
|
|
1176
|
+
// merges the host override onto the defaults once per `guidanceCopy`
|
|
1177
|
+
// identity.
|
|
1178
|
+
const guidanceCopyResolved = useMemo(
|
|
1179
|
+
() => mergeGuidanceCopy(guidanceCopy),
|
|
1180
|
+
[guidanceCopy],
|
|
1181
|
+
);
|
|
1182
|
+
|
|
935
1183
|
// v0.13.2 — capture-source constraint (default 'both'). Derives which
|
|
936
1184
|
// sources are permitted; `captureSources` overrides any conflicting
|
|
937
1185
|
// `defaultCaptureSource`. Used to constrain the initial AR preference
|
|
@@ -978,6 +1226,44 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
978
1226
|
null,
|
|
979
1227
|
);
|
|
980
1228
|
const [incrementalState, setIncrementalState] = useState<IncrementalState | null>(null);
|
|
1229
|
+
// ── Panorama GUIDANCE state (feature/pano-ux-guidance) ──────────
|
|
1230
|
+
// Item 1/2 — a hold that was BLOCKED on the rotate-to-landscape gate.
|
|
1231
|
+
// Latches when the user holds the shutter in portrait under Mode A;
|
|
1232
|
+
// an effect below resumes the capture the instant they rotate.
|
|
1233
|
+
const [pendingPanStart, setPendingPanStart] = useState(false);
|
|
1234
|
+
// Item 6 — the latched lateral-drift popup (capture already finalized
|
|
1235
|
+
// by the time it shows).
|
|
1236
|
+
const [lateralStopVisible, setLateralStopVisible] = useState(false);
|
|
1237
|
+
// Item 6 — true when the lateral stop happened with too few frames to
|
|
1238
|
+
// stitch (the user veered off almost immediately): the popup then shows
|
|
1239
|
+
// the "follow the arrow" copy and the capture is abandoned, not finalized.
|
|
1240
|
+
const [lateralWrongDirection, setLateralWrongDirection] = useState(false);
|
|
1241
|
+
// Item 3 — the brief pan how-to overlay shown at the start of a
|
|
1242
|
+
// recording, auto-dismissed after a timeout.
|
|
1243
|
+
const [howToVisible, setHowToVisible] = useState(false);
|
|
1244
|
+
// Item 5 — a ~250 ms ticking clock that drives the displayed countdown
|
|
1245
|
+
// seconds while recording (the authoritative auto-stop is a setTimeout,
|
|
1246
|
+
// not this tick).
|
|
1247
|
+
const [nowTick, setNowTick] = useState(() => Date.now());
|
|
1248
|
+
// Item 7 — a finalized panorama awaiting the user's crop decision.
|
|
1249
|
+
// Non-null mounts the RectCropPreview; `captureResultObj` is the exact
|
|
1250
|
+
// CameraCaptureResult we'd otherwise have emitted, stashed so cancel /
|
|
1251
|
+
// crop-confirm can emit it (possibly with cropped dims) afterwards.
|
|
1252
|
+
const [cropPending, setCropPending] = useState<{
|
|
1253
|
+
uri: string;
|
|
1254
|
+
width: number;
|
|
1255
|
+
height: number;
|
|
1256
|
+
captureResultObj: PanoramaCaptureResult;
|
|
1257
|
+
/**
|
|
1258
|
+
* Item 2 — max-inscribed-rect seed for the crop quad (image-pixel
|
|
1259
|
+
* coords). Undefined → RectCropPreview falls back to its 8 %-inset
|
|
1260
|
+
* default seed (native module absent / inscribed-rect call failed).
|
|
1261
|
+
*/
|
|
1262
|
+
initialRect?: ImageRect;
|
|
1263
|
+
/** Warnings to surface as a banner on the crop editor. */
|
|
1264
|
+
warnings: CaptureWarning[];
|
|
1265
|
+
} | null>(null);
|
|
1266
|
+
|
|
981
1267
|
// 2026-05-22 (audit F9 + F3) — debug stitch-stats toast. Hook
|
|
982
1268
|
// exposes an imperative API; we fire `showResult(finalizeResult)`
|
|
983
1269
|
// on every successful finalize when settings.debug is on (gated
|
|
@@ -1023,6 +1309,18 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1023
1309
|
arPreference && lens !== '0.5x' && !isARSupportProbed;
|
|
1024
1310
|
const deviceOrientation = useDeviceOrientation();
|
|
1025
1311
|
|
|
1312
|
+
// ── Panorama GUIDANCE — shared motion signals (item 3/4/6) ──────
|
|
1313
|
+
// One gyro + one accelerometer subscription, live only while a non-AR
|
|
1314
|
+
// capture is recording. Feeds the too-fast pill (`panSpeedBucket`)
|
|
1315
|
+
// and the lateral-drift FINALIZE (`lateralExceeded`). `panTooFast-
|
|
1316
|
+
// Threshold` (if set) tunes the 'warn'→'bad' boundary; `lateralBudget-
|
|
1317
|
+
// Cm` tunes the drift latch (0 disables the latch in the hook).
|
|
1318
|
+
const panMotion = usePanMotion({
|
|
1319
|
+
active: statusPhase === 'recording' && isNonAR,
|
|
1320
|
+
warnMaxRadPerSec: panTooFastThreshold,
|
|
1321
|
+
lateralBudgetCm,
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1026
1324
|
// v0.13.1 — counter-rotation for control CONTENT (AR toggle, lens
|
|
1027
1325
|
// pill, flash icon, thumbnails) so their labels read upright relative
|
|
1028
1326
|
// to gravity when the device is held landscape under a PORTRAIT-LOCKED
|
|
@@ -1210,9 +1508,46 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1210
1508
|
// The imperative pattern (start on hold-start, stop on hold-end)
|
|
1211
1509
|
// avoids the re-render churn entirely.
|
|
1212
1510
|
const fpDriver = useFrameProcessorDriver();
|
|
1213
|
-
// Safety: stop the driver
|
|
1511
|
+
// Safety: stop the driver AND clear the pan-duration auto-finalize
|
|
1512
|
+
// timer if the component unmounts mid-recording (item 5 exit path #4).
|
|
1214
1513
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1215
|
-
useEffect(() => () => { fpDriver.stop(); }, []);
|
|
1514
|
+
useEffect(() => () => { fpDriver.stop(); clearPanTimer(); }, []);
|
|
1515
|
+
|
|
1516
|
+
// ── Panorama GUIDANCE — auto-finalize timer + ref bridges ───────
|
|
1517
|
+
// The 9 s pan-duration ceiling (item 5) is an authoritative
|
|
1518
|
+
// `setTimeout` (not derived from the cosmetic countdown tick). Stored
|
|
1519
|
+
// in a ref so the start logic can schedule it and ALL four capture-exit
|
|
1520
|
+
// paths (manual release, drift cancel, lateral stop, unmount) clear it.
|
|
1521
|
+
const panDurationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
1522
|
+
null,
|
|
1523
|
+
);
|
|
1524
|
+
const clearPanTimer = useCallback(() => {
|
|
1525
|
+
if (panDurationTimerRef.current) {
|
|
1526
|
+
clearTimeout(panDurationTimerRef.current);
|
|
1527
|
+
panDurationTimerRef.current = null;
|
|
1528
|
+
}
|
|
1529
|
+
}, []);
|
|
1530
|
+
// `handleHoldEnd` / `startCapture` are defined further down but are
|
|
1531
|
+
// referenced from effects + timers declared above them. Refs break
|
|
1532
|
+
// the declaration-order + circular-useCallback-dep cycle: each is
|
|
1533
|
+
// kept current by a commit-phase effect, and callers invoke via the
|
|
1534
|
+
// ref (`handleHoldEndRef.current?.()`) — mirroring how the drift
|
|
1535
|
+
// effect avoids putting these in its dep array.
|
|
1536
|
+
const handleHoldEndRef = useRef<(() => void) | null>(null);
|
|
1537
|
+
const startCaptureRef = useRef<(() => void) | null>(null);
|
|
1538
|
+
// Synchronous re-entrancy latch for the finalize path: the auto-finalize
|
|
1539
|
+
// timer and a manual release can both pass the async statusPhase guard in
|
|
1540
|
+
// the same tick before React commits 'stitching'.
|
|
1541
|
+
const finalizingRef = useRef(false);
|
|
1542
|
+
// Item 6 — set by the lateral-drift effect just before it calls
|
|
1543
|
+
// handleHoldEnd, so the finalize knows this stop was a sideways-drift
|
|
1544
|
+
// auto-stop and can attach the LATERAL_DRIFT_FINALIZE warning. Consumed
|
|
1545
|
+
// (reset) at the start of handleHoldEnd so it never leaks to the next pan.
|
|
1546
|
+
const lateralFinalizeRef = useRef(false);
|
|
1547
|
+
// Item 4 — latched true if the pan ever exceeded the recommended pace (the
|
|
1548
|
+
// live "too fast" cue fired) during the capture, so the finalize attaches a
|
|
1549
|
+
// HIGH_PAN_SPEED warning. Reset at capture start; consumed at finalize.
|
|
1550
|
+
const fastPanRef = useRef(false);
|
|
1216
1551
|
|
|
1217
1552
|
// ── v0.12.0 — Orientation drift detection + auto-abandon ────────
|
|
1218
1553
|
//
|
|
@@ -1230,10 +1565,20 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1230
1565
|
// the engine spec.
|
|
1231
1566
|
const drift = useOrientationDrift(statusPhase === 'recording');
|
|
1232
1567
|
const [driftModalDismissed, setDriftModalDismissed] = useState(false);
|
|
1233
|
-
// Reset the
|
|
1234
|
-
// recording
|
|
1568
|
+
// Reset the modal flags when a new capture STARTS (statusPhase →
|
|
1569
|
+
// 'recording'), NOT when one stops. v0.16 fix: the old "any non-recording
|
|
1570
|
+
// state" condition cleared `lateralStopVisible` the instant a lateral stop
|
|
1571
|
+
// moved statusPhase out of 'recording' — so the popup was hidden before it
|
|
1572
|
+
// could ever show (the user only saw the downstream error). Clearing on
|
|
1573
|
+
// capture START instead lets the lateral / drift popups persist after the
|
|
1574
|
+
// stop until the user dismisses them, while still giving the next capture
|
|
1575
|
+
// a clean slate.
|
|
1235
1576
|
useEffect(() => {
|
|
1236
|
-
if (statusPhase
|
|
1577
|
+
if (statusPhase === 'recording') {
|
|
1578
|
+
setDriftModalDismissed(false);
|
|
1579
|
+
setLateralStopVisible(false);
|
|
1580
|
+
setLateralWrongDirection(false);
|
|
1581
|
+
}
|
|
1237
1582
|
}, [statusPhase]);
|
|
1238
1583
|
|
|
1239
1584
|
useEffect(() => {
|
|
@@ -1251,6 +1596,9 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1251
1596
|
// through `onError` — abandonment must succeed even if the engine
|
|
1252
1597
|
// is in a weird state.
|
|
1253
1598
|
void (async () => {
|
|
1599
|
+
// item 5 exit path #2 — kill the pan-duration auto-finalize timer
|
|
1600
|
+
// so it can't fire into an already-cancelled capture.
|
|
1601
|
+
clearPanTimer();
|
|
1254
1602
|
fpDriver.stop();
|
|
1255
1603
|
try {
|
|
1256
1604
|
await incremental.cancel();
|
|
@@ -1449,7 +1797,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1449
1797
|
height = result.height;
|
|
1450
1798
|
}
|
|
1451
1799
|
|
|
1452
|
-
onCapture?.({ type: 'photo', uri, width, height });
|
|
1800
|
+
onCapture?.({ ok: true, type: 'photo', uri, width, height, warnings: [] });
|
|
1453
1801
|
} catch (err) {
|
|
1454
1802
|
const e = err instanceof CameraError
|
|
1455
1803
|
? err
|
|
@@ -1458,24 +1806,24 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1458
1806
|
err instanceof Error ? err.message : String(err),
|
|
1459
1807
|
err,
|
|
1460
1808
|
);
|
|
1809
|
+
// v0.16 — failures now reach `onCapture` too (ok:false), with
|
|
1810
|
+
// `onError` kept as a mirror so existing handlers keep working.
|
|
1461
1811
|
onError?.(e);
|
|
1812
|
+
onCapture?.({ ok: false, type: 'photo', error: e, warnings: [] });
|
|
1462
1813
|
}
|
|
1463
1814
|
}, [enablePhotoMode, isAR, capture, outputDir, onCapture, onError]);
|
|
1464
1815
|
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
);
|
|
1474
|
-
return;
|
|
1475
|
-
}
|
|
1816
|
+
// ── startCapture — the "actually start recording" logic ─────────
|
|
1817
|
+
// Extracted from `handleHoldStart` so the rotate-to-landscape gate
|
|
1818
|
+
// (item 1/2) can DEFER it: a portrait Mode-A hold latches
|
|
1819
|
+
// `pendingPanStart` and an effect calls this once the user rotates.
|
|
1820
|
+
// Identical behaviour to the inline body it replaced — the only new
|
|
1821
|
+
// line is the item-5 auto-finalize timer scheduled right after
|
|
1822
|
+
// `setRecordingStartedAt`.
|
|
1823
|
+
const startCapture = useCallback(async () => {
|
|
1476
1824
|
try {
|
|
1477
1825
|
// 2026-05-23 (race fix) — synchronously clear thumbnails +
|
|
1478
|
-
// engine state at the top of
|
|
1826
|
+
// engine state at the top of startCapture, BEFORE awaiting
|
|
1479
1827
|
// incremental.start(). In the previous effect-based design
|
|
1480
1828
|
// the GL thread could ingest an AR frame during the await
|
|
1481
1829
|
// window and add to thumbnails BEFORE React's
|
|
@@ -1485,8 +1833,21 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1485
1833
|
// an empty array and accumulates from there.
|
|
1486
1834
|
setBatchKeyframeThumbnails([]);
|
|
1487
1835
|
setIncrementalState(null);
|
|
1836
|
+
// Item 4 — fresh capture: clear the latched too-fast flag.
|
|
1837
|
+
fastPanRef.current = false;
|
|
1488
1838
|
setStatusPhase('recording');
|
|
1489
1839
|
setRecordingStartedAt(Date.now());
|
|
1840
|
+
// Item 5 — schedule the hard-ceiling auto-finalize. Fires
|
|
1841
|
+
// `handleHoldEnd` (via ref to dodge the circular useCallback dep),
|
|
1842
|
+
// which finalizes what's captured — the FINALIZE-on-zero product
|
|
1843
|
+
// decision. Cleared on every other capture-exit path. Skipped
|
|
1844
|
+
// when the feature is disabled (`maxPanDurationMs <= 0`).
|
|
1845
|
+
clearPanTimer();
|
|
1846
|
+
if (maxPanDurationMs > 0) {
|
|
1847
|
+
panDurationTimerRef.current = setTimeout(() => {
|
|
1848
|
+
handleHoldEndRef.current?.();
|
|
1849
|
+
}, maxPanDurationMs);
|
|
1850
|
+
}
|
|
1490
1851
|
const orientationRotation: 0 | 90 | 180 | 270 =
|
|
1491
1852
|
deviceOrientation === 'portrait' ? 90
|
|
1492
1853
|
: deviceOrientation === 'portrait-upside-down' ? 270
|
|
@@ -1545,6 +1906,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1545
1906
|
}
|
|
1546
1907
|
} catch (err) {
|
|
1547
1908
|
setStatusPhase('idle');
|
|
1909
|
+
clearPanTimer();
|
|
1548
1910
|
onError?.(
|
|
1549
1911
|
new CameraError(
|
|
1550
1912
|
'PANORAMA_START_FAILED',
|
|
@@ -1554,7 +1916,6 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1554
1916
|
);
|
|
1555
1917
|
}
|
|
1556
1918
|
}, [
|
|
1557
|
-
enablePanoramaMode,
|
|
1558
1919
|
incremental,
|
|
1559
1920
|
isNonAR,
|
|
1560
1921
|
deviceOrientation,
|
|
@@ -1564,15 +1925,100 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1564
1925
|
fpDriver,
|
|
1565
1926
|
engine,
|
|
1566
1927
|
onError,
|
|
1928
|
+
maxPanDurationMs,
|
|
1929
|
+
clearPanTimer,
|
|
1930
|
+
]);
|
|
1931
|
+
|
|
1932
|
+
// Keep the ref current so the auto-finalize timer + the rotate-resume
|
|
1933
|
+
// effect can invoke the latest `startCapture` without taking it as a
|
|
1934
|
+
// dep (which would re-run them on every recording-driven re-render).
|
|
1935
|
+
useEffect(() => {
|
|
1936
|
+
startCaptureRef.current = () => { void startCapture(); };
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1939
|
+
// ── handleHoldStart — early guards + the rotate-to-landscape gate ─
|
|
1940
|
+
// The "actually start" body lives in `startCapture`; this wrapper only
|
|
1941
|
+
// decides WHETHER to start now. Under Mode A in portrait it latches
|
|
1942
|
+
// `pendingPanStart` instead (item 1/2) and the resume effect below
|
|
1943
|
+
// starts the capture once the user rotates to landscape.
|
|
1944
|
+
const handleHoldStart = useCallback(() => {
|
|
1945
|
+
if (!enablePanoramaMode) return;
|
|
1946
|
+
if (!incrementalStitcherIsAvailable()) {
|
|
1947
|
+
onError?.(
|
|
1948
|
+
new CameraError(
|
|
1949
|
+
'PANORAMA_START_FAILED',
|
|
1950
|
+
'Native incremental stitcher module not available',
|
|
1951
|
+
),
|
|
1952
|
+
);
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
if (shouldGateForPanMode(panMode, deviceOrientation)) {
|
|
1956
|
+
// Mode-A + portrait — block the start and show the rotate prompt.
|
|
1957
|
+
// The resume effect picks this up the instant the device rotates.
|
|
1958
|
+
setPendingPanStart(true);
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
void startCapture();
|
|
1962
|
+
}, [
|
|
1963
|
+
enablePanoramaMode,
|
|
1964
|
+
onError,
|
|
1965
|
+
panMode,
|
|
1966
|
+
deviceOrientation,
|
|
1967
|
+
startCapture,
|
|
1567
1968
|
]);
|
|
1568
1969
|
|
|
1970
|
+
// ── Rotate-to-landscape resume (item 1/2) ───────────────────────
|
|
1971
|
+
// When a hold was gated (`pendingPanStart`) and the user has since
|
|
1972
|
+
// rotated so the gate no longer fires, start the deferred capture.
|
|
1973
|
+
// Invoked through `startCaptureRef` (kept current above) so this
|
|
1974
|
+
// effect's deps don't churn on every recording re-render.
|
|
1975
|
+
useEffect(() => {
|
|
1976
|
+
if (pendingPanStart && !shouldGateForPanMode(panMode, deviceOrientation)) {
|
|
1977
|
+
setPendingPanStart(false);
|
|
1978
|
+
startCaptureRef.current?.();
|
|
1979
|
+
}
|
|
1980
|
+
}, [pendingPanStart, deviceOrientation, panMode]);
|
|
1981
|
+
|
|
1569
1982
|
const handleHoldEnd = useCallback(async () => {
|
|
1983
|
+
// Item 5 exit path #1 — always kill the auto-finalize timer on
|
|
1984
|
+
// release, even on the early-return below (it's idempotent).
|
|
1985
|
+
clearPanTimer();
|
|
1986
|
+
// Item 1/2 — if the shutter is released while a rotate-gated hold is
|
|
1987
|
+
// pending (user let go before rotating to landscape), abandon the
|
|
1988
|
+
// deferred start rather than starting on the next rotation.
|
|
1989
|
+
if (pendingPanStart) setPendingPanStart(false);
|
|
1570
1990
|
if (statusPhase !== 'recording') return;
|
|
1991
|
+
// Re-entrancy latch — close the timer-vs-release double-finalize window
|
|
1992
|
+
// synchronously so incremental.finalize()/onCapture fire exactly once.
|
|
1993
|
+
if (finalizingRef.current) return;
|
|
1994
|
+
finalizingRef.current = true;
|
|
1995
|
+
// Consume the lateral-drift flag once, here, so it's cleared on BOTH the
|
|
1996
|
+
// success and failure paths and never leaks into the next capture.
|
|
1997
|
+
const wasLateralFinalize = lateralFinalizeRef.current;
|
|
1998
|
+
lateralFinalizeRef.current = false;
|
|
1999
|
+
const wasFastPan = fastPanRef.current;
|
|
2000
|
+
fastPanRef.current = false;
|
|
2001
|
+
if (__DEV__) {
|
|
2002
|
+
// eslint-disable-next-line no-console
|
|
2003
|
+
console.log(
|
|
2004
|
+
`[capture] finalize: wasFastPan=${wasFastPan} `
|
|
2005
|
+
+ `wasLateralFinalize=${wasLateralFinalize}`,
|
|
2006
|
+
);
|
|
2007
|
+
}
|
|
1571
2008
|
setStatusPhase('stitching');
|
|
1572
2009
|
// Stop pumping new frames before finalizing so the engine isn't
|
|
1573
2010
|
// racing the final cv::Stitcher pass against late-arriving
|
|
1574
2011
|
// keyframes. No-op in AR mode (the driver was never started).
|
|
1575
2012
|
fpDriver.stop();
|
|
2013
|
+
// V12.14.8 restore (regressed in the SDK camera extraction): the
|
|
2014
|
+
// render below unmounts <CameraView>/<ARCameraView> while
|
|
2015
|
+
// statusPhase==='stitching'. Yield a macrotask so React commits that
|
|
2016
|
+
// unmount and vision-camera tears down the AVCaptureSession + preview
|
|
2017
|
+
// buffers (~150-250 MB) BEFORE the memory-heavy stitch runs. Without
|
|
2018
|
+
// it the live-camera footprint and the stitch peak coexist and
|
|
2019
|
+
// jetsam (iOS) / lmkd (Android) OOM-kill the app — the exact
|
|
2020
|
+
// WatchdogTermination crash V12.14.8 originally fixed.
|
|
2021
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1576
2022
|
try {
|
|
1577
2023
|
// Compose the panorama output path: host-controlled if
|
|
1578
2024
|
// `outputDir` is set, else the lib's canonical capture dir
|
|
@@ -1595,6 +2041,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1595
2041
|
90, // default JPEG quality
|
|
1596
2042
|
deviceOrientation,
|
|
1597
2043
|
imuTotalTranslationM,
|
|
2044
|
+
lens, // 2026-06-16 — explicit '1x'|'0.5x' for the high-level warper tree
|
|
1598
2045
|
);
|
|
1599
2046
|
if (
|
|
1600
2047
|
typeof result.framesRequested === 'number'
|
|
@@ -1607,7 +2054,20 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1607
2054
|
});
|
|
1608
2055
|
}
|
|
1609
2056
|
|
|
1610
|
-
|
|
2057
|
+
// v0.16 — non-fatal quality signals attached to the result + (when
|
|
2058
|
+
// the crop editor shows) the crop banner. LOW_FRAME_UTILIZATION when
|
|
2059
|
+
// <70 % of captured frames survived; LATERAL_DRIFT_FINALIZE when item-6
|
|
2060
|
+
// stopped this capture early.
|
|
2061
|
+
const warnings = buildCaptureWarnings({
|
|
2062
|
+
framesRequested: result.framesRequested,
|
|
2063
|
+
framesIncluded: result.framesIncluded,
|
|
2064
|
+
lateralFinalize: wasLateralFinalize,
|
|
2065
|
+
highPanSpeed: wasFastPan,
|
|
2066
|
+
copy: captureWarningCopyFrom(guidanceCopyResolved),
|
|
2067
|
+
});
|
|
2068
|
+
|
|
2069
|
+
const captureResultObj: PanoramaCaptureResult = {
|
|
2070
|
+
ok: true,
|
|
1611
2071
|
type: 'panorama',
|
|
1612
2072
|
// Native finalize() returns a bare `/data/.../foo.jpg` path;
|
|
1613
2073
|
// normalise to `file://` for Android <Image>.
|
|
@@ -1621,7 +2081,56 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1621
2081
|
finalConfidenceThresh: result.finalConfidenceThresh ?? -1,
|
|
1622
2082
|
durationMs: Date.now() - (recordingStartedAt ?? Date.now()),
|
|
1623
2083
|
stitchModeResolved: result.stitchModeResolved,
|
|
1624
|
-
|
|
2084
|
+
rRadians: result.rRadians,
|
|
2085
|
+
tMeters: result.tMeters,
|
|
2086
|
+
decisionRatio: result.decisionRatio,
|
|
2087
|
+
debugSummary: result.debugSummary,
|
|
2088
|
+
keyframePaths: result.batchKeyframePaths,
|
|
2089
|
+
captureOrientation: result.captureOrientation,
|
|
2090
|
+
warnings,
|
|
2091
|
+
};
|
|
2092
|
+
// When the crop editor OR a plain preview is enabled AND the panorama
|
|
2093
|
+
// has valid intrinsic dims, defer `onCapture`: stash the result and
|
|
2094
|
+
// mount RectCropPreview (crop mode when `rectCrop`, preview-only when
|
|
2095
|
+
// just `showPreview`). The modal's confirm / use-original / retake
|
|
2096
|
+
// decision emits the final result. Otherwise emit immediately.
|
|
2097
|
+
if (
|
|
2098
|
+
(rectCrop || showPreview)
|
|
2099
|
+
&& result.width > 0
|
|
2100
|
+
&& result.height > 0
|
|
2101
|
+
) {
|
|
2102
|
+
// Crop mode only — seed the quad from the max-inscribed rectangle of
|
|
2103
|
+
// the (un-cropped) panorama so the editor opens on the tightest clean
|
|
2104
|
+
// rectangle, not a blind 8 % inset. Best-effort: an absent native
|
|
2105
|
+
// module / decode failure falls back to the default seed. Skipped in
|
|
2106
|
+
// preview-only mode (no quad to seed).
|
|
2107
|
+
let initialRect: ImageRect | undefined;
|
|
2108
|
+
if (rectCrop) {
|
|
2109
|
+
try {
|
|
2110
|
+
const inscribed = await computeInscribedRect(captureResultObj.uri);
|
|
2111
|
+
if (inscribed && inscribed.width > 0 && inscribed.height > 0) {
|
|
2112
|
+
initialRect = {
|
|
2113
|
+
x: inscribed.x,
|
|
2114
|
+
y: inscribed.y,
|
|
2115
|
+
width: inscribed.width,
|
|
2116
|
+
height: inscribed.height,
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
} catch {
|
|
2120
|
+
// No seed — RectCropPreview uses its default inset.
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
setCropPending({
|
|
2124
|
+
uri: captureResultObj.uri,
|
|
2125
|
+
width: result.width,
|
|
2126
|
+
height: result.height,
|
|
2127
|
+
captureResultObj,
|
|
2128
|
+
initialRect,
|
|
2129
|
+
warnings,
|
|
2130
|
+
});
|
|
2131
|
+
} else {
|
|
2132
|
+
onCapture?.(captureResultObj);
|
|
2133
|
+
}
|
|
1625
2134
|
// 2026-05-22 (audit F9) — fire the debug stitch-stats toast on
|
|
1626
2135
|
// every successful finalize when settings.debug is on. Shows
|
|
1627
2136
|
// the leaveBiggestComponent retry telemetry + resolved mode so
|
|
@@ -1631,18 +2140,29 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1631
2140
|
}
|
|
1632
2141
|
} catch (err) {
|
|
1633
2142
|
const message = err instanceof Error ? err.message : String(err);
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
onError?.(
|
|
2143
|
+
// Classify the raw native failure string → typed code. The chain
|
|
2144
|
+
// lives in classifyStitchError() (the load-bearing C++↔JS contract,
|
|
2145
|
+
// unit-tested against the actual native strings) so a future reword
|
|
2146
|
+
// of a cpp throw can't silently drop the "pan more slowly" path.
|
|
2147
|
+
const code = classifyStitchError(message);
|
|
2148
|
+
const error = new CameraError(code, message, err);
|
|
2149
|
+
// v0.16 — surface the failure on BOTH callbacks: `onError` (unchanged
|
|
2150
|
+
// mirror) and `onCapture` (ok:false) so a host has one place to learn
|
|
2151
|
+
// the outcome. A lateral-drift stop that then failed to stitch still
|
|
2152
|
+
// reports that cause via the warning.
|
|
2153
|
+
onError?.(error);
|
|
2154
|
+
onCapture?.({
|
|
2155
|
+
ok: false,
|
|
2156
|
+
type: 'panorama',
|
|
2157
|
+
error,
|
|
2158
|
+
warnings: buildCaptureWarnings({
|
|
2159
|
+
lateralFinalize: wasLateralFinalize,
|
|
2160
|
+
highPanSpeed: wasFastPan,
|
|
2161
|
+
copy: captureWarningCopyFrom(guidanceCopyResolved),
|
|
2162
|
+
}),
|
|
2163
|
+
});
|
|
1645
2164
|
} finally {
|
|
2165
|
+
finalizingRef.current = false;
|
|
1646
2166
|
setStatusPhase('idle');
|
|
1647
2167
|
setRecordingStartedAt(null);
|
|
1648
2168
|
}
|
|
@@ -1667,8 +2187,164 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1667
2187
|
isNonAR,
|
|
1668
2188
|
imuGate,
|
|
1669
2189
|
stitchToast,
|
|
2190
|
+
// 2026-06-16 — the finalize passes `lens` (the high-level warper tree's zoom
|
|
2191
|
+
// signal); without it here the closure would send a STALE lens if the user
|
|
2192
|
+
// switched 1x↔0.5x after this callback was last memoized.
|
|
2193
|
+
lens,
|
|
2194
|
+
// feature/pano-ux-guidance — the release also tears down the
|
|
2195
|
+
// pan-duration timer + a pending rotate-gate, and decides whether to
|
|
2196
|
+
// route the result through the crop editor.
|
|
2197
|
+
clearPanTimer,
|
|
2198
|
+
pendingPanStart,
|
|
2199
|
+
rectCrop,
|
|
2200
|
+
showPreview,
|
|
1670
2201
|
]);
|
|
1671
2202
|
|
|
2203
|
+
// Keep `handleHoldEndRef` current so the auto-finalize timer + the
|
|
2204
|
+
// lateral-drift effect invoke the latest `handleHoldEnd` without
|
|
2205
|
+
// adding it as a dep (it changes identity on every recording tick).
|
|
2206
|
+
useEffect(() => {
|
|
2207
|
+
handleHoldEndRef.current = () => { void handleHoldEnd(); };
|
|
2208
|
+
});
|
|
2209
|
+
|
|
2210
|
+
// ── Item 6 — lateral drift → FINALIZE + popup ───────────────────
|
|
2211
|
+
// Mirrors the orientation-drift effect, but FINALIZES the capture
|
|
2212
|
+
// (keeps what was stitched) rather than cancelling it: clear the
|
|
2213
|
+
// pan-duration timer, latch the popup, then call handleHoldEnd via
|
|
2214
|
+
// its ref. Gated off when the budget is disabled (`<= 0`).
|
|
2215
|
+
useEffect(() => {
|
|
2216
|
+
if (
|
|
2217
|
+
!panMotion.lateralExceeded
|
|
2218
|
+
|| statusPhase !== 'recording'
|
|
2219
|
+
|| lateralBudgetCm <= 0
|
|
2220
|
+
) {
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
clearPanTimer();
|
|
2224
|
+
|
|
2225
|
+
// #3 — if the user veered off before enough frames were captured to
|
|
2226
|
+
// stitch, finalizing would fail with a misleading "need more images"
|
|
2227
|
+
// error. Instead ABANDON the capture (no stitch → no error) and show
|
|
2228
|
+
// the "follow the arrow" popup. Otherwise FINALIZE what was captured
|
|
2229
|
+
// (a usable partial pano) and show the "keep it straight" popup.
|
|
2230
|
+
const MIN_STITCHABLE_KEYFRAMES = 2;
|
|
2231
|
+
if (acceptedKeyframeCount < MIN_STITCHABLE_KEYFRAMES) {
|
|
2232
|
+
setLateralWrongDirection(true);
|
|
2233
|
+
setLateralStopVisible(true);
|
|
2234
|
+
void (async () => {
|
|
2235
|
+
fpDriver.stop();
|
|
2236
|
+
try {
|
|
2237
|
+
await incremental.cancel();
|
|
2238
|
+
} catch {
|
|
2239
|
+
// best-effort — abandonment must succeed even in a weird state.
|
|
2240
|
+
} finally {
|
|
2241
|
+
setStatusPhase('idle');
|
|
2242
|
+
setRecordingStartedAt(null);
|
|
2243
|
+
onCaptureAbandoned?.('lateral-drift');
|
|
2244
|
+
}
|
|
2245
|
+
})();
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
setLateralWrongDirection(false);
|
|
2250
|
+
setLateralStopVisible(true);
|
|
2251
|
+
// Mark this finalize as lateral-drift-triggered so handleHoldEnd attaches
|
|
2252
|
+
// the LATERAL_DRIFT_FINALIZE warning to the result.
|
|
2253
|
+
lateralFinalizeRef.current = true;
|
|
2254
|
+
handleHoldEndRef.current?.();
|
|
2255
|
+
// Deps mirror the drift effect: re-run when the latch trips or the
|
|
2256
|
+
// recording state changes. Other reads are stable setters / refs.
|
|
2257
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2258
|
+
}, [panMotion.lateralExceeded, statusPhase, lateralBudgetCm]);
|
|
2259
|
+
|
|
2260
|
+
// ── Item 7 — auto-finalize when the configured keyframe count is hit ─
|
|
2261
|
+
// The engine caps accepted keyframes at `keyframeMaxCount`; once it
|
|
2262
|
+
// reports that many, no more frames will be accepted, so stop + stitch
|
|
2263
|
+
// (same finalize path as releasing the shutter). `handleHoldEnd`'s
|
|
2264
|
+
// re-entrancy latch makes this idempotent vs. a manual release in the
|
|
2265
|
+
// same tick. This is the PRIMARY auto-stop (the time cap is opt-in).
|
|
2266
|
+
const keyframeMaxCount = settings.frameSelection.maxKeyframes;
|
|
2267
|
+
const acceptedKeyframeCount = incrementalState?.acceptedCount ?? 0;
|
|
2268
|
+
// Item 4 — speed cue routed into the REC banner/border colour (green→red).
|
|
2269
|
+
// Gated on panGuidance so opting out keeps the banner calm/green.
|
|
2270
|
+
const recordingTooFast =
|
|
2271
|
+
panGuidance && panMotion.panSpeedBucket !== 'good';
|
|
2272
|
+
// Latch the too-fast flag for the HIGH_PAN_SPEED warning (shown on the crop
|
|
2273
|
+
// editor + returned in onCapture.warnings). Latches when the live cue is
|
|
2274
|
+
// active OR — per the user's request — when a KEYFRAME the stitch will use
|
|
2275
|
+
// is accepted while the pan is too fast (so a captured frame was actually
|
|
2276
|
+
// taken at speed). Depending on panSpeedBucket + acceptedKeyframeCount
|
|
2277
|
+
// directly (not just the derived `recordingTooFast`) makes the effect run
|
|
2278
|
+
// on every bucket / keyframe change, so a brief red window can't be missed.
|
|
2279
|
+
// Reset at capture start.
|
|
2280
|
+
const prevAcceptedForSpeedRef = useRef(0);
|
|
2281
|
+
useEffect(() => {
|
|
2282
|
+
if (statusPhase !== 'recording') {
|
|
2283
|
+
prevAcceptedForSpeedRef.current = acceptedKeyframeCount;
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
const newKeyframe = acceptedKeyframeCount > prevAcceptedForSpeedRef.current;
|
|
2287
|
+
prevAcceptedForSpeedRef.current = acceptedKeyframeCount;
|
|
2288
|
+
if (recordingTooFast) {
|
|
2289
|
+
if (__DEV__ && !fastPanRef.current) {
|
|
2290
|
+
// eslint-disable-next-line no-console
|
|
2291
|
+
console.log(
|
|
2292
|
+
`[panMotion] HIGH_PAN_SPEED latched (bucket=`
|
|
2293
|
+
+ `${panMotion.panSpeedBucket} acceptedCount=${acceptedKeyframeCount}`
|
|
2294
|
+
+ `${newKeyframe ? ' on a keyframe' : ''})`,
|
|
2295
|
+
);
|
|
2296
|
+
}
|
|
2297
|
+
fastPanRef.current = true;
|
|
2298
|
+
}
|
|
2299
|
+
}, [
|
|
2300
|
+
statusPhase,
|
|
2301
|
+
recordingTooFast,
|
|
2302
|
+
acceptedKeyframeCount,
|
|
2303
|
+
panMotion.panSpeedBucket,
|
|
2304
|
+
]);
|
|
2305
|
+
useEffect(() => {
|
|
2306
|
+
if (
|
|
2307
|
+
statusPhase === 'recording'
|
|
2308
|
+
&& keyframeMaxCount > 0
|
|
2309
|
+
&& acceptedKeyframeCount >= keyframeMaxCount
|
|
2310
|
+
) {
|
|
2311
|
+
handleHoldEndRef.current?.();
|
|
2312
|
+
}
|
|
2313
|
+
}, [statusPhase, keyframeMaxCount, acceptedKeyframeCount]);
|
|
2314
|
+
|
|
2315
|
+
// ── Item 3 — brief pan how-to overlay at recording start ────────
|
|
2316
|
+
// Show the how-to GIF + direction arrow for a short window when a
|
|
2317
|
+
// recording begins, then auto-fade. The component never self-times;
|
|
2318
|
+
// this effect owns the lifecycle.
|
|
2319
|
+
useEffect(() => {
|
|
2320
|
+
if (statusPhase !== 'recording') {
|
|
2321
|
+
setHowToVisible(false);
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
setHowToVisible(true);
|
|
2325
|
+
const t = setTimeout(() => setHowToVisible(false), 2500);
|
|
2326
|
+
return () => clearTimeout(t);
|
|
2327
|
+
}, [statusPhase]);
|
|
2328
|
+
|
|
2329
|
+
// ── Item 5 — cosmetic countdown tick ────────────────────────────
|
|
2330
|
+
// While recording, bump `nowTick` ~4×/s so `countdownSecondsFrom`
|
|
2331
|
+
// recomputes the displayed whole-seconds. The authoritative auto-stop
|
|
2332
|
+
// is the `panDurationTimerRef` setTimeout, NOT this interval. Skipped
|
|
2333
|
+
// when the countdown feature is disabled (`maxPanDurationMs <= 0`).
|
|
2334
|
+
useEffect(() => {
|
|
2335
|
+
if (statusPhase !== 'recording' || maxPanDurationMs <= 0) return;
|
|
2336
|
+
const id = setInterval(() => setNowTick(Date.now()), 250);
|
|
2337
|
+
return () => clearInterval(id);
|
|
2338
|
+
}, [statusPhase, maxPanDurationMs]);
|
|
2339
|
+
|
|
2340
|
+
// Whole seconds remaining for the countdown overlay (item 5). Pure
|
|
2341
|
+
// helper; clamps to [0, round(maxPanDurationMs/1000)].
|
|
2342
|
+
const countdownSeconds = countdownSecondsFrom(
|
|
2343
|
+
recordingStartedAt,
|
|
2344
|
+
nowTick,
|
|
2345
|
+
maxPanDurationMs,
|
|
2346
|
+
);
|
|
2347
|
+
|
|
1672
2348
|
// ── Lens / AR-toggle handlers ───────────────────────────────────
|
|
1673
2349
|
const handleLensChange = useCallback((next: CameraLens) => {
|
|
1674
2350
|
setLens(next);
|
|
@@ -1729,9 +2405,16 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1729
2405
|
only ONE camera component is alive at a time; matches the
|
|
1730
2406
|
monorepo's working pattern and avoids the Camera2-in-use
|
|
1731
2407
|
conflict that "always mount both" caused on Android. */}
|
|
1732
|
-
{inFlightTransition
|
|
2408
|
+
{cameraShouldUnmount(inFlightTransition, arSupportPending, statusPhase) ? (
|
|
2409
|
+
// statusPhase==='stitching' UNMOUNTS the camera so vision-camera
|
|
2410
|
+
// frees the AVCaptureSession + preview buffers during the stitch
|
|
2411
|
+
// (V12.14.8 OOM fix). The CaptureStatusOverlay renders the
|
|
2412
|
+
// "Stitching…" state on top, so no placeholder label is needed
|
|
2413
|
+
// in that case — only for the camera-switch transition.
|
|
1733
2414
|
<View style={[StyleSheet.absoluteFill, styles.transitionPlaceholder]}>
|
|
1734
|
-
|
|
2415
|
+
{statusPhase === 'stitching' ? null : (
|
|
2416
|
+
<Text style={styles.transitionLabel}>Switching camera…</Text>
|
|
2417
|
+
)}
|
|
1735
2418
|
</View>
|
|
1736
2419
|
) : isAR ? (
|
|
1737
2420
|
<ARCameraView
|
|
@@ -1785,11 +2468,21 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1785
2468
|
/>
|
|
1786
2469
|
)}
|
|
1787
2470
|
|
|
1788
|
-
{/* REC banner + record border (during recording / stitching).
|
|
2471
|
+
{/* REC banner + record border (during recording / stitching). v0.16
|
|
2472
|
+
— the banner + border are GREEN normally and turn RED (with the
|
|
2473
|
+
too-fast copy) when the pan is too fast: the single speed cue that
|
|
2474
|
+
replaced the always-red border + separate amber pill. */}
|
|
1789
2475
|
<CaptureStatusOverlay
|
|
1790
2476
|
phase={statusPhase}
|
|
1791
2477
|
topInset={insets.top}
|
|
1792
2478
|
recordingStartedAt={recordingStartedAt ?? undefined}
|
|
2479
|
+
tooFast={recordingTooFast}
|
|
2480
|
+
recordingMessage={
|
|
2481
|
+
recordingTooFast
|
|
2482
|
+
? guidanceCopyResolved.tooFast
|
|
2483
|
+
: guidanceCopyResolved.statusRecording
|
|
2484
|
+
}
|
|
2485
|
+
stitchingMessage={guidanceCopyResolved.statusStitching}
|
|
1793
2486
|
/>
|
|
1794
2487
|
|
|
1795
2488
|
{/* v0.13.1 — the built-in pan-guidance overlays
|
|
@@ -1799,6 +2492,35 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
1799
2492
|
renders them and the `panGuide` / `panoramaGuidance` props
|
|
1800
2493
|
are gone. Re-wire here if a host need resurfaces. */}
|
|
1801
2494
|
|
|
2495
|
+
{/* feature/pano-ux-guidance — in-capture guidance overlays.
|
|
2496
|
+
All gated on `panGuidance`; each renders null when not
|
|
2497
|
+
visible so they can mount unconditionally. */}
|
|
2498
|
+
{/* Item 6 — live keyframe counter "k / n" (top-centre). The primary
|
|
2499
|
+
capture HUD; the capture auto-finalizes when k reaches n. */}
|
|
2500
|
+
<CaptureFrameCounterOverlay
|
|
2501
|
+
visible={statusPhase === 'recording' && panGuidance}
|
|
2502
|
+
framesCaptured={acceptedKeyframeCount}
|
|
2503
|
+
framesMax={keyframeMaxCount}
|
|
2504
|
+
orientation={deviceOrientation}
|
|
2505
|
+
/>
|
|
2506
|
+
{/* Item 5 — optional blinking time countdown (top corner), shown only
|
|
2507
|
+
when the host opts into a wall-clock cap via maxPanDurationMs. */}
|
|
2508
|
+
<CaptureCountdownOverlay
|
|
2509
|
+
visible={statusPhase === 'recording' && panGuidance && maxPanDurationMs > 0}
|
|
2510
|
+
secondsRemaining={countdownSeconds}
|
|
2511
|
+
orientation={deviceOrientation}
|
|
2512
|
+
/>
|
|
2513
|
+
{/* Item 3 — brief pan how-to graphic + direction arrow. */}
|
|
2514
|
+
<PanHowToOverlay
|
|
2515
|
+
visible={statusPhase === 'recording' && panGuidance && howToVisible}
|
|
2516
|
+
orientation={deviceOrientation}
|
|
2517
|
+
/>
|
|
2518
|
+
{/* Item 4 — "moving too fast" feedback is no longer a separate pill.
|
|
2519
|
+
v0.16 — it's consolidated into the CaptureStatusOverlay banner +
|
|
2520
|
+
border above, which turn from GREEN to RED (with the too-fast copy)
|
|
2521
|
+
when `recordingTooFast` — one calm cue instead of an always-red
|
|
2522
|
+
border plus a second amber pill. */}
|
|
2523
|
+
|
|
1802
2524
|
{/*
|
|
1803
2525
|
2026-05-22 (audit F9 + F3) — debug UI suite, all gated on
|
|
1804
2526
|
settings.debug. Mounts in <Camera> automatically; Layer-2
|
|
@@ -2016,6 +2738,24 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
2016
2738
|
onClose={() => setSettingsModalVisible(false)}
|
|
2017
2739
|
/>
|
|
2018
2740
|
|
|
2741
|
+
{/* Item 1/2 — rotate prompt. Shown while a gated hold is blocked on
|
|
2742
|
+
the user rotating to the target orientation (landscape for
|
|
2743
|
+
panMode='vertical', portrait for 'horizontal'). The resume effect
|
|
2744
|
+
starts the deferred capture the instant they do. */}
|
|
2745
|
+
{/* The rotate prompt is the ONLY feedback for the mode gate, so it is
|
|
2746
|
+
NOT gated on `panGuidance` — otherwise panGuidance={false} +
|
|
2747
|
+
a gated panMode would block the hold with a dead, silent shutter.
|
|
2748
|
+
`panGuidance` governs only the cosmetic in-capture overlays. */}
|
|
2749
|
+
<RotateToLandscapePrompt
|
|
2750
|
+
visible={pendingPanStart}
|
|
2751
|
+
target={gateTargetOrientation(panMode) ?? 'landscape'}
|
|
2752
|
+
copy={
|
|
2753
|
+
gateTargetOrientation(panMode) === 'portrait'
|
|
2754
|
+
? guidanceCopyResolved.rotateToPortrait
|
|
2755
|
+
: guidanceCopyResolved.rotateToLandscape
|
|
2756
|
+
}
|
|
2757
|
+
/>
|
|
2758
|
+
|
|
2019
2759
|
{/* v0.12.0 — Orientation drift modal. Shows AFTER the SDK has
|
|
2020
2760
|
auto-abandoned the capture (the useEffect above stops the
|
|
2021
2761
|
engine + transitions to idle + fires onCaptureAbandoned).
|
|
@@ -2029,6 +2769,29 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
2029
2769
|
onAcknowledge={() => setDriftModalDismissed(true)}
|
|
2030
2770
|
/>
|
|
2031
2771
|
|
|
2772
|
+
{/* Item 6 — lateral-drift popup. Latched true by the lateral
|
|
2773
|
+
effect AFTER it finalizes the capture; informational only,
|
|
2774
|
+
dismiss just clears the latch so the next capture starts
|
|
2775
|
+
fresh. */}
|
|
2776
|
+
<LateralMotionModal
|
|
2777
|
+
visible={lateralStopVisible}
|
|
2778
|
+
title={
|
|
2779
|
+
lateralWrongDirection
|
|
2780
|
+
? guidanceCopyResolved.lateralWrongDirectionTitle
|
|
2781
|
+
: guidanceCopyResolved.lateralStopTitle
|
|
2782
|
+
}
|
|
2783
|
+
body={
|
|
2784
|
+
lateralWrongDirection
|
|
2785
|
+
? guidanceCopyResolved.lateralWrongDirectionBody
|
|
2786
|
+
: guidanceCopyResolved.lateralStopBody
|
|
2787
|
+
}
|
|
2788
|
+
dismissLabel={guidanceCopyResolved.lateralStopDismiss}
|
|
2789
|
+
onDismiss={() => {
|
|
2790
|
+
setLateralStopVisible(false);
|
|
2791
|
+
setLateralWrongDirection(false);
|
|
2792
|
+
}}
|
|
2793
|
+
/>
|
|
2794
|
+
|
|
2032
2795
|
{/* v0.13.0 — built-in post-stitch / tap-to-preview modal.
|
|
2033
2796
|
Visible when the host supplies `capturePreview`. When
|
|
2034
2797
|
undefined the modal stays hidden (visible=false) so it
|
|
@@ -2043,6 +2806,114 @@ export function Camera(props: CameraProps): React.JSX.Element {
|
|
|
2043
2806
|
actions={capturePreviewActions}
|
|
2044
2807
|
onClose={onCapturePreviewClose ?? noop}
|
|
2045
2808
|
/>
|
|
2809
|
+
|
|
2810
|
+
{/* Post-capture review surface, shown after a panorama finalizes when
|
|
2811
|
+
`rectCrop` OR `showPreview` is on (handleHoldEnd stashed the pending
|
|
2812
|
+
result instead of emitting it). `showCropControls={rectCrop}`:
|
|
2813
|
+
- crop mode (rectCrop) → draggable quad seeded on the max-inscribed
|
|
2814
|
+
rectangle; any capture warnings banner on top.
|
|
2815
|
+
- Use original → emit the original, un-cropped panorama.
|
|
2816
|
+
- Crop → cropQuad (perspective-rectify when the quad
|
|
2817
|
+
isn't axis-aligned) overwrites the file in place; emit with
|
|
2818
|
+
the rectified dims + a cache-busting query so <Image> reloads
|
|
2819
|
+
it. On any crop failure, fall back to the original.
|
|
2820
|
+
- preview-only mode (showPreview, no rectCrop) → bare image with
|
|
2821
|
+
[Retake]/[Confirm]; Confirm routes through onUseOriginal. */}
|
|
2822
|
+
<RectCropPreview
|
|
2823
|
+
// Remount per capture so the dragged-quad + layout state re-seed to
|
|
2824
|
+
// the new image (RectCropPreview seeds its quad once via useState).
|
|
2825
|
+
key={cropPending?.uri ?? 'crop'}
|
|
2826
|
+
visible={cropPending != null}
|
|
2827
|
+
imageUri={cropPending?.uri ?? ''}
|
|
2828
|
+
imageWidth={cropPending?.width ?? 0}
|
|
2829
|
+
imageHeight={cropPending?.height ?? 0}
|
|
2830
|
+
initialRect={cropPending?.initialRect}
|
|
2831
|
+
warnings={cropPending?.warnings.map((w) => w.message) ?? []}
|
|
2832
|
+
showCropControls={rectCrop}
|
|
2833
|
+
topInset={insets.top}
|
|
2834
|
+
bottomInset={insets.bottom}
|
|
2835
|
+
copy={guidanceCopyResolved}
|
|
2836
|
+
// Carry the live memory pill onto the preview too (same settings.debug
|
|
2837
|
+
// gate as the camera), so the operator can watch the RSS spike when the
|
|
2838
|
+
// on-demand high-level re-stitch fires.
|
|
2839
|
+
showMemoryPill={settings.debug}
|
|
2840
|
+
// DEV overlay — show the stitcher's runtime choices (pipeline / warper /
|
|
2841
|
+
// route / seam / blend) + score / frames / size for this output, so the
|
|
2842
|
+
// operator can see HOW it was built. __DEV__ only.
|
|
2843
|
+
debugInfo={
|
|
2844
|
+
__DEV__ && cropPending
|
|
2845
|
+
? buildStitchDebugInfo(cropPending.captureResultObj)
|
|
2846
|
+
: undefined
|
|
2847
|
+
}
|
|
2848
|
+
onUseOriginal={(altUri) => {
|
|
2849
|
+
if (cropPending) {
|
|
2850
|
+
// altUri set → the user picked the alt (manual) pipeline's output
|
|
2851
|
+
// in the A/B toggle; emit THAT image (cache-bust for <Image>).
|
|
2852
|
+
onCapture?.(
|
|
2853
|
+
altUri
|
|
2854
|
+
? {
|
|
2855
|
+
...cropPending.captureResultObj,
|
|
2856
|
+
uri: `${altUri}?t=${Date.now()}`,
|
|
2857
|
+
}
|
|
2858
|
+
: cropPending.captureResultObj,
|
|
2859
|
+
);
|
|
2860
|
+
}
|
|
2861
|
+
setCropPending(null);
|
|
2862
|
+
}}
|
|
2863
|
+
onRetake={() => {
|
|
2864
|
+
// Discard this capture entirely — no onCapture — and return to
|
|
2865
|
+
// the live camera (statusPhase is already 'idle' post-finalize).
|
|
2866
|
+
setCropPending(null);
|
|
2867
|
+
}}
|
|
2868
|
+
onConfirm={async ({ quad, perspective }) => {
|
|
2869
|
+
if (!cropPending) return;
|
|
2870
|
+
const pending = cropPending;
|
|
2871
|
+
// perspective=true → rectify the dragged quad to an upright
|
|
2872
|
+
// rectangle (cropToQuad). perspective=false (the user dragged a
|
|
2873
|
+
// ~rectangular quad) → crop to the quad's axis-aligned bounding box
|
|
2874
|
+
// — a plain crop, no warp.
|
|
2875
|
+
const xs = quad.map((p) => p.x);
|
|
2876
|
+
const ys = quad.map((p) => p.y);
|
|
2877
|
+
const cropPoints: Quad = perspective
|
|
2878
|
+
? quad
|
|
2879
|
+
: [
|
|
2880
|
+
{ x: Math.min(...xs), y: Math.min(...ys) },
|
|
2881
|
+
{ x: Math.max(...xs), y: Math.min(...ys) },
|
|
2882
|
+
{ x: Math.max(...xs), y: Math.max(...ys) },
|
|
2883
|
+
{ x: Math.min(...xs), y: Math.max(...ys) },
|
|
2884
|
+
];
|
|
2885
|
+
try {
|
|
2886
|
+
// cropQuad takes a BARE path; the stashed uri is a file://
|
|
2887
|
+
// URI. Overwrites in place (pass the same path).
|
|
2888
|
+
const cropped = await cropQuad(
|
|
2889
|
+
toBareFilePath(pending.uri),
|
|
2890
|
+
cropPoints,
|
|
2891
|
+
undefined,
|
|
2892
|
+
{ quality: 90 },
|
|
2893
|
+
);
|
|
2894
|
+
onCapture?.({
|
|
2895
|
+
...pending.captureResultObj,
|
|
2896
|
+
// Cache-bust so <Image> reloads the overwritten file.
|
|
2897
|
+
uri: `${toFileUri(cropped.outputPath)}?t=${Date.now()}`,
|
|
2898
|
+
width: cropped.width,
|
|
2899
|
+
height: cropped.height,
|
|
2900
|
+
});
|
|
2901
|
+
} catch (err) {
|
|
2902
|
+
onError?.(
|
|
2903
|
+
new CameraError(
|
|
2904
|
+
'OUTPUT_WRITE_FAILED',
|
|
2905
|
+
err instanceof Error ? err.message : String(err),
|
|
2906
|
+
err,
|
|
2907
|
+
),
|
|
2908
|
+
);
|
|
2909
|
+
// Fall back to the un-cropped panorama so the capture isn't
|
|
2910
|
+
// lost on a crop failure.
|
|
2911
|
+
onCapture?.(pending.captureResultObj);
|
|
2912
|
+
} finally {
|
|
2913
|
+
setCropPending(null);
|
|
2914
|
+
}
|
|
2915
|
+
}}
|
|
2916
|
+
/>
|
|
2046
2917
|
</View>
|
|
2047
2918
|
);
|
|
2048
2919
|
}
|
|
@@ -2120,6 +2991,33 @@ export const _homeIndicatorEdgeForTests = homeIndicatorEdge;
|
|
|
2120
2991
|
export const _isSideEdgeForTests = isSideEdge;
|
|
2121
2992
|
|
|
2122
2993
|
|
|
2994
|
+
/**
|
|
2995
|
+
* cameraShouldUnmount — whether the live camera (<CameraView> /
|
|
2996
|
+
* <ARCameraView>) should be UNMOUNTED (replaced by the placeholder) this
|
|
2997
|
+
* render rather than mounted.
|
|
2998
|
+
*
|
|
2999
|
+
* True while a camera-switch transition or AR-support probe is in flight,
|
|
3000
|
+
* OR during the stitch (statusPhase==='stitching'). The stitching case is
|
|
3001
|
+
* the V12.14.8 OOM fix: unmounting frees vision-camera's AVCaptureSession +
|
|
3002
|
+
* preview buffers (~150-250 MB) BEFORE the memory-heavy stitch, so the
|
|
3003
|
+
* live-camera footprint and the stitch peak never coexist and jetsam (iOS)
|
|
3004
|
+
* / lmkd (Android) don't OOM-kill the app.
|
|
3005
|
+
*
|
|
3006
|
+
* Pure + exported for test — the lib's jest config can't mount <Camera>,
|
|
3007
|
+
* so this boolean is the unit-testable core of the OOM render gate.
|
|
3008
|
+
*/
|
|
3009
|
+
function cameraShouldUnmount(
|
|
3010
|
+
inFlightTransition: boolean,
|
|
3011
|
+
arSupportPending: boolean,
|
|
3012
|
+
statusPhase: CaptureStatusPhase,
|
|
3013
|
+
): boolean {
|
|
3014
|
+
return inFlightTransition || arSupportPending || statusPhase === 'stitching';
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
/** @internal test-only — see `cameraShouldUnmount`. */
|
|
3018
|
+
export const _cameraShouldUnmountForTests = cameraShouldUnmount;
|
|
3019
|
+
|
|
3020
|
+
|
|
2123
3021
|
/**
|
|
2124
3022
|
* v0.12.0 — bottom-controls outer container positioning. Anchors
|
|
2125
3023
|
* to the home-indicator JS edge with the appropriate flex direction
|