react-native-image-stitcher 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/android/build.gradle +35 -1
  3. package/android/src/main/cpp/CMakeLists.txt +64 -2
  4. package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +227 -0
  5. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +30 -11
  6. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +21 -3
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +78 -3
  8. package/android/src/main/java/io/imagestitcher/rn/SaveFrameAsJpegPlugin.kt +162 -0
  9. package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +103 -0
  10. package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +256 -0
  11. package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +100 -0
  12. package/cpp/stitcher_frame_data.hpp +141 -0
  13. package/cpp/stitcher_frame_jsi.cpp +214 -0
  14. package/cpp/stitcher_frame_jsi.hpp +108 -0
  15. package/cpp/stitcher_proxy_jsi.cpp +109 -0
  16. package/cpp/stitcher_proxy_jsi.hpp +46 -0
  17. package/cpp/stitcher_worklet_dispatch.cpp +103 -0
  18. package/cpp/stitcher_worklet_dispatch.hpp +71 -0
  19. package/cpp/stitcher_worklet_registry.cpp +81 -0
  20. package/cpp/stitcher_worklet_registry.hpp +136 -0
  21. package/dist/camera/Camera.d.ts +62 -12
  22. package/dist/camera/Camera.js +30 -15
  23. package/dist/index.d.ts +6 -0
  24. package/dist/index.js +30 -1
  25. package/dist/stitching/StitcherFrame.d.ts +170 -0
  26. package/dist/stitching/StitcherFrame.js +4 -0
  27. package/dist/stitching/StitcherWorkletRegistry.d.ts +117 -0
  28. package/dist/stitching/StitcherWorkletRegistry.js +78 -0
  29. package/dist/stitching/ensureStitcherProxyInstalled.d.ts +8 -0
  30. package/dist/stitching/ensureStitcherProxyInstalled.js +81 -0
  31. package/dist/stitching/useFrameProcessor.d.ts +119 -0
  32. package/dist/stitching/useFrameProcessor.js +196 -0
  33. package/dist/stitching/useFrameStream.d.ts +34 -0
  34. package/dist/stitching/useFrameStream.js +219 -0
  35. package/dist/stitching/useThrottledFrameProcessor.d.ts +33 -0
  36. package/dist/stitching/useThrottledFrameProcessor.js +132 -0
  37. package/dist/types.d.ts +87 -0
  38. package/ios/Sources/RNImageStitcher/RNSARSession.swift +46 -10
  39. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +128 -0
  40. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +313 -0
  41. package/ios/Sources/RNImageStitcher/SaveFrameAsJpegPlugin.mm +185 -0
  42. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.h +60 -0
  43. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.mm +214 -0
  44. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.h +42 -0
  45. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +103 -0
  46. package/package.json +1 -1
  47. package/src/camera/Camera.tsx +93 -28
  48. package/src/index.ts +35 -0
  49. package/src/stitching/StitcherFrame.ts +197 -0
  50. package/src/stitching/StitcherWorkletRegistry.ts +156 -0
  51. package/src/stitching/__tests__/StitcherWorkletRegistry.test.ts +176 -0
  52. package/src/stitching/__tests__/ensureStitcherProxyInstalled.test.ts +94 -0
  53. package/src/stitching/__tests__/useThrottledFrameProcessor.test.ts +178 -0
  54. package/src/stitching/ensureStitcherProxyInstalled.ts +141 -0
  55. package/src/stitching/useFrameProcessor.ts +226 -0
  56. package/src/stitching/useFrameStream.ts +255 -0
  57. package/src/stitching/useThrottledFrameProcessor.ts +145 -0
  58. package/src/types.ts +95 -0
