react-native-image-stitcher 0.2.1 → 0.4.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 (65) hide show
  1. package/CHANGELOG.md +511 -1
  2. package/README.md +1 -1
  3. package/android/src/main/cpp/keyframe_gate_jni.cpp +138 -0
  4. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +412 -40
  5. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +128 -0
  6. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +87 -45
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +46 -4
  8. package/cpp/stitcher.cpp +101 -1
  9. package/cpp/stitcher.hpp +8 -0
  10. package/dist/camera/Camera.d.ts +9 -0
  11. package/dist/camera/Camera.js +165 -43
  12. package/dist/camera/CaptureDebugOverlay.d.ts +45 -0
  13. package/dist/camera/CaptureDebugOverlay.js +146 -0
  14. package/dist/camera/CaptureKeyframePill.d.ts +28 -0
  15. package/dist/camera/CaptureKeyframePill.js +60 -0
  16. package/dist/camera/CaptureMemoryPill.d.ts +28 -0
  17. package/dist/camera/CaptureMemoryPill.js +109 -0
  18. package/dist/camera/CaptureOrientationPill.d.ts +22 -0
  19. package/dist/camera/CaptureOrientationPill.js +44 -0
  20. package/dist/camera/CaptureStitchStatsToast.d.ts +45 -0
  21. package/dist/camera/CaptureStitchStatsToast.js +133 -0
  22. package/dist/camera/PanoramaSettings.d.ts +478 -0
  23. package/dist/camera/PanoramaSettings.js +120 -0
  24. package/dist/camera/PanoramaSettingsBridge.d.ts +84 -0
  25. package/dist/camera/PanoramaSettingsBridge.js +208 -0
  26. package/dist/camera/PanoramaSettingsModal.d.ts +50 -298
  27. package/dist/camera/PanoramaSettingsModal.js +189 -354
  28. package/dist/camera/buildPanoramaInitialSettings.d.ts +70 -0
  29. package/dist/camera/buildPanoramaInitialSettings.js +97 -0
  30. package/dist/camera/lowMemDevice.d.ts +24 -0
  31. package/dist/camera/lowMemDevice.js +69 -0
  32. package/dist/index.d.ts +16 -2
  33. package/dist/index.js +37 -2
  34. package/dist/sensors/useIMUTranslationGate.d.ts +26 -0
  35. package/dist/sensors/useIMUTranslationGate.js +83 -1
  36. package/dist/stitching/incremental.d.ts +25 -0
  37. package/dist/stitching/useIncrementalStitcher.d.ts +12 -1
  38. package/dist/stitching/useIncrementalStitcher.js +7 -1
  39. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +321 -7
  40. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +8 -0
  41. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +12 -0
  42. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +13 -0
  43. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +15 -0
  44. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +1 -0
  45. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +17 -4
  46. package/ios/Sources/RNImageStitcher/Stitcher.swift +6 -1
  47. package/package.json +6 -2
  48. package/src/camera/Camera.tsx +220 -54
  49. package/src/camera/CaptureDebugOverlay.tsx +180 -0
  50. package/src/camera/CaptureKeyframePill.tsx +77 -0
  51. package/src/camera/CaptureMemoryPill.tsx +96 -0
  52. package/src/camera/CaptureOrientationPill.tsx +57 -0
  53. package/src/camera/CaptureStitchStatsToast.tsx +155 -0
  54. package/src/camera/PanoramaSettings.ts +605 -0
  55. package/src/camera/PanoramaSettingsBridge.ts +238 -0
  56. package/src/camera/PanoramaSettingsModal.tsx +296 -988
  57. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +375 -0
  58. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +119 -0
  59. package/src/camera/__tests__/lowMemDevice.test.ts +52 -0
  60. package/src/camera/buildPanoramaInitialSettings.ts +139 -0
  61. package/src/camera/lowMemDevice.ts +71 -0
  62. package/src/index.ts +61 -3
  63. package/src/sensors/useIMUTranslationGate.ts +112 -1
  64. package/src/stitching/incremental.ts +25 -0
  65. package/src/stitching/useIncrementalStitcher.ts +18 -0
@@ -84,8 +84,16 @@ const ARCameraView_1 = require("./ARCameraView");
84
84
  const CameraShutter_1 = require("./CameraShutter");
85
85
  const CameraView_1 = require("./CameraView");
86
86
  const CaptureStatusOverlay_1 = require("./CaptureStatusOverlay");
87
+ const CaptureDebugOverlay_1 = require("./CaptureDebugOverlay");
88
+ const CaptureMemoryPill_1 = require("./CaptureMemoryPill");
89
+ const CaptureKeyframePill_1 = require("./CaptureKeyframePill");
90
+ const CaptureOrientationPill_1 = require("./CaptureOrientationPill");
91
+ const CaptureStitchStatsToast_1 = require("./CaptureStitchStatsToast");
87
92
  const PanoramaBandOverlay_1 = require("./PanoramaBandOverlay");
93
+ const PanoramaSettingsBridge_1 = require("./PanoramaSettingsBridge");
88
94
  const PanoramaSettingsModal_1 = require("./PanoramaSettingsModal");
