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.
@@ -492,6 +492,24 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
492
492
  private var lastArFrameMetaEmit: TimeInterval = 0
493
493
  private let arFrameMetaLock = NSLock()
494
494
 
495
+ // ──────────────────────────────────────────────────────────────
496
+ // v0.19.0 — native AR plugin framework (RNISARPluginRegistry)
497
+ // ──────────────────────────────────────────────────────────────
498
+ //
499
+ // While the plugin registry is NON-EMPTY, the per-frame path builds a
500
+ // `RNISARFrameContext` once and calls each registered plugin's
501
+ // `process(_:)` on the AR (delegate) thread (see `invokeArPlugins`).
502
+ // Non-nil SYNC results are cached here so the throttled `onArFrame`
503
+ // meta build can fold them in under `plugins: { [name]: result }`
504
+ // without re-running plugins. Zero-plugin apps skip the whole path
505
+ // (the registry's `isEmpty` gate), so they pay nothing.
506
+ //
507
+ // Written on the AR thread (per-frame) and read on the same thread
508
+ // (the meta build runs inline in `session(_:didUpdate:)`), but guarded
509
+ // anyway for defensiveness against any future off-thread reader.
510
+ private var latestPluginSyncResults: [String: Any] = [:]
511
+ private let pluginSyncResultsLock = NSLock()
512
+
495
513
  private override init() {
496
514
  super.init()
497
515
  arSession.delegate = self
@@ -594,6 +612,12 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
594
612
  detectedPlaneTransformInternal = nil
595
613
  bestRejectedAlignment = -1.0
596
614
  planeLatchLock.unlock()
615
+ // v0.19.0 — drop any cached SYNC plugin results so the next
616
+ // capture's `onArFrame` meta doesn't surface stale plugin output
617
+ // before the first frame of the new session runs the plugins.
618
+ pluginSyncResultsLock.lock()
619
+ latestPluginSyncResults = [:]
620
+ pluginSyncResultsLock.unlock()
597
621
  }
598
622
 
599
623
  /// Build the `ARWorldTrackingConfiguration` shared by `start()` and
@@ -846,6 +870,16 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
846
870
  // double-consuming would ingest each frame twice.
847
871
  RNSARWorkletRuntime.shared().dispatchFrame(frame, pose: pose)
848
872
 
873
+ // v0.19.0 — native AR plugin framework. When the registry is
874
+ // non-empty, build the per-frame `RNISARFrameContext` once and run
875
+ // every registered plugin's `process(_:)` SYNCHRONOUSLY on this AR
876
+ // thread (so the live pixel/depth buffers are valid for the call).
877
+ // Caches non-nil SYNC results for the meta build below. Cheap
878
+ // no-op when no plugins are registered (the common case). Runs
879
+ // BEFORE `maybeEmitArFrameMeta` so the throttled `onArFrame` meta
880
+ // can fold in this frame's freshest plugin results.
881
+ invokeArPlugins(frame, pose: pose)
882
+
849
883
  // v0.18.0 — `onArFrame` LIGHT-metadata channel. Gated +
850
884
  // throttled; builds the ARFrameMeta dictionary and posts it for
851
885
  // the bridge to re-emit. Cheap no-op when disabled (the common
@@ -1433,13 +1467,105 @@ public final class RNSARSession: NSObject, ARSessionDelegate {
1433
1467
  // Obj-C++ extraction helpers + the shared C++ extraction-config
1434
1468
  // gating (depth/anchors/mesh ⇐ enableDepth/enableAnchors/enableMesh).
1435
1469
  let meta = CameraFrameHostObject.lightArFrameMeta(from: frame, pose: pose)
1470
+
1471
+ // v0.19.0 — fold in any SYNC plugin results captured by
1472
+ // `invokeArPlugins` for the freshest frames. Only attach the
1473
+ // `plugins` key when there's at least one result, so the common
1474
+ // (no-plugin) meta shape is unchanged. Snapshot under the lock,
1475
+ // then bridge into a fresh dictionary copy.
1476
+ pluginSyncResultsLock.lock()
1477
+ let pluginResults = latestPluginSyncResults
1478
+ pluginSyncResultsLock.unlock()
1479
+ let userInfo: [AnyHashable: Any]
1480
+ if pluginResults.isEmpty {
1481
+ userInfo = meta
1482
+ } else {
1483
+ var withPlugins = meta
1484
+ withPlugins["plugins"] = pluginResults
1485
+ userInfo = withPlugins
1486
+ }
1487
+
1436
1488
  NotificationCenter.default.post(
1437
1489
  name: .retailensARFrameMeta,
1438
1490
  object: nil,
1439
- userInfo: meta
1491
+ userInfo: userInfo
1440
1492
  )
1441
1493
  }
