react-native-image-stitcher 0.18.0 → 0.20.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 +62 -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/AROverlayRenderer.kt +406 -0
- package/android/src/main/java/io/imagestitcher/rn/AROverlayStore.kt +441 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +472 -13
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +30 -5
- package/android/src/main/java/io/imagestitcher/rn/RNSARPluginRegistry.kt +177 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +127 -0
- package/dist/camera/ARCameraView.d.ts +55 -2
- package/dist/camera/ARCameraView.js +68 -2
- package/dist/camera/Camera.d.ts +65 -2
- package/dist/camera/Camera.js +24 -6
- package/dist/camera/arOverlayController.d.ts +52 -0
- package/dist/camera/arOverlayController.js +132 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -2
- package/dist/stitching/ARFrameMeta.d.ts +49 -0
- package/dist/stitching/AROverlay.d.ts +97 -0
- package/dist/stitching/AROverlay.js +4 -0
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +15 -8
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +22 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.m +14 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +117 -1
- package/ios/Sources/RNImageStitcher/CameraFrameHostObject.h +25 -0
- package/ios/Sources/RNImageStitcher/CameraFrameHostObject.mm +66 -54
- package/ios/Sources/RNImageStitcher/RNISARFramePlugin.swift +284 -0
- package/ios/Sources/RNImageStitcher/RNISAROverlay.swift +409 -0
- package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +281 -3
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +127 -1
- package/package.json +1 -1
- package/src/camera/ARCameraView.tsx +139 -3
- package/src/camera/Camera.tsx +94 -3
- package/src/camera/arOverlayController.ts +184 -0
- package/src/index.ts +21 -1
- package/src/stitching/ARFrameMeta.ts +50 -0
- package/src/stitching/AROverlay.ts +105 -0
|
@@ -31,17 +31,37 @@
|
|
|
31
31
|
|
|
32
32
|
import Foundation
|
|
33
33
|
import ARKit
|
|
34
|
+
import SceneKit
|
|
34
35
|
import UIKit
|
|
36
|
+
import simd
|
|
35
37
|
|
|
36
38
|
|
|
37
39
|
@objc(RNSARCameraView)
|
|
38
|
-
public final class RNSARCameraView: UIView {
|
|
40
|
+
public final class RNSARCameraView: UIView, ARSCNViewDelegate {
|
|
39
41
|
|
|
40
42
|
/// The ARSCNView that does the actual rendering. Bound to the
|
|
41
43
|
/// singleton's ARSession so all preview surfaces share the same
|
|
42
44
|
/// session (and the same pose log that the stitcher consumes).
|
|
43
45
|
private var arSCNView: ARSCNView!
|
|
44
46
|
|
|
47
|
+
/// v0.20.0 — world-anchored overlays backed by REAL `ARAnchor`s. Each
|
|
48
|
+
/// overlay becomes an `ARAnchor` added to the session; ARSCNView creates a
|
|
49
|
+
/// node per anchor (via `renderer(_:nodeFor:)`) and keeps its transform
|
|
50
|
+
/// synced to the anchor every frame — and crucially ARKit *refines* the
|
|
51
|
+
/// anchor as its world understanding improves (drift / re-localization),
|
|
52
|
+
/// so the marker stays glued to the real-world spot across a long session,
|
|
53
|
+
/// not just a short one (which a fixed world coordinate would not survive).
|
|
54
|
+
/// Diffed against the overlay store each render pass (`RNISAROverlay` is
|
|
55
|
+
/// `Equatable`): add new ids, rebuild changed ones, remove gone ones.
|
|
56
|
+
///
|
|
57
|
+
/// Two maps, guarded by `anchorLock` because `nodeFor` may run on a
|
|
58
|
+
/// different (render) thread than the `updateAtTime` diff:
|
|
59
|
+
/// - `overlayAnchors`: overlay id → (its anchor, the overlay it came from)
|
|
60
|
+
/// - `anchorOverlays`: anchor UUID → overlay (so `nodeFor` can build it)
|
|
61
|
+
private var overlayAnchors: [String: (anchor: ARAnchor, overlay: RNISAROverlay)] = [:]
|
|
62
|
+
private var anchorOverlays: [UUID: RNISAROverlay] = [:]
|
|
63
|
+
private let anchorLock = NSLock()
|
|
64
|
+
|
|
45
65
|
public override init(frame: CGRect) {
|
|
46
66
|
super.init(frame: frame)
|
|
47
67
|
setupView()
|
|
@@ -68,6 +88,14 @@ public final class RNSARCameraView: UIView {
|
|
|
68
88
|
// a renderer.
|
|
69
89
|
arSCNView.session = RNSARSession.shared.arSession
|
|
70
90
|
|
|
91
|
+
// v0.20.0 — become the ARSCNView's render delegate so
|
|
92
|
+
// `renderer(_:updateAtTime:)` fires once per render pass (display
|
|
93
|
+
// rate) on the MAIN thread. This is our per-frame overlay redraw
|
|
94
|
+
// hook: cheap (a handful of overlays), already on the main thread,
|
|
95
|
+
// and gives us smooth display-rate tracking without touching the
|
|
96
|
+
// ARSession delegate (which `RNSARSession` owns for pose logging).
|
|
97
|
+
arSCNView.delegate = self
|
|
98
|
+
|
|
71
99
|
// We don't draw any 3D content in Phase 4.4. Disable
|
|
72
100
|
// SceneKit's automatic statistics overlay and lighting model
|
|
73
101
|
// — we just want the camera feed.
|
|
@@ -78,6 +106,39 @@ public final class RNSARCameraView: UIView {
|
|
|
78
106
|
// this view outside ARSCNView's letterboxed sub-rect).
|
|
79
107
|
backgroundColor = .black
|
|
80
108
|
addSubview(arSCNView)
|
|
109
|
+
|
|
110
|
+
// v0.20.0 — overlays render as world-anchored SceneKit nodes inside
|
|
111
|
+
// `arSCNView.scene` (see `syncOverlayNodes`), so there is no separate
|
|
112
|
+
// 2D overlay UIView to add here.
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// MARK: - v0.20.0 — declarative `overlays` prop (KVC from RN)
|
|
116
|
+
|
|
117
|
+
/// Declarative `overlays` prop. RN sets this via KVC
|
|
118
|
+
/// (`RCT_EXPORT_VIEW_PROPERTY(overlays, NSArray)`) whenever the prop
|
|
119
|
+
/// changes; we replace the JS overlay namespace wholesale (declarative
|
|
120
|
+
/// = the full set each render). Forwarded to the global store rather
|
|
121
|
+
/// than held per-view because the overlay world is global to the single
|
|
122
|
+
/// AR session. `@objc` + `NSArray` so KVC can set it.
|
|
123
|
+
@objc public var overlays: NSArray = [] {
|
|
124
|
+
didSet {
|
|
125
|
+
Self.applyJSSetOverlays(overlays)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// Parse a JS overlay-dictionary array and replace the JS overlay
|
|
130
|
+
/// namespace in the shared store. Shared by the declarative prop
|
|
131
|
+
/// setter (above) and the imperative `setOverlays` view command (in
|
|
132
|
+
/// `RNSARCameraViewManager`).
|
|
133
|
+
static func applyJSSetOverlays(_ overlays: NSArray) {
|
|
134
|
+
var parsed: [RNISAROverlay] = []
|
|
135
|
+
parsed.reserveCapacity(overlays.count)
|
|
136
|
+
for item in overlays {
|
|
137
|
+
guard let dict = item as? [String: Any],
|
|
138
|
+
let o = RNISAROverlay.from(dictionary: dict) else { continue }
|
|
139
|
+
parsed.append(o)
|
|
140
|
+
}
|
|
141
|
+
RNISAROverlayStore.shared.setJSOverlays(parsed)
|
|
81
142
|
}
|
|
82
143
|
|
|
83
144
|
public override func layoutSubviews() {
|
|
@@ -96,7 +157,12 @@ public final class RNSARCameraView: UIView {
|
|
|
96
157
|
// ARSCNView fills a same-AR sub-rect, there is nothing to crop
|
|
97
158
|
// and the user sees the full captured scene. The parent view's
|
|
98
159
|
// black background fills the remainder.
|
|
99
|
-
|
|
160
|
+
let lb = letterboxedFrame()
|
|
161
|
+
arSCNView.frame = lb
|
|
162
|
+
// Overlay nodes live in `arSCNView.scene` and are rendered by the
|
|
163
|
+
// same (letterboxed) ARSCNView against the live AR camera, so they
|
|
164
|
+
// need no separate framing — they align with the camera feed
|
|
165
|
+
// automatically.
|
|
100
166
|
}
|
|
101
167
|
|
|
102
168
|
/// Returns the largest `CGRect` inside `bounds` that matches the
|
|
@@ -184,6 +250,218 @@ public final class RNSARCameraView: UIView {
|
|
|
184
250
|
}
|
|
185
251
|
}
|
|
186
252
|
}
|
|
187
|
-
}
|
|
188
253
|
|
|
254
|
+
// MARK: - ARSCNViewDelegate (v0.20.0 overlay redraw hook)
|
|
255
|
+
|
|
256
|
+
/// Called once per ARSCNView render pass. Keeps the set of overlay
|
|
257
|
+
/// ARAnchors in sync with the overlay store (add / rebuild / remove).
|
|
258
|
+
/// ARKit then tracks each anchor and ARSCNView positions its node — we do
|
|
259
|
+
/// NOT project or position anything by hand. Cheap: an `==` diff over a
|
|
260
|
+
/// handful of overlays.
|
|
261
|
+
///
|
|
262
|
+
/// We deliberately do NOT touch `RNSARSession`'s pose log / plugin /
|
|
263
|
+
/// onArFrame paths — those ride the ARSession delegate which the singleton
|
|
264
|
+
/// owns. This delegate is purely the overlay-sync hook.
|
|
265
|
+
public func renderer(
|
|
266
|
+
_ renderer: SCNSceneRenderer,
|
|
267
|
+
updateAtTime time: TimeInterval
|
|
268
|
+
) {
|
|
269
|
+
syncOverlayAnchors()
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/// ARSCNViewDelegate: vend the node for one of our overlay anchors. May
|
|
273
|
+
/// run on the render thread; ARSCNView keeps the returned node's transform
|
|
274
|
+
/// synced to the (ARKit-refined) anchor. We build the visual RELATIVE to
|
|
275
|
+
/// the anchor origin — the anchor carries the world position.
|
|
276
|
+
public func renderer(
|
|
277
|
+
_ renderer: SCNSceneRenderer,
|
|
278
|
+
nodeFor anchor: ARAnchor
|
|
279
|
+
) -> SCNNode? {
|
|
280
|
+
anchorLock.lock()
|
|
281
|
+
let overlay = anchorOverlays[anchor.identifier]
|
|
282
|
+
anchorLock.unlock()
|
|
283
|
+
guard let overlay = overlay else { return nil }
|
|
284
|
+
|
|
285
|
+
if let quad = overlay.worldQuad, quad.count >= 3 {
|
|
286
|
+
// Anchor sits at the centroid; draw the loop relative to it.
|
|
287
|
+
var c = simd_float3(0, 0, 0)
|
|
288
|
+
for v in quad { c += v }
|
|
289
|
+
c /= Float(quad.count)
|
|
290
|
+
return Self.makeQuadOutlineNode(
|
|
291
|
+
relCorners: quad.map { $0 - c },
|
|
292
|
+
color: overlay.color, label: overlay.label)
|
|
293
|
+
}
|
|
294
|
+
return Self.makeBillboardNode(
|
|
295
|
+
sizeMeters: overlay.sizeMeters, color: overlay.color,
|
|
296
|
+
label: overlay.label)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/// Diff the overlay store against the live anchors: add an ARAnchor for
|
|
300
|
+
/// each new/changed overlay, remove anchors whose overlay is gone.
|
|
301
|
+
private func syncOverlayAnchors() {
|
|
302
|
+
let session = arSCNView.session
|
|
303
|
+
let overlays = RNISAROverlayStore.shared.snapshot()
|
|
304
|
+
var liveIDs = Set<String>()
|
|
305
|
+
liveIDs.reserveCapacity(overlays.count)
|
|
306
|
+
|
|
307
|
+
for overlay in overlays {
|
|
308
|
+
liveIDs.insert(overlay.id)
|
|
309
|
+
anchorLock.lock()
|
|
310
|
+
let existing = overlayAnchors[overlay.id]
|
|
311
|
+
anchorLock.unlock()
|
|
312
|
+
if let existing = existing, existing.overlay == overlay { continue }
|
|
313
|
+
|
|
314
|
+
// New or changed → drop the old anchor, add a fresh one.
|
|
315
|
+
if let existing = existing {
|
|
316
|
+
session.remove(anchor: existing.anchor)
|
|
317
|
+
anchorLock.lock()
|
|
318
|
+
anchorOverlays[existing.anchor.identifier] = nil
|
|
319
|
+
overlayAnchors[overlay.id] = nil
|
|
320
|
+
anchorLock.unlock()
|
|
321
|
+
}
|
|
322
|
+
guard let pose = Self.anchorPose(for: overlay) else { continue }
|
|
323
|
+
let anchor = ARAnchor(transform: pose)
|
|
324
|
+
anchorLock.lock()
|
|
325
|
+
anchorOverlays[anchor.identifier] = overlay
|
|
326
|
+
overlayAnchors[overlay.id] = (anchor, overlay)
|
|
327
|
+
anchorLock.unlock()
|
|
328
|
+
session.add(anchor: anchor)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Remove anchors whose overlay id is gone (snapshot under the lock,
|
|
332
|
+
// call session.remove outside it).
|
|
333
|
+
anchorLock.lock()
|
|
334
|
+
let goneIDs = overlayAnchors.keys.filter { !liveIDs.contains($0) }
|
|
335
|
+
let goneAnchors = goneIDs.compactMap { overlayAnchors[$0]?.anchor }
|
|
336
|
+
for id in goneIDs {
|
|
337
|
+
if let a = overlayAnchors[id]?.anchor { anchorOverlays[a.identifier] = nil }
|
|
338
|
+
overlayAnchors[id] = nil
|
|
339
|
+
}
|
|
340
|
+
anchorLock.unlock()
|
|
341
|
+
for a in goneAnchors { session.remove(anchor: a) }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// MARK: - v0.20.0 overlay node builders (RELATIVE to the anchor origin)
|
|
345
|
+
|
|
346
|
+
/// World transform for an overlay's anchor: a translation-only pose at the
|
|
347
|
+
/// `worldPosition`, or at the centroid of `worldQuad`. nil if no geometry.
|
|
348
|
+
private static func anchorPose(for overlay: RNISAROverlay) -> simd_float4x4? {
|
|
349
|
+
let p: simd_float3
|
|
350
|
+
if let quad = overlay.worldQuad, quad.count >= 3 {
|
|
351
|
+
var c = simd_float3(0, 0, 0)
|
|
352
|
+
for v in quad { c += v }
|
|
353
|
+
p = c / Float(quad.count)
|
|
354
|
+
} else if let center = overlay.worldPosition {
|
|
355
|
+
p = center
|
|
356
|
+
} else {
|
|
357
|
+
return nil
|
|
358
|
+
}
|
|
359
|
+
var m = matrix_identity_float4x4
|
|
360
|
+
m.columns.3 = simd_float4(p.x, p.y, p.z, 1)
|
|
361
|
+
return m
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/// A camera-facing billboard plane (at the anchor origin), sized in metres,
|
|
365
|
+
/// textured with a stroked outline + optional centred label. Always drawn
|
|
366
|
+
/// on top (depth read/write off) so an annotation is never hidden.
|
|
367
|
+
private static func makeBillboardNode(
|
|
368
|
+
sizeMeters: CGSize?,
|
|
369
|
+
color: UIColor,
|
|
370
|
+
label: String?
|
|
371
|
+
) -> SCNNode {
|
|
372
|
+
let w = sizeMeters?.width ?? RNISAROverlay.defaultMarkerExtent
|
|
373
|
+
let h = sizeMeters?.height ?? RNISAROverlay.defaultMarkerExtent
|
|
374
|
+
let plane = SCNPlane(width: w, height: h)
|
|
375
|
+
let mat = SCNMaterial()
|
|
376
|
+
mat.diffuse.contents = overlayImage(color: color, label: label)
|
|
377
|
+
mat.isDoubleSided = true
|
|
378
|
+
mat.lightingModel = .constant // unlit — show the texture as-is
|
|
379
|
+
mat.writesToDepthBuffer = false
|
|
380
|
+
mat.readsFromDepthBuffer = false // never occluded by the scene
|
|
381
|
+
plane.firstMaterial = mat
|
|
382
|
+
|
|
383
|
+
let node = SCNNode(geometry: plane)
|
|
384
|
+
node.renderingOrder = 1000 // draw after the camera background
|
|
385
|
+
let billboard = SCNBillboardConstraint()
|
|
386
|
+
billboard.freeAxes = .all // always face the camera, flat
|
|
387
|
+
node.constraints = [billboard]
|
|
388
|
+
return node
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/// A 3D line-loop through corners expressed RELATIVE to the anchor origin
|
|
392
|
+
/// (the anchor is at the quad centroid), with an optional camera-facing
|
|
393
|
+
/// label at the centre.
|
|
394
|
+
private static func makeQuadOutlineNode(
|
|
395
|
+
relCorners: [simd_float3],
|
|
396
|
+
color: UIColor,
|
|
397
|
+
label: String?
|
|
398
|
+
) -> SCNNode {
|
|
399
|
+
let vertices = relCorners.map { SCNVector3($0.x, $0.y, $0.z) }
|
|
400
|
+
var indices: [Int32] = []
|
|
401
|
+
let n = vertices.count
|
|
402
|
+
indices.reserveCapacity(n * 2)
|
|
403
|
+
for i in 0..<n {
|
|
404
|
+
indices.append(Int32(i))
|
|
405
|
+
indices.append(Int32((i + 1) % n)) // close the loop
|
|
406
|
+
}
|
|
407
|
+
let src = SCNGeometrySource(vertices: vertices)
|
|
408
|
+
let elem = SCNGeometryElement(indices: indices, primitiveType: .line)
|
|
409
|
+
let geo = SCNGeometry(sources: [src], elements: [elem])
|
|
410
|
+
let mat = SCNMaterial()
|
|
411
|
+
mat.diffuse.contents = color
|
|
412
|
+
mat.lightingModel = .constant
|
|
413
|
+
mat.writesToDepthBuffer = false
|
|
414
|
+
mat.readsFromDepthBuffer = false
|
|
415
|
+
geo.firstMaterial = mat
|
|
416
|
+
|
|
417
|
+
let node = SCNNode(geometry: geo)
|
|
418
|
+
node.renderingOrder = 1000
|
|
419
|
+
|
|
420
|
+
if let label = label, !label.isEmpty {
|
|
421
|
+
// Label at the centroid (≈ local origin in relative space).
|
|
422
|
+
let labelNode = makeBillboardNode(
|
|
423
|
+
sizeMeters: CGSize(width: 0.12, height: 0.12),
|
|
424
|
+
color: color, label: label)
|
|
425
|
+
node.addChildNode(labelNode)
|
|
426
|
+
}
|
|
427
|
+
return node
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/// Render a stroked rounded-rect outline + optional centred label chip to
|
|
431
|
+
/// a square image, used as the billboard plane's texture. Transparent
|
|
432
|
+
/// background so only the outline + chip show over the camera feed.
|
|
433
|
+
private static func overlayImage(color: UIColor, label: String?) -> UIImage {
|
|
434
|
+
let px: CGFloat = 512
|
|
435
|
+
let renderer = UIGraphicsImageRenderer(size: CGSize(width: px, height: px))
|
|
436
|
+
return renderer.image { ctx in
|
|
437
|
+
let cg = ctx.cgContext
|
|
438
|
+
let inset = px * 0.07
|
|
439
|
+
let rect = CGRect(x: inset, y: inset,
|
|
440
|
+
width: px - 2 * inset, height: px - 2 * inset)
|
|
441
|
+
let path = UIBezierPath(roundedRect: rect, cornerRadius: px * 0.05)
|
|
442
|
+
cg.setStrokeColor(color.cgColor)
|
|
443
|
+
cg.setLineWidth(px * 0.03)
|
|
444
|
+
path.stroke()
|
|
445
|
+
|
|
446
|
+
guard let label = label, !label.isEmpty else { return }
|
|
447
|
+
let fontSize = px * 0.13
|
|
448
|
+
let font = UIFont.systemFont(ofSize: fontSize, weight: .bold)
|
|
449
|
+
let attrs: [NSAttributedString.Key: Any] = [
|
|
450
|
+
.font: font, .foregroundColor: UIColor.white,
|
|
451
|
+
]
|
|
452
|
+
let ts = (label as NSString).size(withAttributes: attrs)
|
|
453
|
+
let pad = fontSize * 0.35
|
|
454
|
+
let chip = CGRect(x: (px - ts.width) / 2 - pad,
|
|
455
|
+
y: (px - ts.height) / 2 - pad,
|
|
456
|
+
width: ts.width + 2 * pad,
|
|
457
|
+
height: ts.height + 2 * pad)
|
|
458
|
+
color.withAlphaComponent(0.9).setFill()
|
|
459
|
+
UIBezierPath(roundedRect: chip, cornerRadius: pad).fill()
|
|
460
|
+
(label as NSString).draw(
|
|
461
|
+
at: CGPoint(x: (px - ts.width) / 2, y: (px - ts.height) / 2),
|
|
462
|
+
withAttributes: attrs)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
}
|
|
189
467
|
|
|
@@ -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:
|
|
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.
|
|
3
|
+
"version": "0.20.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",
|