react-native-image-stitcher 0.14.1 → 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 (119) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +35 -0
  3. package/RNImageStitcher.podspec +8 -1
  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/ar/useARSession.d.ts +9 -0
  21. package/dist/ar/useARSession.js +24 -2
  22. package/dist/camera/Camera.d.ts +31 -16
  23. package/dist/camera/Camera.js +27 -4
  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/RNSARSession.swift +10 -35
  53. package/ios/Sources/RNImageStitcher/Stitcher.swift +84 -35
  54. package/ios/Sources/RNImageStitcher/StitcherBridge.m +13 -0
  55. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +132 -5
  56. package/package.json +3 -2
  57. package/src/ar/useARSession.ts +35 -5
  58. package/src/camera/Camera.tsx +63 -24
  59. package/src/camera/CaptureStitchStatsToast.tsx +58 -14
  60. package/src/camera/PanoramaSettings.ts +16 -289
  61. package/src/camera/PanoramaSettingsBridge.ts +3 -114
  62. package/src/camera/PanoramaSettingsModal.tsx +14 -1
  63. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +3 -188
  64. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +41 -0
  65. package/src/camera/__tests__/cameraErrorMessages.test.ts +76 -0
  66. package/src/camera/__tests__/selectCaptureDevice.test.ts +33 -0
  67. package/src/camera/buildPanoramaInitialSettings.ts +17 -0
  68. package/src/camera/cameraErrorMessages.ts +84 -0
  69. package/src/camera/selectCaptureDevice.ts +28 -3
  70. package/src/camera/useCapture.ts +44 -1
  71. package/src/index.ts +11 -40
  72. package/src/stitching/incremental.ts +3 -140
  73. package/src/stitching/stitchVideo.ts +0 -26
  74. package/src/types.ts +0 -95
  75. package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +0 -227
  76. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +0 -1081
  77. package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +0 -103
  78. package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +0 -256
  79. package/cpp/stitcher_frame_jsi.cpp +0 -214
  80. package/cpp/stitcher_frame_jsi.hpp +0 -108
  81. package/cpp/stitcher_proxy_jsi.cpp +0 -109
  82. package/cpp/stitcher_proxy_jsi.hpp +0 -46
  83. package/cpp/stitcher_worklet_dispatch.cpp +0 -103
  84. package/cpp/stitcher_worklet_dispatch.hpp +0 -71
  85. package/cpp/stitcher_worklet_registry.cpp +0 -91
  86. package/cpp/stitcher_worklet_registry.hpp +0 -146
  87. package/cpp/tests/stitcher_worklet_registry_test.cpp +0 -195
  88. package/dist/stitching/IncrementalStitcherView.d.ts +0 -41
  89. package/dist/stitching/IncrementalStitcherView.js +0 -157
  90. package/dist/stitching/StitcherWorkletRegistry.d.ts +0 -117
  91. package/dist/stitching/StitcherWorkletRegistry.js +0 -78
  92. package/dist/stitching/ensureStitcherProxyInstalled.d.ts +0 -8
  93. package/dist/stitching/ensureStitcherProxyInstalled.js +0 -81
  94. package/dist/stitching/useFrameProcessor.d.ts +0 -119
  95. package/dist/stitching/useFrameProcessor.js +0 -196
  96. package/dist/stitching/useFrameStream.d.ts +0 -34
  97. package/dist/stitching/useFrameStream.js +0 -234
  98. package/dist/stitching/useThrottledFrameProcessor.d.ts +0 -33
  99. package/dist/stitching/useThrottledFrameProcessor.js +0 -132
  100. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +0 -474
  101. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +0 -1328
  102. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +0 -103
  103. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +0 -3285
  104. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +0 -128
  105. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +0 -313
  106. package/ios/Sources/RNImageStitcher/SaveFrameAsJpegPlugin.mm +0 -185
  107. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.h +0 -60
  108. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.mm +0 -214
  109. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.h +0 -42
  110. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +0 -160
  111. package/src/stitching/IncrementalStitcherView.tsx +0 -198
  112. package/src/stitching/StitcherWorkletRegistry.ts +0 -156
  113. package/src/stitching/__tests__/StitcherWorkletRegistry.test.ts +0 -176
  114. package/src/stitching/__tests__/ensureStitcherProxyInstalled.test.ts +0 -94
  115. package/src/stitching/__tests__/useThrottledFrameProcessor.test.ts +0 -178
  116. package/src/stitching/ensureStitcherProxyInstalled.ts +0 -141
  117. package/src/stitching/useFrameProcessor.ts +0 -226
  118. package/src/stitching/useFrameStream.ts +0 -271
  119. package/src/stitching/useThrottledFrameProcessor.ts +0 -145