1442
1494
 
1495
+ /// v0.19.0 — run all registered native AR plugins for this frame.
1496
+ /// Gated on the registry being NON-EMPTY (the cheap `isEmpty` check) so
1497
+ /// zero-plugin apps skip the context build entirely. When plugins are
1498
+ /// present, builds ONE `RNISARFrameContext` (zero-copy view of the
1499
+ /// frame's live buffers + the already-built anchor dicts) and calls
1500
+ /// each plugin's `process(_:)` SYNCHRONOUSLY on this AR (delegate)
1501
+ /// thread — so the live `pixelBuffer` / `depthBuffer` are valid for the
1502
+ /// call (the plugin must copy before offloading; see the protocol
1503
+ /// docstring). Non-nil SYNC results are cached in
1504
+ /// `latestPluginSyncResults` for the throttled `onArFrame` meta to fold
1505
+ /// in; ASYNC results arrive later via `RNISARPluginRegistry.emit`.
1506
+ private func invokeArPlugins(_ frame: ARFrame, pose: RNSARFramePose) {
1507
+ let registry = RNISARPluginRegistry.shared
1508
+ guard !registry.isEmpty else { return }
1509
+ let plugins = registry.plugins()
1510
+ guard !plugins.isEmpty else { return }
1511
+
1512
+ // depthBuffer: expose the live sceneDepth map ONLY when the
1513
+ // `<Camera enableDepth>` prop is on (gating read in Obj-C++ so the
1514
+ // C++ extraction-config header stays out of Swift). Prefer
1515
+ // `sceneDepth`, fall back to `smoothedSceneDepth` — same precedence
1516
+ // as the full extraction path.
1517
+ var depthBuffer: CVPixelBuffer? = nil
1518
+ if CameraFrameHostObject.arExtractionDepthEnabled() {
1519
+ if let dd = frame.sceneDepth ?? frame.smoothedSceneDepth {
1520
+ depthBuffer = dd.depthMap
1521
+ }
1522
+ }
1523
+
1524
+ // anchors: reuse the EXACT light dicts the `onArFrame` meta builds
1525
+ // (gated on `enableAnchors`; empty otherwise) — DRY single source.
1526
+ let anchorDicts = CameraFrameHostObject.arAnchorDicts(from: frame)
1527
+ let anchors = anchorDicts as? [[String: Any]] ?? []
1528
+
1529
+ let context = RNISARFrameContext(
1530
+ pixelBuffer: frame.capturedImage,
1531
+ timestampNs: frame.timestamp * 1e9,
1532
+ fx: pose.fx, fy: pose.fy, cx: pose.cx, cy: pose.cy,
1533
+ imageWidth: pose.imageWidth, imageHeight: pose.imageHeight,
1534
+ poseRotation: [pose.qx, pose.qy, pose.qz, pose.qw],
1535
+ poseTranslation: [pose.tx, pose.ty, pose.tz],
1536
+ trackingState: Self.trackingStateString(pose.trackingState),
1537
+ depthBuffer: depthBuffer,
1538
+ anchors: anchors
1539
+ )
1540
+
1541
+ var syncResults: [String: Any] = [:]
1542
+ for plugin in plugins {
1543
+ // Defensive: a plugin throwing/crashing in `process` would take
1544
+ // down the AR thread, but Swift has no try/catch for non-Error
1545
+ // crashes — the contract is that plugins are well-behaved. We
1546
+ // simply collect non-nil results keyed by the plugin's name.
1547
+ if let result = plugin.process(context) {
1548
+ syncResults[plugin.name()] = result
1549
+ }
1550
+ }
1551
+
1552
+ pluginSyncResultsLock.lock()
1553
+ latestPluginSyncResults = syncResults
1554
+ pluginSyncResultsLock.unlock()
1555
+ }
1556
+
1557
+ /// Map the SDK's `RNSARTrackingState` to the same string the
1558
+ /// `onArFrame` meta + `CameraFrame.trackingState` use, so plugins see a
1559
+ /// consistent vocabulary.
1560
+ private static func trackingStateString(_ s: RNSARTrackingState) -> String {
1561
+ switch s {
1562
+ case .tracking: return "normal"
1563
+ case .limited: return "limited"
1564
+ case .initialising: return "limited"
1565
+ case .notAvailable: return "notAvailable"
1566
+ }
1567
+ }
1568
+
1443
1569
  private func makePose(from frame: ARFrame) -> RNSARFramePose {
1444
1570
  // ARKit's transform is a 4x4 matrix; extract translation
1445
1571
  // (last column) and rotation (top-left 3x3 → quaternion).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Pose-aware panorama capture + stitching for React Native. One <Camera> component, both tap-to-photo and hold-to-pan modes, both AR-backed and IMU-fallback capture paths.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -44,7 +44,7 @@ import {
44
44
 
45
45
  import { ensureStitcherProxyInstalled } from '../stitching/ensureStitcherProxyInstalled';
46
46
  import type { CameraFrameProcessor } from '../stitching/CameraFrame';
47
- import type { ARFrameMeta } from '../stitching/ARFrameMeta';
47
+ import type { ARFrameMeta, ARPluginResult } from '../stitching/ARFrameMeta';
48
48
 
49
49
 
50
50
  // React Native looks up the component by its NATIVE name.
@@ -145,6 +145,28 @@ export interface ARCameraViewProps {
145
145
  * (≈ 10 Hz). No effect unless `onArFrame` is provided.
146
146
  */
147
147
  arFrameMetaInterval?: number;
148
+
149
+ /**
150
+ * v0.19.0 — ASYNCHRONOUS AR-plugin result callback, invoked on the JS MAIN
151
+ * thread (NOT a worklet). Part of the AR plugin framework: host-registered
152
+ * native plugins (see `RNISARPluginRegistry` / `RNSARPluginRegistry`) can
153
+ * offload heavy per-frame work to their own queue and later push a result
154
+ * via `registry.emit(name, result)`. The SDK routes that to JS as a
155
+ * `RNImageStitcherARPluginResult` device event; when this prop is provided,
156
+ * this component subscribes and invokes the handler with
157
+ * `{ plugin, result }`.
158
+ *
159
+ * SYNCHRONOUS plugin results (computed inline on the AR thread) instead ride
160
+ * the throttled {@link onArFrame} event on {@link ARFrameMeta.plugins} —
161
+ * read them there. This callback is ONLY for the out-of-band async channel.
162
+ *
163
+ * The subscription is independent of {@link onArFrame}: a host can read
164
+ * sync results via `onArFrame` and async results via `onArPluginResult`,
165
+ * either, or both. Wiring mirrors `onArFrame` exactly (latest handler held
166
+ * in a ref so the subscription effect depends only on whether a handler is
167
+ * present; cleanup on unmount / when the handler is removed).
168
+ */
169
+ onArPluginResult?: (e: ARPluginResult) => void;
148
170
  }
149
171
 
150
172
 
@@ -237,6 +259,7 @@ export const ARCameraView = forwardRef<ARCameraViewHandle, ARCameraViewProps>(
237
259
  planeDetection,
238
260
  onArFrame,
239
261
  arFrameMetaInterval,
262
+ onArPluginResult,
240
263
  },
241
264
  ref,
242
265
  ): React.JSX.Element {
@@ -362,6 +385,48 @@ export const ARCameraView = forwardRef<ARCameraViewHandle, ARCameraViewProps>(
362
385
  };
363
386
  }, [arFrameEnabled, arFrameMetaInterval]);
