react-native-image-stitcher 0.15.1 → 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 +147 -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 +62 -5
  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 +75 -5
  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
@@ -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
+ });