react-native-image-stitcher 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +124 -1
  2. package/README.md +116 -5
  3. package/android/src/main/cpp/image_stitcher_jni.cpp +107 -11
  4. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
  5. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +87 -30
  6. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
  8. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
  9. package/cpp/crop_quad.cpp +162 -0
  10. package/cpp/crop_quad.hpp +163 -0
  11. package/cpp/stitcher.cpp +651 -55
  12. package/cpp/stitcher.hpp +10 -0
  13. package/cpp/warp_guard.hpp +212 -0
  14. package/dist/camera/Camera.d.ts +196 -12
  15. package/dist/camera/Camera.js +629 -35
  16. package/dist/camera/CameraView.js +35 -16
  17. package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
  18. package/dist/camera/CaptureCountdownOverlay.js +239 -0
  19. package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
  20. package/dist/camera/CaptureFrameCounterOverlay.js +142 -0
  21. package/dist/camera/CaptureMemoryPill.d.ts +9 -1
  22. package/dist/camera/CaptureMemoryPill.js +3 -3
  23. package/dist/camera/CapturePreview.js +2 -1
  24. package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
  25. package/dist/camera/CaptureStatusOverlay.js +22 -5
  26. package/dist/camera/CaptureThumbnailStrip.js +2 -1
  27. package/dist/camera/LateralMotionModal.d.ts +85 -0
  28. package/dist/camera/LateralMotionModal.js +134 -0
  29. package/dist/camera/PanHowToOverlay.d.ts +76 -0
  30. package/dist/camera/PanHowToOverlay.js +222 -0
  31. package/dist/camera/PanoramaSettings.d.ts +8 -6
  32. package/dist/camera/PanoramaSettings.js +26 -5
  33. package/dist/camera/PanoramaSettingsModal.js +4 -4
  34. package/dist/camera/RectCropPreview.d.ts +161 -0
  35. package/dist/camera/RectCropPreview.js +480 -0
  36. package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
  37. package/dist/camera/RotateToLandscapePrompt.js +138 -0
  38. package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
  39. package/dist/camera/buildPanoramaInitialSettings.js +9 -0
  40. package/dist/camera/cameraErrorMessages.d.ts +30 -1
  41. package/dist/camera/cameraErrorMessages.js +26 -10
  42. package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
  43. package/dist/camera/cameraGuidanceCopy.js +80 -0
  44. package/dist/camera/captureCountdown.d.ts +52 -0
  45. package/dist/camera/captureCountdown.js +76 -0
  46. package/dist/camera/captureWarnings.d.ts +90 -0
  47. package/dist/camera/captureWarnings.js +108 -0
  48. package/dist/camera/classifyStitchError.d.ts +30 -0
  49. package/dist/camera/classifyStitchError.js +42 -0
  50. package/dist/camera/cropGeometry.d.ts +136 -0
  51. package/dist/camera/cropGeometry.js +223 -0
  52. package/dist/camera/displayDecodeImageProps.d.ts +25 -0
  53. package/dist/camera/displayDecodeImageProps.js +29 -0
  54. package/dist/camera/guidanceGraphics.d.ts +58 -0
  55. package/dist/camera/guidanceGraphics.js +280 -0
  56. package/dist/camera/guidanceTokens.d.ts +54 -0
  57. package/dist/camera/guidanceTokens.js +58 -0
  58. package/dist/camera/panModeGate.d.ts +54 -0
  59. package/dist/camera/panModeGate.js +62 -0
  60. package/dist/camera/pickCaptureFormat.d.ts +71 -0
  61. package/dist/camera/pickCaptureFormat.js +85 -0
  62. package/dist/camera/stitchDebugInfo.d.ts +27 -0
  63. package/dist/camera/stitchDebugInfo.js +55 -0
  64. package/dist/camera/usePanMotion.d.ts +250 -0
  65. package/dist/camera/usePanMotion.js +451 -0
  66. package/dist/index.d.ts +24 -3
  67. package/dist/index.js +33 -2
  68. package/dist/stitching/computeInscribedRect.d.ts +40 -0
  69. package/dist/stitching/computeInscribedRect.js +55 -0
  70. package/dist/stitching/cropQuad.d.ts +78 -0
  71. package/dist/stitching/cropQuad.js +116 -0
  72. package/dist/stitching/incremental.d.ts +45 -0
  73. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +56 -8
  74. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
  75. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
  76. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
  77. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +191 -7
  78. package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
  79. package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
  80. package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
  81. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
  82. package/package.json +5 -1
  83. package/src/camera/Camera.tsx +994 -47
  84. package/src/camera/CameraView.tsx +48 -16
  85. package/src/camera/CaptureCountdownOverlay.tsx +272 -0
  86. package/src/camera/CaptureFrameCounterOverlay.tsx +183 -0
  87. package/src/camera/CaptureMemoryPill.tsx +17 -3
  88. package/src/camera/CapturePreview.tsx +5 -0
  89. package/src/camera/CaptureStatusOverlay.tsx +35 -7
  90. package/src/camera/CaptureThumbnailStrip.tsx +4 -0
  91. package/src/camera/LateralMotionModal.tsx +199 -0
  92. package/src/camera/PanHowToOverlay.tsx +246 -0
  93. package/src/camera/PanoramaSettings.ts +34 -11
  94. package/src/camera/PanoramaSettingsModal.tsx +4 -4
  95. package/src/camera/RectCropPreview.tsx +820 -0
  96. package/src/camera/RotateToLandscapePrompt.tsx +188 -0
  97. package/src/camera/buildPanoramaInitialSettings.ts +30 -1
  98. package/src/camera/cameraErrorMessages.ts +39 -2
  99. package/src/camera/cameraGuidanceCopy.ts +145 -0
  100. package/src/camera/captureCountdown.ts +83 -0
  101. package/src/camera/captureWarnings.ts +190 -0
  102. package/src/camera/classifyStitchError.ts +68 -0
  103. package/src/camera/cropGeometry.ts +268 -0
  104. package/src/camera/displayDecodeImageProps.ts +25 -0
  105. package/src/camera/guidanceGraphics.tsx +347 -0
  106. package/src/camera/guidanceTokens.ts +57 -0
  107. package/src/camera/panModeGate.ts +81 -0
  108. package/src/camera/pickCaptureFormat.ts +130 -0
  109. package/src/camera/stitchDebugInfo.ts +71 -0
  110. package/src/camera/usePanMotion.ts +667 -0
  111. package/src/index.ts +66 -3
  112. package/src/stitching/computeInscribedRect.ts +81 -0
  113. package/src/stitching/cropQuad.ts +167 -0
  114. package/src/stitching/incremental.ts +45 -0
  115. package/cpp/tests/CMakeLists.txt +0 -104
  116. package/cpp/tests/README.md +0 -86
  117. package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
  118. package/cpp/tests/pose_test.cpp +0 -74
  119. package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
  120. package/cpp/tests/stubs/jsi/jsi.h +0 -33
  121. package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
  122. package/cpp/tests/warp_guard_test.cpp +0 -48
  123. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
  124. package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
  125. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
  126. package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
  127. package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
  128. package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
  129. package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
  130. package/src/camera/__tests__/useContentRotation.test.ts +0 -89
  131. package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
  132. package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
  133. package/src/stitching/__tests__/useStitcherWorklet.test.ts +0 -202
