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 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). `forwardToIncremental` does the
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
- // 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()) {
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;