react-native-image-stitcher 0.7.0 → 0.8.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 (47) hide show
  1. package/CHANGELOG.md +180 -1
  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 +4 -0
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +78 -3
  8. package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +103 -0
  9. package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +256 -0
  10. package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +100 -0
  11. package/cpp/stitcher_frame_data.hpp +141 -0
  12. package/cpp/stitcher_frame_jsi.cpp +214 -0
  13. package/cpp/stitcher_frame_jsi.hpp +108 -0
  14. package/cpp/stitcher_proxy_jsi.cpp +109 -0
  15. package/cpp/stitcher_proxy_jsi.hpp +46 -0
  16. package/cpp/stitcher_worklet_dispatch.cpp +103 -0
  17. package/cpp/stitcher_worklet_dispatch.hpp +71 -0
  18. package/cpp/stitcher_worklet_registry.cpp +81 -0
  19. package/cpp/stitcher_worklet_registry.hpp +136 -0
  20. package/dist/camera/Camera.d.ts +62 -12
  21. package/dist/camera/Camera.js +30 -15
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.js +11 -1
  24. package/dist/stitching/StitcherFrame.d.ts +170 -0
  25. package/dist/stitching/StitcherFrame.js +4 -0
  26. package/dist/stitching/StitcherWorkletRegistry.d.ts +117 -0
  27. package/dist/stitching/StitcherWorkletRegistry.js +78 -0
  28. package/dist/stitching/ensureStitcherProxyInstalled.d.ts +8 -0
  29. package/dist/stitching/ensureStitcherProxyInstalled.js +81 -0
  30. package/dist/stitching/useFrameProcessor.d.ts +119 -0
  31. package/dist/stitching/useFrameProcessor.js +196 -0
  32. package/ios/Sources/RNImageStitcher/RNSARSession.swift +46 -10
  33. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +128 -0
  34. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +313 -0
  35. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.h +60 -0
  36. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.mm +214 -0
  37. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.h +42 -0
  38. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +103 -0
  39. package/package.json +1 -1
  40. package/src/camera/Camera.tsx +93 -28
  41. package/src/index.ts +16 -0
  42. package/src/stitching/StitcherFrame.ts +197 -0
  43. package/src/stitching/StitcherWorkletRegistry.ts +156 -0
  44. package/src/stitching/__tests__/StitcherWorkletRegistry.test.ts +176 -0
  45. package/src/stitching/__tests__/ensureStitcherProxyInstalled.test.ts +94 -0
  46. package/src/stitching/ensureStitcherProxyInstalled.ts +141 -0
  47. package/src/stitching/useFrameProcessor.ts +226 -0
