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
@@ -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,197 @@
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
+ /**
40
+ * Extra distance (px) to drop the counter from the user-top in landscape so it
41
+ * clears the pan how-to coach-mark's bouncing arrow. Landscape only; portrait
42
+ * is unaffected. 72 px (the symmetric lift) over-cleared, so this is smaller.
43
+ */
44
+ const COUNTER_LANDSCAPE_EXTRA_INSET = 40;
45
+
46
+
47
+ export interface CaptureFrameCounterOverlayProps {
48
+ /** Show / hide. Driven by the host while a capture is recording. */
49
+ visible: boolean;
50
+ /** Keyframes accepted so far this capture (the engine's live count). */
51
+ framesCaptured: number;
52
+ /** Configured keyframe cap — the capture auto-finalizes when reached. */
53
+ framesMax: number;
54
+ /** Physical device orientation (from `useDeviceOrientation`). */
55
+ orientation: DeviceOrientation;
56
+ /** Outer style passthrough. */
57
+ style?: StyleProp<ViewStyle>;
58
+ }
59
+
60
+
61
+ export function CaptureFrameCounterOverlay({
62
+ visible,
63
+ framesCaptured,
64
+ framesMax,
65
+ orientation,
66
+ style,
67
+ }: CaptureFrameCounterOverlayProps): React.JSX.Element | null {
68
+ if (!visible || framesMax <= 0) return null;
69
+
70
+ // Clamp the displayed numerator into [0, framesMax] — the engine can
71
+ // briefly report the cap-th accept before the parent finalizes.
72
+ const k = Math.max(0, Math.min(framesCaptured, framesMax));
73
+
74
+ // 2026-06-16 — in LANDSCAPE, push the counter further from the user-top so it
75
+ // clears the pan how-to coach-mark's bouncing amber arrow, which sits near the
76
+ // top there and otherwise overlaps it. Portrait keeps the standard inset.
77
+ // Tune COUNTER_LANDSCAPE_EXTRA_INSET if the gap is too small / too large.
78
+ const isLandscape =
79
+ orientation === 'landscape-left' || orientation === 'landscape-right';
80
+ const { container, rotate } = topCenterForOrientation(
81
+ orientation,
82
+ GUIDANCE_COUNTDOWN.inset + (isLandscape ? COUNTER_LANDSCAPE_EXTRA_INSET : 0),
83
+ );
84
+
85
+ return (
86
+ <View
87
+ pointerEvents="none"
88
+ style={[styles.layer, container, style]}
89
+ >
90
+ <View style={[styles.pill, { transform: [{ rotate }] }]}>
91
+ <View style={styles.dot} />
92
+ <Text style={styles.text} allowFontScaling={false} numberOfLines={1}>
93
+ <Text style={styles.count}>{k}</Text>
94
+ <Text style={styles.slash}> / {framesMax}</Text>
95
+ </Text>
96
+ </View>
97
+ </View>
98
+ );
99
+ }
100
+
101
+
102
+ /**
103
+ * Flex alignment that pins content to the user-perceived TOP-CENTRE for a
104
+ * given device hold, plus the rotation that makes it read upright:
105
+ *
106
+ * portrait → layout top edge, centred, 0°
107
+ * landscape-left → layout left edge, centred, +90°
108
+ * landscape-right → layout right edge, centred, -90°
109
+ * portrait-upside-down → layout bottom edge, centred, 180°
110
+ *
111
+ * `inset` is the distance from the user's top edge (larger values push the
112
+ * content further down the screen) — exported so other top-anchored overlays
113
+ * (e.g. the too-fast pill) can stack BELOW the counter by passing a bigger
114
+ * inset, and stay correctly placed + upright in every orientation.
115
+ */
116
+ export function topCenterForOrientation(
117
+ orientation: DeviceOrientation,
118
+ inset: number,
119
+ ): { container: ViewStyle; rotate: string } {
120
+ switch (orientation) {
121
+ case 'landscape-left':
122
+ return {
123
+ container: {
124
+ justifyContent: 'center',
125
+ alignItems: 'flex-start',
126
+ paddingLeft: inset,
127
+ },
128
+ rotate: '90deg',
129
+ };
130
+ case 'landscape-right':
131
+ return {
132
+ container: {
133
+ justifyContent: 'center',
134
+ alignItems: 'flex-end',
135
+ paddingRight: inset,
136
+ },
137
+ rotate: '-90deg',
138
+ };
139
+ case 'portrait-upside-down':
140
+ return {
141
+ container: {
142
+ justifyContent: 'flex-end',
143
+ alignItems: 'center',
144
+ paddingBottom: inset,
145
+ },
146
+ rotate: '180deg',
147
+ };
148
+ case 'portrait':
149
+ default:
150
+ return {
151
+ container: {
152
+ justifyContent: 'flex-start',
153
+ alignItems: 'center',
154
+ paddingTop: inset,
155
+ },
156
+ rotate: '0deg',
157
+ };
158
+ }
159
+ }
160
+
161
+
162
+ const styles = StyleSheet.create({
163
+ // Full-screen, non-interactive layer; the per-orientation flex alignment
164
+ // places the pill on the correct edge, centred along it.
165
+ layer: { ...StyleSheet.absoluteFillObject },
166
+ pill: {
167
+ flexDirection: 'row',
168
+ alignItems: 'center',
169
+ paddingVertical: GUIDANCE_PILL.paddingVertical,
170
+ paddingHorizontal: GUIDANCE_PILL.paddingHorizontal,
171
+ borderRadius: GUIDANCE_PILL.borderRadius,
172
+ backgroundColor: GUIDANCE_TOKENS.scrim,
173
+ borderWidth: StyleSheet.hairlineWidth,
174
+ borderColor: GUIDANCE_TOKENS.hairline,
175
+ },
176
+ dot: {
177
+ width: GUIDANCE_PILL.dotSize,
178
+ height: GUIDANCE_PILL.dotSize,
179
+ borderRadius: GUIDANCE_PILL.dotSize / 2,
180
+ backgroundColor: GUIDANCE_TOKENS.amber,
181
+ marginRight: GUIDANCE_PILL.dotGap,
182
+ },
183
+ text: {
184
+ // Tabular figures keep the counter from jittering as k ticks up.
185
+ fontVariant: ['tabular-nums'],
186
+ },
187
+ count: {
188
+ color: GUIDANCE_TOKENS.white,
189
+ fontSize: 17,
190
+ fontWeight: '700',
191
+ },
192
+ slash: {
193
+ color: GUIDANCE_TOKENS.amber,
194
+ fontSize: 15,
195
+ fontWeight: '600',
196
+ },
197
+ });
@@ -3,22 +3,36 @@
3
3
  * CaptureMemoryPill — top-right diagnostic pill showing native
