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,292 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* CameraShutter — dual-mode shutter button for the SDK's panorama UX.
|
|
4
|
+
*
|
|
5
|
+
* ┌──────────────────────────────────────────────────┐
|
|
6
|
+
* │ TAP → take a single photo (existing flow) │
|
|
7
|
+
* │ HOLD → start recording video │
|
|
8
|
+
* │ RELEASE → stop recording → return video file │
|
|
9
|
+
* └──────────────────────────────────────────────────┘
|
|
10
|
+
*
|
|
11
|
+
* The button is "pure" UI — it owns gesture detection + visual
|
|
12
|
+
* feedback (idle / pressing / recording / processing rings) but
|
|
13
|
+
* NOT the recording / stitching pipeline itself. Host apps wire
|
|
14
|
+
* the resulting `onTap` / `onHoldComplete` callbacks to whatever
|
|
15
|
+
* `useCapture` / `useVideoCapture` instance they've configured.
|
|
16
|
+
*
|
|
17
|
+
* Why expose just the button (not the full surface)?
|
|
18
|
+
* Different audit screens want different layouts (thumbnails,
|
|
19
|
+
* quality badges, mode chips); pinning them all to one
|
|
20
|
+
* "PanoramaCaptureSurface" would overfit one customer's UX. The
|
|
21
|
+
* button is the only piece every screen needs identical, so it
|
|
22
|
+
* ships in the SDK. The orchestration helpers ship as
|
|
23
|
+
* `<PanoramaCaptureSurface>` next door — host apps that want full
|
|
24
|
+
* plug-and-play use that; the rest stitch this button into their
|
|
25
|
+
* own layout.
|
|
26
|
+
*
|
|
27
|
+
* Gesture detection
|
|
28
|
+
* onPressIn fires immediately on touch down; we start a
|
|
29
|
+
* ``HOLD_THRESHOLD_MS`` timer. Two outcomes:
|
|
30
|
+
*
|
|
31
|
+
* - onPressOut fires before the timer → it was a tap.
|
|
32
|
+
* - timer fires before onPressOut → transition to recording
|
|
33
|
+
* state, fire ``onHoldStart``. When onPressOut eventually
|
|
34
|
+
* fires we call ``onHoldComplete``.
|
|
35
|
+
*
|
|
36
|
+
* We deliberately do NOT use react-native-gesture-handler — the
|
|
37
|
+
* Pressable + setTimeout pattern stays in the SDK's existing dep
|
|
38
|
+
* surface (RN core only). Adds zero new peer deps for host apps.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import React, {
|
|
42
|
+
useCallback,
|
|
43
|
+
useEffect,
|
|
44
|
+
useImperativeHandle,
|
|
45
|
+
useRef,
|
|
46
|
+
useState,
|
|
47
|
+
forwardRef,
|
|
48
|
+
} from 'react';
|
|
49
|
+
import { Pressable, StyleSheet, View, type ViewStyle } from 'react-native';
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
/// Time the user must hold before tap → hold mode flip. 250 ms is
|
|
53
|
+
/// the iOS native "long press" default; matches user muscle memory
|
|
54
|
+
/// for distinguishing "snap" from "stay".
|
|
55
|
+
const HOLD_THRESHOLD_MS = 250;
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
export interface CameraShutterProps {
|
|
59
|
+
/** Called when the user taps (press-and-release before the threshold). */
|
|
60
|
+
onTap: () => void;
|
|
61
|
+
/** Called when the press crosses the threshold and recording should start. */
|
|
62
|
+
onHoldStart: () => void;
|
|
63
|
+
/** Called on release while in the hold state — recording should stop. */
|
|
64
|
+
onHoldComplete: () => void;
|
|
65
|
+
/**
|
|
66
|
+
* Maximum hold duration in milliseconds. When the timer fires
|
|
67
|
+
* we auto-fire `onHoldComplete` — same behaviour as the user
|
|
68
|
+
* releasing the button. Default 8000 ms; keeps recording
|
|
69
|
+
* within the stitcher's adjacent-frame-overlap budget
|
|
70
|
+
* (16 frames × 2 fps = 8 s upper bound). Pass 0 / undefined
|
|
71
|
+
* to disable the auto-stop.
|
|
72
|
+
*
|
|
73
|
+
* Pair with `<CaptureStatusOverlay countdownMs>` so the user
|
|
74
|
+
* sees how much hold time is left.
|
|
75
|
+
*/
|
|
76
|
+
maxHoldMs?: number;
|
|
77
|
+
/**
|
|
78
|
+
* Optional state-driven visual override. When the host has its own
|
|
79
|
+
* processing indicator (e.g. "Stitching... 70%") set this to true to
|
|
80
|
+
* paint the button in the disabled-while-processing visual.
|
|
81
|
+
*/
|
|
82
|
+
isProcessing?: boolean;
|
|
83
|
+
/** Disable the whole button (e.g. while permissions are loading). */
|
|
84
|
+
disabled?: boolean;
|
|
85
|
+
/** Optional style applied to the outer touch target. */
|
|
86
|
+
style?: ViewStyle;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Imperative handle so a parent can force-release (e.g. on unmount
|
|
92
|
+
* during a long press). Exposed via forwardRef.
|
|
93
|
+
*/
|
|
94
|
+
export interface CameraShutterHandle {
|
|
95
|
+
/** Cancel any in-flight hold without calling onHoldComplete. */
|
|
96
|
+
cancelHold: () => void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
export const CameraShutter = forwardRef<CameraShutterHandle, CameraShutterProps>(
|
|
101
|
+
function CameraShutter(
|
|
102
|
+
{
|
|
103
|
+
onTap,
|
|
104
|
+
onHoldStart,
|
|
105
|
+
onHoldComplete,
|
|
106
|
+
maxHoldMs,
|
|
107
|
+
isProcessing = false,
|
|
108
|
+
disabled = false,
|
|
109
|
+
style,
|
|
110
|
+
},
|
|
111
|
+
ref,
|
|
112
|
+
) {
|
|
113
|
+
type Phase = 'idle' | 'pressing' | 'holding';
|
|
114
|
+
|
|
115
|
+
// Phase machine. We use a state value for re-render-driven
|
|
116
|
+
// visuals AND a ref so onPressOut can read the up-to-date phase
|
|
117
|
+
// without waiting on React's render cycle (otherwise the
|
|
118
|
+
// tap-vs-hold decision can race the timer).
|
|
119
|
+
const [phase, setPhase] = useState<Phase>('idle');
|
|
120
|
+
const phaseRef = useRef<Phase>('idle');
|
|
121
|
+
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
122
|
+
// Separate timer for the auto-stop (max hold). Distinct from
|
|
123
|
+
// the tap-vs-hold detection timer so each can fire independently.
|
|
124
|
+
const maxHoldTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
125
|
+
|
|
126
|
+
const setPhaseBoth = useCallback((next: Phase) => {
|
|
127
|
+
phaseRef.current = next;
|
|
128
|
+
setPhase(next);
|
|
129
|
+
}, []);
|
|
130
|
+
|
|
131
|
+
const clearHoldTimer = useCallback(() => {
|
|
132
|
+
if (holdTimerRef.current !== null) {
|
|
133
|
+
clearTimeout(holdTimerRef.current);
|
|
134
|
+
holdTimerRef.current = null;
|
|
135
|
+
}
|
|
136
|
+
}, []);
|
|
137
|
+
|
|
138
|
+
const clearMaxHoldTimer = useCallback(() => {
|
|
139
|
+
if (maxHoldTimerRef.current !== null) {
|
|
140
|
+
clearTimeout(maxHoldTimerRef.current);
|
|
141
|
+
maxHoldTimerRef.current = null;
|
|
142
|
+
}
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
const cancelHold = useCallback(() => {
|
|
146
|
+
clearHoldTimer();
|
|
147
|
+
clearMaxHoldTimer();
|
|
148
|
+
setPhaseBoth('idle');
|
|
149
|
+
}, [clearHoldTimer, clearMaxHoldTimer, setPhaseBoth]);
|
|
150
|
+
|
|
151
|
+
useImperativeHandle(ref, () => ({ cancelHold }), [cancelHold]);
|
|
152
|
+
|
|
153
|
+
// Belt-and-suspenders: clean both timers on unmount so a
|
|
154
|
+
// fast navigation away from the camera doesn't leave one
|
|
155
|
+
// firing into a stale closure.
|
|
156
|
+
useEffect(() => () => {
|
|
157
|
+
clearHoldTimer();
|
|
158
|
+
clearMaxHoldTimer();
|
|
159
|
+
}, [clearHoldTimer, clearMaxHoldTimer]);
|
|
160
|
+
|
|
161
|
+
const handlePressIn = useCallback(() => {
|
|
162
|
+
if (disabled || isProcessing) return;
|
|
163
|
+
setPhaseBoth('pressing');
|
|
164
|
+
holdTimerRef.current = setTimeout(() => {
|
|
165
|
+
// Threshold elapsed → enter hold mode + notify.
|
|
166
|
+
if (phaseRef.current === 'pressing') {
|
|
167
|
+
setPhaseBoth('holding');
|
|
168
|
+
onHoldStart();
|
|
169
|
+
// Schedule the auto-stop if maxHoldMs is set. Same
|
|
170
|
+
// outcome as the user releasing the button manually —
|
|
171
|
+
// fires onHoldComplete + drops back to idle.
|
|
172
|
+
if (maxHoldMs && maxHoldMs > 0) {
|
|
173
|
+
maxHoldTimerRef.current = setTimeout(() => {
|
|
174
|
+
// Auto-stop unconditionally after maxHoldMs. Earlier
|
|
175
|
+
// versions gated this on `phase === 'holding'`, which
|
|
176
|
+
// skipped the fire when iOS' gesture recogniser had
|
|
177
|
+
// already flipped the phase to 'idle' due to finger
|
|
178
|
+
// drift from camera motion — leaving the engine running
|
|
179
|
+
// for hundreds of frames after the user thought they
|
|
180
|
+
// released. An extra onHoldComplete call when nothing
|
|
181
|
+
// is recording is a safe no-op (`!incremental.isRunning`
|
|
182
|
+
// early-returns).
|
|
183
|
+
if (phaseRef.current === 'holding') setPhaseBoth('idle');
|
|
184
|
+
onHoldComplete();
|
|
185
|
+
}, maxHoldMs);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}, HOLD_THRESHOLD_MS);
|
|
189
|
+
}, [disabled, isProcessing, onHoldStart, onHoldComplete, maxHoldMs, setPhaseBoth]);
|
|
190
|
+
|
|
191
|
+
const handlePressOut = useCallback(() => {
|
|
192
|
+
// CRITICAL: release ALWAYS stops the recording, regardless of
|
|
193
|
+
// disabled/isProcessing state. The previous version returned
|
|
194
|
+
// early when `isProcessing === true`, silently swallowing the
|
|
195
|
+
// release. When that happened mid-recording, `onHoldComplete`
|
|
196
|
+
// never fired, the engine kept ingesting AR frames forever
|
|
197
|
+
// (hundreds of frames stacked up before the user even noticed),
|
|
198
|
+
// and the final stitch ran on data the user never intended.
|
|
199
|
+
//
|
|
200
|
+
// The release event is the user's primary signal that they
|
|
201
|
+
// want this capture to end. No internal state is allowed to
|
|
202
|
+
// block it. `onHoldComplete` itself is idempotent (it
|
|
203
|
+
// early-returns when there's nothing running), so an extra
|
|
204
|
+
// call when the engine is already finishing is a safe no-op.
|
|
205
|
+
const wasHolding = phaseRef.current === 'holding';
|
|
206
|
+
clearHoldTimer();
|
|
207
|
+
clearMaxHoldTimer();
|
|
208
|
+
setPhaseBoth('idle');
|
|
209
|
+
if (wasHolding) {
|
|
210
|
+
onHoldComplete();
|
|
211
|
+
} else if (!disabled && !isProcessing) {
|
|
212
|
+
// It was a tap (released before the threshold). Suppress
|
|
213
|
+
// the tap when the camera is busy — taps trigger photos and
|
|
214
|
+
// we don't want to fire-and-forget into a busy pipeline.
|
|
215
|
+
onTap();
|
|
216
|
+
}
|
|
217
|
+
}, [disabled, isProcessing, onTap, onHoldComplete, clearHoldTimer, clearMaxHoldTimer, setPhaseBoth]);
|
|
218
|
+
|
|
219
|
+
// Visuals. Three layered circles so the inner colour can swap
|
|
220
|
+
// without animating the outer ring (smoother on lower-end phones).
|
|
221
|
+
const innerStyle =
|
|
222
|
+
isProcessing
|
|
223
|
+
? styles.innerProcessing
|
|
224
|
+
: phase === 'holding'
|
|
225
|
+
? styles.innerRecording
|
|
226
|
+
: phase === 'pressing'
|
|
227
|
+
? styles.innerPressing
|
|
228
|
+
: styles.innerIdle;
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<Pressable
|
|
232
|
+
accessibilityRole="button"
|
|
233
|
+
accessibilityLabel={
|
|
234
|
+
phase === 'holding'
|
|
235
|
+
? 'Recording — release to stitch panorama'
|
|
236
|
+
: 'Tap for photo, hold for panorama'
|
|
237
|
+
}
|
|
238
|
+
accessibilityState={{ disabled: disabled || isProcessing }}
|
|
239
|
+
disabled={disabled || isProcessing}
|
|
240
|
+
onPressIn={handlePressIn}
|
|
241
|
+
onPressOut={handlePressOut}
|
|
242
|
+
style={[styles.outer, disabled && styles.disabled, style]}
|
|
243
|
+
>
|
|
244
|
+
<View style={styles.ring} />
|
|
245
|
+
<View style={[styles.inner, innerStyle]} />
|
|
246
|
+
</Pressable>
|
|
247
|
+
);
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
const styles = StyleSheet.create({
|
|
253
|
+
outer: {
|
|
254
|
+
width: 76,
|
|
255
|
+
height: 76,
|
|
256
|
+
alignItems: 'center',
|
|
257
|
+
justifyContent: 'center',
|
|
258
|
+
},
|
|
259
|
+
disabled: {
|
|
260
|
+
opacity: 0.4,
|
|
261
|
+
},
|
|
262
|
+
ring: {
|
|
263
|
+
position: 'absolute',
|
|
264
|
+
width: 76,
|
|
265
|
+
height: 76,
|
|
266
|
+
borderRadius: 38,
|
|
267
|
+
borderWidth: 4,
|
|
268
|
+
borderColor: '#ffffff',
|
|
269
|
+
},
|
|
270
|
+
inner: {
|
|
271
|
+
width: 60,
|
|
272
|
+
height: 60,
|
|
273
|
+
borderRadius: 30,
|
|
274
|
+
},
|
|
275
|
+
innerIdle: {
|
|
276
|
+
backgroundColor: '#ffffff',
|
|
277
|
+
},
|
|
278
|
+
innerPressing: {
|
|
279
|
+
// Subtle shrink-effect via colour shift; opacity dims confirm
|
|
280
|
+
// touch landed without committing to a mode yet.
|
|
281
|
+
backgroundColor: 'rgba(255,255,255,0.7)',
|
|
282
|
+
},
|
|
283
|
+
innerRecording: {
|
|
284
|
+
// Apple-native panorama / shutter recording uses red.
|
|
285
|
+
backgroundColor: '#FF3B30',
|
|
286
|
+
},
|
|
287
|
+
innerProcessing: {
|
|
288
|
+
// Greyed mid-tone with reduced contrast — clearly "busy, can't
|
|
289
|
+
// press me" without being alarming.
|
|
290
|
+
backgroundColor: '#9aa0a6',
|
|
291
|
+
},
|
|
292
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* CameraView — the SDK's drop-in replacement for the raw
|
|
4
|
+
* vision-camera ``<Camera />``.
|
|
5
|
+
*
|
|
6
|
+
* Why wrap it?
|
|
7
|
+
* 1. **Default props** — always ``isActive={true}``, ``photo={true}``,
|
|
8
|
+
* and honouring the hook's flash state. Every call-site in the
|
|
9
|
+
* mobile app repeated the same tuple; the SDK canonicalises it.
|
|
10
|
+
* 2. **Branded guidance overlay** — optional ``guidance`` prop renders
|
|
11
|
+
* a themed banner over the preview without the host app having to
|
|
12
|
+
* know about positioning / contrast.
|
|
13
|
+
* 3. **Forward ref** — so ``useCapture``'s ref attaches cleanly.
|
|
14
|
+
*
|
|
15
|
+
* The component is intentionally thin — anything more elaborate goes
|
|
16
|
+
* into a separate screen (e.g. AuditCaptureSurface that combines this
|
|
17
|
+
* view with thumbnails and a shutter button). Keeping CameraView at
|
|
18
|
+
* the vision-camera layer means host apps that want a highly-custom
|
|
19
|
+
* UI can still use it as their building block.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
|
|
23
|
+
import { StyleSheet, Text, View, type ViewStyle } from 'react-native';
|
|
24
|
+
import {
|
|
25
|
+
Camera,
|
|
26
|
+
type CameraDevice,
|
|
27
|
+
type CameraProps,
|
|
28
|
+
} from 'react-native-vision-camera';
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
export interface CameraViewProps {
|
|
32
|
+
/** Output of ``useCapture().device``. If null, a placeholder is shown. */
|
|
33
|
+
device: CameraDevice | null | undefined;
|
|
34
|
+
/** Flash / torch state from ``useCapture().flash``. */
|
|
35
|
+
flash?: 'off' | 'on';
|
|
36
|
+
/** Whether the preview is actively rendering. Defaults to true. */
|
|
37
|
+
isActive?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Enable video recording on the underlying camera. Required for
|
|
40
|
+
* `useVideoCapture().startRecording()` — vision-camera throws
|
|
41
|
+
* `capture/video-not-enabled` if you call startRecording without
|
|
42
|
+
* this flag set. Defaults to `false` so apps that only take photos
|
|
43
|
+
* don't pay the video-pipeline allocation cost.
|
|
44
|
+
*
|
|
45
|
+
* Photo capture remains enabled regardless of this flag, so a
|
|
46
|
+
* single `<CameraView video />` can do both tap (photo) and
|
|
47
|
+
* hold (video → stitch) flows.
|
|
48
|
+
*/
|
|
49
|
+
video?: boolean;
|
|
50
|
+
/** Optional themed guidance banner. Renders over the preview at the top. */
|
|
51
|
+
guidance?: string;
|
|
52
|
+
/** Extra style layer applied on top of the default full-screen layout. */
|
|
53
|
+
style?: ViewStyle;
|
|
54
|
+
/** Pass-through to vision-camera for anything custom. */
|
|
55
|
+
cameraProps?: Partial<CameraProps>;
|
|
56
|
+
/**
|
|
57
|
+
* Called when the user taps the preview. Host apps may use this to
|
|
58
|
+
* drive focus-on-tap, AE/AF lock, etc. Not wired into vision-camera's
|
|
59
|
+
* focus API by this component on purpose — host apps have different
|
|
60
|
+
* preferences (focus-on-tap vs. tap-to-lock).
|
|
61
|
+
*/
|
|
62
|
+
onPreviewTap?: (event: { x: number; y: number }) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A forwardRef'd wrapper that exposes the underlying Camera ref
|
|
68
|
+
* to callers (so ``cameraRef.current.takePhoto()`` keeps working),
|
|
69
|
+
* while presenting a smaller API on the outside.
|
|
70
|
+
*/
|
|
71
|
+
export const CameraView = forwardRef<Camera | null, CameraViewProps>(function CameraView(
|
|
72
|
+
{
|
|
73
|
+
device,
|
|
74
|
+
flash = 'off',
|
|
75
|
+
isActive = true,
|
|
76
|
+
video = false,
|
|
77
|
+
guidance,
|
|
78
|
+
style,
|
|
79
|
+
cameraProps,
|
|
80
|
+
},
|
|
81
|
+
ref,
|
|
82
|
+
): React.JSX.Element {
|
|
83
|
+
// Internal ref so we can both attach to <Camera> and forward outward.
|
|
84
|
+
const innerRef = useRef<Camera>(null);
|
|
85
|
+
useImperativeHandle(ref, () => innerRef.current as Camera);
|
|
86
|
+
|
|
87
|
+
if (!device) {
|
|
88
|
+
return (
|
|
89
|
+
<View style={[styles.placeholder, style]} accessibilityLabel="Camera initialising">
|
|
90
|
+
<Text style={styles.placeholderText}>Initialising camera…</Text>
|
|
91
|
+
</View>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<View style={[styles.root, style]}>
|
|
97
|
+
<Camera
|
|
98
|
+
ref={innerRef}
|
|
99
|
+
style={StyleSheet.absoluteFill}
|
|
100
|
+
device={device}
|
|
101
|
+
isActive={isActive}
|
|
102
|
+
photo
|
|
103
|
+
video={video}
|
|
104
|
+
// Bake the device orientation into the captured pixels.
|
|
105
|
+
// Without this, vision-camera writes the file in the camera
|
|
106
|
+
// sensor's native landscape and relies on EXIF metadata to
|
|
107
|
+
// tell viewers "rotate me" — but RN's <Image> on iOS often
|
|
108
|
+
// ignores EXIF, leading to thumbnails / previews appearing
|
|
109
|
+
// sideways even though the user shot in portrait. Setting
|
|
110
|
+
// `outputOrientation="device"` rotates the pixels to match
|
|
111
|
+
// how the user is holding the phone, so the saved JPEG is
|
|
112
|
+
// "what you see is what was taken".
|
|
113
|
+
outputOrientation="device"
|
|
114
|
+
torch={flash === 'on' ? 'on' : 'off'}
|
|
115
|
+
{...cameraProps}
|
|
116
|
+
/>
|
|
117
|
+
{guidance ? (
|
|
118
|
+
<View style={styles.guidance} pointerEvents="none" accessible accessibilityRole="text">
|
|
119
|
+
<Text style={styles.guidanceText} numberOfLines={2}>
|
|
120
|
+
{guidance}
|
|
121
|
+
</Text>
|
|
122
|
+
</View>
|
|
123
|
+
) : null}
|
|
124
|
+
</View>
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
const styles = StyleSheet.create({
|
|
130
|
+
root: {
|
|
131
|
+
flex: 1,
|
|
132
|
+
overflow: 'hidden',
|
|
133
|
+
},
|
|
134
|
+
placeholder: {
|
|
135
|
+
flex: 1,
|
|
136
|
+
alignItems: 'center',
|
|
137
|
+
justifyContent: 'center',
|
|
138
|
+
backgroundColor: '#000',
|
|
139
|
+
},
|
|
140
|
+
placeholderText: {
|
|
141
|
+
color: '#ffffff',
|
|
142
|
+
fontSize: 14,
|
|
143
|
+
},
|
|
144
|
+
guidance: {
|
|
145
|
+
position: 'absolute',
|
|
146
|
+
top: 0,
|
|
147
|
+
left: 0,
|
|
148
|
+
right: 0,
|
|
149
|
+
paddingHorizontal: 16,
|
|
150
|
+
paddingVertical: 10,
|
|
151
|
+
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
|
152
|
+
},
|
|
153
|
+
guidanceText: {
|
|
154
|
+
color: '#ffffff',
|
|
155
|
+
fontSize: 13,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* CaptureControlsBar — bottom-of-screen controls for any capture
|
|
4
|
+
* surface.
|
|
5
|
+
*
|
|
6
|
+
* ┌──────────────────────────────────────────────────────────┐
|
|
7
|
+
* │ [⚡ flash] [● shutter] [ host slot ] │
|
|
8
|
+
* └──────────────────────────────────────────────────────────┘
|
|
9
|
+
*
|
|
10
|
+
* The SDK owns the flash button and the shutter button (which is
|
|
11
|
+
* `<CameraShutter>` under the hood, so tap-vs-hold gesture handling
|
|
12
|
+
* comes "for free" in any host that uses this). The right-side
|
|
13
|
+
* action is a render-prop — host apps put a "Submit", "Done",
|
|
14
|
+
* "Save", "Next" button there as fits their flow.
|
|
15
|
+
*
|
|
16
|
+
* Why a slot for the right-side action?
|
|
17
|
+
* The flash and shutter buttons are universally camera-shaped;
|
|
18
|
+
* every host wants them with the same gesture, the same colors,
|
|
19
|
+
* the same accessibility labels. But the third action varies
|
|
20
|
+
* wildly — submitting an audit, saving a single photo, advancing
|
|
21
|
+
* a wizard step. Slotting keeps the SDK from prescribing host
|
|
22
|
+
* semantics.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import React from 'react';
|
|
26
|
+
import {
|
|
27
|
+
Pressable,
|
|
28
|
+
StyleSheet,
|
|
29
|
+
Text,
|
|
30
|
+
View,
|
|
31
|
+
type StyleProp,
|
|
32
|
+
type ViewStyle,
|
|
33
|
+
} from 'react-native';
|
|
34
|
+
|
|
35
|
+
import { CameraShutter } from './CameraShutter';
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
export interface CaptureControlsBarProps {
|
|
39
|
+
/** Current flash mode — drives the flash icon's colour. */
|
|
40
|
+
flashMode: 'off' | 'on';
|
|
41
|
+
/** Called when the flash button is pressed. */
|
|
42
|
+
onToggleFlash: () => void;
|
|
43
|
+
/**
|
|
44
|
+
* 2026-05-16 — disable the flash button. Pass `true` when the
|
|
45
|
+
* active camera surface doesn't honour torch state — currently the
|
|
46
|
+
* AR camera (ARKit / ARCore own AVCaptureDevice and don't expose
|
|
47
|
+
* torch control through the JS bridge; toggling would silently
|
|
48
|
+
* no-op). Renders the button at reduced opacity + ignores presses.
|
|
49
|
+
*/
|
|
50
|
+
flashDisabled?: boolean;
|
|
51
|
+
|
|
52
|
+
// ── Shutter callbacks (forwarded to <CameraShutter>) ───────────────
|
|
53
|
+
/** Tap → take photo. */
|
|
54
|
+
onShutterTap: () => void;
|
|
55
|
+
/** Hold crosses threshold → start video recording. */
|
|
56
|
+
onShutterHoldStart: () => void;
|
|
57
|
+
/** Release after hold → stop recording, stitch. */
|
|
58
|
+
onShutterHoldComplete: () => void;
|
|
59
|
+
/**
|
|
60
|
+
* Disable the shutter (e.g. at-max-photos for the audit, no
|
|
61
|
+
* camera permission, etc). Flash and the right-side action
|
|
62
|
+
* remain interactive.
|
|
63
|
+
*/
|
|
64
|
+
shutterDisabled?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Show the shutter's "processing" visual. Use this while a
|
|
67
|
+
* stitch is in progress so the operator can't kick off a second
|
|
68
|
+
* recording mid-stitch.
|
|
69
|
+
*/
|
|
70
|
+
shutterProcessing?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Forwards to <CameraShutter maxHoldMs>. Auto-fires
|
|
73
|
+
* onShutterHoldComplete when the timer elapses, simulating the
|
|
74
|
+
* user releasing. Pair with <CaptureStatusOverlay countdownMs>
|
|
75
|
+
* so the user sees how long they have left.
|
|
76
|
+
*/
|
|
77
|
+
shutterMaxHoldMs?: number;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Render-prop slot for the host's right-side action. Typically a
|
|
81
|
+
* Pressable wrapping a "Submit" / "Done" button, but anything is
|
|
82
|
+
* fair game. Receives no arguments — wire your callbacks in the
|
|
83
|
+
* usual way.
|
|
84
|
+
*
|
|
85
|
+
* Pass `null` to render an empty spacer instead (keeps the shutter
|
|
86
|
+
* centred when there's no action to show).
|
|
87
|
+
*/
|
|
88
|
+
rightAction?: React.ReactNode;
|
|
89
|
+
|
|
90
|
+
/** Override the default colours. */
|
|
91
|
+
colors?: {
|
|
92
|
+
background?: string;
|
|
93
|
+
iconButton?: string;
|
|
94
|
+
iconActive?: string;
|
|
95
|
+
icon?: string;
|
|
96
|
+
iconAccessible?: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Bottom inset for safe-area on devices with a home indicator. */
|
|
100
|
+
bottomInset?: number;
|
|
101
|
+
|
|
102
|
+
/** Outer style passthrough. */
|
|
103
|
+
style?: StyleProp<ViewStyle>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
export function CaptureControlsBar({
|
|
108
|
+
flashMode,
|
|
109
|
+
onToggleFlash,
|
|
110
|
+
flashDisabled = false,
|
|
111
|
+
onShutterTap,
|
|
112
|
+
onShutterHoldStart,
|
|
113
|
+
onShutterHoldComplete,
|
|
114
|
+
shutterDisabled = false,
|
|
115
|
+
shutterProcessing = false,
|
|
116
|
+
shutterMaxHoldMs,
|
|
117
|
+
rightAction = null,
|
|
118
|
+
colors,
|
|
119
|
+
bottomInset = 0,
|
|
120
|
+
style,
|
|
121
|
+
}: CaptureControlsBarProps): React.JSX.Element {
|
|
122
|
+
const bg = colors?.background ?? '#000000';
|
|
123
|
+
const iconButtonBg = colors?.iconButton ?? 'rgba(255,255,255,0.12)';
|
|
124
|
+
const iconActiveBg = colors?.iconActive ?? '#FF9F0A';
|
|
125
|
+
const iconColor = colors?.icon ?? '#ffffff';
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<View
|
|
129
|
+
style={[
|
|
130
|
+
styles.bar,
|
|
131
|
+
{ backgroundColor: bg, paddingBottom: bottomInset + 16 },
|
|
132
|
+
style,
|
|
133
|
+
]}
|
|
134
|
+
>
|
|
135
|
+
{/* Flash button — colour shifts when active. Greyed out and
|
|
136
|
+
* inert when `flashDisabled` (e.g. AR mode owns the camera and
|
|
137
|
+
* doesn't expose torch). */}
|
|
138
|
+
<Pressable
|
|
139
|
+
onPress={flashDisabled ? undefined : onToggleFlash}
|
|
140
|
+
accessibilityRole="button"
|
|
141
|
+
accessibilityLabel={
|
|
142
|
+
flashDisabled
|
|
143
|
+
? 'Flash unavailable in AR mode'
|
|
144
|
+
: `Flash ${flashMode === 'on' ? 'on' : 'off'}`
|
|
145
|
+
}
|
|
146
|
+
accessibilityState={{
|
|
147
|
+
selected: flashMode === 'on',
|
|
148
|
+
disabled: flashDisabled,
|
|
149
|
+
}}
|
|
150
|
+
disabled={flashDisabled}
|
|
151
|
+
style={[
|
|
152
|
+
styles.iconButton,
|
|
153
|
+
{ backgroundColor: flashMode === 'on' ? iconActiveBg : iconButtonBg },
|
|
154
|
+
flashDisabled ? { opacity: 0.35 } : null,
|
|
155
|
+
]}
|
|
156
|
+
hitSlop={8}
|
|
157
|
+
>
|
|
158
|
+
<Text style={[styles.icon, { color: iconColor }]}>⚡</Text>
|
|
159
|
+
</Pressable>
|
|
160
|
+
|
|
161
|
+
{/* Shutter — SDK component, owns tap-vs-hold gesture. */}
|
|
162
|
+
<CameraShutter
|
|
163
|
+
onTap={onShutterTap}
|
|
164
|
+
onHoldStart={onShutterHoldStart}
|
|
165
|
+
onHoldComplete={onShutterHoldComplete}
|
|
166
|
+
maxHoldMs={shutterMaxHoldMs}
|
|
167
|
+
disabled={shutterDisabled}
|
|
168
|
+
isProcessing={shutterProcessing}
|
|
169
|
+
/>
|
|
170
|
+
|
|
171
|
+
{/* Right-side host slot. Wrapped in a fixed-width view so
|
|
172
|
+
* the flash and shutter stay positioned identically across
|
|
173
|
+
* hosts regardless of what the slot contains. */}
|
|
174
|
+
<View style={styles.rightSlot}>{rightAction}</View>
|
|
175
|
+
</View>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
const styles = StyleSheet.create({
|
|
181
|
+
bar: {
|
|
182
|
+
flexDirection: 'row',
|
|
183
|
+
alignItems: 'center',
|
|
184
|
+
justifyContent: 'space-between',
|
|
185
|
+
paddingHorizontal: 24,
|
|
186
|
+
paddingTop: 16,
|
|
187
|
+
},
|
|
188
|
+
iconButton: {
|
|
189
|
+
width: 48,
|
|
190
|
+
height: 48,
|
|
191
|
+
borderRadius: 24,
|
|
192
|
+
alignItems: 'center',
|
|
193
|
+
justifyContent: 'center',
|
|
194
|
+
},
|
|
195
|
+
icon: {
|
|
196
|
+
fontSize: 22,
|
|
197
|
+
},
|
|
198
|
+
rightSlot: {
|
|
199
|
+
width: 48,
|
|
200
|
+
height: 48,
|
|
201
|
+
alignItems: 'center',
|
|
202
|
+
justifyContent: 'center',
|
|
203
|
+
},
|
|
204
|
+
});
|