package/CHANGELOG.md CHANGED
@@ -16,6 +16,184 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.8.0] — 2026-05-27
20
+
21
+ ### Added — `useFrameProcessor` hook for host worklets
22
+
23
+ Hosts can now attach a `'worklet'`-prefixed function that fires on
24
+ every AR (and non-AR) capture frame, alongside the lib's own
25
+ first-party stitching. Use case: real-time OCR, packet detection,
26
+ ML inference, custom telemetry — anything that wants per-frame
27
+ pixel access in a worklet runtime.
28
+
29
+ ```tsx
30
+ import { useFrameProcessor, type StitcherFrame }
31
+ from 'react-native-image-stitcher';
32
+
33
+ const fp = useFrameProcessor((frame: StitcherFrame) => {
34
+ 'worklet';
35
+ // frame.toArrayBuffer(), frame.pose, frame.source ('ar' | 'vc'), …
36
+ }, []);
37
+ ```
38
+
39
+ **AR mode** (iPhone via ARKit, Android via ARCore): worklets fire
40
+ on every AR frame at the device's native rate (~30 Hz on A35,
41
+ ~60 Hz on iPhone 16 Pro). Auto-registered into a process-scope
42
+ native registry via `globalThis.__stitcherProxy.install(workletFn)`.
43
+ The AR-session dispatch path fans out to both the lib's first-party
44
+ stitching AND every registered host worklet, with **per-worklet
45
+ failure isolation** (one host worklet throwing does NOT break
46
+ others or the lib's stitching).
47
+
48
+ **Non-AR mode** (vision-camera): pass the hook's return through
49
+ `<Camera frameProcessor={fp}>` to enable. Honest tradeoff: vc's
50
+ `<Camera>` accepts ONE processor, so supplying a host processor
51
+ displaces the lib's first-party stitching in non-AR mode. Hosts
52
+ that want both running concurrently should use AR mode (which
53
+ natively composes both). Composition for non-AR is tracked as
54
+ v0.9+.
55
+
56
+ ### Added — `StitcherFrame` contract
57
+
58
+ Unified frame shape across AR and non-AR modes (`src/stitching/
59
+ StitcherFrame.ts`):
60
+
61
+ - `width` / `height` / `pixelFormat` / `orientation` / `timestamp`
62
+ / `toArrayBuffer()` — vc-shape parity
63
+ - `pose: { rotation: [x,y,z,w], translation?: [x,y,z] }` — always
64
+ present in AR mode; rotation-only in non-AR
65
+ - `source: 'ar' | 'vc'` discriminator for safe AR-field access
66
+ - `arDepth?`, `arAnchors?`, `arTrackingState?` — populated in AR
67
+ mode on supported devices
68
+
69
+ ### Added — JSI proxy host object
70
+
71
+ `globalThis.__stitcherProxy` installed on lib bootstrap (iOS:
72
+ `StitcherJsiInstaller` RN module via `RCTBridgeProxy.runtime` in
73
+ bridgeless mode; Android: `StitcherJsiInstallerModule` via
74
+ `ReactApplicationContext.getJavaScriptContextHolder()`). Exposes
75
+ `install` / `uninstall` / `count` host functions backed by a
76
+ shared C++ `retailens::StitcherWorkletRegistry` (process-scope,
77
+ mutex-serialised, snapshot-isolated).
78
+
79
+ ### Changed — AR-mode dispatch architecture
80
+
81
+ Internal-only refactor (strict additive BC for hosts that don't
82
+ use `useFrameProcessor`):
83
+
84
+ - **iOS**: `ARSessionDelegate.session(_:didUpdate:)` now routes
85
+ through `RNSARWorkletRuntime.dispatchFrame:pose:` instead of
86
+ directly invoking the engine. First-party callback (Phase 3c)
87
+ runs synchronously on the caller thread (preserves ARKit's
88
+ pool-reuse contract); host worklet fan-out (Phase 4b.i)
89
+ dispatches asynchronously onto a dedicated worklets-core
90
+ context.
91
+
92
+ - **Android**: `RNSARCameraView.onDrawFrame` now wraps the
93
+ existing `module.ingestFromARCameraView(...)` call in
94
+ `StitcherWorkletRuntime.runFirstParty { ... }` (Phase 3c) and
95
+ follows with `StitcherWorkletRuntime.dispatchToHostWorklets(...)`
96
+ (Phase 4b.iii). Per-frame fan-out runs every AR frame when host
97
+ worklets are registered (not just during capture).
98
+
99
+ ### Performance posture
100
+
101
+ - **First-party-only deployments** (no `useFrameProcessor`):
102
+ zero per-frame cost added. `hasHostWorklets()` atomic-read
103
+ short-circuits before any dispatch path.
104
+ - **Host worklets registered, idle preview**: Android pays
105
+ ~6-10ms per AR frame (NV21 pack + JNI byte copy + worklet
106
+ dispatch). iOS uses `CFBridgingRetain` (no per-frame copy,
107
+ but ARKit pool back-pressure on next frame). Both acceptable
108
+ for v0.8.0; future optimization → zero-copy NV21 transfer via
109
+ direct `ByteBuffer` (Android).
110
+
111
+ ### Added — SSIM parity gate harness
112
+
113
+ `scripts/ssim-compare.py` — pixel-wise SSIM comparison between
114
+ panorama JPEGs (Pillow + numpy + scikit-image; threshold 0.98).
115
+ Procedure in `docs/phase-7-parity-gate.md`.
116
+
117
+ > **v0.8.0 release note:** the formal SSIM parity gate was NOT
118
+ > run for this release. Verification rests on manual visual
119
+ > inspection of v0.8.0 panorama output on iPhone 16 Pro (Phase
120
+ > 4b.i) and Galaxy A35 (Phase 4b.iii) — both produced stitched
121
+ > panoramas matching the v0.7.x behaviour subjectively. The
122
+ > harness is in place for v0.8.1+ / future releases where the
123
+ > gate is mandatory.
124
+
125
+ ### Migration guide
126
+
127
+ No host-side changes required for the common case. Hosts that
128
+ want to attach worklets:
129
+
130
+ 1. Add `react-native-worklets-core` if not already a peer dep
131
+ (already in v0.7.x's peer-deps list).
132
+ 2. Replace `useFrameProcessor` imports from
133
+ `react-native-vision-camera` with the lib's own export:
134
+ ```diff
135
+ - import { useFrameProcessor } from 'react-native-vision-camera';
136
+ + import { useFrameProcessor } from 'react-native-image-stitcher';
137
+ ```
138
+ 3. Worklet body now receives `StitcherFrame` instead of vc's
139
+ `Frame` — see `src/stitching/StitcherFrame.ts` for the contract.
140
+
141
+ ## [0.7.1] — 2026-05-26
142
+
143
+ ### Fixed — CI binary-packaging bloat
144
+
145
+ The v0.7.0 release (and likely v0.5.1 before it — both built by
146
+ CI) shipped uncompressed binary archives that consumers downloaded
147
+ on every `npm install`. Sizes vs. the manual recipe used for
148
+ v0.6.0:
149
+
150
+ | Platform | v0.7.0 (CI, unstripped) | v0.7.1 (CI, stripped) | Saving |
151
+ |---|---|---|---|
152
+ | iOS zip | 43 MB | ~26 MB | -17 MB |
153
+ | Android zip | 165 MB | ~42 MB | -123 MB |
154
+
155
+ The lib itself is unchanged; consumers on the `^0.7.0` semver range
156
+ automatically pick up v0.7.1 and start getting the smaller download.
157
+ No source-code changes; binary-only re-release.
158
+
159
+ #### Root cause
160
+
161
+ - **iOS**: `scripts/build-opencv-ios.sh` produced an xcframework
162
+ containing both the device slice (`ios-arm64`) and the simulator
163
+ slice (`ios-arm64_x86_64-simulator`). vision-camera + ARKit
164
+ don't work on the simulator and the example app targets devices
165
+ only, so the simulator slice was dead weight in every download.
166
+ - **Android**: `scripts/build-opencv-android.sh` ran OpenCV's
167
+ `build_sdk.py` for all four NDK ABIs (per the script's own
168
+ contract — produces a multi-arch fat SDK). The lib's
169
+ `android/build.gradle` sets `ndk.abiFilters arm64-v8a` so only
170
+ arm64-v8a binaries reach any consumer APK, but the zip carried
171
+ `armeabi-v7a` / `x86` / `x86_64` libs in three sibling dirs
172
+ (`sdk/native/libs/`, `staticlibs/`, `3rdparty/libs/`) plus
173
+ `samples/` (~10 MB) and `apk/` (~5 MB) — none of it ever loaded
174
+ at runtime.
175
+
176
+ #### Fix
177
+
178
+ Both build scripts now strip the dead-weight pieces immediately
179
+ after the OpenCV build completes, before zipping for upload.
180
+ Sentinel checks fail loudly if a strip removes the required
181
+ arm64-v8a artifacts (defends against a future refactor of the
182
+ strip block). Pattern matches the manual recipe in
183
+ `feedback_binary_release_packaging.md` (project memory).
184
+
185
+ The iOS strip auto-detects the simulator entry's index in the
186
+ xcframework's `Info.plist::AvailableLibraries` via a
187
+ `plutil -convert json | python3` one-liner — the index isn't fixed
188
+ across OpenCV builds and previous manual recipes that hardcoded
189
+ `AvailableLibraries.1` would have silently stripped the wrong
190
+ slice if the order changed.
191
+
192
+ #### Compatibility
193
+
194
+ Strict additive over v0.7.0. No code changes — the lib's runtime
195
+ and public API surface are byte-identical.
196
+
19
197
  ## [0.7.0] — 2026-05-26
20
198
 
21
199
  ### Added — Tier 1: `useKeyframeStream`
@@ -1248,7 +1426,8 @@ Native module names also changed:
1248
1426
  - iOS pod: `RetaiLensCaptureSDK` → `RNImageStitcher`
1249
1427
  - iOS xcframework: shipped as `opencv2.xcframework` (linked from `RNImageStitcher.podspec`)
1250
1428
 
1251
- [Unreleased]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.7.0...HEAD
1429
+ [Unreleased]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.7.1...HEAD
1430
+ [0.7.1]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.7.0...v0.7.1
1252
1431
  [0.7.0]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.6.0...v0.7.0
1253
1432
  [0.6.0]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.5.1...v0.6.0
1254
1433
  [0.5.1]: https://github.com/bhargavkanda/react-native-image-stitcher/compare/v0.5.0...v0.5.1
@@ -88,12 +88,30 @@ android {
88
88
  externalNativeBuild {
89
89
  cmake {
90
90
  arguments "-DOPENCV_ANDROID_SDK=${file("$projectDir/vendor/OpenCV-android-sdk").absolutePath}",
91
- "-DANDROID_STL=c++_static"
91
+ // v0.8.0 Phase 3 — switched from c++_static
92
+ // to c++_shared. Required for linking
93
+ // ReactAndroid::jsi (RN's prefab uses
94
+ // shared libc++). STL probe at
95
+ // android/src/main/cpp/CMakeLists.txt:99-118
96
+ // confirms OpenCV's libopencv_stitching.a is
97
+ // already built with __ndk1 (c++_shared), so
98
+ // the static archive link continues to
99
+ // work cleanly. Pre-Phase-3 it worked only
100
+ // because the JNI shim's .so boundary used
101
+ // POD types — fragile. Now properly aligned.
102
+ "-DANDROID_STL=c++_shared"
92
103
  cppFlags "-std=c++17"
93
104
  }
94
105
  }
95
106
  }
96
107
 
108
+ // v0.8.0 Phase 3 — consume React Native's prefab packages
109
+ // (ReactAndroid::jsi + fbjni::fbjni) for the JSI host object.
110
+ // RN 0.71+ ships these as prefabs; this lib targets RN 0.84.
111
+ buildFeatures {
112
+ prefab true
113
+ }
114
+
97
115
  // ── JNI shim build path ─────────────────────────────────────────
98
116
  // Gradle compiles cpp/image_stitcher_jni.cpp into
99
117
  // libimage_stitcher.so for the ABIs filtered above. The shim
@@ -248,6 +266,22 @@ dependencies {
248
266
  // still builds for non-camera consumers.
249
267
  android.sourceSets.main.java.exclude '**/CvFlowGateFrameProcessor.kt'
250
268
  }
269
+
270
+ // v0.8.0 Phase 4b.ii — react-native-worklets-core's Android
271
+ // prefab (`rnworklets`) is consumed by the native shim
272
+ // (`stitcher_worklet_registry.cpp` constructs
273
+ // `RNWorklet::WorkletInvoker`s). `implementation` not
274
+ // `compileOnly` because we need the prefab's `.so` available at
275
+ // both link time AND runtime — without the runtime presence,
276
+ // `dlopen` would fail when our `libimage_stitcher.so` is loaded.
277
+ //
278
+ // Host apps that use this lib already declare worklets-core as
279
+ // a peer dep (see package.json's peerDependencies); RN
280
+ // autolinking + Gradle deduplicates, so the host doesn't get
281
+ // a second copy.
282
+ if (findProject(':react-native-worklets-core') != null) {
283
+ implementation project(':react-native-worklets-core')
284
+ }
251
285
  }
252
286
 
253
287
  // Helper from the React Native gradle convention to read host-app
@@ -80,6 +80,35 @@ if(NOT EXISTS "${SHARED_CPP_DIR}/keyframe_gate.hpp")
80
80
  "Expected react-native-image-stitcher/cpp/ — was the package layout broken?")
