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,399 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * PanoramaBandOverlay — V16 Phase 2 (merged band + strip).
5
+ *
6
+ * SINGLE source of truth for the live "progress strip" that sits on
7
+ * top of the camera preview during a panorama hold. Replaces what
8
+ * was previously TWO components rendered side-by-side:
9
+ *
10
+ * 1. live per-keyframe thumbnail strip — fed by accepted-frame URIs
11
+ * (batch-keyframe engine) OR by
12
+ * periodic vision-camera snapshots.
13
+ * 2. <PanoramaBandOverlay /> — a single cumulative-panorama
14
+ * thumbnail with a "fill ratio"
15
+ * bar growing with the pan.
16
+ *
17
+ * The split made the UI visually noisy AND made it differ between
18
+ * platforms when one side emitted keyframe events and the other
19
+ * didn't. V16 Phase 2 collapses them into ONE component that:
20
+ *
21
+ * • Renders a horizontally-scrolling list of per-keyframe
22
+ * thumbnails when `frameUris` is non-empty (batch-keyframe
23
+ * mode). Each frame the KeyframeGate accepts shows up as a
24
+ * mini-thumb.
25
+ *
26
+ * • Falls back to a SINGLE cumulative-panorama thumbnail (the
27
+ * V12.14.9 fill-ratio behaviour) when `frameUris` is empty —
28
+ * i.e. the live-stitching engines that don't surface
29
+ * per-keyframe paths. This preserves the existing visual for
30
+ * hybrid / firstwins / firstwins-rectilinear engines.
31
+ *
32
+ * • Edge-pinned to the BOTTOM of the camera area in portrait, and
33
+ * to the user's RIGHT in landscape (which corresponds to
34
+ * JS-bottom under the app's portrait-lock). Both anchors keep
35
+ * the band out of the centre of the scene the operator is
36
+ * framing.
37
+ *
38
+ * • Trailing arrow points along the pan axis (→ in portrait, ← in
39
+ * landscape-left's user perception). Arrow always sits at the
40
+ * pan-END side, so the LATEST keyframe abuts the arrow.
41
+ *
42
+ * • Auto-scrolls a `<ScrollView>` so the latest keyframe stays
43
+ * visible regardless of how many frames have been accumulated.
44
+ *
45
+ * Empty-state intentional non-design:
46
+ * The KeyframeGate force-accepts the FIRST frame of every capture
47
+ * (see C++ `AcceptFirstAnchoredOnPlane` / `AcceptFirstNoPlane` in
48
+ * keyframe_gate.cpp). By the time the operator's perceived "the
49
+ * band appeared", we already have at least one thumb/snapshot in
50
+ * flight. We therefore don't render any "no frames yet"
51
+ * placeholder — the empty period is sub-perceptual.
52
+ *
53
+ * Why this component is in react-native-image-stitcher (not host):
54
+ * It's the same JSX shipped to iOS and Android. Differences in
55
+ * what shows up come only from native-emitted data
56
+ * (`state.batchKeyframeThumbnailPath` / `state.panoramaPath`),
57
+ * not from per-platform component code. That's exactly the parity
58
+ * property the user wants: "the UI should not differ between iOS
59
+ * and Android — it's the same UI reused".
60
+ */
61
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
62
+ if (k2 === undefined) k2 = k;
63
+ var desc = Object.getOwnPropertyDescriptor(m, k);
64
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
65
+ desc = { enumerable: true, get: function() { return m[k]; } };
66
+ }
67
+ Object.defineProperty(o, k2, desc);
68
+ }) : (function(o, m, k, k2) {
69
+ if (k2 === undefined) k2 = k;
70
+ o[k2] = m[k];
71
+ }));
72
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
73
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
74
+ }) : function(o, v) {
75
+ o["default"] = v;
76
+ });
77
+ var __importStar = (this && this.__importStar) || (function () {
78
+ var ownKeys = function(o) {
79
+ ownKeys = Object.getOwnPropertyNames || function (o) {
80
+ var ar = [];
81
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
82
+ return ar;
83
+ };
84
+ return ownKeys(o);
85
+ };
86
+ return function (mod) {
87
+ if (mod && mod.__esModule) return mod;
88
+ var result = {};
89
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
90
+ __setModuleDefault(result, mod);
91
+ return result;
92
+ };
93
+ })();
94
+ Object.defineProperty(exports, "__esModule", { value: true });
95
+ exports.PanoramaBandOverlay = PanoramaBandOverlay;
96
+ const react_1 = __importStar(require("react"));
97
+ const react_native_1 = require("react-native");
98
+ // ── Layout constants — tuned to read clearly at arm's length ────────
99
+ const BAND_PADDING = 6;
100
+ const BAND_THICKNESS = 64;
101
+ const ARROW_TRACK_LEN = 44; // fixed slot for the arrow glyph
102
+ const SINGLE_THUMB_INNER = BAND_THICKNESS - BAND_PADDING * 2;
103
+ const SINGLE_THUMB_MAX_PAN_LEN = 240;
104
+ const MULTI_THUMB_LEN = 48;
105
+ const MULTI_THUMB_GAP = 4;
106
+ const MULTI_THUMB_HARD_CAP = 32; // safety net; host typically caps at 24
107
+ /**
108
+ * Resolve band layout from capture orientation. 2026-05-18 (Issue #3)
109
+ * — uses the 4-way `BandCaptureOrientation` instead of the 2-way
110
+ * `state.isLandscape` so we can pick the right flex direction +
111
+ * arrow glyph in EACH landscape rotation.
112
+ *
113
+ * The two landscape rotations require different JS-coordinate setups
114
+ * because the phone tilts the JS coordinate system relative to the
115
+ * user differently:
116
+ *
117
+ * LANDSCAPE-LEFT (Apple: home indicator on user's RIGHT; phone
118
+ * rotated 90° CCW from portrait).
119
+ * JS-left = user-top
120
+ * JS-right = user-bottom
121
+ * Band at JS-bottom edge appears on user's RIGHT edge.
122
+ * For "oldest at user-top, newest at user-bottom":
123
+ * flexDirection = 'row' (array[0] at JS-left = user-top).
124
+ * For arrow appearing as user-DOWN-arrow:
125
+ * glyph `←` (rotated 90° CCW = points user-down).
126
+ *
127
+ * LANDSCAPE-RIGHT (Apple: home indicator on user's LEFT; phone
128
+ * rotated 90° CW from portrait).
129
+ * JS-left = user-bottom
130
+ * JS-right = user-top
131
+ * Band at JS-TOP edge appears on user's RIGHT edge (so we move
132
+ * the band to JS-top here, not JS-bottom).
133
+ * For "oldest at user-top, newest at user-bottom":
134
+ * flexDirection = 'row-reverse' (array[0] at JS-right = user-top).
135
+ * For arrow appearing as user-DOWN-arrow:
136
+ * glyph `→` (rotated 90° CW = points user-down).
137
+ *
138
+ * PORTRAIT (and portrait-upside-down — collapsed because the band's
139
+ * bottom-anchored position remains sensible either way):
140
+ * Band at JS-bottom = user-bottom. Row left-to-right. Arrow `→`
141
+ * reads as user-right-arrow (pointing along the horizontal pan
142
+ * direction).
143
+ */
144
+ function layoutFor(orientation) {
145
+ const commonInner = {
146
+ alignItems: 'center',
147
+ paddingHorizontal: BAND_PADDING,
148
+ paddingVertical: BAND_PADDING,
149
+ backgroundColor: 'rgba(0, 0, 0, 0.55)',
150
+ };
151
+ // 2026-05-19 — repositioned tethered to the shutter (no longer
152
+ // edge-pinned via absolute positioning). The parent stack in
153
+ // Camera.tsx now puts this band in a vertical column immediately
154
+ // above the shutter row. The SDK's orientation lock holds the UI
155
+ // in portrait regardless of physical device rotation, so the band
156
+ // is ALWAYS a horizontal strip in JS coordinates. In landscape
157
+ // (physically held), the rendered strip visually appears as a
158
+ // vertical column on the viewport-side of the shutter.
159
+ //
160
+ // What still varies by physical orientation: the order in which
161
+ // thumbnails should appear so newest is at the user-perceived
162
+ // "leading edge" of the pan. That's the flexDirection (row vs
163
+ // row-reverse) and the arrow glyph.
164
+ if (orientation === 'landscape-left') {
165
+ // Phone rotated 90° CCW from portrait (home indicator on the
166
+ // user's RIGHT). With UI orientation-locked to portrait:
167
+ // JS-left (band horizontal start) = user-BOTTOM
168
+ // JS-right (band horizontal end) = user-TOP
169
+ // For the canonical "oldest at user-TOP, growth toward user-
170
+ // BOTTOM" reading direction the monorepo established, we want:
171
+ // array[0] (oldest) at user-TOP = JS-rightmost
172
+ // newest at user-BOTTOM = JS-leftmost
173
+ // → flexDirection: 'row-reverse' (array[0] at JS-rightmost)
174
+ return {
175
+ kind: 'landscape',
176
+ band: {
177
+ marginHorizontal: 16,
178
+ marginVertical: 8,
179
+ height: BAND_THICKNESS,
180
+ flexDirection: 'row-reverse',
181
+ ...commonInner,
182
+ },
183
+ flexDirection: 'row-reverse',
184
+ arrowGlyph: '←',
185
+ };
186
+ }
187
+ if (orientation === 'landscape-right') {
188
+ // Phone rotated 90° CW from portrait (home indicator on the
189
+ // user's LEFT). Mirror of landscape-left:
190
+ // JS-left = user-TOP
191
+ // JS-right = user-BOTTOM
192
+ // For "oldest at user-TOP, newest at user-BOTTOM":
193
+ // array[0] (oldest) at user-TOP = JS-leftmost
194
+ // → flexDirection: 'row' (array[0] at JS-leftmost)
195
+ return {
196
+ kind: 'landscape',
197
+ band: {
198
+ marginHorizontal: 16,
199
+ marginVertical: 8,
200
+ height: BAND_THICKNESS,
201
+ flexDirection: 'row',
202
+ ...commonInner,
203
+ },
204
+ flexDirection: 'row',
205
+ arrowGlyph: '→',
206
+ };
207
+ }
208
+ // portrait / portrait-upside-down / default. Held portrait, pan
209
+ // is horizontal left→right (or right→left for left-handed scans;
210
+ // the band doesn't enforce a direction). newest at JS-rightmost.
211
+ return {
212
+ kind: 'portrait',
213
+ band: {
214
+ marginHorizontal: 16,
215
+ marginVertical: 8,
216
+ height: BAND_THICKNESS,
217
+ flexDirection: 'row',
218
+ ...commonInner,
219
+ },
220
+ flexDirection: 'row',
221
+ arrowGlyph: '→',
222
+ };
223
+ }
224
+ function PanoramaBandOverlay({ state, frameUris, captureOrientation, }) {
225
+ // 2026-05-18 (Issue #3 fix) — orientation source priority:
226
+ // 1. `captureOrientation` prop from the host (4-way; correct
227
+ // for landscape-left vs landscape-right disambiguation).
228
+ // 2. Fallback to `state.isLandscape` (2-way; collapses both
229
+ // landscape rotations to landscape-left semantics).
230
+ // 3. Default `portrait` (the band's bottom-anchor still reads
231
+ // sensibly before any orientation info is available).
232
+ const resolvedOrientation = captureOrientation
233
+ ?? (state?.isLandscape ? 'landscape-left' : 'portrait');
234
+ const layout = (0, react_1.useMemo)(() => layoutFor(resolvedOrientation), [resolvedOrientation]);
235
+ const scrollRef = (0, react_1.useRef)(null);
236
+ // Trim incoming URIs to a hard cap. The host already caps at 24
237
+ // (AuditCaptureScreen) but defence-in-depth keeps the ScrollView
238
+ // bounded if a different host forgets to. Slice from the END so
239
+ // we keep the MOST RECENT N — older frames slide off the start.
240
+ const cappedFrameUris = (0, react_1.useMemo)(() => {
241
+ if (!frameUris || frameUris.length === 0)
242
+ return [];
243
+ return frameUris.length > MULTI_THUMB_HARD_CAP
244
+ ? frameUris.slice(frameUris.length - MULTI_THUMB_HARD_CAP)
245
+ : frameUris;
246
+ }, [frameUris]);
247
+ const hasMultiThumb = cappedFrameUris.length > 0;
248
+ // Auto-scroll on content-size change.
249
+ //
250
+ // 2026-05-18 (Issue #4 fix-b): the direction depends on flex
251
+ // direction. In `row` (portrait, landscape-right) the LATEST
252
+ // item is at JS-rightmost → scrollToEnd shows it. In
253
+ // `row-reverse` (landscape-left) the latest is at JS-leftmost →
254
+ // scrollTo({x: 0}) shows it. The earlier always-scrollToEnd
255
+ // behaviour scrolled to OLDEST in row-reverse, which hid the
256
+ // just-captured frame off-screen at user-bottom.
257
+ const onContentSizeChange = (0, react_1.useCallback)(() => {
258
+ const sv = scrollRef.current;
259
+ if (!sv)
260
+ return;
261
+ if (layout.flexDirection === 'row-reverse') {
262
+ sv.scrollTo({ x: 0, y: 0, animated: false });
263
+ }
264
+ else {
265
+ sv.scrollToEnd({ animated: false });
266
+ }
267
+ }, [layout.flexDirection]);
268
+ // ── Single cumulative thumbnail (live-engine fallback) ──────────
269
+ //
270
+ // Same fill-ratio math as V12.14.9. Kept so live-stitching engines
271
+ // (hybrid / firstwins / firstwins-rectilinear / firstwins-zoomed)
272
+ // that don't emit per-keyframe URIs still get a useful
273
+ // progress-thumbnail UX — the thumb widens proportionally as the
274
+ // operator pans further.
275
+ const cumulativeUri = (0, react_1.useMemo)(() => {
276
+ if (!state?.panoramaPath)
277
+ return null;
278
+ return `file://${state.panoramaPath}?v=${state.acceptedCount}`;
279
+ }, [state?.panoramaPath, state?.acceptedCount]);
280
+ const fillRatio = (0, react_1.useMemo)(() => {
281
+ if (!state?.paintedExtent || !state?.panExtent)
282
+ return 0;
283
+ return Math.max(0, Math.min(1, state.paintedExtent / state.panExtent));
284
+ }, [state?.paintedExtent, state?.panExtent]);
285
+ const singleThumbPanLen = (0, react_1.useMemo)(() => {
286
+ return Math.max(SINGLE_THUMB_INNER, SINGLE_THUMB_MAX_PAN_LEN * fillRatio);
287
+ }, [fillRatio]);
288
+ // V12.14.9 — rotate the panorama image 90° in landscape mode so
289
+ // the captured scene reads UPRIGHT to the user in landscape head-up
290
+ // view. See original comment in the pre-V16 PanoramaBandOverlay for
291
+ // the full reasoning. Portrait+horizontal-pan mode (the other
292
+ // supported mode) doesn't need rotation.
293
+ //
294
+ // 2026-05-18 (Issue #3) — derive from `resolvedOrientation` instead
295
+ // of the deprecated 2-way `isLandscape`. In landscape-RIGHT we
296
+ // rotate −90° so the captured scene still reads upright (the
297
+ // opposite sense from landscape-LEFT).
298
+ const singleImageStyle = (0, react_1.useMemo)(() => {
299
+ if (resolvedOrientation === 'landscape-left') {
300
+ return [react_native_1.StyleSheet.absoluteFill, { transform: [{ rotate: '90deg' }] }];
301
+ }
302
+ if (resolvedOrientation === 'landscape-right') {
303
+ return [react_native_1.StyleSheet.absoluteFill, { transform: [{ rotate: '-90deg' }] }];
304
+ }
305
+ return react_native_1.StyleSheet.absoluteFill;
306
+ }, [resolvedOrientation]);
307
+ return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: [styles.bandBase, layout.band] }, hasMultiThumb ? (
308
+ // Multi-thumb path: one image per accepted keyframe, scrolling
309
+ // horizontally (in JS-coords) within the band. Content
310
+ // flex-direction matches the outer band so OLDEST is at the
311
+ // pan-start side and LATEST sits next to the arrow.
312
+ //
313
+ // 2026-05-18 (Issue A — arrow placement) — the arrow is the
314
+ // LAST child of contentContainer (after the thumbnail map)
315
+ // so it flows with the scroll content and always sits
316
+ // adjacent to the newest thumbnail. Previously it was a
317
+ // sibling of the ScrollView at the band's far end, which
318
+ // looked detached when there were only a few thumbnails.
319
+ react_1.default.createElement(react_native_1.ScrollView, { ref: scrollRef, horizontal: true, showsHorizontalScrollIndicator: false, showsVerticalScrollIndicator: false, style: styles.thumbScroll, contentContainerStyle: [
320
+ styles.thumbScrollContent,
321
+ { flexDirection: layout.flexDirection },
322
+ ], onContentSizeChange: onContentSizeChange },
323
+ cappedFrameUris.map((uri, idx) => (react_1.default.createElement(react_native_1.Image
324
+ // Composite key: idx prevents collisions if the same path
325
+ // ever gets re-emitted (shouldn't happen but cheap to be
326
+ // defensive). URI segment helps RN's image cache key.
327
+ , {
328
+ // Composite key: idx prevents collisions if the same path
329
+ // ever gets re-emitted (shouldn't happen but cheap to be
330
+ // defensive). URI segment helps RN's image cache key.
331
+ key: `${idx}-${uri}`, source: { uri }, style: styles.multiThumb, resizeMode: "cover", fadeDuration: 0 }))),
332
+ react_1.default.createElement(react_native_1.View, { style: styles.arrowTrack },
333
+ react_1.default.createElement(react_native_1.Text, { style: styles.arrowGlyph }, layout.arrowGlyph)))) : (react_1.default.createElement(react_1.default.Fragment, null,
334
+ react_1.default.createElement(react_native_1.View, { style: [
335
+ styles.thumbBox,
336
+ { width: singleThumbPanLen, height: SINGLE_THUMB_INNER },
337
+ ] }, cumulativeUri ? (react_1.default.createElement(react_native_1.Image, { key: state?.acceptedCount ?? 0, source: { uri: cumulativeUri }, style: singleImageStyle, resizeMode: "cover", fadeDuration: 0 })) : null),
338
+ react_1.default.createElement(react_native_1.View, { style: styles.arrowTrack },
339
+ react_1.default.createElement(react_native_1.Text, { style: styles.arrowGlyph }, layout.arrowGlyph))))));
340
+ }
341
+ const styles = react_native_1.StyleSheet.create({
342
+ // Properties common to every layout — uniform border-radius so the
343
+ // band reads as a single capsule regardless of which edge it's
344
+ // anchored to. Orientation-specific values (position, flexDirection,
345
+ // sizing) come from `layoutFor()`.
346
+ bandBase: {
347
+ borderRadius: 12,
348
+ },
349
+ thumbScroll: {
350
+ flex: 1,
351
+ },
352
+ thumbScrollContent: {
353
+ alignItems: 'center',
354
+ paddingHorizontal: BAND_PADDING,
355
+ // 2026-05-18 (Issue #4 fix-a): contentContainer must FILL the
356
+ // ScrollView width so flexDirection aligns items at the correct
357
+ // end of the viewport. Without flexGrow, contentContainer
358
+ // takes the natural width of its items (e.g. 150 px for 3
359
+ // thumbs) and anchors at JS-leftmost of the ScrollView, leaving
360
+ // a big empty gap on JS-right. In landscape-left that gap is
361
+ // on user-TOP — exactly what the operator reports as "thumbs
362
+ // clump at the bottom". flexGrow:1 makes the contentContainer
363
+ // span the viewport so items align at the END of the row-
364
+ // direction (JS-right for `row`, JS-left for `row-reverse`).
365
+ flexGrow: 1,
366
+ },
367
+ multiThumb: {
368
+ width: MULTI_THUMB_LEN,
369
+ height: MULTI_THUMB_LEN,
370
+ borderRadius: 4,
371
+ // marginHorizontal so the gap applies in both `row` and
372
+ // `row-reverse` directions identically; flex layout collapses
373
+ // adjacent margins, giving us a single inter-thumb gap.
374
+ marginHorizontal: MULTI_THUMB_GAP / 2,
375
+ backgroundColor: 'rgba(255, 255, 255, 0.08)',
376
+ borderWidth: 1,
377
+ borderColor: 'rgba(255, 255, 255, 0.55)',
378
+ },
379
+ thumbBox: {
380
+ backgroundColor: 'rgba(255, 255, 255, 0.08)',
381
+ borderWidth: 1,
382
+ borderColor: 'rgba(255, 255, 255, 0.55)',
383
+ borderRadius: 4,
384
+ overflow: 'hidden',
385
+ },
386
+ arrowTrack: {
387
+ width: ARROW_TRACK_LEN,
388
+ alignItems: 'center',
389
+ justifyContent: 'center',
390
+ paddingHorizontal: BAND_PADDING,
391
+ },
392
+ arrowGlyph: {
393
+ color: 'rgba(255, 255, 255, 0.9)',
394
+ fontSize: 28,
395
+ lineHeight: 28,
396
+ fontWeight: '600',
397
+ },
398
+ });
399
+ //# sourceMappingURL=PanoramaBandOverlay.js.map
@@ -0,0 +1,57 @@
1
+ /**
2
+ * PanoramaConfirmModal — post-stitch review screen.
3
+ *
4
+ * ┌──────────────────────────────────────────────────────────┐
5
+ * │ │
6
+ * │ ┌──────────────────────────────┐ │
7
+ * │ │ stitched panorama │ │
8
+ * │ │ (resizeMode=contain) │ │
9
+ * │ └──────────────────────────────┘ │
10
+ * │ │
11
+ * │ [✕ Discard] [↺ Retry] [✓ Save] │
12
+ * └──────────────────────────────────────────────────────────┘
13
+ *
14
+ * Why this exists
15
+ * Without it, a panorama lands directly into the audit's
16
+ * thumbnail strip — operator only finds out it's bad once they
17
+ * tap the thumbnail, or worse, never. The confirm step is the
18
+ * safety net iOS' native panorama UX gives by default.
19
+ *
20
+ * Three actions, three callbacks
21
+ * - Save: host persists the panorama (writes Capture row, etc).
22
+ * - Retry: host throws away the panorama and re-enters the
23
+ * capture flow. Good UX is to keep the camera
24
+ * ready so the operator can immediately re-pan.
25
+ * - Discard: host throws away the panorama and returns to the
26
+ * capture flow without re-entering. Same as Retry
27
+ * minus the "ready to record" hint.
28
+ *
29
+ * The modal is purely presentational — it doesn't know about
30
+ * WatermelonDB, file paths, or any host-domain concept beyond the
31
+ * panorama URI + dimensions to display.
32
+ */
33
+ import React from 'react';
34
+ export interface PanoramaConfirmModalProps {
35
+ /**
36
+ * Modal visibility. When true, the modal animates in over the
37
+ * current screen. Drive this from the host's "stitch result is
38
+ * pending review" state.
39
+ */
40
+ visible: boolean;
41
+ /** file:// URI of the stitched panorama to preview. */
42
+ panoramaUri: string;
43
+ /** Pixel width of the panorama (for the preview's aspect ratio). */
44
+ width: number;
45
+ /** Pixel height of the panorama. */
46
+ height: number;
47
+ /** User confirmed — host should persist the panorama. */
48
+ onSave: () => void;
49
+ /** User wants to re-record — host should drop and reopen camera. */
50
+ onRetry: () => void;
51
+ /** User wants to discard without re-recording. */
52
+ onDiscard: () => void;
53
+ /** Optional override for the title (defaults to "Review panorama"). */
54
+ title?: string;
55
+ }
56
+ export declare function PanoramaConfirmModal({ visible, panoramaUri, width, height, onSave, onRetry, onDiscard, title, }: PanoramaConfirmModalProps): React.JSX.Element;
57
+ //# sourceMappingURL=PanoramaConfirmModal.d.ts.map
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * PanoramaConfirmModal — post-stitch review screen.
5
+ *
6
+ * ┌──────────────────────────────────────────────────────────┐
7
+ * │ │
8
+ * │ ┌──────────────────────────────┐ │
9
+ * │ │ stitched panorama │ │
10
+ * │ │ (resizeMode=contain) │ │
11
+ * │ └──────────────────────────────┘ │
12
+ * │ │
13
+ * │ [✕ Discard] [↺ Retry] [✓ Save] │
14
+ * └──────────────────────────────────────────────────────────┘
15
+ *
16
+ * Why this exists
17
+ * Without it, a panorama lands directly into the audit's
18
+ * thumbnail strip — operator only finds out it's bad once they
19
+ * tap the thumbnail, or worse, never. The confirm step is the
20
+ * safety net iOS' native panorama UX gives by default.
21
+ *
22
+ * Three actions, three callbacks
23
+ * - Save: host persists the panorama (writes Capture row, etc).
24
+ * - Retry: host throws away the panorama and re-enters the
25
+ * capture flow. Good UX is to keep the camera
26
+ * ready so the operator can immediately re-pan.
27
+ * - Discard: host throws away the panorama and returns to the
28
+ * capture flow without re-entering. Same as Retry
29
+ * minus the "ready to record" hint.
30
+ *
31
+ * The modal is purely presentational — it doesn't know about
32
+ * WatermelonDB, file paths, or any host-domain concept beyond the
33
+ * panorama URI + dimensions to display.
34
+ */
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.PanoramaConfirmModal = PanoramaConfirmModal;
40
+ const react_1 = __importDefault(require("react"));
41
+ const react_native_1 = require("react-native");
42
+ function PanoramaConfirmModal({ visible, panoramaUri, width, height, onSave, onRetry, onDiscard, title = 'Review panorama', }) {
43
+ // The aspect-ratio-locked image lets `<Image>` size itself
44
+ // correctly inside a flexible container without us having to
45
+ // measure the modal's available area on every layout change.
46
+ const aspectRatio = width > 0 && height > 0 ? width / height : 16 / 9;
47
+ return (react_1.default.createElement(react_native_1.Modal, { visible: visible, animationType: "fade", transparent: true, statusBarTranslucent: true, onRequestClose: onDiscard },
48
+ react_1.default.createElement(react_native_1.View, { style: styles.backdrop },
49
+ react_1.default.createElement(react_native_1.Text, { style: styles.title, accessibilityRole: "header" }, title),
50
+ react_1.default.createElement(react_native_1.View, { style: styles.imageWrapper },
51
+ react_1.default.createElement(react_native_1.Image, { source: { uri: panoramaUri }, style: [styles.image, { aspectRatio }], resizeMode: "contain", accessibilityIgnoresInvertColors: true })),
52
+ react_1.default.createElement(react_native_1.View, { style: styles.buttonRow },
53
+ react_1.default.createElement(react_native_1.Pressable, { onPress: onDiscard, style: [styles.button, styles.buttonGhost], accessibilityRole: "button", accessibilityLabel: "Discard panorama" },
54
+ react_1.default.createElement(react_native_1.Text, { style: styles.buttonGhostText }, "\u2715 Discard")),
55
+ react_1.default.createElement(react_native_1.Pressable, { onPress: onRetry, style: [styles.button, styles.buttonNeutral], accessibilityRole: "button", accessibilityLabel: "Retry panorama" },
56
+ react_1.default.createElement(react_native_1.Text, { style: styles.buttonNeutralText }, "\u21BA Retry")),
57
+ react_1.default.createElement(react_native_1.Pressable, { onPress: onSave, style: [styles.button, styles.buttonPrimary], accessibilityRole: "button", accessibilityLabel: "Save panorama" },
58
+ react_1.default.createElement(react_native_1.Text, { style: styles.buttonPrimaryText }, "\u2713 Save"))))));
59
+ }
60
+ const styles = react_native_1.StyleSheet.create({
61
+ backdrop: {
62
+ flex: 1,
63
+ backgroundColor: 'rgba(0,0,0,0.96)',
64
+ paddingTop: 64,
65
+ paddingBottom: 32,
66
+ paddingHorizontal: 16,
67
+ },
68
+ title: {
69
+ color: '#ffffff',
70
+ fontSize: 16,
71
+ fontWeight: '600',
72
+ textAlign: 'center',
73
+ marginBottom: 12,
74
+ },
75
+ imageWrapper: {
76
+ flex: 1,
77
+ alignItems: 'center',
78
+ justifyContent: 'center',
79
+ },
80
+ image: {
81
+ width: '100%',
82
+ maxHeight: '100%',
83
+ backgroundColor: '#111',
84
+ borderRadius: 4,
85
+ },
86
+ buttonRow: {
87
+ flexDirection: 'row',
88
+ justifyContent: 'space-between',
89
+ alignItems: 'center',
90
+ marginTop: 16,
91
+ gap: 12,
92
+ },
93
+ button: {
94
+ flex: 1,
95
+ paddingVertical: 14,
96
+ borderRadius: 10,
97
+ alignItems: 'center',
98
+ justifyContent: 'center',
99
+ },
100
+ buttonGhost: {
101
+ backgroundColor: 'transparent',
102
+ borderWidth: 1,
103
+ borderColor: 'rgba(255,255,255,0.3)',
104
+ },
105
+ buttonGhostText: {
106
+ color: '#ffffff',
107
+ fontSize: 14,
108
+ fontWeight: '500',
109
+ opacity: 0.9,
110
+ },
111
+ buttonNeutral: {
112
+ backgroundColor: 'rgba(255,255,255,0.12)',
113
+ },
114
+ buttonNeutralText: {
115
+ color: '#ffffff',
116
+ fontSize: 14,
117
+ fontWeight: '600',
118
+ },
119
+ buttonPrimary: {
120
+ backgroundColor: '#34C759',
121
+ },
122
+ buttonPrimaryText: {
123
+ color: '#ffffff',
124
+ fontSize: 14,
125
+ fontWeight: '700',
126
+ },
127
+ });
128
+ //# sourceMappingURL=PanoramaConfirmModal.js.map
@@ -0,0 +1,79 @@
1
+ /**
2
+ * PanoramaGuidance — gyroscope-driven pan-speed indicator for the
3
+ * tap-and-hold panorama flow.
4
+ *
5
+ * ┌──────────────────────────────────────────────────────────┐
6
+ * │ (camera preview) │
7
+ * │ │
8
+ * │ ↓ │ ← portrait + landscape pan
9
+ * │ green / yellow / red │
10
+ * │ │
11
+ * │ "Pan slowly" / "Slow down" / "Too fast" │
12
+ * └──────────────────────────────────────────────────────────┘
13
+ *
14
+ * Why this exists
15
+ * The SCANS-mode stitcher needs ~30–50 % overlap between
16
+ * consecutive frames. At 30 fps, frames are ~33 ms apart, so
17
+ * pan rates above roughly 30°/s (≈ 0.5 rad/s) produce frames
18
+ * the stitcher can't align — and the user finds out only after
19
+ * the post-release "Stitching failed" alert. Real-time feedback
20
+ * prevents that failure mode.
21
+ *
22
+ * What it does
23
+ * - Subscribes to the device gyroscope (react-native-sensors)
24
+ * ONLY while `active` is true; tears down on inactive so the
25
+ * sensor isn't running the rest of the time the screen is up.
26
+ * - Detects portrait vs landscape from window dimensions; the
27
+ * dominant pan axis changes accordingly:
28
+ * portrait → user pans horizontally → we track gyro Y.
29
+ * landscape → user pans vertically → we track gyro X.
30
+ * - Maps the dominant axis's |rad/s| onto a colour scale and a
31
+ * human-readable hint. Defaults are tuned for SCANS but
32
+ * overrideable.
33
+ *
34
+ * Performance
35
+ * The gyroscope fires ~30 Hz. We update an Animated.Value (which
36
+ * updates the colour interpolation on the native driver) and only
37
+ * call setState when the qualitative bucket (good/warn/bad)
38
+ * changes — keeps re-render volume low.
39
+ */
40
+ import React from 'react';
41
+ import { type StyleProp, type ViewStyle } from 'react-native';
42
+ export type PanoramaSpeedBucket = 'good' | 'warn' | 'bad';
43
+ type PanAxis = 'horizontal' | 'vertical';
44
+ export interface PanoramaGuidanceProps {
45
+ /**
46
+ * Subscribe to the gyroscope only while this is true. Typically
47
+ * driven by the host's `statusPhase === 'recording'`.
48
+ */
49
+ active: boolean;
50
+ /**
51
+ * Force the pan axis instead of auto-detecting from window
52
+ * orientation. Useful for hosts that lock orientation but want
53
+ * the user to pan the orthogonal axis.
54
+ *
55
+ * Default: undefined → auto-detect ("horizontal" in portrait,
56
+ * "vertical" in landscape — matches the user's described
57
+ * "pan top-to-bottom in landscape, left-to-right in portrait").
58
+ */
59
+ axis?: PanAxis;
60
+ /**
61
+ * Rotation rates in rad/s defining the speed buckets. Defaults
62
+ * tuned for cv::Stitcher::SCANS at 30 fps with iPhone FOV ≈ 70°:
63
+ * |rate| ≤ goodMax → green ("good")
64
+ * |rate| ≤ warnMax → amber ("slow down a bit")
65
+ * else → red ("too fast")
66
+ */
67
+ goodMaxRadPerSec?: number;
68
+ warnMaxRadPerSec?: number;
69
+ /** Optional hint message overrides. */
70
+ messages?: {
71
+ good?: string;
72
+ warn?: string;
73
+ bad?: string;
74
+ };
75
+ style?: StyleProp<ViewStyle>;
76
+ }
77
+ export declare function PanoramaGuidance({ active, axis, goodMaxRadPerSec, warnMaxRadPerSec, messages, style, }: PanoramaGuidanceProps): React.JSX.Element | null;
78
+ export {};
79
+ //# sourceMappingURL=PanoramaGuidance.d.ts.map