4
4
  * process memory footprint in MB, polled at 500 ms.
5
5
  *
6
- * Color-coded against the iPhone 16 Pro per-process jetsam limit:
6
+ * Color-coded against the device's per-process memory budget, which is read
7
+ * once at mount via `getDeviceTotalRamMB()` (RAM-aware):
7
8
  *
8
- * - green <1500 MB (comfortable)
9
- * - amber 1500–2200 (approaching pressure)
10
- * - red >2200 (close to limit capture may be killed)
9
+ * budget = max(RAM × 0.42, 900 MB) (mirrors warp_guard.hpp
10
+ * perProcessMemoryBudgetMB)
11
+ * - green < 55 % of budget (comfortable)
12
+ * - amber 55–70 % of budget (approaching pressure)
13
+ * - red > 70 % of budget (close to limit — capture may be killed)
11
14
  *
12
- * Backed by the existing `getMemoryFootprintMB()` native module
13
- * (iOS: `task_info phys_footprint`, Android: `Debug.MemoryInfo
14
- * getTotalPss * 1024`). Returns -1 if the native call fails.
15
+ * Why RAM-aware: the old fixed 1500/2200 MB thresholds were tuned for the
16
+ * iPhone 16 Pro and NEVER tripped on a 4 GB Android phone that jetsams ~1.3 GB
17
+ * (false comfort exactly where OOM happens). Falls back to 1500/2200 if the
18
+ * RAM read is unavailable.
19
+ *
20
+ * Backed by the `getMemoryFootprintMB()` native module (iOS: `task_info`
21
+ * `phys_footprint`; Android: `/proc/self/statm` RSS — the SAME number the C++
22
+ * `[memstat]` logs report). Returns -1 if the native call fails.
15
23
  *
