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.
- package/CHANGELOG.md +124 -1
- package/README.md +116 -5
- package/android/src/main/cpp/image_stitcher_jni.cpp +107 -11
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +87 -30
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
- package/cpp/crop_quad.cpp +162 -0
- package/cpp/crop_quad.hpp +163 -0
- package/cpp/stitcher.cpp +651 -55
- package/cpp/stitcher.hpp +10 -0
- package/cpp/warp_guard.hpp +212 -0
- package/dist/camera/Camera.d.ts +196 -12
- package/dist/camera/Camera.js +629 -35
- package/dist/camera/CameraView.js +35 -16
- package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
- package/dist/camera/CaptureCountdownOverlay.js +239 -0
- package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
- package/dist/camera/CaptureFrameCounterOverlay.js +142 -0
- package/dist/camera/CaptureMemoryPill.d.ts +9 -1
- package/dist/camera/CaptureMemoryPill.js +3 -3
- package/dist/camera/CapturePreview.js +2 -1
- package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
- package/dist/camera/CaptureStatusOverlay.js +22 -5
- package/dist/camera/CaptureThumbnailStrip.js +2 -1
- package/dist/camera/LateralMotionModal.d.ts +85 -0
- package/dist/camera/LateralMotionModal.js +134 -0
- package/dist/camera/PanHowToOverlay.d.ts +76 -0
- package/dist/camera/PanHowToOverlay.js +222 -0
- package/dist/camera/PanoramaSettings.d.ts +8 -6
- package/dist/camera/PanoramaSettings.js +26 -5
- package/dist/camera/PanoramaSettingsModal.js +4 -4
- package/dist/camera/RectCropPreview.d.ts +161 -0
- package/dist/camera/RectCropPreview.js +480 -0
- package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
- package/dist/camera/RotateToLandscapePrompt.js +138 -0
- package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
- package/dist/camera/buildPanoramaInitialSettings.js +9 -0
- package/dist/camera/cameraErrorMessages.d.ts +30 -1
- package/dist/camera/cameraErrorMessages.js +26 -10
- package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
- package/dist/camera/cameraGuidanceCopy.js +80 -0
- package/dist/camera/captureCountdown.d.ts +52 -0
- package/dist/camera/captureCountdown.js +76 -0
- package/dist/camera/captureWarnings.d.ts +90 -0
- package/dist/camera/captureWarnings.js +108 -0
- package/dist/camera/classifyStitchError.d.ts +30 -0
- package/dist/camera/classifyStitchError.js +42 -0
- package/dist/camera/cropGeometry.d.ts +136 -0
- package/dist/camera/cropGeometry.js +223 -0
- package/dist/camera/displayDecodeImageProps.d.ts +25 -0
- package/dist/camera/displayDecodeImageProps.js +29 -0
- package/dist/camera/guidanceGraphics.d.ts +58 -0
- package/dist/camera/guidanceGraphics.js +280 -0
- package/dist/camera/guidanceTokens.d.ts +54 -0
- package/dist/camera/guidanceTokens.js +58 -0
- package/dist/camera/panModeGate.d.ts +54 -0
- package/dist/camera/panModeGate.js +62 -0
- package/dist/camera/pickCaptureFormat.d.ts +71 -0
- package/dist/camera/pickCaptureFormat.js +85 -0
- package/dist/camera/stitchDebugInfo.d.ts +27 -0
- package/dist/camera/stitchDebugInfo.js +55 -0
- package/dist/camera/usePanMotion.d.ts +250 -0
- package/dist/camera/usePanMotion.js +451 -0
- package/dist/index.d.ts +24 -3
- package/dist/index.js +33 -2
- package/dist/stitching/computeInscribedRect.d.ts +40 -0
- package/dist/stitching/computeInscribedRect.js +55 -0
- package/dist/stitching/cropQuad.d.ts +78 -0
- package/dist/stitching/cropQuad.js +116 -0
- package/dist/stitching/incremental.d.ts +45 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +56 -8
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +191 -7
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
- package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
- package/package.json +5 -1
- package/src/camera/Camera.tsx +994 -47
- package/src/camera/CameraView.tsx +48 -16
- package/src/camera/CaptureCountdownOverlay.tsx +272 -0
- package/src/camera/CaptureFrameCounterOverlay.tsx +183 -0
- package/src/camera/CaptureMemoryPill.tsx +17 -3
- package/src/camera/CapturePreview.tsx +5 -0
- package/src/camera/CaptureStatusOverlay.tsx +35 -7
- package/src/camera/CaptureThumbnailStrip.tsx +4 -0
- package/src/camera/LateralMotionModal.tsx +199 -0
- package/src/camera/PanHowToOverlay.tsx +246 -0
- package/src/camera/PanoramaSettings.ts +34 -11
- package/src/camera/PanoramaSettingsModal.tsx +4 -4
- package/src/camera/RectCropPreview.tsx +820 -0
- package/src/camera/RotateToLandscapePrompt.tsx +188 -0
- package/src/camera/buildPanoramaInitialSettings.ts +30 -1
- package/src/camera/cameraErrorMessages.ts +39 -2
- package/src/camera/cameraGuidanceCopy.ts +145 -0
- package/src/camera/captureCountdown.ts +83 -0
- package/src/camera/captureWarnings.ts +190 -0
- package/src/camera/classifyStitchError.ts +68 -0
- package/src/camera/cropGeometry.ts +268 -0
- package/src/camera/displayDecodeImageProps.ts +25 -0
- package/src/camera/guidanceGraphics.tsx +347 -0
- package/src/camera/guidanceTokens.ts +57 -0
- package/src/camera/panModeGate.ts +81 -0
- package/src/camera/pickCaptureFormat.ts +130 -0
- package/src/camera/stitchDebugInfo.ts +71 -0
- package/src/camera/usePanMotion.ts +667 -0
- package/src/index.ts +66 -3
- package/src/stitching/computeInscribedRect.ts +81 -0
- package/src/stitching/cropQuad.ts +167 -0
- package/src/stitching/incremental.ts +45 -0
- package/cpp/tests/CMakeLists.txt +0 -104
- package/cpp/tests/README.md +0 -86
- package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
- package/cpp/tests/pose_test.cpp +0 -74
- package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
- package/cpp/tests/stubs/jsi/jsi.h +0 -33
- package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
- package/cpp/tests/warp_guard_test.cpp +0 -48
- package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
- package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
- package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
- package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
- package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
- package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
- package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
- package/src/camera/__tests__/useContentRotation.test.ts +0 -89
- package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
- package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
- 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
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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 authoritative
|
|
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 {
|
|
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
|
-
{
|
|
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>
|