react-native-image-stitcher 0.14.2 → 0.15.1

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 (120) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +35 -0
  3. package/RNImageStitcher.podspec +8 -7
  4. package/android/build.gradle +0 -16
  5. package/android/src/main/cpp/CMakeLists.txt +2 -63
  6. package/android/src/main/cpp/image_stitcher_jni.cpp +14 -0
  7. package/android/src/main/cpp/keyframe_gate_jni.cpp +13 -0
  8. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +285 -3
  9. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +180 -1162
  10. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +29 -0
  11. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +0 -4
  12. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +129 -71
  13. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +49 -0
  14. package/cpp/keyframe_gate.cpp +82 -23
  15. package/cpp/keyframe_gate.hpp +31 -2
  16. package/cpp/stitcher.cpp +208 -28
  17. package/cpp/tests/CMakeLists.txt +18 -12
  18. package/cpp/tests/keyframe_timebudget_test.cpp +65 -0
  19. package/cpp/tests/warp_guard_test.cpp +48 -0
  20. package/cpp/warp_guard.hpp +41 -0
  21. package/dist/camera/Camera.d.ts +31 -16
  22. package/dist/camera/Camera.js +11 -3
  23. package/dist/camera/CameraView.js +93 -3
  24. package/dist/camera/CaptureStitchStatsToast.d.ts +15 -2
  25. package/dist/camera/CaptureStitchStatsToast.js +27 -7
  26. package/dist/camera/PanoramaSettings.d.ts +10 -223
  27. package/dist/camera/PanoramaSettings.js +6 -28
  28. package/dist/camera/PanoramaSettingsBridge.d.ts +1 -24
  29. package/dist/camera/PanoramaSettingsBridge.js +3 -102
  30. package/dist/camera/PanoramaSettingsModal.js +7 -1
  31. package/dist/camera/buildPanoramaInitialSettings.d.ts +11 -0
  32. package/dist/camera/buildPanoramaInitialSettings.js +4 -0
  33. package/dist/camera/cameraErrorMessages.d.ts +32 -0
  34. package/dist/camera/cameraErrorMessages.js +53 -0
  35. package/dist/camera/selectCaptureDevice.d.ts +5 -1
  36. package/dist/camera/selectCaptureDevice.js +22 -2
  37. package/dist/camera/useCapture.js +38 -0
  38. package/dist/index.d.ts +5 -8
  39. package/dist/index.js +11 -34
  40. package/dist/stitching/incremental.d.ts +1 -117
  41. package/dist/stitching/stitchVideo.d.ts +0 -35
  42. package/dist/types.d.ts +0 -87
  43. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +96 -674
  44. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +9 -12
  45. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +14 -0
  46. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +7 -0
  47. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +6 -0
  48. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +2 -2
  49. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +3 -3
  50. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +28 -60
  51. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +180 -921
  52. package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +82 -7
  53. package/ios/Sources/RNImageStitcher/RNSARSession.swift +10 -35
  54. package/ios/Sources/RNImageStitcher/Stitcher.swift +84 -35
  55. package/ios/Sources/RNImageStitcher/StitcherBridge.m +13 -0
  56. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +132 -5
  57. package/package.json +3 -2
  58. package/src/camera/Camera.tsx +44 -23
  59. package/src/camera/CameraView.tsx +113 -4
  60. package/src/camera/CaptureStitchStatsToast.tsx +58 -14
  61. package/src/camera/PanoramaSettings.ts +16 -289
  62. package/src/camera/PanoramaSettingsBridge.ts +3 -114
  63. package/src/camera/PanoramaSettingsModal.tsx +14 -1
  64. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +3 -188
  65. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +41 -0
  66. package/src/camera/__tests__/cameraErrorMessages.test.ts +76 -0
  67. package/src/camera/__tests__/selectCaptureDevice.test.ts +33 -0
  68. package/src/camera/buildPanoramaInitialSettings.ts +17 -0
  69. package/src/camera/cameraErrorMessages.ts +84 -0
  70. package/src/camera/selectCaptureDevice.ts +28 -3
  71. package/src/camera/useCapture.ts +44 -1
  72. package/src/index.ts +11 -40
  73. package/src/stitching/incremental.ts +3 -140
  74. package/src/stitching/stitchVideo.ts +0 -26
  75. package/src/types.ts +0 -95
  76. package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +0 -227
  77. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +0 -1081
  78. package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +0 -103
  79. package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +0 -256
  80. package/cpp/stitcher_frame_jsi.cpp +0 -214
  81. package/cpp/stitcher_frame_jsi.hpp +0 -108
  82. package/cpp/stitcher_proxy_jsi.cpp +0 -109
  83. package/cpp/stitcher_proxy_jsi.hpp +0 -46
  84. package/cpp/stitcher_worklet_dispatch.cpp +0 -103
  85. package/cpp/stitcher_worklet_dispatch.hpp +0 -71
  86. package/cpp/stitcher_worklet_registry.cpp +0 -91
  87. package/cpp/stitcher_worklet_registry.hpp +0 -146
  88. package/cpp/tests/stitcher_worklet_registry_test.cpp +0 -195
  89. package/dist/stitching/IncrementalStitcherView.d.ts +0 -41
  90. package/dist/stitching/IncrementalStitcherView.js +0 -157
  91. package/dist/stitching/StitcherWorkletRegistry.d.ts +0 -117
  92. package/dist/stitching/StitcherWorkletRegistry.js +0 -78
  93. package/dist/stitching/ensureStitcherProxyInstalled.d.ts +0 -8
  94. package/dist/stitching/ensureStitcherProxyInstalled.js +0 -81
  95. package/dist/stitching/useFrameProcessor.d.ts +0 -119
  96. package/dist/stitching/useFrameProcessor.js +0 -196
  97. package/dist/stitching/useFrameStream.d.ts +0 -34
  98. package/dist/stitching/useFrameStream.js +0 -234
  99. package/dist/stitching/useThrottledFrameProcessor.d.ts +0 -33
  100. package/dist/stitching/useThrottledFrameProcessor.js +0 -132
  101. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +0 -474
  102. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +0 -1328
  103. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +0 -103
  104. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +0 -3285
  105. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +0 -128
  106. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +0 -313
  107. package/ios/Sources/RNImageStitcher/SaveFrameAsJpegPlugin.mm +0 -185
  108. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.h +0 -60
  109. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.mm +0 -214
  110. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.h +0 -42
  111. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +0 -160
  112. package/src/stitching/IncrementalStitcherView.tsx +0 -198
  113. package/src/stitching/StitcherWorkletRegistry.ts +0 -156
  114. package/src/stitching/__tests__/StitcherWorkletRegistry.test.ts +0 -176
  115. package/src/stitching/__tests__/ensureStitcherProxyInstalled.test.ts +0 -94
  116. package/src/stitching/__tests__/useThrottledFrameProcessor.test.ts +0 -178
  117. package/src/stitching/ensureStitcherProxyInstalled.ts +0 -141
  118. package/src/stitching/useFrameProcessor.ts +0 -226
  119. package/src/stitching/useFrameStream.ts +0 -271
  120. package/src/stitching/useThrottledFrameProcessor.ts +0 -145