95
+ const buildPanoramaInitialSettings_1 = require("./buildPanoramaInitialSettings");
96
+ const lowMemDevice_1 = require("./lowMemDevice");
89
97
  const useCapture_1 = require("./useCapture");
90
98
  const useDeviceOrientation_1 = require("./useDeviceOrientation");
91
99
  const incremental_1 = require("../stitching/incremental");
@@ -223,32 +231,31 @@ function deriveEffectiveCaptureSource(arPreference, lens, isARSupportedOnDevice)
223
231
  return arPreference ? 'ar' : 'non-ar';
224
232
  }
225
233
  /**
226
- * Apply per-prop defaults to build the initial settings snapshot.
227
- * The settings live in component state from there; the prop values
228
- * never re-flow.
234
+ * Pluck the props that influence the initial PanoramaSettings tree.
235
+ * Kept inline (vs. a wide structural type) so future Camera prop
236
+ * additions don't accidentally widen the settings-translation
237
+ * surface — the pure builder in `./buildPanoramaInitialSettings.ts`
238
+ * has the canonical interface; this just forwards the relevant
239
+ * fields.
229
240
  *
230
- * Note: the `default*ResolMP` props don't have a home on PanoramaSettings
231
- * yet they're accepted on the prop interface for forward compatibility
232
- * but ignored here. Wiring is a follow-up once PanoramaSettings is
233
- * extended.
241
+ * The `default*ResolMP` props on `CameraProps` are documented as
242
+ * forward-looking no-ops; the new PanoramaSettings tree has no home
243
+ * for them yet (the v0.3 audit found cv::Stitcher's resol knobs
244
+ * aren't reached by either platform's bridge). They're accepted on
245
+ * the prop interface for API stability and ignored here.
234
246
  */
