react-native-image-stitcher 0.1.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 (151) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +21 -0
  4. package/README.md +189 -0
  5. package/RNImageStitcher.podspec +76 -0
  6. package/android/build.gradle +224 -0
  7. package/android/src/main/AndroidManifest.xml +3 -0
  8. package/android/src/main/cpp/CMakeLists.txt +124 -0
  9. package/android/src/main/cpp/image_stitcher_jni.cpp +145 -0
  10. package/android/src/main/cpp/keyframe_gate_jni.cpp +204 -0
  11. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +426 -0
  12. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +960 -0
  13. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +2371 -0
  14. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +256 -0
  15. package/android/src/main/java/io/imagestitcher/rn/QualityChecker.kt +167 -0
  16. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +39 -0
  17. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +558 -0
  18. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +35 -0
  19. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +784 -0
  20. package/android/src/main/java/io/imagestitcher/rn/ar/BackgroundRenderer.kt +176 -0
  21. package/android/src/main/java/io/imagestitcher/rn/ar/ShaderUtil.kt +67 -0
  22. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +201 -0
  23. package/cpp/ar_frame_pose.h +63 -0
  24. package/cpp/keyframe_gate.cpp +927 -0
  25. package/cpp/keyframe_gate.hpp +240 -0
  26. package/cpp/stitcher.cpp +2207 -0
  27. package/cpp/stitcher.hpp +275 -0
  28. package/dist/ar/useARSession.d.ts +102 -0
  29. package/dist/ar/useARSession.js +133 -0
  30. package/dist/camera/ARCameraView.d.ts +93 -0
  31. package/dist/camera/ARCameraView.js +170 -0
  32. package/dist/camera/Camera.d.ts +134 -0
  33. package/dist/camera/Camera.js +688 -0
  34. package/dist/camera/CameraShutter.d.ts +80 -0
  35. package/dist/camera/CameraShutter.js +237 -0
  36. package/dist/camera/CameraView.d.ts +65 -0
  37. package/dist/camera/CameraView.js +117 -0
  38. package/dist/camera/CaptureControlsBar.d.ts +87 -0
  39. package/dist/camera/CaptureControlsBar.js +82 -0
  40. package/dist/camera/CaptureHeader.d.ts +62 -0
  41. package/dist/camera/CaptureHeader.js +81 -0
  42. package/dist/camera/CapturePreview.d.ts +70 -0
  43. package/dist/camera/CapturePreview.js +188 -0
  44. package/dist/camera/CaptureStatusOverlay.d.ts +75 -0
  45. package/dist/camera/CaptureStatusOverlay.js +326 -0
  46. package/dist/camera/CaptureThumbnailStrip.d.ts +87 -0
  47. package/dist/camera/CaptureThumbnailStrip.js +177 -0
  48. package/dist/camera/IncrementalPanGuide.d.ts +83 -0
  49. package/dist/camera/IncrementalPanGuide.js +267 -0
  50. package/dist/camera/PanoramaBandOverlay.d.ts +107 -0
  51. package/dist/camera/PanoramaBandOverlay.js +399 -0
  52. package/dist/camera/PanoramaConfirmModal.d.ts +57 -0
  53. package/dist/camera/PanoramaConfirmModal.js +128 -0
  54. package/dist/camera/PanoramaGuidance.d.ts +79 -0
  55. package/dist/camera/PanoramaGuidance.js +246 -0
  56. package/dist/camera/PanoramaSettingsModal.d.ts +311 -0
  57. package/dist/camera/PanoramaSettingsModal.js +611 -0
  58. package/dist/camera/ViewportCropOverlay.d.ts +46 -0
  59. package/dist/camera/ViewportCropOverlay.js +67 -0
  60. package/dist/camera/useCapture.d.ts +111 -0
  61. package/dist/camera/useCapture.js +160 -0
  62. package/dist/camera/useDeviceOrientation.d.ts +48 -0
  63. package/dist/camera/useDeviceOrientation.js +131 -0
  64. package/dist/camera/useVideoCapture.d.ts +79 -0
  65. package/dist/camera/useVideoCapture.js +151 -0
  66. package/dist/index.d.ts +26 -0
  67. package/dist/index.js +39 -0
  68. package/dist/quality/normaliseOrientation.d.ts +36 -0
  69. package/dist/quality/normaliseOrientation.js +62 -0
  70. package/dist/quality/runQualityCheck.d.ts +41 -0
  71. package/dist/quality/runQualityCheck.js +98 -0
  72. package/dist/sensors/useIMUTranslationGate.d.ts +70 -0
  73. package/dist/sensors/useIMUTranslationGate.js +235 -0
  74. package/dist/stitching/IncrementalStitcherView.d.ts +41 -0
  75. package/dist/stitching/IncrementalStitcherView.js +157 -0
  76. package/dist/stitching/incremental.d.ts +930 -0
  77. package/dist/stitching/incremental.js +133 -0
  78. package/dist/stitching/stitchFrames.d.ts +55 -0
  79. package/dist/stitching/stitchFrames.js +56 -0
  80. package/dist/stitching/stitchVideo.d.ts +119 -0
  81. package/dist/stitching/stitchVideo.js +57 -0
  82. package/dist/stitching/useIncrementalJSDriver.d.ts +74 -0
  83. package/dist/stitching/useIncrementalJSDriver.js +199 -0
  84. package/dist/stitching/useIncrementalStitcher.d.ts +58 -0
  85. package/dist/stitching/useIncrementalStitcher.js +172 -0
  86. package/dist/types.d.ts +58 -0
  87. package/dist/types.js +15 -0
  88. package/ios/Package.swift +72 -0
  89. package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +33 -0
  90. package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +40 -0
  91. package/ios/Sources/RNImageStitcher/ARSessionBridge.m +55 -0
  92. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +149 -0
  93. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +2727 -0
  94. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +85 -0
  95. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +625 -0
  96. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +328 -0
  97. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +141 -0
  98. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +278 -0
  99. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +473 -0
  100. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +1326 -0
  101. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +97 -0
  102. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +296 -0
  103. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +103 -0
  104. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +3285 -0
  105. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +238 -0
  106. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +1880 -0
  107. package/ios/Sources/RNImageStitcher/QualityChecker.swift +252 -0
  108. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.m +26 -0
  109. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.swift +72 -0
  110. package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +114 -0
  111. package/ios/Sources/RNImageStitcher/RNSARSession.swift +1111 -0
  112. package/ios/Sources/RNImageStitcher/Stitcher.swift +243 -0
  113. package/ios/Sources/RNImageStitcher/StitcherBridge.m +28 -0
  114. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +246 -0
  115. package/package.json +73 -0
  116. package/react-native.config.js +34 -0
  117. package/scripts/opencv-version.txt +1 -0
  118. package/scripts/postinstall-fetch-binaries.js +286 -0
  119. package/src/ar/useARSession.ts +210 -0
  120. package/src/camera/.gitkeep +0 -0
  121. package/src/camera/ARCameraView.tsx +256 -0
  122. package/src/camera/Camera.tsx +1053 -0
  123. package/src/camera/CameraShutter.tsx +292 -0
  124. package/src/camera/CameraView.tsx +157 -0
  125. package/src/camera/CaptureControlsBar.tsx +204 -0
  126. package/src/camera/CaptureHeader.tsx +184 -0
  127. package/src/camera/CapturePreview.tsx +318 -0
  128. package/src/camera/CaptureStatusOverlay.tsx +391 -0
  129. package/src/camera/CaptureThumbnailStrip.tsx +277 -0
  130. package/src/camera/IncrementalPanGuide.tsx +328 -0
  131. package/src/camera/PanoramaBandOverlay.tsx +498 -0
  132. package/src/camera/PanoramaConfirmModal.tsx +206 -0
  133. package/src/camera/PanoramaGuidance.tsx +327 -0
  134. package/src/camera/PanoramaSettingsModal.tsx +1357 -0
  135. package/src/camera/ViewportCropOverlay.tsx +81 -0
  136. package/src/camera/useCapture.ts +279 -0
  137. package/src/camera/useDeviceOrientation.ts +140 -0
  138. package/src/camera/useVideoCapture.ts +236 -0
  139. package/src/index.ts +53 -0
  140. package/src/quality/.gitkeep +0 -0
  141. package/src/quality/normaliseOrientation.ts +79 -0
  142. package/src/quality/runQualityCheck.ts +131 -0
  143. package/src/sensors/useIMUTranslationGate.ts +347 -0
  144. package/src/stitching/.gitkeep +0 -0
  145. package/src/stitching/IncrementalStitcherView.tsx +198 -0
  146. package/src/stitching/incremental.ts +1021 -0
  147. package/src/stitching/stitchFrames.ts +88 -0
  148. package/src/stitching/stitchVideo.ts +153 -0
  149. package/src/stitching/useIncrementalJSDriver.ts +273 -0
  150. package/src/stitching/useIncrementalStitcher.ts +252 -0
  151. package/src/types.ts +78 -0
