react-native-image-stitcher 0.2.1 → 0.3.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 (49) hide show
  1. package/CHANGELOG.md +316 -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 +118 -8
  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/PanoramaSettingsModal.d.ts +6 -5
  23. package/dist/index.d.ts +10 -0
  24. package/dist/index.js +15 -1
  25. package/dist/sensors/useIMUTranslationGate.d.ts +26 -0
  26. package/dist/sensors/useIMUTranslationGate.js +83 -1
  27. package/dist/stitching/incremental.d.ts +25 -0
  28. package/dist/stitching/useIncrementalStitcher.d.ts +12 -1
  29. package/dist/stitching/useIncrementalStitcher.js +7 -1
  30. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +321 -7
  31. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +8 -0
  32. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +12 -0
  33. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +13 -0
  34. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +15 -0
  35. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +1 -0
  36. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +17 -4
  37. package/ios/Sources/RNImageStitcher/Stitcher.swift +6 -1
  38. package/package.json +1 -1
  39. package/src/camera/Camera.tsx +165 -7
  40. package/src/camera/CaptureDebugOverlay.tsx +180 -0
  41. package/src/camera/CaptureKeyframePill.tsx +77 -0
  42. package/src/camera/CaptureMemoryPill.tsx +96 -0
  43. package/src/camera/CaptureOrientationPill.tsx +57 -0
  44. package/src/camera/CaptureStitchStatsToast.tsx +155 -0
  45. package/src/camera/PanoramaSettingsModal.tsx +6 -5
  46. package/src/index.ts +19 -0
  47. package/src/sensors/useIMUTranslationGate.ts +112 -1
  48. package/src/stitching/incremental.ts +25 -0
  49. package/src/stitching/useIncrementalStitcher.ts +18 -0
@@ -63,6 +63,11 @@ import { ARCameraView, type ARCameraViewHandle } from './ARCameraView';
63
63
  import { CameraShutter } from './CameraShutter';
64
64
  import { CameraView } from './CameraView';
65
65
  import { CaptureStatusOverlay, type CaptureStatusPhase } from './CaptureStatusOverlay';
66
+ import { CaptureDebugOverlay } from './CaptureDebugOverlay';
67
+ import { CaptureMemoryPill } from './CaptureMemoryPill';
68
+ import { CaptureKeyframePill } from './CaptureKeyframePill';
69
+ import { CaptureOrientationPill } from './CaptureOrientationPill';
70
+ import { CaptureStitchStatsToast, useStitchStatsToast } from './CaptureStitchStatsToast';
66
71
  import { PanoramaBandOverlay } from './PanoramaBandOverlay';
67
72
  import {
68
73
  DEFAULT_PANORAMA_SETTINGS,
@@ -129,6 +134,15 @@ export type CameraCaptureResult =
129
134
  framesDropped: number;
130
135
  finalConfidenceThresh: number;
131
136
  durationMs: number;
137
+ /**
138
+ * 2026-05-22 (audit F2g) — which cv::Stitcher pipeline the
139
+ * batch finalize ran (after auto-resolution if applicable).
140
+ * Useful for displaying a "Stitched as: scans" pill on the
141
+ * output preview. Undefined when the engine wasn't
142
+ * batch-keyframe (hybrid / slit-scan don't go through
143
+ * cv::Stitcher at finalize).
144
+ */
145
+ stitchModeResolved?: 'panorama' | 'scans';
132
146
  };
133
147
 
134
148
 
@@ -540,6 +554,11 @@ export function Camera(props: CameraProps): React.JSX.Element {
540
554
  null,
541
555
  );
542
556
  const [incrementalState, setIncrementalState] = useState<IncrementalState | null>(null);
557
+ // 2026-05-22 (audit F9 + F3) — debug stitch-stats toast. Hook
558
+ // exposes an imperative API; we fire `showResult(finalizeResult)`
559
+ // on every successful finalize when settings.debug is on (gated
560
+ // a few hundred lines below in handleHoldEnd's onCapture branch).
561
+ const stitchToast = useStitchStatsToast();
543
562
  const [batchKeyframeThumbnails, setBatchKeyframeThumbnails] = useState<
