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,688 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Camera — the public, props-based camera component for the
5
+ * `react-native-image-stitcher` library (publication target per the
6
+ * 2026-05-15 design doc).
7
+ *
8
+ * One component, both modes:
9
+ * - **Tap shutter** → single photo via vision-camera's takePhoto
10
+ * (non-AR) or ARFrame.capturedImage (AR).
11
+ * - **Hold shutter** → panorama capture; pan-and-release produces
12
+ * a stitched panorama JPEG via the incremental stitcher.
13
+ *
14
+ * One component, both capture sources:
15
+ * - **AR mode** (ARKit / ARCore) — used for pose-aware stitching
16
+ * when the device supports it.
17
+ * - **Non-AR mode** (vision-camera + IMU) — fallback path,
18
+ * forced when the 0.5× ultra-wide lens is selected (AR sessions
19
+ * are tied to a single physical lens; can't switch mid-session).
20
+ *
21
+ * The Camera component owns its runtime state (arPreference, lens,
22
+ * settings). Parent props are read as INITIAL VALUES at mount; the
23
+ * parent listens for state changes via the callback props. This
24
+ * "uncontrolled" model matches React's `<input>` convention and
25
+ * matches the design doc's intent (NF — component owns runtime state,
26
+ * parent persists via callbacks if desired).
27
+ *
28
+ * Scope note (step 2 of the SDK extract plan):
29
+ * - Props-driven API for both photo + panorama modes — DONE here.
30
+ * - Lens chip + AR toggle UI (U1) — DONE here.
31
+ * - `showSettingsButton` gates the existing PanoramaSettingsModal — DONE.
32
+ * - Imperative ref methods (`takePhoto()`, `startPanorama()`,
33
+ * `stopPanorama()`) — deferred; the built-in shutter button is the
34
+ * primary affordance for v0.1.0.
35
+ * - Forward-looking props (`defaultCompositingResolMP`,
36
+ * `defaultRegistrationResolMP`, `defaultSeamEstimationResolMP`)
37
+ * are accepted but currently no-ops — those fields don't exist on
38
+ * PanoramaSettings yet. They're declared so the public API is
39
+ * stable before they wire through; the wiring is a follow-up.
40
+ *
41
+ * See: docs/site-content/design/2026-05-15-react-native-image-stitcher-publication.md
42
+ */
43
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
44
+ if (k2 === undefined) k2 = k;
45
+ var desc = Object.getOwnPropertyDescriptor(m, k);
46
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
47
+ desc = { enumerable: true, get: function() { return m[k]; } };
48
+ }
49
+ Object.defineProperty(o, k2, desc);
50
+ }) : (function(o, m, k, k2) {
51
+ if (k2 === undefined) k2 = k;
52
+ o[k2] = m[k];
53
+ }));
54
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
55
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
56
+ }) : function(o, v) {
57
+ o["default"] = v;
58
+ });
59
+ var __importStar = (this && this.__importStar) || (function () {
60
+ var ownKeys = function(o) {
61
+ ownKeys = Object.getOwnPropertyNames || function (o) {
62
+ var ar = [];
63
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
64
+ return ar;
65
+ };
66
+ return ownKeys(o);
67
+ };
68
+ return function (mod) {
69
+ if (mod && mod.__esModule) return mod;
70
+ var result = {};
71
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
72
+ __setModuleDefault(result, mod);
73
+ return result;
74
+ };
75
+ })();
76
+ Object.defineProperty(exports, "__esModule", { value: true });
77
+ exports.CameraError = void 0;
78
+ exports.Camera = Camera;
79
+ const react_1 = __importStar(require("react"));
80
+ const react_native_1 = require("react-native");
81
+ const react_native_safe_area_context_1 = require("react-native-safe-area-context");
82
+ const useARSession_1 = require("../ar/useARSession");
83
+ const ARCameraView_1 = require("./ARCameraView");
84
+ const CameraShutter_1 = require("./CameraShutter");
85
+ const CameraView_1 = require("./CameraView");
86
+ const CaptureStatusOverlay_1 = require("./CaptureStatusOverlay");
87
+ const PanoramaBandOverlay_1 = require("./PanoramaBandOverlay");
88
+ const PanoramaSettingsModal_1 = require("./PanoramaSettingsModal");
89
+ const useCapture_1 = require("./useCapture");
90
+ const useDeviceOrientation_1 = require("./useDeviceOrientation");
91
+ const incremental_1 = require("../stitching/incremental");
92
+ const useIncrementalJSDriver_1 = require("../stitching/useIncrementalJSDriver");
93
+ const useIncrementalStitcher_1 = require("../stitching/useIncrementalStitcher");
94
+ const useIMUTranslationGate_1 = require("../sensors/useIMUTranslationGate");
95
+ class CameraError extends Error {
96
+ constructor(code, message, cause) {
97
+ super(message);
98
+ this.code = code;
99
+ this.cause = cause;
100
+ this.name = 'CameraError';
101
+ }
102
+ }
103
+ exports.CameraError = CameraError;
104
+ function LensChip({ lens, onChange, has0_5x }) {
105
+ if (!has0_5x) {
106
+ return (react_1.default.createElement(react_native_1.View, { style: [lensChipStyles.container, lensChipStyles.singleLens] },
107
+ react_1.default.createElement(react_native_1.Text, { style: lensChipStyles.label }, "1\u00D7")));
108
+ }
109
+ return (react_1.default.createElement(react_native_1.View, { style: lensChipStyles.container },
110
+ react_1.default.createElement(react_native_1.Pressable, { onPress: () => onChange('0.5x'), accessibilityRole: "button", accessibilityLabel: "0.5x ultra-wide lens", accessibilityState: { selected: lens === '0.5x' }, style: [
111
+ lensChipStyles.pill,
112
+ lens === '0.5x' && lensChipStyles.pillActive,
113
+ ] },
114
+ react_1.default.createElement(react_native_1.Text, { style: [
115
+ lensChipStyles.label,
116
+ lens === '0.5x' && lensChipStyles.labelActive,
117
+ ] }, "0.5\u00D7")),
118
+ react_1.default.createElement(react_native_1.Pressable, { onPress: () => onChange('1x'), accessibilityRole: "button", accessibilityLabel: "1x wide-angle lens", accessibilityState: { selected: lens === '1x' }, style: [
119
+ lensChipStyles.pill,
120
+ lens === '1x' && lensChipStyles.pillActive,
121
+ ] },
122
+ react_1.default.createElement(react_native_1.Text, { style: [
123
+ lensChipStyles.label,
124
+ lens === '1x' && lensChipStyles.labelActive,
125
+ ] }, "1\u00D7"))));
126
+ }
127
+ const lensChipStyles = react_native_1.StyleSheet.create({
128
+ container: {
129
+ flexDirection: 'row',
130
+ backgroundColor: 'rgba(0,0,0,0.45)',
131
+ borderRadius: 18,
132
+ padding: 3,
133
+ alignSelf: 'center',
134
+ },
135
+ singleLens: {
136
+ paddingHorizontal: 12,
137
+ },
138
+ pill: {
139
+ paddingHorizontal: 12,
140
+ paddingVertical: 6,
141
+ borderRadius: 14,
142
+ minWidth: 44,
143
+ alignItems: 'center',
144
+ },
145
+ pillActive: {
146
+ backgroundColor: '#ffd34d',
147
+ },
148
+ label: {
149
+ color: '#ffffff',
150
+ fontSize: 13,
151
+ fontWeight: '600',
152
+ },
153
+ labelActive: {
154
+ color: '#1a1a1a',
155
+ },
156
+ });
157
+ function ARToggle({ arEnabled, onToggle }) {
158
+ return (react_1.default.createElement(react_native_1.Pressable, { onPress: onToggle, accessibilityRole: "switch", accessibilityLabel: `AR mode ${arEnabled ? 'on' : 'off'}`, accessibilityState: { checked: arEnabled }, style: [arToggleStyles.container, arEnabled && arToggleStyles.containerOn] },
159
+ react_1.default.createElement(react_native_1.Text, { style: [
160
+ arToggleStyles.label,
161
+ arEnabled && arToggleStyles.labelOn,
162
+ ] }, "AR")));
163
+ }
164
+ const arToggleStyles = react_native_1.StyleSheet.create({
165
+ container: {
166
+ paddingHorizontal: 14,
167
+ paddingVertical: 8,
168
+ borderRadius: 16,
169
+ backgroundColor: 'rgba(0,0,0,0.45)',
170
+ minWidth: 56,
171
+ alignItems: 'center',
172
+ },
173
+ containerOn: {
174
+ backgroundColor: '#ffd34d',
175
+ },
176
+ label: {
177
+ color: '#ffffff',
178
+ fontSize: 13,
179
+ fontWeight: '700',
180
+ letterSpacing: 1,
181
+ },
182
+ labelOn: {
183
+ color: '#1a1a1a',
184
+ },
185
+ });
186
+ function SettingsButton({ onPress, topInset }) {
187
+ return (react_1.default.createElement(react_native_1.Pressable, { onPress: onPress, accessibilityRole: "button", accessibilityLabel: "Open camera settings", style: [settingsButtonStyles.container, { top: topInset + 8 }] },
188
+ react_1.default.createElement(react_native_1.Text, { style: settingsButtonStyles.glyph }, "\u2699")));
189
+ }
190
+ const settingsButtonStyles = react_native_1.StyleSheet.create({
191
+ container: {
192
+ position: 'absolute',
193
+ right: 14,
194
+ width: 40,
195
+ height: 40,
196
+ borderRadius: 20,
197
+ backgroundColor: 'rgba(0,0,0,0.45)',
198
+ alignItems: 'center',
199
+ justifyContent: 'center',
200
+ },
201
+ glyph: {
202
+ color: '#ffffff',
203
+ fontSize: 22,
204
+ lineHeight: 24,
205
+ },
206
+ });
207
+ // ─── Main component ─────────────────────────────────────────────────
208
+ /**
209
+ * Effective capture source derived from arPreference + lens + the
210
+ * device's AR support. On a device without ARKit / ARCore, AR mode
211
+ * is unavailable regardless of the user's preference, and the AR
212
+ * toggle is hidden in the UI (see the bottom-bar JSX). Selecting
213
+ * the 0.5x lens also forces non-AR because ARKit / ARCore sessions
214
+ * don't expose the ultra-wide camera.
215
+ */
216
+ function deriveEffectiveCaptureSource(arPreference, lens, isARSupportedOnDevice) {
217
+ if (!isARSupportedOnDevice)
218
+ return 'non-ar';
219
+ if (lens === '0.5x')
220
+ return 'non-ar';
221
+ return arPreference ? 'ar' : 'non-ar';
222
+ }
223
+ /**
224
+ * Apply per-prop defaults to build the initial settings snapshot.
225
+ * The settings live in component state from there; the prop values
226
+ * never re-flow.
227
+ *
228
+ * Note: the `default*ResolMP` props don't have a home on PanoramaSettings
229
+ * yet — they're accepted on the prop interface for forward compatibility
230
+ * but ignored here. Wiring is a follow-up once PanoramaSettings is
231
+ * extended.
232
+ */
233
+ function buildInitialSettings(props) {
234
+ return {
235
+ ...PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS,
236
+ stitchMode: props.defaultStitchMode ?? PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.stitchMode,
237
+ blenderType: props.defaultBlender ?? PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.blenderType,
238
+ seamFinderType: props.defaultSeamFinder ?? PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.seamFinderType,
239
+ warperType: props.defaultWarper ?? PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.warperType,
240
+ flowNoveltyPercentile: props.defaultFlowNoveltyPercentile ??
241
+ PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.flowNoveltyPercentile,
242
+ flowEvalEveryNFrames: props.defaultFlowEvalEveryNFrames ??
243
+ PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.flowEvalEveryNFrames,
244
+ flowMaxTranslationCm: props.defaultFlowMaxTranslationCm ??
245
+ PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.flowMaxTranslationCm,
246
+ keyframeMaxCount: props.defaultKeyframeMaxCount ??
247
+ PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.keyframeMaxCount,
248
+ keyframeOverlapThreshold: props.defaultKeyframeOverlapThreshold ??
249
+ PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.keyframeOverlapThreshold,
250
+ };
251
+ }
252
+ /**
253
+ * Normalise a native-side file path into the `file://...` URI form
254
+ * that React Native's `<Image>` requires on Android. iOS is lenient,
255
+ * but Android rejects bare `/data/...` paths and renders a blank
256
+ * Image with no error in the JS layer.
257
+ *
258
+ * Native code in this lib emits paths in two flavours:
259
+ * - useCapture.compressedUri already includes `file://` (it's
260
+ * normalised in `makeCaptureResult`).
261
+ * - ARCameraView.takePhoto, IncrementalStitcher.finalize, and the
262
+ * `batchKeyframeThumbnailPath` from `IncrementalStateUpdate` all
263
+ * return bare paths. Those are the cases this helper handles.
264
+ *
265
+ * Already-prefixed inputs are passed through unchanged, so it's safe
266
+ * to call defensively at every public-API boundary.
267
+ */
268
+ function ensureFileUri(path) {
269
+ if (!path)
270
+ return '';
271
+ if (path.startsWith('file://') || path.startsWith('content://') || path.startsWith('http')) {
272
+ return path;
273
+ }
274
+ return `file://${path}`;
275
+ }
276
+ /**
277
+ * The public `<Camera>` component.
278
+ */
279
+ function Camera(props) {
280
+ const { defaultCaptureSource = 'ar', defaultLens = '1x', enablePhotoMode = true, enablePanoramaMode = true, showSettingsButton = false, style, onCapture, onCaptureSourceChange, onLensChange, onFramesDropped, onError, } = props;
281
+ const insets = (0, react_native_safe_area_context_1.useSafeAreaInsets)();
282
+ // ── State ───────────────────────────────────────────────────────
283
+ const [arPreference, setArPreference] = (0, react_1.useState)(defaultCaptureSource === 'ar');
284
+ const [lens, setLens] = (0, react_1.useState)(defaultLens);
285
+ const [settings, setSettings] = (0, react_1.useState)(() => buildInitialSettings(props));
286
+ const [settingsModalVisible, setSettingsModalVisible] = (0, react_1.useState)(false);
287
+ const [statusPhase, setStatusPhase] = (0, react_1.useState)('idle');
288
+ const [recordingStartedAt, setRecordingStartedAt] = (0, react_1.useState)(null);
289
+ const [incrementalState, setIncrementalState] = (0, react_1.useState)(null);
290
+ const [batchKeyframeThumbnails, setBatchKeyframeThumbnails] = (0, react_1.useState)([]);
291
+ const [cameraTransitioning, setCameraTransitioning] = (0, react_1.useState)(false);
292
+ // ARKit / ARCore device-support probe. `isAvailable` is `false`
293
+ // initially and becomes `true` after the native isSupported() check
294
+ // resolves (~50-200 ms after mount). Devices without ARKit / ARCore
295
+ // (older iPhones, ARCore-less Androids, simulators) stay `false`
296
+ // forever, which forces non-AR capture everywhere and hides the
297
+ // AR toggle in the bottom bar (see JSX below).
298
+ const { isAvailable: isARSupportedOnDevice } = (0, useARSession_1.useARSession)();
299
+ const effectiveCaptureSource = deriveEffectiveCaptureSource(arPreference, lens, isARSupportedOnDevice);
300
+ const isAR = effectiveCaptureSource === 'ar';
301
+ const isNonAR = !isAR;
302
+ const deviceOrientation = (0, useDeviceOrientation_1.useDeviceOrientation)();
303
+ // ── Camera handoff gate ─────────────────────────────────────────
304
+ //
305
+ // The placeholder rendered while the underlying camera identity
306
+ // changes (AR toggle, lens swap). Without this gap, Android
307
+ // vision-camera v4 races the new session's open against the old
308
+ // session's teardown → "Session has been closed"
309
+ // IllegalStateException OR "Maximum cameras in use"
310
+ // CameraAccessException.
311
+ //
312
+ // CRITICAL: A naive useState + useEffect approach DOESN'T WORK.
313
+ // useEffect runs AFTER the commit phase — so on the render where
314
+ // isAR/lens flips, the effect hasn't yet set the gate flag, the
315
+ // render branch already evaluated `flag ? placeholder : camera`
316
+ // against the STALE flag=false → the new camera mounts in that
317
+ // commit → race → crash.
318
+ //
319
+ // Fix (mirrors AuditCaptureScreen.tsx ~L695-766): track the
320
+ // "last fully settled" identity in refs and compare them
321
+ // SYNCHRONOUSLY during render. The gate closes on the FIRST
322
+ // render where isAR/lens differs from the settled refs. The
323
+ // useEffect below does the async work (explicit AR session stop +
324
+ // 250 ms grace) and then updates the refs + clears the flag
325
+ // together to drop the gate.
326
+ const settledIsARRef = (0, react_1.useRef)(isAR);
327
+ const settledLensRef = (0, react_1.useRef)(lens);
328
+ const inFlightTransition = settledIsARRef.current !== isAR
329
+ || settledLensRef.current !== lens
330
+ || cameraTransitioning;
331
+ // ── Notify parent of capture-source changes ─────────────────────
332
+ const lastEmittedSourceRef = (0, react_1.useRef)(null);
333
+ (0, react_1.useEffect)(() => {
334
+ if (lastEmittedSourceRef.current !== effectiveCaptureSource) {
335
+ lastEmittedSourceRef.current = effectiveCaptureSource;
336
+ onCaptureSourceChange?.(effectiveCaptureSource);
337
+ }
338
+ }, [effectiveCaptureSource, onCaptureSourceChange]);
339
+ // ── Lens chip availability ──────────────────────────────────────
340
+ // TODO follow-up: probe the device's available physical lenses via
341
+ // vision-camera's `useCameraDevices` and surface in
342
+ // `useCapture().availablePhysicalDevices`. For now we assume the
343
+ // 0.5x ultra-wide exists on modern devices. When it doesn't, the
344
+ // lens chip degenerates to a static 1× indicator (see LensChip).
345
+ const has0_5x = true;
346
+ // ── Capture hooks ───────────────────────────────────────────────
347
+ const capture = (0, useCapture_1.useCapture)({
348
+ cameraPosition: 'back',
349
+ enableQualityChecks: false,
350
+ preferredPhysicalDevice: lens === '0.5x' ? 'ultra-wide-angle-camera' : 'wide-angle-camera',
351
+ });
352
+ const incremental = (0, useIncrementalStitcher_1.useIncrementalStitcher)();
353
+ const visionCameraRef = (0, react_1.useRef)(null);
354
+ const arViewRef = (0, react_1.useRef)(null);
355
+ // Effect that does the async transition work whenever the settled
356
+ // refs disagree with the current isAR/lens. Order matters:
357
+ // 1. Set the cameraTransitioning state so the gate stays closed
358
+ // after the synchronous compare flips back to "settled" once
359
+ // we update the refs.
360
+ // 2. Explicitly stop the AR session if we were in AR mode — this
361
+ // releases ARCore's grip on Camera2 BEFORE vision-camera tries
362
+ // to open it. Without this on Android the next openCamera()
363
+ // call hits "Maximum cameras in use". The promise is ignored
364
+ // if RNSARSession.stop fails or isn't available.
365
+ // 3. Wait 250 ms (Camera2's HAL onClosed is async; this gives it
366
+ // time to fully release the handle).
367
+ // 4. Update settled refs + clear cameraTransitioning together so
368
+ // the gate opens on the same commit.
369
+ (0, react_1.useEffect)(() => {
370
+ if (settledIsARRef.current === isAR && settledLensRef.current === lens) {
371
+ return undefined;
372
+ }
373
+ setCameraTransitioning(true);
374
+ let cancelled = false;
375
+ const finishTransition = () => {
376
+ if (cancelled)
377
+ return;
378
+ settledIsARRef.current = isAR;
379
+ settledLensRef.current = lens;
380
+ setCameraTransitioning(false);
381
+ };
382
+ const wasAR = settledIsARRef.current;
383
+ const arModule = react_native_1.NativeModules.RNSARSession;
384
+ const stopPromise = wasAR && arModule?.stop ? arModule.stop() : Promise.resolve();
385
+ stopPromise
386
+ .catch(() => undefined)
387
+ .then(() => {
388
+ setTimeout(finishTransition, 250);
389
+ });
390
+ return () => { cancelled = true; };
391
+ }, [isAR, lens]);
392
+ // IMU translation gate — only in non-AR mode.
393
+ const imuGate = (0, useIMUTranslationGate_1.useIMUTranslationGate)({
394
+ enabled: isNonAR
395
+ && statusPhase === 'recording'
396
+ && settings.flowMaxTranslationCm > 0,
397
+ budgetMeters: Math.max(0.001, settings.flowMaxTranslationCm / 100.0),
398
+ onBudgetExceeded: () => {
399
+ const mod = (0, incremental_1.getIncrementalNativeModule)();
400
+ mod?.markNextFrameAsLastKeyframe?.().catch(() => undefined);
401
+ },
402
+ });
403
+ // JS-driver for non-AR captures (iOS + Android). In AR mode the
404
+ // engine consumes frames from the ARSession stream natively, so this
405
+ // hook stays idle.
406
+ //
407
+ // IMPORTANT: start()/stop() are called imperatively from the hold
408
+ // handlers below — NOT from a useEffect driven by statusPhase. The
409
+ // hook returns a fresh object identity on every render, and during
410
+ // a recording the engine emits IncrementalStateUpdate events that
411
+ // cause re-renders multiple times per second. An effect with
412
+ // `jsDriver` in its deps would teardown + restart the driver on
413
+ // every event, resetting the gyro accumulator (yaw/pitch) to zero
414
+ // each cycle and nulling the cameraRef during the brief gap. The
415
+ // user-visible symptom was "only the first keyframe is accepted,
416
+ // every subsequent snapshot sees pose=(0,0) and is rejected as a
417
+ // duplicate of the first". Matching AuditCaptureScreen's proven
418
+ // imperative pattern (start on hold-start, stop on hold-end) avoids
419
+ // the re-render churn entirely.
420
+ const jsDriver = (0, useIncrementalJSDriver_1.useIncrementalJSDriver)();
421
+ // Safety: ensure the driver is stopped if the component unmounts
422
+ // mid-recording. Empty deps so this only fires on unmount.
423
+ // eslint-disable-next-line react-hooks/exhaustive-deps
424
+ (0, react_1.useEffect)(() => () => { jsDriver.stop(); }, []);
425
+ // ── Subscribe to engine state for live keyframe thumbs ──────────
426
+ (0, react_1.useEffect)(() => {
427
+ const sub = (0, incremental_1.subscribeIncrementalState)((state) => {
428
+ setIncrementalState(state);
429
+ if (state?.batchKeyframeThumbnailPath) {
430
+ setBatchKeyframeThumbnails((prev) => {
431
+ // De-dupe — same path may emit on subsequent ticks.
432
+ // Normalise to `file://...` so Android <Image> in the band
433
+ // overlay can actually render the thumbnail.
434
+ const path = ensureFileUri(state.batchKeyframeThumbnailPath);
435
+ if (prev.includes(path))
436
+ return prev;
437
+ return [...prev, path];
438
+ });
439
+ }
440
+ });
441
+ return () => { sub?.remove?.(); };
442
+ }, []);
443
+ (0, react_1.useEffect)(() => {
444
+ if (statusPhase === 'recording') {
445
+ setBatchKeyframeThumbnails([]);
446
+ setIncrementalState(null);
447
+ }
448
+ }, [statusPhase]);
449
+ // ── Shutter handlers ────────────────────────────────────────────
450
+ const handleTap = (0, react_1.useCallback)(async () => {
451
+ if (!enablePhotoMode)
452
+ return;
453
+ try {
454
+ let uri;
455
+ let width;
456
+ let height;
457
+ if (isAR && arViewRef.current) {
458
+ const photo = await arViewRef.current.takePhoto({ quality: 90 });
459
+ // Native side returns a bare `/data/.../foo.jpg` path. Android
460
+ // <Image> needs the `file://` scheme to render it; iOS is OK
461
+ // either way.
462
+ uri = ensureFileUri(photo.path);
463
+ width = photo.width;
464
+ height = photo.height;
465
+ }
466
+ else {
467
+ if (!visionCameraRef.current) {
468
+ throw new CameraError('CAMERA_DEVICE_UNAVAILABLE', 'vision-camera ref is not attached');
469
+ }
470
+ // useCapture.takePhoto wraps the cameraRef internally;
471
+ // attach via assignment so the hook's ref points at our
472
+ // local ref. This works because RefObject is just { current }.
473
+ // Effect: capture.takePhoto() resolves with the SDK's
474
+ // CaptureResult (with compressedUri / width / height).
475
+ // We adapt to the public CameraCaptureResult shape.
476
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
477
+ capture.cameraRef.current = visionCameraRef.current;
478
+ const result = await capture.takePhoto();
479
+ uri = result.compressedUri;
480
+ width = result.width;
481
+ height = result.height;
482
+ }
483
+ onCapture?.({ type: 'photo', uri, width, height });
484
+ }
485
+ catch (err) {
486
+ const e = err instanceof CameraError
487
+ ? err
488
+ : new CameraError('PHOTO_CAPTURE_FAILED', err instanceof Error ? err.message : String(err), err);
489
+ onError?.(e);
490
+ }
491
+ }, [enablePhotoMode, isAR, capture, onCapture, onError]);
492
+ const handleHoldStart = (0, react_1.useCallback)(async () => {
493
+ if (!enablePanoramaMode)
494
+ return;
495
+ if (!(0, incremental_1.incrementalStitcherIsAvailable)()) {
496
+ onError?.(new CameraError('PANORAMA_START_FAILED', 'Native incremental stitcher module not available'));
497
+ return;
498
+ }
499
+ try {
500
+ setStatusPhase('recording');
501
+ setRecordingStartedAt(Date.now());
502
+ const orientationRotation = deviceOrientation === 'portrait' ? 90
503
+ : deviceOrientation === 'portrait-upside-down' ? 270
504
+ : 0;
505
+ await incremental.start({
506
+ snapshotJpegQuality: 75,
507
+ snapshotEveryNAccepts: 1,
508
+ frameRotationDegrees: orientationRotation,
509
+ captureOrientation: deviceOrientation,
510
+ frameSourceMode: isNonAR ? 'jsDriver' : 'arSession',
511
+ composeWidth: 1920,
512
+ composeHeight: 1080,
513
+ canvasWidth: 5000,
514
+ canvasHeight: 5000,
515
+ engine: 'batch-keyframe',
516
+ config: {
517
+ stitchMode: settings.stitchMode,
518
+ warperType: settings.warperType,
519
+ blenderType: settings.blenderType,
520
+ seamFinderType: settings.seamFinderType,
521
+ flowNoveltyPercentile: settings.flowNoveltyPercentile,
522
+ flowEvalEveryNFrames: settings.flowEvalEveryNFrames,
523
+ flowMaxTranslationCm: settings.flowMaxTranslationCm,
524
+ keyframeMaxCount: settings.keyframeMaxCount,
525
+ keyframeOverlapThreshold: settings.keyframeOverlapThreshold,
526
+ frameSelectionMode: 'flow-based',
527
+ },
528
+ });
529
+ imuGate.resetAnchor();
530
+ // Start pumping vision-camera snapshots into the engine for
531
+ // non-AR captures. AR mode feeds frames natively from the
532
+ // ARSession, so the JS driver stays idle in that path. This
533
+ // mirrors AuditCaptureScreen.handleHoldStart's `androidDriver.start`
534
+ // imperative call — see the comment near `useIncrementalJSDriver`
535
+ // for why this is NOT done via useEffect.
536
+ if (isNonAR) {
537
+ jsDriver.start(visionCameraRef);
538
+ }
539
+ }
540
+ catch (err) {
541
+ setStatusPhase('idle');
542
+ onError?.(new CameraError('PANORAMA_START_FAILED', err instanceof Error ? err.message : String(err), err));
543
+ }
544
+ }, [
545
+ enablePanoramaMode,
546
+ incremental,
547
+ isNonAR,
548
+ deviceOrientation,
549
+ settings,
550
+ imuGate,
551
+ jsDriver,
552
+ onError,
553
+ ]);
554
+ const handleHoldEnd = (0, react_1.useCallback)(async () => {
555
+ if (statusPhase !== 'recording')
556
+ return;
557
+ setStatusPhase('stitching');
558
+ // Stop pumping new snapshots before finalizing so the engine isn't
559
+ // racing the final cv::Stitcher pass against late-arriving keyframes.
560
+ // No-op in AR mode where jsDriver was never started.
561
+ jsDriver.stop();
562
+ try {
563
+ const result = await incremental.finalize(undefined, 90, deviceOrientation);
564
+ if (typeof result.framesRequested === 'number'
565
+ && typeof result.framesIncluded === 'number'
566
+ && result.framesIncluded < result.framesRequested) {
567
+ onFramesDropped?.({
568
+ requested: result.framesRequested,
569
+ included: result.framesIncluded,
570
+ });
571
+ }
572
+ onCapture?.({
573
+ type: 'panorama',
574
+ // Native finalize() returns a bare `/data/.../foo.jpg` path;
575
+ // normalise to `file://` for Android <Image>.
576
+ uri: ensureFileUri(result.panoramaPath),
577
+ width: result.width,
578
+ height: result.height,
579
+ framesRequested: result.framesRequested ?? -1,
580
+ framesIncluded: result.framesIncluded ?? -1,
581
+ framesDropped: (result.framesRequested ?? 0) - (result.framesIncluded ?? 0),
582
+ finalConfidenceThresh: result.finalConfidenceThresh ?? -1,
583
+ durationMs: Date.now() - (recordingStartedAt ?? Date.now()),
584
+ });
585
+ }
586
+ catch (err) {
587
+ const message = err instanceof Error ? err.message : String(err);
588
+ const code = /need more images/i.test(message) ? 'STITCH_NEED_MORE_IMGS'
589
+ : /homography/i.test(message) ? 'STITCH_HOMOGRAPHY_FAIL'
590
+ : /camera params/i.test(message) ? 'STITCH_CAMERA_PARAMS_FAIL'
591
+ : /out of memory|oom/i.test(message) ? 'STITCH_OOM'
592
+ : 'PANORAMA_FINALIZE_FAILED';
593
+ onError?.(new CameraError(code, message, err));
594
+ }
595
+ finally {
596
+ setStatusPhase('idle');
597
+ setRecordingStartedAt(null);
598
+ }
599
+ }, [
600
+ statusPhase,
601
+ incremental,
602
+ deviceOrientation,
603
+ onCapture,
604
+ onFramesDropped,
605
+ onError,
606
+ recordingStartedAt,
607
+ jsDriver,
608
+ ]);
609
+ // ── Lens / AR-toggle handlers ───────────────────────────────────
610
+ const handleLensChange = (0, react_1.useCallback)((next) => {
611
+ setLens(next);
612
+ onLensChange?.(next);
613
+ }, [onLensChange]);
614
+ const handleARToggle = (0, react_1.useCallback)(() => {
615
+ setArPreference((prev) => !prev);
616
+ }, []);
617
+ // ── JSX ─────────────────────────────────────────────────────────
618
+ return (react_1.default.createElement(react_native_1.View, { style: [styles.container, style] },
619
+ inFlightTransition ? (react_1.default.createElement(react_native_1.View, { style: [react_native_1.StyleSheet.absoluteFill, styles.transitionPlaceholder] },
620
+ react_1.default.createElement(react_native_1.Text, { style: styles.transitionLabel }, "Switching camera\u2026"))) : isAR ? (react_1.default.createElement(ARCameraView_1.ARCameraView, { ref: arViewRef, style: react_native_1.StyleSheet.absoluteFill })) : (react_1.default.createElement(CameraView_1.CameraView, { ref: visionCameraRef, device: capture.device, isActive: true,
621
+ // `video={true}` is REQUIRED for takeSnapshot to work on iOS.
622
+ // vision-camera v4's iOS implementation of takeSnapshot waits
623
+ // for a frame on the video pipeline; with video disabled, the
624
+ // promise never resolves and the JS frame-driver stalls after
625
+ // the very first buffered preview frame. Android takeSnapshot
626
+ // works either way. Pattern matches AuditCaptureScreen.tsx
627
+ // which has run on `video` (true) for months without issue.
628
+ video: true, flash: "off", style: react_native_1.StyleSheet.absoluteFill })),
629
+ react_1.default.createElement(CaptureStatusOverlay_1.CaptureStatusOverlay, { phase: statusPhase, topInset: insets.top, recordingStartedAt: recordingStartedAt ?? undefined }),
630
+ showSettingsButton && (react_1.default.createElement(SettingsButton, { topInset: insets.top, onPress: () => setSettingsModalVisible(true) })),
631
+ react_1.default.createElement(react_native_1.View, { pointerEvents: "box-none", style: [styles.bottomArea, { paddingBottom: insets.bottom + 12 }] },
632
+ statusPhase === 'recording' && (react_1.default.createElement(PanoramaBandOverlay_1.PanoramaBandOverlay, { state: incrementalState, frameUris: batchKeyframeThumbnails, captureOrientation: deviceOrientation })),
633
+ react_1.default.createElement(react_native_1.View, { style: styles.bottomBar },
634
+ react_1.default.createElement(react_native_1.View, { style: styles.bottomBarLeft }),
635
+ react_1.default.createElement(react_native_1.View, { style: styles.bottomBarCenter },
636
+ react_1.default.createElement(LensChip, { lens: lens, onChange: handleLensChange, has0_5x: has0_5x }),
637
+ react_1.default.createElement(react_native_1.View, { style: styles.shutterWrap },
638
+ react_1.default.createElement(CameraShutter_1.CameraShutter, { onTap: handleTap, onHoldStart: enablePanoramaMode ? handleHoldStart : noop, onHoldComplete: enablePanoramaMode ? handleHoldEnd : noop, isProcessing: statusPhase === 'stitching', disabled: statusPhase === 'stitching' }))),
639
+ react_1.default.createElement(react_native_1.View, { style: styles.bottomBarRight }, lens === '1x' && isARSupportedOnDevice && (react_1.default.createElement(ARToggle, { arEnabled: arPreference, onToggle: handleARToggle }))))),
640
+ react_1.default.createElement(PanoramaSettingsModal_1.PanoramaSettingsModal, { visible: settingsModalVisible, settings: settings, onChange: setSettings, onClose: () => setSettingsModalVisible(false) })));
641
+ }
642
+ function noop() {
643
+ /* no-op handler used when panorama mode is disabled */
644
+ }
645
+ const styles = react_native_1.StyleSheet.create({
646
+ container: {
647
+ flex: 1,
648
+ backgroundColor: '#000',
649
+ },
650
+ transitionPlaceholder: {
651
+ backgroundColor: '#000',
652
+ alignItems: 'center',
653
+ justifyContent: 'center',
654
+ },
655
+ transitionLabel: {
656
+ color: 'rgba(255,255,255,0.6)',
657
+ fontSize: 13,
658
+ },
659
+ bottomArea: {
660
+ position: 'absolute',
661
+ left: 0,
662
+ right: 0,
663
+ bottom: 0,
664
+ flexDirection: 'column',
665
+ alignItems: 'stretch',
666
+ },
667
+ bottomBar: {
668
+ flexDirection: 'row',
669
+ paddingHorizontal: 18,
670
+ alignItems: 'flex-end',
671
+ },
672
+ bottomBarLeft: {
673
+ flex: 1,
674
+ },
675
+ bottomBarCenter: {
676
+ flex: 1,
677
+ alignItems: 'center',
678
+ },
679
+ bottomBarRight: {
680
+ flex: 1,
681
+ alignItems: 'flex-end',
682
+ justifyContent: 'flex-end',
683
+ },
684
+ shutterWrap: {
685
+ marginTop: 12,
686
+ },
687
+ });
688
+ //# sourceMappingURL=Camera.js.map