react-native-image-stitcher 0.16.0 → 0.16.2
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.
- package/CHANGELOG.md +80 -0
- package/README.md +41 -44
- package/android/build.gradle +34 -0
- package/android/src/main/cpp/image_stitcher_jni.cpp +52 -7
- package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +135 -59
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
- package/cpp/keyframe_gate.cpp +54 -15
- package/cpp/keyframe_gate.hpp +33 -0
- package/cpp/stitcher.cpp +481 -87
- package/cpp/stitcher.hpp +52 -0
- package/dist/camera/Camera.d.ts +13 -0
- package/dist/camera/Camera.js +9 -64
- package/dist/camera/CaptureFrameCounterOverlay.js +12 -1
- package/dist/camera/CaptureMemoryPill.d.ts +15 -7
- package/dist/camera/CaptureMemoryPill.js +34 -9
- package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
- package/dist/camera/PanoramaBandOverlay.js +9 -3
- package/dist/camera/PanoramaSettings.js +22 -25
- package/dist/camera/RectCropPreview.d.ts +3 -29
- package/dist/camera/RectCropPreview.js +20 -130
- package/dist/stitching/incremental.d.ts +29 -0
- package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
- package/dist/stitching/useIncrementalStitcher.js +7 -1
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +103 -26
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +21 -1
- package/package.json +1 -1
- package/src/camera/Camera.tsx +21 -70
- package/src/camera/CaptureFrameCounterOverlay.tsx +15 -1
- package/src/camera/CaptureMemoryPill.tsx +33 -9
- package/src/camera/PanoramaBandOverlay.tsx +9 -1
- package/src/camera/PanoramaSettings.ts +22 -25
- package/src/camera/RectCropPreview.tsx +38 -220
- package/src/stitching/incremental.ts +29 -0
- package/src/stitching/useIncrementalStitcher.ts +13 -0
- package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +0 -100
|
@@ -44,7 +44,6 @@ import React, {
|
|
|
44
44
|
useState,
|
|
45
45
|
} from 'react';
|
|
46
46
|
import {
|
|
47
|
-
ActivityIndicator,
|
|
48
47
|
Image,
|
|
49
48
|
Modal,
|
|
50
49
|
PanResponder,
|
|
@@ -110,29 +109,6 @@ export interface RectCropPreviewProps {
|
|
|
110
109
|
imageWidth: number;
|
|
111
110
|
/** Intrinsic pixel height of `imageUri`. */
|
|
112
111
|
imageHeight: number;
|
|
113
|
-
/**
|
|
114
|
-
* DEBUG A/B harness — file:// URI of the SAME capture stitched by the
|
|
115
|
-
* OPPOSITE pipeline (manual cv::detail + plane). When set, a toggle appears
|
|
116
|
-
* that flips the displayed panorama between the primary (high-level +
|
|
117
|
-
* spherical) and this one, for on-device comparison on a single capture.
|
|
118
|
-
* Its dimensions are read at runtime via `Image.getSize`. When the manual
|
|
119
|
-
* output is showing, the crop quad is hidden and the accept button emits
|
|
120
|
-
* THIS uri (so you can pick the better pipeline per capture).
|
|
121
|
-
*/
|
|
122
|
-
altImageUri?: string;
|
|
123
|
-
/**
|
|
124
|
-
* 2026-06-15 — ON-DEMAND alt (high-level) stitch. The PRIMARY image is the
|
|
125
|
-
* MANUAL pipeline (the default output); this callback re-stitches the SAME
|
|
126
|
-
* captured keyframes via cv::Stitcher and resolves with a file:// uri (or
|
|
127
|
-
* null on failure). It runs only the FIRST time the user taps the
|
|
128
|
-
* "High-level" tab — nothing is computed unless asked for. When provided (or
|
|
129
|
-
* `altImageUri` is), the A/B toggle appears.
|
|
130
|
-
*
|
|
131
|
-
* Resolves with the high-level output's file:// `uri` AND its OWN
|
|
132
|
-
* DEV-overlay `debugInfo` recipe (so the params pill can switch to the
|
|
133
|
-
* high-level recipe while that tab is viewed), or `null` on failure.
|
|
134
|
-
*/
|
|
135
|
-
onRequestAlt?: () => Promise<{ uri: string; debugInfo: string } | null>;
|
|
136
112
|
/** Show / hide the editor. */
|
|
137
113
|
visible: boolean;
|
|
138
114
|
/**
|
|
@@ -176,9 +152,9 @@ export interface RectCropPreviewProps {
|
|
|
176
152
|
copy?: Partial<GuidanceCopy>;
|
|
177
153
|
/**
|
|
178
154
|
* Safe-area insets (px). The editor is a full-screen Modal, so the host
|
|
179
|
-
* passes `insets.top`/`insets.bottom` to keep the top toolbar (
|
|
180
|
-
*
|
|
181
|
-
*
|
|
155
|
+
* passes `insets.top`/`insets.bottom` to keep the top toolbar (warnings)
|
|
156
|
+
* clear of the notch/Dynamic Island and the bottom button bar clear of the
|
|
157
|
+
* home indicator. Default 0.
|
|
182
158
|
*/
|
|
183
159
|
topInset?: number;
|
|
184
160
|
bottomInset?: number;
|
|
@@ -244,7 +220,6 @@ export function RectCropPreview(
|
|
|
244
220
|
imageUri,
|
|
245
221
|
imageWidth,
|
|
246
222
|
imageHeight,
|
|
247
|
-
altImageUri,
|
|
248
223
|
visible,
|
|
249
224
|
onConfirm,
|
|
250
225
|
onUseOriginal,
|
|
@@ -256,63 +231,11 @@ export function RectCropPreview(
|
|
|
256
231
|
topInset = 0,
|
|
257
232
|
bottomInset = 0,
|
|
258
233
|
debugInfo,
|
|
259
|
-
onRequestAlt,
|
|
260
234
|
showMemoryPill,
|
|
261
235
|
} = props;
|
|
262
236
|
|
|
263
237
|
const resolvedCopy = useMemo(() => mergeGuidanceCopy(copy), [copy]);
|
|
264
238
|
|
|
265
|
-
// ── A/B comparison — the PRIMARY (imageUri) is the MANUAL pipeline (the
|
|
266
|
-
// default output). The alt is HIGH-LEVEL cv::Stitcher, produced either
|
|
267
|
-
// EAGERLY (`altImageUri`, legacy) or ON DEMAND (`onRequestAlt`, re-stitched
|
|
268
|
-
// the first time the user opens the high-level tab). `altSize` is fetched
|
|
269
|
-
// once the alt uri exists; when the alt is showing we use its dims for the
|
|
270
|
-
// contain-fit and hide the crop quad.
|
|
271
|
-
const [showingAlt, setShowingAlt] = useState(false);
|
|
272
|
-
const [lazyAltUri, setLazyAltUri] = useState<string | null>(null);
|
|
273
|
-
// The high-level (alt) stitch's OWN DEV-overlay recipe, resolved alongside
|
|
274
|
-
// its uri from `onRequestAlt`. Shown in the params pill in place of the
|
|
275
|
-
// manual primary's `debugInfo` while the high-level tab is being viewed.
|
|
276
|
-
const [lazyAltDebugInfo, setLazyAltDebugInfo] = useState<string | null>(null);
|
|
277
|
-
const [altLoading, setAltLoading] = useState(false);
|
|
278
|
-
const [altFailed, setAltFailed] = useState(false);
|
|
279
|
-
const altUri = altImageUri ?? lazyAltUri ?? null;
|
|
280
|
-
const altOffered = !!altImageUri || !!onRequestAlt;
|
|
281
|
-
const [altSize, setAltSize] = useState<{ w: number; h: number } | null>(null);
|
|
282
|
-
React.useEffect(() => {
|
|
283
|
-
if (!altUri) {
|
|
284
|
-
setAltSize(null);
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
Image.getSize(
|
|
288
|
-
altUri,
|
|
289
|
-
(w, h) => setAltSize({ w, h }),
|
|
290
|
-
() => setAltSize(null),
|
|
291
|
-
);
|
|
292
|
-
}, [altUri]);
|
|
293
|
-
// Switch to the high-level (alt) view; compute it lazily on first request.
|
|
294
|
-
const showHighLevel = React.useCallback(() => {
|
|
295
|
-
setShowingAlt(true);
|
|
296
|
-
if (altUri || altLoading || !onRequestAlt) return;
|
|
297
|
-
setAltFailed(false);
|
|
298
|
-
setAltLoading(true);
|
|
299
|
-
onRequestAlt()
|
|
300
|
-
.then((result) => {
|
|
301
|
-
if (result) {
|
|
302
|
-
setLazyAltUri(result.uri);
|
|
303
|
-
setLazyAltDebugInfo(result.debugInfo);
|
|
304
|
-
} else {
|
|
305
|
-
setAltFailed(true);
|
|
306
|
-
}
|
|
307
|
-
})
|
|
308
|
-
.catch(() => setAltFailed(true))
|
|
309
|
-
.finally(() => setAltLoading(false));
|
|
310
|
-
}, [altUri, altLoading, onRequestAlt]);
|
|
311
|
-
const showAlt = showingAlt && !!altUri && !!altSize;
|
|
312
|
-
const activeUri = showAlt ? (altUri as string) : imageUri;
|
|
313
|
-
const activeW = showAlt ? (altSize as { w: number }).w : imageWidth;
|
|
314
|
-
const activeH = showAlt ? (altSize as { h: number }).h : imageHeight;
|
|
315
|
-
|
|
316
239
|
// The 4 corners live in IMAGE-PIXEL space (the source of truth) so they
|
|
317
240
|
// survive layout-box changes (rotation, keyboard) without drift. We map
|
|
318
241
|
// to screen for rendering and back on every drag via cropGeometry.
|
|
@@ -429,19 +352,18 @@ export function RectCropPreview(
|
|
|
429
352
|
let screenCorners: Point[] | null = null;
|
|
430
353
|
|
|
431
354
|
if (box) {
|
|
432
|
-
const fit = containFit(box,
|
|
355
|
+
const fit = containFit(box, imageWidth, imageHeight);
|
|
433
356
|
if (fit) {
|
|
434
357
|
imageBox = {
|
|
435
358
|
left: fit.offX,
|
|
436
359
|
top: fit.offY,
|
|
437
|
-
width:
|
|
438
|
-
height:
|
|
360
|
+
width: imageWidth * fit.scale,
|
|
361
|
+
height: imageHeight * fit.scale,
|
|
439
362
|
};
|
|
440
|
-
// Quad corners only apply
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
: imageQuad.map((p) => imageToScreen(p, box, imageWidth, imageHeight));
|
|
363
|
+
// Quad corners only apply in crop mode.
|
|
364
|
+
screenCorners = showCropControls
|
|
365
|
+
? imageQuad.map((p) => imageToScreen(p, box, imageWidth, imageHeight))
|
|
366
|
+
: null;
|
|
445
367
|
}
|
|
446
368
|
}
|
|
447
369
|
|
|
@@ -476,41 +398,28 @@ export function RectCropPreview(
|
|
|
476
398
|
>
|
|
477
399
|
<View style={[styles.root, { paddingTop: topInset }]}>
|
|
478
400
|
{/* Live memory-footprint pill (host gates on settings.debug). Top-LEFT
|
|
479
|
-
so it clears the top-right stitch-params pill
|
|
480
|
-
the high-level re-stitch fires. */}
|
|
401
|
+
so it clears the top-right stitch-params pill. */}
|
|
481
402
|
{showMemoryPill ? (
|
|
482
403
|
<CaptureMemoryPill
|
|
483
404
|
style={{
|
|
484
405
|
position: 'absolute',
|
|
485
|
-
top: topInset +
|
|
406
|
+
top: topInset + 8,
|
|
486
407
|
left: 12,
|
|
487
408
|
zIndex: 21,
|
|
488
409
|
}}
|
|
489
410
|
/>
|
|
490
411
|
) : null}
|
|
491
412
|
|
|
492
|
-
{/* DEV stitch-params overlay (host gates on __DEV__). Top-right pill
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
<View
|
|
503
|
-
style={[
|
|
504
|
-
styles.debugPill,
|
|
505
|
-
{ top: topInset + (altImageUri && altSize ? 76 : 8) },
|
|
506
|
-
]}
|
|
507
|
-
pointerEvents="none"
|
|
508
|
-
accessibilityRole="text"
|
|
509
|
-
>
|
|
510
|
-
<Text style={styles.debugPillText}>{pillText}</Text>
|
|
511
|
-
</View>
|
|
512
|
-
) : null;
|
|
513
|
-
})()}
|
|
413
|
+
{/* DEV stitch-params overlay (host gates on __DEV__). Top-right pill. */}
|
|
414
|
+
{debugInfo ? (
|
|
415
|
+
<View
|
|
416
|
+
style={[styles.debugPill, { top: topInset + 8 }]}
|
|
417
|
+
pointerEvents="none"
|
|
418
|
+
accessibilityRole="text"
|
|
419
|
+
>
|
|
420
|
+
<Text style={styles.debugPillText}>{debugInfo}</Text>
|
|
421
|
+
</View>
|
|
422
|
+
) : null}
|
|
514
423
|
|
|
515
424
|
{/* Non-fatal warning banner (e.g. "<70 % of frames used"), shown
|
|
516
425
|
ABOVE the image so the user sees it before accepting a crop. */}
|
|
@@ -524,61 +433,18 @@ export function RectCropPreview(
|
|
|
524
433
|
</View>
|
|
525
434
|
)}
|
|
526
435
|
|
|
527
|
-
{/* A/B comparison. Primary = MANUAL (the default output); the
|
|
528
|
-
HIGH-LEVEL segment re-stitches the same keyframes ON DEMAND the
|
|
529
|
-
first time it's tapped (spinner while it runs, then it caches). */}
|
|
530
|
-
{altOffered && (
|
|
531
|
-
<View style={styles.abBar}>
|
|
532
|
-
<Text style={styles.abBarLabel}>
|
|
533
|
-
{altLoading
|
|
534
|
-
? 'Stitching high-level… (manual shown meanwhile)'
|
|
535
|
-
: altFailed
|
|
536
|
-
? 'High-level stitch failed — showing manual'
|
|
537
|
-
: 'Viewing the highlighted pipeline — tap to switch:'}
|
|
538
|
-
</Text>
|
|
539
|
-
<View style={styles.abSegments}>
|
|
540
|
-
<Pressable
|
|
541
|
-
style={[styles.abSeg, !showAlt && styles.abSegActive]}
|
|
542
|
-
onPress={() => setShowingAlt(false)}
|
|
543
|
-
accessibilityRole="button"
|
|
544
|
-
accessibilityState={{ selected: !showAlt }}
|
|
545
|
-
accessibilityLabel="View manual pipeline (default)"
|
|
546
|
-
>
|
|
547
|
-
<Text style={[styles.abSegText, !showAlt && styles.abSegTextActive]}>
|
|
548
|
-
Manual
|
|
549
|
-
</Text>
|
|
550
|
-
</Pressable>
|
|
551
|
-
<Pressable
|
|
552
|
-
style={[styles.abSeg, showAlt && styles.abSegActive]}
|
|
553
|
-
onPress={showHighLevel}
|
|
554
|
-
accessibilityRole="button"
|
|
555
|
-
accessibilityState={{ selected: showAlt, busy: altLoading }}
|
|
556
|
-
accessibilityLabel="View high-level pipeline (computed on demand)"
|
|
557
|
-
>
|
|
558
|
-
{altLoading ? (
|
|
559
|
-
<ActivityIndicator size="small" color="#fff" />
|
|
560
|
-
) : (
|
|
561
|
-
<Text style={[styles.abSegText, showAlt && styles.abSegTextActive]}>
|
|
562
|
-
High-level
|
|
563
|
-
</Text>
|
|
564
|
-
)}
|
|
565
|
-
</Pressable>
|
|
566
|
-
</View>
|
|
567
|
-
</View>
|
|
568
|
-
)}
|
|
569
|
-
|
|
570
436
|
<View style={styles.canvas} onLayout={onLayout}>
|
|
571
437
|
{imageBox && (
|
|
572
438
|
<Image
|
|
573
|
-
source={{ uri:
|
|
439
|
+
source={{ uri: imageUri }}
|
|
574
440
|
style={[styles.image, imageBox]}
|
|
575
441
|
resizeMode="stretch"
|
|
576
442
|
/>
|
|
577
443
|
)}
|
|
578
444
|
|
|
579
|
-
{/* Crop affordances — quad edges + draggable handles — only in
|
|
580
|
-
|
|
581
|
-
{showCropControls &&
|
|
445
|
+
{/* Crop affordances — quad edges + draggable handles — only in crop
|
|
446
|
+
mode (hidden in preview-only mode). */}
|
|
447
|
+
{showCropControls && (
|
|
582
448
|
<>
|
|
583
449
|
{/* Quad edges (non-interactive). */}
|
|
584
450
|
{edges.map((e, i) => (
|
|
@@ -626,9 +492,9 @@ export function RectCropPreview(
|
|
|
626
492
|
<Text style={styles.btnText}>{resolvedCopy.cropRetake}</Text>
|
|
627
493
|
</Pressable>
|
|
628
494
|
{/* Crop mode only — "Use original" emits the stitch un-cropped.
|
|
629
|
-
Hidden in preview-only mode
|
|
630
|
-
|
|
631
|
-
{showCropControls &&
|
|
495
|
+
Hidden in preview-only mode (Confirm below is the accept action
|
|
496
|
+
there). */}
|
|
497
|
+
{showCropControls && (
|
|
632
498
|
<Pressable
|
|
633
499
|
style={({ pressed }) => [styles.btn, pressed && styles.btnPressed]}
|
|
634
500
|
onPress={() => onUseOriginal()}
|
|
@@ -638,37 +504,26 @@ export function RectCropPreview(
|
|
|
638
504
|
<Text style={styles.btnText}>{resolvedCopy.cropUseOriginal}</Text>
|
|
639
505
|
</Pressable>
|
|
640
506
|
)}
|
|
641
|
-
{/* Primary action — "
|
|
642
|
-
|
|
643
|
-
"Confirm" accepts the primary as-is (preview-only mode). */}
|
|
507
|
+
{/* Primary action — "Crop" applies the quad (crop mode) or "Confirm"
|
|
508
|
+
accepts the image as-is (preview-only mode). */}
|
|
644
509
|
<Pressable
|
|
645
510
|
style={({ pressed }) => [
|
|
646
511
|
styles.btn,
|
|
647
512
|
styles.primary,
|
|
648
513
|
pressed && styles.btnPressed,
|
|
649
514
|
]}
|
|
650
|
-
onPress={
|
|
651
|
-
showAlt
|
|
652
|
-
? () => onUseOriginal(activeUri)
|
|
653
|
-
: showCropControls
|
|
654
|
-
? handleConfirm
|
|
655
|
-
: () => onUseOriginal()
|
|
656
|
-
}
|
|
515
|
+
onPress={showCropControls ? handleConfirm : () => onUseOriginal()}
|
|
657
516
|
accessibilityRole="button"
|
|
658
517
|
accessibilityLabel={
|
|
659
|
-
|
|
660
|
-
?
|
|
661
|
-
:
|
|
662
|
-
? resolvedCopy.cropConfirm
|
|
663
|
-
: resolvedCopy.previewConfirm
|
|
518
|
+
showCropControls
|
|
519
|
+
? resolvedCopy.cropConfirm
|
|
520
|
+
: resolvedCopy.previewConfirm
|
|
664
521
|
}
|
|
665
522
|
>
|
|
666
523
|
<Text style={styles.btnText}>
|
|
667
|
-
{
|
|
668
|
-
?
|
|
669
|
-
:
|
|
670
|
-
? resolvedCopy.cropConfirm
|
|
671
|
-
: resolvedCopy.previewConfirm}
|
|
524
|
+
{showCropControls
|
|
525
|
+
? resolvedCopy.cropConfirm
|
|
526
|
+
: resolvedCopy.previewConfirm}
|
|
672
527
|
</Text>
|
|
673
528
|
</Pressable>
|
|
674
529
|
</View>
|
|
@@ -745,43 +600,6 @@ const styles = StyleSheet.create({
|
|
|
745
600
|
fontSize: 13,
|
|
746
601
|
fontWeight: '600',
|
|
747
602
|
},
|
|
748
|
-
abBar: {
|
|
749
|
-
backgroundColor: '#1a1a1a',
|
|
750
|
-
paddingVertical: 10,
|
|
751
|
-
paddingHorizontal: 12,
|
|
752
|
-
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
753
|
-
borderBottomColor: '#333',
|
|
754
|
-
},
|
|
755
|
-
abBarLabel: {
|
|
756
|
-
color: '#aaa',
|
|
757
|
-
fontSize: 11,
|
|
758
|
-
fontWeight: '600',
|
|
759
|
-
textAlign: 'center',
|
|
760
|
-
marginBottom: 8,
|
|
761
|
-
},
|
|
762
|
-
abSegments: {
|
|
763
|
-
flexDirection: 'row',
|
|
764
|
-
alignSelf: 'center',
|
|
765
|
-
backgroundColor: '#000',
|
|
766
|
-
borderRadius: 9,
|
|
767
|
-
padding: 3,
|
|
768
|
-
},
|
|
769
|
-
abSeg: {
|
|
770
|
-
paddingVertical: 7,
|
|
771
|
-
paddingHorizontal: 22,
|
|
772
|
-
borderRadius: 7,
|
|
773
|
-
},
|
|
774
|
-
abSegActive: {
|
|
775
|
-
backgroundColor: '#0A84FF',
|
|
776
|
-
},
|
|
777
|
-
abSegText: {
|
|
778
|
-
color: '#9aa',
|
|
779
|
-
fontSize: 14,
|
|
780
|
-
fontWeight: '700',
|
|
781
|
-
},
|
|
782
|
-
abSegTextActive: {
|
|
783
|
-
color: '#fff',
|
|
784
|
-
},
|
|
785
603
|
canvas: { flex: 1 },
|
|
786
604
|
image: { position: 'absolute' },
|
|
787
605
|
edge: {
|
|
@@ -682,6 +682,23 @@ export interface IncrementalFinalizeResult {
|
|
|
682
682
|
* on the just-completed capture.
|
|
683
683
|
*/
|
|
684
684
|
stitchModeResolved?: 'panorama' | 'scans';
|
|
685
|
+
/**
|
|
686
|
+
* 2026-06-15 (DEV) — gyro rotation magnitude of the capture, in RADIANS
|
|
687
|
+
* (angle between the first and last accepted keyframe camera-forward vectors).
|
|
688
|
+
* Surfaced so a dev tool can display it and tune the panorama-vs-SCANS
|
|
689
|
+
* rotation threshold from real captures. `0` when there is no pose-derived
|
|
690
|
+
* rotation signal (non-AR with no poses) — not necessarily "no rotation".
|
|
691
|
+
*/
|
|
692
|
+
rRadians?: number;
|
|
693
|
+
/**
|
|
694
|
+
* 2026-06-16 (DEV) — translation magnitude (metres) and the auto decision
|
|
695
|
+
* ratio (`tScore/(tScore+rScore)`, `>=0.55` → SCANS) that drove the
|
|
696
|
+
* panorama-vs-SCANS choice. Surfaced alongside `rRadians` so a dev tool can
|
|
697
|
+
* display the full decision inputs and tune the threshold from real captures.
|
|
698
|
+
* `0` when there is no motion signal (non-AR with no poses / no movement).
|
|
699
|
+
*/
|
|
700
|
+
tMeters?: number;
|
|
701
|
+
decisionRatio?: number;
|
|
685
702
|
/**
|
|
686
703
|
* 2026-06-14 (DEV overlay) — a semicolon-separated `key=value` trace of the
|
|
687
704
|
* stitcher's RUNTIME choices for this output, e.g.
|
|
@@ -897,6 +914,14 @@ interface NativeIncrementalModule {
|
|
|
897
914
|
* are zero, matching legacy behaviour.
|
|
898
915
|
*/
|
|
899
916
|
imuTranslationMetres?: number;
|
|
917
|
+
/**
|
|
918
|
+
* 2026-06-16 — the explicit lens the user selected (`'1x'` | `'0.5x'`).
|
|
919
|
+
* The reliable zoom signal for the high-level warper tree: `'0.5x'`
|
|
920
|
+
* (ultra-wide) → spherical warper. Replaces deriving zoom from the
|
|
921
|
+
* intrinsics FOV (unreliable on multi-cam 0.5x / non-AR fx=0). Omitted →
|
|
922
|
+
* treated as `'1x'`.
|
|
923
|
+
*/
|
|
924
|
+
lens?: string;
|
|
900
925
|
}): Promise<IncrementalFinalizeResult>;
|
|
901
926
|
cancel(): Promise<{ ok: true }>;
|
|
902
927
|
getState(): Promise<IncrementalState | null>;
|
|
@@ -922,6 +947,10 @@ interface NativeIncrementalModule {
|
|
|
922
947
|
* one-true-number for "how close are we to OOM?". Returns -1
|
|
923
948
|
* on task_info failure (very rare). Resolves immediately. */
|
|
924
949
|
getMemoryFootprintMB(): Promise<number>;
|
|
950
|
+
/** 2026-06-16 — total physical RAM in MB. Lets the DEV memory pill derive
|
|
951
|
+
* RAM-aware pressure bands instead of iPhone-fixed thresholds. -1 on
|
|
952
|
+
* failure. Resolves immediately. */
|
|
953
|
+
getDeviceTotalRamMB?(): Promise<number>;
|
|
925
954
|
/**
|
|
926
955
|
* 2026-05-16 — realtime+batch fusion API foundation. Run the
|
|
927
956
|
* shared C++ `cv::Stitcher` pipeline over a caller-supplied list
|
|
@@ -85,6 +85,12 @@ export interface UseIncrementalStitcherReturn {
|
|
|
85
85
|
* translation magnitude and prefers that).
|
|
86
86
|
*/
|
|
87
87
|
imuTranslationMetres?: number,
|
|
88
|
+
/**
|
|
89
|
+
* 2026-06-16 — the EXPLICIT lens the user selected (`'1x'` | `'0.5x'`).
|
|
90
|
+
* The reliable zoom signal for the high-level warper tree (`'0.5x'`
|
|
91
|
+
* ultra-wide → spherical). Omit ⇒ treated as `'1x'`.
|
|
92
|
+
*/
|
|
93
|
+
lens?: string,
|
|
88
94
|
) => Promise<IncrementalFinalizeResult>;
|
|
89
95
|
/** Abort the capture without producing output. */
|
|
90
96
|
cancel: () => Promise<void>;
|
|
@@ -198,6 +204,7 @@ export function useIncrementalStitcher(): UseIncrementalStitcherReturn {
|
|
|
198
204
|
quality = 90,
|
|
199
205
|
captureOrientation?: string,
|
|
200
206
|
imuTranslationMetres?: number,
|
|
207
|
+
lens?: string,
|
|
201
208
|
): Promise<IncrementalFinalizeResult> => {
|
|
202
209
|
if (!native) {
|
|
203
210
|
throw new Error('useIncrementalStitcher: native module unavailable');
|
|
@@ -216,6 +223,12 @@ export function useIncrementalStitcher(): UseIncrementalStitcherReturn {
|
|
|
216
223
|
// doesn't carry tx/ty/tz, so pose-derived translation is 0).
|
|
217
224
|
// Native side treats it as a magnitude (always ≥ 0).
|
|
218
225
|
imuTranslationMetres: Math.max(0, imuTranslationMetres ?? 0),
|
|
226
|
+
// 2026-06-16 — the EXPLICIT lens the user selected ('1x' | '0.5x').
|
|
227
|
+
// This is the reliable zoom signal for the high-level warper tree
|
|
228
|
+
// (0.5x ultra-wide → spherical); deriving zoom from intrinsics FOV was
|
|
229
|
+
// unreliable (multi-cam 0.5x reaches the ultra-wide by zoom without
|
|
230
|
+
// changing the reported fx, and the non-AR path may supply fx=0).
|
|
231
|
+
lens,
|
|
219
232
|
});
|
|
220
233
|
setIsRunning(false);
|
|
221
234
|
// Clear React state on finalize so the next start doesn't
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
package io.imagestitcher.rn
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* v0.10.0 (audit #4A) — single-use NV21 byte-array handle that
|
|
6
|
-
* enforces the engine's pixel-data ownership contract at runtime.
|
|
7
|
-
*
|
|
8
|
-
* ## Why this exists
|
|
9
|
-
*
|
|
10
|
-
* `IncrementalStitcher.ingestFromARCameraView` accepts an
|
|
11
|
-
* `nv21PixelData` parameter that the engine retains for ~50 ms
|
|
12
|
-
* after the producer thread returns (until the `workScope`
|
|
13
|
-
* coroutine consumes it). The documented contract is
|
|
14
|
-
* "callers MUST treat the array as transferred — do not mutate it
|
|
15
|
-
* or return it to a buffer pool after calling this method."
|
|
16
|
-
*
|
|
17
|
-
* The v0.10.0 audit (`docs/plans/handoff/2026-05-26-autonomous-run-handoff.md`
|
|
18
|
-
* finding #4A) noted this is by-convention only. The current AR
|
|
19
|
-
* caller (`RNSARCameraView`) passes the same `packed.nv21` array
|
|
20
|
-
* as BOTH `grayData` (consumed synchronously inside the gate)
|
|
21
|
-
* AND `nv21PixelData` (consumed asynchronously). Today no race
|
|
22
|
-
* because the sync read finishes before the async coroutine reads,
|
|
23
|
-
* but a future refactor that reorders consumption would silently
|
|
24
|
-
* corrupt frames.
|
|
25
|
-
*
|
|
26
|
-
* Wrapping the bytes in `TransferredNV21` turns the documentation
|
|
27
|
-
* contract into a runtime contract: callers can only extract the
|
|
28
|
-
* bytes once via `takeOnce()`; the second call throws. The
|
|
29
|
-
* misuse is caught at the call site, not at the engine.
|
|
30
|
-
*
|
|
31
|
-
* ## Cost
|
|
32
|
-
*
|
|
33
|
-
* Construction: tens of ns (one heap allocation for the wrapper +
|
|
34
|
-
* one volatile write of the bytes reference). `takeOnce()`: tens
|
|
35
|
-
* of ns (one synchronized read + one null-out). Negligible vs the
|
|
36
|
-
* underlying NV21 array's KB-scale memory footprint and the
|
|
37
|
-
* ms-scale frame-processing cost — but not a free pointer hop.
|
|
38
|
-
*
|
|
39
|
-
* ## Thread-safety
|
|
40
|
-
*
|
|
41
|
-
* `takeOnce()` and `available` are `synchronized` on the wrapper
|
|
42
|
-
* itself. Producers should still extract on a single thread (the
|
|
43
|
-
* frame producer); the synchronization defends against the
|
|
44
|
-
* pathological case where two threads race to extract.
|
|
45
|
-
*/
|
|
46
|
-
class TransferredNV21(bytes: ByteArray) {
|
|
47
|
-
init {
|
|
48
|
-
// Empty arrays would propagate as "0 bytes of pixel data with
|
|
49
|
-
// a non-zero width/height" downstream and crash inside the
|
|
50
|
-
// C++ ingest with a far less actionable error. Catch at
|
|
51
|
-
// construction. Critic-finding [MAJOR][B].
|
|
52
|
-
require(bytes.isNotEmpty()) {
|
|
53
|
-
"TransferredNV21 requires a non-empty byte array " +
|
|
54
|
-
"(received zero-length)"
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
@Volatile
|
|
59
|
-
private var bytes: ByteArray? = bytes
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Take the wrapped bytes. Throws on second call.
|
|
63
|
-
*
|
|
64
|
-
* Consumers should call this exactly once — typically once per
|
|
65
|
-
* frame, on the producer thread, immediately before handing
|
|
66
|
-
* the bytes to the async work queue:
|
|
67
|
-
*
|
|
68
|
-
* ```kotlin
|
|
69
|
-
* val pixelBytes: ByteArray? = if (hasPixelData) nv21PixelData!!.takeOnce() else null
|
|
70
|
-
* workScope.launch {
|
|
71
|
-
* // pixelBytes is captured by value; no race.
|
|
72
|
-
* engine.addFramePixelData(nv21 = pixelBytes!!, ...)
|
|
73
|
-
* }
|
|
74
|
-
* ```
|
|
75
|
-
*
|
|
76
|
-
* Concurrency note: `@Volatile` on the bytes field plus the
|
|
77
|
-
* `synchronized(this)` block here together guarantee both
|
|
78
|
-
* visibility AND atomicity across threads. The `@Volatile` is
|
|
79
|
-
* defensive for any future non-synchronized read; today every
|
|
80
|
-
* accessor goes through the synchronized block.
|
|
81
|
-
*/
|
|
82
|
-
fun takeOnce(): ByteArray = synchronized(this) {
|
|
83
|
-
val b = bytes ?: error(
|
|
84
|
-
"TransferredNV21.takeOnce() called twice — bytes already transferred. " +
|
|
85
|
-
"Check that you're not passing the same TransferredNV21 instance to " +
|
|
86
|
-
"two consumers (e.g., a sync gate-eval call AND an async workScope.launch)."
|
|
87
|
-
)
|
|
88
|
-
bytes = null
|
|
89
|
-
b
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Note: an `available` property was considered and removed in
|
|
93
|
-
// pre-merge review (critic-finding [MAJOR][B]). Any
|
|
94
|
-
// `if (handle.available) handle.takeOnce()` pattern is
|
|
95
|
-
// inherently TOCTOU-racy — another thread could win the
|
|
96
|
-
// takeOnce() between the check and the use. Consumers should
|
|
97
|
-
// call `takeOnce()` directly and catch the `IllegalStateException`
|
|
98
|
-
// if they need recovery semantics. No internal caller used
|
|
99
|
-
// `available`; YAGNI removed it.
|
|
100
|
-
}
|