81
81
  endif()
82
82
 
83
+ # ── React Native prefab packages for JSI ──────────────────────────
84
+ #
85
+ # v0.8.0 Phase 3 — activating the previously-deferred JSI integration.
86
+ # The shared C++ host object (cpp/stitcher_frame_jsi.cpp) depends on
87
+ # `facebook::jsi`. ReactAndroid ships JSI as a prefab starting
88
+ # RN 0.71+; the lib targets RN 0.84 so this is always available.
89
+ #
90
+ # `buildFeatures { prefab true }` in android/build.gradle enables
91
+ # consumption + `ANDROID_STL=c++_shared` aligns the STL with what
92
+ # the prefabs require. The Phase-2 STL probe (`llvm-nm
93
+ # libopencv_stitching.a | grep '__ndk1'`) confirmed OpenCV's
94
+ # stitching archive was already built with c++_shared (768
95
+ # __ndk1 symbols + 0 __cxx11 / NSt3) — switching the lib's flag
96
+ # from c++_static to c++_shared just aligns + matches. The
97
+ # previous c++_static was working only because the JNI shim's
98
+ # `.so` boundary used POD/C types; the new c++_shared is properly
99
+ # matched throughout.
100
+ find_package(ReactAndroid REQUIRED CONFIG)
101
+ find_package(fbjni REQUIRED CONFIG)
102
+
103
+ # v0.8.0 Phase 4b.ii — react-native-worklets-core prefab. The
104
+ # Gradle module name is `react-native-worklets-core`; inside it
105
+ # publishes a library named `rnworklets` (matches vc's consumption
106
+ # pattern in node_modules/react-native-vision-camera/android/CMakeLists.txt).
107
+ # We consume both the headers (for `WKTJsiWorklet.h` etc.) AND
108
+ # the .so (for `RNWorklet::WorkletInvoker` + `JsiWrapper::unwrap`
109
+ # symbols, which are defined in worklets-core's WKTJsiWrapper.cpp).
110
+ find_package(react-native-worklets-core REQUIRED CONFIG)
111
+
83
112
  # ── Our shim ───────────────────────────────────────────────────────
