react-native-image-stitcher 0.16.2 → 0.18.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 (52) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/RNImageStitcher.podspec +26 -1
  3. package/android/build.gradle +20 -0
  4. package/android/src/main/cpp/CMakeLists.txt +46 -3
  5. package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +436 -0
  6. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +6 -0
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +711 -6
  8. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +156 -0
  9. package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +103 -0
  10. package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +338 -0
  11. package/cpp/{stitcher_frame_data.hpp → camera_frame_data.hpp} +96 -13
  12. package/cpp/camera_frame_jsi.cpp +357 -0
  13. package/cpp/camera_frame_jsi.hpp +108 -0
  14. package/cpp/stitcher_proxy_jsi.cpp +140 -0
  15. package/cpp/stitcher_proxy_jsi.hpp +62 -0
  16. package/cpp/stitcher_worklet_dispatch.cpp +103 -0
  17. package/cpp/stitcher_worklet_dispatch.hpp +71 -0
  18. package/cpp/stitcher_worklet_registry.cpp +91 -0
  19. package/cpp/stitcher_worklet_registry.hpp +146 -0
  20. package/dist/camera/ARCameraView.d.ts +77 -0
  21. package/dist/camera/ARCameraView.js +90 -1
  22. package/dist/camera/Camera.d.ts +63 -4
  23. package/dist/camera/Camera.js +2 -2
  24. package/dist/camera/CaptureMemoryPill.d.ts +4 -3
  25. package/dist/camera/CaptureMemoryPill.js +4 -3
  26. package/dist/index.d.ts +2 -1
  27. package/dist/stitching/ARFrameMeta.d.ts +100 -0
  28. package/dist/stitching/{StitcherFrame.js → ARFrameMeta.js} +1 -1
  29. package/dist/stitching/{StitcherFrame.d.ts → CameraFrame.d.ts} +70 -11
  30. package/dist/stitching/CameraFrame.js +4 -0
  31. package/dist/stitching/ensureStitcherProxyInstalled.d.ts +8 -0
  32. package/dist/stitching/ensureStitcherProxyInstalled.js +81 -0
  33. package/dist/stitching/useStitcherWorklet.d.ts +4 -4
  34. package/dist/stitching/useStitcherWorklet.js +4 -4
  35. package/ios/Sources/RNImageStitcher/ARSessionBridge.m +23 -1
  36. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +137 -2
  37. package/ios/Sources/RNImageStitcher/CameraFrameHostObject.h +83 -0
  38. package/ios/Sources/RNImageStitcher/CameraFrameHostObject.mm +760 -0
  39. package/ios/Sources/RNImageStitcher/RNSARSession.swift +336 -40
  40. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +128 -0
  41. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +313 -0
  42. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.h +42 -0
  43. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +160 -0
  44. package/package.json +1 -1
  45. package/src/camera/ARCameraView.tsx +211 -2
  46. package/src/camera/Camera.tsx +81 -4
  47. package/src/camera/CaptureMemoryPill.tsx +4 -3
  48. package/src/index.ts +7 -3
  49. package/src/stitching/ARFrameMeta.ts +107 -0
  50. package/src/stitching/{StitcherFrame.ts → CameraFrame.ts} +79 -11
  51. package/src/stitching/ensureStitcherProxyInstalled.ts +141 -0
  52. package/src/stitching/useStitcherWorklet.ts +9 -9
@@ -18,6 +18,8 @@ import com.google.ar.core.exceptions.CameraNotAvailableException
18
18
  import com.google.ar.core.exceptions.SessionPausedException
19
19
  import io.imagestitcher.rn.ar.BackgroundRenderer
20
20
  import io.imagestitcher.rn.ar.YuvImageConverter
21
+ import java.nio.ByteBuffer
22
+ import java.nio.ByteOrder
21
23
  import java.util.concurrent.atomic.AtomicReference
22
24
  import javax.microedition.khronos.egl.EGLConfig
23
25
  import javax.microedition.khronos.opengles.GL10
@@ -383,17 +385,38 @@ class RNSARCameraView @JvmOverloads constructor(
383
385
  cameraPosWorld,
384
386
  )
385
387
 
388
+ // v0.8.0 Phase 4b.iii — ensure the host-worklet runtime is
389
+ // installed before any per-frame fan-out can run. Idempotent
390
+ // (AtomicBoolean CAS): the first frame starts the dispatch
391
+ // thread; every later frame is a single atomic read. Kept on
392
+ // the GL thread because that's the only thread guaranteed to
393
+ // run once the AR session is live.
394
+ StitcherWorkletRuntime.installIfNeeded()
395
+
386
396
  // Push pose into the AR session log. Mirrors iOS' delegate
387
397
  // path; the existing RNSARFramePose / appendPose
388
398
  // contract was already in place for Phase 4.
389
399
  appendPose(camera, frame.timestamp)
390
400
 
