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,81 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * ViewportCropOverlay — V12.12.
4
+ *
5
+ * Translucent dim bars on the camera preview's PAN-AXIS edges
6
+ * showing where the panorama engine's source-crop is. Earlier
7
+ * versions (V12.11 Step B) put the bars on JS-top/bottom because
8
+ * the engine clipped the long sensor axis (perpendicular to pan
9
+ * in landscape, along pan in portrait) — that produced visible
10
+ * bars on the user-LEFT/RIGHT in landscape, which is the WRONG
11
+ * place: those edges aren't what the engine clips.
12
+ *
13
+ * V12.12: engine now clips ALONG the pan axis. In sensor-native
14
+ * coords:
15
+ * • landscape capture (vertical pan): clip = sensor Y (rows).
16
+ * User perceives this as TOP and BOTTOM of their landscape view.
17
+ * • portrait capture (horizontal pan): clip = sensor X (cols).
18
+ * User perceives this as LEFT and RIGHT of their portrait view.
19
+ *
20
+ * In JS coords (the host app is portrait-locked):
21
+ * • portrait device: user-left/right == JS-left/right. Bars on
22
+ * JS-left/right.
23
+ * • landscape device: user-top/bottom == JS-left/right (because
24
+ * the user's vertical maps to JS-horizontal
25
+ * under portrait-lock). Bars on JS-left/right.
26
+ *
27
+ * So in BOTH device orientations the bars sit at JS-left and JS-right.
28
+ * **No orientation detection needed in this component.** The
29
+ * engine has already arranged for the clip to manifest at the same
30
+ * JS edges regardless of physical device orientation.
31
+ *
32
+ * Bar width = `(1 - panFraction) / 2` of the JS-horizontal extent.
33
+ * For the default `kPanAxisFractionRect = 0.70` engine constant,
34
+ * each bar is 15 % wide — visibly substantial, matching what the
35
+ * engine clips out per frame.
36
+ */
37
+
38
+ import React from 'react';
39
+ import { StyleSheet, View } from 'react-native';
40
+
41
+
42
+ export interface ViewportCropOverlayProps {
43
+ /**
44
+ * Fraction of the PAN axis the engine keeps per frame, in (0, 1].
45
+ * E.g. 0.70 for the V12.12 rectilinear engine's
46
+ * `kPanAxisFractionRect`. Values ≥ 1 hide the overlay (no clip).
47
+ */
48
+ panFraction: number;
49
+ }
50
+
51
+
52
+ export function ViewportCropOverlay({
53
+ panFraction,
54
+ }: ViewportCropOverlayProps): React.JSX.Element | null {
55
+ if (panFraction >= 1) return null;
56
+
57
+ // (1 - panFraction) / 2 of the JS-horizontal extent on each side.
58
+ const barPercent = `${((1 - panFraction) / 2) * 100}%` as const;
59
+
60
+ return (
61
+ <View pointerEvents="none" style={styles.root}>
62
+ <View style={[styles.bar, { left: 0, top: 0, bottom: 0, width: barPercent }]} />
63
+ <View style={[styles.bar, { right: 0, top: 0, bottom: 0, width: barPercent }]} />
64
+ </View>
65
+ );
66
+ }
67
+
68
+
69
+ const styles = StyleSheet.create({
70
+ root: {
71
+ ...StyleSheet.absoluteFillObject,
72
+ },
73
+ // Dim bars: translucent black overlay so the underlying camera
74
+ // preview is still visible (the user gets spatial context for
75
+ // what's about to leave the frame), but darkened enough to read
76
+ // as "this is OUTSIDE the capture region."
77
+ bar: {
78
+ position: 'absolute',
79
+ backgroundColor: 'rgba(0, 0, 0, 0.55)',
80
+ },
81
+ });
@@ -0,0 +1,279 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * useCapture — React hook that encapsulates the camera capture state
4
+ * machine so host apps get a drop-in replacement for the ad-hoc
5
+ * vision-camera wiring they used to have inline on each screen.
6
+ *
7
+ * Responsibilities:
8
+ * - Holds the Camera ref for ``takePhoto``.
9
+ * - Tracks the device permission state and exposes a request helper.
10
+ * - Manages torch / flash state + a toggle helper.
11
+ * - Wraps takePhoto with a single-flight guard so a double-tap on
12
+ * the shutter button doesn't spawn two captures in parallel.
13
+ * - Runs an optional JS-side quality check on the captured image
14
+ * before resolving; the host app sees the QualityReport on the
15
+ * returned CaptureResult.
16
+ *
17
+ * Non-goals:
18
+ * - This hook does NOT persist captures. Host apps hand the
19
+ * returned CaptureResult to their own storage layer (WatermelonDB
20
+ * insert, Redux dispatch, whatever).
21
+ * - Video recording lives in useVideoCapture (TODO).
22
+ *
23
+ * The public API is designed to be minimal and replaceable: host apps
24
+ * that prefer the raw vision-camera API can opt out of this hook and
25
+ * still use the SDK's quality + stitching modules.
26
+ */
27
+
28
+ import { useCallback, useMemo, useRef, useState } from 'react';
29
+ import {
30
+ Camera,
31
+ useCameraDevice,
32
+ useCameraDevices,
33
+ useCameraPermission,
34
+ type PhotoFile,
35
+ type PhysicalCameraDeviceType,
36
+ type TakePhotoOptions,
37
+ } from 'react-native-vision-camera';
38
+
39
+ import { runQualityCheck } from '../quality/runQualityCheck';
40
+ import { normaliseOrientation } from '../quality/normaliseOrientation';
41
+ import type {
42
+ CaptureResult,
43
+ QualityReport,
44
+ QualityThresholds,
45
+ } from '../types';
46
+
47
+
48
+ /**
49
+ * Hook input. Everything optional; sensible defaults are applied
50
+ * so simple call-sites can write ``useCapture()`` and get a usable
51
+ * back-camera pipeline with ``flash=off`` and no quality checking.
52
+ */
53
+ export interface UseCaptureOptions {
54
+ /** 'back' | 'front' — defaults to 'back' (shelf photos). */
55
+ cameraPosition?: 'back' | 'front';
56
+ /** Quality check toggle + thresholds. */
57
+ enableQualityChecks?: boolean;
58
+ qualityThresholds?: QualityThresholds;
59
+ /**
60
+ * Extra TakePhotoOptions to pass through to vision-camera.
61
+ * The SDK merges these with its defaults; host-supplied values win.
62
+ */
63
+ takePhotoOptions?: TakePhotoOptions;
64
+ /**
65
+ * 2026-05-14 — preferred physical-lens type for the chosen
66
+ * `cameraPosition`. Maps to vision-camera's `physicalDevices`
67
+ * filter on `useCameraDevice`.
68
+ *
69
+ * undefined (default) — use vision-camera's selection algorithm,
70
+ * which picks the device that combines
71
+ * the most lenses (typically the "main"
72
+ * multi-lens virtual camera). Existing
73
+ * behaviour; backwards-compatible.
74
+ * 'wide-angle-camera' — 1× physical lens (the standard rear
75
+ * camera most users think of as "the
76
+ * camera").
77
+ * 'ultra-wide-angle-camera' — 0.5× ultra-wide lens (only on
78
+ * devices with one; Samsung A35 has one;
79
+ * iPhone 11 Pro and later have one).
80
+ * 'telephoto-camera' — 2× / 3× telephoto if the device has
81
+ * one. Rare on field-rep deployments;
82
+ * exposed for symmetry.
83
+ *
84
+ * When the preferred type isn't available on the device, the
85
+ * hook falls back to vision-camera's default selection (i.e.,
86
+ * behaves as if `preferredPhysicalDevice` was undefined). The
87
+ * returned `availablePhysicalDevices` exposes what the device
88
+ * actually offers so the host can render an appropriate switcher.
89
+ */
90
+ preferredPhysicalDevice?: PhysicalCameraDeviceType;
91
+ }
92
+
93
+
94
+ /**
95
+ * Hook output. Intentionally flat so destructuring a subset is
96
+ * cheap and the API doesn't force callers to drill into nested
97
+ * objects for common concerns.
98
+ */
99
+ export interface UseCaptureReturn {
100
+ /** Pass to <CameraView ref={ref} /> (or the raw Camera directly). */
101
+ cameraRef: React.RefObject<Camera | null>;
102
+ /** The currently selected device — null while vision-camera hasn't picked one. */
103
+ device: ReturnType<typeof useCameraDevice>;
104
+ /** True once the user has granted camera permission. */
105
+ hasPermission: boolean;
106
+ /** Trigger the system permission sheet. Resolves to the new state. */
107
+ requestPermission: () => Promise<boolean>;
108
+ /** Current flash mode — controlled from host code. */
109
+ flash: 'off' | 'on';
110
+ toggleFlash: () => void;
111
+ /** True while takePhoto is in flight. Use to disable the shutter button. */
112
+ isCapturing: boolean;
113
+ /**
114
+ * Take a photo. Single-flight: parallel calls return the in-flight
115
+ * promise. Returns a CaptureResult (with an optional QualityReport
116
+ * when ``enableQualityChecks`` is on).
117
+ */
118
+ takePhoto: () => Promise<CaptureResult>;
119
+ /**
120
+ * 2026-05-14 — physical lens types available on the chosen
121
+ * `cameraPosition`. Computed once at the first vision-camera
122
+ * device-list emission; useful for the host to decide whether to
123
+ * render a 0.5×/1× camera switcher chip (only show if both
124
+ * `wide-angle-camera` AND `ultra-wide-angle-camera` are present).
125
+ *
126
+ * Empty array on platforms that haven't enumerated devices yet
127
+ * (very brief — vision-camera resolves the device list at module
128
+ * load). Always populated by the time the camera is mountable.
129
+ */
130
+ availablePhysicalDevices: PhysicalCameraDeviceType[];
131
+ }
132
+
133
+
134
+ function makeCaptureResult(
135
+ photo: PhotoFile,
136
+ qualityReport: QualityReport | undefined,
137
+ ): CaptureResult {
138
+ const capturedAt = new Date().toISOString();
139
+ return {
140
+ // The device UUID the host wants to identify this capture with is
141
+ // app-specific. We synthesise a deterministic ish value so the
142
+ // host gets a placeholder; most hosts will swap it out for a uuid
143
+ // library (react-native-uuid or similar) before persisting.
144
+ deviceUuid: `${capturedAt}-${photo.path.split('/').pop() ?? 'photo'}`,
145
+ compressedUri: `file://${photo.path}`,
146
+ // vision-camera reports width/height post-orientation-correction,
147
+ // matching what `<Image>` renders. Forwarding them lets the
148
+ // SDK's thumbnail strip / preview modal lay out at the correct
149
+ // aspect ratio instead of forcing square crops.
150
+ width: photo.width,
151
+ height: photo.height,
152
+ isStitched: false,
153
+ capturedAt,
154
+ qualityReport,
155
+ deviceMetadata: {
156
+ platform: 'ios',
157
+ osVersion: '',
158
+ deviceModel: '',
159
+ cameraId: '',
160
+ flashEnabled: false,
161
+ },
162
+ };
163
+ }
164
+
165
+
166
+ export function useCapture(options: UseCaptureOptions = {}): UseCaptureReturn {
167
+ const {
168
+ cameraPosition = 'back',
169
+ enableQualityChecks = false,
170
+ qualityThresholds,
171
+ takePhotoOptions,
172
+ preferredPhysicalDevice,
173
+ } = options;
174
+
175
+ const cameraRef = useRef<Camera | null>(null);
176
+
177
+ // 2026-05-14 — physical-lens-aware device picker.
178
+ //
179
+ // When `preferredPhysicalDevice` is supplied, ask vision-camera
180
+ // for a device that exposes that specific physical lens (e.g.,
181
+ // 'ultra-wide-angle-camera'). Falls back to the position-default
182
+ // when the device doesn't have that lens. When undefined, behaves
183
+ // identically to the pre-2026-05-14 useCameraDevice(position) call.
184
+ const deviceWithPreferred = useCameraDevice(cameraPosition, {
185
+ physicalDevices: preferredPhysicalDevice ? [preferredPhysicalDevice] : undefined,
186
+ });
187
+ const deviceFallback = useCameraDevice(cameraPosition);
188
+ const device = deviceWithPreferred ?? deviceFallback;
189
+
190
+ // Enumerate ALL physical lens types available on the chosen
191
+ // position so the host can decide whether to render a switcher.
192
+ // Vision-camera's `useCameraDevices()` returns CameraDevice[]; each
193
+ // has `physicalDevices: PhysicalCameraDeviceType[]`. We dedupe the
194
+ // union across all devices at `position` so the host sees the full
195
+ // set the platform exposes (some phones expose ultra-wide only via
196
+ // a separate logical camera, not the main one).
197
+ const allDevices = useCameraDevices();
198
+ const availablePhysicalDevices = useMemo<PhysicalCameraDeviceType[]>(() => {
199
+ const seen = new Set<PhysicalCameraDeviceType>();
200
+ for (const d of allDevices) {
201
+ if (d.position !== cameraPosition) continue;
202
+ for (const pd of d.physicalDevices ?? []) {
203
+ seen.add(pd);
204
+ }
205
+ }
206
+ return Array.from(seen);
207
+ }, [allDevices, cameraPosition]);
208
+
209
+ const { hasPermission, requestPermission } = useCameraPermission();
210
+ const [flash, setFlash] = useState<'off' | 'on'>('off');
211
+ const [isCapturing, setIsCapturing] = useState(false);
212
+ // Holds the in-flight takePhoto promise so we don't kick off a second
213
+ // call while the first is still settling. Cleared in the finally.
214
+ const inFlightRef = useRef<Promise<CaptureResult> | null>(null);
215
+
216
+ const toggleFlash = useCallback(() => {
217
+ setFlash((prev) => (prev === 'off' ? 'on' : 'off'));
218
+ }, []);
219
+
220
+ const takePhoto = useCallback(async (): Promise<CaptureResult> => {
221
+ if (inFlightRef.current) {
222
+ return inFlightRef.current;
223
+ }
224
+ if (!cameraRef.current) {
225
+ throw new Error(
226
+ 'useCapture.takePhoto: cameraRef is not yet attached. '
227
+ + 'Render <CameraView ref={cameraRef} /> or the raw Camera with this ref first.',
228
+ );
229
+ }
230
+
231
+ const promise = (async () => {
232
+ setIsCapturing(true);
233
+ try {
234
+ const photo = await cameraRef.current!.takePhoto({
235
+ flash,
236
+ ...takePhotoOptions,
237
+ });
238
+ // Bake EXIF rotation into pixels so the file on disk matches
239
+ // what the operator just saw on the preview, regardless of
240
+ // how downstream consumers handle EXIF. Returns the
241
+ // post-rotation dimensions; we override the photo's
242
+ // width/height before constructing the CaptureResult so
243
+ // the SDK contract reports "what's actually saved".
244
+ const normalised = await normaliseOrientation(photo.path, {
245
+ width: photo.width,
246
+ height: photo.height,
247
+ });
248
+ const orientedPhoto: PhotoFile = {
249
+ ...photo,
250
+ width: normalised.width || photo.width,
251
+ height: normalised.height || photo.height,
252
+ };
253
+
254
+ let report: QualityReport | undefined;
255
+ if (enableQualityChecks && qualityThresholds) {
256
+ report = await runQualityCheck(orientedPhoto.path, qualityThresholds);
257
+ }
258
+ return makeCaptureResult(orientedPhoto, report);
259
+ } finally {
260
+ setIsCapturing(false);
261
+ inFlightRef.current = null;
262
+ }
263
+ })();
264
+ inFlightRef.current = promise;
265
+ return promise;
266
+ }, [flash, enableQualityChecks, qualityThresholds, takePhotoOptions]);
267
+
268
+ return {
269
+ cameraRef,
270
+ device,
271
+ hasPermission,
272
+ requestPermission,
273
+ flash,
274
+ toggleFlash,
275
+ isCapturing,
276
+ takePhoto,
277
+ availablePhysicalDevices,
278
+ };
279
+ }
@@ -0,0 +1,140 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * useDeviceOrientation — physical device orientation hook.
4
+ *
5
+ * The host app is portrait-locked at the iOS app level (so the
6
+ * camera preview, header, controls, and thumbnails stay in their
7
+ * portrait positions even when the user holds the phone sideways
8
+ * for a vertical pan). But text overlays — the REC banner, the
9
+ * pan-speed pill, the live frame strip — need to follow the
10
+ * physical device orientation so they stay readable in the user's
11
+ * hands. RN's `useWindowDimensions` can't help with this when
12
+ * the app is orientation-locked: window dimensions don't change
13
+ * when only the device rotates.
14
+ *
15
+ * 2026-05-18 (Issue #3) — rewritten on top of `expo-sensors`
16
+ * `DeviceMotion` (CoreMotion-fused on iOS, SensorManager on
17
+ * Android). The previous implementation used
18
+ * `react-native-sensors` raw accelerometer with an Android-only
19
+ * sign convention (`y > 0` ⇒ portrait), which silently failed on
20
+ * iOS — Apple's CoreMotion convention is `y < 0` ⇒ portrait
21
+ * because device-Y points from the phone's bottom to the top,
22
+ * and gravity in that frame is `-Y`. Users on iOS saw the hook
23
+ * stuck at its initial value ('portrait') regardless of physical
24
+ * rotation, which cascaded into wrong panorama bake-rotation and
25
+ * a broken landscape band layout.
26
+ *
27
+ * Sign conventions used here (per platform docs):
28
+ *
29
+ * iOS (CMDeviceMotion.accelerationIncludingGravity, reported in
30
+ * m/s² in the device reference frame):
31
+ * portrait → y ≈ -9.8
32
+ * portrait-upside-down → y ≈ +9.8
33
+ * landscape-left (home indicator on user's RIGHT) → x ≈ +9.8
34
+ * landscape-right (home indicator on user's LEFT) → x ≈ -9.8
35
+ *
36
+ * Android (Sensor.TYPE_ACCELEROMETER, reaction-force convention):
37
+ * portrait → y ≈ +9.8 ← opposite sign vs iOS
38
+ * portrait-upside-down → y ≈ -9.8
39
+ * landscape-left → x ≈ -9.8
40
+ * landscape-right → x ≈ +9.8
41
+ *
42
+ * We flip the Android x/y to match the iOS convention before
43
+ * classification so the rest of the logic stays platform-
44
+ * independent. The classification then unambiguously maps to
45
+ * the user-visible `DeviceOrientation` enum.
46
+ */
47
+
48
+ import { useEffect, useState } from 'react';
49
+ import { DeviceMotion } from 'expo-sensors';
50
+ import type { DeviceMotionMeasurement } from 'expo-sensors';
51
+
52
+
53
+ export type DeviceOrientation =
54
+ | 'portrait'
55
+ | 'portrait-upside-down'
56
+ | 'landscape-left'
57
+ | 'landscape-right';
58
+
59
+
60
+ /// Threshold (m/s²) above which gravity dominance is considered
61
+ /// conclusive. 5 m/s² out of ~9.8 means the phone is at least ~30°
62
+ /// tilted toward that axis — comfortable for stable orientation
63
+ /// classification without flipping on minor wobbles.
64
+ const DOMINANT_AXIS_THRESHOLD = 5.0;
65
+
66
+ /// Sample at ~10 Hz — plenty for orientation detection (phones
67
+ /// don't physically flip faster than this).
68
+ const SAMPLE_INTERVAL_MS = 100;
69
+
70
+
71
+ function classify(x: number, y: number): DeviceOrientation | null {
72
+ // 2026-05-18 (Issue #3 round 2) — re-derived sign convention.
73
+ //
74
+ // Through expo-sensors, BOTH platforms normalize to the iOS
75
+ // CoreMotion gravity-vector convention: stationary phone reports
76
+ // the gravity vector itself in the device frame. Device axes:
77
+ // +X points from phone-left to phone-right; +Y from phone-bottom
78
+ // to phone-top; +Z out of the screen toward the viewer.
79
+ //
80
+ // Per-orientation gravity-vector signs in the device frame:
81
+ //
82
+ // portrait (upright) → y ≈ -9.8
83
+ // Phone-Y points up in world; gravity is along device -Y.
84
+ //
85
+ // portrait-upside-down → y ≈ +9.8
86
+ // Phone-Y points down in world; gravity is along device +Y.
87
+ //
88
+ // landscape-left (Apple: home indicator on user's RIGHT;
89
+ // phone rotated 90° CCW from portrait):
90
+ // phone-X axis points from user-bottom to user-top in this
91
+ // orientation, so gravity (world-down) is along device -X.
92
+ // → x ≈ -9.8
93
+ //
94
+ // landscape-right (Apple: home indicator on user's LEFT;
95
+ // phone rotated 90° CW from portrait):
96
+ // phone-X axis points from user-top to user-bottom, so
97
+ // gravity is along device +X.
98
+ // → x ≈ +9.8
99
+ //
100
+ // The earlier implementation had an Android-specific axis flip
101
+ // baked in. Removed — expo-sensors normalizes Android signs to
102
+ // match iOS, and the platform branch was producing wrong values
103
+ // (Android portrait → reported as portrait-upside-down; iOS
104
+ // landscape-left → reported as landscape-right).
105
+ if (Math.abs(y) > Math.abs(x)) {
106
+ if (y < -DOMINANT_AXIS_THRESHOLD) return 'portrait';
107
+ if (y > DOMINANT_AXIS_THRESHOLD) return 'portrait-upside-down';
108
+ } else {
109
+ if (x < -DOMINANT_AXIS_THRESHOLD) return 'landscape-left';
110
+ if (x > DOMINANT_AXIS_THRESHOLD) return 'landscape-right';
111
+ }
112
+ // Phone face-up or face-down (z dominates): keep the previous
113
+ // orientation rather than flicker.
114
+ return null;
115
+ }
116
+
117
+
118
+ export function useDeviceOrientation(): DeviceOrientation {
119
+ const [orientation, setOrientation] = useState<DeviceOrientation>('portrait');
120
+
121
+ useEffect(() => {
122
+ DeviceMotion.setUpdateInterval(SAMPLE_INTERVAL_MS);
123
+
124
+ let last: DeviceOrientation = 'portrait';
125
+ const sub = DeviceMotion.addListener((m: DeviceMotionMeasurement) => {
126
+ const g = m.accelerationIncludingGravity;
127
+ // First emissions can be null on cold start while CoreMotion
128
+ // warms up; skip until data arrives.
129
+ if (!g) return;
130
+ const next = classify(g.x, g.y);
131
+ if (next && next !== last) {
132
+ last = next;
133
+ setOrientation(next);
134
+ }
135
+ });
136
+ return () => sub.remove();
137
+ }, []);
138
+
139
+ return orientation;
140
+ }