react-native-image-stitcher 0.16.0 → 0.16.2

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 (38) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +41 -44
  3. package/android/build.gradle +34 -0
  4. package/android/src/main/cpp/image_stitcher_jni.cpp +52 -7
  5. package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
  6. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +135 -59
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
  8. package/cpp/keyframe_gate.cpp +54 -15
  9. package/cpp/keyframe_gate.hpp +33 -0
  10. package/cpp/stitcher.cpp +481 -87
  11. package/cpp/stitcher.hpp +52 -0
  12. package/dist/camera/Camera.d.ts +13 -0
  13. package/dist/camera/Camera.js +9 -64
  14. package/dist/camera/CaptureFrameCounterOverlay.js +12 -1
  15. package/dist/camera/CaptureMemoryPill.d.ts +15 -7
  16. package/dist/camera/CaptureMemoryPill.js +34 -9
  17. package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
  18. package/dist/camera/PanoramaBandOverlay.js +9 -3
  19. package/dist/camera/PanoramaSettings.js +22 -25
  20. package/dist/camera/RectCropPreview.d.ts +3 -29
  21. package/dist/camera/RectCropPreview.js +20 -130
  22. package/dist/stitching/incremental.d.ts +29 -0
  23. package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
  24. package/dist/stitching/useIncrementalStitcher.js +7 -1
  25. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +103 -26
  26. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
  27. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
  28. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +21 -1
  29. package/package.json +1 -1
  30. package/src/camera/Camera.tsx +21 -70
  31. package/src/camera/CaptureFrameCounterOverlay.tsx +15 -1
  32. package/src/camera/CaptureMemoryPill.tsx +33 -9
  33. package/src/camera/PanoramaBandOverlay.tsx +9 -1
  34. package/src/camera/PanoramaSettings.ts +22 -25
  35. package/src/camera/RectCropPreview.tsx +38 -220
  36. package/src/stitching/incremental.ts +29 -0
  37. package/src/stitching/useIncrementalStitcher.ts +13 -0
  38. package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +0 -100
package/cpp/stitcher.hpp CHANGED
@@ -47,6 +47,23 @@
47
47
  #include <vector>
48
48
 
49
49
 
