react-native-image-stitcher 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +25 -10
  3. package/android/src/main/cpp/image_stitcher_jni.cpp +52 -7
  4. package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
  5. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +135 -59
  6. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
  7. package/cpp/keyframe_gate.cpp +54 -15
  8. package/cpp/keyframe_gate.hpp +33 -0
  9. package/cpp/stitcher.cpp +481 -87
  10. package/cpp/stitcher.hpp +52 -0
  11. package/dist/camera/Camera.d.ts +13 -0
  12. package/dist/camera/Camera.js +9 -64
  13. package/dist/camera/CaptureFrameCounterOverlay.js +12 -1
  14. package/dist/camera/CaptureMemoryPill.d.ts +15 -7
  15. package/dist/camera/CaptureMemoryPill.js +34 -9
  16. package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
  17. package/dist/camera/PanoramaBandOverlay.js +9 -3
  18. package/dist/camera/PanoramaSettings.js +22 -25
  19. package/dist/camera/RectCropPreview.d.ts +3 -29
  20. package/dist/camera/RectCropPreview.js +20 -130
  21. package/dist/stitching/incremental.d.ts +29 -0
  22. package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
  23. package/dist/stitching/useIncrementalStitcher.js +7 -1
  24. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +103 -26
  25. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
  26. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
  27. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +21 -1
  28. package/package.json +1 -1
  29. package/src/camera/Camera.tsx +21 -70
  30. package/src/camera/CaptureFrameCounterOverlay.tsx +15 -1
  31. package/src/camera/CaptureMemoryPill.tsx +33 -9
  32. package/src/camera/PanoramaBandOverlay.tsx +9 -1
  33. package/src/camera/PanoramaSettings.ts +22 -25
  34. package/src/camera/RectCropPreview.tsx +38 -220
  35. package/src/stitching/incremental.ts +29 -0
  36. package/src/stitching/useIncrementalStitcher.ts +13 -0
  37. 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 (A/B toggle,
180
- * warnings) clear of the notch/Dynamic Island and the bottom button bar
181
- * clear of the home indicator. Default 0.
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, activeW, activeH);
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: activeW * fit.scale,
438
- height: activeH * fit.scale,
360
+ width: imageWidth * fit.scale,
361
+ height: imageHeight * fit.scale,
439
362
  };
440
- // Quad corners only apply to the primary (croppable) image — hidden
441
- // while the alt (manual) output is shown for comparison.
442
- screenCorners = showAlt
443
- ? null
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; watch it spike when
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 + (altOffered ? 76 : 8),
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
- pushed below the A/B bar when that's present so they don't overlap.
494
- A/B-AWARE: while the user is viewing the on-demand high-level tab and
495
- its recipe is known, show the HIGH-LEVEL recipe (pipe=highlevel;…)
496
- instead of the manual primary's `debugInfo` — otherwise the pill
497
- would misleadingly claim the manual recipe for a high-level output. */}
498
- {(() => {
499
- const pillText =
500
- showAlt && lazyAltDebugInfo ? lazyAltDebugInfo : debugInfo;
501
- return pillText ? (
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: activeUri }}
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
- crop mode on the PRIMARY image (hidden while comparing the alt). */}
581
- {showCropControls && !showAlt && (
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 and while comparing the alt (the
630
- primary button below is the accept action there). */}
631
- {showCropControls && !showAlt && (
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 — "Use this" emits the SHOWN (alt) pipeline's
642
- output; otherwise "Crop" applies the quad (crop mode) or
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
- showAlt
660
- ? 'Use this output'
661
- : showCropControls
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
- {showAlt
668
- ? 'Use this'
669
- : showCropControls
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
- }