@@ -1001,14 +1001,17 @@ class IncrementalStitcher(
1001
1001
  // per accepted frame on a mid-tier device. Pass null to use
1002
1002
  // the legacy JPEG path.
1003
1003
  //
1004
- // OWNERSHIP: the engine retains a reference to `nv21PixelData`
1005
- // until `workScope`'s coroutine consumes it (~50 ms later).
1006
- // Callers MUST treat the array as transferred — do not
1007
- // mutate it or return it to a buffer pool after calling
1008
- // this method. If a caller needs to recycle the buffer,
1009
- // pass `.copyOf()` (currently no caller does the F8.4
1010
- // Frame Processor plugin allocates a fresh array per frame).
1011
- nv21PixelData: ByteArray? = null,
1004
+ // OWNERSHIP: wrapped in `TransferredNV21` (audit #4A,
1005
+ // v0.10.0). The wrapper enforces single-use: the engine
1006
+ // calls `.takeOnce()` on the producer thread before
1007
+ // dispatching to `workScope`; subsequent attempts to extract
1008
+ // the bytes throw. Callers MUST construct a fresh
1009
+ // `TransferredNV21` per frame and MUST NOT hand the same
1010
+ // instance to two consumers (e.g., a sync gate-eval + an
1011
+ // async workScope.launch). The Frame Processor plugin and
1012
+ // the AR camera view both allocate fresh NV21 arrays per
1013
+ // frame; the wrapper is a defensive-programming guard.
1014
+ nv21PixelData: TransferredNV21? = null,
1012
1015
  nv21PixelWidth: Int = 0,
1013
1016
  nv21PixelHeight: Int = 0,
1014
1017
  ) {
@@ -1215,11 +1218,20 @@ class IncrementalStitcher(
1215
1218
  )
1216
1219
  return
1217
1220
  }
