react-native-image-stitcher 0.5.1 → 0.7.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 (28) hide show
  1. package/CHANGELOG.md +199 -1
  2. package/android/src/main/java/io/imagestitcher/rn/CvFlowGateFrameProcessor.kt +2 -2
  3. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +2 -30
  4. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +90 -368
  5. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +6 -3
  6. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +17 -30
  7. package/dist/camera/Camera.d.ts +11 -27
  8. package/dist/camera/Camera.js +46 -78
  9. package/dist/index.d.ts +2 -3
  10. package/dist/index.js +10 -6
  11. package/dist/stitching/incremental.d.ts +79 -11
  12. package/dist/stitching/useFrameProcessorDriver.d.ts +7 -6
  13. package/dist/stitching/useFrameProcessorDriver.js +12 -11
  14. package/dist/stitching/useKeyframeStream.d.ts +69 -0
  15. package/dist/stitching/useKeyframeStream.js +120 -0
  16. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +48 -208
  17. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +0 -8
  18. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +6 -126
  19. package/ios/Sources/RNImageStitcher/KeyframeGateFrameProcessor.mm +6 -6
  20. package/package.json +1 -1
  21. package/src/camera/Camera.tsx +57 -106
  22. package/src/index.ts +9 -9
  23. package/src/stitching/incremental.ts +84 -11
  24. package/src/stitching/useFrameProcessorDriver.ts +12 -11
  25. package/src/stitching/useKeyframeStream.ts +127 -0
  26. package/dist/stitching/useIncrementalJSDriver.d.ts +0 -74
  27. package/dist/stitching/useIncrementalJSDriver.js +0 -220
  28. package/src/stitching/useIncrementalJSDriver.ts +0 -297
@@ -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,27 +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
-
244
- /// F8.6 perf-diagnostic one-shot guard so the "live-engine
245
- /// route" log fires exactly once per capture session. Reset
246
- /// in start().
247
- private val f8_6_routeLoggedThisCapture = AtomicBoolean(false)
248
- /// Critic #5 fix: serial dispatcher so concurrent
249
- /// processFrameAtPath() calls can't race on the engine's canvas.
250
- /// `limitedParallelism(1)` guarantees one-at-a-time execution
251
- /// while still backing onto the Default pool — matches iOS'
252
- /// `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).
253
250
  @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
254
251
  private val workScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1))
255
252
 