364
387
 
388
+ // v0.19.0 — onArPluginResult device-event wiring (worklet-free, main
389
+ // thread). Mirrors the onArFrame subscription above: the latest handler
390
+ // is held in a ref so the subscription effect depends only on WHETHER a
391
+ // handler is present, not its (per-render-changing) identity — so the
392
+ // native event subscription isn't torn down + re-established every render.
393
+ //
394
+ // This is a PURELY-JS subscription: unlike onArFrame there's no native
395
+ // "enable" toggle to flip. Native emits `RNImageStitcherARPluginResult`
396
+ // whenever a registered plugin calls `registry.emit(...)`; the registry is
397
+ // empty unless the host registered plugins, so an app with no plugins
398
+ // never sees an event even if this prop is wired.
399
+ const onArPluginResultRef = useRef<
400
+ ((e: ARPluginResult) => void) | undefined
401
+ >(onArPluginResult);
402
+ useEffect(() => {
403
+ onArPluginResultRef.current = onArPluginResult;
404
+ }, [onArPluginResult]);
405
+
406
+ const arPluginResultEnabled = onArPluginResult != null;
407
+ useEffect(() => {
408
+ if (!arPluginResultEnabled) {
409
+ return undefined;
410
+ }
411
+ const native = (NativeModules as Record<string, unknown>)
412
+ .RNSARSession;
413
+ if (native == null) {
414
+ // Native module unavailable (e.g. web, or a native build predating
415
+ // the plugin event channel): no-op, no crash.
416
+ return undefined;
417
+ }
418
+ const emitter = new NativeEventEmitter(native as never);
419
+ const sub = emitter.addListener(
420
+ 'RNImageStitcherARPluginResult',
421
+ (e: ARPluginResult) => {
422
+ onArPluginResultRef.current?.(e);
423
+ },
424
+ );
425
+ return () => {
426
+ sub.remove();
427
+ };
428
+ }, [arPluginResultEnabled]);
429
+
365
430
  useImperativeHandle(ref, () => ({
366
431
  takePhoto: async (options = {}) => {
367
432
  const native: any =
@@ -67,7 +67,7 @@ import type {
67
67
 
68
68
  import { useARSession } from '../ar/useARSession';
69
69
  import type { CameraFrameProcessor } from '../stitching/CameraFrame';
70
- import type { ARFrameMeta } from '../stitching/ARFrameMeta';
70
+ import type { ARFrameMeta, ARPluginResult } from '../stitching/ARFrameMeta';
71
71
  import { ARCameraView, type ARCameraViewHandle } from './ARCameraView';
72
72
  import { CameraShutter } from './CameraShutter';
73
73
  import { CameraView } from './CameraView';
@@ -814,6 +814,26 @@ export interface CameraProps {
814
814
  */
815
815
  arFrameMetaInterval?: number;
816
816
 
817
+ /**
818
+ * v0.19.0 — ASYNCHRONOUS AR-plugin result callback (the AR plugin
819
+ * framework), invoked on the JS MAIN thread (NOT a worklet). Only fires in
820
+ * AR capture (`captureSource === 'ar'`). Host-registered native plugins
821
+ * (see `RNISARPluginRegistry` / `RNSARPluginRegistry`) that offload heavy
822
+ * per-frame work to their own queue push results via
823
+ * `registry.emit(name, result)`; `<Camera>` threads this handler to
824
+ * `<ARCameraView>`, which subscribes to the `RNImageStitcherARPluginResult`
825
+ * device event and invokes it with `{ plugin, result }`.
826
+ *
827
+ * SYNCHRONOUS plugin results (computed inline on the AR thread) instead ride
828
+ * the throttled {@link onArFrame} event on {@link ARFrameMeta.plugins}.
829
+ * Use `onArFrame` for the in-band sync channel and `onArPluginResult` for
830
+ * the out-of-band async channel — a host can wire either or both.
831
+ *
832
+ * The SDK ships ONLY the generic plugin framework; there are no built-in
833
+ * plugins, so this never fires unless the host registers native plugins.
834
+ */
835
+ onArPluginResult?: (e: ARPluginResult) => void;
836
+
817
837
  // ── Panorama GUIDANCE (feature/pano-ux-guidance) ──────────────────
818
838
  /**
819
839
  * Which device holds the non-AR panorama capture accepts.
@@ -1227,6 +1247,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
1227
1247
  planeDetection,
1228
1248
  onArFrame,
1229
1249
  arFrameMetaInterval,
1250
+ onArPluginResult,
1230
1251
  engine = 'batch-keyframe',
1231
1252
  // ── Panorama GUIDANCE (feature/pano-ux-guidance) ──────────────
1232
1253
  panMode = 'vertical',
@@ -2497,6 +2518,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
2497
2518
  planeDetection={planeDetection}
2498
2519
  onArFrame={onArFrame}
2499
2520
  arFrameMetaInterval={arFrameMetaInterval}
2521
+ onArPluginResult={onArPluginResult}
2500
2522
  />
2501
2523
  ) : (
2502
2524
  <CameraView
package/src/index.ts CHANGED
@@ -265,8 +265,13 @@ export type {
265
265
  } from './stitching/CameraFrame';
266
266
  // v0.18.0 — LIGHT per-frame AR metadata delivered via the `onArFrame`
267
267
  // callback (main-thread, worklet-free). See the type's docstring for why
268
- // it bypasses the worklet path.
268
+ // it bypasses the worklet path. v0.19.0 adds `plugins` (sync results from
269
+ // host-registered AR plugins ride this same throttled event).
269
270
  export type { ARFrameMeta } from './stitching/ARFrameMeta';
271
+ // v0.19.0 — the AR plugin framework's ASYNC result type, delivered via the
272
+ // `onArPluginResult` callback (a plugin's out-of-band `registry.emit(...)`
273
+ // result). The SDK ships only the generic framework — no built-in plugins.
274
+ export type { ARPluginResult } from './stitching/ARFrameMeta';
270
275
  // NOTE: the host-worklet / frame-stream hooks `useFrameProcessor`,
271
276
  // `useThrottledFrameProcessor` and `useFrameStream` (v0.8–v0.9) were
272
277
  // archived in the batch-keyframe cleanup — they drove the third-party
@@ -104,4 +104,54 @@ export interface ARFrameMeta {
104
104
  vertexCount: number;
105
105
  faceCount: number;
106
106
  } | null;
107
+
108
+ /**
109
+ * v0.19.0 — SYNCHRONOUS results from host-registered AR frame plugins
110
+ * (the AR plugin framework). Keyed by each plugin's `name()`; the value
111
+ * is the light JSON result the plugin's `process(ctx)` returned on the AR
112
+ * thread (`nil`/`null`-returning plugins are omitted). Only present when
113
+ * the native plugin registry is non-empty AND at least one plugin returned
114
+ * a sync result for this frame; otherwise omitted entirely (zero-plugin
115
+ * apps pay nothing — native skips building the context).
116
+ *
117
+ * The SDK ships ONLY the generic framework — there are no built-in
118
+ * plugins. Hosts register native plugins via `RNISARPluginRegistry`
119
+ * (iOS) / `RNSARPluginRegistry` (Android) at startup; each plugin is
120
+ * called once per AR frame while the registry is non-empty. Result
121
+ * values are `unknown` because each plugin defines its own shape — cast
122
+ * after reading the entry you care about (e.g.
123
+ * `meta.plugins?.brightness as number`).
124
+ *
125
+ * ## Sync vs async results
126
+ *
127
+ * This field carries only the LIGHT, in-band SYNC results (computed fast
128
+ * enough to ride the throttled `onArFrame` event). Plugins that offload
129
+ * heavy work to their own queue deliver results out-of-band via
130
+ * `registry.emit(name, result)`, which surfaces through the separate
131
+ * `onArPluginResult` callback (the `RNImageStitcherARPluginResult`
132
+ * event) — NOT here.
133
+ */
134
+ plugins?: { [name: string]: unknown };
135
+ }
136
+
137
+
138
+ /**
139
+ * v0.19.0 — an ASYNCHRONOUS result from a host-registered AR frame plugin,
140
+ * delivered via the `onArPluginResult` callback.
141
+ *
142
+ * Unlike the in-band SYNC results carried on {@link ARFrameMeta.plugins}
143
+ * (which ride the throttled `onArFrame` event), a plugin produces an async
144
+ * result by offloading heavy work to its own queue and later calling
145
+ * `registry.emit(name, result)` on the native side. The SDK routes that to
146
+ * JS as a `RNImageStitcherARPluginResult` device event; `<ARCameraView>`
147
+ * subscribes and invokes `onArPluginResult` on the JS MAIN thread.
148
+ *
149
+ * `result` is `unknown` because each plugin defines its own result shape —
150
+ * branch on `plugin` (the emitting plugin's `name()`) and cast accordingly.
151
+ */
152
+ export interface ARPluginResult {
153
+ /** The `name()` of the plugin that emitted this result. */
154
+ plugin: string;
155
+ /** The plugin-defined result payload (cast after branching on `plugin`). */
156
+ result: unknown;
107
157
  }