391
- // Forward to the incremental stitcher only when capture is
392
- // engaged. (The v0.8.0 host-worklet dispatch which also
393
- // forwarded preview frames whenever host worklets were
394
- // registered was archived in the 2026-06 batch-keyframe
395
- // cleanup.)
396
- if (ingestActive) {
401
+ // onArFrame (v0.18.0) LIGHT AR-metadata event channel. Built
402
+ // + emitted INDEPENDENTLY of the stitcher ingest / host-worklet
403
+ // fan-out below: a host that only wants per-frame AR metadata
404
+ // (no capture, no worklet) still gets it. Gated + throttled
405
+ // internally; near-free (one volatile read + one nanoTime
406
+ // compare) when disabled or inside the throttle window.
407
+ maybeEmitArFrameMeta(frame, camera)
408
+
409
+ // Forward to the incremental stitcher when capture is engaged,
410
+ // OR when an AR frame-processor host worklet is registered (the
411
+ // v0.8.0 Phase 4b.iii fan-out forwards preview frames whenever
412
+ // host worklets exist, even with capture off — the host worklet
413
+ // observes the live AR stream). `forwardToIncremental` does the
414
+ // NV21 pack once and gates the first-party ingest internally on
415
+ // `ingestActive`; the host-worklet dispatch is gated on the
416
+ // native registry count. `hasHostWorklets()` is a cheap atomic
417
+ // read (microseconds) so the common capture-off / no-worklet
418
+ // preview path stays near-free.
419
+ if (ingestActive || StitcherWorkletRuntime.hasHostWorklets()) {
397
420
  forwardToIncremental(frame, camera)
398
421
  }
399
422
 
@@ -656,6 +679,688 @@ class RNSARCameraView @JvmOverloads constructor(
656
679
  },
657
680
  )
658
681
  } // closes `if (ingestActive)` (v0.8.0 Phase 4b.iii)
