react-native-image-stitcher 0.15.2 → 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 +124 -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 +35 -16
  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 +48 -16
  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
@@ -0,0 +1,347 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * guidanceGraphics — code-drawn replacements for the two authored guidance
4
+ * GIFs (rotate-to-landscape, pan-capture). Built from pure React-Native
5
+ * core `View` + `Animated` primitives — NO `react-native-svg`, NO bundled
6
+ * image assets — so the library keeps its "zero extra native deps for
7
+ * guidance" contract (see `RectCropPreview`) AND no longer needs the host
8
+ * to add Fresco's `animated-gif` module on Android just to make the
9
+ * coach-marks move.
10
+ *
11
+ * Why not GIFs: the authored GIFs were 280 px sources shown at 240 dp;
12
+ * on a ~2.6×-density phone that 240 dp is ~630 physical px, so the 280 px
13
+ * source was up-scaled ~2.25× → visibly pixelated. A 256-colour GIF also
14
+ * bands. These vector-ish primitives are resolution-independent (they're
15
+ * just borders + transforms the GPU rasterises at native density) and fully
16
+ * themeable via `GUIDANCE_TOKENS`.
17
+ *
18
+ * Both graphics:
19
+ * • run a single `Animated.loop` on the NATIVE driver (transform/opacity
20
+ * only) so the loop is off the JS thread;
21
+ * • take a `playing` flag — the host renders them only while `visible`,
22
+ * but we still gate the loop so a mounted-but-paused graphic costs
23
+ * nothing;
24
+ * • scale every dimension off a single `size` (defaults to the shared
25
+ * `GUIDANCE_TOKENS.graphicSize`) so callers can resize without restyle.
26
+ */
27
+
28
+ import React, { useEffect, useRef } from 'react';
29
+ import {
30
+ Animated,
31
+ Easing,
32
+ StyleSheet,
33
+ View,
34
+ type StyleProp,
35
+ type ViewStyle,
36
+ } from 'react-native';
37
+
38
+ import { GUIDANCE_TOKENS } from './guidanceTokens';
39
+
40
+
41
+ const DEFAULT_SIZE = GUIDANCE_TOKENS.graphicSize;
42
+
43
+
44
+ /** Pan direction the pan-graphic should animate (mirrors PanHowToOverlay). */
45
+ export type PanGraphicDirection = 'down' | 'right';
46
+
47
+
48
+ export interface GuidanceGraphicProps {
49
+ /** Canvas square size in px. Defaults to `GUIDANCE_TOKENS.graphicSize`. */
50
+ size?: number;
51
+ /** Run the animation loop. `false` parks the value at rest. */
52
+ playing?: boolean;
53
+ /** Outer style passthrough. */
54
+ style?: StyleProp<ViewStyle>;
55
+ }
56
+
57
+
58
+ /**
59
+ * A white rounded-rectangle "phone" outline with a small camera dot on its
60
+ * top short edge. The dot makes the device's up-axis legible, so when the
61
+ * rotate graphic turns the body the rotation reads unambiguously. Children
62
+ * (e.g. the pan sweep band) render over the screen area.
63
+ */
64
+ function PhoneBody({
65
+ width,
66
+ height,
67
+ children,
68
+ style,
69
+ }: {
70
+ width: number;
71
+ height: number;
72
+ children?: React.ReactNode;
73
+ style?: StyleProp<ViewStyle>;
74
+ }): React.JSX.Element {
75
+ const radius = Math.min(width, height) * 0.16;
76
+ const short = Math.min(width, height);
77
+ const dotSize = Math.max(4, short * 0.09);
78
+ const inset = Math.max(4, short * 0.06);
79
+ // The front-facing camera always sits on a SHORT edge: top-centre for a
80
+ // tall (portrait) body, side-centre for a wide (landscape) body. (Was
81
+ // top-centre unconditionally, which put the dot mid-LONG-edge on a
82
+ // landscape body.)
83
+ const isWide = width > height;
84
+ const dotPos: ViewStyle = isWide
85
+ ? { left: inset, top: height / 2 - dotSize / 2 }
86
+ : { top: inset, left: width / 2 - dotSize / 2 };
87
+ return (
88
+ <View
89
+ style={[
90
+ {
91
+ width,
92
+ height,
93
+ borderRadius: radius,
94
+ borderWidth: Math.max(2, width * 0.03),
95
+ borderColor: GUIDANCE_TOKENS.white,
96
+ },
97
+ styles.phoneBody,
98
+ style,
99
+ ]}
100
+ >
101
+ <View
102
+ style={[
103
+ styles.cameraDot,
104
+ { width: dotSize, height: dotSize, borderRadius: dotSize / 2 },
105
+ dotPos,
106
+ ]}
107
+ />
108
+ {children}
109
+ </View>
110
+ );
111
+ }
112
+
113
+
114
+ /**
115
+ * RotatePhoneGraphic — a portrait phone outline that rotates 0°→90°→0°
116
+ * (portrait → landscape → portrait) on a loop, riding a faint amber guide
117
+ * ring with a clockwise arrowhead, demonstrating the "rotate to landscape"
118
+ * gesture. Replaces `rotate-to-landscape.gif`.
119
+ */
120
+ export function RotatePhoneGraphic({
121
+ size = DEFAULT_SIZE,
122
+ playing = true,
123
+ style,
124
+ target = 'landscape',
125
+ }: GuidanceGraphicProps & {
126
+ /** Orientation to rotate TO: 'landscape' (default) or 'portrait'. */
127
+ target?: 'landscape' | 'portrait';
128
+ }): React.JSX.Element {
129
+ // Single 0→1 loop value drives a ONE-WAY demonstration: hold the START
130
+ // orientation, rotate to the TARGET, hold, then fade out + reset (the
131
+ // reverse rotation happens while invisible). This avoids the symmetric
132
+ // oscillation, which dwelt at the target and read as "starts at target,
133
+ // rotates away" — i.e. backwards.
134
+ const t = useRef(new Animated.Value(0)).current;
135
+
136
+ useEffect(() => {
137
+ if (!playing) {
138
+ t.setValue(0);
139
+ return;
140
+ }
141
+ const loop = Animated.loop(
142
+ Animated.timing(t, {
143
+ toValue: 1,
144
+ duration: 2200,
145
+ easing: Easing.inOut(Easing.cubic),
146
+ useNativeDriver: true,
147
+ }),
148
+ );
149
+ loop.start();
150
+ return () => loop.stop();
151
+ }, [playing, t]);
152
+
153
+ // To-landscape: start portrait (tall), rotate anticlockwise to landscape.
154
+ // To-portrait: start landscape (wide), rotate clockwise to stand upright.
155
+ const toLandscape = target === 'landscape';
156
+ const targetDeg = toLandscape ? '-90deg' : '90deg';
157
+ // Hold START (0°) → rotate to TARGET → hold TARGET.
158
+ const rotate = t.interpolate({
159
+ inputRange: [0, 0.18, 0.62, 1],
160
+ outputRange: ['0deg', '0deg', targetDeg, targetDeg],
161
+ });
162
+ // Fade in at START, hold through the rotation, fade out at TARGET so the
163
+ // invisible reset (target→start on loop) is never seen.
164
+ const phoneOpacity = t.interpolate({
165
+ inputRange: [0, 0.12, 0.82, 1],
166
+ outputRange: [0, 1, 1, 0],
167
+ });
168
+
169
+ const ring = size * 0.78;
170
+ const ringInset = (size - ring) / 2;
171
+ const phoneW = toLandscape ? size * 0.3 : size * 0.56;
172
+ const phoneH = toLandscape ? size * 0.56 : size * 0.3;
173
+
174
+ return (
175
+ <View
176
+ style={[{ width: size, height: size }, styles.center, style]}
177
+ pointerEvents="none"
178
+ >
179
+ {/* Faint full guide ring — the rotation "path" (centred behind the
180
+ phone via explicit insets; absolute views don't honour the
181
+ parent's center alignment). */}
182
+ <View
183
+ style={[
184
+ styles.ring,
185
+ {
186
+ width: ring,
187
+ height: ring,
188
+ borderRadius: ring / 2,
189
+ top: ringInset,
190
+ left: ringInset,
191
+ borderColor: GUIDANCE_TOKENS.amber,
192
+ },
193
+ ]}
194
+ />
195
+ {/* Arrowhead on the ring at top-centre, pointing along the rotation's
196
+ tangent: LEFT for anticlockwise (to-landscape), RIGHT for clockwise
197
+ (to-portrait). */}
198
+ <View
199
+ style={[
200
+ styles.arrowHead,
201
+ toLandscape ? styles.arrowHeadLeft : styles.arrowHeadRight,
202
+ { top: ringInset - 5, left: size / 2 - 5 },
203
+ ]}
204
+ />
205
+
206
+ <Animated.View
207
+ style={{ opacity: phoneOpacity, transform: [{ rotate }] }}
208
+ >
209
+ <PhoneBody width={phoneW} height={phoneH} />
210
+ </Animated.View>
211
+ </View>
212
+ );
213
+ }
214
+
215
+
216
+ /**
217
+ * PanPhoneGraphic — a phone outline (landscape for Mode-A `down`, portrait
218
+ * for Mode-B `right`) with an amber sweep band that travels across the pan
219
+ * axis on a loop, demonstrating the camera sweep. The band fades in/out at
220
+ * the travel ends so the loop reset is invisible. Replaces
221
+ * `pan-capture.gif`. The bouncing direction arrow stays in PanHowToOverlay.
222
+ */
223
+ export function PanPhoneGraphic({
224
+ direction,
225
+ size = DEFAULT_SIZE,
226
+ playing = true,
227
+ style,
228
+ }: GuidanceGraphicProps & { direction: PanGraphicDirection }): React.JSX.Element {
229
+ // One value loops 0→1; drives the phone's travel + perspective tilt
230
+ // together so the device reads as ROTATING as it sweeps along the arrow.
231
+ const t = useRef(new Animated.Value(0)).current;
232
+
233
+ useEffect(() => {
234
+ if (!playing) {
235
+ t.setValue(0);
236
+ return;
237
+ }
238
+ const loop = Animated.loop(
239
+ Animated.timing(t, {
240
+ toValue: 1,
241
+ duration: 1900,
242
+ easing: Easing.inOut(Easing.ease),
243
+ useNativeDriver: true,
244
+ }),
245
+ );
246
+ loop.start();
247
+ return () => loop.stop();
248
+ }, [playing, t, direction]);
249
+
250
+ const down = direction === 'down';
251
+ // Mode A (down) holds the phone LANDSCAPE; Mode B (right) PORTRAIT.
252
+ const phoneW = down ? size * 0.5 : size * 0.34;
253
+ const phoneH = down ? size * 0.34 : size * 0.5;
254
+
255
+ // Travel ± along the pan axis (down → +Y, right → +X), kept in-canvas.
256
+ const amp = size * 0.2;
257
+ const translate = t.interpolate({
258
+ inputRange: [0, 1],
259
+ outputRange: [-amp, amp],
260
+ });
261
+ // The device TILTS through the sweep — rotating about the cross-pan axis
262
+ // as it pans — which is the 3D "the phone is turning" read the flat
263
+ // band lacked. rotateX for a vertical (down) pan, rotateY for horizontal.
264
+ // The horizontal (right) tilt is INVERTED vs the vertical one so the edge
265
+ // on the side the phone is currently on reads LONGER (convex toward the
266
+ // viewer) — matched to on-device feedback for the portrait Mode-B pan.
267
+ const tilt = t.interpolate({
268
+ inputRange: [0, 1],
269
+ outputRange: down ? ['-24deg', '24deg'] : ['24deg', '-24deg'],
270
+ });
271
+ // Fade at the travel ends so the loop's restart is invisible.
272
+ const opacity = t.interpolate({
273
+ inputRange: [0, 0.15, 0.85, 1],
274
+ outputRange: [0, 1, 1, 0],
275
+ });
276
+
277
+ // `perspective` makes the rotateX/rotateY read as depth (a turning
278
+ // device), not a flat vertical squash.
279
+ const transform = down
280
+ ? [{ perspective: 800 }, { translateY: translate }, { rotateX: tilt }]
281
+ : [{ perspective: 800 }, { translateX: translate }, { rotateY: tilt }];
282
+
283
+ return (
284
+ <View
285
+ style={[{ width: size, height: size }, styles.center, style]}
286
+ pointerEvents="none"
287
+ >
288
+ <Animated.View style={{ opacity, transform }}>
289
+ <PhoneBody width={phoneW} height={phoneH}>
290
+ {/* Faint amber "screen" so the turning glass catches the light. */}
291
+ <View style={styles.screenGlow} pointerEvents="none" />
292
+ </PhoneBody>
293
+ </Animated.View>
294
+ </View>
295
+ );
296
+ }
297
+
298
+
299
+ const styles = StyleSheet.create({
300
+ center: { alignItems: 'center', justifyContent: 'center' },
301
+ phoneBody: {
302
+ alignItems: 'center',
303
+ justifyContent: 'center',
304
+ backgroundColor: 'transparent',
305
+ },
306
+ cameraDot: {
307
+ position: 'absolute',
308
+ backgroundColor: GUIDANCE_TOKENS.white,
309
+ },
310
+ ring: {
311
+ position: 'absolute',
312
+ borderWidth: 1.5,
313
+ opacity: 0.28,
314
+ backgroundColor: 'transparent',
315
+ },
316
+ // Amber CSS-triangle arrowhead at the top of the ring. The base props are
317
+ // shared; the direction-specific style colours the trailing border so the
318
+ // apex points along the rotation tangent.
319
+ arrowHead: {
320
+ position: 'absolute',
321
+ width: 0,
322
+ height: 0,
323
+ borderTopWidth: 6,
324
+ borderBottomWidth: 6,
325
+ borderTopColor: 'transparent',
326
+ borderBottomColor: 'transparent',
327
+ },
328
+ // Points LEFT (anticlockwise / to-landscape): RIGHT border amber.
329
+ arrowHeadLeft: {
330
+ borderRightWidth: 10,
331
+ borderRightColor: GUIDANCE_TOKENS.amber,
332
+ },
333
+ // Points RIGHT (clockwise / to-portrait): LEFT border amber.
334
+ arrowHeadRight: {
335
+ borderLeftWidth: 10,
336
+ borderLeftColor: GUIDANCE_TOKENS.amber,
337
+ },
338
+ // Faint amber fill inside the phone outline — a hint of the live
339
+ // preview so the turning device reads as a screen, not an empty frame.
340
+ screenGlow: {
341
+ width: '78%',
342
+ height: '70%',
343
+ borderRadius: 6,
344
+ backgroundColor: GUIDANCE_TOKENS.amber,
345
+ opacity: 0.14,
346
+ },
347
+ });
@@ -0,0 +1,57 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * guidanceTokens — the single source of truth for the panorama capture
4
+ * GUIDANCE visual language (rotate prompt, pan how-to, countdown, too-fast
5
+ * pill, lateral popup). Values are taken verbatim from the design handoff
6
+ * ("Camera Capture Guides") so every guidance surface shares exact styling
7
+ * instead of re-declaring colors per component.
8
+ *
9
+ * The two looping device-motion graphics are drawn programmatically (see
10
+ * ./guidanceGraphics — pure RN View + Animated, no image assets); these
11
+ * tokens cover both those graphics and the code-built chrome around them.
12
+ */
13
+
14
+ export const GUIDANCE_TOKENS = {
15
+ /** Device outline, caption text, countdown number. */
16
+ white: '#FFFFFF',
17
+ /** Rotation ring/arrow, pan guide line, dots, glow — the one accent. */
18
+ amber: '#FFC462',
19
+ /** Caption-pill / popup background scrim. */
20
+ scrim: 'rgba(0,0,0,0.42)',
21
+ /** Pill hairline border. */
22
+ hairline: 'rgba(255,255,255,0.16)',
23
+ /** On-screen size (px square) of the rotate / pan guidance graphics. */
24
+ graphicSize: 240,
25
+ } as const;
26
+
27
+ /**
28
+ * Caption-pill spec (item 2 "Rotate to landscape" + reused by the too-fast
29
+ * pill): full pill, scrim bg, hairline border, amber leading dot, white
30
+ * 13px/600 text.
31
+ */
32
+ export const GUIDANCE_PILL = {
33
+ paddingVertical: 8,
34
+ paddingHorizontal: 15,
35
+ borderRadius: 999,
36
+ dotSize: 6,
37
+ dotGap: 7,
38
+ fontSize: 13,
39
+ fontWeight: '600' as const,
40
+ } as const;
41
+
42
+ /**
43
+ * Countdown spec (item 5): amber dot + glow, white 30px/700 tabular-nums
44
+ * number, whole timer blinks opacity 0.18↔1 over a 1s ease-in-out cycle.
45
+ */
46
+ export const GUIDANCE_COUNTDOWN = {
47
+ dotSize: 9,
48
+ dotGap: 8,
49
+ dotGlow: 'rgba(255,196,98,0.85)',
50
+ fontSize: 30,
51
+ fontWeight: '700' as const,
52
+ blinkMinOpacity: 0.18,
53
+ blinkMaxOpacity: 1,
54
+ blinkPeriodMs: 1000,
55
+ /** top/left inset from the (user-perceived) corner. */
56
+ inset: 16,
57
+ } as const;
@@ -0,0 +1,81 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * panModeGate — pure decision helper for the first-time-user "rotate the
4
+ * device" gate (guidance item 1).
5
+ *
6
+ * The non-AR panorama flow has two pan directions:
7
+ *
8
+ * - **vertical** — the user holds the phone LANDSCAPE and pans the camera
9
+ * TOP → BOTTOM down a tall fixture. Both `landscape-left` and
10
+ * `landscape-right` are valid holds.
11
+ * - **horizontal** — the user holds the phone PORTRAIT and pans LEFT →
12
+ * RIGHT across a wide scene. Both portrait holds are valid.
13
+ *
14
+ * A host restricts capture via the `panMode` flag:
15
+ * - `'vertical'` → landscape-only; a PORTRAIT hold is gated (rotate to
16
+ * landscape).
17
+ * - `'horizontal'` → portrait-only; a LANDSCAPE hold is gated (rotate to
18
+ * portrait).
19
+ * - `'both'` → either; the gate never fires.
20
+ *
21
+ * When the gate fires the host must NOT start the capture — it shows the
22
+ * rotate prompt (guidance item 2, pointing at the target orientation) and
23
+ * waits for the user to rotate.
24
+ *
25
+ * This module is the single pure predicate for that decision: no React, no
26
+ * sensors, no side effects, so the gate logic is unit-testable in the node
27
+ * jest env without booting a render or mocking the accelerometer.
28
+ */
29
+
30
+ import type { DeviceOrientation } from './useDeviceOrientation';
31
+
32
+
33
+ /**
34
+ * Which device holds the panorama capture accepts.
35
+ *
36
+ * - `'vertical'` — LANDSCAPE only (top→bottom pan; the product default).
37
+ * Portrait holds are gated behind the rotate-to-landscape prompt.
38
+ * - `'horizontal'` — PORTRAIT only (left→right pan). Landscape holds are
39
+ * gated behind the rotate-to-portrait prompt.
40
+ * - `'both'` — LANDSCAPE or PORTRAIT; the gate never fires, the user
41
+ * captures in whichever hold they're already in.
42
+ */
43
+ export type PanMode = 'vertical' | 'horizontal' | 'both';
44
+
45
+
46
+ function isPortrait(orientation: DeviceOrientation): boolean {
47
+ return (
48
+ orientation === 'portrait' || orientation === 'portrait-upside-down'
49
+ );
50
+ }
51
+
52
+
53
+ /**
54
+ * True when the caller must BLOCK capture-start and show the rotate prompt
55
+ * for the current device hold:
56
+ * - `'vertical'` gates a PORTRAIT hold (needs landscape).
57
+ * - `'horizontal'` gates a LANDSCAPE hold (needs portrait).
58
+ * - `'both'` never gates.
59
+ */
60
+ export function shouldGateForPanMode(
61
+ panMode: PanMode,
62
+ orientation: DeviceOrientation,
63
+ ): boolean {
64
+ if (panMode === 'vertical') return isPortrait(orientation);
65
+ if (panMode === 'horizontal') return !isPortrait(orientation);
66
+ return false; // 'both'
67
+ }
68
+
69
+
70
+ /**
71
+ * The orientation the user must rotate TO when a hold is gated, used to pick
72
+ * the rotate prompt's copy + graphic. `'vertical'` wants landscape,
73
+ * `'horizontal'` wants portrait; `'both'` never gates so returns `null`.
74
+ */
75
+ export function gateTargetOrientation(
76
+ panMode: PanMode,
77
+ ): 'landscape' | 'portrait' | null {
78
+ if (panMode === 'vertical') return 'landscape';
79
+ if (panMode === 'horizontal') return 'portrait';
80
+ return null;
81
+ }
@@ -0,0 +1,130 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * pickCaptureFormat — choose the vision-camera format for the capture stream.
4
+ *
5
+ * Replaces a plain `useCameraFormat([{ videoResolution: 'max' }, …])`, which
6
+ * picks the device's MAX-video format and lets the PHOTO resolution ride
7
+ * along — on the iPhone 16 Pro ultra-wide that pairs a **48 MP** still
8
+ * (8064×6048) with the 4032×3024 max-video format, so a tap photo came out
9
+ * ~6000 px. vision-camera 4.x exposes each format's photo/video resolution
10
+ * but NOT its pixel format / bit-depth, so we can't filter for 8-bit; the
11
+ * empirical rule is that the device's MAX 4:3 video format is 8-bit (the
12
+ * frame processor needs 8-bit for non-AR stitching), and lower video
13
+ * resolutions risk 10-bit.
14
+ *
15
+ * Strategy: among the ~4:3 formats whose photo long-edge is within
16
+ * `maxPhotoLongEdge`, pick the one with the HIGHEST video resolution (keeps
17
+ * the preview/stitch stream as sharp as possible while bounding the still),
18
+ * tie-breaking on higher fps, then the largest photo under the cap, then
19
+ * non-HDR (a hedge toward 8-bit). If NO format fits the cap, fall back to
20
+ * the overall max-video format (never returns nothing for a non-empty list).
21
+ *
22
+ * Verified against the real iPhone 16 Pro ultra-wide format list (see the
23
+ * unit test): cap 4032 → 4032×3024 photo (12 MP) + 3264×2448 video (was
24
+ * 8064×6048 photo); cap 2048 → 2016×1512 photo (3 MP) + 1920×1440 video.
25
+ *
26
+ * Pure + structurally-typed (no vision-camera import) so it unit-tests in the
27
+ * node jest env; `CameraDeviceFormat` is structurally assignable to
28
+ * `FormatLike`.
29
+ */
30
+
31
+ /** The CameraDeviceFormat fields this picker reads. */
32
+ export interface FormatLike {
33
+ photoWidth: number;
34
+ photoHeight: number;
35
+ videoWidth: number;
36
+ videoHeight: number;
37
+ maxFps: number;
38
+ supportsVideoHdr: boolean;
39
+ }
40
+
41
+ export interface PickFormatOptions {
42
+ /**
43
+ * Cap on the chosen format's photo LONG edge, in px. The picker prefers
44
+ * the sharpest-video format whose photo fits this. `0` disables the cap
45
+ * (reverts to pure max-video). Default 4032 (≈12 MP at 4:3, "4K"-ish).
46
+ */
47
+ maxPhotoLongEdge?: number;
48
+ /** Target capture aspect (W/H in landscape). Default 4/3. */
49
+ aspect?: number;
50
+ /** Aspect match tolerance. Default 0.05. */
51
+ aspectTolerance?: number;
52
+ /**
53
+ * Prefer a SMOOTH (high-fps) preview over the sharpest video format. Off by
54
+ * default → max-video-resolution-first (back-compat). On (the panorama
55
+ * camera opts in) → rank by frame rate up to `fpsTarget` first, THEN video
56
+ * resolution. The default video-first sort picks e.g. a 3264×2448 **@30 fps**
57
+ * format over a 1920×1440 **@60 fps** one, halving the preview frame rate —
58
+ * visible as jitter while panning. The stitch clamps keyframes to 640/1280 px
59
+ * anyway, so the higher video resolution buys nothing for the panorama; a
60
+ * 60 fps stream just looks smooth.
61
+ */
62
+ preferHighFps?: boolean;
63
+ /**
64
+ * Ceiling for the fps preference when `preferHighFps` is on. Formats at or
65
+ * above this are treated as equally smooth (so resolution breaks the tie
66
+ * instead of chasing 120 fps at a lower resolution). Default 60.
67
+ */
68
+ fpsTarget?: number;
69
+ }
70
+
71
+ const DEFAULT_MAX_PHOTO_LONG_EDGE = 4032;
72
+ const DEFAULT_FPS_TARGET = 60;
73
+
74
+ const longEdge = (f: FormatLike): number =>
75
+ Math.max(f.photoWidth, f.photoHeight);
76
+ const videoPixels = (f: FormatLike): number => f.videoWidth * f.videoHeight;
77
+
78
+ /**
79
+ * Pick the best capture format, or `undefined` for an empty list.
80
+ */
81
+ export function pickCaptureFormat<F extends FormatLike>(
82
+ formats: readonly F[],
83
+ opts: PickFormatOptions = {},
84
+ ): F | undefined {
85
+ if (!formats || formats.length === 0) return undefined;
86
+
87
+ const aspect = opts.aspect ?? 4 / 3;
88
+ const tol = opts.aspectTolerance ?? 0.05;
89
+ const cap = opts.maxPhotoLongEdge ?? DEFAULT_MAX_PHOTO_LONG_EDGE;
90
+ const preferHighFps = opts.preferHighFps ?? false;
91
+ const fpsTarget = opts.fpsTarget ?? DEFAULT_FPS_TARGET;
92
+ // Treat everything at/above the target as equally smooth so resolution, not
93
+ // a chase for 120 fps, breaks the tie.
94
+ const smoothness = (f: FormatLike): number => Math.min(f.maxFps, fpsTarget);
95
+
96
+ const matchesAspect = (f: FormatLike): boolean =>
97
+ f.photoHeight > 0
98
+ && f.videoHeight > 0
99
+ && Math.abs(f.photoWidth / f.photoHeight - aspect) < tol
100
+ && Math.abs(f.videoWidth / f.videoHeight - aspect) < tol;
101
+
102
+ // Prefer 4:3 formats; if the device has none, consider all.
103
+ const fourThree = formats.filter(matchesAspect);
104
+ const base = fourThree.length > 0 ? fourThree : formats.slice();
105
+
106
+ // Among those within the photo cap; if none fit, fall back to all (which
107
+ // then resolves to the max-video format — never worse than today).
108
+ const withinCap =
109
+ cap > 0 ? base.filter((f) => longEdge(f) <= cap) : base.slice();
110
+ const candidates = withinCap.length > 0 ? withinCap : base;
111
+
112
+ return candidates.slice().sort((a, b) => {
113
+ if (preferHighFps) {
114
+ // Smooth-preview priority: frame rate (up to the target) before video
115
+ // resolution. Keeps the panorama preview at ~60 fps instead of dropping
116
+ // to a sharper-but-30fps format.
117
+ const sa = smoothness(a);
118
+ const sb = smoothness(b);
119
+ if (sb !== sa) return sb - sa;
120
+ }
121
+ const va = videoPixels(a);
122
+ const vb = videoPixels(b);
123
+ if (vb !== va) return vb - va; // highest video resolution first
124
+ if (b.maxFps !== a.maxFps) return b.maxFps - a.maxFps; // then higher fps
125
+ if (longEdge(b) !== longEdge(a)) return longEdge(b) - longEdge(a); // largest photo under cap
126
+ // Prefer non-HDR — a hedge toward an 8-bit pixel format (the stitch
127
+ // frame processor needs 8-bit; vision-camera doesn't expose bit-depth).
128
+ return (a.supportsVideoHdr ? 1 : 0) - (b.supportsVideoHdr ? 1 : 0);
129
+ })[0];
130
+ }
@@ -0,0 +1,71 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * buildStitchDebugInfo — format the stitcher's runtime stats as a compact,
4
+ * multi-line string for the __DEV__-only overlay on the output preview.
5
+ *
6
+ * The operator uses this to SEE how a panorama was built — which pipeline +
7
+ * warper ran, whether the low-memory stream/feather fallback kicked in, the
8
+ * confidence score the successful attempt used, and how many keyframes
9
+ * survived pruning. Purely presentational; never shown in release.
10
+ *
11
+ * Pure + structurally typed so it unit-tests in the node jest env.
12
+ */
13
+
14
+ export interface StitchDebugFields {
15
+ /** Native `debugSummary`: `"pipe=…;warp=…;route=…;seam=…;blend=…"`. */
16
+ debugSummary?: string;
17
+ stitchModeResolved?: 'panorama' | 'scans';
18
+ finalConfidenceThresh?: number;
19
+ framesIncluded?: number;
20
+ framesRequested?: number;
21
+ width?: number;
22
+ height?: number;
23
+ }
24
+
25
+ /**
26
+ * Build the overlay text. Returns `''` when nothing useful is present (so the
27
+ * caller can skip rendering the pill entirely). One `key: value` per line.
28
+ */
29
+ export function buildStitchDebugInfo(r: StitchDebugFields): string {
30
+ const lines: string[] = [];
31
+
32
+ // Expand the native summary ("pipe=manual;warp=spherical;…") into one
33
+ // labelled line per pair, preserving order. Malformed pairs are skipped.
34
+ if (r.debugSummary) {
35
+ for (const pair of r.debugSummary.split(';')) {
36
+ const eq = pair.indexOf('=');
37
+ if (eq <= 0) continue;
38
+ const key = pair.slice(0, eq).trim();
39
+ const value = pair.slice(eq + 1).trim();
40
+ if (key && value) lines.push(`${key}: ${value}`);
41
+ }
42
+ }
43
+
44
+ if (r.stitchModeResolved) lines.push(`mode: ${r.stitchModeResolved}`);
45
+
46
+ if (
47
+ typeof r.finalConfidenceThresh === 'number'
48
+ && r.finalConfidenceThresh >= 0
49
+ ) {
50
+ lines.push(`score: ${r.finalConfidenceThresh.toFixed(2)}`);
51
+ }
52
+
53
+ if (typeof r.framesIncluded === 'number' && r.framesIncluded >= 0) {
54
+ const req =
55
+ typeof r.framesRequested === 'number' && r.framesRequested >= 0
56
+ ? String(r.framesRequested)
57
+ : '?';
58
+ lines.push(`frames: ${r.framesIncluded}/${req}`);
59
+ }
60
+
61
+ if (
62
+ typeof r.width === 'number'
63
+ && typeof r.height === 'number'
64
+ && r.width > 0
65
+ && r.height > 0
66
+ ) {
67
+ lines.push(`size: ${r.width}×${r.height}`);
68
+ }
69
+
70
+ return lines.join('\n');
71
+ }