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
@@ -23,6 +23,7 @@ import React, {
23
23
  forwardRef,
24
24
  useCallback,
25
25
  useImperativeHandle,
26
+ useMemo,
26
27
  useRef,
27
28
  useState,
28
29
  } from 'react';
@@ -35,11 +36,22 @@ import {
35
36
  } from 'react-native';
36
37
  import {
37
38
  Camera,
38
- useCameraFormat,
39
39
  type CameraDevice,
40
40
  type CameraProps,
41
41
  } from 'react-native-vision-camera';
42
42
 
43
+ import { pickCaptureFormat } from './pickCaptureFormat';
44
+
45
+
46
+ /**
47
+ * Cap on the chosen capture format's PHOTO long edge (px). 4032 ≈ 12 MP at
48
+ * 4:3 ("4K"-ish), matching the 1× lens, so the ultra-wide stops producing a
49
+ * 48 MP / ~6000 px still. Set to 2016 for "2K" (~3 MP). `0` reverts to pure
50
+ * max-video. TODO(v0.16): expose as a `<Camera photoMaxLongEdge>` prop once
51
+ * the 0.5× panorama 8-bit check passes on-device.
52
+ */
53
+ const PHOTO_LONG_EDGE_CAP = 4032;
54
+
43
55
 
44
56
  export interface CameraViewProps {
45
57
  /** Output of ``useCapture().device``. If null, a placeholder is shown. */
@@ -190,21 +202,39 @@ export const CameraView = forwardRef<Camera | null, CameraViewProps>(function Ca
190
202
  // natively while keeping full-res keyframes. Aspect stays the
191
203
  // top-priority filter, so 4:3 WYSIWYG parity holds on every device.
192
204
  //
193
- // Still resolution is capped at ~12 MP. The max-video 4:3 format pairs
194
- // with a 24 MP photo (5712×4284) on the iPhone 16 Pro by default — 2×
195
- // the file size + per-capture memory for no benefit on the panorama
196
- // path (which uses the VIDEO stream, not takePhoto). `photoResolution`
197
- // is the LOWEST-priority filter, so it only breaks ties between equal
198
- // max-video formats (e.g. the 12 MP-photo vs 24 MP-photo variants that
199
- // share the same 4032×3024 video)it never trades preview/stitch
200
- // sharpness for a smaller still. 4032×3024 = 12 MP at 4:3; nearest-
201
- // match keeps stills near there on any device.
202
- const format = useCameraFormat(device ?? undefined, [
203
- { photoAspectRatio: 4 / 3 },
204
- { videoAspectRatio: 4 / 3 },
205
- { videoResolution: 'max' },
206
- { photoResolution: { width: 4032, height: 3024 } },
207
- ]);
205
+ // Still resolution: a plain `videoResolution:'max'` filter (what we used
206
+ // before) maximises VIDEO and lets the PHOTO ride along — on the iPhone 16
207
+ // Pro ULTRA-WIDE that pairs a 48 MP still (8064×6048) with the max-video
208
+ // format, so a tap photo came out ~6000 px. `pickCaptureFormat` instead
209
+ // picks the SHARPEST-video 4:3 format whose photo is within
210
+ // PHOTO_LONG_EDGE_CAP (verified on-device: the ultra-wide then chooses
211
+ // 3264×2448 video + 12 MP photo still a crisp preview, no 48 MP still).
212
+ // The cap is on the PHOTO; video stays as high as the cap allows, so the
213
+ // 8-bit/sharp-preview rationale above still holds.
214
+ //
215
+ // preferHighFps: a panorama preview must stay SMOOTH while panning. Video-
216
+ // resolution-first would pick the 3264×2448 **@30 fps** format over the
217
+ // 1920×1440 **@60 fps** one — visibly jittery. Keyframes are clamped to
218
+ // 640/1280 px before stitching, so the extra video resolution buys nothing
219
+ // here; a 60 fps stream just looks right. We opt the panorama camera in.
220
+ const format = useMemo(
221
+ () =>
222
+ pickCaptureFormat(device?.formats ?? [], {
223
+ maxPhotoLongEdge: PHOTO_LONG_EDGE_CAP,
224
+ aspect: 4 / 3,
225
+ preferHighFps: true,
226
+ }),
227
+ [device],
228
+ );
229
+
230
+ // Pin the session frame rate to the format's max, capped at 60. Picking a
231
+ // 60 fps-capable format is necessary but NOT sufficient — without an explicit
232
+ // `fps`, vision-camera can leave the session at a lower default, which is the
233
+ // jitter the user saw. min(maxFps, 60) is always within the format's range.
234
+ const fps = useMemo(
235
+ () => (format ? Math.min(format.maxFps ?? 30, 60) : undefined),
236
+ [format],
237
+ );
208
238
 
209
239
  // Measured size of our container, so we can size the <Camera> view to
210
240
  // the largest box of the capture's aspect ratio that fits inside it
@@ -275,6 +305,8 @@ export const CameraView = forwardRef<Camera | null, CameraViewProps>(function Ca
275
305
  video={video}
276
306
  // Pin preview + photo to the same 4:3 format (WYSIWYG capture).
277
307
  format={format}
308
+ // Run the session at the format's fps (≤60) for a smooth pan preview.
309
+ {...(fps != null ? { fps } : {})}
278
310
  // v0.13.2 — multi-cam lens switch via zoom (undefined = default).
279
311
  {...(zoom != null ? { zoom } : {})}
280
312
  // Bake the device orientation into the captured pixels.
@@ -0,0 +1,272 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * CaptureCountdownOverlay — the blinking auto-stop countdown shown at the
4
+ * user-perceived TOP-LEFT during an in-progress panorama capture (item 5
5
+ * of the first-time-user GUIDANCE flow).
6
+ *
7
+ * ┌──────────────────────────────────────────────────┐
8
+ * │ ● 9 │ ← top-left, blinks
9
+ * │ │
10
+ * │ (camera preview / pan in progress) │
11
+ * │ │
12
+ * └──────────────────────────────────────────────────┘
13
+ *
14
+ * Why this exists
15
+ * The capture auto-finalizes after a fixed window (the parent owns the
16
+ * real timer — see Camera's `countdownSecondsFrom`). Without a visible
17
+ * counter the auto-stop feels abrupt ("why did it stop?"). A calm
18
+ * blinking "● N" tells the user how many seconds of pan they have left.
19
+ *
20
+ * What it does
21
+ * - Renders an amber glow dot + a white tabular-nums integer
22
+ * (`secondsRemaining`, computed by the parent) using the shared
23
+ * {@link GUIDANCE_COUNTDOWN} design tokens.
24
+ * - Pins itself to the user-perceived top-left corner across all four
25
+ * device orientations. The app layout is portrait-locked, so — like
26
+ * {@link CaptureStatusOverlay} / {@link PanoramaGuidance} — we anchor
27
+ * to the matching layout corner and apply a rotation transform so the
28
+ * number reads upright in the user's hold.
29
+ * - Blinks the WHOLE timer (dot + number) between
30
+ * `GUIDANCE_COUNTDOWN.blinkMinOpacity` and `blinkMaxOpacity` over
31
+ * `blinkPeriodMs` with an ease-in-out loop on the native driver.
32
+ *
33
+ * The displayed number is COSMETIC only — the parent owns the auth­oritative
34
+ * auto-stop timer and computes `secondsRemaining`. This component never
35
+ * fires a stop; it purely visualises the remaining time, so a dropped frame
36
+ * or a re-render hiccup can never desync from (or pre-empt) the real timer.
37
+ *
38
+ * Pure-presentational and `pointerEvents="none"`: it never steals taps from
39
+ * the camera / shutter beneath it, and renders nothing when `!visible` so
40
+ * the host can mount it unconditionally without layout shifts.
41
+ */
42
+
43
+ import React, { useEffect, useRef } from 'react';
44
+ import {
45
+ Animated,
46
+ Easing,
47
+ StyleSheet,
48
+ Text,
49
+ View,
50
+ type StyleProp,
51
+ type ViewStyle,
52
+ } from 'react-native';
53
+
54
+ import { GUIDANCE_COUNTDOWN, GUIDANCE_TOKENS } from './guidanceTokens';
55
+ import { type DeviceOrientation } from './useDeviceOrientation';
56
+
57
+
58
+ export interface CaptureCountdownOverlayProps {
59
+ /**
60
+ * Show / hide. Driven by the host while a capture is in progress
61
+ * (typically `statusPhase === 'recording'`). Renders nothing when
62
+ * false so the host can mount it unconditionally.
63
+ */
64
+ visible: boolean;
65
+ /**
66
+ * Whole seconds of capture remaining, as computed by the parent's
67
+ * authoritative timer (Camera's `countdownSecondsFrom`). Displayed
68
+ * verbatim via `Math.max(0, Math.round(...))` so a fractional or
69
+ * transiently-negative value never renders as "-0" or "3.0".
70
+ *
71
+ * COSMETIC ONLY — this component does not own the auto-stop.
72
+ */
73
+ secondsRemaining: number;
74
+ /**
75
+ * Physical device orientation (typically from `useDeviceOrientation`).
76
+ * Drives the corner anchoring + rotation so the number sits at the
77
+ * user-perceived top-left and reads upright.
78
+ */
79
+ orientation: DeviceOrientation;
80
+ /** Outer style passthrough. */
81
+ style?: StyleProp<ViewStyle>;
82
+ }
83
+
84
+
85
+ export function CaptureCountdownOverlay({
86
+ visible,
87
+ secondsRemaining,
88
+ orientation,
89
+ style,
90
+ }: CaptureCountdownOverlayProps): React.JSX.Element | null {
91
+ // Single Animated.Value looping min→max→min drives the whole-timer
92
+ // blink. Cheap (no JS listeners, native driver) and only spins up
93
+ // while visible; torn down on hide so the loop isn't running the
94
+ // rest of the time the screen is up.
95
+ const blink = useRef(
96
+ new Animated.Value(GUIDANCE_COUNTDOWN.blinkMaxOpacity),
97
+ ).current;
98
+
99
+ useEffect(() => {
100
+ if (!visible) {
101
+ // Reset to fully-opaque so the next show starts bright rather
102
+ // than mid-fade.
103
+ blink.setValue(GUIDANCE_COUNTDOWN.blinkMaxOpacity);
104
+ return;
105
+ }
106
+ // Symmetric fade down + back up, so a full min→max→min cycle takes
107
+ // `blinkPeriodMs` (each half-leg is half the period).
108
+ const halfPeriod = GUIDANCE_COUNTDOWN.blinkPeriodMs / 2;
109
+ const loop = Animated.loop(
110
+ Animated.sequence([
111
+ Animated.timing(blink, {
112
+ toValue: GUIDANCE_COUNTDOWN.blinkMinOpacity,
113
+ duration: halfPeriod,
114
+ easing: Easing.inOut(Easing.ease),
115
+ useNativeDriver: true,
116
+ }),
117
+ Animated.timing(blink, {
118
+ toValue: GUIDANCE_COUNTDOWN.blinkMaxOpacity,
119
+ duration: halfPeriod,
120
+ easing: Easing.inOut(Easing.ease),
121
+ useNativeDriver: true,
122
+ }),
123
+ ]),
124
+ );
125
+ loop.start();
126
+ return () => loop.stop();
127
+ }, [visible, blink]);
128
+
129
+ if (!visible) return null;
130
+
131
+ // Clamp to a non-negative integer. The parent's timer may briefly
132
+ // report a fractional or sub-zero value at the auto-stop boundary;
133
+ // we never want to render "-1" or "2.4".
134
+ const displaySeconds = Math.max(0, Math.round(secondsRemaining));
135
+
136
+ const cornerStyle = countdownStyleForOrientation(orientation);
137
+
138
+ return (
139
+ <Animated.View
140
+ pointerEvents="none"
141
+ style={[styles.root, cornerStyle, { opacity: blink }, style]}
142
+ accessibilityRole="timer"
143
+ accessibilityLiveRegion="polite"
144
+ accessibilityLabel={`${displaySeconds} seconds remaining`}
145
+ >
146
+ <View style={styles.dot} />
147
+ <Text style={styles.number} numberOfLines={1} allowFontScaling={false}>
148
+ {displaySeconds}
149
+ </Text>
150
+ </Animated.View>
151
+ );
152
+ }
153
+
154
+
155
+ /**
156
+ * Style placing the countdown at the user-perceived TOP-LEFT corner with
157
+ * the number reading upright in the user's hold, inset by
158
+ * `GUIDANCE_COUNTDOWN.inset` from that corner.
159
+ *
160
+ * Mirrors the corner-anchor + percentage-translate self-centering of
161
+ * {@link CaptureStatusOverlay}'s `bannerStyleForOrientation`, but anchored
162
+ * to the user's top-LEFT instead of top-center. For each orientation we
163
+ * anchor the row to the layout corner that maps to the user's top-left,
164
+ * then rotate the row about its center so it reads upright:
165
+ *
166
+ * portrait → layout top-left, 0°
167
+ * landscape-left → layout bottom-left, +90°
168
+ * landscape-right → layout top-right, -90°
169
+ * portrait-upside-down → layout bottom-right, 180°
170
+ *
171
+ * The `translate('±50%')` pair pins the row's CENTER a fixed `inset`
172
+ * from the chosen corner so the post-rotation top-left edge lands at
173
+ * `inset` regardless of the row's own width/height — the same trick the
174
+ * banner uses to stay corner-aligned without measuring its content.
175
+ */
176
+ function countdownStyleForOrientation(
177
+ orientation: DeviceOrientation,
178
+ ): ViewStyle {
179
+ const { inset } = GUIDANCE_COUNTDOWN;
180
+ switch (orientation) {
181
+ case 'landscape-left':
182
+ // Device held so user-top runs along the layout LEFT edge; the
183
+ // user's top-left maps to the layout BOTTOM-left. +90° makes the
184
+ // row read upright.
185
+ return {
186
+ position: 'absolute',
187
+ bottom: inset,
188
+ left: inset,
189
+ transform: [
190
+ { translateX: '50%' },
191
+ { translateY: '-50%' },
192
+ { rotate: '90deg' },
193
+ ],
194
+ };
195
+ case 'landscape-right':
196
+ // User-top runs along the layout RIGHT edge; user's top-left maps
197
+ // to the layout TOP-right. -90° makes the row read upright.
198
+ return {
199
+ position: 'absolute',
200
+ top: inset,
201
+ right: inset,
202
+ transform: [
203
+ { translateX: '-50%' },
204
+ { translateY: '50%' },
205
+ { rotate: '-90deg' },
206
+ ],
207
+ };
208
+ case 'portrait-upside-down':
209
+ // User-top-left maps to the layout BOTTOM-right; 180° flips the row.
210
+ return {
211
+ position: 'absolute',
212
+ bottom: inset,
213
+ right: inset,
214
+ transform: [
215
+ { translateX: '-50%' },
216
+ { translateY: '-50%' },
217
+ { rotate: '180deg' },
218
+ ],
219
+ };
220
+ case 'portrait':
221
+ default:
222
+ return {
223
+ position: 'absolute',
224
+ top: inset,
225
+ left: inset,
226
+ transform: [
227
+ { translateX: '50%' },
228
+ { translateY: '50%' },
229
+ ],
230
+ };
231
+ }
232
+ }
233
+
234
+
235
+ const styles = StyleSheet.create({
236
+ root: {
237
+ // position: 'absolute' is re-applied by countdownStyleForOrientation
238
+ // alongside the corner offsets + transform; kept here too so the row
239
+ // lays out as a self-sized box even before the orientation style
240
+ // merges in.
241
+ position: 'absolute',
242
+ flexDirection: 'row',
243
+ alignItems: 'center',
244
+ },
245
+ dot: {
246
+ width: GUIDANCE_COUNTDOWN.dotSize,
247
+ height: GUIDANCE_COUNTDOWN.dotSize,
248
+ borderRadius: GUIDANCE_COUNTDOWN.dotSize / 2,
249
+ backgroundColor: GUIDANCE_TOKENS.amber,
250
+ marginRight: GUIDANCE_COUNTDOWN.dotGap,
251
+ // Amber glow around the dot. iOS honours all four shadow props;
252
+ // Android renders the glow via `elevation` (set below) since RN
253
+ // ignores view shadowColor there.
254
+ shadowColor: GUIDANCE_COUNTDOWN.dotGlow,
255
+ shadowOpacity: 1,
256
+ shadowRadius: GUIDANCE_COUNTDOWN.dotSize,
257
+ shadowOffset: { width: 0, height: 0 },
258
+ elevation: 6,
259
+ },
260
+ number: {
261
+ color: GUIDANCE_TOKENS.white,
262
+ fontSize: GUIDANCE_COUNTDOWN.fontSize,
263
+ fontWeight: GUIDANCE_COUNTDOWN.fontWeight,
264
+ // Tabular figures keep the glyph box a fixed width so the number
265
+ // doesn't jitter horizontally as it ticks 9→8→…→0.
266
+ fontVariant: ['tabular-nums'],
267
+ // Drop shadow for legibility over a bright/busy preview.
268
+ textShadowColor: GUIDANCE_TOKENS.scrim,
269
+ textShadowOffset: { width: 0, height: 1 },
270
+ textShadowRadius: 3,
271
+ },
272
+ });
@@ -0,0 +1,183 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * CaptureFrameCounterOverlay — a live "k / n" keyframe counter shown at the
4
+ * user-perceived TOP-CENTRE during a panorama capture.
5
+ *
6
+ * ┌──────────────────────────────────────────────────┐
7
+ * │ ● 3 / 6 │ ← top-centre
8
+ * │ │
9
+ * │ (camera preview / pan in progress) │
10
+ * └──────────────────────────────────────────────────┘
11
+ *
12
+ * Replaces the time countdown (item 5) as the primary capture HUD: instead
13
+ * of "seconds left" it shows how many keyframes have been captured out of
14
+ * the configured maximum, so the user can see the capture filling up and
15
+ * understand WHY it auto-finalizes at the cap (the parent stops + stitches
16
+ * when `framesCaptured` reaches `framesMax`).
17
+ *
18
+ * Pure-presentational + `pointerEvents="none"` (never steals taps); renders
19
+ * nothing when `!visible` so the host can mount it unconditionally. Pins
20
+ * itself to the user-perceived TOP-CENTRE across all four orientations: the
21
+ * app is typically portrait-locked, so we anchor the pill to the layout edge
22
+ * that maps to the user's top and counter-rotate it to read upright (same
23
+ * idea as CaptureCountdownOverlay, but centre-anchored instead of corner).
24
+ */
25
+
26
+ import React from 'react';
27
+ import {
28
+ StyleSheet,
29
+ Text,
30
+ View,
31
+ type StyleProp,
32
+ type ViewStyle,
33
+ } from 'react-native';
34
+
35
+ import { GUIDANCE_COUNTDOWN, GUIDANCE_PILL, GUIDANCE_TOKENS } from './guidanceTokens';
36
+ import { type DeviceOrientation } from './useDeviceOrientation';
37
+
38
+
39
+ export interface CaptureFrameCounterOverlayProps {
40
+ /** Show / hide. Driven by the host while a capture is recording. */
41
+ visible: boolean;
42
+ /** Keyframes accepted so far this capture (the engine's live count). */
43
+ framesCaptured: number;
44
+ /** Configured keyframe cap — the capture auto-finalizes when reached. */
45
+ framesMax: number;
46
+ /** Physical device orientation (from `useDeviceOrientation`). */
47
+ orientation: DeviceOrientation;
48
+ /** Outer style passthrough. */
49
+ style?: StyleProp<ViewStyle>;
50
+ }
51
+
52
+
53
+ export function CaptureFrameCounterOverlay({
54
+ visible,
55
+ framesCaptured,
56
+ framesMax,
57
+ orientation,
58
+ style,
59
+ }: CaptureFrameCounterOverlayProps): React.JSX.Element | null {
60
+ if (!visible || framesMax <= 0) return null;
61
+
62
+ // Clamp the displayed numerator into [0, framesMax] — the engine can
63
+ // briefly report the cap-th accept before the parent finalizes.
64
+ const k = Math.max(0, Math.min(framesCaptured, framesMax));
65
+
66
+ const { container, rotate } = topCenterForOrientation(
67
+ orientation,
68
+ GUIDANCE_COUNTDOWN.inset,
69
+ );
70
+
71
+ return (
72
+ <View
73
+ pointerEvents="none"
74
+ style={[styles.layer, container, style]}
75
+ >
76
+ <View style={[styles.pill, { transform: [{ rotate }] }]}>
77
+ <View style={styles.dot} />
78
+ <Text style={styles.text} allowFontScaling={false} numberOfLines={1}>
79
+ <Text style={styles.count}>{k}</Text>
80
+ <Text style={styles.slash}> / {framesMax}</Text>
81
+ </Text>
82
+ </View>
83
+ </View>
84
+ );
85
+ }
86
+
87
+
88
+ /**
89
+ * Flex alignment that pins content to the user-perceived TOP-CENTRE for a
90
+ * given device hold, plus the rotation that makes it read upright:
91
+ *
92
+ * portrait → layout top edge, centred, 0°
93
+ * landscape-left → layout left edge, centred, +90°
94
+ * landscape-right → layout right edge, centred, -90°
95
+ * portrait-upside-down → layout bottom edge, centred, 180°
96
+ *
97
+ * `inset` is the distance from the user's top edge (larger values push the
98
+ * content further down the screen) — exported so other top-anchored overlays
99
+ * (e.g. the too-fast pill) can stack BELOW the counter by passing a bigger
100
+ * inset, and stay correctly placed + upright in every orientation.
101
+ */
102
+ export function topCenterForOrientation(
103
+ orientation: DeviceOrientation,
104
+ inset: number,
105
+ ): { container: ViewStyle; rotate: string } {
106
+ switch (orientation) {
107
+ case 'landscape-left':
108
+ return {
109
+ container: {
110
+ justifyContent: 'center',
111
+ alignItems: 'flex-start',
112
+ paddingLeft: inset,
113
+ },
114
+ rotate: '90deg',
115
+ };
116
+ case 'landscape-right':
117
+ return {
118
+ container: {
119
+ justifyContent: 'center',
120
+ alignItems: 'flex-end',
121
+ paddingRight: inset,
122
+ },
123
+ rotate: '-90deg',
124
+ };
125
+ case 'portrait-upside-down':
126
+ return {
127
+ container: {
128
+ justifyContent: 'flex-end',
129
+ alignItems: 'center',
130
+ paddingBottom: inset,
131
+ },
132
+ rotate: '180deg',
133
+ };
134
+ case 'portrait':
135
+ default:
136
+ return {
137
+ container: {
138
+ justifyContent: 'flex-start',
139
+ alignItems: 'center',
140
+ paddingTop: inset,
141
+ },
142
+ rotate: '0deg',
143
+ };
144
+ }
145
+ }
146
+
147
+
148
+ const styles = StyleSheet.create({
149
+ // Full-screen, non-interactive layer; the per-orientation flex alignment
150
+ // places the pill on the correct edge, centred along it.
151
+ layer: { ...StyleSheet.absoluteFillObject },
152
+ pill: {
153
+ flexDirection: 'row',
154
+ alignItems: 'center',
155
+ paddingVertical: GUIDANCE_PILL.paddingVertical,
156
+ paddingHorizontal: GUIDANCE_PILL.paddingHorizontal,
157
+ borderRadius: GUIDANCE_PILL.borderRadius,
158
+ backgroundColor: GUIDANCE_TOKENS.scrim,
159
+ borderWidth: StyleSheet.hairlineWidth,
160
+ borderColor: GUIDANCE_TOKENS.hairline,
161
+ },
162
+ dot: {
163
+ width: GUIDANCE_PILL.dotSize,
164
+ height: GUIDANCE_PILL.dotSize,
165
+ borderRadius: GUIDANCE_PILL.dotSize / 2,
166
+ backgroundColor: GUIDANCE_TOKENS.amber,
167
+ marginRight: GUIDANCE_PILL.dotGap,
168
+ },
169
+ text: {
170
+ // Tabular figures keep the counter from jittering as k ticks up.
171
+ fontVariant: ['tabular-nums'],
172
+ },
173
+ count: {
174
+ color: GUIDANCE_TOKENS.white,
175
+ fontSize: 17,
176
+ fontWeight: '700',
177
+ },
178
+ slash: {
179
+ color: GUIDANCE_TOKENS.amber,
180
+ fontSize: 15,
181
+ fontWeight: '600',
182
+ },
183
+ });
@@ -18,7 +18,13 @@
18
18
  */
19
19
 
20
20
  import React, { useEffect, useState } from 'react';
21
- import { StyleSheet, Text, View } from 'react-native';
21
+ import {
22
+ StyleSheet,
23
+ Text,
24
+ View,
25
+ type StyleProp,
26
+ type ViewStyle,
27
+ } from 'react-native';
22
28
 
23
29
  import { getIncrementalNativeModule } from '../stitching/incremental';
24
30
 
@@ -29,11 +35,19 @@ export interface CaptureMemoryPillProps {
29
35
  * for no visible benefit; higher loses correlation with capture
30
36
  * activity. */
31
37
  pollIntervalMs?: number;
38
+ /**
39
+ * Optional position override. When supplied it REPLACES the default
40
+ * top-right anchor (`top: topInset + 56, right: 12`), so the pill can be
41
+ * reused on other screens (e.g. the crop/preview surface) without colliding
42
+ * with their own corner UI. Pass the full absolute position you want.
43
+ */
44
+ style?: StyleProp<ViewStyle>;
32
45
  }
33
46
 
34
47
  export function CaptureMemoryPill({
35
48
  topInset = 0,
36
49
  pollIntervalMs = 500,
50
+ style,
37
51
  }: CaptureMemoryPillProps): React.JSX.Element | null {
38
52
  const [memMB, setMemMB] = useState<number | null>(null);
39
53
 
@@ -69,7 +83,8 @@ export function CaptureMemoryPill({
69
83
  pointerEvents="none"
70
84
  style={[
71
85
  styles.container,
72
- { top: topInset + 56, backgroundColor: bg },
86
+ { backgroundColor: bg },
87
+ style ?? { top: topInset + 56, right: 12 },
73
88
  ]}
74
89
  accessibilityRole="alert"
75
90
  >
@@ -81,7 +96,6 @@ export function CaptureMemoryPill({
81
96
  const styles = StyleSheet.create({
82
97
  container: {
83
98
  position: 'absolute',
84
- right: 12,
85
99
  paddingHorizontal: 10,
86
100
  paddingVertical: 5,
87
101
  borderRadius: 999,
@@ -35,6 +35,8 @@ import {
35
35
  View,
36
36
  } from 'react-native';
37
37
 
38
+ import { DISPLAY_DECODE_IMAGE_PROPS } from './displayDecodeImageProps';
39
+
38
40
 
39
41
  export type CapturePreviewActionVariant =
40
42
  | 'primary'
@@ -158,6 +160,9 @@ export function CapturePreview({
158
160
  source={{ uri: imageUri }}
159
161
  style={[styles.image, { aspectRatio }]}
160
162
  resizeMode="contain"
163
+ // OOM fix — decode at display size, not full panorama res
164
+ // (see DISPLAY_DECODE_IMAGE_PROPS for the native-heap rationale).
165
+ {...DISPLAY_DECODE_IMAGE_PROPS}
161
166
  accessibilityIgnoresInvertColors
162
167
  />
163
168
  </Pressable>