16
24
  * Mount this pill inside a `settings.debug`-gated branch — it
17
25
  * polls native every 500 ms and is unwanted in production builds.
18
26
  */
19
27
 
20
28
  import React, { useEffect, useState } from 'react';
21
- import { StyleSheet, Text, View } from 'react-native';
29
+ import {
30
+ StyleSheet,
31
+ Text,
32
+ View,
33
+ type StyleProp,
34
+ type ViewStyle,
35
+ } from 'react-native';
22
36
 
23
37
  import { getIncrementalNativeModule } from '../stitching/incremental';
24
38
 
@@ -29,18 +43,36 @@ export interface CaptureMemoryPillProps {
29
43
  * for no visible benefit; higher loses correlation with capture
30
44
  * activity. */
31
45
  pollIntervalMs?: number;
46
+ /**
47
+ * Optional position override. When supplied it REPLACES the default
48
+ * top-right anchor (`top: topInset + 56, right: 12`), so the pill can be
49
+ * reused on other screens (e.g. the crop/preview surface) without colliding
50
+ * with their own corner UI. Pass the full absolute position you want.
51
+ */
52
+ style?: StyleProp<ViewStyle>;
32
53
  }
33
54
 
34
55
  export function CaptureMemoryPill({
35
56
  topInset = 0,
36
57
  pollIntervalMs = 500,
58
+ style,
37
59
  }: CaptureMemoryPillProps): React.JSX.Element | null {
38
60
  const [memMB, setMemMB] = useState<number | null>(null);
61
+ // Device total RAM (MB), read once — drives the RAM-aware pressure bands.
62
+ const [ramMB, setRamMB] = useState<number | null>(null);
39
63
 
40
64
  useEffect(() => {
41
65
  const native = getIncrementalNativeModule();
42
66
  if (!native?.getMemoryFootprintMB) return undefined;
43
67
  let cancelled = false;
68
+ // One-time RAM read for the bands (optional native method — older bridges
69
+ // without it just keep the fixed-threshold fallback).
70
+ native
71
+ .getDeviceTotalRamMB?.()
72
+ .then((r) => {
73
+ if (!cancelled && r > 0) setRamMB(r);
74
+ })
75
+ .catch(() => {});
44
76
  const tick = async () => {
45
77
  try {
46
78
  const mb = await native.getMemoryFootprintMB();
@@ -59,9 +91,15 @@ export function CaptureMemoryPill({
59
91
 
60
92
  if (memMB === null || memMB < 0) return null;
61
93
 
94
+ // RAM-aware bands: budget = max(RAM × 0.42, 900) (mirrors warp_guard.hpp
95
+ // perProcessMemoryBudgetMB); amber at 55 %, red at 70 %. Fall back to the
96
+ // iPhone-tuned fixed thresholds when RAM is unknown.
97
+ const budget = ramMB != null ? Math.max(ramMB * 0.42, 900) : null;
98
+ const redAt = budget != null ? budget * 0.7 : 2200;
99
+ const amberAt = budget != null ? budget * 0.55 : 1500;
62
100
  const bg =
63
- memMB > 2200 ? 'rgba(239, 68, 68, 0.92)' // red
64
- : memMB > 1500 ? 'rgba(245, 158, 11, 0.92)' // amber
101
+ memMB > redAt ? 'rgba(239, 68, 68, 0.92)' // red
102
+ : memMB > amberAt ? 'rgba(245, 158, 11, 0.92)' // amber
65
103
  : 'rgba(34, 197, 94, 0.92)'; // green
66
104
 
67
105
  return (
@@ -69,7 +107,8 @@ export function CaptureMemoryPill({
69
107
  pointerEvents="none"
70
108
  style={[
71
109
  styles.container,
72
- { top: topInset + 56, backgroundColor: bg },
110
+ { backgroundColor: bg },
111
+ style ?? { top: topInset + 56, right: 12 },
73
112
  ]}
74
113
  accessibilityRole="alert"
75
114
  >
@@ -81,7 +120,6 @@ export function CaptureMemoryPill({
81
120
  const styles = StyleSheet.create({
82
121
  container: {
83
122
  position: 'absolute',
84
- right: 12,
85
123
  paddingHorizontal: 10,
86
124
  paddingVertical: 5,
87
125
  borderRadius: 999,