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
@@ -58,6 +58,11 @@
58
58
  // The header lives in the SDK's `cpp/` dir and is on the pod's
59
59
  // HEADER_SEARCH_PATHS (see RNImageStitcher.podspec).
60
60
  #import "stitcher.hpp"
61
+ // item-7 cropToQuad: the OpenCV-free quad geometry (quadDstRect /
62
+ // isQuadAcceptable) + the shared canvas OOM guard. Same `cpp/`
63
+ // HEADER_SEARCH_PATHS as stitcher.hpp.
64
+ #import "crop_quad.hpp"
65
+ #import "warp_guard.hpp"
61
66
  #import <UIKit/UIKit.h>
62
67
  #import <AVFoundation/AVFoundation.h>
63
68
  #import <os/log.h>
@@ -262,6 +267,12 @@ NSString *const RNImageStitcherErrorDomain = @"RNImageStitcherErrorDomain";
262
267
  // RNStitchResult
263
268
  // ─────────────────────────────────────────────────────────────────────
264
269
 
270
+ // Redeclare debugSummary as readwrite internally so it can be set after the
271
+ // designated initializer (keeps the init signature unchanged).
272
+ @interface RNStitchResult ()
273
+ @property (nonatomic, copy, readwrite) NSString *debugSummary;
274
+ @end
275
+
265
276
  @implementation RNStitchResult
266
277
 
267
278
  - (instancetype)initWithOutputPath:(NSString *)outputPath
@@ -280,6 +291,7 @@ NSString *const RNImageStitcherErrorDomain = @"RNImageStitcherErrorDomain";
280
291
  _framesRequested = framesRequested;
281
292
  _framesIncluded = framesIncluded;
282
293
  _finalConfidenceThresh = finalConfidenceThresh;
294
+ _debugSummary = @"";
283
295
  }
284
296
  return self;
285
297
  }