84
113
  add_library(image_stitcher SHARED
85
114
  image_stitcher_jni.cpp
@@ -90,7 +119,30 @@ add_library(image_stitcher SHARED
90
119
  # retry + dimension/memory instrumentation. Used to live in this
91
120
  # file (image_stitcher_jni.cpp). See cpp/stitcher.hpp for design
92
121
  # rationale.
93
- "${SHARED_CPP_DIR}/stitcher.cpp")
122
+ "${SHARED_CPP_DIR}/stitcher.cpp"
123
+ # v0.8.0 Phase 3 — shared JSI host object for `StitcherFrame`.
124
+ # Compiles to identical dispatch on both platforms; iOS consumes
125
+ # it via the .mm shim at
126
+ # `ios/Sources/RNImageStitcher/StitcherFrameHostObject.mm`.
127
+ # See cpp/stitcher_frame_jsi.hpp for the class API.
128
+ "${SHARED_CPP_DIR}/stitcher_frame_jsi.cpp"
129
+ # v0.8.0 Phase 4b.ii — shared C++ registry of host-supplied
130
+ # worklets + the `globalThis.__stitcherProxy` host object that
131
+ # JS calls into. iOS picked these up via the podspec glob in
132
+ # Phase 4b.i; Android adds them here.
133
+ "${SHARED_CPP_DIR}/stitcher_worklet_registry.cpp"
134
+ "${SHARED_CPP_DIR}/stitcher_proxy_jsi.cpp"
135
+ # v0.8.0 Phase 4b.iii — shared per-frame fan-out helper. Posts
136
+ # a `StitcherFrameData` onto worklets-core's default context's
137
+ # worklet thread; iterates the host worklet registry; invalidates
138
+ # the JSI host object after dispatch completes.
139
+ "${SHARED_CPP_DIR}/stitcher_worklet_dispatch.cpp"
140
+ # v0.8.0 Phase 4b.ii — Android JNI bindings for the JSI install
141
+ # (`StitcherJsiInstallerModule.nativeInstall`). Reaches into the
142
+ # main JS runtime via the `long` JSI handle Kotlin pulls from
143
+ # `ReactApplicationContext.getJavaScriptContextHolder()`. See
144
+ # worklets-core's `WorkletsModule.java` for the canonical pattern.
145
+ stitcher_jsi_install_jni.cpp)
94
146
 