544
563
  string[]
545
564
  >([]);
@@ -666,6 +685,15 @@ export function Camera(props: CameraProps): React.JSX.Element {
666
685
  // the C++ engine to force-accept the next frame. This is what
667
686
  // keeps non-AR captures producing keyframes at all (the flow-
668
687
  // novelty algorithm alone is too strict in practice).
688
+ //
689
+ // 2026-05-22 (audit F2f) — IMU translation gate. The gate's own
690
+ // `totalAbsMetres` accumulator (banks each segment's |displacement|
691
+ // at every anchor reset) is the right input for the finalize-time
692
+ // auto-resolver in non-AR mode (where pose-derived translation is
693
+ // 0). Pre-F2f this was reconstructed from `fires × budget +
694
+ // |residual|` — which undercounted any time a non-IMU accept
695
+ // (flow novelty, force-last) reset the integrator before the
696
+ // budget threshold was reached.
669
697
  const imuGate = useIMUTranslationGate({
670
698
  enabled:
671
699
  isNonAR
@@ -718,12 +746,52 @@ export function Camera(props: CameraProps): React.JSX.Element {
718
746
  });
719
747
  return () => { sub?.remove?.(); };
720
748
  }, []);
749
+ // 2026-05-23 (race fix) — Previously this useEffect cleared
750
+ // `batchKeyframeThumbnails` + `incrementalState` when statusPhase
751
+ // transitioned to 'recording'. But handleHoldStart is async
752
+ // (`await incremental.start(...)`), and on Android the ARSession
753
+ // was already alive on the GL thread — it could emit an ACCEPT
754
+ // event during the await window, BEFORE the effect ran. Order
755
+ // observed in logcat:
756
+ // 1. setStatusPhase('recording') queued
757
+ // 2. await incremental.start() yields
758
+ // 3. ARCore frame → ingest → JS [state] emit
759
+ // 4. setBatchKeyframeThumbnails((prev=[]) => [keyframe-0.jpg])
760
+ // 5. React commits statusPhase change → THIS effect ran
761
+ // 6. setBatchKeyframeThumbnails([]) ← WIPED frame 0!
762
+ // 7. Frame 1 arrives → updater sees prev=[] → adds only frame 1
763
+ // ⇒ final array missing keyframe-0.jpg
764
+ // The reset is now done synchronously at the top of
765
+ // handleHoldStart, before any await, so the GL thread can't race
766
+ // ahead. This effect is intentionally removed.
767
+
768
+ // 2026-05-22 (audit F2f) — every accepted keyframe is a fresh
769
+ // anchor for the IMU translation gate, regardless of which
770
+ // mechanism qualified the frame (flow novelty, plane-overlap,
771
+ // angular fallback, IMU-budget force-accept, force-last). Reset
772
+ // the gate's per-segment integrator on every acceptedCount
773
+ // increment so the operator sees `imuΔ` reset to 0 in the debug
774
+ // overlay after every accept — consistent UX regardless of WHY
775
+ // the gate took the frame. Pre-F2f only the IMU-budget path
776
+ // reset the integrator; flow accepts left `posX` ticking up
777
+ // forever, which surprised the user.
778
+ //
779
+ // The gate's `totalAbsMetres` cumulative accumulator banks the
780
+ // |segment displacement| before zeroing, so finalize-time
781
+ // translation magnitude is preserved across non-IMU accepts.
782
+ const lastAcceptedCountRef = useRef(0);
721
783
  useEffect(() => {
722
- if (statusPhase === 'recording') {
723
- setBatchKeyframeThumbnails([]);
724
- setIncrementalState(null);
784
+ const accepted = incrementalState?.acceptedCount ?? 0;
785
+ if (accepted > lastAcceptedCountRef.current) {
786
+ lastAcceptedCountRef.current = accepted;
787
+ if (isNonAR) {
788
+ imuGate.resetAnchor();
789
+ }
790
+ } else if (accepted === 0) {
791
+ // New capture (state cleared) — reset our edge-detect ref.
792
+ lastAcceptedCountRef.current = 0;
725
793
  }
726
- }, [statusPhase]);
794
+ }, [incrementalState?.acceptedCount, isNonAR, imuGate]);
727
795
 
