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.
Files changed (151) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +21 -0
  4. package/README.md +189 -0
  5. package/RNImageStitcher.podspec +76 -0
  6. package/android/build.gradle +224 -0
  7. package/android/src/main/AndroidManifest.xml +3 -0
  8. package/android/src/main/cpp/CMakeLists.txt +124 -0
  9. package/android/src/main/cpp/image_stitcher_jni.cpp +145 -0
  10. package/android/src/main/cpp/keyframe_gate_jni.cpp +204 -0
  11. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +426 -0
  12. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +960 -0
  13. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +2371 -0
  14. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +256 -0
  15. package/android/src/main/java/io/imagestitcher/rn/QualityChecker.kt +167 -0
  16. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +39 -0
  17. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +558 -0
  18. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +35 -0
  19. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +784 -0
  20. package/android/src/main/java/io/imagestitcher/rn/ar/BackgroundRenderer.kt +176 -0
  21. package/android/src/main/java/io/imagestitcher/rn/ar/ShaderUtil.kt +67 -0
  22. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +201 -0
  23. package/cpp/ar_frame_pose.h +63 -0
  24. package/cpp/keyframe_gate.cpp +927 -0
  25. package/cpp/keyframe_gate.hpp +240 -0
  26. package/cpp/stitcher.cpp +2207 -0
  27. package/cpp/stitcher.hpp +275 -0
  28. package/dist/ar/useARSession.d.ts +102 -0
  29. package/dist/ar/useARSession.js +133 -0
  30. package/dist/camera/ARCameraView.d.ts +93 -0
  31. package/dist/camera/ARCameraView.js +170 -0
  32. package/dist/camera/Camera.d.ts +134 -0
  33. package/dist/camera/Camera.js +688 -0
  34. package/dist/camera/CameraShutter.d.ts +80 -0
  35. package/dist/camera/CameraShutter.js +237 -0
  36. package/dist/camera/CameraView.d.ts +65 -0
  37. package/dist/camera/CameraView.js +117 -0
  38. package/dist/camera/CaptureControlsBar.d.ts +87 -0
  39. package/dist/camera/CaptureControlsBar.js +82 -0
  40. package/dist/camera/CaptureHeader.d.ts +62 -0
  41. package/dist/camera/CaptureHeader.js +81 -0
  42. package/dist/camera/CapturePreview.d.ts +70 -0
  43. package/dist/camera/CapturePreview.js +188 -0
  44. package/dist/camera/CaptureStatusOverlay.d.ts +75 -0
  45. package/dist/camera/CaptureStatusOverlay.js +326 -0
  46. package/dist/camera/CaptureThumbnailStrip.d.ts +87 -0
  47. package/dist/camera/CaptureThumbnailStrip.js +177 -0
  48. package/dist/camera/IncrementalPanGuide.d.ts +83 -0
  49. package/dist/camera/IncrementalPanGuide.js +267 -0
  50. package/dist/camera/PanoramaBandOverlay.d.ts +107 -0
  51. package/dist/camera/PanoramaBandOverlay.js +399 -0
  52. package/dist/camera/PanoramaConfirmModal.d.ts +57 -0
  53. package/dist/camera/PanoramaConfirmModal.js +128 -0
  54. package/dist/camera/PanoramaGuidance.d.ts +79 -0
  55. package/dist/camera/PanoramaGuidance.js +246 -0
  56. package/dist/camera/PanoramaSettingsModal.d.ts +311 -0
  57. package/dist/camera/PanoramaSettingsModal.js +611 -0
  58. package/dist/camera/ViewportCropOverlay.d.ts +46 -0
  59. package/dist/camera/ViewportCropOverlay.js +67 -0
  60. package/dist/camera/useCapture.d.ts +111 -0
  61. package/dist/camera/useCapture.js +160 -0
  62. package/dist/camera/useDeviceOrientation.d.ts +48 -0
  63. package/dist/camera/useDeviceOrientation.js +131 -0
  64. package/dist/camera/useVideoCapture.d.ts +79 -0
  65. package/dist/camera/useVideoCapture.js +151 -0
  66. package/dist/index.d.ts +26 -0
  67. package/dist/index.js +39 -0
  68. package/dist/quality/normaliseOrientation.d.ts +36 -0
  69. package/dist/quality/normaliseOrientation.js +62 -0
  70. package/dist/quality/runQualityCheck.d.ts +41 -0
  71. package/dist/quality/runQualityCheck.js +98 -0
  72. package/dist/sensors/useIMUTranslationGate.d.ts +70 -0
  73. package/dist/sensors/useIMUTranslationGate.js +235 -0
  74. package/dist/stitching/IncrementalStitcherView.d.ts +41 -0
  75. package/dist/stitching/IncrementalStitcherView.js +157 -0
  76. package/dist/stitching/incremental.d.ts +930 -0
  77. package/dist/stitching/incremental.js +133 -0
  78. package/dist/stitching/stitchFrames.d.ts +55 -0
  79. package/dist/stitching/stitchFrames.js +56 -0
  80. package/dist/stitching/stitchVideo.d.ts +119 -0
  81. package/dist/stitching/stitchVideo.js +57 -0
  82. package/dist/stitching/useIncrementalJSDriver.d.ts +74 -0
  83. package/dist/stitching/useIncrementalJSDriver.js +199 -0
  84. package/dist/stitching/useIncrementalStitcher.d.ts +58 -0
  85. package/dist/stitching/useIncrementalStitcher.js +172 -0
  86. package/dist/types.d.ts +58 -0
  87. package/dist/types.js +15 -0
  88. package/ios/Package.swift +72 -0
  89. package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +33 -0
  90. package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +40 -0
  91. package/ios/Sources/RNImageStitcher/ARSessionBridge.m +55 -0
  92. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +149 -0
  93. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +2727 -0
  94. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +85 -0
  95. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +625 -0
  96. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +328 -0
  97. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +141 -0
  98. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +278 -0
  99. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +473 -0
  100. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +1326 -0
  101. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +97 -0
  102. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +296 -0
  103. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +103 -0
  104. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +3285 -0
  105. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +238 -0
  106. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +1880 -0
  107. package/ios/Sources/RNImageStitcher/QualityChecker.swift +252 -0
  108. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.m +26 -0
  109. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.swift +72 -0
  110. package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +114 -0
  111. package/ios/Sources/RNImageStitcher/RNSARSession.swift +1111 -0
  112. package/ios/Sources/RNImageStitcher/Stitcher.swift +243 -0
  113. package/ios/Sources/RNImageStitcher/StitcherBridge.m +28 -0
  114. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +246 -0
  115. package/package.json +73 -0
  116. package/react-native.config.js +34 -0
  117. package/scripts/opencv-version.txt +1 -0
  118. package/scripts/postinstall-fetch-binaries.js +286 -0
  119. package/src/ar/useARSession.ts +210 -0
  120. package/src/camera/.gitkeep +0 -0
  121. package/src/camera/ARCameraView.tsx +256 -0
  122. package/src/camera/Camera.tsx +1053 -0
  123. package/src/camera/CameraShutter.tsx +292 -0
  124. package/src/camera/CameraView.tsx +157 -0
  125. package/src/camera/CaptureControlsBar.tsx +204 -0
  126. package/src/camera/CaptureHeader.tsx +184 -0
  127. package/src/camera/CapturePreview.tsx +318 -0
  128. package/src/camera/CaptureStatusOverlay.tsx +391 -0
  129. package/src/camera/CaptureThumbnailStrip.tsx +277 -0
  130. package/src/camera/IncrementalPanGuide.tsx +328 -0
  131. package/src/camera/PanoramaBandOverlay.tsx +498 -0
  132. package/src/camera/PanoramaConfirmModal.tsx +206 -0
  133. package/src/camera/PanoramaGuidance.tsx +327 -0
  134. package/src/camera/PanoramaSettingsModal.tsx +1357 -0
  135. package/src/camera/ViewportCropOverlay.tsx +81 -0
  136. package/src/camera/useCapture.ts +279 -0
  137. package/src/camera/useDeviceOrientation.ts +140 -0
  138. package/src/camera/useVideoCapture.ts +236 -0
  139. package/src/index.ts +53 -0
  140. package/src/quality/.gitkeep +0 -0
  141. package/src/quality/normaliseOrientation.ts +79 -0
  142. package/src/quality/runQualityCheck.ts +131 -0
  143. package/src/sensors/useIMUTranslationGate.ts +347 -0
  144. package/src/stitching/.gitkeep +0 -0
  145. package/src/stitching/IncrementalStitcherView.tsx +198 -0
  146. package/src/stitching/incremental.ts +1021 -0
  147. package/src/stitching/stitchFrames.ts +88 -0
  148. package/src/stitching/stitchVideo.ts +153 -0
  149. package/src/stitching/useIncrementalJSDriver.ts +273 -0
  150. package/src/stitching/useIncrementalStitcher.ts +252 -0
  151. 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
+
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
+ });