@@ -0,0 +1,190 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * captureWarnings — non-fatal quality / behaviour signals attached to a
4
+ * SUCCESSFUL capture result. A stitch that *failed* surfaces a
5
+ * `CameraError` (via the `ok:false` result + `onError`); these warnings
6
+ * cover the "it succeeded but the host/user should know something" cases:
7
+ *
8
+ * • LOW_FRAME_UTILIZATION — fewer than `threshold` (default 70 %) of the
9
+ * captured frames survived the confidence filter, so the panorama may
10
+ * be patchy / shorter than intended.
11
+ * • LATERAL_DRIFT_FINALIZE — the capture was auto-finalized early because
12
+ * the phone drifted sideways (item 6); only the pre-drift portion was
13
+ * stitched.
14
+ * • HIGH_PAN_SPEED — the pan exceeded the recommended pace at some point
15
+ * during the capture (the live "too fast" cue fired), so motion blur /
16
+ * thin overlap may have hurt the result.
17
+ *
18
+ * `<Camera>` builds these at finalize and threads them into BOTH the
19
+ * `onCapture` result payload (so any host — not just the example app —
20
+ * learns of degraded output programmatically) AND the crop editor's banner
21
+ * (so the user sees it before accepting the crop).
22
+ *
23
+ * Pure + dependency-free so it's unit-testable in isolation (the lib's jest
24
+ * config is pure-TS and can't mount `<Camera>`), mirroring
25
+ * `classifyStitchError` / `buildPanoramaInitialSettings`.
26
+ */
27
+
28
+ /** Stable codes a host can branch on (in addition to the message). */
29
+ export type CaptureWarningCode =
30
+ | 'LOW_FRAME_UTILIZATION'
31
+ | 'LATERAL_DRIFT_FINALIZE'
32
+ | 'HIGH_PAN_SPEED';
33
+
34
+ export interface CaptureWarning {
35
+ /** Stable, host-switchable code. */
36
+ code: CaptureWarningCode;
37
+ /** Plain-language default message (shown in the crop banner). */
38
+ message: string;
39
+ /** Frames the engine tried to use (LOW_FRAME_UTILIZATION only). */
40
+ framesRequested?: number;
41
+ /** Frames that survived the confidence filter (LOW_FRAME_UTILIZATION). */
42
+ framesIncluded?: number;
43
+ /** included / requested in [0, 1] (LOW_FRAME_UTILIZATION only). */
44
+ utilization?: number;
45
+ }
46
+
47
+ /**
48
+ * Default trip point for LOW_FRAME_UTILIZATION: warn when fewer than 70 %
49
+ * of captured frames survived. Matches the threshold the user specified.
50
+ */
51
+ export const LOW_FRAME_UTILIZATION_THRESHOLD = 0.7;
52
+
53
+ /**
54
+ * The overridable message strings for the three capture warnings. This is
55
+ * the SINGLE SOURCE OF TRUTH for the default English warning copy — the
56
+ * `GuidanceCopy` surface re-uses these defaults (see `cameraGuidanceCopy`),
57
+ * so a host that localises via the `guidanceCopy` `<Camera>` prop re-words
58
+ * these too.
59
+ *
60
+ * `lowFrameUtilization` is a TEMPLATE: the placeholders `{included}`,
61
+ * `{requested}` and `{percent}` are substituted at build time with the
62
+ * actual frame counts. A translation must keep the placeholders (any it
63
+ * omits is simply not interpolated; an unknown placeholder is left as-is).
64
+ */
65
+ export interface CaptureWarningCopy {
66
+ /** LOW_FRAME_UTILIZATION — template; `{included}`/`{requested}`/`{percent}`. */
67
+ lowFrameUtilization: string;
68
+ /** LATERAL_DRIFT_FINALIZE. */
69
+ lateralDriftFinalize: string;
70
+ /** HIGH_PAN_SPEED. */
71
+ highPanSpeed: string;
72
+ }
73
+
74
+ export const DEFAULT_CAPTURE_WARNING_COPY: CaptureWarningCopy = {
75
+ lowFrameUtilization:
76
+ 'Only {included} of {requested} captured frames ({percent}%) could be '
77
+ + 'used — the panorama may be incomplete. Pan more slowly and steadily '
78
+ + 'next time.',
79
+ lateralDriftFinalize:
80
+ 'Capture stopped early because the phone drifted sideways — only the '
81
+ + 'part captured before the drift was stitched.',
82
+ highPanSpeed:
83
+ 'The capture was taken faster than the recommended pace — the result '
84
+ + 'may not be the best. Pan more slowly next time.',
85
+ };
86
+
87
+ /**
88
+ * Substitute `{name}` placeholders in a template with `vars[name]`. An
89
+ * unknown placeholder is left verbatim (so a malformed translation degrades
90
+ * to showing `{percent}` rather than throwing).
91
+ */
92
+ function fillTemplate(
93
+ tpl: string,
94
+ vars: Record<string, string | number>,
95
+ ): string {
96
+ return tpl.replace(/\{(\w+)\}/g, (m, k: string) =>
97
+ k in vars ? String(vars[k]) : m,
98
+ );
99
+ }
100
+
101
+ export interface BuildCaptureWarningsInput {
102
+ /** `framesRequested` from the native finalize result. */
103
+ framesRequested?: number;
104
+ /** `framesIncluded` from the native finalize result. */
105
+ framesIncluded?: number;
106
+ /** True when this finalize was triggered by lateral-drift auto-stop. */
107
+ lateralFinalize?: boolean;
108
+ /** True when the pan exceeded the recommended pace during the capture. */
109
+ highPanSpeed?: boolean;
110
+ /** Override the LOW_FRAME_UTILIZATION trip point (fraction in (0, 1]). */
111
+ lowFrameUtilizationThreshold?: number;
112
+ /**
113
+ * Localised / re-worded warning messages. Missing keys fall back to
114
+ * {@link DEFAULT_CAPTURE_WARNING_COPY}. `<Camera>` threads the resolved
115
+ * `guidanceCopy` here so the crop-banner warnings honour the host's i18n.
116
+ */
117
+ copy?: Partial<CaptureWarningCopy>;
118
+ }
119
+
120
+ /**
121
+ * Build the warning list for a successful capture. Order is by cause →
122
+ * symptom: a lateral-drift stop (the reason a capture is short) is listed
123
+ * before the low-utilization symptom it usually produces.
124
+ */
125
+ export function buildCaptureWarnings(
126
+ input: BuildCaptureWarningsInput,
127
+ ): CaptureWarning[] {
128
+ const {
129
+ framesRequested,
130
+ framesIncluded,
131
+ lateralFinalize = false,
132
+ highPanSpeed = false,
133
+ lowFrameUtilizationThreshold = LOW_FRAME_UTILIZATION_THRESHOLD,
134
+ } = input;
135
+ const copy: CaptureWarningCopy = {
136
+ ...DEFAULT_CAPTURE_WARNING_COPY,
137
+ ...stripUndefinedCopy(input.copy),
138
+ };
139
+
140
+ const warnings: CaptureWarning[] = [];
141
+
142
+ if (lateralFinalize) {
143
+ warnings.push({
144
+ code: 'LATERAL_DRIFT_FINALIZE',
145
+ message: copy.lateralDriftFinalize,
146
+ });
147
+ }
148
+
149
+ if (highPanSpeed) {
150
+ warnings.push({
151
+ code: 'HIGH_PAN_SPEED',
152
+ message: copy.highPanSpeed,
153
+ });
154
+ }
155
+
156
+ if (
157
+ typeof framesRequested === 'number'
158
+ && typeof framesIncluded === 'number'
159
+ && framesRequested > 0
160
+ && framesIncluded >= 0
161
+ && framesIncluded < framesRequested * lowFrameUtilizationThreshold
162
+ ) {
163
+ const utilization = framesIncluded / framesRequested;
164
+ warnings.push({
165
+ code: 'LOW_FRAME_UTILIZATION',
166
+ message: fillTemplate(copy.lowFrameUtilization, {
167
+ included: framesIncluded,
168
+ requested: framesRequested,
169
+ percent: Math.round(utilization * 100),
170
+ }),
171
+ framesRequested,
172
+ framesIncluded,
173
+ utilization,
174
+ });
175
+ }
176
+
177
+ return warnings;
178
+ }
179
+
180
+ /** Drop `undefined` values so a partial override never clobbers a default. */
181
+ function stripUndefinedCopy(
182
+ o?: Partial<CaptureWarningCopy>,
183
+ ): Partial<CaptureWarningCopy> {
184
+ if (!o) return {};
185
+ const out: Partial<CaptureWarningCopy> = {};
186
+ (Object.keys(o) as (keyof CaptureWarningCopy)[]).forEach((k) => {
187
+ if (o[k] !== undefined) out[k] = o[k];
188
+ });
189
+ return out;
190
+ }
@@ -0,0 +1,68 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * classifyStitchError — map a raw native stitch-failure message to a
4
+ * `CameraErrorCode`.
5
+ *
6
+ * This is the load-bearing C++↔JS contract: the native pipeline reports
7
+ * failures only as exception strings (see cpp/stitcher.cpp — the warp /
8
+ * cumulative-canvas guards throw "warpRoi too large … degenerate camera
9
+ * params", cv::Stitcher reports "need more images", etc.), and this
10
+ * function is the single place that turns those strings into the typed
11
+ * codes `<Camera onError>` surfaces. The code then drives the friendly
12
+ * copy in {@link userFacingStitchError} (cameraErrorMessages.ts) — e.g.
13
+ * STITCH_CAMERA_PARAMS_FAIL → "Please pan more slowly".
14
+ *
15
+ * Extracted from the inline chain in Camera.tsx so the contract is
16
+ * unit-testable against the actual native strings (the lib's jest config
17
+ * is pure-TS and can't mount <Camera>).
18
+ *
19
+ * Ordering matters — the branches are checked top-to-bottom and the first
20
+ * match wins:
21
+ * 1. need-more-images (insufficient overlap) — most specific.
22
+ * 2. homography estimation.
23
+ * 3. degenerate camera params / warp-canvas guard (the divergent-warp
24
+ * OOM path, now converted to a clean throw by the canvas guard).
25
+ * 4. low-quality / disjoint output (the post-stitch validator, v0.16).
26
+ * 5. out-of-memory (incl. the pre-stitch memory abort).
27
+ * 6. fallback — an unclassified finalize failure.
28
+ */
29
+ import type { CameraErrorCode } from './Camera';
30
+
31
+ export function classifyStitchError(message: string): CameraErrorCode {
32
+ // Insufficient overlap surfaces two ways: cv::Stitcher's
33
+ // ERR_NEED_MORE_IMGS ("need more images") and the manual pipeline's
34
+ // "0 valid pairwise matches / frames may not overlap enough" — both are
35
+ // the same recoverable "pan more slowly" case.
36
+ if (/need more images|pairwise match|overlap enough/i.test(message)) {
37
+ return 'STITCH_NEED_MORE_IMGS';
38
+ }
39
+ if (/homography/i.test(message)) {
40
+ return 'STITCH_HOMOGRAPHY_FAIL';
41
+ }
42
+ // Degenerate camera params — the rapid/wide-pan divergent-warp path.
43
+ // Broadened beyond the original "camera params" so a future reword of
44
+ // the native throw can't silently drop the "pan more slowly" copy: the
45
+ // per-frame warp guard and the cumulative-canvas guard carry "warpRoi"
46
+ // / "canvas too large" / "degenerate" respectively (see cpp/stitcher.cpp
47
+ // degenerateFrameException / degenerateCanvasException). Kept AFTER the
48
+ // homography branch and BEFORE the OOM branch so a true OOM string still
49
+ // routes to STITCH_OOM.
50
+ if (/camera params|warpRoi|degenerate|canvas too large/i.test(message)) {
51
+ return 'STITCH_CAMERA_PARAMS_FAIL';
52
+ }
53
+ // v0.16 — the native post-stitch validator rejected the output as
54
+ // disjoint / fragmented / wildly mis-proportioned (frames didn't connect
55
+ // into one coherent panorama). The cpp throw carries "stitch validation"
56
+ // / "disjoint" / "fragmented" (see cpp/stitcher.cpp validateStitchOutput).
57
+ // Kept BEFORE the OOM branch so its distinct message isn't swallowed.
58
+ if (/stitch validation|disjoint|fragmented|low-quality stitch/i.test(message)) {
59
+ return 'STITCH_LOW_QUALITY';
60
+ }
61
+ // OOM, including the pre-stitch headroom abort ("pre-stitch memory abort"
62
+ // / "memory abort") that fires when even the minimal streaming config
63
+ // won't fit — same user remedy (shorter sweep), so same code.
64
+ if (/out of memory|oom|memory abort/i.test(message)) {
65
+ return 'STITCH_OOM';
66
+ }
67
+ return 'PANORAMA_FINALIZE_FAILED';
68
+ }
@@ -0,0 +1,268 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * cropGeometry — pure coordinate + quad helpers behind the item-7
4
+ * draggable-corner crop editor (`RectCropPreview`).
5
+ *
6
+ * The editor shows the full result image with a `resizeMode="contain"`
7
+ * letterbox: the image is centred and uniformly scaled to fit the layout
8
+ * box, leaving symmetric bars on one axis. The 4 draggable corners live in
9
+ * ON-SCREEN coordinates (the touch space PanResponder reports), but the
10
+ * native crop needs IMAGE-PIXEL coordinates. These helpers are the
11
+ * letterbox transform and its inverse — extracted verbatim from
12
+ * `example/InscribedRectDebug.tsx` (~lines 178-204), which mapped an
13
+ * inscribed-rect from image px → screen the same way.
14
+ *
15
+ * Everything here is pure (no React, no native) so it's unit-testable
16
+ * without booting a render — same posture as `contentRotationDeg` and
17
+ * `buildPanoramaInitialSettings`.
18
+ *
19
+ * Coordinate conventions:
20
+ * - A `Point` is `{ x, y }`. Screen points are in the layout box's
21
+ * local space (origin = box top-left); image points are in pixel
22
+ * space (origin = image top-left, range [0..imageW] × [0..imageH]).
23
+ * - A `Quad` is exactly 4 points. `orderQuadCorners` canonicalises
24
+ * winding to [TL, TR, BR, BL] so downstream native perspective
25
+ * rectify gets corners in the order it expects.
26
+ */
27
+
28
+ /** A 2-D point in either screen-local or image-pixel space. */
29
+ export interface Point {
30
+ x: number;
31
+ y: number;
32
+ }
33
+
34
+ /** The contain-fit letterbox layout of the image inside its box. */
35
+ export interface ContainLayout {
36
+ /** Layout box width (on-screen px). */
37
+ width: number;
38
+ /** Layout box height (on-screen px). */
39
+ height: number;
40
+ }
41
+
42
+ /** Exactly four points (corners of a crop quad). */
43
+ export type Quad = [Point, Point, Point, Point];
44
+
45
+
46
+ /**
47
+ * The contain-fit transform: uniform scale + centring offsets that map
48
+ * image-pixel space into the on-screen layout box. Returns `null` when
49
+ * any dimension is non-positive (nothing to lay out).
50
+ *
51
+ * `scale` is `min(box.w / imageW, box.h / imageH)` — the same
52
+ * `resizeMode="contain"` math RN's <Image> applies — and `offX`/`offY`
53
+ * centre the scaled image, producing the letterbox bars.
54
+ */
55
+ export function containFit(
56
+ layout: ContainLayout,
57
+ imageW: number,
58
+ imageH: number,
59
+ ): { scale: number; offX: number; offY: number } | null {
60
+ if (
61
+ layout.width <= 0
62
+ || layout.height <= 0
63
+ || imageW <= 0
64
+ || imageH <= 0
65
+ ) {
66
+ return null;
67
+ }
68
+ const scale = Math.min(layout.width / imageW, layout.height / imageH);
69
+ const dispW = imageW * scale;
70
+ const dispH = imageH * scale;
71
+ const offX = (layout.width - dispW) / 2;
72
+ const offY = (layout.height - dispH) / 2;
73
+ return { scale, offX, offY };
74
+ }
75
+
76
+
77
+ /**
78
+ * Map an on-screen point (layout-box local coords) → image-pixel coords.
79
+ * Inverse of {@link imageToScreen}. The result is clamped to
80
+ * `[0..imageW] × [0..imageH]` so a corner dragged onto / past the
81
+ * letterbox bar still yields a valid in-bounds pixel for the native crop
82
+ * (the user can't pick pixels that don't exist).
83
+ *
84
+ * Returns the un-mapped point unchanged when the layout is degenerate
85
+ * (see {@link containFit}) — the caller has no valid letterbox yet.
86
+ */
87
+ export function screenToImage(
88
+ point: Point,
89
+ layout: ContainLayout,
90
+ imageW: number,
91
+ imageH: number,
92
+ ): Point {
93
+ const fit = containFit(layout, imageW, imageH);
94
+ if (!fit) return point;
95
+ const x = (point.x - fit.offX) / fit.scale;
96
+ const y = (point.y - fit.offY) / fit.scale;
97
+ return {
98
+ x: clamp(x, 0, imageW),
99
+ y: clamp(y, 0, imageH),
100
+ };
101
+ }
102
+
103
+
104
+ /**
105
+ * Map an image-pixel point → on-screen point (layout-box local coords).
106
+ * Inverse of {@link screenToImage}. Used to seed the draggable corners
107
+ * from an image-space initial rect and to keep the overlay aligned to the
108
+ * letterboxed image.
109
+ *
110
+ * Returns the un-mapped point unchanged when the layout is degenerate.
111
+ */
112
+ export function imageToScreen(
113
+ point: Point,
114
+ layout: ContainLayout,
115
+ imageW: number,
116
+ imageH: number,
117
+ ): Point {
118
+ const fit = containFit(layout, imageW, imageH);
119
+ if (!fit) return point;
120
+ return {
121
+ x: fit.offX + point.x * fit.scale,
122
+ y: fit.offY + point.y * fit.scale,
123
+ };
124
+ }
125
+
126
+
127
+ /**
128
+ * Re-order 4 arbitrary corner points into canonical
129
+ * [TL, TR, BR, BL] (clockwise from top-left) winding.
130
+ *
131
+ * Strategy (robust to slight perspective skew, no trig):
132
+ * - Top two = the two points with the smallest `y`; bottom two = the
133
+ * largest `y`. Within each pair, the smaller `x` is left.
134
+ * Ties on `y` (a perfectly axis-aligned rect) resolve deterministically
135
+ * because the sort is stable and we then split by `x`.
136
+ *
137
+ * This matches the corner order the native `cropToQuad` perspective
138
+ * rectify expects (dst rect: TL→TR→BR→BL).
139
+ */
140
+ export function orderQuadCorners(pts: Quad): Quad {
141
+ // Sort a copy by y ascending so [0,1] are the top pair, [2,3] bottom.
142
+ const byY = [...pts].sort((a, b) => a.y - b.y);
143
+ const [t0, t1, b0, b1] = byY;
144
+ // Within each horizontal pair, smaller x is the left corner.
145
+ const [tl, tr] = t0.x <= t1.x ? [t0, t1] : [t1, t0];
146
+ const [bl, br] = b0.x <= b1.x ? [b0, b1] : [b1, b0];
147
+ return [tl, tr, br, bl];
148
+ }
149
+
150
+
151
+ /**
152
+ * 2× the signed area of a polygon via the shoelace formula. Positive for
153
+ * counter-clockwise winding, negative for clockwise, ~0 for degenerate.
154
+ * Exported for tests + reused by {@link isQuadValid}.
155
+ */
156
+ export function signedArea2(pts: Quad): number {
157
+ let sum = 0;
158
+ for (let i = 0; i < pts.length; i++) {
159
+ const a = pts[i];
160
+ const b = pts[(i + 1) % pts.length];
161
+ sum += a.x * b.y - b.x * a.y;
162
+ }
163
+ return sum;
164
+ }
165
+
166
+
167
+ /**
168
+ * True when the 4 points form a usable crop quad:
169
+ * 1. **Non-degenerate area** — `|signed area|` ≥ `minArea` (default
170
+ * `1`, i.e. at least 1 px²). Rejects all-collinear / zero-size.
171
+ * 2. **Convex** — every cross-product of consecutive edges shares one
172
+ * sign (allowing zero for a straight, axis-aligned corner). Rejects
173
+ * self-intersecting / "bowtie" quads, which the native perspective
174
+ * warp can't rectify.
175
+ *
176
+ * Operates on the points in their given winding (call `orderQuadCorners`
177
+ * first if you need canonical order); convexity is winding-agnostic.
178
+ */
179
+ export function isQuadValid(pts: Quad, minArea = 1): boolean {
180
+ if (Math.abs(signedArea2(pts)) < minArea * 2) return false;
181
+ return isConvex(pts);
182
+ }
183
+
184
+
185
+ /**
186
+ * Target rectangle size for the perspective `dst` quad, derived from the
187
+ * 4 ORDERED ([TL, TR, BR, BL]) image-pixel corners:
188
+ * - `w` = average of the top edge (TL→TR) and bottom edge (BL→BR)
189
+ * lengths.
190
+ * - `h` = average of the left edge (TL→BL) and right edge (TR→BR)
191
+ * lengths.
192
+ * Averaging opposite edges gives a stable output size for a skewed quad
193
+ * (each pair of opposite edges differs under perspective; the mean is the
194
+ * least-distorting target). Rounds to whole pixels — the native crop
195
+ * allocates an integer-sized bitmap.
196
+ *
197
+ * Caller must pass corners already in [TL, TR, BR, BL] order (use
198
+ * {@link orderQuadCorners}); the math assumes that winding.
199
+ */
200
+ export function rectSizeForQuad(orderedImagePts: Quad): {
201
+ w: number;
202
+ h: number;
203
+ } {
204
+ const [tl, tr, br, bl] = orderedImagePts;
205
+ const top = dist(tl, tr);
206
+ const bottom = dist(bl, br);
207
+ const left = dist(tl, bl);
208
+ const right = dist(tr, br);
209
+ return {
210
+ w: Math.round((top + bottom) / 2),
211
+ h: Math.round((left + right) / 2),
212
+ };
213
+ }
214
+
215
+
216
+ /**
217
+ * True when an ORDERED ([TL, TR, BR, BL]) image-pixel quad is, within
218
+ * `tolerancePx`, an axis-aligned rectangle — i.e. the cheap axis-aligned
219
+ * `cropToRect` path applies and no perspective warp is needed. The parent
220
+ * uses this to choose `cropToRect` vs `cropToQuad`.
221
+ *
222
+ * Checks the two top/bottom corners share a `y` and the two left/right
223
+ * corners share an `x`, all within tolerance.
224
+ */
225
+ export function isAxisAlignedRect(
226
+ orderedImagePts: Quad,
227
+ tolerancePx = 1,
228
+ ): boolean {
229
+ const [tl, tr, br, bl] = orderedImagePts;
230
+ return (
231
+ Math.abs(tl.y - tr.y) <= tolerancePx
232
+ && Math.abs(bl.y - br.y) <= tolerancePx
233
+ && Math.abs(tl.x - bl.x) <= tolerancePx
234
+ && Math.abs(tr.x - br.x) <= tolerancePx
235
+ );
236
+ }
237
+
238
+
239
+ /** Convexity test: all consecutive edge cross-products share a sign. */
240
+ function isConvex(pts: Quad): boolean {
241
+ let sign = 0;
242
+ for (let i = 0; i < pts.length; i++) {
243
+ const a = pts[i];
244
+ const b = pts[(i + 1) % pts.length];
245
+ const c = pts[(i + 2) % pts.length];
246
+ const cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x);
247
+ if (cross !== 0) {
248
+ const s = cross > 0 ? 1 : -1;
249
+ if (sign === 0) sign = s;
250
+ else if (s !== sign) return false;
251
+ }
252
+ }
253
+ return true;
254
+ }
255
+
256
+
257
+ /** Euclidean distance between two points. */
258
+ function dist(a: Point, b: Point): number {
259
+ return Math.hypot(a.x - b.x, a.y - b.y);
260
+ }
261
+
262
+
263
+ /** Clamp `v` into the inclusive `[lo, hi]` range. */
264
+ function clamp(v: number, lo: number, hi: number): number {
265
+ if (v < lo) return lo;
266
+ if (v > hi) return hi;
267
+ return v;
268
+ }
@@ -0,0 +1,25 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * DISPLAY_DECODE_IMAGE_PROPS — props every <Image> that displays a
4
+ * FULL-RES capture (a stitched panorama or a photo file) must spread.
5
+ *
6
+ * Why this exists (the accumulation half of the OOM crash):
7
+ * On Android 8+, decoded bitmap pixels live in the NATIVE heap, and the
8
+ * source here is the full-resolution capture file — a wide panorama can
9
+ * be tens of megapixels. Without `resizeMethod="resize"`, Android/Fresco
10
+ * decodes the source at FULL resolution into a native bitmap that the
11
+ * mounted <Image> pins (not LRU-evictable), and Fresco's URI-keyed cache
12
+ * keeps it even after the view unmounts. Each capture (especially wide
13
+ * panoramas) then accumulates tens of MB of native heap until lmkd
14
+ * OOM-kills the app. 'resize' decodes at the on-screen (~device-width)
15
+ * size instead, making per-image memory tiny and panorama-size-
16
+ * independent. No-op on iOS (harmless).
17
+ *
18
+ * Centralised (rather than a bare `resizeMethod="resize"` at each call
19
+ * site) so the decode strategy + its rationale have one home, and so the
20
+ * contract is unit-testable without mounting a component. Spread it:
21
+ * <Image source={...} resizeMode="cover" {...DISPLAY_DECODE_IMAGE_PROPS} />
22
+ */
23
+ export const DISPLAY_DECODE_IMAGE_PROPS = {
24
+ resizeMethod: 'resize',
25
+ } as const;