728
796
  // ── Shutter handlers ────────────────────────────────────────────
729
797
 
@@ -805,6 +873,17 @@ export function Camera(props: CameraProps): React.JSX.Element {
805
873
  return;
806
874
  }
807
875
  try {
876
+ // 2026-05-23 (race fix) — synchronously clear thumbnails +
877
+ // engine state at the top of handleHoldStart, BEFORE awaiting
878
+ // incremental.start(). In the previous effect-based design
879
+ // the GL thread could ingest an AR frame during the await
880
+ // window and add to thumbnails BEFORE React's
881
+ // statusPhase-effect ran and wiped them. See the removed
882
+ // useEffect a few hundred lines above for the full log trace.
883
+ // Synchronous reset here means any racing frame ingest sees
884
+ // an empty array and accumulates from there.
885
+ setBatchKeyframeThumbnails([]);
886
+ setIncrementalState(null);
808
887
  setStatusPhase('recording');
809
888
  setRecordingStartedAt(Date.now());
810
889
  const orientationRotation: 0 | 90 | 180 | 270 =
@@ -823,16 +902,35 @@ export function Camera(props: CameraProps): React.JSX.Element {
823
902
  canvasHeight: 5000,
824
903
  engine: 'batch-keyframe',
825
904
  config: {
905
+ // ── cv::Stitcher (batch finalize) ─────────────────────────
826
906
  stitchMode: settings.stitchMode,
827
907
  warperType: settings.warperType,
828
908
  blenderType: settings.blenderType,
829
909
  seamFinderType: settings.seamFinderType,
910
+ enableMaxInscribedRectCrop: settings.enableMaxInscribedRectCrop,
911
+ // ── KeyframeGate (per-frame selection) ────────────────────
912
+ // F6 audit fix: pass settings.frameSelectionMode through
913
+ // instead of hardcoding 'flow-based' (which silently made the
914
+ // time-based / pose-based modal options no-ops).
915
+ frameSelectionMode: settings.frameSelectionMode,
916
+ keyframeMaxCount: settings.keyframeMaxCount,
917
+ keyframeOverlapThreshold: settings.keyframeOverlapThreshold,
918
+ // ── Flow-strategy tunables ────────────────────────────────
919
+ // F4 audit fix: previously omitted, which made the modal
920
+ // sliders for these three a complete no-op (only iOS native
921
+ // even read them, and only when JS sent them).
830
922
  flowNoveltyPercentile: settings.flowNoveltyPercentile,
831
923
  flowEvalEveryNFrames: settings.flowEvalEveryNFrames,
832
924
  flowMaxTranslationCm: settings.flowMaxTranslationCm,
833
- keyframeMaxCount: settings.keyframeMaxCount,
834
- keyframeOverlapThreshold: settings.keyframeOverlapThreshold,
835
- frameSelectionMode: 'flow-based',
925
+ flowMaxCorners: settings.flowMaxCorners,
926
+ flowQualityLevel: settings.flowQualityLevel,
927
+ flowMinDistance: settings.flowMinDistance,
928
+ // ── Engine-routing flags consumed by native ───────────────
929
+ // F1 audit fix: Android keyframe gate's disableAngularFallback
930
+ // opt-out reads this to decide whether to skip the angular
931
+ // fallback (gyro pose is too noisy for the FoV-overlap calc
932
+ // in non-AR mode, causing degenerate cv::Stitcher params).
933
+ captureSource: settings.captureSource,
836
934
  },
837
935
  });
838
936
  imuGate.resetAnchor();