@@ -280,17 +277,6 @@ class IncrementalStitcher(
280
277
  /// on start/stop so the view feeds frames only during a capture.
281
278
  @Volatile private var arCameraViewRef: RNSARCameraView? = null
282
279
 
283
- /// 2026-05-21 (v0.3) — public-to-the-view-file getter so
284
- /// `RNSARCameraView.forwardToIncremental` can ask whether the
285
- /// currently-running engine is the batch-keyframe path (v0.3
286
- /// Y-plane pixel-data flow) or the legacy hybrid/firstwins live
287
- /// engine (which still needs a per-frame JPEG path). Used to
288
- /// elide the eager JPEG encode for batchKeyframe mode (the
289
- /// production Camera component's path); the legacy live engine
290
- /// still pays the per-frame JPEG cost.
291
- internal val isBatchKeyframeMode: Boolean
292
- get() = batchKeyframeMode
293
-
294
280
  init {
295
281
  // Static back-pointer so `RNSARCameraView` can call into
296
282
  // the singleton-style bridge module without a DI dance. RN
@@ -332,15 +318,17 @@ class IncrementalStitcher(
332
318
  // F8.4 — frameSourceMode honoured on Android. Pre-F8.4,
333
319
  // Android ignored this option (only iOS interpreted it).
334
320
  // Now `"frameProcessor"` unlocks `consumeFrameFromPlugin`'s
335
- // producer-thread ingest path; everything else (the
336
- // implicit default + the legacy `"jsDriver"`) keeps the
337
- // ingest path dormant so the existing `processFrameAtPath`
338
- // / ARCore paths run unmodified.
339
- 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"
340
331
  frameProcessorIngestEnabled.set(frameSourceMode == "frameProcessor")
341
- // F8.6 perf-diagnostic — re-arm the route log for the
342
- // new capture.
343
- f8_6_routeLoggedThisCapture.set(false)
344
332
  val rotation = options.getIntOrDefault("frameRotationDegrees", 90)
345
333
  val composeW = options.getIntOrDefault("composeWidth", 960)
346
334
  val composeH = options.getIntOrDefault("composeHeight", 720)
@@ -593,14 +581,6 @@ class IncrementalStitcher(
593
581
  }
594
582
  }
595
583
 
596
- /**
597
- * Feed one frame at a JPEG path into the engine. Pose inputs
598
- * drive the same FoV-overlap gate as iOS. When a pose source
599
- * isn't available pass yaw=0, pitch=0, fovHorizDegrees=0 — the
600
- * engine treats fov<=0 as a sentinel for "no intrinsics" and
601
- * substitutes a 65° default, so frames will still be processed,
602
- * just less gated.
603
- */
604
584
  /**
605
585
  * Copy a (non-persistent) source JPEG to a persistent per-keyframe
606
586
  * path under the React context's cache dir. The ARCameraView's
@@ -622,67 +602,6 @@ class IncrementalStitcher(
622
602
  * for a Phase 3 follow-up; this MVP is just enough to make
623
603
  * batch-keyframe work end-to-end on Android.
624
604
  */
625
- /**
626
- * 2026-05-21 (v0.3) — JPEG-to-grayscale decode for the JS-driver
627
- * (non-AR) keyframe-gate path. Used by the batch-keyframe branch
628
- * of processFrameAtPath to feed the C++ KeyframeGate with real
629
- * pixel data so its Flow strategy actually runs (pre-0.3 the
630
- * non-AR call was `evaluate(pose, null)` which silently fell back
631
- * to the Pose strategy because no pixel data was supplied).
632
- *
633
- * Uses OpenCV's Imgcodecs.imread with IMREAD_GRAYSCALE, which
634
- * decodes the JPEG straight into a single-channel CV_8UC1 Mat —
635
- * faster than BitmapFactory + manual luma loop (~10-20 ms here
636
- * vs ~100+ ms in interpreted Kotlin for a 1920×1080 image).
637
- *
638
- * The non-AR snapshot cadence is ~4 FPS so the per-call cost is
639
- * well under the inter-snapshot interval. v0.4 will replace
640
- * this code path entirely by moving non-AR capture to
641
- * vision-camera's Frame Processor API (which delivers raw
642
- * pixels directly with no JPEG roundtrip). Tracked at issue #11.
643
- *
644
- * Returns null when the decode fails (corrupt JPEG, OOM, etc.);
645
- * caller is expected to fall back to the pose-only evaluate
646
- * path so the capture doesn't lock up.
647
- */
648
- private data class GrayscaleFrame(
649
- val bytes: ByteArray,
650
- val width: Int,
651
- val height: Int,
652
- val stride: Int,
653
- )
654
-
655
- private fun decodeJpegToGrayscale(path: String): GrayscaleFrame? {
656
- val mat = Imgcodecs.imread(path, Imgcodecs.IMREAD_GRAYSCALE)
657
- if (mat.empty()) {
658
- android.util.Log.w(
659
- "IncrementalStitcher",
660
- "decodeJpegToGrayscale: imread returned empty Mat for $path"
661
- )
662
- return null
663
- }
664
- return try {
665
- val width = mat.cols()
666
- val height = mat.rows()
667
- // step1() returns bytes-per-row for a CV_8UC1 Mat. For a
668
- // continuous Mat from imread (no ROI) stride == width.
669
- val stride = mat.step1().toInt()
670
- val size = stride * height
671
- val bytes = ByteArray(size)
672
- mat.get(0, 0, bytes)
673
- GrayscaleFrame(bytes, width, height, stride)
674
- } catch (e: Exception) {
675
- android.util.Log.w(
676
- "IncrementalStitcher",
677
- "decodeJpegToGrayscale: failed to copy Mat bytes for $path: ${e.message}",
678
- e,
679
- )
680
- null
681
- } finally {
682
- mat.release()
683
- }
684
- }
685
-
686
605
  private fun copyKeyframeToStore(srcPath: String): String? {
687
606
  // V16 Phase 2 (Android Fix-1) — write into the per-session
688
607
  // subdir created by start(). If start() didn't run (defensive
@@ -716,8 +635,8 @@ class IncrementalStitcher(
716
635
  // ── V16 Phase 1 → P3-F migration note ────────────────────────
717
636
  // The frame-counter placeholder gate `handleBatchKeyframeFrame`
718
637
  // that lived here has been REMOVED. Both the AR-driven path
719
- // (ingestFromARCameraView) and the vision-camera fallback path
720
- // (processFrameAtPath) now route through the shared-C++
638
+ // (`ingestFromARCameraView`) and the Frame Processor path
639
+ // (`consumeFrameFromPlugin`) now route through the shared-C++
721
640
  // `KeyframeGate` (cpp/keyframe_gate.{hpp,cpp}) — same algorithm
722
641
  // iOS has used since the V16 ship. See
723
642
  // `private val keyframeGate = KeyframeGate()` above for the
@@ -730,204 +649,6 @@ class IncrementalStitcher(
730
649
  // we ever add a "force every Nth frame regardless of overlap"
731
650
  // override.
732
651
 
733
- @ReactMethod
734
- fun processFrameAtPath(options: ReadableMap, promise: Promise) {
735
- val hybrid = this.engine
736
- val firstwins = this.firstwinsEngine
737
- // batch-keyframe mode runs without a live engine — handle it
738
- // up-front before the null-check rejects.
739
- //
740
- // P3-F: this path uses the same shared-C++ KeyframeGate as
741
- // the AR view path, but with translation = 0 (gyro-derived
742
- // poses have only rotation, not position). The gate's
743
- // internal logic detects the missing plane and uses the
744
- // camera-forward angular-delta fallback automatically.
745
- if (batchKeyframeMode) {
746
- val path = options.getString("path")
747
- ?: return promise.reject("invalid-options", "path required")
748
- val pose = RNSARFramePose(
749
- tx = options.getDoubleOrDefault("tx", 0.0) ?: 0.0,
750
- ty = options.getDoubleOrDefault("ty", 0.0) ?: 0.0,
751
- tz = options.getDoubleOrDefault("tz", 0.0) ?: 0.0,
752
- qx = options.getDoubleOrDefault("qx", 0.0) ?: 0.0,
753
- qy = options.getDoubleOrDefault("qy", 0.0) ?: 0.0,
754
- qz = options.getDoubleOrDefault("qz", 0.0) ?: 0.0,
755
- qw = options.getDoubleOrDefault("qw", 1.0) ?: 1.0,
756
- fx = options.getDoubleOrDefault("fx", 1000.0) ?: 1000.0,
757
- fy = options.getDoubleOrDefault("fy", 1000.0) ?: 1000.0,
758
- cx = options.getDoubleOrDefault("cx", 540.0) ?: 540.0,
759
- cy = options.getDoubleOrDefault("cy", 960.0) ?: 960.0,
760
- imageWidth = options.getIntOrDefault("imageWidth", 1080),
761
- imageHeight = options.getIntOrDefault("imageHeight", 1920),
762
- timestampMs = 0.0,
763
- trackingState = RNSARSession.TRACKING_TRACKING,
764
- )
765
- // Vision-camera path: no plane available (gyro can't fit
766
- // planes). Pass null → C++ skips the plane-overlap math.
767
- //
768
- // 2026-05-21 (v0.3) — pixel-aware Flow-strategy evaluation.
769
- // The JS driver hands us a JPEG file path; we decode it to
770
- // grayscale here so the C++ gate actually runs sparse-flow
771
- // novelty (pre-0.3 the call was `evaluate(pose, null)` and
772
- // the C++ side silently fell back to Pose strategy because
773
- // no pixel data was supplied — same bug as Android AR
774
- // mode, both fixed in v0.3). BitmapFactory + manual luma
775
- // is fine here — non-AR snapshot cadence is ~4 FPS, the
776
- // ~15-30 ms JPEG-decode-to-grayscale per call is well
777
- // under the inter-snapshot interval. (v0.4 will move
778
- // non-AR to vision-camera's Frame Processor API, at which
779
- // point the JPEG step goes away entirely. Tracked at
780
- // https://github.com/bhargavkanda/react-native-image-stitcher/issues/11)
781
- val gray = decodeJpegToGrayscale(path)
782
- val decision = if (gray != null) {
783
- keyframeGate.evaluateWithFrame(
784
- pose, null,
785
- gray.bytes, gray.width, gray.height, gray.stride,
786
- )
787
- } else {
788
- // JPEG decode failed (corrupt file? OOM?). Fall back
789
- // to pose-only path so the capture doesn't lock up;
790
- // this matches the C++ side's own defensive fallback
791
- // for null grayData.
792
- keyframeGate.evaluate(pose, null)
793
- }
794
- val result = Arguments.createMap()
795
- // Outcome mapping for iOS-parity JS contract:
796
- // 1 = accepted, 2 = rejected (gate), 3 = rejected (cap).
797
- // C++ enum → outcome int:
798
- val outcome = when {
799
- decision.accept -> 1
800
- decision.reason == "max-reached" -> 3
801
- else -> 2
802
- }
803
- result.putInt("outcome", outcome)
804
- if (!decision.accept) {
805
- // 2026-05-22 (audit follow-up) — emit reject-state on
806
- // the non-AR JS-driver path too so the debug overlay
807
- // sees overlap % update on every snapshot (~4 Hz).
808
- // Same rationale as the AR path's reject emission
809
- // above; iOS parity.
810
- emitBatchKeyframeRejectState(
811
- decision = decision,
812
- keyframeCount = batchKeyframePaths.size,
813
- keyframeMax = keyframeGate.maxCount,
814
- isLandscape = pose.imageWidth >= pose.imageHeight,
815
- )
816
- }
817
- if (decision.accept) {
818
- batchKeyframePaths.add(path) // vision-camera path
819
- // gives us a unique
820
- // per-snapshot file
821
- // already (no copy
822
- // needed).
823
- // Emit the same state event the AR path emits so
824
- // the JS LiveFrameStrip + "Keyframes n/max" pill
825
- // work identically on the vision-camera fallback
826
- // path.
827
- emitBatchKeyframeAcceptedState(
828
- thumbnailPath = path,
829
- keyframeIndex = batchKeyframePaths.size - 1,
830
- keyframeCount = batchKeyframePaths.size,
831
- keyframeMax = keyframeGate.maxCount,
832
- isLandscape = pose.imageWidth >= pose.imageHeight,
833
- newContentFraction = decision.newContentFraction,
834
- )
835
- }
836
- result.putInt("acceptedCount", batchKeyframePaths.size)
837
- promise.resolve(result)
838
- return
839
- }
840
- if (hybrid == null && firstwins == null) {
841
- return promise.reject(
842
- "incremental-not-running",
843
- "Call start() before processFrameAtPath().",
844
- )
845
- }
846
- val path = options.getString("path")
847
- ?: return promise.reject("invalid-options", "path required")
848
- val yaw = options.getDoubleOrDefault("yaw", 0.0)
849
- val pitch = options.getDoubleOrDefault("pitch", 0.0)
850
- val fovH = options.getDoubleOrDefault("fovHorizDegrees", 65.0)
851
- val fovV = options.getDoubleOrDefault("fovVertDegrees", 50.0)
852
- // V6 pose-driven params. Defaults removed per critic finding
853
- // #3: previously qw=1.0 default meant frames without explicit
854
- // quaternion produced an identity rotation, and EVERY
855
- // subsequent frame had R_rel = R_first^T (constant), so
856
- // strip placement never advanced and `acceptedCount` froze
857
- // at 1 after the first frame. Now every quaternion field is
858
- // required; missing → reject as RejectedAlignmentLost so the
859
- // gyro driver upstream notices instantly.
860
- if (!options.hasKey("qx") || !options.hasKey("qy")
861
- || !options.hasKey("qz") || !options.hasKey("qw")) {
862
- return promise.reject(
863
- "invalid-options",
864
- "qx/qy/qz/qw all required (no identity-quaternion fallback)",
865
- )
866
- }
867
- val qx = options.getDouble("qx")
868
- val qy = options.getDouble("qy")
869
- val qz = options.getDouble("qz")
870
- val qw = options.getDouble("qw")
871
- val fx = options.getDoubleOrDefault("fx", 0.0)
872
- val fy = options.getDoubleOrDefault("fy", 0.0)
873
- val cx = options.getDoubleOrDefault("cx", 0.0)
874
- val cy = options.getDoubleOrDefault("cy", 0.0)
875
- val imageWidth = options.getIntOrDefault("imageWidth", 0)
876
- val imageHeight = options.getIntOrDefault("imageHeight", 0)
877
- val trackingPoor = options.getBooleanOrDefault("trackingPoor", false)
878
-
879
- workScope.launch {
880
- // Critic #4 fix: re-check isRunning synchronously here in
881
- // case finalize/cancel ran on the JS thread between the
882
- // null-check above and this dispatch landing. Skip the
883
- // ingest if we're no longer running — matches iOS' V12.1
884
- // pattern (synchronous-stop + worker re-check).
885
- if (!isRunning.get()) {
886
- promise.resolve(Arguments.createMap().apply { putInt("outcome", -1) })
887
- return@launch
888
- }
889
- try {
890
- val telemetry: FrameTelemetry
891
- val state: WritableMap?
892
- val accepted: Int
893
- if (firstwins != null) {
894
- telemetry = firstwins.addFrameAtPath(
895
- path = path,
896
- qx = qx, qy = qy, qz = qz, qw = qw,
897
- fx = fx, fy = fy, cx = cx, cy = cy,
898
- imageWidth = imageWidth, imageHeight = imageHeight,
899
- yaw = yaw, pitch = pitch,
900
- fovHorizDegrees = fovH, fovVertDegrees = fovV,
901
- trackingPoor = trackingPoor,
902
- )
903
- state = firstwins.snapshotIfDue(telemetry)
904
- accepted = firstwins.acceptedCount
905
- } else {
906
- telemetry = hybrid!!.addFrameAtPath(
907
- path = path,
908
- qx = qx, qy = qy, qz = qz, qw = qw,
909
- fx = fx, fy = fy, cx = cx, cy = cy,
910
- imageWidth = imageWidth, imageHeight = imageHeight,
911
- yaw = yaw, pitch = pitch,
912
- fovHorizDegrees = fovH, fovVertDegrees = fovV,
913
- trackingPoor = trackingPoor,
914
- )
915
- state = hybrid.snapshotIfDue(telemetry)
916
- accepted = hybrid.acceptedCount
917
- }
918
- emitState(state)
919
- val result = Arguments.createMap()
920
- result.putInt("outcome", telemetry.outcome.ordinal)
921
- result.putDouble("confidence", telemetry.confidence)
922
- result.putDouble("overlapPercent", telemetry.overlapPercent)
923
- result.putDouble("processingMs", telemetry.processingMs)
924
- result.putInt("acceptedCount", accepted)
925
- promise.resolve(result)
926
- } catch (t: Throwable) {
927
- promise.reject("incremental-process-failed", t.message, t)
928
- }
929
- }
930
- }
931
652
 
932
653
  @ReactMethod
933
654
  fun finalize(options: ReadableMap, promise: Promise) {
@@ -971,9 +692,10 @@ class IncrementalStitcher(
971
692
  // frames slip into the engine while we serialize the canvas.
972
693
  arCameraViewRef?.setIncrementalIngestionActive(false)
973
694
  // Critic #4 fix: synchronously flip isRunning=false BEFORE
974
- // dispatching the finalize body, so any in-flight
975
- // processFrameAtPath workers that are about to launch will
976
- // 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.
977
699
  // Matches iOS V12.1 fix.
978
700
  isRunning.set(false)
979
701
  frameProcessorIngestEnabled.set(false) // F8.4 — cut producer-thread ingest at finalize
@@ -1266,9 +988,10 @@ class IncrementalStitcher(
1266
988
  // is false (the legacy hybrid/firstwins live-engine path,
1267
989
  // which feeds JPEG paths into addFrameAtPath for each ARCore
1268
990
  // frame). Pass null when batchKeyframeMode is true; the
1269
- // batch path uses `grayData` + `onAccept` instead. Callers
1270
- // can check `isBatchKeyframeMode` to elide the per-frame
1271
- // 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.
1272
995
  legacyJpegPath: String? = null,
1273
996
  // F8.6 — pixel-data path for live engines. When supplied
1274
997
  // (and `batchKeyframeMode == false`), takes precedence over
@@ -1451,6 +1174,15 @@ class IncrementalStitcher(
1451
1174
  keyframeMax = keyframeGate.maxCount,
1452
1175
  isLandscape = imageWidth >= imageHeight,
1453
1176
  newContentFraction = decision.newContentFraction,
1177
+ // v0.7.0 — Tier 1 hook: pose snapshot + accept timestamp
1178
+ // threaded through to JS via the existing state-update
1179
+ // channel. `tx,ty,tz,qx,qy,qz,qw` are parameters of
1180
+ // `ingestFromARCameraView`; in AR mode they're real
1181
+ // ARCore pose components, in non-AR mode they're
1182
+ // gyro-synthesised (translation ≈ 0).
1183
+ poseQx = qx, poseQy = qy, poseQz = qz, poseQw = qw,
1184
+ poseTx = tx, poseTy = ty, poseTz = tz,
1185
+ acceptedAtMs = System.currentTimeMillis(),
1454
1186
  )
1455
1187
  return
1456
1188
  }
@@ -1473,26 +1205,13 @@ class IncrementalStitcher(
1473
1205
  val hasPixelData = nv21PixelData != null
1474
1206
  && nv21PixelWidth > 0
1475
1207
  && nv21PixelHeight > 0
1476
- // F8.6 perf-diagnostic — log route choice once per capture
1477
- // so logcat shows whether the new pixel-data path is
1478
- // actually getting exercised. Throttled to first-hit per
1479
- // capture (counter resets in start()/cancel()).
1480
- if (!f8_6_routeLoggedThisCapture.getAndSet(true)) {
1481
- android.util.Log.i(
1482
- "F8.6-route",
1483
- "ingestFromARCameraView live-engine route: "
1484
- + if (hasPixelData) "pixel-data (F8.6, no JPEG round-trip)"
1485
- else "jpeg-path (legacy, JPEG decode round-trip)",
1486
- )
1487
- }
1488
1208
  val path = if (hasPixelData) null else legacyJpegPath ?: run {
1489
1209
  android.util.Log.w(
1490
1210
  "IncrementalStitcher",
1491
1211
  "ingestFromARCameraView legacy: batchKeyframeMode=false " +
1492
1212
  "but both legacyJpegPath and nv21PixelData are null — " +
1493
- "dropping frame. Caller should have encoded a JPEG " +
1494
- "OR supplied NV21 pixel data when " +
1495
- "isBatchKeyframeMode == false.",
1213
+ "dropping frame. Caller must supply NV21 pixel data " +
1214
+ "(preferred) or a JPEG path for the live engine path.",
1496
1215
  )
1497
1216
  return
1498
1217
  }
@@ -1611,8 +1330,8 @@ class IncrementalStitcher(
1611
1330
  ) {
1612
1331
  // F8.4 — drop the call unless this capture was started in
1613
1332
  // frameProcessor mode. Otherwise the plugin would double-
1614
- // feed the engine alongside the legacy jsDriver /
1615
- // processFrameAtPath path. See the flag's declaration
1333
+ // feed the engine alongside the AR-mode
1334
+ // `ingestFromARCameraView` path. See the flag's declaration
1616
1335
  // for the full reasoning. Mirrors iOS H1.
1617
1336
  if (!frameProcessorIngestEnabled.get()) return
1618
1337
 
@@ -2324,6 +2043,13 @@ class IncrementalStitcher(
2324
2043
  // "unknown" behaviour for call sites that don't have a
2325
2044
  // decision in hand.
2326
2045
  newContentFraction: Double,
2046
+ // v0.7.0 — Tier 1 hook fields. Pose is the AR pose at the
2047
+ // accept moment (gyro-synthesised in non-AR mode — translation
2048
+ // reads as ~zeros). `acceptedAtMs` is wall-clock ms since
2049
+ // Unix epoch; matches `Date.now()` on the JS side.
2050
+ poseQx: Double, poseQy: Double, poseQz: Double, poseQw: Double,
2051
+ poseTx: Double, poseTy: Double, poseTz: Double,
2052
+ acceptedAtMs: Long,
2327
2053
  ) {
2328
2054
  val state = Arguments.createMap()
2329
2055
  state.putNull("panoramaPath")
@@ -2353,6 +2079,25 @@ class IncrementalStitcher(
2353
2079
  // emitter).
2354
2080
  state.putString("batchKeyframeThumbnailPath", thumbnailPath)
2355
2081
  state.putInt("batchKeyframeIndex", keyframeIndex)
2082
+ // v0.7.0 — Tier 1 hook (useKeyframeStream) reads these. See
2083
+ // `AcceptedKeyframe` in src/stitching/incremental.ts. Translation
2084
+ // is always emitted; AR mode populates it from the camera
2085
+ // transform, non-AR mode reads ~zeros (gyro-only, no spatial
2086
+ // anchor).
2087
+ val pose = Arguments.createMap()
2088
+ val rotation = Arguments.createArray()
2089
+ rotation.pushDouble(poseQx)
2090
+ rotation.pushDouble(poseQy)
2091
+ rotation.pushDouble(poseQz)
2092
+ rotation.pushDouble(poseQw)
2093
+ pose.putArray("rotation", rotation)
2094
+ val translation = Arguments.createArray()
2095
+ translation.pushDouble(poseTx)
2096
+ translation.pushDouble(poseTy)
2097
+ translation.pushDouble(poseTz)
2098
+ pose.putArray("translation", translation)
2099
+ state.putMap("batchKeyframePose", pose)
2100
+ state.putDouble("batchKeyframeAcceptedAtMs", acceptedAtMs.toDouble())
2356
2101
  emitState(state)
2357
2102
  }
2358
2103
 
@@ -2635,7 +2380,7 @@ internal class IncrementalEngine(
2635
2380
  // See iOS' equivalent fix for the architectural rationale.
2636
2381
  val frame = downsampleToCompose(srcRaw)
2637
2382
  if (frame !== srcRaw) srcRaw.release()
2638
- val tele = addFrameMat(
2383
+ return addFrameMat(
2639
2384
  frame,
2640
2385
  qx, qy, qz, qw,
2641
2386
  fx, fy, cx, cy,
@@ -2644,8 +2389,6 @@ internal class IncrementalEngine(
2644
2389
  fovHorizDegrees, fovVertDegrees,
2645
2390
  t0,
2646
2391
  )
2647
- f8_6_logPerf("hybrid/jpeg", t0, tele.outcome)
2648
- return tele
2649
2392
  }
2650
2393
 
2651
2394
  /**
@@ -2695,7 +2438,7 @@ internal class IncrementalEngine(
2695
2438
  }
2696
2439
  val frame = downsampleToCompose(srcRaw)
2697
2440
  if (frame !== srcRaw) srcRaw.release()
2698
- val tele = addFrameMat(
2441
+ return addFrameMat(
2699
2442
  frame,
2700
2443
  qx, qy, qz, qw,
2701
2444
  fx, fy, cx, cy,
@@ -2704,27 +2447,6 @@ internal class IncrementalEngine(
2704
2447
  fovHorizDegrees, fovVertDegrees,
2705
2448
  t0,
2706
2449
  )
2707
- f8_6_logPerf("hybrid/pixel", t0, tele.outcome)
2708
- return tele
2709
- }
2710
-
2711
- /**
2712
- * F8.6 perf-diagnostic counter (mirror of the firstwins one).
2713
- * Remove after v0.5.1 ships and the numbers are baked in.
2714
- */
2715
- @Volatile private var f8_6_perfCallCounter: Long = 0L
2716
- private fun f8_6_logPerf(
2717
- path: String,
2718
- t0Nanos: Long,
2719
- outcome: FrameOutcome,
2720
- ) {
2721
- val n = ++f8_6_perfCallCounter
2722
- if (n == 1L || n % 5L == 0L) {
2723
- android.util.Log.i(
2724
- "F8.6-perf",
2725
- "$path took ${msSince(t0Nanos)}ms outcome=$outcome (call #$n)",
2726
- )
2727
- }
2728
2450
  }
2729
2451
 
2730
2452
  /**
@@ -247,9 +247,12 @@ internal class KeyframeGate : AutoCloseable {
247
247
  * the Y plane from the ARCore camera image (YUV_420_888) and
248
248
  * hands it through. Zero-copy on the way in (the byte[] is
249
249
  * pinned via GetPrimitiveArrayCritical in the JNI).
250
- * - Non-AR mode (`IncrementalStitcher.processFrameAtPath`): the
251
- * JS-driver path supplies a JPEG path; the caller decodes the
252
- * JPEG to grayscale before calling this method.
250
+ * - Non-AR mode (`CvFlowGateFrameProcessor` via
251
+ * `IncrementalStitcher.consumeFrameFromPlugin`): extracts the
252
+ * Y plane from the vision-camera Frame's YUV_420_888 image on
253
+ * the producer thread and hands it through. (Pre-v0.6 a
254
+ * JS-driver `processFrameAtPath` path also called this with
255
+ * JPEG-decoded grayscale; both were removed in v0.6.)
253
256
  *
254
257
  * @param grayData The grayscale plane bytes. Length must be
255
258
  * at least `grayStride * grayHeight`.