react-native-image-stitcher 0.14.2 → 0.15.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 (116) hide show
  1. package/CHANGELOG.md +131 -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 +13 -64
  13. package/cpp/keyframe_gate.cpp +82 -23
  14. package/cpp/keyframe_gate.hpp +31 -2
  15. package/cpp/stitcher.cpp +208 -28
  16. package/cpp/tests/CMakeLists.txt +18 -12
  17. package/cpp/tests/keyframe_timebudget_test.cpp +65 -0
  18. package/cpp/tests/warp_guard_test.cpp +48 -0
  19. package/cpp/warp_guard.hpp +41 -0
  20. package/dist/camera/Camera.d.ts +31 -16
  21. package/dist/camera/Camera.js +10 -2
  22. package/dist/camera/CaptureStitchStatsToast.d.ts +15 -2
  23. package/dist/camera/CaptureStitchStatsToast.js +27 -7
  24. package/dist/camera/PanoramaSettings.d.ts +10 -223
  25. package/dist/camera/PanoramaSettings.js +6 -28
  26. package/dist/camera/PanoramaSettingsBridge.d.ts +1 -24
  27. package/dist/camera/PanoramaSettingsBridge.js +3 -102
  28. package/dist/camera/PanoramaSettingsModal.js +7 -1
  29. package/dist/camera/buildPanoramaInitialSettings.d.ts +11 -0
  30. package/dist/camera/buildPanoramaInitialSettings.js +4 -0
  31. package/dist/camera/cameraErrorMessages.d.ts +32 -0
  32. package/dist/camera/cameraErrorMessages.js +53 -0
  33. package/dist/camera/selectCaptureDevice.d.ts +5 -1
  34. package/dist/camera/selectCaptureDevice.js +22 -2
  35. package/dist/camera/useCapture.js +38 -0
  36. package/dist/index.d.ts +5 -8
  37. package/dist/index.js +11 -34
  38. package/dist/stitching/incremental.d.ts +1 -117
  39. package/dist/stitching/stitchVideo.d.ts +0 -35
  40. package/dist/types.d.ts +0 -87
  41. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +96 -674
  42. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +9 -12
  43. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +14 -0
  44. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +7 -0
  45. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +6 -0
  46. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +2 -2
  47. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +3 -3
  48. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +28 -60
  49. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +180 -921
  50. package/ios/Sources/RNImageStitcher/RNSARSession.swift +10 -35
  51. package/ios/Sources/RNImageStitcher/Stitcher.swift +84 -35
  52. package/ios/Sources/RNImageStitcher/StitcherBridge.m +13 -0
  53. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +132 -5
  54. package/package.json +3 -2
  55. package/src/camera/Camera.tsx +43 -22
  56. package/src/camera/CaptureStitchStatsToast.tsx +58 -14
  57. package/src/camera/PanoramaSettings.ts +16 -289
  58. package/src/camera/PanoramaSettingsBridge.ts +3 -114
  59. package/src/camera/PanoramaSettingsModal.tsx +14 -1
  60. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +3 -188
  61. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +41 -0
  62. package/src/camera/__tests__/cameraErrorMessages.test.ts +76 -0
  63. package/src/camera/__tests__/selectCaptureDevice.test.ts +33 -0
  64. package/src/camera/buildPanoramaInitialSettings.ts +17 -0
  65. package/src/camera/cameraErrorMessages.ts +84 -0
  66. package/src/camera/selectCaptureDevice.ts +28 -3
  67. package/src/camera/useCapture.ts +44 -1
  68. package/src/index.ts +11 -40
  69. package/src/stitching/incremental.ts +3 -140
  70. package/src/stitching/stitchVideo.ts +0 -26
  71. package/src/types.ts +0 -95
  72. package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +0 -227
  73. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +0 -1081
  74. package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +0 -103
  75. package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +0 -256
  76. package/cpp/stitcher_frame_jsi.cpp +0 -214
  77. package/cpp/stitcher_frame_jsi.hpp +0 -108
  78. package/cpp/stitcher_proxy_jsi.cpp +0 -109
  79. package/cpp/stitcher_proxy_jsi.hpp +0 -46
  80. package/cpp/stitcher_worklet_dispatch.cpp +0 -103
  81. package/cpp/stitcher_worklet_dispatch.hpp +0 -71
  82. package/cpp/stitcher_worklet_registry.cpp +0 -91
  83. package/cpp/stitcher_worklet_registry.hpp +0 -146
  84. package/cpp/tests/stitcher_worklet_registry_test.cpp +0 -195
  85. package/dist/stitching/IncrementalStitcherView.d.ts +0 -41
  86. package/dist/stitching/IncrementalStitcherView.js +0 -157
  87. package/dist/stitching/StitcherWorkletRegistry.d.ts +0 -117
  88. package/dist/stitching/StitcherWorkletRegistry.js +0 -78
  89. package/dist/stitching/ensureStitcherProxyInstalled.d.ts +0 -8
  90. package/dist/stitching/ensureStitcherProxyInstalled.js +0 -81
  91. package/dist/stitching/useFrameProcessor.d.ts +0 -119
  92. package/dist/stitching/useFrameProcessor.js +0 -196
  93. package/dist/stitching/useFrameStream.d.ts +0 -34
  94. package/dist/stitching/useFrameStream.js +0 -234
  95. package/dist/stitching/useThrottledFrameProcessor.d.ts +0 -33
  96. package/dist/stitching/useThrottledFrameProcessor.js +0 -132
  97. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +0 -474
  98. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +0 -1328
  99. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +0 -103
  100. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +0 -3285
  101. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +0 -128
  102. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +0 -313
  103. package/ios/Sources/RNImageStitcher/SaveFrameAsJpegPlugin.mm +0 -185
  104. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.h +0 -60
  105. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.mm +0 -214
  106. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.h +0 -42
  107. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +0 -160
  108. package/src/stitching/IncrementalStitcherView.tsx +0 -198
  109. package/src/stitching/StitcherWorkletRegistry.ts +0 -156
  110. package/src/stitching/__tests__/StitcherWorkletRegistry.test.ts +0 -176
  111. package/src/stitching/__tests__/ensureStitcherProxyInstalled.test.ts +0 -94
  112. package/src/stitching/__tests__/useThrottledFrameProcessor.test.ts +0 -178
  113. package/src/stitching/ensureStitcherProxyInstalled.ts +0 -141
  114. package/src/stitching/useFrameProcessor.ts +0 -226
  115. package/src/stitching/useFrameStream.ts +0 -271
  116. package/src/stitching/useThrottledFrameProcessor.ts +0 -145