50
+ // ── 2026-06-16 — memory-profiling compile gate (shared) ─────────────────
51
+ // Hard gate for the peak sampler + per-stitch record + [memstat] phase logs
52
+ // (stitcher.cpp) and the mallopt purge diagnostic READS (image_stitcher_jni.cpp).
53
+ // Default: ON in debug, OFF in release, so production pays nothing. Override
54
+ // with -DRNIS_MEMORY_PROFILING=1 to profile a release build. NDEBUG is the
55
+ // portable signal both Gradle (Debug CMake config) and Xcode use, so this is
56
+ // uniform across Android + iOS with no per-build-system flag. Defined in the
57
+ // shared header so all three native translation units agree.
58
+ #ifndef RNIS_MEMORY_PROFILING
59
+ # ifdef NDEBUG
60
+ # define RNIS_MEMORY_PROFILING 0
61
+ # else
62
+ # define RNIS_MEMORY_PROFILING 1
63
+ # endif
64
+ #endif
65
+
66
+
50
67
  namespace retailens {
51
68
 
52
69
  // Stable error codes. Mirror the JS-side `StitchErrorCode` enum so
@@ -197,6 +214,23 @@ struct StitchConfig {
197
214
  // flip it to true once the manual port is verified — separate
198
215
  // commit from this V2 introduction.
199
216
  bool useManualPipeline = false;
217
+
218
+ // ── 2026-06-16 — memory profiling hooks (DEV deploy gate) ───────────
219
+ // memProbeFn: resident-memory source in MB, or < 0 if unavailable. When
220
+ // set it is the canonical reader used by rss_mb() (so the OOM guards, the
221
+ // phase logs, the peak sampler and the per-stitch record all use it). Its
222
+ // reason for existing is iOS, which has no /proc/self/statm — the Obj-C++
223
+ // bridge plugs task_info(TASK_VM_INFO).phys_footprint here. Android leaves
224
+ // it null and rss_mb() falls back to /proc. Must be callable from a
225
+ // background thread (the peak sampler), so the closure must not touch
226
+ // thread-affine state.
227
+ std::function<double()> memProbeFn = nullptr;
228
+ // enableMemoryProfiling: runtime gate (plumbed from settings.debug) for the
229
+ // peak sampler + the per-stitch record + the [memstat] phase logs. The
230
+ // COMPILE-time RNIS_MEMORY_PROFILING flag (off in release) is the hard gate;
231
+ // this is the per-call switch on top. The mallopt(M_PURGE) CALL is NOT
232
+ // gated by this — only its diagnostic READS are.
233
+ bool enableMemoryProfiling = false;
200
234
  };
201
235
 
202
236
 
@@ -235,6 +269,24 @@ struct StitchResult {
235
269
  // Empty on builds that don't populate it (back-compat). iOS marshals it up
236
270
  // to the JS finalize dict; Android leaves it in the log for now.
237
271
  std::string debugSummary;
272
+
273
+ // ── 2026-06-16 — per-stitch memory record (DEV profiling) ───────────
274
+ // All in MB; -1.0 when profiling is off or no memory source is available.
275
+ // memBeforeMB: resident at entry (after the leak-fix once-guard).
276
+ // memPeakMB: max resident sampled DURING the stitch by the 50 ms peak
277
+ // sampler — the transient warp-all + GraphCut + MultiBand
278
+ // spike that the phase-boundary reads miss (it decides OOM).
279
+ // memAfterMB: resident after the pipeline returns (blender pyramids freed).
280
+ // memFloorMB: resident after the platform's post-stitch reclaim — Android
281
+ // fills it after mallopt(M_PURGE); iOS after a settle read.
282
+ // This is the leak-PLATEAU metric (the bridge sets it; the
283
+ // core leaves it at -1).
284
+ // memSource: "phys_footprint" (iOS task_info) | "rss" (/proc) | "".
285
+ double memBeforeMB = -1.0;
286
+ double memPeakMB = -1.0;
287
+ double memAfterMB = -1.0;
288
+ double memFloorMB = -1.0;
289
+ std::string memSource;
238
290
  };
239
291
 
240
292
 
@@ -123,6 +123,19 @@ export type CameraCaptureResult = {
123
123
  * cv::Stitcher at finalize).
124
124
  */
125
125
  stitchModeResolved?: 'panorama' | 'scans';
126
+ /**
127
+ * 2026-06-15 (DEV) — gyro rotation magnitude of the capture, in radians.
128
+ * Shown on the dev preview so the panorama-vs-SCANS rotation threshold can
129
+ * be tuned. `0` = no pose-derived rotation signal (non-AR with no poses).
130
+ */
131
+ rRadians?: number;
132
+ /**
133
+ * 2026-06-16 (DEV) — translation magnitude (m) + auto decision ratio
134
+ * (`>=0.55` → SCANS) that drove panorama-vs-SCANS. Shown on the dev
135
+ * readout alongside `rRadians` to tune the threshold from real captures.
136
+ */
137
+ tMeters?: number;
138
+ decisionRatio?: number;
126
139
  /**
127
140
  * 2026-06-14 (DEV overlay) — semicolon-separated `key=value` trace of the
128
141
  * stitcher's runtime choices (pipe/warp/route/seam/blend) for this
@@ -379,58 +379,6 @@ function Camera(props) {
379
379
  // CameraCaptureResult we'd otherwise have emitted, stashed so cancel /
380
380
  // crop-confirm can emit it (possibly with cropped dims) afterwards.
381
381
  const [cropPending, setCropPending] = (0, react_1.useState)(null);
382
- // 2026-06-15 — ON-DEMAND high-level preview. Manual is the default/eager
383
- // output; when the user switches to the "high-level" tab in the preview we
384
- // re-stitch the SAME captured keyframes through stock cv::Stitcher via
385
- // `refinePanorama` (useManualPipeline:false). Resolves with the high-level
386
- // JPEG's file:// uri AND its OWN DEV-overlay recipe (so the preview pill shows
387
- // the high-level recipe — pipe=highlevel;… — while that tab is viewed, not the
388
- // manual primary's recipe), or null when unavailable (no keyframe paths —
389
- // e.g. Android — or the stitch failed). Computed lazily so it costs nothing
390
- // unless the user actually asks for it.
391
- const requestHighLevelAlt = (0, react_1.useCallback)(async () => {
392
- const pending = cropPending;
393
- const kf = pending?.captureResultObj.keyframePaths;
394
- if (!pending || !kf || kf.length < 2)
395
- return null;
396
- const native = (0, incremental_1.getIncrementalNativeModule)();
397
- if (!native)
398
- return null;
399
- const outputPath = `${(0, paths_1.toBareFilePath)(pending.uri).replace(/\.jpg$/i, '')}-highlevel.jpg`;
400
- try {
401
- const r = await native.refinePanorama({
402
- framePaths: kf,
403
- outputPath,
404
- config: {
405
- useManualPipeline: false,
406
- warperType: 'spherical',
407
- stitchMode: 'panorama',
408
- // Match the manual output's rotation — without this the high-level
409
- // re-stitch bakes "portrait" (no rotation) and comes out sideways.
410
- captureOrientation: pending.captureResultObj.captureOrientation,
411
- },
412
- });
413
- // Plain file:// uri — the path is unique per capture and computed once, so
414
- // no cache-bust here (the accept handler adds one when emitting). The
415
- // DEV pill text is the HIGH-LEVEL stitch's own recipe (only the fields
416
- // IncrementalRefineResult carries; buildStitchDebugInfo tolerates the rest
417
- // being absent).
418
- return {
419
- uri: (0, paths_1.toFileUri)(r.panoramaPath),
420
- debugInfo: (0, stitchDebugInfo_1.buildStitchDebugInfo)({
421
- debugSummary: r.debugSummary,
422
- finalConfidenceThresh: r.finalConfidenceThresh,
423
- framesIncluded: r.framesIncluded,
424
- framesRequested: r.framesRequested,
425
- width: r.width,
426
- height: r.height,
427
- }),
428
- };
429
- }
430
- catch {
431
- return null;
432
- }
433
- }, [cropPending]);
434
382
  // 2026-05-22 (audit F9 + F3) — debug stitch-stats toast. Hook
435
383
  // exposes an imperative API; we fire `showResult(finalizeResult)`
436
384
  // on every successful finalize when settings.debug is on (gated
@@ -1140,7 +1088,7 @@ function Camera(props) {
1140
1088
  // native side uses pose-derived translation and ignores this).
1141
1089
  const imuTotalTranslationM = isNonAR ? imuGate.getTotalAbsMetres() : 0;
1142
1090
  const result = await incremental.finalize(panoOutputPath, 90, // default JPEG quality
1143
- deviceOrientation, imuTotalTranslationM);
1091
+ deviceOrientation, imuTotalTranslationM, lens);
1144
1092
  if (typeof result.framesRequested === 'number'
1145
1093
  && typeof result.framesIncluded === 'number'
1146
1094
  && result.framesIncluded < result.framesRequested) {
@@ -1174,6 +1122,9 @@ function Camera(props) {
1174
1122
  finalConfidenceThresh: result.finalConfidenceThresh ?? -1,
1175
1123
  durationMs: Date.now() - (recordingStartedAt ?? Date.now()),
1176
1124
  stitchModeResolved: result.stitchModeResolved,
1125
+ rRadians: result.rRadians,
1126
+ tMeters: result.tMeters,
1127
+ decisionRatio: result.decisionRatio,
1177
1128
  debugSummary: result.debugSummary,
1178
1129
  keyframePaths: result.batchKeyframePaths,
1179
1130
  captureOrientation: result.captureOrientation,
@@ -1279,6 +1230,10 @@ function Camera(props) {
1279
1230
  isNonAR,
1280
1231
  imuGate,
1281
1232
  stitchToast,
1233
+ // 2026-06-16 — the finalize passes `lens` (the high-level warper tree's zoom
1234
+ // signal); without it here the closure would send a STALE lens if the user
1235
+ // switched 1x↔0.5x after this callback was last memoized.
1236
+ lens,
1282
1237
  // feature/pano-ux-guidance — the release also tears down the
1283
1238
  // pan-duration timer + a pending rotate-gate, and decides whether to
1284
1239
  // route the result through the crop editor.
@@ -1569,17 +1524,7 @@ function Camera(props) {
1569
1524
  , {
1570
1525
  // Remount per capture so the dragged-quad + layout state re-seed to
1571
1526
  // the new image (RectCropPreview seeds its quad once via useState).
1572
- key: cropPending?.uri ?? 'crop', visible: cropPending != null, imageUri: cropPending?.uri ?? '', imageWidth: cropPending?.width ?? 0, imageHeight: cropPending?.height ?? 0,
1573
- // 2026-06-15 — manual is the default/eager output. The high-level tab
1574
- // is ON DEMAND: RectCropPreview calls onRequestAlt() (which re-stitches
1575
- // the captured keyframes via cv::Stitcher) only when the user switches
1576
- // to it. DEBUG-ONLY: it's a pipeline-comparison tool (dev-jargon
1577
- // "Manual"/"High-level" labels), gated behind `settings.debug` like the
1578
- // rest of the diagnostic UI. Also requires keyframePaths, so it only
1579
- // appears where it can run (iOS); Android returns no paths → no tab.
1580
- onRequestAlt: settings.debug && cropPending?.captureResultObj.keyframePaths?.length
1581
- ? requestHighLevelAlt
1582
- : undefined, initialRect: cropPending?.initialRect, warnings: cropPending?.warnings.map((w) => w.message) ?? [], showCropControls: rectCrop, topInset: insets.top, bottomInset: insets.bottom, copy: guidanceCopyResolved,
1527
+ key: cropPending?.uri ?? 'crop', visible: cropPending != null, imageUri: cropPending?.uri ?? '', imageWidth: cropPending?.width ?? 0, imageHeight: cropPending?.height ?? 0, initialRect: cropPending?.initialRect, warnings: cropPending?.warnings.map((w) => w.message) ?? [], showCropControls: rectCrop, topInset: insets.top, bottomInset: insets.bottom, copy: guidanceCopyResolved,
1583
1528
  // Carry the live memory pill onto the preview too (same settings.debug
1584
1529
  // gate as the camera), so the operator can watch the RSS spike when the
1585
1530
  // on-demand high-level re-stitch fires.
@@ -32,13 +32,24 @@ exports.topCenterForOrientation = topCenterForOrientation;
32
32
  const react_1 = __importDefault(require("react"));
33
33
  const react_native_1 = require("react-native");
34
34
  const guidanceTokens_1 = require("./guidanceTokens");
35
+ /**
36
+ * Extra distance (px) to drop the counter from the user-top in landscape so it
37
+ * clears the pan how-to coach-mark's bouncing arrow. Landscape only; portrait
38
+ * is unaffected. 72 px (the symmetric lift) over-cleared, so this is smaller.
39
+ */
40
+ const COUNTER_LANDSCAPE_EXTRA_INSET = 40;
35
41
  function CaptureFrameCounterOverlay({ visible, framesCaptured, framesMax, orientation, style, }) {
36
42
  if (!visible || framesMax <= 0)
37
43
  return null;
38
44
  // Clamp the displayed numerator into [0, framesMax] — the engine can
39
45
  // briefly report the cap-th accept before the parent finalizes.
40
46
  const k = Math.max(0, Math.min(framesCaptured, framesMax));
41
- const { container, rotate } = topCenterForOrientation(orientation, guidanceTokens_1.GUIDANCE_COUNTDOWN.inset);
47
+ // 2026-06-16 — in LANDSCAPE, push the counter further from the user-top so it
48
+ // clears the pan how-to coach-mark's bouncing amber arrow, which sits near the
49
+ // top there and otherwise overlaps it. Portrait keeps the standard inset.
50
+ // Tune COUNTER_LANDSCAPE_EXTRA_INSET if the gap is too small / too large.
51
+ const isLandscape = orientation === 'landscape-left' || orientation === 'landscape-right';
52
+ const { container, rotate } = topCenterForOrientation(orientation, guidanceTokens_1.GUIDANCE_COUNTDOWN.inset + (isLandscape ? COUNTER_LANDSCAPE_EXTRA_INSET : 0));
42
53
  return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: [styles.layer, container, style] },
43
54
  react_1.default.createElement(react_native_1.View, { style: [styles.pill, { transform: [{ rotate }] }] },
44
55
  react_1.default.createElement(react_native_1.View, { style: styles.dot }),
@@ -2,15 +2,23 @@
2
2
  * CaptureMemoryPill — top-right diagnostic pill showing native
3
3
  * process memory footprint in MB, polled at 500 ms.
4
4
  *
5
- * Color-coded against the iPhone 16 Pro per-process jetsam limit:
5
+ * Color-coded against the device's per-process memory budget, which is read
6
+ * once at mount via `getDeviceTotalRamMB()` (RAM-aware):
6
7
  *
7
- * - green <1500 MB (comfortable)
8
- * - amber 1500–2200 (approaching pressure)
9
- * - red >2200 (close to limit capture may be killed)
8
+ * budget = max(RAM × 0.42, 900 MB) (mirrors warp_guard.hpp
9
+ * perProcessMemoryBudgetMB)
10
+ * - green < 55 % of budget (comfortable)
11
+ * - amber 55–70 % of budget (approaching pressure)
12
+ * - red > 70 % of budget (close to limit — capture may be killed)
10
13
  *
11
- * Backed by the existing `getMemoryFootprintMB()` native module
12
- * (iOS: `task_info phys_footprint`, Android: `Debug.MemoryInfo
13
- * getTotalPss * 1024`). Returns -1 if the native call fails.
14
+ * Why RAM-aware: the old fixed 1500/2200 MB thresholds were tuned for the
15
+ * iPhone 16 Pro and NEVER tripped on a 4 GB Android phone that jetsams ~1.3 GB
16
+ * (false comfort exactly where OOM happens). Falls back to 1500/2200 if the
17
+ * RAM read is unavailable.
18
+ *
19
+ * Backed by the `getMemoryFootprintMB()` native module (iOS: `task_info`
20
+ * `phys_footprint`; Android: `/proc/self/statm` RSS — the SAME number the C++
21
+ * `[memstat]` logs report). Returns -1 if the native call fails.
14
22
  *
15
23
  * Mount this pill inside a `settings.debug`-gated branch — it
16
24
  * polls native every 500 ms and is unwanted in production builds.
@@ -4,15 +4,23 @@
4
4
  * CaptureMemoryPill — top-right diagnostic pill showing native
5
5
  * process memory footprint in MB, polled at 500 ms.
6
6
  *
7
- * Color-coded against the iPhone 16 Pro per-process jetsam limit:
7
+ * Color-coded against the device's per-process memory budget, which is read
8
+ * once at mount via `getDeviceTotalRamMB()` (RAM-aware):
8
9
  *
9
- * - green <1500 MB (comfortable)
10
- * - amber 1500–2200 (approaching pressure)
11
- * - red >2200 (close to limit capture may be killed)
10
+ * budget = max(RAM × 0.42, 900 MB) (mirrors warp_guard.hpp
11
+ * perProcessMemoryBudgetMB)
12
+ * - green < 55 % of budget (comfortable)
13
+ * - amber 55–70 % of budget (approaching pressure)
14
+ * - red > 70 % of budget (close to limit — capture may be killed)
12
15
  *
13
- * Backed by the existing `getMemoryFootprintMB()` native module
14
- * (iOS: `task_info phys_footprint`, Android: `Debug.MemoryInfo
15
- * getTotalPss * 1024`). Returns -1 if the native call fails.
16
+ * Why RAM-aware: the old fixed 1500/2200 MB thresholds were tuned for the
17
+ * iPhone 16 Pro and NEVER tripped on a 4 GB Android phone that jetsams ~1.3 GB
18
+ * (false comfort exactly where OOM happens). Falls back to 1500/2200 if the
19
+ * RAM read is unavailable.
20
+ *
21
+ * Backed by the `getMemoryFootprintMB()` native module (iOS: `task_info`
22
+ * `phys_footprint`; Android: `/proc/self/statm` RSS — the SAME number the C++
23
+ * `[memstat]` logs report). Returns -1 if the native call fails.
16
24
  *
17
25
  * Mount this pill inside a `settings.debug`-gated branch — it
18
26
  * polls native every 500 ms and is unwanted in production builds.
@@ -57,11 +65,22 @@ const react_native_1 = require("react-native");
57
65
  const incremental_1 = require("../stitching/incremental");
58
66
  function CaptureMemoryPill({ topInset = 0, pollIntervalMs = 500, style, }) {
59
67
  const [memMB, setMemMB] = (0, react_1.useState)(null);
68
+ // Device total RAM (MB), read once — drives the RAM-aware pressure bands.
69
+ const [ramMB, setRamMB] = (0, react_1.useState)(null);
60
70
  (0, react_1.useEffect)(() => {
61
71
  const native = (0, incremental_1.getIncrementalNativeModule)();
62
72
  if (!native?.getMemoryFootprintMB)
63
73
  return undefined;
64
74
  let cancelled = false;
75
+ // One-time RAM read for the bands (optional native method — older bridges
76
+ // without it just keep the fixed-threshold fallback).
77
+ native
78
+ .getDeviceTotalRamMB?.()
79
+ .then((r) => {
80
+ if (!cancelled && r > 0)
81
+ setRamMB(r);
82
+ })
83
+ .catch(() => { });
65
84
  const tick = async () => {
66
85
  try {
67
86
  const mb = await native.getMemoryFootprintMB();
@@ -81,8 +100,14 @@ function CaptureMemoryPill({ topInset = 0, pollIntervalMs = 500, style, }) {
81
100
  }, [pollIntervalMs]);
82
101
  if (memMB === null || memMB < 0)
83
102
  return null;
84
- const bg = memMB > 2200 ? 'rgba(239, 68, 68, 0.92)' // red
85
- : memMB > 1500 ? 'rgba(245, 158, 11, 0.92)' // amber
103
+ // RAM-aware bands: budget = max(RAM × 0.42, 900) (mirrors warp_guard.hpp
104
+ // perProcessMemoryBudgetMB); amber at 55 %, red at 70 %. Fall back to the
105
+ // iPhone-tuned fixed thresholds when RAM is unknown.
106
+ const budget = ramMB != null ? Math.max(ramMB * 0.42, 900) : null;
107
+ const redAt = budget != null ? budget * 0.7 : 2200;
108
+ const amberAt = budget != null ? budget * 0.55 : 1500;
109
+ const bg = memMB > redAt ? 'rgba(239, 68, 68, 0.92)' // red
110
+ : memMB > amberAt ? 'rgba(245, 158, 11, 0.92)' // amber
86
111
  : 'rgba(34, 197, 94, 0.92)'; // green
87
112
  return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: [
88
113
  styles.container,
@@ -190,6 +190,7 @@ declare function tileRotation(orientation: BandCaptureOrientation, vertical: boo
190
190
  export declare const _bandThumbRotationForTests: typeof bandThumbRotation;
191
191
  /** @internal test-only export — see `tileRotation`. */
192
192
  export declare const _tileRotationForTests: typeof tileRotation;
193
- export declare function PanoramaBandOverlay({ state, frameUris, captureOrientation, vertical, }: PanoramaBandOverlayProps): React.JSX.Element | null;
193
+ declare function PanoramaBandOverlayImpl({ state, frameUris, captureOrientation, vertical, }: PanoramaBandOverlayProps): React.JSX.Element | null;
194
+ export declare const PanoramaBandOverlay: React.MemoExoticComponent<typeof PanoramaBandOverlayImpl>;
194
195
  export {};
195
196
  //# sourceMappingURL=PanoramaBandOverlay.d.ts.map
@@ -92,8 +92,7 @@ var __importStar = (this && this.__importStar) || (function () {
92
92
  };
93
93
  })();
94
94
  Object.defineProperty(exports, "__esModule", { value: true });
95
- exports._tileRotationForTests = exports._bandThumbRotationForTests = void 0;
96
- exports.PanoramaBandOverlay = PanoramaBandOverlay;
95
+ exports.PanoramaBandOverlay = exports._tileRotationForTests = exports._bandThumbRotationForTests = void 0;
97
96
  const react_1 = __importStar(require("react"));
98
97
  const react_native_1 = require("react-native");
99
98
  // ── Layout constants — tuned to read clearly at arm's length ────────
@@ -295,7 +294,7 @@ function layoutFor(orientation, vertical) {
295
294
  arrowGlyph: '→',
296
295
  };
297
296
  }
298
- function PanoramaBandOverlay({ state, frameUris, captureOrientation, vertical = false, }) {
297
+ function PanoramaBandOverlayImpl({ state, frameUris, captureOrientation, vertical = false, }) {
299
298
  // 2026-05-18 (Issue #3 fix) — orientation source priority:
300
299
  // 1. `captureOrientation` prop from the host (4-way; correct
301
300
  // for landscape-left vs landscape-right disambiguation).
@@ -456,6 +455,13 @@ function PanoramaBandOverlay({ state, frameUris, captureOrientation, vertical =
456
455
  react_1.default.createElement(react_native_1.View, { style: styles.arrowTrack },
457
456
  react_1.default.createElement(react_native_1.Text, { style: styles.arrowGlyph }, layout.arrowGlyph))))));
458
457
  }
458
+ // 2026-06-16 (audit #7) — memoized. This is the lone ~6 Hz consumer that mounts
459
+ // in PRODUCTION (the debug pills are settings.debug-gated), and most engine ticks
460
+ // are REJECTED frames that don't change its visible inputs (frameUris /
461
+ // acceptedCount / orientation). React.memo skips the re-render on those, so the
462
+ // ~6×/sec engine emits no longer re-render this overlay's subtree on the hot
463
+ // capture path (battery/heat on long captures).
464
+ exports.PanoramaBandOverlay = react_1.default.memo(PanoramaBandOverlayImpl);
459
465
  const styles = react_native_1.StyleSheet.create({
460
466
  // Properties common to every layout — uniform border-radius so the
461
467
  // band reads as a single capsule regardless of which edge it's
@@ -77,21 +77,19 @@ exports.DEFAULT_PANORAMA_SETTINGS = {
77
77
  captureSource: 'ar',
78
78
  debug: false,
79
79
  stitcher: {
80
- // v0.16 — PANORAMA by default (was 'auto'). The auto-resolver's SCANS
81
- // branch leans on double-integrated IMU translation, which is unreliable
82
- // during rotation (gravity leakage inflates the translation estimate); in
83
- // practice rotational pans are the common case and resolve to panorama
84
- // anyway. Defaulting to panorama is the robust choice host apps that
85
- // genuinely capture flat documents/walls can still opt into 'auto' or
86
- // 'scans' via the ⚙️ panel or the `defaultStitchMode` / `stitcher` props.
87
- stitchMode: 'panorama',
88
- // v0.16SPHERICAL by default (bounds both axes; the proven-robust wide/
89
- // vertical-pan projection). This is now the single source of truth the
90
- // native side no longer hardcodes a warper, so the ⚙️ panel + the host's
91
- // `defaultWarper` prop actually take effect. Note: choosing `plane` here
92
- // re-arms the dynamic plane→spherical fallback/divergence switch in the
93
- // manual pipeline (it only fires when warperType != spherical).
94
- warperType: 'spherical',
80
+ // v0.16 — AUTO by default. Reverted from the brief 'panorama' default after
81
+ // on-device comparison (matches the v0.15.2 behaviour, which produced better
82
+ // results for these captures). The auto-resolver now carries the
83
+ // low-rotation guard (rRadians>0.35 && t<0.25 force PANORAMA), so the old
84
+ // IMU-gravity-leak SCANS misclassification on rotational pans is fixed; auto
85
+ // can again safely pick SCANS (high-level affine) for genuine flat scans.
86
+ stitchMode: 'auto',
87
+ // v0.16 — PLANE by default. Reverted from 'spherical' after on-device
88
+ // comparison (matches v0.15.2flatter, more natural for the common 1x
89
+ // pan). Plane is unbounded, so this re-arms the manual pipeline's dynamic
90
+ // plane→SPHERICAL divergence/quality fallback (it fires only when
91
+ // warperType != 'spherical'), keeping wide/off-axis pans safe.
92
+ warperType: 'plane',
95
93
  blenderType: 'multiband',
96
94
  seamFinderType: 'graphcut',
97
95
  // v0.15 — inscribed-rect crop is OFF by default (bbox crop keeps all
@@ -102,16 +100,15 @@ exports.DEFAULT_PANORAMA_SETTINGS = {
102
100
  },
103
101
  frameSelection: {
104
102
  mode: 'flow-based',
105
- // v0.16 — denser keyframes by default: a 15% novelty gate, up to 8 frames,
106
- // plus a 1.5 s time-budget force-accept (so a slow/static pan still lands a
107
- // keyframe every 1.5 s even when novelty is low). With 8 frames this bounds
108
- // a static/slow capture to ~8×1.5 12 s before the keyframe-count
109
- // auto-finalize. More overlap between consecutive keyframes stronger
110
- // feature matching more robust registration. Memory-checked: 8 frames fit
111
- // the BATCH held-set cap on both platforms. Overlap selectable in the
112
- // settings panel {10,15,20,30}% (native clamp floor 10%); cap clamps [3,10].
113
- maxKeyframes: 8,
114
- overlapThreshold: 0.15,
103
+ // v0.16 — keyframe gate: a 20% novelty gate, up to 6 frames, plus a 1.5 s
104
+ // time-budget force-accept (so a slow/static pan still lands a keyframe every
105
+ // 1.5 s even when novelty is low). These match the leaner v0.15.2 cadence (6
106
+ // frames / 20% overlap) fewer, more-novel keyframes = lighter memory + less
107
+ // redundant overlap. With 6 frames this bounds a static/slow capture to
108
+ // ~6×1.5 9 s before the keyframe-count auto-finalize. Overlap selectable in
109
+ // the settings panel {10,15,20,30}% (native clamp floor 10%); cap clamps [3,10].
110
+ maxKeyframes: 6,
111
+ overlapThreshold: 0.20,
115
112
  maxKeyframeIntervalMs: 1500,
116
113
  flow: exports.DEFAULT_FLOW_GATE_SETTINGS,
117
114
  },
@@ -67,32 +67,6 @@ export interface RectCropPreviewProps {
67
67
  imageWidth: number;
68
68
  /** Intrinsic pixel height of `imageUri`. */
69
69
  imageHeight: number;
70
- /**
71
- * DEBUG A/B harness — file:// URI of the SAME capture stitched by the
72
- * OPPOSITE pipeline (manual cv::detail + plane). When set, a toggle appears
73
- * that flips the displayed panorama between the primary (high-level +
74
- * spherical) and this one, for on-device comparison on a single capture.
75
- * Its dimensions are read at runtime via `Image.getSize`. When the manual
76
- * output is showing, the crop quad is hidden and the accept button emits
77
- * THIS uri (so you can pick the better pipeline per capture).
78
- */
79
- altImageUri?: string;
80
- /**
81
- * 2026-06-15 — ON-DEMAND alt (high-level) stitch. The PRIMARY image is the
82
- * MANUAL pipeline (the default output); this callback re-stitches the SAME
83
- * captured keyframes via cv::Stitcher and resolves with a file:// uri (or
84
- * null on failure). It runs only the FIRST time the user taps the
85
- * "High-level" tab — nothing is computed unless asked for. When provided (or
86
- * `altImageUri` is), the A/B toggle appears.
87
- *
88
- * Resolves with the high-level output's file:// `uri` AND its OWN
89
- * DEV-overlay `debugInfo` recipe (so the params pill can switch to the
90
- * high-level recipe while that tab is viewed), or `null` on failure.
91
- */
92
- onRequestAlt?: () => Promise<{
93
- uri: string;
94
- debugInfo: string;
95
- } | null>;
96
70
  /** Show / hide the editor. */
97
71
  visible: boolean;
98
72
  /**
@@ -136,9 +110,9 @@ export interface RectCropPreviewProps {
136
110
  copy?: Partial<GuidanceCopy>;
137
111
  /**
138
112
  * Safe-area insets (px). The editor is a full-screen Modal, so the host
139
- * passes `insets.top`/`insets.bottom` to keep the top toolbar (A/B toggle,
140
- * warnings) clear of the notch/Dynamic Island and the bottom button bar
141
- * clear of the home indicator. Default 0.
113
+ * passes `insets.top`/`insets.bottom` to keep the top toolbar (warnings)
114
+ * clear of the notch/Dynamic Island and the bottom button bar clear of the
115
+ * home indicator. Default 0.
142
116
  */
143
117
  topInset?: number;
144
118
  bottomInset?: number;