@@ -0,0 +1,611 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * PanoramaSettingsModal — runtime A/B testing surface for the
5
+ * stitcher pipeline. Operators in the field can toggle warper,
6
+ * blender, and tuning constants between captures to see what
7
+ * looks best on real shelf scenes.
8
+ *
9
+ * The modal is presentational: the host owns the settings state
10
+ * (typically `useState<PanoramaSettings>`) and renders the modal
11
+ * with `visible` toggled by a gear-icon press in the capture
12
+ * header. Settings flow OUT via `onChange` for each tweak.
13
+ *
14
+ * Why expose this as an SDK component instead of leaving it to
15
+ * each host? The set of tunable knobs IS the SDK's contract —
16
+ * if a new setting is added (e.g. registration MP) the SDK ships
17
+ * the UI for it in lockstep with the param itself, instead of
18
+ * forcing every host app to update its settings screen.
19
+ */
20
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ var desc = Object.getOwnPropertyDescriptor(m, k);
23
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
24
+ desc = { enumerable: true, get: function() { return m[k]; } };
25
+ }
26
+ Object.defineProperty(o, k2, desc);
27
+ }) : (function(o, m, k, k2) {
28
+ if (k2 === undefined) k2 = k;
29
+ o[k2] = m[k];
30
+ }));
31
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
32
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
33
+ }) : function(o, v) {
34
+ o["default"] = v;
35
+ });
36
+ var __importStar = (this && this.__importStar) || (function () {
37
+ var ownKeys = function(o) {
38
+ ownKeys = Object.getOwnPropertyNames || function (o) {
39
+ var ar = [];
40
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
41
+ return ar;
42
+ };
43
+ return ownKeys(o);
44
+ };
45
+ return function (mod) {
46
+ if (mod && mod.__esModule) return mod;
47
+ var result = {};
48
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
49
+ __setModuleDefault(result, mod);
50
+ return result;
51
+ };
52
+ })();
53
+ Object.defineProperty(exports, "__esModule", { value: true });
54
+ exports.DEFAULT_PANORAMA_SETTINGS = void 0;
55
+ exports.PanoramaSettingsModal = PanoramaSettingsModal;
56
+ const react_1 = __importStar(require("react"));
57
+ const react_native_1 = require("react-native");
58
+ // Per-device default selection. We read the iPhone's physical
59
+ // RAM at SDK module load (exposed by `BatchStitcher`'s
60
+ // `constantsToExport`) and pick the heaviest blender + seam
61
+ // finder combo that fits. Threshold (2 GB) is conservative —
62
+ // iPhone 6s through iPhone X have 2 GB exactly; below that
63
+ // (iPhone 6 / 5s) is unsupported by RN 0.84 anyway. The user
64
+ // can still flip ANY of these in the settings modal at runtime;
65
+ // this only chooses the INITIAL default.
66
+ const _physicalMemoryBytes = (() => {
67
+ const m = react_native_1.NativeModules.BatchStitcher;
68
+ const bytes = m && typeof m === 'object'
69
+ ? m.physicalMemoryBytes
70
+ : undefined;
71
+ return typeof bytes === 'number' ? bytes : 0;
72
+ })();
73
+ const _isLowMem = _physicalMemoryBytes > 0
74
+ && _physicalMemoryBytes < 2 * 1024 * 1024 * 1024;
75
+ // One-line diagnostic so the host's Metro console shows what the
76
+ // SDK saw at module load. If `physicalMemoryBytes=0` here, the
77
+ // native bridge's `constantsToExport` isn't being picked up by
78
+ // React Native and we should investigate the @objc registration.
79
+ // The defaults always pick the SAFE fallback (multiband+graphcut)
80
+ // when the value is 0 — this log is the only signal we have.
81
+ // eslint-disable-next-line no-console
82
+ console.log('[capture-sdk] PanoramaSettings defaults: '
83
+ + `physicalMemoryBytes=${_physicalMemoryBytes} `
84
+ + `isLowMem=${_isLowMem} `
85
+ + `→ blender=${_isLowMem ? 'feather' : 'multiband'} `
86
+ + `seam=${_isLowMem ? 'skip' : 'graphcut'}`);
87
+ exports.DEFAULT_PANORAMA_SETTINGS = {
88
+ warperType: 'plane',
89
+ // High-quality defaults on devices with ≥2 GB RAM (iPhone X+):
90
+ // MultiBandBlender + GraphCutSeamFinder, the same combo
91
+ // cv::Stitcher::PANORAMA uses internally and what produced the
92
+ // sharpest output during iteration.
93
+ // Low-memory devices (<2 GB) fall back to FeatherBlender + skip
94
+ // seam (streams warp+feed) so peak memory stays under the
95
+ // tighter jetsam threshold. Either way, the user can switch
96
+ // both in the settings modal.
97
+ blenderType: _isLowMem ? 'feather' : 'multiband',
98
+ seamFinderType: _isLowMem ? 'skip' : 'graphcut',
99
+ // V16 Phase 1b.fix5c — default OFF. See PanoramaSettings.enableMaxInscribedRectCrop.
100
+ enableMaxInscribedRectCrop: false,
101
+ // AR-backed capture is the default — vision-camera path is kept as
102
+ // a fallback while we shake out edge cases.
103
+ useARPreview: true,
104
+ // V16 Phase 1 — batch-keyframe is the new default-recommended
105
+ // engine: KeyframeGate caps input at ≤ keyframeMaxCount frames,
106
+ // OpenCVStitcher's BA + GraphCut + ExposureCompensator +
107
+ // MultiBandBlender runs once on shutter release. Existing
108
+ // slitscan-* engines remain available for wide-pan fallback.
109
+ incrementalEngine: 'batch-keyframe',
110
+ slitWidthFraction: 0.30,
111
+ acceptGate: 0,
112
+ enableTriangulation: false,
113
+ enableTriAccumulator: false,
114
+ enable2dNcc: false,
115
+ enableRansacHomography: false,
116
+ // V15.0c — Ram observation: FirstPaintedWins is consistently the best
117
+ // output across all combinations. Default switched from FeatherBlend.
118
+ paintMode: 'FirstPaintedWins',
119
+ hybridProjection: 'Planar',
120
+ nccSearchRadius1d: 15,
121
+ useDetectedPlane: false,
122
+ // V16 Phase 1 — Virtual plane is the default since batch-keyframe
123
+ // is the recommended engine and the gate needs a plane to compute
124
+ // polygon overlap. Virtual works without ARKit-detected planes (a
125
+ // synthesized plane perpendicular to the first-frame camera at
126
+ // virtualPlaneDepthMeters); operators can flip to ARKitDetected
127
+ // when in a controlled scene with a clearly-visible wall. Disabled
128
+ // is still selectable for the older slit-scan paths that don't
129
+ // need a plane.
130
+ // V16 Phase 1b.fix5c (Ram's call 2026-05-10): switched default
131
+ // from 'Virtual' to 'ARKitDetected'. ARKit's real plane gives
132
+ // better intrinsics-to-pixel alignment than a synthesised plane
133
+ // at a fixed depth, when ARKit can find a vertical plane. Falls
134
+ // back to slit-scan when no plane latches.
135
+ planeSource: 'ARKitDetected',
136
+ virtualPlaneDepthMeters: 1.5,
137
+ arkitPlaneAlignmentThreshold: 0.6,
138
+ // V15.0g — Rectified is the default (Trapezoidal had the tilt-
139
+ // induced bottom-wider-than-top distortion that was the field
140
+ // blocker on V15.0e/f). Trapezoidal stays available for
141
+ // operator A/B comparison.
142
+ planeProjectionStyle: 'Rectified',
143
+ // V15.0d — NCC 2D defaults match V15.0c.4's hardcoded values, now
144
+ // tunable via the settings UI. EMA smoothing and pan-axis lock are
145
+ // off by default so the V15.0c.4 baseline behaviour is preserved
146
+ // until the operator explicitly opts in.
147
+ nccSearchMargin2d: 12,
148
+ // V15.0i.1 — default raised to 0.99 per Ram (only apply on near-
149
+ // perfect overlap matches; reject ambiguous matches that snap to
150
+ // wrong patterns on repetitive textures like shelf rails).
151
+ nccConfidenceThreshold2d: 0.99,
152
+ enableNcc2dEmaSmoothing: false,
153
+ ncc2dEmaAlpha: 0.4,
154
+ enableNcc2dPanAxisLock: false,
155
+ ncc2dCrossAxisLockPx: 5,
156
+ // V16 A2 (2026-05-13) — flow-based is now the default. Ram report
157
+ // 2026-05-13 13:05 showed that pose-based on a small latched plane
158
+ // produces "bursts" of accepts on small physical motion: a 0.64 m²
159
+ // plane at 2.7 m perpDist gave 6 accepts in 1 s over 12 cm of
160
+ // translation because the plane-projected polygon covers only a
161
+ // sliver of the frame, hyperinflating newContent. Flow-based
162
+ // measures novelty from real image content (sparse KLT), is
163
+ // plane-independent, and is invariant to plane size. Operators
164
+ // can still flip back to 'pose-based' or 'time-based' in the modal
165
+ // for A/B testing or low-texture scenes. Same defaults shared
166
+ // between pose-based and flow-based (40 % new content per
167
+ // keyframe, ≤ 6 keyframes per capture).
168
+ frameSelectionMode: 'flow-based',
169
+ // 2026-05-15 (U4) — flow-based default novelty 0.40 → 0.20.
170
+ // Accept frames with 20 % new content (was 40 %). More inclusive
171
+ // selection for shelf-pan captures where panning slowly produces
172
+ // gradual content reveal. Operator can still bump via Settings.
173
+ keyframeOverlapThreshold: 0.20,
174
+ keyframeMaxCount: 6,
175
+ // V16 A2 — flow-based mode tuning. Defaults are the values that
176
+ // tested cleanly on iPhone 13 Pro / 14 Pro: 150 corners give a
177
+ // stable median across the frame; quality=0.01 + minDistance=10
178
+ // give spatially-spread, repeatable detection. All three are
179
+ // tunable in the modal under "Flow tuning".
180
+ flowMaxCorners: 150,
181
+ flowQualityLevel: 0.01,
182
+ flowMinDistance: 10,
183
+ // V16 — translation-budget force-accept (Flow strategy only).
184
+ // 2026-05-16 (Issue 4a fix) — default flipped from 0 (disabled) to
185
+ // 25 cm so the "Rotate the camera instead of moving it sideways"
186
+ // warning fires out-of-the-box. Set to 0 in Settings to disable
187
+ // both the warning AND the gate's force-accept on budget crossing.
188
+ // 2026-05-17 (Issue 4-A v2) — raised 25 → 50 cm. The 25-cm budget
189
+ // was too tight given IMU double-integration drift (the
190
+ // accelerometer's noise floor accumulates several cm of bogus
191
+ // "translation" per second even when the phone is held still).
192
+ // Combined with the new `resetAnchor` at handleHoldStart (so drift
193
+ // doesn't compound across captures), 50 cm gives the warning real
194
+ // headroom for genuine sideways motion without false positives.
195
+ flowMaxTranslationCm: 50,
196
+ // V16 — novelty aggregation percentile. 0.85 picks up leading-
197
+ // edge motion sooner than the pre-V16 median (0.50). Operator
198
+ // can dial down toward 0.5 for more-conservative captures or up
199
+ // toward 0.99 for more-aggressive.
200
+ flowNoveltyPercentile: 0.85,
201
+ // V16 — every-Nth-frame eval throttle. 2026-05-15 (U4): default
202
+ // 1 → 5 to reduce per-frame KeyframeGate CPU cost (Shi-Tomasi +
203
+ // calcOpticalFlowPyrLK is ~3-5 ms per ARFrame on Galaxy A35; at
204
+ // 30 fps that's ~15 % CPU on flow alone). Evaluating every 5th
205
+ // frame yields novelty samples at ~6 Hz which is still well above
206
+ // the 1-2 Hz keyframe-accept cadence.
207
+ // matches pre-V16 behaviour). Set higher to cut CPU on long
208
+ // captures at the cost of acceptance latency.
209
+ flowEvalEveryNFrames: 5,
210
+ // V15.0c — sliver tweaks: leading-edge sliver from BOTTOM for typical
211
+ // top-to-bottom pan + full first-frame anchor produced the best
212
+ // outputs in early iteration.
213
+ sliverPosition: 'Bottom',
214
+ firstFrameFullFrame: true,
215
+ maxRecordingMs: 8000,
216
+ framesPerSecond: 3,
217
+ minFrames: 6,
218
+ maxFrames: 16,
219
+ quality: 85,
220
+ // 2026-05-14 (revised) — capture source defaults to 'ar' (AR-backed
221
+ // is the recommended path; non-AR is the explicit opt-out). Stitch
222
+ // mode stays 'auto' — the auto-resolution heuristic between PANORAMA
223
+ // and SCANS is per-capture, not per-mode, so it's safe to leave on.
224
+ captureSource: 'ar',
225
+ stitchMode: 'auto',
226
+ debug: false,
227
+ };
228
+ function PanoramaSettingsModal({ visible, settings, onChange, onClose, }) {
229
+ const update = (patch) => onChange({ ...settings, ...patch });
230
+ // V16 Phase 1b — derive the 2-axis (timing × algorithm) UI state
231
+ // from the underlying single `incrementalEngine` field. Storage
232
+ // shape is unchanged; the modal just presents it in two segmented
233
+ // controls so the user's mental model matches the system's actual
234
+ // primary axis (batch vs realtime).
235
+ //
236
+ // Mapping:
237
+ // incrementalEngine === 'batch-keyframe' → timing='batch'
238
+ // incrementalEngine === 'hybrid' → timing='realtime', algo='hybrid'
239
+ // incrementalEngine === 'slitscan-rotate' → timing='realtime', algo='slitscan-rotate'
240
+ // incrementalEngine === 'slitscan-both' → timing='realtime', algo='slitscan-both'
241
+ const timing = settings.incrementalEngine === 'batch-keyframe' ? 'batch' : 'realtime';
242
+ // When in batch mode, remember 'hybrid' as the realtime algorithm
243
+ // the user would land on if they flipped timing back. When already
244
+ // in realtime, the engine field IS the algorithm.
245
+ const realtimeAlgorithm = settings.incrementalEngine === 'batch-keyframe'
246
+ ? 'hybrid'
247
+ : settings.incrementalEngine;
248
+ const setTiming = (t) => {
249
+ if (t === 'batch') {
250
+ update({ incrementalEngine: 'batch-keyframe' });
251
+ }
252
+ else {
253
+ update({ incrementalEngine: realtimeAlgorithm });
254
+ }
255
+ };
256
+ // Frame Selection only makes sense for batch and hybrid engines —
257
+ // slit-scan needs dense input and the gate would starve it.
258
+ const showFrameSelection = timing === 'batch' || realtimeAlgorithm === 'hybrid';
259
+ return (react_1.default.createElement(react_native_1.Modal, { visible: visible, animationType: "slide", transparent: true, statusBarTranslucent: true, onRequestClose: onClose },
260
+ react_1.default.createElement(react_native_1.View, { style: styles.backdrop },
261
+ react_1.default.createElement(react_native_1.View, { style: styles.sheet },
262
+ react_1.default.createElement(react_native_1.View, { style: styles.header },
263
+ react_1.default.createElement(react_native_1.Text, { style: styles.title }, "Panorama settings"),
264
+ react_1.default.createElement(react_native_1.Pressable, { onPress: onClose, hitSlop: 12, accessibilityRole: "button", accessibilityLabel: "Close settings", style: styles.closeBtn },
265
+ react_1.default.createElement(react_native_1.Text, { style: styles.closeText }, "\u00D7"))),
266
+ react_1.default.createElement(react_native_1.ScrollView, { contentContainerStyle: styles.body },
267
+ react_1.default.createElement(react_native_1.Text, { style: styles.debugLine }, `device: physicalMemoryBytes=${_physicalMemoryBytes} `
268
+ + `(${(_physicalMemoryBytes / (1024 ** 3)).toFixed(2)} GB) · `
269
+ + `isLowMem=${_isLowMem ? 'yes' : 'no'} · `
270
+ + `default blender=${_isLowMem ? 'feather' : 'multiband'}`),
271
+ react_1.default.createElement(SectionHeader, { title: "Capture source" }),
272
+ react_1.default.createElement(SegmentedControl, { options: ['ar', 'non-ar'], value: settings.captureSource, onChange: (v) => update({ captureSource: v }), caption: "ar (default): ARKit / ARCore \u2014 plane detection, pose-aware capture, full AR stack. non-ar: vision-camera only \u2014 disables AR plane detection, flips frame-selection to flow-based, runs IMU translation gate. In non-ar mode, the on-screen lens-switcher chip lets you toggle 0.5\u00D7 / 1\u00D7 lens during capture (only shown when the device has both lenses)." }),
273
+ react_1.default.createElement(SectionHeader, { title: "Stitch mode" }),
274
+ react_1.default.createElement(SegmentedControl, { options: ['auto', 'panorama', 'scans'], value: settings.stitchMode, onChange: (v) => update({ stitchMode: v }), caption: "auto (default): pick PANORAMA or SCANS based on translation/rotation totals at finalize. panorama: cv::Stitcher::PANORAMA \u2014 rotation-only (spherical warper, BA-ray); best for rotate-in-place captures, BAD on translation. scans: cv::Stitcher::SCANS \u2014 affine pipeline (plane warper, BA-affine); best for shelf-pan captures." }),
275
+ react_1.default.createElement(SectionHeader, { title: "Stitch timing" }),
276
+ react_1.default.createElement(SegmentedControl, { options: ['batch', 'realtime'], value: timing, onChange: (v) => setTiming(v), caption: "batch (recommended): full cv::Stitcher pipeline at shutter release. Highest quality. ~1\u20132 s post-release. realtime: incremental during pan; lower latency, fewer quality stages." }),
277
+ showFrameSelection && (react_1.default.createElement(react_1.default.Fragment, null,
278
+ react_1.default.createElement(SectionHeader, { title: "Frame selection (V16)" }),
279
+ react_1.default.createElement(SegmentedControl, { options: ['time-based', 'pose-based', 'flow-based'], value: settings.frameSelectionMode, onChange: (v) => update({ frameSelectionMode: v }), caption: "flow-based (V16 A2, default): KeyframeGate uses sparse-Lucas-Kanade optical flow on full frame \u2014 plane-independent, invariant to plane size. pose-based: plane-polygon overlap (oversensitive on small latched planes). time-based: every ARFrame goes to the engine." }),
280
+ (settings.frameSelectionMode === 'pose-based' ||
281
+ settings.frameSelectionMode === 'flow-based') && (react_1.default.createElement(react_1.default.Fragment, null,
282
+ react_1.default.createElement(SectionHeader, { title: "Overlap threshold (new content per keyframe)" }),
283
+ react_1.default.createElement(SegmentedControl, { options: ['20%', '30%', '40%', '50%', '60%'], value: `${Math.round(settings.keyframeOverlapThreshold * 100)}%`, onChange: (v) => update({ keyframeOverlapThreshold: parseInt(v, 10) / 100 }), caption: "Required NEW content per keyframe. 40% (default) \u2248 4\u20135 keyframes for a 90\u00B0 pan. Same threshold semantics for both pose-based and flow-based." }),
284
+ react_1.default.createElement(SectionHeader, { title: "Max keyframes per capture" }),
285
+ react_1.default.createElement(SegmentedControl, { options: ['3', '4', '5', '6', '8', '10'], value: String(settings.keyframeMaxCount), onChange: (v) => update({ keyframeMaxCount: parseInt(v, 10) }), caption: "Hard cap. 6 (default) matches Samsung's behaviour. Once reached, host auto-finalizes." }))),
286
+ settings.frameSelectionMode === 'flow-based' && (react_1.default.createElement(react_1.default.Fragment, null,
287
+ react_1.default.createElement(SectionHeader, { title: "Flow tuning \u2014 max corners" }),
288
+ react_1.default.createElement(SegmentedControl, { options: ['50', '100', '150', '200', '300'], value: String(settings.flowMaxCorners), onChange: (v) => update({ flowMaxCorners: parseInt(v, 10) }), caption: "Max Shi-Tomasi corners detected per accepted keyframe. More = more robust median, slower detect. 150 = default." }),
289
+ react_1.default.createElement(SectionHeader, { title: "Flow tuning \u2014 quality level" }),
290
+ react_1.default.createElement(SegmentedControl, { options: ['0.005', '0.01', '0.02', '0.03', '0.05'], value: String(settings.flowQualityLevel), onChange: (v) => update({ flowQualityLevel: parseFloat(v) }), caption: "Shi-Tomasi corner quality threshold. Lower = more (weaker) corners; higher = fewer (stronger) corners. 0.01 = default." }),
291
+ react_1.default.createElement(SectionHeader, { title: "Flow tuning \u2014 min distance" }),
292
+ react_1.default.createElement(SegmentedControl, { options: ['5', '8', '10', '15', '20'], value: String(settings.flowMinDistance), onChange: (v) => update({ flowMinDistance: parseInt(v, 10) }), caption: "Min pixel distance between detected corners (working resolution = 720 px longest side). Higher = more spatially-spread features. 10 = default." }),
293
+ react_1.default.createElement(SectionHeader, { title: "Flow tuning \u2014 translation budget (cm)" }),
294
+ react_1.default.createElement(SegmentedControl, { options: ['0', '5', '8', '12', '20', '50'], value: String(settings.flowMaxTranslationCm), onChange: (v) => update({ flowMaxTranslationCm: parseInt(v, 10) }), caption: "Force-accept when camera has moved this many cm since last keyframe, even if novelty < overlap threshold. Bounds parallax so the stitcher can match. 0 = disabled (default). 8 = recommended starting value." }),
295
+ react_1.default.createElement(SectionHeader, { title: "Flow tuning \u2014 novelty percentile" }),
296
+ react_1.default.createElement(SegmentedControl, { options: ['0.50', '0.70', '0.85', '0.95', '0.99'], value: settings.flowNoveltyPercentile.toFixed(2), onChange: (v) => update({ flowNoveltyPercentile: parseFloat(v) }), caption: "How tracked-feature displacements are aggregated into novelty. 0.50 = pre-V16 median behaviour (conservative). 0.85 = picks up leading-edge motion sooner (default, matches user perception). 0.99 = near-max, very aggressive." }),
297
+ react_1.default.createElement(SectionHeader, { title: "Flow tuning \u2014 eval every N frames" }),
298
+ react_1.default.createElement(SegmentedControl, { options: ['1', '2', '3', '5', '10'], value: String(settings.flowEvalEveryNFrames), onChange: (v) => update({ flowEvalEveryNFrames: parseInt(v, 10) }), caption: "Throttle gate evaluation to every Nth AR frame. 1 = every frame (default, no throttle). 3-5 = noticeable CPU/battery savings on long captures, up to N-1 frames of acceptance latency." }))))),
299
+ react_1.default.createElement(SectionHeader, { title: "AR plane projection" }),
300
+ react_1.default.createElement(SegmentedControl, { options: ['Disabled', 'ARKitDetected', 'Virtual'], value: settings.planeSource, onChange: (v) => update({ planeSource: v }), caption: "Disabled: no plane (gate falls back to angular delta). ARKitDetected: latch ARKit's vertical plane (best fidelity, picky). Virtual: synthesise plane perpendicular to camera at a fixed depth (always works)." }),
301
+ settings.planeSource === 'ARKitDetected' && (react_1.default.createElement(react_1.default.Fragment, null,
302
+ react_1.default.createElement(SectionHeader, { title: "ARKit alignment threshold" }),
303
+ react_1.default.createElement(SegmentedControl, { options: ['0.3', '0.5', '0.6', '0.7', '0.85'], value: settings.arkitPlaneAlignmentThreshold.toFixed(2), onChange: (v) => update({ arkitPlaneAlignmentThreshold: parseFloat(v) }), caption: "Min dot product between candidate plane normal and camera facing. 0.6 (default) = ~53\u00B0 max angle off-camera. Higher = stricter." }))),
304
+ settings.planeSource === 'Virtual' && (react_1.default.createElement(react_1.default.Fragment, null,
305
+ react_1.default.createElement(SectionHeader, { title: "Virtual plane depth" }),
306
+ react_1.default.createElement(SegmentedControl, { options: ['0.5m', '1.0m', '1.5m', '2.0m', '3.0m'], value: `${settings.virtualPlaneDepthMeters.toFixed(1)}m`, onChange: (v) => update({ virtualPlaneDepthMeters: parseFloat(v) }), caption: "Synthetic plane depth at first frame. Set to your typical scan distance." }))),
307
+ settings.planeSource !== 'Disabled' && (react_1.default.createElement(react_1.default.Fragment, null,
308
+ react_1.default.createElement(SectionHeader, { title: "Plane projection style" }),
309
+ react_1.default.createElement(SegmentedControl, { options: ['Rectified', 'Trapezoidal'], value: settings.planeProjectionStyle, onChange: (v) => update({ planeProjectionStyle: v }), caption: "Rectified (default): clean rectangle paste, no tilt distortion. Trapezoidal: V15.0b legacy 3D-correct raycast \u2014 geometric purity at the cost of tilt artifacts." }))),
310
+ react_1.default.createElement(SectionHeader, { title: "Algorithm" }),
311
+ timing === 'batch' ? (react_1.default.createElement(react_native_1.View, { style: styles.infoBox },
312
+ react_1.default.createElement(react_native_1.Text, { style: styles.infoText }, "Full feature-matched pipeline: ORB \u2192 BFMatcher \u2192 RANSAC \u2192 BundleAdjusterRay \u2192 waveCorrect \u2192 Warper \u2192 GraphCutSeamFinder \u2192 ExposureCompensator \u2192 MultiBandBlender. No engine choice in batch mode."))) : (react_1.default.createElement(SegmentedControl, { options: ['hybrid', 'slitscan-rotate', 'slitscan-both'], value: realtimeAlgorithm, onChange: (v) => update({ incrementalEngine: v }), caption: "hybrid: streaming planar projection + feature matching. slitscan-rotate: V13.0a + 1D NCC. slitscan-both: V13.0a + no accept gate + feather blend (iteration playground)." })),
313
+ timing === 'batch' && (react_1.default.createElement(react_1.default.Fragment, null,
314
+ react_1.default.createElement(SectionHeader, { title: "Batch tuning \u2014 Warper" }),
315
+ react_1.default.createElement(SegmentedControl, { options: ['plane', 'cylindrical', 'spherical'], value: settings.warperType, onChange: (v) => update({ warperType: v }), caption: "plane (default, recommended for retail shelves): flat rectangular output. cylindrical: rotational mid-arc, gentle curvature. spherical: wide pans (180\u00B0+) but always-curved." }),
316
+ react_1.default.createElement(SectionHeader, { title: "Batch tuning \u2014 Blender" }),
317
+ react_1.default.createElement(SegmentedControl, { options: ['multiband', 'feather'], value: settings.blenderType, onChange: (v) => update({ blenderType: v }), caption: "multiband (default): Laplacian-pyramid blending; cleanest seams. feather: faster, no halo when exposure varies." }),
318
+ react_1.default.createElement(SectionHeader, { title: "Batch tuning \u2014 Seam finder" }),
319
+ react_1.default.createElement(SegmentedControl, { options: ['graphcut', 'skip'], value: settings.seamFinderType, onChange: (v) => update({ seamFinderType: v }), caption: "graphcut (default): cv::detail::GraphCutSeamFinder; optimal seams, pairs with multiband, holds all warps in memory. skip: stream warp+feed (lower peak memory)." }),
320
+ react_1.default.createElement(SectionHeader, { title: "Batch tuning \u2014 Inscribed-rect crop" }),
321
+ react_1.default.createElement(SegmentedControl, { options: ['off', 'on'], value: settings.enableMaxInscribedRectCrop ? 'on' : 'off', onChange: (v) => update({ enableMaxInscribedRectCrop: v === 'on' }), caption: "off (default): final crop is just cv::boundingRect of non-black pixels \u2014 preserves all stitched content; may have black corners. on: additionally run MaxInscribedRectFromMask + column-projection second-pass for a clean-cornered rectangle \u2014 can shrink the output if the panorama mask is lopsided. A/B against the bbox crop on real scenes." }))),
322
+ timing === 'realtime' && realtimeAlgorithm === 'hybrid' && (react_1.default.createElement(react_1.default.Fragment, null,
323
+ react_1.default.createElement(SectionHeader, { title: "Hybrid tuning \u2014 Projection" }),
324
+ react_1.default.createElement(SegmentedControl, { options: ['Planar', 'Cylindrical'], value: settings.hybridProjection, onChange: (v) => update({ hybridProjection: v }), caption: "Planar (default): cv::detail::PlaneWarper. Cylindrical: V12.x \u2013 V14.0a behaviour (legacy)." }))),
325
+ timing === 'realtime' && realtimeAlgorithm.startsWith('slitscan') && (react_1.default.createElement(react_1.default.Fragment, null,
326
+ react_1.default.createElement(SectionHeader, { title: "Slit-scan tuning \u2014 Slit width" }),
327
+ react_1.default.createElement(SegmentedControl, { options: ['0.01', '0.05', '0.10', '0.20', '0.30', '0.50'], value: settings.slitWidthFraction.toFixed(2), onChange: (v) => update({ slitWidthFraction: parseFloat(v) }), caption: "Fraction of pan-axis retained per sliver. 0.30 (V15 default) \u2248 324 px. Smaller = less within-slit depth disagreement." }),
328
+ react_1.default.createElement(SectionHeader, { title: "Slit-scan tuning \u2014 Sliver position" }),
329
+ react_1.default.createElement(SegmentedControl, { options: ['Center', 'Bottom', 'Top'], value: settings.sliverPosition, onChange: (v) => update({ sliverPosition: v }), caption: "Where on the camera sensor frame the sliver is taken." }),
330
+ react_1.default.createElement(SectionHeader, { title: "Slit-scan tuning \u2014 Full first-frame" }),
331
+ react_1.default.createElement(SegmentedControl, { options: ['off', 'on'], value: settings.firstFrameFullFrame ? 'on' : 'off', onChange: (v) => update({ firstFrameFullFrame: v === 'on' }), caption: "ON: first accepted frame paints the full camera frame at the canvas anchor; subsequent frames use sliver clip." }),
332
+ react_1.default.createElement(SectionHeader, { title: "Slit-scan tuning \u2014 Paint mode" }),
333
+ react_1.default.createElement(SegmentedControl, { options: ['FirstPaintedWins', 'FeatherBlend'], value: settings.paintMode, onChange: (v) => update({ paintMode: v }), caption: "FirstPaintedWins (default): protect already-painted pixels. FeatherBlend: alpha-blend new content into overlap." }))),
334
+ react_1.default.createElement(Accordion, { title: "Advanced \u2014 2D NCC fine-alignment", badge: "advanced" },
335
+ react_1.default.createElement(SectionHeader, { title: "Enable 2D NCC" }),
336
+ react_1.default.createElement(SegmentedControl, { options: ['off', 'on'], value: settings.enable2dNcc ? 'on' : 'off', onChange: (v) => update({ enable2dNcc: v === 'on' }), caption: "V13.0g 2D NCC fine-alignment after pose-driven projection. Refines (\u0394x, \u0394y) translation via cv::matchTemplate." }),
337
+ settings.enable2dNcc && (react_1.default.createElement(react_1.default.Fragment, null,
338
+ react_1.default.createElement(SectionHeader, { title: "Confidence threshold" }),
339
+ react_1.default.createElement(SegmentedControl, { options: ['0.50', '0.65', '0.75', '0.85', '0.95', '0.99'], value: settings.nccConfidenceThreshold2d.toFixed(2), onChange: (v) => update({ nccConfidenceThreshold2d: parseFloat(v) }), caption: "Reject NCC corrections below this confidence. 0.99 = only apply on near-perfect overlap." }),
340
+ react_1.default.createElement(SectionHeader, { title: "Search half-window (px)" }),
341
+ react_1.default.createElement(SegmentedControl, { options: ['6', '10', '12', '20', '30'], value: String(settings.nccSearchMargin2d), onChange: (v) => update({ nccSearchMargin2d: parseInt(v, 10) }), caption: "Pixels: 2D NCC searches \u00B1this around the pose-predicted match." }),
342
+ react_1.default.createElement(SectionHeader, { title: "EMA smoothing" }),
343
+ react_1.default.createElement(SegmentedControl, { options: ['off', 'on'], value: settings.enableNcc2dEmaSmoothing ? 'on' : 'off', onChange: (v) => update({ enableNcc2dEmaSmoothing: v === 'on' }), caption: "Damp single-frame snaps to spurious peaks via EMA." }),
344
+ settings.enableNcc2dEmaSmoothing && (react_1.default.createElement(react_1.default.Fragment, null,
345
+ react_1.default.createElement(SectionHeader, { title: "EMA alpha (current-frame weight)" }),
346
+ react_1.default.createElement(SegmentedControl, { options: ['0.20', '0.30', '0.40', '0.60', '0.80'], value: settings.ncc2dEmaAlpha.toFixed(2), onChange: (v) => update({ ncc2dEmaAlpha: parseFloat(v) }) }))),
347
+ react_1.default.createElement(SectionHeader, { title: "Pan-axis lock" }),
348
+ react_1.default.createElement(SegmentedControl, { options: ['off', 'on'], value: settings.enableNcc2dPanAxisLock ? 'on' : 'off', onChange: (v) => update({ enableNcc2dPanAxisLock: v === 'on' }), caption: "Clamp cross-axis correction tighter than pan-axis (pose + 1D NCC handle cross-axis already)." }),
349
+ settings.enableNcc2dPanAxisLock && (react_1.default.createElement(react_1.default.Fragment, null,
350
+ react_1.default.createElement(SectionHeader, { title: "Cross-axis clamp (px)" }),
351
+ react_1.default.createElement(SegmentedControl, { options: ['2', '5', '10', '15'], value: String(settings.ncc2dCrossAxisLockPx), onChange: (v) => update({ ncc2dCrossAxisLockPx: parseInt(v, 10) }) })))))),
352
+ timing === 'realtime' && realtimeAlgorithm === 'slitscan-both' && (react_1.default.createElement(Accordion, { title: "Advanced \u2014 Slit-scan experimental", badge: "experimental" },
353
+ react_1.default.createElement(SectionHeader, { title: "Triangulation parallax" }),
354
+ react_1.default.createElement(SegmentedControl, { options: ['off', 'on'], value: settings.enableTriangulation ? 'on' : 'off', onChange: (v) => update({ enableTriangulation: v === 'on' }), caption: "V13.0e ORB triangulation + median-Z parallax correction. Adds ~10ms/accept." }),
355
+ react_1.default.createElement(SectionHeader, { title: "RANSAC homography" }),
356
+ react_1.default.createElement(SegmentedControl, { options: ['off', 'on'], value: settings.enableRansacHomography ? 'on' : 'off', onChange: (v) => update({ enableRansacHomography: v === 'on' }), caption: "V14.0a RANSAC homography per slit + cv::warpPerspective. Known limitation: can absorb pan as scale, leaving gaps." }),
357
+ react_1.default.createElement(SectionHeader, { title: "Accept gate (px)" }),
358
+ react_1.default.createElement(SegmentedControl, { options: ['0', '50'], value: String(settings.acceptGate), onChange: (v) => update({ acceptGate: parseInt(v, 10) }), caption: "0 = accept on every frame (Apple-dense). 50 = V13.0g throttle." }))),
359
+ react_1.default.createElement(SectionHeader, { title: "Recording cap" }),
360
+ react_1.default.createElement(SegmentedControl, { options: ['4 s', '6 s', '8 s', '10 s'], value: `${Math.round(settings.maxRecordingMs / 1000)} s`, onChange: (v) => update({ maxRecordingMs: parseInt(v, 10) * 1000 }), caption: "Auto-stops the hold-recording at this duration." }),
361
+ react_1.default.createElement(SectionHeader, { title: "JPEG quality" }),
362
+ react_1.default.createElement(SegmentedControl, { options: ['70', '85', '92'], value: String(settings.quality), onChange: (v) => update({ quality: parseInt(v, 10) }), caption: "Higher = bigger files, sharper detail. 85 is the recommended default." }),
363
+ react_1.default.createElement(SectionHeader, { title: "Debug" }),
364
+ react_1.default.createElement(SegmentedControl, { options: ['off', 'on'], value: settings.debug ? 'on' : 'off', onChange: (v) => update({ debug: v === 'on' }), caption: "When ON, every successful stitch shows a popup with the C+D progressive-confidence retry telemetry (frames included, final threshold, retry attempts). Off by default." }),
365
+ react_1.default.createElement(Accordion, { title: "Diagnostics / fallbacks", badge: "rarely needed" },
366
+ react_1.default.createElement(react_native_1.View, { style: styles.infoBox },
367
+ react_1.default.createElement(react_native_1.Text, { style: styles.infoText }, "AR-backed capture is the recommended path. Toggle off ONLY if ARKit fails on a specific device (very rare on modern iPhones). Doing so falls back to vision-camera video recording + post-stitch via cv::Stitcher.")),
368
+ react_1.default.createElement(SectionHeader, { title: "AR-backed capture" }),
369
+ react_1.default.createElement(SegmentedControl, { options: ['on', 'off'], value: settings.useARPreview ? 'on' : 'off', onChange: (v) => update({ useARPreview: v === 'on' }), caption: "Default ON. OFF only when ARKit is unavailable or for A/B testing." }),
370
+ !settings.useARPreview && (react_1.default.createElement(react_1.default.Fragment, null,
371
+ react_1.default.createElement(SectionHeader, { title: "Frame extraction \u2014 Frames per second" }),
372
+ react_1.default.createElement(SegmentedControl, { options: ['2', '3', '4'], value: String(settings.framesPerSecond), onChange: (v) => update({ framesPerSecond: parseInt(v, 10) }), caption: "Frames/sec extracted from the recorded video. Lower = faster but riskier overlap." }),
373
+ react_1.default.createElement(SectionHeader, { title: "Frame extraction \u2014 Frame count clamp" }),
374
+ react_1.default.createElement(SegmentedControl, { options: ['4-12', '6-16', '8-20'], value: `${settings.minFrames}-${settings.maxFrames}`, onChange: (v) => {
375
+ const [min, max] = v.split('-').map((n) => parseInt(n, 10));
376
+ update({ minFrames: min, maxFrames: max });
377
+ }, caption: "Floor/ceiling for extracted frames." })))),
378
+ react_1.default.createElement(react_native_1.Pressable, { onPress: () => onChange(exports.DEFAULT_PANORAMA_SETTINGS), style: styles.resetBtn, accessibilityRole: "button", accessibilityLabel: "Reset to defaults" },
379
+ react_1.default.createElement(react_native_1.Text, { style: styles.resetText }, "Reset to defaults")))))));
380
+ }
381
+ function SectionHeader({ title }) {
382
+ return react_1.default.createElement(react_native_1.Text, { style: styles.sectionHeader }, title);
383
+ }
384
+ /**
385
+ * Collapsible section. Used for closed-by-default groupings
386
+ * ("Advanced", "Diagnostics / fallbacks") so the modal's primary
387
+ * surface stays focused on the controls operators actually touch
388
+ * day-to-day.
389
+ *
390
+ * State is local — each Accordion instance manages its own open
391
+ * flag. The modal opens fresh-collapsed every mount which is what
392
+ * we want for now; persisting open state across mounts (e.g. via
393
+ * AsyncStorage) is a future enhancement.
394
+ */
395
+ function Accordion({ title, initiallyOpen = false, badge, children, }) {
396
+ const [open, setOpen] = (0, react_1.useState)(initiallyOpen);
397
+ return (react_1.default.createElement(react_native_1.View, { style: styles.accordion },
398
+ react_1.default.createElement(react_native_1.Pressable, { onPress: () => setOpen((v) => !v), style: styles.accordionHeader, accessibilityRole: "button", accessibilityState: { expanded: open }, accessibilityLabel: `${title}, ${open ? 'expanded' : 'collapsed'}` },
399
+ react_1.default.createElement(react_native_1.Text, { style: styles.accordionChevron }, open ? '▼' : '▶'),
400
+ react_1.default.createElement(react_native_1.Text, { style: styles.accordionTitle }, title),
401
+ badge ? react_1.default.createElement(Tag, { label: badge }) : null),
402
+ open ? react_1.default.createElement(react_native_1.View, { style: styles.accordionBody }, children) : null));
403
+ }
404
+ /**
405
+ * Small grey-text badge. Marks sections / fields as "advanced",
406
+ * "experimental", "legacy", or similar — quick visual signal that
407
+ * the operator can usually ignore them.
408
+ */
409
+ function Tag({ label }) {
410
+ return (react_1.default.createElement(react_native_1.View, { style: styles.tag },
411
+ react_1.default.createElement(react_native_1.Text, { style: styles.tagText }, label)));
412
+ }
413
+ function SegmentedControl({ options, value, onChange, caption, }) {
414
+ return (react_1.default.createElement(react_native_1.View, null,
415
+ react_1.default.createElement(react_native_1.View, { style: styles.segmentedRow }, options.map((opt) => {
416
+ const selected = opt === value;
417
+ return (react_1.default.createElement(react_native_1.Pressable, { key: opt, onPress: () => onChange(opt), style: [
418
+ styles.segment,
419
+ selected && styles.segmentSelected,
420
+ ], accessibilityRole: "button", accessibilityState: { selected }, accessibilityLabel: `${opt}${selected ? ' (selected)' : ''}` },
421
+ react_1.default.createElement(react_native_1.Text, { style: [
422
+ styles.segmentText,
423
+ selected && styles.segmentTextSelected,
424
+ ] }, opt)));
425
+ })),
426
+ caption ? react_1.default.createElement(react_native_1.Text, { style: styles.caption }, caption) : null));
427
+ }
428
+ const styles = react_native_1.StyleSheet.create({
429
+ backdrop: {
430
+ flex: 1,
431
+ backgroundColor: 'rgba(0,0,0,0.55)',
432
+ justifyContent: 'flex-end',
433
+ },
434
+ sheet: {
435
+ backgroundColor: '#1c1c1e',
436
+ borderTopLeftRadius: 16,
437
+ borderTopRightRadius: 16,
438
+ paddingBottom: 32,
439
+ maxHeight: '88%',
440
+ },
441
+ header: {
442
+ flexDirection: 'row',
443
+ alignItems: 'center',
444
+ justifyContent: 'space-between',
445
+ paddingHorizontal: 20,
446
+ paddingVertical: 16,
447
+ borderBottomWidth: react_native_1.StyleSheet.hairlineWidth,
448
+ borderBottomColor: 'rgba(255,255,255,0.15)',
449
+ },
450
+ title: {
451
+ color: '#ffffff',
452
+ fontSize: 18,
453
+ fontWeight: '600',
454
+ },
455
+ closeBtn: {
456
+ width: 36,
457
+ height: 36,
458
+ borderRadius: 18,
459
+ backgroundColor: 'rgba(255,255,255,0.12)',
460
+ alignItems: 'center',
461
+ justifyContent: 'center',
462
+ },
463
+ closeText: {
464
+ color: '#ffffff',
465
+ fontSize: 22,
466
+ fontWeight: '300',
467
+ lineHeight: 24,
468
+ },
469
+ body: {
470
+ paddingHorizontal: 20,
471
+ paddingTop: 12,
472
+ },
473
+ sectionHeader: {
474
+ color: '#ffffff',
475
+ opacity: 0.85,
476
+ fontSize: 13,
477
+ fontWeight: '600',
478
+ textTransform: 'uppercase',
479
+ letterSpacing: 0.5,
480
+ marginTop: 18,
481
+ marginBottom: 8,
482
+ },
483
+ row: {
484
+ marginTop: 4,
485
+ },
486
+ label: {
487
+ color: '#ffffff',
488
+ opacity: 0.85,
489
+ fontSize: 13,
490
+ fontWeight: '600',
491
+ textTransform: 'uppercase',
492
+ letterSpacing: 0.5,
493
+ marginTop: 18,
494
+ marginBottom: 8,
495
+ },
496
+ segmentedRow: {
497
+ flexDirection: 'row',
498
+ backgroundColor: 'rgba(255,255,255,0.08)',
499
+ borderRadius: 10,
500
+ padding: 4,
501
+ gap: 4,
502
+ },
503
+ segment: {
504
+ flex: 1,
505
+ paddingVertical: 10,
506
+ paddingHorizontal: 8,
507
+ borderRadius: 7,
508
+ alignItems: 'center',
509
+ justifyContent: 'center',
510
+ },
511
+ segmentSelected: {
512
+ backgroundColor: '#ffffff',
513
+ },
514
+ segmentText: {
515
+ color: '#ffffff',
516
+ fontSize: 13,
517
+ fontWeight: '500',
518
+ opacity: 0.85,
519
+ },
520
+ segmentTextSelected: {
521
+ color: '#000000',
522
+ fontWeight: '700',
523
+ opacity: 1,
524
+ },
525
+ caption: {
526
+ color: 'rgba(255,255,255,0.55)',
527
+ fontSize: 11,
528
+ marginTop: 6,
529
+ lineHeight: 16,
530
+ },
531
+ debugLine: {
532
+ color: 'rgba(255,200,0,0.85)',
533
+ fontFamily: 'Menlo',
534
+ fontSize: 10,
535
+ paddingVertical: 8,
536
+ paddingHorizontal: 6,
537
+ backgroundColor: 'rgba(255,200,0,0.08)',
538
+ borderRadius: 6,
539
+ marginBottom: 4,
540
+ },
541
+ resetBtn: {
542
+ marginTop: 28,
543
+ paddingVertical: 12,
544
+ borderRadius: 8,
545
+ borderWidth: 1,
546
+ borderColor: 'rgba(255,255,255,0.25)',
547
+ alignItems: 'center',
548
+ },
549
+ resetText: {
550
+ color: '#ffffff',
551
+ fontSize: 14,
552
+ fontWeight: '500',
553
+ },
554
+ // V16 Phase 1b — Accordion + Tag + InfoBox
555
+ accordion: {
556
+ marginTop: 18,
557
+ backgroundColor: 'rgba(255,255,255,0.04)',
558
+ borderRadius: 8,
559
+ overflow: 'hidden',
560
+ },
561
+ accordionHeader: {
562
+ flexDirection: 'row',
563
+ alignItems: 'center',
564
+ paddingVertical: 12,
565
+ paddingHorizontal: 12,
566
+ gap: 8,
567
+ },
568
+ accordionChevron: {
569
+ color: 'rgba(255,255,255,0.5)',
570
+ fontSize: 11,
571
+ width: 14,
572
+ },
573
+ accordionTitle: {
574
+ color: '#ffffff',
575
+ opacity: 0.85,
576
+ fontSize: 13,
577
+ fontWeight: '600',
578
+ textTransform: 'uppercase',
579
+ letterSpacing: 0.5,
580
+ flex: 1,
581
+ },
582
+ accordionBody: {
583
+ paddingHorizontal: 12,
584
+ paddingBottom: 14,
585
+ },
586
+ tag: {
587
+ backgroundColor: 'rgba(255,255,255,0.12)',
588
+ paddingHorizontal: 6,
589
+ paddingVertical: 2,
590
+ borderRadius: 4,
591
+ },
592
+ tagText: {
593
+ color: 'rgba(255,255,255,0.7)',
594
+ fontSize: 9,
595
+ fontWeight: '600',
596
+ textTransform: 'uppercase',
597
+ letterSpacing: 0.5,
598
+ },
599
+ infoBox: {
600
+ backgroundColor: 'rgba(255,255,255,0.06)',
601
+ borderRadius: 8,
602
+ padding: 12,
603
+ marginTop: 4,
604
+ },
605
+ infoText: {
606
+ color: 'rgba(255,255,255,0.75)',
607
+ fontSize: 12,
608
+ lineHeight: 17,
609
+ },
610
+ });
611
+ //# sourceMappingURL=PanoramaSettingsModal.js.map