@@ -44,8 +44,6 @@
44
44
  import {
45
45
  DEFAULT_FLOW_GATE_SETTINGS,
46
46
  type PanoramaSettings,
47
- type SlitscanSettings,
48
- type HybridSettings,
49
47
  } from './PanoramaSettings';
50
48
 
51
49
 
@@ -86,6 +84,9 @@ export function panoramaSettingsToNativeConfig(
86
84
  frameSelectionMode: s.frameSelection.mode,
87
85
  keyframeMaxCount: s.frameSelection.maxKeyframes,
88
86
  keyframeOverlapThreshold: s.frameSelection.overlapThreshold,
87
+ // Time-budget force-accept (both strategies). Native reads
88
+ // configOverrides["maxKeyframeIntervalMs"] → setMaxKeyframeIntervalMs.
89
+ maxKeyframeIntervalMs: s.frameSelection.maxKeyframeIntervalMs,
89
90
  };
90
91
 
91
92
  // Flow strategy knobs — always serialised, regardless of
@@ -124,115 +125,3 @@ export function panoramaSettingsToNativeConfig(
124
125
 
125
126
  return cfg;
126
127
  }
127
-
128
-
129
- /**
130
- * Convert a v0.4 SlitscanSettings tree into the flat dict the
131
- * slit-scan / firstwins native engines read. Handles the
132
- * "presence-as-enable" boolean expansion: a non-undefined
133
- * `registration.ncc1d` means `enable1dNcc: true` on the wire,
134
- * with the sub-object's `searchRadius` carried alongside.
135
- *
136
- * Verified against:
137
- * - iOS `IncrementalStitcher.swift:1006-1100` (applyConfigOverrides)
138
- * - iOS `OpenCVSlitScanStitcher.mm` (all numbered references in
139
- * the audit ground-truth matrix)
140
- */
141
- export function slitscanSettingsToNativeConfig(
142
- s: SlitscanSettings,
143
- ): NativeConfigDict {
144
- const cfg: NativeConfigDict = {
145
- captureSource: s.captureSource,
146
- // The native side reads `engine: 'slitscan-…'` at start time
147
- // from a separate top-level field, NOT from configOverrides.
148
- // We still serialise the variant here for hosts that want to
149
- // round-trip a single settings object through both surfaces.
150
- engineVariant: s.variant,
151
-
152
- // ── Painting ─────────────────────────────────────────────────
153
- paintMode: s.painting.paintMode,
154
- sliverPosition: s.painting.sliverPosition,
155
- firstFrameFullFrame: s.painting.firstFrameFullFrame,
156
-
157
- // ── Registration (explicit booleans) ─────────────────────────
158
- enableTriangulation: s.registration.enableTriangulation,
159
- enableTriAccumulator: s.registration.enableTriAccumulator,
160
- enableRansacHomography: s.registration.enableRansacHomography,
161
-
162
- // ── Plane projection ─────────────────────────────────────────
163
- planeSource: s.plane.source,
164
- };
165
-
166
- // ── 1D NCC: presence-as-enable ─────────────────────────────────
167
- if (s.registration.ncc1d) {
168
- cfg.enable1dNcc = true;
169
- cfg.nccSearchRadius1d = s.registration.ncc1d.searchRadius;
170
- } else {
171
- cfg.enable1dNcc = false;
172
- }
173
-
174
- // ── 2D NCC: presence-as-enable + nested optionals ──────────────
175
- if (s.registration.ncc2d) {
176
- const n2 = s.registration.ncc2d;
177
- cfg.enable2dNcc = true;
178
- cfg.nccSearchMargin2d = n2.searchMargin;
179
- cfg.nccConfidenceThreshold2d = n2.confidenceThreshold;
180
- if (n2.emaSmoothing) {
181
- cfg.enableNcc2dEmaSmoothing = true;
182
- cfg.ncc2dEmaAlpha = n2.emaSmoothing.alpha;
183
- } else {
184
- cfg.enableNcc2dEmaSmoothing = false;
185
- }
186
- if (n2.panAxisLock) {
187
- cfg.enableNcc2dPanAxisLock = true;
188
- cfg.ncc2dCrossAxisLockPx = n2.panAxisLock.crossAxisLockPx;
189
- } else {
190
- cfg.enableNcc2dPanAxisLock = false;
191
- }
192
- } else {
193
- cfg.enable2dNcc = false;
194
- }
195
-
196
- // ── Plane optionals ────────────────────────────────────────────
197
- // Only emit when `source` actually consumes the field. Native
198
- // tolerates unsolicited keys but the modal also walks the dict
199
- // to decide which sliders to render — extra keys would mislead.
200
- if (s.plane.source !== 'Disabled' && s.plane.projectionStyle !== undefined) {
201
- cfg.planeProjectionStyle = s.plane.projectionStyle;
202
- }
203
- if (s.plane.source === 'Virtual' && s.plane.virtualDepthMeters !== undefined) {
204
- cfg.virtualPlaneDepthMeters = s.plane.virtualDepthMeters;
205
- }
206
- if (s.plane.source === 'ARKitDetected' && s.plane.alignmentThreshold !== undefined) {
207
- cfg.arkitPlaneAlignmentThreshold = s.plane.alignmentThreshold;
208
- }
209
-
210
- // ── Advanced motion knobs (only emit if explicitly set) ────────
211
- if (s.advanced?.panAxisFractionRect !== undefined) {
212
- cfg.kPanAxisFractionRect = s.advanced.panAxisFractionRect;
213
- }
214
- if (s.advanced?.minAcceptDeltaPx !== undefined) {
215
- cfg.kMinAcceptDeltaPx = s.advanced.minAcceptDeltaPx;
216
- }
217
-
218
- return cfg;
219
- }
220
-
221
-
222
- /**
223
- * Convert a v0.4 HybridSettings tree into the flat dict the hybrid
224
- * engine reads. Minimal surface — hybrid presets internally clobber
225
- * almost everything; see HybridSettings JSDoc for context.
226
- *
227
- * Verified against:
228
- * - iOS `OpenCVIncrementalStitcher.mm:139-180` (preset paths)
229
- * - iOS `IncrementalStitcher.swift:1034-1040` (hybridProjection override)
230
- */
231
- export function hybridSettingsToNativeConfig(
232
- s: HybridSettings,
233
- ): NativeConfigDict {
234
- return {
235
- captureSource: s.captureSource,
236
- hybridProjection: s.projection,
237
- };
238
- }
@@ -264,6 +264,19 @@ export function PanoramaSettingsModal({
264
264
  })}