682
+
683
+ // ── v0.8.0 Phase 4b.iii — AR frame-processor host-worklet fan-out ──
684
+ //
685
+ // After the first-party stitching ingest (above), fan the SAME
686
+ // already-packed NV21 frame + pose out to every host worklet the
687
+ // JS `arFrameProcessor` registered via `__stitcherProxy.install`.
688
+ // This is independent of `ingestActive`: a host worklet observes
689
+ // the live AR stream whether or not the user has engaged capture
690
+ // (the onDrawFrame gate already let us in when host worklets
691
+ // exist). `dispatchToHostWorklets` does a cheap native
692
+ // registry-count fast-path early-exit + (only when worklets are
693
+ // registered) copies the bytes into an owned native buffer and
694
+ // dispatches asynchronously on worklets-core's default context,
695
+ // so the GL render thread is NOT blocked on worklet execution.
696
+ //
697
+ // We reuse `packed.nv21` (full NV21: Y plane then interleaved
698
+ // VU) + `qarr` / `tArr` (already read above) — no extra Image
699
+ // hold, no second pack. ARCore camera pose is full 6DoF, so
700
+ // translation is always valid.
701
+ val arTracking = when (camera.trackingState) {
702
+ TrackingState.TRACKING -> "normal"
703
+ TrackingState.PAUSED -> "limited"
704
+ TrackingState.STOPPED -> "notAvailable"
705
+ else -> "notAvailable"
706
+ }
707
+
708
+ // ── Opt-in AR-metadata extraction gate ──────────────────────────
709
+ //
710
+ // depth/anchors/mesh are all OFF by default (the JS-driven
711
+ // enableDepth/enableAnchors/enableMesh `<Camera>` props, read via
712
+ // the shared `retailens::getExtractionConfig()` snapshot). Skip
713
+ // the costly ARCore depth-acquire / anchor-collect / mesh-build
714
+ // work for every toggle a host hasn't opted into. A mesh anchor
715
+ // is reconstructed FROM the depth map, so mesh implies acquiring
716
+ // depth even when `depth` (the raw arDepth emission) is off.
717
+ val flags = StitcherWorkletRuntime.extractionFlags()
718
+
719
+ // ── AR depth (ARCore Depth API, DEPTH16) ────────────────────────
720
+ //
721
+ // Acquire the 16-bit depth image for this frame and ROW-PACK it
722
+ // into a contiguous w*h*2 byte array (uint16/pixel, low 13 bits =
723
+ // millimetres, high 3 bits = confidence 0..7). The shared JSI
724
+ // layer (`cpp/camera_frame_jsi.cpp`) unpacks mm->metres and
725
+ // confidence 0..7 -> 0..2, so we emit the RAW packed bytes with
726
+ // format "u16packed" and leave the confidence array empty.
727
+ //
728
+ // ARCore's plane[0].rowStride may EXCEED w*2 (alignment padding);
729
+ // we copy exactly w*2 bytes per row so the JS-side reader sees a
730
+ // dense, no-padding buffer. Older devices / un-supported sessions
731
+ // throw NotYetAvailableException (or depth disabled) — caught and
732
+ // treated as "no depth this frame" (null). `use {}` closes the
733
+ // ARCore Image deterministically in all paths.
734
+ //
735
+ // Acquired when EITHER depth (raw emission) OR mesh
736
+ // (reconstruction) is requested.
737
+ val depth: ArDepthData? =
738
+ if (flags.depth || flags.mesh) acquireDepth16Packed(frame) else null
739
+
740
+ // ── AR anchors ──────────────────────────────────────────────────
741
+ //
742
+ // Emit every TRACKING anchor as { id, type, transform(row-major) }.
743
+ // The app does NOT call session.createAnchor() anywhere today, so
744
+ // getAllAnchors() is empty in practice — an empty list is the
745
+ // CORRECT contract for "AR frame, no anchors" (the JSI layer still
746
+ // returns a [] for source=="ar"). The extraction below is fully
747
+ // wired so it lights up automatically if anchor creation lands.
748
+ // Gated on the anchors toggle.
749
+ val anchors: List<ArAnchorData> =
750
+ if (flags.anchors)
751
+ sessionRef.get()?.let { collectTrackingAnchors(it) } ?: emptyList()
752
+ else emptyList()
753
+
754
+ // ── AR scene mesh (reconstructed from the depth map) ─────────────
755
+ //
756
+ // ARCore has no native scene mesh (unlike ARKit's ARMeshAnchor), so
757
+ // when `mesh` is requested we unproject the DEPTH16 map into a
758
+ // camera-local point grid and triangulate it. Emitted as ONE extra
759
+ // anchor (type="mesh", id="mesh-depth", identity transform — the
760
+ // vertices are camera-local, NOT world). Built only when mesh is
761
+ // on AND a depth map was available this frame.
762
+ val meshAnchor: ArAnchorData? =
763
+ if (flags.mesh && depth != null) buildDepthMesh(depth, intrinsics)
764
+ else null
765
+
766
+ // Combine real anchors + the optional depth mesh into the parallel
767
+ // marshal arrays. meshVertices/meshFaces are null for every
768
+ // non-mesh anchor; the mesh anchor carries its Float32/Uint32 byte
769
+ // buffers (the JNI sets ArAnchor.hasMesh from them).
770
+ val allAnchors: List<ArAnchorData> =
771
+ if (meshAnchor != null) anchors + meshAnchor else anchors
772
+ val anchorIds = Array(allAnchors.size) { allAnchors[it].id }
773
+ val anchorTypes = Array(allAnchors.size) { allAnchors[it].type }
774
+ val anchorTransforms = Array(allAnchors.size) { allAnchors[it].transform }
775
+ val anchorMeshVertices =
776
+ Array<ByteArray?>(allAnchors.size) { allAnchors[it].meshVertices }
777
+ val anchorMeshFaces =
778
+ Array<ByteArray?>(allAnchors.size) { allAnchors[it].meshFaces }
779
+ // Per-anchor plane alignment ("" for image/mesh) + extent
780
+ // ([extentX, extentZ] metres, null for non-plane anchors).
781
+ val anchorAlignments = Array(allAnchors.size) { allAnchors[it].alignment }
782
+ val anchorExtents = Array<DoubleArray?>(allAnchors.size) { allAnchors[it].extent }
783
+
784
+ StitcherWorkletRuntime.dispatchToHostWorklets(
785
+ nv21Bytes = packed.nv21,
786
+ width = packed.width,
787
+ height = packed.height,
788
+ qx = qarr[0].toDouble(), qy = qarr[1].toDouble(),
789
+ qz = qarr[2].toDouble(), qw = qarr[3].toDouble(),
790
+ tx = tArr[0].toDouble(), ty = tArr[1].toDouble(),
791
+ tz = tArr[2].toDouble(),
792
+ timestampNs = frame.timestamp.toDouble(),
793
+ trackingState = arTracking,
794
+ // Emit raw arDepth ONLY when depth was explicitly requested —
795
+ // a mesh-only host gets the mesh anchor but no arDepth buffer.
796
+ depthBytes = if (flags.depth) depth?.bytes else null,
797
+ depthWidth = if (flags.depth) depth?.width ?: 0 else 0,
798
+ depthHeight = if (flags.depth) depth?.height ?: 0 else 0,
799
+ anchorIds = anchorIds,
800
+ anchorTypes = anchorTypes,
801
+ anchorTransforms = anchorTransforms,
802
+ anchorMeshVertices = anchorMeshVertices,
803
+ anchorMeshFaces = anchorMeshFaces,
804
+ // Per-frame camera intrinsics (fx,fy,cx,cy in pixels at the
805
+ // capture resolution). `intrinsics` = camera.imageIntrinsics,
806
+ // already in scope above (declared at the top of this fn).
807
+ fx = intrinsics.focalLength[0].toDouble(),
808
+ fy = intrinsics.focalLength[1].toDouble(),
809
+ cx = intrinsics.principalPoint[0].toDouble(),
810
+ cy = intrinsics.principalPoint[1].toDouble(),
811
+ intrinsicsImageWidth = intrinsics.imageDimensions[0],
812
+ intrinsicsImageHeight = intrinsics.imageDimensions[1],
813
+ anchorAlignments = anchorAlignments,
814
+ anchorExtents = anchorExtents,
815
+ )
816
+ }
817
+
818
+ /// Packed DEPTH16 result: dense (no row padding) uint16-per-pixel
819
+ /// bytes plus the depth-map dimensions. `bytes.size == width*height*2`.
820
+ private data class ArDepthData(
821
+ val bytes: ByteArray,
822
+ val width: Int,
823
+ val height: Int,
824
+ )
825
+
826
+ /// One anchor flattened for the JNI parallel-array marshal.
827
+ /// `transform` is a 16-element ROW-MAJOR (anchor->world) matrix.
828
+ ///
829
+ /// For a depth-derived scene mesh (type="mesh") the geometry rides
830
+ /// along in `meshVertices` (Float32 xyz triplets, LITTLE-ENDIAN) and
831
+ /// `meshFaces` (Uint32 triangle indices, LITTLE-ENDIAN); both are
832
+ /// `null` for plane/image/point anchors. Mesh vertices are
833
+ /// CAMERA-LOCAL, so the mesh anchor's `transform` is identity.
834
+ private data class ArAnchorData(
835
+ val id: String,
836
+ val type: String,
837
+ val transform: DoubleArray,
838
+ val meshVertices: ByteArray? = null,
839
+ val meshFaces: ByteArray? = null,
840
+ /// Plane alignment: "" (n/a — image/mesh anchors) | "horizontal"
841
+ /// | "vertical". Set only on plane anchors; the JNI maps it to
842
+ /// `ArAnchor.alignment` (empty → JS `alignment === undefined`).
843
+ val alignment: String = "",
844
+ /// Plane extent [extentX, extentZ] in metres, or null (image/mesh
845
+ /// anchors). Non-null → the JNI sets `ArAnchor.hasExtent`.
846
+ val extent: DoubleArray? = null,
847
+ )
848
+
849
+ /**
850
+ * Acquire this frame's ARCore depth image (DEPTH16) and copy it into a
851
+ * dense, row-packed `ByteArray` of `w*h*2` bytes (no stride padding).
852
+ *
853
+ * Returns null when depth is unavailable for this frame — older
854
+ * devices that don't support the Depth API, the first frames before
855
+ * ARCore produces a depth estimate (`NotYetAvailableException`), or a
856
+ * session configured without `DepthMode.AUTOMATIC`. The ARCore Image
857
+ * is always closed via `use {}`.
858
+ *
859
+ * Byte order is preserved verbatim from ARCore's little-endian
860
+ * DEPTH16 buffer — the shared C++ JSI layer reinterprets the bytes as
861
+ * `uint16_t` on the same (little-endian ARM) device, so no swap is
862
+ * needed.
863
+ */
864
+ private fun acquireDepth16Packed(
865
+ frame: com.google.ar.core.Frame,
866
+ ): ArDepthData? {
867
+ return try {
868
+ frame.acquireDepthImage16Bits()?.use { img ->
869
+ val w = img.width
870
+ val h = img.height
871
+ if (w <= 0 || h <= 0) return null
872
+ val plane = img.planes[0]
873
+ val rowStride = plane.rowStride // may exceed w*2
874
+ val src = plane.buffer // direct ByteBuffer
875
+ val rowBytes = w * 2 // DEPTH16: 2 bytes/px
876
+ val out = ByteArray(rowBytes * h)
877
+ // Copy ROW BY ROW — only the first `rowBytes` of each
878
+ // `rowStride`-byte source row are real pixels; the tail
879
+ // (rowStride - rowBytes) is alignment padding to skip.
880
+ val row = ByteArray(rowBytes)
881
+ for (y in 0 until h) {
882
+ src.position(y * rowStride)
883
+ src.get(row, 0, rowBytes)
884
+ System.arraycopy(row, 0, out, y * rowBytes, rowBytes)
885
+ }
886
+ ArDepthData(bytes = out, width = w, height = h)
887
+ }
888
+ } catch (t: Throwable) {
889
+ // NotYetAvailableException (early frames), depth unsupported,
890
+ // or any plane-access failure — treat as "no depth this frame".
891
+ if (forwardLogTick % 30 == 1) {
892
+ Log.d(TAG, "acquireDepth16Packed: no depth this frame: ${t.message}")
893
+ }
894
+ null
895
+ }
896
+ }
897
+
898
+ /**
899
+ * Reconstruct a triangle mesh from this frame's DEPTH16 map.
900
+ *
901
+ * ARCore (unlike ARKit's `ARMeshAnchor`) exposes no scene mesh, so we
902
+ * unproject every valid depth pixel into a camera-local 3D point and
903
+ * triangulate the resulting grid. The output is ONE `ArAnchorData`
904
+ * with type="mesh", id="mesh-depth", an IDENTITY transform (vertices
905
+ * are camera-local, not world), a Float32 vertex buffer (xyz triplets,
906
+ * little-endian) and a Uint32 triangle-index buffer (little-endian).
907
+ *
908
+ * ## Intrinsics
909
+ *
910
+ * `camera.imageIntrinsics` gives focal length + principal point at the
911
+ * CAMERA-IMAGE resolution. The depth map is much smaller (~160x120 on
912
+ * ARCore), so we SCALE the intrinsics to the depth resolution:
913
+ * fx_d = fx * depthW / imgW, cx_d = cx * depthW / imgW (and y).
914
+ *
915
+ * ## Unprojection
916
+ *
917
+ * Depth z (metres) = (raw uint16 & 0x1FFF) / 1000.0 (low 13 bits = mm;
918
+ * high 3 bits = confidence, masked off). z==0 ⇒ invalid (skipped).
919
+ * X = (u - cx_d) * z / fx_d
920
+ * Y = (v - cy_d) * z / fy_d
921
+ * Z = z
922
+ *
923
+ * ## Triangulation
924
+ *
925
+ * For each grid cell whose 4 corners are ALL valid, emit 2 triangles
926
+ * (6 Uint32 indices into the vertex array). No decimation (non-goal).
927
+ *
928
+ * Returns null if the depth map has no valid pixels / no full cells.
929
+ */
930
+ private fun buildDepthMesh(
931
+ depth: ArDepthData,
932
+ intrinsics: com.google.ar.core.CameraIntrinsics,
933
+ ): ArAnchorData? {
934
+ val w = depth.width
935
+ val h = depth.height
936
+ if (w <= 1 || h <= 1) return null
937
+
938
+ // Scale camera-image intrinsics to the depth-map resolution.
939
+ val imgW = intrinsics.imageDimensions[0].toDouble()
940
+ val imgH = intrinsics.imageDimensions[1].toDouble()
941
+ if (imgW <= 0.0 || imgH <= 0.0) return null
942
+ val sx = w.toDouble() / imgW
943
+ val sy = h.toDouble() / imgH
944
+ val fxD = intrinsics.focalLength[0].toDouble() * sx
945
+ val fyD = intrinsics.focalLength[1].toDouble() * sy
946
+ val cxD = intrinsics.principalPoint[0].toDouble() * sx
947
+ val cyD = intrinsics.principalPoint[1].toDouble() * sy
948
+ if (fxD <= 0.0 || fyD <= 0.0) return null
949
+
950
+ // Read DEPTH16 as little-endian uint16 (raw mm in low 13 bits).
951
+ val depthBuf = ByteBuffer.wrap(depth.bytes).order(ByteOrder.LITTLE_ENDIAN)
952
+ val px = w * h
953
+
954
+ // Unproject every valid pixel; build a pixel->vertex index map
955
+ // (-1 for invalid) so triangulation can reference the compacted
956
+ // vertex array.
957
+ val vertXyz = FloatArray(px * 3) // upper-bound; trimmed on write
958
+ val indexMap = IntArray(px) { -1 }
959
+ var vertCount = 0
960
+ for (v in 0 until h) {
961
+ val rowBase = v * w
962
+ for (u in 0 until w) {
963
+ val raw = depthBuf.getShort((rowBase + u) * 2).toInt() and 0xFFFF
964
+ val mm = raw and 0x1FFF
965
+ if (mm == 0) continue // invalid depth — skip
966
+ val z = mm / 1000.0
967
+ val x = (u - cxD) * z / fxD
968
+ val y = (v - cyD) * z / fyD
969
+ val o = vertCount * 3
970
+ vertXyz[o] = x.toFloat()
971
+ vertXyz[o + 1] = y.toFloat()
972
+ vertXyz[o + 2] = z.toFloat()
973
+ indexMap[rowBase + u] = vertCount
974
+ vertCount++
975
+ }
976
+ }
977
+ if (vertCount == 0) return null
978
+
979
+ // Triangulate the grid: each cell with all 4 corners valid → 2
980
+ // triangles. Index buffer is grown dynamically (count of full
981
+ // cells isn't known ahead without a second pass).
982
+ // tl tr
983
+ // bl br → (tl, bl, br) + (tl, br, tr)
984
+ val faces = ArrayList<Int>(px * 2)
985
+ for (v in 0 until h - 1) {
986
+ val r0 = v * w
987
+ val r1 = r0 + w
988
+ for (u in 0 until w - 1) {
989
+ val tl = indexMap[r0 + u]
990
+ val tr = indexMap[r0 + u + 1]
991
+ val bl = indexMap[r1 + u]
992
+ val br = indexMap[r1 + u + 1]
993
+ if (tl < 0 || tr < 0 || bl < 0 || br < 0) continue
994
+ faces.add(tl); faces.add(bl); faces.add(br)
995
+ faces.add(tl); faces.add(br); faces.add(tr)
996
+ }
997
+ }
998
+ if (faces.isEmpty()) return null
999
+
1000
+ // Pack vertices (Float32 xyz) + faces (Uint32) into little-endian
1001
+ // byte arrays — the JSI layer reinterprets these as ArrayBuffers
1002
+ // verbatim (Float32Array / Uint32Array on the same LE ARM device).
1003
+ val vertBytes = ByteArray(vertCount * 3 * 4)
1004
+ val vbuf = ByteBuffer.wrap(vertBytes).order(ByteOrder.LITTLE_ENDIAN)
1005
+ for (i in 0 until vertCount * 3) vbuf.putFloat(vertXyz[i])
1006
+
1007
+ val faceBytes = ByteArray(faces.size * 4)
1008
+ val fbuf = ByteBuffer.wrap(faceBytes).order(ByteOrder.LITTLE_ENDIAN)
1009
+ for (idx in faces) fbuf.putInt(idx)
1010
+
1011
+ // Identity 4x4 (row-major == column-major for identity).
1012
+ val identity = DoubleArray(16)
1013
+ identity[0] = 1.0; identity[5] = 1.0; identity[10] = 1.0; identity[15] = 1.0
1014
+
1015
+ return ArAnchorData(
1016
+ id = "mesh-depth",
1017
+ type = "mesh",
1018
+ transform = identity,
1019
+ meshVertices = vertBytes,
1020
+ meshFaces = faceBytes,
1021
+ )
1022
+ }
1023
+
1024
+ // ── onArFrame (v0.18.0) — LIGHT AR-metadata event channel ────────
1025
+ //
1026
+ // Build + throttle + emit the shared `ARFrameMeta` payload over the
1027
+ // `RNImageStitcherARFrame` device event. Runs every render frame
1028
+ // from `onDrawFrame`, but is near-free unless a host has opted in
1029
+ // via `RNSARSession.setArFrameMetaEnabled(true, intervalMs)`:
1030
+ // - one volatile read of `arFrameMetaEnabled` short-circuits the
1031
+ // disabled case,
1032
+ // - a monotonic `nanoTime()` compare throttles to `intervalMs`.
1033
+ //
1034
+ // The payload mirrors the shared contract EXACTLY (timestamp ns,
1035
+ // trackingState string, pose {rotation[4], translation[3]},
1036
+ // intrinsics|null, depth|null, anchors[], mesh|null). depth/anchors/
1037
+ // mesh honour the SAME `enableDepth`/`enableAnchors`/`enableMesh`
1038
+ // extraction flags the worklet fan-out uses, so a host pays no
1039
+ // depth-acquire / anchor-collect cost for a field it didn't request.
1040
+ //
1041
+ // CRITICAL: this is LIGHT. No pixel copies — depth is read for
1042
+ // dimensions + confidence-presence only (no `acquireDepth16Packed`
1043
+ // row-pack), and mesh is reported as anchor/vertex/face COUNTS only
1044
+ // (no vertex/face byte marshaling). The heavy buffers stay on the
1045
+ // `arFrameProcessor` worklet path.
1046
+
1047
+ private fun maybeEmitArFrameMeta(
1048
+ frame: com.google.ar.core.Frame,
1049
+ camera: Camera,
1050
+ ) {
1051
+ // Gate: disabled is the overwhelmingly common case — bail on a
1052
+ // single volatile read before touching the clock or the frame.
1053
+ if (!RNSARSession.arFrameMetaEnabled) return
1054
+
1055
+ // Throttle: emit at most once per `arFrameMetaIntervalMs`. Uses
1056
+ // System.nanoTime() (monotonic; immune to wall-clock jumps). A
1057
+ // 0 interval disables throttling (emit every render frame).
1058
+ val nowNs = System.nanoTime()
1059
+ val intervalMs = RNSARSession.arFrameMetaIntervalMs
1060
+ if (intervalMs > 0L) {
1061
+ val last = RNSARSession.arFrameMetaLastEmitNs
1062
+ if (last != 0L && (nowNs - last) < intervalMs * 1_000_000L) return
1063
+ }
1064
+ RNSARSession.arFrameMetaLastEmitNs = nowNs
1065
+
1066
+ val session = RNSARSession.instance ?: return
1067
+
1068
+ // ── trackingState (always) — contract string enum ───────────
1069
+ val trackingStr = when (camera.trackingState) {
1070
+ TrackingState.TRACKING -> "normal"
1071
+ TrackingState.PAUSED -> "limited"
1072
+ TrackingState.STOPPED -> "notAvailable"
1073
+ else -> "notAvailable"
1074
+ }
1075
+
1076
+ // ── pose (always) — rotation quaternion [x,y,z,w] + translation
1077
+ val pose = camera.pose
1078
+ val q = pose.rotationQuaternion // x, y, z, w
1079
+ val t = pose.translation // x, y, z
1080
+
1081
+ val meta = com.facebook.react.bridge.Arguments.createMap()
1082
+ meta.putDouble("timestamp", frame.timestamp.toDouble()) // ns
1083
+ meta.putString("trackingState", trackingStr)
1084
+
1085
+ val poseMap = com.facebook.react.bridge.Arguments.createMap()
1086
+ val rotArr = com.facebook.react.bridge.Arguments.createArray()
1087
+ rotArr.pushDouble(q[0].toDouble()); rotArr.pushDouble(q[1].toDouble())
1088
+ rotArr.pushDouble(q[2].toDouble()); rotArr.pushDouble(q[3].toDouble())
1089
+ poseMap.putArray("rotation", rotArr)
1090
+ val transArr = com.facebook.react.bridge.Arguments.createArray()
1091
+ transArr.pushDouble(t[0].toDouble()); transArr.pushDouble(t[1].toDouble())
1092
+ transArr.pushDouble(t[2].toDouble())
1093
+ poseMap.putArray("translation", transArr)
1094
+ meta.putMap("pose", poseMap)
1095
+
1096
+ // ── intrinsics (always) — fx,fy,cx,cy + image dims, or null ──
1097
+ // camera.imageIntrinsics is always present once tracking has a
1098
+ // frame; guarded defensively (older devices can throw before the
1099
+ // first valid frame).
1100
+ val intrinsicsMap: com.facebook.react.bridge.WritableMap? = try {
1101
+ val intr = camera.imageIntrinsics
1102
+ com.facebook.react.bridge.Arguments.createMap().apply {
1103
+ putDouble("fx", intr.focalLength[0].toDouble())
1104
+ putDouble("fy", intr.focalLength[1].toDouble())
1105
+ putDouble("cx", intr.principalPoint[0].toDouble())
1106
+ putDouble("cy", intr.principalPoint[1].toDouble())
1107
+ putInt("imageWidth", intr.imageDimensions[0])
1108
+ putInt("imageHeight", intr.imageDimensions[1])
1109
+ }
1110
+ } catch (t2: Throwable) {
1111
+ null
1112
+ }
1113
+ if (intrinsicsMap != null) meta.putMap("intrinsics", intrinsicsMap)
1114
+ else meta.putNull("intrinsics")
1115
+
1116
+ // Honour the SAME extraction flags as the worklet fan-out so
1117
+ // depth/anchors/mesh only cost work when the host opted in.
1118
+ val flags = StitcherWorkletRuntime.extractionFlags()
1119
+
1120
+ // ── depth (only when enableDepth) — DIMS + confidence presence,
1121
+ // NO pixel copy. DEPTH16 packs an 8-bit (high 3 bits)
1122
+ // confidence with each sample, so when a depth image exists
1123
+ // confidence is always present.
1124
+ if (flags.depth) {
1125
+ val depthDims = acquireDepthDimsLight(frame)
1126
+ if (depthDims != null) {
1127
+ val depthMap = com.facebook.react.bridge.Arguments.createMap()
1128
+ depthMap.putInt("width", depthDims[0])
1129
+ depthMap.putInt("height", depthDims[1])
1130
+ depthMap.putBoolean("hasConfidence", true)
1131
+ meta.putMap("depth", depthMap)
1132
+ } else {
1133
+ meta.putNull("depth")
1134
+ }
1135
+ } else {
1136
+ meta.putNull("depth")
1137
+ }
1138
+
1139
+ // ── anchors (only when enableAnchors) — descriptors, no pixels.
1140
+ // Reuses the existing collectTrackingAnchors (id/type/alignment/
1141
+ // extent/transform); the depth-mesh anchor is NOT included here
1142
+ // (mesh is reported as counts in the `mesh` field below).
1143
+ val anchorsArr = com.facebook.react.bridge.Arguments.createArray()
1144
+ if (flags.anchors) {
1145
+ val anchors = sessionRef.get()?.let { collectTrackingAnchors(it) } ?: emptyList()
1146
+ for (a in anchors) {
1147
+ val am = com.facebook.react.bridge.Arguments.createMap()
1148
+ am.putString("id", a.id)
1149
+ am.putString("type", a.type)
1150
+ if (a.alignment.isNotEmpty()) am.putString("alignment", a.alignment)
1151
+ a.extent?.let { ext ->
1152
+ val extArr = com.facebook.react.bridge.Arguments.createArray()
1153
+ extArr.pushDouble(ext[0]); extArr.pushDouble(ext[1])
1154
+ am.putArray("extent", extArr)
1155
+ }
1156
+ // classification: ARCore exposes none for plane/image
1157
+ // trackables (ARKit-only field) — omit it (JS sees
1158
+ // `classification === undefined`), matching the
1159
+ // `classification?` optionality in the contract.
1160
+ val tArr = com.facebook.react.bridge.Arguments.createArray()
1161
+ for (v in a.transform) tArr.pushDouble(v)
1162
+ am.putArray("transform", tArr)
1163
+ anchorsArr.pushMap(am)
1164
+ }
1165
+ }
1166
+ meta.putArray("anchors", anchorsArr)
1167
+
1168
+ // ── mesh (only when enableMesh) — COUNTS only, no byte marshal.
1169
+ // ARCore has no native scene mesh; the depth-reconstructed
1170
+ // mesh is what the worklet path emits. For the LIGHT channel
1171
+ // we report a single anchor (anchorCount=1) whose vertex/face
1172
+ // counts come from a count-only depth scan (no buffer build).
1173
+ // Reported only when mesh is on AND a depth image is available.
1174
+ if (flags.mesh) {
1175
+ val meshCounts = computeDepthMeshCountsLight(frame)
1176
+ if (meshCounts != null) {
1177
+ val meshMap = com.facebook.react.bridge.Arguments.createMap()
1178
+ meshMap.putInt("anchorCount", 1)
1179
+ meshMap.putInt("vertexCount", meshCounts[0])
1180
+ meshMap.putInt("faceCount", meshCounts[1])
1181
+ meta.putMap("mesh", meshMap)
1182
+ } else {
1183
+ meta.putNull("mesh")
1184
+ }
1185
+ } else {
1186
+ meta.putNull("mesh")
1187
+ }
1188
+
1189
+ session.emitArFrameMeta(meta)
1190
+ }
1191
+
1192
+ /**
1193
+ * LIGHT depth probe — return `[width, height]` of this frame's
1194
+ * DEPTH16 image WITHOUT copying any pixels (the contract's depth
1195
+ * field carries dims + confidence-presence only). `use {}` closes
1196
+ * the ARCore Image deterministically in all paths. Returns null when
1197
+ * depth is unavailable (unsupported device, early frames, or depth
1198
+ * not configured).
1199
+ */
1200
+ private fun acquireDepthDimsLight(
1201
+ frame: com.google.ar.core.Frame,
1202
+ ): IntArray? {
1203
+ return try {
1204
+ frame.acquireDepthImage16Bits()?.use { img ->
1205
+ val w = img.width
1206
+ val h = img.height
1207
+ if (w <= 0 || h <= 0) null else intArrayOf(w, h)
1208
+ }
1209
+ } catch (t: Throwable) {
1210
+ // NotYetAvailableException / depth unsupported — no depth.
1211
+ null
1212
+ }
1213
+ }
1214
+
1215
+ /**
1216
+ * LIGHT mesh count probe — return `[vertexCount, faceCount]` for the
1217
+ * depth-reconstructed mesh WITHOUT building any vertex/face byte
1218
+ * buffers (the contract's mesh field carries counts only).
1219
+ *
1220
+ * Mirrors [buildDepthMesh]'s validity rules exactly (z==0 ⇒ invalid
1221
+ * vertex; a grid cell contributes 2 faces iff all 4 corners are
1222
+ * valid) so the reported counts match what the worklet path would
1223
+ * actually marshal — but we never allocate the vertex/index/byte
1224
+ * arrays. Reuses [acquireDepth16Packed] for the row-packed DEPTH16
1225
+ * read (the only depth read available), then scans it numerically.
1226
+ *
1227
+ * Returns null when no depth image is available or the mesh would be
1228
+ * empty (no valid pixels / no full cells).
1229
+ *
1230
+ * Note: camera intrinsics are NOT needed here — vertex/face VALIDITY
1231
+ * is purely a function of the depth value (mm != 0), and counts are
1232
+ * invariant to the unprojection the worklet path performs.
1233
+ */
1234
+ private fun computeDepthMeshCountsLight(
1235
+ frame: com.google.ar.core.Frame,
1236
+ ): IntArray? {
1237
+ val depth = acquireDepth16Packed(frame) ?: return null
1238
+ val w = depth.width
1239
+ val h = depth.height
1240
+ if (w <= 1 || h <= 1) return null
1241
+
1242
+ val depthBuf = ByteBuffer.wrap(depth.bytes).order(ByteOrder.LITTLE_ENDIAN)
1243
+
1244
+ // Per-pixel validity (matches buildDepthMesh: low 13 bits = mm;
1245
+ // mm==0 ⇒ invalid). Track which pixels are valid so face cells
1246
+ // can test their 4 corners without re-reading the buffer.
1247
+ val valid = BooleanArray(w * h)
1248
+ var vertexCount = 0
1249
+ for (i in 0 until w * h) {
1250
+ val raw = depthBuf.getShort(i * 2).toInt() and 0xFFFF
1251
+ if ((raw and 0x1FFF) != 0) {
1252
+ valid[i] = true
1253
+ vertexCount++
1254
+ }
1255
+ }
1256
+ if (vertexCount == 0) return null
1257
+
1258
+ // Faces: each cell with all 4 corners valid → 2 triangles.
1259
+ var faceCount = 0
1260
+ for (v in 0 until h - 1) {
1261
+ val r0 = v * w
1262
+ val r1 = r0 + w
1263
+ for (u in 0 until w - 1) {
1264
+ if (valid[r0 + u] && valid[r0 + u + 1] &&
1265
+ valid[r1 + u] && valid[r1 + u + 1]
1266
+ ) {
1267
+ faceCount += 2
1268
+ }
1269
+ }
1270
+ }
1271
+ if (faceCount == 0) return null
1272
+ return intArrayOf(vertexCount, faceCount)
1273
+ }
1274
+
1275
+ /**
1276
+ * Collect every currently-TRACKING anchor from the session as
1277
+ * `ArAnchorData` (id, coarse type, row-major 4x4 transform).
1278
+ *
1279
+ * `Pose.toMatrix(float[16], 0)` yields a COLUMN-MAJOR (OpenGL) matrix;
1280
+ * we TRANSPOSE it to the row-major layout the shared C++ contract
1281
+ * expects (`ArAnchor.transform`, anchor->world, row-major).
1282
+ *
1283
+ * Cross-platform parity: ARKit's `frame.anchors` auto-includes detected
1284
+ * `ARPlaneAnchor`s (planeDetection is on), so iOS surfaces planes as
1285
+ * anchors for free. ARCore exposes detected planes / augmented images
1286
+ * as TRACKABLES (not `Anchor`s) until you call `createAnchor`, and this
1287
+ * app creates none — so to give the worklet the same useful per-frame
1288
+ * spatial data, we surface detected plane + augmented-image trackables
1289
+ * (in TRACKING state) directly as anchors. `centerPose` is the anchor
1290
+ * pose; `Pose.toMatrix` is COLUMN-MAJOR (OpenGL) so we transpose to the
1291
+ * row-major layout the shared C++ contract (`ArAnchor.transform`,
1292
+ * anchor->world) expects. ids are per-session-stable (identity hash).
1293
+ */
1294
+ private fun collectTrackingAnchors(
1295
+ session: Session,
1296
+ ): List<ArAnchorData> {
1297
+ val out = ArrayList<ArAnchorData>()
1298
+ val colMajor = FloatArray(16)
1299
+
1300
+ // Transpose ARCore's COLUMN-MAJOR (OpenGL) pose matrix to the
1301
+ // ROW-MAJOR (anchor->world) layout the shared C++ contract wants.
1302
+ fun rowMajorTransform(pose: com.google.ar.core.Pose): DoubleArray {
1303
+ pose.toMatrix(colMajor, 0) // COLUMN-MAJOR (OpenGL)
1304
+ val rowMajor = DoubleArray(16)
1305
+ for (r in 0 until 4) {
1306
+ for (c in 0 until 4) {
1307
+ rowMajor[r * 4 + c] = colMajor[c * 4 + r].toDouble()
1308
+ }
1309
+ }
1310
+ return rowMajor
1311
+ }
1312
+
1313
+ // Image/mesh anchors carry no alignment/extent (alignment=""/
1314
+ // extent=null) — same shape as before this change.
1315
+ fun emit(id: String, type: String, pose: com.google.ar.core.Pose) {
1316
+ out.add(ArAnchorData(id = id, type = type, transform = rowMajorTransform(pose)))
1317
+ }
1318
+
1319
+ // Read the JS `<Camera planeDetection=...>` filter once per frame
1320
+ // ("vertical" | "horizontal" | "both"). We FILTER which plane
1321
+ // orientations are surfaced here — ARCore's planeFindingMode stays
1322
+ // HORIZONTAL_AND_VERTICAL (see RNSARSession.setPlaneDetection).
1323
+ val planeMode = RNSARSession.planeDetectionMode
1324
+
1325
+ for (plane in session.getAllTrackables(com.google.ar.core.Plane::class.java)) {
1326
+ if (plane.trackingState != TrackingState.TRACKING) continue
1327
+ // Skip planes merged into a larger one (avoids duplicate poses).
1328
+ if (plane.subsumedBy != null) continue
1329
+
1330
+ val alignment = when (plane.type) {
1331
+ com.google.ar.core.Plane.Type.HORIZONTAL_UPWARD_FACING,
1332
+ com.google.ar.core.Plane.Type.HORIZONTAL_DOWNWARD_FACING -> "horizontal"
1333
+ com.google.ar.core.Plane.Type.VERTICAL -> "vertical"
1334
+ else -> ""
1335
+ }
1336
+ // Filter by the JS plane-detection prop (applied AFTER the
1337
+ // subsumedBy / trackingState skips above). "both" keeps all.
1338
+ when (planeMode) {
1339
+ "vertical" -> if (alignment != "vertical") continue
1340
+ "horizontal" -> if (alignment != "horizontal") continue
1341
+ else -> { /* "both" — keep all orientations */ }
1342
+ }
1343
+
1344
+ out.add(
1345
+ ArAnchorData(
1346
+ id = "plane-${System.identityHashCode(plane)}",
1347
+ type = "plane",
1348
+ transform = rowMajorTransform(plane.centerPose),
1349
+ alignment = alignment,
1350
+ // extentX/extentZ: plane size (metres) along its local
1351
+ // X/Z axes (Y is the normal).
1352
+ extent = doubleArrayOf(
1353
+ plane.extentX.toDouble(),
1354
+ plane.extentZ.toDouble(),
1355
+ ),
1356
+ ),
1357
+ )
1358
+ }
1359
+ for (img in session.getAllTrackables(com.google.ar.core.AugmentedImage::class.java)) {
1360
+ if (img.trackingState != TrackingState.TRACKING) continue
1361
+ emit("image-${System.identityHashCode(img)}", "image", img.centerPose)
1362
+ }
1363
+ return out
659
1364
  }
660
1365
 
661
1366
  /// v0.13.2 — map the JS physical device orientation to the