react-native-image-stitcher 0.5.0 → 0.6.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.
@@ -58,20 +58,20 @@ import java.util.concurrent.atomic.AtomicBoolean
58
58
  *
59
59
  * What the bridge exposes to JS:
60
60
  * - start(options) — spin up the engine
61
- * - processFrameAtPath() — feed a JPEG path + pose; engine returns
62
- * the same outcome enum iOS emits as events
63
61
  * - finalize(options) — write the final panorama and reset
64
62
  * - cancel() — abort without producing output
65
63
  * - getState() — pull the latest state on demand
66
- * - Event "IncrementalStateUpdate" emitted on every
67
- * processFrameAtPath call
64
+ * - refinePanorama() — re-run the C++ stitcher over saved keyframes
65
+ * - cleanupKeyframes() — GC stale per-capture keyframe directories
66
+ * - Event "IncrementalStateUpdate" emitted on every accepted frame
68
67
  *
69
- * What's missing for true live capture on Android:
70
- * ARCore-backed live frame delivery. The engine itself doesn't
71
- * care where frames come from; today the only Android caller is
72
- * the `processFrameAtPath` bridge method. A follow-up will plumb
73
- * ARCore's per-frame `Frame.acquireCameraImage()` directly into
74
- * the engine the same way iOS uses ARSession.
68
+ * How frames reach the engine (no JS-driven path post-v0.6):
69
+ * - AR mode: `RNSARCameraView` calls `ingestFromARCameraView(...)`
70
+ * once per ARCore Frame from its scene-update listener.
71
+ * - Non-AR mode: the vision-camera Frame Processor plugin
72
+ * (`CvFlowGateFrameProcessor`) calls `consumeFrameFromPlugin(...)`
73
+ * on the producer thread, gated by `frameProcessorIngestEnabled`.
74
+ * The pre-v0.6 `processFrameAtPath` JS-driver entry point is gone.
75
75
  */