265
265
  caption="Required NEW-content fraction. 20% (default): generous, ~5–6 keyframes for a 90° pan. Native clamps to [10%, 80%]."
266
266
  />
267
+ <SectionHeader title="Keyframe interval (time-budget force-accept)" />
268
+ <SegmentedControl
269
+ options={['off', '1s', '2s', '3s', '5s']}
270
+ value={
271
+ settings.frameSelection.maxKeyframeIntervalMs === 0
272
+ ? 'off'
273
+ : `${settings.frameSelection.maxKeyframeIntervalMs / 1000}s`
274
+ }
275
+ onChange={(v) => updateFrameSelection({
276
+ maxKeyframeIntervalMs: v === 'off' ? 0 : parseInt(v, 10) * 1000,
277
+ })}
278
+ caption="Force-accept a keyframe at least this often even if novelty is low, so slow / static pans don't leave gaps. Counts toward the keyframe cap. off = disabled. 2s (default). Applies to AR + non-AR."
279
+ />
267
280
 
268
281
  {showFlowTunables && (
269
282
  <View style={styles.nested}>
@@ -373,7 +386,7 @@ export function PanoramaSettingsModal({
373
386
  onChange={(v) => updateStitcher({
374
387
  enableMaxInscribedRectCrop: v === 'on',
375
388
  })}
376
- caption="off (default): crop to cv::boundingRect of non-black pixels — preserves all stitched content; may leave black corners. on: run MaxInscribedRectFromMask + column-projection second-pass for a clean rectangle (can shrink output if mask is lopsided)."
389
+ caption="off (default): crop to cv::boundingRect of non-black pixels — preserves all stitched content; may leave black corners. on: run MaxInscribedRectFromMask + column-projection second-pass for a clean rectangle (can shrink output a lot if mask is lopsided / ultra-wide)."
377
390
  />
378
391
  </Accordion>
379
392
 
@@ -39,17 +39,11 @@
39
39
 
40
40
  import {
41
41
  DEFAULT_FLOW_GATE_SETTINGS,
42
- DEFAULT_HYBRID_SETTINGS,
43
42
  DEFAULT_PANORAMA_SETTINGS,
44
- DEFAULT_SLITSCAN_SETTINGS,
45
- type HybridSettings,
46
43
  type PanoramaSettings,
47
- type SlitscanSettings,
48
44
  } from '../PanoramaSettings';
49
45
  import {
50
- hybridSettingsToNativeConfig,
51
46
  panoramaSettingsToNativeConfig,
52
- slitscanSettingsToNativeConfig,
53
47
  } from '../PanoramaSettingsBridge';
54
48
 
55
49
 
@@ -75,6 +69,7 @@ describe('panoramaSettingsToNativeConfig', () => {
75
69
  expect(cfg.frameSelectionMode).toBe('flow-based');
76
70
  expect(cfg.keyframeMaxCount).toBe(6);
77
71
  expect(cfg.keyframeOverlapThreshold).toBe(0.2);
72
+ expect(cfg.maxKeyframeIntervalMs).toBe(2000);
78
73
 
79
74
  // FlowGateSettings (flow is defined in the default)
80
75
  expect(cfg.flowNoveltyPercentile).toBe(0.85);
@@ -125,6 +120,7 @@ describe('panoramaSettingsToNativeConfig', () => {
125
120
  mode: 'flow-based',
126
121
  maxKeyframes: 6,
127
122
  overlapThreshold: 0.20,
123
+ maxKeyframeIntervalMs: 2000,
128
124
  // flow omitted — legal per the optional `?` in the type
129
125
  },
130
126
  };
@@ -157,6 +153,7 @@ describe('panoramaSettingsToNativeConfig', () => {
157
153
  'frameSelectionMode',
158
154
  'keyframeMaxCount',
159
155
  'keyframeOverlapThreshold',
156
+ 'maxKeyframeIntervalMs',
160
157
  'seamFinderType',
161
158
  'stitchMode',
162
159
  'warperType',
@@ -191,185 +188,3 @@ describe('panoramaSettingsToNativeConfig', () => {
191
188
  expect(cfg).not.toHaveProperty('debug');
192
189
  });
193
190
  });
194
-
195
-
196
- // ════════════════════════════════════════════════════════════════════
197
- // SLITSCAN — Layer 2 slit-scan engines
198
- // ════════════════════════════════════════════════════════════════════
199
-
200
- describe('slitscanSettingsToNativeConfig', () => {
201
- it('round-trips DEFAULT_SLITSCAN_SETTINGS to the expected flat dict', () => {
202
- const cfg = slitscanSettingsToNativeConfig(DEFAULT_SLITSCAN_SETTINGS);
203
-
204
- expect(cfg.captureSource).toBe('ar');
205
- expect(cfg.engineVariant).toBe('slitscan-rotate');
206
-
207
- // Painting
208
- expect(cfg.paintMode).toBe('FirstPaintedWins');
209
- expect(cfg.sliverPosition).toBe('Bottom');
210
- expect(cfg.firstFrameFullFrame).toBe(true);
211
-
212
- // Registration (explicit booleans)
213
- expect(cfg.enableTriangulation).toBe(false);
214
- expect(cfg.enableTriAccumulator).toBe(false);
215
- expect(cfg.enableRansacHomography).toBe(false);
216
-
217
- // Plane
218
- expect(cfg.planeSource).toBe('ARKitDetected');
219
- expect(cfg.planeProjectionStyle).toBe('Rectified');
220
- expect(cfg.arkitPlaneAlignmentThreshold).toBe(0.6);
221
-
222
- // ncc1d / ncc2d both omitted in defaults
223
- expect(cfg.enable1dNcc).toBe(false);
224
- expect(cfg.enable2dNcc).toBe(false);
225
- expect(cfg).not.toHaveProperty('nccSearchRadius1d');
226
- expect(cfg).not.toHaveProperty('nccSearchMargin2d');
227
- expect(cfg).not.toHaveProperty('nccConfidenceThreshold2d');
228
- expect(cfg).not.toHaveProperty('ncc2dEmaAlpha');
229
- expect(cfg).not.toHaveProperty('ncc2dCrossAxisLockPx');
230
-
231
- // Plane: ARKitDetected — alignmentThreshold present, virtual depth absent
232
- expect(cfg).not.toHaveProperty('virtualPlaneDepthMeters');
233
-
234
- // Advanced: not set in defaults
235
- expect(cfg).not.toHaveProperty('kPanAxisFractionRect');
236
- expect(cfg).not.toHaveProperty('kMinAcceptDeltaPx');
237
- });
238
-
239
- it('expands `registration.ncc1d` presence-as-enable correctly', () => {
240
- const withNcc1d: SlitscanSettings = {
241
- ...DEFAULT_SLITSCAN_SETTINGS,
242
- registration: {
243
- ...DEFAULT_SLITSCAN_SETTINGS.registration,
244
- ncc1d: { searchRadius: 25 },
245
- },
246
- };
247
- const cfg = slitscanSettingsToNativeConfig(withNcc1d);
248
- expect(cfg.enable1dNcc).toBe(true);
249
- expect(cfg.nccSearchRadius1d).toBe(25);
250
- });
251
-
252
- it('expands `registration.ncc2d` presence-as-enable with nested optionals', () => {
253
- const withNcc2dFull: SlitscanSettings = {
254
- ...DEFAULT_SLITSCAN_SETTINGS,
255
- registration: {
256
- ...DEFAULT_SLITSCAN_SETTINGS.registration,
257
- ncc2d: {
258
- searchMargin: 14,
259
- confidenceThreshold: 0.95,
260
- emaSmoothing: { alpha: 0.5 },
261
- panAxisLock: { crossAxisLockPx: 4 },
262
- },
263
- },
264
- };
265
- const cfg = slitscanSettingsToNativeConfig(withNcc2dFull);
266
-
267
- expect(cfg.enable2dNcc).toBe(true);
268
- expect(cfg.nccSearchMargin2d).toBe(14);
269
- expect(cfg.nccConfidenceThreshold2d).toBe(0.95);
270
- expect(cfg.enableNcc2dEmaSmoothing).toBe(true);
271
- expect(cfg.ncc2dEmaAlpha).toBe(0.5);
272
- expect(cfg.enableNcc2dPanAxisLock).toBe(true);
273
- expect(cfg.ncc2dCrossAxisLockPx).toBe(4);
274
- });
275
-
276
- it('honours ncc2d nested-optional absence (ema + panAxisLock undefined)', () => {
277
- const withNcc2dBare: SlitscanSettings = {
278
- ...DEFAULT_SLITSCAN_SETTINGS,
279
- registration: {
280
- ...DEFAULT_SLITSCAN_SETTINGS.registration,
281
- ncc2d: {
282
- searchMargin: 12,
283
- confidenceThreshold: 0.99,
284
- // emaSmoothing + panAxisLock omitted → enable-flag false, no payload
285
- },
286
- },
287
- };
288
- const cfg = slitscanSettingsToNativeConfig(withNcc2dBare);
289
-
290
- expect(cfg.enable2dNcc).toBe(true);
291
- expect(cfg.enableNcc2dEmaSmoothing).toBe(false);
292
- expect(cfg.enableNcc2dPanAxisLock).toBe(false);
293
- // Critical: payload keys for the disabled sub-features must NOT
294
- // ride along — Native engine would treat them as authoritative
295
- // even with the enable flag off (defensive against a native bug).
296
- expect(cfg).not.toHaveProperty('ncc2dEmaAlpha');
297
- expect(cfg).not.toHaveProperty('ncc2dCrossAxisLockPx');
298
- });
299
-
300
- it.each([
301
- ['Disabled', { virtualPlaneDepthMeters: false, arkitPlaneAlignmentThreshold: false, planeProjectionStyle: false }],
302
- ['Virtual', { virtualPlaneDepthMeters: true, arkitPlaneAlignmentThreshold: false, planeProjectionStyle: true }],
303
- ['ARKitDetected', { virtualPlaneDepthMeters: false, arkitPlaneAlignmentThreshold: true, planeProjectionStyle: true }],
304
- ] as const)(
305
- 'emits plane optionals consistent with source=%s',
306
- (source, expected) => {
307
- const s: SlitscanSettings = {
308
- ...DEFAULT_SLITSCAN_SETTINGS,
309
- plane: {
310
- source,
311
- projectionStyle: 'Rectified',
312
- virtualDepthMeters: 2.0,
313
- alignmentThreshold: 0.7,
314
- },
315
- };
316
- const cfg = slitscanSettingsToNativeConfig(s);
317
- expect(cfg.planeSource).toBe(source);
318
- expect('virtualPlaneDepthMeters' in cfg).toBe(expected.virtualPlaneDepthMeters);
319
- expect('arkitPlaneAlignmentThreshold' in cfg).toBe(expected.arkitPlaneAlignmentThreshold);
320
- expect('planeProjectionStyle' in cfg).toBe(expected.planeProjectionStyle);
321
- },
322
- );
323
-
324
- it('emits `advanced` knobs only when explicitly set', () => {
325
- const withAdvanced: SlitscanSettings = {
326
- ...DEFAULT_SLITSCAN_SETTINGS,
327
- advanced: { panAxisFractionRect: 0.6, minAcceptDeltaPx: 30 },
328
- };
329
- const cfg = slitscanSettingsToNativeConfig(withAdvanced);
330
- expect(cfg.kPanAxisFractionRect).toBe(0.6);
331
- expect(cfg.kMinAcceptDeltaPx).toBe(30);
332
-
333
- const onlyOne: SlitscanSettings = {
334
- ...DEFAULT_SLITSCAN_SETTINGS,
335
- advanced: { panAxisFractionRect: 0.6 },
336
- // minAcceptDeltaPx omitted within the sub-object
337
- };
338
- const cfgOne = slitscanSettingsToNativeConfig(onlyOne);
339
- expect(cfgOne.kPanAxisFractionRect).toBe(0.6);
340
- expect(cfgOne).not.toHaveProperty('kMinAcceptDeltaPx');
341
- });
342
- });
343
-
344
-
345
- // ════════════════════════════════════════════════════════════════════
346
- // HYBRID — RetaiLens live engine
347
- // ════════════════════════════════════════════════════════════════════
348
-
349
- describe('hybridSettingsToNativeConfig', () => {
350
- it('round-trips DEFAULT_HYBRID_SETTINGS to the expected flat dict', () => {
351
- const cfg = hybridSettingsToNativeConfig(DEFAULT_HYBRID_SETTINGS);
352
- expect(cfg.captureSource).toBe('ar');
353
- expect(cfg.hybridProjection).toBe('Planar');
354
- });
355
-
356
- it('honours projection override', () => {
357
- const cyl: HybridSettings = {
358
- ...DEFAULT_HYBRID_SETTINGS,
359
- projection: 'Cylindrical',
360
- };
361
- expect(hybridSettingsToNativeConfig(cyl).hybridProjection).toBe('Cylindrical');
362
- });
363
-
364
- it('emits only the documented hybrid surface (debug is JS-only)', () => {
365
- // Hybrid presets internally clobber most fields; the bridge
366
- // deliberately keeps the wire surface minimal. This test guards
367
- // against future drift where someone adds a hybrid setting to the
368
- // bridge without first validating that the engine actually reads it.
369
- const cfg = hybridSettingsToNativeConfig({
370
- ...DEFAULT_HYBRID_SETTINGS,
371
- debug: true, // JS-only, must NOT reach the wire
372
- });
373
- expect(Object.keys(cfg).sort()).toEqual(['captureSource', 'hybridProjection']);
374
- });
375
- });
@@ -61,6 +61,7 @@ describe('buildPanoramaInitialSettings', () => {
61
61
  defaultFlowMaxTranslationCm: 12,
62
62
  defaultKeyframeMaxCount: 8,
63
63
  defaultKeyframeOverlapThreshold: 0.30,
64
+ maxInscribedRectCrop: true,
64
65
  },
65
66
  false,
66
67
  );
@@ -75,6 +76,46 @@ describe('buildPanoramaInitialSettings', () => {
75
76
  expect(s.frameSelection.flow?.maxTranslationCm).toBe(12);
76
77
  expect(s.frameSelection.maxKeyframes).toBe(8);
77
78
  expect(s.frameSelection.overlapThreshold).toBe(0.30);
79
+ expect(s.stitcher.enableMaxInscribedRectCrop).toBe(true);
80
+ });
81
+
82
+ it('maps maxInscribedRectCrop → stitcher.enableMaxInscribedRectCrop', () => {
83
+ expect(
84
+ buildPanoramaInitialSettings({ maxInscribedRectCrop: true }, false)
85
+ .stitcher.enableMaxInscribedRectCrop,
86
+ ).toBe(true);
87
+ expect(
88
+ buildPanoramaInitialSettings({ maxInscribedRectCrop: false }, false)
89
+ .stitcher.enableMaxInscribedRectCrop,
90
+ ).toBe(false);
91
+ // Omitted ⇒ default (false — inscribed-rect crop is opt-in), and the
92
+ // low-mem fallback must not flip it.
93
+ expect(
94
+ buildPanoramaInitialSettings({}, false)
95
+ .stitcher.enableMaxInscribedRectCrop,
96
+ ).toBe(false);
97
+ expect(
98
+ buildPanoramaInitialSettings({}, true)
99
+ .stitcher.enableMaxInscribedRectCrop,
100
+ ).toBe(false);
101
+ });
102
+
103
+ it('maps defaultMaxKeyframeIntervalMs → frameSelection.maxKeyframeIntervalMs', () => {
104
+ expect(
105
+ buildPanoramaInitialSettings({ defaultMaxKeyframeIntervalMs: 3500 }, false)
106
+ .frameSelection.maxKeyframeIntervalMs,
107
+ ).toBe(3500);
108
+ // 0 explicitly disables the time-budget force-accept — it is NOT
109
+ // nullish, so `??` does not replace it with the default.
110
+ expect(
111
+ buildPanoramaInitialSettings({ defaultMaxKeyframeIntervalMs: 0 }, false)
112
+ .frameSelection.maxKeyframeIntervalMs,
113
+ ).toBe(0);
114
+ // Omitted ⇒ the 2000 ms default.
115
+ expect(
116
+ buildPanoramaInitialSettings({}, false)
117
+ .frameSelection.maxKeyframeIntervalMs,
118
+ ).toBe(2000);
78
119
  });
79
120
 
80
121
  it('leaves non-overridden fields at the default (partial override)', () => {
@@ -0,0 +1,76 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Unit tests for the recoverable-stitch-failure guidance map.
4
+ *
5
+ * Guarantees: (1) every recoverable code yields non-empty, plain-language
6
+ * copy with no raw cv::Stitcher diagnostic leaking through; (2) the
7
+ * cause-specific guidance actually names its corrective action; (3) every
8
+ * non-recoverable code returns null so the host falls back to its generic
9
+ * error UI.
10
+ */
11
+ import { userFacingStitchError } from '../cameraErrorMessages';
12
+ import type { CameraErrorCode } from '../Camera';
13
+
14
+ describe('userFacingStitchError', () => {
15
+ const RECOVERABLE: CameraErrorCode[] = [
16
+ 'STITCH_NEED_MORE_IMGS',
17
+ 'STITCH_HOMOGRAPHY_FAIL',
18
+ 'STITCH_CAMERA_PARAMS_FAIL',
19
+ 'STITCH_OOM',
20
+ ];
21
+
22
+ it.each(RECOVERABLE)(
23
+ 'returns non-empty, jargon-free title+message for %s',
24
+ (code) => {
25
+ const r = userFacingStitchError(code);
26
+ expect(r).not.toBeNull();
27
+ expect(r!.title.length).toBeGreaterThan(0);
28
+ expect(r!.message.length).toBeGreaterThan(0);
29
+ // No raw stitcher diagnostics should ever reach the user.
30
+ expect(r!.message).not.toMatch(/warpRoi|cv::|OpenCV|ERR_|StsOutOfRange|estimator/i);
31
+ expect(r!.title).not.toMatch(/cv::|OpenCV|ERR_/i);
32
+ // The title is the corrective ASK (e.g. "Please pan more slowly"),
33
+ // not a generic failure headline.
34
+ expect(r!.title).not.toMatch(/couldn't|can't|error|failed|too large/i);
35
+ },
36
+ );
37
+
38
+ it('camera-params guidance names the 0.5x sensitivity and the 1x fix', () => {
39
+ const r = userFacingStitchError('STITCH_CAMERA_PARAMS_FAIL');
40
+ expect(r).not.toBeNull();
41
+ // The actual root cause (translation) + the actionable lens advice.
42
+ expect(r!.message).toMatch(/0\.5x|ultra-wide/i);
43
+ expect(r!.message).toMatch(/\b1x\b/i);
44
+ expect(r!.message).toMatch(/pivot|turning|one spot|moved|shifted/i);
45
+ });
46
+
47
+ it('need-more-images guidance is about overlap', () => {
48
+ expect(userFacingStitchError('STITCH_NEED_MORE_IMGS')!.message).toMatch(
49
+ /overlap/i,
50
+ );
51
+ });
52
+
53
+ it('oom guidance suggests a shorter sweep', () => {
54
+ expect(userFacingStitchError('STITCH_OOM')!.message).toMatch(
55
+ /shorter|narrower|memory/i,
56
+ );
57
+ });
58
+
59
+ const NON_RECOVERABLE: CameraErrorCode[] = [
60
+ 'CAMERA_PERMISSION_DENIED',
61
+ 'CAMERA_DEVICE_UNAVAILABLE',
62
+ 'PHOTO_CAPTURE_FAILED',
63
+ 'PANORAMA_START_FAILED',
64
+ 'PANORAMA_FINALIZE_FAILED',
65
+ 'OUTPUT_WRITE_FAILED',
66
+ 'VISION_CAMERA_RUNTIME',
67
+ 'UNKNOWN',
68
+ ];
69
+
70
+ it.each(NON_RECOVERABLE)(
71
+ 'returns null for non-recoverable code %s',
72
+ (code) => {
73
+ expect(userFacingStitchError(code)).toBeNull();
74
+ },
75
+ );
76
+ });
@@ -157,6 +157,39 @@ describe('selectCaptureDevice', () => {
157
157
  expect(sel.device).toBe(wideTorch);
158
158
  expect(sel.hasTorch).toBe(true);
159
159
  });
160
+
161
+ it('S24: multicam LISTS ultra-wide but zoom cannot reach it (minZoom~1) + standalone uw swaps', () => {
162
+ // Samsung/Camera2: the logical device lists the ultra-wide but its zoom
163
+ // range starts at 1.0, so zoom cannot reach it. A separate ultra-wide id
164
+ // exists -> keep the multicam for 1x (torch) and swap to the standalone
165
+ // ultra-wide on 0.5x.
166
+ const multicamNoReach = dualWide({ minZoom: 1, hasTorch: true });
167
+ const uw = standaloneUltraWide();
168
+ const sel = selectCaptureDevice([multicamNoReach, uw]);
169
+ expect(sel.mode).toBe('standalone-uw');
170
+ expect(sel.device).toBe(multicamNoReach); // 1x primary keeps the torch
171
+ expect(sel.ultraWideDevice).toBe(uw); // 0.5x swaps to the real ultra-wide
172
+ expect(sel.has0_5x).toBe(true);
173
+ expect(sel.hasTorch).toBe(true);
174
+ });
175
+
176
+ it('multicam lists ultra-wide, zoom cannot reach (minZoom~1), NO standalone uw -> hide', () => {
177
+ // The ultra-wide exists ONLY inside a non-zoomable logical device with no
178
+ // separate id to swap to -> undeliverable -> hide the chooser.
179
+ const multicamNoReach = dualWide({ minZoom: 1 });
180
+ const sel = selectCaptureDevice([multicamNoReach]);
181
+ expect(sel.mode).toBe('wide-only');
182
+ expect(sel.has0_5x).toBe(false);
183
+ expect(sel.ultraWideDevice).toBeNull();
184
+ });
185
+
186
+ it('minZoom threshold: <=0.7 zoom-switches, >0.7 falls through to swap', () => {
187
+ const atThreshold = dualWide({ minZoom: 0.7 });
188
+ expect(selectCaptureDevice([atThreshold]).mode).toBe('multicam');
189
+ const aboveThreshold = dualWide({ minZoom: 0.71 });
190
+ const uw = standaloneUltraWide();
191
+ expect(selectCaptureDevice([aboveThreshold, uw]).mode).toBe('standalone-uw');
192
+ });
160
193
  });
161
194
 
162
195
  describe('zoomForLens (multicam lens→zoom mapping)', () => {
@@ -58,6 +58,17 @@ export interface PanoramaPropOverrides {
58
58
  defaultFlowMaxTranslationCm?: number;
59
59
  defaultKeyframeMaxCount?: number;
60
60
  defaultKeyframeOverlapThreshold?: number;
61
+ /**
62
+ * Initial value for `frameSelection.maxKeyframeIntervalMs` — the
63
+ * time-budget force-accept (ms). `0` disables it. Default 2000.
64
+ */
65
+ defaultMaxKeyframeIntervalMs?: number;
66
+ /**
67
+ * v0.15 — initial value for `stitcher.enableMaxInscribedRectCrop`.
68
+ * Maps from the standalone `maxInscribedRectCrop` <Camera> prop.
69
+ * Omitted ⇒ the stitcher default (false = bounding-rect crop).
70
+ */
71
+ maxInscribedRectCrop?: boolean;
61
72
  }
62
73
 
63
74
 
@@ -113,6 +124,9 @@ export function buildPanoramaInitialSettings(
113
124
  blenderType: overrides.defaultBlender ?? stitcherDefaults.blenderType,
114
125
  seamFinderType:
115
126
  overrides.defaultSeamFinder ?? stitcherDefaults.seamFinderType,
127
+ enableMaxInscribedRectCrop:
128
+ overrides.maxInscribedRectCrop
129
+ ?? stitcherDefaults.enableMaxInscribedRectCrop,
116
130
  },
117
131
 
118
132
  frameSelection: {
@@ -122,6 +136,9 @@ export function buildPanoramaInitialSettings(
122
136
  overlapThreshold:
123
137
  overrides.defaultKeyframeOverlapThreshold
124
138
  ?? base.frameSelection.overlapThreshold,
139
+ maxKeyframeIntervalMs:
140
+ overrides.defaultMaxKeyframeIntervalMs
141
+ ?? base.frameSelection.maxKeyframeIntervalMs,
125
142
  flow: {
126
143
  ...flowDefaults,
127
144
  noveltyPercentile:
@@ -0,0 +1,84 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Friendly, action-guiding copy for the *recoverable* stitch-failure
4
+ * `CameraErrorCode`s — the ones a user can fix by simply re-capturing.
5
+ * Hosts map these onto an Alert/toast instead of surfacing the raw
6
+ * cv::Stitcher diagnostic (e.g. "warpRoi too large (8171x12336) —
7
+ * estimator produced degenerate camera params").
8
+ *
9
+ * Returns `null` for every non-recoverable / non-stitch code (permission
10
+ * denied, device unavailable, generic finalize failure, unknown, ...):
11
+ * those have no single corrective action to suggest, so the host should
12
+ * fall back to its generic error display.
13
+ *
14
+ * Lives in the SDK (not per-host) so every consumer shows the same
15
+ * vetted guidance for the same failure — and so the mapping is
16
+ * unit-testable in isolation.
17
+ */
18
+ import type { CameraErrorCode } from './Camera';
19
+
20
+ export interface UserFacingStitchError {
21
+ /** Short, friendly alert title. */
22
+ title: string;
23
+ /** One-paragraph, plain-language corrective guidance. */
24
+ message: string;
25
+ }
26
+
27
+ /**
28
+ * The four recoverable stitch outcomes, each with copy tuned to its
29
+ * actual root cause. `Partial<Record<...>>` keeps the keys
30
+ * compile-checked against the `CameraErrorCode` union — a renamed or
31
+ * dropped code breaks the build here rather than silently going
32
+ * unhandled.
33
+ */
34
+ const RECOVERABLE_STITCH_GUIDANCE: Partial<
35
+ Record<CameraErrorCode, UserFacingStitchError>
36
+ > = {
37
+ // cv::Stitcher ERR_NEED_MORE_IMGS / the manual pipeline's "0 valid
38
+ // pairwise matches" — the frames simply don't overlap enough to chain.
39
+ STITCH_NEED_MORE_IMGS: {
40
+ title: 'Please pan more slowly',
41
+ message:
42
+ "There wasn't enough overlap between the frames to stitch them "
43
+ + 'together — each frame needs to overlap the one before it.',
44
+ },
45
+ // Bundle adjuster produced degenerate camera params (the warp canvas
46
+ // blew past the size guard) — almost always real camera *translation*
47
+ // breaking PANORAMA mode's pure-rotation assumption, amplified hugely
48
+ // on the ultra-wide lens.
49
+ STITCH_CAMERA_PARAMS_FAIL: {
50
+ title: 'Please pan more slowly',
51
+ message:
52
+ 'The view moved too much between frames to line them up — usually '
53
+ + 'because the phone moved through space rather than just turning. '
54
+ + 'The ultra-wide (0.5x) lens is especially sensitive to this, so '
55
+ + 'try 1x for wide scenes.',
56
+ },
57
+ // Pairwise homography estimation failed — frames couldn't be aligned.
58
+ STITCH_HOMOGRAPHY_FAIL: {
59
+ title: 'Please pan more slowly',
60
+ message:
61
+ "The frames couldn't be aligned — keep the phone level and steady so "
62
+ + 'each frame overlaps the one before it.',
63
+ },
64
+ // Ran out of memory finishing the stitch — usually an over-long sweep.
65
+ STITCH_OOM: {
66
+ title: 'Try a shorter sweep',
67
+ message:
68
+ 'This panorama needs more memory than the device can spare to finish '
69
+ + '— a shorter, narrower sweep (or 1x for wide scenes) will fit.',
70
+ },
71
+ };
72
+
73
+ /**
74
+ * Maps a `CameraErrorCode` to friendly, action-guiding alert copy.
75
+ *
76
+ * @returns the title+message for a recoverable stitch failure, or `null`
77
+ * if `code` has no single user-recoverable action (the host should
78
+ * then show its generic error UI).
79
+ */
80
+ export function userFacingStitchError(
81
+ code: CameraErrorCode,
82
+ ): UserFacingStitchError | null {
83
+ return RECOVERABLE_STITCH_GUIDANCE[code] ?? null;
84
+ }