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
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* RectCropPreview — item-7 of the first-time-user guidance flow: the
|
|
4
|
+
* post-capture crop editor.
|
|
5
|
+
*
|
|
6
|
+
* Shows the full stitched result image (contain-fit, letterboxed) with a
|
|
7
|
+
* 4-corner quad overlay. Each corner is INDEPENDENTLY draggable in
|
|
8
|
+
* on-screen coords via RN-core `PanResponder` (deliberately NO
|
|
9
|
+
* react-native-gesture-handler dependency — this library ships zero extra
|
|
10
|
+
* native deps for guidance). Corner positions are mapped to image-pixel
|
|
11
|
+
* space through the pure `cropGeometry` letterbox transform.
|
|
12
|
+
*
|
|
13
|
+
* ## What it surfaces (and what it does NOT do)
|
|
14
|
+
*
|
|
15
|
+
* This component is presentation + gesture only. On confirm it computes
|
|
16
|
+
* the 4 image-pixel corners and hands them to `onConfirm` — it does NOT
|
|
17
|
+
* call any native crop. The PARENT decides between the cheap axis-aligned
|
|
18
|
+
* `cropToRect` (when the quad is ~rectangular) and the perspective
|
|
19
|
+
* `cropToQuad`, using the `perspective` flag in the result:
|
|
20
|
+
*
|
|
21
|
+
* onConfirm({ quad, perspective: perspectiveCorrect && !isAxisAligned })
|
|
22
|
+
*
|
|
23
|
+
* Promoted + extended from `example/InscribedRectDebug.tsx`, which already
|
|
24
|
+
* did the image-px ↔ on-screen contain-fit mapping, a rect overlay, and
|
|
25
|
+
* the in-place native crop. This version replaces the single computed
|
|
26
|
+
* inscribed rect with a user-draggable free quad and the perspective
|
|
27
|
+
* decision; the letterbox math now lives in the shared `cropGeometry`
|
|
28
|
+
* module. Styling is carried over from InscribedRectDebug.
|
|
29
|
+
*
|
|
30
|
+
* ## Seeding
|
|
31
|
+
*
|
|
32
|
+
* The initial quad comes from `initialRect` (image-pixel coords) when the
|
|
33
|
+
* host passes one — `<Camera>` passes the panorama's MAX-INSCRIBED rectangle
|
|
34
|
+
* (the tightest clean rectangle with no black corners; item 2) so the editor
|
|
35
|
+
* opens on a sensible crop the user drags to taste. With no `initialRect`
|
|
36
|
+
* (native inscribed-rect unavailable) it falls back to an 8 %-inset
|
|
37
|
+
* rectangle. "Reset" returns to whichever seed was used.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import React, {
|
|
41
|
+
useCallback,
|
|
42
|
+
useMemo,
|
|
43
|
+
useRef,
|
|
44
|
+
useState,
|
|
45
|
+
} from 'react';
|
|
46
|
+
import {
|
|
47
|
+
ActivityIndicator,
|
|
48
|
+
Image,
|
|
49
|
+
Modal,
|
|
50
|
+
PanResponder,
|
|
51
|
+
Platform,
|
|
52
|
+
Pressable,
|
|
53
|
+
StyleSheet,
|
|
54
|
+
Text,
|
|
55
|
+
View,
|
|
56
|
+
type GestureResponderEvent,
|
|
57
|
+
type LayoutChangeEvent,
|
|
58
|
+
type PanResponderGestureState,
|
|
59
|
+
type ViewStyle,
|
|
60
|
+
} from 'react-native';
|
|
61
|
+
|
|
62
|
+
import {
|
|
63
|
+
type GuidanceCopy,
|
|
64
|
+
mergeGuidanceCopy,
|
|
65
|
+
} from './cameraGuidanceCopy';
|
|
66
|
+
import {
|
|
67
|
+
containFit,
|
|
68
|
+
imageToScreen,
|
|
69
|
+
isAxisAlignedRect,
|
|
70
|
+
isQuadValid,
|
|
71
|
+
orderQuadCorners,
|
|
72
|
+
screenToImage,
|
|
73
|
+
type ContainLayout,
|
|
74
|
+
type Point,
|
|
75
|
+
type Quad,
|
|
76
|
+
} from './cropGeometry';
|
|
77
|
+
import { GUIDANCE_TOKENS } from './guidanceTokens';
|
|
78
|
+
import { CaptureMemoryPill } from './CaptureMemoryPill';
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
/** Image-pixel rectangle, used for the optional `initialRect` seed. */
|
|
82
|
+
export interface ImageRect {
|
|
83
|
+
x: number;
|
|
84
|
+
y: number;
|
|
85
|
+
width: number;
|
|
86
|
+
height: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What the host receives when the user taps Crop. */
|
|
90
|
+
export interface RectCropResult {
|
|
91
|
+
/**
|
|
92
|
+
* The 4 chosen corners in IMAGE-PIXEL space, canonically ordered
|
|
93
|
+
* [TL, TR, BR, BL]. The host feeds these to the native crop.
|
|
94
|
+
*/
|
|
95
|
+
quad: Quad;
|
|
96
|
+
/**
|
|
97
|
+
* `true` → the host should perspective-rectify (`cropToQuad`): the user
|
|
98
|
+
* picked a non-rectangular quad and `perspectiveCorrect` is enabled.
|
|
99
|
+
* `false` → the host can use the cheap axis-aligned `cropToRect` (the
|
|
100
|
+
* quad is ~rectangular, or perspective correction is disabled).
|
|
101
|
+
*/
|
|
102
|
+
perspective: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
export interface RectCropPreviewProps {
|
|
107
|
+
/** file:// URI of the full result image to crop. */
|
|
108
|
+
imageUri: string;
|
|
109
|
+
/** Intrinsic pixel width of `imageUri`. */
|
|
110
|
+
imageWidth: number;
|
|
111
|
+
/** Intrinsic pixel height of `imageUri`. */
|
|
112
|
+
imageHeight: number;
|
|
113
|
+
/**
|
|
114
|
+
* DEBUG A/B harness — file:// URI of the SAME capture stitched by the
|
|
115
|
+
* OPPOSITE pipeline (manual cv::detail + plane). When set, a toggle appears
|
|
116
|
+
* that flips the displayed panorama between the primary (high-level +
|
|
117
|
+
* spherical) and this one, for on-device comparison on a single capture.
|
|
118
|
+
* Its dimensions are read at runtime via `Image.getSize`. When the manual
|
|
119
|
+
* output is showing, the crop quad is hidden and the accept button emits
|
|
120
|
+
* THIS uri (so you can pick the better pipeline per capture).
|
|
121
|
+
*/
|
|
122
|
+
altImageUri?: string;
|
|
123
|
+
/**
|
|
124
|
+
* 2026-06-15 — ON-DEMAND alt (high-level) stitch. The PRIMARY image is the
|
|
125
|
+
* MANUAL pipeline (the default output); this callback re-stitches the SAME
|
|
126
|
+
* captured keyframes via cv::Stitcher and resolves with a file:// uri (or
|
|
127
|
+
* null on failure). It runs only the FIRST time the user taps the
|
|
128
|
+
* "High-level" tab — nothing is computed unless asked for. When provided (or
|
|
129
|
+
* `altImageUri` is), the A/B toggle appears.
|
|
130
|
+
*
|
|
131
|
+
* Resolves with the high-level output's file:// `uri` AND its OWN
|
|
132
|
+
* DEV-overlay `debugInfo` recipe (so the params pill can switch to the
|
|
133
|
+
* high-level recipe while that tab is viewed), or `null` on failure.
|
|
134
|
+
*/
|
|
135
|
+
onRequestAlt?: () => Promise<{ uri: string; debugInfo: string } | null>;
|
|
136
|
+
/** Show / hide the editor. */
|
|
137
|
+
visible: boolean;
|
|
138
|
+
/**
|
|
139
|
+
* Tapped on "Crop". Receives the ordered image-pixel quad + the
|
|
140
|
+
* perspective decision; the host performs the actual native crop.
|
|
141
|
+
*/
|
|
142
|
+
onConfirm: (result: RectCropResult) => void;
|
|
143
|
+
/**
|
|
144
|
+
* Tapped on "Use original" (or hardware back / dismiss) — emit the stitch
|
|
145
|
+
* un-cropped. Also called when the user collapses the quad to something
|
|
146
|
+
* un-warpable, so a degenerate quad never reaches the native crop.
|
|
147
|
+
*/
|
|
148
|
+
onUseOriginal: (uri?: string) => void;
|
|
149
|
+
/**
|
|
150
|
+
* Tapped on "Retake" — discard this capture entirely and return to the
|
|
151
|
+
* camera. No result is emitted (the host clears the editor + lets the
|
|
152
|
+
* user capture again).
|
|
153
|
+
*/
|
|
154
|
+
onRetake: () => void;
|
|
155
|
+
/**
|
|
156
|
+
* Optional non-fatal warning messages (e.g. "<70 % of frames used") shown
|
|
157
|
+
* as a banner across the top of the editor so the user sees them before
|
|
158
|
+
* accepting a crop. Empty / undefined → no banner.
|
|
159
|
+
*/
|
|
160
|
+
warnings?: string[];
|
|
161
|
+
/**
|
|
162
|
+
* Crop mode vs preview-only mode. `true` (default) shows the draggable
|
|
163
|
+
* quad + corner handles + the [Retake][Use original][Crop] bar — the full
|
|
164
|
+
* crop editor. `false` hides the quad and all crop affordances, showing
|
|
165
|
+
* just the stitched image with a [Retake][Confirm] bar — a plain preview
|
|
166
|
+
* (`<Camera showPreview>` without `rectCrop`). Confirm emits the image
|
|
167
|
+
* un-cropped (same as "Use original").
|
|
168
|
+
*/
|
|
169
|
+
showCropControls?: boolean;
|
|
170
|
+
/**
|
|
171
|
+
* Optional image-pixel seed rect for the draggable quad. Defaults to
|
|
172
|
+
* an 8 %-inset rectangle of the full image. Ignored in preview-only mode.
|
|
173
|
+
*/
|
|
174
|
+
initialRect?: ImageRect;
|
|
175
|
+
/** Copy overrides (cropConfirm / cropReset). Falls back to defaults. */
|
|
176
|
+
copy?: Partial<GuidanceCopy>;
|
|
177
|
+
/**
|
|
178
|
+
* Safe-area insets (px). The editor is a full-screen Modal, so the host
|
|
179
|
+
* passes `insets.top`/`insets.bottom` to keep the top toolbar (A/B toggle,
|
|
180
|
+
* warnings) clear of the notch/Dynamic Island and the bottom button bar
|
|
181
|
+
* clear of the home indicator. Default 0.
|
|
182
|
+
*/
|
|
183
|
+
topInset?: number;
|
|
184
|
+
bottomInset?: number;
|
|
185
|
+
/**
|
|
186
|
+
* 2026-06-14 (DEV overlay) — optional multi-line debug text describing how
|
|
187
|
+
* this output was stitched (pipeline / warper / route / seam / blend / score
|
|
188
|
+
* / frames / size). When non-empty, rendered as a small monospace pill in
|
|
189
|
+
* the top-right corner. The host gates this on `__DEV__`; this component
|
|
190
|
+
* just renders whatever non-empty string it's given.
|
|
191
|
+
*/
|
|
192
|
+
debugInfo?: string;
|
|
193
|
+
/**
|
|
194
|
+
* 2026-06-15 — show the live memory-footprint pill (polled native RSS,
|
|
195
|
+
* green/amber/red) on the preview too, so the operator can watch the spike
|
|
196
|
+
* when the on-demand high-level re-stitch fires. Host gates on settings.debug.
|
|
197
|
+
*/
|
|
198
|
+
showMemoryPill?: boolean;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
/** Default inset (fraction of each dimension) for the seed quad. */
|
|
203
|
+
const DEFAULT_INSET_FRACTION = 0.08;
|
|
204
|
+
/** On-screen radius of each draggable corner handle. */
|
|
205
|
+
const HANDLE_RADIUS = 16;
|
|
206
|
+
/** Enlarged hit-slop radius so the small handle is easy to grab. */
|
|
207
|
+
const HANDLE_HIT_RADIUS = 28;
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Build the seed quad in IMAGE-PIXEL coords: the host's `initialRect` if
|
|
212
|
+
* given, else an inset rectangle of the full image. Always returned in
|
|
213
|
+
* [TL, TR, BR, BL] order.
|
|
214
|
+
*/
|
|
215
|
+
function seedImageQuad(
|
|
216
|
+
imageWidth: number,
|
|
217
|
+
imageHeight: number,
|
|
218
|
+
initialRect?: ImageRect,
|
|
219
|
+
): Quad {
|
|
220
|
+
if (initialRect) {
|
|
221
|
+
const { x, y, width, height } = initialRect;
|
|
222
|
+
return [
|
|
223
|
+
{ x, y },
|
|
224
|
+
{ x: x + width, y },
|
|
225
|
+
{ x: x + width, y: y + height },
|
|
226
|
+
{ x, y: y + height },
|
|
227
|
+
];
|
|
228
|
+
}
|
|
229
|
+
const ix = imageWidth * DEFAULT_INSET_FRACTION;
|
|
230
|
+
const iy = imageHeight * DEFAULT_INSET_FRACTION;
|
|
231
|
+
return [
|
|
232
|
+
{ x: ix, y: iy },
|
|
233
|
+
{ x: imageWidth - ix, y: iy },
|
|
234
|
+
{ x: imageWidth - ix, y: imageHeight - iy },
|
|
235
|
+
{ x: ix, y: imageHeight - iy },
|
|
236
|
+
];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
export function RectCropPreview(
|
|
241
|
+
props: RectCropPreviewProps,
|
|
242
|
+
): React.JSX.Element {
|
|
243
|
+
const {
|
|
244
|
+
imageUri,
|
|
245
|
+
imageWidth,
|
|
246
|
+
imageHeight,
|
|
247
|
+
altImageUri,
|
|
248
|
+
visible,
|
|
249
|
+
onConfirm,
|
|
250
|
+
onUseOriginal,
|
|
251
|
+
onRetake,
|
|
252
|
+
warnings,
|
|
253
|
+
showCropControls = true,
|
|
254
|
+
initialRect,
|
|
255
|
+
copy,
|
|
256
|
+
topInset = 0,
|
|
257
|
+
bottomInset = 0,
|
|
258
|
+
debugInfo,
|
|
259
|
+
onRequestAlt,
|
|
260
|
+
showMemoryPill,
|
|
261
|
+
} = props;
|
|
262
|
+
|
|
263
|
+
const resolvedCopy = useMemo(() => mergeGuidanceCopy(copy), [copy]);
|
|
264
|
+
|
|
265
|
+
// ── A/B comparison — the PRIMARY (imageUri) is the MANUAL pipeline (the
|
|
266
|
+
// default output). The alt is HIGH-LEVEL cv::Stitcher, produced either
|
|
267
|
+
// EAGERLY (`altImageUri`, legacy) or ON DEMAND (`onRequestAlt`, re-stitched
|
|
268
|
+
// the first time the user opens the high-level tab). `altSize` is fetched
|
|
269
|
+
// once the alt uri exists; when the alt is showing we use its dims for the
|
|
270
|
+
// contain-fit and hide the crop quad.
|
|
271
|
+
const [showingAlt, setShowingAlt] = useState(false);
|
|
272
|
+
const [lazyAltUri, setLazyAltUri] = useState<string | null>(null);
|
|
273
|
+
// The high-level (alt) stitch's OWN DEV-overlay recipe, resolved alongside
|
|
274
|
+
// its uri from `onRequestAlt`. Shown in the params pill in place of the
|
|
275
|
+
// manual primary's `debugInfo` while the high-level tab is being viewed.
|
|
276
|
+
const [lazyAltDebugInfo, setLazyAltDebugInfo] = useState<string | null>(null);
|
|
277
|
+
const [altLoading, setAltLoading] = useState(false);
|
|
278
|
+
const [altFailed, setAltFailed] = useState(false);
|
|
279
|
+
const altUri = altImageUri ?? lazyAltUri ?? null;
|
|
280
|
+
const altOffered = !!altImageUri || !!onRequestAlt;
|
|
281
|
+
const [altSize, setAltSize] = useState<{ w: number; h: number } | null>(null);
|
|
282
|
+
React.useEffect(() => {
|
|
283
|
+
if (!altUri) {
|
|
284
|
+
setAltSize(null);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
Image.getSize(
|
|
288
|
+
altUri,
|
|
289
|
+
(w, h) => setAltSize({ w, h }),
|
|
290
|
+
() => setAltSize(null),
|
|
291
|
+
);
|
|
292
|
+
}, [altUri]);
|
|
293
|
+
// Switch to the high-level (alt) view; compute it lazily on first request.
|
|
294
|
+
const showHighLevel = React.useCallback(() => {
|
|
295
|
+
setShowingAlt(true);
|
|
296
|
+
if (altUri || altLoading || !onRequestAlt) return;
|
|
297
|
+
setAltFailed(false);
|
|
298
|
+
setAltLoading(true);
|
|
299
|
+
onRequestAlt()
|
|
300
|
+
.then((result) => {
|
|
301
|
+
if (result) {
|
|
302
|
+
setLazyAltUri(result.uri);
|
|
303
|
+
setLazyAltDebugInfo(result.debugInfo);
|
|
304
|
+
} else {
|
|
305
|
+
setAltFailed(true);
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
.catch(() => setAltFailed(true))
|
|
309
|
+
.finally(() => setAltLoading(false));
|
|
310
|
+
}, [altUri, altLoading, onRequestAlt]);
|
|
311
|
+
const showAlt = showingAlt && !!altUri && !!altSize;
|
|
312
|
+
const activeUri = showAlt ? (altUri as string) : imageUri;
|
|
313
|
+
const activeW = showAlt ? (altSize as { w: number }).w : imageWidth;
|
|
314
|
+
const activeH = showAlt ? (altSize as { h: number }).h : imageHeight;
|
|
315
|
+
|
|
316
|
+
// The 4 corners live in IMAGE-PIXEL space (the source of truth) so they
|
|
317
|
+
// survive layout-box changes (rotation, keyboard) without drift. We map
|
|
318
|
+
// to screen for rendering and back on every drag via cropGeometry.
|
|
319
|
+
const [imageQuad, setImageQuad] = useState<Quad>(() =>
|
|
320
|
+
seedImageQuad(imageWidth, imageHeight, initialRect),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
const [box, setBox] = useState<ContainLayout | null>(null);
|
|
324
|
+
|
|
325
|
+
// Drag bookkeeping kept in refs so the per-move handler doesn't churn
|
|
326
|
+
// state identity / re-create PanResponders mid-gesture.
|
|
327
|
+
const boxRef = useRef<ContainLayout | null>(null);
|
|
328
|
+
const dragCornerRef = useRef<number | null>(null);
|
|
329
|
+
// Screen-space corner positions at gesture start, so dx/dy from the
|
|
330
|
+
// gesture state apply to a stable origin (PanResponder reports
|
|
331
|
+
// cumulative deltas from touch-down, not per-frame).
|
|
332
|
+
const dragStartScreenRef = useRef<Point | null>(null);
|
|
333
|
+
|
|
334
|
+
const onLayout = useCallback((e: LayoutChangeEvent) => {
|
|
335
|
+
const { width, height } = e.nativeEvent.layout;
|
|
336
|
+
const next = { width, height };
|
|
337
|
+
boxRef.current = next;
|
|
338
|
+
setBox(next);
|
|
339
|
+
}, []);
|
|
340
|
+
|
|
341
|
+
const handleConfirm = useCallback(() => {
|
|
342
|
+
const ordered = orderQuadCorners(imageQuad);
|
|
343
|
+
// Guard: if the user collapsed the quad to something un-warpable, emit
|
|
344
|
+
// the original un-cropped panorama rather than hand native a degenerate
|
|
345
|
+
// quad.
|
|
346
|
+
if (!isQuadValid(ordered)) {
|
|
347
|
+
onUseOriginal();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const axisAligned = isAxisAlignedRect(ordered);
|
|
351
|
+
// A skewed quad is perspective-rectified; a ~rectangular drag is a plain
|
|
352
|
+
// axis-aligned crop. (The former `perspectiveCorrect` opt-out was removed
|
|
353
|
+
// in v0.16 — the SDK always honours a skewed quad with a warp.)
|
|
354
|
+
onConfirm({
|
|
355
|
+
quad: ordered,
|
|
356
|
+
perspective: !axisAligned,
|
|
357
|
+
});
|
|
358
|
+
}, [imageQuad, onConfirm, onUseOriginal]);
|
|
359
|
+
|
|
360
|
+
// One PanResponder per corner. Built once (the corner index is the
|
|
361
|
+
// closure key); the move handler reads live box/quad via refs + setState
|
|
362
|
+
// updater so the responders never need to be rebuilt on drag.
|
|
363
|
+
const responders = useMemo(
|
|
364
|
+
() =>
|
|
365
|
+
[0, 1, 2, 3].map((corner) =>
|
|
366
|
+
PanResponder.create({
|
|
367
|
+
onStartShouldSetPanResponder: () => true,
|
|
368
|
+
onMoveShouldSetPanResponder: () => true,
|
|
369
|
+
onPanResponderGrant: () => {
|
|
370
|
+
dragCornerRef.current = corner;
|
|
371
|
+
const layout = boxRef.current;
|
|
372
|
+
if (!layout) return;
|
|
373
|
+
// Capture this corner's screen position at touch-down.
|
|
374
|
+
setImageQuad((q) => {
|
|
375
|
+
dragStartScreenRef.current = imageToScreen(
|
|
376
|
+
q[corner],
|
|
377
|
+
layout,
|
|
378
|
+
imageWidth,
|
|
379
|
+
imageHeight,
|
|
380
|
+
);
|
|
381
|
+
return q;
|
|
382
|
+
});
|
|
383
|
+
},
|
|
384
|
+
onPanResponderMove: (
|
|
385
|
+
_e: GestureResponderEvent,
|
|
386
|
+
gesture: PanResponderGestureState,
|
|
387
|
+
) => {
|
|
388
|
+
const layout = boxRef.current;
|
|
389
|
+
const start = dragStartScreenRef.current;
|
|
390
|
+
if (!layout || !start) return;
|
|
391
|
+
const screenPt: Point = {
|
|
392
|
+
x: start.x + gesture.dx,
|
|
393
|
+
y: start.y + gesture.dy,
|
|
394
|
+
};
|
|
395
|
+
const imgPt = screenToImage(
|
|
396
|
+
screenPt,
|
|
397
|
+
layout,
|
|
398
|
+
imageWidth,
|
|
399
|
+
imageHeight,
|
|
400
|
+
);
|
|
401
|
+
setImageQuad((q) => {
|
|
402
|
+
const next = [...q] as Quad;
|
|
403
|
+
next[corner] = imgPt;
|
|
404
|
+
return next;
|
|
405
|
+
});
|
|
406
|
+
},
|
|
407
|
+
onPanResponderRelease: () => {
|
|
408
|
+
dragCornerRef.current = null;
|
|
409
|
+
dragStartScreenRef.current = null;
|
|
410
|
+
},
|
|
411
|
+
onPanResponderTerminate: () => {
|
|
412
|
+
dragCornerRef.current = null;
|
|
413
|
+
dragStartScreenRef.current = null;
|
|
414
|
+
},
|
|
415
|
+
}),
|
|
416
|
+
),
|
|
417
|
+
[imageWidth, imageHeight],
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
// ── Derived screen geometry (recomputed each render from the box) ──
|
|
421
|
+
// The display box (letterboxed image rect) and the 4 corners projected
|
|
422
|
+
// into screen space for the overlay + handles.
|
|
423
|
+
let imageBox: {
|
|
424
|
+
left: number;
|
|
425
|
+
top: number;
|
|
426
|
+
width: number;
|
|
427
|
+
height: number;
|
|
428
|
+
} | null = null;
|
|
429
|
+
let screenCorners: Point[] | null = null;
|
|
430
|
+
|
|
431
|
+
if (box) {
|
|
432
|
+
const fit = containFit(box, activeW, activeH);
|
|
433
|
+
if (fit) {
|
|
434
|
+
imageBox = {
|
|
435
|
+
left: fit.offX,
|
|
436
|
+
top: fit.offY,
|
|
437
|
+
width: activeW * fit.scale,
|
|
438
|
+
height: activeH * fit.scale,
|
|
439
|
+
};
|
|
440
|
+
// Quad corners only apply to the primary (croppable) image — hidden
|
|
441
|
+
// while the alt (manual) output is shown for comparison.
|
|
442
|
+
screenCorners = showAlt
|
|
443
|
+
? null
|
|
444
|
+
: imageQuad.map((p) => imageToScreen(p, box, imageWidth, imageHeight));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Outline path (a <View> per edge — RN core has no <Polygon>; this
|
|
449
|
+
// mirrors InscribedRectDebug's single-rect border treatment, generalised
|
|
450
|
+
// to 4 free edges).
|
|
451
|
+
const edges = screenCorners
|
|
452
|
+
? screenCorners.map((from, i) => {
|
|
453
|
+
const to = screenCorners![(i + 1) % screenCorners!.length];
|
|
454
|
+
return edgeStyle(from, to);
|
|
455
|
+
})
|
|
456
|
+
: [];
|
|
457
|
+
|
|
458
|
+
return (
|
|
459
|
+
<Modal
|
|
460
|
+
visible={visible}
|
|
461
|
+
animationType="fade"
|
|
462
|
+
onRequestClose={() => onUseOriginal()}
|
|
463
|
+
accessibilityLabel={
|
|
464
|
+
showCropControls
|
|
465
|
+
? 'Crop the captured panorama'
|
|
466
|
+
: 'Review the captured panorama'
|
|
467
|
+
}
|
|
468
|
+
// Mirror OrientationDriftModal: declare all 4 orientations so iOS
|
|
469
|
+
// doesn't force-rotate the window when this opens mid-rotation.
|
|
470
|
+
supportedOrientations={[
|
|
471
|
+
'portrait',
|
|
472
|
+
'portrait-upside-down',
|
|
473
|
+
'landscape-left',
|
|
474
|
+
'landscape-right',
|
|
475
|
+
]}
|
|
476
|
+
>
|
|
477
|
+
<View style={[styles.root, { paddingTop: topInset }]}>
|
|
478
|
+
{/* Live memory-footprint pill (host gates on settings.debug). Top-LEFT
|
|
479
|
+
so it clears the top-right stitch-params pill; watch it spike when
|
|
480
|
+
the high-level re-stitch fires. */}
|
|
481
|
+
{showMemoryPill ? (
|
|
482
|
+
<CaptureMemoryPill
|
|
483
|
+
style={{
|
|
484
|
+
position: 'absolute',
|
|
485
|
+
top: topInset + (altOffered ? 76 : 8),
|
|
486
|
+
left: 12,
|
|
487
|
+
zIndex: 21,
|
|
488
|
+
}}
|
|
489
|
+
/>
|
|
490
|
+
) : null}
|
|
491
|
+
|
|
492
|
+
{/* DEV stitch-params overlay (host gates on __DEV__). Top-right pill;
|
|
493
|
+
pushed below the A/B bar when that's present so they don't overlap.
|
|
494
|
+
A/B-AWARE: while the user is viewing the on-demand high-level tab and
|
|
495
|
+
its recipe is known, show the HIGH-LEVEL recipe (pipe=highlevel;…)
|
|
496
|
+
instead of the manual primary's `debugInfo` — otherwise the pill
|
|
497
|
+
would misleadingly claim the manual recipe for a high-level output. */}
|
|
498
|
+
{(() => {
|
|
499
|
+
const pillText =
|
|
500
|
+
showAlt && lazyAltDebugInfo ? lazyAltDebugInfo : debugInfo;
|
|
501
|
+
return pillText ? (
|
|
502
|
+
<View
|
|
503
|
+
style={[
|
|
504
|
+
styles.debugPill,
|
|
505
|
+
{ top: topInset + (altImageUri && altSize ? 76 : 8) },
|
|
506
|
+
]}
|
|
507
|
+
pointerEvents="none"
|
|
508
|
+
accessibilityRole="text"
|
|
509
|
+
>
|
|
510
|
+
<Text style={styles.debugPillText}>{pillText}</Text>
|
|
511
|
+
</View>
|
|
512
|
+
) : null;
|
|
513
|
+
})()}
|
|
514
|
+
|
|
515
|
+
{/* Non-fatal warning banner (e.g. "<70 % of frames used"), shown
|
|
516
|
+
ABOVE the image so the user sees it before accepting a crop. */}
|
|
517
|
+
{warnings && warnings.length > 0 && (
|
|
518
|
+
<View style={styles.warningBanner} accessibilityRole="alert">
|
|
519
|
+
{warnings.map((w, i) => (
|
|
520
|
+
<Text key={`warn-${i}`} style={styles.warningText}>
|
|
521
|
+
{w}
|
|
522
|
+
</Text>
|
|
523
|
+
))}
|
|
524
|
+
</View>
|
|
525
|
+
)}
|
|
526
|
+
|
|
527
|
+
{/* A/B comparison. Primary = MANUAL (the default output); the
|
|
528
|
+
HIGH-LEVEL segment re-stitches the same keyframes ON DEMAND the
|
|
529
|
+
first time it's tapped (spinner while it runs, then it caches). */}
|
|
530
|
+
{altOffered && (
|
|
531
|
+
<View style={styles.abBar}>
|
|
532
|
+
<Text style={styles.abBarLabel}>
|
|
533
|
+
{altLoading
|
|
534
|
+
? 'Stitching high-level… (manual shown meanwhile)'
|
|
535
|
+
: altFailed
|
|
536
|
+
? 'High-level stitch failed — showing manual'
|
|
537
|
+
: 'Viewing the highlighted pipeline — tap to switch:'}
|
|
538
|
+
</Text>
|
|
539
|
+
<View style={styles.abSegments}>
|
|
540
|
+
<Pressable
|
|
541
|
+
style={[styles.abSeg, !showAlt && styles.abSegActive]}
|
|
542
|
+
onPress={() => setShowingAlt(false)}
|
|
543
|
+
accessibilityRole="button"
|
|
544
|
+
accessibilityState={{ selected: !showAlt }}
|
|
545
|
+
accessibilityLabel="View manual pipeline (default)"
|
|
546
|
+
>
|
|
547
|
+
<Text style={[styles.abSegText, !showAlt && styles.abSegTextActive]}>
|
|
548
|
+
Manual
|
|
549
|
+
</Text>
|
|
550
|
+
</Pressable>
|
|
551
|
+
<Pressable
|
|
552
|
+
style={[styles.abSeg, showAlt && styles.abSegActive]}
|
|
553
|
+
onPress={showHighLevel}
|
|
554
|
+
accessibilityRole="button"
|
|
555
|
+
accessibilityState={{ selected: showAlt, busy: altLoading }}
|
|
556
|
+
accessibilityLabel="View high-level pipeline (computed on demand)"
|
|
557
|
+
>
|
|
558
|
+
{altLoading ? (
|
|
559
|
+
<ActivityIndicator size="small" color="#fff" />
|
|
560
|
+
) : (
|
|
561
|
+
<Text style={[styles.abSegText, showAlt && styles.abSegTextActive]}>
|
|
562
|
+
High-level
|
|
563
|
+
</Text>
|
|
564
|
+
)}
|
|
565
|
+
</Pressable>
|
|
566
|
+
</View>
|
|
567
|
+
</View>
|
|
568
|
+
)}
|
|
569
|
+
|
|
570
|
+
<View style={styles.canvas} onLayout={onLayout}>
|
|
571
|
+
{imageBox && (
|
|
572
|
+
<Image
|
|
573
|
+
source={{ uri: activeUri }}
|
|
574
|
+
style={[styles.image, imageBox]}
|
|
575
|
+
resizeMode="stretch"
|
|
576
|
+
/>
|
|
577
|
+
)}
|
|
578
|
+
|
|
579
|
+
{/* Crop affordances — quad edges + draggable handles — only in
|
|
580
|
+
crop mode on the PRIMARY image (hidden while comparing the alt). */}
|
|
581
|
+
{showCropControls && !showAlt && (
|
|
582
|
+
<>
|
|
583
|
+
{/* Quad edges (non-interactive). */}
|
|
584
|
+
{edges.map((e, i) => (
|
|
585
|
+
<View key={`edge-${i}`} style={[styles.edge, e]} pointerEvents="none" />
|
|
586
|
+
))}
|
|
587
|
+
|
|
588
|
+
{/* Draggable corner handles. */}
|
|
589
|
+
{screenCorners
|
|
590
|
+
&& screenCorners.map((c, i) => (
|
|
591
|
+
<View
|
|
592
|
+
key={`handle-${i}`}
|
|
593
|
+
{...responders[i].panHandlers}
|
|
594
|
+
hitSlop={{
|
|
595
|
+
top: HANDLE_HIT_RADIUS,
|
|
596
|
+
bottom: HANDLE_HIT_RADIUS,
|
|
597
|
+
left: HANDLE_HIT_RADIUS,
|
|
598
|
+
right: HANDLE_HIT_RADIUS,
|
|
599
|
+
}}
|
|
600
|
+
accessibilityRole="adjustable"
|
|
601
|
+
accessibilityLabel={`Crop corner ${i + 1}`}
|
|
602
|
+
style={[
|
|
603
|
+
styles.handle,
|
|
604
|
+
{
|
|
605
|
+
left: c.x - HANDLE_RADIUS,
|
|
606
|
+
top: c.y - HANDLE_RADIUS,
|
|
607
|
+
},
|
|
608
|
+
]}
|
|
609
|
+
>
|
|
610
|
+
<View style={styles.handleDot} pointerEvents="none" />
|
|
611
|
+
</View>
|
|
612
|
+
))}
|
|
613
|
+
</>
|
|
614
|
+
)}
|
|
615
|
+
</View>
|
|
616
|
+
|
|
617
|
+
<View style={[styles.bar, { paddingBottom: 16 + bottomInset }]}>
|
|
618
|
+
<View style={styles.buttons}>
|
|
619
|
+
{/* "Retake" — discard this capture, back to the camera. */}
|
|
620
|
+
<Pressable
|
|
621
|
+
style={({ pressed }) => [styles.btn, pressed && styles.btnPressed]}
|
|
622
|
+
onPress={onRetake}
|
|
623
|
+
accessibilityRole="button"
|
|
624
|
+
accessibilityLabel={resolvedCopy.cropRetake}
|
|
625
|
+
>
|
|
626
|
+
<Text style={styles.btnText}>{resolvedCopy.cropRetake}</Text>
|
|
627
|
+
</Pressable>
|
|
628
|
+
{/* Crop mode only — "Use original" emits the stitch un-cropped.
|
|
629
|
+
Hidden in preview-only mode and while comparing the alt (the
|
|
630
|
+
primary button below is the accept action there). */}
|
|
631
|
+
{showCropControls && !showAlt && (
|
|
632
|
+
<Pressable
|
|
633
|
+
style={({ pressed }) => [styles.btn, pressed && styles.btnPressed]}
|
|
634
|
+
onPress={() => onUseOriginal()}
|
|
635
|
+
accessibilityRole="button"
|
|
636
|
+
accessibilityLabel={resolvedCopy.cropUseOriginal}
|
|
637
|
+
>
|
|
638
|
+
<Text style={styles.btnText}>{resolvedCopy.cropUseOriginal}</Text>
|
|
639
|
+
</Pressable>
|
|
640
|
+
)}
|
|
641
|
+
{/* Primary action — "Use this" emits the SHOWN (alt) pipeline's
|
|
642
|
+
output; otherwise "Crop" applies the quad (crop mode) or
|
|
643
|
+
"Confirm" accepts the primary as-is (preview-only mode). */}
|
|
644
|
+
<Pressable
|
|
645
|
+
style={({ pressed }) => [
|
|
646
|
+
styles.btn,
|
|
647
|
+
styles.primary,
|
|
648
|
+
pressed && styles.btnPressed,
|
|
649
|
+
]}
|
|
650
|
+
onPress={
|
|
651
|
+
showAlt
|
|
652
|
+
? () => onUseOriginal(activeUri)
|
|
653
|
+
: showCropControls
|
|
654
|
+
? handleConfirm
|
|
655
|
+
: () => onUseOriginal()
|
|
656
|
+
}
|
|
657
|
+
accessibilityRole="button"
|
|
658
|
+
accessibilityLabel={
|
|
659
|
+
showAlt
|
|
660
|
+
? 'Use this output'
|
|
661
|
+
: showCropControls
|
|
662
|
+
? resolvedCopy.cropConfirm
|
|
663
|
+
: resolvedCopy.previewConfirm
|
|
664
|
+
}
|
|
665
|
+
>
|
|
666
|
+
<Text style={styles.btnText}>
|
|
667
|
+
{showAlt
|
|
668
|
+
? 'Use this'
|
|
669
|
+
: showCropControls
|
|
670
|
+
? resolvedCopy.cropConfirm
|
|
671
|
+
: resolvedCopy.previewConfirm}
|
|
672
|
+
</Text>
|
|
673
|
+
</Pressable>
|
|
674
|
+
</View>
|
|
675
|
+
</View>
|
|
676
|
+
</View>
|
|
677
|
+
</Modal>
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Absolute-positioned style for a 1-px-thick edge line between two
|
|
684
|
+
* screen points (origin → length + rotation). RN core has no line
|
|
685
|
+
* primitive, so we render a thin rotated <View> per quad edge.
|
|
686
|
+
*/
|
|
687
|
+
function edgeStyle(
|
|
688
|
+
from: Point,
|
|
689
|
+
to: Point,
|
|
690
|
+
): {
|
|
691
|
+
left: number;
|
|
692
|
+
top: number;
|
|
693
|
+
width: number;
|
|
694
|
+
transform: ViewStyle['transform'];
|
|
695
|
+
} {
|
|
696
|
+
const dx = to.x - from.x;
|
|
697
|
+
const dy = to.y - from.y;
|
|
698
|
+
const length = Math.hypot(dx, dy);
|
|
699
|
+
const angle = Math.atan2(dy, dx);
|
|
700
|
+
return {
|
|
701
|
+
left: from.x,
|
|
702
|
+
top: from.y,
|
|
703
|
+
width: length,
|
|
704
|
+
// Rotate about the line's start point (RN rotates about centre, so
|
|
705
|
+
// translate the midpoint back onto the start first).
|
|
706
|
+
transform: [
|
|
707
|
+
{ translateX: -length / 2 },
|
|
708
|
+
{ rotate: `${angle}rad` },
|
|
709
|
+
{ translateX: length / 2 },
|
|
710
|
+
],
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
const styles = StyleSheet.create({
|
|
716
|
+
root: { flex: 1, backgroundColor: '#000' },
|
|
717
|
+
debugPill: {
|
|
718
|
+
position: 'absolute',
|
|
719
|
+
right: 8,
|
|
720
|
+
zIndex: 20,
|
|
721
|
+
maxWidth: '60%',
|
|
722
|
+
paddingVertical: 6,
|
|
723
|
+
paddingHorizontal: 8,
|
|
724
|
+
borderRadius: 6,
|
|
725
|
+
backgroundColor: 'rgba(0,0,0,0.66)',
|
|
726
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
727
|
+
borderColor: 'rgba(120,220,160,0.5)',
|
|
728
|
+
},
|
|
729
|
+
debugPillText: {
|
|
730
|
+
color: '#7fe3a8',
|
|
731
|
+
fontSize: 10,
|
|
732
|
+
lineHeight: 14,
|
|
733
|
+
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace' }),
|
|
734
|
+
},
|
|
735
|
+
warningBanner: {
|
|
736
|
+
paddingVertical: 10,
|
|
737
|
+
paddingHorizontal: 16,
|
|
738
|
+
backgroundColor: 'rgba(255,196,98,0.16)',
|
|
739
|
+
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
740
|
+
borderBottomColor: GUIDANCE_TOKENS.amber,
|
|
741
|
+
gap: 4,
|
|
742
|
+
},
|
|
743
|
+
warningText: {
|
|
744
|
+
color: GUIDANCE_TOKENS.amber,
|
|
745
|
+
fontSize: 13,
|
|
746
|
+
fontWeight: '600',
|
|
747
|
+
},
|
|
748
|
+
abBar: {
|
|
749
|
+
backgroundColor: '#1a1a1a',
|
|
750
|
+
paddingVertical: 10,
|
|
751
|
+
paddingHorizontal: 12,
|
|
752
|
+
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
753
|
+
borderBottomColor: '#333',
|
|
754
|
+
},
|
|
755
|
+
abBarLabel: {
|
|
756
|
+
color: '#aaa',
|
|
757
|
+
fontSize: 11,
|
|
758
|
+
fontWeight: '600',
|
|
759
|
+
textAlign: 'center',
|
|
760
|
+
marginBottom: 8,
|
|
761
|
+
},
|
|
762
|
+
abSegments: {
|
|
763
|
+
flexDirection: 'row',
|
|
764
|
+
alignSelf: 'center',
|
|
765
|
+
backgroundColor: '#000',
|
|
766
|
+
borderRadius: 9,
|
|
767
|
+
padding: 3,
|
|
768
|
+
},
|
|
769
|
+
abSeg: {
|
|
770
|
+
paddingVertical: 7,
|
|
771
|
+
paddingHorizontal: 22,
|
|
772
|
+
borderRadius: 7,
|
|
773
|
+
},
|
|
774
|
+
abSegActive: {
|
|
775
|
+
backgroundColor: '#0A84FF',
|
|
776
|
+
},
|
|
777
|
+
abSegText: {
|
|
778
|
+
color: '#9aa',
|
|
779
|
+
fontSize: 14,
|
|
780
|
+
fontWeight: '700',
|
|
781
|
+
},
|
|
782
|
+
abSegTextActive: {
|
|
783
|
+
color: '#fff',
|
|
784
|
+
},
|
|
785
|
+
canvas: { flex: 1 },
|
|
786
|
+
image: { position: 'absolute' },
|
|
787
|
+
edge: {
|
|
788
|
+
position: 'absolute',
|
|
789
|
+
height: 2,
|
|
790
|
+
backgroundColor: GUIDANCE_TOKENS.amber,
|
|
791
|
+
},
|
|
792
|
+
handle: {
|
|
793
|
+
position: 'absolute',
|
|
794
|
+
width: HANDLE_RADIUS * 2,
|
|
795
|
+
height: HANDLE_RADIUS * 2,
|
|
796
|
+
borderRadius: HANDLE_RADIUS,
|
|
797
|
+
alignItems: 'center',
|
|
798
|
+
justifyContent: 'center',
|
|
799
|
+
backgroundColor: 'rgba(0,0,0,0.35)',
|
|
800
|
+
borderWidth: 2,
|
|
801
|
+
borderColor: GUIDANCE_TOKENS.white,
|
|
802
|
+
},
|
|
803
|
+
handleDot: {
|
|
804
|
+
width: 8,
|
|
805
|
+
height: 8,
|
|
806
|
+
borderRadius: 4,
|
|
807
|
+
backgroundColor: GUIDANCE_TOKENS.amber,
|
|
808
|
+
},
|
|
809
|
+
bar: { padding: 16, backgroundColor: '#111', gap: 12 },
|
|
810
|
+
buttons: { flexDirection: 'row', justifyContent: 'center', gap: 12 },
|
|
811
|
+
btn: {
|
|
812
|
+
paddingVertical: 12,
|
|
813
|
+
paddingHorizontal: 20,
|
|
814
|
+
borderRadius: 10,
|
|
815
|
+
backgroundColor: '#333',
|
|
816
|
+
},
|
|
817
|
+
primary: { backgroundColor: '#0A84FF' },
|
|
818
|
+
btnPressed: { opacity: 0.6 },
|
|
819
|
+
btnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
|
|
820
|
+
});
|