react-native-image-stitcher 0.18.0 → 0.19.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.
- package/CHANGELOG.md +30 -0
- package/android/src/main/java/io/imagestitcher/rn/ARFrameContext.kt +89 -0
- package/android/src/main/java/io/imagestitcher/rn/ARFramePlugin.kt +57 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +183 -14
- package/android/src/main/java/io/imagestitcher/rn/RNSARPluginRegistry.kt +109 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +28 -0
- package/dist/camera/ARCameraView.d.ts +22 -1
- package/dist/camera/ARCameraView.js +36 -1
- package/dist/camera/Camera.d.ts +20 -1
- package/dist/camera/Camera.js +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/stitching/ARFrameMeta.d.ts +49 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +36 -1
- package/ios/Sources/RNImageStitcher/CameraFrameHostObject.h +25 -0
- package/ios/Sources/RNImageStitcher/CameraFrameHostObject.mm +66 -54
- package/ios/Sources/RNImageStitcher/RNISARFramePlugin.swift +247 -0
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +127 -1
- package/package.json +1 -1
- package/src/camera/ARCameraView.tsx +66 -1
- package/src/camera/Camera.tsx +23 -1
- package/src/index.ts +6 -1
- package/src/stitching/ARFrameMeta.ts +50 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
14
14
|
> during 0.x are bumped to a new MINOR (e.g., 0.1 → 0.2), and the
|
|
15
15
|
> upgrade path is documented in this CHANGELOG.
|
|
16
16
|
|
|
17
|
+
## [0.19.0] — 2026-06-19
|
|
18
|
+
|
|
19
|
+
### Added — Native AR frame-processor plugins
|
|
20
|
+
|
|
21
|
+
AR-mode `<Camera>` can now run **native per-frame plugins** with zero-copy
|
|
22
|
+
access to the AR frame — the foundation for on-device CV (OCR, object
|
|
23
|
+
detection, reconstruction feeds) **without** baking that domain code into the
|
|
24
|
+
SDK. The SDK ships only the generic framework; plugins live in your app.
|
|
25
|
+
|
|
26
|
+
- **Plugin interface:** implement `RNISARFramePlugin` (iOS) / `ARFramePlugin`
|
|
27
|
+
(Android) — `name()` + `process(context)`.
|
|
28
|
+
- **`ARFrameContext`** hands the plugin the frame **zero-copy**: the camera
|
|
29
|
+
buffer, `pose`, `intrinsics`, tracking state, timestamp, and — when the
|
|
30
|
+
matching `enable*` prop is on — `depth` + `anchors`. The buffer is valid
|
|
31
|
+
**only during `process()`**; copy it before offloading to another thread.
|
|
32
|
+
- **Register at startup:** `RNISARPluginRegistry.shared.register(…)` (iOS) /
|
|
33
|
+
`RNSARPluginRegistry.register(…)` (Android). The SDK invokes registered
|
|
34
|
+
plugins per AR frame, gated on a **non-empty registry** — zero-plugin apps
|
|
35
|
+
pay nothing.
|
|
36
|
+
- **Two result channels:** light **synchronous** `process()` returns fold into
|
|
37
|
+
`onArFrame`'s `ARFrameMeta.plugins` (keyed by plugin name); heavy / **async**
|
|
38
|
+
results are pushed via `registry.emit(name, result)` → the new
|
|
39
|
+
**`onArPluginResult`** callback prop (delivered off the AR thread — for slow
|
|
40
|
+
work like OCR that must not block frame capture).
|
|
41
|
+
- The example ships a sample `FrameBrightnessPlugin` (both platforms),
|
|
42
|
+
surfaced live in the AR overlay.
|
|
43
|
+
|
|
44
|
+
Device-verified on iPhone 16 Pro. The SDK stays dependency-light — no OCR / ML
|
|
45
|
+
runtimes are added to core.
|
|
46
|
+
|
|
17
47
|
## [0.18.0] — 2026-06-18
|
|
18
48
|
|
|
19
49
|
### ⚠️ Breaking — `StitcherFrame` → `CameraFrame`
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
package io.imagestitcher.rn
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 0.19.0 — zero-copy native view of one ARCore frame, handed to every
|
|
6
|
+
* registered [ARFramePlugin.process] (iOS twin: `RNISARFrameContext`).
|
|
7
|
+
*
|
|
8
|
+
* The SDK builds ONE of these per AR frame — only when [RNSARPluginRegistry]
|
|
9
|
+
* is non-empty — from the SAME `ARCore Frame` + pose the `onArFrame` meta
|
|
10
|
+
* path uses, then passes it to each plugin in turn. Zero-plugin apps never
|
|
11
|
+
* pay for the build.
|
|
12
|
+
*
|
|
13
|
+
* ## Camera image
|
|
14
|
+
*
|
|
15
|
+
* ARCore hands the SDK a `YUV_420_888` camera image which is already packed
|
|
16
|
+
* into a contiguous JVM-side NV21 byte array ([nv21]) before the ARCore
|
|
17
|
+
* `Image` is closed (the SDK does this once per frame for its own stitch /
|
|
18
|
+
* worklet paths — we reuse it here, no extra acquire). Layout:
|
|
19
|
+
* - bytes `[0 .. width*height)` = Y plane (luminance), dense,
|
|
20
|
+
* row stride = [width]
|
|
21
|
+
* - bytes `[width*height .. width*height*3/2)` = interleaved V-U chroma
|
|
22
|
+
* [yPlane] is a convenience read-only window onto just the Y plane.
|
|
23
|
+
*
|
|
24
|
+
* ## Lifetime — COPY BEFORE OFFLOADING
|
|
25
|
+
*
|
|
26
|
+
* [nv21] / [yPlane] / [depthBytes] are the SDK's own arrays, reused on the
|
|
27
|
+
* next frame. They are valid ONLY for the duration of the synchronous
|
|
28
|
+
* [ARFramePlugin.process] call. A plugin that hands bytes to another
|
|
29
|
+
* thread (async OCR, network upload, etc.) **MUST copy** them first
|
|
30
|
+
* (`bytes.copyOf()`), or it will read torn/overwritten data on the next AR
|
|
31
|
+
* frame.
|
|
32
|
+
*
|
|
33
|
+
* @property nv21 Full NV21 camera image (Y plane then interleaved VU).
|
|
34
|
+
* @property width Camera image width (px).
|
|
35
|
+
* @property height Camera image height (px).
|
|
36
|
+
* @property timestampNs ARCore frame timestamp (nanoseconds).
|
|
37
|
+
* @property fx Focal length x (px, at capture resolution).
|
|
38
|
+
* @property fy Focal length y (px).
|
|
39
|
+
* @property cx Principal point x (px).
|
|
40
|
+
* @property cy Principal point y (px).
|
|
41
|
+
* @property imageWidth Intrinsics reference image width (px).
|
|
42
|
+
* @property imageHeight Intrinsics reference image height (px).
|
|
43
|
+
* @property poseRotation Camera pose rotation quaternion `[x, y, z, w]`.
|
|
44
|
+
* @property poseTranslation Camera pose translation `[x, y, z]` (metres, world).
|
|
45
|
+
* @property trackingState Contract enum string: "normal" | "limited" | "notAvailable".
|
|
46
|
+
* @property depthBytes Row-packed DEPTH16 (uint16/px, w*h*2 bytes) or null
|
|
47
|
+
* (null unless `enableDepth` AND depth available this frame).
|
|
48
|
+
* @property depthWidth Depth map width (px), 0 when [depthBytes] is null.
|
|
49
|
+
* @property depthHeight Depth map height (px), 0 when [depthBytes] is null.
|
|
50
|
+
* @property anchors Anchor descriptor maps already collected for the
|
|
51
|
+
* `onArFrame` event (empty unless `enableAnchors`).
|
|
52
|
+
* Each map: { id, type, transform[16 row-major],
|
|
53
|
+
* alignment?, extent? } — same shape the JS
|
|
54
|
+
* `ARAnchor` contract uses.
|
|
55
|
+
*/
|
|
56
|
+
class ARFrameContext(
|
|
57
|
+
@JvmField val nv21: ByteArray,
|
|
58
|
+
@JvmField val width: Int,
|
|
59
|
+
@JvmField val height: Int,
|
|
60
|
+
@JvmField val timestampNs: Double,
|
|
61
|
+
@JvmField val fx: Double,
|
|
62
|
+
@JvmField val fy: Double,
|
|
63
|
+
@JvmField val cx: Double,
|
|
64
|
+
@JvmField val cy: Double,
|
|
65
|
+
@JvmField val imageWidth: Int,
|
|
66
|
+
@JvmField val imageHeight: Int,
|
|
67
|
+
@JvmField val poseRotation: DoubleArray,
|
|
68
|
+
@JvmField val poseTranslation: DoubleArray,
|
|
69
|
+
@JvmField val trackingState: String,
|
|
70
|
+
@JvmField val depthBytes: ByteArray? = null,
|
|
71
|
+
@JvmField val depthWidth: Int = 0,
|
|
72
|
+
@JvmField val depthHeight: Int = 0,
|
|
73
|
+
@JvmField val anchors: List<Map<String, Any?>> = emptyList(),
|
|
74
|
+
) {
|
|
75
|
+
/**
|
|
76
|
+
* Read-only window onto JUST the Y (luminance) plane of [nv21] — the
|
|
77
|
+
* first `width * height` bytes. Cheap (no copy): a sliced, read-only
|
|
78
|
+
* [java.nio.ByteBuffer] over the same backing array. Convenient for
|
|
79
|
+
* plugins that only need luma (brightness, simple CV gates).
|
|
80
|
+
*
|
|
81
|
+
* Like [nv21], valid only during [ARFramePlugin.process]; copy before
|
|
82
|
+
* offloading.
|
|
83
|
+
*/
|
|
84
|
+
val yPlane: java.nio.ByteBuffer
|
|
85
|
+
get() = java.nio.ByteBuffer
|
|
86
|
+
.wrap(nv21, 0, width * height)
|
|
87
|
+
.slice()
|
|
88
|
+
.asReadOnlyBuffer()
|
|
89
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
package io.imagestitcher.rn
|
|
3
|
+
|
|
4
|
+
import com.facebook.react.bridge.WritableMap
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 0.19.0 — Android AR frame-processor plugin SPI (Swift twin:
|
|
8
|
+
* `ios/Sources/RNImageStitcher/RNISARFramePlugin.swift`).
|
|
9
|
+
*
|
|
10
|
+
* Mirrors vision-camera's `FrameProcessorPlugin` registration ergonomics:
|
|
11
|
+
* the *host app* implements this interface, registers an instance with
|
|
12
|
+
* [RNSARPluginRegistry] at startup, and the SDK invokes [process] for every
|
|
13
|
+
* ARCore frame while the registry is non-empty. The SDK ships ONLY this
|
|
14
|
+
* generic framework — no OCR or any other concrete plugin (the host writes
|
|
15
|
+
* those against this contract).
|
|
16
|
+
*
|
|
17
|
+
* ## Threading & lifetime
|
|
18
|
+
*
|
|
19
|
+
* [process] runs on the **AR (GL render) thread**, synchronously, once per
|
|
20
|
+
* ARCore frame. The [ARFrameContext] handed in is a zero-copy view onto
|
|
21
|
+
* the live frame — its byte buffers (`yPlane` / `nv21` / depth `bytes`) are
|
|
22
|
+
* the SDK's own arrays and are reused on subsequent frames. A plugin that
|
|
23
|
+
* offloads heavy work to another thread **MUST copy** any bytes it needs
|
|
24
|
+
* before returning from [process] (see [ARFrameContext]).
|
|
25
|
+
*
|
|
26
|
+
* ## Sync vs async results
|
|
27
|
+
*
|
|
28
|
+
* - Return a non-null [WritableMap] for a *light, synchronous* result. The
|
|
29
|
+
* SDK folds it into the throttled `onArFrame` `ARFrameMeta` event under
|
|
30
|
+
* `plugins[name]`, so it rides the existing channel for free.
|
|
31
|
+
* - Return `null` and call [RNSARPluginRegistry.emit] later (from the
|
|
32
|
+
* plugin's own queue) to deliver an *async* result over the dedicated
|
|
33
|
+
* `RNImageStitcherARPluginResult` device event.
|
|
34
|
+
*
|
|
35
|
+
* Plugins are responsible for their own throttling / work-offloading — the
|
|
36
|
+
* SDK calls [process] on EVERY AR frame while the registry is non-empty.
|
|
37
|
+
*/
|
|
38
|
+
interface ARFramePlugin {
|
|
39
|
+
/**
|
|
40
|
+
* Stable, unique name for this plugin. Used as the key under
|
|
41
|
+
* `ARFrameMeta.plugins` for sync results and as the `plugin` field of
|
|
42
|
+
* the `RNImageStitcherARPluginResult` event for async results. The JS
|
|
43
|
+
* side keys off this string, so keep it stable across app launches.
|
|
44
|
+
*/
|
|
45
|
+
fun name(): String
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Process one ARCore frame. Return a light synchronous result map
|
|
49
|
+
* (folded into the `onArFrame` event) or `null` (no sync result — emit
|
|
50
|
+
* later via [RNSARPluginRegistry.emit] if needed).
|
|
51
|
+
*
|
|
52
|
+
* Runs on the AR thread. Do NOT block: self-throttle and offload heavy
|
|
53
|
+
* work. Copy any [ARFrameContext] byte buffers you retain past the
|
|
54
|
+
* call.
|
|
55
|
+
*/
|
|
56
|
+
fun process(context: ARFrameContext): WritableMap?
|
|
57
|
+
}
|
|
@@ -398,28 +398,46 @@ class RNSARCameraView @JvmOverloads constructor(
|
|
|
398
398
|
// contract was already in place for Phase 4.
|
|
399
399
|
appendPose(camera, frame.timestamp)
|
|
400
400
|
|
|
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
401
|
// Forward to the incremental stitcher when capture is engaged,
|
|
410
402
|
// OR when an AR frame-processor host worklet is registered (the
|
|
411
403
|
// v0.8.0 Phase 4b.iii fan-out forwards preview frames whenever
|
|
412
404
|
// host worklets exist, even with capture off — the host worklet
|
|
413
|
-
// observes the live AR stream)
|
|
405
|
+
// observes the live AR stream), OR when a native AR plugin is
|
|
406
|
+
// registered (0.19.0 — `forwardToIncremental` builds the
|
|
407
|
+
// ARFrameContext + runs the plugins; their SYNC results are stashed
|
|
408
|
+
// for the onArFrame meta below). `forwardToIncremental` does the
|
|
414
409
|
// NV21 pack once and gates the first-party ingest internally on
|
|
415
|
-
// `ingestActive`; the host-worklet dispatch is gated on the
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
// preview path stays
|
|
419
|
-
|
|
410
|
+
// `ingestActive`; the host-worklet dispatch is gated on the native
|
|
411
|
+
// worklet registry count; the plugin invocation on the plugin
|
|
412
|
+
// registry. All three checks are cheap atomic reads so the common
|
|
413
|
+
// idle preview path (no capture, no worklet, no plugin) stays
|
|
414
|
+
// near-free.
|
|
415
|
+
//
|
|
416
|
+
// ORDER (0.19.0): run forwardToIncremental BEFORE maybeEmitArFrameMeta
|
|
417
|
+
// so the native-plugin SYNC results computed in the former are
|
|
418
|
+
// available to fold into the onArFrame `plugins` field built in the
|
|
419
|
+
// latter (same render frame).
|
|
420
|
+
if (ingestActive ||
|
|
421
|
+
StitcherWorkletRuntime.hasHostWorklets() ||
|
|
422
|
+
!RNSARPluginRegistry.isEmpty()
|
|
423
|
+
) {
|
|
420
424
|
forwardToIncremental(frame, camera)
|
|
425
|
+
} else {
|
|
426
|
+
// No consumer this frame — make sure last frame's stashed plugin
|
|
427
|
+
// sync results don't leak into a later onArFrame meta.
|
|
428
|
+
lastPluginSyncResults = null
|
|
421
429
|
}
|
|
422
430
|
|
|
431
|
+
// onArFrame (v0.18.0) — LIGHT AR-metadata event channel. Built
|
|
432
|
+
// + emitted INDEPENDENTLY of the stitcher ingest / host-worklet
|
|
433
|
+
// fan-out above: a host that only wants per-frame AR metadata
|
|
434
|
+
// (no capture, no worklet) still gets it. Gated + throttled
|
|
435
|
+
// internally; near-free (one volatile read + one nanoTime
|
|
436
|
+
// compare) when disabled or inside the throttle window. Native-
|
|
437
|
+
// plugin SYNC results (0.19.0) stashed by forwardToIncremental
|
|
438
|
+
// above ride along under the meta's `plugins` field.
|
|
439
|
+
maybeEmitArFrameMeta(frame, camera)
|
|
440
|
+
|
|
423
441
|
// takePhoto consumer — runs on EVERY render tick (not just
|
|
424
442
|
// when ingest is active), since the host calls takePhoto in
|
|
425
443
|
// photo mode where ingest is off. No-op when no request is
|
|
@@ -813,6 +831,132 @@ class RNSARCameraView @JvmOverloads constructor(
|
|
|
813
831
|
anchorAlignments = anchorAlignments,
|
|
814
832
|
anchorExtents = anchorExtents,
|
|
815
833
|
)
|
|
834
|
+
|
|
835
|
+
// ── 0.19.0 — native AR-plugin per-frame invocation ───────────────
|
|
836
|
+
//
|
|
837
|
+
// Mirror of iOS' RNSARSession.session(_:didUpdate:) plugin loop.
|
|
838
|
+
// Only build the ARFrameContext + call plugins when the registry is
|
|
839
|
+
// NON-EMPTY (the onDrawFrame gate already let us in via that check,
|
|
840
|
+
// but a worklet-only frame can reach here with an empty plugin
|
|
841
|
+
// registry — re-check so those frames pay nothing). Runs on the AR
|
|
842
|
+
// (GL render) thread, synchronously. Reuses the already-packed
|
|
843
|
+
// `packed.nv21`, the depth/anchors collected above, and the pose +
|
|
844
|
+
// intrinsics already read — no extra Image acquire, no second pack.
|
|
845
|
+
// Depth is passed ONLY when the host opted into enableDepth (a
|
|
846
|
+
// mesh-only host acquired depth for its mesh, but the contract says
|
|
847
|
+
// the context's depth is null unless enableDepth).
|
|
848
|
+
runArPlugins(
|
|
849
|
+
packed, qarr, tArr, arTracking, frame, intrinsics, anchors,
|
|
850
|
+
depth = if (flags.depth) depth else null,
|
|
851
|
+
)
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/// 0.19.0 — last frame's native-plugin SYNC results, keyed by plugin
|
|
855
|
+
/// name. Written by [runArPlugins] on the GL render thread, read by
|
|
856
|
+
/// [maybeEmitArFrameMeta] on the same thread one step later in the same
|
|
857
|
+
/// onDrawFrame tick. Null = no plugins ran / no sync results this
|
|
858
|
+
/// frame. Single-threaded handoff (both on the GL thread) so no
|
|
859
|
+
/// synchronisation is needed, but @Volatile is cheap insurance against
|
|
860
|
+
/// any future cross-thread read.
|
|
861
|
+
@Volatile
|
|
862
|
+
private var lastPluginSyncResults: Map<String, Any?>? = null
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* 0.19.0 — build one [ARFrameContext] from the current frame and invoke
|
|
866
|
+
* every registered [ARFramePlugin].
|
|
867
|
+
*
|
|
868
|
+
* - Non-null SYNC results are collected into a `{ name -> result }` map
|
|
869
|
+
* and stashed in [lastPluginSyncResults] for [maybeEmitArFrameMeta]
|
|
870
|
+
* to fold into the onArFrame `plugins` field this same tick.
|
|
871
|
+
* - A plugin returning `null` defers to the ASYNC channel
|
|
872
|
+
* ([RNSARPluginRegistry.emit] → `RNImageStitcherARPluginResult`).
|
|
873
|
+
*
|
|
874
|
+
* A throwing plugin is isolated (logged, skipped) so one bad plugin
|
|
875
|
+
* can't take down the AR render loop.
|
|
876
|
+
*
|
|
877
|
+
* Reuses caller-collected data (no extra Image work):
|
|
878
|
+
* @param packed the already-packed NV21 camera image.
|
|
879
|
+
* @param qarr pose rotation quaternion [x,y,z,w].
|
|
880
|
+
* @param tArr pose translation [x,y,z] (world metres).
|
|
881
|
+
* @param tracking contract tracking string ("normal"|"limited"|"notAvailable").
|
|
882
|
+
* @param frame the ARCore frame (for the timestamp).
|
|
883
|
+
* @param intrinsics camera intrinsics (fx,fy,cx,cy + image dims).
|
|
884
|
+
* @param anchors anchor descriptors already collected for onArFrame
|
|
885
|
+
* (enableAnchors-gated; empty otherwise).
|
|
886
|
+
* @param depth row-packed DEPTH16 or null (enableDepth-gated).
|
|
887
|
+
*/
|
|
888
|
+
private fun runArPlugins(
|
|
889
|
+
packed: YuvImageConverter.PackedYuv,
|
|
890
|
+
qarr: FloatArray,
|
|
891
|
+
tArr: FloatArray,
|
|
892
|
+
tracking: String,
|
|
893
|
+
frame: com.google.ar.core.Frame,
|
|
894
|
+
intrinsics: com.google.ar.core.CameraIntrinsics,
|
|
895
|
+
anchors: List<ArAnchorData>,
|
|
896
|
+
depth: ArDepthData?,
|
|
897
|
+
) {
|
|
898
|
+
val plugins = RNSARPluginRegistry.plugins()
|
|
899
|
+
if (plugins.isEmpty()) {
|
|
900
|
+
lastPluginSyncResults = null
|
|
901
|
+
return
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Flatten the already-collected anchor descriptors into plain maps
|
|
905
|
+
// (id/type/transform + optional alignment/extent) so plugins get the
|
|
906
|
+
// same shape as the JS `ARAnchor` contract without a JSI dependency.
|
|
907
|
+
val anchorMaps: List<Map<String, Any?>> =
|
|
908
|
+
if (anchors.isEmpty()) emptyList()
|
|
909
|
+
else anchors.map { a ->
|
|
910
|
+
val m = HashMap<String, Any?>(5)
|
|
911
|
+
m["id"] = a.id
|
|
912
|
+
m["type"] = a.type
|
|
913
|
+
m["transform"] = a.transform
|
|
914
|
+
if (a.alignment.isNotEmpty()) m["alignment"] = a.alignment
|
|
915
|
+
a.extent?.let { m["extent"] = it }
|
|
916
|
+
m
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
val ctx = ARFrameContext(
|
|
920
|
+
nv21 = packed.nv21,
|
|
921
|
+
width = packed.width,
|
|
922
|
+
height = packed.height,
|
|
923
|
+
timestampNs = frame.timestamp.toDouble(),
|
|
924
|
+
fx = intrinsics.focalLength[0].toDouble(),
|
|
925
|
+
fy = intrinsics.focalLength[1].toDouble(),
|
|
926
|
+
cx = intrinsics.principalPoint[0].toDouble(),
|
|
927
|
+
cy = intrinsics.principalPoint[1].toDouble(),
|
|
928
|
+
imageWidth = intrinsics.imageDimensions[0],
|
|
929
|
+
imageHeight = intrinsics.imageDimensions[1],
|
|
930
|
+
poseRotation = doubleArrayOf(
|
|
931
|
+
qarr[0].toDouble(), qarr[1].toDouble(),
|
|
932
|
+
qarr[2].toDouble(), qarr[3].toDouble(),
|
|
933
|
+
),
|
|
934
|
+
poseTranslation = doubleArrayOf(
|
|
935
|
+
tArr[0].toDouble(), tArr[1].toDouble(), tArr[2].toDouble(),
|
|
936
|
+
),
|
|
937
|
+
trackingState = tracking,
|
|
938
|
+
depthBytes = depth?.bytes,
|
|
939
|
+
depthWidth = depth?.width ?: 0,
|
|
940
|
+
depthHeight = depth?.height ?: 0,
|
|
941
|
+
anchors = anchorMaps,
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
var sync: HashMap<String, Any?>? = null
|
|
945
|
+
for (plugin in plugins) {
|
|
946
|
+
val result = try {
|
|
947
|
+
plugin.process(ctx)
|
|
948
|
+
} catch (t: Throwable) {
|
|
949
|
+
if (forwardLogTick % 30 == 1) {
|
|
950
|
+
Log.w(TAG, "AR plugin '${plugin.name()}' threw in process(): ${t.message}")
|
|
951
|
+
}
|
|
952
|
+
null
|
|
953
|
+
}
|
|
954
|
+
if (result != null) {
|
|
955
|
+
if (sync == null) sync = HashMap()
|
|
956
|
+
sync[plugin.name()] = result
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
lastPluginSyncResults = sync
|
|
816
960
|
}
|
|
817
961
|
|
|
818
962
|
/// Packed DEPTH16 result: dense (no row padding) uint16-per-pixel
|
|
@@ -1186,6 +1330,31 @@ class RNSARCameraView @JvmOverloads constructor(
|
|
|
1186
1330
|
meta.putNull("mesh")
|
|
1187
1331
|
}
|
|
1188
1332
|
|
|
1333
|
+
// ── plugins (0.19.0) — native-plugin SYNC results, if any ────────
|
|
1334
|
+
// `lastPluginSyncResults` was stashed by `runArPlugins` earlier
|
|
1335
|
+
// in THIS same onDrawFrame tick (forwardToIncremental runs before
|
|
1336
|
+
// maybeEmitArFrameMeta). Each value is the WritableMap a plugin
|
|
1337
|
+
// returned from `process()`; we re-key it under `plugins[name]`.
|
|
1338
|
+
// Omitted entirely when no plugin produced a sync result (JS sees
|
|
1339
|
+
// `meta.plugins === undefined`), matching the optional `plugins?`
|
|
1340
|
+
// field in the ARFrameMeta contract.
|
|
1341
|
+
val pluginResults = lastPluginSyncResults
|
|
1342
|
+
if (!pluginResults.isNullOrEmpty()) {
|
|
1343
|
+
val pluginsMap = com.facebook.react.bridge.Arguments.createMap()
|
|
1344
|
+
for ((name, value) in pluginResults) {
|
|
1345
|
+
when (value) {
|
|
1346
|
+
is com.facebook.react.bridge.WritableMap ->
|
|
1347
|
+
pluginsMap.putMap(name, value)
|
|
1348
|
+
null -> pluginsMap.putNull(name)
|
|
1349
|
+
// Defensive: a plugin should only ever return a
|
|
1350
|
+
// WritableMap, but never let an unexpected type crash the
|
|
1351
|
+
// emit — drop it.
|
|
1352
|
+
else -> { /* skip unsupported result type */ }
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
meta.putMap("plugins", pluginsMap)
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1189
1358
|
session.emitArFrameMeta(meta)
|
|
1190
1359
|
}
|
|
1191
1360
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
package io.imagestitcher.rn
|
|
3
|
+
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import com.facebook.react.bridge.Arguments
|
|
6
|
+
import com.facebook.react.bridge.WritableMap
|
|
7
|
+
import java.util.concurrent.CopyOnWriteArrayList
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 0.19.0 — process-wide registry of [ARFramePlugin]s (iOS twin:
|
|
11
|
+
* `RNISARPluginRegistry`).
|
|
12
|
+
*
|
|
13
|
+
* The host app registers native plugins HERE at startup (typically from its
|
|
14
|
+
* `MainApplication.onCreate`); the SDK's AR per-frame path
|
|
15
|
+
* ([RNSARCameraView.forwardToIncremental]) reads [plugins] each frame and,
|
|
16
|
+
* when the list is non-empty, builds one [ARFrameContext] and calls every
|
|
17
|
+
* plugin's [ARFramePlugin.process].
|
|
18
|
+
*
|
|
19
|
+
* Doubles as the **async result channel**: a plugin that returned `null`
|
|
20
|
+
* from `process` (deferring heavy work to its own queue) calls [emit] later
|
|
21
|
+
* to deliver its result to JS over the `RNImageStitcherARPluginResult`
|
|
22
|
+
* device event.
|
|
23
|
+
*
|
|
24
|
+
* ## Threading
|
|
25
|
+
*
|
|
26
|
+
* Backed by a [CopyOnWriteArrayList], so [register] / [unregister] (usually
|
|
27
|
+
* on the main thread at startup) never race the AR thread's [plugins] read.
|
|
28
|
+
* [emit] is safe from any thread — it routes through the singleton
|
|
29
|
+
* [RNSARSession]'s React context, which guards a torn-down Catalyst
|
|
30
|
+
* instance internally.
|
|
31
|
+
*/
|
|
32
|
+
object RNSARPluginRegistry {
|
|
33
|
+
|
|
34
|
+
private const val TAG = "RNSARPluginRegistry"
|
|
35
|
+
|
|
36
|
+
/// Event name carrying an ASYNC plugin result to JS. MUST match the
|
|
37
|
+
/// iOS `supportedEvents` entry + the TS `NativeEventEmitter`
|
|
38
|
+
/// subscription string exactly.
|
|
39
|
+
const val AR_PLUGIN_RESULT_EVENT = "RNImageStitcherARPluginResult"
|
|
40
|
+
|
|
41
|
+
private val registered = CopyOnWriteArrayList<ARFramePlugin>()
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register a plugin. Idempotent by [ARFramePlugin.name]: registering a
|
|
45
|
+
* plugin whose name matches an existing one REPLACES the old instance
|
|
46
|
+
* (so a host re-registering on a JS reload doesn't accumulate
|
|
47
|
+
* duplicates). Safe to call from any thread.
|
|
48
|
+
*/
|
|
49
|
+
@JvmStatic
|
|
50
|
+
fun register(plugin: ARFramePlugin) {
|
|
51
|
+
val name = plugin.name()
|
|
52
|
+
// Drop any prior plugin with the same name, then add the new one.
|
|
53
|
+
registered.removeAll { it.name() == name }
|
|
54
|
+
registered.add(plugin)
|
|
55
|
+
Log.i(TAG, "register: '$name' (now ${registered.size} plugin(s))")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Unregister the plugin with the given [name] (no-op if none match).
|
|
60
|
+
* Safe to call from any thread.
|
|
61
|
+
*/
|
|
62
|
+
@JvmStatic
|
|
63
|
+
fun unregister(name: String) {
|
|
64
|
+
val removed = registered.removeAll { it.name() == name }
|
|
65
|
+
if (removed) Log.i(TAG, "unregister: '$name' (now ${registered.size} plugin(s))")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Snapshot of the currently-registered plugins. Read once per AR frame
|
|
70
|
+
* by the SDK; the [CopyOnWriteArrayList] makes iteration race-free
|
|
71
|
+
* against concurrent [register] / [unregister].
|
|
72
|
+
*/
|
|
73
|
+
@JvmStatic
|
|
74
|
+
fun plugins(): List<ARFramePlugin> = registered
|
|
75
|
+
|
|
76
|
+
/** Cheap fast-path read for the AR thread: "do we have any plugins?". */
|
|
77
|
+
@JvmStatic
|
|
78
|
+
fun isEmpty(): Boolean = registered.isEmpty()
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Emit an ASYNC plugin result to JS over the
|
|
82
|
+
* `RNImageStitcherARPluginResult` device event.
|
|
83
|
+
*
|
|
84
|
+
* Event body: `{ plugin: <pluginName>, result: <result> }` — the same
|
|
85
|
+
* shape the TS `onArPluginResult` prop consumes. Routes through the
|
|
86
|
+
* singleton [RNSARSession]'s `DeviceEventManagerModule` emitter (the
|
|
87
|
+
* SAME channel `onArFrame` uses), so RN drops the event when no JS
|
|
88
|
+
* listener is attached and a torn-down Catalyst instance is swallowed
|
|
89
|
+
* silently.
|
|
90
|
+
*
|
|
91
|
+
* Safe from any thread (the plugin's own work queue, typically).
|
|
92
|
+
*
|
|
93
|
+
* @param pluginName the emitting plugin's [ARFramePlugin.name].
|
|
94
|
+
* @param result the plugin's result map (consumed by JS verbatim).
|
|
95
|
+
*/
|
|
96
|
+
@JvmStatic
|
|
97
|
+
fun emit(pluginName: String, result: WritableMap) {
|
|
98
|
+
val session = RNSARSession.instance
|
|
99
|
+
if (session == null) {
|
|
100
|
+
Log.d(TAG, "emit('$pluginName'): no RNSARSession instance yet — dropping")
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
val body: WritableMap = Arguments.createMap().apply {
|
|
104
|
+
putString("plugin", pluginName)
|
|
105
|
+
putMap("result", result)
|
|
106
|
+
}
|
|
107
|
+
session.emitArPluginResult(body)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -928,6 +928,28 @@ class RNSARSession(reactContext: ReactApplicationContext)
|
|
|
928
928
|
}
|
|
929
929
|
}
|
|
930
930
|
|
|
931
|
+
/**
|
|
932
|
+
* Emit a pre-built ASYNC plugin-result body to JS over the
|
|
933
|
+
* `RNImageStitcherARPluginResult` device event (0.19.0). Called from
|
|
934
|
+
* [RNSARPluginRegistry.emit] — the body is `{ plugin, result }`.
|
|
935
|
+
*
|
|
936
|
+
* Reuses the SAME `DeviceEventManagerModule.RCTDeviceEventEmitter`
|
|
937
|
+
* channel as [emitArFrameMeta]; RN drops the event when no JS listener
|
|
938
|
+
* is attached, and a torn-down Catalyst instance is swallowed silently
|
|
939
|
+
* (plugin results are best-effort).
|
|
940
|
+
*/
|
|
941
|
+
internal fun emitArPluginResult(body: com.facebook.react.bridge.WritableMap) {
|
|
942
|
+
try {
|
|
943
|
+
reactApplicationContext
|
|
944
|
+
.getJSModule(
|
|
945
|
+
com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java,
|
|
946
|
+
)
|
|
947
|
+
.emit(AR_PLUGIN_RESULT_EVENT, body)
|
|
948
|
+
} catch (t: Throwable) {
|
|
949
|
+
Log.d(TAG, "emitArPluginResult: emit failed (ignoring): ${t.message}")
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
931
953
|
/// Required by RN's `NativeEventEmitter` contract — the TS
|
|
932
954
|
/// `onArFrame` wiring constructs a `NativeEventEmitter` over this
|
|
933
955
|
/// module, which calls `addListener`/`removeListeners` on subscribe /
|
|
@@ -1071,6 +1093,12 @@ class RNSARSession(reactContext: ReactApplicationContext)
|
|
|
1071
1093
|
/// match the shared contract + the iOS `supportedEvents` entry +
|
|
1072
1094
|
/// the TS `NativeEventEmitter` subscription string exactly.
|
|
1073
1095
|
const val AR_FRAME_META_EVENT = "RNImageStitcherARFrame"
|
|
1096
|
+
|
|
1097
|
+
/// Event name carrying an ASYNC plugin result to JS (0.19.0).
|
|
1098
|
+
/// MUST match [RNSARPluginRegistry.AR_PLUGIN_RESULT_EVENT], the
|
|
1099
|
+
/// iOS `supportedEvents` entry, and the TS subscription string.
|
|
1100
|
+
const val AR_PLUGIN_RESULT_EVENT =
|
|
1101
|
+
RNSARPluginRegistry.AR_PLUGIN_RESULT_EVENT
|
|
1074
1102
|
}
|
|
1075
1103
|
|
|
1076
1104
|
init {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
import React from 'react';
|
|
32
32
|
import { type ViewStyle } from 'react-native';
|
|
33
33
|
import type { CameraFrameProcessor } from '../stitching/CameraFrame';
|
|
34
|
-
import type { ARFrameMeta } from '../stitching/ARFrameMeta';
|
|
34
|
+
import type { ARFrameMeta, ARPluginResult } from '../stitching/ARFrameMeta';
|
|
35
35
|
export interface ARCameraViewProps {
|
|
36
36
|
/** Layout style, typically `StyleSheet.absoluteFill` or `flex: 1`. */
|
|
37
37
|
style?: ViewStyle;
|
|
@@ -116,6 +116,27 @@ export interface ARCameraViewProps {
|
|
|
116
116
|
* (≈ 10 Hz). No effect unless `onArFrame` is provided.
|
|
117
117
|
*/
|
|
118
118
|
arFrameMetaInterval?: number;
|
|
119
|
+
/**
|
|
120
|
+
* v0.19.0 — ASYNCHRONOUS AR-plugin result callback, invoked on the JS MAIN
|
|
121
|
+
* thread (NOT a worklet). Part of the AR plugin framework: host-registered
|
|
122
|
+
* native plugins (see `RNISARPluginRegistry` / `RNSARPluginRegistry`) can
|
|
123
|
+
* offload heavy per-frame work to their own queue and later push a result
|
|
124
|
+
* via `registry.emit(name, result)`. The SDK routes that to JS as a
|
|
125
|
+
* `RNImageStitcherARPluginResult` device event; when this prop is provided,
|
|
126
|
+
* this component subscribes and invokes the handler with
|
|
127
|
+
* `{ plugin, result }`.
|
|
128
|
+
*
|
|
129
|
+
* SYNCHRONOUS plugin results (computed inline on the AR thread) instead ride
|
|
130
|
+
* the throttled {@link onArFrame} event on {@link ARFrameMeta.plugins} —
|
|
131
|
+
* read them there. This callback is ONLY for the out-of-band async channel.
|
|
132
|
+
*
|
|
133
|
+
* The subscription is independent of {@link onArFrame}: a host can read
|
|
134
|
+
* sync results via `onArFrame` and async results via `onArPluginResult`,
|
|
135
|
+
* either, or both. Wiring mirrors `onArFrame` exactly (latest handler held
|
|
136
|
+
* in a ref so the subscription effect depends only on whether a handler is
|
|
137
|
+
* present; cleanup on unmount / when the handler is removed).
|
|
138
|
+
*/
|
|
139
|
+
onArPluginResult?: (e: ARPluginResult) => void;
|
|
119
140
|
}
|
|
120
141
|
/**
|
|
121
142
|
* Imperative handle exposed via the ref — shape mirrors the subset
|
|
@@ -77,7 +77,7 @@ const ensureStitcherProxyInstalled_1 = require("../stitching/ensureStitcherProxy
|
|
|
77
77
|
const NativeARCameraView = react_native_1.Platform.OS === 'ios' || react_native_1.Platform.OS === 'android'
|
|
78
78
|
? (0, react_native_1.requireNativeComponent)('RNSARCameraView')
|
|
79
79
|
: null;
|
|
80
|
-
exports.ARCameraView = (0, react_1.forwardRef)(function ARCameraView({ style, guidance, arFrameProcessor, enableDepth, enableAnchors, enableMesh, planeDetection, onArFrame, arFrameMetaInterval, }, ref) {
|
|
80
|
+
exports.ARCameraView = (0, react_1.forwardRef)(function ARCameraView({ style, guidance, arFrameProcessor, enableDepth, enableAnchors, enableMesh, planeDetection, onArFrame, arFrameMetaInterval, onArPluginResult, }, ref) {
|
|
81
81
|
// Held across the start→stop lifecycle so stopRecording's
|
|
82
82
|
// resolved VideoFile can be delivered via the same callback
|
|
83
83
|
// pair vision-camera uses.
|
|
@@ -170,6 +170,41 @@ exports.ARCameraView = (0, react_1.forwardRef)(function ARCameraView({ style, gu
|
|
|
170
170
|
session.setArFrameMetaEnabled?.(false, intervalMs);
|
|
171
171
|
};
|
|
172
172
|
}, [arFrameEnabled, arFrameMetaInterval]);
|
|
173
|
+
// v0.19.0 — onArPluginResult device-event wiring (worklet-free, main
|
|
174
|
+
// thread). Mirrors the onArFrame subscription above: the latest handler
|
|
175
|
+
// is held in a ref so the subscription effect depends only on WHETHER a
|
|
176
|
+
// handler is present, not its (per-render-changing) identity — so the
|
|
177
|
+
// native event subscription isn't torn down + re-established every render.
|
|
178
|
+
//
|
|
179
|
+
// This is a PURELY-JS subscription: unlike onArFrame there's no native
|
|
180
|
+
// "enable" toggle to flip. Native emits `RNImageStitcherARPluginResult`
|
|
181
|
+
// whenever a registered plugin calls `registry.emit(...)`; the registry is
|
|
182
|
+
// empty unless the host registered plugins, so an app with no plugins
|
|
183
|
+
// never sees an event even if this prop is wired.
|
|
184
|
+
const onArPluginResultRef = (0, react_1.useRef)(onArPluginResult);
|
|
185
|
+
(0, react_1.useEffect)(() => {
|
|
186
|
+
onArPluginResultRef.current = onArPluginResult;
|
|
187
|
+
}, [onArPluginResult]);
|
|
188
|
+
const arPluginResultEnabled = onArPluginResult != null;
|
|
189
|
+
(0, react_1.useEffect)(() => {
|
|
190
|
+
if (!arPluginResultEnabled) {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
const native = react_native_1.NativeModules
|
|
194
|
+
.RNSARSession;
|
|
195
|
+
if (native == null) {
|
|
196
|
+
// Native module unavailable (e.g. web, or a native build predating
|
|
197
|
+
// the plugin event channel): no-op, no crash.
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
const emitter = new react_native_1.NativeEventEmitter(native);
|
|
201
|
+
const sub = emitter.addListener('RNImageStitcherARPluginResult', (e) => {
|
|
202
|
+
onArPluginResultRef.current?.(e);
|
|
203
|
+
});
|
|
204
|
+
return () => {
|
|
205
|
+
sub.remove();
|
|
206
|
+
};
|
|
207
|
+
}, [arPluginResultEnabled]);
|
|
173
208
|
(0, react_1.useImperativeHandle)(ref, () => ({
|
|
174
209
|
takePhoto: async (options = {}) => {
|
|
175
210
|
const native = react_native_1.NativeModules.RNSARSession;
|