@@ -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
+ }
@@ -47,7 +47,11 @@ export interface DeviceLike {
47
47
  export type CaptureDeviceMode =
48
48
  /** One multi-cam device spans wide + ultra-wide; switch lenses via zoom. */
49
49
  | 'multicam'
50
- /** Separate standalone wide + ultra-wide devices; switch by remounting. */
50
+ /**
51
+ * Ultra-wide reached by remounting a dedicated ultra-wide device on 0.5x
52
+ * (the 1x primary may be a multi-cam *or* a standalone wide). Used when
53
+ * no multi-cam device can reach the ultra-wide by zoom.
54
+ */
51
55
  | 'standalone-uw'
52
56
  /** No ultra-wide anywhere; wide-angle only (no 0.5× chip). */
53
57
  | 'wide-only';
@@ -71,6 +75,16 @@ export interface CaptureDeviceSelection<D extends DeviceLike = DeviceLike> {
71
75
  const hasLens = (d: DeviceLike, lens: LensType) =>
72
76
  d.physicalDevices.includes(lens);
73
77
 
78
+ /**
79
+ * Max `minZoom` a multi-cam device may report and still count as able to
80
+ * reach the ultra-wide *by zoom*. Real ultra-wides sit at ~0.5-0.65x, so a
81
+ * logical device whose zoom range genuinely extends to the ultra-wide reports
82
+ * `minZoom <= ~0.65`. A device that only *lists* the ultra-wide (a separate
83
+ * physical camera on Android/Camera2, not a zoom target) reports
84
+ * `minZoom = 1.0`. 0.7 cleanly separates the two.
85
+ */
86
+ const UW_ZOOM_REACH_MAX = 0.7;
87
+
74
88
  /**
75
89
  * Choose the back-camera device(s) for capture.
76
90
  *
@@ -107,7 +121,13 @@ export function selectCaptureDevice<D extends DeviceLike>(
107
121
  (d) =>
108
122
  d.isMultiCam &&
109
123
  hasLens(d, 'wide-angle-camera') &&
110
- hasLens(d, 'ultra-wide-angle-camera'),
124
+ hasLens(d, 'ultra-wide-angle-camera') &&
125
+ // Must reach the ultra-wide by zoom. On iOS the virtual device's zoom
126
+ // range spans it (minZoom ~0.5); on Android a logical device often
127
+ // *lists* the ultra-wide while its zoom range starts at 1.0 (separate
128
+ // physical camera, not a zoom target). If it can't zoom there, it
129
+ // does NOT qualify -- we fall through to the device-swap path below.
130
+ d.minZoom <= UW_ZOOM_REACH_MAX,
111
131
  );
112
132
  if (multicamCandidates.length > 0) {
113
133
  const device = multicamCandidates.reduce((best, d) => {
@@ -135,9 +155,14 @@ export function selectCaptureDevice<D extends DeviceLike>(
135
155
  //
136
156
  // Prefer a torch-bearing wide-angle device as the `1×`/primary mount.
137
157
  const wideDevices = back.filter((d) => hasLens(d, 'wide-angle-camera'));
158
+ // A *true* standalone ultra-wide (its own id, NOT a multi-cam grouping).
159
+ // We deliberately do NOT fall back to a multi-cam device: mounting a
160
+ // logical multi-cam yields its WIDE member, not the ultra-wide, so a
161
+ // "swap" to it would silently show the wrong FOV. If the only ultra-wide
162
+ // lives inside a non-zoomable multi-cam device, it is undeliverable and we
163
+ // hide the chooser (wide-only) below.
138
164
  const ultraWide =
139
165
  back.find((d) => !d.isMultiCam && hasLens(d, 'ultra-wide-angle-camera')) ??
140
- back.find((d) => hasLens(d, 'ultra-wide-angle-camera')) ??
141
166
  null;
142
167
 
143
168
  if (wideDevices.length > 0 && ultraWide != null) {
@@ -25,7 +25,7 @@
25
25
  * still use the SDK's quality + stitching modules.
26
26
  */
27
27
 
28
- import { useCallback, useMemo, useRef, useState } from 'react';
28
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
29
29
  import {
30
30
  Camera,
31
31
  useCameraDevice,
@@ -309,6 +309,49 @@ export function useCapture(options: UseCaptureOptions = {}): UseCaptureReturn {
309
309
  activeZoom = undefined;
310
310
  }
311
311
 
312
+ // v0.15 diagnostic (dev-only) — for the "0.5× pill shows but tapping
313
+ // doesn't switch the camera" report on Android (Samsung). Logs the
314
+ // resolved capture mode + the mounted device's zoom range so logcat
315
+ // reveals whether `minZoom` actually reaches the ultra-wide. On
316
+ // Camera2 the logical multi-camera's zoom range usually starts at 1.0
317
+ // (the ultra-wide is a separate physical id, not a zoom target), so a
318
+ // zoom-based 0.5× switch is a silent no-op.
319
+ useEffect(() => {
320
+ if (!__DEV__) return;
321
+ const summarise = (d: DeviceLike | null) =>
322
+ d
323
+ ? {
324
+ id: d.id,
325
+ physical: d.physicalDevices,
326
+ isMultiCam: d.isMultiCam,
327
+ minZoom: d.minZoom,
328
+ neutralZoom: d.neutralZoom,
329
+ maxZoom: d.maxZoom,
330
+ hasTorch: d.hasTorch,
331
+ }
332
+ : null;
333
+ const back = (allDevices as unknown as DeviceLike[]).filter(
334
+ (d) => d.position === 'back',
335
+ );
336
+ // eslint-disable-next-line no-console
337
+ console.log(
338
+ '[rnimagestitcher] lens-select ' +
339
+ JSON.stringify({
340
+ lens: lens ?? null,
341
+ mode: selection.mode,
342
+ has0_5x: selection.has0_5x,
343
+ activeZoom: activeZoom ?? null,
344
+ selected: summarise(selection.device),
345
+ ultraWide: summarise(selection.ultraWideDevice),
346
+ // Full back-camera enumeration — reveals whether a multicam
347
+ // device merely *lists* the ultra-wide while its zoom range
348
+ // can't reach it (minZoom ~1.0), and whether a STANDALONE
349
+ // ultra-wide device exists for the standalone-uw fallback.
350
+ allBack: back.map(summarise),
351
+ }),
352
+ );
353
+ }, [allDevices, selection, lens, activeZoom]);
354
+
312
355
  // Enumerate ALL physical lens types available on the chosen
313
356
  // position so the host can decide whether to render a switcher.
314
357
  // Vision-camera's `useCameraDevices()` returns CameraDevice[]; each