react-native-image-stitcher 0.1.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 +96 -0
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +189 -0
- package/RNImageStitcher.podspec +76 -0
- package/android/build.gradle +224 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/cpp/CMakeLists.txt +124 -0
- package/android/src/main/cpp/image_stitcher_jni.cpp +145 -0
- package/android/src/main/cpp/keyframe_gate_jni.cpp +204 -0
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +426 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +960 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +2371 -0
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +256 -0
- package/android/src/main/java/io/imagestitcher/rn/QualityChecker.kt +167 -0
- package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +39 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +558 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +35 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +784 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/BackgroundRenderer.kt +176 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/ShaderUtil.kt +67 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +201 -0
- package/cpp/ar_frame_pose.h +63 -0
- package/cpp/keyframe_gate.cpp +927 -0
- package/cpp/keyframe_gate.hpp +240 -0
- package/cpp/stitcher.cpp +2207 -0
- package/cpp/stitcher.hpp +275 -0
- package/dist/ar/useARSession.d.ts +102 -0
- package/dist/ar/useARSession.js +133 -0
- package/dist/camera/ARCameraView.d.ts +93 -0
- package/dist/camera/ARCameraView.js +170 -0
- package/dist/camera/Camera.d.ts +134 -0
- package/dist/camera/Camera.js +688 -0
- package/dist/camera/CameraShutter.d.ts +80 -0
- package/dist/camera/CameraShutter.js +237 -0
- package/dist/camera/CameraView.d.ts +65 -0
- package/dist/camera/CameraView.js +117 -0
- package/dist/camera/CaptureControlsBar.d.ts +87 -0
- package/dist/camera/CaptureControlsBar.js +82 -0
- package/dist/camera/CaptureHeader.d.ts +62 -0
- package/dist/camera/CaptureHeader.js +81 -0
- package/dist/camera/CapturePreview.d.ts +70 -0
- package/dist/camera/CapturePreview.js +188 -0
- package/dist/camera/CaptureStatusOverlay.d.ts +75 -0
- package/dist/camera/CaptureStatusOverlay.js +326 -0
- package/dist/camera/CaptureThumbnailStrip.d.ts +87 -0
- package/dist/camera/CaptureThumbnailStrip.js +177 -0
- package/dist/camera/IncrementalPanGuide.d.ts +83 -0
- package/dist/camera/IncrementalPanGuide.js +267 -0
- package/dist/camera/PanoramaBandOverlay.d.ts +107 -0
- package/dist/camera/PanoramaBandOverlay.js +399 -0
- package/dist/camera/PanoramaConfirmModal.d.ts +57 -0
- package/dist/camera/PanoramaConfirmModal.js +128 -0
- package/dist/camera/PanoramaGuidance.d.ts +79 -0
- package/dist/camera/PanoramaGuidance.js +246 -0
- package/dist/camera/PanoramaSettingsModal.d.ts +311 -0
- package/dist/camera/PanoramaSettingsModal.js +611 -0
- package/dist/camera/ViewportCropOverlay.d.ts +46 -0
- package/dist/camera/ViewportCropOverlay.js +67 -0
- package/dist/camera/useCapture.d.ts +111 -0
- package/dist/camera/useCapture.js +160 -0
- package/dist/camera/useDeviceOrientation.d.ts +48 -0
- package/dist/camera/useDeviceOrientation.js +131 -0
- package/dist/camera/useVideoCapture.d.ts +79 -0
- package/dist/camera/useVideoCapture.js +151 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +39 -0
- package/dist/quality/normaliseOrientation.d.ts +36 -0
- package/dist/quality/normaliseOrientation.js +62 -0
- package/dist/quality/runQualityCheck.d.ts +41 -0
- package/dist/quality/runQualityCheck.js +98 -0
- package/dist/sensors/useIMUTranslationGate.d.ts +70 -0
- package/dist/sensors/useIMUTranslationGate.js +235 -0
- package/dist/stitching/IncrementalStitcherView.d.ts +41 -0
- package/dist/stitching/IncrementalStitcherView.js +157 -0
- package/dist/stitching/incremental.d.ts +930 -0
- package/dist/stitching/incremental.js +133 -0
- package/dist/stitching/stitchFrames.d.ts +55 -0
- package/dist/stitching/stitchFrames.js +56 -0
- package/dist/stitching/stitchVideo.d.ts +119 -0
- package/dist/stitching/stitchVideo.js +57 -0
- package/dist/stitching/useIncrementalJSDriver.d.ts +74 -0
- package/dist/stitching/useIncrementalJSDriver.js +199 -0
- package/dist/stitching/useIncrementalStitcher.d.ts +58 -0
- package/dist/stitching/useIncrementalStitcher.js +172 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +15 -0
- package/ios/Package.swift +72 -0
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +33 -0
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +40 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.m +55 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +149 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +2727 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +85 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +625 -0
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +328 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +141 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +278 -0
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +473 -0
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +1326 -0
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +97 -0
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +296 -0
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +103 -0
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +3285 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +238 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +1880 -0
- package/ios/Sources/RNImageStitcher/QualityChecker.swift +252 -0
- package/ios/Sources/RNImageStitcher/QualityCheckerBridge.m +26 -0
- package/ios/Sources/RNImageStitcher/QualityCheckerBridge.swift +72 -0
- package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +114 -0
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +1111 -0
- package/ios/Sources/RNImageStitcher/Stitcher.swift +243 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +28 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +246 -0
- package/package.json +73 -0
- package/react-native.config.js +34 -0
- package/scripts/opencv-version.txt +1 -0
- package/scripts/postinstall-fetch-binaries.js +286 -0
- package/src/ar/useARSession.ts +210 -0
- package/src/camera/.gitkeep +0 -0
- package/src/camera/ARCameraView.tsx +256 -0
- package/src/camera/Camera.tsx +1053 -0
- package/src/camera/CameraShutter.tsx +292 -0
- package/src/camera/CameraView.tsx +157 -0
- package/src/camera/CaptureControlsBar.tsx +204 -0
- package/src/camera/CaptureHeader.tsx +184 -0
- package/src/camera/CapturePreview.tsx +318 -0
- package/src/camera/CaptureStatusOverlay.tsx +391 -0
- package/src/camera/CaptureThumbnailStrip.tsx +277 -0
- package/src/camera/IncrementalPanGuide.tsx +328 -0
- package/src/camera/PanoramaBandOverlay.tsx +498 -0
- package/src/camera/PanoramaConfirmModal.tsx +206 -0
- package/src/camera/PanoramaGuidance.tsx +327 -0
- package/src/camera/PanoramaSettingsModal.tsx +1357 -0
- package/src/camera/ViewportCropOverlay.tsx +81 -0
- package/src/camera/useCapture.ts +279 -0
- package/src/camera/useDeviceOrientation.ts +140 -0
- package/src/camera/useVideoCapture.ts +236 -0
- package/src/index.ts +53 -0
- package/src/quality/.gitkeep +0 -0
- package/src/quality/normaliseOrientation.ts +79 -0
- package/src/quality/runQualityCheck.ts +131 -0
- package/src/sensors/useIMUTranslationGate.ts +347 -0
- package/src/stitching/.gitkeep +0 -0
- package/src/stitching/IncrementalStitcherView.tsx +198 -0
- package/src/stitching/incremental.ts +1021 -0
- package/src/stitching/stitchFrames.ts +88 -0
- package/src/stitching/stitchVideo.ts +153 -0
- package/src/stitching/useIncrementalJSDriver.ts +273 -0
- package/src/stitching/useIncrementalStitcher.ts +252 -0
- package/src/types.ts +78 -0
|
@@ -0,0 +1,1053 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Camera — the public, props-based camera component for the
|
|
4
|
+
* `react-native-image-stitcher` library (publication target per the
|
|
5
|
+
* 2026-05-15 design doc).
|
|
6
|
+
*
|
|
7
|
+
* One component, both modes:
|
|
8
|
+
* - **Tap shutter** → single photo via vision-camera's takePhoto
|
|
9
|
+
* (non-AR) or ARFrame.capturedImage (AR).
|
|
10
|
+
* - **Hold shutter** → panorama capture; pan-and-release produces
|
|
11
|
+
* a stitched panorama JPEG via the incremental stitcher.
|
|
12
|
+
*
|
|
13
|
+
* One component, both capture sources:
|
|
14
|
+
* - **AR mode** (ARKit / ARCore) — used for pose-aware stitching
|
|
15
|
+
* when the device supports it.
|
|
16
|
+
* - **Non-AR mode** (vision-camera + IMU) — fallback path,
|
|
17
|
+
* forced when the 0.5× ultra-wide lens is selected (AR sessions
|
|
18
|
+
* are tied to a single physical lens; can't switch mid-session).
|
|
19
|
+
*
|
|
20
|
+
* The Camera component owns its runtime state (arPreference, lens,
|
|
21
|
+
* settings). Parent props are read as INITIAL VALUES at mount; the
|
|
22
|
+
* parent listens for state changes via the callback props. This
|
|
23
|
+
* "uncontrolled" model matches React's `<input>` convention and
|
|
24
|
+
* matches the design doc's intent (NF — component owns runtime state,
|
|
25
|
+
* parent persists via callbacks if desired).
|
|
26
|
+
*
|
|
27
|
+
* Scope note (step 2 of the SDK extract plan):
|
|
28
|
+
* - Props-driven API for both photo + panorama modes — DONE here.
|
|
29
|
+
* - Lens chip + AR toggle UI (U1) — DONE here.
|
|
30
|
+
* - `showSettingsButton` gates the existing PanoramaSettingsModal — DONE.
|
|
31
|
+
* - Imperative ref methods (`takePhoto()`, `startPanorama()`,
|
|
32
|
+
* `stopPanorama()`) — deferred; the built-in shutter button is the
|
|
33
|
+
* primary affordance for v0.1.0.
|
|
34
|
+
* - Forward-looking props (`defaultCompositingResolMP`,
|
|
35
|
+
* `defaultRegistrationResolMP`, `defaultSeamEstimationResolMP`)
|
|
36
|
+
* are accepted but currently no-ops — those fields don't exist on
|
|
37
|
+
* PanoramaSettings yet. They're declared so the public API is
|
|
38
|
+
* stable before they wire through; the wiring is a follow-up.
|
|
39
|
+
*
|
|
40
|
+
* See: docs/site-content/design/2026-05-15-react-native-image-stitcher-publication.md
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import React, {
|
|
44
|
+
useCallback,
|
|
45
|
+
useEffect,
|
|
46
|
+
useRef,
|
|
47
|
+
useState,
|
|
48
|
+
} from 'react';
|
|
49
|
+
import {
|
|
50
|
+
NativeModules,
|
|
51
|
+
Pressable,
|
|
52
|
+
StyleSheet,
|
|
53
|
+
Text,
|
|
54
|
+
View,
|
|
55
|
+
type StyleProp,
|
|
56
|
+
type ViewStyle,
|
|
57
|
+
} from 'react-native';
|
|
58
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
59
|
+
import type { Camera as VisionCamera } from 'react-native-vision-camera';
|
|
60
|
+
|
|
61
|
+
import { useARSession } from '../ar/useARSession';
|
|
62
|
+
import { ARCameraView, type ARCameraViewHandle } from './ARCameraView';
|
|
63
|
+
import { CameraShutter } from './CameraShutter';
|
|
64
|
+
import { CameraView } from './CameraView';
|
|
65
|
+
import { CaptureStatusOverlay, type CaptureStatusPhase } from './CaptureStatusOverlay';
|
|
66
|
+
import { PanoramaBandOverlay } from './PanoramaBandOverlay';
|
|
67
|
+
import {
|
|
68
|
+
DEFAULT_PANORAMA_SETTINGS,
|
|
69
|
+
PanoramaSettingsModal,
|
|
70
|
+
type PanoramaSettings,
|
|
71
|
+
} from './PanoramaSettingsModal';
|
|
72
|
+
import { useCapture } from './useCapture';
|
|
73
|
+
import { useDeviceOrientation } from './useDeviceOrientation';
|
|
74
|
+
import {
|
|
75
|
+
getIncrementalNativeModule,
|
|
76
|
+
incrementalStitcherIsAvailable,
|
|
77
|
+
subscribeIncrementalState,
|
|
78
|
+
type IncrementalState,
|
|
79
|
+
} from '../stitching/incremental';
|
|
80
|
+
import { useIncrementalJSDriver } from '../stitching/useIncrementalJSDriver';
|
|
81
|
+
import { useIncrementalStitcher } from '../stitching/useIncrementalStitcher';
|
|
82
|
+
import { useIMUTranslationGate } from '../sensors/useIMUTranslationGate';
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
// ─── Types ──────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
export type CaptureSource = 'ar' | 'non-ar';
|
|
88
|
+
export type CameraLens = '1x' | '0.5x';
|
|
89
|
+
export type StitchMode = 'auto' | 'panorama' | 'scans';
|
|
90
|
+
export type Blender = 'multiband' | 'feather';
|
|
91
|
+
export type SeamFinder = 'graphcut' | 'skip';
|
|
92
|
+
export type Warper = 'plane' | 'cylindrical' | 'spherical';
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Result emitted via `onCapture`. Discriminated union keyed on
|
|
97
|
+
* `type` so consumers handle both photo and panorama outputs through
|
|
98
|
+
* one callback path.
|
|
99
|
+
*
|
|
100
|
+
* Identifier `CameraCaptureResult` (vs. the SDK's existing
|
|
101
|
+
* `CaptureResult` from `../types`) is intentional — the existing
|
|
102
|
+
* CaptureResult shape has SDK-specific fields (deviceMetadata,
|
|
103
|
+
* qualityReport, deviceUuid) that don't belong in the public RN
|
|
104
|
+
* library's surface. Step 3 (symbol rename) will retire the
|
|
105
|
+
* historical SDK-specific names; for now we keep both types
|
|
106
|
+
* side-by-side so the existing host code continues to work.
|
|
107
|
+
*/
|
|
108
|
+
export type CameraCaptureResult =
|
|
109
|
+
| {
|
|
110
|
+
type: 'photo';
|
|
111
|
+
uri: string;
|
|
112
|
+
width: number;
|
|
113
|
+
height: number;
|
|
114
|
+
}
|
|
115
|
+
| {
|
|
116
|
+
type: 'panorama';
|
|
117
|
+
uri: string;
|
|
118
|
+
width: number;
|
|
119
|
+
height: number;
|
|
120
|
+
framesRequested: number;
|
|
121
|
+
framesIncluded: number;
|
|
122
|
+
framesDropped: number;
|
|
123
|
+
finalConfidenceThresh: number;
|
|
124
|
+
durationMs: number;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Errors surfaced via `onError`. Classified codes so consumers can
|
|
130
|
+
* branch on the kind of failure (toast vs retry vs report).
|
|
131
|
+
*/
|
|
132
|
+
export type CameraErrorCode =
|
|
133
|
+
| 'CAMERA_PERMISSION_DENIED'
|
|
134
|
+
| 'CAMERA_DEVICE_UNAVAILABLE'
|
|
135
|
+
| 'PHOTO_CAPTURE_FAILED'
|
|
136
|
+
| 'PANORAMA_START_FAILED'
|
|
137
|
+
| 'PANORAMA_FINALIZE_FAILED'
|
|
138
|
+
| 'STITCH_NEED_MORE_IMGS'
|
|
139
|
+
| 'STITCH_HOMOGRAPHY_FAIL'
|
|
140
|
+
| 'STITCH_CAMERA_PARAMS_FAIL'
|
|
141
|
+
| 'STITCH_OOM'
|
|
142
|
+
| 'UNKNOWN';
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
export class CameraError extends Error {
|
|
146
|
+
public readonly code: CameraErrorCode;
|
|
147
|
+
public readonly cause?: unknown;
|
|
148
|
+
constructor(code: CameraErrorCode, message: string, cause?: unknown) {
|
|
149
|
+
super(message);
|
|
150
|
+
this.code = code;
|
|
151
|
+
this.cause = cause;
|
|
152
|
+
this.name = 'CameraError';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Frames-dropped info delivered via `onFramesDropped`. Fires once
|
|
159
|
+
* per panorama capture if the C+D progressive-confidence retry loop
|
|
160
|
+
* inside cv::Stitcher dropped one or more input frames.
|
|
161
|
+
*/
|
|
162
|
+
export interface FramesDroppedInfo {
|
|
163
|
+
requested: number;
|
|
164
|
+
included: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Camera component props. See the design doc's "Component API"
|
|
170
|
+
* section for the full rationale per field.
|
|
171
|
+
*/
|
|
172
|
+
export interface CameraProps {
|
|
173
|
+
// ── Initial values (uncontrolled — read once at mount) ────────────
|
|
174
|
+
defaultCaptureSource?: CaptureSource;
|
|
175
|
+
defaultLens?: CameraLens;
|
|
176
|
+
defaultStitchMode?: StitchMode;
|
|
177
|
+
defaultBlender?: Blender;
|
|
178
|
+
defaultSeamFinder?: SeamFinder;
|
|
179
|
+
defaultWarper?: Warper;
|
|
180
|
+
defaultFlowNoveltyPercentile?: number;
|
|
181
|
+
defaultFlowEvalEveryNFrames?: number;
|
|
182
|
+
defaultFlowMaxTranslationCm?: number;
|
|
183
|
+
defaultKeyframeMaxCount?: number;
|
|
184
|
+
defaultKeyframeOverlapThreshold?: number;
|
|
185
|
+
/** Forward-looking — wires through to cv::Stitcher's compositingResol
|
|
186
|
+
* once PanoramaSettings exposes the field (currently a no-op). */
|
|
187
|
+
defaultCompositingResolMP?: number;
|
|
188
|
+
/** Forward-looking — see above. */
|
|
189
|
+
defaultRegistrationResolMP?: number;
|
|
190
|
+
/** Forward-looking — see above. */
|
|
191
|
+
defaultSeamEstimationResolMP?: number;
|
|
192
|
+
|
|
193
|
+
// ── UI knobs ──────────────────────────────────────────────────────
|
|
194
|
+
enablePhotoMode?: boolean;
|
|
195
|
+
enablePanoramaMode?: boolean;
|
|
196
|
+
showSettingsButton?: boolean;
|
|
197
|
+
style?: StyleProp<ViewStyle>;
|
|
198
|
+
|
|
199
|
+
// ── Callbacks ─────────────────────────────────────────────────────
|
|
200
|
+
onCapture?: (result: CameraCaptureResult) => void;
|
|
201
|
+
onCaptureSourceChange?: (source: CaptureSource) => void;
|
|
202
|
+
onLensChange?: (lens: CameraLens) => void;
|
|
203
|
+
onFramesDropped?: (info: FramesDroppedInfo) => void;
|
|
204
|
+
onError?: (err: CameraError) => void;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
// ─── Sub-components ─────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Lens chip — toggles between 1× and 0.5× physical lenses.
|
|
212
|
+
*
|
|
213
|
+
* Placement: bottom-center of the preview, just above the shutter
|
|
214
|
+
* button. Standard iOS-camera-app convention so users know where to
|
|
215
|
+
* look. Two pills side-by-side, the active one filled.
|
|
216
|
+
*/
|
|
217
|
+
interface LensChipProps {
|
|
218
|
+
lens: CameraLens;
|
|
219
|
+
onChange: (lens: CameraLens) => void;
|
|
220
|
+
has0_5x: boolean;
|
|
221
|
+
}
|
|
222
|
+
function LensChip({ lens, onChange, has0_5x }: LensChipProps): React.JSX.Element {
|
|
223
|
+
if (!has0_5x) {
|
|
224
|
+
return (
|
|
225
|
+
<View style={[lensChipStyles.container, lensChipStyles.singleLens]}>
|
|
226
|
+
<Text style={lensChipStyles.label}>1×</Text>
|
|
227
|
+
</View>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return (
|
|
231
|
+
<View style={lensChipStyles.container}>
|
|
232
|
+
<Pressable
|
|
233
|
+
onPress={() => onChange('0.5x')}
|
|
234
|
+
accessibilityRole="button"
|
|
235
|
+
accessibilityLabel="0.5x ultra-wide lens"
|
|
236
|
+
accessibilityState={{ selected: lens === '0.5x' }}
|
|
237
|
+
style={[
|
|
238
|
+
lensChipStyles.pill,
|
|
239
|
+
lens === '0.5x' && lensChipStyles.pillActive,
|
|
240
|
+
]}
|
|
241
|
+
>
|
|
242
|
+
<Text
|
|
243
|
+
style={[
|
|
244
|
+
lensChipStyles.label,
|
|
245
|
+
lens === '0.5x' && lensChipStyles.labelActive,
|
|
246
|
+
]}
|
|
247
|
+
>
|
|
248
|
+
0.5×
|
|
249
|
+
</Text>
|
|
250
|
+
</Pressable>
|
|
251
|
+
<Pressable
|
|
252
|
+
onPress={() => onChange('1x')}
|
|
253
|
+
accessibilityRole="button"
|
|
254
|
+
accessibilityLabel="1x wide-angle lens"
|
|
255
|
+
accessibilityState={{ selected: lens === '1x' }}
|
|
256
|
+
style={[
|
|
257
|
+
lensChipStyles.pill,
|
|
258
|
+
lens === '1x' && lensChipStyles.pillActive,
|
|
259
|
+
]}
|
|
260
|
+
>
|
|
261
|
+
<Text
|
|
262
|
+
style={[
|
|
263
|
+
lensChipStyles.label,
|
|
264
|
+
lens === '1x' && lensChipStyles.labelActive,
|
|
265
|
+
]}
|
|
266
|
+
>
|
|
267
|
+
1×
|
|
268
|
+
</Text>
|
|
269
|
+
</Pressable>
|
|
270
|
+
</View>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const lensChipStyles = StyleSheet.create({
|
|
275
|
+
container: {
|
|
276
|
+
flexDirection: 'row',
|
|
277
|
+
backgroundColor: 'rgba(0,0,0,0.45)',
|
|
278
|
+
borderRadius: 18,
|
|
279
|
+
padding: 3,
|
|
280
|
+
alignSelf: 'center',
|
|
281
|
+
},
|
|
282
|
+
singleLens: {
|
|
283
|
+
paddingHorizontal: 12,
|
|
284
|
+
},
|
|
285
|
+
pill: {
|
|
286
|
+
paddingHorizontal: 12,
|
|
287
|
+
paddingVertical: 6,
|
|
288
|
+
borderRadius: 14,
|
|
289
|
+
minWidth: 44,
|
|
290
|
+
alignItems: 'center',
|
|
291
|
+
},
|
|
292
|
+
pillActive: {
|
|
293
|
+
backgroundColor: '#ffd34d',
|
|
294
|
+
},
|
|
295
|
+
label: {
|
|
296
|
+
color: '#ffffff',
|
|
297
|
+
fontSize: 13,
|
|
298
|
+
fontWeight: '600',
|
|
299
|
+
},
|
|
300
|
+
labelActive: {
|
|
301
|
+
color: '#1a1a1a',
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* AR toggle — switch between AR-backed and non-AR capture.
|
|
308
|
+
* Conditional on `lens === '1x'`; hidden when the user is on 0.5×
|
|
309
|
+
* (which forces non-AR).
|
|
310
|
+
*/
|
|
311
|
+
interface ARToggleProps {
|
|
312
|
+
arEnabled: boolean;
|
|
313
|
+
onToggle: () => void;
|
|
314
|
+
}
|
|
315
|
+
function ARToggle({ arEnabled, onToggle }: ARToggleProps): React.JSX.Element {
|
|
316
|
+
return (
|
|
317
|
+
<Pressable
|
|
318
|
+
onPress={onToggle}
|
|
319
|
+
accessibilityRole="switch"
|
|
320
|
+
accessibilityLabel={`AR mode ${arEnabled ? 'on' : 'off'}`}
|
|
321
|
+
accessibilityState={{ checked: arEnabled }}
|
|
322
|
+
style={[arToggleStyles.container, arEnabled && arToggleStyles.containerOn]}
|
|
323
|
+
>
|
|
324
|
+
<Text
|
|
325
|
+
style={[
|
|
326
|
+
arToggleStyles.label,
|
|
327
|
+
arEnabled && arToggleStyles.labelOn,
|
|
328
|
+
]}
|
|
329
|
+
>
|
|
330
|
+
AR
|
|
331
|
+
</Text>
|
|
332
|
+
</Pressable>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const arToggleStyles = StyleSheet.create({
|
|
337
|
+
container: {
|
|
338
|
+
paddingHorizontal: 14,
|
|
339
|
+
paddingVertical: 8,
|
|
340
|
+
borderRadius: 16,
|
|
341
|
+
backgroundColor: 'rgba(0,0,0,0.45)',
|
|
342
|
+
minWidth: 56,
|
|
343
|
+
alignItems: 'center',
|
|
344
|
+
},
|
|
345
|
+
containerOn: {
|
|
346
|
+
backgroundColor: '#ffd34d',
|
|
347
|
+
},
|
|
348
|
+
label: {
|
|
349
|
+
color: '#ffffff',
|
|
350
|
+
fontSize: 13,
|
|
351
|
+
fontWeight: '700',
|
|
352
|
+
letterSpacing: 1,
|
|
353
|
+
},
|
|
354
|
+
labelOn: {
|
|
355
|
+
color: '#1a1a1a',
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Settings button — opens the internal PanoramaSettingsModal. Gated
|
|
362
|
+
* on the `showSettingsButton` prop (default false) so public
|
|
363
|
+
* consumers don't see it.
|
|
364
|
+
*/
|
|
365
|
+
interface SettingsButtonProps {
|
|
366
|
+
onPress: () => void;
|
|
367
|
+
topInset: number;
|
|
368
|
+
}
|
|
369
|
+
function SettingsButton({ onPress, topInset }: SettingsButtonProps): React.JSX.Element {
|
|
370
|
+
return (
|
|
371
|
+
<Pressable
|
|
372
|
+
onPress={onPress}
|
|
373
|
+
accessibilityRole="button"
|
|
374
|
+
accessibilityLabel="Open camera settings"
|
|
375
|
+
style={[settingsButtonStyles.container, { top: topInset + 8 }]}
|
|
376
|
+
>
|
|
377
|
+
<Text style={settingsButtonStyles.glyph}>⚙</Text>
|
|
378
|
+
</Pressable>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const settingsButtonStyles = StyleSheet.create({
|
|
383
|
+
container: {
|
|
384
|
+
position: 'absolute',
|
|
385
|
+
right: 14,
|
|
386
|
+
width: 40,
|
|
387
|
+
height: 40,
|
|
388
|
+
borderRadius: 20,
|
|
389
|
+
backgroundColor: 'rgba(0,0,0,0.45)',
|
|
390
|
+
alignItems: 'center',
|
|
391
|
+
justifyContent: 'center',
|
|
392
|
+
},
|
|
393
|
+
glyph: {
|
|
394
|
+
color: '#ffffff',
|
|
395
|
+
fontSize: 22,
|
|
396
|
+
lineHeight: 24,
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
// ─── Main component ─────────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Effective capture source derived from arPreference + lens + the
|
|
405
|
+
* device's AR support. On a device without ARKit / ARCore, AR mode
|
|
406
|
+
* is unavailable regardless of the user's preference, and the AR
|
|
407
|
+
* toggle is hidden in the UI (see the bottom-bar JSX). Selecting
|
|
408
|
+
* the 0.5x lens also forces non-AR because ARKit / ARCore sessions
|
|
409
|
+
* don't expose the ultra-wide camera.
|
|
410
|
+
*/
|
|
411
|
+
function deriveEffectiveCaptureSource(
|
|
412
|
+
arPreference: boolean,
|
|
413
|
+
lens: CameraLens,
|
|
414
|
+
isARSupportedOnDevice: boolean,
|
|
415
|
+
): CaptureSource {
|
|
416
|
+
if (!isARSupportedOnDevice) return 'non-ar';
|
|
417
|
+
if (lens === '0.5x') return 'non-ar';
|
|
418
|
+
return arPreference ? 'ar' : 'non-ar';
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Apply per-prop defaults to build the initial settings snapshot.
|
|
424
|
+
* The settings live in component state from there; the prop values
|
|
425
|
+
* never re-flow.
|
|
426
|
+
*
|
|
427
|
+
* Note: the `default*ResolMP` props don't have a home on PanoramaSettings
|
|
428
|
+
* yet — they're accepted on the prop interface for forward compatibility
|
|
429
|
+
* but ignored here. Wiring is a follow-up once PanoramaSettings is
|
|
430
|
+
* extended.
|
|
431
|
+
*/
|
|
432
|
+
function buildInitialSettings(props: CameraProps): PanoramaSettings {
|
|
433
|
+
return {
|
|
434
|
+
...DEFAULT_PANORAMA_SETTINGS,
|
|
435
|
+
stitchMode: props.defaultStitchMode ?? DEFAULT_PANORAMA_SETTINGS.stitchMode,
|
|
436
|
+
blenderType:
|
|
437
|
+
props.defaultBlender ?? DEFAULT_PANORAMA_SETTINGS.blenderType,
|
|
438
|
+
seamFinderType:
|
|
439
|
+
props.defaultSeamFinder ?? DEFAULT_PANORAMA_SETTINGS.seamFinderType,
|
|
440
|
+
warperType:
|
|
441
|
+
props.defaultWarper ?? DEFAULT_PANORAMA_SETTINGS.warperType,
|
|
442
|
+
flowNoveltyPercentile:
|
|
443
|
+
props.defaultFlowNoveltyPercentile ??
|
|
444
|
+
DEFAULT_PANORAMA_SETTINGS.flowNoveltyPercentile,
|
|
445
|
+
flowEvalEveryNFrames:
|
|
446
|
+
props.defaultFlowEvalEveryNFrames ??
|
|
447
|
+
DEFAULT_PANORAMA_SETTINGS.flowEvalEveryNFrames,
|
|
448
|
+
flowMaxTranslationCm:
|
|
449
|
+
props.defaultFlowMaxTranslationCm ??
|
|
450
|
+
DEFAULT_PANORAMA_SETTINGS.flowMaxTranslationCm,
|
|
451
|
+
keyframeMaxCount:
|
|
452
|
+
props.defaultKeyframeMaxCount ??
|
|
453
|
+
DEFAULT_PANORAMA_SETTINGS.keyframeMaxCount,
|
|
454
|
+
keyframeOverlapThreshold:
|
|
455
|
+
props.defaultKeyframeOverlapThreshold ??
|
|
456
|
+
DEFAULT_PANORAMA_SETTINGS.keyframeOverlapThreshold,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Normalise a native-side file path into the `file://...` URI form
|
|
463
|
+
* that React Native's `<Image>` requires on Android. iOS is lenient,
|
|
464
|
+
* but Android rejects bare `/data/...` paths and renders a blank
|
|
465
|
+
* Image with no error in the JS layer.
|
|
466
|
+
*
|
|
467
|
+
* Native code in this lib emits paths in two flavours:
|
|
468
|
+
* - useCapture.compressedUri already includes `file://` (it's
|
|
469
|
+
* normalised in `makeCaptureResult`).
|
|
470
|
+
* - ARCameraView.takePhoto, IncrementalStitcher.finalize, and the
|
|
471
|
+
* `batchKeyframeThumbnailPath` from `IncrementalStateUpdate` all
|
|
472
|
+
* return bare paths. Those are the cases this helper handles.
|
|
473
|
+
*
|
|
474
|
+
* Already-prefixed inputs are passed through unchanged, so it's safe
|
|
475
|
+
* to call defensively at every public-API boundary.
|
|
476
|
+
*/
|
|
477
|
+
function ensureFileUri(path: string | null | undefined): string {
|
|
478
|
+
if (!path) return '';
|
|
479
|
+
if (path.startsWith('file://') || path.startsWith('content://') || path.startsWith('http')) {
|
|
480
|
+
return path;
|
|
481
|
+
}
|
|
482
|
+
return `file://${path}`;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* The public `<Camera>` component.
|
|
488
|
+
*/
|
|
489
|
+
export function Camera(props: CameraProps): React.JSX.Element {
|
|
490
|
+
const {
|
|
491
|
+
defaultCaptureSource = 'ar',
|
|
492
|
+
defaultLens = '1x',
|
|
493
|
+
enablePhotoMode = true,
|
|
494
|
+
enablePanoramaMode = true,
|
|
495
|
+
showSettingsButton = false,
|
|
496
|
+
style,
|
|
497
|
+
onCapture,
|
|
498
|
+
onCaptureSourceChange,
|
|
499
|
+
onLensChange,
|
|
500
|
+
onFramesDropped,
|
|
501
|
+
onError,
|
|
502
|
+
} = props;
|
|
503
|
+
|
|
504
|
+
const insets = useSafeAreaInsets();
|
|
505
|
+
|
|
506
|
+
// ── State ───────────────────────────────────────────────────────
|
|
507
|
+
const [arPreference, setArPreference] = useState(
|
|
508
|
+
defaultCaptureSource === 'ar',
|
|
509
|
+
);
|
|
510
|
+
const [lens, setLens] = useState<CameraLens>(defaultLens);
|
|
511
|
+
const [settings, setSettings] = useState<PanoramaSettings>(() =>
|
|
512
|
+
buildInitialSettings(props),
|
|
513
|
+
);
|
|
514
|
+
const [settingsModalVisible, setSettingsModalVisible] = useState(false);
|
|
515
|
+
const [statusPhase, setStatusPhase] = useState<CaptureStatusPhase>('idle');
|
|
516
|
+
const [recordingStartedAt, setRecordingStartedAt] = useState<number | null>(
|
|
517
|
+
null,
|
|
518
|
+
);
|
|
519
|
+
const [incrementalState, setIncrementalState] = useState<IncrementalState | null>(null);
|
|
520
|
+
const [batchKeyframeThumbnails, setBatchKeyframeThumbnails] = useState<
|
|
521
|
+
string[]
|
|
522
|
+
>([]);
|
|
523
|
+
const [cameraTransitioning, setCameraTransitioning] = useState(false);
|
|
524
|
+
|
|
525
|
+
// ARKit / ARCore device-support probe. `isAvailable` is `false`
|
|
526
|
+
// initially and becomes `true` after the native isSupported() check
|
|
527
|
+
// resolves (~50-200 ms after mount). Devices without ARKit / ARCore
|
|
528
|
+
// (older iPhones, ARCore-less Androids, simulators) stay `false`
|
|
529
|
+
// forever, which forces non-AR capture everywhere and hides the
|
|
530
|
+
// AR toggle in the bottom bar (see JSX below).
|
|
531
|
+
const { isAvailable: isARSupportedOnDevice } = useARSession();
|
|
532
|
+
|
|
533
|
+
const effectiveCaptureSource = deriveEffectiveCaptureSource(
|
|
534
|
+
arPreference,
|
|
535
|
+
lens,
|
|
536
|
+
isARSupportedOnDevice,
|
|
537
|
+
);
|
|
538
|
+
const isAR = effectiveCaptureSource === 'ar';
|
|
539
|
+
const isNonAR = !isAR;
|
|
540
|
+
const deviceOrientation = useDeviceOrientation();
|
|
541
|
+
|
|
542
|
+
// ── Camera handoff gate ─────────────────────────────────────────
|
|
543
|
+
//
|
|
544
|
+
// The placeholder rendered while the underlying camera identity
|
|
545
|
+
// changes (AR toggle, lens swap). Without this gap, Android
|
|
546
|
+
// vision-camera v4 races the new session's open against the old
|
|
547
|
+
// session's teardown → "Session has been closed"
|
|
548
|
+
// IllegalStateException OR "Maximum cameras in use"
|
|
549
|
+
// CameraAccessException.
|
|
550
|
+
//
|
|
551
|
+
// CRITICAL: A naive useState + useEffect approach DOESN'T WORK.
|
|
552
|
+
// useEffect runs AFTER the commit phase — so on the render where
|
|
553
|
+
// isAR/lens flips, the effect hasn't yet set the gate flag, the
|
|
554
|
+
// render branch already evaluated `flag ? placeholder : camera`
|
|
555
|
+
// against the STALE flag=false → the new camera mounts in that
|
|
556
|
+
// commit → race → crash.
|
|
557
|
+
//
|
|
558
|
+
// Fix (mirrors AuditCaptureScreen.tsx ~L695-766): track the
|
|
559
|
+
// "last fully settled" identity in refs and compare them
|
|
560
|
+
// SYNCHRONOUSLY during render. The gate closes on the FIRST
|
|
561
|
+
// render where isAR/lens differs from the settled refs. The
|
|
562
|
+
// useEffect below does the async work (explicit AR session stop +
|
|
563
|
+
// 250 ms grace) and then updates the refs + clears the flag
|
|
564
|
+
// together to drop the gate.
|
|
565
|
+
const settledIsARRef = useRef(isAR);
|
|
566
|
+
const settledLensRef = useRef(lens);
|
|
567
|
+
const inFlightTransition =
|
|
568
|
+
settledIsARRef.current !== isAR
|
|
569
|
+
|| settledLensRef.current !== lens
|
|
570
|
+
|| cameraTransitioning;
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
// ── Notify parent of capture-source changes ─────────────────────
|
|
574
|
+
const lastEmittedSourceRef = useRef<CaptureSource | null>(null);
|
|
575
|
+
useEffect(() => {
|
|
576
|
+
if (lastEmittedSourceRef.current !== effectiveCaptureSource) {
|
|
577
|
+
lastEmittedSourceRef.current = effectiveCaptureSource;
|
|
578
|
+
onCaptureSourceChange?.(effectiveCaptureSource);
|
|
579
|
+
}
|
|
580
|
+
}, [effectiveCaptureSource, onCaptureSourceChange]);
|
|
581
|
+
|
|
582
|
+
// ── Lens chip availability ──────────────────────────────────────
|
|
583
|
+
// TODO follow-up: probe the device's available physical lenses via
|
|
584
|
+
// vision-camera's `useCameraDevices` and surface in
|
|
585
|
+
// `useCapture().availablePhysicalDevices`. For now we assume the
|
|
586
|
+
// 0.5x ultra-wide exists on modern devices. When it doesn't, the
|
|
587
|
+
// lens chip degenerates to a static 1× indicator (see LensChip).
|
|
588
|
+
const has0_5x = true;
|
|
589
|
+
|
|
590
|
+
// ── Capture hooks ───────────────────────────────────────────────
|
|
591
|
+
const capture = useCapture({
|
|
592
|
+
cameraPosition: 'back',
|
|
593
|
+
enableQualityChecks: false,
|
|
594
|
+
preferredPhysicalDevice:
|
|
595
|
+
lens === '0.5x' ? 'ultra-wide-angle-camera' : 'wide-angle-camera',
|
|
596
|
+
});
|
|
597
|
+
const incremental = useIncrementalStitcher();
|
|
598
|
+
const visionCameraRef = useRef<VisionCamera | null>(null);
|
|
599
|
+
const arViewRef = useRef<ARCameraViewHandle | null>(null);
|
|
600
|
+
|
|
601
|
+
// Effect that does the async transition work whenever the settled
|
|
602
|
+
// refs disagree with the current isAR/lens. Order matters:
|
|
603
|
+
// 1. Set the cameraTransitioning state so the gate stays closed
|
|
604
|
+
// after the synchronous compare flips back to "settled" once
|
|
605
|
+
// we update the refs.
|
|
606
|
+
// 2. Explicitly stop the AR session if we were in AR mode — this
|
|
607
|
+
// releases ARCore's grip on Camera2 BEFORE vision-camera tries
|
|
608
|
+
// to open it. Without this on Android the next openCamera()
|
|
609
|
+
// call hits "Maximum cameras in use". The promise is ignored
|
|
610
|
+
// if RNSARSession.stop fails or isn't available.
|
|
611
|
+
// 3. Wait 250 ms (Camera2's HAL onClosed is async; this gives it
|
|
612
|
+
// time to fully release the handle).
|
|
613
|
+
// 4. Update settled refs + clear cameraTransitioning together so
|
|
614
|
+
// the gate opens on the same commit.
|
|
615
|
+
useEffect(() => {
|
|
616
|
+
if (settledIsARRef.current === isAR && settledLensRef.current === lens) {
|
|
617
|
+
return undefined;
|
|
618
|
+
}
|
|
619
|
+
setCameraTransitioning(true);
|
|
620
|
+
let cancelled = false;
|
|
621
|
+
const finishTransition = () => {
|
|
622
|
+
if (cancelled) return;
|
|
623
|
+
settledIsARRef.current = isAR;
|
|
624
|
+
settledLensRef.current = lens;
|
|
625
|
+
setCameraTransitioning(false);
|
|
626
|
+
};
|
|
627
|
+
const wasAR = settledIsARRef.current;
|
|
628
|
+
const arModule = (NativeModules as Record<string, unknown>).RNSARSession as
|
|
629
|
+
| { stop?: () => Promise<void> }
|
|
630
|
+
| undefined;
|
|
631
|
+
const stopPromise: Promise<unknown> =
|
|
632
|
+
wasAR && arModule?.stop ? arModule.stop() : Promise.resolve();
|
|
633
|
+
stopPromise
|
|
634
|
+
.catch(() => undefined)
|
|
635
|
+
.then(() => {
|
|
636
|
+
setTimeout(finishTransition, 250);
|
|
637
|
+
});
|
|
638
|
+
return () => { cancelled = true; };
|
|
639
|
+
}, [isAR, lens]);
|
|
640
|
+
|
|
641
|
+
// IMU translation gate — only in non-AR mode.
|
|
642
|
+
const imuGate = useIMUTranslationGate({
|
|
643
|
+
enabled:
|
|
644
|
+
isNonAR
|
|
645
|
+
&& statusPhase === 'recording'
|
|
646
|
+
&& settings.flowMaxTranslationCm > 0,
|
|
647
|
+
budgetMeters: Math.max(0.001, settings.flowMaxTranslationCm / 100.0),
|
|
648
|
+
onBudgetExceeded: () => {
|
|
649
|
+
const mod = getIncrementalNativeModule();
|
|
650
|
+
mod?.markNextFrameAsLastKeyframe?.().catch(() => undefined);
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
// JS-driver for non-AR captures (iOS + Android). In AR mode the
|
|
655
|
+
// engine consumes frames from the ARSession stream natively, so this
|
|
656
|
+
// hook stays idle.
|
|
657
|
+
//
|
|
658
|
+
// IMPORTANT: start()/stop() are called imperatively from the hold
|
|
659
|
+
// handlers below — NOT from a useEffect driven by statusPhase. The
|
|
660
|
+
// hook returns a fresh object identity on every render, and during
|
|
661
|
+
// a recording the engine emits IncrementalStateUpdate events that
|
|
662
|
+
// cause re-renders multiple times per second. An effect with
|
|
663
|
+
// `jsDriver` in its deps would teardown + restart the driver on
|
|
664
|
+
// every event, resetting the gyro accumulator (yaw/pitch) to zero
|
|
665
|
+
// each cycle and nulling the cameraRef during the brief gap. The
|
|
666
|
+
// user-visible symptom was "only the first keyframe is accepted,
|
|
667
|
+
// every subsequent snapshot sees pose=(0,0) and is rejected as a
|
|
668
|
+
// duplicate of the first". Matching AuditCaptureScreen's proven
|
|
669
|
+
// imperative pattern (start on hold-start, stop on hold-end) avoids
|
|
670
|
+
// the re-render churn entirely.
|
|
671
|
+
const jsDriver = useIncrementalJSDriver();
|
|
672
|
+
// Safety: ensure the driver is stopped if the component unmounts
|
|
673
|
+
// mid-recording. Empty deps so this only fires on unmount.
|
|
674
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
675
|
+
useEffect(() => () => { jsDriver.stop(); }, []);
|
|
676
|
+
|
|
677
|
+
// ── Subscribe to engine state for live keyframe thumbs ──────────
|
|
678
|
+
useEffect(() => {
|
|
679
|
+
const sub = subscribeIncrementalState((state) => {
|
|
680
|
+
setIncrementalState(state);
|
|
681
|
+
if (state?.batchKeyframeThumbnailPath) {
|
|
682
|
+
setBatchKeyframeThumbnails((prev) => {
|
|
683
|
+
// De-dupe — same path may emit on subsequent ticks.
|
|
684
|
+
// Normalise to `file://...` so Android <Image> in the band
|
|
685
|
+
// overlay can actually render the thumbnail.
|
|
686
|
+
const path = ensureFileUri(state.batchKeyframeThumbnailPath!);
|
|
687
|
+
if (prev.includes(path)) return prev;
|
|
688
|
+
return [...prev, path];
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
return () => { sub?.remove?.(); };
|
|
693
|
+
}, []);
|
|
694
|
+
useEffect(() => {
|
|
695
|
+
if (statusPhase === 'recording') {
|
|
696
|
+
setBatchKeyframeThumbnails([]);
|
|
697
|
+
setIncrementalState(null);
|
|
698
|
+
}
|
|
699
|
+
}, [statusPhase]);
|
|
700
|
+
|
|
701
|
+
// ── Shutter handlers ────────────────────────────────────────────
|
|
702
|
+
|
|
703
|
+
const handleTap = useCallback(async () => {
|
|
704
|
+
if (!enablePhotoMode) return;
|
|
705
|
+
try {
|
|
706
|
+
let uri: string;
|
|
707
|
+
let width: number;
|
|
708
|
+
let height: number;
|
|
709
|
+
if (isAR && arViewRef.current) {
|
|
710
|
+
const photo = await arViewRef.current.takePhoto({ quality: 90 });
|
|
711
|
+
// Native side returns a bare `/data/.../foo.jpg` path. Android
|
|
712
|
+
// <Image> needs the `file://` scheme to render it; iOS is OK
|
|
713
|
+
// either way.
|
|
714
|
+
uri = ensureFileUri(photo.path);
|
|
715
|
+
width = photo.width;
|
|
716
|
+
height = photo.height;
|
|
717
|
+
} else {
|
|
718
|
+
if (!visionCameraRef.current) {
|
|
719
|
+
throw new CameraError(
|
|
720
|
+
'CAMERA_DEVICE_UNAVAILABLE',
|
|
721
|
+
'vision-camera ref is not attached',
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
// useCapture.takePhoto wraps the cameraRef internally;
|
|
725
|
+
// attach via assignment so the hook's ref points at our
|
|
726
|
+
// local ref. This works because RefObject is just { current }.
|
|
727
|
+
// Effect: capture.takePhoto() resolves with the SDK's
|
|
728
|
+
// CaptureResult (with compressedUri / width / height).
|
|
729
|
+
// We adapt to the public CameraCaptureResult shape.
|
|
730
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
731
|
+
(capture.cameraRef as any).current = visionCameraRef.current;
|
|
732
|
+
const result = await capture.takePhoto();
|
|
733
|
+
uri = result.compressedUri;
|
|
734
|
+
width = result.width;
|
|
735
|
+
height = result.height;
|
|
736
|
+
}
|
|
737
|
+
onCapture?.({ type: 'photo', uri, width, height });
|
|
738
|
+
} catch (err) {
|
|
739
|
+
const e = err instanceof CameraError
|
|
740
|
+
? err
|
|
741
|
+
: new CameraError(
|
|
742
|
+
'PHOTO_CAPTURE_FAILED',
|
|
743
|
+
err instanceof Error ? err.message : String(err),
|
|
744
|
+
err,
|
|
745
|
+
);
|
|
746
|
+
onError?.(e);
|
|
747
|
+
}
|
|
748
|
+
}, [enablePhotoMode, isAR, capture, onCapture, onError]);
|
|
749
|
+
|
|
750
|
+
const handleHoldStart = useCallback(async () => {
|
|
751
|
+
if (!enablePanoramaMode) return;
|
|
752
|
+
if (!incrementalStitcherIsAvailable()) {
|
|
753
|
+
onError?.(
|
|
754
|
+
new CameraError(
|
|
755
|
+
'PANORAMA_START_FAILED',
|
|
756
|
+
'Native incremental stitcher module not available',
|
|
757
|
+
),
|
|
758
|
+
);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
try {
|
|
762
|
+
setStatusPhase('recording');
|
|
763
|
+
setRecordingStartedAt(Date.now());
|
|
764
|
+
const orientationRotation: 0 | 90 | 180 | 270 =
|
|
765
|
+
deviceOrientation === 'portrait' ? 90
|
|
766
|
+
: deviceOrientation === 'portrait-upside-down' ? 270
|
|
767
|
+
: 0;
|
|
768
|
+
await incremental.start({
|
|
769
|
+
snapshotJpegQuality: 75,
|
|
770
|
+
snapshotEveryNAccepts: 1,
|
|
771
|
+
frameRotationDegrees: orientationRotation,
|
|
772
|
+
captureOrientation: deviceOrientation,
|
|
773
|
+
frameSourceMode: isNonAR ? 'jsDriver' : 'arSession',
|
|
774
|
+
composeWidth: 1920,
|
|
775
|
+
composeHeight: 1080,
|
|
776
|
+
canvasWidth: 5000,
|
|
777
|
+
canvasHeight: 5000,
|
|
778
|
+
engine: 'batch-keyframe',
|
|
779
|
+
config: {
|
|
780
|
+
stitchMode: settings.stitchMode,
|
|
781
|
+
warperType: settings.warperType,
|
|
782
|
+
blenderType: settings.blenderType,
|
|
783
|
+
seamFinderType: settings.seamFinderType,
|
|
784
|
+
flowNoveltyPercentile: settings.flowNoveltyPercentile,
|
|
785
|
+
flowEvalEveryNFrames: settings.flowEvalEveryNFrames,
|
|
786
|
+
flowMaxTranslationCm: settings.flowMaxTranslationCm,
|
|
787
|
+
keyframeMaxCount: settings.keyframeMaxCount,
|
|
788
|
+
keyframeOverlapThreshold: settings.keyframeOverlapThreshold,
|
|
789
|
+
frameSelectionMode: 'flow-based',
|
|
790
|
+
},
|
|
791
|
+
});
|
|
792
|
+
imuGate.resetAnchor();
|
|
793
|
+
// Start pumping vision-camera snapshots into the engine for
|
|
794
|
+
// non-AR captures. AR mode feeds frames natively from the
|
|
795
|
+
// ARSession, so the JS driver stays idle in that path. This
|
|
796
|
+
// mirrors AuditCaptureScreen.handleHoldStart's `androidDriver.start`
|
|
797
|
+
// imperative call — see the comment near `useIncrementalJSDriver`
|
|
798
|
+
// for why this is NOT done via useEffect.
|
|
799
|
+
if (isNonAR) {
|
|
800
|
+
jsDriver.start(visionCameraRef);
|
|
801
|
+
}
|
|
802
|
+
} catch (err) {
|
|
803
|
+
setStatusPhase('idle');
|
|
804
|
+
onError?.(
|
|
805
|
+
new CameraError(
|
|
806
|
+
'PANORAMA_START_FAILED',
|
|
807
|
+
err instanceof Error ? err.message : String(err),
|
|
808
|
+
err,
|
|
809
|
+
),
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
}, [
|
|
813
|
+
enablePanoramaMode,
|
|
814
|
+
incremental,
|
|
815
|
+
isNonAR,
|
|
816
|
+
deviceOrientation,
|
|
817
|
+
settings,
|
|
818
|
+
imuGate,
|
|
819
|
+
jsDriver,
|
|
820
|
+
onError,
|
|
821
|
+
]);
|
|
822
|
+
|
|
823
|
+
const handleHoldEnd = useCallback(async () => {
|
|
824
|
+
if (statusPhase !== 'recording') return;
|
|
825
|
+
setStatusPhase('stitching');
|
|
826
|
+
// Stop pumping new snapshots before finalizing so the engine isn't
|
|
827
|
+
// racing the final cv::Stitcher pass against late-arriving keyframes.
|
|
828
|
+
// No-op in AR mode where jsDriver was never started.
|
|
829
|
+
jsDriver.stop();
|
|
830
|
+
try {
|
|
831
|
+
const result = await incremental.finalize(
|
|
832
|
+
undefined,
|
|
833
|
+
90,
|
|
834
|
+
deviceOrientation,
|
|
835
|
+
);
|
|
836
|
+
if (
|
|
837
|
+
typeof result.framesRequested === 'number'
|
|
838
|
+
&& typeof result.framesIncluded === 'number'
|
|
839
|
+
&& result.framesIncluded < result.framesRequested
|
|
840
|
+
) {
|
|
841
|
+
onFramesDropped?.({
|
|
842
|
+
requested: result.framesRequested,
|
|
843
|
+
included: result.framesIncluded,
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
onCapture?.({
|
|
847
|
+
type: 'panorama',
|
|
848
|
+
// Native finalize() returns a bare `/data/.../foo.jpg` path;
|
|
849
|
+
// normalise to `file://` for Android <Image>.
|
|
850
|
+
uri: ensureFileUri(result.panoramaPath),
|
|
851
|
+
width: result.width,
|
|
852
|
+
height: result.height,
|
|
853
|
+
framesRequested: result.framesRequested ?? -1,
|
|
854
|
+
framesIncluded: result.framesIncluded ?? -1,
|
|
855
|
+
framesDropped:
|
|
856
|
+
(result.framesRequested ?? 0) - (result.framesIncluded ?? 0),
|
|
857
|
+
finalConfidenceThresh: result.finalConfidenceThresh ?? -1,
|
|
858
|
+
durationMs: Date.now() - (recordingStartedAt ?? Date.now()),
|
|
859
|
+
});
|
|
860
|
+
} catch (err) {
|
|
861
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
862
|
+
const code: CameraErrorCode =
|
|
863
|
+
/need more images/i.test(message) ? 'STITCH_NEED_MORE_IMGS'
|
|
864
|
+
: /homography/i.test(message) ? 'STITCH_HOMOGRAPHY_FAIL'
|
|
865
|
+
: /camera params/i.test(message) ? 'STITCH_CAMERA_PARAMS_FAIL'
|
|
866
|
+
: /out of memory|oom/i.test(message) ? 'STITCH_OOM'
|
|
867
|
+
: 'PANORAMA_FINALIZE_FAILED';
|
|
868
|
+
onError?.(new CameraError(code, message, err));
|
|
869
|
+
} finally {
|
|
870
|
+
setStatusPhase('idle');
|
|
871
|
+
setRecordingStartedAt(null);
|
|
872
|
+
}
|
|
873
|
+
}, [
|
|
874
|
+
statusPhase,
|
|
875
|
+
incremental,
|
|
876
|
+
deviceOrientation,
|
|
877
|
+
onCapture,
|
|
878
|
+
onFramesDropped,
|
|
879
|
+
onError,
|
|
880
|
+
recordingStartedAt,
|
|
881
|
+
jsDriver,
|
|
882
|
+
]);
|
|
883
|
+
|
|
884
|
+
// ── Lens / AR-toggle handlers ───────────────────────────────────
|
|
885
|
+
const handleLensChange = useCallback((next: CameraLens) => {
|
|
886
|
+
setLens(next);
|
|
887
|
+
onLensChange?.(next);
|
|
888
|
+
}, [onLensChange]);
|
|
889
|
+
|
|
890
|
+
const handleARToggle = useCallback(() => {
|
|
891
|
+
setArPreference((prev) => !prev);
|
|
892
|
+
}, []);
|
|
893
|
+
|
|
894
|
+
// ── JSX ─────────────────────────────────────────────────────────
|
|
895
|
+
|
|
896
|
+
return (
|
|
897
|
+
<View style={[styles.container, style]}>
|
|
898
|
+
{/* Preview — AR or non-AR (or the brief "switching…" placeholder
|
|
899
|
+
while the previous session tears down). Conditional mount so
|
|
900
|
+
only ONE camera component is alive at a time; matches the
|
|
901
|
+
monorepo's working pattern and avoids the Camera2-in-use
|
|
902
|
+
conflict that "always mount both" caused on Android. */}
|
|
903
|
+
{inFlightTransition ? (
|
|
904
|
+
<View style={[StyleSheet.absoluteFill, styles.transitionPlaceholder]}>
|
|
905
|
+
<Text style={styles.transitionLabel}>Switching camera…</Text>
|
|
906
|
+
</View>
|
|
907
|
+
) : isAR ? (
|
|
908
|
+
<ARCameraView
|
|
909
|
+
ref={arViewRef}
|
|
910
|
+
style={StyleSheet.absoluteFill}
|
|
911
|
+
/>
|
|
912
|
+
) : (
|
|
913
|
+
<CameraView
|
|
914
|
+
ref={visionCameraRef}
|
|
915
|
+
device={capture.device}
|
|
916
|
+
isActive
|
|
917
|
+
// `video={true}` is REQUIRED for takeSnapshot to work on iOS.
|
|
918
|
+
// vision-camera v4's iOS implementation of takeSnapshot waits
|
|
919
|
+
// for a frame on the video pipeline; with video disabled, the
|
|
920
|
+
// promise never resolves and the JS frame-driver stalls after
|
|
921
|
+
// the very first buffered preview frame. Android takeSnapshot
|
|
922
|
+
// works either way. Pattern matches AuditCaptureScreen.tsx
|
|
923
|
+
// which has run on `video` (true) for months without issue.
|
|
924
|
+
video
|
|
925
|
+
flash="off"
|
|
926
|
+
style={StyleSheet.absoluteFill}
|
|
927
|
+
/>
|
|
928
|
+
)}
|
|
929
|
+
|
|
930
|
+
{/* REC banner + record border (during recording / stitching). */}
|
|
931
|
+
<CaptureStatusOverlay
|
|
932
|
+
phase={statusPhase}
|
|
933
|
+
topInset={insets.top}
|
|
934
|
+
recordingStartedAt={recordingStartedAt ?? undefined}
|
|
935
|
+
/>
|
|
936
|
+
|
|
937
|
+
{/* Settings gear (top-right), gated on showSettingsButton. */}
|
|
938
|
+
{showSettingsButton && (
|
|
939
|
+
<SettingsButton
|
|
940
|
+
topInset={insets.top}
|
|
941
|
+
onPress={() => setSettingsModalVisible(true)}
|
|
942
|
+
/>
|
|
943
|
+
)}
|
|
944
|
+
|
|
945
|
+
{/*
|
|
946
|
+
Bottom area: stacks the live-frame band ABOVE the shutter row
|
|
947
|
+
so the band is tethered to the shutter on the viewport side
|
|
948
|
+
(the operator's eye is drawn from the camera preview, down
|
|
949
|
+
the band, into the shutter — a single continuous reading
|
|
950
|
+
path). With the SDK's orientation lock holding the UI in
|
|
951
|
+
portrait, this stack works the same regardless of how the
|
|
952
|
+
device is physically held.
|
|
953
|
+
*/}
|
|
954
|
+
<View
|
|
955
|
+
pointerEvents="box-none"
|
|
956
|
+
style={[styles.bottomArea, { paddingBottom: insets.bottom + 12 }]}
|
|
957
|
+
>
|
|
958
|
+
{/* Live-frame band — only visible while recording. */}
|
|
959
|
+
{statusPhase === 'recording' && (
|
|
960
|
+
<PanoramaBandOverlay
|
|
961
|
+
state={incrementalState}
|
|
962
|
+
frameUris={batchKeyframeThumbnails}
|
|
963
|
+
captureOrientation={deviceOrientation}
|
|
964
|
+
/>
|
|
965
|
+
)}
|
|
966
|
+
|
|
967
|
+
{/* Shutter row: lens chip (left), shutter (centre), AR toggle (right). */}
|
|
968
|
+
<View style={styles.bottomBar}>
|
|
969
|
+
<View style={styles.bottomBarLeft} />
|
|
970
|
+
<View style={styles.bottomBarCenter}>
|
|
971
|
+
<LensChip
|
|
972
|
+
lens={lens}
|
|
973
|
+
onChange={handleLensChange}
|
|
974
|
+
has0_5x={has0_5x}
|
|
975
|
+
/>
|
|
976
|
+
<View style={styles.shutterWrap}>
|
|
977
|
+
<CameraShutter
|
|
978
|
+
onTap={handleTap}
|
|
979
|
+
onHoldStart={enablePanoramaMode ? handleHoldStart : noop}
|
|
980
|
+
onHoldComplete={enablePanoramaMode ? handleHoldEnd : noop}
|
|
981
|
+
isProcessing={statusPhase === 'stitching'}
|
|
982
|
+
disabled={statusPhase === 'stitching'}
|
|
983
|
+
/>
|
|
984
|
+
</View>
|
|
985
|
+
</View>
|
|
986
|
+
<View style={styles.bottomBarRight}>
|
|
987
|
+
{lens === '1x' && isARSupportedOnDevice && (
|
|
988
|
+
<ARToggle arEnabled={arPreference} onToggle={handleARToggle} />
|
|
989
|
+
)}
|
|
990
|
+
</View>
|
|
991
|
+
</View>
|
|
992
|
+
</View>
|
|
993
|
+
|
|
994
|
+
{/* Settings modal (rendered always, visible-gated). */}
|
|
995
|
+
<PanoramaSettingsModal
|
|
996
|
+
visible={settingsModalVisible}
|
|
997
|
+
settings={settings}
|
|
998
|
+
onChange={setSettings}
|
|
999
|
+
onClose={() => setSettingsModalVisible(false)}
|
|
1000
|
+
/>
|
|
1001
|
+
</View>
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
function noop(): void {
|
|
1007
|
+
/* no-op handler used when panorama mode is disabled */
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
const styles = StyleSheet.create({
|
|
1012
|
+
container: {
|
|
1013
|
+
flex: 1,
|
|
1014
|
+
backgroundColor: '#000',
|
|
1015
|
+
},
|
|
1016
|
+
transitionPlaceholder: {
|
|
1017
|
+
backgroundColor: '#000',
|
|
1018
|
+
alignItems: 'center',
|
|
1019
|
+
justifyContent: 'center',
|
|
1020
|
+
},
|
|
1021
|
+
transitionLabel: {
|
|
1022
|
+
color: 'rgba(255,255,255,0.6)',
|
|
1023
|
+
fontSize: 13,
|
|
1024
|
+
},
|
|
1025
|
+
bottomArea: {
|
|
1026
|
+
position: 'absolute',
|
|
1027
|
+
left: 0,
|
|
1028
|
+
right: 0,
|
|
1029
|
+
bottom: 0,
|
|
1030
|
+
flexDirection: 'column',
|
|
1031
|
+
alignItems: 'stretch',
|
|
1032
|
+
},
|
|
1033
|
+
bottomBar: {
|
|
1034
|
+
flexDirection: 'row',
|
|
1035
|
+
paddingHorizontal: 18,
|
|
1036
|
+
alignItems: 'flex-end',
|
|
1037
|
+
},
|
|
1038
|
+
bottomBarLeft: {
|
|
1039
|
+
flex: 1,
|
|
1040
|
+
},
|
|
1041
|
+
bottomBarCenter: {
|
|
1042
|
+
flex: 1,
|
|
1043
|
+
alignItems: 'center',
|
|
1044
|
+
},
|
|
1045
|
+
bottomBarRight: {
|
|
1046
|
+
flex: 1,
|
|
1047
|
+
alignItems: 'flex-end',
|
|
1048
|
+
justifyContent: 'flex-end',
|
|
1049
|
+
},
|
|
1050
|
+
shutterWrap: {
|
|
1051
|
+
marginTop: 12,
|
|
1052
|
+
},
|
|
1053
|
+
});
|