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,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
|