@@ -23,28 +23,47 @@ import type { IncrementalFinalizeResult } from '../stitching/incremental';
23
23
  export interface CaptureStitchStatsToastProps {
24
24
  /** Toast message to show. Pass null to hide. */
25
25
  message: string | null;
26
+ /**
27
+ * Optional bold title rendered above the message (e.g. an action ask
28
+ * like "Pan more slowly"). Omit for a plain single-line toast.
29
+ */
30
+ title?: string | null;
26
31
  /** Top inset for safe-area placement. Toast pinned `topInset + 12`. */
27
32
  topInset?: number;
33
+ /**
34
+ * Vertical placement. 'top' (default) pins it `topInset + 12` from the
35
+ * top; 'center' vertically centers it — more prominent, and dodges the
36
+ * notch / Dynamic Island entirely.
37
+ */
38
+ placement?: 'top' | 'center';
28
39
  }
29
40
 
30
41
  export function CaptureStitchStatsToast({
31
42
  message,
43
+ title = null,
32
44
  topInset = 0,
45
+ placement = 'top',
33
46
  }: CaptureStitchStatsToastProps): React.JSX.Element | null {
34
47
  if (message === null) return null;
35
48
  return (
36
49
  <View
37
50
  pointerEvents="none"
38
- style={[
39
- styles.wrap,
40
- { top: topInset + 12 },
41
- ]}
51
+ style={
52
+ placement === 'center'
53
+ ? styles.wrapCenter
54
+ : [styles.wrap, { top: topInset + 12 }]
55
+ }
42
56
  >
43
57
  <View
44
58
  style={styles.capsule}
45
59
  accessibilityRole="alert"
46
60
  accessibilityLiveRegion="polite"
47
61
  >
62
+ {title ? (
63
+ <Text style={styles.title} numberOfLines={2}>
64
+ {title}
65
+ </Text>
66
+ ) : null}
48
67
  <Text style={styles.text} numberOfLines={3}>
49
68
  {message}
50
69
  </Text>
@@ -61,6 +80,16 @@ const styles = StyleSheet.create({
61
80
  alignItems: 'center',
62
81
  zIndex: 110,
63
82
  },
83
+ wrapCenter: {
84
+ position: 'absolute',
85
+ top: 0,
86
+ bottom: 0,
87
+ left: 24,
88
+ right: 24,
89
+ alignItems: 'center',
90
+ justifyContent: 'center',
91
+ zIndex: 110,
92
+ },
64
93
  capsule: {
65
94
  paddingHorizontal: 16,
66
95
  paddingVertical: 10,
@@ -68,6 +97,13 @@ const styles = StyleSheet.create({
68
97
  backgroundColor: 'rgba(15, 23, 42, 0.92)',
69
98
  maxWidth: '100%',
70
99
  },
100
+ title: {
101
+ color: '#ffffff',
102
+ fontSize: 14,
103
+ fontWeight: '700',
104
+ textAlign: 'center',
105
+ marginBottom: 3,
106
+ },
71
107
  text: {
72
108
  color: '#ffffff',
73
109
  fontSize: 13,
@@ -93,7 +129,9 @@ const styles = StyleSheet.create({
93
129
  */
94
130
  export interface UseStitchStatsToastReturn {
95
131
  message: string | null;
96
- showFor: (msg: string, ms?: number) => void;
132
+ /** Optional bold title shown above `message` (pass to the toast). */
133
+ title: string | null;
134
+ showFor: (msg: string, ms?: number, title?: string) => void;
97
135
  showResult: (result: IncrementalFinalizeResult, ms?: number) => void;
98
136
  }
99
137
 
@@ -101,16 +139,22 @@ const DEFAULT_DISMISS_MS = 4500;
101
139
 
102
140
  export function useStitchStatsToast(): UseStitchStatsToastReturn {
103
141
  const [message, setMessage] = useState<string | null>(null);
142
+ const [title, setTitle] = useState<string | null>(null);
104
143
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
105
144
 
106
- const showFor = useCallback((msg: string, ms = DEFAULT_DISMISS_MS) => {
107
- if (timerRef.current) clearTimeout(timerRef.current);
108
- setMessage(msg);
109
- timerRef.current = setTimeout(() => {
110
- setMessage(null);
111
- timerRef.current = null;
112
- }, ms);
113
- }, []);
145
+ const showFor = useCallback(
146
+ (msg: string, ms = DEFAULT_DISMISS_MS, titleText?: string) => {
147
+ if (timerRef.current) clearTimeout(timerRef.current);
148
+ setTitle(titleText ?? null);
149
+ setMessage(msg);
150
+ timerRef.current = setTimeout(() => {
151
+ setMessage(null);
152
+ setTitle(null);
153
+ timerRef.current = null;
154
+ }, ms);
155
+ },
156
+ [],
157
+ );
114
158
 
115
159
  const showResult = useCallback(
116
160
  (result: IncrementalFinalizeResult, ms = DEFAULT_DISMISS_MS) => {
@@ -151,5 +195,5 @@ export function useStitchStatsToast(): UseStitchStatsToastReturn {
151
195
  if (timerRef.current) clearTimeout(timerRef.current);
152
196
  }, []);
153
197
 
154
- return { message, showFor, showResult };
198
+ return { message, title, showFor, showResult };
155
199
  }
@@ -203,6 +203,17 @@ export interface FrameSelectionSettings {
203
203
  */
204
204
  overlapThreshold: number;
205
205
 
206
+ /**
207
+ * Time-budget force-accept (BOTH strategies, AR + non-AR). When > 0,
208
+ * the gate accepts a keyframe whenever this many milliseconds have
209
+ * elapsed since the last accepted keyframe — even if the novelty /
210
+ * overlap threshold wasn't met — so a slow or static pan never goes
211
+ * longer than this without a keyframe. Counts toward `maxKeyframes`
212
+ * (the cap still finalises the capture). `0` disables it. Default
213
+ * `2000` (2 s). Maps to the native gate's `setMaxKeyframeIntervalMs`.
214
+ */
215
+ maxKeyframeIntervalMs: number;
216
+
206
217
  /**
207
218
  * Sparse-optical-flow strategy tunables. Consulted only when
208
219
  * `mode === 'flow-based'`; safe to omit otherwise. Defaults
@@ -305,301 +316,17 @@ export const DEFAULT_PANORAMA_SETTINGS: PanoramaSettings = {
305
316
  warperType: 'plane',
306
317
  blenderType: 'multiband',
307
318
  seamFinderType: 'graphcut',
319
+ // v0.15 — inscribed-rect crop is OFF by default (bbox crop keeps all
320
+ // stitched content). Opt in with `maxInscribedRectCrop={true}` (or toggle
321
+ // it on in settings) for a clean-cornered rectangle — but it can shrink the
322
+ // output a lot on lopsided / ultra-wide masks, which is why it's opt-in.
308
323
  enableMaxInscribedRectCrop: false,
309
324
  },
310
325
  frameSelection: {
311
326
  mode: 'flow-based',
312
327
  maxKeyframes: 6,
313
328
  overlapThreshold: 0.20,
329
+ maxKeyframeIntervalMs: 2000,
314
330
  flow: DEFAULT_FLOW_GATE_SETTINGS,
315
331
  },
316
332
  };
317
-
318
-
319
- // ═════════════════════════════════════════════════════════════════════
320
- // SlitscanSettings — Layer 2 hosts using the slit-scan engine.
321
- // ═════════════════════════════════════════════════════════════════════
322
-
323
- /**
324
- * Settings for slit-scan stitching engines (`slitscan-rotate`,
325
- * `slitscan-both`, `firstwins-rectilinear`). Reached via
326
- * `incremental.start({ engine: '<variant>', config: { ... } })`,
327
- * NOT via <Camera> (which always uses batch-keyframe). Each
328
- * sub-tree corresponds to a section of the native `RLISStitcherConfig`
329
- * the slit-scan engine reads at start.
330
- *
331
- * Field-by-field native consumer references are documented in
332
- * `OpenCVSlitScanStitcher.mm` / `OpenCVIncrementalStitcher.h`.
333
- */
334
- export interface SlitscanSettings extends CaptureBaseSettings {
335
- /**
336
- * Which slit-scan variant the engine runs. All three share the
337
- * same painting + registration + plane configuration; they differ
338
- * in their internal motion model (rotation-only vs combined
339
- * translation+rotation, and slit position).
340
- *
341
- * • `'slitscan-rotate'` — preferred name; rotation-only
342
- * motion model.
343
- * • `'slitscan-both'` — combined translation + rotation
344
- * motion model.
345
- * • `'firstwins-rectilinear'` — legacy alias of
346
- * `'slitscan-rotate'` (V13.0a naming). Accepted natively
347
- * but new code should prefer the canonical name.
348
- */
349
- variant: 'slitscan-rotate' | 'slitscan-both' | 'firstwins-rectilinear';
350
-
351
- /** Where the per-accept slit is taken from + how it's blended. */
352
- painting: SlitscanPaintingSettings;
353
-
354
- /** Frame-to-frame registration (NCC + RANSAC + triangulation). */
355
- registration: SlitscanRegistrationSettings;
356
-
357
- /** Plane projection (ARKit-detected, virtual, or disabled). */
358
- plane: PlaneProjectionSettings;
359
-
360
- /**
361
- * Advanced motion-tuning knobs that the v0.3 modal never exposed.
362
- * Both are read by the native side
363
- * (`IncrementalStitcher.swift:1074, 1077`) and have sensible
364
- * defaults; most consumers can leave this field undefined.
365
- */
366
- advanced?: SlitscanAdvancedSettings;
367
- }
368
-
369
-
370
- export interface SlitscanAdvancedSettings {
371
- /**
372
- * Fraction of the pan-axis sensor extent used to compute the
373
- * per-frame slit width. Range `[0.05, 0.90]`, default 0.70
374
- * (engine internal). Higher = wider slits = fewer accepts per
375
- * pan. Set this only if you know what the slit-scan motion
376
- * model needs for your specific capture geometry.
377
- * Native key: `kPanAxisFractionRect`.
378
- */
379
- panAxisFractionRect?: number;
380
-
381
- /**
382
- * Minimum pan-axis delta (in canvas pixels) between consecutive
383
- * accepted strips. Acts as a hard floor below which subsequent
384
- * frames are rejected regardless of NCC scores. Range
385
- * `[0, 500]`, default 0 (no floor). Native key:
386
- * `kMinAcceptDeltaPx`.
387
- */
388
- minAcceptDeltaPx?: number;
389
- }
390
-
391
-
392
- export interface SlitscanPaintingSettings {
393
- /**
394
- * How new strips are blended into already-painted canvas pixels.
395
- *
396
- * • `'FirstPaintedWins'` (default) — preserve the first frame's
397
- * content at any pixel; later strips don't overwrite.
398
- * • `'FeatherBlend'` — alpha-blend new strips into
399
- * already-painted areas at slit boundaries. Smooths visible
400
- * seams when many narrow slits stack.
401
- */
402
- paintMode: 'FirstPaintedWins' | 'FeatherBlend';
403
-
404
- /**
405
- * Where on the camera frame the per-accept slit is sampled from.
406
- * For a typical landscape vertical pan tilting DOWN, the leading
407
- * edge (new content) is at the BOTTOM of the camera frame; for
408
- * upward tilt, it's at the TOP. `'Center'` is the V13.x default.
409
- */
410
- sliverPosition: 'Center' | 'Bottom' | 'Top';
411
-
412
- /**
413
- * When `true`, the very first frame's FULL frame is painted onto
414
- * the canvas (not just the configured slit clip). Default
415
- * `true` — gives the panorama a wider initial anchor that
416
- * subsequent slits extend from. Set false if you want strict
417
- * slit-only behaviour even on the first frame.
418
- */
419
- firstFrameFullFrame: boolean;
420
- }
421
-
422
-
423
- export interface SlitscanRegistrationSettings {
424
- /**
425
- * 3D triangulation step. Cross-references features across
426
- * multiple frames to estimate scene depth. Default `false` (off);
427
- * adds latency, useful for parallax-heavy captures.
428
- */
429
- enableTriangulation: boolean;
430
-
431
- /**
432
- * Triangulation accumulator — when `enableTriangulation` is on,
433
- * keeps a running pose graph across the whole capture. Default
434
- * `false` (off); needed for multi-shot fusion.
435
- */
436
- enableTriAccumulator: boolean;
437
-
438
- /**
439
- * RANSAC homography fit per pair. Adds robustness to feature
440
- * matching at the cost of a few ms per frame. Default `false`.
441
- */
442
- enableRansacHomography: boolean;
443
-
444
- /**
445
- * 1D NCC strip alignment. Present iff enabled. Default
446
- * undefined (disabled); engine uses pure feature matching.
447
- */
448
- ncc1d?: Ncc1dSettings;
449
-
450
- /**
451
- * 2D NCC strip alignment. Present iff enabled. More expensive
452
- * than 1D NCC; needed for shelf-scan captures with vertical
453
- * misalignment. Default undefined (disabled).
454
- */
455
- ncc2d?: Ncc2dSettings;
456
- }
457
-
458
-
459
- export interface Ncc1dSettings {
460
- /**
461
- * Search radius in working-resolution pixels (along the pan axis).
462
- * Clamped to `[5, 60]`. Default 15 when the field is set.
463
- */
464
- searchRadius: number;
465
- }
466
-
467
-
468
- export interface Ncc2dSettings {
469
- /**
470
- * 2D search margin in pixels (rectangular region around the
471
- * predicted strip position). Clamped to `[4, 60]`. Default 12.
472
- */
473
- searchMargin: number;
474
-
475
- /**
476
- * Minimum NCC score to accept a match. Below this the engine
477
- * falls back to the predicted (pose-only) position. Clamped
478
- * to `[0.30, 0.99]`. Default 0.99 (only accept very strong
479
- * matches; the canvas falls back to pose-only quickly).
480
- */
481
- confidenceThreshold: number;
482
-
483
- /**
484
- * EMA smoothing of the NCC-derived offset across consecutive
485
- * strips. Present iff enabled. Default undefined. Useful
486
- * for jittery captures.
487
- */
488
- emaSmoothing?: { alpha: number };
489
-
490
- /**
491
- * Pan-axis-lock — when enabled, the NCC offset is constrained
492
- * to the dominant pan axis (cross-axis movement bounded by
493
- * `crossAxisLockPx`). Useful when the operator's hand wobble
494
- * introduces unwanted cross-axis motion. Present iff enabled.
495
- */
496
- panAxisLock?: { crossAxisLockPx: number };
497
- }
498
-
499
-
500
- export interface PlaneProjectionSettings {
501
- /**
502
- * Where the plane the slit-scan projects onto comes from.
503
- *
504
- * • `'Disabled'` — no plane projection; engine runs
505
- * its baseline slit-scan path.
506
- * • `'ARKitDetected'` — use the first vertical plane that
507
- * ARKit/ARCore finds AND whose normal
508
- * aligns with the camera (filtered by
509
- * `alignmentThreshold`). Requires
510
- * `captureSource === 'ar'`.
511
- * • `'Virtual'` — synthesise a plane at a fixed depth
512
- * (`virtualDepthMeters`) in front of the
513
- * camera at first-frame pose. No
514
- * ARKit dependency.
515
- */
516
- source: 'Disabled' | 'ARKitDetected' | 'Virtual';
517
-
518
- /**
519
- * How frames are warped onto the plane. Only consulted when
520
- * `source !== 'Disabled'`. Default `'Rectified'` for slit-scan.
521
- */
522
- projectionStyle?: 'Trapezoidal' | 'Rectified';
523
-
524
- /**
525
- * Depth in metres for `source === 'Virtual'`. Range `[0.3, 5.0]`,
526
- * default 1.5. Set close to the actual shelf distance for the
527
- * cleanest projection.
528
- */
529
- virtualDepthMeters?: number;
530
-
531
- /**
532
- * Minimum `|planeNormal · cameraForward|` for an ARKit-detected
533
- * plane to be accepted (when `source === 'ARKitDetected'`).
534
- * Range `[0, 1]`, default 0.6 (≈ 53° max off-axis). Higher =
535
- * stricter, only accept very-on-axis planes.
536
- */
537
- alignmentThreshold?: number;
538
- }
539
-
540
-
541
- export const DEFAULT_SLITSCAN_SETTINGS: SlitscanSettings = {
542
- captureSource: 'ar',
543
- debug: false,
544
- variant: 'slitscan-rotate',
545
- painting: {
546
- paintMode: 'FirstPaintedWins',
547
- sliverPosition: 'Bottom',
548
- firstFrameFullFrame: true,
549
- },
550
- registration: {
551
- enableTriangulation: false,
552
- enableTriAccumulator: false,
553
- enableRansacHomography: false,
554
- // ncc1d / ncc2d omitted — both disabled by default.
555
- },
556
- plane: {
557
- source: 'ARKitDetected',
558
- projectionStyle: 'Rectified',
559
- virtualDepthMeters: 1.5,
560
- alignmentThreshold: 0.6,
561
- },
562
- };
563
-
564
-
565
- // ═════════════════════════════════════════════════════════════════════
566
- // HybridSettings — RetaiLens-specific live engine.
567
- // ═════════════════════════════════════════════════════════════════════
568
-
569
- /**
570
- * Settings for the hybrid live-compositing engine
571
- * (`incremental.start({ engine: 'hybrid', ... })`). Most consumers
572
- * won't touch this — the hybrid engine is RetaiLens-specific and
573
- * the public lib's batch-keyframe pipeline is a better fit for
574
- * general-purpose captures. Exported here for completeness.
575
- *
576
- * Important: the hybrid engine has internal preset paths
577
- * (`OpenCVIncrementalStitcher.mm:139-180`) that hard-set
578
- * `enableTriangulation`, `enable2dNcc`, `enableRansacHomography`,
579
- * `planeSource = Disabled`, etc. Code-reviewer flagged that
580
- * exposing those fields would be misleading — the engine clobbers
581
- * any overrides. So this type is intentionally minimal: only
582
- * `projection` is reliably operator-tunable. Hosts that need to
583
- * reach deeper-level hybrid knobs can pass a raw config dict to
584
- * `incremental.start()` directly (Layer 2 escape hatch).
585
- */
586
- export interface HybridSettings extends CaptureBaseSettings {
587
- /**
588
- * Internal projection during real-time compositing. Independent
589
- * from the panorama-stitcher's warperType (which doesn't apply
590
- * to the hybrid engine — its output is the live canvas directly).
591
- *
592
- * Note: only effective in the rotation-only preset path (hybrid
593
- * preset 1). In the other hybrid presets the engine forces
594
- * Planar internally regardless of this setting. Native source:
595
- * `OpenCVIncrementalStitcher.mm:146,161,180`.
596
- */
597
- projection: 'Cylindrical' | 'Planar';
598
- }
599
-
600
-
601
- export const DEFAULT_HYBRID_SETTINGS: HybridSettings = {
602
- captureSource: 'ar',
603
- debug: false,
604
- projection: 'Planar',
605
- };
@@ -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