react-native-image-stitcher 0.15.1 → 0.16.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 (133) hide show
  1. package/CHANGELOG.md +147 -1
  2. package/README.md +116 -5
  3. package/android/src/main/cpp/image_stitcher_jni.cpp +107 -11
  4. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
  5. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +87 -30
  6. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
  8. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
  9. package/cpp/crop_quad.cpp +162 -0
  10. package/cpp/crop_quad.hpp +163 -0
  11. package/cpp/stitcher.cpp +651 -55
  12. package/cpp/stitcher.hpp +10 -0
  13. package/cpp/warp_guard.hpp +212 -0
  14. package/dist/camera/Camera.d.ts +196 -12
  15. package/dist/camera/Camera.js +629 -35
  16. package/dist/camera/CameraView.js +62 -5
  17. package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
  18. package/dist/camera/CaptureCountdownOverlay.js +239 -0
  19. package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
  20. package/dist/camera/CaptureFrameCounterOverlay.js +142 -0
  21. package/dist/camera/CaptureMemoryPill.d.ts +9 -1
  22. package/dist/camera/CaptureMemoryPill.js +3 -3
  23. package/dist/camera/CapturePreview.js +2 -1
  24. package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
  25. package/dist/camera/CaptureStatusOverlay.js +22 -5
  26. package/dist/camera/CaptureThumbnailStrip.js +2 -1
  27. package/dist/camera/LateralMotionModal.d.ts +85 -0
  28. package/dist/camera/LateralMotionModal.js +134 -0
  29. package/dist/camera/PanHowToOverlay.d.ts +76 -0
  30. package/dist/camera/PanHowToOverlay.js +222 -0
  31. package/dist/camera/PanoramaSettings.d.ts +8 -6
  32. package/dist/camera/PanoramaSettings.js +26 -5
  33. package/dist/camera/PanoramaSettingsModal.js +4 -4
  34. package/dist/camera/RectCropPreview.d.ts +161 -0
  35. package/dist/camera/RectCropPreview.js +480 -0
  36. package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
  37. package/dist/camera/RotateToLandscapePrompt.js +138 -0
  38. package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
  39. package/dist/camera/buildPanoramaInitialSettings.js +9 -0
  40. package/dist/camera/cameraErrorMessages.d.ts +30 -1
  41. package/dist/camera/cameraErrorMessages.js +26 -10
  42. package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
  43. package/dist/camera/cameraGuidanceCopy.js +80 -0
  44. package/dist/camera/captureCountdown.d.ts +52 -0
  45. package/dist/camera/captureCountdown.js +76 -0
  46. package/dist/camera/captureWarnings.d.ts +90 -0
  47. package/dist/camera/captureWarnings.js +108 -0
  48. package/dist/camera/classifyStitchError.d.ts +30 -0
  49. package/dist/camera/classifyStitchError.js +42 -0
  50. package/dist/camera/cropGeometry.d.ts +136 -0
  51. package/dist/camera/cropGeometry.js +223 -0
  52. package/dist/camera/displayDecodeImageProps.d.ts +25 -0
  53. package/dist/camera/displayDecodeImageProps.js +29 -0
  54. package/dist/camera/guidanceGraphics.d.ts +58 -0
  55. package/dist/camera/guidanceGraphics.js +280 -0
  56. package/dist/camera/guidanceTokens.d.ts +54 -0
  57. package/dist/camera/guidanceTokens.js +58 -0
  58. package/dist/camera/panModeGate.d.ts +54 -0
  59. package/dist/camera/panModeGate.js +62 -0
  60. package/dist/camera/pickCaptureFormat.d.ts +71 -0
  61. package/dist/camera/pickCaptureFormat.js +85 -0
  62. package/dist/camera/stitchDebugInfo.d.ts +27 -0
  63. package/dist/camera/stitchDebugInfo.js +55 -0
  64. package/dist/camera/usePanMotion.d.ts +250 -0
  65. package/dist/camera/usePanMotion.js +451 -0
  66. package/dist/index.d.ts +24 -3
  67. package/dist/index.js +33 -2
  68. package/dist/stitching/computeInscribedRect.d.ts +40 -0
  69. package/dist/stitching/computeInscribedRect.js +55 -0
  70. package/dist/stitching/cropQuad.d.ts +78 -0
  71. package/dist/stitching/cropQuad.js +116 -0
  72. package/dist/stitching/incremental.d.ts +45 -0
  73. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +56 -8
  74. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
  75. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
  76. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
  77. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +191 -7
  78. package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
  79. package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
  80. package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
  81. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
  82. package/package.json +5 -1
  83. package/src/camera/Camera.tsx +994 -47
  84. package/src/camera/CameraView.tsx +75 -5
  85. package/src/camera/CaptureCountdownOverlay.tsx +272 -0
  86. package/src/camera/CaptureFrameCounterOverlay.tsx +183 -0
  87. package/src/camera/CaptureMemoryPill.tsx +17 -3
  88. package/src/camera/CapturePreview.tsx +5 -0
  89. package/src/camera/CaptureStatusOverlay.tsx +35 -7
  90. package/src/camera/CaptureThumbnailStrip.tsx +4 -0
  91. package/src/camera/LateralMotionModal.tsx +199 -0
  92. package/src/camera/PanHowToOverlay.tsx +246 -0
  93. package/src/camera/PanoramaSettings.ts +34 -11
  94. package/src/camera/PanoramaSettingsModal.tsx +4 -4
  95. package/src/camera/RectCropPreview.tsx +820 -0
  96. package/src/camera/RotateToLandscapePrompt.tsx +188 -0
  97. package/src/camera/buildPanoramaInitialSettings.ts +30 -1
  98. package/src/camera/cameraErrorMessages.ts +39 -2
  99. package/src/camera/cameraGuidanceCopy.ts +145 -0
  100. package/src/camera/captureCountdown.ts +83 -0
  101. package/src/camera/captureWarnings.ts +190 -0
  102. package/src/camera/classifyStitchError.ts +68 -0
  103. package/src/camera/cropGeometry.ts +268 -0
  104. package/src/camera/displayDecodeImageProps.ts +25 -0
  105. package/src/camera/guidanceGraphics.tsx +347 -0
  106. package/src/camera/guidanceTokens.ts +57 -0
  107. package/src/camera/panModeGate.ts +81 -0
  108. package/src/camera/pickCaptureFormat.ts +130 -0
  109. package/src/camera/stitchDebugInfo.ts +71 -0
  110. package/src/camera/usePanMotion.ts +667 -0
  111. package/src/index.ts +66 -3
  112. package/src/stitching/computeInscribedRect.ts +81 -0
  113. package/src/stitching/cropQuad.ts +167 -0
  114. package/src/stitching/incremental.ts +45 -0
  115. package/cpp/tests/CMakeLists.txt +0 -104
  116. package/cpp/tests/README.md +0 -86
  117. package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
  118. package/cpp/tests/pose_test.cpp +0 -74
  119. package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
  120. package/cpp/tests/stubs/jsi/jsi.h +0 -33
  121. package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
  122. package/cpp/tests/warp_guard_test.cpp +0 -48
  123. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
  124. package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
  125. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
  126. package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
  127. package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
  128. package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
  129. package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
  130. package/src/camera/__tests__/useContentRotation.test.ts +0 -89
  131. package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
  132. package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
  133. package/src/stitching/__tests__/useStitcherWorklet.test.ts +0 -202
