react-native-image-stitcher 0.15.2 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/CHANGELOG.md +171 -1
  2. package/README.md +131 -5
  3. package/android/src/main/cpp/image_stitcher_jni.cpp +154 -13
  4. package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
  5. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
  6. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +220 -87
  7. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
  8. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
  9. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
  10. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
  11. package/cpp/crop_quad.cpp +162 -0
  12. package/cpp/crop_quad.hpp +163 -0
  13. package/cpp/keyframe_gate.cpp +54 -15
  14. package/cpp/keyframe_gate.hpp +33 -0
  15. package/cpp/stitcher.cpp +1122 -132
  16. package/cpp/stitcher.hpp +62 -0
  17. package/cpp/warp_guard.hpp +212 -0
  18. package/dist/camera/Camera.d.ts +209 -12
  19. package/dist/camera/Camera.js +575 -36
  20. package/dist/camera/CameraView.js +35 -16
  21. package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
  22. package/dist/camera/CaptureCountdownOverlay.js +239 -0
  23. package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
  24. package/dist/camera/CaptureFrameCounterOverlay.js +153 -0
  25. package/dist/camera/CaptureMemoryPill.d.ts +24 -8
  26. package/dist/camera/CaptureMemoryPill.js +37 -12
  27. package/dist/camera/CapturePreview.js +2 -1
  28. package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
  29. package/dist/camera/CaptureStatusOverlay.js +22 -5
  30. package/dist/camera/CaptureThumbnailStrip.js +2 -1
  31. package/dist/camera/LateralMotionModal.d.ts +85 -0
  32. package/dist/camera/LateralMotionModal.js +134 -0
  33. package/dist/camera/PanHowToOverlay.d.ts +76 -0
  34. package/dist/camera/PanHowToOverlay.js +222 -0
  35. package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
  36. package/dist/camera/PanoramaBandOverlay.js +9 -3
  37. package/dist/camera/PanoramaSettings.d.ts +8 -6
  38. package/dist/camera/PanoramaSettings.js +19 -1
  39. package/dist/camera/PanoramaSettingsModal.js +4 -4
  40. package/dist/camera/RectCropPreview.d.ts +135 -0
  41. package/dist/camera/RectCropPreview.js +370 -0
  42. package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
  43. package/dist/camera/RotateToLandscapePrompt.js +138 -0
  44. package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
  45. package/dist/camera/buildPanoramaInitialSettings.js +9 -0
  46. package/dist/camera/cameraErrorMessages.d.ts +30 -1
  47. package/dist/camera/cameraErrorMessages.js +26 -10
  48. package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
  49. package/dist/camera/cameraGuidanceCopy.js +80 -0
  50. package/dist/camera/captureCountdown.d.ts +52 -0
  51. package/dist/camera/captureCountdown.js +76 -0
  52. package/dist/camera/captureWarnings.d.ts +90 -0
  53. package/dist/camera/captureWarnings.js +108 -0
  54. package/dist/camera/classifyStitchError.d.ts +30 -0
  55. package/dist/camera/classifyStitchError.js +42 -0
  56. package/dist/camera/cropGeometry.d.ts +136 -0
  57. package/dist/camera/cropGeometry.js +223 -0
  58. package/dist/camera/displayDecodeImageProps.d.ts +25 -0
  59. package/dist/camera/displayDecodeImageProps.js +29 -0
  60. package/dist/camera/guidanceGraphics.d.ts +58 -0
  61. package/dist/camera/guidanceGraphics.js +280 -0
  62. package/dist/camera/guidanceTokens.d.ts +54 -0
  63. package/dist/camera/guidanceTokens.js +58 -0
  64. package/dist/camera/panModeGate.d.ts +54 -0
  65. package/dist/camera/panModeGate.js +62 -0
  66. package/dist/camera/pickCaptureFormat.d.ts +71 -0
  67. package/dist/camera/pickCaptureFormat.js +85 -0
  68. package/dist/camera/stitchDebugInfo.d.ts +27 -0
  69. package/dist/camera/stitchDebugInfo.js +55 -0
  70. package/dist/camera/usePanMotion.d.ts +250 -0
  71. package/dist/camera/usePanMotion.js +451 -0
  72. package/dist/index.d.ts +24 -3
  73. package/dist/index.js +33 -2
  74. package/dist/stitching/computeInscribedRect.d.ts +40 -0
  75. package/dist/stitching/computeInscribedRect.js +55 -0
  76. package/dist/stitching/cropQuad.d.ts +78 -0
  77. package/dist/stitching/cropQuad.js +116 -0
  78. package/dist/stitching/incremental.d.ts +74 -0
  79. package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
  80. package/dist/stitching/useIncrementalStitcher.js +7 -1
  81. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +154 -29
  82. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
  83. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
  84. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
  85. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
  86. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
  87. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +211 -7
  88. package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
  89. package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
  90. package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
  91. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
  92. package/package.json +5 -1
  93. package/src/camera/Camera.tsx +945 -47
  94. package/src/camera/CameraView.tsx +48 -16
  95. package/src/camera/CaptureCountdownOverlay.tsx +272 -0
  96. package/src/camera/CaptureFrameCounterOverlay.tsx +197 -0
  97. package/src/camera/CaptureMemoryPill.tsx +50 -12
  98. package/src/camera/CapturePreview.tsx +5 -0
  99. package/src/camera/CaptureStatusOverlay.tsx +35 -7
  100. package/src/camera/CaptureThumbnailStrip.tsx +4 -0
  101. package/src/camera/LateralMotionModal.tsx +199 -0
  102. package/src/camera/PanHowToOverlay.tsx +246 -0
  103. package/src/camera/PanoramaBandOverlay.tsx +9 -1
  104. package/src/camera/PanoramaSettings.ts +27 -7
  105. package/src/camera/PanoramaSettingsModal.tsx +4 -4
  106. package/src/camera/RectCropPreview.tsx +638 -0
  107. package/src/camera/RotateToLandscapePrompt.tsx +188 -0
  108. package/src/camera/buildPanoramaInitialSettings.ts +30 -1
  109. package/src/camera/cameraErrorMessages.ts +39 -2
  110. package/src/camera/cameraGuidanceCopy.ts +145 -0
  111. package/src/camera/captureCountdown.ts +83 -0
  112. package/src/camera/captureWarnings.ts +190 -0
  113. package/src/camera/classifyStitchError.ts +68 -0
  114. package/src/camera/cropGeometry.ts +268 -0
  115. package/src/camera/displayDecodeImageProps.ts +25 -0
  116. package/src/camera/guidanceGraphics.tsx +347 -0
  117. package/src/camera/guidanceTokens.ts +57 -0
  118. package/src/camera/panModeGate.ts +81 -0
  119. package/src/camera/pickCaptureFormat.ts +130 -0
  120. package/src/camera/stitchDebugInfo.ts +71 -0
  121. package/src/camera/usePanMotion.ts +667 -0
  122. package/src/index.ts +66 -3
  123. package/src/stitching/computeInscribedRect.ts +81 -0
  124. package/src/stitching/cropQuad.ts +167 -0
  125. package/src/stitching/incremental.ts +74 -0
  126. package/src/stitching/useIncrementalStitcher.ts +13 -0
  127. package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +0 -100
  128. package/cpp/tests/CMakeLists.txt +0 -104
  129. package/cpp/tests/README.md +0 -86
  130. package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
  131. package/cpp/tests/pose_test.cpp +0 -74
  132. package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
  133. package/cpp/tests/stubs/jsi/jsi.h +0 -33
  134. package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
  135. package/cpp/tests/warp_guard_test.cpp +0 -48
  136. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
  137. package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
  138. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
  139. package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
  140. package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
  141. package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
  142. package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
  143. package/src/camera/__tests__/useContentRotation.test.ts +0 -89
  144. package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
  145. package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
  146. package/src/stitching/__tests__/useStitcherWorklet.test.ts +0 -202
