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,326 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* CaptureStatusOverlay — screen-level visual feedback for the
|
|
5
|
+
* panorama capture lifecycle.
|
|
6
|
+
*
|
|
7
|
+
* Lives in the SDK because the existing shutter-button colour change
|
|
8
|
+
* is hidden by the user's finger during a hold. An overlay above
|
|
9
|
+
* the preview is the only reliable channel for "you ARE recording
|
|
10
|
+
* right now" feedback.
|
|
11
|
+
*
|
|
12
|
+
* ┌──────────────────────────────────────────────────┐
|
|
13
|
+
* │ ● REC Hold steady, pan slowly… │ ← banner
|
|
14
|
+
* ├──────────────────────────────────────────────────┤
|
|
15
|
+
* │ ┌──────────────────────────────────────────────┐ │
|
|
16
|
+
* │ │ │ │
|
|
17
|
+
* │ │ ⬛ red glow border │ │
|
|
18
|
+
* │ │ around the preview │ │
|
|
19
|
+
* │ │ │ │
|
|
20
|
+
* │ └──────────────────────────────────────────────┘ │
|
|
21
|
+
* └──────────────────────────────────────────────────┘
|
|
22
|
+
*
|
|
23
|
+
* The component is intentionally pure-presentational: it takes a
|
|
24
|
+
* `phase` prop and renders the matching UI. Recording vs stitching
|
|
25
|
+
* vs idle is the host's source of truth — typically derived from
|
|
26
|
+
* `useVideoCapture().state` and a local "isStitching" boolean.
|
|
27
|
+
*
|
|
28
|
+
* The overlay renders nothing in `idle` so the host can render it
|
|
29
|
+
* unconditionally without conditional layout shifts.
|
|
30
|
+
*/
|
|
31
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
32
|
+
if (k2 === undefined) k2 = k;
|
|
33
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
34
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
35
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
36
|
+
}
|
|
37
|
+
Object.defineProperty(o, k2, desc);
|
|
38
|
+
}) : (function(o, m, k, k2) {
|
|
39
|
+
if (k2 === undefined) k2 = k;
|
|
40
|
+
o[k2] = m[k];
|
|
41
|
+
}));
|
|
42
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
43
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
44
|
+
}) : function(o, v) {
|
|
45
|
+
o["default"] = v;
|
|
46
|
+
});
|
|
47
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
48
|
+
var ownKeys = function(o) {
|
|
49
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
50
|
+
var ar = [];
|
|
51
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
52
|
+
return ar;
|
|
53
|
+
};
|
|
54
|
+
return ownKeys(o);
|
|
55
|
+
};
|
|
56
|
+
return function (mod) {
|
|
57
|
+
if (mod && mod.__esModule) return mod;
|
|
58
|
+
var result = {};
|
|
59
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
60
|
+
__setModuleDefault(result, mod);
|
|
61
|
+
return result;
|
|
62
|
+
};
|
|
63
|
+
})();
|
|
64
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
65
|
+
exports.CaptureStatusOverlay = CaptureStatusOverlay;
|
|
66
|
+
const react_1 = __importStar(require("react"));
|
|
67
|
+
const react_native_1 = require("react-native");
|
|
68
|
+
const useDeviceOrientation_1 = require("./useDeviceOrientation");
|
|
69
|
+
function CaptureStatusOverlay({ phase, recordingMessage = 'Hold steady — pan slowly', stitchingMessage = 'Stitching panorama…', countdownMs, recordingStartedAt, topInset = 0, style, }) {
|
|
70
|
+
// Countdown ticker — re-renders every 250 ms while recording so
|
|
71
|
+
// the "REC 4s left" text stays current without flooding render
|
|
72
|
+
// calls. Disabled (no interval) when not in recording phase or
|
|
73
|
+
// when countdown isn't configured.
|
|
74
|
+
const [, setTick] = react_1.default.useState(0);
|
|
75
|
+
react_1.default.useEffect(() => {
|
|
76
|
+
if (phase !== 'recording' || !countdownMs || !recordingStartedAt)
|
|
77
|
+
return;
|
|
78
|
+
const id = setInterval(() => setTick((t) => t + 1), 250);
|
|
79
|
+
return () => clearInterval(id);
|
|
80
|
+
}, [phase, countdownMs, recordingStartedAt]);
|
|
81
|
+
// Pulse animation for the REC dot. Driven by a single Animated
|
|
82
|
+
// value that loops 0→1→0. Cheap (no listeners, runs on the
|
|
83
|
+
// native driver) and only spins up while recording.
|
|
84
|
+
const pulse = (0, react_1.useRef)(new react_native_1.Animated.Value(0)).current;
|
|
85
|
+
(0, react_1.useEffect)(() => {
|
|
86
|
+
if (phase !== 'recording') {
|
|
87
|
+
pulse.setValue(0);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const loop = react_native_1.Animated.loop(react_native_1.Animated.sequence([
|
|
91
|
+
react_native_1.Animated.timing(pulse, {
|
|
92
|
+
toValue: 1,
|
|
93
|
+
duration: 600,
|
|
94
|
+
easing: react_native_1.Easing.inOut(react_native_1.Easing.ease),
|
|
95
|
+
useNativeDriver: true,
|
|
96
|
+
}),
|
|
97
|
+
react_native_1.Animated.timing(pulse, {
|
|
98
|
+
toValue: 0,
|
|
99
|
+
duration: 600,
|
|
100
|
+
easing: react_native_1.Easing.inOut(react_native_1.Easing.ease),
|
|
101
|
+
useNativeDriver: true,
|
|
102
|
+
}),
|
|
103
|
+
]));
|
|
104
|
+
loop.start();
|
|
105
|
+
return () => loop.stop();
|
|
106
|
+
}, [phase, pulse]);
|
|
107
|
+
// Always call the hook — even when phase is 'idle' — so React's
|
|
108
|
+
// hook-order rule isn't violated. The accelerometer subscription
|
|
109
|
+
// is cheap and stays alive for the screen's lifetime.
|
|
110
|
+
const deviceOrientation = (0, useDeviceOrientation_1.useDeviceOrientation)();
|
|
111
|
+
if (phase === 'idle')
|
|
112
|
+
return null;
|
|
113
|
+
// Interpolate pulse → opacity & scale so the dot breathes 0.6→1.0
|
|
114
|
+
// opacity and 1.0→1.3 scale. Subtle; not distracting.
|
|
115
|
+
const dotOpacity = pulse.interpolate({
|
|
116
|
+
inputRange: [0, 1],
|
|
117
|
+
outputRange: [0.5, 1],
|
|
118
|
+
});
|
|
119
|
+
const dotScale = pulse.interpolate({
|
|
120
|
+
inputRange: [0, 1],
|
|
121
|
+
outputRange: [1, 1.3],
|
|
122
|
+
});
|
|
123
|
+
// The red border appears only during recording — once the user
|
|
124
|
+
// releases and we move to stitching the recording is over and a
|
|
125
|
+
// bright red border would be misleading.
|
|
126
|
+
const showBorder = phase === 'recording';
|
|
127
|
+
// Compute remaining seconds for the countdown. Re-rendered
|
|
128
|
+
// every 250 ms by the tick interval above. If countdownMs or
|
|
129
|
+
// recordingStartedAt are missing we just render the base
|
|
130
|
+
// message without a "Xs left" suffix.
|
|
131
|
+
let baseMessage = phase === 'recording' ? recordingMessage : stitchingMessage;
|
|
132
|
+
if (phase === 'recording'
|
|
133
|
+
&& countdownMs
|
|
134
|
+
&& recordingStartedAt) {
|
|
135
|
+
const elapsedMs = Date.now() - recordingStartedAt;
|
|
136
|
+
const remainingMs = Math.max(0, countdownMs - elapsedMs);
|
|
137
|
+
const remainingSec = Math.ceil(remainingMs / 1000);
|
|
138
|
+
baseMessage = `${recordingMessage} · ${remainingSec}s left`;
|
|
139
|
+
}
|
|
140
|
+
const message = baseMessage;
|
|
141
|
+
// Orientation-aware banner placement via DIRECT absolute positioning
|
|
142
|
+
// + percentage transforms.
|
|
143
|
+
//
|
|
144
|
+
// Why this instead of a rotated-wrapper approach: the previous
|
|
145
|
+
// wrapper approach (sized to user-view dims, positioned to align
|
|
146
|
+
// center, rotated) is geometrically correct on paper but rendered
|
|
147
|
+
// off-center on device (probably a RN flex+rotation interaction).
|
|
148
|
+
// Direct absolute positioning of the banner with translateX/Y('-50%')
|
|
149
|
+
// for self-centering is simpler, doesn't depend on useWindowDimensions,
|
|
150
|
+
// and uses only well-trodden RN style features.
|
|
151
|
+
//
|
|
152
|
+
// Border is rendered separately because it hugs the physical camera
|
|
153
|
+
// preview (in layout coords) — it must not rotate with the banner.
|
|
154
|
+
const bannerOrientationStyle = bannerStyleForOrientation(deviceOrientation, topInset);
|
|
155
|
+
return (react_1.default.createElement(react_native_1.View
|
|
156
|
+
// pointerEvents=box-none so the overlay never steals taps from
|
|
157
|
+
// the underlying camera / shutter / preview. The banner and
|
|
158
|
+
// border are read-only.
|
|
159
|
+
, {
|
|
160
|
+
// pointerEvents=box-none so the overlay never steals taps from
|
|
161
|
+
// the underlying camera / shutter / preview. The banner and
|
|
162
|
+
// border are read-only.
|
|
163
|
+
pointerEvents: "box-none", style: [react_native_1.StyleSheet.absoluteFill, style], accessibilityLiveRegion: "polite" },
|
|
164
|
+
showBorder ? (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: styles.recordBorder })) : null,
|
|
165
|
+
react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: [
|
|
166
|
+
styles.banner,
|
|
167
|
+
phase === 'recording' ? styles.bannerRecording : styles.bannerStitching,
|
|
168
|
+
bannerOrientationStyle,
|
|
169
|
+
] },
|
|
170
|
+
phase === 'recording' ? (react_1.default.createElement(react_native_1.Animated.View, { style: [
|
|
171
|
+
styles.recDot,
|
|
172
|
+
{ opacity: dotOpacity, transform: [{ scale: dotScale }] },
|
|
173
|
+
] })) : (react_1.default.createElement(react_native_1.View, { style: styles.stitchSpinner })),
|
|
174
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.bannerText, numberOfLines: 1 },
|
|
175
|
+
phase === 'recording' ? 'REC' : '•••',
|
|
176
|
+
' ',
|
|
177
|
+
message))));
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Compute the style placing the banner at user-perceived top-center
|
|
181
|
+
* with text reading in the user's view direction.
|
|
182
|
+
*
|
|
183
|
+
* Approach: direct absolute positioning + percentage-translate self-
|
|
184
|
+
* centering (works on the banner's own measured dimensions, so the
|
|
185
|
+
* banner's text content can be any width).
|
|
186
|
+
*
|
|
187
|
+
* For each orientation, anchor the banner to the layout edge that
|
|
188
|
+
* corresponds to user-perceived top:
|
|
189
|
+
*
|
|
190
|
+
* portrait → layout-top + horizontally centered + 0°
|
|
191
|
+
* portrait-upside-down → layout-bottom + horizontally centered + 180°
|
|
192
|
+
* landscape-left → layout-right + vertically centered + 90°
|
|
193
|
+
* landscape-right → layout-left + vertically centered + -90°
|
|
194
|
+
*
|
|
195
|
+
* In landscape, the banner is rotated around its center so its text
|
|
196
|
+
* (originally horizontal in layout) reads horizontally in the user's
|
|
197
|
+
* view. The translateY('-50%') aligns the banner's center with the
|
|
198
|
+
* layout's vertical center, which maps to user-horizontal-center
|
|
199
|
+
* post-rotation.
|
|
200
|
+
*
|
|
201
|
+
* RN supports `'50%'` for absolute positions and percentage values in
|
|
202
|
+
* translateX/Y since 0.70 — the percentage in a translate is relative
|
|
203
|
+
* to the element's OWN dimensions, which is exactly what self-
|
|
204
|
+
* centering an unknown-width element needs.
|
|
205
|
+
*/
|
|
206
|
+
function bannerStyleForOrientation(orientation, topInset) {
|
|
207
|
+
switch (orientation) {
|
|
208
|
+
case 'landscape-left':
|
|
209
|
+
// 2026-05-18 round 2 — pre-rotation `right: 8` put the
|
|
210
|
+
// banner's right edge at layout right minus 8; after 90° CW
|
|
211
|
+
// rotation around banner CENTER, the banner's bbox center
|
|
212
|
+
// ended up at layout-x = W - 8 - banner_width/2. The
|
|
213
|
+
// banner's user-top edge (post-rotation max layout-x) then
|
|
214
|
+
// landed at user_y = (banner_width - banner_height) / 2 + 8
|
|
215
|
+
// ≈ 130 px from user-top — way too far down, hence the
|
|
216
|
+
// "appearing in center vertically" complaint.
|
|
217
|
+
//
|
|
218
|
+
// Fix: shift banner's center to layout-x = W - 34 (= 8 +
|
|
219
|
+
// estimated banner_height/2 ≈ 26). Achieved by anchoring
|
|
220
|
+
// at `right: 34` (banner right edge = W-34) and then
|
|
221
|
+
// `translateX('50%')` (shifts center right by banner_width/
|
|
222
|
+
// 2 = back to W-34). Post-rotation max layout-x = W - 34 +
|
|
223
|
+
// banner_height/2 = W - 8 → user_y = 8 px from user-top.
|
|
224
|
+
// Works regardless of banner_width because the translateX
|
|
225
|
+
// percentage cancels the W_b/2 offset.
|
|
226
|
+
//
|
|
227
|
+
// Landscape user-top has no notch (the Dynamic Island sits
|
|
228
|
+
// on user-LEFT in landscape-left, user-RIGHT in landscape-
|
|
229
|
+
// right), so we don't add topInset here — 8 px is the tight
|
|
230
|
+
// visual minimum the user asked for.
|
|
231
|
+
return {
|
|
232
|
+
position: 'absolute',
|
|
233
|
+
right: 34,
|
|
234
|
+
top: '50%',
|
|
235
|
+
transform: [
|
|
236
|
+
{ translateY: '-50%' },
|
|
237
|
+
{ translateX: '50%' },
|
|
238
|
+
{ rotate: '90deg' },
|
|
239
|
+
],
|
|
240
|
+
};
|
|
241
|
+
case 'landscape-right':
|
|
242
|
+
return {
|
|
243
|
+
position: 'absolute',
|
|
244
|
+
left: 34,
|
|
245
|
+
top: '50%',
|
|
246
|
+
transform: [
|
|
247
|
+
{ translateY: '-50%' },
|
|
248
|
+
{ translateX: '-50%' },
|
|
249
|
+
{ rotate: '-90deg' },
|
|
250
|
+
],
|
|
251
|
+
};
|
|
252
|
+
case 'portrait-upside-down':
|
|
253
|
+
return {
|
|
254
|
+
position: 'absolute',
|
|
255
|
+
bottom: topInset + 8,
|
|
256
|
+
left: '50%',
|
|
257
|
+
transform: [
|
|
258
|
+
{ translateX: '-50%' },
|
|
259
|
+
{ rotate: '180deg' },
|
|
260
|
+
],
|
|
261
|
+
};
|
|
262
|
+
case 'portrait':
|
|
263
|
+
default:
|
|
264
|
+
return {
|
|
265
|
+
position: 'absolute',
|
|
266
|
+
top: topInset + 8,
|
|
267
|
+
left: '50%',
|
|
268
|
+
transform: [
|
|
269
|
+
{ translateX: '-50%' },
|
|
270
|
+
],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const styles = react_native_1.StyleSheet.create({
|
|
275
|
+
banner: {
|
|
276
|
+
// position: 'absolute' is added back so the orientation-specific
|
|
277
|
+
// style (returned by bannerStyleForOrientation) can position the
|
|
278
|
+
// banner at the layout edge for that orientation using top/right/
|
|
279
|
+
// bottom/left. The transform array on the same style does the
|
|
280
|
+
// self-centering via translateX/Y('-50%') and applies rotation.
|
|
281
|
+
position: 'absolute',
|
|
282
|
+
flexDirection: 'row',
|
|
283
|
+
alignItems: 'center',
|
|
284
|
+
paddingHorizontal: 14,
|
|
285
|
+
paddingVertical: 8,
|
|
286
|
+
borderRadius: 22,
|
|
287
|
+
minHeight: 36,
|
|
288
|
+
},
|
|
289
|
+
bannerRecording: {
|
|
290
|
+
backgroundColor: 'rgba(255,59,48,0.92)',
|
|
291
|
+
},
|
|
292
|
+
bannerStitching: {
|
|
293
|
+
// Neutral grey while we wait for the stitcher; communicates
|
|
294
|
+
// "still working" without the alarming red of active recording.
|
|
295
|
+
backgroundColor: 'rgba(0,0,0,0.78)',
|
|
296
|
+
},
|
|
297
|
+
bannerText: {
|
|
298
|
+
color: '#ffffff',
|
|
299
|
+
fontSize: 13,
|
|
300
|
+
fontWeight: '600',
|
|
301
|
+
marginLeft: 8,
|
|
302
|
+
},
|
|
303
|
+
recDot: {
|
|
304
|
+
width: 10,
|
|
305
|
+
height: 10,
|
|
306
|
+
borderRadius: 5,
|
|
307
|
+
backgroundColor: '#ffffff',
|
|
308
|
+
},
|
|
309
|
+
stitchSpinner: {
|
|
310
|
+
// Static dot for now — RN's ActivityIndicator is fine here too,
|
|
311
|
+
// but a calm static dot keeps visual noise low when the user
|
|
312
|
+
// already gets the spinner via the shutter-button "processing"
|
|
313
|
+
// state below.
|
|
314
|
+
width: 8,
|
|
315
|
+
height: 8,
|
|
316
|
+
borderRadius: 4,
|
|
317
|
+
backgroundColor: '#ffffff',
|
|
318
|
+
opacity: 0.7,
|
|
319
|
+
},
|
|
320
|
+
recordBorder: {
|
|
321
|
+
...react_native_1.StyleSheet.absoluteFillObject,
|
|
322
|
+
borderWidth: 4,
|
|
323
|
+
borderColor: 'rgba(255,59,48,0.9)',
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
//# sourceMappingURL=CaptureStatusOverlay.js.map
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CaptureThumbnailStrip — horizontal thumbnail strip with built-in
|
|
3
|
+
* tap-to-preview modal, designed for the audit capture surface.
|
|
4
|
+
*
|
|
5
|
+
* ┌─────────────────────────────────────────────────────────┐
|
|
6
|
+
* │ [thumb 4:3] [thumb 9:16] [thumb pano] … │
|
|
7
|
+
* │ 3 / 5 min · 10 max │
|
|
8
|
+
* └─────────────────────────────────────────────────────────┘
|
|
9
|
+
*
|
|
10
|
+
* Two reasons this lives in the SDK rather than the host:
|
|
11
|
+
* 1. The thumbnail UX is camera-shaped — tightly coupled to the
|
|
12
|
+
* `CaptureResult` dimension fields the SDK introduced for
|
|
13
|
+
* aspect-ratio rendering. Any host that uses `useCapture`
|
|
14
|
+
* benefits from the same display logic, so the SDK is the
|
|
15
|
+
* right home.
|
|
16
|
+
* 2. The preview modal is a non-trivial chunk of UI (full-screen
|
|
17
|
+
* Image with close affordance + safe-area handling). Hosts
|
|
18
|
+
* were inevitably going to re-implement it with subtly
|
|
19
|
+
* different gesture handling; centralising avoids drift.
|
|
20
|
+
*
|
|
21
|
+
* The strip is intentionally headless about persistence: it knows
|
|
22
|
+
* nothing about WatermelonDB, the host's DB schema, or sync state.
|
|
23
|
+
* Callers pass an array of plain objects with `id`, `uri`, and
|
|
24
|
+
* optional `width`/`height` and the strip handles the rest.
|
|
25
|
+
*
|
|
26
|
+
* Aspect-ratio rendering:
|
|
27
|
+
* - Thumbnails are anchored at a fixed height (default 60 px)
|
|
28
|
+
* and width is computed from `width / height` * height.
|
|
29
|
+
* - Width is clamped to [40, 180] so a tall portrait doesn't
|
|
30
|
+
* squish to a sliver and a 5000 px panorama doesn't push
|
|
31
|
+
* siblings off-screen.
|
|
32
|
+
* - Items missing dimensions (legacy captures saved before the
|
|
33
|
+
* SDK exposed them) fall back to square — matches prior
|
|
34
|
+
* behaviour and avoids visual jumps when scrolling mixed
|
|
35
|
+
* histories.
|
|
36
|
+
*/
|
|
37
|
+
import React from 'react';
|
|
38
|
+
import { type StyleProp, type ViewStyle } from 'react-native';
|
|
39
|
+
export interface CaptureThumbnailItem {
|
|
40
|
+
/** Stable id for FlatList keying. */
|
|
41
|
+
id: string;
|
|
42
|
+
/** `file://` or remote URI of the captured image. */
|
|
43
|
+
uri: string;
|
|
44
|
+
/** Image width in pixels. Optional; falls back to square. */
|
|
45
|
+
width?: number;
|
|
46
|
+
/** Image height in pixels. Optional; falls back to square. */
|
|
47
|
+
height?: number;
|
|
48
|
+
}
|
|
49
|
+
export interface CaptureThumbnailStripProps {
|
|
50
|
+
/** Captures to render, in the order they should appear. */
|
|
51
|
+
items: CaptureThumbnailItem[];
|
|
52
|
+
/**
|
|
53
|
+
* Optional minimum-photos hint for the count line. When `count >=
|
|
54
|
+
* minPhotos` the count text uses the success colour, otherwise it
|
|
55
|
+
* uses the warning colour. Pass undefined to suppress the hint.
|
|
56
|
+
*/
|
|
57
|
+
minPhotos?: number;
|
|
58
|
+
/** Optional maximum-photos hint for the count line. */
|
|
59
|
+
maxPhotos?: number;
|
|
60
|
+
/** Strip background colour (defaults to translucent black). */
|
|
61
|
+
backgroundColor?: string;
|
|
62
|
+
/** Text colour applied to the count line and "No photos" placeholder. */
|
|
63
|
+
textColor?: string;
|
|
64
|
+
/** Colour used when count meets `minPhotos`. */
|
|
65
|
+
successColor?: string;
|
|
66
|
+
/** Colour used when count is below `minPhotos`. */
|
|
67
|
+
warningColor?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Disable tap-to-preview. When true, thumbnails are still rendered
|
|
70
|
+
* but tapping them is a no-op. Default false (preview enabled).
|
|
71
|
+
*/
|
|
72
|
+
disablePreview?: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Custom tap handler. When provided, tapping a thumbnail calls
|
|
75
|
+
* this instead of opening the strip's built-in preview modal.
|
|
76
|
+
* Use this when the host wants to show its own preview UI (e.g.
|
|
77
|
+
* with delete / recapture buttons gated on capture sync state).
|
|
78
|
+
*/
|
|
79
|
+
onItemPress?: (item: CaptureThumbnailItem) => void;
|
|
80
|
+
/**
|
|
81
|
+
* Optional outer style. Layout-related props (height, padding)
|
|
82
|
+
* stay under the strip's control to keep the count line consistent.
|
|
83
|
+
*/
|
|
84
|
+
style?: StyleProp<ViewStyle>;
|
|
85
|
+
}
|
|
86
|
+
export declare function CaptureThumbnailStrip({ items, minPhotos, maxPhotos, backgroundColor, textColor, successColor, warningColor, disablePreview, onItemPress, style, }: CaptureThumbnailStripProps): React.JSX.Element;
|
|
87
|
+
//# sourceMappingURL=CaptureThumbnailStrip.d.ts.map
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* CaptureThumbnailStrip — horizontal thumbnail strip with built-in
|
|
5
|
+
* tap-to-preview modal, designed for the audit capture surface.
|
|
6
|
+
*
|
|
7
|
+
* ┌─────────────────────────────────────────────────────────┐
|
|
8
|
+
* │ [thumb 4:3] [thumb 9:16] [thumb pano] … │
|
|
9
|
+
* │ 3 / 5 min · 10 max │
|
|
10
|
+
* └─────────────────────────────────────────────────────────┘
|
|
11
|
+
*
|
|
12
|
+
* Two reasons this lives in the SDK rather than the host:
|
|
13
|
+
* 1. The thumbnail UX is camera-shaped — tightly coupled to the
|
|
14
|
+
* `CaptureResult` dimension fields the SDK introduced for
|
|
15
|
+
* aspect-ratio rendering. Any host that uses `useCapture`
|
|
16
|
+
* benefits from the same display logic, so the SDK is the
|
|
17
|
+
* right home.
|
|
18
|
+
* 2. The preview modal is a non-trivial chunk of UI (full-screen
|
|
19
|
+
* Image with close affordance + safe-area handling). Hosts
|
|
20
|
+
* were inevitably going to re-implement it with subtly
|
|
21
|
+
* different gesture handling; centralising avoids drift.
|
|
22
|
+
*
|
|
23
|
+
* The strip is intentionally headless about persistence: it knows
|
|
24
|
+
* nothing about WatermelonDB, the host's DB schema, or sync state.
|
|
25
|
+
* Callers pass an array of plain objects with `id`, `uri`, and
|
|
26
|
+
* optional `width`/`height` and the strip handles the rest.
|
|
27
|
+
*
|
|
28
|
+
* Aspect-ratio rendering:
|
|
29
|
+
* - Thumbnails are anchored at a fixed height (default 60 px)
|
|
30
|
+
* and width is computed from `width / height` * height.
|
|
31
|
+
* - Width is clamped to [40, 180] so a tall portrait doesn't
|
|
32
|
+
* squish to a sliver and a 5000 px panorama doesn't push
|
|
33
|
+
* siblings off-screen.
|
|
34
|
+
* - Items missing dimensions (legacy captures saved before the
|
|
35
|
+
* SDK exposed them) fall back to square — matches prior
|
|
36
|
+
* behaviour and avoids visual jumps when scrolling mixed
|
|
37
|
+
* histories.
|
|
38
|
+
*/
|
|
39
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
40
|
+
if (k2 === undefined) k2 = k;
|
|
41
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
42
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
43
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
44
|
+
}
|
|
45
|
+
Object.defineProperty(o, k2, desc);
|
|
46
|
+
}) : (function(o, m, k, k2) {
|
|
47
|
+
if (k2 === undefined) k2 = k;
|
|
48
|
+
o[k2] = m[k];
|
|
49
|
+
}));
|
|
50
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
51
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
52
|
+
}) : function(o, v) {
|
|
53
|
+
o["default"] = v;
|
|
54
|
+
});
|
|
55
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
56
|
+
var ownKeys = function(o) {
|
|
57
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
58
|
+
var ar = [];
|
|
59
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
60
|
+
return ar;
|
|
61
|
+
};
|
|
62
|
+
return ownKeys(o);
|
|
63
|
+
};
|
|
64
|
+
return function (mod) {
|
|
65
|
+
if (mod && mod.__esModule) return mod;
|
|
66
|
+
var result = {};
|
|
67
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
68
|
+
__setModuleDefault(result, mod);
|
|
69
|
+
return result;
|
|
70
|
+
};
|
|
71
|
+
})();
|
|
72
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
73
|
+
exports.CaptureThumbnailStrip = CaptureThumbnailStrip;
|
|
74
|
+
const react_1 = __importStar(require("react"));
|
|
75
|
+
const react_native_1 = require("react-native");
|
|
76
|
+
const CapturePreview_1 = require("./CapturePreview");
|
|
77
|
+
/// Fixed thumbnail height — width varies with aspect ratio.
|
|
78
|
+
const THUMB_HEIGHT = 60;
|
|
79
|
+
/// Width clamps protect the strip from extreme aspect ratios (very
|
|
80
|
+
/// tall portraits squishing to a sliver, very wide panoramas pushing
|
|
81
|
+
/// siblings off-screen).
|
|
82
|
+
const THUMB_MIN_WIDTH = 40;
|
|
83
|
+
const THUMB_MAX_WIDTH = 180;
|
|
84
|
+
function thumbWidth(item) {
|
|
85
|
+
const { width, height } = item;
|
|
86
|
+
if (!width || !height || width <= 0 || height <= 0) {
|
|
87
|
+
// Legacy capture without dimensions — fall back to square.
|
|
88
|
+
return THUMB_HEIGHT;
|
|
89
|
+
}
|
|
90
|
+
const ratio = width / height;
|
|
91
|
+
const computed = Math.round(THUMB_HEIGHT * ratio);
|
|
92
|
+
return Math.max(THUMB_MIN_WIDTH, Math.min(THUMB_MAX_WIDTH, computed));
|
|
93
|
+
}
|
|
94
|
+
function CaptureThumbnailStrip({ items, minPhotos, maxPhotos, backgroundColor = 'rgba(0,0,0,0.85)', textColor = '#ffffff', successColor = '#34C759', warningColor = '#FF9F0A', disablePreview = false, onItemPress, style, }) {
|
|
95
|
+
// Built-in preview state — only used when the host hasn't
|
|
96
|
+
// provided its own onItemPress handler. Letting the host pass a
|
|
97
|
+
// handler is how the AuditCaptureScreen unifies thumbnail
|
|
98
|
+
// preview with post-stitch confirmation.
|
|
99
|
+
const [previewItem, setPreviewItem] = (0, react_1.useState)(null);
|
|
100
|
+
// Memoise so FlatList doesn't see a fresh callback identity every
|
|
101
|
+
// render and re-render every row.
|
|
102
|
+
const handleItemPress = (0, react_1.useCallback)((item) => {
|
|
103
|
+
if (disablePreview)
|
|
104
|
+
return;
|
|
105
|
+
if (onItemPress) {
|
|
106
|
+
onItemPress(item);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
setPreviewItem(item);
|
|
110
|
+
}, [disablePreview, onItemPress]);
|
|
111
|
+
const closePreview = (0, react_1.useCallback)(() => setPreviewItem(null), []);
|
|
112
|
+
const countLine = (0, react_1.useMemo)(() => {
|
|
113
|
+
if (minPhotos === undefined && maxPhotos === undefined)
|
|
114
|
+
return null;
|
|
115
|
+
const meetsMin = minPhotos === undefined ? true : items.length >= minPhotos;
|
|
116
|
+
const text = minPhotos !== undefined && maxPhotos !== undefined
|
|
117
|
+
? `${items.length} / ${minPhotos} min · ${maxPhotos} max`
|
|
118
|
+
: minPhotos !== undefined
|
|
119
|
+
? `${items.length} / ${minPhotos} min`
|
|
120
|
+
: `${items.length} / ${maxPhotos} max`;
|
|
121
|
+
return (react_1.default.createElement(react_native_1.Text, { style: [
|
|
122
|
+
styles.count,
|
|
123
|
+
{ color: meetsMin ? successColor : warningColor },
|
|
124
|
+
], accessibilityLabel: `Captured ${items.length} photos` }, text));
|
|
125
|
+
}, [items.length, minPhotos, maxPhotos, successColor, warningColor]);
|
|
126
|
+
return (react_1.default.createElement(react_native_1.View, { style: [styles.root, { backgroundColor }, style] },
|
|
127
|
+
react_1.default.createElement(react_native_1.FlatList, { data: items, horizontal: true, showsHorizontalScrollIndicator: false, keyExtractor: (item) => item.id, contentContainerStyle: styles.listContent, ListEmptyComponent: react_1.default.createElement(react_native_1.View, { style: [styles.placeholder, { borderColor: textColor }], accessibilityLabel: "No photos captured" },
|
|
128
|
+
react_1.default.createElement(react_native_1.Text, { style: [styles.placeholderText, { color: textColor }] }, "No photos")), renderItem: ({ item }) => (react_1.default.createElement(react_native_1.Pressable, { onPress: () => handleItemPress(item), disabled: disablePreview, accessibilityRole: "imagebutton", accessibilityLabel: "Open preview",
|
|
129
|
+
// Resolve the width per-item — done at render rather than
|
|
130
|
+
// inside renderItem's style prop so the function isn't
|
|
131
|
+
// re-created on every parent render.
|
|
132
|
+
style: [
|
|
133
|
+
styles.thumbWrapper,
|
|
134
|
+
{ width: thumbWidth(item), height: THUMB_HEIGHT },
|
|
135
|
+
] },
|
|
136
|
+
react_1.default.createElement(react_native_1.Image, { source: { uri: item.uri }, style: styles.thumbImage, resizeMode: "cover" }))) }),
|
|
137
|
+
countLine,
|
|
138
|
+
react_1.default.createElement(CapturePreview_1.CapturePreview, { visible: previewItem !== null, imageUri: previewItem?.uri ?? '', imageWidth: previewItem?.width, imageHeight: previewItem?.height, onClose: closePreview })));
|
|
139
|
+
}
|
|
140
|
+
const styles = react_native_1.StyleSheet.create({
|
|
141
|
+
root: {
|
|
142
|
+
paddingVertical: 8,
|
|
143
|
+
},
|
|
144
|
+
listContent: {
|
|
145
|
+
paddingHorizontal: 12,
|
|
146
|
+
alignItems: 'center',
|
|
147
|
+
},
|
|
148
|
+
thumbWrapper: {
|
|
149
|
+
marginRight: 8,
|
|
150
|
+
borderRadius: 4,
|
|
151
|
+
overflow: 'hidden',
|
|
152
|
+
backgroundColor: '#222',
|
|
153
|
+
},
|
|
154
|
+
thumbImage: {
|
|
155
|
+
width: '100%',
|
|
156
|
+
height: '100%',
|
|
157
|
+
},
|
|
158
|
+
placeholder: {
|
|
159
|
+
height: THUMB_HEIGHT,
|
|
160
|
+
paddingHorizontal: 16,
|
|
161
|
+
borderRadius: 4,
|
|
162
|
+
borderWidth: 1,
|
|
163
|
+
borderStyle: 'dashed',
|
|
164
|
+
alignItems: 'center',
|
|
165
|
+
justifyContent: 'center',
|
|
166
|
+
},
|
|
167
|
+
placeholderText: {
|
|
168
|
+
fontSize: 12,
|
|
169
|
+
opacity: 0.6,
|
|
170
|
+
},
|
|
171
|
+
count: {
|
|
172
|
+
fontSize: 12,
|
|
173
|
+
paddingHorizontal: 16,
|
|
174
|
+
paddingTop: 4,
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
//# sourceMappingURL=CaptureThumbnailStrip.js.map
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IncrementalPanGuide — V12.11 Step 2 (item 2 of the four-step
|
|
3
|
+
* preview-UX overhaul).
|
|
4
|
+
*
|
|
5
|
+
* Apple-pano-style "keep the arrow on the line" pan guide for the
|
|
6
|
+
* incremental capture flow.
|
|
7
|
+
*
|
|
8
|
+
* ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
|
9
|
+
* ▲
|
|
10
|
+
* ● ← marker drifts perpendicular
|
|
11
|
+
* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ← guide line
|
|
12
|
+
* (along pan axis)
|
|
13
|
+
* ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
|
14
|
+
*
|
|
15
|
+
* The user is told (via the band overlay's caption) to pan along
|
|
16
|
+
* one axis. This guide gives them live feedback on how WELL they
|
|
17
|
+
* are obeying that instruction:
|
|
18
|
+
*
|
|
19
|
+
* • A solid GUIDE LINE runs along the intended pan axis (the
|
|
20
|
+
* "ideal" pan path) across the centre of the screen.
|
|
21
|
+
* • A circular MARKER sits on the line. As the user tilts the
|
|
22
|
+
* device perpendicular to the pan axis, the marker drifts
|
|
23
|
+
* OFF the line by an amount proportional to the integrated
|
|
24
|
+
* perpendicular rotation since capture started.
|
|
25
|
+
* • The marker's COLOUR signals how far off they've drifted —
|
|
26
|
+
* green (on track), amber (slight drift), red (significant
|
|
27
|
+
* drift). Same colour scale as PanoramaGuidance for
|
|
28
|
+
* consistency.
|
|
29
|
+
*
|
|
30
|
+
* Why integrate perpendicular rotation rather than absolute device
|
|
31
|
+
* angle? We don't care about the user's starting orientation (they
|
|
32
|
+
* may begin a horizontal pan with the phone tilted slightly down
|
|
33
|
+
* — that's fine). We care about CHANGE during the pan. So we
|
|
34
|
+
* reset the integral to 0 at `active=true` and accumulate the
|
|
35
|
+
* perpendicular gyro rate from there. Drift over a typical 5–10 s
|
|
36
|
+
* capture is well within tolerance (a few degrees max).
|
|
37
|
+
*
|
|
38
|
+
* Why not consume ARKit pose? Two reasons:
|
|
39
|
+
* 1. The vision-camera (non-AR) capture path doesn't have ARKit
|
|
40
|
+
* pose at all — gyro is the only common signal.
|
|
41
|
+
* 2. We want this guide to work the SAME way regardless of
|
|
42
|
+
* whether AR mode is on or off — gyro keeps the UX
|
|
43
|
+
* consistent.
|
|
44
|
+
*
|
|
45
|
+
* Performance: gyro at 30 Hz, integration is two multiplies per
|
|
46
|
+
* sample, marker position update via setState. Negligible.
|
|
47
|
+
*/
|
|
48
|
+
import React from 'react';
|
|
49
|
+
import { type StyleProp, type ViewStyle } from 'react-native';
|
|
50
|
+
export interface IncrementalPanGuideProps {
|
|
51
|
+
/**
|
|
52
|
+
* Subscribe to the gyroscope only while this is true. Typically
|
|
53
|
+
* driven by the host's `statusPhase === 'recording' &&
|
|
54
|
+
* useIncrementalPath`. When this flips false the integral
|
|
55
|
+
* resets so the next capture starts from a known zero.
|
|
56
|
+
*/
|
|
57
|
+
active: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Pixels per radian of perpendicular drift. Higher = more
|
|
60
|
+
* sensitive (small drifts move the marker more visibly).
|
|
61
|
+
* Default 600 px/rad gives ~10 px of marker travel for ~1° of
|
|
62
|
+
* tilt — visible without feeling twitchy.
|
|
63
|
+
*/
|
|
64
|
+
pixelsPerRad?: number;
|
|
65
|
+
/**
|
|
66
|
+
* Maximum marker travel from the line, in pixels. Beyond this
|
|
67
|
+
* the marker pins to the edge of its track. Default 80 px.
|
|
68
|
+
*/
|
|
69
|
+
maxTravelPx?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Drift thresholds for the colour scale, in radians. Tuned so
|
|
72
|
+
* "green" covers ±1° (no human-perceptible misalignment),
|
|
73
|
+
* "amber" up to ±3°, "red" beyond. Same defaults as
|
|
74
|
+
* PanoramaGuidance's pan-speed pill for visual consistency.
|
|
75
|
+
*/
|
|
76
|
+
warnRad?: number;
|
|
77
|
+
badRad?: number;
|
|
78
|
+
style?: StyleProp<ViewStyle>;
|
|
79
|
+
}
|
|
80
|
+
export declare function IncrementalPanGuide({ active, pixelsPerRad, maxTravelPx, warnRad, // ≈ 2.9°
|
|
81
|
+
badRad, // ≈ 5.7°
|
|
82
|
+
style, }: IncrementalPanGuideProps): React.JSX.Element | null;
|
|
83
|
+
//# sourceMappingURL=IncrementalPanGuide.d.ts.map
|