@@ -48,9 +48,9 @@ export type CaptureStatusPhase = 'idle' | 'recording' | 'stitching';
48
48
  export interface CaptureStatusOverlayProps {
49
49
  /**
50
50
  * Current phase. `idle` renders nothing. `recording` shows the
51
- * REC banner + red glowing border. `stitching` swaps to a neutral
52
- * "Stitching..." banner with no border (recording is over; UI
53
- * cue should de-escalate).
51
+ * REC banner + glowing border (GREEN normally, RED when `tooFast`).
52
+ * `stitching` swaps to a neutral "Stitching..." banner with no border
53
+ * (recording is over; UI cue should de-escalate).
54
54
  */
55
55
  phase: CaptureStatusPhase;
56
56
  /**
@@ -60,6 +60,13 @@ export interface CaptureStatusOverlayProps {
60
60
  * type.
61
61
  */
62
62
  recordingMessage?: string;
63
+ /**
64
+ * v0.16 — speed feedback. When `false` (default) the recording banner +
65
+ * border are GREEN ("your pace is fine"); when `true` they turn RED to
66
+ * signal the pan is too fast. This consolidates the old always-red border
67
+ * + separate amber "slow down" pill into one calm-by-default cue.
68
+ */
69
+ tooFast?: boolean;
63
70
  /**
64
71
  * Optional override for the stitching-phase message. Defaults to
65
72
  * "Stitching panorama…".
@@ -92,6 +99,7 @@ export interface CaptureStatusOverlayProps {
92
99
  export function CaptureStatusOverlay({
93
100
  phase,
94
101
  recordingMessage = 'Hold steady — pan slowly',
102
+ tooFast = false,
95
103
  stitchingMessage = 'Stitching panorama…',
96
104
  countdownMs,
97
105
  recordingStartedAt,
@@ -207,14 +215,22 @@ export function CaptureStatusOverlay({
207
215
  accessibilityLiveRegion="polite"
208
216
  >
209
217
  {showBorder ? (
210
- <View pointerEvents="none" style={styles.recordBorder} />
218
+ <View
219
+ pointerEvents="none"
220
+ style={[
221
+ styles.recordBorder,
222
+ tooFast ? styles.recordBorderTooFast : styles.recordBorderOk,
223
+ ]}
224
+ />
211
225
  ) : null}
212
226
 
213
227
  <View
214
228
  pointerEvents="none"
215
229
  style={[
216
230
  styles.banner,
217
- phase === 'recording' ? styles.bannerRecording : styles.bannerStitching,
231
+ phase === 'recording'
232
+ ? (tooFast ? styles.bannerTooFast : styles.bannerRecording)
233
+ : styles.bannerStitching,
218
234
  bannerOrientationStyle,
219
235
  ]}
220
236
  >
@@ -353,7 +369,12 @@ const styles = StyleSheet.create({
353
369
  minHeight: 36,
354
370
  },
355
371
  bannerRecording: {
356
- backgroundColor: 'rgba(255,59,48,0.92)',
372
+ // Green by default — "you're recording and your pace is fine".
373
+ backgroundColor: 'rgba(52,199,89,0.92)',
374
+ },
375
+ bannerTooFast: {
376
+ // Red only when the pan is too fast (consolidates the old amber pill).
377
+ backgroundColor: 'rgba(255,59,48,0.94)',
357
378
  },
358
379
  bannerStitching: {
359
380
  // Neutral grey while we wait for the stitcher; communicates
@@ -386,6 +407,13 @@ const styles = StyleSheet.create({
386
407
  recordBorder: {
387
408
  ...StyleSheet.absoluteFillObject,
388
409
  borderWidth: 4,
389
- borderColor: 'rgba(255,59,48,0.9)',
410
+ },
411
+ recordBorderOk: {
412
+ // Green by default (calm — the pan pace is fine).
413
+ borderColor: 'rgba(52,199,89,0.9)',
414
+ },
415
+ recordBorderTooFast: {
416
+ // Red only when too fast.
417
+ borderColor: 'rgba(255,59,48,0.95)',
390
418
  },
391
419
  });
@@ -49,6 +49,7 @@ import {
49
49
  } from 'react-native';
50
50
 
51
51
  import { CapturePreview } from './CapturePreview';
52
+ import { DISPLAY_DECODE_IMAGE_PROPS } from './displayDecodeImageProps';
52
53
 
53
54
 
54
55
  export interface CaptureThumbnailItem {
@@ -247,6 +248,9 @@ export function CaptureThumbnailStrip({
247
248
  source={{ uri: item.uri }}
248
249
  style={[styles.thumbImage, contentRotation]}
249
250
  resizeMode="cover"
251
+ // OOM fix — decode at thumbnail size, not full capture res
252
+ // (see DISPLAY_DECODE_IMAGE_PROPS for the native-heap rationale).
253
+ {...DISPLAY_DECODE_IMAGE_PROPS}
250
254
  />
251
255
  </Pressable>
252
256
  )}
@@ -0,0 +1,199 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * LateralMotionModal — informational popup shown when the SDK stops an
4
+ * in-progress capture because the user panned sideways (cross-axis /
5
+ * lateral drift) instead of holding the single sweep direction
6
+ * (Mode A: top→bottom; Mode B: left→right).
7
+ *
8
+ * ## When this modal appears
9
+ *
10
+ * This is the item-6 sibling of `OrientationDriftModal` — the "you
11
+ * moved sideways" variant. By the time the modal renders, the capture
12
+ * has ALREADY been finalized by the parent `<Camera>` (the lateral-stop
13
+ * effect calls the engine's `stop()` and keeps whatever was captured up
14
+ * to that point — there is no malformed-output risk, so the copy says
15
+ * "we stitched what you captured"). The modal exists solely to explain
16
+ * to the user what happened and how to avoid it next time; the single
17
+ * dismiss button just clears the latched lateral-stop state so the next
18
+ * capture can start fresh.
19
+ *
20
+ * ## Layer-2 host usage
21
+ *
22
+ * Hosts using `CameraView` directly (rather than the flagship
23
+ * `<Camera>`) can compose this modal with their own lateral-drift
24
+ * detector for the same finalize-and-explain UX:
25
+ *
26
+ * const lateral = useLateralDrift(captureActive);
27
+ * useEffect(() => {
28
+ * if (lateral.stopped) {
29
+ * // host finalizes capture (engine stop + keep captured output)
30
+ * finalizeCapture();
31
+ * }
32
+ * }, [lateral.stopped]);
33
+ *
34
+ * return <>
35
+ * <CameraView ... />
36
+ * <LateralMotionModal
37
+ * visible={lateral.stopped}
38
+ * onDismiss={dismissLateralModal}
39
+ * />
40
+ * </>;
41
+ *
42
+ * ## Copy
43
+ *
44
+ * `title` / `body` / `dismissLabel` default to the centralised
45
+ * `DEFAULT_GUIDANCE_COPY.lateralStop*` strings so hosts can localise or
46
+ * re-word every guidance message in one place via the `guidanceCopy`
47
+ * `<Camera>` prop; pass explicit props to override per-instance.
48
+ *
49
+ * ## Accessibility
50
+ *
51
+ * Modal `role` defaults to RN's native dialog handling. The dismiss
52
+ * button carries an `accessibilityRole='button'` + label. Body text
53
+ * uses `accessibilityRole='text'` so the guidance is read by VoiceOver
54
+ * / TalkBack.
55
+ */
56
+
57
+ import React from 'react';
58
+ import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
59
+
60
+ import { DEFAULT_GUIDANCE_COPY } from './cameraGuidanceCopy';
61
+
62
+
63
+ export interface LateralMotionModalProps {
64
+ /**
65
+ * Show / hide. In the `<Camera>` integration this is driven by the
66
+ * latched lateral-stop flag (capture already finalized when true).
67
+ */
68
+ visible: boolean;
69
+
70
+ /**
71
+ * Popup title. Defaults to
72
+ * `DEFAULT_GUIDANCE_COPY.lateralStopTitle`.
73
+ */
74
+ title?: string;
75
+
76
+ /**
77
+ * Popup body / guidance copy. Defaults to
78
+ * `DEFAULT_GUIDANCE_COPY.lateralStopBody`.
79
+ */
80
+ body?: string;
81
+
82
+ /**
83
+ * Dismiss button label. Defaults to
84
+ * `DEFAULT_GUIDANCE_COPY.lateralStopDismiss`.
85
+ */
86
+ dismissLabel?: string;
87
+
88
+ /**
89
+ * Tapped when the user dismisses. By the time the modal renders the
90
+ * capture is already finalized; this callback exists only to clear
91
+ * the latched lateral-stop state so the next capture can start fresh.
92
+ */
93
+ onDismiss: () => void;
94
+ }
95
+
96
+
97
+ export function LateralMotionModal(
98
+ props: LateralMotionModalProps,
99
+ ): React.JSX.Element {
100
+ const {
101
+ visible,
102
+ title = DEFAULT_GUIDANCE_COPY.lateralStopTitle,
103
+ body = DEFAULT_GUIDANCE_COPY.lateralStopBody,
104
+ dismissLabel = DEFAULT_GUIDANCE_COPY.lateralStopDismiss,
105
+ onDismiss,
106
+ } = props;
107
+
108
+ return (
109
+ <Modal
110
+ visible={visible}
111
+ transparent
112
+ animationType="fade"
113
+ onRequestClose={onDismiss}
114
+ accessibilityLabel="Capture finalized — moved sideways"
115
+ // v0.12.0 — see OrientationDriftModal / PanoramaSettingsModal for
116
+ // the same prop's rationale. Declaring all orientations prevents
117
+ // iOS from force-rotating the window to portrait when this modal
118
+ // opens mid-rotation, which would otherwise leave the underlying
119
+ // <Camera>'s ARSession in a stale-orientation state on dismiss.
120
+ supportedOrientations={[
121
+ 'portrait',
122
+ 'portrait-upside-down',
123
+ 'landscape-left',
124
+ 'landscape-right',
125
+ ]}
126
+ >
127
+ <View style={styles.backdrop}>
128
+ <View style={styles.card}>
129
+ <Text style={styles.title} accessibilityRole="header">
130
+ {title}
131
+ </Text>
132
+
133
+ <Text style={styles.body} accessibilityRole="text">
134
+ {body}
135
+ </Text>
136
+
137
+ <Pressable
138
+ style={({ pressed }) => [
139
+ styles.button,
140
+ pressed && styles.buttonPressed,
141
+ ]}
142
+ onPress={onDismiss}
143
+ accessibilityRole="button"
144
+ accessibilityLabel={dismissLabel}
145
+ >
146
+ <Text style={styles.buttonLabel}>{dismissLabel}</Text>
147
+ </Pressable>
148
+ </View>
149
+ </View>
150
+ </Modal>
151
+ );
152
+ }
153
+
154
+
155
+ const styles = StyleSheet.create({
156
+ backdrop: {
157
+ flex: 1,
158
+ backgroundColor: 'rgba(0, 0, 0, 0.6)',
159
+ alignItems: 'center',
160
+ justifyContent: 'center',
161
+ paddingHorizontal: 32,
162
+ },
163
+ card: {
164
+ backgroundColor: '#1c1c1e',
165
+ borderRadius: 14,
166
+ paddingHorizontal: 20,
167
+ paddingVertical: 24,
168
+ width: '100%',
169
+ maxWidth: 340,
170
+ },
171
+ title: {
172
+ color: '#fff',
173
+ fontSize: 18,
174
+ fontWeight: '600',
175
+ marginBottom: 12,
176
+ textAlign: 'center',
177
+ },
178
+ body: {
179
+ color: '#e5e5ea',
180
+ fontSize: 15,
181
+ lineHeight: 21,
182
+ textAlign: 'center',
183
+ marginBottom: 20,
184
+ },
185
+ button: {
186
+ backgroundColor: '#0a84ff',
187
+ borderRadius: 10,
188
+ paddingVertical: 12,
189
+ alignItems: 'center',
190
+ },
191
+ buttonPressed: {
192
+ backgroundColor: '#0860c0',
193
+ },
194
+ buttonLabel: {
195
+ color: '#fff',
196
+ fontSize: 17,
197
+ fontWeight: '600',
198
+ },
199
+ });
@@ -0,0 +1,246 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * PanHowToOverlay — the "how to pan" coach-mark (guidance item 3).
4
+ *
5
+ * Shown briefly at the START of a capture to teach the panning
6
+ * gesture before the live pan-speed pill (`PanoramaGuidance`) takes
7
+ * over. It pairs the code-drawn `PanPhoneGraphic` (white phone +
8
+ * sweeping amber band) with a code-built bouncing arrow so the
9
+ * direction reads instantly without any copy.
10
+ *
11
+ * ┌──────────────────────────────────────────────────────────┐
12
+ * │ │
13
+ * │ ┌───────────────┐ │
14
+ * │ │ PanPhone │ (240px graphic, the │
15
+ * │ │ Graphic │ white phone + │
16
+ * │ └───────────────┘ amber sweep) │
17
+ * │ ▼ ← amber triangle │
18
+ * │ ▼ bouncing ~12px along the │
19
+ * │ pan axis, back and forth │
20
+ * └──────────────────────────────────────────────────────────┘
21
+ *
22
+ * Direction follows the capture mode (derived from the physical
23
+ * device orientation, sensor-based — works under portrait-lock):
24
+ *
25
+ * Mode A — LANDSCAPE → pan TOP → BOTTOM → arrow points DOWN.
26
+ * Mode B — PORTRAIT → pan LEFT → RIGHT → arrow points RIGHT.
27
+ *
28
+ * Both `landscape-left` and `landscape-right` are valid Mode A.
29
+ *
30
+ * ## Visibility & timing
31
+ *
32
+ * This component is intentionally pure-presentational: the PARENT
33
+ * owns `visible` and the brief auto-fade lifecycle (mount → show →
34
+ * dismiss once recording is under way). We never self-time;
35
+ * `visible === false` renders `null` so the host can mount us
36
+ * unconditionally without layout shift.
37
+ *
38
+ * ## Upright under portrait-lock
39
+ *
40
+ * The app layout is typically portrait-locked, so when the user
41
+ * holds the device in landscape (Mode A) the JS framebuffer is NOT
42
+ * rotated. We counter-rotate the whole coach-mark with
43
+ * `useContentRotation()` (same hook the bottom controls use) so the
44
+ * graphic and arrow read upright relative to gravity. The arrow's
45
+ * bounce axis and triangle point are expressed in that upright frame
46
+ * — i.e. the user's view — so "down" / "right" mean what the user
47
+ * sees, not the layout's raw axes.
48
+ *
49
+ * ## No SVG / no extra deps
50
+ *
51
+ * The arrow is a pure CSS border-width triangle (a zero-size View
52
+ * whose thick coloured border on one edge + transparent borders on
53
+ * the adjacent edges read as a filled triangle). Bounce is a single
54
+ * `Animated.loop` on the native driver — cheap, and only running
55
+ * while `visible`.
56
+ */
57
+
58
+ import React, { useEffect, useMemo, useRef } from 'react';
59
+ import {
60
+ Animated,
61
+ Easing,
62
+ StyleSheet,
63
+ View,
64
+ type StyleProp,
65
+ type ViewStyle,
66
+ } from 'react-native';
67
+
68
+ import { PanPhoneGraphic } from './guidanceGraphics';
69
+ import { GUIDANCE_TOKENS } from './guidanceTokens';
70
+ import { useContentRotation } from './useContentRotation';
71
+ import { type DeviceOrientation } from './useDeviceOrientation';
72
+
73
+
74
+ /** Distance (px) the arrow travels along the pan axis each bounce. */
75
+ const BOUNCE_DISTANCE = 12;
76
+ /** Half-period of the bounce (out, then back) — ~700 ms each leg. */
77
+ const BOUNCE_DURATION_MS = 700;
78
+ /** Visual size of the CSS-triangle arrow (base width / height in px). */
79
+ const ARROW_SIZE = 18;
80
+
81
+
82
+ export interface PanHowToOverlayProps {
83
+ /**
84
+ * Show / hide. `false` renders `null`. The host owns the brief
85
+ * auto-fade lifecycle — this component never self-times.
86
+ */
87
+ visible: boolean;
88
+ /**
89
+ * Physical device orientation (sensor-based, from
90
+ * `useDeviceOrientation`). Selects the pan mode → arrow
91
+ * direction: landscape-* → DOWN (Mode A), portrait-* → RIGHT
92
+ * (Mode B).
93
+ */
94
+ orientation: DeviceOrientation;
95
+ /** Outer style passthrough (positioning / opacity from the host). */
96
+ style?: StyleProp<ViewStyle>;
97
+ }
98
+
99
+
100
+ type PanDirection = 'down' | 'right';
101
+
102
+
103
+ /**
104
+ * Map a physical orientation to the pan direction the user should
105
+ * sweep. Mode A (either landscape) pans top→bottom (DOWN); Mode B
106
+ * (either portrait variant) pans left→right (RIGHT). Directions are
107
+ * in the user's upright view — the content wrapper is counter-rotated
108
+ * so these read correctly under portrait-lock.
109
+ */
110
+ function directionForOrientation(orientation: DeviceOrientation): PanDirection {
111
+ switch (orientation) {
112
+ case 'landscape-left':
113
+ case 'landscape-right':
114
+ return 'down';
115
+ case 'portrait':
116
+ case 'portrait-upside-down':
117
+ default:
118
+ return 'right';
119
+ }
120
+ }
121
+
122
+
123
+ export function PanHowToOverlay({
124
+ visible,
125
+ orientation,
126
+ style,
127
+ }: PanHowToOverlayProps): React.JSX.Element | null {
128
+ // Counter-rotation so the GIF + arrow read upright relative to
129
+ // gravity even when the app is portrait-locked and the device is
130
+ // held in landscape (Mode A). Always called so hook order is
131
+ // stable across the `visible` toggle.
132
+ const contentRotation = useContentRotation();
133
+
134
+ // Single Animated value driving the bounce, 0 → 1 → 0. Native
135
+ // driver (transform-only), so the loop runs off the JS thread.
136
+ const bounce = useRef(new Animated.Value(0)).current;
137
+
138
+ const direction = directionForOrientation(orientation);
139
+
140
+ useEffect(() => {
141
+ if (!visible) {
142
+ bounce.setValue(0);
143
+ return;
144
+ }
145
+ const loop = Animated.loop(
146
+ Animated.sequence([
147
+ Animated.timing(bounce, {
148
+ toValue: 1,
149
+ duration: BOUNCE_DURATION_MS,
150
+ easing: Easing.inOut(Easing.ease),
151
+ useNativeDriver: true,
152
+ }),
153
+ Animated.timing(bounce, {
154
+ toValue: 0,
155
+ duration: BOUNCE_DURATION_MS,
156
+ easing: Easing.inOut(Easing.ease),
157
+ useNativeDriver: true,
158
+ }),
159
+ ]),
160
+ );
161
+ loop.start();
162
+ return () => loop.stop();
163
+ }, [visible, bounce]);
164
+
165
+ // Translate 0→BOUNCE_DISTANCE along the pan axis. In the upright
166
+ // (counter-rotated) frame, "down" moves +Y and "right" moves +X.
167
+ const travel = bounce.interpolate({
168
+ inputRange: [0, 1],
169
+ outputRange: [0, BOUNCE_DISTANCE],
170
+ });
171
+ const arrowTransform = useMemo<ViewStyle['transform']>(
172
+ () =>
173
+ direction === 'down'
174
+ ? [{ translateY: travel }]
175
+ : [{ translateX: travel }],
176
+ [direction, travel],
177
+ );
178
+
179
+ if (!visible) return null;
180
+
181
+ return (
182
+ <View
183
+ // box-none on the root: never intercept taps anywhere on the
184
+ // full-screen layer. The inner content is also non-interactive.
185
+ pointerEvents="none"
186
+ style={[styles.root, style]}
187
+ >
188
+ <View style={[styles.content, contentRotation]}>
189
+ {/* Code-drawn phone + sweeping band (decorative — the bouncing
190
+ arrow + parent copy convey the gesture for assistive tech). */}
191
+ <PanPhoneGraphic direction={direction} playing={visible} />
192
+ <Animated.View
193
+ style={[
194
+ styles.arrow,
195
+ direction === 'down' ? styles.arrowDown : styles.arrowRight,
196
+ { transform: arrowTransform },
197
+ ]}
198
+ />
199
+ </View>
200
+ </View>
201
+ );
202
+ }
203
+
204
+
205
+ const styles = StyleSheet.create({
206
+ root: {
207
+ ...StyleSheet.absoluteFillObject,
208
+ alignItems: 'center',
209
+ justifyContent: 'center',
210
+ },
211
+ content: {
212
+ alignItems: 'center',
213
+ justifyContent: 'center',
214
+ },
215
+ // CSS-triangle base: a zero-size box whose borders are coloured on
216
+ // one edge and transparent on the two adjacent edges, producing a
217
+ // filled triangle pointing away from the coloured edge. The
218
+ // direction-specific styles below set which edge is amber.
219
+ arrow: {
220
+ width: 0,
221
+ height: 0,
222
+ backgroundColor: 'transparent',
223
+ borderStyle: 'solid',
224
+ marginTop: 8,
225
+ },
226
+ // Triangle pointing DOWN (Mode A): left + right borders transparent,
227
+ // TOP border amber → apex at the bottom.
228
+ arrowDown: {
229
+ borderLeftWidth: ARROW_SIZE / 2,
230
+ borderRightWidth: ARROW_SIZE / 2,
231
+ borderTopWidth: ARROW_SIZE,
232
+ borderLeftColor: 'transparent',
233
+ borderRightColor: 'transparent',
234
+ borderTopColor: GUIDANCE_TOKENS.amber,
235
+ },
236
+ // Triangle pointing RIGHT (Mode B): top + bottom borders
237
+ // transparent, LEFT border amber → apex on the right.
238
+ arrowRight: {
239
+ borderTopWidth: ARROW_SIZE / 2,
240
+ borderBottomWidth: ARROW_SIZE / 2,
241
+ borderLeftWidth: ARROW_SIZE,
242
+ borderTopColor: 'transparent',
243
+ borderBottomColor: 'transparent',
244
+ borderLeftColor: GUIDANCE_TOKENS.amber,
245
+ },
246
+ });
@@ -195,11 +195,11 @@ export interface FrameSelectionSettings {
195
195
 
196
196
  /**
197
197
  * Required NEW-content fraction (0..1) for a candidate frame to
198
- * be accepted. Default 0.20 = 20% novel content per accept.
199
- * Lower = more frames accepted, larger panoramas. Higher = fewer
200
- * frames, faster captures but more conservative about coverage.
201
- * Clamped to `[0.10, 0.80]` natively
202
- * (`IncrementalStitcher.swift:962`).
198
+ * be accepted. Default 0.15 = 15% novel content per accept (v0.16;
199
+ * was 0.20). Lower = more frames accepted, denser overlap, more
200
+ * robust registration. Higher = fewer frames, faster captures but
201
+ * more conservative about coverage. Clamped to `[0.10, 0.80]`
202
+ * natively (`IncrementalStitcher.swift:962`) — 0.10 is the floor.
203
203
  */
204
204
  overlapThreshold: number;
205
205
 
@@ -210,7 +210,9 @@ export interface FrameSelectionSettings {
210
210
  * overlap threshold wasn't met — so a slow or static pan never goes
211
211
  * longer than this without a keyframe. Counts toward `maxKeyframes`
212
212
  * (the cap still finalises the capture). `0` disables it. Default
213
- * `2000` (2 s). Maps to the native gate's `setMaxKeyframeIntervalMs`.
213
+ * `1500` (1.5 s) with `maxKeyframes` 8 this bounds a static/slow
214
+ * capture to ~8×1.5 ≈ 12 s before the keyframe-count auto-finalize.
215
+ * Maps to the native gate's `setMaxKeyframeIntervalMs`.
214
216
  */
215
217
  maxKeyframeIntervalMs: number;
216
218
 
@@ -312,8 +314,21 @@ export const DEFAULT_PANORAMA_SETTINGS: PanoramaSettings = {
312
314
  captureSource: 'ar',
313
315
  debug: false,
314
316
  stitcher: {
315
- stitchMode: 'auto',
316
- warperType: 'plane',
317
+ // v0.16 — PANORAMA by default (was 'auto'). The auto-resolver's SCANS
318
+ // branch leans on double-integrated IMU translation, which is unreliable
319
+ // during rotation (gravity leakage inflates the translation estimate); in
320
+ // practice rotational pans are the common case and resolve to panorama
321
+ // anyway. Defaulting to panorama is the robust choice — host apps that
322
+ // genuinely capture flat documents/walls can still opt into 'auto' or
323
+ // 'scans' via the ⚙️ panel or the `defaultStitchMode` / `stitcher` props.
324
+ stitchMode: 'panorama',
325
+ // v0.16 — SPHERICAL by default (bounds both axes; the proven-robust wide/
326
+ // vertical-pan projection). This is now the single source of truth — the
327
+ // native side no longer hardcodes a warper, so the ⚙️ panel + the host's
328
+ // `defaultWarper` prop actually take effect. Note: choosing `plane` here
329
+ // re-arms the dynamic plane→spherical fallback/divergence switch in the
330
+ // manual pipeline (it only fires when warperType != spherical).
331
+ warperType: 'spherical',
317
332
  blenderType: 'multiband',
318
333
  seamFinderType: 'graphcut',
319
334
  // v0.15 — inscribed-rect crop is OFF by default (bbox crop keeps all
@@ -324,9 +339,17 @@ export const DEFAULT_PANORAMA_SETTINGS: PanoramaSettings = {
324
339
  },
325
340
  frameSelection: {
326
341
  mode: 'flow-based',
327
- maxKeyframes: 6,
328
- overlapThreshold: 0.20,
329
- maxKeyframeIntervalMs: 2000,
342
+ // v0.16 — denser keyframes by default: a 15% novelty gate, up to 8 frames,
343
+ // plus a 1.5 s time-budget force-accept (so a slow/static pan still lands a
344
+ // keyframe every 1.5 s even when novelty is low). With 8 frames this bounds
345
+ // a static/slow capture to ~8×1.5 ≈ 12 s before the keyframe-count
346
+ // auto-finalize. More overlap between consecutive keyframes ⇒ stronger
347
+ // feature matching ⇒ more robust registration. Memory-checked: 8 frames fit
348
+ // the BATCH held-set cap on both platforms. Overlap selectable in the
349
+ // settings panel {10,15,20,30}% (native clamp floor 10%); cap clamps [3,10].
350
+ maxKeyframes: 8,
351
+ overlapThreshold: 0.15,
352
+ maxKeyframeIntervalMs: 1500,
330
353
  flow: DEFAULT_FLOW_GATE_SETTINGS,
331
354
  },
332
355
  };
@@ -253,16 +253,16 @@ export function PanoramaSettingsModal({
253
253
  onChange={(v) => updateFrameSelection({
254
254
  maxKeyframes: parseInt(v, 10),
255
255
  })}
256
- caption="Hard cap on accepted keyframes; native clamps to [3, 10]. 6 (default) matches Samsung Pano's behaviour and is the sweet spot for cv::Stitcher BA convergence."
256
+ caption="Hard cap on accepted keyframes; native clamps to [3, 10]. 8 (default) is the sweet spot for cv::detail BA convergence while giving the 15%-overlap + 1 s time gate room to land frames."
257
257
  />
258
258
  <SectionHeader title="Overlap threshold (new content per keyframe)" />
259
259
  <SegmentedControl
260
- options={['20%', '30%', '40%', '50%', '60%']}
260
+ options={['10%', '15%', '20%', '30%']}
261
261
  value={`${Math.round(settings.frameSelection.overlapThreshold * 100)}%`}
262
262
  onChange={(v) => updateFrameSelection({
263
263
  overlapThreshold: parseInt(v, 10) / 100,
264
264
  })}
265
- caption="Required NEW-content fraction. 20% (default): generous, ~56 keyframes for a 90° pan. Native clamps to [10%, 80%]."
265
+ caption="Required NEW-content fraction (lower = denser keyframes, more overlap). 15% (default): ~79 keyframes for a 90° pan. 10% is the native clamp floor."
266
266
  />
267
267
  <SectionHeader title="Keyframe interval (time-budget force-accept)" />
268
268
  <SegmentedControl
@@ -275,7 +275,7 @@ export function PanoramaSettingsModal({
275
275
  onChange={(v) => updateFrameSelection({
276
276
  maxKeyframeIntervalMs: v === 'off' ? 0 : parseInt(v, 10) * 1000,
277
277
  })}
278
- caption="Force-accept a keyframe at least this often even if novelty is low, so slow / static pans don't leave gaps. Counts toward the keyframe cap. off = disabled. 2s (default). Applies to AR + non-AR."
278
+ caption="Force-accept a keyframe at least this often even if novelty is low, so slow / static pans don't leave gaps. Counts toward the keyframe cap. off = disabled. 1s (default). Applies to AR + non-AR."
279
279
  />
280
280
 
281
281
  {showFlowTunables && (