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,78 @@
1
+ /**
2
+ * cropQuad — item-7 perspective crop: rectify a user-dragged
3
+ * quadrilateral to an upright rectangle.
4
+ *
5
+ * The post-capture crop editor (`src/camera/RectCropPreview.tsx`) lets the
6
+ * user drag 4 independent corners over the stitched result. When that
7
+ * quad isn't ~axis-aligned, the host calls THIS wrapper instead of the
8
+ * cheap `cropToRect`: it hands the 4 IMAGE-PIXEL corners to the native
9
+ * `BatchStitcher.cropToQuad`, which runs
10
+ * `cv::getPerspectiveTransform` + `cv::warpPerspective` to produce an
11
+ * upright rectangle (averaged opposite-edge dimensions) and overwrites the
12
+ * file in place.
13
+ *
14
+ * This is the typed twin of the `cropToRect` call in
15
+ * `example/InscribedRectDebug.tsx` — same native module (`BatchStitcher`),
16
+ * same in-place overwrite + `{ width, height }` result contract, same
17
+ * platform-availability fallback posture as
18
+ * `src/quality/normaliseOrientation.ts`.
19
+ *
20
+ * Corner-order contract: `quadImagePoints` MUST be in canonical
21
+ * [TL, TR, BR, BL] (clockwise from top-left) order — exactly what
22
+ * `cropGeometry.ts:orderQuadCorners` produces and `RectCropResult.quad`
23
+ * carries. The native side rectifies into a rectangle whose corners map
24
+ * TL→(0,0), TR→(w,0), BR→(w,h), BL→(0,h); pass un-ordered points and the
25
+ * output is mirrored / rotated.
26
+ */
27
+ import type { Quad } from '../camera/cropGeometry';
28
+ /** Options for {@link cropQuad}. */
29
+ export interface CropQuadOptions {
30
+ /**
31
+ * JPEG quality for the re-encoded output, 1–100. Defaults to 90 (the
32
+ * native default, matching `cropToRect`).
33
+ */
34
+ quality?: number;
35
+ }
36
+ /** Resolved result of a successful {@link cropQuad}. */
37
+ export interface CropQuadResult {
38
+ /**
39
+ * The file the rectified image was written to. Equals the input
40
+ * `imagePath` (the native crop overwrites in place) — surfaced
41
+ * explicitly so callers don't have to assume the in-place contract.
42
+ */
43
+ outputPath: string;
44
+ /** Width of the rectified rectangle, in pixels. */
45
+ width: number;
46
+ /** Height of the rectified rectangle, in pixels. */
47
+ height: number;
48
+ }
49
+ /**
50
+ * Flatten the 4 ordered ([TL, TR, BR, BL]) image-pixel corners into the
51
+ * `[tlX, tlY, trX, trY, brX, brY, blX, blY]` array the native module
52
+ * expects. Exported for unit tests + reuse.
53
+ */
54
+ export declare function flattenQuad(quad: Quad): number[];
55
+ /**
56
+ * Perspective-rectify `quadImagePoints` out of `imagePath` into an upright
57
+ * rectangle, overwriting the file in place, and resolve the output path +
58
+ * rectified dimensions.
59
+ *
60
+ * @param imagePath file:// URI (or bare path) of the image to crop.
61
+ * @param quadImagePoints the 4 corners in IMAGE-PIXEL space, canonically
62
+ * ordered [TL, TR, BR, BL] (use
63
+ * `orderQuadCorners`). This is exactly
64
+ * `RectCropResult.quad`.
65
+ * @param outPath where to write the result. The native crop
66
+ * OVERWRITES IN PLACE, so this currently MUST equal
67
+ * `imagePath` (or be omitted — defaults to it).
68
+ * Passing a different path throws, surfacing the
69
+ * limitation rather than silently ignoring it; see
70
+ * the integrator note in the item-7 handoff.
71
+ * @param opts optional `{ quality }`.
72
+ *
73
+ * @throws if the native module isn't registered, if `outPath` differs from
74
+ * `imagePath`, or if the native crop rejects (degenerate quad,
75
+ * canvas guard, write failure).
76
+ */
77
+ export declare function cropQuad(imagePath: string, quadImagePoints: Quad, outPath?: string, opts?: CropQuadOptions): Promise<CropQuadResult>;
78
+ //# sourceMappingURL=cropQuad.d.ts.map
@@ -0,0 +1,116 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * cropQuad — item-7 perspective crop: rectify a user-dragged
5
+ * quadrilateral to an upright rectangle.
6
+ *
7
+ * The post-capture crop editor (`src/camera/RectCropPreview.tsx`) lets the
8
+ * user drag 4 independent corners over the stitched result. When that
9
+ * quad isn't ~axis-aligned, the host calls THIS wrapper instead of the
10
+ * cheap `cropToRect`: it hands the 4 IMAGE-PIXEL corners to the native
11
+ * `BatchStitcher.cropToQuad`, which runs
12
+ * `cv::getPerspectiveTransform` + `cv::warpPerspective` to produce an
13
+ * upright rectangle (averaged opposite-edge dimensions) and overwrites the
14
+ * file in place.
15
+ *
16
+ * This is the typed twin of the `cropToRect` call in
17
+ * `example/InscribedRectDebug.tsx` — same native module (`BatchStitcher`),
18
+ * same in-place overwrite + `{ width, height }` result contract, same
19
+ * platform-availability fallback posture as
20
+ * `src/quality/normaliseOrientation.ts`.
21
+ *
22
+ * Corner-order contract: `quadImagePoints` MUST be in canonical
23
+ * [TL, TR, BR, BL] (clockwise from top-left) order — exactly what
24
+ * `cropGeometry.ts:orderQuadCorners` produces and `RectCropResult.quad`
25
+ * carries. The native side rectifies into a rectangle whose corners map
26
+ * TL→(0,0), TR→(w,0), BR→(w,h), BL→(0,h); pass un-ordered points and the
27
+ * output is mirrored / rotated.
28
+ */
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.flattenQuad = flattenQuad;
31
+ exports.cropQuad = cropQuad;
32
+ const react_native_1 = require("react-native");
33
+ /**
34
+ * Resolve the native `cropToQuad` function off `NativeModules.BatchStitcher`,
35
+ * or `null` when the module / method isn't registered (e.g. an older native
36
+ * build). Same defensive lookup as `normaliseOrientation`.
37
+ */
38
+ function resolveCropToQuad() {
39
+ const native = react_native_1.NativeModules['BatchStitcher'];
40
+ if (native
41
+ && typeof native === 'object'
42
+ && typeof native.cropToQuad === 'function') {
43
+ return native.cropToQuad;
44
+ }
45
+ return null;
46
+ }
47
+ /**
48
+ * Flatten the 4 ordered ([TL, TR, BR, BL]) image-pixel corners into the
49
+ * `[tlX, tlY, trX, trY, brX, brY, blX, blY]` array the native module
50
+ * expects. Exported for unit tests + reuse.
51
+ */
52
+ function flattenQuad(quad) {
53
+ const out = [];
54
+ for (const p of quad) {
55
+ out.push(p.x, p.y);
56
+ }
57
+ return out;
58
+ }
59
+ /**
60
+ * Perspective-rectify `quadImagePoints` out of `imagePath` into an upright
61
+ * rectangle, overwriting the file in place, and resolve the output path +
62
+ * rectified dimensions.
63
+ *
64
+ * @param imagePath file:// URI (or bare path) of the image to crop.
65
+ * @param quadImagePoints the 4 corners in IMAGE-PIXEL space, canonically
66
+ * ordered [TL, TR, BR, BL] (use
67
+ * `orderQuadCorners`). This is exactly
68
+ * `RectCropResult.quad`.
69
+ * @param outPath where to write the result. The native crop
70
+ * OVERWRITES IN PLACE, so this currently MUST equal
71
+ * `imagePath` (or be omitted — defaults to it).
72
+ * Passing a different path throws, surfacing the
73
+ * limitation rather than silently ignoring it; see
74
+ * the integrator note in the item-7 handoff.
75
+ * @param opts optional `{ quality }`.
76
+ *
77
+ * @throws if the native module isn't registered, if `outPath` differs from
78
+ * `imagePath`, or if the native crop rejects (degenerate quad,
79
+ * canvas guard, write failure).
80
+ */
81
+ async function cropQuad(imagePath, quadImagePoints, outPath, opts) {
82
+ if (outPath !== undefined && outPath !== imagePath) {
83
+ // The native cropToQuad (like cropToRect) only overwrites in place.
84
+ // Fail loudly rather than silently writing to imagePath and returning
85
+ // a path the file isn't at.
86
+ throw new Error('[capture-sdk] cropQuad: native crop overwrites in place; '
87
+ + 'outPath must equal imagePath (or be omitted).');
88
+ }
89
+ const fn = resolveCropToQuad();
90
+ if (!fn) {
91
+ throw new Error(`[capture-sdk] cropQuad: native module BatchStitcher.cropToQuad not `
92
+ + `available on ${react_native_1.Platform.OS}. Ensure the native module is registered.`);
93
+ }
94
+ const quality = clampQuality(opts?.quality);
95
+ const dims = await fn({
96
+ imagePath,
97
+ quad: flattenQuad(quadImagePoints),
98
+ quality,
99
+ });
100
+ return {
101
+ outputPath: imagePath,
102
+ width: dims.width,
103
+ height: dims.height,
104
+ };
105
+ }
106
+ /** Clamp the requested JPEG quality into [1, 100]; default 90. */
107
+ function clampQuality(quality) {
108
+ if (quality === undefined || Number.isNaN(quality))
109
+ return 90;
110
+ if (quality < 1)
111
+ return 1;
112
+ if (quality > 100)
113
+ return 100;
114
+ return Math.round(quality);
115
+ }
116
+ //# sourceMappingURL=cropQuad.js.map
@@ -639,6 +639,33 @@ export interface IncrementalFinalizeResult {
639
639
  * on the just-completed capture.
640
640
  */
641
641
  stitchModeResolved?: 'panorama' | 'scans';
642
+ /**
643
+ * 2026-06-14 (DEV overlay) — a semicolon-separated `key=value` trace of the
644
+ * stitcher's RUNTIME choices for this output, e.g.
645
+ * `"pipe=manual;warp=spherical;route=batch;seam=graphcut;blend=multiband"`.
646
+ * pipe: `manual` (cv::detail) | `highlevel` (cv::Stitcher)
647
+ * warp: `plane` | `cylindrical` | `spherical`
648
+ * route: `batch` (warp-all + seam) | `stream` (low-memory per-frame)
649
+ * seam: `graphcut` | `none`
650
+ * blend: `multiband` | `feather`
651
+ * Intended for a __DEV__-only overlay so the operator can see HOW the
652
+ * panorama was built (which warper, whether the low-memory stream/feather
653
+ * fallback kicked in, etc.). iOS only for now; undefined elsewhere.
654
+ */
655
+ debugSummary?: string;
656
+ /**
657
+ * 2026-06-15 (iOS) — the exact keyframe JPEG paths used for this stitch.
658
+ * Lets the host re-stitch the SAME frames on demand via `refinePanorama`
659
+ * (e.g. the high-level preview tab) without re-running the capture or
660
+ * enumerating the session directory. iOS only; undefined elsewhere.
661
+ */
662
+ batchKeyframePaths?: string[];
663
+ /**
664
+ * 2026-06-15 (iOS) — the capture orientation this stitch baked into the
665
+ * output. An on-demand re-stitch (refinePanorama) MUST pass this back or the
666
+ * result comes out in the raw sensor landscape (sideways). iOS only.
667
+ */
668
+ captureOrientation?: string;
642
669
  }