76
76
  class IncrementalStitcher(
77
77
  private val reactContext: ReactApplicationContext,
@@ -115,9 +115,9 @@ class IncrementalStitcher(
115
115
  // The MVP gate is frame-count-based ("accept every Nth frame
116
116
  // until cap"). iOS uses a pose-based gate (overlap < threshold)
117
117
  // — adding that here is a follow-up that needs ARCore-pose
118
- // accumulation across processFrameAtPath calls. For now, every
119
- // N-th frame is good enough to validate end-to-end stitching
120
- // parity.
118
+ // accumulation across `ingestFromARCameraView` calls. For now,
119
+ // every N-th frame is good enough to validate end-to-end
120
+ // stitching parity.
121
121
  private var batchKeyframeMode: Boolean = false
122
122
  private val batchKeyframePaths: MutableList<String> = mutableListOf()
123
123
  private var batchKeyframeFrameCounter: Int = 0
@@ -217,9 +217,10 @@ class IncrementalStitcher(
217
217
 
218
218
  /// 2026-05-22 (audit F5) — Frame counter for the flowEvalEveryNFrames
219
219
  /// throttle. Incremented on every per-frame entry point
220
- /// (ingestFromARCameraView for AR, processFrameAtPath for non-AR);
221
- /// gate evaluation only runs when (counter - 1) % evalCadence == 0
222
- /// so frame #1 always evaluates regardless of cadence. iOS parity:
220
+ /// (`ingestFromARCameraView` for AR mode, `consumeFrameFromPlugin`
221
+ /// for non-AR Frame Processor mode); gate evaluation only runs when
222
+ /// (counter - 1) % evalCadence == 0 so frame #1 always evaluates
223
+ /// regardless of cadence. iOS parity:
223
224
  /// IncrementalStitcher.swift:2459-2471 (`consumeFrameCounter`).
224
225
  /// Reset to 0 on each `start()` call.
225
226
  private var consumeFrameCounter: Long = 0L
@@ -229,22 +230,23 @@ class IncrementalStitcher(
229
230
  /// F8.4 — gate for `consumeFrameFromPlugin` (the vision-camera
230
231
  /// Frame Processor producer-thread entry point on Android).
231
232
  /// TRUE only when the current capture was started with
232
- /// `frameSourceMode == "frameProcessor"`. In other modes
233
- /// (especially the legacy `"jsDriver"` path that feeds via
234
- /// `processFrameAtPath`), the plugin would double-feed the
235
- /// engine bytes from the producer thread + JPEG paths from
236
- /// the JS interval, racing on the same workScope serial
237
- /// dispatcher — so we drop the producer-thread call.
233
+ /// `frameSourceMode == "frameProcessor"`. In AR mode
234
+ /// (`frameSourceMode == "arSession"`) the plugin would double-feed
235
+ /// the engine alongside `ingestFromARCameraView` bytes from the
236
+ /// producer thread + bytes from the ARCore Frame listener, racing
237
+ /// on the same workScope serial dispatcher so we drop the
238
+ /// producer-thread call.
238
239
  ///
239
240
  /// AtomicBoolean: producer thread reads lock-free, JS thread
240
241
  /// (start/cancel/finalize) writes via `set()`/`compareAndSet()`.
241
242
  /// Mirror of iOS' `frameProcessorIngestEnabled` ivar.
242
243
  private val frameProcessorIngestEnabled = AtomicBoolean(false)
243
- /// Critic #5 fix: serial dispatcher so concurrent
244
- /// processFrameAtPath() calls can't race on the engine's canvas.
245
- /// `limitedParallelism(1)` guarantees one-at-a-time execution
246
- /// while still backing onto the Default pool — matches iOS'
247
- /// `workQueue` (DispatchQueue.serial).
244
+ /// Critic #5 fix: serial dispatcher so concurrent per-frame
245
+ /// ingest calls (today: `ingestFromARCameraView` in AR mode,
246
+ /// `consumeFrameFromPlugin` in frame-processor mode) can't race
247
+ /// on the engine's canvas. `limitedParallelism(1)` guarantees
248
+ /// one-at-a-time execution while still backing onto the Default
249
+ /// pool — matches iOS' `workQueue` (DispatchQueue.serial).
248
250
  @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
249
251
  private val workScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1))
250
252
 
@@ -275,17 +277,6 @@ class IncrementalStitcher(
275
277
  /// on start/stop so the view feeds frames only during a capture.
276
278
  @Volatile private var arCameraViewRef: RNSARCameraView? = null
277
279
 
278
- /// 2026-05-21 (v0.3) — public-to-the-view-file getter so
279
- /// `RNSARCameraView.forwardToIncremental` can ask whether the
280
- /// currently-running engine is the batch-keyframe path (v0.3
281
- /// Y-plane pixel-data flow) or the legacy hybrid/firstwins live
282
- /// engine (which still needs a per-frame JPEG path). Used to
283
- /// elide the eager JPEG encode for batchKeyframe mode (the
284
- /// production Camera component's path); the legacy live engine
285
- /// still pays the per-frame JPEG cost.
286
- internal val isBatchKeyframeMode: Boolean
287
- get() = batchKeyframeMode
288
-
289
280
  init {
290
281
  // Static back-pointer so `RNSARCameraView` can call into
291
282
  // the singleton-style bridge module without a DI dance. RN
@@ -327,11 +318,16 @@ class IncrementalStitcher(
327
318
  // F8.4 — frameSourceMode honoured on Android. Pre-F8.4,
328
319
  // Android ignored this option (only iOS interpreted it).
329
320
  // Now `"frameProcessor"` unlocks `consumeFrameFromPlugin`'s
330
- // producer-thread ingest path; everything else (the
331
- // implicit default + the legacy `"jsDriver"`) keeps the
332
- // ingest path dormant so the existing `processFrameAtPath`
333
- // / ARCore paths run unmodified.
334
- val frameSourceMode = options.getString("frameSourceMode") ?: "jsDriver"
321
+ // producer-thread ingest path; `"arSession"` (the default)
322
+ // keeps it dormant so the ARCore-driven
323
+ // `ingestFromARCameraView` path runs unmodified.
324
+ // Default to "arSession" for parity with iOS — pre-v0.6 this
325
+ // defaulted to "jsDriver", but that mode (the JS-driver
326
+ // processFrameAtPath path) was removed in v0.6. Raw
327
+ // NativeModules callers that omit `frameSourceMode` get the
328
+ // AR-mode behaviour (the production <Camera> always sets
329
+ // 'arSession' explicitly for AR captures anyway).
330
+ val frameSourceMode = options.getString("frameSourceMode") ?: "arSession"
335
331
  frameProcessorIngestEnabled.set(frameSourceMode == "frameProcessor")
336
332
  val rotation = options.getIntOrDefault("frameRotationDegrees", 90)
337
333
  val composeW = options.getIntOrDefault("composeWidth", 960)
@@ -585,14 +581,6 @@ class IncrementalStitcher(
585
581
  }
586
582
  }
587
583
 
588
- /**
589
- * Feed one frame at a JPEG path into the engine. Pose inputs
590
- * drive the same FoV-overlap gate as iOS. When a pose source
591
- * isn't available pass yaw=0, pitch=0, fovHorizDegrees=0 — the
592
- * engine treats fov<=0 as a sentinel for "no intrinsics" and
593
- * substitutes a 65° default, so frames will still be processed,
594
- * just less gated.
595
- */
596
584
  /**
597
585
  * Copy a (non-persistent) source JPEG to a persistent per-keyframe
598
586
  * path under the React context's cache dir. The ARCameraView's
@@ -614,67 +602,6 @@ class IncrementalStitcher(
614
602
  * for a Phase 3 follow-up; this MVP is just enough to make
615
603
  * batch-keyframe work end-to-end on Android.
616
604
  */
617
- /**
618
- * 2026-05-21 (v0.3) — JPEG-to-grayscale decode for the JS-driver
619
- * (non-AR) keyframe-gate path. Used by the batch-keyframe branch
620
- * of processFrameAtPath to feed the C++ KeyframeGate with real
621
- * pixel data so its Flow strategy actually runs (pre-0.3 the
622
- * non-AR call was `evaluate(pose, null)` which silently fell back
623
- * to the Pose strategy because no pixel data was supplied).
624
- *
625
- * Uses OpenCV's Imgcodecs.imread with IMREAD_GRAYSCALE, which
626
- * decodes the JPEG straight into a single-channel CV_8UC1 Mat —
627
- * faster than BitmapFactory + manual luma loop (~10-20 ms here
628
- * vs ~100+ ms in interpreted Kotlin for a 1920×1080 image).
629
- *
630
- * The non-AR snapshot cadence is ~4 FPS so the per-call cost is
631
- * well under the inter-snapshot interval. v0.4 will replace
632
- * this code path entirely by moving non-AR capture to
633
- * vision-camera's Frame Processor API (which delivers raw
634
- * pixels directly with no JPEG roundtrip). Tracked at issue #11.
635
- *
636
- * Returns null when the decode fails (corrupt JPEG, OOM, etc.);
637
- * caller is expected to fall back to the pose-only evaluate
638
- * path so the capture doesn't lock up.
639
- */
640
- private data class GrayscaleFrame(
641
- val bytes: ByteArray,
642
- val width: Int,
643
- val height: Int,
644
- val stride: Int,
645
- )
646
-
647
- private fun decodeJpegToGrayscale(path: String): GrayscaleFrame? {
648
- val mat = Imgcodecs.imread(path, Imgcodecs.IMREAD_GRAYSCALE)
649
- if (mat.empty()) {
650
- android.util.Log.w(
651
- "IncrementalStitcher",
652
- "decodeJpegToGrayscale: imread returned empty Mat for $path"
653
- )
654
- return null
655
- }
656
- return try {
657
- val width = mat.cols()
658
- val height = mat.rows()
659
- // step1() returns bytes-per-row for a CV_8UC1 Mat. For a
660
- // continuous Mat from imread (no ROI) stride == width.
661
- val stride = mat.step1().toInt()
662
- val size = stride * height
663
- val bytes = ByteArray(size)
664
- mat.get(0, 0, bytes)
665
- GrayscaleFrame(bytes, width, height, stride)
666
- } catch (e: Exception) {
667
- android.util.Log.w(
668
- "IncrementalStitcher",
669
- "decodeJpegToGrayscale: failed to copy Mat bytes for $path: ${e.message}",
670
- e,
671
- )
672
- null
673
- } finally {
674
- mat.release()
675
- }
676
- }
677
-
678
605
  private fun copyKeyframeToStore(srcPath: String): String? {
679
606
  // V16 Phase 2 (Android Fix-1) — write into the per-session
680
607
  // subdir created by start(). If start() didn't run (defensive
@@ -708,8 +635,8 @@ class IncrementalStitcher(
708
635
  // ── V16 Phase 1 → P3-F migration note ────────────────────────
709
636
  // The frame-counter placeholder gate `handleBatchKeyframeFrame`
710
637
  // that lived here has been REMOVED. Both the AR-driven path
711
- // (ingestFromARCameraView) and the vision-camera fallback path
712
- // (processFrameAtPath) now route through the shared-C++
638
+ // (`ingestFromARCameraView`) and the Frame Processor path
639
+ // (`consumeFrameFromPlugin`) now route through the shared-C++
713
640
  // `KeyframeGate` (cpp/keyframe_gate.{hpp,cpp}) — same algorithm
714
641
  // iOS has used since the V16 ship. See
715
642
  // `private val keyframeGate = KeyframeGate()` above for the
@@ -722,204 +649,6 @@ class IncrementalStitcher(
722
649
  // we ever add a "force every Nth frame regardless of overlap"
723
650
  // override.
724
651
 
725
- @ReactMethod
726
- fun processFrameAtPath(options: ReadableMap, promise: Promise) {
727
- val hybrid = this.engine
728
- val firstwins = this.firstwinsEngine
729
- // batch-keyframe mode runs without a live engine — handle it
730
- // up-front before the null-check rejects.
731
- //
732
- // P3-F: this path uses the same shared-C++ KeyframeGate as
733
- // the AR view path, but with translation = 0 (gyro-derived
734
- // poses have only rotation, not position). The gate's
735
- // internal logic detects the missing plane and uses the
736
- // camera-forward angular-delta fallback automatically.
737
- if (batchKeyframeMode) {
738
- val path = options.getString("path")
739
- ?: return promise.reject("invalid-options", "path required")
740
- val pose = RNSARFramePose(
741
- tx = options.getDoubleOrDefault("tx", 0.0) ?: 0.0,
742
- ty = options.getDoubleOrDefault("ty", 0.0) ?: 0.0,
743
- tz = options.getDoubleOrDefault("tz", 0.0) ?: 0.0,
744
- qx = options.getDoubleOrDefault("qx", 0.0) ?: 0.0,
745
- qy = options.getDoubleOrDefault("qy", 0.0) ?: 0.0,
746
- qz = options.getDoubleOrDefault("qz", 0.0) ?: 0.0,
747
- qw = options.getDoubleOrDefault("qw", 1.0) ?: 1.0,
748
- fx = options.getDoubleOrDefault("fx", 1000.0) ?: 1000.0,
749
- fy = options.getDoubleOrDefault("fy", 1000.0) ?: 1000.0,
750
- cx = options.getDoubleOrDefault("cx", 540.0) ?: 540.0,
751
- cy = options.getDoubleOrDefault("cy", 960.0) ?: 960.0,
752
- imageWidth = options.getIntOrDefault("imageWidth", 1080),
753
- imageHeight = options.getIntOrDefault("imageHeight", 1920),
754
- timestampMs = 0.0,
755
- trackingState = RNSARSession.TRACKING_TRACKING,
756
- )
757
- // Vision-camera path: no plane available (gyro can't fit
758
- // planes). Pass null → C++ skips the plane-overlap math.
759
- //
760
- // 2026-05-21 (v0.3) — pixel-aware Flow-strategy evaluation.
761
- // The JS driver hands us a JPEG file path; we decode it to
762
- // grayscale here so the C++ gate actually runs sparse-flow
763
- // novelty (pre-0.3 the call was `evaluate(pose, null)` and
764
- // the C++ side silently fell back to Pose strategy because
765
- // no pixel data was supplied — same bug as Android AR
766
- // mode, both fixed in v0.3). BitmapFactory + manual luma
767
- // is fine here — non-AR snapshot cadence is ~4 FPS, the
768
- // ~15-30 ms JPEG-decode-to-grayscale per call is well
769
- // under the inter-snapshot interval. (v0.4 will move
770
- // non-AR to vision-camera's Frame Processor API, at which
771
- // point the JPEG step goes away entirely. Tracked at
772
- // https://github.com/bhargavkanda/react-native-image-stitcher/issues/11)
773
- val gray = decodeJpegToGrayscale(path)
774
- val decision = if (gray != null) {
775
- keyframeGate.evaluateWithFrame(
776
- pose, null,
777
- gray.bytes, gray.width, gray.height, gray.stride,
778
- )
779
- } else {
780
- // JPEG decode failed (corrupt file? OOM?). Fall back
781
- // to pose-only path so the capture doesn't lock up;
782
- // this matches the C++ side's own defensive fallback
783
- // for null grayData.
784
- keyframeGate.evaluate(pose, null)
785
- }
786
- val result = Arguments.createMap()
787
- // Outcome mapping for iOS-parity JS contract:
788
- // 1 = accepted, 2 = rejected (gate), 3 = rejected (cap).
789
- // C++ enum → outcome int:
790
- val outcome = when {
791
- decision.accept -> 1
792
- decision.reason == "max-reached" -> 3
793
- else -> 2
794
- }
795
- result.putInt("outcome", outcome)
796
- if (!decision.accept) {
797
- // 2026-05-22 (audit follow-up) — emit reject-state on
798
- // the non-AR JS-driver path too so the debug overlay
799
- // sees overlap % update on every snapshot (~4 Hz).
800
- // Same rationale as the AR path's reject emission
801
- // above; iOS parity.
802
- emitBatchKeyframeRejectState(
803
- decision = decision,
804
- keyframeCount = batchKeyframePaths.size,
805
- keyframeMax = keyframeGate.maxCount,
806
- isLandscape = pose.imageWidth >= pose.imageHeight,
807
- )
808
- }
809
- if (decision.accept) {
810
- batchKeyframePaths.add(path) // vision-camera path
811
- // gives us a unique
812
- // per-snapshot file
813
- // already (no copy
814
- // needed).
815
- // Emit the same state event the AR path emits so
816
- // the JS LiveFrameStrip + "Keyframes n/max" pill
817
- // work identically on the vision-camera fallback
818
- // path.
819
- emitBatchKeyframeAcceptedState(
820
- thumbnailPath = path,
821
- keyframeIndex = batchKeyframePaths.size - 1,
822
- keyframeCount = batchKeyframePaths.size,
823
- keyframeMax = keyframeGate.maxCount,
824
- isLandscape = pose.imageWidth >= pose.imageHeight,
825
- newContentFraction = decision.newContentFraction,
826
- )
827
- }
828
- result.putInt("acceptedCount", batchKeyframePaths.size)
829
- promise.resolve(result)
830
- return
831
- }
832
- if (hybrid == null && firstwins == null) {
833
- return promise.reject(
834
- "incremental-not-running",
835
- "Call start() before processFrameAtPath().",
836
- )
837
- }
838
- val path = options.getString("path")
839
- ?: return promise.reject("invalid-options", "path required")
840
- val yaw = options.getDoubleOrDefault("yaw", 0.0)
841
- val pitch = options.getDoubleOrDefault("pitch", 0.0)
842
- val fovH = options.getDoubleOrDefault("fovHorizDegrees", 65.0)
843
- val fovV = options.getDoubleOrDefault("fovVertDegrees", 50.0)
844
- // V6 pose-driven params. Defaults removed per critic finding
845
- // #3: previously qw=1.0 default meant frames without explicit
846
- // quaternion produced an identity rotation, and EVERY
847
- // subsequent frame had R_rel = R_first^T (constant), so
848
- // strip placement never advanced and `acceptedCount` froze
849
- // at 1 after the first frame. Now every quaternion field is
850
- // required; missing → reject as RejectedAlignmentLost so the
851
- // gyro driver upstream notices instantly.
852
- if (!options.hasKey("qx") || !options.hasKey("qy")
853
- || !options.hasKey("qz") || !options.hasKey("qw")) {
854
- return promise.reject(
855
- "invalid-options",
856
- "qx/qy/qz/qw all required (no identity-quaternion fallback)",
857
- )
858
- }
859
- val qx = options.getDouble("qx")
860
- val qy = options.getDouble("qy")
861
- val qz = options.getDouble("qz")
862
- val qw = options.getDouble("qw")
863
- val fx = options.getDoubleOrDefault("fx", 0.0)
864
- val fy = options.getDoubleOrDefault("fy", 0.0)
865
- val cx = options.getDoubleOrDefault("cx", 0.0)
866
- val cy = options.getDoubleOrDefault("cy", 0.0)
867
- val imageWidth = options.getIntOrDefault("imageWidth", 0)
868
- val imageHeight = options.getIntOrDefault("imageHeight", 0)
869
- val trackingPoor = options.getBooleanOrDefault("trackingPoor", false)
870
-
871
- workScope.launch {
872
- // Critic #4 fix: re-check isRunning synchronously here in
873
- // case finalize/cancel ran on the JS thread between the
874
- // null-check above and this dispatch landing. Skip the
875
- // ingest if we're no longer running — matches iOS' V12.1
876
- // pattern (synchronous-stop + worker re-check).
877
- if (!isRunning.get()) {
878
- promise.resolve(Arguments.createMap().apply { putInt("outcome", -1) })
879
- return@launch
880
- }
881
- try {
882
- val telemetry: FrameTelemetry
883
- val state: WritableMap?
884
- val accepted: Int
885
- if (firstwins != null) {
886
- telemetry = firstwins.addFrameAtPath(
887
- path = path,
888
- qx = qx, qy = qy, qz = qz, qw = qw,
889
- fx = fx, fy = fy, cx = cx, cy = cy,
890
- imageWidth = imageWidth, imageHeight = imageHeight,
891
- yaw = yaw, pitch = pitch,
892
- fovHorizDegrees = fovH, fovVertDegrees = fovV,
893
- trackingPoor = trackingPoor,
894
- )
895
- state = firstwins.snapshotIfDue(telemetry)
896
- accepted = firstwins.acceptedCount
897
- } else {
898
- telemetry = hybrid!!.addFrameAtPath(
899
- path = path,
900
- qx = qx, qy = qy, qz = qz, qw = qw,
901
- fx = fx, fy = fy, cx = cx, cy = cy,
902
- imageWidth = imageWidth, imageHeight = imageHeight,
903
- yaw = yaw, pitch = pitch,
904
- fovHorizDegrees = fovH, fovVertDegrees = fovV,
905
- trackingPoor = trackingPoor,
906
- )
907
- state = hybrid.snapshotIfDue(telemetry)
908
- accepted = hybrid.acceptedCount
909
- }
910
- emitState(state)
911
- val result = Arguments.createMap()
912
- result.putInt("outcome", telemetry.outcome.ordinal)
913
- result.putDouble("confidence", telemetry.confidence)
914
- result.putDouble("overlapPercent", telemetry.overlapPercent)
915
- result.putDouble("processingMs", telemetry.processingMs)
916
- result.putInt("acceptedCount", accepted)
917
- promise.resolve(result)
918
- } catch (t: Throwable) {
919
- promise.reject("incremental-process-failed", t.message, t)
920
- }
921
- }
922
- }
923
652
 
924
653
  @ReactMethod
925
654
  fun finalize(options: ReadableMap, promise: Promise) {
@@ -963,9 +692,10 @@ class IncrementalStitcher(
963
692
  // frames slip into the engine while we serialize the canvas.
964
693
  arCameraViewRef?.setIncrementalIngestionActive(false)
965
694
  // Critic #4 fix: synchronously flip isRunning=false BEFORE
966
- // dispatching the finalize body, so any in-flight
967
- // processFrameAtPath workers that are about to launch will
968
- // bail at the re-check (see processFrameAtPath above).
695
+ // dispatching the finalize body, so any in-flight per-frame
696
+ // ingest workers about to launch on workScope (today:
697
+ // `ingestFromARCameraView` or `consumeFrameFromPlugin`) bail
698
+ // at the re-check inside their workScope.launch blocks.
969
699
  // Matches iOS V12.1 fix.
970
700
  isRunning.set(false)
971
701
  frameProcessorIngestEnabled.set(false) // F8.4 — cut producer-thread ingest at finalize
@@ -1258,10 +988,29 @@ class IncrementalStitcher(
1258
988
  // is false (the legacy hybrid/firstwins live-engine path,
1259
989
  // which feeds JPEG paths into addFrameAtPath for each ARCore
1260
990
  // frame). Pass null when batchKeyframeMode is true; the
1261
- // batch path uses `grayData` + `onAccept` instead. Callers
1262
- // can check `isBatchKeyframeMode` to elide the per-frame
1263
- // JPEG encode for the batch path.
991
+ // batch path uses `grayData` + `onAccept` instead. Modern
992
+ // callers prefer `nv21PixelData` below `legacyJpegPath` is
993
+ // kept only as a defensive fallback for older call sites
994
+ // that have not yet been migrated.
1264
995
  legacyJpegPath: String? = null,
996
+ // F8.6 — pixel-data path for live engines. When supplied
997
+ // (and `batchKeyframeMode == false`), takes precedence over
998
+ // `legacyJpegPath`: the live engine ingests via
999
+ // `addFramePixelData` (NV21 → BGR Mat in-process) instead of
1000
+ // `addFrameAtPath` (JPEG decode round-trip). Saves ~30-50 ms
1001
+ // per accepted frame on a mid-tier device. Pass null to use
1002
+ // the legacy JPEG path.
1003
+ //
1004
+ // OWNERSHIP: the engine retains a reference to `nv21PixelData`
1005
+ // until `workScope`'s coroutine consumes it (~50 ms later).
1006
+ // Callers MUST treat the array as transferred — do not
1007
+ // mutate it or return it to a buffer pool after calling
1008
+ // this method. If a caller needs to recycle the buffer,
1009
+ // pass `.copyOf()` (currently no caller does — the F8.4
1010
+ // Frame Processor plugin allocates a fresh array per frame).
1011
+ nv21PixelData: ByteArray? = null,
1012
+ nv21PixelWidth: Int = 0,
1013
+ nv21PixelHeight: Int = 0,
1265
1014
  ) {
1266
1015
  // ── V16 batch-keyframe: AR-driven path ─────────────────────
1267
1016
  //
@@ -1439,40 +1188,78 @@ class IncrementalStitcher(
1439
1188
  // we only get here when batchKeyframeMode == false. Caller
1440
1189
  // (RNSARCameraView) was expected to supply legacyJpegPath in
1441
1190
  // that case — defensively drop the frame if it didn't.
1442
- val path = legacyJpegPath ?: run {
1191
+ // F8.6 — prefer the pixel-data path when the caller supplied
1192
+ // NV21 bytes (Frame Processor / refactored ARCore path),
1193
+ // otherwise fall back to legacyJpegPath (un-migrated ARCore
1194
+ // path). At least one of them must be present; drop the
1195
+ // frame defensively otherwise.
1196
+ val hasPixelData = nv21PixelData != null
1197
+ && nv21PixelWidth > 0
1198
+ && nv21PixelHeight > 0
1199
+ val path = if (hasPixelData) null else legacyJpegPath ?: run {
1443
1200
  android.util.Log.w(
1444
1201
  "IncrementalStitcher",
1445
1202
  "ingestFromARCameraView legacy: batchKeyframeMode=false " +
1446
- "but legacyJpegPath is null — dropping frame. " +
1447
- "Caller should have encoded a JPEG when " +
1448
- "isBatchKeyframeMode == false.",
1203
+ "but both legacyJpegPath and nv21PixelData are null — " +
1204
+ "dropping frame. Caller must supply NV21 pixel data " +
1205
+ "(preferred) or a JPEG path for the live engine path.",
1449
1206
  )
1450
1207
  return
1451
1208
  }
1452
1209
  workScope.launch {
1453
1210
  val state: WritableMap? = if (firstwins != null) {
1454
- val tele = firstwins.addFrameAtPath(
1455
- path = path,
1456
- qx = qx, qy = qy, qz = qz, qw = qw,
1457
- fx = fx, fy = fy, cx = cx, cy = cy,
1458
- imageWidth = imageWidth, imageHeight = imageHeight,
1459
- yaw = yaw, pitch = pitch,
1460
- fovHorizDegrees = fovHorizDegrees,
1461
- fovVertDegrees = fovVertDegrees,
1462
- trackingPoor = trackingPoor,
1463
- )
1211
+ val tele = if (hasPixelData) {
1212
+ firstwins.addFramePixelData(
1213
+ nv21 = nv21PixelData!!,
1214
+ nv21Width = nv21PixelWidth,
1215
+ nv21Height = nv21PixelHeight,
1216
+ qx = qx, qy = qy, qz = qz, qw = qw,
1217
+ fx = fx, fy = fy, cx = cx, cy = cy,
1218
+ imageWidth = imageWidth, imageHeight = imageHeight,
1219
+ yaw = yaw, pitch = pitch,
1220
+ fovHorizDegrees = fovHorizDegrees,
1221
+ fovVertDegrees = fovVertDegrees,
1222
+ trackingPoor = trackingPoor,
1223
+ )
1224
+ } else {
1225
+ firstwins.addFrameAtPath(
1226
+ path = path!!,
1227
+ qx = qx, qy = qy, qz = qz, qw = qw,
1228
+ fx = fx, fy = fy, cx = cx, cy = cy,
1229
+ imageWidth = imageWidth, imageHeight = imageHeight,
1230
+ yaw = yaw, pitch = pitch,
1231
+ fovHorizDegrees = fovHorizDegrees,
1232
+ fovVertDegrees = fovVertDegrees,
1233
+ trackingPoor = trackingPoor,
1234
+ )
1235
+ }
1464
1236
  firstwins.snapshotIfDue(tele)
1465
1237
  } else {
1466
- val tele = hybrid!!.addFrameAtPath(
1467
- path = path,
1468
- qx = qx, qy = qy, qz = qz, qw = qw,
1469
- fx = fx, fy = fy, cx = cx, cy = cy,
1470
- imageWidth = imageWidth, imageHeight = imageHeight,
1471
- yaw = yaw, pitch = pitch,
1472
- fovHorizDegrees = fovHorizDegrees,
1473
- fovVertDegrees = fovVertDegrees,
1474
- trackingPoor = trackingPoor,
1475
- )
1238
+ val tele = if (hasPixelData) {
1239
+ hybrid!!.addFramePixelData(
1240
+ nv21 = nv21PixelData!!,
1241
+ nv21Width = nv21PixelWidth,
1242
+ nv21Height = nv21PixelHeight,
1243
+ qx = qx, qy = qy, qz = qz, qw = qw,
1244
+ fx = fx, fy = fy, cx = cx, cy = cy,
1245
+ imageWidth = imageWidth, imageHeight = imageHeight,
1246
+ yaw = yaw, pitch = pitch,
1247
+ fovHorizDegrees = fovHorizDegrees,
1248
+ fovVertDegrees = fovVertDegrees,
1249
+ trackingPoor = trackingPoor,
1250
+ )
1251
+ } else {
1252
+ hybrid!!.addFrameAtPath(
1253
+ path = path!!,
1254
+ qx = qx, qy = qy, qz = qz, qw = qw,
1255
+ fx = fx, fy = fy, cx = cx, cy = cy,
1256
+ imageWidth = imageWidth, imageHeight = imageHeight,
1257
+ yaw = yaw, pitch = pitch,
1258
+ fovHorizDegrees = fovHorizDegrees,
1259
+ fovVertDegrees = fovVertDegrees,
1260
+ trackingPoor = trackingPoor,
1261
+ )
1262
+ }
1476
1263
  hybrid.snapshotIfDue(tele)
1477
1264
  }
1478
1265
  emitState(state)
@@ -1534,23 +1321,36 @@ class IncrementalStitcher(
1534
1321
  ) {
1535
1322
  // F8.4 — drop the call unless this capture was started in
1536
1323
  // frameProcessor mode. Otherwise the plugin would double-
1537
- // feed the engine alongside the legacy jsDriver /
1538
- // processFrameAtPath path. See the flag's declaration
1324
+ // feed the engine alongside the AR-mode
1325
+ // `ingestFromARCameraView` path. See the flag's declaration
1539
1326
  // for the full reasoning. Mirrors iOS H1.
1540
1327
  if (!frameProcessorIngestEnabled.get()) return
1541
1328
 
1542
- val width = image.width
1543
- val height = image.height
1544
- val yPlane = image.planes[0]
1545
- val yRowStride = yPlane.rowStride
1546
-
1547
- // Read Y plane bytes. ByteBuffer.get() advances position;
1548
- // copy into our own ByteArray so the engine's downstream
1549
- // workScope can safely outlive this method (the Image
1550
- // closes after callback returns, but we've already copied).
1551
- val yBuffer = yPlane.buffer
1552
- val yBytes = ByteArray(yBuffer.remaining())
1553
- yBuffer.get(yBytes)
1329
+ // F8.6 pack the full NV21 (Y + interleaved VU) once,
1330
+ // then reuse it for BOTH the gate's Y-plane read AND the
1331
+ // live engine's pixel-data ingest. Previously the plugin
1332
+ // only extracted Y; the live engine then had to JPEG-decode
1333
+ // a separately-encoded path to recover BGR colour. Now we
1334
+ // skip both round-trips: the packed NV21 → BGR cvtColor
1335
+ // inside `addFramePixelData` produces the BGR Mat directly.
1336
+ //
1337
+ // YuvImageConverter.packNV21 is stride-aware and densely
1338
+ // repacks Y (so the gate's `grayStride = grayWidth = width`
1339
+ // works), then interleaves VU per the standard NV21 layout
1340
+ // [Y...][VU...]. Returns null only on degenerate Images
1341
+ // (closed mid-callback or non-YUV format).
1342
+ val packed = io.imagestitcher.rn.ar.YuvImageConverter.packNV21(image)
1343
+ ?: return
1344
+ val width = packed.width
1345
+ val height = packed.height
1346
+ val nv21Bytes = packed.nv21
1347
+ // The gate reads `grayHeight` rows of `grayWidth` pixels
1348
+ // at stride=width starting from offset 0. That's exactly
1349
+ // the Y plane region of nv21Bytes — the gate naturally
1350
+ // stops before the UV bytes start. No need to slice into
1351
+ // a separate ByteArray.
1352
+ val yBytes = nv21Bytes
1353
+ val yRowStride = width
1554
1354
 
1555
1355
  // Compute derived params expected by the existing ingest
1556
1356
  // API. Quaternion-to-yaw/pitch follows the same convention
@@ -1596,13 +1396,22 @@ class IncrementalStitcher(
1596
1396
  grayWidth = width,
1597
1397
  grayHeight = height,
1598
1398
  grayStride = yRowStride,
1399
+ // F8.6 — pass the already-packed NV21 so the live
1400
+ // engine branch (hybrid / firstwins) can ingest via
1401
+ // `addFramePixelData` instead of JPEG-decoding a
1402
+ // separately-written path. Batch-keyframe mode
1403
+ // ignores these (it uses `grayData` + `onAccept`).
1404
+ nv21PixelData = nv21Bytes,
1405
+ nv21PixelWidth = width,
1406
+ nv21PixelHeight = height,
1599
1407
  onAccept = { targetPath ->
1600
1408
  // Synchronous JPEG encode via the existing
1601
1409
  // YuvImageConverter (also used by RNSARCameraView's
1602
- // ARCore path). Handles both the NV21 conversion
1603
- // (stride / pixelStride aware) and the EXIF
1604
- // Orientation tag write so the JPEG displays upright
1605
- // in the UI thumbnail strip + any RN Image consumer.
1410
+ // ARCore path). Reuses the NV21 already packed at
1411
+ // the top of `consumeFrameFromPlugin` F8.6 saves
1412
+ // a duplicate packNV21 call here (the previous
1413
+ // version repacked the live `image` inside the
1414
+ // lambda).
1606
1415
  //
1607
1416
  // EXIF rotation is BAKED-AS-METADATA, not pixel-
1608
1417
  // rotated. cv::imread in the stitcher ignores EXIF
@@ -1614,32 +1423,20 @@ class IncrementalStitcher(
1614
1423
  // Returning `true` tells the engine the keyframe was
1615
1424
  // persisted; `false` tells it to drop the accept.
1616
1425
  try {
1617
- val packed = YuvImageConverter.packNV21(image)
1618
- ?: run {
1619
- android.util.Log.w(
1620
- "IncrementalStitcher",
1621
- "consumeFrameFromPlugin: packNV21 returned null for $targetPath",
1622
- )
1623
- return@run null
1624
- }
1625
- if (packed == null) {
1626
- false
1627
- } else {
1628
- val displayRotation = when (sensorRotationDegrees) {
1629
- 0 -> android.view.Surface.ROTATION_90
1630
- 90 -> android.view.Surface.ROTATION_0
1631
- 180 -> android.view.Surface.ROTATION_270
1632
- 270 -> android.view.Surface.ROTATION_180
1633
- else -> android.view.Surface.ROTATION_0
1634
- }
1635
- val outPath = YuvImageConverter.encodeJpegFromNV21(
1636
- packed,
1637
- targetPath,
1638
- jpegQuality = 80,
1639
- displayRotation = displayRotation,
1640
- )
1641
- outPath != null
1426
+ val displayRotation = when (sensorRotationDegrees) {
1427
+ 0 -> android.view.Surface.ROTATION_90
1428
+ 90 -> android.view.Surface.ROTATION_0
1429
+ 180 -> android.view.Surface.ROTATION_270
1430
+ 270 -> android.view.Surface.ROTATION_180
1431
+ else -> android.view.Surface.ROTATION_0
1642
1432
  }
1433
+ val outPath = YuvImageConverter.encodeJpegFromNV21(
1434
+ packed,
1435
+ targetPath,
1436
+ jpegQuality = 80,
1437
+ displayRotation = displayRotation,
1438
+ )
1439
+ outPath != null
1643
1440
  } catch (e: Throwable) {
1644
1441
  android.util.Log.w(
1645
1442
  "IncrementalStitcher",
@@ -2548,7 +2345,91 @@ internal class IncrementalEngine(
2548
2345
  // See iOS' equivalent fix for the architectural rationale.
2549
2346
  val frame = downsampleToCompose(srcRaw)
2550
2347
  if (frame !== srcRaw) srcRaw.release()
2348
+ return addFrameMat(
2349
+ frame,
2350
+ qx, qy, qz, qw,
2351
+ fx, fy, cx, cy,
2352
+ imageWidth, imageHeight,
2353
+ yaw, pitch,
2354
+ fovHorizDegrees, fovVertDegrees,
2355
+ t0,
2356
+ )
2357
+ }
2551
2358
 
2359
+ /**
2360
+ * F8.6 — pixel-data twin of [addFrameAtPath]. Accepts the
2361
+ * camera frame as an NV21 byte buffer instead of a JPEG file
2362
+ * path; skips the JPEG decode round-trip. See
2363
+ * `IncrementalFirstwinsEngine.addFramePixelData` for the
2364
+ * sibling implementation rationale.
2365
+ */
2366
+ fun addFramePixelData(
2367
+ nv21: ByteArray,
2368
+ nv21Width: Int,
2369
+ nv21Height: Int,
2370
+ qx: Double, qy: Double, qz: Double, qw: Double,
2371
+ fx: Double, fy: Double, cx: Double, cy: Double,
2372
+ imageWidth: Int, imageHeight: Int,
2373
+ yaw: Double, pitch: Double,
2374
+ fovHorizDegrees: Double, fovVertDegrees: Double,
2375
+ trackingPoor: Boolean,
2376
+ ): FrameTelemetry {
2377
+ val t0 = System.nanoTime()
2378
+ if (trackingPoor) {
2379
+ return FrameTelemetry(
2380
+ FrameOutcome.SkippedTrackingPoor, -1.0, 0, 0.0, 0.0,
2381
+ msSince(t0),
2382
+ )
2383
+ }
2384
+ // F8.6 IS-1 — length guard; see
2385
+ // `IncrementalFirstwinsEngine.addFramePixelData` for the
2386
+ // failure-mode rationale.
2387
+ val expectedBytes = nv21Width * nv21Height * 3 / 2
2388
+ require(nv21.size >= expectedBytes) {
2389
+ "addFramePixelData: nv21 buffer too small " +
2390
+ "(${nv21.size} bytes < $expectedBytes for " +
2391
+ "${nv21Width}x${nv21Height})"
2392
+ }
2393
+ val yuv = Mat(nv21Height + nv21Height / 2, nv21Width, CvType.CV_8UC1)
2394
+ yuv.put(0, 0, nv21)
2395
+ val srcRaw = Mat()
2396
+ Imgproc.cvtColor(yuv, srcRaw, Imgproc.COLOR_YUV2BGR_NV21)
2397
+ yuv.release()
2398
+ if (srcRaw.empty()) {
2399
+ return FrameTelemetry(
2400
+ FrameOutcome.SkippedTrackingPoor, -1.0, 0, 0.0, 0.0,
2401
+ msSince(t0),
2402
+ )
2403
+ }
2404
+ val frame = downsampleToCompose(srcRaw)
2405
+ if (frame !== srcRaw) srcRaw.release()
2406
+ return addFrameMat(
2407
+ frame,
2408
+ qx, qy, qz, qw,
2409
+ fx, fy, cx, cy,
2410
+ imageWidth, imageHeight,
2411
+ yaw, pitch,
2412
+ fovHorizDegrees, fovVertDegrees,
2413
+ t0,
2414
+ )
2415
+ }
2416
+
2417
+ /**
2418
+ * F8.6 — the body extracted from [addFrameAtPath]. Takes a
2419
+ * BGR `Mat` (already downsampled to compose dims) and runs the
2420
+ * pose-driven homography paste pipeline. Behaviour is
2421
+ * identical to the pre-F8.6 `addFrameAtPath` — the body is a
2422
+ * verbatim move.
2423
+ */
2424
+ private fun addFrameMat(
2425
+ frame: Mat,
2426
+ qx: Double, qy: Double, qz: Double, qw: Double,
2427
+ fx: Double, fy: Double, cx: Double, cy: Double,
2428
+ imageWidth: Int, imageHeight: Int,
2429
+ yaw: Double, pitch: Double,
2430
+ fovHorizDegrees: Double, fovVertDegrees: Double,
2431
+ t0: Long,
2432
+ ): FrameTelemetry {
2552
2433
  // Build R_new from quaternion.
2553
2434
  val rNew = quaternionToRotationMat(qx, qy, qz, qw)
2554
2435