1221
+ // v0.10.0 audit #4A — extract the wrapped bytes ONCE on the
1222
+ // producer thread before dispatching to workScope. This
1223
+ // makes the transfer-of-ownership explicit + caught early:
1224
+ // if a caller accidentally passes the same TransferredNV21
1225
+ // instance to a sync consumer earlier, takeOnce() would
1226
+ // have already thrown there. Capturing `pixelBytes` by
1227
+ // value inside the coroutine sidesteps any chance of the
1228
+ // wrapper being read from two threads.
1229
+ val pixelBytes: ByteArray? = if (hasPixelData) nv21PixelData!!.takeOnce() else null
1218
1230
  workScope.launch {
1219
1231
  val state: WritableMap? = if (firstwins != null) {
1220
1232
  val tele = if (hasPixelData) {
1221
1233
  firstwins.addFramePixelData(
1222
- nv21 = nv21PixelData!!,
1234
+ nv21 = pixelBytes!!,
1223
1235
  nv21Width = nv21PixelWidth,
1224
1236
  nv21Height = nv21PixelHeight,
1225
1237
  qx = qx, qy = qy, qz = qz, qw = qw,
@@ -1246,7 +1258,7 @@ class IncrementalStitcher(
1246
1258
  } else {
1247
1259
  val tele = if (hasPixelData) {
1248
1260
  hybrid!!.addFramePixelData(
1249
- nv21 = nv21PixelData!!,
1261
+ nv21 = pixelBytes!!,
1250
1262
  nv21Width = nv21PixelWidth,
1251
1263
  nv21Height = nv21PixelHeight,
1252
1264
  qx = qx, qy = qy, qz = qz, qw = qw,
@@ -1410,7 +1422,14 @@ class IncrementalStitcher(
1410
1422
  // `addFramePixelData` instead of JPEG-decoding a
1411
1423
  // separately-written path. Batch-keyframe mode
1412
1424
  // ignores these (it uses `grayData` + `onAccept`).
1413
- nv21PixelData = nv21Bytes,
1425
+ //
1426
+ // v0.10.0 audit #4A — wrap in TransferredNV21 so the
1427
+ // engine takes ownership exactly once on the producer
1428
+ // thread (engine calls `.takeOnce()` before workScope).
1429
+ // Misuse (handing this same instance to two consumers)
1430
+ // throws at the second `.takeOnce()` site, not silently
1431
+ // corrupting frames.
1432
+ nv21PixelData = TransferredNV21(nv21Bytes),
1414
1433
  nv21PixelWidth = width,
1415
1434
  nv21PixelHeight = height,
1416
1435
  onAccept = { targetPath ->
@@ -55,19 +55,33 @@ class RNImageStitcherPackage : ReactPackage {
55
55
  ) { proxy, options ->
56
56
  CvFlowGateFrameProcessor(proxy, options)
57
57
  }
58
+ // v0.9.0 Layer 1 — register `save_frame_as_jpeg`
59
+ // alongside the cv_flow_gate plugin. Same lifecycle,
60
+ // same defensive error handling (the outer try/catch
61
+ // covers both registrations). Either both register
62
+ // or neither does — if vc isn't on the classpath,
63
+ // both calls are skipped together.
64
+ FrameProcessorPluginRegistry.addFrameProcessorPlugin(
65
+ SaveFrameAsJpegPlugin.PLUGIN_NAME,
66
+ ) { proxy, options ->
67
+ SaveFrameAsJpegPlugin(proxy, options)
68
+ }
58
69
  fpPluginRegistered = true
59
70
  } catch (e: NoClassDefFoundError) {
60
71
  android.util.Log.i(
61
72
  "RNImageStitcherPackage",
62
73
  "vision-camera FrameProcessorPluginRegistry not on classpath — "
63
- + "skipping cv_flow_gate_process_frame plugin registration "
64
- + "(host app doesn't appear to use Frame Processors).",
74
+ + "skipping cv_flow_gate_process_frame + save_frame_as_jpeg "
75
+ + "plugin registration (host app doesn't appear to use "
76
+ + "Frame Processors).",
65
77
  )
66
78
  fpPluginRegistered = true // don't retry every package init
67
79
  } catch (e: Throwable) {
68
80
  android.util.Log.w(
69
81
  "RNImageStitcherPackage",
70
- "Failed to register cv_flow_gate_process_frame plugin: ${e.message}",
82
+ "Failed to register Frame Processor plugins "
83
+ + "(cv_flow_gate_process_frame / save_frame_as_jpeg): "
84
+ + e.message,
71
85
  )
72
86
  fpPluginRegistered = true
73
87
  }
@@ -87,6 +101,10 @@ class RNImageStitcherPackage : ReactPackage {
87
101
  RNSARSession(reactContext),
88
102
  IncrementalStitcher(reactContext),
89
103
  FileBridge(reactContext),
104
+ // v0.8.0 Phase 4b.ii — Android JSI installer for the
105
+ // host-worklet `__stitcherProxy` global. Mirror of
106
+ // iOS' `StitcherJsiInstaller`.
107
+ StitcherJsiInstallerModule(reactContext),
90
108
  )
91
109
  }
92
110
 
@@ -287,8 +287,20 @@ class RNSARCameraView @JvmOverloads constructor(
287
287
  // contract was already in place for Phase 4.
288
288
  appendPose(camera, frame.timestamp)
289
289
 
290
- // Forward to the incremental stitcher if engaged.
291
- if (ingestActive) {
290
+ // Forward to the incremental stitcher if engaged, OR if any
291
+ // host worklets are registered (v0.8.0 Phase 4b.iii). iOS'
292
+ // `RNSARWorkletRuntime.dispatchFrame:pose:` fires on every
293
+ // AR frame regardless of capture state; Android needs the
294
+ // same semantic so host worklets see the AR-mode preview
295
+ // stream, not just capture frames.
296
+ //
297
+ // `hasHostWorklets()` is a microsecond atomic-read on the
298
+ // native registry — cheap enough to hit per frame. When
299
+ // no host worklets are registered AND no capture is active,
300
+ // the entire forwardToIncremental branch (including the
301
+ // ~3-5ms NV21 pack) is skipped — same cost envelope as
302
+ // before Phase 4b.iii.
303
+ if (ingestActive || StitcherWorkletRuntime.hasHostWorklets()) {
292
304
  forwardToIncremental(frame, camera)
293
305
  }
294
306
 
@@ -514,6 +526,23 @@ class RNSARCameraView @JvmOverloads constructor(
514
526
  // (Was: eager JPEG encode for non-batch-keyframe modes,
515
527
  // written to `tmpJpegFile`, passed as `legacyJpegPath`.
516
528
  // See the v0.3 / F8.6 entries in CHANGELOG.md.)
529
+ //
530
+ // v0.8.0 Phase 3c — route through the worklet runtime's
531
+ // `runFirstParty` indirection. The lambda body is the
532
+ // unchanged engine ingest call; the indirection sets up
533
+ // the seam where Phase 4 will fan out to host worklets
534
+ // without touching this first-party path. Synchronous
535
+ // invocation preserves the ARCore Image ownership contract
536
+ // — the engine consumes the TransferredNV21 inside the
537
+ // lambda before ARCore recycles the Image.
538
+ StitcherWorkletRuntime.installIfNeeded()
539
+ // v0.8.0 Phase 4b.iii — only run first-party stitching when
540
+ // the host has actively engaged capture (`setIncrementalIngestionActive(true)`).
541
+ // The host-worklet dispatch below runs regardless, so AR-mode
542
+ // preview frames stream through registered host worklets even
543
+ // before/after capture.
544
+ if (ingestActive) {
545
+ StitcherWorkletRuntime.runFirstParty {
517
546
  module.ingestFromARCameraView(
518
547
  tx = tArr[0].toDouble(),
519
548
  ty = tArr[1].toDouble(),
@@ -538,7 +567,17 @@ class RNSARCameraView @JvmOverloads constructor(
538
567
  legacyJpegPath = null,
539
568
  // F8.6 — pixel-data path for live engines. Batch-
540
569
  // keyframe mode ignores these (bails earlier).
541
- nv21PixelData = packed.nv21,
570
+ //
571
+ // v0.10.0 audit #4A — wrap `packed.nv21` in
572
+ // TransferredNV21 so ownership is enforced at runtime.
573
+ // The AR caller passes the SAME `packed.nv21` array as
574
+ // both `grayData` (sync, gate-eval read) and
575
+ // `nv21PixelData` (async, engine ingest). Today no race
576
+ // because grayData is consumed inside evaluateWithFrame
577
+ // before workScope.launch fires; the wrapper makes a
578
+ // future refactor that reorders consumption fail loudly
579
+ // instead of silently corrupting frames.
580
+ nv21PixelData = TransferredNV21(packed.nv21),
542
581
  nv21PixelWidth = packed.width,
543
582
  nv21PixelHeight = packed.height,
544
583
  onAccept = { targetPath ->
@@ -555,6 +594,42 @@ class RNSARCameraView @JvmOverloads constructor(
555
594
  ) != null
556
595
  },
557
596
  )
597
+ } // closes StitcherWorkletRuntime.runFirstParty { … } (v0.8.0 Phase 3c)
598
+ } // closes `if (ingestActive)` (v0.8.0 Phase 4b.iii)
599
+
600
+ // ── v0.8.0 Phase 4b.iii — host-worklet fan-out ─────────────
601
+ //
602
+ // Dispatch the AR frame to every host worklet registered via
603
+ // `globalThis.__stitcherProxy.install(workletFn)` (the
604
+ // `useFrameProcessor` hook's AR-mode path). The native side
605
+ // fast-path early-exits when the registry is empty (~ns
606
+ // cost), so this call is free for first-party-only deployments.
607
+ //
608
+ // Map the trackingState back to the JS-visible string set.
609
+ // `RNSARSession.TRACKING_*` are int codes; we re-derive the
610
+ // string here instead of plumbing it through. (Could be
611
+ // refactored into a helper if/when other call sites need
612
+ // it.)
613
+ val trackingStateStr = when (camera.trackingState) {
614
+ TrackingState.TRACKING -> "normal"
615
+ TrackingState.PAUSED -> "limited"
616
+ TrackingState.STOPPED -> "notAvailable"
617
+ else -> ""
618
+ }
619
+ StitcherWorkletRuntime.dispatchToHostWorklets(
620
+ nv21Bytes = packed.nv21,
621
+ width = packed.width,
622
+ height = packed.height,
623
+ qx = qarr[0].toDouble(),
624
+ qy = qarr[1].toDouble(),
625
+ qz = qarr[2].toDouble(),
626
+ qw = qarr[3].toDouble(),
627
+ tx = tArr[0].toDouble(),
628
+ ty = tArr[1].toDouble(),
629
+ tz = tArr[2].toDouble(),
630
+ timestampNs = frame.timestamp.toDouble(),
631
+ trackingState = trackingStateStr,
632
+ )
558
633
  }
559
634
 
560
635
  private fun applyDisplayGeometry() {
@@ -0,0 +1,162 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ package io.imagestitcher.rn
3
+
4
+ import android.graphics.ImageFormat
5
+ import android.media.Image
6
+ import android.util.Log
7
+ import androidx.annotation.Keep
8
+ import com.facebook.proguard.annotations.DoNotStrip
9
+ import com.mrousavy.camera.frameprocessors.Frame
10
+ import com.mrousavy.camera.frameprocessors.FrameProcessorPlugin
11
+ import com.mrousavy.camera.frameprocessors.VisionCameraProxy
12
+ import io.imagestitcher.rn.ar.YuvImageConverter
13
+
14
+ /**
15
+ * v0.9.0 Layer 1 — Android vc Frame Processor plugin that JPEG-
16
+ * encodes the supplied frame to a host-supplied path. Mirror of
17
+ * iOS' `SaveFrameAsJpegPlugin.mm`.
18
+ *
19
+ * Plugin name (must match iOS): `save_frame_as_jpeg`.
20
+ *
21
+ * ## Wrapping the existing encoder
22
+ *
23
+ * The lib already encodes JPEGs from NV21 bytes via
24
+ * `YuvImageConverter.encodeJpegFromNV21` — that's the path
25
+ * `RNSARCameraView.kt`'s keyframe-accept callback uses (line 589,
26
+ * `onAccept = { targetPath -> ... encodeJpegFromNV21(...) }`).
27
+ * This plugin reuses that exact encoder so:
28
+ * - JPEG output is byte-equivalent to the keyframe-accept output
29
+ * (same encoder, same quality knob)
30
+ * - No new encoder maintenance burden
31
+ *
32
+ * vision-camera's `Frame.image` is an `android.media.Image` in
33
+ * `YUV_420_888` format (the camera's native). We pass it through
34
+ * `YuvImageConverter.packNV21(image)` to extract the dense NV21
35
+ * byte array + dims, then `encodeJpegFromNV21(packed, file, q, rot)`
36
+ * does the JPEG write.
37
+ *
38
+ * ## Plugin contract (matches iOS surface exactly)
39
+ *
40
+ * Arguments dict:
41
+ * - `path` (string, REQUIRED): absolute output path.
42
+ * - `quality` (number, optional): 0-100 JPEG quality. Default 75.
43
+ * Clamped to [1, 100].
44
+ *
45
+ * Returns:
46
+ * - On success: `{ "ok" => true, "path" => ..., "width" => ...,
47
+ * "height" => ... }`
48
+ * - On failure: `{ "ok" => false, "error" => "..." }`
49
+ *
50
+ * Errors surfaced via the result map (not thrown) — host worklets
51
+ * can branch on `result.ok` without try/catch. Same convention
52
+ * as iOS.
53
+ *
54
+ * ## Lifetime / threading
55
+ *
56
+ * The supplied `Frame` (and its `Image`) is valid only for the
57
+ * duration of this callback — vision-camera closes the underlying
58
+ * `Image` on return. All Image access (NV21 pack + JPEG encode)
59
+ * happens synchronously inside `callback()`.
60
+ *
61
+ * ## Format restriction
62
+ *
63
+ * Only `YUV_420_888` input is supported (vc Android's standard).
64
+ * Anything else returns `{ ok: false, error: "unsupported format" }`.
65
+ * No format conversion fallback — that would mask bugs in the host
66
+ * camera config.
67
+ *
68
+ * ## Registration
69
+ *
70
+ * Registered in `RNImageStitcherPackage.kt`'s companion-object
71
+ * `ensureFrameProcessorPluginRegistered()`, alongside
72
+ * `cv_flow_gate_process_frame`. Same defensive
73
+ * NoClassDefFoundError handling — if vc isn't on the host's
74
+ * classpath, registration is silently skipped (and the plugin's
75
+ * `init { … }` calls below never happen).
76
+ */
77
+ @DoNotStrip
78
+ @Keep
79
+ class SaveFrameAsJpegPlugin(
80
+ @Suppress("UNUSED_PARAMETER") proxy: VisionCameraProxy,
81
+ @Suppress("UNUSED_PARAMETER") options: Map<String, Any>?,
82
+ ) : FrameProcessorPlugin() {
83
+
84
+ override fun callback(frame: Frame, params: Map<String, Any>?): Any {
85
+ val path = params?.get("path") as? String
86
+ ?: return mapOf(
87
+ "ok" to false,
88
+ "error" to "missing required `path` argument",
89
+ )
90
+ val rawQuality = (params["quality"] as? Number)?.toInt() ?: 75
91
+ val quality = rawQuality.coerceIn(1, 100)
92
+
93
+ // Frame may throw if vc already released it.
94
+ val image: Image = try {
95
+ frame.image
96
+ } catch (e: Throwable) {
97
+ return mapOf(
98
+ "ok" to false,
99
+ "error" to "frame invalid: ${e.message}",
100
+ )
101
+ }
102
+
103
+ if (image.format != ImageFormat.YUV_420_888) {
104
+ return mapOf(
105
+ "ok" to false,
106
+ "error" to "unsupported format ${image.format} (need YUV_420_888)",
107
+ )
108
+ }
109
+
110
+ // Pack NV21 — same call site RNSARCameraView uses.
111
+ val packed = YuvImageConverter.packNV21(image)
112
+ ?: return mapOf(
113
+ "ok" to false,
114
+ "error" to "YuvImageConverter.packNV21 returned null",
115
+ )
116
+
117
+ // Reuse the lib's existing JPEG encoder. Rotation is 0 here
118
+ // (the host's frame is in camera-native orientation; if they
119
+ // want display orientation they can pass an `orientation`
120
+ // arg in a future version — for v0.9.0 the worklet emits
121
+ // raw-camera-oriented JPEGs, matching the keyframe-accept
122
+ // pipeline's behaviour).
123
+ //
124
+ // Signature: `encodeJpegFromNV21(packed, outputPath: String,
125
+ // jpegQuality: Int, displayRotation: Int): String?` — returns
126
+ // the written path on success, null on failure.
127
+ val encodedPath: String? = try {
128
+ YuvImageConverter.encodeJpegFromNV21(
129
+ packed,
130
+ path,
131
+ jpegQuality = quality,
132
+ displayRotation = 0,
133
+ )
134
+ } catch (e: Throwable) {
135
+ return mapOf(
136
+ "ok" to false,
137
+ "error" to "encodeJpegFromNV21 threw: ${e.message}",
138
+ )
139
+ }
140
+ if (encodedPath == null) {
141
+ return mapOf(
142
+ "ok" to false,
143
+ "error" to "encodeJpegFromNV21 returned null",
144
+ )
145
+ }
146
+
147
+ return mapOf(
148
+ "ok" to true,
149
+ "path" to encodedPath,
150
+ "width" to packed.width,
151
+ "height" to packed.height,
152
+ )
153
+ }
154
+
155
+ companion object {
156
+ private const val TAG = "SaveFrameAsJpegPlugin"
157
+
158
+ /// Plugin name; MUST match iOS + the JS-side
159
+ /// `initFrameProcessorPlugin('save_frame_as_jpeg')` call.
160
+ const val PLUGIN_NAME = "save_frame_as_jpeg"
161
+ }
162
+ }
@@ -0,0 +1,103 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ package io.imagestitcher.rn
3
+
4
+ import android.util.Log
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
7
+ import com.facebook.react.bridge.ReactMethod
8
+
9
+ /**
10
+ * v0.8.0 Phase 4b.ii — Android-side JSI installer for the host
11
+ * worklet proxy. Mirror of iOS' `StitcherJsiInstaller`.
12
+ *
13
+ * The module exposes one synchronous method, `install()`, which JS
14
+ * calls once at lib bootstrap (via the
15
+ * `ensureStitcherProxyInstalled` helper in
16
+ * `src/stitching/ensureStitcherProxyInstalled.ts`). We reach into
17
+ * the main JS runtime via `ReactApplicationContext.getJavaScriptContextHolder().get()`
18
+ * — the canonical bridgeless-compatible accessor in modern RN
19
+ * (worklets-core's `WorkletsModule` uses the same pattern, verified
20
+ * working on RN 0.84.1 + new arch + Hermes).
21
+ *
22
+ * The native `nativeInstall(jsiRuntimeRef)` JNI then casts the long
23
+ * back to a `jsi::Runtime*` and calls into the shared C++
24
+ * `retailens::installStitcherProxy(runtime)` (in
25
+ * `cpp/stitcher_proxy_jsi.{hpp,cpp}`). Identical destination on
26
+ * both platforms — `globalThis.__stitcherProxy` exposes the same
27
+ * `install` / `uninstall` / `count` host functions.
28
+ *
29
+ * ## Returning `Boolean` (not `Promise`) from a sync method
30
+ *
31
+ * `isBlockingSynchronousMethod = true` + `Boolean` return is the
32
+ * documented pattern for "I'm doing one-shot native setup that
33
+ * needs to complete before the next JS line runs." Same shape as
34
+ * `WorkletsModule.install()`.
35
+ *
36
+ * ## What we DON'T do here (Phase 4b.ii follow-up)
37
+ *
38
+ * Phase 4b.ii's MVP installs the proxy ONLY. Host worklets that
39
+ * register through `__stitcherProxy.install` land in the native
40
+ * `retailens::StitcherWorkletRegistry`. Per-frame fan-out from
41
+ * Android's `StitcherWorkletRuntime` is a separate piece of work
42
+ * (Phase 4b.ii follow-up) — needs the Kotlin↔JNI bridge that
43
+ * constructs a `StitcherFrameJsiHostObject` from an `ArImage` +
44
+ * pose and posts it through a worklet runtime. Until that lands,
45
+ * Android-registered worklets behave exactly like iOS-registered
46
+ * worklets BEFORE Phase 4b.i: they exist in the registry but
47
+ * aren't invoked.
48
+ *
49
+ * The proxy install itself is still useful as a foundation —
50
+ * verifies the JNI handshake works, exercises the bridgeless
51
+ * runtime accessor, and gives us a `count()` smoke test for the
52
+ * device verification step.
53
+ */
54
+ class StitcherJsiInstallerModule(
55
+ private val reactContext: ReactApplicationContext,
56
+ ) : ReactContextBaseJavaModule(reactContext) {
57
+ override fun getName(): String = NAME
58
+
59
+ @ReactMethod(isBlockingSynchronousMethod = true)
60
+ fun install(): Boolean {
61
+ return try {
62
+ // `getJavaScriptContextHolder().get()` returns a raw
63
+ // `jsi::Runtime*` boxed as `Long`. Same accessor
64
+ // worklets-core's `WorkletsModule.install()` uses;
65
+ // documented to work in both legacy + bridgeless modes
66
+ // on RN 0.71+.
67
+ val holder = reactContext.javaScriptContextHolder
68
+ if (holder == null) {
69
+ Log.e(TAG, "getJavaScriptContextHolder() returned null; runtime unreachable")
70
+ return false
71
+ }
72
+ val runtimeRef = holder.get()
73
+ if (runtimeRef == 0L) {
74
+ Log.e(TAG, "JavaScriptContextHolder.get() returned 0; runtime not initialized yet")
75
+ return false
76
+ }
77
+ val ok = nativeInstall(runtimeRef)
78
+ if (!ok) {
79
+ Log.e(TAG, "nativeInstall(runtimeRef=$runtimeRef) returned false")
80
+ }
81
+ ok
82
+ } catch (t: Throwable) {
83
+ Log.e(TAG, "install() threw — falling back to JS-side registry", t)
84
+ false
85
+ }
86
+ }
87
+
88
+ private external fun nativeInstall(jsiRuntimeRef: Long): Boolean
89
+
90
+ companion object {
91
+ const val NAME = "StitcherJsiInstaller"
92
+ private const val TAG = "StitcherJsiInstaller"
93
+
94
+ init {
95
+ // The Phase 3a JNI shim (`libimage_stitcher.so`) absorbed
96
+ // the JSI-install JNI binding from Phase 4b.ii. Loading
97
+ // it once is enough — Android's loader deduplicates,
98
+ // so even if `IncrementalStitcher.kt`'s init block
99
+ // already loaded the lib, calling again is a cheap no-op.
100
+ System.loadLibrary("image_stitcher")
101
+ }
102
+ }
103
+ }