643
670
  /**
644
671
  * 2026-05-16 — input to `refinePanorama`. Mirrors the subset of
@@ -701,6 +728,15 @@ export interface IncrementalRefineOptions {
701
728
  stitchMode?: 'auto' | 'panorama' | 'scans';
702
729
  /** JPEG quality 1..100, default 90. */
703
730
  jpegQuality?: number;
731
+ /**
732
+ * 2026-06-15 (iOS) — which stitch pipeline to run. `true` = the manual
733
+ * `cv::detail` pipeline (the default batch-capture output); `false` = stock
734
+ * high-level `cv::Stitcher`. Default `false` on the refine path. This is
735
+ * how the on-demand "high-level" preview tab re-stitches the captured
736
+ * keyframes via cv::Stitcher without re-running the whole capture. iOS only
737
+ * (Android refine is always cv::Stitcher).
738
+ */
739
+ useManualPipeline?: boolean;
704
740
  }
705
741
  /**
706
742
  * 2026-05-16 — result of an explicit `refinePanorama` call. Mirrors
@@ -720,6 +756,15 @@ export interface IncrementalRefineResult {
720
756
  framesDropped: number;
721
757
  /** The confidence threshold that succeeded. -1 when not applicable. */
722
758
  finalConfidenceThresh: number;
