react-native-image-stitcher 0.15.2 → 0.16.1

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 (146) hide show
  1. package/CHANGELOG.md +171 -1
  2. package/README.md +131 -5
  3. package/android/src/main/cpp/image_stitcher_jni.cpp +154 -13
  4. package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
  5. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
  6. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +220 -87
  7. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
  8. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
  9. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
  10. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
  11. package/cpp/crop_quad.cpp +162 -0
  12. package/cpp/crop_quad.hpp +163 -0
  13. package/cpp/keyframe_gate.cpp +54 -15
  14. package/cpp/keyframe_gate.hpp +33 -0
  15. package/cpp/stitcher.cpp +1122 -132
  16. package/cpp/stitcher.hpp +62 -0
  17. package/cpp/warp_guard.hpp +212 -0
  18. package/dist/camera/Camera.d.ts +209 -12
  19. package/dist/camera/Camera.js +575 -36
  20. package/dist/camera/CameraView.js +35 -16
  21. package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
  22. package/dist/camera/CaptureCountdownOverlay.js +239 -0
  23. package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
  24. package/dist/camera/CaptureFrameCounterOverlay.js +153 -0
  25. package/dist/camera/CaptureMemoryPill.d.ts +24 -8
  26. package/dist/camera/CaptureMemoryPill.js +37 -12
  27. package/dist/camera/CapturePreview.js +2 -1
  28. package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
  29. package/dist/camera/CaptureStatusOverlay.js +22 -5
  30. package/dist/camera/CaptureThumbnailStrip.js +2 -1
  31. package/dist/camera/LateralMotionModal.d.ts +85 -0
  32. package/dist/camera/LateralMotionModal.js +134 -0
  33. package/dist/camera/PanHowToOverlay.d.ts +76 -0
  34. package/dist/camera/PanHowToOverlay.js +222 -0
  35. package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
  36. package/dist/camera/PanoramaBandOverlay.js +9 -3
  37. package/dist/camera/PanoramaSettings.d.ts +8 -6
  38. package/dist/camera/PanoramaSettings.js +19 -1
  39. package/dist/camera/PanoramaSettingsModal.js +4 -4
  40. package/dist/camera/RectCropPreview.d.ts +135 -0
  41. package/dist/camera/RectCropPreview.js +370 -0
  42. package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
  43. package/dist/camera/RotateToLandscapePrompt.js +138 -0
  44. package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
  45. package/dist/camera/buildPanoramaInitialSettings.js +9 -0
  46. package/dist/camera/cameraErrorMessages.d.ts +30 -1
  47. package/dist/camera/cameraErrorMessages.js +26 -10
  48. package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
  49. package/dist/camera/cameraGuidanceCopy.js +80 -0
  50. package/dist/camera/captureCountdown.d.ts +52 -0
  51. package/dist/camera/captureCountdown.js +76 -0
  52. package/dist/camera/captureWarnings.d.ts +90 -0
  53. package/dist/camera/captureWarnings.js +108 -0
  54. package/dist/camera/classifyStitchError.d.ts +30 -0
  55. package/dist/camera/classifyStitchError.js +42 -0
  56. package/dist/camera/cropGeometry.d.ts +136 -0
  57. package/dist/camera/cropGeometry.js +223 -0
  58. package/dist/camera/displayDecodeImageProps.d.ts +25 -0
  59. package/dist/camera/displayDecodeImageProps.js +29 -0
  60. package/dist/camera/guidanceGraphics.d.ts +58 -0
  61. package/dist/camera/guidanceGraphics.js +280 -0
  62. package/dist/camera/guidanceTokens.d.ts +54 -0
  63. package/dist/camera/guidanceTokens.js +58 -0
  64. package/dist/camera/panModeGate.d.ts +54 -0
  65. package/dist/camera/panModeGate.js +62 -0
  66. package/dist/camera/pickCaptureFormat.d.ts +71 -0
  67. package/dist/camera/pickCaptureFormat.js +85 -0
  68. package/dist/camera/stitchDebugInfo.d.ts +27 -0
  69. package/dist/camera/stitchDebugInfo.js +55 -0
  70. package/dist/camera/usePanMotion.d.ts +250 -0
  71. package/dist/camera/usePanMotion.js +451 -0
  72. package/dist/index.d.ts +24 -3
  73. package/dist/index.js +33 -2
  74. package/dist/stitching/computeInscribedRect.d.ts +40 -0
  75. package/dist/stitching/computeInscribedRect.js +55 -0
  76. package/dist/stitching/cropQuad.d.ts +78 -0
  77. package/dist/stitching/cropQuad.js +116 -0
  78. package/dist/stitching/incremental.d.ts +74 -0
  79. package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
  80. package/dist/stitching/useIncrementalStitcher.js +7 -1
  81. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +154 -29
  82. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
  83. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
  84. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
  85. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
  86. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
  87. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +211 -7
  88. package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
  89. package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
  90. package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
  91. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
  92. package/package.json +5 -1
  93. package/src/camera/Camera.tsx +945 -47
  94. package/src/camera/CameraView.tsx +48 -16
  95. package/src/camera/CaptureCountdownOverlay.tsx +272 -0
  96. package/src/camera/CaptureFrameCounterOverlay.tsx +197 -0
  97. package/src/camera/CaptureMemoryPill.tsx +50 -12
  98. package/src/camera/CapturePreview.tsx +5 -0
  99. package/src/camera/CaptureStatusOverlay.tsx +35 -7
  100. package/src/camera/CaptureThumbnailStrip.tsx +4 -0
  101. package/src/camera/LateralMotionModal.tsx +199 -0
  102. package/src/camera/PanHowToOverlay.tsx +246 -0
  103. package/src/camera/PanoramaBandOverlay.tsx +9 -1
  104. package/src/camera/PanoramaSettings.ts +27 -7
  105. package/src/camera/PanoramaSettingsModal.tsx +4 -4
  106. package/src/camera/RectCropPreview.tsx +638 -0
  107. package/src/camera/RotateToLandscapePrompt.tsx +188 -0
  108. package/src/camera/buildPanoramaInitialSettings.ts +30 -1
  109. package/src/camera/cameraErrorMessages.ts +39 -2
  110. package/src/camera/cameraGuidanceCopy.ts +145 -0
  111. package/src/camera/captureCountdown.ts +83 -0
  112. package/src/camera/captureWarnings.ts +190 -0
  113. package/src/camera/classifyStitchError.ts +68 -0
  114. package/src/camera/cropGeometry.ts +268 -0
  115. package/src/camera/displayDecodeImageProps.ts +25 -0
  116. package/src/camera/guidanceGraphics.tsx +347 -0
  117. package/src/camera/guidanceTokens.ts +57 -0
  118. package/src/camera/panModeGate.ts +81 -0
  119. package/src/camera/pickCaptureFormat.ts +130 -0
  120. package/src/camera/stitchDebugInfo.ts +71 -0
  121. package/src/camera/usePanMotion.ts +667 -0
  122. package/src/index.ts +66 -3
  123. package/src/stitching/computeInscribedRect.ts +81 -0
  124. package/src/stitching/cropQuad.ts +167 -0
  125. package/src/stitching/incremental.ts +74 -0
  126. package/src/stitching/useIncrementalStitcher.ts +13 -0
  127. package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +0 -100
  128. package/cpp/tests/CMakeLists.txt +0 -104
  129. package/cpp/tests/README.md +0 -86
  130. package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
  131. package/cpp/tests/pose_test.cpp +0 -74
  132. package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
  133. package/cpp/tests/stubs/jsi/jsi.h +0 -33
  134. package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
  135. package/cpp/tests/warp_guard_test.cpp +0 -48
  136. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
  137. package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
  138. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
  139. package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
  140. package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
  141. package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
  142. package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
  143. package/src/camera/__tests__/useContentRotation.test.ts +0 -89
  144. package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
  145. package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
  146. package/src/stitching/__tests__/useStitcherWorklet.test.ts +0 -202