235
- function buildInitialSettings(props) {
247
+ function extractPanoramaOverrides(props) {
236
248
  return {
237
- ...PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS,
238
- stitchMode: props.defaultStitchMode ?? PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.stitchMode,
239
- blenderType: props.defaultBlender ?? PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.blenderType,
240
- seamFinderType: props.defaultSeamFinder ?? PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.seamFinderType,
241
- warperType: props.defaultWarper ?? PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.warperType,
242
- flowNoveltyPercentile: props.defaultFlowNoveltyPercentile ??
243
- PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.flowNoveltyPercentile,
244
- flowEvalEveryNFrames: props.defaultFlowEvalEveryNFrames ??
245
- PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.flowEvalEveryNFrames,
246
- flowMaxTranslationCm: props.defaultFlowMaxTranslationCm ??
247
- PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.flowMaxTranslationCm,
248
- keyframeMaxCount: props.defaultKeyframeMaxCount ??
249
- PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.keyframeMaxCount,
250
- keyframeOverlapThreshold: props.defaultKeyframeOverlapThreshold ??
251
- PanoramaSettingsModal_1.DEFAULT_PANORAMA_SETTINGS.keyframeOverlapThreshold,
249
+ defaultCaptureSource: props.defaultCaptureSource,
250
+ defaultStitchMode: props.defaultStitchMode,
251
+ defaultBlender: props.defaultBlender,
252
+ defaultSeamFinder: props.defaultSeamFinder,
253
+ defaultWarper: props.defaultWarper,
254
+ defaultFlowNoveltyPercentile: props.defaultFlowNoveltyPercentile,
255
+ defaultFlowEvalEveryNFrames: props.defaultFlowEvalEveryNFrames,
256
+ defaultFlowMaxTranslationCm: props.defaultFlowMaxTranslationCm,
257
+ defaultKeyframeMaxCount: props.defaultKeyframeMaxCount,
258
+ defaultKeyframeOverlapThreshold: props.defaultKeyframeOverlapThreshold,
252
259
  };
253
260
  }
254
261
  // `toFileUri` (used to be an inline `toFileUri` here) lives in
@@ -268,11 +275,16 @@ function Camera(props) {
268
275
  // ── State ───────────────────────────────────────────────────────
269
276
  const [arPreference, setArPreference] = (0, react_1.useState)(defaultCaptureSource === 'ar');
270
277
  const [lens, setLens] = (0, react_1.useState)(defaultLens);
271
- const [settings, setSettings] = (0, react_1.useState)(() => buildInitialSettings(props));
278
+ const [settings, setSettings] = (0, react_1.useState)(() => (0, buildPanoramaInitialSettings_1.buildPanoramaInitialSettings)(extractPanoramaOverrides(props), (0, lowMemDevice_1.isLowMemDevice)()));
272
279
  const [settingsModalVisible, setSettingsModalVisible] = (0, react_1.useState)(false);
273
280
  const [statusPhase, setStatusPhase] = (0, react_1.useState)('idle');
274
281
  const [recordingStartedAt, setRecordingStartedAt] = (0, react_1.useState)(null);
275
282
  const [incrementalState, setIncrementalState] = (0, react_1.useState)(null);
283
+ // 2026-05-22 (audit F9 + F3) — debug stitch-stats toast. Hook
284
+ // exposes an imperative API; we fire `showResult(finalizeResult)`
285
+ // on every successful finalize when settings.debug is on (gated
286
+ // a few hundred lines below in handleHoldEnd's onCapture branch).
287
+ const stitchToast = (0, CaptureStitchStatsToast_1.useStitchStatsToast)();
276
288
  const [batchKeyframeThumbnails, setBatchKeyframeThumbnails] = (0, react_1.useState)([]);
277
289
  const [cameraTransitioning, setCameraTransitioning] = (0, react_1.useState)(false);
278
290
  // ARKit / ARCore device-support probe. `isAvailable` is `false`
@@ -380,11 +392,25 @@ function Camera(props) {
380
392
  // the C++ engine to force-accept the next frame. This is what
381
393
  // keeps non-AR captures producing keyframes at all (the flow-
382
394
  // novelty algorithm alone is too strict in practice).
395
+ //
396
+ // 2026-05-22 (audit F2f) — IMU translation gate. The gate's own
397
+ // `totalAbsMetres` accumulator (banks each segment's |displacement|
398
+ // at every anchor reset) is the right input for the finalize-time
399
+ // auto-resolver in non-AR mode (where pose-derived translation is
400
+ // 0). Pre-F2f this was reconstructed from `fires × budget +
401
+ // |residual|` — which undercounted any time a non-IMU accept
402
+ // (flow novelty, force-last) reset the integrator before the
403
+ // budget threshold was reached.
404
+ // The translation budget lives at `frameSelection.flow.maxTranslationCm`
405
+ // in the new hierarchical settings shape. When `flow` is undefined
406
+ // (the consumer opted out of the flow strategy entirely), the gate
407
+ // stays disabled — same observable behaviour as v0.3's `0` default.
408
+ const flowMaxTranslationCm = settings.frameSelection.flow?.maxTranslationCm ?? 0;
383
409
  const imuGate = (0, useIMUTranslationGate_1.useIMUTranslationGate)({
384
410
  enabled: isNonAR
385
411
  && statusPhase === 'recording'
386
- && settings.flowMaxTranslationCm > 0,
387
- budgetMeters: Math.max(0.001, settings.flowMaxTranslationCm / 100.0),
412
+ && flowMaxTranslationCm > 0,
413
+ budgetMeters: Math.max(0.001, flowMaxTranslationCm / 100.0),
388
414
  onBudgetExceeded: () => {
389
415
  const mod = (0, incremental_1.getIncrementalNativeModule)();
390
416
  mod?.markNextFrameAsLastKeyframe?.().catch(() => undefined);
@@ -430,12 +456,52 @@ function Camera(props) {
430
456
  });
431
457
  return () => { sub?.remove?.(); };
432
458
  }, []);
459
+ // 2026-05-23 (race fix) — Previously this useEffect cleared
460
+ // `batchKeyframeThumbnails` + `incrementalState` when statusPhase
461
+ // transitioned to 'recording'. But handleHoldStart is async
462
+ // (`await incremental.start(...)`), and on Android the ARSession
463
+ // was already alive on the GL thread — it could emit an ACCEPT
464
+ // event during the await window, BEFORE the effect ran. Order
465
+ // observed in logcat:
466
+ // 1. setStatusPhase('recording') queued
467
+ // 2. await incremental.start() yields
468
+ // 3. ARCore frame → ingest → JS [state] emit
469
+ // 4. setBatchKeyframeThumbnails((prev=[]) => [keyframe-0.jpg])
470
+ // 5. React commits statusPhase change → THIS effect ran
471
+ // 6. setBatchKeyframeThumbnails([]) ← WIPED frame 0!
472
+ // 7. Frame 1 arrives → updater sees prev=[] → adds only frame 1
473
+ // ⇒ final array missing keyframe-0.jpg
474
+ // The reset is now done synchronously at the top of
475
+ // handleHoldStart, before any await, so the GL thread can't race
476
+ // ahead. This effect is intentionally removed.
477
+ // 2026-05-22 (audit F2f) — every accepted keyframe is a fresh
478
+ // anchor for the IMU translation gate, regardless of which
479
+ // mechanism qualified the frame (flow novelty, plane-overlap,
480
+ // angular fallback, IMU-budget force-accept, force-last). Reset
481
+ // the gate's per-segment integrator on every acceptedCount
482
+ // increment so the operator sees `imuΔ` reset to 0 in the debug
483
+ // overlay after every accept — consistent UX regardless of WHY
484
+ // the gate took the frame. Pre-F2f only the IMU-budget path
485
+ // reset the integrator; flow accepts left `posX` ticking up
486
+ // forever, which surprised the user.
487
+ //
488
+ // The gate's `totalAbsMetres` cumulative accumulator banks the
489
+ // |segment displacement| before zeroing, so finalize-time
490
+ // translation magnitude is preserved across non-IMU accepts.
491
+ const lastAcceptedCountRef = (0, react_1.useRef)(0);
433
492
  (0, react_1.useEffect)(() => {
434
- if (statusPhase === 'recording') {
435
- setBatchKeyframeThumbnails([]);
436
- setIncrementalState(null);
493
+ const accepted = incrementalState?.acceptedCount ?? 0;
494
+ if (accepted > lastAcceptedCountRef.current) {
495
+ lastAcceptedCountRef.current = accepted;
496
+ if (isNonAR) {
497
+ imuGate.resetAnchor();
498
+ }
499
+ }
500
+ else if (accepted === 0) {
501
+ // New capture (state cleared) — reset our edge-detect ref.
502
+ lastAcceptedCountRef.current = 0;
437
503
  }
438
- }, [statusPhase]);
504
+ }, [incrementalState?.acceptedCount, isNonAR, imuGate]);
439
505
  // ── Shutter handlers ────────────────────────────────────────────
440
506
  const handleTap = (0, react_1.useCallback)(async () => {
441
507
  if (!enablePhotoMode)
@@ -503,11 +569,41 @@ function Camera(props) {
503
569
  return;
504
570
  }
505
571
  try {
572
+ // 2026-05-23 (race fix) — synchronously clear thumbnails +
573
+ // engine state at the top of handleHoldStart, BEFORE awaiting
574
+ // incremental.start(). In the previous effect-based design
575
+ // the GL thread could ingest an AR frame during the await
576
+ // window and add to thumbnails BEFORE React's
577
+ // statusPhase-effect ran and wiped them. See the removed
578
+ // useEffect a few hundred lines above for the full log trace.
579
+ // Synchronous reset here means any racing frame ingest sees
580
+ // an empty array and accumulates from there.
581
+ setBatchKeyframeThumbnails([]);
582
+ setIncrementalState(null);
506
583
  setStatusPhase('recording');
507
584
  setRecordingStartedAt(Date.now());
508
585
  const orientationRotation = deviceOrientation === 'portrait' ? 90
509
586
  : deviceOrientation === 'portrait-upside-down' ? 270
510
587
  : 0;
588
+ // v0.4 — the inline-flat config dict that v0.3 maintained here
589
+ // moved into `panoramaSettingsToNativeConfig` (see
590
+ // PanoramaSettingsBridge.ts). That adapter is the single source
591
+ // of truth for the JS→native wire format; both this call site
592
+ // AND the modal's reset-to-defaults preview agree on the same
593
+ // mapping. Audit fixes F1 / F4 / F6 from v0.3 are now properties
594
+ // of the bridge (verified by the unit tests in
595
+ // src/camera/__tests__/PanoramaSettingsBridge.test.ts).
596
+ //
597
+ // 2026-05-23 — override `captureSource` with the runtime-derived
598
+ // `effectiveCaptureSource` (from `arPreference + lens +
599
+ // AR-device-support`). Pre-this change the camera-screen AR
600
+ // toggle wrote ONLY to local `arPreference` state while the
601
+ // bridge read `settings.captureSource` — so native could think
602
+ // the capture was AR while the operator had toggled it off (or
603
+ // vice-versa). Single source of truth now: whatever camera the
604
+ // operator can see is what native is told it is. The settings
605
+ // modal's `captureSource` control has been removed for the same
606
+ // reason — see PanoramaSettingsModal.tsx for the rationale.
511
607
  await incremental.start({
512
608
  snapshotJpegQuality: 75,
513
609
  snapshotEveryNAccepts: 1,
@@ -519,18 +615,10 @@ function Camera(props) {
519
615
  canvasWidth: 5000,
520
616
  canvasHeight: 5000,
521
617
  engine: 'batch-keyframe',
522
- config: {
523
- stitchMode: settings.stitchMode,
524
- warperType: settings.warperType,
525
- blenderType: settings.blenderType,
526
- seamFinderType: settings.seamFinderType,
527
- flowNoveltyPercentile: settings.flowNoveltyPercentile,
528
- flowEvalEveryNFrames: settings.flowEvalEveryNFrames,
529
- flowMaxTranslationCm: settings.flowMaxTranslationCm,
530
- keyframeMaxCount: settings.keyframeMaxCount,
531
- keyframeOverlapThreshold: settings.keyframeOverlapThreshold,
532
- frameSelectionMode: 'flow-based',
533
- },
618
+ config: (0, PanoramaSettingsBridge_1.panoramaSettingsToNativeConfig)({
619
+ ...settings,
620
+ captureSource: effectiveCaptureSource,
621
+ }),
534
622
  });
535
623
  imuGate.resetAnchor();
536
624
  // Start pumping vision-camera snapshots into the engine for
@@ -553,6 +641,7 @@ function Camera(props) {
553
641
  isNonAR,
554
642
  deviceOrientation,
555
643
  settings,
644
+ effectiveCaptureSource,
556
645
  imuGate,
557
646
  jsDriver,
558
647
  onError,
@@ -574,7 +663,14 @@ function Camera(props) {
574
663
  const panoOutputPath = outputDir
575
664
  ? `${(0, paths_1.toBareFilePath)(outputDir).replace(/\/$/, '')}/${(0, files_1.defaultPanoramaFilename)()}`
576
665
  : `${await (0, files_1.getDefaultCaptureDir)()}/${(0, files_1.defaultPanoramaFilename)()}`;
577
- const result = await incremental.finalize(panoOutputPath, 90, deviceOrientation);
666
+ // 2026-05-22 (audit F2f) total IMU translation directly from
667
+ // the gate's cumulative accumulator (banks |segment displacement|
668
+ // at every anchor reset, including non-IMU-driven resets like
669
+ // flow-novelty accepts). No more fires × budget + residual
670
+ // reconstruction. Only meaningful in non-AR mode (in AR the
671
+ // native side uses pose-derived translation and ignores this).
672
+ const imuTotalTranslationM = isNonAR ? imuGate.getTotalAbsMetres() : 0;
673
+ const result = await incremental.finalize(panoOutputPath, 90, deviceOrientation, imuTotalTranslationM);
578
674
  if (typeof result.framesRequested === 'number'
579
675
  && typeof result.framesIncluded === 'number'
580
676
  && result.framesIncluded < result.framesRequested) {
@@ -595,7 +691,15 @@ function Camera(props) {
595
691
  framesDropped: (result.framesRequested ?? 0) - (result.framesIncluded ?? 0),
596
692
  finalConfidenceThresh: result.finalConfidenceThresh ?? -1,
597
693
  durationMs: Date.now() - (recordingStartedAt ?? Date.now()),
694
+ stitchModeResolved: result.stitchModeResolved,
598
695
  });
696
+ // 2026-05-22 (audit F9) — fire the debug stitch-stats toast on
697
+ // every successful finalize when settings.debug is on. Shows
698
+ // the leaveBiggestComponent retry telemetry + resolved mode so
699
+ // the operator can see what choice the auto-resolver made.
700
+ if (settings.debug) {
701
+ stitchToast.showResult(result);
702
+ }
599
703
  }
600
704
  catch (err) {
601
705
  const message = err instanceof Error ? err.message : String(err);
@@ -619,6 +723,18 @@ function Camera(props) {
619
723
  onError,
620
724
  recordingStartedAt,
621
725
  jsDriver,
726
+ // F10 Phase 2 review N1 — these four were missing pre-fix. The
727
+ // callback reads `settings.debug` (to gate the stitchToast),
728
+ // `isNonAR` (to decide whether to read IMU totalAbs translation),
729
+ // `imuGate` (the read itself), and `stitchToast` (the toast hook
730
+ // object). If any of those identities change between the user
731
+ // pressing-and-holding the shutter and the release, the stale-
732
+ // closure read could disagree with the actual current state.
733
+ // Pre-existing v0.3 bug; v0.4 was the natural time to address it.
734
+ settings,
735
+ isNonAR,
736
+ imuGate,
737
+ stitchToast,
622
738
  ]);
623
739
  // ── Lens / AR-toggle handlers ───────────────────────────────────
624
740
  const handleLensChange = (0, react_1.useCallback)((next) => {
@@ -641,6 +757,12 @@ function Camera(props) {
641
757
  // which has run on `video` (true) for months without issue.
642
758
  video: true, flash: "off", style: react_native_1.StyleSheet.absoluteFill })),
643
759
  react_1.default.createElement(CaptureStatusOverlay_1.CaptureStatusOverlay, { phase: statusPhase, topInset: insets.top, recordingStartedAt: recordingStartedAt ?? undefined }),
760
+ settings.debug && (react_1.default.createElement(react_1.default.Fragment, null,
761
+ react_1.default.createElement(CaptureOrientationPill_1.CaptureOrientationPill, { orientation: deviceOrientation, topInset: insets.top }),
762
+ react_1.default.createElement(CaptureKeyframePill_1.CaptureKeyframePill, { state: incrementalState, topInset: insets.top }),
763
+ react_1.default.createElement(CaptureMemoryPill_1.CaptureMemoryPill, { topInset: insets.top }),
764
+ react_1.default.createElement(CaptureDebugOverlay_1.CaptureDebugOverlay, { incrementalState: incrementalState, imuTranslationMetres: isNonAR ? imuGate.getTranslationMetres() : null, captureSource: effectiveCaptureSource, frameSelectionMode: settings.frameSelection.mode, stitchMode: settings.stitcher.stitchMode }))),
765
+ react_1.default.createElement(CaptureStitchStatsToast_1.CaptureStitchStatsToast, { message: stitchToast.message, topInset: insets.top }),
644
766
  showSettingsButton && (react_1.default.createElement(SettingsButton, { topInset: insets.top, onPress: () => setSettingsModalVisible(true) })),
645
767
  react_1.default.createElement(react_native_1.View, { pointerEvents: "box-none", style: [styles.bottomArea, { paddingBottom: insets.bottom + 12 }] },
646
768
  statusPhase === 'recording' && (react_1.default.createElement(PanoramaBandOverlay_1.PanoramaBandOverlay, { state: incrementalState, frameUris: batchKeyframeThumbnails, captureOrientation: deviceOrientation })),
@@ -0,0 +1,45 @@
1
+ /**
2
+ * CaptureDebugOverlay — diagnostic overlay for capture sessions.
3
+ *
4
+ * Shows the live engine state in a floating pill at the top of the
5
+ * capture screen so operators can see:
6
+ *
7
+ * - which frame outcome the engine just emitted (accept/skip/reject)
8
+ * - keyframe count vs. cap (e.g. "3 / 6")
9
+ * - per-frame newContent fraction + overlap percent
10
+ * - latest processingMs (how long the gate eval took)
11
+ * - JS-side IMU translation accumulator (when non-AR)
12
+ * - JS heap usage estimate (rough — RN doesn't expose Native heap)
13
+ *
14
+ * The overlay is gated by `<Camera>`'s `settings.debug` flag. When
15
+ * `debug = false` the component renders null and consumes no CPU.
16
+ *
17
+ * Why a separate component (not inline in Camera.tsx)?
18
+ *
19
+ * Camera.tsx is already a 1200-line beast and the debug pill needs
20
+ * its own styling/layout that would distract from the main capture
21
+ * UX. Splitting it out keeps Camera.tsx focused and the debug
22
+ * surface easy to evolve independently (future F9 work — port the
23
+ * richer memory bubble + stitch toast from the RetaiLens host).
24
+ *
25
+ * This component is intentionally PRESENTATIONAL — all data is
26
+ * pushed in as props. The host (Camera.tsx) owns the
27
+ * subscriptions / refs / state and decides when to mount the
28
+ * overlay.
29
+ */
30
+ import React from 'react';
31
+ import type { IncrementalState } from '../stitching/incremental';
32
+ export interface CaptureDebugOverlayProps {
33
+ /** Latest engine state (null = no capture in progress). */
34
+ incrementalState: IncrementalState | null;
35
+ /** JS-side IMU translation accumulator in metres (non-AR mode). */
36
+ imuTranslationMetres?: number | null;
37
+ /** Capture-source label so the operator knows which gate path is live. */
38
+ captureSource: 'ar' | 'non-ar';
39
+ /** Effective frame selection mode that's running right now. */
40
+ frameSelectionMode: 'time-based' | 'pose-based' | 'flow-based';
41
+ /** Effective stitchMode setting (operator-set, before auto-resolution). */
42
+ stitchMode: 'auto' | 'panorama' | 'scans';
43
+ }
44
+ export declare function CaptureDebugOverlay({ incrementalState, imuTranslationMetres, captureSource, frameSelectionMode, stitchMode, }: CaptureDebugOverlayProps): React.JSX.Element;
45
+ //# sourceMappingURL=CaptureDebugOverlay.d.ts.map
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * CaptureDebugOverlay — diagnostic overlay for capture sessions.
5
+ *
6
+ * Shows the live engine state in a floating pill at the top of the
7
+ * capture screen so operators can see:
8
+ *
9
+ * - which frame outcome the engine just emitted (accept/skip/reject)
10
+ * - keyframe count vs. cap (e.g. "3 / 6")
11
+ * - per-frame newContent fraction + overlap percent
12
+ * - latest processingMs (how long the gate eval took)
13
+ * - JS-side IMU translation accumulator (when non-AR)
14
+ * - JS heap usage estimate (rough — RN doesn't expose Native heap)
15
+ *
16
+ * The overlay is gated by `<Camera>`'s `settings.debug` flag. When
17
+ * `debug = false` the component renders null and consumes no CPU.
18
+ *
19
+ * Why a separate component (not inline in Camera.tsx)?
20
+ *
21
+ * Camera.tsx is already a 1200-line beast and the debug pill needs
22
+ * its own styling/layout that would distract from the main capture
23
+ * UX. Splitting it out keeps Camera.tsx focused and the debug
24
+ * surface easy to evolve independently (future F9 work — port the
25
+ * richer memory bubble + stitch toast from the RetaiLens host).
26
+ *
27
+ * This component is intentionally PRESENTATIONAL — all data is
28
+ * pushed in as props. The host (Camera.tsx) owns the
29
+ * subscriptions / refs / state and decides when to mount the
30
+ * overlay.
31
+ */
32
+ var __importDefault = (this && this.__importDefault) || function (mod) {
33
+ return (mod && mod.__esModule) ? mod : { "default": mod };
34
+ };
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.CaptureDebugOverlay = CaptureDebugOverlay;
37
+ const react_1 = __importDefault(require("react"));
38
+ const react_native_1 = require("react-native");
39
+ /**
40
+ * Map the numeric `outcome` enum to a short human label. Mirrors
41
+ * the iOS/Android C++ enum. Hidden in production builds — only
42
+ * surfaced via this debug overlay.
43
+ */
44
+ function outcomeLabel(outcome) {
45
+ switch (outcome) {
46
+ case 1: return 'accept';
47
+ case 2: return 'reject';
48
+ case 3: return 'cap-hit';
49
+ default: return outcome == null ? '—' : String(outcome);
50
+ }
51
+ }
52
+ function CaptureDebugOverlay({ incrementalState, imuTranslationMetres, captureSource, frameSelectionMode, stitchMode, }) {
53
+ const accepted = incrementalState?.acceptedCount ?? 0;
54
+ const cap = incrementalState?.keyframeMax ?? 0;
55
+ const overlap = incrementalState?.overlapPercent;
56
+ const proc = incrementalState?.processingMs;
57
+ const outcome = outcomeLabel(incrementalState?.outcome);
58
+ const isLandscape = incrementalState?.isLandscape;
59
+ const painted = incrementalState?.paintedExtent ?? 0;
60
+ const panTotal = incrementalState?.panExtent ?? 0;
61
+ const fillPct = panTotal > 0 ? Math.round((painted / panTotal) * 100) : 0;
62
+ // Translation pill is only meaningful in non-AR mode (in AR the
63
+ // engine's own pose is the source of truth; we don't surface the
64
+ // tx/ty/tz separately because the operator can't act on them).
65
+ const showImu = captureSource === 'non-ar' && imuTranslationMetres != null;
66
+ const imuCm = showImu ? (imuTranslationMetres * 100).toFixed(1) : null;
67
+ return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: styles.container },
68
+ react_1.default.createElement(react_native_1.View, { style: styles.row },
69
+ react_1.default.createElement(react_native_1.Text, { style: styles.label },
70
+ captureSource,
71
+ "/",
72
+ frameSelectionMode,
73
+ "/",
74
+ stitchMode)),
75
+ react_1.default.createElement(react_native_1.View, { style: styles.row },
76
+ react_1.default.createElement(react_native_1.Text, { style: styles.metricKey }, "frames"),
77
+ react_1.default.createElement(react_native_1.Text, { style: styles.metricVal },
78
+ accepted,
79
+ cap > 0 ? ` / ${cap}` : '')),
80
+ react_1.default.createElement(react_native_1.View, { style: styles.row },
81
+ react_1.default.createElement(react_native_1.Text, { style: styles.metricKey }, "last"),
82
+ react_1.default.createElement(react_native_1.Text, { style: styles.metricVal }, outcome)),
83
+ (overlap != null && overlap >= 0) && (react_1.default.createElement(react_native_1.View, { style: styles.row },
84
+ react_1.default.createElement(react_native_1.Text, { style: styles.metricKey }, "overlap"),
85
+ react_1.default.createElement(react_native_1.Text, { style: styles.metricVal },
86
+ overlap.toFixed(0),
87
+ "%"))),
88
+ (proc != null && proc > 0) && (react_1.default.createElement(react_native_1.View, { style: styles.row },
89
+ react_1.default.createElement(react_native_1.Text, { style: styles.metricKey }, "proc"),
90
+ react_1.default.createElement(react_native_1.Text, { style: styles.metricVal },
91
+ proc.toFixed(0),
92
+ "ms"))),
93
+ panTotal > 0 && (react_1.default.createElement(react_native_1.View, { style: styles.row },
94
+ react_1.default.createElement(react_native_1.Text, { style: styles.metricKey }, "pan"),
95
+ react_1.default.createElement(react_native_1.Text, { style: styles.metricVal },
96
+ fillPct,
97
+ "% (",
98
+ isLandscape ? 'L' : 'P',
99
+ ")"))),
100
+ showImu && (react_1.default.createElement(react_native_1.View, { style: styles.row },
101
+ react_1.default.createElement(react_native_1.Text, { style: styles.metricKey }, "imu\u0394"),
102
+ react_1.default.createElement(react_native_1.Text, { style: styles.metricVal },
103
+ imuCm,
104
+ "cm")))));
105
+ }
106
+ const styles = react_native_1.StyleSheet.create({
107
+ container: {
108
+ position: 'absolute',
109
+ // 2026-05-22 — moved from top-left to left-middle so it doesn't
110
+ // collide with the orientation pill (top-left) or the keyframe
111
+ // pill (top-center) when all three are mounted together in
112
+ // <Camera>'s debug mode.
113
+ top: 160,
114
+ left: 12,
115
+ backgroundColor: 'rgba(0, 0, 0, 0.65)',
116
+ paddingHorizontal: 10,
117
+ paddingVertical: 6,
118
+ borderRadius: 8,
119
+ minWidth: 130,
120
+ },
121
+ row: {
122
+ flexDirection: 'row',
123
+ justifyContent: 'space-between',
124
+ alignItems: 'center',
125
+ marginVertical: 1,
126
+ },
127
+ label: {
128
+ color: '#fff',
129
+ fontSize: 11,
130
+ fontWeight: '600',
131
+ fontFamily: 'Menlo',
132
+ },
133
+ metricKey: {
134
+ color: '#9aa',
135
+ fontSize: 10,
136
+ fontFamily: 'Menlo',
137
+ marginRight: 8,
138
+ },
139
+ metricVal: {
140
+ color: '#fff',
141
+ fontSize: 11,
142
+ fontWeight: '600',
143
+ fontFamily: 'Menlo',
144
+ },
145
+ });
146
+ //# sourceMappingURL=CaptureDebugOverlay.js.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * CaptureKeyframePill — top-center "Keyframes: N/M" diagnostic pill.
3
+ *
4
+ * Renders while a capture is in flight AND the engine is running
5
+ * the pose-driven / flow-driven keyframe gate (keyframeMax > 0).
6
+ * Hidden when the gate is disabled (time-based frame selection) or
7
+ * when no capture is active.
8
+ *
9
+ * Color-coded by closeness to the cap:
10
+ *
11
+ * - green N < M − 1 (plenty of budget remaining)
12
+ * - amber N ≥ M − 1 (last frame, or cap already hit — next
13
+ * accept will be rejected)
14
+ *
15
+ * Layer-2 hosts that compose their own capture UI can mount this
16
+ * pill directly; Layer-1 `<Camera>` mounts it automatically when
17
+ * `settings.debug = true`.
18
+ */
19
+ import React from 'react';
20
+ import type { IncrementalState } from '../stitching/incremental';
21
+ export interface CaptureKeyframePillProps {
22
+ /** Latest engine state. Null = capture not running. */
23
+ state: IncrementalState | null;
24
+ /** Top inset for safe-area placement. Pill pinned `topInset + 56`. */
25
+ topInset?: number;
26
+ }
27
+ export declare function CaptureKeyframePill({ state, topInset, }: CaptureKeyframePillProps): React.JSX.Element | null;
28
+ //# sourceMappingURL=CaptureKeyframePill.d.ts.map
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * CaptureKeyframePill — top-center "Keyframes: N/M" diagnostic pill.
5
+ *
6
+ * Renders while a capture is in flight AND the engine is running
7
+ * the pose-driven / flow-driven keyframe gate (keyframeMax > 0).
8
+ * Hidden when the gate is disabled (time-based frame selection) or
9
+ * when no capture is active.
10
+ *
11
+ * Color-coded by closeness to the cap:
12
+ *
13
+ * - green N < M − 1 (plenty of budget remaining)
14
+ * - amber N ≥ M − 1 (last frame, or cap already hit — next
15
+ * accept will be rejected)
16
+ *
17
+ * Layer-2 hosts that compose their own capture UI can mount this
18
+ * pill directly; Layer-1 `<Camera>` mounts it automatically when
19
+ * `settings.debug = true`.
20
+ */
21
+ var __importDefault = (this && this.__importDefault) || function (mod) {
22
+ return (mod && mod.__esModule) ? mod : { "default": mod };
23
+ };
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.CaptureKeyframePill = CaptureKeyframePill;
26
+ const react_1 = __importDefault(require("react"));
27
+ const react_native_1 = require("react-native");
28
+ function CaptureKeyframePill({ state, topInset = 0, }) {
29
+ const accepted = state?.acceptedCount ?? 0;
30
+ const max = state?.keyframeMax ?? 0;
31
+ if (max <= 0)
32
+ return null;
33
+ const isAmber = accepted >= max - 1;
34
+ return (react_1.default.createElement(react_native_1.View, { pointerEvents: "none", style: [
35
+ styles.container,
36
+ {
37
+ top: topInset + 56,
38
+ backgroundColor: isAmber
39
+ ? 'rgba(245, 158, 11, 0.95)'
40
+ : 'rgba(34, 197, 94, 0.95)',
41
+ },
42
+ ], accessibilityRole: "alert", accessibilityLiveRegion: "polite" },
43
+ react_1.default.createElement(react_native_1.Text, { style: styles.text }, `Keyframes: ${accepted}/${max}`)));
44
+ }
45
+ const styles = react_native_1.StyleSheet.create({
46
+ container: {
47
+ position: 'absolute',
48
+ alignSelf: 'center',
49
+ paddingHorizontal: 14,
50
+ paddingVertical: 6,
51
+ borderRadius: 999,
52
+ zIndex: 100,
53
+ },
54
+ text: {
55
+ color: '#fff',
56
+ fontSize: 13,
57
+ fontWeight: '600',
58
+ },
59
+ });
60
+ //# sourceMappingURL=CaptureKeyframePill.js.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * CaptureMemoryPill — top-right diagnostic pill showing native
3
+ * process memory footprint in MB, polled at 500 ms.
4
+ *
5
+ * Color-coded against the iPhone 16 Pro per-process jetsam limit:
6
+ *
7
+ * - green <1500 MB (comfortable)
8
+ * - amber 1500–2200 (approaching pressure)
9
+ * - red >2200 (close to limit — capture may be killed)
10
+ *
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
+ *
15
+ * Mount this pill inside a `settings.debug`-gated branch — it
16
+ * polls native every 500 ms and is unwanted in production builds.
17
+ */
18
+ import React from 'react';
19
+ export interface CaptureMemoryPillProps {
20
+ /** Top inset (status bar / notch). Pill pinned `topInset + 56`. */
21
+ topInset?: number;
22
+ /** Polling interval in ms. Default 500. Lower wastes battery
23
+ * for no visible benefit; higher loses correlation with capture
24
+ * activity. */
25
+ pollIntervalMs?: number;
26
+ }
27
+ export declare function CaptureMemoryPill({ topInset, pollIntervalMs, }: CaptureMemoryPillProps): React.JSX.Element | null;
28
+ //# sourceMappingURL=CaptureMemoryPill.d.ts.map