95
147
  target_include_directories(image_stitcher PRIVATE
96
148
  "${OPENCV_INCLUDE_DIR}"
@@ -117,7 +169,17 @@ target_link_libraries(image_stitcher
117
169
  opencv_stitching
118
170
  -Wl,--no-whole-archive
119
171
  opencv_java
120
- log)
172
+ log
173
+ # v0.8.0 Phase 3 — JSI for the shared C++ host object
174
+ # (cpp/stitcher_frame_jsi.cpp's `facebook::jsi::HostObject`
175
+ # subclass). fbjni for the Phase 3c JNI bridge between Kotlin
176
+ # worklet runtime + C++ host object construction.
177
+ ReactAndroid::jsi
178
+ fbjni::fbjni
179
+ # v0.8.0 Phase 4b.ii — worklets-core's `RNWorklet::WorkletInvoker`
180
+ # is constructed in the C++ registry's `install` method and
181
+ # invoked from the Android per-frame dispatch path.
182
+ react-native-worklets-core::rnworklets)
121
183
 
122
184
  target_compile_options(image_stitcher PRIVATE
123
185
  -fvisibility=hidden
@@ -0,0 +1,227 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // stitcher_jsi_install_jni.cpp — JNI binding for the Android-side
4
+ // JSI install (v0.8.0 Phase 4b.ii).
5
+ //
6
+ // Kotlin's `StitcherJsiInstallerModule.nativeInstall(jsiRuntimeRef)`
7
+ // calls into this file. We unbox the `jsi::Runtime*` from the
8
+ // Java `long` and hand it to the shared
9
+ // `retailens::installStitcherProxy(runtime)` function which sets
10
+ // `globalThis.__stitcherProxy`. Same destination as iOS — the
11
+ // host object class lives in `cpp/stitcher_proxy_jsi.{hpp,cpp}`.
12
+ //
13
+ // ## Why a `long` ref, not a JSI handle wrapper class
14
+ //
15
+ // `ReactApplicationContext.getJavaScriptContextHolder()` returns a
16
+ // `JavaScriptContextHolder` whose `.get()` returns a Java `long`
17
+ // that's the raw pointer to the C++ `jsi::Runtime*`. Same
18
+ // contract as worklets-core's `WorkletsModule.nativeInstall`
19
+ // (verified at the same call site). Caller is responsible for
20
+ // ensuring the runtime outlives this call — in practice, the
21
+ // runtime IS the JS thread's runtime which lives the whole
22
+ // process lifetime, so this is structurally always safe in our
23
+ // usage.
24
+ //
25
+ // ## Threading
26
+ //
27
+ // Kotlin invokes this from a `@ReactMethod(isBlockingSynchronousMethod
28
+ // = true)` so we're already on the JS thread. Synchronous JSI
29
+ // access is safe.
30
+
31
+ #include "stitcher_proxy_jsi.hpp"
32
+
33
+ // v0.8.0 Phase 4b.iii — per-frame fan-out support. The shared
34
+ // `dispatchToHostWorklets` posts to worklets-core's default context;
35
+ // this JNI file's `nativeDispatchToHostWorklets` constructs the
36
+ // `StitcherFrameData` from raw bytes + pose + dims and forwards it.
37
+ #include "stitcher_frame_data.hpp"
38
+ #include "stitcher_worklet_dispatch.hpp"
39
+ #include "stitcher_worklet_registry.hpp"
40
+
41
+ #include <react-native-worklets-core/WKTJsiWorkletContext.h>
42
+
43
+ #include <jni.h>
44
+ #include <jsi/jsi.h>
45
+
46
+ #include <android/log.h>
47
+
48
+ #include <cstdint>
49
+ #include <cstring>
50
+ #include <memory>
51
+ #include <utility>
52
+ #include <vector>
53
+
54
+ #define LOG_TAG "StitcherJsiInstaller"
55
+ #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
56
+ #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
57
+
58
+ extern "C" JNIEXPORT jboolean JNICALL
59
+ Java_io_imagestitcher_rn_StitcherJsiInstallerModule_nativeInstall(
60
+ JNIEnv* /*env*/, jobject /*thiz*/, jlong jsiRuntimeRef) {
61
+ if (jsiRuntimeRef == 0) {
62
+ // ReactApplicationContext.getJavaScriptContextHolder().get()
63
+ // returns 0 when the runtime isn't ready (rare — JS would have
64
+ // had to call us before its own runtime was up; impossible in
65
+ // practice). Defensive.
66
+ return JNI_FALSE;
67
+ }
68
+ auto* runtime = reinterpret_cast<facebook::jsi::Runtime*>(jsiRuntimeRef);
69
+ retailens::installStitcherProxy(*runtime);
70
+ LOGI("installed globalThis.__stitcherProxy on main JS runtime.");
71
+ return JNI_TRUE;
72
+ }
73
+
74
+ // ─── v0.8.0 Phase 4b.iii — Android NV21 PixelBufferReader ──────────
75
+ //
76
+ // Owns a heap-allocated `std::vector<uint8_t>` of pre-copied NV21
77
+ // bytes. Constructed by `nativeDispatchToHostWorklets` after one
78
+ // JNI byte-array copy from Kotlin; outlives the AR render thread
79
+ // scope via `StitcherFrameData::pixelReader`'s `shared_ptr` —
80
+ // dropped when the host object is invalidated.
81
+
82
+ namespace {
83
+
84
+ class AndroidNV21BufferReader : public retailens::PixelBufferReader {
85
+ public:
86
+ explicit AndroidNV21BufferReader(std::vector<uint8_t>&& bytes)
87
+ : _bytes(std::move(bytes)) {}
88
+
89
+ std::size_t byteSize() const override { return _bytes.size(); }
90
+
91
+ std::size_t copyTo(uint8_t* dst, std::size_t maxBytes) override {
92
+ if (dst == nullptr) return 0;
93
+ std::size_t n = std::min(maxBytes, _bytes.size());
94
+ if (n > 0) {
95
+ std::memcpy(dst, _bytes.data(), n);
96
+ }
97
+ return n;
98
+ }
99
+
100
+ private:
101
+ std::vector<uint8_t> _bytes;
102
+ };
103
+
104
+ } // namespace
105
+
106
+ // ─── v0.8.0 Phase 4b.iii — registry count accessor ─────────────────
107
+ //
108
+ // Cheap (microsecond) accessor for the per-frame gate in
109
+ // `RNSARCameraView.onDrawFrame`. Avoids the NV21 byte-pack cost
110
+ // when no host worklets are registered AND no capture is active.
111
+ // Same atomic-read the JSI host object's `count()` host function
112
+ // goes through.
113
+ extern "C" JNIEXPORT jint JNICALL
114
+ Java_io_imagestitcher_rn_StitcherWorkletRuntime_nativeRegistryCount(
115
+ JNIEnv* /*env*/, jobject /*thiz*/) {
116
+ return static_cast<jint>(
117
+ retailens::StitcherWorkletRegistry::shared().count());
118
+ }
119
+
120
+ // ─── v0.8.0 Phase 4b.iii — per-frame dispatch JNI binding ──────────
121
+ //
122
+ // Called from Kotlin's `StitcherWorkletRuntime.dispatchToHostWorklets`
123
+ // after the first-party stitching block has returned (the AR-frame
124
+ // data is still in scope on the Kotlin side because
125
+ // `RNSARCameraView.onDrawFrame` reads the ARCore Frame, builds the
126
+ // NV21 byte[], invokes first-party via `runFirstParty { ... }`,
127
+ // THEN calls into here).
128
+ //
129
+ // The byte[] is COPIED into our owned vector — ARCore's pixel data
130
+ // becomes inaccessible shortly after `onDrawFrame` returns, and our
131
+ // async dispatch must outlive that scope. Cost: one ~3MB memcpy
132
+ // per frame at 1080p NV21 (~90 MB/s at 30 fps; <5 ms on a mid-range
133
+ // Android device). Fast-path early-exit when the registry is empty
134
+ // skips the copy entirely.
135
+ //
136
+ // trackingState: Kotlin passes one of "" / "notAvailable" / "limited"
137
+ // / "normal" (empty string = field unset → JS sees undefined).
138
+ extern "C" JNIEXPORT void JNICALL
139
+ Java_io_imagestitcher_rn_StitcherWorkletRuntime_nativeDispatchToHostWorklets(
140
+ JNIEnv* env, jobject /*thiz*/,
141
+ jbyteArray nv21Bytes,
142
+ jint width, jint height,
143
+ jdouble qx, jdouble qy, jdouble qz, jdouble qw,
144
+ jdouble tx, jdouble ty, jdouble tz,
145
+ jdouble timestampNs,
146
+ jstring trackingState) {
147
+ // Fast-path early-exit BEFORE the JNI byte-array copy. Saves the
148
+ // ~3MB memcpy + JSI host object alloc on every frame in the
149
+ // common first-party-only case.
150
+ if (retailens::StitcherWorkletRegistry::shared().count() == 0) {
151
+ return;
152
+ }
153
+
154
+ if (nv21Bytes == nullptr) {
155
+ LOGE("nativeDispatchToHostWorklets: nv21Bytes is null");
156
+ return;
157
+ }
158
+
159
+ const jsize byteLen = env->GetArrayLength(nv21Bytes);
160
+ if (byteLen <= 0) {
161
+ LOGE("nativeDispatchToHostWorklets: nv21Bytes is empty");
162
+ return;
163
+ }
164
+
165
+ // Copy into our owned vector. `GetByteArrayRegion` is the
166
+ // canonical "copy" path — `GetByteArrayElements + Release` MAY
167
+ // pin the JVM array (zero-copy) but the contract isn't
168
+ // guaranteed; we need our own buffer for the async dispatch
169
+ // anyway, so the explicit copy is cleaner.
170
+ std::vector<uint8_t> bytes(static_cast<std::size_t>(byteLen));
171
+ env->GetByteArrayRegion(
172
+ nv21Bytes, 0, byteLen,
173
+ reinterpret_cast<jbyte*>(bytes.data()));
174
+
175
+ // Extract trackingState string (may be null on the Kotlin side
176
+ // for non-AR or pre-tracking frames — guard accordingly).
177
+ std::string trackingStateStr;
178
+ if (trackingState != nullptr) {
179
+ const char* cs = env->GetStringUTFChars(trackingState, nullptr);
180
+ if (cs != nullptr) {
181
+ trackingStateStr = cs;
182
+ env->ReleaseStringUTFChars(trackingState, cs);
183
+ }
184
+ }
185
+
186
+ // Build StitcherFrameData. Field semantics match the iOS
187
+ // `StitcherFrameHostObject::fromARFrame:pose:` factory; this is
188
+ // the Android equivalent path.
189
+ retailens::StitcherFrameData data;
190
+ data.source = "ar";
191
+ data.width = static_cast<int32_t>(width);
192
+ data.height = static_cast<int32_t>(height);
193
+ // ARCore's camera image is YUV_420_888 on Android, mapped to NV21
194
+ // by the existing `YuvImageConverter.packNV21` path — the byte[]
195
+ // we receive is interleaved Y then VU. Worklets gate on this
196
+ // string identifier (`'yuv'` vs `'unknown'`); v0.8.0 always
197
+ // emits `'yuv'` for AR mode on Android (NV21).
198
+ data.pixelFormat = "yuv";
199
+ // Android AR-mode camera image is always landscape-natural; the
200
+ // mapping matches iOS' coarse two-value set. Hosts that need
201
+ // exact display orientation read it from the device-orientation
202
+ // sensors (see `useDeviceOrientation` hook).
203
+ data.orientation = (width >= height) ? "landscape-right" : "portrait";
204
+ data.timestampNs = timestampNs;
205
+ data.qx = qx;
206
+ data.qy = qy;
207
+ data.qz = qz;
208
+ data.qw = qw;
209
+ data.tx = tx;
210
+ data.ty = ty;
211
+ data.tz = tz;
212
+ data.hasTranslation = true; // AR mode always has translation
213
+ data.arTrackingState = trackingStateStr;
214
+ data.pixelReader =
215
+ std::make_shared<AndroidNV21BufferReader>(std::move(bytes));
216
+
217
+ // Dispatch on worklets-core's default context. That context is
218
+ // initialised by JS' `Worklets.install()` (which runs at lib
219
+ // bootstrap when worklets-core's module is imported); by the
220
+ // time host worklets are registered, the default context is up.
221
+ // The shared dispatch helper handles the registry snapshot,
222
+ // host-object construction (inside the worklet thread), per-
223
+ // worklet failure isolation, and invalidation.
224
+ retailens::dispatchToHostWorklets(
225
+ RNWorklet::JsiWorkletContext::getDefaultInstance(),
226
+ std::move(data));
227
+ }
@@ -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 ->
@@ -87,6 +87,10 @@ class RNImageStitcherPackage : ReactPackage {
87
87
  RNSARSession(reactContext),
88
88
  IncrementalStitcher(reactContext),
89
89
  FileBridge(reactContext),
90
+ // v0.8.0 Phase 4b.ii — Android JSI installer for the
91
+ // host-worklet `__stitcherProxy` global. Mirror of
92
+ // iOS' `StitcherJsiInstaller`.
93
+ StitcherJsiInstallerModule(reactContext),
90
94
  )
91
95
  }
92
96