@@ -411,6 +423,7 @@ cv::detail::CameraParams cameraParamsFromPose(NSDictionary *pose) {
411
423
  captureOrientation:(NSString *)captureOrientation
412
424
  useInscribedRectCrop:(BOOL)useInscribedRectCrop
413
425
  stitchMode:(NSString *)stitchMode
426
+ useManualPipeline:(BOOL)useManualPipeline
414
427
  error:(NSError **)error {
415
428
  // ── Phase 2 (2026-05-16): delegated to shared C++ ───────────────────
416
429
  //
@@ -438,7 +451,7 @@ cv::detail::CameraParams cameraParamsFromPose(NSDictionary *pose) {
438
451
  // working until they are updated. The shared C++ has its own
439
452
  // defaults but we want the wrapper to be tolerant of nil inputs
440
453
  // from Swift / Obj-C callers that grew up against the legacy API.
441
- if (warperType == nil || warperType.length == 0) warperType = @"plane";
454
+ if (warperType == nil || warperType.length == 0) warperType = @"spherical";
442
455
  if (blenderType == nil || blenderType.length == 0) blenderType = @"multiband";
443
456
  if (seamFinderType == nil || seamFinderType.length == 0) seamFinderType = @"graphcut";
444
457
  if (captureOrientation == nil || captureOrientation.length == 0) captureOrientation = @"portrait";
@@ -479,12 +492,22 @@ cv::detail::CameraParams cameraParamsFromPose(NSDictionary *pose) {
479
492
  cfg.availableRamMB =
480
493
  (double)NSProcessInfo.processInfo.physicalMemory
481
494
  / (1024.0 * 1024.0);
482
- // Route to the manual cv::detail::* pipeline; the high-level
483
- // cv::Stitcher::create path (Android's default) is unsuitable for
484
- // iOS's shelf-pan capture shape (compose-MP defaults, graphcut at
485
- // compose-MP, BA convergence params see stitcher.hpp comment
486
- // block).
487
- cfg.useManualPipeline = true;
495
+ // 2026-06-15 — DEFAULT to the MANUAL cv::detail pipeline. ALL the memory/OOM
496
+ // hardening lives on the manual path (PreStitchMemoryAbort, RAM-aware
497
+ // canvas-budget downscale, STREAM/BATCH held-set routing, the black-canvas
498
+ // utilization guard); the high-level cv::Stitcher path calls NONE of it. So
499
+ // manual is both the user's preferred output AND the memory-safe one.
500
+ //
501
+ // WARPER: NOT hardcoded — cfg.warperType carries the caller's choice (set from
502
+ // the JS `warperType`, which defaults to "spherical" and is settable via the
503
+ // ⚙️ panel / the host's `defaultWarper` prop). The JS default is the single
504
+ // source of truth now. Choosing "plane" re-arms the dynamic plane→spherical
505
+ // fallback + divergence switch in the manual pipeline (they only fire when
506
+ // warperType != "spherical").
507
+ //
508
+ // The pipeline is caller-driven: batch capture passes YES (manual, default
509
+ // output); the on-demand high-level tab re-stitches with NO.
510
+ cfg.useManualPipeline = useManualPipeline;
488
511
 
489
512
  // Marshal NSArray<NSString*> → std::vector<std::string>. Strip the
490
513
  // `file://` scheme that some callers attach so the shared C++ can
@@ -566,6 +589,18 @@ cv::detail::CameraParams cameraParamsFromPose(NSDictionary *pose) {
566
589
  framesRequested:framesRequested
567
590
  framesIncluded:(NSInteger)r.framesIncluded
568
591
  finalConfidenceThresh:r.finalConfidenceThresh];
592
+ if (!r.debugSummary.empty()) {
593
+ result.debugSummary =
594
+ [NSString stringWithUTF8String:r.debugSummary.c_str()];
595
+ }
596
+ // 2026-06-15 — the eager A/B harness that ALSO stitched the high-level
597
+ // alt on EVERY capture has been REMOVED. Manual is now the default (this
598
+ // method), so computing high-level eagerly was pure wasted work —
599
+ // especially while profiling memory/perf — when the user isn't viewing
600
+ // it. The keyframe JPEGs are retained on disk so high-level can be
601
+ // produced ON DEMAND (follow-up: a `useManualPipeline` param on this
602
+ // method lets `refinePanorama` re-stitch them via the high-level path
603
+ // when the user switches to the high-level tab).
569
604
  } else {
570
605
  // Map StitchErrorCode → NSError.code. Preserves the existing
571
606
  // 9001/9002/9003/1001/9007 sentinels the JS UX layer already
@@ -863,6 +898,7 @@ cv::detail::CameraParams cameraParamsFromPose(NSDictionary *pose) {
863
898
  captureOrientation:nil
864
899
  useInscribedRectCrop:NO
865
900
  stitchMode:nil
901
+ useManualPipeline:NO // legacy video path keeps high-level cv::Stitcher
866
902
  error:&stitchErr];
867
903
 
868
904
  // Always tear down the tmp dir, success or fail — leaving
@@ -1063,6 +1099,154 @@ cv::detail::CameraParams cameraParamsFromPose(NSDictionary *pose) {
1063
1099
  };
1064
1100
  }
1065
1101
 
1102
+ // item-7 — free-quad perspective crop. Mirrors cropToRectAtPath, but
1103
+ // instead of an axis-aligned sub-rectangle it takes 4 user-dragged
1104
+ // corners in IMAGE-PIXEL space (ordered TL, TR, BR, BL by the JS editor's
1105
+ // orderQuadCorners) and rectifies them to an upright rectangle via
1106
+ // cv::getPerspectiveTransform + cv::warpPerspective. The destination
1107
+ // size + the convex/min-area/in-bounds gate come from the shared OpenCV-
1108
+ // free cpp/crop_quad.hpp so iOS / Android / JS agree bit-for-bit; the
1109
+ // output canvas is GUARDED with the same canvasExceedsGuard the stitch
1110
+ // pipeline uses so a near-collinear quad can't OOM a multi-MP panorama.
1111
+ + (NSDictionary<NSString *, NSNumber *> *)cropToQuadAtPath:(NSString *)imagePath
1112
+ tlX:(double)tlX
1113
+ tlY:(double)tlY
1114
+ trX:(double)trX
1115
+ trY:(double)trY
1116
+ brX:(double)brX
1117
+ brY:(double)brY
1118
+ blX:(double)blX
1119
+ blY:(double)blY
1120
+ quality:(NSInteger)quality
1121
+ error:(NSError **)error {
1122
+ NSString *cleaned = normalizeImagePath(imagePath);
1123
+ if (![[NSFileManager defaultManager] fileExistsAtPath:cleaned]) {
1124
+ if (error) {
1125
+ *error = [NSError errorWithDomain:RNImageStitcherErrorDomain
1126
+ code:1020
1127
+ userInfo:@{
1128
+ NSLocalizedDescriptionKey:
1129
+ [NSString stringWithFormat:@"Image not found: %@", imagePath],
1130
+ }];
1131
+ }
1132
+ return nil;
1133
+ }
1134
+
1135
+ std::string nativePath(cleaned.UTF8String);
1136
+ cv::Mat img = cv::imread(nativePath, cv::IMREAD_COLOR);
1137
+ if (img.empty()) {
1138
+ if (error) {
1139
+ *error = [NSError errorWithDomain:RNImageStitcherErrorDomain
1140
+ code:1021
1141
+ userInfo:@{
1142
+ NSLocalizedDescriptionKey:
1143
+ [NSString stringWithFormat:@"Could not decode image at %@", imagePath],
1144
+ }];
1145
+ }
1146
+ return nil;
1147
+ }
1148
+
1149
+ retailens::CropQuad quad;
1150
+ quad.tl = {tlX, tlY};
1151
+ quad.tr = {trX, trY};
1152
+ quad.br = {brX, brY};
1153
+ quad.bl = {blX, blY};
1154
+
1155
+ // Geometry gate — convex, non-degenerate, inside the decoded image.
1156
+ if (!retailens::isQuadAcceptable(quad, (double)img.cols, (double)img.rows)) {
1157
+ if (error) {
1158
+ *error = [NSError errorWithDomain:RNImageStitcherErrorDomain
1159
+ code:1023
1160
+ userInfo:@{
1161
+ NSLocalizedDescriptionKey:
1162
+ @"Crop quad is degenerate (non-convex, zero-area, or out of bounds)",
1163
+ }];
1164
+ }
1165
+ return nil;
1166
+ }
1167
+
1168
+ const retailens::QuadDstSize dst = retailens::quadDstRect(quad);
1169
+ // Output-canvas OOM net — the same guard the stitch pipeline uses.
1170
+ if (dst.width <= 0 || dst.height <= 0 ||
1171
+ retailens::canvasExceedsGuard(dst.width, dst.height)) {
1172
+ if (error) {
1173
+ *error = [NSError errorWithDomain:RNImageStitcherErrorDomain
1174
+ code:1024
1175
+ userInfo:@{
1176
+ NSLocalizedDescriptionKey:
1177
+ [NSString stringWithFormat:
1178
+ @"Crop quad output canvas is degenerate or exceeds the size guard (%dx%d)",
1179
+ dst.width, dst.height],
1180
+ }];
1181
+ }
1182
+ return nil;
1183
+ }
1184
+
1185
+ const cv::Point2f src[4] = {
1186
+ cv::Point2f((float)tlX, (float)tlY),
1187
+ cv::Point2f((float)trX, (float)trY),
1188
+ cv::Point2f((float)brX, (float)brY),
1189
+ cv::Point2f((float)blX, (float)blY),
1190
+ };
1191
+ const cv::Point2f dstPts[4] = {
1192
+ cv::Point2f(0.0f, 0.0f),
1193
+ cv::Point2f((float)dst.width, 0.0f),
1194
+ cv::Point2f((float)dst.width, (float)dst.height),
1195
+ cv::Point2f(0.0f, (float)dst.height),
1196
+ };
1197
+
1198
+ cv::Mat warped;
1199
+ // OpenCV throws cv::Exception (a C++ exception) — catch with a C++
1200
+ // try/catch, NOT @try/@catch (which only traps NSException).
1201
+ try {
1202
+ cv::Mat transform = cv::getPerspectiveTransform(src, dstPts);
1203
+ cv::warpPerspective(img, warped, transform,
1204
+ cv::Size(dst.width, dst.height), cv::INTER_LINEAR);
1205
+ } catch (const cv::Exception &e) {
1206
+ if (error) {
1207
+ *error = [NSError errorWithDomain:RNImageStitcherErrorDomain
1208
+ code:1025
1209
+ userInfo:@{
1210
+ NSLocalizedDescriptionKey:
1211
+ [NSString stringWithFormat:@"Perspective warp failed: %s", e.what()],
1212
+ }];
1213
+ }
1214
+ return nil;
1215
+ }
1216
+ if (warped.empty()) {
1217
+ if (error) {
1218
+ *error = [NSError errorWithDomain:RNImageStitcherErrorDomain
1219
+ code:1025
1220
+ userInfo:@{
1221
+ NSLocalizedDescriptionKey: @"Perspective warp produced an empty image",
1222
+ }];
1223
+ }
1224
+ return nil;
1225
+ }
1226
+
1227
+ int q = (int)quality;
1228
+ if (q < 1) { q = 1; }
1229
+ if (q > 100) { q = 100; }
1230
+ std::vector<int> writeParams = { cv::IMWRITE_JPEG_QUALITY, q };
1231
+ bool ok = cv::imwrite(nativePath, warped, writeParams);
1232
+ if (!ok) {
1233
+ if (error) {
1234
+ *error = [NSError errorWithDomain:RNImageStitcherErrorDomain
1235
+ code:1022
1236
+ userInfo:@{
1237
+ NSLocalizedDescriptionKey:
1238
+ [NSString stringWithFormat:@"Could not rewrite image at %@", imagePath],
1239
+ }];
1240
+ }
1241
+ return nil;
1242
+ }
1243
+
1244
+ return @{
1245
+ @"width": @((NSInteger)warped.cols),
1246
+ @"height": @((NSInteger)warped.rows),
1247
+ };
1248
+ }
1249
+
1066
1250
  + (NSDictionary *)debugMaskOverlayAtPath:(NSString *)imagePath
1067
1251
  threshold:(NSInteger)threshold
1068
1252
  error:(NSError **)error {
@@ -154,6 +154,10 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
154
154
  attributes: .concurrent
155
155
  )
156
156
  private static let MAX_POSE_LOG = 600 // ~10 s @ 60Hz
157
+ /// AR keyframe long-edge budget (px) — downscale every device's frame to
158
+ /// this before encoding so stitch memory is consistent cross-device.
159
+ /// Mirrors Android's AR_KEYFRAME_MAX_LONG_EDGE.
160
+ private static let arKeyframeMaxLongEdge: CGFloat = 640
157
161
 
158
162
  /// Latest tracking state. Read by JS for UI feedback.
159
163
  @objc public private(set) var currentTrackingState: RNSARTrackingState = .notAvailable
@@ -472,6 +476,18 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
472
476
  // small text and packaging detail.
473
477
  config.isAutoFocusEnabled = true
474
478
 
479
+ // Option B — prefer the 4:3 videoFormat for full sensor FOV; the
480
+ // keyframe is downscaled to arKeyframeMaxLongEdge below so memory
481
+ // stays consistent across devices regardless of the format res.
482
+ if let fmt = ARWorldTrackingConfiguration.supportedVideoFormats.min(by: { a, b in
483
+ let da = abs(a.imageResolution.width / a.imageResolution.height - 4.0 / 3.0)
484
+ let db = abs(b.imageResolution.width / b.imageResolution.height - 4.0 / 3.0)
485
+ if abs(da - db) > 0.001 { return da < db }
486
+ return a.imageResolution.width * a.imageResolution.height
487
+ < b.imageResolution.width * b.imageResolution.height
488
+ }) {
489
+ config.videoFormat = fmt
490
+ }
475
491
  arSession.run(config, options: [.resetTracking, .removeExistingAnchors])
476
492
  // V16-diag — log the chosen video format so we can correlate
477
493
  // batch-keyframe memory with ARFrame resolution. iPhone Pro
@@ -855,8 +871,16 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
855
871
  case "portrait-upside-down": exifOrientation = .left
856
872
  default: exifOrientation = .right // portrait + unknown
857
873
  }
858
- let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
874
+ var ciImage = CIImage(cvPixelBuffer: pixelBuffer)
859
875
  .oriented(exifOrientation)
876
+ // AR keyframe downscale guard — normalise long edge to the budget so
877
+ // every device produces a ~0.3 MP keyframe (cross-device-consistent
878
+ // stitch memory). Mirrors Android's downscale in YuvImageConverter.
879
+ let kfLongEdge = max(ciImage.extent.width, ciImage.extent.height)
880
+ if kfLongEdge > Self.arKeyframeMaxLongEdge {
881
+ let kfScale = Self.arKeyframeMaxLongEdge / kfLongEdge
882
+ ciImage = ciImage.transformed(by: CGAffineTransform(scaleX: kfScale, y: kfScale))
883
+ }
860
884
  let context = CIContext(options: nil)
861
885
  guard let cgImage = context.createCGImage(
862
886
  ciImage,
@@ -161,7 +161,11 @@ public enum Stitcher {
161
161
  // expose stitchMode in its options dict yet. nil falls
162
162
  // through to Panorama in OpenCVStitcher.mm (preserves
163
163
  // historical behaviour).
164
- stitchMode: nil
164
+ stitchMode: nil,
165
+ // Generic one-shot API keeps the high-level cv::Stitcher pipeline
166
+ // (its historical behaviour); the batch capture is what defaults to
167
+ // manual. warperType "plane" above only matters on the manual path.
168
+ useManualPipeline: false
165
169
  )
166
170
  return StitchResult(
167
171
  outputPath: result.outputPath,
@@ -239,6 +243,35 @@ public enum Stitcher {
239
243
  }
240
244
  }
241
245
 
246
+ /// item-7 — perspective-rectify the quadrilateral with corners
247
+ /// `(tlX,tlY) (trX,trY) (brX,brY) (blX,blY)` (IMAGE-PIXEL space, ordered
248
+ /// TL→TR→BR→BL) out of `imagePath` into an upright rectangle, overwrite
249
+ /// in place, re-encode at `quality`. Pairs with the axis-aligned
250
+ /// `cropToRect`; the JS editor picks this when the dragged quad isn't
251
+ /// ~rectangular. Returns the rectified `{ width, height }`.
252
+ public static func cropToQuad(
253
+ imagePath: String,
254
+ tlX: Double, tlY: Double,
255
+ trX: Double, trY: Double,
256
+ brX: Double, brY: Double,
257
+ blX: Double, blY: Double,
258
+ quality: Int
259
+ ) throws -> (width: Int, height: Int) {
260
+ do {
261
+ let d = try OpenCVStitcher.cropToQuad(
262
+ atPath: imagePath,
263
+ tlX: tlX, tlY: tlY,
264
+ trX: trX, trY: trY,
265
+ brX: brX, brY: brY,
266
+ blX: blX, blY: blY,
267
+ quality: quality
268
+ )
269
+ return (width: d["width"]?.intValue ?? 0, height: d["height"]?.intValue ?? 0)
270
+ } catch let nsError as NSError {
271
+ throw StitcherError.fromNSError(nsError)
272
+ }
273
+ }
274
+
242
275
  /// v0.15 debug — write a red-tinted mask overlay (excluded pixels =
243
276
  /// red) next to the image and report what fraction the brightness mask
244
277
  /// drops. Pairs with the inscribed-rect debug harness.
@@ -34,6 +34,11 @@ RCT_EXTERN_METHOD(cropToRect:(NSDictionary *)options
34
34
  resolver:(RCTPromiseResolveBlock)resolver
35
35
  rejecter:(RCTPromiseRejectBlock)rejecter)
36
36
 
37
+ // item-7 perspective crop (free-quad rectify; pairs with cropToRect).
38
+ RCT_EXTERN_METHOD(cropToQuad:(NSDictionary *)options
39
+ resolver:(RCTPromiseResolveBlock)resolver
40
+ rejecter:(RCTPromiseRejectBlock)rejecter)
41
+
37
42
  RCT_EXTERN_METHOD(debugMaskOverlay:(NSDictionary *)options
38
43
  resolver:(RCTPromiseResolveBlock)resolver
39
44
  rejecter:(RCTPromiseRejectBlock)rejecter)
@@ -265,6 +265,62 @@ public class StitcherBridge: NSObject {
265
265
  }
266
266
  }
267
267
 
268
+ /// item-7 — perspective-rectify a user-dragged quad. `options` carries
269
+ /// `imagePath` + the 4 IMAGE-PIXEL corners as a flat `quad` array of 8
270
+ /// numbers `[tlX, tlY, trX, trY, brX, brY, blX, blY]` (ordered
271
+ /// TL→TR→BR→BL by the JS editor) + optional `quality` (default 90).
272
+ /// Resolves the rectified `{ width, height }`.
273
+ @objc(cropToQuad:resolver:rejecter:)
274
+ public func cropToQuad(
275
+ options: NSDictionary,
276
+ resolver: @escaping RCTPromiseResolveBlock,
277
+ rejecter: @escaping RCTPromiseRejectBlock
278
+ ) {
279
+ guard let imagePath = options["imagePath"] as? String else {
280
+ rejecter("invalid-options", "imagePath must be a string", nil)
281
+ return
282
+ }
283
+ guard let quad = options["quad"] as? [NSNumber], quad.count == 8 else {
284
+ rejecter(
285
+ "invalid-options",
286
+ "quad must be an array of 8 numbers [tlX,tlY,trX,trY,brX,brY,blX,blY]",
287
+ nil
288
+ )
289
+ return
290
+ }
291
+ let p = quad.map { $0.doubleValue }
292
+ let quality = (options["quality"] as? NSNumber)?.intValue ?? 90
293
+ DispatchQueue.global(qos: .userInitiated).async {
294
+ do {
295
+ let dims = try Stitcher.cropToQuad(
296
+ imagePath: imagePath,
297
+ tlX: p[0], tlY: p[1],
298
+ trX: p[2], trY: p[3],
299
+ brX: p[4], brY: p[5],
300
+ blX: p[6], blY: p[7],
301
+ quality: quality
302
+ )
303
+ resolver([
304
+ "width": dims.width,
305
+ "height": dims.height,
306
+ ])
307
+ } catch let err as StitcherError {
308
+ switch err {
309
+ case .insufficientFrames(let count):
310
+ rejecter("insufficient-frames", "(unexpected for cropToQuad) frames=\(count)", err)
311
+ case .readFailed(let path):
312
+ rejecter("read-failed", "Could not read image: \(path)", err)
313
+ case .writeFailed(let path):
314
+ rejecter("write-failed", "Could not write image: \(path)", err)
315
+ case .opencvFailed(let code, let message):
316
+ rejecter("opencv-failed-\(code)", message, err)
317
+ }
318
+ } catch {
319
+ rejecter("unknown", "Unexpected cropToQuad failure: \(error)", error)
320
+ }
321
+ }
322
+ }
323
+
268
324
  /// v0.15 debug — write a red-tinted mask overlay for `imagePath`
269
325
  /// (excluded pixels red). `threshold` optional (default 1, matching the
270
326
  /// inscribed-rect mask). Resolves
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.15.2",
3
+ "version": "0.16.0",
4
4
  "description": "Pose-aware panorama capture + stitching for React Native. One <Camera> component, both tap-to-photo and hold-to-pan modes, both AR-backed and IMU-fallback capture paths.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -8,6 +8,8 @@
8
8
  "dist/**/*.js",
9
9
  "dist/**/*.d.ts",
10
10
  "src",
11
+ "!src/**/__tests__",
12
+ "!src/**/__tests__/**",
11
13
  "ios/Sources",
12
14
  "ios/Package.swift",
13
15
  "android/build.gradle",
@@ -15,6 +17,8 @@
15
17
  "android/src/main/cpp",
16
18
  "android/src/main/AndroidManifest.xml",
17
19
  "cpp",
20
+ "!cpp/tests",
21
+ "!cpp/tests/**",
18
22
  "scripts/postinstall-fetch-binaries.js",
19
23
  "scripts/opencv-version.txt",
20
24
  "RNImageStitcher.podspec",