@@ -0,0 +1,280 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * guidanceGraphics — code-drawn replacements for the two authored guidance
5
+ * GIFs (rotate-to-landscape, pan-capture). Built from pure React-Native
6
+ * core `View` + `Animated` primitives — NO `react-native-svg`, NO bundled
7
+ * image assets — so the library keeps its "zero extra native deps for
8
+ * guidance" contract (see `RectCropPreview`) AND no longer needs the host
9
+ * to add Fresco's `animated-gif` module on Android just to make the
10
+ * coach-marks move.
11
+ *
12
+ * Why not GIFs: the authored GIFs were 280 px sources shown at 240 dp;
13
+ * on a ~2.6×-density phone that 240 dp is ~630 physical px, so the 280 px
14
+ * source was up-scaled ~2.25× → visibly pixelated. A 256-colour GIF also
15
+ * bands. These vector-ish primitives are resolution-independent (they're
16
+ * just borders + transforms the GPU rasterises at native density) and fully
17
+ * themeable via `GUIDANCE_TOKENS`.
18
+ *
19
+ * Both graphics:
20
+ * • run a single `Animated.loop` on the NATIVE driver (transform/opacity
21
+ * only) so the loop is off the JS thread;
22
+ * • take a `playing` flag — the host renders them only while `visible`,
23
+ * but we still gate the loop so a mounted-but-paused graphic costs
24
+ * nothing;
25
+ * • scale every dimension off a single `size` (defaults to the shared
26
+ * `GUIDANCE_TOKENS.graphicSize`) so callers can resize without restyle.
27
+ */
28
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
29
+ if (k2 === undefined) k2 = k;
30
+ var desc = Object.getOwnPropertyDescriptor(m, k);
31
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
32
+ desc = { enumerable: true, get: function() { return m[k]; } };
33
+ }
34
+ Object.defineProperty(o, k2, desc);
35
+ }) : (function(o, m, k, k2) {
36
+ if (k2 === undefined) k2 = k;
37
+ o[k2] = m[k];
38
+ }));
39
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
40
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
41
+ }) : function(o, v) {
42
+ o["default"] = v;
43
+ });
44
+ var __importStar = (this && this.__importStar) || (function () {
45
+ var ownKeys = function(o) {
46
+ ownKeys = Object.getOwnPropertyNames || function (o) {
47
+ var ar = [];
48
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
49
+ return ar;
50
+ };
51
+ return ownKeys(o);
52
+ };
53
+ return function (mod) {
54
+ if (mod && mod.__esModule) return mod;
55
+ var result = {};
56
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
57
+ __setModuleDefault(result, mod);
58
+ return result;
59
+ };
60
+ })();
61
+ Object.defineProperty(exports, "__esModule", { value: true });
62
+ exports.RotatePhoneGraphic = RotatePhoneGraphic;
63
+ exports.PanPhoneGraphic = PanPhoneGraphic;
64
+ const react_1 = __importStar(require("react"));
65
+ const react_native_1 = require("react-native");
66
+ const guidanceTokens_1 = require("./guidanceTokens");
67
+ const DEFAULT_SIZE = guidanceTokens_1.GUIDANCE_TOKENS.graphicSize;
68
+ /**
69
+ * A white rounded-rectangle "phone" outline with a small camera dot on its
70
+ * top short edge. The dot makes the device's up-axis legible, so when the
71
+ * rotate graphic turns the body the rotation reads unambiguously. Children
72
+ * (e.g. the pan sweep band) render over the screen area.
73
+ */
74
+ function PhoneBody({ width, height, children, style, }) {
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 = isWide
85
+ ? { left: inset, top: height / 2 - dotSize / 2 }
86
+ : { top: inset, left: width / 2 - dotSize / 2 };
87
+ return (react_1.default.createElement(react_native_1.View, { style: [
88
+ {
89
+ width,
90
+ height,
91
+ borderRadius: radius,
92
+ borderWidth: Math.max(2, width * 0.03),
93
+ borderColor: guidanceTokens_1.GUIDANCE_TOKENS.white,
94
+ },
95
+ styles.phoneBody,
96
+ style,
97
+ ] },
98
+ react_1.default.createElement(react_native_1.View, { style: [
99
+ styles.cameraDot,
100
+ { width: dotSize, height: dotSize, borderRadius: dotSize / 2 },
101
+ dotPos,
102
+ ] }),
103
+ children));
104
+ }
105
+ /**
106
+ * RotatePhoneGraphic — a portrait phone outline that rotates 0°→90°→0°
107
+ * (portrait → landscape → portrait) on a loop, riding a faint amber guide
108
+ * ring with a clockwise arrowhead, demonstrating the "rotate to landscape"
109
+ * gesture. Replaces `rotate-to-landscape.gif`.
110
+ */
111
+ function RotatePhoneGraphic({ size = DEFAULT_SIZE, playing = true, style, target = 'landscape', }) {
112
+ // Single 0→1 loop value drives a ONE-WAY demonstration: hold the START
113
+ // orientation, rotate to the TARGET, hold, then fade out + reset (the
114
+ // reverse rotation happens while invisible). This avoids the symmetric
115
+ // oscillation, which dwelt at the target and read as "starts at target,
116
+ // rotates away" — i.e. backwards.
117
+ const t = (0, react_1.useRef)(new react_native_1.Animated.Value(0)).current;
118
+ (0, react_1.useEffect)(() => {
119
+ if (!playing) {
120
+ t.setValue(0);
121
+ return;
122
+ }
123
+ const loop = react_native_1.Animated.loop(react_native_1.Animated.timing(t, {
124
+ toValue: 1,
125
+ duration: 2200,
126
+ easing: react_native_1.Easing.inOut(react_native_1.Easing.cubic),
127
+ useNativeDriver: true,
128
+ }));
129
+ loop.start();
130
+ return () => loop.stop();
131
+ }, [playing, t]);
132
+ // To-landscape: start portrait (tall), rotate anticlockwise to landscape.
133
+ // To-portrait: start landscape (wide), rotate clockwise to stand upright.
134
+ const toLandscape = target === 'landscape';
135
+ const targetDeg = toLandscape ? '-90deg' : '90deg';
136
+ // Hold START (0°) → rotate to TARGET → hold TARGET.
137
+ const rotate = t.interpolate({
138
+ inputRange: [0, 0.18, 0.62, 1],
139
+ outputRange: ['0deg', '0deg', targetDeg, targetDeg],
140
+ });
141
+ // Fade in at START, hold through the rotation, fade out at TARGET so the
142
+ // invisible reset (target→start on loop) is never seen.
143
+ const phoneOpacity = t.interpolate({
144
+ inputRange: [0, 0.12, 0.82, 1],
145
+ outputRange: [0, 1, 1, 0],
146
+ });
147
+ const ring = size * 0.78;
148
+ const ringInset = (size - ring) / 2;
149
+ const phoneW = toLandscape ? size * 0.3 : size * 0.56;
150
+ const phoneH = toLandscape ? size * 0.56 : size * 0.3;
151
+ return (react_1.default.createElement(react_native_1.View, { style: [{ width: size, height: size }, styles.center, style], pointerEvents: "none" },
152
+ react_1.default.createElement(react_native_1.View, { style: [
153
+ styles.ring,
154
+ {
155
+ width: ring,
156
+ height: ring,
157
+ borderRadius: ring / 2,
158
+ top: ringInset,
159
+ left: ringInset,
160
+ borderColor: guidanceTokens_1.GUIDANCE_TOKENS.amber,
161
+ },
162
+ ] }),
163
+ react_1.default.createElement(react_native_1.View, { style: [
164
+ styles.arrowHead,
165
+ toLandscape ? styles.arrowHeadLeft : styles.arrowHeadRight,
166
+ { top: ringInset - 5, left: size / 2 - 5 },
167
+ ] }),
168
+ react_1.default.createElement(react_native_1.Animated.View, { style: { opacity: phoneOpacity, transform: [{ rotate }] } },
169
+ react_1.default.createElement(PhoneBody, { width: phoneW, height: phoneH }))));
170
+ }
171
+ /**
172
+ * PanPhoneGraphic — a phone outline (landscape for Mode-A `down`, portrait
173
+ * for Mode-B `right`) with an amber sweep band that travels across the pan
174
+ * axis on a loop, demonstrating the camera sweep. The band fades in/out at
175
+ * the travel ends so the loop reset is invisible. Replaces
176
+ * `pan-capture.gif`. The bouncing direction arrow stays in PanHowToOverlay.
177
+ */
178
+ function PanPhoneGraphic({ direction, size = DEFAULT_SIZE, playing = true, style, }) {
179
+ // One value loops 0→1; drives the phone's travel + perspective tilt
180
+ // together so the device reads as ROTATING as it sweeps along the arrow.
181
+ const t = (0, react_1.useRef)(new react_native_1.Animated.Value(0)).current;
182
+ (0, react_1.useEffect)(() => {
183
+ if (!playing) {
184
+ t.setValue(0);
185
+ return;
186
+ }
187
+ const loop = react_native_1.Animated.loop(react_native_1.Animated.timing(t, {
188
+ toValue: 1,
189
+ duration: 1900,
190
+ easing: react_native_1.Easing.inOut(react_native_1.Easing.ease),
191
+ useNativeDriver: true,
192
+ }));
193
+ loop.start();
194
+ return () => loop.stop();
195
+ }, [playing, t, direction]);
196
+ const down = direction === 'down';
197
+ // Mode A (down) holds the phone LANDSCAPE; Mode B (right) PORTRAIT.
198
+ const phoneW = down ? size * 0.5 : size * 0.34;
199
+ const phoneH = down ? size * 0.34 : size * 0.5;
200
+ // Travel ± along the pan axis (down → +Y, right → +X), kept in-canvas.
201
+ const amp = size * 0.2;
202
+ const translate = t.interpolate({
203
+ inputRange: [0, 1],
204
+ outputRange: [-amp, amp],
205
+ });
206
+ // The device TILTS through the sweep — rotating about the cross-pan axis
207
+ // as it pans — which is the 3D "the phone is turning" read the flat
208
+ // band lacked. rotateX for a vertical (down) pan, rotateY for horizontal.
209
+ // The horizontal (right) tilt is INVERTED vs the vertical one so the edge
210
+ // on the side the phone is currently on reads LONGER (convex toward the
211
+ // viewer) — matched to on-device feedback for the portrait Mode-B pan.
212
+ const tilt = t.interpolate({
213
+ inputRange: [0, 1],
214
+ outputRange: down ? ['-24deg', '24deg'] : ['24deg', '-24deg'],
215
+ });
216
+ // Fade at the travel ends so the loop's restart is invisible.
217
+ const opacity = t.interpolate({
218
+ inputRange: [0, 0.15, 0.85, 1],
219
+ outputRange: [0, 1, 1, 0],
220
+ });
221
+ // `perspective` makes the rotateX/rotateY read as depth (a turning
222
+ // device), not a flat vertical squash.
223
+ const transform = down
224
+ ? [{ perspective: 800 }, { translateY: translate }, { rotateX: tilt }]
225
+ : [{ perspective: 800 }, { translateX: translate }, { rotateY: tilt }];
226
+ return (react_1.default.createElement(react_native_1.View, { style: [{ width: size, height: size }, styles.center, style], pointerEvents: "none" },
227
+ react_1.default.createElement(react_native_1.Animated.View, { style: { opacity, transform } },
228
+ react_1.default.createElement(PhoneBody, { width: phoneW, height: phoneH },
229
+ react_1.default.createElement(react_native_1.View, { style: styles.screenGlow, pointerEvents: "none" })))));
230
+ }
231
+ const styles = react_native_1.StyleSheet.create({
232
+ center: { alignItems: 'center', justifyContent: 'center' },
233
+ phoneBody: {
234
+ alignItems: 'center',
235
+ justifyContent: 'center',
236
+ backgroundColor: 'transparent',
237
+ },
238
+ cameraDot: {
239
+ position: 'absolute',
240
+ backgroundColor: guidanceTokens_1.GUIDANCE_TOKENS.white,
241
+ },
242
+ ring: {
243
+ position: 'absolute',
244
+ borderWidth: 1.5,
245
+ opacity: 0.28,
246
+ backgroundColor: 'transparent',
247
+ },
248
+ // Amber CSS-triangle arrowhead at the top of the ring. The base props are
249
+ // shared; the direction-specific style colours the trailing border so the
250
+ // apex points along the rotation tangent.
251
+ arrowHead: {
252
+ position: 'absolute',
253
+ width: 0,
254
+ height: 0,
255
+ borderTopWidth: 6,
256
+ borderBottomWidth: 6,
257
+ borderTopColor: 'transparent',
258
+ borderBottomColor: 'transparent',
259
+ },
260
+ // Points LEFT (anticlockwise / to-landscape): RIGHT border amber.
261
+ arrowHeadLeft: {
262
+ borderRightWidth: 10,
263
+ borderRightColor: guidanceTokens_1.GUIDANCE_TOKENS.amber,
264
+ },
265
+ // Points RIGHT (clockwise / to-portrait): LEFT border amber.
266
+ arrowHeadRight: {
267
+ borderLeftWidth: 10,
268
+ borderLeftColor: guidanceTokens_1.GUIDANCE_TOKENS.amber,
269
+ },
270
+ // Faint amber fill inside the phone outline — a hint of the live
271
+ // preview so the turning device reads as a screen, not an empty frame.
272
+ screenGlow: {
273
+ width: '78%',
274
+ height: '70%',
275
+ borderRadius: 6,
276
+ backgroundColor: guidanceTokens_1.GUIDANCE_TOKENS.amber,
277
+ opacity: 0.14,
278
+ },
279
+ });
280
+ //# sourceMappingURL=guidanceGraphics.js.map
@@ -0,0 +1,54 @@
1
+ /**
2
+ * guidanceTokens — the single source of truth for the panorama capture
3
+ * GUIDANCE visual language (rotate prompt, pan how-to, countdown, too-fast
4
+ * pill, lateral popup). Values are taken verbatim from the design handoff
5
+ * ("Camera Capture Guides") so every guidance surface shares exact styling
6
+ * instead of re-declaring colors per component.
7
+ *
8
+ * The two looping device-motion graphics are drawn programmatically (see
9
+ * ./guidanceGraphics — pure RN View + Animated, no image assets); these
10
+ * tokens cover both those graphics and the code-built chrome around them.
11
+ */
12
+ export declare const GUIDANCE_TOKENS: {
13
+ /** Device outline, caption text, countdown number. */
14
+ readonly white: "#FFFFFF";
15
+ /** Rotation ring/arrow, pan guide line, dots, glow — the one accent. */
16
+ readonly amber: "#FFC462";
17
+ /** Caption-pill / popup background scrim. */
18
+ readonly scrim: "rgba(0,0,0,0.42)";
19
+ /** Pill hairline border. */
20
+ readonly hairline: "rgba(255,255,255,0.16)";
21
+ /** On-screen size (px square) of the rotate / pan guidance graphics. */
22
+ readonly graphicSize: 240;
23
+ };
24
+ /**
25
+ * Caption-pill spec (item 2 "Rotate to landscape" + reused by the too-fast
26
+ * pill): full pill, scrim bg, hairline border, amber leading dot, white
27
+ * 13px/600 text.
28
+ */
29
+ export declare const GUIDANCE_PILL: {
30
+ readonly paddingVertical: 8;
31
+ readonly paddingHorizontal: 15;
32
+ readonly borderRadius: 999;
33
+ readonly dotSize: 6;
34
+ readonly dotGap: 7;
35
+ readonly fontSize: 13;
36
+ readonly fontWeight: "600";
37
+ };
38
+ /**
39
+ * Countdown spec (item 5): amber dot + glow, white 30px/700 tabular-nums
40
+ * number, whole timer blinks opacity 0.18↔1 over a 1s ease-in-out cycle.
41
+ */
42
+ export declare const GUIDANCE_COUNTDOWN: {
43
+ readonly dotSize: 9;
44
+ readonly dotGap: 8;
45
+ readonly dotGlow: "rgba(255,196,98,0.85)";
46
+ readonly fontSize: 30;
47
+ readonly fontWeight: "700";
48
+ readonly blinkMinOpacity: 0.18;
49
+ readonly blinkMaxOpacity: 1;
50
+ readonly blinkPeriodMs: 1000;
51
+ /** top/left inset from the (user-perceived) corner. */
52
+ readonly inset: 16;
53
+ };
54
+ //# sourceMappingURL=guidanceTokens.d.ts.map
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * guidanceTokens — the single source of truth for the panorama capture
5
+ * GUIDANCE visual language (rotate prompt, pan how-to, countdown, too-fast
6
+ * pill, lateral popup). Values are taken verbatim from the design handoff
7
+ * ("Camera Capture Guides") so every guidance surface shares exact styling
8
+ * instead of re-declaring colors per component.
9
+ *
10
+ * The two looping device-motion graphics are drawn programmatically (see
11
+ * ./guidanceGraphics — pure RN View + Animated, no image assets); these
12
+ * tokens cover both those graphics and the code-built chrome around them.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.GUIDANCE_COUNTDOWN = exports.GUIDANCE_PILL = exports.GUIDANCE_TOKENS = void 0;
16
+ exports.GUIDANCE_TOKENS = {
17
+ /** Device outline, caption text, countdown number. */
18
+ white: '#FFFFFF',
19
+ /** Rotation ring/arrow, pan guide line, dots, glow — the one accent. */
20
+ amber: '#FFC462',
21
+ /** Caption-pill / popup background scrim. */
22
+ scrim: 'rgba(0,0,0,0.42)',
23
+ /** Pill hairline border. */
24
+ hairline: 'rgba(255,255,255,0.16)',
25
+ /** On-screen size (px square) of the rotate / pan guidance graphics. */
26
+ graphicSize: 240,
27
+ };
28
+ /**
29
+ * Caption-pill spec (item 2 "Rotate to landscape" + reused by the too-fast
30
+ * pill): full pill, scrim bg, hairline border, amber leading dot, white
31
+ * 13px/600 text.
32
+ */
33
+ exports.GUIDANCE_PILL = {
34
+ paddingVertical: 8,
35
+ paddingHorizontal: 15,
36
+ borderRadius: 999,
37
+ dotSize: 6,
38
+ dotGap: 7,
39
+ fontSize: 13,
40
+ fontWeight: '600',
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
+ exports.GUIDANCE_COUNTDOWN = {
47
+ dotSize: 9,
48
+ dotGap: 8,
49
+ dotGlow: 'rgba(255,196,98,0.85)',
50
+ fontSize: 30,
51
+ fontWeight: '700',
52
+ blinkMinOpacity: 0.18,
53
+ blinkMaxOpacity: 1,
54
+ blinkPeriodMs: 1000,
55
+ /** top/left inset from the (user-perceived) corner. */
56
+ inset: 16,
57
+ };
58
+ //# sourceMappingURL=guidanceTokens.js.map
@@ -0,0 +1,54 @@
1
+ /**
2
+ * panModeGate — pure decision helper for the first-time-user "rotate the
3
+ * device" gate (guidance item 1).
4
+ *
5
+ * The non-AR panorama flow has two pan directions:
6
+ *
7
+ * - **vertical** — the user holds the phone LANDSCAPE and pans the camera
8
+ * TOP → BOTTOM down a tall fixture. Both `landscape-left` and
9
+ * `landscape-right` are valid holds.
10
+ * - **horizontal** — the user holds the phone PORTRAIT and pans LEFT →
11
+ * RIGHT across a wide scene. Both portrait holds are valid.
12
+ *
13
+ * A host restricts capture via the `panMode` flag:
14
+ * - `'vertical'` → landscape-only; a PORTRAIT hold is gated (rotate to
15
+ * landscape).
16
+ * - `'horizontal'` → portrait-only; a LANDSCAPE hold is gated (rotate to
17
+ * portrait).
18
+ * - `'both'` → either; the gate never fires.
19
+ *
20
+ * When the gate fires the host must NOT start the capture — it shows the
21
+ * rotate prompt (guidance item 2, pointing at the target orientation) and
22
+ * waits for the user to rotate.
23
+ *
24
+ * This module is the single pure predicate for that decision: no React, no
25
+ * sensors, no side effects, so the gate logic is unit-testable in the node
26
+ * jest env without booting a render or mocking the accelerometer.
27
+ */
28
+ import type { DeviceOrientation } from './useDeviceOrientation';
29
+ /**
30
+ * Which device holds the panorama capture accepts.
31
+ *
32
+ * - `'vertical'` — LANDSCAPE only (top→bottom pan; the product default).
33
+ * Portrait holds are gated behind the rotate-to-landscape prompt.
34
+ * - `'horizontal'` — PORTRAIT only (left→right pan). Landscape holds are
35
+ * gated behind the rotate-to-portrait prompt.
36
+ * - `'both'` — LANDSCAPE or PORTRAIT; the gate never fires, the user
37
+ * captures in whichever hold they're already in.
38
+ */
39
+ export type PanMode = 'vertical' | 'horizontal' | 'both';
40
+ /**
41
+ * True when the caller must BLOCK capture-start and show the rotate prompt
42
+ * for the current device hold:
43
+ * - `'vertical'` gates a PORTRAIT hold (needs landscape).
44
+ * - `'horizontal'` gates a LANDSCAPE hold (needs portrait).
45
+ * - `'both'` never gates.
46
+ */
47
+ export declare function shouldGateForPanMode(panMode: PanMode, orientation: DeviceOrientation): boolean;
48
+ /**
49
+ * The orientation the user must rotate TO when a hold is gated, used to pick
50
+ * the rotate prompt's copy + graphic. `'vertical'` wants landscape,
51
+ * `'horizontal'` wants portrait; `'both'` never gates so returns `null`.
52
+ */
53
+ export declare function gateTargetOrientation(panMode: PanMode): 'landscape' | 'portrait' | null;
54
+ //# sourceMappingURL=panModeGate.d.ts.map
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * panModeGate — pure decision helper for the first-time-user "rotate the
5
+ * device" gate (guidance item 1).
6
+ *
7
+ * The non-AR panorama flow has two pan directions:
8
+ *
9
+ * - **vertical** — the user holds the phone LANDSCAPE and pans the camera
10
+ * TOP → BOTTOM down a tall fixture. Both `landscape-left` and
11
+ * `landscape-right` are valid holds.
12
+ * - **horizontal** — the user holds the phone PORTRAIT and pans LEFT →
13
+ * RIGHT across a wide scene. Both portrait holds are valid.
14
+ *
15
+ * A host restricts capture via the `panMode` flag:
16
+ * - `'vertical'` → landscape-only; a PORTRAIT hold is gated (rotate to
17
+ * landscape).
18
+ * - `'horizontal'` → portrait-only; a LANDSCAPE hold is gated (rotate to
19
+ * portrait).
20
+ * - `'both'` → either; the gate never fires.
21
+ *
22
+ * When the gate fires the host must NOT start the capture — it shows the
23
+ * rotate prompt (guidance item 2, pointing at the target orientation) and
24
+ * waits for the user to rotate.
25
+ *
26
+ * This module is the single pure predicate for that decision: no React, no
27
+ * sensors, no side effects, so the gate logic is unit-testable in the node
28
+ * jest env without booting a render or mocking the accelerometer.
29
+ */
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.shouldGateForPanMode = shouldGateForPanMode;
32
+ exports.gateTargetOrientation = gateTargetOrientation;
33
+ function isPortrait(orientation) {
34
+ return (orientation === 'portrait' || orientation === 'portrait-upside-down');
35
+ }
36
+ /**
37
+ * True when the caller must BLOCK capture-start and show the rotate prompt
38
+ * for the current device hold:
39
+ * - `'vertical'` gates a PORTRAIT hold (needs landscape).
40
+ * - `'horizontal'` gates a LANDSCAPE hold (needs portrait).
41
+ * - `'both'` never gates.
42
+ */
43
+ function shouldGateForPanMode(panMode, orientation) {
44
+ if (panMode === 'vertical')
45
+ return isPortrait(orientation);
46
+ if (panMode === 'horizontal')
47
+ return !isPortrait(orientation);
48
+ return false; // 'both'
49
+ }
50
+ /**
51
+ * The orientation the user must rotate TO when a hold is gated, used to pick
52
+ * the rotate prompt's copy + graphic. `'vertical'` wants landscape,
53
+ * `'horizontal'` wants portrait; `'both'` never gates so returns `null`.
54
+ */
55
+ function gateTargetOrientation(panMode) {
56
+ if (panMode === 'vertical')
57
+ return 'landscape';
58
+ if (panMode === 'horizontal')
59
+ return 'portrait';
60
+ return null;
61
+ }
62
+ //# sourceMappingURL=panModeGate.js.map
@@ -0,0 +1,71 @@
1
+ /**
2
+ * pickCaptureFormat — choose the vision-camera format for the capture stream.
3
+ *
4
+ * Replaces a plain `useCameraFormat([{ videoResolution: 'max' }, …])`, which
5
+ * picks the device's MAX-video format and lets the PHOTO resolution ride
6
+ * along — on the iPhone 16 Pro ultra-wide that pairs a **48 MP** still
7
+ * (8064×6048) with the 4032×3024 max-video format, so a tap photo came out
8
+ * ~6000 px. vision-camera 4.x exposes each format's photo/video resolution
9
+ * but NOT its pixel format / bit-depth, so we can't filter for 8-bit; the
10
+ * empirical rule is that the device's MAX 4:3 video format is 8-bit (the
11
+ * frame processor needs 8-bit for non-AR stitching), and lower video
12
+ * resolutions risk 10-bit.
13
+ *
14
+ * Strategy: among the ~4:3 formats whose photo long-edge is within
15
+ * `maxPhotoLongEdge`, pick the one with the HIGHEST video resolution (keeps
16
+ * the preview/stitch stream as sharp as possible while bounding the still),
17
+ * tie-breaking on higher fps, then the largest photo under the cap, then
18
+ * non-HDR (a hedge toward 8-bit). If NO format fits the cap, fall back to
19
+ * the overall max-video format (never returns nothing for a non-empty list).
20
+ *
21
+ * Verified against the real iPhone 16 Pro ultra-wide format list (see the
22
+ * unit test): cap 4032 → 4032×3024 photo (12 MP) + 3264×2448 video (was
23
+ * 8064×6048 photo); cap 2048 → 2016×1512 photo (3 MP) + 1920×1440 video.
24
+ *
25
+ * Pure + structurally-typed (no vision-camera import) so it unit-tests in the
26
+ * node jest env; `CameraDeviceFormat` is structurally assignable to
27
+ * `FormatLike`.
28
+ */
29
+ /** The CameraDeviceFormat fields this picker reads. */
30
+ export interface FormatLike {
31
+ photoWidth: number;
32
+ photoHeight: number;
33
+ videoWidth: number;
34
+ videoHeight: number;
35
+ maxFps: number;
36
+ supportsVideoHdr: boolean;
37
+ }
38
+ export interface PickFormatOptions {
39
+ /**
40
+ * Cap on the chosen format's photo LONG edge, in px. The picker prefers
41
+ * the sharpest-video format whose photo fits this. `0` disables the cap
42
+ * (reverts to pure max-video). Default 4032 (≈12 MP at 4:3, "4K"-ish).
43
+ */
44
+ maxPhotoLongEdge?: number;
45
+ /** Target capture aspect (W/H in landscape). Default 4/3. */
46
+ aspect?: number;
47
+ /** Aspect match tolerance. Default 0.05. */
48
+ aspectTolerance?: number;
49
+ /**
50
+ * Prefer a SMOOTH (high-fps) preview over the sharpest video format. Off by
51
+ * default → max-video-resolution-first (back-compat). On (the panorama
52
+ * camera opts in) → rank by frame rate up to `fpsTarget` first, THEN video
53
+ * resolution. The default video-first sort picks e.g. a 3264×2448 **@30 fps**
54
+ * format over a 1920×1440 **@60 fps** one, halving the preview frame rate —
55
+ * visible as jitter while panning. The stitch clamps keyframes to 640/1280 px
56
+ * anyway, so the higher video resolution buys nothing for the panorama; a
57
+ * 60 fps stream just looks smooth.
58
+ */
59
+ preferHighFps?: boolean;
60
+ /**
61
+ * Ceiling for the fps preference when `preferHighFps` is on. Formats at or
62
+ * above this are treated as equally smooth (so resolution breaks the tie
63
+ * instead of chasing 120 fps at a lower resolution). Default 60.
64
+ */
65
+ fpsTarget?: number;
66
+ }
67
+ /**
68
+ * Pick the best capture format, or `undefined` for an empty list.
69
+ */
70
+ export declare function pickCaptureFormat<F extends FormatLike>(formats: readonly F[], opts?: PickFormatOptions): F | undefined;
71
+ //# sourceMappingURL=pickCaptureFormat.d.ts.map
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * pickCaptureFormat — choose the vision-camera format for the capture stream.
5
+ *
6
+ * Replaces a plain `useCameraFormat([{ videoResolution: 'max' }, …])`, which
7
+ * picks the device's MAX-video format and lets the PHOTO resolution ride
8
+ * along — on the iPhone 16 Pro ultra-wide that pairs a **48 MP** still
9
+ * (8064×6048) with the 4032×3024 max-video format, so a tap photo came out
10
+ * ~6000 px. vision-camera 4.x exposes each format's photo/video resolution
11
+ * but NOT its pixel format / bit-depth, so we can't filter for 8-bit; the
12
+ * empirical rule is that the device's MAX 4:3 video format is 8-bit (the
13
+ * frame processor needs 8-bit for non-AR stitching), and lower video
14
+ * resolutions risk 10-bit.
15
+ *
16
+ * Strategy: among the ~4:3 formats whose photo long-edge is within
17
+ * `maxPhotoLongEdge`, pick the one with the HIGHEST video resolution (keeps
18
+ * the preview/stitch stream as sharp as possible while bounding the still),
19
+ * tie-breaking on higher fps, then the largest photo under the cap, then
20
+ * non-HDR (a hedge toward 8-bit). If NO format fits the cap, fall back to
21
+ * the overall max-video format (never returns nothing for a non-empty list).
22
+ *
23
+ * Verified against the real iPhone 16 Pro ultra-wide format list (see the
24
+ * unit test): cap 4032 → 4032×3024 photo (12 MP) + 3264×2448 video (was
25
+ * 8064×6048 photo); cap 2048 → 2016×1512 photo (3 MP) + 1920×1440 video.
26
+ *
27
+ * Pure + structurally-typed (no vision-camera import) so it unit-tests in the
28
+ * node jest env; `CameraDeviceFormat` is structurally assignable to
29
+ * `FormatLike`.
30
+ */
31
+ Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.pickCaptureFormat = pickCaptureFormat;
33
+ const DEFAULT_MAX_PHOTO_LONG_EDGE = 4032;
34
+ const DEFAULT_FPS_TARGET = 60;
35
+ const longEdge = (f) => Math.max(f.photoWidth, f.photoHeight);
36
+ const videoPixels = (f) => f.videoWidth * f.videoHeight;
37
+ /**
38
+ * Pick the best capture format, or `undefined` for an empty list.
39
+ */
40
+ function pickCaptureFormat(formats, opts = {}) {
41
+ if (!formats || formats.length === 0)
42
+ return undefined;
43
+ const aspect = opts.aspect ?? 4 / 3;
44
+ const tol = opts.aspectTolerance ?? 0.05;
45
+ const cap = opts.maxPhotoLongEdge ?? DEFAULT_MAX_PHOTO_LONG_EDGE;
46
+ const preferHighFps = opts.preferHighFps ?? false;
47
+ const fpsTarget = opts.fpsTarget ?? DEFAULT_FPS_TARGET;
48
+ // Treat everything at/above the target as equally smooth so resolution, not
49
+ // a chase for 120 fps, breaks the tie.
50
+ const smoothness = (f) => Math.min(f.maxFps, fpsTarget);
51
+ const matchesAspect = (f) => f.photoHeight > 0
52
+ && f.videoHeight > 0
53
+ && Math.abs(f.photoWidth / f.photoHeight - aspect) < tol
54
+ && Math.abs(f.videoWidth / f.videoHeight - aspect) < tol;
55
+ // Prefer 4:3 formats; if the device has none, consider all.
56
+ const fourThree = formats.filter(matchesAspect);
57
+ const base = fourThree.length > 0 ? fourThree : formats.slice();
58
+ // Among those within the photo cap; if none fit, fall back to all (which
59
+ // then resolves to the max-video format — never worse than today).
60
+ const withinCap = cap > 0 ? base.filter((f) => longEdge(f) <= cap) : base.slice();
61
+ const candidates = withinCap.length > 0 ? withinCap : base;
62
+ return candidates.slice().sort((a, b) => {
63
+ if (preferHighFps) {
64
+ // Smooth-preview priority: frame rate (up to the target) before video
65
+ // resolution. Keeps the panorama preview at ~60 fps instead of dropping
66
+ // to a sharper-but-30fps format.
67
+ const sa = smoothness(a);
68
+ const sb = smoothness(b);
69
+ if (sb !== sa)
70
+ return sb - sa;
71
+ }
72
+ const va = videoPixels(a);
73
+ const vb = videoPixels(b);
74
+ if (vb !== va)
75
+ return vb - va; // highest video resolution first
76
+ if (b.maxFps !== a.maxFps)
77
+ return b.maxFps - a.maxFps; // then higher fps
78
+ if (longEdge(b) !== longEdge(a))
79
+ return longEdge(b) - longEdge(a); // largest photo under cap
80
+ // Prefer non-HDR — a hedge toward an 8-bit pixel format (the stitch
81
+ // frame processor needs 8-bit; vision-camera doesn't expose bit-depth).
82
+ return (a.supportsVideoHdr ? 1 : 0) - (b.supportsVideoHdr ? 1 : 0);
83
+ })[0];
84
+ }
85
+ //# sourceMappingURL=pickCaptureFormat.js.map