react-native-image-stitcher 0.1.0

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