@@ -0,0 +1,638 @@
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
+ Image,
48
+ Modal,
49
+ PanResponder,
50
+ Platform,
51
+ Pressable,
52
+ StyleSheet,
53
+ Text,
54
+ View,
55
+ type GestureResponderEvent,
56
+ type LayoutChangeEvent,
57
+ type PanResponderGestureState,
58
+ type ViewStyle,
59
+ } from 'react-native';
60
+
61
+ import {
62
+ type GuidanceCopy,
63
+ mergeGuidanceCopy,
64
+ } from './cameraGuidanceCopy';
65
+ import {
66
+ containFit,
67
+ imageToScreen,
68
+ isAxisAlignedRect,
69
+ isQuadValid,
70
+ orderQuadCorners,
71
+ screenToImage,
72
+ type ContainLayout,
73
+ type Point,
74
+ type Quad,
75
+ } from './cropGeometry';
76
+ import { GUIDANCE_TOKENS } from './guidanceTokens';
77
+ import { CaptureMemoryPill } from './CaptureMemoryPill';
78
+
79
+
80
+ /** Image-pixel rectangle, used for the optional `initialRect` seed. */
81
+ export interface ImageRect {
82
+ x: number;
83
+ y: number;
84
+ width: number;
85
+ height: number;
86
+ }
87
+
88
+ /** What the host receives when the user taps Crop. */
89
+ export interface RectCropResult {
90
+ /**
91
+ * The 4 chosen corners in IMAGE-PIXEL space, canonically ordered
92
+ * [TL, TR, BR, BL]. The host feeds these to the native crop.
93
+ */
94
+ quad: Quad;
95
+ /**
96
+ * `true` → the host should perspective-rectify (`cropToQuad`): the user
97
+ * picked a non-rectangular quad and `perspectiveCorrect` is enabled.
98
+ * `false` → the host can use the cheap axis-aligned `cropToRect` (the
99
+ * quad is ~rectangular, or perspective correction is disabled).
100
+ */
101
+ perspective: boolean;
102
+ }
103
+
104
+
105
+ export interface RectCropPreviewProps {
106
+ /** file:// URI of the full result image to crop. */
107
+ imageUri: string;
108
+ /** Intrinsic pixel width of `imageUri`. */
109
+ imageWidth: number;
110
+ /** Intrinsic pixel height of `imageUri`. */
111
+ imageHeight: number;
112
+ /** Show / hide the editor. */
113
+ visible: boolean;
114
+ /**
115
+ * Tapped on "Crop". Receives the ordered image-pixel quad + the
116
+ * perspective decision; the host performs the actual native crop.
117
+ */
118
+ onConfirm: (result: RectCropResult) => void;
119
+ /**
120
+ * Tapped on "Use original" (or hardware back / dismiss) — emit the stitch
121
+ * un-cropped. Also called when the user collapses the quad to something
122
+ * un-warpable, so a degenerate quad never reaches the native crop.
123
+ */
124
+ onUseOriginal: (uri?: string) => void;
125
+ /**
126
+ * Tapped on "Retake" — discard this capture entirely and return to the
127
+ * camera. No result is emitted (the host clears the editor + lets the
128
+ * user capture again).
129
+ */
130
+ onRetake: () => void;
131
+ /**
132
+ * Optional non-fatal warning messages (e.g. "<70 % of frames used") shown
133
+ * as a banner across the top of the editor so the user sees them before
134
+ * accepting a crop. Empty / undefined → no banner.
135
+ */
136
+ warnings?: string[];
137
+ /**
138
+ * Crop mode vs preview-only mode. `true` (default) shows the draggable
139
+ * quad + corner handles + the [Retake][Use original][Crop] bar — the full
140
+ * crop editor. `false` hides the quad and all crop affordances, showing
141
+ * just the stitched image with a [Retake][Confirm] bar — a plain preview
142
+ * (`<Camera showPreview>` without `rectCrop`). Confirm emits the image
143
+ * un-cropped (same as "Use original").
144
+ */
145
+ showCropControls?: boolean;
146
+ /**
147
+ * Optional image-pixel seed rect for the draggable quad. Defaults to
148
+ * an 8 %-inset rectangle of the full image. Ignored in preview-only mode.
149
+ */
150
+ initialRect?: ImageRect;
151
+ /** Copy overrides (cropConfirm / cropReset). Falls back to defaults. */
152
+ copy?: Partial<GuidanceCopy>;
153
+ /**
154
+ * Safe-area insets (px). The editor is a full-screen Modal, so the host
155
+ * passes `insets.top`/`insets.bottom` to keep the top toolbar (warnings)
156
+ * clear of the notch/Dynamic Island and the bottom button bar clear of the
157
+ * home indicator. Default 0.
158
+ */
159
+ topInset?: number;
160
+ bottomInset?: number;
161
+ /**
162
+ * 2026-06-14 (DEV overlay) — optional multi-line debug text describing how
163
+ * this output was stitched (pipeline / warper / route / seam / blend / score
164
+ * / frames / size). When non-empty, rendered as a small monospace pill in
165
+ * the top-right corner. The host gates this on `__DEV__`; this component
166
+ * just renders whatever non-empty string it's given.
167
+ */
168
+ debugInfo?: string;
169
+ /**
170
+ * 2026-06-15 — show the live memory-footprint pill (polled native RSS,
171
+ * green/amber/red) on the preview too, so the operator can watch the spike
172
+ * when the on-demand high-level re-stitch fires. Host gates on settings.debug.
173
+ */
174
+ showMemoryPill?: boolean;
175
+ }
176
+
177
+
178
+ /** Default inset (fraction of each dimension) for the seed quad. */
179
+ const DEFAULT_INSET_FRACTION = 0.08;
180
+ /** On-screen radius of each draggable corner handle. */
181
+ const HANDLE_RADIUS = 16;
182
+ /** Enlarged hit-slop radius so the small handle is easy to grab. */
183
+ const HANDLE_HIT_RADIUS = 28;
184
+
185
+
186
+ /**
187
+ * Build the seed quad in IMAGE-PIXEL coords: the host's `initialRect` if
188
+ * given, else an inset rectangle of the full image. Always returned in
189
+ * [TL, TR, BR, BL] order.
190
+ */
191
+ function seedImageQuad(
192
+ imageWidth: number,
193
+ imageHeight: number,
194
+ initialRect?: ImageRect,
195
+ ): Quad {
196
+ if (initialRect) {
197
+ const { x, y, width, height } = initialRect;
198
+ return [
199
+ { x, y },
200
+ { x: x + width, y },
201
+ { x: x + width, y: y + height },
202
+ { x, y: y + height },
203
+ ];
204
+ }
205
+ const ix = imageWidth * DEFAULT_INSET_FRACTION;
206
+ const iy = imageHeight * DEFAULT_INSET_FRACTION;
207
+ return [
208
+ { x: ix, y: iy },
209
+ { x: imageWidth - ix, y: iy },
210
+ { x: imageWidth - ix, y: imageHeight - iy },
211
+ { x: ix, y: imageHeight - iy },
212
+ ];
213
+ }
214
+
215
+
216
+ export function RectCropPreview(
217
+ props: RectCropPreviewProps,
218
+ ): React.JSX.Element {
219
+ const {
220
+ imageUri,
221
+ imageWidth,
222
+ imageHeight,
223
+ visible,
224
+ onConfirm,
225
+ onUseOriginal,
226
+ onRetake,
227
+ warnings,
228
+ showCropControls = true,
229
+ initialRect,
230
+ copy,
231
+ topInset = 0,
232
+ bottomInset = 0,
233
+ debugInfo,
234
+ showMemoryPill,
235
+ } = props;
236
+
237
+ const resolvedCopy = useMemo(() => mergeGuidanceCopy(copy), [copy]);
238
+
239
+ // The 4 corners live in IMAGE-PIXEL space (the source of truth) so they
240
+ // survive layout-box changes (rotation, keyboard) without drift. We map
241
+ // to screen for rendering and back on every drag via cropGeometry.
242
+ const [imageQuad, setImageQuad] = useState<Quad>(() =>
243
+ seedImageQuad(imageWidth, imageHeight, initialRect),
244
+ );
245
+
246
+ const [box, setBox] = useState<ContainLayout | null>(null);
247
+
248
+ // Drag bookkeeping kept in refs so the per-move handler doesn't churn
249
+ // state identity / re-create PanResponders mid-gesture.
250
+ const boxRef = useRef<ContainLayout | null>(null);
251
+ const dragCornerRef = useRef<number | null>(null);
252
+ // Screen-space corner positions at gesture start, so dx/dy from the
253
+ // gesture state apply to a stable origin (PanResponder reports
254
+ // cumulative deltas from touch-down, not per-frame).
255
+ const dragStartScreenRef = useRef<Point | null>(null);
256
+
257
+ const onLayout = useCallback((e: LayoutChangeEvent) => {
258
+ const { width, height } = e.nativeEvent.layout;
259
+ const next = { width, height };
260
+ boxRef.current = next;
261
+ setBox(next);
262
+ }, []);
263
+
264
+ const handleConfirm = useCallback(() => {
265
+ const ordered = orderQuadCorners(imageQuad);
266
+ // Guard: if the user collapsed the quad to something un-warpable, emit
267
+ // the original un-cropped panorama rather than hand native a degenerate
268
+ // quad.
269
+ if (!isQuadValid(ordered)) {
270
+ onUseOriginal();
271
+ return;
272
+ }
273
+ const axisAligned = isAxisAlignedRect(ordered);
274
+ // A skewed quad is perspective-rectified; a ~rectangular drag is a plain
275
+ // axis-aligned crop. (The former `perspectiveCorrect` opt-out was removed
276
+ // in v0.16 — the SDK always honours a skewed quad with a warp.)
277
+ onConfirm({
278
+ quad: ordered,
279
+ perspective: !axisAligned,
280
+ });
281
+ }, [imageQuad, onConfirm, onUseOriginal]);
282
+
283
+ // One PanResponder per corner. Built once (the corner index is the
284
+ // closure key); the move handler reads live box/quad via refs + setState
285
+ // updater so the responders never need to be rebuilt on drag.
286
+ const responders = useMemo(
287
+ () =>
288
+ [0, 1, 2, 3].map((corner) =>
289
+ PanResponder.create({
290
+ onStartShouldSetPanResponder: () => true,
291
+ onMoveShouldSetPanResponder: () => true,
292
+ onPanResponderGrant: () => {
293
+ dragCornerRef.current = corner;
294
+ const layout = boxRef.current;
295
+ if (!layout) return;
296
+ // Capture this corner's screen position at touch-down.
297
+ setImageQuad((q) => {
298
+ dragStartScreenRef.current = imageToScreen(
299
+ q[corner],
300
+ layout,
301
+ imageWidth,
302
+ imageHeight,
303
+ );
304
+ return q;
305
+ });
306
+ },
307
+ onPanResponderMove: (
308
+ _e: GestureResponderEvent,
309
+ gesture: PanResponderGestureState,
310
+ ) => {
311
+ const layout = boxRef.current;
312
+ const start = dragStartScreenRef.current;
313
+ if (!layout || !start) return;
314
+ const screenPt: Point = {
315
+ x: start.x + gesture.dx,
316
+ y: start.y + gesture.dy,
317
+ };
318
+ const imgPt = screenToImage(
319
+ screenPt,
320
+ layout,
321
+ imageWidth,
322
+ imageHeight,
323
+ );
324
+ setImageQuad((q) => {
325
+ const next = [...q] as Quad;
326
+ next[corner] = imgPt;
327
+ return next;
328
+ });
329
+ },
330
+ onPanResponderRelease: () => {
331
+ dragCornerRef.current = null;
332
+ dragStartScreenRef.current = null;
333
+ },
334
+ onPanResponderTerminate: () => {
335
+ dragCornerRef.current = null;
336
+ dragStartScreenRef.current = null;
337
+ },
338
+ }),
339
+ ),
340
+ [imageWidth, imageHeight],
341
+ );
342
+
343
+ // ── Derived screen geometry (recomputed each render from the box) ──
344
+ // The display box (letterboxed image rect) and the 4 corners projected
345
+ // into screen space for the overlay + handles.
346
+ let imageBox: {
347
+ left: number;
348
+ top: number;
349
+ width: number;
350
+ height: number;
351
+ } | null = null;
352
+ let screenCorners: Point[] | null = null;
353
+
354
+ if (box) {
355
+ const fit = containFit(box, imageWidth, imageHeight);
356
+ if (fit) {
357
+ imageBox = {
358
+ left: fit.offX,
359
+ top: fit.offY,
360
+ width: imageWidth * fit.scale,
361
+ height: imageHeight * fit.scale,
362
+ };
363
+ // Quad corners only apply in crop mode.
364
+ screenCorners = showCropControls
365
+ ? imageQuad.map((p) => imageToScreen(p, box, imageWidth, imageHeight))
366
+ : null;
367
+ }
368
+ }
369
+
370
+ // Outline path (a <View> per edge — RN core has no <Polygon>; this
371
+ // mirrors InscribedRectDebug's single-rect border treatment, generalised
372
+ // to 4 free edges).
373
+ const edges = screenCorners
374
+ ? screenCorners.map((from, i) => {
375
+ const to = screenCorners![(i + 1) % screenCorners!.length];
376
+ return edgeStyle(from, to);
377
+ })
378
+ : [];
379
+
380
+ return (
381
+ <Modal
382
+ visible={visible}
383
+ animationType="fade"
384
+ onRequestClose={() => onUseOriginal()}
385
+ accessibilityLabel={
386
+ showCropControls
387
+ ? 'Crop the captured panorama'
388
+ : 'Review the captured panorama'
389
+ }
390
+ // Mirror OrientationDriftModal: declare all 4 orientations so iOS
391
+ // doesn't force-rotate the window when this opens mid-rotation.
392
+ supportedOrientations={[
393
+ 'portrait',
394
+ 'portrait-upside-down',
395
+ 'landscape-left',
396
+ 'landscape-right',
397
+ ]}
398
+ >
399
+ <View style={[styles.root, { paddingTop: topInset }]}>
400
+ {/* Live memory-footprint pill (host gates on settings.debug). Top-LEFT
401
+ so it clears the top-right stitch-params pill. */}
402
+ {showMemoryPill ? (
403
+ <CaptureMemoryPill
404
+ style={{
405
+ position: 'absolute',
406
+ top: topInset + 8,
407
+ left: 12,
408
+ zIndex: 21,
409
+ }}
410
+ />
411
+ ) : null}
412
+
413
+ {/* DEV stitch-params overlay (host gates on __DEV__). Top-right pill. */}
414
+ {debugInfo ? (
415
+ <View
416
+ style={[styles.debugPill, { top: topInset + 8 }]}
417
+ pointerEvents="none"
418
+ accessibilityRole="text"
419
+ >
420
+ <Text style={styles.debugPillText}>{debugInfo}</Text>
421
+ </View>
422
+ ) : null}
423
+
424
+ {/* Non-fatal warning banner (e.g. "<70 % of frames used"), shown
425
+ ABOVE the image so the user sees it before accepting a crop. */}
426
+ {warnings && warnings.length > 0 && (
427
+ <View style={styles.warningBanner} accessibilityRole="alert">
428
+ {warnings.map((w, i) => (
429
+ <Text key={`warn-${i}`} style={styles.warningText}>
430
+ {w}
431
+ </Text>
432
+ ))}
433
+ </View>
434
+ )}
435
+
436
+ <View style={styles.canvas} onLayout={onLayout}>
437
+ {imageBox && (
438
+ <Image
439
+ source={{ uri: imageUri }}
440
+ style={[styles.image, imageBox]}
441
+ resizeMode="stretch"
442
+ />
443
+ )}
444
+
445
+ {/* Crop affordances — quad edges + draggable handles — only in crop
446
+ mode (hidden in preview-only mode). */}
447
+ {showCropControls && (
448
+ <>
449
+ {/* Quad edges (non-interactive). */}
450
+ {edges.map((e, i) => (
451
+ <View key={`edge-${i}`} style={[styles.edge, e]} pointerEvents="none" />
452
+ ))}
453
+
454
+ {/* Draggable corner handles. */}
455
+ {screenCorners
456
+ && screenCorners.map((c, i) => (
457
+ <View
458
+ key={`handle-${i}`}
459
+ {...responders[i].panHandlers}
460
+ hitSlop={{
461
+ top: HANDLE_HIT_RADIUS,
462
+ bottom: HANDLE_HIT_RADIUS,
463
+ left: HANDLE_HIT_RADIUS,
464
+ right: HANDLE_HIT_RADIUS,
465
+ }}
466
+ accessibilityRole="adjustable"
467
+ accessibilityLabel={`Crop corner ${i + 1}`}
468
+ style={[
469
+ styles.handle,
470
+ {
471
+ left: c.x - HANDLE_RADIUS,
472
+ top: c.y - HANDLE_RADIUS,
473
+ },
474
+ ]}
475
+ >
476
+ <View style={styles.handleDot} pointerEvents="none" />
477
+ </View>
478
+ ))}
479
+ </>
480
+ )}
481
+ </View>
482
+
483
+ <View style={[styles.bar, { paddingBottom: 16 + bottomInset }]}>
484
+ <View style={styles.buttons}>
485
+ {/* "Retake" — discard this capture, back to the camera. */}
486
+ <Pressable
487
+ style={({ pressed }) => [styles.btn, pressed && styles.btnPressed]}
488
+ onPress={onRetake}
489
+ accessibilityRole="button"
490
+ accessibilityLabel={resolvedCopy.cropRetake}
491
+ >
492
+ <Text style={styles.btnText}>{resolvedCopy.cropRetake}</Text>
493
+ </Pressable>
494
+ {/* Crop mode only — "Use original" emits the stitch un-cropped.
495
+ Hidden in preview-only mode (Confirm below is the accept action
496
+ there). */}
497
+ {showCropControls && (
498
+ <Pressable
499
+ style={({ pressed }) => [styles.btn, pressed && styles.btnPressed]}
500
+ onPress={() => onUseOriginal()}
501
+ accessibilityRole="button"
502
+ accessibilityLabel={resolvedCopy.cropUseOriginal}
503
+ >
504
+ <Text style={styles.btnText}>{resolvedCopy.cropUseOriginal}</Text>
505
+ </Pressable>
506
+ )}
507
+ {/* Primary action — "Crop" applies the quad (crop mode) or "Confirm"
508
+ accepts the image as-is (preview-only mode). */}
509
+ <Pressable
510
+ style={({ pressed }) => [
511
+ styles.btn,
512
+ styles.primary,
513
+ pressed && styles.btnPressed,
514
+ ]}
515
+ onPress={showCropControls ? handleConfirm : () => onUseOriginal()}
516
+ accessibilityRole="button"
517
+ accessibilityLabel={
518
+ showCropControls
519
+ ? resolvedCopy.cropConfirm
520
+ : resolvedCopy.previewConfirm
521
+ }
522
+ >
523
+ <Text style={styles.btnText}>
524
+ {showCropControls
525
+ ? resolvedCopy.cropConfirm
526
+ : resolvedCopy.previewConfirm}
527
+ </Text>
528
+ </Pressable>
529
+ </View>
530
+ </View>
531
+ </View>
532
+ </Modal>
533
+ );
534
+ }
535
+
536
+
537
+ /**
538
+ * Absolute-positioned style for a 1-px-thick edge line between two
539
+ * screen points (origin → length + rotation). RN core has no line
540
+ * primitive, so we render a thin rotated <View> per quad edge.
541
+ */
542
+ function edgeStyle(
543
+ from: Point,
544
+ to: Point,
545
+ ): {
546
+ left: number;
547
+ top: number;
548
+ width: number;
549
+ transform: ViewStyle['transform'];
550
+ } {
551
+ const dx = to.x - from.x;
552
+ const dy = to.y - from.y;
553
+ const length = Math.hypot(dx, dy);
554
+ const angle = Math.atan2(dy, dx);
555
+ return {
556
+ left: from.x,
557
+ top: from.y,
558
+ width: length,
559
+ // Rotate about the line's start point (RN rotates about centre, so
560
+ // translate the midpoint back onto the start first).
561
+ transform: [
562
+ { translateX: -length / 2 },
563
+ { rotate: `${angle}rad` },
564
+ { translateX: length / 2 },
565
+ ],
566
+ };
567
+ }
568
+
569
+
570
+ const styles = StyleSheet.create({
571
+ root: { flex: 1, backgroundColor: '#000' },
572
+ debugPill: {
573
+ position: 'absolute',
574
+ right: 8,
575
+ zIndex: 20,
576
+ maxWidth: '60%',
577
+ paddingVertical: 6,
578
+ paddingHorizontal: 8,
579
+ borderRadius: 6,
580
+ backgroundColor: 'rgba(0,0,0,0.66)',
581
+ borderWidth: StyleSheet.hairlineWidth,
582
+ borderColor: 'rgba(120,220,160,0.5)',
583
+ },
584
+ debugPillText: {
585
+ color: '#7fe3a8',
586
+ fontSize: 10,
587
+ lineHeight: 14,
588
+ fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace' }),
589
+ },
590
+ warningBanner: {
591
+ paddingVertical: 10,
592
+ paddingHorizontal: 16,
593
+ backgroundColor: 'rgba(255,196,98,0.16)',
594
+ borderBottomWidth: StyleSheet.hairlineWidth,
595
+ borderBottomColor: GUIDANCE_TOKENS.amber,
596
+ gap: 4,
597
+ },
598
+ warningText: {
599
+ color: GUIDANCE_TOKENS.amber,
600
+ fontSize: 13,
601
+ fontWeight: '600',
602
+ },
603
+ canvas: { flex: 1 },
604
+ image: { position: 'absolute' },
605
+ edge: {
606
+ position: 'absolute',
607
+ height: 2,
608
+ backgroundColor: GUIDANCE_TOKENS.amber,
609
+ },
610
+ handle: {
611
+ position: 'absolute',
612
+ width: HANDLE_RADIUS * 2,
613
+ height: HANDLE_RADIUS * 2,
614
+ borderRadius: HANDLE_RADIUS,
615
+ alignItems: 'center',
616
+ justifyContent: 'center',
617
+ backgroundColor: 'rgba(0,0,0,0.35)',
618
+ borderWidth: 2,
619
+ borderColor: GUIDANCE_TOKENS.white,
620
+ },
621
+ handleDot: {
622
+ width: 8,
623
+ height: 8,
624
+ borderRadius: 4,
625
+ backgroundColor: GUIDANCE_TOKENS.amber,
626
+ },
627
+ bar: { padding: 16, backgroundColor: '#111', gap: 12 },
628
+ buttons: { flexDirection: 'row', justifyContent: 'center', gap: 12 },
629
+ btn: {
630
+ paddingVertical: 12,
631
+ paddingHorizontal: 20,
632
+ borderRadius: 10,
633
+ backgroundColor: '#333',
634
+ },
635
+ primary: { backgroundColor: '#0A84FF' },
636
+ btnPressed: { opacity: 0.6 },
637
+ btnText: { color: '#fff', fontSize: 15, fontWeight: '600' },
638
+ });