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