759
+ /**
760
+ * 2026-06-15 (DEV overlay A/B-aware) — the stitcher's own semicolon-separated
761
+ * `key=value` runtime recipe for THIS refined output, e.g.
762
+ * `"pipe=highlevel;warp=spherical;route=batch;seam=graphcut;blend=multiband"`.
763
+ * Mirrors `IncrementalFinalizeResult.debugSummary`. Lets the on-demand
764
+ * high-level preview tab show its OWN recipe in the __DEV__ overlay pill
765
+ * instead of the manual primary's recipe. iOS only; undefined elsewhere.
766
+ */
767
+ debugSummary?: string;
723
768
  }
724
769
  /**
725
770
  * V15.0e — ARKit plane detection state, polled by the capture screen
@@ -943,7 +943,7 @@ public final class IncrementalStitcher: NSObject {
943
943
  } else if let v = configOverrides["maxKeyframeIntervalMs"] as? Int {
944
944
  self.keyframeGate.maxKeyframeIntervalMs = max(0.0, Double(v))
945
945
  } else {
946
- self.keyframeGate.maxKeyframeIntervalMs = 2000.0
946
+ self.keyframeGate.maxKeyframeIntervalMs = 1500.0
947
947
  }
948
948
  // V16 — novelty aggregation percentile. Clamp at start to
949
949
  // [0.5, 0.99]; the bridge re-clamps but matching it here
@@ -1438,7 +1438,10 @@ public final class IncrementalStitcher: NSObject {
1438
1438
  seamFinderType: payload.batchSeamFinderType,
1439
1439
  captureOrientation: payload.captureOrientation,
1440
1440
  useInscribedRectCrop: payload.batchEnableInscribedRectCrop,
1441
- stitchMode: payload.batchStitchModeResolved
1441
+ stitchMode: payload.batchStitchModeResolved,
1442
+ // Batch capture = the default output = MANUAL pipeline
1443
+ // (graphcut + multiband + the full memory-guard set).
1444
+ useManualPipeline: true
1442
1445
  )
1443
1446
  // V16 fix-attempt 9 (verified on device,
1444
1447
  // 2026-05-13) — sentinel-result detection.
@@ -1501,6 +1504,17 @@ public final class IncrementalStitcher: NSObject {
1501
1504
  "batchKeyframeSessionDir":
1502
1505
  payload.collector?.sessionDir ?? "",
1503
1506
  "batchKeyframeCount": payload.paths.count,
1507
+ // 2026-06-15 — the exact keyframe JPEG paths used for
1508
+ // this stitch, so JS can re-stitch them ON DEMAND via
1509
+ // refinePanorama (the high-level tab) without listing
1510
+ // the session dir itself.
1511
+ "batchKeyframePaths": payload.paths,
1512
+ // The orientation this stitch baked into the output.
1513
+ // The on-demand high-level re-stitch MUST pass the
1514
+ // same value or it comes out in the raw sensor
1515
+ // landscape (sideways) — refinePanorama otherwise
1516
+ // defaults to "portrait" (no bake-rotation).
1517
+ "captureOrientation": payload.captureOrientation,
1504
1518
  ]
1505
1519
  if r.framesRequested >= 0 {
1506
1520
  batchDict["framesRequested"] = Int(r.framesRequested)
@@ -1523,6 +1537,12 @@ public final class IncrementalStitcher: NSObject {
1523
1537
  // helps the operator understand why the
1524
1538
  // panorama looks the way it does.
1525
1539
  batchDict["stitchModeResolved"] = payload.batchStitchModeResolved
1540
+ // 2026-06-14 (DEV overlay) — the stitcher's runtime
1541
+ // choices (pipeline/warper/route/seam/blend) for this
1542
+ // output, shown on the preview in __DEV__.
1543
+ if !r.debugSummary.isEmpty {
1544
+ batchDict["debugSummary"] = r.debugSummary
1545
+ }
1526
1546
  completion(batchDict, nil)
1527
1547
  } catch let stitchErr as NSError {
1528
1548
  completion(nil, stitchErr)
@@ -1641,6 +1661,11 @@ public final class IncrementalStitcher: NSObject {
1641
1661
  // at line 738 of src/stitching/incremental.ts). JS callers
1642
1662
  // can override by passing config["stitchMode"].
1643
1663
  let refineStitchMode = (config["stitchMode"] as? String) ?? "scans"
1664
+ // 2026-06-15 — pipeline is caller-selectable. The on-demand high-level
1665
+ // tab calls refinePanorama with useManualPipeline:false to re-stitch the
1666
+ // captured keyframes via stock cv::Stitcher. Default false (high-level)
1667
+ // preserves the refine path's historical cv::Stitcher behaviour.
1668
+ let refineManual = (config["useManualPipeline"] as? Bool) ?? false
1644
1669
  let quality = max(1, min(100, (config["jpegQuality"] as? Int) ?? 90))
1645
1670
  let cleanedOutput = outputPath.hasPrefix("file://")
1646
1671
  ? String(outputPath.dropFirst(7))
@@ -1679,7 +1704,8 @@ public final class IncrementalStitcher: NSObject {
1679
1704
  seamFinderType: seam,
1680
1705
  captureOrientation: orientation,
1681
1706
  useInscribedRectCrop: useInscribed,
1682
- stitchMode: refineStitchMode
1707
+ stitchMode: refineStitchMode,
1708
+ useManualPipeline: refineManual
1683
1709
  )
1684
1710
  // fix-9 sentinel detection — see the finalize() path
1685
1711
  // for the full rationale. A 0×0 result means
@@ -1717,7 +1743,13 @@ public final class IncrementalStitcher: NSObject {
1717
1743
  frames: frameCount,
1718
1744
  errorMessage: nil
1719
1745
  )
1720
- completion([
1746
+ // 2026-06-15 (DEV overlay A/B-aware) — carry the stitcher's
1747
+ // own runtime recipe up to JS so the preview's DEV pill shows
1748
+ // the HIGH-LEVEL recipe (pipe=highlevel;warp=spherical;…) while
1749
+ // the user views the high-level tab, instead of the manual
1750
+ // primary's recipe. Mirrors the batch finalize's batchDict
1751
+ // (guard empty — empty string means unavailable).
1752
+ var refineDict: [String: Any] = [
1721
1753
  "panoramaPath": r.outputPath,
1722
1754
  "width": Int(r.width),
1723
1755
  "height": Int(r.height),
@@ -1725,7 +1757,11 @@ public final class IncrementalStitcher: NSObject {
1725
1757
  "framesIncluded": frameCount,
1726
1758
  "framesDropped": 0,
1727
1759
  "finalConfidenceThresh": -1.0,
1728
- ], nil)
1760
+ ]
1761
+ if !r.debugSummary.isEmpty {
1762
+ refineDict["debugSummary"] = r.debugSummary
1763
+ }
1764
+ completion(refineDict, nil)
1729
1765
  } catch let err as NSError {
1730
1766
  self?.emitRefineProgress(
1731
1767
  stage: "error",
@@ -2450,11 +2486,23 @@ public final class IncrementalStitcher: NSObject {
2450
2486
  let denom = tScore + rScore
2451
2487
  if denom <= 1e-9 { return "panorama" } // no motion either way
2452
2488
  let ratio = tScore / denom
2489
+
2490
+ // 2026-06-15 — LOW-ROTATION GUARD. The gyro rotation (rRadians) is
2491
+ // trustworthy; the IMU translation (tMeters, in non-AR) is NOT — a
2492
+ // continuous rotation leaks gravity into the double-integrated accel and
2493
+ // inflates it, which can falsely push `ratio` over 0.55 → SCANS, whose
2494
+ // affine warper can't represent the rotation. When the gyro shows a
2495
+ // clear pan (> ~20°) with only modest translation, force PANORAMA
2496
+ // regardless of the (possibly-inflated) translation. Genuine shelf
2497
+ // scans (low rotation, large real translation) skip this and still
2498
+ // reach SCANS via the ratio.
2499
+ let lowRotationGuard = rRadians > 0.35 && tMeters < 0.25
2500
+ let mode = (!lowRotationGuard && ratio >= 0.55) ? "scans" : "panorama"
2453
2501
  os_log(.fault, log: Self.diagLog,
2454
- "[stitchMode.auto] tPose=%.3fm tImu=%.3fm r=%.3frad ratio=%.3f → %{public}@",
2502
+ "[stitchMode.auto] tPose=%.3fm tImu=%.3fm r=%.3frad ratio=%.3f rotGuard=%d → %{public}@",
2455
2503
  tPose, imuTranslationMetres, rRadians, ratio,
2456
- ratio >= 0.55 ? "scans" : "panorama")
2457
- return ratio >= 0.55 ? "scans" : "panorama"
2504
+ lowRotationGuard ? 1 : 0, mode)
2505
+ return mode
2458
2506
  }
2459
2507
 
2460
2508
  /// Closed-form q · (0,0,-1) · q⁻¹ — rotates the camera-forward
@@ -151,9 +151,9 @@ final class KeyframeGate {
151
151
  /// overlapThreshold. Unlike `flowMaxTranslationCm` this applies to
152
152
  /// BOTH the Pose and Flow strategies, and is passed STRAIGHT
153
153
  /// THROUGH to the bridge (the unit is already what C++ expects — no
154
- /// cm→m style conversion). Default 2000 ms; 0 = disabled. The C++
154
+ /// cm→m style conversion). Default 1500 ms; 0 = disabled. The C++
155
155
  /// setter clamps to ≥ 0.
156
- var maxKeyframeIntervalMs: Double = 2000.0 {
156
+ var maxKeyframeIntervalMs: Double = 1500.0 {
157
157
  didSet {
158
158
  bridge.setMaxKeyframeIntervalMs(maxKeyframeIntervalMs)
159
159
  }
@@ -12,6 +12,16 @@
12
12
  #include <opencv2/imgcodecs.hpp>
13
13
  #pragma pop_macro("NO")
14
14
 
15
+ // v0.16 — keyframe long-edge clamp (px) applied before the JPEG is written.
16
+ // The stitcher composites at ~1 MP (COMPOSE_MP) and `compose_scale` never
17
+ // upscales, so a keyframe larger than ~1.2 MP only inflates the held-set RAM
18
+ // (N × decoded frame) without sharpening the panorama — the 0.5× ultra-wide
19
+ // otherwise lands ~8 MP/frame here. 1280 px sits just above the compose
20
+ // target, so it reclaims ~6× of that RAM with zero quality loss. (Android's
21
+ // equivalent clamp is 640 px — a tighter low-RAM budget for A35-class
22
+ // devices; iOS can afford the full compose resolution.)
23
+ static const int kKeyframeMaxLongEdge = 1280;
24
+
15
25
  // V16 Phase 1.fix2 — write a JPEG with an EXIF Orientation tag so
16
26
  // iOS image renderers display the saved frame correctly while
17
27
  // cv::imread (with IMREAD_IGNORE_ORIENTATION) gets raw landscape
@@ -119,17 +129,26 @@ static BOOL WriteJPEGWithEXIF(const cv::Mat &bgr,
119
129
 
120
130
  - (nullable instancetype)initWithError:(NSError **)error {
121
131
  if ((self = [super init])) {
122
- NSURL *appSupport = [[NSFileManager defaultManager]
123
- URLForDirectory:NSApplicationSupportDirectory
132
+ // DEBUG builds write keyframes under Documents so they are inspectable in
133
+ // the Files app (gated by the example's Info.plist UIFileSharingEnabled +
134
+ // LSSupportsOpeningDocumentsInPlace). RELEASE keeps them in the private,
135
+ // auto-cleaned ApplicationSupport dir. See `cleanup` (retains in DEBUG).
136
+ #if DEBUG
137
+ NSSearchPathDirectory baseDirType = NSDocumentDirectory;
138
+ #else
139
+ NSSearchPathDirectory baseDirType = NSApplicationSupportDirectory;
140
+ #endif
141
+ NSURL *baseDir = [[NSFileManager defaultManager]
142
+ URLForDirectory:baseDirType
124
143
  inDomain:NSUserDomainMask
125
144
  appropriateForURL:nil
126
145
  create:YES
127
146
  error:error];
128
- if (!appSupport) return nil;
147
+ if (!baseDir) return nil;
129
148
  NSString *captureUUID = [[NSUUID UUID] UUIDString];
130
149
  NSString *sessionPath =
131
- [[appSupport.path stringByAppendingPathComponent:@"Captures"]
132
- stringByAppendingPathComponent:captureUUID];
150
+ [[baseDir.path stringByAppendingPathComponent:@"Captures"]
151
+ stringByAppendingPathComponent:captureUUID];
133
152
  BOOL ok = [[NSFileManager defaultManager]
134
153
  createDirectoryAtPath:sessionPath
135
154
  withIntermediateDirectories:YES
@@ -190,6 +209,22 @@ static BOOL WriteJPEGWithEXIF(const cv::Mat &bgr,
190
209
  rotated = bgr;
191
210
  }
192
211
 
212
+ // Clamp the keyframe's long edge (see kKeyframeMaxLongEdge). Uniform
213
+ // downscale — same factor on both axes — so it preserves aspect ratio AND
214
+ // orientation (no transpose/flip); the rotate above and the EXIF tag below
215
+ // are unaffected, only the pixel count shrinks. INTER_AREA is the correct
216
+ // filter for downsampling.
217
+ {
218
+ const int longEdge =
219
+ rotated.cols > rotated.rows ? rotated.cols : rotated.rows;
220
+ if (longEdge > kKeyframeMaxLongEdge) {
221
+ const double s = (double)kKeyframeMaxLongEdge / (double)longEdge;
222
+ cv::Mat scaled;
223
+ cv::resize(rotated, scaled, cv::Size(), s, s, cv::INTER_AREA);
224
+ rotated = scaled;
225
+ }
226
+ }
227
+
193
228
  NSInteger idx = self.acceptedCount;
194
229
  NSString *filename =
195
230
  [NSString stringWithFormat:@"keyframe-%03ld.jpg", (long)idx];
@@ -230,8 +265,16 @@ static BOOL WriteJPEGWithEXIF(const cv::Mat &bgr,
230
265
 
231
266
  - (void)cleanup {
232
267
  if (self.sessionDir.length == 0) return;
268
+ #if DEBUG
269
+ // DEBUG: keep the session's keyframes on disk so they can be inspected in
270
+ // the Files app (Documents/Captures/<uuid>/keyframe-NNN.jpg). Each capture
271
+ // is a fresh UUID folder; delete old ones via Files when done.
272
+ NSLog(@"[KeyframeCollector] DEBUG — retaining keyframes for inspection: %@",
273
+ self.sessionDir);
274
+ #else
233
275
  [[NSFileManager defaultManager] removeItemAtPath:self.sessionDir
234
276
  error:nil];
277
+ #endif
235
278
  }
236
279
 
237
280
  // ── CVPixelBuffer → cv::Mat (BGR) ──────────────────────────────────
@@ -44,6 +44,10 @@ extern NSString *const RNImageStitcherErrorDomain;
44
44
  @property (nonatomic, assign, readonly) NSInteger framesRequested;
45
45
  @property (nonatomic, assign, readonly) NSInteger framesIncluded;
46
46
  @property (nonatomic, assign, readonly) double finalConfidenceThresh;
47
+ /// 2026-06-14 (DEV overlay) — semicolon-separated `key=value` trace of the
48
+ /// stitcher's runtime choices for this output (pipeline/warper/route/seam/
49
+ /// blend), surfaced on the preview in __DEV__. Empty string when unavailable.
50
+ @property (nonatomic, copy, readonly) NSString *debugSummary;
47
51
  - (instancetype)initWithOutputPath:(NSString *)outputPath
48
52
  width:(NSInteger)width
49
53
  height:(NSInteger)height
@@ -108,6 +112,10 @@ extern NSString *const RNImageStitcherErrorDomain;
108
112
  /// them. With `useInscribedRectCrop:YES` we find the largest
109
113
  /// axis-aligned rectangle entirely inside the non-zero region
110
114
  /// and crop to that — clean output with no black corners.
115
+ /// `useManualPipeline`: YES → the manual cv::detail pipeline (graphcut +
116
+ /// multiband, with the full memory-guard machinery); NO → stock high-level
117
+ /// cv::Stitcher. The batch capture passes YES (the default output); the
118
+ /// on-demand high-level tab re-stitches the same keyframes with NO.
111
119
  + (nullable RNStitchResult *)stitchFramePaths:(NSArray<NSString *> *)framePaths
112
120
  outputPath:(NSString *)outputPath
113
121
  jpegQuality:(NSInteger)quality
@@ -117,6 +125,7 @@ extern NSString *const RNImageStitcherErrorDomain;
117
125
  captureOrientation:(nullable NSString *)captureOrientation
118
126
  useInscribedRectCrop:(BOOL)useInscribedRectCrop
119
127
  stitchMode:(nullable NSString *)stitchMode
128
+ useManualPipeline:(BOOL)useManualPipeline
120
129
  error:(NSError **)error;
121
130
 
122
131
  /// Extract `maxFrames` evenly-spaced frames from the video at
@@ -194,6 +203,24 @@ extern NSString *const RNImageStitcherErrorDomain;
194
203
  quality:(NSInteger)quality
195
204
  error:(NSError **)error;
196
205
 
206
+ /// item-7 — free-quad perspective crop. Takes 4 IMAGE-PIXEL corners
207
+ /// (ordered TL, TR, BR, BL) and rectifies the enclosed quadrilateral to
208
+ /// an upright rectangle (cv::getPerspectiveTransform + warpPerspective),
209
+ /// re-encodes at `quality`, overwrites in place. Returns the rectified
210
+ /// `{ width, height }`. Rejects a degenerate / non-convex / out-of-bounds
211
+ /// quad, and guards the output canvas with the shared canvasExceedsGuard.
212
+ + (nullable NSDictionary<NSString *, NSNumber *> *)cropToQuadAtPath:(NSString *)imagePath
213
+ tlX:(double)tlX
214
+ tlY:(double)tlY
215
+ trX:(double)trX
216
+ trY:(double)trY
217
+ brX:(double)brX
218
+ brY:(double)brY
219
+ blX:(double)blX
220
+ blY:(double)blY
221
+ quality:(NSInteger)quality
222
+ error:(NSError **)error;
223
+
197
224
  /// v0.15 debug — write a red-tinted overlay JPEG (excluded / sub-threshold
198
225
  /// pixels rendered red) next to `imagePath` (suffix ".mask.jpg") so the
199
226
  /// harness can show WHY the inscribed rect lands where it does. Returns