@@ -882,10 +980,19 @@ export function Camera(props: CameraProps): React.JSX.Element {
882
980
  const panoOutputPath = outputDir
883
981
  ? `${toBareFilePath(outputDir).replace(/\/$/, '')}/${defaultPanoramaFilename()}`
884
982
  : `${await getDefaultCaptureDir()}/${defaultPanoramaFilename()}`;
983
+ // 2026-05-22 (audit F2f) — total IMU translation directly from
984
+ // the gate's cumulative accumulator (banks |segment displacement|
985
+ // at every anchor reset, including non-IMU-driven resets like
986
+ // flow-novelty accepts). No more fires × budget + residual
987
+ // reconstruction. Only meaningful in non-AR mode (in AR the
988
+ // native side uses pose-derived translation and ignores this).
989
+ const imuTotalTranslationM =
990
+ isNonAR ? imuGate.getTotalAbsMetres() : 0;
885
991
  const result = await incremental.finalize(
886
992
  panoOutputPath,
887
993
  90,
888
994
  deviceOrientation,
995
+ imuTotalTranslationM,
889
996
  );
890
997
  if (
891
998
  typeof result.framesRequested === 'number'
@@ -910,7 +1017,15 @@ export function Camera(props: CameraProps): React.JSX.Element {
910
1017
  (result.framesRequested ?? 0) - (result.framesIncluded ?? 0),
911
1018
  finalConfidenceThresh: result.finalConfidenceThresh ?? -1,
912
1019
  durationMs: Date.now() - (recordingStartedAt ?? Date.now()),
1020
+ stitchModeResolved: result.stitchModeResolved,
913
1021
  });
1022
+ // 2026-05-22 (audit F9) — fire the debug stitch-stats toast on
1023
+ // every successful finalize when settings.debug is on. Shows
1024
+ // the leaveBiggestComponent retry telemetry + resolved mode so
1025
+ // the operator can see what choice the auto-resolver made.
1026
+ if (settings.debug) {
1027
+ stitchToast.showResult(result);
1028
+ }
914
1029
  } catch (err) {
915
1030
  const message = err instanceof Error ? err.message : String(err);
916
1031
  const code: CameraErrorCode =
@@ -988,6 +1103,49 @@ export function Camera(props: CameraProps): React.JSX.Element {
988
1103
  recordingStartedAt={recordingStartedAt ?? undefined}
989
1104
  />
990
1105
 
1106
+ {/*
1107
+ 2026-05-22 (audit F9 + F3) — debug UI suite, all gated on
1108
+ settings.debug. Mounts in <Camera> automatically; Layer-2
1109
+ hosts can import the individual components from the public
1110
+ API and compose their own debug surface. Layout:
1111
+ - top-left: orientation pill (purple)
1112
+ - top-center: keyframes pill (green/amber)
1113
+ - top-right: memory pill (green/amber/red)
1114
+ - top-center: stitch-stats toast (dark capsule, transient)
1115
+ - left-mid: detailed metrics block (overlap, processing,
1116
+ imuΔ, etc.) — uses CaptureDebugOverlay
1117
+ */}
1118
+ {settings.debug && (
1119
+ <>
1120
+ <CaptureOrientationPill
1121
+ orientation={deviceOrientation}
1122
+ topInset={insets.top}
1123
+ />
1124
+ <CaptureKeyframePill
1125
+ state={incrementalState}
1126
+ topInset={insets.top}
1127
+ />
1128
+ <CaptureMemoryPill topInset={insets.top} />
1129
+ <CaptureDebugOverlay
1130
+ incrementalState={incrementalState}
1131
+ imuTranslationMetres={
1132
+ isNonAR ? imuGate.getTranslationMetres() : null
1133
+ }
1134
+ captureSource={effectiveCaptureSource}
1135
+ frameSelectionMode={settings.frameSelectionMode}
1136
+ stitchMode={settings.stitchMode}
1137
+ />
1138
+ </>
1139
+ )}
1140
+ {/* Toast renders regardless of `settings.debug` — toast hook
1141
+ * is only ever fired from the debug-gated path, but mounting
1142
+ * unconditionally lets Layer-2 hosts wire their own showFor()
1143
+ * callers without needing a separate mount. */}
1144
+ <CaptureStitchStatsToast
1145
+ message={stitchToast.message}
1146
+ topInset={insets.top}
1147
+ />
1148
+
991
1149
  {/* Settings gear (top-right), gated on showSettingsButton. */}
992
1150
  {showSettingsButton && (
993
1151
  <SettingsButton
@@ -0,0 +1,180 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * CaptureDebugOverlay — diagnostic overlay for capture sessions.
4
+ *
5
+ * Shows the live engine state in a floating pill at the top of the
6
+ * capture screen so operators can see:
7
+ *
8
+ * - which frame outcome the engine just emitted (accept/skip/reject)
9
+ * - keyframe count vs. cap (e.g. "3 / 6")
10
+ * - per-frame newContent fraction + overlap percent
11
+ * - latest processingMs (how long the gate eval took)
12
+ * - JS-side IMU translation accumulator (when non-AR)
13
+ * - JS heap usage estimate (rough — RN doesn't expose Native heap)
14
+ *
15
+ * The overlay is gated by `<Camera>`'s `settings.debug` flag. When
16
+ * `debug = false` the component renders null and consumes no CPU.
17
+ *
18
+ * Why a separate component (not inline in Camera.tsx)?
19
+ *
20
+ * Camera.tsx is already a 1200-line beast and the debug pill needs
21
+ * its own styling/layout that would distract from the main capture
22
+ * UX. Splitting it out keeps Camera.tsx focused and the debug
23
+ * surface easy to evolve independently (future F9 work — port the
24
+ * richer memory bubble + stitch toast from the RetaiLens host).
25
+ *
26
+ * This component is intentionally PRESENTATIONAL — all data is
27
+ * pushed in as props. The host (Camera.tsx) owns the
28
+ * subscriptions / refs / state and decides when to mount the
29
+ * overlay.
30
+ */
31
+
32
+ import React from 'react';
33
+ import { StyleSheet, Text, View } from 'react-native';
34
+
35
+ import type { IncrementalState } from '../stitching/incremental';
36
+
37
+ export interface CaptureDebugOverlayProps {
38
+ /** Latest engine state (null = no capture in progress). */
39
+ incrementalState: IncrementalState | null;
40
+ /** JS-side IMU translation accumulator in metres (non-AR mode). */
41
+ imuTranslationMetres?: number | null;
42
+ /** Capture-source label so the operator knows which gate path is live. */
43
+ captureSource: 'ar' | 'non-ar';
44
+ /** Effective frame selection mode that's running right now. */
45
+ frameSelectionMode: 'time-based' | 'pose-based' | 'flow-based';
46
+ /** Effective stitchMode setting (operator-set, before auto-resolution). */
47
+ stitchMode: 'auto' | 'panorama' | 'scans';
48
+ }
49
+
50
+ /**
51
+ * Map the numeric `outcome` enum to a short human label. Mirrors
52
+ * the iOS/Android C++ enum. Hidden in production builds — only
53
+ * surfaced via this debug overlay.
54
+ */
55
+ function outcomeLabel(outcome: number | undefined): string {
56
+ switch (outcome) {
57
+ case 1: return 'accept';
58
+ case 2: return 'reject';
59
+ case 3: return 'cap-hit';
60
+ default: return outcome == null ? '—' : String(outcome);
61
+ }
62
+ }
63
+
64
+
65
+ export function CaptureDebugOverlay({
66
+ incrementalState,
67
+ imuTranslationMetres,
68
+ captureSource,
69
+ frameSelectionMode,
70
+ stitchMode,
71
+ }: CaptureDebugOverlayProps): React.JSX.Element {
72
+ const accepted = incrementalState?.acceptedCount ?? 0;
73
+ const cap = incrementalState?.keyframeMax ?? 0;
74
+ const overlap = incrementalState?.overlapPercent;
75
+ const proc = incrementalState?.processingMs;
76
+ const outcome = outcomeLabel(incrementalState?.outcome);
77
+ const isLandscape = incrementalState?.isLandscape;
78
+ const painted = incrementalState?.paintedExtent ?? 0;
79
+ const panTotal = incrementalState?.panExtent ?? 0;
80
+ const fillPct = panTotal > 0 ? Math.round((painted / panTotal) * 100) : 0;
81
+
82
+ // Translation pill is only meaningful in non-AR mode (in AR the
83
+ // engine's own pose is the source of truth; we don't surface the
84
+ // tx/ty/tz separately because the operator can't act on them).
85
+ const showImu = captureSource === 'non-ar' && imuTranslationMetres != null;
86
+ const imuCm = showImu ? (imuTranslationMetres! * 100).toFixed(1) : null;
87
+
88
+ return (
89
+ <View pointerEvents="none" style={styles.container}>
90
+ {/* Top row: mode summary */}
91
+ <View style={styles.row}>
92
+ <Text style={styles.label}>
93
+ {captureSource}/{frameSelectionMode}/{stitchMode}
94
+ </Text>
95
+ </View>
96
+ {/* Keyframes pill */}
97
+ <View style={styles.row}>
98
+ <Text style={styles.metricKey}>frames</Text>
99
+ <Text style={styles.metricVal}>
100
+ {accepted}{cap > 0 ? ` / ${cap}` : ''}
101
+ </Text>
102
+ </View>
103
+ {/* Outcome */}
104
+ <View style={styles.row}>
105
+ <Text style={styles.metricKey}>last</Text>
106
+ <Text style={styles.metricVal}>{outcome}</Text>
107
+ </View>
108
+ {/* Overlap + processing */}
109
+ {(overlap != null && overlap >= 0) && (
110
+ <View style={styles.row}>
111
+ <Text style={styles.metricKey}>overlap</Text>
112
+ <Text style={styles.metricVal}>{overlap.toFixed(0)}%</Text>
113
+ </View>
114
+ )}
115
+ {(proc != null && proc > 0) && (
116
+ <View style={styles.row}>
117
+ <Text style={styles.metricKey}>proc</Text>
118
+ <Text style={styles.metricVal}>{proc.toFixed(0)}ms</Text>
119
+ </View>
120
+ )}
121
+ {/* Pan progress (band overlay metric) */}
122
+ {panTotal > 0 && (
123
+ <View style={styles.row}>
124
+ <Text style={styles.metricKey}>pan</Text>
125
+ <Text style={styles.metricVal}>
126
+ {fillPct}% ({isLandscape ? 'L' : 'P'})
127
+ </Text>
128
+ </View>
129
+ )}
130
+ {/* IMU translation — only in non-AR mode */}
131
+ {showImu && (
132
+ <View style={styles.row}>
133
+ <Text style={styles.metricKey}>imuΔ</Text>
134
+ <Text style={styles.metricVal}>{imuCm}cm</Text>
135
+ </View>
136
+ )}
137
+ </View>
138
+ );
139
+ }
140
+
141
+ const styles = StyleSheet.create({
142
+ container: {
143
+ position: 'absolute',
144
+ // 2026-05-22 — moved from top-left to left-middle so it doesn't
145
+ // collide with the orientation pill (top-left) or the keyframe
146
+ // pill (top-center) when all three are mounted together in
147
+ // <Camera>'s debug mode.
148
+ top: 160,
149
+ left: 12,
150
+ backgroundColor: 'rgba(0, 0, 0, 0.65)',
151
+ paddingHorizontal: 10,
152
+ paddingVertical: 6,
153
+ borderRadius: 8,
154
+ minWidth: 130,
155
+ },
156
+ row: {
157
+ flexDirection: 'row',
158
+ justifyContent: 'space-between',
159
+ alignItems: 'center',
160
+ marginVertical: 1,
161
+ },
162
+ label: {
163
+ color: '#fff',
164
+ fontSize: 11,
165
+ fontWeight: '600',
166
+ fontFamily: 'Menlo',
167
+ },
168
+ metricKey: {
169
+ color: '#9aa',
170
+ fontSize: 10,
171
+ fontFamily: 'Menlo',
172
+ marginRight: 8,
173
+ },
174
+ metricVal: {
175
+ color: '#fff',
176
+ fontSize: 11,
177
+ fontWeight: '600',
178
+ fontFamily: 'Menlo',
179
+ },
180
+ });
@@ -0,0 +1,77 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * CaptureKeyframePill — top-center "Keyframes: N/M" diagnostic pill.
4
+ *
5
+ * Renders while a capture is in flight AND the engine is running
6
+ * the pose-driven / flow-driven keyframe gate (keyframeMax > 0).
7
+ * Hidden when the gate is disabled (time-based frame selection) or
8
+ * when no capture is active.
9
+ *
10
+ * Color-coded by closeness to the cap:
11
+ *
12
+ * - green N < M − 1 (plenty of budget remaining)
13
+ * - amber N ≥ M − 1 (last frame, or cap already hit — next
14
+ * accept will be rejected)
15
+ *
16
+ * Layer-2 hosts that compose their own capture UI can mount this
17
+ * pill directly; Layer-1 `<Camera>` mounts it automatically when
18
+ * `settings.debug = true`.
19
+ */
20
+
21
+ import React from 'react';
22
+ import { StyleSheet, Text, View } from 'react-native';
23
+
24
+ import type { IncrementalState } from '../stitching/incremental';
25
+
26
+ export interface CaptureKeyframePillProps {
27
+ /** Latest engine state. Null = capture not running. */
28
+ state: IncrementalState | null;
29
+ /** Top inset for safe-area placement. Pill pinned `topInset + 56`. */
30
+ topInset?: number;
31
+ }
32
+
33
+ export function CaptureKeyframePill({
34
+ state,
35
+ topInset = 0,
36
+ }: CaptureKeyframePillProps): React.JSX.Element | null {
37
+ const accepted = state?.acceptedCount ?? 0;
38
+ const max = state?.keyframeMax ?? 0;
39
+ if (max <= 0) return null;
40
+
41
+ const isAmber = accepted >= max - 1;
42
+
43
+ return (
44
+ <View
45
+ pointerEvents="none"
46
+ style={[
47
+ styles.container,
48
+ {
49
+ top: topInset + 56,
50
+ backgroundColor: isAmber
51
+ ? 'rgba(245, 158, 11, 0.95)'
52
+ : 'rgba(34, 197, 94, 0.95)',
53
+ },
54
+ ]}
55
+ accessibilityRole="alert"
56
+ accessibilityLiveRegion="polite"
57
+ >
58
+ <Text style={styles.text}>{`Keyframes: ${accepted}/${max}`}</Text>
59
+ </View>
60
+ );
61
+ }
62
+
63
+ const styles = StyleSheet.create({
64
+ container: {
65
+ position: 'absolute',
66
+ alignSelf: 'center',
67
+ paddingHorizontal: 14,
68
+ paddingVertical: 6,
69
+ borderRadius: 999,
70
+ zIndex: 100,
71
+ },
72
+ text: {
73
+ color: '#fff',
74
+ fontSize: 13,
75
+ fontWeight: '600',
76
+ },
77
+ });
@@ -0,0 +1,96 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * CaptureMemoryPill — top-right diagnostic pill showing native
4
+ * process memory footprint in MB, polled at 500 ms.
5
+ *
6
+ * Color-coded against the iPhone 16 Pro per-process jetsam limit:
7
+ *
8
+ * - green <1500 MB (comfortable)
9
+ * - amber 1500–2200 (approaching pressure)
10
+ * - red >2200 (close to limit — capture may be killed)
11
+ *
12
+ * Backed by the existing `getMemoryFootprintMB()` native module
13
+ * (iOS: `task_info phys_footprint`, Android: `Debug.MemoryInfo
14
+ * getTotalPss * 1024`). Returns -1 if the native call fails.
15
+ *
16
+ * Mount this pill inside a `settings.debug`-gated branch — it
17
+ * polls native every 500 ms and is unwanted in production builds.
18
+ */
19
+
20
+ import React, { useEffect, useState } from 'react';
21
+ import { StyleSheet, Text, View } from 'react-native';
22
+
23
+ import { getIncrementalNativeModule } from '../stitching/incremental';
24
+
25
+ export interface CaptureMemoryPillProps {
26
+ /** Top inset (status bar / notch). Pill pinned `topInset + 56`. */
27
+ topInset?: number;
28
+ /** Polling interval in ms. Default 500. Lower wastes battery
29
+ * for no visible benefit; higher loses correlation with capture
30
+ * activity. */
31
+ pollIntervalMs?: number;
32
+ }
33
+
34
+ export function CaptureMemoryPill({
35
+ topInset = 0,
36
+ pollIntervalMs = 500,
37
+ }: CaptureMemoryPillProps): React.JSX.Element | null {
38
+ const [memMB, setMemMB] = useState<number | null>(null);
39
+
40
+ useEffect(() => {
41
+ const native = getIncrementalNativeModule();
42
+ if (!native?.getMemoryFootprintMB) return undefined;
43
+ let cancelled = false;
44
+ const tick = async () => {
45
+ try {
46
+ const mb = await native.getMemoryFootprintMB();
47
+ if (!cancelled) setMemMB(mb);
48
+ } catch {
49
+ // Bridge error — leave the previous reading visible.
50
+ }
51
+ };
52
+ tick();
53
+ const id = setInterval(tick, pollIntervalMs);
54
+ return () => {
55
+ cancelled = true;
56
+ clearInterval(id);
57
+ };
58
+ }, [pollIntervalMs]);
59
+
60
+ if (memMB === null || memMB < 0) return null;
61
+
62
+ const bg =
63
+ memMB > 2200 ? 'rgba(239, 68, 68, 0.92)' // red
64
+ : memMB > 1500 ? 'rgba(245, 158, 11, 0.92)' // amber
65
+ : 'rgba(34, 197, 94, 0.92)'; // green
66
+
67
+ return (
68
+ <View
69
+ pointerEvents="none"
70
+ style={[
71
+ styles.container,
72
+ { top: topInset + 56, backgroundColor: bg },
73
+ ]}
74
+ accessibilityRole="alert"
75
+ >
76
+ <Text style={styles.text}>{`${Math.round(memMB)} MB`}</Text>
77
+ </View>
78
+ );
79
+ }
80
+
81
+ const styles = StyleSheet.create({
82
+ container: {
83
+ position: 'absolute',
84
+ right: 12,
85
+ paddingHorizontal: 10,
86
+ paddingVertical: 5,
87
+ borderRadius: 999,
88
+ zIndex: 100,
89
+ },
90
+ text: {
91
+ color: '#fff',
92
+ fontSize: 12,
93
+ fontWeight: '700',
94
+ fontFamily: 'Menlo',
95
+ },
96
+ });
@@ -0,0 +1,57 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * CaptureOrientationPill — diagnostic pill showing the operator's
4
+ * current hold orientation as detected by the pose-derived hook.
5
+ *
6
+ * Useful for diagnosing rotation issues — if the pill says
7
+ * `landscape-left` but the band overlay is rendering as if it's
8
+ * `portrait`, there's a mismatch between the JS orientation hook
9
+ * and the engine's pose-derived isLandscape signal.
10
+ *
11
+ * Pinned top-left below the status bar. Layer-2 hosts can mount
12
+ * this directly; Layer-1 `<Camera>` mounts it automatically when
13
+ * `settings.debug = true`.
14
+ */
15
+
16
+ import React from 'react';
17
+ import { StyleSheet, Text, View } from 'react-native';
18
+
19
+ export interface CaptureOrientationPillProps {
20
+ /** Current device orientation (typically from useDeviceOrientation). */
21
+ orientation: string;
22
+ /** Top inset for safe-area placement. Pill pinned `topInset + 56`. */
23
+ topInset?: number;
24
+ }
25
+
26
+ export function CaptureOrientationPill({
27
+ orientation,
28
+ topInset = 0,
29
+ }: CaptureOrientationPillProps): React.JSX.Element {
30
+ return (
31
+ <View
32
+ pointerEvents="none"
33
+ style={[styles.container, { top: topInset + 56 }]}
34
+ accessibilityRole="alert"
35
+ >
36
+ <Text style={styles.text}>{`orient: ${orientation}`}</Text>
37
+ </View>
38
+ );
39
+ }
40
+
41
+ const styles = StyleSheet.create({
42
+ container: {
43
+ position: 'absolute',
44
+ left: 12,
45
+ paddingHorizontal: 10,
46
+ paddingVertical: 5,
47
+ borderRadius: 999,
48
+ backgroundColor: 'rgba(99, 102, 241, 0.92)',
49
+ zIndex: 100,
50
+ },
51
+ text: {
52
+ color: '#fff',
53
+ fontSize: 11,
54
+ fontWeight: '700',
55
+ fontFamily: 'Menlo',
56
+ },
57
+ });