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,498 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * PanoramaBandOverlay — V16 Phase 2 (merged band + strip).
4
+ *
5
+ * SINGLE source of truth for the live "progress strip" that sits on
6
+ * top of the camera preview during a panorama hold. Replaces what
7
+ * was previously TWO components rendered side-by-side:
8
+ *
9
+ * 1. live per-keyframe thumbnail strip — fed by accepted-frame URIs
10
+ * (batch-keyframe engine) OR by
11
+ * periodic vision-camera snapshots.
12
+ * 2. <PanoramaBandOverlay /> — a single cumulative-panorama
13
+ * thumbnail with a "fill ratio"
14
+ * bar growing with the pan.
15
+ *
16
+ * The split made the UI visually noisy AND made it differ between
17
+ * platforms when one side emitted keyframe events and the other
18
+ * didn't. V16 Phase 2 collapses them into ONE component that:
19
+ *
20
+ * • Renders a horizontally-scrolling list of per-keyframe
21
+ * thumbnails when `frameUris` is non-empty (batch-keyframe
22
+ * mode). Each frame the KeyframeGate accepts shows up as a
23
+ * mini-thumb.
24
+ *
25
+ * • Falls back to a SINGLE cumulative-panorama thumbnail (the
26
+ * V12.14.9 fill-ratio behaviour) when `frameUris` is empty —
27
+ * i.e. the live-stitching engines that don't surface
28
+ * per-keyframe paths. This preserves the existing visual for
29
+ * hybrid / firstwins / firstwins-rectilinear engines.
30
+ *
31
+ * • Edge-pinned to the BOTTOM of the camera area in portrait, and
32
+ * to the user's RIGHT in landscape (which corresponds to
33
+ * JS-bottom under the app's portrait-lock). Both anchors keep
34
+ * the band out of the centre of the scene the operator is
35
+ * framing.
36
+ *
37
+ * • Trailing arrow points along the pan axis (→ in portrait, ← in
38
+ * landscape-left's user perception). Arrow always sits at the
39
+ * pan-END side, so the LATEST keyframe abuts the arrow.
40
+ *
41
+ * • Auto-scrolls a `<ScrollView>` so the latest keyframe stays
42
+ * visible regardless of how many frames have been accumulated.
43
+ *
44
+ * Empty-state intentional non-design:
45
+ * The KeyframeGate force-accepts the FIRST frame of every capture
46
+ * (see C++ `AcceptFirstAnchoredOnPlane` / `AcceptFirstNoPlane` in
47
+ * keyframe_gate.cpp). By the time the operator's perceived "the
48
+ * band appeared", we already have at least one thumb/snapshot in
49
+ * flight. We therefore don't render any "no frames yet"
50
+ * placeholder — the empty period is sub-perceptual.
51
+ *
52
+ * Why this component is in react-native-image-stitcher (not host):
53
+ * It's the same JSX shipped to iOS and Android. Differences in
54
+ * what shows up come only from native-emitted data
55
+ * (`state.batchKeyframeThumbnailPath` / `state.panoramaPath`),
56
+ * not from per-platform component code. That's exactly the parity
57
+ * property the user wants: "the UI should not differ between iOS
58
+ * and Android — it's the same UI reused".
59
+ */
60
+
61
+ import React, { useCallback, useMemo, useRef } from 'react';
62
+ import {
63
+ Image,
64
+ ScrollView,
65
+ StyleSheet,
66
+ Text,
67
+ View,
68
+ type ViewStyle,
69
+ } from 'react-native';
70
+ import type { IncrementalState } from '../stitching/incremental';
71
+
72
+
73
+ /**
74
+ * 2026-05-18 (Issue #3 fix) — 4-way capture orientation classifier.
75
+ * Replaces the 2-way `state.isLandscape` boolean which couldn't
76
+ * distinguish landscape-LEFT (home button on user's right) from
77
+ * landscape-RIGHT (home button on user's left). Required because
78
+ * the JS-coordinate mapping to user-perceived directions inverts
79
+ * between the two landscape rotations — `flexDirection: 'row'`
80
+ * gives oldest-at-user-top in landscape-LEFT but oldest-at-user-
81
+ * bottom in landscape-RIGHT, so we need to branch the layout.
82
+ */
83
+ export type BandCaptureOrientation =
84
+ | 'portrait'
85
+ | 'portrait-upside-down'
86
+ | 'landscape-left'
87
+ | 'landscape-right';
88
+
89
+ export interface PanoramaBandOverlayProps {
90
+ /**
91
+ * Latest engine state. Pass `useIncrementalStitcher().state`.
92
+ * Used for single-thumb fallback URI and fill-ratio when no
93
+ * per-keyframe URIs are provided. `state.isLandscape` is now
94
+ * superseded by `captureOrientation` below for layout selection.
95
+ */
96
+ state: IncrementalState | null;
97
+ /**
98
+ * Optional list of per-keyframe thumbnail URIs accumulated by the
99
+ * host as the native batch-keyframe engine emits
100
+ * `batchKeyframeThumbnailPath` events. When non-empty, the band
101
+ * renders these as a scrolling mini-thumb strip. When empty or
102
+ * undefined, the band falls back to the single cumulative-panorama
103
+ * thumbnail (legacy live-engine visual).
104
+ *
105
+ * Caller should cap the list length itself if needed (e.g. the
106
+ * AuditCaptureScreen already trims at 24 entries). This component
107
+ * applies an internal hard cap as a safety net so a runaway
108
+ * emission doesn't blow up the scroll view.
109
+ */
110
+ frameUris?: string[];
111
+ /**
112
+ * 2026-05-18 (Issue #3) — capture orientation passed from the host.
113
+ * Drives a 4-way layout switch so the band reads correctly in
114
+ * either landscape rotation (the 2-way `state.isLandscape` boolean
115
+ * collapses landscape-LEFT and landscape-RIGHT to the same render
116
+ * path, which inverts the user's perceived "oldest-top, grows
117
+ * down" intent in one of them). Pass
118
+ * `panoramaSettings.captureOrientation` from the host. Defaults
119
+ * to `'portrait'` when omitted (back-compat).
120
+ */
121
+ captureOrientation?: BandCaptureOrientation;
122
+ }
123
+
124
+
125
+ // ── Layout constants — tuned to read clearly at arm's length ────────
126
+ const BAND_PADDING = 6;
127
+ const BAND_THICKNESS = 64;
128
+ const ARROW_TRACK_LEN = 44; // fixed slot for the arrow glyph
129
+ const SINGLE_THUMB_INNER = BAND_THICKNESS - BAND_PADDING * 2;
130
+ const SINGLE_THUMB_MAX_PAN_LEN = 240;
131
+ const MULTI_THUMB_LEN = 48;
132
+ const MULTI_THUMB_GAP = 4;
133
+ const MULTI_THUMB_HARD_CAP = 32; // safety net; host typically caps at 24
134
+
135
+
136
+ type LayoutKind = 'portrait' | 'landscape';
137
+ interface Layout {
138
+ kind: LayoutKind;
139
+ /** Outer container style — positioning + flexDirection. */
140
+ band: ViewStyle;
141
+ /** Direction used by both the outer band AND the scroll content. */
142
+ flexDirection: 'row' | 'row-reverse';
143
+ /** Unicode arrow pointing along the user-perceived pan axis. */
144
+ arrowGlyph: string;
145
+ }
146
+
147
+
148
+ /**
149
+ * Resolve band layout from capture orientation. 2026-05-18 (Issue #3)
150
+ * — uses the 4-way `BandCaptureOrientation` instead of the 2-way
151
+ * `state.isLandscape` so we can pick the right flex direction +
152
+ * arrow glyph in EACH landscape rotation.
153
+ *
154
+ * The two landscape rotations require different JS-coordinate setups
155
+ * because the phone tilts the JS coordinate system relative to the
156
+ * user differently:
157
+ *
158
+ * LANDSCAPE-LEFT (Apple: home indicator on user's RIGHT; phone
159
+ * rotated 90° CCW from portrait).
160
+ * JS-left = user-top
161
+ * JS-right = user-bottom
162
+ * Band at JS-bottom edge appears on user's RIGHT edge.
163
+ * For "oldest at user-top, newest at user-bottom":
164
+ * flexDirection = 'row' (array[0] at JS-left = user-top).
165
+ * For arrow appearing as user-DOWN-arrow:
166
+ * glyph `←` (rotated 90° CCW = points user-down).
167
+ *
168
+ * LANDSCAPE-RIGHT (Apple: home indicator on user's LEFT; phone
169
+ * rotated 90° CW from portrait).
170
+ * JS-left = user-bottom
171
+ * JS-right = user-top
172
+ * Band at JS-TOP edge appears on user's RIGHT edge (so we move
173
+ * the band to JS-top here, not JS-bottom).
174
+ * For "oldest at user-top, newest at user-bottom":
175
+ * flexDirection = 'row-reverse' (array[0] at JS-right = user-top).
176
+ * For arrow appearing as user-DOWN-arrow:
177
+ * glyph `→` (rotated 90° CW = points user-down).
178
+ *
179
+ * PORTRAIT (and portrait-upside-down — collapsed because the band's
180
+ * bottom-anchored position remains sensible either way):
181
+ * Band at JS-bottom = user-bottom. Row left-to-right. Arrow `→`
182
+ * reads as user-right-arrow (pointing along the horizontal pan
183
+ * direction).
184
+ */
185
+ function layoutFor(orientation: BandCaptureOrientation): Layout {
186
+ const commonInner: ViewStyle = {
187
+ alignItems: 'center',
188
+ paddingHorizontal: BAND_PADDING,
189
+ paddingVertical: BAND_PADDING,
190
+ backgroundColor: 'rgba(0, 0, 0, 0.55)',
191
+ };
192
+ // 2026-05-19 — repositioned tethered to the shutter (no longer
193
+ // edge-pinned via absolute positioning). The parent stack in
194
+ // Camera.tsx now puts this band in a vertical column immediately
195
+ // above the shutter row. The SDK's orientation lock holds the UI
196
+ // in portrait regardless of physical device rotation, so the band
197
+ // is ALWAYS a horizontal strip in JS coordinates. In landscape
198
+ // (physically held), the rendered strip visually appears as a
199
+ // vertical column on the viewport-side of the shutter.
200
+ //
201
+ // What still varies by physical orientation: the order in which
202
+ // thumbnails should appear so newest is at the user-perceived
203
+ // "leading edge" of the pan. That's the flexDirection (row vs
204
+ // row-reverse) and the arrow glyph.
205
+ if (orientation === 'landscape-left') {
206
+ // Phone rotated 90° CCW from portrait (home indicator on the
207
+ // user's RIGHT). With UI orientation-locked to portrait:
208
+ // JS-left (band horizontal start) = user-BOTTOM
209
+ // JS-right (band horizontal end) = user-TOP
210
+ // For the canonical "oldest at user-TOP, growth toward user-
211
+ // BOTTOM" reading direction the monorepo established, we want:
212
+ // array[0] (oldest) at user-TOP = JS-rightmost
213
+ // newest at user-BOTTOM = JS-leftmost
214
+ // → flexDirection: 'row-reverse' (array[0] at JS-rightmost)
215
+ return {
216
+ kind: 'landscape',
217
+ band: {
218
+ marginHorizontal: 16,
219
+ marginVertical: 8,
220
+ height: BAND_THICKNESS,
221
+ flexDirection: 'row-reverse',
222
+ ...commonInner,
223
+ },
224
+ flexDirection: 'row-reverse',
225
+ arrowGlyph: '←',
226
+ };
227
+ }
228
+ if (orientation === 'landscape-right') {
229
+ // Phone rotated 90° CW from portrait (home indicator on the
230
+ // user's LEFT). Mirror of landscape-left:
231
+ // JS-left = user-TOP
232
+ // JS-right = user-BOTTOM
233
+ // For "oldest at user-TOP, newest at user-BOTTOM":
234
+ // array[0] (oldest) at user-TOP = JS-leftmost
235
+ // → flexDirection: 'row' (array[0] at JS-leftmost)
236
+ return {
237
+ kind: 'landscape',
238
+ band: {
239
+ marginHorizontal: 16,
240
+ marginVertical: 8,
241
+ height: BAND_THICKNESS,
242
+ flexDirection: 'row',
243
+ ...commonInner,
244
+ },
245
+ flexDirection: 'row',
246
+ arrowGlyph: '→',
247
+ };
248
+ }
249
+ // portrait / portrait-upside-down / default. Held portrait, pan
250
+ // is horizontal left→right (or right→left for left-handed scans;
251
+ // the band doesn't enforce a direction). newest at JS-rightmost.
252
+ return {
253
+ kind: 'portrait',
254
+ band: {
255
+ marginHorizontal: 16,
256
+ marginVertical: 8,
257
+ height: BAND_THICKNESS,
258
+ flexDirection: 'row',
259
+ ...commonInner,
260
+ },
261
+ flexDirection: 'row',
262
+ arrowGlyph: '→',
263
+ };
264
+ }
265
+
266
+
267
+ export function PanoramaBandOverlay({
268
+ state,
269
+ frameUris,
270
+ captureOrientation,
271
+ }: PanoramaBandOverlayProps): React.JSX.Element | null {
272
+ // 2026-05-18 (Issue #3 fix) — orientation source priority:
273
+ // 1. `captureOrientation` prop from the host (4-way; correct
274
+ // for landscape-left vs landscape-right disambiguation).
275
+ // 2. Fallback to `state.isLandscape` (2-way; collapses both
276
+ // landscape rotations to landscape-left semantics).
277
+ // 3. Default `portrait` (the band's bottom-anchor still reads
278
+ // sensibly before any orientation info is available).
279
+ const resolvedOrientation: BandCaptureOrientation =
280
+ captureOrientation
281
+ ?? (state?.isLandscape ? 'landscape-left' : 'portrait');
282
+ const layout = useMemo(
283
+ () => layoutFor(resolvedOrientation),
284
+ [resolvedOrientation],
285
+ );
286
+
287
+ const scrollRef = useRef<ScrollView | null>(null);
288
+
289
+ // Trim incoming URIs to a hard cap. The host already caps at 24
290
+ // (AuditCaptureScreen) but defence-in-depth keeps the ScrollView
291
+ // bounded if a different host forgets to. Slice from the END so
292
+ // we keep the MOST RECENT N — older frames slide off the start.
293
+ const cappedFrameUris = useMemo(() => {
294
+ if (!frameUris || frameUris.length === 0) return [];
295
+ return frameUris.length > MULTI_THUMB_HARD_CAP
296
+ ? frameUris.slice(frameUris.length - MULTI_THUMB_HARD_CAP)
297
+ : frameUris;
298
+ }, [frameUris]);
299
+
300
+ const hasMultiThumb = cappedFrameUris.length > 0;
301
+
302
+ // Auto-scroll on content-size change.
303
+ //
304
+ // 2026-05-18 (Issue #4 fix-b): the direction depends on flex
305
+ // direction. In `row` (portrait, landscape-right) the LATEST
306
+ // item is at JS-rightmost → scrollToEnd shows it. In
307
+ // `row-reverse` (landscape-left) the latest is at JS-leftmost →
308
+ // scrollTo({x: 0}) shows it. The earlier always-scrollToEnd
309
+ // behaviour scrolled to OLDEST in row-reverse, which hid the
310
+ // just-captured frame off-screen at user-bottom.
311
+ const onContentSizeChange = useCallback(() => {
312
+ const sv = scrollRef.current;
313
+ if (!sv) return;
314
+ if (layout.flexDirection === 'row-reverse') {
315
+ sv.scrollTo({ x: 0, y: 0, animated: false });
316
+ } else {
317
+ sv.scrollToEnd({ animated: false });
318
+ }
319
+ }, [layout.flexDirection]);
320
+
321
+ // ── Single cumulative thumbnail (live-engine fallback) ──────────
322
+ //
323
+ // Same fill-ratio math as V12.14.9. Kept so live-stitching engines
324
+ // (hybrid / firstwins / firstwins-rectilinear / firstwins-zoomed)
325
+ // that don't emit per-keyframe URIs still get a useful
326
+ // progress-thumbnail UX — the thumb widens proportionally as the
327
+ // operator pans further.
328
+ const cumulativeUri = useMemo(() => {
329
+ if (!state?.panoramaPath) return null;
330
+ return `file://${state.panoramaPath}?v=${state.acceptedCount}`;
331
+ }, [state?.panoramaPath, state?.acceptedCount]);
332
+
333
+ const fillRatio = useMemo(() => {
334
+ if (!state?.paintedExtent || !state?.panExtent) return 0;
335
+ return Math.max(0, Math.min(1, state.paintedExtent / state.panExtent));
336
+ }, [state?.paintedExtent, state?.panExtent]);
337
+
338
+ const singleThumbPanLen = useMemo(() => {
339
+ return Math.max(SINGLE_THUMB_INNER, SINGLE_THUMB_MAX_PAN_LEN * fillRatio);
340
+ }, [fillRatio]);
341
+
342
+ // V12.14.9 — rotate the panorama image 90° in landscape mode so
343
+ // the captured scene reads UPRIGHT to the user in landscape head-up
344
+ // view. See original comment in the pre-V16 PanoramaBandOverlay for
345
+ // the full reasoning. Portrait+horizontal-pan mode (the other
346
+ // supported mode) doesn't need rotation.
347
+ //
348
+ // 2026-05-18 (Issue #3) — derive from `resolvedOrientation` instead
349
+ // of the deprecated 2-way `isLandscape`. In landscape-RIGHT we
350
+ // rotate −90° so the captured scene still reads upright (the
351
+ // opposite sense from landscape-LEFT).
352
+ const singleImageStyle = useMemo(
353
+ () => {
354
+ if (resolvedOrientation === 'landscape-left') {
355
+ return [StyleSheet.absoluteFill, { transform: [{ rotate: '90deg' }] }];
356
+ }
357
+ if (resolvedOrientation === 'landscape-right') {
358
+ return [StyleSheet.absoluteFill, { transform: [{ rotate: '-90deg' }] }];
359
+ }
360
+ return StyleSheet.absoluteFill;
361
+ },
362
+ [resolvedOrientation],
363
+ );
364
+
365
+ return (
366
+ <View pointerEvents="none" style={[styles.bandBase, layout.band]}>
367
+ {hasMultiThumb ? (
368
+ // Multi-thumb path: one image per accepted keyframe, scrolling
369
+ // horizontally (in JS-coords) within the band. Content
370
+ // flex-direction matches the outer band so OLDEST is at the
371
+ // pan-start side and LATEST sits next to the arrow.
372
+ //
373
+ // 2026-05-18 (Issue A — arrow placement) — the arrow is the
374
+ // LAST child of contentContainer (after the thumbnail map)
375
+ // so it flows with the scroll content and always sits
376
+ // adjacent to the newest thumbnail. Previously it was a
377
+ // sibling of the ScrollView at the band's far end, which
378
+ // looked detached when there were only a few thumbnails.
379
+ <ScrollView
380
+ ref={scrollRef}
381
+ horizontal
382
+ showsHorizontalScrollIndicator={false}
383
+ showsVerticalScrollIndicator={false}
384
+ style={styles.thumbScroll}
385
+ contentContainerStyle={[
386
+ styles.thumbScrollContent,
387
+ { flexDirection: layout.flexDirection },
388
+ ]}
389
+ onContentSizeChange={onContentSizeChange}
390
+ >
391
+ {cappedFrameUris.map((uri, idx) => (
392
+ <Image
393
+ // Composite key: idx prevents collisions if the same path
394
+ // ever gets re-emitted (shouldn't happen but cheap to be
395
+ // defensive). URI segment helps RN's image cache key.
396
+ key={`${idx}-${uri}`}
397
+ source={{ uri }}
398
+ style={styles.multiThumb}
399
+ resizeMode="cover"
400
+ fadeDuration={0}
401
+ />
402
+ ))}
403
+ <View style={styles.arrowTrack}>
404
+ <Text style={styles.arrowGlyph}>{layout.arrowGlyph}</Text>
405
+ </View>
406
+ </ScrollView>
407
+ ) : (
408
+ <>
409
+ {/* Single-thumb path: cumulative panorama image, width
410
+ * grows with the pan extent. Visually identical to
411
+ * pre-V16 PanoramaBandOverlay so live-engine UX is
412
+ * unchanged. Arrow stays a sibling here so it sits at
413
+ * the band's end (the single-thumb View is fixed-width
414
+ * so the layout is naturally "thumb + arrow"). */}
415
+ <View
416
+ style={[
417
+ styles.thumbBox,
418
+ { width: singleThumbPanLen, height: SINGLE_THUMB_INNER },
419
+ ]}
420
+ >
421
+ {cumulativeUri ? (
422
+ <Image
423
+ key={state?.acceptedCount ?? 0}
424
+ source={{ uri: cumulativeUri }}
425
+ style={singleImageStyle}
426
+ resizeMode="cover"
427
+ fadeDuration={0}
428
+ />
429
+ ) : null}
430
+ </View>
431
+ <View style={styles.arrowTrack}>
432
+ <Text style={styles.arrowGlyph}>{layout.arrowGlyph}</Text>
433
+ </View>
434
+ </>
435
+ )}
436
+ </View>
437
+ );
438
+ }
439
+
440
+
441
+ const styles = StyleSheet.create({
442
+ // Properties common to every layout — uniform border-radius so the
443
+ // band reads as a single capsule regardless of which edge it's
444
+ // anchored to. Orientation-specific values (position, flexDirection,
445
+ // sizing) come from `layoutFor()`.
446
+ bandBase: {
447
+ borderRadius: 12,
448
+ },
449
+ thumbScroll: {
450
+ flex: 1,
451
+ },
452
+ thumbScrollContent: {
453
+ alignItems: 'center',
454
+ paddingHorizontal: BAND_PADDING,
455
+ // 2026-05-18 (Issue #4 fix-a): contentContainer must FILL the
456
+ // ScrollView width so flexDirection aligns items at the correct
457
+ // end of the viewport. Without flexGrow, contentContainer
458
+ // takes the natural width of its items (e.g. 150 px for 3
459
+ // thumbs) and anchors at JS-leftmost of the ScrollView, leaving
460
+ // a big empty gap on JS-right. In landscape-left that gap is
461
+ // on user-TOP — exactly what the operator reports as "thumbs
462
+ // clump at the bottom". flexGrow:1 makes the contentContainer
463
+ // span the viewport so items align at the END of the row-
464
+ // direction (JS-right for `row`, JS-left for `row-reverse`).
465
+ flexGrow: 1,
466
+ },
467
+ multiThumb: {
468
+ width: MULTI_THUMB_LEN,
469
+ height: MULTI_THUMB_LEN,
470
+ borderRadius: 4,
471
+ // marginHorizontal so the gap applies in both `row` and
472
+ // `row-reverse` directions identically; flex layout collapses
473
+ // adjacent margins, giving us a single inter-thumb gap.
474
+ marginHorizontal: MULTI_THUMB_GAP / 2,
475
+ backgroundColor: 'rgba(255, 255, 255, 0.08)',
476
+ borderWidth: 1,
477
+ borderColor: 'rgba(255, 255, 255, 0.55)',
478
+ },
479
+ thumbBox: {
480
+ backgroundColor: 'rgba(255, 255, 255, 0.08)',
481
+ borderWidth: 1,
482
+ borderColor: 'rgba(255, 255, 255, 0.55)',
483
+ borderRadius: 4,
484
+ overflow: 'hidden',
485
+ },
486
+ arrowTrack: {
487
+ width: ARROW_TRACK_LEN,
488
+ alignItems: 'center',
489
+ justifyContent: 'center',
490
+ paddingHorizontal: BAND_PADDING,
491
+ },
492
+ arrowGlyph: {
493
+ color: 'rgba(255, 255, 255, 0.9)',
494
+ fontSize: 28,
495
+ lineHeight: 28,
496
+ fontWeight: '600',
497
+ },
498
+ });
@@ -0,0 +1,206 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * PanoramaConfirmModal — post-stitch review screen.
4
+ *
5
+ * ┌──────────────────────────────────────────────────────────┐
6
+ * │ │
7
+ * │ ┌──────────────────────────────┐ │
8
+ * │ │ stitched panorama │ │
9
+ * │ │ (resizeMode=contain) │ │
10
+ * │ └──────────────────────────────┘ │
11
+ * │ │
12
+ * │ [✕ Discard] [↺ Retry] [✓ Save] │
13
+ * └──────────────────────────────────────────────────────────┘
14
+ *
15
+ * Why this exists
16
+ * Without it, a panorama lands directly into the audit's
17
+ * thumbnail strip — operator only finds out it's bad once they
18
+ * tap the thumbnail, or worse, never. The confirm step is the
19
+ * safety net iOS' native panorama UX gives by default.
20
+ *
21
+ * Three actions, three callbacks
22
+ * - Save: host persists the panorama (writes Capture row, etc).
23
+ * - Retry: host throws away the panorama and re-enters the
24
+ * capture flow. Good UX is to keep the camera
25
+ * ready so the operator can immediately re-pan.
26
+ * - Discard: host throws away the panorama and returns to the
27
+ * capture flow without re-entering. Same as Retry
28
+ * minus the "ready to record" hint.
29
+ *
30
+ * The modal is purely presentational — it doesn't know about
31
+ * WatermelonDB, file paths, or any host-domain concept beyond the
32
+ * panorama URI + dimensions to display.
33
+ */
34
+
35
+ import React from 'react';
36
+ import {
37
+ Image,
38
+ Modal,
39
+ Pressable,
40
+ StyleSheet,
41
+ Text,
42
+ View,
43
+ } from 'react-native';
44
+
45
+
46
+ export interface PanoramaConfirmModalProps {
47
+ /**
48
+ * Modal visibility. When true, the modal animates in over the
49
+ * current screen. Drive this from the host's "stitch result is
50
+ * pending review" state.
51
+ */
52
+ visible: boolean;
53
+ /** file:// URI of the stitched panorama to preview. */
54
+ panoramaUri: string;
55
+ /** Pixel width of the panorama (for the preview's aspect ratio). */
56
+ width: number;
57
+ /** Pixel height of the panorama. */
58
+ height: number;
59
+ /** User confirmed — host should persist the panorama. */
60
+ onSave: () => void;
61
+ /** User wants to re-record — host should drop and reopen camera. */
62
+ onRetry: () => void;
63
+ /** User wants to discard without re-recording. */
64
+ onDiscard: () => void;
65
+ /** Optional override for the title (defaults to "Review panorama"). */
66
+ title?: string;
67
+ }
68
+
69
+
70
+ export function PanoramaConfirmModal({
71
+ visible,
72
+ panoramaUri,
73
+ width,
74
+ height,
75
+ onSave,
76
+ onRetry,
77
+ onDiscard,
78
+ title = 'Review panorama',
79
+ }: PanoramaConfirmModalProps): React.JSX.Element {
80
+ // The aspect-ratio-locked image lets `<Image>` size itself
81
+ // correctly inside a flexible container without us having to
82
+ // measure the modal's available area on every layout change.
83
+ const aspectRatio = width > 0 && height > 0 ? width / height : 16 / 9;
84
+
85
+ return (
86
+ <Modal
87
+ visible={visible}
88
+ animationType="fade"
89
+ transparent
90
+ statusBarTranslucent
91
+ onRequestClose={onDiscard}
92
+ >
93
+ <View style={styles.backdrop}>
94
+ <Text style={styles.title} accessibilityRole="header">
95
+ {title}
96
+ </Text>
97
+
98
+ <View style={styles.imageWrapper}>
99
+ <Image
100
+ source={{ uri: panoramaUri }}
101
+ style={[styles.image, { aspectRatio }]}
102
+ resizeMode="contain"
103
+ accessibilityIgnoresInvertColors
104
+ />
105
+ </View>
106
+
107
+ <View style={styles.buttonRow}>
108
+ <Pressable
109
+ onPress={onDiscard}
110
+ style={[styles.button, styles.buttonGhost]}
111
+ accessibilityRole="button"
112
+ accessibilityLabel="Discard panorama"
113
+ >
114
+ <Text style={styles.buttonGhostText}>✕ Discard</Text>
115
+ </Pressable>
116
+ <Pressable
117
+ onPress={onRetry}
118
+ style={[styles.button, styles.buttonNeutral]}
119
+ accessibilityRole="button"
120
+ accessibilityLabel="Retry panorama"
121
+ >
122
+ <Text style={styles.buttonNeutralText}>↺ Retry</Text>
123
+ </Pressable>
124
+ <Pressable
125
+ onPress={onSave}
126
+ style={[styles.button, styles.buttonPrimary]}
127
+ accessibilityRole="button"
128
+ accessibilityLabel="Save panorama"
129
+ >
130
+ <Text style={styles.buttonPrimaryText}>✓ Save</Text>
131
+ </Pressable>
132
+ </View>
133
+ </View>
134
+ </Modal>
135
+ );
136
+ }
137
+
138
+
139
+ const styles = StyleSheet.create({
140
+ backdrop: {
141
+ flex: 1,
142
+ backgroundColor: 'rgba(0,0,0,0.96)',
143
+ paddingTop: 64,
144
+ paddingBottom: 32,
145
+ paddingHorizontal: 16,
146
+ },
147
+ title: {
148
+ color: '#ffffff',
149
+ fontSize: 16,
150
+ fontWeight: '600',
151
+ textAlign: 'center',
152
+ marginBottom: 12,
153
+ },
154
+ imageWrapper: {
155
+ flex: 1,
156
+ alignItems: 'center',
157
+ justifyContent: 'center',
158
+ },
159
+ image: {
160
+ width: '100%',
161
+ maxHeight: '100%',
162
+ backgroundColor: '#111',
163
+ borderRadius: 4,
164
+ },
165
+ buttonRow: {
166
+ flexDirection: 'row',
167
+ justifyContent: 'space-between',
168
+ alignItems: 'center',
169
+ marginTop: 16,
170
+ gap: 12,
171
+ },
172
+ button: {
173
+ flex: 1,
174
+ paddingVertical: 14,
175
+ borderRadius: 10,
176
+ alignItems: 'center',
177
+ justifyContent: 'center',
178
+ },
179
+ buttonGhost: {
180
+ backgroundColor: 'transparent',
181
+ borderWidth: 1,
182
+ borderColor: 'rgba(255,255,255,0.3)',
183
+ },
184
+ buttonGhostText: {
185
+ color: '#ffffff',
186
+ fontSize: 14,
187
+ fontWeight: '500',
188
+ opacity: 0.9,
189
+ },
190
+ buttonNeutral: {
191
+ backgroundColor: 'rgba(255,255,255,0.12)',
192
+ },
193
+ buttonNeutralText: {
194
+ color: '#ffffff',
195
+ fontSize: 14,
196
+ fontWeight: '600',
197
+ },
198
+ buttonPrimary: {
199
+ backgroundColor: '#34C759',
200
+ },
201
+ buttonPrimaryText: {
202
+ color: '#ffffff',
203
+ fontSize: 14,
204
+ fontWeight: '700',
205
+ },
206
+ });